Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca8104c279 | ||
|
|
f57e36e254 | ||
|
|
71622a20e3 | ||
|
|
d46d37f3f9 | ||
|
|
ffc41a3c8c | ||
|
|
cc8307f4c8 | ||
|
|
21c637a384 | ||
|
|
5f71979e76 | ||
|
|
c539e9ba26 | ||
|
|
53f9851af9 | ||
|
|
3e3a81684f | ||
|
|
c6331badb1 | ||
|
|
c03db08448 | ||
|
|
96e178c432 | ||
|
|
358d4af438 | ||
|
|
02b4e1b45b | ||
|
|
e49cc02d75 | ||
|
|
a806e34ff0 | ||
|
|
b088a098a9 | ||
|
|
98a32372e7 | ||
|
|
0e3bd17480 | ||
|
|
332b6b7d38 | ||
|
|
62f4f455c8 | ||
|
|
a3716581b0 | ||
|
|
1d7b677f9c | ||
|
|
43b01cfb60 | ||
|
|
4a18361589 | ||
|
|
c35a899ab4 | ||
|
|
970406a457 | ||
|
|
a19771e90d | ||
|
|
b8eeef55ab | ||
|
|
4ce7af4f25 | ||
|
|
e5462f1ac8 | ||
|
|
94a00c0abf | ||
|
|
6873b808d5 | ||
|
|
9a83062302 | ||
|
|
f5ad566534 | ||
|
|
c01c88b1eb | ||
|
|
a280179988 | ||
|
|
a18dc39bab | ||
|
|
b56daaee3e | ||
|
|
53be8df129 | ||
|
|
0ab085961f | ||
|
|
d2dd44ad3b | ||
|
|
0f554e72ae | ||
|
|
00191c479e | ||
|
|
34ef71b363 | ||
|
|
1f43ef8004 | ||
|
|
68b26c220b | ||
|
|
c491b7ca68 | ||
|
|
2cacedf0a0 | ||
|
|
180a1eafd5 | ||
|
|
19be540053 | ||
|
|
324f4e01d7 | ||
|
|
ff767a5e86 | ||
|
|
993f693b5c | ||
|
|
fb49ee1f1c | ||
|
|
82b41da2d8 | ||
|
|
2aa6e90edb | ||
|
|
c34a600138 | ||
|
|
0b06900a9c | ||
|
|
c0702b5e91 | ||
|
|
928e1c5e01 | ||
|
|
545c35d4cd | ||
|
|
eac5cc63b4 | ||
|
|
e82fc0d8cc | ||
|
|
7cf78fe53c | ||
|
|
cf74b76d39 | ||
|
|
afc76c368f | ||
|
|
0abe134218 | ||
|
|
391817d648 | ||
|
|
df6c821700 | ||
|
|
dd2513d958 | ||
|
|
ed5ef32280 | ||
|
|
ff40a73a1b | ||
|
|
59c9a19269 | ||
|
|
902d4e4d8a | ||
|
|
d86c17407a | ||
|
|
f1a8552046 | ||
|
|
480890bcce | ||
|
|
62000c6dcf | ||
|
|
202c670090 | ||
|
|
3d572b272d | ||
|
|
fca17f901e | ||
|
|
abb4413932 | ||
|
|
d8ab206b80 | ||
|
|
489f157eef | ||
|
|
60cbb449e8 | ||
|
|
0d39f65af1 | ||
|
|
8117d4826f | ||
|
|
b70994b079 | ||
|
|
5c5cab6903 | ||
|
|
36d7d2bdd0 | ||
|
|
f3c5b8a65d | ||
|
|
8c7ea939ab | ||
|
|
727e395fcf | ||
|
|
205ea55b0c | ||
|
|
54677960d0 | ||
|
|
7f73f9089f | ||
|
|
f6b0bbd3ed | ||
|
|
d32bb00959 | ||
|
|
3d129f85cd | ||
|
|
6ba9f1e948 | ||
|
|
3a2f90722c | ||
|
|
c0b929d4b2 | ||
|
|
db1d6bfe96 | ||
|
|
69d0aa4d30 | ||
|
|
d06bf37471 | ||
|
|
2cf09a88dc | ||
|
|
2349272b21 | ||
|
|
9fc047f2cd | ||
|
|
c293786e9d | ||
|
|
928cabc68d | ||
|
|
9a72f31148 | ||
|
|
9f55611f72 | ||
|
|
87f7268de7 | ||
|
|
f19d1bc668 | ||
|
|
98f7441dfa | ||
|
|
b7d7c08906 | ||
|
|
f67e997688 | ||
|
|
514943c142 | ||
|
|
a72aa1a594 | ||
|
|
46732fc76b | ||
|
|
5b4f31f661 | ||
|
|
70c7d20d3c | ||
|
|
8413095aa9 | ||
|
|
d856a52316 | ||
|
|
37eaacc07b | ||
|
|
34340c2b29 | ||
|
|
2dbdb27bb6 | ||
|
|
fbd06fca48 | ||
|
|
85d0dea9fc | ||
|
|
259bfa9097 | ||
|
|
b6b37e3718 | ||
|
|
a50835e30f | ||
|
|
dfd961393f | ||
|
|
9eaae72af1 | ||
|
|
d658ec40f5 | ||
|
|
84b523df83 | ||
|
|
086039bf67 | ||
|
|
b8e78ccbf5 | ||
|
|
3b7e78a16b | ||
|
|
97fa74aa92 | ||
|
|
0daf2295a3 | ||
|
|
10614c2950 | ||
|
|
b36a32d44b | ||
|
|
ca826d9088 | ||
|
|
ef5855f1f4 | ||
|
|
ea09fdc12c | ||
|
|
9f7f498e22 | ||
|
|
a9df720554 | ||
|
|
37ab35f5a8 | ||
|
|
4a55dcdb4c | ||
|
|
cfe6b9e594 | ||
|
|
7826cc44e3 | ||
|
|
57eed2c829 | ||
|
|
12350520cc | ||
|
|
a9181e6782 | ||
|
|
3be317b7f9 | ||
|
|
49ee3ac0db | ||
|
|
fd2bf21e7e | ||
|
|
94e848452e | ||
|
|
de3c2ef5bd | ||
|
|
c4acb9d6e7 | ||
|
|
a1a75badde | ||
|
|
7319ec2db6 | ||
|
|
de2cc6546a | ||
|
|
9348b9c7a0 | ||
|
|
00fbd95452 | ||
|
|
dd413785f3 | ||
|
|
b25c2c8363 | ||
|
|
a686fa012a | ||
|
|
24ce67b5a7 | ||
|
|
2993a57853 | ||
|
|
36ceca38f9 | ||
|
|
7bae09fc88 | ||
|
|
bc8655a4bb | ||
|
|
e7ca9ecb5d | ||
|
|
0e130b6d49 | ||
|
|
b5c105b5d6 | ||
|
|
788b0a2e9f | ||
|
|
8516707880 | ||
|
|
58722a1d84 | ||
|
|
65bb5bc8dd | ||
|
|
31e394fe1c | ||
|
|
e708ae973e | ||
|
|
179047b834 | ||
|
|
df6da28ad2 | ||
|
|
c1ba82fc71 | ||
|
|
73870c65ef | ||
|
|
b2f9afab8c | ||
|
|
6a4ddc5dad | ||
|
|
b9e2134749 | ||
|
|
7102110de5 | ||
|
|
fbdc7d5697 | ||
|
|
6018ba0fe1 | ||
|
|
e50f65e3b6 | ||
|
|
80e2c3545d | ||
|
|
939de7081b | ||
|
|
55dfca05aa | ||
|
|
5e76170307 | ||
|
|
0d3c4df453 | ||
|
|
9d5be8beb4 | ||
|
|
c4eb3003be | ||
|
|
7bf07611c3 | ||
|
|
c9985a08d4 | ||
|
|
55bc0c9836 | ||
|
|
2ee00a7cfa | ||
|
|
a0bcd9fe90 | ||
|
|
5417581f0e | ||
|
|
3a48e54db1 | ||
|
|
2b7e6f1677 | ||
|
|
1c431fe473 | ||
|
|
7216f255a0 | ||
|
|
c13d3c71bc | ||
|
|
33bb751140 | ||
|
|
d802473d86 | ||
|
|
872bd959da | ||
|
|
b0293e7d93 | ||
|
|
1d36ad239d | ||
|
|
b41c4f49fc | ||
|
|
201729e8ab | ||
|
|
0720a6d802 | ||
|
|
8137e816fd | ||
|
|
4e5b7a4f5a | ||
|
|
afd5e35730 | ||
|
|
ac2e34e58c | ||
|
|
4d3bce0f20 | ||
|
|
b349c0a27c | ||
|
|
238fc97c6d | ||
|
|
0d9e821171 | ||
|
|
34e22d001c | ||
|
|
722f630117 | ||
|
|
f4fc7fab9e | ||
|
|
f91300a1b7 | ||
|
|
82bffefa54 | ||
|
|
826790fe52 | ||
|
|
9b85cacec7 | ||
|
|
f76fb423be | ||
|
|
e33d2205e6 | ||
|
|
3a32a140cc | ||
|
|
d0341580d6 | ||
|
|
ab3d38d90f | ||
|
|
c7b43a50ef | ||
|
|
0ebf5c143b | ||
|
|
c0deb03414 | ||
|
|
38d727eb8e | ||
|
|
84484d6167 | ||
|
|
fdcd882058 | ||
|
|
fb85f66875 | ||
|
|
048189da72 | ||
|
|
7929aea908 | ||
|
|
588a0a7be8 | ||
|
|
062cf2a87c | ||
|
|
d21e5eb34c | ||
|
|
87240a919e | ||
|
|
3d1d4a5ff0 | ||
|
|
ef36da4a4f | ||
|
|
bb0dec7344 | ||
|
|
28ff0fb7bf | ||
|
|
e9650225ba | ||
|
|
248ebabcf1 | ||
|
|
44f59bfa89 | ||
|
|
6f099b3483 | ||
|
|
d3b51e84f2 | ||
|
|
162e734b76 | ||
|
|
2b8ec9e3a5 | ||
|
|
e3d4a330df | ||
|
|
7bbb855e8e | ||
|
|
52cb709a88 | ||
|
|
68a95c642a | ||
|
|
2ae7aca185 | ||
|
|
8afca89520 | ||
|
|
7feb05cca7 | ||
|
|
9ac4e778f8 | ||
|
|
ed825d8b37 | ||
|
|
b48370e3d0 | ||
|
|
8c0c555640 | ||
|
|
24be97bae7 | ||
|
|
c820b416f9 | ||
|
|
559e97ddea | ||
|
|
156e9c1ec5 | ||
|
|
42c596da66 | ||
|
|
613ed008fc | ||
|
|
3aad05abd2 |
@@ -29,6 +29,14 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
# Cache the downloaded caption model so we don't re-fetch from HuggingFace every run
|
||||
# (and to avoid 429s when the platform matrix builds hit it at once).
|
||||
- name: Cache caption assets
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: caption-assets
|
||||
key: caption-assets-${{ hashFiles('scripts/fetch-caption-model.mjs') }}
|
||||
|
||||
- name: Build Windows app
|
||||
run: npm run build:win
|
||||
env:
|
||||
@@ -69,6 +77,23 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
# ─── Ensure sharp prebuilt binary (+ bundled libvips) ─────
|
||||
# `npm ci` can leave sharp without its prebuilt binary/vendored libvips,
|
||||
# which makes electron-builder's native rebuild fail ("vips-cpp.42 not
|
||||
# found"). Re-fetch the prebuilt (never compile from source).
|
||||
- name: Ensure sharp prebuilt
|
||||
run: npm rebuild sharp
|
||||
env:
|
||||
npm_config_build_from_source: "false"
|
||||
|
||||
# ─── Cache caption assets ─────────────────────────────────
|
||||
# Avoid re-fetching the Whisper model from HuggingFace every run (and 429s under the matrix).
|
||||
- name: Cache caption assets
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: caption-assets
|
||||
key: caption-assets-${{ hashFiles('scripts/fetch-caption-model.mjs') }}
|
||||
|
||||
# ─── Import Code Signing Certificate ──────────────────────
|
||||
# This is the KEY step that makes CI signing work.
|
||||
# We create a temporary keychain, import the .p12 cert into it,
|
||||
@@ -111,6 +136,17 @@ jobs:
|
||||
- name: Build Vite + Electron
|
||||
run: npx tsc && npx vite build
|
||||
|
||||
# ─── Build native macOS helpers ───────────────────────────
|
||||
# The package step below calls electron-builder directly (not `npm run build:mac`),
|
||||
# so the Swift screencapturekit + cursor helpers must be compiled here first or the
|
||||
# packaged app ships without them (no recording, "cursor helper couldn't be found").
|
||||
# Build the matrix arch into electron/native/bin/darwin-<arch> so each DMG gets its
|
||||
# own native binary.
|
||||
- name: Build native macOS helpers
|
||||
run: npm run build:native:mac
|
||||
env:
|
||||
OPENSCREEN_MAC_HELPER_ARCHS: ${{ matrix.arch }}
|
||||
|
||||
# ─── Package with electron-builder ────────────────────────
|
||||
# electron-builder handles deep codesigning the .app bundle
|
||||
# "notarize: false" in electron-builder.json5 prevents it from
|
||||
@@ -236,6 +272,14 @@ jobs:
|
||||
- name: Install pacman build dependencies
|
||||
run: sudo apt-get update && sudo apt-get install -y libarchive-tools
|
||||
|
||||
# Cache the downloaded caption model so we don't re-fetch from HuggingFace every run
|
||||
# (and to avoid 429s when the platform matrix builds hit it at once).
|
||||
- name: Cache caption assets
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: caption-assets
|
||||
key: caption-assets-${{ hashFiles('scripts/fetch-caption-model.mjs') }}
|
||||
|
||||
- name: Build Linux app
|
||||
run: npm run build:linux
|
||||
env:
|
||||
|
||||
@@ -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 }}
|
||||
@@ -123,13 +123,13 @@ jobs:
|
||||
|
||||
on_arm do
|
||||
sha256 "${ARM_SHA}"
|
||||
url "https://github.com/${REPO}/releases/download/v#{version}/${ARM_NAME}",
|
||||
verified: "github.com/${REPO}/"
|
||||
|
||||
url "https://github.com/${REPO}/releases/download/v#{version}/${ARM_NAME}"
|
||||
end
|
||||
on_intel do
|
||||
sha256 "${X64_SHA}"
|
||||
url "https://github.com/${REPO}/releases/download/v#{version}/${X64_NAME}",
|
||||
verified: "github.com/${REPO}/"
|
||||
|
||||
url "https://github.com/${REPO}/releases/download/v#{version}/${X64_NAME}"
|
||||
end
|
||||
|
||||
name "Openscreen"
|
||||
|
||||
@@ -1,45 +1,68 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-electron
|
||||
dist-ssr
|
||||
*.local
|
||||
.env
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
.zed/
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
release/**
|
||||
*.kiro/
|
||||
.claude/
|
||||
# npx electron-builder --mac --win
|
||||
|
||||
# Playwright
|
||||
test-results
|
||||
playwright-report/
|
||||
|
||||
# Vitest browser mode screenshots
|
||||
__screenshots__/
|
||||
|
||||
# shell files
|
||||
/shell.sh
|
||||
# Nix
|
||||
result
|
||||
result-*
|
||||
.direnv/
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-electron
|
||||
dist-ssr
|
||||
*.local
|
||||
.env
|
||||
|
||||
# Native helper build outputs
|
||||
/electron/native/wgc-capture/build/
|
||||
/electron/native/screencapturekit/build/
|
||||
/electron/native/screencapturekit/.build/
|
||||
/electron/native/screencapturekit/.swiftpm/
|
||||
/electron/native/bin/
|
||||
|
||||
# Native macOS generated files
|
||||
DerivedData/
|
||||
*.xcuserstate
|
||||
xcuserdata/
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
.zed/
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
release/**
|
||||
*.kiro/
|
||||
.claude/
|
||||
# npx electron-builder --mac --win
|
||||
|
||||
# Playwright
|
||||
test-results
|
||||
playwright-report/
|
||||
|
||||
|
||||
# Vitest browser mode screenshots
|
||||
__screenshots__/
|
||||
|
||||
# shell files
|
||||
/shell.sh
|
||||
# Nix
|
||||
result
|
||||
result-*
|
||||
.direnv/
|
||||
|
||||
#kilocode
|
||||
.kilo/
|
||||
|
||||
#others
|
||||
|
||||
**/*.import
|
||||
|
||||
# Auto-caption model + ORT wasm — regenerated at build by scripts/fetch-caption-model.mjs
|
||||
/caption-assets/
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Contribution Guidelines
|
||||
# Contribution Guidelines
|
||||
|
||||
Thank you for considering contributing to this project! By contributing, you help make this project better for everyone. Please take a moment to review these guidelines to ensure a smooth contribution process.
|
||||
|
||||
|
||||
@@ -1,48 +1,59 @@
|
||||
> [!WARNING]
|
||||
> This is very much in beta and might be buggy here and there (but hope you have a good experience!).
|
||||
|
||||
> [!Note]
|
||||
> OpenScreen is now archived and no longer maintained. if you are looking for something similar, checkout : https://github.com/getopenscreen/openscreen maintained by some folks, nice enough to continue this project in their own way.
|
||||
>
|
||||
<p align="center">
|
||||
<img src="public/openscreen.png" alt="OpenScreen Logo" width="64" />
|
||||
<br />
|
||||
<br />
|
||||
<a href="https://trendshift.io/repositories/17427" target="_blank"><img src="https://trendshift.io/api/badge/repositories/17427" alt="siddharthvaddem%2Fopenscreen | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
<br />
|
||||
<br />
|
||||
<a href="https://deepwiki.com/siddharthvaddem/openscreen">
|
||||
<img src="https://deepwiki.com/badge.svg" alt="Ask DeepWiki" />
|
||||
</a>
|
||||
|
||||
<a href="https://discord.gg/yAQQhRaEeg">
|
||||
<img src="https://dcbadge.limes.pink/api/server/https://discord.gg/yAQQhRaEeg?style=flat" alt="Join Discord" />
|
||||
<a href="https://trendshift.io/repositories/17427" target="_blank"><img src="https://trendshift.io/api/badge/repositories/17427" alt="siddharthvaddem%2Fopenscreen | Trendshift" style="width: 256px; height: 64px;" width="256" height="64"/></a>
|
||||
<p align="center">
|
||||
<a href="https://www.star-history.com/siddharthvaddem/openscreen"><img src="https://api.star-history.com/badge?repo=siddharthvaddem/openscreen&type=rank&theme=dark" alt="Star History Rank" /></a>
|
||||
</p>
|
||||
|
||||
</a>
|
||||
</p>
|
||||
|
||||
# <p align="center">OpenScreen</p>
|
||||
|
||||
<p align="center"><strong>OpenScreen is your free, open-source alternative to Screen Studio (sort of).</strong></p>
|
||||
<p align="center"><strong>OpenScreen is your free, open-source alternative to Screen Studio.</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 version that does what most people seem to need - quick, polished product demos and walkthroughs you'd post on X, Reddit or Youtube. OpenScreen does not offer every Screen Studio feature, but covers a lot of the core functionality.
|
||||
|
||||
Screen Studio is an awesome product and this is definitely not a 1:1 clone. If you just want something fully free and open source, this project should cover most of your needs.
|
||||
|
||||
**100% free** for both **personal** and **commercial** use. Use it, modify it, distribute it. Please respect the License.
|
||||
|
||||
|
||||
> [!NOTE]
|
||||
>Software should be accessible. OpenScreen has no paid tiers, premium features, upsells, or functionality locked behind a paywall.
|
||||
|
||||
|
||||
> [!WARNING]
|
||||
> This started as a side project that blew up; not production grade and you'll hit bugs, but hopefully it covers what you need.
|
||||
|
||||
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 !)
|
||||
|
||||
<p align="center">
|
||||
<img src="public/preview3.png" alt="OpenScreen App Preview 3" style="height: 0.2467; margin-right: 12px;" />
|
||||
<img src="public/preview4.png" alt="OpenScreen App Preview 4" style="height: 0.1678; margin-right: 12px;" />
|
||||
<img src="public/demo.png" alt="" style="height: 0.2467; margin-right: 12px;" />
|
||||
<img src="public/sample.png" alt="" style="height: 0.2467; margin-right: 12px;" />
|
||||
</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, 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, mirroring, and shape options.
|
||||
- Auto or manual zooms with adjustable depth, duration, easing, and pixel-precise position; auto-zoom follows your cursor as you work.
|
||||
- Custom cursor size, smoothing, and click effects, with cursor themes and post-recording path smoothing.
|
||||
- Automatic captions for voiceovers, generated on-device with no upload (works offline).
|
||||
- Wallpapers, solid colors, gradients, or your own background image.
|
||||
- Motion blur.
|
||||
- Crop, trim, and per-segment speed control on the timeline.
|
||||
- Text, arrow, and image annotations, with text animation presets.
|
||||
- Timeline snapping guides and an audio waveform to make trimming easier.
|
||||
- Customizable keyboard shortcuts.
|
||||
- Export to MP4 or GIF in multiple aspect ratios and resolutions.
|
||||
- Languages supported: Arabic, English, Spanish, French, Italian, Japanese, Korean, Portuguese (Brazil), Russian, Turkish, Vietnamese, Simplified Chinese, and Traditional Chinese.
|
||||
|
||||
|
||||
## Installation
|
||||
|
||||
@@ -50,7 +61,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,61 +84,93 @@ 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.
|
||||
|
||||
> [!NOTE]
|
||||
> **Upgrading from an older version and hitting permission issues?** If you already had OpenScreen installed and the new version won't record (Screen Recording or Accessibility keep failing even after you grant them), uninstall the old version, remove OpenScreen's existing entries under **System Settings > Privacy & Security** (both Screen Recording and Accessibility), then do a fresh install and grant the permissions again when prompted.
|
||||
|
||||
### 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
|
||||
```
|
||||
|
||||
### Limitations
|
||||
### Platform differences
|
||||
|
||||
System audio capture relies on Electron's [desktopCapturer](https://www.electronjs.org/docs/latest/api/desktop-capturer) and has some platform-specific quirks:
|
||||
Everything in the editor and export is the same on macOS, Windows, and Linux: zooms, backgrounds, motion blur, crop/trim/speed, blur regions, annotations, auto-captions, projects, export, and all languages. The differences are in **capture**, where macOS and Windows use a native pipeline that Linux doesn't have:
|
||||
|
||||
- **macOS**: Requires macOS 13+. On macOS 14.2+ you'll be prompted to grant audio capture permission. macOS 12 and below does not support system audio (mic still works).
|
||||
- **Windows**: Works out of the box.
|
||||
- **Linux**: Needs PipeWire (default on Ubuntu 22.04+, Fedora 34+). Older PulseAudio-only setups may not support system audio (mic should still work).
|
||||
|
||||
## Built with
|
||||
- Electron
|
||||
- React
|
||||
- TypeScript
|
||||
- Vite
|
||||
- PixiJS
|
||||
- dnd-timeline
|
||||
- **Native recording**: macOS (ScreenCaptureKit) and Windows (Windows Graphics Capture) record through a native pipeline for higher quality and clean window-level capture. Linux records through the browser pipeline instead.
|
||||
- **Custom cursors**: on macOS and Windows the real cursor is captured (shape, type, and clicks), which powers the cursor themes, click effects, and editable cursor overlay. On Linux only the cursor position is captured (used for auto-zoom), so those cursor options aren't available.
|
||||
- **Webcam**: captured natively on macOS and Windows; on Linux it's recorded through the browser, but still works as a picture-in-picture overlay.
|
||||
- **System audio** support varies by OS:
|
||||
- **macOS**: requires macOS 13+. On macOS 14.2+ you'll be prompted to grant audio capture permission. macOS 12 and below can't capture system audio (mic still works).
|
||||
- **Windows**: works out of the box.
|
||||
- **Linux**: needs PipeWire (default on Ubuntu 22.04+, Fedora 34+). Older PulseAudio-only setups may not capture system audio (mic should still work).
|
||||
|
||||
---
|
||||
|
||||
_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)
|
||||
|
||||
## 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.
|
||||
|
||||
## Star History
|
||||
|
||||
<a href="https://www.star-history.com/?repos=siddharthvaddem%2Fopenscreen&type=date&legend=top-left">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=siddharthvaddem/openscreen&type=date&theme=dark&legend=top-left" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=siddharthvaddem/openscreen&type=date&legend=top-left" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=siddharthvaddem/openscreen&type=date&legend=top-left" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the [MIT License](./LICENSE). By using this software, you agree that the authors are not liable for any issues, damages, or claims arising from its use.
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
# Native Bridge Architecture
|
||||
|
||||
## Goal
|
||||
|
||||
Provide a single, resilient source of truth for platform-native capabilities while keeping Electron transport thin and renderer APIs unified.
|
||||
|
||||
## Layers
|
||||
|
||||
1. Native adapters
|
||||
Platform-specific providers implement stable domain interfaces such as cursor telemetry or system asset discovery.
|
||||
|
||||
2. Main-process services
|
||||
Services orchestrate adapters, own runtime state, and expose domain-level operations.
|
||||
|
||||
3. Unified IPC transport
|
||||
Renderer code talks to a single `native-bridge:invoke` channel using versioned contracts.
|
||||
|
||||
4. Renderer client
|
||||
React code should consume `src/native/client.ts` rather than binding directly to ad hoc Electron APIs.
|
||||
|
||||
## Principles
|
||||
|
||||
- Single source of truth: runtime-native state lives in the Electron main process.
|
||||
- Capability-first: renderer can query support before attempting native behavior.
|
||||
- Versioned contracts: requests and responses are explicit and evolve predictably.
|
||||
- Resilience: every response uses a consistent result envelope with stable error codes.
|
||||
|
||||
## Current rollout
|
||||
|
||||
This repository now contains the initial scaffold:
|
||||
|
||||
- shared contracts in `src/native/contracts.ts`
|
||||
- renderer SDK in `src/native/client.ts`
|
||||
- main-process state store in `electron/native-bridge/store.ts`
|
||||
- cursor telemetry adapter in `electron/native-bridge/cursor/telemetryCursorAdapter.ts`
|
||||
- domain services in `electron/native-bridge/services/*`
|
||||
- unified handler registration in `electron/ipc/nativeBridge.ts`
|
||||
|
||||
The legacy `window.electronAPI` surface still exists for backward compatibility. New native-facing features should prefer the unified bridge client.
|
||||
@@ -0,0 +1,210 @@
|
||||
# macOS Native Recorder Roadmap
|
||||
|
||||
OpenScreen's macOS recorder should follow the same architecture boundaries as the Windows native recorder: Electron owns session orchestration and persistence, while a platform-native helper owns capture, timing, encoding, and platform-specific permissions.
|
||||
|
||||
This work is intentionally scoped as a macOS-only port. Windows native capture remains owned by the WGC helper, and Linux remains on the existing Electron path.
|
||||
|
||||
## Goals
|
||||
|
||||
- Capture displays and windows through ScreenCaptureKit.
|
||||
- Exclude the real system cursor during capture when using the editable OpenScreen cursor overlay.
|
||||
- Preserve the current high-quality cursor overlay path in preview and export.
|
||||
- Capture macOS system audio through ScreenCaptureKit on supported macOS versions.
|
||||
- Capture microphone audio through the same native timing domain where the OS supports it, or through an explicit companion path until it can be moved into the helper.
|
||||
- Mix system audio and microphone audio into the primary MP4 without renderer-side track assembly.
|
||||
- Capture webcam video natively and compose it into the helper-owned MP4 during the native-recording migration.
|
||||
- Keep screen video, audio, webcam, and cursor aligned to one native timing origin.
|
||||
- Package per-architecture helper binaries with macOS builds.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Replacing the editor/export pipeline.
|
||||
- Changing Windows native capture behavior.
|
||||
- Adding Linux native capture.
|
||||
- Shipping a silent fallback from native macOS capture to Electron capture when the user explicitly requested a native-only feature.
|
||||
|
||||
## Architecture
|
||||
|
||||
The renderer keeps the existing recording controls. On macOS, `useScreenRecorder` should eventually send a complete recording request to Electron instead of assembling display, audio, microphone, webcam, and cursor streams in the browser.
|
||||
|
||||
Electron owns the native recording session:
|
||||
|
||||
- resolves the selected display/window source;
|
||||
- resolves output paths;
|
||||
- starts cursor telemetry capture when editable cursor mode is selected;
|
||||
- starts the ScreenCaptureKit helper process;
|
||||
- sends pause/resume/stop/cancel commands;
|
||||
- writes `RecordingSession` manifests;
|
||||
- reports explicit errors when a macOS-native capability is unavailable.
|
||||
|
||||
The helper owns macOS media capture:
|
||||
|
||||
- ScreenCaptureKit display/window frames;
|
||||
- ScreenCaptureKit system audio where supported;
|
||||
- microphone capture or helper-owned companion audio capture;
|
||||
- webcam capture and initial picture-in-picture composition;
|
||||
- AVFoundation/VideoToolbox encoding and muxing;
|
||||
- stream timestamp normalization.
|
||||
|
||||
## Helper Contract V1
|
||||
|
||||
The helper receives a single JSON argument:
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"recordingId": 1234567890,
|
||||
"source": {
|
||||
"type": "display",
|
||||
"sourceId": "screen:0:0",
|
||||
"displayId": 1,
|
||||
"windowId": null,
|
||||
"bounds": { "x": 0, "y": 0, "width": 1920, "height": 1080 }
|
||||
},
|
||||
"video": {
|
||||
"fps": 60,
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"bitrate": 18000000,
|
||||
"hideSystemCursor": true
|
||||
},
|
||||
"audio": {
|
||||
"system": { "enabled": true },
|
||||
"microphone": {
|
||||
"enabled": true,
|
||||
"deviceId": "default",
|
||||
"deviceName": "MacBook Pro Microphone",
|
||||
"gain": 1.4
|
||||
}
|
||||
},
|
||||
"webcam": {
|
||||
"enabled": true,
|
||||
"deviceId": "default",
|
||||
"deviceName": "FaceTime HD Camera",
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"fps": 30
|
||||
},
|
||||
"cursor": {
|
||||
"mode": "editable-overlay"
|
||||
},
|
||||
"outputs": {
|
||||
"screenPath": "/Users/me/Library/Application Support/openscreen/recordings/recording-123.mp4",
|
||||
"manifestPath": "/Users/me/Library/Application Support/openscreen/recordings/recording-123.session.json"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The helper emits newline-delimited JSON events to stdout:
|
||||
|
||||
```json
|
||||
{ "event": "ready", "schemaVersion": 1 }
|
||||
{ "event": "recording-started", "timestampMs": 1234567890 }
|
||||
{ "event": "warning", "code": "microphone-unavailable", "message": "..." }
|
||||
{ "event": "recording-stopped", "screenPath": "..." }
|
||||
{ "event": "error", "code": "screen-permission-denied", "message": "..." }
|
||||
```
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
Current PR status: macOS screen/window capture routes through the ScreenCaptureKit helper when it is available so editable-cursor recordings can hide the system cursor. The helper now writes ScreenCaptureKit system audio into the primary MP4 and attempts runtime-gated native microphone capture on macOS versions that expose ScreenCaptureKit microphone output. Webcam capture is currently an Electron-recorded sidecar attached to the same recording session; native AVFoundation webcam composition remains the target end state.
|
||||
|
||||
### 1. Native Session Boundary
|
||||
|
||||
- Add a structured macOS native recording request type.
|
||||
- Add a macOS helper resolver and build script placeholders.
|
||||
- Keep the helper contract process-based, matching the Windows helper boundary.
|
||||
- Do not route production macOS recording through this helper until the helper is available and validated.
|
||||
|
||||
Acceptance:
|
||||
|
||||
- TypeScript build passes.
|
||||
- The macOS helper path and request contract are documented and testable without affecting Windows/Linux behavior.
|
||||
|
||||
### 2. ScreenCaptureKit Display Capture
|
||||
|
||||
- Implement a Swift helper using ScreenCaptureKit.
|
||||
- Select display captures by `displayId`.
|
||||
- Encode H.264 MP4 through AVFoundation/VideoToolbox.
|
||||
- Set `showsCursor = false` when editable cursor overlay mode is selected.
|
||||
|
||||
Acceptance:
|
||||
|
||||
- Display-only recording produces a valid MP4.
|
||||
- The real cursor is not baked into editable-cursor recordings.
|
||||
|
||||
### 3. ScreenCaptureKit Window Capture
|
||||
|
||||
- Resolve Electron `window:*` selections to ScreenCaptureKit window ids.
|
||||
- Capture `SCContentFilter(desktopIndependentWindow:)`.
|
||||
- Handle closed/minimized/protected windows with explicit errors.
|
||||
- Keep window selection and capture source resolution in Electron/main, not the renderer.
|
||||
|
||||
Acceptance:
|
||||
|
||||
- Capturing a normal app window works with cursor/audio/webcam disabled.
|
||||
- Unsupported windows return clear native errors.
|
||||
|
||||
### 4. System Audio
|
||||
|
||||
- Enable ScreenCaptureKit system audio on supported macOS versions.
|
||||
- Keep audio format and timing owned by the helper.
|
||||
- Encode or mux AAC audio into the primary MP4.
|
||||
|
||||
Acceptance:
|
||||
|
||||
- System-audio-only recordings produce a valid AAC track.
|
||||
- Unsupported macOS versions return an explicit capability error.
|
||||
|
||||
### 5. Microphone
|
||||
|
||||
- Resolve the selected microphone device from the renderer-provided browser `deviceId` and user-visible label.
|
||||
- Capture microphone audio in the helper timing domain.
|
||||
- Apply OpenScreen microphone gain policy.
|
||||
- Mix system and microphone audio before final AAC output.
|
||||
|
||||
Acceptance:
|
||||
|
||||
- Mic-only and mic-plus-system recordings produce a valid, balanced AAC track.
|
||||
- Device selection honors the selected microphone, not only the default device.
|
||||
|
||||
### 6. Webcam Composition
|
||||
|
||||
- Capture the selected camera natively through AVFoundation.
|
||||
- Match browser device id first where possible, then user-visible label.
|
||||
- Compose an initial picture-in-picture overlay into the primary MP4.
|
||||
- Hide webcam output until the first usable frame to avoid black startup flashes.
|
||||
|
||||
Acceptance:
|
||||
|
||||
- Native display/window recordings can include webcam without returning to Electron capture.
|
||||
- Selected camera is honored.
|
||||
|
||||
### 7. Runtime Controls
|
||||
|
||||
- Add pause/resume commands to the helper.
|
||||
- Add cancel command that removes partial outputs.
|
||||
- Keep restart as stop-discard-start until the helper exposes a native restart operation.
|
||||
|
||||
Acceptance:
|
||||
|
||||
- Pause/resume keeps output duration coherent.
|
||||
- Cancel leaves no stale media/session files.
|
||||
|
||||
### 8. Test Pipeline
|
||||
|
||||
- `npm run build:native:mac`: builds Swift helper binaries on macOS.
|
||||
- `npm run test:sck-helper:mac`: display-only helper smoke test.
|
||||
- `npm run test:sck-window:mac`: window capture smoke test.
|
||||
- `npm run test:sck-audio:mac`: system audio smoke test when supported.
|
||||
- `npm run test:sck-mic:mac`: microphone smoke test.
|
||||
- `npm run test:sck-webcam:mac`: webcam smoke test when a webcam is available.
|
||||
- Packaging check: confirms helpers are available under `electron/native/bin/darwin-${arch}` in packaged builds.
|
||||
|
||||
## SSOT Rules
|
||||
|
||||
- `src/lib/nativeMacRecording.ts` is the renderer/main TypeScript request contract.
|
||||
- This document is the feature-level contract and phase checklist.
|
||||
- The Swift helper owns ScreenCaptureKit/AVFoundation media timing.
|
||||
- Electron owns output paths, session manifests, and selected source/device resolution.
|
||||
- Renderer code must use existing hooks/client APIs and should not bind directly to helper process details.
|
||||
@@ -0,0 +1,248 @@
|
||||
# Windows Native Recorder Roadmap
|
||||
|
||||
OpenScreen's Windows recorder should be owned by one native backend. Electron capture can remain available for non-Windows platforms and temporary developer diagnostics, but Windows production recording should not silently fall back to `getDisplayMedia` / `MediaRecorder`.
|
||||
|
||||
## Goals
|
||||
|
||||
- Capture displays and windows through Windows Graphics Capture (WGC).
|
||||
- Render the native Windows cursor as OpenScreen's high-quality scalable cursor overlay.
|
||||
- Capture system audio through WASAPI loopback.
|
||||
- Capture microphone audio through WASAPI.
|
||||
- Mix system audio and microphone audio into the primary screen recording.
|
||||
- Capture webcam video natively and compose it into the Windows helper MP4 during the native-recording migration.
|
||||
- Keep preview/export aligned because screen video, audio, webcam, and cursor share one native timing origin.
|
||||
- Keep exported MP4s Windows-friendly: H.264 video plus AAC audio. Opus-in-MP4 is not an acceptable Windows export target.
|
||||
- Package the native helper with the Windows app.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Replacing the editor/export pipeline.
|
||||
- Replacing the editor/export pipeline. A later pass can reintroduce a separate editable native `webcamVideoPath`; the current Windows-native milestone prioritizes a helper-owned multi-flux MP4 with deterministic screen/audio/mic/webcam sync.
|
||||
- Adding a native fallback for macOS or Linux in this branch.
|
||||
|
||||
## Target Architecture
|
||||
|
||||
The renderer keeps the existing recording controls. On Windows, `useScreenRecorder` sends a complete recording request to Electron and does not assemble Windows `MediaStream` tracks with `MediaRecorder`.
|
||||
|
||||
Electron owns the native recording session:
|
||||
|
||||
- resolves the selected source;
|
||||
- resolves output paths;
|
||||
- starts cursor sampling;
|
||||
- starts the helper process;
|
||||
- sends pause/resume/stop/cancel commands;
|
||||
- writes `RecordingSession` manifests;
|
||||
- reports explicit errors when a Windows-native capability is unavailable.
|
||||
|
||||
The helper owns Windows media capture:
|
||||
|
||||
- WGC screen/window frames;
|
||||
- WASAPI system loopback;
|
||||
- WASAPI microphone input;
|
||||
- Media Foundation webcam capture;
|
||||
- DirectShow webcam fallback for virtual cameras not visible to Media Foundation;
|
||||
- Media Foundation encoding/muxing;
|
||||
- stream timestamp normalization.
|
||||
|
||||
## Helper Contract V2
|
||||
|
||||
The helper receives a single JSON argument:
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": 2,
|
||||
"recordingId": 1234567890,
|
||||
"source": {
|
||||
"type": "display",
|
||||
"sourceId": "screen:0:0",
|
||||
"displayId": 123,
|
||||
"windowHandle": null,
|
||||
"bounds": { "x": 0, "y": 0, "width": 1920, "height": 1080 }
|
||||
},
|
||||
"video": {
|
||||
"fps": 60,
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"bitrate": 18000000
|
||||
},
|
||||
"audio": {
|
||||
"system": { "enabled": true },
|
||||
"microphone": { "enabled": true, "deviceId": "default", "gain": 1.4 }
|
||||
},
|
||||
"webcam": {
|
||||
"enabled": true,
|
||||
"deviceId": "default",
|
||||
"deviceName": "Camera (NVIDIA Broadcast)",
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"fps": 30,
|
||||
"bitrate": 18000000
|
||||
},
|
||||
"outputs": {
|
||||
"screenPath": "C:\\Users\\me\\recording-123.mp4",
|
||||
"manifestPath": "C:\\Users\\me\\recording-123.session.json"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The helper emits newline-delimited JSON events to stdout:
|
||||
|
||||
```json
|
||||
{ "event": "ready", "schemaVersion": 2 }
|
||||
{ "event": "recording-started", "timestampMs": 1234567890 }
|
||||
{ "event": "warning", "code": "audio-device-unavailable", "message": "..." }
|
||||
{ "event": "recording-stopped", "screenPath": "..." }
|
||||
{ "event": "error", "code": "unsupported-window-source", "message": "..." }
|
||||
```
|
||||
|
||||
During migration, Electron also accepts the current textual helper messages so existing display-only smoke tests keep working.
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### 1. Native Session Boundary
|
||||
|
||||
- Add a structured Windows native recording request type.
|
||||
- Pass source kind, audio flags, microphone device, webcam flags, and output paths into the helper.
|
||||
- On Windows, do not silently fall back to Electron capture. If the helper is unavailable or a native feature is missing, show a clear error.
|
||||
- Keep Electron fallback only for non-Windows and optional developer diagnostics.
|
||||
|
||||
Acceptance:
|
||||
|
||||
- Display-only recording still works.
|
||||
- Enabling an unsupported native feature returns an explicit native error instead of recording through Electron.
|
||||
|
||||
### 2. WASAPI System Audio
|
||||
|
||||
Status: initial implementation landed. The helper captures the default render endpoint with WASAPI loopback, passes the runtime mix format into `MFEncoder`, and muxes AAC audio into the primary MP4. Long-run drift correction and explicit silence insertion remain follow-up hardening work.
|
||||
|
||||
- Add `WasapiLoopbackCapture`.
|
||||
- Capture the default render endpoint in shared loopback mode.
|
||||
- Keep `WasapiLoopbackCapture` responsible only for device activation, packet capture, and packet timestamps.
|
||||
- Keep `MFEncoder` responsible for all Media Foundation stream definitions and muxing.
|
||||
- Feed the endpoint mix format into `MFEncoder` as the single source of truth for audio stream shape: sample rate, channel count, bits per sample, block alignment, average bytes/sec, and subtype (`PCM` or `Float`).
|
||||
- Encode the primary screen MP4 with H.264 video and AAC audio through one `IMFSinkWriter`.
|
||||
- Timestamp audio from the captured frame count in 100ns units. The first implementation uses the WASAPI packet timeline; later drift correction will add explicit silence or resampling if long recordings show measurable clock skew.
|
||||
- Treat microphone mixing as a later phase. System loopback must land first without introducing renderer-side audio code.
|
||||
|
||||
Acceptance:
|
||||
|
||||
- Screen MP4 has an AAC audio track when system audio is enabled.
|
||||
- A 5-minute recording has audio/video duration drift below one frame.
|
||||
|
||||
SSOT rules for this phase:
|
||||
|
||||
- `src/lib/nativeWindowsRecording.ts` is the renderer/main TypeScript request contract.
|
||||
- `docs/engineering/windows-native-recorder-roadmap.md` is the feature-level contract and phase checklist.
|
||||
- `WgcSession::captureWidth()/captureHeight()` is the encoded screen frame size until a dedicated native scaling stage exists.
|
||||
- `WasapiLoopbackCapture::inputFormat()` is the runtime audio format source used by `MFEncoder`.
|
||||
- The renderer passes both the browser webcam `deviceId` and selected display label as `deviceName`; `electron/native/wgc-capture/src/webcam_capture.*` is the only place that maps those values to Media Foundation devices.
|
||||
- Electron resolves the selected label to a DirectShow filter CLSID once and passes it as `webcamDirectShowClsid`; the helper must not independently guess among DirectShow filters.
|
||||
- No duplicated hard-coded audio format assumptions in `main.cpp`.
|
||||
|
||||
### 3. WASAPI Microphone
|
||||
|
||||
Status: initial implementation in progress. The helper can open the default WASAPI capture endpoint, apply the OpenScreen microphone gain, encode mic-only audio, and mix system loopback plus microphone through a single queued `AudioMixer` timeline when both endpoints expose the same runtime format. Audio endpoints are warmed before WGC starts, the mixer drops pre-roll and begins its paced timeline on the first encoded video frame, then cuts queued tail audio on stop so the MP4 does not drift past the video. Browser `deviceId` to MMDevice id mapping, resampling between mismatched endpoint formats, and drift correction remain follow-up hardening work.
|
||||
|
||||
- Add microphone device enumeration and stable device-id mapping.
|
||||
- Capture selected/default microphone through WASAPI.
|
||||
- Apply OpenScreen's current mic gain policy.
|
||||
- Mix microphone and system audio before AAC encoding.
|
||||
|
||||
Acceptance:
|
||||
|
||||
- Mic-only, system-only, and mixed audio recordings produce a valid AAC track.
|
||||
- Device unplug/permission failure produces an explicit error or warning.
|
||||
|
||||
### 4. Webcam Capture
|
||||
|
||||
- Add Media Foundation webcam source reader.
|
||||
- Select requested dimensions/fps or the nearest format accepted by Media Foundation.
|
||||
- Convert webcam samples to BGRA and compose them into the primary helper MP4 as an initial bottom-right picture-in-picture overlay.
|
||||
- Ignore black webcam warmup frames and keep the overlay hidden until the first visible frame is available, so virtual cameras do not flash a black picture-in-picture rectangle at recording start.
|
||||
- Keep the helper process as the SSOT for screen/window, WASAPI system audio, microphone, webcam, and mux timing.
|
||||
- Match the requested webcam through Media Foundation friendly names first, then browser device ids/symbolic links, so UI selection remains stable across Chromium and Windows native device namespaces.
|
||||
- Use the Electron-resolved DirectShow CLSID when the selected virtual camera, for example NVIDIA Broadcast, is registered for DirectShow but absent from Media Foundation enumeration.
|
||||
- Later: promote the same webcam capture source to a separate editable native `webcamVideoPath` if product requirements need post-recording layout edits.
|
||||
|
||||
Acceptance:
|
||||
|
||||
- Native display/window recordings can include webcam without returning to Electron capture.
|
||||
- `npm run test:wgc-webcam:win` validates the helper path when a webcam is available and skips explicitly when no webcam device exists.
|
||||
- Combined webcam + system audio + microphone produces one MP4 with H.264 video and AAC audio.
|
||||
|
||||
### 5. Native Window Capture
|
||||
|
||||
Status: initial implementation in progress. Electron parses the `window:<HWND>:...` desktop source id through the shared native Windows recording contract and passes `windowHandle` to the helper. The helper resolves the `HWND`, validates it with `IsWindow`, and creates the WGC item with `CreateForWindow(HWND)`. Resize/minimize/move hardening and protected-window diagnostics remain follow-up work.
|
||||
|
||||
- Resolve Electron `window:*` selections to an `HWND`.
|
||||
- Use WGC `CreateForWindow(HWND)`.
|
||||
- Handle window close, minimize, resize, DPI scaling, and monitor moves.
|
||||
- Return clear errors for unsupported protected windows.
|
||||
|
||||
Acceptance:
|
||||
|
||||
- Capturing a normal app window works with cursor/audio/mic/webcam.
|
||||
- Window resize and movement do not corrupt the recording.
|
||||
|
||||
### 6. Runtime Controls
|
||||
|
||||
- Add pause/resume commands to the helper.
|
||||
- Add cancel command that removes partial screen/webcam outputs.
|
||||
- Keep restart as stop-discard-start from Electron until the helper supports a native restart event.
|
||||
|
||||
Acceptance:
|
||||
|
||||
- Pause/resume keeps preview duration coherent.
|
||||
- Cancel leaves no stale media/session/cursor files.
|
||||
|
||||
### 7. Test Pipeline
|
||||
|
||||
- `npm run test:wgc-helper:win`: display-only helper smoke test.
|
||||
- `npm run test:wgc-audio:win`: validates AAC track presence and duration.
|
||||
- `npm run test:wgc-window:win`: captures a fixture window by HWND.
|
||||
- `npm run test:wgc-webcam:win`: validates webcam output when a webcam is available, otherwise skips explicitly.
|
||||
- Packaging check: confirms the helper is in `app.asar.unpacked`.
|
||||
- Export check: exported MP4s generated from native recordings keep an AAC audio track when the source has audio.
|
||||
- `npm run test:wgc-mic:win`: validates default-microphone capture writes an AAC track when an input endpoint is available.
|
||||
- `npm run test:wgc-mixed-audio:win`: validates system loopback plus microphone writes one mixed AAC track when endpoint formats are compatible.
|
||||
|
||||
## Backlog
|
||||
|
||||
### Native Cursor Click Bounce Is Not Visibly Applied
|
||||
|
||||
Status: open. Do not treat Windows native cursor `Click Bounce` as shipped.
|
||||
|
||||
Problem:
|
||||
|
||||
- The cursor settings UI exposes `Size`, `Smoothing`, `Motion Blur`, and `Click Bounce`.
|
||||
- On Windows native cursor recordings, `Size`, `Smoothing`, and `Motion Blur` are visibly applied in preview/export.
|
||||
- `Click Bounce` still has no visible effect in manual packaged-app testing, even after adding click-related sample metadata.
|
||||
|
||||
What has already been tried:
|
||||
|
||||
- Added `interactionType: "click" | "mouseup" | "move"` to native cursor samples.
|
||||
- Added polling-based left-button state through `GetAsyncKeyState`.
|
||||
- Added the `GetAsyncKeyState` low-bit path to catch quick clicks between samples.
|
||||
- Added a PowerShell/C# `WH_MOUSE_LL` mouse hook experiment and launched the sampler through a temporary `.ps1` file to avoid Windows command-line length limits.
|
||||
- Updated `npm run test:cursor-native:win` so the diagnostic can observe a synthetic short click and emit `clickSampleCount`.
|
||||
|
||||
Current diagnosis:
|
||||
|
||||
- The diagnostic can observe synthetic click events, but this has not translated into a visible `Click Bounce` effect in the real packaged app.
|
||||
- The test currently proves that some click metadata can be recorded, not that the full OpenScreen record -> preview -> export path displays a bounce at the expected time.
|
||||
- The current native implementation may be animating from metadata that is not present in the real recording session, may be using the wrong timestamp origin, or may be applying a scale change too subtle to notice on the DOM/native cursor path.
|
||||
|
||||
Next investigation when resumed:
|
||||
|
||||
- Inspect the actual `.cursor.json`/session sidecar generated by a packaged-app manual recording and confirm whether real clicks produce `interactionType: "click"` at the right `timeMs`.
|
||||
- Add a targeted end-to-end fixture that records a known click, loads the generated project, and asserts the preview/export cursor scale changes across adjacent frames.
|
||||
- Compare the native DOM cursor path against the older `PixiCursorOverlay` click visual state and decide whether native cursor bounce should be a scale-only animation, an additional click ring, or a short explicit keyframe animation independent of sample cadence.
|
||||
- If event capture remains unreliable in the PowerShell sampler, move click events into a small native cursor helper instead of PowerShell/C# script injection.
|
||||
|
||||
## Ship Criteria
|
||||
|
||||
- Windows display capture works with cursor, system audio, microphone, and webcam.
|
||||
- Windows window capture works with cursor, system audio, microphone, and webcam.
|
||||
- Preview and export show no cursor position drift.
|
||||
- Preview and export show no measurable audio/video/webcam drift.
|
||||
- Windows production builds do not depend on Electron capture fallback.
|
||||
@@ -0,0 +1,183 @@
|
||||
# macOS native cursor test pipeline
|
||||
|
||||
This document covers manual and diagnostic testing for macOS native cursor capture — the path that records real system cursor bitmaps via `NSCursor.currentSystem` and surfaces them through the OpenScreen editor and export pipeline.
|
||||
|
||||
## How the macOS cursor helper works
|
||||
|
||||
The helper binary (`openscreen-macos-cursor-helper`) runs as a child process of Electron during recording. It:
|
||||
|
||||
- polls `NSCursor.currentSystem` at the configured sample interval
|
||||
- converts each cursor image to PNG and computes a SHA-256 content hash as a stable asset id
|
||||
- emits the full base64 bitmap payload **once** per unique cursor shape per session; subsequent samples carry only the `assetId` so stdout stays small
|
||||
- tracks left-button down/up events via `CGEventTap` and tags each sample with `interactionType`
|
||||
- uses the Accessibility API to detect `text` and `pointer` affordances (link/button/input roles) when Accessibility is granted; these shapes use the bundled high-quality SVG replacements instead of the raw bitmap
|
||||
|
||||
Each sample line is newline-delimited JSON:
|
||||
|
||||
```json
|
||||
{ "type": "ready", "timestampMs": 1234567890, "accessibilityTrusted": true, "mouseTapReady": true }
|
||||
{ "type": "sample", "timestampMs": 1234567891, "assetId": "a7472...", "asset": { "id": "a7472...", "imageDataUrl": "data:image/png;base64,...", "width": 64, "height": 64, "hotspotX": 16, "hotspotY": 16, "scaleFactor": 2.0 }, "cursorType": null, "leftButtonDown": false, "leftButtonPressed": false, "leftButtonReleased": false }
|
||||
{ "type": "sample", "timestampMs": 1234567924, "assetId": "a7472...", "cursorType": null, "leftButtonDown": false, "leftButtonPressed": false, "leftButtonReleased": false }
|
||||
```
|
||||
|
||||
`asset` is present only the first time a given `assetId` appears. The TypeScript session (`MacNativeCursorRecordingSession`) collects unique assets into a map and sets `provider: "native"` in the final `CursorRecordingData` when at least one bitmap was captured.
|
||||
|
||||
## Build the helper
|
||||
|
||||
```bash
|
||||
npm run build:native:mac
|
||||
```
|
||||
|
||||
This builds both Swift helpers (`openscreen-screencapturekit-helper` and `openscreen-macos-cursor-helper`) and copies them to:
|
||||
|
||||
- `electron/native/screencapturekit/build/` — used by the local dev server
|
||||
- `electron/native/bin/darwin-arm64/` or `darwin-x64/` — used by packaged builds
|
||||
|
||||
Requires Xcode (not just Command Line Tools). If you see a build error about missing SDK metadata, run:
|
||||
|
||||
```bash
|
||||
sudo xcode-select --switch /Applications/Xcode.app/Contents/Developer
|
||||
sudo xcodebuild -license accept
|
||||
```
|
||||
|
||||
## Smoke-test the helper directly
|
||||
|
||||
You can run the cursor helper standalone to inspect its raw output before involving the full app:
|
||||
|
||||
```bash
|
||||
BIN=electron/native/screencapturekit/build/openscreen-macos-cursor-helper
|
||||
("$BIN" '{"sampleIntervalMs":100}' & PID=$!; sleep 2; kill $PID) | head -20
|
||||
```
|
||||
|
||||
Expected first line:
|
||||
|
||||
```json
|
||||
{"type":"ready","mouseTapReady":true,"accessibilityTrusted":false,"timestampMs":...}
|
||||
```
|
||||
|
||||
`accessibilityTrusted: false` is normal in dev/unsigned builds. It means text/pointer affordance detection is disabled; native bitmap capture still works.
|
||||
|
||||
Expected sample lines:
|
||||
|
||||
```json
|
||||
{"type":"sample","assetId":"a7472...","asset":{"id":"a7472...","imageDataUrl":"data:image/png;base64,...","width":64,"height":64,"hotspotX":26,"hotspotY":16,"scaleFactor":2.0},...}
|
||||
{"type":"sample","assetId":"a7472...",...}
|
||||
```
|
||||
|
||||
Move the cursor over a text input while the helper is running and check that a new `assetId` appears with a different bitmap (if Accessibility is granted — see below).
|
||||
|
||||
## Point the app at a custom helper binary
|
||||
|
||||
```bash
|
||||
export OPENSCREEN_MAC_CURSOR_HELPER_EXE=/path/to/openscreen-macos-cursor-helper
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## macOS permissions
|
||||
|
||||
Two separate permissions are needed:
|
||||
|
||||
| Permission | What it enables | Where to grant |
|
||||
|---|---|---|
|
||||
| Screen Recording | ScreenCaptureKit video capture | System Settings → Privacy & Security → Screen & System Audio Recording → Electron ✅ |
|
||||
| Accessibility | `text` / `pointer` cursor type detection (affordance hints) | System Settings → Privacy & Security → Accessibility → Electron ✅ |
|
||||
|
||||
**Screen Recording** is required to record. Without it the recording never starts.
|
||||
|
||||
**Accessibility** is optional. Without it, `cursorType` will always be `null` and all cursors render from their captured bitmaps (no SVG substitution). This is the expected fallback and does not degrade cursor quality for non-text/pointer shapes.
|
||||
|
||||
After granting either permission in System Settings, **fully quit and relaunch** the dev server — `getMediaAccessStatus` caches the result per-process.
|
||||
|
||||
## Manual test checklist
|
||||
|
||||
### P0 — core bitmap capture
|
||||
|
||||
- [ ] Record a short clip. Open the editor. Confirm the default arrow cursor is the real system arrow (not the bundled SVG approximation).
|
||||
- [ ] Record while hovering over a web browser. Confirm custom-CSS cursors (e.g. `cursor: grab`, `cursor: crosshair`) appear as their actual shapes.
|
||||
- [ ] Export to MP4. Confirm the cursor renders correctly in the exported video.
|
||||
- [ ] Export to GIF. Same check.
|
||||
|
||||
### P1 — affordance substitution (requires Accessibility)
|
||||
|
||||
- [ ] Grant Accessibility permission and restart the app.
|
||||
- [ ] Record hovering over a text input field. Confirm the text I-beam uses the bundled SVG version (prettier than the system bitmap).
|
||||
- [ ] Record hovering over a link/button. Confirm the pointer hand uses the bundled SVG.
|
||||
|
||||
### P1 — hotspot alignment (Retina)
|
||||
|
||||
- [ ] On a Retina display, record a precise click on a small button. In the editor, confirm the cursor tip aligns with the actual click point. The helper reports `scaleFactor: 2.0`; the renderer divides pixel dimensions and hotspot by this value to recover point sizes.
|
||||
|
||||
### P1 — click detection
|
||||
|
||||
- [ ] Record several left-clicks. In the editor, confirm the click-bounce animation fires on each click.
|
||||
- [ ] Confirm `interactionType: "click"` and `"mouseup"` events are present in the recording session sidecar (`cursorRecordingData` inside `<videoPath>.cursor.json`).
|
||||
|
||||
### P2 — graceful degradation
|
||||
|
||||
- [ ] Remove **both** build-output copies of the helper binary and start a recording. The session should succeed with `provider: "none"` (position-only telemetry, default arrow rendered). Restore both binaries afterward.
|
||||
```bash
|
||||
ARCH=$([ "$(uname -m)" = "arm64" ] && echo darwin-arm64 || echo darwin-x64)
|
||||
mv electron/native/screencapturekit/build/openscreen-macos-cursor-helper /tmp/cursor-helper-build
|
||||
mv electron/native/bin/$ARCH/openscreen-macos-cursor-helper /tmp/cursor-helper-bin
|
||||
# ... start recording, then restore:
|
||||
mv /tmp/cursor-helper-bin electron/native/bin/$ARCH/openscreen-macos-cursor-helper
|
||||
mv /tmp/cursor-helper-build electron/native/screencapturekit/build/openscreen-macos-cursor-helper
|
||||
```
|
||||
- [ ] Revoke Accessibility. Confirm recording still works and cursors render from bitmaps (no SVG substitution).
|
||||
|
||||
### P2 — multi-display
|
||||
|
||||
- [ ] Move the cursor to a secondary display during recording. Confirm the cursor clips to the canvas edge rather than snapping invisible on fast swipes. Confirm it hides after ≈100 ms of sustained out-of-bounds movement.
|
||||
|
||||
### P2 — long recording memory
|
||||
|
||||
- [ ] Record for 3–5 minutes while switching between many apps (browser, terminal, editor). The helper should not grow in memory because each iteration drains Cocoa objects via `autoreleasepool`. Check `Activity Monitor` → `openscreen-macos-cursor-helper` RSS stays flat after the first few seconds.
|
||||
|
||||
## What a healthy recording looks like
|
||||
|
||||
Inspect the cursor sidecar file written alongside the recorded video (`<videoPath>.cursor.json`). For a recording saved to `/tmp/rec.mp4`, the sidecar is `/tmp/rec.mp4.cursor.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 2,
|
||||
"provider": "native",
|
||||
"assets": [
|
||||
{ "id": "a7472...", "platform": "darwin", "imageDataUrl": "data:image/png;base64,...", "width": 64, "height": 64, "hotspotX": 26.0, "hotspotY": 16.0, "scaleFactor": 2.0 }
|
||||
],
|
||||
"samples": [
|
||||
{ "timeMs": 0, "cx": 0.42, "cy": 0.38, "visible": true, "assetId": "a7472...", "interactionType": "move" },
|
||||
...
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`provider: "native"` and a non-empty `assets` array confirm bitmap capture is active. If you see `provider: "none"` and `assets: []`, the helper was not found or exited before `ready`.
|
||||
|
||||
## Native macOS capture backend
|
||||
|
||||
The app routes macOS recordings through the ScreenCaptureKit helper (`openscreen-screencapturekit-helper`) when it is available, so the real system cursor is excluded from the video frame. The cursor position and bitmap are captured separately by the cursor helper and composited in the editor and export pipeline.
|
||||
|
||||
Current native availability rules:
|
||||
|
||||
- macOS 13 (Ventura) or newer
|
||||
- `openscreen-screencapturekit-helper` binary is present
|
||||
- Screen Recording permission is granted
|
||||
|
||||
Build both helpers locally:
|
||||
|
||||
```bash
|
||||
npm run build:native:mac
|
||||
```
|
||||
|
||||
For local diagnostics with a custom helper binary, use the environment override:
|
||||
|
||||
```bash
|
||||
export OPENSCREEN_MAC_CURSOR_HELPER_EXE=/path/to/openscreen-macos-cursor-helper
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## Known limitations
|
||||
|
||||
- **Intel (x86\_64) Macs**: the distributed helper is built for `darwin-arm64`. On Intel Macs, you need to build from source with `npm run build:native:mac` on the target machine.
|
||||
- **Accessibility permission in unsigned/dev builds**: `getMediaAccessStatus("accessibility")` may not reflect the toggle state for unsigned Electron in dev mode. The helper will always probe and report `accessibilityTrusted` in its `ready` event — use that as the authoritative signal.
|
||||
- **App-defined custom cursors (CGS layer)**: `NSCursor.currentSystem` captures the active AppKit cursor. Cursors set at the CoreGraphics/CGS layer by some games or GPU-accelerated apps may not be visible here. This is a known macOS API limitation.
|
||||
@@ -0,0 +1,130 @@
|
||||
# Windows native cursor test pipeline
|
||||
|
||||
This branch includes two Windows-focused diagnostics for fast iteration on native cursor capture and rendering. They are intentionally local developer tools: they create short videos and JSON reports so cursor changes can be inspected without doing a full manual record/edit/export cycle.
|
||||
|
||||
## Native sampler diagnostic
|
||||
|
||||
```powershell
|
||||
npm run test:cursor-native:win
|
||||
```
|
||||
|
||||
This script does not launch OpenScreen. It:
|
||||
|
||||
- starts a Windows `GetCursorInfo` sampler
|
||||
- moves the real OS pointer with `SetCursorPos`
|
||||
- captures native cursor handles, hotspots, assets, and standard `IDC_*` cursor types
|
||||
- writes normalized `CursorRecordingData`
|
||||
- generates an abstract preview video
|
||||
- generates a real-screen preview video using screenshots of the current desktop
|
||||
|
||||
The output directory is printed in the command result, for example:
|
||||
|
||||
```text
|
||||
C:\Users\<user>\AppData\Local\Temp\openscreen-cursor-native-...
|
||||
```
|
||||
|
||||
Useful files:
|
||||
|
||||
- `report.json`: sample counts, asset counts, cursor handles, and generated artifact paths
|
||||
- `cursor-recording-data.json`: sidecar-compatible cursor data
|
||||
- `preview.webm`: abstract path/asset/hotspot preview
|
||||
- `real-capture-preview.webm`: real desktop screenshot background with reconstructed cursor overlay
|
||||
- `assets/*.png`: raw cursor bitmaps captured from Windows
|
||||
|
||||
Environment overrides:
|
||||
|
||||
```powershell
|
||||
$env:CURSOR_TEST_DURATION_MS = "3000"
|
||||
$env:CURSOR_TEST_SAMPLE_INTERVAL_MS = "16"
|
||||
$env:CURSOR_TEST_SCREEN_FRAME_INTERVAL_MS = "80"
|
||||
$env:CURSOR_TEST_OUTPUT_DIR = "C:\temp\openscreen-cursor-test"
|
||||
npm run test:cursor-native:win
|
||||
```
|
||||
|
||||
## OpenScreen preview capture
|
||||
|
||||
```powershell
|
||||
npm run capture:openscreen-preview
|
||||
```
|
||||
|
||||
This script launches the real Electron app, injects a fixture video plus cursor sidecar data, opens the editor, captures frames from the actual OpenScreen preview UI, and encodes them into a WebM.
|
||||
|
||||
By default it uses the latest `cursor-recording-data.json` generated by `npm run test:cursor-native:win`. To force a specific sidecar:
|
||||
|
||||
```powershell
|
||||
$env:CURSOR_RECORDING_DATA_PATH = "C:\path\to\cursor-recording-data.json"
|
||||
npm run capture:openscreen-preview
|
||||
```
|
||||
|
||||
Useful environment overrides:
|
||||
|
||||
```powershell
|
||||
$env:OPENSCREEN_PREVIEW_SKIP_BUILD = "true"
|
||||
$env:OPENSCREEN_PREVIEW_FRAME_COUNT = "120"
|
||||
$env:OPENSCREEN_PREVIEW_FPS = "30"
|
||||
$env:OPENSCREEN_PREVIEW_OUTPUT_DIR = "C:\temp\openscreen-preview"
|
||||
npm run capture:openscreen-preview
|
||||
```
|
||||
|
||||
Useful files:
|
||||
|
||||
- `openscreen-preview.webm`: video of the real OpenScreen editor preview
|
||||
- `frames/*.png`: captured preview frames
|
||||
- `report.json`: fixture paths, source sidecar, frame count, and output path
|
||||
|
||||
## What these tests validate
|
||||
|
||||
Together, the scripts make it quick to inspect:
|
||||
|
||||
- whether Windows cursor samples are visible and continuous
|
||||
- whether native hotspots stay anchored when scaling to `3x`
|
||||
- whether standard Windows cursors are recognized via `IDC_*`
|
||||
- whether high-quality SVG cursor replacements follow the native hotspot
|
||||
- whether the real OpenScreen preview renders the same cursor behavior as the diagnostic pipeline
|
||||
|
||||
They are not a full substitute for an end-to-end manual recording pass. Before shipping cursor changes, also test a real capture session and export from the packaged app.
|
||||
|
||||
## Known Gap
|
||||
|
||||
Windows native cursor `Click Bounce` is currently backlogged. `Size`, `Smoothing`, and `Motion Blur` can be validated through preview/export, but `Click Bounce` has not shown a visible effect in packaged-app manual testing. The current diagnostic can observe synthetic click metadata, but that is not enough to validate the real OpenScreen record -> preview -> export path.
|
||||
|
||||
Track the open item in `docs/engineering/windows-native-recorder-roadmap.md` under `Native Cursor Click Bounce Is Not Visibly Applied`.
|
||||
|
||||
## Native Windows capture backend
|
||||
|
||||
The app now routes Windows recordings through an external WGC helper instead of Electron `getDisplayMedia`. This is meant to remove the coordinate and clock split that made the reconstructed cursor drift in the preview/export path.
|
||||
|
||||
Current native availability rules:
|
||||
|
||||
- Windows 10 build 19041 or newer
|
||||
- a helper executable is available
|
||||
|
||||
The helper currently implements display/window video capture, system audio loopback, default microphone capture, Media Foundation webcam capture, and DirectShow fallback for selected virtual cameras such as NVIDIA Broadcast. Webcam frames are composed into the primary MP4 as a bottom-right picture-in-picture overlay, and black webcam warmup frames are ignored until the first visible frame is available.
|
||||
|
||||
Build OpenScreen's helper locally:
|
||||
|
||||
```powershell
|
||||
npm run build:native:win
|
||||
```
|
||||
|
||||
Smoke-test the helper directly:
|
||||
|
||||
```powershell
|
||||
npm run test:wgc-helper:win
|
||||
npm run test:wgc-helper:win -- --capture-cursor
|
||||
npm run test:wgc-window:win
|
||||
npm run test:wgc-audio:win
|
||||
npm run test:wgc-mic:win
|
||||
npm run test:wgc-mixed-audio:win
|
||||
npm run test:wgc-webcam:win
|
||||
```
|
||||
|
||||
For local diagnostics with another compatible helper, point OpenScreen at that executable:
|
||||
|
||||
```powershell
|
||||
$env:OPENSCREEN_WGC_CAPTURE_EXE = "C:\path\to\wgc-capture.exe"
|
||||
npm run build-vite
|
||||
npm run dev
|
||||
```
|
||||
|
||||
The helper receives one JSON config argument, emits JSON lifecycle events, prints the legacy `Recording started` marker, accepts `stop` on stdin, and prints `Recording stopped. Output path: <path>`. See `electron/native/README.md` for the exact contract and build output paths.
|
||||
@@ -5,12 +5,16 @@
|
||||
"asar": true,
|
||||
// .node binaries can't be dlopen'd from inside an asar — must live unpacked.
|
||||
"asarUnpack": [
|
||||
"node_modules/uiohook-napi/**/*",
|
||||
"**/*.node"
|
||||
],
|
||||
"productName": "Openscreen",
|
||||
// Fetch the auto-caption model + ORT wasm into caption-assets/ before packaging (idempotent).
|
||||
"beforePack": "scripts/before-pack.cjs",
|
||||
"npmRebuild": true,
|
||||
"buildDependenciesFromSource": true,
|
||||
// sharp ships ABI-stable (napi) prebuilt binaries with bundled libvips. Building it from source
|
||||
// needs a system libvips we don't provide and breaks on CI/local ("vips-cpp.42 not found"), so we
|
||||
// let electron-builder use the prebuilt instead of recompiling.
|
||||
"buildDependenciesFromSource": false,
|
||||
"compression": "normal",
|
||||
"directories": {
|
||||
"output": "release/${version}"
|
||||
@@ -25,12 +29,20 @@
|
||||
"!CONTRIBUTING.md",
|
||||
"!LICENSE"
|
||||
],
|
||||
// Asset layout contract: "wallpapers/" under resourcesPath must align with
|
||||
// assetBaseDir in electron/preload.ts (packaged branch).
|
||||
// Asset layout contract: "wallpapers/" and "cursors/" under resourcesPath must
|
||||
// align with assetBaseDir in electron/preload.ts (packaged branch).
|
||||
"extraResources": [
|
||||
{
|
||||
"from": "public/wallpapers",
|
||||
"to": "wallpapers"
|
||||
},
|
||||
{
|
||||
"from": "public/cursors",
|
||||
"to": "cursors"
|
||||
},
|
||||
{
|
||||
"from": "caption-assets",
|
||||
"to": "caption-assets"
|
||||
}
|
||||
],
|
||||
|
||||
@@ -47,6 +59,13 @@
|
||||
],
|
||||
"icon": "icons/icons/mac/icon.icns",
|
||||
"artifactName": "${productName}-Mac-${arch}-${version}-Installer.${ext}",
|
||||
"extraResources": [
|
||||
{
|
||||
"from": "electron/native/bin",
|
||||
"to": "electron/native/bin",
|
||||
"filter": ["darwin-*/*"]
|
||||
}
|
||||
],
|
||||
"extendInfo": {
|
||||
"NSAudioCaptureUsageDescription": "OpenScreen needs audio capture permission to record system audio.",
|
||||
"NSMicrophoneUsageDescription": "OpenScreen needs microphone access to record voice audio.",
|
||||
@@ -69,7 +88,14 @@
|
||||
"target": [
|
||||
"nsis"
|
||||
],
|
||||
"icon": "icons/icons/win/icon.ico"
|
||||
"icon": "icons/icons/win/icon.ico",
|
||||
"extraResources": [
|
||||
{
|
||||
"from": "electron/native/bin",
|
||||
"to": "electron/native/bin",
|
||||
"filter": ["win32-*/*"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"nsis": {
|
||||
"oneClick": false,
|
||||
|
||||
@@ -24,11 +24,23 @@ declare namespace NodeJS {
|
||||
// Used in Renderer process, expose in `preload.ts`
|
||||
interface Window {
|
||||
electronAPI: {
|
||||
invokeNativeBridge: <TData = unknown>(
|
||||
request: import("../src/native/contracts").NativeBridgeRequest,
|
||||
) => Promise<import("../src/native/contracts").NativeBridgeResponse<TData>>;
|
||||
getSources: (opts: Electron.SourcesOptions) => Promise<ProcessedDesktopSource[]>;
|
||||
switchToEditor: () => Promise<void>;
|
||||
switchToHud: () => Promise<void>;
|
||||
startNewRecording: () => Promise<{ success: boolean; error?: string }>;
|
||||
openSourceSelector: () => Promise<void>;
|
||||
openSourceSelector: () => Promise<{
|
||||
opened: boolean;
|
||||
reason?: string;
|
||||
access?: {
|
||||
success: boolean;
|
||||
granted: boolean;
|
||||
status: string;
|
||||
error?: string;
|
||||
};
|
||||
}>;
|
||||
selectSource: (source: ProcessedDesktopSource) => Promise<ProcessedDesktopSource | null>;
|
||||
getSelectedSource: () => Promise<ProcessedDesktopSource | null>;
|
||||
requestCameraAccess: () => Promise<{
|
||||
@@ -37,9 +49,16 @@ interface Window {
|
||||
status: string;
|
||||
error?: string;
|
||||
}>;
|
||||
requestAccessibilityAccess: () => Promise<{
|
||||
requestScreenAccess: () => Promise<{
|
||||
success: boolean;
|
||||
granted: boolean;
|
||||
status: string;
|
||||
error?: string;
|
||||
}>;
|
||||
requestNativeMacCursorAccess: () => Promise<{
|
||||
success: boolean;
|
||||
granted: boolean;
|
||||
status: string;
|
||||
error?: string;
|
||||
}>;
|
||||
assetBaseUrl: string;
|
||||
@@ -62,13 +81,87 @@ interface Window {
|
||||
message?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
openRecordingStream: (fileName: string) => Promise<{ success: boolean; error?: string }>;
|
||||
appendRecordingChunk: (
|
||||
fileName: string,
|
||||
chunk: ArrayBuffer,
|
||||
) => Promise<{ success: boolean; error?: string }>;
|
||||
closeRecordingStream: (fileName: string) => Promise<{ success: boolean; error?: string }>;
|
||||
getRecordedVideoPath: () => Promise<{
|
||||
success: boolean;
|
||||
path?: string;
|
||||
message?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
setRecordingState: (recording: boolean, recordingId?: number) => Promise<void>;
|
||||
setRecordingState: (
|
||||
recording: boolean,
|
||||
recordingId?: number,
|
||||
cursorCaptureMode?: import("../src/lib/recordingSession").CursorCaptureMode,
|
||||
) => Promise<void>;
|
||||
isNativeWindowsCaptureAvailable: () => Promise<{
|
||||
success: boolean;
|
||||
available: boolean;
|
||||
helperPath?: string;
|
||||
reason?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
isNativeMacCaptureAvailable: () => Promise<{
|
||||
success: boolean;
|
||||
available: boolean;
|
||||
helperPath?: string;
|
||||
reason?: "unsupported-platform" | "missing-helper" | string;
|
||||
error?: string;
|
||||
}>;
|
||||
startNativeWindowsRecording: (
|
||||
request: import("../src/lib/nativeWindowsRecording").NativeWindowsRecordingRequest,
|
||||
) => Promise<import("../src/lib/nativeWindowsRecording").NativeWindowsRecordingStartResult>;
|
||||
stopNativeWindowsRecording: (discard?: boolean) => Promise<{
|
||||
success: boolean;
|
||||
path?: string;
|
||||
session?: import("../src/lib/recordingSession").RecordingSession;
|
||||
message?: string;
|
||||
discarded?: boolean;
|
||||
error?: string;
|
||||
}>;
|
||||
pauseNativeWindowsRecording: () => Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}>;
|
||||
resumeNativeWindowsRecording: () => Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}>;
|
||||
startNativeMacRecording: (
|
||||
request: import("../src/lib/nativeMacRecording").NativeMacRecordingRequest,
|
||||
) => Promise<import("../src/lib/nativeMacRecording").NativeMacRecordingStartResult>;
|
||||
pauseNativeMacRecording: () => Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}>;
|
||||
resumeNativeMacRecording: () => Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}>;
|
||||
stopNativeMacRecording: (discard?: boolean) => Promise<{
|
||||
success: boolean;
|
||||
path?: string;
|
||||
session?: import("../src/lib/recordingSession").RecordingSession;
|
||||
message?: string;
|
||||
discarded?: boolean;
|
||||
error?: string;
|
||||
}>;
|
||||
attachNativeMacWebcamRecording: (payload: {
|
||||
screenVideoPath: string;
|
||||
recordingId: number;
|
||||
webcam: import("../src/lib/recordingSession").RecordedVideoAssetInput;
|
||||
cursorCaptureMode?: import("../src/lib/recordingSession").CursorCaptureMode;
|
||||
}) => Promise<{
|
||||
success: boolean;
|
||||
path?: string;
|
||||
session?: import("../src/lib/recordingSession").RecordingSession;
|
||||
message?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
discardCursorTelemetry: (recordingId: number) => Promise<void>;
|
||||
getCursorTelemetry: (videoPath?: string) => Promise<{
|
||||
success: boolean;
|
||||
@@ -118,6 +211,12 @@ interface Window {
|
||||
message?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
preparePreviewAudioTrack: (filePath: string) => Promise<{
|
||||
success: boolean;
|
||||
path?: string | null;
|
||||
message?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
clearCurrentVideoPath: () => Promise<{ success: boolean }>;
|
||||
saveProjectFile: (
|
||||
projectData: unknown,
|
||||
@@ -130,7 +229,7 @@ interface Window {
|
||||
canceled?: boolean;
|
||||
error?: string;
|
||||
}>;
|
||||
loadProjectFile: () => Promise<{
|
||||
loadProjectFile: (projectFolder?: string) => Promise<{
|
||||
success: boolean;
|
||||
path?: string;
|
||||
project?: unknown;
|
||||
@@ -146,6 +245,17 @@ interface Window {
|
||||
canceled?: boolean;
|
||||
error?: string;
|
||||
}>;
|
||||
getPathForFile: (file: File) => string;
|
||||
loadProjectFileFromPath: (filePath: string) => Promise<{
|
||||
success: boolean;
|
||||
path?: string;
|
||||
project?: unknown;
|
||||
message?: string;
|
||||
canceled?: boolean;
|
||||
error?: string;
|
||||
}>;
|
||||
onMenuNewProject: (callback: () => void) => () => void;
|
||||
onMenuImportVideo: (callback: () => void) => () => void;
|
||||
onMenuLoadProject: (callback: () => void) => () => void;
|
||||
onMenuSaveProject: (callback: () => void) => () => void;
|
||||
onMenuSaveProjectAs: (callback: () => void) => () => void;
|
||||
@@ -155,8 +265,17 @@ interface Window {
|
||||
) => Promise<{ success: boolean; error?: string; message?: string }>;
|
||||
getShortcuts: () => Promise<Record<string, unknown> | null>;
|
||||
saveShortcuts: (shortcuts: unknown) => Promise<{ success: boolean; error?: string }>;
|
||||
updateGlobalShortcut: (binding: {
|
||||
key: string;
|
||||
ctrl?: boolean;
|
||||
shift?: boolean;
|
||||
alt?: boolean;
|
||||
}) => Promise<{ success: boolean }>;
|
||||
hudOverlayHide: () => void;
|
||||
hudOverlayClose: () => void;
|
||||
setHudOverlayIgnoreMouseEvents: (ignore: boolean) => void;
|
||||
moveHudOverlayBy: (deltaX: number, deltaY: number) => void;
|
||||
setHudOverlaySize: (width: number, height: number) => void;
|
||||
showCountdownOverlay: (value: number, runId: number) => Promise<void>;
|
||||
setCountdownOverlayValue: (value: number, runId: number) => Promise<void>;
|
||||
hideCountdownOverlay: (runId: number) => Promise<void>;
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import fs from "node:fs/promises";
|
||||
import { globalShortcut } from "electron";
|
||||
import { type ShortcutBinding } from "../src/lib/shortcuts";
|
||||
import { SHORTCUTS_FILE } from "./ipc/handlers";
|
||||
|
||||
const DEFAULT_OPEN_APP_BINDING: ShortcutBinding = { key: "o", ctrl: true, shift: true };
|
||||
|
||||
// Maps KeyboardEvent.key values to Electron accelerator key names
|
||||
const KEY_TO_ACCELERATOR: Record<string, string> = {
|
||||
" ": "Space",
|
||||
"+": "Plus",
|
||||
"-": "numsub",
|
||||
"*": "nummult",
|
||||
"/": "numdiv",
|
||||
arrowup: "Up",
|
||||
arrowdown: "Down",
|
||||
arrowleft: "Left",
|
||||
arrowright: "Right",
|
||||
escape: "Escape",
|
||||
enter: "Return",
|
||||
backspace: "Backspace",
|
||||
delete: "Delete",
|
||||
tab: "Tab",
|
||||
};
|
||||
|
||||
function bindingToAccelerator(binding: ShortcutBinding): string {
|
||||
const parts: string[] = [];
|
||||
if (binding.ctrl) parts.push("CommandOrControl");
|
||||
if (binding.shift) parts.push("Shift");
|
||||
if (binding.alt) parts.push("Alt");
|
||||
|
||||
const keyLower = binding.key.toLowerCase();
|
||||
const acceleratorKey = KEY_TO_ACCELERATOR[keyLower] ?? binding.key.toUpperCase();
|
||||
parts.push(acceleratorKey);
|
||||
|
||||
return parts.join("+");
|
||||
}
|
||||
|
||||
let currentAccelerator: string | null = null;
|
||||
|
||||
export function registerOpenAppShortcut(binding: ShortcutBinding, onTrigger: () => void): boolean {
|
||||
const accelerator = bindingToAccelerator(binding);
|
||||
|
||||
if (accelerator === currentAccelerator) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Register the new shortcut before unregistering the old, so a failure leaves the old binding intact
|
||||
const success = globalShortcut.register(accelerator, onTrigger);
|
||||
|
||||
if (success) {
|
||||
if (currentAccelerator) {
|
||||
globalShortcut.unregister(currentAccelerator);
|
||||
}
|
||||
currentAccelerator = accelerator;
|
||||
console.log(`Global shortcut registered: ${accelerator}`);
|
||||
} else {
|
||||
console.warn(`Failed to register global shortcut: ${accelerator}`);
|
||||
}
|
||||
|
||||
return success;
|
||||
}
|
||||
|
||||
export async function loadAndRegisterGlobalShortcut(onTrigger: () => void): Promise<void> {
|
||||
try {
|
||||
const data = await fs.readFile(SHORTCUTS_FILE, "utf-8");
|
||||
const shortcuts = JSON.parse(data);
|
||||
const binding = shortcuts.openApp || DEFAULT_OPEN_APP_BINDING;
|
||||
registerOpenAppShortcut(binding, onTrigger);
|
||||
} catch {
|
||||
registerOpenAppShortcut(DEFAULT_OPEN_APP_BINDING, onTrigger);
|
||||
}
|
||||
}
|
||||
|
||||
export function unregisterAllGlobalShortcuts(): void {
|
||||
globalShortcut.unregisterAll();
|
||||
}
|
||||
@@ -9,10 +9,14 @@ import commonEs from "../src/i18n/locales/es/common.json";
|
||||
import dialogsEs from "../src/i18n/locales/es/dialogs.json";
|
||||
import commonFr from "../src/i18n/locales/fr/common.json";
|
||||
import dialogsFr from "../src/i18n/locales/fr/dialogs.json";
|
||||
import commonIt from "../src/i18n/locales/it/common.json";
|
||||
import dialogsIt from "../src/i18n/locales/it/dialogs.json";
|
||||
import commonJa from "../src/i18n/locales/ja-JP/common.json";
|
||||
import dialogsJa from "../src/i18n/locales/ja-JP/dialogs.json";
|
||||
import commonKo from "../src/i18n/locales/ko-KR/common.json";
|
||||
import dialogsKo from "../src/i18n/locales/ko-KR/dialogs.json";
|
||||
import commonRu from "../src/i18n/locales/ru/common.json";
|
||||
import dialogsRu from "../src/i18n/locales/ru/dialogs.json";
|
||||
import commonTr from "../src/i18n/locales/tr/common.json";
|
||||
import dialogsTr from "../src/i18n/locales/tr/dialogs.json";
|
||||
import commonVi from "../src/i18n/locales/vi/common.json";
|
||||
@@ -22,21 +26,35 @@ import dialogsZh from "../src/i18n/locales/zh-CN/dialogs.json";
|
||||
import commonZhTw from "../src/i18n/locales/zh-TW/common.json";
|
||||
import dialogsZhTw from "../src/i18n/locales/zh-TW/dialogs.json";
|
||||
|
||||
type Locale = "en" | "zh-CN" | "zh-TW" | "es" | "fr" | "ja-JP" | "ko-KR" | "tr" | "ar" | "vi";
|
||||
type Locale =
|
||||
| "en"
|
||||
| "ar"
|
||||
| "es"
|
||||
| "fr"
|
||||
| "it"
|
||||
| "ja-JP"
|
||||
| "ko-KR"
|
||||
| "ru"
|
||||
| "tr"
|
||||
| "vi"
|
||||
| "zh-CN"
|
||||
| "zh-TW";
|
||||
type Namespace = "common" | "dialogs";
|
||||
type MessageMap = Record<string, unknown>;
|
||||
|
||||
const messages: Record<Locale, Record<Namespace, MessageMap>> = {
|
||||
en: { common: commonEn, dialogs: dialogsEn },
|
||||
"zh-CN": { common: commonZh, dialogs: dialogsZh },
|
||||
"zh-TW": { common: commonZhTw, dialogs: dialogsZhTw },
|
||||
ar: { common: commonAr, dialogs: dialogsAr },
|
||||
es: { common: commonEs, dialogs: dialogsEs },
|
||||
fr: { common: commonFr, dialogs: dialogsFr },
|
||||
it: { common: commonIt, dialogs: dialogsIt },
|
||||
"ja-JP": { common: commonJa, dialogs: dialogsJa },
|
||||
"ko-KR": { common: commonKo, dialogs: dialogsKo },
|
||||
ru: { common: commonRu, dialogs: dialogsRu },
|
||||
tr: { common: commonTr, dialogs: dialogsTr },
|
||||
ar: { common: commonAr, dialogs: dialogsAr },
|
||||
vi: { common: commonVi, dialogs: dialogsVi },
|
||||
"zh-CN": { common: commonZh, dialogs: dialogsZh },
|
||||
"zh-TW": { common: commonZhTw, dialogs: dialogsZhTw },
|
||||
};
|
||||
|
||||
let currentLocale: Locale = "en";
|
||||
@@ -44,15 +62,17 @@ let currentLocale: Locale = "en";
|
||||
export function setMainLocale(locale: string) {
|
||||
if (
|
||||
locale === "en" ||
|
||||
locale === "zh-CN" ||
|
||||
locale === "zh-TW" ||
|
||||
locale === "ar" ||
|
||||
locale === "es" ||
|
||||
locale === "fr" ||
|
||||
locale === "it" ||
|
||||
locale === "ja-JP" ||
|
||||
locale === "ko-KR" ||
|
||||
locale === "ru" ||
|
||||
locale === "tr" ||
|
||||
locale === "ar" ||
|
||||
locale === "vi"
|
||||
locale === "vi" ||
|
||||
locale === "zh-CN" ||
|
||||
locale === "zh-TW"
|
||||
) {
|
||||
currentLocale = locale;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
import { ipcMain } from "electron";
|
||||
import {
|
||||
NATIVE_BRIDGE_CHANNEL,
|
||||
NATIVE_BRIDGE_VERSION,
|
||||
type NativeBridgeErrorCode,
|
||||
type NativeBridgeRequest,
|
||||
type NativeBridgeResponse,
|
||||
type NativePlatform,
|
||||
type ProjectFileResult,
|
||||
type ProjectPathResult,
|
||||
} from "../../src/native/contracts";
|
||||
import type { CursorTelemetryLoadResult } from "../native-bridge/cursor/adapter";
|
||||
import { TelemetryCursorAdapter } from "../native-bridge/cursor/telemetryCursorAdapter";
|
||||
import { CursorService } from "../native-bridge/services/cursorService";
|
||||
import { ProjectService } from "../native-bridge/services/projectService";
|
||||
import { SystemService } from "../native-bridge/services/systemService";
|
||||
import { NativeBridgeStateStore } from "../native-bridge/store";
|
||||
|
||||
export interface NativeBridgeContext {
|
||||
getPlatform: () => NodeJS.Platform;
|
||||
getCurrentProjectPath: () => string | null;
|
||||
getCurrentVideoPath: () => string | null;
|
||||
saveProjectFile: (
|
||||
projectData: unknown,
|
||||
suggestedName?: string,
|
||||
existingProjectPath?: string,
|
||||
) => Promise<ProjectFileResult>;
|
||||
loadProjectFile: (projectFolder?: string) => Promise<ProjectFileResult>;
|
||||
loadCurrentProjectFile: () => Promise<ProjectFileResult>;
|
||||
loadProjectFileFromPath: (path: string) => Promise<ProjectFileResult>;
|
||||
setCurrentVideoPath: (path: string) => ProjectPathResult | Promise<ProjectPathResult>;
|
||||
getCurrentVideoPathResult: () => ProjectPathResult;
|
||||
clearCurrentVideoPath: () => ProjectPathResult;
|
||||
resolveAssetBasePath: () => string | null;
|
||||
resolveVideoPath: (videoPath?: string | null) => string | null;
|
||||
loadCursorRecordingData: (
|
||||
videoPath: string,
|
||||
) => Promise<import("../../src/native/contracts").CursorRecordingData>;
|
||||
loadCursorTelemetry: (videoPath: string) => Promise<CursorTelemetryLoadResult>;
|
||||
}
|
||||
|
||||
function normalizePlatform(platform: NodeJS.Platform): NativePlatform {
|
||||
if (platform === "darwin" || platform === "win32") {
|
||||
return platform;
|
||||
}
|
||||
|
||||
return "linux";
|
||||
}
|
||||
|
||||
function createMeta(requestId?: string) {
|
||||
return {
|
||||
version: NATIVE_BRIDGE_VERSION,
|
||||
requestId: requestId || `native-${Date.now()}`,
|
||||
timestampMs: Date.now(),
|
||||
} as const;
|
||||
}
|
||||
|
||||
function createSuccessResponse<TData>(requestId: string | undefined, data: TData) {
|
||||
return {
|
||||
ok: true,
|
||||
data,
|
||||
meta: createMeta(requestId),
|
||||
} satisfies NativeBridgeResponse<TData>;
|
||||
}
|
||||
|
||||
function createErrorResponse(
|
||||
requestId: string | undefined,
|
||||
code: NativeBridgeErrorCode,
|
||||
message: string,
|
||||
retryable = false,
|
||||
) {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
code,
|
||||
message,
|
||||
retryable,
|
||||
},
|
||||
meta: createMeta(requestId),
|
||||
} satisfies NativeBridgeResponse;
|
||||
}
|
||||
|
||||
function isBridgeRequest(value: unknown): value is NativeBridgeRequest {
|
||||
if (!value || typeof value !== "object") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const candidate = value as Partial<NativeBridgeRequest>;
|
||||
return typeof candidate.domain === "string" && typeof candidate.action === "string";
|
||||
}
|
||||
|
||||
export function registerNativeBridgeHandlers(context: NativeBridgeContext) {
|
||||
ipcMain.removeHandler(NATIVE_BRIDGE_CHANNEL);
|
||||
|
||||
const platform = normalizePlatform(context.getPlatform());
|
||||
const store = new NativeBridgeStateStore(platform);
|
||||
const projectService = new ProjectService({
|
||||
store,
|
||||
getCurrentProjectPath: context.getCurrentProjectPath,
|
||||
getCurrentVideoPath: context.getCurrentVideoPath,
|
||||
saveProjectFile: context.saveProjectFile,
|
||||
loadProjectFile: context.loadProjectFile,
|
||||
loadCurrentProjectFile: context.loadCurrentProjectFile,
|
||||
loadProjectFileFromPath: context.loadProjectFileFromPath,
|
||||
setCurrentVideoPath: context.setCurrentVideoPath,
|
||||
getCurrentVideoPathResult: context.getCurrentVideoPathResult,
|
||||
clearCurrentVideoPath: context.clearCurrentVideoPath,
|
||||
});
|
||||
const cursorService = new CursorService({
|
||||
store,
|
||||
adapter: new TelemetryCursorAdapter({
|
||||
loadRecordingData: context.loadCursorRecordingData,
|
||||
resolveVideoPath: context.resolveVideoPath,
|
||||
loadTelemetry: context.loadCursorTelemetry,
|
||||
}),
|
||||
});
|
||||
const systemService = new SystemService({
|
||||
store,
|
||||
getPlatform: () => platform,
|
||||
getAssetBasePath: context.resolveAssetBasePath,
|
||||
getCursorCapabilities: () => cursorService.getCapabilities(),
|
||||
});
|
||||
|
||||
ipcMain.handle(NATIVE_BRIDGE_CHANNEL, async (_, request: unknown) => {
|
||||
if (!isBridgeRequest(request)) {
|
||||
return createErrorResponse(undefined, "INVALID_REQUEST", "Invalid native bridge request.");
|
||||
}
|
||||
|
||||
const requestId = request.requestId;
|
||||
const domain = request.domain as string;
|
||||
|
||||
try {
|
||||
switch (request.domain) {
|
||||
case "system": {
|
||||
const action = request.action as string;
|
||||
switch (request.action) {
|
||||
case "getPlatform":
|
||||
return createSuccessResponse(requestId, systemService.getPlatform());
|
||||
case "getAssetBasePath":
|
||||
return createSuccessResponse(requestId, systemService.getAssetBasePath());
|
||||
case "getCapabilities":
|
||||
return createSuccessResponse(requestId, await systemService.getCapabilities());
|
||||
default:
|
||||
return createErrorResponse(
|
||||
requestId,
|
||||
"UNSUPPORTED_ACTION",
|
||||
`Unsupported system action: ${action}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
case "project": {
|
||||
const action = request.action as string;
|
||||
switch (request.action) {
|
||||
case "getCurrentContext":
|
||||
return createSuccessResponse(requestId, projectService.getCurrentContext());
|
||||
case "saveProjectFile":
|
||||
return createSuccessResponse(
|
||||
requestId,
|
||||
await projectService.saveProjectFile(
|
||||
request.payload.projectData,
|
||||
request.payload.suggestedName,
|
||||
request.payload.existingProjectPath,
|
||||
),
|
||||
);
|
||||
case "loadProjectFile":
|
||||
return createSuccessResponse(
|
||||
requestId,
|
||||
await projectService.loadProjectFile(request.payload?.projectFolder),
|
||||
);
|
||||
case "loadCurrentProjectFile":
|
||||
return createSuccessResponse(
|
||||
requestId,
|
||||
await projectService.loadCurrentProjectFile(),
|
||||
);
|
||||
case "loadProjectFileFromPath":
|
||||
return createSuccessResponse(
|
||||
requestId,
|
||||
await projectService.loadProjectFileFromPath(request.payload.path),
|
||||
);
|
||||
case "setCurrentVideoPath":
|
||||
return createSuccessResponse(
|
||||
requestId,
|
||||
await projectService.setCurrentVideoPath(request.payload.path),
|
||||
);
|
||||
case "getCurrentVideoPath":
|
||||
return createSuccessResponse(requestId, projectService.getCurrentVideoPath());
|
||||
case "clearCurrentVideoPath":
|
||||
return createSuccessResponse(requestId, projectService.clearCurrentVideoPath());
|
||||
default:
|
||||
return createErrorResponse(
|
||||
requestId,
|
||||
"UNSUPPORTED_ACTION",
|
||||
`Unsupported project action: ${action}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
case "cursor": {
|
||||
const action = request.action as string;
|
||||
switch (request.action) {
|
||||
case "getCapabilities":
|
||||
return createSuccessResponse(requestId, await cursorService.getCapabilities());
|
||||
case "getTelemetry":
|
||||
return createSuccessResponse(
|
||||
requestId,
|
||||
await cursorService.getTelemetry(request.payload?.videoPath),
|
||||
);
|
||||
case "getRecordingData":
|
||||
return createSuccessResponse(
|
||||
requestId,
|
||||
await cursorService.getRecordingData(request.payload?.videoPath),
|
||||
);
|
||||
default:
|
||||
return createErrorResponse(
|
||||
requestId,
|
||||
"UNSUPPORTED_ACTION",
|
||||
`Unsupported cursor action: ${action}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
return createErrorResponse(
|
||||
requestId,
|
||||
"UNSUPPORTED_ACTION",
|
||||
`Unsupported bridge domain: ${domain}`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
return createErrorResponse(
|
||||
requestId,
|
||||
"INTERNAL_ERROR",
|
||||
error instanceof Error ? error.message : "Unknown native bridge error.",
|
||||
true,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { mkdtemp, readFile, rm, stat } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { RecordingStreamRegistry } from "./recordingStream";
|
||||
|
||||
describe("RecordingStreamRegistry", () => {
|
||||
let dir: string;
|
||||
const pathFor = (name: string) => path.join(dir, name);
|
||||
|
||||
beforeEach(async () => {
|
||||
dir = await mkdtemp(path.join(tmpdir(), "openscreen-stream-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("streams chunks to disk in order and reports streamed on finalize", async () => {
|
||||
const registry = new RecordingStreamRegistry();
|
||||
await registry.open("rec.webm", pathFor("rec.webm"));
|
||||
await registry.append("rec.webm", Buffer.from("hello "));
|
||||
await registry.append("rec.webm", Buffer.from("world"));
|
||||
|
||||
const streamed = await registry.finalize("rec.webm");
|
||||
|
||||
expect(streamed).toBe(true);
|
||||
expect(await readFile(pathFor("rec.webm"), "utf8")).toBe("hello world");
|
||||
// A second finalize has nothing to close.
|
||||
expect(await registry.finalize("rec.webm")).toBe(false);
|
||||
});
|
||||
|
||||
it("reports not-streamed when no stream was opened", async () => {
|
||||
const registry = new RecordingStreamRegistry();
|
||||
expect(await registry.finalize("missing.webm")).toBe(false);
|
||||
expect(registry.has("missing.webm")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects open when the target path is not writable (open is awaited, not assumed)", async () => {
|
||||
const registry = new RecordingStreamRegistry();
|
||||
// Parent directory does not exist, so createWriteStream emits 'error' on open.
|
||||
await expect(
|
||||
registry.open("rec.webm", path.join(dir, "does-not-exist", "rec.webm")),
|
||||
).rejects.toThrow();
|
||||
// A failed open must not register a stream the renderer would treat as live.
|
||||
expect(registry.has("rec.webm")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects append when no stream is open", async () => {
|
||||
const registry = new RecordingStreamRegistry();
|
||||
await expect(registry.append("rec.webm", Buffer.from("x"))).rejects.toThrow(
|
||||
/No active recording stream/,
|
||||
);
|
||||
});
|
||||
|
||||
it("discard closes the stream and removes the partial file", async () => {
|
||||
const registry = new RecordingStreamRegistry();
|
||||
await registry.open("rec.webm", pathFor("rec.webm"));
|
||||
await registry.append("rec.webm", Buffer.from("partial"));
|
||||
|
||||
await registry.discard("rec.webm", pathFor("rec.webm"));
|
||||
|
||||
expect(registry.has("rec.webm")).toBe(false);
|
||||
await expect(stat(pathFor("rec.webm"))).rejects.toThrow();
|
||||
// Nothing left to finalize after a discard.
|
||||
expect(await registry.finalize("rec.webm")).toBe(false);
|
||||
});
|
||||
|
||||
it("discard tolerates a missing file", async () => {
|
||||
const registry = new RecordingStreamRegistry();
|
||||
await expect(registry.discard("never.webm", pathFor("never.webm"))).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("opening the same file twice replaces the prior stream", async () => {
|
||||
const registry = new RecordingStreamRegistry();
|
||||
await registry.open("rec.webm", pathFor("rec.webm"));
|
||||
await registry.append("rec.webm", Buffer.from("first"));
|
||||
await registry.open("rec.webm", pathFor("rec.webm"));
|
||||
await registry.append("rec.webm", Buffer.from("second"));
|
||||
await registry.finalize("rec.webm");
|
||||
|
||||
expect(await readFile(pathFor("rec.webm"), "utf8")).toBe("second");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
import { createWriteStream, type WriteStream } from "node:fs";
|
||||
import { unlink } from "node:fs/promises";
|
||||
import type { IpcMain } from "electron";
|
||||
|
||||
/**
|
||||
* Owns write streams for in-progress recordings, keyed by output file name.
|
||||
* MediaRecorder chunks are appended as they arrive so a long recording never
|
||||
* buffers the whole video in the renderer (#616 fix). File name is the key
|
||||
* because it's already exchanged across IPC and is unique per recording.
|
||||
*/
|
||||
export class RecordingStreamRegistry {
|
||||
private readonly streams = new Map<string, WriteStream>();
|
||||
|
||||
/**
|
||||
* Open a write stream, resolving only on the `open` event so a bad path or
|
||||
* permission error rejects here instead of becoming a silent chunk drop later,
|
||||
* letting the renderer's fallback take over.
|
||||
*/
|
||||
async open(fileName: string, filePath: string): Promise<void> {
|
||||
await this.endStream(fileName);
|
||||
|
||||
const ws = createWriteStream(filePath, { flags: "w" });
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const onError = (error: Error) => reject(error);
|
||||
ws.once("error", onError);
|
||||
ws.once("open", () => {
|
||||
ws.removeListener("error", onError);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
// Keep a lifetime listener so a late error logs instead of crashing the main
|
||||
// process with an unhandled 'error'. Per-write failures still surface in `append`.
|
||||
ws.on("error", (error) => {
|
||||
console.error(`[recording-stream] ${fileName}:`, error);
|
||||
});
|
||||
|
||||
this.streams.set(fileName, ws);
|
||||
}
|
||||
|
||||
has(fileName: string): boolean {
|
||||
return this.streams.has(fileName);
|
||||
}
|
||||
|
||||
/** Append a chunk; rejects if no stream is open or the write fails. */
|
||||
async append(fileName: string, chunk: Buffer): Promise<void> {
|
||||
const ws = this.streams.get(fileName);
|
||||
if (!ws) {
|
||||
throw new Error(`No active recording stream for ${fileName}`);
|
||||
}
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
ws.write(chunk, (error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush and close the stream, keeping the file. Returns true if a stream was
|
||||
* open (streamed to disk) or false if the caller still needs to write its buffer.
|
||||
*/
|
||||
async finalize(fileName: string): Promise<boolean> {
|
||||
const ws = this.streams.get(fileName);
|
||||
if (!ws) {
|
||||
return false;
|
||||
}
|
||||
this.streams.delete(fileName);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
ws.end((error?: Error | null) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the stream (if any) and delete the partial file, so a discarded or
|
||||
* failed recording doesn't leak descriptors or orphan partial files on disk.
|
||||
*/
|
||||
async discard(fileName: string, filePath: string): Promise<void> {
|
||||
await this.endStream(fileName);
|
||||
await unlink(filePath).catch(() => undefined);
|
||||
}
|
||||
|
||||
private async endStream(fileName: string): Promise<void> {
|
||||
const ws = this.streams.get(fileName);
|
||||
if (!ws) {
|
||||
return;
|
||||
}
|
||||
this.streams.delete(fileName);
|
||||
await new Promise<void>((resolve) => ws.end(() => resolve()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the streaming IPC handlers. Thin wrappers that translate the
|
||||
* registry's throw-on-failure contract into the `{ success, error }` shape the
|
||||
* renderer expects.
|
||||
*/
|
||||
export function registerRecordingStreamHandlers(
|
||||
ipcMain: IpcMain,
|
||||
registry: RecordingStreamRegistry,
|
||||
resolveRecordingOutputPath: (fileName: string) => string,
|
||||
): void {
|
||||
ipcMain.handle(
|
||||
"open-recording-stream",
|
||||
async (_, fileName: string): Promise<{ success: boolean; error?: string }> => {
|
||||
try {
|
||||
await registry.open(fileName, resolveRecordingOutputPath(fileName));
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: String(error) };
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
"append-recording-chunk",
|
||||
async (
|
||||
_,
|
||||
fileName: string,
|
||||
chunk: ArrayBuffer,
|
||||
): Promise<{ success: boolean; error?: string }> => {
|
||||
try {
|
||||
await registry.append(fileName, Buffer.from(chunk));
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: String(error) };
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
"close-recording-stream",
|
||||
async (_, fileName: string): Promise<{ success: boolean; error?: string }> => {
|
||||
try {
|
||||
await registry.discard(fileName, resolveRecordingOutputPath(fileName));
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: String(error) };
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
app,
|
||||
BrowserWindow,
|
||||
desktopCapturer,
|
||||
ipcMain,
|
||||
Menu,
|
||||
nativeImage,
|
||||
@@ -12,8 +11,14 @@ import {
|
||||
systemPreferences,
|
||||
Tray,
|
||||
} from "electron";
|
||||
import { ShortcutBinding } from "../src/lib/shortcuts";
|
||||
import {
|
||||
loadAndRegisterGlobalShortcut,
|
||||
registerOpenAppShortcut,
|
||||
unregisterAllGlobalShortcuts,
|
||||
} from "./globalShortcut";
|
||||
import { mainT, setMainLocale } from "./i18n";
|
||||
import { registerIpcHandlers } from "./ipc/handlers";
|
||||
import { getSelectedDesktopSource, registerIpcHandlers } from "./ipc/handlers";
|
||||
import {
|
||||
createCountdownOverlayWindow,
|
||||
createEditorWindow,
|
||||
@@ -23,15 +28,14 @@ import {
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
// Use Screen & System Audio Recording permissions instead of CoreAudio Tap API on macOS.
|
||||
// CoreAudio Tap requires NSAudioCaptureUsageDescription in the parent app's Info.plist,
|
||||
// which doesn't work when running from a terminal/IDE during development, makes my life easier
|
||||
// Use Screen & System Audio Recording permissions instead of the CoreAudio Tap API on macOS.
|
||||
// Tap needs NSAudioCaptureUsageDescription in the parent app's Info.plist, which breaks when
|
||||
// running from a terminal/IDE during dev.
|
||||
if (process.platform === "darwin") {
|
||||
app.commandLine.appendSwitch("disable-features", "MacCatapLoopbackAudioForScreenShare");
|
||||
}
|
||||
|
||||
// Enable Wayland support for proper screen capture and window management
|
||||
// on Wayland compositors (Hyprland, GNOME, KDE, etc.)
|
||||
// Wayland support for screen capture and window management on Wayland compositors.
|
||||
if (process.platform === "linux") {
|
||||
const isWayland =
|
||||
process.env.XDG_SESSION_TYPE === "wayland" || process.env.WAYLAND_DISPLAY !== undefined;
|
||||
@@ -109,7 +113,7 @@ function isEditorWindow(window: BrowserWindow) {
|
||||
}
|
||||
|
||||
function sendEditorMenuAction(
|
||||
channel: "menu-load-project" | "menu-save-project" | "menu-save-project-as",
|
||||
channel: "menu-load-project" | "menu-save-project" | "menu-save-project-as" | "menu-new-project",
|
||||
) {
|
||||
let targetWindow = BrowserWindow.getFocusedWindow() ?? mainWindow;
|
||||
|
||||
@@ -168,6 +172,12 @@ function setupApplicationMenu() {
|
||||
{
|
||||
label: mainT("common", "actions.file") || "File",
|
||||
submenu: [
|
||||
{
|
||||
label: mainT("dialogs", "unsavedChanges.newProject") || "New Project",
|
||||
accelerator: "CmdOrCtrl+N",
|
||||
click: () => sendEditorMenuAction("menu-new-project"),
|
||||
},
|
||||
{ type: "separator" as const },
|
||||
{
|
||||
label: mainT("dialogs", "unsavedChanges.loadProject") || "Load Project…",
|
||||
accelerator: "CmdOrCtrl+O",
|
||||
@@ -373,7 +383,7 @@ function createEditorWindowWrapper() {
|
||||
const windowToClose = mainWindow;
|
||||
if (!windowToClose || windowToClose.isDestroyed()) return;
|
||||
|
||||
// Ask renderer to show the custom in-app dialog
|
||||
// Ask renderer to show the in-app close dialog.
|
||||
windowToClose.webContents.send("request-close-confirm");
|
||||
|
||||
ipcMain.once("close-confirm-response", (event, choice: "save" | "discard" | "cancel") => {
|
||||
@@ -382,7 +392,7 @@ function createEditorWindowWrapper() {
|
||||
if (!windowToClose || windowToClose.isDestroyed()) return;
|
||||
|
||||
if (choice === "save") {
|
||||
// Tell renderer to save the project, then close when done
|
||||
// Save first, then close when the renderer reports done.
|
||||
windowToClose.webContents.send("request-save-before-close");
|
||||
ipcMain.once("save-before-close-done", (event, shouldClose: boolean) => {
|
||||
if (event.sender.id !== windowToClose?.webContents.id) return;
|
||||
@@ -417,16 +427,14 @@ function createCountdownOverlayWindowWrapper() {
|
||||
return countdownOverlayWindow;
|
||||
}
|
||||
|
||||
// Closing every window quits the app entirely (tray icon goes too).
|
||||
// The in-app "Return to Recorder" button covers the editor → HUD round-trip,
|
||||
// so closing the last window is an explicit "I'm done" signal.
|
||||
// Closing every window quits the app (tray goes too). The in-app "Return to Recorder"
|
||||
// button covers the editor-to-HUD round-trip, so closing the last window means "I'm done".
|
||||
app.on("window-all-closed", () => {
|
||||
app.quit();
|
||||
});
|
||||
|
||||
app.on("activate", () => {
|
||||
// On OS X it's common to re-create a window in the app when the
|
||||
// dock icon is clicked and there are no other windows open.
|
||||
// On macOS, re-open a window when the dock icon is clicked and none are open.
|
||||
const hasVisibleWindow = BrowserWindow.getAllWindows().some((window) => {
|
||||
if (window.isDestroyed() || !window.isVisible()) {
|
||||
return false;
|
||||
@@ -441,16 +449,18 @@ app.on("activate", () => {
|
||||
}
|
||||
});
|
||||
|
||||
// Register all IPC handlers when app is ready
|
||||
app.on("will-quit", () => {
|
||||
unregisterAllGlobalShortcuts();
|
||||
});
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
// Force the app into "regular" activation policy so the Dock icon appears.
|
||||
// The HUD overlay (transparent + frameless + skipTaskbar) is the first
|
||||
// window we open, and AppKit otherwise classifies us as an accessory app.
|
||||
// Force "regular" activation policy so the Dock icon appears. The HUD overlay
|
||||
// (transparent, frameless, skipTaskbar) is the first window, and AppKit would
|
||||
// otherwise classify us as an accessory app.
|
||||
if (process.platform === "darwin") {
|
||||
app.dock?.show();
|
||||
}
|
||||
|
||||
// Allow microphone/media/screen permission checks
|
||||
session.defaultSession.setPermissionCheckHandler((_webContents, permission) => {
|
||||
const allowed = [
|
||||
"media",
|
||||
@@ -477,24 +487,31 @@ app.whenReady().then(async () => {
|
||||
callback(allowed.includes(permission));
|
||||
});
|
||||
|
||||
// Request microphone and screen recording permissions from macOS
|
||||
session.defaultSession.setDisplayMediaRequestHandler(
|
||||
(request, callback) => {
|
||||
const source = getSelectedDesktopSource();
|
||||
if (!request.videoRequested || !source) {
|
||||
callback({});
|
||||
return;
|
||||
}
|
||||
|
||||
callback({
|
||||
video: source,
|
||||
...(request.audioRequested && process.platform === "win32" ? { audio: "loopback" } : {}),
|
||||
});
|
||||
},
|
||||
{ useSystemPicker: false },
|
||||
);
|
||||
|
||||
// Request mic permission now. Screen Recording is requested lazily from the
|
||||
// source-picker action so its prompt isn't hidden behind the selector window.
|
||||
if (process.platform === "darwin") {
|
||||
const micStatus = systemPreferences.getMediaAccessStatus("microphone");
|
||||
if (micStatus !== "granted") {
|
||||
await systemPreferences.askForMediaAccess("microphone");
|
||||
}
|
||||
|
||||
// Screen recording has no askForMediaAccess equivalent — the TCC prompt is
|
||||
// triggered by the first desktopCapturer.getSources() call. Firing it here
|
||||
// at startup settles the permission state early and prevents repeated prompts
|
||||
// driven by later getSources() calls (fixes repeated permission dialog).
|
||||
const screenStatus = systemPreferences.getMediaAccessStatus("screen");
|
||||
if (screenStatus === "not-determined") {
|
||||
desktopCapturer.getSources({ types: ["screen"] }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
// Listen for HUD overlay quit event (macOS only)
|
||||
ipcMain.on("hud-overlay-close", () => {
|
||||
app.quit();
|
||||
});
|
||||
@@ -504,10 +521,14 @@ app.whenReady().then(async () => {
|
||||
updateTrayMenu();
|
||||
});
|
||||
|
||||
ipcMain.handle("update-global-shortcut", (_, binding: ShortcutBinding) => {
|
||||
const success = registerOpenAppShortcut(binding, showMainWindow);
|
||||
return { success };
|
||||
});
|
||||
|
||||
createTray();
|
||||
updateTrayMenu();
|
||||
setupApplicationMenu();
|
||||
// Ensure recordings directory exists
|
||||
await ensureRecordingsDir();
|
||||
|
||||
function switchToHudWrapper() {
|
||||
@@ -537,5 +558,8 @@ app.whenReady().then(async () => {
|
||||
},
|
||||
switchToHudWrapper,
|
||||
);
|
||||
|
||||
await loadAndRegisterGlobalShortcut(showMainWindow);
|
||||
|
||||
createWindow();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import type {
|
||||
CursorCapabilities,
|
||||
CursorProviderKind,
|
||||
CursorRecordingData,
|
||||
CursorTelemetryPoint,
|
||||
} from "../../../src/native/contracts";
|
||||
|
||||
export interface CursorTelemetryLoadResult {
|
||||
success: boolean;
|
||||
samples: CursorTelemetryPoint[];
|
||||
message?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface CursorNativeAdapter {
|
||||
readonly kind: CursorProviderKind;
|
||||
getCapabilities(): Promise<CursorCapabilities>;
|
||||
getRecordingData(videoPath?: string | null): Promise<CursorRecordingData>;
|
||||
getTelemetry(videoPath?: string | null): Promise<CursorTelemetryLoadResult>;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { Rectangle } from "electron";
|
||||
import { MacNativeCursorRecordingSession } from "./macNativeCursorRecordingSession";
|
||||
import type { CursorRecordingSession } from "./session";
|
||||
import { TelemetryRecordingSession } from "./telemetryRecordingSession";
|
||||
import { WindowsNativeRecordingSession } from "./windowsNativeRecordingSession";
|
||||
|
||||
interface CreateCursorRecordingSessionOptions {
|
||||
getDisplayBounds: () => Rectangle | null;
|
||||
maxSamples: number;
|
||||
platform: NodeJS.Platform;
|
||||
sampleIntervalMs: number;
|
||||
sourceId?: string | null;
|
||||
startTimeMs?: number;
|
||||
}
|
||||
|
||||
export function createCursorRecordingSession(
|
||||
options: CreateCursorRecordingSessionOptions,
|
||||
): CursorRecordingSession {
|
||||
if (options.platform === "win32") {
|
||||
return new WindowsNativeRecordingSession({
|
||||
getDisplayBounds: options.getDisplayBounds,
|
||||
maxSamples: options.maxSamples,
|
||||
sampleIntervalMs: options.sampleIntervalMs,
|
||||
sourceId: options.sourceId,
|
||||
startTimeMs: options.startTimeMs,
|
||||
});
|
||||
}
|
||||
|
||||
if (options.platform === "darwin") {
|
||||
return new MacNativeCursorRecordingSession({
|
||||
getDisplayBounds: options.getDisplayBounds,
|
||||
maxSamples: options.maxSamples,
|
||||
sampleIntervalMs: options.sampleIntervalMs,
|
||||
startTimeMs: options.startTimeMs,
|
||||
});
|
||||
}
|
||||
|
||||
// Linux: capture cursor positions via Electron's `screen` API on an interval.
|
||||
// No cursor sprites/assets and no clicks, just position telemetry.
|
||||
return new TelemetryRecordingSession({
|
||||
getDisplayBounds: options.getDisplayBounds,
|
||||
maxSamples: options.maxSamples,
|
||||
sampleIntervalMs: options.sampleIntervalMs,
|
||||
startTimeMs: options.startTimeMs,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,449 @@
|
||||
import { type ChildProcessByStdio, spawn } from "node:child_process";
|
||||
import { accessSync, constants as fsConstants } from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { Readable } from "node:stream";
|
||||
import { type Rectangle, screen, systemPreferences } from "electron";
|
||||
import type {
|
||||
CursorRecordingData,
|
||||
CursorRecordingSample,
|
||||
NativeCursorAsset,
|
||||
NativeCursorType,
|
||||
} from "../../../../src/native/contracts";
|
||||
import type { CursorRecordingSession } from "./session";
|
||||
|
||||
interface MacCursorAssetPayload {
|
||||
id: string;
|
||||
imageDataUrl: string;
|
||||
width: number;
|
||||
height: number;
|
||||
hotspotX: number;
|
||||
hotspotY: number;
|
||||
scaleFactor?: number;
|
||||
}
|
||||
|
||||
interface MacNativeCursorRecordingSessionOptions {
|
||||
getDisplayBounds: () => Rectangle | null;
|
||||
maxSamples: number;
|
||||
sampleIntervalMs: number;
|
||||
startTimeMs?: number;
|
||||
}
|
||||
|
||||
type MacCursorEvent =
|
||||
| {
|
||||
type: "ready";
|
||||
timestampMs: number;
|
||||
accessibilityTrusted?: boolean;
|
||||
mouseTapReady?: boolean;
|
||||
}
|
||||
| {
|
||||
type: "sample";
|
||||
timestampMs: number;
|
||||
cursorType?: NativeCursorType | null;
|
||||
assetId?: string | null;
|
||||
asset?: MacCursorAssetPayload | null;
|
||||
leftButtonDown?: boolean;
|
||||
leftButtonPressed?: boolean;
|
||||
leftButtonReleased?: boolean;
|
||||
};
|
||||
|
||||
const HELPER_NAME = "openscreen-macos-cursor-helper";
|
||||
const READY_TIMEOUT_MS = 5_000;
|
||||
|
||||
function helperCandidates() {
|
||||
const envPath = process.env.OPENSCREEN_MAC_CURSOR_HELPER_EXE?.trim();
|
||||
const appRoot = process.env.APP_ROOT ? path.resolve(process.env.APP_ROOT) : process.cwd();
|
||||
const archTag = process.arch === "arm64" ? "darwin-arm64" : "darwin-x64";
|
||||
const resourceRoot =
|
||||
typeof process.resourcesPath === "string"
|
||||
? process.resourcesPath
|
||||
: path.join(appRoot, "resources");
|
||||
|
||||
return [
|
||||
envPath,
|
||||
path.join(appRoot, "electron", "native", "screencapturekit", "build", HELPER_NAME),
|
||||
path.join(appRoot, "electron", "native", "bin", archTag, HELPER_NAME),
|
||||
path.join(resourceRoot, "electron", "native", "bin", archTag, HELPER_NAME),
|
||||
].filter((candidate): candidate is string => Boolean(candidate));
|
||||
}
|
||||
|
||||
export function findMacCursorHelperPath() {
|
||||
for (const candidate of helperCandidates()) {
|
||||
try {
|
||||
accessSync(candidate, fsConstants.X_OK);
|
||||
return candidate;
|
||||
} catch {
|
||||
// Try the next helper location.
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function requestMacCursorAccessibilityAccess() {
|
||||
if (process.platform !== "darwin") {
|
||||
return { success: true, granted: true, status: "granted" };
|
||||
}
|
||||
|
||||
try {
|
||||
systemPreferences.isTrustedAccessibilityClient(true);
|
||||
} catch {
|
||||
// Continue with helper probing; it can trigger the same macOS prompt.
|
||||
}
|
||||
|
||||
const helperPath = findMacCursorHelperPath();
|
||||
if (!helperPath) {
|
||||
return { success: true, granted: false, status: "missing-helper" };
|
||||
}
|
||||
|
||||
return new Promise<{ success: boolean; granted: boolean; status: string; error?: string }>(
|
||||
(resolve) => {
|
||||
const child = spawn(helperPath, [JSON.stringify({ sampleIntervalMs: 250 })], {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
let settled = false;
|
||||
let lineBuffer = "";
|
||||
const finish = (result: {
|
||||
success: boolean;
|
||||
granted: boolean;
|
||||
status: string;
|
||||
error?: string;
|
||||
}) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
if (!child.killed) {
|
||||
child.kill("SIGTERM");
|
||||
}
|
||||
resolve(result);
|
||||
};
|
||||
const timer = setTimeout(() => {
|
||||
finish({
|
||||
success: false,
|
||||
granted: false,
|
||||
status: "timeout",
|
||||
error: "Timed out waiting for macOS cursor helper",
|
||||
});
|
||||
}, READY_TIMEOUT_MS);
|
||||
|
||||
child.stdout.setEncoding("utf8");
|
||||
child.stdout.on("data", (chunk: string) => {
|
||||
lineBuffer += chunk;
|
||||
const lines = lineBuffer.split(/\r?\n/);
|
||||
lineBuffer = lines.pop() ?? "";
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const event = JSON.parse(trimmed) as MacCursorEvent;
|
||||
if (event.type === "ready") {
|
||||
finish({
|
||||
success: true,
|
||||
granted: event.accessibilityTrusted === true,
|
||||
status: event.accessibilityTrusted === true ? "granted" : "not-determined",
|
||||
});
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Ignore non-JSON helper output.
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
child.once("error", (error) => {
|
||||
finish({
|
||||
success: false,
|
||||
granted: false,
|
||||
status: "error",
|
||||
error: error.message,
|
||||
});
|
||||
});
|
||||
child.once("exit", (code, signal) => {
|
||||
finish({
|
||||
success: false,
|
||||
granted: false,
|
||||
status: "exited",
|
||||
error: `macOS cursor helper exited before ready (code=${code}, signal=${signal})`,
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
function normalizeCursorType(value: unknown): NativeCursorType | null {
|
||||
return value === "arrow" || value === "pointer" || value === "text" ? value : null;
|
||||
}
|
||||
|
||||
export class MacNativeCursorRecordingSession implements CursorRecordingSession {
|
||||
private samples: CursorRecordingSample[] = [];
|
||||
private assets = new Map<string, NativeCursorAsset>();
|
||||
private process: ChildProcessByStdio<null, Readable, Readable> | null = null;
|
||||
private lineBuffer = "";
|
||||
private startTimeMs = 0;
|
||||
private fallbackInterval: NodeJS.Timeout | null = null;
|
||||
private readyResolve: (() => void) | null = null;
|
||||
private readyReject: ((error: Error) => void) | null = null;
|
||||
private readyTimer: NodeJS.Timeout | null = null;
|
||||
private previousLeftButtonDown = false;
|
||||
private consecutiveOutsideSamples = 0;
|
||||
// Hide only after this many consecutive out-of-bounds samples (~100ms at 33ms interval).
|
||||
// Fast swipes that briefly exit the display are clipped by clip-path instead of disappearing.
|
||||
private static readonly OUTSIDE_HIDE_THRESHOLD = 3;
|
||||
|
||||
constructor(private readonly options: MacNativeCursorRecordingSessionOptions) {}
|
||||
|
||||
async start(): Promise<void> {
|
||||
this.samples = [];
|
||||
this.assets.clear();
|
||||
this.lineBuffer = "";
|
||||
this.startTimeMs = this.options.startTimeMs ?? Date.now();
|
||||
this.previousLeftButtonDown = false;
|
||||
this.consecutiveOutsideSamples = 0;
|
||||
|
||||
try {
|
||||
systemPreferences.isTrustedAccessibilityClient(true);
|
||||
} catch {
|
||||
// Without Accessibility, text/pointer affordance detection is unavailable;
|
||||
// bitmaps are still captured natively via NSCursor.
|
||||
}
|
||||
|
||||
const helperPath = findMacCursorHelperPath();
|
||||
if (!helperPath) {
|
||||
this.startPositionOnlyFallback();
|
||||
return;
|
||||
}
|
||||
|
||||
const child = spawn(
|
||||
helperPath,
|
||||
[
|
||||
JSON.stringify({
|
||||
sampleIntervalMs: this.options.sampleIntervalMs,
|
||||
}),
|
||||
],
|
||||
{
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
},
|
||||
);
|
||||
this.process = child;
|
||||
|
||||
child.stdout.setEncoding("utf8");
|
||||
child.stdout.on("data", (chunk: string) => this.handleStdoutChunk(chunk));
|
||||
child.stderr.setEncoding("utf8");
|
||||
child.stderr.on("data", (chunk: string) => {
|
||||
const message = chunk.trim();
|
||||
if (message) {
|
||||
console.error("[cursor-macos]", message);
|
||||
}
|
||||
});
|
||||
child.once("exit", (code, signal) => {
|
||||
this.rejectReady(
|
||||
new Error(`macOS cursor helper exited before ready (code=${code}, signal=${signal})`),
|
||||
);
|
||||
this.process = null;
|
||||
});
|
||||
child.once("error", (error) => {
|
||||
this.rejectReady(error);
|
||||
this.process = null;
|
||||
});
|
||||
|
||||
try {
|
||||
await this.waitUntilReady();
|
||||
} catch (error) {
|
||||
this.killHelperProcess(child);
|
||||
this.process = null;
|
||||
console.warn("[cursor-macos] falling back to position-only cursor telemetry:", error);
|
||||
this.startPositionOnlyFallback();
|
||||
}
|
||||
}
|
||||
|
||||
async stop(): Promise<CursorRecordingData> {
|
||||
const child = this.process;
|
||||
this.process = null;
|
||||
this.clearReadyState();
|
||||
|
||||
if (this.fallbackInterval) {
|
||||
clearInterval(this.fallbackInterval);
|
||||
this.fallbackInterval = null;
|
||||
}
|
||||
|
||||
if (child) {
|
||||
this.killHelperProcess(child);
|
||||
}
|
||||
|
||||
return {
|
||||
version: 2,
|
||||
provider: this.assets.size > 0 ? "native" : "none",
|
||||
samples: this.samples,
|
||||
assets: [...this.assets.values()],
|
||||
};
|
||||
}
|
||||
|
||||
private startPositionOnlyFallback() {
|
||||
this.captureSample(Date.now(), null, null, false, false, false);
|
||||
this.fallbackInterval = setInterval(() => {
|
||||
this.captureSample(Date.now(), null, null, false, false, false);
|
||||
}, this.options.sampleIntervalMs);
|
||||
}
|
||||
|
||||
private rememberAsset(asset: MacCursorAssetPayload | null | undefined) {
|
||||
if (!asset?.id || this.assets.has(asset.id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cursor = screen.getCursorScreenPoint();
|
||||
const displayScaleFactor = screen.getDisplayNearestPoint(cursor).scaleFactor;
|
||||
this.assets.set(asset.id, {
|
||||
id: asset.id,
|
||||
platform: "darwin",
|
||||
imageDataUrl: asset.imageDataUrl,
|
||||
width: asset.width,
|
||||
height: asset.height,
|
||||
hotspotX: asset.hotspotX,
|
||||
hotspotY: asset.hotspotY,
|
||||
scaleFactor: asset.scaleFactor ?? displayScaleFactor,
|
||||
});
|
||||
}
|
||||
|
||||
private handleStdoutChunk(chunk: string) {
|
||||
this.lineBuffer += chunk;
|
||||
const lines = this.lineBuffer.split(/\r?\n/);
|
||||
this.lineBuffer = lines.pop() ?? "";
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmedLine = line.trim();
|
||||
if (!trimmedLine) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
this.handleEvent(JSON.parse(trimmedLine) as MacCursorEvent);
|
||||
} catch (error) {
|
||||
console.error("Failed to parse macOS cursor helper output:", error, trimmedLine);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private handleEvent(payload: MacCursorEvent) {
|
||||
if (payload.type === "ready") {
|
||||
if (payload.accessibilityTrusted === false) {
|
||||
console.warn(
|
||||
"[cursor-macos] Accessibility is not trusted; text/pointer affordance detection disabled (bitmap capture still active).",
|
||||
);
|
||||
}
|
||||
this.resolveReady();
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload.type === "sample") {
|
||||
this.rememberAsset(payload.asset);
|
||||
this.captureSample(
|
||||
payload.timestampMs,
|
||||
normalizeCursorType(payload.cursorType),
|
||||
payload.assetId ?? null,
|
||||
payload.leftButtonDown === true,
|
||||
payload.leftButtonPressed === true,
|
||||
payload.leftButtonReleased === true,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private captureSample(
|
||||
timestampMs: number,
|
||||
cursorType: NativeCursorType | null,
|
||||
assetId: string | null,
|
||||
leftButtonDown: boolean,
|
||||
leftButtonPressed: boolean,
|
||||
leftButtonReleased: boolean,
|
||||
) {
|
||||
const cursor = screen.getCursorScreenPoint();
|
||||
const bounds = this.options.getDisplayBounds() ?? screen.getDisplayNearestPoint(cursor).bounds;
|
||||
const width = Math.max(1, bounds.width);
|
||||
const height = Math.max(1, bounds.height);
|
||||
const normalizedX = (cursor.x - bounds.x) / width;
|
||||
const normalizedY = (cursor.y - bounds.y) / height;
|
||||
const isOutsideDisplay =
|
||||
normalizedX < 0 || normalizedX > 1 || normalizedY < 0 || normalizedY > 1;
|
||||
// Brief exits (under THRESHOLD samples) clip to the canvas edge via clip-path instead
|
||||
// of snapping invisible. Sustained exits (>=THRESHOLD, ~100ms) mark visible=false to
|
||||
// avoid ghost cursors and motion trails from multi-display movement.
|
||||
if (isOutsideDisplay) {
|
||||
this.consecutiveOutsideSamples++;
|
||||
} else {
|
||||
this.consecutiveOutsideSamples = 0;
|
||||
}
|
||||
const visible =
|
||||
this.consecutiveOutsideSamples < MacNativeCursorRecordingSession.OUTSIDE_HIDE_THRESHOLD;
|
||||
const interactionType =
|
||||
leftButtonPressed || (leftButtonDown && !this.previousLeftButtonDown)
|
||||
? "click"
|
||||
: leftButtonReleased || (!leftButtonDown && this.previousLeftButtonDown)
|
||||
? "mouseup"
|
||||
: "move";
|
||||
this.previousLeftButtonDown = leftButtonDown;
|
||||
|
||||
this.samples.push({
|
||||
timeMs: Math.max(0, timestampMs - this.startTimeMs),
|
||||
cx: clamp(normalizedX, 0, 1),
|
||||
cy: clamp(normalizedY, 0, 1),
|
||||
visible,
|
||||
interactionType,
|
||||
...(assetId ? { assetId } : {}),
|
||||
...(cursorType ? { cursorType } : {}),
|
||||
});
|
||||
|
||||
if (this.samples.length > this.options.maxSamples) {
|
||||
this.samples.shift();
|
||||
}
|
||||
}
|
||||
|
||||
private waitUntilReady() {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
this.readyResolve = resolve;
|
||||
this.readyReject = reject;
|
||||
this.readyTimer = setTimeout(() => {
|
||||
this.rejectReady(new Error("Timed out waiting for macOS cursor helper"));
|
||||
}, READY_TIMEOUT_MS);
|
||||
});
|
||||
}
|
||||
|
||||
private resolveReady() {
|
||||
const resolve = this.readyResolve;
|
||||
this.clearReadyState();
|
||||
resolve?.();
|
||||
}
|
||||
|
||||
private rejectReady(error: Error) {
|
||||
const reject = this.readyReject;
|
||||
this.clearReadyState();
|
||||
reject?.(error);
|
||||
}
|
||||
|
||||
private clearReadyState() {
|
||||
if (this.readyTimer) {
|
||||
clearTimeout(this.readyTimer);
|
||||
this.readyTimer = null;
|
||||
}
|
||||
this.readyResolve = null;
|
||||
this.readyReject = null;
|
||||
}
|
||||
|
||||
private killHelperProcess(child: ChildProcessByStdio<null, Readable, Readable>) {
|
||||
if (child.killed) {
|
||||
return;
|
||||
}
|
||||
|
||||
child.kill("SIGTERM");
|
||||
setTimeout(() => {
|
||||
if (!child.killed) {
|
||||
child.kill("SIGKILL");
|
||||
}
|
||||
}, 500).unref();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { CursorRecordingData } from "../../../../src/native/contracts";
|
||||
|
||||
export interface CursorRecordingSession {
|
||||
start(): Promise<void>;
|
||||
stop(): Promise<CursorRecordingData>;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { type Rectangle, screen } from "electron";
|
||||
import type { CursorRecordingData, CursorRecordingSample } from "../../../../src/native/contracts";
|
||||
import type { CursorRecordingSession } from "./session";
|
||||
|
||||
interface TelemetryRecordingSessionOptions {
|
||||
getDisplayBounds: () => Rectangle | null;
|
||||
maxSamples: number;
|
||||
sampleIntervalMs: number;
|
||||
startTimeMs?: number;
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
export class TelemetryRecordingSession implements CursorRecordingSession {
|
||||
private samples: CursorRecordingSample[] = [];
|
||||
private interval: NodeJS.Timeout | null = null;
|
||||
private startTimeMs = 0;
|
||||
|
||||
constructor(private readonly options: TelemetryRecordingSessionOptions) {}
|
||||
|
||||
async start(): Promise<void> {
|
||||
this.samples = [];
|
||||
this.startTimeMs = this.options.startTimeMs ?? Date.now();
|
||||
this.captureSample();
|
||||
this.interval = setInterval(() => {
|
||||
this.captureSample();
|
||||
}, this.options.sampleIntervalMs);
|
||||
}
|
||||
|
||||
async stop(): Promise<CursorRecordingData> {
|
||||
if (this.interval) {
|
||||
clearInterval(this.interval);
|
||||
this.interval = null;
|
||||
}
|
||||
|
||||
return {
|
||||
version: 2,
|
||||
provider: "none",
|
||||
samples: this.samples,
|
||||
assets: [],
|
||||
};
|
||||
}
|
||||
|
||||
private captureSample() {
|
||||
const cursor = screen.getCursorScreenPoint();
|
||||
const display = this.options.getDisplayBounds() ?? screen.getDisplayNearestPoint(cursor).bounds;
|
||||
const width = Math.max(1, display.width);
|
||||
const height = Math.max(1, display.height);
|
||||
|
||||
this.samples.push({
|
||||
timeMs: Math.max(0, Date.now() - this.startTimeMs),
|
||||
cx: clamp((cursor.x - display.x) / width, 0, 1),
|
||||
cy: clamp((cursor.y - display.y) / height, 0, 1),
|
||||
visible: true,
|
||||
});
|
||||
|
||||
if (this.samples.length > this.options.maxSamples) {
|
||||
this.samples.shift();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
import { type ChildProcessByStdio, spawn } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import type { Readable } from "node:stream";
|
||||
import { app, screen } from "electron";
|
||||
import { parseWindowHandleFromSourceId } from "../../../../src/lib/nativeWindowsRecording";
|
||||
import type {
|
||||
CursorRecordingData,
|
||||
CursorRecordingSample,
|
||||
NativeCursorAsset,
|
||||
} from "../../../../src/native/contracts";
|
||||
import type { CursorRecordingSession } from "./session";
|
||||
import type {
|
||||
WindowsCursorEvent,
|
||||
WindowsNativeRecordingSessionOptions,
|
||||
} from "./windowsNativeRecordingSession.types";
|
||||
|
||||
function getCursorSamplerCandidates(): string[] {
|
||||
const envPath = process.env.OPENSCREEN_CURSOR_SAMPLER_EXE?.trim();
|
||||
const archTag = process.arch === "arm64" ? "win32-arm64" : "win32-x64";
|
||||
const resolve = (...segs: string[]) => {
|
||||
const p = join(app.getAppPath(), ...segs);
|
||||
return app.isPackaged ? p.replace(/\.asar([/\\])/, ".asar.unpacked$1") : p;
|
||||
};
|
||||
const resolvePackaged = (...segs: string[]) => {
|
||||
return app.isPackaged ? join(process.resourcesPath, ...segs) : null;
|
||||
};
|
||||
return [
|
||||
envPath,
|
||||
resolve("electron", "native", "wgc-capture", "build", "cursor-sampler.exe"),
|
||||
resolve("electron", "native", "bin", archTag, "cursor-sampler.exe"),
|
||||
resolvePackaged("electron", "native", "bin", archTag, "cursor-sampler.exe"),
|
||||
].filter((c): c is string => Boolean(c));
|
||||
}
|
||||
|
||||
function findCursorSamplerPath(): string | null {
|
||||
for (const candidate of getCursorSamplerCandidates()) {
|
||||
if (existsSync(candidate)) return candidate;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const READY_TIMEOUT_MS = 5_000;
|
||||
|
||||
interface NormalizedSample {
|
||||
sample: CursorRecordingSample;
|
||||
withinBounds: boolean;
|
||||
}
|
||||
|
||||
export class WindowsNativeRecordingSession implements CursorRecordingSession {
|
||||
private assets = new Map<string, NativeCursorAsset>();
|
||||
private samples: CursorRecordingSample[] = [];
|
||||
private process: ChildProcessByStdio<null, Readable, Readable> | null = null;
|
||||
private lineBuffer = "";
|
||||
private startTimeMs = 0;
|
||||
private readyResolve: (() => void) | null = null;
|
||||
private readyReject: ((error: Error) => void) | null = null;
|
||||
private readyTimer: NodeJS.Timeout | null = null;
|
||||
private sampleCount = 0;
|
||||
private outOfBoundsSampleCount = 0;
|
||||
private previousLeftButtonDown = false;
|
||||
|
||||
constructor(private readonly options: WindowsNativeRecordingSessionOptions) {}
|
||||
|
||||
async start(): Promise<void> {
|
||||
this.assets.clear();
|
||||
this.samples = [];
|
||||
this.lineBuffer = "";
|
||||
this.startTimeMs = this.options.startTimeMs ?? Date.now();
|
||||
this.sampleCount = 0;
|
||||
this.outOfBoundsSampleCount = 0;
|
||||
this.previousLeftButtonDown = false;
|
||||
|
||||
const helperPath = findCursorSamplerPath();
|
||||
if (!helperPath) {
|
||||
throw new Error("Windows cursor sampler helper is not available.");
|
||||
}
|
||||
|
||||
const windowHandle = parseWindowHandleFromSourceId(this.options.sourceId);
|
||||
const args = [String(this.options.sampleIntervalMs)];
|
||||
if (windowHandle) args.push(windowHandle);
|
||||
|
||||
const child = spawn(helperPath, args, {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
windowsHide: true,
|
||||
});
|
||||
|
||||
this.process = child;
|
||||
this.logDiagnostic("spawn", {
|
||||
pid: child.pid ?? null,
|
||||
sampleIntervalMs: this.options.sampleIntervalMs,
|
||||
sourceId: this.options.sourceId ?? null,
|
||||
windowHandle,
|
||||
});
|
||||
|
||||
child.stdout.setEncoding("utf8");
|
||||
child.stdout.on("data", (chunk: string) => {
|
||||
this.handleStdoutChunk(chunk);
|
||||
});
|
||||
child.stderr.setEncoding("utf8");
|
||||
child.stderr.on("data", (chunk: string) => {
|
||||
const message = chunk.trim();
|
||||
if (message) {
|
||||
this.logDiagnostic("stderr", { message });
|
||||
}
|
||||
console.error("[cursor-native]", message);
|
||||
});
|
||||
child.once("exit", (code, signal) => {
|
||||
this.logDiagnostic("exit", {
|
||||
code,
|
||||
signal,
|
||||
sampleCount: this.sampleCount,
|
||||
assetCount: this.assets.size,
|
||||
outOfBoundsSampleCount: this.outOfBoundsSampleCount,
|
||||
});
|
||||
this.rejectReady(
|
||||
new Error(`Windows cursor helper exited before ready (code=${code}, signal=${signal})`),
|
||||
);
|
||||
});
|
||||
child.once("error", (error) => {
|
||||
this.logDiagnostic("process-error", { message: error.message });
|
||||
this.rejectReady(error);
|
||||
});
|
||||
|
||||
try {
|
||||
await this.waitUntilReady();
|
||||
} catch (error) {
|
||||
this.terminateHelperProcess();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async stop(): Promise<CursorRecordingData> {
|
||||
const child = this.process;
|
||||
this.process = null;
|
||||
this.clearReadyState();
|
||||
|
||||
this.killHelperProcess(child);
|
||||
|
||||
this.logDiagnostic("stop", {
|
||||
sampleCount: this.sampleCount,
|
||||
assetCount: this.assets.size,
|
||||
outOfBoundsSampleCount: this.outOfBoundsSampleCount,
|
||||
});
|
||||
|
||||
return {
|
||||
version: 2,
|
||||
provider: this.assets.size > 0 ? "native" : "none",
|
||||
samples: this.samples,
|
||||
assets: [...this.assets.values()],
|
||||
};
|
||||
}
|
||||
|
||||
private handleStdoutChunk(chunk: string) {
|
||||
this.lineBuffer += chunk;
|
||||
const lines = this.lineBuffer.split(/\r?\n/);
|
||||
this.lineBuffer = lines.pop() ?? "";
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmedLine = line.trim();
|
||||
if (!trimmedLine) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = JSON.parse(trimmedLine) as WindowsCursorEvent;
|
||||
this.handleEvent(payload);
|
||||
} catch (error) {
|
||||
console.error("Failed to parse Windows cursor helper output:", error, trimmedLine);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private handleEvent(payload: WindowsCursorEvent) {
|
||||
if (payload.type === "error") {
|
||||
this.logDiagnostic("helper-error", { message: payload.message });
|
||||
console.error("Windows cursor helper error:", payload.message);
|
||||
this.failHelper(new Error(payload.message));
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload.type === "ready") {
|
||||
this.logDiagnostic("ready", { timestampMs: payload.timestampMs });
|
||||
this.resolveReady();
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload.asset?.id && !this.assets.has(payload.asset.id)) {
|
||||
const assetDisplay = screen.getDisplayNearestPoint({ x: payload.x, y: payload.y });
|
||||
this.assets.set(payload.asset.id, {
|
||||
id: payload.asset.id,
|
||||
platform: "win32",
|
||||
imageDataUrl: payload.asset.imageDataUrl,
|
||||
width: payload.asset.width,
|
||||
height: payload.asset.height,
|
||||
hotspotX: payload.asset.hotspotX,
|
||||
hotspotY: payload.asset.hotspotY,
|
||||
scaleFactor: assetDisplay.scaleFactor,
|
||||
cursorType: payload.asset.cursorType ?? payload.cursorType ?? null,
|
||||
});
|
||||
this.logDiagnostic("asset", {
|
||||
id: payload.asset.id,
|
||||
width: payload.asset.width,
|
||||
height: payload.asset.height,
|
||||
hotspotX: payload.asset.hotspotX,
|
||||
hotspotY: payload.asset.hotspotY,
|
||||
scaleFactor: assetDisplay.scaleFactor,
|
||||
});
|
||||
}
|
||||
|
||||
const normalized = this.normalizeSample(payload);
|
||||
this.sampleCount += 1;
|
||||
if (!normalized.withinBounds) {
|
||||
this.outOfBoundsSampleCount += 1;
|
||||
}
|
||||
|
||||
this.samples.push(normalized.sample);
|
||||
|
||||
if (this.samples.length > this.options.maxSamples) {
|
||||
this.samples.shift();
|
||||
}
|
||||
}
|
||||
|
||||
private normalizeSample(
|
||||
payload: Extract<WindowsCursorEvent, { type: "sample" }>,
|
||||
): NormalizedSample {
|
||||
const bounds =
|
||||
payload.bounds ?? this.options.getDisplayBounds() ?? screen.getPrimaryDisplay().bounds;
|
||||
const width = Math.max(1, bounds.width);
|
||||
const height = Math.max(1, bounds.height);
|
||||
const normalizedX = (payload.x - bounds.x) / width;
|
||||
const normalizedY = (payload.y - bounds.y) / height;
|
||||
const withinBounds =
|
||||
normalizedX >= 0 && normalizedX <= 1 && normalizedY >= 0 && normalizedY <= 1;
|
||||
const leftButtonDown = payload.leftButtonDown === true;
|
||||
const leftButtonPressed = payload.leftButtonPressed === true;
|
||||
const leftButtonReleased = payload.leftButtonReleased === true;
|
||||
const interactionType =
|
||||
leftButtonPressed || (leftButtonDown && !this.previousLeftButtonDown)
|
||||
? "click"
|
||||
: leftButtonReleased || (!leftButtonDown && this.previousLeftButtonDown)
|
||||
? "mouseup"
|
||||
: "move";
|
||||
this.previousLeftButtonDown = leftButtonDown;
|
||||
|
||||
if (this.sampleCount === 0 || (!withinBounds && this.outOfBoundsSampleCount === 0)) {
|
||||
this.logDiagnostic("sample", {
|
||||
rawX: payload.x,
|
||||
rawY: payload.y,
|
||||
normalizedX,
|
||||
normalizedY,
|
||||
visible: payload.visible,
|
||||
withinBounds,
|
||||
bounds,
|
||||
handle: payload.handle,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
withinBounds,
|
||||
sample: {
|
||||
timeMs: Math.max(0, payload.timestampMs - this.startTimeMs),
|
||||
cx: normalizedX,
|
||||
cy: normalizedY,
|
||||
assetId: payload.handle,
|
||||
visible: payload.visible && withinBounds,
|
||||
cursorType: payload.cursorType ?? payload.asset?.cursorType ?? null,
|
||||
interactionType,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private waitUntilReady() {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
this.readyResolve = resolve;
|
||||
this.readyReject = reject;
|
||||
this.readyTimer = setTimeout(() => {
|
||||
this.rejectReady(new Error("Timed out waiting for Windows cursor helper readiness"));
|
||||
}, READY_TIMEOUT_MS);
|
||||
});
|
||||
}
|
||||
|
||||
private resolveReady() {
|
||||
const resolve = this.readyResolve;
|
||||
this.clearReadyState();
|
||||
resolve?.();
|
||||
}
|
||||
|
||||
private rejectReady(error: Error) {
|
||||
const reject = this.readyReject;
|
||||
this.clearReadyState();
|
||||
reject?.(error);
|
||||
}
|
||||
|
||||
private failHelper(error: Error) {
|
||||
this.rejectReady(error);
|
||||
this.terminateHelperProcess();
|
||||
}
|
||||
|
||||
private terminateHelperProcess() {
|
||||
const child = this.process;
|
||||
this.process = null;
|
||||
this.killHelperProcess(child);
|
||||
}
|
||||
|
||||
private killHelperProcess(child: ChildProcessByStdio<null, Readable, Readable> | null) {
|
||||
if (child && !child.killed) {
|
||||
child.kill();
|
||||
}
|
||||
}
|
||||
|
||||
private clearReadyState() {
|
||||
if (this.readyTimer) {
|
||||
clearTimeout(this.readyTimer);
|
||||
this.readyTimer = null;
|
||||
}
|
||||
this.readyResolve = null;
|
||||
this.readyReject = null;
|
||||
}
|
||||
|
||||
private logDiagnostic(event: string, data: Record<string, unknown>) {
|
||||
console.info(
|
||||
"[cursor-native][win32]",
|
||||
JSON.stringify({
|
||||
event,
|
||||
...data,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { Rectangle } from "electron";
|
||||
import type { NativeCursorType } from "../../../../src/native/contracts";
|
||||
|
||||
export interface WindowsCursorSampleEvent {
|
||||
type: "sample";
|
||||
timestampMs: number;
|
||||
x: number;
|
||||
y: number;
|
||||
visible: boolean;
|
||||
handle: string | null;
|
||||
cursorType?: NativeCursorType | null;
|
||||
leftButtonDown?: boolean;
|
||||
leftButtonPressed?: boolean;
|
||||
leftButtonReleased?: boolean;
|
||||
bounds?: {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
} | null;
|
||||
asset: WindowsCursorAssetPayload | null;
|
||||
}
|
||||
|
||||
export interface WindowsCursorReadyEvent {
|
||||
type: "ready";
|
||||
timestampMs: number;
|
||||
}
|
||||
|
||||
export interface WindowsCursorErrorEvent {
|
||||
type: "error";
|
||||
timestampMs: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface WindowsCursorAssetPayload {
|
||||
id: string;
|
||||
imageDataUrl: string;
|
||||
width: number;
|
||||
height: number;
|
||||
hotspotX: number;
|
||||
hotspotY: number;
|
||||
cursorType?: NativeCursorType | null;
|
||||
}
|
||||
|
||||
export type WindowsCursorEvent =
|
||||
| WindowsCursorSampleEvent
|
||||
| WindowsCursorReadyEvent
|
||||
| WindowsCursorErrorEvent;
|
||||
|
||||
export interface WindowsNativeRecordingSessionOptions {
|
||||
getDisplayBounds: () => Rectangle | null;
|
||||
maxSamples: number;
|
||||
sampleIntervalMs: number;
|
||||
sourceId?: string | null;
|
||||
startTimeMs?: number;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { CursorCapabilities, CursorRecordingData } from "../../../src/native/contracts";
|
||||
import type { CursorNativeAdapter, CursorTelemetryLoadResult } from "./adapter";
|
||||
|
||||
interface TelemetryCursorAdapterOptions {
|
||||
loadRecordingData: (videoPath: string) => Promise<CursorRecordingData>;
|
||||
resolveVideoPath: (videoPath?: string | null) => string | null;
|
||||
loadTelemetry: (videoPath: string) => Promise<CursorTelemetryLoadResult>;
|
||||
}
|
||||
|
||||
export class TelemetryCursorAdapter implements CursorNativeAdapter {
|
||||
readonly kind = "none" as const;
|
||||
|
||||
constructor(private readonly options: TelemetryCursorAdapterOptions) {}
|
||||
|
||||
async getCapabilities(): Promise<CursorCapabilities> {
|
||||
return {
|
||||
telemetry: true,
|
||||
systemAssets: false,
|
||||
provider: this.kind,
|
||||
};
|
||||
}
|
||||
|
||||
async getRecordingData(videoPath?: string | null): Promise<CursorRecordingData> {
|
||||
const resolvedVideoPath = this.options.resolveVideoPath(videoPath);
|
||||
if (!resolvedVideoPath) {
|
||||
return {
|
||||
version: 2,
|
||||
provider: this.kind,
|
||||
samples: [],
|
||||
assets: [],
|
||||
};
|
||||
}
|
||||
|
||||
return this.options.loadRecordingData(resolvedVideoPath);
|
||||
}
|
||||
|
||||
async getTelemetry(videoPath?: string | null) {
|
||||
const resolvedVideoPath = this.options.resolveVideoPath(videoPath);
|
||||
if (!resolvedVideoPath) {
|
||||
return {
|
||||
success: false,
|
||||
message: "No video path is available for cursor telemetry",
|
||||
samples: [],
|
||||
} satisfies CursorTelemetryLoadResult;
|
||||
}
|
||||
|
||||
return this.options.loadTelemetry(resolvedVideoPath);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import type {
|
||||
CursorCapabilities,
|
||||
CursorRecordingData,
|
||||
CursorTelemetryPoint,
|
||||
} from "../../../src/native/contracts";
|
||||
import type { CursorNativeAdapter } from "../cursor/adapter";
|
||||
import type { NativeBridgeStateStore } from "../store";
|
||||
|
||||
interface CursorServiceOptions {
|
||||
store: NativeBridgeStateStore;
|
||||
adapter: CursorNativeAdapter;
|
||||
}
|
||||
|
||||
export class CursorService {
|
||||
constructor(private readonly options: CursorServiceOptions) {}
|
||||
|
||||
async getCapabilities(): Promise<CursorCapabilities> {
|
||||
const capabilities = await this.options.adapter.getCapabilities();
|
||||
this.options.store.setCursorCapabilities(capabilities);
|
||||
return capabilities;
|
||||
}
|
||||
|
||||
async getTelemetry(videoPath?: string | null): Promise<CursorTelemetryPoint[]> {
|
||||
const result = await this.options.adapter.getTelemetry(videoPath);
|
||||
if (!result.success) {
|
||||
throw new Error(result.message || result.error || "Failed to load cursor telemetry");
|
||||
}
|
||||
|
||||
const resolvedVideoPath = videoPath ?? this.options.store.getState().project.currentVideoPath;
|
||||
if (resolvedVideoPath) {
|
||||
this.options.store.markCursorTelemetryLoaded(resolvedVideoPath, result.samples.length);
|
||||
}
|
||||
|
||||
return result.samples;
|
||||
}
|
||||
|
||||
async getRecordingData(videoPath?: string | null): Promise<CursorRecordingData> {
|
||||
const data = await this.options.adapter.getRecordingData(videoPath);
|
||||
const resolvedVideoPath = videoPath ?? this.options.store.getState().project.currentVideoPath;
|
||||
if (resolvedVideoPath) {
|
||||
this.options.store.markCursorTelemetryLoaded(resolvedVideoPath, data.samples.length);
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import type {
|
||||
ProjectContext,
|
||||
ProjectFileResult,
|
||||
ProjectPathResult,
|
||||
} from "../../../src/native/contracts";
|
||||
import type { NativeBridgeStateStore } from "../store";
|
||||
|
||||
interface ProjectServiceOptions {
|
||||
store: NativeBridgeStateStore;
|
||||
getCurrentProjectPath: () => string | null;
|
||||
getCurrentVideoPath: () => string | null;
|
||||
saveProjectFile: (
|
||||
projectData: unknown,
|
||||
suggestedName?: string,
|
||||
existingProjectPath?: string,
|
||||
) => Promise<ProjectFileResult>;
|
||||
loadProjectFile: (projectFolder?: string) => Promise<ProjectFileResult>;
|
||||
loadCurrentProjectFile: () => Promise<ProjectFileResult>;
|
||||
loadProjectFileFromPath: (path: string) => Promise<ProjectFileResult>;
|
||||
setCurrentVideoPath: (path: string) => ProjectPathResult | Promise<ProjectPathResult>;
|
||||
getCurrentVideoPathResult: () => ProjectPathResult;
|
||||
clearCurrentVideoPath: () => ProjectPathResult;
|
||||
}
|
||||
|
||||
export class ProjectService {
|
||||
constructor(private readonly options: ProjectServiceOptions) {}
|
||||
|
||||
getCurrentContext(): ProjectContext {
|
||||
const context = {
|
||||
currentProjectPath: this.options.getCurrentProjectPath(),
|
||||
currentVideoPath: this.options.getCurrentVideoPath(),
|
||||
};
|
||||
|
||||
this.options.store.setProjectContext(context);
|
||||
return context;
|
||||
}
|
||||
|
||||
async saveProjectFile(
|
||||
projectData: unknown,
|
||||
suggestedName?: string,
|
||||
existingProjectPath?: string,
|
||||
) {
|
||||
const result = await this.options.saveProjectFile(
|
||||
projectData,
|
||||
suggestedName,
|
||||
existingProjectPath,
|
||||
);
|
||||
this.getCurrentContext();
|
||||
return result;
|
||||
}
|
||||
|
||||
async loadProjectFile(projectFolder?: string) {
|
||||
const result = await this.options.loadProjectFile(projectFolder);
|
||||
this.getCurrentContext();
|
||||
return result;
|
||||
}
|
||||
|
||||
async loadCurrentProjectFile() {
|
||||
const result = await this.options.loadCurrentProjectFile();
|
||||
this.getCurrentContext();
|
||||
return result;
|
||||
}
|
||||
|
||||
async loadProjectFileFromPath(path: string) {
|
||||
const result = await this.options.loadProjectFileFromPath(path);
|
||||
this.getCurrentContext();
|
||||
return result;
|
||||
}
|
||||
|
||||
async setCurrentVideoPath(path: string) {
|
||||
const result = await this.options.setCurrentVideoPath(path);
|
||||
this.getCurrentContext();
|
||||
return result;
|
||||
}
|
||||
|
||||
getCurrentVideoPath() {
|
||||
const result = this.options.getCurrentVideoPathResult();
|
||||
this.getCurrentContext();
|
||||
return result;
|
||||
}
|
||||
|
||||
clearCurrentVideoPath() {
|
||||
const result = this.options.clearCurrentVideoPath();
|
||||
this.getCurrentContext();
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import type {
|
||||
CursorCapabilities,
|
||||
NativePlatform,
|
||||
SystemCapabilities,
|
||||
} from "../../../src/native/contracts";
|
||||
import { NATIVE_BRIDGE_VERSION } from "../../../src/native/contracts";
|
||||
import type { NativeBridgeStateStore } from "../store";
|
||||
|
||||
interface SystemServiceOptions {
|
||||
store: NativeBridgeStateStore;
|
||||
getPlatform: () => NativePlatform;
|
||||
getAssetBasePath: () => string | null;
|
||||
getCursorCapabilities: () => Promise<CursorCapabilities>;
|
||||
}
|
||||
|
||||
export class SystemService {
|
||||
constructor(private readonly options: SystemServiceOptions) {}
|
||||
|
||||
getPlatform() {
|
||||
return this.options.getPlatform();
|
||||
}
|
||||
|
||||
getAssetBasePath() {
|
||||
return this.options.getAssetBasePath();
|
||||
}
|
||||
|
||||
async getCapabilities(): Promise<SystemCapabilities> {
|
||||
const platform = this.getPlatform();
|
||||
const cursorCapabilities = await this.options.getCursorCapabilities();
|
||||
|
||||
const capabilities: SystemCapabilities = {
|
||||
bridgeVersion: NATIVE_BRIDGE_VERSION,
|
||||
platform,
|
||||
cursor: cursorCapabilities,
|
||||
project: {
|
||||
currentContext: true,
|
||||
},
|
||||
};
|
||||
|
||||
this.options.store.setSystemCapabilities(capabilities);
|
||||
return capabilities;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import type {
|
||||
CursorCapabilities,
|
||||
NativePlatform,
|
||||
ProjectContext,
|
||||
SystemCapabilities,
|
||||
} from "../../src/native/contracts";
|
||||
|
||||
export interface NativeBridgeState {
|
||||
system: {
|
||||
platform: NativePlatform;
|
||||
capabilities: SystemCapabilities | null;
|
||||
};
|
||||
project: ProjectContext;
|
||||
cursor: {
|
||||
capabilities: CursorCapabilities | null;
|
||||
lastTelemetryLoad: {
|
||||
videoPath: string;
|
||||
sampleCount: number;
|
||||
loadedAt: number;
|
||||
} | null;
|
||||
};
|
||||
}
|
||||
|
||||
export class NativeBridgeStateStore {
|
||||
private state: NativeBridgeState;
|
||||
|
||||
constructor(platform: NativePlatform) {
|
||||
this.state = {
|
||||
system: {
|
||||
platform,
|
||||
capabilities: null,
|
||||
},
|
||||
project: {
|
||||
currentProjectPath: null,
|
||||
currentVideoPath: null,
|
||||
},
|
||||
cursor: {
|
||||
capabilities: null,
|
||||
lastTelemetryLoad: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
getState() {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
setProjectContext(project: ProjectContext) {
|
||||
this.state = {
|
||||
...this.state,
|
||||
project,
|
||||
};
|
||||
}
|
||||
|
||||
setSystemCapabilities(capabilities: SystemCapabilities) {
|
||||
this.state = {
|
||||
...this.state,
|
||||
system: {
|
||||
...this.state.system,
|
||||
capabilities,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
setCursorCapabilities(capabilities: CursorCapabilities) {
|
||||
this.state = {
|
||||
...this.state,
|
||||
cursor: {
|
||||
...this.state.cursor,
|
||||
capabilities,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
markCursorTelemetryLoaded(videoPath: string, sampleCount: number) {
|
||||
this.state = {
|
||||
...this.state,
|
||||
cursor: {
|
||||
...this.state.cursor,
|
||||
lastTelemetryLoad: {
|
||||
videoPath,
|
||||
sampleCount,
|
||||
loadedAt: Date.now(),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
# Native capture helpers
|
||||
|
||||
## macOS
|
||||
|
||||
macOS native recording will use a ScreenCaptureKit helper with the same process boundary as the Windows WGC helper:
|
||||
|
||||
1. Electron resolves the selected source, output paths, and user-selected devices.
|
||||
2. The helper receives one structured JSON request.
|
||||
3. The helper owns ScreenCaptureKit/AVFoundation capture, timing, encoding, and muxing.
|
||||
4. Electron persists the resulting media/session manifest and reports helper errors explicitly.
|
||||
|
||||
Helper locations:
|
||||
|
||||
1. `OPENSCREEN_SCK_CAPTURE_EXE`, for local development and diagnostics.
|
||||
2. `electron/native/screencapturekit/build/openscreen-screencapturekit-helper`, for locally built Swift output.
|
||||
3. `electron/native/bin/darwin-arm64/openscreen-screencapturekit-helper` or `electron/native/bin/darwin-x64/openscreen-screencapturekit-helper`, for packaged prebuilt helpers.
|
||||
|
||||
The macOS cursor-shape helper is resolved from `OPENSCREEN_MAC_CURSOR_HELPER_EXE` first, then the matching `openscreen-macos-cursor-helper` binary in the same local build and packaged `electron/native/bin/darwin-${arch}` directories.
|
||||
|
||||
Build the macOS helper with:
|
||||
|
||||
```bash
|
||||
npm run build:native:mac
|
||||
```
|
||||
|
||||
On non-macOS hosts this command exits successfully and does not affect Windows/Linux development. On macOS it builds the Swift package at `electron/native/screencapturekit`, writes the development binaries to `electron/native/screencapturekit/build`, and copies redistributable binaries to `electron/native/bin/darwin-${arch}`.
|
||||
|
||||
The current helper implementation supports display/window ScreenCaptureKit video capture, cursor exclusion through `SCStreamConfiguration.showsCursor`, H.264 encoding, MP4 muxing, and ScreenCaptureKit system audio. It also attempts native ScreenCaptureKit microphone capture when the running macOS version exposes that capability. Webcam recording currently stays as an Electron sidecar and is attached to the same recording session after the native screen capture stops.
|
||||
|
||||
Electron exposes `is-native-mac-capture-available` for capability probing. It resolves the same helper locations listed above and reports `missing-helper` until a Swift helper binary is present. When available, macOS recording routes screen/window capture through the native helper so editable cursor recordings do not bake the system cursor into the video. Cursor positions are sampled in Electron; when the cursor helper is available and Accessibility is granted, samples are also tagged with link/text cursor hints such as `pointer`.
|
||||
|
||||
See `docs/engineering/macos-native-recorder-roadmap.md` for the contract, rollout phases, and SSOT rules.
|
||||
|
||||
## Windows
|
||||
|
||||
Windows native recording is resolved from one of these locations:
|
||||
|
||||
1. `OPENSCREEN_WGC_CAPTURE_EXE`, for local development and diagnostics.
|
||||
2. `electron/native/wgc-capture/build/wgc-capture.exe`, for a locally built Ninja helper.
|
||||
3. `electron/native/wgc-capture/build/Release/wgc-capture.exe`, for a locally built multi-config helper.
|
||||
4. `electron/native/bin/win32-x64/wgc-capture.exe` or `electron/native/bin/win32-arm64/wgc-capture.exe`, for packaged prebuilt helpers.
|
||||
|
||||
Build the Windows helper with:
|
||||
|
||||
```powershell
|
||||
npm run build:native:win
|
||||
```
|
||||
|
||||
The build writes the CMake output to `electron/native/wgc-capture/build/wgc-capture.exe` and copies the redistributable binary to `electron/native/bin/win32-x64/wgc-capture.exe`.
|
||||
|
||||
The helper contract is process-based: the app starts the process with one JSON argument and sends commands on stdin. `stop\n` finalizes the recording. During migration the helper prints both newline-delimited JSON events and the legacy text messages `Recording started` / `Recording stopped. Output path: <path>`.
|
||||
|
||||
Current V2 JSON shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": 2,
|
||||
"recordingId": 123,
|
||||
"sourceType": "display",
|
||||
"sourceId": "screen:0:0",
|
||||
"displayId": 1,
|
||||
"windowHandle": null,
|
||||
"outputPath": "C:\\path\\recording-123.mp4",
|
||||
"videoWidth": 1920,
|
||||
"videoHeight": 1080,
|
||||
"fps": 60,
|
||||
"captureSystemAudio": false,
|
||||
"captureMic": false,
|
||||
"microphoneDeviceId": "default",
|
||||
"microphoneDeviceName": "Microphone (NVIDIA Broadcast)",
|
||||
"microphoneGain": 1.4,
|
||||
"webcamEnabled": true,
|
||||
"webcamDeviceId": "default",
|
||||
"webcamDeviceName": "Camera (NVIDIA Broadcast)",
|
||||
"webcamWidth": 1280,
|
||||
"webcamHeight": 720,
|
||||
"webcamFps": 30,
|
||||
"outputs": {
|
||||
"screenPath": "C:\\path\\recording-123.mp4"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The current helper implementation supports display/window video capture, system audio loopback, selected-microphone capture, Media Foundation webcam capture, and a DirectShow webcam fallback for virtual cameras that are not exposed through Media Foundation. Webcam frames are currently composed into the primary MP4 as a bottom-right picture-in-picture overlay. Browser `deviceId` values do not always map to Media Foundation symbolic links or WASAPI endpoint IDs, so the renderer passes both browser IDs and user-visible device names. For microphones, the helper tries the requested WASAPI endpoint ID first, then resolves an active capture endpoint by `microphoneDeviceName`, then falls back to the default endpoint. For webcams, Electron resolves a matching DirectShow filter CLSID for the selected label; the helper uses Media Foundation first, then that exact DirectShow filter when the requested camera is absent from Media Foundation.
|
||||
|
||||
Smoke-test the helper with:
|
||||
|
||||
```powershell
|
||||
npm run test:wgc-helper:win
|
||||
npm run test:wgc-window:win
|
||||
npm run test:wgc-audio:win
|
||||
npm run test:wgc-mic:win
|
||||
npm run test:wgc-mixed-audio:win
|
||||
npm run test:wgc-webcam:win
|
||||
```
|
||||
|
||||
To validate a specific native webcam manually:
|
||||
|
||||
```powershell
|
||||
$env:OPENSCREEN_WGC_TEST_WEBCAM_DEVICE_NAME = "NVIDIA Broadcast"
|
||||
npm run test:wgc-webcam:win
|
||||
Remove-Item Env:OPENSCREEN_WGC_TEST_WEBCAM_DEVICE_NAME
|
||||
```
|
||||
|
||||
To validate a specific native microphone manually:
|
||||
|
||||
```powershell
|
||||
$env:OPENSCREEN_WGC_TEST_MICROPHONE_DEVICE_NAME = "Microphone (NVIDIA Broadcast)"
|
||||
npm run test:wgc-mic:win
|
||||
Remove-Item Env:OPENSCREEN_WGC_TEST_MICROPHONE_DEVICE_NAME
|
||||
```
|
||||
@@ -0,0 +1,30 @@
|
||||
// swift-tools-version: 5.9
|
||||
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "OpenScreenScreenCaptureKitHelper",
|
||||
platforms: [
|
||||
.macOS(.v13)
|
||||
],
|
||||
products: [
|
||||
.executable(
|
||||
name: "openscreen-screencapturekit-helper",
|
||||
targets: ["OpenScreenScreenCaptureKitHelper"]
|
||||
),
|
||||
.executable(
|
||||
name: "openscreen-macos-cursor-helper",
|
||||
targets: ["OpenScreenMacOSCursorHelper"]
|
||||
)
|
||||
],
|
||||
targets: [
|
||||
.executableTarget(
|
||||
name: "OpenScreenScreenCaptureKitHelper",
|
||||
path: "Sources/OpenScreenScreenCaptureKitHelper"
|
||||
),
|
||||
.executableTarget(
|
||||
name: "OpenScreenMacOSCursorHelper",
|
||||
path: "Sources/OpenScreenMacOSCursorHelper"
|
||||
)
|
||||
]
|
||||
)
|
||||
@@ -0,0 +1,352 @@
|
||||
import AppKit
|
||||
import ApplicationServices
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
|
||||
struct CursorHelperRequest: Decodable {
|
||||
let sampleIntervalMs: Int?
|
||||
}
|
||||
|
||||
struct CapturedCursorAsset {
|
||||
let id: String
|
||||
let imageDataUrl: String
|
||||
let width: Int
|
||||
let height: Int
|
||||
let hotspotX: Double
|
||||
let hotspotY: Double
|
||||
let scaleFactor: Double
|
||||
}
|
||||
|
||||
final class MouseButtonTracker {
|
||||
private let lock = NSLock()
|
||||
private var leftDownCount = 0
|
||||
private var leftUpCount = 0
|
||||
private var eventTap: CFMachPort?
|
||||
private var runLoopSource: CFRunLoopSource?
|
||||
|
||||
struct Events {
|
||||
let leftDownCount: Int
|
||||
let leftUpCount: Int
|
||||
}
|
||||
|
||||
func start() -> Bool {
|
||||
let mask =
|
||||
(1 << CGEventType.leftMouseDown.rawValue) |
|
||||
(1 << CGEventType.leftMouseUp.rawValue)
|
||||
guard let tap = CGEvent.tapCreate(
|
||||
tap: .cgSessionEventTap,
|
||||
place: .headInsertEventTap,
|
||||
options: .listenOnly,
|
||||
eventsOfInterest: CGEventMask(mask),
|
||||
callback: { _, type, event, userInfo in
|
||||
if let userInfo {
|
||||
let tracker = Unmanaged<MouseButtonTracker>.fromOpaque(userInfo).takeUnretainedValue()
|
||||
tracker.record(type)
|
||||
}
|
||||
return Unmanaged.passUnretained(event)
|
||||
},
|
||||
userInfo: UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque())
|
||||
) else {
|
||||
return false
|
||||
}
|
||||
|
||||
guard let source = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, tap, 0) else {
|
||||
return false
|
||||
}
|
||||
|
||||
eventTap = tap
|
||||
runLoopSource = source
|
||||
CFRunLoopAddSource(CFRunLoopGetCurrent(), source, .commonModes)
|
||||
CGEvent.tapEnable(tap: tap, enable: true)
|
||||
return true
|
||||
}
|
||||
|
||||
func pump() {
|
||||
CFRunLoopRunInMode(.defaultMode, 0.001, false)
|
||||
}
|
||||
|
||||
func consume() -> Events {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
let events = Events(leftDownCount: leftDownCount, leftUpCount: leftUpCount)
|
||||
leftDownCount = 0
|
||||
leftUpCount = 0
|
||||
return events
|
||||
}
|
||||
|
||||
private func record(_ type: CGEventType) {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
if type == .tapDisabledByTimeout || type == .tapDisabledByUserInput {
|
||||
reenableTap()
|
||||
return
|
||||
}
|
||||
if type == .leftMouseDown {
|
||||
leftDownCount += 1
|
||||
} else if type == .leftMouseUp {
|
||||
leftUpCount += 1
|
||||
}
|
||||
}
|
||||
|
||||
private func reenableTap() {
|
||||
if let eventTap {
|
||||
CGEvent.tapEnable(tap: eventTap, enable: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func emit(_ fields: [String: Any?]) {
|
||||
let compacted = fields.compactMapValues { $0 }
|
||||
if let data = try? JSONSerialization.data(withJSONObject: compacted, options: []),
|
||||
let line = String(data: data, encoding: .utf8)
|
||||
{
|
||||
print(line)
|
||||
fflush(stdout)
|
||||
}
|
||||
}
|
||||
|
||||
func stringAttribute(_ element: AXUIElement, _ attribute: String) -> String? {
|
||||
var value: CFTypeRef?
|
||||
let result = AXUIElementCopyAttributeValue(element, attribute as CFString, &value)
|
||||
guard result == .success else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return value as? String
|
||||
}
|
||||
|
||||
func parentElement(_ element: AXUIElement) -> AXUIElement? {
|
||||
var value: CFTypeRef?
|
||||
let result = AXUIElementCopyAttributeValue(element, kAXParentAttribute as CFString, &value)
|
||||
guard result == .success else {
|
||||
return nil
|
||||
}
|
||||
|
||||
guard CFGetTypeID(value) == AXUIElementGetTypeID() else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return (value as! AXUIElement)
|
||||
}
|
||||
|
||||
func roleDescription(_ element: AXUIElement) -> String? {
|
||||
var value: CFTypeRef?
|
||||
let result = AXUIElementCopyAttributeValue(element, kAXRoleDescriptionAttribute as CFString, &value)
|
||||
guard result == .success else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return value as? String
|
||||
}
|
||||
|
||||
func actionNames(_ element: AXUIElement) -> [String] {
|
||||
var value: CFArray?
|
||||
let result = AXUIElementCopyActionNames(element, &value)
|
||||
guard result == .success, let value else {
|
||||
return []
|
||||
}
|
||||
|
||||
return (value as NSArray).compactMap { $0 as? String }
|
||||
}
|
||||
func isTextInputRole(_ role: String?) -> Bool {
|
||||
role == "AXTextField" ||
|
||||
role == "AXTextArea" ||
|
||||
role == "AXTextView" ||
|
||||
role == "AXComboBox"
|
||||
}
|
||||
|
||||
func isPointerRole(_ role: String?, _ subrole: String?, _ description: String?) -> Bool {
|
||||
if role == "AXLink" ||
|
||||
subrole?.localizedCaseInsensitiveContains("link") == true ||
|
||||
description?.contains("link") == true
|
||||
{
|
||||
return true
|
||||
}
|
||||
|
||||
return role == "AXButton" ||
|
||||
role == "AXMenuButton" ||
|
||||
role == "AXPopUpButton" ||
|
||||
role == "AXCheckBox" ||
|
||||
role == "AXRadioButton" ||
|
||||
role == "AXSwitch" ||
|
||||
role == "AXDisclosureTriangle" ||
|
||||
role == "AXTab" ||
|
||||
role == "AXMenuItem"
|
||||
}
|
||||
|
||||
func cursorTypeForElement(_ element: AXUIElement) -> String? {
|
||||
var current: AXUIElement? = element
|
||||
|
||||
for _ in 0..<5 {
|
||||
guard let element = current else {
|
||||
break
|
||||
}
|
||||
|
||||
let role = stringAttribute(element, kAXRoleAttribute)
|
||||
let subrole = stringAttribute(element, kAXSubroleAttribute)
|
||||
let description = roleDescription(element)?.lowercased()
|
||||
|
||||
if isTextInputRole(role) {
|
||||
return "text"
|
||||
}
|
||||
|
||||
if isPointerRole(role, subrole, description) {
|
||||
return "pointer"
|
||||
}
|
||||
|
||||
current = parentElement(element)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func accessibilityPointForMouse() -> CGPoint {
|
||||
let mouse = NSEvent.mouseLocation
|
||||
let primaryHeight = NSScreen.screens.first?.frame.height ?? NSScreen.main?.frame.height ?? 0
|
||||
return CGPoint(x: mouse.x, y: primaryHeight - mouse.y)
|
||||
}
|
||||
|
||||
func currentCursorType() -> String? {
|
||||
guard AXIsProcessTrusted() else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let point = accessibilityPointForMouse()
|
||||
let systemWide = AXUIElementCreateSystemWide()
|
||||
var element: AXUIElement?
|
||||
let result = AXUIElementCopyElementAtPosition(
|
||||
systemWide,
|
||||
Float(point.x),
|
||||
Float(point.y),
|
||||
&element
|
||||
)
|
||||
|
||||
guard result == .success, let element else {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Returns nil for anything that is not a text/pointer affordance so the
|
||||
// renderer falls through to the natively captured cursor bitmap (this is
|
||||
// what makes default and custom cursors render as their real images).
|
||||
return cursorTypeForElement(element)
|
||||
}
|
||||
|
||||
func currentCursorAsset() -> CapturedCursorAsset? {
|
||||
guard let cursor = NSCursor.currentSystem ?? NSCursor.current as NSCursor? else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let image = cursor.image
|
||||
let pointSize = image.size
|
||||
guard pointSize.width > 0, pointSize.height > 0 else {
|
||||
return nil
|
||||
}
|
||||
|
||||
var proposedRect = NSRect(origin: .zero, size: pointSize)
|
||||
guard let cgImage = image.cgImage(forProposedRect: &proposedRect, context: nil, hints: nil) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let bitmap = NSBitmapImageRep(cgImage: cgImage)
|
||||
guard let png = bitmap.representation(using: .png, properties: [:]) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let pixelsWide = bitmap.pixelsWide
|
||||
let pixelsHigh = bitmap.pixelsHigh
|
||||
guard pixelsWide > 0, pixelsHigh > 0 else {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Intrinsic backing scale of the cursor image (e.g. 2.0 on Retina). The
|
||||
// renderer divides pixel dimensions/hotspot by this to recover point sizes.
|
||||
let scaleFactor = Double(pixelsWide) / Double(pointSize.width)
|
||||
let hotSpot = cursor.hotSpot
|
||||
|
||||
let digest = SHA256.hash(data: png)
|
||||
let id = digest.map { String(format: "%02x", $0) }.joined()
|
||||
let imageDataUrl = "data:image/png;base64,\(png.base64EncodedString())"
|
||||
|
||||
return CapturedCursorAsset(
|
||||
id: id,
|
||||
imageDataUrl: imageDataUrl,
|
||||
width: pixelsWide,
|
||||
height: pixelsHigh,
|
||||
hotspotX: hotSpot.x * scaleFactor,
|
||||
hotspotY: hotSpot.y * scaleFactor,
|
||||
scaleFactor: scaleFactor
|
||||
)
|
||||
}
|
||||
|
||||
func timestampMs() -> Int {
|
||||
Int(Date().timeIntervalSince1970 * 1000)
|
||||
}
|
||||
|
||||
func leftButtonDown() -> Bool {
|
||||
CGEventSource.buttonState(.hidSystemState, button: .left)
|
||||
}
|
||||
|
||||
func requestAccessibilityTrust() -> Bool {
|
||||
let options = [
|
||||
kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String: true
|
||||
] as CFDictionary
|
||||
return AXIsProcessTrustedWithOptions(options)
|
||||
}
|
||||
|
||||
let request: CursorHelperRequest
|
||||
if CommandLine.arguments.count >= 2,
|
||||
let data = CommandLine.arguments[1].data(using: .utf8),
|
||||
let decoded = try? JSONDecoder().decode(CursorHelperRequest.self, from: data)
|
||||
{
|
||||
request = decoded
|
||||
} else {
|
||||
request = CursorHelperRequest(sampleIntervalMs: nil)
|
||||
}
|
||||
|
||||
let intervalMs = max(8, request.sampleIntervalMs ?? 33)
|
||||
let accessibilityTrusted = requestAccessibilityTrust()
|
||||
let mouseTracker = MouseButtonTracker()
|
||||
let mouseTapReady = mouseTracker.start()
|
||||
emit([
|
||||
"type": "ready",
|
||||
"timestampMs": timestampMs(),
|
||||
"accessibilityTrusted": accessibilityTrusted,
|
||||
"mouseTapReady": mouseTapReady,
|
||||
])
|
||||
|
||||
// Process-wide set so each unique cursor shape is serialised at most once,
|
||||
// even if the user alternates between shapes (e.g. arrow → text → arrow).
|
||||
var emittedAssetIds = Set<String>()
|
||||
|
||||
while true {
|
||||
autoreleasepool {
|
||||
mouseTracker.pump()
|
||||
let mouseEvents = mouseTracker.consume()
|
||||
let asset = currentCursorAsset()
|
||||
// Only ship the (large) base64 payload the first time a cursor shape is seen;
|
||||
// subsequent samples reference it by assetId so stdout stays small.
|
||||
var assetPayload: [String: Any]?
|
||||
if let asset, emittedAssetIds.insert(asset.id).inserted {
|
||||
assetPayload = [
|
||||
"id": asset.id,
|
||||
"imageDataUrl": asset.imageDataUrl,
|
||||
"width": asset.width,
|
||||
"height": asset.height,
|
||||
"hotspotX": asset.hotspotX,
|
||||
"hotspotY": asset.hotspotY,
|
||||
"scaleFactor": asset.scaleFactor,
|
||||
]
|
||||
}
|
||||
emit([
|
||||
"type": "sample",
|
||||
"timestampMs": timestampMs(),
|
||||
"cursorType": currentCursorType(),
|
||||
"assetId": asset?.id,
|
||||
"asset": assetPayload,
|
||||
"leftButtonDown": leftButtonDown(),
|
||||
"leftButtonPressed": mouseEvents.leftDownCount > 0,
|
||||
"leftButtonReleased": mouseEvents.leftUpCount > 0,
|
||||
])
|
||||
Thread.sleep(forTimeInterval: Double(intervalMs) / 1000.0)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,673 @@
|
||||
import AVFoundation
|
||||
import CoreGraphics
|
||||
import CoreMedia
|
||||
import Foundation
|
||||
import ScreenCaptureKit
|
||||
|
||||
struct Rectangle: Decodable {
|
||||
let x: Double
|
||||
let y: Double
|
||||
let width: Double
|
||||
let height: Double
|
||||
}
|
||||
|
||||
struct RecordingRequest: Decodable {
|
||||
struct Source: Decodable {
|
||||
let type: String
|
||||
let sourceId: String
|
||||
let displayId: UInt32?
|
||||
let windowId: UInt32?
|
||||
let bounds: Rectangle?
|
||||
}
|
||||
|
||||
struct Video: Decodable {
|
||||
let fps: Int
|
||||
let width: Int
|
||||
let height: Int
|
||||
let bitrate: Int?
|
||||
let hideSystemCursor: Bool
|
||||
}
|
||||
|
||||
struct Audio: Decodable {
|
||||
struct SystemAudio: Decodable {
|
||||
let enabled: Bool
|
||||
}
|
||||
|
||||
struct Microphone: Decodable {
|
||||
let enabled: Bool
|
||||
let deviceId: String?
|
||||
let deviceName: String?
|
||||
let gain: Double
|
||||
}
|
||||
|
||||
let system: SystemAudio
|
||||
let microphone: Microphone
|
||||
}
|
||||
|
||||
struct Webcam: Decodable {
|
||||
let enabled: Bool
|
||||
let deviceId: String?
|
||||
let deviceName: String?
|
||||
let width: Int
|
||||
let height: Int
|
||||
let fps: Int
|
||||
}
|
||||
|
||||
struct Cursor: Decodable {
|
||||
let mode: String
|
||||
}
|
||||
|
||||
struct Outputs: Decodable {
|
||||
let screenPath: String
|
||||
let manifestPath: String?
|
||||
}
|
||||
|
||||
let schemaVersion: Int?
|
||||
let recordingId: Int?
|
||||
let source: Source
|
||||
let video: Video
|
||||
let audio: Audio
|
||||
let webcam: Webcam
|
||||
let cursor: Cursor
|
||||
let outputs: Outputs
|
||||
}
|
||||
|
||||
enum HelperError: Error, CustomStringConvertible {
|
||||
case invalidArguments
|
||||
case unsupportedMacOS
|
||||
case unsupportedFeature(String)
|
||||
case sourceNotFound(String)
|
||||
case invalidSourceType(String)
|
||||
case permissionDenied(String)
|
||||
case writerSetupFailed(String)
|
||||
|
||||
var description: String {
|
||||
switch self {
|
||||
case .invalidArguments:
|
||||
return "Expected one JSON recording request argument."
|
||||
case .unsupportedMacOS:
|
||||
return "ScreenCaptureKit recording requires macOS 13 or newer."
|
||||
case .unsupportedFeature(let message):
|
||||
return message
|
||||
case .sourceNotFound(let message):
|
||||
return message
|
||||
case .invalidSourceType(let sourceType):
|
||||
return "Unsupported source type: \(sourceType)."
|
||||
case .permissionDenied(let message):
|
||||
return message
|
||||
case .writerSetupFailed(let message):
|
||||
return message
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func emit(_ fields: [String: Any]) {
|
||||
if let data = try? JSONSerialization.data(withJSONObject: fields, options: []),
|
||||
let line = String(data: data, encoding: .utf8)
|
||||
{
|
||||
print(line)
|
||||
fflush(stdout)
|
||||
}
|
||||
}
|
||||
|
||||
func emitError(code: String, message: String) {
|
||||
emit([
|
||||
"event": "error",
|
||||
"code": code,
|
||||
"message": message,
|
||||
])
|
||||
}
|
||||
|
||||
@available(macOS 13.0, *)
|
||||
final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate {
|
||||
private struct CaptureTarget {
|
||||
let filter: SCContentFilter
|
||||
let width: Int
|
||||
let height: Int
|
||||
}
|
||||
|
||||
private let request: RecordingRequest
|
||||
private let sampleQueue = DispatchQueue(label: "app.openscreen.sck-helper.samples")
|
||||
private let stateQueue = DispatchQueue(label: "app.openscreen.sck-helper.state")
|
||||
private var stream: SCStream?
|
||||
private var writer: AVAssetWriter?
|
||||
private var videoInput: AVAssetWriterInput?
|
||||
private var systemAudioInput: AVAssetWriterInput?
|
||||
private var microphoneAudioInput: AVAssetWriterInput?
|
||||
private var didStartWriting = false
|
||||
private var didEmitRecordingStarted = false
|
||||
private var isStopping = false
|
||||
private var isPaused = false
|
||||
private var pauseStartedAt: CMTime?
|
||||
private var totalPausedDuration = CMTime.zero
|
||||
private var nativeMicrophoneEnabled = false
|
||||
private var outputWidth = 1920
|
||||
private var outputHeight = 1080
|
||||
private let microphoneOutputTypeRawValue = 2
|
||||
private let hostClock = CMClockGetHostTimeClock()
|
||||
|
||||
init(request: RecordingRequest) {
|
||||
self.request = request
|
||||
}
|
||||
|
||||
func start() async throws {
|
||||
try ensureRequestedPermissions()
|
||||
|
||||
let content = try await SCShareableContent.excludingDesktopWindows(
|
||||
false,
|
||||
onScreenWindowsOnly: true
|
||||
)
|
||||
let target = try makeCaptureTarget(from: content)
|
||||
outputWidth = target.width
|
||||
outputHeight = target.height
|
||||
let configuration = makeStreamConfiguration()
|
||||
let stream = SCStream(filter: target.filter, configuration: configuration, delegate: self)
|
||||
|
||||
try stream.addStreamOutput(self, type: .screen, sampleHandlerQueue: sampleQueue)
|
||||
if request.audio.system.enabled {
|
||||
try stream.addStreamOutput(self, type: .audio, sampleHandlerQueue: sampleQueue)
|
||||
}
|
||||
if nativeMicrophoneEnabled {
|
||||
guard let microphoneOutputType = SCStreamOutputType(rawValue: microphoneOutputTypeRawValue) else {
|
||||
throw HelperError.unsupportedFeature(
|
||||
"Native microphone capture requires a macOS version with ScreenCaptureKit microphone output."
|
||||
)
|
||||
}
|
||||
try stream.addStreamOutput(self, type: microphoneOutputType, sampleHandlerQueue: sampleQueue)
|
||||
}
|
||||
try setupWriter()
|
||||
|
||||
self.stream = stream
|
||||
emit(["event": "ready", "schemaVersion": 1])
|
||||
try await stream.startCapture()
|
||||
}
|
||||
|
||||
func stop() async {
|
||||
let shouldStop = stateQueue.sync {
|
||||
if isStopping {
|
||||
return false
|
||||
}
|
||||
isStopping = true
|
||||
return true
|
||||
}
|
||||
if !shouldStop {
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
try await stream?.stopCapture()
|
||||
} catch {
|
||||
emit([
|
||||
"event": "warning",
|
||||
"code": "stop-capture-failed",
|
||||
"message": "\(error)",
|
||||
])
|
||||
}
|
||||
|
||||
await finishWriter()
|
||||
}
|
||||
|
||||
func pause() {
|
||||
let didPause = stateQueue.sync {
|
||||
if isStopping || isPaused {
|
||||
return false
|
||||
}
|
||||
|
||||
isPaused = true
|
||||
pauseStartedAt = CMClockGetTime(hostClock)
|
||||
return true
|
||||
}
|
||||
|
||||
if didPause {
|
||||
emit([
|
||||
"event": "recording-paused",
|
||||
"timestampMs": Int(Date().timeIntervalSince1970 * 1000),
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
func resume() {
|
||||
let didResume = stateQueue.sync {
|
||||
if isStopping || !isPaused {
|
||||
return false
|
||||
}
|
||||
|
||||
if let pauseStartedAt {
|
||||
let now = CMClockGetTime(hostClock)
|
||||
totalPausedDuration = CMTimeAdd(
|
||||
totalPausedDuration,
|
||||
CMTimeSubtract(now, pauseStartedAt)
|
||||
)
|
||||
}
|
||||
isPaused = false
|
||||
pauseStartedAt = nil
|
||||
return true
|
||||
}
|
||||
|
||||
if didResume {
|
||||
emit([
|
||||
"event": "recording-resumed",
|
||||
"timestampMs": Int(Date().timeIntervalSince1970 * 1000),
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
func stream(_ stream: SCStream, didStopWithError error: Error) {
|
||||
emitError(code: "capture-stopped-with-error", message: "\(error)")
|
||||
Task {
|
||||
await stop()
|
||||
}
|
||||
}
|
||||
|
||||
func stream(_ stream: SCStream, didOutputSampleBuffer sampleBuffer: CMSampleBuffer, of type: SCStreamOutputType) {
|
||||
guard CMSampleBufferDataIsReady(sampleBuffer) else {
|
||||
return
|
||||
}
|
||||
let pauseState = currentPauseState()
|
||||
if pauseState.paused {
|
||||
return
|
||||
}
|
||||
guard let sampleBuffer = retimedSampleBuffer(sampleBuffer, subtracting: pauseState.offset) else {
|
||||
return
|
||||
}
|
||||
|
||||
if type == .audio {
|
||||
appendAudioSampleBuffer(sampleBuffer, to: systemAudioInput)
|
||||
return
|
||||
}
|
||||
|
||||
if type.rawValue == microphoneOutputTypeRawValue {
|
||||
appendAudioSampleBuffer(sampleBuffer, to: microphoneAudioInput)
|
||||
return
|
||||
}
|
||||
|
||||
guard type == .screen else {
|
||||
return
|
||||
}
|
||||
guard isCompleteFrame(sampleBuffer) else {
|
||||
return
|
||||
}
|
||||
guard let videoInput, let writer else {
|
||||
return
|
||||
}
|
||||
let presentationTime = CMSampleBufferGetPresentationTimeStamp(sampleBuffer)
|
||||
if !didStartWriting {
|
||||
writer.startWriting()
|
||||
writer.startSession(atSourceTime: presentationTime)
|
||||
didStartWriting = true
|
||||
}
|
||||
|
||||
if videoInput.isReadyForMoreMediaData {
|
||||
if videoInput.append(sampleBuffer), !didEmitRecordingStarted {
|
||||
didEmitRecordingStarted = true
|
||||
emit([
|
||||
"event": "recording-started",
|
||||
"timestampMs": Int(Date().timeIntervalSince1970 * 1000),
|
||||
"width": outputWidth,
|
||||
"height": outputHeight,
|
||||
])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func ensureRequestedPermissions() throws {
|
||||
if !CGPreflightScreenCaptureAccess() {
|
||||
let granted = CGRequestScreenCaptureAccess()
|
||||
if !granted {
|
||||
throw HelperError.permissionDenied("Screen recording permission is required for ScreenCaptureKit capture.")
|
||||
}
|
||||
}
|
||||
|
||||
if request.audio.microphone.enabled {
|
||||
switch AVCaptureDevice.authorizationStatus(for: .audio) {
|
||||
case .authorized:
|
||||
break
|
||||
case .notDetermined:
|
||||
let semaphore = DispatchSemaphore(value: 0)
|
||||
AVCaptureDevice.requestAccess(for: .audio) { _ in
|
||||
semaphore.signal()
|
||||
}
|
||||
let waitResult = semaphore.wait(timeout: .now() + 30)
|
||||
if waitResult == .timedOut || AVCaptureDevice.authorizationStatus(for: .audio) != .authorized {
|
||||
throw HelperError.permissionDenied("Microphone permission is required for native microphone capture.")
|
||||
}
|
||||
default:
|
||||
throw HelperError.permissionDenied("Microphone permission is required for native microphone capture.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func makeCaptureTarget(from content: SCShareableContent) throws -> CaptureTarget {
|
||||
switch request.source.type {
|
||||
case "display":
|
||||
guard let displayId = request.source.displayId else {
|
||||
throw HelperError.sourceNotFound("Display capture requires source.displayId.")
|
||||
}
|
||||
guard let display = content.displays.first(where: { $0.displayID == displayId }) else {
|
||||
throw HelperError.sourceNotFound("No ScreenCaptureKit display found for id \(displayId).")
|
||||
}
|
||||
let width = Int(CGDisplayPixelsWide(display.displayID))
|
||||
let height = Int(CGDisplayPixelsHigh(display.displayID))
|
||||
return CaptureTarget(
|
||||
filter: SCContentFilter(display: display, excludingWindows: []),
|
||||
width: clampCaptureDimension(width, fallback: request.video.width),
|
||||
height: clampCaptureDimension(height, fallback: request.video.height)
|
||||
)
|
||||
case "window":
|
||||
guard let windowId = request.source.windowId else {
|
||||
throw HelperError.sourceNotFound("Window capture requires source.windowId.")
|
||||
}
|
||||
guard let window = content.windows.first(where: { $0.windowID == windowId }) else {
|
||||
throw HelperError.sourceNotFound("No ScreenCaptureKit window found for id \(windowId).")
|
||||
}
|
||||
let candidateDisplay = content.displays.first {
|
||||
$0.frame.intersects(window.frame) || $0.frame.contains(CGPoint(x: window.frame.midX, y: window.frame.midY))
|
||||
}
|
||||
let scaleFactor = Self.scaleFactor(for: candidateDisplay?.displayID ?? CGMainDisplayID())
|
||||
let width = Int(window.frame.width) * scaleFactor
|
||||
let height = Int(window.frame.height) * scaleFactor
|
||||
return CaptureTarget(
|
||||
filter: SCContentFilter(desktopIndependentWindow: window),
|
||||
width: clampCaptureDimension(width, fallback: request.video.width),
|
||||
height: clampCaptureDimension(height, fallback: request.video.height)
|
||||
)
|
||||
default:
|
||||
throw HelperError.invalidSourceType(request.source.type)
|
||||
}
|
||||
}
|
||||
|
||||
private func makeStreamConfiguration() -> SCStreamConfiguration {
|
||||
let configuration = SCStreamConfiguration()
|
||||
configuration.width = outputWidth
|
||||
configuration.height = outputHeight
|
||||
configuration.minimumFrameInterval = CMTime(value: 1, timescale: CMTimeScale(max(1, request.video.fps)))
|
||||
configuration.queueDepth = 6
|
||||
configuration.showsCursor = !request.video.hideSystemCursor
|
||||
configuration.pixelFormat = kCVPixelFormatType_32BGRA
|
||||
configuration.sampleRate = 48_000
|
||||
configuration.channelCount = 2
|
||||
configuration.excludesCurrentProcessAudio = true
|
||||
configuration.capturesAudio = request.audio.system.enabled
|
||||
|
||||
if request.audio.microphone.enabled {
|
||||
guard supportsNativeMicrophoneCapture(streamConfig: configuration) else {
|
||||
nativeMicrophoneEnabled = false
|
||||
emit([
|
||||
"event": "warning",
|
||||
"code": "microphone-unavailable",
|
||||
"message": "Native microphone capture requires ScreenCaptureKit microphone support on this macOS version.",
|
||||
])
|
||||
return configuration
|
||||
}
|
||||
nativeMicrophoneEnabled = true
|
||||
configuration.capturesAudio = true
|
||||
configuration.setValue(true, forKey: "captureMicrophone")
|
||||
if let deviceId = resolveMicrophoneCaptureDeviceID() {
|
||||
configuration.setValue(deviceId, forKey: "microphoneCaptureDeviceID")
|
||||
}
|
||||
} else {
|
||||
nativeMicrophoneEnabled = false
|
||||
}
|
||||
|
||||
return configuration
|
||||
}
|
||||
|
||||
private func setupWriter() throws {
|
||||
let outputUrl = URL(fileURLWithPath: request.outputs.screenPath)
|
||||
try? FileManager.default.removeItem(at: outputUrl)
|
||||
try FileManager.default.createDirectory(
|
||||
at: outputUrl.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
|
||||
let writer = try AVAssetWriter(outputURL: outputUrl, fileType: .mp4)
|
||||
let settings: [String: Any] = [
|
||||
AVVideoCodecKey: AVVideoCodecType.h264,
|
||||
AVVideoWidthKey: outputWidth,
|
||||
AVVideoHeightKey: outputHeight,
|
||||
AVVideoCompressionPropertiesKey: [
|
||||
AVVideoAverageBitRateKey: request.video.bitrate ?? 18_000_000,
|
||||
AVVideoExpectedSourceFrameRateKey: request.video.fps,
|
||||
],
|
||||
]
|
||||
let input = AVAssetWriterInput(mediaType: .video, outputSettings: settings)
|
||||
input.expectsMediaDataInRealTime = true
|
||||
|
||||
guard writer.canAdd(input) else {
|
||||
throw HelperError.writerSetupFailed("Unable to add H.264 video input to AVAssetWriter.")
|
||||
}
|
||||
|
||||
writer.add(input)
|
||||
self.writer = writer
|
||||
self.videoInput = input
|
||||
|
||||
if request.audio.system.enabled {
|
||||
systemAudioInput = try addAudioInput(to: writer, bitRate: 192_000)
|
||||
}
|
||||
if nativeMicrophoneEnabled {
|
||||
microphoneAudioInput = try addAudioInput(to: writer, bitRate: 128_000)
|
||||
}
|
||||
}
|
||||
|
||||
private func finishWriter() async {
|
||||
guard let writer else {
|
||||
return
|
||||
}
|
||||
|
||||
videoInput?.markAsFinished()
|
||||
systemAudioInput?.markAsFinished()
|
||||
microphoneAudioInput?.markAsFinished()
|
||||
|
||||
await withCheckedContinuation { continuation in
|
||||
writer.finishWriting {
|
||||
continuation.resume()
|
||||
}
|
||||
}
|
||||
|
||||
if writer.status == .completed {
|
||||
emit([
|
||||
"event": "recording-stopped",
|
||||
"screenPath": request.outputs.screenPath,
|
||||
])
|
||||
} else {
|
||||
emitError(
|
||||
code: "writer-failed",
|
||||
message: writer.error.map { "\($0)" } ?? "AVAssetWriter failed with status \(writer.status.rawValue)."
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func addAudioInput(to writer: AVAssetWriter, bitRate: Int) throws -> AVAssetWriterInput {
|
||||
let settings: [String: Any] = [
|
||||
AVFormatIDKey: kAudioFormatMPEG4AAC,
|
||||
AVSampleRateKey: 48_000,
|
||||
AVNumberOfChannelsKey: 2,
|
||||
AVEncoderBitRateKey: bitRate,
|
||||
]
|
||||
let input = AVAssetWriterInput(mediaType: .audio, outputSettings: settings)
|
||||
input.expectsMediaDataInRealTime = true
|
||||
|
||||
guard writer.canAdd(input) else {
|
||||
throw HelperError.writerSetupFailed("Unable to add AAC audio input to AVAssetWriter.")
|
||||
}
|
||||
|
||||
writer.add(input)
|
||||
return input
|
||||
}
|
||||
|
||||
private func appendAudioSampleBuffer(_ sampleBuffer: CMSampleBuffer, to input: AVAssetWriterInput?) {
|
||||
guard didStartWriting else {
|
||||
return
|
||||
}
|
||||
guard let input, input.isReadyForMoreMediaData else {
|
||||
return
|
||||
}
|
||||
|
||||
input.append(sampleBuffer)
|
||||
}
|
||||
|
||||
private func currentPauseState() -> (paused: Bool, offset: CMTime) {
|
||||
stateQueue.sync {
|
||||
(isPaused, totalPausedDuration)
|
||||
}
|
||||
}
|
||||
|
||||
private func retimedSampleBuffer(_ sampleBuffer: CMSampleBuffer, subtracting offset: CMTime) -> CMSampleBuffer? {
|
||||
if !offset.isValid || offset == .zero {
|
||||
return sampleBuffer
|
||||
}
|
||||
|
||||
let sampleCount = CMSampleBufferGetNumSamples(sampleBuffer)
|
||||
if sampleCount <= 0 {
|
||||
return sampleBuffer
|
||||
}
|
||||
|
||||
var timing = Array(repeating: CMSampleTimingInfo(), count: sampleCount)
|
||||
let timingStatus = CMSampleBufferGetSampleTimingInfoArray(
|
||||
sampleBuffer,
|
||||
entryCount: sampleCount,
|
||||
arrayToFill: &timing,
|
||||
entriesNeededOut: nil
|
||||
)
|
||||
if timingStatus != noErr {
|
||||
emit([
|
||||
"event": "warning",
|
||||
"code": "sample-retime-failed",
|
||||
"message": "Unable to read sample timing info: \(timingStatus).",
|
||||
])
|
||||
return sampleBuffer
|
||||
}
|
||||
|
||||
for index in timing.indices {
|
||||
if timing[index].presentationTimeStamp.isValid {
|
||||
timing[index].presentationTimeStamp = CMTimeSubtract(
|
||||
timing[index].presentationTimeStamp,
|
||||
offset
|
||||
)
|
||||
}
|
||||
if timing[index].decodeTimeStamp.isValid {
|
||||
timing[index].decodeTimeStamp = CMTimeSubtract(timing[index].decodeTimeStamp, offset)
|
||||
}
|
||||
}
|
||||
|
||||
var retimedBuffer: CMSampleBuffer?
|
||||
let copyStatus = CMSampleBufferCreateCopyWithNewTiming(
|
||||
allocator: kCFAllocatorDefault,
|
||||
sampleBuffer: sampleBuffer,
|
||||
sampleTimingEntryCount: sampleCount,
|
||||
sampleTimingArray: &timing,
|
||||
sampleBufferOut: &retimedBuffer
|
||||
)
|
||||
if copyStatus != noErr {
|
||||
emit([
|
||||
"event": "warning",
|
||||
"code": "sample-retime-failed",
|
||||
"message": "Unable to copy sample timing info: \(copyStatus).",
|
||||
])
|
||||
return sampleBuffer
|
||||
}
|
||||
|
||||
return retimedBuffer
|
||||
}
|
||||
|
||||
private func isCompleteFrame(_ sampleBuffer: CMSampleBuffer) -> Bool {
|
||||
guard let attachments = CMSampleBufferGetSampleAttachmentsArray(
|
||||
sampleBuffer,
|
||||
createIfNecessary: false
|
||||
) as? [[SCStreamFrameInfo: Any]],
|
||||
let attachment = attachments.first,
|
||||
let statusRawValue = attachment[SCStreamFrameInfo.status] as? Int,
|
||||
let status = SCFrameStatus(rawValue: statusRawValue)
|
||||
else {
|
||||
return true
|
||||
}
|
||||
|
||||
return status == .complete
|
||||
}
|
||||
|
||||
private func clampCaptureDimension(_ value: Int, fallback: Int) -> Int {
|
||||
let requested = max(2, fallback)
|
||||
let candidate = value > 0 ? value : requested
|
||||
let clamped = min(candidate, requested)
|
||||
return max(2, clamped - (clamped % 2))
|
||||
}
|
||||
|
||||
private static func scaleFactor(for displayId: CGDirectDisplayID) -> Int {
|
||||
guard let mode = CGDisplayCopyDisplayMode(displayId) else {
|
||||
return 1
|
||||
}
|
||||
|
||||
return max(1, mode.pixelWidth / max(1, mode.width))
|
||||
}
|
||||
|
||||
private func supportsNativeMicrophoneCapture(streamConfig: SCStreamConfiguration) -> Bool {
|
||||
streamConfig.responds(to: Selector(("setCaptureMicrophone:"))) &&
|
||||
streamConfig.responds(to: Selector(("setMicrophoneCaptureDeviceID:"))) &&
|
||||
SCStreamOutputType(rawValue: microphoneOutputTypeRawValue) != nil
|
||||
}
|
||||
|
||||
private func resolveMicrophoneCaptureDeviceID() -> String? {
|
||||
let devices = AVCaptureDevice.devices(for: .audio)
|
||||
|
||||
if let deviceName = request.audio.microphone.deviceName?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!deviceName.isEmpty,
|
||||
let device = devices.first(where: { $0.localizedName == deviceName })
|
||||
{
|
||||
return device.uniqueID
|
||||
}
|
||||
|
||||
if let deviceId = request.audio.microphone.deviceId?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!deviceId.isEmpty,
|
||||
devices.contains(where: { $0.uniqueID == deviceId })
|
||||
{
|
||||
return deviceId
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@main
|
||||
struct OpenScreenScreenCaptureKitHelper {
|
||||
static func main() async {
|
||||
do {
|
||||
guard CommandLine.arguments.count == 2 else {
|
||||
throw HelperError.invalidArguments
|
||||
}
|
||||
|
||||
guard #available(macOS 13.0, *) else {
|
||||
throw HelperError.unsupportedMacOS
|
||||
}
|
||||
|
||||
let requestData = Data(CommandLine.arguments[1].utf8)
|
||||
let decoder = JSONDecoder()
|
||||
let request = try decoder.decode(RecordingRequest.self, from: requestData)
|
||||
let recorder = ScreenCaptureRecorder(request: request)
|
||||
let stopTask = Task.detached {
|
||||
while let line = readLine() {
|
||||
let command = line.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
switch command {
|
||||
case "pause":
|
||||
recorder.pause()
|
||||
case "resume":
|
||||
recorder.resume()
|
||||
case "stop":
|
||||
await recorder.stop()
|
||||
exit(0)
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try await recorder.start()
|
||||
await stopTask.value
|
||||
} catch let error as HelperError {
|
||||
emitError(code: "helper-error", message: error.description)
|
||||
exit(1)
|
||||
} catch {
|
||||
emitError(code: "helper-error", message: "\(error)")
|
||||
exit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
cmake_minimum_required(VERSION 3.20)
|
||||
|
||||
# The local Windows SDK image used by some contributors can miss gdi32.lib,
|
||||
# while CMake's default MSVC console template links it unconditionally. This
|
||||
# helper does not use GDI, so keep the standard library set minimal and explicit.
|
||||
set(CMAKE_CXX_STANDARD_LIBRARIES
|
||||
"kernel32.lib user32.lib shell32.lib ole32.lib oleaut32.lib uuid.lib comdlg32.lib advapi32.lib"
|
||||
CACHE STRING "" FORCE)
|
||||
|
||||
project(openscreen-wgc-capture LANGUAGES CXX)
|
||||
|
||||
set(CMAKE_CXX_STANDARD 20)
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
set(CMAKE_CXX_EXTENSIONS OFF)
|
||||
|
||||
add_executable(wgc-capture
|
||||
src/audio_sample_utils.cpp
|
||||
src/audio_sample_utils.h
|
||||
src/dshow_webcam_capture.cpp
|
||||
src/dshow_webcam_capture.h
|
||||
src/main.cpp
|
||||
src/mf_encoder.cpp
|
||||
src/mf_encoder.h
|
||||
src/monitor_utils.cpp
|
||||
src/monitor_utils.h
|
||||
src/wasapi_loopback_capture.cpp
|
||||
src/wasapi_loopback_capture.h
|
||||
src/webcam_capture.cpp
|
||||
src/webcam_capture.h
|
||||
src/wgc_session.cpp
|
||||
src/wgc_session.h
|
||||
)
|
||||
|
||||
target_compile_definitions(wgc-capture PRIVATE
|
||||
NOMINMAX
|
||||
WIN32_LEAN_AND_MEAN
|
||||
_WIN32_WINNT=0x0A00
|
||||
)
|
||||
|
||||
target_compile_options(wgc-capture PRIVATE /EHsc /W4 /utf-8 /await)
|
||||
|
||||
target_link_libraries(wgc-capture PRIVATE
|
||||
d3d11
|
||||
dxgi
|
||||
mf
|
||||
mfplat
|
||||
mfreadwrite
|
||||
mfuuid
|
||||
runtimeobject
|
||||
windowsapp
|
||||
)
|
||||
|
||||
add_executable(cursor-sampler
|
||||
src/cursor-sampler.cpp
|
||||
)
|
||||
|
||||
target_compile_definitions(cursor-sampler PRIVATE
|
||||
NOMINMAX
|
||||
_WIN32_WINNT=0x0A00
|
||||
)
|
||||
|
||||
target_compile_options(cursor-sampler PRIVATE /EHsc /W4 /utf-8)
|
||||
|
||||
target_link_libraries(cursor-sampler PRIVATE
|
||||
gdi32
|
||||
gdiplus
|
||||
)
|
||||
@@ -0,0 +1,439 @@
|
||||
#include "audio_sample_utils.h"
|
||||
|
||||
#include <mfapi.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <cmath>
|
||||
#include <cstring>
|
||||
#include <limits>
|
||||
|
||||
namespace {
|
||||
|
||||
bool isFloatFormat(const AudioInputFormat& format) {
|
||||
return format.subtype == MFAudioFormat_Float && format.bitsPerSample == 32;
|
||||
}
|
||||
|
||||
bool isPcmFormat(const AudioInputFormat& format, UINT32 bitsPerSample) {
|
||||
return format.subtype == MFAudioFormat_PCM && format.bitsPerSample == bitsPerSample;
|
||||
}
|
||||
|
||||
template <typename T>
|
||||
T clampTo(double value) {
|
||||
const double minValue = static_cast<double>(std::numeric_limits<T>::min());
|
||||
const double maxValue = static_cast<double>(std::numeric_limits<T>::max());
|
||||
return static_cast<T>(std::clamp(std::round(value), minValue, maxValue));
|
||||
}
|
||||
|
||||
size_t bytesPerSample(const AudioInputFormat& format) {
|
||||
return format.bitsPerSample / 8;
|
||||
}
|
||||
|
||||
double readSampleAsDouble(const BYTE* source, const AudioInputFormat& format, size_t frameIndex, UINT32 channelIndex) {
|
||||
if (!source || format.blockAlign == 0 || channelIndex >= format.channels) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
const size_t offset = frameIndex * format.blockAlign + channelIndex * bytesPerSample(format);
|
||||
if (isFloatFormat(format)) {
|
||||
return static_cast<double>(*reinterpret_cast<const float*>(source + offset));
|
||||
}
|
||||
if (isPcmFormat(format, 16)) {
|
||||
return static_cast<double>(*reinterpret_cast<const int16_t*>(source + offset)) / 32768.0;
|
||||
}
|
||||
if (isPcmFormat(format, 32)) {
|
||||
return static_cast<double>(*reinterpret_cast<const int32_t*>(source + offset)) / 2147483648.0;
|
||||
}
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
void writeSampleFromDouble(BYTE* destination, const AudioInputFormat& format, size_t frameIndex, UINT32 channelIndex, double value) {
|
||||
if (!destination || format.blockAlign == 0 || channelIndex >= format.channels) {
|
||||
return;
|
||||
}
|
||||
|
||||
const double clamped = std::clamp(value, -1.0, 1.0);
|
||||
const size_t offset = frameIndex * format.blockAlign + channelIndex * bytesPerSample(format);
|
||||
if (isFloatFormat(format)) {
|
||||
*reinterpret_cast<float*>(destination + offset) = static_cast<float>(clamped);
|
||||
return;
|
||||
}
|
||||
if (isPcmFormat(format, 16)) {
|
||||
*reinterpret_cast<int16_t*>(destination + offset) = clampTo<int16_t>(clamped * 32767.0);
|
||||
return;
|
||||
}
|
||||
if (isPcmFormat(format, 32)) {
|
||||
*reinterpret_cast<int32_t*>(destination + offset) = clampTo<int32_t>(clamped * 2147483647.0);
|
||||
}
|
||||
}
|
||||
|
||||
double readMappedChannel(const BYTE* source, const AudioInputFormat& format, size_t frameIndex, UINT32 targetChannel, UINT32 targetChannels) {
|
||||
if (format.channels == 0) {
|
||||
return 0.0;
|
||||
}
|
||||
if (format.channels == targetChannels && targetChannel < format.channels) {
|
||||
return readSampleAsDouble(source, format, frameIndex, targetChannel);
|
||||
}
|
||||
if (format.channels == 1) {
|
||||
return readSampleAsDouble(source, format, frameIndex, 0);
|
||||
}
|
||||
if (targetChannels == 1) {
|
||||
double sum = 0.0;
|
||||
for (UINT32 channel = 0; channel < format.channels; ++channel) {
|
||||
sum += readSampleAsDouble(source, format, frameIndex, channel);
|
||||
}
|
||||
return sum / static_cast<double>(format.channels);
|
||||
}
|
||||
return readSampleAsDouble(source, format, frameIndex, std::min(targetChannel, format.channels - 1));
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
constexpr int64_t HnsPerSecond = 10'000'000;
|
||||
|
||||
bool sameAudioFormatForMixing(const AudioInputFormat& left, const AudioInputFormat& right) {
|
||||
return left.subtype == right.subtype &&
|
||||
left.sampleRate == right.sampleRate &&
|
||||
left.channels == right.channels &&
|
||||
left.bitsPerSample == right.bitsPerSample &&
|
||||
left.blockAlign == right.blockAlign &&
|
||||
left.avgBytesPerSec == right.avgBytesPerSec;
|
||||
}
|
||||
|
||||
AudioInputFormat makeAacCompatibleAudioFormat(const AudioInputFormat& source) {
|
||||
AudioInputFormat format{};
|
||||
format.subtype = MFAudioFormat_PCM;
|
||||
format.sampleRate = source.sampleRate > 0 ? source.sampleRate : 48000;
|
||||
format.channels = 2;
|
||||
format.bitsPerSample = 16;
|
||||
format.blockAlign = format.channels * (format.bitsPerSample / 8);
|
||||
format.avgBytesPerSec = format.sampleRate * format.blockAlign;
|
||||
return format;
|
||||
}
|
||||
|
||||
void copyAudioWithGain(
|
||||
const BYTE* source,
|
||||
DWORD byteCount,
|
||||
const AudioInputFormat& format,
|
||||
double gain,
|
||||
std::vector<BYTE>& destination) {
|
||||
destination.resize(byteCount);
|
||||
if (!source || byteCount == 0) {
|
||||
std::fill(destination.begin(), destination.end(), static_cast<BYTE>(0));
|
||||
return;
|
||||
}
|
||||
|
||||
if (std::abs(gain - 1.0) < 0.0001) {
|
||||
std::memcpy(destination.data(), source, byteCount);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isFloatFormat(format)) {
|
||||
const auto* input = reinterpret_cast<const float*>(source);
|
||||
auto* output = reinterpret_cast<float*>(destination.data());
|
||||
const size_t sampleCount = byteCount / sizeof(float);
|
||||
for (size_t index = 0; index < sampleCount; index += 1) {
|
||||
output[index] = static_cast<float>(std::clamp(input[index] * gain, -1.0, 1.0));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (isPcmFormat(format, 16)) {
|
||||
const auto* input = reinterpret_cast<const int16_t*>(source);
|
||||
auto* output = reinterpret_cast<int16_t*>(destination.data());
|
||||
const size_t sampleCount = byteCount / sizeof(int16_t);
|
||||
for (size_t index = 0; index < sampleCount; index += 1) {
|
||||
output[index] = clampTo<int16_t>(static_cast<double>(input[index]) * gain);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (isPcmFormat(format, 32)) {
|
||||
const auto* input = reinterpret_cast<const int32_t*>(source);
|
||||
auto* output = reinterpret_cast<int32_t*>(destination.data());
|
||||
const size_t sampleCount = byteCount / sizeof(int32_t);
|
||||
for (size_t index = 0; index < sampleCount; index += 1) {
|
||||
output[index] = clampTo<int32_t>(static_cast<double>(input[index]) * gain);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
std::memcpy(destination.data(), source, byteCount);
|
||||
}
|
||||
|
||||
void convertAudioWithGain(
|
||||
const BYTE* source,
|
||||
DWORD byteCount,
|
||||
const AudioInputFormat& sourceFormat,
|
||||
const AudioInputFormat& targetFormat,
|
||||
double gain,
|
||||
std::vector<BYTE>& destination) {
|
||||
if (!source || byteCount == 0 || sourceFormat.blockAlign == 0 || targetFormat.blockAlign == 0 ||
|
||||
sourceFormat.sampleRate == 0 || targetFormat.sampleRate == 0 || sourceFormat.channels == 0 ||
|
||||
targetFormat.channels == 0) {
|
||||
destination.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
if (sameAudioFormatForMixing(sourceFormat, targetFormat)) {
|
||||
copyAudioWithGain(source, byteCount, targetFormat, gain, destination);
|
||||
return;
|
||||
}
|
||||
|
||||
const size_t sourceFrames = byteCount / sourceFormat.blockAlign;
|
||||
if (sourceFrames == 0) {
|
||||
destination.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
const double rateRatio = static_cast<double>(targetFormat.sampleRate) /
|
||||
static_cast<double>(sourceFormat.sampleRate);
|
||||
const size_t targetFrames = std::max<size_t>(1, static_cast<size_t>(std::llround(sourceFrames * rateRatio)));
|
||||
destination.assign(targetFrames * targetFormat.blockAlign, 0);
|
||||
|
||||
for (size_t targetFrame = 0; targetFrame < targetFrames; ++targetFrame) {
|
||||
const double sourcePosition = static_cast<double>(targetFrame) / rateRatio;
|
||||
const size_t sourceFrame = std::min(
|
||||
sourceFrames - 1,
|
||||
static_cast<size_t>(std::llround(sourcePosition)));
|
||||
for (UINT32 channel = 0; channel < targetFormat.channels; ++channel) {
|
||||
const double sample = readMappedChannel(
|
||||
source,
|
||||
sourceFormat,
|
||||
sourceFrame,
|
||||
channel,
|
||||
targetFormat.channels);
|
||||
writeSampleFromDouble(destination.data(), targetFormat, targetFrame, channel, sample * gain);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void mixAudioInPlace(
|
||||
std::vector<BYTE>& destination,
|
||||
const BYTE* source,
|
||||
DWORD byteCount,
|
||||
const AudioInputFormat& format) {
|
||||
if (!source || byteCount == 0 || destination.empty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const size_t mixByteCount = std::min(destination.size(), static_cast<size_t>(byteCount));
|
||||
|
||||
if (isFloatFormat(format)) {
|
||||
auto* output = reinterpret_cast<float*>(destination.data());
|
||||
const auto* input = reinterpret_cast<const float*>(source);
|
||||
const size_t sampleCount = mixByteCount / sizeof(float);
|
||||
for (size_t index = 0; index < sampleCount; index += 1) {
|
||||
output[index] = static_cast<float>(std::clamp(output[index] + input[index], -1.0f, 1.0f));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (isPcmFormat(format, 16)) {
|
||||
auto* output = reinterpret_cast<int16_t*>(destination.data());
|
||||
const auto* input = reinterpret_cast<const int16_t*>(source);
|
||||
const size_t sampleCount = mixByteCount / sizeof(int16_t);
|
||||
for (size_t index = 0; index < sampleCount; index += 1) {
|
||||
output[index] = clampTo<int16_t>(
|
||||
static_cast<double>(output[index]) + static_cast<double>(input[index]));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (isPcmFormat(format, 32)) {
|
||||
auto* output = reinterpret_cast<int32_t*>(destination.data());
|
||||
const auto* input = reinterpret_cast<const int32_t*>(source);
|
||||
const size_t sampleCount = mixByteCount / sizeof(int32_t);
|
||||
for (size_t index = 0; index < sampleCount; index += 1) {
|
||||
output[index] = clampTo<int32_t>(
|
||||
static_cast<double>(output[index]) + static_cast<double>(input[index]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AudioMixer::AudioMixer(
|
||||
const AudioInputFormat& format,
|
||||
const AudioInputFormat& systemFormat,
|
||||
const AudioInputFormat& microphoneFormat,
|
||||
bool includeSystem,
|
||||
bool includeMicrophone,
|
||||
double microphoneGain,
|
||||
OutputCallback output)
|
||||
: format_(format),
|
||||
systemFormat_(systemFormat),
|
||||
microphoneFormat_(microphoneFormat),
|
||||
includeSystem_(includeSystem),
|
||||
includeMicrophone_(includeMicrophone),
|
||||
microphoneGain_(microphoneGain),
|
||||
output_(std::move(output)) {}
|
||||
|
||||
AudioMixer::~AudioMixer() {
|
||||
stop();
|
||||
}
|
||||
|
||||
bool AudioMixer::start() {
|
||||
if (!output_ || format_.sampleRate == 0 || format_.blockAlign == 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
stopRequested_ = false;
|
||||
emittedFrames_ = 0;
|
||||
timelineStarted_ = false;
|
||||
paused_ = false;
|
||||
thread_ = std::thread([this] {
|
||||
mixLoop();
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
void AudioMixer::beginTimeline() {
|
||||
{
|
||||
std::scoped_lock lock(mutex_);
|
||||
systemQueue_.clear();
|
||||
microphoneQueue_.clear();
|
||||
emittedFrames_ = 0;
|
||||
timelineStarted_ = true;
|
||||
}
|
||||
cv_.notify_all();
|
||||
}
|
||||
|
||||
void AudioMixer::setPaused(bool paused) {
|
||||
{
|
||||
std::scoped_lock lock(mutex_);
|
||||
paused_ = paused;
|
||||
if (paused_) {
|
||||
systemQueue_.clear();
|
||||
microphoneQueue_.clear();
|
||||
}
|
||||
}
|
||||
cv_.notify_all();
|
||||
}
|
||||
|
||||
void AudioMixer::stop() {
|
||||
stopRequested_ = true;
|
||||
cv_.notify_all();
|
||||
if (thread_.joinable()) {
|
||||
thread_.join();
|
||||
}
|
||||
}
|
||||
|
||||
void AudioMixer::pushSystem(const BYTE* data, DWORD byteCount) {
|
||||
if (!includeSystem_ || stopRequested_) {
|
||||
return;
|
||||
}
|
||||
|
||||
{
|
||||
std::scoped_lock lock(mutex_);
|
||||
if (paused_) {
|
||||
return;
|
||||
}
|
||||
append(systemQueue_, data, byteCount, systemFormat_, 1.0);
|
||||
}
|
||||
cv_.notify_all();
|
||||
}
|
||||
|
||||
void AudioMixer::pushMicrophone(const BYTE* data, DWORD byteCount) {
|
||||
if (!includeMicrophone_ || stopRequested_) {
|
||||
return;
|
||||
}
|
||||
|
||||
{
|
||||
std::scoped_lock lock(mutex_);
|
||||
if (paused_) {
|
||||
return;
|
||||
}
|
||||
append(microphoneQueue_, data, byteCount, microphoneFormat_, microphoneGain_);
|
||||
}
|
||||
cv_.notify_all();
|
||||
}
|
||||
|
||||
void AudioMixer::append(
|
||||
std::vector<BYTE>& queue,
|
||||
const BYTE* data,
|
||||
DWORD byteCount,
|
||||
const AudioInputFormat& sourceFormat,
|
||||
double gain) {
|
||||
if (!data || byteCount == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
convertAudioWithGain(data, byteCount, sourceFormat, format_, gain, gainBuffer_);
|
||||
queue.insert(queue.end(), gainBuffer_.begin(), gainBuffer_.end());
|
||||
}
|
||||
|
||||
bool AudioMixer::pop(std::vector<BYTE>& queue, std::vector<BYTE>& chunk, size_t byteCount) {
|
||||
if (queue.empty()) {
|
||||
chunk.assign(byteCount, 0);
|
||||
return false;
|
||||
}
|
||||
|
||||
chunk.assign(byteCount, 0);
|
||||
const size_t copiedBytes = std::min(byteCount, queue.size());
|
||||
std::memcpy(chunk.data(), queue.data(), copiedBytes);
|
||||
queue.erase(queue.begin(), queue.begin() + static_cast<std::ptrdiff_t>(copiedBytes));
|
||||
return copiedBytes > 0;
|
||||
}
|
||||
|
||||
void AudioMixer::mixLoop() {
|
||||
const uint32_t chunkFrames = std::max<uint32_t>(1, format_.sampleRate / 100);
|
||||
const size_t chunkBytes = static_cast<size_t>(chunkFrames) * format_.blockAlign;
|
||||
std::vector<BYTE> mixedChunk;
|
||||
std::vector<BYTE> sourceChunk;
|
||||
std::chrono::steady_clock::time_point audioClockStart;
|
||||
bool audioClockStarted = false;
|
||||
|
||||
while (true) {
|
||||
{
|
||||
std::unique_lock lock(mutex_);
|
||||
cv_.wait_for(lock, std::chrono::milliseconds(20), [&] {
|
||||
const bool hasSystem = !includeSystem_ || systemQueue_.size() >= chunkBytes;
|
||||
const bool hasMicrophone = !includeMicrophone_ || microphoneQueue_.size() >= chunkBytes;
|
||||
const bool hasAnySource = !systemQueue_.empty() || !microphoneQueue_.empty();
|
||||
return stopRequested_.load() ||
|
||||
(timelineStarted_ && !paused_ && (hasSystem || hasMicrophone) && hasAnySource);
|
||||
});
|
||||
|
||||
if (stopRequested_) {
|
||||
break;
|
||||
}
|
||||
if (!timelineStarted_ || paused_) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const bool hasAnyQueuedAudio = !systemQueue_.empty() || !microphoneQueue_.empty();
|
||||
if (!hasAnyQueuedAudio) {
|
||||
continue;
|
||||
}
|
||||
|
||||
mixedChunk.assign(chunkBytes, 0);
|
||||
if (includeSystem_) {
|
||||
pop(systemQueue_, sourceChunk, chunkBytes);
|
||||
mixAudioInPlace(mixedChunk, sourceChunk.data(), static_cast<DWORD>(sourceChunk.size()), format_);
|
||||
}
|
||||
if (includeMicrophone_) {
|
||||
pop(microphoneQueue_, sourceChunk, chunkBytes);
|
||||
mixAudioInPlace(mixedChunk, sourceChunk.data(), static_cast<DWORD>(sourceChunk.size()), format_);
|
||||
}
|
||||
}
|
||||
|
||||
if (!audioClockStarted) {
|
||||
audioClockStart = std::chrono::steady_clock::now();
|
||||
audioClockStarted = true;
|
||||
}
|
||||
|
||||
const int64_t timestampHns =
|
||||
static_cast<int64_t>((emittedFrames_ * HnsPerSecond) / format_.sampleRate);
|
||||
const int64_t durationHns =
|
||||
static_cast<int64_t>((static_cast<uint64_t>(chunkFrames) * HnsPerSecond) / format_.sampleRate);
|
||||
if (!output_(mixedChunk.data(), static_cast<DWORD>(mixedChunk.size()), timestampHns, durationHns)) {
|
||||
stopRequested_ = true;
|
||||
break;
|
||||
}
|
||||
emittedFrames_ += chunkFrames;
|
||||
|
||||
const auto nextDeadline = audioClockStart +
|
||||
std::chrono::duration_cast<std::chrono::steady_clock::duration>(
|
||||
std::chrono::duration<double>(static_cast<double>(emittedFrames_) / format_.sampleRate));
|
||||
std::this_thread::sleep_until(nextDeadline);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
#pragma once
|
||||
|
||||
#include "mf_encoder.h"
|
||||
|
||||
#include <Windows.h>
|
||||
|
||||
#include <atomic>
|
||||
#include <condition_variable>
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
#include <mutex>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
bool sameAudioFormatForMixing(const AudioInputFormat& left, const AudioInputFormat& right);
|
||||
AudioInputFormat makeAacCompatibleAudioFormat(const AudioInputFormat& source);
|
||||
void copyAudioWithGain(
|
||||
const BYTE* source,
|
||||
DWORD byteCount,
|
||||
const AudioInputFormat& format,
|
||||
double gain,
|
||||
std::vector<BYTE>& destination);
|
||||
void convertAudioWithGain(
|
||||
const BYTE* source,
|
||||
DWORD byteCount,
|
||||
const AudioInputFormat& sourceFormat,
|
||||
const AudioInputFormat& targetFormat,
|
||||
double gain,
|
||||
std::vector<BYTE>& destination);
|
||||
void mixAudioInPlace(
|
||||
std::vector<BYTE>& destination,
|
||||
const BYTE* source,
|
||||
DWORD byteCount,
|
||||
const AudioInputFormat& format);
|
||||
|
||||
class AudioMixer {
|
||||
public:
|
||||
using OutputCallback = std::function<bool(const BYTE* data, DWORD byteCount, int64_t timestampHns, int64_t durationHns)>;
|
||||
|
||||
AudioMixer(
|
||||
const AudioInputFormat& format,
|
||||
const AudioInputFormat& systemFormat,
|
||||
const AudioInputFormat& microphoneFormat,
|
||||
bool includeSystem,
|
||||
bool includeMicrophone,
|
||||
double microphoneGain,
|
||||
OutputCallback output);
|
||||
~AudioMixer();
|
||||
|
||||
AudioMixer(const AudioMixer&) = delete;
|
||||
AudioMixer& operator=(const AudioMixer&) = delete;
|
||||
|
||||
bool start();
|
||||
void beginTimeline();
|
||||
void setPaused(bool paused);
|
||||
void stop();
|
||||
void pushSystem(const BYTE* data, DWORD byteCount);
|
||||
void pushMicrophone(const BYTE* data, DWORD byteCount);
|
||||
|
||||
private:
|
||||
void append(
|
||||
std::vector<BYTE>& queue,
|
||||
const BYTE* data,
|
||||
DWORD byteCount,
|
||||
const AudioInputFormat& sourceFormat,
|
||||
double gain);
|
||||
bool pop(std::vector<BYTE>& queue, std::vector<BYTE>& chunk, size_t byteCount);
|
||||
void mixLoop();
|
||||
|
||||
AudioInputFormat format_{};
|
||||
AudioInputFormat systemFormat_{};
|
||||
AudioInputFormat microphoneFormat_{};
|
||||
bool includeSystem_ = false;
|
||||
bool includeMicrophone_ = false;
|
||||
double microphoneGain_ = 1.0;
|
||||
OutputCallback output_;
|
||||
std::mutex mutex_;
|
||||
std::condition_variable cv_;
|
||||
std::vector<BYTE> systemQueue_;
|
||||
std::vector<BYTE> microphoneQueue_;
|
||||
std::vector<BYTE> gainBuffer_;
|
||||
std::thread thread_;
|
||||
std::atomic<bool> stopRequested_ = false;
|
||||
bool timelineStarted_ = false;
|
||||
bool paused_ = false;
|
||||
uint64_t emittedFrames_ = 0;
|
||||
};
|
||||
@@ -0,0 +1,482 @@
|
||||
#include <windows.h>
|
||||
#include <gdiplus.h>
|
||||
#include <objbase.h>
|
||||
|
||||
#include <atomic>
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <cinttypes>
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <iostream>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Global mouse-hook state
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
static HHOOK g_mouseHook = nullptr;
|
||||
static DWORD g_mainThreadId = 0;
|
||||
static std::atomic<int> g_leftDownCount{0};
|
||||
static std::atomic<int> g_leftUpCount{0};
|
||||
static std::atomic<bool> g_stop{false};
|
||||
static std::mutex g_stdoutMtx;
|
||||
|
||||
static LRESULT CALLBACK LowLevelMouseProc(int nCode, WPARAM wParam, LPARAM lParam) {
|
||||
if (nCode >= 0) {
|
||||
if (wParam == WM_LBUTTONDOWN) g_leftDownCount.fetch_add(1, std::memory_order_relaxed);
|
||||
else if (wParam == WM_LBUTTONUP) g_leftUpCount.fetch_add(1, std::memory_order_relaxed);
|
||||
}
|
||||
return CallNextHookEx(g_mouseHook, nCode, wParam, lParam);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Utilities
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
static int64_t nowMs() {
|
||||
return static_cast<int64_t>(
|
||||
std::chrono::duration_cast<std::chrono::milliseconds>(
|
||||
std::chrono::system_clock::now().time_since_epoch())
|
||||
.count());
|
||||
}
|
||||
|
||||
static void writeJsonLine(const std::string& json) {
|
||||
std::lock_guard<std::mutex> lock(g_stdoutMtx);
|
||||
std::cout << json << '\n';
|
||||
std::cout.flush();
|
||||
}
|
||||
|
||||
static std::string jsonEscape(const std::string& s) {
|
||||
std::string r;
|
||||
r.reserve(s.size());
|
||||
for (unsigned char c : s) {
|
||||
switch (c) {
|
||||
case '"': r += "\\\""; break;
|
||||
case '\\': r += "\\\\"; break;
|
||||
case '\n': r += "\\n"; break;
|
||||
case '\r': r += "\\r"; break;
|
||||
case '\t': r += "\\t"; break;
|
||||
default: r.push_back(static_cast<char>(c)); break;
|
||||
}
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
static const char kBase64Chars[] =
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
|
||||
static std::string base64Encode(const uint8_t* data, size_t len) {
|
||||
std::string out;
|
||||
out.reserve(((len + 2) / 3) * 4);
|
||||
for (size_t i = 0; i < len; i += 3) {
|
||||
const uint32_t b =
|
||||
(static_cast<uint32_t>(data[i]) << 16) |
|
||||
(i + 1 < len ? static_cast<uint32_t>(data[i + 1]) << 8 : 0u) |
|
||||
(i + 2 < len ? static_cast<uint32_t>(data[i + 2]) : 0u);
|
||||
out.push_back(kBase64Chars[(b >> 18) & 0x3F]);
|
||||
out.push_back(kBase64Chars[(b >> 12) & 0x3F]);
|
||||
out.push_back(i + 1 < len ? kBase64Chars[(b >> 6) & 0x3F] : '=');
|
||||
out.push_back(i + 2 < len ? kBase64Chars[(b ) & 0x3F] : '=');
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// GDI+ PNG encoder CLSID
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
static bool getPngClsid(CLSID& out) {
|
||||
UINT num = 0, sz = 0;
|
||||
if (Gdiplus::GetImageEncodersSize(&num, &sz) != Gdiplus::Ok || sz == 0) return false;
|
||||
std::vector<uint8_t> buf(sz);
|
||||
auto* enc = reinterpret_cast<Gdiplus::ImageCodecInfo*>(buf.data());
|
||||
if (Gdiplus::GetImageEncoders(num, sz, enc) != Gdiplus::Ok) return false;
|
||||
for (UINT i = 0; i < num; ++i) {
|
||||
if (std::wstring(enc[i].MimeType) == L"image/png") {
|
||||
out = enc[i].Clsid;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Standard cursor-type lookup
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
static const char* standardCursorType(HCURSOR hc) {
|
||||
if (!hc) return nullptr;
|
||||
static const struct { WORD id; const char* name; } kMap[] = {
|
||||
{32512, "arrow"},
|
||||
{32513, "text"},
|
||||
{32514, "wait"},
|
||||
{32515, "crosshair"},
|
||||
{32516, "up-arrow"},
|
||||
{32642, "resize-nwse"},
|
||||
{32643, "resize-nesw"},
|
||||
{32644, "resize-ew"},
|
||||
{32645, "resize-ns"},
|
||||
{32646, "move"},
|
||||
{32648, "not-allowed"},
|
||||
{32649, "pointer"},
|
||||
{32650, "app-starting"},
|
||||
{32651, "help"},
|
||||
};
|
||||
static constexpr int N = static_cast<int>(sizeof(kMap) / sizeof(kMap[0]));
|
||||
static HCURSOR g_handles[N] = {};
|
||||
static bool g_init = false;
|
||||
if (!g_init) {
|
||||
for (int i = 0; i < N; ++i)
|
||||
g_handles[i] = LoadCursor(nullptr, MAKEINTRESOURCE(kMap[i].id));
|
||||
g_init = true;
|
||||
}
|
||||
for (int i = 0; i < N; ++i)
|
||||
if (g_handles[i] && g_handles[i] == hc) return kMap[i].name;
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Custom cursor-type detection (replicates the PowerShell heuristic)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
static const char* detectCustomCursorType(
|
||||
const uint32_t* pixels, int w, int h, int hotX, int hotY)
|
||||
{
|
||||
if (w < 24 || h < 24 || w > 64 || h > 64) return nullptr;
|
||||
if (hotX < w * 0.25 || hotX > w * 0.75) return nullptr;
|
||||
if (hotY < h * 0.15 || hotY > h * 0.55) return nullptr;
|
||||
|
||||
int opaque = 0, topHalf = 0;
|
||||
int left = w, top = h, right = -1, bottom = -1;
|
||||
|
||||
for (int y = 0; y < h; ++y) {
|
||||
for (int x = 0; x < w; ++x) {
|
||||
const uint8_t a = static_cast<uint8_t>(pixels[y * w + x] >> 24);
|
||||
if (a <= 32) continue;
|
||||
++opaque;
|
||||
if (y < h / 2) ++topHalf;
|
||||
if (x < left) left = x;
|
||||
if (x > right) right = x;
|
||||
if (y < top) top = y;
|
||||
if (y > bottom) bottom = y;
|
||||
}
|
||||
}
|
||||
|
||||
if (opaque < 90 || right < left || bottom < top) return nullptr;
|
||||
|
||||
const int ow = right - left + 1;
|
||||
const int oh = bottom - top + 1;
|
||||
if (ow < w * 0.35 || ow > w * 0.9) return nullptr;
|
||||
if (oh < h * 0.45 || oh > static_cast<double>(h)) return nullptr;
|
||||
if (top > h * 0.45 || bottom < h * 0.65) return nullptr;
|
||||
|
||||
return topHalf > opaque * 0.55 ? "closed-hand" : "open-hand";
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Build asset JSON for the given cursor (returns empty string on failure)
|
||||
//
|
||||
// Renders the cursor via GDI DrawIconEx onto a 32-bpp transparent DIB section
|
||||
// and then encodes to PNG — matching the PowerShell approach of
|
||||
// Graphics.Clear(Transparent) + Graphics.DrawIcon(). This correctly preserves
|
||||
// per-pixel alpha for 32-bit cursors, unlike Gdiplus::Bitmap::FromHICON which
|
||||
// can produce incorrect alpha for cursor handles.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
static std::string buildAssetJson(
|
||||
HCURSOR hCursor,
|
||||
const std::string& handleStr,
|
||||
const CLSID& pngClsid,
|
||||
const char** outCustomType)
|
||||
{
|
||||
*outCustomType = nullptr;
|
||||
|
||||
// Get hotspot and cursor dimensions from the icon info.
|
||||
// For color cursors hbmColor gives the size; for monochrome cursors the
|
||||
// mask bitmap is twice the cursor height (AND mask stacked on XOR mask).
|
||||
ICONINFO ii{};
|
||||
if (!GetIconInfo(hCursor, &ii)) return {};
|
||||
const int hotX = static_cast<int>(ii.xHotspot);
|
||||
const int hotY = static_cast<int>(ii.yHotspot);
|
||||
|
||||
int w = 0, h = 0;
|
||||
if (ii.hbmColor) {
|
||||
BITMAP bm{};
|
||||
if (GetObject(ii.hbmColor, sizeof(bm), &bm)) { w = bm.bmWidth; h = bm.bmHeight; }
|
||||
}
|
||||
if (ii.hbmMask && (w == 0 || h == 0)) {
|
||||
BITMAP bm{};
|
||||
if (GetObject(ii.hbmMask, sizeof(bm), &bm)) {
|
||||
w = bm.bmWidth;
|
||||
h = ii.hbmColor ? bm.bmHeight : bm.bmHeight / 2;
|
||||
}
|
||||
}
|
||||
if (ii.hbmMask) DeleteObject(ii.hbmMask);
|
||||
if (ii.hbmColor) DeleteObject(ii.hbmColor);
|
||||
if (w <= 0 || h <= 0) return {};
|
||||
|
||||
// Copy the cursor handle so DrawIconEx cannot affect the live system cursor.
|
||||
const HICON hCopy = CopyIcon(hCursor);
|
||||
if (!hCopy) return {};
|
||||
|
||||
// Allocate a 32-bpp top-down DIB section and clear it to transparent black,
|
||||
// then draw the cursor with DI_NORMAL. For 32-bit alpha cursors Windows
|
||||
// writes correct per-pixel alpha into the high byte of each BGRA pixel.
|
||||
const int stride = w * 4;
|
||||
BITMAPINFOHEADER bih{};
|
||||
bih.biSize = sizeof(bih);
|
||||
bih.biWidth = w;
|
||||
bih.biHeight = -h; // negative = top-down scanline order
|
||||
bih.biPlanes = 1;
|
||||
bih.biBitCount = 32;
|
||||
bih.biCompression = BI_RGB;
|
||||
|
||||
void* pBits = nullptr;
|
||||
HDC hDC = CreateCompatibleDC(nullptr);
|
||||
HBITMAP hBmp = hDC ? CreateDIBSection(hDC,
|
||||
reinterpret_cast<const BITMAPINFO*>(&bih),
|
||||
DIB_RGB_COLORS, &pBits, nullptr, 0)
|
||||
: nullptr;
|
||||
|
||||
if (!hBmp || !pBits) {
|
||||
if (hBmp) DeleteObject(hBmp);
|
||||
if (hDC) DeleteDC(hDC);
|
||||
DestroyIcon(hCopy);
|
||||
return {};
|
||||
}
|
||||
|
||||
HGDIOBJ hOld = SelectObject(hDC, hBmp);
|
||||
std::memset(pBits, 0, static_cast<size_t>(stride * h)); // transparent black
|
||||
DrawIconEx(hDC, 0, 0, hCopy, w, h, 0, nullptr, DI_NORMAL);
|
||||
GdiFlush();
|
||||
SelectObject(hDC, hOld);
|
||||
DeleteDC(hDC);
|
||||
DestroyIcon(hCopy);
|
||||
|
||||
// GDI's 32-bit DIB stores pixels as BGRA in memory. GDI+'s
|
||||
// PixelFormat32bppARGB interprets each 32-bit word as 0xAARRGGBB which is
|
||||
// identical to BGRA on little-endian, so the alpha byte is always >> 24.
|
||||
{
|
||||
const auto* px = static_cast<const uint32_t*>(pBits);
|
||||
*outCustomType = detectCustomCursorType(px, w, h, hotX, hotY);
|
||||
}
|
||||
|
||||
// Wrap the DIB pixels in a GDI+ Bitmap (zero-copy) and save to PNG.
|
||||
// Keep hBmp alive until after gBmp is destroyed so pBits remains valid.
|
||||
std::vector<uint8_t> pngData;
|
||||
{
|
||||
Gdiplus::Bitmap gBmp(w, h, stride, PixelFormat32bppARGB,
|
||||
static_cast<BYTE*>(pBits));
|
||||
if (gBmp.GetLastStatus() == Gdiplus::Ok) {
|
||||
IStream* pStream = nullptr;
|
||||
if (SUCCEEDED(CreateStreamOnHGlobal(nullptr, TRUE, &pStream))) {
|
||||
if (gBmp.Save(pStream, &pngClsid) == Gdiplus::Ok) {
|
||||
ULARGE_INTEGER sz{};
|
||||
LARGE_INTEGER zero{};
|
||||
pStream->Seek(zero, STREAM_SEEK_END, &sz);
|
||||
pStream->Seek(zero, STREAM_SEEK_SET, nullptr);
|
||||
pngData.resize(static_cast<size_t>(sz.QuadPart));
|
||||
ULONG n = 0;
|
||||
pStream->Read(pngData.data(), static_cast<ULONG>(pngData.size()), &n);
|
||||
pngData.resize(n);
|
||||
}
|
||||
pStream->Release();
|
||||
}
|
||||
}
|
||||
} // gBmp destroyed here; pBits (owned by hBmp) still valid
|
||||
DeleteObject(hBmp);
|
||||
|
||||
if (pngData.empty()) return {};
|
||||
|
||||
const std::string dataUrl =
|
||||
"data:image/png;base64," + base64Encode(pngData.data(), pngData.size());
|
||||
|
||||
std::string json;
|
||||
json.reserve(dataUrl.size() + 128);
|
||||
json = "{\"id\":\"" + handleStr + "\"";
|
||||
json += ",\"imageDataUrl\":\"" + jsonEscape(dataUrl) + "\"";
|
||||
json += ",\"width\":" + std::to_string(w);
|
||||
json += ",\"height\":" + std::to_string(h);
|
||||
json += ",\"hotspotX\":" + std::to_string(hotX);
|
||||
json += ",\"hotspotY\":" + std::to_string(hotY);
|
||||
if (*outCustomType) {
|
||||
json += ",\"cursorType\":\"";
|
||||
json += *outCustomType;
|
||||
json += "\"";
|
||||
} else {
|
||||
json += ",\"cursorType\":null";
|
||||
}
|
||||
json += "}";
|
||||
return json;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Sampling loop (background thread)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
static void runSamplingLoop(int intervalMs, HWND targetWindow, const CLSID& pngClsid) {
|
||||
HCURSOR lastCursor = nullptr;
|
||||
|
||||
while (!g_stop.load(std::memory_order_relaxed)) {
|
||||
const int downCount = g_leftDownCount.exchange(0, std::memory_order_relaxed);
|
||||
const int upCount = g_leftUpCount.exchange(0, std::memory_order_relaxed);
|
||||
|
||||
CURSORINFO ci{};
|
||||
ci.cbSize = sizeof(ci);
|
||||
if (!GetCursorInfo(&ci)) {
|
||||
char buf[160];
|
||||
std::snprintf(buf, sizeof(buf),
|
||||
"{\"type\":\"error\",\"timestampMs\":%" PRId64 ",\"message\":\"GetCursorInfo failed\"}",
|
||||
nowMs());
|
||||
writeJsonLine(buf);
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(intervalMs));
|
||||
continue;
|
||||
}
|
||||
|
||||
const bool visible = (ci.flags & CURSOR_SHOWING) != 0;
|
||||
const HCURSOR hc = ci.hCursor;
|
||||
|
||||
// Handle string ("0xHEX" or empty for null cursor)
|
||||
char handleBuf[32] = {};
|
||||
if (hc)
|
||||
std::snprintf(handleBuf, sizeof(handleBuf),
|
||||
"0x%" PRIX64, static_cast<uint64_t>(reinterpret_cast<uintptr_t>(hc)));
|
||||
const std::string handleStr = hc ? handleBuf : "";
|
||||
|
||||
// Standard cursor type
|
||||
const char* cursorType = standardCursorType(hc);
|
||||
|
||||
// Mouse button state
|
||||
const SHORT ks = GetAsyncKeyState(VK_LBUTTON);
|
||||
const bool leftDown = (ks & 0x8000) != 0;
|
||||
const bool leftPressed = downCount > 0 || (ks & 0x0001) != 0;
|
||||
const bool leftReleased = upCount > 0;
|
||||
|
||||
// Asset — only when the cursor handle changes
|
||||
std::string assetJson;
|
||||
if (visible && hc && hc != lastCursor) {
|
||||
const char* customType = nullptr;
|
||||
assetJson = buildAssetJson(hc, handleStr, pngClsid, &customType);
|
||||
if (!assetJson.empty() && !cursorType && customType)
|
||||
cursorType = customType;
|
||||
lastCursor = hc;
|
||||
}
|
||||
|
||||
// Window bounds
|
||||
std::string boundsJson = "null";
|
||||
if (targetWindow && IsWindow(targetWindow)) {
|
||||
RECT r{};
|
||||
if (GetWindowRect(targetWindow, &r)) {
|
||||
const int bw = r.right - r.left;
|
||||
const int bh = r.bottom - r.top;
|
||||
if (bw > 0 && bh > 0) {
|
||||
char buf[128];
|
||||
std::snprintf(buf, sizeof(buf),
|
||||
"{\"x\":%ld,\"y\":%ld,\"width\":%d,\"height\":%d}",
|
||||
r.left, r.top, bw, bh);
|
||||
boundsJson = buf;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Emit sample JSON
|
||||
std::string out;
|
||||
out.reserve(256);
|
||||
out += "{\"type\":\"sample\"";
|
||||
out += ",\"timestampMs\":"; out += std::to_string(nowMs());
|
||||
out += ",\"x\":"; out += std::to_string(ci.ptScreenPos.x);
|
||||
out += ",\"y\":"; out += std::to_string(ci.ptScreenPos.y);
|
||||
out += ",\"visible\":"; out += visible ? "true" : "false";
|
||||
out += ",\"handle\":"; out += hc ? ("\"" + handleStr + "\"") : "null";
|
||||
out += ",\"cursorType\":"; out += cursorType ? ("\"" + std::string(cursorType) + "\"") : "null";
|
||||
out += ",\"leftButtonDown\":"; out += leftDown ? "true" : "false";
|
||||
out += ",\"leftButtonPressed\":"; out += leftPressed ? "true" : "false";
|
||||
out += ",\"leftButtonReleased\":"; out += leftReleased ? "true" : "false";
|
||||
out += ",\"bounds\":"; out += boundsJson;
|
||||
out += ",\"asset\":"; out += assetJson.empty() ? "null" : assetJson;
|
||||
out += "}";
|
||||
|
||||
writeJsonLine(out);
|
||||
|
||||
// Exit if stdout pipe is broken (parent process died)
|
||||
if (std::cout.fail()) {
|
||||
PostThreadMessage(g_mainThreadId, WM_QUIT, 0, 0);
|
||||
break;
|
||||
}
|
||||
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(intervalMs));
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// main
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
int main(int argc, char* argv[]) {
|
||||
if (argc < 2) {
|
||||
std::cerr << "Usage: cursor-sampler <intervalMs> [windowHandle]" << std::endl;
|
||||
return 1;
|
||||
}
|
||||
|
||||
const int intervalMs = std::max(1, std::atoi(argv[1]));
|
||||
|
||||
HWND targetWindow = nullptr;
|
||||
if (argc >= 3) {
|
||||
const std::string arg = argv[2];
|
||||
if (!arg.empty() && arg != "null") {
|
||||
try {
|
||||
const int base = (arg.rfind("0x", 0) == 0 || arg.rfind("0X", 0) == 0) ? 16 : 10;
|
||||
const uint64_t v = std::stoull(arg, nullptr, base);
|
||||
if (v) targetWindow = reinterpret_cast<HWND>(static_cast<uintptr_t>(v));
|
||||
} catch (...) {}
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize GDI+
|
||||
Gdiplus::GdiplusStartupInput gdipInput{};
|
||||
ULONG_PTR gdipToken = 0;
|
||||
if (Gdiplus::GdiplusStartup(&gdipToken, &gdipInput, nullptr) != Gdiplus::Ok) {
|
||||
std::cerr << "GDI+ init failed" << std::endl;
|
||||
return 1;
|
||||
}
|
||||
|
||||
CLSID pngClsid{};
|
||||
if (!getPngClsid(pngClsid)) {
|
||||
std::cerr << "PNG encoder not found" << std::endl;
|
||||
Gdiplus::GdiplusShutdown(gdipToken);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Install global low-level mouse hook on this thread
|
||||
g_mouseHook = SetWindowsHookEx(WH_MOUSE_LL, LowLevelMouseProc, GetModuleHandle(nullptr), 0);
|
||||
if (!g_mouseHook) {
|
||||
std::cerr << "SetWindowsHookEx failed" << std::endl;
|
||||
Gdiplus::GdiplusShutdown(gdipToken);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Prime GetAsyncKeyState so the first poll doesn't return stale "since-last-call" bits
|
||||
GetAsyncKeyState(VK_LBUTTON);
|
||||
|
||||
// Signal readiness
|
||||
g_mainThreadId = GetCurrentThreadId();
|
||||
{
|
||||
char buf[80];
|
||||
std::snprintf(buf, sizeof(buf),
|
||||
"{\"type\":\"ready\",\"timestampMs\":%" PRId64 "}", nowMs());
|
||||
writeJsonLine(buf);
|
||||
}
|
||||
|
||||
// Start sampling on a background thread
|
||||
std::thread sampler(runSamplingLoop, intervalMs, targetWindow, std::cref(pngClsid));
|
||||
|
||||
// Run the message pump on the main thread — required for WH_MOUSE_LL callbacks
|
||||
MSG msg;
|
||||
while (GetMessage(&msg, nullptr, 0, 0) > 0) {
|
||||
TranslateMessage(&msg);
|
||||
DispatchMessage(&msg);
|
||||
}
|
||||
|
||||
g_stop.store(true, std::memory_order_relaxed);
|
||||
if (sampler.joinable()) sampler.join();
|
||||
UnhookWindowsHookEx(g_mouseHook);
|
||||
Gdiplus::GdiplusShutdown(gdipToken);
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,427 @@
|
||||
#include "dshow_webcam_capture.h"
|
||||
|
||||
#include <initguid.h>
|
||||
#include <dshow.h>
|
||||
#include <wrl/client.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <chrono>
|
||||
#include <exception>
|
||||
#include <iomanip>
|
||||
#include <iostream>
|
||||
#include <sstream>
|
||||
|
||||
namespace {
|
||||
|
||||
const CLSID CLSID_SampleGrabberLocal = {0xC1F400A0, 0x3F08, 0x11D3, {0x9F, 0x0B, 0x00, 0x60, 0x08, 0x03, 0x9E, 0x37}};
|
||||
const CLSID CLSID_NullRendererLocal = {0xC1F400A4, 0x3F08, 0x11D3, {0x9F, 0x0B, 0x00, 0x60, 0x08, 0x03, 0x9E, 0x37}};
|
||||
|
||||
MIDL_INTERFACE("6B652FFF-11FE-4FCE-92AD-0266B5D7C78F")
|
||||
ISampleGrabber : public IUnknown {
|
||||
public:
|
||||
virtual HRESULT STDMETHODCALLTYPE SetOneShot(BOOL oneShot) = 0;
|
||||
virtual HRESULT STDMETHODCALLTYPE SetMediaType(const AM_MEDIA_TYPE* type) = 0;
|
||||
virtual HRESULT STDMETHODCALLTYPE GetConnectedMediaType(AM_MEDIA_TYPE* type) = 0;
|
||||
virtual HRESULT STDMETHODCALLTYPE SetBufferSamples(BOOL bufferThem) = 0;
|
||||
virtual HRESULT STDMETHODCALLTYPE GetCurrentBuffer(long* bufferSize, long* buffer) = 0;
|
||||
virtual HRESULT STDMETHODCALLTYPE GetCurrentSample(IMediaSample** sample) = 0;
|
||||
virtual HRESULT STDMETHODCALLTYPE SetCallback(IUnknown* callback, long whichMethodToCallback) = 0;
|
||||
};
|
||||
|
||||
bool succeeded(HRESULT hr, const char* label) {
|
||||
if (SUCCEEDED(hr)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
std::cerr << "ERROR: " << label << " failed (hr=0x" << std::hex << hr << std::dec << ")"
|
||||
<< std::endl;
|
||||
return false;
|
||||
}
|
||||
|
||||
std::string guidToString(const GUID& guid) {
|
||||
if (guid == MEDIASUBTYPE_RGB32) {
|
||||
return "RGB32";
|
||||
}
|
||||
if (guid == MEDIASUBTYPE_YUY2) {
|
||||
return "YUY2";
|
||||
}
|
||||
if (guid == MEDIASUBTYPE_NV12) {
|
||||
return "NV12";
|
||||
}
|
||||
|
||||
std::ostringstream stream;
|
||||
stream << std::hex << std::setfill('0')
|
||||
<< '{' << std::setw(8) << guid.Data1
|
||||
<< '-' << std::setw(4) << guid.Data2
|
||||
<< '-' << std::setw(4) << guid.Data3
|
||||
<< '-';
|
||||
for (int index = 0; index < 2; index += 1) {
|
||||
stream << std::setw(2) << static_cast<int>(guid.Data4[index]);
|
||||
}
|
||||
stream << '-';
|
||||
for (int index = 2; index < 8; index += 1) {
|
||||
stream << std::setw(2) << static_cast<int>(guid.Data4[index]);
|
||||
}
|
||||
stream << '}';
|
||||
return stream.str();
|
||||
}
|
||||
|
||||
void freeMediaType(AM_MEDIA_TYPE& type) {
|
||||
if (type.cbFormat != 0) {
|
||||
CoTaskMemFree(type.pbFormat);
|
||||
type.cbFormat = 0;
|
||||
type.pbFormat = nullptr;
|
||||
}
|
||||
if (type.pUnk) {
|
||||
type.pUnk->Release();
|
||||
type.pUnk = nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
BYTE clampToByte(int value) {
|
||||
return static_cast<BYTE>(std::clamp(value, 0, 255));
|
||||
}
|
||||
|
||||
std::array<BYTE, 3> yuvToBgr(int y, int u, int v) {
|
||||
const int c = y - 16;
|
||||
const int d = u - 128;
|
||||
const int e = v - 128;
|
||||
const int blue = (298 * c + 516 * d + 128) >> 8;
|
||||
const int green = (298 * c - 100 * d - 208 * e + 128) >> 8;
|
||||
const int red = (298 * c + 409 * e + 128) >> 8;
|
||||
return {clampToByte(blue), clampToByte(green), clampToByte(red)};
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
struct DirectShowWebcamCapture::Impl {
|
||||
Microsoft::WRL::ComPtr<IGraphBuilder> graph;
|
||||
Microsoft::WRL::ComPtr<ICaptureGraphBuilder2> captureGraph;
|
||||
Microsoft::WRL::ComPtr<IBaseFilter> captureFilter;
|
||||
Microsoft::WRL::ComPtr<IBaseFilter> sampleGrabberFilter;
|
||||
Microsoft::WRL::ComPtr<ISampleGrabber> sampleGrabber;
|
||||
Microsoft::WRL::ComPtr<IBaseFilter> nullRenderer;
|
||||
Microsoft::WRL::ComPtr<IMediaControl> mediaControl;
|
||||
bool comInitialized = false;
|
||||
bool running = false;
|
||||
};
|
||||
|
||||
DirectShowWebcamCapture::~DirectShowWebcamCapture() {
|
||||
stop();
|
||||
delete impl_;
|
||||
}
|
||||
|
||||
bool DirectShowWebcamCapture::initialize(
|
||||
const std::wstring& deviceId,
|
||||
const std::wstring& deviceName,
|
||||
const std::wstring& directShowClsid,
|
||||
int requestedWidth,
|
||||
int requestedHeight,
|
||||
int requestedFps) {
|
||||
(void)deviceId;
|
||||
stop();
|
||||
delete impl_;
|
||||
impl_ = nullptr;
|
||||
impl_ = new Impl();
|
||||
fps_ = std::clamp(requestedFps > 0 ? requestedFps : 30, 1, 60);
|
||||
|
||||
HRESULT hr = CoInitializeEx(nullptr, COINIT_MULTITHREADED);
|
||||
if (SUCCEEDED(hr)) {
|
||||
impl_->comInitialized = true;
|
||||
} else if (hr != RPC_E_CHANGED_MODE) {
|
||||
return succeeded(hr, "CoInitializeEx(DirectShow webcam)");
|
||||
}
|
||||
|
||||
if (directShowClsid.empty()) {
|
||||
std::cerr << "ERROR: DirectShow webcam fallback requires a resolved filter CLSID" << std::endl;
|
||||
return false;
|
||||
}
|
||||
|
||||
CLSID selectedClsid{};
|
||||
if (FAILED(CLSIDFromString(directShowClsid.c_str(), &selectedClsid))) {
|
||||
std::cerr << "ERROR: DirectShow webcam fallback received an invalid filter CLSID" << std::endl;
|
||||
return false;
|
||||
}
|
||||
selectedDeviceName_ = deviceName.empty() ? directShowClsid : deviceName;
|
||||
|
||||
if (!succeeded(CoCreateInstance(selectedClsid, nullptr, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(&impl_->captureFilter)),
|
||||
"CoCreateInstance(DirectShow webcam filter)")) {
|
||||
return false;
|
||||
}
|
||||
if (!succeeded(CoCreateInstance(CLSID_FilterGraph, nullptr, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(&impl_->graph)),
|
||||
"CoCreateInstance(FilterGraph)")) {
|
||||
return false;
|
||||
}
|
||||
if (!succeeded(CoCreateInstance(CLSID_CaptureGraphBuilder2, nullptr, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(&impl_->captureGraph)),
|
||||
"CoCreateInstance(CaptureGraphBuilder2)")) {
|
||||
return false;
|
||||
}
|
||||
if (!succeeded(impl_->captureGraph->SetFiltergraph(impl_->graph.Get()), "SetFiltergraph(DirectShow webcam)")) {
|
||||
return false;
|
||||
}
|
||||
if (!succeeded(impl_->graph->AddFilter(impl_->captureFilter.Get(), L"OpenScreen Webcam Source"),
|
||||
"AddFilter(DirectShow webcam source)")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!succeeded(CoCreateInstance(CLSID_SampleGrabberLocal, nullptr, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(&impl_->sampleGrabberFilter)),
|
||||
"CoCreateInstance(SampleGrabber)")) {
|
||||
return false;
|
||||
}
|
||||
if (!succeeded(impl_->sampleGrabberFilter.As(&impl_->sampleGrabber), "QueryInterface(ISampleGrabber)")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
AM_MEDIA_TYPE requestedType{};
|
||||
requestedType.majortype = MEDIATYPE_Video;
|
||||
requestedType.formattype = FORMAT_VideoInfo;
|
||||
if (!succeeded(impl_->sampleGrabber->SetMediaType(&requestedType), "SetMediaType(DirectShow video)")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!succeeded(impl_->graph->AddFilter(impl_->sampleGrabberFilter.Get(), L"OpenScreen Webcam Sample Grabber"),
|
||||
"AddFilter(SampleGrabber)")) {
|
||||
return false;
|
||||
}
|
||||
if (!succeeded(CoCreateInstance(CLSID_NullRendererLocal, nullptr, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(&impl_->nullRenderer)),
|
||||
"CoCreateInstance(NullRenderer)")) {
|
||||
return false;
|
||||
}
|
||||
if (!succeeded(impl_->graph->AddFilter(impl_->nullRenderer.Get(), L"OpenScreen Webcam Null Renderer"),
|
||||
"AddFilter(NullRenderer)")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!succeeded(impl_->captureGraph->RenderStream(
|
||||
&PIN_CATEGORY_CAPTURE,
|
||||
&MEDIATYPE_Video,
|
||||
impl_->captureFilter.Get(),
|
||||
impl_->sampleGrabberFilter.Get(),
|
||||
impl_->nullRenderer.Get()),
|
||||
"RenderStream(DirectShow webcam)")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
AM_MEDIA_TYPE connectedType{};
|
||||
if (!succeeded(impl_->sampleGrabber->GetConnectedMediaType(&connectedType), "GetConnectedMediaType(DirectShow webcam)")) {
|
||||
return false;
|
||||
}
|
||||
if (connectedType.subtype == MEDIASUBTYPE_YUY2) {
|
||||
pixelFormat_ = PixelFormat::Yuy2;
|
||||
} else if (connectedType.subtype == MEDIASUBTYPE_NV12) {
|
||||
pixelFormat_ = PixelFormat::Nv12;
|
||||
} else if (connectedType.subtype == MEDIASUBTYPE_RGB32) {
|
||||
pixelFormat_ = PixelFormat::Bgra;
|
||||
} else {
|
||||
std::cerr << "ERROR: Unsupported DirectShow webcam media subtype "
|
||||
<< guidToString(connectedType.subtype) << std::endl;
|
||||
freeMediaType(connectedType);
|
||||
return false;
|
||||
}
|
||||
if (connectedType.formattype == FORMAT_VideoInfo && connectedType.pbFormat) {
|
||||
const auto* videoInfo = reinterpret_cast<VIDEOINFOHEADER*>(connectedType.pbFormat);
|
||||
width_ = std::abs(videoInfo->bmiHeader.biWidth);
|
||||
height_ = std::abs(videoInfo->bmiHeader.biHeight);
|
||||
const int bitsPerPixel = videoInfo->bmiHeader.biBitCount > 0 ? videoInfo->bmiHeader.biBitCount : 16;
|
||||
if (pixelFormat_ == PixelFormat::Nv12) {
|
||||
sourceStride_ = ((width_ + 3) / 4) * 4;
|
||||
} else {
|
||||
sourceStride_ = ((width_ * bitsPerPixel + 31) / 32) * 4;
|
||||
}
|
||||
sourceTopDown_ = pixelFormat_ != PixelFormat::Bgra || videoInfo->bmiHeader.biHeight < 0;
|
||||
}
|
||||
std::cerr << "INFO: DirectShow webcam connected subtype " << guidToString(connectedType.subtype)
|
||||
<< " " << width_ << "x" << height_ << " stride=" << sourceStride_ << std::endl;
|
||||
freeMediaType(connectedType);
|
||||
if (width_ <= 0 || height_ <= 0) {
|
||||
width_ = requestedWidth > 0 ? requestedWidth : 1280;
|
||||
height_ = requestedHeight > 0 ? requestedHeight : 720;
|
||||
}
|
||||
if (sourceStride_ <= 0) {
|
||||
sourceStride_ = pixelFormat_ == PixelFormat::Bgra ? width_ * 4 : ((width_ + 3) / 4) * 4;
|
||||
}
|
||||
|
||||
impl_->sampleGrabber->SetBufferSamples(TRUE);
|
||||
impl_->sampleGrabber->SetOneShot(FALSE);
|
||||
if (!succeeded(impl_->graph.As(&impl_->mediaControl), "QueryInterface(IMediaControl)")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
bool DirectShowWebcamCapture::start() {
|
||||
if (!impl_ || !impl_->mediaControl || impl_->running) {
|
||||
return false;
|
||||
}
|
||||
HRESULT hr = impl_->mediaControl->Run();
|
||||
if (!succeeded(hr, "Run(DirectShow webcam)")) {
|
||||
return false;
|
||||
}
|
||||
stopRequested_ = false;
|
||||
try {
|
||||
thread_ = std::thread(&DirectShowWebcamCapture::captureLoop, this);
|
||||
} catch (const std::exception& error) {
|
||||
stopRequested_ = true;
|
||||
impl_->mediaControl->Stop();
|
||||
std::cerr << "ERROR: Failed to start DirectShow webcam capture thread: " << error.what() << std::endl;
|
||||
return false;
|
||||
} catch (...) {
|
||||
stopRequested_ = true;
|
||||
impl_->mediaControl->Stop();
|
||||
std::cerr << "ERROR: Failed to start DirectShow webcam capture thread" << std::endl;
|
||||
return false;
|
||||
}
|
||||
impl_->running = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
void DirectShowWebcamCapture::stop() {
|
||||
stopRequested_ = true;
|
||||
if (thread_.joinable()) {
|
||||
thread_.join();
|
||||
}
|
||||
if (!impl_) {
|
||||
return;
|
||||
}
|
||||
if (impl_->mediaControl && impl_->running) {
|
||||
impl_->mediaControl->Stop();
|
||||
}
|
||||
impl_->running = false;
|
||||
impl_->mediaControl.Reset();
|
||||
impl_->nullRenderer.Reset();
|
||||
impl_->sampleGrabber.Reset();
|
||||
impl_->sampleGrabberFilter.Reset();
|
||||
impl_->captureFilter.Reset();
|
||||
impl_->captureGraph.Reset();
|
||||
impl_->graph.Reset();
|
||||
if (impl_->comInitialized) {
|
||||
CoUninitialize();
|
||||
impl_->comInitialized = false;
|
||||
}
|
||||
}
|
||||
|
||||
void DirectShowWebcamCapture::captureLoop() {
|
||||
const HRESULT coinitHr = CoInitializeEx(nullptr, COINIT_MULTITHREADED);
|
||||
while (!stopRequested_ && impl_ && impl_->sampleGrabber) {
|
||||
long bufferSize = 0;
|
||||
HRESULT hr = impl_->sampleGrabber->GetCurrentBuffer(&bufferSize, nullptr);
|
||||
if (SUCCEEDED(hr) && bufferSize > 0) {
|
||||
std::vector<BYTE> buffer(static_cast<size_t>(bufferSize));
|
||||
hr = impl_->sampleGrabber->GetCurrentBuffer(&bufferSize, reinterpret_cast<long*>(buffer.data()));
|
||||
if (SUCCEEDED(hr)) {
|
||||
storeFrame(buffer.data(), bufferSize);
|
||||
}
|
||||
}
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(1000 / std::max(1, fps_)));
|
||||
}
|
||||
if (SUCCEEDED(coinitHr)) {
|
||||
CoUninitialize();
|
||||
}
|
||||
}
|
||||
|
||||
void DirectShowWebcamCapture::storeFrame(const BYTE* buffer, long length) {
|
||||
const int destinationStride = width_ * 4;
|
||||
const int sourceStride = sourceStride_ > 0 ? sourceStride_ : destinationStride;
|
||||
const int expectedLength = pixelFormat_ == PixelFormat::Nv12
|
||||
? sourceStride * height_ + sourceStride * ((height_ + 1) / 2)
|
||||
: sourceStride * height_;
|
||||
if (!buffer || length < expectedLength || width_ <= 0 || height_ <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
std::vector<BYTE> frame(static_cast<size_t>(destinationStride * height_));
|
||||
for (int y = 0; y < height_; y += 1) {
|
||||
const int sourceY = sourceTopDown_ ? y : height_ - 1 - y;
|
||||
const BYTE* source = buffer + sourceY * sourceStride;
|
||||
BYTE* destination = frame.data() + y * destinationStride;
|
||||
if (pixelFormat_ == PixelFormat::Bgra) {
|
||||
std::copy(source, source + destinationStride, destination);
|
||||
for (int x = 0; x < width_; x += 1) {
|
||||
destination[x * 4 + 3] = 255;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (pixelFormat_ == PixelFormat::Nv12) {
|
||||
const BYTE* yPlane = buffer + sourceY * sourceStride;
|
||||
const BYTE* uvPlane = buffer + sourceStride * height_ + (sourceY / 2) * sourceStride;
|
||||
for (int x = 0; x < width_; x += 1) {
|
||||
const int uvX = (x / 2) * 2;
|
||||
const auto color = yuvToBgr(yPlane[x], uvPlane[uvX], uvPlane[uvX + 1]);
|
||||
BYTE* pixel = destination + x * 4;
|
||||
pixel[0] = color[0];
|
||||
pixel[1] = color[1];
|
||||
pixel[2] = color[2];
|
||||
pixel[3] = 255;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
for (int x = 0; x + 1 < width_; x += 2) {
|
||||
const BYTE y0 = source[x * 2];
|
||||
const BYTE u = source[x * 2 + 1];
|
||||
const BYTE y1 = source[x * 2 + 2];
|
||||
const BYTE v = source[x * 2 + 3];
|
||||
const auto first = yuvToBgr(y0, u, v);
|
||||
const auto second = yuvToBgr(y1, u, v);
|
||||
BYTE* firstPixel = destination + x * 4;
|
||||
BYTE* secondPixel = firstPixel + 4;
|
||||
firstPixel[0] = first[0];
|
||||
firstPixel[1] = first[1];
|
||||
firstPixel[2] = first[2];
|
||||
firstPixel[3] = 255;
|
||||
secondPixel[0] = second[0];
|
||||
secondPixel[1] = second[1];
|
||||
secondPixel[2] = second[2];
|
||||
secondPixel[3] = 255;
|
||||
}
|
||||
if (width_ % 2 == 1) {
|
||||
const int x = width_ - 1;
|
||||
const int previousPairStart = ((x - 1) / 2) * 4;
|
||||
const BYTE y = source[x * 2];
|
||||
const BYTE u = source[previousPairStart + 1];
|
||||
const BYTE v = source[previousPairStart + 3];
|
||||
const auto color = yuvToBgr(y, u, v);
|
||||
BYTE* pixel = destination + x * 4;
|
||||
pixel[0] = color[0];
|
||||
pixel[1] = color[1];
|
||||
pixel[2] = color[2];
|
||||
pixel[3] = 255;
|
||||
}
|
||||
}
|
||||
|
||||
std::scoped_lock lock(frameMutex_);
|
||||
latestFrame_ = std::move(frame);
|
||||
latestFrameSequence_ += 1;
|
||||
}
|
||||
|
||||
bool DirectShowWebcamCapture::copyLatestFrame(WebcamFrameSnapshot& destination) {
|
||||
std::scoped_lock lock(frameMutex_);
|
||||
if (latestFrame_.empty() || width_ <= 0 || height_ <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
destination.data = latestFrame_;
|
||||
destination.width = width_;
|
||||
destination.height = height_;
|
||||
destination.sequence = latestFrameSequence_;
|
||||
return true;
|
||||
}
|
||||
|
||||
int DirectShowWebcamCapture::width() const {
|
||||
return width_;
|
||||
}
|
||||
|
||||
int DirectShowWebcamCapture::height() const {
|
||||
return height_;
|
||||
}
|
||||
|
||||
int DirectShowWebcamCapture::fps() const {
|
||||
return fps_;
|
||||
}
|
||||
|
||||
const std::wstring& DirectShowWebcamCapture::selectedDeviceName() const {
|
||||
return selectedDeviceName_;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
#pragma once
|
||||
|
||||
#include <Windows.h>
|
||||
|
||||
#include <atomic>
|
||||
#include <cstdint>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
struct WebcamFrameSnapshot {
|
||||
std::vector<BYTE> data;
|
||||
int width = 0;
|
||||
int height = 0;
|
||||
uint64_t sequence = 0;
|
||||
};
|
||||
|
||||
class DirectShowWebcamCapture {
|
||||
public:
|
||||
DirectShowWebcamCapture() = default;
|
||||
~DirectShowWebcamCapture();
|
||||
|
||||
DirectShowWebcamCapture(const DirectShowWebcamCapture&) = delete;
|
||||
DirectShowWebcamCapture& operator=(const DirectShowWebcamCapture&) = delete;
|
||||
|
||||
bool initialize(
|
||||
const std::wstring& deviceId,
|
||||
const std::wstring& deviceName,
|
||||
const std::wstring& directShowClsid,
|
||||
int requestedWidth,
|
||||
int requestedHeight,
|
||||
int requestedFps);
|
||||
bool start();
|
||||
void stop();
|
||||
bool copyLatestFrame(WebcamFrameSnapshot& destination);
|
||||
|
||||
int width() const;
|
||||
int height() const;
|
||||
int fps() const;
|
||||
const std::wstring& selectedDeviceName() const;
|
||||
void storeFrame(const BYTE* buffer, long length);
|
||||
|
||||
private:
|
||||
enum class PixelFormat {
|
||||
Bgra,
|
||||
Nv12,
|
||||
Yuy2,
|
||||
};
|
||||
|
||||
struct Impl;
|
||||
void captureLoop();
|
||||
|
||||
Impl* impl_ = nullptr;
|
||||
std::thread thread_;
|
||||
std::atomic<bool> stopRequested_ = false;
|
||||
std::mutex frameMutex_;
|
||||
std::vector<BYTE> latestFrame_;
|
||||
uint64_t latestFrameSequence_ = 0;
|
||||
int width_ = 0;
|
||||
int height_ = 0;
|
||||
int fps_ = 30;
|
||||
int sourceStride_ = 0;
|
||||
bool sourceTopDown_ = false;
|
||||
PixelFormat pixelFormat_ = PixelFormat::Bgra;
|
||||
std::wstring selectedDeviceName_;
|
||||
};
|
||||
@@ -0,0 +1,859 @@
|
||||
#include "audio_sample_utils.h"
|
||||
#include "mf_encoder.h"
|
||||
#include "monitor_utils.h"
|
||||
#include "wasapi_loopback_capture.h"
|
||||
#include "webcam_capture.h"
|
||||
#include "wgc_session.h"
|
||||
|
||||
#include <winrt/Windows.Foundation.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <condition_variable>
|
||||
#include <cctype>
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
#include <iostream>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
|
||||
namespace {
|
||||
|
||||
struct CaptureConfig {
|
||||
int schemaVersion = 1;
|
||||
int64_t displayId = 0;
|
||||
int64_t recordingId = 0;
|
||||
std::string sourceType = "display";
|
||||
std::string sourceId;
|
||||
std::string windowHandle;
|
||||
std::string outputPath;
|
||||
std::string webcamOutputPath;
|
||||
int fps = 60;
|
||||
int width = 0;
|
||||
int height = 0;
|
||||
MonitorBounds bounds{};
|
||||
bool hasDisplayBounds = false;
|
||||
bool captureSystemAudio = false;
|
||||
bool captureMic = false;
|
||||
bool captureCursor = false;
|
||||
bool webcamEnabled = false;
|
||||
std::string microphoneDeviceId;
|
||||
std::string microphoneDeviceName;
|
||||
double microphoneGain = 1.0;
|
||||
std::string webcamDeviceId;
|
||||
std::string webcamDeviceName;
|
||||
std::string webcamDirectShowClsid;
|
||||
int webcamWidth = 0;
|
||||
int webcamHeight = 0;
|
||||
int webcamFps = 0;
|
||||
};
|
||||
|
||||
struct CaptureControl {
|
||||
std::atomic<bool> stopRequested = false;
|
||||
std::atomic<bool> paused = false;
|
||||
std::mutex mutex;
|
||||
std::condition_variable cv;
|
||||
std::chrono::steady_clock::time_point pauseStartedAt;
|
||||
std::chrono::steady_clock::duration totalPausedDuration{};
|
||||
|
||||
int64_t pausedDurationHns() {
|
||||
std::scoped_lock lock(mutex);
|
||||
auto total = totalPausedDuration;
|
||||
if (paused.load()) {
|
||||
total += std::chrono::steady_clock::now() - pauseStartedAt;
|
||||
}
|
||||
return std::chrono::duration_cast<std::chrono::nanoseconds>(total).count() / 100;
|
||||
}
|
||||
|
||||
void setPaused(bool nextPaused) {
|
||||
std::scoped_lock lock(mutex);
|
||||
if (nextPaused == paused.load()) {
|
||||
return;
|
||||
}
|
||||
if (nextPaused) {
|
||||
pauseStartedAt = std::chrono::steady_clock::now();
|
||||
} else {
|
||||
totalPausedDuration += std::chrono::steady_clock::now() - pauseStartedAt;
|
||||
}
|
||||
paused = nextPaused;
|
||||
}
|
||||
};
|
||||
|
||||
std::wstring utf8ToWide(const std::string& value) {
|
||||
if (value.empty()) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const int size = MultiByteToWideChar(CP_UTF8, 0, value.data(), static_cast<int>(value.size()), nullptr, 0);
|
||||
std::wstring result(static_cast<size_t>(size), L'\0');
|
||||
MultiByteToWideChar(CP_UTF8, 0, value.data(), static_cast<int>(value.size()), result.data(), size);
|
||||
return result;
|
||||
}
|
||||
|
||||
std::string wideToUtf8(const std::wstring& value) {
|
||||
if (value.empty()) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const int size = WideCharToMultiByte(CP_UTF8, 0, value.data(), static_cast<int>(value.size()), nullptr, 0, nullptr, nullptr);
|
||||
std::string result(static_cast<size_t>(size), '\0');
|
||||
WideCharToMultiByte(CP_UTF8, 0, value.data(), static_cast<int>(value.size()), result.data(), size, nullptr, nullptr);
|
||||
return result;
|
||||
}
|
||||
|
||||
std::string jsonEscape(const std::string& value) {
|
||||
std::string result;
|
||||
result.reserve(value.size());
|
||||
for (const char c : value) {
|
||||
switch (c) {
|
||||
case '\\':
|
||||
result += "\\\\";
|
||||
break;
|
||||
case '"':
|
||||
result += "\\\"";
|
||||
break;
|
||||
case '\n':
|
||||
result += "\\n";
|
||||
break;
|
||||
case '\r':
|
||||
result += "\\r";
|
||||
break;
|
||||
case '\t':
|
||||
result += "\\t";
|
||||
break;
|
||||
default:
|
||||
result.push_back(c);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
bool hasVisibleBgraContent(const std::vector<BYTE>& frame) {
|
||||
if (frame.size() < 4) {
|
||||
return false;
|
||||
}
|
||||
|
||||
uint64_t lumaTotal = 0;
|
||||
BYTE maxLuma = 0;
|
||||
const size_t pixelCount = frame.size() / 4;
|
||||
const size_t step = std::max<size_t>(1, pixelCount / 4096);
|
||||
size_t sampledPixels = 0;
|
||||
for (size_t pixel = 0; pixel < pixelCount; pixel += step) {
|
||||
const size_t offset = pixel * 4;
|
||||
const BYTE b = frame[offset + 0];
|
||||
const BYTE g = frame[offset + 1];
|
||||
const BYTE r = frame[offset + 2];
|
||||
const BYTE luma = static_cast<BYTE>((static_cast<uint16_t>(r) * 54 + static_cast<uint16_t>(g) * 183 + static_cast<uint16_t>(b) * 19) >> 8);
|
||||
lumaTotal += luma;
|
||||
maxLuma = std::max(maxLuma, luma);
|
||||
sampledPixels += 1;
|
||||
}
|
||||
|
||||
const uint64_t averageLuma = sampledPixels > 0 ? lumaTotal / sampledPixels : 0;
|
||||
return maxLuma > 24 || averageLuma > 4;
|
||||
}
|
||||
|
||||
bool findBool(const std::string& json, const std::string& key, bool fallback) {
|
||||
auto pos = json.find("\"" + key + "\"");
|
||||
if (pos == std::string::npos) {
|
||||
return fallback;
|
||||
}
|
||||
pos = json.find(':', pos);
|
||||
if (pos == std::string::npos) {
|
||||
return fallback;
|
||||
}
|
||||
pos += 1;
|
||||
while (pos < json.size() && std::isspace(static_cast<unsigned char>(json[pos]))) {
|
||||
pos += 1;
|
||||
}
|
||||
if (json.compare(pos, 4, "true") == 0) {
|
||||
return true;
|
||||
}
|
||||
if (json.compare(pos, 5, "false") == 0) {
|
||||
return false;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
int64_t findInt64(const std::string& json, const std::string& key, int64_t fallback) {
|
||||
auto pos = json.find("\"" + key + "\"");
|
||||
if (pos == std::string::npos) {
|
||||
return fallback;
|
||||
}
|
||||
pos = json.find(':', pos);
|
||||
if (pos == std::string::npos) {
|
||||
return fallback;
|
||||
}
|
||||
pos += 1;
|
||||
while (pos < json.size() && std::isspace(static_cast<unsigned char>(json[pos]))) {
|
||||
pos += 1;
|
||||
}
|
||||
try {
|
||||
return std::stoll(json.substr(pos));
|
||||
} catch (...) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
int findInt(const std::string& json, const std::string& key, int fallback) {
|
||||
return static_cast<int>(findInt64(json, key, fallback));
|
||||
}
|
||||
|
||||
double findDouble(const std::string& json, const std::string& key, double fallback) {
|
||||
auto pos = json.find("\"" + key + "\"");
|
||||
if (pos == std::string::npos) {
|
||||
return fallback;
|
||||
}
|
||||
pos = json.find(':', pos);
|
||||
if (pos == std::string::npos) {
|
||||
return fallback;
|
||||
}
|
||||
pos += 1;
|
||||
while (pos < json.size() && std::isspace(static_cast<unsigned char>(json[pos]))) {
|
||||
pos += 1;
|
||||
}
|
||||
try {
|
||||
return std::stod(json.substr(pos));
|
||||
} catch (...) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
std::string findString(const std::string& json, const std::string& key) {
|
||||
auto pos = json.find("\"" + key + "\"");
|
||||
if (pos == std::string::npos) {
|
||||
return {};
|
||||
}
|
||||
pos = json.find(':', pos);
|
||||
if (pos == std::string::npos) {
|
||||
return {};
|
||||
}
|
||||
pos += 1;
|
||||
while (pos < json.size() && std::isspace(static_cast<unsigned char>(json[pos]))) {
|
||||
pos += 1;
|
||||
}
|
||||
if (pos >= json.size() || json[pos] != '"') {
|
||||
return {};
|
||||
}
|
||||
pos += 1;
|
||||
|
||||
std::string result;
|
||||
while (pos < json.size()) {
|
||||
const char c = json[pos++];
|
||||
if (c == '"') {
|
||||
break;
|
||||
}
|
||||
if (c == '\\' && pos < json.size()) {
|
||||
const char escaped = json[pos++];
|
||||
switch (escaped) {
|
||||
case '\\':
|
||||
case '"':
|
||||
case '/':
|
||||
result.push_back(escaped);
|
||||
break;
|
||||
case 'n':
|
||||
result.push_back('\n');
|
||||
break;
|
||||
case 'r':
|
||||
result.push_back('\r');
|
||||
break;
|
||||
case 't':
|
||||
result.push_back('\t');
|
||||
break;
|
||||
default:
|
||||
result.push_back(escaped);
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
result.push_back(c);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
std::string parseWindowHandleFromSourceId(const std::string& sourceId) {
|
||||
constexpr char prefix[] = "window:";
|
||||
if (sourceId.rfind(prefix, 0) != 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const size_t start = sizeof(prefix) - 1;
|
||||
const size_t end = sourceId.find(':', start);
|
||||
const std::string handle = sourceId.substr(start, end == std::string::npos ? std::string::npos : end - start);
|
||||
return handle.empty() ? std::string{} : handle;
|
||||
}
|
||||
|
||||
HWND parseWindowHandle(const std::string& value) {
|
||||
if (value.empty()) {
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
try {
|
||||
size_t parsed = 0;
|
||||
const int base = value.rfind("0x", 0) == 0 || value.rfind("0X", 0) == 0 ? 16 : 10;
|
||||
const uint64_t handleValue = std::stoull(value, &parsed, base);
|
||||
if (parsed != value.size() || handleValue == 0) {
|
||||
return nullptr;
|
||||
}
|
||||
return reinterpret_cast<HWND>(static_cast<uintptr_t>(handleValue));
|
||||
} catch (...) {
|
||||
return nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
bool parseConfig(const std::string& json, CaptureConfig& config) {
|
||||
config.schemaVersion = findInt(json, "schemaVersion", 1);
|
||||
config.outputPath = findString(json, "screenPath");
|
||||
if (config.outputPath.empty()) {
|
||||
config.outputPath = findString(json, "outputPath");
|
||||
}
|
||||
if (config.outputPath.empty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
config.recordingId = findInt64(json, "recordingId", 0);
|
||||
config.sourceType = findString(json, "sourceType");
|
||||
if (config.sourceType.empty()) {
|
||||
config.sourceType = "display";
|
||||
}
|
||||
config.sourceId = findString(json, "sourceId");
|
||||
config.windowHandle = findString(json, "windowHandle");
|
||||
if (config.windowHandle.empty()) {
|
||||
config.windowHandle = parseWindowHandleFromSourceId(config.sourceId);
|
||||
}
|
||||
config.displayId = findInt64(json, "displayId", 0);
|
||||
config.fps = std::clamp(findInt(json, "fps", 60), 1, 120);
|
||||
config.width = findInt(json, "videoWidth", findInt(json, "width", 0));
|
||||
config.height = findInt(json, "videoHeight", findInt(json, "height", 0));
|
||||
config.bounds.x = findInt(json, "displayX", 0);
|
||||
config.bounds.y = findInt(json, "displayY", 0);
|
||||
config.bounds.width = findInt(json, "displayW", 0);
|
||||
config.bounds.height = findInt(json, "displayH", 0);
|
||||
config.hasDisplayBounds = findBool(json, "hasDisplayBounds", false);
|
||||
config.captureSystemAudio = findBool(json, "captureSystemAudio", false);
|
||||
config.captureMic = findBool(json, "captureMic", false);
|
||||
config.captureCursor = findBool(json, "captureCursor", false);
|
||||
config.webcamEnabled = findBool(json, "webcamEnabled", false);
|
||||
config.microphoneDeviceId = findString(json, "microphoneDeviceId");
|
||||
config.microphoneDeviceName = findString(json, "microphoneDeviceName");
|
||||
config.microphoneGain = findDouble(json, "microphoneGain", 1.0);
|
||||
config.webcamDeviceId = findString(json, "webcamDeviceId");
|
||||
config.webcamDeviceName = findString(json, "webcamDeviceName");
|
||||
config.webcamDirectShowClsid = findString(json, "webcamDirectShowClsid");
|
||||
config.webcamOutputPath = findString(json, "webcamPath");
|
||||
config.webcamWidth = findInt(json, "webcamWidth", 0);
|
||||
config.webcamHeight = findInt(json, "webcamHeight", 0);
|
||||
config.webcamFps = findInt(json, "webcamFps", 0);
|
||||
return true;
|
||||
}
|
||||
|
||||
void readCaptureCommands(CaptureControl& control, const std::function<void(bool)>& onPauseChanged) {
|
||||
std::string line;
|
||||
while (std::getline(std::cin, line)) {
|
||||
if (line == "stop" || line == "q" || line == "quit") {
|
||||
control.stopRequested = true;
|
||||
control.cv.notify_all();
|
||||
return;
|
||||
}
|
||||
if (line == "pause") {
|
||||
control.setPaused(true);
|
||||
onPauseChanged(true);
|
||||
std::cout << "{\"event\":\"recording-paused\",\"schemaVersion\":2}" << std::endl;
|
||||
control.cv.notify_all();
|
||||
continue;
|
||||
}
|
||||
if (line == "resume") {
|
||||
control.setPaused(false);
|
||||
onPauseChanged(false);
|
||||
std::cout << "{\"event\":\"recording-resumed\",\"schemaVersion\":2}" << std::endl;
|
||||
control.cv.notify_all();
|
||||
continue;
|
||||
}
|
||||
}
|
||||
control.stopRequested = true;
|
||||
control.cv.notify_all();
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main(int argc, char* argv[]) {
|
||||
if (argc < 2) {
|
||||
std::cerr << "ERROR: Missing JSON config argument" << std::endl;
|
||||
return 1;
|
||||
}
|
||||
|
||||
winrt::init_apartment(winrt::apartment_type::multi_threaded);
|
||||
|
||||
CaptureConfig config;
|
||||
if (!parseConfig(argv[1], config)) {
|
||||
std::cerr << "ERROR: Failed to parse config JSON" << std::endl;
|
||||
return 1;
|
||||
}
|
||||
|
||||
std::cout << "{\"event\":\"ready\",\"schemaVersion\":2}" << std::endl;
|
||||
|
||||
WgcSession session;
|
||||
if (config.sourceType == "display") {
|
||||
HMONITOR monitor = findMonitorForCapture(
|
||||
config.displayId,
|
||||
config.hasDisplayBounds ? &config.bounds : nullptr);
|
||||
if (!monitor) {
|
||||
std::cerr << "ERROR: Could not resolve monitor" << std::endl;
|
||||
return 1;
|
||||
}
|
||||
if (!session.initialize(monitor, config.fps, config.captureCursor)) {
|
||||
std::cerr << "ERROR: Failed to initialize WGC display session" << std::endl;
|
||||
return 1;
|
||||
}
|
||||
} else if (config.sourceType == "window") {
|
||||
HWND window = parseWindowHandle(config.windowHandle);
|
||||
if (!window || !IsWindow(window)) {
|
||||
std::cerr << "ERROR: Native window capture requires a valid HWND" << std::endl;
|
||||
return 1;
|
||||
}
|
||||
if (!session.initialize(window, config.fps, config.captureCursor)) {
|
||||
std::cerr << "ERROR: Failed to initialize WGC window session" << std::endl;
|
||||
return 1;
|
||||
}
|
||||
} else {
|
||||
std::cerr << "ERROR: Unsupported native capture source type: " << config.sourceType << std::endl;
|
||||
return 1;
|
||||
}
|
||||
|
||||
// WGC owns the captured texture size. Encoding must use that exact size
|
||||
// until a dedicated GPU scaling pass is introduced; CopyResource requires
|
||||
// matching resource dimensions.
|
||||
int width = session.captureWidth();
|
||||
int height = session.captureHeight();
|
||||
width = (std::max(2, width) / 2) * 2;
|
||||
height = (std::max(2, height) / 2) * 2;
|
||||
|
||||
const int pixels = width * height;
|
||||
const int bitrate = pixels >= 3840 * 2160 ? 45'000'000 : pixels >= 2560 * 1440 ? 28'000'000 : 18'000'000;
|
||||
|
||||
WebcamCapture webcamCapture;
|
||||
bool webcamActive = false;
|
||||
bool writeSeparateWebcam = false;
|
||||
if (config.webcamEnabled) {
|
||||
if (!webcamCapture.initialize(
|
||||
utf8ToWide(config.webcamDeviceId),
|
||||
utf8ToWide(config.webcamDeviceName),
|
||||
utf8ToWide(config.webcamDirectShowClsid),
|
||||
config.webcamWidth,
|
||||
config.webcamHeight,
|
||||
config.webcamFps > 0 ? config.webcamFps : config.fps)) {
|
||||
std::cerr << "ERROR: Failed to initialize native webcam capture" << std::endl;
|
||||
return 1;
|
||||
}
|
||||
std::cout << "{\"event\":\"webcam-format\",\"schemaVersion\":2,\"width\":" << webcamCapture.width()
|
||||
<< ",\"height\":" << webcamCapture.height()
|
||||
<< ",\"fps\":" << webcamCapture.fps()
|
||||
<< ",\"deviceName\":\"" << jsonEscape(wideToUtf8(webcamCapture.selectedDeviceName()))
|
||||
<< "\"}" << std::endl;
|
||||
writeSeparateWebcam = !config.webcamOutputPath.empty();
|
||||
}
|
||||
|
||||
WasapiLoopbackCapture loopbackCapture;
|
||||
WasapiLoopbackCapture microphoneCapture;
|
||||
const AudioInputFormat* audioFormat = nullptr;
|
||||
AudioInputFormat encoderAudioFormat{};
|
||||
AudioInputFormat systemAudioFormat{};
|
||||
AudioInputFormat microphoneAudioFormat{};
|
||||
if (config.captureSystemAudio) {
|
||||
if (!loopbackCapture.initializeSystemLoopback()) {
|
||||
std::cerr << "ERROR: Failed to initialize WASAPI loopback capture" << std::endl;
|
||||
return 1;
|
||||
}
|
||||
systemAudioFormat = loopbackCapture.inputFormat();
|
||||
audioFormat = &loopbackCapture.inputFormat();
|
||||
}
|
||||
if (config.captureMic) {
|
||||
if (!microphoneCapture.initializeMicrophone(
|
||||
utf8ToWide(config.microphoneDeviceId),
|
||||
utf8ToWide(config.microphoneDeviceName))) {
|
||||
std::cerr << "ERROR: Failed to initialize WASAPI microphone capture" << std::endl;
|
||||
return 1;
|
||||
}
|
||||
microphoneAudioFormat = microphoneCapture.inputFormat();
|
||||
if (!audioFormat) {
|
||||
audioFormat = µphoneCapture.inputFormat();
|
||||
}
|
||||
}
|
||||
if (audioFormat) {
|
||||
std::cout << "{\"event\":\"audio-format\",\"schemaVersion\":2,\"sampleRate\":" << audioFormat->sampleRate
|
||||
<< ",\"channels\":" << audioFormat->channels
|
||||
<< ",\"bitsPerSample\":" << audioFormat->bitsPerSample
|
||||
<< ",\"system\":" << (config.captureSystemAudio ? "true" : "false")
|
||||
<< ",\"microphone\":" << (config.captureMic ? "true" : "false");
|
||||
if (config.captureMic) {
|
||||
std::cout << ",\"microphoneDeviceName\":\""
|
||||
<< jsonEscape(wideToUtf8(microphoneCapture.selectedDeviceName())) << "\"";
|
||||
}
|
||||
std::cout << "}" << std::endl;
|
||||
encoderAudioFormat = makeAacCompatibleAudioFormat(*audioFormat);
|
||||
std::cout << "{\"event\":\"encoder-audio-format\",\"schemaVersion\":2,\"sampleRate\":"
|
||||
<< encoderAudioFormat.sampleRate
|
||||
<< ",\"channels\":" << encoderAudioFormat.channels
|
||||
<< ",\"bitsPerSample\":" << encoderAudioFormat.bitsPerSample
|
||||
<< "}" << std::endl;
|
||||
}
|
||||
|
||||
MFEncoder encoder;
|
||||
if (!encoder.initialize(
|
||||
utf8ToWide(config.outputPath),
|
||||
width,
|
||||
height,
|
||||
config.fps,
|
||||
bitrate,
|
||||
session.device(),
|
||||
session.context(),
|
||||
audioFormat ? &encoderAudioFormat : nullptr)) {
|
||||
std::cerr << "ERROR: Failed to initialize Media Foundation encoder" << std::endl;
|
||||
return 1;
|
||||
}
|
||||
|
||||
MFEncoder webcamEncoder;
|
||||
if (writeSeparateWebcam) {
|
||||
const int webcamPixels = std::max(1, webcamCapture.width()) * std::max(1, webcamCapture.height());
|
||||
const int webcamBitrate = webcamPixels >= 1280 * 720 ? 8'000'000 : 4'000'000;
|
||||
if (!webcamEncoder.initialize(
|
||||
utf8ToWide(config.webcamOutputPath),
|
||||
webcamCapture.width(),
|
||||
webcamCapture.height(),
|
||||
webcamCapture.fps(),
|
||||
webcamBitrate,
|
||||
session.device(),
|
||||
session.context(),
|
||||
nullptr)) {
|
||||
std::cerr << "ERROR: Failed to initialize native webcam encoder" << std::endl;
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
std::mutex mutex;
|
||||
CaptureControl control;
|
||||
std::atomic<bool> firstFrameWritten = false;
|
||||
std::atomic<bool> encodeFailed = false;
|
||||
Microsoft::WRL::ComPtr<ID3D11Texture2D> latestFrameTexture;
|
||||
int64_t latestFrameTimestampHns = 0;
|
||||
int64_t firstFrameTimestampHns = -1;
|
||||
std::vector<BYTE> latestWebcamFrame;
|
||||
int latestWebcamWidth = 0;
|
||||
int latestWebcamHeight = 0;
|
||||
uint64_t latestWebcamSequence = 0;
|
||||
bool hasVisibleWebcamFrame = false;
|
||||
|
||||
session.setFrameCallback([&](ID3D11Texture2D* texture, int64_t timestampHns) {
|
||||
if (control.stopRequested || control.paused) {
|
||||
return;
|
||||
}
|
||||
|
||||
std::scoped_lock lock(mutex);
|
||||
if (!latestFrameTexture) {
|
||||
D3D11_TEXTURE2D_DESC desc{};
|
||||
texture->GetDesc(&desc);
|
||||
desc.BindFlags = 0;
|
||||
desc.CPUAccessFlags = 0;
|
||||
desc.MiscFlags = 0;
|
||||
if (FAILED(session.device()->CreateTexture2D(&desc, nullptr, &latestFrameTexture))) {
|
||||
encodeFailed = true;
|
||||
control.stopRequested = true;
|
||||
control.cv.notify_all();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
session.context()->CopyResource(latestFrameTexture.Get(), texture);
|
||||
latestFrameTimestampHns = timestampHns;
|
||||
if (!firstFrameWritten.exchange(true)) {
|
||||
control.cv.notify_all();
|
||||
}
|
||||
});
|
||||
|
||||
auto writeVideoFrames = [&]() {
|
||||
const auto frameDuration = std::chrono::duration_cast<std::chrono::steady_clock::duration>(
|
||||
std::chrono::duration<double>(1.0 / config.fps));
|
||||
uint64_t frameIndex = 0;
|
||||
uint64_t lastWrittenWebcamSequence = 0;
|
||||
uint64_t webcamOutputFrameIndex = 0;
|
||||
int64_t lastEncodedVideoTimestampHns = -1;
|
||||
|
||||
while (!control.stopRequested && !encodeFailed) {
|
||||
{
|
||||
std::unique_lock lock(mutex);
|
||||
control.cv.wait(lock, [&] {
|
||||
return control.stopRequested.load() ||
|
||||
encodeFailed.load() ||
|
||||
(!control.paused.load() && latestFrameTexture);
|
||||
});
|
||||
if (control.stopRequested || encodeFailed) {
|
||||
break;
|
||||
}
|
||||
if (webcamActive) {
|
||||
WebcamFrameSnapshot candidateWebcamFrame;
|
||||
if (webcamCapture.copyLatestFrame(candidateWebcamFrame) &&
|
||||
candidateWebcamFrame.sequence != latestWebcamSequence &&
|
||||
hasVisibleBgraContent(candidateWebcamFrame.data)) {
|
||||
latestWebcamFrame = std::move(candidateWebcamFrame.data);
|
||||
latestWebcamWidth = candidateWebcamFrame.width;
|
||||
latestWebcamHeight = candidateWebcamFrame.height;
|
||||
latestWebcamSequence = candidateWebcamFrame.sequence;
|
||||
hasVisibleWebcamFrame = true;
|
||||
}
|
||||
}
|
||||
const BgraFrameView webcamFrame{
|
||||
hasVisibleWebcamFrame && !latestWebcamFrame.empty() ? latestWebcamFrame.data() : nullptr,
|
||||
latestWebcamWidth,
|
||||
latestWebcamHeight,
|
||||
};
|
||||
const int64_t syntheticTimestampHns =
|
||||
static_cast<int64_t>((frameIndex * 10'000'000ULL) / config.fps);
|
||||
const int64_t sourceTimestampHns =
|
||||
latestFrameTimestampHns > 0 ? latestFrameTimestampHns : syntheticTimestampHns;
|
||||
if (firstFrameTimestampHns < 0) {
|
||||
firstFrameTimestampHns = sourceTimestampHns;
|
||||
}
|
||||
int64_t frameTimestampHns =
|
||||
std::max<int64_t>(
|
||||
0,
|
||||
sourceTimestampHns - firstFrameTimestampHns - control.pausedDurationHns());
|
||||
if (lastEncodedVideoTimestampHns >= 0 &&
|
||||
frameTimestampHns <= lastEncodedVideoTimestampHns) {
|
||||
frameTimestampHns =
|
||||
lastEncodedVideoTimestampHns + static_cast<int64_t>(10'000'000ULL / config.fps);
|
||||
}
|
||||
if (writeSeparateWebcam && webcamFrame.data &&
|
||||
latestWebcamSequence != lastWrittenWebcamSequence) {
|
||||
const int64_t webcamTimestampHns = static_cast<int64_t>(
|
||||
(webcamOutputFrameIndex * 10'000'000ULL) / std::max(1, webcamCapture.fps()));
|
||||
if (!webcamEncoder.writeBgraFrame(webcamFrame, webcamTimestampHns)) {
|
||||
encodeFailed = true;
|
||||
control.stopRequested = true;
|
||||
control.cv.notify_all();
|
||||
return;
|
||||
}
|
||||
lastWrittenWebcamSequence = latestWebcamSequence;
|
||||
webcamOutputFrameIndex += 1;
|
||||
}
|
||||
if (latestFrameTexture && !encoder.writeFrame(
|
||||
latestFrameTexture.Get(),
|
||||
frameTimestampHns,
|
||||
!writeSeparateWebcam && webcamFrame.data ? &webcamFrame : nullptr)) {
|
||||
encodeFailed = true;
|
||||
control.stopRequested = true;
|
||||
control.cv.notify_all();
|
||||
return;
|
||||
}
|
||||
if (latestFrameTexture) {
|
||||
lastEncodedVideoTimestampHns = frameTimestampHns;
|
||||
}
|
||||
}
|
||||
|
||||
frameIndex += 1;
|
||||
std::this_thread::sleep_for(frameDuration);
|
||||
}
|
||||
};
|
||||
|
||||
std::thread videoWriterThread;
|
||||
|
||||
auto stopVideoWriter = [&]() {
|
||||
if (videoWriterThread.joinable()) {
|
||||
videoWriterThread.join();
|
||||
}
|
||||
};
|
||||
|
||||
auto startVideoWriter = [&]() {
|
||||
videoWriterThread = std::thread(writeVideoFrames);
|
||||
};
|
||||
|
||||
std::unique_ptr<AudioMixer> audioMixer;
|
||||
auto startAudioCaptures = [&]() -> bool {
|
||||
if (!audioFormat) {
|
||||
return true;
|
||||
}
|
||||
|
||||
audioMixer = std::make_unique<AudioMixer>(
|
||||
encoderAudioFormat,
|
||||
config.captureSystemAudio ? systemAudioFormat : encoderAudioFormat,
|
||||
config.captureMic ? microphoneAudioFormat : encoderAudioFormat,
|
||||
config.captureSystemAudio,
|
||||
config.captureMic,
|
||||
config.microphoneGain,
|
||||
[&](const BYTE* data, DWORD byteCount, int64_t timestampHns, int64_t durationHns) {
|
||||
if (!encoder.writeAudio(data, byteCount, timestampHns, durationHns)) {
|
||||
encodeFailed = true;
|
||||
control.stopRequested = true;
|
||||
control.cv.notify_all();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
if (!audioMixer->start()) {
|
||||
std::cerr << "ERROR: Failed to start native audio mixer" << std::endl;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (config.captureMic) {
|
||||
if (!microphoneCapture.start([&](const BYTE* data, DWORD byteCount, int64_t timestampHns, int64_t durationHns) {
|
||||
(void)timestampHns;
|
||||
(void)durationHns;
|
||||
if (control.stopRequested || !audioMixer) {
|
||||
return;
|
||||
}
|
||||
|
||||
audioMixer->pushMicrophone(data, byteCount);
|
||||
})) {
|
||||
std::cerr << "ERROR: Failed to start WASAPI microphone capture" << std::endl;
|
||||
audioMixer->stop();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (config.captureSystemAudio) {
|
||||
if (!loopbackCapture.start([&](const BYTE* data, DWORD byteCount, int64_t timestampHns, int64_t durationHns) {
|
||||
(void)timestampHns;
|
||||
(void)durationHns;
|
||||
if (control.stopRequested || !audioMixer) {
|
||||
return;
|
||||
}
|
||||
|
||||
audioMixer->pushSystem(data, byteCount);
|
||||
})) {
|
||||
std::cerr << "ERROR: Failed to start WASAPI loopback capture" << std::endl;
|
||||
microphoneCapture.stop();
|
||||
audioMixer->stop();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
if (!startAudioCaptures()) {
|
||||
return 1;
|
||||
}
|
||||
if (config.webcamEnabled) {
|
||||
if (!webcamCapture.start()) {
|
||||
microphoneCapture.stop();
|
||||
loopbackCapture.stop();
|
||||
if (audioMixer) {
|
||||
audioMixer->stop();
|
||||
}
|
||||
std::cerr << "ERROR: Failed to start native webcam capture" << std::endl;
|
||||
return 1;
|
||||
}
|
||||
webcamActive = true;
|
||||
const auto webcamDeadline = std::chrono::steady_clock::now() + std::chrono::seconds(3);
|
||||
while (std::chrono::steady_clock::now() < webcamDeadline && !hasVisibleWebcamFrame) {
|
||||
WebcamFrameSnapshot candidateWebcamFrame;
|
||||
if (webcamCapture.copyLatestFrame(candidateWebcamFrame) &&
|
||||
hasVisibleBgraContent(candidateWebcamFrame.data)) {
|
||||
latestWebcamFrame = std::move(candidateWebcamFrame.data);
|
||||
latestWebcamWidth = candidateWebcamFrame.width;
|
||||
latestWebcamHeight = candidateWebcamFrame.height;
|
||||
latestWebcamSequence = candidateWebcamFrame.sequence;
|
||||
hasVisibleWebcamFrame = true;
|
||||
break;
|
||||
}
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(20));
|
||||
}
|
||||
if (!hasVisibleWebcamFrame) {
|
||||
std::cerr << "WARNING: Native webcam started but no visible frame was available before screen capture"
|
||||
<< std::endl;
|
||||
}
|
||||
}
|
||||
|
||||
if (!session.start()) {
|
||||
webcamCapture.stop();
|
||||
microphoneCapture.stop();
|
||||
loopbackCapture.stop();
|
||||
if (audioMixer) {
|
||||
audioMixer->stop();
|
||||
}
|
||||
std::cerr << "ERROR: Failed to start WGC session" << std::endl;
|
||||
return 1;
|
||||
}
|
||||
|
||||
std::thread stdinThread(readCaptureCommands, std::ref(control), [&](bool isPaused) {
|
||||
if (audioMixer) {
|
||||
audioMixer->setPaused(isPaused);
|
||||
}
|
||||
});
|
||||
|
||||
{
|
||||
std::unique_lock lock(mutex);
|
||||
const bool started = control.cv.wait_for(lock, std::chrono::seconds(10), [&] {
|
||||
return firstFrameWritten.load() || control.stopRequested.load();
|
||||
});
|
||||
if (!started || !firstFrameWritten) {
|
||||
control.stopRequested = true;
|
||||
control.cv.notify_all();
|
||||
if (stdinThread.joinable()) {
|
||||
stdinThread.detach();
|
||||
}
|
||||
microphoneCapture.stop();
|
||||
loopbackCapture.stop();
|
||||
webcamCapture.stop();
|
||||
if (audioMixer) {
|
||||
audioMixer->stop();
|
||||
}
|
||||
session.stop();
|
||||
std::cerr << "ERROR: Timed out waiting for first WGC frame" << std::endl;
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (audioMixer) {
|
||||
audioMixer->beginTimeline();
|
||||
}
|
||||
startVideoWriter();
|
||||
|
||||
std::cout << "{\"event\":\"recording-started\",\"schemaVersion\":2}" << std::endl;
|
||||
std::cout << "Recording started" << std::endl;
|
||||
|
||||
{
|
||||
std::unique_lock lock(mutex);
|
||||
control.cv.wait(lock, [&] {
|
||||
return control.stopRequested.load();
|
||||
});
|
||||
}
|
||||
|
||||
microphoneCapture.stop();
|
||||
loopbackCapture.stop();
|
||||
webcamCapture.stop();
|
||||
if (audioMixer) {
|
||||
audioMixer->stop();
|
||||
}
|
||||
stopVideoWriter();
|
||||
session.stop();
|
||||
{
|
||||
std::scoped_lock lock(mutex);
|
||||
encoder.finalize();
|
||||
if (writeSeparateWebcam) {
|
||||
webcamEncoder.finalize();
|
||||
}
|
||||
}
|
||||
|
||||
if (stdinThread.joinable()) {
|
||||
stdinThread.detach();
|
||||
}
|
||||
|
||||
if (encodeFailed) {
|
||||
std::cerr << "ERROR: Failed to encode WGC frame" << std::endl;
|
||||
return 1;
|
||||
}
|
||||
|
||||
std::cout << "{\"event\":\"recording-stopped\",\"schemaVersion\":2,\"screenPath\":\""
|
||||
<< jsonEscape(config.outputPath) << "\"";
|
||||
if (writeSeparateWebcam) {
|
||||
std::cout << ",\"webcamPath\":\"" << jsonEscape(config.webcamOutputPath) << "\"";
|
||||
}
|
||||
std::cout << "}" << std::endl;
|
||||
std::cout << "Recording stopped. Output path: " << config.outputPath << std::endl;
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,450 @@
|
||||
#include "mf_encoder.h"
|
||||
|
||||
#include "audio_sample_utils.h"
|
||||
|
||||
#include <mfapi.h>
|
||||
#include <mferror.h>
|
||||
#include <propvarutil.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
#include <iostream>
|
||||
|
||||
namespace {
|
||||
|
||||
bool succeeded(HRESULT hr, const char* label) {
|
||||
if (SUCCEEDED(hr)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
std::cerr << "ERROR: " << label << " failed (hr=0x" << std::hex << hr << std::dec << ")"
|
||||
<< std::endl;
|
||||
return false;
|
||||
}
|
||||
|
||||
void setFrameSize(IMFMediaType* type, UINT32 width, UINT32 height) {
|
||||
MFSetAttributeSize(type, MF_MT_FRAME_SIZE, width, height);
|
||||
}
|
||||
|
||||
void setFrameRate(IMFMediaType* type, UINT32 fps) {
|
||||
MFSetAttributeRatio(type, MF_MT_FRAME_RATE, fps, 1);
|
||||
}
|
||||
|
||||
void setPixelAspectRatio(IMFMediaType* type) {
|
||||
MFSetAttributeRatio(type, MF_MT_PIXEL_ASPECT_RATIO, 1, 1);
|
||||
}
|
||||
|
||||
void setAudioFormat(IMFMediaType* type, UINT32 channels, UINT32 sampleRate, UINT32 bitsPerSample) {
|
||||
type->SetUINT32(MF_MT_AUDIO_NUM_CHANNELS, channels);
|
||||
type->SetUINT32(MF_MT_AUDIO_SAMPLES_PER_SECOND, sampleRate);
|
||||
type->SetUINT32(MF_MT_AUDIO_BITS_PER_SAMPLE, bitsPerSample);
|
||||
}
|
||||
|
||||
void compositeWebcam(BYTE* destination, int width, int height, const BgraFrameView& webcamFrame) {
|
||||
if (!webcamFrame.data || webcamFrame.width <= 0 || webcamFrame.height <= 0 || width <= 0 || height <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const int margin = std::max(16, std::min(width, height) / 60);
|
||||
const int maxOverlayWidth = std::max(2, width / 4);
|
||||
int overlayWidth = maxOverlayWidth;
|
||||
int overlayHeight = static_cast<int>(
|
||||
(static_cast<int64_t>(overlayWidth) * webcamFrame.height) / std::max(1, webcamFrame.width));
|
||||
const int maxOverlayHeight = std::max(2, height / 3);
|
||||
if (overlayHeight > maxOverlayHeight) {
|
||||
overlayHeight = maxOverlayHeight;
|
||||
overlayWidth = static_cast<int>(
|
||||
(static_cast<int64_t>(overlayHeight) * webcamFrame.width) / std::max(1, webcamFrame.height));
|
||||
}
|
||||
|
||||
overlayWidth = std::max(2, std::min(overlayWidth, width - margin * 2));
|
||||
overlayHeight = std::max(2, std::min(overlayHeight, height - margin * 2));
|
||||
const int originX = std::max(0, width - overlayWidth - margin);
|
||||
const int originY = std::max(0, height - overlayHeight - margin);
|
||||
|
||||
for (int y = 0; y < overlayHeight; y += 1) {
|
||||
const int sourceY = static_cast<int>((static_cast<int64_t>(y) * webcamFrame.height) / overlayHeight);
|
||||
BYTE* destinationRow = destination + ((originY + y) * width + originX) * 4;
|
||||
for (int x = 0; x < overlayWidth; x += 1) {
|
||||
const int sourceX = static_cast<int>((static_cast<int64_t>(x) * webcamFrame.width) / overlayWidth);
|
||||
const BYTE* source = webcamFrame.data + (sourceY * webcamFrame.width + sourceX) * 4;
|
||||
BYTE* target = destinationRow + x * 4;
|
||||
target[0] = source[0];
|
||||
target[1] = source[1];
|
||||
target[2] = source[2];
|
||||
target[3] = 255;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
MFEncoder::~MFEncoder() {
|
||||
finalize();
|
||||
}
|
||||
|
||||
bool MFEncoder::initialize(
|
||||
const std::wstring& outputPath,
|
||||
int width,
|
||||
int height,
|
||||
int fps,
|
||||
int bitrate,
|
||||
ID3D11Device* device,
|
||||
ID3D11DeviceContext* context,
|
||||
const AudioInputFormat* audioFormat) {
|
||||
width_ = (std::max(2, width) / 2) * 2;
|
||||
height_ = (std::max(2, height) / 2) * 2;
|
||||
fps_ = std::max(1, fps);
|
||||
device_ = device;
|
||||
context_ = context;
|
||||
|
||||
if (!succeeded(MFStartup(MF_VERSION), "MFStartup")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
Microsoft::WRL::ComPtr<IMFMediaType> outputType;
|
||||
if (!succeeded(MFCreateMediaType(&outputType), "MFCreateMediaType(output)")) {
|
||||
return false;
|
||||
}
|
||||
outputType->SetGUID(MF_MT_MAJOR_TYPE, MFMediaType_Video);
|
||||
outputType->SetGUID(MF_MT_SUBTYPE, MFVideoFormat_H264);
|
||||
outputType->SetUINT32(MF_MT_AVG_BITRATE, static_cast<UINT32>(std::max(1, bitrate)));
|
||||
outputType->SetUINT32(MF_MT_INTERLACE_MODE, MFVideoInterlace_Progressive);
|
||||
setFrameSize(outputType.Get(), static_cast<UINT32>(width_), static_cast<UINT32>(height_));
|
||||
setFrameRate(outputType.Get(), static_cast<UINT32>(fps_));
|
||||
setPixelAspectRatio(outputType.Get());
|
||||
|
||||
if (!succeeded(MFCreateSinkWriterFromURL(outputPath.c_str(), nullptr, nullptr, &sinkWriter_),
|
||||
"MFCreateSinkWriterFromURL")) {
|
||||
return false;
|
||||
}
|
||||
if (!succeeded(sinkWriter_->AddStream(outputType.Get(), &videoStreamIndex_), "AddStream")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (audioFormat && !configureAudioStream(*audioFormat)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
Microsoft::WRL::ComPtr<IMFMediaType> inputType;
|
||||
if (!succeeded(MFCreateMediaType(&inputType), "MFCreateMediaType(input)")) {
|
||||
return false;
|
||||
}
|
||||
inputType->SetGUID(MF_MT_MAJOR_TYPE, MFMediaType_Video);
|
||||
inputType->SetGUID(MF_MT_SUBTYPE, MFVideoFormat_RGB32);
|
||||
inputType->SetUINT32(MF_MT_INTERLACE_MODE, MFVideoInterlace_Progressive);
|
||||
inputType->SetUINT32(MF_MT_DEFAULT_STRIDE, static_cast<UINT32>(width_ * 4));
|
||||
setFrameSize(inputType.Get(), static_cast<UINT32>(width_), static_cast<UINT32>(height_));
|
||||
setFrameRate(inputType.Get(), static_cast<UINT32>(fps_));
|
||||
setPixelAspectRatio(inputType.Get());
|
||||
|
||||
if (!succeeded(sinkWriter_->SetInputMediaType(videoStreamIndex_, inputType.Get(), nullptr),
|
||||
"SetInputMediaType")) {
|
||||
return false;
|
||||
}
|
||||
if (!succeeded(sinkWriter_->BeginWriting(), "BeginWriting")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
bool MFEncoder::configureAudioStream(const AudioInputFormat& audioFormat) {
|
||||
if (!sinkWriter_) {
|
||||
return false;
|
||||
}
|
||||
if (audioFormat.sampleRate == 0 || audioFormat.channels == 0 || audioFormat.blockAlign == 0) {
|
||||
std::cerr << "ERROR: Invalid audio input format" << std::endl;
|
||||
return false;
|
||||
}
|
||||
|
||||
const AudioInputFormat encoderFormat = makeAacCompatibleAudioFormat(audioFormat);
|
||||
const UINT32 aacBytesPerSecond = 24'000;
|
||||
|
||||
Microsoft::WRL::ComPtr<IMFMediaType> outputType;
|
||||
if (!succeeded(MFCreateMediaType(&outputType), "MFCreateMediaType(audio output)")) {
|
||||
return false;
|
||||
}
|
||||
outputType->SetGUID(MF_MT_MAJOR_TYPE, MFMediaType_Audio);
|
||||
outputType->SetGUID(MF_MT_SUBTYPE, MFAudioFormat_AAC);
|
||||
setAudioFormat(outputType.Get(), encoderFormat.channels, encoderFormat.sampleRate, 16);
|
||||
outputType->SetUINT32(MF_MT_AUDIO_AVG_BYTES_PER_SECOND, aacBytesPerSecond);
|
||||
outputType->SetUINT32(MF_MT_AAC_PAYLOAD_TYPE, 0);
|
||||
|
||||
if (!succeeded(sinkWriter_->AddStream(outputType.Get(), &audioStreamIndex_), "AddStream(audio)")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
Microsoft::WRL::ComPtr<IMFMediaType> inputType;
|
||||
if (!succeeded(MFCreateMediaType(&inputType), "MFCreateMediaType(audio input)")) {
|
||||
return false;
|
||||
}
|
||||
inputType->SetGUID(MF_MT_MAJOR_TYPE, MFMediaType_Audio);
|
||||
inputType->SetGUID(MF_MT_SUBTYPE, encoderFormat.subtype);
|
||||
setAudioFormat(inputType.Get(), encoderFormat.channels, encoderFormat.sampleRate, encoderFormat.bitsPerSample);
|
||||
inputType->SetUINT32(MF_MT_AUDIO_BLOCK_ALIGNMENT, encoderFormat.blockAlign);
|
||||
inputType->SetUINT32(MF_MT_AUDIO_AVG_BYTES_PER_SECOND, encoderFormat.avgBytesPerSec);
|
||||
inputType->SetUINT32(MF_MT_ALL_SAMPLES_INDEPENDENT, TRUE);
|
||||
|
||||
if (!succeeded(sinkWriter_->SetInputMediaType(audioStreamIndex_, inputType.Get(), nullptr),
|
||||
"SetInputMediaType(audio)")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
hasAudioStream_ = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool MFEncoder::ensureStagingTexture(ID3D11Texture2D* texture) {
|
||||
if (stagingTexture_) {
|
||||
return true;
|
||||
}
|
||||
|
||||
D3D11_TEXTURE2D_DESC desc{};
|
||||
texture->GetDesc(&desc);
|
||||
desc.Width = static_cast<UINT>(width_);
|
||||
desc.Height = static_cast<UINT>(height_);
|
||||
desc.MipLevels = 1;
|
||||
desc.ArraySize = 1;
|
||||
desc.Format = DXGI_FORMAT_B8G8R8A8_UNORM;
|
||||
desc.SampleDesc.Count = 1;
|
||||
desc.SampleDesc.Quality = 0;
|
||||
desc.Usage = D3D11_USAGE_STAGING;
|
||||
desc.BindFlags = 0;
|
||||
desc.CPUAccessFlags = D3D11_CPU_ACCESS_READ;
|
||||
desc.MiscFlags = 0;
|
||||
|
||||
return succeeded(device_->CreateTexture2D(&desc, nullptr, &stagingTexture_),
|
||||
"CreateTexture2D(staging)");
|
||||
}
|
||||
|
||||
bool MFEncoder::copyFrameToBuffer(
|
||||
ID3D11Texture2D* texture,
|
||||
BYTE* destination,
|
||||
DWORD destinationSize,
|
||||
const BgraFrameView* webcamFrame) {
|
||||
if (!ensureStagingTexture(texture)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
context_->CopyResource(stagingTexture_.Get(), texture);
|
||||
|
||||
D3D11_MAPPED_SUBRESOURCE mapped{};
|
||||
if (!succeeded(context_->Map(stagingTexture_.Get(), 0, D3D11_MAP_READ, 0, &mapped), "Map")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const DWORD rowBytes = static_cast<DWORD>(width_ * 4);
|
||||
const DWORD requiredBytes = rowBytes * static_cast<DWORD>(height_);
|
||||
if (destinationSize < requiredBytes) {
|
||||
context_->Unmap(stagingTexture_.Get(), 0);
|
||||
std::cerr << "ERROR: Media Foundation buffer is too small" << std::endl;
|
||||
return false;
|
||||
}
|
||||
|
||||
auto* source = static_cast<const BYTE*>(mapped.pData);
|
||||
for (int y = 0; y < height_; y += 1) {
|
||||
std::memcpy(destination + rowBytes * y, source + mapped.RowPitch * y, rowBytes);
|
||||
}
|
||||
if (webcamFrame) {
|
||||
compositeWebcam(destination, width_, height_, *webcamFrame);
|
||||
}
|
||||
|
||||
context_->Unmap(stagingTexture_.Get(), 0);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool MFEncoder::copyBgraFrameToBuffer(const BgraFrameView& frame, BYTE* destination, DWORD destinationSize) {
|
||||
if (!frame.data || frame.width <= 0 || frame.height <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const DWORD rowBytes = static_cast<DWORD>(width_ * 4);
|
||||
const DWORD requiredBytes = rowBytes * static_cast<DWORD>(height_);
|
||||
if (destinationSize < requiredBytes) {
|
||||
std::cerr << "ERROR: Media Foundation webcam buffer is too small" << std::endl;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (frame.width == width_ && frame.height == height_) {
|
||||
for (DWORD i = 0; i < requiredBytes; i += 4) {
|
||||
destination[i] = frame.data[i];
|
||||
destination[i + 1] = frame.data[i + 1];
|
||||
destination[i + 2] = frame.data[i + 2];
|
||||
destination[i + 3] = 255;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
for (int y = 0; y < height_; y += 1) {
|
||||
const int sourceY = static_cast<int>((static_cast<int64_t>(y) * frame.height) / height_);
|
||||
BYTE* destinationRow = destination + rowBytes * y;
|
||||
for (int x = 0; x < width_; x += 1) {
|
||||
const int sourceX = static_cast<int>((static_cast<int64_t>(x) * frame.width) / width_);
|
||||
const BYTE* source = frame.data + (sourceY * frame.width + sourceX) * 4;
|
||||
BYTE* target = destinationRow + x * 4;
|
||||
target[0] = source[0];
|
||||
target[1] = source[1];
|
||||
target[2] = source[2];
|
||||
target[3] = 255;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
bool MFEncoder::writeFrame(ID3D11Texture2D* texture, int64_t timestampHns, const BgraFrameView* webcamFrame) {
|
||||
std::scoped_lock writerLock(writerMutex_);
|
||||
if (!sinkWriter_ || finalized_) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (firstTimestampHns_ < 0) {
|
||||
firstTimestampHns_ = timestampHns;
|
||||
}
|
||||
|
||||
int64_t sampleTime = timestampHns - firstTimestampHns_;
|
||||
if (sampleTime <= lastTimestampHns_) {
|
||||
sampleTime = lastTimestampHns_ + (10'000'000LL / fps_);
|
||||
}
|
||||
const int64_t sampleDuration = 10'000'000LL / fps_;
|
||||
lastTimestampHns_ = sampleTime;
|
||||
|
||||
Microsoft::WRL::ComPtr<IMFMediaBuffer> buffer;
|
||||
const DWORD frameBytes = static_cast<DWORD>(width_ * height_ * 4);
|
||||
if (!succeeded(MFCreateMemoryBuffer(frameBytes, &buffer), "MFCreateMemoryBuffer")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
BYTE* data = nullptr;
|
||||
DWORD maxLength = 0;
|
||||
DWORD currentLength = 0;
|
||||
if (!succeeded(buffer->Lock(&data, &maxLength, ¤tLength), "IMFMediaBuffer::Lock")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const bool copied = copyFrameToBuffer(texture, data, maxLength, webcamFrame);
|
||||
buffer->Unlock();
|
||||
if (!copied) {
|
||||
return false;
|
||||
}
|
||||
buffer->SetCurrentLength(frameBytes);
|
||||
|
||||
Microsoft::WRL::ComPtr<IMFSample> sample;
|
||||
if (!succeeded(MFCreateSample(&sample), "MFCreateSample")) {
|
||||
return false;
|
||||
}
|
||||
sample->AddBuffer(buffer.Get());
|
||||
sample->SetSampleTime(sampleTime);
|
||||
sample->SetSampleDuration(sampleDuration);
|
||||
|
||||
return succeeded(sinkWriter_->WriteSample(videoStreamIndex_, sample.Get()), "WriteSample");
|
||||
}
|
||||
|
||||
bool MFEncoder::writeBgraFrame(const BgraFrameView& frame, int64_t timestampHns) {
|
||||
std::scoped_lock writerLock(writerMutex_);
|
||||
if (!sinkWriter_ || finalized_) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (firstTimestampHns_ < 0) {
|
||||
firstTimestampHns_ = timestampHns;
|
||||
}
|
||||
|
||||
int64_t sampleTime = timestampHns - firstTimestampHns_;
|
||||
if (sampleTime <= lastTimestampHns_) {
|
||||
sampleTime = lastTimestampHns_ + (10'000'000LL / fps_);
|
||||
}
|
||||
const int64_t sampleDuration = 10'000'000LL / fps_;
|
||||
lastTimestampHns_ = sampleTime;
|
||||
|
||||
Microsoft::WRL::ComPtr<IMFMediaBuffer> buffer;
|
||||
const DWORD frameBytes = static_cast<DWORD>(width_ * height_ * 4);
|
||||
if (!succeeded(MFCreateMemoryBuffer(frameBytes, &buffer), "MFCreateMemoryBuffer(webcam)")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
BYTE* data = nullptr;
|
||||
DWORD maxLength = 0;
|
||||
DWORD currentLength = 0;
|
||||
if (!succeeded(buffer->Lock(&data, &maxLength, ¤tLength), "IMFMediaBuffer::Lock(webcam)")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const bool copied = copyBgraFrameToBuffer(frame, data, maxLength);
|
||||
buffer->Unlock();
|
||||
if (!copied) {
|
||||
return false;
|
||||
}
|
||||
buffer->SetCurrentLength(frameBytes);
|
||||
|
||||
Microsoft::WRL::ComPtr<IMFSample> sample;
|
||||
if (!succeeded(MFCreateSample(&sample), "MFCreateSample(webcam)")) {
|
||||
return false;
|
||||
}
|
||||
sample->AddBuffer(buffer.Get());
|
||||
sample->SetSampleTime(sampleTime);
|
||||
sample->SetSampleDuration(sampleDuration);
|
||||
|
||||
return succeeded(sinkWriter_->WriteSample(videoStreamIndex_, sample.Get()), "WriteSample(webcam)");
|
||||
}
|
||||
|
||||
bool MFEncoder::writeAudio(const BYTE* data, DWORD byteCount, int64_t timestampHns, int64_t durationHns) {
|
||||
std::scoped_lock writerLock(writerMutex_);
|
||||
if (!sinkWriter_ || finalized_ || !hasAudioStream_) {
|
||||
return false;
|
||||
}
|
||||
if (!data || byteCount == 0 || durationHns <= 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
Microsoft::WRL::ComPtr<IMFMediaBuffer> buffer;
|
||||
if (!succeeded(MFCreateMemoryBuffer(byteCount, &buffer), "MFCreateMemoryBuffer(audio)")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
BYTE* destination = nullptr;
|
||||
DWORD maxLength = 0;
|
||||
DWORD currentLength = 0;
|
||||
if (!succeeded(buffer->Lock(&destination, &maxLength, ¤tLength),
|
||||
"IMFMediaBuffer::Lock(audio)")) {
|
||||
return false;
|
||||
}
|
||||
if (maxLength < byteCount) {
|
||||
buffer->Unlock();
|
||||
std::cerr << "ERROR: Media Foundation audio buffer is too small" << std::endl;
|
||||
return false;
|
||||
}
|
||||
std::memcpy(destination, data, byteCount);
|
||||
buffer->Unlock();
|
||||
buffer->SetCurrentLength(byteCount);
|
||||
|
||||
Microsoft::WRL::ComPtr<IMFSample> sample;
|
||||
if (!succeeded(MFCreateSample(&sample), "MFCreateSample(audio)")) {
|
||||
return false;
|
||||
}
|
||||
sample->AddBuffer(buffer.Get());
|
||||
sample->SetSampleTime(std::max<int64_t>(0, timestampHns));
|
||||
sample->SetSampleDuration(durationHns);
|
||||
|
||||
return succeeded(sinkWriter_->WriteSample(audioStreamIndex_, sample.Get()), "WriteSample(audio)");
|
||||
}
|
||||
|
||||
bool MFEncoder::finalize() {
|
||||
std::scoped_lock writerLock(writerMutex_);
|
||||
if (finalized_) {
|
||||
return true;
|
||||
}
|
||||
|
||||
finalized_ = true;
|
||||
bool ok = true;
|
||||
if (sinkWriter_) {
|
||||
ok = succeeded(sinkWriter_->Finalize(), "SinkWriter::Finalize");
|
||||
sinkWriter_.Reset();
|
||||
}
|
||||
stagingTexture_.Reset();
|
||||
context_.Reset();
|
||||
device_.Reset();
|
||||
MFShutdown();
|
||||
return ok;
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
#pragma once
|
||||
|
||||
#include <Windows.h>
|
||||
#include <d3d11.h>
|
||||
#include <mfapi.h>
|
||||
#include <mfidl.h>
|
||||
#include <mfreadwrite.h>
|
||||
#include <wrl/client.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
|
||||
struct BgraFrameView {
|
||||
const BYTE* data = nullptr;
|
||||
int width = 0;
|
||||
int height = 0;
|
||||
};
|
||||
|
||||
struct AudioInputFormat {
|
||||
GUID subtype = MFAudioFormat_PCM;
|
||||
UINT32 sampleRate = 0;
|
||||
UINT32 channels = 0;
|
||||
UINT32 bitsPerSample = 0;
|
||||
UINT32 blockAlign = 0;
|
||||
UINT32 avgBytesPerSec = 0;
|
||||
};
|
||||
|
||||
class MFEncoder {
|
||||
public:
|
||||
MFEncoder() = default;
|
||||
~MFEncoder();
|
||||
|
||||
MFEncoder(const MFEncoder&) = delete;
|
||||
MFEncoder& operator=(const MFEncoder&) = delete;
|
||||
|
||||
bool initialize(
|
||||
const std::wstring& outputPath,
|
||||
int width,
|
||||
int height,
|
||||
int fps,
|
||||
int bitrate,
|
||||
ID3D11Device* device,
|
||||
ID3D11DeviceContext* context,
|
||||
const AudioInputFormat* audioFormat = nullptr);
|
||||
bool writeFrame(ID3D11Texture2D* texture, int64_t timestampHns, const BgraFrameView* webcamFrame = nullptr);
|
||||
bool writeBgraFrame(const BgraFrameView& frame, int64_t timestampHns);
|
||||
bool writeAudio(const BYTE* data, DWORD byteCount, int64_t timestampHns, int64_t durationHns);
|
||||
bool finalize();
|
||||
|
||||
private:
|
||||
bool ensureStagingTexture(ID3D11Texture2D* texture);
|
||||
bool copyFrameToBuffer(
|
||||
ID3D11Texture2D* texture,
|
||||
BYTE* destination,
|
||||
DWORD destinationSize,
|
||||
const BgraFrameView* webcamFrame);
|
||||
bool copyBgraFrameToBuffer(const BgraFrameView& frame, BYTE* destination, DWORD destinationSize);
|
||||
bool configureAudioStream(const AudioInputFormat& audioFormat);
|
||||
|
||||
Microsoft::WRL::ComPtr<IMFSinkWriter> sinkWriter_;
|
||||
Microsoft::WRL::ComPtr<ID3D11Device> device_;
|
||||
Microsoft::WRL::ComPtr<ID3D11DeviceContext> context_;
|
||||
Microsoft::WRL::ComPtr<ID3D11Texture2D> stagingTexture_;
|
||||
std::mutex writerMutex_;
|
||||
DWORD videoStreamIndex_ = 0;
|
||||
DWORD audioStreamIndex_ = 0;
|
||||
bool hasAudioStream_ = false;
|
||||
int width_ = 0;
|
||||
int height_ = 0;
|
||||
int fps_ = 60;
|
||||
int64_t firstTimestampHns_ = -1;
|
||||
int64_t lastTimestampHns_ = -1;
|
||||
bool finalized_ = false;
|
||||
};
|
||||
@@ -0,0 +1,88 @@
|
||||
#include "monitor_utils.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <vector>
|
||||
|
||||
namespace {
|
||||
|
||||
struct MonitorCandidate {
|
||||
HMONITOR monitor = nullptr;
|
||||
RECT rect{};
|
||||
};
|
||||
|
||||
std::vector<MonitorCandidate> enumerateMonitors() {
|
||||
std::vector<MonitorCandidate> monitors;
|
||||
EnumDisplayMonitors(
|
||||
nullptr,
|
||||
nullptr,
|
||||
[](HMONITOR monitor, HDC, LPRECT rect, LPARAM userData) -> BOOL {
|
||||
auto* result = reinterpret_cast<std::vector<MonitorCandidate>*>(userData);
|
||||
result->push_back({monitor, *rect});
|
||||
return TRUE;
|
||||
},
|
||||
reinterpret_cast<LPARAM>(&monitors));
|
||||
return monitors;
|
||||
}
|
||||
|
||||
bool rectMatchesBounds(const RECT& rect, const MonitorBounds& bounds) {
|
||||
return rect.left == bounds.x &&
|
||||
rect.top == bounds.y &&
|
||||
(rect.right - rect.left) == bounds.width &&
|
||||
(rect.bottom - rect.top) == bounds.height;
|
||||
}
|
||||
|
||||
int64_t overlapArea(const RECT& rect, const MonitorBounds& bounds) {
|
||||
const LONG left = std::max<LONG>(rect.left, bounds.x);
|
||||
const LONG top = std::max<LONG>(rect.top, bounds.y);
|
||||
const LONG right = std::min<LONG>(rect.right, bounds.x + bounds.width);
|
||||
const LONG bottom = std::min<LONG>(rect.bottom, bounds.y + bounds.height);
|
||||
if (right <= left || bottom <= top) {
|
||||
return 0;
|
||||
}
|
||||
return static_cast<int64_t>(right - left) * static_cast<int64_t>(bottom - top);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
HMONITOR findMonitorForCapture(int64_t displayId, const MonitorBounds* bounds) {
|
||||
const auto monitors = enumerateMonitors();
|
||||
if (monitors.empty()) {
|
||||
return MonitorFromPoint({0, 0}, MONITOR_DEFAULTTOPRIMARY);
|
||||
}
|
||||
|
||||
// Electron's display_id is not stable across all Windows capture backends.
|
||||
// Bounds are the most reliable contract because they come from Electron's
|
||||
// selected display and match the WGC monitor coordinate space.
|
||||
if (bounds && bounds->width > 0 && bounds->height > 0) {
|
||||
for (const auto& candidate : monitors) {
|
||||
if (rectMatchesBounds(candidate.rect, *bounds)) {
|
||||
return candidate.monitor;
|
||||
}
|
||||
}
|
||||
|
||||
HMONITOR bestMonitor = nullptr;
|
||||
int64_t bestArea = 0;
|
||||
for (const auto& candidate : monitors) {
|
||||
const int64_t area = overlapArea(candidate.rect, *bounds);
|
||||
if (area > bestArea) {
|
||||
bestArea = area;
|
||||
bestMonitor = candidate.monitor;
|
||||
}
|
||||
}
|
||||
if (bestMonitor) {
|
||||
return bestMonitor;
|
||||
}
|
||||
}
|
||||
|
||||
// Best-effort fallback for helpers invoked without bounds. Some callers pass
|
||||
// zero-based ids while Win32 monitor handles are pointer values, so only use
|
||||
// this when it exactly matches the HMONITOR value.
|
||||
for (const auto& candidate : monitors) {
|
||||
if (reinterpret_cast<int64_t>(candidate.monitor) == displayId) {
|
||||
return candidate.monitor;
|
||||
}
|
||||
}
|
||||
|
||||
return MonitorFromPoint({0, 0}, MONITOR_DEFAULTTOPRIMARY);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
#pragma once
|
||||
|
||||
#include <Windows.h>
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
struct MonitorBounds {
|
||||
int x = 0;
|
||||
int y = 0;
|
||||
int width = 0;
|
||||
int height = 0;
|
||||
};
|
||||
|
||||
HMONITOR findMonitorForCapture(int64_t displayId, const MonitorBounds* bounds);
|
||||
@@ -0,0 +1,411 @@
|
||||
#include "wasapi_loopback_capture.h"
|
||||
|
||||
#include <Functiondiscoverykeys_devpkey.h>
|
||||
#include <ksmedia.h>
|
||||
#include <propvarutil.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <cwctype>
|
||||
#include <iostream>
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr REFERENCE_TIME BufferDurationHns = 10'000'000;
|
||||
constexpr int64_t HnsPerSecond = 10'000'000;
|
||||
|
||||
bool succeeded(HRESULT hr, const char* label) {
|
||||
if (SUCCEEDED(hr)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
std::cerr << "ERROR: " << label << " failed (hr=0x" << std::hex << hr << std::dec << ")"
|
||||
<< std::endl;
|
||||
return false;
|
||||
}
|
||||
|
||||
GUID audioSubtypeFromFormat(WAVEFORMATEX* format) {
|
||||
if (format->wFormatTag == WAVE_FORMAT_IEEE_FLOAT) {
|
||||
return MFAudioFormat_Float;
|
||||
}
|
||||
if (format->wFormatTag == WAVE_FORMAT_PCM) {
|
||||
return MFAudioFormat_PCM;
|
||||
}
|
||||
if (format->wFormatTag == WAVE_FORMAT_EXTENSIBLE &&
|
||||
format->cbSize >= sizeof(WAVEFORMATEXTENSIBLE) - sizeof(WAVEFORMATEX)) {
|
||||
auto* extensible = reinterpret_cast<WAVEFORMATEXTENSIBLE*>(format);
|
||||
if (extensible->SubFormat == KSDATAFORMAT_SUBTYPE_IEEE_FLOAT) {
|
||||
return MFAudioFormat_Float;
|
||||
}
|
||||
if (extensible->SubFormat == KSDATAFORMAT_SUBTYPE_PCM) {
|
||||
return MFAudioFormat_PCM;
|
||||
}
|
||||
}
|
||||
return GUID_NULL;
|
||||
}
|
||||
|
||||
std::wstring normalizeDeviceName(const std::wstring& value) {
|
||||
std::wstring result;
|
||||
result.reserve(value.size());
|
||||
bool lastWasSpace = true;
|
||||
|
||||
for (const wchar_t c : value) {
|
||||
if (std::iswalnum(c)) {
|
||||
result.push_back(static_cast<wchar_t>(std::towlower(c)));
|
||||
lastWasSpace = false;
|
||||
} else if (!lastWasSpace) {
|
||||
result.push_back(L' ');
|
||||
lastWasSpace = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!result.empty() && result.back() == L' ') {
|
||||
result.pop_back();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
int scoreDeviceName(const std::wstring& candidateName, const std::wstring& candidateId, const std::wstring& requestedName) {
|
||||
const std::wstring candidate = normalizeDeviceName(candidateName);
|
||||
const std::wstring id = normalizeDeviceName(candidateId);
|
||||
const std::wstring requested = normalizeDeviceName(requestedName);
|
||||
if (requested.empty()) {
|
||||
return 0;
|
||||
}
|
||||
if (candidate == requested) {
|
||||
return 1000;
|
||||
}
|
||||
if (!candidate.empty() && (candidate.find(requested) != std::wstring::npos || requested.find(candidate) != std::wstring::npos)) {
|
||||
return 900;
|
||||
}
|
||||
if (!id.empty() && (id.find(requested) != std::wstring::npos || requested.find(id) != std::wstring::npos)) {
|
||||
return 800;
|
||||
}
|
||||
|
||||
int score = 0;
|
||||
size_t pos = 0;
|
||||
while (pos < requested.size()) {
|
||||
const size_t end = requested.find(L' ', pos);
|
||||
const std::wstring word = requested.substr(pos, end == std::wstring::npos ? std::wstring::npos : end - pos);
|
||||
if (word.size() > 1 && word != L"microphone" && word != L"mic" && word != L"audio" && word != L"input") {
|
||||
if (candidate.find(word) != std::wstring::npos) {
|
||||
score += 100;
|
||||
} else if (id.find(word) != std::wstring::npos) {
|
||||
score += 50;
|
||||
}
|
||||
}
|
||||
if (end == std::wstring::npos) {
|
||||
break;
|
||||
}
|
||||
pos = end + 1;
|
||||
}
|
||||
return score;
|
||||
}
|
||||
|
||||
std::wstring getDeviceFriendlyName(IMMDevice* device) {
|
||||
if (!device) {
|
||||
return {};
|
||||
}
|
||||
|
||||
Microsoft::WRL::ComPtr<IPropertyStore> properties;
|
||||
HRESULT hr = device->OpenPropertyStore(STGM_READ, &properties);
|
||||
if (FAILED(hr) || !properties) {
|
||||
return {};
|
||||
}
|
||||
|
||||
PROPVARIANT value;
|
||||
PropVariantInit(&value);
|
||||
hr = properties->GetValue(PKEY_Device_FriendlyName, &value);
|
||||
std::wstring name;
|
||||
if (SUCCEEDED(hr) && value.vt == VT_LPWSTR && value.pwszVal) {
|
||||
name = value.pwszVal;
|
||||
}
|
||||
PropVariantClear(&value);
|
||||
return name;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
WasapiLoopbackCapture::~WasapiLoopbackCapture() {
|
||||
stop();
|
||||
if (mixFormat_) {
|
||||
CoTaskMemFree(mixFormat_);
|
||||
mixFormat_ = nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
bool WasapiLoopbackCapture::initializeSystemLoopback() {
|
||||
return initialize(WasapiCaptureEndpoint::SystemLoopback, {}, {});
|
||||
}
|
||||
|
||||
bool WasapiLoopbackCapture::initializeMicrophone(const std::wstring& deviceId, const std::wstring& deviceName) {
|
||||
return initialize(WasapiCaptureEndpoint::Microphone, deviceId, deviceName);
|
||||
}
|
||||
|
||||
bool WasapiLoopbackCapture::initialize(WasapiCaptureEndpoint endpoint, const std::wstring& deviceId, const std::wstring& deviceName) {
|
||||
HRESULT hr = CoCreateInstance(
|
||||
__uuidof(MMDeviceEnumerator),
|
||||
nullptr,
|
||||
CLSCTX_ALL,
|
||||
IID_PPV_ARGS(&deviceEnumerator_));
|
||||
if (!succeeded(hr, "CoCreateInstance(MMDeviceEnumerator)")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (endpoint == WasapiCaptureEndpoint::Microphone && !deviceId.empty() && deviceId != L"default") {
|
||||
hr = deviceEnumerator_->GetDevice(deviceId.c_str(), &device_);
|
||||
if (FAILED(hr)) {
|
||||
std::wcerr << L"WARNING: Could not resolve microphone device id directly"
|
||||
<< std::endl;
|
||||
device_.Reset();
|
||||
}
|
||||
}
|
||||
|
||||
if (endpoint == WasapiCaptureEndpoint::Microphone && !device_ && !deviceName.empty()) {
|
||||
if (!resolveMicrophoneByName(deviceName)) {
|
||||
std::wcerr << L"WARNING: Could not resolve microphone by name; using default capture endpoint"
|
||||
<< std::endl;
|
||||
}
|
||||
}
|
||||
|
||||
if (!device_) {
|
||||
const EDataFlow flow =
|
||||
endpoint == WasapiCaptureEndpoint::SystemLoopback ? eRender : eCapture;
|
||||
hr = deviceEnumerator_->GetDefaultAudioEndpoint(flow, eConsole, &device_);
|
||||
if (!succeeded(hr, "GetDefaultAudioEndpoint")) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
selectedDeviceName_ = getDeviceFriendlyName(device_.Get());
|
||||
|
||||
hr = device_->Activate(__uuidof(IAudioClient), CLSCTX_ALL, nullptr, &audioClient_);
|
||||
if (!succeeded(hr, "IMMDevice::Activate(IAudioClient)")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
hr = audioClient_->GetMixFormat(&mixFormat_);
|
||||
if (!succeeded(hr, "IAudioClient::GetMixFormat") || !mixFormat_) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!resolveInputFormat(mixFormat_)) {
|
||||
std::cerr << "ERROR: Unsupported WASAPI loopback mix format" << std::endl;
|
||||
return false;
|
||||
}
|
||||
|
||||
const DWORD streamFlags =
|
||||
endpoint == WasapiCaptureEndpoint::SystemLoopback ? AUDCLNT_STREAMFLAGS_LOOPBACK : 0;
|
||||
hr = audioClient_->Initialize(
|
||||
AUDCLNT_SHAREMODE_SHARED,
|
||||
streamFlags,
|
||||
BufferDurationHns,
|
||||
0,
|
||||
mixFormat_,
|
||||
nullptr);
|
||||
if (!succeeded(hr, "IAudioClient::Initialize(loopback)")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
hr = audioClient_->GetService(IID_PPV_ARGS(&captureClient_));
|
||||
if (!succeeded(hr, "IAudioClient::GetService(IAudioCaptureClient)")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
bool WasapiLoopbackCapture::resolveMicrophoneByName(const std::wstring& deviceName) {
|
||||
if (!deviceEnumerator_ || deviceName.empty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
Microsoft::WRL::ComPtr<IMMDeviceCollection> devices;
|
||||
HRESULT hr = deviceEnumerator_->EnumAudioEndpoints(eCapture, DEVICE_STATE_ACTIVE, &devices);
|
||||
if (!succeeded(hr, "IMMDeviceEnumerator::EnumAudioEndpoints(eCapture)")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
UINT count = 0;
|
||||
hr = devices->GetCount(&count);
|
||||
if (!succeeded(hr, "IMMDeviceCollection::GetCount")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
Microsoft::WRL::ComPtr<IMMDevice> bestDevice;
|
||||
std::wstring bestId;
|
||||
std::wstring bestName;
|
||||
int bestScore = 0;
|
||||
for (UINT i = 0; i < count; ++i) {
|
||||
Microsoft::WRL::ComPtr<IMMDevice> candidate;
|
||||
hr = devices->Item(i, &candidate);
|
||||
if (FAILED(hr) || !candidate) {
|
||||
continue;
|
||||
}
|
||||
|
||||
LPWSTR rawId = nullptr;
|
||||
std::wstring candidateId;
|
||||
if (SUCCEEDED(candidate->GetId(&rawId)) && rawId) {
|
||||
candidateId = rawId;
|
||||
CoTaskMemFree(rawId);
|
||||
}
|
||||
|
||||
const std::wstring candidateName = getDeviceFriendlyName(candidate.Get());
|
||||
const int score = scoreDeviceName(candidateName, candidateId, deviceName);
|
||||
std::wcerr << L"Native microphone candidate: " << candidateName << L" score=" << score << std::endl;
|
||||
if (score > bestScore) {
|
||||
bestScore = score;
|
||||
bestDevice = candidate;
|
||||
bestId = candidateId;
|
||||
bestName = candidateName;
|
||||
}
|
||||
}
|
||||
|
||||
if (!bestDevice || bestScore <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
device_ = bestDevice;
|
||||
std::wcerr << L"Selected native microphone endpoint: " << bestName << L" id=" << bestId << std::endl;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool WasapiLoopbackCapture::resolveInputFormat(WAVEFORMATEX* mixFormat) {
|
||||
const GUID subtype = audioSubtypeFromFormat(mixFormat);
|
||||
if (subtype == GUID_NULL) {
|
||||
return false;
|
||||
}
|
||||
|
||||
inputFormat_.subtype = subtype;
|
||||
inputFormat_.sampleRate = mixFormat->nSamplesPerSec;
|
||||
inputFormat_.channels = mixFormat->nChannels;
|
||||
inputFormat_.bitsPerSample = mixFormat->wBitsPerSample;
|
||||
inputFormat_.blockAlign = mixFormat->nBlockAlign;
|
||||
inputFormat_.avgBytesPerSec = mixFormat->nAvgBytesPerSec;
|
||||
return inputFormat_.sampleRate > 0 && inputFormat_.channels > 0 && inputFormat_.blockAlign > 0;
|
||||
}
|
||||
|
||||
bool WasapiLoopbackCapture::start(AudioCallback callback) {
|
||||
if (!audioClient_ || !captureClient_ || !callback) {
|
||||
return false;
|
||||
}
|
||||
|
||||
callback_ = std::move(callback);
|
||||
stopRequested_ = false;
|
||||
writtenFrames_ = 0;
|
||||
lastDevicePositionEnd_ = 0;
|
||||
hasLastDevicePosition_ = false;
|
||||
|
||||
HRESULT hr = audioClient_->Start();
|
||||
if (!succeeded(hr, "IAudioClient::Start")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
thread_ = std::thread([this] {
|
||||
captureLoop();
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
void WasapiLoopbackCapture::stop() {
|
||||
stopRequested_ = true;
|
||||
if (thread_.joinable()) {
|
||||
thread_.join();
|
||||
}
|
||||
if (audioClient_) {
|
||||
audioClient_->Stop();
|
||||
}
|
||||
}
|
||||
|
||||
const AudioInputFormat& WasapiLoopbackCapture::inputFormat() const {
|
||||
return inputFormat_;
|
||||
}
|
||||
|
||||
const std::wstring& WasapiLoopbackCapture::selectedDeviceName() const {
|
||||
return selectedDeviceName_;
|
||||
}
|
||||
|
||||
void WasapiLoopbackCapture::captureLoop() {
|
||||
auto emitSilenceFrames = [&](uint64_t frames, int64_t timestampHns) {
|
||||
constexpr uint64_t MaxSilenceChunkFrames = 4800;
|
||||
uint64_t remainingFrames = frames;
|
||||
int64_t currentTimestampHns = timestampHns;
|
||||
while (remainingFrames > 0 && !stopRequested_) {
|
||||
const uint64_t chunkFrames = std::min<uint64_t>(remainingFrames, MaxSilenceChunkFrames);
|
||||
const DWORD chunkBytes = static_cast<DWORD>(chunkFrames * inputFormat_.blockAlign);
|
||||
const int64_t chunkDurationHns =
|
||||
static_cast<int64_t>((chunkFrames * HnsPerSecond) / inputFormat_.sampleRate);
|
||||
silenceBuffer_.assign(chunkBytes, 0);
|
||||
callback_(silenceBuffer_.data(), chunkBytes, currentTimestampHns, chunkDurationHns);
|
||||
remainingFrames -= chunkFrames;
|
||||
currentTimestampHns += chunkDurationHns;
|
||||
}
|
||||
};
|
||||
|
||||
while (!stopRequested_) {
|
||||
UINT32 packetFrames = 0;
|
||||
HRESULT hr = captureClient_->GetNextPacketSize(&packetFrames);
|
||||
if (FAILED(hr)) {
|
||||
std::cerr << "ERROR: IAudioCaptureClient::GetNextPacketSize failed (hr=0x" << std::hex
|
||||
<< hr << std::dec << ")" << std::endl;
|
||||
break;
|
||||
}
|
||||
|
||||
while (packetFrames > 0 && !stopRequested_) {
|
||||
BYTE* data = nullptr;
|
||||
UINT32 framesAvailable = 0;
|
||||
DWORD flags = 0;
|
||||
UINT64 devicePosition = 0;
|
||||
UINT64 qpcPosition = 0;
|
||||
|
||||
hr = captureClient_->GetBuffer(&data, &framesAvailable, &flags, &devicePosition, &qpcPosition);
|
||||
if (FAILED(hr)) {
|
||||
std::cerr << "ERROR: IAudioCaptureClient::GetBuffer failed (hr=0x" << std::hex
|
||||
<< hr << std::dec << ")" << std::endl;
|
||||
break;
|
||||
}
|
||||
|
||||
(void)qpcPosition;
|
||||
if (hasLastDevicePosition_ && devicePosition > lastDevicePositionEnd_) {
|
||||
const uint64_t gapFrames = devicePosition - lastDevicePositionEnd_;
|
||||
if ((flags & AUDCLNT_BUFFERFLAGS_DATA_DISCONTINUITY) != 0 || gapFrames > framesAvailable) {
|
||||
const int64_t gapTimestampHns =
|
||||
static_cast<int64_t>((lastDevicePositionEnd_ * HnsPerSecond) / inputFormat_.sampleRate);
|
||||
emitSilenceFrames(gapFrames, gapTimestampHns);
|
||||
}
|
||||
}
|
||||
|
||||
const DWORD byteCount = framesAvailable * inputFormat_.blockAlign;
|
||||
const int64_t timestampHns =
|
||||
static_cast<int64_t>((devicePosition * HnsPerSecond) / inputFormat_.sampleRate);
|
||||
const int64_t durationHns =
|
||||
static_cast<int64_t>((static_cast<uint64_t>(framesAvailable) * HnsPerSecond) /
|
||||
inputFormat_.sampleRate);
|
||||
|
||||
if (byteCount > 0) {
|
||||
if ((flags & AUDCLNT_BUFFERFLAGS_SILENT) != 0 || !data) {
|
||||
silenceBuffer_.assign(byteCount, 0);
|
||||
callback_(silenceBuffer_.data(), byteCount, timestampHns, durationHns);
|
||||
} else {
|
||||
callback_(data, byteCount, timestampHns, durationHns);
|
||||
}
|
||||
}
|
||||
|
||||
writtenFrames_ += framesAvailable;
|
||||
lastDevicePositionEnd_ = devicePosition + framesAvailable;
|
||||
hasLastDevicePosition_ = true;
|
||||
captureClient_->ReleaseBuffer(framesAvailable);
|
||||
|
||||
hr = captureClient_->GetNextPacketSize(&packetFrames);
|
||||
if (FAILED(hr)) {
|
||||
std::cerr << "ERROR: IAudioCaptureClient::GetNextPacketSize failed (hr=0x"
|
||||
<< std::hex << hr << std::dec << ")" << std::endl;
|
||||
packetFrames = 0;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(5));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
#pragma once
|
||||
|
||||
#include "mf_encoder.h"
|
||||
|
||||
#include <Windows.h>
|
||||
#include <audioclient.h>
|
||||
#include <mmdeviceapi.h>
|
||||
#include <wrl/client.h>
|
||||
|
||||
#include <atomic>
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
enum class WasapiCaptureEndpoint {
|
||||
SystemLoopback,
|
||||
Microphone,
|
||||
};
|
||||
|
||||
class WasapiLoopbackCapture {
|
||||
public:
|
||||
using AudioCallback = std::function<void(const BYTE* data, DWORD byteCount, int64_t timestampHns, int64_t durationHns)>;
|
||||
|
||||
WasapiLoopbackCapture() = default;
|
||||
~WasapiLoopbackCapture();
|
||||
|
||||
WasapiLoopbackCapture(const WasapiLoopbackCapture&) = delete;
|
||||
WasapiLoopbackCapture& operator=(const WasapiLoopbackCapture&) = delete;
|
||||
|
||||
bool initializeSystemLoopback();
|
||||
bool initializeMicrophone(const std::wstring& deviceId, const std::wstring& deviceName);
|
||||
bool start(AudioCallback callback);
|
||||
void stop();
|
||||
|
||||
const AudioInputFormat& inputFormat() const;
|
||||
const std::wstring& selectedDeviceName() const;
|
||||
|
||||
private:
|
||||
bool initialize(WasapiCaptureEndpoint endpoint, const std::wstring& deviceId, const std::wstring& deviceName);
|
||||
bool resolveMicrophoneByName(const std::wstring& deviceName);
|
||||
void captureLoop();
|
||||
bool resolveInputFormat(WAVEFORMATEX* mixFormat);
|
||||
|
||||
Microsoft::WRL::ComPtr<IMMDeviceEnumerator> deviceEnumerator_;
|
||||
Microsoft::WRL::ComPtr<IMMDevice> device_;
|
||||
Microsoft::WRL::ComPtr<IAudioClient> audioClient_;
|
||||
Microsoft::WRL::ComPtr<IAudioCaptureClient> captureClient_;
|
||||
WAVEFORMATEX* mixFormat_ = nullptr;
|
||||
AudioInputFormat inputFormat_{};
|
||||
std::wstring selectedDeviceName_;
|
||||
AudioCallback callback_;
|
||||
std::thread thread_;
|
||||
std::atomic<bool> stopRequested_ = false;
|
||||
std::vector<BYTE> silenceBuffer_;
|
||||
uint64_t writtenFrames_ = 0;
|
||||
uint64_t lastDevicePositionEnd_ = 0;
|
||||
bool hasLastDevicePosition_ = false;
|
||||
};
|
||||
@@ -0,0 +1,419 @@
|
||||
#include "webcam_capture.h"
|
||||
|
||||
#include <mfapi.h>
|
||||
#include <mferror.h>
|
||||
#include <propvarutil.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <cwctype>
|
||||
#include <iostream>
|
||||
|
||||
namespace {
|
||||
|
||||
bool succeeded(HRESULT hr, const char* label) {
|
||||
if (SUCCEEDED(hr)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
std::cerr << "ERROR: " << label << " failed (hr=0x" << std::hex << hr << std::dec << ")"
|
||||
<< std::endl;
|
||||
return false;
|
||||
}
|
||||
|
||||
std::wstring readAllocatedString(IMFActivate* activate, REFGUID key) {
|
||||
WCHAR* value = nullptr;
|
||||
UINT32 length = 0;
|
||||
if (FAILED(activate->GetAllocatedString(key, &value, &length)) || !value) {
|
||||
return {};
|
||||
}
|
||||
|
||||
std::wstring result(value, value + length);
|
||||
CoTaskMemFree(value);
|
||||
return result;
|
||||
}
|
||||
|
||||
bool containsInsensitive(const std::wstring& haystack, const std::wstring& needle) {
|
||||
if (haystack.empty() || needle.empty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
std::wstring lowerHaystack = haystack;
|
||||
std::wstring lowerNeedle = needle;
|
||||
std::transform(lowerHaystack.begin(), lowerHaystack.end(), lowerHaystack.begin(), ::towlower);
|
||||
std::transform(lowerNeedle.begin(), lowerNeedle.end(), lowerNeedle.begin(), ::towlower);
|
||||
return lowerHaystack.find(lowerNeedle) != std::wstring::npos ||
|
||||
lowerNeedle.find(lowerHaystack) != std::wstring::npos;
|
||||
}
|
||||
|
||||
std::wstring normalizeDeviceName(const std::wstring& value) {
|
||||
std::wstring normalized;
|
||||
normalized.reserve(value.size());
|
||||
bool lastWasSpace = true;
|
||||
for (const wchar_t ch : value) {
|
||||
if (std::iswalnum(ch)) {
|
||||
normalized.push_back(static_cast<wchar_t>(std::towlower(ch)));
|
||||
lastWasSpace = false;
|
||||
continue;
|
||||
}
|
||||
if (!lastWasSpace) {
|
||||
normalized.push_back(L' ');
|
||||
lastWasSpace = true;
|
||||
}
|
||||
}
|
||||
while (!normalized.empty() && normalized.back() == L' ') {
|
||||
normalized.pop_back();
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
std::vector<std::wstring> splitWords(const std::wstring& value) {
|
||||
std::vector<std::wstring> words;
|
||||
size_t start = 0;
|
||||
while (start < value.size()) {
|
||||
const size_t end = value.find(L' ', start);
|
||||
const auto word = value.substr(start, end == std::wstring::npos ? std::wstring::npos : end - start);
|
||||
if (word.size() > 1 && word != L"camera" && word != L"webcam" && word != L"video" && word != L"input") {
|
||||
words.push_back(word);
|
||||
}
|
||||
if (end == std::wstring::npos) {
|
||||
break;
|
||||
}
|
||||
start = end + 1;
|
||||
}
|
||||
return words;
|
||||
}
|
||||
|
||||
int deviceMatchScore(
|
||||
const std::wstring& candidateName,
|
||||
const std::wstring& candidateLink,
|
||||
const std::wstring& requestedName,
|
||||
const std::wstring& requestedId) {
|
||||
int score = 0;
|
||||
const auto normalizedName = normalizeDeviceName(candidateName);
|
||||
const auto normalizedLink = normalizeDeviceName(candidateLink);
|
||||
const auto normalizedRequestedName = normalizeDeviceName(requestedName);
|
||||
const auto normalizedRequestedId = normalizeDeviceName(requestedId);
|
||||
|
||||
if (!normalizedRequestedName.empty()) {
|
||||
if (normalizedName == normalizedRequestedName) {
|
||||
score = std::max(score, 1000);
|
||||
}
|
||||
if (containsInsensitive(normalizedName, normalizedRequestedName)) {
|
||||
score = std::max(score, 900);
|
||||
}
|
||||
if (containsInsensitive(normalizedLink, normalizedRequestedName)) {
|
||||
score = std::max(score, 800);
|
||||
}
|
||||
|
||||
int wordScore = 0;
|
||||
for (const auto& word : splitWords(normalizedRequestedName)) {
|
||||
if (normalizedName.find(word) != std::wstring::npos) {
|
||||
wordScore += 100;
|
||||
} else if (normalizedLink.find(word) != std::wstring::npos) {
|
||||
wordScore += 50;
|
||||
}
|
||||
}
|
||||
score = std::max(score, wordScore);
|
||||
}
|
||||
|
||||
if (!normalizedRequestedId.empty()) {
|
||||
if (containsInsensitive(normalizedLink, normalizedRequestedId)) {
|
||||
score = std::max(score, 700);
|
||||
}
|
||||
if (containsInsensitive(normalizedName, normalizedRequestedId)) {
|
||||
score = std::max(score, 600);
|
||||
}
|
||||
}
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
WebcamCapture::~WebcamCapture() {
|
||||
stop();
|
||||
}
|
||||
|
||||
bool WebcamCapture::initialize(
|
||||
const std::wstring& deviceId,
|
||||
const std::wstring& deviceName,
|
||||
const std::wstring& directShowClsid,
|
||||
int requestedWidth,
|
||||
int requestedHeight,
|
||||
int requestedFps) {
|
||||
fps_ = std::clamp(requestedFps > 0 ? requestedFps : 30, 1, 60);
|
||||
usingDirectShow_ = false;
|
||||
selectedMatchScore_ = 0;
|
||||
if (!succeeded(MFStartup(MF_VERSION), "MFStartup(webcam)")) {
|
||||
if (directShowCapture_.initialize(deviceId, deviceName, directShowClsid, requestedWidth, requestedHeight, fps_)) {
|
||||
usingDirectShow_ = true;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
mfStarted_ = true;
|
||||
if (!selectDevice(deviceId, deviceName)) {
|
||||
if (mfStarted_) {
|
||||
MFShutdown();
|
||||
mfStarted_ = false;
|
||||
}
|
||||
if (directShowCapture_.initialize(deviceId, deviceName, directShowClsid, requestedWidth, requestedHeight, fps_)) {
|
||||
usingDirectShow_ = true;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if ((!deviceId.empty() || !deviceName.empty()) && selectedMatchScore_ <= 0) {
|
||||
if (mediaSource_) {
|
||||
mediaSource_->Shutdown();
|
||||
}
|
||||
sourceReader_.Reset();
|
||||
mediaSource_.Reset();
|
||||
if (mfStarted_) {
|
||||
MFShutdown();
|
||||
mfStarted_ = false;
|
||||
}
|
||||
if (directShowCapture_.initialize(deviceId, deviceName, directShowClsid, requestedWidth, requestedHeight, fps_)) {
|
||||
usingDirectShow_ = true;
|
||||
return true;
|
||||
}
|
||||
std::cerr << "ERROR: Requested webcam device was not found by native Windows webcam providers"
|
||||
<< std::endl;
|
||||
return false;
|
||||
}
|
||||
|
||||
return configureReader(requestedWidth, requestedHeight, fps_);
|
||||
}
|
||||
|
||||
bool WebcamCapture::selectDevice(const std::wstring& deviceId, const std::wstring& deviceName) {
|
||||
Microsoft::WRL::ComPtr<IMFAttributes> attributes;
|
||||
if (!succeeded(MFCreateAttributes(&attributes, 1), "MFCreateAttributes(webcam enumeration)")) {
|
||||
return false;
|
||||
}
|
||||
if (!succeeded(attributes->SetGUID(
|
||||
MF_DEVSOURCE_ATTRIBUTE_SOURCE_TYPE,
|
||||
MF_DEVSOURCE_ATTRIBUTE_SOURCE_TYPE_VIDCAP_GUID),
|
||||
"SetGUID(webcam source type)")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
IMFActivate** devices = nullptr;
|
||||
UINT32 deviceCount = 0;
|
||||
HRESULT hr = MFEnumDeviceSources(attributes.Get(), &devices, &deviceCount);
|
||||
if (!succeeded(hr, "MFEnumDeviceSources") || deviceCount == 0) {
|
||||
if (devices) {
|
||||
CoTaskMemFree(devices);
|
||||
}
|
||||
std::cerr << "ERROR: No native Windows webcam devices were found" << std::endl;
|
||||
return false;
|
||||
}
|
||||
|
||||
UINT32 selectedIndex = 0;
|
||||
int bestScore = 0;
|
||||
for (UINT32 index = 0; index < deviceCount; index += 1) {
|
||||
const std::wstring name = readAllocatedString(devices[index], MF_DEVSOURCE_ATTRIBUTE_FRIENDLY_NAME);
|
||||
const std::wstring symbolicLink = readAllocatedString(devices[index], MF_DEVSOURCE_ATTRIBUTE_SOURCE_TYPE_VIDCAP_SYMBOLIC_LINK);
|
||||
const int score = deviceMatchScore(name, symbolicLink, deviceName, deviceId);
|
||||
std::wcerr << L"INFO: Native webcam candidate [" << index << L"] name=\"" << name << L"\" score=" << score << std::endl;
|
||||
if (score > bestScore) {
|
||||
selectedIndex = index;
|
||||
bestScore = score;
|
||||
}
|
||||
}
|
||||
|
||||
if ((!deviceId.empty() || !deviceName.empty()) && bestScore <= 0) {
|
||||
std::cerr << "WARNING: Requested webcam device was not found by Media Foundation; trying DirectShow"
|
||||
<< std::endl;
|
||||
}
|
||||
|
||||
selectedMatchScore_ = bestScore;
|
||||
selectedDeviceName_ = readAllocatedString(devices[selectedIndex], MF_DEVSOURCE_ATTRIBUTE_FRIENDLY_NAME);
|
||||
hr = devices[selectedIndex]->ActivateObject(IID_PPV_ARGS(&mediaSource_));
|
||||
|
||||
for (UINT32 index = 0; index < deviceCount; index += 1) {
|
||||
devices[index]->Release();
|
||||
}
|
||||
CoTaskMemFree(devices);
|
||||
|
||||
return succeeded(hr, "ActivateObject(webcam)");
|
||||
}
|
||||
|
||||
bool WebcamCapture::configureReader(int requestedWidth, int requestedHeight, int requestedFps) {
|
||||
Microsoft::WRL::ComPtr<IMFAttributes> attributes;
|
||||
if (!succeeded(MFCreateAttributes(&attributes, 2), "MFCreateAttributes(webcam reader)")) {
|
||||
return false;
|
||||
}
|
||||
attributes->SetUINT32(MF_SOURCE_READER_ENABLE_VIDEO_PROCESSING, TRUE);
|
||||
attributes->SetUINT32(MF_READWRITE_DISABLE_CONVERTERS, FALSE);
|
||||
|
||||
if (!succeeded(MFCreateSourceReaderFromMediaSource(mediaSource_.Get(), attributes.Get(), &sourceReader_),
|
||||
"MFCreateSourceReaderFromMediaSource(webcam)")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
Microsoft::WRL::ComPtr<IMFMediaType> mediaType;
|
||||
if (!succeeded(MFCreateMediaType(&mediaType), "MFCreateMediaType(webcam output)")) {
|
||||
return false;
|
||||
}
|
||||
mediaType->SetGUID(MF_MT_MAJOR_TYPE, MFMediaType_Video);
|
||||
mediaType->SetGUID(MF_MT_SUBTYPE, MFVideoFormat_RGB32);
|
||||
if (requestedWidth > 0 && requestedHeight > 0) {
|
||||
MFSetAttributeSize(mediaType.Get(), MF_MT_FRAME_SIZE, static_cast<UINT32>(requestedWidth), static_cast<UINT32>(requestedHeight));
|
||||
}
|
||||
MFSetAttributeRatio(mediaType.Get(), MF_MT_FRAME_RATE, static_cast<UINT32>(std::max(1, requestedFps)), 1);
|
||||
|
||||
if (!succeeded(sourceReader_->SetCurrentMediaType(MF_SOURCE_READER_FIRST_VIDEO_STREAM, nullptr, mediaType.Get()),
|
||||
"SetCurrentMediaType(webcam RGB32)")) {
|
||||
return false;
|
||||
}
|
||||
sourceReader_->SetStreamSelection(MF_SOURCE_READER_ALL_STREAMS, FALSE);
|
||||
sourceReader_->SetStreamSelection(MF_SOURCE_READER_FIRST_VIDEO_STREAM, TRUE);
|
||||
|
||||
Microsoft::WRL::ComPtr<IMFMediaType> currentType;
|
||||
if (!succeeded(sourceReader_->GetCurrentMediaType(MF_SOURCE_READER_FIRST_VIDEO_STREAM, ¤tType),
|
||||
"GetCurrentMediaType(webcam)")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
UINT32 width = 0;
|
||||
UINT32 height = 0;
|
||||
if (FAILED(MFGetAttributeSize(currentType.Get(), MF_MT_FRAME_SIZE, &width, &height)) || width == 0 || height == 0) {
|
||||
width = static_cast<UINT32>(requestedWidth > 0 ? requestedWidth : 1280);
|
||||
height = static_cast<UINT32>(requestedHeight > 0 ? requestedHeight : 720);
|
||||
}
|
||||
width_ = static_cast<int>(width);
|
||||
height_ = static_cast<int>(height);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool WebcamCapture::start() {
|
||||
if (usingDirectShow_) {
|
||||
return directShowCapture_.start();
|
||||
}
|
||||
if (!sourceReader_ || thread_.joinable()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
stopRequested_ = false;
|
||||
thread_ = std::thread(&WebcamCapture::captureLoop, this);
|
||||
return true;
|
||||
}
|
||||
|
||||
void WebcamCapture::stop() {
|
||||
directShowCapture_.stop();
|
||||
stopRequested_ = true;
|
||||
if (thread_.joinable()) {
|
||||
thread_.join();
|
||||
}
|
||||
if (mediaSource_) {
|
||||
mediaSource_->Shutdown();
|
||||
}
|
||||
sourceReader_.Reset();
|
||||
mediaSource_.Reset();
|
||||
if (mfStarted_) {
|
||||
MFShutdown();
|
||||
mfStarted_ = false;
|
||||
}
|
||||
}
|
||||
|
||||
void WebcamCapture::captureLoop() {
|
||||
CoInitializeEx(nullptr, COINIT_MULTITHREADED);
|
||||
|
||||
while (!stopRequested_) {
|
||||
DWORD streamIndex = 0;
|
||||
DWORD flags = 0;
|
||||
LONGLONG timestamp = 0;
|
||||
Microsoft::WRL::ComPtr<IMFSample> sample;
|
||||
HRESULT hr = sourceReader_->ReadSample(
|
||||
MF_SOURCE_READER_FIRST_VIDEO_STREAM,
|
||||
0,
|
||||
&streamIndex,
|
||||
&flags,
|
||||
×tamp,
|
||||
&sample);
|
||||
(void)streamIndex;
|
||||
(void)timestamp;
|
||||
|
||||
if (FAILED(hr)) {
|
||||
std::cerr << "WARNING: Failed to read webcam sample (hr=0x" << std::hex << hr << std::dec << ")"
|
||||
<< std::endl;
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(20));
|
||||
continue;
|
||||
}
|
||||
if ((flags & MF_SOURCE_READERF_ENDOFSTREAM) != 0) {
|
||||
break;
|
||||
}
|
||||
if (!sample) {
|
||||
continue;
|
||||
}
|
||||
|
||||
Microsoft::WRL::ComPtr<IMFMediaBuffer> buffer;
|
||||
if (FAILED(sample->ConvertToContiguousBuffer(&buffer)) || !buffer) {
|
||||
continue;
|
||||
}
|
||||
|
||||
BYTE* data = nullptr;
|
||||
DWORD maxLength = 0;
|
||||
DWORD currentLength = 0;
|
||||
if (FAILED(buffer->Lock(&data, &maxLength, ¤tLength)) || !data) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const DWORD expectedLength = static_cast<DWORD>(std::max(0, width_) * std::max(0, height_) * 4);
|
||||
if (currentLength >= expectedLength && expectedLength > 0) {
|
||||
std::scoped_lock lock(frameMutex_);
|
||||
latestFrame_.assign(data, data + expectedLength);
|
||||
latestFrameSequence_ += 1;
|
||||
}
|
||||
|
||||
buffer->Unlock();
|
||||
}
|
||||
|
||||
CoUninitialize();
|
||||
}
|
||||
|
||||
bool WebcamCapture::copyLatestFrame(WebcamFrameSnapshot& destination) {
|
||||
if (usingDirectShow_) {
|
||||
return directShowCapture_.copyLatestFrame(destination);
|
||||
}
|
||||
std::scoped_lock lock(frameMutex_);
|
||||
if (latestFrame_.empty() || width_ <= 0 || height_ <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
destination.data = latestFrame_;
|
||||
destination.width = width_;
|
||||
destination.height = height_;
|
||||
destination.sequence = latestFrameSequence_;
|
||||
return true;
|
||||
}
|
||||
|
||||
int WebcamCapture::width() const {
|
||||
if (usingDirectShow_) {
|
||||
return directShowCapture_.width();
|
||||
}
|
||||
return width_;
|
||||
}
|
||||
|
||||
int WebcamCapture::height() const {
|
||||
if (usingDirectShow_) {
|
||||
return directShowCapture_.height();
|
||||
}
|
||||
return height_;
|
||||
}
|
||||
|
||||
int WebcamCapture::fps() const {
|
||||
if (usingDirectShow_) {
|
||||
return directShowCapture_.fps();
|
||||
}
|
||||
return fps_;
|
||||
}
|
||||
|
||||
const std::wstring& WebcamCapture::selectedDeviceName() const {
|
||||
if (usingDirectShow_) {
|
||||
return directShowCapture_.selectedDeviceName();
|
||||
}
|
||||
return selectedDeviceName_;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
#pragma once
|
||||
|
||||
#include "dshow_webcam_capture.h"
|
||||
|
||||
#include <Windows.h>
|
||||
#include <mfidl.h>
|
||||
#include <mfreadwrite.h>
|
||||
#include <wrl/client.h>
|
||||
|
||||
#include <atomic>
|
||||
#include <cstdint>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
class WebcamCapture {
|
||||
public:
|
||||
WebcamCapture() = default;
|
||||
~WebcamCapture();
|
||||
|
||||
WebcamCapture(const WebcamCapture&) = delete;
|
||||
WebcamCapture& operator=(const WebcamCapture&) = delete;
|
||||
|
||||
bool initialize(
|
||||
const std::wstring& deviceId,
|
||||
const std::wstring& deviceName,
|
||||
const std::wstring& directShowClsid,
|
||||
int requestedWidth,
|
||||
int requestedHeight,
|
||||
int requestedFps);
|
||||
bool start();
|
||||
void stop();
|
||||
bool copyLatestFrame(WebcamFrameSnapshot& destination);
|
||||
|
||||
int width() const;
|
||||
int height() const;
|
||||
int fps() const;
|
||||
const std::wstring& selectedDeviceName() const;
|
||||
|
||||
private:
|
||||
bool selectDevice(const std::wstring& deviceId, const std::wstring& deviceName);
|
||||
bool configureReader(int requestedWidth, int requestedHeight, int requestedFps);
|
||||
void captureLoop();
|
||||
|
||||
Microsoft::WRL::ComPtr<IMFMediaSource> mediaSource_;
|
||||
Microsoft::WRL::ComPtr<IMFSourceReader> sourceReader_;
|
||||
DirectShowWebcamCapture directShowCapture_;
|
||||
std::thread thread_;
|
||||
std::atomic<bool> stopRequested_ = false;
|
||||
std::mutex frameMutex_;
|
||||
std::vector<BYTE> latestFrame_;
|
||||
uint64_t latestFrameSequence_ = 0;
|
||||
int width_ = 0;
|
||||
int height_ = 0;
|
||||
int fps_ = 30;
|
||||
bool mfStarted_ = false;
|
||||
bool usingDirectShow_ = false;
|
||||
int selectedMatchScore_ = 0;
|
||||
std::wstring selectedDeviceName_;
|
||||
};
|
||||
@@ -0,0 +1,315 @@
|
||||
#include "wgc_session.h"
|
||||
|
||||
#include <Windows.Graphics.Capture.Interop.h>
|
||||
#include <dxgi1_2.h>
|
||||
#include <inspectable.h>
|
||||
#include <winrt/base.h>
|
||||
|
||||
#include <iostream>
|
||||
|
||||
namespace wf = winrt::Windows::Foundation;
|
||||
namespace wgcap = winrt::Windows::Graphics::Capture;
|
||||
namespace wgdx = winrt::Windows::Graphics::DirectX;
|
||||
namespace wgd3d = winrt::Windows::Graphics::DirectX::Direct3D11;
|
||||
|
||||
extern "C" HRESULT __stdcall CreateDirect3D11DeviceFromDXGIDevice(
|
||||
::IDXGIDevice* dxgiDevice,
|
||||
::IInspectable** graphicsDevice);
|
||||
|
||||
namespace {
|
||||
|
||||
bool succeeded(HRESULT hr, const char* label) {
|
||||
if (SUCCEEDED(hr)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
std::cerr << "ERROR: " << label << " failed (hr=0x" << std::hex << hr << std::dec << ")"
|
||||
<< std::endl;
|
||||
return false;
|
||||
}
|
||||
|
||||
int64_t timeSpanToHns(wf::TimeSpan const& value) {
|
||||
return value.count();
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
WgcSession::~WgcSession() {
|
||||
stop();
|
||||
}
|
||||
|
||||
bool WgcSession::createD3DDevice() {
|
||||
UINT flags = D3D11_CREATE_DEVICE_BGRA_SUPPORT;
|
||||
#if defined(_DEBUG)
|
||||
flags |= D3D11_CREATE_DEVICE_DEBUG;
|
||||
#endif
|
||||
|
||||
D3D_FEATURE_LEVEL featureLevels[] = {
|
||||
D3D_FEATURE_LEVEL_11_1,
|
||||
D3D_FEATURE_LEVEL_11_0,
|
||||
D3D_FEATURE_LEVEL_10_1,
|
||||
D3D_FEATURE_LEVEL_10_0,
|
||||
};
|
||||
D3D_FEATURE_LEVEL featureLevel{};
|
||||
|
||||
HRESULT hr = D3D11CreateDevice(
|
||||
nullptr,
|
||||
D3D_DRIVER_TYPE_HARDWARE,
|
||||
nullptr,
|
||||
flags,
|
||||
featureLevels,
|
||||
ARRAYSIZE(featureLevels),
|
||||
D3D11_SDK_VERSION,
|
||||
&d3dDevice_,
|
||||
&featureLevel,
|
||||
&d3dContext_);
|
||||
|
||||
#if defined(_DEBUG)
|
||||
if (FAILED(hr)) {
|
||||
flags &= ~D3D11_CREATE_DEVICE_DEBUG;
|
||||
hr = D3D11CreateDevice(
|
||||
nullptr,
|
||||
D3D_DRIVER_TYPE_HARDWARE,
|
||||
nullptr,
|
||||
flags,
|
||||
featureLevels,
|
||||
ARRAYSIZE(featureLevels),
|
||||
D3D11_SDK_VERSION,
|
||||
&d3dDevice_,
|
||||
&featureLevel,
|
||||
&d3dContext_);
|
||||
}
|
||||
#endif
|
||||
|
||||
if (!succeeded(hr, "D3D11CreateDevice")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
Microsoft::WRL::ComPtr<IDXGIDevice> dxgiDevice;
|
||||
if (!succeeded(d3dDevice_.As(&dxgiDevice), "Query IDXGIDevice")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
winrt::com_ptr<::IInspectable> inspectableDevice;
|
||||
if (!succeeded(CreateDirect3D11DeviceFromDXGIDevice(dxgiDevice.Get(), inspectableDevice.put()),
|
||||
"CreateDirect3D11DeviceFromDXGIDevice")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
winrtDevice_ = inspectableDevice.as<wgd3d::IDirect3DDevice>();
|
||||
return true;
|
||||
}
|
||||
|
||||
bool WgcSession::createCaptureItem(HMONITOR monitor) {
|
||||
auto factory = winrt::get_activation_factory<wgcap::GraphicsCaptureItem>();
|
||||
auto interop = factory.as<IGraphicsCaptureItemInterop>();
|
||||
|
||||
wgcap::GraphicsCaptureItem item{nullptr};
|
||||
HRESULT hr = interop->CreateForMonitor(
|
||||
monitor,
|
||||
winrt::guid_of<wgcap::GraphicsCaptureItem>(),
|
||||
reinterpret_cast<void**>(winrt::put_abi(item)));
|
||||
if (!succeeded(hr, "CreateForMonitor")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
item_ = item;
|
||||
const auto size = item_.Size();
|
||||
width_ = static_cast<int>(size.Width);
|
||||
height_ = static_cast<int>(size.Height);
|
||||
return width_ > 0 && height_ > 0;
|
||||
}
|
||||
|
||||
bool WgcSession::createCaptureItem(HWND window) {
|
||||
auto factory = winrt::get_activation_factory<wgcap::GraphicsCaptureItem>();
|
||||
auto interop = factory.as<IGraphicsCaptureItemInterop>();
|
||||
|
||||
wgcap::GraphicsCaptureItem item{nullptr};
|
||||
HRESULT hr = interop->CreateForWindow(
|
||||
window,
|
||||
winrt::guid_of<wgcap::GraphicsCaptureItem>(),
|
||||
reinterpret_cast<void**>(winrt::put_abi(item)));
|
||||
if (!succeeded(hr, "CreateForWindow")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
item_ = item;
|
||||
const auto size = item_.Size();
|
||||
width_ = static_cast<int>(size.Width);
|
||||
height_ = static_cast<int>(size.Height);
|
||||
return width_ > 0 && height_ > 0;
|
||||
}
|
||||
|
||||
bool WgcSession::applySessionOptions(bool captureCursor) {
|
||||
captureCursor_ = captureCursor;
|
||||
|
||||
try {
|
||||
auto session2 = session_.try_as<wgcap::IGraphicsCaptureSession2>();
|
||||
if (!session2) {
|
||||
if (!captureCursor) {
|
||||
std::cerr << "ERROR: WGC cursor suppression is not supported by this Windows runtime"
|
||||
<< std::endl;
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
session2.IsCursorCaptureEnabled(captureCursor);
|
||||
const bool appliedCursorCapture = session2.IsCursorCaptureEnabled();
|
||||
std::cout << "{\"event\":\"cursor-capture\",\"schemaVersion\":2,\"requested\":"
|
||||
<< (captureCursor ? "true" : "false")
|
||||
<< ",\"applied\":" << (appliedCursorCapture ? "true" : "false") << "}"
|
||||
<< std::endl;
|
||||
|
||||
if (appliedCursorCapture != captureCursor) {
|
||||
std::cerr << "ERROR: WGC cursor capture setting did not apply" << std::endl;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
} catch (winrt::hresult_error const& error) {
|
||||
std::cerr << "ERROR: Failed to configure WGC cursor capture (hr=0x" << std::hex
|
||||
<< static_cast<uint32_t>(error.code()) << std::dec << ")" << std::endl;
|
||||
if (!captureCursor) {
|
||||
return false;
|
||||
}
|
||||
} catch (...) {
|
||||
std::cerr << "ERROR: Failed to configure WGC cursor capture" << std::endl;
|
||||
if (!captureCursor) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
session_.IsBorderRequired(false);
|
||||
} catch (...) {
|
||||
// IsBorderRequired is Windows 11-only. Ignore it on older builds.
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
bool WgcSession::initialize(HMONITOR monitor, int fps, bool captureCursor) {
|
||||
fps_ = fps > 0 ? fps : 60;
|
||||
if (!createD3DDevice()) {
|
||||
return false;
|
||||
}
|
||||
if (!createCaptureItem(monitor)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
framePool_ = wgcap::Direct3D11CaptureFramePool::CreateFreeThreaded(
|
||||
winrtDevice_,
|
||||
wgdx::DirectXPixelFormat::B8G8R8A8UIntNormalized,
|
||||
2,
|
||||
item_.Size());
|
||||
session_ = framePool_.CreateCaptureSession(item_);
|
||||
|
||||
if (!applySessionOptions(captureCursor)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
frameArrivedToken_ = framePool_.FrameArrived({this, &WgcSession::onFrameArrived});
|
||||
return true;
|
||||
}
|
||||
|
||||
bool WgcSession::initialize(HWND window, int fps, bool captureCursor) {
|
||||
fps_ = fps > 0 ? fps : 60;
|
||||
if (!createD3DDevice()) {
|
||||
return false;
|
||||
}
|
||||
if (!createCaptureItem(window)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
framePool_ = wgcap::Direct3D11CaptureFramePool::CreateFreeThreaded(
|
||||
winrtDevice_,
|
||||
wgdx::DirectXPixelFormat::B8G8R8A8UIntNormalized,
|
||||
2,
|
||||
item_.Size());
|
||||
session_ = framePool_.CreateCaptureSession(item_);
|
||||
|
||||
if (!applySessionOptions(captureCursor)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
frameArrivedToken_ = framePool_.FrameArrived({this, &WgcSession::onFrameArrived});
|
||||
return true;
|
||||
}
|
||||
|
||||
void WgcSession::setFrameCallback(FrameCallback callback) {
|
||||
std::scoped_lock lock(callbackMutex_);
|
||||
frameCallback_ = std::move(callback);
|
||||
}
|
||||
|
||||
bool WgcSession::start() {
|
||||
if (!session_) {
|
||||
return false;
|
||||
}
|
||||
if (!applySessionOptions(captureCursor_)) {
|
||||
return false;
|
||||
}
|
||||
session_.StartCapture();
|
||||
started_ = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
void WgcSession::stop() {
|
||||
if (framePool_) {
|
||||
framePool_.FrameArrived(frameArrivedToken_);
|
||||
}
|
||||
if (session_) {
|
||||
session_.Close();
|
||||
session_ = nullptr;
|
||||
}
|
||||
if (framePool_) {
|
||||
framePool_.Close();
|
||||
framePool_ = nullptr;
|
||||
}
|
||||
item_ = nullptr;
|
||||
winrtDevice_ = nullptr;
|
||||
d3dContext_.Reset();
|
||||
d3dDevice_.Reset();
|
||||
started_ = false;
|
||||
}
|
||||
|
||||
void WgcSession::onFrameArrived(
|
||||
wgcap::Direct3D11CaptureFramePool const& sender,
|
||||
wf::IInspectable const&) {
|
||||
auto frame = sender.TryGetNextFrame();
|
||||
if (!frame) {
|
||||
return;
|
||||
}
|
||||
|
||||
auto surface = frame.Surface();
|
||||
auto access = surface.as<::Windows::Graphics::DirectX::Direct3D11::IDirect3DDxgiInterfaceAccess>();
|
||||
Microsoft::WRL::ComPtr<ID3D11Texture2D> texture;
|
||||
HRESULT hr = access->GetInterface(__uuidof(ID3D11Texture2D), reinterpret_cast<void**>(texture.GetAddressOf()));
|
||||
if (FAILED(hr) || !texture) {
|
||||
return;
|
||||
}
|
||||
|
||||
FrameCallback callback;
|
||||
{
|
||||
std::scoped_lock lock(callbackMutex_);
|
||||
callback = frameCallback_;
|
||||
}
|
||||
|
||||
if (callback) {
|
||||
callback(texture.Get(), timeSpanToHns(frame.SystemRelativeTime()));
|
||||
}
|
||||
frame.Close();
|
||||
}
|
||||
|
||||
int WgcSession::captureWidth() const {
|
||||
return width_;
|
||||
}
|
||||
|
||||
int WgcSession::captureHeight() const {
|
||||
return height_;
|
||||
}
|
||||
|
||||
ID3D11Device* WgcSession::device() const {
|
||||
return d3dDevice_.Get();
|
||||
}
|
||||
|
||||
ID3D11DeviceContext* WgcSession::context() const {
|
||||
return d3dContext_.Get();
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
#pragma once
|
||||
|
||||
#include <Windows.h>
|
||||
#include <d3d11.h>
|
||||
#include <windows.graphics.capture.h>
|
||||
#include <windows.graphics.directx.direct3d11.interop.h>
|
||||
#include <winrt/Windows.Foundation.h>
|
||||
#include <winrt/Windows.Graphics.Capture.h>
|
||||
#include <winrt/Windows.Graphics.DirectX.Direct3D11.h>
|
||||
#include <wrl/client.h>
|
||||
|
||||
#include <functional>
|
||||
#include <mutex>
|
||||
|
||||
class WgcSession {
|
||||
public:
|
||||
using FrameCallback = std::function<void(ID3D11Texture2D*, int64_t)>;
|
||||
|
||||
WgcSession() = default;
|
||||
~WgcSession();
|
||||
|
||||
WgcSession(const WgcSession&) = delete;
|
||||
WgcSession& operator=(const WgcSession&) = delete;
|
||||
|
||||
bool initialize(HMONITOR monitor, int fps, bool captureCursor);
|
||||
bool initialize(HWND window, int fps, bool captureCursor);
|
||||
void setFrameCallback(FrameCallback callback);
|
||||
bool start();
|
||||
void stop();
|
||||
|
||||
int captureWidth() const;
|
||||
int captureHeight() const;
|
||||
ID3D11Device* device() const;
|
||||
ID3D11DeviceContext* context() const;
|
||||
|
||||
private:
|
||||
bool createD3DDevice();
|
||||
bool createCaptureItem(HMONITOR monitor);
|
||||
bool createCaptureItem(HWND window);
|
||||
bool applySessionOptions(bool captureCursor);
|
||||
void onFrameArrived(
|
||||
winrt::Windows::Graphics::Capture::Direct3D11CaptureFramePool const& sender,
|
||||
winrt::Windows::Foundation::IInspectable const&);
|
||||
|
||||
Microsoft::WRL::ComPtr<ID3D11Device> d3dDevice_;
|
||||
Microsoft::WRL::ComPtr<ID3D11DeviceContext> d3dContext_;
|
||||
winrt::Windows::Graphics::DirectX::Direct3D11::IDirect3DDevice winrtDevice_{nullptr};
|
||||
winrt::Windows::Graphics::Capture::GraphicsCaptureItem item_{nullptr};
|
||||
winrt::Windows::Graphics::Capture::Direct3D11CaptureFramePool framePool_{nullptr};
|
||||
winrt::Windows::Graphics::Capture::GraphicsCaptureSession session_{nullptr};
|
||||
winrt::event_token frameArrivedToken_{};
|
||||
FrameCallback frameCallback_;
|
||||
std::mutex callbackMutex_;
|
||||
int width_ = 0;
|
||||
int height_ = 0;
|
||||
int fps_ = 60;
|
||||
bool captureCursor_ = false;
|
||||
bool started_ = false;
|
||||
};
|
||||
@@ -1,5 +1,9 @@
|
||||
import { contextBridge, ipcRenderer } from "electron";
|
||||
import { contextBridge, ipcRenderer, webUtils } from "electron";
|
||||
import type { NativeMacRecordingRequest } from "../src/lib/nativeMacRecording";
|
||||
import type { NativeWindowsRecordingRequest } from "../src/lib/nativeWindowsRecording";
|
||||
import type { RecordingSession, StoreRecordedSessionInput } from "../src/lib/recordingSession";
|
||||
import type { ShortcutBinding } from "../src/lib/shortcuts";
|
||||
import { NATIVE_BRIDGE_CHANNEL, type NativeBridgeRequest } from "../src/native/contracts";
|
||||
|
||||
// Asset base URL is passed from the main process via webPreferences.additionalArguments
|
||||
// (see windows.ts). Sandboxed preloads cannot import node:path / node:url, so we
|
||||
@@ -10,12 +14,24 @@ const assetBaseUrl = assetBaseUrlArg ? assetBaseUrlArg.slice(ASSET_BASE_URL_ARG_
|
||||
|
||||
contextBridge.exposeInMainWorld("electronAPI", {
|
||||
assetBaseUrl,
|
||||
invokeNativeBridge: <TData>(request: NativeBridgeRequest) => {
|
||||
return ipcRenderer.invoke(NATIVE_BRIDGE_CHANNEL, request) as Promise<TData>;
|
||||
},
|
||||
hudOverlayHide: () => {
|
||||
ipcRenderer.send("hud-overlay-hide");
|
||||
},
|
||||
hudOverlayClose: () => {
|
||||
ipcRenderer.send("hud-overlay-close");
|
||||
},
|
||||
setHudOverlayIgnoreMouseEvents: (ignore: boolean) => {
|
||||
ipcRenderer.send("hud-overlay-ignore-mouse-events", ignore);
|
||||
},
|
||||
moveHudOverlayBy: (deltaX: number, deltaY: number) => {
|
||||
ipcRenderer.send("hud-overlay-move-by", deltaX, deltaY);
|
||||
},
|
||||
setHudOverlaySize: (width: number, height: number) => {
|
||||
ipcRenderer.send("hud-overlay-set-size", width, height);
|
||||
},
|
||||
getSources: async (opts: Electron.SourcesOptions) => {
|
||||
return await ipcRenderer.invoke("get-sources", opts);
|
||||
},
|
||||
@@ -40,22 +56,75 @@ contextBridge.exposeInMainWorld("electronAPI", {
|
||||
requestCameraAccess: () => {
|
||||
return ipcRenderer.invoke("request-camera-access");
|
||||
},
|
||||
requestAccessibilityAccess: () => {
|
||||
return ipcRenderer.invoke("request-accessibility-access");
|
||||
requestScreenAccess: () => {
|
||||
return ipcRenderer.invoke("request-screen-access");
|
||||
},
|
||||
requestNativeMacCursorAccess: () => {
|
||||
return ipcRenderer.invoke("request-native-mac-cursor-access");
|
||||
},
|
||||
|
||||
storeRecordedVideo: (videoData: ArrayBuffer, fileName: string) => {
|
||||
return ipcRenderer.invoke("store-recorded-video", videoData, fileName);
|
||||
},
|
||||
storeRecordedSession: (payload: StoreRecordedSessionInput) => {
|
||||
return ipcRenderer.invoke("store-recorded-session", payload);
|
||||
},
|
||||
openRecordingStream: (fileName: string) => {
|
||||
return ipcRenderer.invoke("open-recording-stream", fileName);
|
||||
},
|
||||
appendRecordingChunk: (fileName: string, chunk: ArrayBuffer) => {
|
||||
return ipcRenderer.invoke("append-recording-chunk", fileName, chunk);
|
||||
},
|
||||
closeRecordingStream: (fileName: string) => {
|
||||
return ipcRenderer.invoke("close-recording-stream", fileName);
|
||||
},
|
||||
|
||||
getRecordedVideoPath: () => {
|
||||
return ipcRenderer.invoke("get-recorded-video-path");
|
||||
},
|
||||
setRecordingState: (recording: boolean, recordingId?: number) => {
|
||||
return ipcRenderer.invoke("set-recording-state", recording, recordingId);
|
||||
setRecordingState: (
|
||||
recording: boolean,
|
||||
recordingId?: number,
|
||||
cursorCaptureMode?: import("../src/lib/recordingSession").CursorCaptureMode,
|
||||
) => {
|
||||
return ipcRenderer.invoke("set-recording-state", recording, recordingId, cursorCaptureMode);
|
||||
},
|
||||
isNativeWindowsCaptureAvailable: () => {
|
||||
return ipcRenderer.invoke("is-native-windows-capture-available");
|
||||
},
|
||||
isNativeMacCaptureAvailable: () => {
|
||||
return ipcRenderer.invoke("is-native-mac-capture-available");
|
||||
},
|
||||
startNativeWindowsRecording: (request: NativeWindowsRecordingRequest) => {
|
||||
return ipcRenderer.invoke("start-native-windows-recording", request);
|
||||
},
|
||||
stopNativeWindowsRecording: (discard?: boolean) => {
|
||||
return ipcRenderer.invoke("stop-native-windows-recording", discard);
|
||||
},
|
||||
pauseNativeWindowsRecording: () => {
|
||||
return ipcRenderer.invoke("pause-native-windows-recording");
|
||||
},
|
||||
resumeNativeWindowsRecording: () => {
|
||||
return ipcRenderer.invoke("resume-native-windows-recording");
|
||||
},
|
||||
startNativeMacRecording: (request: NativeMacRecordingRequest) => {
|
||||
return ipcRenderer.invoke("start-native-mac-recording", request);
|
||||
},
|
||||
pauseNativeMacRecording: () => {
|
||||
return ipcRenderer.invoke("pause-native-mac-recording");
|
||||
},
|
||||
resumeNativeMacRecording: () => {
|
||||
return ipcRenderer.invoke("resume-native-mac-recording");
|
||||
},
|
||||
stopNativeMacRecording: (discard?: boolean) => {
|
||||
return ipcRenderer.invoke("stop-native-mac-recording", discard);
|
||||
},
|
||||
attachNativeMacWebcamRecording: (payload: {
|
||||
screenVideoPath: string;
|
||||
recordingId: number;
|
||||
webcam: { fileName: string; videoData: ArrayBuffer };
|
||||
cursorCaptureMode?: import("../src/lib/recordingSession").CursorCaptureMode;
|
||||
}) => {
|
||||
return ipcRenderer.invoke("attach-native-mac-webcam-recording", payload);
|
||||
},
|
||||
getCursorTelemetry: (videoPath?: string) => {
|
||||
return ipcRenderer.invoke("get-cursor-telemetry", videoPath);
|
||||
@@ -95,18 +164,41 @@ contextBridge.exposeInMainWorld("electronAPI", {
|
||||
readBinaryFile: (filePath: string) => {
|
||||
return ipcRenderer.invoke("read-binary-file", filePath);
|
||||
},
|
||||
preparePreviewAudioTrack: (filePath: string) => {
|
||||
return ipcRenderer.invoke("prepare-preview-audio-track", filePath);
|
||||
},
|
||||
clearCurrentVideoPath: () => {
|
||||
return ipcRenderer.invoke("clear-current-video-path");
|
||||
},
|
||||
saveProjectFile: (projectData: unknown, suggestedName?: string, existingProjectPath?: string) => {
|
||||
return ipcRenderer.invoke("save-project-file", projectData, suggestedName, existingProjectPath);
|
||||
},
|
||||
loadProjectFile: () => {
|
||||
return ipcRenderer.invoke("load-project-file");
|
||||
loadProjectFile: (projectFolder?: string) => {
|
||||
return ipcRenderer.invoke("load-project-file", projectFolder);
|
||||
},
|
||||
loadProjectFileFromPath: (filePath: string) => {
|
||||
return ipcRenderer.invoke("load-project-file-from-path", filePath);
|
||||
},
|
||||
getPathForFile: (file: File) => {
|
||||
try {
|
||||
return webUtils.getPathForFile(file);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
},
|
||||
loadCurrentProjectFile: () => {
|
||||
return ipcRenderer.invoke("load-current-project-file");
|
||||
},
|
||||
onMenuNewProject: (callback: () => void) => {
|
||||
const listener = () => callback();
|
||||
ipcRenderer.on("menu-new-project", listener);
|
||||
return () => ipcRenderer.removeListener("menu-new-project", listener);
|
||||
},
|
||||
onMenuImportVideo: (callback: () => void) => {
|
||||
const listener = () => callback();
|
||||
ipcRenderer.on("menu-import-video", listener);
|
||||
return () => ipcRenderer.removeListener("menu-import-video", listener);
|
||||
},
|
||||
onMenuLoadProject: (callback: () => void) => {
|
||||
const listener = () => callback();
|
||||
ipcRenderer.on("menu-load-project", listener);
|
||||
@@ -134,6 +226,9 @@ contextBridge.exposeInMainWorld("electronAPI", {
|
||||
saveShortcuts: (shortcuts: unknown) => {
|
||||
return ipcRenderer.invoke("save-shortcuts", shortcuts);
|
||||
},
|
||||
updateGlobalShortcut: (binding: ShortcutBinding) => {
|
||||
return ipcRenderer.invoke("update-global-shortcut", binding);
|
||||
},
|
||||
setLocale: (locale: string) => {
|
||||
return ipcRenderer.invoke("set-locale", locale);
|
||||
},
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import fs from "node:fs/promises";
|
||||
import { fixParsedWebmDuration } from "@fix-webm-duration/fix";
|
||||
import { WebmFile } from "@fix-webm-duration/parser";
|
||||
|
||||
export type DurationPatchResult =
|
||||
| { patched: true }
|
||||
| { patched: false; reason: "no-section" | "already-valid" | "io-error" | "internal" };
|
||||
|
||||
/**
|
||||
* Patch the WebM Duration header on a finalized recording file.
|
||||
*
|
||||
* MediaRecorder writes WebM with no Duration EBML element, and the streaming-to-disk
|
||||
* path never holds the blob so the old `fixWebmDuration(blob, durationMs)` can't run.
|
||||
* Patching on disk after `WriteStream.end()` gives the editor a real duration instead of `N/A`.
|
||||
*
|
||||
* Atomic: writes to `<filePath>.duration-patch.tmp` and renames in place, so a mid-rewrite
|
||||
* crash leaves the original intact. Best-effort: any read/parse/write failure logs and returns
|
||||
* a non-`patched` result rather than throwing; the file still plays without the patch (decoders
|
||||
* walk frames sequentially), only the seek bar and timeline break.
|
||||
*
|
||||
* Reads the whole file into a main-process Buffer, off the renderer so it dodges V8's heap cap.
|
||||
*/
|
||||
export async function patchWebmDurationOnDisk(
|
||||
filePath: string,
|
||||
durationMs: number,
|
||||
): Promise<DurationPatchResult> {
|
||||
try {
|
||||
const fileBytes = await fs.readFile(filePath);
|
||||
const webm = new WebmFile(new Uint8Array(fileBytes));
|
||||
|
||||
const patched = fixParsedWebmDuration(webm, durationMs, { logger: false });
|
||||
if (!patched) {
|
||||
// false means missing Segment, missing Info, or an already-valid Duration.
|
||||
// The first two mean a malformed (likely truncated) file; the third is a no-op.
|
||||
const reason = inferUnpatchedReason(webm);
|
||||
if (reason === "no-section") {
|
||||
console.warn(
|
||||
`[webm-duration] no Segment/Info section in ${filePath}; file may be truncated`,
|
||||
);
|
||||
}
|
||||
return { patched: false, reason };
|
||||
}
|
||||
|
||||
if (!webm.source) {
|
||||
console.error(`[webm-duration] patched but source missing for ${filePath}`);
|
||||
return { patched: false, reason: "internal" };
|
||||
}
|
||||
|
||||
const tmpPath = `${filePath}.duration-patch.tmp`;
|
||||
const patchedBytes = Buffer.from(
|
||||
webm.source.buffer,
|
||||
webm.source.byteOffset,
|
||||
webm.source.byteLength,
|
||||
);
|
||||
try {
|
||||
await fs.writeFile(tmpPath, patchedBytes);
|
||||
await fs.rename(tmpPath, filePath);
|
||||
return { patched: true };
|
||||
} catch (writeError) {
|
||||
console.error(`[webm-duration] failed to write patched ${filePath}:`, writeError);
|
||||
// Clean up the temp file; the original is untouched since the rename never ran.
|
||||
await fs.unlink(tmpPath).catch(() => undefined);
|
||||
return { patched: false, reason: "io-error" };
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[webm-duration] failed to patch ${filePath}:`, error);
|
||||
return { patched: false, reason: "io-error" };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Distinguish "no Segment/Info section" (malformed/truncated file) from "Info present
|
||||
* but Duration already valid" (patch unnecessary).
|
||||
*
|
||||
* The IDs are the length-descriptor-stripped form @fix-webm-duration/parser uses as lookup
|
||||
* keys (Segment `0x8538067`, Info `0x549a966`), per the parser's `src/lib/sections.js`, not
|
||||
* the canonical 4-byte EBML IDs (`0x18538067` / `0x1549A966`) that `getSectionById` never matches.
|
||||
*/
|
||||
function inferUnpatchedReason(webm: WebmFile): "no-section" | "already-valid" {
|
||||
const segment = webm.getSectionById?.(0x8538067);
|
||||
if (!segment) return "no-section";
|
||||
const info = (
|
||||
segment as unknown as { getSectionById?: (id: number) => unknown }
|
||||
).getSectionById?.(0x549a966);
|
||||
return info ? "already-valid" : "no-section";
|
||||
}
|
||||
@@ -10,7 +10,7 @@ const RENDERER_DIST = path.join(APP_ROOT, "dist");
|
||||
const HEADLESS = process.env["HEADLESS"] === "true";
|
||||
|
||||
// Asset base URL for renderer (wallpapers, etc.). Packaged: extraResources copies
|
||||
// public/wallpapers -> resources/wallpapers. Unpackaged: <appRoot>/public/.
|
||||
// public/wallpapers to resources/wallpapers. Unpackaged: <appRoot>/public/.
|
||||
const ASSET_BASE_DIR = process.defaultApp
|
||||
? path.join(__dirname, "..", "public")
|
||||
: process.resourcesPath;
|
||||
@@ -24,10 +24,65 @@ ipcMain.on("hud-overlay-hide", () => {
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.on("hud-overlay-ignore-mouse-events", (_event, ignore: boolean) => {
|
||||
if (hudOverlayWindow && !hudOverlayWindow.isDestroyed()) {
|
||||
hudOverlayWindow.setIgnoreMouseEvents(ignore, { forward: true });
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.on("hud-overlay-move-by", (_event, deltaX: number, deltaY: number) => {
|
||||
if (
|
||||
!hudOverlayWindow ||
|
||||
hudOverlayWindow.isDestroyed() ||
|
||||
!Number.isFinite(deltaX) ||
|
||||
!Number.isFinite(deltaY)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [x, y] = hudOverlayWindow.getPosition();
|
||||
hudOverlayWindow.setPosition(Math.round(x + deltaX), Math.round(y + deltaY), false);
|
||||
});
|
||||
|
||||
// Resize the HUD to fit its rendered content. Anchored by its bottom-centre so it
|
||||
// stays where the user dragged it while only growing/shrinking, which lets the
|
||||
// vertical tray layout grow tall instead of scrolling inside a fixed window.
|
||||
ipcMain.on("hud-overlay-set-size", (_event, width: number, height: number) => {
|
||||
if (
|
||||
!hudOverlayWindow ||
|
||||
hudOverlayWindow.isDestroyed() ||
|
||||
!Number.isFinite(width) ||
|
||||
!Number.isFinite(height)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const bounds = hudOverlayWindow.getBounds();
|
||||
|
||||
// Clamp to the work area of the display the HUD sits on; on a short screen the
|
||||
// vertical layout can exceed the display, where the bar's own overflow scroll takes over.
|
||||
const { workArea } = screen.getDisplayMatching(bounds);
|
||||
const nextWidth = Math.min(workArea.width, Math.max(1, Math.round(width)));
|
||||
const nextHeight = Math.min(workArea.height, Math.max(1, Math.round(height)));
|
||||
|
||||
if (bounds.width === nextWidth && bounds.height === nextHeight) {
|
||||
return;
|
||||
}
|
||||
|
||||
const centerX = bounds.x + bounds.width / 2;
|
||||
const bottomY = bounds.y + bounds.height;
|
||||
|
||||
hudOverlayWindow.setBounds({
|
||||
x: Math.round(centerX - nextWidth / 2),
|
||||
y: Math.round(bottomY - nextHeight),
|
||||
width: nextWidth,
|
||||
height: nextHeight,
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 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
|
||||
* across macOS Spaces so it is never lost when switching virtual desktops.
|
||||
* Frameless transparent HUD overlay, always-on-top, centred at the bottom of the
|
||||
* primary display. Follows the user across macOS Spaces so it isn't lost on switch.
|
||||
*/
|
||||
export function createHudOverlayWindow(): BrowserWindow {
|
||||
const primaryDisplay = screen.getPrimaryDisplay();
|
||||
@@ -42,19 +97,25 @@ export function createHudOverlayWindow(): BrowserWindow {
|
||||
const win = new BrowserWindow({
|
||||
width: windowWidth,
|
||||
height: windowHeight,
|
||||
minWidth: 600,
|
||||
maxWidth: 600,
|
||||
minHeight: 160,
|
||||
maxHeight: 160,
|
||||
// Min/max are intentionally loose: the renderer resizes to fit content via
|
||||
// "hud-overlay-set-size" (above), needed for the vertical tray to grow taller.
|
||||
minWidth: 120,
|
||||
minHeight: 80,
|
||||
x: x,
|
||||
y: y,
|
||||
frame: false,
|
||||
transparent: true,
|
||||
// Fully-transparent ARGB backing. Without this macOS draws the window as a
|
||||
// rounded glass panel with a border around the HUD content.
|
||||
backgroundColor: "#00000000",
|
||||
// Don't let macOS mask the window into a rounded rect; the HUD bar provides
|
||||
// its own rounding and the window itself must be invisible.
|
||||
roundedCorners: false,
|
||||
resizable: false,
|
||||
alwaysOnTop: true,
|
||||
skipTaskbar: true,
|
||||
hasShadow: false,
|
||||
show: !HEADLESS,
|
||||
show: false, // shown via ready-to-show to avoid black rectangle flash
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, "preload.mjs"),
|
||||
additionalArguments: [ASSET_BASE_URL_ARG],
|
||||
@@ -63,13 +124,20 @@ 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.
|
||||
// Follow the user across macOS Spaces, else the HUD stays pinned to the Space
|
||||
// it was first opened on.
|
||||
if (process.platform === "darwin") {
|
||||
win.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });
|
||||
}
|
||||
|
||||
// Show only once painted to avoid the black rectangle flash when a transparent
|
||||
// window is shown before its first paint.
|
||||
win.once("ready-to-show", () => {
|
||||
if (!HEADLESS) win.show();
|
||||
});
|
||||
|
||||
win.webContents.on("did-finish-load", () => {
|
||||
win?.webContents.send("main-process-message", new Date().toLocaleString());
|
||||
});
|
||||
@@ -94,8 +162,8 @@ export function createHudOverlayWindow(): BrowserWindow {
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the main editor window. Starts maximised with a hidden title bar on
|
||||
* macOS. This window is not always-on-top and appears in the taskbar/dock.
|
||||
* Main editor window. Starts maximised with a hidden title bar on macOS; not
|
||||
* always-on-top and appears in the taskbar/dock.
|
||||
*/
|
||||
export function createEditorWindow(): BrowserWindow {
|
||||
const isMac = process.platform === "darwin";
|
||||
@@ -114,8 +182,8 @@ export function createEditorWindow(): BrowserWindow {
|
||||
alwaysOnTop: false,
|
||||
skipTaskbar: false,
|
||||
title: "OpenScreen",
|
||||
backgroundColor: "#000000",
|
||||
show: !HEADLESS,
|
||||
backgroundColor: "#09090b",
|
||||
show: false, // shown via ready-to-show to avoid white flash on first load
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, "preload.mjs"),
|
||||
additionalArguments: [ASSET_BASE_URL_ARG],
|
||||
@@ -126,9 +194,21 @@ export function createEditorWindow(): BrowserWindow {
|
||||
},
|
||||
});
|
||||
|
||||
// Maximize the window by default
|
||||
win.maximize();
|
||||
|
||||
// Show only once painted to avoid a white flash on cold Vite start.
|
||||
win.once("ready-to-show", () => {
|
||||
if (!HEADLESS) win.show();
|
||||
});
|
||||
|
||||
// Inject dark background before any React paint so the sub-titlebar area never
|
||||
// flashes white on a cold Vite load.
|
||||
win.webContents.on("dom-ready", () => {
|
||||
win.webContents.insertCSS("html, body, #root { background: #09090b !important; }").catch(() => {
|
||||
// Best-effort cosmetic; ignore if the page is mid-teardown.
|
||||
});
|
||||
});
|
||||
|
||||
win.webContents.on("did-finish-load", () => {
|
||||
win?.webContents.send("main-process-message", new Date().toLocaleString());
|
||||
});
|
||||
@@ -145,8 +225,8 @@ export function createEditorWindow(): BrowserWindow {
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the floating source-selector window used to pick a screen or window
|
||||
* to record. Frameless, transparent, and follows the user across macOS Spaces.
|
||||
* Floating source-selector window for picking a screen or window to record.
|
||||
* Frameless, transparent, and follows the user across macOS Spaces.
|
||||
*/
|
||||
export function createSourceSelectorWindow(): BrowserWindow {
|
||||
const { width, height } = screen.getPrimaryDisplay().workAreaSize;
|
||||
@@ -171,8 +251,8 @@ export function createSourceSelectorWindow(): BrowserWindow {
|
||||
},
|
||||
});
|
||||
|
||||
// Follow the user across macOS Spaces so the selector appears on the
|
||||
// active desktop regardless of where the HUD was originally opened.
|
||||
// Follow the user across macOS Spaces so the selector appears on the active
|
||||
// desktop regardless of where the HUD was opened.
|
||||
if (process.platform === "darwin") {
|
||||
win.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });
|
||||
}
|
||||
@@ -189,8 +269,8 @@ export function createSourceSelectorWindow(): BrowserWindow {
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a centered transparent countdown overlay window that sits above the
|
||||
* HUD while recording pre-roll is running.
|
||||
* Centered transparent countdown overlay that sits above the HUD during
|
||||
* recording pre-roll.
|
||||
*/
|
||||
export function createCountdownOverlayWindow(): BrowserWindow {
|
||||
const { workArea } = screen.getPrimaryDisplay();
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<html lang="en" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title></title>
|
||||
</head>
|
||||
<body>
|
||||
<body style="background:#09090b;margin:0">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
buildNpmPackage {
|
||||
nodejs = nodejs_22;
|
||||
pname = "openscreen";
|
||||
version = "1.4.0";
|
||||
version = "1.5.0";
|
||||
|
||||
src =
|
||||
let
|
||||
@@ -33,7 +33,7 @@ buildNpmPackage {
|
||||
);
|
||||
};
|
||||
|
||||
npmDepsHash = "sha256-i8QMhvd/ydFPww7qTG3Bz2LOAIFyp65n1NXakr3MTk8=";
|
||||
npmDepsHash = "sha256-lx38H0qG5IrjQRekLG2N+x90Zq/emPfbxOo/qDSn7iE=";
|
||||
|
||||
env.ELECTRON_SKIP_BINARY_DOWNLOAD = "1";
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "openscreen",
|
||||
"private": true,
|
||||
"version": "1.4.0",
|
||||
"version": "1.5.0",
|
||||
"type": "module",
|
||||
"packageManager": "npm@10.9.4",
|
||||
"engines": {
|
||||
@@ -20,18 +20,29 @@
|
||||
"format": "biome format --write .",
|
||||
"i18n:check": "node scripts/i18n-check.mjs",
|
||||
"preview": "vite preview",
|
||||
"build:mac": "tsc && vite build && electron-builder --mac",
|
||||
"build:win": "tsc && vite build && electron-builder --win --config.npmRebuild=false",
|
||||
"build:native:mac": "node scripts/build-macos-screencapturekit-helper.mjs",
|
||||
"build:mac": "npm run build:native:mac && tsc && vite build && electron-builder --mac",
|
||||
"build:native:win": "node scripts/build-windows-wgc-helper.mjs",
|
||||
"build:win": "npm run build:native:win && tsc && vite build && electron-builder --win --config.npmRebuild=false",
|
||||
"build:linux": "tsc && vite build && electron-builder --linux AppImage deb pacman --config.npmRebuild=false",
|
||||
"test": "vitest --run",
|
||||
"test:watch": "vitest",
|
||||
"test:cursor-native:win": "node scripts/test-windows-native-cursor.mjs",
|
||||
"test:wgc-helper:win": "node scripts/test-windows-wgc-helper.mjs",
|
||||
"test:wgc-window:win": "node scripts/test-windows-wgc-helper.mjs --window",
|
||||
"test:wgc-audio:win": "node scripts/test-windows-wgc-helper.mjs --system-audio",
|
||||
"test:wgc-mic:win": "node scripts/test-windows-wgc-helper.mjs --microphone",
|
||||
"test:wgc-mixed-audio:win": "node scripts/test-windows-wgc-helper.mjs --system-audio --microphone",
|
||||
"test:wgc-webcam:win": "node scripts/test-windows-wgc-helper.mjs --webcam",
|
||||
"test:wgc-full:win": "node scripts/test-windows-wgc-helper.mjs --webcam --system-audio --microphone",
|
||||
"capture:openscreen-preview": "node scripts/capture-openscreen-preview.mjs",
|
||||
"inspect:cursor-click-bounce": "node scripts/inspect-native-cursor-click-bounce.mjs",
|
||||
"build-vite": "tsc && vite build",
|
||||
"test:browser": "vitest --config vitest.browser.config.ts --run",
|
||||
"test:browser:install": "playwright install --with-deps chromium-headless-shell",
|
||||
"test:e2e": "playwright test",
|
||||
"prepare": "husky",
|
||||
"rebuild:native": "node ./scripts/rebuild-native.mjs",
|
||||
"postinstall": "npm run rebuild:native"
|
||||
"test:e2e:windows-native-checklist": "playwright test tests/e2e/windows-native-checklist.spec.ts",
|
||||
"prepare": "husky"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fix-webm-duration/fix": "^1.0.1",
|
||||
@@ -52,6 +63,7 @@
|
||||
"@uiw/color-convert": "^2.10.1",
|
||||
"@uiw/react-color-block": "^2.10.1",
|
||||
"@uiw/react-color-colorful": "^2.9.2",
|
||||
"@xenova/transformers": "^2.17.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"dnd-timeline": "^2.4.0",
|
||||
@@ -73,7 +85,6 @@
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"uiohook-napi": "^1.5.5",
|
||||
"uuid": "^13.0.0",
|
||||
"web-demuxer": "^4.0.0"
|
||||
},
|
||||
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 484 B |
|
After Width: | Height: | Size: 490 B |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 7.9 KiB |
|
After Width: | Height: | Size: 8.7 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 7.1 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 6.7 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 7.9 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 6.0 KiB |
|
After Width: | Height: | Size: 8.6 KiB |
|
After Width: | Height: | Size: 6.1 KiB |
|
After Width: | Height: | Size: 8.1 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 2.7 MiB |
|
After Width: | Height: | Size: 2.5 MiB |
|
Before Width: | Height: | Size: 283 KiB After Width: | Height: | Size: 264 KiB |
@@ -0,0 +1,14 @@
|
||||
// electron-builder beforePack hook: ensure the auto-caption assets (Whisper model + ORT wasm) exist
|
||||
// before packaging, so the `caption-assets` extraResources entry has something to copy. Runs on
|
||||
// every package invocation (local `npm run build:*` and CI's bare `electron-builder`). The fetch
|
||||
// script is idempotent, so it's a no-op once the assets are present.
|
||||
|
||||
const { execFileSync } = require("node:child_process");
|
||||
const path = require("node:path");
|
||||
|
||||
exports.default = async function beforePack() {
|
||||
execFileSync("node", [path.join(__dirname, "fetch-caption-model.mjs")], {
|
||||
stdio: "inherit",
|
||||
cwd: path.join(__dirname, ".."),
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,144 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { spawnSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
if (process.platform !== "darwin") {
|
||||
console.log("Skipping macOS ScreenCaptureKit helper build: host platform is not macOS.");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const root = path.resolve(__dirname, "..");
|
||||
const helperName = "openscreen-screencapturekit-helper";
|
||||
const cursorHelperName = "openscreen-macos-cursor-helper";
|
||||
const packageDir = path.join(root, "electron", "native", "screencapturekit");
|
||||
const buildDir = path.join(packageDir, "build");
|
||||
const swiftBuildDir = path.join(buildDir, "swiftpm");
|
||||
const localHelperPath = path.join(buildDir, helperName);
|
||||
const localCursorHelperPath = path.join(buildDir, cursorHelperName);
|
||||
|
||||
// Build a separate single-arch binary per requested arch and place each in its own
|
||||
// electron/native/bin/darwin-<arch> folder (the runtime resolves that folder by the running app's
|
||||
// arch). No universal/fat binary. Defaults to the host arch for local builds; CI sets
|
||||
// OPENSCREEN_MAC_HELPER_ARCHS per matrix entry (accepts arm64, x64, or x86_64).
|
||||
function normalizeArch(value) {
|
||||
return value === "x64" || value === "x86_64"
|
||||
? { swift: "x86_64", tag: "darwin-x64" }
|
||||
: { swift: "arm64", tag: "darwin-arm64" };
|
||||
}
|
||||
const hostArch = process.arch === "arm64" ? "arm64" : "x86_64";
|
||||
const archs = (process.env.OPENSCREEN_MAC_HELPER_ARCHS ?? hostArch)
|
||||
.split(",")
|
||||
.map((a) => a.trim())
|
||||
.filter(Boolean)
|
||||
.map(normalizeArch);
|
||||
|
||||
const xcodebuildVersion = spawnSync("xcodebuild", ["-version"], {
|
||||
cwd: root,
|
||||
encoding: "utf8",
|
||||
});
|
||||
|
||||
if (xcodebuildVersion.status !== 0) {
|
||||
const message = `${xcodebuildVersion.stderr ?? ""}${xcodebuildVersion.stdout ?? ""}`.trim();
|
||||
console.error(
|
||||
[
|
||||
"Unable to build the macOS ScreenCaptureKit helper because full Xcode is not active.",
|
||||
"",
|
||||
message,
|
||||
"",
|
||||
"Install Xcode from the App Store or Apple Developer downloads, then run:",
|
||||
" sudo xcode-select --switch /Applications/Xcode.app/Contents/Developer",
|
||||
" sudo xcodebuild -license accept",
|
||||
"",
|
||||
"Command Line Tools alone may not include the Swift SDK/platform metadata required by SwiftPM.",
|
||||
].join("\n"),
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// SwiftPM writes a single-arch release build to <buildPath>/<swiftArch>-apple-macosx/release/<name>.
|
||||
// Fall back to a search that skips the identically-named file inside the .dSYM debug bundle (matching
|
||||
// that file and feeding it forward is what produced an unrunnable "exec format error" binary before).
|
||||
function findExecutable(dir, swiftArch, name) {
|
||||
const expected = path.join(dir, `${swiftArch}-apple-macosx`, "release", name);
|
||||
if (fs.existsSync(expected)) return expected;
|
||||
|
||||
const stack = [dir];
|
||||
const matches = [];
|
||||
while (stack.length > 0) {
|
||||
const current = stack.pop();
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(current, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (entry.name.endsWith(".dSYM")) continue;
|
||||
const full = path.join(current, entry.name);
|
||||
if (entry.isDirectory()) stack.push(full);
|
||||
else if (entry.isFile() && entry.name === name && /[/\\]release[/\\]/i.test(full)) {
|
||||
matches.push(full);
|
||||
}
|
||||
}
|
||||
}
|
||||
return matches[0] ?? null;
|
||||
}
|
||||
|
||||
fs.mkdirSync(buildDir, { recursive: true });
|
||||
|
||||
for (const { swift, tag } of archs) {
|
||||
const archBuildDir = path.join(swiftBuildDir, swift);
|
||||
const result = spawnSync(
|
||||
"swift",
|
||||
[
|
||||
"build",
|
||||
"-c",
|
||||
"release",
|
||||
"--arch",
|
||||
swift,
|
||||
"--package-path",
|
||||
packageDir,
|
||||
"--build-path",
|
||||
archBuildDir,
|
||||
],
|
||||
{
|
||||
cwd: root,
|
||||
stdio: "inherit",
|
||||
},
|
||||
);
|
||||
if (result.error) {
|
||||
console.error(`Failed to start Swift build (${swift}): ${result.error.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
if (result.status !== 0) {
|
||||
process.exit(result.status ?? 1);
|
||||
}
|
||||
|
||||
const targetDir = path.join(root, "electron", "native", "bin", tag);
|
||||
fs.mkdirSync(targetDir, { recursive: true });
|
||||
|
||||
for (const [name, localPath] of [
|
||||
[helperName, localHelperPath],
|
||||
[cursorHelperName, localCursorHelperPath],
|
||||
]) {
|
||||
const exe = findExecutable(archBuildDir, swift, name);
|
||||
if (!exe) {
|
||||
console.error(`Swift build (${swift}) completed but executable was not found: ${name}`);
|
||||
process.exit(1);
|
||||
}
|
||||
// Always place it in the arch's bin folder; mirror the host-arch build into the dev build
|
||||
// dir so `npm run dev` (candidate path #2) can spawn it.
|
||||
const dests = [path.join(targetDir, name)];
|
||||
if (swift === hostArch) dests.push(localPath);
|
||||
for (const dest of dests) {
|
||||
fs.copyFileSync(exe, dest);
|
||||
fs.chmodSync(dest, 0o755);
|
||||
}
|
||||
}
|
||||
console.log(`Built ${tag} helpers (${swift})`);
|
||||
}
|
||||