Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e6280961b | ||
|
|
e3d4a330df | ||
|
|
7bbb855e8e | ||
|
|
52cb709a88 | ||
|
|
68a95c642a | ||
|
|
2ae7aca185 | ||
|
|
8afca89520 | ||
|
|
7feb05cca7 | ||
|
|
9ac4e778f8 | ||
|
|
ed825d8b37 | ||
|
|
b48370e3d0 | ||
|
|
8c0c555640 | ||
|
|
24be97bae7 | ||
|
|
c820b416f9 | ||
|
|
f42c478725 | ||
|
|
64c2a43006 | ||
|
|
d8da26a41a | ||
|
|
f0699cc8b0 | ||
|
|
3ad3e22a16 | ||
|
|
2381e48a46 | ||
|
|
3dd7b85ebb | ||
|
|
be4e2d0c94 | ||
|
|
c9b6074626 | ||
|
|
37c1ea5984 |
@@ -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
|
||||
)"
|
||||
@@ -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 }}
|
||||
@@ -112,18 +112,24 @@ jobs:
|
||||
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 "${BASE}/${ARM_NAME}"
|
||||
end
|
||||
|
||||
url "https://github.com/${REPO}/releases/download/v#{version}/${ARM_NAME}"
|
||||
end
|
||||
on_intel do
|
||||
sha256 "${X64_SHA}"
|
||||
url "${BASE}/${X64_NAME}"
|
||||
|
||||
url "https://github.com/${REPO}/releases/download/v#{version}/${X64_NAME}"
|
||||
end
|
||||
|
||||
name "Openscreen"
|
||||
@@ -137,9 +143,9 @@ jobs:
|
||||
|
||||
zap trash: [
|
||||
"~/Library/Application Support/Openscreen",
|
||||
"~/Library/Preferences/com.siddharthvaddem.openscreen.plist",
|
||||
"~/Library/Logs/Openscreen",
|
||||
"~/Library/Caches/com.siddharthvaddem.openscreen",
|
||||
"~/Library/Logs/Openscreen",
|
||||
"~/Library/Preferences/com.siddharthvaddem.openscreen.plist",
|
||||
"~/Library/Saved Application State/com.siddharthvaddem.openscreen.savedState",
|
||||
]
|
||||
end
|
||||
|
||||
@@ -11,11 +11,15 @@ node_modules
|
||||
dist
|
||||
dist-electron
|
||||
dist-ssr
|
||||
*.local
|
||||
.env
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
*.local
|
||||
.env
|
||||
|
||||
# Native helper build outputs
|
||||
/electron/native/wgc-capture/build/
|
||||
/electron/native/bin/
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
.zed/
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
|
||||
@@ -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 you’d like to help out or see what’s 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
|
||||
|
||||
|
||||
@@ -52,8 +52,7 @@
|
||||
"NSMicrophoneUsageDescription": "OpenScreen needs microphone access to record voice audio.",
|
||||
"NSCameraUsageDescription": "OpenScreen needs camera access to record webcam video.",
|
||||
"NSScreenCaptureUsageDescription": "OpenScreen needs screen recording permission to detect and capture windows.",
|
||||
"NSCameraUseContinuityCameraDeviceType": true,
|
||||
"com.apple.security.device.audio-input": true
|
||||
"NSCameraUseContinuityCameraDeviceType": true
|
||||
}
|
||||
},
|
||||
"linux": {
|
||||
@@ -66,12 +65,19 @@
|
||||
"artifactName": "${productName}-Linux-${version}.${ext}",
|
||||
"category": "AudioVideo"
|
||||
},
|
||||
"win": {
|
||||
"target": [
|
||||
"nsis"
|
||||
],
|
||||
"icon": "icons/icons/win/icon.ico"
|
||||
},
|
||||
"win": {
|
||||
"target": [
|
||||
"nsis"
|
||||
],
|
||||
"icon": "icons/icons/win/icon.ico",
|
||||
"extraResources": [
|
||||
{
|
||||
"from": "electron/native/bin",
|
||||
"to": "electron/native/bin",
|
||||
"filter": ["**/*"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"nsis": {
|
||||
"oneClick": false,
|
||||
"allowToChangeInstallationDirectory": true
|
||||
|
||||
@@ -69,6 +69,24 @@ interface Window {
|
||||
error?: string;
|
||||
}>;
|
||||
setRecordingState: (recording: boolean, recordingId?: number) => 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;
|
||||
@@ -77,6 +95,12 @@ interface Window {
|
||||
message?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
getCursorRecordingData: (videoPath?: string) => Promise<{
|
||||
success: boolean;
|
||||
data: import("../src/native/contracts").CursorRecordingData | null;
|
||||
message?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
onStopRecordingFromTray: (callback: () => void) => () => void;
|
||||
openExternalUrl: (url: string) => Promise<{ success: boolean; error?: string }>;
|
||||
pickExportSavePath: (
|
||||
@@ -157,6 +181,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>;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
app,
|
||||
BrowserWindow,
|
||||
desktopCapturer,
|
||||
ipcMain,
|
||||
Menu,
|
||||
nativeImage,
|
||||
@@ -12,7 +13,7 @@ import {
|
||||
Tray,
|
||||
} from "electron";
|
||||
import { mainT, setMainLocale } from "./i18n";
|
||||
import { registerIpcHandlers } from "./ipc/handlers";
|
||||
import { getSelectedDesktopSource, registerIpcHandlers } from "./ipc/handlers";
|
||||
import {
|
||||
createCountdownOverlayWindow,
|
||||
createEditorWindow,
|
||||
@@ -449,23 +450,68 @@ app.whenReady().then(async () => {
|
||||
app.dock?.show();
|
||||
}
|
||||
|
||||
// Allow microphone/media permission checks
|
||||
// Windows can use the pre-selected source without showing Electron's picker.
|
||||
// Registering this only on Windows preserves the default macOS/Linux capture
|
||||
// behavior, including platform audio handling.
|
||||
if (process.platform === "win32") {
|
||||
session.defaultSession.setDisplayMediaRequestHandler((request, callback) => {
|
||||
const source = getSelectedDesktopSource();
|
||||
if (!source) {
|
||||
callback({});
|
||||
return;
|
||||
}
|
||||
const audioRequested = Boolean((request as { audioRequested?: boolean }).audioRequested);
|
||||
callback({
|
||||
video: source,
|
||||
...(audioRequested ? { audio: "loopback" as const } : {}),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 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(() => {
|
||||
// The permission prompt is best-effort at startup.
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Listen for HUD overlay quit event (macOS only)
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { CursorRecordingData } from "../../../../src/native/contracts";
|
||||
|
||||
export interface CursorRecordingSession {
|
||||
start(): Promise<void>;
|
||||
stop(): Promise<CursorRecordingData>;
|
||||
}
|
||||
@@ -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,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,409 @@
|
||||
#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;
|
||||
}
|
||||
|
||||
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,84 @@
|
||||
#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);
|
||||
void copyAudioWithGain(
|
||||
const BYTE* source,
|
||||
DWORD byteCount,
|
||||
const AudioInputFormat& format,
|
||||
double gain,
|
||||
std::vector<BYTE>& destination);
|
||||
void convertAudioWithGain(
|
||||
const BYTE* source,
|
||||
DWORD byteCount,
|
||||
const AudioInputFormat& sourceFormat,
|
||||
const AudioInputFormat& targetFormat,
|
||||
double gain,
|
||||
std::vector<BYTE>& destination);
|
||||
void mixAudioInPlace(
|
||||
std::vector<BYTE>& destination,
|
||||
const BYTE* source,
|
||||
DWORD byteCount,
|
||||
const AudioInputFormat& format);
|
||||
|
||||
class AudioMixer {
|
||||
public:
|
||||
using OutputCallback = std::function<bool(const BYTE* data, DWORD byteCount, int64_t timestampHns, int64_t durationHns)>;
|
||||
|
||||
AudioMixer(
|
||||
const AudioInputFormat& format,
|
||||
const AudioInputFormat& systemFormat,
|
||||
const AudioInputFormat& microphoneFormat,
|
||||
bool includeSystem,
|
||||
bool includeMicrophone,
|
||||
double microphoneGain,
|
||||
OutputCallback output);
|
||||
~AudioMixer();
|
||||
|
||||
AudioMixer(const AudioMixer&) = delete;
|
||||
AudioMixer& operator=(const AudioMixer&) = delete;
|
||||
|
||||
bool start();
|
||||
void beginTimeline();
|
||||
void stop();
|
||||
void pushSystem(const BYTE* data, DWORD byteCount);
|
||||
void pushMicrophone(const BYTE* data, DWORD byteCount);
|
||||
|
||||
private:
|
||||
void append(
|
||||
std::vector<BYTE>& queue,
|
||||
const BYTE* data,
|
||||
DWORD byteCount,
|
||||
const AudioInputFormat& sourceFormat,
|
||||
double gain);
|
||||
bool pop(std::vector<BYTE>& queue, std::vector<BYTE>& chunk, size_t byteCount);
|
||||
void mixLoop();
|
||||
|
||||
AudioInputFormat format_{};
|
||||
AudioInputFormat systemFormat_{};
|
||||
AudioInputFormat microphoneFormat_{};
|
||||
bool includeSystem_ = false;
|
||||
bool includeMicrophone_ = false;
|
||||
double microphoneGain_ = 1.0;
|
||||
OutputCallback output_;
|
||||
std::mutex mutex_;
|
||||
std::condition_variable cv_;
|
||||
std::vector<BYTE> systemQueue_;
|
||||
std::vector<BYTE> microphoneQueue_;
|
||||
std::vector<BYTE> gainBuffer_;
|
||||
std::thread thread_;
|
||||
std::atomic<bool> stopRequested_ = false;
|
||||
bool timelineStarted_ = false;
|
||||
uint64_t emittedFrames_ = 0;
|
||||
};
|
||||
@@ -0,0 +1,312 @@
|
||||
#include "dshow_webcam_capture.h"
|
||||
|
||||
#include <initguid.h>
|
||||
#include <dshow.h>
|
||||
#include <wrl/client.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <exception>
|
||||
#include <iostream>
|
||||
|
||||
namespace {
|
||||
|
||||
const CLSID CLSID_SampleGrabberLocal = {0xC1F400A0, 0x3F08, 0x11D3, {0x9F, 0x0B, 0x00, 0x60, 0x08, 0x03, 0x9E, 0x37}};
|
||||
const CLSID CLSID_NullRendererLocal = {0xC1F400A4, 0x3F08, 0x11D3, {0x9F, 0x0B, 0x00, 0x60, 0x08, 0x03, 0x9E, 0x37}};
|
||||
|
||||
MIDL_INTERFACE("0579154A-2B53-4994-B0D0-E773148EFF85")
|
||||
ISampleGrabberCB : public IUnknown {
|
||||
public:
|
||||
virtual HRESULT STDMETHODCALLTYPE SampleCB(double sampleTime, IMediaSample* sample) = 0;
|
||||
virtual HRESULT STDMETHODCALLTYPE BufferCB(double sampleTime, BYTE* buffer, long bufferLength) = 0;
|
||||
};
|
||||
|
||||
MIDL_INTERFACE("6B652FFF-11FE-4FCE-92AD-0266B5D7C78F")
|
||||
ISampleGrabber : public IUnknown {
|
||||
public:
|
||||
virtual HRESULT STDMETHODCALLTYPE SetOneShot(BOOL oneShot) = 0;
|
||||
virtual HRESULT STDMETHODCALLTYPE SetMediaType(const AM_MEDIA_TYPE* type) = 0;
|
||||
virtual HRESULT STDMETHODCALLTYPE GetConnectedMediaType(AM_MEDIA_TYPE* type) = 0;
|
||||
virtual HRESULT STDMETHODCALLTYPE SetBufferSamples(BOOL bufferThem) = 0;
|
||||
virtual HRESULT STDMETHODCALLTYPE GetCurrentBuffer(long* bufferSize, long* buffer) = 0;
|
||||
virtual HRESULT STDMETHODCALLTYPE GetCurrentSample(IMediaSample** sample) = 0;
|
||||
virtual HRESULT STDMETHODCALLTYPE SetCallback(ISampleGrabberCB* callback, long whichMethodToCallback) = 0;
|
||||
};
|
||||
|
||||
bool succeeded(HRESULT hr, const char* label) {
|
||||
if (SUCCEEDED(hr)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
std::cerr << "ERROR: " << label << " failed (hr=0x" << std::hex << hr << std::dec << ")"
|
||||
<< std::endl;
|
||||
return false;
|
||||
}
|
||||
|
||||
void freeMediaType(AM_MEDIA_TYPE& type) {
|
||||
if (type.cbFormat != 0) {
|
||||
CoTaskMemFree(type.pbFormat);
|
||||
type.cbFormat = 0;
|
||||
type.pbFormat = nullptr;
|
||||
}
|
||||
if (type.pUnk) {
|
||||
type.pUnk->Release();
|
||||
type.pUnk = nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
struct DirectShowWebcamCapture::Impl {
|
||||
Microsoft::WRL::ComPtr<IGraphBuilder> graph;
|
||||
Microsoft::WRL::ComPtr<ICaptureGraphBuilder2> captureGraph;
|
||||
Microsoft::WRL::ComPtr<IBaseFilter> captureFilter;
|
||||
Microsoft::WRL::ComPtr<IBaseFilter> sampleGrabberFilter;
|
||||
Microsoft::WRL::ComPtr<ISampleGrabber> sampleGrabber;
|
||||
Microsoft::WRL::ComPtr<IBaseFilter> nullRenderer;
|
||||
Microsoft::WRL::ComPtr<IMediaControl> mediaControl;
|
||||
bool comInitialized = false;
|
||||
bool running = false;
|
||||
};
|
||||
|
||||
DirectShowWebcamCapture::~DirectShowWebcamCapture() {
|
||||
stop();
|
||||
delete impl_;
|
||||
}
|
||||
|
||||
bool DirectShowWebcamCapture::initialize(
|
||||
const std::wstring& deviceId,
|
||||
const std::wstring& deviceName,
|
||||
const std::wstring& directShowClsid,
|
||||
int requestedWidth,
|
||||
int requestedHeight,
|
||||
int requestedFps) {
|
||||
(void)deviceId;
|
||||
stop();
|
||||
delete impl_;
|
||||
impl_ = nullptr;
|
||||
impl_ = new Impl();
|
||||
fps_ = std::clamp(requestedFps > 0 ? requestedFps : 30, 1, 60);
|
||||
|
||||
HRESULT hr = CoInitializeEx(nullptr, COINIT_MULTITHREADED);
|
||||
if (SUCCEEDED(hr)) {
|
||||
impl_->comInitialized = true;
|
||||
} else if (hr != RPC_E_CHANGED_MODE) {
|
||||
return succeeded(hr, "CoInitializeEx(DirectShow webcam)");
|
||||
}
|
||||
|
||||
if (directShowClsid.empty()) {
|
||||
std::cerr << "ERROR: DirectShow webcam fallback requires a resolved filter CLSID" << std::endl;
|
||||
return false;
|
||||
}
|
||||
|
||||
CLSID selectedClsid{};
|
||||
if (FAILED(CLSIDFromString(directShowClsid.c_str(), &selectedClsid))) {
|
||||
std::cerr << "ERROR: DirectShow webcam fallback received an invalid filter CLSID" << std::endl;
|
||||
return false;
|
||||
}
|
||||
selectedDeviceName_ = deviceName.empty() ? directShowClsid : deviceName;
|
||||
|
||||
if (!succeeded(CoCreateInstance(selectedClsid, nullptr, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(&impl_->captureFilter)),
|
||||
"CoCreateInstance(DirectShow webcam filter)")) {
|
||||
return false;
|
||||
}
|
||||
if (!succeeded(CoCreateInstance(CLSID_FilterGraph, nullptr, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(&impl_->graph)),
|
||||
"CoCreateInstance(FilterGraph)")) {
|
||||
return false;
|
||||
}
|
||||
if (!succeeded(CoCreateInstance(CLSID_CaptureGraphBuilder2, nullptr, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(&impl_->captureGraph)),
|
||||
"CoCreateInstance(CaptureGraphBuilder2)")) {
|
||||
return false;
|
||||
}
|
||||
if (!succeeded(impl_->captureGraph->SetFiltergraph(impl_->graph.Get()), "SetFiltergraph(DirectShow webcam)")) {
|
||||
return false;
|
||||
}
|
||||
if (!succeeded(impl_->graph->AddFilter(impl_->captureFilter.Get(), L"OpenScreen Webcam Source"),
|
||||
"AddFilter(DirectShow webcam source)")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!succeeded(CoCreateInstance(CLSID_SampleGrabberLocal, nullptr, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(&impl_->sampleGrabberFilter)),
|
||||
"CoCreateInstance(SampleGrabber)")) {
|
||||
return false;
|
||||
}
|
||||
if (!succeeded(impl_->sampleGrabberFilter.As(&impl_->sampleGrabber), "QueryInterface(ISampleGrabber)")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
AM_MEDIA_TYPE requestedType{};
|
||||
requestedType.majortype = MEDIATYPE_Video;
|
||||
requestedType.subtype = MEDIASUBTYPE_RGB32;
|
||||
requestedType.formattype = FORMAT_VideoInfo;
|
||||
if (!succeeded(impl_->sampleGrabber->SetMediaType(&requestedType), "SetMediaType(DirectShow RGB32)")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!succeeded(impl_->graph->AddFilter(impl_->sampleGrabberFilter.Get(), L"OpenScreen Webcam Sample Grabber"),
|
||||
"AddFilter(SampleGrabber)")) {
|
||||
return false;
|
||||
}
|
||||
if (!succeeded(CoCreateInstance(CLSID_NullRendererLocal, nullptr, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(&impl_->nullRenderer)),
|
||||
"CoCreateInstance(NullRenderer)")) {
|
||||
return false;
|
||||
}
|
||||
if (!succeeded(impl_->graph->AddFilter(impl_->nullRenderer.Get(), L"OpenScreen Webcam Null Renderer"),
|
||||
"AddFilter(NullRenderer)")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!succeeded(impl_->captureGraph->RenderStream(
|
||||
&PIN_CATEGORY_CAPTURE,
|
||||
&MEDIATYPE_Video,
|
||||
impl_->captureFilter.Get(),
|
||||
impl_->sampleGrabberFilter.Get(),
|
||||
impl_->nullRenderer.Get()),
|
||||
"RenderStream(DirectShow webcam)")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
AM_MEDIA_TYPE connectedType{};
|
||||
if (!succeeded(impl_->sampleGrabber->GetConnectedMediaType(&connectedType), "GetConnectedMediaType(DirectShow webcam)")) {
|
||||
return false;
|
||||
}
|
||||
if (connectedType.formattype == FORMAT_VideoInfo && connectedType.pbFormat) {
|
||||
const auto* videoInfo = reinterpret_cast<VIDEOINFOHEADER*>(connectedType.pbFormat);
|
||||
width_ = std::abs(videoInfo->bmiHeader.biWidth);
|
||||
height_ = std::abs(videoInfo->bmiHeader.biHeight);
|
||||
sourceTopDown_ = videoInfo->bmiHeader.biHeight < 0;
|
||||
}
|
||||
freeMediaType(connectedType);
|
||||
if (width_ <= 0 || height_ <= 0) {
|
||||
width_ = requestedWidth > 0 ? requestedWidth : 1280;
|
||||
height_ = requestedHeight > 0 ? requestedHeight : 720;
|
||||
}
|
||||
|
||||
impl_->sampleGrabber->SetBufferSamples(TRUE);
|
||||
impl_->sampleGrabber->SetOneShot(FALSE);
|
||||
if (!succeeded(impl_->graph.As(&impl_->mediaControl), "QueryInterface(IMediaControl)")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
bool DirectShowWebcamCapture::start() {
|
||||
if (!impl_ || !impl_->mediaControl || impl_->running) {
|
||||
return false;
|
||||
}
|
||||
HRESULT hr = impl_->mediaControl->Run();
|
||||
if (!succeeded(hr, "Run(DirectShow webcam)")) {
|
||||
return false;
|
||||
}
|
||||
stopRequested_ = false;
|
||||
try {
|
||||
thread_ = std::thread(&DirectShowWebcamCapture::captureLoop, this);
|
||||
} catch (const std::exception& error) {
|
||||
stopRequested_ = true;
|
||||
impl_->mediaControl->Stop();
|
||||
std::cerr << "ERROR: Failed to start DirectShow webcam capture thread: " << error.what() << std::endl;
|
||||
return false;
|
||||
} catch (...) {
|
||||
stopRequested_ = true;
|
||||
impl_->mediaControl->Stop();
|
||||
std::cerr << "ERROR: Failed to start DirectShow webcam capture thread" << std::endl;
|
||||
return false;
|
||||
}
|
||||
impl_->running = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
void DirectShowWebcamCapture::stop() {
|
||||
stopRequested_ = true;
|
||||
if (thread_.joinable()) {
|
||||
thread_.join();
|
||||
}
|
||||
if (!impl_) {
|
||||
return;
|
||||
}
|
||||
if (impl_->mediaControl && impl_->running) {
|
||||
impl_->mediaControl->Stop();
|
||||
}
|
||||
impl_->running = false;
|
||||
impl_->mediaControl.Reset();
|
||||
impl_->nullRenderer.Reset();
|
||||
impl_->sampleGrabber.Reset();
|
||||
impl_->sampleGrabberFilter.Reset();
|
||||
impl_->captureFilter.Reset();
|
||||
impl_->captureGraph.Reset();
|
||||
impl_->graph.Reset();
|
||||
if (impl_->comInitialized) {
|
||||
CoUninitialize();
|
||||
impl_->comInitialized = false;
|
||||
}
|
||||
}
|
||||
|
||||
void DirectShowWebcamCapture::captureLoop() {
|
||||
const HRESULT coinitHr = CoInitializeEx(nullptr, COINIT_MULTITHREADED);
|
||||
while (!stopRequested_ && impl_ && impl_->sampleGrabber) {
|
||||
long bufferSize = 0;
|
||||
HRESULT hr = impl_->sampleGrabber->GetCurrentBuffer(&bufferSize, nullptr);
|
||||
if (SUCCEEDED(hr) && bufferSize > 0) {
|
||||
std::vector<BYTE> buffer(static_cast<size_t>(bufferSize));
|
||||
hr = impl_->sampleGrabber->GetCurrentBuffer(&bufferSize, reinterpret_cast<long*>(buffer.data()));
|
||||
if (SUCCEEDED(hr)) {
|
||||
storeFrame(buffer.data(), bufferSize);
|
||||
}
|
||||
}
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(1000 / std::max(1, fps_)));
|
||||
}
|
||||
if (SUCCEEDED(coinitHr)) {
|
||||
CoUninitialize();
|
||||
}
|
||||
}
|
||||
|
||||
void DirectShowWebcamCapture::storeFrame(const BYTE* buffer, long length) {
|
||||
const int stride = width_ * 4;
|
||||
const int expectedLength = stride * height_;
|
||||
if (!buffer || length < expectedLength || width_ <= 0 || height_ <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
std::vector<BYTE> frame(static_cast<size_t>(expectedLength));
|
||||
for (int y = 0; y < height_; y += 1) {
|
||||
const int sourceY = sourceTopDown_ ? y : height_ - 1 - y;
|
||||
const BYTE* source = buffer + sourceY * stride;
|
||||
BYTE* destination = frame.data() + y * stride;
|
||||
std::copy(source, source + stride, destination);
|
||||
for (int x = 0; x < width_; x += 1) {
|
||||
destination[x * 4 + 3] = 255;
|
||||
}
|
||||
}
|
||||
|
||||
std::scoped_lock lock(frameMutex_);
|
||||
latestFrame_ = std::move(frame);
|
||||
}
|
||||
|
||||
bool DirectShowWebcamCapture::copyLatestFrame(std::vector<BYTE>& destination, int& width, int& height) {
|
||||
std::scoped_lock lock(frameMutex_);
|
||||
if (latestFrame_.empty() || width_ <= 0 || height_ <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
destination = latestFrame_;
|
||||
width = width_;
|
||||
height = height_;
|
||||
return true;
|
||||
}
|
||||
|
||||
int DirectShowWebcamCapture::width() const {
|
||||
return width_;
|
||||
}
|
||||
|
||||
int DirectShowWebcamCapture::height() const {
|
||||
return height_;
|
||||
}
|
||||
|
||||
int DirectShowWebcamCapture::fps() const {
|
||||
return fps_;
|
||||
}
|
||||
|
||||
const std::wstring& DirectShowWebcamCapture::selectedDeviceName() const {
|
||||
return selectedDeviceName_;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
#pragma once
|
||||
|
||||
#include <Windows.h>
|
||||
|
||||
#include <atomic>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
class DirectShowWebcamCapture {
|
||||
public:
|
||||
DirectShowWebcamCapture() = default;
|
||||
~DirectShowWebcamCapture();
|
||||
|
||||
DirectShowWebcamCapture(const DirectShowWebcamCapture&) = delete;
|
||||
DirectShowWebcamCapture& operator=(const DirectShowWebcamCapture&) = delete;
|
||||
|
||||
bool initialize(
|
||||
const std::wstring& deviceId,
|
||||
const std::wstring& deviceName,
|
||||
const std::wstring& directShowClsid,
|
||||
int requestedWidth,
|
||||
int requestedHeight,
|
||||
int requestedFps);
|
||||
bool start();
|
||||
void stop();
|
||||
bool copyLatestFrame(std::vector<BYTE>& destination, int& width, int& height);
|
||||
|
||||
int width() const;
|
||||
int height() const;
|
||||
int fps() const;
|
||||
const std::wstring& selectedDeviceName() const;
|
||||
void storeFrame(const BYTE* buffer, long length);
|
||||
|
||||
private:
|
||||
struct Impl;
|
||||
void captureLoop();
|
||||
|
||||
Impl* impl_ = nullptr;
|
||||
std::thread thread_;
|
||||
std::atomic<bool> stopRequested_ = false;
|
||||
std::mutex frameMutex_;
|
||||
std::vector<BYTE> latestFrame_;
|
||||
int width_ = 0;
|
||||
int height_ = 0;
|
||||
int fps_ = 30;
|
||||
bool sourceTopDown_ = false;
|
||||
std::wstring selectedDeviceName_;
|
||||
};
|
||||
@@ -0,0 +1,757 @@
|
||||
#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 systemAudioFormat{};
|
||||
AudioInputFormat microphoneAudioFormat{};
|
||||
if (config.captureSystemAudio) {
|
||||
if (!loopbackCapture.initializeSystemLoopback()) {
|
||||
std::cerr << "ERROR: Failed to initialize WASAPI loopback capture" << std::endl;
|
||||
return 1;
|
||||
}
|
||||
systemAudioFormat = loopbackCapture.inputFormat();
|
||||
audioFormat = &loopbackCapture.inputFormat();
|
||||
}
|
||||
if (config.captureMic) {
|
||||
if (!microphoneCapture.initializeMicrophone(
|
||||
utf8ToWide(config.microphoneDeviceId),
|
||||
utf8ToWide(config.microphoneDeviceName))) {
|
||||
std::cerr << "ERROR: Failed to initialize WASAPI microphone capture" << std::endl;
|
||||
return 1;
|
||||
}
|
||||
microphoneAudioFormat = microphoneCapture.inputFormat();
|
||||
if (!audioFormat) {
|
||||
audioFormat = µphoneCapture.inputFormat();
|
||||
}
|
||||
}
|
||||
if (audioFormat) {
|
||||
std::cout << "{\"event\":\"audio-format\",\"schemaVersion\":2,\"sampleRate\":" << audioFormat->sampleRate
|
||||
<< ",\"channels\":" << audioFormat->channels
|
||||
<< ",\"bitsPerSample\":" << audioFormat->bitsPerSample
|
||||
<< ",\"system\":" << (config.captureSystemAudio ? "true" : "false")
|
||||
<< ",\"microphone\":" << (config.captureMic ? "true" : "false");
|
||||
if (config.captureMic) {
|
||||
std::cout << ",\"microphoneDeviceName\":\""
|
||||
<< jsonEscape(wideToUtf8(microphoneCapture.selectedDeviceName())) << "\"";
|
||||
}
|
||||
std::cout << "}" << std::endl;
|
||||
}
|
||||
AudioInputFormat encoderAudioFormat{};
|
||||
const AudioInputFormat* encoderAudioFormatPtr = nullptr;
|
||||
if (audioFormat) {
|
||||
encoderAudioFormat = makeAacCompatibleAudioFormat(*audioFormat);
|
||||
encoderAudioFormatPtr = &encoderAudioFormat;
|
||||
}
|
||||
|
||||
MFEncoder encoder;
|
||||
if (!encoder.initialize(
|
||||
utf8ToWide(config.outputPath),
|
||||
width,
|
||||
height,
|
||||
config.fps,
|
||||
bitrate,
|
||||
session.device(),
|
||||
session.context(),
|
||||
encoderAudioFormatPtr)) {
|
||||
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 (!encoderAudioFormatPtr) {
|
||||
return true;
|
||||
}
|
||||
|
||||
audioMixer = std::make_unique<AudioMixer>(
|
||||
*encoderAudioFormatPtr,
|
||||
config.captureSystemAudio ? systemAudioFormat : *audioFormat,
|
||||
config.captureMic ? microphoneAudioFormat : *audioFormat,
|
||||
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,380 @@
|
||||
#include "mf_encoder.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
|
||||
|
||||
AudioInputFormat makeAacCompatibleAudioFormat(const AudioInputFormat& sourceFormat) {
|
||||
AudioInputFormat normalized{};
|
||||
if (sourceFormat.sampleRate == 0 || sourceFormat.channels == 0) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
normalized.subtype = MFAudioFormat_PCM;
|
||||
normalized.sampleRate = sourceFormat.sampleRate;
|
||||
normalized.channels = sourceFormat.channels > 1 ? 2 : 1;
|
||||
normalized.bitsPerSample = 16;
|
||||
normalized.blockAlign = normalized.channels * static_cast<UINT32>(sizeof(int16_t));
|
||||
normalized.avgBytesPerSec = normalized.sampleRate * normalized.blockAlign;
|
||||
return normalized;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
const AudioInputFormat encoderFormat = makeAacCompatibleAudioFormat(audioFormat);
|
||||
if (encoderFormat.sampleRate == 0 || encoderFormat.channels == 0 || encoderFormat.blockAlign == 0) {
|
||||
std::cerr << "ERROR: Invalid audio input format" << std::endl;
|
||||
return false;
|
||||
}
|
||||
|
||||
const UINT32 aacBytesPerSecond = 24'000;
|
||||
|
||||
Microsoft::WRL::ComPtr<IMFMediaType> outputType;
|
||||
if (!succeeded(MFCreateMediaType(&outputType), "MFCreateMediaType(audio output)")) {
|
||||
return false;
|
||||
}
|
||||
outputType->SetGUID(MF_MT_MAJOR_TYPE, MFMediaType_Audio);
|
||||
outputType->SetGUID(MF_MT_SUBTYPE, MFAudioFormat_AAC);
|
||||
setAudioFormat(outputType.Get(), encoderFormat.channels, encoderFormat.sampleRate, 16);
|
||||
outputType->SetUINT32(MF_MT_AUDIO_AVG_BYTES_PER_SECOND, aacBytesPerSecond);
|
||||
outputType->SetUINT32(MF_MT_AAC_PAYLOAD_TYPE, 0);
|
||||
|
||||
if (!succeeded(sinkWriter_->AddStream(outputType.Get(), &audioStreamIndex_), "AddStream(audio)")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
Microsoft::WRL::ComPtr<IMFMediaType> inputType;
|
||||
if (!succeeded(MFCreateMediaType(&inputType), "MFCreateMediaType(audio input)")) {
|
||||
return false;
|
||||
}
|
||||
inputType->SetGUID(MF_MT_MAJOR_TYPE, MFMediaType_Audio);
|
||||
inputType->SetGUID(MF_MT_SUBTYPE, encoderFormat.subtype);
|
||||
setAudioFormat(
|
||||
inputType.Get(),
|
||||
encoderFormat.channels,
|
||||
encoderFormat.sampleRate,
|
||||
encoderFormat.bitsPerSample);
|
||||
inputType->SetUINT32(MF_MT_AUDIO_BLOCK_ALIGNMENT, encoderFormat.blockAlign);
|
||||
inputType->SetUINT32(MF_MT_AUDIO_AVG_BYTES_PER_SECOND, encoderFormat.avgBytesPerSec);
|
||||
inputType->SetUINT32(MF_MT_ALL_SAMPLES_INDEPENDENT, TRUE);
|
||||
|
||||
if (!succeeded(sinkWriter_->SetInputMediaType(audioStreamIndex_, inputType.Get(), nullptr),
|
||||
"SetInputMediaType(audio)")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
hasAudioStream_ = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool MFEncoder::ensureStagingTexture(ID3D11Texture2D* texture) {
|
||||
if (stagingTexture_) {
|
||||
return true;
|
||||
}
|
||||
|
||||
D3D11_TEXTURE2D_DESC desc{};
|
||||
texture->GetDesc(&desc);
|
||||
desc.Width = static_cast<UINT>(width_);
|
||||
desc.Height = static_cast<UINT>(height_);
|
||||
desc.MipLevels = 1;
|
||||
desc.ArraySize = 1;
|
||||
desc.Format = DXGI_FORMAT_B8G8R8A8_UNORM;
|
||||
desc.SampleDesc.Count = 1;
|
||||
desc.SampleDesc.Quality = 0;
|
||||
desc.Usage = D3D11_USAGE_STAGING;
|
||||
desc.BindFlags = 0;
|
||||
desc.CPUAccessFlags = D3D11_CPU_ACCESS_READ;
|
||||
desc.MiscFlags = 0;
|
||||
|
||||
return succeeded(device_->CreateTexture2D(&desc, nullptr, &stagingTexture_),
|
||||
"CreateTexture2D(staging)");
|
||||
}
|
||||
|
||||
bool MFEncoder::copyFrameToBuffer(
|
||||
ID3D11Texture2D* texture,
|
||||
BYTE* destination,
|
||||
DWORD destinationSize,
|
||||
const BgraFrameView* webcamFrame) {
|
||||
if (!ensureStagingTexture(texture)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
context_->CopyResource(stagingTexture_.Get(), texture);
|
||||
|
||||
D3D11_MAPPED_SUBRESOURCE mapped{};
|
||||
if (!succeeded(context_->Map(stagingTexture_.Get(), 0, D3D11_MAP_READ, 0, &mapped), "Map")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const DWORD rowBytes = static_cast<DWORD>(width_ * 4);
|
||||
const DWORD requiredBytes = rowBytes * static_cast<DWORD>(height_);
|
||||
if (destinationSize < requiredBytes) {
|
||||
context_->Unmap(stagingTexture_.Get(), 0);
|
||||
std::cerr << "ERROR: Media Foundation buffer is too small" << std::endl;
|
||||
return false;
|
||||
}
|
||||
|
||||
auto* source = static_cast<const BYTE*>(mapped.pData);
|
||||
for (int y = 0; y < height_; y += 1) {
|
||||
std::memcpy(destination + rowBytes * y, source + mapped.RowPitch * y, rowBytes);
|
||||
}
|
||||
if (webcamFrame) {
|
||||
compositeWebcam(destination, width_, height_, *webcamFrame);
|
||||
}
|
||||
|
||||
context_->Unmap(stagingTexture_.Get(), 0);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool MFEncoder::writeFrame(ID3D11Texture2D* texture, int64_t timestampHns, const BgraFrameView* webcamFrame) {
|
||||
std::scoped_lock writerLock(writerMutex_);
|
||||
if (!sinkWriter_ || finalized_) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (firstTimestampHns_ < 0) {
|
||||
firstTimestampHns_ = timestampHns;
|
||||
}
|
||||
|
||||
int64_t sampleTime = timestampHns - firstTimestampHns_;
|
||||
if (sampleTime <= lastTimestampHns_) {
|
||||
sampleTime = lastTimestampHns_ + (10'000'000LL / fps_);
|
||||
}
|
||||
const int64_t sampleDuration = 10'000'000LL / fps_;
|
||||
lastTimestampHns_ = sampleTime;
|
||||
|
||||
Microsoft::WRL::ComPtr<IMFMediaBuffer> buffer;
|
||||
const DWORD frameBytes = static_cast<DWORD>(width_ * height_ * 4);
|
||||
if (!succeeded(MFCreateMemoryBuffer(frameBytes, &buffer), "MFCreateMemoryBuffer")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
BYTE* data = nullptr;
|
||||
DWORD maxLength = 0;
|
||||
DWORD currentLength = 0;
|
||||
if (!succeeded(buffer->Lock(&data, &maxLength, ¤tLength), "IMFMediaBuffer::Lock")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const bool copied = copyFrameToBuffer(texture, data, maxLength, webcamFrame);
|
||||
buffer->Unlock();
|
||||
if (!copied) {
|
||||
return false;
|
||||
}
|
||||
buffer->SetCurrentLength(frameBytes);
|
||||
|
||||
Microsoft::WRL::ComPtr<IMFSample> sample;
|
||||
if (!succeeded(MFCreateSample(&sample), "MFCreateSample")) {
|
||||
return false;
|
||||
}
|
||||
sample->AddBuffer(buffer.Get());
|
||||
sample->SetSampleTime(sampleTime);
|
||||
sample->SetSampleDuration(sampleDuration);
|
||||
|
||||
return succeeded(sinkWriter_->WriteSample(videoStreamIndex_, sample.Get()), "WriteSample");
|
||||
}
|
||||
|
||||
bool MFEncoder::writeAudio(const BYTE* data, DWORD byteCount, int64_t timestampHns, int64_t durationHns) {
|
||||
std::scoped_lock writerLock(writerMutex_);
|
||||
if (!sinkWriter_ || finalized_ || !hasAudioStream_) {
|
||||
return false;
|
||||
}
|
||||
if (!data || byteCount == 0 || durationHns <= 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
Microsoft::WRL::ComPtr<IMFMediaBuffer> buffer;
|
||||
if (!succeeded(MFCreateMemoryBuffer(byteCount, &buffer), "MFCreateMemoryBuffer(audio)")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
BYTE* destination = nullptr;
|
||||
DWORD maxLength = 0;
|
||||
DWORD currentLength = 0;
|
||||
if (!succeeded(buffer->Lock(&destination, &maxLength, ¤tLength),
|
||||
"IMFMediaBuffer::Lock(audio)")) {
|
||||
return false;
|
||||
}
|
||||
if (maxLength < byteCount) {
|
||||
buffer->Unlock();
|
||||
std::cerr << "ERROR: Media Foundation audio buffer is too small" << std::endl;
|
||||
return false;
|
||||
}
|
||||
std::memcpy(destination, data, byteCount);
|
||||
buffer->Unlock();
|
||||
buffer->SetCurrentLength(byteCount);
|
||||
|
||||
Microsoft::WRL::ComPtr<IMFSample> sample;
|
||||
if (!succeeded(MFCreateSample(&sample), "MFCreateSample(audio)")) {
|
||||
return false;
|
||||
}
|
||||
sample->AddBuffer(buffer.Get());
|
||||
sample->SetSampleTime(std::max<int64_t>(0, timestampHns));
|
||||
sample->SetSampleDuration(durationHns);
|
||||
|
||||
return succeeded(sinkWriter_->WriteSample(audioStreamIndex_, sample.Get()), "WriteSample(audio)");
|
||||
}
|
||||
|
||||
bool MFEncoder::finalize() {
|
||||
std::scoped_lock writerLock(writerMutex_);
|
||||
if (finalized_) {
|
||||
return true;
|
||||
}
|
||||
|
||||
finalized_ = true;
|
||||
bool ok = true;
|
||||
if (sinkWriter_) {
|
||||
ok = succeeded(sinkWriter_->Finalize(), "SinkWriter::Finalize");
|
||||
sinkWriter_.Reset();
|
||||
}
|
||||
stagingTexture_.Reset();
|
||||
context_.Reset();
|
||||
device_.Reset();
|
||||
MFShutdown();
|
||||
return ok;
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
#pragma once
|
||||
|
||||
#include <Windows.h>
|
||||
#include <d3d11.h>
|
||||
#include <mfapi.h>
|
||||
#include <mfidl.h>
|
||||
#include <mfreadwrite.h>
|
||||
#include <wrl/client.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
|
||||
struct BgraFrameView {
|
||||
const BYTE* data = nullptr;
|
||||
int width = 0;
|
||||
int height = 0;
|
||||
};
|
||||
|
||||
struct AudioInputFormat {
|
||||
GUID subtype = MFAudioFormat_PCM;
|
||||
UINT32 sampleRate = 0;
|
||||
UINT32 channels = 0;
|
||||
UINT32 bitsPerSample = 0;
|
||||
UINT32 blockAlign = 0;
|
||||
UINT32 avgBytesPerSec = 0;
|
||||
};
|
||||
|
||||
AudioInputFormat makeAacCompatibleAudioFormat(const AudioInputFormat& sourceFormat);
|
||||
|
||||
class MFEncoder {
|
||||
public:
|
||||
MFEncoder() = default;
|
||||
~MFEncoder();
|
||||
|
||||
MFEncoder(const MFEncoder&) = delete;
|
||||
MFEncoder& operator=(const MFEncoder&) = delete;
|
||||
|
||||
bool initialize(
|
||||
const std::wstring& outputPath,
|
||||
int width,
|
||||
int height,
|
||||
int fps,
|
||||
int bitrate,
|
||||
ID3D11Device* device,
|
||||
ID3D11DeviceContext* context,
|
||||
const AudioInputFormat* audioFormat = nullptr);
|
||||
bool writeFrame(ID3D11Texture2D* texture, int64_t timestampHns, const BgraFrameView* webcamFrame = nullptr);
|
||||
bool writeAudio(const BYTE* data, DWORD byteCount, int64_t timestampHns, int64_t durationHns);
|
||||
bool finalize();
|
||||
|
||||
private:
|
||||
bool ensureStagingTexture(ID3D11Texture2D* texture);
|
||||
bool copyFrameToBuffer(
|
||||
ID3D11Texture2D* texture,
|
||||
BYTE* destination,
|
||||
DWORD destinationSize,
|
||||
const BgraFrameView* webcamFrame);
|
||||
bool configureAudioStream(const AudioInputFormat& audioFormat);
|
||||
|
||||
Microsoft::WRL::ComPtr<IMFSinkWriter> sinkWriter_;
|
||||
Microsoft::WRL::ComPtr<ID3D11Device> device_;
|
||||
Microsoft::WRL::ComPtr<ID3D11DeviceContext> context_;
|
||||
Microsoft::WRL::ComPtr<ID3D11Texture2D> stagingTexture_;
|
||||
std::mutex writerMutex_;
|
||||
DWORD videoStreamIndex_ = 0;
|
||||
DWORD audioStreamIndex_ = 0;
|
||||
bool hasAudioStream_ = false;
|
||||
int width_ = 0;
|
||||
int height_ = 0;
|
||||
int fps_ = 60;
|
||||
int64_t firstTimestampHns_ = -1;
|
||||
int64_t lastTimestampHns_ = -1;
|
||||
bool finalized_ = false;
|
||||
};
|
||||
@@ -0,0 +1,88 @@
|
||||
#include "monitor_utils.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <vector>
|
||||
|
||||
namespace {
|
||||
|
||||
struct MonitorCandidate {
|
||||
HMONITOR monitor = nullptr;
|
||||
RECT rect{};
|
||||
};
|
||||
|
||||
std::vector<MonitorCandidate> enumerateMonitors() {
|
||||
std::vector<MonitorCandidate> monitors;
|
||||
EnumDisplayMonitors(
|
||||
nullptr,
|
||||
nullptr,
|
||||
[](HMONITOR monitor, HDC, LPRECT rect, LPARAM userData) -> BOOL {
|
||||
auto* result = reinterpret_cast<std::vector<MonitorCandidate>*>(userData);
|
||||
result->push_back({monitor, *rect});
|
||||
return TRUE;
|
||||
},
|
||||
reinterpret_cast<LPARAM>(&monitors));
|
||||
return monitors;
|
||||
}
|
||||
|
||||
bool rectMatchesBounds(const RECT& rect, const MonitorBounds& bounds) {
|
||||
return rect.left == bounds.x &&
|
||||
rect.top == bounds.y &&
|
||||
(rect.right - rect.left) == bounds.width &&
|
||||
(rect.bottom - rect.top) == bounds.height;
|
||||
}
|
||||
|
||||
int64_t overlapArea(const RECT& rect, const MonitorBounds& bounds) {
|
||||
const LONG left = std::max<LONG>(rect.left, bounds.x);
|
||||
const LONG top = std::max<LONG>(rect.top, bounds.y);
|
||||
const LONG right = std::min<LONG>(rect.right, bounds.x + bounds.width);
|
||||
const LONG bottom = std::min<LONG>(rect.bottom, bounds.y + bounds.height);
|
||||
if (right <= left || bottom <= top) {
|
||||
return 0;
|
||||
}
|
||||
return static_cast<int64_t>(right - left) * static_cast<int64_t>(bottom - top);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
HMONITOR findMonitorForCapture(int64_t displayId, const MonitorBounds* bounds) {
|
||||
const auto monitors = enumerateMonitors();
|
||||
if (monitors.empty()) {
|
||||
return MonitorFromPoint({0, 0}, MONITOR_DEFAULTTOPRIMARY);
|
||||
}
|
||||
|
||||
// Electron's display_id is not stable across all Windows capture backends.
|
||||
// Bounds are the most reliable contract because they come from Electron's
|
||||
// selected display and match the WGC monitor coordinate space.
|
||||
if (bounds && bounds->width > 0 && bounds->height > 0) {
|
||||
for (const auto& candidate : monitors) {
|
||||
if (rectMatchesBounds(candidate.rect, *bounds)) {
|
||||
return candidate.monitor;
|
||||
}
|
||||
}
|
||||
|
||||
HMONITOR bestMonitor = nullptr;
|
||||
int64_t bestArea = 0;
|
||||
for (const auto& candidate : monitors) {
|
||||
const int64_t area = overlapArea(candidate.rect, *bounds);
|
||||
if (area > bestArea) {
|
||||
bestArea = area;
|
||||
bestMonitor = candidate.monitor;
|
||||
}
|
||||
}
|
||||
if (bestMonitor) {
|
||||
return bestMonitor;
|
||||
}
|
||||
}
|
||||
|
||||
// Best-effort fallback for helpers invoked without bounds. Some callers pass
|
||||
// zero-based ids while Win32 monitor handles are pointer values, so only use
|
||||
// this when it exactly matches the HMONITOR value.
|
||||
for (const auto& candidate : monitors) {
|
||||
if (reinterpret_cast<int64_t>(candidate.monitor) == displayId) {
|
||||
return candidate.monitor;
|
||||
}
|
||||
}
|
||||
|
||||
return MonitorFromPoint({0, 0}, MONITOR_DEFAULTTOPRIMARY);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
#pragma once
|
||||
|
||||
#include <Windows.h>
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
struct MonitorBounds {
|
||||
int x = 0;
|
||||
int y = 0;
|
||||
int width = 0;
|
||||
int height = 0;
|
||||
};
|
||||
|
||||
HMONITOR findMonitorForCapture(int64_t displayId, const MonitorBounds* bounds);
|
||||
@@ -0,0 +1,411 @@
|
||||
#include "wasapi_loopback_capture.h"
|
||||
|
||||
#include <Functiondiscoverykeys_devpkey.h>
|
||||
#include <ksmedia.h>
|
||||
#include <propvarutil.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <cwctype>
|
||||
#include <iostream>
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr REFERENCE_TIME BufferDurationHns = 10'000'000;
|
||||
constexpr int64_t HnsPerSecond = 10'000'000;
|
||||
|
||||
bool succeeded(HRESULT hr, const char* label) {
|
||||
if (SUCCEEDED(hr)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
std::cerr << "ERROR: " << label << " failed (hr=0x" << std::hex << hr << std::dec << ")"
|
||||
<< std::endl;
|
||||
return false;
|
||||
}
|
||||
|
||||
GUID audioSubtypeFromFormat(WAVEFORMATEX* format) {
|
||||
if (format->wFormatTag == WAVE_FORMAT_IEEE_FLOAT) {
|
||||
return MFAudioFormat_Float;
|
||||
}
|
||||
if (format->wFormatTag == WAVE_FORMAT_PCM) {
|
||||
return MFAudioFormat_PCM;
|
||||
}
|
||||
if (format->wFormatTag == WAVE_FORMAT_EXTENSIBLE &&
|
||||
format->cbSize >= sizeof(WAVEFORMATEXTENSIBLE) - sizeof(WAVEFORMATEX)) {
|
||||
auto* extensible = reinterpret_cast<WAVEFORMATEXTENSIBLE*>(format);
|
||||
if (extensible->SubFormat == KSDATAFORMAT_SUBTYPE_IEEE_FLOAT) {
|
||||
return MFAudioFormat_Float;
|
||||
}
|
||||
if (extensible->SubFormat == KSDATAFORMAT_SUBTYPE_PCM) {
|
||||
return MFAudioFormat_PCM;
|
||||
}
|
||||
}
|
||||
return GUID_NULL;
|
||||
}
|
||||
|
||||
std::wstring normalizeDeviceName(const std::wstring& value) {
|
||||
std::wstring result;
|
||||
result.reserve(value.size());
|
||||
bool lastWasSpace = true;
|
||||
|
||||
for (const wchar_t c : value) {
|
||||
if (std::iswalnum(c)) {
|
||||
result.push_back(static_cast<wchar_t>(std::towlower(c)));
|
||||
lastWasSpace = false;
|
||||
} else if (!lastWasSpace) {
|
||||
result.push_back(L' ');
|
||||
lastWasSpace = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!result.empty() && result.back() == L' ') {
|
||||
result.pop_back();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
int scoreDeviceName(const std::wstring& candidateName, const std::wstring& candidateId, const std::wstring& requestedName) {
|
||||
const std::wstring candidate = normalizeDeviceName(candidateName);
|
||||
const std::wstring id = normalizeDeviceName(candidateId);
|
||||
const std::wstring requested = normalizeDeviceName(requestedName);
|
||||
if (requested.empty()) {
|
||||
return 0;
|
||||
}
|
||||
if (candidate == requested) {
|
||||
return 1000;
|
||||
}
|
||||
if (!candidate.empty() && (candidate.find(requested) != std::wstring::npos || requested.find(candidate) != std::wstring::npos)) {
|
||||
return 900;
|
||||
}
|
||||
if (!id.empty() && (id.find(requested) != std::wstring::npos || requested.find(id) != std::wstring::npos)) {
|
||||
return 800;
|
||||
}
|
||||
|
||||
int score = 0;
|
||||
size_t pos = 0;
|
||||
while (pos < requested.size()) {
|
||||
const size_t end = requested.find(L' ', pos);
|
||||
const std::wstring word = requested.substr(pos, end == std::wstring::npos ? std::wstring::npos : end - pos);
|
||||
if (word.size() > 1 && word != L"microphone" && word != L"mic" && word != L"audio" && word != L"input") {
|
||||
if (candidate.find(word) != std::wstring::npos) {
|
||||
score += 100;
|
||||
} else if (id.find(word) != std::wstring::npos) {
|
||||
score += 50;
|
||||
}
|
||||
}
|
||||
if (end == std::wstring::npos) {
|
||||
break;
|
||||
}
|
||||
pos = end + 1;
|
||||
}
|
||||
return score;
|
||||
}
|
||||
|
||||
std::wstring getDeviceFriendlyName(IMMDevice* device) {
|
||||
if (!device) {
|
||||
return {};
|
||||
}
|
||||
|
||||
Microsoft::WRL::ComPtr<IPropertyStore> properties;
|
||||
HRESULT hr = device->OpenPropertyStore(STGM_READ, &properties);
|
||||
if (FAILED(hr) || !properties) {
|
||||
return {};
|
||||
}
|
||||
|
||||
PROPVARIANT value;
|
||||
PropVariantInit(&value);
|
||||
hr = properties->GetValue(PKEY_Device_FriendlyName, &value);
|
||||
std::wstring name;
|
||||
if (SUCCEEDED(hr) && value.vt == VT_LPWSTR && value.pwszVal) {
|
||||
name = value.pwszVal;
|
||||
}
|
||||
PropVariantClear(&value);
|
||||
return name;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
WasapiLoopbackCapture::~WasapiLoopbackCapture() {
|
||||
stop();
|
||||
if (mixFormat_) {
|
||||
CoTaskMemFree(mixFormat_);
|
||||
mixFormat_ = nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
bool WasapiLoopbackCapture::initializeSystemLoopback() {
|
||||
return initialize(WasapiCaptureEndpoint::SystemLoopback, {}, {});
|
||||
}
|
||||
|
||||
bool WasapiLoopbackCapture::initializeMicrophone(const std::wstring& deviceId, const std::wstring& deviceName) {
|
||||
return initialize(WasapiCaptureEndpoint::Microphone, deviceId, deviceName);
|
||||
}
|
||||
|
||||
bool WasapiLoopbackCapture::initialize(WasapiCaptureEndpoint endpoint, const std::wstring& deviceId, const std::wstring& deviceName) {
|
||||
HRESULT hr = CoCreateInstance(
|
||||
__uuidof(MMDeviceEnumerator),
|
||||
nullptr,
|
||||
CLSCTX_ALL,
|
||||
IID_PPV_ARGS(&deviceEnumerator_));
|
||||
if (!succeeded(hr, "CoCreateInstance(MMDeviceEnumerator)")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (endpoint == WasapiCaptureEndpoint::Microphone && !deviceId.empty() && deviceId != L"default") {
|
||||
hr = deviceEnumerator_->GetDevice(deviceId.c_str(), &device_);
|
||||
if (FAILED(hr)) {
|
||||
std::wcerr << L"WARNING: Could not resolve microphone device id directly"
|
||||
<< std::endl;
|
||||
device_.Reset();
|
||||
}
|
||||
}
|
||||
|
||||
if (endpoint == WasapiCaptureEndpoint::Microphone && !device_ && !deviceName.empty()) {
|
||||
if (!resolveMicrophoneByName(deviceName)) {
|
||||
std::wcerr << L"WARNING: Could not resolve microphone by name; using default capture endpoint"
|
||||
<< std::endl;
|
||||
}
|
||||
}
|
||||
|
||||
if (!device_) {
|
||||
const EDataFlow flow =
|
||||
endpoint == WasapiCaptureEndpoint::SystemLoopback ? eRender : eCapture;
|
||||
hr = deviceEnumerator_->GetDefaultAudioEndpoint(flow, eConsole, &device_);
|
||||
if (!succeeded(hr, "GetDefaultAudioEndpoint")) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
selectedDeviceName_ = getDeviceFriendlyName(device_.Get());
|
||||
|
||||
hr = device_->Activate(__uuidof(IAudioClient), CLSCTX_ALL, nullptr, &audioClient_);
|
||||
if (!succeeded(hr, "IMMDevice::Activate(IAudioClient)")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
hr = audioClient_->GetMixFormat(&mixFormat_);
|
||||
if (!succeeded(hr, "IAudioClient::GetMixFormat") || !mixFormat_) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!resolveInputFormat(mixFormat_)) {
|
||||
std::cerr << "ERROR: Unsupported WASAPI loopback mix format" << std::endl;
|
||||
return false;
|
||||
}
|
||||
|
||||
const DWORD streamFlags =
|
||||
endpoint == WasapiCaptureEndpoint::SystemLoopback ? AUDCLNT_STREAMFLAGS_LOOPBACK : 0;
|
||||
hr = audioClient_->Initialize(
|
||||
AUDCLNT_SHAREMODE_SHARED,
|
||||
streamFlags,
|
||||
BufferDurationHns,
|
||||
0,
|
||||
mixFormat_,
|
||||
nullptr);
|
||||
if (!succeeded(hr, "IAudioClient::Initialize(loopback)")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
hr = audioClient_->GetService(IID_PPV_ARGS(&captureClient_));
|
||||
if (!succeeded(hr, "IAudioClient::GetService(IAudioCaptureClient)")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
bool WasapiLoopbackCapture::resolveMicrophoneByName(const std::wstring& deviceName) {
|
||||
if (!deviceEnumerator_ || deviceName.empty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
Microsoft::WRL::ComPtr<IMMDeviceCollection> devices;
|
||||
HRESULT hr = deviceEnumerator_->EnumAudioEndpoints(eCapture, DEVICE_STATE_ACTIVE, &devices);
|
||||
if (!succeeded(hr, "IMMDeviceEnumerator::EnumAudioEndpoints(eCapture)")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
UINT count = 0;
|
||||
hr = devices->GetCount(&count);
|
||||
if (!succeeded(hr, "IMMDeviceCollection::GetCount")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
Microsoft::WRL::ComPtr<IMMDevice> bestDevice;
|
||||
std::wstring bestId;
|
||||
std::wstring bestName;
|
||||
int bestScore = 0;
|
||||
for (UINT i = 0; i < count; ++i) {
|
||||
Microsoft::WRL::ComPtr<IMMDevice> candidate;
|
||||
hr = devices->Item(i, &candidate);
|
||||
if (FAILED(hr) || !candidate) {
|
||||
continue;
|
||||
}
|
||||
|
||||
LPWSTR rawId = nullptr;
|
||||
std::wstring candidateId;
|
||||
if (SUCCEEDED(candidate->GetId(&rawId)) && rawId) {
|
||||
candidateId = rawId;
|
||||
CoTaskMemFree(rawId);
|
||||
}
|
||||
|
||||
const std::wstring candidateName = getDeviceFriendlyName(candidate.Get());
|
||||
const int score = scoreDeviceName(candidateName, candidateId, deviceName);
|
||||
std::wcerr << L"Native microphone candidate: " << candidateName << L" score=" << score << std::endl;
|
||||
if (score > bestScore) {
|
||||
bestScore = score;
|
||||
bestDevice = candidate;
|
||||
bestId = candidateId;
|
||||
bestName = candidateName;
|
||||
}
|
||||
}
|
||||
|
||||
if (!bestDevice || bestScore <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
device_ = bestDevice;
|
||||
std::wcerr << L"Selected native microphone endpoint: " << bestName << L" id=" << bestId << std::endl;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool WasapiLoopbackCapture::resolveInputFormat(WAVEFORMATEX* mixFormat) {
|
||||
const GUID subtype = audioSubtypeFromFormat(mixFormat);
|
||||
if (subtype == GUID_NULL) {
|
||||
return false;
|
||||
}
|
||||
|
||||
inputFormat_.subtype = subtype;
|
||||
inputFormat_.sampleRate = mixFormat->nSamplesPerSec;
|
||||
inputFormat_.channels = mixFormat->nChannels;
|
||||
inputFormat_.bitsPerSample = mixFormat->wBitsPerSample;
|
||||
inputFormat_.blockAlign = mixFormat->nBlockAlign;
|
||||
inputFormat_.avgBytesPerSec = mixFormat->nAvgBytesPerSec;
|
||||
return inputFormat_.sampleRate > 0 && inputFormat_.channels > 0 && inputFormat_.blockAlign > 0;
|
||||
}
|
||||
|
||||
bool WasapiLoopbackCapture::start(AudioCallback callback) {
|
||||
if (!audioClient_ || !captureClient_ || !callback) {
|
||||
return false;
|
||||
}
|
||||
|
||||
callback_ = std::move(callback);
|
||||
stopRequested_ = false;
|
||||
writtenFrames_ = 0;
|
||||
lastDevicePositionEnd_ = 0;
|
||||
hasLastDevicePosition_ = false;
|
||||
|
||||
HRESULT hr = audioClient_->Start();
|
||||
if (!succeeded(hr, "IAudioClient::Start")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
thread_ = std::thread([this] {
|
||||
captureLoop();
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
void WasapiLoopbackCapture::stop() {
|
||||
stopRequested_ = true;
|
||||
if (thread_.joinable()) {
|
||||
thread_.join();
|
||||
}
|
||||
if (audioClient_) {
|
||||
audioClient_->Stop();
|
||||
}
|
||||
}
|
||||
|
||||
const AudioInputFormat& WasapiLoopbackCapture::inputFormat() const {
|
||||
return inputFormat_;
|
||||
}
|
||||
|
||||
const std::wstring& WasapiLoopbackCapture::selectedDeviceName() const {
|
||||
return selectedDeviceName_;
|
||||
}
|
||||
|
||||
void WasapiLoopbackCapture::captureLoop() {
|
||||
auto emitSilenceFrames = [&](uint64_t frames, int64_t timestampHns) {
|
||||
constexpr uint64_t MaxSilenceChunkFrames = 4800;
|
||||
uint64_t remainingFrames = frames;
|
||||
int64_t currentTimestampHns = timestampHns;
|
||||
while (remainingFrames > 0 && !stopRequested_) {
|
||||
const uint64_t chunkFrames = std::min<uint64_t>(remainingFrames, MaxSilenceChunkFrames);
|
||||
const DWORD chunkBytes = static_cast<DWORD>(chunkFrames * inputFormat_.blockAlign);
|
||||
const int64_t chunkDurationHns =
|
||||
static_cast<int64_t>((chunkFrames * HnsPerSecond) / inputFormat_.sampleRate);
|
||||
silenceBuffer_.assign(chunkBytes, 0);
|
||||
callback_(silenceBuffer_.data(), chunkBytes, currentTimestampHns, chunkDurationHns);
|
||||
remainingFrames -= chunkFrames;
|
||||
currentTimestampHns += chunkDurationHns;
|
||||
}
|
||||
};
|
||||
|
||||
while (!stopRequested_) {
|
||||
UINT32 packetFrames = 0;
|
||||
HRESULT hr = captureClient_->GetNextPacketSize(&packetFrames);
|
||||
if (FAILED(hr)) {
|
||||
std::cerr << "ERROR: IAudioCaptureClient::GetNextPacketSize failed (hr=0x" << std::hex
|
||||
<< hr << std::dec << ")" << std::endl;
|
||||
break;
|
||||
}
|
||||
|
||||
while (packetFrames > 0 && !stopRequested_) {
|
||||
BYTE* data = nullptr;
|
||||
UINT32 framesAvailable = 0;
|
||||
DWORD flags = 0;
|
||||
UINT64 devicePosition = 0;
|
||||
UINT64 qpcPosition = 0;
|
||||
|
||||
hr = captureClient_->GetBuffer(&data, &framesAvailable, &flags, &devicePosition, &qpcPosition);
|
||||
if (FAILED(hr)) {
|
||||
std::cerr << "ERROR: IAudioCaptureClient::GetBuffer failed (hr=0x" << std::hex
|
||||
<< hr << std::dec << ")" << std::endl;
|
||||
break;
|
||||
}
|
||||
|
||||
(void)qpcPosition;
|
||||
if (hasLastDevicePosition_ && devicePosition > lastDevicePositionEnd_) {
|
||||
const uint64_t gapFrames = devicePosition - lastDevicePositionEnd_;
|
||||
if ((flags & AUDCLNT_BUFFERFLAGS_DATA_DISCONTINUITY) != 0 || gapFrames > framesAvailable) {
|
||||
const int64_t gapTimestampHns =
|
||||
static_cast<int64_t>((lastDevicePositionEnd_ * HnsPerSecond) / inputFormat_.sampleRate);
|
||||
emitSilenceFrames(gapFrames, gapTimestampHns);
|
||||
}
|
||||
}
|
||||
|
||||
const DWORD byteCount = framesAvailable * inputFormat_.blockAlign;
|
||||
const int64_t timestampHns =
|
||||
static_cast<int64_t>((devicePosition * HnsPerSecond) / inputFormat_.sampleRate);
|
||||
const int64_t durationHns =
|
||||
static_cast<int64_t>((static_cast<uint64_t>(framesAvailable) * HnsPerSecond) /
|
||||
inputFormat_.sampleRate);
|
||||
|
||||
if (byteCount > 0) {
|
||||
if ((flags & AUDCLNT_BUFFERFLAGS_SILENT) != 0 || !data) {
|
||||
silenceBuffer_.assign(byteCount, 0);
|
||||
callback_(silenceBuffer_.data(), byteCount, timestampHns, durationHns);
|
||||
} else {
|
||||
callback_(data, byteCount, timestampHns, durationHns);
|
||||
}
|
||||
}
|
||||
|
||||
writtenFrames_ += framesAvailable;
|
||||
lastDevicePositionEnd_ = devicePosition + framesAvailable;
|
||||
hasLastDevicePosition_ = true;
|
||||
captureClient_->ReleaseBuffer(framesAvailable);
|
||||
|
||||
hr = captureClient_->GetNextPacketSize(&packetFrames);
|
||||
if (FAILED(hr)) {
|
||||
std::cerr << "ERROR: IAudioCaptureClient::GetNextPacketSize failed (hr=0x"
|
||||
<< std::hex << hr << std::dec << ")" << std::endl;
|
||||
packetFrames = 0;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(5));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
#pragma once
|
||||
|
||||
#include "mf_encoder.h"
|
||||
|
||||
#include <Windows.h>
|
||||
#include <audioclient.h>
|
||||
#include <mmdeviceapi.h>
|
||||
#include <wrl/client.h>
|
||||
|
||||
#include <atomic>
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
enum class WasapiCaptureEndpoint {
|
||||
SystemLoopback,
|
||||
Microphone,
|
||||
};
|
||||
|
||||
class WasapiLoopbackCapture {
|
||||
public:
|
||||
using AudioCallback = std::function<void(const BYTE* data, DWORD byteCount, int64_t timestampHns, int64_t durationHns)>;
|
||||
|
||||
WasapiLoopbackCapture() = default;
|
||||
~WasapiLoopbackCapture();
|
||||
|
||||
WasapiLoopbackCapture(const WasapiLoopbackCapture&) = delete;
|
||||
WasapiLoopbackCapture& operator=(const WasapiLoopbackCapture&) = delete;
|
||||
|
||||
bool initializeSystemLoopback();
|
||||
bool initializeMicrophone(const std::wstring& deviceId, const std::wstring& deviceName);
|
||||
bool start(AudioCallback callback);
|
||||
void stop();
|
||||
|
||||
const AudioInputFormat& inputFormat() const;
|
||||
const std::wstring& selectedDeviceName() const;
|
||||
|
||||
private:
|
||||
bool initialize(WasapiCaptureEndpoint endpoint, const std::wstring& deviceId, const std::wstring& deviceName);
|
||||
bool resolveMicrophoneByName(const std::wstring& deviceName);
|
||||
void captureLoop();
|
||||
bool resolveInputFormat(WAVEFORMATEX* mixFormat);
|
||||
|
||||
Microsoft::WRL::ComPtr<IMMDeviceEnumerator> deviceEnumerator_;
|
||||
Microsoft::WRL::ComPtr<IMMDevice> device_;
|
||||
Microsoft::WRL::ComPtr<IAudioClient> audioClient_;
|
||||
Microsoft::WRL::ComPtr<IAudioCaptureClient> captureClient_;
|
||||
WAVEFORMATEX* mixFormat_ = nullptr;
|
||||
AudioInputFormat inputFormat_{};
|
||||
std::wstring selectedDeviceName_;
|
||||
AudioCallback callback_;
|
||||
std::thread thread_;
|
||||
std::atomic<bool> stopRequested_ = false;
|
||||
std::vector<BYTE> silenceBuffer_;
|
||||
uint64_t writtenFrames_ = 0;
|
||||
uint64_t lastDevicePositionEnd_ = 0;
|
||||
bool hasLastDevicePosition_ = false;
|
||||
};
|
||||
@@ -0,0 +1,417 @@
|
||||
#include "webcam_capture.h"
|
||||
|
||||
#include <mfapi.h>
|
||||
#include <mferror.h>
|
||||
#include <propvarutil.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <cwctype>
|
||||
#include <iostream>
|
||||
|
||||
namespace {
|
||||
|
||||
bool succeeded(HRESULT hr, const char* label) {
|
||||
if (SUCCEEDED(hr)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
std::cerr << "ERROR: " << label << " failed (hr=0x" << std::hex << hr << std::dec << ")"
|
||||
<< std::endl;
|
||||
return false;
|
||||
}
|
||||
|
||||
std::wstring readAllocatedString(IMFActivate* activate, REFGUID key) {
|
||||
WCHAR* value = nullptr;
|
||||
UINT32 length = 0;
|
||||
if (FAILED(activate->GetAllocatedString(key, &value, &length)) || !value) {
|
||||
return {};
|
||||
}
|
||||
|
||||
std::wstring result(value, value + length);
|
||||
CoTaskMemFree(value);
|
||||
return result;
|
||||
}
|
||||
|
||||
bool containsInsensitive(const std::wstring& haystack, const std::wstring& needle) {
|
||||
if (haystack.empty() || needle.empty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
std::wstring lowerHaystack = haystack;
|
||||
std::wstring lowerNeedle = needle;
|
||||
std::transform(lowerHaystack.begin(), lowerHaystack.end(), lowerHaystack.begin(), ::towlower);
|
||||
std::transform(lowerNeedle.begin(), lowerNeedle.end(), lowerNeedle.begin(), ::towlower);
|
||||
return lowerHaystack.find(lowerNeedle) != std::wstring::npos ||
|
||||
lowerNeedle.find(lowerHaystack) != std::wstring::npos;
|
||||
}
|
||||
|
||||
std::wstring normalizeDeviceName(const std::wstring& value) {
|
||||
std::wstring normalized;
|
||||
normalized.reserve(value.size());
|
||||
bool lastWasSpace = true;
|
||||
for (const wchar_t ch : value) {
|
||||
if (std::iswalnum(ch)) {
|
||||
normalized.push_back(static_cast<wchar_t>(std::towlower(ch)));
|
||||
lastWasSpace = false;
|
||||
continue;
|
||||
}
|
||||
if (!lastWasSpace) {
|
||||
normalized.push_back(L' ');
|
||||
lastWasSpace = true;
|
||||
}
|
||||
}
|
||||
while (!normalized.empty() && normalized.back() == L' ') {
|
||||
normalized.pop_back();
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
std::vector<std::wstring> splitWords(const std::wstring& value) {
|
||||
std::vector<std::wstring> words;
|
||||
size_t start = 0;
|
||||
while (start < value.size()) {
|
||||
const size_t end = value.find(L' ', start);
|
||||
const auto word = value.substr(start, end == std::wstring::npos ? std::wstring::npos : end - start);
|
||||
if (word.size() > 1 && word != L"camera" && word != L"webcam" && word != L"video" && word != L"input") {
|
||||
words.push_back(word);
|
||||
}
|
||||
if (end == std::wstring::npos) {
|
||||
break;
|
||||
}
|
||||
start = end + 1;
|
||||
}
|
||||
return words;
|
||||
}
|
||||
|
||||
int deviceMatchScore(
|
||||
const std::wstring& candidateName,
|
||||
const std::wstring& candidateLink,
|
||||
const std::wstring& requestedName,
|
||||
const std::wstring& requestedId) {
|
||||
int score = 0;
|
||||
const auto normalizedName = normalizeDeviceName(candidateName);
|
||||
const auto normalizedLink = normalizeDeviceName(candidateLink);
|
||||
const auto normalizedRequestedName = normalizeDeviceName(requestedName);
|
||||
const auto normalizedRequestedId = normalizeDeviceName(requestedId);
|
||||
|
||||
if (!normalizedRequestedName.empty()) {
|
||||
if (normalizedName == normalizedRequestedName) {
|
||||
score = std::max(score, 1000);
|
||||
}
|
||||
if (containsInsensitive(normalizedName, normalizedRequestedName)) {
|
||||
score = std::max(score, 900);
|
||||
}
|
||||
if (containsInsensitive(normalizedLink, normalizedRequestedName)) {
|
||||
score = std::max(score, 800);
|
||||
}
|
||||
|
||||
int wordScore = 0;
|
||||
for (const auto& word : splitWords(normalizedRequestedName)) {
|
||||
if (normalizedName.find(word) != std::wstring::npos) {
|
||||
wordScore += 100;
|
||||
} else if (normalizedLink.find(word) != std::wstring::npos) {
|
||||
wordScore += 50;
|
||||
}
|
||||
}
|
||||
score = std::max(score, wordScore);
|
||||
}
|
||||
|
||||
if (!normalizedRequestedId.empty()) {
|
||||
if (containsInsensitive(normalizedLink, normalizedRequestedId)) {
|
||||
score = std::max(score, 700);
|
||||
}
|
||||
if (containsInsensitive(normalizedName, normalizedRequestedId)) {
|
||||
score = std::max(score, 600);
|
||||
}
|
||||
}
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
WebcamCapture::~WebcamCapture() {
|
||||
stop();
|
||||
}
|
||||
|
||||
bool WebcamCapture::initialize(
|
||||
const std::wstring& deviceId,
|
||||
const std::wstring& deviceName,
|
||||
const std::wstring& directShowClsid,
|
||||
int requestedWidth,
|
||||
int requestedHeight,
|
||||
int requestedFps) {
|
||||
fps_ = std::clamp(requestedFps > 0 ? requestedFps : 30, 1, 60);
|
||||
usingDirectShow_ = false;
|
||||
selectedMatchScore_ = 0;
|
||||
if (!succeeded(MFStartup(MF_VERSION), "MFStartup(webcam)")) {
|
||||
if (directShowCapture_.initialize(deviceId, deviceName, directShowClsid, requestedWidth, requestedHeight, fps_)) {
|
||||
usingDirectShow_ = true;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
mfStarted_ = true;
|
||||
if (!selectDevice(deviceId, deviceName)) {
|
||||
if (mfStarted_) {
|
||||
MFShutdown();
|
||||
mfStarted_ = false;
|
||||
}
|
||||
if (directShowCapture_.initialize(deviceId, deviceName, directShowClsid, requestedWidth, requestedHeight, fps_)) {
|
||||
usingDirectShow_ = true;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if ((!deviceId.empty() || !deviceName.empty()) && selectedMatchScore_ <= 0) {
|
||||
if (mediaSource_) {
|
||||
mediaSource_->Shutdown();
|
||||
}
|
||||
sourceReader_.Reset();
|
||||
mediaSource_.Reset();
|
||||
if (mfStarted_) {
|
||||
MFShutdown();
|
||||
mfStarted_ = false;
|
||||
}
|
||||
if (directShowCapture_.initialize(deviceId, deviceName, directShowClsid, requestedWidth, requestedHeight, fps_)) {
|
||||
usingDirectShow_ = true;
|
||||
return true;
|
||||
}
|
||||
std::cerr << "ERROR: Requested webcam device was not found by native Windows webcam providers"
|
||||
<< std::endl;
|
||||
return false;
|
||||
}
|
||||
|
||||
return configureReader(requestedWidth, requestedHeight, fps_);
|
||||
}
|
||||
|
||||
bool WebcamCapture::selectDevice(const std::wstring& deviceId, const std::wstring& deviceName) {
|
||||
Microsoft::WRL::ComPtr<IMFAttributes> attributes;
|
||||
if (!succeeded(MFCreateAttributes(&attributes, 1), "MFCreateAttributes(webcam enumeration)")) {
|
||||
return false;
|
||||
}
|
||||
if (!succeeded(attributes->SetGUID(
|
||||
MF_DEVSOURCE_ATTRIBUTE_SOURCE_TYPE,
|
||||
MF_DEVSOURCE_ATTRIBUTE_SOURCE_TYPE_VIDCAP_GUID),
|
||||
"SetGUID(webcam source type)")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
IMFActivate** devices = nullptr;
|
||||
UINT32 deviceCount = 0;
|
||||
HRESULT hr = MFEnumDeviceSources(attributes.Get(), &devices, &deviceCount);
|
||||
if (!succeeded(hr, "MFEnumDeviceSources") || deviceCount == 0) {
|
||||
if (devices) {
|
||||
CoTaskMemFree(devices);
|
||||
}
|
||||
std::cerr << "ERROR: No native Windows webcam devices were found" << std::endl;
|
||||
return false;
|
||||
}
|
||||
|
||||
UINT32 selectedIndex = 0;
|
||||
int bestScore = 0;
|
||||
for (UINT32 index = 0; index < deviceCount; index += 1) {
|
||||
const std::wstring name = readAllocatedString(devices[index], MF_DEVSOURCE_ATTRIBUTE_FRIENDLY_NAME);
|
||||
const std::wstring symbolicLink = readAllocatedString(devices[index], MF_DEVSOURCE_ATTRIBUTE_SOURCE_TYPE_VIDCAP_SYMBOLIC_LINK);
|
||||
const int score = deviceMatchScore(name, symbolicLink, deviceName, deviceId);
|
||||
std::wcerr << L"INFO: Native webcam candidate [" << index << L"] name=\"" << name << L"\" score=" << score << std::endl;
|
||||
if (score > bestScore) {
|
||||
selectedIndex = index;
|
||||
bestScore = score;
|
||||
}
|
||||
}
|
||||
|
||||
if ((!deviceId.empty() || !deviceName.empty()) && bestScore <= 0) {
|
||||
std::cerr << "WARNING: Requested webcam device was not found by Media Foundation; trying DirectShow"
|
||||
<< std::endl;
|
||||
}
|
||||
|
||||
selectedMatchScore_ = bestScore;
|
||||
selectedDeviceName_ = readAllocatedString(devices[selectedIndex], MF_DEVSOURCE_ATTRIBUTE_FRIENDLY_NAME);
|
||||
hr = devices[selectedIndex]->ActivateObject(IID_PPV_ARGS(&mediaSource_));
|
||||
|
||||
for (UINT32 index = 0; index < deviceCount; index += 1) {
|
||||
devices[index]->Release();
|
||||
}
|
||||
CoTaskMemFree(devices);
|
||||
|
||||
return succeeded(hr, "ActivateObject(webcam)");
|
||||
}
|
||||
|
||||
bool WebcamCapture::configureReader(int requestedWidth, int requestedHeight, int requestedFps) {
|
||||
Microsoft::WRL::ComPtr<IMFAttributes> attributes;
|
||||
if (!succeeded(MFCreateAttributes(&attributes, 2), "MFCreateAttributes(webcam reader)")) {
|
||||
return false;
|
||||
}
|
||||
attributes->SetUINT32(MF_SOURCE_READER_ENABLE_VIDEO_PROCESSING, TRUE);
|
||||
attributes->SetUINT32(MF_READWRITE_DISABLE_CONVERTERS, FALSE);
|
||||
|
||||
if (!succeeded(MFCreateSourceReaderFromMediaSource(mediaSource_.Get(), attributes.Get(), &sourceReader_),
|
||||
"MFCreateSourceReaderFromMediaSource(webcam)")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
Microsoft::WRL::ComPtr<IMFMediaType> mediaType;
|
||||
if (!succeeded(MFCreateMediaType(&mediaType), "MFCreateMediaType(webcam output)")) {
|
||||
return false;
|
||||
}
|
||||
mediaType->SetGUID(MF_MT_MAJOR_TYPE, MFMediaType_Video);
|
||||
mediaType->SetGUID(MF_MT_SUBTYPE, MFVideoFormat_RGB32);
|
||||
if (requestedWidth > 0 && requestedHeight > 0) {
|
||||
MFSetAttributeSize(mediaType.Get(), MF_MT_FRAME_SIZE, static_cast<UINT32>(requestedWidth), static_cast<UINT32>(requestedHeight));
|
||||
}
|
||||
MFSetAttributeRatio(mediaType.Get(), MF_MT_FRAME_RATE, static_cast<UINT32>(std::max(1, requestedFps)), 1);
|
||||
|
||||
if (!succeeded(sourceReader_->SetCurrentMediaType(MF_SOURCE_READER_FIRST_VIDEO_STREAM, nullptr, mediaType.Get()),
|
||||
"SetCurrentMediaType(webcam RGB32)")) {
|
||||
return false;
|
||||
}
|
||||
sourceReader_->SetStreamSelection(MF_SOURCE_READER_ALL_STREAMS, FALSE);
|
||||
sourceReader_->SetStreamSelection(MF_SOURCE_READER_FIRST_VIDEO_STREAM, TRUE);
|
||||
|
||||
Microsoft::WRL::ComPtr<IMFMediaType> currentType;
|
||||
if (!succeeded(sourceReader_->GetCurrentMediaType(MF_SOURCE_READER_FIRST_VIDEO_STREAM, ¤tType),
|
||||
"GetCurrentMediaType(webcam)")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
UINT32 width = 0;
|
||||
UINT32 height = 0;
|
||||
if (FAILED(MFGetAttributeSize(currentType.Get(), MF_MT_FRAME_SIZE, &width, &height)) || width == 0 || height == 0) {
|
||||
width = static_cast<UINT32>(requestedWidth > 0 ? requestedWidth : 1280);
|
||||
height = static_cast<UINT32>(requestedHeight > 0 ? requestedHeight : 720);
|
||||
}
|
||||
width_ = static_cast<int>(width);
|
||||
height_ = static_cast<int>(height);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool WebcamCapture::start() {
|
||||
if (usingDirectShow_) {
|
||||
return directShowCapture_.start();
|
||||
}
|
||||
if (!sourceReader_ || thread_.joinable()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
stopRequested_ = false;
|
||||
thread_ = std::thread(&WebcamCapture::captureLoop, this);
|
||||
return true;
|
||||
}
|
||||
|
||||
void WebcamCapture::stop() {
|
||||
directShowCapture_.stop();
|
||||
stopRequested_ = true;
|
||||
if (thread_.joinable()) {
|
||||
thread_.join();
|
||||
}
|
||||
if (mediaSource_) {
|
||||
mediaSource_->Shutdown();
|
||||
}
|
||||
sourceReader_.Reset();
|
||||
mediaSource_.Reset();
|
||||
if (mfStarted_) {
|
||||
MFShutdown();
|
||||
mfStarted_ = false;
|
||||
}
|
||||
}
|
||||
|
||||
void WebcamCapture::captureLoop() {
|
||||
CoInitializeEx(nullptr, COINIT_MULTITHREADED);
|
||||
|
||||
while (!stopRequested_) {
|
||||
DWORD streamIndex = 0;
|
||||
DWORD flags = 0;
|
||||
LONGLONG timestamp = 0;
|
||||
Microsoft::WRL::ComPtr<IMFSample> sample;
|
||||
HRESULT hr = sourceReader_->ReadSample(
|
||||
MF_SOURCE_READER_FIRST_VIDEO_STREAM,
|
||||
0,
|
||||
&streamIndex,
|
||||
&flags,
|
||||
×tamp,
|
||||
&sample);
|
||||
(void)streamIndex;
|
||||
(void)timestamp;
|
||||
|
||||
if (FAILED(hr)) {
|
||||
std::cerr << "WARNING: Failed to read webcam sample (hr=0x" << std::hex << hr << std::dec << ")"
|
||||
<< std::endl;
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(20));
|
||||
continue;
|
||||
}
|
||||
if ((flags & MF_SOURCE_READERF_ENDOFSTREAM) != 0) {
|
||||
break;
|
||||
}
|
||||
if (!sample) {
|
||||
continue;
|
||||
}
|
||||
|
||||
Microsoft::WRL::ComPtr<IMFMediaBuffer> buffer;
|
||||
if (FAILED(sample->ConvertToContiguousBuffer(&buffer)) || !buffer) {
|
||||
continue;
|
||||
}
|
||||
|
||||
BYTE* data = nullptr;
|
||||
DWORD maxLength = 0;
|
||||
DWORD currentLength = 0;
|
||||
if (FAILED(buffer->Lock(&data, &maxLength, ¤tLength)) || !data) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const DWORD expectedLength = static_cast<DWORD>(std::max(0, width_) * std::max(0, height_) * 4);
|
||||
if (currentLength >= expectedLength && expectedLength > 0) {
|
||||
std::scoped_lock lock(frameMutex_);
|
||||
latestFrame_.assign(data, data + expectedLength);
|
||||
}
|
||||
|
||||
buffer->Unlock();
|
||||
}
|
||||
|
||||
CoUninitialize();
|
||||
}
|
||||
|
||||
bool WebcamCapture::copyLatestFrame(std::vector<BYTE>& destination, int& width, int& height) {
|
||||
if (usingDirectShow_) {
|
||||
return directShowCapture_.copyLatestFrame(destination, width, height);
|
||||
}
|
||||
std::scoped_lock lock(frameMutex_);
|
||||
if (latestFrame_.empty() || width_ <= 0 || height_ <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
destination = latestFrame_;
|
||||
width = width_;
|
||||
height = height_;
|
||||
return true;
|
||||
}
|
||||
|
||||
int WebcamCapture::width() const {
|
||||
if (usingDirectShow_) {
|
||||
return directShowCapture_.width();
|
||||
}
|
||||
return width_;
|
||||
}
|
||||
|
||||
int WebcamCapture::height() const {
|
||||
if (usingDirectShow_) {
|
||||
return directShowCapture_.height();
|
||||
}
|
||||
return height_;
|
||||
}
|
||||
|
||||
int WebcamCapture::fps() const {
|
||||
if (usingDirectShow_) {
|
||||
return directShowCapture_.fps();
|
||||
}
|
||||
return fps_;
|
||||
}
|
||||
|
||||
const std::wstring& WebcamCapture::selectedDeviceName() const {
|
||||
if (usingDirectShow_) {
|
||||
return directShowCapture_.selectedDeviceName();
|
||||
}
|
||||
return selectedDeviceName_;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
#pragma once
|
||||
|
||||
#include "dshow_webcam_capture.h"
|
||||
|
||||
#include <Windows.h>
|
||||
#include <mfidl.h>
|
||||
#include <mfreadwrite.h>
|
||||
#include <wrl/client.h>
|
||||
|
||||
#include <atomic>
|
||||
#include <cstdint>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
class WebcamCapture {
|
||||
public:
|
||||
WebcamCapture() = default;
|
||||
~WebcamCapture();
|
||||
|
||||
WebcamCapture(const WebcamCapture&) = delete;
|
||||
WebcamCapture& operator=(const WebcamCapture&) = delete;
|
||||
|
||||
bool initialize(
|
||||
const std::wstring& deviceId,
|
||||
const std::wstring& deviceName,
|
||||
const std::wstring& directShowClsid,
|
||||
int requestedWidth,
|
||||
int requestedHeight,
|
||||
int requestedFps);
|
||||
bool start();
|
||||
void stop();
|
||||
bool copyLatestFrame(std::vector<BYTE>& destination, int& width, int& height);
|
||||
|
||||
int width() const;
|
||||
int height() const;
|
||||
int fps() const;
|
||||
const std::wstring& selectedDeviceName() const;
|
||||
|
||||
private:
|
||||
bool selectDevice(const std::wstring& deviceId, const std::wstring& deviceName);
|
||||
bool configureReader(int requestedWidth, int requestedHeight, int requestedFps);
|
||||
void captureLoop();
|
||||
|
||||
Microsoft::WRL::ComPtr<IMFMediaSource> mediaSource_;
|
||||
Microsoft::WRL::ComPtr<IMFSourceReader> sourceReader_;
|
||||
DirectShowWebcamCapture directShowCapture_;
|
||||
std::thread thread_;
|
||||
std::atomic<bool> stopRequested_ = false;
|
||||
std::mutex frameMutex_;
|
||||
std::vector<BYTE> latestFrame_;
|
||||
int width_ = 0;
|
||||
int height_ = 0;
|
||||
int fps_ = 30;
|
||||
bool mfStarted_ = false;
|
||||
bool usingDirectShow_ = false;
|
||||
int selectedMatchScore_ = 0;
|
||||
std::wstring selectedDeviceName_;
|
||||
};
|
||||
@@ -0,0 +1,276 @@
|
||||
#include "wgc_session.h"
|
||||
|
||||
#include <Windows.Graphics.Capture.Interop.h>
|
||||
#include <dxgi1_2.h>
|
||||
#include <inspectable.h>
|
||||
#include <winrt/base.h>
|
||||
|
||||
#include <iostream>
|
||||
|
||||
namespace wf = winrt::Windows::Foundation;
|
||||
namespace wgcap = winrt::Windows::Graphics::Capture;
|
||||
namespace wgdx = winrt::Windows::Graphics::DirectX;
|
||||
namespace wgd3d = winrt::Windows::Graphics::DirectX::Direct3D11;
|
||||
|
||||
extern "C" HRESULT __stdcall CreateDirect3D11DeviceFromDXGIDevice(
|
||||
::IDXGIDevice* dxgiDevice,
|
||||
::IInspectable** graphicsDevice);
|
||||
|
||||
namespace {
|
||||
|
||||
bool succeeded(HRESULT hr, const char* label) {
|
||||
if (SUCCEEDED(hr)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
std::cerr << "ERROR: " << label << " failed (hr=0x" << std::hex << hr << std::dec << ")"
|
||||
<< std::endl;
|
||||
return false;
|
||||
}
|
||||
|
||||
int64_t timeSpanToHns(wf::TimeSpan const& value) {
|
||||
return value.count();
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
WgcSession::~WgcSession() {
|
||||
stop();
|
||||
}
|
||||
|
||||
bool WgcSession::createD3DDevice() {
|
||||
UINT flags = D3D11_CREATE_DEVICE_BGRA_SUPPORT;
|
||||
#if defined(_DEBUG)
|
||||
flags |= D3D11_CREATE_DEVICE_DEBUG;
|
||||
#endif
|
||||
|
||||
D3D_FEATURE_LEVEL featureLevels[] = {
|
||||
D3D_FEATURE_LEVEL_11_1,
|
||||
D3D_FEATURE_LEVEL_11_0,
|
||||
D3D_FEATURE_LEVEL_10_1,
|
||||
D3D_FEATURE_LEVEL_10_0,
|
||||
};
|
||||
D3D_FEATURE_LEVEL featureLevel{};
|
||||
|
||||
HRESULT hr = D3D11CreateDevice(
|
||||
nullptr,
|
||||
D3D_DRIVER_TYPE_HARDWARE,
|
||||
nullptr,
|
||||
flags,
|
||||
featureLevels,
|
||||
ARRAYSIZE(featureLevels),
|
||||
D3D11_SDK_VERSION,
|
||||
&d3dDevice_,
|
||||
&featureLevel,
|
||||
&d3dContext_);
|
||||
|
||||
#if defined(_DEBUG)
|
||||
if (FAILED(hr)) {
|
||||
flags &= ~D3D11_CREATE_DEVICE_DEBUG;
|
||||
hr = D3D11CreateDevice(
|
||||
nullptr,
|
||||
D3D_DRIVER_TYPE_HARDWARE,
|
||||
nullptr,
|
||||
flags,
|
||||
featureLevels,
|
||||
ARRAYSIZE(featureLevels),
|
||||
D3D11_SDK_VERSION,
|
||||
&d3dDevice_,
|
||||
&featureLevel,
|
||||
&d3dContext_);
|
||||
}
|
||||
#endif
|
||||
|
||||
if (!succeeded(hr, "D3D11CreateDevice")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
Microsoft::WRL::ComPtr<IDXGIDevice> dxgiDevice;
|
||||
if (!succeeded(d3dDevice_.As(&dxgiDevice), "Query IDXGIDevice")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
winrt::com_ptr<::IInspectable> inspectableDevice;
|
||||
if (!succeeded(CreateDirect3D11DeviceFromDXGIDevice(dxgiDevice.Get(), inspectableDevice.put()),
|
||||
"CreateDirect3D11DeviceFromDXGIDevice")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
winrtDevice_ = inspectableDevice.as<wgd3d::IDirect3DDevice>();
|
||||
return true;
|
||||
}
|
||||
|
||||
bool WgcSession::createCaptureItem(HMONITOR monitor) {
|
||||
auto factory = winrt::get_activation_factory<wgcap::GraphicsCaptureItem>();
|
||||
auto interop = factory.as<IGraphicsCaptureItemInterop>();
|
||||
|
||||
wgcap::GraphicsCaptureItem item{nullptr};
|
||||
HRESULT hr = interop->CreateForMonitor(
|
||||
monitor,
|
||||
winrt::guid_of<wgcap::GraphicsCaptureItem>(),
|
||||
reinterpret_cast<void**>(winrt::put_abi(item)));
|
||||
if (!succeeded(hr, "CreateForMonitor")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
item_ = item;
|
||||
const auto size = item_.Size();
|
||||
width_ = static_cast<int>(size.Width);
|
||||
height_ = static_cast<int>(size.Height);
|
||||
return width_ > 0 && height_ > 0;
|
||||
}
|
||||
|
||||
bool WgcSession::createCaptureItem(HWND window) {
|
||||
auto factory = winrt::get_activation_factory<wgcap::GraphicsCaptureItem>();
|
||||
auto interop = factory.as<IGraphicsCaptureItemInterop>();
|
||||
|
||||
wgcap::GraphicsCaptureItem item{nullptr};
|
||||
HRESULT hr = interop->CreateForWindow(
|
||||
window,
|
||||
winrt::guid_of<wgcap::GraphicsCaptureItem>(),
|
||||
reinterpret_cast<void**>(winrt::put_abi(item)));
|
||||
if (!succeeded(hr, "CreateForWindow")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
item_ = item;
|
||||
const auto size = item_.Size();
|
||||
width_ = static_cast<int>(size.Width);
|
||||
height_ = static_cast<int>(size.Height);
|
||||
return width_ > 0 && height_ > 0;
|
||||
}
|
||||
|
||||
void WgcSession::applySessionOptions(bool captureCursor) {
|
||||
try {
|
||||
session_.IsCursorCaptureEnabled(captureCursor);
|
||||
} catch (...) {
|
||||
// Older WGC builds can omit this property. They will keep the OS default.
|
||||
}
|
||||
|
||||
try {
|
||||
session_.IsBorderRequired(false);
|
||||
} catch (...) {
|
||||
// IsBorderRequired is Windows 11-only. Ignore it on older builds.
|
||||
}
|
||||
}
|
||||
|
||||
bool WgcSession::initialize(HMONITOR monitor, int fps, bool captureCursor) {
|
||||
fps_ = fps > 0 ? fps : 60;
|
||||
if (!createD3DDevice()) {
|
||||
return false;
|
||||
}
|
||||
if (!createCaptureItem(monitor)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
framePool_ = wgcap::Direct3D11CaptureFramePool::CreateFreeThreaded(
|
||||
winrtDevice_,
|
||||
wgdx::DirectXPixelFormat::B8G8R8A8UIntNormalized,
|
||||
2,
|
||||
item_.Size());
|
||||
session_ = framePool_.CreateCaptureSession(item_);
|
||||
|
||||
applySessionOptions(captureCursor);
|
||||
|
||||
frameArrivedToken_ = framePool_.FrameArrived({this, &WgcSession::onFrameArrived});
|
||||
return true;
|
||||
}
|
||||
|
||||
bool WgcSession::initialize(HWND window, int fps, bool captureCursor) {
|
||||
fps_ = fps > 0 ? fps : 60;
|
||||
if (!createD3DDevice()) {
|
||||
return false;
|
||||
}
|
||||
if (!createCaptureItem(window)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
framePool_ = wgcap::Direct3D11CaptureFramePool::CreateFreeThreaded(
|
||||
winrtDevice_,
|
||||
wgdx::DirectXPixelFormat::B8G8R8A8UIntNormalized,
|
||||
2,
|
||||
item_.Size());
|
||||
session_ = framePool_.CreateCaptureSession(item_);
|
||||
|
||||
applySessionOptions(captureCursor);
|
||||
|
||||
frameArrivedToken_ = framePool_.FrameArrived({this, &WgcSession::onFrameArrived});
|
||||
return true;
|
||||
}
|
||||
|
||||
void WgcSession::setFrameCallback(FrameCallback callback) {
|
||||
std::scoped_lock lock(callbackMutex_);
|
||||
frameCallback_ = std::move(callback);
|
||||
}
|
||||
|
||||
bool WgcSession::start() {
|
||||
if (!session_) {
|
||||
return false;
|
||||
}
|
||||
session_.StartCapture();
|
||||
started_ = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
void WgcSession::stop() {
|
||||
if (framePool_) {
|
||||
framePool_.FrameArrived(frameArrivedToken_);
|
||||
}
|
||||
if (session_) {
|
||||
session_.Close();
|
||||
session_ = nullptr;
|
||||
}
|
||||
if (framePool_) {
|
||||
framePool_.Close();
|
||||
framePool_ = nullptr;
|
||||
}
|
||||
item_ = nullptr;
|
||||
winrtDevice_ = nullptr;
|
||||
d3dContext_.Reset();
|
||||
d3dDevice_.Reset();
|
||||
started_ = false;
|
||||
}
|
||||
|
||||
void WgcSession::onFrameArrived(
|
||||
wgcap::Direct3D11CaptureFramePool const& sender,
|
||||
wf::IInspectable const&) {
|
||||
auto frame = sender.TryGetNextFrame();
|
||||
if (!frame) {
|
||||
return;
|
||||
}
|
||||
|
||||
auto surface = frame.Surface();
|
||||
auto access = surface.as<::Windows::Graphics::DirectX::Direct3D11::IDirect3DDxgiInterfaceAccess>();
|
||||
Microsoft::WRL::ComPtr<ID3D11Texture2D> texture;
|
||||
HRESULT hr = access->GetInterface(__uuidof(ID3D11Texture2D), reinterpret_cast<void**>(texture.GetAddressOf()));
|
||||
if (FAILED(hr) || !texture) {
|
||||
return;
|
||||
}
|
||||
|
||||
FrameCallback callback;
|
||||
{
|
||||
std::scoped_lock lock(callbackMutex_);
|
||||
callback = frameCallback_;
|
||||
}
|
||||
|
||||
if (callback) {
|
||||
callback(texture.Get(), timeSpanToHns(frame.SystemRelativeTime()));
|
||||
}
|
||||
frame.Close();
|
||||
}
|
||||
|
||||
int WgcSession::captureWidth() const {
|
||||
return width_;
|
||||
}
|
||||
|
||||
int WgcSession::captureHeight() const {
|
||||
return height_;
|
||||
}
|
||||
|
||||
ID3D11Device* WgcSession::device() const {
|
||||
return d3dDevice_.Get();
|
||||
}
|
||||
|
||||
ID3D11DeviceContext* WgcSession::context() const {
|
||||
return d3dContext_.Get();
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
#pragma once
|
||||
|
||||
#include <Windows.h>
|
||||
#include <d3d11.h>
|
||||
#include <windows.graphics.capture.h>
|
||||
#include <windows.graphics.directx.direct3d11.interop.h>
|
||||
#include <winrt/Windows.Foundation.h>
|
||||
#include <winrt/Windows.Graphics.Capture.h>
|
||||
#include <winrt/Windows.Graphics.DirectX.Direct3D11.h>
|
||||
#include <wrl/client.h>
|
||||
|
||||
#include <functional>
|
||||
#include <mutex>
|
||||
|
||||
class WgcSession {
|
||||
public:
|
||||
using FrameCallback = std::function<void(ID3D11Texture2D*, int64_t)>;
|
||||
|
||||
WgcSession() = default;
|
||||
~WgcSession();
|
||||
|
||||
WgcSession(const WgcSession&) = delete;
|
||||
WgcSession& operator=(const WgcSession&) = delete;
|
||||
|
||||
bool initialize(HMONITOR monitor, int fps, bool captureCursor);
|
||||
bool initialize(HWND window, int fps, bool captureCursor);
|
||||
void setFrameCallback(FrameCallback callback);
|
||||
bool start();
|
||||
void stop();
|
||||
|
||||
int captureWidth() const;
|
||||
int captureHeight() const;
|
||||
ID3D11Device* device() const;
|
||||
ID3D11DeviceContext* context() const;
|
||||
|
||||
private:
|
||||
bool createD3DDevice();
|
||||
bool createCaptureItem(HMONITOR monitor);
|
||||
bool createCaptureItem(HWND window);
|
||||
void applySessionOptions(bool captureCursor);
|
||||
void onFrameArrived(
|
||||
winrt::Windows::Graphics::Capture::Direct3D11CaptureFramePool const& sender,
|
||||
winrt::Windows::Foundation::IInspectable const&);
|
||||
|
||||
Microsoft::WRL::ComPtr<ID3D11Device> d3dDevice_;
|
||||
Microsoft::WRL::ComPtr<ID3D11DeviceContext> d3dContext_;
|
||||
winrt::Windows::Graphics::DirectX::Direct3D11::IDirect3DDevice winrtDevice_{nullptr};
|
||||
winrt::Windows::Graphics::Capture::GraphicsCaptureItem item_{nullptr};
|
||||
winrt::Windows::Graphics::Capture::Direct3D11CaptureFramePool framePool_{nullptr};
|
||||
winrt::Windows::Graphics::Capture::GraphicsCaptureSession session_{nullptr};
|
||||
winrt::event_token frameArrivedToken_{};
|
||||
FrameCallback frameCallback_;
|
||||
std::mutex callbackMutex_;
|
||||
int width_ = 0;
|
||||
int height_ = 0;
|
||||
int fps_ = 60;
|
||||
bool started_ = false;
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
import { contextBridge, ipcRenderer } from "electron";
|
||||
import type { NativeWindowsRecordingRequest } from "../src/lib/nativeWindowsRecording";
|
||||
import type { RecordingSession, StoreRecordedSessionInput } from "../src/lib/recordingSession";
|
||||
|
||||
// Asset base URL is passed from the main process via webPreferences.additionalArguments
|
||||
@@ -16,6 +17,9 @@ contextBridge.exposeInMainWorld("electronAPI", {
|
||||
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);
|
||||
},
|
||||
@@ -57,9 +61,21 @@ contextBridge.exposeInMainWorld("electronAPI", {
|
||||
setRecordingState: (recording: boolean, recordingId?: number) => {
|
||||
return ipcRenderer.invoke("set-recording-state", recording, recordingId);
|
||||
},
|
||||
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);
|
||||
},
|
||||
getCursorRecordingData: (videoPath?: string) => {
|
||||
return ipcRenderer.invoke("get-cursor-recording-data", videoPath);
|
||||
},
|
||||
discardCursorTelemetry: (recordingId: number) => {
|
||||
return ipcRenderer.invoke("discard-cursor-telemetry", recordingId);
|
||||
},
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -33,7 +33,7 @@ buildNpmPackage {
|
||||
);
|
||||
};
|
||||
|
||||
npmDepsHash = "sha256-i8QMhvd/ydFPww7qTG3Bz2LOAIFyp65n1NXakr3MTk8=";
|
||||
npmDepsHash = "sha256-tOpoJPzaZDK3HJijGHpZ0+jWsbrYyQUuw1pO0Uxcifg=";
|
||||
|
||||
env.ELECTRON_SKIP_BINARY_DOWNLOAD = "1";
|
||||
|
||||
|
||||
@@ -21,10 +21,19 @@
|
||||
"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: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",
|
||||
"test:cursor-native:win": "node scripts/test-windows-native-cursor.mjs",
|
||||
"build-vite": "tsc && vite build",
|
||||
"test:browser": "vitest --config vitest.browser.config.ts --run",
|
||||
"test:browser:install": "playwright install --with-deps chromium-headless-shell",
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = path.join(__dirname, "..");
|
||||
const SOURCE_DIR = path.join(ROOT, "electron", "native", "wgc-capture");
|
||||
const BUILD_DIR = path.join(SOURCE_DIR, "build");
|
||||
const COMPAT_LIB_DIR = path.join(BUILD_DIR, "compat-libs");
|
||||
const BIN_DIR = path.join(ROOT, "electron", "native", "bin", "win32-x64");
|
||||
const CMAKE = process.env.CMAKE_EXE ?? "cmake";
|
||||
|
||||
function findVcVarsAll() {
|
||||
const explicit = process.env.VCVARSALL;
|
||||
if (explicit && fs.existsSync(explicit)) {
|
||||
return explicit;
|
||||
}
|
||||
|
||||
const roots = [
|
||||
process.env.VSINSTALLDIR,
|
||||
"C:\\Program Files\\Microsoft Visual Studio\\2022\\Community",
|
||||
"C:\\Program Files\\Microsoft Visual Studio\\2022\\Professional",
|
||||
"C:\\Program Files\\Microsoft Visual Studio\\2022\\Enterprise",
|
||||
"C:\\Program Files (x86)\\Microsoft Visual Studio\\2022\\BuildTools",
|
||||
"C:\\Program Files (x86)\\Microsoft Visual Studio\\2022\\Community",
|
||||
];
|
||||
|
||||
for (const root of roots.filter(Boolean)) {
|
||||
const candidate = path.join(root, "VC", "Auxiliary", "Build", "vcvarsall.bat");
|
||||
if (fs.existsSync(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function run(command, args, options = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(command, args, {
|
||||
cwd: ROOT,
|
||||
stdio: "inherit",
|
||||
windowsHide: true,
|
||||
...options,
|
||||
});
|
||||
child.once("error", reject);
|
||||
child.once("exit", (code) => {
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(`${command} ${args.join(" ")} failed with code ${code}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function runInVsEnv(command) {
|
||||
const vcvarsAll = findVcVarsAll();
|
||||
if (!vcvarsAll) {
|
||||
throw new Error(
|
||||
"Could not find Visual Studio vcvarsall.bat. Install Visual Studio Build Tools with C++.",
|
||||
);
|
||||
}
|
||||
|
||||
const cmdPath = path.join(os.tmpdir(), `openscreen-build-wgc-${process.pid}-${Date.now()}.cmd`);
|
||||
fs.writeFileSync(
|
||||
cmdPath,
|
||||
[
|
||||
"@echo off",
|
||||
`call "${vcvarsAll}" x64`,
|
||||
"if errorlevel 1 exit /b %errorlevel%",
|
||||
`if not exist "${COMPAT_LIB_DIR}" mkdir "${COMPAT_LIB_DIR}"`,
|
||||
`for %%L in (gdi32.lib winspool.lib shell32.lib oleaut32.lib uuid.lib comdlg32.lib advapi32.lib) do if not exist "%WindowsSdkDir%Lib\\%WindowsSDKLibVersion%um\\x64\\%%L" copy /Y "%WindowsSdkDir%Lib\\%WindowsSDKLibVersion%um\\x64\\kernel32.Lib" "${COMPAT_LIB_DIR}\\%%L" >nul`,
|
||||
"if errorlevel 1 exit /b %errorlevel%",
|
||||
`set "LIB=${COMPAT_LIB_DIR};%LIB%"`,
|
||||
command,
|
||||
"exit /b %errorlevel%",
|
||||
"",
|
||||
].join("\r\n"),
|
||||
);
|
||||
try {
|
||||
await run("cmd.exe", ["/d", "/c", cmdPath]);
|
||||
} finally {
|
||||
fs.rmSync(cmdPath, { force: true });
|
||||
}
|
||||
}
|
||||
|
||||
if (process.platform !== "win32") {
|
||||
console.log("Skipping WGC helper build: Windows-only.");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
fs.mkdirSync(BUILD_DIR, { recursive: true });
|
||||
|
||||
await runInVsEnv(
|
||||
`"${CMAKE}" -S "${SOURCE_DIR}" -B "${BUILD_DIR}" -G Ninja -DCMAKE_BUILD_TYPE=Release`,
|
||||
);
|
||||
await runInVsEnv(`"${CMAKE}" --build "${BUILD_DIR}" --config Release`);
|
||||
|
||||
const outputPath = path.join(BUILD_DIR, "wgc-capture.exe");
|
||||
if (!fs.existsSync(outputPath)) {
|
||||
throw new Error(`WGC helper build completed but ${outputPath} was not found.`);
|
||||
}
|
||||
|
||||
fs.mkdirSync(BIN_DIR, { recursive: true });
|
||||
const distributablePath = path.join(BIN_DIR, "wgc-capture.exe");
|
||||
fs.copyFileSync(outputPath, distributablePath);
|
||||
|
||||
console.log(`Built ${outputPath}`);
|
||||
console.log(`Copied ${distributablePath}`);
|
||||
@@ -0,0 +1,344 @@
|
||||
import { spawn, spawnSync } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = path.join(__dirname, "..");
|
||||
const HELPER_PATH =
|
||||
process.env.OPENSCREEN_WGC_CAPTURE_EXE ??
|
||||
path.join(ROOT, "electron", "native", "bin", "win32-x64", "wgc-capture.exe");
|
||||
|
||||
const DURATION_MS = Number(process.env.OPENSCREEN_WGC_TEST_DURATION_MS ?? 5000);
|
||||
const WITH_SYSTEM_AUDIO =
|
||||
process.env.OPENSCREEN_WGC_TEST_SYSTEM_AUDIO === "true" ||
|
||||
process.argv.includes("--system-audio");
|
||||
const WITH_MICROPHONE =
|
||||
process.env.OPENSCREEN_WGC_TEST_MICROPHONE === "true" ||
|
||||
process.argv.includes("--microphone") ||
|
||||
process.argv.includes("--mic");
|
||||
const WITH_WINDOW =
|
||||
process.env.OPENSCREEN_WGC_TEST_WINDOW === "true" || process.argv.includes("--window");
|
||||
const WITH_WEBCAM =
|
||||
process.env.OPENSCREEN_WGC_TEST_WEBCAM === "true" || process.argv.includes("--webcam");
|
||||
|
||||
function runHelper(config) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(HELPER_PATH, [JSON.stringify(config)], {
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
windowsHide: true,
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
let stopTimer = null;
|
||||
const scheduleStop = () => {
|
||||
if (stopTimer) {
|
||||
return;
|
||||
}
|
||||
stopTimer = setTimeout(() => {
|
||||
child.stdin.write("stop\n");
|
||||
}, DURATION_MS);
|
||||
};
|
||||
const fallbackTimer = setTimeout(scheduleStop, 15_000);
|
||||
|
||||
child.stdout.on("data", (chunk) => {
|
||||
stdout += chunk.toString();
|
||||
if (stdout.includes('"recording-started"') || stdout.includes("Recording started")) {
|
||||
scheduleStop();
|
||||
}
|
||||
});
|
||||
child.stderr.on("data", (chunk) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
child.once("error", reject);
|
||||
child.once("exit", (code) => {
|
||||
clearTimeout(fallbackTimer);
|
||||
if (stopTimer) {
|
||||
clearTimeout(stopTimer);
|
||||
}
|
||||
resolve({ code, stdout, stderr });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function startFixtureWindow() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn("mspaint.exe", [], {
|
||||
stdio: ["ignore", "ignore", "ignore"],
|
||||
windowsHide: false,
|
||||
});
|
||||
|
||||
const poll = setInterval(() => {
|
||||
const lookup = spawnSync(
|
||||
"powershell",
|
||||
[
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
`(Get-Process -Id ${child.pid} -ErrorAction SilentlyContinue).MainWindowHandle`,
|
||||
],
|
||||
{ encoding: "utf8", windowsHide: true },
|
||||
);
|
||||
const handle = lookup.stdout
|
||||
.trim()
|
||||
.split(/\r?\n/)
|
||||
.find((line) => /^\d+$/.test(line.trim()));
|
||||
if (handle && handle !== "0") {
|
||||
clearInterval(poll);
|
||||
clearTimeout(timer);
|
||||
resolve({ child, sourceId: `window:${handle.trim()}:0` });
|
||||
}
|
||||
}, 250);
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
clearInterval(poll);
|
||||
child.kill();
|
||||
reject(new Error("Timed out waiting for fixture window handle"));
|
||||
}, 10_000);
|
||||
child.once("error", (error) => {
|
||||
clearInterval(poll);
|
||||
clearTimeout(timer);
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeDeviceName(value) {
|
||||
return value
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function scoreDeviceName(candidateName, candidateId, requestedName) {
|
||||
const candidate = normalizeDeviceName(candidateName ?? "");
|
||||
const id = normalizeDeviceName(candidateId ?? "");
|
||||
const requested = normalizeDeviceName(requestedName ?? "");
|
||||
if (!requested) return 0;
|
||||
if (candidate === requested) return 1000;
|
||||
if (candidate.includes(requested) || requested.includes(candidate)) return 900;
|
||||
if (id.includes(requested) || requested.includes(id)) return 800;
|
||||
return requested
|
||||
.split(/\s+/)
|
||||
.filter((word) => word.length > 1 && !["camera", "webcam", "video", "input"].includes(word))
|
||||
.reduce((score, word) => {
|
||||
if (candidate.includes(word)) return score + 100;
|
||||
if (id.includes(word)) return score + 50;
|
||||
return score;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function resolveDirectShowWebcamClsid(requestedName) {
|
||||
if (!requestedName) return "";
|
||||
const query = spawnSync(
|
||||
"reg.exe",
|
||||
["query", "HKCR\\CLSID\\{860BB310-5D01-11D0-BD3B-00A0C911CE86}\\Instance", "/s"],
|
||||
{ encoding: "utf8", windowsHide: true },
|
||||
);
|
||||
if (query.status !== 0) return "";
|
||||
const entries = [];
|
||||
let current = {};
|
||||
for (const rawLine of query.stdout.split(/\r?\n/)) {
|
||||
const line = rawLine.trim();
|
||||
if (!line) continue;
|
||||
if (/^HKEY_/i.test(line)) {
|
||||
if (current.friendlyName || current.clsid) entries.push(current);
|
||||
current = {};
|
||||
continue;
|
||||
}
|
||||
const match = line.match(/^(\S+)\s+REG_SZ\s+(.+)$/);
|
||||
if (!match) continue;
|
||||
if (match[1] === "FriendlyName") current.friendlyName = match[2].trim();
|
||||
if (match[1] === "CLSID") current.clsid = match[2].trim();
|
||||
}
|
||||
if (current.friendlyName || current.clsid) entries.push(current);
|
||||
|
||||
let best = null;
|
||||
for (const entry of entries) {
|
||||
if (!entry.clsid) continue;
|
||||
const score = scoreDeviceName(entry.friendlyName, entry.clsid, requestedName);
|
||||
if (!best || score > best.score) {
|
||||
best = { ...entry, score };
|
||||
}
|
||||
}
|
||||
return best && best.score > 0 ? best.clsid : "";
|
||||
}
|
||||
|
||||
function probeStreams(outputPath) {
|
||||
const ffprobe = spawnSync(
|
||||
"ffprobe",
|
||||
["-v", "error", "-show_streams", "-of", "json", outputPath],
|
||||
{ encoding: "utf8", windowsHide: true },
|
||||
);
|
||||
if (ffprobe.status !== 0) {
|
||||
throw new Error(`ffprobe failed: ${ffprobe.stderr || ffprobe.stdout}`);
|
||||
}
|
||||
return JSON.parse(ffprobe.stdout).streams ?? [];
|
||||
}
|
||||
|
||||
function measureFirstFrameLuma(outputPath) {
|
||||
const ffmpeg = spawnSync(
|
||||
"ffmpeg",
|
||||
[
|
||||
"-v",
|
||||
"error",
|
||||
"-i",
|
||||
outputPath,
|
||||
"-frames:v",
|
||||
"1",
|
||||
"-f",
|
||||
"rawvideo",
|
||||
"-pix_fmt",
|
||||
"gray",
|
||||
"pipe:1",
|
||||
],
|
||||
{ windowsHide: true, maxBuffer: 64 * 1024 * 1024 },
|
||||
);
|
||||
if (ffmpeg.status !== 0) {
|
||||
throw new Error(`ffmpeg frame extraction failed: ${ffmpeg.stderr?.toString() ?? ""}`);
|
||||
}
|
||||
const data = ffmpeg.stdout;
|
||||
if (!data || data.length === 0) {
|
||||
throw new Error(`ffmpeg did not return frame data for ${outputPath}`);
|
||||
}
|
||||
let sum = 0;
|
||||
let max = 0;
|
||||
for (const value of data) {
|
||||
sum += value;
|
||||
if (value > max) {
|
||||
max = value;
|
||||
}
|
||||
}
|
||||
return { average: sum / data.length, max };
|
||||
}
|
||||
|
||||
if (process.platform !== "win32") {
|
||||
console.log("Skipping WGC helper smoke test: Windows-only.");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (!fs.existsSync(HELPER_PATH)) {
|
||||
throw new Error(`WGC helper not found at ${HELPER_PATH}. Run npm run build:native:win first.`);
|
||||
}
|
||||
|
||||
const outputPath = path.join(
|
||||
os.tmpdir(),
|
||||
`openscreen-wgc-helper-${WITH_WEBCAM ? "webcam" : WITH_WINDOW ? "window" : WITH_SYSTEM_AUDIO || WITH_MICROPHONE ? "audio" : "video"}-${process.pid}-${Date.now()}-${randomUUID()}.mp4`,
|
||||
);
|
||||
|
||||
const fixtureWindow = WITH_WINDOW ? await startFixtureWindow() : null;
|
||||
|
||||
const config = {
|
||||
schemaVersion: 2,
|
||||
recordingId: Date.now(),
|
||||
outputPath,
|
||||
sourceType: fixtureWindow ? "window" : "display",
|
||||
sourceId: fixtureWindow ? fixtureWindow.sourceId : "screen:0:0",
|
||||
displayId: 0,
|
||||
fps: 30,
|
||||
videoWidth: 1280,
|
||||
videoHeight: 720,
|
||||
displayX: 0,
|
||||
displayY: 0,
|
||||
displayW: 1920,
|
||||
displayH: 1080,
|
||||
hasDisplayBounds: true,
|
||||
captureSystemAudio: WITH_SYSTEM_AUDIO,
|
||||
captureMic: WITH_MICROPHONE,
|
||||
microphoneDeviceId: process.env.OPENSCREEN_WGC_TEST_MICROPHONE_DEVICE_ID ?? "default",
|
||||
microphoneDeviceName: process.env.OPENSCREEN_WGC_TEST_MICROPHONE_DEVICE_NAME ?? "",
|
||||
microphoneGain: 1.4,
|
||||
webcamEnabled: WITH_WEBCAM,
|
||||
webcamDeviceId: process.env.OPENSCREEN_WGC_TEST_WEBCAM_DEVICE_ID ?? "",
|
||||
webcamDeviceName: process.env.OPENSCREEN_WGC_TEST_WEBCAM_DEVICE_NAME ?? "",
|
||||
webcamDirectShowClsid: resolveDirectShowWebcamClsid(
|
||||
process.env.OPENSCREEN_WGC_TEST_WEBCAM_DEVICE_NAME ?? "",
|
||||
),
|
||||
webcamWidth: 640,
|
||||
webcamHeight: 360,
|
||||
webcamFps: 30,
|
||||
outputs: { screenPath: outputPath },
|
||||
};
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = await runHelper(config);
|
||||
} finally {
|
||||
if (fixtureWindow) {
|
||||
fixtureWindow.child.kill();
|
||||
}
|
||||
}
|
||||
if (result.code !== 0) {
|
||||
if (
|
||||
WITH_WEBCAM &&
|
||||
/No native Windows webcam devices were found|Failed to initialize native webcam/.test(
|
||||
result.stderr,
|
||||
)
|
||||
) {
|
||||
console.log("Skipping WGC webcam smoke test: no native Windows webcam device is available.");
|
||||
process.exit(0);
|
||||
}
|
||||
throw new Error(`WGC helper exited with ${result.code}\n${result.stdout}\n${result.stderr}`);
|
||||
}
|
||||
if (!fs.existsSync(outputPath) || fs.statSync(outputPath).size === 0) {
|
||||
throw new Error(`WGC helper did not produce a video at ${outputPath}`);
|
||||
}
|
||||
|
||||
const streams = probeStreams(outputPath);
|
||||
const hasVideo = streams.some((stream) => stream.codec_type === "video");
|
||||
const hasAudio = streams.some((stream) => stream.codec_type === "audio");
|
||||
const webcamFormatLine = result.stdout
|
||||
.split(/\r?\n/)
|
||||
.find((line) => line.includes('"event":"webcam-format"'));
|
||||
const webcamFormat = webcamFormatLine ? JSON.parse(webcamFormatLine) : null;
|
||||
const audioFormatLine = result.stdout
|
||||
.split(/\r?\n/)
|
||||
.find((line) => line.includes('"event":"audio-format"'));
|
||||
const audioFormat = audioFormatLine ? JSON.parse(audioFormatLine) : null;
|
||||
const nativeWebcamDiagnostics = result.stderr
|
||||
.split(/\r?\n/)
|
||||
.filter((line) => line.includes("Native webcam candidate"));
|
||||
const nativeMicrophoneDiagnostics = result.stderr
|
||||
.split(/\r?\n/)
|
||||
.filter(
|
||||
(line) =>
|
||||
line.includes("Native microphone candidate") ||
|
||||
line.includes("Selected native microphone endpoint"),
|
||||
);
|
||||
if (!hasVideo) {
|
||||
throw new Error(`WGC helper output has no video stream: ${outputPath}`);
|
||||
}
|
||||
if ((WITH_SYSTEM_AUDIO || WITH_MICROPHONE) && !hasAudio) {
|
||||
throw new Error(`WGC helper output has no audio stream: ${outputPath}`);
|
||||
}
|
||||
const frameLuma = measureFirstFrameLuma(outputPath);
|
||||
if (frameLuma.average < 1 && frameLuma.max < 5) {
|
||||
throw new Error(
|
||||
`WGC helper output first frame is black: ${outputPath}\n${result.stdout}\n${result.stderr}`,
|
||||
);
|
||||
}
|
||||
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
success: true,
|
||||
outputPath,
|
||||
bytes: fs.statSync(outputPath).size,
|
||||
streams: streams.map((stream) => ({
|
||||
index: stream.index,
|
||||
codecType: stream.codec_type,
|
||||
codecName: stream.codec_name,
|
||||
duration: stream.duration,
|
||||
})),
|
||||
selectedMicrophoneDeviceName: audioFormat?.microphoneDeviceName,
|
||||
selectedWebcamDeviceName: webcamFormat?.deviceName,
|
||||
nativeMicrophoneDiagnostics,
|
||||
nativeWebcamDiagnostics,
|
||||
firstFrameLuma: frameLuma,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
@@ -0,0 +1,8 @@
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M7.501 2.8601L15.884 11.2611C16.937 12.3171 16.19 14.1191 14.699 14.1191H13.475L14.6908 17.0067C14.9038 17.5127 14.9068 18.0727 14.6998 18.5817C14.4918 19.0917 14.0978 19.4897 13.5898 19.7027C13.3338 19.8097 13.0658 19.8637 12.7918 19.8637C11.9608 19.8637 11.2158 19.3687 10.8938 18.6027L9.616 15.565L8.784 16.3031C7.703 17.2591 6 16.4921 6 15.0481V3.4811C6 2.6971 6.947 2.3051 7.501 2.8601Z" fill="white"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M6.9995 4.1292C6.9995 3.9982 7.1585 3.9322 7.2505 4.0252L15.1585 11.9502C15.5895 12.3822 15.2835 13.1192 14.6735 13.1192H11.9695L13.7691 17.3936C13.9961 17.9336 13.7421 18.5546 13.2031 18.7806C12.6621 19.0076 12.0421 18.7546 11.8161 18.2156L9.9985 13.8917L8.1385 15.5392C7.7225 15.9072 7.0806 15.6507 7.0065 15.1274L6.9995 15.0262V4.1292Z" fill="black"/>
|
||||
<circle cx="22" cy="22" r="7.25" stroke="white" stroke-width="3.5"/>
|
||||
<path d="M22 14.75C23.9228 14.75 25.7669 15.5138 27.1265 16.8735" stroke="black" stroke-width="2.3" stroke-linecap="round"/>
|
||||
<path d="M27.1265 16.8735C28.4862 18.2331 29.25 20.0772 29.25 22" stroke="#2563EB" stroke-width="2.3" stroke-linecap="round"/>
|
||||
<path d="M22 29.25C17.9959 29.25 14.75 26.0041 14.75 22" stroke="black" stroke-width="2.3" stroke-linecap="round" opacity="0.7"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,46 @@
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g filter="url(#filter0_d_1_311)">
|
||||
<path d="M12.0455 7.54446C12.819 7.64044 13.5864 7.88965 14.3038 8.30386C17.1736 9.96071 18.1568 13.6303 16.5 16.5C14.8431 13.6303 11.1736 12.647 8.30384 14.3039C7.58745 14.7175 6.98862 15.2565 6.51907 15.8772C6.74376 12.2206 8.93355 9.09535 12.0455 7.54446Z" fill="url(#paint0_linear_1_311)"/>
|
||||
<path d="M6.51908 15.8772C6.98862 15.2565 7.58745 14.7175 8.30385 14.3038C11.1736 12.647 14.8431 13.6302 16.5 16.5C13.1863 16.5 10.5 19.1863 10.5 22.5C10.5 23.3277 10.6676 24.1162 10.9707 24.8336C8.27601 23.0421 6.5 19.9784 6.5 16.5C6.5 16.2908 6.50642 16.0832 6.51908 15.8772Z" fill="url(#paint1_linear_1_311)"/>
|
||||
<path d="M10.9707 24.8336C10.6676 24.1163 10.5 23.3277 10.5 22.5C10.5 19.1863 13.1863 16.5 16.5 16.5C14.8431 19.3698 15.8264 23.0393 18.6962 24.6962C19.4136 25.1104 20.181 25.3596 20.9545 25.4555C19.6131 26.124 18.1005 26.5 16.5 26.5C14.4556 26.5 12.5545 25.8865 10.9707 24.8336Z" fill="url(#paint2_linear_1_311)"/>
|
||||
<path d="M20.9545 25.4555C20.181 25.3596 19.4136 25.1104 18.6962 24.6962C15.8264 23.0393 14.8432 19.3698 16.5 16.5C18.1569 19.3698 21.8264 20.353 24.6962 18.6962C25.4126 18.2825 26.0114 17.7435 26.4809 17.1228C26.2562 20.7794 24.0665 23.9047 20.9545 25.4555Z" fill="url(#paint3_linear_1_311)"/>
|
||||
<path d="M26.4809 17.1228C26.0114 17.7435 25.4125 18.2825 24.6962 18.6961C21.8264 20.353 18.1569 19.3697 16.5 16.5C19.8137 16.5 22.5 13.8137 22.5 10.5C22.5 9.67229 22.3324 8.88374 22.0293 8.16641C24.724 9.95791 26.5 13.0215 26.5 16.5C26.5 16.7092 26.4936 16.9168 26.4809 17.1228Z" fill="url(#paint4_linear_1_311)"/>
|
||||
<path d="M22.0293 8.16642C22.3324 8.88375 22.5 9.6723 22.5 10.5C22.5 13.8137 19.8137 16.5 16.5 16.5C18.1569 13.6302 17.1736 9.9607 14.3038 8.30385C13.5864 7.88964 12.819 7.64043 12.0455 7.54445C13.3869 6.87599 14.8995 6.5 16.5 6.5C18.5444 6.5 20.4455 7.11349 22.0293 8.16642Z" fill="url(#paint5_linear_1_311)"/>
|
||||
</g>
|
||||
<defs>
|
||||
<filter id="filter0_d_1_311" x="4.5" y="5.5" width="24" height="24" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dy="1"/>
|
||||
<feGaussianBlur stdDeviation="1"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.4049 0"/>
|
||||
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_1_311"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_1_311" result="shape"/>
|
||||
</filter>
|
||||
<linearGradient id="paint0_linear_1_311" x1="545.808" y1="7.54446" x2="545.808" y2="903.099" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#FFD305" style="stop-color:#FFD305;stop-color:color(display-p3 1.0000 0.8275 0.0196);stop-opacity:1;"/>
|
||||
<stop offset="1" stop-color="#FDCF01" style="stop-color:#FDCF01;stop-color:color(display-p3 0.9922 0.8118 0.0039);stop-opacity:1;"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint1_linear_1_311" x1="506.5" y1="13.499" x2="506.5" y2="1146.96" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#52CF30" style="stop-color:#52CF30;stop-color:color(display-p3 0.3216 0.8118 0.1882);stop-opacity:1;"/>
|
||||
<stop offset="1" stop-color="#3BBD1C" style="stop-color:#3BBD1C;stop-color:color(display-p3 0.2314 0.7412 0.1098);stop-opacity:1;"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint2_linear_1_311" x1="533.223" y1="16.5" x2="533.223" y2="1016.5" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#14ADF6" style="stop-color:#14ADF6;stop-color:color(display-p3 0.0784 0.6784 0.9647);stop-opacity:1;"/>
|
||||
<stop offset="1" stop-color="#1191F4" style="stop-color:#1191F4;stop-color:color(display-p3 0.0667 0.5686 0.9569);stop-opacity:1;"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint3_linear_1_311" x1="554.984" y1="16.5" x2="554.984" y2="912.055" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#CA70E1" style="stop-color:#CA70E1;stop-color:color(display-p3 0.7922 0.4392 0.8824);stop-opacity:1;"/>
|
||||
<stop offset="1" stop-color="#B452CB" style="stop-color:#B452CB;stop-color:color(display-p3 0.7059 0.3216 0.7961);stop-opacity:1;"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint4_linear_1_311" x1="516.5" y1="8.16641" x2="516.5" y2="1141.62" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#FF645D" style="stop-color:#FF645D;stop-color:color(display-p3 1.0000 0.3922 0.3647);stop-opacity:1;"/>
|
||||
<stop offset="1" stop-color="#FF4332" style="stop-color:#FF4332;stop-color:color(display-p3 1.0000 0.2627 0.1961);stop-opacity:1;"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint5_linear_1_311" x1="534.769" y1="6.5" x2="534.769" y2="1006.5" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#FBB114" style="stop-color:#FBB114;stop-color:color(display-p3 0.9843 0.6941 0.0784);stop-opacity:1;"/>
|
||||
<stop offset="1" stop-color="#FF9508" style="stop-color:#FF9508;stop-color:color(display-p3 1.0000 0.5843 0.0314);stop-opacity:1;"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
@@ -0,0 +1,5 @@
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M16.5 9C17.328 9 18 9.672 18 10.5V15H22.5C23.2793 15 23.9204 15.5953 23.9931 16.3556L24 16.5C24 17.328 23.328 18 22.5 18H18V22.5C18 23.2793 17.4047 23.9204 16.6444 23.9931L16.5 24C15.672 24 15 23.328 15 22.5V18H10.5C9.72071 18 9.0796 17.4047 9.00687 16.6444L9 16.5C9 15.672 9.672 15 10.5 15H15V10.5C15 9.72071 15.5953 9.0796 16.3556 9.00687L16.5 9Z" fill="white" style="fill:white;fill-opacity:1;"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M16.5 10C16.776 10 17 10.224 17 10.5V16H22.5C22.7453 16 22.9496 16.177 22.9919 16.4102L23 16.5C23 16.776 22.776 17 22.5 17H17V22.5C17 22.7453 16.823 22.9496 16.5898 22.9919L16.5 23C16.224 23 16 22.776 16 22.5V17H10.5C10.2547 17 10.0504 16.823 10.0081 16.5898L10 16.5C10 16.224 10.224 16 10.5 16H16V10.5C16 10.2547 16.177 10.0504 16.4102 10.0081L16.5 10Z" fill="#232020" style="fill:#232020;fill:color(display-p3 0.1373 0.1255 0.1255);fill-opacity:1;"/>
|
||||
</svg>
|
||||
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1,5 @@
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M16.501 13.8601L24.884 22.2611C25.937 23.3171 25.19 25.1191 23.699 25.1191L22.475 25.119L23.6908 28.0067C23.9038 28.5127 23.9068 29.0727 23.6998 29.5817C23.4918 30.0917 23.0978 30.4897 22.5898 30.7027C22.3338 30.8097 22.0658 30.8637 21.7918 30.8637C20.9608 30.8637 20.2158 30.3687 19.8938 29.6027L18.616 26.565L17.784 27.3031C16.703 28.2591 15 27.4921 15 26.0481V14.4811C15 13.6971 15.947 13.3051 16.501 13.8601Z" fill="white" style="fill:white;fill-opacity:1;"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M15.9995 15.1292C15.9995 14.9982 16.1585 14.9322 16.2505 15.0252L24.1585 22.9502C24.5895 23.3822 24.2835 24.1192 23.6735 24.1192L20.9695 24.1177L22.7691 28.3936C22.9961 28.9336 22.7421 29.5546 22.2031 29.7806C21.6621 30.0076 21.0421 29.7546 20.8161 29.2156L18.9985 24.8917L17.1385 26.5392C16.7225 26.9072 16.0806 26.6507 16.0065 26.1274L15.9995 26.0262V15.1292Z" fill="black" style="fill:black;fill-opacity:1;"/>
|
||||
</svg>
|
||||
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,5 @@
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M23.7191 13.556C23.3671 13.299 22.9751 13.199 22.5421 13.258C22.3341 13.286 22.1621 13.347 22.0191 13.432C22.0241 13.273 22.0121 13.122 21.9761 12.98C21.9011 12.683 21.7611 12.439 21.5561 12.247C21.3511 12.055 21.1051 11.917 20.8181 11.832C20.5581 11.754 20.2931 11.731 20.0231 11.764C19.7521 11.797 19.5101 11.889 19.2951 12.042C19.2251 12.092 19.1731 12.156 19.1171 12.217C19.0781 12.148 19.0421 12.077 18.9871 12.013C18.8211 11.821 18.6101 11.673 18.3531 11.569C18.0961 11.465 17.8231 11.412 17.5371 11.412C17.2441 11.412 16.9681 11.465 16.7121 11.569C16.4551 11.673 16.2431 11.821 16.0771 12.013C16.0231 12.075 15.9891 12.145 15.9491 12.213C15.9121 12.171 15.8811 12.123 15.8381 12.086C15.6621 11.936 15.4551 11.834 15.2171 11.778C14.9801 11.723 14.7411 11.715 14.5001 11.754C14.1941 11.799 13.9191 11.914 13.6741 12.096C13.4301 12.278 13.2501 12.524 13.1321 12.833C13.0231 13.122 13.0101 13.464 13.0771 13.847C12.5071 13.777 11.9771 13.943 11.4921 14.356C10.9911 14.783 10.7431 15.455 10.7501 16.373C10.7561 17.857 11.0381 19.175 11.5941 20.328C12.1511 21.48 12.9341 22.381 13.9431 23.028C14.9521 23.676 16.1211 24 17.4491 24C18.6601 24 19.6951 23.787 20.5551 23.36C21.4141 22.934 22.1101 22.317 22.6401 21.509C23.1701 20.703 23.5561 19.703 23.7971 18.511C23.9271 17.881 24.0291 17.261 24.1051 16.656C24.1801 16.05 24.2241 15.41 24.2361 14.732C24.2431 14.205 24.0701 13.813 23.7191 13.556Z" fill="black" style="fill:black;fill-opacity:1;"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M13.2635 14.9171L13.5095 16.1871C13.5555 16.4281 13.6755 16.5821 13.8705 16.6461C14.0665 16.7121 14.2435 16.6801 14.4035 16.5531C14.5625 16.4261 14.6165 16.2461 14.5645 16.0111L14.0565 13.5801C13.9975 13.3261 14.0715 13.1211 14.2765 12.9651C14.4815 12.8091 14.7085 12.7501 14.9595 12.7891C15.2105 12.8281 15.3715 12.9751 15.4435 13.2291L15.5895 13.7561C15.6415 13.9581 15.7635 14.0851 15.9555 14.1371C16.1485 14.1891 16.3315 14.1661 16.5075 14.0691C16.6835 13.9711 16.7715 13.8281 16.7715 13.6391V12.9651C16.7715 12.7891 16.8445 12.6481 16.9915 12.5401C17.1375 12.4331 17.3155 12.3791 17.5235 12.3791C17.7255 12.3791 17.8995 12.4331 18.0465 12.5401C18.1925 12.6481 18.2655 12.7891 18.2655 12.9651V13.6391C18.2655 13.8211 18.3535 13.9631 18.5295 14.0641C18.7055 14.1651 18.8885 14.1891 19.0815 14.1371C19.2735 14.0851 19.3955 13.9581 19.4475 13.7561L19.6035 13.2091C19.6695 12.9681 19.8255 12.8281 20.0725 12.7891C20.3195 12.7501 20.5465 12.8091 20.7515 12.9651C20.9565 13.1211 21.0325 13.3261 20.9805 13.5801L20.8245 14.4591C20.7785 14.6931 20.8245 14.8761 20.9615 15.0061C21.0975 15.1361 21.2635 15.1981 21.4595 15.1911C21.6545 15.1851 21.8015 15.1131 21.8985 14.9761L22.1525 14.5661C22.2435 14.4291 22.3795 14.3501 22.5575 14.3271C22.7365 14.3041 22.8975 14.3411 23.0415 14.4391C23.1845 14.5371 23.2565 14.6771 23.2565 14.8591C23.2565 15.4001 23.2175 15.9181 23.1385 16.4121C23.0605 16.9071 22.9465 17.4761 22.7975 18.1211L22.7385 18.3941C22.5115 19.4101 22.1845 20.2471 21.7625 20.9041C21.3395 21.5621 20.7775 22.0581 20.0775 22.3931C19.3775 22.7291 18.5125 22.8961 17.4845 22.8961C16.3445 22.8961 15.3455 22.6171 14.4865 22.0561C13.6265 21.4971 12.9655 20.7171 12.5035 19.7171C12.0415 18.7181 11.8105 17.5811 11.8105 16.3041C11.8105 15.8491 11.9095 15.4991 12.1085 15.2551C12.3065 15.0101 12.5445 14.8781 12.8215 14.8541C12.9845 14.8411 13.1285 14.8681 13.2635 14.9171Z" fill="white" style="fill:white;fill-opacity:1;"/>
|
||||
</svg>
|
||||
|
||||
|
After Width: | Height: | Size: 3.5 KiB |
@@ -0,0 +1,5 @@
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M17.5908 23.4186C16.6578 23.4186 15.8038 23.2506 15.0528 22.9196C14.2958 22.5856 13.6148 22.0696 13.0298 21.3876C12.4598 20.7226 11.9558 19.8716 11.5308 18.8596L9.61877 14.1546C9.47777 13.8136 9.46177 13.4866 9.57177 13.1866C9.69177 12.8636 9.92977 12.6346 10.2618 12.5236C10.3818 12.4806 10.5158 12.4556 10.6488 12.4556C10.8128 12.4556 10.9718 12.4926 11.1228 12.5656C11.3728 12.6876 11.5798 12.9046 11.7388 13.2116L13.3258 16.0706C13.4298 16.2506 13.5078 16.3166 13.5358 16.3366C13.5788 16.3656 13.6208 16.3776 13.6768 16.3776C13.7438 16.3776 13.7848 16.3586 13.8308 16.3066C13.8498 16.2846 13.8778 16.1896 13.8428 15.9836L12.7408 8.9466C12.6648 8.5056 12.7988 8.1986 12.9258 8.0206C13.1068 7.7656 13.3658 7.6026 13.6738 7.5486C13.7518 7.5386 13.8058 7.5346 13.8598 7.5346C14.1098 7.5346 14.3428 7.6086 14.5508 7.7526C14.8108 7.9326 14.9818 8.2066 15.0438 8.5456L16.1318 13.6776L16.1918 7.6486C16.1918 7.3076 16.3048 7.0056 16.5208 6.7776C16.7428 6.5416 17.0408 6.4166 17.3798 6.4166C17.7218 6.4166 18.0238 6.5376 18.2528 6.7676C18.4828 6.9966 18.6038 7.3026 18.6038 7.6516L18.5688 13.7016L19.5158 8.4556C19.5638 8.1296 19.7268 7.8486 19.9838 7.6576C20.1908 7.5036 20.4218 7.4256 20.6708 7.4256C20.7368 7.4256 20.8038 7.43159 20.8718 7.4426C21.1958 7.49259 21.4728 7.6556 21.6628 7.9116C21.7988 8.0936 21.9448 8.4066 21.8648 8.8606L20.9988 14.1766L22.2198 10.7096C22.3198 10.3946 22.4928 10.1486 22.7298 9.9916C22.9248 9.8616 23.1408 9.7956 23.3718 9.7956C23.4278 9.7956 23.4838 9.7996 23.5408 9.8076C23.8708 9.8646 24.1278 10.0286 24.3018 10.2856C24.4698 10.5356 24.5298 10.8336 24.4808 11.1696L23.3418 17.7446C23.0218 19.5926 22.4068 21.0076 21.5138 21.9506C20.5928 22.9246 19.2728 23.4186 17.5908 23.4186Z" fill="white" style="fill:white;fill-opacity:1;"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M22.8523 17.66L23.9893 11.097C24.0203 10.886 23.9873 10.708 23.8893 10.564C23.7923 10.419 23.6533 10.331 23.4733 10.3C23.3013 10.276 23.1453 10.312 23.0053 10.405C22.8643 10.499 22.7583 10.655 22.6883 10.874L21.4933 14.273C21.4383 14.422 21.3623 14.527 21.2643 14.589C21.1673 14.652 21.0513 14.672 20.9193 14.648C20.7943 14.625 20.6903 14.56 20.6083 14.455C20.5263 14.349 20.4933 14.23 20.5083 14.097L21.3763 8.776C21.4153 8.55 21.3783 8.36 21.2643 8.208C21.1513 8.056 20.9933 7.964 20.7903 7.933C20.6023 7.901 20.4323 7.942 20.2803 8.056C20.1283 8.169 20.0363 8.331 20.0053 8.542L19.0673 13.851C19.0443 13.984 18.9833 14.087 18.8853 14.161C18.7883 14.236 18.6693 14.261 18.5283 14.238C18.3723 14.214 18.2573 14.155 18.1823 14.062C18.1083 13.968 18.0713 13.847 18.0713 13.698L18.1063 7.651C18.1063 7.433 18.0383 7.255 17.9013 7.118C17.7643 6.982 17.5903 6.913 17.3803 6.913C17.1763 6.913 17.0103 6.982 16.8823 7.118C16.7533 7.255 16.6883 7.433 16.6883 7.651L16.6533 13.745C16.6533 13.886 16.6043 14.003 16.5073 14.097C16.4093 14.191 16.2943 14.238 16.1613 14.238C16.0283 14.238 15.9173 14.196 15.8273 14.114C15.7373 14.032 15.6763 13.921 15.6453 13.78L14.5553 8.636C14.5163 8.425 14.4213 8.267 14.2683 8.161C14.1163 8.056 13.9463 8.015 13.7583 8.038C13.5793 8.069 13.4363 8.159 13.3313 8.308C13.2253 8.456 13.1923 8.644 13.2313 8.87L14.3333 15.902C14.3873 16.23 14.3443 16.474 14.2043 16.634C14.0633 16.795 13.8873 16.875 13.6763 16.875C13.5203 16.875 13.3803 16.832 13.2553 16.746C13.1303 16.66 13.0083 16.515 12.8913 16.312L11.2983 13.441C11.1883 13.23 11.0573 13.087 10.9053 13.013C10.7533 12.939 10.5903 12.933 10.4193 12.995C10.2313 13.058 10.1043 13.179 10.0383 13.359C9.97126 13.538 9.98526 13.741 10.0793 13.968L11.9893 18.668C12.3953 19.636 12.8683 20.435 13.4073 21.064C13.9463 21.693 14.5613 22.16 15.2533 22.464C15.9443 22.769 16.7233 22.921 17.5903 22.921C19.1373 22.921 20.3253 22.484 21.1533 21.609C21.9813 20.734 22.5483 19.418 22.8523 17.66ZM11.0283 19.007L9.16526 14.414C9.03226 14.085 8.97926 13.773 9.00726 13.476C9.03426 13.179 9.12826 12.921 9.28826 12.702C9.44826 12.484 9.64926 12.312 9.89126 12.187C10.1333 12.069 10.3973 12.015 10.6823 12.023C10.9673 12.03 11.2433 12.112 11.5083 12.269C11.7743 12.425 11.9973 12.663 12.1763 12.984L13.1023 14.754C13.1263 14.8 13.1573 14.82 13.1963 14.812C13.2353 14.804 13.2513 14.773 13.2433 14.718L12.2473 9.046C12.1763 8.679 12.1923 8.351 12.2943 8.062C12.3953 7.773 12.5593 7.538 12.7863 7.359C13.0123 7.179 13.2743 7.066 13.5713 7.019C13.8683 6.972 14.1533 7.001 14.4263 7.107C14.7003 7.212 14.9343 7.386 15.1303 7.628C15.3253 7.87 15.4623 8.171 15.5403 8.53L15.6803 9.292V7.651C15.6803 7.136 15.8413 6.714 16.1613 6.386C16.4813 6.058 16.8873 5.894 17.3803 5.894C17.8953 5.894 18.3113 6.062 18.6283 6.398C18.9443 6.734 19.1023 7.167 19.1023 7.698L19.0793 8.202C19.1883 7.734 19.4173 7.384 19.7643 7.153C20.1123 6.923 20.5013 6.843 20.9303 6.913C21.2273 6.96 21.4913 7.073 21.7213 7.253C21.9523 7.433 22.1243 7.665 22.2373 7.95C22.3503 8.235 22.3833 8.562 22.3373 8.929L22.2373 9.85652C22.2179 9.76817 22.3253 9.65233 22.5593 9.509C22.9113 9.294 23.2983 9.222 23.7193 9.292C23.9933 9.347 24.2333 9.458 24.4403 9.626C24.6473 9.794 24.7993 10.019 24.8973 10.3C24.9953 10.581 25.0123 10.901 24.9503 11.261L23.8953 17.765C23.5673 19.789 22.8833 21.334 21.8443 22.4C20.8053 23.466 19.3763 24 17.5553 24C16.0163 24 14.7163 23.58 13.6533 22.74C12.5903 21.9 11.7153 20.656 11.0283 19.007Z" fill="black" style="fill:black;fill-opacity:1;"/>
|
||||
</svg>
|
||||
|
||||
|
After Width: | Height: | Size: 5.3 KiB |
@@ -0,0 +1,5 @@
|
||||
<svg width="32" height="33" viewBox="0 0 32 33" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M19.301 31.942C20.359 31.942 21.279 31.722 22.059 31.281C22.841 30.84 23.445 30.195 23.87 29.343C24.296 28.491 24.509 27.449 24.509 26.214V24.564C24.509 23.797 24.389 23.208 24.149 22.794C23.909 22.381 23.563 22.174 23.114 22.174V23.268C23.114 23.42 23.063 23.542 22.963 23.633C22.862 23.724 22.749 23.769 22.621 23.769C22.487 23.769 22.37 23.724 22.27 23.633C22.169 23.542 22.119 23.42 22.119 23.268V21.909C22.119 21.636 22.049 21.424 21.91 21.275C21.769 21.126 21.572 21.052 21.316 21.052C21.116 21.052 20.918 21.095 20.724 21.18V22.913C20.724 23.071 20.673 23.195 20.573 23.287C20.472 23.378 20.359 23.423 20.231 23.423C20.097 23.423 19.98 23.378 19.88 23.287C19.779 23.195 19.729 23.071 19.729 22.913V20.906C19.729 20.639 19.658 20.427 19.515 20.272C19.372 20.117 19.179 20.039 18.936 20.039C18.729 20.039 18.528 20.085 18.334 20.176V22.566C18.334 22.706 18.287 22.825 18.192 22.922C18.098 23.019 17.978 23.068 17.832 23.068C17.692 23.068 17.575 23.019 17.481 22.922C17.387 22.825 17.339 22.706 17.339 22.566V15.981C17.339 15.756 17.277 15.575 17.152 15.438C17.028 15.301 16.859 15.233 16.646 15.233C16.433 15.233 16.262 15.301 16.131 15.438C16 15.575 15.935 15.756 15.935 15.981V25.257C15.935 25.452 15.877 25.611 15.762 25.736C15.646 25.86 15.5 25.922 15.324 25.922C15.165 25.922 15.024 25.883 14.9 25.804C14.775 25.725 14.67 25.579 14.585 25.366L13.363 22.575C13.211 22.21 12.982 22.028 12.679 22.028C12.484 22.028 12.326 22.09 12.204 22.215C12.082 22.339 12.022 22.49 12.022 22.666C12.022 22.812 12.049 22.958 12.104 23.104L13.737 27.701C14.271 29.191 15.014 30.269 15.962 30.939C16.911 31.608 18.024 31.942 19.301 31.942ZM19.265 33C17.707 33 16.383 32.583 15.292 31.751C14.2 30.918 13.366 29.68 12.788 28.038L11.155 23.432C11.106 23.287 11.069 23.135 11.041 22.976C11.014 22.818 11 22.675 11 22.548C11 22.073 11.158 21.706 11.475 21.444C11.79 21.183 12.162 21.052 12.587 21.052C12.898 21.052 13.177 21.142 13.427 21.321C13.676 21.5 13.876 21.77 14.029 22.128L14.749 23.907C14.767 23.949 14.795 23.97 14.831 23.97C14.88 23.97 14.904 23.943 14.904 23.889V16.044C14.904 15.491 15.068 15.052 15.397 14.726C15.725 14.401 16.141 14.238 16.646 14.238C17.145 14.238 17.557 14.401 17.882 14.726C18.207 15.052 18.37 15.491 18.37 16.044V19.155C18.607 19.088 18.838 19.054 19.064 19.054C19.453 19.054 19.784 19.156 20.058 19.36C20.331 19.564 20.523 19.842 20.633 20.195C20.912 20.098 21.186 20.049 21.454 20.049C21.83 20.049 22.148 20.144 22.406 20.336C22.665 20.527 22.843 20.787 22.94 21.116C23.766 21.122 24.407 21.414 24.86 21.991C25.313 22.569 25.54 23.381 25.54 24.426V26.333C25.54 27.743 25.275 28.946 24.746 29.94C24.217 30.935 23.481 31.692 22.539 32.216C21.596 32.739 20.505 33 19.265 33Z" fill="black" style="fill:black;fill-opacity:1;"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M19.301 31.942C20.359 31.942 21.279 31.722 22.059 31.281C22.841 30.84 23.445 30.195 23.87 29.343C24.296 28.491 24.509 27.449 24.509 26.214V24.564C24.509 23.797 24.389 23.208 24.149 22.794C23.909 22.381 23.563 22.174 23.114 22.174V23.268C23.114 23.42 23.063 23.542 22.963 23.633C22.862 23.724 22.749 23.769 22.621 23.769C22.487 23.769 22.37 23.724 22.27 23.633C22.169 23.542 22.119 23.42 22.119 23.268V21.909C22.119 21.636 22.049 21.424 21.91 21.275C21.769 21.126 21.572 21.052 21.316 21.052C21.116 21.052 20.918 21.095 20.724 21.18V22.913C20.724 23.071 20.673 23.195 20.573 23.287C20.472 23.378 20.359 23.423 20.231 23.423C20.097 23.423 19.98 23.378 19.88 23.287C19.779 23.195 19.729 23.071 19.729 22.913V20.906C19.729 20.639 19.658 20.427 19.515 20.272C19.372 20.117 19.179 20.039 18.936 20.039C18.729 20.039 18.528 20.085 18.334 20.176V22.566C18.334 22.706 18.287 22.825 18.192 22.922C18.098 23.019 17.978 23.068 17.832 23.068C17.692 23.068 17.575 23.019 17.481 22.922C17.387 22.825 17.339 22.706 17.339 22.566V15.981C17.339 15.756 17.277 15.575 17.152 15.438C17.028 15.301 16.859 15.233 16.646 15.233C16.433 15.233 16.262 15.301 16.131 15.438C16 15.575 15.935 15.756 15.935 15.981V25.257C15.935 25.452 15.877 25.611 15.762 25.736C15.646 25.86 15.5 25.922 15.324 25.922C15.165 25.922 15.024 25.883 14.9 25.804C14.775 25.725 14.67 25.579 14.585 25.366L13.363 22.575C13.211 22.21 12.982 22.028 12.679 22.028C12.484 22.028 12.326 22.09 12.204 22.215C12.082 22.339 12.022 22.49 12.022 22.666C12.022 22.812 12.049 22.958 12.104 23.104L13.737 27.701C14.271 29.191 15.014 30.269 15.962 30.939C16.911 31.608 18.024 31.942 19.301 31.942Z" fill="white" style="fill:white;fill-opacity:1;"/>
|
||||
</svg>
|
||||
|
||||
|
After Width: | Height: | Size: 4.5 KiB |
@@ -0,0 +1,8 @@
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M7.501 2.8601L15.884 11.2611C16.937 12.3171 16.19 14.1191 14.699 14.1191H13.475L14.6908 17.0067C14.9038 17.5127 14.9068 18.0727 14.6998 18.5817C14.4918 19.0917 14.0978 19.4897 13.5898 19.7027C13.3338 19.8097 13.0658 19.8637 12.7918 19.8637C11.9608 19.8637 11.2158 19.3687 10.8938 18.6027L9.616 15.565L8.784 16.3031C7.703 17.2591 6 16.4921 6 15.0481V3.4811C6 2.6971 6.947 2.3051 7.501 2.8601Z" fill="white"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M6.9995 4.1292C6.9995 3.9982 7.1585 3.9322 7.2505 4.0252L15.1585 11.9502C15.5895 12.3822 15.2835 13.1192 14.6735 13.1192H11.9695L13.7691 17.3936C13.9961 17.9336 13.7421 18.5546 13.2031 18.7806C12.6621 19.0076 12.0421 18.7546 11.8161 18.2156L9.9985 13.8917L8.1385 15.5392C7.7225 15.9072 7.0806 15.6507 7.0065 15.1274L6.9995 15.0262V4.1292Z" fill="black"/>
|
||||
<circle cx="23" cy="22" r="7" fill="white"/>
|
||||
<path d="M23 28.25C26.4518 28.25 29.25 25.4518 29.25 22C29.25 18.5482 26.4518 15.75 23 15.75C19.5482 15.75 16.75 18.5482 16.75 22C16.75 25.4518 19.5482 28.25 23 28.25Z" stroke="black" stroke-width="1.8"/>
|
||||
<path d="M20.9 20.1C21.05 18.85 21.85 18.15 23.1 18.15C24.4 18.15 25.25 18.95 25.25 20.05C25.25 21.05 24.7 21.55 23.9 22.05C23.25 22.45 23 22.85 23 23.65" stroke="#2563EB" stroke-width="1.9" stroke-linecap="round"/>
|
||||
<circle cx="23" cy="25.8" r="1.15" fill="#2563EB"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,18 @@
|
||||
<svg width="32" height="43" viewBox="0 0 32 43" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_1_302)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M16.501 13.8601L24.884 22.2611C25.937 23.3171 25.19 25.1191 23.699 25.1191L20.252 25.1181L17.784 27.3031C16.703 28.2591 15 27.4921 15 26.0481V14.4811C15 13.6971 15.947 13.3051 16.501 13.8601Z" fill="white" style="fill:white;fill-opacity:1;"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M15.9995 15.1292V26.0262C15.9995 26.6162 16.6965 26.9302 17.1385 26.5392L19.8735 24.1182L23.6735 24.1192C24.2835 24.1192 24.5895 23.3822 24.1585 22.9502L16.2505 15.0252C16.1585 14.9322 15.9995 14.9982 15.9995 15.1292Z" fill="black" style="fill:black;fill-opacity:1;"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M29.8888 42.1186H22.1098C20.9448 42.1186 19.9998 41.1736 19.9998 40.0076V29.2296C19.9998 28.0636 20.9448 27.1186 22.1098 27.1186H29.8888C31.0548 27.1186 31.9998 28.0636 31.9998 29.2296V40.0076C31.9998 41.1736 31.0548 42.1186 29.8888 42.1186Z" fill="white" style="fill:white;fill-opacity:1;"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M29.7495 41.1186H22.2495C21.5595 41.1186 20.9995 40.5586 20.9995 39.8686V29.3686C20.9995 28.6786 21.5595 28.1186 22.2495 28.1186H29.7495C30.4395 28.1186 30.9995 28.6786 30.9995 29.3686V39.8686C30.9995 40.5586 30.4395 41.1186 29.7495 41.1186Z" fill="black" style="fill:black;fill-opacity:1;"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M28.4995 31.1186H23.4995C23.2235 31.1186 22.9995 30.8946 22.9995 30.6186C22.9995 30.3426 23.2235 30.1186 23.4995 30.1186H28.4995C28.7755 30.1186 28.9995 30.3426 28.9995 30.6186C28.9995 30.8946 28.7755 31.1186 28.4995 31.1186Z" fill="white" style="fill:white;fill-opacity:1;"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M28.4995 39.1186H23.4995C23.2235 39.1186 22.9995 38.8946 22.9995 38.6186C22.9995 38.3426 23.2235 38.1186 23.4995 38.1186H28.4995C28.7755 38.1186 28.9995 38.3426 28.9995 38.6186C28.9995 38.8946 28.7755 39.1186 28.4995 39.1186Z" fill="white" style="fill:white;fill-opacity:1;"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M29.0608 36.1186H22.9378C22.4198 36.1186 21.9998 35.6986 21.9998 35.1796V34.0576C21.9998 33.5386 22.4198 33.1186 22.9378 33.1186H29.0608C29.5788 33.1186 29.9998 33.5386 29.9998 34.0576V35.1796C29.9998 35.6986 29.5788 36.1186 29.0608 36.1186Z" fill="white" style="fill:white;fill-opacity:1;"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M26.4995 35.1186H23.4995C23.2235 35.1186 22.9995 34.8946 22.9995 34.6186C22.9995 34.3426 23.2235 34.1186 23.4995 34.1186H26.4995C26.7755 34.1186 26.9995 34.3426 26.9995 34.6186C26.9995 34.8946 26.7755 35.1186 26.4995 35.1186Z" fill="black" style="fill:black;fill-opacity:1;"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_1_302">
|
||||
<rect width="16.9998" height="28.5186" fill="white" style="fill:white;fill-opacity:1;" transform="translate(15 13.6)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
||||
|
After Width: | Height: | Size: 2.9 KiB |
@@ -0,0 +1,5 @@
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M16.0764 8.5093C16.5864 7.9993 17.4134 7.9993 17.9234 8.5093L25.4904 16.0763C26.0004 16.5863 26.0004 17.4133 25.4904 17.9233L17.9234 25.4903C17.4134 26.0003 16.5864 26.0003 16.0764 25.4903L8.50939 17.9233C7.99939 17.4133 7.99939 16.5863 8.50939 16.0763L16.0764 8.5093Z" fill="white" style="fill:white;fill-opacity:1;"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M13.0004 19.8444C13.0004 20.2704 12.4844 20.4844 12.1824 20.1824L9.4064 17.4064C9.1824 17.1824 9.1824 16.8184 9.4064 16.5934L12.1824 13.8174C12.4844 13.5154 13.0004 13.7294 13.0004 14.1554L13.001 16H16V13L14.1556 13.0004C13.758 13.0004 13.5451 12.5509 13.764 12.2454L13.8176 12.1824L16.5936 9.4064C16.8176 9.1824 17.1816 9.1824 17.4066 9.4064L20.1826 12.1824C20.4846 12.4844 20.2706 13.0004 19.8446 13.0004L18 13V16H21V14.1556C21 13.758 21.4495 13.5451 21.7542 13.764L21.817 13.8176L24.594 16.5936C24.818 16.8176 24.818 17.1816 24.594 17.4066L21.817 20.1826C21.516 20.4846 21 20.2706 21 19.8446V18H18V20.999L19.8444 20.9996C20.242 20.9996 20.4549 21.4491 20.236 21.7546L20.1824 21.8176L17.4064 24.5936C17.1824 24.8176 16.8184 24.8176 16.5934 24.5936L13.8174 21.8176C13.5154 21.5156 13.7294 20.9996 14.1554 20.9996L16 20.999V18H13.001L13.0004 19.8444Z" fill="black" style="fill:black;fill-opacity:1;"/>
|
||||
</svg>
|
||||
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,7 @@
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="16" cy="16" r="10.75" fill="white"/>
|
||||
<circle cx="16" cy="16" r="9.25" stroke="black" stroke-width="3"/>
|
||||
<path d="M9.55 22.45L22.45 9.55" stroke="black" stroke-width="3" stroke-linecap="round"/>
|
||||
<circle cx="16" cy="16" r="8.25" stroke="#DC2626" stroke-width="2"/>
|
||||
<path d="M10.25 21.75L21.75 10.25" stroke="#DC2626" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 472 B |
@@ -0,0 +1,5 @@
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M23 16C23 17.103 22.103 18 21 18H11C9.897 18 9 17.103 9 16C9 14.897 9.897 14 11 14H21C22.103 14 23 14.897 23 16ZM19.7852 21.3564C19.7852 21.5684 19.7342 21.7824 19.6392 21.9734L17.2562 26.7354C17.0212 27.2064 16.5482 27.4994 16.0212 27.5004C15.4932 27.5004 15.0192 27.2074 14.7842 26.7364L12.4012 21.9744C12.3052 21.7834 12.2542 21.5694 12.2542 21.3564C12.2542 20.5934 12.8752 19.9734 13.6382 19.9734H18.4022C18.7812 19.9734 19.1352 20.1234 19.3972 20.3964C19.6472 20.6554 19.7852 20.9974 19.7852 21.3564Z" fill="white" style="fill:white;fill-opacity:1;"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M22 16C22 16.552 21.552 17 21 17H11C10.448 17 10 16.552 10 16C10 15.448 10.448 15 11 15H21C21.552 15 22 15.448 22 16ZM16.362 26.2884L18.744 21.5274C18.871 21.2734 18.686 20.9734 18.402 20.9734H13.638C13.353 20.9734 13.168 21.2734 13.295 21.5274L15.678 26.2884C15.819 26.5704 16.221 26.5704 16.362 26.2884Z" fill="black" style="fill:black;fill-opacity:1;"/>
|
||||
</svg>
|
||||
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,5 @@
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M16.0004 23C14.8974 23 14.0004 22.103 14.0004 21V11C14.0004 9.897 14.8974 9 16.0004 9C17.1034 9 18.0004 9.897 18.0004 11V21C18.0004 22.103 17.1034 23 16.0004 23ZM10.644 19.7852C10.432 19.7852 10.218 19.7342 10.027 19.6392L5.265 17.2562C4.794 17.0212 4.501 16.5482 4.5 16.0212C4.5 15.4932 4.793 15.0192 5.264 14.7842L10.026 12.4012C10.217 12.3052 10.431 12.2542 10.644 12.2542C11.407 12.2542 12.027 12.8752 12.027 13.6382V18.4022C12.027 18.7812 11.877 19.1352 11.604 19.3972C11.345 19.6472 11.003 19.7852 10.644 19.7852Z" fill="white" style="fill:white;fill-opacity:1;"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M16.0004 22C15.4484 22 15.0004 21.552 15.0004 21V11C15.0004 10.448 15.4484 10 16.0004 10C16.5524 10 17.0004 10.448 17.0004 11V21C17.0004 21.552 16.5524 22 16.0004 22ZM5.712 16.362L10.473 18.744C10.727 18.871 11.027 18.686 11.027 18.402V13.638C11.027 13.353 10.727 13.168 10.473 13.295L5.712 15.678C5.43 15.819 5.43 16.221 5.712 16.362Z" fill="black" style="fill:black;fill-opacity:1;"/>
|
||||
</svg>
|
||||
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,5 @@
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M16 23C14.8996 23 14.0047 22.103 14.0047 21V11C14.0047 9.897 14.8996 9 16 9C17.1004 9 17.9953 9.897 17.9953 11V21C17.9953 22.103 17.1004 23 16 23ZM21.3703 19.7852C20.6091 19.7852 19.9905 19.1652 19.9905 18.4022V13.6382C19.9905 12.8752 20.6091 12.2542 21.3703 12.2542C21.5828 12.2542 21.7973 12.3052 21.9878 12.4022L26.7368 14.7842C27.2077 15.0192 27.5 15.4932 27.5 16.0212C27.499 16.5482 27.2067 17.0212 26.7358 17.2572L21.9868 19.6392C21.7963 19.7342 21.5828 19.7852 21.3703 19.7852ZM10.6297 19.7852C10.4182 19.7852 10.2047 19.7342 10.0141 19.6392L5.26322 17.2562C4.79332 17.0212 4.501 16.5482 4.5 16.0212C4.5 15.4932 4.79232 15.0192 5.26222 14.7842L10.0132 12.4012C10.2037 12.3052 10.4172 12.2542 10.6297 12.2542C11.3909 12.2542 12.0095 12.8752 12.0095 13.6382V18.4022C12.0095 18.7812 11.8598 19.1352 11.5875 19.3972C11.3291 19.6472 10.9879 19.7852 10.6297 19.7852Z" fill="white" style="fill:white;fill-opacity:1;"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M16 22C15.4493 22 15.0023 21.552 15.0023 21V11C15.0023 10.448 15.4493 10 16 10C16.5507 10 16.9977 10.448 16.9977 11V21C16.9977 21.552 16.5507 22 16 22ZM26.2908 15.6775L21.5409 13.2955C21.2875 13.1685 20.9882 13.3535 20.9882 13.6375V18.4015C20.9882 18.6865 21.2875 18.8715 21.5409 18.7445L26.2908 16.3615C26.5722 16.2205 26.5722 15.8185 26.2908 15.6775ZM5.70918 16.362L10.4591 18.744C10.7125 18.871 11.0118 18.686 11.0118 18.402V13.638C11.0118 13.353 10.7125 13.168 10.4591 13.295L5.70918 15.678C5.42783 15.819 5.42783 16.221 5.70918 16.362Z" fill="black" style="fill:black;fill-opacity:1;"/>
|
||||
</svg>
|
||||
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1,5 @@
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M16 9C17.103 9 18 9.897 18 11V21C18 22.103 17.103 23 16 23C14.897 23 14 22.103 14 21V11C14 9.897 14.897 9 16 9ZM21.3564 12.2148C21.5684 12.2148 21.7824 12.2658 21.9734 12.3608L26.7354 14.7438C27.2064 14.9788 27.4994 15.4518 27.5004 15.9788C27.5004 16.5068 27.2074 16.9808 26.7364 17.2158L21.9744 19.5988C21.7834 19.6948 21.5694 19.7458 21.3564 19.7458C20.5934 19.7458 19.9734 19.1248 19.9734 18.3618V13.5978C19.9734 13.2188 20.1234 12.8648 20.3964 12.6028C20.6554 12.3528 20.9974 12.2148 21.3564 12.2148Z" fill="white" style="fill:white;fill-opacity:1;"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M16 10C16.552 10 17 10.448 17 11V21C17 21.552 16.552 22 16 22C15.448 22 15 21.552 15 21V11C15 10.448 15.448 10 16 10ZM26.2884 15.638L21.5274 13.256C21.2734 13.129 20.9734 13.314 20.9734 13.598V18.362C20.9734 18.647 21.2734 18.832 21.5274 18.705L26.2884 16.322C26.5704 16.181 26.5704 15.779 26.2884 15.638Z" fill="black" style="fill:black;fill-opacity:1;"/>
|
||||
</svg>
|
||||
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,5 @@
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M9 16.0004C9 14.8974 9.897 14.0004 11 14.0004H21C22.103 14.0004 23 14.8974 23 16.0004C23 17.1034 22.103 18.0004 21 18.0004H11C9.897 18.0004 9 17.1034 9 16.0004ZM12.2148 10.644C12.2148 10.432 12.2658 10.218 12.3608 10.027L14.7438 5.265C14.9788 4.794 15.4518 4.501 15.9788 4.5C16.5068 4.5 16.9808 4.793 17.2158 5.264L19.5988 10.026C19.6948 10.217 19.7458 10.431 19.7458 10.644C19.7458 11.407 19.1248 12.027 18.3618 12.027H13.5978C13.2188 12.027 12.8648 11.877 12.6028 11.604C12.3528 11.345 12.2148 11.003 12.2148 10.644Z" fill="white" style="fill:white;fill-opacity:1;"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M10 16.0004C10 15.4484 10.448 15.0004 11 15.0004H21C21.552 15.0004 22 15.4484 22 16.0004C22 16.5524 21.552 17.0004 21 17.0004H11C10.448 17.0004 10 16.5524 10 16.0004ZM15.638 5.712L13.256 10.473C13.129 10.727 13.314 11.027 13.598 11.027H18.362C18.647 11.027 18.832 10.727 18.705 10.473L16.322 5.712C16.181 5.43 15.779 5.43 15.638 5.712Z" fill="black" style="fill:black;fill-opacity:1;"/>
|
||||
</svg>
|
||||
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,5 @@
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M23 16C23 17.1004 22.103 17.9953 21 17.9953H11C9.897 17.9953 9 17.1004 9 16C9 14.8996 9.897 14.0047 11 14.0047H21C22.103 14.0047 23 14.8996 23 16ZM19.7852 10.6297C19.7852 11.3909 19.1652 12.0095 18.4022 12.0095H13.6382C12.8752 12.0095 12.2542 11.3909 12.2542 10.6297C12.2542 10.4172 12.3052 10.2027 12.4022 10.0122L14.7842 5.26322C15.0192 4.79232 15.4932 4.5 16.0212 4.5C16.5482 4.501 17.0212 4.79332 17.2572 5.26422L19.6392 10.0132C19.7342 10.2037 19.7852 10.4172 19.7852 10.6297ZM19.7852 21.3703C19.7852 21.5818 19.7342 21.7953 19.6392 21.9859L17.2562 26.7368C17.0212 27.2067 16.5482 27.499 16.0212 27.5C15.4932 27.5 15.0192 27.2077 14.7842 26.7378L12.4012 21.9868C12.3052 21.7963 12.2542 21.5828 12.2542 21.3703C12.2542 20.6091 12.8752 19.9905 13.6382 19.9905H18.4022C18.7812 19.9905 19.1352 20.1402 19.3972 20.4125C19.6472 20.6709 19.7852 21.0121 19.7852 21.3703Z" fill="white" style="fill:white;fill-opacity:1;"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M22 16C22 16.5507 21.552 16.9977 21 16.9977H11C10.448 16.9977 10 16.5507 10 16C10 15.4493 10.448 15.0023 11 15.0023H21C21.552 15.0023 22 15.4493 22 16ZM15.6775 5.70918L13.2955 10.4591C13.1685 10.7125 13.3535 11.0118 13.6375 11.0118H18.4015C18.6865 11.0118 18.8715 10.7125 18.7445 10.4591L16.3615 5.70918C16.2205 5.42783 15.8185 5.42783 15.6775 5.70918ZM16.362 26.2908L18.744 21.5409C18.871 21.2875 18.686 20.9882 18.402 20.9882H13.638C13.353 20.9882 13.168 21.2875 13.295 21.5409L15.678 26.2908C15.819 26.5722 16.221 26.5722 16.362 26.2908Z" fill="black" style="fill:black;fill-opacity:1;"/>
|
||||
</svg>
|
||||
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1,5 @@
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M12.64 15.4658L16.5353 19.3601C16.8182 19.643 16.9701 20.0188 16.9621 20.4176C16.9551 20.7994 16.8042 21.1573 16.5353 21.4261C16.3754 21.5841 16.1785 21.707 15.9636 21.778L10.1241 23.7241C9.59533 23.9 9.02258 23.7651 8.62875 23.3713C8.23492 22.9774 8.09998 22.4037 8.2759 21.8759L10.2221 16.0366C10.293 15.8217 10.415 15.6238 10.5739 15.4648C11.1437 14.8961 12.0703 14.8961 12.64 15.4658ZM23.3713 8.62873C23.7651 9.02255 23.9 9.59529 23.7241 10.1231L21.781 15.9614C21.71 16.1763 21.587 16.3732 21.4281 16.5322C20.8584 17.1019 19.9318 17.1019 19.362 16.5322L15.4677 12.6379C14.8979 12.0692 14.8979 11.1426 15.4677 10.5718C15.6266 10.4129 15.8245 10.292 16.0394 10.22L21.8769 8.27589C22.4047 8.09997 22.9774 8.2349 23.3713 8.62873Z" fill="white" style="fill:white;fill-opacity:1;"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M9.80824 22.7821L15.6512 20.8351C15.9762 20.7271 16.0732 20.3141 15.8312 20.0721L11.9352 16.1761C11.6932 15.9331 11.2802 16.0311 11.1712 16.3561L9.22524 22.1991C9.10524 22.5591 9.44824 22.9021 9.80824 22.7821ZM22.199 9.22462L16.358 11.1696C16.033 11.2776 15.936 11.6906 16.178 11.9336L20.074 15.8286C20.316 16.0716 20.729 15.9736 20.837 15.6486L22.782 9.80762C22.902 9.44762 22.559 9.10462 22.199 9.22462Z" fill="black" style="fill:black;fill-opacity:1;"/>
|
||||
</svg>
|
||||
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,5 @@
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M20.204 19.4693C20.203 19.6943 20.15 19.9203 20.049 20.1223L17.294 25.6303C17.045 26.1283 16.544 26.4383 15.987 26.4383C15.43 26.4383 14.929 26.1283 14.68 25.6303L11.925 20.1223C11.824 19.9203 11.77 19.6943 11.77 19.4693C11.77 18.6633 12.426 18.0083 13.232 18.0083H18.742C19.142 18.0083 19.517 18.1663 19.793 18.4543C20.058 18.7283 20.204 19.0893 20.204 19.4693ZM20.2031 12.546C20.2031 13.352 19.5481 14.008 18.7411 14.008H13.2321C12.4261 14.008 11.7701 13.353 11.7701 12.546C11.7701 12.321 11.8241 12.096 11.9251 11.893L14.6801 6.388C14.9291 5.89 15.4301 5.58 15.9871 5.58C16.5441 5.58 17.0451 5.89 17.2941 6.388L20.0491 11.893C20.1501 12.095 20.2031 12.321 20.2031 12.546Z" fill="white" style="fill:white;fill-opacity:1;"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M16.3992 25.1833L19.1542 19.6753C19.3072 19.3683 19.0842 19.0083 18.7412 19.0083H13.2322C12.8892 19.0083 12.6662 19.3683 12.8192 19.6753L15.5742 25.1833C15.7442 25.5233 16.2292 25.5233 16.3992 25.1833ZM15.5743 6.8351L12.8193 12.3401C12.6663 12.6471 12.8893 13.0081 13.2323 13.0081H18.7413C19.0843 13.0081 19.3073 12.6471 19.1543 12.3401L16.3993 6.8351C16.2293 6.4951 15.7443 6.4951 15.5743 6.8351Z" fill="black" style="fill:black;fill-opacity:1;"/>
|
||||
</svg>
|
||||
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,5 @@
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M19.36 15.4658C19.9297 14.8961 20.8563 14.8961 21.4261 15.4648C21.585 15.6238 21.707 15.8217 21.7779 16.0366L23.7241 21.8759C23.9 22.4037 23.7651 22.9774 23.3713 23.3713C22.9774 23.7651 22.4047 23.9 21.8759 23.7241L16.0364 21.778C15.8215 21.707 15.6246 21.5841 15.4647 21.4261C15.1958 21.1573 15.0449 20.7994 15.0379 20.4176C15.0299 20.0188 15.1818 19.643 15.4647 19.3601L19.36 15.4658ZM8.62873 8.62873C9.02256 8.2349 9.59532 8.09997 10.1231 8.27589L15.9606 10.22C16.1755 10.292 16.3734 10.4129 16.5323 10.5718C17.1021 11.1426 17.1021 12.0692 16.5323 12.6379L12.638 16.5322C12.0682 17.1019 11.1416 17.1019 10.5719 16.5322C10.413 16.3732 10.29 16.1763 10.219 15.9614L8.27589 10.1231C8.09996 9.59529 8.2349 9.02255 8.62873 8.62873Z" fill="white" style="fill:white;fill-opacity:1;"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M22.1926 22.7755L16.3521 20.8294C16.0272 20.7215 15.9303 20.3087 16.1722 20.0668L20.0665 16.1725C20.3084 15.9296 20.7212 16.0276 20.8302 16.3524L22.7753 22.1928C22.8953 22.5526 22.5524 22.8955 22.1926 22.7755ZM9.80703 9.22416L15.6455 11.1683C15.9704 11.2762 16.0683 11.689 15.8254 11.9319L11.9311 15.8252C11.6892 16.0681 11.2764 15.9701 11.1684 15.6453L9.22428 9.80689C9.10433 9.44706 9.44718 9.10421 9.80703 9.22416Z" fill="black" style="fill:black;fill-opacity:1;"/>
|
||||
</svg>
|
||||
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,5 @@
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M19.7634 12C19.9884 12.001 20.2144 12.054 20.4164 12.155L25.9244 14.91C26.4224 15.159 26.7324 15.66 26.7324 16.217C26.7324 16.774 26.4224 17.275 25.9244 17.524L20.4164 20.279C20.2144 20.38 19.9884 20.434 19.7634 20.434C18.9574 20.434 18.3024 19.778 18.3024 18.972V13.462C18.3024 13.062 18.4604 12.687 18.7484 12.411C19.0224 12.146 19.3834 12 19.7634 12ZM12.84 12.001C13.646 12.001 14.302 12.656 14.302 13.463V18.972C14.302 19.778 13.647 20.434 12.84 20.434C12.615 20.434 12.39 20.38 12.187 20.279L6.68199 17.524C6.18399 17.275 5.87399 16.774 5.87399 16.217C5.87399 15.66 6.18399 15.159 6.68199 14.91L12.187 12.155C12.389 12.054 12.615 12.001 12.84 12.001Z" fill="white" style="fill:white;fill-opacity:1;"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M25.4773 15.8049L19.9693 13.0499C19.6623 12.8969 19.3023 13.1199 19.3023 13.4629V18.9719C19.3023 19.3149 19.6623 19.5379 19.9693 19.3849L25.4773 16.6299C25.8173 16.4599 25.8173 15.9749 25.4773 15.8049ZM7.12921 16.6298L12.6342 19.3848C12.9412 19.5378 13.3022 19.3148 13.3022 18.9718V13.4628C13.3022 13.1198 12.9412 12.8968 12.6342 13.0498L7.12921 15.8048C6.78921 15.9748 6.78921 16.4598 7.12921 16.6298Z" fill="black" style="fill:black;fill-opacity:1;"/>
|
||||
</svg>
|
||||
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,5 @@
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M20.315 7.5046C20.315 8.2486 19.759 8.8886 19.023 8.9936C18.431 9.0786 17.984 9.5956 17.984 10.1956V20.4256C17.984 21.0266 18.43 21.5436 19.021 21.6286C19.756 21.7326 20.312 22.3736 20.312 23.1176C20.309 23.5606 20.118 23.9736 19.787 24.2566C19.458 24.5396 19.021 24.6676 18.59 24.6036C17.807 24.4916 17.078 24.1606 16.481 23.6586C15.883 24.1606 15.154 24.4916 14.367 24.6046C13.94 24.6676 13.504 24.5406 13.173 24.2566C12.842 23.9696 12.653 23.5576 12.65 23.1246C12.65 22.3736 13.206 21.7326 13.942 21.6276C14.536 21.5426 14.984 21.0256 14.984 20.4256V10.1956C14.984 9.5956 14.537 9.0786 13.944 8.9936C13.209 8.8886 12.654 8.2486 12.654 7.5046C12.655 7.0646 12.846 6.6506 13.177 6.3656C13.447 6.1326 13.796 6.0016 14.158 6.0016H14.239L14.39 6.0206C15.164 6.1316 15.889 6.4616 16.484 6.9636C17.083 6.4616 17.812 6.1296 18.598 6.0176C19.013 5.9506 19.455 6.0766 19.792 6.3656C20.123 6.6526 20.312 7.0646 20.315 7.4976V7.5016V7.5046Z" fill="white" style="fill:white;fill-opacity:1;"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M14.087 8.00285C15.169 8.15785 15.985 9.10085 15.985 10.1949V20.4249C15.985 21.5189 15.167 22.4619 14.083 22.6169C13.836 22.6519 13.65 22.8669 13.65 23.1169C13.651 23.2629 13.714 23.4009 13.824 23.4969C13.935 23.5909 14.08 23.6349 14.226 23.6139C15.148 23.4819 15.978 22.9429 16.481 22.1599C16.985 22.9429 17.814 23.4819 18.737 23.6139C18.879 23.6349 19.027 23.5909 19.137 23.4959C19.248 23.4009 19.311 23.2629 19.312 23.1169C19.312 22.8669 19.126 22.6519 18.879 22.6169C17.799 22.4619 16.985 21.5199 16.985 20.4249V10.1949C16.985 9.10085 17.801 8.15785 18.883 8.00285C19.13 7.96785 19.316 7.75285 19.316 7.50285C19.315 7.35785 19.251 7.21885 19.141 7.12285C19.03 7.02885 18.885 6.98285 18.74 7.00585C17.818 7.13885 16.988 7.67685 16.484 8.45985C15.98 7.67685 15.151 7.13885 14.229 7.00585C14.206 7.00285 14.182 7.00085 14.158 7.00085C14.038 7.00085 13.921 7.04385 13.829 7.12285C13.718 7.21885 13.655 7.35685 13.654 7.50385C13.654 7.75285 13.84 7.96785 14.087 8.00285Z" fill="black" style="fill:black;fill-opacity:1;"/>
|
||||
</svg>
|
||||
|
||||
|
After Width: | Height: | Size: 2.2 KiB |
@@ -0,0 +1,4 @@
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M16 2.5L7.25 12.25H12V28.5H20V12.25H24.75L16 2.5Z" fill="white" stroke="white" stroke-width="3" stroke-linejoin="round"/>
|
||||
<path d="M16 5.25L10 11.95H14V26.5H18V11.95H22L16 5.25Z" fill="black"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 306 B |
@@ -0,0 +1,6 @@
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="16" cy="16" r="10.5" stroke="white" stroke-width="4"/>
|
||||
<path d="M16 5.5C18.7848 5.5 21.4555 6.60625 23.4246 8.57538" stroke="black" stroke-width="3" stroke-linecap="round"/>
|
||||
<path d="M23.4246 8.57538C25.3938 10.5445 26.5 13.2152 26.5 16" stroke="#2563EB" stroke-width="3" stroke-linecap="round"/>
|
||||
<path d="M16 26.5C10.201 26.5 5.5 21.799 5.5 16" stroke="black" stroke-width="3" stroke-linecap="round" opacity="0.7"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 532 B |
@@ -0,0 +1,8 @@
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M24 17C24 20.866 20.866 24 17 24C13.134 24 10 20.866 10 17C10 13.134 13.134 10 17 10C20.866 10 24 13.134 24 17Z" fill="black" style="fill:black;fill-opacity:1;"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M22.2532 17C22.2532 19.901 19.9012 22.253 17.0002 22.253C14.0982 22.253 11.7472 19.901 11.7472 17C11.7472 14.099 14.0982 11.747 17.0002 11.747C19.9012 11.747 22.2532 14.099 22.2532 17Z" fill="white" style="fill:white;fill-opacity:1;"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M17 20.5C16.448 20.5 16 20.052 16 19.5V14.5C16 13.948 16.448 13.5 17 13.5C17.552 13.5 18 13.948 18 14.5V19.5C18 20.052 17.552 20.5 17 20.5Z" fill="black" style="fill:black;fill-opacity:1;"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M13.5 17C13.5 16.448 13.948 16 14.5 16H19.5C20.052 16 20.5 16.448 20.5 17C20.5 17.552 20.052 18 19.5 18H14.5C13.948 18 13.5 17.552 13.5 17Z" fill="black" style="fill:black;fill-opacity:1;"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M26.9315 26.9316C27.3215 26.5416 27.3215 25.9076 26.9315 25.5176L22.1895 20.7756L20.7755 22.1896L25.5175 26.9316C25.9075 27.3216 26.5415 27.3216 26.9315 26.9316Z" fill="black" style="fill:black;fill-opacity:1;"/>
|
||||
</svg>
|
||||
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,6 @@
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M24 17C24 20.866 20.866 24 17 24C13.134 24 10 20.866 10 17C10 13.134 13.134 10 17 10C20.866 10 24 13.134 24 17Z" fill="black" style="fill:black;fill-opacity:1;"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M22.2532 17C22.2532 19.901 19.9012 22.253 17.0002 22.253C14.0982 22.253 11.7472 19.901 11.7472 17C11.7472 14.099 14.0982 11.747 17.0002 11.747C19.9012 11.747 22.2532 14.099 22.2532 17Z" fill="white" style="fill:white;fill-opacity:1;"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M13.5 17C13.5 16.448 13.948 16 14.5 16H19.5C20.052 16 20.5 16.448 20.5 17C20.5 17.552 20.052 18 19.5 18H14.5C13.948 18 13.5 17.552 13.5 17ZM26.9315 26.9316C27.3215 26.5416 27.3215 25.9076 26.9315 25.5176L22.1895 20.7756L20.7755 22.1896L25.5175 26.9316C25.9075 27.3216 26.5415 27.3216 26.9315 26.9316Z" fill="black" style="fill:black;fill-opacity:1;"/>
|
||||
</svg>
|
||||
|
||||
|
After Width: | Height: | Size: 1002 B |
@@ -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;
|
||||
}
|
||||
|
||||
@@ -62,16 +62,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 +87,7 @@ export function LaunchWindow() {
|
||||
resolveSystemLocaleSuggestion,
|
||||
} = useI18n();
|
||||
const suggestedLanguageName = systemLocaleSuggestion ? getLocaleName(systemLocaleSuggestion) : "";
|
||||
const activeLanguageLabel = getLocaleName(locale).split(/\s+/)[0] || locale.toUpperCase();
|
||||
|
||||
const {
|
||||
recording,
|
||||
@@ -100,12 +101,14 @@ export function LaunchWindow() {
|
||||
setMicrophoneEnabled,
|
||||
microphoneDeviceId,
|
||||
setMicrophoneDeviceId,
|
||||
setMicrophoneDeviceName,
|
||||
systemAudioEnabled,
|
||||
setSystemAudioEnabled,
|
||||
webcamEnabled,
|
||||
setWebcamEnabled,
|
||||
webcamDeviceId,
|
||||
setWebcamDeviceId,
|
||||
setWebcamDeviceName,
|
||||
} = useScreenRecorder();
|
||||
|
||||
const showMicControls = microphoneEnabled && !recording;
|
||||
@@ -167,12 +170,20 @@ export function LaunchWindow() {
|
||||
}
|
||||
}, [selectedMicId, setMicrophoneDeviceId]);
|
||||
|
||||
useEffect(() => {
|
||||
setMicrophoneDeviceName(selectedMicLabel);
|
||||
}, [selectedMicLabel, setMicrophoneDeviceName]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedCameraId) {
|
||||
setWebcamDeviceId(selectedCameraId);
|
||||
}
|
||||
}, [selectedCameraId, setWebcamDeviceId]);
|
||||
|
||||
useEffect(() => {
|
||||
setWebcamDeviceName(selectedCameraLabel);
|
||||
}, [selectedCameraLabel, setWebcamDeviceName]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!import.meta.env.DEV) {
|
||||
return;
|
||||
@@ -248,6 +259,13 @@ 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);
|
||||
|
||||
@@ -320,6 +338,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 +384,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)}
|
||||
@@ -409,7 +434,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)}
|
||||
@@ -485,7 +510,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,13 +520,15 @@ 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 */}
|
||||
@@ -548,7 +576,7 @@ export function LaunchWindow() {
|
||||
? 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}
|
||||
@@ -624,11 +652,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>
|
||||
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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,18 @@ 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
|
||||
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
|
||||
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>
|
||||
);
|
||||
|
||||
@@ -16,6 +16,7 @@ import { useShortcuts } from "@/contexts/ShortcutsContext";
|
||||
import { INITIAL_EDITOR_STATE, useEditorHistory } from "@/hooks/useEditorHistory";
|
||||
import { type Locale } from "@/i18n/config";
|
||||
import { getAvailableLocales, getLocaleName } from "@/i18n/loader";
|
||||
import { hasNativeCursorRecordingData } from "@/lib/cursor/nativeCursor";
|
||||
import {
|
||||
calculateOutputDimensions,
|
||||
type ExportFormat,
|
||||
@@ -38,6 +39,7 @@ import {
|
||||
saveUserPreferences,
|
||||
} from "@/lib/userPreferences";
|
||||
import { BackgroundLoadError } from "@/lib/wallpaper";
|
||||
import type { CursorRecordingData } from "@/native/contracts";
|
||||
import {
|
||||
getAspectRatioValue,
|
||||
getNativeAspectRatioValue,
|
||||
@@ -67,6 +69,10 @@ import {
|
||||
DEFAULT_ANNOTATION_SIZE,
|
||||
DEFAULT_ANNOTATION_STYLE,
|
||||
DEFAULT_BLUR_DATA,
|
||||
DEFAULT_CURSOR_CLICK_BOUNCE,
|
||||
DEFAULT_CURSOR_MOTION_BLUR,
|
||||
DEFAULT_CURSOR_SIZE,
|
||||
DEFAULT_CURSOR_SMOOTHING,
|
||||
DEFAULT_FIGURE_DATA,
|
||||
DEFAULT_PLAYBACK_SPEED,
|
||||
DEFAULT_ZOOM_DEPTH,
|
||||
@@ -131,6 +137,13 @@ export default function VideoEditor() {
|
||||
durationRef.current = duration;
|
||||
const [cursorTelemetry, setCursorTelemetry] = useState<CursorTelemetryPoint[]>([]);
|
||||
const [cursorClickTimestamps, setCursorClickTimestamps] = useState<number[]>([]);
|
||||
const [cursorRecordingData, setCursorRecordingData] = useState<CursorRecordingData | null>(null);
|
||||
const [showCursor, setShowCursor] = useState(true);
|
||||
const [cursorSize, setCursorSize] = useState(DEFAULT_CURSOR_SIZE);
|
||||
const [cursorSmoothing, setCursorSmoothing] = useState(DEFAULT_CURSOR_SMOOTHING);
|
||||
const [cursorMotionBlur, setCursorMotionBlur] = useState(DEFAULT_CURSOR_MOTION_BLUR);
|
||||
const [cursorClickBounce, setCursorClickBounce] = useState(DEFAULT_CURSOR_CLICK_BOUNCE);
|
||||
const [nativePlatform, setNativePlatform] = useState<string | null>(null);
|
||||
const [selectedZoomId, setSelectedZoomId] = useState<string | null>(null);
|
||||
const [selectedTrimId, setSelectedTrimId] = useState<string | null>(null);
|
||||
const [selectedSpeedId, setSelectedSpeedId] = useState<string | null>(null);
|
||||
@@ -164,6 +177,8 @@ export default function VideoEditor() {
|
||||
const nextSpeedIdRef = useRef(1);
|
||||
|
||||
const { shortcuts, isMac } = useShortcuts();
|
||||
const hasNativeCursorData = hasNativeCursorRecordingData(cursorRecordingData);
|
||||
const showNativeCursorSettings = nativePlatform === "win32" && hasNativeCursorData;
|
||||
// Off-Mac doesn't have click telemetry, so force `onlyOnClicks` off for
|
||||
// renderers while keeping the persisted value intact for round-tripping.
|
||||
const effectiveCursorHighlight = useMemo(
|
||||
@@ -619,6 +634,23 @@ export default function VideoEditor() {
|
||||
};
|
||||
}, [handleLoadProject, handleSaveProject, handleSaveProjectAs]);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
window.electronAPI
|
||||
.getPlatform()
|
||||
.then((platform) => {
|
||||
if (mounted) setNativePlatform(platform);
|
||||
})
|
||||
.catch((platformError) => {
|
||||
console.warn("Unable to resolve platform:", platformError);
|
||||
if (mounted) setNativePlatform(null);
|
||||
});
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
|
||||
@@ -629,21 +661,27 @@ export default function VideoEditor() {
|
||||
if (mounted) {
|
||||
setCursorTelemetry([]);
|
||||
setCursorClickTimestamps([]);
|
||||
setCursorRecordingData(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await window.electronAPI.getCursorTelemetry(sourcePath);
|
||||
const [result, recordingDataResult] = await Promise.all([
|
||||
window.electronAPI.getCursorTelemetry(sourcePath),
|
||||
window.electronAPI.getCursorRecordingData(sourcePath),
|
||||
]);
|
||||
if (mounted) {
|
||||
setCursorTelemetry(result.success ? result.samples : []);
|
||||
setCursorClickTimestamps(result.success ? (result.clicks ?? []) : []);
|
||||
setCursorRecordingData(recordingDataResult.success ? recordingDataResult.data : null);
|
||||
}
|
||||
} catch (telemetryError) {
|
||||
console.warn("Unable to load cursor telemetry:", telemetryError);
|
||||
if (mounted) {
|
||||
setCursorTelemetry([]);
|
||||
setCursorClickTimestamps([]);
|
||||
setCursorRecordingData(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1503,6 +1541,11 @@ export default function VideoEditor() {
|
||||
cursorTelemetry,
|
||||
cursorClickTimestamps,
|
||||
cursorHighlight: effectiveCursorHighlight,
|
||||
cursorRecordingData,
|
||||
cursorScale: showCursor ? cursorSize : 0,
|
||||
cursorSmoothing,
|
||||
cursorMotionBlur,
|
||||
cursorClickBounce,
|
||||
onProgress: (progress: ExportProgress) => {
|
||||
setExportProgress(progress);
|
||||
},
|
||||
@@ -1644,6 +1687,11 @@ export default function VideoEditor() {
|
||||
cursorTelemetry,
|
||||
cursorClickTimestamps,
|
||||
cursorHighlight: effectiveCursorHighlight,
|
||||
cursorRecordingData,
|
||||
cursorScale: showCursor ? cursorSize : 0,
|
||||
cursorSmoothing,
|
||||
cursorMotionBlur,
|
||||
cursorClickBounce,
|
||||
onProgress: (progress: ExportProgress) => {
|
||||
setExportProgress(progress);
|
||||
},
|
||||
@@ -1725,6 +1773,12 @@ export default function VideoEditor() {
|
||||
cursorTelemetry,
|
||||
cursorClickTimestamps,
|
||||
effectiveCursorHighlight,
|
||||
cursorRecordingData,
|
||||
showCursor,
|
||||
cursorSize,
|
||||
cursorSmoothing,
|
||||
cursorMotionBlur,
|
||||
cursorClickBounce,
|
||||
t,
|
||||
],
|
||||
);
|
||||
@@ -1869,7 +1923,7 @@ export default function VideoEditor() {
|
||||
</Dialog>
|
||||
|
||||
<div
|
||||
className="h-10 flex-shrink-0 bg-[#09090b]/80 backdrop-blur-md border-b border-white/5 flex items-center justify-between px-6 z-50"
|
||||
className="h-11 flex-shrink-0 bg-[#070809]/85 backdrop-blur-xl border-b border-white/[0.07] flex items-center justify-between px-5 z-50 shadow-[0_1px_0_rgba(255,255,255,0.03)]"
|
||||
style={{ WebkitAppRegion: "drag" } as React.CSSProperties}
|
||||
>
|
||||
<div
|
||||
@@ -1877,7 +1931,7 @@ export default function VideoEditor() {
|
||||
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
|
||||
>
|
||||
<div
|
||||
className={`flex items-center gap-1 px-2 py-1 rounded-md text-white/50 hover:text-white/90 hover:bg-white/10 transition-all duration-150 ${isMac ? "ml-14" : "ml-2"}`}
|
||||
className={`flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-white/50 hover:text-white/90 hover:bg-white/[0.08] transition-all duration-150 ${isMac ? "ml-14" : "ml-2"}`}
|
||||
>
|
||||
<Languages size={14} />
|
||||
<select
|
||||
@@ -1896,7 +1950,7 @@ export default function VideoEditor() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowNewRecordingDialog(true)}
|
||||
className="flex items-center gap-1 px-2 py-1 rounded-md text-white/50 hover:text-white/90 hover:bg-white/10 transition-all duration-150 text-[11px] font-medium"
|
||||
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-white/50 hover:text-white/90 hover:bg-white/[0.08] transition-all duration-150 text-[11px] font-medium"
|
||||
>
|
||||
<Video size={14} />
|
||||
{t("newRecording.title")}
|
||||
@@ -1904,7 +1958,7 @@ export default function VideoEditor() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLoadProject}
|
||||
className="flex items-center gap-1 px-2 py-1 rounded-md text-white/50 hover:text-white/90 hover:bg-white/10 transition-all duration-150 text-[11px] font-medium"
|
||||
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-white/50 hover:text-white/90 hover:bg-white/[0.08] transition-all duration-150 text-[11px] font-medium"
|
||||
>
|
||||
<FolderOpen size={14} />
|
||||
{ts("project.load")}
|
||||
@@ -1912,7 +1966,7 @@ export default function VideoEditor() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSaveProject}
|
||||
className="flex items-center gap-1 px-2 py-1 rounded-md text-white/50 hover:text-white/90 hover:bg-white/10 transition-all duration-150 text-[11px] font-medium"
|
||||
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-white/50 hover:text-white/90 hover:bg-white/[0.08] transition-all duration-150 text-[11px] font-medium"
|
||||
>
|
||||
<Save size={14} />
|
||||
{ts("project.save")}
|
||||
@@ -1920,289 +1974,308 @@ export default function VideoEditor() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 p-5 gap-4 flex min-h-0 relative">
|
||||
{/* Left Column - Video & Timeline */}
|
||||
<div className="flex-[7] flex flex-col gap-3 min-w-0 h-full">
|
||||
<PanelGroup direction="vertical" className="gap-3">
|
||||
{/* Top section: video preview and controls */}
|
||||
<Panel defaultSize={70} maxSize={70} minSize={40}>
|
||||
<div
|
||||
ref={playerContainerRef}
|
||||
className={
|
||||
isFullscreen
|
||||
? "fixed inset-0 z-[99999] w-full h-full flex flex-col items-center justify-center bg-[#09090b]"
|
||||
: "w-full h-full flex flex-col items-center justify-center bg-black/40 rounded-2xl border border-white/5 shadow-2xl overflow-hidden relative"
|
||||
}
|
||||
>
|
||||
{/* Video preview */}
|
||||
<div className="w-full flex justify-center items-center flex-auto mt-1.5">
|
||||
<div
|
||||
className="relative flex justify-center items-center w-auto h-full max-w-full box-border"
|
||||
style={{
|
||||
aspectRatio:
|
||||
aspectRatio === "native"
|
||||
? getNativeAspectRatioValue(
|
||||
videoPlaybackRef.current?.video?.videoWidth || 1920,
|
||||
videoPlaybackRef.current?.video?.videoHeight || 1080,
|
||||
cropRegion,
|
||||
)
|
||||
: getAspectRatioValue(aspectRatio),
|
||||
}}
|
||||
>
|
||||
<VideoPlayback
|
||||
key={`${videoPath || "no-video"}:${webcamVideoPath || "no-webcam"}`}
|
||||
aspectRatio={aspectRatio}
|
||||
ref={videoPlaybackRef}
|
||||
videoPath={videoPath || ""}
|
||||
webcamVideoPath={webcamVideoPath || undefined}
|
||||
webcamLayoutPreset={webcamLayoutPreset}
|
||||
webcamMaskShape={webcamMaskShape}
|
||||
webcamSizePreset={webcamSizePreset}
|
||||
webcamPosition={webcamPosition}
|
||||
onWebcamPositionChange={(pos) => updateState({ webcamPosition: pos })}
|
||||
onWebcamPositionDragEnd={commitState}
|
||||
onDurationChange={setDuration}
|
||||
onTimeUpdate={setCurrentTime}
|
||||
currentTime={currentTime}
|
||||
onPlayStateChange={setIsPlaying}
|
||||
onError={setError}
|
||||
wallpaper={wallpaper}
|
||||
zoomRegions={zoomRegions}
|
||||
selectedZoomId={selectedZoomId}
|
||||
onSelectZoom={handleSelectZoom}
|
||||
onZoomFocusChange={handleZoomFocusChange}
|
||||
onZoomFocusDragEnd={commitState}
|
||||
isPlaying={isPlaying}
|
||||
showShadow={shadowIntensity > 0}
|
||||
shadowIntensity={shadowIntensity}
|
||||
showBlur={showBlur}
|
||||
motionBlurAmount={motionBlurAmount}
|
||||
borderRadius={borderRadius}
|
||||
padding={padding}
|
||||
cropRegion={cropRegion}
|
||||
trimRegions={trimRegions}
|
||||
speedRegions={speedRegions}
|
||||
annotationRegions={annotationOnlyRegions}
|
||||
selectedAnnotationId={selectedAnnotationId}
|
||||
onSelectAnnotation={handleSelectAnnotation}
|
||||
onAnnotationPositionChange={handleAnnotationPositionChange}
|
||||
onAnnotationSizeChange={handleAnnotationSizeChange}
|
||||
blurRegions={blurRegions}
|
||||
selectedBlurId={selectedBlurId}
|
||||
onSelectBlur={handleSelectBlur}
|
||||
onBlurPositionChange={handleAnnotationPositionChange}
|
||||
onBlurSizeChange={handleAnnotationSizeChange}
|
||||
onBlurDataChange={handleBlurDataPreviewChange}
|
||||
onBlurDataCommit={commitState}
|
||||
cursorTelemetry={cursorTelemetry}
|
||||
cursorHighlight={effectiveCursorHighlight}
|
||||
cursorClickTimestamps={cursorClickTimestamps}
|
||||
/>
|
||||
<div className="editor-workspace flex-1 min-h-0 relative">
|
||||
<PanelGroup direction="vertical" className="gap-3 min-h-0">
|
||||
{/* Top section: preview and contextual settings */}
|
||||
<Panel defaultSize={67} maxSize={76} minSize={46} className="min-h-[300px]">
|
||||
<div className="editor-main-deck h-full min-h-0">
|
||||
<div className="editor-preview-zone min-w-0 h-full">
|
||||
<div
|
||||
ref={playerContainerRef}
|
||||
className={
|
||||
isFullscreen
|
||||
? "fixed inset-0 z-[99999] w-full h-full flex flex-col items-center justify-center bg-[#09090b]"
|
||||
: "editor-preview-panel w-full h-full flex flex-col items-center justify-center overflow-hidden relative"
|
||||
}
|
||||
>
|
||||
{/* Video preview */}
|
||||
<div className="w-full min-h-0 flex justify-center items-center flex-auto px-4 pt-4">
|
||||
<div
|
||||
className="relative flex justify-center items-center w-auto h-full max-w-full box-border"
|
||||
style={{
|
||||
aspectRatio:
|
||||
aspectRatio === "native"
|
||||
? getNativeAspectRatioValue(
|
||||
videoPlaybackRef.current?.video?.videoWidth || 1920,
|
||||
videoPlaybackRef.current?.video?.videoHeight || 1080,
|
||||
cropRegion,
|
||||
)
|
||||
: getAspectRatioValue(aspectRatio),
|
||||
}}
|
||||
>
|
||||
<VideoPlayback
|
||||
key={`${videoPath || "no-video"}:${webcamVideoPath || "no-webcam"}`}
|
||||
aspectRatio={aspectRatio}
|
||||
ref={videoPlaybackRef}
|
||||
videoPath={videoPath || ""}
|
||||
webcamVideoPath={webcamVideoPath || undefined}
|
||||
webcamLayoutPreset={webcamLayoutPreset}
|
||||
webcamMaskShape={webcamMaskShape}
|
||||
webcamSizePreset={webcamSizePreset}
|
||||
webcamPosition={webcamPosition}
|
||||
onWebcamPositionChange={(pos) => updateState({ webcamPosition: pos })}
|
||||
onWebcamPositionDragEnd={commitState}
|
||||
onDurationChange={setDuration}
|
||||
onTimeUpdate={setCurrentTime}
|
||||
currentTime={currentTime}
|
||||
onPlayStateChange={setIsPlaying}
|
||||
onError={setError}
|
||||
wallpaper={wallpaper}
|
||||
zoomRegions={zoomRegions}
|
||||
selectedZoomId={selectedZoomId}
|
||||
onSelectZoom={handleSelectZoom}
|
||||
onZoomFocusChange={handleZoomFocusChange}
|
||||
onZoomFocusDragEnd={commitState}
|
||||
isPlaying={isPlaying}
|
||||
showShadow={shadowIntensity > 0}
|
||||
shadowIntensity={shadowIntensity}
|
||||
showBlur={showBlur}
|
||||
motionBlurAmount={motionBlurAmount}
|
||||
borderRadius={borderRadius}
|
||||
padding={padding}
|
||||
cropRegion={cropRegion}
|
||||
cursorRecordingData={cursorRecordingData}
|
||||
trimRegions={trimRegions}
|
||||
speedRegions={speedRegions}
|
||||
annotationRegions={annotationOnlyRegions}
|
||||
selectedAnnotationId={selectedAnnotationId}
|
||||
onSelectAnnotation={handleSelectAnnotation}
|
||||
onAnnotationPositionChange={handleAnnotationPositionChange}
|
||||
onAnnotationSizeChange={handleAnnotationSizeChange}
|
||||
blurRegions={blurRegions}
|
||||
selectedBlurId={selectedBlurId}
|
||||
onSelectBlur={handleSelectBlur}
|
||||
onBlurPositionChange={handleAnnotationPositionChange}
|
||||
onBlurSizeChange={handleAnnotationSizeChange}
|
||||
onBlurDataChange={handleBlurDataPreviewChange}
|
||||
onBlurDataCommit={commitState}
|
||||
cursorTelemetry={cursorTelemetry}
|
||||
cursorHighlight={effectiveCursorHighlight}
|
||||
cursorClickTimestamps={cursorClickTimestamps}
|
||||
showCursor={showCursor}
|
||||
cursorSize={cursorSize}
|
||||
cursorSmoothing={cursorSmoothing}
|
||||
cursorMotionBlur={cursorMotionBlur}
|
||||
cursorClickBounce={cursorClickBounce}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Playback controls */}
|
||||
<div className="w-full flex justify-center items-center h-12 flex-shrink-0 px-3 py-1.5 my-1.5">
|
||||
<div className="w-full max-w-[700px]">
|
||||
<PlaybackControls
|
||||
isPlaying={isPlaying}
|
||||
currentTime={currentTime}
|
||||
duration={duration}
|
||||
isFullscreen={isFullscreen}
|
||||
onToggleFullscreen={toggleFullscreen}
|
||||
onTogglePlayPause={togglePlayPause}
|
||||
onSeek={handleSeek}
|
||||
/>
|
||||
{/* Playback controls */}
|
||||
<div className="w-full flex justify-center items-center h-14 flex-shrink-0 px-4 py-2">
|
||||
<div className="w-full max-w-[760px]">
|
||||
<PlaybackControls
|
||||
isPlaying={isPlaying}
|
||||
currentTime={currentTime}
|
||||
duration={duration}
|
||||
isFullscreen={isFullscreen}
|
||||
onToggleFullscreen={toggleFullscreen}
|
||||
onTogglePlayPause={togglePlayPause}
|
||||
onSeek={handleSeek}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<PanelResizeHandle className="bg-[#09090b]/80 hover:bg-[#09090b] transition-colors rounded-full flex items-center justify-center">
|
||||
<div className="w-8 h-1 bg-white/20 rounded-full"></div>
|
||||
</PanelResizeHandle>
|
||||
|
||||
{/* Timeline section */}
|
||||
<Panel defaultSize={30} maxSize={60} minSize={30}>
|
||||
<div className="h-full bg-[#09090b] rounded-2xl border border-white/5 shadow-lg overflow-hidden flex flex-col">
|
||||
<TimelineEditor
|
||||
videoDuration={duration}
|
||||
currentTime={currentTime}
|
||||
onSeek={handleSeek}
|
||||
cursorTelemetry={cursorTelemetry}
|
||||
zoomRegions={zoomRegions}
|
||||
onZoomAdded={handleZoomAdded}
|
||||
onZoomSuggested={handleZoomSuggested}
|
||||
onZoomSpanChange={handleZoomSpanChange}
|
||||
onZoomDelete={handleZoomDelete}
|
||||
<div className="editor-settings-rail min-w-0 h-full">
|
||||
<SettingsPanel
|
||||
cursorHighlight={cursorHighlight}
|
||||
onCursorHighlightChange={(next) => pushState({ cursorHighlight: next })}
|
||||
cursorHighlightSupportsClicks={isMac}
|
||||
showNativeCursorSettings={showNativeCursorSettings}
|
||||
showCursor={showCursor}
|
||||
onShowCursorChange={setShowCursor}
|
||||
cursorSize={cursorSize}
|
||||
onCursorSizeChange={setCursorSize}
|
||||
cursorSmoothing={cursorSmoothing}
|
||||
onCursorSmoothingChange={setCursorSmoothing}
|
||||
cursorMotionBlur={cursorMotionBlur}
|
||||
onCursorMotionBlurChange={setCursorMotionBlur}
|
||||
cursorClickBounce={cursorClickBounce}
|
||||
onCursorClickBounceChange={setCursorClickBounce}
|
||||
selected={wallpaper}
|
||||
onWallpaperChange={(w) => pushState({ wallpaper: w })}
|
||||
selectedZoomDepth={
|
||||
selectedZoomId ? zoomRegions.find((z) => z.id === selectedZoomId)?.depth : null
|
||||
}
|
||||
onZoomDepthChange={(depth) => selectedZoomId && handleZoomDepthChange(depth)}
|
||||
selectedZoomCustomScale={
|
||||
selectedZoomId
|
||||
? (zoomRegions.find((z) => z.id === selectedZoomId)?.customScale ?? null)
|
||||
: null
|
||||
}
|
||||
onZoomCustomScaleChange={handleZoomCustomScaleChange}
|
||||
onZoomCustomScaleCommit={handleZoomCustomScaleCommit}
|
||||
selectedZoomFocusMode={
|
||||
selectedZoomId
|
||||
? (zoomRegions.find((z) => z.id === selectedZoomId)?.focusMode ?? "manual")
|
||||
: null
|
||||
}
|
||||
onZoomFocusModeChange={(mode) =>
|
||||
selectedZoomId && handleZoomFocusModeChange(mode)
|
||||
}
|
||||
selectedZoomFocus={
|
||||
selectedZoomId
|
||||
? (zoomRegions.find((z) => z.id === selectedZoomId)?.focus ?? null)
|
||||
: null
|
||||
}
|
||||
onZoomFocusCoordinateChange={(focus) =>
|
||||
selectedZoomId && handleZoomFocusChange(selectedZoomId, focus)
|
||||
}
|
||||
onZoomFocusCoordinateCommit={commitState}
|
||||
hasCursorTelemetry={cursorTelemetry.length > 0}
|
||||
selectedZoomId={selectedZoomId}
|
||||
onSelectZoom={handleSelectZoom}
|
||||
trimRegions={trimRegions}
|
||||
onTrimAdded={handleTrimAdded}
|
||||
onTrimSpanChange={handleTrimSpanChange}
|
||||
onTrimDelete={handleTrimDelete}
|
||||
onZoomDelete={handleZoomDelete}
|
||||
selectedZoomRotationPreset={
|
||||
selectedZoomId
|
||||
? (zoomRegions.find((z) => z.id === selectedZoomId)?.rotationPreset ?? null)
|
||||
: null
|
||||
}
|
||||
onZoomRotationPresetChange={handleZoomRotationPresetChange}
|
||||
selectedTrimId={selectedTrimId}
|
||||
onSelectTrim={handleSelectTrim}
|
||||
speedRegions={speedRegions}
|
||||
onSpeedAdded={handleSpeedAdded}
|
||||
onSpeedSpanChange={handleSpeedSpanChange}
|
||||
onSpeedDelete={handleSpeedDelete}
|
||||
selectedSpeedId={selectedSpeedId}
|
||||
onSelectSpeed={handleSelectSpeed}
|
||||
annotationRegions={annotationOnlyRegions}
|
||||
onAnnotationAdded={handleAnnotationAdded}
|
||||
onAnnotationSpanChange={handleAnnotationSpanChange}
|
||||
onAnnotationDelete={handleAnnotationDelete}
|
||||
selectedAnnotationId={selectedAnnotationId}
|
||||
onSelectAnnotation={handleSelectAnnotation}
|
||||
blurRegions={blurRegions}
|
||||
onBlurAdded={handleBlurAdded}
|
||||
onBlurSpanChange={handleAnnotationSpanChange}
|
||||
onBlurDelete={handleAnnotationDelete}
|
||||
selectedBlurId={selectedBlurId}
|
||||
onSelectBlur={handleSelectBlur}
|
||||
onTrimDelete={handleTrimDelete}
|
||||
shadowIntensity={shadowIntensity}
|
||||
onShadowChange={(v) => updateState({ shadowIntensity: v })}
|
||||
onShadowCommit={commitState}
|
||||
showBlur={showBlur}
|
||||
onBlurChange={(v) => pushState({ showBlur: v })}
|
||||
motionBlurAmount={motionBlurAmount}
|
||||
onMotionBlurChange={(v) => updateState({ motionBlurAmount: v })}
|
||||
onMotionBlurCommit={commitState}
|
||||
borderRadius={borderRadius}
|
||||
onBorderRadiusChange={(v) => updateState({ borderRadius: v })}
|
||||
onBorderRadiusCommit={commitState}
|
||||
padding={padding}
|
||||
onPaddingChange={(v) => updateState({ padding: v })}
|
||||
onPaddingCommit={commitState}
|
||||
cropRegion={cropRegion}
|
||||
onCropChange={(r) => pushState({ cropRegion: r })}
|
||||
aspectRatio={aspectRatio}
|
||||
onAspectRatioChange={(ar) =>
|
||||
hasWebcam={Boolean(webcamVideoPath)}
|
||||
webcamLayoutPreset={webcamLayoutPreset}
|
||||
onWebcamLayoutPresetChange={(preset) =>
|
||||
pushState({
|
||||
aspectRatio: ar,
|
||||
webcamLayoutPreset:
|
||||
(isPortraitAspectRatio(ar) && webcamLayoutPreset === "dual-frame") ||
|
||||
(!isPortraitAspectRatio(ar) && webcamLayoutPreset === "vertical-stack")
|
||||
? "picture-in-picture"
|
||||
: webcamLayoutPreset,
|
||||
webcamLayoutPreset: preset,
|
||||
webcamPosition: preset === "picture-in-picture" ? webcamPosition : null,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</Panel>
|
||||
</PanelGroup>
|
||||
</div>
|
||||
|
||||
{/* Right section: settings panel */}
|
||||
<div className="flex-[3] min-w-[280px] max-w-[420px] h-full">
|
||||
<SettingsPanel
|
||||
cursorHighlight={cursorHighlight}
|
||||
onCursorHighlightChange={(next) => pushState({ cursorHighlight: next })}
|
||||
cursorHighlightSupportsClicks={isMac}
|
||||
selected={wallpaper}
|
||||
onWallpaperChange={(w) => pushState({ wallpaper: w })}
|
||||
selectedZoomDepth={
|
||||
selectedZoomId ? zoomRegions.find((z) => z.id === selectedZoomId)?.depth : null
|
||||
}
|
||||
onZoomDepthChange={(depth) => selectedZoomId && handleZoomDepthChange(depth)}
|
||||
selectedZoomCustomScale={
|
||||
selectedZoomId
|
||||
? (zoomRegions.find((z) => z.id === selectedZoomId)?.customScale ?? null)
|
||||
: null
|
||||
}
|
||||
onZoomCustomScaleChange={handleZoomCustomScaleChange}
|
||||
onZoomCustomScaleCommit={handleZoomCustomScaleCommit}
|
||||
selectedZoomFocusMode={
|
||||
selectedZoomId
|
||||
? (zoomRegions.find((z) => z.id === selectedZoomId)?.focusMode ?? "manual")
|
||||
: null
|
||||
}
|
||||
onZoomFocusModeChange={(mode) => selectedZoomId && handleZoomFocusModeChange(mode)}
|
||||
selectedZoomFocus={
|
||||
selectedZoomId
|
||||
? (zoomRegions.find((z) => z.id === selectedZoomId)?.focus ?? null)
|
||||
: null
|
||||
}
|
||||
onZoomFocusCoordinateChange={(focus) =>
|
||||
selectedZoomId && handleZoomFocusChange(selectedZoomId, focus)
|
||||
}
|
||||
onZoomFocusCoordinateCommit={commitState}
|
||||
hasCursorTelemetry={cursorTelemetry.length > 0}
|
||||
selectedZoomId={selectedZoomId}
|
||||
onZoomDelete={handleZoomDelete}
|
||||
selectedZoomRotationPreset={
|
||||
selectedZoomId
|
||||
? (zoomRegions.find((z) => z.id === selectedZoomId)?.rotationPreset ?? null)
|
||||
: null
|
||||
}
|
||||
onZoomRotationPresetChange={handleZoomRotationPresetChange}
|
||||
selectedTrimId={selectedTrimId}
|
||||
onTrimDelete={handleTrimDelete}
|
||||
shadowIntensity={shadowIntensity}
|
||||
onShadowChange={(v) => updateState({ shadowIntensity: v })}
|
||||
onShadowCommit={commitState}
|
||||
showBlur={showBlur}
|
||||
onBlurChange={(v) => pushState({ showBlur: v })}
|
||||
motionBlurAmount={motionBlurAmount}
|
||||
onMotionBlurChange={(v) => updateState({ motionBlurAmount: v })}
|
||||
onMotionBlurCommit={commitState}
|
||||
borderRadius={borderRadius}
|
||||
onBorderRadiusChange={(v) => updateState({ borderRadius: v })}
|
||||
onBorderRadiusCommit={commitState}
|
||||
padding={padding}
|
||||
onPaddingChange={(v) => updateState({ padding: v })}
|
||||
onPaddingCommit={commitState}
|
||||
cropRegion={cropRegion}
|
||||
onCropChange={(r) => pushState({ cropRegion: r })}
|
||||
aspectRatio={aspectRatio}
|
||||
hasWebcam={Boolean(webcamVideoPath)}
|
||||
webcamLayoutPreset={webcamLayoutPreset}
|
||||
onWebcamLayoutPresetChange={(preset) =>
|
||||
pushState({
|
||||
webcamLayoutPreset: preset,
|
||||
webcamPosition: preset === "picture-in-picture" ? webcamPosition : null,
|
||||
})
|
||||
}
|
||||
webcamMaskShape={webcamMaskShape}
|
||||
onWebcamMaskShapeChange={(shape) => pushState({ webcamMaskShape: shape })}
|
||||
webcamSizePreset={webcamSizePreset}
|
||||
onWebcamSizePresetChange={(v) => updateState({ webcamSizePreset: v })}
|
||||
onWebcamSizePresetCommit={commitState}
|
||||
videoElement={videoPlaybackRef.current?.video || null}
|
||||
exportQuality={exportQuality}
|
||||
onExportQualityChange={setExportQuality}
|
||||
exportFormat={exportFormat}
|
||||
onExportFormatChange={setExportFormat}
|
||||
gifFrameRate={gifFrameRate}
|
||||
onGifFrameRateChange={setGifFrameRate}
|
||||
gifLoop={gifLoop}
|
||||
onGifLoopChange={setGifLoop}
|
||||
gifSizePreset={gifSizePreset}
|
||||
onGifSizePresetChange={setGifSizePreset}
|
||||
gifOutputDimensions={calculateOutputDimensions(
|
||||
videoPlaybackRef.current?.video?.videoWidth || 1920,
|
||||
videoPlaybackRef.current?.video?.videoHeight || 1080,
|
||||
gifSizePreset,
|
||||
GIF_SIZE_PRESETS,
|
||||
aspectRatio === "native"
|
||||
? getNativeAspectRatioValue(
|
||||
webcamMaskShape={webcamMaskShape}
|
||||
onWebcamMaskShapeChange={(shape) => pushState({ webcamMaskShape: shape })}
|
||||
webcamSizePreset={webcamSizePreset}
|
||||
onWebcamSizePresetChange={(v) => updateState({ webcamSizePreset: v })}
|
||||
onWebcamSizePresetCommit={commitState}
|
||||
videoElement={videoPlaybackRef.current?.video || null}
|
||||
exportQuality={exportQuality}
|
||||
onExportQualityChange={setExportQuality}
|
||||
exportFormat={exportFormat}
|
||||
onExportFormatChange={setExportFormat}
|
||||
gifFrameRate={gifFrameRate}
|
||||
onGifFrameRateChange={setGifFrameRate}
|
||||
gifLoop={gifLoop}
|
||||
onGifLoopChange={setGifLoop}
|
||||
gifSizePreset={gifSizePreset}
|
||||
onGifSizePresetChange={setGifSizePreset}
|
||||
gifOutputDimensions={calculateOutputDimensions(
|
||||
videoPlaybackRef.current?.video?.videoWidth || 1920,
|
||||
videoPlaybackRef.current?.video?.videoHeight || 1080,
|
||||
cropRegion,
|
||||
)
|
||||
: getAspectRatioValue(aspectRatio),
|
||||
)}
|
||||
onExport={handleOpenExportDialog}
|
||||
selectedAnnotationId={selectedAnnotationId}
|
||||
annotationRegions={annotationOnlyRegions}
|
||||
onAnnotationContentChange={handleAnnotationContentChange}
|
||||
onAnnotationTypeChange={handleAnnotationTypeChange}
|
||||
onAnnotationStyleChange={handleAnnotationStyleChange}
|
||||
onAnnotationFigureDataChange={handleAnnotationFigureDataChange}
|
||||
onAnnotationDuplicate={handleAnnotationDuplicate}
|
||||
onAnnotationDelete={handleAnnotationDelete}
|
||||
selectedBlurId={selectedBlurId}
|
||||
blurRegions={blurRegions}
|
||||
onBlurDataChange={handleBlurDataPanelChange}
|
||||
onBlurDataCommit={commitState}
|
||||
onBlurDelete={handleAnnotationDelete}
|
||||
selectedSpeedId={selectedSpeedId}
|
||||
selectedSpeedValue={
|
||||
selectedSpeedId
|
||||
? (speedRegions.find((r) => r.id === selectedSpeedId)?.speed ?? null)
|
||||
: null
|
||||
}
|
||||
onSpeedChange={handleSpeedChange}
|
||||
onSpeedDelete={handleSpeedDelete}
|
||||
unsavedExport={unsavedExport}
|
||||
onSaveUnsavedExport={handleSaveUnsavedExport}
|
||||
onSaveDiagnostic={handleSaveDiagnostic}
|
||||
/>
|
||||
</div>
|
||||
gifSizePreset,
|
||||
GIF_SIZE_PRESETS,
|
||||
aspectRatio === "native"
|
||||
? getNativeAspectRatioValue(
|
||||
videoPlaybackRef.current?.video?.videoWidth || 1920,
|
||||
videoPlaybackRef.current?.video?.videoHeight || 1080,
|
||||
cropRegion,
|
||||
)
|
||||
: getAspectRatioValue(aspectRatio),
|
||||
)}
|
||||
onExport={handleOpenExportDialog}
|
||||
selectedAnnotationId={selectedAnnotationId}
|
||||
annotationRegions={annotationOnlyRegions}
|
||||
onAnnotationContentChange={handleAnnotationContentChange}
|
||||
onAnnotationTypeChange={handleAnnotationTypeChange}
|
||||
onAnnotationStyleChange={handleAnnotationStyleChange}
|
||||
onAnnotationFigureDataChange={handleAnnotationFigureDataChange}
|
||||
onAnnotationDuplicate={handleAnnotationDuplicate}
|
||||
onAnnotationDelete={handleAnnotationDelete}
|
||||
selectedBlurId={selectedBlurId}
|
||||
blurRegions={blurRegions}
|
||||
onBlurDataChange={handleBlurDataPanelChange}
|
||||
onBlurDataCommit={commitState}
|
||||
onBlurDelete={handleAnnotationDelete}
|
||||
selectedSpeedId={selectedSpeedId}
|
||||
selectedSpeedValue={
|
||||
selectedSpeedId
|
||||
? (speedRegions.find((r) => r.id === selectedSpeedId)?.speed ?? null)
|
||||
: null
|
||||
}
|
||||
onSpeedChange={handleSpeedChange}
|
||||
onSpeedDelete={handleSpeedDelete}
|
||||
unsavedExport={unsavedExport}
|
||||
onSaveUnsavedExport={handleSaveUnsavedExport}
|
||||
onSaveDiagnostic={handleSaveDiagnostic}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<PanelResizeHandle className="editor-resize-handle group">
|
||||
<div className="w-10 h-1 bg-white/20 rounded-full transition-colors group-hover:bg-[#34B27B]/70"></div>
|
||||
</PanelResizeHandle>
|
||||
|
||||
{/* Full-width timeline */}
|
||||
<Panel defaultSize={33} maxSize={54} minSize={24} className="min-h-[210px]">
|
||||
<div className="editor-timeline-panel h-full overflow-hidden flex flex-col">
|
||||
<TimelineEditor
|
||||
videoDuration={duration}
|
||||
currentTime={currentTime}
|
||||
onSeek={handleSeek}
|
||||
cursorTelemetry={cursorTelemetry}
|
||||
zoomRegions={zoomRegions}
|
||||
onZoomAdded={handleZoomAdded}
|
||||
onZoomSuggested={handleZoomSuggested}
|
||||
onZoomSpanChange={handleZoomSpanChange}
|
||||
onZoomDelete={handleZoomDelete}
|
||||
selectedZoomId={selectedZoomId}
|
||||
onSelectZoom={handleSelectZoom}
|
||||
trimRegions={trimRegions}
|
||||
onTrimAdded={handleTrimAdded}
|
||||
onTrimSpanChange={handleTrimSpanChange}
|
||||
onTrimDelete={handleTrimDelete}
|
||||
selectedTrimId={selectedTrimId}
|
||||
onSelectTrim={handleSelectTrim}
|
||||
speedRegions={speedRegions}
|
||||
onSpeedAdded={handleSpeedAdded}
|
||||
onSpeedSpanChange={handleSpeedSpanChange}
|
||||
onSpeedDelete={handleSpeedDelete}
|
||||
selectedSpeedId={selectedSpeedId}
|
||||
onSelectSpeed={handleSelectSpeed}
|
||||
annotationRegions={annotationOnlyRegions}
|
||||
onAnnotationAdded={handleAnnotationAdded}
|
||||
onAnnotationSpanChange={handleAnnotationSpanChange}
|
||||
onAnnotationDelete={handleAnnotationDelete}
|
||||
selectedAnnotationId={selectedAnnotationId}
|
||||
onSelectAnnotation={handleSelectAnnotation}
|
||||
blurRegions={blurRegions}
|
||||
onBlurAdded={handleBlurAdded}
|
||||
onBlurSpanChange={handleAnnotationSpanChange}
|
||||
onBlurDelete={handleAnnotationDelete}
|
||||
selectedBlurId={selectedBlurId}
|
||||
onSelectBlur={handleSelectBlur}
|
||||
aspectRatio={aspectRatio}
|
||||
onAspectRatioChange={(ar) =>
|
||||
pushState({
|
||||
aspectRatio: ar,
|
||||
webcamLayoutPreset:
|
||||
(isPortraitAspectRatio(ar) && webcamLayoutPreset === "dual-frame") ||
|
||||
(!isPortraitAspectRatio(ar) && webcamLayoutPreset === "vertical-stack")
|
||||
? "picture-in-picture"
|
||||
: webcamLayoutPreset,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</Panel>
|
||||
</PanelGroup>
|
||||
</div>
|
||||
|
||||
<ExportDialog
|
||||
|
||||
@@ -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,
|
||||
@@ -37,6 +53,10 @@ import {
|
||||
type AnnotationRegion,
|
||||
type BlurData,
|
||||
computeRotation3DContainScale,
|
||||
DEFAULT_CURSOR_CLICK_BOUNCE,
|
||||
DEFAULT_CURSOR_MOTION_BLUR,
|
||||
DEFAULT_CURSOR_SIZE,
|
||||
DEFAULT_CURSOR_SMOOTHING,
|
||||
DEFAULT_ROTATION_3D,
|
||||
getZoomScale,
|
||||
isRotation3DIdentity,
|
||||
@@ -80,6 +100,13 @@ import {
|
||||
type MotionBlurState,
|
||||
} from "./videoPlayback/zoomTransform";
|
||||
|
||||
type BlurPreviewCanvasSource = {
|
||||
clientHeight?: number;
|
||||
clientWidth?: number;
|
||||
height: number;
|
||||
width: number;
|
||||
};
|
||||
|
||||
interface VideoPlaybackProps {
|
||||
videoPath: string;
|
||||
webcamVideoPath?: string;
|
||||
@@ -111,6 +138,7 @@ interface VideoPlaybackProps {
|
||||
trimRegions?: TrimRegion[];
|
||||
speedRegions?: SpeedRegion[];
|
||||
aspectRatio: AspectRatio;
|
||||
cursorRecordingData?: CursorRecordingData | null;
|
||||
annotationRegions?: AnnotationRegion[];
|
||||
selectedAnnotationId?: string | null;
|
||||
onSelectAnnotation?: (id: string | null) => void;
|
||||
@@ -126,6 +154,11 @@ interface VideoPlaybackProps {
|
||||
cursorTelemetry?: import("./types").CursorTelemetryPoint[];
|
||||
cursorHighlight?: CursorHighlightConfig;
|
||||
cursorClickTimestamps?: number[];
|
||||
showCursor?: boolean;
|
||||
cursorSize?: number;
|
||||
cursorSmoothing?: number;
|
||||
cursorMotionBlur?: number;
|
||||
cursorClickBounce?: number;
|
||||
}
|
||||
|
||||
export interface VideoPlaybackRef {
|
||||
@@ -171,6 +204,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
trimRegions = [],
|
||||
speedRegions = [],
|
||||
aspectRatio,
|
||||
cursorRecordingData = null,
|
||||
annotationRegions = [],
|
||||
selectedAnnotationId,
|
||||
onSelectAnnotation,
|
||||
@@ -186,6 +220,11 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
cursorTelemetry = [],
|
||||
cursorHighlight = DEFAULT_CURSOR_HIGHLIGHT,
|
||||
cursorClickTimestamps = [],
|
||||
showCursor = true,
|
||||
cursorSize = DEFAULT_CURSOR_SIZE,
|
||||
cursorSmoothing = DEFAULT_CURSOR_SMOOTHING,
|
||||
cursorMotionBlur = DEFAULT_CURSOR_MOTION_BLUR,
|
||||
cursorClickBounce = DEFAULT_CURSOR_CLICK_BOUNCE,
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
@@ -214,6 +253,20 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
const cursorHighlightRef = useRef<CursorHighlightConfig>(DEFAULT_CURSOR_HIGHLIGHT);
|
||||
const cursorClickTimestampsRef = useRef<number[]>([]);
|
||||
const cursorHighlightGraphicsRef = useRef<Graphics | null>(null);
|
||||
const showCursorRef = useRef(showCursor);
|
||||
const cursorSizeRef = useRef(cursorSize);
|
||||
const cursorSmoothingRef = useRef(cursorSmoothing);
|
||||
const cursorMotionBlurRef = useRef(cursorMotionBlur);
|
||||
const cursorClickBounceRef = useRef(cursorClickBounce);
|
||||
const cursorRecordingDataRef = useRef<CursorRecordingData | null>(cursorRecordingData);
|
||||
const hasNativeCursorRecordingRef = useRef(hasNativeCursorRecordingData(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 selectedZoomIdRef = useRef<string | null>(null);
|
||||
const animationStateRef = useRef({
|
||||
scale: 1,
|
||||
@@ -256,6 +309,12 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
const videoReadyRafRef = useRef<number | null>(null);
|
||||
const smoothedAutoFocusRef = useRef<ZoomFocus | null>(null);
|
||||
const prevTargetProgressRef = useRef(0);
|
||||
const blurPreviewSnapshotRef = useRef<{
|
||||
bucket: number;
|
||||
canvas: BlurPreviewCanvasSource | null;
|
||||
height: number;
|
||||
width: number;
|
||||
}>({ bucket: -1, canvas: null, height: 0, width: 0 });
|
||||
|
||||
const updateOverlayForRegion = useCallback(
|
||||
(region: ZoomRegion | null, focusOverride?: ZoomFocus) => {
|
||||
@@ -548,6 +607,37 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
cursorClickTimestampsRef.current = cursorClickTimestamps;
|
||||
}, [cursorClickTimestamps]);
|
||||
|
||||
useEffect(() => {
|
||||
cursorRecordingDataRef.current = cursorRecordingData;
|
||||
hasNativeCursorRecordingRef.current = hasNativeCursorRecordingData(cursorRecordingData);
|
||||
resetNativeCursorSmoothingState(nativeCursorSmoothingStateRef.current);
|
||||
resetNativeCursorMotionBlurState(nativeCursorMotionBlurStateRef.current);
|
||||
}, [cursorRecordingData]);
|
||||
|
||||
useEffect(() => {
|
||||
showCursorRef.current = showCursor;
|
||||
}, [showCursor]);
|
||||
|
||||
useEffect(() => {
|
||||
cursorSizeRef.current = cursorSize;
|
||||
}, [cursorSize]);
|
||||
|
||||
useEffect(() => {
|
||||
cursorSmoothingRef.current = cursorSmoothing;
|
||||
}, [cursorSmoothing]);
|
||||
|
||||
useEffect(() => {
|
||||
cursorMotionBlurRef.current = cursorMotionBlur;
|
||||
}, [cursorMotionBlur]);
|
||||
|
||||
useEffect(() => {
|
||||
cursorClickBounceRef.current = cursorClickBounce;
|
||||
}, [cursorClickBounce]);
|
||||
|
||||
useEffect(() => {
|
||||
cropRegionRef.current = cropRegion;
|
||||
}, [cropRegion]);
|
||||
|
||||
useEffect(() => {
|
||||
selectedZoomIdRef.current = selectedZoomId;
|
||||
}, [selectedZoomId]);
|
||||
@@ -795,6 +885,12 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
cursorHighlightGraphicsRef.current = cursorHighlightGraphics;
|
||||
drawCursorHighlightGraphics(cursorHighlightGraphics, cursorHighlightRef.current);
|
||||
|
||||
const nativeCursorSprite = new Sprite(Texture.EMPTY);
|
||||
nativeCursorSprite.visible = false;
|
||||
nativeCursorSprite.eventMode = "none";
|
||||
videoContainer.addChild(nativeCursorSprite);
|
||||
nativeCursorSpriteRef.current = nativeCursorSprite;
|
||||
|
||||
animationStateRef.current = {
|
||||
scale: 1,
|
||||
focusX: DEFAULT_FOCUS.cx,
|
||||
@@ -862,6 +958,12 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
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;
|
||||
if (blurFilterRef.current) {
|
||||
@@ -1116,6 +1218,115 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
}
|
||||
}
|
||||
|
||||
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 && hasNativeCursorRecordingRef.current && showCursorRef.current) {
|
||||
const timeMs = currentTimeRef.current;
|
||||
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();
|
||||
}
|
||||
|
||||
const isMotionBlurActive =
|
||||
(motionBlurAmountRef.current || 0) > 0 && isPlayingRef.current && !isScrubbingRef.current;
|
||||
|
||||
@@ -1354,6 +1565,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");
|
||||
@@ -1448,15 +1671,32 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
region: blurRegion,
|
||||
})),
|
||||
].sort((a, b) => a.region.zIndex - b.region.zIndex);
|
||||
const previewSnapshotBucket = Math.floor(currentTime * 10);
|
||||
const previewSnapshotCanvas =
|
||||
filteredBlurRegions.length > 0
|
||||
? (() => {
|
||||
const cached = blurPreviewSnapshotRef.current;
|
||||
if (
|
||||
cached.bucket === previewSnapshotBucket &&
|
||||
cached.width === overlaySize.width &&
|
||||
cached.height === overlaySize.height
|
||||
) {
|
||||
return cached.canvas;
|
||||
}
|
||||
|
||||
const app = appRef.current;
|
||||
if (!app?.renderer?.extract) return null;
|
||||
if (!app?.renderer?.extract) return cached.canvas;
|
||||
try {
|
||||
return app.renderer.extract.canvas(app.stage);
|
||||
const canvas = app.renderer.extract.canvas(app.stage);
|
||||
blurPreviewSnapshotRef.current = {
|
||||
bucket: previewSnapshotBucket,
|
||||
canvas,
|
||||
height: overlaySize.height,
|
||||
width: overlaySize.width,
|
||||
};
|
||||
return canvas;
|
||||
} catch {
|
||||
return null;
|
||||
return cached.canvas;
|
||||
}
|
||||
})()
|
||||
: null;
|
||||
@@ -1528,7 +1768,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
: item.region.id === selectedAnnotationId
|
||||
}
|
||||
previewSourceCanvas={previewSnapshotCanvas}
|
||||
previewFrameVersion={Math.round(currentTime * 1000)}
|
||||
previewFrameVersion={previewSnapshotBucket}
|
||||
/>
|
||||
));
|
||||
})()}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -101,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?.();
|
||||
@@ -130,12 +130,12 @@ 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">
|
||||
<span className="text-[11px] font-semibold whitespace-nowrap">
|
||||
{zoomCustomScale != null
|
||||
? `${zoomCustomScale.toFixed(2)}×`
|
||||
: ZOOM_LABELS[zoomDepth] || `${zoomDepth}×`}
|
||||
@@ -150,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;
|
||||
}
|
||||
|
||||
@@ -3,31 +3,21 @@ import { useRow } from "dnd-timeline";
|
||||
|
||||
interface RowProps extends RowDefinition {
|
||||
children: React.ReactNode;
|
||||
label?: string;
|
||||
hint?: string;
|
||||
isEmpty?: boolean;
|
||||
labelColor?: string;
|
||||
}
|
||||
|
||||
export default function Row({ id, children, label, hint, isEmpty, labelColor = "#666" }: RowProps) {
|
||||
export default function Row({ id, children, hint, isEmpty }: RowProps) {
|
||||
const { setNodeRef, rowWrapperStyle, rowStyle } = useRow({ id });
|
||||
|
||||
return (
|
||||
<div
|
||||
className="border-b border-[#18181b] bg-[#18181b] relative"
|
||||
style={{ ...rowWrapperStyle, minHeight: 48, marginBottom: 4 }}
|
||||
className="border-b border-white/[0.055] bg-[#101116] relative overflow-hidden"
|
||||
style={{ ...rowWrapperStyle, minHeight: 36 }}
|
||||
>
|
||||
{label && (
|
||||
<div
|
||||
className="absolute left-1.5 top-1/2 -translate-y-1/2 text-[9px] font-semibold uppercase tracking-widest z-20 pointer-events-none select-none"
|
||||
style={{ color: labelColor, writingMode: "horizontal-tb" }}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
)}
|
||||
{isEmpty && hint && (
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none select-none z-10">
|
||||
<span className="text-[11px] text-white/15 font-medium">{hint}</span>
|
||||
<span className="text-[11px] text-white/[0.12] font-medium">{hint}</span>
|
||||
</div>
|
||||
)}
|
||||
<div ref={setNodeRef} style={rowStyle}>
|
||||
|
||||
@@ -26,7 +26,6 @@ import { matchesShortcut } from "@/lib/shortcuts";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ASPECT_RATIOS, type AspectRatio, getAspectRatioLabel } from "@/utils/aspectRatioUtils";
|
||||
import { formatShortcut } from "@/utils/platformUtils";
|
||||
import { TutorialHelp } from "../TutorialHelp";
|
||||
import type {
|
||||
AnnotationRegion,
|
||||
CursorTelemetryPoint,
|
||||
@@ -378,7 +377,7 @@ function PlaybackCursor({
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="absolute top-0 bottom-0 w-[2px] bg-[#34B27B] shadow-[0_0_10px_rgba(52,178,123,0.5)] cursor-ew-resize pointer-events-auto hover:shadow-[0_0_15px_rgba(52,178,123,0.7)] transition-shadow"
|
||||
className="absolute top-0 bottom-0 w-[2px] bg-[#6C55FF] shadow-[0_0_18px_rgba(108,85,255,0.68)] cursor-ew-resize pointer-events-auto hover:shadow-[0_0_24px_rgba(108,85,255,0.85)] transition-shadow"
|
||||
style={{
|
||||
[sideProperty]: `${offset}px`,
|
||||
}}
|
||||
@@ -389,10 +388,10 @@ function PlaybackCursor({
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="absolute -top-1 left-1/2 -translate-x-1/2 hover:scale-125 transition-transform"
|
||||
style={{ width: "16px", height: "16px" }}
|
||||
className="absolute -top-2 left-1/2 -translate-x-1/2 hover:scale-110 transition-transform"
|
||||
style={{ width: "20px", height: "20px" }}
|
||||
>
|
||||
<div className="w-3 h-3 mx-auto mt-[2px] bg-[#34B27B] rotate-45 rounded-sm shadow-lg border border-white/20" />
|
||||
<div className="w-4 h-4 mx-auto mt-[2px] bg-[#6C55FF] rotate-45 rounded-[5px] shadow-lg shadow-[#6C55FF]/30 border border-white/30" />
|
||||
</div>
|
||||
{isDragging && (
|
||||
<div className="absolute -top-6 left-1/2 -translate-x-1/2 px-1.5 py-0.5 rounded bg-black/80 text-[10px] text-white/90 font-medium tabular-nums whitespace-nowrap border border-white/10 shadow-lg pointer-events-none">
|
||||
@@ -475,7 +474,7 @@ function TimelineAxis({
|
||||
|
||||
return (
|
||||
<div
|
||||
className="h-8 bg-[#09090b] border-b border-white/5 relative overflow-hidden select-none"
|
||||
className="h-9 bg-[#0c0d10] border-b border-white/[0.07] relative overflow-hidden select-none"
|
||||
style={{
|
||||
[sideProperty === "right" ? "marginRight" : "marginLeft"]: `${sidebarWidth}px`,
|
||||
}}
|
||||
@@ -486,7 +485,7 @@ function TimelineAxis({
|
||||
return (
|
||||
<div
|
||||
key={`minor-${time}`}
|
||||
className="absolute bottom-0 h-1 w-[1px] bg-white/5"
|
||||
className="absolute bottom-0 h-1.5 w-[1px] bg-white/[0.07]"
|
||||
style={{ [sideProperty]: `${offset}px` }}
|
||||
/>
|
||||
);
|
||||
@@ -508,7 +507,7 @@ function TimelineAxis({
|
||||
return (
|
||||
<div key={marker.time} style={markerStyle}>
|
||||
<div className="flex flex-col items-center pb-1">
|
||||
<div className="h-2 w-[1px] bg-white/20 mb-1" />
|
||||
<div className="h-2.5 w-[1px] bg-white/20 mb-1" />
|
||||
<span
|
||||
className={cn(
|
||||
"text-[10px] font-medium tabular-nums tracking-tight",
|
||||
@@ -659,11 +658,11 @@ function Timeline({
|
||||
<div
|
||||
ref={setRefs}
|
||||
style={style}
|
||||
className="select-none bg-[#09090b] min-h-[140px] relative cursor-pointer group"
|
||||
className="select-none bg-[#0b0c0f] min-h-[190px] relative cursor-pointer group"
|
||||
onClick={handleTimelineClick}
|
||||
onWheel={handleTimelineWheel}
|
||||
>
|
||||
<div className="absolute inset-0 bg-[linear-gradient(to_right,#ffffff03_1px,transparent_1px)] bg-[length:20px_100%] pointer-events-none" />
|
||||
<div className="absolute inset-0 bg-[linear-gradient(to_right,#ffffff05_1px,transparent_1px)] bg-[length:24px_100%] pointer-events-none" />
|
||||
<TimelineAxis videoDurationMs={videoDurationMs} currentTimeMs={currentTimeMs} />
|
||||
<PlaybackCursor
|
||||
currentTimeMs={currentTimeMs}
|
||||
@@ -1448,14 +1447,14 @@ export default function TimelineEditor({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col bg-[#09090b] overflow-hidden">
|
||||
<div className="flex items-center gap-2 px-4 py-2 border-b border-white/5 bg-[#09090b]">
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="flex-1 min-h-0 flex flex-col bg-[#09090b] overflow-hidden">
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 border-b border-white/[0.06] bg-[#08090b]/95">
|
||||
<div className="flex items-center gap-0.5 rounded-xl border border-white/[0.06] bg-white/[0.025] p-0.5">
|
||||
<Button
|
||||
onClick={handleAddZoom}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-slate-400 hover:text-[#34B27B] hover:bg-[#34B27B]/10 transition-all"
|
||||
className="h-7 w-7 rounded-lg text-slate-400 hover:text-[#34B27B] hover:bg-[#34B27B]/10 transition-all"
|
||||
title={t("buttons.addZoom")}
|
||||
>
|
||||
<ZoomIn className="w-4 h-4" />
|
||||
@@ -1464,7 +1463,7 @@ export default function TimelineEditor({
|
||||
onClick={handleSuggestZooms}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-slate-400 hover:text-[#34B27B] hover:bg-[#34B27B]/10 transition-all"
|
||||
className="h-7 w-7 rounded-lg text-slate-400 hover:text-[#34B27B] hover:bg-[#34B27B]/10 transition-all"
|
||||
title={t("buttons.suggestZooms")}
|
||||
>
|
||||
<WandSparkles className="w-4 h-4" />
|
||||
@@ -1473,7 +1472,7 @@ export default function TimelineEditor({
|
||||
onClick={handleAddTrim}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-slate-400 hover:text-[#ef4444] hover:bg-[#ef4444]/10 transition-all"
|
||||
className="h-7 w-7 rounded-lg text-slate-400 hover:text-[#ef4444] hover:bg-[#ef4444]/10 transition-all"
|
||||
title={t("buttons.addTrim")}
|
||||
>
|
||||
<Scissors className="w-4 h-4" />
|
||||
@@ -1482,7 +1481,7 @@ export default function TimelineEditor({
|
||||
onClick={handleAddAnnotation}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-slate-400 hover:text-[#B4A046] hover:bg-[#B4A046]/10 transition-all"
|
||||
className="h-7 w-7 rounded-lg text-slate-400 hover:text-[#B4A046] hover:bg-[#B4A046]/10 transition-all"
|
||||
title={t("buttons.addAnnotation")}
|
||||
>
|
||||
<MessageSquare className="w-4 h-4" />
|
||||
@@ -1491,7 +1490,7 @@ export default function TimelineEditor({
|
||||
onClick={handleAddBlur}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-slate-400 hover:text-[#7dd3fc] hover:bg-[#7dd3fc]/10 transition-all"
|
||||
className="h-7 w-7 rounded-lg text-slate-400 hover:text-[#7dd3fc] hover:bg-[#7dd3fc]/10 transition-all"
|
||||
title={t("buttons.addBlur")}
|
||||
>
|
||||
<svg
|
||||
@@ -1510,19 +1509,19 @@ export default function TimelineEditor({
|
||||
onClick={handleAddSpeed}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-slate-400 hover:text-[#d97706] hover:bg-[#d97706]/10 transition-all"
|
||||
className="h-7 w-7 rounded-lg text-slate-400 hover:text-[#d97706] hover:bg-[#d97706]/10 transition-all"
|
||||
title={t("buttons.addSpeed")}
|
||||
>
|
||||
<Gauge className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs text-slate-400 hover:text-slate-200 hover:bg-white/10 transition-all gap-1"
|
||||
className="h-7 px-2 rounded-lg text-[11px] text-slate-400 hover:text-slate-200 hover:bg-white/[0.07] transition-all gap-1"
|
||||
>
|
||||
<span className="font-medium">{getAspectRatioLabel(aspectRatio)}</span>
|
||||
<ChevronDown className="w-3 h-3" />
|
||||
@@ -1541,11 +1540,9 @@ export default function TimelineEditor({
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<div className="w-[1px] h-4 bg-white/10" />
|
||||
<TutorialHelp />
|
||||
</div>
|
||||
<div className="flex-1" />
|
||||
<div className="flex items-center gap-4 text-[10px] text-slate-500 font-medium">
|
||||
<div className="hidden md:flex items-center gap-3 text-[10px] text-slate-500 font-medium">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<kbd className="px-1.5 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-sans">
|
||||
{scrollLabels.pan}
|
||||
@@ -1562,7 +1559,7 @@ export default function TimelineEditor({
|
||||
</div>
|
||||
<div
|
||||
ref={timelineContainerRef}
|
||||
className="flex-1 overflow-hidden bg-[#09090b] relative"
|
||||
className="flex-1 min-h-0 overflow-auto custom-scrollbar bg-[#09090b] relative"
|
||||
onClick={() => setSelectedKeyframeId(null)}
|
||||
>
|
||||
<TimelineWrapper
|
||||
|
||||
@@ -172,6 +172,11 @@ export interface CursorTelemetryPoint {
|
||||
cy: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_CURSOR_SIZE = 3.0;
|
||||
export const DEFAULT_CURSOR_SMOOTHING = 0.67;
|
||||
export const DEFAULT_CURSOR_MOTION_BLUR = 0.35;
|
||||
export const DEFAULT_CURSOR_CLICK_BOUNCE = 2.5;
|
||||
|
||||
export interface TrimRegion {
|
||||
id: string;
|
||||
startMs: number;
|
||||
@@ -294,7 +299,7 @@ export const DEFAULT_BLUR_FREEHAND_POINTS: Array<{ x: number; y: number }> = [
|
||||
];
|
||||
|
||||
export const DEFAULT_BLUR_DATA: BlurData = {
|
||||
type: "blur",
|
||||
type: "mosaic",
|
||||
shape: "rectangle",
|
||||
color: "white",
|
||||
intensity: DEFAULT_BLUR_INTENSITY,
|
||||
|
||||
@@ -2,6 +2,10 @@ import { fixWebmDuration } from "@fix-webm-duration/fix";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { useScopedT } from "@/contexts/I18nContext";
|
||||
import {
|
||||
type NativeWindowsRecordingRequest,
|
||||
parseWindowHandleFromSourceId,
|
||||
} from "@/lib/nativeWindowsRecording";
|
||||
import { requestCameraAccess } from "@/lib/requestCameraAccess";
|
||||
|
||||
const TARGET_FRAME_RATE = 60;
|
||||
@@ -51,8 +55,12 @@ type UseScreenRecorderReturn = {
|
||||
setMicrophoneEnabled: (enabled: boolean) => void;
|
||||
microphoneDeviceId: string | undefined;
|
||||
setMicrophoneDeviceId: (deviceId: string | undefined) => void;
|
||||
microphoneDeviceName: string | undefined;
|
||||
setMicrophoneDeviceName: (deviceName: string | undefined) => void;
|
||||
webcamDeviceId: string | undefined;
|
||||
setWebcamDeviceId: (deviceId: string | undefined) => void;
|
||||
webcamDeviceName: string | undefined;
|
||||
setWebcamDeviceName: (deviceName: string | undefined) => void;
|
||||
systemAudioEnabled: boolean;
|
||||
setSystemAudioEnabled: (enabled: boolean) => void;
|
||||
webcamEnabled: boolean;
|
||||
@@ -64,6 +72,11 @@ type RecorderHandle = {
|
||||
recordedBlobPromise: Promise<Blob>;
|
||||
};
|
||||
|
||||
type NativeWindowsRecordingHandle = {
|
||||
recordingId: number;
|
||||
finalizing: boolean;
|
||||
};
|
||||
|
||||
function createRecorderHandle(stream: MediaStream, options: MediaRecorderOptions): RecorderHandle {
|
||||
const recorder = new MediaRecorder(stream, options);
|
||||
const chunks: Blob[] = [];
|
||||
@@ -93,11 +106,14 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
const [elapsedSeconds, setElapsedSeconds] = useState(0);
|
||||
const [microphoneEnabled, setMicrophoneEnabled] = useState(false);
|
||||
const [microphoneDeviceId, setMicrophoneDeviceId] = useState<string | undefined>(undefined);
|
||||
const [microphoneDeviceName, setMicrophoneDeviceName] = useState<string | undefined>(undefined);
|
||||
const [webcamDeviceId, setWebcamDeviceId] = useState<string | undefined>(undefined);
|
||||
const [webcamDeviceName, setWebcamDeviceName] = useState<string | undefined>(undefined);
|
||||
const [systemAudioEnabled, setSystemAudioEnabled] = useState(false);
|
||||
const [webcamEnabled, setWebcamEnabledState] = useState(false);
|
||||
const screenRecorder = useRef<RecorderHandle | null>(null);
|
||||
const webcamRecorder = useRef<RecorderHandle | null>(null);
|
||||
const nativeWindowsRecording = useRef<NativeWindowsRecordingHandle | null>(null);
|
||||
const stream = useRef<MediaStream | null>(null);
|
||||
const screenStream = useRef<MediaStream | null>(null);
|
||||
const microphoneStream = useRef<MediaStream | null>(null);
|
||||
@@ -174,6 +190,20 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const stopWebcamPreviewStream = useCallback(() => {
|
||||
if (!webcamStream.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
webcamAcquireId.current++;
|
||||
webcamStream.current.getTracks().forEach((track) => {
|
||||
track.onended = null;
|
||||
track.stop();
|
||||
});
|
||||
webcamStream.current = null;
|
||||
webcamReady.current = true;
|
||||
}, []);
|
||||
|
||||
const setWebcamEnabled = useCallback(
|
||||
async (enabled: boolean) => {
|
||||
if (!enabled) {
|
||||
@@ -198,6 +228,14 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
[t],
|
||||
);
|
||||
|
||||
const safeHideCountdownOverlay = useCallback(async (runId: number) => {
|
||||
try {
|
||||
await window.electronAPI.hideCountdownOverlay(runId);
|
||||
} catch (error) {
|
||||
console.warn("Failed to hide countdown overlay:", error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!webcamEnabled) return;
|
||||
|
||||
@@ -367,7 +405,65 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
[teardownMedia],
|
||||
);
|
||||
|
||||
const finalizeNativeWindowsRecording = useCallback(async (discard = false) => {
|
||||
const activeNativeRecording = nativeWindowsRecording.current;
|
||||
if (!activeNativeRecording || activeNativeRecording.finalizing) {
|
||||
return false;
|
||||
}
|
||||
|
||||
activeNativeRecording.finalizing = true;
|
||||
|
||||
const clearNativeRecordingState = () => {
|
||||
nativeWindowsRecording.current = null;
|
||||
setRecording(false);
|
||||
setPaused(false);
|
||||
setElapsedSeconds(0);
|
||||
accumulatedDurationMs.current = 0;
|
||||
segmentStartedAt.current = null;
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await window.electronAPI.stopNativeWindowsRecording(discard);
|
||||
if (discard || result.discarded) {
|
||||
clearNativeRecordingState();
|
||||
return true;
|
||||
}
|
||||
if (!result.success) {
|
||||
console.error("Failed to stop native Windows recording:", result.error);
|
||||
toast.error(result.error ?? "Failed to stop native Windows recording");
|
||||
activeNativeRecording.finalizing = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
clearNativeRecordingState();
|
||||
if (result.session) {
|
||||
await window.electronAPI.setCurrentRecordingSession(result.session);
|
||||
} else if (result.path) {
|
||||
await window.electronAPI.setCurrentVideoPath(result.path);
|
||||
}
|
||||
|
||||
await window.electronAPI.switchToEditor();
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Error saving native Windows recording:", error);
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Failed to save native Windows recording",
|
||||
);
|
||||
activeNativeRecording.finalizing = false;
|
||||
return true;
|
||||
} finally {
|
||||
if (discardRecordingId.current === activeNativeRecording.recordingId) {
|
||||
discardRecordingId.current = null;
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const stopRecording = useRef(() => {
|
||||
if (nativeWindowsRecording.current) {
|
||||
void finalizeNativeWindowsRecording(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const activeScreenRecorder = screenRecorder.current;
|
||||
if (!activeScreenRecorder) {
|
||||
return;
|
||||
@@ -425,6 +521,9 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
allowAutoFinalize.current = false;
|
||||
restarting.current = false;
|
||||
discardRecordingId.current = null;
|
||||
if (nativeWindowsRecording.current) {
|
||||
void finalizeNativeWindowsRecording(true);
|
||||
}
|
||||
|
||||
if (
|
||||
screenRecorder.current?.recorder.state === "recording" ||
|
||||
@@ -450,7 +549,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
webcamRecorder.current = null;
|
||||
teardownMedia();
|
||||
};
|
||||
}, [teardownMedia]);
|
||||
}, [teardownMedia, finalizeNativeWindowsRecording, safeHideCountdownOverlay]);
|
||||
|
||||
const safeShowCountdownOverlay = async (value: number, runId: number) => {
|
||||
try {
|
||||
@@ -477,17 +576,97 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
}
|
||||
};
|
||||
|
||||
const safeHideCountdownOverlay = async (runId: number) => {
|
||||
try {
|
||||
await window.electronAPI.hideCountdownOverlay(runId);
|
||||
} catch (error) {
|
||||
console.warn("Failed to hide countdown overlay:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const isCountdownRunActive = (runId?: number) =>
|
||||
runId === undefined || countdownRunId.current === runId;
|
||||
|
||||
const startNativeWindowsRecordingIfAvailable = async (
|
||||
selectedSource: ProcessedDesktopSource,
|
||||
countdownRunToken?: number,
|
||||
) => {
|
||||
const platform = await window.electronAPI.getPlatform();
|
||||
if (platform !== "win32") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const availability = await window.electronAPI.isNativeWindowsCaptureAvailable();
|
||||
if (!availability.success || !availability.available) {
|
||||
if (availability.reason === "unsupported-os") {
|
||||
return false;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
availability.reason === "missing-helper"
|
||||
? "Native Windows capture helper is not available."
|
||||
: (availability.error ?? "Native Windows capture is not available."),
|
||||
);
|
||||
}
|
||||
|
||||
if (!isCountdownRunActive(countdownRunToken)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const activeRecordingId = Date.now();
|
||||
const displayId = Number(selectedSource.display_id);
|
||||
const sourceType = selectedSource.id.startsWith("window:") ? "window" : "display";
|
||||
const windowHandle = parseWindowHandleFromSourceId(selectedSource.id);
|
||||
if (webcamEnabled) {
|
||||
stopWebcamPreviewStream();
|
||||
}
|
||||
const request: NativeWindowsRecordingRequest = {
|
||||
recordingId: activeRecordingId,
|
||||
source: {
|
||||
type: sourceType,
|
||||
sourceId: selectedSource.id,
|
||||
...(Number.isFinite(displayId) ? { displayId } : {}),
|
||||
...(windowHandle ? { windowHandle } : {}),
|
||||
},
|
||||
video: {
|
||||
fps: TARGET_FRAME_RATE,
|
||||
width: TARGET_WIDTH,
|
||||
height: TARGET_HEIGHT,
|
||||
},
|
||||
audio: {
|
||||
system: {
|
||||
enabled: systemAudioEnabled,
|
||||
},
|
||||
microphone: {
|
||||
enabled: microphoneEnabled,
|
||||
deviceId: microphoneDeviceId,
|
||||
deviceName: microphoneDeviceName,
|
||||
gain: MIC_GAIN_BOOST,
|
||||
},
|
||||
},
|
||||
webcam: {
|
||||
enabled: webcamEnabled,
|
||||
deviceId: webcamDeviceId,
|
||||
deviceName: webcamDeviceName,
|
||||
width: WEBCAM_TARGET_WIDTH,
|
||||
height: WEBCAM_TARGET_HEIGHT,
|
||||
fps: WEBCAM_TARGET_FRAME_RATE,
|
||||
},
|
||||
cursor: {
|
||||
mode: "editable-overlay",
|
||||
},
|
||||
};
|
||||
const result = await window.electronAPI.startNativeWindowsRecording(request);
|
||||
if (!result.success || !result.recordingId) {
|
||||
throw new Error(result.error ?? "Native Windows capture failed.");
|
||||
}
|
||||
|
||||
recordingId.current = result.recordingId;
|
||||
nativeWindowsRecording.current = {
|
||||
recordingId: result.recordingId,
|
||||
finalizing: false,
|
||||
};
|
||||
accumulatedDurationMs.current = 0;
|
||||
segmentStartedAt.current = Date.now();
|
||||
allowAutoFinalize.current = true;
|
||||
setRecording(true);
|
||||
setPaused(false);
|
||||
setElapsedSeconds(0);
|
||||
return true;
|
||||
};
|
||||
|
||||
const startRecordCountdown = async () => {
|
||||
if (countdownActive || recording) {
|
||||
return;
|
||||
@@ -575,43 +754,60 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
return;
|
||||
}
|
||||
|
||||
if (await startNativeWindowsRecordingIfAvailable(selectedSource, countdownRunToken)) {
|
||||
return;
|
||||
}
|
||||
|
||||
let screenMediaStream: MediaStream;
|
||||
const platform = await window.electronAPI.getPlatform();
|
||||
|
||||
const videoConstraints = {
|
||||
mandatory: {
|
||||
chromeMediaSource: CHROME_MEDIA_SOURCE,
|
||||
chromeMediaSourceId: selectedSource.id,
|
||||
maxWidth: TARGET_WIDTH,
|
||||
maxHeight: TARGET_HEIGHT,
|
||||
maxFrameRate: TARGET_FRAME_RATE,
|
||||
minFrameRate: MIN_FRAME_RATE,
|
||||
},
|
||||
};
|
||||
if (platform === "win32") {
|
||||
screenMediaStream = await navigator.mediaDevices.getDisplayMedia({
|
||||
video: {
|
||||
cursor: "always",
|
||||
width: { max: TARGET_WIDTH },
|
||||
height: { max: TARGET_HEIGHT },
|
||||
frameRate: { ideal: TARGET_FRAME_RATE },
|
||||
} as MediaTrackConstraints,
|
||||
audio: systemAudioEnabled,
|
||||
} as DisplayMediaStreamOptions);
|
||||
} else {
|
||||
const videoConstraints = {
|
||||
mandatory: {
|
||||
chromeMediaSource: CHROME_MEDIA_SOURCE,
|
||||
chromeMediaSourceId: selectedSource.id,
|
||||
maxWidth: TARGET_WIDTH,
|
||||
maxHeight: TARGET_HEIGHT,
|
||||
maxFrameRate: TARGET_FRAME_RATE,
|
||||
minFrameRate: MIN_FRAME_RATE,
|
||||
},
|
||||
};
|
||||
|
||||
if (systemAudioEnabled) {
|
||||
try {
|
||||
screenMediaStream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
mandatory: {
|
||||
chromeMediaSource: CHROME_MEDIA_SOURCE,
|
||||
chromeMediaSourceId: selectedSource.id,
|
||||
if (systemAudioEnabled) {
|
||||
try {
|
||||
screenMediaStream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
mandatory: {
|
||||
chromeMediaSource: CHROME_MEDIA_SOURCE,
|
||||
chromeMediaSourceId: selectedSource.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
video: videoConstraints,
|
||||
} as unknown as MediaStreamConstraints);
|
||||
} catch (audioErr) {
|
||||
console.warn("System audio capture failed, falling back to video-only:", audioErr);
|
||||
toast.error(t("recording.systemAudioUnavailable"));
|
||||
video: videoConstraints,
|
||||
} as unknown as MediaStreamConstraints);
|
||||
} catch (audioErr) {
|
||||
console.warn("System audio capture failed, falling back to video-only:", audioErr);
|
||||
toast.error(t("recording.systemAudioUnavailable"));
|
||||
screenMediaStream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: false,
|
||||
video: videoConstraints,
|
||||
} as unknown as MediaStreamConstraints);
|
||||
}
|
||||
} else {
|
||||
screenMediaStream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: false,
|
||||
video: videoConstraints,
|
||||
} as unknown as MediaStreamConstraints);
|
||||
}
|
||||
} else {
|
||||
screenMediaStream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: false,
|
||||
video: videoConstraints,
|
||||
} as unknown as MediaStreamConstraints);
|
||||
}
|
||||
screenStream.current = screenMediaStream;
|
||||
|
||||
@@ -871,6 +1067,19 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
const restartRecording = async () => {
|
||||
if (restarting.current) return;
|
||||
|
||||
if (nativeWindowsRecording.current) {
|
||||
const activeRecordingId = recordingId.current;
|
||||
restarting.current = true;
|
||||
discardRecordingId.current = activeRecordingId;
|
||||
try {
|
||||
await finalizeNativeWindowsRecording(true);
|
||||
await startRecording();
|
||||
} finally {
|
||||
restarting.current = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const activeScreenRecorder = screenRecorder.current;
|
||||
if (!activeScreenRecorder || activeScreenRecorder.recorder.state === "inactive") return;
|
||||
|
||||
@@ -928,6 +1137,14 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
}, [getRecordingDurationMs, paused, recording]);
|
||||
|
||||
const cancelRecording = () => {
|
||||
if (nativeWindowsRecording.current) {
|
||||
const activeRecordingId = recordingId.current;
|
||||
discardRecordingId.current = activeRecordingId;
|
||||
allowAutoFinalize.current = false;
|
||||
void finalizeNativeWindowsRecording(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const activeScreenRecorder = screenRecorder.current;
|
||||
if (
|
||||
activeScreenRecorder?.recorder.state === "recording" ||
|
||||
@@ -959,8 +1176,12 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
setMicrophoneEnabled,
|
||||
microphoneDeviceId,
|
||||
setMicrophoneDeviceId,
|
||||
microphoneDeviceName,
|
||||
setMicrophoneDeviceName,
|
||||
webcamDeviceId,
|
||||
setWebcamDeviceId,
|
||||
webcamDeviceName,
|
||||
setWebcamDeviceName,
|
||||
systemAudioEnabled,
|
||||
setSystemAudioEnabled,
|
||||
webcamEnabled,
|
||||
|
||||
@@ -8,6 +8,7 @@ import jaJPDialogs from "@/i18n/locales/ja-JP/dialogs.json";
|
||||
import koKRDialogs from "@/i18n/locales/ko-KR/dialogs.json";
|
||||
import ruDialogs from "@/i18n/locales/ru/dialogs.json";
|
||||
import trDialogs from "@/i18n/locales/tr/dialogs.json";
|
||||
import viDialogs from "@/i18n/locales/vi/dialogs.json";
|
||||
import zhCNDialogs from "@/i18n/locales/zh-CN/dialogs.json";
|
||||
import zhTWDialogs from "@/i18n/locales/zh-TW/dialogs.json";
|
||||
|
||||
@@ -47,6 +48,7 @@ const dialogsByLocale = {
|
||||
ru: ruDialogs,
|
||||
"ja-JP": jaJPDialogs,
|
||||
ar: arDialogs,
|
||||
vi: viDialogs,
|
||||
} satisfies Record<Locale, { tutorial: Record<string, unknown> }>;
|
||||
|
||||
describe("TutorialHelp translations", () => {
|
||||
|
||||
@@ -10,6 +10,7 @@ export const SUPPORTED_LOCALES = [
|
||||
"ja-JP",
|
||||
"ar",
|
||||
"ru",
|
||||
"vi",
|
||||
] as const;
|
||||
export const I18N_NAMESPACES = [
|
||||
"common",
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"actions": {
|
||||
"cancel": "Hủy",
|
||||
"save": "Lưu",
|
||||
"delete": "Xóa",
|
||||
"close": "Đóng",
|
||||
"share": "Chia sẻ",
|
||||
"done": "Hoàn tất",
|
||||
"open": "Mở",
|
||||
"upload": "Tải lên",
|
||||
"export": "Xuất",
|
||||
"showInFolder": "Hiển thị trong thư mục",
|
||||
"file": "Tệp",
|
||||
"edit": "Chỉnh sửa",
|
||||
"view": "Xem",
|
||||
"window": "Cửa sổ",
|
||||
"quit": "Thoát",
|
||||
"stopRecording": "Dừng ghi hình"
|
||||
},
|
||||
"playback": {
|
||||
"play": "Phát",
|
||||
"pause": "Tạm dừng",
|
||||
"fullscreen": "Toàn màn hình",
|
||||
"exitFullscreen": "Thoát toàn màn hình"
|
||||
},
|
||||
"locale": {
|
||||
"name": "Tiếng Việt",
|
||||
"short": "VI"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
{
|
||||
"export": {
|
||||
"complete": "Xuất hoàn tất",
|
||||
"yourFormatReady": "{{format}} của bạn đã sẵn sàng",
|
||||
"showInFolder": "Hiển thị trong thư mục",
|
||||
"finalizingVideo": "Đang hoàn tất xuất video...",
|
||||
"compilingGifProgress": "Đang biên dịch GIF... {{progress}}%",
|
||||
"compilingGifWait": "Đang biên dịch GIF... Có thể mất một lúc",
|
||||
"takeMoment": "Có thể mất một chút thời gian...",
|
||||
"failed": "Xuất thất bại",
|
||||
"tryAgain": "Vui lòng thử lại",
|
||||
"finalizingVideoTitle": "Hoàn tất video",
|
||||
"compilingGif": "Biên dịch GIF",
|
||||
"exportingFormat": "Đang xuất {{format}}",
|
||||
"compiling": "Đang biên dịch",
|
||||
"renderingFrames": "Kết xuất khung hình",
|
||||
"processing": "Đang xử lý...",
|
||||
"finalizing": "Đang hoàn tất...",
|
||||
"compilingStatus": "Đang biên dịch...",
|
||||
"status": "Trạng thái",
|
||||
"format": "Định dạng",
|
||||
"frames": "Khung hình",
|
||||
"cancelExport": "Hủy xuất",
|
||||
"savedSuccessfully": "Đã lưu {{format}} thành công!"
|
||||
},
|
||||
"tutorial": {
|
||||
"triggerLabel": "Cách hoạt động của công cụ cắt",
|
||||
"title": "Cách cắt video",
|
||||
"description": "Hiểu cách cắt bỏ các phần không mong muốn trong video của bạn.",
|
||||
"explanationBefore": "Công cụ Cắt hoạt động bằng cách xác định các đoạn bạn muốn",
|
||||
"remove": "xóa",
|
||||
"explanationMiddle": " — bất cứ thứ gì",
|
||||
"covered": "được bao phủ",
|
||||
"explanationAfter": "bởi một đoạn cắt màu đỏ sẽ bị loại bỏ khi bạn xuất.",
|
||||
"visualExample": "Ví dụ trực quan",
|
||||
"removed": "ĐÃ XÓA",
|
||||
"kept": "Giữ lại",
|
||||
"part1": "Phần 1",
|
||||
"part2": "Phần 2",
|
||||
"part3": "Phần 3",
|
||||
"finalVideo": "Video cuối cùng",
|
||||
"step1Title": "1. Thêm đoạn cắt",
|
||||
"step1DescriptionBefore": "Nhấn ",
|
||||
"step1DescriptionAfter": " hoặc nhấp vào biểu tượng cái kéo để đánh dấu một phần cần xóa.",
|
||||
"step2Title": "2. Điều chỉnh",
|
||||
"step2Description": "Kéo các cạnh của vùng màu đỏ để bao phủ chính xác những gì bạn muốn cắt bỏ."
|
||||
},
|
||||
"unsavedChanges": {
|
||||
"title": "Thay đổi chưa được lưu",
|
||||
"message": "Bạn có các thay đổi chưa được lưu.",
|
||||
"detail": "Bạn có muốn lưu dự án của mình trước khi đóng không?",
|
||||
"saveAndClose": "Lưu & Đóng",
|
||||
"discardAndClose": "Bỏ qua & Đóng",
|
||||
"loadProject": "Tải dự án…",
|
||||
"saveProject": "Lưu dự án…",
|
||||
"saveProjectAs": "Lưu dự án thành…"
|
||||
},
|
||||
"fileDialogs": {
|
||||
"saveGif": "Lưu GIF đã xuất",
|
||||
"saveVideo": "Lưu Video đã xuất",
|
||||
"selectVideo": "Chọn tệp video",
|
||||
"saveProject": "Lưu dự án OpenScreen",
|
||||
"openProject": "Mở dự án OpenScreen",
|
||||
"gifImage": "Hình ảnh GIF",
|
||||
"mp4Video": "Video MP4",
|
||||
"videoFiles": "Tệp Video",
|
||||
"openscreenProject": "Dự án OpenScreen",
|
||||
"allFiles": "Tất cả các tệp"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"newRecording": {
|
||||
"title": "Quay lại Trình ghi",
|
||||
"description": "Phiên hiện tại của bạn đã được lưu.",
|
||||
"cancel": "Hủy",
|
||||
"confirm": "Xác nhận"
|
||||
},
|
||||
"loadingVideo": "Đang tải video...",
|
||||
"errors": {
|
||||
"noVideoLoaded": "Chưa tải video nào",
|
||||
"videoNotReady": "Video chưa sẵn sàng",
|
||||
"unableToDetermineSourcePath": "Không thể xác định đường dẫn video gốc",
|
||||
"failedToSaveGif": "Không thể lưu GIF",
|
||||
"gifExportFailed": "Xuất GIF thất bại",
|
||||
"failedToSaveVideo": "Không thể lưu video",
|
||||
"exportFailed": "Xuất thất bại",
|
||||
"exportFailedWithError": "Xuất thất bại: {{error}}",
|
||||
"exportBackgroundLoadFailed": "Xuất thất bại: không thể tải hình nền ({{url}})",
|
||||
"failedToSaveExport": "Không thể lưu bản xuất",
|
||||
"failedToSaveExportedVideo": "Không thể lưu video đã xuất",
|
||||
"failedToRevealInFolder": "Lỗi khi hiển thị trong thư mục: {{error}}"
|
||||
},
|
||||
"export": {
|
||||
"canceled": "Đã hủy xuất",
|
||||
"exportedSuccessfully": "Đã xuất {{format}} thành công"
|
||||
},
|
||||
"project": {
|
||||
"saveCanceled": "Đã hủy lưu dự án",
|
||||
"failedToSave": "Lưu dự án thất bại",
|
||||
"savedTo": "Đã lưu dự án vào {{path}}",
|
||||
"failedToLoad": "Tải dự án thất bại",
|
||||
"invalidFormat": "Định dạng tệp dự án không hợp lệ",
|
||||
"loadedFrom": "Đã tải dự án từ {{path}}"
|
||||
},
|
||||
"recording": {
|
||||
"failedCameraAccess": "Yêu cầu quyền truy cập máy ảnh thất bại.",
|
||||
"cameraBlocked": "Quyền truy cập máy ảnh bị chặn. Hãy bật nó trong cài đặt hệ thống để sử dụng webcam.",
|
||||
"systemAudioUnavailable": "Âm thanh hệ thống không khả dụng. Ghi hình không có âm thanh hệ thống.",
|
||||
"microphoneDenied": "Quyền truy cập micro bị từ chối. Sẽ tiếp tục ghi hình không có âm thanh.",
|
||||
"cameraDenied": "Quyền truy cập máy ảnh bị từ chối. Sẽ tiếp tục ghi hình không có webcam.",
|
||||
"cameraDisconnected": "Webcam bị ngắt kết nối.",
|
||||
"cameraNotFound": "Không tìm thấy máy ảnh.",
|
||||
"permissionDenied": "Quyền ghi hình bị từ chối. Vui lòng cho phép ghi màn hình."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"tooltips": {
|
||||
"hideHUD": "Ẩn HUD",
|
||||
"closeApp": "Đóng ứng dụng",
|
||||
"restartRecording": "Khởi động lại ghi hình",
|
||||
"cancelRecording": "Hủy ghi hình",
|
||||
"pauseRecording": "Tạm dừng ghi hình",
|
||||
"resumeRecording": "Tiếp tục ghi hình",
|
||||
"openVideoFile": "Mở tệp video",
|
||||
"openProject": "Mở dự án"
|
||||
},
|
||||
"audio": {
|
||||
"enableSystemAudio": "Bật âm thanh hệ thống",
|
||||
"disableSystemAudio": "Tắt âm thanh hệ thống",
|
||||
"enableMicrophone": "Bật micro",
|
||||
"disableMicrophone": "Tắt micro",
|
||||
"defaultMicrophone": "Micro Mặc định"
|
||||
},
|
||||
"webcam": {
|
||||
"enableWebcam": "Bật webcam",
|
||||
"disableWebcam": "Tắt webcam",
|
||||
"defaultCamera": "Máy ảnh Mặc định",
|
||||
"searching": "Đang tìm kiếm...",
|
||||
"noneFound": "Không tìm thấy máy ảnh",
|
||||
"unavailable": "Máy ảnh không khả dụng"
|
||||
},
|
||||
"sourceSelector": {
|
||||
"loading": "Đang tải nguồn...",
|
||||
"screens": "Màn hình ({{count}})",
|
||||
"windows": "Cửa sổ ({{count}})",
|
||||
"defaultSourceName": "Màn hình"
|
||||
},
|
||||
"recording": {
|
||||
"selectSource": "Vui lòng chọn một nguồn để ghi"
|
||||
},
|
||||
"language": "Ngôn ngữ",
|
||||
"systemLanguagePrompt": {
|
||||
"title": "Sử dụng ngôn ngữ hệ thống của bạn?",
|
||||
"description": "Chúng tôi phát hiện {{language}} là ngôn ngữ hệ thống của bạn. Bạn có muốn chuyển OpenScreen sang {{language}} không?",
|
||||
"switch": "Chuyển sang {{language}}",
|
||||
"keepDefault": "Giữ ngôn ngữ hiện tại"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
{
|
||||
"zoom": {
|
||||
"level": "Mức độ thu phóng",
|
||||
"selectRegion": "Chọn vùng thu phóng để điều chỉnh",
|
||||
"deleteZoom": "Xóa thu phóng",
|
||||
"focusMode": {
|
||||
"title": "Chế độ lấy nét",
|
||||
"manual": "Thủ công",
|
||||
"auto": "Tự động",
|
||||
"autoDescription": "Máy ảnh đi theo vị trí con trỏ đã ghi"
|
||||
}
|
||||
},
|
||||
"speed": {
|
||||
"playbackSpeed": "Tốc độ phát",
|
||||
"selectRegion": "Chọn vùng tốc độ để điều chỉnh",
|
||||
"deleteRegion": "Xóa vùng tốc độ",
|
||||
"customPlaybackSpeed": "Tốc độ phát tùy chỉnh",
|
||||
"maxSpeedError": "Tốc độ không thể cao hơn 16×"
|
||||
},
|
||||
"trim": {
|
||||
"deleteRegion": "Xóa vùng cắt"
|
||||
},
|
||||
"layout": {
|
||||
"title": "Bố cục",
|
||||
"preset": "Cài đặt sẵn",
|
||||
"selectPreset": "Chọn cài đặt sẵn",
|
||||
"pictureInPicture": "Hình trong hình",
|
||||
"verticalStack": "Xếp chồng dọc",
|
||||
"dualFrame": "Khung kép",
|
||||
"webcamShape": "Hình dạng máy ảnh",
|
||||
"webcamSize": "Kích thước Webcam"
|
||||
},
|
||||
"effects": {
|
||||
"title": "Hiệu ứng video",
|
||||
"blurBg": "Làm mờ nền",
|
||||
"motionBlur": "Làm mờ chuyển động",
|
||||
"off": "tắt",
|
||||
"shadow": "Bóng đổ",
|
||||
"roundness": "Độ bo tròn",
|
||||
"padding": "Phần đệm"
|
||||
},
|
||||
"background": {
|
||||
"title": "Nền",
|
||||
"image": "Hình ảnh",
|
||||
"color": "Màu sắc",
|
||||
"gradient": "Dải màu",
|
||||
"uploadCustom": "Tải lên tùy chỉnh",
|
||||
"gradientLabel": "Dải màu {{index}}"
|
||||
},
|
||||
"crop": {
|
||||
"title": "Cắt xén",
|
||||
"cropVideo": "Cắt xén video",
|
||||
"dragInstruction": "Kéo ở mỗi cạnh để điều chỉnh vùng cắt xén",
|
||||
"ratio": "Tỷ lệ",
|
||||
"free": "Tự do",
|
||||
"done": "Hoàn tất",
|
||||
"lockAspectRatio": "Khóa tỷ lệ khung hình",
|
||||
"unlockAspectRatio": "Mở khóa tỷ lệ khung hình"
|
||||
},
|
||||
"exportFormat": {
|
||||
"mp4": "MP4",
|
||||
"gif": "GIF",
|
||||
"mp4Video": "Video MP4",
|
||||
"mp4Description": "Tệp video chất lượng cao",
|
||||
"gifAnimation": "Ảnh động GIF",
|
||||
"gifDescription": "Hình ảnh động để chia sẻ"
|
||||
},
|
||||
"exportQuality": {
|
||||
"title": "Chất lượng xuất",
|
||||
"low": "Thấp",
|
||||
"medium": "Trung bình",
|
||||
"high": "Cao"
|
||||
},
|
||||
"gifSettings": {
|
||||
"frameRate": "Tốc độ khung hình GIF",
|
||||
"size": "Kích thước GIF",
|
||||
"loop": "Lặp lại GIF"
|
||||
},
|
||||
"project": {
|
||||
"save": "Lưu dự án",
|
||||
"load": "Tải dự án"
|
||||
},
|
||||
"export": {
|
||||
"videoButton": "Xuất Video",
|
||||
"gifButton": "Xuất GIF",
|
||||
"chooseSaveLocation": "Chọn vị trí lưu"
|
||||
},
|
||||
"links": {
|
||||
"reportBug": "Báo cáo lỗi",
|
||||
"starOnGithub": "Đánh giá sao trên GitHub"
|
||||
},
|
||||
"imageUpload": {
|
||||
"invalidFileType": "Loại tệp không hợp lệ",
|
||||
"jpgOnly": "Vui lòng tải lên tệp hình ảnh JPG hoặc JPEG.",
|
||||
"uploadSuccess": "Tải lên hình ảnh tùy chỉnh thành công!",
|
||||
"failedToUpload": "Tải lên hình ảnh thất bại",
|
||||
"errorReading": "Đã xảy ra lỗi khi đọc tệp."
|
||||
},
|
||||
"annotation": {
|
||||
"title": "Cài đặt chú thích",
|
||||
"active": "Hoạt động",
|
||||
"typeText": "Văn bản",
|
||||
"typeImage": "Hình ảnh",
|
||||
"typeArrow": "Mũi tên",
|
||||
"typeBlur": "Làm mờ",
|
||||
"textContent": "Nội dung văn bản",
|
||||
"textPlaceholder": "Nhập văn bản của bạn...",
|
||||
"fontStyle": "Kiểu phông chữ",
|
||||
"selectStyle": "Chọn kiểu",
|
||||
"size": "Kích thước",
|
||||
"customFonts": "Phông chữ tùy chỉnh",
|
||||
"textColor": "Màu văn bản",
|
||||
"background": "Nền",
|
||||
"none": "Không có",
|
||||
"color": "Màu sắc",
|
||||
"clearBackground": "Xóa nền",
|
||||
"uploadImage": "Tải lên hình ảnh",
|
||||
"supportedFormats": "Định dạng hỗ trợ: JPG, PNG, GIF, WebP",
|
||||
"arrowDirection": "Hướng mũi tên",
|
||||
"strokeWidth": "Độ dày nét: {{width}}px",
|
||||
"arrowColor": "Màu mũi tên",
|
||||
"blurType": "Loại làm mờ",
|
||||
"blurTypeBlur": "Làm mờ",
|
||||
"blurTypeMosaic": "Khảm",
|
||||
"blurColor": "Màu làm mờ",
|
||||
"blurColorWhite": "Trắng",
|
||||
"blurColorBlack": "Đen",
|
||||
"blurShape": "Hình dạng làm mờ",
|
||||
"blurIntensity": "Cường độ làm mờ",
|
||||
"mosaicBlockSize": "Kích thước khối khảm",
|
||||
"blurShapeRectangle": "Chữ nhật",
|
||||
"blurShapeOval": "Bầu dục",
|
||||
"blurShapeFreehand": "Vẽ tự do",
|
||||
"deleteAnnotation": "Xóa chú thích",
|
||||
"shortcutsAndTips": "Phím tắt & Mẹo",
|
||||
"tipMovePlayhead": "Di chuyển đầu phát đến phần chú thích chồng chéo và chọn một mục.",
|
||||
"tipTabCycle": "Sử dụng Tab để chuyển qua các mục chồng chéo.",
|
||||
"tipShiftTabCycle": "Sử dụng Shift+Tab để chuyển ngược lại.",
|
||||
"invalidImageType": "Loại tệp không hợp lệ",
|
||||
"imageFormatsOnly": "Vui lòng tải lên tệp hình ảnh JPG, PNG, GIF hoặc WebP.",
|
||||
"imageUploadSuccess": "Tải lên hình ảnh thành công!",
|
||||
"failedImageUpload": "Tải lên hình ảnh thất bại"
|
||||
},
|
||||
"fontStyles": {
|
||||
"classic": "Cổ điển",
|
||||
"editor": "Trình chỉnh sửa",
|
||||
"strong": "Đậm",
|
||||
"typewriter": "Máy đánh chữ",
|
||||
"deco": "Trang trí",
|
||||
"simple": "Đơn giản",
|
||||
"modern": "Hiện đại",
|
||||
"clean": "Sạch sẽ"
|
||||
},
|
||||
"customFont": {
|
||||
"dialogTitle": "Thêm Google Font",
|
||||
"urlLabel": "URL nhập Google Fonts",
|
||||
"urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
|
||||
"urlHelp": "Lấy từ Google Fonts: Chọn một phông chữ → Nhấp \"Get font\" → Sao chép URL @import",
|
||||
"nameLabel": "Tên hiển thị",
|
||||
"namePlaceholder": "Phông chữ tùy chỉnh của tôi",
|
||||
"nameHelp": "Đây là cách phông chữ sẽ xuất hiện trong bộ chọn phông chữ",
|
||||
"addButton": "Thêm phông chữ",
|
||||
"addingButton": "Đang thêm...",
|
||||
"errorEmptyUrl": "Vui lòng nhập URL nhập Google Fonts",
|
||||
"errorInvalidUrl": "Vui lòng nhập URL Google Fonts hợp lệ",
|
||||
"errorEmptyName": "Vui lòng nhập tên phông chữ",
|
||||
"errorExtractFailed": "Không thể trích xuất họ phông chữ từ URL",
|
||||
"successMessage": "Thêm phông chữ \"{{fontName}}\" thành công",
|
||||
"failedToAdd": "Thêm phông chữ thất bại",
|
||||
"errorTimeout": "Tải phông chữ mất quá nhiều thời gian. Vui lòng kiểm tra URL và thử lại.",
|
||||
"errorLoadFailed": "Không thể tải phông chữ. Vui lòng xác minh URL Google Fonts là chính xác."
|
||||
},
|
||||
"language": {
|
||||
"title": "Ngôn ngữ"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"title": "Phím tắt",
|
||||
"customize": "Tùy chỉnh",
|
||||
"configurable": "Có thể định cấu hình",
|
||||
"fixed": "Cố định",
|
||||
"pressKey": "Nhấn một phím…",
|
||||
"clickToChange": "Nhấp để thay đổi",
|
||||
"pressEscToCancel": "Nhấn Esc để hủy",
|
||||
"helpText": "Nhấp vào một phím tắt rồi nhấn tổ hợp phím mới. Nhấn Esc để hủy.",
|
||||
"resetToDefaults": "Khôi phục mặc định",
|
||||
"alreadyUsedBy": "Đã được sử dụng bởi {{action}}",
|
||||
"swap": "Hoán đổi",
|
||||
"reservedShortcut": "Phím tắt này được dành riêng cho \"{{label}}\" và không thể gán lại.",
|
||||
"savedToast": "Đã lưu phím tắt",
|
||||
"resetToast": "Đã đặt lại về phím tắt mặc định — nhấp Lưu để áp dụng",
|
||||
"actions": {
|
||||
"addZoom": "Thêm Thu phóng",
|
||||
"addTrim": "Thêm Cắt",
|
||||
"addSpeed": "Thêm Tốc độ",
|
||||
"addAnnotation": "Thêm Chú thích",
|
||||
"addBlur": "Thêm Làm mờ",
|
||||
"addKeyframe": "Thêm Khung hình chính",
|
||||
"deleteSelected": "Xóa mục đã chọn",
|
||||
"playPause": "Phát / Tạm dừng"
|
||||
},
|
||||
"fixedActions": {
|
||||
"undo": "Hoàn tác",
|
||||
"redo": "Làm lại",
|
||||
"cycleAnnotationsForward": "Chuyển tiếp qua các chú thích",
|
||||
"cycleAnnotationsBackward": "Chuyển lùi qua các chú thích",
|
||||
"deleteSelectedAlt": "Xóa mục đã chọn (alt)",
|
||||
"panTimeline": "Xoay Trục thời gian",
|
||||
"zoomTimeline": "Thu phóng Trục thời gian",
|
||||
"frameBack": "Lùi một khung hình",
|
||||
"frameForward": "Tiến một khung hình"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"buttons": {
|
||||
"addZoom": "Thêm Thu phóng (Z)",
|
||||
"suggestZooms": "Đề xuất Thu phóng từ Con trỏ",
|
||||
"addTrim": "Thêm Cắt (T)",
|
||||
"addAnnotation": "Thêm Chú thích (A)",
|
||||
"addBlur": "Thêm Làm mờ (B)",
|
||||
"addSpeed": "Thêm Tốc độ (S)"
|
||||
},
|
||||
"hints": {
|
||||
"pressZoom": "Nhấn Z để thêm thu phóng",
|
||||
"pressTrim": "Nhấn T để thêm cắt",
|
||||
"pressAnnotation": "Nhấn A để thêm chú thích",
|
||||
"pressBlur": "Nhấn B để thêm vùng làm mờ",
|
||||
"pressSpeed": "Nhấn S để thêm tốc độ"
|
||||
},
|
||||
"labels": {
|
||||
"pan": "Xoay",
|
||||
"zoom": "Thu phóng",
|
||||
"trim": "Cắt",
|
||||
"speed": "Tốc độ",
|
||||
"zoomItem": "Thu phóng {{index}}",
|
||||
"trimItem": "Cắt {{index}}",
|
||||
"speedItem": "Tốc độ {{index}}",
|
||||
"annotationItem": "Chú thích",
|
||||
"blurItem": "Làm mờ {{index}}",
|
||||
"imageItem": "Hình ảnh",
|
||||
"emptyText": "Văn bản trống"
|
||||
},
|
||||
"emptyState": {
|
||||
"noVideo": "Chưa tải video",
|
||||
"dragAndDrop": "Kéo và thả video để bắt đầu chỉnh sửa"
|
||||
},
|
||||
"errors": {
|
||||
"cannotPlaceZoom": "Không thể đặt thu phóng ở đây",
|
||||
"zoomExistsAtLocation": "Thu phóng đã tồn tại ở vị trí này hoặc không có đủ không gian.",
|
||||
"zoomSuggestionUnavailable": "Trình xử lý đề xuất thu phóng không khả dụng",
|
||||
"noCursorTelemetry": "Không có dữ liệu từ xa của con trỏ",
|
||||
"noCursorTelemetryDescription": "Ghi hình màn hình trước để tạo các đề xuất dựa trên con trỏ.",
|
||||
"noUsableTelemetry": "Không có dữ liệu từ xa của con trỏ có thể sử dụng",
|
||||
"noUsableTelemetryDescription": "Bản ghi không chứa đủ dữ liệu chuyển động của con trỏ.",
|
||||
"noDwellMoments": "Không tìm thấy khoảnh khắc dừng con trỏ rõ ràng",
|
||||
"noDwellMomentsDescription": "Thử ghi hình với các lần tạm dừng con trỏ chậm hơn ở các thao tác quan trọng.",
|
||||
"noAutoZoomSlots": "Không có khe thu phóng tự động nào",
|
||||
"noAutoZoomSlotsDescription": "Các điểm dừng được phát hiện chồng chéo với các vùng thu phóng hiện có.",
|
||||
"cannotPlaceTrim": "Không thể đặt cắt ở đây",
|
||||
"trimExistsAtLocation": "Cắt đã tồn tại ở vị trí này hoặc không có đủ không gian.",
|
||||
"cannotPlaceSpeed": "Không thể đặt tốc độ ở đây",
|
||||
"speedExistsAtLocation": "Vùng tốc độ đã tồn tại ở vị trí này hoặc không có đủ không gian."
|
||||
},
|
||||
"success": {
|
||||
"addedZoomSuggestions": "Đã thêm {{count}} đề xuất thu phóng dựa trên con trỏ",
|
||||
"addedZoomSuggestionsPlural": "Đã thêm {{count}} đề xuất thu phóng dựa trên con trỏ"
|
||||
}
|
||||
}
|
||||
@@ -66,11 +66,154 @@
|
||||
@apply bg-background text-foreground;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
overflow: hidden;
|
||||
text-rendering: geometricPrecision;
|
||||
}
|
||||
}
|
||||
|
||||
/* Smooth timeline cursor animations */
|
||||
@layer utilities {
|
||||
.editor-workspace {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 14px;
|
||||
background:
|
||||
radial-gradient(circle at 18% 0%, rgba(52, 178, 123, 0.08), transparent 30%),
|
||||
linear-gradient(180deg, #08090b 0%, #050606 100%);
|
||||
}
|
||||
|
||||
.editor-main-deck {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) clamp(286px, 20vw, 352px);
|
||||
gap: 14px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.editor-preview-zone,
|
||||
.editor-settings-rail {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.editor-preview-panel,
|
||||
.editor-timeline-panel,
|
||||
.editor-inspector-shell {
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.035), rgba(255, 255, 255, 0.012)),
|
||||
#090a0c;
|
||||
border: 1px solid rgba(255, 255, 255, 0.075);
|
||||
border-radius: 18px;
|
||||
box-shadow:
|
||||
0 24px 70px rgba(0, 0, 0, 0.42),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.045);
|
||||
}
|
||||
|
||||
.editor-timeline-panel {
|
||||
border-radius: 16px;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(18, 20, 24, 0.96), rgba(8, 9, 11, 0.98)),
|
||||
#090a0c;
|
||||
}
|
||||
|
||||
.editor-resize-handle {
|
||||
height: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 999px;
|
||||
transition:
|
||||
background-color 160ms ease,
|
||||
opacity 160ms ease;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.editor-resize-handle:hover {
|
||||
background: rgba(255, 255, 255, 0.035);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.editor-panel-section {
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.editor-control-surface {
|
||||
border: 1px solid rgba(255, 255, 255, 0.055);
|
||||
border-radius: 10px;
|
||||
background: rgba(255, 255, 255, 0.032);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.025);
|
||||
}
|
||||
|
||||
.editor-inspector-shell {
|
||||
color: #d9e2ee;
|
||||
}
|
||||
|
||||
.editor-inspector-shell button,
|
||||
.editor-inspector-shell [role="button"] {
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.editor-inspector-shell button:focus-visible,
|
||||
.editor-inspector-shell [role="button"]:focus-visible {
|
||||
outline: 1px solid rgba(52, 178, 123, 0.68);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.editor-inspector-shell [data-radix-collection-item],
|
||||
.editor-inspector-shell input,
|
||||
.editor-inspector-shell textarea {
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.editor-inspector-shell [role="slider"] {
|
||||
box-shadow: 0 0 0 4px rgba(52, 178, 123, 0.12);
|
||||
}
|
||||
|
||||
.editor-inspector-shell .editor-panel-section > h3 {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.editor-inspector-shell .editor-panel-section > div {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
@media (max-width: 1240px) {
|
||||
.editor-workspace {
|
||||
gap: 10px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.editor-main-deck {
|
||||
grid-template-columns: minmax(0, 1fr) 286px;
|
||||
gap: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1020px) {
|
||||
.editor-main-deck {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
grid-template-rows: minmax(0, 1fr) 180px;
|
||||
}
|
||||
|
||||
.editor-settings-rail {
|
||||
min-height: 180px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1900px) {
|
||||
.editor-workspace {
|
||||
padding: 18px;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.editor-main-deck {
|
||||
grid-template-columns: minmax(0, 1fr) 372px;
|
||||
gap: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.timeline-cursor-smooth {
|
||||
will-change: transform;
|
||||
transition:
|
||||
@@ -78,14 +221,31 @@
|
||||
right 33ms linear;
|
||||
}
|
||||
|
||||
/* Hidden scrollbar - still scrollable but invisible */
|
||||
.custom-scrollbar {
|
||||
scrollbar-width: none; /* Firefox */
|
||||
-ms-overflow-style: none; /* IE/Edge */
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(148, 163, 184, 0.32) transparent;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
display: none; /* Chrome, Safari, Opera */
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: rgba(148, 163, 184, 0.22);
|
||||
border: 3px solid transparent;
|
||||
border-radius: 999px;
|
||||
background-clip: content-box;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(52, 178, 123, 0.55);
|
||||
border: 3px solid transparent;
|
||||
background-clip: content-box;
|
||||
}
|
||||
|
||||
.squircle {
|
||||
|
||||
@@ -69,12 +69,12 @@ describe("blur color helpers", () => {
|
||||
it("returns a dark overlay when black blur color is selected", () => {
|
||||
expect(
|
||||
getBlurOverlayColor({
|
||||
type: "blur",
|
||||
type: "mosaic",
|
||||
shape: "rectangle",
|
||||
color: "black",
|
||||
intensity: 12,
|
||||
blockSize: 12,
|
||||
}),
|
||||
).toBe("rgba(0, 0, 0, 0.56)");
|
||||
).toBe("rgba(0, 0, 0, 0.72)");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,7 +16,8 @@ function clamp(value: number, min: number, max: number) {
|
||||
}
|
||||
|
||||
export function normalizeBlurType(value: unknown): BlurType {
|
||||
return value === "mosaic" ? "mosaic" : "blur";
|
||||
void value;
|
||||
return "mosaic";
|
||||
}
|
||||
|
||||
export function normalizeBlurColor(value: unknown): BlurColor {
|
||||
@@ -42,13 +43,8 @@ export function getNormalizedMosaicBlockSize(blurData?: BlurData | null, scaleFa
|
||||
|
||||
export function getBlurOverlayColor(blurData?: BlurData | null): string {
|
||||
const blurColor = normalizeBlurColor(blurData?.color);
|
||||
const blurType = normalizeBlurType(blurData?.type);
|
||||
|
||||
if (blurColor === "black") {
|
||||
return blurType === "mosaic" ? "rgba(0, 0, 0, 0.72)" : "rgba(0, 0, 0, 0.56)";
|
||||
}
|
||||
|
||||
return blurType === "mosaic" ? "rgba(255, 255, 255, 0.06)" : "rgba(255, 255, 255, 0.02)";
|
||||
return blurColor === "black" ? "rgba(0, 0, 0, 0.72)" : "rgba(255, 255, 255, 0.06)";
|
||||
}
|
||||
|
||||
export function getMosaicGridOverlayColor(blurData?: BlurData | null): string {
|
||||
|
||||
@@ -0,0 +1,591 @@
|
||||
import { type Container, Point } from "pixi.js";
|
||||
import appStartingUrl from "@/assets/cursors/Cursor=App-Starting.svg";
|
||||
import crosshairUrl from "@/assets/cursors/Cursor=Cross.svg";
|
||||
import arrowUrl from "@/assets/cursors/Cursor=Default.svg";
|
||||
import closedHandUrl from "@/assets/cursors/Cursor=Hand-(Grabbing).svg";
|
||||
import openHandUrl from "@/assets/cursors/Cursor=Hand-(Open).svg";
|
||||
import pointerUrl from "@/assets/cursors/Cursor=Hand-(Pointing).svg";
|
||||
import helpUrl from "@/assets/cursors/Cursor=Help.svg";
|
||||
import moveUrl from "@/assets/cursors/Cursor=Move.svg";
|
||||
import notAllowedUrl from "@/assets/cursors/Cursor=Not-Allowed.svg";
|
||||
import resizeNeswUrl from "@/assets/cursors/Cursor=Resize-North-East-South-West.svg";
|
||||
import resizeNsUrl from "@/assets/cursors/Cursor=Resize-North-South.svg";
|
||||
import resizeNwseUrl from "@/assets/cursors/Cursor=Resize-North-West-South-East.svg";
|
||||
import resizeEwUrl from "@/assets/cursors/Cursor=Resize-West-East.svg";
|
||||
import textUrl from "@/assets/cursors/Cursor=Text-Cursor.svg";
|
||||
import upArrowUrl from "@/assets/cursors/Cursor=Up-Arrow.svg";
|
||||
import waitUrl from "@/assets/cursors/Cursor=Wait.svg";
|
||||
import type { CropRegion } from "@/components/video-editor/types";
|
||||
import type {
|
||||
CursorRecordingData,
|
||||
CursorRecordingSample,
|
||||
NativeCursorAsset,
|
||||
NativeCursorType,
|
||||
} from "@/native/contracts";
|
||||
|
||||
export interface ActiveNativeCursorFrame {
|
||||
asset: NativeCursorAsset;
|
||||
sample: CursorRecordingSample;
|
||||
}
|
||||
|
||||
export interface NativeCursorSmoothingState {
|
||||
cx: number;
|
||||
cy: number;
|
||||
lastTimeMs: number | null;
|
||||
initialized: boolean;
|
||||
}
|
||||
|
||||
export interface NativeCursorMotionBlurState {
|
||||
x: number;
|
||||
y: number;
|
||||
lastTimeMs: number | null;
|
||||
initialized: boolean;
|
||||
}
|
||||
|
||||
interface ProjectNativeCursorOptions {
|
||||
cropRegion: CropRegion;
|
||||
maskRect: { x: number; y: number; width: number; height: number };
|
||||
sample: CursorRecordingSample;
|
||||
}
|
||||
|
||||
interface ProjectNativeCursorToStageOptions extends ProjectNativeCursorOptions {
|
||||
cameraContainer: Container;
|
||||
videoContainerPosition: { x: number; y: number };
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
const NATIVE_CURSOR_CLICK_ANIMATION_MS = 260;
|
||||
const NATIVE_CURSOR_MOTION_BLUR_MAX_PX = 6;
|
||||
const nativeCursorAssetMapCache = new WeakMap<
|
||||
CursorRecordingData,
|
||||
Map<string, NativeCursorAsset>
|
||||
>();
|
||||
|
||||
function findNativeCursorSampleIndexAtOrBefore(samples: CursorRecordingSample[], timeMs: number) {
|
||||
let low = 0;
|
||||
let high = samples.length - 1;
|
||||
let result = -1;
|
||||
|
||||
while (low <= high) {
|
||||
const middle = low + Math.floor((high - low) / 2);
|
||||
if (samples[middle].timeMs <= timeMs) {
|
||||
result = middle;
|
||||
low = middle + 1;
|
||||
} else {
|
||||
high = middle - 1;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function getNativeCursorAssetMap(recordingData: CursorRecordingData) {
|
||||
const cached = nativeCursorAssetMapCache.get(recordingData);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const assetMap = new Map(recordingData.assets.map((asset) => [asset.id, asset]));
|
||||
nativeCursorAssetMapCache.set(recordingData, assetMap);
|
||||
return assetMap;
|
||||
}
|
||||
|
||||
function getNativeCursorAsset(recordingData: CursorRecordingData, assetId: string) {
|
||||
return getNativeCursorAssetMap(recordingData).get(assetId) ?? null;
|
||||
}
|
||||
|
||||
interface PrettyNativeCursorAsset {
|
||||
imageDataUrl: string;
|
||||
width: number;
|
||||
height: number;
|
||||
hotspotX: number;
|
||||
hotspotY: number;
|
||||
}
|
||||
|
||||
const PRETTY_NATIVE_CURSOR_ASSETS: Partial<Record<NativeCursorType, PrettyNativeCursorAsset>> = {
|
||||
arrow: {
|
||||
imageDataUrl: arrowUrl,
|
||||
width: 32,
|
||||
height: 32,
|
||||
hotspotX: 16.25,
|
||||
hotspotY: 15.03,
|
||||
},
|
||||
text: {
|
||||
imageDataUrl: textUrl,
|
||||
width: 32,
|
||||
height: 32,
|
||||
hotspotX: 16,
|
||||
hotspotY: 16,
|
||||
},
|
||||
pointer: {
|
||||
imageDataUrl: pointerUrl,
|
||||
width: 32,
|
||||
height: 33,
|
||||
hotspotX: 16.65,
|
||||
hotspotY: 14.24,
|
||||
},
|
||||
crosshair: {
|
||||
imageDataUrl: crosshairUrl,
|
||||
width: 32,
|
||||
height: 32,
|
||||
hotspotX: 16,
|
||||
hotspotY: 16,
|
||||
},
|
||||
"open-hand": {
|
||||
imageDataUrl: openHandUrl,
|
||||
width: 32,
|
||||
height: 32,
|
||||
hotspotX: 16,
|
||||
hotspotY: 9,
|
||||
},
|
||||
"closed-hand": {
|
||||
imageDataUrl: closedHandUrl,
|
||||
width: 32,
|
||||
height: 32,
|
||||
hotspotX: 16,
|
||||
hotspotY: 9,
|
||||
},
|
||||
"resize-ew": {
|
||||
imageDataUrl: resizeEwUrl,
|
||||
width: 32,
|
||||
height: 32,
|
||||
hotspotX: 16,
|
||||
hotspotY: 16,
|
||||
},
|
||||
"resize-ns": {
|
||||
imageDataUrl: resizeNsUrl,
|
||||
width: 32,
|
||||
height: 32,
|
||||
hotspotX: 16,
|
||||
hotspotY: 16,
|
||||
},
|
||||
"resize-nesw": {
|
||||
imageDataUrl: resizeNeswUrl,
|
||||
width: 32,
|
||||
height: 32,
|
||||
hotspotX: 16,
|
||||
hotspotY: 16,
|
||||
},
|
||||
"resize-nwse": {
|
||||
imageDataUrl: resizeNwseUrl,
|
||||
width: 32,
|
||||
height: 32,
|
||||
hotspotX: 16,
|
||||
hotspotY: 16,
|
||||
},
|
||||
move: {
|
||||
imageDataUrl: moveUrl,
|
||||
width: 32,
|
||||
height: 32,
|
||||
hotspotX: 16,
|
||||
hotspotY: 16,
|
||||
},
|
||||
"not-allowed": {
|
||||
imageDataUrl: notAllowedUrl,
|
||||
width: 32,
|
||||
height: 32,
|
||||
hotspotX: 16,
|
||||
hotspotY: 16,
|
||||
},
|
||||
wait: {
|
||||
imageDataUrl: waitUrl,
|
||||
width: 32,
|
||||
height: 32,
|
||||
hotspotX: 16,
|
||||
hotspotY: 16,
|
||||
},
|
||||
"app-starting": {
|
||||
imageDataUrl: appStartingUrl,
|
||||
width: 32,
|
||||
height: 32,
|
||||
hotspotX: 7.25,
|
||||
hotspotY: 4.03,
|
||||
},
|
||||
help: {
|
||||
imageDataUrl: helpUrl,
|
||||
width: 32,
|
||||
height: 32,
|
||||
hotspotX: 7.25,
|
||||
hotspotY: 4.03,
|
||||
},
|
||||
"up-arrow": {
|
||||
imageDataUrl: upArrowUrl,
|
||||
width: 32,
|
||||
height: 32,
|
||||
hotspotX: 16,
|
||||
hotspotY: 3,
|
||||
},
|
||||
};
|
||||
|
||||
function resolveUntypedPrettyNativeCursorAsset(asset: NativeCursorAsset) {
|
||||
if (
|
||||
asset.cursorType ||
|
||||
asset.width < 24 ||
|
||||
asset.width > 64 ||
|
||||
asset.height < 24 ||
|
||||
asset.height > 64
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hotspotXNorm = asset.hotspotX / asset.width;
|
||||
const hotspotYNorm = asset.hotspotY / asset.height;
|
||||
const looksLikeChromiumGrabCursor =
|
||||
hotspotXNorm >= 0.22 && hotspotXNorm <= 0.55 && hotspotYNorm >= 0.2 && hotspotYNorm <= 0.45;
|
||||
|
||||
return looksLikeChromiumGrabCursor ? (PRETTY_NATIVE_CURSOR_ASSETS["open-hand"] ?? null) : null;
|
||||
}
|
||||
|
||||
export function hasNativeCursorRecordingData(
|
||||
recordingData: CursorRecordingData | null | undefined,
|
||||
): recordingData is CursorRecordingData {
|
||||
return Boolean(
|
||||
recordingData &&
|
||||
recordingData.provider === "native" &&
|
||||
recordingData.samples.length > 0 &&
|
||||
recordingData.assets.length > 0,
|
||||
);
|
||||
}
|
||||
|
||||
export function createNativeCursorSmoothingState(): NativeCursorSmoothingState {
|
||||
return {
|
||||
cx: 0,
|
||||
cy: 0,
|
||||
lastTimeMs: null,
|
||||
initialized: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function resetNativeCursorSmoothingState(state: NativeCursorSmoothingState) {
|
||||
state.cx = 0;
|
||||
state.cy = 0;
|
||||
state.lastTimeMs = null;
|
||||
state.initialized = false;
|
||||
}
|
||||
|
||||
export function createNativeCursorMotionBlurState(): NativeCursorMotionBlurState {
|
||||
return {
|
||||
x: 0,
|
||||
y: 0,
|
||||
lastTimeMs: null,
|
||||
initialized: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function resetNativeCursorMotionBlurState(state: NativeCursorMotionBlurState) {
|
||||
state.x = 0;
|
||||
state.y = 0;
|
||||
state.lastTimeMs = null;
|
||||
state.initialized = false;
|
||||
}
|
||||
|
||||
export function smoothNativeCursorSample({
|
||||
forceSnap = false,
|
||||
sample,
|
||||
smoothing,
|
||||
state,
|
||||
timeMs,
|
||||
}: {
|
||||
forceSnap?: boolean;
|
||||
sample: CursorRecordingSample;
|
||||
smoothing: number;
|
||||
state: NativeCursorSmoothingState;
|
||||
timeMs: number;
|
||||
}): CursorRecordingSample {
|
||||
const clampedSmoothing = clamp(Number.isFinite(smoothing) ? smoothing : 0, 0, 0.98);
|
||||
const previousTimeMs = state.lastTimeMs;
|
||||
const shouldSnap =
|
||||
forceSnap ||
|
||||
clampedSmoothing <= 0 ||
|
||||
!state.initialized ||
|
||||
previousTimeMs === null ||
|
||||
timeMs <= previousTimeMs;
|
||||
|
||||
if (shouldSnap) {
|
||||
state.cx = sample.cx;
|
||||
state.cy = sample.cy;
|
||||
state.lastTimeMs = timeMs;
|
||||
state.initialized = true;
|
||||
return sample;
|
||||
}
|
||||
|
||||
const frameCount = Math.max(1, (timeMs - previousTimeMs) / (1000 / 60));
|
||||
const alpha = 1 - Math.pow(clampedSmoothing, frameCount);
|
||||
state.cx += (sample.cx - state.cx) * alpha;
|
||||
state.cy += (sample.cy - state.cy) * alpha;
|
||||
state.lastTimeMs = timeMs;
|
||||
|
||||
return {
|
||||
...sample,
|
||||
cx: state.cx,
|
||||
cy: state.cy,
|
||||
};
|
||||
}
|
||||
|
||||
export function getNativeCursorClickBounceProgress(
|
||||
recordingData: CursorRecordingData | null | undefined,
|
||||
timeMs: number,
|
||||
) {
|
||||
if (!recordingData || recordingData.provider !== "native" || recordingData.samples.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
for (
|
||||
let index = findNativeCursorSampleIndexAtOrBefore(recordingData.samples, timeMs);
|
||||
index >= 0;
|
||||
index -= 1
|
||||
) {
|
||||
const sample = recordingData.samples[index];
|
||||
const ageMs = timeMs - sample.timeMs;
|
||||
if (ageMs > NATIVE_CURSOR_CLICK_ANIMATION_MS) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (sample.interactionType === "click") {
|
||||
return 1 - ageMs / NATIVE_CURSOR_CLICK_ANIMATION_MS;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function getNativeCursorClickBounceScale(clickBounce: number, progress: number) {
|
||||
if (progress <= 0 || clickBounce <= 0) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
const intensity = clamp(clickBounce, 0, 5) / 5;
|
||||
const elapsed = 1 - clamp(progress, 0, 1);
|
||||
if (elapsed < 0.38) {
|
||||
const pressProgress = Math.sin((elapsed / 0.38) * Math.PI);
|
||||
return 1 - pressProgress * intensity * 0.24;
|
||||
}
|
||||
|
||||
const reboundProgress = Math.sin(((elapsed - 0.38) / 0.62) * Math.PI);
|
||||
return 1 + reboundProgress * intensity * 0.16;
|
||||
}
|
||||
|
||||
export function getNativeCursorMotionBlurPx({
|
||||
motionBlur,
|
||||
point,
|
||||
state,
|
||||
timeMs,
|
||||
}: {
|
||||
motionBlur: number;
|
||||
point: { x: number; y: number };
|
||||
state: NativeCursorMotionBlurState;
|
||||
timeMs: number;
|
||||
}) {
|
||||
const clampedMotionBlur = clamp(Number.isFinite(motionBlur) ? motionBlur : 0, 0, 1);
|
||||
const previousTimeMs = state.lastTimeMs;
|
||||
const shouldSnap =
|
||||
clampedMotionBlur <= 0 ||
|
||||
!state.initialized ||
|
||||
previousTimeMs === null ||
|
||||
timeMs <= previousTimeMs;
|
||||
|
||||
if (shouldSnap) {
|
||||
state.x = point.x;
|
||||
state.y = point.y;
|
||||
state.lastTimeMs = timeMs;
|
||||
state.initialized = true;
|
||||
return 0;
|
||||
}
|
||||
|
||||
const deltaMs = Math.max(1, timeMs - previousTimeMs);
|
||||
const distance = Math.hypot(point.x - state.x, point.y - state.y);
|
||||
const speedPxPerSecond = (distance / deltaMs) * 1000;
|
||||
state.x = point.x;
|
||||
state.y = point.y;
|
||||
state.lastTimeMs = timeMs;
|
||||
|
||||
return clamp(speedPxPerSecond * clampedMotionBlur * 0.004, 0, NATIVE_CURSOR_MOTION_BLUR_MAX_PX);
|
||||
}
|
||||
|
||||
function getCroppedCursorPosition(sample: CursorRecordingSample, cropRegion: CropRegion) {
|
||||
if (cropRegion.width <= 0 || cropRegion.height <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const croppedCx = (sample.cx - cropRegion.x) / cropRegion.width;
|
||||
const croppedCy = (sample.cy - cropRegion.y) / cropRegion.height;
|
||||
|
||||
if (croppedCx < 0 || croppedCx > 1 || croppedCy < 0 || croppedCy > 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
cx: clamp(croppedCx, 0, 1),
|
||||
cy: clamp(croppedCy, 0, 1),
|
||||
};
|
||||
}
|
||||
|
||||
function getNativeCursorMaskPoint(sample: CursorRecordingSample, cropRegion: CropRegion) {
|
||||
const croppedPosition = getCroppedCursorPosition(sample, cropRegion);
|
||||
if (!croppedPosition) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new Point(croppedPosition.cx, croppedPosition.cy);
|
||||
}
|
||||
|
||||
export function resolveActiveNativeCursorFrame(
|
||||
recordingData: CursorRecordingData | null | undefined,
|
||||
timeMs: number,
|
||||
): ActiveNativeCursorFrame | null {
|
||||
if (!hasNativeCursorRecordingData(recordingData)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const index = findNativeCursorSampleIndexAtOrBefore(recordingData.samples, timeMs);
|
||||
if (index >= 0) {
|
||||
const sample = recordingData.samples[index];
|
||||
|
||||
if (sample.visible === false || !sample.assetId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const asset = getNativeCursorAsset(recordingData, sample.assetId);
|
||||
if (!asset) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { sample, asset };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function resolveInterpolatedNativeCursorFrame(
|
||||
recordingData: CursorRecordingData | null | undefined,
|
||||
timeMs: number,
|
||||
): ActiveNativeCursorFrame | null {
|
||||
if (!hasNativeCursorRecordingData(recordingData)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const samples = recordingData.samples;
|
||||
const activeIndex = findNativeCursorSampleIndexAtOrBefore(samples, timeMs);
|
||||
|
||||
if (activeIndex < 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const activeSample = samples[activeIndex];
|
||||
if (activeSample.visible === false || !activeSample.assetId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const asset = getNativeCursorAsset(recordingData, activeSample.assetId);
|
||||
if (!asset) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const nextSample = samples[activeIndex + 1];
|
||||
if (
|
||||
!nextSample ||
|
||||
nextSample.timeMs <= activeSample.timeMs ||
|
||||
nextSample.visible === false ||
|
||||
nextSample.assetId !== activeSample.assetId ||
|
||||
timeMs <= activeSample.timeMs
|
||||
) {
|
||||
return { asset, sample: activeSample };
|
||||
}
|
||||
|
||||
const interpolation = clamp(
|
||||
(timeMs - activeSample.timeMs) / (nextSample.timeMs - activeSample.timeMs),
|
||||
0,
|
||||
1,
|
||||
);
|
||||
|
||||
return {
|
||||
asset,
|
||||
sample: {
|
||||
...activeSample,
|
||||
cx: activeSample.cx + (nextSample.cx - activeSample.cx) * interpolation,
|
||||
cy: activeSample.cy + (nextSample.cy - activeSample.cy) * interpolation,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function projectNativeCursorToLocal({
|
||||
cropRegion,
|
||||
maskRect,
|
||||
sample,
|
||||
}: ProjectNativeCursorOptions) {
|
||||
const maskPoint = getNativeCursorMaskPoint(sample, cropRegion);
|
||||
if (!maskPoint) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new Point(
|
||||
maskRect.x + maskPoint.x * maskRect.width,
|
||||
maskRect.y + maskPoint.y * maskRect.height,
|
||||
);
|
||||
}
|
||||
|
||||
export function projectNativeCursorToStage({
|
||||
cameraContainer,
|
||||
videoContainerPosition,
|
||||
...options
|
||||
}: ProjectNativeCursorToStageOptions) {
|
||||
const localPoint = projectNativeCursorToLocal(options);
|
||||
if (!localPoint) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return cameraContainer.toGlobal(
|
||||
new Point(localPoint.x + videoContainerPosition.x, localPoint.y + videoContainerPosition.y),
|
||||
);
|
||||
}
|
||||
|
||||
export function getNativeCursorDisplayMetrics(asset: NativeCursorAsset, deviceScaleFactor: number) {
|
||||
const scaleFactor = asset.scaleFactor ?? deviceScaleFactor ?? 1;
|
||||
return {
|
||||
width: asset.width / scaleFactor,
|
||||
height: asset.height / scaleFactor,
|
||||
hotspotX: asset.hotspotX / scaleFactor,
|
||||
hotspotY: asset.hotspotY / scaleFactor,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolvePrettyNativeCursorAsset(
|
||||
asset: NativeCursorAsset,
|
||||
sample?: CursorRecordingSample,
|
||||
) {
|
||||
const cursorType = sample?.cursorType ?? asset.cursorType ?? null;
|
||||
return cursorType
|
||||
? (PRETTY_NATIVE_CURSOR_ASSETS[cursorType] ?? null)
|
||||
: resolveUntypedPrettyNativeCursorAsset(asset);
|
||||
}
|
||||
|
||||
export function resolveNativeCursorRenderAsset(
|
||||
asset: NativeCursorAsset,
|
||||
deviceScaleFactor: number,
|
||||
sample?: CursorRecordingSample,
|
||||
) {
|
||||
const prettyAsset = resolvePrettyNativeCursorAsset(asset, sample);
|
||||
if (prettyAsset) {
|
||||
return {
|
||||
id: `pretty:${sample?.cursorType ?? asset.cursorType}`,
|
||||
imageDataUrl: prettyAsset.imageDataUrl,
|
||||
width: prettyAsset.width,
|
||||
height: prettyAsset.height,
|
||||
hotspotX: prettyAsset.hotspotX,
|
||||
hotspotY: prettyAsset.hotspotY,
|
||||
};
|
||||
}
|
||||
|
||||
const metrics = getNativeCursorDisplayMetrics(asset, deviceScaleFactor);
|
||||
return {
|
||||
id: asset.id,
|
||||
imageDataUrl: asset.imageDataUrl,
|
||||
width: metrics.width,
|
||||
height: metrics.height,
|
||||
hotspotX: metrics.hotspotX,
|
||||
hotspotY: metrics.hotspotY,
|
||||
};
|
||||
}
|
||||
@@ -56,8 +56,22 @@ import {
|
||||
type Size,
|
||||
type StyledRenderRect,
|
||||
} from "@/lib/compositeLayout";
|
||||
import {
|
||||
createNativeCursorMotionBlurState,
|
||||
createNativeCursorSmoothingState,
|
||||
getNativeCursorClickBounceProgress,
|
||||
getNativeCursorClickBounceScale,
|
||||
getNativeCursorMotionBlurPx,
|
||||
hasNativeCursorRecordingData,
|
||||
projectNativeCursorToLocal,
|
||||
resetNativeCursorMotionBlurState,
|
||||
resolveInterpolatedNativeCursorFrame,
|
||||
resolveNativeCursorRenderAsset,
|
||||
smoothNativeCursorSample,
|
||||
} from "@/lib/cursor/nativeCursor";
|
||||
import { BackgroundLoadError, classifyWallpaper, resolveImageWallpaperUrl } from "@/lib/wallpaper";
|
||||
import { drawCanvasClipPath } from "@/lib/webcamMaskShapes";
|
||||
import type { CursorRecordingData } from "@/native/contracts";
|
||||
import { renderAnnotations } from "./annotationRenderer";
|
||||
import {
|
||||
getLinearGradientPoints,
|
||||
@@ -93,6 +107,11 @@ interface FrameRenderConfig {
|
||||
cursorTelemetry?: import("@/components/video-editor/types").CursorTelemetryPoint[];
|
||||
cursorHighlight?: CursorHighlightConfig;
|
||||
cursorClickTimestamps?: number[];
|
||||
cursorRecordingData?: CursorRecordingData | null;
|
||||
cursorScale?: number;
|
||||
cursorSmoothing?: number;
|
||||
cursorMotionBlur?: number;
|
||||
cursorClickBounce?: number;
|
||||
platform: string;
|
||||
}
|
||||
|
||||
@@ -141,6 +160,9 @@ export class FrameRenderer {
|
||||
private layoutCache: LayoutCache | null = null;
|
||||
private currentVideoTime = 0;
|
||||
private motionBlurState: MotionBlurState = createMotionBlurState();
|
||||
private nativeCursorSmoothingState = createNativeCursorSmoothingState();
|
||||
private nativeCursorMotionBlurState = createNativeCursorMotionBlurState();
|
||||
private nativeCursorImageCache = new Map<string, Promise<HTMLImageElement>>();
|
||||
private smoothedAutoFocus: { cx: number; cy: number } | null = null;
|
||||
private prevAnimationTimeMs: number | null = null;
|
||||
private prevTargetProgress = 0;
|
||||
@@ -360,6 +382,20 @@ export class FrameRenderer {
|
||||
this.backgroundSprite = bgCanvas;
|
||||
}
|
||||
|
||||
private loadNativeCursorImage(id: string, url: string): Promise<HTMLImageElement> {
|
||||
const cached = this.nativeCursorImageCache.get(id);
|
||||
if (cached) return cached;
|
||||
|
||||
const promise = new Promise<HTMLImageElement>((resolve, reject) => {
|
||||
const image = new Image();
|
||||
image.onload = () => resolve(image);
|
||||
image.onerror = () => reject(new Error(`Failed to load native cursor asset: ${id}`));
|
||||
image.src = url;
|
||||
});
|
||||
this.nativeCursorImageCache.set(id, promise);
|
||||
return promise;
|
||||
}
|
||||
|
||||
async renderFrame(
|
||||
videoFrame: VideoFrame,
|
||||
timestamp: number,
|
||||
@@ -468,6 +504,72 @@ export class FrameRenderer {
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
hasNativeCursorRecordingData(this.config.cursorRecordingData) &&
|
||||
this.config.cursorScale !== undefined &&
|
||||
this.config.cursorScale > 0 &&
|
||||
this.foregroundCtx
|
||||
) {
|
||||
const frame = resolveInterpolatedNativeCursorFrame(this.config.cursorRecordingData, timeMs);
|
||||
if (frame) {
|
||||
const displaySample = smoothNativeCursorSample({
|
||||
sample: frame.sample,
|
||||
smoothing: this.config.cursorSmoothing ?? 0,
|
||||
state: this.nativeCursorSmoothingState,
|
||||
timeMs,
|
||||
});
|
||||
const localPoint = projectNativeCursorToLocal({
|
||||
cropRegion: this.config.cropRegion,
|
||||
maskRect: layoutCache.maskRect,
|
||||
sample: displaySample,
|
||||
});
|
||||
|
||||
if (localPoint) {
|
||||
const renderAsset = resolveNativeCursorRenderAsset(frame.asset, 1, displaySample);
|
||||
try {
|
||||
const image = await this.loadNativeCursorImage(
|
||||
renderAsset.id,
|
||||
renderAsset.imageDataUrl,
|
||||
);
|
||||
const appliedScale = this.animationState.appliedScale;
|
||||
const canvasX = localPoint.x * appliedScale + this.animationState.x;
|
||||
const canvasY = localPoint.y * appliedScale + this.animationState.y;
|
||||
const bounceProgress = getNativeCursorClickBounceProgress(
|
||||
this.config.cursorRecordingData,
|
||||
timeMs,
|
||||
);
|
||||
const cursorScale =
|
||||
(this.config.cursorScale ?? 1) *
|
||||
getNativeCursorClickBounceScale(this.config.cursorClickBounce ?? 0, bounceProgress);
|
||||
const drawScale = cursorScale * appliedScale;
|
||||
const width = renderAsset.width * drawScale;
|
||||
const height = renderAsset.height * drawScale;
|
||||
const x = canvasX - renderAsset.hotspotX * drawScale;
|
||||
const y = canvasY - renderAsset.hotspotY * drawScale;
|
||||
const blurPx = getNativeCursorMotionBlurPx({
|
||||
motionBlur: this.config.cursorMotionBlur ?? 0,
|
||||
point: { x: canvasX, y: canvasY },
|
||||
state: this.nativeCursorMotionBlurState,
|
||||
timeMs,
|
||||
});
|
||||
|
||||
this.foregroundCtx.save();
|
||||
if (blurPx > 0) {
|
||||
this.foregroundCtx.filter = `blur(${blurPx.toFixed(2)}px)`;
|
||||
}
|
||||
this.foregroundCtx.drawImage(image, x, y, width, height);
|
||||
this.foregroundCtx.restore();
|
||||
} catch (error) {
|
||||
console.warn("[FrameRenderer] Failed to draw native cursor:", error);
|
||||
}
|
||||
} else {
|
||||
resetNativeCursorMotionBlurState(this.nativeCursorMotionBlurState);
|
||||
}
|
||||
} else {
|
||||
resetNativeCursorMotionBlurState(this.nativeCursorMotionBlurState);
|
||||
}
|
||||
}
|
||||
|
||||
// Render annotations on top of foreground (so they rotate with recording).
|
||||
if (
|
||||
this.config.annotationRegions &&
|
||||
|
||||
@@ -53,6 +53,11 @@ interface GifExporterConfig {
|
||||
cursorTelemetry?: import("@/components/video-editor/types").CursorTelemetryPoint[];
|
||||
cursorHighlight?: import("@/components/video-editor/videoPlayback/cursorHighlight").CursorHighlightConfig;
|
||||
cursorClickTimestamps?: number[];
|
||||
cursorRecordingData?: import("@/native/contracts").CursorRecordingData | null;
|
||||
cursorScale?: number;
|
||||
cursorSmoothing?: number;
|
||||
cursorMotionBlur?: number;
|
||||
cursorClickBounce?: number;
|
||||
onProgress?: (progress: ExportProgress) => void;
|
||||
}
|
||||
|
||||
@@ -165,6 +170,11 @@ export class GifExporter {
|
||||
cursorTelemetry: this.config.cursorTelemetry,
|
||||
cursorClickTimestamps: this.config.cursorClickTimestamps,
|
||||
cursorHighlight: this.config.cursorHighlight,
|
||||
cursorRecordingData: this.config.cursorRecordingData,
|
||||
cursorScale: this.config.cursorScale,
|
||||
cursorSmoothing: this.config.cursorSmoothing,
|
||||
cursorMotionBlur: this.config.cursorMotionBlur,
|
||||
cursorClickBounce: this.config.cursorClickBounce,
|
||||
platform,
|
||||
});
|
||||
await this.renderer.initialize();
|
||||
|
||||
@@ -44,6 +44,11 @@ interface VideoExporterConfig extends ExportConfig {
|
||||
cursorTelemetry?: import("@/components/video-editor/types").CursorTelemetryPoint[];
|
||||
cursorHighlight?: import("@/components/video-editor/videoPlayback/cursorHighlight").CursorHighlightConfig;
|
||||
cursorClickTimestamps?: number[];
|
||||
cursorRecordingData?: import("@/native/contracts").CursorRecordingData | null;
|
||||
cursorScale?: number;
|
||||
cursorSmoothing?: number;
|
||||
cursorMotionBlur?: number;
|
||||
cursorClickBounce?: number;
|
||||
onProgress?: (progress: ExportProgress) => void;
|
||||
}
|
||||
|
||||
@@ -160,6 +165,11 @@ export class VideoExporter {
|
||||
cursorTelemetry: this.config.cursorTelemetry,
|
||||
cursorClickTimestamps: this.config.cursorClickTimestamps,
|
||||
cursorHighlight: this.config.cursorHighlight,
|
||||
cursorRecordingData: this.config.cursorRecordingData,
|
||||
cursorScale: this.config.cursorScale,
|
||||
cursorSmoothing: this.config.cursorSmoothing,
|
||||
cursorMotionBlur: this.config.cursorMotionBlur,
|
||||
cursorClickBounce: this.config.cursorClickBounce,
|
||||
platform,
|
||||
});
|
||||
this.renderer = renderer;
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
export type NativeWindowsSourceType = "display" | "window";
|
||||
|
||||
export type NativeWindowsRecordingRequest = {
|
||||
recordingId?: number;
|
||||
source: {
|
||||
type: NativeWindowsSourceType;
|
||||
sourceId: string;
|
||||
displayId?: number;
|
||||
windowHandle?: string;
|
||||
};
|
||||
video: {
|
||||
fps: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
audio: {
|
||||
system: {
|
||||
enabled: boolean;
|
||||
};
|
||||
microphone: {
|
||||
enabled: boolean;
|
||||
deviceId?: string;
|
||||
deviceName?: string;
|
||||
gain: number;
|
||||
};
|
||||
};
|
||||
webcam: {
|
||||
enabled: boolean;
|
||||
deviceId?: string;
|
||||
deviceName?: string;
|
||||
directShowClsid?: string;
|
||||
width: number;
|
||||
height: number;
|
||||
fps: number;
|
||||
};
|
||||
cursor: {
|
||||
mode: import("./recordingSession").CursorCaptureMode;
|
||||
};
|
||||
};
|
||||
|
||||
export type NativeWindowsRecordingStartResult = {
|
||||
success: boolean;
|
||||
recordingId?: number;
|
||||
path?: string;
|
||||
helperPath?: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export function parseWindowHandleFromSourceId(sourceId?: string | null) {
|
||||
if (!sourceId?.startsWith("window:")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handlePart = sourceId.split(":")[1];
|
||||
if (!handlePart || !/^\d+$/.test(handlePart)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return handlePart;
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
export interface ProjectMedia {
|
||||
screenVideoPath: string;
|
||||
webcamVideoPath?: string;
|
||||
cursorCaptureMode?: CursorCaptureMode;
|
||||
}
|
||||
|
||||
export type CursorCaptureMode = "editable-overlay" | "system";
|
||||
|
||||
export interface RecordingSession extends ProjectMedia {
|
||||
createdAt: number;
|
||||
}
|
||||
@@ -16,6 +19,11 @@ export interface StoreRecordedSessionInput {
|
||||
screen: RecordedVideoAssetInput;
|
||||
webcam?: RecordedVideoAssetInput;
|
||||
createdAt?: number;
|
||||
cursorCaptureMode?: CursorCaptureMode;
|
||||
}
|
||||
|
||||
export function normalizeCursorCaptureMode(value: unknown): CursorCaptureMode | undefined {
|
||||
return value === "editable-overlay" || value === "system" ? value : undefined;
|
||||
}
|
||||
|
||||
function normalizePath(value: unknown): string | undefined {
|
||||
@@ -40,12 +48,13 @@ export function normalizeProjectMedia(candidate: unknown): ProjectMedia | null {
|
||||
}
|
||||
|
||||
const webcamVideoPath = normalizePath(raw.webcamVideoPath);
|
||||
const cursorCaptureMode = normalizeCursorCaptureMode(raw.cursorCaptureMode);
|
||||
|
||||
return webcamVideoPath
|
||||
? { screenVideoPath, webcamVideoPath }
|
||||
: {
|
||||
screenVideoPath,
|
||||
};
|
||||
return {
|
||||
screenVideoPath,
|
||||
...(webcamVideoPath ? { webcamVideoPath } : {}),
|
||||
...(cursorCaptureMode ? { cursorCaptureMode } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeRecordingSession(candidate: unknown): RecordingSession | null {
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
export const NATIVE_BRIDGE_CHANNEL = "native-bridge:invoke";
|
||||
export const NATIVE_BRIDGE_VERSION = 1;
|
||||
|
||||
export type NativePlatform = "darwin" | "win32" | "linux";
|
||||
export type CursorProviderKind = "native" | "none";
|
||||
export type NativeCursorType =
|
||||
| "arrow"
|
||||
| "text"
|
||||
| "pointer"
|
||||
| "crosshair"
|
||||
| "open-hand"
|
||||
| "closed-hand"
|
||||
| "resize-ew"
|
||||
| "resize-ns"
|
||||
| "resize-nesw"
|
||||
| "resize-nwse"
|
||||
| "move"
|
||||
| "not-allowed"
|
||||
| "wait"
|
||||
| "app-starting"
|
||||
| "help"
|
||||
| "up-arrow";
|
||||
|
||||
export interface CursorTelemetryPoint {
|
||||
timeMs: number;
|
||||
cx: number;
|
||||
cy: number;
|
||||
}
|
||||
|
||||
export interface CursorRecordingSample extends CursorTelemetryPoint {
|
||||
assetId?: string | null;
|
||||
visible?: boolean;
|
||||
cursorType?: NativeCursorType | null;
|
||||
interactionType?: "move" | "click" | "mouseup";
|
||||
}
|
||||
|
||||
export interface NativeCursorAsset {
|
||||
id: string;
|
||||
platform: NativePlatform;
|
||||
imageDataUrl: string;
|
||||
width: number;
|
||||
height: number;
|
||||
hotspotX: number;
|
||||
hotspotY: number;
|
||||
scaleFactor?: number;
|
||||
cursorType?: NativeCursorType | null;
|
||||
}
|
||||
|
||||
export interface CursorRecordingData {
|
||||
version: number;
|
||||
provider: CursorProviderKind;
|
||||
samples: CursorRecordingSample[];
|
||||
assets: NativeCursorAsset[];
|
||||
}
|
||||
|
||||
export interface CursorCapabilities {
|
||||
telemetry: boolean;
|
||||
systemAssets: boolean;
|
||||
provider: CursorProviderKind;
|
||||
}
|
||||
|
||||
export interface SystemCapabilities {
|
||||
bridgeVersion: typeof NATIVE_BRIDGE_VERSION;
|
||||
platform: NativePlatform;
|
||||
cursor: CursorCapabilities;
|
||||
project: {
|
||||
currentContext: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ProjectContext {
|
||||
currentProjectPath: string | null;
|
||||
currentVideoPath: string | null;
|
||||
}
|
||||
|
||||
export interface ProjectPathResult {
|
||||
success: boolean;
|
||||
path?: string;
|
||||
message?: string;
|
||||
canceled?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface ProjectFileResult {
|
||||
success: boolean;
|
||||
path?: string;
|
||||
project?: unknown;
|
||||
message?: string;
|
||||
canceled?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export type NativeBridgeErrorCode =
|
||||
| "INVALID_REQUEST"
|
||||
| "UNSUPPORTED_ACTION"
|
||||
| "NOT_FOUND"
|
||||
| "UNAVAILABLE"
|
||||
| "INTERNAL_ERROR";
|
||||
|
||||
export interface NativeBridgeError {
|
||||
code: NativeBridgeErrorCode;
|
||||
message: string;
|
||||
retryable: boolean;
|
||||
}
|
||||
|
||||
export interface NativeBridgeMeta {
|
||||
version: typeof NATIVE_BRIDGE_VERSION;
|
||||
requestId: string;
|
||||
timestampMs: number;
|
||||
}
|
||||
|
||||
export interface NativeBridgeSuccess<TData> {
|
||||
ok: true;
|
||||
data: TData;
|
||||
meta: NativeBridgeMeta;
|
||||
}
|
||||
|
||||
export interface NativeBridgeFailure {
|
||||
ok: false;
|
||||
error: NativeBridgeError;
|
||||
meta: NativeBridgeMeta;
|
||||
}
|
||||
|
||||
export type NativeBridgeResponse<TData = unknown> =
|
||||
| NativeBridgeSuccess<TData>
|
||||
| NativeBridgeFailure;
|
||||
|
||||
type EmptyPayload = Record<string, never>;
|
||||
|
||||
export type NativeBridgeRequest =
|
||||
| {
|
||||
domain: "system";
|
||||
action: "getPlatform";
|
||||
payload?: EmptyPayload;
|
||||
requestId?: string;
|
||||
}
|
||||
| {
|
||||
domain: "system";
|
||||
action: "getAssetBasePath";
|
||||
payload?: EmptyPayload;
|
||||
requestId?: string;
|
||||
}
|
||||
| {
|
||||
domain: "system";
|
||||
action: "getCapabilities";
|
||||
payload?: EmptyPayload;
|
||||
requestId?: string;
|
||||
}
|
||||
| {
|
||||
domain: "project";
|
||||
action: "getCurrentContext";
|
||||
payload?: EmptyPayload;
|
||||
requestId?: string;
|
||||
}
|
||||
| {
|
||||
domain: "project";
|
||||
action: "saveProjectFile";
|
||||
payload: {
|
||||
projectData: unknown;
|
||||
suggestedName?: string;
|
||||
existingProjectPath?: string;
|
||||
};
|
||||
requestId?: string;
|
||||
}
|
||||
| {
|
||||
domain: "project";
|
||||
action: "loadProjectFile";
|
||||
payload?: EmptyPayload;
|
||||
requestId?: string;
|
||||
}
|
||||
| {
|
||||
domain: "project";
|
||||
action: "loadCurrentProjectFile";
|
||||
payload?: EmptyPayload;
|
||||
requestId?: string;
|
||||
}
|
||||
| {
|
||||
domain: "project";
|
||||
action: "setCurrentVideoPath";
|
||||
payload: {
|
||||
path: string;
|
||||
};
|
||||
requestId?: string;
|
||||
}
|
||||
| {
|
||||
domain: "project";
|
||||
action: "getCurrentVideoPath";
|
||||
payload?: EmptyPayload;
|
||||
requestId?: string;
|
||||
}
|
||||
| {
|
||||
domain: "project";
|
||||
action: "clearCurrentVideoPath";
|
||||
payload?: EmptyPayload;
|
||||
requestId?: string;
|
||||
}
|
||||
| {
|
||||
domain: "cursor";
|
||||
action: "getCapabilities";
|
||||
payload?: EmptyPayload;
|
||||
requestId?: string;
|
||||
}
|
||||
| {
|
||||
domain: "cursor";
|
||||
action: "getTelemetry";
|
||||
payload?: {
|
||||
videoPath?: string;
|
||||
};
|
||||
requestId?: string;
|
||||
}
|
||||
| {
|
||||
domain: "cursor";
|
||||
action: "getRecordingData";
|
||||
payload?: {
|
||||
videoPath?: string;
|
||||
};
|
||||
requestId?: string;
|
||||
};
|
||||
|
||||
export type NativeBridgeEventName =
|
||||
| "project.contextChanged"
|
||||
| "cursor.providerChanged"
|
||||
| "cursor.telemetryLoaded";
|
||||
|
||||
export interface NativeBridgeEvent<TPayload = unknown> {
|
||||
name: NativeBridgeEventName;
|
||||
payload: TPayload;
|
||||
meta: NativeBridgeMeta;
|
||||
}
|
||||