diff --git a/.coderabbit.yaml b/.coderabbit.yaml
new file mode 100644
index 000000000..cb9f84b34
--- /dev/null
+++ b/.coderabbit.yaml
@@ -0,0 +1,6 @@
+reviews:
+ profile: assertive
+ auto_review:
+ enabled: true
+ base_branches:
+ - ".*"
diff --git a/.env.example b/.env.example
new file mode 100644
index 000000000..45d02d2a4
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,3 @@
+# Public client configuration. Never put a Supabase service-role key here.
+VITE_SUPABASE_URL=https://YOUR_PROJECT_REF.supabase.co
+VITE_SUPABASE_PUBLISHABLE_KEY=sb_publishable_YOUR_KEY
diff --git a/.eslintrc.cjs b/.eslintrc.cjs
deleted file mode 100644
index 7dbd9d18b..000000000
--- a/.eslintrc.cjs
+++ /dev/null
@@ -1,18 +0,0 @@
-module.exports = {
- root: true,
- env: { browser: true, es2020: true },
- extends: [
- 'eslint:recommended',
- 'plugin:@typescript-eslint/recommended',
- 'plugin:react-hooks/recommended',
- ],
- ignorePatterns: ['dist', '.eslintrc.cjs'],
- parser: '@typescript-eslint/parser',
- plugins: ['react-refresh'],
- rules: {
- 'react-refresh/only-export-components': [
- 'warn',
- { allowConstantExport: true },
- ],
- },
-}
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 000000000..f54a72c5d
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,25 @@
+* text=auto eol=lf
+
+*.bat text eol=crlf
+*.cmd text eol=crlf
+
+*.dll binary
+*.exe binary
+*.gif binary
+*.icns binary
+*.ico binary
+*.jpeg binary
+*.jpg binary
+*.mov binary
+*.mp3 binary
+*.mp4 binary
+*.node binary
+*.pdf binary
+*.png binary
+*.ttf binary
+*.wasm binary
+*.wav binary
+*.webm binary
+*.woff binary
+*.woff2 binary
+*.zip binary
diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml
new file mode 100644
index 000000000..d9910a8f0
--- /dev/null
+++ b/.github/FUNDING.yml
@@ -0,0 +1,15 @@
+# These are supported funding model platforms
+
+github: webadderall
+patreon: # Replace with a single Patreon username
+open_collective: # Replace with a single Open Collective username
+ko_fi: webadderall
+tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
+community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
+liberapay: # Replace with a single Liberapay username
+issuehunt: # Replace with a single IssueHunt username
+lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
+polar: # Replace with a single Polar username
+buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
+thanks_dev: # Replace with a single thanks.dev username
+custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md
new file mode 100644
index 000000000..dd84ea782
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug_report.md
@@ -0,0 +1,38 @@
+---
+name: Bug report
+about: Create a report to help us improve
+title: ''
+labels: ''
+assignees: ''
+
+---
+
+**Describe the bug**
+A clear and concise description of what the bug is.
+
+**To Reproduce**
+Steps to reproduce the behavior:
+1. Go to '...'
+2. Click on '....'
+3. Scroll down to '....'
+4. See error
+
+**Expected behavior**
+A clear and concise description of what you expected to happen.
+
+**Screenshots**
+If applicable, add screenshots to help explain your problem.
+
+**Desktop (please complete the following information):**
+ - OS: [e.g. iOS]
+ - Browser [e.g. chrome, safari]
+ - Version [e.g. 22]
+
+**Smartphone (please complete the following information):**
+ - Device: [e.g. iPhone6]
+ - OS: [e.g. iOS8.1]
+ - Browser [e.g. stock browser, safari]
+ - Version [e.g. 22]
+
+**Additional context**
+Add any other context about the problem here.
diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml
new file mode 100644
index 000000000..7cef0a218
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug_report.yml
@@ -0,0 +1,93 @@
+name: Bug Report
+description: Create a report to help us improve. If you are not sure what you are experiencing is a bug, report it anyway. We would rather you submit an unprofessional bug report than not report it at all.
+title: "[Bug]: "
+labels: ["bug", "triage"]
+body:
+ - type: checkboxes
+ attributes:
+ label: Search existing issues
+ description: Please search to see if an issue already exists for the bug you encountered.
+ options:
+ - label: I have searched the existing issues
+ required: true
+
+ - type: textarea
+ id: bug-description
+ attributes:
+ label: Describe the bug
+ description: A clear and concise description of what the bug is.
+ placeholder: e.g., When I click submit, nothing happens...
+ validations:
+ required: true
+
+ - type: textarea
+ id: expected-behavior
+ attributes:
+ label: Expected behavior
+ description: A clear and concise description of what you expected to happen.
+ placeholder: e.g., The form should submit and show a success message
+ validations:
+ required: true
+
+ - type: textarea
+ id: steps-to-reproduce
+ attributes:
+ label: To Reproduce
+ description: Steps to reproduce the behavior.
+ placeholder: |
+ 1. Go to '...'
+ 2. Click on '....'
+ 3. Scroll down to '....'
+ 4. See error
+ validations:
+ required: false
+
+ - type: textarea
+ id: screenshots
+ attributes:
+ label: Screenshots
+ description: If applicable, add screenshots to help explain your problem.
+ placeholder: Drag and drop images here or paste them
+ validations:
+ required: false
+
+ - type: dropdown
+ id: os-type
+ attributes:
+ label: OS
+ description: Operating system
+ options:
+ - Windows
+ - macOS
+ - Linux
+ - Other
+ validations:
+ required: false
+
+ - type: input
+ id: os-version
+ attributes:
+ label: OS Version
+ description: Please specify your OS version
+ placeholder: e.g., Windows 11, macOS Sonoma, Ubuntu 22.04
+ validations:
+ required: false
+
+ - type: input
+ id: os-other
+ attributes:
+ label: Other OS
+ description: If you selected "Other" for OS, please specify your operating system
+ placeholder: e.g., FreeBSD, Solaris
+ validations:
+ required: false
+
+ - type: textarea
+ id: additional-context
+ attributes:
+ label: Additional context
+ description: Add any other context about the problem here.
+ placeholder: Links, references, or any additional information
+ validations:
+ required: false
+
diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md
new file mode 100644
index 000000000..bbcbbe7d6
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/feature_request.md
@@ -0,0 +1,20 @@
+---
+name: Feature request
+about: Suggest an idea for this project
+title: ''
+labels: ''
+assignees: ''
+
+---
+
+**Is your feature request related to a problem? Please describe.**
+A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
+
+**Describe the solution you'd like**
+A clear and concise description of what you want to happen.
+
+**Describe alternatives you've considered**
+A clear and concise description of any alternative solutions or features you've considered.
+
+**Additional context**
+Add any other context or screenshots about the feature request here.
diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml
new file mode 100644
index 000000000..4200c689e
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/feature_request.yml
@@ -0,0 +1,49 @@
+name: Feature Request
+description: Suggest an idea for this project
+title: "[Feature]: "
+labels: ["enhancement", "feature-request"]
+body:
+ - type: checkboxes
+ attributes:
+ label: Search existing issues
+ description: Please search to see if an issue already exists for this feature request.
+ options:
+ - label: I have searched the existing issues
+ required: true
+
+ - type: textarea
+ id: problem-description
+ attributes:
+ label: Is your feature request related to a problem?
+ description: A clear and concise description of what the problem is.
+ placeholder: e.g., I'm always frustrated when I have to...
+ validations:
+ required: true
+
+ - type: textarea
+ id: solution-description
+ attributes:
+ label: Describe the solution you'd like
+ description: A clear and concise description of what you want to happen.
+ placeholder: Describe the feature or change you're proposing
+ validations:
+ required: false
+
+ - type: textarea
+ id: alternatives
+ attributes:
+ label: Describe alternatives you've considered
+ description: A clear and concise description of any alternative solutions or features you've considered.
+ placeholder: Have you considered any workarounds or alternative approaches?
+ validations:
+ required: false
+
+ - type: textarea
+ id: additional-context
+ attributes:
+ label: Additional context
+ description: Add any other context or screenshots about the feature request here.
+ placeholder: Links, mockups, or any additional information
+ validations:
+ required: false
+
diff --git a/.github/instructions/general.instructions.md b/.github/instructions/general.instructions.md
new file mode 100644
index 000000000..79eba3dfa
--- /dev/null
+++ b/.github/instructions/general.instructions.md
@@ -0,0 +1 @@
+Review this PR. Do not leave line comments. Provide exactly one summary comment including: 1. A letter grade (A-F), 2. Major shortcomings blocking merge 3. Nice-to-have shortcomings 4. Merge readiness (Yes/No).
\ No newline at end of file
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
new file mode 100644
index 000000000..c45d35e06
--- /dev/null
+++ b/.github/pull_request_template.md
@@ -0,0 +1,44 @@
+# Pull Request Template
+
+## Description
+
+
+## Motivation
+
+
+## Type of Change
+- [ ] New Feature
+- [ ] Bug Fix
+- [ ] Refactor / Code Cleanup
+- [ ] Documentation Update
+- [ ] Other (please specify)
+
+## Related Issue(s)
+
+
+## Screenshots / Video
+
+
+**Screenshot** (if applicable):
+
+```markdown
+
+```
+
+**Video** (wherever possible):
+
+```html
+
+```
+
+## Testing Guide
+
+
+## Checklist
+- [ ] I have performed a self-review of my code.
+- [ ] I have added any necessary screenshots or videos.
+- [ ] I have linked related issue(s) and updated the changelog if applicable.
+
+---
+*Thank you for contributing!*
+
diff --git a/.github/workflows/auto-translate-zh.yml b/.github/workflows/auto-translate-zh.yml
new file mode 100644
index 000000000..62846e9d1
--- /dev/null
+++ b/.github/workflows/auto-translate-zh.yml
@@ -0,0 +1,38 @@
+name: Auto Translate to zh-CN
+
+on:
+ push:
+ branches: [main]
+ paths:
+ - "*.md"
+
+permissions:
+ contents: write
+ models: read
+
+jobs:
+ translate:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - uses: actions/cache@v4
+ with:
+ path: .genaiscript/cache/**
+ key: translation-cache-${{ github.run_id }}
+ restore-keys: translation-cache-
+
+ - uses: pelikhan/action-continuous-translation@v0.2.11
+ with:
+ github_token: ${{ secrets.GITHUB_TOKEN }}
+ source: en
+ lang: zh
+ files: "*.md;README.md;CONTRIBUTING.md;TRANSLATION_GUIDE.md;CLAUDE.md"
+ filename_template: "{{ base }}.zh-CN{{ ext }}"
+
+ - uses: stefanzweifel/git-auto-commit-action@v5
+ with:
+ commit_message: "docs(zh-CN): auto-translate updates"
+ file_pattern: "*.zh-CN.md"
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
new file mode 100644
index 000000000..78f1ddae1
--- /dev/null
+++ b/.github/workflows/build.yml
@@ -0,0 +1,204 @@
+
+name: Build Electron App
+
+on:
+ workflow_dispatch:
+
+permissions:
+ contents: read
+ id-token: write
+ attestations: write
+ artifact-metadata: write
+
+jobs:
+ build-windows:
+ runs-on: windows-latest
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: '22'
+
+ - name: Install dependencies
+ run: npm ci --ignore-scripts
+
+ - name: Install bundled FFmpeg binary
+ run: node node_modules/ffmpeg-static/install.js
+
+ - name: Verify bundled FFmpeg binary
+ run: |
+ node -e "const fs=require('node:fs'); const ffmpeg=require('ffmpeg-static'); if (typeof ffmpeg !== 'string' || !fs.existsSync(ffmpeg)) { throw new Error('Bundled ffmpeg-static binary is missing: ' + ffmpeg); } console.log('Using bundled ffmpeg binary: ' + ffmpeg);"
+
+ - name: Install app dependencies
+ run: npx electron-builder install-app-deps
+
+ - name: Build Windows app
+ shell: pwsh
+ run: |
+ npm run build:platform-native-helpers
+ if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
+ npx tsc
+ if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
+ npx vite build
+ if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
+ npx electron-builder --win dir nsis --x64 --publish never
+
+ - name: Smoke test packaged Windows paths
+ run: npm run smoke:packaged-binaries
+
+ - name: Generate Windows artifact checksums
+ run: npm run checksums:release -- SHA256SUMS-windows.txt
+
+ - name: Attest Windows artifacts
+ uses: actions/attest@v4
+ with:
+ subject-checksums: release/SHA256SUMS-windows.txt
+
+ - name: Upload Windows build
+ uses: actions/upload-artifact@v4
+ with:
+ name: windows-x64-release
+ path: |
+ release/*.exe
+ release/*.blockmap
+ release/latest*.yml
+ release/SHA256SUMS*.txt
+ if-no-files-found: error
+ retention-days: 30
+
+ build-macos:
+ runs-on: ${{ matrix.runner }}
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - arch: x64
+ arch_tag: darwin-x64
+ runner: macos-15-intel
+ - arch: arm64
+ arch_tag: darwin-arm64
+ runner: macos-14
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: '22'
+
+ - name: Setup Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: '3.11'
+
+ - name: Install dependencies
+ run: npm ci --ignore-scripts
+
+ - name: Install bundled FFmpeg binary
+ run: node node_modules/ffmpeg-static/install.js
+
+ - name: Verify bundled FFmpeg binary
+ run: |
+ node -e "const fs=require('node:fs'); const ffmpeg=require('ffmpeg-static'); if (typeof ffmpeg !== 'string' || !fs.existsSync(ffmpeg)) { throw new Error('Bundled ffmpeg-static binary is missing: ' + ffmpeg); } console.log('Using bundled ffmpeg binary: ' + ffmpeg);"
+
+ - name: Install app dependencies
+ run: npx electron-builder install-app-deps
+
+ - name: Build macOS app
+ run: |
+ npm run build:platform-native-helpers
+ npx tsc
+ npx vite build
+ npx electron-builder --mac dir dmg zip --${{ matrix.arch }} --publish never
+
+ - name: Smoke test packaged macOS paths
+ env:
+ PACKAGED_SMOKE_ARCH_TAGS: ${{ matrix.arch_tag }}
+ run: npm run smoke:packaged-binaries
+
+ - name: Generate macOS artifact checksums
+ run: npm run checksums:release -- SHA256SUMS-macos-${{ matrix.arch }}.txt
+
+ - name: Attest macOS artifacts
+ uses: actions/attest@v4
+ with:
+ subject-checksums: release/SHA256SUMS-macos-${{ matrix.arch }}.txt
+
+ - name: Upload macOS build
+ uses: actions/upload-artifact@v4
+ with:
+ name: macos-${{ matrix.arch }}-release
+ path: |
+ release/**/*.dmg
+ release/**/*.zip
+ release/**/*.blockmap
+ release/latest-mac.yml
+ release/SHA256SUMS*.txt
+ if-no-files-found: error
+ retention-days: 30
+
+ build-linux:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: '22'
+
+ - name: Install dependencies
+ run: npm ci --ignore-scripts
+
+ - name: Install bundled FFmpeg binary
+ run: node node_modules/ffmpeg-static/install.js
+
+ - name: Verify bundled FFmpeg binary
+ run: |
+ node -e "const fs=require('node:fs'); const ffmpeg=require('ffmpeg-static'); if (typeof ffmpeg !== 'string' || !fs.existsSync(ffmpeg)) { throw new Error('Bundled ffmpeg-static binary is missing: ' + ffmpeg); } console.log('Using bundled ffmpeg binary: ' + ffmpeg);"
+
+ - name: Install Linux native build dependencies
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y libx11-dev libxt-dev libxtst-dev libxkbfile-dev libxi-dev libxrandr-dev libxinerama-dev
+
+ - name: Remove stale uiohook build output
+ run: rm -rf node_modules/uiohook-napi/build
+
+ - name: Install app dependencies
+ run: npx electron-builder install-app-deps
+
+ - name: Build Linux app
+ run: |
+ npm run build:platform-native-helpers
+ npx tsc
+ npx vite build
+ npx electron-builder --linux dir AppImage --x64 --publish never
+
+ - name: Smoke test packaged Linux paths
+ run: npm run smoke:packaged-binaries
+
+ - name: Generate Linux artifact checksums
+ run: npm run checksums:release -- SHA256SUMS-linux.txt
+
+ - name: Attest Linux artifacts
+ uses: actions/attest@v4
+ with:
+ subject-checksums: release/SHA256SUMS-linux.txt
+
+ - name: Upload Linux build
+ uses: actions/upload-artifact@v4
+ with:
+ name: linux-x64-release
+ path: |
+ release/**/*.AppImage
+ release/**/*.blockmap
+ release/latest-linux.yml
+ release/SHA256SUMS*.txt
+ if-no-files-found: error
+ retention-days: 30
diff --git a/.github/workflows/homebrew-tap.yml b/.github/workflows/homebrew-tap.yml
new file mode 100644
index 000000000..307ab1194
--- /dev/null
+++ b/.github/workflows/homebrew-tap.yml
@@ -0,0 +1,176 @@
+name: Update Homebrew Tap
+
+on:
+ workflow_dispatch:
+ inputs:
+ tag:
+ description: "Release tag to publish to Homebrew tap (e.g. v1.0.5)"
+ required: true
+ type: string
+ tap_repo:
+ description: "Tap repository in owner/repo format"
+ required: false
+ default: "webadderall/homebrew-tap"
+ type: string
+
+permissions:
+ contents: read
+
+jobs:
+ update-tap:
+ runs-on: ubuntu-latest
+ env:
+ GH_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
+ TAP_REPO: ${{ github.event.inputs.tap_repo || vars.HOMEBREW_TAP_REPO || 'webadderall/homebrew-tap' }}
+ AUTO_MERGE_TAP_PR: ${{ vars.HOMEBREW_TAP_AUTO_MERGE || 'true' }}
+ steps:
+ - name: Validate token
+ run: |
+ if [ -z "${GH_TOKEN}" ]; then
+ echo "HOMEBREW_TAP_TOKEN secret is not set."
+ exit 1
+ fi
+
+ - name: Resolve release tag
+ id: tag
+ shell: bash
+ run: |
+ TAG="${{ github.event.inputs.tag }}"
+
+ if [ -z "$TAG" ]; then
+ echo "Release tag is empty"
+ exit 1
+ fi
+
+ VERSION="${TAG#v}"
+ echo "tag=$TAG" >> "$GITHUB_OUTPUT"
+ echo "version=$VERSION" >> "$GITHUB_OUTPUT"
+
+ - name: Download release artifacts
+ shell: bash
+ run: |
+ set -euo pipefail
+ mkdir -p /tmp/recordly-release
+ gh release download "${{ steps.tag.outputs.tag }}" \
+ --repo "${{ github.repository }}" \
+ --dir /tmp/recordly-release \
+ --pattern "Recordly-arm64.dmg" \
+ --pattern "Recordly-x64.dmg"
+
+ - name: Compute checksums
+ id: sha
+ shell: bash
+ run: |
+ set -euo pipefail
+ ARM_FILE=$(find /tmp/recordly-release -maxdepth 1 -name 'Recordly-arm64.dmg' | head -n 1)
+ INTEL_FILE=$(find /tmp/recordly-release -maxdepth 1 -name 'Recordly-x64.dmg' | head -n 1)
+
+ if [ -z "$ARM_FILE" ] || [ -z "$INTEL_FILE" ]; then
+ echo "Unable to locate macOS release artifacts for ${{ steps.tag.outputs.tag }}"
+ exit 1
+ fi
+
+ SHA_ARM=$(sha256sum "$ARM_FILE" | awk '{print $1}')
+ SHA_INTEL=$(sha256sum "$INTEL_FILE" | awk '{print $1}')
+ echo "arm=$SHA_ARM" >> "$GITHUB_OUTPUT"
+ echo "intel=$SHA_INTEL" >> "$GITHUB_OUTPUT"
+ echo "arm_file=$(basename "$ARM_FILE")" >> "$GITHUB_OUTPUT"
+ echo "intel_file=$(basename "$INTEL_FILE")" >> "$GITHUB_OUTPUT"
+
+ - name: Clone tap repository
+ shell: bash
+ run: |
+ gh repo clone "$TAP_REPO" /tmp/homebrew-tap
+ git -C /tmp/homebrew-tap remote set-url origin "https://x-access-token:${GH_TOKEN}@github.com/${TAP_REPO}.git"
+
+ - name: Update cask file
+ id: update
+ shell: bash
+ run: |
+ set -euo pipefail
+ cd /tmp/homebrew-tap
+ mkdir -p Casks
+
+ BRANCH="recordly-cask-${{ steps.tag.outputs.tag }}"
+ git checkout -B "$BRANCH"
+
+ cat > Casks/recordly.rb <<'RUBY'
+ cask "recordly" do
+ version "__VERSION__"
+
+ on_arm do
+ sha256 "__SHA_ARM__"
+ url "https://github.com/__REPO__/releases/download/__TAG__/__ARM_FILE__"
+ end
+
+ on_intel do
+ sha256 "__SHA_INTEL__"
+ url "https://github.com/__REPO__/releases/download/__TAG__/__INTEL_FILE__"
+ end
+
+ name "Recordly"
+ desc "Open-source screen recorder and editor"
+ homepage "https://www.recordly.dev"
+
+ app "Recordly.app"
+ end
+ RUBY
+
+ sed -i "s/__VERSION__/${{ steps.tag.outputs.version }}/g" Casks/recordly.rb
+ sed -i "s/__SHA_ARM__/${{ steps.sha.outputs.arm }}/g" Casks/recordly.rb
+ sed -i "s/__SHA_INTEL__/${{ steps.sha.outputs.intel }}/g" Casks/recordly.rb
+ sed -i "s#__REPO__#${{ github.repository }}#g" Casks/recordly.rb
+ sed -i "s#__TAG__#${{ steps.tag.outputs.tag }}#g" Casks/recordly.rb
+ sed -i "s#__ARM_FILE__#${{ steps.sha.outputs.arm_file }}#g" Casks/recordly.rb
+ sed -i "s#__INTEL_FILE__#${{ steps.sha.outputs.intel_file }}#g" Casks/recordly.rb
+
+ if git diff --quiet; then
+ echo "changed=false" >> "$GITHUB_OUTPUT"
+ exit 0
+ fi
+
+ git config user.name "github-actions[bot]"
+ git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
+ git add Casks/recordly.rb
+ git commit -m "chore(cask): update recordly to ${{ steps.tag.outputs.tag }}"
+ git push --force-with-lease origin "$BRANCH"
+
+ echo "changed=true" >> "$GITHUB_OUTPUT"
+ echo "branch=$BRANCH" >> "$GITHUB_OUTPUT"
+
+ - name: Open pull request in tap repo
+ if: steps.update.outputs.changed == 'true'
+ id: pr
+ shell: bash
+ run: |
+ cd /tmp/homebrew-tap
+
+ PR_NUMBER=$(gh pr list --head "${{ steps.update.outputs.branch }}" --json number --jq '.[0].number // empty')
+ if [ -n "$PR_NUMBER" ]; then
+ echo "PR already exists for ${{ steps.update.outputs.branch }} (#$PR_NUMBER)"
+ echo "number=$PR_NUMBER" >> "$GITHUB_OUTPUT"
+ exit 0
+ fi
+
+ PR_URL=$(gh pr create \
+ --title "chore(cask): update recordly to ${{ steps.tag.outputs.tag }}" \
+ --body "Automated cask update for ${{ steps.tag.outputs.tag }}.\n\n- arm64 sha256: \\`${{ steps.sha.outputs.arm }}\\`\n- x64 sha256: \\`${{ steps.sha.outputs.intel }}\\`" \
+ --head "${{ steps.update.outputs.branch }}" \
+ --base main)
+
+ PR_NUMBER=$(echo "$PR_URL" | sed -E 's#.*/pull/([0-9]+).*#\1#')
+ echo "number=$PR_NUMBER" >> "$GITHUB_OUTPUT"
+
+ - name: Auto-merge tap PR
+ if: steps.update.outputs.changed == 'true' && env.AUTO_MERGE_TAP_PR == 'true'
+ shell: bash
+ run: |
+ cd /tmp/homebrew-tap
+
+ PR_NUMBER="${{ steps.pr.outputs.number }}"
+ if [ -z "$PR_NUMBER" ]; then
+ echo "No pull request number available; skipping auto-merge."
+ exit 0
+ fi
+
+ gh pr merge "$PR_NUMBER" --squash --delete-branch --repo "$TAP_REPO"
diff --git a/.github/workflows/macos-release-candidate.yml b/.github/workflows/macos-release-candidate.yml
new file mode 100644
index 000000000..51af13cfe
--- /dev/null
+++ b/.github/workflows/macos-release-candidate.yml
@@ -0,0 +1,297 @@
+name: macOS Release Candidate
+
+run-name: macOS RC validation for ${{ inputs.source_sha }}
+
+on:
+ workflow_dispatch:
+ inputs:
+ source_sha:
+ description: Exact 40-character SHA at the current head of main
+ required: true
+ type: string
+
+permissions:
+ contents: read
+
+concurrency:
+ group: macos-release-candidate-${{ inputs.source_sha }}
+ cancel-in-progress: false
+
+jobs:
+ authorize-source:
+ name: Authorize source commit
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ outputs:
+ source_sha: ${{ steps.authorize.outputs.source_sha }}
+ steps:
+ - name: Checkout current main
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ fetch-depth: 0
+ persist-credentials: false
+ ref: main
+
+ - name: Require the canonical repository and current main
+ id: authorize
+ shell: bash
+ env:
+ REQUESTED_SHA: ${{ inputs.source_sha }}
+ run: |
+ set -euo pipefail
+
+ if [[ "${GITHUB_REPOSITORY,,}" != "webadderallorg/recordly" ]]; then
+ echo "This workflow may run only in webadderallorg/Recordly."
+ exit 1
+ fi
+
+ if [[ "$GITHUB_REF" != "refs/heads/main" ]]; then
+ echo "This workflow must itself be dispatched from main."
+ exit 1
+ fi
+
+ requested_sha="${REQUESTED_SHA,,}"
+ if [[ ! "$requested_sha" =~ ^[0-9a-f]{40}$ ]]; then
+ echo "source_sha must be an exact 40-character commit SHA."
+ exit 1
+ fi
+
+ main_sha="$(git rev-parse HEAD)"
+ if [[ "$requested_sha" != "$main_sha" ]]; then
+ echo "Refusing to sign a stale or non-main commit."
+ echo "Requested: $requested_sha"
+ echo "Current main: $main_sha"
+ exit 1
+ fi
+
+ echo "source_sha=$main_sha" >> "$GITHUB_OUTPUT"
+ echo "Authorized current main commit: $main_sha"
+
+ build-and-verify:
+ name: Sign, notarize, and verify macOS ${{ matrix.arch }}
+ needs: authorize-source
+ runs-on: ${{ matrix.runner }}
+ timeout-minutes: 120
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - arch: x64
+ arch_tag: darwin-x64
+ runner: macos-15-intel
+ - arch: arm64
+ arch_tag: darwin-arm64
+ runner: macos-14
+ env:
+ CI: true
+ steps:
+ - name: Checkout authorized source
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+ ref: ${{ needs.authorize-source.outputs.source_sha }}
+
+ - name: Setup Node.js
+ uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
+ with:
+ cache: npm
+ cache-dependency-path: package-lock.json
+ node-version: '22'
+
+ - name: Setup Python
+ uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
+ with:
+ python-version: '3.11'
+
+ - name: Install dependencies
+ run: npm ci --ignore-scripts
+
+ - name: Install and verify bundled FFmpeg
+ shell: bash
+ run: |
+ set -euo pipefail
+ node scripts/install-ffmpeg-static.mjs
+ node -e "const fs=require('node:fs'); const ffmpeg=require('ffmpeg-static'); if (typeof ffmpeg !== 'string' || !fs.existsSync(ffmpeg)) throw new Error('Bundled FFmpeg is missing'); console.log(ffmpeg);"
+
+ - name: Install app dependencies
+ run: npx electron-builder install-app-deps
+
+ - name: Validate source entitlement plists
+ run: |
+ plutil -lint build/entitlements.mac.plist
+ plutil -lint build/entitlements.mac.inherit.plist
+
+ - name: Build macOS application inputs
+ shell: bash
+ run: |
+ set -euo pipefail
+ npm run build:platform-native-helpers
+ npx tsc
+ npx vite build --config vite.config.ts
+ npm run normalize:electron-main-cjs
+ npm run smoke:electron-main-cjs
+
+ - name: Validate Apple signing certificate
+ shell: bash
+ env:
+ APPLE_SIGNING_CERTIFICATE_P12_BASE64: ${{ secrets.APPLE_SIGNING_CERTIFICATE_P12_BASE64 }}
+ APPLE_SIGNING_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_SIGNING_CERTIFICATE_PASSWORD }}
+ APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
+ run: |
+ set -euo pipefail
+ umask 077
+
+ for name in APPLE_SIGNING_CERTIFICATE_P12_BASE64 APPLE_SIGNING_CERTIFICATE_PASSWORD APPLE_TEAM_ID; do
+ if [[ -z "${!name:-}" ]]; then
+ echo "Missing required macOS candidate secret: $name"
+ exit 1
+ fi
+ done
+
+ if [[ ! "$APPLE_TEAM_ID" =~ ^[A-Z0-9]{10}$ ]]; then
+ echo "APPLE_TEAM_ID must be exactly 10 uppercase letters or digits."
+ exit 1
+ fi
+
+ cert_path="$RUNNER_TEMP/recordly-developer-id.p12"
+ cert_pem_path="$RUNNER_TEMP/recordly-developer-id.pem"
+ printf '%s' "$APPLE_SIGNING_CERTIFICATE_P12_BASE64" | base64 --decode > "$cert_path"
+ if ! openssl pkcs12 \
+ -in "$cert_path" \
+ -clcerts \
+ -nokeys \
+ -passin env:APPLE_SIGNING_CERTIFICATE_PASSWORD \
+ -out "$cert_pem_path"; then
+ rm -f "$cert_pem_path"
+ pkcs12_help="$(openssl pkcs12 -help 2>&1 || true)"
+ if [[ "$pkcs12_help" != *"-legacy"* ]]; then
+ echo "PKCS#12 extraction failed and this OpenSSL has no legacy-provider fallback."
+ exit 1
+ fi
+ echo "Standard PKCS#12 extraction failed; retrying legacy Keychain compatibility."
+ openssl pkcs12 \
+ -legacy \
+ -in "$cert_path" \
+ -clcerts \
+ -nokeys \
+ -passin env:APPLE_SIGNING_CERTIFICATE_PASSWORD \
+ -out "$cert_pem_path"
+ fi
+
+ openssl x509 -in "$cert_pem_path" -noout -checkend 86400
+ cert_subject="$(openssl x509 -in "$cert_pem_path" -noout -subject -nameopt RFC2253 | sed 's/^subject=//')"
+ if [[ "$cert_subject" != *"CN=Developer ID Application:"* ]]; then
+ echo "The P12 leaf certificate is not a Developer ID Application certificate."
+ exit 1
+ fi
+ if [[ ! "$cert_subject" =~ (^|,)OU=${APPLE_TEAM_ID}(,|$) ]]; then
+ echo "The P12 certificate team does not match APPLE_TEAM_ID."
+ exit 1
+ fi
+
+ echo "Developer ID certificate preflight passed and remains valid for at least 24 hours."
+
+ - name: Authenticate Apple notarization credentials
+ timeout-minutes: 5
+ shell: bash
+ env:
+ APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
+ APPLE_ID: ${{ secrets.APPLE_ID }}
+ APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
+ run: |
+ set -euo pipefail
+
+ for name in APPLE_ID APPLE_APP_SPECIFIC_PASSWORD APPLE_TEAM_ID; do
+ if [[ -z "${!name:-}" ]]; then
+ echo "Missing required macOS candidate secret: $name"
+ exit 1
+ fi
+ done
+
+ xcrun notarytool history \
+ --apple-id "$APPLE_ID" \
+ --password "$APPLE_APP_SPECIFIC_PASSWORD" \
+ --team-id "$APPLE_TEAM_ID" \
+ >/dev/null
+
+ echo "Apple notarization credential preflight passed."
+
+ - name: Package, sign, and notarize macOS ${{ matrix.arch }}
+ shell: bash
+ env:
+ APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
+ APPLE_ID: ${{ secrets.APPLE_ID }}
+ APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
+ CSC_KEY_PASSWORD: ${{ secrets.APPLE_SIGNING_CERTIFICATE_PASSWORD }}
+ CSC_LINK: ${{ runner.temp }}/recordly-developer-id.p12
+ run: |
+ set -euo pipefail
+ npx electron-builder \
+ --mac dir dmg zip \
+ --${{ matrix.arch }} \
+ --publish never \
+ -c.mac.forceCodeSigning=true \
+ -c.mac.notarize=true
+
+ - name: Remove local signing material
+ if: always()
+ shell: bash
+ run: |
+ rm -f \
+ "$RUNNER_TEMP/recordly-developer-id.p12" \
+ "$RUNNER_TEMP/recordly-developer-id.pem"
+
+ - name: Smoke test packaged binary paths
+ env:
+ PACKAGED_SMOKE_ARCH_TAGS: ${{ matrix.arch_tag }}
+ run: npm run smoke:packaged-binaries
+
+ - name: Verify signed and notarized distribution artifacts
+ env:
+ APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
+ run: >-
+ npm run verify:macos-distribution --
+ --release-dir release
+ --arch ${{ matrix.arch }}
+ --team-id "$APPLE_TEAM_ID"
+ --report "release/macos-distribution-report-${{ matrix.arch }}.json"
+ --summary "$GITHUB_STEP_SUMMARY"
+
+ - name: Generate candidate checksums
+ run: npm run checksums:release -- SHA256SUMS-macos-${{ matrix.arch }}.txt
+
+ - name: Upload temporary candidate evidence
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: macos-${{ matrix.arch }}-release-candidate
+ path: |
+ release/*.dmg
+ release/*.zip
+ release/*.blockmap
+ release/latest-mac.yml
+ release/SHA256SUMS-macos-${{ matrix.arch }}.txt
+ release/macos-distribution-report-${{ matrix.arch }}.json
+ if-no-files-found: error
+ retention-days: 3
+
+ candidate-verdict:
+ name: macOS candidate verdict
+ if: always()
+ needs:
+ - authorize-source
+ - build-and-verify
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+ steps:
+ - name: Require both architectures to pass
+ shell: bash
+ env:
+ AUTHORIZE_RESULT: ${{ needs.authorize-source.result }}
+ BUILD_RESULT: ${{ needs.build-and-verify.result }}
+ run: |
+ set -euo pipefail
+ if [[ "$AUTHORIZE_RESULT" != "success" || "$BUILD_RESULT" != "success" ]]; then
+ echo "macOS candidate rejected: authorize=$AUTHORIZE_RESULT build=$BUILD_RESULT"
+ exit 1
+ fi
+ echo "Both signed and notarized macOS architectures passed the distribution gate."
diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml
new file mode 100644
index 000000000..bb3499870
--- /dev/null
+++ b/.github/workflows/quality.yml
@@ -0,0 +1,71 @@
+name: Code Quality
+
+on:
+ pull_request:
+ types: [opened, reopened, synchronize, ready_for_review]
+ push:
+ branches: [main]
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ quality:
+ name: Repository quality
+ runs-on: ubuntu-latest
+ timeout-minutes: 20
+ env:
+ CI: true
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ with:
+ persist-credentials: false
+
+ - name: Setup Node.js
+ uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
+ with:
+ node-version: '22'
+ cache: npm
+ cache-dependency-path: package-lock.json
+
+ - name: Install dependencies
+ id: install
+ run: npm ci --ignore-scripts
+
+ - name: Typecheck
+ if: ${{ !cancelled() && steps.install.outcome == 'success' }}
+ run: npx tsc --noEmit
+
+ - name: Lint
+ if: ${{ !cancelled() && steps.install.outcome == 'success' }}
+ run: npm run lint
+
+ - name: Check formatting (advisory)
+ if: ${{ !cancelled() && steps.install.outcome == 'success' }}
+ continue-on-error: true
+ run: npm run format:check
+
+ # Real recording-import tests require the binary skipped by --ignore-scripts.
+ - name: Install FFmpeg for integration tests
+ id: ffmpeg
+ if: ${{ !cancelled() && steps.install.outcome == 'success' }}
+ run: npm rebuild ffmpeg-static
+
+ - name: Test
+ if: ${{ !cancelled() && steps.install.outcome == 'success' && steps.ffmpeg.outcome == 'success' }}
+ run: npm test
+
+ # Main currently has known locale-parity debt covered by PR #710. Keep the
+ # check visible without blocking unrelated PRs; make it required once that
+ # existing translation PR (or an equivalent fix) lands.
+ - name: Check translations (advisory)
+ if: ${{ !cancelled() && steps.install.outcome == 'success' }}
+ continue-on-error: true
+ run: npm run i18n:check
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 000000000..dea29ea6d
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,746 @@
+name: Publish Release
+
+on:
+ release:
+ types: [published]
+ workflow_dispatch:
+ inputs:
+ tag_name:
+ description: Existing release tag to rebuild and publish
+ required: true
+ type: string
+ release_scope:
+ description: Platforms to publish
+ required: true
+ default: all
+ type: choice
+ options:
+ - all
+ - windows-linux
+
+permissions:
+ contents: write
+ actions: write
+ id-token: write
+ attestations: write
+ artifact-metadata: write
+
+concurrency:
+ group: release-${{ github.event.release.tag_name || github.event.inputs.tag_name || github.run_id }}
+ cancel-in-progress: false
+
+jobs:
+ prepare-release:
+ name: Prepare release
+ runs-on: ubuntu-latest
+ outputs:
+ tag_name: ${{ steps.prepare.outputs.tag_name }}
+ package_version: ${{ steps.prepare.outputs.package_version }}
+ source_sha: ${{ steps.prepare.outputs.source_sha }}
+ prerelease: ${{ steps.prepare.outputs.prerelease }}
+ release_scope: ${{ steps.prepare.outputs.release_scope }}
+ steps:
+ - name: Checkout release source
+ uses: actions/checkout@v4
+ with:
+ ref: ${{ github.event_name == 'release' && github.event.release.tag_name || github.event.inputs.tag_name }}
+ fetch-depth: 0
+
+ - name: Validate release tag
+ id: prepare
+ shell: bash
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ RELEASE_BODY: ${{ github.event.release.body }}
+ REQUESTED_SCOPE: ${{ github.event.inputs.release_scope }}
+ RELEASE_TAG_NAME: ${{ github.event.release.tag_name }}
+ DISPATCH_TAG_NAME: ${{ github.event.inputs.tag_name }}
+ run: |
+ set -euo pipefail
+ if [ "${{ github.event_name }}" = "release" ]; then
+ TAG_NAME="$RELEASE_TAG_NAME"
+ IS_PRERELEASE="${{ github.event.release.prerelease }}"
+ RELEASE_SCOPE="all"
+ if [ "$IS_PRERELEASE" = "true" ] && grep -Fq '' <<< "$RELEASE_BODY"; then
+ RELEASE_SCOPE="windows-linux"
+ fi
+ else
+ TAG_NAME="$DISPATCH_TAG_NAME"
+ IS_PRERELEASE="$(gh release view "$TAG_NAME" --repo "${{ github.repository }}" --json isPrerelease --jq '.isPrerelease' 2>/dev/null || echo false)"
+ RELEASE_SCOPE="${REQUESTED_SCOPE:-all}"
+ fi
+
+ if [ -z "$TAG_NAME" ]; then
+ echo "Release tag is required."
+ exit 1
+ fi
+
+ TAG_VERSION="${TAG_NAME#v}"
+ PACKAGE_VERSION=$(node -p "require('./package.json').version")
+
+ if [ "$PACKAGE_VERSION" != "$TAG_VERSION" ]; then
+ echo "Release tag version ($TAG_VERSION) does not match package.json version ($PACKAGE_VERSION)."
+ exit 1
+ fi
+
+ case "$RELEASE_SCOPE" in
+ all|windows-linux) ;;
+ *)
+ echo "Unsupported release scope: $RELEASE_SCOPE"
+ exit 1
+ ;;
+ esac
+
+ if [ "$IS_PRERELEASE" != "true" ] && [ "$RELEASE_SCOPE" != "all" ]; then
+ echo "Partial platform scope is allowed only for prereleases."
+ exit 1
+ fi
+
+ if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
+ gh release view "$TAG_NAME" --repo "${{ github.repository }}" >/dev/null 2>&1 || \
+ gh release create "$TAG_NAME" --repo "${{ github.repository }}" --verify-tag --draft --title "$TAG_NAME"
+ fi
+
+ echo "tag_name=$TAG_NAME" >> "$GITHUB_OUTPUT"
+ echo "package_version=$PACKAGE_VERSION" >> "$GITHUB_OUTPUT"
+ echo "source_sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
+ echo "prerelease=$IS_PRERELEASE" >> "$GITHUB_OUTPUT"
+ echo "release_scope=$RELEASE_SCOPE" >> "$GITHUB_OUTPUT"
+ build-macos-x64:
+ name: Build macOS x64
+ needs: prepare-release
+ if: needs.prepare-release.outputs.release_scope == 'all'
+ runs-on: macos-15-intel
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ APPLE_ID: ${{ secrets.APPLE_ID }}
+ APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
+ APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+ with:
+ ref: ${{ needs.prepare-release.outputs.source_sha }}
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: 22
+ cache: npm
+
+ - name: Setup Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: '3.11'
+
+ - name: Validate macOS signing secrets
+ shell: bash
+ env:
+ APPLE_SIGNING_CERTIFICATE_P12_BASE64: ${{ secrets.APPLE_SIGNING_CERTIFICATE_P12_BASE64 }}
+ APPLE_SIGNING_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_SIGNING_CERTIFICATE_PASSWORD }}
+ run: |
+ set -euo pipefail
+ for name in APPLE_SIGNING_CERTIFICATE_P12_BASE64 APPLE_SIGNING_CERTIFICATE_PASSWORD APPLE_ID APPLE_APP_SPECIFIC_PASSWORD APPLE_TEAM_ID; do
+ if [ -z "${!name:-}" ]; then
+ echo "Missing required macOS release secret: $name"
+ exit 1
+ fi
+ done
+
+ - name: Prepare macOS signing certificate
+ shell: bash
+ env:
+ APPLE_SIGNING_CERTIFICATE_P12_BASE64: ${{ secrets.APPLE_SIGNING_CERTIFICATE_P12_BASE64 }}
+ APPLE_SIGNING_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_SIGNING_CERTIFICATE_PASSWORD }}
+ run: |
+ set -euo pipefail
+ CERT_PATH="$RUNNER_TEMP/apple-signing-cert.p12"
+ echo "$APPLE_SIGNING_CERTIFICATE_P12_BASE64" | base64 --decode > "$CERT_PATH"
+ {
+ echo "CSC_LINK=$CERT_PATH"
+ echo "CSC_KEY_PASSWORD=$APPLE_SIGNING_CERTIFICATE_PASSWORD"
+ } >> "$GITHUB_ENV"
+
+ - name: Install dependencies
+ run: npm ci --ignore-scripts
+
+ - name: Install bundled FFmpeg binary
+ run: node scripts/install-ffmpeg-static.mjs
+
+ - name: Verify bundled FFmpeg binary
+ run: |
+ node -e "const fs=require('node:fs'); const ffmpeg=require('ffmpeg-static'); if (typeof ffmpeg !== 'string' || !fs.existsSync(ffmpeg)) { throw new Error('Bundled ffmpeg-static binary is missing: ' + ffmpeg); } console.log('Using bundled ffmpeg binary: ' + ffmpeg);"
+
+ - name: Install app dependencies
+ run: npx electron-builder install-app-deps
+
+ - name: Build native macOS helpers
+ run: npm run build:platform-native-helpers
+
+ - name: Build macOS x64
+ run: |
+ npx tsc
+ npx vite build
+ npx electron-builder --mac dir dmg zip --x64 --publish never
+
+ - name: Smoke test packaged macOS x64 paths
+ env:
+ PACKAGED_SMOKE_ARCH_TAGS: darwin-x64
+ run: npm run smoke:packaged-binaries
+
+ - name: Generate macOS x64 artifact checksums
+ run: npm run checksums:release -- SHA256SUMS-macos-x64.txt
+
+ - name: Attest macOS x64 artifacts
+ uses: actions/attest@v4
+ with:
+ subject-checksums: release/SHA256SUMS-macos-x64.txt
+
+ - name: Upload macOS x64 artifacts
+ uses: actions/upload-artifact@v4
+ with:
+ name: macos-x64-release
+ path: |
+ release/*.dmg
+ release/*.zip
+ release/*.blockmap
+ release/latest-mac.yml
+ release/SHA256SUMS*.txt
+ if-no-files-found: error
+
+ build-macos-arm64:
+ name: Build macOS arm64
+ needs: prepare-release
+ if: needs.prepare-release.outputs.release_scope == 'all'
+ runs-on: macos-14
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ APPLE_ID: ${{ secrets.APPLE_ID }}
+ APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
+ APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+ with:
+ ref: ${{ needs.prepare-release.outputs.source_sha }}
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: 22
+ cache: npm
+
+ - name: Setup Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: '3.11'
+
+ - name: Validate macOS signing secrets
+ shell: bash
+ env:
+ APPLE_SIGNING_CERTIFICATE_P12_BASE64: ${{ secrets.APPLE_SIGNING_CERTIFICATE_P12_BASE64 }}
+ APPLE_SIGNING_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_SIGNING_CERTIFICATE_PASSWORD }}
+ run: |
+ set -euo pipefail
+ for name in APPLE_SIGNING_CERTIFICATE_P12_BASE64 APPLE_SIGNING_CERTIFICATE_PASSWORD APPLE_ID APPLE_APP_SPECIFIC_PASSWORD APPLE_TEAM_ID; do
+ if [ -z "${!name:-}" ]; then
+ echo "Missing required macOS release secret: $name"
+ exit 1
+ fi
+ done
+
+ - name: Prepare macOS signing certificate
+ shell: bash
+ env:
+ APPLE_SIGNING_CERTIFICATE_P12_BASE64: ${{ secrets.APPLE_SIGNING_CERTIFICATE_P12_BASE64 }}
+ APPLE_SIGNING_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_SIGNING_CERTIFICATE_PASSWORD }}
+ run: |
+ set -euo pipefail
+ CERT_PATH="$RUNNER_TEMP/apple-signing-cert.p12"
+ echo "$APPLE_SIGNING_CERTIFICATE_P12_BASE64" | base64 --decode > "$CERT_PATH"
+ {
+ echo "CSC_LINK=$CERT_PATH"
+ echo "CSC_KEY_PASSWORD=$APPLE_SIGNING_CERTIFICATE_PASSWORD"
+ } >> "$GITHUB_ENV"
+
+ - name: Install dependencies
+ run: npm ci --ignore-scripts
+
+ - name: Install bundled FFmpeg binary
+ run: node scripts/install-ffmpeg-static.mjs
+
+ - name: Verify bundled FFmpeg binary
+ run: |
+ node -e "const fs=require('node:fs'); const ffmpeg=require('ffmpeg-static'); if (typeof ffmpeg !== 'string' || !fs.existsSync(ffmpeg)) { throw new Error('Bundled ffmpeg-static binary is missing: ' + ffmpeg); } console.log('Using bundled ffmpeg binary: ' + ffmpeg);"
+
+ - name: Install app dependencies
+ run: npx electron-builder install-app-deps
+
+ - name: Build native macOS helpers
+ run: npm run build:platform-native-helpers
+
+ - name: Build macOS arm64
+ run: |
+ npx tsc
+ npx vite build
+ npx electron-builder --mac dir dmg zip --arm64 --publish never
+
+ - name: Smoke test packaged macOS arm64 paths
+ env:
+ PACKAGED_SMOKE_ARCH_TAGS: darwin-arm64
+ run: npm run smoke:packaged-binaries
+
+ - name: Generate macOS arm64 artifact checksums
+ run: npm run checksums:release -- SHA256SUMS-macos-arm64.txt
+
+ - name: Attest macOS arm64 artifacts
+ uses: actions/attest@v4
+ with:
+ subject-checksums: release/SHA256SUMS-macos-arm64.txt
+
+ - name: Upload macOS arm64 artifacts
+ uses: actions/upload-artifact@v4
+ with:
+ name: macos-arm64-release
+ path: |
+ release/*.dmg
+ release/*.zip
+ release/*.blockmap
+ release/latest-mac.yml
+ release/SHA256SUMS*.txt
+ if-no-files-found: error
+
+ merge-macos-update-metadata:
+ name: Merge macOS update metadata
+ needs:
+ - prepare-release
+ - build-macos-x64
+ - build-macos-arm64
+ if: needs.prepare-release.outputs.release_scope == 'all'
+ runs-on: ubuntu-latest
+ steps:
+ - name: Download macOS x64 artifacts
+ uses: actions/download-artifact@v4
+ with:
+ name: macos-x64-release
+ path: release-assets/macos-x64
+
+ - name: Download macOS arm64 artifacts
+ uses: actions/download-artifact@v4
+ with:
+ name: macos-arm64-release
+ path: release-assets/macos-arm64
+
+ - name: Setup Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: '3.11'
+
+ - name: Install PyYAML
+ run: python -m pip install pyyaml
+
+ - name: Merge latest-mac.yml
+ shell: bash
+ run: |
+ set -euo pipefail
+ mkdir -p release-assets/merged
+ python <<'PY'
+ from pathlib import Path
+ import yaml
+
+ x64_info = yaml.safe_load(Path('release-assets/macos-x64/latest-mac.yml').read_text())
+ arm64_info = yaml.safe_load(Path('release-assets/macos-arm64/latest-mac.yml').read_text())
+
+ merged_files = []
+ seen_urls = set()
+ for info in (x64_info, arm64_info):
+ for file_info in info.get('files', []):
+ url = file_info.get('url')
+ if url and url not in seen_urls:
+ seen_urls.add(url)
+ merged_files.append(file_info)
+
+ if not merged_files:
+ raise SystemExit('No macOS update files found to merge.')
+
+ preferred = next((item for item in merged_files if 'x64' in item.get('url', '')), merged_files[0])
+ merged = dict(x64_info)
+ merged['files'] = merged_files
+ merged['path'] = preferred.get('url', merged.get('path'))
+ merged['sha512'] = preferred.get('sha512', merged.get('sha512'))
+ if not merged.get('releaseDate') and arm64_info.get('releaseDate'):
+ merged['releaseDate'] = arm64_info['releaseDate']
+
+ Path('release-assets/merged/latest-mac.yml').write_text(
+ yaml.safe_dump(merged, sort_keys=False)
+ )
+ PY
+
+ - name: Upload merged latest-mac.yml artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: macos-merged-metadata
+ path: release-assets/merged/latest-mac.yml
+ if-no-files-found: error
+
+ build-windows-x64:
+ name: Build Windows x64
+ needs: prepare-release
+ runs-on: windows-latest
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+ with:
+ ref: ${{ needs.prepare-release.outputs.source_sha }}
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: 22
+ cache: npm
+
+ - name: Setup Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: '3.11'
+
+ - name: Configure Windows signing
+ shell: pwsh
+ env:
+ WINDOWS_SIGNING_CERTIFICATE_P12_BASE64: ${{ secrets.WINDOWS_SIGNING_CERTIFICATE_P12_BASE64 }}
+ WINDOWS_SIGNING_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_SIGNING_CERTIFICATE_PASSWORD }}
+ run: |
+ if ([string]::IsNullOrWhiteSpace($env:WINDOWS_SIGNING_CERTIFICATE_P12_BASE64) -or [string]::IsNullOrWhiteSpace($env:WINDOWS_SIGNING_CERTIFICATE_PASSWORD)) {
+ Write-Warning 'Windows signing secrets are missing. Building an unsigned installer.'
+ Add-Content -Path $env:GITHUB_ENV -Value 'CSC_IDENTITY_AUTO_DISCOVERY=false'
+ exit 0
+ }
+
+ $certPath = Join-Path $env:RUNNER_TEMP 'windows-signing-cert.p12'
+ [IO.File]::WriteAllBytes($certPath, [Convert]::FromBase64String($env:WINDOWS_SIGNING_CERTIFICATE_P12_BASE64))
+ Add-Content -Path $env:GITHUB_ENV -Value "WIN_CSC_LINK=$certPath"
+ Add-Content -Path $env:GITHUB_ENV -Value "WIN_CSC_KEY_PASSWORD=$env:WINDOWS_SIGNING_CERTIFICATE_PASSWORD"
+
+ - name: Install dependencies
+ run: npm ci --ignore-scripts
+
+ - name: Install bundled FFmpeg binary
+ run: node scripts/install-ffmpeg-static.mjs
+
+ - name: Verify bundled FFmpeg binary
+ run: |
+ node -e "const fs=require('node:fs'); const ffmpeg=require('ffmpeg-static'); if (typeof ffmpeg !== 'string' || !fs.existsSync(ffmpeg)) { throw new Error('Bundled ffmpeg-static binary is missing: ' + ffmpeg); } console.log('Using bundled ffmpeg binary: ' + ffmpeg);"
+
+ - name: Remove stale uiohook build output
+ shell: pwsh
+ run: |
+ if (Test-Path node_modules/uiohook-napi/build) {
+ Remove-Item node_modules/uiohook-napi/build -Recurse -Force
+ }
+
+ - name: Install app dependencies
+ run: npx electron-builder install-app-deps
+
+ - name: Build Windows x64
+ shell: pwsh
+ run: |
+ npm run build:platform-native-helpers
+ npx tsc
+ npx vite build
+ npx electron-builder --win dir nsis --x64 --publish never
+
+ - name: Smoke test packaged Windows x64 paths
+ run: npm run smoke:packaged-binaries
+
+ - name: Generate Windows x64 artifact checksums
+ run: npm run checksums:release -- SHA256SUMS-windows-x64.txt
+
+ - name: Attest Windows x64 artifacts
+ uses: actions/attest@v4
+ with:
+ subject-checksums: release/SHA256SUMS-windows-x64.txt
+
+ - name: Upload Windows x64 artifacts
+ uses: actions/upload-artifact@v4
+ with:
+ name: windows-x64-release
+ path: |
+ release/*.exe
+ release/*.blockmap
+ release/latest*.yml
+ release/SHA256SUMS*.txt
+ if-no-files-found: error
+
+ build-linux-x64:
+ name: Build Linux x64
+ needs: prepare-release
+ runs-on: ubuntu-latest
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+ with:
+ ref: ${{ needs.prepare-release.outputs.source_sha }}
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: 22
+ cache: npm
+
+ - name: Setup Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: '3.11'
+
+ - name: Install Linux native build dependencies
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y libx11-dev libxt-dev libxtst-dev libxkbfile-dev libxi-dev libxrandr-dev libxinerama-dev
+
+ - name: Install dependencies
+ run: npm ci --ignore-scripts
+
+ - name: Install bundled FFmpeg binary
+ run: node scripts/install-ffmpeg-static.mjs
+
+ - name: Verify bundled FFmpeg binary
+ run: |
+ node -e "const fs=require('node:fs'); const ffmpeg=require('ffmpeg-static'); if (typeof ffmpeg !== 'string' || !fs.existsSync(ffmpeg)) { throw new Error('Bundled ffmpeg-static binary is missing: ' + ffmpeg); } console.log('Using bundled ffmpeg binary: ' + ffmpeg);"
+
+ - name: Remove stale uiohook build output
+ run: rm -rf node_modules/uiohook-napi/build
+
+ - name: Install app dependencies
+ run: npx electron-builder install-app-deps
+
+ - name: Build Linux x64
+ run: |
+ export CSC_IDENTITY_AUTO_DISCOVERY=false
+ npm run build:platform-native-helpers
+ npx tsc
+ npx vite build
+ npx electron-builder --linux dir AppImage --x64 --publish never
+
+ - name: Smoke test packaged Linux x64 paths
+ run: npm run smoke:packaged-binaries
+
+ - name: Generate Linux x64 artifact checksums
+ run: npm run checksums:release -- SHA256SUMS-linux-x64.txt
+
+ - name: Attest Linux x64 artifacts
+ uses: actions/attest@v4
+ with:
+ subject-checksums: release/SHA256SUMS-linux-x64.txt
+
+ - name: Upload Linux x64 artifacts
+ uses: actions/upload-artifact@v4
+ with:
+ name: linux-x64-release
+ path: |
+ release/*.AppImage
+ release/*.blockmap
+ release/latest-linux.yml
+ release/SHA256SUMS*.txt
+ if-no-files-found: error
+
+ publish-release-assets:
+ name: Publish release assets
+ needs:
+ - prepare-release
+ - build-macos-x64
+ - build-macos-arm64
+ - merge-macos-update-metadata
+ - build-windows-x64
+ - build-linux-x64
+ if: |
+ always() &&
+ needs.prepare-release.result == 'success' &&
+ needs.build-windows-x64.result == 'success' &&
+ needs.build-linux-x64.result == 'success' &&
+ (
+ needs.prepare-release.outputs.release_scope == 'windows-linux' ||
+ (
+ needs.build-macos-x64.result == 'success' &&
+ needs.build-macos-arm64.result == 'success' &&
+ needs.merge-macos-update-metadata.result == 'success'
+ )
+ )
+ runs-on: ubuntu-latest
+ steps:
+ - name: Download macOS x64 artifacts
+ if: needs.prepare-release.outputs.release_scope == 'all'
+ uses: actions/download-artifact@v4
+ with:
+ name: macos-x64-release
+ path: release-assets/macos-x64
+
+ - name: Download macOS arm64 artifacts
+ if: needs.prepare-release.outputs.release_scope == 'all'
+ uses: actions/download-artifact@v4
+ with:
+ name: macos-arm64-release
+ path: release-assets/macos-arm64
+
+ - name: Download merged macOS metadata
+ if: needs.prepare-release.outputs.release_scope == 'all'
+ uses: actions/download-artifact@v4
+ with:
+ name: macos-merged-metadata
+ path: release-assets/macos-merged
+
+ - name: Download Windows x64 artifacts
+ uses: actions/download-artifact@v4
+ with:
+ name: windows-x64-release
+ path: release-assets/windows-x64
+
+ - name: Download Linux x64 artifacts
+ uses: actions/download-artifact@v4
+ with:
+ name: linux-x64-release
+ path: release-assets/linux-x64
+
+ - name: Stage release assets
+ shell: bash
+ env:
+ RELEASE_SCOPE: ${{ needs.prepare-release.outputs.release_scope }}
+ IS_PRERELEASE: ${{ needs.prepare-release.outputs.prerelease }}
+ run: |
+ set -euo pipefail
+ shopt -s nullglob
+
+ mkdir -p release-assets/upload
+
+ if [ "$IS_PRERELEASE" = "true" ] && [ "$RELEASE_SCOPE" != "all" ]; then
+ echo "Prerelease beta updates must include all platforms so every opted-in client can resolve beta metadata."
+ exit 1
+ fi
+
+ required_metadata=(
+ release-assets/windows-x64/latest.yml
+ release-assets/linux-x64/latest-linux.yml
+ )
+
+ if [ "$RELEASE_SCOPE" = "all" ]; then
+ required_metadata+=(release-assets/macos-merged/latest-mac.yml)
+ fi
+
+ for file in "${required_metadata[@]}"; do
+ if [ ! -f "$file" ]; then
+ echo "Required update metadata is missing: $file"
+ exit 1
+ fi
+ done
+
+ if [ "$IS_PRERELEASE" = "true" ]; then
+ cp release-assets/windows-x64/latest.yml release-assets/windows-x64/beta.yml
+ cp release-assets/linux-x64/latest-linux.yml release-assets/linux-x64/beta-linux.yml
+ update_metadata=(
+ release-assets/windows-x64/beta.yml
+ release-assets/linux-x64/beta-linux.yml
+ )
+
+ if [ "$RELEASE_SCOPE" = "all" ]; then
+ cp release-assets/macos-merged/latest-mac.yml release-assets/macos-merged/beta-mac.yml
+ update_metadata+=(release-assets/macos-merged/beta-mac.yml)
+ fi
+ else
+ update_metadata=(
+ release-assets/windows-x64/latest.yml
+ release-assets/linux-x64/latest-linux.yml
+ )
+
+ if [ "$RELEASE_SCOPE" = "all" ]; then
+ update_metadata+=(release-assets/macos-merged/latest-mac.yml)
+ fi
+ fi
+
+ assets=(
+ release-assets/windows-x64/*.exe
+ release-assets/windows-x64/*.blockmap
+ release-assets/linux-x64/*.AppImage
+ release-assets/linux-x64/*.blockmap
+ )
+
+ if [ "$RELEASE_SCOPE" = "all" ]; then
+ assets+=(
+ release-assets/macos-x64/*.dmg
+ release-assets/macos-x64/*.zip
+ release-assets/macos-x64/*.blockmap
+ release-assets/macos-arm64/*.dmg
+ release-assets/macos-arm64/*.zip
+ release-assets/macos-arm64/*.blockmap
+ )
+ fi
+
+ assets+=("${update_metadata[@]}")
+
+ if [ ${#assets[@]} -eq 0 ]; then
+ echo "No release assets found to checksum."
+ exit 1
+ fi
+
+ for file in "${assets[@]}"; do
+ target="release-assets/upload/$(basename "$file")"
+ if [ -e "$target" ]; then
+ echo "Duplicate release asset name: $(basename "$file")"
+ exit 1
+ fi
+ cp "$file" "$target"
+ done
+
+ - name: Generate release asset checksums
+ shell: bash
+ run: |
+ set -euo pipefail
+ cd release-assets/upload
+ sha256sum * | sort -k2,2 > SHA256SUMS.txt
+ sha256sum -c SHA256SUMS.txt
+
+ - name: Attest release assets
+ uses: actions/attest@v4
+ with:
+ subject-checksums: release-assets/upload/SHA256SUMS.txt
+
+ - name: Upload release assets to GitHub
+ shell: bash
+ run: |
+ set -euo pipefail
+ shopt -s nullglob
+
+ assets=(
+ release-assets/upload/*
+ )
+
+ if [ ${#assets[@]} -eq 0 ]; then
+ echo "No release assets found to upload."
+ exit 1
+ fi
+
+ gh release upload "${{ needs.prepare-release.outputs.tag_name }}" \
+ "${assets[@]}" \
+ --repo "${{ github.repository }}" \
+ --clobber
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
+ trigger-homebrew-update:
+ name: Trigger Homebrew tap update
+ needs:
+ - prepare-release
+ - publish-release-assets
+ runs-on: ubuntu-latest
+ if: needs.prepare-release.outputs.prerelease != 'true' && needs.prepare-release.outputs.release_scope == 'all'
+ steps:
+ - name: Dispatch Homebrew tap workflow
+ shell: bash
+ run: |
+ gh workflow run homebrew-tap.yml \
+ --repo "${{ github.repository }}" \
+ -f tag="${{ needs.prepare-release.outputs.tag_name }}"
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.github/workflows/winget-releaser.yml b/.github/workflows/winget-releaser.yml
new file mode 100644
index 000000000..741d48fd3
--- /dev/null
+++ b/.github/workflows/winget-releaser.yml
@@ -0,0 +1,26 @@
+name: Publish release to WinGet
+on:
+ workflow_run:
+ workflows: ["Publish Release"]
+ types: [completed]
+ workflow_dispatch:
+jobs:
+ publish:
+ runs-on: windows-latest
+ if: github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success'
+ steps:
+ - name: Get release tag
+ id: tag
+ shell: bash
+ run: |
+ TAG=$(gh api repos/${{ github.repository }}/releases/latest --jq '.tag_name')
+ echo "tag=$TAG" >> "$GITHUB_OUTPUT"
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ - uses: vedantmgoyal9/winget-releaser@v2
+ with:
+ identifier: Webadderall.Recordly
+ installers-regex: 'windows\-x64\.exe$'
+ version: ${{ steps.tag.outputs.tag }}
+ release-tag: ${{ steps.tag.outputs.tag }}
+ token: ${{ secrets.WINGET_ACC_TOKEN }}
diff --git a/.gitignore b/.gitignore
index ea2ff9e84..de89bad27 100644
--- a/.gitignore
+++ b/.gitignore
@@ -9,6 +9,7 @@ lerna-debug.log*
node_modules
dist
+dist-electron
dist-ssr
*.local
@@ -23,5 +24,28 @@ dist-ssr
*.sln
*.sw?
release/**
+*.kiro/
+# npx electron-builder --mac --win
+.tmp/
+.history/
+extension-sources/
+*.tsbuildinfo
+vite.config.js
+vite.config.d.ts
-# npx electron-builder --mac --win
\ No newline at end of file
+# Native capture build artifacts
+electron/native/wgc-capture/build/
+electron/native/cursor-monitor/build/
+electron/native/gpu-export-probe/build/
+electron/native/nvidia-cuda-compositor/build/
+electron/native/bin/*/whisper-*
+electron/native/bin/*/whisper-runtime.json
+
+# Local debug helpers
+tmp-*.ps1
+.tmp-*.ps1
+gpu-export-probe.mp4
+
+# Browser test output
+test-results/
+playwright-report/
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 000000000..196186e59
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,63 @@
+# 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.
+
+Areas where help is especially valuable:
+- Export optimisations
+- Native screen recording for Linux
+- Wallpaper submissions
+- Extensions (device frames, click effects, render hooks — see [EXTENSIONS.md](./EXTENSIONS.md))
+
+## How to Contribute
+
+1. **Fork the Repository**
+ - Click the "Fork" button at the top right of this repository to create your own copy.
+
+2. **Clone Your Fork**
+ - Clone your forked repository to your local machine:
+ ```bash
+ git clone https://github.com/your-username/Recordly.git
+ ```
+
+3. **Create a New Branch**
+ - Create a branch for your feature or bug fix:
+ ```bash
+ git checkout -b feature/your-feature-name
+ ```
+
+4. **Make Changes**
+ - Make your changes.
+
+5. **Test Your Changes**
+ - Test your changes thoroughly to ensure they work as expected and do not break existing functionality.
+
+6. **Commit Your Changes**
+ - Commit your changes with a clear and concise commit message:
+ ```bash
+ git add .
+ git commit -m "Add a brief description of your changes"
+ ```
+
+7. **Push Your Changes**
+ - Push your branch to your forked repository:
+ ```bash
+ git push origin feature/your-feature-name
+ ```
+
+8. **Open a Pull Request**
+ - Go to the original repository and open a pull request from your branch. Provide a clear description of your changes and the problem they solve.
+
+## Reporting Issues
+
+If you encounter a bug or have a feature request, please open an issue in the [Issues](https://github.com/webadderallorg/Recordly/issues) section of this repository. Provide as much detail as possible to help us address the issue effectively.
+
+## Style Guide
+
+- Write clear, concise, and descriptive commit messages.
+- Include comments where necessary to explain complex code.
+
+## License
+
+By contributing to this project, you agree that your contributions will be licensed under the [GNU Affero General Public License v3.0](./LICENSE.md).
+
+Thank you for your contributions!
diff --git a/EXTENSIONS.md b/EXTENSIONS.md
new file mode 100644
index 000000000..1104e9526
--- /dev/null
+++ b/EXTENSIONS.md
@@ -0,0 +1,310 @@
+# Recordly Extension API
+
+Go to https://www.marketplace.recordly.dev/extensions for full, regularly updated documentation
+
+Recordly extensions run in the editor renderer and use a permission-gated host API. They can draw into the render pipeline, react to playback and export events, register cursor effects, add settings panels, and contribute packaged assets such as frames, wallpapers, and cursor styles.
+
+## Quick Start
+
+For local user-installed extensions, use `Extensions -> Open Directory` in the app. Recordly stores them in the app `userData/extensions` directory. This repo also includes installable example bundles under `extension-examples/`.
+
+### Minimum Extension
+
+```text
+my-extension/
+ recordly-extension.json
+ index.js
+```
+
+```json
+{
+ "id": "com.example.my-extension",
+ "name": "My Extension",
+ "version": "1.0.0",
+ "description": "A short description",
+ "author": "Your Name",
+ "license": "MIT",
+ "main": "index.js",
+ "permissions": ["render"]
+}
+```
+
+```js
+export function activate(api) {
+ api.log("Hello from my extension");
+}
+
+export function deactivate() {}
+```
+
+### TypeScript
+
+Extensions can be authored in TypeScript. Import the `RecordlyExtensionAPI` type from `types.ts` in this repo for full IDE auto-complete and compile-time checks:
+
+```ts
+import type { RecordlyExtensionAPI } from "./types";
+
+export function activate(api: RecordlyExtensionAPI) {
+ api.registerRenderHook("final", (ctx) => {
+ ctx.ctx.fillStyle = "rgba(255,255,255,0.1)";
+ ctx.ctx.fillRect(0, 0, ctx.width, 30);
+ });
+}
+
+export function deactivate() {}
+```
+
+Recordly loads the `main` entry from the manifest, which must be a `.js` file. If you author in TypeScript, compile or bundle to JavaScript before packaging. A `tsconfig.json` with `"module": "ESNext"` and `"target": "ESNext"` works well since extensions run in a Chromium renderer.
+
+### Manifest Screenshots
+
+Extensions can include `screenshots` in the manifest to show preview images in the marketplace:
+
+```json
+{
+ "id": "com.example.my-extension",
+ "screenshots": [
+ "screenshots/preview-1.png",
+ "screenshots/preview-2.png"
+ ]
+}
+```
+
+Paths are relative to the extension root. The marketplace displays screenshots in a carousel on both the in-app detail modal and the web detail page.
+
+## Manifest
+
+`recordly-extension.json` is validated both when the extension loads and when a zip is uploaded to the marketplace.
+
+| Field | Type | Required | Description |
+|-------|------|----------|-------------|
+| `id` | `string` | Yes | Unique identifier, for example `yourname.cool-effect` |
+| `name` | `string` | Yes | Human-readable name shown in the UI |
+| `version` | `string` | Yes | Strict semver version, for example `1.0.0` |
+| `description` | `string` | Yes | One-line summary |
+| `author` | `string` | No | Author or organisation |
+| `homepage` | `string` | No | HTTPS homepage or repository URL |
+| `license` | `string` | No | SPDX license identifier |
+| `engine` | `string` | No | Minimum supported Recordly version |
+| `icon` | `string` | No | Relative path to a PNG icon |
+| `screenshots` | `string[]` | No | Relative paths to preview images shown in the marketplace |
+| `main` | `string` | Yes | Relative entry point JS file |
+| `permissions` | `string[]` | Yes | Required capabilities |
+| `contributes` | `object` | No | Metadata for packaged frames, cursor styles, sounds, wallpapers, and webcam frames |
+
+`contributes` is metadata only today. Recordly does not auto-register runtime behavior from the manifest. Use `activate()` to call APIs such as `registerFrame()`, `registerWallpaper()`, `registerCursorStyle()`, `registerSettingsPanel()`, and `playSound()`.
+
+## Permissions
+
+| Permission | Grants access to |
+|-----------|------------------|
+| `render` | Render hook registration |
+| `cursor` | Cursor telemetry and cursor effect registration |
+| `audio` | Bundled sound playback |
+| `timeline` | Playback and timeline events |
+| `ui` | Settings panels and device frame registration |
+| `assets` | Bundled asset resolution plus wallpaper and cursor style registration |
+| `export` | Export lifecycle events |
+
+## Render Pipeline
+
+Render hooks draw into `hookCtx.ctx`, a `CanvasRenderingContext2D`, at specific phases.
+
+| Phase | Preview | Export | Notes |
+|-------|---------|--------|-------|
+| `background` | Reserved | Reserved | Exists in the type surface but is not dispatched yet |
+| `post-video` | Yes | Yes | Runs inside the scene transform |
+| `post-zoom` | Yes | Yes | Runs inside the scene transform |
+| `post-cursor` | Yes | Yes | Runs inside the scene transform |
+| `post-webcam` | Yes | Yes | Runs after the built-in transform |
+| `post-annotations` | Yes | Yes | Runs after the built-in transform |
+| `final` | Yes | Yes | Last pass for HUD-style overlays |
+
+### Inside vs Outside the Scene Transform
+
+- `post-video`, `post-zoom`, and `post-cursor` already follow zoom and motion in preview and export.
+- `post-webcam`, `post-annotations`, and `final` run after Recordly restores the canvas transform. Use `sceneTransform` manually if you want those overlays to move with the scene.
+
+### RenderHookContext
+
+```ts
+{
+ width: number;
+ height: number;
+ timeMs: number;
+ durationMs: number;
+ cursor: { cx: number; cy: number; interactionType?: string } | null;
+ smoothedCursor?: {
+ cx: number;
+ cy: number;
+ trail: Array<{ cx: number; cy: number }>;
+ } | null;
+ ctx: CanvasRenderingContext2D;
+ videoLayout?: {
+ maskRect: { x: number; y: number; width: number; height: number };
+ borderRadius: number;
+ padding: number;
+ };
+ zoom?: { scale: number; focusX: number; focusY: number; progress: number };
+ sceneTransform?: { scale: number; x: number; y: number };
+ shadow?: { enabled: boolean; intensity: number };
+ getPixelColor(x: number, y: number): { r: number; g: number; b: number; a: number };
+ getAverageSceneColor(): { r: number; g: number; b: number; a: number };
+ getEdgeAverageColor(edgeWidth?: number): { r: number; g: number; b: number; a: number };
+ getDominantColors(count?: number): Array<{ r: number; g: number; b: number; frequency: number }>;
+}
+```
+
+Use `videoLayout.maskRect` and `videoLayout.borderRadius` for scene-relative sizing. That gives you true scene borders and correctly shaped rounded corners instead of canvas-wide overlays.
+
+## Cursor Effects
+
+Cursor effect callbacks run each frame after a click until they return `false`.
+
+```js
+api.registerCursorEffect((ctx) => {
+ const progress = ctx.elapsedMs / 400;
+ if (progress >= 1) return false;
+
+ const sceneWidth = ctx.videoLayout?.maskRect.width ?? ctx.width;
+ const x = ctx.cx * ctx.width;
+ const y = ctx.cy * ctx.height;
+ const radius = sceneWidth * 0.03 * progress;
+
+ ctx.ctx.beginPath();
+ ctx.ctx.arc(x, y, radius, 0, Math.PI * 2);
+ ctx.ctx.stroke();
+ return true;
+});
+```
+
+`CursorEffectContext` now includes `videoLayout`, `zoom`, and `sceneTransform`, so effects can scale relative to the scene instead of the full canvas.
+
+## API Surface
+
+### Registration
+
+```js
+api.registerRenderHook(phase, hook);
+api.registerCursorEffect(effect);
+api.registerFrame(frame);
+api.registerWallpaper(wallpaper);
+api.registerCursorStyle(cursorStyle);
+api.registerSettingsPanel(panel);
+api.on(event, handler);
+```
+
+Every registration returns a dispose function.
+
+### Settings
+
+```js
+api.getSetting(settingId);
+api.setSetting(settingId, value);
+api.onSettingChange((settingId, value) => {});
+api.getAllSettings();
+```
+
+Supported settings field types are `toggle`, `slider`, `select`, `color`, and `text`.
+
+### Assets and Audio
+
+```js
+api.resolveAsset("images/overlay.png");
+api.playSound("sounds/click.mp3", { volume: 0.8 });
+api.log("hello", payload);
+```
+
+### Read-only Queries
+
+```js
+api.getVideoInfo();
+api.getVideoLayout();
+api.getCursorAt(timeMs);
+api.getSmoothedCursor();
+api.getZoomState();
+api.getShadowConfig();
+api.getKeystrokesInRange(startMs, endMs);
+api.getAspectRatio();
+api.getActiveFrame();
+api.isExtensionActive(extensionId);
+api.getPlaybackState();
+api.getCanvasDimensions();
+api.drawIcon(ctx, "Sparkle", 100, 100, 20, "#2563EB", "regular");
+```
+
+### Drawing Icons
+
+Extensions can draw icons from Recordly's bundled Phosphor icon set directly on a canvas context:
+
+```js
+api.drawIcon(
+ ctx,
+ "ArrowClockwise", // icon name from @phosphor-icons/react
+ 120, // x (center)
+ 80, // y (center)
+ 18, // size in px
+ "#ffffff", // color
+ "bold", // optional weight: thin | light | regular | bold | fill
+);
+```
+
+This is useful for lightweight overlays and avoids bundling your own icon assets.
+
+## Settings Panels
+
+```js
+api.registerSettingsPanel({
+ id: "my-settings",
+ label: "My Extension",
+ icon: "sparkles",
+ parentSection: "cursor",
+ fields: [
+ { id: "enabled", label: "Enable", type: "toggle", defaultValue: true },
+ { id: "size", label: "Size", type: "slider", defaultValue: 1, min: 0.1, max: 3, step: 0.1 },
+ {
+ id: "style",
+ label: "Style",
+ type: "select",
+ defaultValue: "ripple",
+ options: [{ label: "Ripple", value: "ripple" }, { label: "Pulse", value: "pulse" }],
+ },
+ { id: "color", label: "Color", type: "color", defaultValue: "#2563EB" },
+ ],
+});
+```
+
+Use `parentSection` to nest your panel inside an existing area such as `cursor` or `scene`.
+
+## Events
+
+| Event | Permission | Description |
+|-------|------------|-------------|
+| `playback:timeupdate` | `timeline` | Fires each playback tick |
+| `playback:play` | `timeline` | Playback started |
+| `playback:pause` | `timeline` | Playback paused |
+| `cursor:click` | `cursor` | Cursor click detected |
+| `cursor:move` | `cursor` | Cursor move detected |
+| `timeline:region-added` | `timeline` | Region added |
+| `timeline:region-removed` | `timeline` | Region removed |
+| `export:start` | `export` | Export started |
+| `export:frame` | `export` | A frame was rendered during export |
+| `export:complete` | `export` | Export finished |
+
+## Frames, Wallpapers, and Cursor Styles
+
+- `registerFrame()` is the runtime API for device frames. Use a `draw(ctx, width, height)` function when possible for resolution-independent output.
+- `registerWallpaper()` contributes scene backgrounds.
+- `registerCursorStyle()` contributes cursor image packs.
+- All three rely on packaged files relative to the extension root.
+
+## Lifecycle
+
+1. Discovery: Recordly scans built-in extensions and the user extensions directory.
+2. Activation: `activate(api)` runs and you register hooks, effects, panels, and assets.
+3. Runtime: Registered callbacks execute in preview and export according to their phase.
+4. Deactivation: `deactivate()` runs and all registrations are automatically disposed.
+
+## Examples
+
+- `extension-examples/webadderall.more-wallpapers` shows a user-installable wallpaper bundle that registers 180 packaged wallpapers through `registerWallpaper()`.
diff --git a/LICENSE b/LICENSE
deleted file mode 100644
index 45139414b..000000000
--- a/LICENSE
+++ /dev/null
@@ -1,8 +0,0 @@
-OpenScreen Free Use License
-Copyright (c) 2025 Siddharth Vaddem
-
-Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
diff --git a/LICENSE.md b/LICENSE.md
new file mode 100644
index 000000000..e7ad2a920
--- /dev/null
+++ b/LICENSE.md
@@ -0,0 +1,252 @@
+## GNU AFFERO GENERAL PUBLIC LICENSE
+Version 3, 19 November 2007
+
+### QUICK SUMMARY:
+- You ARE allowed to **use, distribute, and modify this code for all intents and purposes.**
+- You ARE allowed to self-host this for your own personal use.
+- RECIPROCAL LICENSING: **If you modify this code or use it
+ to power a website or service (SaaS)**, you MUST make your **entire
+ source code** (including all edits) p**ublicly available** under this
+ same AGPLv3 license.
+- You CANNOT use the "Recordly" name or branding for your own project.
+- If you use Recordly's code or create code derived from Recordly you must attribute Recordly in the user-facing UI and the repo.
+
+Copyright (C) 2026 webadderall
+
+Recordly is an independent application that started as a fork of the OpenScreen project, authored by Siddharth Vaddem (2025).
+
+---
+PART 1: THE AGPLv3 LICENSE
+---
+(The following is the full text of the AGPLv3. This license governs the
+entirety of this project from 2026 onwards.)
+
+GNU AFFERO GENERAL PUBLIC LICENSE
+Version 3, 19 November 2007
+
+Copyright © 2007 Free Software Foundation, Inc.
+Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed.
+
+Preamble
+The GNU Affero General Public License is a free, copyleft license for software and other kinds of works, specifically designed to ensure cooperation with the community in the case of network server software.
+
+The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, our General Public Licenses are intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users.
+
+When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things.
+
+Developers that use our General Public Licenses protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License which gives you legal permission to copy, distribute and/or modify the software.
+
+A secondary benefit of defending all users' freedom is that improvements made in alternate versions of the program, if they receive widespread use, become available for other developers to incorporate. Many developers of free software are heartened and encouraged by the resulting cooperation. However, in the case of software used on network servers, this result may fail to come about. The GNU General Public License permits making a modified version and letting the public access it on a server without ever releasing its source code to the public.
+
+The GNU Affero General Public License is designed specifically to ensure that, in such cases, the modified source code becomes available to the community. It requires the operator of a network server to provide the source code of the modified version running there to the users of that server. Therefore, public use of a modified version, on a publicly accessible server, gives the public access to the source code of the modified version.
+
+An older license, called the Affero General Public License and published by Affero, was designed to accomplish similar goals. This is a different license, not a version of the Affero GPL, but Affero has released a new version of the Affero GPL which permits relicensing under this license.
+
+The precise terms and conditions for copying, distribution and modification follow.
+
+TERMS AND CONDITIONS
+0. Definitions.
+"This License" refers to version 3 of the GNU Affero General Public License.
+
+"Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks.
+
+"The Program" refers to any copyrightable work licensed under this License. Each licensee is addressed as "you". "Licensees" and "recipients" may be individuals or organizations.
+
+To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a work "based on" the earlier work.
+
+A "covered work" means either the unmodified Program or a work based on the Program.
+
+To "propagate" a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well.
+
+To "convey" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying.
+
+An interactive user interface displays "Appropriate Legal Notices" to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion.
+
+1. Source Code.
+The "source code" for a work means the preferred form of the work for making modifications to it. "Object code" means any non-source form of a work.
+
+A "Standard Interface" means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language.
+
+The "System Libraries" of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A "Major Component", in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it.
+
+The "Corresponding Source" for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work.
+
+The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source.
+
+The Corresponding Source for a work in source code form is that same work.
+
+2. Basic Permissions.
+All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law.
+
+You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you.
+
+Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary.
+
+3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures.
+
+When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures.
+
+4. Conveying Verbatim Copies.
+You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program.
+
+You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee.
+
+5. Conveying Modified Source Versions.
+You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions:
+
+a) The work must carry prominent notices stating that you modified it, and giving a relevant date.
+b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to "keep intact all notices".
+c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it.
+d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so.
+A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an "aggregate" if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate.
+
+6. Conveying Non-Source Forms.
+You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways:
+
+a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange.
+b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge.
+c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b.
+d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements.
+e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d.
+A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work.
+
+A "User Product" is either (1) a "consumer product", which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, "normally used" refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product.
+
+"Installation Information" for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made.
+
+If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM).
+
+The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network.
+
+Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying.
+
+7. Additional Terms.
+"Additional permissions" are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions.
+
+When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission.
+
+Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms:
+
+a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or
+b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or
+c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or
+d) Limiting the use for publicity purposes of names of licensors or authors of the material; or
+e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or
+f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors.
+All other non-permissive additional terms are considered "further restrictions" within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying.
+
+If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms.
+
+Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way.
+
+8. Termination.
+You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11).
+
+However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation.
+
+Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice.
+
+Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10.
+
+9. Acceptance Not Required for Having Copies.
+You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so.
+
+10. Automatic Licensing of Downstream Recipients.
+Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License.
+
+An "entity transaction" is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts.
+
+You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it.
+
+11. Patents.
+A "contributor" is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's "contributor version".
+
+A contributor's "essential patent claims" are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, "control" includes the right to grant patent sublicenses in a manner consistent with the requirements of this License.
+
+Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version.
+
+In the following three paragraphs, a "patent license" is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To "grant" such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party.
+
+If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. "Knowingly relying" means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid.
+
+If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it.
+
+A patent license is "discriminatory" if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007.
+
+Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law.
+
+12. No Surrender of Others' Freedom.
+If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program.
+
+13. Remote Network Interaction; Use with the GNU General Public License.
+Notwithstanding any other provision of this License, if you modify the Program, your modified version must prominently offer all users interacting with it remotely through a computer network (if your version supports such interaction) an opportunity to receive the Corresponding Source of your version by providing access to the Corresponding Source from a network server at no charge, through some standard or customary means of facilitating copying of software. This Corresponding Source shall include the Corresponding Source for any work covered by version 3 of the GNU General Public License that is incorporated pursuant to the following paragraph.
+
+Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the work with which it is combined will remain governed by version 3 of the GNU General Public License.
+
+14. Revised Versions of this License.
+The Free Software Foundation may publish revised and/or new versions of the GNU Affero General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns.
+
+Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU Affero General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU Affero General Public License, you may choose any version ever published by the Free Software Foundation.
+
+If the Program specifies that a proxy can decide which future versions of the GNU Affero General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program.
+
+Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version.
+
+15. Disclaimer of Warranty.
+THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+16. Limitation of Liability.
+IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
+
+17. Interpretation of Sections 15 and 16.
+If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee.
+
+END OF TERMS AND CONDITIONS
+
+How to Apply These Terms to Your New Programs
+If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms.
+
+To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found.
+
+
+ Copyright (C)
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as
+ published by the Free Software Foundation, either version 3 of the
+ License, or (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see .
+
+=======================================================================
+PART 2: ORIGINAL MIT ATTRIBUTION (Pursuant to MIT License requirements)
+=======================================================================
+The original code upon which this project is based is used under the
+following terms:
+
+Copyright (c) 2025 Siddharth Vaddem
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
index 39872a264..121ef9343 100644
--- a/README.md
+++ b/README.md
@@ -1,63 +1,409 @@
-
-
-
-
-
-
-
-
-# OpenScreen
-
-Beautiful screen recordings with zoom animation. Simple, open-source alternative to Screen.studio.
-
-
-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!
-
-
-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 !)
-
-
-
-**⚠️ DISCLAIMER: This is very much in beta and might be buggy here and there (but hope you have a good experience!).**
-
-
-
-
-
-
-
-
-
-## Core Features
-- Record your whole screen or specific apps
-- Add manual zooms (customizable depth levels)
-- Customize the duration and position of zooms however you please
-- Crop video recordings to hide parts
-- Choose between wallpapers, solid colors, or gradients for your background
-- Motion blur and exponential easing for smoother pan and zoom effects
-
-**Note:**
-- OpenScreen is designed to support both Windows and macOS. We did not use any native APIs specific to one OS, so it should work on both. However, it hasn't been fully tested on Windows yet, if you run into issues, please let me know!
-- After you install the app, you'll need to grant it accessibility and screen recording permissions for it to work properly.
-
-**Contribution:**
-Planning to add a contribution guide soon so people can contribute to this repo. Stay tuned!
-
-## Getting Started
-Download the latest installer for your platform from the [GitHub Releases](https://github.com/siddharthvaddem/openscreen/releases) page.
-
-## Built with
-- Electron
-- React
-- TypeScript
-- Vite
-- PixiJS
-- dnd-timeline
-
----
-
-
-_I'm new to open source, idk what I'm doing lol. If something is wrong please raise an issue 🙏_
-
-## License
-MIT License: You can use, modify, and distribute this software for any purpose, including commercial use, without restriction. See the LICENSE file for details.
+Language: EN | [简中](README.zh-CN.md)
+
+
+
+
+
+
+
+
+
+
+### Create polished demo videos in minutes
+[Recordly](https://www.recordly.dev) is your **open-source screen recorder** and editor for **walkthroughs, demos, product videos**, and more.
+**Accepting PRs.**
+
+
+
+
+---
+### Backed by the community
+
+
+---
+
+## What is Recordly?
+
+Recordly is a desktop app for recording and editing screen captures with motion-driven presentation tools built in. Instead of sending raw footage to a motion designer just to add zooms, cursor polish, or a styled background, Recordly handles that workflow in one place for free.
+
+Recordly runs on:
+
+- **macOS** 14.0+
+- **Windows** 10 Build 19041+
+- **Linux** on modern distros
+
+Platform notes:
+
+- **macOS** uses native ScreenCaptureKit-based capture helpers.
+- **Windows** uses a native Windows Graphics Capture (WGC) helper on supported builds, with native WASAPI audio support.
+- **Linux** records through Electron capture APIs. Cursor hiding is not supported on Linux today.
+
+---
+
+# Core Features
+
+## Auto-zooms, cursor polish, and styled frames
+Recordly can automatically emphasize activity with zoom suggestions, smooth cursor movement, add motion effects, and place the final composition inside a styled frame with wallpapers, colors, gradients, blur, padding, and shadows.
+
+
+
+
+
+## Dynamic webcam bubble overlays
+Add webcam footage as an overlay bubble, position it with presets or custom coordinates, mirror it, control shadow and roundness, and optionally make it react to zoom so it stays visually balanced during motion.
+
+
+
+
+
+## Timeline editing built for demos
+Use drag-and-drop timeline tools for zooms, trims, speed regions, annotations, extra audio regions, and crop-aware edits. Save and reopen work as `.recordly` project files.
+
+
+
+
+
+## Extensions & Marketplace
+
+Recordly has a community-driven extension system. Anyone can build and publish extensions that add new capabilities to Recordly — cursor click sounds, device frames, browser mockups, wallpapers, render hooks, settings panels, and more.
+
+Browse and install community extensions from the [Recordly Marketplace](https://marketplace.recordly.dev/extensions).
+
+---
+
+## All Features
+
+### Recording
+
+- Record an entire display or a single app window
+- Jump directly from recording into the editor
+- Capture microphone audio and system audio
+- Use native capture backends where supported
+- Resume editing from saved `.recordly` project files
+- Open existing recordings or existing project files from the app
+
+### Timeline and Editing
+
+- Drag-and-drop timeline editing
+- Trim unwanted sections
+- Add manual zoom regions
+- Use automatic zoom suggestions based on cursor activity
+- Add speed-up and slow-down regions
+- Add text, image, and figure annotations
+- Add extra audio regions on the timeline
+- Crop the recorded frame
+- Save and reopen projects with editor state preserved
+
+### Cursor Controls
+
+- Show or hide the rendered cursor overlay
+- Cursor size adjustment
+- Cursor smoothing
+- Cursor motion blur
+- Cursor click bounce
+- Cursor sway
+- Cursor loop mode for cleaner looping exports
+- macOS-style cursor assets for the rendered overlay
+
+### Webcam Overlay
+
+- Enable or disable webcam overlay footage
+- Upload, replace, or remove webcam footage
+- Mirror webcam footage
+- Size control
+- Preset positions and custom X/Y placement
+- Margin control
+- Roundness control
+- Shadow control
+- Optional zoom-reactive webcam scaling
+
+### Frame Styling and Backgrounds
+
+- Built-in wallpapers
+- Runtime wallpaper discovery from the wallpapers directory
+- Custom uploaded backgrounds
+- Solid color backgrounds
+- Gradient backgrounds
+- Frame padding
+- Rounded corners
+- Background blur
+- Drop shadows
+- Aspect ratio presets for the final frame
+
+### Export
+
+- MP4 export
+- GIF export
+- Export quality selection
+- GIF frame-rate selection
+- GIF loop toggle
+- GIF size presets
+- Aspect ratio and output dimension controls
+- Reveal exported files in the system file manager
+
+### Workflow and Usability
+
+- Customizable keyboard shortcuts
+- In-app shortcut reference
+- Feedback and issue links from the editor
+- Project persistence for editor preferences
+- Faster preview recovery after export
+---
+
+# Screenshots
+
+
+
+
+
+
+
+
+
+
+
+
+
+---
+
+# Installation
+
+## Download a build
+
+Prebuilt releases are available at:
+
+https://github.com/webadderallorg/Recordly/releases
+
+---
+
+## Arch Linux / Manjaro (yay)
+
+Install from the AUR ([recordly-bin](https://aur.archlinux.org/packages/recordly-bin)):
+
+```bash
+yay -S recordly-bin
+```
+
+PKGBUILD, desktop entry, release sync, and optional **local-from-source** packaging live in **[recordly-aur](https://github.com/firtoz/recordly-aur)** so this repository stays free of Arch release chores. For maintainer contact and how the package is updated, see that repo or the AUR package page.
+
+---
+
+## Build from source
+
+### Prerequisites
+
+**macOS:** Xcode Command Line Tools (`xcode-select --install`).
+
+**Linux (Ubuntu/Debian):**
+
+```bash
+sudo apt install build-essential cmake libx11-dev libxtst-dev libxrandr-dev libxt-dev
+```
+
+**Windows:** Visual Studio 2022 (or Build Tools) with the C++ workload and CMake.
+
+### Steps
+
+```bash
+git clone https://github.com/webadderallorg/Recordly.git recordly
+cd recordly
+npm install
+npm run dev
+```
+
+For packaged builds:
+
+```bash
+npm run build
+```
+
+Target-specific build commands are also available:
+
+- `npm run build:mac`
+- `npm run build:win`
+- `npm run build:linux`
+
+---
+
+## macOS: "App cannot be opened"
+
+Locally built apps may be quarantined by macOS.
+
+Remove the quarantine flag with:
+
+```bash
+xattr -rd com.apple.quarantine /Applications/Recordly.app
+```
+
+---
+
+# System Requirements
+
+| Platform | Minimum version | Notes |
+|---|---|---|
+| **macOS** | macOS 14.0 (Sonoma) | Required for ScreenCaptureKit audio and microphone capture. |
+| **Windows** | Windows 10 20H1 (Build 19041, May 2020) | Required for the native Windows Graphics Capture (WGC) helper and best cursor-hiding behavior. |
+| **Linux** | Any modern distro | Recording works through Electron capture. System audio generally requires PipeWire. |
+
+> [!IMPORTANT]
+> On Windows builds older than 19041, recording can still work through fallback capture, but the real OS cursor may remain visible in recordings.
+
+---
+
+# Usage
+
+## Record
+
+1. Launch Recordly.
+2. Select a screen or window.
+3. Choose microphone and system-audio options.
+4. Start recording.
+5. Stop recording to open the editor.
+
+## Edit
+
+Inside the editor you can:
+
+- add trims, zooms, speed regions, and annotations
+- tune cursor behavior and preview volume
+- style the frame with wallpapers, colors, gradients, blur, padding, and corners
+- add or adjust webcam overlay footage
+- add extra audio regions
+- crop the frame and choose an aspect ratio
+
+Save your work anytime as a `.recordly` project.
+
+## Export
+
+Export options include:
+
+- **MP4** for standard video output
+- **GIF** for lightweight sharing and loops
+
+You can adjust format-specific settings such as quality, GIF frame rate, GIF looping, and output size before export.
+
+---
+
+# Limitations
+
+### Cursor capture
+
+Recordly renders a polished cursor overlay on top of the recording. Platform cursor-hiding behavior still depends on OS support.
+
+**macOS**
+- ScreenCaptureKit can exclude the real cursor cleanly.
+
+**Windows**
+- Best results require Windows 10 Build 19041+ and the native capture helper.
+- Older builds fall back to Electron capture, so the real cursor may remain visible.
+
+**Linux**
+- Electron desktop capture does not currently support cursor hiding.
+- If you also enable the rendered cursor overlay, exports may show both the real cursor and the styled cursor.
+
+### System audio
+
+System audio support varies by platform.
+
+**Windows**
+- Native WASAPI support
+
+**Linux**
+- Usually requires PipeWire
+
+**macOS**
+- Requires macOS 14.0+ and the ScreenCaptureKit-based workflow
+
+---
+
+# How It Works
+
+Recordly combines a platform-specific capture layer with a renderer-driven editor and export pipeline.
+
+**Capture**
+- Electron coordinates recording and application flow
+- macOS uses native ScreenCaptureKit helpers
+- Windows uses a native Windows Graphics Capture (WGC) helper and native audio helpers where available
+
+**Editing**
+- Timeline regions define zooms, trims, speed changes, audio overlays, and annotations
+- Cursor and webcam styling are applied in the editor state
+
+**Rendering**
+- Scene composition is handled by **PixiJS**
+
+**Export**
+- The same scene logic used in preview is rendered into exported MP4 or GIF output
+
+**Projects**
+- `.recordly` files store the source media path plus editor state so work can be reopened later
+
+---
+
+# Contribution
+
+Contributions are welcome.
+
+Areas where help is especially useful:
+
+- Linux capture and cursor behavior
+- Export performance and stability
+- UI and UX refinement
+- Localisation work
+- Additional editor tools and workflow polish
+
+Please keep pull requests focused, test recording/edit/export flows, and avoid unrelated refactors.
+
+See `CONTRIBUTING.md` for guidelines.
+
+---
+
+# Community
+
+Bug reports and feature requests:
+
+https://github.com/webadderallorg/Recordly/issues
+
+Pull requests are welcome.
+
+---
+
+# Hall of Supporters
+
+[](https://ko-fi.com/webadderall)
+
+- Tom Egan @tomegan on X
+- Robin Ebers @robinebers on X
+- Tadees
+- buildwithfur
+- piccinato
+- Tobias
+- Anonymous Supporter
+- Tandava Appadoo
+- Digitalfastmind
+- Roberto Marcelino
+- Tony
+- Rajan RK
+- Francesco
+- Erwan
+- Anonymous supporter
+
+---
+
+# License
+
+Recordly is licensed under the **AGPL 3.0**.
+
+---
+
+# Credits
+
+## Acknowledgements
+
+Recordly originally started as a fork of [OpenScreen](https://github.com/siddharthvaddem/openscreen). Over 80% of code has diverged since.
+Many features of OpenScreen such as its zoom animations are directly ported from early versions of Recordly.
+
+Created by
+[@webadderall](https://x.com/webadderall)
+
+---
diff --git a/README.zh-CN.md b/README.zh-CN.md
new file mode 100644
index 000000000..b1ca9f556
--- /dev/null
+++ b/README.zh-CN.md
@@ -0,0 +1,400 @@
+语言: [EN](README.md) | 简中
+
+
+
+
+
+
+
+
+
+
+### 无需额外剪辑,也能做出精致的屏幕录制。
+[Recordly](https://www.recordly.dev) 是一款**开源屏幕录制器**和编辑器,适合制作**操作讲解、演示、产品视频**等内容。
+**欢迎提交 PR。** [赞助](https://ko-fi.com/webadderall/goal?g=0)
+
+https://github.com/user-attachments/assets/9b66c71d-ac97-49ff-a0c9-63ac26edf2e4
+
+---
+
+## Recordly 是什么?
+
+Recordly 是一款桌面应用,用于录制并编辑屏幕内容,内置面向演示视频的动态呈现工具。你不需要再把原始素材交给动效设计师去补缩放、光标润色或样式化背景,Recordly 可以在一个地方免费完成整套流程。
+
+Recordly 支持:
+
+- **macOS** 14.0+
+- **Windows** 10 Build 19041+
+- **Linux** 现代发行版
+
+平台说明:
+
+- **macOS** 使用基于 ScreenCaptureKit 的原生捕获辅助程序。
+- **Windows** 在支持的系统版本上使用原生 Windows Graphics Capture(WGC)辅助程序,并支持原生 WASAPI 音频。
+- **Linux** 通过 Electron 捕获 API 录制。目前 Linux 还不支持隐藏真实光标。
+
+---
+
+# 核心功能
+
+## 自动缩放、光标润色与样式化画面
+Recordly 可以根据操作自动强调重点区域,平滑光标运动,添加动态效果,并将最终画面放进带有壁纸、纯色、渐变、模糊、留白和阴影的样式化边框中。
+
+
+
+
+
+## 动态摄像头气泡叠加
+你可以把摄像头素材作为气泡叠加层加入画面,使用预设位置或自定义坐标摆放,支持镜像、阴影和圆角调节,也可以让它跟随缩放变化,保证动态镜头里整体视觉更协调。
+
+
+
+
+
+## 为演示设计的时间线编辑
+使用拖拽式时间线工具处理缩放、裁剪、变速区域、注释、额外音频区域以及裁切感知编辑,并将工作保存为 `.recordly` 项目文件,之后随时回来继续编辑。
+
+
+
+
+
+## 扩展与市场
+
+Recordly 拥有一个社区驱动的扩展系统。任何人都可以构建和发布扩展来为 Recordly 添加新功能,例如光标点击音效、设备边框、浏览器模拟外壳、壁纸、渲染钩子、设置面板等等。
+
+浏览并安装社区扩展:[Recordly 扩展市场](https://marketplace.recordly.dev/extensions)。
+
+---
+
+## 全部功能
+
+### 录制
+
+- 录制整个显示器或单个应用窗口
+- 录制完成后直接进入编辑器
+- 录制麦克风音频和系统音频
+- 在支持的平台上使用原生捕获后端
+- 从保存的 `.recordly` 项目文件继续编辑
+- 可在应用中打开已有录像或已有项目文件
+
+### 时间线与编辑
+
+- 拖拽式时间线编辑
+- 裁掉不需要的片段
+- 添加手动缩放区域
+- 根据光标活动生成自动缩放建议
+- 添加加速和减速区域
+- 添加文本、图片和图形注释
+- 在时间线上添加额外音频区域
+- 裁切录制画面
+- 保存并重新打开项目,保留编辑状态
+
+### 光标控制
+
+- 显示或隐藏渲染后的光标叠加层
+- 调整光标大小
+- 光标平滑
+- 光标运动模糊
+- 点击弹跳效果
+- 光标摆动效果
+- 光标循环模式,方便导出更自然的循环片段
+- 使用 macOS 风格的渲染光标素材
+
+### 摄像头叠加
+
+- 启用或禁用摄像头叠加素材
+- 上传、替换或移除摄像头素材
+- 镜像摄像头画面
+- 调整尺寸
+- 使用预设位置或自定义 X/Y 坐标
+- 调整边距
+- 调整圆角程度
+- 调整阴影强度
+- 可选的缩放联动摄像头缩放效果
+
+### 画面样式与背景
+
+- 内置壁纸
+- 运行时自动发现 wallpapers 目录中的壁纸
+- 上传自定义背景图片
+- 纯色背景
+- 渐变背景
+- 画面留白
+- 圆角
+- 背景模糊
+- 投影阴影
+- 最终画面的宽高比预设
+
+### 导出
+
+- MP4 导出
+- GIF 导出
+- 导出质量选择
+- GIF 帧率选择
+- GIF 循环开关
+- GIF 尺寸预设
+- 宽高比和输出尺寸控制
+- 在系统文件管理器中定位导出文件
+
+### 工作流与易用性
+
+- 可自定义键盘快捷键
+- 应用内快捷键说明
+- 在编辑器中直接打开反馈和问题链接
+- 编辑器偏好设置持久化
+- 导出后更快恢复预览
+
+---
+
+# 截图
+
+
+
+
+
+
+
+
+
+
+
+
+
+---
+
+# 安装
+
+## 下载构建版本
+
+预构建发布版本请见:
+
+https://github.com/webadderallorg/Recordly/releases
+
+---
+
+## Arch Linux / Manjaro(yay)
+
+可通过 AUR 安装([recordly-bin](https://aur.archlinux.org/packages/recordly-bin)):
+
+```bash
+yay -S recordly-bin
+```
+
+PKGBUILD、桌面入口、发布同步,以及可选的**本地源码打包**都维护在 **[recordly-aur](https://github.com/firtoz/recordly-aur)** 中,因此这个仓库本身不需要承担 Arch 发布维护工作。关于维护者联系方式和软件包更新方式,请查看该仓库或 AUR 软件包页面。
+
+---
+
+## 从源码构建
+
+### 前置依赖
+
+**macOS:** 安装 Xcode Command Line Tools(`xcode-select --install`)。
+
+**Linux(Ubuntu / Debian):**
+
+```bash
+sudo apt install build-essential cmake libx11-dev libxtst-dev libxrandr-dev libxt-dev
+```
+
+**Windows:** 安装 Visual Studio 2022(或 Build Tools),并勾选 C++ 工作负载和 CMake。
+
+### 步骤
+
+```bash
+git clone https://github.com/webadderallorg/Recordly.git recordly
+cd recordly
+npm install
+npm run dev
+```
+
+如果需要打包构建:
+
+```bash
+npm run build
+```
+
+也可以使用平台专用构建命令:
+
+- `npm run build:mac`
+- `npm run build:win`
+- `npm run build:linux`
+
+---
+
+## macOS:“App cannot be opened”
+
+本地构建的应用可能会被 macOS 隔离。
+
+可以用以下命令移除隔离标记:
+
+```bash
+xattr -rd com.apple.quarantine /Applications/Recordly.app
+```
+
+---
+
+# 系统要求
+
+| 平台 | 最低版本 | 说明 |
+|---|---|---|
+| **macOS** | macOS 14.0 (Sonoma) | 使用 ScreenCaptureKit 捕获音频和麦克风所必需。 |
+| **Windows** | Windows 10 20H1(Build 19041,2020 年 5 月) | 原生 Windows Graphics Capture(WGC)辅助程序及最佳光标隐藏行为所必需。 |
+| **Linux** | 任意现代发行版 | 通过 Electron 捕获录制。系统音频通常需要 PipeWire。 |
+
+> [!IMPORTANT]
+> 在 Windows 19041 之前的版本上,录制仍可能通过回退捕获方式工作,但真实系统光标可能仍会出现在视频中。
+
+---
+
+# 使用方法
+
+## 录制
+
+1. 启动 Recordly。
+2. 选择屏幕或窗口。
+3. 选择麦克风和系统音频选项。
+4. 开始录制。
+5. 停止录制后进入编辑器。
+
+## 编辑
+
+在编辑器中,你可以:
+
+- 添加裁剪、缩放、变速区域和注释
+- 调整光标行为和预览音量
+- 使用壁纸、纯色、渐变、模糊、留白和圆角来美化画面
+- 添加或调整摄像头叠加素材
+- 添加额外音频区域
+- 裁切画面并选择宽高比
+
+你可以随时将工作保存为 `.recordly` 项目。
+
+## 导出
+
+支持以下导出格式:
+
+- **MP4**,适合常规视频输出
+- **GIF**,适合轻量分享和循环片段
+
+你可以在导出前调整格式相关设置,例如质量、GIF 帧率、GIF 循环方式和输出尺寸。
+
+---
+
+# 限制
+
+### 光标捕获
+
+Recordly 会在录制画面上渲染一个经过美化的光标叠加层,但真实系统光标是否能被隐藏仍取决于平台能力。
+
+**macOS**
+- ScreenCaptureKit 可以较干净地排除真实光标。
+
+**Windows**
+- 最佳效果需要 Windows 10 Build 19041+ 和原生捕获辅助程序。
+- 较旧版本会回退到 Electron 捕获,因此真实光标可能仍会显示。
+
+**Linux**
+- Electron 桌面捕获目前不支持隐藏真实光标。
+- 如果同时启用渲染光标叠加,导出中可能会同时看到真实光标和样式化光标。
+
+### 系统音频
+
+系统音频支持因平台而异。
+
+**Windows**
+- 原生 WASAPI 支持
+
+**Linux**
+- 通常需要 PipeWire
+
+**macOS**
+- 需要 macOS 14.0+ 和基于 ScreenCaptureKit 的工作流
+
+---
+
+# 工作原理
+
+Recordly 将平台相关的捕获层与基于渲染器的编辑、导出流程结合在一起。
+
+**捕获**
+- Electron 负责录制流程和应用级控制
+- macOS 使用原生 ScreenCaptureKit 辅助程序
+- Windows 在可用时使用原生 Windows Graphics Capture(WGC)辅助程序和原生音频辅助程序
+
+**编辑**
+- 时间线区域定义缩放、裁剪、变速、音频叠加和注释
+- 光标和摄像头样式都保存在编辑器状态中
+
+**渲染**
+- 场景合成由 **PixiJS** 负责
+
+**导出**
+- 预览使用的同一套场景逻辑会被用于导出 MP4 或 GIF
+
+**项目**
+- `.recordly` 文件会保存源媒体路径和编辑器状态,方便后续继续编辑
+
+---
+
+# 贡献
+
+欢迎贡献。
+
+特别需要帮助的方向包括:
+
+- Linux 录制与光标行为改进
+- 导出性能与稳定性优化
+- UI 和 UX 打磨
+- 本地化工作
+- 更多编辑工具与工作流优化
+
+请尽量让 Pull Request 保持聚焦,测试录制、编辑、导出流程,并避免无关重构。
+
+请参阅 `CONTRIBUTING.md` 了解具体指南。
+
+---
+
+# 社区
+
+问题反馈和功能建议:
+
+https://github.com/webadderallorg/Recordly/issues
+
+欢迎提交 Pull Request。
+
+---
+
+# 支持者名单
+
+[](https://ko-fi.com/webadderall)
+
+- Tadees
+- buildwithfur
+- Tobias
+- Anonymous Supporter
+- Tandava Appadoo
+- Digitalfastmind
+- Roberto Marcelino
+- Rajan RK
+- Francesco
+- Erwan
+- Anonymous supporter
+
+---
+
+# 许可证
+
+Recordly 基于 **AGPL 3.0** 发布。
+
+---
+
+# 致谢
+
+## 鸣谢
+
+Recordly 最初是从 [OpenScreen](https://github.com/siddharthvaddem/openscreen) 分叉而来,之后已逐步演变为一个不同的项目。
+
+创建者
+[@webadderall](https://x.com/webadderall)
+
+---
diff --git a/RELEASING.md b/RELEASING.md
new file mode 100644
index 000000000..4ec8e22ad
--- /dev/null
+++ b/RELEASING.md
@@ -0,0 +1,103 @@
+# Releasing Recordly
+
+This repository now uses `electron-builder` + `electron-updater` for macOS, Windows, and Linux auto-updates.
+
+For this Electron app, that is the right path instead of wiring Sparkle.framework directly. On macOS, `electron-updater` handles the release metadata and update flow that Sparkle would otherwise cover in a native app, while still sharing the same GitHub Releases pipeline used by Windows and Linux.
+
+## What the release workflow does
+
+When you publish a GitHub release tagged like `v1.2.3`, `.github/workflows/release.yml` will:
+
+- validate that `package.json` is also `1.2.3`
+- build signed macOS x64 and arm64 artifacts
+- notarize the macOS builds
+- merge the dual-architecture `latest-mac.yml` metadata into one release asset
+- build and sign the Windows NSIS installer
+- build the Linux AppImage
+- publish installer artifacts and auto-update metadata files to the GitHub release
+- dispatch the Homebrew tap workflow after the release assets are available
+
+The packaged app then checks GitHub Releases for:
+
+- `latest-mac.yml`
+- `latest.yml`
+- `latest-linux.yml`
+
+## Required GitHub secrets
+
+### macOS signing and notarization
+
+Set these repository secrets:
+
+- `APPLE_SIGNING_CERTIFICATE_P12_BASE64`
+- `APPLE_SIGNING_CERTIFICATE_PASSWORD`
+- `APPLE_ID`
+- `APPLE_APP_SPECIFIC_PASSWORD`
+- `APPLE_TEAM_ID`
+
+`APPLE_SIGNING_CERTIFICATE_P12_BASE64` must be a base64-encoded `.p12` export of a `Developer ID Application` certificate.
+
+If the certificate you currently have is only `Apple Development`, that is not enough for public notarized releases and auto-update distribution. You need `Developer ID Application`.
+
+To export and encode the certificate:
+
+```bash
+security export -k ~/Library/Keychains/login.keychain-db -t identities -f pkcs12 -P "YOUR_P12_PASSWORD" -o recordly-mac-signing.p12
+base64 < recordly-mac-signing.p12 | pbcopy
+```
+
+Paste the copied base64 into `APPLE_SIGNING_CERTIFICATE_P12_BASE64` and the export password into `APPLE_SIGNING_CERTIFICATE_PASSWORD`.
+
+### Windows signing
+
+Set these repository secrets:
+
+- `WINDOWS_SIGNING_CERTIFICATE_P12_BASE64`
+- `WINDOWS_SIGNING_CERTIFICATE_PASSWORD`
+
+These should point to an Authenticode code-signing certificate exported as `.p12` and then base64-encoded.
+
+### Homebrew tap automation
+
+Set this repository secret if you want the cask PR to open automatically:
+
+- `HOMEBREW_TAP_TOKEN`
+
+Optional repository variables:
+
+- `HOMEBREW_TAP_REPO`
+- `HOMEBREW_TAP_AUTO_MERGE`
+
+## Release flow
+
+1. Bump `package.json` to the version you want to ship.
+2. Commit and push that version.
+3. Create a Git tag in the form `vX.Y.Z`.
+4. Create and publish a GitHub release for that tag. Prefer the helper so custom notes are prepended while GitHub still generates the contributor section:
+
+```bash
+npm run release:create -- --tag v1.2.3 --title "v1.2.3" --notes-file ./release-notes.md
+```
+
+For prereleases:
+
+```bash
+npm run release:create -- --tag v1.2.0-beta.2 --title "v1.2.0 beta-2" --prerelease --notes-file ./release-notes.md
+```
+
+This uses `gh release create --generate-notes`, which keeps GitHub's generated change summary and contributor list instead of replacing it with a fully manual release body.
+
+5. The `Publish Release` workflow builds, signs, notarizes, uploads, and publishes update metadata.
+
+That is the normal path if you want “click new release and let CI do the rest.”
+
+## Rebuilding an existing release
+
+If you need to rerun publishing for an existing tag, use the manual dispatch for `.github/workflows/release.yml` and provide the existing tag.
+
+## Notes
+
+- macOS auto-updates require the `zip` target in addition to `dmg`, because `latest-mac.yml` is generated from the zipped build.
+- macOS arm64 and x64 builds both publish updater zips, and the release workflow merges them into one `latest-mac.yml` so `electron-updater` can choose the correct architecture automatically.
+- The release workflow publishes versioned artifact names so the generated update metadata matches the uploaded files.
+- `build.yml` is intentionally forced to `--publish never` so ad hoc CI builds do not accidentally upload to a draft release.
diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md
new file mode 100644
index 000000000..6d1558837
--- /dev/null
+++ b/THIRD_PARTY_NOTICES.md
@@ -0,0 +1,49 @@
+# Third-party notices
+
+## Voom
+
+Recordly Share includes code adapted from the Voom project:
+
+- Project: [Voom](https://github.com/aritropaul/voom)
+- Adapted component: `services/recordly-share/worker`
+- License: MIT
+- Copyright © 2026 Aritro Paul
+- Upstream reference: `c1c0350ed610b0c9bc4a307fcc6a01aab0f926f9`
+
+Recordly changes include product branding, desktop publishing integration,
+authenticated commenting, local-development configuration, and viewer styling.
+
+The Voom software is provided under the following license:
+
+> MIT License
+>
+> Copyright (c) 2026 Aritro Paul
+>
+> Permission is hereby granted, free of charge, to any person obtaining a copy
+> of this software and associated documentation files (the "Software"), to deal
+> in the Software without restriction, including without limitation the rights
+> to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+> copies of the Software, and to permit persons to whom the Software is
+> furnished to do so, subject to the following conditions:
+>
+> The above copyright notice and this permission notice shall be included in all
+> copies or substantial portions of the Software.
+>
+> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+> IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+> FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+> AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+> LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+> OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+> SOFTWARE.
+
+## Solar Icons
+
+Solar Icon Set by 480 Design, licensed under [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/).
+Source: https://github.com/480-Design/Solar-Icon-Set. Icons are rendered using the MIT-licensed `@solar-icons/react` package. The muted microphone adds a diagonal stroke.
+
+## DM Sans
+
+Copyright 2014 The DM Sans Project Authors (https://github.com/googlefonts/dm-fonts).
+
+DM Sans is licensed under the SIL Open Font License, Version 1.1. The unmodified regular and italic variable fonts are sourced from [Google Fonts](https://github.com/google/fonts/tree/main/ofl/dmsans). The full license is included in [src/assets/fonts/dm-sans/OFL.txt](src/assets/fonts/dm-sans/OFL.txt).
diff --git a/TRANSLATION_GUIDE.md b/TRANSLATION_GUIDE.md
new file mode 100644
index 000000000..b0b599d72
--- /dev/null
+++ b/TRANSLATION_GUIDE.md
@@ -0,0 +1,65 @@
+# Translation Guide
+
+This project uses a namespace-based i18n setup so contributors can localize safely without changing app logic.
+
+## Locale Files
+
+All locale files live under:
+
+- `src/i18n/locales/en/`
+- `src/i18n/locales/es/`
+- `src/i18n/locales/zh-CN/`
+
+Each locale has the same namespace files:
+
+- `common.json`
+- `launch.json`
+- `editor.json`
+- `timeline.json`
+- `settings.json`
+- `dialogs.json`
+- `shortcuts.json`
+
+English (`en`) is the source of truth for key structure.
+
+## Key Rules
+
+- Keep the same key paths across locales.
+- Do not rename existing keys unless coordinated with code changes.
+- Add new keys to `en` first, then mirror into all other locales.
+- Prefer descriptive, stable keys. Example: `app.editorTitle`.
+- Interpolation is supported with `{{name}}` style placeholders.
+
+## How Translation Is Read
+
+- Keys with a namespace prefix like `settings.export.title` use that namespace.
+- Keys without a namespace default to `common`.
+- Missing translations fall back to English, then to the provided fallback string, then to the key.
+
+## Validate Locale Structure
+
+Run:
+
+```bash
+npm run i18n:check
+```
+
+This checks for:
+
+- Missing namespace files
+- Missing keys compared to `en`
+- Extra keys not present in `en`
+
+## Contributor Workflow
+
+1. Pull latest `main`.
+2. Update `en/.json` with new keys if needed.
+3. Add matching keys to other locale files.
+4. Run `npm run i18n:check`.
+5. Run app locally (`npm run dev`) and spot-check UI text.
+6. Open PR with a short summary of changed namespaces.
+
+## Scope Notes
+
+Current framework is app-wide and ready for full localization rollout.
+Not every UI string is migrated yet. Migration should be done incrementally by namespace to keep PRs reviewable and low-risk.
diff --git a/announcements.json b/announcements.json
new file mode 100644
index 000000000..f7e39713e
--- /dev/null
+++ b/announcements.json
@@ -0,0 +1,6 @@
+{
+ "settings": {
+ "aspectRatio": "4:3"
+ },
+ "announcements": []
+}
diff --git a/biome.json b/biome.json
new file mode 100644
index 000000000..5ae2a194e
--- /dev/null
+++ b/biome.json
@@ -0,0 +1,194 @@
+{
+ "$schema": "https://biomejs.dev/schemas/2.3.13/schema.json",
+ "vcs": { "enabled": true, "clientKind": "git", "useIgnoreFile": true },
+ "files": {
+ "ignoreUnknown": false,
+ "includes": [
+ "**",
+ "!!**/dist",
+ "!!**/dist-electron",
+ "!!**/dist-ssr",
+ "!!**/release",
+ "!!**/.tmp",
+ "!!**/electron/native/**/build"
+ ]
+ },
+ "formatter": {
+ "enabled": true,
+ "indentStyle": "tab",
+ "formatWithErrors": true,
+ "lineEnding": "lf",
+ "lineWidth": 100,
+ "attributePosition": "auto",
+ "indentWidth": 4
+ },
+ "linter": {
+ "enabled": true,
+ "rules": {
+ "recommended": false,
+ "complexity": {
+ "noAdjacentSpacesInRegex": "error",
+ "noBannedTypes": "error",
+ "noExtraBooleanCast": "error",
+ "noUselessCatch": "error",
+ "noUselessEscapeInRegex": "error",
+ "noUselessThisAlias": "error",
+ "noUselessTypeConstraint": "error"
+ },
+ "correctness": {
+ "noConstAssign": "error",
+ "noConstantCondition": "error",
+ "noEmptyCharacterClassInRegex": "error",
+ "noEmptyPattern": "error",
+ "noGlobalObjectCalls": "error",
+ "noInnerDeclarations": "error",
+ "noInvalidConstructorSuper": "error",
+ "noNonoctalDecimalEscape": "error",
+ "noPrecisionLoss": "error",
+ "noSelfAssign": "error",
+ "noSetterReturn": "error",
+ "noSwitchDeclarations": "error",
+ "noUndeclaredVariables": "error",
+ "noUnreachable": "error",
+ "noUnreachableSuper": "error",
+ "noUnsafeFinally": "error",
+ "noUnsafeOptionalChaining": "error",
+ "noUnusedLabels": "error",
+ "noUnusedVariables": "error",
+ "useExhaustiveDependencies": "warn",
+ "useHookAtTopLevel": "error",
+ "useIsNan": "error",
+ "useValidForDirection": "error",
+ "useValidTypeof": "error",
+ "useYield": "error"
+ },
+ "style": {
+ "noNamespace": "error",
+ "useArrayLiterals": "error",
+ "useAsConstAssertion": "error",
+ "useComponentExportOnlyModules": "off"
+ },
+ "suspicious": {
+ "noAssignInExpressions": "error",
+ "noAsyncPromiseExecutor": "error",
+ "noCatchAssign": "error",
+ "noClassAssign": "error",
+ "noCompareNegZero": "error",
+ "noControlCharactersInRegex": "error",
+ "noDebugger": "error",
+ "noDuplicateCase": "error",
+ "noDuplicateClassMembers": "error",
+ "noDuplicateElseIf": "error",
+ "noDuplicateObjectKeys": "error",
+ "noDuplicateParameters": "error",
+ "noEmptyBlockStatements": "error",
+ "noExplicitAny": "error",
+ "noExtraNonNullAssertion": "error",
+ "noFallthroughSwitchClause": "error",
+ "noFunctionAssign": "error",
+ "noGlobalAssign": "error",
+ "noImportAssign": "error",
+ "noIrregularWhitespace": "error",
+ "noMisleadingCharacterClass": "error",
+ "noMisleadingInstantiator": "error",
+ "noNonNullAssertedOptionalChain": "error",
+ "noPrototypeBuiltins": "error",
+ "noRedeclare": "error",
+ "noShadowRestrictedNames": "error",
+ "noSparseArray": "error",
+ "noTsIgnore": "error",
+ "noUnsafeDeclarationMerging": "error",
+ "noUnsafeNegation": "error",
+ "noUselessRegexBackrefs": "error",
+ "noWith": "error",
+ "useGetterReturn": "error"
+ }
+ }
+ },
+ "javascript": { "formatter": { "quoteStyle": "double" } },
+ "css": { "parser": { "cssModules": true, "tailwindDirectives": true } },
+ "overrides": [
+ {
+ "includes": ["services/supabase/functions/**/*.ts"],
+ "javascript": { "globals": ["Deno"] }
+ },
+ {
+ "includes": ["*.ts", "*.tsx", "*.mts", "*.cts"],
+ "linter": {
+ "rules": {
+ "complexity": { "noArguments": "error" },
+ "correctness": {
+ "noConstAssign": "off",
+ "noGlobalObjectCalls": "off",
+ "noInvalidBuiltinInstantiation": "off",
+ "noInvalidConstructorSuper": "off",
+ "noSetterReturn": "off",
+ "noUndeclaredVariables": "off",
+ "noUnreachable": "off",
+ "noUnreachableSuper": "off"
+ },
+ "style": { "useConst": "error" },
+ "suspicious": {
+ "noDuplicateClassMembers": "off",
+ "noDuplicateObjectKeys": "off",
+ "noDuplicateParameters": "off",
+ "noFunctionAssign": "off",
+ "noImportAssign": "off",
+ "noRedeclare": "off",
+ "noUnsafeNegation": "off",
+ "noVar": "error",
+ "useGetterReturn": "off"
+ }
+ }
+ }
+ },
+ {
+ "includes": ["electron/electron-env.d.ts"],
+ "linter": {
+ "rules": {
+ "style": {
+ "noNamespace": "off"
+ },
+ "suspicious": {
+ "noExplicitAny": "off"
+ }
+ }
+ }
+ },
+ {
+ "includes": ["**/*.test.ts", "**/*.test.tsx"],
+ "linter": {
+ "rules": {
+ "correctness": {
+ "noEmptyPattern": "off"
+ },
+ "suspicious": {
+ "noEmptyBlockStatements": "off"
+ }
+ }
+ }
+ },
+ {
+ "includes": [
+ "src/components/video-editor/audio.test.ts",
+ "src/lib/exporter/frameRenderer.test.ts",
+ "src/lib/exporter/localMediaSource.test.ts",
+ "src/lib/exporter/modernFrameRenderer.test.ts"
+ ],
+ "linter": {
+ "rules": {
+ "correctness": {
+ "noUnusedVariables": "off"
+ },
+ "suspicious": {
+ "noExplicitAny": "off"
+ }
+ }
+ }
+ }
+ ],
+ "assist": {
+ "enabled": true,
+ "actions": { "source": { "organizeImports": "on" } }
+ }
+}
diff --git a/branding/source-assets/1024-mac.png b/branding/source-assets/1024-mac.png
new file mode 100644
index 000000000..7602ca8eb
Binary files /dev/null and b/branding/source-assets/1024-mac.png differ
diff --git a/branding/source-assets/128-mac.png b/branding/source-assets/128-mac.png
new file mode 100644
index 000000000..94ad18d1b
Binary files /dev/null and b/branding/source-assets/128-mac.png differ
diff --git a/branding/source-assets/16-mac.png b/branding/source-assets/16-mac.png
new file mode 100644
index 000000000..90118bdf0
Binary files /dev/null and b/branding/source-assets/16-mac.png differ
diff --git a/branding/source-assets/256-mac.png b/branding/source-assets/256-mac.png
new file mode 100644
index 000000000..fac38ff6e
Binary files /dev/null and b/branding/source-assets/256-mac.png differ
diff --git a/branding/source-assets/32-mac.png b/branding/source-assets/32-mac.png
new file mode 100644
index 000000000..2e21b5e13
Binary files /dev/null and b/branding/source-assets/32-mac.png differ
diff --git a/branding/source-assets/512-mac.png b/branding/source-assets/512-mac.png
new file mode 100644
index 000000000..3a0647e1a
Binary files /dev/null and b/branding/source-assets/512-mac.png differ
diff --git a/branding/source-assets/64-mac.png b/branding/source-assets/64-mac.png
new file mode 100644
index 000000000..15ec012b2
Binary files /dev/null and b/branding/source-assets/64-mac.png differ
diff --git a/branding/source-assets/Recordly.svg b/branding/source-assets/Recordly.svg
new file mode 100644
index 000000000..a936f4979
--- /dev/null
+++ b/branding/source-assets/Recordly.svg
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/branding/source-assets/recordlygeneric.svg b/branding/source-assets/recordlygeneric.svg
new file mode 100644
index 000000000..57910c64a
--- /dev/null
+++ b/branding/source-assets/recordlygeneric.svg
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/branding/source-assets/recordlymac.png b/branding/source-assets/recordlymac.png
new file mode 100644
index 000000000..6e4baf246
Binary files /dev/null and b/branding/source-assets/recordlymac.png differ
diff --git a/build/entitlements.mac.inherit.plist b/build/entitlements.mac.inherit.plist
new file mode 100644
index 000000000..a0414705f
--- /dev/null
+++ b/build/entitlements.mac.inherit.plist
@@ -0,0 +1,18 @@
+
+
+
+
+ com.apple.security.cs.allow-jit
+
+ com.apple.security.cs.allow-unsigned-executable-memory
+
+ com.apple.security.cs.disable-library-validation
+
+ com.apple.security.device.camera
+
+ com.apple.security.device.audio-input
+
+ com.apple.security.inherit
+
+
+
\ No newline at end of file
diff --git a/build/entitlements.mac.plist b/build/entitlements.mac.plist
new file mode 100644
index 000000000..98652b7fb
--- /dev/null
+++ b/build/entitlements.mac.plist
@@ -0,0 +1,16 @@
+
+
+
+
+ com.apple.security.cs.allow-jit
+
+ com.apple.security.cs.allow-unsigned-executable-memory
+
+ com.apple.security.cs.disable-library-validation
+
+ com.apple.security.device.camera
+
+ com.apple.security.device.audio-input
+
+
+
\ No newline at end of file
diff --git a/components.json b/components.json
deleted file mode 100644
index b4f439552..000000000
--- a/components.json
+++ /dev/null
@@ -1,22 +0,0 @@
-{
- "$schema": "https://ui.shadcn.com/schema.json",
- "style": "new-york",
- "rsc": false,
- "tsx": true,
- "tailwind": {
- "config": "tailwind.config.cjs",
- "css": "src/index.css",
- "baseColor": "stone",
- "cssVariables": true,
- "prefix": ""
- },
- "iconLibrary": "lucide",
- "aliases": {
- "components": "@/components",
- "utils": "@/lib/utils",
- "ui": "@/components/ui",
- "lib": "@/lib",
- "hooks": "@/hooks"
- },
- "registries": {}
-}
diff --git a/dist-electron/main.js b/dist-electron/main.js
deleted file mode 100644
index 0634f7dc0..000000000
--- a/dist-electron/main.js
+++ /dev/null
@@ -1,342 +0,0 @@
-import { BrowserWindow as E, screen as O, ipcMain as c, desktopCapturer as W, shell as V, app as d, nativeImage as L, Tray as U, Menu as A } from "electron";
-import { fileURLToPath as S } from "node:url";
-import t from "node:path";
-import p from "node:fs/promises";
-import { uIOhook as w } from "uiohook-napi";
-const P = t.dirname(S(import.meta.url)), C = t.join(P, ".."), y = process.env.VITE_DEV_SERVER_URL, x = t.join(C, "dist");
-function N() {
- const e = new E({
- width: 250,
- height: 80,
- minWidth: 250,
- maxWidth: 250,
- minHeight: 80,
- maxHeight: 80,
- frame: !1,
- transparent: !0,
- resizable: !1,
- alwaysOnTop: !0,
- skipTaskbar: !0,
- hasShadow: !1,
- webPreferences: {
- preload: t.join(P, "preload.mjs"),
- nodeIntegration: !1,
- contextIsolation: !0,
- backgroundThrottling: !1
- }
- });
- return e.webContents.on("did-finish-load", () => {
- e == null || e.webContents.send("main-process-message", (/* @__PURE__ */ new Date()).toLocaleString());
- }), y ? e.loadURL(y + "?windowType=hud-overlay") : e.loadFile(t.join(x, "index.html"), {
- query: { windowType: "hud-overlay" }
- }), e;
-}
-function H() {
- const e = new E({
- width: 1200,
- height: 800,
- minWidth: 800,
- minHeight: 600,
- frame: !0,
- transparent: !1,
- resizable: !0,
- alwaysOnTop: !1,
- skipTaskbar: !1,
- title: "",
- webPreferences: {
- preload: t.join(P, "preload.mjs"),
- nodeIntegration: !1,
- contextIsolation: !0,
- webSecurity: !1
- }
- });
- return e.maximize(), e.webContents.on("did-finish-load", () => {
- e == null || e.webContents.send("main-process-message", (/* @__PURE__ */ new Date()).toLocaleString());
- }), y ? e.loadURL(y + "?windowType=editor") : e.loadFile(t.join(x, "index.html"), {
- query: { windowType: "editor" }
- }), e;
-}
-function z() {
- const { width: e, height: n } = O.getPrimaryDisplay().workAreaSize, i = new E({
- width: 620,
- height: 420,
- minHeight: 350,
- maxHeight: 500,
- x: Math.round((e - 620) / 2),
- y: Math.round((n - 420) / 2),
- frame: !1,
- resizable: !1,
- alwaysOnTop: !0,
- transparent: !0,
- backgroundColor: "#00000000",
- webPreferences: {
- preload: t.join(P, "preload.mjs"),
- nodeIntegration: !1,
- contextIsolation: !0
- }
- });
- return y ? i.loadURL(y + "?windowType=source-selector") : i.loadFile(t.join(x, "index.html"), {
- query: { windowType: "source-selector" }
- }), i;
-}
-let u = !1, b = !1, m = 0, f = [];
-function q() {
- if (u)
- return { success: !1, message: "Already tracking" };
- if (u = !0, m = performance.now(), f = [], b)
- return { success: !0, message: "Mouse tracking resumed", startTime: m };
- $();
- try {
- return w.start(), b = !0, { success: !0, message: "Mouse tracking started", startTime: m };
- } catch (e) {
- return console.error("Failed to start mouse tracking:", e), u = !1, { success: !1, message: "Failed to start hook", error: e };
- }
-}
-function B() {
- if (!u)
- return { success: !1, message: "Not currently tracking" };
- u = !1;
- const e = performance.now() - m;
- return {
- success: !0,
- message: "Mouse tracking stopped",
- data: {
- startTime: m,
- events: f,
- duration: e
- }
- };
-}
-function $() {
- w.on("mousemove", (e) => {
- if (u) {
- const i = {
- type: "move",
- timestamp: performance.now() - m,
- x: e.x,
- y: e.y
- };
- f.push(i);
- }
- }), w.on("mousedown", (e) => {
- if (u) {
- const i = {
- type: "down",
- timestamp: performance.now() - m,
- x: e.x,
- y: e.y,
- button: e.button,
- clicks: e.clicks
- };
- f.push(i);
- }
- }), w.on("mouseup", (e) => {
- if (u) {
- const i = {
- type: "up",
- timestamp: performance.now() - m,
- x: e.x,
- y: e.y,
- button: e.button
- };
- f.push(i);
- }
- }), w.on("click", (e) => {
- if (u) {
- const i = {
- type: "click",
- timestamp: performance.now() - m,
- x: e.x,
- y: e.y,
- button: e.button,
- clicks: e.clicks
- };
- f.push(i);
- }
- });
-}
-function G() {
- return [...f];
-}
-function I() {
- if (b)
- try {
- w.stop(), b = !1, u = !1, f = [];
- } catch (e) {
- console.error("Error cleaning up mouse tracking:", e);
- }
-}
-let _ = null;
-function J(e, n, i, v, T) {
- c.handle("get-sources", async (o, a) => (await W.getSources(a)).map((r) => ({
- id: r.id,
- name: r.name,
- display_id: r.display_id,
- thumbnail: r.thumbnail ? r.thumbnail.toDataURL() : null,
- appIcon: r.appIcon ? r.appIcon.toDataURL() : null
- }))), c.handle("select-source", (o, a) => {
- _ = a;
- const s = v();
- return s && s.close(), _;
- }), c.handle("get-selected-source", () => _), c.handle("open-source-selector", () => {
- const o = v();
- if (o) {
- o.focus();
- return;
- }
- n();
- }), c.handle("switch-to-editor", () => {
- const o = i();
- o && o.close(), e();
- }), c.handle("start-mouse-tracking", () => q()), c.handle("stop-mouse-tracking", () => B()), c.handle("store-recorded-video", async (o, a, s) => {
- try {
- const r = t.join(h, s);
- return await p.writeFile(r, Buffer.from(a)), {
- success: !0,
- path: r,
- message: "Video stored successfully"
- };
- } catch (r) {
- return console.error("Failed to store video:", r), {
- success: !1,
- message: "Failed to store video",
- error: String(r)
- };
- }
- }), c.handle("store-mouse-tracking-data", async (o, a) => {
- try {
- const s = G();
- if (s.length === 0)
- return { success: !1, message: "No tracking data to save" };
- const r = t.join(h, a);
- return await p.writeFile(r, JSON.stringify(s, null, 2), "utf-8"), {
- success: !0,
- path: r,
- eventCount: s.length,
- message: "Mouse tracking data stored successfully"
- };
- } catch (s) {
- return console.error("Failed to store mouse tracking data:", s), {
- success: !1,
- message: "Failed to store mouse tracking data",
- error: String(s)
- };
- }
- }), c.handle("get-recorded-video-path", async () => {
- try {
- const a = (await p.readdir(h)).filter((R) => R.endsWith(".webm"));
- if (a.length === 0)
- return { success: !1, message: "No recorded video found" };
- const s = a.sort().reverse()[0];
- return { success: !0, path: t.join(h, s) };
- } catch (o) {
- return console.error("Failed to get video path:", o), { success: !1, message: "Failed to get video path", error: String(o) };
- }
- }), c.handle("set-recording-state", (o, a) => {
- T && T(a, (_ || { name: "Screen" }).name);
- }), c.handle("open-external-url", async (o, a) => {
- try {
- return await V.openExternal(a), { success: !0 };
- } catch (s) {
- return console.error("Failed to open URL:", s), { success: !1, error: String(s) };
- }
- }), c.handle("get-asset-base-path", () => {
- try {
- return d.isPackaged ? t.join(process.resourcesPath, "assets") : t.join(d.getAppPath(), "public", "assets");
- } catch (o) {
- return console.error("Failed to resolve asset base path:", o), null;
- }
- }), c.handle("save-exported-video", async (o, a, s) => {
- try {
- const r = d.getPath("downloads"), R = t.join(r, s);
- return await p.writeFile(R, Buffer.from(a)), {
- success: !0,
- path: R,
- message: "Video exported successfully"
- };
- } catch (r) {
- return console.error("Failed to save exported video:", r), {
- success: !1,
- message: "Failed to save exported video",
- error: String(r)
- };
- }
- });
-}
-const K = t.dirname(S(import.meta.url)), h = t.join(d.getPath("userData"), "recordings");
-async function Q() {
- try {
- const e = await p.readdir(h), n = Date.now(), i = 1 * 24 * 60 * 60 * 1e3;
- for (const v of e) {
- const T = t.join(h, v), o = await p.stat(T);
- n - o.mtimeMs > i && (await p.unlink(T), console.log(`Deleted old recording: ${v}`));
- }
- } catch (e) {
- console.error("Failed to cleanup old recordings:", e);
- }
-}
-async function X() {
- try {
- await p.mkdir(h, { recursive: !0 }), console.log("Recordings directory ready:", h);
- } catch (e) {
- console.error("Failed to create recordings directory:", e);
- }
-}
-process.env.APP_ROOT = t.join(K, "..");
-const Y = process.env.VITE_DEV_SERVER_URL, ie = t.join(process.env.APP_ROOT, "dist-electron"), j = t.join(process.env.APP_ROOT, "dist");
-process.env.VITE_PUBLIC = Y ? t.join(process.env.APP_ROOT, "public") : j;
-let l = null, k = null, g = null, D = "";
-function F() {
- l = N();
-}
-function Z() {
- const e = t.join(process.env.VITE_PUBLIC || j, "rec-button.png");
- let n = L.createFromPath(e);
- n = n.resize({ width: 24, height: 24, quality: "best" }), g = new U(n), M();
-}
-function M() {
- if (!g) return;
- const e = [
- {
- label: "Stop Recording",
- click: () => {
- l && !l.isDestroyed() && l.webContents.send("stop-recording-from-tray");
- }
- }
- ], n = A.buildFromTemplate(e);
- g.setContextMenu(n), g.setToolTip(`Recording: ${D}`);
-}
-function ee() {
- l && (l.close(), l = null), l = H();
-}
-function te() {
- return k = z(), k.on("closed", () => {
- k = null;
- }), k;
-}
-d.on("window-all-closed", () => {
- process.platform !== "darwin" && (I(), d.quit(), l = null);
-});
-d.on("activate", () => {
- E.getAllWindows().length === 0 && F();
-});
-d.on("before-quit", async (e) => {
- e.preventDefault(), I(), await Q(), d.exit(0);
-});
-d.whenReady().then(async () => {
- await X(), J(
- ee,
- te,
- () => l,
- () => k,
- (e, n) => {
- D = n, e ? (g || Z(), M(), l && l.minimize()) : (g && (g.destroy(), g = null), l && l.restore());
- }
- ), F();
-});
-export {
- ie as MAIN_DIST,
- h as RECORDINGS_DIR,
- j as RENDERER_DIST,
- Y as VITE_DEV_SERVER_URL
-};
diff --git a/dist-electron/preload.mjs b/dist-electron/preload.mjs
deleted file mode 100644
index 92d0779b1..000000000
--- a/dist-electron/preload.mjs
+++ /dev/null
@@ -1 +0,0 @@
-"use strict";const e=require("electron");e.contextBridge.exposeInMainWorld("electronAPI",{getAssetBasePath:async()=>await e.ipcRenderer.invoke("get-asset-base-path"),getSources:async r=>await e.ipcRenderer.invoke("get-sources",r),switchToEditor:()=>e.ipcRenderer.invoke("switch-to-editor"),openSourceSelector:()=>e.ipcRenderer.invoke("open-source-selector"),selectSource:r=>e.ipcRenderer.invoke("select-source",r),getSelectedSource:()=>e.ipcRenderer.invoke("get-selected-source"),startMouseTracking:()=>e.ipcRenderer.invoke("start-mouse-tracking"),stopMouseTracking:()=>e.ipcRenderer.invoke("stop-mouse-tracking"),storeRecordedVideo:(r,t)=>e.ipcRenderer.invoke("store-recorded-video",r,t),storeMouseTrackingData:r=>e.ipcRenderer.invoke("store-mouse-tracking-data",r),getRecordedVideoPath:()=>e.ipcRenderer.invoke("get-recorded-video-path"),setRecordingState:r=>e.ipcRenderer.invoke("set-recording-state",r),onStopRecordingFromTray:r=>{const t=()=>r();return e.ipcRenderer.on("stop-recording-from-tray",t),()=>e.ipcRenderer.removeListener("stop-recording-from-tray",t)},openExternalUrl:r=>e.ipcRenderer.invoke("open-external-url",r),saveExportedVideo:(r,t)=>e.ipcRenderer.invoke("save-exported-video",r,t)});
diff --git a/docs/HEROUI_MIGRATION.md b/docs/HEROUI_MIGRATION.md
new file mode 100644
index 000000000..22b182b96
--- /dev/null
+++ b/docs/HEROUI_MIGRATION.md
@@ -0,0 +1,92 @@
+# HeroUI UI branch
+
+This migration is isolated on `codex/heroui-ui`, based on `9693ad7` from
+`codex/clip-controls`. It lives in the sibling `recordly-heroui` Git worktree.
+The original `recordly` checkout and its uncommitted files were left intact.
+Nothing has been merged or pushed.
+
+## Try it
+
+From the original checkout:
+
+```sh
+cd ../recordly-heroui
+npm run dev
+```
+
+A fresh checkout needs `npm install` first, including Recordly's normal native
+helper installation. The dependency lockfile belongs to this branch.
+
+To go back, close the development app and run `npm run dev` from the original
+`recordly` directory. No reset, stash, or file restoration is needed. You can
+keep both checkouts while comparing them. To undo just the UI refinement pass,
+use `git revert `. To undo the entire migration after merging,
+revert the branch commits newest-first, including the original `93be193` migration.
+
+## UI approach
+
+The controls use HeroUI React 3.2.6 and its default light/dark theme, following
+[the official component demos](https://heroui.com/en/docs/react/components).
+This includes buttons, fields, switches, sliders, tabs, tag groups, toggle groups, radios,
+selects, modals, popovers, menus, tooltips, color pickers, progress indicators,
+skeletons, and toasts. React 19 and Tailwind 4 satisfy HeroUI v3 requirements.
+The former Radix, Sonner, and third-party color picker dependencies are removed.
+
+The editor uses a floating inspector card, an open canvas and timeline, aligned
+toolbars, consistent spacing, and restrained selection colors. Advanced controls
+live behind a per-section switch; changing views preserves project values.
+Background types and other exclusive choices use TagGroup. The header follows
+native fullscreen state and keeps project titles centered at narrow widths. Floating layers keep one
+surface instead of nesting cards and shadows. Timeline blocks retain the original
+Recordly palette in both themes. Inspector controls use compact 12–13px text and
+32–36px controls, with 14px section titles. Image and video wallpaper grids share
+a plus tile for importing and a small remove control on custom tiles, revealed
+on hover or keyboard focus. The background gallery slides left/right with tab order.
+
+The clip lane samples real source frames with a bounded cache and one background
+video decoder. Sampling follows clip source offsets, speed, and the visible timeline
+range; it never seeks the playback element. Zooms use a compact lane with labels
+that adapt to block width. The ruler chooses tick density from available width,
+and the red playhead has a white centre line. Webcam roundness defaults to 100%;
+existing saved values remain intact. New recordings inherit the saved webcam
+appearance, and 100% is the maximum squircle radius rather than a circular mask.
+Three compact timeline tracks fit before
+vertical scrolling. Popovers support outside-click and Escape dismissal and
+share one padding layer. Export states retain generous content padding. Timeline
+selection ignores pointer jitter up to 4px, including clicks on trim handles,
+so selecting a clip cannot introduce a leading gap. Project and preset names truncate inside their rows.
+The recorder keeps its compact desktop layout.
+
+The adapters in `src/components/ui` translate existing Recordly state/callback
+props to HeroUI APIs. The timeline geometry, crop handles, video canvas, caption
+canvas editor, waveform, and native file inputs remain application-specific;
+HeroUI does not replace those editing engines. Recording/export/project logic
+is retained, with nullable ref types updated for React 19.
+
+## Verification
+
+```sh
+npm run typecheck
+npm test
+npx vite build
+npx playwright install chromium
+npm run test:ui
+```
+
+The production build includes both the renderer and Electron main/preload
+bundles. It is not an installer packaging run.
+
+Browser tests use an explicit mocked Electron bridge and a generated six-second
+video fixture; they never start a real screen recording. A second changing-frame
+fixture verifies that timeline thumbnails decode distinct source frames. They cover control
+callbacks and keyboard behavior, modal focus, export settings, presets, cropping,
+annotation formatting/undo, project menus, recorder popovers, countdown and update
+windows, theme switching, Advanced state, color editing, wallpaper uploads and
+keyboard/hover removal, and header/playback
+alignment from 800–1440px with and without macOS window controls. Screenshots and failure
+traces go to the ignored `test-results/` directory.
+
+`npm run dev:ui` starts only Vite for browser inspection; the component fixture
+is at `/tests/ui/controls.html`. The real editor requires Electron or the test
+bridge. Native recording, device permissions, and end-to-end media export still
+need a desktop smoke test on the target platform.
diff --git a/docs/announcements.md b/docs/announcements.md
new file mode 100644
index 000000000..51637ff42
--- /dev/null
+++ b/docs/announcements.md
@@ -0,0 +1,96 @@
+# In-app announcements
+
+Recordly can show dismissible announcements in the editor as a popup, carousel slide, lightweight live notification, or header banner. Popups can contain images or video; notifications and banners are text-only with optional buttons.
+
+## Remote announcements
+
+Edit [`announcements.json`](../announcements.json) on the `main` branch to publish an announcement without releasing a new app version. Released clients check the raw GitHub file at most once every six hours per running app instance. Fetch failures are silent and never block startup.
+
+```json
+{
+ "settings": {
+ "aspectRatio": "4:3"
+ },
+ "announcements": [
+ {
+ "id": "recordly-1.4-release",
+ "title": "A faster Recordly is here",
+ "body": "Exports are faster and cursor motion is smoother. Thanks for using Recordly!",
+ "presentation": "popup",
+ "audience": "editor",
+ "priority": 10,
+ "mediaMode": "cover",
+ "displayDurationSeconds": 15,
+ "maxImpressions": 3,
+ "controls": {
+ "close": true,
+ "dismiss": false,
+ "action": true,
+ "navigation": false,
+ "indicators": true
+ },
+ "startsAt": "2026-09-01T00:00:00Z",
+ "endsAt": "2026-10-01T00:00:00Z",
+ "minVersion": "1.4.0",
+ "media": {
+ "type": "image",
+ "url": "https://example.com/recordly-1.4-banner.jpg",
+ "alt": "Recordly 1.4 feature preview"
+ },
+ "action": {
+ "label": "See what changed",
+ "url": "https://github.com/webadderallorg/Recordly/releases"
+ }
+ },
+ {
+ "id": "recordly-maintenance-notice",
+ "title": "Quick service notice",
+ "body": "Cloud sharing will undergo brief maintenance tonight.",
+ "presentation": "notification",
+ "audience": "editor",
+ "displayDurationSeconds": 10,
+ "maxImpressions": 2,
+ "startsAt": "2026-09-05T00:00:00Z",
+ "endsAt": "2026-09-06T00:00:00Z"
+ },
+ {
+ "id": "recordly-editor-banner",
+ "title": "Try the new editor",
+ "body": "The redesigned timeline is now available.",
+ "presentation": "banner",
+ "audience": "editor",
+ "maxImpressions": 3,
+ "action": {
+ "label": "Open settings",
+ "section": "settings"
+ }
+ }
+ ]
+}
+```
+
+Use a new stable `id` whenever an announcement should appear again. Once a user dismisses an ID, it remains dismissed. Remote items with the same ID override bundled items.
+
+Supported fields:
+
+- `settings.aspectRatio` sets one shared `width:height` ratio for the entire popup carousel, such as `16:9`, `4:3`, or `1:1`. All slides keep that same size. If omitted, the bundled default is used.
+- `id`, `title`, and `body` are required.
+- `presentation` is `popup`, `notification`, or `banner` and defaults to `popup`. Notifications appear as text-only non-modal toasts, while banners appear only in the editor directly beneath its header. Media and `mediaMode` are ignored for notifications and banners. At most five notifications are shown from one feed load; banners are shown one at a time by priority.
+- `audience` is `all` or `editor` and defaults to `all`.
+- `priority` controls carousel order; larger numbers appear first.
+- `mediaMode` is `banner` or `cover`. `banner` is the default current layout; `cover` fills the popup with the media and overlays the text.
+- `startsAt` and `endsAt` are optional ISO timestamps.
+- `displayDurationSeconds` accepts 3–300 seconds. It auto-advances popup slides; for notifications it controls how long the toast remains visible and defaults to 10 seconds.
+- `maxImpressions` optionally limits an announcement to 1–100 app sessions. Each announcement counts at most once per session; an explicit dismissal always hides it permanently.
+- `controls` can independently show or hide `close`, `dismiss`, `action`, `navigation`, and `indicators`. Every control defaults to `true`. Notifications and banners use only `close` and `action`; popup carousels use the other controls. Escape and clicking outside a popup remain available even when visible close controls are hidden.
+- `minVersion` and `maxVersion` are optional inclusive app-version bounds.
+- `media.type` is `image` or `video` for popups. Media URLs must be HTTPS or root-relative bundled assets. Videos can also specify `posterUrl`.
+- `action` has a label and exactly one destination: an HTTPS `url` opened in the system browser, or an editor `section` opened inside the app. Supported sections are `scene`, `cursor`, `webcam`, `captions`, `settings`, and `extensions`. An action containing both destinations, neither destination, or an unknown section is ignored.
+
+Set `RECORDLY_ANNOUNCEMENTS_URL` before launching the app to use a different HTTPS feed. Set it to `off` to disable remote announcements.
+
+## Announcements bundled with an update
+
+Add typed entries to `src/content/announcements.ts`. Bundled items use the same schema and are available offline. This is useful when a message should ship atomically with a new release.
+
+Remote content is treated as data only: HTML is not rendered, URLs are restricted, feeds are size-limited and time-limited, and malformed items are ignored.
diff --git a/docs/authentication.md b/docs/authentication.md
new file mode 100644
index 000000000..96e7b30b6
--- /dev/null
+++ b/docs/authentication.md
@@ -0,0 +1,52 @@
+# Recordly authentication setup
+
+Recordly uses Supabase Auth for one shared session across email/password and Google. The desktop application uses PKCE and returns from the system browser through a loopback callback in development and `recordly://auth/callback` in production. X is shown in the sign-in dialog and requires its provider to be enabled in Supabase. SAML is intentionally hidden until configured.
+
+## 1. Create the project
+
+1. Create a Supabase project.
+2. In **Project settings → API**, copy the project URL and publishable key.
+3. Copy `.env.example` to `.env.local` and fill in both values. Never use the service-role key in the desktop application.
+4. In **Authentication → URL configuration**, add `recordly://auth/callback` and `http://127.0.0.1:43821/auth/callback` to the redirect allow list.
+
+Email/password works once email authentication is enabled and a user has been created. Password-reset emails use the same desktop callback.
+
+## 2. Google
+
+1. Create a Web OAuth client in Google Auth Platform.
+2. Add Supabase's callback URL, `https://YOUR_PROJECT_REF.supabase.co/auth/v1/callback`, as an authorized redirect URI.
+3. Enable Google under **Supabase → Authentication → Sign In / Providers** and paste the Google client ID and secret.
+4. Keep the requested scopes to `openid`, email, and profile unless Recordly genuinely needs more.
+
+## 3. X
+
+1. Create an OAuth 2.0 app in the X Developer Dashboard and enable requesting the user's email.
+2. Set its callback URL to `https://YOUR_PROJECT_REF.supabase.co/auth/v1/callback`.
+3. Enable X/Twitter OAuth 2.0 in Supabase and paste the client ID and secret.
+
+## 4. SAML SSO (currently hidden)
+
+SAML is configured per customer workspace. Supabase's SAML support requires Pro or above.
+
+1. Enable SAML in the Supabase Auth provider settings.
+2. Obtain the customer's IdP metadata URL or metadata XML file.
+3. Register the connection and its email domain with the Supabase CLI, for example:
+
+ ```sh
+ supabase sso add --type saml --project-ref YOUR_PROJECT_REF \
+ --metadata-url 'https://customer.example/idp/metadata' \
+ --domains customer.example
+ ```
+
+The Recordly modal extracts the domain from the entered email address and starts the matching SAML connection.
+
+## 5. Verify locally
+
+Restart `npm run dev` after creating `.env.local`. Open an editor and verify:
+
+1. The account button opens **Sign into Recordly**.
+2. Email/password creates a persistent Supabase session.
+3. Google opens the system browser and returns to Recordly.
+4. Clicking **Create link** while signed out opens this modal; after successful authentication it continues to the share dialog.
+
+Add the same `SUPABASE_URL` and `SUPABASE_PUBLISHABLE_KEY` values to the share Worker's secrets or variables. Set `OWNER_USER_ID` to the owner’s Supabase user ID. The Worker validates the access token with Supabase and requires that owner identity before accepting API requests. `API_SECRET` is server-side only and remains available for library administration and explicitly enabled local integration tests; it is never entered into or exposed by the desktop app.
diff --git a/docs/cloud-sharing.md b/docs/cloud-sharing.md
new file mode 100644
index 000000000..f8e5fc238
--- /dev/null
+++ b/docs/cloud-sharing.md
@@ -0,0 +1,30 @@
+# Recordly cloud sharing
+
+Recordly's **Export** menu offers downloading the current edit or **Create share link**. The Account button at the bottom of the left toolbar opens sign-in. Sharing renders an MP4 to a temporary file, uploads it to Recordly Share, finalizes its metadata, and removes the temporary file when the dialog closes.
+
+The desktop app defaults to the local development endpoint:
+
+```text
+http://localhost:8787/api/upload
+```
+
+The endpoint is intentionally not user-configurable. All builds currently use the local service above. Production service integration is planned but is not selected by any build. Publishing requires the user's Recordly access token. No share API secret is exposed in the app.
+
+## Publishing protocol
+
+1. `POST /api/upload` creates a recording and share code.
+2. Small files use `PUT /api/upload-data/:shareCode`; files over 50 MB use multipart uploads in retryable 25 MB chunks.
+3. `POST /api/metadata/:shareCode` publishes the title and creator notes.
+4. The returned `/s/:shareCode` URL opens the complete viewing and feedback page.
+
+The service provides R2 video storage, D1 metadata, range streaming, a responsive viewing page, timestamped comments, reactions, captions, password protection, expiry, and a private video library. See [the service README](../services/recordly-share/worker/README.md) for local and deployment instructions.
+
+## Comments
+
+Anyone with a valid link can watch a public recording and leave timestamped feedback using a display name. No account or sign-in is required. The existing per-IP rate limit and 2,000-character comment limit still apply.
+
+## Third-party licensing
+
+The hosting service is based on MIT-licensed open-source software. Required attribution and the complete license text are preserved in [THIRD_PARTY_NOTICES.md](../THIRD_PARTY_NOTICES.md) and [the vendored license](../services/recordly-share/LICENSE).
+
+The self-hosted worker is a single-owner library. Set `OWNER_USER_ID` to the owner’s Supabase user ID. Other accounts in the same Supabase project cannot administer the library. Missing owner configuration disables Supabase bearer access.
diff --git a/docs/media/CursorLoop.gif b/docs/media/CursorLoop.gif
new file mode 100644
index 000000000..e4844abb8
Binary files /dev/null and b/docs/media/CursorLoop.gif differ
diff --git a/docs/media/CursorSwayDemo.gif b/docs/media/CursorSwayDemo.gif
new file mode 100644
index 000000000..352e82e08
Binary files /dev/null and b/docs/media/CursorSwayDemo.gif differ
diff --git a/docs/media/demo.gif b/docs/media/demo.gif
new file mode 100644
index 000000000..d307f34b1
Binary files /dev/null and b/docs/media/demo.gif differ
diff --git a/public/wallpapers/wallpaper8.jpg b/docs/media/feature1.gif
similarity index 52%
rename from public/wallpapers/wallpaper8.jpg
rename to docs/media/feature1.gif
index 9cf1193f6..3ff0ae37b 100644
Binary files a/public/wallpapers/wallpaper8.jpg and b/docs/media/feature1.gif differ
diff --git a/docs/media/feature2.gif b/docs/media/feature2.gif
new file mode 100644
index 000000000..2b4ebb2ad
Binary files /dev/null and b/docs/media/feature2.gif differ
diff --git a/docs/media/feature3.png b/docs/media/feature3.png
new file mode 100644
index 000000000..b8330b154
Binary files /dev/null and b/docs/media/feature3.png differ
diff --git a/docs/pr/project-browser/idle-hud.png b/docs/pr/project-browser/idle-hud.png
new file mode 100644
index 000000000..ffc398104
Binary files /dev/null and b/docs/pr/project-browser/idle-hud.png differ
diff --git a/docs/pr/project-browser/login-expanded.png b/docs/pr/project-browser/login-expanded.png
new file mode 100644
index 000000000..1c6657e13
Binary files /dev/null and b/docs/pr/project-browser/login-expanded.png differ
diff --git a/docs/pr/project-browser/login.png b/docs/pr/project-browser/login.png
new file mode 100644
index 000000000..84139cb49
Binary files /dev/null and b/docs/pr/project-browser/login.png differ
diff --git a/docs/pr/project-browser/projects-dark.png b/docs/pr/project-browser/projects-dark.png
new file mode 100644
index 000000000..3e20a451e
Binary files /dev/null and b/docs/pr/project-browser/projects-dark.png differ
diff --git a/docs/pr/project-browser/projects.png b/docs/pr/project-browser/projects.png
new file mode 100644
index 000000000..a2025cba6
Binary files /dev/null and b/docs/pr/project-browser/projects.png differ
diff --git a/docs/pr/project-browser/raw.png b/docs/pr/project-browser/raw.png
new file mode 100644
index 000000000..4655ee2de
Binary files /dev/null and b/docs/pr/project-browser/raw.png differ
diff --git a/docs/pr/project-browser/recording-hud.png b/docs/pr/project-browser/recording-hud.png
new file mode 100644
index 000000000..b236e2f8d
Binary files /dev/null and b/docs/pr/project-browser/recording-hud.png differ
diff --git a/docs/pr/project-browser/settings-motion.png b/docs/pr/project-browser/settings-motion.png
new file mode 100644
index 000000000..5c2cf8ad3
Binary files /dev/null and b/docs/pr/project-browser/settings-motion.png differ
diff --git a/docs/pr/project-browser/settings.png b/docs/pr/project-browser/settings.png
new file mode 100644
index 000000000..5cdae534f
Binary files /dev/null and b/docs/pr/project-browser/settings.png differ
diff --git a/docs/pr/project-browser/shortcuts.png b/docs/pr/project-browser/shortcuts.png
new file mode 100644
index 000000000..61982c2d8
Binary files /dev/null and b/docs/pr/project-browser/shortcuts.png differ
diff --git a/docs/timeline-sequence.md b/docs/timeline-sequence.md
new file mode 100644
index 000000000..dc0fe667b
--- /dev/null
+++ b/docs/timeline-sequence.md
@@ -0,0 +1,25 @@
+# Clip sequence timing
+
+Primary footage is an ordered, contiguous sequence. A visual clip separator has no duration. Both sides of a cut at 2.8 seconds represent 2.8 seconds, even if the second clip starts at a different point in the source recording.
+
+- `ClipRegion.startMs/endMs`: edited timeline coordinates.
+- `sourceStartMs`: the retained recording in-point. Packing or reordering never derives this from the new timeline position.
+- `speed`: source milliseconds consumed per timeline millisecond.
+- `clipSequence.ts`: packs trims/deletes/speed changes, handles explicit insertion indices, and maps connected effects and audio anchors.
+- `timeline/core/clipPresentation.ts`: maps media time into inset clip rectangles and back. Scrubbing a separator returns its cut timestamp. The playhead does not wait or animate through a separator.
+
+Clip resizing is constrained by available source footage and minimum clip duration, rather than collision with the next clip. Dragging a clip chooses an insertion index rather than looking for empty space. Source-based captions are reprojected from their original cues. Imported audio retains its duration as its anchor moves. Annotation selection is independent of whether the annotation is visible at the current playhead time.
+
+Legacy projects with primary-track gaps are normalized before establishing the loaded undo baseline. Editing and export use the same contiguous clip positions.
+
+## Verification
+
+135 focused unit tests passed for sequence packing/rippling, persistence, drag and resize resolution, visual seam mapping, playback, annotation visibility and deletion shortcuts. All 13 renderer regressions passed for block deletion, dragged annotation selection, background transitions, gap-free trims, clip reordering, and connected annotation/audio undo. These browser checks use fixture media and a mocked Electron bridge.
+
+## Boundary snapping and preview motion
+
+Timeline effects use the same media-to-display mapping as clips and the playhead. Dragging or resizing a zoom, annotation, caption, or audio block snaps within 8 screen pixels of other block boundaries, including clip cuts, at every timeline zoom level. Dragging preserves the block duration. Moving beyond that distance releases the snap. Zoom blocks can span visual separators continuously.
+
+Hover previews and caption placement use the same inverse mapping. Keyframe positions and playhead snapping use media time consistently. Source-video seeks at contiguous cuts preserve camera springs; explicit timeline seeks still snap to the requested frame.
+
+Caption visibility was checked in the real renderer with fixture footage at 1×, 2× and 4×, including source-frame matching, cue visibility boundaries and moving playback. The caption test captures the preview at 2×. This verifies rendering and timing, not speech-recognition accuracy or a packaged export.
diff --git a/docs/ui-redundancy-audit.md b/docs/ui-redundancy-audit.md
new file mode 100644
index 000000000..2cd0fcfd3
--- /dev/null
+++ b/docs/ui-redundancy-audit.md
@@ -0,0 +1,65 @@
+# HeroUI editor redundancy audit
+
+Reviewed 19 September 2026 in the `recordly-heroui` redesign. Findings use the current renderer and source, including the existing uncommitted redesign work.
+
+## Cleaned up
+
+| Area | Problem | Change |
+| --- | --- | --- |
+| Zoom inspector | Two Delete Zoom buttons for the same selected block: one in the scrolling content, another in the fixed footer. | Kept the fixed footer action. |
+| Extensions navigation | A permanent tool tab leads only to “Extensions are no longer available.” | Removed the navigation entry; retained the existing compatibility component. |
+| Account navigation | A permanent button only shows “Account coming soon.” | Removed the placeholder button. |
+| Clip and Caption inspectors | The panel title is repeated immediately as a section heading without introducing a subsection. | Kept the panel title and removed the repeated heading. |
+| Basic Zoom inspector | Animation heading, Reset button and directions to Settings appear without an animation control. The actual control is Advanced-only. | Grouped these with Classic Animation under Advanced. |
+
+## Further consolidation opportunities
+
+These are recommendations, not additional changes in this patch.
+
+1. **Crop has two separate editing surfaces.** The toolbar opens the visual Crop Video dialog; Scene → Advanced offers four inset sliders for the same crop state. Put precise inset inputs and reset inside the crop dialog so there is one place to edit cropping. Preserve numeric precision when consolidating.
+2. **Zoom controls are scattered across two inspectors.** The block inspector contains Mode, Amount and Classic Animation; Settings contains Connect neighboring zooms, automatic suggestions and Motion Presets. Group motion settings in a clearly labelled project-wide subsection alongside Zoom controls, instead of a message directing users elsewhere. Some motion presets also affect the cursor, so their scope must stay explicit.
+3. **Adding content is split between Add Layer and adjacent buttons.** Add Layer offers annotation/audio, while Add Zoom is a separate icon. A single Add menu could enumerate all supported insertions; keep Split separate as an editing operation. Hover-to-add is useful direct manipulation and need not be removed.
+4. **Support commands occupy permanent editing-toolbar space.** Discord and Feedback sit beside project opening and Undo/Redo. A single Help menu could contain both plus shortcut help. This is lower-priority toolbar clutter, not two identical functions.
+5. **Audio repeats its inspector title.** An “Audio” heading inside the Audio inspector mainly provides a home for Reset. Move Reset to the inspector header when standardizing section actions.
+6. **Saved Presets and Motion Presets need clearer scope.** The header stores whole-editor presets; Settings applies motion presets. These are different functions, but the repeated naming obscures that distinction. Label the former “Style presets” and the latter “Motion” or explain their scope.
+
+Removed the unused compatibility timeline toolbar and its design-catalog fixture, plus the unmounted TimelineAxis and ClipMarkerOverlay components. Removed ignored SliderControl props and their call-site values.
+
+## Interaction defects fixed
+
+- Connected `handleClipDelete` to `TimelineEditor.onClipDelete`. Previously Backspace reached a no-op because the callback was absent.
+- Routed Delete, Backspace and the configured delete shortcut consistently for every selected block type before the timeline-focus check. Inspecting a block no longer makes it undeletable. Form controls, editable text, composition events and overlay controls retain their own keyboard behavior.
+- Removed the clip-only Backspace special case, which bypassed shared selection priority.
+- Preserved a selected caption fragment when the playhead leaves its time span. Deletion still maps the fragment to its source caption cue.
+- Removed unnecessary requirements for optional selection callbacks from clip, annotation and audio deletion.
+- Cleared stale keyframe selection when selecting a timeline block.
+- Cleared block selection when navigating to a global sidebar tool. A selected annotation previously overrode every tool's inspector, making Cursor, Scene, Webcam and Settings appear to show the same content.
+
+## Validation
+
+Passed: TypeScript checking, 72 focused unit tests, 14 browser tests, targeted lint, and diff whitespace checks.
+
+Regression coverage includes keyboard deletion for each block type, inspector deletion, clip undo, caption deletion after seeking, text-entry protection, and switching away from an annotation inspector. Unit coverage includes both delete keys, configured shortcuts, selection priority, editable/overlay guards and composition/modifier handling. Browser tests use the real renderer with a mocked Electron bridge and fixture media; packaged Electron is not exercised here.
+
+## Videos library and HUD follow-up
+
+- HUD source/device rows and source/region triggers use ghost buttons. Project cards fit the 300px HUD popup, scroll vertically with thin scrollbars, and no longer truncate the library at 24 entries.
+- Caption generation skips session re-registration when the source is unchanged, preserving active companion-audio approvals. Local media URLs resolve the current server port; preview retries a failed local load once through the existing approved-media API.
+- Videos uses a secondary header button at the far left and replaces the existing left inspector with lightweight file cards (no embedded video players). It lists files in the configured recordings directory. Removal stages the selected recordings and their capture companions using filesystem moves. Undo restores the latest removal without copying media or overwriting newer files; the next removal or app exit sends staged files to system Trash. This replaces the earlier hide-only behavior. Automatic recording age/count pruning is removed.
+- Timeline drops insert at a clip boundary. Since preview/export share a single source, imports prepare an immutable combined source in `.recordly-media`. New footage fits the current source canvas/frame rate; subsequent imports copy existing prepared video, with lossless system/mic companions and shifted cursor telemetry. Preparation takes time for large recordings. Clip source bounds prevent trimming into the next recording. Timeline history can undo/redo insertions because existing source offsets are retained.
+- The existing FFmpeg metadata parser is now shared by import and export, rather than duplicating it or depending on the incompatible FFprobe binary found in this development install.
+
+Validation: real FFmpeg integration covers differing dimensions, audio routing, repeated inserts, and original preservation. Browser tests cover library removal/undo, drag insertion/undo/redo, caption generation without session reset, preview recovery, and 30 projects in the HUD popup. Caption visibility at 1x/2x/4x and gap snapping remain covered.
+
+
+## Clips, overlays, and cloud integration
+
+- The Videos inspector uses shared search, checkbox, menu, and button controls. File rows support selection, double-click/+ insertion, and drag-to-timeline; bulk Trash is in the actions menu. No library video elements are loaded.
+- Webcam imports normalize and concatenate a separate webcam track, account for capture start offsets, and preserve source-time visibility ranges. Preview and both renderer paths hide the bubble in screen-only spans. Saved projects preserve these ranges; style presets omit them and preserve the current source's ranges when applied.
+- Imported click, mouse-up, right-click, and cursor-shape samples retain event types, shifted timestamps, and letterbox-adjusted coordinates.
+- Annotation drag bounds now use the complete canvas. Position percentages may extend outside the recording rectangle and survive save/reload; export uses the same coordinates. Webcam and caption overlays already use their own canvas layout.
+- Zoom blocks show the icon with the full label when space allows; narrow blocks hide the icon and mode while retaining the numeric multiplier.
+- Cloud integration was ported from the cloud worktree, including its current service/viewer edits, desktop authentication, multipart uploads, and preparation of the current edit for sharing. Account lives at the bottom of the tool rail; Create share link lives in Export. Nothing was deployed or uploaded. Live sign-in requires the public Supabase environment settings described in authentication.md.
+
+
+Thumbnail and sign-in refinement: Videos uses lazily requested 160×90 JPEG stills, cached by source path, modification time, and size. It displays unmodified filenames, including extensions, with ellipsis and the full hover title. Sign-in uses HeroUI Modal, Form, TextField, Input, Button, and Alert components with shared styling.
diff --git a/electron-builder.json5 b/electron-builder.json5
index 327778bfe..4a6b6aed1 100644
--- a/electron-builder.json5
+++ b/electron-builder.json5
@@ -1,52 +1,130 @@
-// @see - https://www.electron.build/configuration/configuration
-{
- "$schema": "https://raw.githubusercontent.com/electron-userland/electron-builder/master/packages/app-builder-lib/scheme.json",
- "appId": "com.siddharthvaddem.openscreen",
- "asar": true,
- "productName": "Openscreen",
- "directories": {
- "output": "release/${version}"
- },
- "files": [
- "dist",
- "dist-electron"
- ],
- "extraResources": [
- {
- "from": "public/wallpapers",
- "to": "assets/wallpapers"
- }
- ],
- "mac": {
- "target": [
- "dmg"
- ],
- "icon": "icons/icons/mac/icon.icns",
- "artifactName": "${productName}-Mac-${version}-Installer.${ext}"
- },
- "win": {
- "target": [
- {
- "target": "nsis",
- "arch": [
- "x64"
- ]
- }
- ],
- "icon": "icons/icons/win/icon.ico",
- "artifactName": "${productName}-Windows-${version}-Setup.${ext}",
- "requestedExecutionLevel": "asInvoker"
- },
- "nsis": {
- "oneClick": false,
- "perMachine": false,
- "allowToChangeInstallationDirectory": true,
- "deleteAppDataOnUninstall": false
- },
- "linux": {
- "target": [
- "AppImage"
- ],
- "artifactName": "${productName}-Linux-${version}.${ext}"
- }
-}
+// @see - https://www.electron.build/configuration/configuration
+{
+ "$schema": "https://raw.githubusercontent.com/electron-userland/electron-builder/master/packages/app-builder-lib/scheme.json",
+ "appId": "dev.recordly.app",
+ "electronUpdaterCompatibility": ">=2.16",
+ "asar": true,
+ "asarUnpack": [
+ "node_modules/ffmpeg-static/**",
+ "node_modules/ffprobe-static/**",
+ "node_modules/uiohook-napi/**",
+ "electron/native/bin/**"
+ ],
+ "productName": "Recordly",
+ "protocols": [
+ {
+ "name": "Recordly authentication",
+ "schemes": ["recordly"]
+ }
+ ],
+ "npmRebuild": true,
+ "buildDependenciesFromSource": true,
+ "compression": "normal",
+ "directories": {
+ "output": "release"
+ },
+ "files": [
+ "dist",
+ "!dist/wallpapers/**",
+ "dist-electron",
+ "electron/native/bin",
+ "!electron/native/bin/**/whisper-bench*",
+ "!electron/native/bin/**/whisper-quantize*",
+ "!electron/native/bin/**/whisper-server*",
+ "!electron/native/bin/**/whisper-vad-speech-segments*",
+ "!node_modules/ffprobe-static/bin/darwin/**",
+ "!node_modules/ffprobe-static/bin/linux/**",
+ "!node_modules/ffprobe-static/bin/win32/ia32/**",
+ "!electron/native/**/build/**",
+ "!*.png",
+ "!preview*.png",
+ "!*.md",
+ "!README.md",
+ "!CONTRIBUTING.md",
+ "!LICENSE"
+ ],
+ "extraResources": [
+ {
+ "from": "public/wallpapers",
+ "to": "assets/wallpapers"
+ }
+ ],
+ "publish": [
+ {
+ "provider": "github",
+ "owner": "webadderall",
+ "repo": "Recordly",
+ "tagNamePrefix": "v",
+ "publishAutoUpdate": true
+ }
+ ],
+
+ "mac": {
+ "hardenedRuntime": true,
+ "entitlements": "build/entitlements.mac.plist",
+ "entitlementsInherit": "build/entitlements.mac.inherit.plist",
+ "target": [
+ {
+ "target": "dmg",
+ "arch": ["x64", "arm64"]
+ },
+ {
+ "target": "zip",
+ "arch": ["x64", "arm64"]
+ }
+ ],
+ "icon": "icons/icons/mac/icon.icns",
+ "artifactName": "${productName}-${arch}.${ext}",
+ "extendInfo": {
+ "NSAudioCaptureUsageDescription": "Recordly needs audio capture permission to record system audio.",
+ "NSCameraUsageDescription": "Recordly needs camera access to record webcam video.",
+ "NSMicrophoneUsageDescription": "Recordly needs microphone access to record voice audio.",
+ "NSCameraUseContinuityCameraDeviceType": true,
+ "com.apple.security.device.audio-input": true,
+ "CFBundleDocumentTypes": [
+ {
+ "CFBundleTypeName": "Recordly Project",
+ "CFBundleTypeRole": "Editor",
+ "CFBundleTypeExtensions": ["recordly"],
+ "CFBundleTypeIconFile": "icon.icns",
+ "LSHandlerRank": "Owner",
+ "LSItemContentTypes": ["dev.recordly.app.project"]
+ }
+ ],
+ "UTExportedTypeDeclarations": [
+ {
+ "UTTypeIdentifier": "dev.recordly.app.project",
+ "UTTypeDescription": "Recordly Project",
+ "UTTypeConformsTo": ["public.json"],
+ "UTTypeTagSpecification": {
+ "public.filename-extension": ["recordly"],
+ "public.mime-type": "application/x-recordly-project"
+ }
+ }
+ ]
+ }
+ },
+ "linux": {
+ "target": [
+ "AppImage"
+ ],
+ "icon": "icons/icons/png",
+ "artifactName": "${productName}-linux-x64.${ext}",
+ "category": "AudioVideo"
+ },
+ "win": {
+ "target": [
+ "nsis"
+ ],
+ "icon": "icons/icons/win/icon.ico",
+ "executableName": "Recordly",
+ "artifactName": "${productName}-windows-${arch}.${ext}"
+ },
+ "nsis": {
+ "oneClick": false,
+ "allowToChangeInstallationDirectory": true,
+ "createDesktopShortcut": true,
+ "createStartMenuShortcut": true,
+ "shortcutName": "Recordly"
+ }
+}
diff --git a/electron/appPaths.ts b/electron/appPaths.ts
new file mode 100644
index 000000000..68d6cfc39
--- /dev/null
+++ b/electron/appPaths.ts
@@ -0,0 +1,11 @@
+import path from "node:path";
+import { app } from "electron";
+
+if (process.env["VITE_DEV_SERVER_URL"]) {
+ const devUserDataPath = path.join(app.getPath("appData"), "Recordly-dev");
+ app.setPath("userData", devUserDataPath);
+ app.setPath("sessionData", path.join(devUserDataPath, "session"));
+}
+
+export const USER_DATA_PATH = app.getPath("userData");
+export const RECORDINGS_DIR = path.join(USER_DATA_PATH, "recordings");
diff --git a/electron/appSettingsStore.ts b/electron/appSettingsStore.ts
new file mode 100644
index 000000000..c777b1004
--- /dev/null
+++ b/electron/appSettingsStore.ts
@@ -0,0 +1,36 @@
+import { readFileSync, writeFileSync } from "node:fs";
+import { APP_SETTINGS_FILE } from "./ipc/constants";
+import { parseJsonWithByteOrderMark } from "./ipc/utils";
+
+export function readAppSettingsStore(): Record {
+ try {
+ const content = readFileSync(APP_SETTINGS_FILE, "utf-8");
+ const parsed = parseJsonWithByteOrderMark(content);
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
+ return {};
+ }
+
+ return parsed as Record;
+ } catch {
+ return {};
+ }
+}
+
+export function writeAppSettingsStore(store: Record) {
+ writeFileSync(APP_SETTINGS_FILE, JSON.stringify(store, null, 2), "utf-8");
+}
+
+export function hasAppSetting(store: Record, key: string): boolean {
+ return Reflect.getOwnPropertyDescriptor(store, key) !== undefined;
+}
+
+export function readAppSetting(key: string): unknown {
+ const store = readAppSettingsStore();
+ return hasAppSetting(store, key) ? store[key] : null;
+}
+
+export function writeAppSetting(key: string, value: unknown) {
+ const store = readAppSettingsStore();
+ store[key] = value;
+ writeAppSettingsStore(store);
+}
diff --git a/electron/authCallback.ts b/electron/authCallback.ts
new file mode 100644
index 000000000..439aeb0ec
--- /dev/null
+++ b/electron/authCallback.ts
@@ -0,0 +1,120 @@
+import { createServer, type Server } from "node:http";
+import { app, BrowserWindow, ipcMain } from "electron";
+
+const DEV_CALLBACK_HOST = "127.0.0.1:43821";
+const DEV_CALLBACK_ORIGIN = `http://${DEV_CALLBACK_HOST}`;
+const CALLBACK_PATH = "/callback";
+const LOOPBACK_CALLBACK_PATH = "/auth/callback";
+const MAX_CALLBACK_URL_LENGTH = 8192;
+const CALLBACK_PARAMETERS = new Set(["code", "error", "error_code", "error_description"]);
+
+type AuthCallbackOptions = {
+ isDev: boolean;
+ focusApp: () => void;
+};
+
+function callbackHeaders(contentType: string) {
+ return {
+ "Cache-Control": "no-store",
+ "Content-Security-Policy":
+ "default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; frame-ancestors 'none'",
+ "Content-Type": contentType,
+ "Referrer-Policy": "no-referrer",
+ "X-Content-Type-Options": "nosniff",
+ };
+}
+
+export function createAuthCallbackController({ isDev, focusApp }: AuthCallbackOptions) {
+ const protocol = isDev ? "recordly-dev" : "recordly";
+ let pendingUrl: string | null = null;
+ let server: Server | null = null;
+
+ function parseCallback(rawUrl: string): URL | null {
+ if (rawUrl.length > MAX_CALLBACK_URL_LENGTH) return null;
+ try {
+ const url = new URL(rawUrl);
+ if (url.protocol !== `${protocol}:` || url.hostname !== "auth") return null;
+ if (url.pathname !== CALLBACK_PATH || url.username || url.password) return null;
+ return url;
+ } catch {
+ return null;
+ }
+ }
+
+ function find(args: readonly string[]) {
+ return args.find((arg) => parseCallback(arg) !== null) ?? null;
+ }
+
+ function dispatch(rawUrl: string) {
+ const url = parseCallback(rawUrl);
+ if (!url) return false;
+ pendingUrl = url.href;
+ for (const window of BrowserWindow.getAllWindows()) {
+ if (!window.isDestroyed()) window.webContents.send("auth:callback", url.href);
+ }
+ if (app.isReady()) focusApp();
+ return true;
+ }
+
+ function startDevServer() {
+ if (!isDev || server) return;
+ server = createServer((request, response) => {
+ if (
+ request.method !== "GET" ||
+ request.headers.host !== DEV_CALLBACK_HOST ||
+ (request.url?.length ?? 0) > MAX_CALLBACK_URL_LENGTH
+ ) {
+ response.writeHead(404, callbackHeaders("text/plain; charset=utf-8"));
+ response.end("Not found");
+ return;
+ }
+
+ const requestUrl = new URL(request.url ?? "/", DEV_CALLBACK_ORIGIN);
+ if (requestUrl.pathname !== LOOPBACK_CALLBACK_PATH) {
+ response.writeHead(404, callbackHeaders("text/plain; charset=utf-8"));
+ response.end("Not found");
+ return;
+ }
+
+ const appUrl = new URL(`${protocol}://auth${CALLBACK_PATH}`);
+ for (const key of CALLBACK_PARAMETERS) {
+ for (const value of requestUrl.searchParams.getAll(key)) {
+ appUrl.searchParams.append(key, value);
+ }
+ }
+ if (!appUrl.searchParams.has("code") && !appUrl.searchParams.has("error")) {
+ response.writeHead(400, callbackHeaders("text/plain; charset=utf-8"));
+ response.end("Invalid authentication callback");
+ return;
+ }
+
+ dispatch(appUrl.href);
+ response.writeHead(200, callbackHeaders("text/html; charset=utf-8"));
+ response.end(
+ 'Signed into Recordly Signed into Recordly You can close this tab and return to the app.
',
+ );
+ });
+ server.on("error", (error) => {
+ console.error("[auth] Could not start local callback server", error);
+ server = null;
+ });
+ server.listen(43821, "127.0.0.1");
+ }
+
+ function close() {
+ server?.close();
+ server = null;
+ }
+
+ app.on("open-url", (event, url) => {
+ event.preventDefault();
+ dispatch(url);
+ });
+
+ ipcMain.handle("auth:get-pending-callback", () => pendingUrl);
+ ipcMain.handle("auth:ack-callback", (_, url: string) => {
+ if (pendingUrl === url) pendingUrl = null;
+ });
+
+ return { close, dispatch, find, protocol, startDevServer };
+}
diff --git a/electron/countdownController.test.ts b/electron/countdownController.test.ts
new file mode 100644
index 000000000..eca328a56
--- /dev/null
+++ b/electron/countdownController.test.ts
@@ -0,0 +1,86 @@
+import { EventEmitter } from "node:events";
+import type { BrowserWindow } from "electron";
+import { afterEach, beforeEach, expect, it, vi } from "vitest";
+import { createCountdownController } from "./countdownController";
+function fixture(loading = false) {
+ const contents = Object.assign(new EventEmitter(), {
+ isLoadingMainFrame: () => loading,
+ isDestroyed: () => false,
+ send: vi.fn(),
+ });
+ const win = Object.assign(new EventEmitter(), {
+ webContents: contents,
+ isDestroyed: () => false,
+ close: vi.fn(),
+ });
+ const state = vi.fn();
+ const controller = createCountdownController(() => win as unknown as BrowserWindow, state);
+ return { controller, win, contents, state };
+}
+beforeEach(() => vi.useFakeTimers());
+afterEach(() => vi.useRealTimers());
+it("cancels an active countdown immediately and allows another start", async () => {
+ const { controller, state } = fixture();
+ const pending = controller.start(3);
+ controller.cancel();
+ await expect(pending).resolves.toEqual({ success: false, cancelled: true });
+ expect(state).toHaveBeenLastCalledWith(null);
+ expect(vi.getTimerCount()).toBe(0);
+ const next = controller.start(1);
+ await vi.advanceTimersByTimeAsync(1000);
+ await expect(next).resolves.toEqual({ success: true });
+});
+it("settles cancellation before the countdown renderer finishes loading", async () => {
+ const { controller, contents } = fixture(true);
+ const pending = controller.start(3);
+ controller.cancel();
+ await expect(pending).resolves.toEqual({ success: false, cancelled: true });
+ contents.emit("did-finish-load");
+ expect(contents.send).not.toHaveBeenCalled();
+ expect(vi.getTimerCount()).toBe(0);
+});
+it.each([
+ "closed",
+ "render-process-gone",
+])("settles when the window exits through %s", async (event) => {
+ const { controller, win, contents } = fixture(true);
+ const pending = controller.start(3);
+ (event === "closed" ? win : contents).emit(event);
+ await expect(pending).resolves.toEqual({ success: false, cancelled: true });
+});
+it("settles loading failures and releases the countdown lock", async () => {
+ const { controller, contents } = fixture(true);
+ const pending = controller.start(3);
+ contents.emit("did-fail-load");
+ await expect(pending).resolves.toMatchObject({ success: false, error: expect.any(String) });
+ const next = controller.start(1);
+ contents.emit("did-finish-load");
+ await vi.advanceTimersByTimeAsync(1000);
+ await expect(next).resolves.toEqual({ success: true });
+});
+it("rejects concurrent starts and emits ticks until normal completion", async () => {
+ const { controller, contents } = fixture();
+ const pending = controller.start(2);
+ await expect(controller.start(3)).resolves.toMatchObject({
+ success: false,
+ error: expect.any(String),
+ });
+ await vi.advanceTimersByTimeAsync(2000);
+ await expect(pending).resolves.toEqual({ success: true });
+ expect(contents.send.mock.calls).toEqual([
+ ["countdown-tick", 2],
+ ["countdown-tick", 1],
+ ]);
+ expect(vi.getTimerCount()).toBe(0);
+});
+it("resets state if creating the countdown window fails", async () => {
+ const state = vi.fn();
+ const controller = createCountdownController(() => {
+ throw Error("window unavailable");
+ }, state);
+ await expect(controller.start(3)).resolves.toMatchObject({
+ success: false,
+ error: expect.any(String),
+ });
+ expect(state).toHaveBeenLastCalledWith(null);
+});
diff --git a/electron/countdownController.ts b/electron/countdownController.ts
new file mode 100644
index 000000000..3a3ae3098
--- /dev/null
+++ b/electron/countdownController.ts
@@ -0,0 +1,83 @@
+import type { BrowserWindow } from "electron";
+
+type Result = { success: boolean; cancelled?: boolean; error?: string };
+/** Every countdown exit settles the IPC request, including cancellation during window load. */
+export function createCountdownController(
+ createWindow: () => BrowserWindow,
+ onRemaining: (seconds: number | null) => void,
+) {
+ let finishActive: ((result: Result) => void) | null = null;
+ return {
+ start(seconds: number): Promise {
+ if (finishActive)
+ return Promise.resolve({ success: false, error: "Countdown already in progress" });
+ if (!Number.isFinite(seconds) || seconds < 0)
+ return Promise.resolve({ success: false, error: "Invalid countdown delay" });
+ return new Promise((resolve) => {
+ let win: BrowserWindow | undefined;
+ let timer: ReturnType | undefined;
+ let settled = false;
+ let started = false;
+ let remaining = Math.ceil(seconds);
+ const finish = (result: Result) => {
+ if (settled) return;
+ settled = true;
+ if (timer) clearInterval(timer);
+ finishActive = null;
+ onRemaining(null);
+ if (win) {
+ win.removeListener("closed", cancel);
+ win.webContents.removeListener("did-finish-load", begin);
+ win.webContents.removeListener("did-fail-load", fail);
+ win.webContents.removeListener("render-process-gone", cancel);
+ }
+ resolve(result);
+ if (win && !win.isDestroyed()) win.close();
+ };
+ const cancel = () => finish({ success: false, cancelled: true });
+ const fail = () =>
+ finish({ success: false, error: "Countdown window failed to load" });
+ const tick = () => {
+ if (!win || win.isDestroyed() || win.webContents.isDestroyed()) {
+ cancel();
+ return;
+ }
+ onRemaining(remaining);
+ win.webContents.send("countdown-tick", remaining);
+ };
+ const begin = () => {
+ if (settled || started) return;
+ started = true;
+ if (remaining === 0) {
+ finish({ success: true });
+ return;
+ }
+ tick();
+ if (settled) return;
+ timer = setInterval(() => {
+ remaining--;
+ if (remaining <= 0) finish({ success: true });
+ else tick();
+ }, 1000);
+ };
+ finishActive = finish;
+ onRemaining(remaining);
+ try {
+ win = createWindow();
+ win.once("closed", cancel);
+ win.webContents.once("did-fail-load", fail);
+ win.webContents.once("render-process-gone", cancel);
+ if (win.webContents.isLoadingMainFrame())
+ win.webContents.once("did-finish-load", begin);
+ else begin();
+ } catch (error) {
+ finish({ success: false, error: String(error) });
+ }
+ });
+ },
+ cancel() {
+ finishActive?.({ success: false, cancelled: true });
+ return { success: true };
+ },
+ };
+}
diff --git a/electron/cursorHider.ts b/electron/cursorHider.ts
new file mode 100644
index 000000000..266d111a9
--- /dev/null
+++ b/electron/cursorHider.ts
@@ -0,0 +1,144 @@
+import { spawnSync } from "node:child_process";
+
+const PY_HIDE_WIN = `
+import ctypes, sys
+
+class POINT(ctypes.Structure):
+ _fields_ = [("x", ctypes.c_long), ("y", ctypes.c_long)]
+
+class CURSORINFO(ctypes.Structure):
+ _fields_ = [
+ ("cbSize", ctypes.c_uint),
+ ("flags", ctypes.c_uint),
+ ("hCursor", ctypes.c_void_p),
+ ("ptScreenPos", POINT),
+ ]
+
+user32 = ctypes.windll.user32
+CURSOR_SHOWING = 0x00000001
+
+for _ in range(32):
+ info = CURSORINFO()
+ info.cbSize = ctypes.sizeof(CURSORINFO)
+ if user32.GetCursorInfo(ctypes.byref(info)) and not (info.flags & CURSOR_SHOWING):
+ sys.exit(0)
+ user32.ShowCursor(False)
+
+sys.exit(0)
+`.trim();
+
+const PY_SHOW_WIN = `
+import ctypes, sys
+
+class POINT(ctypes.Structure):
+ _fields_ = [("x", ctypes.c_long), ("y", ctypes.c_long)]
+
+class CURSORINFO(ctypes.Structure):
+ _fields_ = [
+ ("cbSize", ctypes.c_uint),
+ ("flags", ctypes.c_uint),
+ ("hCursor", ctypes.c_void_p),
+ ("ptScreenPos", POINT),
+ ]
+
+user32 = ctypes.windll.user32
+CURSOR_SHOWING = 0x00000001
+
+for _ in range(32):
+ info = CURSORINFO()
+ info.cbSize = ctypes.sizeof(CURSORINFO)
+ if user32.GetCursorInfo(ctypes.byref(info)) and (info.flags & CURSOR_SHOWING):
+ sys.exit(0)
+ user32.ShowCursor(True)
+
+sys.exit(0)
+`.trim();
+
+function getPowerShellCommand(show: boolean) {
+ const desiredFlag = show ? 1 : 0;
+ const showLiteral = show ? "$true" : "$false";
+
+ return [
+ '$signature = @"',
+ "using System;",
+ "using System.Runtime.InteropServices;",
+ "public struct POINT { public int X; public int Y; }",
+ "public struct CURSORINFO { public int cbSize; public int flags; public IntPtr hCursor; public POINT ptScreenPos; }",
+ "public static class CursorNative {",
+ ' [DllImport("user32.dll")] public static extern int ShowCursor(bool show);',
+ ' [DllImport("user32.dll")] public static extern bool GetCursorInfo(ref CURSORINFO info);',
+ "}",
+ '"@;',
+ "Add-Type -TypeDefinition $signature -Language CSharp -ErrorAction SilentlyContinue | Out-Null;",
+ "$info = New-Object CURSORINFO;",
+ "$info.cbSize = [Runtime.InteropServices.Marshal]::SizeOf([type]CURSORINFO);",
+ "for ($i = 0; $i -lt 32; $i++) {",
+ " if ([CursorNative]::GetCursorInfo([ref]$info) -and (($info.flags -band 1) -eq " +
+ desiredFlag +
+ ")) { exit 0 }",
+ " [CursorNative]::ShowCursor(" + showLiteral + ") | Out-Null;",
+ "}",
+ "exit 0",
+ ].join(" ");
+}
+
+function runPythonSnippet(code: string) {
+ for (const executable of ["python", "python3", "py"]) {
+ const result = spawnSync(executable, ["-c", code], { timeout: 5000 });
+ if (!result.error && result.status === 0) {
+ return true;
+ }
+ }
+
+ return false;
+}
+
+function runPowerShellSnippet(command: string) {
+ const result = spawnSync(
+ "powershell.exe",
+ ["-NoProfile", "-NonInteractive", "-WindowStyle", "Hidden", "-Command", command],
+ { timeout: 8000 },
+ );
+
+ return !result.error && result.status === 0;
+}
+
+let cursorHidden = false;
+
+export function hideCursor() {
+ if (process.platform !== "win32" || cursorHidden) {
+ return false;
+ }
+
+ try {
+ const didHide =
+ runPythonSnippet(PY_HIDE_WIN) || runPowerShellSnippet(getPowerShellCommand(false));
+
+ if (didHide) {
+ cursorHidden = true;
+ }
+
+ return didHide;
+ } catch (error) {
+ console.error("[cursorHider] Failed to hide Windows cursor:", error);
+ return false;
+ }
+}
+
+export function showCursor() {
+ if (process.platform !== "win32" || !cursorHidden) {
+ return false;
+ }
+
+ try {
+ const didShow =
+ runPythonSnippet(PY_SHOW_WIN) || runPowerShellSnippet(getPowerShellCommand(true));
+ if (didShow) {
+ cursorHidden = false;
+ }
+ return didShow;
+ } catch (error) {
+ console.error("[cursorHider] Failed to show Windows cursor:", error);
+ return false;
+ }
+}
diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts
index 0853fc160..5409fcbee 100644
--- a/electron/electron-env.d.ts
+++ b/electron/electron-env.d.ts
@@ -1,50 +1,1080 @@
-///
-
-declare namespace NodeJS {
- interface ProcessEnv {
- /**
- * The built directory structure
- *
- * ```tree
- * ├─┬─┬ dist
- * │ │ └── index.html
- * │ │
- * │ ├─┬ dist-electron
- * │ │ ├── main.js
- * │ │ └── preload.js
- * │
- * ```
- */
- APP_ROOT: string
- /** /dist/ or /public/ */
- VITE_PUBLIC: string
- }
-}
-
-// Used in Renderer process, expose in `preload.ts`
-interface Window {
- electronAPI: {
- getSources: (opts: Electron.SourcesOptions) => Promise
- switchToEditor: () => Promise
- openSourceSelector: () => Promise
- selectSource: (source: any) => Promise
- getSelectedSource: () => Promise
- startMouseTracking: () => Promise
- stopMouseTracking: () => Promise
- storeRecordedVideo: (videoData: ArrayBuffer, fileName: string) => Promise<{ success: boolean; path?: string; message?: string }>
- storeMouseTrackingData: (fileName: string) => Promise<{ success: boolean; path?: string; eventCount?: number; message?: string }>
- getRecordedVideoPath: () => Promise<{ success: boolean; path?: string; message?: string }>
- setRecordingState: (recording: boolean) => Promise
- onStopRecordingFromTray: (callback: () => void) => () => void
- openExternalUrl: (url: string) => Promise<{ success: boolean; error?: string }>
- saveExportedVideo: (videoData: ArrayBuffer, fileName: string) => Promise<{ success: boolean; path?: string; message?: string }>
- }
-}
-
-interface ProcessedDesktopSource {
- id: string
- name: string
- display_id: string
- thumbnail: string | null
- appIcon: string | null
-}
+///
+
+declare namespace NodeJS {
+ interface ProcessEnv {
+ /**
+ * The built directory structure
+ *
+ * ```tree
+ * ├─┬─┬ dist
+ * │ │ └── index.html
+ * │ │
+ * │ ├─┬ dist-electron
+ * │ │ ├── main.js
+ * │ │ └── preload.js
+ * │
+ * ```
+ */
+ APP_ROOT: string;
+ /** /dist/ or /public/ */
+ VITE_PUBLIC: string;
+ }
+}
+
+// Used in Renderer process, expose in `preload.ts`
+interface NativeCaptureDiagnostics {
+ backend: "windows-wgc" | "mac-screencapturekit" | "browser-store" | "ffmpeg";
+ phase: "availability" | "start" | "stop" | "mux";
+ timestamp: string;
+ sourceId?: string | null;
+ sourceType?: "screen" | "window" | "unknown";
+ displayId?: number | null;
+ displayBounds?: { x: number; y: number; width: number; height: number } | null;
+ windowHandle?: number | null;
+ helperPath?: string | null;
+ outputPath?: string | null;
+ systemAudioPath?: string | null;
+ microphonePath?: string | null;
+ osRelease?: string;
+ supported?: boolean;
+ helperExists?: boolean;
+ fileSizeBytes?: number | null;
+ processOutput?: string;
+ error?: string;
+}
+
+interface UpdateToastState {
+ version: string;
+ detail: string;
+ phase: "available" | "downloading" | "ready" | "error";
+ delayMs: number;
+ isPreview?: boolean;
+ isExperimental?: boolean;
+ progressPercent?: number;
+ transferredBytes?: number;
+ totalBytes?: number;
+ remainingBytes?: number;
+ bytesPerSecond?: number;
+ primaryAction?: "install-and-restart" | "retry-check";
+}
+
+interface UpdateStatusSummary {
+ status: "idle" | "checking" | "up-to-date" | "available" | "downloading" | "ready" | "error";
+ currentVersion: string;
+ availableVersion: string | null;
+ detail?: string;
+}
+
+type RendererRecordingSessionData = import("./ipc/types").RecordingSessionData;
+
+interface RendererFfmpegAudioMuxMetrics {
+ tempVideoWriteMs?: number;
+ tempEditedAudioWriteMs?: number;
+ ffmpegExecMs?: number;
+ muxedVideoReadMs?: number;
+ tempVideoBytes?: number;
+ tempEditedAudioBytes?: number;
+ muxedVideoBytes?: number;
+}
+
+interface RendererWindowsGpuExportSummary {
+ success?: boolean;
+ width?: number;
+ height?: number;
+ fps?: number;
+ seconds?: number;
+ mediaMs?: number;
+ frames?: number;
+ gpuDecodeSurface?: boolean;
+ webcamOverlay?: boolean;
+ cursorOverlay?: boolean;
+ zoomOverlay?: boolean;
+ surfacePoolSize?: number;
+ adapterIndex?: number;
+ adapterVendorId?: number;
+ adapterDeviceId?: number;
+ adapterDedicatedVideoMemoryMB?: number;
+ encoderBackend?: string;
+ encoderTuningApplied?: boolean;
+ nvencOutputBytes?: number;
+ initializeMs?: number;
+ initCoInitializeMs?: number;
+ initMfStartupMs?: number;
+ initD3DDeviceMs?: number;
+ initSourceReaderMs?: number;
+ initWebcamReaderMs?: number;
+ initVideoProcessorMs?: number;
+ initTexturesMs?: number;
+ initShaderPipelineMs?: number;
+ initSinkWriterMs?: number;
+ totalMs?: number;
+ readMs?: number;
+ clearMs?: number;
+ videoProcessMs?: number;
+ writeSampleMs?: number;
+ finalizeMs?: number;
+ realtimeMultiplier?: number;
+}
+
+interface RendererNativeStaticLayoutChunkMetric {
+ index: number;
+ startSec: number;
+ durationSec: number;
+ backend:
+ | "cuda-overlay"
+ | "cuda-scale-cpu-pad"
+ | "cuda-static-composite"
+ | "nvidia-cuda-compositor"
+ | "windows-d3d11-compositor";
+ elapsedMs: number;
+ outputBytes: number;
+ fallbackReason?: string;
+ windowsGpuSummary?: RendererWindowsGpuExportSummary;
+}
+
+interface RendererNativeStaticLayoutMetrics extends RendererFfmpegAudioMuxMetrics {
+ chunkCount: number;
+ chunkDurationSec: number;
+ chunkExecMs: number;
+ concatExecMs?: number;
+ staticAssetExecMs?: number;
+ fallbackChunkCount: number;
+ videoOnlyBytes?: number;
+ chunks: RendererNativeStaticLayoutChunkMetric[];
+}
+
+interface RendererNativeStaticLayoutProgress {
+ sessionId?: string;
+ backend?: RendererNativeStaticLayoutChunkMetric["backend"];
+ stage?: "preparing" | "finalizing";
+ elapsedMs?: number;
+ averageFps?: number;
+ instantFps?: number;
+ intervalMs?: number;
+ intervalFrames?: number;
+ intervalDecodeWallMs?: number;
+ intervalEncodeMs?: number;
+ intervalPipelineWaitMs?: number;
+ intervalCompositeMs?: number;
+ intervalNvencMs?: number;
+ intervalPacketWriteMs?: number;
+ intervalWebcamDecodeMs?: number;
+ intervalWebcamCopyMs?: number;
+ intervalRoiCompositeFrames?: number;
+ intervalMonolithicCompositeFrames?: number;
+ intervalCopyCompositeFrames?: number;
+ currentFrame: number;
+ totalFrames: number;
+ percentage: number;
+}
+
+interface RendererNativeVideoMetadataProbe {
+ width: number;
+ height: number;
+ duration: number;
+ mediaStartTime?: number;
+ streamStartTime?: number;
+ streamDuration?: number;
+ frameRate: number;
+ codec: string;
+ hasAudio: boolean;
+ audioCodec?: string;
+ audioSampleRate?: number;
+}
+
+interface RendererNativeExportCapabilities {
+ platform: NodeJS.Platform;
+ nvidiaCuda: {
+ available: boolean;
+ skipReason: string | null;
+ hasNvidiaGpu: boolean | null;
+ hasWrapper: boolean;
+ explicitEnabled: boolean;
+ explicitDisabled: boolean;
+ userOptInRequired: boolean;
+ };
+}
+
+interface RendererExportHardwareInfo {
+ platform: NodeJS.Platform;
+ release: string;
+ arch: string;
+ cpuModel: string | null;
+ logicalProcessors: number;
+ totalMemoryGb: number;
+ machineModel: string | null;
+ gpus: Array<{
+ name: string;
+ vendor: string | null;
+ active: boolean | null;
+ }>;
+ gpuFeatures: {
+ videoDecode: string | null;
+ videoEncode: string | null;
+ webgl: string | null;
+ webgpu: string | null;
+ };
+}
+
+interface Window {
+ electronAPI: {
+ hudOverlaySetIgnoreMouse: (ignore: boolean) => void;
+ hudOverlaySetSourceSelectionActive: (active: boolean) => void;
+ hudOverlayDrag: (phase: "start" | "move" | "end", screenX: number, screenY: number) => void;
+ hudOverlayHide: () => void;
+ hudOverlayClose: () => void;
+ getEditorMode: () => Promise;
+ onEditorModeChanged: (callback: (inEditor: boolean) => void) => () => void;
+ hudOverlayRendererReady: () => void;
+ hudOverlaySetWebcamPreviewVisible: (visible: boolean) => void;
+ getHudOverlayCaptureProtection: () => Promise<{ success: boolean; enabled: boolean }>;
+ getHudOverlayMousePassthroughSupported: () => Promise<{
+ success: boolean;
+ supported: boolean;
+ }>;
+ setHudOverlayCaptureProtection: (
+ enabled: boolean,
+ ) => Promise<{ success: boolean; enabled: boolean }>;
+ getAssetBasePath: () => Promise;
+ getSources: (opts: Electron.SourcesOptions) => Promise;
+ showProjectDashboard: () => Promise;
+ switchToEditor: () => Promise;
+ openSourceSelector: () => Promise;
+ selectSource: (source: ProcessedDesktopSource) => Promise;
+ showSourceHighlight: (source: ProcessedDesktopSource) => Promise<{ success: boolean }>;
+ getSelectedSource: () => Promise;
+ onSelectedSourceChanged: (
+ callback: (source: ProcessedDesktopSource | null) => void,
+ ) => () => void;
+ startNativeScreenRecording: (
+ source: ProcessedDesktopSource,
+ options?: {
+ capturesSystemAudio?: boolean;
+ capturesMicrophone?: boolean;
+ microphoneDeviceId?: string;
+ microphoneLabel?: string;
+ },
+ ) => Promise<{
+ success: boolean;
+ path?: string;
+ message?: string;
+ error?: string;
+ userNotified?: boolean;
+ microphoneFallbackRequired?: boolean;
+ }>;
+ stopNativeScreenRecording: () => Promise<{
+ success: boolean;
+ path?: string;
+ message?: string;
+ error?: string;
+ }>;
+ recoverNativeScreenRecording: () => Promise<{
+ success: boolean;
+ path?: string;
+ message?: string;
+ error?: string;
+ }>;
+ getLastNativeCaptureDiagnostics: () => Promise<{
+ success: boolean;
+ diagnostics?: NativeCaptureDiagnostics | null;
+ }>;
+ pauseNativeScreenRecording: () => Promise<{
+ success: boolean;
+ message?: string;
+ error?: string;
+ }>;
+ resumeNativeScreenRecording: () => Promise<{
+ success: boolean;
+ message?: string;
+ error?: string;
+ }>;
+ pauseCursorCapture: (pausedAtMs?: number) => Promise<{
+ success: boolean;
+ message?: string;
+ error?: string;
+ }>;
+ resumeCursorCapture: (resumedAtMs?: number) => Promise<{
+ success: boolean;
+ message?: string;
+ error?: string;
+ }>;
+ startFfmpegRecording: (
+ source: ProcessedDesktopSource,
+ ) => Promise<{ success: boolean; path?: string; message?: string; error?: string }>;
+ stopFfmpegRecording: () => Promise<{
+ success: boolean;
+ path?: string;
+ message?: string;
+ error?: string;
+ }>;
+ storeRecordedVideo: (
+ videoData: ArrayBuffer,
+ fileName: string,
+ ) => Promise<{ success: boolean; path?: string; message?: string }>;
+ storeMicrophoneSidecar: (
+ audioData: ArrayBuffer,
+ videoPath: string,
+ options?: {
+ startDelayMs?: number;
+ browserMicrophoneProfile?: string;
+ requestedBrowserMicrophoneProfile?: string | null;
+ requestedConstraints?: unknown;
+ mediaTrackSettings?: Record;
+ audioInputDevices?: Array<{
+ deviceId: string;
+ groupId?: string;
+ label: string;
+ }>;
+ mediaRecorder?: {
+ mimeType?: string;
+ audioBitsPerSecond?: number;
+ timesliceMs?: number;
+ };
+ chunkEvents?: Array<{
+ index: number;
+ size: number;
+ elapsedMs: number;
+ deltaMs: number | null;
+ recordedElapsedMs?: number;
+ recordedDeltaMs?: number | null;
+ }>;
+ pauseIntervals?: Array<{
+ startElapsedMs: number;
+ endElapsedMs?: number;
+ durationMs?: number;
+ }>;
+ },
+ ) => Promise<{ success: boolean; path?: string; error?: string }>;
+ getRecordedVideoPath: () => Promise<{ success: boolean; path?: string; message?: string }>;
+ listAssetDirectory: (relativeDir: string) => Promise<{
+ success: boolean;
+ files?: string[];
+ error?: string;
+ }>;
+ readLocalFile: (
+ filePath: string,
+ ) => Promise<{ success: boolean; data?: Uint8Array; error?: string }>;
+ generateWallpaperThumbnail: (
+ filePath: string,
+ ) => Promise<{ success: boolean; data?: Uint8Array; error?: string }>;
+ probeNativeVideoMetadata: (filePath: string) => Promise<{
+ success: boolean;
+ metadata?: RendererNativeVideoMetadataProbe;
+ error?: string;
+ }>;
+ getNativeExportCapabilities: () => Promise<{
+ success: boolean;
+ capabilities?: RendererNativeExportCapabilities;
+ error?: string;
+ }>;
+ getExportHardwareInfo: () => Promise<{
+ success: boolean;
+ hardware?: RendererExportHardwareInfo;
+ error?: string;
+ }>;
+ nativeStaticLayoutExport: (options: {
+ sessionId?: string;
+ inputPath: string;
+ width: number;
+ height: number;
+ frameRate: number;
+ bitrate: number;
+ encodingMode: "fast" | "balanced" | "quality";
+ durationSec: number;
+ contentWidth: number;
+ contentHeight: number;
+ offsetX: number;
+ offsetY: number;
+ sourceCropX?: number;
+ sourceCropY?: number;
+ sourceCropWidth?: number;
+ sourceCropHeight?: number;
+ backgroundColor: string;
+ backgroundImagePath?: string | null;
+ backgroundBlurPx?: number;
+ borderRadius?: number;
+ shadowIntensity?: number;
+ webcamInputPath?: string | null;
+ webcamLeft?: number;
+ webcamTop?: number;
+ webcamSize?: number;
+ webcamRadius?: number;
+ webcamShadowIntensity?: number;
+ webcamMirror?: boolean;
+ webcamTimeOffsetMs?: number;
+ cursorTelemetry?: Array<{
+ timeMs: number;
+ cx: number;
+ cy: number;
+ cursorTypeIndex?: number;
+ bounceScale?: number;
+ visible?: boolean;
+ }>;
+ cursorSize?: number;
+ cursorAtlasPngDataUrl?: string | null;
+ cursorAtlasEntries?: Array<{
+ index: number;
+ x: number;
+ y: number;
+ width: number;
+ height: number;
+ anchorX: number;
+ anchorY: number;
+ aspectRatio: number;
+ }>;
+ zoomTelemetry?: Array<{ timeMs: number; scale: number; x: number; y: number }>;
+ timelineSegments?: Array<{
+ sourceStartMs: number;
+ sourceEndMs: number;
+ outputStartMs: number;
+ outputEndMs: number;
+ speed: number;
+ }>;
+ chunkDurationSec?: number;
+ experimentalWindowsGpuCompositor?: boolean;
+ experimentalNvidiaCudaExport?: boolean;
+ audioOptions?: {
+ audioMode?: "none" | "copy-source" | "trim-source" | "edited-track";
+ audioSourcePath?: string | null;
+ audioSourceCodec?: string | null;
+ audioSourceSampleRate?: number;
+ outputDurationSec?: number;
+ trimSegments?: Array<{ startMs: number; endMs: number }>;
+ editedTrackStrategy?: "filtergraph-fast-path" | "offline-render-fallback";
+ editedTrackSegments?: Array<{ startMs: number; endMs: number; speed: number }>;
+ editedAudioData?: ArrayBuffer;
+ editedAudioMimeType?: string | null;
+ };
+ }) => Promise<{
+ success: boolean;
+ tempPath?: string;
+ encoderName?: string;
+ error?: string;
+ metrics?: RendererNativeStaticLayoutMetrics;
+ }>;
+ nativeStaticLayoutExportCancel: (sessionId: string) => Promise<{
+ success: boolean;
+ }>;
+ onNativeStaticLayoutExportProgress: (
+ callback: (progress: RendererNativeStaticLayoutProgress) => void,
+ ) => () => void;
+ nativeVideoExportStart: (options: {
+ width: number;
+ height: number;
+ frameRate: number;
+ bitrate: number;
+ encodingMode: "fast" | "balanced" | "quality";
+ inputMode?: "rawvideo" | "h264-stream";
+ }) => Promise<{
+ success: boolean;
+ sessionId?: string;
+ encoderName?: string;
+ error?: string;
+ }>;
+ nativeVideoExportWriteFrame: (
+ sessionId: string,
+ frameData: Uint8Array,
+ ) => Promise<{ success: boolean; error?: string }>;
+ nativeVideoExportWriteFrames: (
+ sessionId: string,
+ frameDataList: Uint8Array[],
+ ) => Promise<{ success: boolean; error?: string }>;
+ nativeVideoExportFinish: (
+ sessionId: string,
+ options?: {
+ audioMode?: "none" | "copy-source" | "trim-source" | "edited-track";
+ audioSourcePath?: string | null;
+ audioSourceCodec?: string | null;
+ audioSourceSampleRate?: number;
+ outputDurationSec?: number;
+ trimSegments?: Array<{ startMs: number; endMs: number }>;
+ editedTrackStrategy?: "filtergraph-fast-path" | "offline-render-fallback";
+ editedTrackSegments?: Array<{ startMs: number; endMs: number; speed: number }>;
+ editedAudioData?: ArrayBuffer;
+ editedAudioMimeType?: string | null;
+ },
+ ) => Promise<{
+ success: boolean;
+ tempPath?: string;
+ encoderName?: string;
+ error?: string;
+ metrics?: RendererFfmpegAudioMuxMetrics;
+ }>;
+ nativeVideoExportCancel: (
+ sessionId: string,
+ ) => Promise<{ success: boolean; error?: string }>;
+ muxExportedVideoAudio: (
+ videoData: ArrayBuffer,
+ options?: {
+ audioMode?: "none" | "copy-source" | "trim-source" | "edited-track";
+ audioSourcePath?: string | null;
+ audioSourceCodec?: string | null;
+ audioSourceSampleRate?: number;
+ outputDurationSec?: number;
+ trimSegments?: Array<{ startMs: number; endMs: number }>;
+ editedTrackStrategy?: "filtergraph-fast-path" | "offline-render-fallback";
+ editedTrackSegments?: Array<{ startMs: number; endMs: number; speed: number }>;
+ editedAudioData?: ArrayBuffer;
+ editedAudioMimeType?: string | null;
+ },
+ ) => Promise<{
+ success: boolean;
+ tempPath?: string;
+ error?: string;
+ metrics?: RendererFfmpegAudioMuxMetrics;
+ }>;
+ muxExportedVideoAudioFromPath: (
+ videoPath: string,
+ options?: {
+ audioMode?: "none" | "copy-source" | "trim-source" | "edited-track";
+ audioSourcePath?: string | null;
+ audioSourceCodec?: string | null;
+ audioSourceSampleRate?: number;
+ outputDurationSec?: number;
+ trimSegments?: Array<{ startMs: number; endMs: number }>;
+ editedTrackStrategy?: "filtergraph-fast-path" | "offline-render-fallback";
+ editedTrackSegments?: Array<{ startMs: number; endMs: number; speed: number }>;
+ editedAudioData?: ArrayBuffer;
+ editedAudioMimeType?: string | null;
+ },
+ ) => Promise<{
+ success: boolean;
+ tempPath?: string;
+ error?: string;
+ metrics?: RendererFfmpegAudioMuxMetrics;
+ }>;
+ openExportStream: (options?: { extension?: string }) => Promise<{
+ success: boolean;
+ streamId?: string;
+ tempPath?: string;
+ error?: string;
+ }>;
+ writeExportStreamChunk: (
+ streamId: string,
+ position: number,
+ chunk: Uint8Array,
+ ) => Promise<{ success: boolean; error?: string }>;
+ closeExportStream: (
+ streamId: string,
+ options?: { abort?: boolean },
+ ) => Promise<{
+ success: boolean;
+ tempPath?: string;
+ bytesWritten?: number;
+ error?: string;
+ }>;
+ finalizeExportedVideo: (payload: {
+ tempPath: string;
+ fileName: string;
+ outputPath?: string | null;
+ captionSidecar?: {
+ format: "srt" | "vtt" | "both";
+ cues: Array<{
+ startMs: number;
+ endMs: number;
+ text: string;
+ }>;
+ };
+ }) => Promise<{
+ success: boolean;
+ path?: string;
+ canceled?: boolean;
+ message?: string;
+ error?: string;
+ }>;
+ discardExportedTemp: (tempPath: string) => Promise<{ success: boolean; error?: string }>;
+ getVideoAudioFallbackPaths: (videoPath: string) => Promise<{
+ success: boolean;
+ paths: string[];
+ startDelayMsByPath?: Record;
+ error?: string;
+ }>;
+ setRecordingState: (recording: boolean) => Promise;
+ getCursorTelemetry: (videoPath?: string) => Promise<{
+ success: boolean;
+ samples: CursorTelemetryPoint[];
+ message?: string;
+ error?: string;
+ }>;
+ setCursorTelemetry: (
+ videoPath: string | undefined,
+ samples: CursorTelemetryPoint[],
+ ) => Promise<{
+ success: boolean;
+ samples: CursorTelemetryPoint[];
+ message?: string;
+ error?: string;
+ }>;
+ getSystemCursorAssets: () => Promise<{
+ success: boolean;
+ cursors: Record;
+ error?: string;
+ }>;
+ onStopRecordingFromTray: (callback: () => void) => () => void;
+ onRecordingStateChanged: (
+ callback: (state: { recording: boolean; sourceName: string }) => void,
+ ) => () => void;
+ onRecordingSessionChanged: (
+ callback: (session: RendererRecordingSessionData | null) => void,
+ ) => () => void;
+ onRecordingInterrupted: (
+ callback: (state: { reason: string; message: string }) => void,
+ ) => () => void;
+ onCursorStateChanged: (
+ callback: (state: { cursorType: CursorTelemetryPoint["cursorType"] }) => void,
+ ) => () => void;
+ openExternalUrl: (url: string) => Promise<{ success: boolean; error?: string }>;
+ getAccessibilityPermissionStatus: () => Promise<{
+ success: boolean;
+ trusted: boolean;
+ prompted: boolean;
+ error?: string;
+ }>;
+ requestAccessibilityPermission: () => Promise<{
+ success: boolean;
+ trusted: boolean;
+ prompted: boolean;
+ error?: string;
+ }>;
+ getScreenRecordingPermissionStatus: () => Promise<{
+ success: boolean;
+ status: string;
+ error?: string;
+ }>;
+ openScreenRecordingPreferences: () => Promise<{ success: boolean; error?: string }>;
+ openAccessibilityPreferences: () => Promise<{ success: boolean; error?: string }>;
+ saveExportedVideo: (
+ videoData: ArrayBuffer,
+ fileName: string,
+ captionSidecar?: {
+ format: "srt" | "vtt" | "both";
+ cues: Array<{
+ startMs: number;
+ endMs: number;
+ text: string;
+ }>;
+ },
+ ) => Promise<{ success: boolean; path?: string; message?: string; canceled?: boolean }>;
+ writeExportedVideoToPath: (
+ videoData: ArrayBuffer,
+ outputPath: string,
+ captionSidecar?: {
+ format: "srt" | "vtt" | "both";
+ cues: Array<{
+ startMs: number;
+ endMs: number;
+ text: string;
+ }>;
+ },
+ ) => Promise<{
+ success: boolean;
+ path?: string;
+ message?: string;
+ error?: string;
+ canceled?: boolean;
+ }>;
+ openVideoFilePicker: (options?: { includeProjects?: boolean }) => Promise<{
+ success: boolean;
+ kind?: "media" | "project";
+ path?: string;
+ project?: unknown;
+ extension?: string;
+ message?: string;
+ canceled?: boolean;
+ error?: string;
+ }>;
+ openAudioFilePicker: () => Promise<{ success: boolean; path?: string; canceled?: boolean }>;
+ openWhisperExecutablePicker: () => Promise<{
+ success: boolean;
+ path?: string;
+ canceled?: boolean;
+ error?: string;
+ }>;
+ openWhisperModelPicker: () => Promise<{
+ success: boolean;
+ path?: string;
+ canceled?: boolean;
+ error?: string;
+ }>;
+ getWhisperSmallModelStatus: () => Promise<{
+ success: boolean;
+ exists: boolean;
+ path?: string | null;
+ error?: string;
+ }>;
+ downloadWhisperSmallModel: () => Promise<{
+ success: boolean;
+ path?: string;
+ alreadyDownloaded?: boolean;
+ error?: string;
+ }>;
+ deleteWhisperSmallModel: () => Promise<{ success: boolean; error?: string }>;
+ onWhisperSmallModelDownloadProgress: (
+ callback: (state: {
+ status: "idle" | "downloading" | "downloaded" | "error";
+ progress: number;
+ path?: string | null;
+ error?: string;
+ }) => void,
+ ) => () => void;
+ generateAutoCaptions: (options: {
+ videoPath: string;
+ whisperExecutablePath?: string;
+ whisperModelPath: string;
+ language?: string;
+ }) => Promise<{
+ success: boolean;
+ cues?: AutoCaptionCue[];
+ message?: string;
+ error?: string;
+ }>;
+ setCurrentVideoPath: (
+ path: string,
+ options?: {
+ preserveProjectPath?: boolean;
+ hideOverlayCursorByDefault?: boolean;
+ },
+ ) => Promise<{ success: boolean; webcamPath: string | null }>;
+ setCurrentRecordingSession: (
+ session: {
+ videoPath: string;
+ webcamPath?: string | null;
+ timeOffsetMs?: number;
+ hideOverlayCursorByDefault?: boolean;
+ },
+ options?: { preserveProjectPath?: boolean },
+ ) => Promise<{ success: boolean }>;
+ getCurrentRecordingSession: () => Promise<{
+ success: boolean;
+ session?: {
+ videoPath: string;
+ webcamPath?: string | null;
+ timeOffsetMs?: number;
+ hideOverlayCursorByDefault?: boolean;
+ };
+ }>;
+ getCurrentVideoPath: () => Promise<{ success: boolean; path?: string }>;
+ clearCurrentVideoPath: () => Promise<{ success: boolean }>;
+ getRecordingThumbnail: (
+ filePath: string,
+ ) => Promise>;
+ finishRecordingImport: (
+ keepPath: string,
+ commit?: boolean,
+ ) => Promise<{ success: boolean; error?: string }>;
+ cancelRecordingImport: () => Promise<{ success: boolean }>;
+ getProjectPreview: (
+ projectPath: string,
+ ) => Promise<
+ import("../src/types/recordingLibrary").LibraryResult<
+ import("../src/types/projectPreview").ProjectPreviewData
+ >
+ >;
+ listRecordings: (
+ includeSources?: boolean,
+ ) => Promise<
+ import("../src/types/recordingLibrary").LibraryResult<
+ import("../src/types/recordingLibrary").RecordingLibraryEntry[]
+ >
+ >;
+ setRecordingsRemoved: (
+ paths: string[],
+ removed: boolean,
+ ) => Promise>;
+ importRecording: (
+ currentPath: string,
+ recordingPath: string,
+ webcam?: import("../src/types/recordingLibrary").RecordingWebcamSource,
+ ) => Promise<
+ import("../src/types/recordingLibrary").LibraryResult<
+ import("../src/types/recordingLibrary").RecordingImportResult
+ >
+ >;
+ deleteRecordingFile: (filePath: string) => Promise<{ success: boolean; error?: string }>;
+ getLocalMediaUrl: (
+ filePath: string,
+ ) => Promise<{ success: true; url: string } | { success: false }>;
+ saveProjectFile: (
+ projectData: unknown,
+ suggestedName?: string,
+ existingProjectPath?: string,
+ thumbnailDataUrl?: string | null,
+ ) => Promise<{
+ success: boolean;
+ path?: string;
+ projectId?: string;
+ message?: string;
+ canceled?: boolean;
+ error?: string;
+ }>;
+ saveProjectFileNamed: (
+ projectData: unknown,
+ projectName: string,
+ thumbnailDataUrl?: string | null,
+ mode?: "rename" | "copy",
+ ) => Promise<{
+ success: boolean;
+ path?: string;
+ projectId?: string;
+ message?: string;
+ canceled?: boolean;
+ error?: string;
+ }>;
+ loadProjectFile: () => Promise<{
+ success: boolean;
+ path?: string;
+ project?: unknown;
+ message?: string;
+ canceled?: boolean;
+ error?: string;
+ }>;
+ loadCurrentProjectFile: () => Promise<{
+ success: boolean;
+ path?: string;
+ project?: unknown;
+ message?: string;
+ canceled?: boolean;
+ error?: string;
+ }>;
+ getProjectsDirectory: () => Promise<{
+ success: boolean;
+ path?: string;
+ error?: string;
+ }>;
+ showRecordingHud: () => Promise;
+ createProjectFile: (
+ data: unknown,
+ thumbnail?: string | null,
+ ) => Promise<{
+ success: boolean;
+ path?: string;
+ projectId?: string;
+ message?: string;
+ canceled?: boolean;
+ }>;
+ renameLibraryProject: (
+ path: string,
+ name: string,
+ ) => Promise<{ success: boolean; path?: string; error?: string }>;
+ trashProjectFiles: (
+ paths: string[],
+ ) => Promise<{ success: boolean; deleted: string[]; errors: string[] }>;
+ listProjectFiles: () => Promise<{
+ success: boolean;
+ projectsDir?: string | null;
+ entries: Array<{
+ path: string;
+ name: string;
+ createdAt?: number;
+ updatedAt: number;
+ thumbnailPath: string | null;
+ isCurrent: boolean;
+ isInProjectsDirectory: boolean;
+ }>;
+ error?: string;
+ }>;
+ openProjectFileAtPath: (filePath: string) => Promise<{
+ success: boolean;
+ path?: string;
+ project?: unknown;
+ message?: string;
+ canceled?: boolean;
+ error?: string;
+ }>;
+ openProjectsDirectory: () => Promise<{
+ success: boolean;
+ path?: string;
+ message?: string;
+ error?: string;
+ }>;
+ installDownloadedUpdate: () => Promise<{ success: boolean }>;
+ downloadAvailableUpdate: (
+ installAfterDownload?: boolean,
+ ) => Promise<{ success: boolean; message?: string }>;
+ deferDownloadedUpdate: (delayMs?: number) => Promise<{
+ success: boolean;
+ message?: string;
+ }>;
+ dismissUpdateToast: () => Promise<{ success: boolean }>;
+ skipUpdateVersion: () => Promise<{ success: boolean; message?: string }>;
+ getCurrentUpdateToastPayload: () => Promise;
+ getUpdateStatusSummary: () => Promise;
+ getExperimentalUpdatesEnabled: () => Promise;
+ setExperimentalUpdatesEnabled: (enabled: boolean) => Promise<{
+ success: boolean;
+ enabled: boolean;
+ error?: string;
+ }>;
+ previewUpdateToast: () => Promise<{ success: boolean }>;
+ checkForAppUpdates: () => Promise<{ success: boolean; logPath: string }>;
+ onUpdateToastStateChanged: (
+ callback: (payload: UpdateToastState | null) => void,
+ ) => () => void;
+ onUpdateReadyToast: (
+ callback: (payload: {
+ version: string;
+ detail: string;
+ delayMs: number;
+ isPreview?: boolean;
+ }) => void,
+ ) => () => void;
+ onMenuLoadProject: (callback: () => void) => () => void;
+ onMenuSaveProject: (callback: () => void) => () => void;
+ onMenuSaveProjectAs: (callback: () => void) => () => void;
+ getWindowChrome: () => Promise<{ trafficLightsVisible: boolean }>;
+ onWindowChromeChanged: (
+ callback: (chrome: { trafficLightsVisible: boolean }) => void,
+ ) => () => void;
+ getPlatform: () => Promise;
+ isWindowFullscreen: () => Promise;
+ onWindowFullscreenChanged: (callback: (isFullscreen: boolean) => void) => () => void;
+ getLinuxWindowSystem: () => Promise<"wayland" | "x11" | null>;
+ ackAuthCallbackUrl: (url: string) => Promise;
+ getPendingAuthCallbackUrl: () => Promise;
+ onAuthCallbackUrl: (callback: (url: string) => void) => () => void;
+ revealInFolder: (
+ filePath: string,
+ ) => Promise<{ success: boolean; error?: string; message?: string }>;
+ cloudShareUpload: (input: {
+ filePath: string;
+ endpoint: string;
+ token?: string;
+ title?: string;
+ notes?: string;
+ uploadId?: string;
+ }) => Promise<{
+ success: boolean;
+ uploadId?: string;
+ shareUrl?: string;
+ canceled?: boolean;
+ error?: string;
+ }>;
+ cloudShareCancel: (uploadId: string) => Promise<{ success: boolean }>;
+ onCloudShareProgress: (
+ callback: (progress: {
+ uploadId: string;
+ uploadedBytes: number;
+ totalBytes: number;
+ }) => void,
+ ) => () => void;
+ openRecordingsFolder: () => Promise<{ success: boolean; error?: string; message?: string }>;
+ getRecordingsDirectory: () => Promise<{
+ success: boolean;
+ path: string;
+ isDefault: boolean;
+ error?: string;
+ }>;
+ chooseRecordingsDirectory: () => Promise<{
+ success: boolean;
+ canceled?: boolean;
+ path?: string;
+ isDefault?: boolean;
+ message?: string;
+ error?: string;
+ }>;
+ getShortcuts: () => Promise | null>;
+ saveShortcuts: (shortcuts: unknown) => Promise<{ success: boolean; error?: string }>;
+ getAppSetting: (key: string) => unknown;
+ setAppSetting: (key: string, value: unknown) => boolean;
+ setHasUnsavedChanges: (hasChanges: boolean) => void;
+ onRequestSaveBeforeClose: (callback: () => Promise) => () => void;
+ isNativeWindowsCaptureAvailable: () => Promise<{ available: boolean }>;
+ muxNativeWindowsRecording: (expectedDurationMs?: number) => Promise<{
+ success: boolean;
+ path?: string;
+ message?: string;
+ error?: string;
+ }>;
+ /** Returns the app version from package.json */
+ getAppVersion: () => Promise;
+ /** Returns the configured remote announcement feed, or null when unavailable. */
+ getAnnouncements: () => Promise;
+ /** Hide the OS cursor before browser capture starts. */
+ hideOsCursor: () => Promise<{ success: boolean }>;
+ /** Recording preferences (mic, system audio, webcam) */
+ getRecordingPreferences: () => Promise<{
+ success: boolean;
+ microphoneEnabled: boolean;
+ microphoneDeviceId?: string;
+ systemAudioEnabled: boolean;
+ webcamEnabled: boolean;
+ webcamDeviceId?: string;
+ }>;
+ getRecordingAudioLabConfig: () => Promise<{
+ browserMicrophoneProfile: string;
+ requestedBrowserMicrophoneProfile: string | null;
+ }>;
+ setRecordingPreferences: (prefs: {
+ microphoneEnabled?: boolean;
+ microphoneDeviceId?: string;
+ systemAudioEnabled?: boolean;
+ webcamEnabled?: boolean;
+ webcamDeviceId?: string;
+ }) => Promise<{ success: boolean; error?: string }>;
+ /** Countdown timer before recording */
+ getCountdownDelay: () => Promise<{ success: boolean; delay: number }>;
+ setCountdownDelay: (delay: number) => Promise<{ success: boolean; error?: string }>;
+ finishRecordingStartup: () => Promise;
+ startCountdown: (seconds: number) => Promise<{ success: boolean; cancelled?: boolean }>;
+ cancelCountdown: () => Promise<{ success: boolean }>;
+ getActiveCountdown: () => Promise<{ success: boolean; seconds: number | null }>;
+ onCountdownTick: (callback: (seconds: number) => void) => () => void;
+ };
+}
+
+interface ProcessedDesktopSource {
+ id: string;
+ name: string;
+ display_id: string;
+ thumbnail: string | null;
+ appIcon: string | null;
+ originalName?: string;
+ sourceType?: "screen" | "window";
+ appName?: string;
+ windowTitle?: string;
+}
+
+interface CursorTelemetryPoint {
+ timeMs: number;
+ cx: number;
+ cy: number;
+ pressure?: number;
+ interactionType?:
+ | "move"
+ | "click"
+ | "double-click"
+ | "right-click"
+ | "middle-click"
+ | "mouseup";
+ cursorType?:
+ | "arrow"
+ | "text"
+ | "pointer"
+ | "crosshair"
+ | "open-hand"
+ | "closed-hand"
+ | "resize-ew"
+ | "resize-ns"
+ | "not-allowed";
+}
+
+interface SystemCursorAsset {
+ dataUrl: string;
+ hotspotX: number;
+ hotspotY: number;
+ width: number;
+ height: number;
+}
+
+interface AutoCaptionCue {
+ id: string;
+ startMs: number;
+ endMs: number;
+ text: string;
+ words?: Array<{
+ text: string;
+ startMs: number;
+ endMs: number;
+ leadingSpace?: boolean;
+ }>;
+}
diff --git a/electron/gpuSwitches.test.ts b/electron/gpuSwitches.test.ts
new file mode 100644
index 000000000..4cbb1cdf4
--- /dev/null
+++ b/electron/gpuSwitches.test.ts
@@ -0,0 +1,67 @@
+import { describe, expect, it } from "vitest";
+
+import { getGpuSwitches, shouldForceLinuxEgl } from "./gpuSwitches";
+
+describe("shouldForceLinuxEgl", () => {
+ it("does not force EGL in a Wayland session", () => {
+ expect(
+ shouldForceLinuxEgl({
+ XDG_SESSION_TYPE: "wayland",
+ WAYLAND_DISPLAY: "wayland-0",
+ }),
+ ).toBe(false);
+ });
+
+ it("does not force EGL when Wayland is explicitly requested via Ozone", () => {
+ expect(
+ shouldForceLinuxEgl({
+ OZONE_PLATFORM: "wayland",
+ XDG_SESSION_TYPE: "x11",
+ }),
+ ).toBe(false);
+ });
+
+ it("falls back to Electron's ozone hint when OZONE_PLATFORM is invalid", () => {
+ expect(
+ shouldForceLinuxEgl({
+ OZONE_PLATFORM: "auto",
+ ELECTRON_OZONE_PLATFORM_HINT: "wayland",
+ XDG_SESSION_TYPE: "x11",
+ }),
+ ).toBe(false);
+ });
+
+ it("forces EGL in an X11 session", () => {
+ expect(shouldForceLinuxEgl({ XDG_SESSION_TYPE: "x11" })).toBe(true);
+ });
+
+ it("forces EGL when x11 is explicitly requested via Electron's ozone hint", () => {
+ expect(
+ shouldForceLinuxEgl({
+ ELECTRON_OZONE_PLATFORM_HINT: "x11",
+ WAYLAND_DISPLAY: "wayland-0",
+ }),
+ ).toBe(true);
+ });
+});
+
+describe("getGpuSwitches", () => {
+ it("returns the Linux VAAPI workaround without forcing EGL on Wayland", () => {
+ expect(
+ getGpuSwitches("linux", {
+ XDG_SESSION_TYPE: "wayland",
+ WAYLAND_DISPLAY: "wayland-0",
+ }),
+ ).toEqual({
+ useGl: undefined,
+ disableFeatures: ["VaapiVideoDecoder", "VaapiVideoEncoder"],
+ });
+ });
+
+ it("returns the X11 EGL workaround on Linux X11", () => {
+ expect(getGpuSwitches("linux", { XDG_SESSION_TYPE: "x11" })).toEqual({
+ useGl: "egl",
+ disableFeatures: ["VaapiVideoDecoder", "VaapiVideoEncoder"],
+ });
+ });
+});
diff --git a/electron/gpuSwitches.ts b/electron/gpuSwitches.ts
new file mode 100644
index 000000000..7b7c81ee8
--- /dev/null
+++ b/electron/gpuSwitches.ts
@@ -0,0 +1,66 @@
+export interface GpuSwitches {
+ useAngle?: string;
+ useGl?: string;
+ disableFeatures?: string[];
+}
+
+function normalizeLinuxWindowSystem(value: string | undefined): "wayland" | "x11" | null {
+ const normalized = value?.trim().toLowerCase();
+ if (normalized === "wayland" || normalized === "x11") {
+ return normalized;
+ }
+
+ return null;
+}
+
+function getForcedLinuxWindowSystem(env: NodeJS.ProcessEnv): "wayland" | "x11" | null {
+ return (
+ normalizeLinuxWindowSystem(env.OZONE_PLATFORM) ??
+ normalizeLinuxWindowSystem(env.ELECTRON_OZONE_PLATFORM_HINT)
+ );
+}
+
+export function shouldForceLinuxEgl(env: NodeJS.ProcessEnv): boolean {
+ const forcedWindowSystem = getForcedLinuxWindowSystem(env);
+ if (forcedWindowSystem === "wayland") {
+ return false;
+ }
+ if (forcedWindowSystem === "x11") {
+ return true;
+ }
+
+ const sessionType = env.XDG_SESSION_TYPE?.toLowerCase();
+ if (sessionType === "wayland") {
+ return false;
+ }
+ if (sessionType === "x11") {
+ return true;
+ }
+
+ return !env.WAYLAND_DISPLAY;
+}
+
+export function getGpuSwitches(
+ platform: NodeJS.Platform,
+ env: NodeJS.ProcessEnv = process.env,
+): GpuSwitches {
+ if (platform === "darwin") {
+ return {
+ useAngle: "metal",
+ disableFeatures: ["MacCatapLoopbackAudioForScreenShare"],
+ };
+ }
+
+ if (platform === "win32") {
+ return { useAngle: "d3d11" };
+ }
+
+ if (platform === "linux") {
+ return {
+ useGl: shouldForceLinuxEgl(env) ? "egl" : undefined,
+ disableFeatures: ["VaapiVideoDecoder", "VaapiVideoEncoder"],
+ };
+ }
+
+ return {};
+}
diff --git a/electron/hudCaptureProtection.test.ts b/electron/hudCaptureProtection.test.ts
new file mode 100644
index 000000000..28f807222
--- /dev/null
+++ b/electron/hudCaptureProtection.test.ts
@@ -0,0 +1,30 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ getHudCaptureExcludedProcessIds,
+ shouldProtectHudCapture,
+ supportsHudCaptureProtection,
+} from "../src/lib/hudCaptureProtection";
+
+describe("HUD capture protection lifecycle", () => {
+ it("uses window protection on Windows and macOS only", () => {
+ expect(supportsHudCaptureProtection("win32")).toBe(true);
+ expect(supportsHudCaptureProtection("darwin")).toBe(true);
+ expect(supportsHudCaptureProtection("linux")).toBe(false);
+ });
+
+ it("only builds a macOS process exclusion when protection is enabled", () => {
+ expect(getHudCaptureExcludedProcessIds("darwin", true, 734)).toEqual([734]);
+ expect(getHudCaptureExcludedProcessIds("darwin", false, 734)).toEqual([]);
+ expect(getHudCaptureExcludedProcessIds("win32", true, 734)).toEqual([]);
+ expect(getHudCaptureExcludedProcessIds("linux", true, 734)).toEqual([]);
+ });
+});
+
+it("protects initial capture frames while keeping idle and failed starts visible", () => {
+ expect(shouldProtectHudCapture(true, false, false)).toBe(false);
+ expect(shouldProtectHudCapture(true, false, true)).toBe(true);
+ expect(shouldProtectHudCapture(true, true, false)).toBe(true);
+ expect(shouldProtectHudCapture(true, false, false)).toBe(false);
+ expect(shouldProtectHudCapture(false, true, true)).toBe(false);
+});
diff --git a/electron/hudEditorMode.test.ts b/electron/hudEditorMode.test.ts
new file mode 100644
index 000000000..b8d886845
--- /dev/null
+++ b/electron/hudEditorMode.test.ts
@@ -0,0 +1,13 @@
+import { expect, it } from "vitest";
+import { isHudInEditorMode } from "./hudEditorMode";
+it("allows the webcam when New prepares a recording despite an existing editor", () => {
+ expect(isHudInEditorMode(1, false, false)).toBe(true);
+ expect(isHudInEditorMode(1, true, false)).toBe(false);
+ expect(isHudInEditorMode(1, true, true)).toBe(false);
+ // Cancelling leaves the HUD ready to retry, including its webcam preview.
+ expect(isHudInEditorMode(1, true, false)).toBe(false);
+ // Returning Home stops an idle camera, but does not stop an active capture preview.
+ expect(isHudInEditorMode(1, false, false)).toBe(true);
+ expect(isHudInEditorMode(1, false, true)).toBe(false);
+ expect(isHudInEditorMode(0, false, false)).toBe(false);
+});
diff --git a/electron/hudEditorMode.ts b/electron/hudEditorMode.ts
new file mode 100644
index 000000000..f87ddcd6e
--- /dev/null
+++ b/electron/hudEditorMode.ts
@@ -0,0 +1,8 @@
+/** An editor may stay open behind the HUD while a new capture is prepared. */
+export function isHudInEditorMode(
+ editorCount: number,
+ preparingRecording: boolean,
+ recording: boolean,
+) {
+ return editorCount > 0 && !preparingRecording && !recording;
+}
diff --git a/electron/hudOverlayBounds.test.ts b/electron/hudOverlayBounds.test.ts
new file mode 100644
index 000000000..db21e92bd
--- /dev/null
+++ b/electron/hudOverlayBounds.test.ts
@@ -0,0 +1,188 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ getHudOverlayWindowBounds,
+ resizeHudOverlayFallbackBounds,
+ shouldExpandHudOverlayFallback,
+} from "./hudOverlayBounds";
+
+describe("getHudOverlayWindowBounds", () => {
+ const workArea = {
+ x: 120,
+ y: 40,
+ width: 1920,
+ height: 1040,
+ };
+
+ it("uses the full work area when mouse passthrough is supported", () => {
+ expect(getHudOverlayWindowBounds(workArea, true)).toEqual(workArea);
+ });
+
+ it("uses a bottom-centered compact fallback when mouse passthrough is unavailable", () => {
+ expect(getHudOverlayWindowBounds(workArea, false)).toEqual({
+ x: 650,
+ y: 920,
+ width: 860,
+ height: 160,
+ });
+ });
+
+ it("expands the non-passthrough fallback for HUD menus and hover interaction", () => {
+ expect(getHudOverlayWindowBounds(workArea, false, true)).toEqual({
+ x: 650,
+ y: 540,
+ width: 860,
+ height: 540,
+ });
+ });
+
+ it("keeps the compact fallback inside small displays", () => {
+ expect(
+ getHudOverlayWindowBounds(
+ {
+ x: -100,
+ y: 20,
+ width: 640,
+ height: 420,
+ },
+ false,
+ ),
+ ).toEqual({
+ x: -100,
+ y: 280,
+ width: 640,
+ height: 160,
+ });
+ });
+
+ it("fits the expanded fallback inside small displays", () => {
+ expect(
+ getHudOverlayWindowBounds(
+ {
+ x: -100,
+ y: 20,
+ width: 640,
+ height: 420,
+ },
+ false,
+ true,
+ ),
+ ).toEqual({
+ x: -100,
+ y: 20,
+ width: 640,
+ height: 420,
+ });
+ });
+});
+
+describe("resizeHudOverlayFallbackBounds", () => {
+ const workArea = {
+ x: 0,
+ y: 0,
+ width: 1920,
+ height: 1080,
+ };
+
+ it("preserves the dragged bottom edge when expanding", () => {
+ expect(
+ resizeHudOverlayFallbackBounds(
+ workArea,
+ {
+ x: 420,
+ y: 700,
+ width: 860,
+ height: 160,
+ },
+ true,
+ ),
+ ).toEqual({
+ x: 420,
+ y: 320,
+ width: 860,
+ height: 540,
+ });
+ });
+
+ it("preserves the dragged bottom edge when compacting", () => {
+ expect(
+ resizeHudOverlayFallbackBounds(
+ workArea,
+ {
+ x: 420,
+ y: 320,
+ width: 860,
+ height: 540,
+ },
+ false,
+ ),
+ ).toEqual({
+ x: 420,
+ y: 700,
+ width: 860,
+ height: 160,
+ });
+ });
+
+ it("keeps resized fallback bounds inside the display work area", () => {
+ expect(
+ resizeHudOverlayFallbackBounds(
+ workArea,
+ {
+ x: 1500,
+ y: 900,
+ width: 860,
+ height: 160,
+ },
+ true,
+ ),
+ ).toEqual({
+ x: 1060,
+ y: 520,
+ width: 860,
+ height: 540,
+ });
+ });
+});
+
+describe("shouldExpandHudOverlayFallback", () => {
+ it("expands while recording only when the floating webcam preview is visible", () => {
+ expect(
+ shouldExpandHudOverlayFallback({
+ fallbackExpanded: false,
+ recordingActive: true,
+ webcamPreviewVisible: true,
+ }),
+ ).toBe(true);
+ });
+
+ it("keeps the compact recording fallback when there is no webcam preview", () => {
+ expect(
+ shouldExpandHudOverlayFallback({
+ fallbackExpanded: false,
+ recordingActive: true,
+ webcamPreviewVisible: false,
+ }),
+ ).toBe(false);
+ });
+
+ it("preserves manual fallback expansion outside recording", () => {
+ expect(
+ shouldExpandHudOverlayFallback({
+ fallbackExpanded: true,
+ recordingActive: false,
+ webcamPreviewVisible: false,
+ }),
+ ).toBe(true);
+ });
+
+ it("does not expand for webcam visibility outside recording", () => {
+ expect(
+ shouldExpandHudOverlayFallback({
+ fallbackExpanded: false,
+ recordingActive: false,
+ webcamPreviewVisible: true,
+ }),
+ ).toBe(false);
+ });
+});
diff --git a/electron/hudOverlayBounds.ts b/electron/hudOverlayBounds.ts
new file mode 100644
index 000000000..8c51b88c7
--- /dev/null
+++ b/electron/hudOverlayBounds.ts
@@ -0,0 +1,67 @@
+export interface HudOverlayWorkArea {
+ x: number;
+ y: number;
+ width: number;
+ height: number;
+}
+
+const NON_PASSTHROUGH_HUD_WIDTH_DIP = 860;
+const NON_PASSTHROUGH_HUD_COMPACT_HEIGHT_DIP = 160;
+const NON_PASSTHROUGH_HUD_EXPANDED_HEIGHT_DIP = 540;
+
+function clamp(value: number, min: number, max: number): number {
+ return Math.min(Math.max(value, min), max);
+}
+
+export function getHudOverlayWindowBounds(
+ workArea: HudOverlayWorkArea,
+ mousePassthroughSupported: boolean,
+ fallbackExpanded = false,
+): HudOverlayWorkArea {
+ if (mousePassthroughSupported) {
+ return { ...workArea };
+ }
+
+ const width = Math.min(workArea.width, NON_PASSTHROUGH_HUD_WIDTH_DIP);
+ const height = Math.min(
+ workArea.height,
+ fallbackExpanded
+ ? NON_PASSTHROUGH_HUD_EXPANDED_HEIGHT_DIP
+ : NON_PASSTHROUGH_HUD_COMPACT_HEIGHT_DIP,
+ );
+
+ return {
+ x: Math.round(workArea.x + (workArea.width - width) / 2),
+ y: Math.round(workArea.y + workArea.height - height),
+ width,
+ height,
+ };
+}
+
+export function shouldExpandHudOverlayFallback({
+ fallbackExpanded,
+ recordingActive,
+ webcamPreviewVisible,
+}: {
+ fallbackExpanded: boolean;
+ recordingActive: boolean;
+ webcamPreviewVisible: boolean;
+}): boolean {
+ return fallbackExpanded || (recordingActive && webcamPreviewVisible);
+}
+
+export function resizeHudOverlayFallbackBounds(
+ workArea: HudOverlayWorkArea,
+ currentBounds: HudOverlayWorkArea,
+ fallbackExpanded: boolean,
+): HudOverlayWorkArea {
+ const nextBounds = getHudOverlayWindowBounds(workArea, false, fallbackExpanded);
+ const maxX = workArea.x + workArea.width - nextBounds.width;
+ const maxY = workArea.y + workArea.height - nextBounds.height;
+
+ return {
+ ...nextBounds,
+ x: clamp(currentBounds.x, workArea.x, maxX),
+ y: clamp(currentBounds.y + currentBounds.height - nextBounds.height, workArea.y, maxY),
+ };
+}
diff --git a/electron/hudOverlayWindowOptions.test.ts b/electron/hudOverlayWindowOptions.test.ts
new file mode 100644
index 000000000..08f8c6531
--- /dev/null
+++ b/electron/hudOverlayWindowOptions.test.ts
@@ -0,0 +1,21 @@
+import { describe, expect, it } from "vitest";
+import { getHudOverlayTaskbarOptions } from "./hudOverlayWindowOptions";
+
+describe("getHudOverlayTaskbarOptions", () => {
+ it("keeps a focusable HUD in the Windows taskbar", () => {
+ expect(getHudOverlayTaskbarOptions("win32")).toEqual({
+ skipTaskbar: false,
+ focusable: true,
+ });
+ });
+
+ it.each([
+ "darwin",
+ "linux",
+ ] as const)("keeps the HUD non-focusable and out of the taskbar on %s", (platform) => {
+ expect(getHudOverlayTaskbarOptions(platform)).toEqual({
+ skipTaskbar: true,
+ focusable: false,
+ });
+ });
+});
diff --git a/electron/hudOverlayWindowOptions.ts b/electron/hudOverlayWindowOptions.ts
new file mode 100644
index 000000000..27fcd2dea
--- /dev/null
+++ b/electron/hudOverlayWindowOptions.ts
@@ -0,0 +1,12 @@
+export interface HudOverlayTaskbarOptions {
+ skipTaskbar: boolean;
+ focusable: boolean;
+}
+
+export function getHudOverlayTaskbarOptions(platform: NodeJS.Platform): HudOverlayTaskbarOptions {
+ const showInWindowsTaskbar = platform === "win32";
+ return {
+ skipTaskbar: !showInWindowsTaskbar,
+ focusable: showInWindowsTaskbar,
+ };
+}
diff --git a/electron/ipc/captions/audioCandidates.test.ts b/electron/ipc/captions/audioCandidates.test.ts
new file mode 100644
index 000000000..c08b49ff1
--- /dev/null
+++ b/electron/ipc/captions/audioCandidates.test.ts
@@ -0,0 +1,33 @@
+import { describe, expect, it } from "vitest";
+import { getCaptionCompanionAudioCandidates } from "./audioCandidates";
+
+describe("getCaptionCompanionAudioCandidates", () => {
+ it("prefers microphone audio and includes system audio when both sidecars exist", () => {
+ expect(
+ getCaptionCompanionAudioCandidates([
+ {
+ platform: "win",
+ micPath: "recording.mic.wav",
+ systemPath: "recording.system.wav",
+ usablePaths: ["recording.system.wav", "recording.mic.wav"],
+ },
+ ]),
+ ).toEqual([
+ { path: "recording.mic.wav", label: "microphone audio sidecar" },
+ { path: "recording.system.wav", label: "system audio sidecar" },
+ ]);
+ });
+
+ it("omits companion paths that were not found on disk", () => {
+ expect(
+ getCaptionCompanionAudioCandidates([
+ {
+ platform: "win",
+ micPath: "recording.mic.wav",
+ systemPath: "recording.system.wav",
+ usablePaths: ["recording.mic.wav"],
+ },
+ ]),
+ ).toEqual([{ path: "recording.mic.wav", label: "microphone audio sidecar" }]);
+ });
+});
diff --git a/electron/ipc/captions/audioCandidates.ts b/electron/ipc/captions/audioCandidates.ts
new file mode 100644
index 000000000..33e62d2e1
--- /dev/null
+++ b/electron/ipc/captions/audioCandidates.ts
@@ -0,0 +1,21 @@
+import type { CompanionAudioCandidate } from "../types";
+
+export type CaptionAudioCandidate = {
+ path: string;
+ label: string;
+};
+
+export function getCaptionCompanionAudioCandidates(
+ companions: CompanionAudioCandidate[],
+): CaptionAudioCandidate[] {
+ return companions.flatMap((companion) => {
+ const candidates: CaptionAudioCandidate[] = [];
+ if (companion.usablePaths.includes(companion.micPath)) {
+ candidates.push({ path: companion.micPath, label: "microphone audio sidecar" });
+ }
+ if (companion.usablePaths.includes(companion.systemPath)) {
+ candidates.push({ path: companion.systemPath, label: "system audio sidecar" });
+ }
+ return candidates;
+ });
+}
diff --git a/electron/ipc/captions/generate.test.ts b/electron/ipc/captions/generate.test.ts
new file mode 100644
index 000000000..9844fecc8
--- /dev/null
+++ b/electron/ipc/captions/generate.test.ts
@@ -0,0 +1,16 @@
+import { describe, expect, it } from "vitest";
+import { isMissingWindowsWhisperRuntimeDependency } from "./runtimeErrors";
+
+describe("isMissingWindowsWhisperRuntimeDependency", () => {
+ it("recognizes the signed and unsigned STATUS_DLL_NOT_FOUND exit codes on Windows", () => {
+ if (process.platform !== "win32") return;
+
+ expect(isMissingWindowsWhisperRuntimeDependency({ code: -1073741515 })).toBe(true);
+ expect(isMissingWindowsWhisperRuntimeDependency({ code: 3221225781 })).toBe(true);
+ });
+
+ it("does not classify ordinary Whisper failures as missing runtimes", () => {
+ expect(isMissingWindowsWhisperRuntimeDependency({ code: 1 })).toBe(false);
+ expect(isMissingWindowsWhisperRuntimeDependency(new Error("bad model"))).toBe(false);
+ });
+});
diff --git a/electron/ipc/captions/generate.ts b/electron/ipc/captions/generate.ts
new file mode 100644
index 000000000..1e1184348
--- /dev/null
+++ b/electron/ipc/captions/generate.ts
@@ -0,0 +1,367 @@
+import { execFile, spawnSync } from "node:child_process";
+import { constants as fsConstants } from "node:fs";
+import fs from "node:fs/promises";
+import path from "node:path";
+import { promisify } from "node:util";
+import { app } from "electron";
+import { getFfmpegBinaryPath } from "../ffmpeg/binary";
+import { getBundledWhisperExecutableCandidates } from "../paths/binaries";
+import { resolveRecordingSession } from "../project/session";
+import {
+ getCompanionAudioStartDelayMs,
+ getUsableCompanionAudioCandidates,
+} from "../recording/diagnostics";
+import { normalizeVideoSourcePath } from "../utils";
+import { type CaptionAudioCandidate, getCaptionCompanionAudioCandidates } from "./audioCandidates";
+import { shouldRetryWhisperWithoutJson } from "./parser";
+import { readWhisperCaptionOutput } from "./output";
+import { isMissingWindowsWhisperRuntimeDependency } from "./runtimeErrors";
+import { mergeCaptionSources } from "./mergeSources";
+import { segmentCuesIntoPhrases } from "./segment";
+import {
+ parseSilenceIntervals,
+ SILENCE_DETECT_MIN_S,
+ SILENCE_NOISE_DB,
+ type SilenceInterval,
+} from "./silence";
+
+const execFileAsync = promisify(execFile);
+
+class NoCaptionAudioError extends Error {}
+
+async function executeWhisper(whisperExecutablePath: string, args: string[]) {
+ try {
+ await execFileAsync(whisperExecutablePath, args, {
+ timeout: 30 * 60 * 1000,
+ maxBuffer: 20 * 1024 * 1024,
+ });
+ } catch (error) {
+ if (isMissingWindowsWhisperRuntimeDependency(error)) {
+ throw new Error(
+ "Whisper could not start because the Microsoft Visual C++ x64 Redistributable is missing. Install it from https://aka.ms/vc14/vc_redist.x64.exe, then restart Recordly.",
+ );
+ }
+ throw error;
+ }
+}
+
+export async function ensureReadableFile(filePath: string, options?: { executable?: boolean }) {
+ await fs.access(filePath, fsConstants.R_OK);
+ if (options?.executable) {
+ try {
+ await fs.access(filePath, fsConstants.X_OK);
+ } catch {
+ throw new Error("The selected Whisper executable is not marked as executable.");
+ }
+ }
+}
+
+export async function isExecutableFile(filePath: string) {
+ try {
+ await fs.access(filePath, fsConstants.R_OK | fsConstants.X_OK);
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+export async function resolveWhisperExecutablePath(preferredPath?: string | null) {
+ const candidatePaths = [
+ preferredPath?.trim() || null,
+ ...getBundledWhisperExecutableCandidates(),
+ process.env["WHISPER_CPP_PATH"]?.trim() || null,
+ process.platform === "darwin" ? "/opt/homebrew/bin/whisper-cli" : null,
+ process.platform === "darwin" ? "/usr/local/bin/whisper-cli" : null,
+ process.platform === "darwin" ? "/opt/homebrew/bin/whisper-cpp" : null,
+ process.platform === "darwin" ? "/usr/local/bin/whisper-cpp" : null,
+ ].filter((value): value is string => Boolean(value));
+
+ for (const candidate of candidatePaths) {
+ const normalized = path.resolve(candidate);
+ if (await isExecutableFile(normalized)) {
+ return normalized;
+ }
+ }
+
+ const pathCommand = process.platform === "win32" ? "where" : "which";
+ const binaryNames =
+ process.platform === "win32"
+ ? ["whisper-cli.exe", "whisper.exe", "main.exe"]
+ : ["whisper-cli", "whisper-cpp", "whisper", "main"];
+
+ for (const binaryName of binaryNames) {
+ const result = spawnSync(pathCommand, [binaryName], { encoding: "utf-8" });
+ if (result.status === 0) {
+ const resolvedPath = result.stdout
+ .split(/\r?\n/)
+ .map((line) => line.trim())
+ .find(Boolean);
+
+ if (resolvedPath && (await isExecutableFile(resolvedPath))) {
+ return resolvedPath;
+ }
+ }
+ }
+
+ throw new Error(
+ `No Whisper runtime was found for ${process.platform}/${process.arch}. ` +
+ "This Recordly build is missing its bundled caption runtime. Reinstall or update Recordly, or select a whisper-cli executable in Caption settings.",
+ );
+}
+
+export async function resolveCaptionAudioCandidates(videoPath: string) {
+ const candidates: Array<{ path: string; label: string }> = [];
+ const seenPaths = new Set();
+
+ const pushCandidate = (candidatePath: string | null | undefined, label: string) => {
+ const normalizedCandidatePath = normalizeVideoSourcePath(candidatePath);
+ if (!normalizedCandidatePath || seenPaths.has(normalizedCandidatePath)) {
+ return;
+ }
+
+ seenPaths.add(normalizedCandidatePath);
+ candidates.push({ path: normalizedCandidatePath, label });
+ };
+
+ const companionAudio = await getUsableCompanionAudioCandidates(videoPath);
+ const companions = getCaptionCompanionAudioCandidates(companionAudio);
+ for (const candidate of companions.filter(
+ (candidate) => candidate.label === "microphone audio sidecar",
+ )) {
+ pushCandidate(candidate.path, candidate.label);
+ }
+ pushCandidate(videoPath, "recording");
+ for (const candidate of companions) {
+ pushCandidate(candidate.path, candidate.label);
+ }
+
+ const requestedRecordingSession = await resolveRecordingSession(videoPath);
+ pushCandidate(requestedRecordingSession?.webcamPath, "linked webcam recording");
+
+ return candidates;
+}
+
+export async function extractCaptionAudioSource(options: {
+ videoPath: string;
+ ffmpegPath: string;
+ wavPath: string;
+ candidates?: CaptionAudioCandidate[];
+}) {
+ const candidates =
+ options.candidates ?? (await resolveCaptionAudioCandidates(options.videoPath));
+ const attemptedCandidates: Array<{
+ path: string;
+ label: string;
+ readable: boolean;
+ extractedAudio: boolean;
+ error?: string;
+ }> = [];
+
+ for (const candidate of candidates) {
+ try {
+ await ensureReadableFile(candidate.path);
+ const delayMs = candidate.label.endsWith("audio sidecar")
+ ? ((await getCompanionAudioStartDelayMs(candidate.path)) ?? 0)
+ : 0;
+ await execFileAsync(
+ options.ffmpegPath,
+ [
+ "-y",
+ "-i",
+ candidate.path,
+ "-map",
+ "0:a:0",
+ "-vn",
+ ...(delayMs > 0 ? ["-af", `adelay=${delayMs}:all=1`] : []),
+ "-ac",
+ "1",
+ "-ar",
+ "16000",
+ "-c:a",
+ "pcm_s16le",
+ options.wavPath,
+ ],
+ { timeout: 5 * 60 * 1000, maxBuffer: 20 * 1024 * 1024 },
+ );
+ attemptedCandidates.push({ ...candidate, readable: true, extractedAudio: true });
+ return candidate;
+ } catch (error) {
+ attemptedCandidates.push({
+ ...candidate,
+ readable: true,
+ extractedAudio: false,
+ error: error instanceof Error ? error.message : String(error),
+ });
+ }
+ }
+
+ console.warn(
+ "[auto-captions] No audio source candidate could be extracted:",
+ attemptedCandidates,
+ );
+
+ throw new NoCaptionAudioError(
+ "No audio was found to transcribe in the saved recording file. Captions need an audio track. If this recording should have contained sound, the recording was saved without an audio stream.",
+ );
+}
+
+export async function detectSilenceIntervals(options: {
+ ffmpegPath: string;
+ wavPath: string;
+}): Promise {
+ // ffmpeg writes silencedetect results to stderr; the null muxer just runs the filter.
+ const { stderr } = await execFileAsync(
+ options.ffmpegPath,
+ [
+ "-hide_banner",
+ "-nostats",
+ "-i",
+ options.wavPath,
+ "-af",
+ `silencedetect=noise=${SILENCE_NOISE_DB}dB:d=${SILENCE_DETECT_MIN_S}`,
+ "-f",
+ "null",
+ "-",
+ ],
+ { timeout: 5 * 60 * 1000, maxBuffer: 20 * 1024 * 1024 },
+ );
+
+ return parseSilenceIntervals(stderr ?? "");
+}
+
+async function generateCaptionsForSource(options: {
+ videoPath: string;
+ whisperExecutablePath?: string;
+ whisperModelPath: string;
+ language?: string;
+ candidates: CaptionAudioCandidate[];
+}) {
+ const ffmpegPath = getFfmpegBinaryPath();
+ const normalizedVideoPath = normalizeVideoSourcePath(options.videoPath);
+ if (!normalizedVideoPath) {
+ throw new Error("Missing source video path.");
+ }
+
+ const whisperExecutablePath = await resolveWhisperExecutablePath(options.whisperExecutablePath);
+ const whisperModelPath = path.resolve(options.whisperModelPath);
+ await ensureReadableFile(whisperExecutablePath, { executable: true });
+ await ensureReadableFile(whisperModelPath);
+
+ const tempBase = path.join(
+ app.getPath("temp"),
+ `recordly-captions-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
+ );
+ const wavPath = `${tempBase}.wav`;
+ const outputBase = `${tempBase}-whisper`;
+ const srtPath = `${outputBase}.srt`;
+ const jsonPath = `${outputBase}.json`;
+
+ try {
+ const audioSource = await extractCaptionAudioSource({
+ videoPath: normalizedVideoPath,
+ ffmpegPath,
+ wavPath,
+ candidates: options.candidates,
+ });
+
+ const language =
+ options.language && options.language.trim() ? options.language.trim() : "auto";
+ const whisperBaseArgs = [
+ "-m",
+ whisperModelPath,
+ "-f",
+ wavPath,
+ "-osrt",
+ "-of",
+ outputBase,
+ "-l",
+ language,
+ "-np",
+ // Do not carry a hallucinated phrase through later decoding windows.
+ "-mc",
+ "0",
+ ];
+
+ let jsonEnabled = true;
+ try {
+ await executeWhisper(whisperExecutablePath, [...whisperBaseArgs, "-ojf"]);
+ } catch (error) {
+ if (!shouldRetryWhisperWithoutJson(error)) {
+ throw error;
+ }
+
+ jsonEnabled = false;
+ console.warn(
+ "[auto-captions] Whisper runtime does not support JSON full output, retrying with SRT only:",
+ error,
+ );
+ await executeWhisper(whisperExecutablePath, whisperBaseArgs);
+ }
+
+ const cues = await readWhisperCaptionOutput(outputBase, jsonEnabled);
+
+ // Whisper cues run sentences together and don't break on pauses. Re-segment them
+ // into one caption per sentence/phrase using Whisper's own word stream (punctuation
+ // + pauses), backed by ground-truth acoustic silence (ffmpeg `silencedetect`).
+ // Failure here must not block caption generation — fall back to raw.
+ let cuesToReturn = cues;
+ try {
+ const silences = await detectSilenceIntervals({ ffmpegPath, wavPath });
+ // An empty result is a valid resegmentation (e.g. every transcribed word fell
+ // inside a long detected silence and was dropped as a hallucination), so take it
+ // as-is. Only a thrown exception should fall back to the raw cues.
+ cuesToReturn = segmentCuesIntoPhrases(cues, silences);
+ } catch (error) {
+ console.warn(
+ "[auto-captions] Silence-aware re-segmentation failed, using raw cues:",
+ error,
+ );
+ }
+
+ return {
+ cues: cuesToReturn,
+ audioSourceLabel: audioSource.label,
+ };
+ } finally {
+ await Promise.allSettled([
+ fs.rm(wavPath, { force: true }),
+ fs.rm(srtPath, { force: true }),
+ fs.rm(jsonPath, { force: true }),
+ ]);
+ }
+}
+
+export async function generateAutoCaptionsFromVideo(options: {
+ videoPath: string;
+ whisperExecutablePath?: string;
+ whisperModelPath: string;
+ language?: string;
+}) {
+ const candidates = await resolveCaptionAudioCandidates(options.videoPath);
+ const microphone = candidates.filter((source) => source.label === "microphone audio sidecar");
+ const system = candidates.filter((source) => source.label === "system audio sidecar");
+ const secondary = candidates.filter(
+ (source) => !microphone.includes(source) && !system.includes(source),
+ );
+ if (microphone.length === 0) {
+ return generateCaptionsForSource({ ...options, candidates: [...system, ...secondary] });
+ }
+ // Decode independently so simultaneous voices do not confuse recognition. The
+ // sidecar replaces embedded system audio to avoid transcribing it twice.
+ const transcribeTrack = async (sources: CaptionAudioCandidate[]) => {
+ try {
+ return (await generateCaptionsForSource({ ...options, candidates: sources })).cues;
+ } catch (error) {
+ if (!(error instanceof NoCaptionAudioError)) throw error;
+ return null;
+ }
+ };
+ const micCues = await transcribeTrack(microphone);
+ const systemCues = await transcribeTrack([...system, ...secondary]);
+ if (micCues === null && systemCues === null)
+ throw new NoCaptionAudioError("No audio could be extracted from the recording.");
+ return {
+ cues: mergeCaptionSources(micCues ?? [], systemCues ?? []),
+ audioSourceLabel: "microphone and system audio",
+ };
+}
diff --git a/electron/ipc/captions/generation.test.ts b/electron/ipc/captions/generation.test.ts
new file mode 100644
index 000000000..70ba26588
--- /dev/null
+++ b/electron/ipc/captions/generation.test.ts
@@ -0,0 +1,166 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const mocks = vi.hoisted(() => ({
+ exec: vi.fn(),
+ readFile: vi.fn(),
+ rm: vi.fn(),
+ companions: vi.fn(),
+ delay: vi.fn(),
+ session: vi.fn(),
+}));
+vi.mock("node:child_process", () => ({
+ execFile: (...args: unknown[]) => {
+ const callback = args.pop() as (error: Error | null, result: unknown) => void;
+ Promise.resolve()
+ .then(() => mocks.exec(...args))
+ .then(
+ (result) => callback(null, result ?? { stderr: "" }),
+ (error) => callback(error, undefined),
+ );
+ },
+ spawnSync: vi.fn(),
+}));
+vi.mock("node:fs/promises", () => ({
+ default: {
+ access: vi.fn().mockResolvedValue(undefined),
+ readFile: mocks.readFile,
+ rm: mocks.rm,
+ },
+}));
+vi.mock("electron", () => ({ app: { getPath: () => "/tmp" } }));
+vi.mock("../ffmpeg/binary", () => ({ getFfmpegBinaryPath: () => "/ffmpeg" }));
+vi.mock("../paths/binaries", () => ({ getBundledWhisperExecutableCandidates: () => ["/whisper"] }));
+vi.mock("../project/session", () => ({ resolveRecordingSession: mocks.session }));
+vi.mock("../recording/diagnostics", () => ({
+ getUsableCompanionAudioCandidates: mocks.companions,
+ getCompanionAudioStartDelayMs: mocks.delay,
+}));
+vi.mock("../utils", () => ({ normalizeVideoSourcePath: (value: string) => value }));
+
+import { generateAutoCaptionsFromVideo } from "./generate";
+
+const options = { videoPath: "/video.mp4", whisperModelPath: "/model.bin" };
+const srt = "1\n00:00:00,000 --> 00:00:02,000\nHello world.\n";
+const json = JSON.stringify({
+ transcription: [
+ {
+ offsets: { from: 0, to: 2000 },
+ text: "Hello world.",
+ tokens: [
+ { text: "Hello", offsets: { from: 0, to: 900 } },
+ { text: " world.", offsets: { from: 900, to: 2000 } },
+ ],
+ },
+ ],
+});
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ mocks.session.mockReset().mockResolvedValue(null);
+ mocks.companions.mockReset().mockResolvedValue([]);
+ mocks.delay.mockReset().mockResolvedValue(null);
+ mocks.exec.mockReset().mockResolvedValue({ stderr: "" });
+ mocks.readFile
+ .mockReset()
+ .mockImplementation(async (file: string) => (file.endsWith(".json") ? json : srt));
+ mocks.rm.mockResolvedValue(undefined);
+});
+
+const whisperCalls = () => mocks.exec.mock.calls.filter(([file]) => file === "/whisper");
+
+describe("caption generation pipeline", () => {
+ it("transcribes both sidecars independently, preserving the microphone delay", async () => {
+ mocks.companions.mockResolvedValue([
+ {
+ platform: "mac",
+ micPath: "/video.mic.wav",
+ systemPath: "/video.system.wav",
+ usablePaths: ["/video.system.wav", "/video.mic.wav"],
+ },
+ ]);
+ mocks.delay.mockResolvedValue(450);
+ const result = await generateAutoCaptionsFromVideo(options);
+ expect(result.audioSourceLabel).toBe("microphone and system audio");
+ expect(whisperCalls()).toHaveLength(2);
+ expect(mocks.exec.mock.calls.some(([, args]) => args.includes("/video.system.wav"))).toBe(
+ true,
+ );
+ expect(mocks.exec.mock.calls[0][1]).toEqual(
+ expect.arrayContaining(["-i", "/video.mic.wav", "-af", "adelay=450:all=1"]),
+ );
+ });
+
+ it("falls back to embedded audio when the microphone cannot be extracted", async () => {
+ mocks.companions.mockResolvedValue([
+ {
+ platform: "mac",
+ micPath: "/video.mic.wav",
+ systemPath: "/video.system.wav",
+ usablePaths: ["/video.mic.wav"],
+ },
+ ]);
+ mocks.exec.mockImplementation(async (_file: string, args: string[]) => {
+ if (args.includes("/video.mic.wav")) throw new Error("unreadable microphone");
+ return { stderr: "" };
+ });
+ expect((await generateAutoCaptionsFromVideo(options)).cues[0].text).toBe("Hello world.");
+ });
+
+ it("extracts mono audio, transcribes, segments word timings and cleans up", async () => {
+ const result = await generateAutoCaptionsFromVideo(options);
+ expect(result.cues[0].text).toBe("Hello world.");
+ expect(result.cues[0].words).toHaveLength(2);
+ expect(mocks.exec.mock.calls[0][1]).toEqual(
+ expect.arrayContaining(["-ac", "1", "-ar", "16000"]),
+ );
+ expect(whisperCalls()).toHaveLength(1);
+ expect(whisperCalls()[0][1]).toEqual(expect.arrayContaining(["-mc", "0"]));
+ expect(mocks.rm).toHaveBeenCalledTimes(3);
+ });
+
+ it("retries SRT only for a runtime that rejects full JSON", async () => {
+ mocks.exec.mockImplementation(async (file: string, args: string[]) => {
+ if (file === "/whisper" && args.includes("-ojf"))
+ throw new Error("unknown argument: -ojf");
+ return { stderr: "" };
+ });
+ expect((await generateAutoCaptionsFromVideo(options)).cues).toHaveLength(1);
+ expect(whisperCalls()).toHaveLength(2);
+ expect(whisperCalls()[1][1]).not.toContain("-ojf");
+ });
+
+ it("keeps transcription when optional silence detection fails", async () => {
+ mocks.exec.mockImplementation(async (_file: string, args: string[]) => {
+ if (args.includes("-af")) throw new Error("silence filter failed");
+ return { stderr: "" };
+ });
+ expect((await generateAutoCaptionsFromVideo(options)).cues[0].text).toBe("Hello world.");
+ });
+});
+
+it("falls back to linked webcam audio when mic exists and other secondary sources fail", async () => {
+ mocks.session.mockResolvedValue({ webcamPath: "/webcam.mp4" });
+ mocks.companions.mockResolvedValue([
+ {
+ platform: "mac",
+ micPath: "/video.mic.wav",
+ systemPath: "/video.system.wav",
+ usablePaths: ["/video.mic.wav", "/video.system.wav"],
+ },
+ ]);
+ mocks.exec.mockImplementation(async (file: string, args: string[]) => {
+ if (file === "/ffmpeg" && args.includes("pcm_s16le")) {
+ const source = args[args.indexOf("-i") + 1];
+ if (source === "/video.mp4" || source === "/video.system.wav")
+ throw new Error("No audio");
+ }
+ return { stderr: "" };
+ });
+ const result = await generateAutoCaptionsFromVideo(options);
+ expect(result.cues.length).toBeGreaterThan(0);
+ expect(
+ mocks.exec.mock.calls.some(
+ ([file, args]) => file === "/ffmpeg" && args.includes("/webcam.mp4"),
+ ),
+ ).toBe(true);
+});
diff --git a/electron/ipc/captions/mergeSources.test.ts b/electron/ipc/captions/mergeSources.test.ts
new file mode 100644
index 000000000..01a3cc4ee
--- /dev/null
+++ b/electron/ipc/captions/mergeSources.test.ts
@@ -0,0 +1,117 @@
+import { expect, it } from "vitest";
+import { mergeCaptionSources } from "./mergeSources";
+import { parseWhisperJsonCues } from "./parser";
+
+it("keeps system speech when the microphone has no speech", () => {
+ const system = [{ id: "s", startMs: 0, endMs: 1000, text: "System speech" }];
+ expect(mergeCaptionSources([], system)[0].text).toBe("System speech");
+});
+it("keeps both tracks, choosing mic only for overlapping words", () => {
+ const mic = [{ id: "m", startMs: 1000, endMs: 2000, text: "Mic speech" }];
+ const system = [
+ {
+ id: "s",
+ startMs: 0,
+ endMs: 3000,
+ text: "Before conflict after",
+ words: [
+ { text: "Before", startMs: 0, endMs: 900 },
+ { text: "conflict", startMs: 1100, endMs: 1900, leadingSpace: true },
+ { text: "after", startMs: 2100, endMs: 3000, leadingSpace: true },
+ ],
+ },
+ ];
+ expect(mergeCaptionSources(mic, system).map((cue) => cue.text)).toEqual([
+ "Before",
+ "Mic speech",
+ "after",
+ ]);
+});
+it("retains real Whisper word timing through control tokens and punctuation", () => {
+ const cues = parseWhisperJsonCues(
+ JSON.stringify({
+ transcription: [
+ {
+ offsets: { from: 0, to: 1000 },
+ text: "Hello.",
+ tokens: [
+ { text: "[_BEG_]", offsets: { from: 0, to: 0 } },
+ { text: " Hello", offsets: { from: 0, to: 900 } },
+ { text: ".", offsets: { from: 900, to: 900 } },
+ { text: "[_TT_50]", offsets: { from: 1000, to: 1000 } },
+ ],
+ },
+ ],
+ }),
+ );
+ expect(cues[0].words).toEqual([{ text: "Hello.", startMs: 0, endMs: 900 }]);
+});
+
+it("does not let mic sound labels replace system speech", () => {
+ expect(
+ mergeCaptionSources(
+ [{ id: "m", startMs: 0, endMs: 2000, text: "[Coughing]" }],
+ [{ id: "s", startMs: 0, endMs: 2000, text: "The spoken paragraph." }],
+ ).map((c) => c.text),
+ ).toEqual(["The spoken paragraph."]);
+});
+
+it("preserves untimed speech next to timed words", async () => {
+ const { segmentCuesIntoPhrases } = await import("./segment");
+ const result = segmentCuesIntoPhrases(
+ [
+ {
+ id: "a",
+ startMs: 0,
+ endMs: 1000,
+ text: "First.",
+ words: [{ text: "First.", startMs: 0, endMs: 1000 }],
+ },
+ { id: "b", startMs: 1000, endMs: 3000, text: "There is a timeline editor." },
+ ],
+ [],
+ );
+ expect(result.map((c) => c.text).join(" ")).toContain("There is a timeline editor.");
+});
+
+it("preserves system speech between timed microphone words", () => {
+ const result = mergeCaptionSources(
+ [
+ {
+ id: "mic",
+ startMs: 0,
+ endMs: 3000,
+ text: "Hello again",
+ words: [
+ { text: "Hello", startMs: 0, endMs: 600 },
+ { text: "again", startMs: 2400, endMs: 3000 },
+ ],
+ },
+ ],
+ [{ id: "system", startMs: 1000, endMs: 1800, text: "In the gap" }],
+ );
+ expect(result.map((cue) => cue.text)).toContain("In the gap");
+});
+
+it("retains the unopposed portions of an untimed system cue", () => {
+ const result = mergeCaptionSources(
+ [
+ {
+ id: "mic",
+ startMs: 0,
+ endMs: 3000,
+ text: "Mic",
+ words: [{ text: "Mic", startMs: 1000, endMs: 2000 }],
+ },
+ ],
+ [{ id: "system", startMs: 0, endMs: 3000, text: "System paragraph" }],
+ );
+ expect(
+ result
+ .filter((cue) => cue.text === "System paragraph")
+ .map(({ startMs, endMs }) => [startMs, endMs]),
+ ).toEqual([
+ [0, 1000],
+ [2000, 3000],
+ ]);
+});
diff --git a/electron/ipc/captions/mergeSources.ts b/electron/ipc/captions/mergeSources.ts
new file mode 100644
index 000000000..1eab4c6c3
--- /dev/null
+++ b/electron/ipc/captions/mergeSources.ts
@@ -0,0 +1,62 @@
+import type { CaptionCuePayload, CaptionWordPayload } from "../types";
+import { buildCaptionTextFromWords } from "./parser";
+
+/** Keep both speakers, with microphone speech taking priority during overlap. */
+export function mergeCaptionSources(
+ microphone: CaptionCuePayload[],
+ system: CaptionCuePayload[],
+): CaptionCuePayload[] {
+ // Sound-event labels are not competing microphone speech.
+ const micSpeech = microphone.filter((cue) => !/^(?:\s*[[(][^\])]+[\])]\s*)+$/.test(cue.text));
+ const micSpans = micSpeech.flatMap((cue) => (cue.words?.length ? cue.words : [cue]));
+ const overlapsMic = (startMs: number, endMs: number) =>
+ micSpans.some((span) => startMs < span.endMs && endMs > span.startMs);
+ const systemCues: CaptionCuePayload[] = [];
+ for (const cue of system) {
+ if (!overlapsMic(cue.startMs, cue.endMs)) {
+ systemCues.push(cue);
+ continue;
+ }
+ if (!cue.words?.length) {
+ let spans = [{ startMs: cue.startMs, endMs: cue.endMs }];
+ for (const mic of micSpans) {
+ spans = spans.flatMap((span) => {
+ if (mic.endMs <= span.startMs || mic.startMs >= span.endMs) return [span];
+ return [
+ { startMs: span.startMs, endMs: Math.min(span.endMs, mic.startMs) },
+ { startMs: Math.max(span.startMs, mic.endMs), endMs: span.endMs },
+ ].filter((part) => part.endMs > part.startMs);
+ });
+ }
+ // SRT has no word boundaries: retain its text during the unopposed portions.
+ systemCues.push(...spans.map((span) => ({ ...cue, ...span })));
+ continue;
+ }
+ // Preserve words outside the conflict, rather than dropping a whole paragraph.
+ let run: CaptionWordPayload[] = [];
+ const flush = () => {
+ if (run.length === 0) return;
+ systemCues.push({
+ id: cue.id,
+ startMs: run[0].startMs,
+ endMs: run[run.length - 1].endMs,
+ text: buildCaptionTextFromWords(run),
+ words: run,
+ });
+ run = [];
+ };
+ for (const word of cue.words ?? []) {
+ if (overlapsMic(word.startMs, word.endMs)) flush();
+ else run.push(word);
+ }
+ flush();
+ }
+ const micEvents = microphone.filter(
+ (cue) =>
+ !micSpeech.includes(cue) &&
+ !system.some((other) => cue.startMs < other.endMs && cue.endMs > other.startMs),
+ );
+ return [...micSpeech, ...micEvents, ...systemCues]
+ .sort((a, b) => a.startMs - b.startMs || a.endMs - b.endMs)
+ .map((cue, index) => ({ ...cue, id: `caption-${index + 1}` }));
+}
diff --git a/electron/ipc/captions/output.test.ts b/electron/ipc/captions/output.test.ts
new file mode 100644
index 000000000..8b000966f
--- /dev/null
+++ b/electron/ipc/captions/output.test.ts
@@ -0,0 +1,51 @@
+import fs from "node:fs/promises";
+import os from "node:os";
+import path from "node:path";
+import { afterEach, beforeEach, expect, it } from "vitest";
+import { readWhisperCaptionOutput } from "./output";
+let directory: string;
+let output: string;
+beforeEach(async () => {
+ directory = await fs.mkdtemp(path.join(os.tmpdir(), "recordly-caption-output-"));
+ output = path.join(directory, "speech");
+ await fs.writeFile(`${output}.srt`, "1\n00:00:01,200 --> 00:00:02,400\nFallback speech\n");
+});
+afterEach(async () => {
+ await fs.rm(directory, { recursive: true, force: true });
+});
+it.each([
+ undefined,
+ "invalid json",
+ '{"transcription":[]}',
+])("uses SRT when JSON is unusable: %s", async (json) => {
+ if (json !== undefined) await fs.writeFile(`${output}.json`, json);
+ const cues = await readWhisperCaptionOutput(output, true);
+ expect(cues).toEqual([
+ { id: "caption-1", startMs: 1200, endMs: 2400, text: "Fallback speech" },
+ ]);
+});
+it("prefers valid JSON word timing", async () => {
+ await fs.writeFile(
+ `${output}.json`,
+ JSON.stringify({
+ transcription: [
+ {
+ offsets: { from: 1200, to: 2400 },
+ text: "Timed speech",
+ tokens: [
+ { text: "Timed", offsets: { from: 1200, to: 1800 } },
+ { text: " speech", offsets: { from: 1800, to: 2400 } },
+ ],
+ },
+ ],
+ }),
+ );
+ const cues = await readWhisperCaptionOutput(output, true);
+ expect(cues[0].text).toBe("Timed speech");
+ expect(cues[0].words).toHaveLength(2);
+ expect(cues[0].words?.[1].startMs).toBe(1800);
+});
+it("still surfaces missing subtitle output", async () => {
+ await fs.rm(`${output}.srt`);
+ await expect(readWhisperCaptionOutput(output, false)).rejects.toMatchObject({ code: "ENOENT" });
+});
diff --git a/electron/ipc/captions/output.ts b/electron/ipc/captions/output.ts
new file mode 100644
index 000000000..a48f43f05
--- /dev/null
+++ b/electron/ipc/captions/output.ts
@@ -0,0 +1,18 @@
+import fs from "node:fs/promises";
+import { parseSrtCues, parseWhisperJsonCues } from "./parser";
+
+/** Older Whisper builds may emit SRT without the requested word-timing JSON. */
+export async function readWhisperCaptionOutput(outputBase: string, jsonEnabled: boolean) {
+ if (jsonEnabled) {
+ let json = "";
+ try {
+ json = await fs.readFile(`${outputBase}.json`, "utf8");
+ } catch (error) {
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
+ }
+ const timed = json ? parseWhisperJsonCues(json) : [];
+ if (timed.length) return timed;
+ console.warn("[auto-captions] No usable JSON timing output; falling back to SRT.");
+ }
+ return parseSrtCues(await fs.readFile(`${outputBase}.srt`, "utf8"));
+}
diff --git a/electron/ipc/captions/parser.ts b/electron/ipc/captions/parser.ts
new file mode 100644
index 000000000..7ab213183
--- /dev/null
+++ b/electron/ipc/captions/parser.ts
@@ -0,0 +1,187 @@
+import type {
+ CaptionCuePayload,
+ CaptionWordPayload,
+ WhisperJsonSegment,
+ WhisperJsonToken,
+} from "../types";
+
+function isFiniteNumber(value: unknown): value is number {
+ return typeof value === "number" && Number.isFinite(value);
+}
+
+export function buildCaptionTextFromWords(words: CaptionWordPayload[]): string {
+ return words
+ .map((word, index) => `${index > 0 && word.leadingSpace ? " " : ""}${word.text}`)
+ .join("")
+ .trim();
+}
+
+export function parseWhisperJsonWords(tokens: unknown): CaptionWordPayload[] {
+ if (!Array.isArray(tokens)) {
+ return [];
+ }
+
+ const words: CaptionWordPayload[] = [];
+ let nextLeadingSpace = false;
+
+ for (const token of tokens) {
+ if (!token || typeof token !== "object") {
+ continue;
+ }
+
+ const tokenData = token as WhisperJsonToken;
+ const tokenText = typeof tokenData.text === "string" ? tokenData.text : "";
+ if (!tokenText || /^\[_[^\]]+\]$/.test(tokenText)) {
+ continue;
+ }
+
+ const tokenStartMs = isFiniteNumber(tokenData.offsets?.from)
+ ? Math.round(tokenData.offsets.from)
+ : null;
+ const tokenEndMs = isFiniteNumber(tokenData.offsets?.to)
+ ? Math.round(tokenData.offsets.to)
+ : null;
+ const parts = tokenText.match(/\s+|[^\s]+/g) ?? [];
+
+ for (const part of parts) {
+ if (/^\s+$/.test(part)) {
+ nextLeadingSpace = words.length > 0;
+ continue;
+ }
+
+ const previousWord = words.length > 0 ? words[words.length - 1] : null;
+ if (previousWord && !nextLeadingSpace && /^\p{P}+$/u.test(part)) {
+ previousWord.text += part;
+ continue;
+ }
+ if (tokenStartMs == null || tokenEndMs == null || tokenEndMs <= tokenStartMs) return [];
+
+ if (!previousWord || nextLeadingSpace) {
+ words.push({
+ text: part,
+ startMs: tokenStartMs,
+ endMs: tokenEndMs,
+ ...(words.length > 0 && nextLeadingSpace ? { leadingSpace: true } : {}),
+ });
+ } else {
+ previousWord.text += part;
+ previousWord.endMs = Math.max(previousWord.endMs, tokenEndMs);
+ }
+
+ nextLeadingSpace = false;
+ }
+ }
+
+ return words.filter((word) => word.text.trim().length > 0);
+}
+
+export function parseWhisperJsonCues(content: string): CaptionCuePayload[] {
+ try {
+ const parsed = JSON.parse(content) as {
+ transcription?: unknown;
+ };
+
+ if (!Array.isArray(parsed.transcription)) {
+ return [];
+ }
+
+ return parsed.transcription
+ .map((segment, index) => {
+ if (!segment || typeof segment !== "object") {
+ return null;
+ }
+
+ const segmentData = segment as WhisperJsonSegment;
+ const startMs = isFiniteNumber(segmentData.offsets?.from)
+ ? Math.round(segmentData.offsets.from)
+ : null;
+ const endMs = isFiniteNumber(segmentData.offsets?.to)
+ ? Math.round(segmentData.offsets.to)
+ : null;
+ const segmentText =
+ typeof segmentData.text === "string" ? segmentData.text.trim() : "";
+
+ if (startMs == null || endMs == null || endMs <= startMs) {
+ return null;
+ }
+
+ const words = parseWhisperJsonWords(segmentData.tokens);
+ const text = words.length > 0 ? buildCaptionTextFromWords(words) : segmentText;
+
+ if (!text) {
+ return null;
+ }
+
+ return {
+ id: `caption-${index + 1}`,
+ startMs,
+ endMs,
+ text,
+ ...(words.length > 0 ? { words } : {}),
+ };
+ })
+ .filter((cue): cue is CaptionCuePayload => cue != null);
+ } catch (error) {
+ console.warn("[auto-captions] Failed to parse Whisper JSON output:", error);
+ return [];
+ }
+}
+
+export function parseSrtTimestamp(value: string): number | null {
+ const match = value.trim().match(/^(\d{2}):(\d{2}):(\d{2}),(\d{3})$/);
+ if (!match) {
+ return null;
+ }
+
+ const [, hours, minutes, seconds, milliseconds] = match;
+ return (
+ Number(hours) * 60 * 60 * 1000 +
+ Number(minutes) * 60 * 1000 +
+ Number(seconds) * 1000 +
+ Number(milliseconds)
+ );
+}
+
+export function parseSrtCues(content: string): CaptionCuePayload[] {
+ return content
+ .split(/\r?\n\r?\n/)
+ .map((block, index) => {
+ const lines = block.split(/\r?\n/).map((line) => line.trim());
+ const timingLine = lines.find((line) => line.includes("-->"));
+ if (!timingLine) {
+ return null;
+ }
+
+ const [rawStart, rawEnd] = timingLine.split("-->").map((part) => part.trim());
+ const startMs = parseSrtTimestamp(rawStart);
+ const endMs = parseSrtTimestamp(rawEnd);
+ if (startMs == null || endMs == null || endMs <= startMs) {
+ return null;
+ }
+
+ const text = lines
+ .slice(lines.indexOf(timingLine) + 1)
+ .filter((line) => line.length > 0)
+ .join("\n")
+ .trim();
+
+ if (!text) {
+ return null;
+ }
+
+ return {
+ id: `caption-${index + 1}`,
+ startMs,
+ endMs,
+ text,
+ };
+ })
+ .filter((cue): cue is CaptionCuePayload => cue != null);
+}
+
+export function shouldRetryWhisperWithoutJson(error: unknown): boolean {
+ const message = error instanceof Error ? error.message : String(error);
+ return /(?:unknown|unrecognized|unsupported) (?:argument|option)[:\s=]+['"]?--?(?:ojf|output-json-full)\b/i.test(
+ message,
+ );
+}
diff --git a/electron/ipc/captions/runtimeErrors.ts b/electron/ipc/captions/runtimeErrors.ts
new file mode 100644
index 000000000..707848dd6
--- /dev/null
+++ b/electron/ipc/captions/runtimeErrors.ts
@@ -0,0 +1,10 @@
+const WINDOWS_MISSING_RUNTIME_EXIT_CODES = new Set([-1073741515, 3221225781]);
+
+export function isMissingWindowsWhisperRuntimeDependency(error: unknown) {
+ if (process.platform !== "win32" || !error || typeof error !== "object") {
+ return false;
+ }
+
+ const code = (error as { code?: unknown }).code;
+ return typeof code === "number" && WINDOWS_MISSING_RUNTIME_EXIT_CODES.has(code);
+}
diff --git a/electron/ipc/captions/segment.test.ts b/electron/ipc/captions/segment.test.ts
new file mode 100644
index 000000000..57d86dab3
--- /dev/null
+++ b/electron/ipc/captions/segment.test.ts
@@ -0,0 +1,391 @@
+import { describe, expect, it } from "vitest";
+import type { CaptionCuePayload } from "../types";
+import { endsSentence, segmentCuesIntoPhrases } from "./segment";
+
+describe("endsSentence", () => {
+ it("detects terminal punctuation, ignoring trailing closers", () => {
+ expect(endsSentence("world.")).toBe(true);
+ expect(endsSentence("you?")).toBe(true);
+ expect(endsSentence("stop!")).toBe(true);
+ expect(endsSentence("wait…")).toBe(true);
+ expect(endsSentence('said."')).toBe(true);
+ expect(endsSentence("done,")).toBe(false);
+ expect(endsSentence("hello")).toBe(false);
+ });
+
+ it("does not treat common abbreviations or initialisms as a sentence end", () => {
+ expect(endsSentence("Mr.")).toBe(false);
+ expect(endsSentence("Dr.")).toBe(false);
+ expect(endsSentence("etc.")).toBe(false);
+ expect(endsSentence("e.g.")).toBe(false);
+ expect(endsSentence("U.S.")).toBe(false);
+ expect(endsSentence("J.")).toBe(false);
+ // real sentence ends are unaffected
+ expect(endsSentence("Smith.")).toBe(true);
+ expect(endsSentence("5.")).toBe(true);
+ // `?`/`!` after an abbreviation-like token still end the sentence
+ expect(endsSentence("really?")).toBe(true);
+ });
+});
+
+describe("segmentCuesIntoPhrases", () => {
+ it("splits back-to-back sentences with no pause into separate captions", () => {
+ const cues: CaptionCuePayload[] = [
+ {
+ id: "caption-1",
+ startMs: 0,
+ endMs: 1_800,
+ text: "Hello world. How are you?",
+ words: [
+ { text: "Hello", startMs: 0, endMs: 400 },
+ { text: "world.", startMs: 400, endMs: 800, leadingSpace: true },
+ { text: "How", startMs: 800, endMs: 1_100, leadingSpace: true },
+ { text: "are", startMs: 1_100, endMs: 1_400, leadingSpace: true },
+ { text: "you?", startMs: 1_400, endMs: 1_800, leadingSpace: true },
+ ],
+ },
+ ];
+ const result = segmentCuesIntoPhrases(cues, []);
+ expect(result).toHaveLength(2);
+ expect(result[0].text).toBe("Hello world.");
+ expect(result[1].text).toBe("How are you?");
+ expect(result[0].endMs).toBeLessThanOrEqual(result[1].startMs);
+ });
+
+ it("does not leak the first word of the next sentence into the previous caption", () => {
+ const cues: CaptionCuePayload[] = [
+ {
+ id: "caption-1",
+ startMs: 0,
+ endMs: 1_800,
+ text: "Hello world. How are you?",
+ words: [
+ { text: "Hello", startMs: 0, endMs: 400 },
+ { text: "world.", startMs: 400, endMs: 800, leadingSpace: true },
+ { text: "How", startMs: 800, endMs: 1_100, leadingSpace: true },
+ { text: "are", startMs: 1_100, endMs: 1_400, leadingSpace: true },
+ { text: "you?", startMs: 1_400, endMs: 1_800, leadingSpace: true },
+ ],
+ },
+ ];
+ const result = segmentCuesIntoPhrases(cues, []);
+ expect(result[1].words?.[0].text).toBe("How");
+ expect(result[1].words?.[0].startMs).toBe(800);
+ // the first word of a phrase has no leading space
+ expect(result[1].words?.[0].leadingSpace).toBeUndefined();
+ });
+
+ it("merges rapid-fire short sentences into one caption (word-timed)", () => {
+ const cues: CaptionCuePayload[] = [
+ {
+ id: "caption-1",
+ startMs: 0,
+ endMs: 800,
+ text: "Okay. Great.",
+ words: [
+ { text: "Okay.", startMs: 0, endMs: 400 },
+ { text: "Great.", startMs: 400, endMs: 800, leadingSpace: true },
+ ],
+ },
+ ];
+ const result = segmentCuesIntoPhrases(cues, []);
+ expect(result).toHaveLength(1);
+ expect(result[0].text).toBe("Okay. Great.");
+ expect(result[0].words?.map((word) => word.text)).toEqual(["Okay.", "Great."]);
+ });
+
+ it("merges rapid-fire short sentences into one caption (word-less)", () => {
+ const cues: CaptionCuePayload[] = [
+ { id: "caption-1", startMs: 0, endMs: 400, text: "Okay." },
+ { id: "caption-2", startMs: 400, endMs: 800, text: "Great." },
+ ];
+ const result = segmentCuesIntoPhrases(cues, []);
+ expect(result).toHaveLength(1);
+ expect(result[0].text).toBe("Okay. Great.");
+ });
+
+ it("does not merge two short captions across a real pause just above the merge gap", () => {
+ // Two short, sentence-ended phrases separated by a 450ms gap (just above the 400ms
+ // mergeGapMs). Edge padding shrinks the apparent gap, so if merge eligibility ran on
+ // padded timings it would wrongly fuse them. Merge must run on the true speech gap.
+ const cues: CaptionCuePayload[] = [
+ {
+ id: "caption-1",
+ startMs: 0,
+ endMs: 1_250,
+ text: "Okay. Great.",
+ words: [
+ { text: "Okay.", startMs: 0, endMs: 400 },
+ { text: "Great.", startMs: 850, endMs: 1_250, leadingSpace: true },
+ ],
+ },
+ ];
+ const result = segmentCuesIntoPhrases(cues, []);
+ expect(result).toHaveLength(2);
+ expect(result[0].text).toBe("Okay.");
+ expect(result[1].text).toBe("Great.");
+ // Padding still applies, but cues stay separate and non-overlapping.
+ expect(result[0].endMs).toBeLessThanOrEqual(result[1].startMs);
+ });
+
+ it("does not merge a short sentence into a full-length one", () => {
+ const cues: CaptionCuePayload[] = [
+ {
+ id: "caption-1",
+ startMs: 0,
+ endMs: 2_400,
+ text: "Okay. This is a full length sentence that should stand alone.",
+ words: [
+ { text: "Okay.", startMs: 0, endMs: 400 },
+ { text: "This", startMs: 400, endMs: 700, leadingSpace: true },
+ { text: "is", startMs: 700, endMs: 900, leadingSpace: true },
+ { text: "a", startMs: 900, endMs: 1_050, leadingSpace: true },
+ { text: "full", startMs: 1_050, endMs: 1_350, leadingSpace: true },
+ { text: "length", startMs: 1_350, endMs: 1_650, leadingSpace: true },
+ { text: "sentence", startMs: 1_650, endMs: 1_950, leadingSpace: true },
+ { text: "that", startMs: 1_950, endMs: 2_100, leadingSpace: true },
+ { text: "should", startMs: 2_100, endMs: 2_250, leadingSpace: true },
+ { text: "stand", startMs: 2_250, endMs: 2_350, leadingSpace: true },
+ { text: "alone.", startMs: 2_350, endMs: 2_400, leadingSpace: true },
+ ],
+ },
+ ];
+ const result = segmentCuesIntoPhrases(cues, []);
+ expect(result).toHaveLength(2);
+ expect(result[0].text).toBe("Okay.");
+ expect(result[1].text).toBe("This is a full length sentence that should stand alone.");
+ });
+
+ it("keeps a comma/clause inside one caption", () => {
+ const cues: CaptionCuePayload[] = [
+ {
+ id: "caption-1",
+ startMs: 0,
+ endMs: 1_500,
+ text: "When I'm done, I start.",
+ words: [
+ { text: "When", startMs: 0, endMs: 300 },
+ { text: "I'm", startMs: 300, endMs: 600, leadingSpace: true },
+ { text: "done,", startMs: 600, endMs: 900, leadingSpace: true },
+ { text: "I", startMs: 900, endMs: 1_100, leadingSpace: true },
+ { text: "start.", startMs: 1_100, endMs: 1_500, leadingSpace: true },
+ ],
+ },
+ ];
+ const result = segmentCuesIntoPhrases(cues, []);
+ expect(result).toHaveLength(1);
+ expect(result[0].text).toBe("When I'm done, I start.");
+ });
+
+ it("does not split on an abbreviation, only on the real sentence end", () => {
+ const cues: CaptionCuePayload[] = [
+ {
+ id: "caption-1",
+ startMs: 0,
+ endMs: 1_800,
+ text: "Hello Mr. Smith. How are you?",
+ words: [
+ { text: "Hello", startMs: 0, endMs: 300 },
+ { text: "Mr.", startMs: 300, endMs: 600, leadingSpace: true },
+ { text: "Smith.", startMs: 600, endMs: 900, leadingSpace: true },
+ { text: "How", startMs: 900, endMs: 1_200, leadingSpace: true },
+ { text: "are", startMs: 1_200, endMs: 1_500, leadingSpace: true },
+ { text: "you?", startMs: 1_500, endMs: 1_800, leadingSpace: true },
+ ],
+ },
+ ];
+ const result = segmentCuesIntoPhrases(cues, []);
+ expect(result).toHaveLength(2);
+ expect(result[0].text).toBe("Hello Mr. Smith.");
+ expect(result[1].text).toBe("How are you?");
+ });
+
+ it("splits on a real pause even without punctuation", () => {
+ const cues: CaptionCuePayload[] = [
+ {
+ id: "caption-1",
+ startMs: 0,
+ endMs: 2_000,
+ text: "one two three four",
+ words: [
+ { text: "one", startMs: 0, endMs: 300 },
+ { text: "two", startMs: 300, endMs: 600, leadingSpace: true },
+ // 800ms gap (>= 700ms pause) -> phrase boundary
+ { text: "three", startMs: 1_400, endMs: 1_700, leadingSpace: true },
+ { text: "four", startMs: 1_700, endMs: 2_000, leadingSpace: true },
+ ],
+ },
+ ];
+ const result = segmentCuesIntoPhrases(cues, []);
+ expect(result).toHaveLength(2);
+ expect(result[0].text).toBe("one two");
+ expect(result[1].text).toBe("three four");
+ });
+
+ it("splits on a long acoustic silence in a word gap even when the word gap is short", () => {
+ const cues: CaptionCuePayload[] = [
+ {
+ id: "caption-1",
+ startMs: 0,
+ endMs: 2_600,
+ text: "alpha beta",
+ words: [
+ { text: "alpha", startMs: 0, endMs: 600 },
+ { text: "beta", startMs: 2_200, endMs: 2_600, leadingSpace: true },
+ ],
+ },
+ ];
+ // pauseMs high so the word gap (1600ms) wouldn't split on its own; the 1500ms
+ // silence sitting in the gap is what forces the break. beta starts after the
+ // silence ends, so it is not dropped as a hallucination.
+ const result = segmentCuesIntoPhrases(cues, [{ startMs: 600, endMs: 2_100 }], {
+ pauseMs: 5_000,
+ });
+ expect(result).toHaveLength(2);
+ expect(result[0].text).toBe("alpha");
+ expect(result[1].text).toBe("beta");
+ });
+
+ it("does not split that same input when there is no silence and the gap is below the pause", () => {
+ const cues: CaptionCuePayload[] = [
+ {
+ id: "caption-1",
+ startMs: 0,
+ endMs: 2_600,
+ text: "alpha beta",
+ words: [
+ { text: "alpha", startMs: 0, endMs: 600 },
+ { text: "beta", startMs: 2_200, endMs: 2_600, leadingSpace: true },
+ ],
+ },
+ ];
+ const result = segmentCuesIntoPhrases(cues, [], { pauseMs: 5_000 });
+ expect(result).toHaveLength(1);
+ expect(result[0].text).toBe("alpha beta");
+ });
+
+ it("splits a runaway phrase with no punctuation and no pause via the safety cap", () => {
+ const words = Array.from({ length: 12 }, (_, index) => ({
+ text: `w${index}`,
+ startMs: index * 300,
+ endMs: index * 300 + 300,
+ ...(index > 0 ? { leadingSpace: true } : {}),
+ }));
+ const cues: CaptionCuePayload[] = [
+ {
+ id: "caption-1",
+ startMs: 0,
+ endMs: 3_600,
+ text: words.map((w) => w.text).join(" "),
+ words,
+ },
+ ];
+ const result = segmentCuesIntoPhrases(cues, [], { maxPhraseMs: 2_000 });
+ expect(result.length).toBeGreaterThan(1);
+ expect(result.map((cue) => cue.text).join(" ")).toBe(words.map((w) => w.text).join(" "));
+ });
+
+ it("drops words that fall entirely inside a long silence (hallucination)", () => {
+ const cues: CaptionCuePayload[] = [
+ {
+ id: "caption-1",
+ startMs: 5_000,
+ endMs: 6_000,
+ text: "Thank you.",
+ words: [
+ { text: "Thank", startMs: 5_000, endMs: 5_500 },
+ { text: "you.", startMs: 5_500, endMs: 6_000, leadingSpace: true },
+ ],
+ },
+ ];
+ const result = segmentCuesIntoPhrases(cues, [{ startMs: 4_000, endMs: 9_000 }]);
+ expect(result).toHaveLength(0);
+ });
+
+ it("produces sorted, non-overlapping cues with sequential ids", () => {
+ const cues: CaptionCuePayload[] = [
+ {
+ id: "caption-1",
+ startMs: 0,
+ endMs: 2_400,
+ text: "First phrase here. Second phrase here. Third one.",
+ words: [
+ { text: "First", startMs: 0, endMs: 300 },
+ { text: "phrase", startMs: 300, endMs: 600, leadingSpace: true },
+ { text: "here.", startMs: 600, endMs: 900, leadingSpace: true },
+ { text: "Second", startMs: 900, endMs: 1_200, leadingSpace: true },
+ { text: "phrase", startMs: 1_200, endMs: 1_500, leadingSpace: true },
+ { text: "here.", startMs: 1_500, endMs: 1_800, leadingSpace: true },
+ { text: "Third", startMs: 1_800, endMs: 2_100, leadingSpace: true },
+ { text: "one.", startMs: 2_100, endMs: 2_400, leadingSpace: true },
+ ],
+ },
+ ];
+ const result = segmentCuesIntoPhrases(cues, []);
+ expect(result).toHaveLength(3);
+ for (let index = 0; index < result.length - 1; index += 1) {
+ expect(result[index].startMs).toBeLessThanOrEqual(result[index + 1].startMs);
+ expect(result[index].endMs).toBeLessThanOrEqual(result[index + 1].startMs);
+ }
+ expect(result.map((cue) => cue.id)).toEqual(
+ result.map((_, index) => `caption-${index + 1}`),
+ );
+ });
+
+ it("keeps a single-sentence word-less cue intact (legacy silence trim preserved)", () => {
+ const cues: CaptionCuePayload[] = [
+ { id: "caption-1", startMs: 0, endMs: 13_920, text: "Hello, this is a test" },
+ ];
+ // One sentence -> unchanged: trim leading silence, keep text (legacy behavior).
+ const result = segmentCuesIntoPhrases(cues, [{ startMs: 0, endMs: 3_000 }]);
+ expect(result).toHaveLength(1);
+ expect(result[0].startMs).toBe(3_000 - 80);
+ expect(result[0].endMs).toBe(13_920 + 80);
+ expect(result[0].text).toBe("Hello, this is a test");
+ });
+
+ it("splits a continuous word-less paragraph into one caption per sentence", () => {
+ // The reported bug: no word timings (SRT path) + continuous speech collapsed into one
+ // caption. It must now split on sentence punctuation even without word timings.
+ const cues: CaptionCuePayload[] = [
+ { id: "caption-1", startMs: 0, endMs: 3_000, text: "This is one." },
+ { id: "caption-2", startMs: 3_000, endMs: 6_000, text: "This is two." },
+ { id: "caption-3", startMs: 6_000, endMs: 9_000, text: "This is three." },
+ ];
+ const result = segmentCuesIntoPhrases(cues, []);
+ expect(result).toHaveLength(3);
+ expect(result.map((cue) => cue.text)).toEqual([
+ "This is one.",
+ "This is two.",
+ "This is three.",
+ ]);
+ for (let index = 0; index < result.length - 1; index += 1) {
+ expect(result[index].endMs).toBeLessThanOrEqual(result[index + 1].startMs);
+ }
+ expect(result.map((cue) => cue.id)).toEqual(["caption-1", "caption-2", "caption-3"]);
+ });
+
+ it("returns an empty array for empty input", () => {
+ expect(segmentCuesIntoPhrases([], [])).toEqual([]);
+ });
+});
+
+it("keeps the last timed word when an adjacent untimed cue overlaps padded edges", () => {
+ const result = segmentCuesIntoPhrases(
+ [
+ {
+ id: "timed",
+ startMs: 0,
+ endMs: 1200,
+ text: "Hello.",
+ words: [{ text: "Hello.", startMs: 0, endMs: 1000 }],
+ },
+ { id: "untimed", startMs: 950, endMs: 2000, text: "Next sentence." },
+ ],
+ [],
+ { minCaptionMs: 0, edgePadMs: 100 },
+ );
+ const timed = result.find((cue) => cue.words?.length)!;
+ expect(timed.endMs).toBeGreaterThanOrEqual(1000);
+ expect(result.some((cue) => cue.text === "Next sentence.")).toBe(true);
+});
diff --git a/electron/ipc/captions/segment.ts b/electron/ipc/captions/segment.ts
new file mode 100644
index 000000000..f1c889a70
--- /dev/null
+++ b/electron/ipc/captions/segment.ts
@@ -0,0 +1,475 @@
+import type { CaptionCuePayload, CaptionWordPayload } from "../types";
+import { buildCaptionTextFromWords } from "./parser";
+import { padSpans, resegmentCuesBySilence, type SilenceInterval } from "./silence";
+
+/**
+ * Phrase-aware caption segmentation.
+ *
+ * Whisper breaks speech on its own internal boundaries — not on sentences — so a single
+ * cue can run two phrases together, and re-segmenting purely on acoustic silence merges
+ * back-to-back sentences and misassigns boundary words. We instead walk Whisper's own
+ * word stream (which carries punctuation) and start a new caption at a real boundary:
+ * - the end of a sentence (`.`, `?`, `!`, `…`), or
+ * - a real pause — a large gap between two consecutive words, or a long ffmpeg
+ * `silencedetect` interval sitting in that gap.
+ *
+ * Because every break happens *between two consecutive words*, a word can never leak into
+ * the wrong caption (the failure mode of center-time region assignment). Commas/clauses
+ * stay inside a caption and a whole sentence is allowed to be one caption; only a high
+ * safety cap splits a runaway phrase with no punctuation and no pause.
+ *
+ * When the transcript has no word timings (SRT fallback) we first re-segment by acoustic
+ * silence (`resegmentCuesBySilence`) and then split each cue on its sentence punctuation, so
+ * a continuous paragraph still becomes one caption per sentence (timing is proportional).
+ */
+
+/** A gap (ms) between two consecutive words this long or longer starts a new phrase. */
+const DEFAULT_PHRASE_PAUSE_MS = 700;
+/** An ffmpeg `silencedetect` interval this long inside a word gap also starts a new phrase. */
+const DEFAULT_SPLIT_SILENCE_MS = 1_500;
+/** Padding (ms) kept around each phrase so captions don't feel clipped. */
+const DEFAULT_EDGE_PAD_MS = 80;
+/** Safety cap: a phrase with no sentence end and no pause is split once it gets this long. */
+const DEFAULT_MAX_PHRASE_MS = 12_000;
+
+/**
+ * A caption shorter than this (ms) is "too quick" and may be merged with an adjacent short
+ * caption so rapid-fire one-word sentences ("Okay." "Great.") don't each flash by alone.
+ */
+const DEFAULT_MIN_CAPTION_MS = 800;
+/** Only merge short captions separated by at most this gap (ms) — never across a real pause. */
+const DEFAULT_MERGE_GAP_MS = 400;
+/** A merged short-caption run never grows past this duration (ms) or character count. */
+const DEFAULT_MAX_MERGED_MS = 2_500;
+const DEFAULT_MAX_MERGED_CHARS = 80;
+
+export interface SegmentOptions {
+ /** Word gap (ms) that splits one phrase into two. Lower = more, shorter captions. */
+ pauseMs?: number;
+ /** Minimum acoustic silence (ms) inside a word gap that also splits a phrase. */
+ splitSilenceMs?: number;
+ /** Padding (ms) kept around each phrase. */
+ edgePadMs?: number;
+ /** Safety cap (ms) that splits a punctuation-less, pause-less runaway phrase. */
+ maxPhraseMs?: number;
+ /** A caption shorter than this (ms) may be merged with an adjacent short caption. */
+ minCaptionMs?: number;
+ /** Only merge short captions separated by at most this gap (ms). */
+ mergeGapMs?: number;
+ /** A merged short-caption run never grows past this duration (ms). */
+ maxMergedMs?: number;
+ /** A merged short-caption run never grows past this character count. */
+ maxMergedChars?: number;
+}
+
+interface CaptionPiece {
+ startMs: number;
+ endMs: number;
+ text: string;
+ words: CaptionWordPayload[];
+}
+
+const SENTENCE_END = /[.?!…。!?]$/;
+/** Closing quotes/brackets that can trail terminal punctuation, e.g. `said."` */
+const TRAILING_CLOSERS = /[)\]}"'”’»」』)】]}>]+$/u;
+
+/**
+ * Unambiguous English titles that take a trailing period mid-sentence. Kept deliberately
+ * short: only words that are never themselves a sentence (so we don't suppress a real
+ * break — e.g. "no" is excluded because "No." is a valid sentence). Dotted initialisms
+ * like "e.g."/"U.S."/"a.m." are handled by the regex below, not this list. `?`/`!`/`…`
+ * always end a sentence. For non-English audio this simply never matches.
+ */
+const ABBREVIATIONS = new Set(["mr", "mrs", "ms", "dr", "prof", "sr", "jr", "st", "vs", "etc"]);
+
+/** A trailing period belongs to an abbreviation/initialism rather than ending a sentence. */
+function isAbbreviation(text: string): boolean {
+ const trimmed = text.trim().replace(TRAILING_CLOSERS, "").trim();
+ if (!trimmed.endsWith(".")) {
+ return false; // only a plain period can be an abbreviation marker
+ }
+ const core = trimmed.slice(0, -1).toLowerCase();
+ if (core.length === 0) {
+ return false;
+ }
+ // Single-letter initial ("J.", "U.") or dotted initialism ("U.S.", "e.g.", "a.m.").
+ if (/^[a-z]$/.test(core) || /^[a-z](\.[a-z])+$/.test(core)) {
+ return true;
+ }
+ return ABBREVIATIONS.has(core);
+}
+
+/**
+ * True when a word's text ends a sentence, ignoring trailing closing quotes/brackets and
+ * common abbreviations (so "Mr. Smith" or "e.g." don't start a new caption).
+ */
+export function endsSentence(text: string): boolean {
+ const trimmed = text.trim().replace(TRAILING_CLOSERS, "").trim();
+ if (!SENTENCE_END.test(trimmed)) {
+ return false;
+ }
+ return !isAbbreviation(text);
+}
+
+/** Every cue carries usable word timing, so we can segment on the word stream. */
+function hasWordTimings(cues: CaptionCuePayload[]): boolean {
+ return cues.length > 0 && cues.every((cue) => Array.isArray(cue.words) && cue.words.length > 0);
+}
+
+/** Flatten all cues' words into one time-ordered stream, spacing across cue joins. */
+function flattenWords(cues: CaptionCuePayload[]): CaptionWordPayload[] {
+ const stream: CaptionWordPayload[] = [];
+ for (const cue of cues) {
+ const words = (cue.words ?? []) as CaptionWordPayload[];
+ words.forEach((word, index) => {
+ // A new cue continues the speech, so its first word leads with a space.
+ const leadingSpace =
+ stream.length > 0 && (index === 0 ? true : word.leadingSpace !== false);
+ stream.push({
+ text: word.text,
+ startMs: word.startMs,
+ endMs: word.endMs,
+ ...(leadingSpace ? { leadingSpace: true } : {}),
+ });
+ });
+ }
+ return stream.sort((left, right) => left.startMs - right.startMs || left.endMs - right.endMs);
+}
+
+/** Drop words that sit entirely inside a long detected silence (Whisper hallucinations). */
+function dropHallucinations(
+ words: CaptionWordPayload[],
+ silences: SilenceInterval[],
+ splitSilenceMs: number,
+): CaptionWordPayload[] {
+ const longSilences = silences.filter(
+ (silence) => silence.endMs - silence.startMs >= splitSilenceMs,
+ );
+ if (longSilences.length === 0) {
+ return words;
+ }
+ return words.filter(
+ (word) =>
+ !longSilences.some(
+ (silence) => silence.startMs <= word.startMs && word.endMs <= silence.endMs,
+ ),
+ );
+}
+
+/** A long silence interval overlaps the gap between two consecutive words. */
+function silenceInGap(
+ gapStartMs: number,
+ gapEndMs: number,
+ silences: SilenceInterval[],
+ splitSilenceMs: number,
+): boolean {
+ return silences.some(
+ (silence) =>
+ silence.endMs - silence.startMs >= splitSilenceMs &&
+ silence.startMs < gapEndMs &&
+ silence.endMs > gapStartMs,
+ );
+}
+
+/** Reset the first word's leading space so a phrase reads as its own line. */
+function normalizePhraseWords(words: CaptionWordPayload[]): CaptionWordPayload[] {
+ return words.map((word, index) => {
+ if (index === 0 && word.leadingSpace) {
+ const { leadingSpace: _leadingSpace, ...rest } = word;
+ return rest;
+ }
+ return word;
+ });
+}
+
+/** Group a cue's words into runs that each end on a sentence boundary. */
+function groupWordsBySentence(words: CaptionWordPayload[]): CaptionWordPayload[][] {
+ const groups: CaptionWordPayload[][] = [];
+ let current: CaptionWordPayload[] = [];
+ words.forEach((word, index) => {
+ current.push(word);
+ if (endsSentence(word.text) && index < words.length - 1) {
+ groups.push(current);
+ current = [];
+ }
+ });
+ if (current.length > 0) {
+ groups.push(current);
+ }
+ return groups;
+}
+
+/**
+ * Split a word-less cue's text into one cue per sentence, distributing the cue's time span
+ * across sentences by character length. Used on the fallback (no word timing) path so a
+ * continuous paragraph still becomes one caption per sentence.
+ */
+function splitTextBySentence(cue: CaptionCuePayload): CaptionCuePayload[] {
+ const tokens = cue.text.trim().split(/\s+/).filter(Boolean);
+ if (tokens.length <= 1) {
+ return [cue];
+ }
+
+ const groups: string[][] = [];
+ let current: string[] = [];
+ tokens.forEach((token, index) => {
+ current.push(token);
+ if (endsSentence(token) && index < tokens.length - 1) {
+ groups.push(current);
+ current = [];
+ }
+ });
+ if (current.length > 0) {
+ groups.push(current);
+ }
+ if (groups.length <= 1) {
+ return [cue];
+ }
+
+ const texts = groups.map((group) => group.join(" "));
+ const totalChars = texts.reduce((sum, text) => sum + text.length, 0) || 1;
+ const spanMs = Math.max(1, cue.endMs - cue.startMs);
+ let cursorMs = cue.startMs;
+ return texts.map((text, index) => {
+ const startMs = cursorMs;
+ const endMs =
+ index === texts.length - 1
+ ? cue.endMs
+ : Math.min(
+ cue.endMs - 1,
+ Math.round(startMs + (spanMs * text.length) / totalChars),
+ );
+ cursorMs = Math.max(startMs + 1, endMs);
+ return { id: cue.id, startMs, endMs: Math.max(startMs + 1, endMs), text };
+ });
+}
+
+/** Split one re-segmented cue into one cue per sentence (by words if present, else text). */
+function splitCueBySentences(cue: CaptionCuePayload): CaptionCuePayload[] {
+ const words = Array.isArray(cue.words) ? (cue.words as CaptionWordPayload[]) : [];
+ if (words.length === 0) {
+ return splitTextBySentence(cue);
+ }
+
+ const groups = groupWordsBySentence(words);
+ if (groups.length <= 1) {
+ return [cue];
+ }
+ return groups.map((group) => {
+ const phraseWords = normalizePhraseWords(group);
+ return {
+ id: cue.id,
+ startMs: phraseWords[0].startMs,
+ endMs: phraseWords[phraseWords.length - 1].endMs,
+ text: buildCaptionTextFromWords(phraseWords),
+ words: phraseWords,
+ };
+ });
+}
+
+/** Concatenate two adjacent cues into one, joining words (with a space) when both have them. */
+function mergeTwoCues(left: CaptionCuePayload, right: CaptionCuePayload): CaptionCuePayload {
+ const leftWords = Array.isArray(left.words) ? (left.words as CaptionWordPayload[]) : [];
+ const rightWords = Array.isArray(right.words) ? (right.words as CaptionWordPayload[]) : [];
+ if (leftWords.length > 0 && rightWords.length > 0) {
+ // The right cue's first word started its own phrase (no leading space) — restore it.
+ const joined = normalizePhraseWords([
+ ...leftWords,
+ ...rightWords.map((word, index) =>
+ index === 0 ? { ...word, leadingSpace: true } : word,
+ ),
+ ]);
+ return {
+ id: left.id,
+ startMs: left.startMs,
+ endMs: right.endMs,
+ text: buildCaptionTextFromWords(joined),
+ words: joined,
+ };
+ }
+ return {
+ id: left.id,
+ startMs: left.startMs,
+ endMs: right.endMs,
+ text: `${left.text} ${right.text}`.trim(),
+ };
+}
+
+interface MergeOptions {
+ minCaptionMs: number;
+ mergeGapMs: number;
+ maxMergedMs: number;
+ maxMergedChars: number;
+}
+
+/**
+ * Merge adjacent captions that are BOTH short and rapid-fire (tiny gap), so quick one-word
+ * sentences like "Okay." "Great." read as one caption instead of flashing by individually.
+ * Only merges when both sides are short, so a short caption never absorbs a full-length one,
+ * and never across a real pause or past the size caps.
+ */
+function mergeShortAdjacentCaptions(
+ cues: CaptionCuePayload[],
+ options: MergeOptions,
+): CaptionCuePayload[] {
+ if (cues.length <= 1) {
+ return cues;
+ }
+
+ const merged: CaptionCuePayload[] = [];
+ let group = cues[0];
+ for (let index = 1; index < cues.length; index += 1) {
+ const next = cues[index];
+ const groupDurationMs = group.endMs - group.startMs;
+ const nextDurationMs = next.endMs - next.startMs;
+ const gapMs = next.startMs - group.endMs;
+ const combinedDurationMs = next.endMs - group.startMs;
+ const combinedChars = group.text.length + next.text.length + 1;
+
+ const canMerge =
+ groupDurationMs < options.minCaptionMs &&
+ nextDurationMs < options.minCaptionMs &&
+ gapMs <= options.mergeGapMs &&
+ combinedDurationMs <= options.maxMergedMs &&
+ combinedChars <= options.maxMergedChars;
+
+ if (canMerge) {
+ group = mergeTwoCues(group, next);
+ } else {
+ merged.push(group);
+ group = next;
+ }
+ }
+ merged.push(group);
+ return merged;
+}
+
+/** Assign sequential, stable ids to the final cue list. */
+function renumberCues(cues: CaptionCuePayload[]): CaptionCuePayload[] {
+ return cues.map((cue, index) => ({ ...cue, id: `caption-${index + 1}` }));
+}
+
+/**
+ * Re-segment Whisper cues into one caption per sentence/phrase, then merge rapid-fire short
+ * sentences back together. Returns sorted cues with fresh ids, preserving overlaps required by word timing. Falls back to
+ * silence-only re-segmentation (plus sentence splitting) when the transcript has no word timings.
+ */
+export function segmentCuesIntoPhrases(
+ cues: CaptionCuePayload[],
+ silences: SilenceInterval[],
+ options: SegmentOptions = {},
+): CaptionCuePayload[] {
+ const pauseMs = options.pauseMs ?? DEFAULT_PHRASE_PAUSE_MS;
+ const splitSilenceMs = options.splitSilenceMs ?? DEFAULT_SPLIT_SILENCE_MS;
+ const edgePadMs = options.edgePadMs ?? DEFAULT_EDGE_PAD_MS;
+ const maxPhraseMs = options.maxPhraseMs ?? DEFAULT_MAX_PHRASE_MS;
+ const mergeOptions: MergeOptions = {
+ minCaptionMs: options.minCaptionMs ?? DEFAULT_MIN_CAPTION_MS,
+ mergeGapMs: options.mergeGapMs ?? DEFAULT_MERGE_GAP_MS,
+ maxMergedMs: options.maxMergedMs ?? DEFAULT_MAX_MERGED_MS,
+ maxMergedChars: options.maxMergedChars ?? DEFAULT_MAX_MERGED_CHARS,
+ };
+
+ if (cues.length === 0) {
+ return [];
+ }
+
+ // Keep untimed segments separate: merging them into timed segments would
+ // rebuild the text from words and silently discard the untimed speech.
+ if (!hasWordTimings(cues) && cues.some((cue) => (cue.words?.length ?? 0) > 0)) {
+ const segmented = cues.flatMap((cue) => segmentCuesIntoPhrases([cue], silences, options));
+ segmented.sort((a, b) => a.startMs - b.startMs || a.endMs - b.endMs);
+ for (let index = 0; index < segmented.length - 1; index++) {
+ const cue = segmented[index];
+ const lastWordEnd = Math.max(
+ cue.startMs,
+ ...(cue.words ?? []).map((word) => word.endMs),
+ );
+ cue.endMs = Math.max(lastWordEnd, Math.min(cue.endMs, segmented[index + 1].startMs));
+ }
+ return renumberCues(segmented.filter((cue) => cue.endMs > cue.startMs));
+ }
+
+ // No word timings (SRT path): the silence-only segmenter trims/merges by acoustic
+ // silence but can't see sentence boundaries, so a continuous paragraph would collapse
+ // into one caption. Re-segment by silence first, then split each cue on its sentence
+ // punctuation so we still get one caption per sentence.
+ if (!hasWordTimings(cues)) {
+ const base = resegmentCuesBySilence(cues, silences, { splitSilenceMs, edgePadMs });
+ const sentences = base.flatMap(splitCueBySentences);
+ return renumberCues(mergeShortAdjacentCaptions(sentences, mergeOptions));
+ }
+
+ const sortedCues = [...cues].sort(
+ (left, right) => left.startMs - right.startMs || left.endMs - right.endMs,
+ );
+ const stream = dropHallucinations(flattenWords(sortedCues), silences, splitSilenceMs);
+ if (stream.length === 0) {
+ return [];
+ }
+
+ const phrases: CaptionWordPayload[][] = [];
+ let current: CaptionWordPayload[] = [];
+
+ for (let index = 0; index < stream.length; index += 1) {
+ const word = stream[index];
+ current.push(word);
+
+ const next = stream[index + 1];
+ if (!next) {
+ break;
+ }
+
+ const gapMs = next.startMs - word.endMs;
+ const phraseDurationMs = word.endMs - current[0].startMs;
+ const shouldBreak =
+ endsSentence(word.text) ||
+ gapMs >= pauseMs ||
+ // Only consult acoustic silence when there's a real gap between the words. When
+ // consecutive words overlap or abut (gapMs <= 0) a silence interval spanning that
+ // region must not manufacture a bogus split.
+ (gapMs > 0 && silenceInGap(word.endMs, next.startMs, silences, splitSilenceMs)) ||
+ phraseDurationMs >= maxPhraseMs;
+
+ if (shouldBreak) {
+ phrases.push(current);
+ current = [];
+ }
+ }
+ if (current.length > 0) {
+ phrases.push(current);
+ }
+
+ const pieces: CaptionPiece[] = phrases
+ .map((words) => {
+ const phraseWords = normalizePhraseWords(words);
+ return {
+ startMs: phraseWords[0].startMs,
+ endMs: phraseWords[phraseWords.length - 1].endMs,
+ text: buildCaptionTextFromWords(phraseWords),
+ words: phraseWords,
+ };
+ })
+ .filter((piece) => piece.text.trim().length > 0);
+
+ if (pieces.length === 0) {
+ return [];
+ }
+
+ pieces.sort((left, right) => left.startMs - right.startMs || left.endMs - right.endMs);
+
+ const sentenceCues: CaptionCuePayload[] = pieces.map((piece) => ({
+ id: "",
+ startMs: piece.startMs,
+ endMs: piece.endMs,
+ text: piece.text,
+ ...(piece.words.length > 0 ? { words: piece.words } : {}),
+ }));
+ // Merge rapid-fire short captions BEFORE padding so merge eligibility sees the true
+ // speech gaps. Padding pulls cue edges toward each other, which would shrink the
+ // apparent gap and could merge two captions across a real pause sitting just above
+ // mergeGapMs. Pad the survivors afterward so envelopes still get their edge padding.
+ const merged = mergeShortAdjacentCaptions(sentenceCues, mergeOptions);
+ padSpans(merged, edgePadMs);
+ return renumberCues(merged);
+}
diff --git a/electron/ipc/captions/silence.test.ts b/electron/ipc/captions/silence.test.ts
new file mode 100644
index 000000000..941d1076f
--- /dev/null
+++ b/electron/ipc/captions/silence.test.ts
@@ -0,0 +1,141 @@
+import { describe, expect, it } from "vitest";
+import type { CaptionCuePayload } from "../types";
+import { parseSilenceIntervals, resegmentCuesBySilence } from "./silence";
+
+describe("parseSilenceIntervals", () => {
+ it("pairs silence_start / silence_end lines into ms intervals", () => {
+ const stderr = [
+ "[silencedetect @ 0x1] silence_start: 0",
+ "[silencedetect @ 0x1] silence_end: 3.2 | silence_duration: 3.2",
+ "[silencedetect @ 0x1] silence_start: 8.5",
+ "[silencedetect @ 0x1] silence_end: 9.3 | silence_duration: 0.8",
+ ].join("\n");
+ expect(parseSilenceIntervals(stderr)).toEqual([
+ { startMs: 0, endMs: 3_200 },
+ { startMs: 8_500, endMs: 9_300 },
+ ]);
+ });
+
+ it("treats a trailing silence_start with no end as running to end-of-audio", () => {
+ const stderr = "[silencedetect] silence_start: 12.0";
+ expect(parseSilenceIntervals(stderr)).toEqual([
+ { startMs: 12_000, endMs: Number.POSITIVE_INFINITY },
+ ]);
+ });
+
+ it("returns an empty array when there is no silence output", () => {
+ expect(parseSilenceIntervals("some unrelated ffmpeg log\n")).toEqual([]);
+ });
+});
+
+describe("resegmentCuesBySilence", () => {
+ it("trims leading silence off a word-less cue", () => {
+ const cues: CaptionCuePayload[] = [
+ { id: "caption-1", startMs: 0, endMs: 13_920, text: "Hello, this is a test" },
+ ];
+ const result = resegmentCuesBySilence(cues, [{ startMs: 0, endMs: 3_000 }]);
+ expect(result).toHaveLength(1);
+ // speech span [3000, 13920]; only an 80ms pad re-enters the silence
+ expect(result[0].startMs).toBe(3_000 - 80);
+ expect(result[0].endMs).toBe(13_920 + 80);
+ expect(result[0].text).toBe("Hello, this is a test");
+ });
+
+ it("does NOT split on a short pause (slowing down stays one phrase)", () => {
+ const cues: CaptionCuePayload[] = [
+ { id: "caption-1", startMs: 0, endMs: 8_000, text: "one two three four" },
+ ];
+ // 800ms pause is below the 1500ms split threshold -> absorbed, stays one phrase
+ const result = resegmentCuesBySilence(cues, [{ startMs: 3_000, endMs: 3_800 }]);
+ expect(result).toHaveLength(1);
+ expect(result[0].text).toBe("one two three four");
+ });
+
+ it("merges adjacent cues that are not separated by a long pause", () => {
+ const cues: CaptionCuePayload[] = [
+ { id: "a", startMs: 0, endMs: 3_000, text: "hello world" },
+ { id: "b", startMs: 3_000, endMs: 6_000, text: "how are you" },
+ ];
+ // continuous speech, no silence -> one merged phrase
+ const result = resegmentCuesBySilence(cues, []);
+ expect(result).toHaveLength(1);
+ expect(result[0].text).toBe("hello world how are you");
+ });
+
+ it("splits only at a long pause (>= 1500ms)", () => {
+ const cues: CaptionCuePayload[] = [
+ { id: "caption-1", startMs: 0, endMs: 10_000, text: "one two three four" },
+ ];
+ // 2000ms pause -> a real boundary
+ const result = resegmentCuesBySilence(cues, [{ startMs: 4_000, endMs: 6_000 }]);
+ expect(result).toHaveLength(2);
+ expect(result[0].endMs).toBeLessThanOrEqual(result[1].startMs);
+ expect(`${result[0].text} ${result[1].text}`).toBe("one two three four");
+ });
+
+ it("does not over-split: two short pauses inside a sentence stay one phrase", () => {
+ const cues: CaptionCuePayload[] = [
+ { id: "caption-1", startMs: 0, endMs: 9_000, text: "so this is a slow sentence" },
+ ];
+ const result = resegmentCuesBySilence(cues, [
+ { startMs: 2_000, endMs: 2_700 }, // 700ms
+ { startMs: 5_000, endMs: 5_900 }, // 900ms
+ ]);
+ expect(result).toHaveLength(1);
+ expect(result[0].text).toBe("so this is a slow sentence");
+ });
+
+ it("drops a cue that falls entirely inside silence (hallucination)", () => {
+ const cues: CaptionCuePayload[] = [
+ { id: "caption-1", startMs: 5_000, endMs: 7_000, text: "Thank you." },
+ ];
+ const result = resegmentCuesBySilence(cues, [{ startMs: 4_000, endMs: 9_000 }]);
+ expect(result).toHaveLength(0);
+ });
+
+ it("keeps a continuous cue (no silence) as a single phrase", () => {
+ const cues: CaptionCuePayload[] = [
+ { id: "caption-1", startMs: 1_000, endMs: 4_000, text: "continuous speech" },
+ ];
+ const result = resegmentCuesBySilence(cues, []);
+ expect(result).toHaveLength(1);
+ expect(result[0].text).toBe("continuous speech");
+ });
+
+ it("splits a word-timed cue by assigning each word to its speech region", () => {
+ const cues: CaptionCuePayload[] = [
+ {
+ id: "caption-1",
+ startMs: 0,
+ endMs: 10_000,
+ text: "alpha beta gamma",
+ words: [
+ { text: "alpha", startMs: 500, endMs: 1_000 },
+ { text: "beta", startMs: 1_000, endMs: 1_500, leadingSpace: true },
+ { text: "gamma", startMs: 7_000, endMs: 7_500, leadingSpace: true },
+ ],
+ },
+ ];
+ // 4000ms pause puts alpha+beta in region 1, gamma in region 2
+ const result = resegmentCuesBySilence(cues, [{ startMs: 2_000, endMs: 6_000 }]);
+ expect(result).toHaveLength(2);
+ expect(result[0].text).toBe("alpha beta");
+ expect(result[1].text).toBe("gamma");
+ expect(result[0].endMs).toBeLessThanOrEqual(result[1].startMs);
+ });
+
+ it("produces sorted, non-overlapping cues with sequential ids", () => {
+ const cues: CaptionCuePayload[] = [
+ { id: "x", startMs: 0, endMs: 6_000, text: "first phrase here" },
+ { id: "y", startMs: 6_000, endMs: 14_000, text: "second phrase here" },
+ ];
+ const result = resegmentCuesBySilence(cues, [{ startMs: 6_000, endMs: 8_000 }]);
+ for (let index = 0; index < result.length - 1; index += 1) {
+ expect(result[index].startMs).toBeLessThanOrEqual(result[index + 1].startMs);
+ expect(result[index].endMs).toBeLessThanOrEqual(result[index + 1].startMs);
+ }
+ expect(result.map((cue) => cue.id)).toEqual(
+ result.map((_, index) => `caption-${index + 1}`),
+ );
+ });
+});
diff --git a/electron/ipc/captions/silence.ts b/electron/ipc/captions/silence.ts
new file mode 100644
index 000000000..c8750ca1c
--- /dev/null
+++ b/electron/ipc/captions/silence.ts
@@ -0,0 +1,323 @@
+import type { CaptionCuePayload, CaptionWordPayload } from "../types";
+import { buildCaptionTextFromWords } from "./parser";
+
+/**
+ * Silence-aware caption re-segmentation.
+ *
+ * Whisper breaks speech on its own internal boundaries, not on real pauses, so cues can
+ * cover silence and continuous speech gets chopped arbitrarily. We re-segment against
+ * ground-truth silence from ffmpeg `silencedetect` instead:
+ * ffmpeg silencedetect (stderr) -> parseSilenceIntervals -> resegmentCuesBySilence
+ *
+ * A phrase only breaks at a long pause (>= splitSilenceMs) or at silence touching the
+ * transcript edges (leading/trailing trim). Short pauses are absorbed; speech not separated
+ * by a long pause is merged; a region with no speech is dropped (drops hallucinations).
+ */
+
+/** ffmpeg `silencedetect` noise floor. Quieter than this counts as silence. */
+export const SILENCE_NOISE_DB = -30;
+/** ffmpeg `silencedetect` minimum silence duration (seconds) it should report at all. */
+export const SILENCE_DETECT_MIN_S = 0.5;
+
+/** A pause must be at least this long (ms) to break one phrase into two. The "sensitivity" knob. */
+const DEFAULT_SPLIT_SILENCE_MS = 1_500;
+/** Padding (ms) kept around each phrase so captions don't feel clipped. */
+const DEFAULT_EDGE_PAD_MS = 80;
+/** Speech regions shorter than this (ms) are dropped as artifacts. */
+const DEFAULT_MIN_SPEECH_MS = 150;
+
+export interface SilenceInterval {
+ startMs: number;
+ /** `Number.POSITIVE_INFINITY` for a trailing silence that runs to end-of-audio. */
+ endMs: number;
+}
+
+export interface ResegmentOptions {
+ /** Minimum pause (ms) that splits a phrase. Higher = fewer splits. */
+ splitSilenceMs?: number;
+ /** Padding (ms) kept around each phrase. */
+ edgePadMs?: number;
+ /** Speech regions shorter than this (ms) are dropped. */
+ minSpeechMs?: number;
+}
+
+interface Span {
+ startMs: number;
+ endMs: number;
+}
+
+interface CaptionPiece extends Span {
+ text: string;
+ words?: CaptionWordPayload[];
+}
+
+/**
+ * Parse ffmpeg `silencedetect` stderr into ordered, non-overlapping silence intervals.
+ * Lines look like:
+ * [silencedetect @ 0x..] silence_start: 12.34
+ * [silencedetect @ 0x..] silence_end: 15.67 | silence_duration: 3.33
+ */
+export function parseSilenceIntervals(stderr: string): SilenceInterval[] {
+ const intervals: SilenceInterval[] = [];
+ let pendingStartMs: number | null = null;
+
+ for (const line of stderr.split(/\r?\n/)) {
+ const startMatch = line.match(/silence_start:\s*(-?[\d.]+)/);
+ if (startMatch) {
+ pendingStartMs = Math.max(0, Math.round(Number.parseFloat(startMatch[1]) * 1000));
+ continue;
+ }
+
+ const endMatch = line.match(/silence_end:\s*(-?[\d.]+)/);
+ if (endMatch && pendingStartMs !== null) {
+ const endMs = Math.round(Number.parseFloat(endMatch[1]) * 1000);
+ if (endMs > pendingStartMs) {
+ intervals.push({ startMs: pendingStartMs, endMs });
+ }
+ pendingStartMs = null;
+ }
+ }
+
+ // A trailing silence_start with no matching end runs to the end of the audio.
+ if (pendingStartMs !== null) {
+ intervals.push({ startMs: pendingStartMs, endMs: Number.POSITIVE_INFINITY });
+ }
+
+ return intervals.sort((left, right) => left.startMs - right.startMs);
+}
+
+/** Subtract a set of (sorted, non-overlapping) intervals from [startMs, endMs]. */
+function subtractIntervals(startMs: number, endMs: number, intervals: Span[]): Span[] {
+ const spans: Span[] = [];
+ let cursor = startMs;
+
+ for (const interval of intervals) {
+ if (interval.endMs <= startMs || interval.startMs >= endMs) {
+ continue;
+ }
+ const clippedStart = Math.max(interval.startMs, startMs);
+ if (clippedStart > cursor) {
+ spans.push({ startMs: cursor, endMs: clippedStart });
+ }
+ cursor = Math.max(cursor, Math.min(interval.endMs, endMs));
+ }
+
+ if (cursor < endMs) {
+ spans.push({ startMs: cursor, endMs });
+ }
+
+ return spans;
+}
+
+function nearestRegionIndex(regions: Span[], timeMs: number): number {
+ let bestIndex = 0;
+ let bestDistance = Number.POSITIVE_INFINITY;
+ regions.forEach((region, index) => {
+ const distance =
+ timeMs < region.startMs
+ ? region.startMs - timeMs
+ : timeMs > region.endMs
+ ? timeMs - region.endMs
+ : 0;
+ if (distance < bestDistance) {
+ bestDistance = distance;
+ bestIndex = index;
+ }
+ });
+ return bestIndex;
+}
+
+function joinText(existing: string, addition: string): string {
+ return [existing, addition]
+ .map((part) => part.trim())
+ .filter(Boolean)
+ .join(" ");
+}
+
+/**
+ * Distribute a cue's plain text across the regions it overlaps, proportionally to how much
+ * of the cue's duration falls in each region. Used only when a single Whisper cue straddles
+ * a long pause (rare) and has no word timing.
+ */
+function splitTextProportionally(text: string, overlaps: Span[]): string[] {
+ const tokens = text.trim().split(/\s+/).filter(Boolean);
+ if (tokens.length === 0) {
+ return overlaps.map(() => "");
+ }
+
+ const totalDurationMs =
+ overlaps.reduce((sum, span) => sum + (span.endMs - span.startMs), 0) || 1;
+ const result: string[] = [];
+ let cursor = 0;
+
+ overlaps.forEach((span, index) => {
+ const isLast = index === overlaps.length - 1;
+ const share = (span.endMs - span.startMs) / totalDurationMs;
+ const count = isLast
+ ? tokens.length - cursor
+ : Math.min(tokens.length - cursor, Math.round(tokens.length * share));
+ result.push(tokens.slice(cursor, cursor + count).join(" "));
+ cursor += count;
+ });
+
+ if (cursor < tokens.length) {
+ result[result.length - 1] = joinText(
+ result[result.length - 1],
+ tokens.slice(cursor).join(" "),
+ );
+ }
+
+ return result;
+}
+
+/** Pad spans toward neighbors by at most half the silent gap, so cues never overlap. */
+export function padSpans(spans: Span[], edgePadMs: number): void {
+ for (let index = 0; index < spans.length; index += 1) {
+ const prevEndMs = index > 0 ? spans[index - 1].endMs : null;
+ const nextStartMs = index < spans.length - 1 ? spans[index + 1].startMs : null;
+ const rawStart = spans[index].startMs;
+ const rawEnd = spans[index].endMs;
+
+ const leftPad =
+ prevEndMs === null
+ ? Math.min(edgePadMs, rawStart)
+ : Math.min(edgePadMs, Math.floor(Math.max(0, rawStart - prevEndMs) / 2));
+ const rightPad =
+ nextStartMs === null
+ ? edgePadMs
+ : Math.min(edgePadMs, Math.floor(Math.max(0, nextStartMs - rawEnd) / 2));
+
+ spans[index].startMs = Math.max(0, Math.round(rawStart - leftPad));
+ spans[index].endMs = Math.max(spans[index].startMs + 1, Math.round(rawEnd + rightPad));
+ }
+}
+
+/**
+ * Re-segment Whisper cues against detected silence into phrases that only break on long
+ * pauses. Returns sorted, non-overlapping cues with fresh ids. If no silence is detected
+ * the cues are merged into a single trimmed phrase (continuous speech).
+ */
+export function resegmentCuesBySilence(
+ cues: CaptionCuePayload[],
+ silences: SilenceInterval[],
+ options: ResegmentOptions = {},
+): CaptionCuePayload[] {
+ const splitSilenceMs = options.splitSilenceMs ?? DEFAULT_SPLIT_SILENCE_MS;
+ const edgePadMs = options.edgePadMs ?? DEFAULT_EDGE_PAD_MS;
+ const minSpeechMs = options.minSpeechMs ?? DEFAULT_MIN_SPEECH_MS;
+
+ if (cues.length === 0) {
+ return [];
+ }
+
+ const sortedCues = [...cues].sort(
+ (left, right) => left.startMs - right.startMs || left.endMs - right.endMs,
+ );
+ const transcriptStartMs = sortedCues[0].startMs;
+ const transcriptEndMs = sortedCues.reduce(
+ (max, cue) => Math.max(max, cue.endMs),
+ transcriptStartMs,
+ );
+
+ // Boundaries = long pauses, plus any silence touching the transcript edges (so leading
+ // and trailing silence is always trimmed even if short).
+ const boundaries: Span[] = silences
+ .map((silence) => ({
+ startMs: Math.max(silence.startMs, transcriptStartMs),
+ endMs: Math.min(silence.endMs, transcriptEndMs),
+ }))
+ .filter((span) => span.endMs > span.startMs)
+ .filter(
+ (span) =>
+ span.endMs - span.startMs >= splitSilenceMs ||
+ span.startMs <= transcriptStartMs ||
+ span.endMs >= transcriptEndMs,
+ )
+ .sort((left, right) => left.startMs - right.startMs);
+
+ const regions = subtractIntervals(transcriptStartMs, transcriptEndMs, boundaries).filter(
+ (region) => region.endMs - region.startMs >= minSpeechMs,
+ );
+ if (regions.length === 0) {
+ return [];
+ }
+
+ const regionTexts = regions.map(() => "");
+ const regionWords: CaptionWordPayload[][] = regions.map(() => []);
+
+ for (const cue of sortedCues) {
+ const overlapping = regions
+ .map((region, index) => ({ region, index }))
+ .filter(({ region }) => cue.startMs < region.endMs && cue.endMs > region.startMs);
+ if (overlapping.length === 0) {
+ continue;
+ }
+
+ const words = Array.isArray(cue.words) ? (cue.words as CaptionWordPayload[]) : [];
+ if (words.length > 0) {
+ for (const word of words) {
+ const center = (word.startMs + word.endMs) / 2;
+ const match = overlapping.find(
+ ({ region }) => center >= region.startMs && center <= region.endMs,
+ );
+ const targetIndex =
+ match?.index ??
+ overlapping[
+ nearestRegionIndex(
+ overlapping.map((entry) => entry.region),
+ center,
+ )
+ ].index;
+ regionWords[targetIndex].push(word);
+ }
+ continue;
+ }
+
+ if (overlapping.length === 1) {
+ const { index } = overlapping[0];
+ regionTexts[index] = joinText(regionTexts[index], cue.text);
+ continue;
+ }
+
+ const overlaps = overlapping.map(({ region }) => ({
+ startMs: Math.max(cue.startMs, region.startMs),
+ endMs: Math.min(cue.endMs, region.endMs),
+ }));
+ const texts = splitTextProportionally(cue.text, overlaps);
+ overlapping.forEach(({ index }, position) => {
+ regionTexts[index] = joinText(regionTexts[index], texts[position]);
+ });
+ }
+
+ const pieces = regions
+ .map((region, index): CaptionPiece => {
+ const words = regionWords[index]
+ .slice()
+ .sort((left, right) => left.startMs - right.startMs || left.endMs - right.endMs);
+ if (words.length > 0) {
+ return {
+ startMs: words[0].startMs,
+ endMs: words[words.length - 1].endMs,
+ text: buildCaptionTextFromWords(words),
+ words,
+ };
+ }
+ return {
+ startMs: region.startMs,
+ endMs: region.endMs,
+ text: regionTexts[index].trim(),
+ };
+ })
+ .filter((piece) => piece.text.trim().length > 0);
+
+ pieces.sort((left, right) => left.startMs - right.startMs || left.endMs - right.endMs);
+ padSpans(pieces, edgePadMs);
+
+ return pieces.map((piece, index) => ({
+ id: `caption-${index + 1}`,
+ startMs: piece.startMs,
+ endMs: piece.endMs,
+ text: piece.text,
+ ...(piece.words && piece.words.length > 0 ? { words: piece.words } : {}),
+ }));
+}
diff --git a/electron/ipc/captions/whisper.ts b/electron/ipc/captions/whisper.ts
new file mode 100644
index 000000000..67b04cd58
--- /dev/null
+++ b/electron/ipc/captions/whisper.ts
@@ -0,0 +1,155 @@
+import { createWriteStream } from "node:fs";
+import { constants as fsConstants } from "node:fs";
+import fs from "node:fs/promises";
+import { get as httpsGet } from "node:https";
+import type Electron from "electron";
+import {
+ WHISPER_MODEL_DIR,
+ WHISPER_MODEL_DOWNLOAD_URL,
+ WHISPER_SMALL_MODEL_PATH,
+} from "../constants";
+
+export function sendWhisperModelDownloadProgress(
+ webContents: Electron.WebContents,
+ payload: {
+ status: "idle" | "downloading" | "downloaded" | "error";
+ progress: number;
+ path?: string | null;
+ error?: string;
+ },
+) {
+ webContents.send("whisper-small-model-download-progress", payload);
+}
+
+export async function getWhisperSmallModelStatus() {
+ try {
+ await fs.access(WHISPER_SMALL_MODEL_PATH, fsConstants.R_OK);
+ return {
+ success: true,
+ exists: true,
+ path: WHISPER_SMALL_MODEL_PATH,
+ };
+ } catch {
+ return {
+ success: true,
+ exists: false,
+ path: null,
+ };
+ }
+}
+
+export function downloadFileWithProgress(
+ url: string,
+ destinationPath: string,
+ onProgress: (progress: number) => void,
+): Promise {
+ const request = (currentUrl: string, redirectCount = 0): Promise => {
+ return new Promise((resolve, reject) => {
+ const req = httpsGet(currentUrl, { timeout: 30_000 }, (response) => {
+ const statusCode = response.statusCode ?? 0;
+ const location = response.headers.location;
+
+ if (statusCode >= 300 && statusCode < 400 && location) {
+ response.resume();
+ if (redirectCount >= 5) {
+ reject(new Error("Too many redirects while downloading Whisper model."));
+ return;
+ }
+
+ const nextUrl = new URL(location, currentUrl).toString();
+ void request(nextUrl, redirectCount + 1)
+ .then(resolve)
+ .catch(reject);
+ return;
+ }
+
+ if (statusCode < 200 || statusCode >= 300) {
+ response.resume();
+ reject(new Error(`Whisper model download failed with status ${statusCode}.`));
+ return;
+ }
+
+ const totalBytes = Number.parseInt(
+ String(response.headers["content-length"] ?? "0"),
+ 10,
+ );
+ let downloadedBytes = 0;
+ const fileStream = createWriteStream(destinationPath);
+
+ response.on("data", (chunk: Buffer) => {
+ downloadedBytes += chunk.length;
+ if (Number.isFinite(totalBytes) && totalBytes > 0) {
+ onProgress(Math.min(100, Math.round((downloadedBytes / totalBytes) * 100)));
+ }
+ });
+
+ response.on("error", (error) => {
+ fileStream.destroy(error);
+ });
+
+ fileStream.on("error", (error) => {
+ response.destroy(error);
+ reject(error);
+ });
+
+ fileStream.on("finish", () => {
+ onProgress(100);
+ resolve();
+ });
+
+ response.pipe(fileStream);
+ });
+
+ req.on("error", reject);
+ req.on("timeout", () => {
+ req.destroy(new Error("Whisper model download timed out."));
+ });
+ });
+ };
+
+ return request(url);
+}
+
+export async function downloadWhisperSmallModel(
+ webContents: Electron.WebContents,
+): Promise {
+ await fs.mkdir(WHISPER_MODEL_DIR, { recursive: true });
+ const tempPath = `${WHISPER_SMALL_MODEL_PATH}.download`;
+
+ sendWhisperModelDownloadProgress(webContents, {
+ status: "downloading",
+ progress: 0,
+ path: null,
+ });
+
+ try {
+ await fs.rm(tempPath, { force: true });
+ await downloadFileWithProgress(WHISPER_MODEL_DOWNLOAD_URL, tempPath, (progress) => {
+ sendWhisperModelDownloadProgress(webContents, {
+ status: "downloading",
+ progress,
+ path: null,
+ });
+ });
+ await fs.rename(tempPath, WHISPER_SMALL_MODEL_PATH);
+ sendWhisperModelDownloadProgress(webContents, {
+ status: "downloaded",
+ progress: 100,
+ path: WHISPER_SMALL_MODEL_PATH,
+ });
+ return WHISPER_SMALL_MODEL_PATH;
+ } catch (error) {
+ await fs.rm(tempPath, { force: true }).catch(() => undefined);
+ sendWhisperModelDownloadProgress(webContents, {
+ status: "error",
+ progress: 0,
+ path: null,
+ error: String(error),
+ });
+ throw error;
+ }
+}
+
+export async function deleteWhisperSmallModel(): Promise {
+ await fs.rm(WHISPER_SMALL_MODEL_PATH, { force: true });
+}
diff --git a/electron/ipc/cloudShareContract.ts b/electron/ipc/cloudShareContract.ts
new file mode 100644
index 000000000..f85a28a2c
--- /dev/null
+++ b/electron/ipc/cloudShareContract.ts
@@ -0,0 +1,100 @@
+export type CloudShareTicket = {
+ uploadUrl: string;
+ shareUrl: string;
+ shareCode?: string;
+ method: "PUT";
+ headers: Record;
+ finalizeUrl?: string;
+};
+
+const TRUSTED_SHARE_ORIGINS = new Set([
+ "https://videos.recordly.dev",
+ "http://localhost:8787",
+ "http://127.0.0.1:8787",
+]);
+
+function parseUrl(value: unknown, label: string): URL {
+ if (typeof value !== "string") throw new Error(`${label} is missing.`);
+ const url = new URL(value);
+ if (url.username || url.password) throw new Error(`${label} cannot contain credentials.`);
+ return url;
+}
+
+export function normalizeCloudEndpoint(value: unknown): string {
+ if (typeof value !== "string" || value.trim().length === 0) {
+ throw new Error("Enter a cloud share endpoint.");
+ }
+ const url = parseUrl(value.trim(), "Cloud share endpoint");
+ if (!TRUSTED_SHARE_ORIGINS.has(url.origin)) {
+ throw new Error("Cloud sharing is only allowed through the Recordly service.");
+ }
+ if (url.pathname !== "/api/upload" || url.search) {
+ throw new Error("The cloud share endpoint is invalid.");
+ }
+ url.hash = "";
+ return url.toString();
+}
+
+function parseHttpUrl(value: unknown, label: string): string {
+ const url = parseUrl(value, `${label} from the server response`);
+ const localHttp =
+ url.protocol === "http:" && (url.hostname === "localhost" || url.hostname === "127.0.0.1");
+ if (url.protocol !== "https:" && !localHttp) {
+ throw new Error(`${label} must use HTTPS (HTTP is allowed for localhost).`);
+ }
+ return url.toString();
+}
+
+export function parseCloudShareTicket(value: unknown): CloudShareTicket {
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
+ throw new Error("The cloud share server returned an invalid response.");
+ }
+ const input = value as Record;
+ const uploadUrl = input.uploadUrl ?? input.uploadURL;
+ const shareUrl = input.shareUrl ?? input.shareURL;
+ const shareCode =
+ typeof input.shareCode === "string" && /^[a-z0-9]+$/.test(input.shareCode)
+ ? input.shareCode
+ : undefined;
+ const method = input.method === undefined ? "PUT" : input.method;
+ if (method !== "PUT") throw new Error("Only presigned PUT uploads are currently supported.");
+ const headers: Record = {};
+ if (input.headers !== undefined) {
+ if (!input.headers || typeof input.headers !== "object" || Array.isArray(input.headers)) {
+ throw new Error("Upload headers must be an object.");
+ }
+ for (const [key, headerValue] of Object.entries(input.headers)) {
+ if (typeof headerValue !== "string")
+ throw new Error("Upload header values must be strings.");
+ if (
+ [
+ "authorization",
+ "cookie",
+ "host",
+ "content-length",
+ "proxy-authorization",
+ ].includes(key.toLowerCase())
+ ) {
+ throw new Error(`The upload header ${key} is not allowed.`);
+ }
+ headers[key] = headerValue;
+ }
+ }
+ const parsedUploadUrl = parseHttpUrl(uploadUrl, "uploadUrl");
+ const parsedShareUrl = parseHttpUrl(shareUrl, "shareUrl");
+ let finalizeUrl: string | undefined;
+ if (typeof input.finalizeUrl === "string") {
+ finalizeUrl = parseHttpUrl(input.finalizeUrl, "finalizeUrl");
+ } else if (typeof input.shareCode === "string" && /^[a-z0-9]+$/.test(input.shareCode)) {
+ // Voom marks a recording ready after metadata is posted.
+ finalizeUrl = new URL(`/api/metadata/${input.shareCode}`, parsedShareUrl).toString();
+ }
+ return {
+ uploadUrl: parsedUploadUrl,
+ shareUrl: parsedShareUrl,
+ shareCode,
+ method,
+ headers,
+ finalizeUrl,
+ };
+}
diff --git a/electron/ipc/constants.ts b/electron/ipc/constants.ts
new file mode 100644
index 000000000..9e438b4b4
--- /dev/null
+++ b/electron/ipc/constants.ts
@@ -0,0 +1,29 @@
+import path from "node:path";
+import { USER_DATA_PATH } from "../appPaths";
+
+export const PROJECT_FILE_EXTENSION = "recordly";
+export const LEGACY_PROJECT_FILE_EXTENSIONS = ["openscreen"];
+export const PROJECTS_DIRECTORY_NAME = "Projects";
+export const PROJECT_THUMBNAIL_SUFFIX = ".preview.png";
+export const RECENT_PROJECTS_FILE = path.join(USER_DATA_PATH, "recent-projects.json");
+export const MAX_RECENT_PROJECTS = 16;
+export const SHORTCUTS_FILE = path.join(USER_DATA_PATH, "shortcuts.json");
+export const RECORDINGS_SETTINGS_FILE = path.join(USER_DATA_PATH, "recordings-settings.json");
+export const COUNTDOWN_SETTINGS_FILE = path.join(USER_DATA_PATH, "countdown-settings.json");
+export const APP_SETTINGS_FILE = path.join(USER_DATA_PATH, "app-settings.json");
+export const AUTO_RECORDING_PREFIX = "recording-";
+export const ALLOW_RECORDLY_WINDOW_CAPTURE = Boolean(process.env["VITE_DEV_SERVER_URL"]);
+export const RECORDING_SESSION_MANIFEST_SUFFIX = ".recordly-session.json";
+export const WHISPER_MODEL_DOWNLOAD_URL =
+ "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-small.bin";
+export const WHISPER_MODEL_DIR = path.join(USER_DATA_PATH, "whisper");
+export const WHISPER_SMALL_MODEL_PATH = path.join(WHISPER_MODEL_DIR, "ggml-small.bin");
+export const COMPANION_AUDIO_LAYOUTS = [
+ { platform: "mac" as const, systemSuffix: ".system.m4a", micSuffix: ".mic.m4a" },
+ { platform: "win" as const, systemSuffix: ".system.wav", micSuffix: ".mic.wav" },
+ { platform: "mac" as const, systemSuffix: ".system.webm", micSuffix: ".mic.webm" },
+];
+
+export const CURSOR_TELEMETRY_VERSION = 2;
+export const CURSOR_SAMPLE_INTERVAL_MS = 33;
+export const MAX_CURSOR_SAMPLES = 60 * 60 * 30; // 1 hour @ 30Hz
diff --git a/electron/ipc/cursor/bounds.ts b/electron/ipc/cursor/bounds.ts
new file mode 100644
index 000000000..1b8b8eaa8
--- /dev/null
+++ b/electron/ipc/cursor/bounds.ts
@@ -0,0 +1,209 @@
+import { execFile } from "node:child_process";
+import { promisify } from "node:util";
+import { ensureNativeWindowListBinary } from "../paths/binaries";
+import {
+ cachedNativeMacWindowSources,
+ cachedNativeMacWindowSourcesAtMs,
+ interactionCaptureCleanup,
+ selectedSource,
+ setCachedNativeMacWindowSources,
+ setCachedNativeMacWindowSourcesAtMs,
+ setInteractionCaptureCleanup,
+ setSelectedWindowBounds,
+ setWindowBoundsCaptureInterval,
+ windowBoundsCaptureInterval,
+} from "../state";
+import type { NativeMacWindowSource, SelectedSource, WindowBounds } from "../types";
+import { parseWindowId } from "../utils";
+import { resolveWindowsWindowBounds } from "../windowsWindowControl";
+
+const execFileAsync = promisify(execFile);
+
+export async function getNativeMacWindowSources(options?: { maxAgeMs?: number }) {
+ if (process.platform !== "darwin") {
+ return [] as NativeMacWindowSource[];
+ }
+
+ const maxAgeMs = options?.maxAgeMs ?? 5000;
+ const now = Date.now();
+ if (cachedNativeMacWindowSources && now - cachedNativeMacWindowSourcesAtMs < maxAgeMs) {
+ return cachedNativeMacWindowSources;
+ }
+
+ try {
+ const binaryPath = await ensureNativeWindowListBinary();
+ const { stdout } = await execFileAsync(binaryPath, [], {
+ timeout: 30000,
+ maxBuffer: 10 * 1024 * 1024,
+ });
+
+ const parsed = JSON.parse(stdout);
+ if (!Array.isArray(parsed)) {
+ return [] as NativeMacWindowSource[];
+ }
+
+ const entries = parsed.filter((entry: unknown): entry is NativeMacWindowSource => {
+ if (!entry || typeof entry !== "object") {
+ return false;
+ }
+
+ const candidate = entry as Partial;
+ return typeof candidate.id === "string" && typeof candidate.name === "string";
+ });
+
+ setCachedNativeMacWindowSources(entries);
+ setCachedNativeMacWindowSourcesAtMs(now);
+ return entries;
+ } catch {
+ return cachedNativeMacWindowSources ?? ([] as NativeMacWindowSource[]);
+ }
+}
+
+export function getWindowBoundsFromNativeSource(
+ source?: NativeMacWindowSource | null,
+): WindowBounds | null {
+ if (!source) {
+ return null;
+ }
+
+ const { x, y, width, height } = source;
+ if (
+ typeof x !== "number" ||
+ !Number.isFinite(x) ||
+ typeof y !== "number" ||
+ !Number.isFinite(y) ||
+ typeof width !== "number" ||
+ !Number.isFinite(width) ||
+ typeof height !== "number" ||
+ !Number.isFinite(height)
+ ) {
+ return null;
+ }
+
+ if (width <= 0 || height <= 0) {
+ return null;
+ }
+
+ return { x, y, width, height };
+}
+
+export async function resolveMacWindowBounds(source: SelectedSource): Promise {
+ const windowId = parseWindowId(source.id);
+ if (!windowId) {
+ return null;
+ }
+
+ try {
+ const nativeSources = await getNativeMacWindowSources({ maxAgeMs: 250 });
+ const matchedSource = nativeSources.find((entry) => parseWindowId(entry.id) === windowId);
+ return getWindowBoundsFromNativeSource(matchedSource);
+ } catch {
+ return null;
+ }
+}
+
+export function parseXwininfoBounds(stdout: string): WindowBounds | null {
+ const absX = stdout.match(/Absolute upper-left X:\s+(-?\d+)/);
+ const absY = stdout.match(/Absolute upper-left Y:\s+(-?\d+)/);
+ const width = stdout.match(/Width:\s+(\d+)/);
+ const height = stdout.match(/Height:\s+(\d+)/);
+
+ if (!absX || !absY || !width || !height) {
+ return null;
+ }
+
+ return {
+ x: Number.parseInt(absX[1], 10),
+ y: Number.parseInt(absY[1], 10),
+ width: Number.parseInt(width[1], 10),
+ height: Number.parseInt(height[1], 10),
+ };
+}
+
+export async function resolveLinuxWindowBounds(
+ source: SelectedSource,
+): Promise {
+ const windowId = parseWindowId(source?.id);
+
+ if (windowId) {
+ try {
+ const { stdout } = await execFileAsync("xwininfo", ["-id", String(windowId)], {
+ timeout: 1500,
+ });
+ const bounds = parseXwininfoBounds(stdout);
+ if (bounds && bounds.width > 0 && bounds.height > 0) {
+ return bounds;
+ }
+ } catch {
+ // fall back to title lookup below
+ }
+ }
+
+ const windowTitle =
+ typeof source.windowTitle === "string" ? source.windowTitle.trim() : source.name.trim();
+ if (!windowTitle) {
+ return null;
+ }
+
+ try {
+ const { stdout } = await execFileAsync("xwininfo", ["-name", windowTitle], {
+ timeout: 1500,
+ });
+ const bounds = parseXwininfoBounds(stdout);
+ return bounds && bounds.width > 0 && bounds.height > 0 ? bounds : null;
+ } catch {
+ return null;
+ }
+}
+
+export function stopInteractionCapture() {
+ if (interactionCaptureCleanup) {
+ interactionCaptureCleanup();
+ setInteractionCaptureCleanup(null);
+ }
+}
+
+export function stopWindowBoundsCapture() {
+ if (windowBoundsCaptureInterval) {
+ clearInterval(windowBoundsCaptureInterval);
+ setWindowBoundsCaptureInterval(null);
+ }
+ setSelectedWindowBounds(null);
+}
+
+async function refreshSelectedWindowBounds() {
+ if (!selectedSource?.id?.startsWith("window:")) {
+ setSelectedWindowBounds(null);
+ return;
+ }
+
+ let bounds: WindowBounds | null = null;
+
+ if (process.platform === "darwin") {
+ bounds = await resolveMacWindowBounds(selectedSource);
+ } else if (process.platform === "win32") {
+ bounds = await resolveWindowsWindowBounds(selectedSource);
+ } else if (process.platform === "linux") {
+ bounds = await resolveLinuxWindowBounds(selectedSource);
+ }
+
+ setSelectedWindowBounds(bounds);
+}
+
+export function startWindowBoundsCapture() {
+ stopWindowBoundsCapture();
+
+ if (
+ !["darwin", "win32", "linux"].includes(process.platform) ||
+ !selectedSource?.id?.startsWith("window:")
+ ) {
+ return;
+ }
+
+ void refreshSelectedWindowBounds();
+ setWindowBoundsCaptureInterval(
+ setInterval(() => {
+ void refreshSelectedWindowBounds();
+ }, 250),
+ );
+}
diff --git a/electron/ipc/cursor/interaction.test.ts b/electron/ipc/cursor/interaction.test.ts
new file mode 100644
index 000000000..4ea662414
--- /dev/null
+++ b/electron/ipc/cursor/interaction.test.ts
@@ -0,0 +1,92 @@
+import fs from "node:fs/promises";
+import os from "node:os";
+import path from "node:path";
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+vi.mock("electron", () => ({
+ app: {
+ getPath: vi.fn(() => "/tmp"),
+ setPath: vi.fn(),
+ isReady: vi.fn(() => true),
+ },
+}));
+
+import {
+ repairBundledUiohookBinaryForCurrentArch,
+ shouldStartGlobalInteractionHook,
+} from "./interaction";
+
+describe("shouldStartGlobalInteractionHook", () => {
+ it("does not start the synchronous uiohook event tap on macOS", () => {
+ expect(shouldStartGlobalInteractionHook("darwin")).toBe(false);
+ });
+
+ it("keeps global interaction capture enabled on Windows and Linux", () => {
+ expect(shouldStartGlobalInteractionHook("win32")).toBe(true);
+ expect(shouldStartGlobalInteractionHook("linux")).toBe(true);
+ });
+});
+
+describe("repairBundledUiohookBinaryForCurrentArch", () => {
+ const tempRoots: string[] = [];
+
+ afterEach(async () => {
+ await Promise.all(
+ tempRoots
+ .splice(0)
+ .map((tempRoot) => fs.rm(tempRoot, { recursive: true, force: true })),
+ );
+ });
+
+ it("promotes the bundled darwin-arm64 prebuild over a stale incompatible build", async () => {
+ const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "recordly-uiohook-"));
+ tempRoots.push(tempRoot);
+
+ const packageRoot = path.join(tempRoot, "uiohook-napi");
+ const prebuildPath = path.join(packageRoot, "prebuilds", "darwin-arm64", "node.napi.node");
+ const buildPath = path.join(packageRoot, "build", "Release", "uiohook_napi.node");
+ await fs.mkdir(path.dirname(prebuildPath), { recursive: true });
+ await fs.mkdir(path.dirname(buildPath), { recursive: true });
+ await fs.writeFile(prebuildPath, "arm64-prebuild");
+ await fs.writeFile(buildPath, "x64-build");
+
+ const log = vi.fn();
+ const repaired = repairBundledUiohookBinaryForCurrentArch(
+ Object.assign(
+ new Error(
+ "mach-o file, but is an incompatible architecture (have 'x86_64', need 'arm64')",
+ ),
+ {
+ code: "ERR_DLOPEN_FAILED",
+ },
+ ),
+ { packageRoot, platform: "darwin", arch: "arm64", log },
+ );
+
+ expect(repaired).toBe(true);
+ expect(await fs.readFile(buildPath, "utf8")).toBe("arm64-prebuild");
+ expect(log).toHaveBeenCalledWith(
+ "[CursorTelemetry] Repaired stale uiohook-napi binary using bundled darwin-arm64 prebuild.",
+ );
+ });
+
+ it("does not rewrite binaries for unrelated load failures", async () => {
+ const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "recordly-uiohook-"));
+ tempRoots.push(tempRoot);
+
+ const packageRoot = path.join(tempRoot, "uiohook-napi");
+ const buildPath = path.join(packageRoot, "build", "Release", "uiohook_napi.node");
+ await fs.mkdir(path.dirname(buildPath), { recursive: true });
+ await fs.writeFile(buildPath, "existing-build");
+
+ const repaired = repairBundledUiohookBinaryForCurrentArch(
+ Object.assign(new Error("some other dlopen failure"), {
+ code: "ERR_DLOPEN_FAILED",
+ }),
+ { packageRoot, platform: "darwin", arch: "arm64" },
+ );
+
+ expect(repaired).toBe(false);
+ expect(await fs.readFile(buildPath, "utf8")).toBe("existing-build");
+ });
+});
diff --git a/electron/ipc/cursor/interaction.ts b/electron/ipc/cursor/interaction.ts
new file mode 100644
index 000000000..47c42437f
--- /dev/null
+++ b/electron/ipc/cursor/interaction.ts
@@ -0,0 +1,333 @@
+import fs from "node:fs";
+import { createRequire } from "node:module";
+import path from "node:path";
+import {
+ hasLoggedInteractionHookFailure,
+ interactionCaptureCleanup,
+ isCursorCaptureActive,
+ lastLeftClick,
+ setHasLoggedInteractionHookFailure,
+ setInteractionCaptureCleanup,
+ setLastLeftClick,
+ setLinuxCursorScreenPoint,
+} from "../state";
+import type {
+ CursorInteractionType,
+ HookMouseEvent,
+ UiohookLike,
+ UiohookModuleNamespace,
+} from "../types";
+import {
+ getCursorCaptureElapsedMs,
+ getHookCursorScreenPoint,
+ getNormalizedCursorPoint,
+ isCursorCapturePaused,
+ pushCursorSample,
+} from "./telemetry";
+
+const nodeRequire = createRequire(import.meta.url);
+
+export function normalizeHookMouseButton(rawButton: unknown): 1 | 2 | 3 {
+ if (typeof rawButton !== "number" || !Number.isFinite(rawButton)) {
+ return 1;
+ }
+
+ if (rawButton === 2 || rawButton === 39) {
+ return 2;
+ }
+
+ if (rawButton === 3 || rawButton === 38) {
+ return 3;
+ }
+
+ return 1;
+}
+
+export function getHookMouseButton(event: HookMouseEvent | null | undefined): 1 | 2 | 3 {
+ return normalizeHookMouseButton(
+ event?.button ?? event?.mouseButton ?? event?.data?.button ?? event?.data?.mouseButton,
+ );
+}
+
+export function stopInteractionCapture() {
+ if (interactionCaptureCleanup) {
+ interactionCaptureCleanup();
+ setInteractionCaptureCleanup(null);
+ }
+}
+
+function isUiohookLike(value: unknown): value is UiohookLike {
+ const candidate = value as Partial | null;
+ return typeof candidate?.on === "function" && typeof candidate?.start === "function";
+}
+
+function resolveUiohookModule(moduleExports: UiohookModuleNamespace) {
+ const defaultExport = moduleExports.default;
+
+ if (moduleExports.uIOhook) {
+ return moduleExports.uIOhook;
+ }
+
+ if (moduleExports.uiohook) {
+ return moduleExports.uiohook;
+ }
+
+ if (moduleExports.Uiohook) {
+ return moduleExports.Uiohook;
+ }
+
+ if (isUiohookLike(defaultExport)) {
+ return defaultExport;
+ }
+
+ if (defaultExport?.uIOhook) {
+ return defaultExport.uIOhook;
+ }
+
+ if (defaultExport?.uiohook) {
+ return defaultExport.uiohook;
+ }
+
+ if (defaultExport?.Uiohook) {
+ return defaultExport.Uiohook;
+ }
+
+ return null;
+}
+
+function shouldRepairBundledUiohookBinary(error: unknown): error is NodeJS.ErrnoException {
+ if (process.platform !== "darwin") {
+ return false;
+ }
+
+ if (process.arch !== "arm64") {
+ return false;
+ }
+
+ const candidate = error as NodeJS.ErrnoException | null;
+ return (
+ candidate?.code === "ERR_DLOPEN_FAILED" &&
+ typeof candidate.message === "string" &&
+ candidate.message.includes("incompatible architecture")
+ );
+}
+
+export function repairBundledUiohookBinaryForCurrentArch(
+ error: unknown,
+ options?: {
+ packageRoot?: string;
+ platform?: NodeJS.Platform;
+ arch?: string;
+ log?: (message: string) => void;
+ },
+) {
+ const platform = options?.platform ?? process.platform;
+ const arch = options?.arch ?? process.arch;
+
+ if (platform !== "darwin" || arch !== "arm64") {
+ return false;
+ }
+
+ const candidate = error as NodeJS.ErrnoException | null;
+ if (
+ candidate?.code !== "ERR_DLOPEN_FAILED" ||
+ typeof candidate.message !== "string" ||
+ !candidate.message.includes("incompatible architecture")
+ ) {
+ return false;
+ }
+
+ const packageRoot =
+ options?.packageRoot ?? path.dirname(nodeRequire.resolve("uiohook-napi/package.json"));
+ const prebuildPath = path.join(packageRoot, "prebuilds", `darwin-${arch}`, "node.napi.node");
+ const buildPath = path.join(packageRoot, "build", "Release", "uiohook_napi.node");
+
+ if (!fs.existsSync(prebuildPath)) {
+ return false;
+ }
+
+ try {
+ fs.mkdirSync(path.dirname(buildPath), { recursive: true });
+ fs.copyFileSync(prebuildPath, buildPath);
+ (options?.log ?? console.warn)(
+ "[CursorTelemetry] Repaired stale uiohook-napi binary using bundled darwin-arm64 prebuild.",
+ );
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+function loadUiohookModule() {
+ try {
+ const moduleExports = nodeRequire("uiohook-napi") as UiohookModuleNamespace;
+ return resolveUiohookModule(moduleExports);
+ } catch (error) {
+ if (!shouldRepairBundledUiohookBinary(error)) {
+ throw error;
+ }
+
+ if (!repairBundledUiohookBinaryForCurrentArch(error)) {
+ throw error;
+ }
+
+ delete nodeRequire.cache[nodeRequire.resolve("uiohook-napi")];
+ const moduleExports = nodeRequire("uiohook-napi") as UiohookModuleNamespace;
+ return resolveUiohookModule(moduleExports);
+ }
+}
+
+export function shouldStartGlobalInteractionHook(platform: NodeJS.Platform = process.platform) {
+ // On macOS, uiohook can block forever while its native event tap starts
+ // (notably when Accessibility permission is unavailable or stale). Because
+ // start() executes synchronously, that freezes Electron's main thread and
+ // makes every window, including the recording HUD, unresponsive. Cursor
+ // position and visual-state telemetry still come from the existing native
+ // macOS monitor and Electron sampler.
+ return platform !== "darwin";
+}
+
+export function recordCursorMouseDown(button: 1 | 2 | 3) {
+ if (!isCursorCaptureActive || isCursorCapturePaused()) {
+ return;
+ }
+
+ const point = getNormalizedCursorPoint();
+ if (!point) {
+ return;
+ }
+
+ const timeMs = getCursorCaptureElapsedMs();
+ let interactionType: CursorInteractionType = "click";
+
+ if (button === 2) {
+ interactionType = "right-click";
+ } else if (button === 3) {
+ interactionType = "middle-click";
+ } else {
+ const thresholdMs = 350;
+ const distance = lastLeftClick
+ ? Math.hypot(point.cx - lastLeftClick.cx, point.cy - lastLeftClick.cy)
+ : Number.POSITIVE_INFINITY;
+
+ if (lastLeftClick && timeMs - lastLeftClick.timeMs <= thresholdMs && distance <= 0.04) {
+ interactionType = "double-click";
+ }
+
+ setLastLeftClick({ timeMs, cx: point.cx, cy: point.cy });
+ }
+
+ pushCursorSample(point.cx, point.cy, timeMs, interactionType);
+}
+
+export function recordCursorMouseUp() {
+ if (!isCursorCaptureActive || isCursorCapturePaused()) {
+ return;
+ }
+
+ const point = getNormalizedCursorPoint();
+ if (!point) {
+ return;
+ }
+
+ pushCursorSample(point.cx, point.cy, getCursorCaptureElapsedMs(), "mouseup");
+}
+
+export async function startInteractionCapture() {
+ if (!isCursorCaptureActive) {
+ return;
+ }
+
+ if (!["darwin", "win32", "linux"].includes(process.platform)) {
+ return;
+ }
+
+ if (!shouldStartGlobalInteractionHook()) {
+ console.warn("[CursorTelemetry] Skipping the blocking global interaction hook on macOS.");
+ return;
+ }
+
+ stopInteractionCapture();
+
+ try {
+ const hook = loadUiohookModule();
+ console.log(
+ "[CursorTelemetry] hook loaded:",
+ !!hook,
+ "has.on:",
+ typeof hook?.on,
+ "has.start:",
+ typeof hook?.start,
+ );
+ if (!isCursorCaptureActive) {
+ return;
+ }
+
+ if (!hook || typeof hook.on !== "function" || typeof hook.start !== "function") {
+ console.log("[CursorTelemetry] hook unusable — aborting interaction capture");
+ return;
+ }
+
+ const onMouseDown = (event: HookMouseEvent) => {
+ recordCursorMouseDown(getHookMouseButton(event));
+ };
+
+ const onMouseUp = () => {
+ recordCursorMouseUp();
+ };
+
+ const onMouseMove = (event: HookMouseEvent) => {
+ if (process.platform !== "linux" || !isCursorCaptureActive || isCursorCapturePaused()) {
+ return;
+ }
+
+ const point = getHookCursorScreenPoint(event);
+ if (!point) {
+ return;
+ }
+
+ setLinuxCursorScreenPoint({ x: point.x, y: point.y, updatedAt: Date.now() });
+ };
+
+ hook.on("mousedown", onMouseDown);
+ hook.on("mouseup", onMouseUp);
+ if (process.platform === "linux") {
+ hook.on("mousemove", onMouseMove);
+ }
+
+ setInteractionCaptureCleanup(() => {
+ try {
+ if (typeof hook.off === "function") {
+ hook.off("mousedown", onMouseDown);
+ hook.off("mouseup", onMouseUp);
+ if (process.platform === "linux") {
+ hook.off("mousemove", onMouseMove);
+ }
+ } else if (typeof hook.removeListener === "function") {
+ hook.removeListener("mousedown", onMouseDown);
+ hook.removeListener("mouseup", onMouseUp);
+ if (process.platform === "linux") {
+ hook.removeListener("mousemove", onMouseMove);
+ }
+ }
+ } catch {
+ // ignore listener cleanup errors
+ }
+
+ try {
+ if (typeof hook.stop === "function") {
+ hook.stop();
+ }
+ } catch {
+ // ignore hook shutdown errors
+ }
+ });
+
+ hook.start();
+ } catch (error) {
+ if (!hasLoggedInteractionHookFailure) {
+ setHasLoggedInteractionHookFailure(true);
+ console.warn("[CursorTelemetry] Global interaction capture unavailable:", error);
+ }
+ }
+}
diff --git a/electron/ipc/cursor/monitor.ts b/electron/ipc/cursor/monitor.ts
new file mode 100644
index 000000000..8a507d56d
--- /dev/null
+++ b/electron/ipc/cursor/monitor.ts
@@ -0,0 +1,163 @@
+import { spawn } from "node:child_process";
+import { constants as fsConstants } from "node:fs";
+import fs from "node:fs/promises";
+import { BrowserWindow } from "electron";
+import { ensureNativeCursorMonitorBinary, getCursorMonitorExePath } from "../paths/binaries";
+import {
+ currentCursorVisualType,
+ nativeCursorMonitorOutputBuffer,
+ nativeCursorMonitorProcess,
+ setCurrentCursorVisualType,
+ setNativeCursorMonitorOutputBuffer,
+ setNativeCursorMonitorProcess,
+} from "../state";
+import type { CursorVisualType } from "../types";
+import { recordCursorMouseDown, recordCursorMouseUp } from "./interaction";
+
+export function emitCursorStateChanged(cursorType: CursorVisualType) {
+ BrowserWindow.getAllWindows().forEach((window) => {
+ if (!window.isDestroyed()) {
+ window.webContents.send("cursor-state-changed", { cursorType });
+ }
+ });
+}
+
+export function handleCursorMonitorStdout(chunk: Buffer) {
+ setNativeCursorMonitorOutputBuffer(nativeCursorMonitorOutputBuffer + chunk.toString());
+ const lines = nativeCursorMonitorOutputBuffer.split(/\r?\n/);
+ setNativeCursorMonitorOutputBuffer(lines.pop() ?? "");
+
+ for (const line of lines) {
+ const interactionMatch = line.match(/^INTERACTION:(mousedown|mouseup)(?::([123]))?$/);
+ if (interactionMatch) {
+ if (interactionMatch[1] === "mouseup") {
+ recordCursorMouseUp();
+ } else {
+ const button = Number(interactionMatch[2]);
+ recordCursorMouseDown(button === 2 || button === 3 ? button : 1);
+ }
+ continue;
+ }
+
+ const match = line.match(/^STATE:(.+)$/);
+ if (!match) continue;
+ const next = match[1].trim() as CursorVisualType;
+ if (
+ next === "arrow" ||
+ next === "text" ||
+ next === "pointer" ||
+ next === "crosshair" ||
+ next === "open-hand" ||
+ next === "closed-hand" ||
+ next === "resize-ew" ||
+ next === "resize-ns" ||
+ next === "not-allowed"
+ ) {
+ if (currentCursorVisualType !== next) {
+ setCurrentCursorVisualType(next);
+ // sampleCursorStateChange is called from cursor/telemetry.ts via the handler
+ emitCursorStateChanged(next);
+ }
+ }
+ }
+}
+
+export function stopNativeCursorMonitor() {
+ setCurrentCursorVisualType("arrow");
+
+ if (!nativeCursorMonitorProcess) {
+ return;
+ }
+
+ try {
+ nativeCursorMonitorProcess.stdin.write("stop\n");
+ } catch {
+ // ignore stop signal issues
+ }
+ try {
+ nativeCursorMonitorProcess.kill();
+ } catch {
+ // ignore kill issues
+ }
+
+ setNativeCursorMonitorProcess(null);
+ setNativeCursorMonitorOutputBuffer("");
+}
+
+export async function startNativeCursorMonitor() {
+ stopNativeCursorMonitor();
+
+ if (process.platform !== "darwin" && process.platform !== "win32") {
+ setCurrentCursorVisualType("arrow");
+ return;
+ }
+
+ try {
+ let helperPath: string;
+ if (process.platform === "win32") {
+ helperPath = getCursorMonitorExePath();
+ try {
+ // Use F_OK on Windows — X_OK is meaningless and can give false positives
+ await fs.access(helperPath, fsConstants.F_OK);
+ } catch {
+ console.warn("Windows cursor monitor helper missing:", helperPath);
+ setCurrentCursorVisualType("arrow");
+ return;
+ }
+ } else {
+ helperPath = await ensureNativeCursorMonitorBinary();
+ }
+
+ setNativeCursorMonitorOutputBuffer("");
+ setCurrentCursorVisualType("arrow");
+
+ let proc: ReturnType | null;
+ try {
+ proc = spawn(helperPath, [], {
+ stdio: ["pipe", "pipe", "pipe"],
+ });
+ } catch (spawnError) {
+ console.warn("Failed to spawn cursor monitor:", spawnError);
+ setNativeCursorMonitorProcess(null);
+ setCurrentCursorVisualType("arrow");
+ return;
+ }
+
+ setNativeCursorMonitorProcess(proc as Parameters[0]);
+ const spawned = proc;
+ if (!spawned) {
+ setNativeCursorMonitorProcess(null);
+ setCurrentCursorVisualType("arrow");
+ return;
+ }
+
+ spawned.once("error", (error) => {
+ console.warn("Native cursor monitor process error:", error);
+ if (nativeCursorMonitorProcess === spawned) {
+ setNativeCursorMonitorProcess(null);
+ setNativeCursorMonitorOutputBuffer("");
+ setCurrentCursorVisualType("arrow");
+ }
+ });
+
+ if (spawned.stdout) spawned.stdout.on("data", handleCursorMonitorStdout);
+ if (spawned.stderr) {
+ spawned.stderr.on("data", () => {
+ // Drain stderr so helper logging cannot block the process.
+ });
+ }
+
+ spawned.once("close", () => {
+ if (nativeCursorMonitorProcess === spawned) {
+ setNativeCursorMonitorProcess(null);
+ setNativeCursorMonitorOutputBuffer("");
+ setCurrentCursorVisualType("arrow");
+ }
+ });
+ } catch (error) {
+ console.warn("Failed to start native cursor monitor:", error);
+ setNativeCursorMonitorProcess(null);
+ setNativeCursorMonitorOutputBuffer("");
+ setCurrentCursorVisualType("arrow");
+ }
+}
diff --git a/electron/ipc/cursor/telemetry.test.ts b/electron/ipc/cursor/telemetry.test.ts
new file mode 100644
index 000000000..03613859b
--- /dev/null
+++ b/electron/ipc/cursor/telemetry.test.ts
@@ -0,0 +1,122 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { CURSOR_TELEMETRY_VERSION } from "../constants";
+
+const { writeFile, rm } = vi.hoisted(() => ({
+ writeFile: vi.fn(),
+ rm: vi.fn(),
+}));
+
+vi.mock("node:fs/promises", () => ({
+ default: {
+ writeFile,
+ rm,
+ },
+}));
+
+vi.mock("electron", () => ({
+ app: {
+ getPath: vi.fn(() => "/tmp"),
+ },
+}));
+
+vi.mock("../utils", () => ({
+ getTelemetryPathForVideo: vi.fn(() => "/tmp/recording.cursor.json"),
+ getScreen: vi.fn(() => ({
+ getCursorScreenPoint: () => ({ x: 0, y: 0 }),
+ getPrimaryDisplay: () => ({ scaleFactor: 1 }),
+ getDisplayNearestPoint: () => ({ bounds: { x: 0, y: 0, width: 1, height: 1 } }),
+ getAllDisplays: () => [],
+ })),
+}));
+
+import { activeCursorSamples, setActiveCursorSamples, setCursorCaptureStartTimeMs } from "../state";
+import {
+ getCursorCaptureElapsedMs,
+ normalizeCursorTelemetrySamples,
+ pauseCursorCapture,
+ pauseCursorCaptureAtBoundary,
+ pushCursorSample,
+ resetCursorCaptureClock,
+ resumeCursorCapture,
+ writeCursorTelemetry,
+} from "./telemetry";
+
+describe("cursor telemetry pause clock", () => {
+ beforeEach(() => {
+ writeFile.mockReset();
+ rm.mockReset();
+ setCursorCaptureStartTimeMs(1_000);
+ setActiveCursorSamples([]);
+ resetCursorCaptureClock();
+ });
+
+ it("subtracts paused time from elapsed cursor timestamps", () => {
+ expect(getCursorCaptureElapsedMs(1_120)).toBe(120);
+
+ pauseCursorCapture(1_200);
+ expect(getCursorCaptureElapsedMs(1_450)).toBe(200);
+
+ resumeCursorCapture(1_700);
+ expect(getCursorCaptureElapsedMs(1_900)).toBe(400);
+ });
+
+ it("ignores duplicate pause or resume transitions", () => {
+ pauseCursorCapture(1_150);
+ pauseCursorCapture(1_250);
+ resumeCursorCapture(1_500);
+ resumeCursorCapture(1_650);
+
+ expect(getCursorCaptureElapsedMs(1_900)).toBe(550);
+ });
+
+ it("drops cursor samples captured after the renderer pause boundary", () => {
+ pushCursorSample(0.1, 0.1, 120, "move");
+ pushCursorSample(0.2, 0.2, 205, "move");
+ pushCursorSample(0.3, 0.3, 260, "move");
+
+ pauseCursorCaptureAtBoundary(1_200);
+
+ expect(getCursorCaptureElapsedMs(1_500)).toBe(200);
+ expect(activeCursorSamples.map((sample) => sample.timeMs)).toEqual([120]);
+
+ resumeCursorCapture(1_700);
+ expect(getCursorCaptureElapsedMs(1_900)).toBe(400);
+ });
+
+ it("normalizes cursor telemetry samples before persisting them", async () => {
+ const samples = normalizeCursorTelemetrySamples([
+ { timeMs: 30, cx: 2, cy: -1, interactionType: "click", cursorType: "pointer" },
+ { timeMs: -10, cx: Number.NaN, cy: 0.2, interactionType: "drag", cursorType: "ibeam" },
+ { timeMs: 10, cx: 0.25, cy: 0.75, interactionType: "move", cursorType: "text" },
+ ]);
+
+ expect(samples).toEqual([
+ { timeMs: 0, cx: 0.5, cy: 0.2, interactionType: undefined, cursorType: undefined },
+ { timeMs: 10, cx: 0.25, cy: 0.75, interactionType: "move", cursorType: "text" },
+ { timeMs: 30, cx: 1, cy: 0, interactionType: "click", cursorType: "pointer" },
+ ]);
+
+ await writeCursorTelemetry("/tmp/recording.mp4", samples);
+
+ expect(writeFile).toHaveBeenCalledWith(
+ "/tmp/recording.cursor.json",
+ JSON.stringify(
+ {
+ version: CURSOR_TELEMETRY_VERSION,
+ samples,
+ },
+ null,
+ 2,
+ ),
+ "utf-8",
+ );
+ expect(rm).not.toHaveBeenCalled();
+ });
+
+ it("removes the sidecar when saving an empty cursor telemetry payload", async () => {
+ await writeCursorTelemetry("/tmp/recording.mp4", []);
+
+ expect(rm).toHaveBeenCalledWith("/tmp/recording.cursor.json", { force: true });
+ expect(writeFile).not.toHaveBeenCalled();
+ });
+});
diff --git a/electron/ipc/cursor/telemetry.ts b/electron/ipc/cursor/telemetry.ts
new file mode 100644
index 000000000..73f62714e
--- /dev/null
+++ b/electron/ipc/cursor/telemetry.ts
@@ -0,0 +1,331 @@
+import fs from "node:fs/promises";
+import {
+ CURSOR_SAMPLE_INTERVAL_MS,
+ CURSOR_TELEMETRY_VERSION,
+ MAX_CURSOR_SAMPLES,
+} from "../constants";
+import {
+ activeCursorSamples,
+ currentCursorVisualType,
+ cursorCaptureAccumulatedPausedMs,
+ cursorCaptureInterval,
+ cursorCapturePauseStartedAtMs,
+ cursorCaptureStartTimeMs,
+ isCursorCaptureActive,
+ linuxCursorScreenPoint,
+ pendingCursorSamples,
+ selectedSource,
+ selectedWindowBounds,
+ setActiveCursorSamples,
+ setCursorCaptureAccumulatedPausedMs,
+ setCursorCaptureInterval,
+ setCursorCapturePauseStartedAtMs,
+ setPendingCursorSamples,
+} from "../state";
+import type { CursorInteractionType, CursorTelemetryPoint, CursorVisualType } from "../types";
+import { getScreen, getTelemetryPathForVideo } from "../utils";
+
+export function clamp(value: number, min: number, max: number) {
+ return Math.min(max, Math.max(min, value));
+}
+
+export function normalizeCursorTelemetrySamples(rawSamples: unknown): CursorTelemetryPoint[] {
+ const samples = Array.isArray(rawSamples)
+ ? rawSamples
+ : Array.isArray((rawSamples as { samples?: unknown[] } | null | undefined)?.samples)
+ ? ((rawSamples as { samples: unknown[] }).samples ?? [])
+ : [];
+ const boundedSamples = samples.slice(0, MAX_CURSOR_SAMPLES);
+
+ return boundedSamples
+ .filter((sample: unknown) => Boolean(sample && typeof sample === "object"))
+ .map((sample: unknown) => {
+ const point = sample as Partial;
+ return {
+ timeMs:
+ typeof point.timeMs === "number" && Number.isFinite(point.timeMs)
+ ? Math.max(0, point.timeMs)
+ : 0,
+ cx:
+ typeof point.cx === "number" && Number.isFinite(point.cx)
+ ? clamp(point.cx, 0, 1)
+ : 0.5,
+ cy:
+ typeof point.cy === "number" && Number.isFinite(point.cy)
+ ? clamp(point.cy, 0, 1)
+ : 0.5,
+ interactionType:
+ point.interactionType === "click" ||
+ point.interactionType === "double-click" ||
+ point.interactionType === "right-click" ||
+ point.interactionType === "middle-click" ||
+ point.interactionType === "move" ||
+ point.interactionType === "mouseup"
+ ? point.interactionType
+ : undefined,
+ cursorType:
+ point.cursorType === "arrow" ||
+ point.cursorType === "text" ||
+ point.cursorType === "pointer" ||
+ point.cursorType === "crosshair" ||
+ point.cursorType === "open-hand" ||
+ point.cursorType === "closed-hand" ||
+ point.cursorType === "resize-ew" ||
+ point.cursorType === "resize-ns" ||
+ point.cursorType === "not-allowed"
+ ? point.cursorType
+ : undefined,
+ };
+ })
+ .sort((a, b) => a.timeMs - b.timeMs);
+}
+
+export async function writeCursorTelemetry(videoPath: string, samples: unknown) {
+ const telemetryPath = getTelemetryPathForVideo(videoPath);
+ const normalizedSamples = normalizeCursorTelemetrySamples(samples);
+
+ if (normalizedSamples.length === 0) {
+ await fs.rm(telemetryPath, { force: true });
+ return normalizedSamples;
+ }
+
+ await fs.writeFile(
+ telemetryPath,
+ JSON.stringify({ version: CURSOR_TELEMETRY_VERSION, samples: normalizedSamples }, null, 2),
+ "utf-8",
+ );
+
+ return normalizedSamples;
+}
+
+export function stopCursorCapture() {
+ if (cursorCaptureInterval) {
+ clearTimeout(cursorCaptureInterval);
+ setCursorCaptureInterval(null);
+ }
+}
+
+export function resetCursorCaptureClock() {
+ setCursorCaptureAccumulatedPausedMs(0);
+ setCursorCapturePauseStartedAtMs(null);
+}
+
+export function isCursorCapturePaused() {
+ return cursorCapturePauseStartedAtMs !== null;
+}
+
+export function pauseCursorCapture(pausedAtMs: number) {
+ if (cursorCapturePauseStartedAtMs !== null) {
+ return;
+ }
+
+ setCursorCapturePauseStartedAtMs(pausedAtMs);
+}
+
+export function pauseCursorCaptureAtBoundary(pausedAtMs: number) {
+ if (cursorCapturePauseStartedAtMs !== null) {
+ return;
+ }
+
+ const pausedElapsedMs = getCursorCaptureElapsedMs(pausedAtMs);
+ setActiveCursorSamples(
+ activeCursorSamples.filter((sample) => sample.timeMs <= pausedElapsedMs),
+ );
+ setCursorCapturePauseStartedAtMs(pausedAtMs);
+}
+
+export function resumeCursorCapture(resumedAtMs: number) {
+ if (cursorCapturePauseStartedAtMs === null) {
+ return;
+ }
+
+ const pauseDurationMs = Math.max(0, resumedAtMs - cursorCapturePauseStartedAtMs);
+ setCursorCaptureAccumulatedPausedMs(cursorCaptureAccumulatedPausedMs + pauseDurationMs);
+ setCursorCapturePauseStartedAtMs(null);
+}
+
+export function getCursorCaptureElapsedMs(nowMs = Date.now()) {
+ if (!Number.isFinite(cursorCaptureStartTimeMs) || cursorCaptureStartTimeMs <= 0) {
+ return 0;
+ }
+
+ const safeNowMs = Math.max(cursorCaptureStartTimeMs, nowMs);
+ const activePauseDurationMs =
+ cursorCapturePauseStartedAtMs === null
+ ? 0
+ : Math.max(0, safeNowMs - cursorCapturePauseStartedAtMs);
+
+ return Math.max(
+ 0,
+ safeNowMs -
+ cursorCaptureStartTimeMs -
+ Math.max(0, cursorCaptureAccumulatedPausedMs) -
+ activePauseDurationMs,
+ );
+}
+
+export function getNormalizedCursorPoint() {
+ const fallbackCursor = getScreen().getCursorScreenPoint();
+ const linuxCursorCache = process.platform === "linux" ? linuxCursorScreenPoint : null;
+ const isLinuxCacheFresh = !!linuxCursorCache && Date.now() - linuxCursorCache.updatedAt <= 1000;
+
+ const primarySf =
+ process.platform !== "darwin" ? getScreen().getPrimaryDisplay().scaleFactor || 1 : 1;
+
+ const cursor = isLinuxCacheFresh
+ ? { x: linuxCursorCache.x / primarySf, y: linuxCursorCache.y / primarySf }
+ : fallbackCursor;
+
+ const windowBounds = selectedSource?.id?.startsWith("window:") ? selectedWindowBounds : null;
+ if (windowBounds) {
+ const sf =
+ process.platform === "win32" || process.platform === "darwin"
+ ? 1
+ : getScreen().getDisplayNearestPoint({
+ x: windowBounds.x / primarySf,
+ y: windowBounds.y / primarySf,
+ }).scaleFactor || 1;
+ const width = Math.max(1, windowBounds.width / sf);
+ const height = Math.max(1, windowBounds.height / sf);
+
+ return {
+ cx: clamp((cursor.x - windowBounds.x / sf) / width, 0, 1),
+ cy: clamp((cursor.y - windowBounds.y / sf) / height, 0, 1),
+ };
+ }
+
+ const sourceDisplayId = Number(selectedSource?.display_id);
+ const sourceDisplay = Number.isFinite(sourceDisplayId)
+ ? (getScreen()
+ .getAllDisplays()
+ .find((display) => display.id === sourceDisplayId) ?? null)
+ : null;
+ const display = sourceDisplay ?? getScreen().getDisplayNearestPoint(cursor);
+ const bounds = display.bounds;
+ const width = Math.max(1, bounds.width);
+ const height = Math.max(1, bounds.height);
+
+ const cx = clamp((cursor.x - bounds.x) / width, 0, 1);
+ const cy = clamp((cursor.y - bounds.y) / height, 0, 1);
+ return { cx, cy };
+}
+
+export function getHookCursorScreenPoint(
+ event:
+ | {
+ x?: number;
+ y?: number;
+ data?: { x?: number; y?: number; screenX?: number; screenY?: number };
+ screenX?: number;
+ screenY?: number;
+ }
+ | null
+ | undefined,
+): { x: number; y: number } | null {
+ const rawX = event?.x ?? event?.data?.x ?? event?.screenX ?? event?.data?.screenX;
+ const rawY = event?.y ?? event?.data?.y ?? event?.screenY ?? event?.data?.screenY;
+
+ if (
+ typeof rawX !== "number" ||
+ !Number.isFinite(rawX) ||
+ typeof rawY !== "number" ||
+ !Number.isFinite(rawY)
+ ) {
+ return null;
+ }
+
+ return { x: rawX, y: rawY };
+}
+
+export function pushCursorSample(
+ cx: number,
+ cy: number,
+ timeMs: number,
+ interactionType: CursorInteractionType = "move",
+ cursorType?: CursorVisualType,
+) {
+ activeCursorSamples.push({
+ timeMs: Math.max(0, timeMs),
+ cx,
+ cy,
+ interactionType,
+ cursorType: cursorType ?? currentCursorVisualType,
+ } as CursorTelemetryPoint);
+
+ if (activeCursorSamples.length > MAX_CURSOR_SAMPLES) {
+ activeCursorSamples.shift();
+ }
+}
+
+export function sampleCursorPoint() {
+ const point = getNormalizedCursorPoint();
+ pushCursorSample(point.cx, point.cy, getCursorCaptureElapsedMs(), "move");
+}
+
+export async function persistPendingCursorTelemetry(videoPath: string) {
+ const telemetryPath = getTelemetryPathForVideo(videoPath);
+ if (pendingCursorSamples.length > 0) {
+ await fs.writeFile(
+ telemetryPath,
+ JSON.stringify(
+ { version: CURSOR_TELEMETRY_VERSION, samples: pendingCursorSamples },
+ null,
+ 2,
+ ),
+ "utf-8",
+ );
+ }
+ setPendingCursorSamples([]);
+}
+
+export function snapshotCursorTelemetryForPersistence() {
+ if (activeCursorSamples.length === 0) {
+ return;
+ }
+
+ if (pendingCursorSamples.length === 0) {
+ setPendingCursorSamples([...activeCursorSamples]);
+ return;
+ }
+
+ const lastPendingTimeMs = pendingCursorSamples[pendingCursorSamples.length - 1]?.timeMs ?? -1;
+ setPendingCursorSamples([
+ ...pendingCursorSamples,
+ ...activeCursorSamples.filter((sample) => sample.timeMs > lastPendingTimeMs),
+ ]);
+}
+
+export function startCursorSampling() {
+ stopCursorCapture();
+
+ // Use recursive setTimeout with drift compensation instead of setInterval.
+ // Under CPU load setInterval bunches or skips callbacks, creating large gaps
+ // in telemetry data. This approach measures wall-clock drift each tick and
+ // adjusts the next delay so samples stay close to the target interval.
+ let nextExpectedMs = Date.now() + CURSOR_SAMPLE_INTERVAL_MS;
+
+ const tick = () => {
+ if (isCursorCaptureActive && !isCursorCapturePaused()) {
+ sampleCursorPoint();
+ }
+
+ const now = Date.now();
+ const drift = now - nextExpectedMs;
+ nextExpectedMs += CURSOR_SAMPLE_INTERVAL_MS;
+
+ // If we fell behind by more than one full interval, reset the baseline
+ // so we don't try to "catch up" with a burst of rapid samples.
+ if (drift > CURSOR_SAMPLE_INTERVAL_MS) {
+ nextExpectedMs = now + CURSOR_SAMPLE_INTERVAL_MS;
+ }
+
+ const delay = Math.max(1, nextExpectedMs - now);
+ setCursorCaptureInterval(setTimeout(tick, delay));
+ };
+
+ setCursorCaptureInterval(setTimeout(tick, CURSOR_SAMPLE_INTERVAL_MS));
+}
+
+export { CURSOR_SAMPLE_INTERVAL_MS } from "../constants";
+// Re-export for consumers that use it from this module
+export { getTelemetryPathForVideo } from "../utils";
diff --git a/electron/ipc/export/exportStream.test.ts b/electron/ipc/export/exportStream.test.ts
new file mode 100644
index 000000000..d8889437d
--- /dev/null
+++ b/electron/ipc/export/exportStream.test.ts
@@ -0,0 +1,139 @@
+import fs from "node:fs/promises";
+import os from "node:os";
+import path from "node:path";
+import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
+
+const TMP_ROOT = path.join(os.tmpdir(), `recordly-export-stream-test-${Date.now()}`);
+
+vi.mock("electron", () => ({
+ app: {
+ getPath: (key: string) => {
+ if (key === "temp") {
+ return TMP_ROOT;
+ }
+ throw new Error(`Unexpected app.getPath key: ${key}`);
+ },
+ },
+}));
+
+import {
+ cleanupAllExportStreams,
+ closeExportStream,
+ hasExportStream,
+ isOwnedExportPath,
+ openExportStream,
+ writeToExportStream,
+} from "./exportStream";
+
+async function readBytes(filePath: string): Promise {
+ return new Uint8Array(await fs.readFile(filePath));
+}
+
+describe("exportStream", () => {
+ const openedTempPaths: string[] = [];
+
+ beforeAll(async () => {
+ await fs.mkdir(TMP_ROOT, { recursive: true });
+ });
+
+ afterAll(async () => {
+ await fs.rm(TMP_ROOT, { recursive: true, force: true });
+ });
+
+ beforeEach(() => {
+ openedTempPaths.length = 0;
+ });
+
+ afterEach(async () => {
+ await cleanupAllExportStreams();
+ await Promise.allSettled(
+ openedTempPaths.map((tempPath) => fs.rm(tempPath, { force: true })),
+ );
+ });
+
+ it("persists multiple chunks in order and reports the highest watermark on close", async () => {
+ const { streamId, tempPath } = await openExportStream();
+ openedTempPaths.push(tempPath);
+ expect(hasExportStream(streamId)).toBe(true);
+
+ await writeToExportStream(streamId, 0, new Uint8Array([1, 2, 3, 4]));
+ await writeToExportStream(streamId, 4, new Uint8Array([5, 6, 7, 8]));
+
+ const result = await closeExportStream(streamId);
+ expect(result.tempPath).toBe(tempPath);
+ expect(result.bytesWritten).toBe(8);
+
+ const bytes = await readBytes(tempPath);
+ expect(Array.from(bytes)).toEqual([1, 2, 3, 4, 5, 6, 7, 8]);
+ expect(hasExportStream(streamId)).toBe(false);
+ });
+
+ it("supports out-of-order writes and keeps the watermark at the highest offset", async () => {
+ const { streamId, tempPath } = await openExportStream();
+ openedTempPaths.push(tempPath);
+
+ await writeToExportStream(streamId, 16, new Uint8Array([0xff, 0xee]));
+ await writeToExportStream(streamId, 0, new Uint8Array([0x01, 0x02]));
+
+ const result = await closeExportStream(streamId);
+ expect(result.bytesWritten).toBe(18);
+
+ const bytes = await readBytes(tempPath);
+ expect(bytes.byteLength).toBe(18);
+ expect(bytes[0]).toBe(0x01);
+ expect(bytes[1]).toBe(0x02);
+ expect(bytes[16]).toBe(0xff);
+ expect(bytes[17]).toBe(0xee);
+ });
+
+ it("removes the temp file when closed with abort and returns a null tempPath", async () => {
+ const { streamId, tempPath } = await openExportStream();
+ openedTempPaths.push(tempPath);
+ await writeToExportStream(streamId, 0, new Uint8Array([9, 9, 9]));
+
+ const result = await closeExportStream(streamId, { abort: true });
+
+ await expect(fs.access(tempPath)).rejects.toThrow();
+ expect(hasExportStream(streamId)).toBe(false);
+ expect(result.tempPath).toBeNull();
+ expect(result.bytesWritten).toBe(0);
+ expect(isOwnedExportPath(tempPath)).toBe(false);
+ });
+
+ it("tracks open temp paths in the owned-path registry until close", async () => {
+ const { streamId, tempPath } = await openExportStream();
+ openedTempPaths.push(tempPath);
+
+ expect(isOwnedExportPath(tempPath)).toBe(true);
+ // Spoofed paths must not satisfy the registry check.
+ expect(isOwnedExportPath("/tmp/not-ours.mp4")).toBe(false);
+
+ // A successful close keeps ownership so callers can still move or discard
+ // the file via the subsequent IPC call.
+ const result = await closeExportStream(streamId);
+ expect(result.tempPath).toBe(tempPath);
+ expect(isOwnedExportPath(tempPath)).toBe(true);
+ });
+
+ it("rejects writes after the stream has been aborted", async () => {
+ const { streamId, tempPath } = await openExportStream();
+ openedTempPaths.push(tempPath);
+ await closeExportStream(streamId, { abort: true });
+
+ await expect(writeToExportStream(streamId, 0, new Uint8Array([1]))).rejects.toThrow(
+ /Export stream not found/,
+ );
+ });
+
+ it("rejects an extension that would escape the temp directory", async () => {
+ await expect(openExportStream({ extension: "mp4/../etc/passwd" })).rejects.toThrow(
+ /Invalid export stream extension/,
+ );
+ await expect(openExportStream({ extension: ".." })).rejects.toThrow(
+ /Invalid export stream extension/,
+ );
+ await expect(openExportStream({ extension: "MP4" })).rejects.toThrow(
+ /Invalid export stream extension/,
+ );
+ });
+});
diff --git a/electron/ipc/export/exportStream.ts b/electron/ipc/export/exportStream.ts
new file mode 100644
index 000000000..d0aac4067
--- /dev/null
+++ b/electron/ipc/export/exportStream.ts
@@ -0,0 +1,204 @@
+import { randomUUID } from "node:crypto";
+import fs from "node:fs";
+import fsp from "node:fs/promises";
+import path from "node:path";
+import { app } from "electron";
+
+type ExportStreamSession = {
+ streamId: string;
+ sessionDir: string;
+ tempPath: string;
+ fileHandle: fs.promises.FileHandle;
+ bytesWritten: number;
+ highestWatermark: number;
+ writeQueue: Promise;
+ aborted: boolean;
+};
+
+const exportStreamSessions = new Map();
+
+const EXTENSION_ALLOWLIST = /^[a-z0-9]{1,8}$/;
+const SESSION_DIR_PREFIX = "recordly-export-";
+
+// Paths that the export pipeline itself produced (stream temp files plus any
+// successor temp files returned by main-process helpers such as
+// muxNativeVideoExportAudio). Every renderer-facing handler that moves or
+// deletes a path must assert membership here before touching disk, so a
+// compromised renderer can never route arbitrary file paths into the IPC.
+const ownedExportPaths = new Set();
+
+function normalizeOwnedPath(candidate: string): string {
+ return path.resolve(candidate);
+}
+
+export function registerOwnedExportPath(candidate: string): void {
+ ownedExportPaths.add(normalizeOwnedPath(candidate));
+}
+
+export function releaseOwnedExportPath(candidate: string): void {
+ ownedExportPaths.delete(normalizeOwnedPath(candidate));
+}
+
+export function isOwnedExportPath(candidate: string): boolean {
+ return ownedExportPaths.has(normalizeOwnedPath(candidate));
+}
+
+function generateStreamId() {
+ return `recordly-export-stream-${randomUUID()}`;
+}
+
+export async function openExportStream(options?: { extension?: string }): Promise<{
+ streamId: string;
+ tempPath: string;
+}> {
+ const extension = options?.extension ?? "mp4";
+ if (!EXTENSION_ALLOWLIST.test(extension)) {
+ throw new Error(`Invalid export stream extension: ${extension}`);
+ }
+ const streamId = generateStreamId();
+
+ // Per-session 0700 directory defeats TOCTOU/symlink races on shared
+ // tempdirs (e.g. /tmp on Linux): only the current user can enter the dir,
+ // so an adversary cannot pre-plant a symlink at the file path.
+ const sessionDir = await fsp.mkdtemp(path.join(app.getPath("temp"), SESSION_DIR_PREFIX));
+ try {
+ await fsp.chmod(sessionDir, 0o700);
+ } catch {
+ // chmod is a defense-in-depth on platforms where mkdtemp already sets
+ // a safe mode. Non-Linux filesystems may ignore mode bits entirely.
+ }
+ const tempPath = path.join(sessionDir, `${streamId}.${extension}`);
+ const fileHandle = await fsp.open(
+ tempPath,
+ fs.constants.O_RDWR | fs.constants.O_CREAT | fs.constants.O_EXCL,
+ 0o600,
+ );
+
+ exportStreamSessions.set(streamId, {
+ streamId,
+ sessionDir,
+ tempPath,
+ fileHandle,
+ bytesWritten: 0,
+ highestWatermark: 0,
+ writeQueue: Promise.resolve(),
+ aborted: false,
+ });
+ registerOwnedExportPath(tempPath);
+
+ return { streamId, tempPath };
+}
+
+export async function writeToExportStream(
+ streamId: string,
+ position: number,
+ chunk: Uint8Array,
+): Promise {
+ const session = exportStreamSessions.get(streamId);
+ if (!session) {
+ throw new Error(`Export stream not found: ${streamId}`);
+ }
+
+ if (session.aborted) {
+ throw new Error("Export stream was aborted");
+ }
+
+ // Serialize writes against the session to keep byte counters consistent when
+ // the renderer issues concurrent chunks.
+ const previous = session.writeQueue;
+ const next = previous.then(async () => {
+ if (session.aborted) {
+ return;
+ }
+ const buffer = Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength);
+ await session.fileHandle.write(buffer, 0, buffer.byteLength, position);
+ session.bytesWritten += buffer.byteLength;
+ const end = position + buffer.byteLength;
+ if (end > session.highestWatermark) {
+ session.highestWatermark = end;
+ }
+ });
+ session.writeQueue = next.catch(() => undefined);
+ await next;
+}
+
+export async function closeExportStream(
+ streamId: string,
+ options?: { abort?: boolean },
+): Promise<{ tempPath: string | null; bytesWritten: number }> {
+ const session = exportStreamSessions.get(streamId);
+ if (!session) {
+ throw new Error(`Export stream not found: ${streamId}`);
+ }
+
+ const abort = options?.abort === true;
+ if (abort) {
+ session.aborted = true;
+ }
+
+ try {
+ await session.writeQueue;
+ } catch {
+ // Propagated to the in-flight write promise; closure proceeds regardless.
+ }
+
+ try {
+ await session.fileHandle.close();
+ } catch {
+ // File handle may already be closed; ignore so abort paths stay best-effort.
+ }
+
+ exportStreamSessions.delete(streamId);
+
+ if (abort) {
+ releaseOwnedExportPath(session.tempPath);
+ try {
+ await fsp.rm(session.tempPath, { force: true });
+ } catch {
+ // Temp file may be gone already.
+ }
+ try {
+ await fsp.rm(session.sessionDir, { recursive: true, force: true });
+ } catch {
+ // ignore
+ }
+ // Aborted streams return `tempPath: null` so callers cannot accidentally
+ // reuse a path that no longer references a file on disk (or, worse, a
+ // path a later session may recycle).
+ return { tempPath: null, bytesWritten: 0 };
+ }
+
+ return {
+ tempPath: session.tempPath,
+ bytesWritten: session.highestWatermark,
+ };
+}
+
+export function hasExportStream(streamId: string): boolean {
+ return exportStreamSessions.has(streamId);
+}
+
+export async function cleanupAllExportStreams(): Promise {
+ const sessions = Array.from(exportStreamSessions.values());
+ exportStreamSessions.clear();
+ ownedExportPaths.clear();
+ await Promise.allSettled(
+ sessions.map(async (session) => {
+ try {
+ await session.fileHandle.close();
+ } catch {
+ // ignore
+ }
+ try {
+ await fsp.rm(session.tempPath, { force: true });
+ } catch {
+ // ignore
+ }
+ try {
+ await fsp.rm(session.sessionDir, { recursive: true, force: true });
+ } catch {
+ // ignore
+ }
+ }),
+ );
+}
diff --git a/electron/ipc/export/native-video.test.ts b/electron/ipc/export/native-video.test.ts
new file mode 100644
index 000000000..894485395
--- /dev/null
+++ b/electron/ipc/export/native-video.test.ts
@@ -0,0 +1,1515 @@
+import { describe, expect, it, vi } from "vitest";
+
+vi.mock("electron", () => ({
+ app: {
+ getAppPath: vi.fn(() => process.cwd()),
+ getGPUFeatureStatus: vi.fn(() => ({
+ video_decode: "enabled",
+ video_encode: "enabled",
+ webgl: "enabled",
+ webgpu: "enabled",
+ })),
+ getGPUInfo: vi.fn(async () => ({ gpuDevice: [] })),
+ getPath: vi.fn(() => process.env.TEMP ?? process.cwd()),
+ isPackaged: false,
+ },
+}));
+
+vi.mock("../ffmpeg/binary", () => ({
+ getFfmpegBinaryPath: vi.fn(() => "ffmpeg"),
+ getFfprobeBinaryPath: vi.fn(() => "ffprobe"),
+}));
+
+vi.mock("../state", () => ({
+ cachedNativeVideoEncoder: null,
+ setCachedNativeVideoEncoder: vi.fn(),
+}));
+
+const fsMocks = vi.hoisted(() => ({
+ access: vi.fn(async () => {
+ throw new Error("missing");
+ }),
+ writeFile: vi.fn(async () => undefined),
+ readFile: vi.fn(),
+ stat: vi.fn(async () => ({ size: 5_000_000_000 })),
+ unlink: vi.fn(async () => undefined),
+}));
+
+vi.mock("node:fs/promises", () => ({
+ default: fsMocks,
+ ...fsMocks,
+}));
+
+const execFileMock = vi.hoisted(() =>
+ vi.fn((_cmd: string, _args: string[], _opts: unknown, cb: (err: Error | null) => void) => {
+ cb(null);
+ return { stdout: "", stderr: "" } as unknown;
+ }),
+);
+
+vi.mock("node:child_process", () => ({
+ execFile: execFileMock,
+ spawn: vi.fn(),
+}));
+
+import { app } from "electron";
+import {
+ buildExperimentalNvidiaCudaStaticLayoutArgs,
+ buildExperimentalWindowsGpuStaticLayoutArgs,
+ buildNativeStaticLayoutSourceProxyArgs,
+ buildNativeStaticLayoutTimelineSegments,
+ buildNativeVideoAudioMuxArgs,
+ canCopyAudioCodecIntoMp4,
+ getExperimentalNvidiaCudaExportSkipReason,
+ getExportHardwareInfo,
+ getNativeExportCapabilities,
+ getNativeGpuCompositorStallTimeoutMs,
+ getNativeStaticLayoutSourceProxyBitrate,
+ getNvidiaCudaAudioExportSkipReason,
+ getNvidiaCudaAutoStallTimeoutMs,
+ hasNativeStaticLayoutProgressAdvanced,
+ hasNvidiaGpuDeviceInGpuInfo,
+ mapNvidiaCudaWrapperProgressPercentage,
+ muxExportedVideoAudioBuffer,
+ type NativeStaticLayoutExportOptions,
+ normalizeNativeStaticLayoutBackground,
+ parseFfmpegDurationSeconds,
+ parseFfmpegFrameRate,
+ parseFfmpegProgressLineSeconds,
+ parseNativeVideoMetadataProbeOutput,
+ parseNativeVideoStreamStatsProbeOutput,
+ parseNvidiaCudaExportSummary,
+ parseWindowsGpuExportProgressLine,
+ parseWindowsGpuExportSummary,
+ resolveExperimentalNvidiaCudaExportScriptPath,
+ sanitizeExportGpuInfo,
+ shouldCreateNativeStaticLayoutSourceProxy,
+ validateNativeStaticLayoutSourceProxyMetadata,
+ validateNativeVideoStreamStats,
+ validateNvidiaCudaExportSummary,
+ validateWindowsGpuExportSummary,
+} from "./native-video";
+
+const electronAppMock = app as unknown as {
+ getAppPath: ReturnType;
+ getGPUFeatureStatus: ReturnType;
+ getGPUInfo: ReturnType;
+ isPackaged: boolean;
+};
+
+function withNvidiaCudaAudioOverride(value: string | undefined, callback: () => T) {
+ const envName = "RECORDLY_NVIDIA_CUDA_ALLOW_AUDIO_EXPORT";
+ const originalValue = process.env[envName];
+ if (value === undefined) {
+ delete process.env[envName];
+ } else {
+ process.env[envName] = value;
+ }
+
+ try {
+ return callback();
+ } finally {
+ if (originalValue === undefined) {
+ delete process.env[envName];
+ } else {
+ process.env[envName] = originalValue;
+ }
+ }
+}
+
+function resetFsAccessMock() {
+ fsMocks.access.mockImplementation(async () => {
+ throw new Error("missing");
+ });
+}
+
+function createNvidiaCudaSkipOptions(
+ overrides: Partial = {},
+): NativeStaticLayoutExportOptions {
+ return {
+ inputPath: "input.mp4",
+ width: 1920,
+ height: 1080,
+ frameRate: 30,
+ bitrate: 8_000_000,
+ encodingMode: "quality",
+ durationSec: 10,
+ contentWidth: 1600,
+ contentHeight: 900,
+ offsetX: 160,
+ offsetY: 90,
+ backgroundColor: "#101010",
+ experimentalWindowsGpuCompositor: true,
+ ...overrides,
+ };
+}
+
+async function withPackagedCudaCandidate(gpuInfo: unknown, callback: () => Promise) {
+ const envName = "RECORDLY_EXPERIMENTAL_NVIDIA_CUDA_EXPORT";
+ const originalEnv = process.env[envName];
+ const originalIsPackaged = electronAppMock.isPackaged;
+ electronAppMock.isPackaged = true;
+ electronAppMock.getGPUInfo.mockResolvedValue(gpuInfo);
+ fsMocks.access.mockResolvedValue(undefined);
+ delete process.env[envName];
+
+ try {
+ return await callback();
+ } finally {
+ if (originalEnv === undefined) {
+ delete process.env[envName];
+ } else {
+ process.env[envName] = originalEnv;
+ }
+ electronAppMock.isPackaged = originalIsPackaged;
+ electronAppMock.getGPUInfo.mockReset();
+ electronAppMock.getGPUInfo.mockResolvedValue({ gpuDevice: [] });
+ resetFsAccessMock();
+ }
+}
+
+describe("normalizeNativeStaticLayoutBackground", () => {
+ it("falls back to a solid background when the configured image file is missing", async () => {
+ const normalized = await normalizeNativeStaticLayoutBackground({
+ inputPath: "input.mp4",
+ width: 1920,
+ height: 1080,
+ frameRate: 30,
+ bitrate: 8_000_000,
+ encodingMode: "quality",
+ durationSec: 10,
+ contentWidth: 1600,
+ contentHeight: 900,
+ offsetX: 160,
+ offsetY: 90,
+ backgroundColor: "#101010",
+ backgroundImagePath: "Z:\\recordly-missing-wallpaper\\midnight-8.jpg",
+ });
+
+ expect(normalized.backgroundImagePath).toBeNull();
+ expect(normalized.backgroundColor).toBe("#ffffff");
+ });
+});
+
+describe("native static-layout source proxy", () => {
+ const h264Metadata = {
+ width: 1920,
+ height: 1080,
+ duration: 45,
+ frameRate: 30,
+ codec: "h264 (High)",
+ hasAudio: true,
+ audioCodec: "aac",
+ };
+
+ it("proxies non-H.264 codecs and non-MP4-like containers", () => {
+ expect(shouldCreateNativeStaticLayoutSourceProxy(h264Metadata, "recording.mp4")).toBe(
+ false,
+ );
+ expect(shouldCreateNativeStaticLayoutSourceProxy(h264Metadata, "recording.webm")).toBe(
+ true,
+ );
+ expect(
+ shouldCreateNativeStaticLayoutSourceProxy(
+ { ...h264Metadata, codec: "vp9 (Profile 0)" },
+ "recording.webm",
+ ),
+ ).toBe(true);
+ });
+
+ it("builds a CFR H.264 MP4 proxy at visually safe bitrate", () => {
+ const options = createNvidiaCudaSkipOptions({
+ inputPath: "recording.webm",
+ bitrate: 22_000_000,
+ durationSec: 30,
+ });
+ const metadata = {
+ ...h264Metadata,
+ codec: "vp9 (Profile 0)",
+ audioCodec: "opus",
+ duration: 45.036,
+ streamDuration: 45.036,
+ };
+ const args = buildNativeStaticLayoutSourceProxyArgs(options, "source-proxy.mp4", metadata);
+
+ expect(args).toEqual(
+ expect.arrayContaining([
+ "-i",
+ "recording.webm",
+ "-an",
+ "-t",
+ "45.036",
+ "-c:v",
+ "libx264",
+ "-preset",
+ "veryfast",
+ "-pix_fmt",
+ "yuv420p",
+ "-movflags",
+ "+faststart",
+ "source-proxy.mp4",
+ ]),
+ );
+ expect(args).toEqual(
+ expect.arrayContaining([
+ "-vf",
+ "fps=fps=30:start_time=0,scale=trunc(iw/2)*2:trunc(ih/2)*2,setsar=1,format=yuv420p,setpts=PTS-STARTPTS",
+ ]),
+ );
+ expect(getNativeStaticLayoutSourceProxyBitrate(options, metadata)).toBeGreaterThan(
+ options.bitrate,
+ );
+ });
+
+ it("validates proxy codec and duration before native composition", () => {
+ expect(
+ validateNativeStaticLayoutSourceProxyMetadata(
+ { ...h264Metadata, codec: "vp9", duration: 45 },
+ { sourceDurationSec: 45 },
+ ),
+ ).toEqual(["proxy codec is not H.264/AVC: vp9"]);
+ expect(
+ validateNativeStaticLayoutSourceProxyMetadata(
+ { ...h264Metadata, duration: 10 },
+ { sourceDurationSec: 45 },
+ ),
+ ).toEqual(["proxy duration 10.000s shorter than expected 45.000s"]);
+ });
+});
+
+describe("getNvidiaCudaAudioExportSkipReason", () => {
+ it("allows video-only CUDA exports by default", () => {
+ withNvidiaCudaAudioOverride(undefined, () => {
+ expect(getNvidiaCudaAudioExportSkipReason(undefined)).toBeNull();
+ expect(getNvidiaCudaAudioExportSkipReason("none")).toBeNull();
+ });
+ });
+
+ it("guards CUDA for audio exports unless explicitly overridden", () => {
+ withNvidiaCudaAudioOverride(undefined, () => {
+ expect(getNvidiaCudaAudioExportSkipReason("copy-source")).toBe(
+ "audio-mode:copy-source",
+ );
+ expect(getNvidiaCudaAudioExportSkipReason("trim-source")).toBe(
+ "audio-mode:trim-source",
+ );
+ expect(getNvidiaCudaAudioExportSkipReason("edited-track")).toBe(
+ "audio-mode:edited-track",
+ );
+ });
+ });
+
+ it("allows CUDA audio exports only for explicit lab overrides", () => {
+ withNvidiaCudaAudioOverride("1", () => {
+ expect(getNvidiaCudaAudioExportSkipReason("copy-source")).toBeNull();
+ });
+ });
+
+ it("allows validated fallback candidates to try CUDA audio exports", () => {
+ withNvidiaCudaAudioOverride(undefined, () => {
+ expect(
+ getNvidiaCudaAudioExportSkipReason("copy-source", {
+ allowValidatedFallbackCandidate: true,
+ }),
+ ).toBeNull();
+ });
+ });
+});
+
+describe("getNvidiaCudaAutoStallTimeoutMs", () => {
+ it("only applies the stall guard to active validated CUDA candidates by default", () => {
+ expect(getNvidiaCudaAutoStallTimeoutMs(false)).toBeNull();
+ expect(getNvidiaCudaAutoStallTimeoutMs(true)).toBe(120_000);
+ });
+
+ it("allows the CUDA auto stall guard to be disabled or tuned", () => {
+ const envName = "RECORDLY_NVIDIA_CUDA_AUTO_STALL_TIMEOUT_MS";
+ const originalValue = process.env[envName];
+
+ try {
+ process.env[envName] = "0";
+ expect(getNvidiaCudaAutoStallTimeoutMs(true)).toBeNull();
+
+ process.env[envName] = "5000";
+ expect(getNvidiaCudaAutoStallTimeoutMs(true)).toBe(10_000);
+
+ process.env[envName] = "45000";
+ expect(getNvidiaCudaAutoStallTimeoutMs(true)).toBe(45_000);
+ } finally {
+ if (originalValue === undefined) {
+ delete process.env[envName];
+ } else {
+ process.env[envName] = originalValue;
+ }
+ }
+ });
+});
+
+describe("getNativeGpuCompositorStallTimeoutMs", () => {
+ it("guards Windows GPU compositor stalls by default", () => {
+ expect(getNativeGpuCompositorStallTimeoutMs()).toBe(120_000);
+ });
+
+ it("allows the Windows GPU stall guard to be disabled or tuned", () => {
+ const envName = "RECORDLY_NATIVE_GPU_STALL_TIMEOUT_MS";
+ const originalValue = process.env[envName];
+
+ try {
+ process.env[envName] = "0";
+ expect(getNativeGpuCompositorStallTimeoutMs()).toBeNull();
+
+ process.env[envName] = "5000";
+ expect(getNativeGpuCompositorStallTimeoutMs()).toBe(10_000);
+
+ process.env[envName] = "45000";
+ expect(getNativeGpuCompositorStallTimeoutMs()).toBe(45_000);
+ } finally {
+ if (originalValue === undefined) {
+ delete process.env[envName];
+ } else {
+ process.env[envName] = originalValue;
+ }
+ }
+ });
+});
+
+describe("hasNvidiaGpuDeviceInGpuInfo", () => {
+ it("detects NVIDIA GPUs by vendor id or device strings", () => {
+ expect(
+ hasNvidiaGpuDeviceInGpuInfo({
+ gpuDevice: [{ active: false, vendorId: 0x10de, deviceId: 0x1f91 }],
+ }),
+ ).toBe(true);
+ expect(
+ hasNvidiaGpuDeviceInGpuInfo({
+ gpuDevice: [{ vendorString: "NVIDIA Corporation" }],
+ }),
+ ).toBe(true);
+ expect(
+ hasNvidiaGpuDeviceInGpuInfo({
+ gpuDevice: [{ deviceString: "NVIDIA GeForce GTX 1650" }],
+ }),
+ ).toBe(true);
+ });
+
+ it("rejects non-NVIDIA and malformed GPU info", () => {
+ expect(
+ hasNvidiaGpuDeviceInGpuInfo({
+ gpuDevice: [
+ { vendorId: 0x1002, deviceString: "AMD Radeon" },
+ { vendorId: 0x8086, deviceString: "Intel UHD Graphics" },
+ ],
+ }),
+ ).toBe(false);
+ expect(hasNvidiaGpuDeviceInGpuInfo({})).toBe(false);
+ expect(hasNvidiaGpuDeviceInGpuInfo(null)).toBe(false);
+ });
+});
+
+describe("getNativeExportCapabilities", () => {
+ it("reports NVIDIA CUDA availability when the wrapper and an NVIDIA GPU are present", async () => {
+ const capabilities = await withPackagedCudaCandidate(
+ { gpuDevice: [{ vendorId: 0x10de, deviceString: "NVIDIA GeForce GTX 1650" }] },
+ () => getNativeExportCapabilities(),
+ );
+
+ expect(capabilities.nvidiaCuda.available).toBe(process.platform === "win32");
+ expect(capabilities.nvidiaCuda.hasWrapper).toBe(process.platform === "win32");
+ expect(capabilities.nvidiaCuda.hasNvidiaGpu).toBe(
+ process.platform === "win32" ? true : null,
+ );
+ });
+});
+
+describe("export hardware diagnostics", () => {
+ it("sanitizes machine and GPU details without exposing raw device identifiers", () => {
+ const hardware = sanitizeExportGpuInfo({
+ machineModelName: "MacBookPro",
+ machineModelVersion: "18,2",
+ gpuDevice: [
+ {
+ active: true,
+ vendorId: "0x10de",
+ deviceId: 9999,
+ deviceString: "NVIDIA GeForce RTX 4070",
+ },
+ ],
+ });
+
+ expect(hardware).toEqual({
+ machineModel: "MacBookPro 18,2",
+ gpus: [
+ {
+ name: "NVIDIA GeForce RTX 4070",
+ vendor: "NVIDIA",
+ active: true,
+ },
+ ],
+ });
+ expect(JSON.stringify(hardware)).not.toContain("deviceId");
+ expect(JSON.stringify(hardware)).not.toContain("vendorId");
+ });
+
+ it("returns system capacity and GPU acceleration status", async () => {
+ electronAppMock.getGPUInfo.mockResolvedValueOnce({
+ gpuDevice: [{ active: true, vendorId: 0x8086 }],
+ });
+
+ const hardware = await getExportHardwareInfo();
+
+ expect(electronAppMock.getGPUInfo).toHaveBeenCalledWith("complete");
+ expect(hardware).toMatchObject({
+ platform: process.platform,
+ arch: process.arch,
+ gpus: [{ name: "Intel", vendor: "Intel", active: true }],
+ gpuFeatures: {
+ videoDecode: "enabled",
+ videoEncode: "enabled",
+ webgl: "enabled",
+ webgpu: "enabled",
+ },
+ });
+ expect(hardware.logicalProcessors).toBeGreaterThan(0);
+ expect(hardware.totalMemoryGb).toBeGreaterThan(0);
+ });
+});
+
+describe("getExperimentalNvidiaCudaExportSkipReason", () => {
+ it("requires user opt-in before packaged CUDA candidates run", async () => {
+ const reason = await withPackagedCudaCandidate(
+ { gpuDevice: [{ vendorId: 0x10de, deviceString: "NVIDIA GeForce GTX 1650" }] },
+ () =>
+ getExperimentalNvidiaCudaExportSkipReason(
+ createNvidiaCudaSkipOptions({
+ audioOptions: { audioMode: "copy-source", audioSourcePath: "input.mp4" },
+ }),
+ ),
+ );
+
+ expect(reason).toBe(process.platform === "win32" ? "env-disabled" : "not-windows");
+ });
+
+ it("allows user opt-in CUDA candidates when the helper and an NVIDIA GPU are present", async () => {
+ const reason = await withPackagedCudaCandidate(
+ { gpuDevice: [{ vendorId: 0x10de, deviceString: "NVIDIA GeForce GTX 1650" }] },
+ () =>
+ getExperimentalNvidiaCudaExportSkipReason(
+ createNvidiaCudaSkipOptions({
+ experimentalNvidiaCudaExport: true,
+ audioOptions: { audioMode: "copy-source", audioSourcePath: "input.mp4" },
+ }),
+ ),
+ );
+
+ expect(reason).toBe(process.platform === "win32" ? null : "not-windows");
+ });
+
+ it("allows explicit lab CUDA audio exports when forced onto the shared mux path", async () => {
+ const exportEnvName = "RECORDLY_EXPERIMENTAL_NVIDIA_CUDA_EXPORT";
+ const forceEnvName = "RECORDLY_NVIDIA_CUDA_FORCE_VIDEO_ONLY";
+ const allowAudioEnvName = "RECORDLY_NVIDIA_CUDA_ALLOW_AUDIO_EXPORT";
+ const originalExportEnv = process.env[exportEnvName];
+ const originalForceEnv = process.env[forceEnvName];
+ const originalAllowAudioEnv = process.env[allowAudioEnvName];
+ process.env[exportEnvName] = "1";
+ process.env[forceEnvName] = "1";
+ delete process.env[allowAudioEnvName];
+
+ try {
+ const reason = await getExperimentalNvidiaCudaExportSkipReason(
+ createNvidiaCudaSkipOptions({
+ audioOptions: { audioMode: "copy-source", audioSourcePath: "input.mp4" },
+ }),
+ );
+
+ expect(reason).toBe(process.platform === "win32" ? null : "not-windows");
+ } finally {
+ if (originalExportEnv === undefined) {
+ delete process.env[exportEnvName];
+ } else {
+ process.env[exportEnvName] = originalExportEnv;
+ }
+ if (originalForceEnv === undefined) {
+ delete process.env[forceEnvName];
+ } else {
+ process.env[forceEnvName] = originalForceEnv;
+ }
+ if (originalAllowAudioEnv === undefined) {
+ delete process.env[allowAudioEnvName];
+ } else {
+ process.env[allowAudioEnvName] = originalAllowAudioEnv;
+ }
+ }
+ });
+
+ it("does not use packaged auto-candidate audio bypass when CUDA is explicitly enabled", async () => {
+ const exportEnvName = "RECORDLY_EXPERIMENTAL_NVIDIA_CUDA_EXPORT";
+ const forceEnvName = "RECORDLY_NVIDIA_CUDA_FORCE_VIDEO_ONLY";
+ const allowAudioEnvName = "RECORDLY_NVIDIA_CUDA_ALLOW_AUDIO_EXPORT";
+ const originalExportEnv = process.env[exportEnvName];
+ const originalForceEnv = process.env[forceEnvName];
+ const originalAllowAudioEnv = process.env[allowAudioEnvName];
+ const originalIsPackaged = electronAppMock.isPackaged;
+ electronAppMock.isPackaged = true;
+ process.env[exportEnvName] = "1";
+ delete process.env[forceEnvName];
+ delete process.env[allowAudioEnvName];
+
+ try {
+ const reason = await getExperimentalNvidiaCudaExportSkipReason(
+ createNvidiaCudaSkipOptions({
+ audioOptions: { audioMode: "copy-source", audioSourcePath: "input.mp4" },
+ }),
+ );
+
+ expect(reason).toBe(
+ process.platform === "win32" ? "audio-mode:copy-source" : "not-windows",
+ );
+ } finally {
+ if (originalExportEnv === undefined) {
+ delete process.env[exportEnvName];
+ } else {
+ process.env[exportEnvName] = originalExportEnv;
+ }
+ if (originalForceEnv === undefined) {
+ delete process.env[forceEnvName];
+ } else {
+ process.env[forceEnvName] = originalForceEnv;
+ }
+ if (originalAllowAudioEnv === undefined) {
+ delete process.env[allowAudioEnvName];
+ } else {
+ process.env[allowAudioEnvName] = originalAllowAudioEnv;
+ }
+ electronAppMock.isPackaged = originalIsPackaged;
+ }
+ });
+
+ it("skips user opt-in CUDA candidates when Electron reports no NVIDIA GPU", async () => {
+ const reason = await withPackagedCudaCandidate(
+ { gpuDevice: [{ vendorId: 0x8086, deviceString: "Intel UHD Graphics" }] },
+ () =>
+ getExperimentalNvidiaCudaExportSkipReason(
+ createNvidiaCudaSkipOptions({ experimentalNvidiaCudaExport: true }),
+ ),
+ );
+
+ expect(reason).toBe(
+ process.platform === "win32" ? "nvidia-gpu-unavailable" : "not-windows",
+ );
+ });
+
+ it("lets the user opt-in candidate be explicitly disabled", async () => {
+ const reason = await withPackagedCudaCandidate(
+ { gpuDevice: [{ vendorId: 0x10de, deviceString: "NVIDIA GeForce GTX 1650" }] },
+ async () => {
+ process.env.RECORDLY_EXPERIMENTAL_NVIDIA_CUDA_EXPORT = "0";
+ return getExperimentalNvidiaCudaExportSkipReason(
+ createNvidiaCudaSkipOptions({ experimentalNvidiaCudaExport: true }),
+ );
+ },
+ );
+
+ expect(reason).toBe(process.platform === "win32" ? "env-disabled" : "not-windows");
+ });
+});
+
+describe("resolveExperimentalNvidiaCudaExportScriptPath", () => {
+ it("prefers the packaged app.asar.unpacked CUDA wrapper over the virtual app.asar copy", async () => {
+ const envName = "RECORDLY_NVIDIA_CUDA_EXPORT_SCRIPT";
+ const originalEnv = process.env[envName];
+ const originalResourcesPath = Object.getOwnPropertyDescriptor(process, "resourcesPath");
+ delete process.env[envName];
+
+ try {
+ if (process.platform !== "win32") {
+ expect(await resolveExperimentalNvidiaCudaExportScriptPath()).toBeNull();
+ return;
+ }
+
+ const resourcesPath = "C:\\Recordly\\resources";
+ const unpackedScriptPath =
+ "C:\\Recordly\\resources\\app.asar.unpacked\\electron\\native\\nvidia-cuda-compositor\\run-mp4-pipeline.mjs";
+ const asarScriptPath =
+ "C:\\Recordly\\resources\\app.asar\\electron\\native\\nvidia-cuda-compositor\\run-mp4-pipeline.mjs";
+ Object.defineProperty(process, "resourcesPath", {
+ configurable: true,
+ value: resourcesPath,
+ });
+ electronAppMock.getAppPath.mockReturnValue("C:\\Recordly\\resources\\app.asar");
+ fsMocks.access.mockImplementation(async (candidate: string) => {
+ if (candidate === unpackedScriptPath || candidate === asarScriptPath) {
+ return;
+ }
+ throw new Error(`missing ${candidate}`);
+ });
+
+ expect(await resolveExperimentalNvidiaCudaExportScriptPath()).toBe(unpackedScriptPath);
+ } finally {
+ if (originalEnv === undefined) {
+ delete process.env[envName];
+ } else {
+ process.env[envName] = originalEnv;
+ }
+ if (originalResourcesPath) {
+ Object.defineProperty(process, "resourcesPath", originalResourcesPath);
+ } else {
+ delete (process as NodeJS.Process & { resourcesPath?: string }).resourcesPath;
+ }
+ electronAppMock.getAppPath.mockReset();
+ electronAppMock.getAppPath.mockReturnValue(process.cwd());
+ resetFsAccessMock();
+ }
+ });
+});
+
+describe("buildExperimentalNvidiaCudaStaticLayoutArgs", () => {
+ it("passes output canvas dimensions to the CUDA wrapper", () => {
+ const args = buildExperimentalNvidiaCudaStaticLayoutArgs(
+ createNvidiaCudaSkipOptions({ width: 1020, height: 572 }),
+ "output.mp4",
+ "work",
+ );
+
+ expect(args).toEqual(expect.arrayContaining(["--width", "1020", "--height", "572"]));
+ });
+
+ it("keeps explicit copy-source CUDA audio inline by default", () => {
+ const args = buildExperimentalNvidiaCudaStaticLayoutArgs(
+ createNvidiaCudaSkipOptions({
+ audioOptions: { audioMode: "copy-source", audioSourcePath: "input.mp4" },
+ }),
+ "output.mp4",
+ "work",
+ );
+
+ expect(args).not.toContain("--video-only");
+ });
+
+ it("forces packaged auto CUDA candidates onto the shared audio mux path", () => {
+ const args = buildExperimentalNvidiaCudaStaticLayoutArgs(
+ createNvidiaCudaSkipOptions({
+ audioOptions: { audioMode: "copy-source", audioSourcePath: "input.mp4" },
+ nvidiaCudaForceVideoOnly: true,
+ }),
+ "output.mp4",
+ "work",
+ );
+
+ expect(args).toContain("--video-only");
+ });
+
+ it("passes native timeline maps to the CUDA wrapper", () => {
+ const args = buildExperimentalNvidiaCudaStaticLayoutArgs(
+ createNvidiaCudaSkipOptions({
+ timelineMapPath: "timeline-map.csv",
+ timelineSegments: [
+ {
+ sourceStartMs: 0,
+ sourceEndMs: 6_000,
+ outputStartMs: 0,
+ outputEndMs: 4_000,
+ speed: 1.5,
+ },
+ ],
+ }),
+ "output.mp4",
+ "work",
+ );
+
+ expect(args).toEqual(expect.arrayContaining(["--timeline-map", "timeline-map.csv"]));
+ });
+
+ it("passes background blur to the CUDA wrapper", () => {
+ const args = buildExperimentalNvidiaCudaStaticLayoutArgs(
+ createNvidiaCudaSkipOptions({
+ backgroundImagePath: "wallpaper.jpg",
+ backgroundBlurPx: 36,
+ }),
+ "output.mp4",
+ "work",
+ );
+
+ expect(args).toEqual(expect.arrayContaining(["--background-blur", "36"]));
+ });
+
+ it("passes the export encoding mode to the CUDA wrapper", () => {
+ const args = buildExperimentalNvidiaCudaStaticLayoutArgs(
+ createNvidiaCudaSkipOptions({
+ encodingMode: "quality",
+ }),
+ "output.mp4",
+ "work",
+ );
+
+ expect(args).toEqual(expect.arrayContaining(["--encoding-mode", "quality"]));
+ });
+
+ it("passes webcam source-time controls to the CUDA wrapper", () => {
+ const args = buildExperimentalNvidiaCudaStaticLayoutArgs(
+ createNvidiaCudaSkipOptions({
+ webcamInputPath: "webcam.mp4",
+ webcamLeft: 32,
+ webcamTop: 48,
+ webcamSize: 240,
+ webcamRadius: 18,
+ webcamTimeOffsetMs: -125.5,
+ }),
+ "output.mp4",
+ "work",
+ );
+
+ expect(args).toEqual(
+ expect.arrayContaining([
+ "--webcam-input",
+ "webcam.mp4",
+ "--webcam-time-offset-ms",
+ "-125.5",
+ "--webcam-stream",
+ ]),
+ );
+ });
+
+ it("passes cursor telemetry and atlas assets to the CUDA wrapper", () => {
+ const args = buildExperimentalNvidiaCudaStaticLayoutArgs(
+ createNvidiaCudaSkipOptions({
+ cursorTelemetryPath: "cursor-telemetry.json",
+ cursorSize: 96,
+ cursorAtlasPath: "cursor-atlas.png",
+ cursorAtlasMetadataPath: "cursor-atlas.tsv",
+ }),
+ "output.mp4",
+ "work",
+ );
+
+ expect(args).toEqual(
+ expect.arrayContaining([
+ "--cursor-json",
+ "cursor-telemetry.json",
+ "--cursor-height",
+ "96",
+ "--cursor-style",
+ "external",
+ "--cursor-atlas-png",
+ "cursor-atlas.png",
+ "--cursor-atlas-metadata",
+ "cursor-atlas.tsv",
+ ]),
+ );
+ });
+
+ it("passes source crop coordinates to the CUDA wrapper", () => {
+ const args = buildExperimentalNvidiaCudaStaticLayoutArgs(
+ createNvidiaCudaSkipOptions({
+ sourceCropX: 192,
+ sourceCropY: 108,
+ sourceCropWidth: 1536,
+ sourceCropHeight: 864,
+ }),
+ "output.mp4",
+ "work",
+ );
+
+ expect(args).toEqual(
+ expect.arrayContaining([
+ "--source-crop-x",
+ "192",
+ "--source-crop-y",
+ "108",
+ "--source-crop-width",
+ "1536",
+ "--source-crop-height",
+ "864",
+ ]),
+ );
+ });
+});
+
+describe("buildExperimentalWindowsGpuStaticLayoutArgs", () => {
+ it("passes background blur to the D3D11 compositor", () => {
+ const args = buildExperimentalWindowsGpuStaticLayoutArgs(
+ createNvidiaCudaSkipOptions({
+ backgroundImagePath: "wallpaper.jpg",
+ backgroundBlurPx: 36,
+ }),
+ "output.mp4",
+ );
+
+ expect(args).toEqual(expect.arrayContaining(["--background-blur", "36"]));
+ });
+
+ it("passes source crop coordinates to the D3D11 compositor", () => {
+ const args = buildExperimentalWindowsGpuStaticLayoutArgs(
+ createNvidiaCudaSkipOptions({
+ sourceCropX: 192,
+ sourceCropY: 108,
+ sourceCropWidth: 1536,
+ sourceCropHeight: 864,
+ }),
+ "output.mp4",
+ );
+
+ expect(args).toEqual(
+ expect.arrayContaining([
+ "--source-crop-x",
+ "192",
+ "--source-crop-y",
+ "108",
+ "--source-crop-width",
+ "1536",
+ "--source-crop-height",
+ "864",
+ ]),
+ );
+ });
+});
+
+describe("buildNativeStaticLayoutTimelineSegments", () => {
+ it("derives contiguous output timeline ranges from edited-track source segments", () => {
+ expect(
+ buildNativeStaticLayoutTimelineSegments([
+ { startMs: 0, endMs: 2_000, speed: 1 },
+ { startMs: 2_000, endMs: 8_000, speed: 1.5 },
+ { startMs: 8_000, endMs: 10_000, speed: 0.5 },
+ ]),
+ ).toEqual([
+ {
+ sourceStartMs: 0,
+ sourceEndMs: 2_000,
+ outputStartMs: 0,
+ outputEndMs: 2_000,
+ speed: 1,
+ },
+ {
+ sourceStartMs: 2_000,
+ sourceEndMs: 8_000,
+ outputStartMs: 2_000,
+ outputEndMs: 6_000,
+ speed: 1.5,
+ },
+ {
+ sourceStartMs: 8_000,
+ sourceEndMs: 10_000,
+ outputStartMs: 6_000,
+ outputEndMs: 10_000,
+ speed: 0.5,
+ },
+ ]);
+ });
+});
+
+describe("muxExportedVideoAudioBuffer", () => {
+ it("returns the muxed output path without reading the muxed file into memory", async () => {
+ const videoData = new ArrayBuffer(64);
+ const result = await muxExportedVideoAudioBuffer(videoData, { audioMode: "none" });
+
+ expect(typeof result.outputPath).toBe("string");
+ expect(result.outputPath.length).toBeGreaterThan(0);
+ // The >2 GiB fix relies on stat-only metric collection; readFile must stay unused.
+ expect(fsMocks.readFile).not.toHaveBeenCalled();
+ expect(result.metrics.muxedVideoBytes).toBe(5_000_000_000);
+ });
+
+ it("preserves the input temp path when audioMode='none' (no re-mux)", async () => {
+ const videoData = new ArrayBuffer(32);
+ const result = await muxExportedVideoAudioBuffer(videoData, { audioMode: "none" });
+
+ expect(result.outputPath).toMatch(/recordly-export-video-/);
+ });
+});
+
+describe("buildNativeVideoAudioMuxArgs", () => {
+ it("stream-copies source audio and preserves the requested video duration", () => {
+ const args = buildNativeVideoAudioMuxArgs("video.mp4", "source.mp4", "out.mp4", {
+ audioMode: "copy-source",
+ audioSourceCodec: "aac (LC) (mp4a / 0x6134706D)",
+ outputDurationSec: 60,
+ });
+
+ expect(args).toEqual(
+ expect.arrayContaining([
+ "-map",
+ "0:v:0",
+ "-map",
+ "1:a:0",
+ "-c:v",
+ "copy",
+ "-c:a",
+ "copy",
+ "-t",
+ "60.000",
+ ]),
+ );
+ expect(args).not.toContain("-shortest");
+ });
+
+ it("does not shorten copy-source muxes when no explicit duration is available", () => {
+ const args = buildNativeVideoAudioMuxArgs("video.mp4", "source.mp4", "out.mp4", {
+ audioMode: "copy-source",
+ audioSourceCodec: "aac",
+ });
+
+ expect(args).toEqual(expect.arrayContaining(["-c:a", "copy"]));
+ expect(args).not.toContain("-shortest");
+ });
+
+ it("transcodes WebM/Opus source audio when muxing into MP4", () => {
+ const args = buildNativeVideoAudioMuxArgs("video.mp4", "source.webm", "out.mp4", {
+ audioMode: "copy-source",
+ audioSourceCodec: "opus",
+ outputDurationSec: 60,
+ });
+
+ expect(args).toEqual(expect.arrayContaining(["-c:a", "aac", "-b:a", "192k"]));
+ expect(args.join(";")).not.toContain("-c:a;copy");
+ });
+
+ it("transcodes unknown source audio instead of copying unsafe codecs into MP4", () => {
+ const args = buildNativeVideoAudioMuxArgs("video.mp4", "source.wav", "out.mp4", {
+ audioMode: "copy-source",
+ outputDurationSec: 60,
+ });
+
+ expect(args).toEqual(expect.arrayContaining(["-c:a", "aac", "-b:a", "192k"]));
+ expect(args.join(";")).not.toContain("-c:a;copy");
+ });
+
+ it("keeps filtered audio on the AAC encode path", () => {
+ const args = buildNativeVideoAudioMuxArgs("video.mp4", "source.mp4", "out.mp4", {
+ audioMode: "trim-source",
+ trimSegments: [{ startMs: 0, endMs: 1_000 }],
+ outputDurationSec: 1,
+ });
+
+ expect(args).toEqual(expect.arrayContaining(["-filter_complex"]));
+ expect(args).toEqual(expect.arrayContaining(["-c:a", "aac", "-b:a", "192k"]));
+ });
+
+ it("pads and trims edited-track filtergraph audio to the expected duration", () => {
+ const args = buildNativeVideoAudioMuxArgs("video.mp4", "source.mp4", "out.mp4", {
+ audioMode: "edited-track",
+ editedTrackStrategy: "filtergraph-fast-path",
+ audioSourceSampleRate: 48_000,
+ editedTrackSegments: [{ startMs: 0, endMs: 4_000, speed: 0.5 }],
+ outputDurationSec: 8,
+ });
+
+ expect(args).toEqual(expect.arrayContaining(["-map", "[aout_sync]"]));
+ expect(args.join(";")).toContain(
+ "[aout]apad,atrim=duration=8.000,asetpts=PTS-STARTPTS[aout_sync]",
+ );
+ });
+
+ it("can enable machine-readable FFmpeg mux progress", () => {
+ const args = buildNativeVideoAudioMuxArgs(
+ "video.mp4",
+ "source.mp4",
+ "out.mp4",
+ { audioMode: "copy-source", audioSourceCodec: "aac", outputDurationSec: 60 },
+ { progressPipe: 2 },
+ );
+
+ expect(args).toEqual(
+ expect.arrayContaining(["-stats_period", "0.5", "-progress", "pipe:2", "-nostats"]),
+ );
+ });
+});
+
+describe("canCopyAudioCodecIntoMp4", () => {
+ it("allows common MP4-compatible audio codecs", () => {
+ expect(canCopyAudioCodecIntoMp4("aac (LC) (mp4a / 0x6134706D)")).toBe(true);
+ expect(canCopyAudioCodecIntoMp4("mp3")).toBe(true);
+ });
+
+ it("blocks Opus so native exports transcode it to AAC for MP4", () => {
+ expect(canCopyAudioCodecIntoMp4("opus")).toBe(false);
+ });
+
+ it("blocks unknown codecs so sidecar WAV/PCM audio is encoded for MP4", () => {
+ expect(canCopyAudioCodecIntoMp4(undefined)).toBe(false);
+ expect(canCopyAudioCodecIntoMp4("")).toBe(false);
+ });
+});
+
+describe("validateNativeVideoStreamStats", () => {
+ it("accepts a complete video stream", () => {
+ expect(
+ validateNativeVideoStreamStats(
+ { durationSec: 45, frameCount: 1350, frameRate: 30 },
+ { durationSec: 45, targetFrames: 1350 },
+ ),
+ ).toEqual([]);
+ });
+
+ it("rejects files with container duration but too few video frames", () => {
+ expect(
+ validateNativeVideoStreamStats(
+ { durationSec: 0.067, frameCount: 2, frameRate: 30 },
+ { durationSec: 45, targetFrames: 1350 },
+ ),
+ ).toEqual([
+ "video frames 2 below expected minimum 1282",
+ "video stream duration 0.067s differs from expected 45.000s",
+ ]);
+ });
+});
+
+describe("parseNativeVideoStreamStatsProbeOutput", () => {
+ it("parses FFprobe count-frame JSON output", () => {
+ expect(
+ parseNativeVideoStreamStatsProbeOutput(
+ JSON.stringify({
+ streams: [
+ {
+ duration: "44.999000",
+ nb_read_frames: "1349",
+ avg_frame_rate: "30000/1001",
+ },
+ ],
+ }),
+ ),
+ ).toEqual({
+ durationSec: 44.999,
+ frameCount: 1349,
+ frameRate: 30000 / 1001,
+ });
+ });
+});
+
+describe("parseFfmpegProgressLineSeconds", () => {
+ it("parses FFmpeg progress timestamps into seconds", () => {
+ expect(parseFfmpegProgressLineSeconds("out_time_us=1500000")).toBe(1.5);
+ expect(parseFfmpegProgressLineSeconds("out_time_ms=2500000")).toBe(2.5);
+ expect(parseFfmpegProgressLineSeconds("out_time=00:01:02.500000")).toBe(62.5);
+ expect(parseFfmpegProgressLineSeconds("progress=continue")).toBeNull();
+ });
+});
+
+describe("parseWindowsGpuExportSummary", () => {
+ it("returns the last JSON summary from helper stdout", () => {
+ const summary = parseWindowsGpuExportSummary(
+ [
+ "initializing",
+ '{"success":true,"frames":30,"totalMs":1000,"realtimeMultiplier":2}',
+ "cleanup",
+ '{"success":true,"frames":60,"surfacePoolSize":12,"readMs":12.5,"videoProcessMs":30,"writeSampleMs":40,"finalizeMs":5,"realtimeMultiplier":4}',
+ ].join("\n"),
+ );
+
+ expect(summary).toEqual({
+ success: true,
+ frames: 60,
+ surfacePoolSize: 12,
+ readMs: 12.5,
+ videoProcessMs: 30,
+ writeSampleMs: 40,
+ finalizeMs: 5,
+ realtimeMultiplier: 4,
+ });
+ });
+
+ it("returns null when helper stdout has no valid JSON summary", () => {
+ expect(parseWindowsGpuExportSummary("initializing\nnot-json")).toBeNull();
+ expect(parseWindowsGpuExportSummary("")).toBeNull();
+ });
+});
+
+describe("validateWindowsGpuExportSummary", () => {
+ it("rejects short Windows GPU outputs before muxing audio", () => {
+ expect(
+ validateWindowsGpuExportSummary(
+ { success: true, frames: 2, seconds: 0.067 },
+ { durationSec: 45, targetFrames: 1350 },
+ ),
+ ).toEqual([
+ "Windows GPU frames 2 below expected minimum 1282",
+ "Windows GPU duration 0.067s differs from expected 45.000s",
+ ]);
+ });
+
+ it("accepts complete Windows GPU outputs within tolerance", () => {
+ expect(
+ validateWindowsGpuExportSummary(
+ { success: true, frames: 1345, seconds: 44.9 },
+ { durationSec: 45, targetFrames: 1350 },
+ ),
+ ).toEqual([]);
+ });
+});
+
+describe("parseNvidiaCudaExportSummary", () => {
+ it("parses the pretty JSON summary emitted by the CUDA wrapper", () => {
+ const summary = parseNvidiaCudaExportSummary(
+ [
+ "preflight",
+ JSON.stringify(
+ {
+ success: true,
+ fps: 30,
+ durationSec: 10,
+ targetFrames: 300,
+ timingsMs: { nativeEncode: 920, mux: 45, endToEnd: 1400 },
+ nativeSummary: { success: true, frames: 300, fps: 326.1 },
+ },
+ null,
+ 2,
+ ),
+ ].join("\n"),
+ );
+
+ expect(summary?.success).toBe(true);
+ expect(summary?.targetFrames).toBe(300);
+ expect(summary?.timingsMs?.nativeEncode).toBe(920);
+ expect(summary?.nativeSummary?.fps).toBe(326.1);
+ });
+
+ it("returns null when the wrapper output has no JSON object", () => {
+ expect(parseNvidiaCudaExportSummary("native helper failed before summary")).toBeNull();
+ });
+});
+
+describe("validateNvidiaCudaExportSummary", () => {
+ it("accepts CUDA output when frames and stream durations match the export target", () => {
+ const issues = validateNvidiaCudaExportSummary(
+ {
+ success: true,
+ targetFrames: 300,
+ durationSec: 10,
+ nativeSummary: { success: true, frames: 300 },
+ outputVideo: { duration: "9.999900", nb_frames: "300" },
+ outputAudio: { duration: "10.005000" },
+ },
+ { durationSec: 10, targetFrames: 300 },
+ );
+
+ expect(issues).toEqual([]);
+ });
+
+ it("rejects CUDA output that reports too few frames or a short video stream", () => {
+ const issues = validateNvidiaCudaExportSummary(
+ {
+ success: true,
+ targetFrames: 300,
+ durationSec: 10,
+ nativeSummary: { success: true, frames: 144 },
+ outputVideo: { duration: "4.799952", nb_frames: "144" },
+ outputAudio: { duration: "10.005000" },
+ },
+ { durationSec: 10, targetFrames: 300 },
+ );
+
+ expect(issues).toEqual([
+ "native frames 144 below expected minimum 285",
+ "output video frames 144 below expected minimum 285",
+ "output video duration 4.800s differs from expected 10.000s",
+ ]);
+ });
+
+ it("rejects audio CUDA output unless the helper reports timestamp-aligned frame selection", () => {
+ const issues = validateNvidiaCudaExportSummary(
+ {
+ success: true,
+ targetFrames: 300,
+ durationSec: 10,
+ nativeSummary: {
+ success: true,
+ frames: 300,
+ selectionStage: "decoder-policy-mapped-callback",
+ },
+ outputVideo: { duration: "9.999900", nb_frames: "300" },
+ outputAudio: { duration: "10.005000" },
+ },
+ { durationSec: 10, targetFrames: 300, requiresTimelineSync: true },
+ );
+
+ expect(issues).toEqual(["CUDA timeline mode is not timestamp-aligned for audio export"]);
+ });
+
+ it("accepts video-only CUDA output when audio is muxed by the shared export path", () => {
+ const issues = validateNvidiaCudaExportSummary(
+ {
+ success: true,
+ targetFrames: 300,
+ durationSec: 10,
+ nativeSummary: {
+ success: true,
+ frames: 300,
+ selectionStage: "decoder-policy-mapped-callback",
+ },
+ outputVideo: { duration: "9.999900", nb_frames: "300" },
+ },
+ { durationSec: 10, targetFrames: 300, requiresTimelineSync: false },
+ );
+
+ expect(issues).toEqual([]);
+ });
+
+ it("rejects inline-audio CUDA output when the helper does not produce audio", () => {
+ const issues = validateNvidiaCudaExportSummary(
+ {
+ success: true,
+ targetFrames: 300,
+ durationSec: 10,
+ nativeSummary: {
+ success: true,
+ frames: 300,
+ sourceTimestampMode: "pts",
+ selectionStage: "timestamp-mapped-callback",
+ },
+ outputVideo: { duration: "9.999900", nb_frames: "300" },
+ },
+ { durationSec: 10, targetFrames: 300, requiresTimelineSync: true },
+ );
+
+ expect(issues).toEqual(["missing output audio stream"]);
+ });
+
+ it("rejects inline-audio CUDA output when the probed audio stream is empty", () => {
+ const issues = validateNvidiaCudaExportSummary(
+ {
+ success: true,
+ targetFrames: 300,
+ durationSec: 10,
+ nativeSummary: {
+ success: true,
+ frames: 300,
+ sourceTimestampMode: "pts",
+ selectionStage: "timestamp-mapped-callback",
+ },
+ outputVideo: { duration: "9.999900", nb_frames: "300" },
+ outputAudio: { duration: "0.000000" },
+ },
+ { durationSec: 10, targetFrames: 300, requiresTimelineSync: true },
+ );
+
+ expect(issues).toEqual(["output audio duration is not positive"]);
+ });
+
+ it("rejects inline-audio CUDA output when the audio duration is missing", () => {
+ const issues = validateNvidiaCudaExportSummary(
+ {
+ success: true,
+ targetFrames: 300,
+ durationSec: 10,
+ nativeSummary: {
+ success: true,
+ frames: 300,
+ sourceTimestampMode: "pts",
+ selectionStage: "timestamp-mapped-callback",
+ },
+ outputVideo: { duration: "9.999900", nb_frames: "300" },
+ outputAudio: {},
+ },
+ { durationSec: 10, targetFrames: 300, requiresTimelineSync: true },
+ );
+
+ expect(issues).toEqual(["missing output audio duration"]);
+ });
+
+ it("accepts audio CUDA output when the helper reports PTS-aligned selection", () => {
+ const issues = validateNvidiaCudaExportSummary(
+ {
+ success: true,
+ targetFrames: 300,
+ durationSec: 10,
+ nativeSummary: {
+ success: true,
+ frames: 300,
+ sourceTimestampMode: "pts",
+ selectionStage: "timestamp-mapped-callback",
+ },
+ outputVideo: { duration: "9.999900", nb_frames: "300" },
+ outputAudio: { duration: "10.005000" },
+ },
+ { durationSec: 10, targetFrames: 300, requiresTimelineSync: true },
+ );
+
+ expect(issues).toEqual([]);
+ });
+});
+
+describe("parseWindowsGpuExportProgressLine", () => {
+ it("parses bounded helper progress lines", () => {
+ expect(
+ parseWindowsGpuExportProgressLine(
+ 'PROGRESS {"currentFrame":30,"totalFrames":60,"percentage":50,"averageFps":240.5,"instantFps":180.25,"intervalMs":166.4,"intervalFrames":30,"intervalEncodeMs":120.2,"intervalPipelineWaitMs":46.2,"intervalMonolithicCompositeFrames":0,"stage":"finalizing"}',
+ ),
+ ).toEqual({
+ currentFrame: 30,
+ totalFrames: 60,
+ percentage: 50,
+ stage: "finalizing",
+ averageFps: 240.5,
+ instantFps: 180.25,
+ intervalMs: 166.4,
+ intervalFrames: 30,
+ intervalEncodeMs: 120.2,
+ intervalPipelineWaitMs: 46.2,
+ intervalMonolithicCompositeFrames: 0,
+ });
+ });
+
+ it("preserves CUDA preparation progress as a non-rendering stage", () => {
+ expect(
+ parseWindowsGpuExportProgressLine(
+ 'PROGRESS {"currentFrame":0,"totalFrames":100,"percentage":2.5,"stage":"preparing"}',
+ ),
+ ).toEqual({
+ currentFrame: 0,
+ totalFrames: 100,
+ percentage: 2.5,
+ stage: "preparing",
+ });
+ });
+
+ it("ignores non-progress or malformed helper stderr", () => {
+ expect(parseWindowsGpuExportProgressLine("warning: encoder selected")).toBeNull();
+ expect(parseWindowsGpuExportProgressLine("PROGRESS not-json")).toBeNull();
+ expect(
+ parseWindowsGpuExportProgressLine(
+ 'PROGRESS {"currentFrame":1,"totalFrames":0,"percentage":999}',
+ ),
+ ).toBeNull();
+ });
+});
+
+describe("mapNvidiaCudaWrapperProgressPercentage", () => {
+ it("keeps preflight progress and maps native encode into the main export span", () => {
+ expect(
+ mapNvidiaCudaWrapperProgressPercentage({
+ currentFrame: 0,
+ totalFrames: 100,
+ percentage: 2.5,
+ }),
+ ).toBe(2.5);
+ expect(
+ mapNvidiaCudaWrapperProgressPercentage({
+ currentFrame: 0,
+ totalFrames: 100,
+ percentage: 0,
+ }),
+ ).toBe(3);
+ expect(
+ mapNvidiaCudaWrapperProgressPercentage({
+ currentFrame: 50,
+ totalFrames: 100,
+ percentage: 50,
+ }),
+ ).toBe(50.5);
+ expect(
+ mapNvidiaCudaWrapperProgressPercentage({
+ currentFrame: 100,
+ totalFrames: 100,
+ percentage: 100,
+ }),
+ ).toBe(98);
+ expect(
+ mapNvidiaCudaWrapperProgressPercentage({
+ currentFrame: 100,
+ totalFrames: 100,
+ percentage: 97.25,
+ stage: "finalizing",
+ }),
+ ).toBe(97.25);
+ });
+});
+
+describe("hasNativeStaticLayoutProgressAdvanced", () => {
+ it("treats repeated preparation heartbeats as stalled until real progress arrives", () => {
+ const previous = { currentFrame: 0, percentage: 2.5 };
+
+ expect(
+ hasNativeStaticLayoutProgressAdvanced(
+ { currentFrame: 0, totalFrames: 100, percentage: 2.5, stage: "preparing" },
+ previous,
+ ),
+ ).toBe(false);
+ expect(
+ hasNativeStaticLayoutProgressAdvanced(
+ { currentFrame: 0, totalFrames: 100, percentage: 2.7, stage: "preparing" },
+ previous,
+ ),
+ ).toBe(true);
+ expect(
+ hasNativeStaticLayoutProgressAdvanced(
+ { currentFrame: 1, totalFrames: 100, percentage: 2.5 },
+ previous,
+ ),
+ ).toBe(true);
+ expect(
+ hasNativeStaticLayoutProgressAdvanced(
+ { currentFrame: 100, totalFrames: 100, percentage: 97.25, stage: "finalizing" },
+ { currentFrame: 100, percentage: 97.25 },
+ ),
+ ).toBe(true);
+ });
+});
+
+describe("parseNativeVideoMetadataProbeOutput", () => {
+ it("parses FFmpeg input metadata with video and audio streams", () => {
+ const metadata = parseNativeVideoMetadataProbeOutput(`
+Input #0, mov,mp4,m4a,3gp,3g2,mj2, from 'recording.mp4':
+ Metadata:
+ major_brand : isom
+ Duration: 00:06:04.25, start: 0.000000, bitrate: 3938 kb/s
+ Stream #0:0[0x1](und): Video: h264 (High) (avc1 / 0x31637661), yuv420p(progressive), 1920x1080, 3720 kb/s, 46.05 fps, 60 tbr, 90k tbn (default)
+ Stream #0:1[0x2](und): Audio: aac (LC) (mp4a / 0x6134706D), 48000 Hz, stereo, fltp, 192 kb/s (default)
+`);
+
+ expect(metadata).toEqual({
+ width: 1920,
+ height: 1080,
+ duration: 364.25,
+ mediaStartTime: 0,
+ streamStartTime: 0,
+ streamDuration: 364.25,
+ frameRate: 46.05,
+ codec: "h264 (High) (avc1 / 0x31637661)",
+ hasAudio: true,
+ audioCodec: "aac (LC) (mp4a / 0x6134706D)",
+ audioSampleRate: 48000,
+ });
+ });
+
+ it("parses video-only metadata and falls back to tbr when fps is absent", () => {
+ const metadata = parseNativeVideoMetadataProbeOutput(`
+Input #0, matroska,webm, from 'recording.webm':
+ Duration: 00:00:10.50, start: 0.023000, bitrate: 1000 kb/s
+ Stream #0:0: Video: vp9, yuv420p, 1280x720, 30 tbr, 1k tbn
+`);
+
+ expect(metadata).toEqual({
+ width: 1280,
+ height: 720,
+ duration: 10.5,
+ mediaStartTime: 0.023,
+ streamStartTime: 0.023,
+ streamDuration: 10.5,
+ frameRate: 30,
+ codec: "vp9",
+ hasAudio: false,
+ audioCodec: undefined,
+ audioSampleRate: undefined,
+ });
+ });
+
+ it("rejects output without usable video metadata", () => {
+ expect(parseNativeVideoMetadataProbeOutput("Duration: N/A")).toBeNull();
+ expect(parseNativeVideoMetadataProbeOutput("not a media file")).toBeNull();
+ });
+});
+
+describe("parseFfmpegDurationSeconds", () => {
+ it("parses HH:MM:SS timestamps", () => {
+ expect(parseFfmpegDurationSeconds("01:02:03.5")).toBe(3723.5);
+ expect(parseFfmpegDurationSeconds("bad")).toBeNull();
+ });
+});
+
+describe("parseFfmpegFrameRate", () => {
+ it("prefers fps and falls back to tbr", () => {
+ expect(parseFfmpegFrameRate("Video: h264, 1920x1080, 59.94 fps, 60 tbr")).toBe(59.94);
+ expect(parseFfmpegFrameRate("Video: h264, 1920x1080, 30 tbr")).toBe(30);
+ expect(parseFfmpegFrameRate("Video: h264")).toBeNull();
+ });
+});
diff --git a/electron/ipc/export/native-video.ts b/electron/ipc/export/native-video.ts
new file mode 100644
index 000000000..1aa5fc4e2
--- /dev/null
+++ b/electron/ipc/export/native-video.ts
@@ -0,0 +1,4195 @@
+import { probeNativeVideoMetadata, type NativeVideoMetadataProbe } from "../ffmpeg/metadata";
+export {
+ probeNativeVideoMetadata,
+ parseNativeVideoMetadataProbeOutput,
+ parseFfmpegDurationSeconds,
+ parseFfmpegFrameRate,
+ type NativeVideoMetadataProbe,
+} from "../ffmpeg/metadata";
+import type { ChildProcessByStdio } from "node:child_process";
+import { execFile, spawn } from "node:child_process";
+import fs from "node:fs/promises";
+import os from "node:os";
+import path from "node:path";
+import { performance } from "node:perf_hooks";
+import type { Readable, Writable } from "node:stream";
+import { promisify } from "node:util";
+import type { WebContents } from "electron";
+import { app, powerSaveBlocker } from "electron";
+import { getFfmpegBinaryPath, getFfprobeBinaryPath } from "../ffmpeg/binary";
+import type {
+ NativeExportEncodingMode,
+ NativeStaticLayoutBackend,
+ NativeStaticLayoutExportArgsConfig,
+ NativeVideoAudioMuxMetrics,
+ NativeVideoExportAudioMode,
+ NativeVideoExportEditedTrackSegment,
+ NativeVideoExportFinishOptions,
+} from "../nativeVideoExport";
+import {
+ buildEditedTrackSourceAudioFilter,
+ buildNativeConcatArgs,
+ buildNativeCudaOverlayStaticLayoutArgs,
+ buildNativeCudaScaleCpuPadStaticLayoutArgs,
+ buildNativePrecompositedStaticLayoutArgs,
+ buildNativeStaticBackgroundRenderArgs,
+ buildNativeStaticLayoutChunks,
+ buildNativeVideoExportArgs,
+ buildTrimmedSourceAudioFilter,
+ createNativeSquircleMaskPgmBuffer,
+ getEditedAudioExtension,
+ getNativeVideoInputByteSize,
+ getPreferredNativeVideoEncoders,
+ isNativeCudaOutOfMemory,
+ parseAvailableFfmpegEncoders,
+} from "../nativeVideoExport";
+import { cachedNativeVideoEncoder, setCachedNativeVideoEncoder } from "../state";
+
+const execFileAsync = promisify(execFile);
+const getNowMs = () => performance.now();
+const formatFfmpegSeconds = (milliseconds: number) => (milliseconds / 1000).toFixed(3);
+const MISSING_NATIVE_STATIC_BACKGROUND_COLOR = "#ffffff";
+const NATIVE_EXPORT_HIGH_PRIORITY = os.constants.priority.PRIORITY_HIGH;
+const NVIDIA_PCI_VENDOR_ID = 0x10de;
+const NVIDIA_CUDA_EXPORT_ENV = "RECORDLY_EXPERIMENTAL_NVIDIA_CUDA_EXPORT";
+const NVIDIA_CUDA_ALLOW_AUDIO_EXPORT_ENV = "RECORDLY_NVIDIA_CUDA_ALLOW_AUDIO_EXPORT";
+const NVIDIA_CUDA_FORCE_VIDEO_ONLY_ENV = "RECORDLY_NVIDIA_CUDA_FORCE_VIDEO_ONLY";
+const NVIDIA_CUDA_AUTO_STALL_TIMEOUT_ENV = "RECORDLY_NVIDIA_CUDA_AUTO_STALL_TIMEOUT_MS";
+const DEFAULT_NVIDIA_CUDA_AUTO_STALL_TIMEOUT_MS = 120_000;
+const NATIVE_GPU_STALL_TIMEOUT_ENV = "RECORDLY_NATIVE_GPU_STALL_TIMEOUT_MS";
+const DEFAULT_NATIVE_GPU_STALL_TIMEOUT_MS = 120_000;
+const NATIVE_STATIC_LAYOUT_SOURCE_PROXY_REFERENCE_PIXEL_RATE = 1920 * 1080 * 30;
+const NATIVE_STATIC_LAYOUT_SOURCE_PROXY_1080P30_BITRATE = 24_000_000;
+const NATIVE_STATIC_LAYOUT_SOURCE_PROXY_MAX_BITRATE = 80_000_000;
+const NATIVE_STATIC_LAYOUT_SOURCE_PROXY_CONTAINERS = new Set([".mp4", ".m4v", ".mov"]);
+
+type ElectronGpuDeviceLike = {
+ active?: boolean;
+ vendorId?: number | string;
+ vendorString?: string;
+ deviceString?: string;
+};
+
+type ElectronGpuInfoLike = {
+ gpuDevice?: ElectronGpuDeviceLike[];
+ machineModelName?: string;
+ machineModelVersion?: string;
+};
+
+export interface ExportHardwareInfo {
+ platform: NodeJS.Platform;
+ release: string;
+ arch: string;
+ cpuModel: string | null;
+ logicalProcessors: number;
+ totalMemoryGb: number;
+ machineModel: string | null;
+ gpus: Array<{
+ name: string;
+ vendor: string | null;
+ active: boolean | null;
+ }>;
+ gpuFeatures: {
+ videoDecode: string | null;
+ videoEncode: string | null;
+ webgl: string | null;
+ webgpu: string | null;
+ };
+}
+
+export type NativeVideoExportSession = {
+ ffmpegProcess: ChildProcessByStdio;
+ outputPath: string;
+ inputByteSize: number;
+ inputMode: "rawvideo" | "h264-stream";
+ maxQueuedWriteBytes: number;
+ stderrOutput: string;
+ encoderName: string;
+ processError: Error | null;
+ stdinError: Error | null;
+ terminating: boolean;
+ writeSequence: Promise;
+ completionPromise: Promise;
+ sender: WebContents | null;
+ pendingWriteRequestIds: Set;
+};
+
+export const nativeVideoExportSessions = new Map();
+
+export interface NativeStaticLayoutTimelineSegment {
+ sourceStartMs: number;
+ sourceEndMs: number;
+ outputStartMs: number;
+ outputEndMs: number;
+ speed: number;
+}
+
+export interface NativeStaticLayoutExportOptions {
+ sessionId?: string;
+ inputPath: string;
+ width: number;
+ height: number;
+ frameRate: number;
+ bitrate: number;
+ encodingMode: NativeExportEncodingMode;
+ durationSec: number;
+ contentWidth: number;
+ contentHeight: number;
+ offsetX: number;
+ offsetY: number;
+ sourceCropX?: number;
+ sourceCropY?: number;
+ sourceCropWidth?: number;
+ sourceCropHeight?: number;
+ backgroundColor: string;
+ backgroundImagePath?: string | null;
+ backgroundBlurPx?: number;
+ borderRadius?: number;
+ shadowIntensity?: number;
+ webcamInputPath?: string | null;
+ webcamLeft?: number;
+ webcamTop?: number;
+ webcamSize?: number;
+ webcamRadius?: number;
+ webcamShadowIntensity?: number;
+ webcamMirror?: boolean;
+ webcamTimeOffsetMs?: number;
+ cursorTelemetry?: Array<{
+ timeMs: number;
+ cx: number;
+ cy: number;
+ cursorTypeIndex?: number;
+ bounceScale?: number;
+ visible?: boolean;
+ }>;
+ cursorTelemetryPath?: string | null;
+ cursorSize?: number;
+ cursorAtlasPngDataUrl?: string | null;
+ cursorAtlasPath?: string | null;
+ cursorAtlasEntries?: Array<{
+ index: number;
+ x: number;
+ y: number;
+ width: number;
+ height: number;
+ anchorX: number;
+ anchorY: number;
+ aspectRatio: number;
+ }>;
+ cursorAtlasMetadataPath?: string | null;
+ zoomTelemetry?: Array<{ timeMs: number; scale: number; x: number; y: number }>;
+ zoomTelemetryPath?: string | null;
+ timelineSegments?: NativeStaticLayoutTimelineSegment[];
+ timelineMapPath?: string | null;
+ chunkDurationSec?: number;
+ experimentalWindowsGpuCompositor?: boolean;
+ experimentalNvidiaCudaExport?: boolean;
+ audioOptions?: NativeVideoExportFinishOptions;
+ nvidiaCudaForceVideoOnly?: boolean;
+}
+
+export interface NativeStaticLayoutExportProgress {
+ sessionId?: string;
+ backend?: NativeStaticLayoutBackend;
+ stage?: "preparing" | "finalizing";
+ elapsedMs?: number;
+ averageFps?: number;
+ instantFps?: number;
+ intervalMs?: number;
+ intervalFrames?: number;
+ intervalDecodeWallMs?: number;
+ intervalEncodeMs?: number;
+ intervalPipelineWaitMs?: number;
+ intervalCompositeMs?: number;
+ intervalNvencMs?: number;
+ intervalPacketWriteMs?: number;
+ intervalWebcamDecodeMs?: number;
+ intervalWebcamCopyMs?: number;
+ intervalRoiCompositeFrames?: number;
+ intervalMonolithicCompositeFrames?: number;
+ intervalCopyCompositeFrames?: number;
+ currentFrame: number;
+ totalFrames: number;
+ percentage: number;
+}
+
+type NativeVideoAudioMuxProgress = {
+ ratio: number;
+ processedSec?: number;
+ totalSec?: number;
+};
+
+type NativeVideoAudioMuxArgsOptions = {
+ progressPipe?: 1 | 2;
+};
+
+export interface WindowsGpuExportSummary {
+ success?: boolean;
+ width?: number;
+ height?: number;
+ fps?: number;
+ seconds?: number;
+ mediaMs?: number;
+ frames?: number;
+ gpuDecodeSurface?: boolean;
+ webcamOverlay?: boolean;
+ cursorOverlay?: boolean;
+ cursorAtlas?: boolean;
+ zoomOverlay?: boolean;
+ surfacePoolSize?: number;
+ adapterIndex?: number;
+ adapterVendorId?: number;
+ adapterDeviceId?: number;
+ adapterDedicatedVideoMemoryMB?: number;
+ encoderBackend?: string;
+ encoderTuningApplied?: boolean;
+ nvencOutputBytes?: number;
+ initializeMs?: number;
+ initCoInitializeMs?: number;
+ initMfStartupMs?: number;
+ initD3DDeviceMs?: number;
+ initSourceReaderMs?: number;
+ initWebcamReaderMs?: number;
+ initVideoProcessorMs?: number;
+ initTexturesMs?: number;
+ initShaderPipelineMs?: number;
+ initSinkWriterMs?: number;
+ totalMs?: number;
+ readMs?: number;
+ clearMs?: number;
+ videoProcessMs?: number;
+ writeSampleMs?: number;
+ finalizeMs?: number;
+ realtimeMultiplier?: number;
+}
+
+export interface NvidiaCudaExportSummary {
+ success?: boolean;
+ inputPath?: string;
+ outputPath?: string;
+ fps?: number;
+ bitrateMbps?: number;
+ durationSec?: number;
+ targetFrames?: number;
+ sourcePtsFrames?: number;
+ sourcePtsSource?: string;
+ timingsMs?: {
+ demux?: number;
+ backgroundConvert?: number;
+ cursorAtlas?: number;
+ webcamConvert?: number;
+ webcamDemux?: number;
+ sourcePtsProbe?: number;
+ nativeEncode?: number;
+ mux?: number;
+ endToEnd?: number;
+ };
+ nativeSummary?: {
+ success?: boolean;
+ selectionStage?: string;
+ sourceTimestampMode?: string;
+ timelineMode?: string;
+ frames?: number;
+ totalMs?: number;
+ fps?: number;
+ measuredFps?: number;
+ mappedDisplayFrames?: number;
+ selectedDisplayFrames?: number;
+ skippedDisplayFrames?: number;
+ roiCompositeFrames?: number;
+ monolithicCompositeFrames?: number;
+ copyCompositeFrames?: number;
+ cursorAtlas?: boolean;
+ webcamOverlay?: boolean;
+ zoomOverlay?: boolean;
+ zoomSamples?: number;
+ };
+ nativeProcessPriorityBoosted?: boolean;
+ appRuntimeGuard?: {
+ powerGuardStarted?: boolean;
+ wrapperProcessPriorityBoosted?: boolean;
+ nativeProcessPriorityBoosted?: boolean;
+ };
+ gpuSamples?: unknown[];
+ gpuSummary?: unknown;
+ outputVideo?: unknown;
+ outputAudio?: unknown;
+}
+
+export interface NativeVideoStreamStatsProbe {
+ durationSec: number | null;
+ frameCount: number | null;
+ frameRate: number | null;
+}
+
+export interface NativeStaticLayoutChunkMetric {
+ index: number;
+ startSec: number;
+ durationSec: number;
+ backend: NativeStaticLayoutBackend;
+ elapsedMs: number;
+ outputBytes: number;
+ fallbackReason?: string;
+ windowsGpuSummary?: WindowsGpuExportSummary;
+ nvidiaCudaSummary?: NvidiaCudaExportSummary;
+}
+
+export interface NativeStaticLayoutExportMetrics extends NativeVideoAudioMuxMetrics {
+ chunkCount: number;
+ chunkDurationSec: number;
+ chunkExecMs: number;
+ concatExecMs?: number;
+ staticAssetExecMs?: number;
+ fallbackChunkCount: number;
+ videoOnlyBytes?: number;
+ chunks: NativeStaticLayoutChunkMetric[];
+}
+
+export interface NativeStaticLayoutExportSession {
+ terminating: boolean;
+ currentProcess: ReturnType | null;
+}
+
+export function cleanupNativeVideoExportSessions() {
+ for (const [sessionId, session] of nativeVideoExportSessions) {
+ session.terminating = true;
+ try {
+ if (!session.ffmpegProcess.stdin.destroyed) {
+ session.ffmpegProcess.stdin.destroy();
+ }
+ } catch {
+ /* stream may already be closed */
+ }
+ try {
+ session.ffmpegProcess.kill("SIGKILL");
+ } catch {
+ /* process may already be exited */
+ }
+ nativeVideoExportSessions.delete(sessionId);
+ }
+
+ for (const [sessionId, session] of nativeStaticLayoutExportSessions) {
+ session.terminating = true;
+ try {
+ session.currentProcess?.kill("SIGKILL");
+ } catch {
+ /* process may already be exited */
+ }
+ nativeStaticLayoutExportSessions.delete(sessionId);
+ }
+}
+
+export function parseWindowsGpuExportSummary(stdout: string): WindowsGpuExportSummary | null {
+ const summaryLine = stdout
+ .trim()
+ .split(/\r?\n/)
+ .reverse()
+ .find((line) => line.trim().startsWith("{"));
+ if (!summaryLine) {
+ return null;
+ }
+
+ try {
+ const parsed = JSON.parse(summaryLine) as WindowsGpuExportSummary;
+ return parsed && typeof parsed === "object" ? parsed : null;
+ } catch {
+ return null;
+ }
+}
+
+export function parseNvidiaCudaExportSummary(stdout: string): NvidiaCudaExportSummary | null {
+ const trimmed = stdout.trim();
+ const startIndex = trimmed.indexOf("{");
+ const endIndex = trimmed.lastIndexOf("}");
+ if (startIndex === -1 || endIndex <= startIndex) {
+ return null;
+ }
+
+ try {
+ const parsed = JSON.parse(
+ trimmed.slice(startIndex, endIndex + 1),
+ ) as NvidiaCudaExportSummary;
+ return parsed && typeof parsed === "object" ? parsed : null;
+ } catch {
+ return null;
+ }
+}
+
+function getFiniteNumber(value: unknown) {
+ const numberValue = typeof value === "string" ? Number(value) : value;
+ return typeof numberValue === "number" && Number.isFinite(numberValue) ? numberValue : null;
+}
+
+function getNvidiaCudaOutputStreamNumber(stream: unknown, property: string) {
+ if (!stream || typeof stream !== "object") {
+ return null;
+ }
+
+ return getFiniteNumber((stream as Record)[property]);
+}
+
+export function validateNvidiaCudaExportSummary(
+ summary: NvidiaCudaExportSummary,
+ expected: {
+ durationSec: number;
+ targetFrames: number;
+ requiresTimelineSync?: boolean;
+ },
+) {
+ const issues: string[] = [];
+ const expectedFrames = Math.max(1, Math.round(expected.targetFrames));
+ const minimumFrames = Math.max(1, Math.floor(expectedFrames * 0.95));
+ const expectedDurationSec = Math.max(0, expected.durationSec);
+ const durationToleranceSec = Math.min(2, Math.max(0.5, expectedDurationSec * 0.02));
+ const nativeFrames = getFiniteNumber(summary.nativeSummary?.frames);
+ const outputVideoFrames = getNvidiaCudaOutputStreamNumber(summary.outputVideo, "nb_frames");
+ const outputVideoDurationSec = getNvidiaCudaOutputStreamNumber(summary.outputVideo, "duration");
+ const outputAudioDurationSec = getNvidiaCudaOutputStreamNumber(summary.outputAudio, "duration");
+
+ if (!summary.outputVideo) {
+ issues.push("missing output video probe");
+ }
+ if (expected.requiresTimelineSync && !isNvidiaCudaTimestampAlignedSummary(summary)) {
+ issues.push("CUDA timeline mode is not timestamp-aligned for audio export");
+ }
+ if (expected.requiresTimelineSync) {
+ if (!summary.outputAudio) {
+ issues.push("missing output audio stream");
+ } else if (outputAudioDurationSec === null) {
+ issues.push("missing output audio duration");
+ } else if (outputAudioDurationSec <= 0) {
+ issues.push("output audio duration is not positive");
+ }
+ }
+ if (nativeFrames === null) {
+ issues.push("missing native frame count");
+ } else if (nativeFrames < minimumFrames) {
+ issues.push(`native frames ${nativeFrames} below expected minimum ${minimumFrames}`);
+ }
+ if (outputVideoFrames !== null && outputVideoFrames < minimumFrames) {
+ issues.push(
+ `output video frames ${outputVideoFrames} below expected minimum ${minimumFrames}`,
+ );
+ }
+ if (
+ outputVideoDurationSec !== null &&
+ Math.abs(outputVideoDurationSec - expectedDurationSec) > durationToleranceSec
+ ) {
+ issues.push(
+ `output video duration ${outputVideoDurationSec.toFixed(
+ 3,
+ )}s differs from expected ${expectedDurationSec.toFixed(3)}s`,
+ );
+ }
+ if (
+ outputAudioDurationSec !== null &&
+ outputAudioDurationSec > 0 &&
+ Math.abs(outputAudioDurationSec - expectedDurationSec) > durationToleranceSec
+ ) {
+ issues.push(
+ `output audio duration ${outputAudioDurationSec.toFixed(
+ 3,
+ )}s differs from expected ${expectedDurationSec.toFixed(3)}s`,
+ );
+ }
+
+ return issues;
+}
+
+export function validateWindowsGpuExportSummary(
+ summary: WindowsGpuExportSummary,
+ expected: {
+ durationSec: number;
+ targetFrames: number;
+ },
+) {
+ const issues: string[] = [];
+ const expectedFrames = Math.max(1, Math.round(expected.targetFrames));
+ const minimumFrames = Math.max(1, Math.floor(expectedFrames * 0.95));
+ const expectedDurationSec = Math.max(0, expected.durationSec);
+ const durationToleranceSec = Math.min(2, Math.max(0.5, expectedDurationSec * 0.02));
+ const frames = getFiniteNumber(summary.frames);
+ const mediaMs = getFiniteNumber(summary.mediaMs);
+ const seconds = getFiniteNumber(summary.seconds) ?? (mediaMs !== null ? mediaMs / 1000 : null);
+
+ if (frames === null) {
+ issues.push("missing Windows GPU frame count");
+ } else if (frames < minimumFrames) {
+ issues.push(`Windows GPU frames ${frames} below expected minimum ${minimumFrames}`);
+ }
+ if (
+ seconds !== null &&
+ Number.isFinite(seconds) &&
+ Math.abs(seconds - expectedDurationSec) > durationToleranceSec
+ ) {
+ issues.push(
+ `Windows GPU duration ${seconds.toFixed(3)}s differs from expected ${expectedDurationSec.toFixed(3)}s`,
+ );
+ }
+
+ return issues;
+}
+
+function parseRationalFrameRate(value: unknown) {
+ if (typeof value !== "string") {
+ return null;
+ }
+ const [numeratorRaw, denominatorRaw] = value.split("/");
+ const numerator = Number(numeratorRaw);
+ const denominator = Number(denominatorRaw);
+ if (
+ !Number.isFinite(numerator) ||
+ !Number.isFinite(denominator) ||
+ numerator <= 0 ||
+ denominator <= 0
+ ) {
+ return null;
+ }
+
+ return numerator / denominator;
+}
+
+function parseOptionalPositiveNumber(value: unknown) {
+ const parsed =
+ typeof value === "number" ? value : typeof value === "string" ? Number(value) : NaN;
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
+}
+
+function parseOptionalPositiveInteger(value: unknown) {
+ const parsed = parseOptionalPositiveNumber(value);
+ return parsed === null ? null : Math.max(0, Math.round(parsed));
+}
+
+export function parseNativeVideoStreamStatsProbeOutput(
+ output: string,
+): NativeVideoStreamStatsProbe | null {
+ const parsed = JSON.parse(output) as {
+ streams?: Array<{
+ duration?: unknown;
+ nb_frames?: unknown;
+ nb_read_frames?: unknown;
+ avg_frame_rate?: unknown;
+ r_frame_rate?: unknown;
+ }>;
+ };
+ const stream = parsed.streams?.[0];
+ if (!stream) {
+ return null;
+ }
+
+ return {
+ durationSec: parseOptionalPositiveNumber(stream.duration),
+ frameCount:
+ parseOptionalPositiveInteger(stream.nb_read_frames) ??
+ parseOptionalPositiveInteger(stream.nb_frames),
+ frameRate:
+ parseRationalFrameRate(stream.avg_frame_rate) ??
+ parseRationalFrameRate(stream.r_frame_rate),
+ };
+}
+
+export function validateNativeVideoStreamStats(
+ stats: NativeVideoStreamStatsProbe,
+ expected: {
+ durationSec: number;
+ targetFrames: number;
+ },
+) {
+ const issues: string[] = [];
+ const expectedFrames = Math.max(1, Math.round(expected.targetFrames));
+ const minimumFrames = Math.max(1, Math.floor(expectedFrames * 0.95));
+ const expectedDurationSec = Math.max(0, expected.durationSec);
+ const durationToleranceSec = Math.min(2, Math.max(0.5, expectedDurationSec * 0.02));
+
+ if (stats.frameCount === null) {
+ issues.push("missing video frame count");
+ } else if (stats.frameCount < minimumFrames) {
+ issues.push(`video frames ${stats.frameCount} below expected minimum ${minimumFrames}`);
+ }
+
+ if (stats.durationSec === null) {
+ issues.push("missing video stream duration");
+ } else if (Math.abs(stats.durationSec - expectedDurationSec) > durationToleranceSec) {
+ issues.push(
+ `video stream duration ${stats.durationSec.toFixed(
+ 3,
+ )}s differs from expected ${expectedDurationSec.toFixed(3)}s`,
+ );
+ }
+
+ return issues;
+}
+
+function isNvidiaCudaTimestampAlignedSummary(summary: NvidiaCudaExportSummary) {
+ const nativeSummary = summary.nativeSummary;
+ const timelineFields = [
+ nativeSummary?.sourceTimestampMode,
+ nativeSummary?.timelineMode,
+ nativeSummary?.selectionStage,
+ ];
+ return timelineFields.some((value) => {
+ const normalized = String(value ?? "").toLowerCase();
+ return normalized.includes("pts") || normalized.includes("timestamp");
+ });
+}
+
+function shouldPersistNativeExportDiagnostics() {
+ const rawValue = process.env.RECORDLY_NATIVE_EXPORT_DIAGNOSTICS?.trim().toLowerCase();
+ return rawValue !== "0" && rawValue !== "off" && rawValue !== "false";
+}
+
+function shouldPersistNvidiaCudaExportDiagnostics() {
+ return (
+ shouldPersistNativeExportDiagnostics() ||
+ process.env.RECORDLY_NVIDIA_CUDA_EXPORT_DIAGNOSTICS === "1"
+ );
+}
+
+function getSafeDiagnosticsFileSegment(value: string | undefined) {
+ const safe = (value || "session").replace(/[^a-zA-Z0-9._-]+/g, "-").slice(0, 96);
+ return safe || "session";
+}
+
+function getCliArgValue(args: string[], name: string) {
+ const index = args.indexOf(name);
+ if (index === -1 || index + 1 >= args.length) {
+ return null;
+ }
+ return args[index + 1] || null;
+}
+
+async function persistNvidiaCudaExportDiagnostics(params: {
+ args: string[];
+ code: number | null;
+ elapsedMs: number;
+ outputPath: string;
+ sessionId?: string;
+ signal: NodeJS.Signals | null;
+ startedAtIso: string;
+ stderr: string;
+ stdout: string;
+ summary: NvidiaCudaExportSummary | null;
+ timedOut: boolean;
+}) {
+ if (!shouldPersistNvidiaCudaExportDiagnostics()) {
+ return;
+ }
+
+ const diagnosticsDirectory = path.join(app.getPath("userData"), "native-export-diagnostics");
+ const filePrefix = `${Date.now()}-${getSafeDiagnosticsFileSegment(params.sessionId)}`;
+ const manifest = {
+ backend: "nvidia-cuda-compositor",
+ startedAt: params.startedAtIso,
+ completedAt: new Date().toISOString(),
+ elapsedMs: Number(params.elapsedMs.toFixed(2)),
+ sessionId: params.sessionId ?? null,
+ outputPath: params.outputPath,
+ exitCode: params.code,
+ signal: params.signal,
+ timedOut: params.timedOut,
+ args: params.args,
+ summary: params.summary,
+ };
+
+ try {
+ await fs.mkdir(diagnosticsDirectory, { recursive: true });
+ const artifactsDirectory = path.join(diagnosticsDirectory, `${filePrefix}.artifacts`);
+ const artifactArgs = [
+ "--cursor-json",
+ "--cursor-atlas-png",
+ "--cursor-atlas-metadata",
+ "--zoom-telemetry",
+ ];
+ const artifactCopies = artifactArgs
+ .map((argName) => {
+ const sourcePath = getCliArgValue(params.args, argName);
+ return sourcePath
+ ? {
+ argName,
+ sourcePath,
+ outputPath: path.join(
+ artifactsDirectory,
+ `${getSafeDiagnosticsFileSegment(argName.replace(/^--/, ""))}-${path.basename(sourcePath)}`,
+ ),
+ }
+ : null;
+ })
+ .filter((artifact): artifact is NonNullable => Boolean(artifact));
+ await fs.mkdir(artifactsDirectory, { recursive: true }).catch(() => undefined);
+ await Promise.allSettled([
+ fs.writeFile(
+ path.join(diagnosticsDirectory, `${filePrefix}.manifest.json`),
+ `${JSON.stringify(manifest, null, 2)}\n`,
+ ),
+ fs.writeFile(
+ path.join(diagnosticsDirectory, `${filePrefix}.stdout.json`),
+ params.stdout,
+ ),
+ fs.writeFile(
+ path.join(diagnosticsDirectory, `${filePrefix}.stderr.log`),
+ params.stderr,
+ ),
+ ...artifactCopies.map((artifact) =>
+ fs.copyFile(artifact.sourcePath, artifact.outputPath).catch(() => undefined),
+ ),
+ ]);
+ } catch (error) {
+ console.warn(
+ "[native-static-layout-export] Failed to persist NVIDIA CUDA diagnostics",
+ error,
+ );
+ }
+}
+
+async function persistWindowsGpuExportDiagnostics(params: {
+ args: string[];
+ code: number | null;
+ elapsedMs: number;
+ outputPath: string;
+ signal: NodeJS.Signals | null;
+ startedAtIso: string;
+ stderr: string;
+ stdout: string;
+ summary: WindowsGpuExportSummary | null;
+ timedOut: boolean;
+}) {
+ if (!shouldPersistNativeExportDiagnostics()) {
+ return;
+ }
+
+ const diagnosticsDirectory = path.join(app.getPath("userData"), "native-export-diagnostics");
+ const filePrefix = `${Date.now()}-windows-d3d11-compositor`;
+ const manifest = {
+ backend: "windows-d3d11-compositor",
+ startedAt: params.startedAtIso,
+ completedAt: new Date().toISOString(),
+ elapsedMs: Number(params.elapsedMs.toFixed(2)),
+ outputPath: params.outputPath,
+ exitCode: params.code,
+ signal: params.signal,
+ timedOut: params.timedOut,
+ args: params.args,
+ summary: params.summary,
+ };
+
+ try {
+ await fs.mkdir(diagnosticsDirectory, { recursive: true });
+ await Promise.allSettled([
+ fs.writeFile(
+ path.join(diagnosticsDirectory, `${filePrefix}.manifest.json`),
+ `${JSON.stringify(manifest, null, 2)}\n`,
+ ),
+ fs.writeFile(
+ path.join(diagnosticsDirectory, `${filePrefix}.stdout.log`),
+ params.stdout,
+ ),
+ fs.writeFile(
+ path.join(diagnosticsDirectory, `${filePrefix}.stderr.log`),
+ params.stderr,
+ ),
+ ]);
+ } catch (error) {
+ console.warn(
+ "[native-static-layout-export] Failed to persist Windows GPU diagnostics",
+ error,
+ );
+ }
+}
+
+export function parseWindowsGpuExportProgressLine(
+ line: string,
+): NativeStaticLayoutExportProgress | null {
+ const trimmed = line.trim();
+ const prefix = "PROGRESS ";
+ if (!trimmed.startsWith(prefix)) {
+ return null;
+ }
+
+ try {
+ const parsed = JSON.parse(trimmed.slice(prefix.length)) as {
+ currentFrame?: unknown;
+ totalFrames?: unknown;
+ percentage?: unknown;
+ averageFps?: unknown;
+ instantFps?: unknown;
+ intervalMs?: unknown;
+ intervalFrames?: unknown;
+ intervalDecodeWallMs?: unknown;
+ intervalEncodeMs?: unknown;
+ intervalPipelineWaitMs?: unknown;
+ intervalCompositeMs?: unknown;
+ intervalNvencMs?: unknown;
+ intervalPacketWriteMs?: unknown;
+ intervalWebcamDecodeMs?: unknown;
+ intervalWebcamCopyMs?: unknown;
+ intervalRoiCompositeFrames?: unknown;
+ intervalMonolithicCompositeFrames?: unknown;
+ intervalCopyCompositeFrames?: unknown;
+ stage?: unknown;
+ };
+ const currentFrame = Number(parsed.currentFrame);
+ const totalFrames = Number(parsed.totalFrames);
+ const percentage = Number(parsed.percentage);
+ if (
+ !Number.isFinite(currentFrame) ||
+ !Number.isFinite(totalFrames) ||
+ !Number.isFinite(percentage) ||
+ totalFrames <= 0
+ ) {
+ return null;
+ }
+
+ const progress: NativeStaticLayoutExportProgress = {
+ currentFrame: Math.max(0, Math.floor(currentFrame)),
+ totalFrames: Math.max(1, Math.floor(totalFrames)),
+ percentage: Math.min(100, Math.max(0, percentage)),
+ };
+ if (parsed.stage === "preparing" || parsed.stage === "finalizing") {
+ progress.stage = parsed.stage;
+ }
+ const optionalNumberFields = [
+ "averageFps",
+ "instantFps",
+ "intervalMs",
+ "intervalFrames",
+ "intervalDecodeWallMs",
+ "intervalEncodeMs",
+ "intervalPipelineWaitMs",
+ "intervalCompositeMs",
+ "intervalNvencMs",
+ "intervalPacketWriteMs",
+ "intervalWebcamDecodeMs",
+ "intervalWebcamCopyMs",
+ "intervalRoiCompositeFrames",
+ "intervalMonolithicCompositeFrames",
+ "intervalCopyCompositeFrames",
+ ] as const;
+ for (const field of optionalNumberFields) {
+ const value = Number(parsed[field]);
+ if (Number.isFinite(value) && value >= 0) {
+ progress[field] = value;
+ }
+ }
+ return progress;
+ } catch {
+ return null;
+ }
+}
+
+export function mapNvidiaCudaWrapperProgressPercentage(progress: NativeStaticLayoutExportProgress) {
+ if (progress.stage === "finalizing") {
+ return progress.percentage;
+ }
+
+ if (progress.currentFrame > 0 || progress.percentage === 0) {
+ return Math.min(98, 3 + progress.percentage * 0.95);
+ }
+
+ return progress.percentage;
+}
+
+export function hasNativeStaticLayoutProgressAdvanced(
+ progress: { currentFrame: number; percentage: number; stage?: string },
+ previous: { currentFrame: number; percentage: number; stage?: string },
+) {
+ const currentFrame = Math.max(0, Math.floor(progress.currentFrame));
+ const percentage =
+ typeof progress.percentage === "number" && Number.isFinite(progress.percentage)
+ ? progress.percentage
+ : 0;
+ if (currentFrame > previous.currentFrame) {
+ return true;
+ }
+ if (percentage > previous.percentage + 0.1) {
+ return true;
+ }
+ return progress.stage === "finalizing" && previous.stage !== "finalizing";
+}
+
+function startNativeStaticLayoutExportPowerGuard() {
+ try {
+ const blockerId = powerSaveBlocker.start("prevent-app-suspension");
+ return {
+ started: true,
+ release: () => {
+ if (powerSaveBlocker.isStarted(blockerId)) {
+ powerSaveBlocker.stop(blockerId);
+ }
+ },
+ };
+ } catch (error) {
+ console.warn("[native-static-layout-export] Failed to start power guard", error);
+ return {
+ started: false,
+ release: () => undefined,
+ };
+ }
+}
+
+function setNativeStaticLayoutExportProcessPriority(pid: number | undefined, label: string) {
+ if (!pid) {
+ return false;
+ }
+
+ try {
+ os.setPriority(pid, NATIVE_EXPORT_HIGH_PRIORITY);
+ return true;
+ } catch (error) {
+ console.warn(`[native-static-layout-export] Failed to raise ${label} priority`, error);
+ return false;
+ }
+}
+
+export const nativeStaticLayoutExportSessions = new Map();
+
+export interface NativeExportCapabilities {
+ platform: NodeJS.Platform;
+ nvidiaCuda: {
+ available: boolean;
+ skipReason: string | null;
+ hasNvidiaGpu: boolean | null;
+ hasWrapper: boolean;
+ explicitEnabled: boolean;
+ explicitDisabled: boolean;
+ userOptInRequired: boolean;
+ };
+}
+
+export async function probeNativeVideoStreamStats(
+ ffprobePath: string,
+ inputPath: string,
+): Promise {
+ const result = await execFileAsync(
+ ffprobePath,
+ [
+ "-v",
+ "error",
+ "-select_streams",
+ "v:0",
+ "-count_frames",
+ "-show_entries",
+ "stream=duration,nb_frames,nb_read_frames,avg_frame_rate,r_frame_rate",
+ "-of",
+ "json",
+ inputPath,
+ ],
+ { timeout: 120_000, maxBuffer: 2 * 1024 * 1024 },
+ );
+ const stats = parseNativeVideoStreamStatsProbeOutput(result.stdout);
+ if (!stats) {
+ throw new Error("Unable to parse native video stream stats from FFprobe output");
+ }
+
+ return stats;
+}
+
+async function validateNativeVideoOutputFile(
+ ffprobePath: string,
+ videoPath: string,
+ expected: {
+ durationSec: number;
+ targetFrames: number;
+ },
+) {
+ const stats = await probeNativeVideoStreamStats(ffprobePath, videoPath);
+ const issues = validateNativeVideoStreamStats(stats, expected);
+ if (issues.length > 0) {
+ throw new Error(`Native video output is invalid: ${issues.join("; ")}`);
+ }
+}
+
+export function isNativeStaticLayoutH264SourceCodec(codec: string | undefined) {
+ const normalized = (codec ?? "").trim().toLowerCase();
+ return (
+ /\bh\.?264\b/.test(normalized) ||
+ normalized.includes("avc1") ||
+ normalized.includes("avc3") ||
+ normalized.includes("mpeg-4 avc")
+ );
+}
+
+export function shouldCreateNativeStaticLayoutSourceProxy(
+ metadata: Pick,
+ inputPath: string,
+) {
+ const extension = path.extname(inputPath).toLowerCase();
+ return (
+ !isNativeStaticLayoutH264SourceCodec(metadata.codec) ||
+ !NATIVE_STATIC_LAYOUT_SOURCE_PROXY_CONTAINERS.has(extension)
+ );
+}
+
+export function getNativeStaticLayoutSourceProxyBitrate(
+ options: Pick,
+ metadata?: Pick,
+) {
+ const frameRate = Math.max(1, Math.round(options.frameRate));
+ const width = Math.max(1, Math.round(metadata?.width ?? options.width));
+ const height = Math.max(1, Math.round(metadata?.height ?? options.height));
+ const pixelRateScale = Math.sqrt(
+ (width * height * frameRate) / NATIVE_STATIC_LAYOUT_SOURCE_PROXY_REFERENCE_PIXEL_RATE,
+ );
+ const qualityFloor = Math.round(
+ NATIVE_STATIC_LAYOUT_SOURCE_PROXY_1080P30_BITRATE * pixelRateScale,
+ );
+ return Math.min(
+ NATIVE_STATIC_LAYOUT_SOURCE_PROXY_MAX_BITRATE,
+ Math.max(Math.round(options.bitrate * 1.2), qualityFloor),
+ );
+}
+
+export function buildNativeStaticLayoutSourceProxyArgs(
+ options: NativeStaticLayoutExportOptions,
+ outputPath: string,
+ metadata: NativeVideoMetadataProbe,
+) {
+ const frameRate = Math.max(1, Math.round(options.frameRate));
+ const sourceDurationSec = Math.max(
+ 0.001,
+ metadata.streamDuration ?? metadata.duration ?? options.durationSec,
+ );
+ const proxyBitrate = getNativeStaticLayoutSourceProxyBitrate(options, metadata);
+ return [
+ "-y",
+ "-hide_banner",
+ "-loglevel",
+ "error",
+ "-i",
+ options.inputPath,
+ "-map",
+ "0:v:0",
+ "-an",
+ "-t",
+ formatCliNumber(sourceDurationSec),
+ "-vf",
+ `fps=fps=${frameRate}:start_time=0,scale=trunc(iw/2)*2:trunc(ih/2)*2,setsar=1,format=yuv420p,setpts=PTS-STARTPTS`,
+ "-c:v",
+ "libx264",
+ "-preset",
+ "veryfast",
+ "-tune",
+ "zerolatency",
+ "-g",
+ String(Math.max(1, Math.round(frameRate * 2))),
+ "-b:v",
+ String(proxyBitrate),
+ "-maxrate",
+ String(Math.round(proxyBitrate * 1.25)),
+ "-bufsize",
+ String(proxyBitrate * 2),
+ "-pix_fmt",
+ "yuv420p",
+ "-movflags",
+ "+faststart",
+ outputPath,
+ ];
+}
+
+export function validateNativeStaticLayoutSourceProxyMetadata(
+ metadata: NativeVideoMetadataProbe,
+ expected: { sourceDurationSec: number },
+) {
+ const issues: string[] = [];
+ const expectedDurationSec = Math.max(0, expected.sourceDurationSec);
+ const durationToleranceSec = Math.min(2, Math.max(0.5, expectedDurationSec * 0.03));
+ if (!isNativeStaticLayoutH264SourceCodec(metadata.codec)) {
+ issues.push(`proxy codec is not H.264/AVC: ${metadata.codec || "unknown"}`);
+ }
+ if (!Number.isFinite(metadata.duration) || metadata.duration <= 0) {
+ issues.push("proxy duration is unavailable");
+ } else if (metadata.duration + durationToleranceSec < expectedDurationSec) {
+ issues.push(
+ `proxy duration ${metadata.duration.toFixed(
+ 3,
+ )}s shorter than expected ${expectedDurationSec.toFixed(3)}s`,
+ );
+ }
+ return issues;
+}
+
+export function getNativeVideoExportMaxQueuedWriteBytes(inputByteSize: number) {
+ if (inputByteSize === 0) return 8 * 1024 * 1024;
+ return Math.min(64 * 1024 * 1024, Math.max(16 * 1024 * 1024, inputByteSize * 4));
+}
+
+async function runFfmpegWithMetrics(
+ ffmpegPath: string,
+ args: string[],
+ timeoutMs: number,
+ session?: NativeStaticLayoutExportSession,
+): Promise<{
+ success: boolean;
+ elapsedMs: number;
+ stderr: string;
+ code: number | null;
+ signal: NodeJS.Signals | null;
+}> {
+ const startedAt = getNowMs();
+ return await new Promise((resolve) => {
+ const child = spawn(ffmpegPath, args, {
+ stdio: ["ignore", "ignore", "pipe"],
+ });
+ if (session) {
+ session.currentProcess = child;
+ if (session.terminating) {
+ child.kill("SIGKILL");
+ }
+ }
+ let stderr = "";
+ let settled = false;
+ const timeout = setTimeout(() => {
+ if (settled) return;
+ try {
+ child.kill("SIGKILL");
+ } catch {
+ // ignore
+ }
+ }, timeoutMs);
+
+ child.stderr.on("data", (chunk: Buffer) => {
+ stderr += chunk.toString();
+ });
+ child.once("error", (error) => {
+ if (settled) return;
+ settled = true;
+ if (session?.currentProcess === child) {
+ session.currentProcess = null;
+ }
+ clearTimeout(timeout);
+ resolve({
+ success: false,
+ elapsedMs: getNowMs() - startedAt,
+ stderr: error instanceof Error ? error.message : String(error),
+ code: null,
+ signal: null,
+ });
+ });
+ child.once("close", (code, signal) => {
+ if (settled) return;
+ settled = true;
+ if (session?.currentProcess === child) {
+ session.currentProcess = null;
+ }
+ clearTimeout(timeout);
+ resolve({
+ success: code === 0,
+ elapsedMs: getNowMs() - startedAt,
+ stderr,
+ code,
+ signal,
+ });
+ });
+ });
+}
+
+function parseFfmpegProgressClockSeconds(value: string) {
+ const match = value.match(/^(\d+):(\d+):(\d+(?:\.\d+)?)$/);
+ if (!match) {
+ return null;
+ }
+
+ const hours = Number(match[1]);
+ const minutes = Number(match[2]);
+ const seconds = Number(match[3]);
+ if (![hours, minutes, seconds].every(Number.isFinite)) {
+ return null;
+ }
+
+ return hours * 3600 + minutes * 60 + seconds;
+}
+
+export function parseFfmpegProgressLineSeconds(line: string) {
+ const separatorIndex = line.indexOf("=");
+ if (separatorIndex <= 0) {
+ return null;
+ }
+
+ const key = line.slice(0, separatorIndex).trim();
+ const value = line.slice(separatorIndex + 1).trim();
+ if (key === "out_time_us" || key === "out_time_ms") {
+ const microseconds = Number(value);
+ return Number.isFinite(microseconds) && microseconds >= 0 ? microseconds / 1_000_000 : null;
+ }
+ if (key === "out_time") {
+ return parseFfmpegProgressClockSeconds(value);
+ }
+
+ return null;
+}
+
+function createFfmpegMuxProgressHandler(
+ totalSec: number | undefined,
+ onProgress?: (progress: NativeVideoAudioMuxProgress) => void,
+) {
+ if (!onProgress || !Number.isFinite(totalSec) || !totalSec || totalSec <= 0) {
+ return () => undefined;
+ }
+
+ let lineBuffer = "";
+ let lastRatio = 0;
+ return (chunk: Buffer | string) => {
+ lineBuffer += chunk.toString();
+ const lines = lineBuffer.split(/\r?\n/);
+ lineBuffer = lines.pop() ?? "";
+ for (const line of lines) {
+ const processedSec = parseFfmpegProgressLineSeconds(line);
+ if (processedSec === null) {
+ continue;
+ }
+ const ratio = Math.max(0, Math.min(1, processedSec / totalSec));
+ if (ratio < lastRatio + 0.0025 && ratio < 1) {
+ continue;
+ }
+ lastRatio = Math.max(lastRatio, ratio);
+ onProgress({
+ ratio: lastRatio,
+ processedSec,
+ totalSec,
+ });
+ }
+ };
+}
+
+async function runFfmpegAudioMux(
+ ffmpegPath: string,
+ args: string[],
+ timeoutMs: number,
+ options: NativeVideoExportFinishOptions,
+ onProgress?: (progress: NativeVideoAudioMuxProgress) => void,
+ session?: NativeStaticLayoutExportSession,
+) {
+ if (!onProgress && !session) {
+ await execFileAsync(ffmpegPath, args, {
+ timeout: timeoutMs,
+ maxBuffer: 20 * 1024 * 1024,
+ });
+ return;
+ }
+
+ const handleProgressChunk = createFfmpegMuxProgressHandler(
+ options.outputDurationSec,
+ onProgress,
+ );
+ await new Promise((resolve, reject) => {
+ const child = spawn(ffmpegPath, args, {
+ stdio: ["ignore", "ignore", "pipe"],
+ });
+ if (session) {
+ session.currentProcess = child;
+ if (session.terminating) {
+ child.kill("SIGKILL");
+ }
+ }
+
+ let stderr = "";
+ let settled = false;
+ const timeout = setTimeout(() => {
+ if (settled) return;
+ child.kill("SIGKILL");
+ }, timeoutMs);
+
+ child.stderr.on("data", (chunk: Buffer) => {
+ const text = chunk.toString();
+ stderr += text;
+ handleProgressChunk(text);
+ });
+ child.once("error", (error) => {
+ if (settled) return;
+ settled = true;
+ if (session?.currentProcess === child) {
+ session.currentProcess = null;
+ }
+ clearTimeout(timeout);
+ reject(error);
+ });
+ child.once("close", (code, signal) => {
+ if (settled) return;
+ settled = true;
+ if (session?.currentProcess === child) {
+ session.currentProcess = null;
+ }
+ clearTimeout(timeout);
+ if (session?.terminating) {
+ reject(new Error("Native static layout export was cancelled"));
+ return;
+ }
+ if (code !== 0) {
+ const suffix = signal ? ` (signal ${signal})` : "";
+ reject(
+ new Error(
+ `FFmpeg audio mux exited with code ${code ?? "unknown"}${suffix}` +
+ (stderr.trim() ? `\nSTDERR:\n${stderr.trim()}` : ""),
+ ),
+ );
+ return;
+ }
+ if (onProgress) {
+ const completeProgress: NativeVideoAudioMuxProgress = { ratio: 1 };
+ if (
+ typeof options.outputDurationSec === "number" &&
+ Number.isFinite(options.outputDurationSec)
+ ) {
+ completeProgress.processedSec = options.outputDurationSec;
+ completeProgress.totalSec = options.outputDurationSec;
+ }
+ onProgress(completeProgress);
+ }
+ resolve();
+ });
+ });
+}
+
+export function isHardwareAcceleratedVideoEncoder(encoderName: string) {
+ return /(videotoolbox|nvenc|qsv|amf|mf)/i.test(encoderName);
+}
+
+export async function removeTemporaryExportFile(filePath: string | null | undefined) {
+ if (!filePath) {
+ return;
+ }
+
+ try {
+ await fs.rm(filePath, { force: true });
+ } catch {
+ // Ignore cleanup failures for temp export artifacts.
+ }
+}
+
+export function getNativeVideoExportSessionError(
+ session: NativeVideoExportSession,
+ fallback: string,
+) {
+ return (
+ session.stdinError?.message ||
+ session.processError?.message ||
+ session.stderrOutput.trim() ||
+ fallback
+ );
+}
+
+export function sendNativeVideoExportWriteFrameResult(
+ sender: WebContents | null | undefined,
+ sessionId: string,
+ requestId: number,
+ result: { success: boolean; error?: string },
+) {
+ if (!sender || sender.isDestroyed()) {
+ return;
+ }
+
+ sender.send("native-video-export-write-frame-result", {
+ sessionId,
+ requestId,
+ ...result,
+ });
+}
+
+export function settleNativeVideoExportWriteFrameRequest(
+ sessionId: string,
+ session: NativeVideoExportSession,
+ requestId: number,
+ result: { success: boolean; error?: string },
+) {
+ session.pendingWriteRequestIds.delete(requestId);
+ sendNativeVideoExportWriteFrameResult(session.sender, sessionId, requestId, result);
+}
+
+export function flushNativeVideoExportPendingWriteRequests(
+ sessionId: string,
+ session: NativeVideoExportSession,
+ error: string,
+) {
+ for (const requestId of session.pendingWriteRequestIds) {
+ sendNativeVideoExportWriteFrameResult(session.sender, sessionId, requestId, {
+ success: false,
+ error,
+ });
+ }
+
+ session.pendingWriteRequestIds.clear();
+}
+
+export function isIgnorableNativeVideoExportStreamError(error: Error | null | undefined): boolean {
+ if (!error) {
+ return false;
+ }
+
+ const errno = error as NodeJS.ErrnoException;
+ return (
+ errno.code === "EPIPE" ||
+ errno.code === "ERR_STREAM_DESTROYED" ||
+ /broken pipe|stream destroyed|eof/i.test(error.message)
+ );
+}
+
+export async function waitForNativeVideoExportDrain(session: NativeVideoExportSession) {
+ if (
+ session.stdinError ||
+ session.processError ||
+ session.ffmpegProcess.stdin.destroyed ||
+ session.ffmpegProcess.stdin.writableEnded ||
+ !session.ffmpegProcess.stdin.writable ||
+ session.ffmpegProcess.stdin.writableLength <= 0
+ ) {
+ return;
+ }
+
+ await new Promise((resolve, reject) => {
+ const timeout = setTimeout(() => {
+ cleanup();
+ reject(
+ new Error("Timed out while waiting for native export writer backpressure to clear"),
+ );
+ }, 15000);
+
+ const cleanup = () => {
+ clearTimeout(timeout);
+ session.ffmpegProcess.stdin.off("drain", handleDrain);
+ session.ffmpegProcess.stdin.off("error", handleError);
+ session.ffmpegProcess.off("close", handleClose);
+ };
+
+ const handleDrain = () => {
+ cleanup();
+ resolve();
+ };
+
+ const handleError = (error: Error) => {
+ cleanup();
+ reject(error);
+ };
+
+ const handleClose = () => {
+ cleanup();
+ reject(
+ new Error(
+ getNativeVideoExportSessionError(
+ session,
+ "Native video export writer closed before draining",
+ ),
+ ),
+ );
+ };
+
+ session.ffmpegProcess.stdin.once("drain", handleDrain);
+ session.ffmpegProcess.stdin.once("error", handleError);
+ session.ffmpegProcess.once("close", handleClose);
+ });
+}
+
+export function getNativeVideoExportFrameLength(frameData: Uint8Array | ArrayBuffer) {
+ return frameData.byteLength;
+}
+
+export async function writeNativeVideoExportFrame(
+ session: NativeVideoExportSession,
+ frameData: Uint8Array | ArrayBuffer,
+) {
+ if (
+ session.inputMode !== "h264-stream" &&
+ getNativeVideoExportFrameLength(frameData) !== session.inputByteSize
+ ) {
+ throw new Error(
+ `Native video export expected ${session.inputByteSize} bytes per frame but received ${getNativeVideoExportFrameLength(frameData)}`,
+ );
+ }
+
+ if (
+ session.stdinError ||
+ session.processError ||
+ session.ffmpegProcess.stdin.destroyed ||
+ session.ffmpegProcess.stdin.writableEnded ||
+ !session.ffmpegProcess.stdin.writable
+ ) {
+ throw new Error(
+ getNativeVideoExportSessionError(
+ session,
+ "Native video export encoder is not accepting frames",
+ ),
+ );
+ }
+
+ const frameBuffer =
+ frameData instanceof ArrayBuffer
+ ? Buffer.from(frameData)
+ : Buffer.from(frameData.buffer, frameData.byteOffset, frameData.byteLength);
+
+ try {
+ session.ffmpegProcess.stdin.write(frameBuffer);
+ } catch (error) {
+ session.stdinError = error instanceof Error ? error : new Error(String(error));
+ throw session.stdinError;
+ }
+
+ if (session.ffmpegProcess.stdin.writableLength >= session.maxQueuedWriteBytes) {
+ try {
+ await waitForNativeVideoExportDrain(session);
+ } catch (error) {
+ session.stdinError = error instanceof Error ? error : new Error(String(error));
+ throw session.stdinError;
+ }
+ }
+}
+
+function toConcatFileLine(filePath: string) {
+ const normalized = filePath.replace(/\\/g, "/").replace(/'/g, "'\\''");
+ return `file '${normalized}'`;
+}
+
+function getStaticLayoutChunkOutputPath(directory: string, index: number) {
+ return path.join(directory, `chunk-${String(index).padStart(4, "0")}.mp4`);
+}
+
+function shouldUsePrecompositedStaticLayout(options: NativeStaticLayoutExportOptions) {
+ return Boolean(
+ options.backgroundImagePath ||
+ (options.borderRadius ?? 0) > 0.5 ||
+ (options.shadowIntensity ?? 0) > 0,
+ );
+}
+
+export async function normalizeNativeStaticLayoutBackground(
+ options: NativeStaticLayoutExportOptions,
+): Promise {
+ if (!options.backgroundImagePath || (await pathExists(options.backgroundImagePath))) {
+ return options;
+ }
+
+ console.warn(
+ "[native-static-layout-export] Background image is missing; using solid fallback",
+ {
+ backgroundImagePath: options.backgroundImagePath,
+ },
+ );
+ return {
+ ...options,
+ backgroundColor: MISSING_NATIVE_STATIC_BACKGROUND_COLOR,
+ backgroundImagePath: null,
+ };
+}
+
+function getFfmpegFailureMessage(result: Awaited>) {
+ const suffix = result.signal ? ` (signal ${result.signal})` : "";
+ const status = result.code === null ? "unknown" : String(result.code);
+ return result.stderr.trim() || `FFmpeg exited with code ${status}${suffix}`;
+}
+
+function clampUnit(value: number) {
+ if (!Number.isFinite(value)) {
+ return 0;
+ }
+
+ return Math.min(1, Math.max(0, value));
+}
+
+function formatCliNumber(value: number) {
+ return Number.isInteger(value)
+ ? String(value)
+ : value.toFixed(6).replace(/0+$/, "").replace(/\.$/, "");
+}
+
+function hasNativeStaticLayoutTimeline(options: NativeStaticLayoutExportOptions) {
+ return (options.timelineSegments?.length ?? 0) > 0;
+}
+
+function hasNativeStaticLayoutSourceCrop(options: NativeStaticLayoutExportOptions) {
+ return Boolean(
+ Number.isFinite(options.sourceCropWidth) &&
+ Number.isFinite(options.sourceCropHeight) &&
+ (options.sourceCropWidth ?? 0) >= 2 &&
+ (options.sourceCropHeight ?? 0) >= 2,
+ );
+}
+
+export function buildNativeStaticLayoutTimelineSegments(
+ segments: NativeVideoExportEditedTrackSegment[],
+): NativeStaticLayoutTimelineSegment[] {
+ const timelineSegments: NativeStaticLayoutTimelineSegment[] = [];
+ let outputCursorMs = 0;
+
+ for (const segment of segments) {
+ const sourceStartMs = Math.max(0, segment.startMs);
+ const sourceEndMs = Math.max(sourceStartMs, segment.endMs);
+ const speed = segment.speed;
+ if (
+ !Number.isFinite(sourceStartMs) ||
+ !Number.isFinite(sourceEndMs) ||
+ !Number.isFinite(speed) ||
+ sourceEndMs - sourceStartMs <= 0.5 ||
+ speed <= 0
+ ) {
+ return [];
+ }
+
+ const outputDurationMs = (sourceEndMs - sourceStartMs) / speed;
+ if (!Number.isFinite(outputDurationMs) || outputDurationMs <= 0.5) {
+ return [];
+ }
+
+ const outputStartMs = outputCursorMs;
+ const outputEndMs = outputStartMs + outputDurationMs;
+ timelineSegments.push({
+ sourceStartMs,
+ sourceEndMs,
+ outputStartMs,
+ outputEndMs,
+ speed,
+ });
+ outputCursorMs = outputEndMs;
+ }
+
+ return timelineSegments;
+}
+
+function validateNativeStaticLayoutTimelineSegments(
+ segments: NativeStaticLayoutTimelineSegment[] | undefined,
+ durationSec: number,
+) {
+ if (!segments?.length) {
+ return [];
+ }
+
+ const normalized: NativeStaticLayoutTimelineSegment[] = [];
+ let expectedOutputStartMs = 0;
+ for (const segment of segments) {
+ const sourceStartMs = Math.max(0, segment.sourceStartMs);
+ const sourceEndMs = Math.max(sourceStartMs, segment.sourceEndMs);
+ const outputStartMs = Math.max(0, segment.outputStartMs);
+ const outputEndMs = Math.max(outputStartMs, segment.outputEndMs);
+ const speed = segment.speed;
+ if (
+ !Number.isFinite(sourceStartMs) ||
+ !Number.isFinite(sourceEndMs) ||
+ !Number.isFinite(outputStartMs) ||
+ !Number.isFinite(outputEndMs) ||
+ !Number.isFinite(speed) ||
+ sourceEndMs - sourceStartMs <= 0.5 ||
+ outputEndMs - outputStartMs <= 0.5 ||
+ speed <= 0
+ ) {
+ throw new Error("Native timeline map contains an invalid segment");
+ }
+
+ if (Math.abs(outputStartMs - expectedOutputStartMs) > 2) {
+ throw new Error("Native timeline map output ranges must be contiguous");
+ }
+
+ const expectedOutputDurationMs = (sourceEndMs - sourceStartMs) / speed;
+ if (Math.abs(expectedOutputDurationMs - (outputEndMs - outputStartMs)) > 2) {
+ throw new Error("Native timeline map segment speed does not match its output duration");
+ }
+
+ normalized.push({
+ sourceStartMs,
+ sourceEndMs,
+ outputStartMs,
+ outputEndMs,
+ speed,
+ });
+ expectedOutputStartMs = outputEndMs;
+ }
+
+ if (Math.abs(expectedOutputStartMs / 1000 - durationSec) > 0.05) {
+ throw new Error("Native timeline map duration does not match the export duration");
+ }
+
+ return normalized;
+}
+
+async function prepareNativeStaticLayoutTimelineMap(
+ options: NativeStaticLayoutExportOptions,
+ outputPath: string,
+) {
+ const segments = validateNativeStaticLayoutTimelineSegments(
+ options.timelineSegments,
+ options.durationSec,
+ );
+ if (segments.length === 0) {
+ return null;
+ }
+
+ const lines = segments.map((segment) =>
+ [
+ segment.sourceStartMs,
+ segment.sourceEndMs,
+ segment.outputStartMs,
+ segment.outputEndMs,
+ segment.speed,
+ ]
+ .map(formatCliNumber)
+ .join(","),
+ );
+ await fs.writeFile(outputPath, `${lines.join("\n")}\n`, "utf8");
+ return outputPath;
+}
+
+async function pathExists(filePath: string) {
+ try {
+ await fs.access(filePath);
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+function isNvidiaVendorId(value: unknown) {
+ if (typeof value === "number") {
+ return value === NVIDIA_PCI_VENDOR_ID;
+ }
+ if (typeof value !== "string") {
+ return false;
+ }
+
+ const normalized = value.trim().toLowerCase();
+ if (normalized.length === 0) {
+ return false;
+ }
+ const parsed = normalized.startsWith("0x")
+ ? Number.parseInt(normalized.slice(2), 16)
+ : Number.parseInt(normalized, 10);
+
+ return Number.isFinite(parsed) && parsed === NVIDIA_PCI_VENDOR_ID;
+}
+
+function includesNvidiaGpuName(value: unknown) {
+ return typeof value === "string" && /\bnvidia\b/i.test(value);
+}
+
+function isNvidiaGpuDevice(device: unknown) {
+ if (!device || typeof device !== "object") {
+ return false;
+ }
+
+ const gpuDevice = device as ElectronGpuDeviceLike;
+ return (
+ isNvidiaVendorId(gpuDevice.vendorId) ||
+ includesNvidiaGpuName(gpuDevice.vendorString) ||
+ includesNvidiaGpuName(gpuDevice.deviceString)
+ );
+}
+
+export function hasNvidiaGpuDeviceInGpuInfo(gpuInfo: unknown) {
+ if (!gpuInfo || typeof gpuInfo !== "object") {
+ return false;
+ }
+
+ const devices = (gpuInfo as ElectronGpuInfoLike).gpuDevice;
+ return Array.isArray(devices) && devices.some(isNvidiaGpuDevice);
+}
+
+function getGpuVendorLabel(device: ElectronGpuDeviceLike): string | null {
+ if (device.vendorString?.trim()) {
+ return device.vendorString.trim();
+ }
+
+ const rawVendorId = device.vendorId;
+ const vendorId =
+ typeof rawVendorId === "number"
+ ? rawVendorId
+ : typeof rawVendorId === "string"
+ ? rawVendorId.toLowerCase().startsWith("0x")
+ ? Number.parseInt(rawVendorId.slice(2), 16)
+ : Number.parseInt(rawVendorId, 10)
+ : Number.NaN;
+ return (
+ {
+ [0x1002]: "AMD",
+ [0x106b]: "Apple",
+ [0x10de]: "NVIDIA",
+ [0x8086]: "Intel",
+ }[vendorId] ?? null
+ );
+}
+
+/** Reduces Electron's GPU response to support-safe hardware fields. */
+export function sanitizeExportGpuInfo(
+ gpuInfo: unknown,
+): Pick {
+ if (!gpuInfo || typeof gpuInfo !== "object") {
+ return { machineModel: null, gpus: [] };
+ }
+
+ const info = gpuInfo as ElectronGpuInfoLike;
+ const machineModel =
+ [info.machineModelName, info.machineModelVersion]
+ .filter((value): value is string => Boolean(value?.trim()))
+ .join(" ") || null;
+ const gpus = Array.isArray(info.gpuDevice)
+ ? info.gpuDevice.map((device) => {
+ const vendor = getGpuVendorLabel(device);
+ return {
+ name: device.deviceString?.trim() || vendor || "Unknown GPU",
+ vendor,
+ active: typeof device.active === "boolean" ? device.active : null,
+ };
+ })
+ : [];
+
+ return { machineModel, gpus };
+}
+
+/** Captures sanitized hardware and GPU acceleration details for export support reports. */
+export async function getExportHardwareInfo(): Promise {
+ let sanitizedGpuInfo: Pick = {
+ machineModel: null,
+ gpus: [],
+ };
+ try {
+ sanitizedGpuInfo = sanitizeExportGpuInfo(await app.getGPUInfo("complete"));
+ } catch {
+ // Hardware diagnostics are best effort and must not affect exporting.
+ }
+
+ let gpuFeatureStatus: Record = {};
+ try {
+ gpuFeatureStatus = app.getGPUFeatureStatus() as unknown as Record;
+ } catch {
+ // GPU feature status can be unavailable before Chromium finishes GPU initialization.
+ }
+
+ const cpuModel = os.cpus()[0]?.model?.replace(/\s+/g, " ").trim() || null;
+ return {
+ platform: process.platform,
+ release: os.release(),
+ arch: process.arch,
+ cpuModel,
+ logicalProcessors: os.cpus().length,
+ totalMemoryGb: Math.round((os.totalmem() / 1024 ** 3) * 10) / 10,
+ machineModel: sanitizedGpuInfo.machineModel,
+ gpus: sanitizedGpuInfo.gpus,
+ gpuFeatures: {
+ videoDecode: gpuFeatureStatus.video_decode ?? null,
+ videoEncode: gpuFeatureStatus.video_encode ?? null,
+ webgl: gpuFeatureStatus.webgl ?? null,
+ webgpu: gpuFeatureStatus.webgpu ?? null,
+ },
+ };
+}
+
+async function hasNvidiaGpuForCudaExportCandidate() {
+ const hasNvidiaGpu = await probeNvidiaGpuForCudaExportCandidate();
+ return hasNvidiaGpu ?? true;
+}
+
+async function probeNvidiaGpuForCudaExportCandidate(): Promise {
+ const getGPUInfo = (
+ app as typeof app & {
+ getGPUInfo?: (infoType: "basic") => Promise;
+ }
+ ).getGPUInfo;
+ if (typeof getGPUInfo !== "function") {
+ return null;
+ }
+
+ try {
+ return hasNvidiaGpuDeviceInGpuInfo(await getGPUInfo.call(app, "basic"));
+ } catch (error) {
+ console.warn(
+ "[native-static-layout-export] Unable to inspect GPU info before NVIDIA CUDA export; letting the helper decide",
+ error,
+ );
+ return null;
+ }
+}
+
+function getNativeBinPlatformArch() {
+ return process.arch === "arm64" ? "win32-arm64" : "win32-x64";
+}
+
+async function resolveExperimentalWindowsGpuExporterPath() {
+ if (process.platform !== "win32") {
+ return null;
+ }
+
+ const executableNames = ["recordly-gpu-export.exe", "gpu-export-probe.exe"];
+ const candidates: string[] = [];
+ const configuredPath = process.env.RECORDLY_WINDOWS_GPU_EXPORT_EXE;
+ if (configuredPath) {
+ candidates.push(configuredPath);
+ }
+
+ const nativeBinDir = path.join("electron", "native", "bin", getNativeBinPlatformArch());
+ const resourcesPath = (process as NodeJS.Process & { resourcesPath?: string }).resourcesPath;
+ if (resourcesPath) {
+ for (const executableName of executableNames) {
+ candidates.push(
+ path.join(resourcesPath, "app.asar.unpacked", nativeBinDir, executableName),
+ path.join(resourcesPath, nativeBinDir, executableName),
+ );
+ }
+ }
+
+ for (const executableName of executableNames) {
+ candidates.push(
+ path.join(process.cwd(), nativeBinDir, executableName),
+ path.join(
+ app.getAppPath().replace(/app\.asar$/, "app.asar.unpacked"),
+ nativeBinDir,
+ executableName,
+ ),
+ path.join(process.cwd(), ".tmp", "gpu-export-probe-build", "Release", executableName),
+ path.join(
+ process.cwd(),
+ "electron",
+ "native",
+ "gpu-export-probe",
+ "build",
+ "Release",
+ executableName,
+ ),
+ );
+ }
+
+ for (const candidate of candidates) {
+ if (await pathExists(candidate)) {
+ return candidate;
+ }
+ }
+
+ return null;
+}
+
+export function getNvidiaCudaAudioExportSkipReason(
+ audioMode: NativeVideoExportAudioMode | undefined,
+ options: { allowValidatedFallbackCandidate?: boolean } = {},
+) {
+ const resolvedAudioMode = audioMode ?? "none";
+ if (resolvedAudioMode === "none") {
+ return null;
+ }
+ if (process.env[NVIDIA_CUDA_ALLOW_AUDIO_EXPORT_ENV] === "1") {
+ return null;
+ }
+ if (options.allowValidatedFallbackCandidate) {
+ return null;
+ }
+
+ return `audio-mode:${resolvedAudioMode}`;
+}
+
+function isExplicitNvidiaCudaExportEnabled() {
+ return process.env[NVIDIA_CUDA_EXPORT_ENV] === "1";
+}
+
+function isExplicitNvidiaCudaExportDisabled() {
+ return process.env[NVIDIA_CUDA_EXPORT_ENV] === "0";
+}
+
+function isUserOptedInNvidiaCudaExport(options: NativeStaticLayoutExportOptions) {
+ return options.experimentalNvidiaCudaExport === true && !isExplicitNvidiaCudaExportDisabled();
+}
+
+function isValidatedNvidiaCudaFallbackCandidate(options: NativeStaticLayoutExportOptions) {
+ return isUserOptedInNvidiaCudaExport(options) && !isExplicitNvidiaCudaExportEnabled();
+}
+
+function isNvidiaCudaForceVideoOnlyEnabled() {
+ return process.env[NVIDIA_CUDA_FORCE_VIDEO_ONLY_ENV] === "1";
+}
+
+export function getNvidiaCudaAutoStallTimeoutMs(autoCandidateActive = false) {
+ if (!autoCandidateActive && !isExplicitNvidiaCudaExportEnabled()) {
+ return null;
+ }
+
+ const rawValue = process.env[NVIDIA_CUDA_AUTO_STALL_TIMEOUT_ENV]?.trim();
+ if (rawValue === "0" || rawValue?.toLowerCase() === "off") {
+ return null;
+ }
+
+ const parsed = Number(rawValue);
+ if (Number.isFinite(parsed) && parsed > 0) {
+ return Math.max(10_000, Math.round(parsed));
+ }
+
+ return DEFAULT_NVIDIA_CUDA_AUTO_STALL_TIMEOUT_MS;
+}
+
+export function getNativeGpuCompositorStallTimeoutMs() {
+ const rawValue = process.env[NATIVE_GPU_STALL_TIMEOUT_ENV]?.trim();
+ if (rawValue === "0" || rawValue?.toLowerCase() === "off") {
+ return null;
+ }
+
+ const parsed = Number(rawValue);
+ if (Number.isFinite(parsed) && parsed > 0) {
+ return Math.max(10_000, Math.round(parsed));
+ }
+
+ return DEFAULT_NATIVE_GPU_STALL_TIMEOUT_MS;
+}
+
+export async function getExperimentalNvidiaCudaExportSkipReason(
+ options: NativeStaticLayoutExportOptions,
+) {
+ if (process.platform !== "win32") {
+ return "not-windows";
+ }
+ if (isExplicitNvidiaCudaExportDisabled()) {
+ return "env-disabled";
+ }
+ const explicitCuda = isExplicitNvidiaCudaExportEnabled();
+ const userOptIn = isUserOptedInNvidiaCudaExport(options);
+ if (!explicitCuda && !userOptIn) {
+ return "env-disabled";
+ }
+ if (!options.experimentalWindowsGpuCompositor) {
+ return "windows-gpu-compositor-disabled";
+ }
+
+ if (userOptIn) {
+ if (!(await resolveExperimentalNvidiaCudaExportScriptPath())) {
+ return "cuda-wrapper-unavailable";
+ }
+ if (!(await hasNvidiaGpuForCudaExportCandidate())) {
+ return "nvidia-gpu-unavailable";
+ }
+ }
+
+ return getNvidiaCudaAudioExportSkipReason(options.audioOptions?.audioMode, {
+ allowValidatedFallbackCandidate:
+ isValidatedNvidiaCudaFallbackCandidate(options) || isNvidiaCudaForceVideoOnlyEnabled(),
+ });
+}
+
+export async function getNativeExportCapabilities(): Promise {
+ if (process.platform !== "win32") {
+ return {
+ platform: process.platform,
+ nvidiaCuda: {
+ available: false,
+ skipReason: "not-windows",
+ hasNvidiaGpu: null,
+ hasWrapper: false,
+ explicitEnabled: isExplicitNvidiaCudaExportEnabled(),
+ explicitDisabled: isExplicitNvidiaCudaExportDisabled(),
+ userOptInRequired: true,
+ },
+ };
+ }
+
+ const explicitEnabled = isExplicitNvidiaCudaExportEnabled();
+ const explicitDisabled = isExplicitNvidiaCudaExportDisabled();
+ const wrapperPath = await resolveExperimentalNvidiaCudaExportScriptPath();
+ const hasNvidiaGpu = await probeNvidiaGpuForCudaExportCandidate();
+ const skipReason = explicitDisabled
+ ? "env-disabled"
+ : !wrapperPath
+ ? "cuda-wrapper-unavailable"
+ : hasNvidiaGpu === false
+ ? "nvidia-gpu-unavailable"
+ : hasNvidiaGpu === null
+ ? "nvidia-gpu-probe-unavailable"
+ : null;
+
+ return {
+ platform: process.platform,
+ nvidiaCuda: {
+ available: skipReason === null,
+ skipReason,
+ hasNvidiaGpu,
+ hasWrapper: Boolean(wrapperPath),
+ explicitEnabled,
+ explicitDisabled,
+ userOptInRequired: !explicitEnabled,
+ },
+ };
+}
+
+export async function resolveExperimentalNvidiaCudaExportScriptPath() {
+ if (process.platform !== "win32") {
+ return null;
+ }
+
+ const candidates: string[] = [];
+ const configuredPath = process.env.RECORDLY_NVIDIA_CUDA_EXPORT_SCRIPT;
+ if (configuredPath) {
+ candidates.push(configuredPath);
+ }
+ const resourcesPath = (process as NodeJS.Process & { resourcesPath?: string }).resourcesPath;
+ if (resourcesPath) {
+ candidates.push(
+ path.join(
+ resourcesPath,
+ "app.asar.unpacked",
+ "electron",
+ "native",
+ "nvidia-cuda-compositor",
+ "run-mp4-pipeline.mjs",
+ ),
+ );
+ }
+ candidates.push(
+ path.join(
+ process.cwd(),
+ "electron",
+ "native",
+ "nvidia-cuda-compositor",
+ "run-mp4-pipeline.mjs",
+ ),
+ path.join(
+ app.getAppPath().replace(/app\.asar$/, "app.asar.unpacked"),
+ "electron",
+ "native",
+ "nvidia-cuda-compositor",
+ "run-mp4-pipeline.mjs",
+ ),
+ path.join(
+ app.getAppPath(),
+ "electron",
+ "native",
+ "nvidia-cuda-compositor",
+ "run-mp4-pipeline.mjs",
+ ),
+ path.join(process.cwd(), ".tmp", "nvdec-nvenc-probe", "run-mp4-pipeline.mjs"),
+ path.join(app.getAppPath(), ".tmp", "nvdec-nvenc-probe", "run-mp4-pipeline.mjs"),
+ );
+
+ for (const candidate of candidates) {
+ if (await pathExists(candidate)) {
+ return candidate;
+ }
+ }
+
+ return null;
+}
+
+function resolveExperimentalNvidiaCudaNodeCommand() {
+ const configuredNodePath = process.env.RECORDLY_NVIDIA_CUDA_NODE_EXE;
+ if (configuredNodePath) {
+ return {
+ command: configuredNodePath,
+ env: {},
+ };
+ }
+
+ return {
+ command: process.execPath,
+ env: {
+ ELECTRON_RUN_AS_NODE: "1",
+ },
+ };
+}
+
+function convertHexColorToNv12(color: string) {
+ const hex = color.trim().match(/^#?([0-9a-f]{6})$/i)?.[1] ?? "101010";
+ const r = Number.parseInt(hex.slice(0, 2), 16);
+ const g = Number.parseInt(hex.slice(2, 4), 16);
+ const b = Number.parseInt(hex.slice(4, 6), 16);
+ const clampByte = (value: number) => Math.max(0, Math.min(255, Math.round(value)));
+
+ return {
+ y: clampByte(((66 * r + 129 * g + 25 * b + 128) >> 8) + 16),
+ u: clampByte(((-38 * r - 74 * g + 112 * b + 128) >> 8) + 128),
+ v: clampByte(((112 * r - 94 * g - 18 * b + 128) >> 8) + 128),
+ };
+}
+
+function getNvidiaCudaBitrateMbps(options: NativeStaticLayoutExportOptions) {
+ return Math.max(1, Math.round(options.bitrate / 1_000_000));
+}
+
+export function buildExperimentalWindowsGpuStaticLayoutArgs(
+ options: NativeStaticLayoutExportOptions,
+ outputPath: string,
+) {
+ const shadowPixels = Math.round(clampUnit(options.shadowIntensity ?? 0) * 64);
+ const backgroundBlurPx = Math.max(0, options.backgroundBlurPx ?? 0);
+ const pixelCount = options.width * options.height;
+ const surfacePoolSize = pixelCount <= 1920 * 1080 ? 12 : 8;
+ const args = [
+ "--input",
+ options.inputPath,
+ "--output",
+ outputPath,
+ "--width",
+ String(options.width),
+ "--height",
+ String(options.height),
+ "--fps",
+ String(options.frameRate),
+ "--seconds",
+ formatCliNumber(options.durationSec),
+ "--bitrate",
+ String(options.bitrate),
+ "--shader-composite",
+ "--radius",
+ formatCliNumber(Math.max(0, options.borderRadius ?? 0)),
+ "--shadow",
+ formatCliNumber(shadowPixels),
+ "--content-left",
+ String(Math.round(options.offsetX)),
+ "--content-top",
+ String(Math.round(options.offsetY)),
+ "--content-width",
+ String(Math.round(options.contentWidth)),
+ "--content-height",
+ String(Math.round(options.contentHeight)),
+ "--background-color",
+ options.backgroundColor,
+ "--surface-pool-size",
+ String(surfacePoolSize),
+ ];
+
+ if (options.backgroundImagePath) {
+ args.push("--background-image", options.backgroundImagePath);
+ }
+ if (
+ Number.isFinite(options.sourceCropX) &&
+ Number.isFinite(options.sourceCropY) &&
+ Number.isFinite(options.sourceCropWidth) &&
+ Number.isFinite(options.sourceCropHeight) &&
+ (options.sourceCropWidth ?? 0) >= 2 &&
+ (options.sourceCropHeight ?? 0) >= 2
+ ) {
+ args.push(
+ "--source-crop-x",
+ String(Math.round(options.sourceCropX ?? 0)),
+ "--source-crop-y",
+ String(Math.round(options.sourceCropY ?? 0)),
+ "--source-crop-width",
+ String(Math.round(options.sourceCropWidth ?? 0)),
+ "--source-crop-height",
+ String(Math.round(options.sourceCropHeight ?? 0)),
+ );
+ }
+ if (backgroundBlurPx > 0) {
+ args.push("--background-blur", formatCliNumber(backgroundBlurPx));
+ }
+ if (options.webcamInputPath) {
+ const webcamShadowPixels = Math.round(clampUnit(options.webcamShadowIntensity ?? 0) * 64);
+ args.push(
+ "--webcam-input",
+ options.webcamInputPath,
+ "--webcam-left",
+ String(Math.round(options.webcamLeft ?? 0)),
+ "--webcam-top",
+ String(Math.round(options.webcamTop ?? 0)),
+ "--webcam-size",
+ String(Math.round(options.webcamSize ?? 0)),
+ "--webcam-radius",
+ formatCliNumber(Math.max(0, options.webcamRadius ?? 0)),
+ "--webcam-shadow",
+ formatCliNumber(webcamShadowPixels),
+ "--webcam-time-offset-ms",
+ formatCliNumber(options.webcamTimeOffsetMs ?? 0),
+ );
+ if (options.webcamMirror !== false) {
+ args.push("--webcam-mirror");
+ }
+ }
+ if (options.cursorTelemetryPath) {
+ args.push(
+ "--cursor-telemetry",
+ options.cursorTelemetryPath,
+ "--cursor-size",
+ formatCliNumber(Math.max(1, options.cursorSize ?? 84)),
+ );
+ if (options.cursorAtlasPath && options.cursorAtlasMetadataPath) {
+ args.push(
+ "--cursor-atlas",
+ options.cursorAtlasPath,
+ "--cursor-atlas-metadata",
+ options.cursorAtlasMetadataPath,
+ );
+ }
+ }
+ if (options.zoomTelemetryPath) {
+ args.push("--zoom-telemetry", options.zoomTelemetryPath);
+ }
+ if (options.timelineMapPath) {
+ args.push("--timeline-map", options.timelineMapPath);
+ }
+
+ return args;
+}
+
+async function prepareWindowsGpuCursorTelemetry(
+ options: NativeStaticLayoutExportOptions,
+ outputPath: string,
+) {
+ const telemetry = options.cursorTelemetry;
+ if (!telemetry || telemetry.length === 0) {
+ return null;
+ }
+
+ const lines = telemetry
+ .filter((sample) => {
+ return (
+ Number.isFinite(sample.timeMs) &&
+ Number.isFinite(sample.cx) &&
+ Number.isFinite(sample.cy)
+ );
+ })
+ .map((sample) => {
+ const timeMs = Math.max(0, sample.timeMs);
+ const cx = Math.min(1, Math.max(0, sample.cx));
+ const cy = Math.min(1, Math.max(0, sample.cy));
+ const cursorTypeIndex = Math.max(
+ 0,
+ Math.min(8, Math.round(sample.cursorTypeIndex ?? 0)),
+ );
+ const bounceScale = Math.min(2, Math.max(0.1, sample.bounceScale ?? 1));
+ const visible = sample.visible !== false ? 1 : 0;
+ return [
+ formatCliNumber(timeMs),
+ formatCliNumber(cx),
+ formatCliNumber(cy),
+ String(cursorTypeIndex),
+ formatCliNumber(bounceScale),
+ String(visible),
+ ].join(",");
+ });
+
+ if (lines.length === 0) {
+ return null;
+ }
+
+ await fs.writeFile(outputPath, lines.join("\n"), "utf8");
+ return outputPath;
+}
+
+async function prepareWindowsGpuCursorAtlas(
+ options: NativeStaticLayoutExportOptions,
+ atlasPath: string,
+ metadataPath: string,
+) {
+ const dataUrl = options.cursorAtlasPngDataUrl;
+ const entries = options.cursorAtlasEntries;
+ if (!dataUrl || !entries?.length) {
+ return null;
+ }
+
+ const match = /^data:image\/png;base64,([A-Za-z0-9+/=]+)$/.exec(dataUrl);
+ if (!match) {
+ return null;
+ }
+
+ const metadataLines = entries
+ .filter((entry) => {
+ return (
+ Number.isInteger(entry.index) &&
+ Number.isFinite(entry.x) &&
+ Number.isFinite(entry.y) &&
+ Number.isFinite(entry.width) &&
+ Number.isFinite(entry.height) &&
+ Number.isFinite(entry.anchorX) &&
+ Number.isFinite(entry.anchorY) &&
+ Number.isFinite(entry.aspectRatio) &&
+ entry.width > 0 &&
+ entry.height > 0
+ );
+ })
+ .map((entry) =>
+ [
+ String(Math.max(0, Math.min(8, entry.index))),
+ formatCliNumber(Math.max(0, entry.x)),
+ formatCliNumber(Math.max(0, entry.y)),
+ formatCliNumber(Math.max(1, entry.width)),
+ formatCliNumber(Math.max(1, entry.height)),
+ formatCliNumber(Math.min(1, Math.max(0, entry.anchorX))),
+ formatCliNumber(Math.min(1, Math.max(0, entry.anchorY))),
+ formatCliNumber(Math.max(0.01, entry.aspectRatio)),
+ ].join(","),
+ );
+ if (metadataLines.length === 0) {
+ return null;
+ }
+
+ await fs.writeFile(atlasPath, Buffer.from(match[1], "base64"));
+ await fs.writeFile(metadataPath, metadataLines.join("\n"), "utf8");
+ return { atlasPath, metadataPath };
+}
+
+async function prepareNvidiaCudaCursorTelemetry(
+ options: NativeStaticLayoutExportOptions,
+ outputPath: string,
+) {
+ const telemetry = options.cursorTelemetry;
+ if (!telemetry || telemetry.length === 0) {
+ return null;
+ }
+
+ const samples = telemetry
+ .filter((sample) => {
+ return (
+ Number.isFinite(sample.timeMs) &&
+ Number.isFinite(sample.cx) &&
+ Number.isFinite(sample.cy)
+ );
+ })
+ .map((sample) => ({
+ timeMs: Math.max(0, sample.timeMs),
+ cx: Math.min(1, Math.max(0, sample.cx)),
+ cy: Math.min(1, Math.max(0, sample.cy)),
+ cursorTypeIndex: Math.max(0, Math.min(8, Math.round(sample.cursorTypeIndex ?? 0))),
+ bounceScale: Math.min(2, Math.max(0.1, sample.bounceScale ?? 1)),
+ visible: sample.visible !== false,
+ }));
+ if (samples.length === 0) {
+ return null;
+ }
+
+ await fs.writeFile(outputPath, JSON.stringify({ samples }), "utf8");
+ return outputPath;
+}
+
+async function prepareNvidiaCudaCursorAtlas(
+ options: NativeStaticLayoutExportOptions,
+ atlasPath: string,
+ metadataPath: string,
+) {
+ const dataUrl = options.cursorAtlasPngDataUrl;
+ const entries = options.cursorAtlasEntries;
+ if (!dataUrl || !entries?.length) {
+ return null;
+ }
+
+ const match = /^data:image\/png;base64,([A-Za-z0-9+/=]+)$/.exec(dataUrl);
+ if (!match) {
+ return null;
+ }
+
+ const metadataLines = entries
+ .filter((entry) => {
+ return (
+ Number.isInteger(entry.index) &&
+ Number.isFinite(entry.x) &&
+ Number.isFinite(entry.y) &&
+ Number.isFinite(entry.width) &&
+ Number.isFinite(entry.height) &&
+ Number.isFinite(entry.anchorX) &&
+ Number.isFinite(entry.anchorY) &&
+ Number.isFinite(entry.aspectRatio) &&
+ entry.width > 0 &&
+ entry.height > 0
+ );
+ })
+ .map((entry) =>
+ [
+ String(Math.max(0, Math.min(8, entry.index))),
+ formatCliNumber(Math.max(0, entry.x)),
+ formatCliNumber(Math.max(0, entry.y)),
+ formatCliNumber(Math.max(1, entry.width)),
+ formatCliNumber(Math.max(1, entry.height)),
+ formatCliNumber(Math.min(1, Math.max(0, entry.anchorX))),
+ formatCliNumber(Math.min(1, Math.max(0, entry.anchorY))),
+ formatCliNumber(Math.max(0.01, entry.aspectRatio)),
+ ].join("\t"),
+ );
+ if (metadataLines.length === 0) {
+ return null;
+ }
+
+ await fs.writeFile(atlasPath, Buffer.from(match[1], "base64"));
+ await fs.writeFile(metadataPath, `${metadataLines.join("\n")}\n`, "utf8");
+ return { atlasPath, metadataPath };
+}
+
+async function prepareWindowsGpuZoomTelemetry(
+ options: NativeStaticLayoutExportOptions,
+ outputPath: string,
+) {
+ const telemetry = options.zoomTelemetry;
+ if (!telemetry || telemetry.length === 0) {
+ return null;
+ }
+
+ const lines = telemetry
+ .filter((sample) => {
+ return (
+ Number.isFinite(sample.timeMs) &&
+ Number.isFinite(sample.scale) &&
+ Number.isFinite(sample.x) &&
+ Number.isFinite(sample.y)
+ );
+ })
+ .map((sample) => {
+ const timeMs = Math.max(0, sample.timeMs);
+ const scale = Math.max(0.01, sample.scale);
+ return [
+ formatCliNumber(timeMs),
+ formatCliNumber(scale),
+ formatCliNumber(sample.x),
+ formatCliNumber(sample.y),
+ ].join(",");
+ });
+
+ if (lines.length === 0) {
+ return null;
+ }
+
+ await fs.writeFile(outputPath, lines.join("\n"), "utf8");
+ return outputPath;
+}
+
+function shouldCreateWindowsGpuWebcamProxy(filePath: string) {
+ const extension = path.extname(filePath).toLowerCase();
+ return extension !== ".mp4" && extension !== ".m4v" && extension !== ".mov";
+}
+
+function buildWindowsGpuWebcamProxyArgs(
+ options: NativeStaticLayoutExportOptions,
+ outputPath: string,
+) {
+ if (!options.webcamInputPath) {
+ throw new Error("Windows GPU webcam proxy requires an input path");
+ }
+
+ const frameRate = Math.max(1, Math.round(options.frameRate));
+ const proxyBitrate = Math.max(2_000_000, Math.min(6_000_000, Math.round(options.bitrate / 3)));
+ return [
+ "-y",
+ "-hide_banner",
+ "-loglevel",
+ "error",
+ "-i",
+ options.webcamInputPath,
+ "-map",
+ "0:v:0",
+ "-an",
+ "-t",
+ formatCliNumber(options.durationSec),
+ "-vf",
+ `scale=trunc(iw/2)*2:trunc(ih/2)*2,setsar=1,fps=${frameRate}`,
+ "-c:v",
+ "libx264",
+ "-preset",
+ "ultrafast",
+ "-tune",
+ "zerolatency",
+ "-b:v",
+ String(proxyBitrate),
+ "-maxrate",
+ String(Math.round(proxyBitrate * 1.2)),
+ "-bufsize",
+ String(proxyBitrate * 2),
+ "-pix_fmt",
+ "yuv420p",
+ "-movflags",
+ "+faststart",
+ outputPath,
+ ];
+}
+
+async function prepareWindowsGpuWebcamInput(
+ ffmpegPath: string,
+ options: NativeStaticLayoutExportOptions,
+ outputPath: string,
+ session: NativeStaticLayoutExportSession,
+) {
+ if (!options.webcamInputPath || !shouldCreateWindowsGpuWebcamProxy(options.webcamInputPath)) {
+ return { inputPath: options.webcamInputPath ?? null, elapsedMs: 0 };
+ }
+
+ const result = await runFfmpegWithMetrics(
+ ffmpegPath,
+ buildWindowsGpuWebcamProxyArgs(options, outputPath),
+ Math.max(5 * 60 * 1000, options.durationSec * 1000),
+ session,
+ );
+ if (!result.success) {
+ throw new Error(getFfmpegFailureMessage(result));
+ }
+
+ return { inputPath: outputPath, elapsedMs: result.elapsedMs };
+}
+
+async function prepareNativeStaticLayoutSourceInput(
+ ffmpegPath: string,
+ options: NativeStaticLayoutExportOptions,
+ outputPath: string,
+ session: NativeStaticLayoutExportSession,
+) {
+ const metadata = await probeNativeVideoMetadata(ffmpegPath, options.inputPath);
+ if (!shouldCreateNativeStaticLayoutSourceProxy(metadata, options.inputPath)) {
+ return {
+ inputPath: options.inputPath,
+ elapsedMs: 0,
+ sourceCodec: metadata.codec,
+ proxyCreated: false,
+ };
+ }
+
+ const sourceDurationSec = Math.max(
+ 0.001,
+ metadata.streamDuration ?? metadata.duration ?? options.durationSec,
+ );
+ const result = await runFfmpegWithMetrics(
+ ffmpegPath,
+ buildNativeStaticLayoutSourceProxyArgs(options, outputPath, metadata),
+ Math.max(5 * 60 * 1000, sourceDurationSec * 2000),
+ session,
+ );
+ if (!result.success) {
+ throw new Error(getFfmpegFailureMessage(result));
+ }
+
+ const proxyMetadata = await probeNativeVideoMetadata(ffmpegPath, outputPath);
+ const validationIssues = validateNativeStaticLayoutSourceProxyMetadata(proxyMetadata, {
+ sourceDurationSec,
+ });
+ if (validationIssues.length > 0) {
+ throw new Error(`Native source proxy is invalid: ${validationIssues.join("; ")}`);
+ }
+
+ return {
+ inputPath: outputPath,
+ elapsedMs: result.elapsedMs,
+ sourceCodec: metadata.codec,
+ proxyCodec: proxyMetadata.codec,
+ proxyCreated: true,
+ };
+}
+
+export function buildExperimentalNvidiaCudaStaticLayoutArgs(
+ options: NativeStaticLayoutExportOptions,
+ outputPath: string,
+ workDir: string,
+) {
+ const background = convertHexColorToNv12(options.backgroundColor);
+ const backgroundBlurPx = Math.max(0, options.backgroundBlurPx ?? 0);
+ const shadowIntensityPct = Math.round(clampUnit(options.shadowIntensity ?? 0) * 100);
+ const shadowOffsetY =
+ shadowIntensityPct > 0 ? Math.max(1, Math.round(options.height * 0.012)) : 0;
+ const args = [
+ "--input",
+ options.inputPath,
+ "--output",
+ outputPath,
+ "--work-dir",
+ workDir,
+ "--width",
+ String(options.width),
+ "--height",
+ String(options.height),
+ "--fps",
+ String(Math.max(1, Math.round(options.frameRate))),
+ "--bitrate-mbps",
+ String(getNvidiaCudaBitrateMbps(options)),
+ "--encoding-mode",
+ options.encodingMode,
+ "--duration-sec",
+ formatCliNumber(options.durationSec),
+ "--stream-sync",
+ "--prewarm-ms",
+ process.env.RECORDLY_NVIDIA_CUDA_PREWARM_MS || "500",
+ "--content-x",
+ String(Math.round(options.offsetX)),
+ "--content-y",
+ String(Math.round(options.offsetY)),
+ "--content-width",
+ String(Math.round(options.contentWidth)),
+ "--content-height",
+ String(Math.round(options.contentHeight)),
+ "--radius",
+ String(Math.round(Math.max(0, options.borderRadius ?? 0))),
+ "--background-y",
+ String(background.y),
+ "--background-u",
+ String(background.u),
+ "--background-v",
+ String(background.v),
+ ];
+ if (!canMuxNvidiaCudaSourceAudioInline(options)) {
+ args.push("--video-only");
+ }
+
+ if (options.backgroundImagePath) {
+ args.push("--background-image", options.backgroundImagePath);
+ }
+ if (
+ Number.isFinite(options.sourceCropX) &&
+ Number.isFinite(options.sourceCropY) &&
+ Number.isFinite(options.sourceCropWidth) &&
+ Number.isFinite(options.sourceCropHeight) &&
+ (options.sourceCropWidth ?? 0) >= 2 &&
+ (options.sourceCropHeight ?? 0) >= 2
+ ) {
+ args.push(
+ "--source-crop-x",
+ String(Math.round(options.sourceCropX ?? 0)),
+ "--source-crop-y",
+ String(Math.round(options.sourceCropY ?? 0)),
+ "--source-crop-width",
+ String(Math.round(options.sourceCropWidth ?? 0)),
+ "--source-crop-height",
+ String(Math.round(options.sourceCropHeight ?? 0)),
+ );
+ }
+ if (backgroundBlurPx > 0) {
+ args.push("--background-blur", formatCliNumber(backgroundBlurPx));
+ }
+ if (shadowOffsetY > 0 && shadowIntensityPct > 0) {
+ args.push(
+ "--shadow-offset-y",
+ String(shadowOffsetY),
+ "--shadow-intensity-pct",
+ String(shadowIntensityPct),
+ );
+ }
+ if (options.webcamInputPath && (options.webcamSize ?? 0) > 0) {
+ args.push(
+ "--webcam-input",
+ options.webcamInputPath,
+ "--webcam-x",
+ String(Math.round(options.webcamLeft ?? 0)),
+ "--webcam-y",
+ String(Math.round(options.webcamTop ?? 0)),
+ "--webcam-size",
+ String(Math.round(options.webcamSize ?? 0)),
+ "--webcam-radius",
+ String(Math.round(Math.max(0, options.webcamRadius ?? 0))),
+ "--webcam-time-offset-ms",
+ formatCliNumber(options.webcamTimeOffsetMs ?? 0),
+ "--webcam-stream",
+ );
+ if (options.webcamMirror !== false) {
+ args.push("--webcam-mirror");
+ }
+ }
+ if (options.cursorTelemetryPath) {
+ args.push(
+ "--cursor-json",
+ options.cursorTelemetryPath,
+ "--cursor-height",
+ String(Math.round(Math.max(1, options.cursorSize ?? 84))),
+ "--cursor-style",
+ "external",
+ );
+ if (options.cursorAtlasPath && options.cursorAtlasMetadataPath) {
+ args.push(
+ "--cursor-atlas-png",
+ options.cursorAtlasPath,
+ "--cursor-atlas-metadata",
+ options.cursorAtlasMetadataPath,
+ );
+ }
+ }
+ if (options.zoomTelemetryPath) {
+ args.push("--zoom-telemetry", options.zoomTelemetryPath);
+ }
+ if (options.timelineMapPath) {
+ args.push("--timeline-map", options.timelineMapPath);
+ }
+ if (process.env.RECORDLY_NVIDIA_CUDA_SAMPLE_GPU === "1") {
+ args.push("--sample-gpu");
+ }
+
+ return args;
+}
+
+function canMuxNvidiaCudaSourceAudioInline(options: NativeStaticLayoutExportOptions) {
+ if (options.nvidiaCudaForceVideoOnly) {
+ return false;
+ }
+
+ const audioOptions = options.audioOptions;
+ if (audioOptions?.audioMode !== "copy-source" || !audioOptions.audioSourcePath) {
+ return false;
+ }
+
+ return path.resolve(audioOptions.audioSourcePath) === path.resolve(options.inputPath);
+}
+
+async function runExperimentalNvidiaCudaStaticLayoutExport(
+ ffmpegPath: string,
+ options: NativeStaticLayoutExportOptions,
+ outputPath: string,
+ chunkDirectory: string,
+ session: NativeStaticLayoutExportSession,
+ onProgress?: (progress: NativeStaticLayoutExportProgress) => void,
+) {
+ const scriptPath = await resolveExperimentalNvidiaCudaExportScriptPath();
+ if (!scriptPath) {
+ throw new Error("Experimental NVIDIA CUDA export script is not available");
+ }
+
+ const nodeCommand = resolveExperimentalNvidiaCudaNodeCommand();
+ const workDir = path.join(chunkDirectory, "nvidia-cuda-work");
+ const args = [
+ scriptPath,
+ ...buildExperimentalNvidiaCudaStaticLayoutArgs(options, outputPath, workDir),
+ ];
+ const startedAt = getNowMs();
+ const startedAtIso = new Date().toISOString();
+ const timeoutMs = Math.max(20 * 60 * 1000, options.durationSec * 2000);
+ const stallTimeoutMs = getNvidiaCudaAutoStallTimeoutMs(
+ isValidatedNvidiaCudaFallbackCandidate(options),
+ );
+ const ffmpegDirectory = path.dirname(ffmpegPath);
+ const pathKey = process.platform === "win32" ? "Path" : "PATH";
+ const env = {
+ ...process.env,
+ ...nodeCommand.env,
+ RECORDLY_NVIDIA_CUDA_EXPORT_HIGH_PRIORITY:
+ process.env.RECORDLY_NVIDIA_CUDA_EXPORT_HIGH_PRIORITY ?? "1",
+ RECORDLY_FFMPEG_EXE: ffmpegPath,
+ RECORDLY_FFPROBE_EXE: process.env.RECORDLY_FFPROBE_EXE ?? getFfprobeBinaryPath(),
+ [pathKey]: `${ffmpegDirectory}${path.delimiter}${process.env[pathKey] ?? ""}`,
+ };
+ const powerGuard = startNativeStaticLayoutExportPowerGuard();
+
+ return await new Promise<{
+ elapsedMs: number;
+ stdout: string;
+ stderr: string;
+ summary: NvidiaCudaExportSummary;
+ }>((resolve, reject) => {
+ const child = spawn(nodeCommand.command, args, {
+ env,
+ stdio: ["ignore", "pipe", "pipe"],
+ windowsHide: true,
+ });
+ const childPriorityApplied = setNativeStaticLayoutExportProcessPriority(
+ child.pid,
+ "NVIDIA CUDA export wrapper",
+ );
+ console.info("[native-static-layout-export] NVIDIA CUDA runtime guard started", {
+ childPriorityApplied,
+ powerGuardStarted: powerGuard.started,
+ });
+ session.currentProcess = child;
+ if (session.terminating) {
+ child.kill("SIGKILL");
+ }
+
+ let stdout = "";
+ let stderr = "";
+ let stderrLineBuffer = "";
+ let lastProgressPercentage = 0;
+ let lastProgressForStallGuard: {
+ currentFrame: number;
+ percentage: number;
+ stage?: string;
+ } = { currentFrame: -1, percentage: -1 };
+ let stallTimedOut = false;
+ let settled = false;
+ const timeout = setTimeout(() => {
+ if (settled) return;
+ child.kill("SIGKILL");
+ }, timeoutMs);
+ let stallTimeout: ReturnType | null = null;
+ const clearStallTimeout = () => {
+ if (stallTimeout) {
+ clearTimeout(stallTimeout);
+ stallTimeout = null;
+ }
+ };
+ const armStallTimeout = () => {
+ if (!stallTimeoutMs) {
+ return;
+ }
+ clearStallTimeout();
+ stallTimeout = setTimeout(() => {
+ if (settled) return;
+ stallTimedOut = true;
+ child.kill("SIGKILL");
+ }, stallTimeoutMs);
+ };
+ armStallTimeout();
+
+ child.stdout.on("data", (chunk: Buffer) => {
+ stdout += chunk.toString();
+ });
+ child.stderr.on("data", (chunk: Buffer) => {
+ const text = chunk.toString();
+ stderr += text;
+ stderrLineBuffer += text;
+ const lines = stderrLineBuffer.split(/\r?\n/);
+ stderrLineBuffer = lines.pop() ?? "";
+ for (const line of lines) {
+ const progress = parseWindowsGpuExportProgressLine(line);
+ if (!progress) {
+ continue;
+ }
+ const elapsedMs = Math.max(0, getNowMs() - startedAt);
+ const averageFps =
+ typeof progress.averageFps === "number" &&
+ Number.isFinite(progress.averageFps) &&
+ progress.averageFps > 0
+ ? progress.averageFps
+ : elapsedMs > 0 && progress.currentFrame > 0
+ ? (progress.currentFrame * 1000) / elapsedMs
+ : undefined;
+ const mappedPercentage = mapNvidiaCudaWrapperProgressPercentage(progress);
+ lastProgressPercentage = Math.max(lastProgressPercentage, mappedPercentage);
+ const progressForStallGuard = {
+ currentFrame: progress.currentFrame,
+ percentage: mappedPercentage,
+ stage: progress.stage,
+ };
+ if (
+ hasNativeStaticLayoutProgressAdvanced(
+ progressForStallGuard,
+ lastProgressForStallGuard,
+ )
+ ) {
+ lastProgressForStallGuard = progressForStallGuard;
+ armStallTimeout();
+ }
+ onProgress?.({
+ ...progress,
+ percentage: lastProgressPercentage,
+ sessionId: options.sessionId,
+ backend: "nvidia-cuda-compositor",
+ elapsedMs,
+ averageFps,
+ });
+ }
+ });
+ child.once("error", (error) => {
+ if (settled) return;
+ settled = true;
+ if (session.currentProcess === child) {
+ session.currentProcess = null;
+ }
+ clearTimeout(timeout);
+ clearStallTimeout();
+ powerGuard.release();
+ reject(error);
+ });
+ child.once("close", async (code, signal) => {
+ if (settled) return;
+ settled = true;
+ if (session.currentProcess === child) {
+ session.currentProcess = null;
+ }
+ clearTimeout(timeout);
+ clearStallTimeout();
+ powerGuard.release();
+
+ if (session.terminating) {
+ reject(new Error("Native static layout export was cancelled"));
+ return;
+ }
+
+ const elapsedMs = getNowMs() - startedAt;
+ const summary = parseNvidiaCudaExportSummary(stdout);
+ if (summary) {
+ summary.appRuntimeGuard = {
+ powerGuardStarted: powerGuard.started,
+ wrapperProcessPriorityBoosted: childPriorityApplied,
+ nativeProcessPriorityBoosted: summary.nativeProcessPriorityBoosted,
+ };
+ }
+ await Promise.allSettled([
+ fs.writeFile(path.join(chunkDirectory, "nvidia-cuda-export.stdout.json"), stdout),
+ fs.writeFile(path.join(chunkDirectory, "nvidia-cuda-export.stderr.log"), stderr),
+ summary
+ ? fs.writeFile(
+ path.join(chunkDirectory, "nvidia-cuda-export.summary.json"),
+ `${JSON.stringify(summary, null, 2)}\n`,
+ )
+ : Promise.resolve(),
+ ]);
+ await persistNvidiaCudaExportDiagnostics({
+ args,
+ code,
+ elapsedMs,
+ outputPath,
+ sessionId: options.sessionId,
+ signal,
+ startedAtIso,
+ stderr,
+ stdout,
+ summary,
+ timedOut: stallTimedOut,
+ });
+ if (code !== 0 || !summary?.success) {
+ const suffix = signal ? ` (signal ${signal})` : "";
+ reject(
+ new Error(
+ (stallTimedOut && stallTimeoutMs
+ ? `Experimental NVIDIA CUDA exporter stalled for ${stallTimeoutMs}ms without progress`
+ : stderr.trim()) ||
+ stdout.trim() ||
+ `Experimental NVIDIA CUDA exporter exited with code ${code ?? "unknown"}${suffix}`,
+ ),
+ );
+ return;
+ }
+
+ resolve({
+ elapsedMs,
+ stdout,
+ stderr,
+ summary,
+ });
+ });
+ });
+}
+
+async function runExperimentalWindowsGpuStaticLayoutExport(
+ options: NativeStaticLayoutExportOptions,
+ outputPath: string,
+ session: NativeStaticLayoutExportSession,
+ onProgress?: (progress: NativeStaticLayoutExportProgress) => void,
+) {
+ const executablePath = await resolveExperimentalWindowsGpuExporterPath();
+ if (!executablePath) {
+ throw new Error("Experimental Windows GPU exporter is not built");
+ }
+
+ const args = buildExperimentalWindowsGpuStaticLayoutArgs(options, outputPath);
+ const startedAt = getNowMs();
+ const startedAtIso = new Date().toISOString();
+ const timeoutMs = Math.max(15 * 60 * 1000, options.durationSec * 1000);
+ const stallTimeoutMs = getNativeGpuCompositorStallTimeoutMs();
+
+ return await new Promise<{
+ elapsedMs: number;
+ stdout: string;
+ stderr: string;
+ summary: WindowsGpuExportSummary;
+ }>((resolve, reject) => {
+ const child = spawn(executablePath, args, {
+ stdio: ["ignore", "pipe", "pipe"],
+ });
+ session.currentProcess = child;
+ if (session.terminating) {
+ child.kill("SIGKILL");
+ }
+
+ let stdout = "";
+ let stderr = "";
+ let stderrLineBuffer = "";
+ let lastProgressForStallGuard: {
+ currentFrame: number;
+ percentage: number;
+ stage?: string;
+ } = { currentFrame: -1, percentage: -1 };
+ let stallTimedOut = false;
+ let settled = false;
+ const timeout = setTimeout(() => {
+ if (settled) return;
+ child.kill("SIGKILL");
+ }, timeoutMs);
+ let stallTimeout: ReturnType | null = null;
+ const clearStallTimeout = () => {
+ if (stallTimeout) {
+ clearTimeout(stallTimeout);
+ stallTimeout = null;
+ }
+ };
+ const armStallTimeout = () => {
+ if (!stallTimeoutMs) {
+ return;
+ }
+ clearStallTimeout();
+ stallTimeout = setTimeout(() => {
+ if (settled) return;
+ stallTimedOut = true;
+ child.kill("SIGKILL");
+ }, stallTimeoutMs);
+ };
+ armStallTimeout();
+
+ child.stdout.on("data", (chunk: Buffer) => {
+ stdout += chunk.toString();
+ });
+ child.stderr.on("data", (chunk: Buffer) => {
+ const text = chunk.toString();
+ stderr += text;
+ stderrLineBuffer += text;
+ const lines = stderrLineBuffer.split(/\r?\n/);
+ stderrLineBuffer = lines.pop() ?? "";
+ for (const line of lines) {
+ const progress = parseWindowsGpuExportProgressLine(line);
+ if (!progress) {
+ continue;
+ }
+ if (hasNativeStaticLayoutProgressAdvanced(progress, lastProgressForStallGuard)) {
+ lastProgressForStallGuard = {
+ currentFrame: progress.currentFrame,
+ percentage: progress.percentage,
+ stage: progress.stage,
+ };
+ armStallTimeout();
+ }
+ const elapsedMs = Math.max(0, getNowMs() - startedAt);
+ onProgress?.({
+ ...progress,
+ sessionId: options.sessionId,
+ backend: "windows-d3d11-compositor",
+ elapsedMs,
+ averageFps:
+ elapsedMs > 0 && progress.currentFrame > 0
+ ? (progress.currentFrame * 1000) / elapsedMs
+ : undefined,
+ });
+ }
+ });
+ child.once("error", (error) => {
+ if (settled) return;
+ settled = true;
+ if (session.currentProcess === child) {
+ session.currentProcess = null;
+ }
+ clearTimeout(timeout);
+ clearStallTimeout();
+ reject(error);
+ });
+ child.once("close", async (code, signal) => {
+ if (settled) return;
+ settled = true;
+ if (session.currentProcess === child) {
+ session.currentProcess = null;
+ }
+ clearTimeout(timeout);
+ clearStallTimeout();
+
+ if (session.terminating) {
+ reject(new Error("Native static layout export was cancelled"));
+ return;
+ }
+
+ const elapsedMs = getNowMs() - startedAt;
+ const summary = parseWindowsGpuExportSummary(stdout);
+ await persistWindowsGpuExportDiagnostics({
+ args,
+ code,
+ elapsedMs,
+ outputPath,
+ signal,
+ startedAtIso,
+ stderr,
+ stdout,
+ summary,
+ timedOut: stallTimedOut,
+ });
+
+ if (code !== 0 || !summary?.success) {
+ const suffix = signal ? ` (signal ${signal})` : "";
+ reject(
+ new Error(
+ (stallTimedOut && stallTimeoutMs
+ ? `Experimental Windows GPU exporter stalled for ${stallTimeoutMs}ms without progress`
+ : stderr.trim()) ||
+ stdout.trim() ||
+ `Experimental Windows GPU exporter exited with code ${code ?? "unknown"}${suffix}`,
+ ),
+ );
+ return;
+ }
+
+ resolve({
+ elapsedMs,
+ stdout,
+ stderr,
+ summary,
+ });
+ });
+ });
+}
+
+export async function exportNativeStaticLayoutVideo(
+ ffmpegPath: string,
+ options: NativeStaticLayoutExportOptions,
+ onProgress?: (progress: NativeStaticLayoutExportProgress) => void,
+) {
+ if (options.width % 2 !== 0 || options.height % 2 !== 0) {
+ throw new Error("Native static layout export requires even output dimensions");
+ }
+ if (!Number.isFinite(options.durationSec) || options.durationSec <= 0) {
+ throw new Error("Native static layout export requires a positive duration");
+ }
+ options = await normalizeNativeStaticLayoutBackground(options);
+ if (
+ options.webcamInputPath &&
+ !(options.experimentalWindowsGpuCompositor && process.platform === "win32")
+ ) {
+ throw new Error("Native webcam overlay requires the Windows GPU compositor");
+ }
+ if (
+ options.zoomTelemetry?.length &&
+ !(options.experimentalWindowsGpuCompositor && process.platform === "win32")
+ ) {
+ throw new Error("Native zoom telemetry requires the Windows GPU compositor");
+ }
+
+ const chunkDurationSec = Math.max(1, Math.min(300, options.chunkDurationSec ?? 120));
+ const chunks = buildNativeStaticLayoutChunks(options.durationSec, chunkDurationSec);
+ if (chunks.length === 0) {
+ throw new Error("Native static layout export produced no chunks");
+ }
+
+ const sessionId =
+ options.sessionId ??
+ `recordly-static-layout-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
+ const session: NativeStaticLayoutExportSession = {
+ terminating: false,
+ currentProcess: null,
+ };
+ const chunkDirectory = path.join(app.getPath("temp"), sessionId);
+ const concatListPath = path.join(chunkDirectory, "chunks.txt");
+ const videoOnlyPath = path.join(app.getPath("temp"), `${sessionId}.mp4`);
+ const ffprobePath = getFfprobeBinaryPath();
+ let outputPathToKeep: string | null = null;
+ let videoOutputValidated = false;
+ const metrics: NativeStaticLayoutExportMetrics = {
+ chunkCount: chunks.length,
+ chunkDurationSec,
+ chunkExecMs: 0,
+ fallbackChunkCount: 0,
+ chunks: [],
+ };
+ const validateRenderedVideoOutput = async () => {
+ await validateNativeVideoOutputFile(ffprobePath, videoOnlyPath, {
+ durationSec: options.durationSec,
+ targetFrames: Math.ceil(options.durationSec * options.frameRate),
+ });
+ videoOutputValidated = true;
+ };
+
+ try {
+ nativeStaticLayoutExportSessions.set(sessionId, session);
+ await fs.mkdir(chunkDirectory, { recursive: true });
+ const sourceInput = await prepareNativeStaticLayoutSourceInput(
+ ffmpegPath,
+ options,
+ path.join(chunkDirectory, "source-proxy.mp4"),
+ session,
+ );
+ if (sourceInput.elapsedMs > 0) {
+ metrics.staticAssetExecMs = (metrics.staticAssetExecMs ?? 0) + sourceInput.elapsedMs;
+ }
+ if (sourceInput.inputPath !== options.inputPath) {
+ console.info("[native-static-layout-export] Prepared H.264 source proxy", {
+ sourceCodec: sourceInput.sourceCodec,
+ proxyCodec: sourceInput.proxyCodec,
+ elapsedMs: sourceInput.elapsedMs,
+ });
+ options = {
+ ...options,
+ inputPath: sourceInput.inputPath,
+ };
+ }
+ const timelineMapPath = await prepareNativeStaticLayoutTimelineMap(
+ options,
+ path.join(chunkDirectory, "timeline-map.csv"),
+ );
+ if (timelineMapPath) {
+ if (!(options.experimentalWindowsGpuCompositor && process.platform === "win32")) {
+ throw new Error("Native timeline-map export requires the Windows GPU compositor");
+ }
+ options = {
+ ...options,
+ timelineMapPath,
+ };
+ }
+ const fullConfig: NativeStaticLayoutExportArgsConfig = {
+ inputPath: options.inputPath,
+ outputPath: videoOnlyPath,
+ width: options.width,
+ height: options.height,
+ frameRate: options.frameRate,
+ bitrate: options.bitrate,
+ encodingMode: options.encodingMode,
+ contentWidth: options.contentWidth,
+ contentHeight: options.contentHeight,
+ offsetX: options.offsetX,
+ offsetY: options.offsetY,
+ sourceCropX: options.sourceCropX,
+ sourceCropY: options.sourceCropY,
+ sourceCropWidth: options.sourceCropWidth,
+ sourceCropHeight: options.sourceCropHeight,
+ backgroundColor: options.backgroundColor,
+ backgroundImagePath: options.backgroundImagePath,
+ backgroundBlurPx: options.backgroundBlurPx,
+ borderRadius: options.borderRadius,
+ shadowIntensity: options.shadowIntensity,
+ durationSec: options.durationSec,
+ };
+ const usePrecompositedLayout = shouldUsePrecompositedStaticLayout(options);
+ let didRenderVideo = false;
+ let didMuxAudioInline = false;
+
+ if (options.experimentalWindowsGpuCompositor && process.platform === "win32") {
+ try {
+ if (session.terminating) {
+ throw new Error("Native static layout export was cancelled");
+ }
+ let experimentalGpuOptions = options;
+ if (options.webcamInputPath) {
+ const webcamProxyPath = path.join(chunkDirectory, "webcam-proxy.mp4");
+ const webcamInput = await prepareWindowsGpuWebcamInput(
+ ffmpegPath,
+ options,
+ webcamProxyPath,
+ session,
+ );
+ if (webcamInput.elapsedMs > 0) {
+ metrics.staticAssetExecMs =
+ (metrics.staticAssetExecMs ?? 0) + webcamInput.elapsedMs;
+ }
+ if (
+ webcamInput.inputPath &&
+ webcamInput.inputPath !== options.webcamInputPath
+ ) {
+ experimentalGpuOptions = {
+ ...options,
+ webcamInputPath: webcamInput.inputPath,
+ };
+ }
+ }
+ if (options.cursorTelemetry?.length) {
+ const cursorTelemetryPath = await prepareWindowsGpuCursorTelemetry(
+ options,
+ path.join(chunkDirectory, "cursor-telemetry.csv"),
+ );
+ if (cursorTelemetryPath) {
+ experimentalGpuOptions = {
+ ...experimentalGpuOptions,
+ cursorTelemetryPath,
+ };
+ }
+ const cursorAtlas = await prepareWindowsGpuCursorAtlas(
+ options,
+ path.join(chunkDirectory, "cursor-atlas.png"),
+ path.join(chunkDirectory, "cursor-atlas.csv"),
+ );
+ if (cursorAtlas) {
+ experimentalGpuOptions = {
+ ...experimentalGpuOptions,
+ cursorAtlasPath: cursorAtlas.atlasPath,
+ cursorAtlasMetadataPath: cursorAtlas.metadataPath,
+ };
+ }
+ }
+ if (options.zoomTelemetry?.length) {
+ const zoomTelemetryPath = await prepareWindowsGpuZoomTelemetry(
+ options,
+ path.join(chunkDirectory, "zoom-telemetry.csv"),
+ );
+ if (zoomTelemetryPath) {
+ experimentalGpuOptions = {
+ ...experimentalGpuOptions,
+ zoomTelemetryPath,
+ };
+ }
+ }
+ let experimentalNvidiaCudaOptions = experimentalGpuOptions;
+ const nvidiaCudaSkipReason =
+ await getExperimentalNvidiaCudaExportSkipReason(options);
+ let shouldTryNvidiaCuda = nvidiaCudaSkipReason === null;
+ const validatedCudaFallbackCandidate =
+ isValidatedNvidiaCudaFallbackCandidate(options);
+ if (
+ shouldTryNvidiaCuda &&
+ (validatedCudaFallbackCandidate || isNvidiaCudaForceVideoOnlyEnabled()) &&
+ (experimentalNvidiaCudaOptions.audioOptions?.audioMode ?? "none") !== "none"
+ ) {
+ experimentalNvidiaCudaOptions = {
+ ...experimentalNvidiaCudaOptions,
+ nvidiaCudaForceVideoOnly: true,
+ };
+ console.info(
+ "[native-static-layout-export] NVIDIA CUDA candidate will use shared audio mux validation",
+ {
+ audioMode:
+ experimentalNvidiaCudaOptions.audioOptions?.audioMode ?? "none",
+ forcedByEnv: isNvidiaCudaForceVideoOnlyEnabled(),
+ userOptIn: options.experimentalNvidiaCudaExport === true,
+ },
+ );
+ }
+ const shouldLogNvidiaCudaSkip =
+ isExplicitNvidiaCudaExportEnabled() ||
+ (options.experimentalNvidiaCudaExport === true &&
+ nvidiaCudaSkipReason !== "env-disabled");
+ if (
+ !shouldTryNvidiaCuda &&
+ shouldLogNvidiaCudaSkip &&
+ nvidiaCudaSkipReason !== "env-disabled"
+ ) {
+ console.warn(
+ "[native-static-layout-export] Skipping NVIDIA CUDA compositor; falling back to Windows GPU compositor",
+ {
+ reason: nvidiaCudaSkipReason,
+ audioMode: options.audioOptions?.audioMode ?? "none",
+ overrideEnv: NVIDIA_CUDA_ALLOW_AUDIO_EXPORT_ENV,
+ userOptIn: options.experimentalNvidiaCudaExport === true,
+ },
+ );
+ }
+ if (shouldTryNvidiaCuda && options.cursorTelemetry?.length) {
+ const cursorTelemetryPath = await prepareNvidiaCudaCursorTelemetry(
+ options,
+ path.join(chunkDirectory, "cursor-telemetry.json"),
+ );
+ const cursorAtlas = await prepareNvidiaCudaCursorAtlas(
+ options,
+ path.join(chunkDirectory, "cursor-atlas-nvidia.png"),
+ path.join(chunkDirectory, "cursor-atlas-nvidia.tsv"),
+ );
+ shouldTryNvidiaCuda = Boolean(cursorTelemetryPath && cursorAtlas);
+ if (cursorTelemetryPath && cursorAtlas) {
+ experimentalNvidiaCudaOptions = {
+ ...experimentalNvidiaCudaOptions,
+ cursorTelemetryPath,
+ cursorAtlasPath: cursorAtlas.atlasPath,
+ cursorAtlasMetadataPath: cursorAtlas.metadataPath,
+ };
+ }
+ }
+
+ if (shouldTryNvidiaCuda) {
+ try {
+ const shouldMuxAudioInline = canMuxNvidiaCudaSourceAudioInline(
+ experimentalNvidiaCudaOptions,
+ );
+ const cudaResult = await runExperimentalNvidiaCudaStaticLayoutExport(
+ ffmpegPath,
+ experimentalNvidiaCudaOptions,
+ videoOnlyPath,
+ chunkDirectory,
+ session,
+ onProgress,
+ );
+ const cudaValidationIssues = validateNvidiaCudaExportSummary(
+ cudaResult.summary,
+ {
+ durationSec: options.durationSec,
+ targetFrames: Math.ceil(options.durationSec * options.frameRate),
+ requiresTimelineSync: shouldMuxAudioInline,
+ },
+ );
+ if (cudaValidationIssues.length > 0) {
+ throw new Error(
+ `Experimental NVIDIA CUDA compositor produced an invalid output: ${cudaValidationIssues.join("; ")}`,
+ );
+ }
+ const outputStat = await fs.stat(videoOnlyPath);
+ if (outputStat.size <= 0) {
+ throw new Error(
+ "Experimental NVIDIA CUDA compositor produced an empty output file",
+ );
+ }
+ await validateRenderedVideoOutput();
+ console.info(
+ "[native-static-layout-export] NVIDIA CUDA compositor completed",
+ {
+ elapsedMs: cudaResult.elapsedMs,
+ fps: cudaResult.summary.fps,
+ targetFrames: cudaResult.summary.targetFrames,
+ durationSec: cudaResult.summary.durationSec,
+ nativeEncodeMs: cudaResult.summary.timingsMs?.nativeEncode,
+ muxMs: cudaResult.summary.timingsMs?.mux,
+ endToEndMs: cudaResult.summary.timingsMs?.endToEnd,
+ nativeFps:
+ cudaResult.summary.nativeSummary?.measuredFps ??
+ cudaResult.summary.nativeSummary?.fps,
+ mappedDisplayFrames:
+ cudaResult.summary.nativeSummary?.mappedDisplayFrames,
+ selectedDisplayFrames:
+ cudaResult.summary.nativeSummary?.selectedDisplayFrames,
+ skippedDisplayFrames:
+ cudaResult.summary.nativeSummary?.skippedDisplayFrames,
+ roiCompositeFrames:
+ cudaResult.summary.nativeSummary?.roiCompositeFrames,
+ monolithicCompositeFrames:
+ cudaResult.summary.nativeSummary?.monolithicCompositeFrames,
+ copyCompositeFrames:
+ cudaResult.summary.nativeSummary?.copyCompositeFrames,
+ webcamOverlay: cudaResult.summary.nativeSummary?.webcamOverlay,
+ cursorAtlas: cudaResult.summary.nativeSummary?.cursorAtlas,
+ zoomOverlay: cudaResult.summary.nativeSummary?.zoomOverlay,
+ zoomSamples: cudaResult.summary.nativeSummary?.zoomSamples,
+ },
+ );
+ metrics.chunkCount = 1;
+ metrics.chunkDurationSec = options.durationSec;
+ metrics.chunkExecMs += cudaResult.elapsedMs;
+ metrics.chunks.push({
+ index: 0,
+ startSec: 0,
+ durationSec: options.durationSec,
+ backend: "nvidia-cuda-compositor",
+ elapsedMs: cudaResult.elapsedMs,
+ outputBytes: outputStat.size,
+ nvidiaCudaSummary: cudaResult.summary,
+ });
+ didRenderVideo = true;
+ didMuxAudioInline = shouldMuxAudioInline;
+ } catch (error) {
+ if (session.terminating) {
+ throw error;
+ }
+ metrics.fallbackChunkCount++;
+ console.warn(
+ "[native-static-layout-export] Experimental NVIDIA CUDA compositor failed or produced invalid output; falling back to Windows GPU compositor:",
+ error,
+ );
+ await removeTemporaryExportFile(videoOnlyPath);
+ }
+ }
+
+ if (!didRenderVideo) {
+ const gpuResult = await runExperimentalWindowsGpuStaticLayoutExport(
+ experimentalGpuOptions,
+ videoOnlyPath,
+ session,
+ onProgress,
+ );
+ const gpuValidationIssues = validateWindowsGpuExportSummary(gpuResult.summary, {
+ durationSec: options.durationSec,
+ targetFrames: Math.ceil(options.durationSec * options.frameRate),
+ });
+ if (gpuValidationIssues.length > 0) {
+ throw new Error(
+ `Experimental Windows GPU compositor produced an invalid output: ${gpuValidationIssues.join("; ")}`,
+ );
+ }
+ await validateRenderedVideoOutput();
+ console.info("[native-static-layout-export] Windows GPU compositor completed", {
+ elapsedMs: gpuResult.elapsedMs,
+ width: gpuResult.summary.width,
+ height: gpuResult.summary.height,
+ fps: gpuResult.summary.fps,
+ frames: gpuResult.summary.frames,
+ realtimeMultiplier: gpuResult.summary.realtimeMultiplier,
+ surfacePoolSize: gpuResult.summary.surfacePoolSize,
+ gpuDecodeSurface: gpuResult.summary.gpuDecodeSurface,
+ adapterIndex: gpuResult.summary.adapterIndex,
+ encoderBackend: gpuResult.summary.encoderBackend,
+ encoderTuningApplied: gpuResult.summary.encoderTuningApplied,
+ readMs: gpuResult.summary.readMs,
+ videoProcessMs: gpuResult.summary.videoProcessMs,
+ writeSampleMs: gpuResult.summary.writeSampleMs,
+ finalizeMs: gpuResult.summary.finalizeMs,
+ webcamOverlay: gpuResult.summary.webcamOverlay,
+ cursorOverlay: gpuResult.summary.cursorOverlay,
+ cursorAtlas: gpuResult.summary.cursorAtlas,
+ zoomOverlay: gpuResult.summary.zoomOverlay,
+ });
+ const outputStat = await fs.stat(videoOnlyPath);
+ metrics.chunkCount = 1;
+ metrics.chunkDurationSec = options.durationSec;
+ metrics.chunkExecMs += gpuResult.elapsedMs;
+ metrics.chunks.push({
+ index: 0,
+ startSec: 0,
+ durationSec: options.durationSec,
+ backend: "windows-d3d11-compositor",
+ elapsedMs: gpuResult.elapsedMs,
+ outputBytes: outputStat.size,
+ windowsGpuSummary: gpuResult.summary,
+ });
+ didRenderVideo = true;
+ }
+ } catch (error) {
+ if (session.terminating) {
+ throw error;
+ }
+ if (hasNativeStaticLayoutTimeline(options)) {
+ throw error;
+ }
+ metrics.fallbackChunkCount++;
+ console.warn(
+ "[native-static-layout-export] Experimental Windows GPU compositor unavailable; falling back to FFmpeg static layout:",
+ error,
+ );
+ await removeTemporaryExportFile(videoOnlyPath);
+ if (options.webcamInputPath || options.zoomTelemetry?.length) {
+ throw error;
+ }
+ }
+ }
+
+ if (!didRenderVideo && hasNativeStaticLayoutTimeline(options)) {
+ throw new Error("Native timeline-map export requires a GPU compositor backend");
+ }
+ if (!didRenderVideo && hasNativeStaticLayoutSourceCrop(options)) {
+ throw new Error("Native crop export requires a GPU compositor backend");
+ }
+
+ if (!didRenderVideo && usePrecompositedLayout) {
+ const maskPath = path.join(chunkDirectory, "layout-mask.pgm");
+ const staticBackgroundPath = path.join(chunkDirectory, "layout-background.png");
+ await fs.writeFile(
+ maskPath,
+ createNativeSquircleMaskPgmBuffer(
+ options.contentWidth,
+ options.contentHeight,
+ options.borderRadius ?? 0,
+ ),
+ );
+
+ const backgroundResult = await runFfmpegWithMetrics(
+ ffmpegPath,
+ buildNativeStaticBackgroundRenderArgs({
+ ...fullConfig,
+ inputPath: options.inputPath,
+ outputPath: staticBackgroundPath,
+ maskPath,
+ }),
+ 2 * 60 * 1000,
+ session,
+ );
+ metrics.staticAssetExecMs = backgroundResult.elapsedMs;
+ if (!backgroundResult.success) {
+ throw new Error(getFfmpegFailureMessage(backgroundResult));
+ }
+
+ const fullResult = await runFfmpegWithMetrics(
+ ffmpegPath,
+ buildNativePrecompositedStaticLayoutArgs({
+ ...fullConfig,
+ staticBackgroundPath,
+ maskPath,
+ }),
+ 15 * 60 * 1000,
+ session,
+ );
+ metrics.chunkExecMs += fullResult.elapsedMs;
+ if (!fullResult.success) {
+ throw new Error(getFfmpegFailureMessage(fullResult));
+ }
+
+ const outputStat = await fs.stat(videoOnlyPath);
+ metrics.chunkCount = 1;
+ metrics.chunkDurationSec = options.durationSec;
+ metrics.chunks.push({
+ index: 0,
+ startSec: 0,
+ durationSec: options.durationSec,
+ backend: "cuda-static-composite",
+ elapsedMs: fullResult.elapsedMs,
+ outputBytes: outputStat.size,
+ });
+ } else if (!didRenderVideo) {
+ const primaryResult = await runFfmpegWithMetrics(
+ ffmpegPath,
+ buildNativeCudaOverlayStaticLayoutArgs(fullConfig),
+ 15 * 60 * 1000,
+ session,
+ );
+ let fullResult = primaryResult;
+ let fullBackend: NativeStaticLayoutBackend = "cuda-overlay";
+ let fallbackReason: string | undefined;
+ if (!primaryResult.success) {
+ fullBackend = "cuda-scale-cpu-pad";
+ fallbackReason = isNativeCudaOutOfMemory(primaryResult.stderr)
+ ? "cuda-oom"
+ : "cuda-overlay-failed";
+ metrics.fallbackChunkCount++;
+ fullResult = await runFfmpegWithMetrics(
+ ffmpegPath,
+ buildNativeCudaScaleCpuPadStaticLayoutArgs(fullConfig),
+ 15 * 60 * 1000,
+ session,
+ );
+ }
+ metrics.chunkExecMs += fullResult.elapsedMs;
+ if (fullResult !== primaryResult) {
+ metrics.chunkExecMs += primaryResult.elapsedMs;
+ }
+
+ if (fullResult.success) {
+ const outputStat = await fs.stat(videoOnlyPath);
+ metrics.chunkCount = 1;
+ metrics.chunkDurationSec = options.durationSec;
+ metrics.chunks.push({
+ index: 0,
+ startSec: 0,
+ durationSec: options.durationSec,
+ backend: fullBackend,
+ elapsedMs: fullResult.elapsedMs,
+ outputBytes: outputStat.size,
+ fallbackReason,
+ });
+ } else if (isNativeCudaOutOfMemory(fullResult.stderr)) {
+ const concatLines: string[] = [];
+
+ for (const chunk of chunks) {
+ if (session.terminating) {
+ throw new Error("Native static layout export was cancelled");
+ }
+
+ const outputPath = getStaticLayoutChunkOutputPath(chunkDirectory, chunk.index);
+ const baseConfig: NativeStaticLayoutExportArgsConfig = {
+ inputPath: options.inputPath,
+ outputPath,
+ width: options.width,
+ height: options.height,
+ frameRate: options.frameRate,
+ bitrate: options.bitrate,
+ encodingMode: options.encodingMode,
+ contentWidth: options.contentWidth,
+ contentHeight: options.contentHeight,
+ offsetX: options.offsetX,
+ offsetY: options.offsetY,
+ backgroundColor: options.backgroundColor,
+ startSec: chunk.startSec,
+ durationSec: chunk.durationSec,
+ };
+ const primary = await runFfmpegWithMetrics(
+ ffmpegPath,
+ buildNativeCudaOverlayStaticLayoutArgs(baseConfig),
+ 15 * 60 * 1000,
+ session,
+ );
+ let backend: NativeStaticLayoutBackend = "cuda-overlay";
+ let result = primary;
+ let fallbackReason: string | undefined;
+
+ if (!primary.success && isNativeCudaOutOfMemory(primary.stderr)) {
+ backend = "cuda-scale-cpu-pad";
+ fallbackReason = "cuda-oom";
+ metrics.fallbackChunkCount++;
+ result = await runFfmpegWithMetrics(
+ ffmpegPath,
+ buildNativeCudaScaleCpuPadStaticLayoutArgs(baseConfig),
+ 15 * 60 * 1000,
+ session,
+ );
+ }
+
+ metrics.chunkExecMs += result.elapsedMs;
+ if (!result.success) {
+ throw new Error(getFfmpegFailureMessage(result));
+ }
+
+ const outputStat = await fs.stat(outputPath);
+ metrics.chunks.push({
+ index: chunk.index,
+ startSec: chunk.startSec,
+ durationSec: chunk.durationSec,
+ backend,
+ elapsedMs: result.elapsedMs,
+ outputBytes: outputStat.size,
+ fallbackReason,
+ });
+ concatLines.push(toConcatFileLine(outputPath));
+ }
+
+ metrics.chunkCount = chunks.length;
+ await fs.writeFile(concatListPath, `${concatLines.join("\n")}\n`, "utf8");
+ if (session.terminating) {
+ throw new Error("Native static layout export was cancelled");
+ }
+ const concatStartedAt = getNowMs();
+ const concatResult = await runFfmpegWithMetrics(
+ ffmpegPath,
+ buildNativeConcatArgs({ listPath: concatListPath, outputPath: videoOnlyPath }),
+ 15 * 60 * 1000,
+ session,
+ );
+ metrics.concatExecMs = getNowMs() - concatStartedAt;
+ if (!concatResult.success) {
+ throw new Error(getFfmpegFailureMessage(concatResult));
+ }
+ } else {
+ throw new Error(getFfmpegFailureMessage(fullResult));
+ }
+ }
+
+ const videoOnlyStat = await fs.stat(videoOnlyPath);
+ metrics.videoOnlyBytes = videoOnlyStat.size;
+ if (!videoOutputValidated) {
+ await validateRenderedVideoOutput();
+ }
+ if (didMuxAudioInline) {
+ outputPathToKeep = videoOnlyPath;
+ return {
+ outputPath: videoOnlyPath,
+ metrics,
+ };
+ }
+ const audioMuxProgressStart = 97.25;
+ const audioMuxProgressEnd = 99;
+ const progressTotalFrames = Math.max(1, Math.ceil(options.durationSec * options.frameRate));
+ const finalized = await muxNativeVideoExportAudio(
+ videoOnlyPath,
+ options.audioOptions ?? {},
+ (progress) => {
+ const ratio = Math.max(0, Math.min(1, progress.ratio));
+ const percentage =
+ audioMuxProgressStart + (audioMuxProgressEnd - audioMuxProgressStart) * ratio;
+ const progressPayload: NativeStaticLayoutExportProgress = {
+ sessionId,
+ stage: "finalizing",
+ currentFrame: progressTotalFrames,
+ totalFrames: progressTotalFrames,
+ percentage,
+ };
+ const backend = metrics.chunks[0]?.backend;
+ if (backend) {
+ progressPayload.backend = backend;
+ }
+ onProgress?.(progressPayload);
+ },
+ session,
+ );
+ Object.assign(metrics, finalized.metrics);
+ outputPathToKeep = finalized.outputPath;
+ return {
+ outputPath: finalized.outputPath,
+ metrics,
+ };
+ } catch (error) {
+ await removeTemporaryExportFile(videoOnlyPath);
+ await removeTemporaryExportFile(videoOnlyPath.replace(/\.mp4$/, "-final.mp4"));
+ throw error;
+ } finally {
+ nativeStaticLayoutExportSessions.delete(sessionId);
+ await fs.rm(chunkDirectory, { force: true, recursive: true }).catch(() => undefined);
+ if (outputPathToKeep !== videoOnlyPath) {
+ await removeTemporaryExportFile(videoOnlyPath);
+ }
+ }
+}
+
+export async function enqueueNativeVideoExportFrameWrite(
+ session: NativeVideoExportSession,
+ frameData: Uint8Array | ArrayBuffer,
+) {
+ const writePromise = session.writeSequence.then(async () => {
+ if (session.terminating) {
+ throw new Error("Native video export session was cancelled");
+ }
+
+ await writeNativeVideoExportFrame(session, frameData);
+ });
+
+ session.writeSequence = writePromise.catch(() => undefined);
+ await writePromise;
+}
+
+export async function enqueueNativeVideoExportFrameWrites(
+ session: NativeVideoExportSession,
+ frameDataList: Array,
+) {
+ const writePromise = session.writeSequence.then(async () => {
+ if (session.terminating) {
+ throw new Error("Native video export session was cancelled");
+ }
+
+ for (const frameData of frameDataList) {
+ await writeNativeVideoExportFrame(session, frameData);
+ }
+ });
+
+ session.writeSequence = writePromise.catch(() => undefined);
+ await writePromise;
+}
+
+export async function getAvailableNativeVideoEncoders(ffmpegPath: string) {
+ const { stdout } = await execFileAsync(ffmpegPath, ["-hide_banner", "-encoders"], {
+ timeout: 15000,
+ maxBuffer: 20 * 1024 * 1024,
+ });
+
+ return parseAvailableFfmpegEncoders(stdout);
+}
+
+export async function probeNativeVideoEncoder(
+ ffmpegPath: string,
+ encoderName: string,
+ encodingMode: NativeExportEncodingMode,
+) {
+ const outputPath = path.join(
+ app.getPath("temp"),
+ `recordly-export-probe-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.mp4`,
+ );
+ const args = buildNativeVideoExportArgs(
+ encoderName,
+ {
+ width: 64,
+ height: 64,
+ frameRate: 1,
+ bitrate: 1_500_000,
+ encodingMode,
+ },
+ outputPath,
+ );
+
+ return new Promise((resolve) => {
+ const process = spawn(ffmpegPath, args, {
+ stdio: ["pipe", "ignore", "pipe"],
+ });
+ let stderrOutput = "";
+ const timeout = setTimeout(() => {
+ try {
+ process.kill("SIGKILL");
+ } catch {
+ // ignore
+ }
+ resolve(false);
+ }, 15000);
+
+ process.stderr.on("data", (chunk: Buffer) => {
+ stderrOutput += chunk.toString();
+ });
+
+ process.on("close", (code) => {
+ clearTimeout(timeout);
+ void removeTemporaryExportFile(outputPath);
+ if (code !== 0 && stderrOutput.trim().length > 0) {
+ console.warn(
+ `[native-export] Encoder probe failed for ${encoderName}:`,
+ stderrOutput.trim(),
+ );
+ }
+ resolve(code === 0);
+ });
+
+ process.stdin.end(Buffer.alloc(getNativeVideoInputByteSize(64, 64), 0));
+ });
+}
+
+export async function resolveNativeVideoEncoder(
+ ffmpegPath: string,
+ encodingMode: NativeExportEncodingMode,
+) {
+ if (
+ cachedNativeVideoEncoder?.ffmpegPath === ffmpegPath &&
+ cachedNativeVideoEncoder?.encodingMode === encodingMode
+ ) {
+ return cachedNativeVideoEncoder.encoderName;
+ }
+
+ const availableEncoders = await getAvailableNativeVideoEncoders(ffmpegPath);
+ const candidates = [
+ ...new Set([...getPreferredNativeVideoEncoders(process.platform), "libx264"]),
+ ];
+
+ for (const encoderName of candidates) {
+ if (!availableEncoders.has(encoderName)) {
+ continue;
+ }
+
+ if (await probeNativeVideoEncoder(ffmpegPath, encoderName, encodingMode)) {
+ setCachedNativeVideoEncoder({ ffmpegPath, encodingMode, encoderName });
+ return encoderName;
+ }
+ }
+
+ throw new Error("No usable FFmpeg encoder was available for native export");
+}
+
+export function canCopyAudioCodecIntoMp4(codec?: string | null) {
+ const normalized = (codec ?? "").trim().toLowerCase();
+ if (!normalized) {
+ return false;
+ }
+
+ return (
+ normalized.includes("aac") ||
+ normalized.includes("mp4a") ||
+ normalized.includes("mpeg-4 audio") ||
+ normalized.includes("mp3") ||
+ normalized.includes("alac")
+ );
+}
+
+export function buildNativeVideoAudioMuxArgs(
+ videoPath: string,
+ audioInputPath: string,
+ outputPath: string,
+ options: NativeVideoExportFinishOptions,
+ argsOptions: NativeVideoAudioMuxArgsOptions = {},
+) {
+ const audioMode = options.audioMode ?? "none";
+ const useEditedTrackFiltergraph =
+ audioMode === "edited-track" && options.editedTrackStrategy === "filtergraph-fast-path";
+ const args = ["-y", "-hide_banner", "-loglevel", "error"];
+ if (argsOptions.progressPipe) {
+ args.push(
+ "-stats_period",
+ "0.5",
+ "-progress",
+ `pipe:${argsOptions.progressPipe}`,
+ "-nostats",
+ );
+ }
+ args.push("-i", videoPath, "-i", audioInputPath);
+
+ if (audioMode === "trim-source") {
+ const filter = buildTrimmedSourceAudioFilter(options.trimSegments ?? []);
+ if (filter) {
+ args.push("-filter_complex", filter, "-map", "0:v:0", "-map", "[aout]");
+ } else {
+ args.push("-map", "0:v:0", "-map", "1:a:0");
+ }
+ } else if (useEditedTrackFiltergraph) {
+ const filter = buildEditedTrackSourceAudioFilter(
+ options.editedTrackSegments ?? [],
+ options.audioSourceSampleRate ?? 0,
+ );
+ if (!filter) {
+ throw new Error("Edited-track filtergraph inputs are incomplete for native export");
+ }
+ if (
+ typeof options.outputDurationSec === "number" &&
+ Number.isFinite(options.outputDurationSec) &&
+ options.outputDurationSec > 0
+ ) {
+ const duration = formatFfmpegSeconds(options.outputDurationSec * 1000);
+ args.push(
+ "-filter_complex",
+ `${filter};[aout]apad,atrim=duration=${duration},asetpts=PTS-STARTPTS[aout_sync]`,
+ "-map",
+ "0:v:0",
+ "-map",
+ "[aout_sync]",
+ );
+ } else {
+ args.push("-filter_complex", filter, "-map", "0:v:0", "-map", "[aout]");
+ }
+ } else {
+ args.push("-map", "0:v:0", "-map", "1:a:0");
+ }
+
+ args.push("-c:v", "copy");
+ if (audioMode === "copy-source" && canCopyAudioCodecIntoMp4(options.audioSourceCodec)) {
+ args.push("-c:a", "copy");
+ } else {
+ args.push("-c:a", "aac", "-b:a", "192k");
+ }
+ if (
+ typeof options.outputDurationSec === "number" &&
+ Number.isFinite(options.outputDurationSec) &&
+ options.outputDurationSec > 0
+ ) {
+ args.push("-t", formatFfmpegSeconds(options.outputDurationSec * 1000));
+ } else if (audioMode !== "copy-source") {
+ args.push("-shortest");
+ }
+ args.push("-movflags", "+faststart", outputPath);
+
+ return args;
+}
+
+export async function muxNativeVideoExportAudio(
+ videoPath: string,
+ options: NativeVideoExportFinishOptions,
+ onProgress?: (progress: NativeVideoAudioMuxProgress) => void,
+ session?: NativeStaticLayoutExportSession,
+) {
+ const audioMode = options.audioMode ?? "none";
+ if (audioMode === "none") {
+ return {
+ outputPath: videoPath,
+ metrics: {} as NativeVideoAudioMuxMetrics,
+ };
+ }
+
+ const ffmpegPath = getFfmpegBinaryPath();
+ const metrics: NativeVideoAudioMuxMetrics = {};
+ const tempArtifacts: string[] = [];
+ let audioInputPath = options.audioSourcePath ?? null;
+ const useEditedTrackFiltergraph =
+ audioMode === "edited-track" && options.editedTrackStrategy === "filtergraph-fast-path";
+
+ if (audioMode === "edited-track" && !useEditedTrackFiltergraph) {
+ if (!options.editedAudioData) {
+ throw new Error("Edited audio data is missing for native export");
+ }
+
+ const extension = getEditedAudioExtension(options.editedAudioMimeType);
+ audioInputPath = path.join(
+ app.getPath("temp"),
+ `recordly-export-audio-${Date.now()}-${Math.random().toString(36).slice(2, 8)}${extension}`,
+ );
+ const tempAudioWriteStartedAt = getNowMs();
+ await fs.writeFile(audioInputPath, Buffer.from(options.editedAudioData));
+ metrics.tempEditedAudioWriteMs = getNowMs() - tempAudioWriteStartedAt;
+ metrics.tempEditedAudioBytes = options.editedAudioData.byteLength;
+ tempArtifacts.push(audioInputPath);
+ }
+
+ if (!audioInputPath) {
+ return {
+ outputPath: videoPath,
+ metrics,
+ };
+ }
+
+ const outputPath = path.join(
+ path.dirname(videoPath),
+ `${path.basename(videoPath, path.extname(videoPath))}-final.mp4`,
+ );
+
+ const args = buildNativeVideoAudioMuxArgs(
+ videoPath,
+ audioInputPath,
+ outputPath,
+ options,
+ onProgress ? { progressPipe: 2 } : {},
+ );
+
+ try {
+ const ffmpegExecStartedAt = getNowMs();
+ await runFfmpegAudioMux(ffmpegPath, args, 15 * 60 * 1000, options, onProgress, session);
+ metrics.ffmpegExecMs = getNowMs() - ffmpegExecStartedAt;
+ console.info("[native-video-export] Audio mux completed", {
+ ffmpegExecMs: metrics.ffmpegExecMs,
+ audioMode: options.audioMode,
+ tempVideoBytes: metrics.tempVideoBytes,
+ muxedVideoBytes: metrics.muxedVideoBytes,
+ });
+ await removeTemporaryExportFile(videoPath);
+ return {
+ outputPath,
+ metrics,
+ };
+ } finally {
+ await Promise.allSettled(
+ tempArtifacts.map((artifactPath) => removeTemporaryExportFile(artifactPath)),
+ );
+ }
+}
+
+export async function muxExportedVideoAudioBuffer(
+ videoData: ArrayBuffer,
+ options: NativeVideoExportFinishOptions,
+) {
+ const tempVideoPath = path.join(
+ app.getPath("temp"),
+ `recordly-export-video-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.mp4`,
+ );
+ const metrics: NativeVideoAudioMuxMetrics = {};
+ let succeeded = false;
+ let outputPath = tempVideoPath;
+
+ try {
+ const tempVideoWriteStartedAt = getNowMs();
+ await fs.writeFile(tempVideoPath, Buffer.from(videoData));
+ metrics.tempVideoWriteMs = getNowMs() - tempVideoWriteStartedAt;
+ metrics.tempVideoBytes = videoData.byteLength;
+ const finalized = await muxNativeVideoExportAudio(tempVideoPath, options);
+ Object.assign(metrics, finalized.metrics);
+ outputPath = finalized.outputPath;
+ // Record byte size via stat instead of reading the whole file into a
+ // Buffer — fs.readFile throws ERR_FS_FILE_TOO_LARGE on >2 GiB outputs.
+ try {
+ const stat = await fs.stat(outputPath);
+ metrics.muxedVideoBytes = stat.size;
+ } catch {
+ // Stat failures are non-fatal; size is purely metric data.
+ }
+ succeeded = true;
+ return {
+ outputPath,
+ metrics,
+ };
+ } finally {
+ // Always remove the unmuxed intermediate when the muxer wrote a separate
+ // file. Only remove the muxed output on failure — on success the caller
+ // owns it and is responsible for moving/deleting it.
+ const cleanupTargets: string[] = [];
+ if (outputPath !== tempVideoPath) {
+ cleanupTargets.push(tempVideoPath);
+ }
+ if (!succeeded) {
+ cleanupTargets.push(outputPath);
+ }
+ if (cleanupTargets.length > 0) {
+ await Promise.allSettled(
+ cleanupTargets.map((target) => removeTemporaryExportFile(target)),
+ );
+ }
+ }
+}
diff --git a/electron/ipc/export/nativeStaticLayoutRoutePlan.test.ts b/electron/ipc/export/nativeStaticLayoutRoutePlan.test.ts
new file mode 100644
index 000000000..36bcaf064
--- /dev/null
+++ b/electron/ipc/export/nativeStaticLayoutRoutePlan.test.ts
@@ -0,0 +1,167 @@
+import { describe, expect, it } from "vitest";
+import {
+ type NativeStaticLayoutRouteSource,
+ type NvidiaCudaExportCapabilityProbe,
+ planNativeStaticLayoutRoutes,
+ type WindowsD3D11ExportCapabilityProbe,
+} from "./nativeStaticLayoutRoutePlan";
+
+const source: NativeStaticLayoutRouteSource = {
+ inputCodec: "h264",
+ proxyCodec: "h264",
+ proxyCreated: false,
+};
+
+const cudaProbe: NvidiaCudaExportCapabilityProbe = {
+ platform: "win32",
+ appPackaged: true,
+ explicitEnabled: false,
+ explicitDisabled: false,
+ packagedAutoCandidateEnabled: true,
+ packagedAutoCandidateActive: true,
+ windowsGpuCompositorEnabled: true,
+ wrapperPath: "C:\\Recordly\\run-mp4-pipeline.mjs",
+ hasNvidiaGpu: true,
+ audioMode: "copy-source",
+ audioSkipReason: null,
+ stallTimeoutMs: 120_000,
+ skipReason: null,
+};
+
+const d3d11Probe: WindowsD3D11ExportCapabilityProbe = {
+ platform: "win32",
+ windowsGpuCompositorEnabled: true,
+ helperPath: "C:\\Recordly\\recordly-gpu-export.exe",
+ adapterIndexOverride: null,
+ preferHighPerformanceAdapter: true,
+ nvencSdkRequested: false,
+ skipReason: null,
+};
+
+describe("planNativeStaticLayoutRoutes", () => {
+ it("selects CUDA first and records D3D11 plus FFmpeg fallbacks", () => {
+ expect(
+ planNativeStaticLayoutRoutes({
+ cuda: cudaProbe,
+ d3d11: d3d11Probe,
+ source,
+ }),
+ ).toEqual({
+ selectedRoute: "nvidia-cuda-compositor",
+ decisions: [
+ {
+ route: "nvidia-cuda-compositor",
+ status: "selected",
+ reasons: ["cuda-wrapper-and-nvidia-gpu-available"],
+ },
+ {
+ route: "windows-d3d11-compositor",
+ status: "fallback",
+ reasons: ["documented-fallback-if-cuda-runtime-fails"],
+ },
+ {
+ route: "ffmpeg-static-layout",
+ status: "fallback",
+ reasons: ["native-gpu-runtime-fallback"],
+ },
+ ],
+ cuda: cudaProbe,
+ d3d11: d3d11Probe,
+ source,
+ });
+ });
+
+ it("selects D3D11 when CUDA is unavailable", () => {
+ const cuda = {
+ ...cudaProbe,
+ hasNvidiaGpu: false,
+ skipReason: "nvidia-gpu-unavailable",
+ } satisfies NvidiaCudaExportCapabilityProbe;
+
+ expect(
+ planNativeStaticLayoutRoutes({
+ cuda,
+ d3d11: d3d11Probe,
+ source,
+ }),
+ ).toMatchObject({
+ selectedRoute: "windows-d3d11-compositor",
+ decisions: [
+ {
+ route: "nvidia-cuda-compositor",
+ status: "rejected",
+ reasons: ["nvidia-gpu-unavailable"],
+ },
+ {
+ route: "windows-d3d11-compositor",
+ status: "selected",
+ reasons: ["documented-fallback-after-cuda-skip:nvidia-gpu-unavailable"],
+ },
+ {
+ route: "ffmpeg-static-layout",
+ status: "fallback",
+ reasons: ["windows-d3d11-runtime-fallback"],
+ },
+ ],
+ });
+ });
+
+ it("selects FFmpeg when both native GPU routes are unavailable", () => {
+ const cuda = {
+ ...cudaProbe,
+ wrapperPath: null,
+ skipReason: "cuda-wrapper-unavailable",
+ } satisfies NvidiaCudaExportCapabilityProbe;
+ const d3d11 = {
+ ...d3d11Probe,
+ helperPath: null,
+ skipReason: "windows-gpu-helper-unavailable",
+ } satisfies WindowsD3D11ExportCapabilityProbe;
+
+ expect(
+ planNativeStaticLayoutRoutes({
+ cuda,
+ d3d11,
+ source,
+ }),
+ ).toEqual({
+ selectedRoute: "ffmpeg-static-layout",
+ decisions: [
+ {
+ route: "nvidia-cuda-compositor",
+ status: "rejected",
+ reasons: ["cuda-wrapper-unavailable"],
+ },
+ {
+ route: "windows-d3d11-compositor",
+ status: "rejected",
+ reasons: ["windows-gpu-helper-unavailable"],
+ },
+ {
+ route: "ffmpeg-static-layout",
+ status: "selected",
+ reasons: ["native-gpu-routes-unavailable"],
+ },
+ ],
+ cuda,
+ d3d11,
+ source,
+ });
+ });
+
+ it("preserves proxy source metadata for route diagnostics", () => {
+ const proxiedSource = {
+ inputCodec: "vp9",
+ proxyCodec: "h264",
+ proxyCreated: true,
+ };
+
+ expect(
+ planNativeStaticLayoutRoutes({
+ cuda: cudaProbe,
+ d3d11: d3d11Probe,
+ source: proxiedSource,
+ }).source,
+ ).toEqual(proxiedSource);
+ });
+});
diff --git a/electron/ipc/export/nativeStaticLayoutRoutePlan.ts b/electron/ipc/export/nativeStaticLayoutRoutePlan.ts
new file mode 100644
index 000000000..3ee6c4e73
--- /dev/null
+++ b/electron/ipc/export/nativeStaticLayoutRoutePlan.ts
@@ -0,0 +1,132 @@
+import type { NativeVideoExportAudioMode } from "../nativeVideoExport";
+
+export type NativeStaticLayoutRoute =
+ | "nvidia-cuda-compositor"
+ | "windows-d3d11-compositor"
+ | "ffmpeg-static-layout";
+
+export interface NativeStaticLayoutRouteDecision {
+ route: NativeStaticLayoutRoute;
+ status: "selected" | "fallback" | "rejected";
+ reasons: string[];
+}
+
+export interface NvidiaCudaExportCapabilityProbe {
+ platform: NodeJS.Platform;
+ appPackaged: boolean;
+ explicitEnabled: boolean;
+ explicitDisabled: boolean;
+ packagedAutoCandidateEnabled: boolean;
+ packagedAutoCandidateActive: boolean;
+ windowsGpuCompositorEnabled: boolean;
+ wrapperPath: string | null;
+ hasNvidiaGpu: boolean | null;
+ audioMode: NativeVideoExportAudioMode;
+ audioSkipReason: string | null;
+ stallTimeoutMs: number | null;
+ skipReason: string | null;
+}
+
+export interface WindowsD3D11ExportCapabilityProbe {
+ platform: NodeJS.Platform;
+ windowsGpuCompositorEnabled: boolean;
+ helperPath: string | null;
+ adapterIndexOverride: number | null;
+ preferHighPerformanceAdapter: boolean;
+ nvencSdkRequested: boolean;
+ skipReason: string | null;
+}
+
+export interface NativeStaticLayoutRouteSource {
+ inputCodec: string;
+ proxyCodec?: string;
+ proxyCreated: boolean;
+}
+
+export interface NativeStaticLayoutRoutePlan {
+ selectedRoute: NativeStaticLayoutRoute;
+ decisions: NativeStaticLayoutRouteDecision[];
+ cuda: NvidiaCudaExportCapabilityProbe;
+ d3d11: WindowsD3D11ExportCapabilityProbe;
+ source: NativeStaticLayoutRouteSource;
+}
+
+export function planNativeStaticLayoutRoutes(options: {
+ cuda: NvidiaCudaExportCapabilityProbe;
+ d3d11: WindowsD3D11ExportCapabilityProbe;
+ source: NativeStaticLayoutRouteSource;
+}): NativeStaticLayoutRoutePlan {
+ const { cuda, d3d11, source } = options;
+ const decisions: NativeStaticLayoutRouteDecision[] = [];
+
+ if (!cuda.skipReason) {
+ decisions.push({
+ route: "nvidia-cuda-compositor",
+ status: "selected",
+ reasons: ["cuda-wrapper-and-nvidia-gpu-available"],
+ });
+ decisions.push({
+ route: "windows-d3d11-compositor",
+ status: d3d11.skipReason ? "rejected" : "fallback",
+ reasons: d3d11.skipReason
+ ? [d3d11.skipReason]
+ : ["documented-fallback-if-cuda-runtime-fails"],
+ });
+ decisions.push({
+ route: "ffmpeg-static-layout",
+ status: "fallback",
+ reasons: ["native-gpu-runtime-fallback"],
+ });
+ return {
+ selectedRoute: "nvidia-cuda-compositor",
+ decisions,
+ cuda,
+ d3d11,
+ source,
+ };
+ }
+
+ decisions.push({
+ route: "nvidia-cuda-compositor",
+ status: "rejected",
+ reasons: [cuda.skipReason],
+ });
+
+ if (!d3d11.skipReason) {
+ decisions.push({
+ route: "windows-d3d11-compositor",
+ status: "selected",
+ reasons: [`documented-fallback-after-cuda-skip:${cuda.skipReason}`],
+ });
+ decisions.push({
+ route: "ffmpeg-static-layout",
+ status: "fallback",
+ reasons: ["windows-d3d11-runtime-fallback"],
+ });
+ return {
+ selectedRoute: "windows-d3d11-compositor",
+ decisions,
+ cuda,
+ d3d11,
+ source,
+ };
+ }
+
+ decisions.push({
+ route: "windows-d3d11-compositor",
+ status: "rejected",
+ reasons: [d3d11.skipReason],
+ });
+ decisions.push({
+ route: "ffmpeg-static-layout",
+ status: "selected",
+ reasons: ["native-gpu-routes-unavailable"],
+ });
+ return {
+ selectedRoute: "ffmpeg-static-layout",
+ decisions,
+ cuda,
+ d3d11,
+ source,
+ };
+}
diff --git a/electron/ipc/ffmpeg/binary.ts b/electron/ipc/ffmpeg/binary.ts
new file mode 100644
index 000000000..a0cf8468c
--- /dev/null
+++ b/electron/ipc/ffmpeg/binary.ts
@@ -0,0 +1,161 @@
+import { spawnSync } from "node:child_process";
+import { existsSync } from "node:fs";
+import { createRequire } from "node:module";
+import { app } from "electron";
+
+const nodeRequire = createRequire(import.meta.url);
+
+export function loadFfmpegStatic(): string | null {
+ try {
+ const moduleExports = nodeRequire("ffmpeg-static");
+ if (typeof moduleExports === "string") {
+ return moduleExports;
+ }
+
+ if (typeof moduleExports?.default === "string") {
+ return moduleExports.default as string;
+ }
+ } catch {
+ // ffmpeg-static not available; fall through to system FFmpeg
+ }
+
+ return null;
+}
+
+export function loadFfprobeStatic(): string | null {
+ try {
+ const moduleExports = nodeRequire("ffprobe-static");
+ if (typeof moduleExports === "string") {
+ return moduleExports;
+ }
+
+ if (typeof moduleExports?.path === "string") {
+ return moduleExports.path as string;
+ }
+
+ if (typeof moduleExports?.default === "string") {
+ return moduleExports.default as string;
+ }
+
+ if (typeof moduleExports?.default?.path === "string") {
+ return moduleExports.default.path as string;
+ }
+ } catch {
+ // ffprobe-static not available; fall through to system FFprobe
+ }
+
+ return null;
+}
+
+export function resolveSystemFfmpegBinaryPath(): string | null {
+ const locator = process.platform === "win32" ? "where" : "which";
+ const result = spawnSync(locator, ["ffmpeg"], {
+ encoding: "utf-8",
+ windowsHide: true,
+ });
+
+ if (result.status === 0) {
+ const candidate = result.stdout
+ .split(/\r?\n/)
+ .map((line: string) => line.trim())
+ .find((line: string) => line.length > 0);
+
+ if (candidate) {
+ return candidate;
+ }
+ }
+
+ // Fallback: check common install paths directly (Electron's shell may lack full PATH)
+ if (process.platform !== "win32") {
+ const commonPaths = [
+ "/opt/homebrew/bin/ffmpeg",
+ "/usr/local/bin/ffmpeg",
+ "/usr/bin/ffmpeg",
+ ];
+ for (const p of commonPaths) {
+ if (existsSync(p)) {
+ return p;
+ }
+ }
+ }
+
+ return null;
+}
+
+export function resolveSystemFfprobeBinaryPath(): string | null {
+ const locator = process.platform === "win32" ? "where" : "which";
+ const result = spawnSync(locator, ["ffprobe"], {
+ encoding: "utf-8",
+ windowsHide: true,
+ });
+
+ if (result.status === 0) {
+ const candidate = result.stdout
+ .split(/\r?\n/)
+ .map((line: string) => line.trim())
+ .find((line: string) => line.length > 0);
+
+ if (candidate) {
+ return candidate;
+ }
+ }
+
+ if (process.platform !== "win32") {
+ const commonPaths = [
+ "/opt/homebrew/bin/ffprobe",
+ "/usr/local/bin/ffprobe",
+ "/usr/bin/ffprobe",
+ ];
+ for (const p of commonPaths) {
+ if (existsSync(p)) {
+ return p;
+ }
+ }
+ }
+
+ return null;
+}
+
+export function getFfmpegBinaryPath(): string {
+ const ffmpegStatic = loadFfmpegStatic();
+ if (ffmpegStatic && typeof ffmpegStatic === "string") {
+ const bundledPath = app.isPackaged
+ ? ffmpegStatic.replace(/\.asar([/\\])/, ".asar.unpacked$1")
+ : ffmpegStatic;
+
+ if (existsSync(bundledPath)) {
+ return bundledPath;
+ }
+ }
+
+ const systemFfmpeg = resolveSystemFfmpegBinaryPath();
+ if (systemFfmpeg) {
+ return systemFfmpeg;
+ }
+
+ throw new Error(
+ "FFmpeg binary is unavailable. Install ffmpeg-static for this platform or make ffmpeg available on PATH.",
+ );
+}
+
+export function getFfprobeBinaryPath(): string {
+ const ffprobeStatic = loadFfprobeStatic();
+ if (ffprobeStatic && typeof ffprobeStatic === "string") {
+ const bundledPath = app.isPackaged
+ ? ffprobeStatic.replace(/\.asar([/\\])/, ".asar.unpacked$1")
+ : ffprobeStatic;
+
+ if (existsSync(bundledPath)) {
+ return bundledPath;
+ }
+ }
+
+ const systemFfprobe = resolveSystemFfprobeBinaryPath();
+ if (systemFfprobe) {
+ return systemFfprobe;
+ }
+
+ throw new Error(
+ "FFprobe binary is unavailable. Install ffprobe-static for this platform or make ffprobe available on PATH.",
+ );
+}
diff --git a/electron/ipc/ffmpeg/filters.test.ts b/electron/ipc/ffmpeg/filters.test.ts
new file mode 100644
index 000000000..8b8ecfdb5
--- /dev/null
+++ b/electron/ipc/ffmpeg/filters.test.ts
@@ -0,0 +1,164 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ appendSyncedAudioFilter,
+ applyRecordedAudioStartDelay,
+ getAudioSyncAdjustment,
+} from "./filters";
+
+describe("getAudioSyncAdjustment", () => {
+ it("does not speed up longer audio tracks that would advance speech", () => {
+ expect(getAudioSyncAdjustment(120, 122.5)).toEqual({
+ mode: "none",
+ delayMs: 0,
+ tempoRatio: 1,
+ durationDeltaMs: -2500,
+ });
+ });
+
+ it("still stretches slightly shorter audio tracks to match the video", () => {
+ expect(getAudioSyncAdjustment(120, 117)).toEqual({
+ mode: "tempo",
+ delayMs: 0,
+ tempoRatio: 0.975,
+ durationDeltaMs: 3000,
+ });
+ });
+
+ it("still delays much shorter audio tracks instead of extreme tempo correction", () => {
+ expect(getAudioSyncAdjustment(120, 110)).toEqual({
+ mode: "delay",
+ delayMs: 10000,
+ tempoRatio: 1,
+ durationDeltaMs: 10000,
+ });
+ });
+
+ it("pads trailing silence instead of prepending extreme delay for very short audio tracks", () => {
+ expect(getAudioSyncAdjustment(600, 480)).toEqual({
+ mode: "pad",
+ delayMs: 0,
+ tempoRatio: 1,
+ durationDeltaMs: 120000,
+ });
+ });
+
+ it("does not inject atempo when longer audio stays on the anchored path", () => {
+ const filterParts: string[] = [];
+ appendSyncedAudioFilter(filterParts, "[1:a]", "aout", getAudioSyncAdjustment(120, 122.5));
+
+ expect(filterParts).toEqual([
+ "[1:a]aresample=async=1:first_pts=0,asetpts=PTS-STARTPTS[aout]",
+ ]);
+ });
+
+ it("still injects atempo for slightly shorter audio tracks", () => {
+ const filterParts: string[] = [];
+ appendSyncedAudioFilter(filterParts, "[1:a]", "aout", getAudioSyncAdjustment(120, 117));
+
+ expect(filterParts).toEqual([
+ "[1:a]atempo=0.975000,aresample=async=1:first_pts=0,asetpts=PTS-STARTPTS[aout]",
+ ]);
+ });
+
+ it("pads the tail for very short audio tracks", () => {
+ const filterParts: string[] = [];
+ appendSyncedAudioFilter(filterParts, "[1:a]", "aout", getAudioSyncAdjustment(600, 480));
+
+ expect(filterParts).toEqual([
+ "[1:a]apad=pad_dur=120.000,aresample=async=1:first_pts=0,asetpts=PTS-STARTPTS[aout]",
+ ]);
+ });
+
+ it("pads the remaining tail after a measured late start delay", () => {
+ const filterParts: string[] = [];
+ appendSyncedAudioFilter(filterParts, "[1:a]", "aout", {
+ mode: "delay",
+ delayMs: 18051,
+ tempoRatio: 1,
+ durationDeltaMs: 1631070,
+ });
+
+ expect(filterParts).toEqual([
+ "[1:a]adelay=18051|18051,apad=pad_dur=1613.019,aresample=async=1:first_pts=0,asetpts=PTS-STARTPTS[aout]",
+ ]);
+ });
+
+ it("does not pad when a measured late start already explains the duration gap", () => {
+ const filterParts: string[] = [];
+ appendSyncedAudioFilter(filterParts, "[1:a]", "aout", {
+ mode: "delay",
+ delayMs: 10000,
+ tempoRatio: 1,
+ durationDeltaMs: 10000,
+ });
+
+ expect(filterParts).toEqual([
+ "[1:a]adelay=10000|10000,aresample=async=1:first_pts=0,asetpts=PTS-STARTPTS[aout]",
+ ]);
+ });
+
+ it("can add a small gain boost before resampling", () => {
+ const filterParts: string[] = [];
+ appendSyncedAudioFilter(
+ filterParts,
+ "[1:a]",
+ "aout",
+ getAudioSyncAdjustment(120, 120),
+ 1.4,
+ );
+
+ expect(filterParts).toEqual([
+ "[1:a]volume=1.400,aresample=async=1:first_pts=0,asetpts=PTS-STARTPTS[aout]",
+ ]);
+ });
+
+ it("can prepend mic normalization filters before sync handling", () => {
+ const filterParts: string[] = [];
+ appendSyncedAudioFilter(filterParts, "[1:a]", "aout", getAudioSyncAdjustment(120, 120), {
+ preFilters: ["adeclip=threshold=1", "loudnorm=I=-16:TP=-1.5:LRA=11"],
+ });
+
+ expect(filterParts).toEqual([
+ "[1:a]adeclip=threshold=1,loudnorm=I=-16:TP=-1.5:LRA=11,aresample=async=1:first_pts=0,asetpts=PTS-STARTPTS[aout]",
+ ]);
+ });
+});
+
+describe("applyRecordedAudioStartDelay", () => {
+ it("pads the tail when recorded metadata says audio started on time", () => {
+ expect(applyRecordedAudioStartDelay(getAudioSyncAdjustment(120, 110), 0)).toEqual({
+ mode: "pad",
+ delayMs: 0,
+ tempoRatio: 1,
+ durationDeltaMs: 10000,
+ });
+ });
+
+ it("prefers a measured start delay over a tempo-only heuristic", () => {
+ expect(applyRecordedAudioStartDelay(getAudioSyncAdjustment(120, 119.8), 275)).toEqual({
+ mode: "delay",
+ delayMs: 275,
+ tempoRatio: 1,
+ durationDeltaMs: 200,
+ });
+ });
+
+ it("applies a measured start delay even when durations already match", () => {
+ expect(applyRecordedAudioStartDelay(getAudioSyncAdjustment(120, 120), 275)).toEqual({
+ mode: "delay",
+ delayMs: 275,
+ tempoRatio: 1,
+ durationDeltaMs: 0,
+ });
+ });
+
+ it("leaves tempo correction alone when recorded metadata says there was no late start", () => {
+ expect(applyRecordedAudioStartDelay(getAudioSyncAdjustment(120, 117), 0)).toEqual({
+ mode: "tempo",
+ delayMs: 0,
+ tempoRatio: 0.975,
+ durationDeltaMs: 3000,
+ });
+ });
+});
diff --git a/electron/ipc/ffmpeg/filters.ts b/electron/ipc/ffmpeg/filters.ts
new file mode 100644
index 000000000..2d3370c7f
--- /dev/null
+++ b/electron/ipc/ffmpeg/filters.ts
@@ -0,0 +1,258 @@
+import type { AudioSyncAdjustment, PauseSegment } from "../types";
+
+const MAX_AUDIO_SYNC_DELAY_MS = 15000;
+export const ATEMPO_FILTER_EPSILON = 0.0005;
+
+export function buildAtempoFilters(tempoRatio: number): string[] {
+ if (!Number.isFinite(tempoRatio) || tempoRatio <= 0) {
+ return [];
+ }
+
+ const filters: string[] = [];
+ let remaining = tempoRatio;
+
+ while (remaining < 0.5) {
+ filters.push("atempo=0.5");
+ remaining /= 0.5;
+ }
+
+ while (remaining > 2) {
+ filters.push("atempo=2.0");
+ remaining /= 2.0;
+ }
+
+ if (Math.abs(remaining - 1) > ATEMPO_FILTER_EPSILON) {
+ filters.push(`atempo=${remaining.toFixed(6)}`);
+ }
+
+ return filters;
+}
+
+export function getAudioSyncAdjustment(
+ videoDuration: number,
+ audioDuration: number,
+): AudioSyncAdjustment {
+ if (
+ !Number.isFinite(videoDuration) ||
+ !Number.isFinite(audioDuration) ||
+ videoDuration <= 0 ||
+ audioDuration <= 0
+ ) {
+ return { mode: "none", delayMs: 0, tempoRatio: 1, durationDeltaMs: 0 };
+ }
+
+ const durationDeltaMs = Math.round((videoDuration - audioDuration) * 1000);
+ const absDeltaMs = Math.abs(durationDeltaMs);
+ if (absDeltaMs <= 20) {
+ return { mode: "none", delayMs: 0, tempoRatio: 1, durationDeltaMs };
+ }
+
+ // When the recorded audio runs longer than the video, globally speeding it
+ // up can pull speech ahead of the picture. Keep the track anchored at the
+ // start instead and let the downstream mux path trim any trailing overrun.
+ if (durationDeltaMs < 0) {
+ return { mode: "none", delayMs: 0, tempoRatio: 1, durationDeltaMs };
+ }
+
+ const tempoRatio = Math.max(0.5, Math.min(2, audioDuration / videoDuration));
+ const relativeDelta = absDeltaMs / Math.max(videoDuration * 1000, 1);
+
+ if (relativeDelta <= 0.03 || absDeltaMs <= 1500) {
+ return { mode: "tempo", delayMs: 0, tempoRatio, durationDeltaMs };
+ }
+
+ if (durationDeltaMs > MAX_AUDIO_SYNC_DELAY_MS) {
+ return { mode: "pad", delayMs: 0, tempoRatio: 1, durationDeltaMs };
+ }
+
+ return { mode: "delay", delayMs: durationDeltaMs, tempoRatio: 1, durationDeltaMs };
+}
+
+export function applyRecordedAudioStartDelay(
+ adjustment: AudioSyncAdjustment,
+ recordedStartDelayMs?: number | null,
+): AudioSyncAdjustment {
+ if (!Number.isFinite(recordedStartDelayMs) || (recordedStartDelayMs ?? 0) < 0) {
+ return adjustment;
+ }
+
+ const delayMs = Math.max(0, Math.round(recordedStartDelayMs ?? 0));
+ if (delayMs > 20) {
+ return {
+ mode: "delay",
+ delayMs,
+ tempoRatio: 1,
+ durationDeltaMs: adjustment.durationDeltaMs,
+ };
+ }
+
+ if (adjustment.mode !== "delay" && adjustment.mode !== "pad") {
+ return adjustment;
+ }
+
+ return {
+ mode: "pad",
+ delayMs: 0,
+ tempoRatio: 1,
+ durationDeltaMs: adjustment.durationDeltaMs,
+ };
+}
+
+export function appendSyncedAudioFilter(
+ filterParts: string[],
+ inputLabel: string,
+ outputLabel: string,
+ adjustment: AudioSyncAdjustment,
+ options: number | { volumeMultiplier?: number; preFilters?: string[] } = 1,
+) {
+ const volumeMultiplier =
+ typeof options === "number" ? options : (options.volumeMultiplier ?? 1);
+ const preFilters = typeof options === "number" ? [] : (options.preFilters ?? []);
+ const filters: string[] = [...preFilters];
+
+ if (adjustment.mode === "delay" && adjustment.delayMs > 0) {
+ filters.push(`adelay=${adjustment.delayMs}|${adjustment.delayMs}`);
+ }
+
+ if (adjustment.mode === "delay" && adjustment.durationDeltaMs > adjustment.delayMs + 20) {
+ filters.push(
+ `apad=pad_dur=${formatFfmpegSeconds(adjustment.durationDeltaMs - adjustment.delayMs)}`,
+ );
+ }
+
+ if (adjustment.mode === "tempo") {
+ filters.push(...buildAtempoFilters(adjustment.tempoRatio));
+ }
+
+ if (adjustment.mode === "pad" && adjustment.durationDeltaMs > 0) {
+ filters.push(`apad=pad_dur=${formatFfmpegSeconds(adjustment.durationDeltaMs)}`);
+ }
+
+ if (
+ Number.isFinite(volumeMultiplier) &&
+ volumeMultiplier > 0 &&
+ Math.abs(volumeMultiplier - 1) > 0.0005
+ ) {
+ filters.push(`volume=${volumeMultiplier.toFixed(3)}`);
+ }
+
+ filters.push("aresample=async=1:first_pts=0", "asetpts=PTS-STARTPTS");
+ filterParts.push(`${inputLabel}${filters.join(",")}[${outputLabel}]`);
+}
+
+export function formatFfmpegSeconds(milliseconds: number): string {
+ return (milliseconds / 1000).toFixed(3);
+}
+
+export function normalizePauseSegments(pauseSegments: PauseSegment[] | undefined): PauseSegment[] {
+ if (!Array.isArray(pauseSegments) || pauseSegments.length === 0) {
+ return [];
+ }
+
+ const normalized = pauseSegments
+ .map((segment) => {
+ const startMs = Number(segment?.startMs);
+ const endMs = Number(segment?.endMs);
+
+ if (!Number.isFinite(startMs) || !Number.isFinite(endMs)) {
+ return null;
+ }
+
+ const clampedStart = Math.max(0, Math.round(startMs));
+ const clampedEnd = Math.max(0, Math.round(endMs));
+ if (clampedEnd <= clampedStart) {
+ return null;
+ }
+
+ return { startMs: clampedStart, endMs: clampedEnd };
+ })
+ .filter((segment): segment is PauseSegment => !!segment)
+ .sort((left, right) => left.startMs - right.startMs);
+
+ if (normalized.length <= 1) {
+ return normalized;
+ }
+
+ const merged: PauseSegment[] = [{ ...normalized[0] }];
+
+ for (const segment of normalized.slice(1)) {
+ const previous = merged[merged.length - 1];
+ if (segment.startMs <= previous.endMs) {
+ previous.endMs = Math.max(previous.endMs, segment.endMs);
+ } else {
+ merged.push({ ...segment });
+ }
+ }
+
+ return merged;
+}
+
+export function buildPausedAudioFilter(
+ inputLabel: string,
+ outputLabel: string,
+ pauseSegments: PauseSegment[],
+): string | null {
+ if (pauseSegments.length === 0) {
+ return null;
+ }
+
+ const activeSegments: Array<{ startMs: number; endMs?: number }> = [];
+ let cursorMs = 0;
+
+ for (const pauseSegment of pauseSegments) {
+ if (pauseSegment.startMs > cursorMs) {
+ activeSegments.push({ startMs: cursorMs, endMs: pauseSegment.startMs });
+ }
+ cursorMs = Math.max(cursorMs, pauseSegment.endMs);
+ }
+
+ activeSegments.push({ startMs: cursorMs });
+
+ const filterParts: string[] = [];
+ const segmentLabels: string[] = [];
+
+ activeSegments.forEach((segment, index) => {
+ if (typeof segment.endMs === "number" && segment.endMs <= segment.startMs) {
+ return;
+ }
+
+ const segmentLabel = `${outputLabel}_part${index}`;
+ const trimArgs =
+ typeof segment.endMs === "number"
+ ? `start=${formatFfmpegSeconds(segment.startMs)}:end=${formatFfmpegSeconds(segment.endMs)}`
+ : `start=${formatFfmpegSeconds(segment.startMs)}`;
+
+ filterParts.push(`[${inputLabel}]atrim=${trimArgs},asetpts=PTS-STARTPTS[${segmentLabel}]`);
+ segmentLabels.push(`[${segmentLabel}]`);
+ });
+
+ if (segmentLabels.length === 0) {
+ return null;
+ }
+
+ if (segmentLabels.length === 1) {
+ filterParts.push(`${segmentLabels[0]}anull[${outputLabel}]`);
+ } else {
+ filterParts.push(
+ `${segmentLabels.join("")}concat=n=${segmentLabels.length}:v=0:a=1[${outputLabel}]`,
+ );
+ }
+
+ return filterParts.join(";");
+}
+
+export function parseFfmpegDurationSeconds(stderr: string): number | null {
+ const match = stderr.match(/Duration:\s+(\d+):(\d+):(\d+(?:\.\d+)?)/i);
+ if (!match) {
+ return null;
+ }
+
+ const hours = Number(match[1]);
+ const minutes = Number(match[2]);
+ const seconds = Number(match[3]);
+ if (![hours, minutes, seconds].every(Number.isFinite)) {
+ return null;
+ }
+
+ return hours * 3600 + minutes * 60 + seconds;
+}
diff --git a/electron/ipc/ffmpeg/metadata.ts b/electron/ipc/ffmpeg/metadata.ts
new file mode 100644
index 000000000..50aaf731f
--- /dev/null
+++ b/electron/ipc/ffmpeg/metadata.ts
@@ -0,0 +1,130 @@
+import { execFile } from "node:child_process";
+import { promisify } from "node:util";
+const execFileAsync = promisify(execFile);
+
+export interface NativeVideoMetadataProbe {
+ width: number;
+ height: number;
+ duration: number;
+ mediaStartTime?: number;
+ streamStartTime?: number;
+ streamDuration?: number;
+ frameRate: number;
+ codec: string;
+ hasAudio: boolean;
+ audioCodec?: string;
+ audioSampleRate?: number;
+}
+
+export function parseFfmpegDurationSeconds(value: string): number | null {
+ const parts = value.trim().split(":");
+ if (parts.length !== 3) {
+ return null;
+ }
+
+ const [hours, minutes, seconds] = parts.map(Number);
+ if (![hours, minutes, seconds].every(Number.isFinite)) {
+ return null;
+ }
+
+ return hours * 3600 + minutes * 60 + seconds;
+}
+
+export function parseFfmpegFrameRate(line: string): number | null {
+ const fpsMatch = line.match(/,\s*([0-9]+(?:\.[0-9]+)?)\s*fps\b/i);
+ if (fpsMatch) {
+ const frameRate = Number(fpsMatch[1]);
+ return Number.isFinite(frameRate) && frameRate > 0 ? frameRate : null;
+ }
+
+ const tbrMatch = line.match(/,\s*([0-9]+(?:\.[0-9]+)?)\s*tbr\b/i);
+ if (tbrMatch) {
+ const frameRate = Number(tbrMatch[1]);
+ return Number.isFinite(frameRate) && frameRate > 0 ? frameRate : null;
+ }
+
+ return null;
+}
+
+export function parseNativeVideoMetadataProbeOutput(
+ output: string,
+): NativeVideoMetadataProbe | null {
+ const durationMatch = output.match(
+ /Duration:\s*([0-9:.]+),\s*start:\s*(-?[0-9]+(?:\.[0-9]+)?)/i,
+ );
+ const duration = durationMatch ? parseFfmpegDurationSeconds(durationMatch[1]) : null;
+ if (!duration || duration <= 0) {
+ return null;
+ }
+
+ const mediaStartTime = durationMatch ? Number(durationMatch[2]) : 0;
+ const lines = output.split(/\r?\n/);
+ const videoLine = lines.find((line) => /\bVideo:\s*/i.test(line));
+ if (!videoLine) {
+ return null;
+ }
+
+ const dimensionsMatch = videoLine.match(/,\s*([0-9]{2,5})x([0-9]{2,5})(?:[,\s]|$)/);
+ if (!dimensionsMatch) {
+ return null;
+ }
+
+ const width = Number(dimensionsMatch[1]);
+ const height = Number(dimensionsMatch[2]);
+ if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
+ return null;
+ }
+
+ const videoCodecMatch = videoLine.match(/Video:\s*([^,\r\n]+)/i);
+ const videoStartMatch = videoLine.match(/\bstart:\s*(-?[0-9]+(?:\.[0-9]+)?)/i);
+ const frameRate = parseFfmpegFrameRate(videoLine) ?? 60;
+ const audioLine = lines.find((line) => /\bAudio:\s*/i.test(line));
+ const audioCodecMatch = audioLine?.match(/Audio:\s*([^,\r\n]+)/i);
+ const audioSampleRateMatch = audioLine?.match(/,\s*([0-9]+)\s*Hz\b/i);
+
+ return {
+ width,
+ height,
+ duration,
+ mediaStartTime: Number.isFinite(mediaStartTime) ? mediaStartTime : 0,
+ streamStartTime: videoStartMatch ? Number(videoStartMatch[1]) : mediaStartTime,
+ streamDuration: duration,
+ frameRate,
+ codec: videoCodecMatch?.[1]?.trim() || "unknown",
+ hasAudio: Boolean(audioLine),
+ audioCodec: audioCodecMatch?.[1]?.trim(),
+ audioSampleRate: audioSampleRateMatch ? Number(audioSampleRateMatch[1]) : undefined,
+ };
+}
+
+export async function probeNativeVideoMetadata(
+ ffmpegPath: string,
+ inputPath: string,
+ signal?: AbortSignal,
+): Promise {
+ let output = "";
+ try {
+ const result = await execFileAsync(ffmpegPath, ["-hide_banner", "-i", inputPath], {
+ signal,
+ timeout: 30_000,
+ maxBuffer: 4 * 1024 * 1024,
+ });
+ output = `${result.stdout}\n${result.stderr}`;
+ } catch (error) {
+ signal?.throwIfAborted();
+ const processOutput = error as { stdout?: unknown; stderr?: unknown };
+ output = [processOutput.stdout, processOutput.stderr]
+ .filter((value): value is string => typeof value === "string")
+ .join("\n");
+ if (!output) {
+ throw error;
+ }
+ }
+
+ const metadata = parseNativeVideoMetadataProbeOutput(output);
+ if (!metadata) {
+ throw new Error("Unable to parse native video metadata from FFmpeg output");
+ }
+
+ return metadata;
+}
diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts
index 81e8af5ae..e87e677e4 100644
--- a/electron/ipc/handlers.ts
+++ b/electron/ipc/handlers.ts
@@ -1,181 +1,76 @@
-import { ipcMain, desktopCapturer, BrowserWindow, shell, app } from 'electron'
-import { startMouseTracking, stopMouseTracking, getTrackingData } from './mouseTracking'
-import fs from 'node:fs/promises'
-import path from 'node:path'
-import { RECORDINGS_DIR } from '../main'
+import { BrowserWindow } from "electron";
+import { registerAnnouncementHandlers } from "./register/announcements";
+import { registerAssetHandlers } from "./register/assets";
+import { registerCaptionHandlers } from "./register/captions";
+import { registerCloudShareHandlers } from "./register/cloudShare";
+import { registerExportHandlers } from "./register/export";
+import { registerPermissionHandlers } from "./register/permissions";
+import { registerProjectHandlers } from "./register/project";
+import { registerRecordingHandlers } from "./register/recording";
+import { registerSettingsHandlers } from "./register/settings";
+import { registerSourceHandlers } from "./register/sources";
+import {
+ selectedSource,
+ setNativeScreenRecordingActive,
+ setWindowsCapturePaused,
+ setWindowsCaptureProcess,
+ setWindowsCaptureStopRequested,
+ setWindowsCaptureTargetPath,
+ setWindowsMicAudioPath,
+ setWindowsNativeCaptureActive,
+ setWindowsOrphanedMicAudioPath,
+ setWindowsPendingVideoPath,
+ setWindowsSystemAudioPath,
+ windowsCaptureProcess,
+} from "./state";
+
+export { cleanupAllExportStreams } from "./export/exportStream";
+export { cleanupNativeVideoExportSessions } from "./export/native-video";
+
+/** Returns the currently selected source ID for setDisplayMediaRequestHandler */
+export function getSelectedSourceId(): string | null {
+ return (selectedSource?.id as string | null) ?? null;
+}
-let selectedSource: any = null
+export function killWindowsCaptureProcess() {
+ if (windowsCaptureProcess) {
+ try {
+ windowsCaptureProcess.kill();
+ } catch {
+ /* ignore */
+ }
+ setWindowsCaptureProcess(null);
+ setWindowsCaptureTargetPath(null);
+ setWindowsNativeCaptureActive(false);
+ setNativeScreenRecordingActive(false);
+ setWindowsCaptureStopRequested(false);
+ setWindowsCapturePaused(false);
+ setWindowsSystemAudioPath(null);
+ setWindowsMicAudioPath(null);
+ setWindowsOrphanedMicAudioPath(null);
+ setWindowsPendingVideoPath(null);
+ }
+}
export function registerIpcHandlers(
- createEditorWindow: () => void,
- createSourceSelectorWindow: () => BrowserWindow,
- getMainWindow: () => BrowserWindow | null,
- getSourceSelectorWindow: () => BrowserWindow | null,
- onRecordingStateChange?: (recording: boolean, sourceName: string) => void
+ createEditorWindow: () => void,
+ createSourceSelectorWindow: () => BrowserWindow,
+ _getMainWindow: () => BrowserWindow | null,
+ getSourceSelectorWindow: () => BrowserWindow | null,
+ onRecordingStateChange?: (recording: boolean, sourceName: string) => void,
) {
- ipcMain.handle('get-sources', async (_, opts) => {
- const sources = await desktopCapturer.getSources(opts)
- return sources.map(source => ({
- id: source.id,
- name: source.name,
- display_id: source.display_id,
- thumbnail: source.thumbnail ? source.thumbnail.toDataURL() : null,
- appIcon: source.appIcon ? source.appIcon.toDataURL() : null
- }))
- })
-
- ipcMain.handle('select-source', (_, source) => {
- selectedSource = source
- const sourceSelectorWin = getSourceSelectorWindow()
- if (sourceSelectorWin) {
- sourceSelectorWin.close()
- }
- return selectedSource
- })
-
- ipcMain.handle('get-selected-source', () => {
- return selectedSource
- })
-
- ipcMain.handle('open-source-selector', () => {
- const sourceSelectorWin = getSourceSelectorWindow()
- if (sourceSelectorWin) {
- sourceSelectorWin.focus()
- return
- }
- createSourceSelectorWindow()
- })
-
- ipcMain.handle('switch-to-editor', () => {
- const mainWin = getMainWindow()
- if (mainWin) {
- mainWin.close()
- }
- createEditorWindow()
- })
-
- ipcMain.handle('start-mouse-tracking', () => {
- return startMouseTracking()
- })
-
- ipcMain.handle('stop-mouse-tracking', () => {
- return stopMouseTracking()
- })
-
- ipcMain.handle('store-recorded-video', async (_, videoData: ArrayBuffer, fileName: string) => {
- try {
- const videoPath = path.join(RECORDINGS_DIR, fileName)
- await fs.writeFile(videoPath, Buffer.from(videoData))
-
- return {
- success: true,
- path: videoPath,
- message: 'Video stored successfully'
- }
- } catch (error) {
- console.error('Failed to store video:', error)
- return {
- success: false,
- message: 'Failed to store video',
- error: String(error)
- }
- }
- })
-
- ipcMain.handle('store-mouse-tracking-data', async (_, fileName: string) => {
- try {
- const data = getTrackingData()
-
- if (data.length === 0) {
- return { success: false, message: 'No tracking data to save' }
- }
-
- const trackingPath = path.join(RECORDINGS_DIR, fileName)
- await fs.writeFile(trackingPath, JSON.stringify(data, null, 2), 'utf-8')
-
- return {
- success: true,
- path: trackingPath,
- eventCount: data.length,
- message: 'Mouse tracking data stored successfully'
- }
- } catch (error) {
- console.error('Failed to store mouse tracking data:', error)
- return {
- success: false,
- message: 'Failed to store mouse tracking data',
- error: String(error)
- }
- }
- })
-
- ipcMain.handle('get-recorded-video-path', async () => {
- try {
- const files = await fs.readdir(RECORDINGS_DIR)
- const videoFiles = files.filter(file => file.endsWith('.webm'))
-
- if (videoFiles.length === 0) {
- return { success: false, message: 'No recorded video found' }
- }
-
- const latestVideo = videoFiles.sort().reverse()[0]
- const videoPath = path.join(RECORDINGS_DIR, latestVideo)
-
- return { success: true, path: videoPath }
- } catch (error) {
- console.error('Failed to get video path:', error)
- return { success: false, message: 'Failed to get video path', error: String(error) }
- }
- })
-
- ipcMain.handle('set-recording-state', (_, recording: boolean) => {
- const source = selectedSource || { name: 'Screen' }
- if (onRecordingStateChange) {
- onRecordingStateChange(recording, source.name)
- }
- })
-
- ipcMain.handle('open-external-url', async (_, url: string) => {
- try {
- await shell.openExternal(url)
- return { success: true }
- } catch (error) {
- console.error('Failed to open URL:', error)
- return { success: false, error: String(error) }
- }
- })
-
- // Return base path for assets so renderer can resolve file:// paths in production
- ipcMain.handle('get-asset-base-path', () => {
- try {
- if (app.isPackaged) {
- return path.join(process.resourcesPath, 'assets')
- }
- return path.join(app.getAppPath(), 'public', 'assets')
- } catch (err) {
- console.error('Failed to resolve asset base path:', err)
- return null
- }
- })
-
- ipcMain.handle('save-exported-video', async (_, videoData: ArrayBuffer, fileName: string) => {
- try {
- const downloadsPath = app.getPath('downloads')
- const videoPath = path.join(downloadsPath, fileName)
- await fs.writeFile(videoPath, Buffer.from(videoData))
-
- return {
- success: true,
- path: videoPath,
- message: 'Video exported successfully'
- }
- } catch (error) {
- console.error('Failed to save exported video:', error)
- return {
- success: false,
- message: 'Failed to save exported video',
- error: String(error)
- }
- }
- })
+ registerSourceHandlers({
+ createEditorWindow,
+ createSourceSelectorWindow,
+ getSourceSelectorWindow,
+ });
+ registerRecordingHandlers(onRecordingStateChange);
+ registerPermissionHandlers();
+ registerAnnouncementHandlers();
+ registerAssetHandlers();
+ registerExportHandlers();
+ registerCaptionHandlers();
+ registerCloudShareHandlers();
+ registerProjectHandlers();
+ registerSettingsHandlers();
}
diff --git a/electron/ipc/monitorResolver.ts b/electron/ipc/monitorResolver.ts
new file mode 100644
index 000000000..f14bbd38c
--- /dev/null
+++ b/electron/ipc/monitorResolver.ts
@@ -0,0 +1,78 @@
+import { spawnSync } from "node:child_process";
+
+/**
+ * Represents a Windows monitor handle and its physical desktop coordinates.
+ */
+export interface WinMonitorHandle {
+ handle: number;
+ x: number;
+ y: number;
+ width: number;
+ height: number;
+}
+
+/**
+ * Retrieves raw HMONITOR handles from the Windows OS using a PowerShell bridge.
+ * This is necessary because Electron's display IDs are often internal hashes that
+ * cannot be used directly with native Windows APIs like Graphics Capture (WGC).
+ */
+export function getMonitorHandles(): WinMonitorHandle[] {
+ if (process.platform !== "win32") return [];
+
+ // PowerShell snippet that uses P/Invoke to call EnumDisplayMonitors and return raw handles + bounds.
+ const psScript = `
+Add-Type -TypeDefinition @"
+using System;
+using System.Runtime.InteropServices;
+using System.Collections.Generic;
+
+public class MonitorHelper {
+ [DllImport("user32.dll")]
+ public static extern bool EnumDisplayMonitors(IntPtr hdc, IntPtr lprcClip, MonitorEnumProc lpfnEnum, IntPtr dwData);
+
+ public delegate bool MonitorEnumProc(IntPtr hMonitor, IntPtr hdcMonitor, ref Rect lprcMonitor, IntPtr dwData);
+
+ [StructLayout(LayoutKind.Sequential)]
+ public struct Rect {
+ public int left;
+ public int top;
+ public int right;
+ public int bottom;
+ }
+
+ public static List GetMonitors() {
+ List result = new List();
+ EnumDisplayMonitors(IntPtr.Zero, IntPtr.Zero, (IntPtr hMonitor, IntPtr hdcMonitor, ref Rect lprcMonitor, IntPtr dwData) => {
+ result.Add(string.Format("{0}|{1}|{2}|{3}|{4}", hMonitor.ToInt64(), lprcMonitor.left, lprcMonitor.top, lprcMonitor.right - lprcMonitor.left, lprcMonitor.bottom - lprcMonitor.top));
+ return true;
+ }, IntPtr.Zero);
+ return result;
+ }
+}
+"@
+[MonitorHelper]::GetMonitors()
+`.trim();
+
+ const result = spawnSync(
+ "powershell.exe",
+ ["-NoProfile", "-NonInteractive", "-Command", psScript],
+ {
+ encoding: "utf-8",
+ timeout: 5000,
+ },
+ );
+
+ if (result.error || result.status !== 0) {
+ // Silent failure is preferred; the caller will fall back to coordinate-based matching.
+ return [];
+ }
+
+ return result.stdout
+ .split("\n")
+ .map((line) => line.trim())
+ .filter((line) => line.length > 0)
+ .map((line) => {
+ const [handle, x, y, width, height] = line.split("|").map(Number);
+ return { handle, x, y, width, height };
+ });
+}
diff --git a/electron/ipc/mouseTracking.ts b/electron/ipc/mouseTracking.ts
deleted file mode 100644
index 47d317174..000000000
--- a/electron/ipc/mouseTracking.ts
+++ /dev/null
@@ -1,155 +0,0 @@
-// uiohook-napi [WIP: scoping out mouse tracking with cross platform support for potential auto zoom/ post processing cursor effects]
-// not currently being used.
-
-import { uIOhook } from 'uiohook-napi'
-
-let isMouseTrackingActive = false
-let isHookStarted = false
-let recordingStartTime: number = 0
-let mouseEventData: MouseEvent[] = []
-
-export interface MouseEvent {
- type: 'move' | 'down' | 'up' | 'click'
- timestamp: number // milliseconds since recording started
- x: number
- y: number
- button?: unknown
- clicks?: number
-}
-
-export interface MouseTrackingSession {
- startTime: number
- events: MouseEvent[]
- duration: number
-}
-
-export function startMouseTracking() {
- if (isMouseTrackingActive) {
- return { success: false, message: 'Already tracking' }
- }
-
- isMouseTrackingActive = true
-
- // Reset data for new recording session
- recordingStartTime = performance.now()
- mouseEventData = []
-
- // Only start the hook once
- if (!isHookStarted) {
- setupMouseEventListeners()
-
- try {
- uIOhook.start()
- isHookStarted = true
- return { success: true, message: 'Mouse tracking started', startTime: recordingStartTime }
- } catch (error) {
- console.error('Failed to start mouse tracking:', error)
- isMouseTrackingActive = false
- return { success: false, message: 'Failed to start hook', error }
- }
- } else {
- return { success: true, message: 'Mouse tracking resumed', startTime: recordingStartTime }
- }
-}
-
-export function stopMouseTracking(): { success: boolean; message: string; data?: MouseTrackingSession } {
- if (!isMouseTrackingActive) {
- return { success: false, message: 'Not currently tracking' }
- }
-
- isMouseTrackingActive = false
-
- const duration = performance.now() - recordingStartTime
-
- const session: MouseTrackingSession = {
- startTime: recordingStartTime,
- events: mouseEventData,
- duration: duration
- }
-
- return {
- success: true,
- message: 'Mouse tracking stopped',
- data: session
- }
-}
-
-function setupMouseEventListeners() {
- // Track mouse movement
- uIOhook.on('mousemove', (e) => {
- if (isMouseTrackingActive) {
- const timestamp = performance.now() - recordingStartTime
- const event: MouseEvent = {
- type: 'move',
- timestamp,
- x: e.x,
- y: e.y
- }
- mouseEventData.push(event)
- }
- })
-
- // Track mouse button press
- uIOhook.on('mousedown', (e) => {
- if (isMouseTrackingActive) {
- const timestamp = performance.now() - recordingStartTime
- const event: MouseEvent = {
- type: 'down',
- timestamp,
- x: e.x,
- y: e.y,
- button: e.button,
- clicks: e.clicks
- }
- mouseEventData.push(event)
- }
- })
-
- // Track mouse button release
- uIOhook.on('mouseup', (e) => {
- if (isMouseTrackingActive) {
- const timestamp = performance.now() - recordingStartTime
- const event: MouseEvent = {
- type: 'up',
- timestamp,
- x: e.x,
- y: e.y,
- button: e.button
- }
- mouseEventData.push(event)
- }
- })
-
- // Track complete click events
- uIOhook.on('click', (e) => {
- if (isMouseTrackingActive) {
- const timestamp = performance.now() - recordingStartTime
- const event: MouseEvent = {
- type: 'click',
- timestamp,
- x: e.x,
- y: e.y,
- button: e.button,
- clicks: e.clicks
- }
- mouseEventData.push(event)
- }
- })
-}
-
-export function getTrackingData(): MouseEvent[] {
- return [...mouseEventData]
-}
-
-export function cleanupMouseTracking() {
- if (isHookStarted) {
- try {
- uIOhook.stop()
- isHookStarted = false
- isMouseTrackingActive = false
- mouseEventData = []
- } catch (error) {
- console.error('Error cleaning up mouse tracking:', error)
- }
- }
-}
diff --git a/electron/ipc/nativeVideoExport.test.ts b/electron/ipc/nativeVideoExport.test.ts
new file mode 100644
index 000000000..47a9a273e
--- /dev/null
+++ b/electron/ipc/nativeVideoExport.test.ts
@@ -0,0 +1,325 @@
+import { describe, expect, it } from "vitest";
+import { ATEMPO_FILTER_EPSILON } from "./ffmpeg/filters";
+import {
+ buildEditedTrackSourceAudioFilter,
+ buildNativeConcatArgs,
+ buildNativeCudaOverlayStaticLayoutArgs,
+ buildNativeCudaScaleCpuPadStaticLayoutArgs,
+ buildNativePrecompositedStaticLayoutArgs,
+ buildNativeStaticBackgroundRenderArgs,
+ buildNativeStaticLayoutChunks,
+ buildNativeVideoExportArgs,
+ buildTrimmedSourceAudioFilter,
+ createNativeSquircleMaskPgmBuffer,
+ FFMPEG_BT709_VIDEO_COLOR_ARGS,
+ isNativeCudaOutOfMemory,
+} from "./nativeVideoExport";
+
+describe("buildTrimmedSourceAudioFilter", () => {
+ it("concatenates trimmed source segments into a single output label", () => {
+ expect(
+ buildTrimmedSourceAudioFilter([
+ { startMs: 0, endMs: 2_000 },
+ { startMs: 4_000, endMs: 6_000 },
+ ]),
+ ).toBe(
+ "[1:a]atrim=start=0.000:end=2.000,asetpts=PTS-STARTPTS[trimmed_audio_0];" +
+ "[1:a]atrim=start=4.000:end=6.000,asetpts=PTS-STARTPTS[trimmed_audio_1];" +
+ "[trimmed_audio_0][trimmed_audio_1]concat=n=2:v=0:a=1[aout]",
+ );
+ });
+});
+
+describe("buildEditedTrackSourceAudioFilter", () => {
+ it("builds a concat filtergraph that applies tempo filters for speed changes", () => {
+ const filter = buildEditedTrackSourceAudioFilter(
+ [
+ { startMs: 0, endMs: 2_000, speed: 1 },
+ { startMs: 2_000, endMs: 6_000, speed: 1.5 },
+ ],
+ 44_100,
+ );
+
+ expect(filter).toBe(
+ "[1:a]atrim=start=0.000:end=2.000,asetpts=PTS-STARTPTS[edited_audio_0];" +
+ "[1:a]atrim=start=2.000:end=6.000,asetpts=PTS-STARTPTS,atempo=1.500000[edited_audio_1];" +
+ "[edited_audio_0][edited_audio_1]concat=n=2:v=0:a=1[aout]",
+ );
+ });
+
+ it("builds a filtergraph for slowdown segments with a tempo filter", () => {
+ const filter = buildEditedTrackSourceAudioFilter(
+ [{ startMs: 0, endMs: 2_000, speed: 0.5 }],
+ 44_100,
+ );
+
+ expect(filter).toBe(
+ "[1:a]atrim=start=0.000:end=2.000,asetpts=PTS-STARTPTS,atempo=0.500000[edited_audio_0];" +
+ "[edited_audio_0]anull[aout]",
+ );
+ });
+
+ it("treats near-unity speed changes as unchanged audio", () => {
+ const filter = buildEditedTrackSourceAudioFilter(
+ [{ startMs: 0, endMs: 2_000, speed: 1.0002 }],
+ 44_100,
+ );
+
+ expect(filter).toBe(
+ "[1:a]atrim=start=0.000:end=2.000,asetpts=PTS-STARTPTS[edited_audio_0];" +
+ "[edited_audio_0]anull[aout]",
+ );
+ });
+
+ it("treats exact epsilon speed changes as unchanged audio", () => {
+ for (const speed of [1 - ATEMPO_FILTER_EPSILON, 1 + ATEMPO_FILTER_EPSILON]) {
+ const filter = buildEditedTrackSourceAudioFilter(
+ [{ startMs: 0, endMs: 2_000, speed }],
+ 44_100,
+ );
+
+ expect(filter).toBe(
+ "[1:a]atrim=start=0.000:end=2.000,asetpts=PTS-STARTPTS[edited_audio_0];" +
+ "[edited_audio_0]anull[aout]",
+ );
+ }
+ });
+
+ it("returns null when the edited-track filtergraph inputs are incomplete", () => {
+ expect(buildEditedTrackSourceAudioFilter([], 44_100)).toBeNull();
+ expect(
+ buildEditedTrackSourceAudioFilter(
+ [{ startMs: 0, endMs: 2_000, speed: 1.5 }],
+ Number.NaN,
+ ),
+ ).toBeNull();
+ });
+
+ it("returns null when the edited-track segments are malformed", () => {
+ expect(
+ buildEditedTrackSourceAudioFilter(
+ [{ startMs: Number.NaN, endMs: 2_000, speed: 1.5 }],
+ 44_100,
+ ),
+ ).toBeNull();
+ expect(
+ buildEditedTrackSourceAudioFilter([{ startMs: 0, endMs: 2_000, speed: 0 }], 44_100),
+ ).toBeNull();
+ expect(
+ buildEditedTrackSourceAudioFilter([{ startMs: 0, endMs: 2_000, speed: -1 }], 44_100),
+ ).toBeNull();
+ expect(
+ buildEditedTrackSourceAudioFilter(
+ [{ startMs: 0, endMs: 2_000, speed: Number.NaN }],
+ 44_100,
+ ),
+ ).toBeNull();
+ expect(
+ buildEditedTrackSourceAudioFilter([{ startMs: 0, endMs: 2_000, speed: 1 }], 0.4),
+ ).toBeNull();
+ expect(
+ buildEditedTrackSourceAudioFilter([{ startMs: -100, endMs: 2_000, speed: 1 }], 44_100),
+ ).toBeNull();
+ expect(
+ buildEditedTrackSourceAudioFilter(
+ [{ startMs: 0, endMs: 2_000, speed: Number.MAX_SAFE_INTEGER }],
+ 44_100,
+ ),
+ ).toBeNull();
+ });
+});
+
+describe("native static layout command builders", () => {
+ const baseConfig = {
+ inputPath: "input.mp4",
+ outputPath: "chunk.mp4",
+ width: 1920,
+ height: 1080,
+ frameRate: 60,
+ bitrate: 8_000_000,
+ encodingMode: "fast" as const,
+ contentWidth: 1536,
+ contentHeight: 864,
+ offsetX: 192,
+ offsetY: 108,
+ backgroundColor: "#101010",
+ startSec: 120,
+ durationSec: 60,
+ };
+
+ it("builds the primary CUDA overlay layout command", () => {
+ const args = buildNativeCudaOverlayStaticLayoutArgs(baseConfig);
+
+ expect(args).toContain("-filter_complex");
+ expect(args).toContain(
+ "color=c=0x101010:s=1920x1080:r=60:d=60.000,format=nv12,setrange=limited,hwupload_cuda[bg];" +
+ "[0:v]scale_cuda=w=1536:h=864:format=nv12:passthrough=0,hwdownload,format=nv12,scale=in_range=auto:out_range=tv:sws_dither=bayer,fps=60,hwupload_cuda[fg];" +
+ "[bg][fg]overlay_cuda=192:108:shortest=0:repeatlast=1:eof_action=repeat,trim=duration=60.000,setpts=PTS-STARTPTS[out]",
+ );
+ expect(args).toContain("h264_nvenc");
+ expect(args).toContain("p1");
+ expect(args).not.toContain("yuv420p");
+ expect(args).toEqual(expect.arrayContaining(["-ss", "120.000", "-t", "60.000"]));
+ expect(args).toEqual(expect.arrayContaining([...FFMPEG_BT709_VIDEO_COLOR_ARGS]));
+ });
+
+ it("converts full-range canvas pixels and tags native H.264 as BT.709 video range", () => {
+ const args = buildNativeVideoExportArgs(
+ "h264_videotoolbox",
+ {
+ width: 1920,
+ height: 1080,
+ frameRate: 30,
+ bitrate: 30_000_000,
+ encodingMode: "quality",
+ },
+ "out.mp4",
+ );
+
+ expect(args).toEqual(
+ expect.arrayContaining([
+ "-vf",
+ "vflip,scale=in_range=full:out_range=tv:sws_dither=bayer",
+ "-colorspace",
+ "bt709",
+ "-color_primaries",
+ "bt709",
+ "-color_trc",
+ "bt709",
+ "-color_range",
+ "tv",
+ ]),
+ );
+ });
+
+ it("builds the stable CUDA scale plus CPU pad fallback command", () => {
+ const args = buildNativeCudaScaleCpuPadStaticLayoutArgs(baseConfig);
+
+ expect(args).toEqual(
+ expect.arrayContaining([
+ "-vf",
+ "scale_cuda=w=1536:h=864:format=nv12:passthrough=0,hwdownload,format=nv12,scale=in_range=auto:out_range=tv:sws_dither=bayer,fps=60,pad=w=1920:h=1080:x=192:y=108:color=0x101010",
+ "-map",
+ "0:v:0",
+ "-an",
+ ]),
+ );
+ expect(args).toEqual(expect.arrayContaining([...FFMPEG_BT709_VIDEO_COLOR_ARGS]));
+ });
+
+ it("sanitizes unsupported background colors to the safe dark fallback", () => {
+ const args = buildNativeCudaScaleCpuPadStaticLayoutArgs({
+ ...baseConfig,
+ backgroundColor: "linear-gradient(red, blue)",
+ });
+
+ expect(args).toContain(
+ "scale_cuda=w=1536:h=864:format=nv12:passthrough=0,hwdownload,format=nv12,scale=in_range=auto:out_range=tv:sws_dither=bayer,fps=60,pad=w=1920:h=1080:x=192:y=108:color=0x101010",
+ );
+ });
+
+ it("builds a precomposited background command for image wallpaper and shadow", () => {
+ const args = buildNativeStaticBackgroundRenderArgs({
+ ...baseConfig,
+ outputPath: "background.png",
+ backgroundImagePath: "wallpaper.jpg",
+ maskPath: "mask.pgm",
+ shadowIntensity: 0.67,
+ });
+ const filterComplex = args[args.indexOf("-filter_complex") + 1];
+
+ expect(args).toEqual(expect.arrayContaining(["-i", "wallpaper.jpg", "-i", "mask.pgm"]));
+ expect(filterComplex).toContain(
+ "scale=w=1920:h=1080:force_original_aspect_ratio=increase,crop=w=1920:h=1080",
+ );
+ expect(filterComplex).toContain("split=3");
+ expect(filterComplex).toContain("gblur=sigma=32.16:steps=2");
+ expect(filterComplex).toContain("overlay=x=119:y=35:format=auto");
+ expect(args).toEqual(expect.arrayContaining(["-frames:v", "1", "background.png"]));
+ });
+
+ it("pre-blurs image wallpapers for native fallback static backgrounds", () => {
+ const args = buildNativeStaticBackgroundRenderArgs({
+ ...baseConfig,
+ outputPath: "background.png",
+ backgroundImagePath: "wallpaper.jpg",
+ backgroundBlurPx: 36,
+ });
+ const filterComplex = args[args.indexOf("-filter_complex") + 1];
+
+ expect(filterComplex).toContain("[bg0]gblur=sigma=36:steps=2[bg_blur]");
+ expect(filterComplex).toContain("[bg_blur]format=rgba[out]");
+ });
+
+ it("builds a precomposited static layout command with a squircle alpha mask", () => {
+ const args = buildNativePrecompositedStaticLayoutArgs({
+ ...baseConfig,
+ staticBackgroundPath: "background.png",
+ maskPath: "mask.pgm",
+ borderRadius: 12.5,
+ });
+ const filterComplex = args[args.indexOf("-filter_complex") + 1];
+
+ expect(args).toEqual(expect.arrayContaining(["-i", "background.png", "-i", "mask.pgm"]));
+ expect(filterComplex).toContain(
+ "scale_cuda=w=1536:h=864:format=nv12:passthrough=0,hwdownload,format=nv12,scale=in_range=auto:out_range=full,fps=60,format=rgba",
+ );
+ expect(filterComplex).toContain("[fgbase][mask]alphamerge[fg]");
+ expect(filterComplex).toContain("overlay=x=192:y=108:format=auto");
+ expect(filterComplex).toContain(
+ "scale=in_range=full:out_range=tv:sws_dither=bayer,format=yuv420p[out]",
+ );
+ expect(args).toContain("h264_nvenc");
+ expect(args).toEqual(expect.arrayContaining(["-pix_fmt", "yuv420p"]));
+ expect(args).toEqual(expect.arrayContaining([...FFMPEG_BT709_VIDEO_COLOR_ARGS]));
+ });
+
+ it("creates an opaque PGM mask for square video corners and a partial mask for radius", () => {
+ const squareMask = createNativeSquircleMaskPgmBuffer(4, 4, 0);
+ expect(squareMask.subarray(squareMask.length - 16)).toEqual(Buffer.alloc(16, 255));
+
+ const roundedMask = createNativeSquircleMaskPgmBuffer(8, 8, 4);
+ const header = Buffer.from("P5\n8 8\n255\n", "ascii");
+ const pixels = roundedMask.subarray(header.length);
+ expect(pixels[0]).toBeLessThan(255);
+ expect(pixels[4 * 8 + 4]).toBe(255);
+ });
+
+ it("splits long exports into bounded chunks", () => {
+ expect(buildNativeStaticLayoutChunks(367.5, 120)).toEqual([
+ { index: 0, startSec: 0, durationSec: 120 },
+ { index: 1, startSec: 120, durationSec: 120 },
+ { index: 2, startSec: 240, durationSec: 120 },
+ { index: 3, startSec: 360, durationSec: 7.5 },
+ ]);
+ });
+
+ it("builds concat args for already encoded chunks", () => {
+ expect(buildNativeConcatArgs({ listPath: "chunks.txt", outputPath: "out.mp4" })).toEqual([
+ "-y",
+ "-hide_banner",
+ "-loglevel",
+ "error",
+ "-f",
+ "concat",
+ "-safe",
+ "0",
+ "-i",
+ "chunks.txt",
+ "-c",
+ "copy",
+ "-movflags",
+ "+faststart",
+ "out.mp4",
+ ]);
+ });
+
+ it("detects CUDA OOM as a retryable fast-path failure", () => {
+ expect(
+ isNativeCudaOutOfMemory(
+ "cu->cuMemAlloc(&data, size) failed -> CUDA_ERROR_OUT_OF_MEMORY: out of memory",
+ ),
+ ).toBe(true);
+ expect(isNativeCudaOutOfMemory("FFmpeg exited with code 1")).toBe(false);
+ });
+});
diff --git a/electron/ipc/nativeVideoExport.ts b/electron/ipc/nativeVideoExport.ts
new file mode 100644
index 000000000..8b9a0fa32
--- /dev/null
+++ b/electron/ipc/nativeVideoExport.ts
@@ -0,0 +1,771 @@
+import {
+ getShadowFilterPadding,
+ VIDEO_SHADOW_LAYER_PROFILES,
+} from "../../src/lib/exporter/shadowProfile";
+import { getSquirclePathPoints } from "../../src/lib/geometry/squircle";
+import { ATEMPO_FILTER_EPSILON, buildAtempoFilters } from "./ffmpeg/filters";
+
+const NATIVE_EXPORT_INPUT_BYTES_PER_PIXEL = 4;
+const MIN_EDITED_TRACK_TEMPO_SPEED = 0.5;
+const MAX_EDITED_TRACK_TEMPO_SPEED = 2;
+
+export const FFMPEG_BT709_VIDEO_COLOR_ARGS = [
+ "-colorspace",
+ "bt709",
+ "-color_primaries",
+ "bt709",
+ "-color_trc",
+ "bt709",
+ "-color_range",
+ "tv",
+] as const;
+
+const FFMPEG_AUTO_TO_VIDEO_RANGE_FILTER = "scale=in_range=auto:out_range=tv:sws_dither=bayer";
+const FFMPEG_AUTO_TO_FULL_RANGE_FILTER = "scale=in_range=auto:out_range=full";
+const FFMPEG_FULL_TO_VIDEO_RANGE_FILTER = "scale=in_range=full:out_range=tv:sws_dither=bayer";
+
+export type NativeExportEncodingMode = "fast" | "balanced" | "quality";
+
+export type NativeVideoExportAudioMode = "none" | "copy-source" | "trim-source" | "edited-track";
+export type NativeVideoExportEditedTrackStrategy =
+ | "filtergraph-fast-path"
+ | "offline-render-fallback";
+
+export interface NativeVideoExportStartOptions {
+ width: number;
+ height: number;
+ frameRate: number;
+ bitrate: number;
+ encodingMode: NativeExportEncodingMode;
+ inputMode?: "rawvideo" | "h264-stream";
+}
+
+export interface NativeVideoExportAudioSegment {
+ startMs: number;
+ endMs: number;
+}
+
+export interface NativeVideoExportEditedTrackSegment extends NativeVideoExportAudioSegment {
+ speed: number;
+}
+
+export interface NativeVideoExportFinishOptions {
+ audioMode?: NativeVideoExportAudioMode;
+ audioSourcePath?: string | null;
+ audioSourceCodec?: string | null;
+ audioSourceSampleRate?: number;
+ outputDurationSec?: number;
+ trimSegments?: NativeVideoExportAudioSegment[];
+ editedTrackStrategy?: NativeVideoExportEditedTrackStrategy;
+ editedTrackSegments?: NativeVideoExportEditedTrackSegment[];
+ editedAudioData?: ArrayBuffer;
+ editedAudioMimeType?: string | null;
+}
+
+export interface NativeVideoAudioMuxMetrics {
+ tempVideoWriteMs?: number;
+ tempEditedAudioWriteMs?: number;
+ ffmpegExecMs?: number;
+ muxedVideoReadMs?: number;
+ tempVideoBytes?: number;
+ tempEditedAudioBytes?: number;
+ muxedVideoBytes?: number;
+}
+
+export type NativeStaticLayoutBackend =
+ | "cuda-overlay"
+ | "cuda-scale-cpu-pad"
+ | "cuda-static-composite"
+ | "nvidia-cuda-compositor"
+ | "windows-d3d11-compositor";
+
+export interface NativeStaticLayoutExportArgsConfig {
+ inputPath: string;
+ outputPath: string;
+ width: number;
+ height: number;
+ frameRate: number;
+ bitrate: number;
+ encodingMode: NativeExportEncodingMode;
+ contentWidth: number;
+ contentHeight: number;
+ offsetX: number;
+ offsetY: number;
+ sourceCropX?: number;
+ sourceCropY?: number;
+ sourceCropWidth?: number;
+ sourceCropHeight?: number;
+ backgroundColor: string;
+ backgroundImagePath?: string | null;
+ backgroundBlurPx?: number;
+ staticBackgroundPath?: string | null;
+ maskPath?: string | null;
+ borderRadius?: number;
+ shadowIntensity?: number;
+ startSec?: number;
+ durationSec?: number;
+}
+
+export interface NativeStaticLayoutChunk {
+ index: number;
+ startSec: number;
+ durationSec: number;
+}
+
+export function getNativeVideoInputByteSize(width: number, height: number): number {
+ return width * height * NATIVE_EXPORT_INPUT_BYTES_PER_PIXEL;
+}
+
+export function parseAvailableFfmpegEncoders(stdout: string): Set {
+ const encoders = new Set();
+
+ for (const line of stdout.split(/\r?\n/)) {
+ const match = line.match(/^\s*[A-Z.]{6}\s+([a-z0-9_]+)/i);
+ if (match?.[1]) {
+ encoders.add(match[1]);
+ }
+ }
+
+ return encoders;
+}
+
+export function getPreferredNativeVideoEncoders(platform: NodeJS.Platform): string[] {
+ switch (platform) {
+ case "darwin":
+ return ["h264_videotoolbox", "libx264"];
+ case "win32":
+ return ["h264_nvenc", "h264_qsv", "h264_amf", "h264_mf", "libx264"];
+ case "linux":
+ return ["h264_nvenc", "h264_qsv", "libx264"];
+ default:
+ return ["libx264"];
+ }
+}
+
+function getLibx264ModeArgs(encodingMode: NativeExportEncodingMode): string[] {
+ switch (encodingMode) {
+ case "fast":
+ return ["-preset", "ultrafast", "-tune", "zerolatency"];
+ case "quality":
+ return ["-preset", "slow"];
+ case "balanced":
+ default:
+ return ["-preset", "medium"];
+ }
+}
+
+function getBitrateArgs(bitrate: number): string[] {
+ const effectiveBitrate = Math.max(1_500_000, Math.round(bitrate));
+ const maxRate = Math.max(effectiveBitrate, Math.round(effectiveBitrate * 1.2));
+ const bufferSize = Math.max(maxRate * 2, effectiveBitrate * 2);
+
+ return [
+ "-b:v",
+ String(effectiveBitrate),
+ "-maxrate",
+ String(maxRate),
+ "-bufsize",
+ String(bufferSize),
+ ];
+}
+
+function getNvencStaticLayoutModeArgs(encodingMode: NativeExportEncodingMode): string[] {
+ const lowLatencyRateControlArgs = [
+ "-rc",
+ "vbr",
+ "-multipass",
+ "disabled",
+ "-rc-lookahead",
+ "0",
+ "-surfaces",
+ "32",
+ ];
+ switch (encodingMode) {
+ case "quality":
+ return ["-preset", "p1", "-tune", "hq", ...lowLatencyRateControlArgs];
+ case "balanced":
+ return ["-preset", "p1", "-tune", "ll", ...lowLatencyRateControlArgs];
+ case "fast":
+ default:
+ return ["-preset", "p1", "-tune", "ull", ...lowLatencyRateControlArgs];
+ }
+}
+
+function formatFfmpegColor(value: string): string {
+ const trimmed = value.trim();
+ const hex = trimmed.match(/^#?([0-9a-f]{6})$/i)?.[1];
+ return hex ? `0x${hex.toLowerCase()}` : "0x101010";
+}
+
+function clampUnitInterval(value: number): number {
+ if (!Number.isFinite(value)) {
+ return 0;
+ }
+
+ return Math.min(1, Math.max(0, value));
+}
+
+function formatFfmpegNumber(value: number): string {
+ return Number.isInteger(value)
+ ? String(value)
+ : value.toFixed(6).replace(/0+$/, "").replace(/\.$/, "");
+}
+
+function isPointInsidePolygon(x: number, y: number, points: Array<{ x: number; y: number }>) {
+ let inside = false;
+ for (
+ let index = 0, previousIndex = points.length - 1;
+ index < points.length;
+ previousIndex = index++
+ ) {
+ const current = points[index];
+ const previous = points[previousIndex];
+ const intersects =
+ current.y > y !== previous.y > y &&
+ x < ((previous.x - current.x) * (y - current.y)) / (previous.y - current.y) + current.x;
+
+ if (intersects) {
+ inside = !inside;
+ }
+ }
+
+ return inside;
+}
+
+export function createNativeSquircleMaskPgmBuffer(
+ width: number,
+ height: number,
+ radius: number,
+): Buffer {
+ const safeWidth = Math.max(1, Math.round(width));
+ const safeHeight = Math.max(1, Math.round(height));
+ const clampedRadius = Math.min(Math.max(0, radius), Math.min(safeWidth, safeHeight) / 2);
+ const header = Buffer.from(`P5\n${safeWidth} ${safeHeight}\n255\n`, "ascii");
+ const pixels = Buffer.alloc(safeWidth * safeHeight, 255);
+
+ if (clampedRadius <= 0.5) {
+ return Buffer.concat([header, pixels]);
+ }
+
+ const points = getSquirclePathPoints({
+ x: 0,
+ y: 0,
+ width: safeWidth,
+ height: safeHeight,
+ radius: clampedRadius,
+ });
+ const samples = [
+ [0.25, 0.25],
+ [0.75, 0.25],
+ [0.25, 0.75],
+ [0.75, 0.75],
+ ] as const;
+
+ for (let y = 0; y < safeHeight; y += 1) {
+ for (let x = 0; x < safeWidth; x += 1) {
+ let coveredSamples = 0;
+ for (const [sampleX, sampleY] of samples) {
+ if (isPointInsidePolygon(x + sampleX, y + sampleY, points)) {
+ coveredSamples += 1;
+ }
+ }
+ pixels[y * safeWidth + x] = Math.round((coveredSamples / samples.length) * 255);
+ }
+ }
+
+ return Buffer.concat([header, pixels]);
+}
+
+function pushFfmpegTimeSliceArgs(
+ args: string[],
+ startSec: number | undefined,
+ durationSec: number | undefined,
+) {
+ if (Number.isFinite(startSec) && (startSec ?? 0) > 0) {
+ args.push("-ss", formatFfmpegSeconds((startSec ?? 0) * 1000));
+ }
+
+ if (Number.isFinite(durationSec) && (durationSec ?? 0) > 0) {
+ args.push("-t", formatFfmpegSeconds((durationSec ?? 0) * 1000));
+ }
+}
+
+export function buildNativeVideoExportArgs(
+ encoder: string,
+ options: NativeVideoExportStartOptions,
+ outputPath: string,
+): string[] {
+ const args = [
+ "-y",
+ "-hide_banner",
+ "-loglevel",
+ "error",
+ "-f",
+ "rawvideo",
+ "-pix_fmt",
+ "rgba",
+ "-s:v",
+ `${options.width}x${options.height}`,
+ "-framerate",
+ String(options.frameRate),
+ "-i",
+ "pipe:0",
+ "-vf",
+ `vflip,${FFMPEG_FULL_TO_VIDEO_RANGE_FILTER}`,
+ "-an",
+ "-c:v",
+ encoder,
+ "-g",
+ String(Math.max(1, Math.round(options.frameRate * 5))),
+ ...getBitrateArgs(options.bitrate),
+ ];
+
+ if (encoder === "libx264") {
+ args.push(...getLibx264ModeArgs(options.encodingMode));
+ }
+
+ args.push(
+ "-pix_fmt",
+ "yuv420p",
+ ...FFMPEG_BT709_VIDEO_COLOR_ARGS,
+ "-movflags",
+ "+faststart",
+ outputPath,
+ );
+ return args;
+}
+
+export function buildNativeCudaOverlayStaticLayoutArgs(
+ config: NativeStaticLayoutExportArgsConfig,
+): string[] {
+ const backgroundColor = formatFfmpegColor(config.backgroundColor);
+ const durationSec = formatFfmpegSeconds(Math.max(0.001, config.durationSec ?? 1) * 1000);
+ const args = ["-y", "-hide_banner", "-loglevel", "error"];
+ pushFfmpegTimeSliceArgs(args, config.startSec, config.durationSec);
+ args.push(
+ "-hwaccel",
+ "cuda",
+ "-hwaccel_output_format",
+ "cuda",
+ "-i",
+ config.inputPath,
+ "-filter_complex",
+ `color=c=${backgroundColor}:s=${config.width}x${config.height}:r=${config.frameRate}:d=${durationSec},format=nv12,setrange=limited,hwupload_cuda[bg];[0:v]scale_cuda=w=${config.contentWidth}:h=${config.contentHeight}:format=nv12:passthrough=0,hwdownload,format=nv12,${FFMPEG_AUTO_TO_VIDEO_RANGE_FILTER},fps=${config.frameRate},hwupload_cuda[fg];[bg][fg]overlay_cuda=${config.offsetX}:${config.offsetY}:shortest=0:repeatlast=1:eof_action=repeat,trim=duration=${durationSec},setpts=PTS-STARTPTS[out]`,
+ "-map",
+ "[out]",
+ "-an",
+ "-r",
+ String(config.frameRate),
+ "-c:v",
+ "h264_nvenc",
+ ...getNvencStaticLayoutModeArgs(config.encodingMode),
+ ...getBitrateArgs(config.bitrate),
+ ...FFMPEG_BT709_VIDEO_COLOR_ARGS,
+ "-movflags",
+ "+faststart",
+ config.outputPath,
+ );
+ return args;
+}
+
+export function buildNativeCudaScaleCpuPadStaticLayoutArgs(
+ config: NativeStaticLayoutExportArgsConfig,
+): string[] {
+ const backgroundColor = formatFfmpegColor(config.backgroundColor);
+ const args = ["-y", "-hide_banner", "-loglevel", "error"];
+ pushFfmpegTimeSliceArgs(args, config.startSec, config.durationSec);
+ args.push(
+ "-hwaccel",
+ "cuda",
+ "-hwaccel_output_format",
+ "cuda",
+ "-i",
+ config.inputPath,
+ "-vf",
+ `scale_cuda=w=${config.contentWidth}:h=${config.contentHeight}:format=nv12:passthrough=0,hwdownload,format=nv12,${FFMPEG_AUTO_TO_VIDEO_RANGE_FILTER},fps=${config.frameRate},pad=w=${config.width}:h=${config.height}:x=${config.offsetX}:y=${config.offsetY}:color=${backgroundColor}`,
+ "-map",
+ "0:v:0",
+ "-an",
+ "-r",
+ String(config.frameRate),
+ "-c:v",
+ "h264_nvenc",
+ ...getNvencStaticLayoutModeArgs(config.encodingMode),
+ ...getBitrateArgs(config.bitrate),
+ "-pix_fmt",
+ "yuv420p",
+ ...FFMPEG_BT709_VIDEO_COLOR_ARGS,
+ "-movflags",
+ "+faststart",
+ config.outputPath,
+ );
+ return args;
+}
+
+export function buildNativeStaticBackgroundRenderArgs(
+ config: NativeStaticLayoutExportArgsConfig,
+): string[] {
+ const backgroundColor = formatFfmpegColor(config.backgroundColor);
+ const backgroundBlurPx = Math.max(0, config.backgroundBlurPx ?? 0);
+ const args = ["-y", "-hide_banner", "-loglevel", "error"];
+ if (config.backgroundImagePath) {
+ args.push("-i", config.backgroundImagePath);
+ } else {
+ args.push(
+ "-f",
+ "lavfi",
+ "-i",
+ `color=c=${backgroundColor}:s=${config.width}x${config.height}:d=1`,
+ );
+ }
+
+ const shadowStrength = clampUnitInterval(config.shadowIntensity ?? 0);
+ const shadowLayers =
+ shadowStrength > 0 && config.maskPath
+ ? VIDEO_SHADOW_LAYER_PROFILES.map((layer) => ({
+ offsetY: layer.offsetScale * shadowStrength,
+ alpha: clampUnitInterval(layer.alphaScale * shadowStrength),
+ blur: Math.max(0, layer.blurScale * shadowStrength),
+ })).filter((layer) => layer.alpha > 0)
+ : [];
+
+ if (shadowLayers.length > 0 && config.maskPath) {
+ args.push("-i", config.maskPath);
+ }
+
+ const filterParts = [
+ config.backgroundImagePath
+ ? `[0:v]scale=w=${config.width}:h=${config.height}:force_original_aspect_ratio=increase,crop=w=${config.width}:h=${config.height},setsar=1,format=rgba[bg0]`
+ : "[0:v]format=rgba[bg0]",
+ ];
+ let currentBackgroundLabel = "bg0";
+ if (backgroundBlurPx > 0 && config.backgroundImagePath) {
+ filterParts.push(
+ `[${currentBackgroundLabel}]gblur=sigma=${formatFfmpegNumber(
+ Math.min(96, backgroundBlurPx),
+ )}:steps=2[bg_blur]`,
+ );
+ currentBackgroundLabel = "bg_blur";
+ }
+
+ if (shadowLayers.length > 0) {
+ filterParts.push(
+ `[1:v]format=gray,split=${shadowLayers.length}${shadowLayers
+ .map((_, index) => `[shadow_mask_source_${index}]`)
+ .join("")}`,
+ );
+ }
+
+ shadowLayers.forEach((layer, index) => {
+ const padding = getShadowFilterPadding(layer.blur, layer.offsetY);
+ const paddedWidth = config.contentWidth + padding * 2;
+ const paddedHeight = config.contentHeight + padding * 2;
+ const positionedMaskLabel = `shadow_mask_positioned_${index}`;
+ const shadowMaskLabel = `shadow_mask_${index}`;
+ const shadowColorLabel = `shadow_color_${index}`;
+ const shadowLabel = `shadow_${index}`;
+ const nextBackgroundLabel = `bg${index + 1}`;
+ const blurFilter =
+ layer.blur > 0 ? `,gblur=sigma=${formatFfmpegNumber(layer.blur)}:steps=2` : "";
+
+ filterParts.push(
+ `[shadow_mask_source_${index}]lut=y=val*${formatFfmpegNumber(
+ layer.alpha,
+ )},pad=w=${paddedWidth}:h=${paddedHeight}:x=${padding}:y=${Math.round(
+ padding + layer.offsetY,
+ )}:color=black${blurFilter}[${positionedMaskLabel}]`,
+ `[${positionedMaskLabel}]format=gray[${shadowMaskLabel}]`,
+ `color=c=black:s=${paddedWidth}x${paddedHeight}:d=1,format=rgba[${shadowColorLabel}]`,
+ `[${shadowColorLabel}][${shadowMaskLabel}]alphamerge[${shadowLabel}]`,
+ `[${currentBackgroundLabel}][${shadowLabel}]overlay=x=${
+ config.offsetX - padding
+ }:y=${config.offsetY - padding}:format=auto[${nextBackgroundLabel}]`,
+ );
+ currentBackgroundLabel = nextBackgroundLabel;
+ });
+
+ filterParts.push(`[${currentBackgroundLabel}]format=rgba[out]`);
+ args.push(
+ "-filter_complex",
+ filterParts.join(";"),
+ "-map",
+ "[out]",
+ "-frames:v",
+ "1",
+ config.outputPath,
+ );
+ return args;
+}
+
+export function buildNativePrecompositedStaticLayoutArgs(
+ config: NativeStaticLayoutExportArgsConfig,
+): string[] {
+ if (!config.staticBackgroundPath) {
+ throw new Error("Native precomposited static layout requires a static background path");
+ }
+
+ const durationSec = formatFfmpegSeconds(Math.max(0.001, config.durationSec ?? 1) * 1000);
+ const useMask = Boolean(config.maskPath && (config.borderRadius ?? 0) > 0.5);
+ const args = ["-y", "-hide_banner", "-loglevel", "error"];
+ pushFfmpegTimeSliceArgs(args, config.startSec, config.durationSec);
+ args.push(
+ "-hwaccel",
+ "cuda",
+ "-hwaccel_output_format",
+ "cuda",
+ "-i",
+ config.inputPath,
+ "-loop",
+ "1",
+ "-framerate",
+ String(config.frameRate),
+ "-t",
+ durationSec,
+ "-i",
+ config.staticBackgroundPath,
+ );
+
+ if (useMask && config.maskPath) {
+ args.push(
+ "-loop",
+ "1",
+ "-framerate",
+ String(config.frameRate),
+ "-t",
+ durationSec,
+ "-i",
+ config.maskPath,
+ );
+ }
+
+ const foregroundFilter = `[0:v]scale_cuda=w=${config.contentWidth}:h=${config.contentHeight}:format=nv12:passthrough=0,hwdownload,format=nv12,${FFMPEG_AUTO_TO_FULL_RANGE_FILTER},fps=${config.frameRate},format=rgba[fgbase]`;
+ const maskFilter = useMask ? ";[2:v]format=gray[mask];[fgbase][mask]alphamerge[fg]" : "";
+ const foregroundLabel = useMask ? "fg" : "fgbase";
+ const filterComplex = `${foregroundFilter}${maskFilter};[1:v]format=rgba[bg];[bg][${foregroundLabel}]overlay=x=${config.offsetX}:y=${config.offsetY}:format=auto,trim=duration=${durationSec},setpts=PTS-STARTPTS,${FFMPEG_FULL_TO_VIDEO_RANGE_FILTER},format=yuv420p[out]`;
+
+ args.push(
+ "-filter_complex",
+ filterComplex,
+ "-map",
+ "[out]",
+ "-an",
+ "-r",
+ String(config.frameRate),
+ "-c:v",
+ "h264_nvenc",
+ ...getNvencStaticLayoutModeArgs(config.encodingMode),
+ ...getBitrateArgs(config.bitrate),
+ "-pix_fmt",
+ "yuv420p",
+ ...FFMPEG_BT709_VIDEO_COLOR_ARGS,
+ "-movflags",
+ "+faststart",
+ config.outputPath,
+ );
+ return args;
+}
+
+export function buildNativeConcatArgs(config: { listPath: string; outputPath: string }): string[] {
+ return [
+ "-y",
+ "-hide_banner",
+ "-loglevel",
+ "error",
+ "-f",
+ "concat",
+ "-safe",
+ "0",
+ "-i",
+ config.listPath,
+ "-c",
+ "copy",
+ "-movflags",
+ "+faststart",
+ config.outputPath,
+ ];
+}
+
+export function buildNativeStaticLayoutChunks(
+ durationSec: number,
+ chunkDurationSec: number,
+): NativeStaticLayoutChunk[] {
+ if (!Number.isFinite(durationSec) || durationSec <= 0) {
+ return [];
+ }
+
+ const safeChunkDuration = Math.max(1, Math.min(300, Math.floor(chunkDurationSec)));
+ const chunks: NativeStaticLayoutChunk[] = [];
+ for (
+ let startSec = 0, index = 0;
+ startSec < durationSec;
+ startSec += safeChunkDuration, index++
+ ) {
+ chunks.push({
+ index,
+ startSec,
+ durationSec: Math.min(safeChunkDuration, durationSec - startSec),
+ });
+ }
+
+ return chunks;
+}
+
+export function isNativeCudaOutOfMemory(stderr: string): boolean {
+ return /CUDA_ERROR_OUT_OF_MEMORY|cuMemAlloc.+out of memory/i.test(stderr);
+}
+
+function formatFfmpegSeconds(milliseconds: number): string {
+ return (milliseconds / 1000).toFixed(3);
+}
+
+export function buildTrimmedSourceAudioFilter(
+ segments: NativeVideoExportAudioSegment[],
+): string | null {
+ if (segments.length === 0) {
+ return null;
+ }
+
+ const filterParts: string[] = [];
+ const segmentLabels: string[] = [];
+
+ segments.forEach((segment, index) => {
+ const label = `trimmed_audio_${index}`;
+ filterParts.push(
+ `[1:a]atrim=start=${formatFfmpegSeconds(segment.startMs)}:end=${formatFfmpegSeconds(segment.endMs)},asetpts=PTS-STARTPTS[${label}]`,
+ );
+ segmentLabels.push(`[${label}]`);
+ });
+
+ if (segmentLabels.length === 1) {
+ filterParts.push(`${segmentLabels[0]}anull[aout]`);
+ } else {
+ filterParts.push(`${segmentLabels.join("")}concat=n=${segmentLabels.length}:v=0:a=1[aout]`);
+ }
+
+ return filterParts.join(";");
+}
+
+export function buildEditedTrackSourceAudioFilter(
+ segments: NativeVideoExportEditedTrackSegment[],
+ sourceSampleRate: number,
+): string | null {
+ if (segments.length === 0 || !Number.isFinite(sourceSampleRate) || sourceSampleRate <= 0) {
+ return null;
+ }
+
+ const normalizedSourceSampleRate = Math.round(sourceSampleRate);
+ if (normalizedSourceSampleRate < 1) {
+ return null;
+ }
+
+ const filterParts: string[] = [];
+ const segmentLabels: string[] = [];
+ let hasInvalidSegment = false;
+
+ segments.forEach((segment, index) => {
+ if (
+ !Number.isFinite(segment.startMs) ||
+ !Number.isFinite(segment.endMs) ||
+ segment.startMs < 0 ||
+ segment.endMs < 0
+ ) {
+ hasInvalidSegment = true;
+ return;
+ }
+
+ if (segment.endMs - segment.startMs <= 0.5) {
+ hasInvalidSegment = true;
+ return;
+ }
+
+ const label = `edited_audio_${index}`;
+ const speed = segment.speed;
+ if (
+ !Number.isFinite(speed) ||
+ speed < MIN_EDITED_TRACK_TEMPO_SPEED ||
+ speed > MAX_EDITED_TRACK_TEMPO_SPEED
+ ) {
+ hasInvalidSegment = true;
+ return;
+ }
+
+ const segmentFilter = [
+ `[1:a]atrim=start=${formatFfmpegSeconds(segment.startMs)}:end=${formatFfmpegSeconds(segment.endMs)}`,
+ "asetpts=PTS-STARTPTS",
+ ];
+
+ const tempoFilters = buildAtempoFilters(speed);
+ if (tempoFilters.length > 0) {
+ segmentFilter.push(...tempoFilters);
+ } else if (Math.abs(speed - 1) > ATEMPO_FILTER_EPSILON) {
+ hasInvalidSegment = true;
+ return;
+ }
+
+ filterParts.push(`${segmentFilter.join(",")}[${label}]`);
+ segmentLabels.push(`[${label}]`);
+ });
+
+ if (hasInvalidSegment || segmentLabels.length === 0) {
+ return null;
+ }
+
+ if (segmentLabels.length === 1) {
+ filterParts.push(`${segmentLabels[0]}anull[aout]`);
+ } else {
+ filterParts.push(`${segmentLabels.join("")}concat=n=${segmentLabels.length}:v=0:a=1[aout]`);
+ }
+
+ return filterParts.join(";");
+}
+
+/**
+ * Builds FFmpeg arguments for a zero-copy H.264 stream export.
+ * FFmpeg receives a pre-encoded Annex B H.264 stream on stdin (produced by the
+ * browser's hardware VideoEncoder) and copies it straight into an MP4 container
+ * — no re-encoding step, no raw pixel IPC traffic.
+ */
+export function buildNativeH264StreamExportArgs(config: {
+ frameRate: number;
+ outputPath: string;
+}): string[] {
+ return [
+ "-y",
+ "-hide_banner",
+ "-loglevel",
+ "error",
+ // Input 0: pre-encoded H.264 Annex B stream from browser VideoEncoder via stdin
+ "-f",
+ "h264",
+ "-r",
+ String(config.frameRate),
+ "-i",
+ "pipe:0",
+ "-an", // audio handled separately by muxNativeVideoExportAudio
+ "-c:v",
+ "copy",
+ "-movflags",
+ "+faststart",
+ config.outputPath,
+ ];
+}
+
+export function getEditedAudioExtension(mimeType?: string | null): string {
+ if (!mimeType) {
+ return ".webm";
+ }
+
+ if (mimeType.includes("wav")) {
+ return ".wav";
+ }
+
+ if (mimeType.includes("mp4") || mimeType.includes("m4a")) {
+ return ".m4a";
+ }
+
+ if (mimeType.includes("ogg")) {
+ return ".ogg";
+ }
+
+ return ".webm";
+}
diff --git a/electron/ipc/paths/binaries.test.ts b/electron/ipc/paths/binaries.test.ts
new file mode 100644
index 000000000..361861739
--- /dev/null
+++ b/electron/ipc/paths/binaries.test.ts
@@ -0,0 +1,83 @@
+import fs from "node:fs/promises";
+import os from "node:os";
+import path from "node:path";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+describe("Windows native helper path resolution", () => {
+ let tempRoot: string;
+ let appPath: string;
+
+ beforeEach(async () => {
+ tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "recordly-helper-paths-"));
+ appPath = path.join(tempRoot, "App");
+ await fs.mkdir(appPath, { recursive: true });
+
+ vi.resetModules();
+ vi.doMock("electron", () => ({
+ app: {
+ isPackaged: false,
+ getAppPath: () => appPath,
+ },
+ }));
+ });
+
+ afterEach(async () => {
+ vi.resetModules();
+ vi.doUnmock("electron");
+ await fs.rm(tempRoot, { recursive: true, force: true });
+ });
+
+ it("resolves a requested platform tag independently of the host platform", async () => {
+ const { getNativeArchTag } = await import("./binaries");
+
+ expect(getNativeArchTag("win32")).toBe(
+ process.arch === "arm64" ? "win32-arm64" : "win32-x64",
+ );
+ });
+
+ it("prefers the branch-staged helper over a stale local CMake build in dev", async () => {
+ const buildOutputPath = path.join(
+ appPath,
+ "electron",
+ "native",
+ "wgc-capture",
+ "build",
+ "Release",
+ "wgc-capture.exe",
+ );
+ const prebundledPath = path.join(
+ appPath,
+ "electron",
+ "native",
+ "bin",
+ process.arch === "arm64" ? "win32-arm64" : "win32-x64",
+ "wgc-capture.exe",
+ );
+ await fs.mkdir(path.dirname(buildOutputPath), { recursive: true });
+ await fs.mkdir(path.dirname(prebundledPath), { recursive: true });
+ await fs.writeFile(buildOutputPath, "old-local-build");
+ await fs.writeFile(prebundledPath, "branch-staged-helper");
+
+ const { getWindowsCaptureExePath } = await import("./binaries");
+
+ expect(getWindowsCaptureExePath()).toBe(prebundledPath);
+ });
+
+ it("falls back to the local CMake build when no staged helper exists", async () => {
+ const buildOutputPath = path.join(
+ appPath,
+ "electron",
+ "native",
+ "wgc-capture",
+ "build",
+ "Release",
+ "wgc-capture.exe",
+ );
+ await fs.mkdir(path.dirname(buildOutputPath), { recursive: true });
+ await fs.writeFile(buildOutputPath, "local-build");
+
+ const { getWindowsCaptureExePath } = await import("./binaries");
+
+ expect(getWindowsCaptureExePath()).toBe(buildOutputPath);
+ });
+});
diff --git a/electron/ipc/paths/binaries.ts b/electron/ipc/paths/binaries.ts
new file mode 100644
index 000000000..77c8dd35f
--- /dev/null
+++ b/electron/ipc/paths/binaries.ts
@@ -0,0 +1,273 @@
+import { execFile } from "node:child_process";
+import { existsSync, constants as fsConstants } from "node:fs";
+import fs from "node:fs/promises";
+import path from "node:path";
+import { promisify } from "node:util";
+import { app } from "electron";
+import { nativeHelperMigrationPromise, setNativeHelperMigrationPromise } from "../state";
+
+const execFileAsync = promisify(execFile);
+
+/**
+ * Resolve a path within the app bundle, handling asar unpacking in production.
+ * Files listed in asarUnpack are extracted to app.asar.unpacked/ and must be
+ * accessed via that path instead of the asar virtual filesystem.
+ */
+export function resolveUnpackedAppPath(...segments: string[]): string {
+ const base = app.getAppPath();
+ const resolved = path.join(base, ...segments);
+ if (app.isPackaged) {
+ return resolved.replace(/\.asar([/\\])/, ".asar.unpacked$1");
+ }
+ return resolved;
+}
+
+export function getNativeCaptureHelperSourcePath(): string {
+ return resolveUnpackedAppPath("electron", "native", "ScreenCaptureKitRecorder.swift");
+}
+
+export function getNativeArchTag(platform: NodeJS.Platform = process.platform): string {
+ if (platform === "darwin") {
+ return process.arch === "arm64" ? "darwin-arm64" : "darwin-x64";
+ }
+
+ if (platform === "win32") {
+ return process.arch === "arm64" ? "win32-arm64" : "win32-x64";
+ }
+
+ if (platform === "linux") {
+ return process.arch === "arm64" ? "linux-arm64" : "linux-x64";
+ }
+
+ return `${platform}-${process.arch}`;
+}
+
+export function getPrebundledNativeHelperPath(
+ binaryName: string,
+ archTag = getNativeArchTag(),
+): string {
+ return resolveUnpackedAppPath("electron", "native", "bin", archTag, binaryName);
+}
+
+export function resolvePreferredWindowsNativeHelperPath(
+ helperDirectory: string,
+ binaryName: string,
+): string {
+ const buildOutputPath = resolveUnpackedAppPath(
+ "electron",
+ "native",
+ helperDirectory,
+ "build",
+ "Release",
+ binaryName,
+ );
+ const prebundledPath = getPrebundledNativeHelperPath(binaryName, getNativeArchTag("win32"));
+
+ if (app.isPackaged && existsSync(prebundledPath)) {
+ return prebundledPath;
+ }
+
+ // Source checkouts should run the helper staged in the branch instead of a
+ // stale local CMake build left over from an earlier test run.
+ if (existsSync(prebundledPath)) {
+ return prebundledPath;
+ }
+
+ if (existsSync(buildOutputPath)) {
+ return buildOutputPath;
+ }
+
+ return buildOutputPath;
+}
+
+export function getBundledWhisperExecutableCandidates(): string[] {
+ const binaryNames =
+ process.platform === "win32"
+ ? ["whisper-cli.exe", "whisper-cpp.exe", "whisper.exe", "main.exe"]
+ : ["whisper-cli", "whisper-cpp", "whisper", "main"];
+
+ return binaryNames.map((binaryName) => getPrebundledNativeHelperPath(binaryName));
+}
+
+export function getNativeCaptureHelperBinaryPath(): string {
+ return path.join(app.getPath("userData"), "native-tools", "recordly-screencapturekit-helper");
+}
+
+export function getSystemCursorHelperSourcePath(): string {
+ return resolveUnpackedAppPath("electron", "native", "SystemCursorAssets.swift");
+}
+
+export function getSystemCursorHelperBinaryPath(): string {
+ return path.join(app.getPath("userData"), "native-tools", "recordly-system-cursors");
+}
+
+export function getNativeCursorMonitorSourcePath(): string {
+ return resolveUnpackedAppPath("electron", "native", "NativeCursorMonitor.swift");
+}
+
+export function getNativeCursorMonitorBinaryPath(): string {
+ return path.join(app.getPath("userData"), "native-tools", "recordly-native-cursor-monitor");
+}
+
+export function getNativeWindowListSourcePath(): string {
+ return resolveUnpackedAppPath("electron", "native", "ScreenCaptureKitWindowList.swift");
+}
+
+export function getNativeWindowListBinaryPath(): string {
+ return path.join(app.getPath("userData"), "native-tools", "recordly-window-list");
+}
+
+export function getWindowsCaptureExePath(): string {
+ return resolvePreferredWindowsNativeHelperPath("wgc-capture", "wgc-capture.exe");
+}
+
+export function getCursorMonitorExePath(): string {
+ return resolvePreferredWindowsNativeHelperPath("cursor-monitor", "cursor-monitor.exe");
+}
+
+async function migrateLegacyNativeHelperBinaries(): Promise {
+ const legacyToCurrentPaths: Array<[string, string]> = [
+ [
+ path.join(
+ app.getPath("userData"),
+ "native-tools",
+ "openscreen-screencapturekit-helper",
+ ),
+ getNativeCaptureHelperBinaryPath(),
+ ],
+ [
+ path.join(app.getPath("userData"), "native-tools", "openscreen-window-list"),
+ getNativeWindowListBinaryPath(),
+ ],
+ [
+ path.join(app.getPath("userData"), "native-tools", "openscreen-system-cursors"),
+ getSystemCursorHelperBinaryPath(),
+ ],
+ [
+ path.join(app.getPath("userData"), "native-tools", "openscreen-native-cursor-monitor"),
+ getNativeCursorMonitorBinaryPath(),
+ ],
+ ];
+
+ for (const [legacyPath, currentPath] of legacyToCurrentPaths) {
+ if (legacyPath === currentPath || existsSync(currentPath) || !existsSync(legacyPath)) {
+ continue;
+ }
+
+ try {
+ await fs.mkdir(path.dirname(currentPath), { recursive: true });
+ await fs.rename(legacyPath, currentPath);
+ } catch (error) {
+ console.warn("[native-tools] Failed to migrate helper binary", {
+ legacyPath,
+ currentPath,
+ error,
+ });
+ }
+ }
+}
+
+export async function ensureNativeHelperMigration(): Promise {
+ if (!nativeHelperMigrationPromise) {
+ setNativeHelperMigrationPromise(
+ migrateLegacyNativeHelperBinaries().catch((error) => {
+ setNativeHelperMigrationPromise(null);
+ throw error;
+ }),
+ );
+ }
+
+ return nativeHelperMigrationPromise!;
+}
+
+export async function ensureSwiftHelperBinary(
+ sourcePath: string,
+ binaryPath: string,
+ label: string,
+ prebundledBinaryName?: string,
+): Promise {
+ if (prebundledBinaryName) {
+ const prebundledPath = getPrebundledNativeHelperPath(prebundledBinaryName);
+ try {
+ await fs.access(prebundledPath, fsConstants.X_OK);
+ if (app.isPackaged) {
+ return prebundledPath;
+ }
+
+ const [sourceStat, prebundledStat] = await Promise.all([
+ fs.stat(sourcePath),
+ fs.stat(prebundledPath),
+ ]);
+ if (prebundledStat.mtimeMs >= sourceStat.mtimeMs) {
+ return prebundledPath;
+ }
+ } catch {
+ if (app.isPackaged) {
+ throw new Error(
+ `${label} is missing from this app build (${prebundledPath}). Reinstall or update the app.`,
+ );
+ }
+ }
+ }
+
+ const helperDir = path.dirname(binaryPath);
+ await fs.mkdir(helperDir, { recursive: true });
+
+ let shouldCompile = false;
+ try {
+ const [sourceStat, binaryStat] = await Promise.all([
+ fs.stat(sourcePath),
+ fs.stat(binaryPath).catch(() => null),
+ ]);
+ shouldCompile = !binaryStat || sourceStat.mtimeMs > binaryStat.mtimeMs;
+ } catch (error) {
+ throw new Error(`${label} source is unavailable: ${String(error)}`);
+ }
+
+ if (!shouldCompile) {
+ return binaryPath;
+ }
+
+ try {
+ await execFileAsync("swiftc", ["-O", sourcePath, "-o", binaryPath], {
+ encoding: "utf8",
+ timeout: 120000,
+ });
+ } catch (error) {
+ const err = error as NodeJS.ErrnoException & { stdout?: string; stderr?: string };
+ const details = [err.stderr, err.stdout].filter(Boolean).join("\n").trim();
+ throw new Error(details || `Failed to compile ${label}`);
+ }
+
+ return binaryPath;
+}
+
+export async function ensureNativeCaptureHelperBinary(): Promise {
+ await ensureNativeHelperMigration();
+ return ensureSwiftHelperBinary(
+ getNativeCaptureHelperSourcePath(),
+ getNativeCaptureHelperBinaryPath(),
+ "native ScreenCaptureKit helper",
+ "recordly-screencapturekit-helper",
+ );
+}
+
+export async function ensureNativeWindowListBinary(): Promise {
+ await ensureNativeHelperMigration();
+ return ensureSwiftHelperBinary(
+ getNativeWindowListSourcePath(),
+ getNativeWindowListBinaryPath(),
+ "native ScreenCaptureKit window list helper",
+ "recordly-window-list",
+ );
+}
+
+export async function ensureNativeCursorMonitorBinary(): Promise {
+ await ensureNativeHelperMigration();
+ return ensureSwiftHelperBinary(
+ getNativeCursorMonitorSourcePath(),
+ getNativeCursorMonitorBinaryPath(),
+ "native cursor monitor helper",
+ "recordly-native-cursor-monitor",
+ );
+}
diff --git a/electron/ipc/project/atomicSave.test.ts b/electron/ipc/project/atomicSave.test.ts
new file mode 100644
index 000000000..34646af34
--- /dev/null
+++ b/electron/ipc/project/atomicSave.test.ts
@@ -0,0 +1,112 @@
+import fs from "node:fs/promises";
+import os from "node:os";
+import path from "node:path";
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+
+import { getProjectBackupPath, writeProjectFileAtomically } from "./atomicSave";
+
+describe("writeProjectFileAtomically", () => {
+ let tempDir: string;
+ let projectPath: string;
+
+ beforeEach(async () => {
+ tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "recordly-atomic-project-"));
+ projectPath = path.join(tempDir, "demo.recordly");
+ });
+
+ afterEach(async () => {
+ await fs.rm(tempDir, { recursive: true, force: true });
+ });
+
+ async function expectNoTemporaryArtifacts() {
+ const entries = await fs.readdir(tempDir);
+ expect(entries.filter((entry) => entry.endsWith(".tmp"))).toEqual([]);
+ }
+
+ it("commits a complete new project without creating a backup", async () => {
+ await writeProjectFileAtomically(projectPath, '{"version":1}');
+
+ await expect(fs.readFile(projectPath, "utf-8")).resolves.toBe('{"version":1}');
+ await expect(fs.access(getProjectBackupPath(projectPath))).rejects.toMatchObject({
+ code: "ENOENT",
+ });
+ await expectNoTemporaryArtifacts();
+ await expectNoTemporaryArtifacts();
+ });
+
+ it("removes a stale backup when the target has no previous generation", async () => {
+ await fs.writeFile(getProjectBackupPath(projectPath), "stale-project");
+
+ await writeProjectFileAtomically(projectPath, '{"version":1}');
+
+ await expect(fs.access(getProjectBackupPath(projectPath))).rejects.toMatchObject({
+ code: "ENOENT",
+ });
+ });
+
+ it("preserves the previous complete generation before replacement", async () => {
+ await writeProjectFileAtomically(projectPath, '{"version":1,"name":"old"}');
+ await writeProjectFileAtomically(projectPath, '{"version":1,"name":"new"}');
+
+ await expect(fs.readFile(projectPath, "utf-8")).resolves.toBe('{"version":1,"name":"new"}');
+ await expect(fs.readFile(getProjectBackupPath(projectPath), "utf-8")).resolves.toBe(
+ '{"version":1,"name":"old"}',
+ );
+ await expectNoTemporaryArtifacts();
+ });
+
+ it("keeps the active generation unchanged when backup commit fails", async () => {
+ await writeProjectFileAtomically(projectPath, '{"version":1,"name":"old"}');
+ await fs.mkdir(getProjectBackupPath(projectPath));
+
+ await expect(
+ writeProjectFileAtomically(projectPath, '{"version":1,"name":"new"}'),
+ ).rejects.toBeDefined();
+
+ await expect(fs.readFile(projectPath, "utf-8")).resolves.toBe('{"version":1,"name":"old"}');
+ await expectNoTemporaryArtifacts();
+
+ await fs.rm(getProjectBackupPath(projectPath), { recursive: true });
+ await writeProjectFileAtomically(projectPath, '{"version":1,"name":"retry"}');
+ await expect(fs.readFile(projectPath, "utf-8")).resolves.toBe(
+ '{"version":1,"name":"retry"}',
+ );
+ await expect(fs.readFile(getProjectBackupPath(projectPath), "utf-8")).resolves.toBe(
+ '{"version":1,"name":"old"}',
+ );
+ await expectNoTemporaryArtifacts();
+ });
+
+ it("serializes overlapping writes to the same project", async () => {
+ await writeProjectFileAtomically(projectPath, '{"revision":1}');
+
+ // Each call enters the queue synchronously before its first await, preserving invocation order.
+ await Promise.all([
+ writeProjectFileAtomically(projectPath, '{"revision":2}'),
+ writeProjectFileAtomically(projectPath, '{"revision":3}'),
+ ]);
+
+ await expect(fs.readFile(projectPath, "utf-8")).resolves.toBe('{"revision":3}');
+ await expect(fs.readFile(getProjectBackupPath(projectPath), "utf-8")).resolves.toBe(
+ '{"revision":2}',
+ );
+ await expectNoTemporaryArtifacts();
+ });
+
+ it.skipIf(process.platform === "win32")(
+ "preserves exact project permission bits despite the process umask",
+ async () => {
+ await fs.writeFile(projectPath, '{"revision":1}', { mode: 0o666 });
+ await fs.chmod(projectPath, 0o666);
+ const previousUmask = process.umask(0o077);
+
+ try {
+ await writeProjectFileAtomically(projectPath, '{"revision":2}');
+ } finally {
+ process.umask(previousUmask);
+ }
+
+ expect((await fs.stat(projectPath)).mode & 0o777).toBe(0o666);
+ },
+ );
+});
diff --git a/electron/ipc/project/atomicSave.ts b/electron/ipc/project/atomicSave.ts
new file mode 100644
index 000000000..d2440ffe5
--- /dev/null
+++ b/electron/ipc/project/atomicSave.ts
@@ -0,0 +1,146 @@
+import { randomUUID } from "node:crypto";
+import { constants as fsConstants } from "node:fs";
+import fs from "node:fs/promises";
+import path from "node:path";
+
+const pendingWrites = new Map>();
+
+const unsupportedDirectorySyncErrors = new Set([
+ "EACCES",
+ "EINVAL",
+ "EISDIR",
+ "ENOSYS",
+ "ENOTSUP",
+ "EOPNOTSUPP",
+ "EPERM",
+]);
+
+export function getProjectBackupPath(projectPath: string): string {
+ return `${projectPath}.bak`;
+}
+
+function getQueueKey(projectPath: string): string {
+ const resolvedPath = path.resolve(projectPath);
+ return process.platform === "win32" ? resolvedPath.toLowerCase() : resolvedPath;
+}
+
+function createTemporaryPath(parentDir: string, label: string): string {
+ return path.join(parentDir, `.recordly-${label}-${process.pid}-${randomUUID()}.tmp`);
+}
+
+async function getExistingFileMode(filePath: string): Promise {
+ try {
+ return (await fs.stat(filePath)).mode & 0o777;
+ } catch (error) {
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
+ return undefined;
+ }
+ throw error;
+ }
+}
+
+async function writeSyncedTemporaryFile(
+ filePath: string,
+ contents: string,
+ mode?: number,
+): Promise {
+ const handle = await fs.open(filePath, "wx", mode);
+ try {
+ await handle.writeFile(contents, "utf-8");
+ if (mode !== undefined) {
+ await handle.chmod(mode);
+ }
+ await handle.sync();
+ } finally {
+ await handle.close();
+ }
+}
+
+async function syncExistingFile(filePath: string): Promise {
+ const handle = await fs.open(filePath, "r+");
+ try {
+ await handle.sync();
+ } finally {
+ await handle.close();
+ }
+}
+
+async function syncParentDirectory(parentDir: string): Promise {
+ if (process.platform === "win32") {
+ return;
+ }
+
+ try {
+ const handle = await fs.open(parentDir, "r");
+ try {
+ await handle.sync();
+ } finally {
+ await handle.close();
+ }
+ } catch (error) {
+ const code = (error as NodeJS.ErrnoException).code;
+ if (!code || !unsupportedDirectorySyncErrors.has(code)) {
+ throw error;
+ }
+ }
+}
+
+async function preservePreviousGeneration(
+ targetPath: string,
+ backupPath: string,
+ backupTemporaryPath: string,
+): Promise {
+ try {
+ await fs.copyFile(targetPath, backupTemporaryPath, fsConstants.COPYFILE_EXCL);
+ } catch (error) {
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
+ await fs.rm(backupPath, { force: true });
+ return;
+ }
+ throw error;
+ }
+
+ await syncExistingFile(backupTemporaryPath);
+ await fs.rename(backupTemporaryPath, backupPath);
+}
+
+async function commitProjectFile(projectPath: string, contents: string): Promise {
+ const targetPath = path.resolve(projectPath);
+ const parentDir = path.dirname(targetPath);
+ const backupPath = getProjectBackupPath(targetPath);
+ const temporaryPath = createTemporaryPath(parentDir, "project");
+ const backupTemporaryPath = createTemporaryPath(parentDir, "backup");
+ const existingMode = await getExistingFileMode(targetPath);
+
+ try {
+ await writeSyncedTemporaryFile(temporaryPath, contents, existingMode);
+ await preservePreviousGeneration(targetPath, backupPath, backupTemporaryPath);
+ await fs.rename(temporaryPath, targetPath);
+ await syncParentDirectory(parentDir);
+ } finally {
+ await Promise.all([
+ fs.rm(temporaryPath, { force: true }).catch(() => undefined),
+ fs.rm(backupTemporaryPath, { force: true }).catch(() => undefined),
+ ]);
+ }
+}
+
+export async function writeProjectFileAtomically(
+ projectPath: string,
+ contents: string,
+): Promise {
+ const queueKey = getQueueKey(projectPath);
+ const previousWrite = pendingWrites.get(queueKey) ?? Promise.resolve();
+ const currentWrite = previousWrite
+ .catch(() => undefined)
+ .then(() => commitProjectFile(projectPath, contents));
+ pendingWrites.set(queueKey, currentWrite);
+
+ try {
+ await currentWrite;
+ } finally {
+ if (pendingWrites.get(queueKey) === currentWrite) {
+ pendingWrites.delete(queueKey);
+ }
+ }
+}
diff --git a/electron/ipc/project/createUntitledProject.test.ts b/electron/ipc/project/createUntitledProject.test.ts
new file mode 100644
index 000000000..261da980b
--- /dev/null
+++ b/electron/ipc/project/createUntitledProject.test.ts
@@ -0,0 +1,26 @@
+import { afterEach, expect, it } from "vitest";
+import fs from "node:fs/promises";
+import os from "node:os";
+import path from "node:path";
+import { createUntitledProject } from "./createUntitledProject";
+const dirs: string[] = [];
+afterEach(async () => {
+ await Promise.all(dirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })));
+});
+it("publishes complete, uniquely named projects under concurrent creation without replacing existing projects", async () => {
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), "recordly-new-"));
+ dirs.push(dir);
+ await fs.writeFile(path.join(dir, "Untitled Project.recordly"), "original");
+ const paths = await Promise.all(
+ [1, 2, 3].map((n) => createUntitledProject(dir, JSON.stringify({ id: n }))),
+ );
+ expect(paths.map((p) => path.basename(p)).sort()).toEqual([
+ "Untitled Project 1.recordly",
+ "Untitled Project 2.recordly",
+ "Untitled Project 3.recordly",
+ ]);
+ expect(await fs.readFile(path.join(dir, "Untitled Project.recordly"), "utf8")).toBe("original");
+ for (let i = 0; i < paths.length; i++)
+ expect(JSON.parse(await fs.readFile(paths[i], "utf8"))).toEqual({ id: i + 1 });
+ expect((await fs.readdir(dir)).filter((p) => p.endsWith(".tmp"))).toEqual([]);
+});
diff --git a/electron/ipc/project/createUntitledProject.ts b/electron/ipc/project/createUntitledProject.ts
new file mode 100644
index 000000000..b7bc37792
--- /dev/null
+++ b/electron/ipc/project/createUntitledProject.ts
@@ -0,0 +1,31 @@
+import fs from "node:fs/promises";
+import path from "node:path";
+import { randomUUID } from "node:crypto";
+/** Publish a complete new project without overwriting a concurrent or existing save. */
+export async function createUntitledProject(directory: string, contents: string) {
+ await fs.mkdir(directory, { recursive: true });
+ const temporary = path.join(directory, `.recordly-new-${randomUUID()}.tmp`);
+ try {
+ const file = await fs.open(temporary, "wx");
+ try {
+ await file.writeFile(contents, "utf8");
+ await file.sync();
+ } finally {
+ await file.close();
+ }
+ for (let suffix = 0; ; suffix++) {
+ const target = path.join(
+ directory,
+ `Untitled Project${suffix ? ` ${suffix}` : ""}.recordly`,
+ );
+ try {
+ await fs.link(temporary, target);
+ return target;
+ } catch (error) {
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
+ }
+ }
+ } finally {
+ await fs.rm(temporary, { force: true });
+ }
+}
diff --git a/electron/ipc/project/manager.test.ts b/electron/ipc/project/manager.test.ts
new file mode 100644
index 000000000..c89dec32a
--- /dev/null
+++ b/electron/ipc/project/manager.test.ts
@@ -0,0 +1,261 @@
+import fs from "node:fs/promises";
+import os from "node:os";
+import path from "node:path";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+describe("local media path policy", () => {
+ let tempRoot: string;
+ let appDataPath: string;
+ let userDataPath: string;
+ let tempPath: string;
+ let appPath: string;
+
+ beforeEach(async () => {
+ tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "recordly-media-policy-"));
+ appDataPath = path.join(tempRoot, "AppData");
+ userDataPath = path.join(tempRoot, "UserData");
+ tempPath = path.join(tempRoot, "Temp");
+ appPath = path.join(tempRoot, "App");
+
+ await Promise.all(
+ [appDataPath, userDataPath, tempPath, appPath].map((dirPath) =>
+ fs.mkdir(dirPath, { recursive: true }),
+ ),
+ );
+
+ vi.resetModules();
+ vi.doMock("electron", () => ({
+ app: {
+ isPackaged: false,
+ getAppPath: () => appPath,
+ getPath: (name: string) => {
+ if (name === "appData") return appDataPath;
+ if (name === "userData") return userDataPath;
+ if (name === "temp") return tempPath;
+ return tempRoot;
+ },
+ setPath: () => undefined,
+ },
+ }));
+ });
+
+ afterEach(async () => {
+ vi.resetModules();
+ vi.doUnmock("electron");
+ vi.doUnmock("../../mediaServer");
+ if (tempRoot) {
+ await fs.rm(tempRoot, { recursive: true, force: true });
+ }
+ });
+
+ it("reads library previews without switching the active project and rejects unknown projects", async () => {
+ vi.doMock("../../mediaServer", () => ({
+ getMediaServerBaseUrl: () => "http://127.0.0.1:1234",
+ buildMediaUrl: (base: string, file: string) =>
+ `${base}/media?path=${encodeURIComponent(file)}`,
+ }));
+ const manager = await import("./manager");
+ const state = await import("../state");
+ const source = path.join(tempRoot, "video.mp4");
+ await fs.writeFile(source, "video");
+ const projectsDir = await manager.getProjectsDir();
+ const projectPath = path.join(projectsDir, "preview.recordly");
+ await fs.writeFile(
+ projectPath,
+ JSON.stringify({ version: 1, videoPath: source, editor: {} }),
+ );
+ state.setCurrentProjectPath("active.recordly");
+ state.setCurrentVideoPath("active.mp4");
+ const result = await manager.readProjectPreview(projectPath);
+ expect(result.videoUrl).toContain(encodeURIComponent(source));
+ expect(result.webcamUrl).toBeNull();
+ expect(state.currentProjectPath).toBe("active.recordly");
+ expect(state.currentVideoPath).toBe("active.mp4");
+ await expect(
+ manager.readProjectPreview(path.join(tempRoot, "unknown.recordly")),
+ ).rejects.toThrow("not in the library");
+ });
+
+ it("rejects existing media files outside allowed directories until they are approved", async () => {
+ const downloadsPath = path.join(tempRoot, "Downloads");
+ const exportPath = path.join(downloadsPath, "export-test.mp4");
+ await fs.mkdir(downloadsPath, { recursive: true });
+ await fs.writeFile(exportPath, "test-video");
+
+ const { isAllowedLocalMediaPath, rememberApprovedLocalReadPath } = await import(
+ "./manager"
+ );
+
+ await expect(isAllowedLocalMediaPath(exportPath)).resolves.toBe(false);
+
+ await rememberApprovedLocalReadPath(exportPath);
+
+ await expect(isAllowedLocalMediaPath(exportPath)).resolves.toBe(true);
+ });
+
+ it("rejects missing media files outside the allowed directories", async () => {
+ const missingPath = path.join(tempRoot, "Downloads", "missing.mp4");
+ const { isAllowedLocalMediaPath } = await import("./manager");
+
+ await expect(isAllowedLocalMediaPath(missingPath)).resolves.toBe(false);
+ });
+
+ it("allows approved media paths before the file exists", async () => {
+ const pendingExportPath = path.join(tempRoot, "Downloads", "pending-export.mp4");
+ const { isAllowedLocalMediaPath, rememberApprovedLocalReadPath } = await import(
+ "./manager"
+ );
+
+ await rememberApprovedLocalReadPath(pendingExportPath);
+
+ await expect(isAllowedLocalMediaPath(pendingExportPath)).resolves.toBe(true);
+ });
+
+ it("approves media-server access for approved external files resolved through the URL policy", async () => {
+ const downloadsPath = path.join(tempRoot, "Downloads");
+ const videoPath = path.join(downloadsPath, "external-video.mp4");
+ await fs.mkdir(downloadsPath, { recursive: true });
+ await fs.writeFile(videoPath, "test-video");
+ const resolvedVideoPath = await fs.realpath(videoPath);
+
+ const { resolveApprovedLocalMediaPath, rememberApprovedLocalReadPath } = await import(
+ "./manager"
+ );
+ const { isAllowedMediaPath } = await import("../../mediaServer");
+
+ // Unapproved external paths are rejected before they ever reach the media server.
+ expect(isAllowedMediaPath(videoPath)).toBe(false);
+ await expect(resolveApprovedLocalMediaPath(videoPath)).resolves.toBeNull();
+
+ // Once the user opts in (via dialog/export/etc.) the path is approved.
+ await rememberApprovedLocalReadPath(videoPath);
+
+ await expect(resolveApprovedLocalMediaPath(videoPath)).resolves.toBe(resolvedVideoPath);
+ expect(isAllowedMediaPath(videoPath)).toBe(true);
+ });
+
+ it("rejects existing non-media files when resolving local media URLs", async () => {
+ const downloadsPath = path.join(tempRoot, "Downloads");
+ const textPath = path.join(downloadsPath, "notes.txt");
+ await fs.mkdir(downloadsPath, { recursive: true });
+ await fs.writeFile(textPath, "not media");
+
+ const { resolveApprovedLocalMediaPath } = await import("./manager");
+ const { isAllowedMediaPath } = await import("../../mediaServer");
+
+ await expect(resolveApprovedLocalMediaPath(textPath)).resolves.toBeNull();
+ expect(isAllowedMediaPath(textPath)).toBe(false);
+ });
+
+ it("rejects symlinks under allowed prefixes that point outside the allowlist", async () => {
+ const outsideTarget = path.join(tempRoot, "outside-secret.mp4");
+ const symlinkInsideUserData = path.join(userDataPath, "shortcut-to-secret.mp4");
+ await fs.writeFile(outsideTarget, "secret-bytes");
+
+ try {
+ await fs.symlink(outsideTarget, symlinkInsideUserData);
+ } catch (error) {
+ // Windows requires Developer Mode or admin to create file symlinks. If
+ // we can't create one, the bypass we're guarding against also can't be
+ // crafted on this machine, so skipping is safe.
+ if ((error as NodeJS.ErrnoException).code === "EPERM") {
+ return;
+ }
+ throw error;
+ }
+
+ const { isAllowedLocalMediaPath, resolveApprovedLocalMediaPath } = await import(
+ "./manager"
+ );
+
+ await expect(isAllowedLocalMediaPath(symlinkInsideUserData)).resolves.toBe(false);
+ await expect(resolveApprovedLocalMediaPath(symlinkInsideUserData)).resolves.toBeNull();
+ });
+
+ it("preserves an existing project thumbnail when no replacement is provided", async () => {
+ const projectPath = path.join(tempRoot, "Projects", "demo.recordly");
+ const thumbnailDataUrl = `data:image/png;base64,${Buffer.from("png-thumbnail").toString("base64")}`;
+ await fs.mkdir(path.dirname(projectPath), { recursive: true });
+
+ const { getProjectThumbnailPath, saveProjectThumbnail } = await import("./manager");
+ const thumbnailPath = getProjectThumbnailPath(projectPath);
+
+ await saveProjectThumbnail(projectPath, thumbnailDataUrl);
+ await saveProjectThumbnail(projectPath, undefined);
+
+ await expect(fs.readFile(thumbnailPath, "utf8")).resolves.toBe("png-thumbnail");
+ });
+
+ it("loads project files that start with a UTF-8 byte order mark", async () => {
+ const videoPath = path.join(tempPath, "recording.mp4");
+ const projectPath = path.join(tempPath, "recording.recordly");
+ await fs.writeFile(videoPath, "test-video");
+ await fs.writeFile(
+ projectPath,
+ `\uFEFF${JSON.stringify({
+ version: 1,
+ videoPath,
+ editor: {},
+ })}`,
+ "utf-8",
+ );
+
+ const { loadProjectFromPath } = await import("./manager");
+
+ const result = await loadProjectFromPath(projectPath);
+ expect(result.success).toBe(true);
+ expect(result.path).toBe(projectPath);
+ expect(result.project).toMatchObject({ videoPath });
+ });
+
+ it("rejects invalid project payloads before approving media paths", async () => {
+ const downloadsPath = path.join(tempRoot, "Downloads");
+ const videoPath = path.join(downloadsPath, "recording.mp4");
+ const projectPath = path.join(tempPath, "invalid.recordly");
+ await fs.mkdir(downloadsPath, { recursive: true });
+ await fs.writeFile(videoPath, "test-video");
+ await fs.writeFile(
+ projectPath,
+ JSON.stringify({
+ videoPath,
+ editor: {},
+ }),
+ "utf-8",
+ );
+
+ const { loadProjectFromPath, resolveApprovedLocalMediaPath } = await import("./manager");
+
+ const result = await loadProjectFromPath(projectPath);
+ expect(result.success).toBe(false);
+ expect(result.message).toBe("Invalid project file format");
+ await expect(resolveApprovedLocalMediaPath(videoPath)).resolves.toBeNull();
+ });
+
+ it("approves editor audioRegions audioPath entries when loading a project", async () => {
+ const downloadsPath = path.join(tempRoot, "Downloads");
+ const videoPath = path.join(tempPath, "recording.mp4");
+ const audioPath = path.join(downloadsPath, "music.ogg");
+ const projectPath = path.join(tempPath, "recording.recordly");
+ await fs.mkdir(downloadsPath, { recursive: true });
+ await fs.writeFile(videoPath, "test-video");
+ await fs.writeFile(audioPath, "test-audio");
+ await fs.writeFile(
+ projectPath,
+ JSON.stringify({
+ version: 1,
+ videoPath,
+ editor: {
+ audioRegions: [{ id: "a1", startMs: 0, endMs: 1000, audioPath, volume: 1 }],
+ },
+ }),
+ "utf-8",
+ );
+
+ const { loadProjectFromPath, resolveApprovedLocalMediaPath } = await import("./manager");
+ const resolvedAudioPath = await fs.realpath(audioPath);
+
+ const result = await loadProjectFromPath(projectPath);
+ expect(result.success).toBe(true);
+ await expect(resolveApprovedLocalMediaPath(audioPath)).resolves.toBe(resolvedAudioPath);
+ });
+});
diff --git a/electron/ipc/project/manager.ts b/electron/ipc/project/manager.ts
new file mode 100644
index 000000000..ee32e20a7
--- /dev/null
+++ b/electron/ipc/project/manager.ts
@@ -0,0 +1,524 @@
+import { buildMediaUrl, getMediaServerBaseUrl } from "../../mediaServer";
+import type { ProjectPreviewData } from "../../../src/types/projectPreview";
+import { hasFreshProjectThumbnail } from "./thumbnailFreshness";
+import { existsSync, constants as fsConstants, realpathSync } from "node:fs";
+import fs from "node:fs/promises";
+import path from "node:path";
+import { app } from "electron";
+import { RECORDINGS_DIR, USER_DATA_PATH } from "../../appPaths";
+import { isSupportedLocalMediaPath } from "../../mediaTypes";
+import {
+ LEGACY_PROJECT_FILE_EXTENSIONS,
+ MAX_RECENT_PROJECTS,
+ PROJECT_FILE_EXTENSION,
+ PROJECT_THUMBNAIL_SUFFIX,
+ PROJECTS_DIRECTORY_NAME,
+ RECENT_PROJECTS_FILE,
+ RECORDINGS_SETTINGS_FILE,
+} from "../constants";
+import {
+ approvedLocalReadPaths,
+ currentProjectPath,
+ setCurrentProjectPath,
+ setCurrentRecordingSession,
+ setCurrentVideoPath,
+ setCustomRecordingsDir,
+ setRecordingsDirLoaded,
+} from "../state";
+import type { ProjectLibraryEntry, RecordingSessionData } from "../types";
+import {
+ getRecordingsDir,
+ normalizePath,
+ normalizeVideoSourcePath,
+ parseJsonWithByteOrderMark,
+} from "../utils";
+
+export { normalizePath, normalizeVideoSourcePath };
+
+export function getAssetRootPath() {
+ if (app.isPackaged) {
+ return path.join(process.resourcesPath, "assets");
+ }
+
+ return path.join(app.getAppPath(), "public");
+}
+
+export function isPathInsideDirectory(candidatePath: string, directoryPath: string) {
+ const normalizedCandidatePath = normalizePath(candidatePath);
+ const normalizedDirectoryPath = normalizePath(directoryPath);
+ return (
+ normalizedCandidatePath === normalizedDirectoryPath ||
+ normalizedCandidatePath.startsWith(`${normalizedDirectoryPath}${path.sep}`)
+ );
+}
+
+export function isAllowedLocalReadPath(candidatePath: string) {
+ const allowedPrefixes = [
+ RECORDINGS_DIR,
+ USER_DATA_PATH,
+ getAssetRootPath(),
+ app.getPath("temp"),
+ ];
+ const normalizedCandidatePath = normalizePath(candidatePath);
+
+ // Canonicalize so a symlink placed under an allowed prefix can't smuggle in a
+ // target that lives outside it. realpathSync throws when the path doesn't
+ // exist yet (e.g. a pending export approved before the file is written) — in
+ // that case fall back to the lexical path, which can only succeed via the
+ // approvedLocalReadPaths check below since no symlink target exists yet.
+ let canonicalCandidatePath = normalizedCandidatePath;
+ try {
+ canonicalCandidatePath = normalizePath(realpathSync(normalizedCandidatePath));
+ } catch {
+ // File may not exist yet; keep the lexical path.
+ }
+
+ // Security: only allow paths under app-managed directories or paths the user
+ // has explicitly opted into (recording session sources, files chosen via
+ // dialog, app-produced exports). The lexical path must satisfy the policy
+ // AND the canonical (real) path must satisfy it too, so a symlink under an
+ // allowed prefix that points outside the allowlist is rejected. Previously
+ // this returned true for any existing file, which made the allowlist a no-op
+ // for read-local-file and the local media URL handler.
+ const lexicalAllowed =
+ allowedPrefixes.some((prefix) => isPathInsideDirectory(normalizedCandidatePath, prefix)) ||
+ approvedLocalReadPaths.has(normalizedCandidatePath);
+ if (!lexicalAllowed) {
+ return false;
+ }
+
+ if (canonicalCandidatePath === normalizedCandidatePath) {
+ return true;
+ }
+
+ return (
+ allowedPrefixes.some((prefix) => isPathInsideDirectory(canonicalCandidatePath, prefix)) ||
+ approvedLocalReadPaths.has(canonicalCandidatePath)
+ );
+}
+
+// Keep loopback media-server access restricted to allowlisted or explicitly
+// approved files. Direct renderer-side read-local-file calls can be more
+// permissive, but URL-based serving must stay scoped so arbitrary paths do not
+// become fetchable inside the app.
+export async function isAllowedLocalMediaPath(candidatePath: string) {
+ const normalizedCandidatePath = normalizePath(candidatePath);
+ return isAllowedLocalReadPath(normalizedCandidatePath);
+}
+
+async function collectApprovedLocalReadPaths(filePath?: string | null): Promise {
+ const normalizedPath = normalizeVideoSourcePath(filePath);
+ if (!normalizedPath) {
+ return [];
+ }
+
+ const approvedPaths = [normalizePath(normalizedPath)];
+
+ try {
+ const realPath = await fs.realpath(approvedPaths[0]);
+ const normalizedRealPath = normalizePath(realPath);
+ if (!approvedPaths.includes(normalizedRealPath)) {
+ approvedPaths.push(normalizedRealPath);
+ }
+ } catch {
+ // Ignore missing files; the eventual read will surface the real error.
+ }
+
+ return approvedPaths;
+}
+
+export async function rememberApprovedLocalReadPath(filePath?: string | null) {
+ const normalizedPath = normalizeVideoSourcePath(filePath);
+ if (!normalizedPath) {
+ return;
+ }
+
+ const approvedPaths = await collectApprovedLocalReadPaths(normalizedPath);
+ for (const approvedPath of approvedPaths) {
+ approvedLocalReadPaths.add(approvedPath);
+ }
+}
+
+export async function resolveApprovedLocalMediaPath(candidatePath: string): Promise {
+ const normalizedCandidatePath = normalizePath(candidatePath);
+ const realPath = await fs.realpath(normalizedCandidatePath).catch(() => null);
+
+ if (!realPath) {
+ return null;
+ }
+
+ const stat = await fs.stat(realPath).catch(() => null);
+ if (!stat?.isFile() || !isSupportedLocalMediaPath(realPath)) {
+ return null;
+ }
+
+ if (!(await isAllowedLocalMediaPath(realPath))) {
+ return null;
+ }
+
+ await rememberApprovedLocalReadPath(candidatePath);
+ return realPath;
+}
+
+export async function replaceApprovedSessionLocalReadPaths(
+ filePaths: Array,
+) {
+ const nextApprovedPaths = new Set();
+ const approvedPathLists = await Promise.all(
+ filePaths.map((filePath) => collectApprovedLocalReadPaths(filePath)),
+ );
+
+ for (const approvedPathList of approvedPathLists) {
+ for (const approvedPath of approvedPathList) {
+ nextApprovedPaths.add(approvedPath);
+ }
+ }
+
+ approvedLocalReadPaths.clear();
+ for (const approvedPath of nextApprovedPaths) {
+ approvedLocalReadPaths.add(approvedPath);
+ }
+}
+
+export async function resolveProjectMediaSources(
+ project: unknown,
+): Promise<
+ | { success: true; videoPath: string; webcamPath: string | null }
+ | { success: false; message: string }
+> {
+ if (!project || typeof project !== "object") {
+ return { success: false, message: "Invalid project file format" };
+ }
+
+ const rawVideoPath = (project as { videoPath?: unknown }).videoPath;
+ if (typeof rawVideoPath !== "string") {
+ return { success: false, message: "Project file is missing a video path" };
+ }
+
+ const normalizedVideoPath = normalizeVideoSourcePath(rawVideoPath);
+ if (!normalizedVideoPath) {
+ return { success: false, message: "Project file is missing a valid video path" };
+ }
+
+ try {
+ await fs.access(normalizedVideoPath, fsConstants.F_OK);
+ } catch {
+ return {
+ success: false,
+ message: `Project video file not found: ${normalizedVideoPath}`,
+ };
+ }
+
+ const rawWebcamPath =
+ typeof (project as { editor?: { webcam?: { sourcePath?: unknown } } }).editor?.webcam
+ ?.sourcePath === "string"
+ ? ((project as { editor?: { webcam?: { sourcePath?: string } } }).editor?.webcam
+ ?.sourcePath ?? null)
+ : null;
+ const normalizedWebcamPath = normalizeVideoSourcePath(rawWebcamPath);
+
+ if (!normalizedWebcamPath) {
+ return {
+ success: true,
+ videoPath: normalizedVideoPath,
+ webcamPath: null,
+ };
+ }
+
+ try {
+ await fs.access(normalizedWebcamPath, fsConstants.F_OK);
+ return {
+ success: true,
+ videoPath: normalizedVideoPath,
+ webcamPath: normalizedWebcamPath,
+ };
+ } catch {
+ return {
+ success: true,
+ videoPath: normalizedVideoPath,
+ webcamPath: null,
+ };
+ }
+}
+
+export async function getProjectsDir() {
+ const projectsDir = path.join(await getRecordingsDir(), PROJECTS_DIRECTORY_NAME);
+ await fs.mkdir(projectsDir, { recursive: true });
+ return projectsDir;
+}
+
+export async function persistRecordingsDirectorySetting(nextDir: string) {
+ setCustomRecordingsDir(path.resolve(nextDir));
+ setRecordingsDirLoaded(true);
+ await fs.writeFile(
+ RECORDINGS_SETTINGS_FILE,
+ JSON.stringify({ recordingsDir: path.resolve(nextDir) }, null, 2),
+ "utf-8",
+ );
+}
+
+export function hasProjectFileExtension(filePath: string) {
+ const extension = path.extname(filePath).replace(/^\./, "").toLowerCase();
+ return [PROJECT_FILE_EXTENSION, ...LEGACY_PROJECT_FILE_EXTENSIONS].includes(extension);
+}
+
+export function getProjectThumbnailPath(projectPath: string) {
+ return `${projectPath}${PROJECT_THUMBNAIL_SUFFIX}`;
+}
+
+export async function saveProjectThumbnail(projectPath: string, thumbnailDataUrl?: string | null) {
+ const thumbnailPath = getProjectThumbnailPath(projectPath);
+ if (thumbnailDataUrl === undefined) {
+ return existsSync(thumbnailPath) ? thumbnailPath : null;
+ }
+
+ if (!thumbnailDataUrl) {
+ await fs.rm(thumbnailPath, { force: true }).catch(() => undefined);
+ return null;
+ }
+
+ const match = thumbnailDataUrl.match(/^data:image\/png;base64,(.+)$/);
+ if (!match) {
+ throw new Error("Project thumbnail must be a PNG data URL.");
+ }
+
+ await fs.writeFile(thumbnailPath, Buffer.from(match[1], "base64"));
+ return thumbnailPath;
+}
+
+export async function loadRecentProjectPaths() {
+ try {
+ const content = await fs.readFile(RECENT_PROJECTS_FILE, "utf-8");
+ const parsed = parseJsonWithByteOrderMark<{ paths?: unknown }>(content);
+ return Array.isArray(parsed.paths)
+ ? parsed.paths.filter(
+ (value): value is string =>
+ typeof value === "string" && value.trim().length > 0,
+ )
+ : [];
+ } catch {
+ return [];
+ }
+}
+
+export async function saveRecentProjectPaths(paths: string[]) {
+ const normalizedPaths = Array.from(new Set(paths.map((value) => normalizePath(value)))).slice(
+ 0,
+ MAX_RECENT_PROJECTS,
+ );
+ await fs.writeFile(
+ RECENT_PROJECTS_FILE,
+ JSON.stringify({ paths: normalizedPaths }, null, 2),
+ "utf-8",
+ );
+}
+
+export async function rememberRecentProject(projectPath: string) {
+ if (!hasProjectFileExtension(projectPath)) {
+ return;
+ }
+
+ const existingPaths = await loadRecentProjectPaths();
+ await saveRecentProjectPaths([projectPath, ...existingPaths]);
+}
+
+export async function buildProjectLibraryEntry(
+ projectPath: string,
+ projectsDir: string,
+): Promise {
+ try {
+ const normalizedPath = normalizePath(projectPath);
+ if (!hasProjectFileExtension(normalizedPath)) {
+ return null;
+ }
+
+ const stats = await fs.stat(normalizedPath);
+ if (!stats.isFile()) {
+ return null;
+ }
+
+ const thumbnailPath = getProjectThumbnailPath(normalizedPath);
+ const thumbnailExists = await hasFreshProjectThumbnail(thumbnailPath, stats.mtimeMs);
+
+ return {
+ path: normalizedPath,
+ name: path
+ .basename(normalizedPath)
+ .replace(
+ new RegExp(
+ `\\.(${[PROJECT_FILE_EXTENSION, ...LEGACY_PROJECT_FILE_EXTENSIONS].join("|")})$`,
+ "i",
+ ),
+ "",
+ ),
+ updatedAt: stats.mtimeMs,
+ createdAt: stats.birthtimeMs || stats.ctimeMs,
+ thumbnailPath: thumbnailExists ? thumbnailPath : null,
+ isCurrent: Boolean(
+ currentProjectPath && normalizePath(currentProjectPath) === normalizedPath,
+ ),
+ isInProjectsDirectory: path.dirname(normalizedPath) === normalizePath(projectsDir),
+ };
+ } catch {
+ return null;
+ }
+}
+
+export async function listProjectLibraryEntries() {
+ const projectsDir = await getProjectsDir();
+ const projectPaths: string[] = [];
+
+ try {
+ const entries = await fs.readdir(projectsDir, { withFileTypes: true });
+ for (const entry of entries) {
+ if (!entry.isFile()) {
+ continue;
+ }
+
+ const entryPath = path.join(projectsDir, entry.name);
+ if (hasProjectFileExtension(entryPath)) {
+ projectPaths.push(entryPath);
+ }
+ }
+ } catch {
+ // Ignore directory read failures and fall back to recent files.
+ }
+
+ const recentProjectPaths = await loadRecentProjectPaths();
+ const candidatePaths = Array.from(new Set([...projectPaths, ...recentProjectPaths]));
+ const entries = (
+ await Promise.all(
+ candidatePaths.map((candidatePath) =>
+ buildProjectLibraryEntry(candidatePath, projectsDir),
+ ),
+ )
+ )
+ .filter((entry): entry is ProjectLibraryEntry => entry != null)
+ .sort((left, right) => right.updatedAt - left.updatedAt);
+
+ await saveRecentProjectPaths(entries.map((entry) => entry.path));
+
+ return {
+ projectsDir,
+ entries,
+ };
+}
+
+function isLoadableProjectData(projectData: unknown) {
+ if (!projectData || typeof projectData !== "object" || Array.isArray(projectData)) {
+ return false;
+ }
+
+ const candidate = projectData as {
+ version?: unknown;
+ projectId?: unknown;
+ videoPath?: unknown;
+ editor?: unknown;
+ };
+
+ return (
+ typeof candidate.version === "number" &&
+ (candidate.projectId === undefined || typeof candidate.projectId === "string") &&
+ typeof candidate.videoPath === "string" &&
+ candidate.videoPath.trim().length > 0 &&
+ candidate.editor != null &&
+ typeof candidate.editor === "object" &&
+ !Array.isArray(candidate.editor)
+ );
+}
+
+/** Read a listed project's preview without changing the active project or recording session. */
+export async function readProjectPreview(projectPath: string): Promise {
+ if (typeof projectPath !== "string") throw new Error("Invalid project path");
+ const normalizedPath = normalizePath(projectPath);
+ const { entries } = await listProjectLibraryEntries();
+ if (!entries.some((entry) => entry.path === normalizedPath))
+ throw new Error("Project is not in the library");
+ const project = parseJsonWithByteOrderMark(await fs.readFile(normalizedPath, "utf-8"));
+ if (!isLoadableProjectData(project)) throw new Error("Invalid project file format");
+ const media = await resolveProjectMediaSources(project);
+ if (!media.success) throw new Error(media.message);
+ const baseUrl = getMediaServerBaseUrl();
+ if (!baseUrl) throw new Error("Media server is not ready");
+ await rememberApprovedLocalReadPath(media.videoPath);
+ if (media.webcamPath) await rememberApprovedLocalReadPath(media.webcamPath);
+ return {
+ project: project as ProjectPreviewData["project"],
+ videoUrl: buildMediaUrl(baseUrl, media.videoPath),
+ webcamUrl: media.webcamPath ? buildMediaUrl(baseUrl, media.webcamPath) : null,
+ };
+}
+
+export async function loadProjectFromPath(projectPath: string) {
+ const normalizedPath = normalizePath(projectPath);
+ let project: unknown;
+ try {
+ const content = await fs.readFile(normalizedPath, "utf-8");
+ project = parseJsonWithByteOrderMark(content);
+ } catch (error) {
+ return {
+ success: false,
+ canceled: false,
+ message: `Failed to read project file: ${error instanceof Error ? error.message : String(error)}`,
+ };
+ }
+ if (!isLoadableProjectData(project)) {
+ return {
+ success: false,
+ canceled: false,
+ message: "Invalid project file format",
+ };
+ }
+ const mediaSources = await resolveProjectMediaSources(project);
+
+ if (!mediaSources.success) {
+ return {
+ success: false,
+ canceled: false,
+ message: mediaSources.message,
+ };
+ }
+ const projectObj = project as Record;
+ const editorObj = projectObj?.editor as Record | undefined;
+ const audioTracks = editorObj?.audioTracks as { sourcePath?: unknown }[] | undefined;
+ const audioRegions = editorObj?.audioRegions as { audioPath?: unknown }[] | undefined;
+ const approvedProjectPaths: Array = [
+ mediaSources.videoPath,
+ mediaSources.webcamPath,
+ ];
+ if (Array.isArray(audioTracks)) {
+ for (const track of audioTracks) {
+ if (typeof track?.sourcePath === "string") {
+ approvedProjectPaths.push(track.sourcePath);
+ }
+ }
+ }
+ if (Array.isArray(audioRegions)) {
+ for (const region of audioRegions) {
+ if (typeof region?.audioPath === "string") {
+ approvedProjectPaths.push(region.audioPath);
+ }
+ }
+ }
+ await replaceApprovedSessionLocalReadPaths(approvedProjectPaths);
+ await rememberRecentProject(normalizedPath);
+
+ setCurrentProjectPath(normalizedPath);
+ setCurrentVideoPath(mediaSources.videoPath);
+ setCurrentRecordingSession({
+ videoPath: mediaSources.videoPath,
+ webcamPath: mediaSources.webcamPath,
+ timeOffsetMs: 0,
+ } as RecordingSessionData);
+
+ return {
+ success: true,
+ path: normalizedPath,
+ project,
+ };
+}
+
+export function isTrustedProjectPath(filePath?: string | null): boolean {
+ if (!filePath || !currentProjectPath) return false;
+ return normalizePath(filePath) === normalizePath(currentProjectPath);
+}
diff --git a/electron/ipc/project/recentMetadata.test.ts b/electron/ipc/project/recentMetadata.test.ts
new file mode 100644
index 000000000..c20f49dee
--- /dev/null
+++ b/electron/ipc/project/recentMetadata.test.ts
@@ -0,0 +1,17 @@
+import { expect, it, vi } from "vitest";
+import { persistRecentMetadata } from "./recentMetadata";
+it("reports a metadata warning without rejecting an already committed operation", async () => {
+ const log = vi.spyOn(console, "warn").mockImplementation(() => undefined);
+ const result = await persistRecentMetadata(async () => {
+ throw Error("disk full");
+ });
+ expect(result).toContain("operation completed");
+ expect(result).toContain("disk full");
+ expect(log).toHaveBeenCalledOnce();
+ log.mockRestore();
+});
+it("has no warning after a successful metadata update", async () => {
+ const update = vi.fn().mockResolvedValue(undefined);
+ await expect(persistRecentMetadata(update)).resolves.toBeUndefined();
+ expect(update).toHaveBeenCalledOnce();
+});
diff --git a/electron/ipc/project/recentMetadata.ts b/electron/ipc/project/recentMetadata.ts
new file mode 100644
index 000000000..174bf365c
--- /dev/null
+++ b/electron/ipc/project/recentMetadata.ts
@@ -0,0 +1,13 @@
+/** Library history is secondary to a filesystem operation that already committed. */
+export async function persistRecentMetadata(
+ action: () => Promise,
+): Promise {
+ try {
+ await action();
+ return undefined;
+ } catch (error) {
+ const warning = `Project operation completed, but recent-project history could not be updated: ${String(error)}`;
+ console.warn(warning);
+ return warning;
+ }
+}
diff --git a/electron/ipc/project/renameLibraryProject.test.ts b/electron/ipc/project/renameLibraryProject.test.ts
new file mode 100644
index 000000000..048b6cfb7
--- /dev/null
+++ b/electron/ipc/project/renameLibraryProject.test.ts
@@ -0,0 +1,32 @@
+import fs from "node:fs/promises";
+import os from "node:os";
+import path from "node:path";
+import { expect, it } from "vitest";
+import { renameLibraryProject } from "./renameLibraryProject";
+
+it("renames a library project with its preview without overwriting another project", async () => {
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), "recordly-rename-"));
+ const original = path.join(dir, "Original.recordly"),
+ target = path.join(dir, "Renamed.recordly");
+ const sidecars = (file: string) => [`${file}.png`];
+ try {
+ await fs.writeFile(original, "project");
+ await fs.writeFile(`${original}.png`, "preview");
+ await fs.writeFile(target, "keep");
+ await expect(
+ renameLibraryProject(original, "Renamed", [original], sidecars),
+ ).rejects.toThrow();
+ expect(await fs.readFile(target, "utf8")).toBe("keep");
+ await fs.rm(target);
+ await expect(
+ renameLibraryProject(original, "../escape", [original], sidecars),
+ ).rejects.toThrow();
+ await expect(renameLibraryProject(original, "Renamed", [], sidecars)).rejects.toThrow();
+ expect(await renameLibraryProject(original, "Renamed", [original], sidecars)).toBe(target);
+ expect(await fs.readFile(target, "utf8")).toBe("project");
+ expect(await fs.readFile(`${target}.png`, "utf8")).toBe("preview");
+ await expect(fs.access(original)).rejects.toThrow();
+ } finally {
+ await fs.rm(dir, { recursive: true, force: true });
+ }
+});
diff --git a/electron/ipc/project/renameLibraryProject.ts b/electron/ipc/project/renameLibraryProject.ts
new file mode 100644
index 000000000..476646f01
--- /dev/null
+++ b/electron/ipc/project/renameLibraryProject.ts
@@ -0,0 +1,47 @@
+import fs from "node:fs/promises";
+import path from "node:path";
+
+export async function renameLibraryProject(
+ source: string,
+ name: string,
+ allowedPaths: string[],
+ sidecars: (path: string) => string[],
+) {
+ if (!allowedPaths.includes(source)) throw new Error("Project is not in the library");
+ const clean = name.trim();
+ if (
+ !clean ||
+ clean === "." ||
+ clean === ".." ||
+ /[<>:"/\\|?*]/.test(clean) ||
+ [...clean].some((char) => char.charCodeAt(0) < 32) ||
+ /[. ]$/.test(clean)
+ )
+ throw new Error("Choose a valid project name");
+ const target = path.join(path.dirname(source), `${clean}${path.extname(source)}`);
+ if (target === source) return target;
+ // An exclusive link prevents a rename from overwriting another project.
+ await fs.link(source, target);
+ const copied: string[] = [];
+ try {
+ const previous = sidecars(source),
+ next = sidecars(target);
+ for (let i = 0; i < previous.length; i++) {
+ try {
+ await fs.copyFile(previous[i], next[i], fs.constants.COPYFILE_EXCL);
+ copied.push(next[i]);
+ } catch (error) {
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
+ }
+ }
+ await fs.unlink(source);
+ } catch (error) {
+ await Promise.all([target, ...copied].map((file) => fs.rm(file, { force: true })));
+ throw error;
+ }
+ // Auxiliary files can be cleaned up independently once the project has moved.
+ await Promise.all(
+ sidecars(source).map((file) => fs.rm(file, { force: true }).catch(() => undefined)),
+ );
+ return target;
+}
diff --git a/electron/ipc/project/session.ts b/electron/ipc/project/session.ts
new file mode 100644
index 000000000..7b2131aee
--- /dev/null
+++ b/electron/ipc/project/session.ts
@@ -0,0 +1,140 @@
+import { constants as fsConstants } from "node:fs";
+import fs from "node:fs/promises";
+import path from "node:path";
+import { RECORDING_SESSION_MANIFEST_SUFFIX } from "../constants";
+import type { RecordingSessionData, RecordingSessionManifest } from "../types";
+import { normalizeVideoSourcePath, parseJsonWithByteOrderMark } from "../utils";
+
+function normalizeRecordingTimeOffsetMs(value: unknown): number {
+ return typeof value === "number" && Number.isFinite(value) ? Math.round(value) : 0;
+}
+
+export function getRecordingSessionManifestPath(videoPath: string) {
+ const extension = path.extname(videoPath);
+ const baseName = path.basename(videoPath, extension);
+ return path.join(path.dirname(videoPath), `${baseName}${RECORDING_SESSION_MANIFEST_SUFFIX}`);
+}
+
+export async function persistRecordingSessionManifest(
+ session: RecordingSessionData,
+): Promise {
+ const normalizedVideoPath = normalizeVideoSourcePath(session.videoPath);
+ if (!normalizedVideoPath) {
+ return;
+ }
+
+ const normalizedWebcamPath = normalizeVideoSourcePath(session.webcamPath ?? null);
+ const manifestPath = getRecordingSessionManifestPath(normalizedVideoPath);
+
+ if (!normalizedWebcamPath) {
+ await fs.rm(manifestPath, { force: true });
+ return;
+ }
+
+ const manifest: RecordingSessionManifest = {
+ version: 2,
+ videoFileName: path.basename(normalizedVideoPath),
+ webcamFileName: path.basename(normalizedWebcamPath),
+ timeOffsetMs: normalizeRecordingTimeOffsetMs(session.timeOffsetMs),
+ };
+
+ await fs.writeFile(manifestPath, JSON.stringify(manifest, null, 2), "utf-8");
+}
+
+export async function resolveRecordingSessionManifest(
+ videoPath?: string | null,
+): Promise {
+ const normalizedVideoPath = normalizeVideoSourcePath(videoPath);
+ if (!normalizedVideoPath) {
+ return null;
+ }
+
+ const manifestPath = getRecordingSessionManifestPath(normalizedVideoPath);
+
+ try {
+ const content = await fs.readFile(manifestPath, "utf-8");
+ const parsed = parseJsonWithByteOrderMark>(content);
+ if (parsed.version !== 1 && parsed.version !== 2) {
+ return null;
+ }
+
+ const webcamFileName =
+ typeof parsed.webcamFileName === "string" && parsed.webcamFileName.trim()
+ ? parsed.webcamFileName.trim()
+ : null;
+ if (!webcamFileName) {
+ return {
+ videoPath: normalizedVideoPath,
+ webcamPath: null,
+ timeOffsetMs: normalizeRecordingTimeOffsetMs(parsed.timeOffsetMs),
+ };
+ }
+
+ const webcamPath = path.join(path.dirname(normalizedVideoPath), webcamFileName);
+ const webcamExists = await fs
+ .access(webcamPath, fsConstants.F_OK)
+ .then(() => true)
+ .catch(() => false);
+
+ return {
+ videoPath: normalizedVideoPath,
+ webcamPath: webcamExists ? webcamPath : null,
+ timeOffsetMs: normalizeRecordingTimeOffsetMs(parsed.timeOffsetMs),
+ };
+ } catch {
+ return null;
+ }
+}
+
+export async function resolveLinkedWebcamPath(videoPath?: string | null): Promise {
+ const normalizedVideoPath = normalizeVideoSourcePath(videoPath);
+ if (!normalizedVideoPath) {
+ return null;
+ }
+
+ const extension = path.extname(normalizedVideoPath);
+ const baseName = path.basename(normalizedVideoPath, extension);
+ if (!baseName || baseName.endsWith("-webcam")) {
+ return null;
+ }
+
+ const candidateExtensions = Array.from(
+ new Set([extension, ".webm", ".mp4", ".mov", ".mkv", ".avi"].filter(Boolean)),
+ );
+
+ for (const candidateExtension of candidateExtensions) {
+ const candidatePath = path.join(
+ path.dirname(normalizedVideoPath),
+ `${baseName}-webcam${candidateExtension}`,
+ );
+
+ try {
+ await fs.access(candidatePath, fsConstants.F_OK);
+ return candidatePath;
+ } catch {
+ continue;
+ }
+ }
+
+ return null;
+}
+
+export async function resolveRecordingSession(
+ videoPath?: string | null,
+): Promise {
+ const manifestSession = await resolveRecordingSessionManifest(videoPath);
+ if (manifestSession) {
+ return manifestSession;
+ }
+
+ const normalizedVideoPath = normalizeVideoSourcePath(videoPath);
+ if (!normalizedVideoPath) {
+ return null;
+ }
+
+ const linkedWebcamPath = await resolveLinkedWebcamPath(normalizedVideoPath);
+ return {
+ videoPath: normalizedVideoPath,
+ webcamPath: linkedWebcamPath,
+ };
+}
diff --git a/electron/ipc/project/thumbnailFreshness.test.ts b/electron/ipc/project/thumbnailFreshness.test.ts
new file mode 100644
index 000000000..c70c576b1
--- /dev/null
+++ b/electron/ipc/project/thumbnailFreshness.test.ts
@@ -0,0 +1,32 @@
+import fs from "node:fs/promises";
+import os from "node:os";
+import path from "node:path";
+import { expect, it } from "vitest";
+import { hasFreshProjectThumbnail } from "./thumbnailFreshness";
+
+it("rejects legacy, stale, missing and broken previews while accepting a fresh current-size or larger PNG", async () => {
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), "recordly-preview-"));
+ const file = path.join(dir, "preview.png");
+ try {
+ expect(await hasFreshProjectThumbnail(file, 0)).toBe(false);
+ const header = Buffer.alloc(24);
+ Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]).copy(header);
+ header.writeUInt32BE(320, 16);
+ header.writeUInt32BE(180, 20);
+ await fs.writeFile(file, header);
+ expect(await hasFreshProjectThumbnail(file, 0)).toBe(false);
+ header.writeUInt32BE(640, 16);
+ header.writeUInt32BE(480, 20);
+ await fs.writeFile(file, header);
+ expect(await hasFreshProjectThumbnail(file, 0)).toBe(true);
+ header.writeUInt32BE(1600, 16);
+ header.writeUInt32BE(1200, 20);
+ await fs.writeFile(file, header);
+ expect(await hasFreshProjectThumbnail(file, 0)).toBe(true);
+ expect(await hasFreshProjectThumbnail(file, Date.now() + 10000)).toBe(false);
+ await fs.writeFile(file, "broken");
+ expect(await hasFreshProjectThumbnail(file, 0)).toBe(false);
+ } finally {
+ await fs.rm(dir, { recursive: true, force: true });
+ }
+});
diff --git a/electron/ipc/project/thumbnailFreshness.ts b/electron/ipc/project/thumbnailFreshness.ts
new file mode 100644
index 000000000..89f33cf62
--- /dev/null
+++ b/electron/ipc/project/thumbnailFreshness.ts
@@ -0,0 +1,28 @@
+import fs from "node:fs/promises";
+import {
+ PROJECT_THUMBNAIL_WIDTH,
+ PROJECT_THUMBNAIL_HEIGHT,
+} from "../../../src/lib/projectThumbnail";
+
+// Older releases wrote 320px previews. Hide those and previews predating edits;
+// the editor replaces them with a current-size render when returning home.
+export async function hasFreshProjectThumbnail(thumbnailPath: string, projectModifiedAt: number) {
+ let file: Awaited> | undefined;
+ try {
+ file = await fs.open(thumbnailPath, "r");
+ const stat = await file.stat();
+ if (stat.mtimeMs < projectModifiedAt) return false;
+ const header = Buffer.alloc(24);
+ const { bytesRead } = await file.read(header, 0, 24, 0);
+ return (
+ bytesRead === 24 &&
+ header.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])) &&
+ header.readUInt32BE(16) >= PROJECT_THUMBNAIL_WIDTH &&
+ header.readUInt32BE(20) >= PROJECT_THUMBNAIL_HEIGHT
+ );
+ } catch {
+ return false;
+ } finally {
+ await file?.close();
+ }
+}
diff --git a/electron/ipc/project/trashProjects.test.ts b/electron/ipc/project/trashProjects.test.ts
new file mode 100644
index 000000000..fc1107346
--- /dev/null
+++ b/electron/ipc/project/trashProjects.test.ts
@@ -0,0 +1,34 @@
+import { expect, it, vi } from "vitest";
+import { trashLibraryProjects } from "./trashProjects";
+it("rejects paths outside the project library before touching files", async () => {
+ const trash = vi.fn();
+ await expect(
+ trashLibraryProjects(["/private/tmp/a.recordly", "/private/tmp/private.txt"], {
+ list: async () => ({ entries: [{ path: "/private/tmp/a.recordly" }] }),
+ trash,
+ thumbnailPath: (p) => p + ".png",
+ }),
+ ).rejects.toThrow("not in the library");
+ expect(trash).not.toHaveBeenCalled();
+});
+it("trashes only selected project files, deduplicates paths, and reports partial failures", async () => {
+ const trash = vi.fn(async (p: string) => {
+ if (p.endsWith("b.recordly")) throw Error("locked");
+ });
+ const result = await trashLibraryProjects(
+ ["/private/tmp/a.recordly", "/private/tmp/a.recordly", "/private/tmp/b.recordly"],
+ {
+ list: async () => ({
+ entries: [{ path: "/private/tmp/a.recordly" }, { path: "/private/tmp/b.recordly" }],
+ }),
+ trash,
+ thumbnailPath: (p) => p + ".missing.png",
+ },
+ );
+ expect(result.deleted).toEqual(["/private/tmp/a.recordly"]);
+ expect(result.errors).toEqual(["Could not trash b.recordly"]);
+ expect(trash.mock.calls.map(([p]) => p)).toEqual([
+ "/private/tmp/a.recordly",
+ "/private/tmp/b.recordly",
+ ]);
+});
diff --git a/electron/ipc/project/trashProjects.ts b/electron/ipc/project/trashProjects.ts
new file mode 100644
index 000000000..8ef1e2c84
--- /dev/null
+++ b/electron/ipc/project/trashProjects.ts
@@ -0,0 +1,45 @@
+import path from "node:path";
+import fs from "node:fs/promises";
+import { getProjectBackupPath } from "./atomicSave";
+
+type Dependencies = {
+ list: () => Promise<{ entries: Array<{ path: string }> }>;
+ trash: (path: string) => Promise;
+ thumbnailPath: (path: string) => string;
+};
+/** Only library project files may be trashed; referenced recordings are never touched. */
+export async function trashLibraryProjects(paths: unknown, deps: Dependencies) {
+ if (
+ !Array.isArray(paths) ||
+ !paths.length ||
+ paths.length > 500 ||
+ paths.some((p) => typeof p !== "string")
+ )
+ throw new Error("Invalid project selection");
+ const allowed = new Set((await deps.list()).entries.map((e) => path.resolve(e.path)));
+ const selected = [...new Set((paths as string[]).map((p) => path.resolve(p)))];
+ if (selected.some((p) => !allowed.has(p))) throw new Error("Project is not in the library");
+ const deleted: string[] = [];
+ const errors: string[] = [];
+ for (const projectPath of selected) {
+ try {
+ await deps.trash(projectPath);
+ deleted.push(projectPath);
+ for (const sidecar of [
+ deps.thumbnailPath(projectPath),
+ getProjectBackupPath(projectPath),
+ ]) {
+ try {
+ await fs.access(sidecar);
+ await deps.trash(sidecar);
+ } catch (e) {
+ if ((e as NodeJS.ErrnoException).code !== "ENOENT")
+ errors.push(`Could not trash project sidecar: ${sidecar}`);
+ }
+ }
+ } catch {
+ errors.push(`Could not trash ${path.basename(projectPath)}`);
+ }
+ }
+ return { deleted, errors };
+}
diff --git a/electron/ipc/recording/audioFilters.test.ts b/electron/ipc/recording/audioFilters.test.ts
new file mode 100644
index 000000000..1e80e2665
--- /dev/null
+++ b/electron/ipc/recording/audioFilters.test.ts
@@ -0,0 +1,74 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ BROWSER_MIC_SIDECAR_FILTERS,
+ BROWSER_MIC_SIDECAR_NO_AGC_GAIN_FILTERS,
+ getBrowserMicSidecarFilters,
+ RECORDING_AUDIO_SIDECAR_DEBUG_ENV,
+ shouldKeepRecordingAudioSidecars,
+ WINDOWS_NATIVE_MIC_PRE_FILTERS,
+} from "./audioFilters";
+
+describe("Windows native mic pre-filter policy", () => {
+ it("keeps repair filters without automatic gain or loudness normalization", () => {
+ expect(WINDOWS_NATIVE_MIC_PRE_FILTERS).toContain("adeclip=threshold=1");
+ expect(
+ WINDOWS_NATIVE_MIC_PRE_FILTERS.some((filter) =>
+ /(^|,)(loudnorm|dynaudnorm|volume)=/i.test(filter),
+ ),
+ ).toBe(false);
+ });
+
+ it("keeps native audio sidecars only when explicitly requested", () => {
+ expect(shouldKeepRecordingAudioSidecars({})).toBe(false);
+ expect(
+ shouldKeepRecordingAudioSidecars({
+ [RECORDING_AUDIO_SIDECAR_DEBUG_ENV]: "1",
+ }),
+ ).toBe(true);
+ expect(
+ shouldKeepRecordingAudioSidecars({
+ [RECORDING_AUDIO_SIDECAR_DEBUG_ENV]: "true",
+ }),
+ ).toBe(true);
+ expect(
+ shouldKeepRecordingAudioSidecars({
+ [RECORDING_AUDIO_SIDECAR_DEBUG_ENV]: "off",
+ }),
+ ).toBe(false);
+ });
+});
+
+describe("browser microphone sidecar post-processing", () => {
+ it("keeps the browser fallback chain conservative and speech-oriented", () => {
+ expect(BROWSER_MIC_SIDECAR_FILTERS).toEqual([
+ "adeclip=threshold=1",
+ "adeclick=w=40:o=75:t=3:b=2",
+ "highpass=f=85",
+ "lowpass=f=9500",
+ "afftdn=nr=10:nf=-45:tn=1",
+ "alimiter=limit=0.92:level=0",
+ ]);
+ expect(
+ BROWSER_MIC_SIDECAR_FILTERS.some((filter) =>
+ /(^|,)(loudnorm|dynaudnorm|volume)=/i.test(filter),
+ ),
+ ).toBe(false);
+ });
+
+ it("adds bounded speech gain only for the no-AGC browser mic profile", () => {
+ expect(getBrowserMicSidecarFilters("processed")).toEqual(BROWSER_MIC_SIDECAR_FILTERS);
+ expect(getBrowserMicSidecarFilters("no-agc")).toEqual([
+ "adeclip=threshold=1",
+ "adeclick=w=40:o=75:t=3:b=2",
+ "highpass=f=85",
+ "lowpass=f=9500",
+ "afftdn=nr=10:nf=-45:tn=1",
+ ...BROWSER_MIC_SIDECAR_NO_AGC_GAIN_FILTERS,
+ ]);
+ expect(BROWSER_MIC_SIDECAR_NO_AGC_GAIN_FILTERS).toContain(
+ "speechnorm=p=0.92:e=12:c=2:r=0.0005:f=0.001",
+ );
+ expect(BROWSER_MIC_SIDECAR_NO_AGC_GAIN_FILTERS).toContain("alimiter=limit=0.92:level=0");
+ });
+});
diff --git a/electron/ipc/recording/audioFilters.ts b/electron/ipc/recording/audioFilters.ts
new file mode 100644
index 000000000..b465f5f20
--- /dev/null
+++ b/electron/ipc/recording/audioFilters.ts
@@ -0,0 +1,40 @@
+// Keep native mic capture dry by default. Automatic loudness normalization
+// amplified wireless-headset noise and WASAPI discontinuities during beta tests.
+export const WINDOWS_NATIVE_MIC_PRE_FILTERS = ["adeclip=threshold=1"];
+
+// Browser mic fallback uses Chromium/WebRTC voice processing, but beta tests
+// showed its realtime AGC can introduce short crackle bursts on some Realtek
+// and headset paths. Default to no AGC, then restore usable level offline with
+// bounded speech expansion and a limiter.
+export const BROWSER_MIC_SIDECAR_BASE_FILTERS = [
+ "adeclip=threshold=1",
+ "adeclick=w=40:o=75:t=3:b=2",
+ "highpass=f=85",
+ "lowpass=f=9500",
+ "afftdn=nr=10:nf=-45:tn=1",
+];
+
+export const BROWSER_MIC_SIDECAR_NO_AGC_GAIN_FILTERS = [
+ "speechnorm=p=0.92:e=12:c=2:r=0.0005:f=0.001",
+ "alimiter=limit=0.92:level=0",
+];
+
+export const BROWSER_MIC_SIDECAR_FILTERS = [
+ ...BROWSER_MIC_SIDECAR_BASE_FILTERS,
+ "alimiter=limit=0.92:level=0",
+];
+
+export function getBrowserMicSidecarFilters(profile?: string | null) {
+ if (profile === "no-agc") {
+ return [...BROWSER_MIC_SIDECAR_BASE_FILTERS, ...BROWSER_MIC_SIDECAR_NO_AGC_GAIN_FILTERS];
+ }
+
+ return BROWSER_MIC_SIDECAR_FILTERS;
+}
+
+export const RECORDING_AUDIO_SIDECAR_DEBUG_ENV = "RECORDLY_KEEP_RECORDING_AUDIO_SIDECARS"; // not used yet, because we need to have seperate audio files for system and mic for each recording
+
+export function shouldKeepRecordingAudioSidecars(env: NodeJS.ProcessEnv = process.env) {
+ const value = env[RECORDING_AUDIO_SIDECAR_DEBUG_ENV]?.trim().toLowerCase();
+ return value === "1" || value === "true" || value === "yes" || value === "on";
+}
diff --git a/electron/ipc/recording/diagnostics.test.ts b/electron/ipc/recording/diagnostics.test.ts
new file mode 100644
index 000000000..a0092a602
--- /dev/null
+++ b/electron/ipc/recording/diagnostics.test.ts
@@ -0,0 +1,446 @@
+import fs from "node:fs/promises";
+import os from "node:os";
+import path from "node:path";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+type ExecFileCallback = (error: Error | null, stdout?: string, stderr?: string) => void;
+
+describe("getCompanionAudioFallbackPaths", () => {
+ let tempRoot: string;
+ let appDataPath: string;
+ let userDataPath: string;
+ let tempPath: string;
+ let appPath: string;
+ let execFileMock: ReturnType;
+
+ beforeEach(async () => {
+ tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "recordly-companion-audio-"));
+ appDataPath = path.join(tempRoot, "AppData");
+ userDataPath = path.join(tempRoot, "UserData");
+ tempPath = path.join(tempRoot, "Temp");
+ appPath = path.join(tempRoot, "App");
+ await Promise.all(
+ [appDataPath, userDataPath, tempPath, appPath].map((dirPath) =>
+ fs.mkdir(dirPath, { recursive: true }),
+ ),
+ );
+ execFileMock = vi.fn(
+ (
+ _file: string,
+ _args: string[],
+ _options: Record,
+ callback: ExecFileCallback,
+ ) => {
+ callback(null, "", "");
+ },
+ );
+
+ vi.resetModules();
+ vi.doMock("electron", () => ({
+ app: {
+ isPackaged: false,
+ getAppPath: () => appPath,
+ getPath: (name: string) => {
+ if (name === "appData") return appDataPath;
+ if (name === "userData") return userDataPath;
+ if (name === "temp") return tempPath;
+ return tempRoot;
+ },
+ setPath: () => undefined,
+ },
+ }));
+ vi.doMock("node:child_process", () => ({
+ execFile: execFileMock,
+ }));
+ vi.doMock("../ffmpeg/binary", () => ({
+ getFfmpegBinaryPath: () => "ffmpeg",
+ getFfprobeBinaryPath: () => "ffprobe",
+ }));
+ });
+
+ afterEach(async () => {
+ vi.resetModules();
+ vi.doUnmock("electron");
+ vi.doUnmock("node:child_process");
+ vi.doUnmock("../ffmpeg/binary");
+ if (tempRoot) {
+ await fs.rm(tempRoot, { recursive: true, force: true });
+ }
+ });
+
+ it("returns companion audio files directly when the video has no embedded audio", async () => {
+ const videoPath = path.join(tempRoot, "recording.mp4");
+ const systemPath = path.join(tempRoot, "recording.system.wav");
+ const micPath = path.join(tempRoot, "recording.mic.wav");
+
+ await Promise.all([
+ fs.writeFile(videoPath, "video"),
+ fs.writeFile(systemPath, "system"),
+ fs.writeFile(micPath, "mic"),
+ ]);
+
+ execFileMock.mockImplementation(
+ (
+ _file: string,
+ _args: string[],
+ _options: Record,
+ callback: ExecFileCallback,
+ ) => {
+ const error = new Error("ffmpeg probe failed") as Error & { stderr?: string };
+ error.stderr = "Stream #0:0: Video: h264";
+ callback(error, "", error.stderr);
+ },
+ );
+
+ const { getCompanionAudioFallbackPaths } = await import("./diagnostics");
+
+ await expect(getCompanionAudioFallbackPaths(videoPath)).resolves.toEqual([
+ systemPath,
+ micPath,
+ ]);
+ });
+
+ it("keeps the embedded source audio and adds the mic companion when both are present", async () => {
+ const videoPath = path.join(tempRoot, "recording.mp4");
+ const systemPath = path.join(tempRoot, "recording.system.wav");
+ const micPath = path.join(tempRoot, "recording.mic.wav");
+
+ await Promise.all([
+ fs.writeFile(videoPath, "video"),
+ fs.writeFile(systemPath, "system"),
+ fs.writeFile(micPath, "mic"),
+ ]);
+
+ execFileMock.mockImplementation(
+ (
+ _file: string,
+ _args: string[],
+ _options: Record,
+ callback: ExecFileCallback,
+ ) => {
+ const error = new Error("ffmpeg probe found embedded audio") as Error & {
+ stderr?: string;
+ };
+ error.stderr = "Stream #0:1: Audio: aac";
+ callback(error, "", error.stderr);
+ },
+ );
+
+ const { getCompanionAudioFallbackPaths } = await import("./diagnostics");
+
+ await expect(getCompanionAudioFallbackPaths(videoPath)).resolves.toEqual([
+ videoPath,
+ micPath,
+ ]);
+ });
+
+ it("returns both mac sidecars instead of the video when a system sidecar exists", async () => {
+ const videoPath = path.join(tempRoot, "recording.mp4");
+ const systemPath = path.join(tempRoot, "recording.system.m4a");
+ const micPath = path.join(tempRoot, "recording.mic.m4a");
+
+ await Promise.all([
+ fs.writeFile(videoPath, "video"),
+ fs.writeFile(systemPath, "system"),
+ fs.writeFile(micPath, "mic"),
+ ]);
+
+ execFileMock.mockImplementation(
+ (
+ _file: string,
+ _args: string[],
+ _options: Record,
+ callback: ExecFileCallback,
+ ) => {
+ const error = new Error("ffmpeg probe found embedded audio") as Error & {
+ stderr?: string;
+ };
+ error.stderr = "Stream #0:1: Audio: aac";
+ callback(error, "", error.stderr);
+ },
+ );
+
+ const { getCompanionAudioFallbackPaths } = await import("./diagnostics");
+
+ // The inline mp4 track holds system audio only, so returning [videoPath]
+ // here silently dropped the microphone.
+ await expect(getCompanionAudioFallbackPaths(videoPath)).resolves.toEqual([
+ systemPath,
+ micPath,
+ ]);
+ });
+
+ it("prefers the mac mic companion alone when embedded audio already exists and no system sidecar is present", async () => {
+ const videoPath = path.join(tempRoot, "recording.mp4");
+ const micPath = path.join(tempRoot, "recording.mic.m4a");
+
+ await Promise.all([fs.writeFile(videoPath, "video"), fs.writeFile(micPath, "mic")]);
+
+ execFileMock.mockImplementation(
+ (
+ _file: string,
+ _args: string[],
+ _options: Record,
+ callback: ExecFileCallback,
+ ) => {
+ const error = new Error("ffmpeg probe found embedded audio") as Error & {
+ stderr?: string;
+ };
+ error.stderr = "Stream #0:1: Audio: aac";
+ callback(error, "", error.stderr);
+ },
+ );
+
+ const { getCompanionAudioFallbackPaths } = await import("./diagnostics");
+
+ await expect(getCompanionAudioFallbackPaths(videoPath)).resolves.toEqual([micPath]);
+ });
+
+ it("loads saved sidecar timing metadata alongside companion audio paths", async () => {
+ const videoPath = path.join(tempRoot, "recording.mp4");
+ const micPath = path.join(tempRoot, "recording.mic.webm");
+
+ await Promise.all([
+ fs.writeFile(videoPath, "video"),
+ fs.writeFile(micPath, "mic"),
+ fs.writeFile(`${micPath}.json`, `\ufeff${JSON.stringify({ startDelayMs: 2750 })}`),
+ ]);
+
+ execFileMock.mockImplementation(
+ (
+ _file: string,
+ _args: string[],
+ _options: Record,
+ callback: ExecFileCallback,
+ ) => {
+ const error = new Error("ffmpeg probe failed") as Error & { stderr?: string };
+ error.stderr = "Stream #0:0: Video: h264";
+ callback(error, "", error.stderr);
+ },
+ );
+
+ const { getCompanionAudioFallbackInfo } = await import("./diagnostics");
+
+ await expect(getCompanionAudioFallbackInfo(videoPath)).resolves.toEqual({
+ paths: [micPath],
+ startDelayMsByPath: {
+ [micPath]: 2750,
+ },
+ });
+ });
+
+ it("scales audio mux timeout for long recordings", async () => {
+ const { getRecordingAudioMuxTimeoutMs } = await import("./diagnostics");
+
+ expect(getRecordingAudioMuxTimeoutMs(0)).toBe(5 * 60 * 1000);
+ expect(getRecordingAudioMuxTimeoutMs(29 * 60 + 29.41)).toBeGreaterThan(120000);
+ expect(getRecordingAudioMuxTimeoutMs(29 * 60 + 29.41)).toBeCloseTo(
+ (29 * 60 + 29.41) * 1000 + 60 * 1000,
+ 0,
+ );
+ });
+
+ it("uses video stream frames when container duration is misleading", async () => {
+ const { parseFfprobeVideoStreamDuration } = await import("./diagnostics");
+
+ expect(
+ parseFfprobeVideoStreamDuration(
+ JSON.stringify({
+ streams: [
+ {
+ duration: "18.000000",
+ nb_read_frames: "540",
+ avg_frame_rate: "30/1",
+ },
+ ],
+ }),
+ ),
+ ).toEqual({
+ durationSeconds: 18,
+ frameCount: 540,
+ frameRate: 30,
+ });
+ });
+
+ it("derives video stream duration from frame count when stream duration is absent", async () => {
+ const { parseFfprobeVideoStreamDuration } = await import("./diagnostics");
+
+ expect(
+ parseFfprobeVideoStreamDuration(
+ JSON.stringify({
+ streams: [
+ {
+ nb_read_frames: "540",
+ avg_frame_rate: "30/1",
+ },
+ ],
+ }),
+ ),
+ ).toEqual({
+ durationSeconds: 18,
+ frameCount: 540,
+ frameRate: 30,
+ });
+ });
+
+ it("writes a recording diagnostics sidecar with stream and audio probes", async () => {
+ const videoPath = path.join(tempRoot, "recording-123.mp4");
+ const micPath = path.join(tempRoot, "recording-123.mic.wav");
+ await Promise.all([
+ fs.writeFile(videoPath, "video"),
+ fs.writeFile(micPath, "mic"),
+ fs.writeFile(`${micPath}.json`, JSON.stringify({ startDelayMs: 125 })),
+ ]);
+
+ execFileMock.mockImplementation(
+ (
+ file: string,
+ args: string[],
+ _options: Record,
+ callback: ExecFileCallback,
+ ) => {
+ if (file === "ffprobe" || args.includes("-of")) {
+ callback(
+ null,
+ JSON.stringify({
+ streams: [
+ {
+ duration: "18.000000",
+ nb_read_frames: "540",
+ avg_frame_rate: "30/1",
+ },
+ ],
+ }),
+ "",
+ );
+ return;
+ }
+
+ const error = new Error("ffmpeg probe") as Error & { stderr?: string };
+ error.stderr = "Duration: 00:00:18.00, start: 0.000000";
+ callback(error, "", error.stderr);
+ },
+ );
+
+ const { getRecordingDiagnosticsPath, writeRecordingDiagnosticsSnapshot } = await import(
+ "./diagnostics"
+ );
+
+ const diagnosticsPath = await writeRecordingDiagnosticsSnapshot(videoPath, {
+ backend: "windows-wgc",
+ phase: "mux-start",
+ expectedDurationMs: 60_000,
+ outputPath: videoPath,
+ microphonePath: micPath,
+ details: {
+ hasMicrophone: true,
+ },
+ });
+ const diagnostics = JSON.parse(await fs.readFile(diagnosticsPath, "utf8"));
+
+ expect(diagnosticsPath).toBe(getRecordingDiagnosticsPath(videoPath));
+ expect(diagnostics.events).toHaveLength(1);
+ expect(diagnostics.latest.expectedDurationMs).toBe(60_000);
+ expect(diagnostics.latest.media.video.stream).toEqual({
+ durationSeconds: 18,
+ frameCount: 540,
+ frameRate: 30,
+ });
+ expect(diagnostics.latest.media.microphone).toMatchObject({
+ path: micPath,
+ exists: true,
+ containerDurationSeconds: 18,
+ startDelayMs: 125,
+ });
+ });
+
+ it("ignores invalid sidecar timing metadata values", async () => {
+ const micPath = path.join(tempRoot, "recording.mic.wav");
+ await Promise.all([
+ fs.writeFile(micPath, "mic"),
+ fs.writeFile(`${micPath}.json`, JSON.stringify({ startDelayMs: -250 })),
+ ]);
+
+ const { getCompanionAudioStartDelayMs } = await import("./diagnostics");
+
+ await expect(getCompanionAudioStartDelayMs(micPath)).resolves.toBeNull();
+ });
+
+ it("classifies wall-clock mic chunk gaps covered by pause intervals", async () => {
+ const { summarizeMicrophoneChunkTiming } = await import("./diagnostics");
+
+ expect(
+ summarizeMicrophoneChunkTiming(
+ [
+ {
+ index: 0,
+ size: 1024,
+ elapsedMs: 250,
+ deltaMs: null,
+ recordedElapsedMs: 250,
+ recordedDeltaMs: null,
+ },
+ {
+ index: 1,
+ size: 1024,
+ elapsedMs: 8250,
+ deltaMs: 8000,
+ recordedElapsedMs: 500,
+ recordedDeltaMs: 250,
+ },
+ ],
+ [{ startElapsedMs: 250, endElapsedMs: 8250, durationMs: 7750 }],
+ 250,
+ ),
+ ).toMatchObject({
+ status: "pause-accounted",
+ wallClockGapCount: 1,
+ recordedGapCount: 0,
+ pausedDurationMs: 7750,
+ });
+ });
+
+ it("flags recorded mic chunk gaps that remain after pause accounting", async () => {
+ const { summarizeMicrophoneChunkTiming } = await import("./diagnostics");
+
+ expect(
+ summarizeMicrophoneChunkTiming(
+ [
+ {
+ index: 0,
+ size: 1024,
+ elapsedMs: 250,
+ deltaMs: null,
+ recordedElapsedMs: 250,
+ recordedDeltaMs: null,
+ },
+ {
+ index: 1,
+ size: 1024,
+ elapsedMs: 2500,
+ deltaMs: 2250,
+ recordedElapsedMs: 2500,
+ recordedDeltaMs: 2250,
+ },
+ ],
+ [],
+ 250,
+ ),
+ ).toMatchObject({
+ status: "needs-review",
+ wallClockGapCount: 1,
+ recordedGapCount: 1,
+ });
+ });
+
+ it("rejects tiny MP4 container-only outputs before they reach the editor", async () => {
+ const videoPath = path.join(tempRoot, "recording-123.mp4");
+ await fs.writeFile(videoPath, Buffer.alloc(261));
+
+ const { validateRecordedVideo } = await import("./diagnostics");
+
+ await expect(validateRecordedVideo(videoPath)).rejects.toThrow(
+ "Recorded output is too small to contain playable video",
+ );
+ });
+});
diff --git a/electron/ipc/recording/diagnostics.ts b/electron/ipc/recording/diagnostics.ts
new file mode 100644
index 000000000..2c7c7cd61
--- /dev/null
+++ b/electron/ipc/recording/diagnostics.ts
@@ -0,0 +1,637 @@
+import { isLibrarySequenceSource } from "./sequenceSource";
+import { execFile } from "node:child_process";
+import fs from "node:fs/promises";
+import { promisify } from "node:util";
+import { COMPANION_AUDIO_LAYOUTS } from "../constants";
+import { getFfmpegBinaryPath, getFfprobeBinaryPath } from "../ffmpeg/binary";
+import { lastNativeCaptureDiagnostics, setLastNativeCaptureDiagnostics } from "../state";
+import type { CompanionAudioCandidate, NativeCaptureDiagnostics } from "../types";
+import { parseJsonWithByteOrderMark } from "../utils";
+
+const execFileAsync = promisify(execFile);
+export const MIN_VALID_RECORDED_VIDEO_BYTES = 1024;
+export const RECORDING_AUDIO_MUX_MIN_TIMEOUT_MS = 5 * 60 * 1000;
+export const RECORDING_AUDIO_MUX_MAX_TIMEOUT_MS = 2 * 60 * 60 * 1000;
+
+type CompanionAudioTimingMetadata = {
+ startDelayMs?: number;
+};
+
+export type MicrophoneChunkTimingEvent = {
+ index: number;
+ size: number;
+ elapsedMs: number;
+ deltaMs: number | null;
+ recordedElapsedMs?: number;
+ recordedDeltaMs?: number | null;
+};
+
+export type MicrophonePauseInterval = {
+ startElapsedMs: number;
+ endElapsedMs?: number;
+ durationMs?: number;
+};
+
+export type VideoStreamDurationProbe = {
+ durationSeconds: number | null;
+ frameCount: number | null;
+ frameRate: number | null;
+};
+
+export type RecordingDiagnosticsSnapshot = {
+ backend: NativeCaptureDiagnostics["backend"];
+ phase:
+ | `${NativeCaptureDiagnostics["phase"]}`
+ | "pad"
+ | "mic-sidecar"
+ | "mux-start"
+ | "mux-complete"
+ | "mux-error";
+ expectedDurationMs?: number | null;
+ outputPath?: string | null;
+ systemAudioPath?: string | null;
+ microphonePath?: string | null;
+ processOutput?: string;
+ error?: string;
+ details?: Record;
+};
+
+type RecordingDiagnosticsLog = {
+ version: 1;
+ createdAt: string;
+ updatedAt: string;
+ videoPath: string;
+ diagnosticsPath: string;
+ events: unknown[];
+ latest?: unknown;
+};
+
+export function recordNativeCaptureDiagnostics(
+ diagnostics: Omit,
+) {
+ setLastNativeCaptureDiagnostics({
+ timestamp: new Date().toISOString(),
+ ...diagnostics,
+ });
+
+ return lastNativeCaptureDiagnostics;
+}
+
+export async function getFileSizeIfPresent(filePath: string | null | undefined) {
+ if (!filePath) {
+ return null;
+ }
+
+ try {
+ const stat = await fs.stat(filePath);
+ return stat.size;
+ } catch {
+ return null;
+ }
+}
+
+export function parseFfmpegDurationSeconds(stderr: string) {
+ const match = stderr.match(/Duration:\s+(\d+):(\d+):(\d+(?:\.\d+)?)/i);
+ if (!match) {
+ return null;
+ }
+
+ const hours = Number(match[1]);
+ const minutes = Number(match[2]);
+ const seconds = Number(match[3]);
+ if (![hours, minutes, seconds].every(Number.isFinite)) {
+ return null;
+ }
+
+ return hours * 3600 + minutes * 60 + seconds;
+}
+
+export function getRecordingAudioMuxTimeoutMs(durationSeconds: number) {
+ if (!Number.isFinite(durationSeconds) || durationSeconds <= 0) {
+ return RECORDING_AUDIO_MUX_MIN_TIMEOUT_MS;
+ }
+
+ const realtimeMuxBudgetMs = Math.ceil(durationSeconds * 1000) + 60 * 1000;
+ return Math.min(
+ RECORDING_AUDIO_MUX_MAX_TIMEOUT_MS,
+ Math.max(RECORDING_AUDIO_MUX_MIN_TIMEOUT_MS, realtimeMuxBudgetMs),
+ );
+}
+
+function getFiniteNonNegativeNumber(value: unknown) {
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : null;
+}
+
+export function summarizeMicrophoneChunkTiming(
+ chunkEvents: MicrophoneChunkTimingEvent[] | null | undefined,
+ pauseIntervals: MicrophonePauseInterval[] | null | undefined = [],
+ timesliceMs = 250,
+) {
+ const normalizedTimesliceMs =
+ Number.isFinite(timesliceMs) && timesliceMs > 0 ? Math.round(timesliceMs) : 250;
+ const thresholdMs = Math.max(600, Math.round(normalizedTimesliceMs * 2.5));
+ const events = Array.isArray(chunkEvents) ? chunkEvents : [];
+ const pauses = Array.isArray(pauseIntervals) ? pauseIntervals : [];
+ const pausedDurationMs = pauses.reduce((total, interval) => {
+ const durationMs = getFiniteNonNegativeNumber(interval.durationMs);
+ return durationMs === null ? total : total + Math.round(durationMs);
+ }, 0);
+
+ const wallClockGaps = events
+ .filter((event) => (event.deltaMs ?? 0) > thresholdMs)
+ .map((event) => ({
+ index: event.index,
+ deltaMs: Math.round(event.deltaMs ?? 0),
+ elapsedMs: Math.round(event.elapsedMs),
+ }));
+ const recordedGaps = events
+ .filter((event) => {
+ const recordedDeltaMs =
+ getFiniteNonNegativeNumber(event.recordedDeltaMs) ?? event.deltaMs ?? 0;
+ return recordedDeltaMs > thresholdMs;
+ })
+ .map((event) => ({
+ index: event.index,
+ deltaMs: Math.round(
+ getFiniteNonNegativeNumber(event.recordedDeltaMs) ?? event.deltaMs ?? 0,
+ ),
+ recordedElapsedMs: Math.round(event.recordedElapsedMs ?? event.elapsedMs),
+ }));
+ const maxWallClockDeltaMs = events.reduce(
+ (maxDeltaMs, event) => Math.max(maxDeltaMs, event.deltaMs ?? 0),
+ 0,
+ );
+ const maxRecordedDeltaMs = events.reduce((maxDeltaMs, event) => {
+ const recordedDeltaMs =
+ getFiniteNonNegativeNumber(event.recordedDeltaMs) ?? event.deltaMs ?? 0;
+ return Math.max(maxDeltaMs, recordedDeltaMs);
+ }, 0);
+ const status =
+ recordedGaps.length > 0
+ ? "needs-review"
+ : wallClockGaps.length > 0 && pausedDurationMs > 0
+ ? "pause-accounted"
+ : "ok";
+
+ return {
+ status,
+ timesliceMs: normalizedTimesliceMs,
+ thresholdMs,
+ eventCount: events.length,
+ pauseIntervalCount: pauses.length,
+ pausedDurationMs,
+ maxWallClockDeltaMs: Math.round(maxWallClockDeltaMs),
+ maxRecordedDeltaMs: Math.round(maxRecordedDeltaMs),
+ wallClockGapCount: wallClockGaps.length,
+ recordedGapCount: recordedGaps.length,
+ wallClockGaps: wallClockGaps.slice(0, 10),
+ recordedGaps: recordedGaps.slice(0, 10),
+ };
+}
+
+/** Probe the duration of a media file (in seconds) using the container header. */
+export async function probeMediaDurationSeconds(filePath: string): Promise {
+ const start = Date.now();
+ const ffmpegPath = getFfmpegBinaryPath();
+ try {
+ await execFileAsync(ffmpegPath, ["-i", filePath, "-hide_banner"], { timeout: 5000 });
+ } catch (error) {
+ const stderr = (error as NodeJS.ErrnoException & { stderr?: string })?.stderr ?? "";
+ const duration = parseFfmpegDurationSeconds(stderr);
+ if (duration !== null) {
+ return duration;
+ }
+ } finally {
+ console.log(`[PERF:MAIN] probeMediaDurationSeconds: COMPLETED in ${Date.now() - start}ms`);
+ }
+ return 0;
+}
+
+function parsePositiveNumber(value: unknown) {
+ const parsed =
+ typeof value === "number" ? value : typeof value === "string" ? Number(value) : NaN;
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
+}
+
+function parsePositiveInteger(value: unknown) {
+ const parsed = parsePositiveNumber(value);
+ return parsed === null ? null : Math.max(0, Math.round(parsed));
+}
+
+function parseFrameRate(value: unknown) {
+ if (typeof value !== "string") {
+ return null;
+ }
+ const [numeratorRaw, denominatorRaw] = value.split("/");
+ const numerator = Number(numeratorRaw);
+ const denominator = Number(denominatorRaw);
+ if (
+ !Number.isFinite(numerator) ||
+ !Number.isFinite(denominator) ||
+ numerator <= 0 ||
+ denominator <= 0
+ ) {
+ return null;
+ }
+
+ return numerator / denominator;
+}
+
+export function parseFfprobeVideoStreamDuration(output: string): VideoStreamDurationProbe | null {
+ const parsed = JSON.parse(output) as {
+ streams?: Array<{
+ duration?: unknown;
+ nb_frames?: unknown;
+ nb_read_frames?: unknown;
+ avg_frame_rate?: unknown;
+ r_frame_rate?: unknown;
+ }>;
+ };
+ const stream = parsed.streams?.[0];
+ if (!stream) {
+ return null;
+ }
+
+ const frameRate = parseFrameRate(stream.avg_frame_rate) ?? parseFrameRate(stream.r_frame_rate);
+ const frameCount =
+ parsePositiveInteger(stream.nb_read_frames) ?? parsePositiveInteger(stream.nb_frames);
+ const streamDuration = parsePositiveNumber(stream.duration);
+ const frameDerivedDuration =
+ frameCount !== null && frameRate !== null && frameRate > 0 ? frameCount / frameRate : null;
+
+ return {
+ durationSeconds: streamDuration ?? frameDerivedDuration,
+ frameCount,
+ frameRate,
+ };
+}
+
+export async function probeVideoStreamDuration(
+ filePath: string,
+): Promise {
+ const start = Date.now();
+ try {
+ const result = await execFileAsync(
+ getFfprobeBinaryPath(),
+ [
+ "-v",
+ "error",
+ "-select_streams",
+ "v:0",
+ "-show_entries",
+ "stream=duration,nb_frames,nb_read_frames,avg_frame_rate,r_frame_rate",
+ "-of",
+ "json",
+ filePath,
+ ],
+ { timeout: 30000, maxBuffer: 2 * 1024 * 1024 },
+ );
+ const stdout = typeof result === "string" ? result : result.stdout;
+ return parseFfprobeVideoStreamDuration(stdout);
+ } catch {
+ return null;
+ } finally {
+ console.log(`[PERF:MAIN] probeVideoStreamDuration: COMPLETED in ${Date.now() - start}ms`);
+ }
+}
+
+export async function probeVideoStreamDurationSeconds(filePath: string): Promise {
+ const probe = await probeVideoStreamDuration(filePath);
+ return probe?.durationSeconds && probe.durationSeconds > 0
+ ? probe.durationSeconds
+ : probeMediaDurationSeconds(filePath);
+}
+
+export function getRecordingDiagnosticsPath(videoPath: string) {
+ return `${videoPath.replace(/\.[^.]+$/u, "")}.recording-diagnostics.json`;
+}
+
+function truncateDiagnosticsText(value: string | undefined, maxLength = 12000) {
+ if (!value || value.length <= maxLength) {
+ return value;
+ }
+
+ return `${value.slice(-maxLength)}\n[recordly: truncated to last ${maxLength} chars]`;
+}
+
+async function describeMediaFile(filePath: string | null | undefined) {
+ if (!filePath) {
+ return null;
+ }
+
+ try {
+ const stat = await fs.stat(filePath);
+ if (!stat.isFile()) {
+ return {
+ path: filePath,
+ exists: false,
+ };
+ }
+
+ return {
+ path: filePath,
+ exists: true,
+ sizeBytes: stat.size,
+ containerDurationSeconds: await probeMediaDurationSeconds(filePath),
+ };
+ } catch {
+ return {
+ path: filePath,
+ exists: false,
+ };
+ }
+}
+
+async function describeVideoFile(filePath: string | null | undefined) {
+ const media = await describeMediaFile(filePath);
+ if (!media?.exists || !filePath) {
+ return media;
+ }
+
+ return {
+ ...media,
+ stream: await probeVideoStreamDuration(filePath),
+ };
+}
+
+async function describeAudioFile(filePath: string | null | undefined) {
+ const media = await describeMediaFile(filePath);
+ if (!media?.exists || !filePath) {
+ return media;
+ }
+
+ const startDelayMs = await getCompanionAudioStartDelayMs(filePath);
+ return {
+ ...media,
+ startDelayMs,
+ };
+}
+
+export async function writeRecordingDiagnosticsSnapshot(
+ videoPath: string,
+ snapshot: RecordingDiagnosticsSnapshot,
+) {
+ const diagnosticsPath = getRecordingDiagnosticsPath(videoPath);
+ const now = new Date().toISOString();
+ let existing: RecordingDiagnosticsLog | null = null;
+
+ try {
+ const raw = await fs.readFile(diagnosticsPath, "utf8");
+ const parsed = parseJsonWithByteOrderMark(raw);
+ if (parsed?.version === 1 && Array.isArray(parsed.events)) {
+ existing = parsed;
+ }
+ } catch {
+ // First diagnostics event for this recording.
+ }
+
+ const event = {
+ timestamp: now,
+ ...snapshot,
+ processOutput: truncateDiagnosticsText(snapshot.processOutput),
+ media: {
+ video: await describeVideoFile(videoPath),
+ systemAudio: await describeAudioFile(snapshot.systemAudioPath),
+ microphone: await describeAudioFile(snapshot.microphonePath),
+ },
+ };
+ const log: RecordingDiagnosticsLog = existing ?? {
+ version: 1,
+ createdAt: now,
+ updatedAt: now,
+ videoPath,
+ diagnosticsPath,
+ events: [],
+ };
+ log.updatedAt = now;
+ log.videoPath = videoPath;
+ log.diagnosticsPath = diagnosticsPath;
+ log.events.push(event);
+ log.latest = event;
+
+ await fs.writeFile(diagnosticsPath, JSON.stringify(log, null, 2), "utf8");
+ return diagnosticsPath;
+}
+
+export async function getUsableCompanionAudioCandidates(
+ videoPath: string,
+): Promise {
+ const basePath = videoPath.replace(/\.[^.]+$/u, "");
+ const candidates: CompanionAudioCandidate[] = [];
+
+ for (const layout of COMPANION_AUDIO_LAYOUTS) {
+ const systemPath = `${basePath}${layout.systemSuffix}`;
+ const micPath = `${basePath}${layout.micSuffix}`;
+ const usablePaths: string[] = [];
+
+ for (const companionPath of [systemPath, micPath]) {
+ try {
+ const stat = await fs.stat(companionPath);
+ if (stat.size > 0) {
+ usablePaths.push(companionPath);
+ }
+ } catch {
+ // Missing companion audio is expected for many recordings.
+ }
+ }
+
+ if (usablePaths.length > 0) {
+ candidates.push({
+ platform: layout.platform,
+ systemPath,
+ micPath,
+ usablePaths,
+ });
+ }
+ }
+
+ return candidates;
+}
+
+async function readCompanionAudioTimingMetadata(
+ companionPath: string,
+): Promise {
+ try {
+ const raw = await fs.readFile(`${companionPath}.json`, "utf8");
+ const parsed = parseJsonWithByteOrderMark(raw);
+ if (!parsed || typeof parsed !== "object") {
+ return null;
+ }
+
+ return parsed;
+ } catch {
+ return null;
+ }
+}
+
+export async function getCompanionAudioStartDelayMs(companionPath: string) {
+ const metadata = await readCompanionAudioTimingMetadata(companionPath);
+ const startDelayMs = metadata?.startDelayMs;
+ if (!Number.isFinite(startDelayMs) || (startDelayMs ?? 0) < 0) {
+ return null;
+ }
+
+ return Math.round(startDelayMs ?? 0);
+}
+
+export async function hasEmbeddedAudioStream(videoPath: string) {
+ const ffmpegPath = getFfmpegBinaryPath();
+ let stderr = "";
+
+ try {
+ const result = await execFileAsync(
+ ffmpegPath,
+ ["-hide_banner", "-i", videoPath, "-map", "0:a:0", "-frames:a", "1", "-f", "null", "-"],
+ { timeout: 20000, maxBuffer: 10 * 1024 * 1024 },
+ );
+ stderr = result.stderr;
+ } catch (error) {
+ stderr = (error as NodeJS.ErrnoException & { stderr?: string }).stderr ?? "";
+ }
+
+ return /Stream #.*Audio:/i.test(stderr);
+}
+
+export async function getCompanionAudioFallbackPaths(videoPath: string) {
+ const { paths } = await getCompanionAudioFallbackInfo(videoPath);
+ return paths;
+}
+
+/**
+ * Resolve which audio files the editor should play alongside `videoPath`, and
+ * the start delay recorded for each.
+ *
+ * The renderer treats a `.system.`/`.mic.` pair as independent tracks and mutes
+ * the video's own track when both are present. The macOS helper writes system
+ * audio to the inline track but keeps both sources as sidecars, so once a mac
+ * system sidecar exists the sidecars are authoritative and are returned in place
+ * of the video. Other layouts keep the embedded track and add the mic sidecar.
+ */
+export async function getCompanionAudioFallbackInfo(videoPath: string) {
+ const companionCandidates = await getUsableCompanionAudioCandidates(videoPath);
+ if (companionCandidates.length === 0) {
+ return { paths: [], startDelayMsByPath: {} };
+ }
+
+ let paths: string[];
+ if (isLibrarySequenceSource(videoPath)) {
+ paths = companionCandidates.flatMap((candidate) => candidate.usablePaths);
+ } else if (await hasEmbeddedAudioStream(videoPath)) {
+ const hasUsableMacSystemCompanion = companionCandidates.some(
+ (candidate) =>
+ candidate.platform === "mac" &&
+ candidate.usablePaths.includes(candidate.systemPath),
+ );
+ const usableMacMicOnlyCompanions = Array.from(
+ new Set(
+ companionCandidates.flatMap((candidate) =>
+ candidate.platform === "mac" &&
+ !candidate.usablePaths.includes(candidate.systemPath) &&
+ candidate.usablePaths.includes(candidate.micPath)
+ ? [candidate.micPath]
+ : [],
+ ),
+ ),
+ );
+
+ if (!hasUsableMacSystemCompanion && usableMacMicOnlyCompanions.length > 0) {
+ paths = usableMacMicOnlyCompanions;
+ } else if (hasUsableMacSystemCompanion) {
+ // The inline mp4 audio track carries system audio only (the helper skips
+ // the microphone while system audio is captured), so returning the video
+ // alone drops the mic entirely. Hand over both mac sidecars instead and
+ // let the renderer route them as independent system/mic tracks.
+ paths = Array.from(
+ new Set(
+ companionCandidates.flatMap((candidate) =>
+ candidate.platform === "mac" ? candidate.usablePaths : [],
+ ),
+ ),
+ );
+ } else {
+ const companionPaths = Array.from(
+ new Set(
+ companionCandidates.flatMap((candidate) =>
+ candidate.usablePaths.filter(
+ (companionPath) => companionPath === candidate.micPath,
+ ),
+ ),
+ ),
+ );
+ if (companionPaths.length === 0) {
+ return { paths: [], startDelayMsByPath: {} };
+ }
+
+ paths = [videoPath, ...companionPaths];
+ }
+ } else {
+ paths = Array.from(
+ new Set(companionCandidates.flatMap((candidate) => candidate.usablePaths)),
+ );
+ }
+
+ const metadataEntries = await Promise.all(
+ paths.map(async (audioPath) => {
+ const startDelayMs = await getCompanionAudioStartDelayMs(audioPath);
+ if (!Number.isFinite(startDelayMs)) {
+ return null;
+ }
+
+ return [audioPath, startDelayMs] as const;
+ }),
+ );
+
+ return {
+ paths,
+ startDelayMsByPath: Object.fromEntries(
+ metadataEntries.filter((entry): entry is readonly [string, number] => entry !== null),
+ ),
+ };
+}
+
+export async function validateRecordedVideo(videoPath: string) {
+ const stat = await fs.stat(videoPath);
+ if (!stat.isFile()) {
+ throw new Error(`Recorded output is not a file: ${videoPath}`);
+ }
+
+ if (stat.size <= 0) {
+ throw new Error(`Recorded output is empty: ${videoPath}`);
+ }
+
+ if (stat.size < MIN_VALID_RECORDED_VIDEO_BYTES) {
+ throw new Error(
+ `Recorded output is too small to contain playable video (${stat.size} bytes): ${videoPath}`,
+ );
+ }
+
+ const ffmpegPath = getFfmpegBinaryPath();
+ let stderr = "";
+
+ try {
+ const result = await execFileAsync(
+ ffmpegPath,
+ ["-hide_banner", "-i", videoPath, "-map", "0:v:0", "-frames:v", "1", "-f", "null", "-"],
+ { timeout: 20000, maxBuffer: 10 * 1024 * 1024 },
+ );
+ stderr = result.stderr;
+ } catch (error) {
+ const execError = error as NodeJS.ErrnoException & { stderr?: string };
+ const output = execError.stderr?.trim();
+ throw new Error(output || `Recorded output could not be decoded: ${videoPath}`);
+ }
+
+ if (!/Stream #.*Video:/i.test(stderr)) {
+ throw new Error(`Recorded output does not contain a readable video stream: ${videoPath}`);
+ }
+
+ const durationSeconds = parseFfmpegDurationSeconds(stderr);
+ if (durationSeconds === null || durationSeconds <= 0) {
+ throw new Error(`Recorded output has an invalid duration: ${videoPath}`);
+ }
+
+ return {
+ fileSizeBytes: stat.size,
+ durationSeconds,
+ };
+}
diff --git a/electron/ipc/recording/events.ts b/electron/ipc/recording/events.ts
new file mode 100644
index 000000000..5a2653c3b
--- /dev/null
+++ b/electron/ipc/recording/events.ts
@@ -0,0 +1,9 @@
+import { BrowserWindow } from "electron";
+
+export function emitRecordingInterrupted(reason: string, message: string) {
+ BrowserWindow.getAllWindows().forEach((window) => {
+ if (!window.isDestroyed()) {
+ window.webContents.send("recording-interrupted", { reason, message });
+ }
+ });
+}
diff --git a/electron/ipc/recording/ffmpeg.ts b/electron/ipc/recording/ffmpeg.ts
new file mode 100644
index 000000000..50281a39f
--- /dev/null
+++ b/electron/ipc/recording/ffmpeg.ts
@@ -0,0 +1,225 @@
+import type { ChildProcessWithoutNullStreams } from "node:child_process";
+import fs from "node:fs/promises";
+import { resolveLinuxWindowBounds } from "../cursor/bounds";
+import { ffmpegCaptureOutputBuffer } from "../state";
+import type { SelectedSource } from "../types";
+import { getScreen, parseWindowId } from "../utils";
+import { resolveWindowsCaptureDisplay } from "../windowsCaptureSelection";
+
+export function getDisplayBoundsForSource(source: SelectedSource) {
+ return resolveWindowsCaptureDisplay(
+ source,
+ getScreen().getAllDisplays(),
+ getScreen().getPrimaryDisplay(),
+ ).bounds;
+}
+
+export function getDisplayWorkAreaForSource(source: SelectedSource) {
+ const allDisplays = getScreen().getAllDisplays();
+ const primaryDisplay = getScreen().getPrimaryDisplay();
+ const { displayId } = resolveWindowsCaptureDisplay(source, allDisplays, primaryDisplay);
+ const matched = allDisplays.find((d) => d.id === displayId) ?? primaryDisplay;
+ return matched.workArea;
+}
+
+export async function buildFfmpegCaptureArgs(source: SelectedSource, outputPath: string) {
+ const buildOutputArgs = (inputRange: "auto" | "full") => [
+ "-an",
+ "-vf",
+ `scale=in_range=${inputRange}:out_range=tv:out_color_matrix=bt709:sws_dither=bayer`,
+ "-c:v",
+ "libx264",
+ "-preset",
+ "veryfast",
+ "-pix_fmt",
+ "yuv420p",
+ "-colorspace",
+ "bt709",
+ "-color_primaries",
+ "bt709",
+ "-color_trc",
+ "bt709",
+ "-color_range",
+ "tv",
+ "-movflags",
+ "+faststart",
+ outputPath,
+ ];
+ const fullRangeOutputArgs = buildOutputArgs("full");
+
+ if (process.platform === "win32") {
+ if (source?.id?.startsWith("window:")) {
+ const windowId = parseWindowId(source.id);
+ const windowTitle =
+ typeof source.windowTitle === "string"
+ ? source.windowTitle.trim()
+ : source.name.trim();
+ if (!windowId && !windowTitle) {
+ throw new Error("Missing window identifier for FFmpeg window capture");
+ }
+
+ return [
+ "-y",
+ "-f",
+ "gdigrab",
+ "-framerate",
+ "60",
+ "-draw_mouse",
+ "0",
+ "-i",
+ windowId ? `hwnd=${windowId}` : `title=${windowTitle}`,
+ ...fullRangeOutputArgs,
+ ];
+ }
+
+ return [
+ "-y",
+ "-f",
+ "gdigrab",
+ "-framerate",
+ "60",
+ "-draw_mouse",
+ "0",
+ "-i",
+ "desktop",
+ ...fullRangeOutputArgs,
+ ];
+ }
+
+ if (process.platform === "linux") {
+ const displayEnv = process.env.DISPLAY || ":0.0";
+ if (source?.id?.startsWith("window:")) {
+ const bounds = await resolveLinuxWindowBounds(source);
+ if (!bounds) {
+ throw new Error("Unable to resolve Linux window bounds for FFmpeg capture");
+ }
+
+ return [
+ "-y",
+ "-f",
+ "x11grab",
+ "-framerate",
+ "60",
+ "-draw_mouse",
+ "0",
+ "-video_size",
+ `${Math.max(2, bounds.width)}x${Math.max(2, bounds.height)}`,
+ "-i",
+ `${displayEnv}+${Math.round(bounds.x)},${Math.round(bounds.y)}`,
+ ...fullRangeOutputArgs,
+ ];
+ }
+
+ const bounds = getDisplayBoundsForSource(source);
+ return [
+ "-y",
+ "-f",
+ "x11grab",
+ "-framerate",
+ "60",
+ "-draw_mouse",
+ "0",
+ "-video_size",
+ `${Math.max(2, bounds.width)}x${Math.max(2, bounds.height)}`,
+ "-i",
+ `${displayEnv}+${Math.round(bounds.x)},${Math.round(bounds.y)}`,
+ ...fullRangeOutputArgs,
+ ];
+ }
+
+ if (process.platform === "darwin") {
+ return [
+ "-y",
+ "-f",
+ "avfoundation",
+ "-capture_cursor",
+ "0",
+ "-framerate",
+ "60",
+ "-i",
+ "1:none",
+ ...buildOutputArgs("auto"),
+ ];
+ }
+
+ throw new Error(`FFmpeg capture is not supported on ${process.platform}`);
+}
+
+export function waitForFfmpegCaptureStart(process: ChildProcessWithoutNullStreams) {
+ return new Promise((resolve, reject) => {
+ const onError = (error: Error) => {
+ cleanup();
+ reject(error);
+ };
+
+ const onExit = (code: number | null) => {
+ cleanup();
+ reject(
+ new Error(
+ ffmpegCaptureOutputBuffer.trim() ||
+ `FFmpeg exited before recording started (code ${code ?? "unknown"})`,
+ ),
+ );
+ };
+
+ const timer = setTimeout(() => {
+ cleanup();
+ resolve();
+ }, 900);
+
+ const cleanup = () => {
+ clearTimeout(timer);
+ process.off("error", onError);
+ process.off("exit", onExit);
+ };
+
+ process.once("error", onError);
+ process.once("exit", onExit);
+ });
+}
+
+export function waitForFfmpegCaptureStop(
+ process: ChildProcessWithoutNullStreams,
+ outputPath: string,
+) {
+ return new Promise((resolve, reject) => {
+ const onClose = async (code: number | null) => {
+ cleanup();
+
+ try {
+ await fs.access(outputPath);
+ if (code === 0 || code === null) {
+ resolve(outputPath);
+ return;
+ }
+
+ if (ffmpegCaptureOutputBuffer.includes("Exiting normally")) {
+ resolve(outputPath);
+ return;
+ }
+ } catch {
+ // handled below
+ }
+
+ reject(
+ new Error(
+ ffmpegCaptureOutputBuffer.trim() ||
+ `FFmpeg exited with code ${code ?? "unknown"}`,
+ ),
+ );
+ };
+
+ const onError = (error: Error) => {
+ cleanup();
+ reject(error);
+ };
+
+ const cleanup = () => {
+ process.off("close", onClose);
+ process.off("error", onError);
+ };
+
+ process.once("close", onClose);
+ process.once("error", onError);
+ });
+}
diff --git a/electron/ipc/recording/importRecording.ts b/electron/ipc/recording/importRecording.ts
new file mode 100644
index 000000000..bab9b17fa
--- /dev/null
+++ b/electron/ipc/recording/importRecording.ts
@@ -0,0 +1,263 @@
+import { composeSequenceWebcam, sequenceWebcamOutputs } from "./sequenceWebcam";
+import { probeNativeVideoMetadata } from "../ffmpeg/metadata";
+import { isLibrarySequenceSource } from "./sequenceSource";
+import { execFile } from "node:child_process";
+import { randomUUID } from "node:crypto";
+import fs from "node:fs/promises";
+import path from "node:path";
+import { promisify } from "node:util";
+import { buildMediaUrl, getMediaServerBaseUrl } from "../../mediaServer";
+import { getFfmpegBinaryPath } from "../ffmpeg/binary";
+import { rememberApprovedLocalReadPath, resolveApprovedLocalMediaPath } from "../project/manager";
+import { getRecordingsDir, getTelemetryPathForVideo } from "../utils";
+import { getUsableCompanionAudioCandidates } from "./diagnostics";
+import { normalizeCursorTelemetrySamples, writeCursorTelemetry } from "../cursor/telemetry";
+import { listRecordings } from "./library";
+import type {
+ RecordingImportResult,
+ RecordingWebcamSource,
+} from "../../../src/types/recordingLibrary";
+
+const run = promisify(execFile);
+async function ffmpeg(args: string[], signal?: AbortSignal) {
+ await run(
+ getFfmpegBinaryPath(),
+ ["-hide_banner", "-loglevel", "error", "-nostdin", "-y", ...args],
+ { signal, timeout: 60 * 60 * 1000, maxBuffer: 1024 * 1024, windowsHide: true },
+ );
+}
+async function probe(file: string, signal?: AbortSignal) {
+ const meta = await probeNativeVideoMetadata(getFfmpegBinaryPath(), file, signal);
+ return {
+ width: meta.width,
+ height: meta.height,
+ fps: meta.frameRate,
+ duration: meta.duration,
+ audio: meta.hasAudio,
+ };
+}
+type Format = { width: number; height: number; fps: number };
+
+/** Normalize new media once. Existing sequence video is copied, avoiding generation loss. */
+async function normalize(
+ file: string,
+ out: string,
+ format: Format,
+ copyVideo: boolean,
+ signal?: AbortSignal,
+) {
+ const meta = await probe(file, signal);
+ const candidates = await getUsableCompanionAudioCandidates(file);
+ const companion = candidates[0];
+ const system = companion?.usablePaths.includes(companion.systemPath)
+ ? companion.systemPath
+ : null;
+ const mic = companion?.usablePaths.includes(companion.micPath) ? companion.micPath : null;
+ const args = ["-i", file];
+ const filters: string[] = [];
+ let input = 1;
+ for (const [kind, source] of [
+ ["system", system],
+ ["mic", mic],
+ ] as const) {
+ let label: string;
+ let delay = 0;
+ if (source) {
+ args.push("-i", source);
+ label = `${input++}:a:0`;
+ try {
+ delay = Math.max(
+ 0,
+ Number(JSON.parse(await fs.readFile(`${source}.json`, "utf8")).startDelayMs) ||
+ 0,
+ );
+ } catch {
+ /* Optional capture timing. */
+ }
+ } else if (kind === "system" && meta.audio) {
+ label = "0:a:0";
+ } else {
+ args.push("-f", "lavfi", "-i", "anullsrc=r=48000:cl=stereo");
+ label = `${input++}:a:0`;
+ }
+ filters.push(
+ `[${label}]aresample=48000,aformat=sample_fmts=s16:channel_layouts=stereo,asetpts=PTS-STARTPTS,adelay=${Math.round(delay)}:all=1,apad,atrim=duration=${meta.duration}[${kind}]`,
+ );
+ }
+ if (!copyVideo)
+ filters.push(
+ `[0:v:0]setpts=PTS-STARTPTS,scale=${format.width}:${format.height}:force_original_aspect_ratio=decrease,pad=${format.width}:${format.height}:(ow-iw)/2:(oh-ih)/2,setsar=1,fps=${format.fps},format=yuv420p[v]`,
+ );
+ args.push(
+ "-filter_complex",
+ filters.join(";"),
+ "-map",
+ copyVideo ? "0:v:0" : "[v]",
+ "-map",
+ "[system]",
+ "-map",
+ "[mic]",
+ "-c:v",
+ copyVideo ? "copy" : "libx264",
+ );
+ if (!copyVideo) args.push("-preset", "fast", "-crf", "18");
+ args.push("-c:a", "pcm_s16le", "-t", String(meta.duration), out);
+ await ffmpeg(args, signal);
+}
+
+/** The editor and export share one immutable source, with stable offsets across imports. */
+export async function importRecording(
+ currentPath: string,
+ recordingPath: string,
+ currentWebcam?: RecordingWebcamSource,
+ signal?: AbortSignal,
+): Promise {
+ const entry = (await listRecordings()).find((entry) => entry.path === recordingPath);
+ if (!entry) throw new Error("Recording is no longer in Videos");
+ const current = await resolveApprovedLocalMediaPath(currentPath);
+ if (!current) throw new Error("Current video is no longer available");
+ const server = getMediaServerBaseUrl();
+ if (!server) throw new Error("Media server is not ready");
+ const root = path.join(await getRecordingsDir(), ".recordly-media");
+ await fs.mkdir(root, { recursive: true });
+ const base = await probe(current, signal);
+ const format = {
+ width: Math.ceil(base.width / 2) * 2,
+ height: Math.ceil(base.height / 2) * 2,
+ fps: base.fps,
+ };
+ const id = randomUUID();
+ const work = await fs.mkdtemp(path.join(root, "import-"));
+ const output = path.join(root, `${id}.mp4`);
+ const stem = output.slice(0, -4);
+ const outputs = recordingImportOutputs(output);
+ try {
+ const normalizedBase = path.join(work, "base.mkv");
+ const normalizedNew = path.join(work, "new.mkv");
+ // Only our own normalized source format may be copied across appends.
+ const copyVideo = isLibrarySequenceSource(current);
+ await normalize(current, normalizedBase, format, copyVideo, signal);
+ await normalize(entry.path, normalizedNew, format, false, signal);
+ const baseMeta = await probe(normalizedBase, signal);
+ const newMeta = await probe(normalizedNew, signal);
+ await fs.writeFile(path.join(work, "list.txt"), "file 'base.mkv'\nfile 'new.mkv'\n");
+ const combined = path.join(work, "combined.mkv");
+ await ffmpeg(
+ [
+ "-f",
+ "concat",
+ "-safe",
+ "1",
+ "-i",
+ path.join(work, "list.txt"),
+ "-map",
+ "0",
+ "-c",
+ "copy",
+ combined,
+ ],
+ signal,
+ );
+ await ffmpeg(
+ [
+ "-i",
+ combined,
+ "-filter_complex",
+ "[0:a:0][0:a:1]amix=inputs=2:normalize=0[mix]",
+ "-map",
+ "0:v:0",
+ "-map",
+ "[mix]",
+ "-c:v",
+ "copy",
+ "-c:a",
+ "aac",
+ "-b:a",
+ "192k",
+ "-movflags",
+ "+faststart",
+ output,
+ "-map",
+ "0:a:0",
+ "-c:a",
+ "pcm_s16le",
+ `${stem}.system.wav`,
+ "-map",
+ "0:a:1",
+ "-c:a",
+ "pcm_s16le",
+ `${stem}.mic.wav`,
+ ],
+ signal,
+ );
+ const sourceStartMs = Math.round(baseMeta.duration * 1000);
+ const samples = [];
+ for (const [file, offset, meta] of [
+ [current, 0, base],
+ [entry.path, sourceStartMs, await probe(entry.path, signal)],
+ ] as const) {
+ let points: ReturnType = [];
+ try {
+ points = normalizeCursorTelemetrySamples(
+ JSON.parse(await fs.readFile(getTelemetryPathForVideo(file), "utf8")),
+ );
+ } catch {
+ /* Recording may not contain cursor telemetry. */
+ }
+ const scale = Math.min(format.width / meta.width, format.height / meta.height);
+ samples.push(
+ ...points.map((point) => ({
+ ...point,
+ timeMs: point.timeMs + offset,
+ cx:
+ (point.cx * meta.width * scale + (format.width - meta.width * scale) / 2) /
+ format.width,
+ cy:
+ (point.cy * meta.height * scale +
+ (format.height - meta.height * scale) / 2) /
+ format.height,
+ })),
+ );
+ }
+ await writeCursorTelemetry(output, samples);
+ const webcam = await composeSequenceWebcam(
+ current,
+ entry.path,
+ output,
+ work,
+ sourceStartMs,
+ Math.round(newMeta.duration * 1000),
+ currentWebcam,
+ signal,
+ );
+ signal?.throwIfAborted();
+ await rememberApprovedLocalReadPath(output);
+ return {
+ path: output,
+ webcam,
+ url: buildMediaUrl(server, output),
+ sourceStartMs,
+ durationMs: Math.round(newMeta.duration * 1000),
+ totalDurationMs: sourceStartMs + Math.round(newMeta.duration * 1000),
+ };
+ } catch (error) {
+ await Promise.all(outputs.map((file) => fs.rm(file, { force: true })));
+ throw error;
+ } finally {
+ await fs.rm(work, { recursive: true, force: true });
+ }
+}
+
+function recordingImportOutputs(output: string) {
+ const stem = output.slice(0, -4);
+ return [
+ ...sequenceWebcamOutputs(output),
+ output,
+ `${stem}.system.wav`,
+ `${stem}.mic.wav`,
+ `${output}.cursor.json`,
+ ];
+}
+export async function discardRecordingImport(output: string) {
+ await Promise.all(recordingImportOutputs(output).map((file) => fs.rm(file, { force: true })));
+}
diff --git a/electron/ipc/recording/library.test.ts b/electron/ipc/recording/library.test.ts
new file mode 100644
index 000000000..d44bf1028
--- /dev/null
+++ b/electron/ipc/recording/library.test.ts
@@ -0,0 +1,337 @@
+import { execFile } from "node:child_process";
+import { createRequire } from "node:module";
+import fs from "node:fs/promises";
+import os from "node:os";
+import path from "node:path";
+import { promisify } from "node:util";
+import { afterEach, beforeEach, expect, it, vi } from "vitest";
+
+const state = vi.hoisted(() => ({ root: "", approved: new Set() }));
+vi.mock("electron", () => ({
+ app: { getPath: () => state.root },
+ shell: {
+ trashItem: vi.fn(async (file: string) => {
+ await fs.mkdir(path.join(state.root, ".test-trash"), { recursive: true });
+ await fs.rename(file, path.join(state.root, ".test-trash", path.basename(file)));
+ }),
+ },
+}));
+vi.mock("../../appPaths", () => ({
+ USER_DATA_PATH: "/tmp/recordly-test",
+ RECORDINGS_DIR: "/tmp/recordly-test",
+}));
+vi.mock("../utils", () => ({
+ getRecordingsDir: async () => state.root,
+ getTelemetryPathForVideo: (file: string) => `${file}.cursor.json`,
+ parseJsonWithByteOrderMark: JSON.parse,
+ normalizeVideoSourcePath: (file: string) => file,
+ getScreen: vi.fn(),
+}));
+vi.mock("../../mediaServer", () => ({
+ getMediaServerBaseUrl: () => "http://127.0.0.1:9999",
+ buildMediaUrl: (server: string, file: string) =>
+ `${server}/video?path=${encodeURIComponent(file)}`,
+}));
+vi.mock("../project/manager", () => ({
+ rememberApprovedLocalReadPath: async (file: string) => {
+ state.approved.add(file);
+ },
+ resolveApprovedLocalMediaPath: async (file: string) => (state.approved.has(file) ? file : null),
+ isPathInsideDirectory: (file: string, root: string) => file.startsWith(`${root}${path.sep}`),
+}));
+vi.mock("../ffmpeg/binary", async () => {
+ const require = createRequire(import.meta.url);
+ return {
+ getFfmpegBinaryPath: () => require("ffmpeg-static"),
+ getFfprobeBinaryPath: () => require("ffprobe-static").path,
+ };
+});
+import { listRecordings, setRecordingsRemoved, clearRecordingTrashUndo } from "./library";
+import { importRecording, discardRecordingImport } from "./importRecording";
+import { getRecordingThumbnail } from "./thumbnail";
+import { getCompanionAudioFallbackInfo } from "./diagnostics";
+const require = createRequire(import.meta.url);
+const ffmpeg = require("ffmpeg-static") as string;
+const run = promisify(execFile);
+beforeEach(async () => {
+ state.root = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "recordly-library-")));
+ state.approved.clear();
+});
+afterEach(async () => {
+ await clearRecordingTrashUndo();
+ await fs.rm(state.root, { recursive: true, force: true });
+});
+
+it("lists recordings, moves recordings and their companions to Trash with reversible removals, and excludes companion media and symlinks", async () => {
+ const first = path.join(state.root, "recording-old.mp4");
+ const second = path.join(state.root, "recording-new.mov");
+ for (const name of [
+ first,
+ second,
+ path.join(state.root, "recording-new.webcam.mp4"),
+ path.join(state.root, "recording-new.mic.wav"),
+ ])
+ await fs.writeFile(name, "fixture");
+ await fs.symlink(first, path.join(state.root, "linked.mp4"));
+ expect((await listRecordings()).map((entry) => entry.path).sort()).toEqual(
+ [first, second].sort(),
+ );
+ await setRecordingsRemoved([first, second], true);
+ expect(await listRecordings()).toEqual([]);
+ await expect(fs.access(first)).rejects.toThrow();
+ await expect(fs.access(path.join(state.root, "recording-new.mic.wav"))).rejects.toThrow();
+ expect(
+ (await fs.readdir(state.root)).filter((name) => name.startsWith(".recordly-trash-")),
+ ).toHaveLength(1);
+ await setRecordingsRemoved([first, second], false);
+ expect(await listRecordings()).toHaveLength(2);
+ expect(await fs.readFile(first, "utf8")).toBe("fixture");
+ expect(await fs.readFile(path.join(state.root, "recording-new.mic.wav"), "utf8")).toBe(
+ "fixture",
+ );
+ await expect(setRecordingsRemoved(["/tmp/outside.mp4"], true)).rejects.toThrow("outside");
+});
+
+it("imports different-sized recordings with playable video, separate audio, stable offsets, and untouched originals", async () => {
+ const base = path.join(state.root, "recording-base.mp4");
+ const added = path.join(state.root, "recording-added.mp4");
+ await run(ffmpeg, [
+ "-v",
+ "error",
+ "-f",
+ "lavfi",
+ "-i",
+ "color=c=red:s=160x90:r=30:d=1",
+ "-f",
+ "lavfi",
+ "-i",
+ "sine=frequency=440:duration=1",
+ "-c:v",
+ "libx264",
+ "-pix_fmt",
+ "yuv420p",
+ "-c:a",
+ "aac",
+ "-shortest",
+ base,
+ ]);
+ await run(ffmpeg, [
+ "-v",
+ "error",
+ "-f",
+ "lavfi",
+ "-i",
+ "color=c=blue:s=90x160:r=30:d=1",
+ "-c:v",
+ "libx264",
+ "-pix_fmt",
+ "yuv420p",
+ added,
+ ]);
+ const webcam = added.replace(".mp4", "-webcam.mp4");
+ await run(ffmpeg, [
+ "-v",
+ "error",
+ "-f",
+ "lavfi",
+ "-i",
+ "color=c=lime:s=80x60:r=30:d=0.8",
+ "-c:v",
+ "libx264",
+ "-pix_fmt",
+ "yuv420p",
+ webcam,
+ ]);
+ await fs.writeFile(
+ added.replace(".mp4", ".recordly-session.json"),
+ JSON.stringify({ version: 2, webcamFileName: path.basename(webcam), timeOffsetMs: 200 }),
+ );
+ await fs.writeFile(
+ `${added}.cursor.json`,
+ JSON.stringify({
+ samples: [
+ { timeMs: 200, cx: 0.2, cy: 0.4, interactionType: "click", cursorType: "pointer" },
+ {
+ timeMs: 260,
+ cx: 0.2,
+ cy: 0.4,
+ interactionType: "mouseup",
+ cursorType: "pointer",
+ },
+ { timeMs: 600, cx: 0.4, cy: 0.5, interactionType: "right-click" },
+ ],
+ }),
+ );
+ const original = await fs.readFile(base);
+ await listRecordings();
+ const thumbnail = await getRecordingThumbnail(base);
+ expect(thumbnail).toMatch(/^data:image\/jpeg;base64,/);
+ const jpeg = Buffer.from(thumbnail.split(",")[1], "base64");
+ expect([...jpeg.subarray(0, 2)]).toEqual([0xff, 0xd8]);
+ expect(await getRecordingThumbnail(base)).toBe(thumbnail);
+ await expect(getRecordingThumbnail("/tmp/unapproved.mp4")).rejects.toThrow("outside");
+ const result = await importRecording(base, added);
+ expect(result.sourceStartMs).toBeCloseTo(1000, -1);
+ expect(result.durationMs).toBeCloseTo(1000, -1);
+ expect(result.webcam?.visibleRanges).toEqual([{ startMs: 1200, endMs: 2000 }]);
+ const cursor = JSON.parse(await fs.readFile(`${result.path}.cursor.json`, "utf8")).samples;
+ expect(
+ cursor.map((point: { timeMs: number; interactionType: string }) => [
+ point.timeMs,
+ point.interactionType,
+ ]),
+ ).toEqual([
+ [1200, "click"],
+ [1260, "mouseup"],
+ [1600, "right-click"],
+ ]);
+ expect(cursor[0].cursorType).toBe("pointer");
+ expect(cursor[0].cx).toBeCloseTo(0.405078125);
+ const webcamPixels = await run(
+ ffmpeg,
+ [
+ "-v",
+ "error",
+ "-ss",
+ "1.5",
+ "-i",
+ result.webcam!.sourcePath!,
+ "-vf",
+ "crop=2:2:40:30",
+ "-frames:v",
+ "1",
+ "-f",
+ "rawvideo",
+ "-pix_fmt",
+ "rgb24",
+ "-",
+ ],
+ { encoding: "buffer" },
+ );
+ expect(webcamPixels.stdout[1]).toBeGreaterThan(200);
+ const companions = await getCompanionAudioFallbackInfo(result.path);
+ expect(companions.paths).toHaveLength(2);
+ expect(companions.paths.every((file) => file.endsWith(".wav"))).toBe(true);
+ await run(ffmpeg, ["-v", "error", "-i", result.path, "-f", "null", "-"]);
+ const pixels = await run(
+ ffmpeg,
+ [
+ "-v",
+ "error",
+ "-ss",
+ "1.5",
+ "-i",
+ result.path,
+ "-vf",
+ "crop=2:2:80:44",
+ "-frames:v",
+ "1",
+ "-f",
+ "rawvideo",
+ "-pix_fmt",
+ "rgb24",
+ "-",
+ ],
+ { encoding: "buffer" },
+ );
+ expect(pixels.stdout[2]).toBeGreaterThan(200); // blue centre survives aspect-ratio fitting
+ const second = await importRecording(result.path, added);
+ expect(second.sourceStartMs).toBeCloseTo(2000, -1);
+ expect(second.webcam?.visibleRanges).toEqual([
+ { startMs: 1200, endMs: 2000 },
+ { startMs: 2200, endMs: 3000 },
+ ]);
+ await discardRecordingImport(result.path);
+ await expect(fs.access(result.path)).rejects.toThrow();
+ await expect(fs.access(result.path.replace(/\.mp4$/, ".mic.wav"))).rejects.toThrow();
+ await expect(fs.access(result.path.replace(/\.mp4$/, "-webcam.mp4"))).rejects.toThrow();
+ await expect(
+ fs.access(result.path.replace(/\.mp4$/, ".recordly-session.json")),
+ ).rejects.toThrow();
+ await expect(fs.access(`${result.path}.webcam-ranges.json`)).rejects.toThrow();
+ await expect(fs.access(`${result.path}.cursor.json`)).rejects.toThrow();
+ await expect(fs.access(second.path)).resolves.toBeUndefined();
+ expect(await fs.readFile(base)).toEqual(original);
+ expect((await listRecordings()).map((entry) => entry.path).sort()).toEqual(
+ [base, added].sort(),
+ );
+ await expect(importRecording("/tmp/unapproved.mp4", added)).rejects.toThrow(
+ "no longer available",
+ );
+}, 60000);
+
+it("retains staged originals for undo when the OS refuses Trash", async () => {
+ const { shell } = await import("electron");
+ const file = path.join(state.root, "recording-failure.mp4");
+ await fs.writeFile(file, "original");
+ await setRecordingsRemoved([file], true);
+ vi.mocked(shell.trashItem).mockRejectedValueOnce(new Error("Trash unavailable"));
+ await expect(clearRecordingTrashUndo()).rejects.toThrow("Trash unavailable");
+ await setRecordingsRemoved([file], false);
+ expect(await fs.readFile(file, "utf8")).toBe("original");
+});
+
+it("undo never overwrites a new file at the original location", async () => {
+ const file = path.join(state.root, "recording-conflict.mp4");
+ await fs.writeFile(file, "original");
+ await setRecordingsRemoved([file], true);
+ await fs.writeFile(file, "new recording");
+ await expect(setRecordingsRemoved([file], false)).rejects.toThrow("already exists");
+ expect(await fs.readFile(file, "utf8")).toBe("new recording");
+});
+
+it("cancels an active import and removes partial outputs without changing originals", async () => {
+ const base = path.join(state.root, "recording-base.mp4");
+ const added = path.join(state.root, "recording-added.mp4");
+ await run(ffmpeg, [
+ "-v",
+ "error",
+ "-f",
+ "lavfi",
+ "-i",
+ "color=c=red:s=640x480:r=30:d=10",
+ "-c:v",
+ "libx264",
+ "-pix_fmt",
+ "yuv420p",
+ base,
+ ]);
+ await fs.copyFile(base, added);
+ state.approved.add(base);
+ const original = await fs.readFile(base);
+ const controller = new AbortController();
+ const importing = importRecording(base, added, undefined, controller.signal);
+ const rejected = expect(importing).rejects.toMatchObject({ name: "AbortError" });
+ await vi.waitFor(async () => {
+ const files = await fs.readdir(path.join(state.root, ".recordly-media"));
+ expect(files.some((name) => name.startsWith("import-"))).toBe(true);
+ });
+ controller.abort();
+ await rejected;
+ expect(await fs.readdir(path.join(state.root, ".recordly-media"))).toEqual([]);
+ expect(await fs.readFile(base)).toEqual(original);
+ expect(await fs.readFile(added)).toEqual(original);
+});
+
+it("restores on volumes without hard links and preserves conflicts", async () => {
+ const file = path.join(state.root, "recording-exfat.mp4");
+ await fs.writeFile(file, "recording");
+ await setRecordingsRemoved([file], true);
+ const link = vi
+ .spyOn(fs, "link")
+ .mockRejectedValue(Object.assign(new Error("unsupported"), { code: "ENOTSUP" }));
+ try {
+ await setRecordingsRemoved([file], false);
+ expect(await fs.readFile(file, "utf8")).toBe("recording");
+ } finally {
+ link.mockRestore();
+ }
+});
+
+it("Raw includes camera and audio sources without including metadata or symlinks", async () => {
+ const files = ["screen.mp4", "screen.webcam.mp4", "screen.mic.wav", "screen.system.m4a"];
+ for (const name of [...files, "screen.cursor.json"]) await fs.writeFile(path.join(state.root, name), "fixture");
+ await fs.symlink(path.join(state.root, "screen.mp4"), path.join(state.root, "linked.mp4"));
+ expect((await listRecordings(true)).map(entry => entry.name).sort()).toEqual(files.sort());
+ expect((await listRecordings()).map(entry => entry.name)).toEqual(["screen.mp4"]);
+});
diff --git a/electron/ipc/recording/library.ts b/electron/ipc/recording/library.ts
new file mode 100644
index 000000000..826841375
--- /dev/null
+++ b/electron/ipc/recording/library.ts
@@ -0,0 +1,176 @@
+import fs from "node:fs/promises";
+import { constants } from "node:fs";
+import path from "node:path";
+import { shell } from "electron";
+import { buildMediaUrl, getMediaServerBaseUrl } from "../../mediaServer";
+import { rememberApprovedLocalReadPath } from "../project/manager";
+import { getRecordingsDir } from "../utils";
+import type { RecordingLibraryEntry } from "../../../src/types/recordingLibrary";
+
+let mutation = Promise.resolve();
+const undoBatches = new Map();
+const isRecording = (name: string) =>
+ /\.(mp4|mov|webm|mkv|m4v)$/i.test(name) && !/[.-]webcam[.-]/i.test(name);
+const batchKey = (paths: string[]) => JSON.stringify([...new Set(paths)].sort());
+
+export function listRecordings(includeSources = false): Promise {
+ const task = mutation.then(async () => {
+ const root = await fs.realpath(await getRecordingsDir());
+ const server = getMediaServerBaseUrl();
+ if (!server) throw new Error("Media server is not ready. Try again.");
+ const entries = await fs.readdir(root, { withFileTypes: true });
+ for (const entry of entries) {
+ const staged = path.join(root, entry.name);
+ if (
+ entry.isDirectory() &&
+ /^\.recordly-trash-[A-Za-z0-9]{6}$/.test(entry.name) &&
+ ![...undoBatches.values()].some((batch) => batch.bundle === staged)
+ ) {
+ await shell.trashItem(staged);
+ }
+ }
+ const result: RecordingLibraryEntry[] = [];
+ for (const entry of entries) {
+ if (!entry.isFile() || !(includeSources ? /\.(mp4|mov|webm|mkv|m4v|wav|m4a|mp3|ogg|flac)$/i.test(entry.name) : isRecording(entry.name))) continue;
+ const filePath = path.join(root, entry.name);
+ const stat = await fs.stat(filePath);
+ if (!stat.size) continue;
+ await rememberApprovedLocalReadPath(filePath);
+ result.push({
+ path: filePath,
+ name: entry.name,
+ bytes: stat.size,
+ createdAt: stat.mtimeMs,
+ url: buildMediaUrl(server, filePath),
+ });
+ }
+ return result.sort((a, b) => b.createdAt - a.createdAt);
+ });
+ mutation = task.then(
+ () => undefined,
+ () => undefined,
+ );
+ return task;
+}
+
+// Only capture sidecars belonging to this exact recording, never adjacent recordings or projects.
+function belongsToRecording(name: string, video: string) {
+ const stem = video.slice(0, -path.extname(video).length);
+ return (
+ name === video ||
+ name === `${video}.cursor.json` ||
+ (name.startsWith(`${stem}.`) &&
+ /\.(?:system|mic|microphone|audio|webcam)\.(?:wav|webm|mp4|m4a)(?:\.json)?$/i.test(
+ name,
+ )) ||
+ name === `${stem}.recordly-session.json` ||
+ name === `${stem}.recording-session.json` ||
+ (name.startsWith(`${stem}-webcam.`) && /\.(?:mp4|webm|mov|mkv|avi)$/i.test(name))
+ );
+}
+
+/** Stage a bundle by rename for Undo; the next removal or app exit sends it to Trash. */
+export function setRecordingsRemoved(paths: string[], removed: boolean): Promise {
+ const task = mutation.then(async () => {
+ if (
+ typeof removed !== "boolean" ||
+ !Array.isArray(paths) ||
+ !paths.length ||
+ paths.some((value) => typeof value !== "string")
+ )
+ throw new Error("Invalid recording selection");
+ const root = await fs.realpath(await getRecordingsDir());
+ const selected = [...new Set(paths)];
+ for (const candidate of selected) {
+ if (path.dirname(candidate) !== root || !isRecording(path.basename(candidate)))
+ throw new Error("Recording is outside the Videos library");
+ }
+ const key = batchKey(selected);
+ if (!removed) {
+ const batch = undoBatches.get(key);
+ if (!batch)
+ throw new Error(
+ "This removal can no longer be undone. Restore the files from Trash.",
+ );
+ // Check every destination before restoring any file; never overwrite newer media.
+ for (const file of batch.files) {
+ if (
+ await fs.lstat(file).then(
+ () => true,
+ (error) => {
+ if (error.code === "ENOENT") return false;
+ throw error;
+ },
+ )
+ )
+ throw new Error(
+ "A file with this name already exists. Restore it from Trash instead.",
+ );
+ }
+ const restored: string[] = [];
+ try {
+ for (const file of batch.files) {
+ const staged = path.join(batch.bundle, path.basename(file));
+ try {
+ await fs.link(staged, file);
+ } catch (error) {
+ if (
+ !["EPERM", "ENOTSUP", "EOPNOTSUPP", "EXDEV"].includes(
+ (error as NodeJS.ErrnoException).code ?? "",
+ )
+ )
+ throw error;
+ await fs.copyFile(staged, file, constants.COPYFILE_EXCL);
+ }
+ restored.push(file);
+ }
+ } catch (error) {
+ await Promise.all(restored.map((file) => fs.unlink(file)));
+ throw error;
+ }
+ undoBatches.delete(key);
+ await fs.rm(batch.bundle, { recursive: true, force: true });
+ return;
+ }
+ const available = await fs.readdir(root, { withFileTypes: true });
+ for (const file of selected) {
+ if (!available.some((entry) => entry.name === path.basename(file) && entry.isFile()))
+ throw new Error("Recording is missing or is not a regular file");
+ }
+ const files = available
+ .filter(
+ (entry) =>
+ entry.isFile() &&
+ selected.some((file) => belongsToRecording(entry.name, path.basename(file))),
+ )
+ .map((entry) => path.join(root, entry.name));
+ await finishPendingTrash();
+ const bundle = await fs.mkdtemp(path.join(root, ".recordly-trash-"));
+ const moved: string[] = [];
+ try {
+ for (const file of files) {
+ await fs.rename(file, path.join(bundle, path.basename(file)));
+ moved.push(file);
+ }
+ undoBatches.set(key, { bundle, files });
+ } catch (error) {
+ for (const file of moved) await fs.rename(path.join(bundle, path.basename(file)), file);
+ await fs.rmdir(bundle);
+ throw error;
+ }
+ });
+ mutation = task.catch(() => undefined);
+ return task;
+}
+
+async function finishPendingTrash() {
+ for (const [key, batch] of undoBatches) {
+ await shell.trashItem(batch.bundle);
+ undoBatches.delete(key);
+ }
+}
+export function clearRecordingTrashUndo() {
+ const task = mutation.then(finishPendingTrash);
+ mutation = task.catch(() => undefined);
+ return task;
+}
diff --git a/electron/ipc/recording/mac.ts b/electron/ipc/recording/mac.ts
new file mode 100644
index 000000000..bec79bc06
--- /dev/null
+++ b/electron/ipc/recording/mac.ts
@@ -0,0 +1,348 @@
+import type { ChildProcessWithoutNullStreams } from "node:child_process";
+import fs from "node:fs/promises";
+import { BrowserWindow } from "electron";
+import {
+ persistPendingCursorTelemetry,
+ snapshotCursorTelemetryForPersistence,
+} from "../cursor/telemetry";
+import {
+ lastNativeCaptureDiagnostics,
+ nativeCaptureMicrophonePath,
+ nativeCaptureOutputBuffer,
+ nativeCaptureStopRequested,
+ nativeCaptureSystemAudioPath,
+ nativeCaptureTargetPath,
+ nativeScreenRecordingActive,
+ selectedSource,
+ setCurrentProjectPath,
+ setCurrentVideoPath,
+ setNativeCaptureMicrophonePath,
+ setNativeCaptureProcess,
+ setNativeCaptureStopRequested,
+ setNativeCaptureSystemAudioPath,
+ setNativeCaptureTargetPath,
+ setNativeScreenRecordingActive,
+} from "../state";
+import { moveFileWithOverwrite } from "../utils";
+import {
+ getFileSizeIfPresent,
+ recordNativeCaptureDiagnostics,
+ validateRecordedVideo,
+} from "./diagnostics";
+import { emitRecordingInterrupted } from "./events";
+import { getFinalMacCompanionAudioPath } from "./macCompanionAudio";
+
+export function waitForNativeCaptureStart(process: ChildProcessWithoutNullStreams) {
+ return new Promise((resolve, reject) => {
+ const timer = setTimeout(() => {
+ cleanup();
+ reject(new Error("Timed out waiting for ScreenCaptureKit recorder to start"));
+ }, 12000);
+
+ let stdoutBuffer = "";
+ const onStdout = (chunk: Buffer) => {
+ stdoutBuffer += chunk.toString();
+ if (stdoutBuffer.includes("Recording started")) {
+ cleanup();
+ resolve();
+ }
+ };
+
+ const onError = (error: Error) => {
+ cleanup();
+ reject(error);
+ };
+
+ const onExit = (code: number | null) => {
+ cleanup();
+ reject(
+ new Error(
+ nativeCaptureOutputBuffer.trim() ||
+ `Native capture helper exited before recording started (code ${code ?? "unknown"})`,
+ ),
+ );
+ };
+
+ const cleanup = () => {
+ clearTimeout(timer);
+ process.stdout.off("data", onStdout);
+ process.off("error", onError);
+ process.off("exit", onExit);
+ };
+
+ process.stdout.on("data", onStdout);
+ process.once("error", onError);
+ process.once("exit", onExit);
+ });
+}
+
+export function waitForNativeCaptureCommand(
+ process: ChildProcessWithoutNullStreams,
+ marker: "Recording paused" | "Recording resumed",
+) {
+ return new Promise((resolve, reject) => {
+ const timer = setTimeout(() => {
+ cleanup();
+ reject(new Error(`Timed out waiting for ScreenCaptureKit helper: ${marker}`));
+ }, 5000);
+
+ let stdoutBuffer = "";
+ const onStdout = (chunk: Buffer) => {
+ stdoutBuffer += chunk.toString();
+ if (stdoutBuffer.includes(marker)) {
+ cleanup();
+ resolve();
+ }
+ };
+ const onError = (error: Error) => {
+ cleanup();
+ reject(error);
+ };
+ const onExit = (code: number | null) => {
+ cleanup();
+ reject(
+ new Error(
+ `Native capture helper exited before ${marker.toLowerCase()} (code ${code ?? "unknown"})`,
+ ),
+ );
+ };
+ const cleanup = () => {
+ clearTimeout(timer);
+ process.stdout.off("data", onStdout);
+ process.off("error", onError);
+ process.off("exit", onExit);
+ };
+
+ process.stdout.on("data", onStdout);
+ process.once("error", onError);
+ process.once("exit", onExit);
+ });
+}
+
+export function waitForNativeCaptureStop(process: ChildProcessWithoutNullStreams) {
+ return new Promise((resolve, reject) => {
+ const onClose = (code: number | null) => {
+ cleanup();
+ const match = nativeCaptureOutputBuffer.match(/Recording stopped\. Output path: (.+)/);
+ if (match?.[1]) {
+ resolve(match[1].trim());
+ return;
+ }
+ if (code === 0 && nativeCaptureTargetPath) {
+ resolve(nativeCaptureTargetPath);
+ return;
+ }
+ reject(
+ new Error(
+ nativeCaptureOutputBuffer.trim() ||
+ `Native capture helper exited with code ${code ?? "unknown"}`,
+ ),
+ );
+ };
+
+ const onError = (error: Error) => {
+ cleanup();
+ reject(error);
+ };
+
+ const cleanup = () => {
+ process.off("close", onClose);
+ process.off("error", onError);
+ };
+
+ process.once("close", onClose);
+ process.once("error", onError);
+ });
+}
+
+export async function muxNativeMacRecordingWithAudio(
+ videoPath: string,
+ systemAudioPath?: string | null,
+ microphonePath?: string | null,
+) {
+ console.log("[mac-mux] Optimization active: keeping tracks separate.");
+
+ // Optimization: instead of heavy FFmpeg muxing, we ensure audio sidecars
+ // are available alongside the video for the editor.
+ if (systemAudioPath) {
+ const finalSystemPath = getFinalMacCompanionAudioPath(videoPath, systemAudioPath, "system");
+ try {
+ const stat = await fs.stat(systemAudioPath);
+ if (stat.size > 0 && systemAudioPath !== finalSystemPath) {
+ await moveFileWithOverwrite(systemAudioPath, finalSystemPath);
+ }
+ } catch (err) {
+ console.error(`[mac-mux] Failed to handle system audio:`, err);
+ }
+ }
+
+ if (microphonePath) {
+ const finalMicPath = getFinalMacCompanionAudioPath(videoPath, microphonePath, "mic");
+ try {
+ const stat = await fs.stat(microphonePath);
+ if (stat.size > 0 && microphonePath !== finalMicPath) {
+ await moveFileWithOverwrite(microphonePath, finalMicPath);
+ }
+ } catch (err) {
+ console.error(`[mac-mux] Failed to handle mic audio:`, err);
+ }
+ }
+}
+
+export function attachNativeCaptureLifecycle(process: ChildProcessWithoutNullStreams) {
+ process.once("close", () => {
+ const wasActive = nativeScreenRecordingActive;
+ setNativeCaptureProcess(null);
+
+ if (!wasActive || nativeCaptureStopRequested) {
+ return;
+ }
+
+ setNativeScreenRecordingActive(false);
+ console.log("[mac-finalize] Optimization active: skipping safety-net muxing.");
+ setNativeCaptureTargetPath(null);
+ setNativeCaptureStopRequested(false);
+ setNativeCaptureSystemAudioPath(null);
+ setNativeCaptureMicrophonePath(null);
+
+ const sourceName = selectedSource?.name ?? "Screen";
+ BrowserWindow.getAllWindows().forEach((window) => {
+ if (!window.isDestroyed()) {
+ window.webContents.send("recording-state-changed", {
+ recording: false,
+ sourceName,
+ });
+ }
+ });
+
+ const reason = nativeCaptureOutputBuffer.includes("WINDOW_UNAVAILABLE")
+ ? "window-unavailable"
+ : "capture-stopped";
+ const message =
+ reason === "window-unavailable"
+ ? "The selected window is no longer capturable. Please reselect a window."
+ : "Recording stopped unexpectedly.";
+
+ emitRecordingInterrupted(reason, message);
+ });
+}
+
+export async function finalizeStoredVideo(videoPath: string) {
+ console.log("[finalize] Optimization active: skipping safety-net muxing.");
+
+ let validation: { fileSizeBytes: number; durationSeconds: number | null };
+ try {
+ validation = await validateRecordedVideo(videoPath);
+ } catch (error) {
+ if (
+ lastNativeCaptureDiagnostics?.backend === "mac-screencapturekit" ||
+ lastNativeCaptureDiagnostics?.backend === "windows-wgc"
+ ) {
+ recordNativeCaptureDiagnostics({
+ backend: lastNativeCaptureDiagnostics.backend,
+ phase: lastNativeCaptureDiagnostics.phase === "mux" ? "mux" : "stop",
+ sourceId: lastNativeCaptureDiagnostics.sourceId ?? null,
+ sourceType: lastNativeCaptureDiagnostics.sourceType ?? "unknown",
+ displayId: lastNativeCaptureDiagnostics.displayId ?? null,
+ displayBounds: lastNativeCaptureDiagnostics.displayBounds ?? null,
+ windowHandle: lastNativeCaptureDiagnostics.windowHandle ?? null,
+ helperPath: lastNativeCaptureDiagnostics.helperPath ?? null,
+ outputPath: videoPath,
+ systemAudioPath: lastNativeCaptureDiagnostics.systemAudioPath ?? null,
+ microphonePath: lastNativeCaptureDiagnostics.microphonePath ?? null,
+ osRelease: lastNativeCaptureDiagnostics.osRelease,
+ supported: lastNativeCaptureDiagnostics.supported,
+ helperExists: lastNativeCaptureDiagnostics.helperExists,
+ processOutput: lastNativeCaptureDiagnostics.processOutput,
+ fileSizeBytes: await getFileSizeIfPresent(videoPath),
+ error: error instanceof Error ? error.message : String(error),
+ });
+ }
+ throw error;
+ }
+
+ snapshotCursorTelemetryForPersistence();
+ setCurrentVideoPath(videoPath);
+ setCurrentProjectPath(null);
+ try {
+ await persistPendingCursorTelemetry(videoPath);
+ } catch (error) {
+ console.warn("[mac-stop] Failed to persist cursor telemetry:", error);
+ }
+
+ if (
+ lastNativeCaptureDiagnostics?.backend === "mac-screencapturekit" ||
+ lastNativeCaptureDiagnostics?.backend === "windows-wgc"
+ ) {
+ recordNativeCaptureDiagnostics({
+ backend: lastNativeCaptureDiagnostics.backend,
+ phase: lastNativeCaptureDiagnostics.phase === "mux" ? "mux" : "stop",
+ sourceId: lastNativeCaptureDiagnostics.sourceId ?? null,
+ sourceType: lastNativeCaptureDiagnostics.sourceType ?? "unknown",
+ displayId: lastNativeCaptureDiagnostics.displayId ?? null,
+ displayBounds: lastNativeCaptureDiagnostics.displayBounds ?? null,
+ windowHandle: lastNativeCaptureDiagnostics.windowHandle ?? null,
+ helperPath: lastNativeCaptureDiagnostics.helperPath ?? null,
+ outputPath: videoPath,
+ systemAudioPath: lastNativeCaptureDiagnostics.systemAudioPath ?? null,
+ microphonePath: lastNativeCaptureDiagnostics.microphonePath ?? null,
+ osRelease: lastNativeCaptureDiagnostics.osRelease,
+ supported: lastNativeCaptureDiagnostics.supported,
+ helperExists: lastNativeCaptureDiagnostics.helperExists,
+ processOutput: lastNativeCaptureDiagnostics.processOutput,
+ fileSizeBytes: validation.fileSizeBytes,
+ });
+ }
+
+ return {
+ success: true,
+ path: videoPath,
+ message:
+ validation.durationSeconds !== null
+ ? `Video stored successfully (${validation.fileSizeBytes} bytes, ${validation.durationSeconds.toFixed(2)}s)`
+ : `Video stored successfully`,
+ };
+}
+
+export async function recoverNativeMacCaptureOutput() {
+ const macDiagnostics =
+ lastNativeCaptureDiagnostics?.backend === "mac-screencapturekit"
+ ? lastNativeCaptureDiagnostics
+ : null;
+ const diagnosticsPath = macDiagnostics?.outputPath ?? null;
+ const candidatePath = nativeCaptureTargetPath ?? diagnosticsPath;
+ const systemAudioPath = nativeCaptureSystemAudioPath ?? macDiagnostics?.systemAudioPath ?? null;
+ const microphonePath = nativeCaptureMicrophonePath ?? macDiagnostics?.microphonePath ?? null;
+
+ if (!candidatePath) {
+ return null;
+ }
+
+ try {
+ if (systemAudioPath || microphonePath) {
+ try {
+ await muxNativeMacRecordingWithAudio(
+ candidatePath,
+ systemAudioPath,
+ microphonePath,
+ );
+ } catch (muxError) {
+ console.warn("Failed to mux audio during recovery:", muxError);
+ }
+ }
+
+ return await finalizeStoredVideo(candidatePath);
+ } catch (error) {
+ recordNativeCaptureDiagnostics({
+ backend: "mac-screencapturekit",
+ phase: "stop",
+ outputPath: candidatePath,
+ systemAudioPath,
+ microphonePath,
+ processOutput: nativeCaptureOutputBuffer.trim() || undefined,
+ fileSizeBytes: await getFileSizeIfPresent(candidatePath),
+ error: String(error),
+ });
+ return null;
+ }
+}
diff --git a/electron/ipc/recording/macCompanionAudio.test.ts b/electron/ipc/recording/macCompanionAudio.test.ts
new file mode 100644
index 000000000..269a76623
--- /dev/null
+++ b/electron/ipc/recording/macCompanionAudio.test.ts
@@ -0,0 +1,35 @@
+import { describe, expect, it } from "vitest";
+
+import { getFinalMacCompanionAudioPath } from "./macCompanionAudio";
+
+describe("mac companion audio paths", () => {
+ it("preserves the helper's AAC container extension", () => {
+ expect(
+ getFinalMacCompanionAudioPath(
+ "/Users/egg/Recordly/recording-1.mp4",
+ "/Users/egg/Recordly/recording-1.mic.m4a",
+ "mic",
+ ),
+ ).toBe("/Users/egg/Recordly/recording-1.mic.m4a");
+ });
+
+ it("preserves legacy sidecar extensions instead of renaming bytes", () => {
+ expect(
+ getFinalMacCompanionAudioPath(
+ "/Users/egg/Recordly/recording-1.mp4",
+ "/tmp/recordly-native.system.webm",
+ "system",
+ ),
+ ).toBe("/Users/egg/Recordly/recording-1.system.webm");
+ });
+
+ it("keeps dotted directories when the video path has no extension", () => {
+ expect(
+ getFinalMacCompanionAudioPath(
+ "/Users/egg/Recordly.videos/recording-1",
+ "/tmp/recordly-native.mic.m4a",
+ "mic",
+ ),
+ ).toBe("/Users/egg/Recordly.videos/recording-1.mic.m4a");
+ });
+});
diff --git a/electron/ipc/recording/macCompanionAudio.ts b/electron/ipc/recording/macCompanionAudio.ts
new file mode 100644
index 000000000..fdfc9e26b
--- /dev/null
+++ b/electron/ipc/recording/macCompanionAudio.ts
@@ -0,0 +1,16 @@
+import path from "node:path";
+
+export type MacCompanionAudioSuffix = "system" | "mic";
+
+export function getFinalMacCompanionAudioPath(
+ videoPath: string,
+ sourceAudioPath: string,
+ suffix: MacCompanionAudioSuffix,
+) {
+ const separatorIndex = Math.max(videoPath.lastIndexOf("/"), videoPath.lastIndexOf("\\"));
+ const videoDirectory = separatorIndex >= 0 ? videoPath.slice(0, separatorIndex + 1) : "";
+ const videoFileName = separatorIndex >= 0 ? videoPath.slice(separatorIndex + 1) : videoPath;
+ const videoPathWithoutExt = `${videoDirectory}${path.parse(videoFileName).name}`;
+ const sourceExtension = path.extname(sourceAudioPath).toLowerCase() || ".m4a";
+ return `${videoPathWithoutExt}.${suffix}${sourceExtension}`;
+}
diff --git a/electron/ipc/recording/sequenceSource.ts b/electron/ipc/recording/sequenceSource.ts
new file mode 100644
index 000000000..45abfee34
--- /dev/null
+++ b/electron/ipc/recording/sequenceSource.ts
@@ -0,0 +1,9 @@
+import path from "node:path";
+
+/** Sources generated by Videos imports have independent, lossless audio companions. */
+export function isLibrarySequenceSource(file: string): boolean {
+ return (
+ path.basename(path.dirname(file)) === ".recordly-media" &&
+ /^[0-9a-f-]{36}\.mp4$/.test(path.basename(file))
+ );
+}
diff --git a/electron/ipc/recording/sequenceWebcam.ts b/electron/ipc/recording/sequenceWebcam.ts
new file mode 100644
index 000000000..a6ed5c9c0
--- /dev/null
+++ b/electron/ipc/recording/sequenceWebcam.ts
@@ -0,0 +1,142 @@
+import fs from "node:fs/promises";
+import path from "node:path";
+import { execFile } from "node:child_process";
+import { promisify } from "node:util";
+import { getFfmpegBinaryPath } from "../ffmpeg/binary";
+import { probeNativeVideoMetadata } from "../ffmpeg/metadata";
+import { resolveRecordingSession, persistRecordingSessionManifest } from "../project/session";
+import { rememberApprovedLocalReadPath, resolveApprovedLocalMediaPath } from "../project/manager";
+import type { RecordingWebcamSource } from "../../../src/types/recordingLibrary";
+
+const run = promisify(execFile);
+type Range = { startMs: number; endMs: number };
+const rangesPath = (video: string) => `${video}.webcam-ranges.json`;
+export const sequenceWebcamOutputs = (video: string) => [
+ video.replace(/\.mp4$/, "-webcam.mp4"),
+ video.replace(/\.mp4$/, ".recordly-session.json"),
+ rangesPath(video),
+];
+async function linked(video: string): Promise {
+ const session = await resolveRecordingSession(video);
+ if (!session?.webcamPath) return undefined;
+ const webcamPath = await fs.realpath(session.webcamPath);
+ if (path.dirname(webcamPath) !== path.dirname(video))
+ throw new Error("Linked webcam is outside the recording folder");
+ let visibleRanges: Range[] | undefined;
+ try {
+ visibleRanges = JSON.parse(await fs.readFile(rangesPath(video), "utf8"));
+ } catch {
+ /* Original recordings have no range file. */
+ }
+ return { sourcePath: webcamPath, timeOffsetMs: session.timeOffsetMs ?? 0, visibleRanges };
+}
+async function ffmpeg(args: string[], signal?: AbortSignal) {
+ await run(
+ getFfmpegBinaryPath(),
+ ["-hide_banner", "-loglevel", "error", "-nostdin", "-y", ...args],
+ { signal, timeout: 60 * 60 * 1000, maxBuffer: 1024 * 1024 },
+ );
+}
+
+/** Keep webcam media separate from the screen, including blank spans for screen-only clips. */
+export async function composeSequenceWebcam(
+ current: string,
+ added: string,
+ output: string,
+ work: string,
+ baseDurationMs: number,
+ addedDurationMs: number,
+ currentWebcam?: RecordingWebcamSource,
+ signal?: AbortSignal,
+) {
+ const base = currentWebcam === undefined ? await linked(current) : currentWebcam;
+ if (currentWebcam?.sourcePath) {
+ const approved = await resolveApprovedLocalMediaPath(currentWebcam.sourcePath);
+ if (!approved) throw new Error("Current webcam is no longer available");
+ base!.sourcePath = approved;
+ }
+ const next = await linked(added);
+ const firstPath = base?.sourcePath || next?.sourcePath;
+ if (!firstPath) return undefined;
+ const meta = await probeNativeVideoMetadata(getFfmpegBinaryPath(), firstPath, signal);
+ const width = Math.ceil(meta.width / 2) * 2;
+ const height = Math.ceil(meta.height / 2) * 2;
+ const ranges: Range[] = [];
+ for (const [index, source, durationMs, offsetMs] of [
+ [0, base, baseDurationMs, 0],
+ [1, next, addedDurationMs, baseDurationMs],
+ ] as const) {
+ const duration = durationMs / 1000;
+ const args: string[] = [];
+ let filter = `scale=${width}:${height}:force_original_aspect_ratio=decrease,pad=${width}:${height}:(ow-iw)/2:(oh-ih)/2,setsar=1,fps=30,format=yuv420p`;
+ if (source?.sourcePath) {
+ const info = await probeNativeVideoMetadata(
+ getFfmpegBinaryPath(),
+ source.sourcePath,
+ signal,
+ );
+ const delayMs = Number.isFinite(source.timeOffsetMs) ? source.timeOffsetMs : 0;
+ args.push("-i", source.sourcePath);
+ filter = `trim=start=${Math.max(0, -delayMs) / 1000},setpts=PTS-STARTPTS,${filter},tpad=start_mode=add:start_duration=${Math.max(0, delayMs) / 1000}:stop_mode=add:stop_duration=${duration},trim=duration=${duration}`;
+ const active = source.visibleRanges ?? [
+ {
+ startMs: Math.max(0, delayMs),
+ endMs: Math.min(durationMs, info.duration * 1000 + delayMs),
+ },
+ ];
+ for (const range of active) {
+ if (!Number.isFinite(range.startMs) || !Number.isFinite(range.endMs)) continue;
+ const startMs = Math.max(0, range.startMs);
+ const endMs = Math.min(durationMs, range.endMs);
+ if (endMs > startMs)
+ ranges.push({ startMs: offsetMs + startMs, endMs: offsetMs + endMs });
+ }
+ } else {
+ args.push("-f", "lavfi", "-i", `color=c=black:s=${width}x${height}:r=30:d=${duration}`);
+ }
+ await ffmpeg(
+ [
+ ...args,
+ "-an",
+ "-vf",
+ filter,
+ "-t",
+ String(duration),
+ "-c:v",
+ "libx264",
+ "-preset",
+ "fast",
+ "-crf",
+ "18",
+ path.join(work, `webcam-${index}.mp4`),
+ ],
+ signal,
+ );
+ }
+ await fs.writeFile(
+ path.join(work, "webcam-list.txt"),
+ "file 'webcam-0.mp4'\nfile 'webcam-1.mp4'\n",
+ );
+ const webcamPath = sequenceWebcamOutputs(output)[0];
+ await ffmpeg(
+ [
+ "-f",
+ "concat",
+ "-safe",
+ "1",
+ "-i",
+ path.join(work, "webcam-list.txt"),
+ "-an",
+ "-c:v",
+ "copy",
+ "-movflags",
+ "+faststart",
+ webcamPath,
+ ],
+ signal,
+ );
+ await persistRecordingSessionManifest({ videoPath: output, webcamPath, timeOffsetMs: 0 });
+ await fs.writeFile(rangesPath(output), JSON.stringify(ranges));
+ await rememberApprovedLocalReadPath(webcamPath);
+ return { sourcePath: webcamPath, timeOffsetMs: 0, visibleRanges: ranges };
+}
diff --git a/electron/ipc/recording/storagePath.test.ts b/electron/ipc/recording/storagePath.test.ts
new file mode 100644
index 000000000..cab90c909
--- /dev/null
+++ b/electron/ipc/recording/storagePath.test.ts
@@ -0,0 +1,73 @@
+import path from "node:path";
+import { describe, expect, it } from "vitest";
+import { isWebcamCompanionRecordingPath, resolveRecordedVideoStoragePath } from "./storagePath";
+
+describe("resolveRecordedVideoStoragePath", () => {
+ const recordingsDir = path.resolve("recordings-root");
+
+ it.each([
+ "recording-0.webm",
+ "recording-1720588800000.mp4",
+ "recording-1720588800000-webcam.webm",
+ "recording-1720588800000-webcam.mp4",
+ ])("accepts an app-generated recording name: %s", (fileName) => {
+ expect(resolveRecordedVideoStoragePath(recordingsDir, fileName)).toBe(
+ path.resolve(recordingsDir, fileName),
+ );
+ });
+
+ it.each([
+ "",
+ "../recording-1.webm",
+ "..\\recording-1.webm",
+ "nested/recording-1.webm",
+ "nested\\recording-1.webm",
+ "/tmp/recording-1.webm",
+ "C:\\temp\\recording-1.webm",
+ "\\\\server\\share\\recording-1.webm",
+ "recording-1.webm:payload",
+ "recording-1.webm\n",
+ "recording-1.webm\0",
+ "recording--1.webm",
+ "recording-1.5.webm",
+ "recording-1.mov",
+ "other-1.webm",
+ "recording-1-WEBCAM.webm",
+ ])("rejects an untrusted recording name: %s", (fileName) => {
+ expect(() => resolveRecordedVideoStoragePath(recordingsDir, fileName)).toThrow(
+ "Invalid recording file name",
+ );
+ });
+
+ it.each([
+ { label: "undefined", value: undefined },
+ { label: "null", value: null },
+ { label: "number", value: 1 },
+ { label: "object", value: {} },
+ { label: "array", value: [] },
+ ])("rejects a non-string recording name: $label", ({ value }) => {
+ expect(() => resolveRecordedVideoStoragePath(recordingsDir, value)).toThrow(
+ "Invalid recording file name",
+ );
+ });
+});
+
+describe("isWebcamCompanionRecordingPath", () => {
+ const recordingsDir = path.resolve("recordings-root");
+
+ it.each([
+ "recording-1720588800000-webcam.webm",
+ "recording-1720588800000-webcam.mp4",
+ ])("detects a webcam companion recording: %s", (fileName) => {
+ expect(isWebcamCompanionRecordingPath(path.join(recordingsDir, fileName))).toBe(true);
+ });
+
+ it.each([
+ "recording-1720588800000.webm",
+ "recording-1720588800000.mp4",
+ "recording-webcam.webm",
+ "recording-1720588800000-webcam.mov",
+ ])("does not treat other files as webcam companions: %s", (fileName) => {
+ expect(isWebcamCompanionRecordingPath(path.join(recordingsDir, fileName))).toBe(false);
+ });
+});
diff --git a/electron/ipc/recording/storagePath.ts b/electron/ipc/recording/storagePath.ts
new file mode 100644
index 000000000..426c78ef0
--- /dev/null
+++ b/electron/ipc/recording/storagePath.ts
@@ -0,0 +1,29 @@
+import path from "node:path";
+
+const RECORDED_VIDEO_FILE_NAME = /^recording-[0-9]+(?:-webcam)?\.(?:webm|mp4)$/;
+const WEBCAM_COMPANION_FILE_NAME = /^recording-[0-9]+-webcam\.(?:webm|mp4)$/;
+
+export function resolveRecordedVideoStoragePath(recordingsDir: string, fileName: unknown): string {
+ if (typeof fileName !== "string" || RECORDED_VIDEO_FILE_NAME.exec(fileName)?.[0] !== fileName) {
+ throw new Error("Invalid recording file name");
+ }
+
+ const resolvedRecordingsDir = path.resolve(recordingsDir);
+ const candidatePath = path.resolve(resolvedRecordingsDir, fileName);
+ const relativePath = path.relative(resolvedRecordingsDir, candidatePath);
+
+ if (
+ relativePath.length === 0 ||
+ relativePath === ".." ||
+ relativePath.startsWith(`..${path.sep}`) ||
+ path.isAbsolute(relativePath)
+ ) {
+ throw new Error("Invalid recording file name");
+ }
+
+ return candidatePath;
+}
+
+export function isWebcamCompanionRecordingPath(filePath: string): boolean {
+ return WEBCAM_COMPANION_FILE_NAME.test(path.basename(filePath));
+}
diff --git a/electron/ipc/recording/thumbnail.ts b/electron/ipc/recording/thumbnail.ts
new file mode 100644
index 000000000..5397b3b37
--- /dev/null
+++ b/electron/ipc/recording/thumbnail.ts
@@ -0,0 +1,76 @@
+import { createHash } from "node:crypto";
+import { execFile } from "node:child_process";
+import fs from "node:fs/promises";
+import path from "node:path";
+import { promisify } from "node:util";
+import { app } from "electron";
+import { getFfmpegBinaryPath } from "../ffmpeg/binary";
+import { resolveApprovedLocalMediaPath } from "../project/manager";
+import { getRecordingsDir } from "../utils";
+
+const run = promisify(execFile);
+const pending = new Map>();
+let queue = Promise.resolve();
+
+/** Decode one small still, never a video player per library row. */
+export async function getRecordingThumbnail(candidate: string): Promise {
+ const root = await fs.realpath(await getRecordingsDir());
+ const file = await resolveApprovedLocalMediaPath(candidate);
+ if (!file || path.dirname(file) !== root || !/\.(mp4|mov|webm|mkv|m4v)$/i.test(file))
+ throw new Error("Recording is outside the Videos library");
+ const stat = await fs.stat(file);
+ if (!stat.isFile()) throw new Error("Recording is not a file");
+ const key = createHash("sha256").update(`${file}:${stat.mtimeMs}:${stat.size}`).digest("hex");
+ const existing = pending.get(key);
+ if (existing) return existing;
+ const cache = path.join(app.getPath("userData"), "recording-thumbnails", `${key}.jpg`);
+ const task = queue.then(async () => {
+ let jpeg: Buffer;
+ try {
+ jpeg = await fs.readFile(cache);
+ } catch (error) {
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
+ const result = await run(
+ getFfmpegBinaryPath(),
+ [
+ "-hide_banner",
+ "-loglevel",
+ "error",
+ "-nostdin",
+ "-threads",
+ "1",
+ "-i",
+ file,
+ "-frames:v",
+ "1",
+ "-an",
+ "-vf",
+ "scale=160:90:force_original_aspect_ratio=increase,crop=160:90",
+ "-q:v",
+ "5",
+ "-f",
+ "image2pipe",
+ "-c:v",
+ "mjpeg",
+ "pipe:1",
+ ],
+ { encoding: "buffer", maxBuffer: 512 * 1024, timeout: 15000, windowsHide: true },
+ );
+ jpeg = result.stdout;
+ if (!jpeg.length) throw new Error("Recording has no preview frame");
+ await fs.mkdir(path.dirname(cache), { recursive: true });
+ await fs.writeFile(cache, jpeg);
+ }
+ return `data:image/jpeg;base64,${jpeg.toString("base64")}`;
+ });
+ pending.set(key, task);
+ queue = task.then(
+ () => undefined,
+ () => undefined,
+ );
+ try {
+ return await task;
+ } finally {
+ pending.delete(key);
+ }
+}
diff --git a/electron/ipc/recording/windows.test.ts b/electron/ipc/recording/windows.test.ts
new file mode 100644
index 000000000..0d5613392
--- /dev/null
+++ b/electron/ipc/recording/windows.test.ts
@@ -0,0 +1,130 @@
+import { EventEmitter } from "node:events";
+import { readFileSync } from "node:fs";
+import { PassThrough } from "node:stream";
+import { fileURLToPath } from "node:url";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { setWindowsCaptureOutputBuffer, setWindowsCaptureTargetPath } from "../state";
+import { waitForWindowsCaptureStop } from "./windows";
+
+const windowsCaptureSource = readFileSync(
+ fileURLToPath(new URL("../../native/wgc-capture/src/wgc_session.cpp", import.meta.url)),
+ "utf8",
+);
+
+vi.mock("electron", () => ({
+ app: {
+ getPath: () => "C:\\RecordlyTest",
+ },
+ BrowserWindow: {
+ getAllWindows: () => [],
+ },
+}));
+
+class FakeCaptureProcess extends EventEmitter {
+ stdout = new PassThrough();
+ stderr = new PassThrough();
+ stdin = new PassThrough();
+ killed = false;
+
+ kill = vi.fn(() => {
+ this.killed = true;
+ return true;
+ });
+}
+
+describe("waitForWindowsCaptureStop", () => {
+ beforeEach(() => {
+ setWindowsCaptureOutputBuffer("");
+ setWindowsCaptureTargetPath(null);
+ });
+
+ it("resolves the helper output path when the process closes cleanly", async () => {
+ const proc = new FakeCaptureProcess();
+ setWindowsCaptureOutputBuffer("Recording stopped. Output path: C:\\Recordly\\capture.mp4");
+
+ const stopped = waitForWindowsCaptureStop(
+ proc as unknown as Parameters[0],
+ 1000,
+ );
+ proc.emit("close", 0);
+
+ await expect(stopped).resolves.toBe("C:\\Recordly\\capture.mp4");
+ expect(proc.kill).not.toHaveBeenCalled();
+ });
+
+ it("resolves the fallback target path when the helper closes cleanly without output path", async () => {
+ const proc = new FakeCaptureProcess();
+ setWindowsCaptureOutputBuffer("Recording stopped without output path");
+ setWindowsCaptureTargetPath("C:\\Recordly\\fallback.mp4");
+
+ const stopped = waitForWindowsCaptureStop(
+ proc as unknown as Parameters[0],
+ 1000,
+ );
+ proc.emit("close", 0);
+
+ await expect(stopped).resolves.toBe("C:\\Recordly\\fallback.mp4");
+ expect(proc.kill).not.toHaveBeenCalled();
+ });
+
+ it("rejects with helper output when the helper exits with a non-zero code", async () => {
+ const proc = new FakeCaptureProcess();
+ setWindowsCaptureOutputBuffer("Encoder error: insufficient memory");
+
+ const stopped = waitForWindowsCaptureStop(
+ proc as unknown as Parameters[0],
+ 1000,
+ );
+ proc.emit("close", 1);
+
+ await expect(stopped).rejects.toThrow("Encoder error: insufficient memory");
+ expect(proc.kill).not.toHaveBeenCalled();
+ });
+
+ it("rejects when the helper emits an error", async () => {
+ const proc = new FakeCaptureProcess();
+ const error = new Error("spawn failed");
+
+ const stopped = waitForWindowsCaptureStop(
+ proc as unknown as Parameters[0],
+ 1000,
+ );
+ proc.emit("error", error);
+
+ await expect(stopped).rejects.toBe(error);
+ expect(proc.kill).not.toHaveBeenCalled();
+ });
+
+ it("kills the helper and rejects when stop never completes", async () => {
+ const proc = new FakeCaptureProcess();
+
+ await expect(
+ waitForWindowsCaptureStop(
+ proc as unknown as Parameters[0],
+ 5,
+ ),
+ ).rejects.toThrow("Timed out waiting for native Windows capture to stop");
+ expect(proc.kill).toHaveBeenCalledTimes(1);
+ });
+});
+
+describe("native Windows window capture", () => {
+ it("crops monitor frames to the selected window bounds", () => {
+ expect(windowsCaptureSource).not.toContain("CreateForWindow(");
+ expect(windowsCaptureSource).toContain("DwmGetWindowAttribute(");
+ expect(windowsCaptureSource).toContain("CopySubresourceRegion(cropTexture_");
+ });
+
+ it("maps desktop bounds into WGC texture coordinates and keeps the encoder size fixed", () => {
+ expect(windowsCaptureSource).toContain("normalized * framePoolWidth_");
+ expect(windowsCaptureSource).toContain(
+ "d3dDevice_->CreateTexture2D(&desc, nullptr, &resizedTexture)",
+ );
+ expect(windowsCaptureSource).not.toContain("nextWidth != captureWidth_");
+ });
+
+ it("does not expose desktop pixels when the selected window shrinks", () => {
+ expect(windowsCaptureSource).toContain("(std::min)(mappedWidth");
+ expect(windowsCaptureSource).toContain("ClearRenderTargetView");
+ });
+});
diff --git a/electron/ipc/recording/windows.ts b/electron/ipc/recording/windows.ts
new file mode 100644
index 000000000..650cdca98
--- /dev/null
+++ b/electron/ipc/recording/windows.ts
@@ -0,0 +1,267 @@
+import type { ChildProcessWithoutNullStreams } from "node:child_process";
+import { constants as fsConstants } from "node:fs";
+import fs from "node:fs/promises";
+import { BrowserWindow } from "electron";
+import { getWindowsCaptureExePath } from "../paths/binaries";
+import {
+ selectedSource,
+ setWindowsCaptureProcess,
+ setWindowsCaptureStopRequested,
+ setWindowsNativeCaptureActive,
+ windowsCaptureOutputBuffer,
+ windowsCaptureStopRequested,
+ windowsCaptureTargetPath,
+ windowsNativeCaptureActive,
+} from "../state";
+import { AudioSyncAdjustment } from "../types";
+import { moveFileWithOverwrite } from "../utils";
+import { emitRecordingInterrupted } from "./events";
+
+const WINDOWS_CAPTURE_STOP_TIMEOUT_MS = 45_000;
+
+export type NativeWindowsVideoPaddingResult = {
+ padded: boolean;
+ durationSeconds: number;
+ containerDurationSeconds: number;
+ targetDurationSeconds: number;
+ padDurationSeconds: number;
+};
+
+export type NativeWindowsAudioMuxResult = {
+ muxed: boolean;
+ videoDurationSeconds: number;
+ muxTimeoutMs: number;
+ audioInputs: string[];
+ audio: Record<
+ string,
+ {
+ path: string;
+ sizeBytes: number;
+ durationSeconds: number;
+ startDelayMs: number | null;
+ adjustment: AudioSyncAdjustment;
+ }
+ >;
+ outputPath?: string;
+ keptAudioSidecars?: boolean;
+};
+
+export async function isNativeWindowsCaptureAvailable(): Promise {
+ if (process.platform !== "win32") return false;
+
+ const os = await import("node:os");
+ const [major, , build] = os.release().split(".").map(Number);
+ const supported = major >= 10 && build >= 19041;
+ if (!supported) return false;
+
+ try {
+ await fs.access(getWindowsCaptureExePath(), fsConstants.X_OK);
+ } catch {
+ return false;
+ }
+
+ return true;
+}
+
+export function waitForWindowsCaptureStart(proc: ChildProcessWithoutNullStreams) {
+ return new Promise((resolve, reject) => {
+ const timer = setTimeout(() => {
+ cleanup();
+ reject(new Error("Timed out waiting for native Windows capture to start"));
+ }, 12000);
+
+ let stdoutBuffer = "";
+ const onStdout = (chunk: Buffer) => {
+ stdoutBuffer += chunk.toString();
+ if (stdoutBuffer.includes("Recording started")) {
+ cleanup();
+ resolve();
+ }
+ };
+
+ const onError = (error: Error) => {
+ cleanup();
+ reject(error);
+ };
+
+ const onExit = (code: number | null) => {
+ cleanup();
+ reject(
+ new Error(
+ windowsCaptureOutputBuffer.trim() ||
+ `Native Windows capture exited before recording started (code ${code ?? "unknown"})`,
+ ),
+ );
+ };
+
+ const cleanup = () => {
+ clearTimeout(timer);
+ proc.stdout.off("data", onStdout);
+ proc.off("error", onError);
+ proc.off("exit", onExit);
+ };
+
+ proc.stdout.on("data", onStdout);
+ proc.once("error", onError);
+ proc.once("exit", onExit);
+ });
+}
+
+export function waitForWindowsCaptureStop(
+ proc: ChildProcessWithoutNullStreams,
+ timeoutMs = WINDOWS_CAPTURE_STOP_TIMEOUT_MS,
+) {
+ return new Promise((resolve, reject) => {
+ let settled = false;
+ const finish = (callback: () => void) => {
+ if (settled) return;
+ settled = true;
+ cleanup();
+ callback();
+ };
+
+ const timer = setTimeout(() => {
+ finish(() => {
+ try {
+ if (!proc.killed) proc.kill();
+ } catch {
+ // The process may already be gone; the caller only needs the timeout error.
+ }
+ reject(new Error("Timed out waiting for native Windows capture to stop"));
+ });
+ }, timeoutMs);
+
+ const onClose = (code: number | null) => {
+ finish(() => {
+ const match = windowsCaptureOutputBuffer.match(
+ /Recording stopped\. Output path: (.+)/,
+ );
+ if (match?.[1]) {
+ resolve(match[1].trim());
+ return;
+ }
+ if (code === 0 && windowsCaptureTargetPath) {
+ resolve(windowsCaptureTargetPath);
+ return;
+ }
+ reject(
+ new Error(
+ windowsCaptureOutputBuffer.trim() ||
+ `Native Windows capture exited with code ${code ?? "unknown"}`,
+ ),
+ );
+ });
+ };
+
+ const onError = (error: Error) => {
+ finish(() => {
+ reject(error);
+ });
+ };
+
+ const cleanup = () => {
+ clearTimeout(timer);
+ proc.off("close", onClose);
+ proc.off("error", onError);
+ };
+
+ proc.once("close", onClose);
+ proc.once("error", onError);
+ });
+}
+
+export function attachWindowsCaptureLifecycle(proc: ChildProcessWithoutNullStreams) {
+ proc.once("close", () => {
+ const wasActive = windowsNativeCaptureActive;
+ setWindowsCaptureProcess(null);
+
+ if (!wasActive || windowsCaptureStopRequested) {
+ return;
+ }
+
+ setWindowsNativeCaptureActive(false);
+ setWindowsCaptureStopRequested(false);
+
+ const sourceName = selectedSource?.name ?? "Screen";
+ BrowserWindow.getAllWindows().forEach((window) => {
+ if (!window.isDestroyed()) {
+ window.webContents.send("recording-state-changed", {
+ recording: false,
+ sourceName,
+ });
+ }
+ });
+
+ emitRecordingInterrupted("capture-stopped", "Recording stopped unexpectedly.");
+ });
+}
+
+export async function muxNativeWindowsVideoWithAudio(
+ videoPath: string,
+ systemAudioPath: string | null,
+ micAudioPath: string | null,
+): Promise {
+ const start = Date.now();
+ console.log("[PERF:MAIN] muxNativeWindowsVideoWithAudio: STARTED");
+ const audio: NativeWindowsAudioMuxResult["audio"] = {};
+ const audioInputs: string[] = [];
+
+ const videoPathWithoutExt = videoPath.replace(/\.[^.]+$/u, "");
+
+ // Optimization: instead of heavy FFmpeg muxing, we just move the audio sidecars
+ // to their final companion paths so the editor can find them as separate tracks.
+ if (systemAudioPath) {
+ const finalSystemPath = `${videoPathWithoutExt}.system.wav`;
+ try {
+ const stat = await fs.stat(systemAudioPath);
+ if (stat.size > 0) {
+ if (systemAudioPath !== finalSystemPath) {
+ await moveFileWithOverwrite(systemAudioPath, finalSystemPath);
+ }
+ audioInputs.push("system");
+ audio.system = {
+ path: finalSystemPath,
+ sizeBytes: stat.size,
+ durationSeconds: 0,
+ startDelayMs: null,
+ adjustment: { mode: "none", delayMs: 0, tempoRatio: 1, durationDeltaMs: 0 },
+ };
+ }
+ } catch (err) {
+ console.error(`[mux-win] Failed to handle system audio:`, err);
+ }
+ }
+
+ if (micAudioPath) {
+ const finalMicPath = `${videoPathWithoutExt}.mic.wav`;
+ try {
+ const stat = await fs.stat(micAudioPath);
+ if (stat.size > 0) {
+ if (micAudioPath !== finalMicPath) {
+ await moveFileWithOverwrite(micAudioPath, finalMicPath);
+ }
+ audioInputs.push("mic");
+ audio.mic = {
+ path: finalMicPath,
+ sizeBytes: stat.size,
+ durationSeconds: 0,
+ startDelayMs: null,
+ adjustment: { mode: "none", delayMs: 0, tempoRatio: 1, durationDeltaMs: 0 },
+ };
+ }
+ } catch (err) {
+ console.error(`[mux-win] Failed to handle mic audio:`, err);
+ }
+ }
+
+ console.log(`[PERF:MAIN] muxNativeWindowsVideoWithAudio: COMPLETED in ${Date.now() - start}ms`);
+
+ return {
+ muxed: false,
+ videoDurationSeconds: 0, // No longer needed here
+ muxTimeoutMs: 0,
+ audioInputs,
+ audio,
+ keptAudioSidecars: true,
+ };
+}
diff --git a/electron/ipc/recording/windowsFallbacks.test.ts b/electron/ipc/recording/windowsFallbacks.test.ts
new file mode 100644
index 000000000..512a81bef
--- /dev/null
+++ b/electron/ipc/recording/windowsFallbacks.test.ts
@@ -0,0 +1,104 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ shouldStartWindowsBrowserMicrophoneFallback,
+ shouldUseWindowsBrowserMicrophoneFallback,
+ WINDOWS_MIC_CAPTURE_MODE_ENV,
+} from "./windowsFallbacks";
+
+describe("shouldUseWindowsBrowserMicrophoneFallback", () => {
+ it("defaults Windows microphone capture to native WASAPI", () => {
+ expect(shouldStartWindowsBrowserMicrophoneFallback({ capturesMicrophone: true }, {})).toBe(
+ false,
+ );
+ });
+
+ it("can be forced before native capture starts", () => {
+ expect(
+ shouldStartWindowsBrowserMicrophoneFallback(
+ { capturesMicrophone: true },
+ { [WINDOWS_MIC_CAPTURE_MODE_ENV]: "browser" },
+ ),
+ ).toBe(true);
+ });
+
+ it("keeps native WASAPI enabled when explicitly requested", () => {
+ expect(
+ shouldStartWindowsBrowserMicrophoneFallback(
+ { capturesMicrophone: true },
+ { [WINDOWS_MIC_CAPTURE_MODE_ENV]: "native" },
+ ),
+ ).toBe(false);
+ expect(
+ shouldStartWindowsBrowserMicrophoneFallback(
+ { capturesMicrophone: true },
+ { [WINDOWS_MIC_CAPTURE_MODE_ENV]: "wasapi" },
+ ),
+ ).toBe(false);
+ });
+
+ it("uses native WASAPI for unknown mode values", () => {
+ expect(
+ shouldStartWindowsBrowserMicrophoneFallback(
+ { capturesMicrophone: true },
+ { [WINDOWS_MIC_CAPTURE_MODE_ENV]: "typo" },
+ ),
+ ).toBe(false);
+ });
+
+ it("does not force fallback when microphone capture was not requested", () => {
+ expect(
+ shouldStartWindowsBrowserMicrophoneFallback(
+ { capturesMicrophone: false },
+ { [WINDOWS_MIC_CAPTURE_MODE_ENV]: "browser" },
+ ),
+ ).toBe(false);
+ });
+
+ it("returns true when native Windows mic initialization fails", () => {
+ expect(
+ shouldUseWindowsBrowserMicrophoneFallback(
+ "WARNING: Failed to initialize WASAPI mic capture\nRecording started",
+ { capturesMicrophone: true },
+ ),
+ ).toBe(true);
+ });
+
+ it("returns true when the native helper reports its stable fallback marker", () => {
+ expect(
+ shouldUseWindowsBrowserMicrophoneFallback(
+ "MICROPHONE_CAPTURE_UNAVAILABLE\nRecording started",
+ { capturesMicrophone: true },
+ ),
+ ).toBe(true);
+ });
+
+ it("returns false when microphone capture was not requested", () => {
+ expect(
+ shouldUseWindowsBrowserMicrophoneFallback(
+ "WARNING: Failed to initialize WASAPI mic capture\nRecording started",
+ { capturesMicrophone: false },
+ ),
+ ).toBe(false);
+ });
+
+ it("returns false for a healthy native mic by default", () => {
+ expect(
+ shouldUseWindowsBrowserMicrophoneFallback(
+ "Recording started",
+ { capturesMicrophone: true },
+ {},
+ ),
+ ).toBe(false);
+ });
+
+ it("returns true when browser fallback is forced", () => {
+ expect(
+ shouldUseWindowsBrowserMicrophoneFallback(
+ "Recording started",
+ { capturesMicrophone: true },
+ { [WINDOWS_MIC_CAPTURE_MODE_ENV]: "fallback" },
+ ),
+ ).toBe(true);
+ });
+});
diff --git a/electron/ipc/recording/windowsFallbacks.ts b/electron/ipc/recording/windowsFallbacks.ts
new file mode 100644
index 000000000..f579e0527
--- /dev/null
+++ b/electron/ipc/recording/windowsFallbacks.ts
@@ -0,0 +1,33 @@
+const WINDOWS_MIC_CAPTURE_UNAVAILABLE_MARKERS = [
+ "MICROPHONE_CAPTURE_UNAVAILABLE",
+ "WARNING: Failed to initialize WASAPI mic capture",
+];
+export const WINDOWS_MIC_CAPTURE_MODE_ENV = "RECORDLY_WINDOWS_MIC_CAPTURE";
+
+export function shouldStartWindowsBrowserMicrophoneFallback(
+ options?: { capturesMicrophone?: boolean },
+ env: NodeJS.ProcessEnv = process.env,
+) {
+ if (!options?.capturesMicrophone) {
+ return false;
+ }
+
+ const mode = env[WINDOWS_MIC_CAPTURE_MODE_ENV]?.trim().toLowerCase();
+ // Native WASAPI is the normal Windows path. Keep the renderer path as an
+ // explicit escape hatch and as an automatic fallback when WASAPI cannot start.
+ return mode === "browser" || mode === "fallback" || mode === "renderer";
+}
+
+export function shouldUseWindowsBrowserMicrophoneFallback(
+ captureOutput: string,
+ options?: { capturesMicrophone?: boolean },
+ env: NodeJS.ProcessEnv = process.env,
+) {
+ return (
+ Boolean(options?.capturesMicrophone) &&
+ (shouldStartWindowsBrowserMicrophoneFallback(options, env) ||
+ WINDOWS_MIC_CAPTURE_UNAVAILABLE_MARKERS.some((marker) =>
+ captureOutput.includes(marker),
+ ))
+ );
+}
diff --git a/electron/ipc/register/announcements.ts b/electron/ipc/register/announcements.ts
new file mode 100644
index 000000000..c5791e99f
--- /dev/null
+++ b/electron/ipc/register/announcements.ts
@@ -0,0 +1,91 @@
+import { ipcMain } from "electron";
+
+const DEFAULT_ANNOUNCEMENT_FEED_URL =
+ "https://raw.githubusercontent.com/webadderallorg/Recordly/main/announcements.json";
+const ANNOUNCEMENT_FETCH_TIMEOUT_MS = 5_000;
+const ANNOUNCEMENT_CACHE_TTL_MS = 6 * 60 * 60 * 1_000;
+const MAX_ANNOUNCEMENT_FEED_BYTES = 1_000_000;
+
+let cachedFeed: unknown = null;
+let cachedAt = 0;
+let hasCachedResult = false;
+let pendingFetch: Promise | null = null;
+
+function getAnnouncementFeedUrl(): string | null {
+ const configuredUrl = process.env.RECORDLY_ANNOUNCEMENTS_URL?.trim();
+ if (configuredUrl?.toLowerCase() === "off") {
+ return null;
+ }
+
+ const candidate = configuredUrl || DEFAULT_ANNOUNCEMENT_FEED_URL;
+ try {
+ const parsed = new URL(candidate);
+ return parsed.protocol === "https:" && !parsed.username && !parsed.password
+ ? parsed.href
+ : null;
+ } catch {
+ return null;
+ }
+}
+
+async function requestAnnouncementFeed(feedUrl: string): Promise {
+ const controller = new AbortController();
+ const timeout = setTimeout(() => controller.abort(), ANNOUNCEMENT_FETCH_TIMEOUT_MS);
+
+ try {
+ const response = await fetch(feedUrl, {
+ signal: controller.signal,
+ headers: { Accept: "application/json" },
+ redirect: "follow",
+ });
+ if (!response.ok) {
+ throw new Error(`Announcement feed returned HTTP ${response.status}`);
+ }
+ if (new URL(response.url).protocol !== "https:") {
+ throw new Error("Announcement feed redirected to an unsafe URL");
+ }
+
+ const declaredLength = Number(response.headers.get("content-length"));
+ if (Number.isFinite(declaredLength) && declaredLength > MAX_ANNOUNCEMENT_FEED_BYTES) {
+ throw new Error("Announcement feed is too large");
+ }
+
+ const text = await response.text();
+ if (Buffer.byteLength(text, "utf8") > MAX_ANNOUNCEMENT_FEED_BYTES) {
+ throw new Error("Announcement feed is too large");
+ }
+
+ cachedFeed = JSON.parse(text) as unknown;
+ cachedAt = Date.now();
+ hasCachedResult = true;
+ return cachedFeed;
+ } catch (error) {
+ console.warn("Failed to load announcement feed:", error);
+ return cachedFeed;
+ } finally {
+ clearTimeout(timeout);
+ }
+}
+
+function fetchAnnouncementFeed(): Promise {
+ const feedUrl = getAnnouncementFeedUrl();
+ if (!feedUrl) {
+ return Promise.resolve(null);
+ }
+
+ if (hasCachedResult && Date.now() - cachedAt < ANNOUNCEMENT_CACHE_TTL_MS) {
+ return Promise.resolve(cachedFeed);
+ }
+
+ if (!pendingFetch) {
+ pendingFetch = requestAnnouncementFeed(feedUrl).finally(() => {
+ pendingFetch = null;
+ });
+ }
+
+ return pendingFetch;
+}
+
+export function registerAnnouncementHandlers() {
+ ipcMain.handle("announcements:get", fetchAnnouncementFeed);
+}
diff --git a/electron/ipc/register/assets.ts b/electron/ipc/register/assets.ts
new file mode 100644
index 000000000..e871cc170
--- /dev/null
+++ b/electron/ipc/register/assets.ts
@@ -0,0 +1,151 @@
+import { createHash } from "node:crypto";
+import { existsSync } from "node:fs";
+import fs from "node:fs/promises";
+import path from "node:path";
+import { pathToFileURL } from "node:url";
+import { app, ipcMain } from "electron";
+import { USER_DATA_PATH } from "../../appPaths";
+import { getAssetRootPath } from "../project/manager";
+import { normalizePath } from "../utils";
+
+export function registerAssetHandlers() {
+ async function resolveReadableLocalFilePath(filePath: string) {
+ const normalizedPath = normalizePath(filePath);
+ const resolvedPath = await fs.realpath(normalizedPath).catch(() => normalizedPath);
+ const stats = await fs.stat(resolvedPath);
+ if (!stats.isFile()) {
+ throw new Error("Path is not a readable file");
+ }
+ return normalizePath(resolvedPath);
+ }
+
+ // Generate a tiny thumbnail for a wallpaper image and cache it in userData.
+ // Returns the cached thumbnail as raw JPEG bytes for fast grid rendering.
+ // Serialized to prevent concurrent nativeImage operations from eating memory.
+ const THUMB_SIZE = 96;
+ const thumbCacheDir = path.join(USER_DATA_PATH, "wallpaper-thumbs");
+ let thumbGenerationQueue: Promise = Promise.resolve();
+
+ ipcMain.handle("generate-wallpaper-thumbnail", async (_, filePath: string) => {
+ try {
+ const bundled = filePath.startsWith("/wallpapers/");
+ const wallpaperRoot = path.resolve(getAssetRootPath(), "wallpapers");
+ const candidate = bundled
+ ? path.resolve(
+ wallpaperRoot,
+ decodeURIComponent(filePath.slice("/wallpapers/".length)),
+ )
+ : filePath;
+ if (
+ bundled &&
+ (path.relative(wallpaperRoot, candidate).startsWith("..") ||
+ path.isAbsolute(path.relative(wallpaperRoot, candidate)))
+ ) {
+ throw new Error("Wallpaper path is outside the bundled wallpapers");
+ }
+ const resolved = await resolveReadableLocalFilePath(candidate);
+
+ // Deterministic cache key from file path + mtime
+ const stat = await fs.stat(resolved);
+ const cacheKey = createHash("sha256")
+ .update(`${resolved}:${stat.mtimeMs}`)
+ .digest("hex");
+ const thumbPath = path.join(thumbCacheDir, `${cacheKey}.jpg`);
+
+ // Return cached thumbnail if it exists (no queue needed)
+ if (existsSync(thumbPath)) {
+ const data = await fs.readFile(thumbPath);
+ return { success: true, data };
+ }
+
+ // Serialize nativeImage operations to avoid OOM from concurrent full-res decodes
+ let jpegData: Buffer;
+ const generation = thumbGenerationQueue.then(async () => {
+ const { nativeImage } = await import("electron");
+ const img = nativeImage.createFromPath(resolved);
+ if (img.isEmpty()) {
+ throw new Error("Failed to load image");
+ }
+ const { width, height } = img.getSize();
+ const scale = THUMB_SIZE / Math.min(width, height);
+ const resized = img.resize({
+ width: Math.round(width * scale),
+ height: Math.round(height * scale),
+ quality: "good",
+ });
+ jpegData = resized.toJPEG(70);
+
+ // Cache to disk
+ await fs.mkdir(thumbCacheDir, { recursive: true });
+ await fs.writeFile(thumbPath, jpegData);
+ });
+ // Keep the queue moving even if one fails
+ thumbGenerationQueue = generation.catch(() => undefined);
+ await generation;
+
+ return { success: true, data: jpegData! };
+ } catch (error) {
+ return { success: false, error: String(error) };
+ }
+ });
+
+ // Return base path for assets so renderer can resolve file:// paths in production
+ ipcMain.handle("get-asset-base-path", () => {
+ if (!app.isPackaged) {
+ return null;
+ }
+
+ try {
+ const assetPath = getAssetRootPath();
+ return pathToFileURL(`${assetPath}${path.sep}`).toString();
+ } catch (err) {
+ console.error("Failed to resolve asset base path:", err);
+ return null;
+ }
+ });
+
+ ipcMain.handle("list-asset-directory", async (_, relativeDir: string) => {
+ try {
+ const normalizedRelativeDir = String(relativeDir ?? "")
+ .replace(/\\/g, "/")
+ .replace(/^\/+/, "");
+
+ const assetRootPath = path.resolve(getAssetRootPath());
+ const targetDirPath = path.resolve(assetRootPath, normalizedRelativeDir);
+ if (
+ targetDirPath !== assetRootPath &&
+ !targetDirPath.startsWith(`${assetRootPath}${path.sep}`)
+ ) {
+ return { success: false, error: "Invalid asset directory" };
+ }
+
+ const entries = await fs.readdir(targetDirPath, { withFileTypes: true });
+ const files = entries
+ .filter((entry) => entry.isFile())
+ .map((entry) => entry.name)
+ .sort(new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }).compare);
+
+ return { success: true, files };
+ } catch (error) {
+ console.error("Failed to list asset directory:", error);
+ return { success: false, error: String(error) };
+ }
+ });
+
+ ipcMain.handle("read-local-file", async (_, filePath: string) => {
+ try {
+ // Intentionally more permissive than the media-server allowlist: this IPC
+ // is used for direct renderer-side local file reads after the app has
+ // already accepted a path, while URL-based media serving must stay scoped
+ // to approved/app-managed locations. We still canonicalize the path and
+ // require a real on-disk file so this cannot be used to read directories.
+ const resolved = await resolveReadableLocalFilePath(filePath);
+
+ const data = await fs.readFile(resolved);
+ return { success: true, data };
+ } catch (error) {
+ console.error("Failed to read local file:", error);
+ return { success: false, error: String(error) };
+ }
+ });
+}
diff --git a/electron/ipc/register/captions.ts b/electron/ipc/register/captions.ts
new file mode 100644
index 000000000..7dfe5d671
--- /dev/null
+++ b/electron/ipc/register/captions.ts
@@ -0,0 +1,256 @@
+import path from "node:path";
+import { dialog, ipcMain } from "electron";
+import { generateAutoCaptionsFromVideo } from "../captions/generate";
+import {
+ deleteWhisperSmallModel,
+ downloadWhisperSmallModel,
+ getWhisperSmallModelStatus,
+ sendWhisperModelDownloadProgress,
+} from "../captions/whisper";
+import { LEGACY_PROJECT_FILE_EXTENSIONS, PROJECT_FILE_EXTENSION } from "../constants";
+import { hasProjectFileExtension, loadProjectFromPath } from "../project/manager";
+import { setCurrentProjectPath } from "../state";
+import { approveUserPath, getRecordingsDir } from "../utils";
+
+const VIDEO_FILE_EXTENSIONS = ["webm", "mp4", "mov", "avi", "mkv"];
+const PROJECT_FILE_EXTENSIONS = [PROJECT_FILE_EXTENSION, ...LEGACY_PROJECT_FILE_EXTENSIONS];
+
+type OpenVideoFilePickerOptions = {
+ includeProjects?: boolean;
+};
+
+export function registerCaptionHandlers() {
+ ipcMain.handle("open-video-file-picker", async (_, options?: OpenVideoFilePickerOptions) => {
+ try {
+ const includeProjects = Boolean(options?.includeProjects);
+ const recordingsDir = await getRecordingsDir();
+ const result = await dialog.showOpenDialog({
+ title: includeProjects ? "Import Media or Recordly Project" : "Select Video File",
+ defaultPath: recordingsDir,
+ filters: [
+ ...(includeProjects
+ ? [
+ {
+ name: "Media or Recordly Projects",
+ extensions: [
+ ...VIDEO_FILE_EXTENSIONS,
+ ...PROJECT_FILE_EXTENSIONS,
+ ],
+ },
+ ]
+ : []),
+ { name: "Video Files", extensions: VIDEO_FILE_EXTENSIONS },
+ ...(includeProjects
+ ? [{ name: "Recordly Projects", extensions: PROJECT_FILE_EXTENSIONS }]
+ : []),
+ { name: "All Files", extensions: ["*"] },
+ ],
+ properties: ["openFile"],
+ });
+
+ if (result.canceled || result.filePaths.length === 0) {
+ return { success: false, canceled: true };
+ }
+
+ const selectedPath = result.filePaths[0];
+
+ if (includeProjects && hasProjectFileExtension(selectedPath)) {
+ const projectResult = await loadProjectFromPath(selectedPath);
+ return projectResult.success
+ ? { ...projectResult, kind: "project" }
+ : projectResult;
+ }
+
+ approveUserPath(selectedPath);
+ setCurrentProjectPath(null);
+ return {
+ success: true,
+ kind: "media",
+ path: selectedPath,
+ extension: path.extname(selectedPath).replace(/^\./, "").toLowerCase(),
+ };
+ } catch (error) {
+ console.error("Failed to open file picker:", error);
+ return {
+ success: false,
+ message: "Failed to open file picker",
+ error: String(error),
+ };
+ }
+ });
+
+ ipcMain.handle("open-audio-file-picker", async () => {
+ try {
+ const result = await dialog.showOpenDialog({
+ title: "Select Audio File",
+ filters: [
+ {
+ name: "Audio Files",
+ extensions: ["mp3", "wav", "aac", "m4a", "flac", "ogg"],
+ },
+ { name: "All Files", extensions: ["*"] },
+ ],
+ properties: ["openFile"],
+ });
+
+ if (result.canceled || result.filePaths.length === 0) {
+ return { success: false, canceled: true };
+ }
+
+ approveUserPath(result.filePaths[0]);
+ return {
+ success: true,
+ path: result.filePaths[0],
+ };
+ } catch (error) {
+ console.error("Failed to open audio file picker:", error);
+ return {
+ success: false,
+ message: "Failed to open audio file picker",
+ error: String(error),
+ };
+ }
+ });
+
+ ipcMain.handle("open-whisper-executable-picker", async () => {
+ try {
+ const result = await dialog.showOpenDialog({
+ title: "Select Whisper Executable",
+ filters: [
+ {
+ name: "Executables",
+ extensions: process.platform === "win32" ? ["exe", "cmd", "bat"] : ["*"],
+ },
+ { name: "All Files", extensions: ["*"] },
+ ],
+ properties: ["openFile"],
+ });
+
+ if (result.canceled || result.filePaths.length === 0) {
+ return { success: false, canceled: true };
+ }
+
+ approveUserPath(result.filePaths[0]);
+ return { success: true, path: result.filePaths[0] };
+ } catch (error) {
+ console.error("Failed to open Whisper executable picker:", error);
+ return { success: false, error: String(error) };
+ }
+ });
+
+ ipcMain.handle("open-whisper-model-picker", async () => {
+ try {
+ const result = await dialog.showOpenDialog({
+ title: "Select Whisper Model",
+ filters: [
+ { name: "Whisper Models", extensions: ["bin"] },
+ { name: "All Files", extensions: ["*"] },
+ ],
+ properties: ["openFile"],
+ });
+
+ if (result.canceled || result.filePaths.length === 0) {
+ return { success: false, canceled: true };
+ }
+
+ approveUserPath(result.filePaths[0]);
+ return { success: true, path: result.filePaths[0] };
+ } catch (error) {
+ console.error("Failed to open Whisper model picker:", error);
+ return { success: false, error: String(error) };
+ }
+ });
+
+ ipcMain.handle("get-whisper-small-model-status", async () => {
+ try {
+ return await getWhisperSmallModelStatus();
+ } catch (error) {
+ return { success: false, exists: false, path: null, error: String(error) };
+ }
+ });
+
+ ipcMain.handle("download-whisper-small-model", async (event) => {
+ try {
+ const existing = await getWhisperSmallModelStatus();
+ if (existing.exists) {
+ sendWhisperModelDownloadProgress(event.sender, {
+ status: "downloaded",
+ progress: 100,
+ path: existing.path,
+ });
+ return { success: true, path: existing.path, alreadyDownloaded: true };
+ }
+
+ const modelPath = await downloadWhisperSmallModel(event.sender);
+ return { success: true, path: modelPath };
+ } catch (error) {
+ console.error("Failed to download Whisper small model:", error);
+ return { success: false, error: String(error) };
+ }
+ });
+
+ ipcMain.handle("delete-whisper-small-model", async (event) => {
+ try {
+ await deleteWhisperSmallModel();
+ sendWhisperModelDownloadProgress(event.sender, {
+ status: "idle",
+ progress: 0,
+ path: null,
+ });
+ return { success: true };
+ } catch (error) {
+ console.error("Failed to delete Whisper small model:", error);
+ // Verify whether the file was actually removed despite the error
+ const status = await getWhisperSmallModelStatus();
+ if (!status.exists) {
+ // File is gone — treat as success
+ sendWhisperModelDownloadProgress(event.sender, {
+ status: "idle",
+ progress: 0,
+ path: null,
+ });
+ return { success: true };
+ }
+ sendWhisperModelDownloadProgress(event.sender, {
+ status: "error",
+ progress: 0,
+ path: null,
+ error: String(error),
+ });
+ return { success: false, error: String(error) };
+ }
+ });
+
+ ipcMain.handle(
+ "generate-auto-captions",
+ async (
+ _,
+ options: {
+ videoPath: string;
+ whisperExecutablePath: string;
+ whisperModelPath: string;
+ language?: string;
+ },
+ ) => {
+ try {
+ const result = await generateAutoCaptionsFromVideo(options);
+ return {
+ success: true,
+ cues: result.cues,
+ message:
+ result.audioSourceLabel === "recording"
+ ? `Generated ${result.cues.length} caption cues.`
+ : `Generated ${result.cues.length} caption cues from the ${result.audioSourceLabel}.`,
+ };
+ } catch (error) {
+ console.error("Failed to generate auto captions:", error);
+ return {
+ success: false,
+ error: String(error),
+ message:
+ error instanceof Error ? error.message : "Failed to generate auto captions",
+ };
+ }
+ },
+ );
+}
diff --git a/electron/ipc/register/cloudShare.test.ts b/electron/ipc/register/cloudShare.test.ts
new file mode 100644
index 000000000..bfaa11983
--- /dev/null
+++ b/electron/ipc/register/cloudShare.test.ts
@@ -0,0 +1,92 @@
+import { describe, expect, it } from "vitest";
+import { normalizeCloudEndpoint, parseCloudShareTicket } from "../cloudShareContract";
+
+describe("cloud share contract", () => {
+ it("accepts only Recordly production and local development endpoints", () => {
+ expect(normalizeCloudEndpoint("https://videos.recordly.dev/api/upload#ignored")).toBe(
+ "https://videos.recordly.dev/api/upload",
+ );
+ expect(normalizeCloudEndpoint("http://localhost:8787/api/upload")).toBe(
+ "http://localhost:8787/api/upload",
+ );
+ });
+
+ it("normalizes a Voom upload ticket and derives its metadata endpoint", () => {
+ expect(
+ parseCloudShareTicket({
+ shareCode: "abc123",
+ uploadURL: "http://localhost:8787/api/upload-data/abc123",
+ shareURL: "http://localhost:8787/s/abc123",
+ }),
+ ).toEqual({
+ uploadUrl: "http://localhost:8787/api/upload-data/abc123",
+ shareUrl: "http://localhost:8787/s/abc123",
+ shareCode: "abc123",
+ method: "PUT",
+ headers: {},
+ finalizeUrl: "http://localhost:8787/api/metadata/abc123",
+ });
+ });
+
+ it("rejects insecure and untrusted remote endpoints", () => {
+ expect(() => normalizeCloudEndpoint("http://share.example.com/api/shares")).toThrow(
+ "only allowed through the Recordly service",
+ );
+ expect(() => normalizeCloudEndpoint("https://share.example.com/api/upload")).toThrow(
+ "only allowed through the Recordly service",
+ );
+ expect(() => normalizeCloudEndpoint("https://videos.recordly.dev/api/other")).toThrow(
+ "endpoint is invalid",
+ );
+ });
+
+ it("normalizes a presigned PUT ticket", () => {
+ expect(
+ parseCloudShareTicket({
+ uploadUrl: "https://storage.example.com/upload",
+ shareUrl: "https://share.example.com/s/123",
+ headers: { "x-test": "value" },
+ }),
+ ).toEqual({
+ uploadUrl: "https://storage.example.com/upload",
+ shareUrl: "https://share.example.com/s/123",
+ shareCode: undefined,
+ method: "PUT",
+ headers: { "x-test": "value" },
+ finalizeUrl: undefined,
+ });
+ });
+
+ it("rejects unsupported methods and malformed headers", () => {
+ expect(() =>
+ parseCloudShareTicket({
+ uploadUrl: "https://storage.example.com/upload",
+ shareUrl: "https://share.example.com/s/123",
+ method: "POST",
+ }),
+ ).toThrow("presigned PUT");
+ expect(() =>
+ parseCloudShareTicket({
+ uploadUrl: "https://storage.example.com/upload",
+ shareUrl: "https://share.example.com/s/123",
+ headers: { invalid: 42 },
+ }),
+ ).toThrow("must be strings");
+ expect(() =>
+ parseCloudShareTicket({
+ uploadUrl: "https://storage.example.com/upload",
+ shareUrl: "https://share.example.com/s/123",
+ headers: { authorization: "Bearer exfiltrate-me" },
+ }),
+ ).toThrow("not allowed");
+ });
+
+ it("rejects an insecure remote upload URL", () => {
+ expect(() =>
+ parseCloudShareTicket({
+ uploadUrl: "http://storage.example.com/upload",
+ shareUrl: "https://share.example.com/s/123",
+ }),
+ ).toThrow("uploadUrl must use HTTPS");
+ });
+});
diff --git a/electron/ipc/register/cloudShare.ts b/electron/ipc/register/cloudShare.ts
new file mode 100644
index 000000000..a46afcbf1
--- /dev/null
+++ b/electron/ipc/register/cloudShare.ts
@@ -0,0 +1,440 @@
+import { createReadStream } from "node:fs";
+import fs from "node:fs/promises";
+import path from "node:path";
+import { Transform } from "node:stream";
+import { ipcMain } from "electron";
+import { normalizeCloudEndpoint, parseCloudShareTicket } from "../cloudShareContract";
+import { isOwnedExportPath } from "../export/exportStream";
+import { isAllowedLocalReadPath } from "../project/manager";
+
+const MAX_CONTROL_RESPONSE_BYTES = 1024 * 1024;
+const MAX_AUTH_TOKEN_BYTES = 8192;
+const MULTIPART_THRESHOLD_BYTES = 50 * 1024 * 1024;
+const MULTIPART_PART_BYTES = 25 * 1024 * 1024;
+const MULTIPART_PART_ATTEMPTS = 3;
+const activeUploads = new Map();
+
+type UploadedPart = { partNumber: number; etag: string };
+
+class NonRetryableUploadError extends Error {}
+
+function contentTypeFor(filePath: string) {
+ switch (path.extname(filePath).toLowerCase()) {
+ case ".mp4":
+ return "video/mp4";
+ case ".webm":
+ return "video/webm";
+ case ".mov":
+ return "video/quicktime";
+ case ".gif":
+ return "image/gif";
+ default:
+ return "application/octet-stream";
+ }
+}
+
+async function readJsonResponse(response: Response) {
+ const text = await response.text();
+ if (Buffer.byteLength(text) > MAX_CONTROL_RESPONSE_BYTES) {
+ throw new Error("The cloud share server response was too large.");
+ }
+ try {
+ return JSON.parse(text) as unknown;
+ } catch {
+ throw new Error(`The cloud share server returned invalid JSON (${response.status}).`);
+ }
+}
+
+async function responseError(response: Response, fallback: string) {
+ try {
+ const body = await readJsonResponse(response);
+ if (body && typeof body === "object" && "error" in body) {
+ return String((body as { error: unknown }).error);
+ }
+ } catch {
+ // Cloudflare may return an HTML/plain-text edge error instead of Worker JSON.
+ }
+ return `${fallback} (${response.status}).`;
+}
+
+function waitForRetry(delayMs: number, signal: AbortSignal) {
+ return new Promise((resolve, reject) => {
+ if (signal.aborted) {
+ reject(signal.reason ?? new Error("Upload aborted"));
+ return;
+ }
+ const handleAbort = () => {
+ clearTimeout(timeout);
+ reject(signal.reason ?? new Error("Upload aborted"));
+ };
+ const timeout = setTimeout(() => {
+ signal.removeEventListener("abort", handleAbort);
+ resolve();
+ }, delayMs);
+ signal.addEventListener("abort", handleAbort, { once: true });
+ });
+}
+
+function isRetryableUploadStatus(status: number) {
+ return status === 408 || status === 425 || status === 429 || status >= 500;
+}
+
+async function uploadMultipart(options: {
+ endpoint: string;
+ shareCode: string;
+ filePath: string;
+ fileSize: number;
+ contentType: string;
+ token: string;
+ signal: AbortSignal;
+ onProgress: (uploadedBytes: number) => void;
+}) {
+ const endpointOrigin = new URL(options.endpoint).origin;
+ const authHeaders = { authorization: `Bearer ${options.token}` };
+ const startResponse = await fetch(
+ new URL(`/api/upload-multipart/${options.shareCode}`, endpointOrigin),
+ {
+ method: "POST",
+ headers: authHeaders,
+ redirect: "error",
+ signal: options.signal,
+ },
+ );
+ if (!startResponse.ok) {
+ throw new Error(await responseError(startResponse, "Could not start video upload"));
+ }
+ const startBody = await readJsonResponse(startResponse);
+ const multipartUploadId =
+ startBody &&
+ typeof startBody === "object" &&
+ "uploadId" in startBody &&
+ typeof (startBody as { uploadId: unknown }).uploadId === "string"
+ ? (startBody as { uploadId: string }).uploadId
+ : "";
+ if (!multipartUploadId || Buffer.byteLength(multipartUploadId) > 2048) {
+ throw new Error("The cloud share server returned an invalid multipart upload ID.");
+ }
+
+ const encodedUploadId = encodeURIComponent(multipartUploadId);
+ const parts: UploadedPart[] = [];
+ let confirmedBytes = 0;
+ let completed = false;
+
+ try {
+ const partCount = Math.ceil(options.fileSize / MULTIPART_PART_BYTES);
+ for (let partIndex = 0; partIndex < partCount; partIndex += 1) {
+ const partNumber = partIndex + 1;
+ const start = partIndex * MULTIPART_PART_BYTES;
+ const end = Math.min(options.fileSize, start + MULTIPART_PART_BYTES) - 1;
+ const partSize = end - start + 1;
+ let uploadedPart: UploadedPart | undefined;
+
+ for (let attempt = 1; attempt <= MULTIPART_PART_ATTEMPTS; attempt += 1) {
+ let attemptBytes = 0;
+ const progress = new Transform({
+ transform(chunk, _encoding, callback) {
+ attemptBytes += chunk.length;
+ options.onProgress(
+ Math.min(options.fileSize, confirmedBytes + attemptBytes),
+ );
+ callback(null, chunk);
+ },
+ });
+ const source = createReadStream(options.filePath, { start, end });
+ const body = source.pipe(progress);
+ source.on("error", (error) => body.destroy(error));
+ try {
+ const partResponse = await fetch(
+ new URL(
+ `/api/upload-part/${options.shareCode}/${encodedUploadId}/${partNumber}`,
+ endpointOrigin,
+ ),
+ {
+ method: "PUT",
+ headers: {
+ ...authHeaders,
+ "content-type": options.contentType,
+ "content-length": String(partSize),
+ },
+ body: body as unknown as BodyInit,
+ duplex: "half",
+ redirect: "error",
+ signal: options.signal,
+ } as RequestInit & { duplex: "half" },
+ );
+ if (!partResponse.ok) {
+ const message = await responseError(
+ partResponse,
+ `Video part ${partNumber} failed`,
+ );
+ if (!isRetryableUploadStatus(partResponse.status)) {
+ throw new NonRetryableUploadError(message);
+ }
+ throw new Error(message);
+ } else {
+ const partBody = await readJsonResponse(partResponse);
+ const etag =
+ partBody && typeof partBody === "object" && "etag" in partBody
+ ? (partBody as { etag: unknown }).etag
+ : undefined;
+ const returnedPartNumber =
+ partBody && typeof partBody === "object" && "partNumber" in partBody
+ ? (partBody as { partNumber: unknown }).partNumber
+ : undefined;
+ if (typeof etag !== "string" || returnedPartNumber !== partNumber) {
+ throw new NonRetryableUploadError(
+ `The cloud share server returned invalid data for video part ${partNumber}.`,
+ );
+ }
+ uploadedPart = { partNumber, etag };
+ break;
+ }
+ } catch (error) {
+ if (
+ options.signal.aborted ||
+ error instanceof NonRetryableUploadError ||
+ attempt === MULTIPART_PART_ATTEMPTS
+ ) {
+ throw error;
+ }
+ } finally {
+ body.destroy();
+ source.destroy();
+ }
+
+ options.onProgress(confirmedBytes);
+ await waitForRetry(300 * 3 ** (attempt - 1), options.signal);
+ }
+
+ if (!uploadedPart) throw new Error(`Video part ${partNumber} could not be uploaded.`);
+ parts.push(uploadedPart);
+ confirmedBytes += partSize;
+ options.onProgress(confirmedBytes);
+ }
+
+ const completeResponse = await fetch(
+ new URL(`/api/upload-complete/${options.shareCode}/${encodedUploadId}`, endpointOrigin),
+ {
+ method: "POST",
+ headers: { ...authHeaders, "content-type": "application/json" },
+ body: JSON.stringify({ parts }),
+ redirect: "error",
+ signal: options.signal,
+ },
+ );
+ if (!completeResponse.ok) {
+ throw new Error(await responseError(completeResponse, "Could not finish video upload"));
+ }
+ completed = true;
+ } finally {
+ if (!completed) {
+ try {
+ await fetch(
+ new URL(
+ `/api/upload-abort/${options.shareCode}/${encodedUploadId}`,
+ endpointOrigin,
+ ),
+ {
+ method: "POST",
+ headers: authHeaders,
+ redirect: "error",
+ signal: AbortSignal.timeout(5000),
+ },
+ );
+ } catch {
+ // R2 also reaps abandoned multipart uploads, so cleanup is best-effort.
+ }
+ }
+ }
+}
+
+export function registerCloudShareHandlers() {
+ ipcMain.handle(
+ "cloud-share-upload",
+ async (
+ event,
+ input: {
+ filePath?: unknown;
+ endpoint?: unknown;
+ token?: unknown;
+ title?: unknown;
+ notes?: unknown;
+ uploadId?: unknown;
+ },
+ ) => {
+ let uploadId: string | undefined;
+ try {
+ const endpoint = normalizeCloudEndpoint(input?.endpoint);
+ if (typeof input?.filePath !== "string" || input.filePath.trim().length === 0) {
+ throw new Error("Export a video before sharing it.");
+ }
+ uploadId =
+ typeof input.uploadId === "string" && /^[a-f0-9-]{36}$/i.test(input.uploadId)
+ ? input.uploadId
+ : crypto.randomUUID();
+ const controller = new AbortController();
+ activeUploads.set(uploadId, controller);
+ const requestedPath = path.resolve(input.filePath);
+ const resolvedPath = await fs.realpath(requestedPath);
+ const isRecordlyExport =
+ isOwnedExportPath(requestedPath) || isOwnedExportPath(resolvedPath);
+ if (!isRecordlyExport && !isAllowedLocalReadPath(resolvedPath)) {
+ throw new Error("This file is outside Recordly's approved media locations.");
+ }
+ const stat = await fs.stat(resolvedPath);
+ if (!stat.isFile()) throw new Error("The exported video could not be found.");
+ const title =
+ (typeof input.title === "string" ? input.title.trim().slice(0, 200) : "") ||
+ path.basename(resolvedPath, path.extname(resolvedPath));
+ const notes =
+ typeof input.notes === "string" ? input.notes.trim().slice(0, 2000) : "";
+
+ const token = typeof input.token === "string" ? input.token.trim() : "";
+ if (!token || Buffer.byteLength(token) > MAX_AUTH_TOKEN_BYTES) {
+ throw new Error("Sign in to Recordly before creating a shared link.");
+ }
+ const controlHeaders: Record = {
+ "content-type": "application/json",
+ };
+ if (token) controlHeaders.authorization = `Bearer ${token}`;
+ const ticketResponse = await fetch(endpoint, {
+ method: "POST",
+ headers: controlHeaders,
+ body: JSON.stringify({
+ name: path.basename(resolvedPath),
+ title,
+ fileSize: stat.size,
+ notes,
+ size: stat.size,
+ contentType: contentTypeFor(resolvedPath),
+ }),
+ redirect: "error",
+ signal: controller.signal,
+ });
+ const ticketBody = await readJsonResponse(ticketResponse);
+ if (!ticketResponse.ok) {
+ const message =
+ ticketBody && typeof ticketBody === "object" && "error" in ticketBody
+ ? String((ticketBody as { error: unknown }).error)
+ : `Cloud share request failed (${ticketResponse.status}).`;
+ throw new Error(message);
+ }
+ const ticket = parseCloudShareTicket(ticketBody);
+ const endpointOrigin = new URL(endpoint).origin;
+ if (new URL(ticket.shareUrl).origin !== endpointOrigin) {
+ throw new Error("The share service returned an untrusted viewing URL.");
+ }
+ if (ticket.finalizeUrl && new URL(ticket.finalizeUrl).origin !== endpointOrigin) {
+ throw new Error("The share service returned an untrusted finalization URL.");
+ }
+ const contentType = contentTypeFor(resolvedPath);
+ const sendProgress = (uploadedBytes: number) => {
+ if (event.sender.isDestroyed()) return;
+ event.sender.send("cloud-share-progress", {
+ uploadId,
+ uploadedBytes,
+ totalBytes: stat.size,
+ });
+ };
+ const canUseMultipart =
+ stat.size > MULTIPART_THRESHOLD_BYTES &&
+ ticket.shareCode !== undefined &&
+ new URL(ticket.uploadUrl).origin === endpointOrigin;
+
+ if (canUseMultipart) {
+ await uploadMultipart({
+ endpoint,
+ shareCode: ticket.shareCode as string,
+ filePath: resolvedPath,
+ fileSize: stat.size,
+ contentType,
+ token,
+ signal: controller.signal,
+ onProgress: sendProgress,
+ });
+ } else {
+ let uploaded = 0;
+ const progress = new Transform({
+ transform(chunk, _encoding, callback) {
+ uploaded += chunk.length;
+ sendProgress(uploaded);
+ callback(null, chunk);
+ },
+ });
+ const source = createReadStream(resolvedPath);
+ const body = source.pipe(progress);
+ source.on("error", (error) => body.destroy(error));
+ const uploadHeaders: Record = {
+ "content-type": contentType,
+ "content-length": String(stat.size),
+ ...ticket.headers,
+ };
+ if (new URL(ticket.uploadUrl).origin === endpointOrigin) {
+ uploadHeaders.authorization = `Bearer ${token}`;
+ }
+ try {
+ const uploadResponse = await fetch(ticket.uploadUrl, {
+ method: "PUT",
+ headers: uploadHeaders,
+ body: body as unknown as BodyInit,
+ duplex: "half",
+ redirect: "error",
+ signal: controller.signal,
+ } as RequestInit & { duplex: "half" });
+ if (!uploadResponse.ok) {
+ throw new Error(
+ await responseError(uploadResponse, "Video upload failed"),
+ );
+ }
+ } finally {
+ body.destroy();
+ source.destroy();
+ }
+ }
+
+ if (ticket.finalizeUrl) {
+ const finalizeHeaders: Record = {
+ "content-type": "application/json",
+ };
+ if (token && new URL(ticket.finalizeUrl).origin === new URL(endpoint).origin) {
+ finalizeHeaders.authorization = `Bearer ${token}`;
+ }
+ const finalizeResponse = await fetch(ticket.finalizeUrl, {
+ method: "POST",
+ headers: finalizeHeaders,
+ body: JSON.stringify({
+ title,
+ summary: notes,
+ }),
+ redirect: "error",
+ signal: controller.signal,
+ });
+ if (!finalizeResponse.ok) {
+ throw new Error(
+ `Cloud share finalization failed (${finalizeResponse.status}).`,
+ );
+ }
+ }
+ return { success: true, uploadId, shareUrl: ticket.shareUrl };
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error);
+ return {
+ success: false,
+ uploadId,
+ canceled: message.includes("aborted"),
+ error: message,
+ };
+ } finally {
+ if (uploadId) activeUploads.delete(uploadId);
+ }
+ },
+ );
+
+ ipcMain.handle("cloud-share-cancel", (_event, uploadId: unknown) => {
+ if (typeof uploadId !== "string") return { success: false };
+ const controller = activeUploads.get(uploadId);
+ if (!controller) return { success: false };
+ controller.abort();
+ return { success: true };
+ });
+}
diff --git a/electron/ipc/register/export.test.ts b/electron/ipc/register/export.test.ts
new file mode 100644
index 000000000..df1e9d857
--- /dev/null
+++ b/electron/ipc/register/export.test.ts
@@ -0,0 +1,86 @@
+import fs from "node:fs/promises";
+import os from "node:os";
+import path from "node:path";
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+vi.mock("electron", () => ({
+ app: {
+ getAppPath: () => process.cwd(),
+ getPath: () => process.env.TEMP ?? process.cwd(),
+ isPackaged: false,
+ },
+ BrowserWindow: {
+ fromWebContents: () => null,
+ },
+ dialog: {
+ showSaveDialog: vi.fn(),
+ },
+ ipcMain: {
+ handle: vi.fn(),
+ },
+ powerSaveBlocker: {
+ isStarted: () => true,
+ start: () => 1,
+ stop: vi.fn(),
+ },
+}));
+
+vi.mock("../ffmpeg/binary", () => ({
+ getFfmpegBinaryPath: () => "ffmpeg",
+}));
+
+import { moveExportedTempFile } from "./export";
+
+const tempDirs: string[] = [];
+
+async function makeTempDir() {
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), "recordly-export-move-"));
+ tempDirs.push(dir);
+ return dir;
+}
+
+afterEach(async () => {
+ vi.restoreAllMocks();
+ await Promise.allSettled(
+ tempDirs.splice(0).map((dir) => fs.rm(dir, { force: true, recursive: true })),
+ );
+});
+
+describe("moveExportedTempFile", () => {
+ it("moves an app-managed export temp file to the selected destination", async () => {
+ const dir = await makeTempDir();
+ const tempPath = path.join(dir, "export-temp.mp4");
+ const destinationPath = path.join(dir, "export-final.mp4");
+ await fs.writeFile(tempPath, "recordly-export");
+
+ await moveExportedTempFile(tempPath, destinationPath);
+
+ await expect(fs.readFile(destinationPath, "utf8")).resolves.toBe("recordly-export");
+ await expect(fs.access(tempPath)).rejects.toThrow();
+ });
+
+ it("falls back when Windows reports the destination already exists during initial rename", async () => {
+ const dir = await makeTempDir();
+ const tempPath = path.join(dir, "export-temp.mp4");
+ const destinationPath = path.join(dir, "export-final.mp4");
+ await fs.writeFile(tempPath, "new-export");
+ await fs.writeFile(destinationPath, "previous-export");
+
+ const originalRename = fs.rename.bind(fs);
+ const renameSpy = vi.spyOn(fs, "rename");
+ renameSpy.mockImplementation(async (from, to) => {
+ if (from === tempPath && to === destinationPath) {
+ const error = new Error("destination exists") as NodeJS.ErrnoException;
+ error.code = "EEXIST";
+ throw error;
+ }
+
+ return originalRename(from, to);
+ });
+
+ await moveExportedTempFile(tempPath, destinationPath);
+
+ await expect(fs.readFile(destinationPath, "utf8")).resolves.toBe("new-export");
+ await expect(fs.access(tempPath)).rejects.toThrow();
+ });
+});
diff --git a/electron/ipc/register/export.ts b/electron/ipc/register/export.ts
new file mode 100644
index 000000000..82b523de7
--- /dev/null
+++ b/electron/ipc/register/export.ts
@@ -0,0 +1,1093 @@
+import type { ChildProcessByStdio } from "node:child_process";
+import { spawn } from "node:child_process";
+import fs from "node:fs/promises";
+import path from "node:path";
+import type { Readable, Writable } from "node:stream";
+import type { SaveDialogOptions } from "electron";
+import { app, BrowserWindow, dialog, ipcMain } from "electron";
+import {
+ closeExportStream,
+ isOwnedExportPath,
+ openExportStream,
+ registerOwnedExportPath,
+ releaseOwnedExportPath,
+ writeToExportStream,
+} from "../export/exportStream";
+import {
+ enqueueNativeVideoExportFrameWrite,
+ enqueueNativeVideoExportFrameWrites,
+ exportNativeStaticLayoutVideo,
+ flushNativeVideoExportPendingWriteRequests,
+ getExportHardwareInfo,
+ getNativeExportCapabilities,
+ getNativeVideoExportMaxQueuedWriteBytes,
+ getNativeVideoExportSessionError,
+ isHardwareAcceleratedVideoEncoder,
+ isIgnorableNativeVideoExportStreamError,
+ muxExportedVideoAudioBuffer,
+ muxNativeVideoExportAudio,
+ type NativeStaticLayoutExportOptions,
+ type NativeVideoExportSession,
+ nativeStaticLayoutExportSessions,
+ nativeVideoExportSessions,
+ probeNativeVideoMetadata,
+ removeTemporaryExportFile,
+ resolveNativeVideoEncoder,
+ sendNativeVideoExportWriteFrameResult,
+ settleNativeVideoExportWriteFrameRequest,
+} from "../export/native-video";
+import { getFfmpegBinaryPath } from "../ffmpeg/binary";
+import {
+ buildNativeH264StreamExportArgs,
+ buildNativeVideoExportArgs,
+ getNativeVideoInputByteSize,
+ type NativeExportEncodingMode,
+ type NativeVideoExportFinishOptions,
+} from "../nativeVideoExport";
+import { isAllowedLocalReadPath, resolveApprovedLocalMediaPath } from "../project/manager";
+import { approveUserPath } from "../utils";
+import {
+ type CaptionSidecarPayload,
+ parseCaptionSidecarPayload,
+ withCaptionSidecarMessage,
+ writeCaptionSidecarsBestEffort,
+} from "./exportCaptionSidecars";
+
+function getPartialExportDestinationPath(destinationPath: string) {
+ const parsed = path.parse(destinationPath);
+ const suffix = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
+ return path.join(parsed.dir, `.recordly-partial-${parsed.name}-${suffix}${parsed.ext}`);
+}
+
+const MAX_IN_MEMORY_EXPORT_BYTES = 0x7fffffff;
+
+function getInMemoryExportTooLargeMessage(byteLength: number) {
+ if (byteLength <= MAX_IN_MEMORY_EXPORT_BYTES) {
+ return null;
+ }
+
+ return "Export is too large for the legacy in-memory save path. Please retry with temp-file streaming enabled.";
+}
+
+export async function moveExportedTempFile(tempPath: string, destinationPath: string) {
+ await fs.mkdir(path.dirname(destinationPath), { recursive: true });
+ try {
+ await fs.rename(tempPath, destinationPath);
+ return;
+ } catch (error) {
+ const code = (error as NodeJS.ErrnoException).code;
+ if (code !== "EXDEV" && code !== "EPERM" && code !== "ENOTEMPTY" && code !== "EEXIST") {
+ throw error;
+ }
+ // Cross-device or Windows permission quirks — fall back to copy + unlink so
+ // exporting to a different volume still works.
+ }
+
+ const partialDestinationPath = getPartialExportDestinationPath(destinationPath);
+ try {
+ await fs.copyFile(tempPath, partialDestinationPath);
+ try {
+ await fs.rename(partialDestinationPath, destinationPath);
+ } catch (renameError) {
+ const code = (renameError as NodeJS.ErrnoException).code;
+ if (code !== "EEXIST" && code !== "EPERM") {
+ throw renameError;
+ }
+
+ const backupDestinationPath = getPartialExportDestinationPath(destinationPath);
+ let movedExistingDestination = false;
+ try {
+ await fs.rename(destinationPath, backupDestinationPath);
+ movedExistingDestination = true;
+ } catch (backupError) {
+ if ((backupError as NodeJS.ErrnoException).code !== "ENOENT") {
+ throw backupError;
+ }
+ }
+
+ try {
+ await fs.rename(partialDestinationPath, destinationPath);
+ } catch (replaceError) {
+ if (movedExistingDestination) {
+ await fs.rename(backupDestinationPath, destinationPath).catch(() => undefined);
+ }
+ throw replaceError;
+ }
+
+ if (movedExistingDestination) {
+ await fs.rm(backupDestinationPath, { force: true }).catch((cleanupError) => {
+ console.warn(
+ `[export] Failed to remove backup file after replace (${backupDestinationPath}):`,
+ cleanupError,
+ );
+ });
+ }
+ }
+ try {
+ await fs.rm(tempPath, { force: true });
+ } catch (unlinkError) {
+ // Copy succeeded, so the export itself is safe; surface the leaked temp
+ // path instead of silently swallowing the failure so operators can
+ // reclaim disk space manually if the OS temp reaper misses it.
+ console.warn(
+ `[export] Failed to remove temp file after cross-volume copy (${tempPath}):`,
+ unlinkError,
+ );
+ }
+ } catch (error) {
+ await fs.rm(partialDestinationPath, { force: true }).catch(() => undefined);
+ throw error;
+ }
+}
+
+async function resolveAllowedReadableFilePath(
+ filePath: string,
+ label: string,
+ options: { mediaOnly?: boolean } = {},
+) {
+ if (typeof filePath !== "string" || filePath.trim().length === 0) {
+ throw new Error(`${label} requires a file path`);
+ }
+
+ if (options.mediaOnly ?? true) {
+ const resolvedMediaPath = await resolveApprovedLocalMediaPath(filePath);
+ if (!resolvedMediaPath) {
+ throw new Error(`${label} is not an approved readable media file`);
+ }
+
+ return resolvedMediaPath;
+ }
+
+ const resolvedPath = path.resolve(filePath);
+ if (!isAllowedLocalReadPath(resolvedPath)) {
+ throw new Error(`${label} is not approved for local reads`);
+ }
+
+ const realPath = await fs.realpath(resolvedPath).catch(() => null);
+ if (!realPath) {
+ throw new Error(`${label} does not exist`);
+ }
+
+ const stat = await fs.stat(realPath).catch(() => null);
+ if (!stat?.isFile()) {
+ throw new Error(`${label} is not a readable file`);
+ }
+
+ if (!isAllowedLocalReadPath(realPath)) {
+ throw new Error(`${label} is not approved for local reads`);
+ }
+
+ return realPath;
+}
+
+async function sanitizeNativeStaticLayoutExportOptions(
+ options: NativeStaticLayoutExportOptions,
+): Promise {
+ const sanitized: NativeStaticLayoutExportOptions = {
+ ...options,
+ inputPath: await resolveAllowedReadableFilePath(options.inputPath, "Native input"),
+ };
+ const mutableOptions = sanitized as unknown as Record;
+
+ for (const [field, label] of [
+ ["backgroundImagePath", "Native background image"],
+ ["webcamInputPath", "Native webcam input"],
+ ["cursorAtlasPath", "Native cursor atlas"],
+ ] as const) {
+ const value = mutableOptions[field];
+ if (typeof value === "string" && value.trim().length > 0) {
+ mutableOptions[field] = await resolveAllowedReadableFilePath(value, label);
+ } else if (value === "" || value === undefined) {
+ mutableOptions[field] = null;
+ } else if (value !== null) {
+ throw new Error(`${label} must be a file path`);
+ }
+ }
+
+ for (const [field, label] of [
+ ["cursorTelemetryPath", "Native cursor telemetry"],
+ ["cursorAtlasMetadataPath", "Native cursor atlas metadata"],
+ ["zoomTelemetryPath", "Native zoom telemetry"],
+ ["timelineMapPath", "Native timeline map"],
+ ] as const) {
+ const value = mutableOptions[field];
+ if (typeof value === "string" && value.trim().length > 0) {
+ mutableOptions[field] = await resolveAllowedReadableFilePath(value, label, {
+ mediaOnly: false,
+ });
+ } else if (value === "" || value === undefined) {
+ mutableOptions[field] = null;
+ } else if (value !== null) {
+ throw new Error(`${label} must be a file path`);
+ }
+ }
+
+ const audioOptions = sanitized.audioOptions;
+ if (audioOptions?.audioSourcePath) {
+ sanitized.audioOptions = {
+ ...audioOptions,
+ audioSourcePath: await resolveAllowedReadableFilePath(
+ audioOptions.audioSourcePath,
+ "Native audio source",
+ ),
+ };
+ }
+
+ return sanitized;
+}
+
+function isTempPathSafe(tempPath: string): boolean {
+ const tempRoot = path.resolve(app.getPath("temp"));
+ const candidate = path.resolve(tempPath);
+ if (candidate === tempRoot) {
+ return false;
+ }
+ const withSep = tempRoot.endsWith(path.sep) ? tempRoot : tempRoot + path.sep;
+ return candidate.startsWith(withSep);
+}
+
+export function registerExportHandlers() {
+ ipcMain.handle(
+ "native-video-export-start",
+ async (
+ event,
+ options: {
+ width: number;
+ height: number;
+ frameRate: number;
+ bitrate: number;
+ encodingMode: NativeExportEncodingMode;
+ inputMode?: "rawvideo" | "h264-stream";
+ },
+ ) => {
+ try {
+ if (options.width % 2 !== 0 || options.height % 2 !== 0) {
+ throw new Error("Native export requires even output dimensions");
+ }
+
+ const ffmpegPath = getFfmpegBinaryPath();
+ const inputMode = options.inputMode ?? "rawvideo";
+ const sessionId = `recordly-export-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
+ const outputPath = path.join(app.getPath("temp"), `${sessionId}.mp4`);
+
+ let encoderName: string;
+ let ffmpegArgs: string[];
+
+ if (inputMode === "h264-stream") {
+ // Pre-encoded H.264 Annex B from browser VideoEncoder — just stream-copy into MP4
+ encoderName = "h264-stream-copy";
+ ffmpegArgs = buildNativeH264StreamExportArgs({
+ frameRate: options.frameRate,
+ outputPath,
+ });
+ } else {
+ encoderName = await resolveNativeVideoEncoder(ffmpegPath, options.encodingMode);
+ ffmpegArgs = buildNativeVideoExportArgs(encoderName, options, outputPath);
+ }
+
+ const ffmpegProcess = spawn(ffmpegPath, ffmpegArgs, {
+ stdio: ["pipe", "ignore", "pipe"],
+ }) as ChildProcessByStdio;
+ // For rawvideo, frames are a fixed RGBA size. For h264-stream, chunks are variable.
+ const inputByteSize =
+ inputMode === "rawvideo"
+ ? getNativeVideoInputByteSize(options.width, options.height)
+ : 0;
+
+ const session: NativeVideoExportSession = {
+ ffmpegProcess,
+ outputPath,
+ inputByteSize,
+ inputMode,
+ maxQueuedWriteBytes:
+ inputMode === "h264-stream"
+ ? 32 * 1024 * 1024
+ : getNativeVideoExportMaxQueuedWriteBytes(inputByteSize),
+ stderrOutput: "",
+ encoderName,
+ processError: null,
+ stdinError: null,
+ terminating: false,
+ writeSequence: Promise.resolve(),
+ sender: event.sender,
+ pendingWriteRequestIds: new Set(),
+ completionPromise: new Promise((resolve, reject) => {
+ ffmpegProcess.once("error", (error) => {
+ const processError =
+ error instanceof Error ? error : new Error(String(error));
+ if (session.terminating) {
+ resolve();
+ return;
+ }
+
+ session.processError = processError;
+ reject(processError);
+ });
+ ffmpegProcess.stdin.once("error", (error) => {
+ const stdinError =
+ error instanceof Error ? error : new Error(String(error));
+ if (
+ session.terminating &&
+ isIgnorableNativeVideoExportStreamError(stdinError)
+ ) {
+ return;
+ }
+
+ session.stdinError = stdinError;
+ });
+ ffmpegProcess.once("close", (code, signal) => {
+ if (session.terminating) {
+ resolve();
+ return;
+ }
+
+ if (code === 0) {
+ resolve();
+ return;
+ }
+
+ reject(
+ new Error(
+ getNativeVideoExportSessionError(
+ session,
+ `FFmpeg exited with code ${code ?? "unknown"}${signal ? ` (signal ${signal})` : ""}`,
+ ),
+ ),
+ );
+ });
+ }),
+ };
+ void session.completionPromise.catch(() => undefined);
+
+ ffmpegProcess.stderr.on("data", (chunk: Buffer) => {
+ session.stderrOutput += chunk.toString();
+ });
+
+ nativeVideoExportSessions.set(sessionId, session);
+
+ console.log(
+ `[native-export] Started ${isHardwareAcceleratedVideoEncoder(encoderName) ? "hardware" : "software"} session ${sessionId} with ${encoderName}`,
+ );
+
+ return {
+ success: true,
+ sessionId,
+ encoderName,
+ };
+ } catch (error) {
+ console.error(
+ "[native-export] Failed to start native video export session:",
+ error,
+ );
+ return {
+ success: false,
+ error: String(error),
+ };
+ }
+ },
+ );
+
+ ipcMain.handle("probe-native-video-metadata", async (_, filePath: string) => {
+ try {
+ if (typeof filePath !== "string" || filePath.trim().length === 0) {
+ throw new Error("Native metadata probe requires a file path");
+ }
+
+ const resolvedFilePath = await resolveAllowedReadableFilePath(
+ filePath,
+ "Native metadata probe",
+ );
+ const metadata = await probeNativeVideoMetadata(
+ getFfmpegBinaryPath(),
+ resolvedFilePath,
+ );
+ return {
+ success: true,
+ metadata,
+ };
+ } catch (error) {
+ console.warn("[probe-native-video-metadata] Failed:", error);
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : String(error),
+ };
+ }
+ });
+
+ ipcMain.handle("get-native-export-capabilities", async () => {
+ try {
+ return {
+ success: true,
+ capabilities: await getNativeExportCapabilities(),
+ };
+ } catch (error) {
+ console.warn("[native-export-capabilities] Failed:", error);
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : String(error),
+ };
+ }
+ });
+
+ ipcMain.handle("get-export-hardware-info", async () => {
+ try {
+ return {
+ success: true,
+ hardware: await getExportHardwareInfo(),
+ };
+ } catch (error) {
+ console.warn("[export-hardware-info] Failed:", error);
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : String(error),
+ };
+ }
+ });
+
+ ipcMain.handle(
+ "native-static-layout-export",
+ async (event, options: NativeStaticLayoutExportOptions) => {
+ try {
+ if (!options || typeof options.inputPath !== "string") {
+ throw new Error("Native static layout export requires an input path");
+ }
+ const sanitizedOptions = await sanitizeNativeStaticLayoutExportOptions(options);
+
+ const result = await exportNativeStaticLayoutVideo(
+ getFfmpegBinaryPath(),
+ sanitizedOptions,
+ (progress) => {
+ if (event.sender.isDestroyed()) {
+ return;
+ }
+
+ event.sender.send("native-static-layout-export-progress", progress);
+ },
+ );
+ registerOwnedExportPath(result.outputPath);
+ const primaryBackend = result.metrics.chunks[0]?.backend;
+ return {
+ success: true,
+ tempPath: result.outputPath,
+ encoderName:
+ primaryBackend === "nvidia-cuda-compositor"
+ ? "nvidia-cuda-compositor"
+ : primaryBackend === "windows-d3d11-compositor"
+ ? "windows-d3d11-compositor"
+ : result.metrics.chunkCount > 1
+ ? "chunked-h264-nvenc"
+ : "static-layout-h264-nvenc",
+ metrics: result.metrics,
+ };
+ } catch (error) {
+ console.warn("[native-static-layout-export] Failed:", error);
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : String(error),
+ };
+ }
+ },
+ );
+
+ ipcMain.handle("native-static-layout-export-cancel", async (_, sessionId: string) => {
+ const session = nativeStaticLayoutExportSessions.get(sessionId);
+ if (!session) {
+ return { success: true };
+ }
+
+ session.terminating = true;
+ try {
+ session.currentProcess?.kill("SIGKILL");
+ } catch {
+ // Process may already be closed.
+ }
+
+ return { success: true };
+ });
+
+ ipcMain.on(
+ "native-video-export-write-frames-async",
+ (
+ event,
+ payload: {
+ sessionId: string;
+ requestId: number;
+ frameDataList: Uint8Array[];
+ },
+ ) => {
+ const sessionId = payload?.sessionId;
+ const requestId = payload?.requestId;
+ const frameDataList = payload?.frameDataList;
+
+ if (
+ typeof sessionId !== "string" ||
+ typeof requestId !== "number" ||
+ !Array.isArray(frameDataList) ||
+ frameDataList.length === 0
+ ) {
+ return;
+ }
+
+ const session = nativeVideoExportSessions.get(sessionId);
+ if (!session) {
+ sendNativeVideoExportWriteFrameResult(event.sender, sessionId, requestId, {
+ success: false,
+ error: "Invalid native export session",
+ });
+ return;
+ }
+
+ session.sender = event.sender;
+ session.pendingWriteRequestIds.add(requestId);
+
+ if (session.terminating) {
+ settleNativeVideoExportWriteFrameRequest(sessionId, session, requestId, {
+ success: false,
+ error: "Native video export session was cancelled",
+ });
+ return;
+ }
+
+ if (
+ session.inputMode !== "h264-stream" &&
+ frameDataList.some((frameData) => frameData.byteLength !== session.inputByteSize)
+ ) {
+ settleNativeVideoExportWriteFrameRequest(sessionId, session, requestId, {
+ success: false,
+ error: "Native video export batch contained invalid frame sizes",
+ });
+ return;
+ }
+
+ void enqueueNativeVideoExportFrameWrites(session, frameDataList)
+ .then(() => {
+ settleNativeVideoExportWriteFrameRequest(sessionId, session, requestId, {
+ success: true,
+ });
+ })
+ .catch((error) => {
+ session.stdinError = error instanceof Error ? error : new Error(String(error));
+ settleNativeVideoExportWriteFrameRequest(sessionId, session, requestId, {
+ success: false,
+ error: getNativeVideoExportSessionError(
+ session,
+ session.stdinError.message,
+ ),
+ });
+ });
+ },
+ );
+
+ ipcMain.on(
+ "native-video-export-write-frame-async",
+ (
+ event,
+ payload: {
+ sessionId: string;
+ requestId: number;
+ frameData: Uint8Array;
+ },
+ ) => {
+ const sessionId = payload?.sessionId;
+ const requestId = payload?.requestId;
+ const frameData = payload?.frameData;
+
+ if (typeof sessionId !== "string" || typeof requestId !== "number" || !frameData) {
+ return;
+ }
+
+ const session = nativeVideoExportSessions.get(sessionId);
+ if (!session) {
+ sendNativeVideoExportWriteFrameResult(event.sender, sessionId, requestId, {
+ success: false,
+ error: "Invalid native export session",
+ });
+ return;
+ }
+
+ session.sender = event.sender;
+ session.pendingWriteRequestIds.add(requestId);
+
+ if (session.terminating) {
+ settleNativeVideoExportWriteFrameRequest(sessionId, session, requestId, {
+ success: false,
+ error: "Native video export session was cancelled",
+ });
+ return;
+ }
+
+ if (
+ session.inputMode !== "h264-stream" &&
+ frameData.byteLength !== session.inputByteSize
+ ) {
+ settleNativeVideoExportWriteFrameRequest(sessionId, session, requestId, {
+ success: false,
+ error: `Native video export expected ${session.inputByteSize} bytes per frame but received ${frameData.byteLength}`,
+ });
+ return;
+ }
+
+ void enqueueNativeVideoExportFrameWrite(session, frameData)
+ .then(() => {
+ settleNativeVideoExportWriteFrameRequest(sessionId, session, requestId, {
+ success: true,
+ });
+ })
+ .catch((error) => {
+ session.stdinError = error instanceof Error ? error : new Error(String(error));
+ settleNativeVideoExportWriteFrameRequest(sessionId, session, requestId, {
+ success: false,
+ error: getNativeVideoExportSessionError(
+ session,
+ session.stdinError.message,
+ ),
+ });
+ });
+ },
+ );
+
+ ipcMain.handle(
+ "native-video-export-finish",
+ async (_, sessionId: string, options?: NativeVideoExportFinishOptions) => {
+ const session = nativeVideoExportSessions.get(sessionId);
+ if (!session) {
+ return { success: false, error: "Invalid native export session" };
+ }
+
+ try {
+ await session.writeSequence;
+ if (
+ !session.ffmpegProcess.stdin.destroyed &&
+ !session.ffmpegProcess.stdin.writableEnded
+ ) {
+ session.ffmpegProcess.stdin.end();
+ }
+ await session.completionPromise;
+
+ const finalized = await muxNativeVideoExportAudio(
+ session.outputPath,
+ options ?? {},
+ );
+ nativeVideoExportSessions.delete(sessionId);
+ // Register the finalized path so only app-produced paths can flow back
+ // through finalize-exported-video / discard-exported-temp.
+ registerOwnedExportPath(finalized.outputPath);
+ if (finalized.outputPath !== session.outputPath) {
+ // muxNativeVideoExportAudio removes the intermediate on success, but
+ // clear our registry entry defensively in case a future refactor
+ // changes that contract.
+ releaseOwnedExportPath(session.outputPath);
+ }
+
+ // Return a temp path instead of reading the file back into memory so we
+ // never hit V8's per-ArrayBuffer limit on >2 GiB exports. The renderer
+ // uses finalize-exported-video to move the file to its final path.
+ return {
+ success: true,
+ tempPath: finalized.outputPath,
+ encoderName: session.encoderName,
+ metrics: finalized.metrics,
+ };
+ } catch (error) {
+ flushNativeVideoExportPendingWriteRequests(sessionId, session, String(error));
+ nativeVideoExportSessions.delete(sessionId);
+ await removeTemporaryExportFile(session.outputPath);
+ const finalizedSuffix = session.outputPath.replace(/\.mp4$/, "-final.mp4");
+ await removeTemporaryExportFile(finalizedSuffix);
+ return {
+ success: false,
+ error: String(error),
+ };
+ }
+ },
+ );
+
+ ipcMain.handle(
+ "mux-exported-video-audio-from-path",
+ async (_, videoPath: string, options?: NativeVideoExportFinishOptions) => {
+ if (typeof videoPath !== "string" || !isOwnedExportPath(videoPath)) {
+ return {
+ success: false,
+ error: "Video path is not an app-managed export temp",
+ };
+ }
+ try {
+ const finalized = await muxNativeVideoExportAudio(videoPath, options ?? {});
+ if (finalized.outputPath !== videoPath) {
+ registerOwnedExportPath(finalized.outputPath);
+ // muxNativeVideoExportAudio removes the intermediate on success, so
+ // the input is no longer owned by the registry after the call
+ // returns.
+ releaseOwnedExportPath(videoPath);
+ }
+ return {
+ success: true,
+ tempPath: finalized.outputPath,
+ metrics: finalized.metrics,
+ };
+ } catch (error) {
+ // Only clean up the input path if it is still an owned temp (i.e.
+ // muxNativeVideoExportAudio failed before consuming it).
+ if (isOwnedExportPath(videoPath)) {
+ await removeTemporaryExportFile(videoPath);
+ releaseOwnedExportPath(videoPath);
+ }
+ return { success: false, error: String(error) };
+ }
+ },
+ );
+
+ ipcMain.handle(
+ "mux-exported-video-audio",
+ async (_, videoData: ArrayBuffer, options?: NativeVideoExportFinishOptions) => {
+ try {
+ const sizeError = getInMemoryExportTooLargeMessage(videoData.byteLength);
+ if (sizeError) {
+ return {
+ success: false,
+ error: sizeError,
+ };
+ }
+
+ const result = await muxExportedVideoAudioBuffer(videoData, options ?? {});
+ // Register the muxed output so finalize-exported-video / discard-
+ // exported-temp accept it. Returning a temp path (instead of the
+ // muxed bytes) keeps us off Node's >2 GiB fs.readFile cap and
+ // avoids a redundant copy through the renderer.
+ registerOwnedExportPath(result.outputPath);
+ return {
+ success: true,
+ tempPath: result.outputPath,
+ metrics: result.metrics,
+ };
+ } catch (error) {
+ return {
+ success: false,
+ error: String(error),
+ };
+ }
+ },
+ );
+
+ ipcMain.handle("export-stream-open", async (_event, options?: { extension?: string }) => {
+ try {
+ const result = await openExportStream(options);
+ return { success: true, streamId: result.streamId, tempPath: result.tempPath };
+ } catch (error) {
+ return { success: false, error: String(error) };
+ }
+ });
+
+ ipcMain.handle(
+ "export-stream-write",
+ async (_event, streamId: string, position: number, chunk: Uint8Array) => {
+ try {
+ await writeToExportStream(streamId, position, chunk);
+ return { success: true };
+ } catch (error) {
+ return { success: false, error: String(error) };
+ }
+ },
+ );
+
+ ipcMain.handle(
+ "export-stream-close",
+ async (_event, streamId: string, options?: { abort?: boolean }) => {
+ try {
+ const result = await closeExportStream(streamId, options);
+ return {
+ success: true,
+ tempPath: result.tempPath,
+ bytesWritten: result.bytesWritten,
+ };
+ } catch (error) {
+ return { success: false, error: String(error) };
+ }
+ },
+ );
+
+ ipcMain.handle("native-video-export-cancel", async (_, sessionId: string) => {
+ const session = nativeVideoExportSessions.get(sessionId);
+ if (!session) {
+ return { success: true };
+ }
+
+ session.terminating = true;
+ nativeVideoExportSessions.delete(sessionId);
+ flushNativeVideoExportPendingWriteRequests(
+ sessionId,
+ session,
+ "Native video export session was cancelled",
+ );
+
+ try {
+ if (
+ !session.ffmpegProcess.stdin.destroyed &&
+ !session.ffmpegProcess.stdin.writableEnded
+ ) {
+ session.ffmpegProcess.stdin.destroy();
+ }
+ } catch {
+ // Stream may already be closed.
+ }
+
+ try {
+ session.ffmpegProcess.kill("SIGKILL");
+ } catch {
+ // Process may already be closed.
+ }
+
+ await session.completionPromise.catch(() => undefined);
+ await removeTemporaryExportFile(session.outputPath);
+ return { success: true };
+ });
+
+ ipcMain.handle(
+ "save-exported-video",
+ async (
+ event,
+ videoData: ArrayBuffer,
+ fileName: string,
+ captionSidecar?: CaptionSidecarPayload,
+ ) => {
+ try {
+ const sidecarPayload = parseCaptionSidecarPayload(captionSidecar);
+ const sizeError = getInMemoryExportTooLargeMessage(videoData.byteLength);
+ if (sizeError) {
+ return {
+ success: false,
+ message: sizeError,
+ error: sizeError,
+ };
+ }
+
+ // Determine file type from extension
+ const isGif = fileName.toLowerCase().endsWith(".gif");
+ const filters = isGif
+ ? [{ name: "GIF Image", extensions: ["gif"] }]
+ : [{ name: "MP4 Video", extensions: ["mp4"] }];
+ const parentWindow = BrowserWindow.fromWebContents(event.sender);
+ const saveDialogOptions: SaveDialogOptions = {
+ title: isGif ? "Save Exported GIF" : "Save Exported Video",
+ defaultPath: path.join(app.getPath("downloads"), fileName),
+ filters,
+ properties: ["createDirectory", "showOverwriteConfirmation"],
+ };
+
+ const result = parentWindow
+ ? await dialog.showSaveDialog(parentWindow, saveDialogOptions)
+ : await dialog.showSaveDialog(saveDialogOptions);
+
+ if (result.canceled || !result.filePath) {
+ return {
+ success: false,
+ canceled: true,
+ message: "Export canceled",
+ };
+ }
+
+ await fs.writeFile(result.filePath, Buffer.from(videoData));
+ const captionSidecarResult = await writeCaptionSidecarsBestEffort(
+ result.filePath,
+ sidecarPayload,
+ );
+ approveUserPath(result.filePath);
+
+ return {
+ success: true,
+ path: result.filePath,
+ message: withCaptionSidecarMessage(
+ "Video exported successfully",
+ captionSidecarResult,
+ ),
+ };
+ } catch (error) {
+ console.error("Failed to save exported video:", error);
+ return {
+ success: false,
+ message: "Failed to save exported video",
+ error: String(error),
+ };
+ }
+ },
+ );
+
+ ipcMain.handle(
+ "write-exported-video-to-path",
+ async (
+ _event,
+ videoData: ArrayBuffer,
+ outputPath: string,
+ captionSidecar?: CaptionSidecarPayload,
+ ) => {
+ try {
+ const sidecarPayload = parseCaptionSidecarPayload(captionSidecar);
+ const sizeError = getInMemoryExportTooLargeMessage(videoData.byteLength);
+ if (sizeError) {
+ return {
+ success: false,
+ message: sizeError,
+ canceled: false,
+ error: sizeError,
+ };
+ }
+
+ const resolvedPath = path.resolve(outputPath);
+ await fs.mkdir(path.dirname(resolvedPath), { recursive: true });
+ await fs.writeFile(resolvedPath, Buffer.from(videoData));
+ const captionSidecarResult = await writeCaptionSidecarsBestEffort(
+ resolvedPath,
+ sidecarPayload,
+ );
+ approveUserPath(resolvedPath);
+
+ return {
+ success: true,
+ path: resolvedPath,
+ message: withCaptionSidecarMessage(
+ "Video exported successfully",
+ captionSidecarResult,
+ ),
+ canceled: false,
+ };
+ } catch (error) {
+ console.error("Failed to write exported video to path:", error);
+ return {
+ success: false,
+ message: "Failed to write exported video",
+ canceled: false,
+ error: String(error),
+ };
+ }
+ },
+ );
+
+ ipcMain.handle(
+ "finalize-exported-video",
+ async (
+ event,
+ payload: {
+ tempPath: string;
+ fileName: string;
+ outputPath?: string | null;
+ captionSidecar?: CaptionSidecarPayload;
+ },
+ ) => {
+ const tempPath = payload?.tempPath;
+ const fileName = payload?.fileName;
+ if (typeof tempPath !== "string" || typeof fileName !== "string") {
+ return { success: false, error: "Invalid finalize-exported-video payload" };
+ }
+
+ if (!isTempPathSafe(tempPath) || !isOwnedExportPath(tempPath)) {
+ return {
+ success: false,
+ error: "Temp path is not an app-managed export temp",
+ };
+ }
+
+ try {
+ await fs.access(tempPath);
+ } catch {
+ return {
+ success: false,
+ error: `Exported video temp file is missing: ${tempPath}`,
+ };
+ }
+
+ try {
+ const sidecarPayload = parseCaptionSidecarPayload(payload.captionSidecar);
+ if (payload.outputPath) {
+ const resolvedPath = path.resolve(payload.outputPath);
+ await moveExportedTempFile(tempPath, resolvedPath);
+ releaseOwnedExportPath(tempPath);
+ const captionSidecarResult = await writeCaptionSidecarsBestEffort(
+ resolvedPath,
+ sidecarPayload,
+ );
+ approveUserPath(resolvedPath);
+ return {
+ success: true,
+ path: resolvedPath,
+ canceled: false,
+ message: withCaptionSidecarMessage(
+ "Video exported successfully",
+ captionSidecarResult,
+ ),
+ };
+ }
+
+ const isGif = fileName.toLowerCase().endsWith(".gif");
+ const filters = isGif
+ ? [{ name: "GIF Image", extensions: ["gif"] }]
+ : [{ name: "MP4 Video", extensions: ["mp4"] }];
+ const parentWindow = BrowserWindow.fromWebContents(event.sender);
+ const saveDialogOptions: SaveDialogOptions = {
+ title: isGif ? "Save Exported GIF" : "Save Exported Video",
+ defaultPath: path.join(app.getPath("downloads"), fileName),
+ filters,
+ properties: ["createDirectory", "showOverwriteConfirmation"],
+ };
+
+ const result = parentWindow
+ ? await dialog.showSaveDialog(parentWindow, saveDialogOptions)
+ : await dialog.showSaveDialog(saveDialogOptions);
+
+ if (result.canceled || !result.filePath) {
+ // Leave the temp file in place so the renderer can offer "Save Again"
+ // without re-rendering. The renderer owns cleanup on discard.
+ return {
+ success: false,
+ canceled: true,
+ message: "Export canceled",
+ };
+ }
+
+ await moveExportedTempFile(tempPath, result.filePath);
+ releaseOwnedExportPath(tempPath);
+ const captionSidecarResult = await writeCaptionSidecarsBestEffort(
+ result.filePath,
+ sidecarPayload,
+ );
+ approveUserPath(result.filePath);
+
+ return {
+ success: true,
+ path: result.filePath,
+ canceled: false,
+ message: withCaptionSidecarMessage(
+ "Video exported successfully",
+ captionSidecarResult,
+ ),
+ };
+ } catch (error) {
+ console.error("Failed to finalize exported video:", error);
+ return {
+ success: false,
+ canceled: false,
+ message: "Failed to save exported video",
+ error: String(error),
+ };
+ }
+ },
+ );
+
+ ipcMain.handle("discard-exported-temp", async (_event, tempPath: string) => {
+ if (typeof tempPath !== "string" || tempPath.length === 0) {
+ return { success: false, error: "Invalid temp path" };
+ }
+ if (!isTempPathSafe(tempPath) || !isOwnedExportPath(tempPath)) {
+ return {
+ success: false,
+ error: "Temp path is not an app-managed export temp",
+ };
+ }
+ try {
+ await removeTemporaryExportFile(tempPath);
+ releaseOwnedExportPath(tempPath);
+ return { success: true };
+ } catch (error) {
+ return { success: false, error: String(error) };
+ }
+ });
+}
diff --git a/electron/ipc/register/exportCaptionSidecars.test.ts b/electron/ipc/register/exportCaptionSidecars.test.ts
new file mode 100644
index 000000000..2fc571e13
--- /dev/null
+++ b/electron/ipc/register/exportCaptionSidecars.test.ts
@@ -0,0 +1,114 @@
+import fs from "node:fs/promises";
+import os from "node:os";
+import path from "node:path";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import {
+ parseCaptionSidecarPayload,
+ serializeSrt,
+ serializeVtt,
+ withCaptionSidecarMessage,
+ writeCaptionSidecarsBestEffort,
+} from "./exportCaptionSidecars";
+
+describe("exportCaptionSidecars", () => {
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it("serializes SRT cues with stable numbering and timestamps", () => {
+ expect(
+ serializeSrt([
+ {
+ startMs: 1234,
+ endMs: 5678,
+ text: "Hello\nworld",
+ },
+ ]),
+ ).toBe("1\n00:00:01,234 --> 00:00:05,678\nHello\nworld");
+ });
+
+ it("serializes VTT cues with header and dot timestamps", () => {
+ expect(
+ serializeVtt([
+ {
+ startMs: 0,
+ endMs: 2000,
+ text: "Caption",
+ },
+ ]),
+ ).toBe("WEBVTT\n\n00:00:00.000 --> 00:00:02.000\nCaption");
+ });
+
+ it("drops malformed cues when parsing sidecar payloads", () => {
+ expect(
+ parseCaptionSidecarPayload({
+ format: "both",
+ cues: [
+ { startMs: 0, endMs: 1000, text: "ok" },
+ { startMs: 2000, endMs: 1000, text: "bad range" },
+ { startMs: 1000, endMs: 2000, text: " " },
+ ],
+ }),
+ ).toEqual({
+ format: "both",
+ cues: [{ startMs: 0, endMs: 1000, text: "ok" }],
+ });
+ });
+
+ it("returns a warning result instead of throwing when sidecar writes fail", async () => {
+ const writeFileSpy = vi
+ .spyOn(fs, "writeFile")
+ .mockRejectedValueOnce(new Error("disk full"));
+
+ await expect(
+ writeCaptionSidecarsBestEffort("/tmp/export.mp4", {
+ format: "srt",
+ cues: [{ startMs: 0, endMs: 1000, text: "Caption" }],
+ }),
+ ).resolves.toEqual({
+ wroteAny: false,
+ error: "disk full",
+ });
+
+ expect(writeFileSpy).toHaveBeenCalledTimes(1);
+ });
+
+ it("writes requested caption sidecars when the filesystem succeeds", async () => {
+ const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "recordly-sidecar-test-"));
+ const videoPath = path.join(tempDir, "clip.mp4");
+
+ try {
+ await expect(
+ writeCaptionSidecarsBestEffort(videoPath, {
+ format: "both",
+ cues: [{ startMs: 0, endMs: 1000, text: "Caption" }],
+ }),
+ ).resolves.toEqual({ wroteAny: true, error: null });
+
+ await expect(fs.readFile(path.join(tempDir, "clip.srt"), "utf8")).resolves.toContain(
+ "00:00:00,000 --> 00:00:01,000",
+ );
+ await expect(fs.readFile(path.join(tempDir, "clip.vtt"), "utf8")).resolves.toContain(
+ "WEBVTT",
+ );
+ } finally {
+ await fs.rm(tempDir, { recursive: true, force: true });
+ }
+ });
+
+ it("appends a non-fatal caption warning only when sidecar writes fail", () => {
+ expect(
+ withCaptionSidecarMessage("Video exported successfully", {
+ wroteAny: false,
+ error: "disk full",
+ }),
+ ).toBe("Video exported successfully Captions could not be saved alongside the video.");
+
+ expect(
+ withCaptionSidecarMessage("Video exported successfully", {
+ wroteAny: true,
+ error: null,
+ }),
+ ).toBe("Video exported successfully");
+ });
+});
diff --git a/electron/ipc/register/exportCaptionSidecars.ts b/electron/ipc/register/exportCaptionSidecars.ts
new file mode 100644
index 000000000..0560a9a96
--- /dev/null
+++ b/electron/ipc/register/exportCaptionSidecars.ts
@@ -0,0 +1,157 @@
+import fs from "node:fs/promises";
+import path from "node:path";
+
+export type CaptionSidecarCue = {
+ startMs: number;
+ endMs: number;
+ text: string;
+};
+
+export type CaptionSidecarPayload = {
+ format: "srt" | "vtt" | "both";
+ cues: CaptionSidecarCue[];
+};
+
+export type CaptionSidecarWriteResult = {
+ wroteAny: boolean;
+ error: string | null;
+};
+
+function toSrtTimestamp(totalMs: number): string {
+ const ms = Math.max(0, Math.round(totalMs));
+ const hours = Math.floor(ms / 3_600_000);
+ const minutes = Math.floor((ms % 3_600_000) / 60_000);
+ const seconds = Math.floor((ms % 60_000) / 1000);
+ const millis = ms % 1000;
+ return `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")},${String(millis).padStart(3, "0")}`;
+}
+
+function toVttTimestamp(totalMs: number): string {
+ const ms = Math.max(0, Math.round(totalMs));
+ const hours = Math.floor(ms / 3_600_000);
+ const minutes = Math.floor((ms % 3_600_000) / 60_000);
+ const seconds = Math.floor((ms % 60_000) / 1000);
+ const millis = ms % 1000;
+ return `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}.${String(millis).padStart(3, "0")}`;
+}
+
+function normalizeCaptionSidecarCues(cues: unknown): CaptionSidecarCue[] {
+ if (!Array.isArray(cues)) {
+ return [];
+ }
+
+ return cues
+ .filter((cue): cue is CaptionSidecarCue => {
+ return (
+ typeof cue === "object" &&
+ cue !== null &&
+ typeof cue.startMs === "number" &&
+ typeof cue.endMs === "number" &&
+ typeof cue.text === "string" &&
+ Number.isFinite(cue.startMs) &&
+ Number.isFinite(cue.endMs) &&
+ cue.endMs > cue.startMs &&
+ cue.text.trim().length > 0
+ );
+ })
+ .map((cue) => ({
+ startMs: cue.startMs,
+ endMs: cue.endMs,
+ text: cue.text.replace(/\r\n/g, "\n").trim(),
+ }));
+}
+
+export function parseCaptionSidecarPayload(payload: unknown): CaptionSidecarPayload | null {
+ if (typeof payload !== "object" || payload === null) {
+ return null;
+ }
+
+ const candidate = payload as {
+ format?: unknown;
+ cues?: unknown;
+ };
+
+ const format =
+ candidate.format === "srt" || candidate.format === "vtt" || candidate.format === "both"
+ ? candidate.format
+ : null;
+ if (!format) {
+ return null;
+ }
+
+ const cues = normalizeCaptionSidecarCues(candidate.cues);
+ if (cues.length === 0) {
+ return null;
+ }
+
+ return { format, cues };
+}
+
+export function serializeSrt(cues: CaptionSidecarCue[]): string {
+ return cues
+ .map((cue, index) => {
+ return `${index + 1}\n${toSrtTimestamp(cue.startMs)} --> ${toSrtTimestamp(cue.endMs)}\n${cue.text}`;
+ })
+ .join("\n\n");
+}
+
+export function serializeVtt(cues: CaptionSidecarCue[]): string {
+ const body = cues
+ .map((cue) => {
+ return `${toVttTimestamp(cue.startMs)} --> ${toVttTimestamp(cue.endMs)}\n${cue.text}`;
+ })
+ .join("\n\n");
+ return `WEBVTT\n\n${body}`;
+}
+
+export async function writeCaptionSidecars(
+ videoPath: string,
+ payload: CaptionSidecarPayload | null,
+) {
+ if (!payload) {
+ return;
+ }
+
+ const parsed = path.parse(videoPath);
+ const basePath = path.join(parsed.dir, parsed.name);
+
+ if (payload.format === "srt" || payload.format === "both") {
+ await fs.writeFile(`${basePath}.srt`, serializeSrt(payload.cues), "utf8");
+ }
+
+ if (payload.format === "vtt" || payload.format === "both") {
+ await fs.writeFile(`${basePath}.vtt`, serializeVtt(payload.cues), "utf8");
+ }
+}
+
+export async function writeCaptionSidecarsBestEffort(
+ videoPath: string,
+ payload: CaptionSidecarPayload | null,
+): Promise {
+ if (!payload) {
+ return { wroteAny: false, error: null };
+ }
+
+ try {
+ await writeCaptionSidecars(videoPath, payload);
+ return { wroteAny: true, error: null };
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error);
+ console.warn("[export] Failed to write caption sidecars:", {
+ videoPath,
+ message,
+ });
+ return { wroteAny: false, error: message };
+ }
+}
+
+export function withCaptionSidecarMessage(
+ baseMessage: string,
+ captionSidecarResult: CaptionSidecarWriteResult,
+) {
+ if (!captionSidecarResult.error) {
+ return baseMessage;
+ }
+
+ return `${baseMessage} Captions could not be saved alongside the video.`;
+}
diff --git a/electron/ipc/register/permissions.ts b/electron/ipc/register/permissions.ts
new file mode 100644
index 000000000..f3b8b86f1
--- /dev/null
+++ b/electron/ipc/register/permissions.ts
@@ -0,0 +1,87 @@
+import { ipcMain, shell, systemPreferences } from "electron";
+import { getMacPrivacySettingsUrl } from "../utils";
+
+export function registerPermissionHandlers() {
+ ipcMain.handle("open-external-url", async (_, url: string) => {
+ try {
+ // Security: only allow http/https URLs to prevent file:// or custom protocol abuse
+ const parsed = new URL(url);
+ if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
+ return { success: false, error: `Blocked non-HTTP URL: ${parsed.protocol}` };
+ }
+ await shell.openExternal(url);
+ return { success: true };
+ } catch (error) {
+ console.error("Failed to open URL:", error);
+ return { success: false, error: String(error) };
+ }
+ });
+
+ ipcMain.handle("get-accessibility-permission-status", () => {
+ if (process.platform !== "darwin") {
+ return { success: true, trusted: true, prompted: false };
+ }
+
+ return {
+ success: true,
+ trusted: systemPreferences.isTrustedAccessibilityClient(false),
+ prompted: false,
+ };
+ });
+
+ ipcMain.handle("request-accessibility-permission", () => {
+ if (process.platform !== "darwin") {
+ return { success: true, trusted: true, prompted: false };
+ }
+
+ return {
+ success: true,
+ trusted: systemPreferences.isTrustedAccessibilityClient(true),
+ prompted: true,
+ };
+ });
+
+ ipcMain.handle("get-screen-recording-permission-status", () => {
+ if (process.platform !== "darwin") {
+ return { success: true, status: "granted" };
+ }
+
+ try {
+ return {
+ success: true,
+ status: systemPreferences.getMediaAccessStatus("screen"),
+ };
+ } catch (error) {
+ console.error("Failed to get screen recording permission status:", error);
+ return { success: false, status: "unknown", error: String(error) };
+ }
+ });
+
+ ipcMain.handle("open-screen-recording-preferences", async () => {
+ if (process.platform !== "darwin") {
+ return { success: true };
+ }
+
+ try {
+ await shell.openExternal(getMacPrivacySettingsUrl("screen"));
+ return { success: true };
+ } catch (error) {
+ console.error("Failed to open Screen Recording preferences:", error);
+ return { success: false, error: String(error) };
+ }
+ });
+
+ ipcMain.handle("open-accessibility-preferences", async () => {
+ if (process.platform !== "darwin") {
+ return { success: true };
+ }
+
+ try {
+ await shell.openExternal(getMacPrivacySettingsUrl("accessibility"));
+ return { success: true };
+ } catch (error) {
+ console.error("Failed to open Accessibility preferences:", error);
+ return { success: false, error: String(error) };
+ }
+ });
+}
diff --git a/electron/ipc/register/project.ts b/electron/ipc/register/project.ts
new file mode 100644
index 000000000..27767ba6a
--- /dev/null
+++ b/electron/ipc/register/project.ts
@@ -0,0 +1,955 @@
+import { persistRecentMetadata } from "../project/recentMetadata";
+import { renameLibraryProject } from "../project/renameLibraryProject";
+import { createUntitledProject } from "../project/createUntitledProject";
+import { trashLibraryProjects } from "../project/trashProjects";
+import { getRecordingThumbnail } from "../recording/thumbnail";
+import { listRecordings, setRecordingsRemoved } from "../recording/library";
+import { importRecording, discardRecordingImport } from "../recording/importRecording";
+import { randomUUID } from "node:crypto";
+import { constants as fsConstants } from "node:fs";
+import fs from "node:fs/promises";
+import path from "node:path";
+import { BrowserWindow, dialog, ipcMain, shell } from "electron";
+import { RECORDINGS_DIR } from "../../appPaths";
+import { buildMediaUrl, getMediaServerBaseUrl } from "../../mediaServer";
+import { LEGACY_PROJECT_FILE_EXTENSIONS, PROJECT_FILE_EXTENSION } from "../constants";
+import { getProjectBackupPath, writeProjectFileAtomically } from "../project/atomicSave";
+import {
+ getProjectsDir,
+ getProjectThumbnailPath,
+ isPathInsideDirectory,
+ isTrustedProjectPath,
+ listProjectLibraryEntries,
+ loadProjectFromPath,
+ readProjectPreview,
+ loadRecentProjectPaths,
+ persistRecordingsDirectorySetting,
+ rememberApprovedLocalReadPath,
+ rememberRecentProject,
+ replaceApprovedSessionLocalReadPaths,
+ resolveApprovedLocalMediaPath,
+ saveProjectThumbnail,
+ saveRecentProjectPaths,
+} from "../project/manager";
+import { persistRecordingSessionManifest, resolveRecordingSession } from "../project/session";
+import {
+ currentProjectPath,
+ currentRecordingSession,
+ currentVideoPath,
+ setCurrentProjectPath,
+ setCurrentRecordingSession,
+ setCurrentVideoPath,
+} from "../state";
+import {
+ approveUserPath,
+ getRecordingsDir,
+ getTelemetryPathForVideo,
+ isAutoRecordingPath,
+ normalizeVideoSourcePath,
+ normalizePath,
+ parseJsonWithByteOrderMark,
+} from "../utils";
+
+function normalizeRecordingTimeOffsetMs(value: unknown): number {
+ return typeof value === "number" && Number.isFinite(value) ? Math.round(value) : 0;
+}
+
+function normalizeBoolean(value: unknown, fallback = false): boolean {
+ return typeof value === "boolean" ? value : fallback;
+}
+
+/**
+ * Produces a filesystem-safe project base name without the project extension.
+ */
+function normalizeProjectSaveName(projectName?: string | null) {
+ if (typeof projectName !== "string") {
+ return null;
+ }
+
+ const trimmedName = projectName.trim();
+ if (!trimmedName) {
+ return null;
+ }
+
+ const withoutExtension = trimmedName.replace(
+ new RegExp(`\\.${PROJECT_FILE_EXTENSION}$`, "i"),
+ "",
+ );
+ const withoutInvalidFilesystemChars = withoutExtension.replace(/[<>:"/\\|?*]/g, "");
+ const withoutControlChars = Array.from(withoutInvalidFilesystemChars)
+ .filter((character) => character.charCodeAt(0) > 31)
+ .join("");
+ const sanitizedName = withoutControlChars
+ .replace(/\s+/g, " ")
+ .replace(/[. ]+$/g, "")
+ .trim();
+
+ return sanitizedName || null;
+}
+
+type NamedProjectSaveMode = "rename" | "copy";
+
+function normalizeNamedProjectSaveMode(value: unknown): NamedProjectSaveMode {
+ return value === "copy" ? "copy" : "rename";
+}
+
+/**
+ * Extracts the persisted source video path from a saved project payload.
+ */
+function getProjectVideoPath(projectData: unknown) {
+ if (!projectData || typeof projectData !== "object") {
+ return null;
+ }
+
+ const candidate = projectData as { videoPath?: unknown };
+ return typeof candidate.videoPath === "string" ? candidate.videoPath : null;
+}
+
+function getProjectId(projectData: unknown) {
+ if (!projectData || typeof projectData !== "object") {
+ return null;
+ }
+
+ const candidate = projectData as { projectId?: unknown };
+ return typeof candidate.projectId === "string" && candidate.projectId.trim().length > 0
+ ? candidate.projectId
+ : null;
+}
+
+function withProjectId(projectData: unknown, projectId: string) {
+ if (!projectData || typeof projectData !== "object" || Array.isArray(projectData)) {
+ return projectData;
+ }
+
+ return {
+ ...projectData,
+ projectId,
+ };
+}
+
+function ensureProjectDataHasProjectId(projectData: unknown) {
+ const existingProjectId = getProjectId(projectData);
+ if (existingProjectId) {
+ return {
+ projectId: existingProjectId,
+ projectData,
+ };
+ }
+
+ const projectId = randomUUID();
+ return {
+ projectId,
+ projectData: withProjectId(projectData, projectId),
+ };
+}
+
+async function resolveComparablePath(filePath: string) {
+ return fs.realpath(filePath).catch(() => path.resolve(filePath));
+}
+
+/**
+ * Prevents a named save from silently overwriting a different project file.
+ */
+async function ensureNamedProjectSaveDoesNotOverwriteDifferentProject(
+ targetProjectPath: string,
+ projectData: unknown,
+ activeProjectPath?: string | null,
+) {
+ try {
+ await fs.stat(targetProjectPath);
+ } catch (error) {
+ if ((error as NodeJS.ErrnoException)?.code === "ENOENT") {
+ return { success: true };
+ }
+ throw error;
+ }
+
+ const targetResolvedPath = await resolveComparablePath(targetProjectPath);
+ if (activeProjectPath) {
+ const activeResolvedPath = await resolveComparablePath(activeProjectPath);
+ if (activeResolvedPath === targetResolvedPath) {
+ return { success: true };
+ }
+ }
+
+ const incomingProjectId = getProjectId(projectData);
+ const incomingVideoPath = getProjectVideoPath(projectData);
+
+ try {
+ const existingProjectRaw = await fs.readFile(targetProjectPath, "utf-8");
+ const existingProjectData = parseJsonWithByteOrderMark(existingProjectRaw);
+ const existingProjectId = getProjectId(existingProjectData);
+ const existingVideoPath = getProjectVideoPath(existingProjectData);
+
+ if (existingProjectId && incomingProjectId) {
+ if (existingProjectId === incomingProjectId) {
+ return { success: true };
+ }
+
+ return {
+ success: false,
+ message: "A different project already uses this name",
+ };
+ }
+
+ if (existingVideoPath && incomingVideoPath && existingVideoPath !== incomingVideoPath) {
+ return {
+ success: false,
+ message: "A different project already uses this name",
+ };
+ }
+
+ if (!existingProjectId && !incomingProjectId && existingVideoPath && incomingVideoPath) {
+ return {
+ success: false,
+ message: "Unable to verify project identity for the chosen name",
+ };
+ }
+
+ return {
+ success: false,
+ message: "Unable to verify project identity for the chosen name",
+ };
+ } catch (error) {
+ console.error("Failed to verify existing named project before overwrite:", error);
+ return {
+ success: false,
+ message: "Unable to verify project identity for the chosen name",
+ };
+ }
+}
+
+export function registerProjectHandlers() {
+ ipcMain.handle("rename-library-project", async (_, source: string, name: string) => {
+ try {
+ const entries = await listProjectLibraryEntries();
+ const target = await renameLibraryProject(
+ source,
+ name,
+ entries.entries.map((entry) => entry.path),
+ (value) => [getProjectThumbnailPath(value), getProjectBackupPath(value)],
+ );
+ if (currentProjectPath && normalizePath(currentProjectPath) === normalizePath(source))
+ setCurrentProjectPath(target);
+ const warning = await persistRecentMetadata(() => rememberRecentProject(target));
+ return { success: true, path: target, warning };
+ } catch (error) {
+ return { success: false, error: String(error) };
+ }
+ });
+ const imports = new Map();
+ const pendingImports = new Map>();
+ const watchedImportSenders = new WeakSet();
+ const abandonImports = (owner: number) => {
+ imports.get(owner)?.abort();
+ const outputs = pendingImports.get(owner);
+ pendingImports.delete(owner);
+ void Promise.allSettled([...(outputs ?? [])].map(discardRecordingImport)).then(
+ (results) => {
+ for (const result of results)
+ if (result.status === "rejected")
+ console.warn("Could not discard abandoned import", result.reason);
+ },
+ );
+ };
+ ipcMain.handle("finish-recording-import", async (event, keepPath: string, commit = false) => {
+ if (imports.has(event.sender.id))
+ return { success: false, error: "Import is still running" };
+ const outputs = pendingImports.get(event.sender.id);
+ // Commit only after the renderer confirms that its project is still active.
+ if (commit) outputs?.delete(keepPath);
+ try {
+ for (const output of outputs ?? []) {
+ if (output === keepPath) continue;
+ await discardRecordingImport(output);
+ outputs?.delete(output);
+ }
+ if (!outputs?.size) pendingImports.delete(event.sender.id);
+ return { success: true };
+ } catch (error) {
+ return { success: false, error: String(error) };
+ }
+ });
+ ipcMain.handle("cancel-recording-import", (event) => {
+ imports.get(event.sender.id)?.abort();
+ return { success: true };
+ });
+ ipcMain.handle("get-recording-thumbnail", async (_, file: string) => {
+ try {
+ return { success: true, value: await getRecordingThumbnail(file) };
+ } catch (error) {
+ return { success: false, error: String(error) };
+ }
+ });
+ ipcMain.handle("get-project-preview", async (_, projectPath: string) => {
+ try {
+ return { success: true, value: await readProjectPreview(projectPath) };
+ } catch (error) {
+ return { success: false, error: String(error) };
+ }
+ });
+ ipcMain.handle("list-recordings", async (_, includeSources?: boolean) => {
+ try {
+ return { success: true, value: await listRecordings(includeSources === true) };
+ } catch (error) {
+ return { success: false, error: String(error) };
+ }
+ });
+ ipcMain.handle("set-recordings-removed", async (_, paths: string[], removed: boolean) => {
+ try {
+ await setRecordingsRemoved(paths, removed);
+ return { success: true, value: null };
+ } catch (error) {
+ return { success: false, error: String(error) };
+ }
+ });
+ ipcMain.handle(
+ "import-recording",
+ async (
+ event,
+ currentPath: string,
+ recordingPath: string,
+ webcam?: import("../../../src/types/recordingLibrary").RecordingWebcamSource,
+ ) => {
+ const owner = event.sender.id;
+ if (!watchedImportSenders.has(event.sender)) {
+ watchedImportSenders.add(event.sender);
+ event.sender.once("destroyed", () => abandonImports(owner));
+ event.sender.on("render-process-gone", () => abandonImports(owner));
+ event.sender.on("did-start-navigation", (_event, _url, isInPlace, isMainFrame) => {
+ if (isMainFrame && !isInPlace) abandonImports(owner);
+ });
+ }
+ if (imports.has(owner))
+ return { success: false, error: "An import is already running" };
+ const controller = new AbortController();
+ imports.set(owner, controller);
+ try {
+ const value = await importRecording(
+ currentPath,
+ recordingPath,
+ webcam,
+ controller.signal,
+ );
+ if (controller.signal.aborted || event.sender.isDestroyed()) {
+ await discardRecordingImport(value.path);
+ throw new Error("Import cancelled because its editor closed or reloaded");
+ }
+ const outputs = pendingImports.get(owner) ?? new Set();
+ outputs.add(value.path);
+ pendingImports.set(owner, outputs);
+ return { success: true, value };
+ } catch (error) {
+ return { success: false, error: String(error) };
+ } finally {
+ imports.delete(owner);
+ }
+ },
+ );
+ ipcMain.handle("reveal-in-folder", async (_, filePath: string) => {
+ try {
+ // shell.showItemInFolder doesn't return a value, it throws on error
+ shell.showItemInFolder(filePath);
+ return { success: true };
+ } catch (error) {
+ console.error(`Error revealing item in folder: ${filePath}`, error);
+ // Fallback to open the directory if revealing the item fails
+ // This might happen if the file was moved or deleted after export,
+ // or if the path is somehow invalid for showItemInFolder
+ try {
+ const openPathResult = await shell.openPath(path.dirname(filePath));
+ if (openPathResult) {
+ // openPath returned an error message
+ return { success: false, error: openPathResult };
+ }
+ return { success: true, message: "Could not reveal item, but opened directory." };
+ } catch (openError) {
+ console.error(`Error opening directory: ${path.dirname(filePath)}`, openError);
+ return { success: false, error: String(error) };
+ }
+ }
+ });
+
+ ipcMain.handle("open-recordings-folder", async () => {
+ try {
+ const recordingsDir = await getRecordingsDir();
+ const openPathResult = await shell.openPath(recordingsDir);
+ if (openPathResult) {
+ return {
+ success: false,
+ error: openPathResult,
+ message: "Failed to open recordings folder.",
+ };
+ }
+
+ return { success: true };
+ } catch (error) {
+ console.error("Failed to open recordings folder:", error);
+ return {
+ success: false,
+ error: String(error),
+ message: "Failed to open recordings folder.",
+ };
+ }
+ });
+
+ ipcMain.handle("get-recordings-directory", async () => {
+ try {
+ const recordingsDir = await getRecordingsDir();
+ return {
+ success: true,
+ path: recordingsDir,
+ isDefault: recordingsDir === RECORDINGS_DIR,
+ };
+ } catch (error) {
+ return {
+ success: false,
+ path: RECORDINGS_DIR,
+ isDefault: true,
+ error: String(error),
+ };
+ }
+ });
+
+ ipcMain.handle("choose-recordings-directory", async () => {
+ try {
+ const current = await getRecordingsDir();
+ const result = await dialog.showOpenDialog({
+ title: "Choose recordings folder",
+ defaultPath: current,
+ properties: ["openDirectory", "createDirectory", "promptToCreate"],
+ });
+
+ if (result.canceled || result.filePaths.length === 0) {
+ return { success: false, canceled: true, path: current };
+ }
+
+ const selectedPath = path.resolve(result.filePaths[0]);
+ await fs.mkdir(selectedPath, { recursive: true });
+ await fs.access(selectedPath, fsConstants.W_OK);
+ await persistRecordingsDirectorySetting(selectedPath);
+
+ return {
+ success: true,
+ path: selectedPath,
+ isDefault: selectedPath === RECORDINGS_DIR,
+ };
+ } catch (error) {
+ return {
+ success: false,
+ error: String(error),
+ message: "Failed to set recordings folder",
+ };
+ }
+ });
+
+ ipcMain.handle(
+ "save-project-file",
+ async (
+ _,
+ projectData: unknown,
+ suggestedName?: string,
+ existingProjectPath?: string,
+ thumbnailDataUrl?: string | null,
+ ) => {
+ try {
+ const projectsDir = await getProjectsDir();
+ const preparedProject = ensureProjectDataHasProjectId(projectData);
+ const trustedExistingProjectPath =
+ existingProjectPath &&
+ path.extname(existingProjectPath).toLowerCase() ===
+ `.${PROJECT_FILE_EXTENSION}` &&
+ (isTrustedProjectPath(existingProjectPath) ||
+ isPathInsideDirectory(existingProjectPath, projectsDir))
+ ? path.resolve(existingProjectPath)
+ : null;
+
+ if (trustedExistingProjectPath) {
+ await writeProjectFileAtomically(
+ trustedExistingProjectPath,
+ JSON.stringify(preparedProject.projectData, null, 2),
+ );
+ setCurrentProjectPath(trustedExistingProjectPath);
+ await saveProjectThumbnail(trustedExistingProjectPath, thumbnailDataUrl);
+ await rememberRecentProject(trustedExistingProjectPath);
+ return {
+ success: true,
+ path: trustedExistingProjectPath,
+ projectId: preparedProject.projectId,
+ message: "Project saved successfully",
+ };
+ }
+
+ if (existingProjectPath) {
+ return {
+ success: false,
+ message:
+ "Project path is no longer trusted. Use Save As to choose a project file.",
+ };
+ }
+
+ const safeName = normalizeProjectSaveName(suggestedName) || `project-${Date.now()}`;
+ const defaultName = `${safeName}.${PROJECT_FILE_EXTENSION}`;
+
+ const result = await dialog.showSaveDialog({
+ title: "Save Recordly Project",
+ defaultPath: path.join(projectsDir, defaultName),
+ filters: [
+ { name: "Recordly Project", extensions: [PROJECT_FILE_EXTENSION] },
+ { name: "JSON", extensions: ["json"] },
+ ],
+ properties: ["createDirectory", "showOverwriteConfirmation"],
+ });
+
+ if (result.canceled || !result.filePath) {
+ return {
+ success: false,
+ canceled: true,
+ message: "Save project canceled",
+ };
+ }
+
+ await writeProjectFileAtomically(
+ result.filePath,
+ JSON.stringify(preparedProject.projectData, null, 2),
+ );
+ setCurrentProjectPath(result.filePath);
+ await saveProjectThumbnail(result.filePath, thumbnailDataUrl);
+ await rememberRecentProject(result.filePath);
+
+ return {
+ success: true,
+ path: result.filePath,
+ projectId: preparedProject.projectId,
+ message: "Project saved successfully",
+ };
+ } catch (error) {
+ console.error("Failed to save project file:", error);
+ return {
+ success: false,
+ message: "Failed to save project file",
+ error: String(error),
+ };
+ }
+ },
+ );
+
+ ipcMain.handle(
+ "save-project-file-named",
+ async (
+ _,
+ projectData: unknown,
+ projectName: string,
+ thumbnailDataUrl?: string | null,
+ mode?: unknown,
+ ) => {
+ try {
+ const normalizedProjectName = normalizeProjectSaveName(projectName);
+ if (!normalizedProjectName) {
+ return {
+ success: false,
+ message: "Project name is required",
+ };
+ }
+
+ const projectsDir = await getProjectsDir();
+ const namedSaveMode = normalizeNamedProjectSaveMode(mode);
+ const activeProjectPath = isTrustedProjectPath(currentProjectPath)
+ ? currentProjectPath
+ : null;
+ const targetProjectPath = path.join(
+ projectsDir,
+ `${normalizedProjectName}.${PROJECT_FILE_EXTENSION}`,
+ );
+ const [activeResolvedPath, targetResolvedPath] = await Promise.all([
+ activeProjectPath
+ ? resolveComparablePath(activeProjectPath)
+ : Promise.resolve(null),
+ resolveComparablePath(targetProjectPath),
+ ]);
+ const isSavingToDifferentPath =
+ !activeResolvedPath || activeResolvedPath !== targetResolvedPath;
+ const preparedProject =
+ namedSaveMode === "copy" && isSavingToDifferentPath
+ ? (() => {
+ const projectId = randomUUID();
+ return {
+ projectId,
+ projectData: withProjectId(projectData, projectId),
+ };
+ })()
+ : ensureProjectDataHasProjectId(projectData);
+
+ const overwriteCheck = await ensureNamedProjectSaveDoesNotOverwriteDifferentProject(
+ targetProjectPath,
+ preparedProject.projectData,
+ activeProjectPath,
+ );
+ if (!overwriteCheck.success) {
+ return overwriteCheck;
+ }
+
+ await writeProjectFileAtomically(
+ targetProjectPath,
+ JSON.stringify(preparedProject.projectData, null, 2),
+ );
+ await saveProjectThumbnail(targetProjectPath, thumbnailDataUrl);
+ await rememberRecentProject(targetProjectPath);
+
+ if (namedSaveMode === "rename" && activeProjectPath && isSavingToDifferentPath) {
+ await fs
+ .unlink(activeProjectPath)
+ .catch((unlinkError: NodeJS.ErrnoException) => {
+ if (unlinkError.code !== "ENOENT") {
+ throw unlinkError;
+ }
+ });
+ await fs
+ .rm(getProjectThumbnailPath(activeProjectPath), { force: true })
+ .catch(() => undefined);
+ await fs
+ .rm(getProjectBackupPath(activeProjectPath), { force: true })
+ .catch(() => undefined);
+
+ const recentProjectPaths = await loadRecentProjectPaths();
+ const filteredRecentProjectPaths: string[] = [];
+ for (const recentProjectPath of recentProjectPaths) {
+ const recentResolvedPath = await resolveComparablePath(recentProjectPath);
+ if (recentResolvedPath !== activeResolvedPath) {
+ filteredRecentProjectPaths.push(recentProjectPath);
+ }
+ }
+ await saveRecentProjectPaths(filteredRecentProjectPaths);
+ }
+
+ setCurrentProjectPath(targetProjectPath);
+
+ return {
+ success: true,
+ path: targetProjectPath,
+ projectId: preparedProject.projectId,
+ message: "Project saved successfully",
+ };
+ } catch (error) {
+ console.error("Failed to save named project file:", error);
+ return {
+ success: false,
+ message: "Failed to save project file",
+ error: String(error),
+ };
+ }
+ },
+ );
+
+ ipcMain.handle("load-project-file", async () => {
+ try {
+ const projectsDir = await getProjectsDir();
+ const result = await dialog.showOpenDialog({
+ title: "Open Recordly Project",
+ defaultPath: projectsDir,
+ filters: [
+ {
+ name: "Recordly Project",
+ extensions: [PROJECT_FILE_EXTENSION, ...LEGACY_PROJECT_FILE_EXTENSIONS],
+ },
+ { name: "JSON", extensions: ["json"] },
+ { name: "All Files", extensions: ["*"] },
+ ],
+ properties: ["openFile"],
+ });
+
+ if (result.canceled || result.filePaths.length === 0) {
+ return { success: false, canceled: true, message: "Open project canceled" };
+ }
+
+ return await loadProjectFromPath(result.filePaths[0]);
+ } catch (error) {
+ console.error("Failed to load project file:", error);
+ return {
+ success: false,
+ message: "Failed to load project file",
+ error: String(error),
+ };
+ }
+ });
+
+ ipcMain.handle("load-current-project-file", async () => {
+ try {
+ if (!currentProjectPath) {
+ return { success: false, message: "No active project" };
+ }
+
+ return await loadProjectFromPath(currentProjectPath);
+ } catch (error) {
+ console.error("Failed to load current project file:", error);
+ return {
+ success: false,
+ message: "Failed to load current project file",
+ error: String(error),
+ };
+ }
+ });
+
+ ipcMain.handle("get-projects-directory", async () => {
+ try {
+ return {
+ success: true,
+ path: await getProjectsDir(),
+ };
+ } catch (error) {
+ return {
+ success: false,
+ error: String(error),
+ };
+ }
+ });
+
+ ipcMain.handle(
+ "create-project-file",
+ async (_, projectData: unknown, thumbnailDataUrl?: string | null) => {
+ try {
+ const prepared = ensureProjectDataHasProjectId(projectData);
+ const target = await createUntitledProject(
+ await getProjectsDir(),
+ JSON.stringify(prepared.projectData, null, 2),
+ );
+ setCurrentProjectPath(target);
+ const warning = await persistRecentMetadata(() => rememberRecentProject(target));
+ try {
+ await saveProjectThumbnail(target, thumbnailDataUrl);
+ } catch (error) {
+ console.warn("Could not save project thumbnail", error);
+ }
+ return { success: true, path: target, projectId: prepared.projectId, warning };
+ } catch (error) {
+ return { success: false, message: String(error) };
+ }
+ },
+ );
+ ipcMain.handle("trash-project-files", async (_, paths: unknown) => {
+ try {
+ const result = await trashLibraryProjects(paths, {
+ list: listProjectLibraryEntries,
+ trash: (filePath) => shell.trashItem(filePath),
+ thumbnailPath: getProjectThumbnailPath,
+ });
+ if (currentProjectPath && result.deleted.includes(path.resolve(currentProjectPath)))
+ setCurrentProjectPath(null);
+ const warning = await persistRecentMetadata(async () =>
+ saveRecentProjectPaths(
+ (await loadRecentProjectPaths()).filter(
+ (p) => !result.deleted.includes(path.resolve(p)),
+ ),
+ ),
+ );
+ return { success: result.errors.length === 0, ...result, warning };
+ } catch (error) {
+ return { success: false, deleted: [], errors: [String(error)] };
+ }
+ });
+
+ ipcMain.handle("list-project-files", async () => {
+ try {
+ const library = await listProjectLibraryEntries();
+ return {
+ success: true,
+ projectsDir: library.projectsDir,
+ entries: library.entries,
+ };
+ } catch (error) {
+ return {
+ success: false,
+ projectsDir: null,
+ entries: [],
+ error: String(error),
+ };
+ }
+ });
+
+ ipcMain.handle("open-project-file-at-path", async (_, filePath: string) => {
+ try {
+ return await loadProjectFromPath(filePath);
+ } catch (error) {
+ console.error("Failed to open project file at path:", error);
+ return {
+ success: false,
+ message: "Failed to open project file",
+ error: String(error),
+ };
+ }
+ });
+
+ ipcMain.handle("open-projects-directory", async () => {
+ try {
+ const projectsDir = await getProjectsDir();
+ const openPathResult = await shell.openPath(projectsDir);
+ if (openPathResult) {
+ return {
+ success: false,
+ error: openPathResult,
+ message: "Failed to open projects folder.",
+ };
+ }
+
+ return { success: true, path: projectsDir };
+ } catch (error) {
+ console.error("Failed to open projects folder:", error);
+ return {
+ success: false,
+ error: String(error),
+ message: "Failed to open projects folder.",
+ };
+ }
+ });
+ ipcMain.handle(
+ "set-current-video-path",
+ async (
+ _,
+ path: string,
+ options?: { preserveProjectPath?: boolean; hideOverlayCursorByDefault?: boolean },
+ ) => {
+ setCurrentVideoPath(normalizeVideoSourcePath(path) ?? path);
+ approveUserPath(currentVideoPath);
+ const resolvedSession = (await resolveRecordingSession(currentVideoPath)) ?? {
+ videoPath: currentVideoPath!,
+ webcamPath: null,
+ timeOffsetMs: 0,
+ };
+
+ const nextSession = {
+ ...resolvedSession,
+ hideOverlayCursorByDefault:
+ normalizeBoolean(options?.hideOverlayCursorByDefault) ||
+ normalizeBoolean(resolvedSession.hideOverlayCursorByDefault),
+ };
+
+ setCurrentRecordingSession(nextSession);
+ await replaceApprovedSessionLocalReadPaths([
+ resolvedSession.videoPath,
+ resolvedSession.webcamPath,
+ ]);
+
+ if (nextSession.webcamPath) {
+ await persistRecordingSessionManifest(nextSession);
+ }
+
+ if (!options?.preserveProjectPath) {
+ setCurrentProjectPath(null);
+ }
+
+ for (const window of BrowserWindow.getAllWindows()) {
+ if (!window.isDestroyed()) {
+ window.webContents.send("recording-session-changed", nextSession);
+ }
+ }
+
+ return { success: true, webcamPath: nextSession.webcamPath ?? null };
+ },
+ );
+
+ ipcMain.handle(
+ "set-current-recording-session",
+ async (
+ _,
+ session: {
+ videoPath: string;
+ webcamPath?: string | null;
+ timeOffsetMs?: number;
+ hideOverlayCursorByDefault?: boolean;
+ },
+ options?: { preserveProjectPath?: boolean },
+ ) => {
+ const normalizedVideoPath =
+ normalizeVideoSourcePath(session.videoPath) ?? session.videoPath;
+ setCurrentVideoPath(normalizedVideoPath);
+ setCurrentRecordingSession({
+ videoPath: normalizedVideoPath,
+ webcamPath: normalizeVideoSourcePath(session.webcamPath ?? null),
+ timeOffsetMs: normalizeRecordingTimeOffsetMs(session.timeOffsetMs),
+ hideOverlayCursorByDefault: normalizeBoolean(session.hideOverlayCursorByDefault),
+ });
+ await rememberApprovedLocalReadPath(currentRecordingSession!.videoPath);
+ await rememberApprovedLocalReadPath(currentRecordingSession!.webcamPath);
+ if (!options?.preserveProjectPath) {
+ setCurrentProjectPath(null);
+ }
+ await persistRecordingSessionManifest(currentRecordingSession!);
+
+ for (const window of BrowserWindow.getAllWindows()) {
+ if (!window.isDestroyed()) {
+ window.webContents.send("recording-session-changed", currentRecordingSession);
+ }
+ }
+
+ return { success: true };
+ },
+ );
+
+ ipcMain.handle("get-current-recording-session", () => {
+ if (!currentRecordingSession) {
+ return { success: false };
+ }
+
+ return {
+ success: true,
+ session: currentRecordingSession,
+ };
+ });
+
+ ipcMain.handle("get-current-video-path", () => {
+ return currentVideoPath ? { success: true, path: currentVideoPath } : { success: false };
+ });
+
+ ipcMain.handle("clear-current-video-path", () => {
+ setCurrentVideoPath(null);
+ setCurrentRecordingSession(null);
+ return { success: true };
+ });
+
+ ipcMain.handle("delete-recording-file", async (_, filePath: string) => {
+ try {
+ if (!filePath) {
+ return { success: false, error: "Only auto-generated recordings can be deleted" };
+ }
+ const resolvedPath = await fs.realpath(filePath).catch(() => path.resolve(filePath));
+ const recordingsDirRaw = await getRecordingsDir();
+ const recordingsDir = await fs
+ .realpath(recordingsDirRaw)
+ .catch(() => path.resolve(recordingsDirRaw));
+ if (
+ !isPathInsideDirectory(resolvedPath, recordingsDir) ||
+ !isAutoRecordingPath(resolvedPath)
+ ) {
+ return { success: false, error: "Only auto-generated recordings can be deleted" };
+ }
+ await fs.unlink(resolvedPath);
+ // Also delete the cursor telemetry sidecar if it exists
+ const telemetryPath = getTelemetryPathForVideo(resolvedPath);
+ await fs.unlink(telemetryPath).catch(() => undefined);
+ const currentResolved = currentVideoPath
+ ? await fs.realpath(currentVideoPath).catch(() => currentVideoPath)
+ : null;
+ if (currentResolved === resolvedPath) {
+ setCurrentVideoPath(null);
+ setCurrentRecordingSession(null);
+ }
+ return { success: true };
+ } catch (error) {
+ return { success: false, error: String(error) };
+ }
+ });
+
+ ipcMain.handle("get-local-media-url", async (_, filePath: string) => {
+ const baseUrl = getMediaServerBaseUrl();
+ if (!baseUrl || !filePath) {
+ return { success: false as const };
+ }
+ const resolved = await resolveApprovedLocalMediaPath(filePath);
+ if (!resolved) {
+ const normalized = path.resolve(filePath);
+ console.warn(`[get-local-media-url] Blocked disallowed path: ${normalized}`);
+ return { success: false as const };
+ }
+ return { success: true as const, url: buildMediaUrl(baseUrl, resolved) };
+ });
+}
diff --git a/electron/ipc/register/recording.ts b/electron/ipc/register/recording.ts
new file mode 100644
index 000000000..9ae15ae79
--- /dev/null
+++ b/electron/ipc/register/recording.ts
@@ -0,0 +1,1982 @@
+import type { ChildProcessWithoutNullStreams } from "node:child_process";
+import { execFile, spawn } from "node:child_process";
+import fs from "node:fs/promises";
+import path from "node:path";
+import { promisify } from "node:util";
+import {
+ app,
+ BrowserWindow,
+ desktopCapturer,
+ dialog,
+ ipcMain,
+ shell,
+ systemPreferences,
+} from "electron";
+import { getHudCaptureExcludedProcessIds } from "../../../src/lib/hudCaptureProtection";
+import { showCursor } from "../../cursorHider";
+import { getHudOverlayCaptureProtectionEnabled, beginHudCaptureProtection } from "../../windows";
+import { ALLOW_RECORDLY_WINDOW_CAPTURE } from "../constants";
+import { startWindowBoundsCapture, stopWindowBoundsCapture } from "../cursor/bounds";
+import { startInteractionCapture, stopInteractionCapture } from "../cursor/interaction";
+import { startNativeCursorMonitor, stopNativeCursorMonitor } from "../cursor/monitor";
+import {
+ normalizeCursorTelemetrySamples,
+ pauseCursorCaptureAtBoundary,
+ persistPendingCursorTelemetry,
+ resetCursorCaptureClock,
+ resumeCursorCapture,
+ sampleCursorPoint,
+ snapshotCursorTelemetryForPersistence,
+ startCursorSampling,
+ stopCursorCapture,
+ writeCursorTelemetry,
+} from "../cursor/telemetry";
+import { getFfmpegBinaryPath } from "../ffmpeg/binary";
+import { getMonitorHandles } from "../monitorResolver";
+import {
+ ensureNativeCaptureHelperBinary,
+ ensureSwiftHelperBinary,
+ getNativeCaptureHelperBinaryPath,
+ getSystemCursorHelperBinaryPath,
+ getSystemCursorHelperSourcePath,
+ getWindowsCaptureExePath,
+} from "../paths/binaries";
+import { rememberApprovedLocalReadPath } from "../project/manager";
+import {
+ getBrowserMicSidecarFilters,
+ shouldKeepRecordingAudioSidecars,
+} from "../recording/audioFilters";
+import {
+ getCompanionAudioFallbackInfo,
+ getFileSizeIfPresent,
+ type MicrophoneChunkTimingEvent,
+ type MicrophonePauseInterval,
+ type RecordingDiagnosticsSnapshot,
+ recordNativeCaptureDiagnostics,
+ summarizeMicrophoneChunkTiming,
+ validateRecordedVideo,
+ writeRecordingDiagnosticsSnapshot,
+} from "../recording/diagnostics";
+import {
+ buildFfmpegCaptureArgs,
+ waitForFfmpegCaptureStart,
+ waitForFfmpegCaptureStop,
+} from "../recording/ffmpeg";
+import {
+ attachNativeCaptureLifecycle,
+ finalizeStoredVideo,
+ muxNativeMacRecordingWithAudio,
+ recoverNativeMacCaptureOutput,
+ waitForNativeCaptureCommand,
+ waitForNativeCaptureStart,
+ waitForNativeCaptureStop,
+} from "../recording/mac";
+import {
+ isWebcamCompanionRecordingPath,
+ resolveRecordedVideoStoragePath,
+} from "../recording/storagePath";
+import {
+ attachWindowsCaptureLifecycle,
+ isNativeWindowsCaptureAvailable,
+ muxNativeWindowsVideoWithAudio,
+ waitForWindowsCaptureStart,
+ waitForWindowsCaptureStop,
+} from "../recording/windows";
+import {
+ shouldStartWindowsBrowserMicrophoneFallback,
+ shouldUseWindowsBrowserMicrophoneFallback,
+} from "../recording/windowsFallbacks";
+import {
+ cachedSystemCursorAssets,
+ cachedSystemCursorAssetsSourceMtimeMs,
+ currentVideoPath,
+ ffmpegCaptureOutputBuffer,
+ ffmpegCaptureProcess,
+ ffmpegCaptureTargetPath,
+ ffmpegScreenRecordingActive,
+ lastNativeCaptureDiagnostics,
+ nativeCaptureMicrophonePath,
+ nativeCaptureOutputBuffer,
+ nativeCapturePaused,
+ nativeCaptureProcess,
+ nativeCaptureSystemAudioPath,
+ nativeCaptureTargetPath,
+ nativeScreenRecordingActive,
+ selectedSource,
+ setActiveCursorSamples,
+ setCachedSystemCursorAssets,
+ setCachedSystemCursorAssetsSourceMtimeMs,
+ setCursorCaptureStartTimeMs,
+ setFfmpegCaptureOutputBuffer,
+ setFfmpegCaptureProcess,
+ setFfmpegCaptureTargetPath,
+ setFfmpegScreenRecordingActive,
+ setIsCursorCaptureActive,
+ setLastLeftClick,
+ setLinuxCursorScreenPoint,
+ setNativeCaptureMicrophonePath,
+ setNativeCaptureOutputBuffer,
+ setNativeCapturePaused,
+ setNativeCaptureProcess,
+ setNativeCaptureStopRequested,
+ setNativeCaptureSystemAudioPath,
+ setNativeCaptureTargetPath,
+ setNativeScreenRecordingActive,
+ setPendingCursorSamples,
+ setWindowsCaptureOutputBuffer,
+ setWindowsCapturePaused,
+ setWindowsCaptureProcess,
+ setWindowsCaptureStopRequested,
+ setWindowsCaptureTargetPath,
+ setWindowsMicAudioPath,
+ setWindowsNativeCaptureActive,
+ setWindowsOrphanedMicAudioPath,
+ setWindowsPendingVideoPath,
+ setWindowsSystemAudioPath,
+ windowsCaptureOutputBuffer,
+ windowsCapturePaused,
+ windowsCaptureProcess,
+ windowsCaptureTargetPath,
+ windowsMicAudioPath,
+ windowsNativeCaptureActive,
+ windowsOrphanedMicAudioPath,
+ windowsPendingVideoPath,
+ windowsSystemAudioPath,
+} from "../state";
+import type { CursorTelemetryPoint, NativeMacRecordingOptions, SelectedSource } from "../types";
+import {
+ getMacPrivacySettingsUrl,
+ getRecordingsDir,
+ getScreen,
+ getTelemetryPathForVideo,
+ moveFileWithOverwrite,
+ normalizeVideoSourcePath,
+ parseJsonWithByteOrderMark,
+ parseWindowId,
+} from "../utils";
+import { resolveWindowsCaptureTarget } from "../windowsCaptureSelection";
+import { bringSelectedWindowForward } from "./sources";
+
+const execFileAsync = promisify(execFile);
+
+async function writeWindowsRecordingDiagnostics(
+ videoPath: string | null | undefined,
+ snapshot: Omit,
+) {
+ if (!videoPath) {
+ return null;
+ }
+
+ try {
+ return await writeRecordingDiagnosticsSnapshot(videoPath, {
+ backend: "windows-wgc",
+ ...snapshot,
+ });
+ } catch (error) {
+ console.warn("Failed to write Windows recording diagnostics:", error);
+ return null;
+ }
+}
+
+function isRecord(value: unknown): value is Record {
+ return Boolean(value && typeof value === "object" && !Array.isArray(value));
+}
+
+function pickPrimitiveRecord(value: unknown) {
+ if (!isRecord(value)) {
+ return null;
+ }
+
+ const entries = Object.entries(value).filter(
+ (entry): entry is [string, boolean | number | string] => {
+ const primitive = entry[1];
+ return (
+ typeof primitive === "boolean" ||
+ typeof primitive === "number" ||
+ typeof primitive === "string"
+ );
+ },
+ );
+
+ return entries.length > 0 ? Object.fromEntries(entries) : null;
+}
+
+function normalizeRendererTimestampMs(value: unknown) {
+ const nowMs = Date.now();
+ if (typeof value !== "number" || !Number.isFinite(value)) {
+ return nowMs;
+ }
+
+ return Math.min(Math.max(0, Math.round(value)), nowMs);
+}
+
+function pickMicrophoneChunkEvents(value: unknown): MicrophoneChunkTimingEvent[] | null {
+ if (!Array.isArray(value)) {
+ return null;
+ }
+
+ const events = value
+ .map((event) => {
+ if (!isRecord(event)) {
+ return null;
+ }
+
+ const { index, size, elapsedMs, deltaMs, recordedElapsedMs, recordedDeltaMs } = event;
+ if (
+ typeof index !== "number" ||
+ !Number.isFinite(index) ||
+ index < 0 ||
+ typeof size !== "number" ||
+ !Number.isFinite(size) ||
+ size < 0 ||
+ typeof elapsedMs !== "number" ||
+ !Number.isFinite(elapsedMs) ||
+ elapsedMs < 0
+ ) {
+ return null;
+ }
+
+ return {
+ index: Math.round(index),
+ size: Math.round(size),
+ elapsedMs: Math.round(elapsedMs),
+ deltaMs:
+ typeof deltaMs === "number" && Number.isFinite(deltaMs)
+ ? Math.max(0, Math.round(deltaMs))
+ : null,
+ ...(typeof recordedElapsedMs === "number" &&
+ Number.isFinite(recordedElapsedMs) &&
+ recordedElapsedMs >= 0
+ ? { recordedElapsedMs: Math.round(recordedElapsedMs) }
+ : {}),
+ recordedDeltaMs:
+ typeof recordedDeltaMs === "number" && Number.isFinite(recordedDeltaMs)
+ ? Math.max(0, Math.round(recordedDeltaMs))
+ : null,
+ };
+ })
+ .filter((event): event is NonNullable => event !== null);
+
+ return events.length > 0 ? events : null;
+}
+
+function pickMicrophonePauseIntervals(value: unknown): MicrophonePauseInterval[] | null {
+ if (!Array.isArray(value)) {
+ return null;
+ }
+
+ const intervals = value
+ .map((interval) => {
+ if (
+ !isRecord(interval) ||
+ typeof interval.startElapsedMs !== "number" ||
+ !Number.isFinite(interval.startElapsedMs) ||
+ interval.startElapsedMs < 0
+ ) {
+ return null;
+ }
+
+ const startElapsedMs = Math.max(0, Math.round(interval.startElapsedMs));
+ return {
+ startElapsedMs,
+ ...(typeof interval.endElapsedMs === "number" &&
+ Number.isFinite(interval.endElapsedMs) &&
+ interval.endElapsedMs >= startElapsedMs
+ ? { endElapsedMs: Math.round(interval.endElapsedMs) }
+ : {}),
+ ...(typeof interval.durationMs === "number" &&
+ Number.isFinite(interval.durationMs) &&
+ interval.durationMs >= 0
+ ? { durationMs: Math.round(interval.durationMs) }
+ : {}),
+ };
+ })
+ .filter((interval): interval is NonNullable => interval !== null);
+
+ return intervals.length > 0 ? intervals : null;
+}
+
+function pickAudioInputDevices(value: unknown) {
+ if (!Array.isArray(value)) {
+ return null;
+ }
+
+ const devices = value
+ .map((device) => {
+ if (!isRecord(device) || typeof device.deviceId !== "string") {
+ return null;
+ }
+
+ return {
+ deviceId: device.deviceId,
+ ...(typeof device.groupId === "string" ? { groupId: device.groupId } : {}),
+ label: typeof device.label === "string" ? device.label : "",
+ };
+ })
+ .filter((device): device is NonNullable => device !== null);
+
+ return devices.length > 0 ? devices : null;
+}
+
+async function getSystemCursorAssets() {
+ if (process.platform !== "darwin") {
+ setCachedSystemCursorAssets({});
+ setCachedSystemCursorAssetsSourceMtimeMs(null);
+ return cachedSystemCursorAssets ?? {};
+ }
+ const sourcePath = getSystemCursorHelperSourcePath();
+ const sourceStat = await fs.stat(sourcePath);
+ if (cachedSystemCursorAssets && cachedSystemCursorAssetsSourceMtimeMs === sourceStat.mtimeMs) {
+ return cachedSystemCursorAssets;
+ }
+ const binaryPath = await ensureSwiftHelperBinary(
+ sourcePath,
+ getSystemCursorHelperBinaryPath(),
+ "system cursor helper",
+ "recordly-system-cursors",
+ );
+ const { stdout } = await execFileAsync(binaryPath, [], {
+ timeout: 15000,
+ maxBuffer: 20 * 1024 * 1024,
+ });
+ const parsed = JSON.parse(stdout) as Record<
+ string,
+ Partial
+ >;
+ const result = Object.fromEntries(
+ Object.entries(parsed).filter(
+ ([, asset]) =>
+ typeof asset?.dataUrl === "string" &&
+ typeof asset?.hotspotX === "number" &&
+ typeof asset?.hotspotY === "number" &&
+ typeof asset?.width === "number" &&
+ typeof asset?.height === "number",
+ ),
+ ) as Record;
+ setCachedSystemCursorAssets(result);
+ setCachedSystemCursorAssetsSourceMtimeMs(sourceStat.mtimeMs);
+ return result;
+}
+
+function normalizeDesktopSourceName(value: string) {
+ return value.trim().replace(/\s+/g, " ").toLowerCase();
+}
+
+async function cleanupWindowsOrphanedMicAudioPath(filePath: string | null) {
+ if (!filePath) {
+ return;
+ }
+
+ if (shouldKeepRecordingAudioSidecars()) {
+ console.log(`[recording] Keeping orphaned native mic sidecar for diagnostics: ${filePath}`);
+ return;
+ }
+
+ await fs.rm(filePath, { force: true }).catch(() => undefined);
+}
+
+async function pathExists(filePath: string | null | undefined) {
+ if (!filePath) {
+ return false;
+ }
+
+ try {
+ await fs.access(filePath);
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+async function resolveExistingPath(...candidates: Array) {
+ for (const candidate of candidates) {
+ if (await pathExists(candidate)) {
+ return candidate ?? null;
+ }
+ }
+
+ return null;
+}
+
+export function registerRecordingHandlers(
+ onRecordingStateChange?: (recording: boolean, sourceName: string) => void,
+) {
+ ipcMain.handle(
+ "start-native-screen-recording",
+ async (_, source: SelectedSource, options?: NativeMacRecordingOptions) => {
+ // Protect the HUD at the capture boundary, before the renderer publishes recording state.
+ beginHudCaptureProtection();
+ const visibleWindowBounds = source.id?.startsWith("window:")
+ ? await bringSelectedWindowForward(source)
+ : null;
+
+ // Windows native capture path
+ if (process.platform === "win32") {
+ const windowsCaptureAvailable = await isNativeWindowsCaptureAvailable();
+ if (!windowsCaptureAvailable) {
+ return {
+ success: false,
+ message: "Native Windows capture is not available on this system.",
+ };
+ }
+
+ if (windowsCaptureProcess && !windowsNativeCaptureActive) {
+ try {
+ windowsCaptureProcess.kill();
+ } catch {
+ /* ignore */
+ }
+ setWindowsCaptureProcess(null);
+ setWindowsCaptureTargetPath(null);
+ setWindowsCaptureStopRequested(false);
+ }
+
+ if (windowsCaptureProcess) {
+ return {
+ success: false,
+ message: "A native Windows screen recording is already active.",
+ };
+ }
+
+ let wcProc: ChildProcessWithoutNullStreams | null = null;
+ let tempVideoPath: string | null = null;
+ let tempSystemAudioPath: string | null = null;
+ let tempMicPath: string | null = null;
+ try {
+ const exePath = getWindowsCaptureExePath();
+ const recordingsDir = await getRecordingsDir();
+ const timestamp = Date.now();
+ const outputPath = path.join(recordingsDir, `recording-${timestamp}.mp4`);
+ tempVideoPath = path.join(
+ app.getPath("temp"),
+ `recordly-native-${timestamp}.mp4`,
+ );
+
+ let captureOutput = "";
+ let systemAudioPath: string | null = null;
+ let microphonePath: string | null = null;
+ let orphanedMicAudioPath: string | null = null;
+
+ const browserMicFallbackRequested =
+ shouldStartWindowsBrowserMicrophoneFallback(options);
+ const captureTarget = resolveWindowsCaptureTarget(
+ source,
+ getScreen().getAllDisplays(),
+ getScreen().getPrimaryDisplay(),
+ );
+ const displayBounds =
+ captureTarget.kind === "display" ? captureTarget.bounds : null;
+ setWindowsOrphanedMicAudioPath(null);
+
+ const config: Record = {
+ outputPath: tempVideoPath,
+ fps: 60,
+ };
+
+ if (captureTarget.kind === "invalid-window") {
+ return {
+ success: false,
+ message:
+ "Selected window is no longer available. Please choose the window again.",
+ };
+ }
+
+ if (captureTarget.kind === "window") {
+ config.windowHandle = captureTarget.windowHandle;
+ } else {
+ // Windows Graphics Capture (WGC) requires a raw HMONITOR handle.
+ // We attempt to resolve the handle by matching the physical coordinates of the target display.
+ const monitors = getMonitorHandles();
+ const matchedMonitor = monitors.find(
+ (monitor) =>
+ monitor.x === Math.round(captureTarget.bounds.x) &&
+ monitor.y === Math.round(captureTarget.bounds.y),
+ );
+
+ if (matchedMonitor) {
+ config.displayId = matchedMonitor.handle;
+ } else {
+ // Fallback to coordinate-based matching if handle resolution fails
+ config.displayId = captureTarget.displayId;
+ }
+
+ config.displayX = Math.round(captureTarget.bounds.x);
+ config.displayY = Math.round(captureTarget.bounds.y);
+ config.displayW = Math.round(captureTarget.bounds.width);
+ config.displayH = Math.round(captureTarget.bounds.height);
+ }
+
+ if (options?.capturesSystemAudio) {
+ systemAudioPath = path.join(
+ recordingsDir,
+ `recording-${timestamp}.system.wav`,
+ );
+ tempSystemAudioPath = path.join(
+ app.getPath("temp"),
+ `recordly-native-${timestamp}.system.wav`,
+ );
+ config.captureSystemAudio = true;
+ config.audioOutputPath = tempSystemAudioPath;
+ setWindowsSystemAudioPath(systemAudioPath);
+ } else {
+ setWindowsSystemAudioPath(null);
+ }
+
+ if (options?.capturesMicrophone && !browserMicFallbackRequested) {
+ microphonePath = path.join(recordingsDir, `recording-${timestamp}.mic.wav`);
+ tempMicPath = path.join(
+ app.getPath("temp"),
+ `recordly-native-${timestamp}.mic.wav`,
+ );
+ config.captureMic = true;
+ config.micOutputPath = tempMicPath;
+ if (options.microphoneDeviceId) {
+ config.micDeviceId = options.microphoneDeviceId;
+ }
+ if (options.microphoneLabel) {
+ config.micDeviceName = options.microphoneLabel;
+ }
+ setWindowsMicAudioPath(microphonePath);
+ } else if (browserMicFallbackRequested) {
+ config.captureMic = false;
+ setWindowsMicAudioPath(null);
+ } else {
+ setWindowsMicAudioPath(null);
+ }
+
+ recordNativeCaptureDiagnostics({
+ backend: "windows-wgc",
+ phase: "start",
+ sourceId: source?.id ?? null,
+ sourceType: source?.sourceType ?? "unknown",
+ displayId: typeof config.displayId === "number" ? config.displayId : null,
+ displayBounds,
+ windowHandle:
+ typeof config.windowHandle === "number" ? config.windowHandle : null,
+ helperPath: exePath,
+ outputPath,
+ systemAudioPath,
+ microphonePath,
+ });
+
+ setWindowsCaptureOutputBuffer("");
+ setWindowsCaptureTargetPath(outputPath);
+ setWindowsCaptureStopRequested(false);
+ setWindowsCapturePaused(false);
+
+ wcProc = spawn(exePath, [JSON.stringify(config)], {
+ cwd: recordingsDir,
+ stdio: ["pipe", "pipe", "pipe"],
+ env: process.env,
+ });
+ setWindowsCaptureProcess(wcProc);
+ attachWindowsCaptureLifecycle(wcProc);
+
+ wcProc.stdout.on("data", (chunk: Buffer) => {
+ const msg = chunk.toString();
+ captureOutput += msg;
+ setWindowsCaptureOutputBuffer(captureOutput);
+ });
+ wcProc.stderr.on("data", (chunk: Buffer) => {
+ const msg = chunk.toString();
+ captureOutput += msg;
+ setWindowsCaptureOutputBuffer(captureOutput);
+ });
+
+ await waitForWindowsCaptureStart(wcProc);
+ const microphoneFallbackRequired =
+ browserMicFallbackRequested ||
+ shouldUseWindowsBrowserMicrophoneFallback(captureOutput, options);
+ if (microphoneFallbackRequired) {
+ orphanedMicAudioPath = tempMicPath ?? microphonePath;
+ setWindowsOrphanedMicAudioPath(orphanedMicAudioPath);
+ microphonePath = null;
+ setWindowsMicAudioPath(null);
+ }
+ setWindowsNativeCaptureActive(true);
+ setNativeScreenRecordingActive(true);
+ recordNativeCaptureDiagnostics({
+ backend: "windows-wgc",
+ phase: "start",
+ sourceId: source?.id ?? null,
+ sourceType: source?.sourceType ?? "unknown",
+ displayId: typeof config.displayId === "number" ? config.displayId : null,
+ displayBounds,
+ windowHandle:
+ typeof config.windowHandle === "number" ? config.windowHandle : null,
+ helperPath: exePath,
+ outputPath,
+ systemAudioPath,
+ microphonePath,
+ processOutput: captureOutput.trim() || undefined,
+ });
+ return { success: true, microphoneFallbackRequired };
+ } catch (error) {
+ recordNativeCaptureDiagnostics({
+ backend: "windows-wgc",
+ phase: "start",
+ sourceId: source?.id ?? null,
+ sourceType: source?.sourceType ?? "unknown",
+ helperPath: windowsCaptureTargetPath ? getWindowsCaptureExePath() : null,
+ outputPath: windowsCaptureTargetPath,
+ systemAudioPath: windowsSystemAudioPath,
+ microphonePath: windowsMicAudioPath,
+ processOutput: windowsCaptureOutputBuffer.trim() || undefined,
+ error: String(error),
+ });
+ console.error("Failed to start native Windows capture:", error);
+ try {
+ if (wcProc) wcProc.kill();
+ } catch {
+ /* ignore */
+ }
+ await Promise.allSettled([
+ tempVideoPath
+ ? fs.rm(tempVideoPath, { force: true }).catch(() => undefined)
+ : Promise.resolve(),
+ tempSystemAudioPath
+ ? fs.rm(tempSystemAudioPath, { force: true }).catch(() => undefined)
+ : Promise.resolve(),
+ tempMicPath
+ ? fs.rm(tempMicPath, { force: true }).catch(() => undefined)
+ : Promise.resolve(),
+ ]);
+ setWindowsNativeCaptureActive(false);
+ setNativeScreenRecordingActive(false);
+ setWindowsCaptureProcess(null);
+ setWindowsCaptureTargetPath(null);
+ setWindowsSystemAudioPath(null);
+ setWindowsMicAudioPath(null);
+ setWindowsOrphanedMicAudioPath(null);
+ setWindowsCaptureStopRequested(false);
+ setWindowsCapturePaused(false);
+ return {
+ success: false,
+ message: "Failed to start native Windows capture",
+ error: String(error),
+ };
+ }
+ }
+
+ if (process.platform !== "darwin") {
+ return {
+ success: false,
+ message: "Native screen recording is only available on macOS.",
+ };
+ }
+
+ if (nativeCaptureProcess && !nativeScreenRecordingActive) {
+ try {
+ nativeCaptureProcess.kill();
+ } catch {
+ // ignore stale helper cleanup failures
+ }
+ setNativeCaptureProcess(null);
+ setNativeCaptureTargetPath(null);
+ setNativeCaptureStopRequested(false);
+ }
+
+ if (nativeCaptureProcess) {
+ return { success: false, message: "A native screen recording is already active." };
+ }
+
+ let captProc: ChildProcessWithoutNullStreams | null = null;
+ try {
+ const recordingsDir = await getRecordingsDir();
+
+ // Warm up TCC: trigger an Electron-level screen capture API call so macOS
+ // activates the screen-recording grant for this process tree before the
+ // native helper binary spawns and calls SCStream.startCapture().
+ try {
+ await desktopCapturer.getSources({
+ types: ["screen"],
+ thumbnailSize: { width: 1, height: 1 },
+ });
+ } catch {
+ // non-fatal – the helper will report its own TCC status
+ }
+
+ // Ensure microphone TCC is granted for this process tree when mic capture
+ // is requested, so the child helper inherits the grant.
+ if (options?.capturesMicrophone) {
+ const micStatus = systemPreferences.getMediaAccessStatus("microphone");
+ if (micStatus !== "granted") {
+ await systemPreferences.askForMediaAccess("microphone");
+ }
+ }
+
+ const appName = normalizeDesktopSourceName(String(source?.appName ?? ""));
+ const ownAppName = normalizeDesktopSourceName(app.getName());
+ if (
+ !ALLOW_RECORDLY_WINDOW_CAPTURE &&
+ source?.id?.startsWith("window:") &&
+ appName &&
+ (appName === ownAppName || appName === "recordly")
+ ) {
+ return {
+ success: false,
+ message:
+ "Cannot record Recordly windows. Please select another app window.",
+ };
+ }
+
+ const helperPath = await ensureNativeCaptureHelperBinary();
+ const timestamp = Date.now();
+ const outputPath = path.join(recordingsDir, `recording-${timestamp}.mp4`);
+ const capturesSystemAudio = Boolean(options?.capturesSystemAudio);
+ const capturesMicrophone = Boolean(options?.capturesMicrophone);
+ const systemAudioOutputPath = capturesSystemAudio
+ ? path.join(recordingsDir, `recording-${timestamp}.system.m4a`)
+ : null;
+ const microphoneOutputPath = capturesMicrophone
+ ? path.join(recordingsDir, `recording-${timestamp}.mic.m4a`)
+ : null;
+ const config: Record = {
+ fps: 60,
+ outputPath,
+ capturesSystemAudio,
+ capturesMicrophone,
+ };
+
+ const excludedProcessIds = getHudCaptureExcludedProcessIds(
+ process.platform,
+ getHudOverlayCaptureProtectionEnabled(),
+ process.pid,
+ );
+ if (excludedProcessIds.length > 0) {
+ config.excludedProcessIds = excludedProcessIds;
+ }
+
+ if (options?.microphoneDeviceId) {
+ config.microphoneDeviceId = options.microphoneDeviceId;
+ }
+
+ if (options?.microphoneLabel) {
+ config.microphoneLabel = options.microphoneLabel;
+ }
+
+ if (systemAudioOutputPath) {
+ config.systemAudioOutputPath = systemAudioOutputPath;
+ }
+
+ if (microphoneOutputPath) {
+ config.microphoneOutputPath = microphoneOutputPath;
+ }
+
+ const windowId = parseWindowId(source?.id);
+ const screenId = Number(source?.display_id);
+
+ if (Number.isFinite(windowId) && windowId && source?.id?.startsWith("window:")) {
+ config.windowId = windowId;
+ if (visibleWindowBounds) {
+ config.windowX = visibleWindowBounds.x;
+ config.windowY = visibleWindowBounds.y;
+ config.windowWidth = visibleWindowBounds.width;
+ config.windowHeight = visibleWindowBounds.height;
+ }
+ } else if (Number.isFinite(screenId) && screenId > 0) {
+ config.displayId = screenId;
+ } else {
+ config.displayId = Number(getScreen().getPrimaryDisplay().id);
+ }
+
+ setNativeCaptureOutputBuffer("");
+ setNativeCaptureTargetPath(outputPath);
+ setNativeCaptureSystemAudioPath(systemAudioOutputPath);
+ setNativeCaptureMicrophonePath(microphoneOutputPath);
+ setNativeCaptureStopRequested(false);
+ setNativeCapturePaused(false);
+ captProc = spawn(helperPath, [JSON.stringify(config)], {
+ cwd: recordingsDir,
+ stdio: ["pipe", "pipe", "pipe"],
+ });
+ setNativeCaptureProcess(captProc);
+ attachNativeCaptureLifecycle(captProc);
+
+ captProc.stdout.on("data", (chunk: Buffer) => {
+ setNativeCaptureOutputBuffer(nativeCaptureOutputBuffer + chunk.toString());
+ });
+ captProc.stderr.on("data", (chunk: Buffer) => {
+ setNativeCaptureOutputBuffer(nativeCaptureOutputBuffer + chunk.toString());
+ });
+
+ await waitForNativeCaptureStart(captProc);
+ setNativeScreenRecordingActive(true);
+
+ // If the native helper reported MICROPHONE_CAPTURE_UNAVAILABLE, it started
+ // capture without microphone. Clear the mic path so the renderer can fall
+ // back to a browser-side sidecar recording for the microphone track.
+ const micUnavailableNatively = nativeCaptureOutputBuffer.includes(
+ "MICROPHONE_CAPTURE_UNAVAILABLE",
+ );
+ if (micUnavailableNatively) {
+ setNativeCaptureMicrophonePath(null);
+ }
+
+ recordNativeCaptureDiagnostics({
+ backend: "mac-screencapturekit",
+ phase: "start",
+ sourceId: source?.id ?? null,
+ sourceType: source?.sourceType ?? "unknown",
+ displayId: typeof config.displayId === "number" ? config.displayId : null,
+ helperPath,
+ outputPath,
+ systemAudioPath: systemAudioOutputPath,
+ microphonePath: nativeCaptureMicrophonePath,
+ processOutput: nativeCaptureOutputBuffer.trim() || undefined,
+ });
+ return {
+ success: true,
+ microphoneFallbackRequired: micUnavailableNatively,
+ };
+ } catch (error) {
+ console.error("Failed to start native ScreenCaptureKit recording:", error);
+ const errorStr = String(error);
+
+ // Detect TCC (screen recording permission) errors and show a helpful dialog
+ if (
+ errorStr.includes("declined TCC") ||
+ errorStr.includes("declined TCCs") ||
+ errorStr.includes("SCREEN_RECORDING_PERMISSION_DENIED")
+ ) {
+ const { response } = await dialog.showMessageBox({
+ type: "warning",
+ title: "Screen Recording Permission Required",
+ message:
+ "Recordly needs screen recording permission to capture your screen.",
+ detail: "Please open System Settings > Privacy & Security > Screen Recording, make sure Recordly is toggled ON, then try recording again.",
+ buttons: ["Open System Settings", "Cancel"],
+ defaultId: 0,
+ cancelId: 1,
+ });
+ if (response === 0) {
+ await shell.openExternal(getMacPrivacySettingsUrl("screen"));
+ }
+ try {
+ if (captProc) captProc.kill();
+ } catch {
+ /* ignore */
+ }
+ setNativeScreenRecordingActive(false);
+ setNativeCaptureProcess(null);
+ setNativeCaptureTargetPath(null);
+ setNativeCaptureSystemAudioPath(null);
+ setNativeCaptureMicrophonePath(null);
+ setNativeCaptureStopRequested(false);
+ setNativeCapturePaused(false);
+ return {
+ success: false,
+ message:
+ "Screen recording permission not granted. Please allow access in System Settings and restart the app.",
+ userNotified: true,
+ };
+ }
+
+ if (errorStr.includes("MICROPHONE_PERMISSION_DENIED")) {
+ const { response } = await dialog.showMessageBox({
+ type: "warning",
+ title: "Microphone Permission Required",
+ message: "Recordly needs microphone permission to record audio.",
+ detail: "Please open System Settings > Privacy & Security > Microphone, make sure Recordly is toggled ON, then try recording again.",
+ buttons: ["Open System Settings", "Cancel"],
+ defaultId: 0,
+ cancelId: 1,
+ });
+ if (response === 0) {
+ await shell.openExternal(getMacPrivacySettingsUrl("microphone"));
+ }
+ try {
+ if (captProc) captProc.kill();
+ } catch {
+ /* ignore */
+ }
+ setNativeScreenRecordingActive(false);
+ setNativeCaptureProcess(null);
+ setNativeCaptureTargetPath(null);
+ setNativeCaptureSystemAudioPath(null);
+ setNativeCaptureMicrophonePath(null);
+ setNativeCaptureStopRequested(false);
+ setNativeCapturePaused(false);
+ return {
+ success: false,
+ message:
+ "Microphone permission not granted. Please allow access in System Settings.",
+ userNotified: true,
+ };
+ }
+
+ recordNativeCaptureDiagnostics({
+ backend: "mac-screencapturekit",
+ phase: "start",
+ sourceId: source?.id ?? null,
+ sourceType: source?.sourceType ?? "unknown",
+ helperPath: getNativeCaptureHelperBinaryPath(),
+ outputPath: nativeCaptureTargetPath,
+ systemAudioPath: nativeCaptureSystemAudioPath,
+ microphonePath: nativeCaptureMicrophonePath,
+ processOutput: nativeCaptureOutputBuffer.trim() || undefined,
+ fileSizeBytes: await getFileSizeIfPresent(nativeCaptureTargetPath),
+ error: String(error),
+ });
+ try {
+ if (captProc) captProc.kill();
+ } catch {
+ // ignore cleanup failures
+ }
+ setNativeScreenRecordingActive(false);
+ setNativeCaptureProcess(null);
+ setNativeCaptureTargetPath(null);
+ setNativeCaptureSystemAudioPath(null);
+ setNativeCaptureMicrophonePath(null);
+ setNativeCaptureStopRequested(false);
+ setNativeCapturePaused(false);
+ return {
+ success: false,
+ message: "Failed to start native ScreenCaptureKit recording",
+ error: String(error),
+ };
+ }
+ },
+ );
+
+ ipcMain.handle("stop-native-screen-recording", async () => {
+ const start = Date.now();
+ console.log("[PERF:MAIN] Handler: stop-native-screen-recording: STARTED");
+ try {
+ // Windows native capture stop path
+ if (process.platform === "win32" && windowsNativeCaptureActive) {
+ let stagedTempVideoPath: string | null = null;
+ let stagedTempSystemAudioPath: string | null = null;
+ let stagedTempMicAudioPath: string | null = null;
+ try {
+ if (!windowsCaptureProcess) {
+ throw new Error("Native Windows capture process is not running");
+ }
+
+ const proc = windowsCaptureProcess;
+ const preferredVideoPath = windowsCaptureTargetPath;
+ const preferredOrphanedMicAudioPath = windowsOrphanedMicAudioPath;
+ const diagnosticsSystemAudioPath = windowsSystemAudioPath;
+ const diagnosticsMicAudioPath = windowsMicAudioPath;
+ setWindowsCaptureStopRequested(true);
+ proc.stdin.write("stop\n");
+ const tempVideoPath = await waitForWindowsCaptureStop(proc);
+ stagedTempVideoPath = tempVideoPath;
+ const finalVideoPath = preferredVideoPath ?? tempVideoPath;
+
+ // Native Windows capture results are initially written to a safe temporary path
+ // (to avoid encoding failures with non-ASCII characters). We move them to the final
+ // destination now using Node.js, which handles Unicode paths correctly.
+ if (tempVideoPath !== finalVideoPath) {
+ await moveFileWithOverwrite(tempVideoPath, finalVideoPath);
+ }
+
+ if (windowsSystemAudioPath && tempVideoPath.endsWith(".mp4")) {
+ const tempAudioPath = tempVideoPath.replace(".mp4", ".system.wav");
+ stagedTempSystemAudioPath = tempAudioPath;
+ const finalAudioPath = windowsSystemAudioPath;
+ if (await pathExists(tempAudioPath)) {
+ await moveFileWithOverwrite(tempAudioPath, finalAudioPath);
+ const tempJson = tempAudioPath + ".json";
+ if (await pathExists(tempJson)) {
+ await moveFileWithOverwrite(tempJson, finalAudioPath + ".json");
+ }
+ }
+ }
+
+ if (windowsMicAudioPath && tempVideoPath.endsWith(".mp4")) {
+ const tempMicPath = tempVideoPath.replace(".mp4", ".mic.wav");
+ stagedTempMicAudioPath = tempMicPath;
+ const finalMicPath = windowsMicAudioPath;
+ if (await pathExists(tempMicPath)) {
+ await moveFileWithOverwrite(tempMicPath, finalMicPath);
+ const tempJson = tempMicPath + ".json";
+ if (await pathExists(tempJson)) {
+ await moveFileWithOverwrite(tempJson, finalMicPath + ".json");
+ }
+ }
+ }
+ const validation = await validateRecordedVideo(finalVideoPath);
+
+ setWindowsCaptureProcess(null);
+ setWindowsNativeCaptureActive(false);
+ setNativeScreenRecordingActive(false);
+ setWindowsCaptureTargetPath(null);
+ setWindowsCaptureStopRequested(false);
+ setWindowsCapturePaused(false);
+ setWindowsOrphanedMicAudioPath(null);
+ await cleanupWindowsOrphanedMicAudioPath(preferredOrphanedMicAudioPath);
+ setWindowsPendingVideoPath(finalVideoPath);
+ recordNativeCaptureDiagnostics({
+ backend: "windows-wgc",
+ phase: "stop",
+ outputPath: finalVideoPath,
+ systemAudioPath: diagnosticsSystemAudioPath,
+ microphonePath: diagnosticsMicAudioPath,
+ processOutput: windowsCaptureOutputBuffer.trim() || undefined,
+ fileSizeBytes: validation.fileSizeBytes,
+ });
+ await writeWindowsRecordingDiagnostics(finalVideoPath, {
+ phase: "stop",
+ outputPath: finalVideoPath,
+ systemAudioPath: diagnosticsSystemAudioPath,
+ microphonePath: diagnosticsMicAudioPath,
+ processOutput: windowsCaptureOutputBuffer.trim() || undefined,
+ details: {
+ fileSizeBytes: validation.fileSizeBytes,
+ durationSeconds: validation.durationSeconds,
+ },
+ });
+
+ // Persist cursor telemetry before returning so the editor can find it immediately
+ snapshotCursorTelemetryForPersistence();
+ try {
+ await persistPendingCursorTelemetry(finalVideoPath);
+ } catch (error) {
+ console.warn(
+ "Failed to persist cursor telemetry during native stop:",
+ error,
+ );
+ }
+
+ return { success: true, path: finalVideoPath };
+ } catch (error) {
+ console.error("Failed to stop native Windows capture:", error);
+ const fallbackPath = await resolveExistingPath(
+ windowsCaptureTargetPath,
+ stagedTempVideoPath,
+ );
+ const recoveredSystemAudioPath = await resolveExistingPath(
+ windowsSystemAudioPath,
+ stagedTempSystemAudioPath,
+ );
+ const recoveredMicAudioPath = await resolveExistingPath(
+ windowsMicAudioPath,
+ stagedTempMicAudioPath,
+ );
+ const fallbackOrphanedMicAudioPath = windowsOrphanedMicAudioPath;
+ const diagnosticsSystemAudioPath =
+ recoveredSystemAudioPath ?? windowsSystemAudioPath;
+ const diagnosticsMicAudioPath = recoveredMicAudioPath ?? windowsMicAudioPath;
+ setWindowsNativeCaptureActive(false);
+ setNativeScreenRecordingActive(false);
+ setWindowsCaptureProcess(null);
+ setWindowsCaptureTargetPath(null);
+ setWindowsCaptureStopRequested(false);
+ setWindowsCapturePaused(false);
+ setWindowsOrphanedMicAudioPath(null);
+
+ if (fallbackPath) {
+ try {
+ const validation = await validateRecordedVideo(fallbackPath);
+ setWindowsPendingVideoPath(fallbackPath);
+ setWindowsSystemAudioPath(recoveredSystemAudioPath);
+ setWindowsMicAudioPath(recoveredMicAudioPath);
+ await cleanupWindowsOrphanedMicAudioPath(fallbackOrphanedMicAudioPath);
+ recordNativeCaptureDiagnostics({
+ backend: "windows-wgc",
+ phase: "stop",
+ outputPath: fallbackPath,
+ systemAudioPath: diagnosticsSystemAudioPath,
+ microphonePath: diagnosticsMicAudioPath,
+ processOutput: windowsCaptureOutputBuffer.trim() || undefined,
+ fileSizeBytes: validation.fileSizeBytes,
+ error: String(error),
+ });
+ await writeWindowsRecordingDiagnostics(fallbackPath, {
+ phase: "stop",
+ outputPath: fallbackPath,
+ systemAudioPath: diagnosticsSystemAudioPath,
+ microphonePath: diagnosticsMicAudioPath,
+ processOutput: windowsCaptureOutputBuffer.trim() || undefined,
+ error: String(error),
+ details: {
+ fileSizeBytes: validation.fileSizeBytes,
+ durationSeconds: validation.durationSeconds,
+ recoveredAfterStopFailure: true,
+ },
+ });
+ return { success: true, path: fallbackPath };
+ } catch {
+ // File is absent or failed validation.
+ }
+ }
+
+ setWindowsSystemAudioPath(null);
+ setWindowsMicAudioPath(null);
+ setWindowsPendingVideoPath(null);
+ await cleanupWindowsOrphanedMicAudioPath(fallbackOrphanedMicAudioPath);
+
+ recordNativeCaptureDiagnostics({
+ backend: "windows-wgc",
+ phase: "stop",
+ outputPath: fallbackPath,
+ systemAudioPath: diagnosticsSystemAudioPath,
+ microphonePath: diagnosticsMicAudioPath,
+ processOutput: windowsCaptureOutputBuffer.trim() || undefined,
+ fileSizeBytes: await getFileSizeIfPresent(fallbackPath),
+ error: String(error),
+ });
+ await writeWindowsRecordingDiagnostics(fallbackPath, {
+ phase: "stop",
+ outputPath: fallbackPath,
+ systemAudioPath: diagnosticsSystemAudioPath,
+ microphonePath: diagnosticsMicAudioPath,
+ processOutput: windowsCaptureOutputBuffer.trim() || undefined,
+ error: String(error),
+ details: {
+ fileSizeBytes: await getFileSizeIfPresent(fallbackPath),
+ },
+ });
+
+ return {
+ success: false,
+ message: "Failed to stop native Windows capture",
+ error: String(error),
+ };
+ }
+ }
+
+ if (process.platform !== "darwin") {
+ return {
+ success: false,
+ message: "Native screen recording is only available on macOS.",
+ };
+ }
+
+ if (!nativeScreenRecordingActive) {
+ const recovered = await recoverNativeMacCaptureOutput();
+ if (recovered) {
+ return recovered;
+ }
+
+ return { success: false, message: "No native screen recording is active." };
+ }
+
+ try {
+ if (!nativeCaptureProcess) {
+ throw new Error("Native capture helper process is not running");
+ }
+
+ const process = nativeCaptureProcess;
+ const preferredVideoPath = nativeCaptureTargetPath;
+ const preferredSystemAudioPath = nativeCaptureSystemAudioPath;
+ const preferredMicrophonePath = nativeCaptureMicrophonePath;
+ console.log(
+ "[stop-native] Audio paths — system:",
+ preferredSystemAudioPath,
+ "mic:",
+ preferredMicrophonePath,
+ );
+ setNativeCaptureStopRequested(true);
+ process.stdin.write("stop\n");
+ const tempVideoPath = await waitForNativeCaptureStop(process);
+ console.log("[stop-native] Helper stopped, tempVideoPath:", tempVideoPath);
+ setNativeCaptureProcess(null);
+ setNativeScreenRecordingActive(false);
+ setNativeCaptureTargetPath(null);
+ setNativeCaptureSystemAudioPath(null);
+ setNativeCaptureMicrophonePath(null);
+ setNativeCaptureStopRequested(false);
+ setNativeCapturePaused(false);
+
+ const finalVideoPath = preferredVideoPath ?? tempVideoPath;
+ if (tempVideoPath !== finalVideoPath) {
+ await moveFileWithOverwrite(tempVideoPath, finalVideoPath);
+ }
+
+ if (preferredSystemAudioPath || preferredMicrophonePath) {
+ console.log(
+ "[stop-native] Attempting audio mux (merging separate tracks) into:",
+ finalVideoPath,
+ );
+ try {
+ await muxNativeMacRecordingWithAudio(
+ finalVideoPath,
+ preferredSystemAudioPath,
+ preferredMicrophonePath,
+ );
+ console.log("[stop-native] Audio mux completed successfully");
+ } catch (error) {
+ console.warn(
+ "[stop-native] Audio mux failed (video still has inline audio):",
+ error,
+ );
+ }
+ } else {
+ console.log("[stop-native] No separate audio tracks to mux");
+ }
+
+ return await finalizeStoredVideo(finalVideoPath);
+ } catch (error) {
+ console.error("Failed to stop native ScreenCaptureKit recording:", error);
+ const fallbackPath = nativeCaptureTargetPath;
+ const fallbackSystemAudioPath = nativeCaptureSystemAudioPath;
+ const fallbackMicrophonePath = nativeCaptureMicrophonePath;
+ const fallbackFileSizeBytes = await getFileSizeIfPresent(fallbackPath);
+ setNativeScreenRecordingActive(false);
+ setNativeCaptureProcess(null);
+ setNativeCaptureTargetPath(null);
+ setNativeCaptureSystemAudioPath(null);
+ setNativeCaptureMicrophonePath(null);
+ setNativeCaptureStopRequested(false);
+ setNativeCapturePaused(false);
+
+ recordNativeCaptureDiagnostics({
+ backend: "mac-screencapturekit",
+ phase: "stop",
+ sourceId: lastNativeCaptureDiagnostics?.sourceId ?? null,
+ sourceType: lastNativeCaptureDiagnostics?.sourceType ?? "unknown",
+ displayId: lastNativeCaptureDiagnostics?.displayId ?? null,
+ displayBounds: lastNativeCaptureDiagnostics?.displayBounds ?? null,
+ windowHandle: lastNativeCaptureDiagnostics?.windowHandle ?? null,
+ helperPath: lastNativeCaptureDiagnostics?.helperPath ?? null,
+ outputPath: fallbackPath,
+ systemAudioPath: fallbackSystemAudioPath,
+ microphonePath: fallbackMicrophonePath,
+ osRelease: lastNativeCaptureDiagnostics?.osRelease,
+ supported: lastNativeCaptureDiagnostics?.supported,
+ helperExists: lastNativeCaptureDiagnostics?.helperExists,
+ processOutput: nativeCaptureOutputBuffer.trim() || undefined,
+ fileSizeBytes: fallbackFileSizeBytes,
+ error: String(error),
+ });
+
+ // Try to recover: if the target file exists on disk, finalize with it
+ if (fallbackPath) {
+ try {
+ await fs.access(fallbackPath);
+ console.log(
+ "[stop-native-screen-recording] Recovering with fallback path:",
+ fallbackPath,
+ );
+ if (fallbackSystemAudioPath || fallbackMicrophonePath) {
+ try {
+ await muxNativeMacRecordingWithAudio(
+ fallbackPath,
+ fallbackSystemAudioPath,
+ fallbackMicrophonePath,
+ );
+ } catch (muxError) {
+ console.warn(
+ "Failed to mux recovered native macOS audio into capture:",
+ muxError,
+ );
+ }
+ }
+ return await finalizeStoredVideo(fallbackPath);
+ } catch {
+ // File doesn't exist or isn't accessible
+ }
+ }
+
+ const recovered = await recoverNativeMacCaptureOutput();
+ if (recovered) {
+ return recovered;
+ }
+
+ return {
+ success: false,
+ message: "Failed to stop native ScreenCaptureKit recording",
+ error: String(error),
+ };
+ }
+ } finally {
+ console.log(
+ `[PERF:MAIN] Handler: stop-native-screen-recording: COMPLETED in ${Date.now() - start}ms`,
+ );
+ }
+ });
+
+ ipcMain.handle("recover-native-screen-recording", async () => {
+ if (process.platform !== "darwin") {
+ return {
+ success: false,
+ message: "Native screen recording recovery is only available on macOS.",
+ };
+ }
+
+ const recovered = await recoverNativeMacCaptureOutput();
+ if (recovered) {
+ return recovered;
+ }
+
+ return {
+ success: false,
+ message: "No recoverable native macOS recording output was found.",
+ };
+ });
+
+ ipcMain.handle("pause-native-screen-recording", async () => {
+ if (process.platform === "win32") {
+ if (!windowsNativeCaptureActive || !windowsCaptureProcess) {
+ return { success: false, message: "No native Windows screen recording is active." };
+ }
+
+ if (windowsCapturePaused) {
+ return { success: true };
+ }
+
+ try {
+ windowsCaptureProcess.stdin.write("pause\n");
+ setWindowsCapturePaused(true);
+ return { success: true };
+ } catch (error) {
+ return {
+ success: false,
+ message: "Failed to pause native Windows capture",
+ error: String(error),
+ };
+ }
+ }
+
+ if (process.platform !== "darwin") {
+ return {
+ success: false,
+ message: "Native screen recording is only available on macOS.",
+ };
+ }
+
+ if (!nativeScreenRecordingActive || !nativeCaptureProcess) {
+ return { success: false, message: "No native screen recording is active." };
+ }
+
+ if (nativeCapturePaused) {
+ return { success: true };
+ }
+
+ try {
+ const commandApplied = waitForNativeCaptureCommand(
+ nativeCaptureProcess,
+ "Recording paused",
+ );
+ nativeCaptureProcess.stdin.write("pause\n");
+ await commandApplied;
+ setNativeCapturePaused(true);
+ return { success: true };
+ } catch (error) {
+ return {
+ success: false,
+ message: "Failed to pause native screen recording",
+ error: String(error),
+ };
+ }
+ });
+
+ ipcMain.handle("resume-native-screen-recording", async () => {
+ if (process.platform === "win32") {
+ if (!windowsNativeCaptureActive || !windowsCaptureProcess) {
+ return { success: false, message: "No native Windows screen recording is active." };
+ }
+
+ if (!windowsCapturePaused) {
+ return { success: true };
+ }
+
+ try {
+ windowsCaptureProcess.stdin.write("resume\n");
+ setWindowsCapturePaused(false);
+ return { success: true };
+ } catch (error) {
+ return {
+ success: false,
+ message: "Failed to resume native Windows capture",
+ error: String(error),
+ };
+ }
+ }
+
+ if (process.platform !== "darwin") {
+ return {
+ success: false,
+ message: "Native screen recording is only available on macOS.",
+ };
+ }
+
+ if (!nativeScreenRecordingActive || !nativeCaptureProcess) {
+ return { success: false, message: "No native screen recording is active." };
+ }
+
+ if (!nativeCapturePaused) {
+ return { success: true };
+ }
+
+ try {
+ const commandApplied = waitForNativeCaptureCommand(
+ nativeCaptureProcess,
+ "Recording resumed",
+ );
+ nativeCaptureProcess.stdin.write("resume\n");
+ await commandApplied;
+ setNativeCapturePaused(false);
+ return { success: true };
+ } catch (error) {
+ return {
+ success: false,
+ message: "Failed to resume native screen recording",
+ error: String(error),
+ };
+ }
+ });
+
+ ipcMain.handle("get-system-cursor-assets", async () => {
+ try {
+ return { success: true, cursors: await getSystemCursorAssets() };
+ } catch (error) {
+ console.error("Failed to load system cursor assets:", error);
+ return { success: false, cursors: {}, error: String(error) };
+ }
+ });
+
+ ipcMain.handle("is-native-windows-capture-available", async () => {
+ return { available: await isNativeWindowsCaptureAvailable() };
+ });
+
+ ipcMain.handle("get-last-native-capture-diagnostics", async () => {
+ return { success: true, diagnostics: lastNativeCaptureDiagnostics };
+ });
+
+ ipcMain.handle("get-video-audio-fallback-paths", async (_event, videoPath: string) => {
+ if (!videoPath) {
+ return { success: true, paths: [], startDelayMsByPath: {} };
+ }
+
+ try {
+ const { paths, startDelayMsByPath } = await getCompanionAudioFallbackInfo(videoPath);
+ await Promise.all([
+ rememberApprovedLocalReadPath(videoPath),
+ ...paths.map((fallbackPath) => rememberApprovedLocalReadPath(fallbackPath)),
+ ]);
+ return { success: true, paths, startDelayMsByPath };
+ } catch (error) {
+ console.error("Failed to resolve companion audio fallback paths:", error);
+ return { success: false, paths: [], startDelayMsByPath: {}, error: String(error) };
+ }
+ });
+
+ ipcMain.handle("mux-native-windows-recording", async (_event, expectedDurationMs?: number) => {
+ const start = Date.now();
+ console.log("[PERF:MAIN] Handler: mux-native-windows-recording: STARTED");
+ try {
+ const videoPath = windowsPendingVideoPath;
+ const orphanedMicAudioPath = windowsOrphanedMicAudioPath;
+ const diagnosticsSystemAudioPath = windowsSystemAudioPath;
+ const diagnosticsMicAudioPath = windowsMicAudioPath;
+ setWindowsPendingVideoPath(null);
+ setWindowsOrphanedMicAudioPath(null);
+
+ if (!videoPath) {
+ return { success: false, message: "No native Windows video pending for mux" };
+ }
+
+ try {
+ await writeWindowsRecordingDiagnostics(videoPath, {
+ phase: "mux-start",
+ expectedDurationMs,
+ outputPath: videoPath,
+ systemAudioPath: diagnosticsSystemAudioPath,
+ microphonePath: diagnosticsMicAudioPath,
+ details: {
+ hasSystemAudio: Boolean(diagnosticsSystemAudioPath),
+ hasMicrophone: Boolean(diagnosticsMicAudioPath),
+ hasOrphanedMicrophone: Boolean(orphanedMicAudioPath),
+ },
+ });
+ console.log("[mux-win] Optimization active: skipping video padding.");
+
+ let muxDetails: unknown = null;
+ if (diagnosticsSystemAudioPath || diagnosticsMicAudioPath) {
+ muxDetails = await muxNativeWindowsVideoWithAudio(
+ videoPath,
+ diagnosticsSystemAudioPath,
+ diagnosticsMicAudioPath,
+ );
+ setWindowsSystemAudioPath(null);
+ setWindowsMicAudioPath(null);
+ }
+
+ recordNativeCaptureDiagnostics({
+ backend: "windows-wgc",
+ phase: "mux",
+ outputPath: videoPath,
+ fileSizeBytes: await getFileSizeIfPresent(videoPath),
+ });
+ await writeWindowsRecordingDiagnostics(videoPath, {
+ phase: "mux-complete",
+ expectedDurationMs,
+ outputPath: videoPath,
+ systemAudioPath: diagnosticsSystemAudioPath,
+ microphonePath: diagnosticsMicAudioPath,
+ details: {
+ fileSizeBytes: await getFileSizeIfPresent(videoPath),
+ mux: muxDetails,
+ },
+ });
+ await cleanupWindowsOrphanedMicAudioPath(orphanedMicAudioPath);
+ return await finalizeStoredVideo(videoPath);
+ } catch (error) {
+ console.error("Failed to mux native Windows recording:", error);
+ recordNativeCaptureDiagnostics({
+ backend: "windows-wgc",
+ phase: "mux",
+ outputPath: videoPath,
+ systemAudioPath: diagnosticsSystemAudioPath,
+ microphonePath: diagnosticsMicAudioPath,
+ fileSizeBytes: await getFileSizeIfPresent(videoPath),
+ error: String(error),
+ });
+ await writeWindowsRecordingDiagnostics(videoPath, {
+ phase: "mux-error",
+ expectedDurationMs,
+ outputPath: videoPath,
+ systemAudioPath: diagnosticsSystemAudioPath,
+ microphonePath: diagnosticsMicAudioPath,
+ error: String(error),
+ details: {
+ fileSizeBytes: await getFileSizeIfPresent(videoPath),
+ },
+ });
+ setWindowsSystemAudioPath(null);
+ setWindowsMicAudioPath(null);
+ await cleanupWindowsOrphanedMicAudioPath(orphanedMicAudioPath);
+ try {
+ return await finalizeStoredVideo(videoPath);
+ } catch {
+ try {
+ await validateRecordedVideo(videoPath);
+ return {
+ success: false,
+ path: videoPath,
+ message: "Failed to mux native Windows recording",
+ error: String(error),
+ };
+ } catch {
+ // The fallback path is not safely playable; surface the original mux error.
+ }
+
+ return {
+ success: false,
+ message: "Failed to mux native Windows recording",
+ error: String(error),
+ };
+ }
+ }
+ } finally {
+ console.log(
+ `[PERF:MAIN] Handler: mux-native-windows-recording: COMPLETED in ${Date.now() - start}ms`,
+ );
+ }
+ });
+
+ ipcMain.handle("start-ffmpeg-recording", async (_, source: SelectedSource) => {
+ if (ffmpegCaptureProcess) {
+ return { success: false, message: "An FFmpeg recording is already active." };
+ }
+
+ try {
+ const recordingsDir = await getRecordingsDir();
+ const ffmpegPath = getFfmpegBinaryPath();
+ const outputPath = path.join(recordingsDir, `recording-${Date.now()}.mp4`);
+ const args = await buildFfmpegCaptureArgs(source, outputPath);
+
+ setFfmpegCaptureOutputBuffer("");
+ setFfmpegCaptureTargetPath(outputPath);
+ const ffProc = spawn(ffmpegPath, args, {
+ cwd: recordingsDir,
+ stdio: ["pipe", "pipe", "pipe"],
+ });
+ setFfmpegCaptureProcess(ffProc);
+
+ ffProc.stdout.on("data", (chunk: Buffer) => {
+ setFfmpegCaptureOutputBuffer(ffmpegCaptureOutputBuffer + chunk.toString());
+ });
+ ffProc.stderr.on("data", (chunk: Buffer) => {
+ setFfmpegCaptureOutputBuffer(ffmpegCaptureOutputBuffer + chunk.toString());
+ });
+
+ await waitForFfmpegCaptureStart(ffProc);
+ setFfmpegScreenRecordingActive(true);
+ return { success: true };
+ } catch (error) {
+ console.error("Failed to start FFmpeg recording:", error);
+ setFfmpegScreenRecordingActive(false);
+ setFfmpegCaptureProcess(null);
+ setFfmpegCaptureTargetPath(null);
+ return {
+ success: false,
+ message: "Failed to start FFmpeg recording",
+ error: String(error),
+ };
+ }
+ });
+
+ ipcMain.handle("stop-ffmpeg-recording", async () => {
+ if (!ffmpegScreenRecordingActive) {
+ return { success: false, message: "No FFmpeg recording is active." };
+ }
+
+ try {
+ if (!ffmpegCaptureProcess || !ffmpegCaptureTargetPath) {
+ throw new Error("FFmpeg process is not running");
+ }
+
+ const process = ffmpegCaptureProcess;
+ const outputPath = ffmpegCaptureTargetPath;
+ process.stdin.write("q\n");
+ const finalVideoPath = await waitForFfmpegCaptureStop(process, outputPath);
+
+ setFfmpegCaptureProcess(null);
+ setFfmpegCaptureTargetPath(null);
+ setFfmpegScreenRecordingActive(false);
+
+ return await finalizeStoredVideo(finalVideoPath);
+ } catch (error) {
+ console.error("Failed to stop FFmpeg recording:", error);
+ try {
+ ffmpegCaptureProcess?.kill();
+ } catch {
+ // ignore cleanup failures
+ }
+ setFfmpegCaptureProcess(null);
+ setFfmpegCaptureTargetPath(null);
+ setFfmpegScreenRecordingActive(false);
+ return {
+ success: false,
+ message: "Failed to stop FFmpeg recording",
+ error: String(error),
+ };
+ }
+ });
+
+ ipcMain.handle(
+ "store-microphone-sidecar",
+ async (
+ _,
+ audioData: ArrayBuffer,
+ videoPath: string,
+ options?: {
+ startDelayMs?: number;
+ browserMicrophoneProfile?: string;
+ requestedBrowserMicrophoneProfile?: string | null;
+ requestedConstraints?: unknown;
+ mediaTrackSettings?: Record;
+ audioInputDevices?: unknown;
+ mediaRecorder?: unknown;
+ chunkEvents?: unknown;
+ pauseIntervals?: unknown;
+ },
+ ) => {
+ const baseName = videoPath.replace(/\.[^.]+$/, "");
+ const sidecarPath = `${baseName}.mic.wav`;
+ const sourceWebmPath = `${baseName}.mic.source.webm`;
+ const tempWebmPath = `${sourceWebmPath}.tmp`;
+
+ try {
+ await fs.writeFile(tempWebmPath, Buffer.from(audioData));
+ await execFileAsync(
+ getFfmpegBinaryPath(),
+ [
+ "-y",
+ "-hide_banner",
+ "-nostdin",
+ "-nostats",
+ "-i",
+ tempWebmPath,
+ "-vn",
+ "-ac",
+ "1",
+ "-ar",
+ "48000",
+ "-af",
+ [
+ ...getBrowserMicSidecarFilters(options?.browserMicrophoneProfile),
+ "aresample=async=1:first_pts=0",
+ ].join(","),
+ "-c:a",
+ "pcm_s16le",
+ sidecarPath,
+ ],
+ { timeout: 120000, maxBuffer: 10 * 1024 * 1024 },
+ );
+ if (shouldKeepRecordingAudioSidecars()) {
+ await fs.rename(tempWebmPath, sourceWebmPath).catch(async () => {
+ await fs.copyFile(tempWebmPath, sourceWebmPath);
+ await fs.rm(tempWebmPath, { force: true });
+ });
+ } else {
+ await fs.rm(tempWebmPath, { force: true });
+ }
+ const startDelayMs = options?.startDelayMs;
+ const mediaTrackSettings = pickPrimitiveRecord(options?.mediaTrackSettings);
+ const audioInputDevices = pickAudioInputDevices(options?.audioInputDevices);
+ const mediaRecorder = isRecord(options?.mediaRecorder)
+ ? {
+ ...(typeof options.mediaRecorder.mimeType === "string"
+ ? { mimeType: options.mediaRecorder.mimeType }
+ : {}),
+ ...(typeof options.mediaRecorder.audioBitsPerSecond === "number"
+ ? {
+ audioBitsPerSecond: Math.round(
+ options.mediaRecorder.audioBitsPerSecond,
+ ),
+ }
+ : {}),
+ ...(typeof options.mediaRecorder.timesliceMs === "number"
+ ? { timesliceMs: Math.round(options.mediaRecorder.timesliceMs) }
+ : {}),
+ }
+ : null;
+ const chunkEvents = pickMicrophoneChunkEvents(options?.chunkEvents);
+ const pauseIntervals = pickMicrophonePauseIntervals(options?.pauseIntervals);
+ const chunkTiming =
+ chunkEvents || pauseIntervals
+ ? summarizeMicrophoneChunkTiming(
+ chunkEvents,
+ pauseIntervals,
+ mediaRecorder?.timesliceMs,
+ )
+ : null;
+ const metadata = {
+ ...(Number.isFinite(startDelayMs) && (startDelayMs ?? 0) >= 0
+ ? { startDelayMs: Math.round(startDelayMs ?? 0) }
+ : {}),
+ ...(typeof options?.browserMicrophoneProfile === "string"
+ ? { browserMicrophoneProfile: options.browserMicrophoneProfile }
+ : {}),
+ ...(typeof options?.requestedBrowserMicrophoneProfile === "string"
+ ? {
+ requestedBrowserMicrophoneProfile:
+ options.requestedBrowserMicrophoneProfile,
+ }
+ : {}),
+ ...(isRecord(options?.requestedConstraints)
+ ? { requestedConstraints: options.requestedConstraints }
+ : {}),
+ ...(mediaTrackSettings ? { mediaTrackSettings } : {}),
+ ...(audioInputDevices ? { audioInputDevices } : {}),
+ ...(mediaRecorder && Object.keys(mediaRecorder).length > 0
+ ? { mediaRecorder }
+ : {}),
+ ...(chunkEvents ? { chunkEvents } : {}),
+ ...(pauseIntervals ? { pauseIntervals } : {}),
+ ...(chunkTiming ? { chunkTiming } : {}),
+ };
+ if (Object.keys(metadata).length > 0) {
+ try {
+ await fs.writeFile(`${sidecarPath}.json`, JSON.stringify(metadata));
+ } catch (metadataError) {
+ console.warn(
+ "Failed to store microphone sidecar timing metadata:",
+ metadataError,
+ );
+ }
+ }
+ await writeRecordingDiagnosticsSnapshot(videoPath, {
+ backend: "browser-store",
+ phase: "mic-sidecar",
+ outputPath: videoPath,
+ microphonePath: sidecarPath,
+ details: {
+ sourceBytes: audioData.byteLength,
+ sourceWebmPath: shouldKeepRecordingAudioSidecars() ? sourceWebmPath : null,
+ metadata,
+ },
+ }).catch((diagnosticsError) => {
+ console.warn(
+ "Failed to write microphone sidecar diagnostics:",
+ diagnosticsError,
+ );
+ });
+ return { success: true, path: sidecarPath };
+ } catch (error) {
+ await Promise.all([
+ fs.rm(tempWebmPath, { force: true }).catch(() => undefined),
+ fs.rm(sidecarPath, { force: true }).catch(() => undefined),
+ ]);
+ console.error("Failed to store microphone sidecar:", error);
+ return { success: false, error: String(error) };
+ }
+ },
+ );
+
+ ipcMain.handle("store-recorded-video", async (_, videoData: ArrayBuffer, fileName: unknown) => {
+ try {
+ const recordingsDir = await getRecordingsDir();
+ const videoPath = resolveRecordedVideoStoragePath(recordingsDir, fileName);
+ await fs.writeFile(videoPath, Buffer.from(videoData));
+ if (isWebcamCompanionRecordingPath(videoPath)) {
+ // The webcam companion is stored before the screen recording. Finalizing it
+ // would persist (and clear) the session's cursor telemetry next to the webcam
+ // file, leaving the screen recording without cursor data.
+ await validateRecordedVideo(videoPath);
+ return { success: true, path: videoPath };
+ }
+ return await finalizeStoredVideo(videoPath);
+ } catch (error) {
+ console.error("Failed to store video:", error);
+ return {
+ success: false,
+ message: "Failed to store video",
+ error: String(error),
+ };
+ }
+ });
+
+ ipcMain.handle("get-recorded-video-path", async () => {
+ try {
+ const recordingsDir = await getRecordingsDir();
+ const entries = await fs.readdir(recordingsDir, { withFileTypes: true });
+ const candidates = await Promise.all(
+ entries
+ .filter(
+ (entry) =>
+ entry.isFile() && /^recording-\d+\.(webm|mov|mp4)$/i.test(entry.name),
+ )
+ .map(async (entry) => {
+ const fullPath = path.join(recordingsDir, entry.name);
+ const stat = await fs.stat(fullPath).catch(() => null);
+ return stat ? { path: fullPath, mtimeMs: stat.mtimeMs } : null;
+ }),
+ );
+ const sortedCandidates = candidates
+ .filter(
+ (candidate): candidate is { path: string; mtimeMs: number } =>
+ candidate !== null,
+ )
+ .sort((left, right) => right.mtimeMs - left.mtimeMs);
+
+ for (const candidate of sortedCandidates) {
+ try {
+ await validateRecordedVideo(candidate.path);
+ return { success: true, path: candidate.path };
+ } catch (error) {
+ console.warn(
+ "Skipping unusable recovered recording candidate:",
+ candidate.path,
+ error,
+ );
+ }
+ }
+
+ if (sortedCandidates.length === 0) {
+ return { success: false, message: "No recorded video found" };
+ }
+
+ return { success: false, message: "No usable recorded video found" };
+ } catch (error) {
+ console.error("Failed to get video path:", error);
+ return { success: false, message: "Failed to get video path", error: String(error) };
+ }
+ });
+
+ ipcMain.handle("set-recording-state", (_, recording: boolean) => {
+ if (recording) {
+ stopCursorCapture();
+ stopInteractionCapture();
+ startWindowBoundsCapture();
+ void startNativeCursorMonitor();
+ setIsCursorCaptureActive(true);
+ setActiveCursorSamples([]);
+ setPendingCursorSamples([]);
+ setCursorCaptureStartTimeMs(Date.now());
+ resetCursorCaptureClock();
+ setLinuxCursorScreenPoint(null);
+ setLastLeftClick(null);
+ sampleCursorPoint();
+ startCursorSampling();
+ void startInteractionCapture();
+ } else {
+ setIsCursorCaptureActive(false);
+ stopCursorCapture();
+ stopInteractionCapture();
+ stopWindowBoundsCapture();
+ stopNativeCursorMonitor();
+ showCursor();
+ setLinuxCursorScreenPoint(null);
+ resetCursorCaptureClock();
+ snapshotCursorTelemetryForPersistence();
+ setActiveCursorSamples([]);
+ }
+
+ const source = selectedSource || { name: "Screen" };
+ BrowserWindow.getAllWindows().forEach((window) => {
+ if (!window.isDestroyed()) {
+ window.webContents.send("recording-state-changed", {
+ recording,
+ sourceName: source.name,
+ });
+ }
+ });
+
+ if (onRecordingStateChange) {
+ onRecordingStateChange(recording, source.name);
+ }
+ });
+
+ ipcMain.handle("pause-cursor-capture", (_, pausedAtMs?: unknown) => {
+ pauseCursorCaptureAtBoundary(normalizeRendererTimestampMs(pausedAtMs));
+ return { success: true };
+ });
+
+ ipcMain.handle("resume-cursor-capture", (_, resumedAtMs?: unknown) => {
+ resumeCursorCapture(normalizeRendererTimestampMs(resumedAtMs));
+ sampleCursorPoint();
+ return { success: true };
+ });
+
+ ipcMain.handle("get-cursor-telemetry", async (_, videoPath?: string) => {
+ const targetVideoPath = normalizeVideoSourcePath(videoPath ?? currentVideoPath);
+ if (!targetVideoPath) {
+ return { success: true, samples: [] };
+ }
+
+ const telemetryPath = getTelemetryPathForVideo(targetVideoPath);
+ try {
+ const content = await fs.readFile(telemetryPath, "utf-8");
+ const parsed = parseJsonWithByteOrderMark(content);
+ const samples = normalizeCursorTelemetrySamples(parsed);
+
+ return { success: true, samples };
+ } catch (error) {
+ const nodeError = error as NodeJS.ErrnoException;
+ if (nodeError.code === "ENOENT") {
+ return { success: true, samples: [] };
+ }
+ console.error("Failed to load cursor telemetry:", error);
+ return {
+ success: false,
+ message: "Failed to load cursor telemetry",
+ error: String(error),
+ samples: [],
+ };
+ }
+ });
+
+ ipcMain.handle(
+ "set-cursor-telemetry",
+ async (_, videoPath: string | undefined, samples: CursorTelemetryPoint[]) => {
+ const targetVideoPath = normalizeVideoSourcePath(videoPath ?? currentVideoPath);
+ if (!targetVideoPath) {
+ return {
+ success: false,
+ samples: [],
+ message: "No video path available for cursor telemetry",
+ error: "Missing video path",
+ };
+ }
+
+ try {
+ const normalizedSamples = await writeCursorTelemetry(targetVideoPath, samples);
+ return { success: true, samples: normalizedSamples };
+ } catch (error) {
+ console.error("Failed to save cursor telemetry:", error);
+ return {
+ success: false,
+ samples: [],
+ message: "Failed to save cursor telemetry",
+ error: String(error),
+ };
+ }
+ },
+ );
+}
diff --git a/electron/ipc/register/settings.ts b/electron/ipc/register/settings.ts
new file mode 100644
index 000000000..065ed7070
--- /dev/null
+++ b/electron/ipc/register/settings.ts
@@ -0,0 +1,213 @@
+import { createCountdownController } from "../../countdownController";
+import fs from "node:fs/promises";
+import { app, BrowserWindow, ipcMain } from "electron";
+import { hasAppSetting, readAppSettingsStore, writeAppSettingsStore } from "../../appSettingsStore";
+import { hideCursor } from "../../cursorHider";
+import { createCountdownWindow } from "../../windows";
+import { COUNTDOWN_SETTINGS_FILE, RECORDINGS_SETTINGS_FILE, SHORTCUTS_FILE } from "../constants";
+import {
+ createRecordingPreferencesStore,
+ type RecordingPreferencesPatch,
+} from "../settings/recordingPreferencesStore";
+import {
+ countdownInProgress,
+ countdownRemaining,
+ setCountdownInProgress,
+ setCountdownRemaining,
+} from "../state";
+import { parseJsonWithByteOrderMark } from "../utils";
+
+const BROWSER_MICROPHONE_PROFILE_ENV = "RECORDLY_BROWSER_MIC_PROFILE";
+const DEFAULT_BROWSER_MICROPHONE_PROFILE = "processed";
+const recordingPreferencesStore = createRecordingPreferencesStore(RECORDINGS_SETTINGS_FILE);
+const BROWSER_MICROPHONE_PROFILES = new Set([
+ "processed",
+ "no-agc",
+ "no-echo",
+ "no-noise-suppression",
+ "raw",
+]);
+
+function getBrowserMicrophoneProfileFromEnv() {
+ const requested = process.env[BROWSER_MICROPHONE_PROFILE_ENV]?.trim() || null;
+ const normalized = requested?.toLowerCase() ?? DEFAULT_BROWSER_MICROPHONE_PROFILE;
+ return {
+ browserMicrophoneProfile: BROWSER_MICROPHONE_PROFILES.has(normalized)
+ ? normalized
+ : DEFAULT_BROWSER_MICROPHONE_PROFILE,
+ requestedBrowserMicrophoneProfile: requested,
+ };
+}
+
+export function registerSettingsHandlers() {
+ ipcMain.handle(
+ "get-window-fullscreen",
+ (event) => BrowserWindow.fromWebContents(event.sender)?.isFullScreen() ?? false,
+ );
+ ipcMain.handle("app:getVersion", () => {
+ return app.getVersion();
+ });
+
+ ipcMain.handle("get-window-chrome", (event) => {
+ const win = BrowserWindow.fromWebContents(event.sender);
+ return {
+ trafficLightsVisible:
+ process.platform === "darwin" &&
+ !!win &&
+ !win.isFullScreen() &&
+ !win.isSimpleFullScreen(),
+ };
+ });
+
+ ipcMain.handle("get-platform", () => {
+ return process.platform;
+ });
+
+ ipcMain.on("app-settings:get", (event, key: unknown) => {
+ try {
+ if (typeof key !== "string" || key.length === 0) {
+ event.returnValue = { success: false, value: null };
+ return;
+ }
+
+ const store = readAppSettingsStore();
+ event.returnValue = {
+ success: true,
+ value: hasAppSetting(store, key) ? store[key] : null,
+ };
+ } catch (error) {
+ console.error("Failed to read app setting:", error);
+ event.returnValue = { success: false, value: null };
+ }
+ });
+
+ ipcMain.on("app-settings:set", (event, key: unknown, value: unknown) => {
+ try {
+ if (typeof key !== "string" || key.length === 0) {
+ event.returnValue = { success: false };
+ return;
+ }
+
+ const store = readAppSettingsStore();
+ store[key] = value;
+ writeAppSettingsStore(store);
+ event.returnValue = { success: true };
+ } catch (error) {
+ console.error("Failed to save app setting:", error);
+ event.returnValue = { success: false };
+ }
+ });
+
+ // ---------------------------------------------------------------------------
+ // Cursor hiding for the browser-capture fallback.
+ // The IPC promise resolves only after the cursor hide attempt completes.
+ // ---------------------------------------------------------------------------
+ ipcMain.handle("hide-cursor", () => {
+ if (process.platform !== "win32") {
+ return { success: true };
+ }
+
+ return { success: hideCursor() };
+ });
+
+ ipcMain.handle("get-shortcuts", async () => {
+ try {
+ const data = await fs.readFile(SHORTCUTS_FILE, "utf-8");
+ return parseJsonWithByteOrderMark(data);
+ } catch {
+ return null;
+ }
+ });
+
+ ipcMain.handle("save-shortcuts", async (_, shortcuts: unknown) => {
+ try {
+ await fs.writeFile(SHORTCUTS_FILE, JSON.stringify(shortcuts, null, 2), "utf-8");
+ return { success: true };
+ } catch (error) {
+ console.error("Failed to save shortcuts:", error);
+ return { success: false, error: String(error) };
+ }
+ });
+
+ // ---------------------------------------------------------------------------
+ // Countdown timer before recording
+ // ---------------------------------------------------------------------------
+ ipcMain.handle("get-recording-preferences", async () => {
+ try {
+ const parsed = await recordingPreferencesStore.read();
+ return {
+ success: true,
+ microphoneEnabled: parsed.microphoneEnabled === true,
+ microphoneDeviceId:
+ typeof parsed.microphoneDeviceId === "string"
+ ? parsed.microphoneDeviceId
+ : undefined,
+ systemAudioEnabled: parsed.systemAudioEnabled === true,
+ webcamEnabled: parsed.webcamEnabled === true,
+ webcamDeviceId:
+ typeof parsed.webcamDeviceId === "string" ? parsed.webcamDeviceId : undefined,
+ };
+ } catch {
+ return {
+ success: true,
+ microphoneEnabled: false,
+ microphoneDeviceId: undefined,
+ systemAudioEnabled: false,
+ webcamEnabled: false,
+ webcamDeviceId: undefined,
+ };
+ }
+ });
+
+ ipcMain.handle("get-recording-audio-lab-config", () => {
+ return getBrowserMicrophoneProfileFromEnv();
+ });
+
+ ipcMain.handle("set-recording-preferences", async (_, prefs: RecordingPreferencesPatch) => {
+ try {
+ await recordingPreferencesStore.update(prefs);
+ return { success: true };
+ } catch (error) {
+ console.error("Failed to save recording preferences:", error);
+ return { success: false, error: String(error) };
+ }
+ });
+
+ ipcMain.handle("get-countdown-delay", async () => {
+ try {
+ const content = await fs.readFile(COUNTDOWN_SETTINGS_FILE, "utf-8");
+ const parsed = parseJsonWithByteOrderMark<{ delay?: number }>(content);
+ return { success: true, delay: parsed.delay ?? 3 };
+ } catch {
+ return { success: true, delay: 3 };
+ }
+ });
+
+ ipcMain.handle("set-countdown-delay", async (_, delay: number) => {
+ try {
+ await fs.writeFile(
+ COUNTDOWN_SETTINGS_FILE,
+ JSON.stringify({ delay }, null, 2),
+ "utf-8",
+ );
+ return { success: true };
+ } catch (error) {
+ console.error("Failed to save countdown delay:", error);
+ return { success: false, error: String(error) };
+ }
+ });
+
+ const countdown = createCountdownController(createCountdownWindow, (remaining) => {
+ setCountdownRemaining(remaining);
+ setCountdownInProgress(remaining !== null);
+ });
+ ipcMain.handle("start-countdown", (_, seconds: number) => countdown.start(seconds));
+ ipcMain.handle("cancel-countdown", () => countdown.cancel());
+
+ ipcMain.handle("get-active-countdown", () => {
+ return {
+ success: true,
+ seconds: countdownInProgress ? countdownRemaining : null,
+ };
+ });
+}
diff --git a/electron/ipc/register/sourceMapping.test.ts b/electron/ipc/register/sourceMapping.test.ts
new file mode 100644
index 000000000..84351a506
--- /dev/null
+++ b/electron/ipc/register/sourceMapping.test.ts
@@ -0,0 +1,47 @@
+import { describe, expect, it } from "vitest";
+
+import { getScreenSourceIdForDisplay, LINUX_PORTAL_SCREEN_SOURCE_ID } from "./sourceMapping";
+
+describe("getScreenSourceIdForDisplay", () => {
+ it("keeps the live Electron screen source when one is available", () => {
+ expect(
+ getScreenSourceIdForDisplay({
+ displayId: "42",
+ matchedSourceId: "screen:42:0",
+ platform: "linux",
+ }),
+ ).toBe("screen:42:0");
+ });
+
+ it("routes unmatched Linux Wayland screens through the portal sentinel", () => {
+ expect(
+ getScreenSourceIdForDisplay({
+ displayId: "42",
+ env: { XDG_SESSION_TYPE: "wayland", WAYLAND_DISPLAY: "wayland-0" },
+ matchedSourceId: null,
+ platform: "linux",
+ }),
+ ).toBe(LINUX_PORTAL_SCREEN_SOURCE_ID);
+ });
+
+ it("keeps unmatched Linux X11 screens on the explicit fallback id", () => {
+ expect(
+ getScreenSourceIdForDisplay({
+ displayId: "42",
+ env: { XDG_SESSION_TYPE: "x11", DISPLAY: ":0" },
+ matchedSourceId: null,
+ platform: "linux",
+ }),
+ ).toBe("screen:fallback:42");
+ });
+
+ it("keeps non-Linux unmatched screens on the explicit fallback id", () => {
+ expect(
+ getScreenSourceIdForDisplay({
+ displayId: "42",
+ matchedSourceId: undefined,
+ platform: "win32",
+ }),
+ ).toBe("screen:fallback:42");
+ });
+});
diff --git a/electron/ipc/register/sourceMapping.ts b/electron/ipc/register/sourceMapping.ts
new file mode 100644
index 000000000..8b13501a2
--- /dev/null
+++ b/electron/ipc/register/sourceMapping.ts
@@ -0,0 +1,35 @@
+export const LINUX_PORTAL_SCREEN_SOURCE_ID = "screen:linux-portal";
+
+export function isLikelyLinuxWaylandSession(env: NodeJS.ProcessEnv) {
+ const sessionType = env.XDG_SESSION_TYPE?.trim().toLowerCase();
+ if (sessionType === "wayland") {
+ return true;
+ }
+ if (sessionType === "x11") {
+ return false;
+ }
+
+ return Boolean(env.WAYLAND_DISPLAY);
+}
+
+export function getScreenSourceIdForDisplay({
+ displayId,
+ env = process.env,
+ matchedSourceId,
+ platform,
+}: {
+ displayId: string;
+ env?: NodeJS.ProcessEnv;
+ matchedSourceId?: string | null;
+ platform: NodeJS.Platform | string;
+}) {
+ if (matchedSourceId) {
+ return matchedSourceId;
+ }
+
+ if (platform === "linux" && isLikelyLinuxWaylandSession(env)) {
+ return LINUX_PORTAL_SCREEN_SOURCE_ID;
+ }
+
+ return `screen:fallback:${displayId}`;
+}
diff --git a/electron/ipc/register/sources.ts b/electron/ipc/register/sources.ts
new file mode 100644
index 000000000..e5b7cd0b0
--- /dev/null
+++ b/electron/ipc/register/sources.ts
@@ -0,0 +1,623 @@
+import { createRecordingEditorNavigation } from "../../recordingEditorNavigation";
+import { execFile } from "node:child_process";
+import { promisify } from "node:util";
+import { app, BrowserWindow, desktopCapturer, ipcMain, systemPreferences } from "electron";
+import {
+ setHudRecordingPreparationActive,
+ createHudOverlayWindow,
+ getHudOverlayWindow,
+ reassertHudOverlayMousePassthrough,
+} from "../../windows";
+import { ALLOW_RECORDLY_WINDOW_CAPTURE } from "../constants";
+import {
+ getNativeMacWindowSources,
+ resolveLinuxWindowBounds,
+ resolveMacWindowBounds,
+ stopWindowBoundsCapture,
+} from "../cursor/bounds";
+import { getDisplayBoundsForSource, getDisplayWorkAreaForSource } from "../recording/ffmpeg";
+import { selectedSource, setSelectedSource } from "../state";
+import type { SelectedSource, WindowBounds } from "../types";
+import { getScreen, parseWindowId } from "../utils";
+import { bringWindowsWindowForward, resolveWindowsWindowBounds } from "../windowsWindowControl";
+import { getScreenSourceIdForDisplay } from "./sourceMapping";
+
+const execFileAsync = promisify(execFile);
+const SOURCE_LIST_CACHE_TTL_MS = 1200;
+let sourceListCache: {
+ key: string;
+ expiresAt: number;
+ value: Array>;
+} | null = null;
+
+function normalizeDesktopSourceName(value: string) {
+ return value.trim().replace(/\s+/g, " ").toLowerCase();
+}
+
+function broadcastSelectedSourceChange() {
+ for (const window of BrowserWindow.getAllWindows()) {
+ if (!window.isDestroyed()) {
+ window.webContents.send("selected-source-changed", selectedSource);
+ }
+ }
+}
+
+export async function bringSelectedWindowForward(
+ source: SelectedSource,
+): Promise {
+ const windowId = parseWindowId(source.id);
+ if (!windowId) return null;
+
+ try {
+ if (process.platform === "darwin") {
+ const rawAppName = source.appName || source.name?.split(" — ")[0]?.trim();
+ const appName =
+ rawAppName && /^[\w .&()+'-]{1,64}$/.test(rawAppName) ? rawAppName : null;
+ if (!appName) return null;
+ await execFileAsync("open", ["-a", appName], { timeout: 2000 });
+ try {
+ systemPreferences?.isTrustedAccessibilityClient?.(true);
+ const { stdout } = await execFileAsync(
+ "osascript",
+ [
+ "-e",
+ "on run argv",
+ "-e",
+ 'tell application "System Events" to tell process (item 1 of argv)',
+ "-e",
+ "repeat with candidate in windows",
+ "-e",
+ "try",
+ "-e",
+ 'if value of attribute "AXWindowNumber" of candidate is (item 2 of argv) as integer then',
+ "-e",
+ 'perform action "AXRaise" of candidate',
+ "-e",
+ "set windowPosition to position of candidate",
+ "-e",
+ "set windowSize to size of candidate",
+ "-e",
+ 'return ((item 1 of windowPosition) as text) & "," & ((item 2 of windowPosition) as text) & "," & ((item 1 of windowSize) as text) & "," & ((item 2 of windowSize) as text)',
+ "-e",
+ "end if",
+ "-e",
+ "end try",
+ "-e",
+ "end repeat",
+ "-e",
+ "end tell",
+ "-e",
+ "end run",
+ "--",
+ appName,
+ String(windowId),
+ ],
+ { timeout: 2000 },
+ );
+ const [x, y, width, height] = stdout.trim().split(",").map(Number);
+ if ([x, y, width, height].every(Number.isFinite) && width > 0 && height > 0) {
+ await new Promise((resolve) => setTimeout(resolve, 250));
+ return { x, y, width, height };
+ }
+ } catch {
+ // App activation still works without macOS Accessibility permission.
+ }
+ } else if (process.platform === "win32") {
+ await bringWindowsWindowForward(windowId);
+ } else if (process.platform === "linux") {
+ await execFileAsync("wmctrl", ["-i", "-a", `0x${windowId.toString(16)}`], {
+ timeout: 1500,
+ });
+ }
+ await new Promise((resolve) => setTimeout(resolve, 250));
+ } catch {
+ // Raising the source is best-effort; selection and capture can still continue.
+ }
+ return null;
+}
+
+export function registerSourceHandlers({
+ createEditorWindow,
+ createSourceSelectorWindow,
+ getSourceSelectorWindow,
+}: {
+ createEditorWindow: () => void;
+ createSourceSelectorWindow: () => BrowserWindow;
+ getSourceSelectorWindow: () => BrowserWindow | null;
+}) {
+ const recordingNavigation = createRecordingEditorNavigation(createEditorWindow);
+ ipcMain.handle("get-sources", async (_, opts) => {
+ const cacheKey = JSON.stringify({
+ types: opts?.types,
+ thumbnailSize: opts?.thumbnailSize,
+ fetchWindowIcons: opts?.fetchWindowIcons,
+ });
+ if (
+ sourceListCache &&
+ sourceListCache.key === cacheKey &&
+ sourceListCache.expiresAt > Date.now()
+ ) {
+ return sourceListCache.value;
+ }
+
+ const includeScreens = Array.isArray(opts?.types) ? opts.types.includes("screen") : true;
+ const includeWindows = Array.isArray(opts?.types) ? opts.types.includes("window") : true;
+ const includeWindowIcons = Boolean(opts?.fetchWindowIcons);
+ const electronTypes = [
+ ...(includeScreens ? ["screen" as const] : []),
+ ...(includeWindows ? ["window" as const] : []),
+ ];
+ const electronSources =
+ electronTypes.length > 0
+ ? await desktopCapturer
+ .getSources({
+ ...opts,
+ types: electronTypes,
+ })
+ .catch((error) => {
+ console.warn(
+ "desktopCapturer.getSources failed (screen recording permission may be missing):",
+ error,
+ );
+ return [];
+ })
+ : [];
+ const ownWindowNames = new Set(
+ [
+ app.getName(),
+ "Recordly",
+ ...BrowserWindow.getAllWindows().flatMap((win) => {
+ const title = win.getTitle().trim();
+ return title ? [title] : [];
+ }),
+ ]
+ .map((name) => normalizeDesktopSourceName(name))
+ .filter(Boolean),
+ );
+ const ownAppName = normalizeDesktopSourceName(app.getName());
+
+ const displays = includeScreens
+ ? [...getScreen().getAllDisplays()].sort(
+ (left, right) =>
+ left.bounds.x - right.bounds.x ||
+ left.bounds.y - right.bounds.y ||
+ left.id - right.id,
+ )
+ : [];
+ const primaryDisplayId = includeScreens ? String(getScreen().getPrimaryDisplay().id) : "";
+ const electronScreenSourcesByDisplayId = new Map(
+ electronSources
+ .filter((source) => source.id.startsWith("screen:"))
+ .map((source) => [String(source.display_id ?? ""), source] as const),
+ );
+ // On Linux, desktopCapturer display_id values may not match screen.getAllDisplays() IDs.
+ // Keep an ordered list so we can fall back to position-based matching.
+ const electronScreenSourcesByIndex = electronSources.filter((source) =>
+ source.id.startsWith("screen:"),
+ );
+
+ const screenSources = displays.map((display, index) => {
+ const displayId = String(display.id);
+ const matchedSource =
+ electronScreenSourcesByDisplayId.get(displayId) ??
+ (electronScreenSourcesByIndex.length === displays.length
+ ? electronScreenSourcesByIndex[index]
+ : undefined);
+ const displayName =
+ displayId === primaryDisplayId
+ ? `Screen ${index + 1} (Primary)`
+ : `Screen ${index + 1}`;
+
+ return {
+ id: getScreenSourceIdForDisplay({
+ displayId,
+ env: process.env,
+ matchedSourceId: matchedSource?.id,
+ platform: process.platform,
+ }),
+ name: displayName,
+ originalName: matchedSource?.name ?? displayName,
+ display_id: displayId,
+ thumbnail: matchedSource?.thumbnail ? matchedSource.thumbnail.toDataURL() : null,
+ appIcon: null,
+ sourceType: "screen" as const,
+ };
+ });
+
+ if (process.platform !== "darwin" || !includeWindows) {
+ const windowSources = electronSources
+ .filter((source) => source.id.startsWith("window:"))
+ .filter((source) => {
+ const normalizedName = normalizeDesktopSourceName(source.name);
+ if (!normalizedName) {
+ return true;
+ }
+
+ if (ALLOW_RECORDLY_WINDOW_CAPTURE && normalizedName.includes("recordly")) {
+ return true;
+ }
+
+ for (const ownName of ownWindowNames) {
+ if (!ownName) continue;
+ if (normalizedName === ownName) {
+ return false;
+ }
+ }
+
+ return true;
+ })
+ .map((source) => ({
+ id: source.id,
+ name: source.name,
+ originalName: source.name,
+ display_id: source.display_id,
+ thumbnail: source.thumbnail ? source.thumbnail.toDataURL() : null,
+ appIcon:
+ includeWindowIcons && source.appIcon ? source.appIcon.toDataURL() : null,
+ sourceType: "window" as const,
+ }));
+ const result = [...screenSources, ...windowSources];
+ sourceListCache = {
+ key: cacheKey,
+ expiresAt: Date.now() + SOURCE_LIST_CACHE_TTL_MS,
+ value: result,
+ };
+ return result;
+ }
+
+ try {
+ const nativeWindowSources = await getNativeMacWindowSources();
+ const electronWindowSourceMap = new Map(
+ electronSources
+ .filter((source) => source.id.startsWith("window:"))
+ .map((source) => [source.id, source] as const),
+ );
+
+ const mergedWindowSources = nativeWindowSources
+ .filter((source) => {
+ const normalizedWindowName = normalizeDesktopSourceName(
+ source.windowTitle ?? source.name,
+ );
+ const normalizedAppName = normalizeDesktopSourceName(source.appName ?? "");
+
+ if (
+ !ALLOW_RECORDLY_WINDOW_CAPTURE &&
+ normalizedAppName &&
+ normalizedAppName === ownAppName
+ ) {
+ return false;
+ }
+
+ if (
+ ALLOW_RECORDLY_WINDOW_CAPTURE &&
+ (normalizedAppName === "recordly" ||
+ normalizedWindowName?.includes("recordly"))
+ ) {
+ return true;
+ }
+
+ if (!normalizedWindowName) {
+ return true;
+ }
+
+ for (const ownName of ownWindowNames) {
+ if (!ownName) continue;
+ if (normalizedWindowName === ownName) {
+ return false;
+ }
+ }
+
+ return true;
+ })
+ .map((source) => {
+ const electronWindowSource = electronWindowSourceMap.get(source.id);
+ return {
+ id: source.id,
+ name: source.name,
+ originalName: source.name,
+ display_id: source.display_id ?? electronWindowSource?.display_id ?? "",
+ thumbnail: electronWindowSource?.thumbnail
+ ? electronWindowSource.thumbnail.toDataURL()
+ : null,
+ appIcon: includeWindowIcons
+ ? (source.appIcon ??
+ (electronWindowSource?.appIcon
+ ? electronWindowSource.appIcon.toDataURL()
+ : null))
+ : null,
+ appName: source.appName,
+ windowTitle: source.windowTitle,
+ bundleId: source.bundleId,
+ sourceType: "window" as const,
+ };
+ });
+
+ const result = [...screenSources, ...mergedWindowSources];
+ sourceListCache = {
+ key: cacheKey,
+ expiresAt: Date.now() + SOURCE_LIST_CACHE_TTL_MS,
+ value: result,
+ };
+ return result;
+ } catch (error) {
+ console.warn("Falling back to Electron window enumeration on macOS:", error);
+
+ const windowSources = electronSources
+ .filter((source) => source.id.startsWith("window:"))
+ .filter((source) => {
+ const normalizedName = normalizeDesktopSourceName(source.name);
+ if (!normalizedName) {
+ return true;
+ }
+
+ if (ALLOW_RECORDLY_WINDOW_CAPTURE && normalizedName.includes("recordly")) {
+ return true;
+ }
+
+ for (const ownName of ownWindowNames) {
+ if (!ownName) continue;
+ if (
+ normalizedName === ownName ||
+ normalizedName.includes(ownName) ||
+ ownName.includes(normalizedName)
+ ) {
+ return false;
+ }
+ }
+
+ return true;
+ })
+ .map((source) => ({
+ id: source.id,
+ name: source.name,
+ originalName: source.name,
+ display_id: source.display_id,
+ thumbnail: source.thumbnail ? source.thumbnail.toDataURL() : null,
+ appIcon:
+ includeWindowIcons && source.appIcon ? source.appIcon.toDataURL() : null,
+ sourceType: "window" as const,
+ }));
+
+ const result = [...screenSources, ...windowSources];
+ sourceListCache = {
+ key: cacheKey,
+ expiresAt: Date.now() + SOURCE_LIST_CACHE_TTL_MS,
+ value: result,
+ };
+ return result;
+ }
+ });
+
+ ipcMain.handle("select-source", async (_, source: SelectedSource) => {
+ if (source.id?.startsWith("window:")) {
+ await bringSelectedWindowForward(source);
+ }
+ setSelectedSource(source);
+ broadcastSelectedSourceChange();
+ stopWindowBoundsCapture();
+ const sourceSelectorWin = getSourceSelectorWindow();
+ if (sourceSelectorWin) {
+ sourceSelectorWin.close();
+ }
+ app.focus({ steal: true });
+ return selectedSource;
+ });
+
+ ipcMain.handle("show-source-highlight", async (_, source: SelectedSource) => {
+ try {
+ const isWindow = source.id?.startsWith("window:");
+
+ // ── 1. Resolve bounds ──
+ let bounds: { x: number; y: number; width: number; height: number } | null = null;
+
+ if (source.id?.startsWith("screen:")) {
+ bounds =
+ process.platform === "darwin"
+ ? getDisplayWorkAreaForSource(source)
+ : getDisplayBoundsForSource(source);
+ } else if (isWindow) {
+ if (process.platform === "darwin") {
+ bounds = await resolveMacWindowBounds(source);
+ } else if (process.platform === "win32") {
+ bounds = await resolveWindowsWindowBounds(source);
+ } else if (process.platform === "linux") {
+ bounds = await resolveLinuxWindowBounds(source);
+ }
+ }
+
+ // A window highlight must never silently become a fullscreen highlight.
+ // If HWND bounds cannot be resolved, skip the animation and report the
+ // failure so the next selection can retry with the same window source.
+ if (isWindow && (!bounds || bounds.width <= 0 || bounds.height <= 0)) {
+ console.warn("Unable to resolve selected window bounds for highlight", {
+ sourceId: source.id,
+ platform: process.platform,
+ });
+ return { success: false };
+ }
+
+ if (!bounds || bounds.width <= 0 || bounds.height <= 0) {
+ bounds = getDisplayBoundsForSource(source);
+ }
+
+ if (!bounds || bounds.width <= 0 || bounds.height <= 0) {
+ const primaryBounds = getScreen().getPrimaryDisplay().bounds;
+ if (primaryBounds.width <= 0 || primaryBounds.height <= 0) {
+ return { success: false };
+ }
+ bounds = primaryBounds;
+ }
+
+ const resolvedBounds = bounds;
+
+ // ── 2. Show traveling wave highlight ──
+ // On macOS, screen highlights use workArea and no outward padding —
+ // macOS clamps window positions below the menu bar so outward
+ // padding only works on the left/top while right/bottom run off-screen.
+ const isScreen = source.id?.startsWith("screen:");
+ const isMacScreen = isScreen && process.platform === "darwin";
+ const pad = isMacScreen ? 0 : 6;
+ const highlightWin = new BrowserWindow({
+ x: Math.round(resolvedBounds.x - pad),
+ y: Math.round(resolvedBounds.y - pad),
+ width: Math.max(1, Math.round(resolvedBounds.width + pad * 2)),
+ height: Math.max(1, Math.round(resolvedBounds.height + pad * 2)),
+ frame: false,
+ transparent: true,
+ alwaysOnTop: true,
+ skipTaskbar: true,
+ hasShadow: false,
+ resizable: false,
+ focusable: false,
+ show: false,
+ ...(process.platform === "darwin" ? { type: "panel" as const } : {}),
+ webPreferences: { nodeIntegration: false, contextIsolation: true },
+ });
+
+ highlightWin.setIgnoreMouseEvents(true);
+ highlightWin.setAlwaysOnTop(true, "screen-saver");
+ if (process.platform === "darwin") {
+ highlightWin.setVisibleOnAllWorkspaces(true, {
+ visibleOnFullScreen: true,
+ skipTransformProcessType: true,
+ });
+ }
+
+ const borderRadius = isMacScreen ? 0 : 10;
+ const glowInset = isMacScreen ? 0 : -4;
+ const glowRadius = isMacScreen ? 0 : 14;
+ const glowPad = isMacScreen ? 3 : 6;
+
+ const html = `
+
+
+
+`;
+
+ try {
+ await highlightWin.loadURL(
+ `data:text/html;charset=utf-8,${encodeURIComponent(html)}`,
+ );
+ highlightWin.showInactive();
+ } catch (loadError) {
+ if (!highlightWin.isDestroyed()) {
+ highlightWin.close();
+ }
+ throw loadError;
+ }
+
+ // The highlight window appearing (even with focusable:false) can corrupt
+ // the WS_EX_TRANSPARENT flag on the HUD on Windows 11+, breaking hover
+ // detection until the user moves their mouse over the bar again.
+ // Re-assert passthrough immediately so click-through is restored at once.
+ reassertHudOverlayMousePassthrough();
+
+ const highlightCloseTimer = setTimeout(() => {
+ if (!highlightWin.isDestroyed()) highlightWin.close();
+ }, 1700);
+
+ highlightWin.on("closed", () => {
+ clearTimeout(highlightCloseTimer);
+ // Re-assert once more when the window is actually destroyed so the
+ // native flag is clean regardless of timing.
+ reassertHudOverlayMousePassthrough();
+ });
+
+ return { success: true };
+ } catch (error) {
+ console.error("Failed to show source highlight:", error);
+ return { success: false };
+ }
+ });
+
+ ipcMain.handle("get-selected-source", () => {
+ return selectedSource;
+ });
+
+ ipcMain.handle("open-source-selector", () => {
+ const sourceSelectorWin = getSourceSelectorWindow();
+ if (sourceSelectorWin) {
+ sourceSelectorWin.focus();
+ return;
+ }
+ createSourceSelectorWindow();
+ });
+ ipcMain.handle("show-recording-hud", (event) => {
+ setHudRecordingPreparationActive(true);
+ recordingNavigation.setReturnWindow(BrowserWindow.fromWebContents(event.sender));
+ const hud = getHudOverlayWindow();
+ if (hud && !hud.isDestroyed()) {
+ hud.show();
+ hud.focus();
+ } else {
+ createHudOverlayWindow();
+ }
+ });
+ ipcMain.handle("show-project-dashboard", () => {
+ setHudRecordingPreparationActive(false);
+ recordingNavigation.open(false);
+ });
+ ipcMain.handle("switch-to-editor", () => {
+ setHudRecordingPreparationActive(false);
+ console.log("[switch-to-editor] Opening editor window");
+ const sourceSelectorWin = getSourceSelectorWindow();
+ if (sourceSelectorWin && !sourceSelectorWin.isDestroyed()) {
+ sourceSelectorWin.close();
+ }
+ recordingNavigation.open();
+ });
+}
diff --git a/electron/ipc/settings/recordingPreferencesStore.test.ts b/electron/ipc/settings/recordingPreferencesStore.test.ts
new file mode 100644
index 000000000..9caaeaae8
--- /dev/null
+++ b/electron/ipc/settings/recordingPreferencesStore.test.ts
@@ -0,0 +1,46 @@
+import fs from "node:fs/promises";
+import os from "node:os";
+import path from "node:path";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { createRecordingPreferencesStore } from "./recordingPreferencesStore";
+
+vi.mock("electron", () => ({
+ app: {
+ getPath: () => "",
+ },
+}));
+
+const temporaryDirectories: string[] = [];
+
+afterEach(async () => {
+ await Promise.all(
+ temporaryDirectories.splice(0).map((directory) =>
+ fs.rm(directory, {
+ recursive: true,
+ force: true,
+ }),
+ ),
+ );
+});
+
+describe("recording preferences store", () => {
+ it("preserves concurrent microphone and webcam preference updates", async () => {
+ const directory = await fs.mkdtemp(path.join(os.tmpdir(), "recordly-preferences-"));
+ temporaryDirectories.push(directory);
+ const store = createRecordingPreferencesStore(path.join(directory, "recording.json"));
+
+ await Promise.all([
+ store.update({ microphoneEnabled: true }),
+ store.update({ microphoneDeviceId: "preferred-mic" }),
+ store.update({ webcamEnabled: true }),
+ store.update({ webcamDeviceId: "preferred-camera" }),
+ ]);
+
+ await expect(store.read()).resolves.toEqual({
+ microphoneEnabled: true,
+ microphoneDeviceId: "preferred-mic",
+ webcamEnabled: true,
+ webcamDeviceId: "preferred-camera",
+ });
+ });
+});
diff --git a/electron/ipc/settings/recordingPreferencesStore.ts b/electron/ipc/settings/recordingPreferencesStore.ts
new file mode 100644
index 000000000..26eced169
--- /dev/null
+++ b/electron/ipc/settings/recordingPreferencesStore.ts
@@ -0,0 +1,45 @@
+import fs from "node:fs/promises";
+import { parseJsonWithByteOrderMark } from "../utils";
+
+export interface RecordingPreferencesPatch {
+ microphoneEnabled?: boolean;
+ microphoneDeviceId?: string;
+ systemAudioEnabled?: boolean;
+ webcamEnabled?: boolean;
+ webcamDeviceId?: string;
+}
+
+export function createRecordingPreferencesStore(filePath: string) {
+ let operationQueue: Promise = Promise.resolve();
+
+ const readFile = async (): Promise> => {
+ try {
+ const content = await fs.readFile(filePath, "utf-8");
+ const parsed = parseJsonWithByteOrderMark(content);
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed)
+ ? (parsed as Record)
+ : {};
+ } catch {
+ return {};
+ }
+ };
+
+ return {
+ async read(): Promise> {
+ await operationQueue;
+ return readFile();
+ },
+ async update(patch: RecordingPreferencesPatch): Promise {
+ const operation = operationQueue.then(async () => {
+ const existing = await readFile();
+ await fs.writeFile(
+ filePath,
+ JSON.stringify({ ...existing, ...patch }, null, 2),
+ "utf-8",
+ );
+ });
+ operationQueue = operation.catch(() => undefined);
+ await operation;
+ },
+ };
+}
diff --git a/electron/ipc/state.ts b/electron/ipc/state.ts
new file mode 100644
index 000000000..a0a41744e
--- /dev/null
+++ b/electron/ipc/state.ts
@@ -0,0 +1,293 @@
+import type { ChildProcessWithoutNullStreams } from "node:child_process";
+import type {
+ CursorInteractionType,
+ CursorTelemetryPoint,
+ CursorVisualType,
+ NativeCaptureDiagnostics,
+ RecordingSessionData,
+ SelectedSource,
+ SystemCursorAsset,
+ WindowBounds,
+} from "./types";
+
+// ── Source selection ──────────────────────────────────────────────────────────
+export let selectedSource: SelectedSource | null = null;
+
+// ── Project / video state ─────────────────────────────────────────────────────
+export let currentProjectPath: string | null = null;
+export let currentVideoPath: string | null = null;
+export let currentRecordingSession: RecordingSessionData | null = null;
+
+// ── Security: approved read paths ─────────────────────────────────────────────
+export const approvedLocalReadPaths = new Set();
+
+// ── Native macOS capture ──────────────────────────────────────────────────────
+export let nativeScreenRecordingActive = false;
+export let nativeCaptureProcess: ChildProcessWithoutNullStreams | null = null;
+export let nativeCaptureOutputBuffer = "";
+export let nativeCaptureTargetPath: string | null = null;
+export let nativeCaptureStopRequested = false;
+export let nativeCaptureSystemAudioPath: string | null = null;
+export let nativeCaptureMicrophonePath: string | null = null;
+export let nativeCapturePaused = false;
+
+// ── Native cursor monitor ─────────────────────────────────────────────────────
+export let nativeCursorMonitorProcess: ChildProcessWithoutNullStreams | null = null;
+export let nativeCursorMonitorOutputBuffer = "";
+
+// ── Windows native capture ────────────────────────────────────────────────────
+export let windowsCaptureProcess: ChildProcessWithoutNullStreams | null = null;
+export let windowsCaptureOutputBuffer = "";
+export let windowsCaptureTargetPath: string | null = null;
+export let windowsNativeCaptureActive = false;
+export let windowsCaptureStopRequested = false;
+export let windowsCapturePaused = false;
+export let windowsSystemAudioPath: string | null = null;
+export let windowsMicAudioPath: string | null = null;
+export let windowsOrphanedMicAudioPath: string | null = null;
+export let windowsPendingVideoPath: string | null = null;
+
+// ── Diagnostics ───────────────────────────────────────────────────────────────
+export let lastNativeCaptureDiagnostics: NativeCaptureDiagnostics | null = null;
+
+// ── FFmpeg capture ────────────────────────────────────────────────────────────
+export let ffmpegScreenRecordingActive = false;
+export let ffmpegCaptureProcess: ChildProcessWithoutNullStreams | null = null;
+export let ffmpegCaptureOutputBuffer = "";
+export let ffmpegCaptureTargetPath: string | null = null;
+
+// ── Recordings directory ──────────────────────────────────────────────────────
+export let customRecordingsDir: string | null = null;
+export let recordingsDirLoaded = false;
+
+// ── System cursor assets cache ────────────────────────────────────────────────
+export let cachedSystemCursorAssets: Record | null = null;
+export let cachedSystemCursorAssetsSourceMtimeMs: number | null = null;
+
+// ── Countdown ─────────────────────────────────────────────────────────────────
+export let countdownTimer: ReturnType | null = null;
+export let countdownCancelled = false;
+export let countdownInProgress = false;
+export let countdownRemaining: number | null = null;
+
+// ── Cursor visual type ────────────────────────────────────────────────────────
+export let currentCursorVisualType: CursorVisualType | undefined = undefined;
+
+// ── Cursor telemetry ──────────────────────────────────────────────────────────
+export let cursorCaptureInterval: NodeJS.Timeout | null = null;
+export let cursorCaptureStartTimeMs = 0;
+export let cursorCaptureAccumulatedPausedMs = 0;
+export let cursorCapturePauseStartedAtMs: number | null = null;
+export let activeCursorSamples: CursorTelemetryPoint[] = [];
+export let pendingCursorSamples: CursorTelemetryPoint[] = [];
+export let isCursorCaptureActive = false;
+export let interactionCaptureCleanup: (() => void) | null = null;
+export let hasLoggedInteractionHookFailure = false;
+export let lastLeftClick: { timeMs: number; cx: number; cy: number } | null = null;
+export let linuxCursorScreenPoint: { x: number; y: number; updatedAt: number } | null = null;
+export let selectedWindowBounds: WindowBounds | null = null;
+export let windowBoundsCaptureInterval: NodeJS.Timeout | null = null;
+
+// ── Native macOS window source cache ─────────────────────────────────────────
+export let cachedNativeMacWindowSources: import("./types").NativeMacWindowSource[] | null = null;
+export let cachedNativeMacWindowSourcesAtMs = 0;
+
+// ── Native video export ───────────────────────────────────────────────────────
+export let cachedNativeVideoEncoder: {
+ ffmpegPath: string;
+ encodingMode: string;
+ encoderName: string;
+} | null = null;
+
+// ── Native helper migration ───────────────────────────────────────────────────
+export let nativeHelperMigrationPromise: Promise | null = null;
+
+// ── Cursor interaction capture types ─────────────────────────────────────────
+export type { CursorInteractionType, CursorTelemetryPoint };
+
+// ── Setters (for modules that need to reassign exported lets) ─────────────────
+// TypeScript exported `let` can be reassigned by the owning module but importers
+// cannot assign to them directly. Provide simple setters for cross-module writes.
+
+export function setSelectedSource(v: SelectedSource | null) {
+ selectedSource = v;
+}
+export function setCurrentProjectPath(v: string | null) {
+ currentProjectPath = v;
+}
+export function setCurrentVideoPath(v: string | null) {
+ currentVideoPath = v;
+}
+export function setCurrentRecordingSession(v: RecordingSessionData | null) {
+ currentRecordingSession = v;
+}
+
+export function setNativeScreenRecordingActive(v: boolean) {
+ nativeScreenRecordingActive = v;
+}
+export function setNativeCaptureProcess(v: ChildProcessWithoutNullStreams | null) {
+ nativeCaptureProcess = v;
+}
+export function setNativeCaptureOutputBuffer(v: string) {
+ nativeCaptureOutputBuffer = v;
+}
+export function setNativeCaptureTargetPath(v: string | null) {
+ nativeCaptureTargetPath = v;
+}
+export function setNativeCaptureStopRequested(v: boolean) {
+ nativeCaptureStopRequested = v;
+}
+export function setNativeCaptureSystemAudioPath(v: string | null) {
+ nativeCaptureSystemAudioPath = v;
+}
+export function setNativeCaptureMicrophonePath(v: string | null) {
+ nativeCaptureMicrophonePath = v;
+}
+export function setNativeCapturePaused(v: boolean) {
+ nativeCapturePaused = v;
+}
+
+export function setNativeCursorMonitorProcess(v: ChildProcessWithoutNullStreams | null) {
+ nativeCursorMonitorProcess = v;
+}
+export function setNativeCursorMonitorOutputBuffer(v: string) {
+ nativeCursorMonitorOutputBuffer = v;
+}
+
+export function setWindowsCaptureProcess(v: ChildProcessWithoutNullStreams | null) {
+ windowsCaptureProcess = v;
+}
+export function setWindowsCaptureOutputBuffer(v: string) {
+ windowsCaptureOutputBuffer = v;
+}
+export function setWindowsCaptureTargetPath(v: string | null) {
+ windowsCaptureTargetPath = v;
+}
+export function setWindowsNativeCaptureActive(v: boolean) {
+ windowsNativeCaptureActive = v;
+}
+export function setWindowsCaptureStopRequested(v: boolean) {
+ windowsCaptureStopRequested = v;
+}
+export function setWindowsCapturePaused(v: boolean) {
+ windowsCapturePaused = v;
+}
+export function setWindowsSystemAudioPath(v: string | null) {
+ windowsSystemAudioPath = v;
+}
+export function setWindowsMicAudioPath(v: string | null) {
+ windowsMicAudioPath = v;
+}
+export function setWindowsOrphanedMicAudioPath(v: string | null) {
+ windowsOrphanedMicAudioPath = v;
+}
+export function setWindowsPendingVideoPath(v: string | null) {
+ windowsPendingVideoPath = v;
+}
+
+export function setLastNativeCaptureDiagnostics(v: NativeCaptureDiagnostics | null) {
+ lastNativeCaptureDiagnostics = v;
+}
+
+export function setFfmpegScreenRecordingActive(v: boolean) {
+ ffmpegScreenRecordingActive = v;
+}
+export function setFfmpegCaptureProcess(v: ChildProcessWithoutNullStreams | null) {
+ ffmpegCaptureProcess = v;
+}
+export function setFfmpegCaptureOutputBuffer(v: string) {
+ ffmpegCaptureOutputBuffer = v;
+}
+export function setFfmpegCaptureTargetPath(v: string | null) {
+ ffmpegCaptureTargetPath = v;
+}
+
+export function setCustomRecordingsDir(v: string | null) {
+ customRecordingsDir = v;
+}
+export function setRecordingsDirLoaded(v: boolean) {
+ recordingsDirLoaded = v;
+}
+
+export function setCachedSystemCursorAssets(v: Record | null) {
+ cachedSystemCursorAssets = v;
+}
+export function setCachedSystemCursorAssetsSourceMtimeMs(v: number | null) {
+ cachedSystemCursorAssetsSourceMtimeMs = v;
+}
+
+export function setCountdownTimer(v: ReturnType | null) {
+ countdownTimer = v;
+}
+export function setCountdownCancelled(v: boolean) {
+ countdownCancelled = v;
+}
+export function setCountdownInProgress(v: boolean) {
+ countdownInProgress = v;
+}
+export function setCountdownRemaining(v: number | null) {
+ countdownRemaining = v;
+}
+
+export function setCurrentCursorVisualType(v: CursorVisualType | undefined) {
+ currentCursorVisualType = v;
+}
+
+export function setCursorCaptureInterval(v: NodeJS.Timeout | null) {
+ cursorCaptureInterval = v;
+}
+export function setCursorCaptureStartTimeMs(v: number) {
+ cursorCaptureStartTimeMs = v;
+}
+export function setCursorCaptureAccumulatedPausedMs(v: number) {
+ cursorCaptureAccumulatedPausedMs = v;
+}
+export function setCursorCapturePauseStartedAtMs(v: number | null) {
+ cursorCapturePauseStartedAtMs = v;
+}
+export function setActiveCursorSamples(v: CursorTelemetryPoint[]) {
+ activeCursorSamples = v;
+}
+export function setPendingCursorSamples(v: CursorTelemetryPoint[]) {
+ pendingCursorSamples = v;
+}
+export function setIsCursorCaptureActive(v: boolean) {
+ isCursorCaptureActive = v;
+}
+export function setInteractionCaptureCleanup(v: (() => void) | null) {
+ interactionCaptureCleanup = v;
+}
+export function setHasLoggedInteractionHookFailure(v: boolean) {
+ hasLoggedInteractionHookFailure = v;
+}
+export function setLastLeftClick(v: { timeMs: number; cx: number; cy: number } | null) {
+ lastLeftClick = v;
+}
+export function setLinuxCursorScreenPoint(v: { x: number; y: number; updatedAt: number } | null) {
+ linuxCursorScreenPoint = v;
+}
+export function setSelectedWindowBounds(v: WindowBounds | null) {
+ selectedWindowBounds = v;
+}
+export function setWindowBoundsCaptureInterval(v: NodeJS.Timeout | null) {
+ windowBoundsCaptureInterval = v;
+}
+
+export function setCachedNativeMacWindowSources(
+ v: import("./types").NativeMacWindowSource[] | null,
+) {
+ cachedNativeMacWindowSources = v;
+}
+export function setCachedNativeMacWindowSourcesAtMs(v: number) {
+ cachedNativeMacWindowSourcesAtMs = v;
+}
+
+export function setCachedNativeVideoEncoder(
+ v: { ffmpegPath: string; encodingMode: string; encoderName: string } | null,
+) {
+ cachedNativeVideoEncoder = v;
+}
+
+export function setNativeHelperMigrationPromise(v: Promise | null) {
+ nativeHelperMigrationPromise = v;
+}
diff --git a/electron/ipc/types.ts b/electron/ipc/types.ts
new file mode 100644
index 000000000..7f0221364
--- /dev/null
+++ b/electron/ipc/types.ts
@@ -0,0 +1,204 @@
+export type SelectedSource = {
+ id?: string;
+ name: string;
+ display_id?: string;
+ sourceType?: "screen" | "window";
+ appName?: string;
+ windowTitle?: string;
+ [key: string]: unknown;
+};
+
+export type NativeMacRecordingOptions = {
+ capturesSystemAudio?: boolean;
+ capturesMicrophone?: boolean;
+ microphoneDeviceId?: string;
+ microphoneLabel?: string;
+};
+
+export type WindowBounds = {
+ x: number;
+ y: number;
+ width: number;
+ height: number;
+};
+
+export type NativeCaptureDiagnostics = {
+ backend: "windows-wgc" | "mac-screencapturekit" | "browser-store" | "ffmpeg";
+ phase: "availability" | "start" | "stop" | "mux";
+ timestamp: string;
+ sourceId?: string | null;
+ sourceType?: SelectedSource["sourceType"] | "unknown";
+ displayId?: number | null;
+ displayBounds?: WindowBounds | null;
+ windowHandle?: number | null;
+ helperPath?: string | null;
+ outputPath?: string | null;
+ systemAudioPath?: string | null;
+ microphonePath?: string | null;
+ osRelease?: string;
+ supported?: boolean;
+ helperExists?: boolean;
+ fileSizeBytes?: number | null;
+ processOutput?: string;
+ error?: string;
+};
+
+export type RecordingSessionData = {
+ videoPath: string;
+ webcamPath?: string | null;
+ timeOffsetMs?: number;
+ hideOverlayCursorByDefault?: boolean;
+};
+
+export type PauseSegment = {
+ startMs: number;
+ endMs: number;
+};
+
+export type RecordingSessionManifest = {
+ version: 1 | 2;
+ videoFileName: string;
+ webcamFileName?: string | null;
+ timeOffsetMs?: number;
+};
+
+export type ProjectLibraryEntry = {
+ path: string;
+ name: string;
+ createdAt?: number;
+ updatedAt: number;
+ thumbnailPath: string | null;
+ isCurrent: boolean;
+ isInProjectsDirectory: boolean;
+};
+
+export type SystemCursorAsset = {
+ dataUrl: string;
+ hotspotX: number;
+ hotspotY: number;
+ width: number;
+ height: number;
+};
+
+export type CursorVisualType =
+ | "arrow"
+ | "text"
+ | "pointer"
+ | "crosshair"
+ | "open-hand"
+ | "closed-hand"
+ | "resize-ew"
+ | "resize-ns"
+ | "not-allowed";
+
+export type CursorInteractionType =
+ | "move"
+ | "click"
+ | "double-click"
+ | "right-click"
+ | "middle-click"
+ | "mouseup";
+
+export interface CursorTelemetryPoint {
+ timeMs: number;
+ cx: number;
+ cy: number;
+ interactionType?: CursorInteractionType;
+ cursorType?: CursorVisualType;
+}
+
+export type NativeMacWindowSource = {
+ id: string;
+ name: string;
+ display_id?: string;
+ appName?: string;
+ windowTitle?: string;
+ bundleId?: string;
+ appIcon?: string | null;
+ x?: number;
+ y?: number;
+ width?: number;
+ height?: number;
+};
+
+export type HookEventName = "mousedown" | "mouseup" | "mousemove";
+
+export type HookMouseEvent = {
+ button?: number;
+ mouseButton?: number;
+ x?: number;
+ y?: number;
+ screenX?: number;
+ screenY?: number;
+ data?: {
+ button?: number;
+ mouseButton?: number;
+ x?: number;
+ y?: number;
+ screenX?: number;
+ screenY?: number;
+ };
+};
+
+export type HookEventListener = (event: HookMouseEvent) => void;
+
+export type UiohookLike = {
+ on: (eventName: HookEventName, listener: HookEventListener) => void;
+ off?: (eventName: HookEventName, listener: HookEventListener) => void;
+ removeListener?: (eventName: HookEventName, listener: HookEventListener) => void;
+ start: () => void;
+ stop?: () => void;
+};
+
+export type UiohookModuleNamespace = {
+ uIOhook?: UiohookLike;
+ uiohook?: UiohookLike;
+ Uiohook?: UiohookLike;
+ default?: UiohookLike | UiohookModuleNamespace;
+};
+
+export type AudioSyncAdjustment = {
+ mode: "none" | "tempo" | "delay" | "pad";
+ delayMs: number;
+ tempoRatio: number;
+ durationDeltaMs: number;
+};
+
+export type CompanionAudioCandidate = {
+ platform: "mac" | "win";
+ systemPath: string;
+ micPath: string;
+ usablePaths: string[];
+};
+
+export type CaptionWordPayload = {
+ text: string;
+ startMs: number;
+ endMs: number;
+ leadingSpace?: boolean;
+};
+
+export type CaptionCuePayload = {
+ id: string;
+ startMs: number;
+ endMs: number;
+ text: string;
+ words?: CaptionWordPayload[];
+};
+
+export type WhisperJsonToken = {
+ text?: unknown;
+ offsets?: {
+ from?: unknown;
+ to?: unknown;
+ };
+};
+
+export type WhisperJsonSegment = {
+ text?: unknown;
+ offsets?: {
+ from?: unknown;
+ to?: unknown;
+ };
+ tokens?: unknown;
+};
diff --git a/electron/ipc/utils.ts b/electron/ipc/utils.ts
new file mode 100644
index 000000000..af6b3c2bd
--- /dev/null
+++ b/electron/ipc/utils.ts
@@ -0,0 +1,132 @@
+import { getLocalMediaServerPath } from "../../src/lib/localMediaUrl";
+import fs from "node:fs/promises";
+import { createRequire } from "node:module";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import { app } from "electron";
+import { RECORDINGS_DIR } from "../appPaths";
+import { AUTO_RECORDING_PREFIX, RECORDINGS_SETTINGS_FILE } from "./constants";
+import {
+ approvedLocalReadPaths,
+ customRecordingsDir,
+ recordingsDirLoaded,
+ setCustomRecordingsDir,
+ setRecordingsDirLoaded,
+} from "./state";
+
+const nodeRequire = createRequire(import.meta.url);
+
+export function getScreen() {
+ if (!app.isReady()) {
+ throw new Error(
+ "getScreen() called before app is ready. Ensure all screen access happens after app.whenReady().",
+ );
+ }
+ return nodeRequire("electron").screen as typeof import("electron").screen;
+}
+
+export function normalizePath(filePath: string) {
+ return path.resolve(filePath);
+}
+
+export function normalizeVideoSourcePath(videoPath?: string | null): string | null {
+ if (typeof videoPath !== "string") {
+ return null;
+ }
+
+ const trimmed = videoPath.trim();
+ if (!trimmed) {
+ return null;
+ }
+
+ if (/^file:\/\//i.test(trimmed)) {
+ try {
+ return fileURLToPath(trimmed);
+ } catch {
+ // Fall through and keep best-effort string path below.
+ }
+ }
+
+ return getLocalMediaServerPath(trimmed) ?? trimmed;
+}
+
+export function stripJsonByteOrderMark(content: string) {
+ return content.charCodeAt(0) === 0xfeff ? content.slice(1) : content;
+}
+
+export function parseJsonWithByteOrderMark(content: string): T {
+ return JSON.parse(stripJsonByteOrderMark(content)) as T;
+}
+
+export function parseWindowId(sourceId?: string) {
+ if (!sourceId) return null;
+ const match = sourceId.match(/^window:(\d+)/);
+ return match ? Number.parseInt(match[1], 10) : null;
+}
+
+export function getTelemetryPathForVideo(videoPath: string) {
+ return `${videoPath}.cursor.json`;
+}
+
+export function isAutoRecordingPath(filePath: string) {
+ return path.basename(filePath).startsWith(AUTO_RECORDING_PREFIX);
+}
+
+export async function moveFileWithOverwrite(sourcePath: string, destinationPath: string) {
+ await fs.mkdir(path.dirname(destinationPath), { recursive: true });
+ await fs.rm(destinationPath, { force: true });
+
+ try {
+ await fs.rename(sourcePath, destinationPath);
+ } catch (error) {
+ const nodeError = error as NodeJS.ErrnoException;
+ if (nodeError.code !== "EXDEV") {
+ throw error;
+ }
+
+ await fs.copyFile(sourcePath, destinationPath);
+ await fs.unlink(sourcePath);
+ }
+}
+
+async function loadRecordingsDirectorySetting() {
+ if (recordingsDirLoaded) {
+ return;
+ }
+
+ setRecordingsDirLoaded(true);
+
+ try {
+ const content = await fs.readFile(RECORDINGS_SETTINGS_FILE, "utf-8");
+ const parsed = parseJsonWithByteOrderMark<{ recordingsDir?: unknown }>(content);
+ if (typeof parsed.recordingsDir === "string" && parsed.recordingsDir.trim()) {
+ setCustomRecordingsDir(path.resolve(parsed.recordingsDir));
+ }
+ } catch {
+ setCustomRecordingsDir(null);
+ }
+}
+
+export async function getRecordingsDir() {
+ await loadRecordingsDirectorySetting();
+ const targetDir = customRecordingsDir ?? RECORDINGS_DIR;
+ await fs.mkdir(targetDir, { recursive: true });
+ return targetDir;
+}
+
+export function getMacPrivacySettingsUrl(pane: "screen" | "accessibility" | "microphone"): string {
+ if (pane === "screen")
+ return "x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture";
+ if (pane === "microphone")
+ return "x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone";
+ return "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility";
+}
+
+export function approveUserPath(filePath: string | null | undefined): void {
+ if (!filePath) return;
+ try {
+ approvedLocalReadPaths.add(path.resolve(filePath));
+ } catch {
+ // Ignore invalid paths; later reads will surface the underlying error.
+ }
+}
diff --git a/electron/ipc/windowsCaptureSelection.test.ts b/electron/ipc/windowsCaptureSelection.test.ts
new file mode 100644
index 000000000..cfe954101
--- /dev/null
+++ b/electron/ipc/windowsCaptureSelection.test.ts
@@ -0,0 +1,145 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ convertPhysicalBoundsToDip,
+ resolveWindowsCaptureDisplay,
+ resolveWindowsCaptureTarget,
+} from "./windowsCaptureSelection";
+
+describe("convertPhysicalBoundsToDip", () => {
+ it("converts both physical edges so mixed-DPI window sizes stay aligned", () => {
+ expect(
+ convertPhysicalBoundsToDip({ x: 240, y: 60, width: 938, height: 1030 }, (point) => ({
+ x: point.x / 1.5,
+ y: point.y / 1.5,
+ })),
+ ).toEqual({
+ x: 160,
+ y: 40,
+ width: 625.3333333333334,
+ height: 686.6666666666666,
+ });
+ });
+});
+
+describe("resolveWindowsCaptureDisplay", () => {
+ const primaryDisplay = {
+ id: 101,
+ bounds: {
+ x: 0,
+ y: 0,
+ width: 1920,
+ height: 1080,
+ },
+ };
+
+ const secondaryDisplay = {
+ id: 202,
+ bounds: {
+ x: 1920,
+ y: -40,
+ width: 2560,
+ height: 1440,
+ },
+ };
+
+ it("uses the requested secondary display bounds for WGC fallback metadata", () => {
+ const resolved = resolveWindowsCaptureDisplay(
+ { display_id: String(secondaryDisplay.id) },
+ [primaryDisplay, secondaryDisplay],
+ primaryDisplay,
+ );
+
+ expect(resolved).toEqual({
+ displayId: secondaryDisplay.id,
+ bounds: secondaryDisplay.bounds,
+ });
+ });
+
+ it("falls back to the primary display when the source has no display id", () => {
+ const resolved = resolveWindowsCaptureDisplay(
+ undefined,
+ [primaryDisplay, secondaryDisplay],
+ primaryDisplay,
+ );
+
+ expect(resolved).toEqual({
+ displayId: primaryDisplay.id,
+ bounds: primaryDisplay.bounds,
+ });
+ });
+
+ it("keeps the requested display id even if Electron cannot rematch it, while using primary bounds", () => {
+ const resolved = resolveWindowsCaptureDisplay(
+ { display_id: "303" },
+ [primaryDisplay, secondaryDisplay],
+ primaryDisplay,
+ );
+
+ expect(resolved).toEqual({
+ displayId: 303,
+ bounds: primaryDisplay.bounds,
+ });
+ });
+});
+
+describe("resolveWindowsCaptureTarget", () => {
+ const primaryDisplay = {
+ id: 101,
+ bounds: {
+ x: 0,
+ y: 0,
+ width: 1920,
+ height: 1080,
+ },
+ };
+
+ const secondaryDisplay = {
+ id: 202,
+ bounds: {
+ x: 1920,
+ y: -40,
+ width: 2560,
+ height: 1440,
+ },
+ };
+
+ it("uses a window handle when a Windows window source is selected", () => {
+ const resolved = resolveWindowsCaptureTarget(
+ { id: "window:123456:0", sourceType: "window" },
+ [primaryDisplay, secondaryDisplay],
+ primaryDisplay,
+ );
+
+ expect(resolved).toEqual({
+ kind: "window",
+ windowHandle: 123456,
+ });
+ });
+
+ it("does not silently turn an invalid window source into display capture", () => {
+ const resolved = resolveWindowsCaptureTarget(
+ { id: "window:0:0", sourceType: "window", display_id: String(secondaryDisplay.id) },
+ [primaryDisplay, secondaryDisplay],
+ primaryDisplay,
+ );
+
+ expect(resolved).toEqual({
+ kind: "invalid-window",
+ });
+ });
+
+ it("keeps display capture behavior for selected screens", () => {
+ const resolved = resolveWindowsCaptureTarget(
+ { id: "screen:202:0", sourceType: "screen", display_id: String(secondaryDisplay.id) },
+ [primaryDisplay, secondaryDisplay],
+ primaryDisplay,
+ );
+
+ expect(resolved).toEqual({
+ kind: "display",
+ displayId: secondaryDisplay.id,
+ bounds: secondaryDisplay.bounds,
+ });
+ });
+});
diff --git a/electron/ipc/windowsCaptureSelection.ts b/electron/ipc/windowsCaptureSelection.ts
new file mode 100644
index 000000000..ac88c65e1
--- /dev/null
+++ b/electron/ipc/windowsCaptureSelection.ts
@@ -0,0 +1,120 @@
+export type WindowsCaptureSourceLike = {
+ id?: string;
+ display_id?: string;
+ sourceType?: string;
+};
+
+export type WindowsCaptureDisplayBounds = {
+ x: number;
+ y: number;
+ width: number;
+ height: number;
+};
+
+export type WindowsCaptureDisplayLike = {
+ id: number;
+ bounds: WindowsCaptureDisplayBounds;
+};
+
+export type ResolvedWindowsCaptureDisplay = {
+ displayId: number;
+ bounds: WindowsCaptureDisplayBounds;
+};
+
+export function convertPhysicalBoundsToDip(
+ bounds: WindowsCaptureDisplayBounds,
+ convertPoint: (point: { x: number; y: number }) => { x: number; y: number },
+): WindowsCaptureDisplayBounds {
+ const topLeft = convertPoint({ x: bounds.x, y: bounds.y });
+ const bottomRight = convertPoint({
+ x: bounds.x + bounds.width,
+ y: bounds.y + bounds.height,
+ });
+
+ return {
+ x: topLeft.x,
+ y: topLeft.y,
+ width: Math.max(1, bottomRight.x - topLeft.x),
+ height: Math.max(1, bottomRight.y - topLeft.y),
+ };
+}
+
+export type ResolvedWindowsCaptureTarget =
+ | {
+ kind: "window";
+ windowHandle: number;
+ }
+ | {
+ kind: "display";
+ displayId: number;
+ bounds: WindowsCaptureDisplayBounds;
+ }
+ | {
+ kind: "invalid-window";
+ };
+
+function parseDesktopCapturerWindowHandle(sourceId?: string) {
+ if (!sourceId) {
+ return null;
+ }
+
+ const match = sourceId.match(/^window:(\d+)/);
+ if (!match) {
+ return null;
+ }
+
+ const handle = Number.parseInt(match[1], 10);
+ return Number.isFinite(handle) && handle > 0 ? handle : null;
+}
+
+function isWindowCaptureSource(source: WindowsCaptureSourceLike | null | undefined) {
+ return source?.sourceType === "window" || source?.id?.startsWith("window:") === true;
+}
+
+export function resolveWindowsCaptureDisplay(
+ source: WindowsCaptureSourceLike | null | undefined,
+ allDisplays: WindowsCaptureDisplayLike[],
+ primaryDisplay: WindowsCaptureDisplayLike,
+): ResolvedWindowsCaptureDisplay {
+ const requestedDisplayId = Number(source?.display_id);
+ const primaryDisplayId = Number(primaryDisplay.id);
+ const requestedOrPrimaryDisplayId =
+ Number.isFinite(requestedDisplayId) && requestedDisplayId > 0
+ ? requestedDisplayId
+ : primaryDisplayId;
+
+ const matchedDisplay =
+ allDisplays.find((display) => String(display.id) === String(requestedOrPrimaryDisplayId)) ??
+ primaryDisplay;
+
+ return {
+ displayId: requestedOrPrimaryDisplayId,
+ bounds: matchedDisplay.bounds,
+ };
+}
+
+export function resolveWindowsCaptureTarget(
+ source: WindowsCaptureSourceLike | null | undefined,
+ allDisplays: WindowsCaptureDisplayLike[],
+ primaryDisplay: WindowsCaptureDisplayLike,
+): ResolvedWindowsCaptureTarget {
+ if (isWindowCaptureSource(source)) {
+ const windowHandle = parseDesktopCapturerWindowHandle(source?.id);
+ if (windowHandle !== null) {
+ return {
+ kind: "window",
+ windowHandle,
+ };
+ }
+
+ return {
+ kind: "invalid-window",
+ };
+ }
+
+ const resolvedDisplay = resolveWindowsCaptureDisplay(source, allDisplays, primaryDisplay);
+ return {
+ kind: "display",
+ ...resolvedDisplay,
+ };
+}
diff --git a/electron/ipc/windowsWindowControl.ts b/electron/ipc/windowsWindowControl.ts
new file mode 100644
index 000000000..9c4c43e8d
--- /dev/null
+++ b/electron/ipc/windowsWindowControl.ts
@@ -0,0 +1,100 @@
+import { execFile } from "node:child_process";
+import { promisify } from "node:util";
+import type { SelectedSource, WindowBounds } from "./types";
+import { getScreen, parseWindowId } from "./utils";
+import { convertPhysicalBoundsToDip } from "./windowsCaptureSelection";
+
+const execFileAsync = promisify(execFile);
+
+export async function bringWindowsWindowForward(windowId: number): Promise {
+ const script = [
+ 'Add-Type -TypeDefinition @"',
+ "using System; using System.Runtime.InteropServices;",
+ "public static class RecordlyForegroundWindow {",
+ ' [DllImport("user32.dll")] public static extern bool ShowWindowAsync(IntPtr hWnd, int nCmdShow);',
+ ' [DllImport("user32.dll")] public static extern bool IsIconic(IntPtr hWnd);',
+ ' [DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr hWnd);',
+ "}",
+ '"@',
+ "$handle = [IntPtr][Int64]$env:RECORDLY_WINDOW_ID",
+ "if ([RecordlyForegroundWindow]::IsIconic($handle)) { [RecordlyForegroundWindow]::ShowWindowAsync($handle, 9) | Out-Null }",
+ "[RecordlyForegroundWindow]::SetForegroundWindow($handle) | Out-Null",
+ ].join("\n");
+
+ await execFileAsync("powershell.exe", ["-NoProfile", "-Command", script], {
+ timeout: 5000,
+ env: { ...process.env, RECORDLY_WINDOW_ID: String(windowId) },
+ });
+}
+
+export async function resolveWindowsWindowBounds(
+ source: SelectedSource,
+): Promise {
+ const windowId = parseWindowId(source.id);
+ const windowTitle =
+ typeof source.windowTitle === "string" ? source.windowTitle.trim() : source.name.trim();
+
+ if (!windowId && !windowTitle) return null;
+
+ const script = [
+ "$windowId = $env:RECORDLY_WINDOW_ID",
+ "$windowTitle = $env:RECORDLY_WINDOW_TITLE",
+ 'Add-Type -TypeDefinition @"',
+ "using System;",
+ "using System.Runtime.InteropServices;",
+ "public static class RecordlyWindowBounds {",
+ " [StructLayout(LayoutKind.Sequential)]",
+ " public struct RECT {",
+ " public int Left;",
+ " public int Top;",
+ " public int Right;",
+ " public int Bottom;",
+ " }",
+ ' [DllImport("user32.dll")]',
+ " [return: MarshalAs(UnmanagedType.Bool)]",
+ " public static extern bool GetWindowRect(IntPtr hWnd, out RECT rect);",
+ ' [DllImport("dwmapi.dll")]',
+ " public static extern int DwmGetWindowAttribute(IntPtr hWnd, int attribute, out RECT rect, int size);",
+ ' [DllImport("user32.dll")]',
+ " public static extern IntPtr SetThreadDpiAwarenessContext(IntPtr dpiContext);",
+ "}",
+ '"@',
+ "$handle = [Int64]0",
+ "if ($windowId) { $handle = [Int64]$windowId }",
+ "$escapedWindowTitle = if ($windowTitle) { [WildcardPattern]::Escape($windowTitle) } else { $null }",
+ "if ($handle -le 0 -and $windowTitle) {",
+ ' $matchingProcess = Get-Process | Where-Object { $_.MainWindowTitle -eq $windowTitle -or ($escapedWindowTitle -and $_.MainWindowTitle -like "*$escapedWindowTitle*") } | Select-Object -First 1',
+ " if ($matchingProcess) { $handle = $matchingProcess.MainWindowHandle.ToInt64() }",
+ "}",
+ "if ($handle -le 0) { exit 1 }",
+ "$rect = New-Object RecordlyWindowBounds+RECT",
+ "[RecordlyWindowBounds]::SetThreadDpiAwarenessContext([IntPtr](-4)) | Out-Null",
+ "$dwmResult = [RecordlyWindowBounds]::DwmGetWindowAttribute([IntPtr]$handle, 9, [ref]$rect, [Runtime.InteropServices.Marshal]::SizeOf($rect))",
+ "if ($dwmResult -ne 0 -and -not [RecordlyWindowBounds]::GetWindowRect([IntPtr]$handle, [ref]$rect)) { exit 1 }",
+ "@{ x = $rect.Left; y = $rect.Top; width = $rect.Right - $rect.Left; height = $rect.Bottom - $rect.Top } | ConvertTo-Json -Compress",
+ ].join("\n");
+
+ try {
+ const { stdout } = await execFileAsync(
+ "powershell.exe",
+ ["-NoProfile", "-Command", script],
+ {
+ timeout: 5000,
+ env: {
+ ...process.env,
+ RECORDLY_WINDOW_ID: String(windowId ?? ""),
+ RECORDLY_WINDOW_TITLE: windowTitle,
+ },
+ },
+ );
+ const bounds = JSON.parse(stdout) as WindowBounds;
+ if (!bounds || bounds.width <= 0 || bounds.height <= 0) return null;
+
+ const electronScreen = getScreen();
+ return typeof electronScreen.screenToDipPoint === "function"
+ ? convertPhysicalBoundsToDip(bounds, (point) => electronScreen.screenToDipPoint(point))
+ : bounds;
+ } catch {
+ return null;
+ }
+}
diff --git a/electron/macosDistributionPolicy.test.mjs b/electron/macosDistributionPolicy.test.mjs
new file mode 100644
index 000000000..8fd6f05cd
--- /dev/null
+++ b/electron/macosDistributionPolicy.test.mjs
@@ -0,0 +1,139 @@
+import { describe, expect, it } from "vitest";
+import {
+ assertValidAppleTeamId,
+ collectArchitectureErrors,
+ collectCodeSigningMetadataErrors,
+ collectEntitlementErrors,
+ expectedMachOArchitecture,
+ hasMachOMagic,
+ parseLipoArchitectures,
+} from "../scripts/macos-distribution-policy.mjs";
+
+const validCodeSigningDetails = `
+Identifier=dev.recordly.app
+CodeDirectory v=20500 size=123 flags=0x10000(runtime) hashes=3+7 location=embedded
+Authority=Developer ID Application: Recordly Developer (A1B2C3D4E5)
+Authority=Developer ID Certification Authority
+Authority=Apple Root CA
+Timestamp=Aug 9, 2026 at 10:00:00
+TeamIdentifier=A1B2C3D4E5
+`;
+
+describe("macOS distribution signing policy", () => {
+ it("accepts a valid Developer ID signature", () => {
+ expect(collectCodeSigningMetadataErrors(validCodeSigningDetails, "A1B2C3D4E5")).toEqual([]);
+ });
+
+ it("rejects a wrong identity, team, timestamp, runtime, and bundle identifier", () => {
+ const details = `
+Identifier=dev.example.app
+CodeDirectory v=20400 size=123 flags=0x0(none)
+Authority=Apple Development: Example (Z9Y8X7W6V5)
+Timestamp=none
+TeamIdentifier=Z9Y8X7W6V5
+`;
+
+ expect(collectCodeSigningMetadataErrors(details, "A1B2C3D4E5")).toEqual([
+ "unexpected bundle identifier: dev.example.app",
+ "the leaf signing authority is not Developer ID Application",
+ "unexpected TeamIdentifier: Z9Y8X7W6V5",
+ "secure signing timestamp is missing",
+ "hardened runtime flag is missing",
+ ]);
+ });
+
+ it("validates the expected Apple team ID shape", () => {
+ expect(() => assertValidAppleTeamId("A1B2C3D4E5")).not.toThrow();
+ expect(() => assertValidAppleTeamId("short")).toThrow(/exactly 10/);
+ });
+});
+
+describe("macOS distribution entitlement policy", () => {
+ const validEntitlements = {
+ "com.apple.security.cs.allow-jit": true,
+ "com.apple.security.cs.allow-unsigned-executable-memory": true,
+ "com.apple.security.cs.disable-library-validation": true,
+ "com.apple.security.device.audio-input": true,
+ "com.apple.security.device.camera": true,
+ };
+
+ it("accepts the intended production entitlements", () => {
+ expect(collectEntitlementErrors(validEntitlements)).toEqual([]);
+ });
+
+ it("rejects missing capabilities and debug attachment", () => {
+ expect(
+ collectEntitlementErrors({
+ ...validEntitlements,
+ "com.apple.security.cs.allow-jit": false,
+ "com.apple.security.get-task-allow": true,
+ }),
+ ).toEqual([
+ "required entitlement is missing or disabled: com.apple.security.cs.allow-jit",
+ "distribution build must not enable com.apple.security.get-task-allow",
+ ]);
+ });
+
+ it("rejects unreviewed root runtime exceptions", () => {
+ expect(
+ collectEntitlementErrors({
+ ...validEntitlements,
+ "com.apple.security.cs.allow-dyld-environment-variables": true,
+ }),
+ ).toEqual([
+ "unexpected root application entitlement: com.apple.security.cs.allow-dyld-environment-variables",
+ ]);
+ });
+
+ it("rejects disabled but unreviewed entitlement keys", () => {
+ expect(
+ collectEntitlementErrors({
+ ...validEntitlements,
+ "com.apple.security.get-task-allow": false,
+ }),
+ ).toEqual(["unexpected root application entitlement: com.apple.security.get-task-allow"]);
+ });
+});
+
+describe("macOS distribution architecture policy", () => {
+ it("recognizes thin and universal Mach-O magic bytes without parsing file output", () => {
+ for (const header of [
+ [0xfe, 0xed, 0xfa, 0xce],
+ [0xcf, 0xfa, 0xed, 0xfe],
+ [0xca, 0xfe, 0xba, 0xbe],
+ [0xbf, 0xba, 0xfe, 0xca],
+ ]) {
+ expect(hasMachOMagic(Uint8Array.from(header))).toBe(true);
+ }
+
+ expect(hasMachOMagic(Uint8Array.from([0x7f, 0x45, 0x4c, 0x46]))).toBe(false);
+ expect(hasMachOMagic(Uint8Array.from([0xfe, 0xed, 0xfa]))).toBe(false);
+ });
+
+ it("parses thin and fat lipo output", () => {
+ expect(parseLipoArchitectures("Non-fat file: App is architecture: arm64")).toEqual([
+ "arm64",
+ ]);
+ expect(
+ parseLipoArchitectures("Architectures in the fat file: App are: x86_64 arm64"),
+ ).toEqual(["x86_64", "arm64"]);
+ });
+
+ it("uses path-specific helper architecture before the build architecture", () => {
+ expect(
+ expectedMachOArchitecture("app/electron/native/bin/darwin-arm64/helper", "x64"),
+ ).toBe("arm64");
+ expect(
+ expectedMachOArchitecture("app/electron/native/bin/darwin-x64/helper", "arm64"),
+ ).toBe("x86_64");
+ expect(expectedMachOArchitecture("Recordly.app/Contents/MacOS/Recordly", "x64")).toBe(
+ "x86_64",
+ );
+ });
+
+ it("reports a binary that lacks the required architecture", () => {
+ expect(
+ collectArchitectureErrors("Recordly.app/Contents/MacOS/Recordly", "arm64", "x64"),
+ ).toEqual(["Recordly.app/Contents/MacOS/Recordly does not contain x86_64 (found: arm64)"]);
+ });
+});
diff --git a/electron/main.ts b/electron/main.ts
index 04d15b69e..417d4a05b 100644
--- a/electron/main.ts
+++ b/electron/main.ts
@@ -1,43 +1,129 @@
-import { app, BrowserWindow, Tray, Menu, nativeImage } from 'electron'
-import { fileURLToPath } from 'node:url'
-import path from 'node:path'
-import fs from 'node:fs/promises'
-import { createHudOverlayWindow, createEditorWindow, createSourceSelectorWindow } from './windows'
-import { registerIpcHandlers } from './ipc/handlers'
-import { cleanupMouseTracking } from './ipc/mouseTracking'
-
-const __dirname = path.dirname(fileURLToPath(import.meta.url))
-
-export const RECORDINGS_DIR = path.join(app.getPath('userData'), 'recordings')
-
-// Cleanup old recordings (older than 1 day)
-async function cleanupOldRecordings() {
- try {
- const files = await fs.readdir(RECORDINGS_DIR)
- const now = Date.now()
- const maxAge = 1 * 24 * 60 * 60 * 1000
-
- for (const file of files) {
- const filePath = path.join(RECORDINGS_DIR, file)
- const stats = await fs.stat(filePath)
-
- if (now - stats.mtimeMs > maxAge) {
- await fs.unlink(filePath)
- console.log(`Deleted old recording: ${file}`)
- }
- }
- } catch (error) {
- console.error('Failed to cleanup old recordings:', error)
- }
+import { clearRecordingTrashUndo } from "./ipc/recording/library";
+import fs from "node:fs/promises";
+import path from "node:path";
+import { fileURLToPath, pathToFileURL } from "node:url";
+import {
+ app,
+ BrowserWindow,
+ desktopCapturer,
+ dialog,
+ webContents as electronWebContents,
+ ipcMain,
+ Menu,
+ nativeImage,
+ session,
+ shell,
+ systemPreferences,
+ Tray,
+} from "electron";
+import { RECORDINGS_DIR } from "./appPaths";
+import { createAuthCallbackController } from "./authCallback";
+import { showCursor } from "./cursorHider";
+import { getGpuSwitches } from "./gpuSwitches";
+import {
+ cleanupAllExportStreams,
+ cleanupNativeVideoExportSessions,
+ getSelectedSourceId,
+ killWindowsCaptureProcess,
+ registerIpcHandlers,
+} from "./ipc/handlers";
+import { ensureMediaServer } from "./mediaServer";
+import { hardenWebContentsNavigation, shouldHardenWebContentsType } from "./navigationPolicy";
+import { shouldGrantDisplayCapture, shouldGrantMediaPermission } from "./permissionPolicy";
+import { ensurePackagedRendererServer, getPackagedRendererBaseUrl } from "./rendererServer";
+import {
+ checkForAppUpdates,
+ deferUpdateReminder,
+ dismissUpdateToast,
+ downloadAvailableUpdate,
+ getCurrentUpdateToastPayload,
+ getExperimentalUpdatesEnabled,
+ getUpdaterLogPath,
+ getUpdateStatusSummary,
+ installDownloadedUpdateNow,
+ previewNativeUpdateDialog,
+ previewUpdateToast,
+ setExperimentalUpdatesEnabled,
+ setupAutoUpdates,
+ skipAvailableUpdateVersion,
+} from "./updater";
+import {
+ createEditorWindow,
+ createHudOverlayWindow,
+ createSourceSelectorWindow,
+ getHudOverlayWindow,
+ getUpdateToastWindow,
+ hideUpdateToastWindow,
+ isHudOverlayMousePassthroughSupported,
+ beginHudCaptureProtection,
+ reassertHudOverlayMousePassthrough as reassertHudOverlayMouseState,
+ setHudOverlayRecordingActive,
+ showUpdateToastWindow,
+} from "./windows";
+
+const electronMainDir = path.dirname(fileURLToPath(import.meta.url));
+const IS_SMOKE_EXPORT = process.env.RECORDLY_SMOKE_EXPORT === "1";
+
+function ignoreBrokenConsolePipe(stream: NodeJS.WritableStream | undefined) {
+ stream?.on("error", (error: NodeJS.ErrnoException) => {
+ if (error.code === "EPIPE" || error.code === "EIO") {
+ return;
+ }
+ throw error;
+ });
+}
+
+ignoreBrokenConsolePipe(process.stdout);
+ignoreBrokenConsolePipe(process.stderr);
+
+app.commandLine.appendSwitch("ignore-gpu-blocklist");
+app.commandLine.appendSwitch("enable-unsafe-webgpu");
+app.commandLine.appendSwitch("enable-gpu-rasterization");
+
+app.on("web-contents-created", (_event, contents) => {
+ if (!shouldHardenWebContentsType(contents.getType())) {
+ return;
+ }
+
+ hardenWebContentsNavigation(contents, (url) => shell.openExternal(url));
+});
+
+function configureGpuAccelerationSwitches() {
+ const { useAngle, useGl, disableFeatures } = getGpuSwitches(process.platform, process.env);
+ if (useAngle) {
+ app.commandLine.appendSwitch("use-angle", useAngle);
+ }
+ if (useGl) {
+ app.commandLine.appendSwitch("use-gl", useGl);
+ }
+ if (disableFeatures && disableFeatures.length > 0) {
+ app.commandLine.appendSwitch("disable-features", disableFeatures.join(","));
+ }
}
+async function logSmokeExportGpuDiagnostics() {
+ if (!IS_SMOKE_EXPORT) {
+ return;
+ }
+
+ try {
+ console.log("[smoke-export] GPU feature status", JSON.stringify(app.getGPUFeatureStatus()));
+ console.log("[smoke-export] GPU info", JSON.stringify(await app.getGPUInfo("basic")));
+ } catch (error) {
+ console.warn("[smoke-export] Failed to read GPU diagnostics:", error);
+ }
+}
+
+configureGpuAccelerationSwitches();
+
async function ensureRecordingsDir() {
- try {
- await fs.mkdir(RECORDINGS_DIR, { recursive: true })
- console.log('Recordings directory ready:', RECORDINGS_DIR)
- } catch (error) {
- console.error('Failed to create recordings directory:', error)
- }
+ try {
+ await fs.mkdir(RECORDINGS_DIR, { recursive: true });
+ console.log("RECORDINGS_DIR:", RECORDINGS_DIR);
+ console.log("User Data Path:", app.getPath("userData"));
+ } catch (error) {
+ console.error("Failed to create recordings directory:", error);
+ }
}
// The built directory structure
@@ -49,117 +135,1020 @@ async function ensureRecordingsDir() {
// │ │ ├── main.js
// │ │ └── preload.mjs
// │
-process.env.APP_ROOT = path.join(__dirname, '..')
+process.env.APP_ROOT = path.join(electronMainDir, "..");
// Use ['ENV_NAME'] avoid vite:define plugin - Vite@2.x
-export const VITE_DEV_SERVER_URL = process.env['VITE_DEV_SERVER_URL']
-export const MAIN_DIST = path.join(process.env.APP_ROOT, 'dist-electron')
-export const RENDERER_DIST = path.join(process.env.APP_ROOT, 'dist')
+export const VITE_DEV_SERVER_URL = process.env["VITE_DEV_SERVER_URL"];
+export const MAIN_DIST = path.join(process.env.APP_ROOT, "dist-electron");
+export const RENDERER_DIST = path.join(process.env.APP_ROOT, "dist");
+const IS_DEV = Boolean(VITE_DEV_SERVER_URL);
+
+process.env.VITE_PUBLIC = VITE_DEV_SERVER_URL
+ ? path.join(process.env.APP_ROOT, "public")
+ : RENDERER_DIST;
+
+function getTrustedCaptureDocumentBaseUrls(): string[] {
+ const trustedUrls = [pathToFileURL(path.join(RENDERER_DIST, "index.html")).href];
+
+ if (VITE_DEV_SERVER_URL) {
+ trustedUrls.push(VITE_DEV_SERVER_URL);
+ }
-process.env.VITE_PUBLIC = VITE_DEV_SERVER_URL ? path.join(process.env.APP_ROOT, 'public') : RENDERER_DIST
+ const packagedRendererBaseUrl = getPackagedRendererBaseUrl();
+ if (packagedRendererBaseUrl) {
+ trustedUrls.push(new URL("/", packagedRendererBaseUrl).href);
+ }
+
+ return trustedUrls;
+}
+
+function isHudWebContents(webContents: Electron.WebContents | null): boolean {
+ if (!webContents || webContents.isDestroyed()) {
+ return false;
+ }
+
+ const hudWindow = getHudOverlayWindow();
+ return Boolean(hudWindow && hudWindow.webContents === webContents);
+}
// Window references
-let mainWindow: BrowserWindow | null = null
-let sourceSelectorWindow: BrowserWindow | null = null
-let tray: Tray | null = null
-let selectedSourceName = ''
+let mainWindow: BrowserWindow | null = null;
+let sourceSelectorWindow: BrowserWindow | null = null;
+let tray: Tray | null = null;
+let trayContextMenu: Menu | null = null;
+let selectedSourceName = "";
+let editorHasUnsavedChanges = false;
+let isForceClosing = false;
+let isAppQuitting = false;
+let isCreatingMainWindow = false;
+let isCreatingEditorWindow = false;
+const shouldEnforceSingleInstanceLock = !IS_DEV;
+const hasSingleInstanceLock = shouldEnforceSingleInstanceLock
+ ? app.requestSingleInstanceLock()
+ : true;
+
+if (!hasSingleInstanceLock) {
+ app.quit();
+}
+
+const authCallbacks = createAuthCallbackController({
+ isDev: IS_DEV,
+ focusApp: () => focusOrCreateMainWindow(),
+});
+
+function closeEditorWindowBypassingUnsavedPrompt(window: BrowserWindow | null) {
+ if (!window || window.isDestroyed()) {
+ return;
+ }
+
+ if (isEditorWindow(window)) {
+ isForceClosing = true;
+ editorHasUnsavedChanges = false;
+ }
+ window.close();
+}
+
+function closeEditorWindowToHud(window: BrowserWindow | null) {
+ if (!window || window.isDestroyed()) {
+ return;
+ }
+
+ // The HUD renderer normally remains hidden while the editor is open so
+ // recording finalization can continue. Restore that HUD before destroying
+ // the editor, keeping Recordly in its ready-to-record state on the taskbar.
+ window.hide();
+ if (mainWindow === window) {
+ mainWindow = null;
+ }
+ createWindow();
+ closeEditorWindowBypassingUnsavedPrompt(window);
+}
+
+function restoreWindowSafely(window: BrowserWindow | null) {
+ if (!window || window.isDestroyed()) {
+ return;
+ }
+
+ if (!isEditorWindow(window) && process.platform === "win32") {
+ showHudOverlayFromTray();
+ return;
+ }
+
+ if (window.isMinimized()) {
+ window.restore();
+ }
+
+ if (!window.isVisible()) {
+ window.show();
+ }
+
+ window.moveTop();
+ window.focus();
+}
+
+function getExistingEditorWindow(): BrowserWindow | null {
+ return (
+ BrowserWindow.getAllWindows().find(
+ (window) => !window.isDestroyed() && isEditorWindow(window),
+ ) ?? null
+ );
+}
+
+// Tray Icons (lazily created after app is ready to avoid accessing Electron APIs too early)
+let defaultTrayIcon: ReturnType | null = null;
+let recordingTrayIcon: ReturnType | null = null;
+
+function getPlatformAppIconFilename(size: 32 | 128 | 512) {
+ const baseName = process.platform === "darwin" ? "recordlymac" : "recordly";
+ return `app-icons/${baseName}-${size}.png`;
+}
+
+function getDefaultTrayIcon() {
+ if (!defaultTrayIcon) {
+ defaultTrayIcon = getTrayIcon(getPlatformAppIconFilename(32));
+ }
+ return defaultTrayIcon;
+}
+
+function getRecordingTrayIcon() {
+ if (!recordingTrayIcon) {
+ recordingTrayIcon = getTrayIcon("rec-button.png");
+ }
+ return recordingTrayIcon;
+}
+
+function showHudOverlayFromTray() {
+ const updateToast = getUpdateToastWindow();
+ if (updateToast?.isVisible()) {
+ updateToast.show();
+ updateToast.moveTop();
+ updateToast.focus();
+ return true;
+ }
+
+ const hud = getHudOverlayWindow();
+ if (!hud) {
+ return false;
+ }
+
+ if (hud.isMinimized()) {
+ hud.restore();
+ }
+
+ if (process.platform === "win32" && isHudOverlayMousePassthroughSupported()) {
+ hud.showInactive();
+ hud.moveTop();
+ reassertHudOverlayMouseState();
+ return true;
+ }
+
+ hud.show();
+ hud.moveTop();
+ hud.focus();
+ return true;
+}
+
+ipcMain.on("set-has-unsaved-changes", (_event, hasChanges: boolean) => {
+ editorHasUnsavedChanges = hasChanges;
+});
function createWindow() {
- mainWindow = createHudOverlayWindow()
+ if (!app.isReady()) {
+ void app.whenReady().then(() => {
+ if (!mainWindow || mainWindow.isDestroyed()) {
+ createWindow();
+ }
+ });
+ return;
+ }
+
+ if (isCreatingMainWindow) {
+ return;
+ }
+
+ if (mainWindow && !mainWindow.isDestroyed()) {
+ restoreWindowSafely(mainWindow);
+ return;
+ }
+
+ const existingHudWindow = getHudOverlayWindow();
+ if (existingHudWindow) {
+ mainWindow = existingHudWindow;
+ restoreWindowSafely(existingHudWindow);
+ return;
+ }
+
+ isCreatingMainWindow = true;
+ const createdHudWindow = createHudOverlayWindow();
+ mainWindow = createdHudWindow;
+ createdHudWindow.once("closed", () => {
+ if (mainWindow === createdHudWindow) {
+ mainWindow = null;
+ }
+ });
+ isCreatingMainWindow = false;
+}
+
+function focusOrCreateMainWindow() {
+ if (!app.isReady()) {
+ void app.whenReady().then(() => {
+ focusOrCreateMainWindow();
+ });
+ return;
+ }
+
+ const updateToast = getUpdateToastWindow();
+ if (updateToast?.isVisible()) {
+ updateToast.show();
+ updateToast.moveTop();
+ updateToast.focus();
+ return;
+ }
+
+ if (!mainWindow || mainWindow.isDestroyed()) {
+ const existingHud = getHudOverlayWindow();
+ if (existingHud && !existingHud.isDestroyed()) {
+ mainWindow = existingHud;
+ } else {
+ createWindow();
+ return;
+ }
+ }
+
+ if (mainWindow && !mainWindow.isDestroyed()) {
+ // On Linux/Wayland, focus() often doesn't take effect (compositor ignores it). Apps like Telegram
+ // work because they receive an XDG activation token via StatusNotifierItem.ProvideXdgActivationToken;
+ // Electron's tray doesn't handle that yet. Workaround: destroy and recreate the HUD so the new
+ // window gets focus (creation path works). Only for HUD, not editor.
+ if (
+ process.platform === "linux" &&
+ !mainWindow.isFocused() &&
+ !isEditorWindow(mainWindow)
+ ) {
+ const win = mainWindow;
+ mainWindow = null;
+ win.once("closed", () => createWindow());
+ win.destroy();
+ return;
+ }
+
+ // On Win32 with mouse passthrough enabled (Win11+), calling
+ // show/moveTop/focus on the transparent HUD overlay permanently corrupts
+ // setIgnoreMouseEvents forwarding, making it click-through. Only focus
+ // the editor window; the HUD is alwaysOnTop so it doesn't need explicit
+ // focus. On Win10 (passthrough disabled), the HUD is always interactive
+ // and can be safely shown/restored.
+ if (
+ process.platform === "win32" &&
+ !isEditorWindow(mainWindow) &&
+ isHudOverlayMousePassthroughSupported()
+ ) {
+ showHudOverlayFromTray();
+ return;
+ }
+
+ mainWindow.show();
+ if (mainWindow.isMinimized()) mainWindow.restore();
+ mainWindow.moveTop();
+ mainWindow.focus();
+ }
+}
+
+function isEditorWindow(window: BrowserWindow) {
+ return window.webContents.getURL().includes("windowType=editor");
+}
+
+function sendEditorMenuAction(
+ channel: "menu-load-project" | "menu-save-project" | "menu-save-project-as",
+) {
+ let targetWindow = BrowserWindow.getFocusedWindow() ?? mainWindow;
+
+ if (!targetWindow || targetWindow.isDestroyed() || !isEditorWindow(targetWindow)) {
+ createEditorWindowWrapper();
+ targetWindow = mainWindow;
+ if (!targetWindow || targetWindow.isDestroyed()) return;
+
+ targetWindow.webContents.once("did-finish-load", () => {
+ if (!targetWindow || targetWindow.isDestroyed()) return;
+ targetWindow.webContents.send(channel);
+ });
+ return;
+ }
+
+ targetWindow.webContents.send(channel);
+}
+
+function setupApplicationMenu() {
+ const isMac = process.platform === "darwin";
+ const template: Electron.MenuItemConstructorOptions[] = [];
+ if (isMac) {
+ template.push({
+ label: app.name,
+ submenu: [
+ { role: "about" },
+ { type: "separator" },
+ { role: "services" },
+ { type: "separator" },
+ { role: "hide" },
+ { role: "hideOthers" },
+ { role: "unhide" },
+ { type: "separator" },
+ { role: "quit" },
+ ],
+ });
+ }
+
+ template.push(
+ {
+ label: "File",
+ submenu: [
+ {
+ label: "Open Projects…",
+ accelerator: "CmdOrCtrl+O",
+ click: () => sendEditorMenuAction("menu-load-project"),
+ },
+ {
+ label: "Save Project…",
+ accelerator: "CmdOrCtrl+S",
+ click: () => sendEditorMenuAction("menu-save-project"),
+ },
+ {
+ label: "Save Project As…",
+ accelerator: "CmdOrCtrl+Shift+S",
+ click: () => sendEditorMenuAction("menu-save-project-as"),
+ },
+ ...(isMac
+ ? []
+ : [
+ { type: "separator" as const },
+ { role: "quit" as const, accelerator: "CmdOrCtrl+Q" },
+ ]),
+ ],
+ },
+ {
+ label: "Edit",
+ submenu: [
+ { role: "undo" },
+ { role: "redo" },
+ { type: "separator" },
+ { role: "cut" },
+ { role: "copy" },
+ { role: "paste" },
+ { role: "selectAll" },
+ ],
+ },
+ {
+ label: "View",
+ submenu: [
+ { role: "reload" },
+ { role: "forceReload" },
+ { role: "toggleDevTools" },
+ { type: "separator" },
+ { role: "resetZoom" },
+ { role: "zoomIn" },
+ { role: "zoomOut" },
+ { type: "separator" },
+ { role: "togglefullscreen" },
+ ],
+ },
+ {
+ label: "Window",
+ submenu: isMac
+ ? [{ role: "minimize" }, { role: "zoom" }, { type: "separator" }, { role: "front" }]
+ : [{ role: "minimize" }, { role: "close" }],
+ },
+ {
+ label: "Help",
+ submenu: [
+ {
+ label: "Check for Updates…",
+ click: () => {
+ void checkForAppUpdates(getUpdateDialogWindow, { manual: true });
+ },
+ },
+ ],
+ },
+ );
+
+ const menu = Menu.buildFromTemplate(template);
+ Menu.setApplicationMenu(menu);
+}
+
+function isPrimaryTrayClick(event: unknown) {
+ const button =
+ event && typeof event === "object" && "button" in event
+ ? (event as { button?: number | string }).button
+ : undefined;
+ return button === undefined || button === 0 || button === "left";
}
function createTray() {
- const iconPath = path.join(process.env.VITE_PUBLIC || RENDERER_DIST, 'rec-button.png');
- let icon = nativeImage.createFromPath(iconPath);
- icon = icon.resize({ width: 24, height: 24, quality: 'best' });
- tray = new Tray(icon);
- updateTrayMenu();
-}
-
-function updateTrayMenu() {
- if (!tray) return;
- const menuTemplate = [
- {
- label: 'Stop Recording',
- click: () => {
- if (mainWindow && !mainWindow.isDestroyed()) {
- mainWindow.webContents.send('stop-recording-from-tray');
- }
- }
- }
- ];
- const contextMenu = Menu.buildFromTemplate(menuTemplate);
- tray.setContextMenu(contextMenu);
- tray.setToolTip(`Recording: ${selectedSourceName}`);
+ tray = new Tray(getDefaultTrayIcon());
+ tray.on("click", (event) => {
+ if (process.platform === "win32" && !isPrimaryTrayClick(event)) {
+ return;
+ }
+
+ focusOrCreateMainWindow();
+ });
+
+ if (process.platform === "win32") {
+ tray.on("right-click", () => {
+ if (!tray || !trayContextMenu) {
+ return;
+ }
+
+ tray.popUpContextMenu(trayContextMenu);
+ });
+ return;
+ }
+
+ tray.on("double-click", () => focusOrCreateMainWindow());
+}
+
+function shouldUseTray() {
+ // macOS and Windows expose Recordly through their Dock/taskbar. Keep the
+ // tray entry only on Linux, where it remains the primary app entry point.
+ return process.platform === "linux";
+}
+
+function getPublicAssetPath(filename: string) {
+ return path.join(process.env.VITE_PUBLIC || RENDERER_DIST, filename);
+}
+
+function getAppImage(filename: string) {
+ return nativeImage.createFromPath(getPublicAssetPath(filename));
+}
+
+function getTrayIcon(filename: string) {
+ return getAppImage(filename).resize({
+ width: 24,
+ height: 24,
+ quality: "best",
+ });
+}
+
+function syncDockIcon() {
+ if (process.platform !== "darwin" || !app.dock) {
+ return;
+ }
+
+ const dockIcon = getAppImage(getPlatformAppIconFilename(512));
+ if (!dockIcon.isEmpty()) {
+ app.dock.setIcon(dockIcon);
+ }
+}
+
+function sendUpdateToastToWindows(channel: "update-toast-state", payload: unknown) {
+ if (process.platform !== "darwin") {
+ return false;
+ }
+
+ if (!payload) {
+ const existingWindow = getUpdateToastWindow();
+ if (existingWindow) {
+ existingWindow.webContents.send(channel, null);
+ }
+ hideUpdateToastWindow();
+ return true;
+ }
+
+ const toastWindow = showUpdateToastWindow();
+ const sendPayload = () => {
+ toastWindow.webContents.send(channel, payload);
+ showUpdateToastWindow();
+ };
+
+ if (toastWindow.webContents.isLoadingMainFrame()) {
+ toastWindow.webContents.once("did-finish-load", sendPayload);
+ } else {
+ sendPayload();
+ }
+
+ return true;
+}
+
+function getUpdateDialogWindow() {
+ const focusedWindow = BrowserWindow.getFocusedWindow();
+ if (focusedWindow && !focusedWindow.isDestroyed()) {
+ return focusedWindow;
+ }
+
+ if (mainWindow && !mainWindow.isDestroyed()) {
+ return mainWindow;
+ }
+
+ return getHudOverlayWindow();
+}
+
+ipcMain.handle("install-downloaded-update", () => {
+ installDownloadedUpdateNow(sendUpdateToastToWindows);
+ return { success: true };
+});
+
+ipcMain.handle("download-available-update", (_event, installAfterDownload?: boolean) => {
+ return downloadAvailableUpdate(sendUpdateToastToWindows, {
+ installAfterDownload: Boolean(installAfterDownload),
+ });
+});
+
+ipcMain.handle("defer-downloaded-update", (_event, delayMs?: number) => {
+ return deferUpdateReminder(getUpdateDialogWindow, sendUpdateToastToWindows, delayMs);
+});
+
+ipcMain.handle("dismiss-update-toast", () => {
+ return dismissUpdateToast(getUpdateDialogWindow, sendUpdateToastToWindows);
+});
+
+ipcMain.handle("skip-update-version", () => {
+ return skipAvailableUpdateVersion(sendUpdateToastToWindows);
+});
+
+ipcMain.handle("get-current-update-toast-payload", () => {
+ return getCurrentUpdateToastPayload();
+});
+
+ipcMain.handle("get-update-status-summary", () => {
+ return getUpdateStatusSummary();
+});
+
+ipcMain.handle("get-experimental-updates-enabled", () => {
+ return getExperimentalUpdatesEnabled();
+});
+
+ipcMain.handle("set-experimental-updates-enabled", async (_event, enabled: unknown) => {
+ if (typeof enabled !== "boolean") {
+ return { success: false, enabled: getExperimentalUpdatesEnabled() };
+ }
+
+ try {
+ const savedValue = setExperimentalUpdatesEnabled(enabled);
+ await checkForAppUpdates(getUpdateDialogWindow);
+ return { success: true, enabled: savedValue };
+ } catch (error) {
+ console.error("Failed to update experimental updates preference:", error);
+ return {
+ success: false,
+ enabled: getExperimentalUpdatesEnabled(),
+ error: String(error),
+ };
+ }
+});
+
+ipcMain.handle("preview-update-toast", async () => {
+ if (process.platform !== "darwin") {
+ await previewNativeUpdateDialog(getUpdateDialogWindow);
+ return { success: true };
+ }
+
+ return { success: previewUpdateToast(sendUpdateToastToWindows) };
+});
+
+ipcMain.handle("check-for-app-updates", async () => {
+ await checkForAppUpdates(getUpdateDialogWindow, { manual: true });
+ return { success: true, logPath: getUpdaterLogPath() };
+});
+
+function updateTrayMenu(recording: boolean = false) {
+ if (!tray) return;
+ const trayIcon = recording ? getRecordingTrayIcon() : getDefaultTrayIcon();
+ const trayToolTip = recording ? `Recording: ${selectedSourceName}` : "Recordly";
+ const menuTemplate = recording
+ ? [
+ {
+ label: "Show Controls",
+ click: () => {
+ if (!showHudOverlayFromTray()) {
+ focusOrCreateMainWindow();
+ }
+ },
+ },
+ {
+ label: "Stop Recording",
+ click: () => {
+ if (mainWindow && !mainWindow.isDestroyed()) {
+ mainWindow.webContents.send("stop-recording-from-tray");
+ }
+ },
+ },
+ ]
+ : [
+ {
+ label: "Open",
+ click: () => {
+ if (!showHudOverlayFromTray()) {
+ focusOrCreateMainWindow();
+ }
+ },
+ },
+ {
+ label: "Quit",
+ click: () => {
+ app.quit();
+ },
+ },
+ ];
+ const menu = Menu.buildFromTemplate(menuTemplate);
+ trayContextMenu = menu;
+ tray.setImage(trayIcon);
+ tray.setToolTip(trayToolTip);
+ if (process.platform !== "win32") {
+ tray.setContextMenu(menu);
+ }
}
function createEditorWindowWrapper() {
- if (mainWindow) {
- mainWindow.close()
- mainWindow = null
- }
- mainWindow = createEditorWindow()
+ const existingEditorWindow = getExistingEditorWindow();
+ if (existingEditorWindow) {
+ mainWindow = existingEditorWindow;
+ restoreWindowSafely(existingEditorWindow);
+ return existingEditorWindow;
+ }
+
+ if (isCreatingEditorWindow) {
+ const currentWindow = mainWindow;
+ if (currentWindow && !currentWindow.isDestroyed()) {
+ return currentWindow;
+ }
+
+ const currentEditorWindow = getExistingEditorWindow();
+ if (currentEditorWindow) {
+ mainWindow = currentEditorWindow;
+ return currentEditorWindow;
+ }
+ }
+
+ isCreatingEditorWindow = true;
+ const previousWindow = mainWindow;
+ if (previousWindow && !previousWindow.isDestroyed()) {
+ const closingEditorWindow = isEditorWindow(previousWindow);
+
+ if (closingEditorWindow) {
+ closeEditorWindowBypassingUnsavedPrompt(previousWindow);
+ } else {
+ // It's the HUD or another window. Hide it instead of closing so background
+ // tasks (like webcam finalizing) can finish in its renderer process.
+ previousWindow.hide();
+ }
+
+ if (!closingEditorWindow) {
+ isForceClosing = false;
+ }
+ if (mainWindow === previousWindow) {
+ mainWindow = null;
+ }
+ }
+ const editorWindow = createEditorWindow();
+ mainWindow = editorWindow;
+ editorHasUnsavedChanges = false;
+
+ editorWindow.on("closed", () => {
+ if (mainWindow === editorWindow) {
+ mainWindow = null;
+ }
+ isCreatingEditorWindow = false;
+ isForceClosing = false;
+ editorHasUnsavedChanges = false;
+ });
+
+ editorWindow.on("close", (event) => {
+ if (isForceClosing || !editorHasUnsavedChanges) {
+ if (process.platform === "win32" && !isForceClosing && !isAppQuitting) {
+ event.preventDefault();
+ closeEditorWindowToHud(editorWindow);
+ }
+ return;
+ }
+
+ event.preventDefault();
+
+ const choice = dialog.showMessageBoxSync(editorWindow, {
+ type: "warning",
+ buttons: ["Save & Close", "Discard & Close", "Cancel"],
+ defaultId: 0,
+ cancelId: 2,
+ title: "Unsaved Changes",
+ message: "You have unsaved changes.",
+ detail: "Do you want to save your project before closing?",
+ });
+
+ if (choice === 0) {
+ editorWindow.webContents.send("request-save-before-close");
+ ipcMain.once("save-before-close-done", (_event, saved: boolean) => {
+ if (!saved) {
+ isAppQuitting = false;
+ return;
+ }
+
+ if (process.platform === "win32" && !isAppQuitting) {
+ closeEditorWindowToHud(editorWindow);
+ } else {
+ closeEditorWindowBypassingUnsavedPrompt(editorWindow);
+ }
+ });
+ } else if (choice === 1) {
+ if (process.platform === "win32" && !isAppQuitting) {
+ closeEditorWindowToHud(editorWindow);
+ } else {
+ closeEditorWindowBypassingUnsavedPrompt(editorWindow);
+ }
+ } else {
+ isAppQuitting = false;
+ }
+ });
+
+ return editorWindow;
}
function createSourceSelectorWindowWrapper() {
- sourceSelectorWindow = createSourceSelectorWindow()
- sourceSelectorWindow.on('closed', () => {
- sourceSelectorWindow = null
- })
- return sourceSelectorWindow
+ sourceSelectorWindow = createSourceSelectorWindow();
+ sourceSelectorWindow.on("closed", () => {
+ sourceSelectorWindow = null;
+ });
+ return sourceSelectorWindow;
}
-// Quit when all windows are closed, except on macOS. There, it's common
-// for applications and their menu bar to stay active until the user quits
+// On macOS, applications and their menu bar stay active until the user quits
// explicitly with Cmd + Q.
-app.on('window-all-closed', () => {
- if (process.platform !== 'darwin') {
- cleanupMouseTracking()
- app.quit()
- mainWindow = null
- }
-})
-
-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.
- if (BrowserWindow.getAllWindows().length === 0) {
- createWindow()
- }
-})
-
-// Cleanup old recordings on quit (both macOS and other platforms)
-app.on('before-quit', async (event) => {
- event.preventDefault()
- cleanupMouseTracking()
- await cleanupOldRecordings()
- app.exit(0)
-})
+app.on("before-quit", () => {
+ isAppQuitting = true;
+ authCallbacks.close();
+ void clearRecordingTrashUndo().catch((error) =>
+ console.warn("Could not clear recording undo cache", error),
+ );
+ killWindowsCaptureProcess();
+ showCursor();
+ cleanupNativeVideoExportSessions();
+ void cleanupAllExportStreams();
+});
+
+app.on("window-all-closed", () => {
+ if (IS_SMOKE_EXPORT || process.platform !== "darwin") {
+ 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.
+ focusOrCreateMainWindow();
+});
+
+app.on("second-instance", (_event, commandLine) => {
+ const authCallback = authCallbacks.find(commandLine);
+ if (authCallback) authCallbacks.dispatch(authCallback);
+ focusOrCreateMainWindow();
+});
// Register all IPC handlers when app is ready
app.whenReady().then(async () => {
- // Ensure recordings directory exists
- await ensureRecordingsDir()
-
- registerIpcHandlers(
- createEditorWindowWrapper,
- createSourceSelectorWindowWrapper,
- () => mainWindow,
- () => sourceSelectorWindow,
- (recording: boolean, sourceName: string) => {
- selectedSourceName = sourceName
- if (recording) {
- if (!tray) createTray();
- updateTrayMenu();
- if (mainWindow) mainWindow.minimize();
- } else {
- if (tray) {
- tray.destroy();
- tray = null;
- }
- if (mainWindow) mainWindow.restore();
- }
- }
- )
- createWindow()
-})
+ authCallbacks.startDevServer();
+ if (process.defaultApp && process.argv[1]) {
+ app.setAsDefaultProtocolClient(authCallbacks.protocol, process.execPath, [
+ path.resolve(process.argv[1]),
+ ]);
+ } else {
+ app.setAsDefaultProtocolClient(authCallbacks.protocol);
+ }
+ const startupAuthCallback = authCallbacks.find(process.argv);
+ if (startupAuthCallback) authCallbacks.dispatch(startupAuthCallback);
+
+ if (process.platform === "win32") {
+ app.setAppUserModelId("dev.recordly.app");
+ }
+
+ session.defaultSession.setPermissionCheckHandler(
+ (webContents, permission, requestingOrigin, details) => {
+ return shouldGrantMediaPermission(
+ {
+ permission,
+ isTrustedCaptureWindow: isHudWebContents(webContents),
+ isMainFrame: details.isMainFrame,
+ currentDocumentUrl: webContents?.getURL() ?? "",
+ // Electron 39 may supply the last committed document URL, including its
+ // query, in the requestingOrigin argument for media checks.
+ requestingUrl: details.requestingUrl ?? requestingOrigin,
+ securityOrigins:
+ details.securityOrigin === undefined ? [] : [details.securityOrigin],
+ },
+ getTrustedCaptureDocumentBaseUrls(),
+ );
+ },
+ );
+
+ session.defaultSession.setPermissionRequestHandler(
+ (webContents, permission, callback, details) => {
+ const securityOrigin = "securityOrigin" in details ? details.securityOrigin : undefined;
+
+ callback(
+ shouldGrantMediaPermission(
+ {
+ permission,
+ isTrustedCaptureWindow: isHudWebContents(webContents),
+ isMainFrame: details.isMainFrame,
+ currentDocumentUrl: webContents.getURL(),
+ requestingUrl: details.requestingUrl,
+ securityOrigins: securityOrigin === undefined ? [] : [securityOrigin],
+ },
+ getTrustedCaptureDocumentBaseUrls(),
+ ),
+ );
+ },
+ );
+
+ // Recordly does not use WebHID, Web Serial, or WebUSB. Do not grant devices by default.
+ session.defaultSession.setDevicePermissionHandler(() => false);
+
+ // macOS prompts for camera and microphone access at the point of use. Asking
+ // here blocks the first window behind two modal OS permission flows and makes
+ // a fresh install look hung. Windows has no equivalent request API, so retain
+ // its diagnostic warnings.
+ if (process.platform === "win32") {
+ const cameraStatus = systemPreferences.getMediaAccessStatus("camera");
+ const micStatus = systemPreferences.getMediaAccessStatus("microphone");
+ if (cameraStatus !== "granted") {
+ console.warn(
+ `[permissions] Camera access is "${cameraStatus}" — webcam may not work. Check Windows Settings > Privacy > Camera.`,
+ );
+ }
+ if (micStatus !== "granted") {
+ console.warn(
+ `[permissions] Microphone access is "${micStatus}" — mic recording may not work. Check Windows Settings > Privacy > Microphone.`,
+ );
+ }
+ }
+
+ ipcMain.on("hud-overlay-close", () => {
+ const hud = getHudOverlayWindow();
+ if (hud) {
+ console.log("[main] Closing HUD window via hud-overlay-close");
+ hud.close();
+ }
+
+ // If this was the last window (or we are in a state where we should quit), do it.
+ // We use a small delay to allow window.close() to propagate.
+ setTimeout(() => {
+ const windows = BrowserWindow.getAllWindows().filter((w) => !w.isDestroyed());
+ if (windows.length === 0) {
+ console.log("[main] No windows left, quitting app");
+ app.quit();
+ }
+ }, 100);
+ });
+ if (process.platform === "darwin" && app.dock) {
+ await app.dock.show();
+ }
+ syncDockIcon();
+ if (shouldUseTray()) {
+ createTray();
+ updateTrayMenu();
+ }
+ setupApplicationMenu();
+ await Promise.all([
+ ensureRecordingsDir(),
+ !VITE_DEV_SERVER_URL
+ ? ensurePackagedRendererServer(RENDERER_DIST).catch((error) => {
+ console.warn(
+ "[renderer-server] Failed to start packaged renderer server:",
+ error,
+ );
+ })
+ : Promise.resolve(),
+ ensureMediaServer().catch((error) => {
+ console.warn("[media-server] Failed to start media server:", error);
+ }),
+ ]);
+
+ registerIpcHandlers(
+ createEditorWindowWrapper,
+ createSourceSelectorWindowWrapper,
+ () => mainWindow,
+ () => sourceSelectorWindow,
+ (recording: boolean, sourceName: string) => {
+ selectedSourceName = sourceName;
+ setHudOverlayRecordingActive(recording);
+ if (shouldUseTray()) {
+ if (!tray) createTray();
+ updateTrayMenu(recording);
+ }
+ if (recording) {
+ reassertHudOverlayMouseState();
+ }
+ if (!recording) {
+ restoreWindowSafely(mainWindow);
+ }
+ },
+ );
+
+ if (IS_SMOKE_EXPORT || process.env.RECORDLY_DEV_OPEN_RECORDING_INPUT) {
+ await logSmokeExportGpuDiagnostics();
+ if (IS_SMOKE_EXPORT) {
+ const smokeSource =
+ process.env.RECORDLY_SMOKE_EXPORT_PROJECT ??
+ process.env.RECORDLY_SMOKE_EXPORT_INPUT ??
+ "";
+ console.log(`[smoke-export] Starting editor smoke export for ${smokeSource}`);
+ } else {
+ console.log(
+ `[dev-open-recording] Starting editor for ${process.env.RECORDLY_DEV_OPEN_RECORDING_INPUT}`,
+ );
+ }
+ createEditorWindowWrapper();
+ return;
+ }
+
+ createWindow();
+ setupAutoUpdates(getUpdateDialogWindow, sendUpdateToastToWindows);
+ if (IS_DEV && process.env.RECORDLY_DEV_PREVIEW_UPDATE === "1") {
+ setTimeout(() => {
+ if (process.platform === "darwin") {
+ previewUpdateToast(sendUpdateToastToWindows);
+ return;
+ }
+
+ void previewNativeUpdateDialog(getUpdateDialogWindow);
+ }, 750);
+ }
+
+ // Register the display media handler so that renderer's getDisplayMedia()
+ // calls land on the pre-selected source without showing a system picker.
+ //
+ // IMPORTANT: The callback must receive a plain { id, name } Video object.
+ // Passing the full DesktopCapturerSource (with thumbnail, appIcon, etc.)
+ // via an unsafe cast breaks Electron's internal cursor-constraint
+ // propagation and causes cursor: 'never' from the renderer to be silently
+ // ignored by the native capture pipeline.
+ session.defaultSession.setDisplayMediaRequestHandler(async (request, callback) => {
+ try {
+ const frame = request.frame;
+ const isLiveFrame = Boolean(frame && !frame.isDestroyed());
+ const requestingWebContents =
+ isLiveFrame && frame ? electronWebContents.fromFrame(frame) : undefined;
+ const isHudMainFrame = Boolean(
+ isLiveFrame &&
+ requestingWebContents &&
+ isHudWebContents(requestingWebContents) &&
+ frame === requestingWebContents.mainFrame,
+ );
+
+ if (
+ !shouldGrantDisplayCapture(
+ {
+ isTrustedCaptureWindow: isHudMainFrame,
+ isMainFrame: Boolean(isLiveFrame && frame?.parent === null),
+ currentDocumentUrl: isLiveFrame ? (frame?.url ?? "") : "",
+ securityOrigin: request.securityOrigin,
+ videoRequested: request.videoRequested,
+ },
+ getTrustedCaptureDocumentBaseUrls(),
+ )
+ ) {
+ callback({});
+ return;
+ }
+
+ // Browser and Linux portal capture starts as soon as this callback
+ // resolves, before recording-state-changed is emitted.
+ beginHudCaptureProtection();
+
+ const sourceId = getSelectedSourceId();
+ // On Linux/Wayland, calling desktopCapturer.getSources() itself
+ // invokes the xdg-desktop-portal picker. If we then return one of
+ // those sources, Chromium triggers a SECOND portal because the
+ // pre-enumerated source IDs are stale on Wayland. To collapse this
+ // into a single portal invocation, when the Linux portal sentinel
+ // is set we skip getSources entirely and hand back a synthetic
+ // source id; Chromium then opens the portal once to actually
+ // resolve the capture.
+ // Default to the sentinel on Linux when no source has been
+ // pre-selected (e.g. fresh session where the renderer skipped the
+ // source picker entirely). This avoids calling getSources() which
+ // would itself trigger an extra portal dialog.
+ const isLinuxPortalSentinel =
+ process.platform === "linux" && (sourceId === "screen:linux-portal" || !sourceId);
+ if (isLinuxPortalSentinel) {
+ callback({ video: { id: "screen:0:0", name: "Entire screen" } });
+ return;
+ }
+ const sources = await desktopCapturer.getSources({ types: ["screen", "window"] });
+ const source = sourceId
+ ? (sources.find((s) => s.id === sourceId) ?? sources[0])
+ : sources[0];
+ if (source) {
+ callback({
+ video: { id: source.id, name: source.name },
+ });
+ } else {
+ callback({});
+ }
+ } catch (error) {
+ console.error("setDisplayMediaRequestHandler error:", error);
+ callback({});
+ }
+ });
+
+ const currentToastPayload = getCurrentUpdateToastPayload();
+ if (currentToastPayload) {
+ sendUpdateToastToWindows("update-toast-state", currentToastPayload);
+ }
+});
diff --git a/electron/mainCjsNormalize.test.mjs b/electron/mainCjsNormalize.test.mjs
new file mode 100644
index 000000000..9ccf91da7
--- /dev/null
+++ b/electron/mainCjsNormalize.test.mjs
@@ -0,0 +1,70 @@
+import { describe, expect, it } from "vitest";
+import {
+ findElectronMainCjsEsmSyntax,
+ normalizeElectronMainCjsSource,
+} from "../scripts/normalize-electron-main-cjs.mjs";
+
+describe("Electron main CJS normalizer", () => {
+ it("converts Rollup named export blocks to CommonJS assignments", () => {
+ const source = [
+ 'const MAIN_DIST = "dist-electron";',
+ 'const RENDERER_DIST = "dist";',
+ "const VITE_DEV_SERVER_URL = process.env.VITE_DEV_SERVER_URL;",
+ "export {",
+ " MAIN_DIST,",
+ " RENDERER_DIST,",
+ " VITE_DEV_SERVER_URL",
+ "};",
+ ].join("\n");
+
+ const result = normalizeElectronMainCjsSource(source);
+
+ expect(result.changed).toBe(true);
+ expect(result.source).toContain("exports.MAIN_DIST = MAIN_DIST;");
+ expect(result.source).toContain("exports.RENDERER_DIST = RENDERER_DIST;");
+ expect(result.source).toContain("exports.VITE_DEV_SERVER_URL = VITE_DEV_SERVER_URL;");
+ expect(findElectronMainCjsEsmSyntax(result.source)).toEqual([]);
+ });
+
+ it("converts single-line aliased named exports to CommonJS assignments", () => {
+ const source = "export { VITE_DEV_SERVER_URL as devServerUrl };";
+
+ const result = normalizeElectronMainCjsSource(source);
+
+ expect(result.changed).toBe(true);
+ expect(result.source).toBe("exports.devServerUrl = VITE_DEV_SERVER_URL;");
+ expect(findElectronMainCjsEsmSyntax(result.source)).toEqual([]);
+ });
+
+ it("reports unsupported ESM export syntax that cannot be normalized safely", () => {
+ const source = "export default MAIN_DIST;";
+
+ expect(findElectronMainCjsEsmSyntax(source)).toEqual([
+ { line: 1, text: "export default MAIN_DIST;" },
+ ]);
+ });
+
+ it("preserves an unsupported export block exactly while normalizing other syntax", () => {
+ const source = [
+ 'import fs from "node:fs";',
+ "export {",
+ ' MAIN_DIST as "main-dist",',
+ "};",
+ ].join("\n");
+
+ const result = normalizeElectronMainCjsSource(source);
+
+ expect(result.changed).toBe(true);
+ expect(result.source).toBe(
+ [
+ 'const fs = require("node:fs");',
+ "export {",
+ ' MAIN_DIST as "main-dist",',
+ "};",
+ ].join("\n"),
+ );
+ expect(findElectronMainCjsEsmSyntax(result.source)).toEqual([
+ { line: 2, text: "export {" },
+ ]);
+ });
+});
diff --git a/electron/mediaServer.test.ts b/electron/mediaServer.test.ts
new file mode 100644
index 000000000..cf6cd01ba
--- /dev/null
+++ b/electron/mediaServer.test.ts
@@ -0,0 +1,105 @@
+import fs from "node:fs/promises";
+import os from "node:os";
+import path from "node:path";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+describe("media server path policy", () => {
+ let tempRoot: string;
+ let appDataPath: string;
+ let userDataPath: string;
+ let tempPath: string;
+ let appPath: string;
+
+ beforeEach(async () => {
+ tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "recordly-media-server-"));
+ appDataPath = path.join(tempRoot, "AppData");
+ userDataPath = path.join(tempRoot, "UserData");
+ tempPath = path.join(tempRoot, "Temp");
+ appPath = path.join(tempRoot, "App");
+
+ await Promise.all(
+ [appDataPath, userDataPath, tempPath, appPath].map((dirPath) =>
+ fs.mkdir(dirPath, { recursive: true }),
+ ),
+ );
+
+ vi.resetModules();
+ vi.doMock("electron", () => ({
+ app: {
+ isPackaged: false,
+ getAppPath: () => appPath,
+ getPath: (name: string) => {
+ if (name === "appData") return appDataPath;
+ if (name === "userData") return userDataPath;
+ if (name === "temp") return tempPath;
+ return tempRoot;
+ },
+ setPath: () => undefined,
+ },
+ }));
+ });
+
+ afterEach(async () => {
+ vi.resetModules();
+ vi.doUnmock("electron");
+ if (tempRoot) {
+ await fs.rm(tempRoot, { recursive: true, force: true });
+ }
+ });
+
+ it("rejects existing media files outside the session directories until they are approved", async () => {
+ const downloadsPath = path.join(tempRoot, "Downloads");
+ const videoPath = path.join(downloadsPath, "personal-video.mp4");
+ await fs.mkdir(downloadsPath, { recursive: true });
+ await fs.writeFile(videoPath, "test-video");
+
+ const { isAllowedMediaPath } = await import("./mediaServer");
+ const { rememberApprovedLocalReadPath } = await import("./ipc/project/manager");
+
+ expect(isAllowedMediaPath(videoPath)).toBe(false);
+
+ await rememberApprovedLocalReadPath(videoPath);
+
+ expect(isAllowedMediaPath(videoPath)).toBe(true);
+ });
+
+ it("rejects missing media files outside the allowed directories", async () => {
+ const missingPath = path.join(tempRoot, "Downloads", "missing.mp4");
+ const { isAllowedMediaPath } = await import("./mediaServer");
+
+ expect(isAllowedMediaPath(missingPath)).toBe(false);
+ });
+});
+
+describe("resolveHttpByteRange", () => {
+ it("rejects malformed and multi-range headers", async () => {
+ const { resolveHttpByteRange } = await import("./mediaServer");
+
+ expect(resolveHttpByteRange("bytes=0-1,2-3", 100)).toBeNull();
+ expect(resolveHttpByteRange("bytes=0-1foo", 100)).toBeNull();
+ });
+
+ it("clamps oversized explicit end offsets to EOF", async () => {
+ const { resolveHttpByteRange } = await import("./mediaServer");
+
+ expect(resolveHttpByteRange("bytes=0-9999999999", 3_221_225_472)).toEqual({
+ start: 0,
+ end: 3_221_225_471,
+ });
+ });
+
+ it("rejects ranges that start beyond EOF", async () => {
+ const { resolveHttpByteRange } = await import("./mediaServer");
+
+ expect(resolveHttpByteRange("bytes=500-999", 500)).toBeNull();
+ });
+
+ it("preserves suffix range semantics", async () => {
+ const { resolveHttpByteRange } = await import("./mediaServer");
+
+ expect(resolveHttpByteRange("bytes=-500", 1_000)).toEqual({
+ start: 500,
+ end: 999,
+ });
+ });
+});
diff --git a/electron/mediaServer.ts b/electron/mediaServer.ts
new file mode 100644
index 000000000..c979a84e8
--- /dev/null
+++ b/electron/mediaServer.ts
@@ -0,0 +1,237 @@
+import { createReadStream } from "node:fs";
+import fs from "node:fs/promises";
+import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
+import path from "node:path";
+import { approvedLocalReadPaths } from "./ipc/state";
+import { getMediaContentType } from "./mediaTypes";
+
+let mediaServerBaseUrl: string | null = null;
+let mediaServerStartPromise: Promise | null = null;
+
+export function resolveHttpByteRange(
+ rangeHeader: string,
+ fileSize: number,
+): { start: number; end: number } | null {
+ const match = rangeHeader.trim().match(/^bytes=(\d*)-(\d*)$/);
+ if (!match || (!match[1] && !match[2])) {
+ return null;
+ }
+
+ if (fileSize === 0) {
+ return null;
+ }
+
+ if (!match[1] && match[2]) {
+ // Suffix range: bytes=-500
+ const suffixLength = Number.parseInt(match[2], 10);
+ if (Number.isNaN(suffixLength) || suffixLength <= 0) {
+ return null;
+ }
+
+ return {
+ start: Math.max(0, fileSize - suffixLength),
+ end: fileSize - 1,
+ };
+ }
+
+ const start = Number.parseInt(match[1], 10);
+ if (Number.isNaN(start) || start < 0 || start >= fileSize) {
+ return null;
+ }
+
+ const requestedEnd = match[2] ? Number.parseInt(match[2], 10) : fileSize - 1;
+ if (Number.isNaN(requestedEnd) || requestedEnd < start) {
+ return null;
+ }
+
+ return {
+ start,
+ end: Math.min(requestedEnd, fileSize - 1),
+ };
+}
+
+async function resolveRealPath(filePath: string): Promise {
+ try {
+ return await fs.realpath(path.resolve(filePath));
+ } catch {
+ return null;
+ }
+}
+
+export function isAllowedMediaPath(realPath: string): boolean {
+ return approvedLocalReadPaths.has(realPath);
+}
+
+async function handleMediaRequest(
+ request: IncomingMessage,
+ response: ServerResponse,
+): Promise {
+ try {
+ const url = new URL(request.url ?? "/", "http://127.0.0.1");
+
+ if (url.pathname !== "/video") {
+ response.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
+ response.end("Not Found");
+ return;
+ }
+
+ const rawPath = url.searchParams.get("path");
+ if (!rawPath) {
+ response.writeHead(400, { "Content-Type": "text/plain; charset=utf-8" });
+ response.end("Missing path parameter");
+ return;
+ }
+
+ const resolvedPath = await resolveRealPath(rawPath);
+ if (!resolvedPath || !isAllowedMediaPath(resolvedPath)) {
+ console.warn(`[media-server] Blocked access to unapproved path: ${rawPath}`);
+ response.writeHead(403, { "Content-Type": "text/plain; charset=utf-8" });
+ response.end("Forbidden");
+ return;
+ }
+
+ const stat = await fs.stat(resolvedPath);
+ if (!stat.isFile()) {
+ response.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
+ response.end("Not Found");
+ return;
+ }
+
+ const contentType = getMediaContentType(resolvedPath);
+ const fileSize = stat.size;
+ const rangeHeader = request.headers.range;
+
+ const corsHeaders = {
+ "Access-Control-Allow-Origin": "*",
+ "Access-Control-Allow-Credentials": "false",
+ "Access-Control-Expose-Headers": "Content-Range, Content-Length, Accept-Ranges",
+ };
+
+ if (request.method === "OPTIONS") {
+ response.writeHead(204, {
+ ...corsHeaders,
+ "Access-Control-Allow-Methods": "GET, HEAD, OPTIONS",
+ "Access-Control-Allow-Headers": "Range",
+ });
+ response.end();
+ return;
+ }
+
+ if (rangeHeader) {
+ if (fileSize === 0) {
+ response.writeHead(416, { ...corsHeaders, "Content-Range": `bytes */0` });
+ response.end();
+ return;
+ }
+
+ const byteRange = resolveHttpByteRange(rangeHeader, fileSize);
+ if (!byteRange) {
+ response.writeHead(416, { ...corsHeaders, "Content-Range": `bytes */${fileSize}` });
+ response.end();
+ return;
+ }
+
+ const { start, end } = byteRange;
+
+ const chunkSize = end - start + 1;
+ response.writeHead(206, {
+ ...corsHeaders,
+ "Content-Range": `bytes ${start}-${end}/${fileSize}`,
+ "Accept-Ranges": "bytes",
+ "Content-Length": String(chunkSize),
+ "Content-Type": contentType,
+ "Cache-Control": "no-cache",
+ });
+
+ if (request.method === "HEAD") {
+ response.end();
+ return;
+ }
+
+ const stream = createReadStream(resolvedPath, { start, end });
+ stream.pipe(response);
+ stream.on("error", () => {
+ if (!response.headersSent) {
+ response.writeHead(500, { "Content-Type": "text/plain" });
+ }
+ response.end();
+ });
+ } else {
+ response.writeHead(200, {
+ ...corsHeaders,
+ "Accept-Ranges": "bytes",
+ "Content-Length": String(fileSize),
+ "Content-Type": contentType,
+ "Cache-Control": "no-cache",
+ });
+
+ if (request.method === "HEAD") {
+ response.end();
+ return;
+ }
+
+ const stream = createReadStream(resolvedPath);
+ stream.pipe(response);
+ stream.on("error", () => {
+ if (!response.headersSent) {
+ response.writeHead(500, { "Content-Type": "text/plain" });
+ }
+ response.end();
+ });
+ }
+ } catch (error) {
+ if ((error as NodeJS.ErrnoException)?.code === "ENOENT") {
+ response.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
+ response.end("Not Found");
+ return;
+ }
+
+ console.error("[media-server] Error handling request:", error);
+ response.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" });
+ response.end("Internal Server Error");
+ }
+}
+
+export function getMediaServerBaseUrl(): string | null {
+ return mediaServerBaseUrl;
+}
+
+export async function ensureMediaServer(): Promise {
+ if (mediaServerBaseUrl) {
+ return mediaServerBaseUrl;
+ }
+
+ if (mediaServerStartPromise) {
+ return mediaServerStartPromise;
+ }
+
+ mediaServerStartPromise = new Promise((resolve, reject) => {
+ const server = createServer((request, response) => {
+ void handleMediaRequest(request, response);
+ });
+
+ server.once("error", (error) => {
+ reject(error);
+ });
+
+ server.listen(0, "127.0.0.1", () => {
+ const address = server.address();
+ if (!address || typeof address === "string") {
+ server.close();
+ reject(new Error("Media server did not expose a TCP address"));
+ return;
+ }
+
+ mediaServerBaseUrl = `http://127.0.0.1:${address.port}`;
+ console.log(`[media-server] Listening at ${mediaServerBaseUrl}`);
+ resolve(mediaServerBaseUrl);
+ });
+ });
+
+ return mediaServerStartPromise;
+}
+
+export function buildMediaUrl(baseUrl: string, filePath: string): string {
+ const resolved = path.resolve(filePath);
+ return `${baseUrl}/video?path=${encodeURIComponent(resolved)}`;
+}
diff --git a/electron/mediaTypes.ts b/electron/mediaTypes.ts
new file mode 100644
index 000000000..5c4307b63
--- /dev/null
+++ b/electron/mediaTypes.ts
@@ -0,0 +1,23 @@
+import path from "node:path";
+
+export const MEDIA_CONTENT_TYPES: Record = {
+ ".mp4": "video/mp4",
+ ".webm": "video/webm",
+ ".mov": "video/quicktime",
+ ".mkv": "video/x-matroska",
+ ".avi": "video/x-msvideo",
+ ".wav": "audio/wav",
+ ".mp3": "audio/mpeg",
+ ".ogg": "audio/ogg",
+ ".png": "image/png",
+ ".jpg": "image/jpeg",
+ ".jpeg": "image/jpeg",
+};
+
+export function getMediaContentType(filePath: string): string {
+ return MEDIA_CONTENT_TYPES[path.extname(filePath).toLowerCase()] ?? "application/octet-stream";
+}
+
+export function isSupportedLocalMediaPath(filePath: string): boolean {
+ return path.extname(filePath).toLowerCase() in MEDIA_CONTENT_TYPES;
+}
diff --git a/electron/native/NativeCursorMonitor.swift b/electron/native/NativeCursorMonitor.swift
new file mode 100644
index 000000000..18c644150
--- /dev/null
+++ b/electron/native/NativeCursorMonitor.swift
@@ -0,0 +1,505 @@
+import Foundation
+import AppKit
+import ApplicationServices
+
+// Force AppKit initialization so cursor images are populated in CLI context
+let _ = NSApplication.shared
+
+func knownCursorCandidates() -> [(String, NSCursor)] {
+ var candidates: [(String, NSCursor)] = [
+ ("arrow", .arrow),
+ ("text", .iBeam),
+ ("pointer", .pointingHand),
+ ("pointer", .dragCopy),
+ ("pointer", .dragLink),
+ ("pointer", .contextualMenu),
+ ("crosshair", .crosshair),
+ ("open-hand", .openHand),
+ ("closed-hand", .closedHand),
+ ("resize-ew", .resizeLeft),
+ ("resize-ew", .resizeRight),
+ ("resize-ew", .resizeLeftRight),
+ ("resize-ns", .resizeUp),
+ ("resize-ns", .resizeDown),
+ ("resize-ns", .resizeUpDown),
+ ("not-allowed", .operationNotAllowed),
+ ]
+
+ if #available(macOS 10.13, *) {
+ candidates.append(("text", .iBeamCursorForVerticalLayout))
+ }
+
+ return candidates
+}
+
+let signatureAcceptanceThreshold = 12000
+let relaxedSignatureAcceptanceThresholds: [String: Int] = [
+ "text": 28000,
+ "crosshair": 32000,
+]
+let strictSignatureAcceptanceThresholds: [String: Int] = [
+ "open-hand": 3200,
+ "closed-hand": 3200,
+]
+
+let systemWideElement = AXUIElementCreateSystemWide()
+let totalScreenHeight = NSScreen.screens.reduce(CGFloat(0)) { max($0, $1.frame.maxY) }
+let axEditableAttribute = "AXEditable"
+let axLinkRole = "AXLink"
+
+struct CursorSignature {
+ let aspectRatio: Double
+ let hotspotXRatio: Double
+ let hotspotYRatio: Double
+ let shapeSamples: [UInt8]
+}
+
+func signature(for cursor: NSCursor, sampleSize: Int = 32) -> CursorSignature? {
+ let image = cursor.image
+ let sourceSize = image.size
+ guard sourceSize.width > 0, sourceSize.height > 0 else {
+ return nil
+ }
+
+ guard let bitmap = NSBitmapImageRep(
+ bitmapDataPlanes: nil,
+ pixelsWide: sampleSize,
+ pixelsHigh: sampleSize,
+ bitsPerSample: 8,
+ samplesPerPixel: 4,
+ hasAlpha: true,
+ isPlanar: false,
+ colorSpaceName: .deviceRGB,
+ bytesPerRow: 0,
+ bitsPerPixel: 0
+ ) else {
+ return nil
+ }
+
+ bitmap.size = NSSize(width: sampleSize, height: sampleSize)
+
+ NSGraphicsContext.saveGraphicsState()
+ guard let context = NSGraphicsContext(bitmapImageRep: bitmap) else {
+ NSGraphicsContext.restoreGraphicsState()
+ return nil
+ }
+
+ NSGraphicsContext.current = context
+ context.imageInterpolation = .high
+
+ let scale = min(CGFloat(sampleSize) / sourceSize.width, CGFloat(sampleSize) / sourceSize.height)
+ let drawWidth = sourceSize.width * scale
+ let drawHeight = sourceSize.height * scale
+ let drawRect = NSRect(
+ x: (CGFloat(sampleSize) - drawWidth) / 2,
+ y: (CGFloat(sampleSize) - drawHeight) / 2,
+ width: drawWidth,
+ height: drawHeight
+ )
+
+ image.draw(in: drawRect, from: .zero, operation: .copy, fraction: 1)
+ context.flushGraphics()
+ NSGraphicsContext.restoreGraphicsState()
+
+ guard let data = bitmap.bitmapData else {
+ return nil
+ }
+
+ var alphaSamples: [UInt8] = []
+ alphaSamples.reserveCapacity(sampleSize * sampleSize)
+ let bytesPerRow = bitmap.bytesPerRow
+
+ for y in 0.. 24 ? 255 : 0)
+ }
+ }
+
+ let hotspot = cursor.hotSpot
+ return CursorSignature(
+ aspectRatio: Double(sourceSize.width / max(1, sourceSize.height)),
+ hotspotXRatio: Double(hotspot.x / max(1, sourceSize.width)),
+ hotspotYRatio: Double(hotspot.y / max(1, sourceSize.height)),
+ shapeSamples: alphaSamples
+ )
+}
+
+func signatureScore(_ lhs: CursorSignature, _ rhs: CursorSignature) -> Int {
+ let count = min(lhs.shapeSamples.count, rhs.shapeSamples.count)
+ var imageDifference = 0
+ for index in 0.. String? {
+ var value: CFTypeRef?
+ let error = AXUIElementCopyAttributeValue(element, attribute as CFString, &value)
+ guard error == .success else {
+ return nil
+ }
+
+ return value as? String
+}
+
+func attributeBool(_ element: AXUIElement, _ attribute: String) -> Bool? {
+ var value: CFTypeRef?
+ let error = AXUIElementCopyAttributeValue(element, attribute as CFString, &value)
+ guard error == .success else {
+ return nil
+ }
+
+ return value as? Bool
+}
+
+func actionNames(_ element: AXUIElement) -> [String] {
+ var names: CFArray?
+ let error = AXUIElementCopyActionNames(element, &names)
+ guard error == .success, let actions = names as? [String] else {
+ return []
+ }
+
+ return actions
+}
+
+func currentElement() -> AXUIElement? {
+ guard let location = CGEvent(source: nil)?.location else {
+ return nil
+ }
+
+ var element: AXUIElement?
+ let y = totalScreenHeight > 0 ? totalScreenHeight - location.y : location.y
+ let error = AXUIElementCopyElementAtPosition(systemWideElement, Float(location.x), Float(y), &element)
+ guard error == .success else {
+ return nil
+ }
+
+ return element
+}
+
+func focusedElement() -> AXUIElement? {
+ var value: CFTypeRef?
+ let error = AXUIElementCopyAttributeValue(systemWideElement, kAXFocusedUIElementAttribute as CFString, &value)
+ guard error == .success, let value else {
+ return nil
+ }
+
+ return unsafeBitCast(value, to: AXUIElement.self)
+}
+
+func parentElement(of element: AXUIElement) -> AXUIElement? {
+ var value: CFTypeRef?
+ let error = AXUIElementCopyAttributeValue(element, kAXParentAttribute as CFString, &value)
+ guard error == .success, let value else {
+ return nil
+ }
+
+ return unsafeBitCast(value, to: AXUIElement.self)
+}
+
+func hasAttribute(_ element: AXUIElement, _ attribute: String) -> Bool {
+ var value: CFTypeRef?
+ return AXUIElementCopyAttributeValue(element, attribute as CFString, &value) == .success
+}
+
+func ancestorChain(startingAt element: AXUIElement?, maxDepth: Int = 4) -> [AXUIElement] {
+ guard let element else {
+ return []
+ }
+
+ var elements: [AXUIElement] = [element]
+ var current = element
+ var depth = 0
+
+ while depth < maxDepth, let parent = parentElement(of: current) {
+ elements.append(parent)
+ current = parent
+ depth += 1
+ }
+
+ return elements
+}
+
+func metadataString(for element: AXUIElement) -> String {
+ return [
+ attributeString(element, kAXRoleAttribute),
+ attributeString(element, kAXSubroleAttribute),
+ attributeString(element, kAXRoleDescriptionAttribute),
+ attributeString(element, kAXDescriptionAttribute),
+ attributeString(element, kAXHelpAttribute),
+ attributeString(element, kAXTitleAttribute),
+ ]
+ .compactMap { $0?.lowercased() }
+ .joined(separator: " ")
+}
+
+func elementLooksTextual(_ element: AXUIElement) -> Bool {
+ let role = attributeString(element, kAXRoleAttribute)
+ let subrole = attributeString(element, kAXSubroleAttribute)
+ let editable = attributeBool(element, axEditableAttribute)
+ let metadata = metadataString(for: element)
+ let actions = actionNames(element)
+
+ let textRoles: Set = [
+ kAXTextFieldRole as String,
+ kAXTextAreaRole as String,
+ kAXComboBoxRole as String,
+ kAXSearchFieldSubrole as String,
+ ]
+
+ if editable == true || textRoles.contains(role ?? "") || textRoles.contains(subrole ?? "") {
+ return true
+ }
+
+ if metadata.contains("text field")
+ || metadata.contains("search field")
+ || metadata.contains("editor")
+ || metadata.contains("insertion point")
+ || metadata.contains("caret")
+ || metadata.contains("source editor") {
+ return true
+ }
+
+ return hasAttribute(element, kAXSelectedTextRangeAttribute as String)
+ || hasAttribute(element, kAXNumberOfCharactersAttribute as String)
+ || (actions.contains(kAXPressAction as String) && metadata.contains("text"))
+}
+
+func accessibilityCursorMatch() -> String? {
+ let hoveredChain = ancestorChain(startingAt: currentElement())
+ let focusedChain = ancestorChain(startingAt: focusedElement())
+
+ for element in hoveredChain + focusedChain {
+ if elementLooksTextual(element) {
+ return "text"
+ }
+ }
+
+ guard let element = hoveredChain.first else {
+ return nil
+ }
+
+ let role = attributeString(element, kAXRoleAttribute)
+ let enabled = attributeBool(element, kAXEnabledAttribute)
+ let actions = actionNames(element)
+ let metadata = hoveredChain
+ .map { metadataString(for: $0) }
+ .filter { !$0.isEmpty }
+ .joined(separator: " ")
+ if metadata.contains("crosshair") || metadata.contains("cross hair") || metadata.contains("precision") || metadata.contains("crop") {
+ return "crosshair"
+ }
+
+ if role == kAXSplitterRole as String {
+ return "resize-ew"
+ }
+
+ let pressableRoles: Set = [
+ kAXButtonRole as String,
+ axLinkRole,
+ kAXMenuItemRole as String,
+ kAXPopUpButtonRole as String,
+ kAXRadioButtonRole as String,
+ kAXCheckBoxRole as String,
+ kAXTabGroupRole as String,
+ ]
+ let hasPressAction = actions.contains(kAXPressAction as String)
+ if enabled == false && (hasPressAction || pressableRoles.contains(role ?? "")) {
+ return "not-allowed"
+ }
+ if hasPressAction || pressableRoles.contains(role ?? "") {
+ return "pointer"
+ }
+
+ return nil
+}
+
+func currentSystemCursorType() -> String {
+ let resolvedCursor: NSCursor? = DispatchQueue.main.sync {
+ if #available(macOS 14.0, *) {
+ return NSCursor.currentSystem ?? NSCursor.current
+ }
+
+ return NSCursor.current
+ }
+
+ guard let resolvedCursor else {
+ return accessibilityCursorMatch() ?? "arrow"
+ }
+
+ guard let currentSignature = signature(for: resolvedCursor) else {
+ return accessibilityCursorMatch() ?? "arrow"
+ }
+
+ guard let bestMatch = knownCursorSignatures.min(by: { lhs, rhs in
+ signatureScore(currentSignature, lhs.1) < signatureScore(currentSignature, rhs.1)
+ }) else {
+ return accessibilityCursorMatch() ?? "arrow"
+ }
+
+ let bestScore = signatureScore(currentSignature, bestMatch.1)
+ let primaryThreshold = strictSignatureAcceptanceThresholds[bestMatch.0] ?? signatureAcceptanceThreshold
+ let matchedCursorType: String
+ if bestScore > primaryThreshold {
+ if let relaxedThreshold = relaxedSignatureAcceptanceThresholds[bestMatch.0], bestScore <= relaxedThreshold {
+ matchedCursorType = bestMatch.0
+ } else {
+ matchedCursorType = "arrow"
+ }
+ } else {
+ matchedCursorType = bestMatch.0
+ }
+
+ return matchedCursorType
+}
+
+func exportCursorImages() {
+ let cursorForType: [(String, NSCursor)] = [
+ ("arrow", .arrow),
+ ("text", .iBeam),
+ ("pointer", .pointingHand),
+ ("crosshair", .crosshair),
+ ("open-hand", .openHand),
+ ("closed-hand", .closedHand),
+ ("resize-ew", .resizeLeftRight),
+ ("resize-ns", .resizeUpDown),
+ ("not-allowed", .operationNotAllowed),
+ ]
+
+ for (name, cursor) in cursorForType {
+ let image = cursor.image
+ let hotspot = cursor.hotSpot
+ let size = image.size
+ guard size.width > 0, size.height > 0 else { continue }
+
+ guard let tiffData = image.tiffRepresentation,
+ let bitmapRep = NSBitmapImageRep(data: tiffData),
+ let pngData = bitmapRep.representation(using: .png, properties: [:]) else {
+ continue
+ }
+
+ let base64 = pngData.base64EncodedString()
+ let hotspotXRatio = hotspot.x / size.width
+ let hotspotYRatio = hotspot.y / size.height
+ let aspectRatio = size.width / size.height
+ print("CURSOR_IMAGE:\(name):\(hotspotXRatio):\(hotspotYRatio):\(aspectRatio):\(base64)")
+ fflush(stdout)
+ }
+}
+
+if CommandLine.arguments.contains("--export-images") {
+ exportCursorImages()
+ exit(0)
+}
+
+func mouseInteractionCallback(
+ proxy: CGEventTapProxy,
+ type: CGEventType,
+ event: CGEvent,
+ refcon: UnsafeMutableRawPointer?
+) -> Unmanaged? {
+ let action: String
+ let button: Int
+ switch type {
+ case .leftMouseDown:
+ action = "mousedown"
+ button = 1
+ case .leftMouseUp:
+ action = "mouseup"
+ button = 1
+ case .rightMouseDown:
+ action = "mousedown"
+ button = 2
+ case .rightMouseUp:
+ action = "mouseup"
+ button = 2
+ case .otherMouseDown:
+ guard event.getIntegerValueField(.mouseEventButtonNumber) == 2 else {
+ return Unmanaged.passUnretained(event)
+ }
+ action = "mousedown"
+ button = 3
+ case .otherMouseUp:
+ guard event.getIntegerValueField(.mouseEventButtonNumber) == 2 else {
+ return Unmanaged.passUnretained(event)
+ }
+ action = "mouseup"
+ button = 3
+ default:
+ return Unmanaged.passUnretained(event)
+ }
+
+ print("INTERACTION:\(action):\(button)")
+ fflush(stdout)
+ return Unmanaged.passUnretained(event)
+}
+
+let mouseEventTypes: [CGEventType] = [
+ .leftMouseDown,
+ .leftMouseUp,
+ .rightMouseDown,
+ .rightMouseUp,
+ .otherMouseDown,
+ .otherMouseUp,
+]
+let mouseEventMask = mouseEventTypes.reduce(CGEventMask(0)) { mask, type in
+ mask | (CGEventMask(1) << type.rawValue)
+}
+let mouseEventTap = CGEvent.tapCreate(
+ tap: .cgSessionEventTap,
+ place: .headInsertEventTap,
+ options: .listenOnly,
+ eventsOfInterest: mouseEventMask,
+ callback: mouseInteractionCallback,
+ userInfo: nil
+)
+if let mouseEventTap,
+ let eventTapSource = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, mouseEventTap, 0) {
+ CFRunLoopAddSource(CFRunLoopGetMain(), eventTapSource, .commonModes)
+ CGEvent.tapEnable(tap: mouseEventTap, enable: true)
+} else {
+ fputs("Mouse interaction event tap unavailable; click telemetry disabled\n", stderr)
+ fflush(stderr)
+}
+
+var lastState = ""
+func emitStateIfNeeded() {
+ let state = currentSystemCursorType()
+ if state != lastState {
+ lastState = state
+ print("STATE:\(state)")
+ fflush(stdout)
+ }
+}
+
+let timer = DispatchSource.makeTimerSource(queue: DispatchQueue.global(qos: .utility))
+timer.schedule(deadline: .now(), repeating: .milliseconds(50))
+timer.setEventHandler {
+ emitStateIfNeeded()
+}
+timer.resume()
+
+DispatchQueue.global(qos: .utility).async {
+ while let line = readLine(strippingNewline: true)?.lowercased() {
+ if line == "stop" {
+ exit(0)
+ }
+ }
+ exit(0)
+}
+
+RunLoop.main.run()
diff --git a/electron/native/ScreenCaptureKitRecorder.lifecycle.test.ts b/electron/native/ScreenCaptureKitRecorder.lifecycle.test.ts
new file mode 100644
index 000000000..1e01a43eb
--- /dev/null
+++ b/electron/native/ScreenCaptureKitRecorder.lifecycle.test.ts
@@ -0,0 +1,103 @@
+import { spawnSync } from "node:child_process";
+import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { afterAll, beforeAll, describe, expect, it } from "vitest";
+
+// Exercise the production service and stdin reader in a real Swift process.
+// Only the device backend is simulated: CI must not request screen/mic access.
+describe.skipIf(process.platform !== "darwin")("recorder process lifecycle", () => {
+ let directory: string;
+ let executable: string;
+ beforeAll(() => {
+ directory = mkdtempSync(join(tmpdir(), "recordly-lifecycle-"));
+ executable = join(directory, "helper");
+ const source = readFileSync(
+ new URL("./ScreenCaptureKitRecorder.swift", import.meta.url),
+ "utf8",
+ );
+ const service = source.slice(
+ source.indexOf("final class RecorderService"),
+ source.indexOf("guard CommandLine.arguments.count"),
+ );
+ const commands = source
+ .slice(
+ source.indexOf("let service = RecorderService()"),
+ source.indexOf("if !service.waitUntilFinished()"),
+ )
+ .replace(
+ "service.stop()",
+ "service.stop()\n\tservice.drainCommandsForTest()\n\tcommandsFinished.signal()",
+ );
+ writeFileSync(
+ join(directory, "main.swift"),
+ `
+import Foundation
+final class ScreenCaptureRecorder {
+ var output = ""
+ func startCapture(configJSON: String) async throws {
+ try await Task.sleep(nanoseconds: 50_000_000)
+ if configJSON == "fail" { throw NSError(domain: "startup", code: 1) }
+ output = configJSON
+ try "recording".write(toFile: output, atomically: true, encoding: .utf8)
+ }
+ func stopCapture() async throws -> String {
+ print("BACKEND_STOP")
+ fflush(stdout)
+ if output.isEmpty { throw NSError(domain: "inactive", code: 2) }
+ try "finalized".write(toFile: output, atomically: true, encoding: .utf8)
+ return output
+ }
+ func pauseCapture() async -> Bool { true }
+ func resumeCapture() async -> Bool { true }
+}
+${service}
+extension RecorderService {
+ func drainCommandsForTest() { queue.sync {} }
+}
+let commandsFinished = DispatchSemaphore(value: 0)
+${commands}
+let success = service.waitUntilFinished()
+// Wait for the reader to enqueue stop AND for the serialized operations to finish.
+commandsFinished.wait()
+exit(success ? 0 : 1)
+`,
+ );
+ const build = spawnSync(
+ "swiftc",
+ [
+ "-module-cache-path",
+ join(directory, "cache"),
+ join(directory, "main.swift"),
+ "-o",
+ executable,
+ ],
+ { encoding: "utf8", timeout: 60_000 },
+ );
+ expect(build.status, build.stderr).toBe(0);
+ }, 60_000);
+ afterAll(() => {
+ if (directory) rmSync(directory, { recursive: true, force: true });
+ });
+
+ it.each([
+ "",
+ "stop\n",
+ "pause\nresume\n",
+ "stop\nstop\n",
+ ])("finalizes once with stdin %j", (input) => {
+ const output = join(directory, "recording");
+ const result = spawnSync(executable, [output], { input, encoding: "utf8", timeout: 5_000 });
+ expect(result.status, result.stderr).toBe(0);
+ expect(result.stdout.match(/BACKEND_STOP/g)).toHaveLength(1);
+ expect(readFileSync(output, "utf8")).toBe("finalized");
+ });
+
+ it.each(["", "stop\n"])("exits cleanly after failed startup with stdin %j", (input) => {
+ const result = spawnSync(executable, ["fail"], { input, encoding: "utf8", timeout: 5_000 });
+ expect(result.signal, result.stderr).toBeNull();
+ expect(result.status, result.stderr).toBe(1);
+ expect(result.stderr).toContain("Error starting capture");
+ expect(result.stdout).not.toContain("BACKEND_STOP");
+ });
+});
diff --git a/electron/native/ScreenCaptureKitRecorder.swift b/electron/native/ScreenCaptureKitRecorder.swift
new file mode 100644
index 000000000..002b1a67e
--- /dev/null
+++ b/electron/native/ScreenCaptureKitRecorder.swift
@@ -0,0 +1,1105 @@
+import Foundation
+import ScreenCaptureKit
+import AVFoundation
+import CoreGraphics
+import CoreImage
+
+struct CaptureConfig: Codable {
+ let fps: Int?
+ let displayId: CGDirectDisplayID?
+ let windowId: UInt32?
+ let windowX: Double?
+ let windowY: Double?
+ let windowWidth: Double?
+ let windowHeight: Double?
+ let outputPath: String?
+ let capturesSystemAudio: Bool?
+ let capturesMicrophone: Bool?
+ let systemAudioOutputPath: String?
+ let microphoneDeviceId: String?
+ let microphoneLabel: String?
+ let microphoneOutputPath: String?
+ let excludedProcessIds: [Int32]?
+}
+
+let targetCaptureFPS = 60
+let maxInlineAudioTailExtension = CMTime(seconds: 2.0, preferredTimescale: 600)
+/// How long finalization waits for a backed-up encoder queue before giving up on
+/// the optional tail frame: 100 polls x 10 ms = 1 s.
+let writerReadinessPollAttempts = 100
+let writerReadinessPollInterval: UInt64 = 10_000_000
+
+final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate {
+ private struct CaptureFinalizationResult {
+ let outputResult: Result
+ let interactiveStopParticipated: Bool
+ }
+
+ private let queue = DispatchQueue(label: "recordly.screencapturekit.video")
+ private var assetWriter: AVAssetWriter?
+ private var videoInput: AVAssetWriterInput?
+ private var videoPixelBufferAdaptor: AVAssetWriterInputPixelBufferAdaptor?
+ private var windowCropRect: CGRect?
+ private var windowCropDisplayId: CGDirectDisplayID?
+ private var excludedProcessIds = Set()
+ private var lastCroppedPixelBuffer: CVPixelBuffer?
+ private let imageContext = CIContext(options: [.cacheIntermediates: false])
+ private var systemAudioWriter: AVAssetWriter?
+ private var systemAudioInput: AVAssetWriterInput?
+ private var microphoneOnlyWriter: AVAssetWriter?
+ private var microphoneOnlyInput: AVAssetWriterInput?
+ private var stream: SCStream?
+ private var firstSampleTime: CMTime = .zero
+ private var firstSystemAudioSampleTime: CMTime?
+ private var firstMicrophoneSampleTime: CMTime?
+ private var lastSystemAudioPresentationTime: CMTime = .invalid
+ private var lastMicrophonePresentationTime: CMTime = .invalid
+ private var lastSampleBuffer: CMSampleBuffer?
+ private var lastVideoPresentationTime: CMTime = .zero
+ private var lastVideoDuration: CMTime = .zero
+ private var lastInlineAudioPresentationTime: CMTime = .invalid
+ private var lastInlineAudioDuration: CMTime = .zero
+ private var isRecording = false
+ private var isPaused = false
+ private var pauseStartedHostTime: CMTime?
+ private var pendingResumeAdjustment = false
+ private var accumulatedPausedDuration: CMTime = .zero
+ private var sessionStarted = false
+ private var frameCount = 0
+ private var outputURL: URL?
+ private var microphoneOutputURL: URL?
+ private var trackedWindowId: UInt32?
+ private var windowValidationTask: Task?
+ private var isFinalizing = false
+ private var interactiveStopParticipated = false
+ private var finalizationWaiters: [CheckedContinuation] = []
+ private var inlineAudioInput: AVAssetWriterInput?
+ private var firstInlineAudioSampleTime: CMTime?
+ private var capturesSystemAudio = false
+ private var capturesMicrophone = false
+ private var writesSystemAudioToSeparateTrack = false
+ private var writesMicrophoneToSeparateTrack = false
+
+ private let microphoneOutputTypeRawValue = 2
+
+ func startCapture(configJSON: String) async throws {
+ guard !isRecording else {
+ throw NSError(domain: "RecordlyCapture", code: 1, userInfo: [NSLocalizedDescriptionKey: "Recording is already in progress"])
+ }
+
+ guard let data = configJSON.data(using: .utf8) else {
+ throw NSError(domain: "RecordlyCapture", code: 2, userInfo: [NSLocalizedDescriptionKey: "Invalid JSON input"])
+ }
+
+ let config = try JSONDecoder().decode(CaptureConfig.self, from: data)
+ let availableContent = try await SCShareableContent.excludingDesktopWindows(false, onScreenWindowsOnly: true)
+ let streamConfig = SCStreamConfiguration()
+ capturesSystemAudio = config.capturesSystemAudio ?? false
+ capturesMicrophone = config.capturesMicrophone ?? false
+ if capturesMicrophone && !supportsNativeMicrophoneCapture(streamConfig: streamConfig) {
+ fputs("MICROPHONE_CAPTURE_UNAVAILABLE\n", stderr)
+ fflush(stderr)
+ capturesMicrophone = false
+ }
+ writesSystemAudioToSeparateTrack = capturesSystemAudio
+ writesMicrophoneToSeparateTrack = capturesSystemAudio && capturesMicrophone
+ let requestedFPS = max(targetCaptureFPS, config.fps ?? targetCaptureFPS)
+ streamConfig.minimumFrameInterval = CMTime(value: 1, timescale: CMTimeScale(requestedFPS))
+ streamConfig.queueDepth = 6
+ streamConfig.pixelFormat = kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange
+ streamConfig.colorSpaceName = CGColorSpace.sRGB
+ streamConfig.colorMatrix = CGDisplayStream.yCbCrMatrix_ITU_R_709_2
+ streamConfig.showsCursor = false
+ streamConfig.capturesAudio = capturesSystemAudio || capturesMicrophone
+ streamConfig.sampleRate = 48000
+ streamConfig.channelCount = 2
+ streamConfig.excludesCurrentProcessAudio = true
+
+ if capturesMicrophone {
+ streamConfig.setValue(true, forKey: "captureMicrophone")
+ if let microphoneDeviceId = Self.resolveMicrophoneCaptureDeviceID(config: config) {
+ streamConfig.setValue(microphoneDeviceId, forKey: "microphoneCaptureDeviceID")
+ }
+ }
+
+ let filter: SCContentFilter
+ let outputWidth: Int
+ let outputHeight: Int
+ excludedProcessIds = Set(config.excludedProcessIds ?? [])
+ let excludedApplications = availableContent.applications.filter {
+ excludedProcessIds.contains($0.processID)
+ }
+
+ if let windowId = config.windowId {
+ trackedWindowId = windowId
+ guard let window = availableContent.windows.first(where: { $0.windowID == windowId }) else {
+ throw NSError(domain: "RecordlyCapture", code: 3, userInfo: [NSLocalizedDescriptionKey: "Window not found"])
+ }
+
+ // Accessibility reports the visible frame at the native border.
+ let visibleFrame: CGRect
+ if let x = config.windowX,
+ let y = config.windowY,
+ let width = config.windowWidth,
+ let height = config.windowHeight,
+ width > 0,
+ height > 0 {
+ visibleFrame = CGRect(x: x, y: y, width: width, height: height)
+ } else {
+ visibleFrame = window.frame
+ }
+ guard let display = Self.captureDisplay(for: visibleFrame, from: availableContent.displays) else {
+ throw NSError(domain: "RecordlyCapture", code: 4, userInfo: [NSLocalizedDescriptionKey: "Window display not found"])
+ }
+ let scaleFactor = ScreenCaptureRecorder.scaleFactor(for: display.displayID)
+ let captureRect = visibleFrame.intersection(display.frame)
+ filter = SCContentFilter(
+ display: display,
+ excludingApplications: excludedApplications,
+ exceptingWindows: []
+ )
+ windowCropRect = CGRect(
+ x: (captureRect.minX - display.frame.minX) / display.frame.width,
+ y: (captureRect.minY - display.frame.minY) / display.frame.height,
+ width: captureRect.width / display.frame.width,
+ height: captureRect.height / display.frame.height
+ )
+ windowCropDisplayId = display.displayID
+ outputWidth = max(2, Int(captureRect.width) * scaleFactor) & ~1
+ outputHeight = max(2, Int(captureRect.height) * scaleFactor) & ~1
+ streamConfig.width = max(2, Int(display.frame.width) * scaleFactor)
+ streamConfig.height = max(2, Int(display.frame.height) * scaleFactor)
+ streamConfig.pixelFormat = kCVPixelFormatType_32BGRA
+ } else {
+ trackedWindowId = nil
+ windowCropRect = nil
+ windowCropDisplayId = nil
+ let displayId = config.displayId ?? CGMainDisplayID()
+ guard let display = availableContent.displays.first(where: { $0.displayID == displayId }) else {
+ throw NSError(domain: "RecordlyCapture", code: 4, userInfo: [NSLocalizedDescriptionKey: "Display not found"])
+ }
+
+ filter = SCContentFilter(
+ display: display,
+ excludingApplications: excludedApplications,
+ exceptingWindows: []
+ )
+ let displayBounds = CGDisplayBounds(display.displayID)
+ let scaleFactor = ScreenCaptureRecorder.scaleFactor(for: display.displayID)
+ outputWidth = max(2, Int(displayBounds.width) * scaleFactor)
+ outputHeight = max(2, Int(displayBounds.height) * scaleFactor)
+ streamConfig.width = outputWidth
+ streamConfig.height = outputHeight
+ }
+
+ let destinationURL: URL
+ if let outputPath = config.outputPath, !outputPath.isEmpty {
+ destinationURL = URL(fileURLWithPath: outputPath)
+ } else {
+ destinationURL = URL(fileURLWithPath: FileManager.default.currentDirectoryPath)
+ .appendingPathComponent("output_\(Int(Date().timeIntervalSince1970)).mp4")
+ }
+
+ outputURL = destinationURL
+ let outputFileType: AVFileType = destinationURL.pathExtension.lowercased() == "mp4" ? .mp4 : .mov
+ assetWriter = try AVAssetWriter(url: destinationURL, fileType: outputFileType)
+ microphoneOutputURL = nil
+ firstSystemAudioSampleTime = nil
+ firstMicrophoneSampleTime = nil
+ lastSystemAudioPresentationTime = .invalid
+ lastMicrophonePresentationTime = .invalid
+
+ guard let assistant = AVOutputSettingsAssistant(preset: .preset3840x2160) else {
+ throw NSError(domain: "RecordlyCapture", code: 5, userInfo: [NSLocalizedDescriptionKey: "Unable to create output settings assistant"])
+ }
+
+ let sourceVideoFormat = try CMVideoFormatDescription(
+ videoCodecType: CMFormatDescription.MediaSubType(
+ rawValue: windowCropRect == nil
+ ? kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange
+ : kCVPixelFormatType_32BGRA
+ ),
+ width: outputWidth,
+ height: outputHeight
+ )
+ assistant.sourceVideoFormat = sourceVideoFormat
+
+ guard var outputSettings = assistant.videoSettings else {
+ throw NSError(domain: "RecordlyCapture", code: 6, userInfo: [NSLocalizedDescriptionKey: "Output settings unavailable"])
+ }
+
+ outputSettings[AVVideoWidthKey] = outputWidth
+ outputSettings[AVVideoHeightKey] = outputHeight
+ outputSettings[AVVideoColorPropertiesKey] = [
+ AVVideoColorPrimariesKey: AVVideoColorPrimaries_ITU_R_709_2,
+ AVVideoTransferFunctionKey: AVVideoTransferFunction_ITU_R_709_2,
+ AVVideoYCbCrMatrixKey: AVVideoYCbCrMatrix_ITU_R_709_2,
+ ]
+
+ let videoInput = AVAssetWriterInput(
+ mediaType: .video,
+ outputSettings: outputSettings,
+ sourceFormatHint: sourceVideoFormat
+ )
+ videoInput.expectsMediaDataInRealTime = true
+
+ guard let assetWriter = assetWriter, assetWriter.canAdd(videoInput) else {
+ throw NSError(domain: "RecordlyCapture", code: 7, userInfo: [NSLocalizedDescriptionKey: "Unable to add video writer input"])
+ }
+
+ assetWriter.add(videoInput)
+ self.videoInput = videoInput
+ videoPixelBufferAdaptor = windowCropRect.map { _ in
+ AVAssetWriterInputPixelBufferAdaptor(
+ assetWriterInput: videoInput,
+ sourcePixelBufferAttributes: [
+ kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA,
+ kCVPixelBufferWidthKey as String: outputWidth,
+ kCVPixelBufferHeightKey as String: outputHeight,
+ ]
+ )
+ }
+
+ // Add inline audio track directly to the video so the .mp4 always contains audio.
+ // This eliminates the dependency on the post-recording ffmpeg mux step.
+ if capturesSystemAudio || capturesMicrophone {
+ let inlineAudio = AVAssetWriterInput(mediaType: .audio, outputSettings: Self.audioOutputSettings(bitRate: 192_000))
+ inlineAudio.expectsMediaDataInRealTime = true
+ if assetWriter.canAdd(inlineAudio) {
+ assetWriter.add(inlineAudio)
+ self.inlineAudioInput = inlineAudio
+ }
+ }
+
+ if writesSystemAudioToSeparateTrack {
+ guard let systemAudioOutputPath = config.systemAudioOutputPath, !systemAudioOutputPath.isEmpty else {
+ throw NSError(domain: "RecordlyCapture", code: 11, userInfo: [NSLocalizedDescriptionKey: "Missing system audio output path for audio capture"])
+ }
+
+ let systemAudioURL = URL(fileURLWithPath: systemAudioOutputPath)
+ let systemAudioWriter = try AVAssetWriter(url: systemAudioURL, fileType: .m4a)
+ let systemAudioInput = AVAssetWriterInput(mediaType: .audio, outputSettings: Self.audioOutputSettings(bitRate: 160_000))
+ systemAudioInput.expectsMediaDataInRealTime = true
+
+ guard systemAudioWriter.canAdd(systemAudioInput) else {
+ throw NSError(domain: "RecordlyCapture", code: 12, userInfo: [NSLocalizedDescriptionKey: "Unable to add system audio writer input"])
+ }
+
+ systemAudioWriter.add(systemAudioInput)
+ self.systemAudioWriter = systemAudioWriter
+ self.systemAudioInput = systemAudioInput
+
+ guard systemAudioWriter.startWriting() else {
+ throw NSError(domain: "RecordlyCapture", code: 13, userInfo: [NSLocalizedDescriptionKey: systemAudioWriter.error?.localizedDescription ?? "Unable to start system audio writing"])
+ }
+
+ systemAudioWriter.startSession(atSourceTime: .zero)
+ }
+
+ if writesMicrophoneToSeparateTrack {
+ guard let microphoneOutputPath = config.microphoneOutputPath, !microphoneOutputPath.isEmpty else {
+ throw NSError(domain: "RecordlyCapture", code: 14, userInfo: [NSLocalizedDescriptionKey: "Missing microphone output path for microphone capture"])
+ }
+
+ let microphoneURL = URL(fileURLWithPath: microphoneOutputPath)
+ microphoneOutputURL = microphoneURL
+ let microphoneWriter = try AVAssetWriter(url: microphoneURL, fileType: .m4a)
+ let microphoneInput = AVAssetWriterInput(mediaType: .audio, outputSettings: Self.audioOutputSettings(bitRate: 128_000))
+ microphoneInput.expectsMediaDataInRealTime = true
+
+ guard microphoneWriter.canAdd(microphoneInput) else {
+ throw NSError(domain: "RecordlyCapture", code: 15, userInfo: [NSLocalizedDescriptionKey: "Unable to add microphone writer input"])
+ }
+
+ microphoneWriter.add(microphoneInput)
+ self.microphoneOnlyWriter = microphoneWriter
+ self.microphoneOnlyInput = microphoneInput
+
+ guard microphoneWriter.startWriting() else {
+ throw NSError(domain: "RecordlyCapture", code: 16, userInfo: [NSLocalizedDescriptionKey: microphoneWriter.error?.localizedDescription ?? "Unable to start microphone audio writing"])
+ }
+
+ microphoneWriter.startSession(atSourceTime: .zero)
+ }
+
+ let stream = SCStream(filter: filter, configuration: streamConfig, delegate: self)
+ self.stream = stream
+ try stream.addStreamOutput(self, type: .screen, sampleHandlerQueue: queue)
+ if capturesSystemAudio {
+ try stream.addStreamOutput(self, type: .audio, sampleHandlerQueue: queue)
+ }
+ if capturesMicrophone {
+ guard let microphoneOutputType = SCStreamOutputType(rawValue: microphoneOutputTypeRawValue) else {
+ throw NSError(
+ domain: "RecordlyCapture",
+ code: 17,
+ userInfo: [NSLocalizedDescriptionKey: "Microphone stream output type is unavailable"]
+ )
+ }
+ try stream.addStreamOutput(self, type: microphoneOutputType, sampleHandlerQueue: queue)
+ }
+ try await stream.startCapture()
+
+ guard assetWriter.startWriting() else {
+ throw NSError(domain: "RecordlyCapture", code: 8, userInfo: [NSLocalizedDescriptionKey: assetWriter.error?.localizedDescription ?? "Unable to start video writing"])
+ }
+
+ assetWriter.startSession(atSourceTime: .zero)
+ sessionStarted = true
+ isRecording = true
+ isPaused = false
+ pauseStartedHostTime = nil
+ pendingResumeAdjustment = false
+ accumulatedPausedDuration = .zero
+ frameCount = 0
+ firstSampleTime = .zero
+ lastVideoPresentationTime = .zero
+ lastVideoDuration = .zero
+ startWindowValidationIfNeeded()
+ }
+
+ func stopCapture() async throws -> String {
+ let finalization = await finalizeCapture(interactive: true)
+ return try finalization.outputResult.get()
+ }
+
+ func pauseCapture() async -> Bool {
+ await withCheckedContinuation { continuation in
+ queue.async {
+ guard self.isRecording, !self.isPaused else {
+ continuation.resume(returning: self.isRecording && self.isPaused)
+ return
+ }
+ self.isPaused = true
+ self.pauseStartedHostTime = CMClockGetTime(CMClockGetHostTimeClock())
+ self.pendingResumeAdjustment = false
+ continuation.resume(returning: true)
+ }
+ }
+ }
+
+ func resumeCapture() async -> Bool {
+ await withCheckedContinuation { continuation in
+ queue.async {
+ guard self.isRecording, self.isPaused else {
+ continuation.resume(returning: self.isRecording && !self.isPaused)
+ return
+ }
+ self.isPaused = false
+ self.pendingResumeAdjustment = true
+ continuation.resume(returning: true)
+ }
+ }
+ }
+
+ func stream(_ stream: SCStream, didOutputSampleBuffer sampleBuffer: CMSampleBuffer, of outputType: SCStreamOutputType) {
+ guard sessionStarted, sampleBuffer.isValid, isRecording else { return }
+
+ if outputType == .screen {
+ 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),
+ status == .complete else {
+ return
+ }
+
+ guard let videoInput = videoInput,
+ assetWriter?.status == .writing,
+ videoInput.isReadyForMoreMediaData else { return }
+
+ // Only a complete frame that the writer can accept may establish time zero.
+ guard let presentationTime = adjustedPresentationTime(for: sampleBuffer, outputType: outputType) else { return }
+ if frameCount > 0 && CMTimeCompare(presentationTime, lastVideoPresentationTime) <= 0 {
+ return
+ }
+
+ lastSampleBuffer = sampleBuffer
+ let appended: Bool
+ if videoPixelBufferAdaptor != nil {
+ appended = appendCroppedVideoFrame(sampleBuffer, at: presentationTime)
+ } else {
+ let timing = CMSampleTimingInfo(duration: sampleBuffer.duration, presentationTimeStamp: presentationTime, decodeTimeStamp: sampleBuffer.decodeTimeStamp)
+ if let retimed = try? CMSampleBuffer(copying: sampleBuffer, withNewTiming: [timing]) {
+ appended = videoInput.append(retimed)
+ } else {
+ appended = false
+ }
+ }
+ if appended {
+ lastVideoPresentationTime = presentationTime
+ lastVideoDuration = sampleBuffer.duration
+ frameCount += 1
+ if frameCount == 1 {
+ // Signal readiness only after AVAssetWriter has accepted a
+ // real frame, so countdown warm-start cannot pause too early.
+ print("Recording started")
+ fflush(stdout)
+ }
+ } else if frameCount == 0 {
+ // A failed crop/append must not leave an empty interval before frame one.
+ firstSampleTime = .zero
+ }
+ return
+ }
+
+ guard frameCount > 0,
+ let presentationTime = adjustedPresentationTime(for: sampleBuffer, outputType: outputType) else { return }
+
+ if outputType == .audio {
+ guard let systemAudioInput else { return }
+ appendAudioSampleBuffer(sampleBuffer, to: systemAudioInput, of: systemAudioWriter, firstSampleTime: &firstSystemAudioSampleTime, lastPresentationTime: &lastSystemAudioPresentationTime, presentationTime: presentationTime)
+ // Also write system audio to the inline video track
+ if let inlineAudioInput, inlineAudioInput.isReadyForMoreMediaData {
+ appendAudioSampleBuffer(sampleBuffer, to: inlineAudioInput, of: assetWriter, firstSampleTime: &firstInlineAudioSampleTime, lastPresentationTime: &lastInlineAudioPresentationTime, presentationTime: presentationTime)
+ }
+ return
+ }
+
+ if outputType.rawValue == microphoneOutputTypeRawValue {
+ if let microphoneOnlyInput {
+ appendAudioSampleBuffer(sampleBuffer, to: microphoneOnlyInput, of: microphoneOnlyWriter, firstSampleTime: &firstMicrophoneSampleTime, lastPresentationTime: &lastMicrophonePresentationTime, presentationTime: presentationTime)
+ }
+ // Write mic to inline video track only if there's no system audio (avoids double-writing)
+ if !capturesSystemAudio, let inlineAudioInput, inlineAudioInput.isReadyForMoreMediaData {
+ appendAudioSampleBuffer(sampleBuffer, to: inlineAudioInput, of: assetWriter, firstSampleTime: &firstInlineAudioSampleTime, lastPresentationTime: &lastInlineAudioPresentationTime, presentationTime: presentationTime)
+ }
+ return
+ }
+
+ return
+ }
+
+ private func appendCroppedVideoFrame(_ sampleBuffer: CMSampleBuffer, at presentationTime: CMTime) -> Bool {
+ guard let crop = windowCropRect,
+ let adaptor = videoPixelBufferAdaptor,
+ let pool = adaptor.pixelBufferPool,
+ let source = CMSampleBufferGetImageBuffer(sampleBuffer) else { return false }
+
+ var destination: CVPixelBuffer?
+ guard CVPixelBufferPoolCreatePixelBuffer(nil, pool, &destination) == kCVReturnSuccess,
+ let destination else { return false }
+
+ let sourceWidth = CGFloat(CVPixelBufferGetWidth(source))
+ let sourceHeight = CGFloat(CVPixelBufferGetHeight(source))
+ let sourceRect = CGRect(
+ x: crop.minX * sourceWidth,
+ y: (1 - crop.maxY) * sourceHeight,
+ width: crop.width * sourceWidth,
+ height: crop.height * sourceHeight
+ )
+ let destinationSize = CGSize(
+ width: CVPixelBufferGetWidth(destination),
+ height: CVPixelBufferGetHeight(destination)
+ )
+ let image = CIImage(cvPixelBuffer: source)
+ .cropped(to: sourceRect)
+ .transformed(by: CGAffineTransform(translationX: -sourceRect.minX, y: -sourceRect.minY))
+ .transformed(by: CGAffineTransform(
+ scaleX: destinationSize.width / sourceRect.width,
+ y: destinationSize.height / sourceRect.height
+ ))
+ let bounds = CGRect(origin: .zero, size: destinationSize)
+ imageContext.render(
+ image,
+ to: destination,
+ bounds: bounds,
+ colorSpace: CGColorSpace(name: CGColorSpace.sRGB)
+ )
+
+ let appended = adaptor.append(destination, withPresentationTime: presentationTime)
+ if appended { lastCroppedPixelBuffer = destination }
+ return appended
+ }
+
+ func stream(_ stream: SCStream, didStopWithError error: Error) {
+ fputs("Error: \(error.localizedDescription)\n", stderr)
+ fflush(stderr)
+ }
+
+ /// Starts one finalization operation after all previously delivered samples on
+ /// the recorder queue have drained. Manual stop and automatic window-close
+ /// detection join the same operation instead of racing the asset writers.
+ private func finalizeCapture(interactive: Bool) async -> CaptureFinalizationResult {
+ await withCheckedContinuation { continuation in
+ queue.async {
+ if self.isFinalizing {
+ self.interactiveStopParticipated = self.interactiveStopParticipated || interactive
+ self.finalizationWaiters.append(continuation)
+ return
+ }
+
+ guard self.isRecording else {
+ continuation.resume(returning: CaptureFinalizationResult(
+ outputResult: .failure(NSError(
+ domain: "RecordlyCapture",
+ code: 9,
+ userInfo: [NSLocalizedDescriptionKey: "No recording in progress"]
+ )),
+ interactiveStopParticipated: interactive
+ ))
+ return
+ }
+
+ self.isFinalizing = true
+ self.interactiveStopParticipated = interactive
+ self.isRecording = false
+ self.windowValidationTask = nil
+ self.trackedWindowId = nil
+ self.finalizationWaiters.append(continuation)
+
+ Task {
+ let outputResult: Result
+ do {
+ outputResult = .success(try await self.finishCapture())
+ } catch {
+ outputResult = .failure(error)
+ }
+
+ self.queue.async {
+ let finalizationResult = CaptureFinalizationResult(
+ outputResult: outputResult,
+ interactiveStopParticipated: self.interactiveStopParticipated
+ )
+ let waiters = self.finalizationWaiters
+ self.finalizationWaiters.removeAll()
+ self.isFinalizing = false
+ self.interactiveStopParticipated = false
+ for waiter in waiters {
+ waiter.resume(returning: finalizationResult)
+ }
+ }
+ }
+ }
+ }
+ }
+
+ private func finishCapture() async throws -> String {
+
+ if let activeStream = stream {
+ do {
+ try await activeStream.stopCapture()
+ } catch {
+ // Stream may have already been stopped by the system — continue with file finalization
+ }
+ }
+ stream = nil
+
+ // The tail frame only gives the last captured frame its full duration, so
+ // it must never put the file at risk. Appending to an input whose encoder
+ // queue is still backed up — routine after a long high-resolution capture —
+ // raises an Objective-C exception that Swift cannot catch, aborting the
+ // helper before `finishWriting()` and leaving an mdat with no moov atom:
+ // an unplayable recording. Wait briefly for the queue to drain, then skip
+ // the frame rather than lose the recording.
+ if let originalBuffer = lastSampleBuffer,
+ let videoInput = videoInput,
+ await waitUntilReady(videoInput, of: assetWriter) {
+ let additionalTime = lastVideoPresentationTime + frameDuration(for: originalBuffer)
+ if let adaptor = videoPixelBufferAdaptor, let pixelBuffer = lastCroppedPixelBuffer {
+ adaptor.append(pixelBuffer, withPresentationTime: additionalTime)
+ } else {
+ let timing = CMSampleTimingInfo(duration: originalBuffer.duration, presentationTimeStamp: additionalTime, decodeTimeStamp: originalBuffer.decodeTimeStamp)
+ if let additionalSampleBuffer = try? CMSampleBuffer(copying: originalBuffer, withNewTiming: [timing]) {
+ videoInput.append(additionalSampleBuffer)
+ }
+ }
+ }
+
+ // `endSession`, `markAsFinished` and `finishWriting` all raise when the
+ // writer is no longer in the `.writing` state (a mid-capture failure, for
+ // example a full disk), which would abort the helper the same way.
+ let videoEndTime = lastVideoPresentationTime + (lastSampleBuffer.map { frameDuration(for: $0) } ?? .zero)
+ let endTime = resolvedCaptureEndTime(videoEndTime: videoEndTime)
+ if let assetWriter, assetWriter.status == .writing {
+ assetWriter.endSession(atSourceTime: endTime)
+ videoInput?.markAsFinished()
+ inlineAudioInput?.markAsFinished()
+ await assetWriter.finishWriting()
+ }
+
+ if let systemAudioWriter, systemAudioWriter.status == .writing {
+ systemAudioInput?.markAsFinished()
+ await systemAudioWriter.finishWriting()
+ }
+
+ if let microphoneOnlyWriter, microphoneOnlyWriter.status == .writing {
+ microphoneOnlyInput?.markAsFinished()
+ await microphoneOnlyWriter.finishWriting()
+ }
+
+ let finalizeFailure: Error? = [assetWriter, systemAudioWriter, microphoneOnlyWriter]
+ .compactMap { $0 }
+ .compactMap { writer in
+ writer.status == .completed
+ ? nil
+ : (writer.error ?? unfinalizedWriterError(status: writer.status))
+ }
+ .first
+ let path = outputURL?.path ?? ""
+ assetWriter = nil
+ videoInput = nil
+ videoPixelBufferAdaptor = nil
+ windowCropRect = nil
+ windowCropDisplayId = nil
+ excludedProcessIds.removeAll()
+ lastCroppedPixelBuffer = nil
+ systemAudioWriter = nil
+ systemAudioInput = nil
+ microphoneOnlyWriter = nil
+ microphoneOnlyInput = nil
+ inlineAudioInput = nil
+ outputURL = nil
+ microphoneOutputURL = nil
+ sessionStarted = false
+ firstSampleTime = .zero
+ firstSystemAudioSampleTime = nil
+ firstMicrophoneSampleTime = nil
+ lastSystemAudioPresentationTime = .invalid
+ lastMicrophonePresentationTime = .invalid
+ firstInlineAudioSampleTime = nil
+ lastSampleBuffer = nil
+ lastVideoPresentationTime = .zero
+ lastVideoDuration = .zero
+ lastInlineAudioPresentationTime = .invalid
+ lastInlineAudioDuration = .zero
+ frameCount = 0
+ isPaused = false
+ pauseStartedHostTime = nil
+ pendingResumeAdjustment = false
+ accumulatedPausedDuration = .zero
+ capturesSystemAudio = false
+ capturesMicrophone = false
+ writesSystemAudioToSeparateTrack = false
+ writesMicrophoneToSeparateTrack = false
+
+ // Report a half-written file as a failure instead of handing the editor a
+ // path it cannot decode.
+ if let finalizeFailure {
+ throw finalizeFailure
+ }
+
+ return path
+ }
+
+ /// Waits briefly for an input's encoder queue to drain. Returns false when the
+ /// input stays backed up or its writer is no longer accepting data, in which
+ /// case the caller must skip the append: `AVAssetWriterInput.append` raises an
+ /// uncatchable Objective-C exception in both cases.
+ private func waitUntilReady(_ input: AVAssetWriterInput, of writer: AVAssetWriter?) async -> Bool {
+ guard let writer else { return false }
+
+ var attemptsRemaining = writerReadinessPollAttempts
+ while writer.status == .writing {
+ if input.isReadyForMoreMediaData {
+ return true
+ }
+ guard attemptsRemaining > 0 else { return false }
+ attemptsRemaining -= 1
+ do {
+ try await Task.sleep(nanoseconds: writerReadinessPollInterval)
+ } catch is CancellationError {
+ return false
+ } catch {
+ return false
+ }
+ }
+
+ return false
+ }
+
+ private func unfinalizedWriterError(status: AVAssetWriter.Status) -> Error {
+ NSError(domain: "RecordlyCapture", code: 10, userInfo: [
+ NSLocalizedDescriptionKey: "Recording could not be finalized (writer status \(status.rawValue))",
+ ])
+ }
+
+ private func adjustedPresentationTime(for sampleBuffer: CMSampleBuffer, outputType: SCStreamOutputType) -> CMTime? {
+ if isPaused {
+ return nil
+ }
+
+ let sampleTime = sampleBuffer.presentationTimeStamp
+ if pendingResumeAdjustment {
+ // Audio and video callbacks share this queue but their timestamps can be
+ // offset slightly. Anchor the post-countdown adjustment to video and drop
+ // audio until that anchor exists; otherwise the first audio callback can
+ // make the following video timestamp move backwards and fail the writer.
+ guard outputType == .screen, let pauseStartedHostTime else {
+ return nil
+ }
+ let pauseGap = sampleTime - pauseStartedHostTime
+ if pauseGap > .zero {
+ accumulatedPausedDuration = accumulatedPausedDuration + pauseGap
+ }
+ self.pauseStartedHostTime = nil
+ pendingResumeAdjustment = false
+ }
+
+ if outputType == .screen {
+ if firstSampleTime == .zero {
+ firstSampleTime = sampleTime
+ }
+ }
+
+ // Use video's first sample time as the common time base for ALL tracks.
+ // This ensures audio files contain leading silence when audio hardware
+ // delivers its first sample after the first video frame (e.g. iPhone mic
+ // over Continuity Camera can lag 1-2 seconds behind).
+ if firstSampleTime == .zero {
+ // Video hasn't started yet — drop this audio sample to avoid
+ // negative timestamps.
+ return nil
+ }
+
+ return max(.zero, sampleTime - firstSampleTime - accumulatedPausedDuration)
+ }
+
+ private func frameDuration(for sampleBuffer: CMSampleBuffer) -> CMTime {
+ if sampleBuffer.duration.isValid && sampleBuffer.duration > .zero {
+ return sampleBuffer.duration
+ }
+
+ if lastVideoDuration.isValid && lastVideoDuration > .zero {
+ return lastVideoDuration
+ }
+
+ return CMTime(value: 1, timescale: CMTimeScale(targetCaptureFPS))
+ }
+
+ private func latestInlineAudioEndTime() -> CMTime {
+ guard lastInlineAudioPresentationTime.isValid else {
+ return .invalid
+ }
+
+ if lastInlineAudioDuration.isValid && lastInlineAudioDuration > .zero {
+ return lastInlineAudioPresentationTime + lastInlineAudioDuration
+ }
+
+ return lastInlineAudioPresentationTime
+ }
+
+ private func resolvedCaptureEndTime(videoEndTime: CMTime) -> CMTime {
+ let inlineAudioEndTime = latestInlineAudioEndTime()
+ guard inlineAudioEndTime.isValid else {
+ return videoEndTime
+ }
+
+ if CMTimeCompare(inlineAudioEndTime, videoEndTime) <= 0 {
+ return videoEndTime
+ }
+
+ // Prevent a stray inline-audio timestamp from forcing finishWriting
+ // to finalize an arbitrarily long tail.
+ let tailExtension = CMTimeSubtract(inlineAudioEndTime, videoEndTime)
+ return videoEndTime + CMTimeMinimum(tailExtension, maxInlineAudioTailExtension)
+ }
+
+ private func appendAudioSampleBuffer(_ sampleBuffer: CMSampleBuffer, to input: AVAssetWriterInput, of writer: AVAssetWriter?, firstSampleTime: inout CMTime?, lastPresentationTime: inout CMTime, presentationTime: CMTime) {
+ // A writer that failed mid-capture (a full disk, say) raises on every
+ // further append, which would abort the helper and lose the whole file.
+ guard writer?.status == .writing, input.isReadyForMoreMediaData else { return }
+ guard !lastPresentationTime.isValid || CMTimeCompare(presentationTime, lastPresentationTime) > 0 else { return }
+
+ if firstSampleTime == nil {
+ firstSampleTime = presentationTime
+ }
+
+ // presentationTime is already relative to the video's first frame
+ // (computed by adjustedPresentationTime), so use it directly.
+ let timing = CMSampleTimingInfo(duration: sampleBuffer.duration, presentationTimeStamp: presentationTime, decodeTimeStamp: sampleBuffer.decodeTimeStamp)
+ if let retimedSampleBuffer = try? CMSampleBuffer(copying: sampleBuffer, withNewTiming: [timing]) {
+ let appended = input.append(retimedSampleBuffer)
+ if appended {
+ lastPresentationTime = presentationTime
+ if input === inlineAudioInput {
+ lastInlineAudioDuration = sampleBuffer.duration
+ }
+ }
+ }
+ }
+
+ private static func audioOutputSettings(bitRate: Int) -> [String: Any] {
+ [
+ AVFormatIDKey: kAudioFormatMPEG4AAC,
+ AVSampleRateKey: 48_000,
+ AVNumberOfChannelsKey: 2,
+ AVEncoderBitRateKey: bitRate,
+ ]
+ }
+
+ private static func resolveMicrophoneCaptureDeviceID(config: CaptureConfig) -> String? {
+ let audioDevices = AVCaptureDevice.devices(for: .audio)
+
+ if let microphoneLabel = config.microphoneLabel?.trimmingCharacters(in: .whitespacesAndNewlines), !microphoneLabel.isEmpty {
+ if let matchedDevice = audioDevices.first(where: { $0.localizedName == microphoneLabel }) {
+ return matchedDevice.uniqueID
+ }
+ }
+
+ if let microphoneDeviceId = config.microphoneDeviceId?.trimmingCharacters(in: .whitespacesAndNewlines), !microphoneDeviceId.isEmpty {
+ if audioDevices.contains(where: { $0.uniqueID == microphoneDeviceId }) {
+ return microphoneDeviceId
+ }
+ }
+
+ return nil
+ }
+
+ private func supportsNativeMicrophoneCapture(streamConfig: SCStreamConfiguration) -> Bool {
+ let supportsConfigSelector = streamConfig.responds(to: Selector(("setCaptureMicrophone:")))
+ let supportsDeviceSelector = streamConfig.responds(to: Selector(("setMicrophoneCaptureDeviceID:")))
+ let supportsOutputType = SCStreamOutputType(rawValue: microphoneOutputTypeRawValue) != nil
+ return supportsConfigSelector && supportsDeviceSelector && supportsOutputType
+ }
+
+ private func startWindowValidationIfNeeded() {
+ guard let trackedWindowId else {
+ windowValidationTask?.cancel()
+ windowValidationTask = nil
+ return
+ }
+
+ windowValidationTask?.cancel()
+ windowValidationTask = Task.detached(priority: .utility) { [weak self] in
+ guard let self else { return }
+ while !Task.isCancelled {
+ try? await Task.sleep(nanoseconds: 500_000_000)
+ if Task.isCancelled { return }
+ guard self.isRecording else { return }
+
+ let availableContent: SCShareableContent
+ do {
+ availableContent = try await SCShareableContent.excludingDesktopWindows(false, onScreenWindowsOnly: true)
+ } catch {
+ continue
+ }
+
+ guard let window = availableContent.windows.first(where: { $0.windowID == trackedWindowId }) else {
+ print("WINDOW_UNAVAILABLE")
+ fflush(stdout)
+ let finalization = await self.finalizeCapture(interactive: false)
+ if finalization.interactiveStopParticipated {
+ return
+ }
+ do {
+ let outputPath = try finalization.outputResult.get()
+ print("Recording stopped. Output path: \(outputPath)")
+ fflush(stdout)
+ exit(0)
+ } catch {
+ fputs("Error stopping capture: \(error.localizedDescription)\n", stderr)
+ fflush(stderr)
+ exit(1)
+ }
+ return
+ }
+
+ guard let display = Self.captureDisplay(for: window.frame, from: availableContent.displays) else {
+ continue
+ }
+ let captureRect = window.frame.intersection(display.frame)
+ guard captureRect.width > 0, captureRect.height > 0 else { continue }
+ let cropRect = CGRect(
+ x: (captureRect.minX - display.frame.minX) / display.frame.width,
+ y: (captureRect.minY - display.frame.minY) / display.frame.height,
+ width: captureRect.width / display.frame.width,
+ height: captureRect.height / display.frame.height
+ )
+
+ if self.windowCropDisplayId != display.displayID, let activeStream = self.stream {
+ let excludedApplications = availableContent.applications.filter {
+ self.excludedProcessIds.contains($0.processID)
+ }
+ let filter = SCContentFilter(
+ display: display,
+ excludingApplications: excludedApplications,
+ exceptingWindows: []
+ )
+ do {
+ try await activeStream.updateContentFilter(filter)
+ } catch {
+ continue
+ }
+ }
+
+ await withCheckedContinuation { (continuation: CheckedContinuation) in
+ self.queue.async {
+ if self.isRecording {
+ self.windowCropRect = cropRect
+ self.windowCropDisplayId = display.displayID
+ }
+ continuation.resume()
+ }
+ }
+ }
+ }
+ }
+
+ private static func captureDisplay(for frame: CGRect, from displays: [SCDisplay]) -> SCDisplay? {
+ let midpoint = CGPoint(x: frame.midX, y: frame.midY)
+ return displays.first(where: { $0.frame.contains(midpoint) })
+ ?? displays.filter { $0.frame.intersects(frame) }.max {
+ $0.frame.intersection(frame).width * $0.frame.intersection(frame).height
+ < $1.frame.intersection(frame).width * $1.frame.intersection(frame).height
+ }
+ }
+
+ 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))
+ }
+}
+
+final class RecorderService {
+ private let recorder = ScreenCaptureRecorder()
+ private let queue = DispatchQueue(label: "recordly.screencapturekit.commands")
+ private let completionGroup = DispatchGroup()
+ private var succeeded = true
+ // Accessed only by serialized command operations.
+ private var captureStarted = false
+
+ private func enqueue(_ operation: @escaping () async -> Void) {
+ queue.async {
+ let semaphore = DispatchSemaphore(value: 0)
+ Task {
+ await operation()
+ semaphore.signal()
+ }
+ semaphore.wait()
+ }
+ }
+
+ func start(configJSON: String) {
+ completionGroup.enter()
+ enqueue {
+ do {
+ try await self.recorder.startCapture(configJSON: configJSON)
+ self.captureStarted = true
+ } catch {
+ self.succeeded = false
+ fputs("Error starting capture: \(error.localizedDescription)\n", stderr)
+ fflush(stderr)
+ self.completionGroup.leave()
+ }
+ }
+ }
+
+ func stop() {
+ enqueue {
+ // Failed startup already releases completionGroup. EOF must not do it again.
+ guard self.captureStarted else { return }
+ self.captureStarted = false
+ do {
+ let outputPath = try await self.recorder.stopCapture()
+ print("Recording stopped. Output path: \(outputPath)")
+ fflush(stdout)
+ self.completionGroup.leave()
+ } catch {
+ self.succeeded = false
+ fputs("Error stopping capture: \(error.localizedDescription)\n", stderr)
+ fflush(stderr)
+ self.completionGroup.leave()
+ }
+ }
+ }
+
+ func pause() {
+ enqueue {
+ if await self.recorder.pauseCapture() {
+ print("Recording paused")
+ fflush(stdout)
+ }
+ }
+ }
+
+ func resume() {
+ enqueue {
+ if await self.recorder.resumeCapture() {
+ print("Recording resumed")
+ fflush(stdout)
+ }
+ }
+ }
+
+ func waitUntilFinished() -> Bool {
+ completionGroup.wait()
+ return succeeded
+ }
+}
+
+guard CommandLine.arguments.count >= 2 else {
+ fputs("Missing config JSON\n", stderr)
+ fflush(stderr)
+ exit(1)
+}
+
+// Force CoreGraphics Services initialization on the main thread.
+// ScreenCaptureKit still requires CoreGraphics Services to be initialized in a CLI tool.
+let _ = CGMainDisplayID()
+
+// Pre-flight check: ensure screen recording permission is granted before
+// attempting capture. On macOS 15+, a one-session grant may expire after the
+// parent app restarts. CGRequestScreenCaptureAccess() will trigger the
+// system-level permission dialog (or open System Settings) when not yet granted.
+if !CGPreflightScreenCaptureAccess() {
+ let granted = CGRequestScreenCaptureAccess()
+ if !granted {
+ fputs("SCREEN_RECORDING_PERMISSION_DENIED\n", stderr)
+ fflush(stderr)
+ exit(1)
+ }
+}
+
+// Pre-flight check for microphone access when mic capture is requested.
+if let configData = CommandLine.arguments[1].data(using: .utf8),
+ let config = try? JSONDecoder().decode(CaptureConfig.self, from: configData),
+ config.capturesMicrophone == true {
+ switch AVCaptureDevice.authorizationStatus(for: .audio) {
+ case .authorized:
+ break
+ case .notDetermined:
+ let sem = DispatchSemaphore(value: 0)
+ AVCaptureDevice.requestAccess(for: .audio) { _ in sem.signal() }
+ sem.wait()
+ if AVCaptureDevice.authorizationStatus(for: .audio) != .authorized {
+ fputs("MICROPHONE_PERMISSION_DENIED\n", stderr)
+ fflush(stderr)
+ exit(1)
+ }
+ default:
+ fputs("MICROPHONE_PERMISSION_DENIED\n", stderr)
+ fflush(stderr)
+ exit(1)
+ }
+}
+
+let service = RecorderService()
+service.start(configJSON: CommandLine.arguments[1])
+
+DispatchQueue.global(qos: .utility).async {
+ while let input = readLine(strippingNewline: true)?.lowercased() {
+ if input == "pause" {
+ service.pause()
+ continue
+ }
+
+ if input == "resume" {
+ service.resume()
+ continue
+ }
+
+ if input == "stop" {
+ break
+ }
+ }
+ // EOF means the Electron parent exited or restarted. Finalize and release
+ // capture devices just as we do for an explicit stop command.
+ service.stop()
+}
+
+if !service.waitUntilFinished() {
+ exit(1)
+}
diff --git a/electron/native/ScreenCaptureKitRecorder.test.ts b/electron/native/ScreenCaptureKitRecorder.test.ts
new file mode 100644
index 000000000..bf787842e
--- /dev/null
+++ b/electron/native/ScreenCaptureKitRecorder.test.ts
@@ -0,0 +1,112 @@
+import { readFileSync } from "node:fs";
+import { fileURLToPath } from "node:url";
+import { describe, expect, it } from "vitest";
+
+const recorderSource = readFileSync(
+ fileURLToPath(new URL("./ScreenCaptureKitRecorder.swift", import.meta.url)),
+ "utf8",
+);
+
+describe("ScreenCaptureKitRecorder finalization coordination", () => {
+ it("finalizes on parent pipe closure as well as an explicit stop, exactly once", () => {
+ const commandLoop = recorderSource.slice(
+ recorderSource.indexOf("while let input = readLine"),
+ );
+ expect(commandLoop).toMatch(
+ /if input == "stop"\s*\{\s*break\s*\}\s*\}\s*\/\/.*?service\.stop\(\)/s,
+ );
+ expect(commandLoop.match(/service\.stop\(\)/g)).toHaveLength(1);
+ });
+
+ it("marks manual stops as participants in the shared finalization", () => {
+ expect(recorderSource).toContain("finalizeCapture(interactive: true)");
+ expect(recorderSource).toContain("finalization.outputResult.get()");
+ expect(recorderSource).toContain(
+ "self.interactiveStopParticipated = self.interactiveStopParticipated || interactive",
+ );
+ });
+
+ it("does not let automatic window-close exit preempt a joined manual stop", () => {
+ expect(recorderSource).toContain("self.finalizeCapture(interactive: false)");
+ expect(recorderSource).toMatch(
+ /if finalization\.interactiveStopParticipated\s*\{\s*return\s*\}/,
+ );
+ });
+});
+
+describe("ScreenCaptureKitRecorder resume timing", () => {
+ it("anchors warm-start resume timing to video before accepting audio", () => {
+ expect(recorderSource).toContain(
+ "guard outputType == .screen, let pauseStartedHostTime else",
+ );
+ });
+
+ it("drops non-monotonic video and audio samples", () => {
+ expect(recorderSource).toContain(
+ "CMTimeCompare(presentationTime, lastVideoPresentationTime) <= 0",
+ );
+ expect(recorderSource).toContain(
+ "CMTimeCompare(presentationTime, lastPresentationTime) > 0",
+ );
+ });
+});
+
+describe("ScreenCaptureKitRecorder colour metadata", () => {
+ it("asks ScreenCaptureKit for BT.709-compatible video-range frames", () => {
+ expect(recorderSource).toContain(
+ "streamConfig.pixelFormat = kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange",
+ );
+ expect(recorderSource).toContain("streamConfig.colorSpaceName = CGColorSpace.sRGB");
+ expect(recorderSource).toContain(
+ "streamConfig.colorMatrix = CGDisplayStream.yCbCrMatrix_ITU_R_709_2",
+ );
+ expect(recorderSource).toContain("kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange");
+ expect(recorderSource).toContain("sourceFormatHint: sourceVideoFormat");
+ expect(recorderSource).not.toContain("videoCodecType: .h264");
+ });
+
+ it("tags recordings as BT.709", () => {
+ expect(recorderSource).toContain("AVVideoColorPropertiesKey");
+ expect(recorderSource).toContain("AVVideoColorPrimaries_ITU_R_709_2");
+ expect(recorderSource).toContain("AVVideoTransferFunction_ITU_R_709_2");
+ expect(recorderSource).toContain("AVVideoYCbCrMatrix_ITU_R_709_2");
+ });
+});
+
+describe("ScreenCaptureKitRecorder window capture", () => {
+ it("records the display and crops it to the selected window bounds", () => {
+ expect(recorderSource).not.toContain("streamConfig.sourceRect");
+ expect(recorderSource).not.toContain("desktopIndependentWindow");
+ expect(recorderSource).toContain(
+ "visibleFrame = CGRect(x: x, y: y, width: width, height: height)",
+ );
+ expect(recorderSource).toContain(
+ "let captureRect = visibleFrame.intersection(display.frame)",
+ );
+ expect(recorderSource).toContain("appendCroppedVideoFrame(sampleBuffer");
+ });
+
+ it("refreshes the crop and capture display while the window moves or resizes", () => {
+ expect(recorderSource).toContain(
+ "guard let display = Self.captureDisplay(for: window.frame",
+ );
+ expect(recorderSource).toContain("try await activeStream.updateContentFilter(filter)");
+ expect(recorderSource).toContain("self.windowCropRect = cropRect");
+ });
+});
+
+
+describe("ScreenCaptureKitRecorder first frame timing", () => {
+ const callback = recorderSource.slice(recorderSource.indexOf("func stream(_ stream:"), recorderSource.indexOf("func stream(_ stream:") + 5000);
+ it("validates a complete frame and writer readiness before setting time zero", () => {
+ const clock = callback.indexOf("adjustedPresentationTime(for:");
+ expect(clock).toBeGreaterThan(callback.indexOf("status == .complete"));
+ expect(clock).toBeGreaterThan(callback.indexOf("videoInput.isReadyForMoreMediaData"));
+ });
+ it("resets the origin after a rejected first frame and gates audio on accepted video", () => {
+ expect(callback).toMatch(/else if frameCount == 0\s*\{[^}]*firstSampleTime = \.zero/);
+ const audioGuard = callback.indexOf("guard frameCount > 0,");
+ expect(audioGuard).toBeGreaterThan(0);
+ expect(audioGuard).toBeLessThan(callback.indexOf("if outputType == .audio"));
+ });
+});
diff --git a/electron/native/ScreenCaptureKitWindowList.swift b/electron/native/ScreenCaptureKitWindowList.swift
new file mode 100644
index 000000000..cdb2f5757
--- /dev/null
+++ b/electron/native/ScreenCaptureKitWindowList.swift
@@ -0,0 +1,155 @@
+import Foundation
+import CoreGraphics
+import ScreenCaptureKit
+
+struct WindowListEntry: Codable {
+ let id: String
+ let name: String
+ let display_id: String
+ let appName: String?
+ let windowTitle: String?
+ let bundleId: String?
+ let x: Double
+ let y: Double
+ let width: Double
+ let height: Double
+}
+
+func normalize(_ value: String?) -> String? {
+ guard let rawValue = value?.trimmingCharacters(in: .whitespacesAndNewlines), !rawValue.isEmpty else {
+ return nil
+ }
+
+ return rawValue
+}
+
+let excludedBundleIds: Set = [
+ "com.apple.controlcenter",
+ "com.apple.dock",
+ "com.apple.WindowManager",
+ "com.apple.wallpaper.agent",
+]
+
+let excludedWindowTitles: Set = [
+ "Display 1 Backstop",
+ "Event Shield Window",
+ "Menubar",
+ "Offscreen Wallpaper Window",
+ "Wallpaper-",
+]
+
+// Force CoreGraphics Services initialization before asking ScreenCaptureKit for
+// shareable content. Without this, the helper can stall sporadically when run
+// as a standalone CLI process from Electron.
+let _ = CGMainDisplayID()
+
+let group = DispatchGroup()
+group.enter()
+
+Task {
+ do {
+ let shareableContent = try await SCShareableContent.excludingDesktopWindows(false, onScreenWindowsOnly: true)
+
+ struct RawWindowEntry {
+ let entry: WindowListEntry
+ let hasRawTitle: Bool
+ let bundleId: String?
+ }
+
+ let rawEntries = shareableContent.windows.compactMap { window -> RawWindowEntry? in
+ let appName = normalize(window.owningApplication?.applicationName)
+ let windowTitle = normalize(window.title)
+ let bundleId = normalize(window.owningApplication?.bundleIdentifier)
+ let frame = window.frame
+
+ guard window.windowLayer == 0 else {
+ return nil
+ }
+
+ guard frame.width >= 50, frame.height >= 50 else {
+ return nil
+ }
+
+ guard appName != nil || windowTitle != nil else {
+ return nil
+ }
+
+ if let bundleId, excludedBundleIds.contains(bundleId) {
+ return nil
+ }
+
+ if let windowTitle, excludedWindowTitles.contains(windowTitle) {
+ return nil
+ }
+
+ let matchedDisplay = shareableContent.displays.first(where: { display in
+ display.frame.intersects(frame) || display.frame.contains(CGPoint(x: frame.midX, y: frame.midY))
+ })
+
+ let resolvedWindowTitle = windowTitle ?? appName ?? "Window"
+ let resolvedName: String
+ if let appName, let windowTitle {
+ resolvedName = "\(appName) — \(windowTitle)"
+ } else {
+ resolvedName = resolvedWindowTitle
+ }
+
+ let entry = WindowListEntry(
+ id: "window:\(window.windowID):0",
+ name: resolvedName,
+ display_id: matchedDisplay.map { String($0.displayID) } ?? "",
+ appName: appName,
+ windowTitle: resolvedWindowTitle,
+ bundleId: bundleId,
+ x: Double(frame.origin.x),
+ y: Double(frame.origin.y),
+ width: Double(frame.width),
+ height: Double(frame.height)
+ )
+
+ return RawWindowEntry(entry: entry, hasRawTitle: windowTitle != nil, bundleId: bundleId)
+ }
+
+ // For apps with multiple windows, drop auxiliary windows that lack a
+ // distinct title (e.g. Arc's sidebar/tab-bar chrome). If ALL windows
+ // from an app lack titles, keep them all.
+ var titledCountByBundle: [String: Int] = [:]
+ for raw in rawEntries {
+ if let bid = raw.bundleId, raw.hasRawTitle {
+ titledCountByBundle[bid, default: 0] += 1
+ }
+ }
+
+ let entries = rawEntries
+ .filter { raw in
+ guard let bid = raw.bundleId else { return true }
+ if let titled = titledCountByBundle[bid], titled > 0 {
+ return raw.hasRawTitle
+ }
+ return true
+ }
+ .map { $0.entry }
+ .sorted { lhs, rhs in
+ let lhsApp = lhs.appName ?? lhs.name
+ let rhsApp = rhs.appName ?? rhs.name
+ if lhsApp != rhsApp {
+ return lhsApp.localizedCaseInsensitiveCompare(rhsApp) == .orderedAscending
+ }
+
+ return (lhs.windowTitle ?? lhs.name).localizedCaseInsensitiveCompare(rhs.windowTitle ?? rhs.name) == .orderedAscending
+ }
+
+ let encoder = JSONEncoder()
+ encoder.outputFormatting = [.sortedKeys]
+ let data = try encoder.encode(entries)
+ FileHandle.standardOutput.write(data)
+ } catch {
+ fputs("Error listing windows: \(error.localizedDescription)\n", stderr)
+ fflush(stderr)
+ exit(1)
+ }
+
+ group.leave()
+}
+
+group.wait()
diff --git a/electron/native/SystemCursorAssets.swift b/electron/native/SystemCursorAssets.swift
new file mode 100644
index 000000000..b3adee519
--- /dev/null
+++ b/electron/native/SystemCursorAssets.swift
@@ -0,0 +1,210 @@
+import Foundation
+import AppKit
+
+// Force AppKit initialization so all system cursor images are populated
+let _ = NSApplication.shared
+
+struct CursorAsset: Codable {
+ let dataUrl: String
+ let hotspotX: Double
+ let hotspotY: Double
+ let width: Double
+ let height: Double
+}
+
+let minimumCursorRenderScale: CGFloat = 12
+
+/// Reference pixel height that all cursor images are normalized to after trimming.
+/// Kept high so large in-app cursor scaling still preserves raster detail up to 10x.
+let referencePixelHeight: CGFloat = 1024
+
+func bestCursorCGImage(for image: NSImage) -> CGImage? {
+ var proposedRect = CGRect(origin: .zero, size: image.size)
+ if let cgImage = image.cgImage(forProposedRect: &proposedRect, context: nil, hints: nil) {
+ return cgImage
+ }
+
+ let sortedRepresentations = image.representations.sorted { lhs, rhs in
+ let lhsPixels = lhs.pixelsWide * lhs.pixelsHigh
+ let rhsPixels = rhs.pixelsWide * rhs.pixelsHigh
+ return lhsPixels > rhsPixels
+ }
+
+ for representation in sortedRepresentations {
+ if let bitmap = representation as? NSBitmapImageRep, let cgImage = bitmap.cgImage {
+ return cgImage
+ }
+ }
+
+ return nil
+}
+
+func makeRetinaImage(from cgImage: CGImage, pointSize: NSSize) -> NSImage {
+ let bitmap = NSBitmapImageRep(cgImage: cgImage)
+ bitmap.size = pointSize
+ let image = NSImage(size: pointSize)
+ image.addRepresentation(bitmap)
+ return image
+}
+
+/// Find the bounding box of non-transparent pixels in a bitmap.
+/// Returns (x, y, width, height) in pixel coordinates (origin top-left).
+func findOpaqueBounds(_ bitmap: NSBitmapImageRep) -> (x: Int, y: Int, width: Int, height: Int)? {
+ let w = bitmap.pixelsWide
+ let h = bitmap.pixelsHigh
+ guard let data = bitmap.bitmapData else { return nil }
+ let bpr = bitmap.bytesPerRow
+ var minX = w, minY = h, maxX = -1, maxY = -1
+ for y in 0.. 2 {
+ if x < minX { minX = x }
+ if x > maxX { maxX = x }
+ if y < minY { minY = y }
+ if y > maxY { maxY = y }
+ }
+ }
+ }
+ if maxX < 0 { return nil }
+ return (minX, minY, maxX - minX + 1, maxY - minY + 1)
+}
+
+func makeAsset(for cursor: NSCursor) -> CursorAsset? {
+ let baseImage = cursor.image
+ let pointSize = baseImage.size
+ guard let sourceCGImage = bestCursorCGImage(for: baseImage) else { return nil }
+ let sourcePixelWidth = max(1, sourceCGImage.width)
+ let sourcePixelHeight = max(1, sourceCGImage.height)
+ let sourceScaleX = CGFloat(sourcePixelWidth) / max(1, pointSize.width)
+ let sourceScaleY = CGFloat(sourcePixelHeight) / max(1, pointSize.height)
+ let sourceScale = max(sourceScaleX, sourceScaleY)
+ let image = makeRetinaImage(from: sourceCGImage, pointSize: pointSize)
+ let renderScale = max(
+ minimumCursorRenderScale,
+ referencePixelHeight / max(1, CGFloat(sourcePixelHeight))
+ )
+ let pixelWidth = max(1, Int((CGFloat(sourcePixelWidth) * renderScale).rounded(.up)))
+ let pixelHeight = max(1, Int((CGFloat(sourcePixelHeight) * renderScale).rounded(.up)))
+
+ // Render at full resolution
+ guard let bitmap = NSBitmapImageRep(
+ bitmapDataPlanes: nil,
+ pixelsWide: pixelWidth,
+ pixelsHigh: pixelHeight,
+ bitsPerSample: 8,
+ samplesPerPixel: 4,
+ hasAlpha: true,
+ isPlanar: false,
+ colorSpaceName: .deviceRGB,
+ bytesPerRow: 0,
+ bitsPerPixel: 0
+ ) else { return nil }
+
+ bitmap.size = NSSize(width: CGFloat(pixelWidth), height: CGFloat(pixelHeight))
+
+ NSGraphicsContext.saveGraphicsState()
+ guard let context = NSGraphicsContext(bitmapImageRep: bitmap) else {
+ NSGraphicsContext.restoreGraphicsState()
+ return nil
+ }
+ NSGraphicsContext.current = context
+ context.imageInterpolation = .none
+ context.cgContext.interpolationQuality = .none
+ image.draw(
+ in: NSRect(x: 0, y: 0, width: CGFloat(pixelWidth), height: CGFloat(pixelHeight)),
+ from: .zero,
+ operation: .copy,
+ fraction: 1
+ )
+ context.flushGraphics()
+ NSGraphicsContext.restoreGraphicsState()
+
+ // Find opaque bounds and crop away transparent padding
+ guard let bounds = findOpaqueBounds(bitmap) else { return nil }
+ guard let fullCG = bitmap.cgImage else { return nil }
+ let cropRect = CGRect(x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height)
+ guard let croppedCG = fullCG.cropping(to: cropRect) else { return nil }
+
+ // Scale cropped image to referencePixelHeight
+ let aspect = CGFloat(bounds.width) / CGFloat(bounds.height)
+ let outH = Int(referencePixelHeight)
+ let outW = max(1, Int((referencePixelHeight * aspect).rounded(.up)))
+
+ guard let outBitmap = NSBitmapImageRep(
+ bitmapDataPlanes: nil,
+ pixelsWide: outW,
+ pixelsHigh: outH,
+ bitsPerSample: 8,
+ samplesPerPixel: 4,
+ hasAlpha: true,
+ isPlanar: false,
+ colorSpaceName: .deviceRGB,
+ bytesPerRow: 0,
+ bitsPerPixel: 0
+ ) else { return nil }
+
+ outBitmap.size = NSSize(width: outW, height: outH)
+
+ NSGraphicsContext.saveGraphicsState()
+ guard let outCtx = NSGraphicsContext(bitmapImageRep: outBitmap) else {
+ NSGraphicsContext.restoreGraphicsState()
+ return nil
+ }
+ NSGraphicsContext.current = outCtx
+ outCtx.imageInterpolation = .none
+ outCtx.cgContext.interpolationQuality = .none
+ let nsImage = NSImage(cgImage: croppedCG, size: NSSize(width: bounds.width, height: bounds.height))
+ nsImage.draw(
+ in: NSRect(x: 0, y: 0, width: outW, height: outH),
+ from: .zero,
+ operation: .copy,
+ fraction: 1
+ )
+ outCtx.flushGraphics()
+ NSGraphicsContext.restoreGraphicsState()
+
+ guard let pngData = outBitmap.representation(using: .png, properties: [:]) else { return nil }
+ let base64 = pngData.base64EncodedString()
+
+ // Adjust hotspot using the extracted source image scale so 2x cursor reps keep the correct anchor.
+ let hotspot = cursor.hotSpot
+ let hotspotPxX = hotspot.x * sourceScale * renderScale - CGFloat(bounds.x)
+ let hotspotPxY = hotspot.y * sourceScale * renderScale - CGFloat(bounds.y)
+ let scaleToOut = referencePixelHeight / CGFloat(bounds.height)
+ let outHotspotX = hotspotPxX * scaleToOut
+ let outHotspotY = hotspotPxY * scaleToOut
+
+ return CursorAsset(
+ dataUrl: "data:image/png;base64,\(base64)",
+ hotspotX: Double(outHotspotX),
+ hotspotY: Double(outHotspotY),
+ width: Double(outW),
+ height: Double(outH)
+ )
+}
+
+let cursorMap: [String: NSCursor] = [
+ "arrow": .arrow,
+ "text": .iBeam,
+ "pointer": .pointingHand,
+ "crosshair": .crosshair,
+ "open-hand": .openHand,
+ "closed-hand": .closedHand,
+ "resize-ew": .resizeLeftRight,
+ "resize-ns": .resizeUpDown,
+ "not-allowed": .operationNotAllowed,
+]
+
+var assets: [String: CursorAsset] = [:]
+for (key, cursor) in cursorMap {
+ if let asset = makeAsset(for: cursor) {
+ assets[key] = asset
+ }
+}
+
+let encoder = JSONEncoder()
+encoder.outputFormatting = [.sortedKeys]
+let data = try encoder.encode(assets)
+FileHandle.standardOutput.write(data)
+
diff --git a/electron/native/bin/darwin-arm64/openscreen-window-list b/electron/native/bin/darwin-arm64/openscreen-window-list
new file mode 100755
index 000000000..4cf4957bc
Binary files /dev/null and b/electron/native/bin/darwin-arm64/openscreen-window-list differ
diff --git a/electron/native/bin/darwin-arm64/recordly-native-cursor-monitor b/electron/native/bin/darwin-arm64/recordly-native-cursor-monitor
new file mode 100755
index 000000000..192f8719f
Binary files /dev/null and b/electron/native/bin/darwin-arm64/recordly-native-cursor-monitor differ
diff --git a/electron/native/bin/darwin-arm64/recordly-screencapturekit-helper b/electron/native/bin/darwin-arm64/recordly-screencapturekit-helper
new file mode 100755
index 000000000..54835c5ae
Binary files /dev/null and b/electron/native/bin/darwin-arm64/recordly-screencapturekit-helper differ
diff --git a/electron/native/bin/darwin-arm64/recordly-system-cursors b/electron/native/bin/darwin-arm64/recordly-system-cursors
new file mode 100755
index 000000000..a51c8e1a7
Binary files /dev/null and b/electron/native/bin/darwin-arm64/recordly-system-cursors differ
diff --git a/electron/native/bin/darwin-arm64/recordly-window-list b/electron/native/bin/darwin-arm64/recordly-window-list
new file mode 100755
index 000000000..d80287162
Binary files /dev/null and b/electron/native/bin/darwin-arm64/recordly-window-list differ
diff --git a/electron/native/bin/darwin-arm64/whisper-bench b/electron/native/bin/darwin-arm64/whisper-bench
new file mode 100755
index 000000000..5d33dbf00
Binary files /dev/null and b/electron/native/bin/darwin-arm64/whisper-bench differ
diff --git a/electron/native/bin/darwin-arm64/whisper-cli b/electron/native/bin/darwin-arm64/whisper-cli
new file mode 100755
index 000000000..695c8c9a4
Binary files /dev/null and b/electron/native/bin/darwin-arm64/whisper-cli differ
diff --git a/electron/native/bin/darwin-arm64/whisper-quantize b/electron/native/bin/darwin-arm64/whisper-quantize
new file mode 100755
index 000000000..d51722d9b
Binary files /dev/null and b/electron/native/bin/darwin-arm64/whisper-quantize differ
diff --git a/electron/native/bin/darwin-arm64/whisper-runtime.json b/electron/native/bin/darwin-arm64/whisper-runtime.json
new file mode 100644
index 000000000..380d1adf6
--- /dev/null
+++ b/electron/native/bin/darwin-arm64/whisper-runtime.json
@@ -0,0 +1,6 @@
+{
+ "version": "v1.8.4",
+ "platform": "darwin",
+ "arch": "arm64",
+ "binary": "whisper-cli"
+}
diff --git a/electron/native/bin/darwin-arm64/whisper-server b/electron/native/bin/darwin-arm64/whisper-server
new file mode 100755
index 000000000..790c1fe82
Binary files /dev/null and b/electron/native/bin/darwin-arm64/whisper-server differ
diff --git a/electron/native/bin/darwin-arm64/whisper-vad-speech-segments b/electron/native/bin/darwin-arm64/whisper-vad-speech-segments
new file mode 100755
index 000000000..65e963b39
Binary files /dev/null and b/electron/native/bin/darwin-arm64/whisper-vad-speech-segments differ
diff --git a/electron/native/bin/darwin-x64/recordly-native-cursor-monitor b/electron/native/bin/darwin-x64/recordly-native-cursor-monitor
new file mode 100755
index 000000000..b375827a8
Binary files /dev/null and b/electron/native/bin/darwin-x64/recordly-native-cursor-monitor differ
diff --git a/electron/native/bin/darwin-x64/recordly-screencapturekit-helper b/electron/native/bin/darwin-x64/recordly-screencapturekit-helper
new file mode 100755
index 000000000..1a05cf272
Binary files /dev/null and b/electron/native/bin/darwin-x64/recordly-screencapturekit-helper differ
diff --git a/electron/native/bin/darwin-x64/recordly-system-cursors b/electron/native/bin/darwin-x64/recordly-system-cursors
new file mode 100755
index 000000000..d78520557
Binary files /dev/null and b/electron/native/bin/darwin-x64/recordly-system-cursors differ
diff --git a/electron/native/bin/darwin-x64/recordly-window-list b/electron/native/bin/darwin-x64/recordly-window-list
new file mode 100755
index 000000000..85d6ef659
Binary files /dev/null and b/electron/native/bin/darwin-x64/recordly-window-list differ
diff --git a/electron/native/bin/win32-x64/cursor-monitor.exe b/electron/native/bin/win32-x64/cursor-monitor.exe
new file mode 100644
index 000000000..70738d118
Binary files /dev/null and b/electron/native/bin/win32-x64/cursor-monitor.exe differ
diff --git a/electron/native/bin/win32-x64/helpers-manifest.json b/electron/native/bin/win32-x64/helpers-manifest.json
new file mode 100644
index 000000000..0172a0c30
--- /dev/null
+++ b/electron/native/bin/win32-x64/helpers-manifest.json
@@ -0,0 +1,35 @@
+{
+ "version": 1,
+ "platform": "win32",
+ "arch": "x64",
+ "helpers": {
+ "wgc-capture": {
+ "binaryName": "wgc-capture.exe",
+ "binarySha256": "a5a9c0c417144a834af3206b60bae32d0c21d55deb8a3f5446831621cfdfa7b3",
+ "sourceDir": "electron/native/wgc-capture",
+ "sourceFingerprint": "c65b6eb2230be7db9b49aac48eba52a9eac469028ebb242e10c16c51c86b3220",
+ "updatedAt": "2026-09-05T02:09:18.163Z"
+ },
+ "cursor-monitor": {
+ "binaryName": "cursor-monitor.exe",
+ "binarySha256": "f1d8f30e8d7bee19ecea91c9a90a95ea4824138b8336b18030fa0602a641d70d",
+ "sourceDir": "electron/native/cursor-monitor",
+ "sourceFingerprint": "45bb72e4039e061a354af87317d7c42ec3d2118369b3f4379046ff497b701e72",
+ "updatedAt": "2026-07-11T11:58:56.534Z"
+ },
+ "recordly-gpu-export": {
+ "binaryName": "recordly-gpu-export.exe",
+ "binarySha256": "49a2ac588206305d0129e6263ce4be49780c50a9dc4efc7df63aad09178a919f",
+ "sourceDir": "electron/native/gpu-export-probe",
+ "sourceFingerprint": "37a5842eba63cdeccfddd02cc278a98207248fb0aa6b56153fb85ed152c908c1",
+ "updatedAt": "2026-07-11T11:58:51.659Z"
+ },
+ "recordly-nvidia-cuda-compositor": {
+ "binaryName": "recordly-nvidia-cuda-compositor.exe",
+ "binarySha256": "250a3f8cac7c6ea38a873434d23d4b2be7d6555e42cc0b405aa26f774169159c",
+ "sourceDir": "electron/native/nvidia-cuda-compositor",
+ "sourceFingerprint": "de1219228ce326e96d1f4815a3763b10d5f235cc1286bc6542c99707a85d5947",
+ "updatedAt": "2026-05-27T11:29:32.957Z"
+ }
+ }
+}
diff --git a/electron/native/bin/win32-x64/recordly-gpu-export.exe b/electron/native/bin/win32-x64/recordly-gpu-export.exe
new file mode 100644
index 000000000..b9d1efc9e
Binary files /dev/null and b/electron/native/bin/win32-x64/recordly-gpu-export.exe differ
diff --git a/electron/native/bin/win32-x64/recordly-nvidia-cuda-compositor.exe b/electron/native/bin/win32-x64/recordly-nvidia-cuda-compositor.exe
new file mode 100644
index 000000000..c23c14f90
Binary files /dev/null and b/electron/native/bin/win32-x64/recordly-nvidia-cuda-compositor.exe differ
diff --git a/electron/native/bin/win32-x64/wgc-capture.exe b/electron/native/bin/win32-x64/wgc-capture.exe
new file mode 100644
index 000000000..efa45c37d
Binary files /dev/null and b/electron/native/bin/win32-x64/wgc-capture.exe differ
diff --git a/electron/native/common/bt709_video.h b/electron/native/common/bt709_video.h
new file mode 100644
index 000000000..f4758dcce
--- /dev/null
+++ b/electron/native/common/bt709_video.h
@@ -0,0 +1,66 @@
+#pragma once
+
+#include
+#include
+#include