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..6125a1028
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,7 @@
+# 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
+
+# Leave blank until the hosted recording service is deployed and verified.
+# Development defaults to localhost:8787; packaged builds require an explicit URL.
+VITE_CLOUD_SHARE_ENDPOINT=
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..c13d0fcd2 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. X11 uses a screen/window source list; Wayland opens the system picker when recording starts. 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..a4b1462f4
--- /dev/null
+++ b/docs/authentication.md
@@ -0,0 +1,60 @@
+# Recordly authentication setup
+
+Recordly requires a non-anonymous Supabase Auth session before onboarding can advance to permissions or recording can start. The sign-in dialog uses email magic links and whichever of Google, Microsoft, or GitHub OAuth are enabled in the configured project; there is no guest or local demo login. Sessions persist across app restarts. The desktop application uses PKCE and returns from the system browser through a loopback callback in development and `recordly://auth/callback` in production. The dialog reads `/auth/v1/settings` and shows only enabled OAuth providers. SAML is intentionally hidden until configured.
+
+## Account and permission settings
+
+**Settings → Account** is available in both the editor and Projects dashboard. Display-name changes update Supabase user metadata and refresh the account avatar everywhere. Email changes use Supabase's email confirmation flow and the same desktop PKCE callback as sign-in; the existing email stays active until confirmation completes. Pending email changes are shown after reopening Settings. Connected sign-in methods reflect the user's actual account identities. Sign out clears the local session, revokes refresh sessions on all devices, and brings back mandatory sign-in; local projects remain on disk.
+
+**Settings → Permissions** shows live macOS Screen Recording and Accessibility statuses, refreshes when the app regains focus, and links to the onboarding permission setup. Microphone and camera permission requests remain tied to enabling those devices for recording. On other platforms, capture selection remains part of starting a recording.
+
+## 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.
+
+Enable email authentication and allow new sign-ups for magic links. Request the email from Recordly, then open it on the same computer with Recordly running. Configure production SMTP as described in `services/supabase/EMAIL_SETUP.md`.
+
+## 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. Microsoft and GitHub
+
+Enable Azure and GitHub under **Authentication → Sign In / Providers**, supply their client credentials, and use `https://YOUR_PROJECT_REF.supabase.co/auth/v1/callback` as the provider callback. Microsoft sign-in requests the `email` scope.
+
+## 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 auth helper supports extracting a work email domain for SAML; this option is currently hidden in the modal.
+
+## 5. Verify locally
+
+Restart `npm run dev` after creating `.env.local`. Open an editor and verify:
+
+1. Without a session, **Welcome to Recordly** opens and cannot be skipped or dismissed.
+2. Request an email magic link. Sending the email alone must not grant access. Opening the link exchanges its PKCE code for a persistent Supabase session and advances to **Permissions**.
+3. OAuth opens the system browser and returns to Recordly through the same callback.
+4. On Permissions, **Start recording** opens the recording controls after required system permissions are granted.
+5. Restart the app to verify session restoration. Sign out from the account dialog and verify that mandatory sign-in returns.
+6. **Show onboarding** opens Permissions directly for signed-in users. The recording HUD also checks for a Supabase session before preparing a recording.
+
+Automated UI tests use an isolated Supabase URL and mocked Auth HTTP responses; no production auth bypass is enabled in development or tests.
+
+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/pr/user-system/account-settings.png b/docs/pr/user-system/account-settings.png
new file mode 100644
index 000000000..8967d05ab
Binary files /dev/null and b/docs/pr/user-system/account-settings.png differ
diff --git a/docs/pr/user-system/capture-picker.png b/docs/pr/user-system/capture-picker.png
new file mode 100644
index 000000000..727cf21aa
Binary files /dev/null and b/docs/pr/user-system/capture-picker.png differ
diff --git a/docs/pr/user-system/home-loading.png b/docs/pr/user-system/home-loading.png
new file mode 100644
index 000000000..6c0679d76
Binary files /dev/null and b/docs/pr/user-system/home-loading.png differ
diff --git a/docs/pr/user-system/permissions.png b/docs/pr/user-system/permissions.png
new file mode 100644
index 000000000..8a765b8cc
Binary files /dev/null and b/docs/pr/user-system/permissions.png differ
diff --git a/docs/pr/user-system/sign-in.png b/docs/pr/user-system/sign-in.png
new file mode 100644
index 000000000..eabddb6b8
Binary files /dev/null and b/docs/pr/user-system/sign-in.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..cd4ef1cfc
--- /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(
+ 'Return to Recordly Return to Recordly Your sign-in link has been received. Return to the app to finish signing in.
',
+ );
+ });
+ 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/editorStartupView.test.ts b/electron/editorStartupView.test.ts
new file mode 100644
index 000000000..db1aa14e4
--- /dev/null
+++ b/electron/editorStartupView.test.ts
@@ -0,0 +1,17 @@
+import { expect, it } from "vitest";
+import { resolveEditorStartupView } from "./editorStartupView";
+
+it("shows Home for an empty app session", () => {
+ expect(resolveEditorStartupView({})).toBe("home");
+});
+for (const source of [
+ { projectPath: "/projects/demo.recordly" },
+ { videoPath: "/recordings/demo.mp4" },
+ { recordingVideoPath: "/recordings/fresh.mp4" },
+ { devInput: "/fixtures/demo.mp4" },
+ { smokeExport: true },
+]) {
+ it(`uses the editor skeleton for ${Object.keys(source)[0]}`, () => {
+ expect(resolveEditorStartupView(source)).toBe("editor");
+ });
+}
diff --git a/electron/editorStartupView.ts b/electron/editorStartupView.ts
new file mode 100644
index 000000000..18636f588
--- /dev/null
+++ b/electron/editorStartupView.ts
@@ -0,0 +1,18 @@
+/** Resolve the destination before renderer code or project hydration runs. */
+export function resolveEditorStartupView({
+ projectPath,
+ videoPath,
+ recordingVideoPath,
+ devInput,
+ smokeExport,
+}: {
+ projectPath?: string | null;
+ videoPath?: string | null;
+ recordingVideoPath?: string | null;
+ devInput?: string;
+ smokeExport?: boolean;
+}): "home" | "editor" {
+ return projectPath || videoPath || recordingVideoPath || devInput || smokeExport
+ ? "editor"
+ : "home";
+}
diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts
index 0853fc160..c98cad95a 100644
--- a/electron/electron-env.d.ts
+++ b/electron/electron-env.d.ts
@@ -1,50 +1,1150 @@
-///
-
-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 }>;
+ /** Opens the capture picker on every display and resolves when it closes. */
+ pickCaptureTarget: () => Promise<{
+ success: boolean;
+ canceled?: boolean;
+ record?: boolean;
+ source?: ProcessedDesktopSource;
+ message?: string;
+ }>;
+ completeCapturePick: (pick: CapturePick | null) => Promise;
+ getCapturePickerContext: (displayId: number) => Promise<{
+ displayBounds: CaptureArea | null;
+ lastArea: CaptureArea | null;
+ allowArea: boolean;
+ /** On-screen windows on this display, front to back. */
+ windows: CapturePickerWindow[];
+ cursor: { x: number; y: number } | null;
+ }>;
+ /** Reveals the picker window once it has painted its first state. */
+ capturePickerReady: () => void;
+ 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;
+ }>;
+ showRecordingPermissions: () => Promise;
+ onRecordingPermissionsRequested: (callback: () => void) => () => void;
+ 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[] }>;
+ onProjectThumbnailReady: (
+ callback: (ready: { path: string; thumbnailPath: string; updatedAt: number }) => void,
+ ) => () => void;
+ 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;
+ }>;
+ cloudShareManage: (input: {
+ endpoint: string;
+ token: string;
+ action: "list" | "delete";
+ shareCode?: string;
+ }) => Promise<{
+ success: boolean;
+ error?: string;
+ videos?: Array<{
+ code: string;
+ createdAt?: string;
+ title: string;
+ size: number;
+ ready: boolean;
+ url: 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 }>;
+ getEditorStartupView: () => "home" | "editor";
+ 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;
+ };
+}
+
+/** A rectangle in global display points (top-left origin). */
+interface CaptureArea {
+ x: number;
+ y: number;
+ width: number;
+ height: number;
+}
+
+/** A window the capture picker can highlight, framed in global display points. */
+interface CapturePickerWindow extends CaptureArea {
+ /** The window's source id, `window::0`. */
+ id: string;
+ appName: string;
+ title: string;
+ display_id?: string;
+}
+
+/**
+ * What the capture picker reports when the user confirms: an area they drew, a
+ * window they clicked, or a whole screen. `record` starts recording right away.
+ */
+type CapturePick =
+ | (CaptureArea & { kind: "area"; displayId: number; record: boolean })
+ | { kind: "window"; windowId: string; displayId: number; record: boolean }
+ | { kind: "screen"; displayId: number; record: boolean };
+
+interface ProcessedDesktopSource {
+ id: string;
+ name: string;
+ display_id: string;
+ thumbnail: string | null;
+ appIcon: string | null;
+ originalName?: string;
+ sourceType?: "screen" | "window" | "area";
+ appName?: string;
+ windowTitle?: string;
+ /** For area sources, the recorded part of the display. */
+ area?: CaptureArea;
+}
+
+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/capturePickerSources.test.ts b/electron/ipc/capturePickerSources.test.ts
new file mode 100644
index 000000000..f6780f5b0
--- /dev/null
+++ b/electron/ipc/capturePickerSources.test.ts
@@ -0,0 +1,66 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+const mocks = vi.hoisted(() => ({
+ exec: vi.fn(),
+ sources: vi.fn(),
+ mac: vi.fn(),
+ dip: vi.fn(),
+}));
+vi.mock("electron", () => ({ systemPreferences: { getMediaAccessStatus: () => "granted" } }));
+vi.mock("node:util", () => ({ promisify: () => mocks.exec }));
+vi.mock("./cursor/bounds", () => ({
+ getNativeMacWindowsFrontToBack: mocks.mac,
+}));
+vi.mock("./register/sources", () => ({ getDesktopSources: mocks.sources }));
+vi.mock("./utils", () => ({
+ parseWindowId: (id?: string) => Number(id?.split(":")[1]),
+ getScreen: () => ({ screenToDipRect: mocks.dip }),
+}));
+import { listCapturePickerWindows } from "./capturePickerSources";
+const platform = process.platform;
+afterEach(() => Object.defineProperty(process, "platform", { value: platform }));
+beforeEach(() => {
+ vi.resetAllMocks();
+ mocks.sources.mockResolvedValue([
+ { id: "window:101:0", name: "Front" },
+ { id: "window:102:0", name: "Back" },
+ ]);
+});
+describe("capture picker source adapters", () => {
+ it("keeps Linux window selection on its source list without X11 helper probes", async () => {
+ Object.defineProperty(process, "platform", { value: "linux" });
+ await expect(listCapturePickerWindows()).rejects.toThrow("Use the source list on Linux");
+ expect(mocks.exec).not.toHaveBeenCalled();
+ });
+ it("matches Windows handles to real capture IDs and converts physical bounds to DIP", async () => {
+ Object.defineProperty(process, "platform", { value: "win32" });
+ mocks.exec.mockResolvedValue({
+ stdout: JSON.stringify([
+ { id: 999, x: 0, y: 0, width: 20, height: 20 },
+ { id: 101, x: -1600, y: 0, width: 800, height: 600 },
+ ]),
+ });
+ mocks.dip.mockReturnValue({ x: -800, y: 0, width: 400, height: 300 });
+ expect(await listCapturePickerWindows()).toEqual([
+ {
+ id: "window:101:0",
+ appName: "",
+ title: "Front",
+ display_id: undefined,
+ x: -800,
+ y: 0,
+ width: 400,
+ height: 300,
+ },
+ ]);
+ expect(mocks.dip).toHaveBeenCalledWith(null, expect.objectContaining({ x: -1600 }));
+ });
+ it("excludes Recordly's own macOS windows and invalid geometry", async () => {
+ Object.defineProperty(process, "platform", { value: "darwin" });
+ mocks.mac.mockResolvedValue([
+ { id: "window:1:0", ownerPid: process.pid, x: 0, y: 0, width: 10, height: 10 },
+ { id: "window:2:0", ownerPid: 99, name: "Other", x: 0, y: 0, width: 400, height: 300 },
+ { id: "window:3:0", width: Number.NaN },
+ ]);
+ expect((await listCapturePickerWindows()).map((entry) => entry.id)).toEqual(["window:2:0"]);
+ });
+});
diff --git a/electron/ipc/capturePickerSources.ts b/electron/ipc/capturePickerSources.ts
new file mode 100644
index 000000000..37d78bbe2
--- /dev/null
+++ b/electron/ipc/capturePickerSources.ts
@@ -0,0 +1,106 @@
+import { systemPreferences } from "electron";
+import { execFile } from "node:child_process";
+import { promisify } from "node:util";
+import { getNativeMacWindowsFrontToBack } from "./cursor/bounds";
+import type { CapturePickerWindow, SelectedSource } from "./types";
+import { getScreen, parseWindowId } from "./utils";
+import { getDesktopSources } from "./register/sources";
+
+const exec = promisify(execFile);
+
+// GetTopWindow/GetWindow walks front to back. Restrict the result to Electron's
+// capturable sources rather than inventing source IDs or changing capture routes.
+const WINDOWS_SCRIPT = `Add-Type -TypeDefinition @"
+using System; using System.Runtime.InteropServices; using System.Collections.Generic;
+public static class RecordlyPickerWindows {
+ [StructLayout(LayoutKind.Sequential)] public struct RECT { public int Left, Top, Right, Bottom; }
+ [DllImport("user32.dll")] static extern IntPtr GetTopWindow(IntPtr h);
+ [DllImport("user32.dll")] static extern IntPtr GetWindow(IntPtr h, uint command);
+ [DllImport("user32.dll")] static extern bool IsWindowVisible(IntPtr h);
+ [DllImport("user32.dll")] static extern bool IsIconic(IntPtr h);
+ [DllImport("user32.dll")] static extern bool GetWindowRect(IntPtr h, out RECT rect);
+ [DllImport("user32.dll")] static extern IntPtr SetThreadDpiAwarenessContext(IntPtr context);
+ [DllImport("dwmapi.dll")] static extern int DwmGetWindowAttribute(IntPtr h, int attribute, out RECT rect, int size);
+ public class Entry { public long id; public int x, y, width, height; }
+ public static Entry[] List() {
+ SetThreadDpiAwarenessContext(new IntPtr(-4));
+ var list = new List(); var seen = new HashSet();
+ for (var h = GetTopWindow(IntPtr.Zero); h != IntPtr.Zero && seen.Add(h.ToInt64()); h = GetWindow(h, 2)) {
+ if (!IsWindowVisible(h) || IsIconic(h)) continue;
+ RECT rect;
+ if (DwmGetWindowAttribute(h, 9, out rect, Marshal.SizeOf(typeof(RECT))) != 0 && !GetWindowRect(h, out rect)) continue;
+ if (rect.Right <= rect.Left || rect.Bottom <= rect.Top) continue;
+ list.Add(new Entry { id = h.ToInt64(), x = rect.Left, y = rect.Top, width = rect.Right - rect.Left, height = rect.Bottom - rect.Top });
+ }
+ return list.ToArray();
+ }
+}
+"@
+ConvertTo-Json -InputObject @([RecordlyPickerWindows]::List()) -Compress`;
+
+export async function listCapturePickerWindows(): Promise {
+ if (process.platform === "darwin") {
+ if (systemPreferences.getMediaAccessStatus("screen") !== "granted")
+ throw new Error("Allow screen recording in System Settings before picking a window.");
+ const entries = await getNativeMacWindowsFrontToBack({ strict: true });
+ return entries
+ .filter(
+ (entry) =>
+ entry.ownerPid !== process.pid &&
+ [entry.x, entry.y, entry.width, entry.height].every(Number.isFinite) &&
+ (entry.width ?? 0) > 0 &&
+ (entry.height ?? 0) > 0,
+ )
+ .map((entry) => ({
+ id: entry.id,
+ appName: entry.appName ?? "",
+ title: entry.windowTitle ?? entry.name,
+ display_id: entry.display_id,
+ x: entry.x!,
+ y: entry.y!,
+ width: entry.width!,
+ height: entry.height!,
+ }));
+ }
+ if (process.platform !== "win32") throw new Error("Use the source list on Linux.");
+ const sources = await getDesktopSources(
+ {
+ types: ["window"],
+ thumbnailSize: { width: 0, height: 0 },
+ fetchWindowIcons: false,
+ },
+ { strict: true },
+ );
+ const capturable = sources.filter(
+ (source): source is SelectedSource & { id: string } =>
+ typeof source.id === "string" && source.id.startsWith("window:"),
+ );
+ const byId = new Map(capturable.map((source) => [parseWindowId(source.id), source]));
+ const { stdout } = await exec(
+ "powershell.exe",
+ ["-NoProfile", "-NonInteractive", "-Command", WINDOWS_SCRIPT],
+ { timeout: 10000, maxBuffer: 4 * 1024 * 1024 },
+ );
+ const entries: unknown = JSON.parse(stdout);
+ if (!Array.isArray(entries)) throw new Error("Unable to read window positions.");
+ return entries.flatMap((entry) => {
+ const source = byId.get(entry.id);
+ if (
+ !source ||
+ ![entry.x, entry.y, entry.width, entry.height].every(Number.isFinite) ||
+ entry.width <= 0 ||
+ entry.height <= 0
+ )
+ return [];
+ const bounds = getScreen().screenToDipRect(null, entry);
+ return [
+ {
+ ...bounds,
+ id: source.id,
+ appName: "",
+ title: source.name,
+ display_id: source.display_id,
+ },
+ ];
+ });
+}
diff --git a/electron/ipc/captureSelection.test.ts b/electron/ipc/captureSelection.test.ts
new file mode 100644
index 000000000..54d4fe011
--- /dev/null
+++ b/electron/ipc/captureSelection.test.ts
@@ -0,0 +1,129 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+const mocks = vi.hoisted(() => ({
+ read: vi.fn(),
+ write: vi.fn(),
+ windows: vi.fn(),
+ sources: vi.fn(),
+ current: null as unknown,
+ displays: [{ id: 1, bounds: { x: 0, y: 0, width: 1440, height: 1000 } }],
+}));
+vi.mock("../appSettingsStore", () => ({
+ readAppSetting: mocks.read,
+ writeAppSetting: mocks.write,
+}));
+vi.mock("./capturePickerSources", () => ({ listCapturePickerWindows: mocks.windows }));
+vi.mock("./register/sources", () => ({ getDesktopSources: mocks.sources }));
+vi.mock("./state", () => ({
+ get selectedSource() {
+ return mocks.current;
+ },
+ setSelectedSource: (value: unknown) => {
+ mocks.current = value;
+ },
+}));
+vi.mock("./utils", () => ({ getScreen: () => ({ getAllDisplays: () => mocks.displays }) }));
+import { getSessionCaptureSource, validateCaptureSource } from "./captureSelection";
+const platform = process.platform;
+const source = { id: "window:101:0", name: "Document", windowTitle: "Document", appName: "Notes" };
+beforeEach(() => {
+ vi.clearAllMocks();
+ Object.defineProperty(process, "platform", { value: "darwin" });
+ mocks.current = null;
+ mocks.read.mockReturnValue({ platform: "darwin", source });
+ mocks.windows.mockResolvedValue([
+ { id: source.id, appName: "Notes", title: "Document", x: 0, y: 0, width: 100, height: 100 },
+ ]);
+});
+afterEach(() => {
+ Object.defineProperty(process, "platform", { value: platform });
+ vi.unstubAllEnvs();
+});
+describe("session capture selection", () => {
+ it("starts without a capture source even when the previous window is still available", async () => {
+ expect(await getSessionCaptureSource()).toBeNull();
+ expect(mocks.current).toBeNull();
+ });
+ it("ignores persisted selection without enumerating capture sources on startup", () => {
+ expect(getSessionCaptureSource()).toBeNull();
+ expect(mocks.read).not.toHaveBeenCalled();
+ expect(mocks.windows).not.toHaveBeenCalled();
+ expect(mocks.sources).not.toHaveBeenCalled();
+ });
+ it("rejects a recycled window ID with a different title", async () => {
+ mocks.windows.mockResolvedValue([
+ { id: source.id, title: "Another document", appName: "Notes" },
+ ]);
+ expect(await validateCaptureSource(source)).toBeNull();
+ });
+ it("rejects disconnected screens and areas that would be resized", async () => {
+ expect(
+ await validateCaptureSource({ id: "screen:2:0", name: "Gone", display_id: "2" }),
+ ).toBeNull();
+ expect(
+ await validateCaptureSource({
+ id: "area:1",
+ name: "Area",
+ display_id: "1",
+ area: { x: 1300, y: 0, width: 400, height: 300 },
+ }),
+ ).toBeNull();
+ });
+ it("does not broaden window capture when detection fails", async () => {
+ mocks.windows.mockRejectedValue(new Error("permission denied"));
+ await expect(validateCaptureSource(source)).rejects.toThrow("permission denied");
+ expect(mocks.sources).not.toHaveBeenCalled();
+ });
+ it("keeps a selection only within the current app session", () => {
+ mocks.current = source;
+ expect(getSessionCaptureSource()).toEqual(source);
+ // A fresh main process initializes its in-memory selection to null.
+ mocks.current = null;
+ expect(getSessionCaptureSource()).toBeNull();
+ expect(mocks.read).not.toHaveBeenCalled();
+ expect(mocks.write).not.toHaveBeenCalled();
+ });
+});
+
+describe("Linux selection", () => {
+ beforeEach(() => {
+ Object.defineProperty(process, "platform", { value: "linux" });
+ vi.stubEnv("XDG_SESSION_TYPE", "x11");
+ mocks.sources.mockResolvedValue([{ ...source, display_id: "1" }]);
+ });
+ it("validates X11 windows using live sources without geometry helpers", async () => {
+ expect(await validateCaptureSource(source)).toMatchObject({
+ id: source.id,
+ name: source.name,
+ windowTitle: source.windowTitle,
+ display_id: "1",
+ sourceType: "window",
+ });
+ expect(mocks.windows).not.toHaveBeenCalled();
+ expect(mocks.sources).toHaveBeenCalledWith(
+ { types: ["window"], thumbnailSize: { width: 0, height: 0 } },
+ { strict: true },
+ );
+ });
+ it("rejects missing and recycled X11 windows without substitution", async () => {
+ mocks.sources.mockResolvedValue([{ id: source.id, name: "Different document" }]);
+ expect(await validateCaptureSource(source)).toBeNull();
+ mocks.sources.mockResolvedValue([{ ...source, id: "window:102:0" }]);
+ expect(await validateCaptureSource(source)).toBeNull();
+ });
+ it("starts Wayland unselected without opening the portal", () => {
+ vi.stubEnv("XDG_SESSION_TYPE", "wayland");
+ expect(getSessionCaptureSource()).toBeNull();
+ expect(mocks.read).not.toHaveBeenCalled();
+ expect(mocks.sources).not.toHaveBeenCalled();
+ expect(mocks.windows).not.toHaveBeenCalled();
+ });
+ it("rejects old X11 IDs on Wayland without enumerating portal sources", async () => {
+ vi.stubEnv("XDG_SESSION_TYPE", "wayland");
+ expect(await validateCaptureSource(source)).toBeNull();
+ expect(
+ await validateCaptureSource({ id: "screen:1:0", name: "Screen", display_id: "1" }),
+ ).toBeNull();
+ expect(mocks.sources).not.toHaveBeenCalled();
+ expect(mocks.windows).not.toHaveBeenCalled();
+ });
+});
diff --git a/electron/ipc/captureSelection.ts b/electron/ipc/captureSelection.ts
new file mode 100644
index 000000000..de29c83d9
--- /dev/null
+++ b/electron/ipc/captureSelection.ts
@@ -0,0 +1,98 @@
+import { listCapturePickerWindows } from "./capturePickerSources";
+import {
+ createAreaSource,
+ createWindowSource,
+ getSourceArea,
+ normalizeCapturePick,
+} from "./sourceArea";
+import { selectedSource } from "./state";
+import type { SelectedSource } from "./types";
+import { getScreen } from "./utils";
+import { getDesktopSources } from "./register/sources";
+import {
+ isLikelyLinuxWaylandSession,
+ LINUX_PORTAL_SCREEN_SOURCE_ID,
+} from "./register/sourceMapping";
+
+/** Revalidate identity and geometry; never substitute a broader capture source. */
+export async function validateCaptureSource(candidate: unknown): Promise {
+ if (!candidate || typeof candidate !== "object") return null;
+ const source = candidate as Partial;
+ if (typeof source.id !== "string" || typeof source.name !== "string") return null;
+ if (source.id === LINUX_PORTAL_SCREEN_SOURCE_ID)
+ return process.platform === "linux" && isLikelyLinuxWaylandSession(process.env)
+ ? { id: source.id, name: "System picker", sourceType: "screen" }
+ : null;
+ if (process.platform === "linux" && isLikelyLinuxWaylandSession(process.env)) return null;
+ if (source.id.startsWith("window:") && process.platform === "linux") {
+ const windows = await getDesktopSources(
+ { types: ["window"], thumbnailSize: { width: 0, height: 0 } },
+ { strict: true },
+ );
+ const match = windows.find(
+ (entry) =>
+ entry.id === source.id &&
+ (entry.windowTitle ?? entry.name) === (source.windowTitle ?? source.name),
+ );
+ return match
+ ? {
+ id: match.id,
+ name: match.name,
+ windowTitle: match.windowTitle ?? match.name,
+ display_id: match.display_id,
+ sourceType: "window",
+ }
+ : null;
+ }
+ if (source.id.startsWith("window:")) {
+ const windows = await listCapturePickerWindows();
+ const match = windows.find(
+ (entry) =>
+ entry.id === source.id &&
+ (entry.title || entry.appName || "Window") ===
+ (source.windowTitle ?? source.name) &&
+ (!source.appName || entry.appName === source.appName),
+ );
+ return match ? createWindowSource(match) : null;
+ }
+ const display = getScreen()
+ .getAllDisplays()
+ .find((entry) => String(entry.id) === source.display_id);
+ if (!display) return null;
+ if (source.id.startsWith("area:")) {
+ if (process.platform !== "darwin") return null;
+ const area = getSourceArea(source as SelectedSource);
+ if (!area) return null;
+ const normalized = normalizeCapturePick(
+ { ...area, kind: "area", displayId: display.id, record: false },
+ [{ id: display.id, bounds: display.bounds }],
+ [],
+ );
+ // A changed display must invalidate the area rather than silently resize it.
+ if (
+ !normalized ||
+ normalized.kind !== "area" ||
+ ["x", "y", "width", "height"].some(
+ (key) => normalized[key as keyof typeof area] !== area[key as keyof typeof area],
+ )
+ )
+ return null;
+ return createAreaSource(normalized);
+ }
+ if (!source.id.startsWith("screen:")) return null;
+ const sources = await getDesktopSources(
+ { types: ["screen"], thumbnailSize: { width: 0, height: 0 } },
+ { strict: true },
+ );
+ const match = sources.find(
+ (entry) => entry.id === source.id && entry.display_id === source.display_id,
+ );
+ return match
+ ? { id: match.id, name: match.name, display_id: match.display_id, sourceType: "screen" }
+ : null;
+}
+
+/** A new main process starts unselected; old saved preferences are never restored. */
+export function getSessionCaptureSource(): SelectedSource | null {
+ return selectedSource;
+}
diff --git a/electron/ipc/cloudShareContract.ts b/electron/ipc/cloudShareContract.ts
new file mode 100644
index 000000000..d8dfc8d7b
--- /dev/null
+++ b/electron/ipc/cloudShareContract.ts
@@ -0,0 +1,127 @@
+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",
+ "https://recordly-share-staging.youngchen3442.workers.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,
+ };
+}
+
+/** Validates untrusted library JSON before presenting recording metadata to the renderer. */
+export function parseCloudRecordingList(data: unknown, endpoint: string) {
+ if (!data || typeof data !== "object" || !("videos" in data) || !Array.isArray(data.videos))
+ throw new Error("Invalid recordings response.");
+ return data.videos.slice(0, 100).map((raw: unknown) => {
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
+ throw new Error("Invalid recording response.");
+ const video = raw as Record;
+ if (
+ typeof video.share_code !== "string" ||
+ !/^[a-z0-9]{1,128}$/.test(video.share_code) ||
+ typeof video.title !== "string"
+ )
+ throw new Error("Invalid recording response.");
+ const size = Number(video.file_size);
+ return {
+ code: video.share_code,
+ title: video.title,
+ size: Number.isFinite(size) && size >= 0 ? size : 0,
+ createdAt: typeof video.created_at === "string" ? video.created_at : undefined,
+ ready: video.upload_completed === 1,
+ url: new URL(`/s/${video.share_code}`, endpoint).href,
+ };
+ });
+}
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.test.ts b/electron/ipc/cursor/bounds.test.ts
new file mode 100644
index 000000000..4e2da0610
--- /dev/null
+++ b/electron/ipc/cursor/bounds.test.ts
@@ -0,0 +1,29 @@
+import { expect, it, vi } from "vitest";
+const exec = vi.hoisted(() => vi.fn());
+vi.mock("node:child_process", () => ({ execFile: exec }));
+vi.mock("node:util", () => ({ promisify: () => exec }));
+vi.mock("../paths/binaries", () => ({ ensureNativeWindowListBinary: vi.fn() }));
+vi.mock("../state", () => ({}));
+vi.mock("../utils", () => ({
+ parseWindowId: (id: string) =>
+ id.startsWith("window:") ? Number(id.split(":")[1]) || null : null,
+}));
+vi.mock("../windowsWindowControl", () => ({ resolveWindowsWindowBounds: vi.fn() }));
+import { resolveLinuxWindowBounds } from "./bounds";
+
+it("does not track a different same-title X11 window after the selected window disappears", async () => {
+ exec.mockReset().mockRejectedValue(new Error("No such window"));
+ expect(await resolveLinuxWindowBounds({ id: "window:101:0", name: "Document" })).toBeNull();
+ expect(exec).toHaveBeenCalledExactlyOnceWith("xwininfo", ["-id", "101"], { timeout: 1500 });
+});
+it("returns live X11 bounds by ID", async () => {
+ exec.mockReset().mockResolvedValue({
+ stdout: "Absolute upper-left X: -100\nAbsolute upper-left Y: 50\nWidth: 640\nHeight: 480",
+ });
+ expect(await resolveLinuxWindowBounds({ id: "window:101:0", name: "Document" })).toEqual({
+ x: -100,
+ y: 50,
+ width: 640,
+ height: 480,
+ });
+});
diff --git a/electron/ipc/cursor/bounds.ts b/electron/ipc/cursor/bounds.ts
new file mode 100644
index 000000000..bb5da7a64
--- /dev/null
+++ b/electron/ipc/cursor/bounds.ts
@@ -0,0 +1,246 @@
+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[]);
+ }
+}
+
+/** On-screen windows ordered front to back, for finding the window under a point. */
+export async function getNativeMacWindowsFrontToBack(options?: {
+ strict?: boolean;
+}): Promise {
+ if (process.platform !== "darwin") {
+ return [];
+ }
+
+ try {
+ const binaryPath = await ensureNativeWindowListBinary();
+ const { stdout } = await execFileAsync(binaryPath, ["--z-order"], {
+ timeout: 10000,
+ maxBuffer: 10 * 1024 * 1024,
+ });
+ const parsed: unknown = JSON.parse(stdout);
+ if (!Array.isArray(parsed)) {
+ if (options?.strict) throw new Error("Unable to read window positions.");
+ return [];
+ }
+ return parsed.filter(
+ (entry): entry is NativeMacWindowSource =>
+ Boolean(entry) &&
+ typeof entry === "object" &&
+ typeof (entry as NativeMacWindowSource).id === "string",
+ );
+ } catch (error) {
+ if (options?.strict)
+ throw new Error(
+ "Unable to detect windows. Check screen recording permission and try again.",
+ );
+ console.warn("Failed to list windows front to back:", error);
+ return [];
+ }
+}
+
+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 {
+ return null;
+ }
+ // Do not track a different same-title window after the selected ID disappears.
+ return null;
+ }
+
+ 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..8318497bd
--- /dev/null
+++ b/electron/ipc/cursor/telemetry.ts
@@ -0,0 +1,335 @@
+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 { getSourceArea } from "../sourceArea";
+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;
+
+ // Areas are fixed rectangles, so they normalize like a window that never moves.
+ const windowBounds = selectedSource?.id?.startsWith("window:")
+ ? selectedWindowBounds
+ : getSourceArea(selectedSource);
+ 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..c5c8667e7 100644
--- a/electron/ipc/handlers.ts
+++ b/electron/ipc/handlers.ts
@@ -1,181 +1,78 @@
-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 { registerCapturePickerHandlers } from "./register/capturePicker";
+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)
- }
- }
- })
+ const { pickSourceList } = registerSourceHandlers({
+ createEditorWindow,
+ createSourceSelectorWindow,
+ getSourceSelectorWindow,
+ });
+ registerCapturePickerHandlers({ pickSourceList });
+ 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/firstFrameThumbnail.ts b/electron/ipc/project/firstFrameThumbnail.ts
new file mode 100644
index 000000000..403f5e950
--- /dev/null
+++ b/electron/ipc/project/firstFrameThumbnail.ts
@@ -0,0 +1,45 @@
+import { execFile } from "node:child_process";
+import { promisify } from "node:util";
+import { getFfmpegBinaryPath } from "../ffmpeg/binary";
+import {
+ PROJECT_THUMBNAIL_WIDTH,
+ PROJECT_THUMBNAIL_HEIGHT,
+} from "../../../src/lib/projectThumbnail";
+const run = promisify(execFile);
+let queue = Promise.resolve();
+
+export function createProjectFirstFrameThumbnail(sourcePath: string): Promise {
+ const task = queue.then(async () => {
+ const result = await run(
+ getFfmpegBinaryPath(),
+ [
+ "-hide_banner",
+ "-loglevel",
+ "error",
+ "-nostdin",
+ "-threads",
+ "1",
+ "-i",
+ sourcePath,
+ "-frames:v",
+ "1",
+ "-an",
+ "-vf",
+ `scale=${PROJECT_THUMBNAIL_WIDTH}:${PROJECT_THUMBNAIL_HEIGHT}:force_original_aspect_ratio=increase,crop=${PROJECT_THUMBNAIL_WIDTH}:${PROJECT_THUMBNAIL_HEIGHT}`,
+ "-f",
+ "image2pipe",
+ "-c:v",
+ "png",
+ "pipe:1",
+ ],
+ { encoding: "buffer", maxBuffer: 4 * 1024 * 1024, timeout: 15000, windowsHide: true },
+ );
+ if (!result.stdout.length) throw new Error("Project source has no preview frame");
+ return result.stdout;
+ });
+ queue = task.then(
+ () => undefined,
+ () => undefined,
+ );
+ return task;
+}
diff --git a/electron/ipc/project/manager.test.ts b/electron/ipc/project/manager.test.ts
new file mode 100644
index 000000000..682ed9454
--- /dev/null
+++ b/electron/ipc/project/manager.test.ts
@@ -0,0 +1,368 @@
+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");
+ vi.doUnmock("./firstFrameThumbnail");
+ 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("generates a missing project poster once and keeps it cached", async () => {
+ const png = Buffer.alloc(24);
+ Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]).copy(png);
+ png.writeUInt32BE(640, 16);
+ png.writeUInt32BE(480, 20);
+ const generate = vi.fn().mockResolvedValue(png);
+ vi.doMock("./firstFrameThumbnail", () => ({ createProjectFirstFrameThumbnail: generate }));
+ const manager = await import("./manager");
+ const source = path.join(tempRoot, "source.mp4");
+ const projectPath = path.join(tempRoot, "test.recordly");
+ await fs.writeFile(source, "video");
+ await fs.writeFile(
+ projectPath,
+ JSON.stringify({ version: 2, videoPath: source, editor: {} }),
+ );
+ const before = await fs.readFile(projectPath, "utf8");
+ const entry = await manager.buildProjectLibraryEntry(projectPath, tempRoot);
+ expect(entry?.thumbnailPath).toBeNull();
+ await manager.refreshProjectThumbnail(projectPath, await fs.stat(projectPath));
+ expect((await manager.buildProjectLibraryEntry(projectPath, tempRoot))?.thumbnailPath).toBe(
+ manager.getProjectThumbnailPath(projectPath),
+ );
+ expect(generate).toHaveBeenCalledWith(source);
+ await manager.buildProjectLibraryEntry(projectPath, tempRoot);
+ expect(generate).toHaveBeenCalledTimes(1);
+ expect(await fs.readFile(projectPath, "utf8")).toBe(before);
+ });
+
+ 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);
+ });
+ it("returns a library entry while a thumbnail decode is still pending", async () => {
+ let release!: (data: Buffer) => void;
+ const pending = new Promise((resolve) => {
+ release = resolve;
+ });
+ const generate = vi.fn(() => pending);
+ vi.doMock("./firstFrameThumbnail", () => ({ createProjectFirstFrameThumbnail: generate }));
+ const manager = await import("./manager");
+ const source = path.join(tempRoot, "slow.mp4");
+ const projectPath = path.join(tempRoot, "slow.recordly");
+ await fs.writeFile(source, "video");
+ await fs.writeFile(
+ projectPath,
+ JSON.stringify({ version: 2, videoPath: source, editor: {} }),
+ );
+ const ready = vi.fn();
+ const entry = await manager.buildProjectLibraryEntry(projectPath, tempRoot, ready);
+ expect(entry?.path).toBe(projectPath);
+ expect(entry?.thumbnailPath).toBeNull();
+ const task = manager.refreshProjectThumbnail(projectPath, await fs.stat(projectPath));
+ expect(ready).not.toHaveBeenCalled();
+ release(Buffer.from("fixture thumbnail"));
+ await task;
+ expect(generate).toHaveBeenCalledTimes(1);
+ expect(ready).toHaveBeenCalledWith(expect.objectContaining({ path: projectPath }));
+ });
+
+ it("replaces a thumbnail symlink without overwriting its target", async () => {
+ vi.doMock("./firstFrameThumbnail", () => ({
+ createProjectFirstFrameThumbnail: vi.fn().mockResolvedValue(Buffer.from("new poster")),
+ }));
+ const manager = await import("./manager");
+ const source = path.join(tempRoot, "source.mp4");
+ const projectPath = path.join(tempRoot, "linked.recordly");
+ const target = path.join(tempRoot, "private.txt");
+ await fs.writeFile(source, "video");
+ await fs.writeFile(
+ projectPath,
+ JSON.stringify({ version: 2, videoPath: source, editor: {} }),
+ );
+ await fs.writeFile(target, "must remain intact");
+ const thumbnail = manager.getProjectThumbnailPath(projectPath);
+ try {
+ await fs.symlink(target, thumbnail);
+ } catch (error) {
+ if ((error as NodeJS.ErrnoException).code === "EPERM") return;
+ throw error;
+ }
+ await manager.refreshProjectThumbnail(projectPath, await fs.stat(projectPath));
+ expect(await fs.readFile(target, "utf8")).toBe("must remain intact");
+ expect((await fs.lstat(thumbnail)).isSymbolicLink()).toBe(false);
+ expect(await fs.readFile(thumbnail, "utf8")).toBe("new poster");
+ });
+
+ it("discards a thumbnail when the project changes during decoding", async () => {
+ let release!: (data: Buffer) => void;
+ const pending = new Promise((resolve) => {
+ release = resolve;
+ });
+ const generate = vi.fn(() => pending);
+ vi.doMock("./firstFrameThumbnail", () => ({ createProjectFirstFrameThumbnail: generate }));
+ const manager = await import("./manager");
+ const source = path.join(tempRoot, "source.mp4");
+ const projectPath = path.join(tempRoot, "edited.recordly");
+ await fs.writeFile(source, "video");
+ await fs.writeFile(
+ projectPath,
+ JSON.stringify({ version: 2, videoPath: source, editor: {} }),
+ );
+ const task = manager.refreshProjectThumbnail(projectPath, await fs.stat(projectPath));
+ await vi.waitFor(() => expect(generate).toHaveBeenCalled());
+ await fs.appendFile(projectPath, " ");
+ release(Buffer.from("obsolete poster"));
+ expect(await task).toBeNull();
+ await expect(fs.stat(manager.getProjectThumbnailPath(projectPath))).rejects.toMatchObject({
+ code: "ENOENT",
+ });
+ });
+});
diff --git a/electron/ipc/project/manager.ts b/electron/ipc/project/manager.ts
new file mode 100644
index 000000000..4b066aba5
--- /dev/null
+++ b/electron/ipc/project/manager.ts
@@ -0,0 +1,584 @@
+import { randomUUID } from "node:crypto";
+import { createProjectFirstFrameThumbnail } from "./firstFrameThumbnail";
+import { buildMediaUrl, getMediaServerBaseUrl } from "../../mediaServer";
+import type { ProjectPreviewData } from "../../../src/types/projectPreview";
+import { hasFreshProjectThumbnail } from "./thumbnailFreshness";
+import { type Stats, 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 type ProjectThumbnailReady = { path: string; thumbnailPath: string; updatedAt: number };
+const thumbnailJobs = new Map>();
+
+function sameProjectRevision(left: Stats, right: Stats) {
+ return (
+ left.mtimeMs === right.mtimeMs &&
+ left.ctimeMs === right.ctimeMs &&
+ left.size === right.size &&
+ left.ino === right.ino &&
+ left.dev === right.dev
+ );
+}
+
+export function refreshProjectThumbnail(projectPath: string, revision: Stats) {
+ const pending = thumbnailJobs.get(projectPath);
+ if (pending) return pending;
+ const task = (async (): Promise => {
+ const thumbnailPath = getProjectThumbnailPath(projectPath);
+ let temporary: string | undefined;
+ try {
+ if (!sameProjectRevision(revision, await fs.stat(projectPath))) return null;
+ const project = parseJsonWithByteOrderMark(await fs.readFile(projectPath, "utf-8"));
+ if (!isLoadableProjectData(project)) return null;
+ const media = await resolveProjectMediaSources(project);
+ if (!media.success) return null;
+ const thumbnail = await createProjectFirstFrameThumbnail(media.videoPath);
+ if (!sameProjectRevision(revision, await fs.stat(projectPath))) return null;
+ if (!(await hasFreshProjectThumbnail(thumbnailPath, revision.mtimeMs))) {
+ temporary = `${thumbnailPath}.${randomUUID()}.tmp`;
+ await fs.writeFile(temporary, thumbnail, { flag: "wx" });
+ // Stamp the source revision, rather than generation time, so a later edit invalidates it.
+ await fs.utimes(temporary, new Date(), Math.ceil(revision.mtimeMs) / 1000);
+ if (!sameProjectRevision(revision, await fs.stat(projectPath))) return null;
+ // Rename replaces a sidecar symlink itself; it never writes through its target.
+ await fs.rename(temporary, thumbnailPath);
+ }
+ return { path: projectPath, thumbnailPath, updatedAt: revision.mtimeMs };
+ } catch {
+ // Offline, corrupt or deleted media must not hold up the library.
+ return null;
+ } finally {
+ if (temporary) await fs.rm(temporary, { force: true }).catch(() => undefined);
+ }
+ })().finally(() => thumbnailJobs.delete(projectPath));
+ thumbnailJobs.set(projectPath, task);
+ return task;
+}
+
+export async function buildProjectLibraryEntry(
+ projectPath: string,
+ projectsDir: string,
+ onThumbnailReady?: (ready: ProjectThumbnailReady) => void,
+): 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);
+ if (!thumbnailExists) {
+ void refreshProjectThumbnail(normalizedPath, stats)
+ .then((ready) => {
+ if (ready) onThumbnailReady?.(ready);
+ })
+ .catch(() => undefined);
+ }
+
+ 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(
+ onThumbnailReady?: (ready: ProjectThumbnailReady) => void,
+) {
+ 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, onThumbnailReady),
+ ),
+ )
+ )
+ .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/hudCrashRecovery.test.ts b/electron/ipc/recording/hudCrashRecovery.test.ts
new file mode 100644
index 000000000..06fd7796c
--- /dev/null
+++ b/electron/ipc/recording/hudCrashRecovery.test.ts
@@ -0,0 +1,420 @@
+import { EventEmitter } from "node:events";
+import { describe, expect, it, vi } from "vitest";
+import {
+ createHudCrashRecoveryDeps,
+ createStartTracker,
+ nativeCaptureSidecarPaths,
+ type HudCrashRecoveryDeps,
+ type NativeCaptureState,
+ stopRecordingWhenHudGoes,
+} from "./hudCrashRecovery";
+
+function setup(overrides: Partial = {}) {
+ const hud = new EventEmitter();
+ const deps = {
+ startSettled: vi.fn(async () => {}),
+ isRecordingWithoutStopRequest: vi.fn(() => true),
+ isUserRecording: vi.fn(() => true),
+ stopRecording: vi.fn(async () => ({ success: true, path: "/rec/recording-1.mp4" })),
+ finishRecording: vi.fn(async (stopped) => stopped),
+ discardRecording: vi.fn(async () => {}),
+ takePendingStoppedVideo: vi.fn(
+ async () => null as { path: string; userRecording: boolean } | null,
+ ),
+ takeStoppedCountdownCapture: vi.fn(async () => null as string | null),
+ muxPendingStoppedVideo: vi.fn(async () => ({
+ success: true,
+ path: "/rec/recording-1.mp4",
+ })),
+ setRecordingState: vi.fn(),
+ reportRecovered: vi.fn(async () => {}),
+ ...overrides,
+ } satisfies HudCrashRecoveryDeps;
+ return { hud, deps };
+}
+
+const crash = (hud: EventEmitter, reason = "crashed") =>
+ hud.emit("render-process-gone", {}, { reason });
+
+describe("stopRecordingWhenHudGoes", () => {
+ it("stops, finishes and reports the recording, and leaves the recording state", async () => {
+ const { hud, deps } = setup({
+ finishRecording: vi.fn(async () => ({
+ success: true,
+ path: "/rec/recording-1.muxed.mp4",
+ })),
+ });
+ stopRecordingWhenHudGoes(hud, deps);
+
+ crash(hud);
+
+ await vi.waitFor(() => expect(deps.reportRecovered).toHaveBeenCalled());
+ expect(deps.stopRecording).toHaveBeenCalledTimes(1);
+ expect(deps.finishRecording).toHaveBeenCalledWith({
+ success: true,
+ path: "/rec/recording-1.mp4",
+ });
+ expect(deps.setRecordingState).toHaveBeenCalledWith(false);
+ expect(deps.reportRecovered).toHaveBeenCalledWith(
+ { success: true, path: "/rec/recording-1.muxed.mp4" },
+ "crashed",
+ );
+ expect(deps.discardRecording).not.toHaveBeenCalled();
+ });
+
+ it("does nothing when no recording is running or Stop was already requested", async () => {
+ const { hud, deps } = setup({ isRecordingWithoutStopRequest: vi.fn(() => false) });
+ stopRecordingWhenHudGoes(hud, deps);
+
+ crash(hud);
+
+ await vi.waitFor(() => expect(deps.isRecordingWithoutStopRequest).toHaveBeenCalled());
+ expect(deps.stopRecording).not.toHaveBeenCalled();
+ expect(deps.setRecordingState).not.toHaveBeenCalled();
+ });
+
+ it("ignores a clean renderer exit", async () => {
+ const { hud, deps } = setup();
+ stopRecordingWhenHudGoes(hud, deps);
+
+ crash(hud, "clean-exit");
+
+ await Promise.resolve();
+ expect(deps.startSettled).not.toHaveBeenCalled();
+ expect(deps.stopRecording).not.toHaveBeenCalled();
+ });
+
+ it("stops and deletes a capture that was only warm-started for the countdown", async () => {
+ const { hud, deps } = setup({ isUserRecording: vi.fn(() => false) });
+ stopRecordingWhenHudGoes(hud, deps);
+
+ crash(hud);
+
+ await vi.waitFor(() => expect(deps.discardRecording).toHaveBeenCalled());
+ expect(deps.discardRecording).toHaveBeenCalledWith("/rec/recording-1.mp4");
+ expect(deps.finishRecording).not.toHaveBeenCalled();
+ expect(deps.setRecordingState).not.toHaveBeenCalled();
+ expect(deps.reportRecovered).not.toHaveBeenCalled();
+ });
+
+ it("waits for a running start before it decides", async () => {
+ let settle = () => {};
+ let recording = false;
+ const { hud, deps } = setup({
+ startSettled: vi.fn(
+ () =>
+ new Promise((resolve) => {
+ settle = resolve;
+ }),
+ ),
+ isRecordingWithoutStopRequest: vi.fn(() => recording),
+ isUserRecording: vi.fn(() => false),
+ });
+ stopRecordingWhenHudGoes(hud, deps);
+
+ crash(hud);
+ await Promise.resolve();
+ expect(deps.stopRecording).not.toHaveBeenCalled();
+ // The helper comes up after the HUD died.
+ recording = true;
+ settle();
+
+ await vi.waitFor(() => expect(deps.discardRecording).toHaveBeenCalled());
+ expect(deps.stopRecording).toHaveBeenCalledTimes(1);
+ });
+
+ it("still leaves the recording state and reports when stopping fails", async () => {
+ const { hud, deps } = setup({
+ stopRecording: vi.fn(async () => {
+ throw new Error("helper gone");
+ }),
+ });
+ stopRecordingWhenHudGoes(hud, deps);
+
+ crash(hud, "oom");
+
+ await vi.waitFor(() => expect(deps.reportRecovered).toHaveBeenCalled());
+ expect(deps.setRecordingState).toHaveBeenCalledWith(false);
+ expect(deps.reportRecovered).toHaveBeenCalledWith(
+ { success: false, error: "Error: helper gone" },
+ "oom",
+ );
+ });
+
+ it("does not leave an unhandled rejection when the dialog fails", async () => {
+ const unhandled = vi.fn();
+ process.on("unhandledRejection", unhandled);
+ const error = vi.spyOn(console, "error").mockImplementation(() => {});
+ try {
+ const { hud, deps } = setup({
+ reportRecovered: vi.fn(async () => {
+ throw new Error("no display");
+ }),
+ });
+ stopRecordingWhenHudGoes(hud, deps);
+
+ crash(hud);
+
+ await vi.waitFor(() =>
+ expect(error).toHaveBeenCalledWith(
+ "[hud-crash-recovery] Recovery failed:",
+ expect.any(Error),
+ ),
+ );
+ await new Promise((resolve) => setTimeout(resolve, 10));
+ expect(unhandled).not.toHaveBeenCalled();
+ } finally {
+ process.off("unhandledRejection", unhandled);
+ error.mockRestore();
+ }
+ });
+
+ it("adds one listener per HUD however many recordings it starts", () => {
+ const { hud, deps } = setup();
+ for (let i = 0; i < 20; i++) stopRecordingWhenHudGoes(hud, deps);
+
+ expect(hud.listenerCount("render-process-gone")).toBe(1);
+ });
+});
+
+describe("createHudCrashRecoveryDeps", () => {
+ function platform(
+ state: Partial & {
+ pendingPath?: string;
+ pendingFileExists?: boolean;
+ countdownPath?: string;
+ countdownFileExists?: boolean;
+ },
+ ) {
+ const capture: NativeCaptureState = {
+ nativeActive: false,
+ nativeStopRequested: false,
+ windowsActive: false,
+ windowsStopRequested: false,
+ userRecording: true,
+ lastStopEndedUserRecording: true,
+ ...state,
+ };
+ const io = {
+ captureState: () => capture,
+ startSettled: vi.fn(async () => {}),
+ stopNativeScreenRecording: vi.fn(async () => {
+ // The real stop clears the active flags before it returns.
+ capture.nativeActive = false;
+ capture.windowsActive = false;
+ return { success: true, path: "C:/rec/recording-1.mp4" };
+ }),
+ muxNativeWindowsRecording: vi.fn(async () => ({
+ success: true,
+ path: "C:/rec/recording-1.mp4",
+ message: "muxed",
+ })),
+ clearWindowsPendingVideo: vi.fn(),
+ windowsPendingVideoPath: vi.fn(() => state.pendingPath ?? null),
+ stoppedCountdownPath: vi.fn(() => state.countdownPath ?? null),
+ clearStoppedCountdownPath: vi.fn(),
+ fileExists: vi.fn(async (filePath: string) =>
+ filePath === state.countdownPath
+ ? (state.countdownFileExists ?? true)
+ : (state.pendingFileExists ?? true),
+ ),
+ deleteFile: vi.fn(async () => {}),
+ telemetryPathFor: (videoPath: string) => `${videoPath}.cursor.json`,
+ diagnosticsPathFor: (videoPath: string) =>
+ videoPath.replace(/\.mp4$/, ".recording-diagnostics.json"),
+ setRecordingState: vi.fn(),
+ reportRecovered: vi.fn(async () => {}),
+ };
+ const hud = new EventEmitter();
+ stopRecordingWhenHudGoes(hud, createHudCrashRecoveryDeps(io));
+ return { io, crash: () => crash(hud) };
+ }
+
+ it("muxes the audio on Windows, as the renderer would after a stop", async () => {
+ const { io, crash } = platform({ nativeActive: true, windowsActive: true });
+
+ crash();
+
+ await vi.waitFor(() => expect(io.reportRecovered).toHaveBeenCalled());
+ expect(io.muxNativeWindowsRecording).toHaveBeenCalledTimes(1);
+ expect(io.reportRecovered).toHaveBeenCalledWith(
+ { success: true, path: "C:/rec/recording-1.mp4", message: "muxed" },
+ "crashed",
+ );
+ expect(io.setRecordingState).toHaveBeenCalledWith(false);
+ });
+
+ it("does not mux on macOS", async () => {
+ const { io, crash } = platform({ nativeActive: true });
+
+ crash();
+
+ await vi.waitFor(() => expect(io.reportRecovered).toHaveBeenCalled());
+ expect(io.muxNativeWindowsRecording).not.toHaveBeenCalled();
+ });
+
+ it("deletes a Windows countdown capture and its telemetry, without a mux", async () => {
+ const { io, crash } = platform({
+ nativeActive: true,
+ windowsActive: true,
+ userRecording: false,
+ });
+
+ crash();
+
+ await vi.waitFor(() => expect(io.deleteFile).toHaveBeenCalledTimes(10));
+ expect(io.clearWindowsPendingVideo).toHaveBeenCalled();
+ expect(io.deleteFile).toHaveBeenNthCalledWith(1, "C:/rec/recording-1.mp4");
+ expect(io.deleteFile).toHaveBeenNthCalledWith(2, "C:/rec/recording-1.mp4.cursor.json");
+ expect(io.deleteFile).toHaveBeenNthCalledWith(
+ 3,
+ "C:/rec/recording-1.recording-diagnostics.json",
+ );
+ expect(io.muxNativeWindowsRecording).not.toHaveBeenCalled();
+ expect(io.reportRecovered).not.toHaveBeenCalled();
+ });
+
+ it("leaves a Windows capture alone once its stop was requested, though it sets nativeActive too", async () => {
+ const { io, crash } = platform({
+ nativeActive: true,
+ windowsActive: true,
+ windowsStopRequested: true,
+ });
+
+ crash();
+
+ await vi.waitFor(() => expect(io.startSettled).toHaveBeenCalled());
+ await Promise.resolve();
+ expect(io.stopNativeScreenRecording).not.toHaveBeenCalled();
+ });
+
+ it("muxes a stopped Windows recording whose renderer died before its mux", async () => {
+ // After its Stop the renderer has already left the recording state.
+ const { io, crash } = platform({
+ userRecording: false,
+ lastStopEndedUserRecording: true,
+ pendingPath: "C:/rec/recording-1.mp4",
+ });
+
+ crash();
+
+ await vi.waitFor(() => expect(io.reportRecovered).toHaveBeenCalled());
+ expect(io.stopNativeScreenRecording).not.toHaveBeenCalled();
+ expect(io.muxNativeWindowsRecording).toHaveBeenCalledTimes(1);
+ expect(io.deleteFile).not.toHaveBeenCalled();
+ expect(io.setRecordingState).toHaveBeenCalledWith(false);
+ });
+
+ it("deletes a stopped Windows countdown capture whose renderer died before deleting it", async () => {
+ const { io, crash } = platform({
+ userRecording: false,
+ lastStopEndedUserRecording: false,
+ pendingPath: "C:/rec/recording-1.mp4",
+ });
+
+ crash();
+
+ await vi.waitFor(() => expect(io.deleteFile).toHaveBeenCalledTimes(10));
+ expect(io.deleteFile).toHaveBeenNthCalledWith(1, "C:/rec/recording-1.mp4");
+ expect(io.clearWindowsPendingVideo).toHaveBeenCalled();
+ expect(io.muxNativeWindowsRecording).not.toHaveBeenCalled();
+ expect(io.reportRecovered).not.toHaveBeenCalled();
+ });
+
+ it("deletes a countdown capture the renderer stopped but hadn't deleted (macOS)", async () => {
+ const { io, crash } = platform({
+ userRecording: false,
+ countdownPath: "/rec/recording-1.mp4",
+ });
+
+ crash();
+
+ await vi.waitFor(() => expect(io.deleteFile).toHaveBeenCalledTimes(10));
+ expect(io.deleteFile).toHaveBeenNthCalledWith(1, "/rec/recording-1.mp4");
+ expect(io.deleteFile).toHaveBeenCalledWith("/rec/recording-1.system.m4a");
+ expect(io.clearStoppedCountdownPath).toHaveBeenCalled();
+ expect(io.stopNativeScreenRecording).not.toHaveBeenCalled();
+ expect(io.reportRecovered).not.toHaveBeenCalled();
+ });
+
+ it("only forgets a stopped countdown the renderer already deleted", async () => {
+ const { io, crash } = platform({
+ userRecording: false,
+ countdownPath: "/rec/recording-1.mp4",
+ countdownFileExists: false,
+ });
+
+ crash();
+
+ await vi.waitFor(() => expect(io.clearStoppedCountdownPath).toHaveBeenCalled());
+ await new Promise((resolve) => setTimeout(resolve, 10));
+ expect(io.deleteFile).not.toHaveBeenCalled();
+ });
+
+ it("only forgets a pending Windows video the renderer already deleted", async () => {
+ const { io, crash } = platform({
+ userRecording: false,
+ pendingPath: "C:/rec/recording-1.mp4",
+ pendingFileExists: false,
+ });
+
+ crash();
+
+ await vi.waitFor(() => expect(io.clearWindowsPendingVideo).toHaveBeenCalled());
+ await Promise.resolve();
+ expect(io.muxNativeWindowsRecording).not.toHaveBeenCalled();
+ expect(io.reportRecovered).not.toHaveBeenCalled();
+ });
+});
+
+describe("createStartTracker", () => {
+ it("waits for every overlapping start, not only the latest", async () => {
+ const starts = createStartTracker();
+ let finishSlow = () => {};
+ const slow = starts.track(
+ () =>
+ new Promise((resolve) => {
+ finishSlow = resolve;
+ }),
+ );
+ const fast = starts.track(async () => {});
+ void slow();
+ await fast();
+
+ let settled = false;
+ void starts.settled().then(() => {
+ settled = true;
+ });
+ await new Promise((resolve) => setTimeout(resolve, 10));
+ expect(settled).toBe(false);
+
+ finishSlow();
+ await vi.waitFor(() => expect(settled).toBe(true));
+ });
+
+ it("settles after a start that fails", async () => {
+ const starts = createStartTracker();
+ await expect(
+ starts.track(async () => Promise.reject(new Error("no helper")))(),
+ ).rejects.toThrow();
+ await expect(starts.settled()).resolves.toBeUndefined();
+ });
+});
+
+describe("nativeCaptureSidecarPaths", () => {
+ it("lists the audio and native webcam files of one capture, and nothing else", () => {
+ expect(nativeCaptureSidecarPaths("/rec/recording-1.mp4")).toEqual([
+ "/rec/recording-1.system.m4a",
+ "/rec/recording-1.mic.m4a",
+ "/rec/recording-1.system.wav",
+ "/rec/recording-1.mic.wav",
+ "/rec/recording-1.system.wav.json",
+ "/rec/recording-1.mic.wav.json",
+ "/rec/recording-1-webcam.mp4",
+ ]);
+ });
+
+ it("returns nothing for a path that isn't an MP4", () => {
+ expect(nativeCaptureSidecarPaths("/rec/recording-1.webm")).toEqual([]);
+ });
+});
diff --git a/electron/ipc/recording/hudCrashRecovery.ts b/electron/ipc/recording/hudCrashRecovery.ts
new file mode 100644
index 000000000..8d89bf8e6
--- /dev/null
+++ b/electron/ipc/recording/hudCrashRecovery.ts
@@ -0,0 +1,264 @@
+export type StopResult = {
+ success: boolean;
+ path?: string;
+ message?: string;
+ error?: string;
+};
+
+type CrashableWebContents = {
+ once(
+ event: "render-process-gone",
+ listener: (event: unknown, details: { reason: string }) => void,
+ ): unknown;
+};
+
+export type HudCrashRecoveryDeps = {
+ /** Resolves when no start-native-screen-recording call is still running. */
+ startSettled: () => Promise;
+ /** True while a native screen recording runs and nobody asked it to stop. */
+ isRecordingWithoutStopRequest: () => boolean;
+ /** True after set-recording-state(true): the user sees a recording, not a countdown. */
+ isUserRecording: () => boolean;
+ stopRecording: () => Promise;
+ /** Work the renderer normally does after a stop (the Windows mux). */
+ finishRecording: (stopped: StopResult) => Promise;
+ /** Deletes a capture the user never started (warm start, countdown). */
+ discardRecording: (path: string) => Promise;
+ /**
+ * Windows only: a stopped capture whose file still waits for the renderer's mux, and
+ * whether that Stop ended a recording (rather than a countdown). Resolves to null when
+ * there is none or its file is already gone.
+ */
+ takePendingStoppedVideo: () => Promise<{ path: string; userRecording: boolean } | null>;
+ /**
+ * A countdown capture the renderer stopped but may not have deleted yet. Resolves to
+ * null when there is none or its file is already gone.
+ */
+ takeStoppedCountdownCapture: () => Promise;
+ /** Runs the Windows mux for the pending stopped video. */
+ muxPendingStoppedVideo: () => Promise;
+ setRecordingState: (recording: boolean) => void;
+ reportRecovered: (result: StopResult, reason: string) => Promise;
+};
+
+const watched = new WeakSet();
+
+/**
+ * Stops a native recording when the renderer that controls it dies (issue #1139).
+ *
+ * The HUD renderer owns the Stop button. If it crashes mid-recording, the capture helper
+ * keeps writing, the tray Stop item talks to the dead renderer, and quitting kills the helper
+ * before the MP4 gets its moov atom. This finishes the recording the same way Stop does,
+ * so the file stays playable and the app leaves the recording state.
+ *
+ * A capture that was only warm-started for the countdown is stopped and deleted instead,
+ * as the renderer does when a countdown is cancelled.
+ *
+ * Each webContents is watched once, however many recordings it starts.
+ */
+export function stopRecordingWhenHudGoes(hud: CrashableWebContents, deps: HudCrashRecoveryDeps) {
+ if (watched.has(hud)) {
+ return;
+ }
+ watched.add(hud);
+ hud.once("render-process-gone", (_event, details) => {
+ watched.delete(hud);
+ if (details.reason === "clean-exit") {
+ return;
+ }
+ void recover(deps, details.reason).catch((error) => {
+ console.error("[hud-crash-recovery] Recovery failed:", error);
+ });
+ });
+}
+
+async function recover(deps: HudCrashRecoveryDeps, reason: string) {
+ // A start still waiting for the helper would otherwise finish with no HUD to stop it.
+ await deps.startSettled();
+ if (!deps.isRecordingWithoutStopRequest()) {
+ // On Windows the renderer muxes the audio after its Stop went through. If it died in
+ // between, the stopped file is still waiting: mux it when the Stop ended a recording,
+ // delete it when the Stop ended a countdown (the renderer would have deleted it next).
+ const pending = await deps.takePendingStoppedVideo();
+ // A countdown the renderer stopped but hadn't deleted yet (any platform).
+ const stoppedCountdown = pending ? null : await deps.takeStoppedCountdownCapture();
+ if (stoppedCountdown) {
+ await deps.discardRecording(stoppedCountdown);
+ } else if (pending && !pending.userRecording) {
+ await deps.discardRecording(pending.path);
+ } else if (pending) {
+ console.error(
+ `[hud-crash-recovery] HUD renderer gone (${reason}) before its mux; muxing the stopped recording`,
+ );
+ let muxed: StopResult;
+ try {
+ muxed = await deps.muxPendingStoppedVideo();
+ } catch (error) {
+ muxed = { success: false, error: String(error) };
+ }
+ deps.setRecordingState(false);
+ await deps.reportRecovered(muxed, reason);
+ }
+ return;
+ }
+ const userRecording = deps.isUserRecording();
+ console.error(
+ `[hud-crash-recovery] HUD renderer gone (${reason}) during a ${
+ userRecording ? "recording" : "countdown"
+ }; stopping it`,
+ );
+
+ let result: StopResult;
+ try {
+ result = await deps.stopRecording();
+ if (result.success && userRecording) {
+ result = await deps.finishRecording(result);
+ }
+ } catch (error) {
+ result = { success: false, error: String(error) };
+ }
+
+ if (!userRecording) {
+ if (result.success && result.path) {
+ await deps.discardRecording(result.path);
+ } else if (!result.success) {
+ console.error("[hud-crash-recovery] Could not stop the countdown capture:", result);
+ }
+ return;
+ }
+ deps.setRecordingState(false);
+ await deps.reportRecovered(result, reason);
+}
+
+/** What main knows about the native capture, read at the moment recovery needs it. */
+export type NativeCaptureState = {
+ /** nativeScreenRecordingActive: set by the macOS start and also by the Windows start. */
+ nativeActive: boolean;
+ /** Set by the macOS stop only. */
+ nativeStopRequested: boolean;
+ windowsActive: boolean;
+ windowsStopRequested: boolean;
+ userRecording: boolean;
+ /** Whether the last stop-native-screen-recording ended a recording, not a countdown. */
+ lastStopEndedUserRecording: boolean;
+};
+
+/**
+ * Builds the recovery steps from main's capture state and handlers, so the platform rules
+ * (Windows needs the mux the renderer would have run) are testable without Electron.
+ */
+export function createHudCrashRecoveryDeps(io: {
+ captureState: () => NativeCaptureState;
+ startSettled: () => Promise;
+ stopNativeScreenRecording: () => Promise;
+ muxNativeWindowsRecording: () => Promise;
+ clearWindowsPendingVideo: () => void;
+ windowsPendingVideoPath: () => string | null;
+ /** Video path of the last renderer stop that ended a countdown, until the next start. */
+ stoppedCountdownPath: () => string | null;
+ clearStoppedCountdownPath: () => void;
+ fileExists: (filePath: string) => Promise;
+ deleteFile: (filePath: string) => Promise;
+ telemetryPathFor: (videoPath: string) => string;
+ /** The capture diagnostics file written next to the video (Windows). */
+ diagnosticsPathFor: (videoPath: string) => string;
+ setRecordingState: (recording: boolean) => void;
+ reportRecovered: (result: StopResult, reason: string) => Promise;
+}): HudCrashRecoveryDeps {
+ // Read before stopping: the stop clears the active flags.
+ let stoppedWindowsCapture = false;
+ return {
+ startSettled: io.startSettled,
+ isRecordingWithoutStopRequest: () => {
+ const state = io.captureState();
+ // A Windows capture also sets nativeActive, but only its own stop flag, so judge
+ // it by the Windows flags alone.
+ if (state.windowsActive) {
+ return !state.windowsStopRequested;
+ }
+ return state.nativeActive && !state.nativeStopRequested;
+ },
+ isUserRecording: () => io.captureState().userRecording,
+ stopRecording: () => {
+ stoppedWindowsCapture = io.captureState().windowsActive;
+ return io.stopNativeScreenRecording();
+ },
+ // On Windows the renderer muxes the audio after a stop; do it here instead.
+ finishRecording: async (stopped) =>
+ stoppedWindowsCapture ? await io.muxNativeWindowsRecording() : stopped,
+ discardRecording: async (videoPath) => {
+ io.clearWindowsPendingVideo();
+ for (const filePath of [
+ videoPath,
+ io.telemetryPathFor(videoPath),
+ io.diagnosticsPathFor(videoPath),
+ ...nativeCaptureSidecarPaths(videoPath),
+ ]) {
+ await io.deleteFile(filePath);
+ }
+ },
+ takePendingStoppedVideo: async () => {
+ const pending = io.windowsPendingVideoPath();
+ if (!pending) {
+ return null;
+ }
+ if (await io.fileExists(pending)) {
+ return {
+ path: pending,
+ userRecording: io.captureState().lastStopEndedUserRecording,
+ };
+ }
+ // The renderer already deleted it (a cancelled countdown): forget the stale path.
+ io.clearWindowsPendingVideo();
+ return null;
+ },
+ takeStoppedCountdownCapture: async () => {
+ const stopped = io.stoppedCountdownPath();
+ io.clearStoppedCountdownPath();
+ return stopped && (await io.fileExists(stopped)) ? stopped : null;
+ },
+ muxPendingStoppedVideo: () => io.muxNativeWindowsRecording(),
+ setRecordingState: io.setRecordingState,
+ reportRecovered: io.reportRecovered,
+ };
+}
+
+/**
+ * Tracks start calls that are still running. `settled` waits for every one of them,
+ * including starts that overlap, not only the latest.
+ */
+export function createStartTracker() {
+ let inFlight: Promise = Promise.resolve();
+ return {
+ track:
+ (start: (...args: A) => Promise) =>
+ (...args: A): Promise => {
+ const started = start(...args);
+ inFlight = Promise.allSettled([inFlight, started]);
+ return started;
+ },
+ settled: () => inFlight.then(() => undefined),
+ };
+}
+
+/**
+ * The files a native capture writes next to `recording-.mp4`: audio (`.system.m4a` and
+ * `.mic.m4a` on macOS, `.system.wav` and `.mic.wav` with `.json` metadata on Windows) and a
+ * natively recorded webcam (`-webcam.mp4`, macOS). Only names derived from the video's own
+ * base name, so nothing else can match; files that don't exist are skipped by the caller.
+ */
+export function nativeCaptureSidecarPaths(videoPath: string): string[] {
+ const base = videoPath.replace(/\.mp4$/i, "");
+ if (base === videoPath) {
+ return [];
+ }
+ return [
+ ".system.m4a",
+ ".mic.m4a",
+ ".system.wav",
+ ".mic.wav",
+ ".system.wav.json",
+ ".mic.wav.json",
+ "-webcam.mp4",
+ ].map((suffix) => `${base}${suffix}`);
+}
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/shareThumbnail.test.ts b/electron/ipc/recording/shareThumbnail.test.ts
new file mode 100644
index 000000000..825809cec
--- /dev/null
+++ b/electron/ipc/recording/shareThumbnail.test.ts
@@ -0,0 +1,68 @@
+import { execFile } from "node:child_process";
+import { mkdtemp, rm } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import path from "node:path";
+import { promisify } from "node:util";
+import { describe, expect, it, vi } from "vitest";
+import ffmpeg from "ffmpeg-static";
+
+vi.mock("../ffmpeg/binary", () => ({ getFfmpegBinaryPath: () => ffmpeg }));
+import { createShareThumbnail } from "./shareThumbnail";
+const run = promisify(execFile);
+
+describe("shared recording preview", () => {
+ it("extracts the first frame, preserving aspect ratio and bounding its size", async () => {
+ const dir = await mkdtemp(path.join(tmpdir(), "recordly-poster-test-"));
+ try {
+ const video = path.join(dir, "red-then-blue.mp4");
+ await run(ffmpeg!, [
+ "-hide_banner",
+ "-loglevel",
+ "error",
+ "-f",
+ "lavfi",
+ "-i",
+ "color=red:s=1280x720:d=0.1:r=30",
+ "-f",
+ "lavfi",
+ "-i",
+ "color=blue:s=1280x720:d=0.1:r=30",
+ "-filter_complex",
+ "[0:v][1:v]concat=n=2:v=1:a=0",
+ "-c:v",
+ "libx264",
+ "-pix_fmt",
+ "yuv420p",
+ video,
+ ]);
+ const poster = await createShareThumbnail(video, new AbortController().signal);
+ expect(poster.subarray(0, 2)).toEqual(Buffer.from([0xff, 0xd8]));
+ const decoded = await new Promise((resolve, reject) => {
+ const child = execFile(
+ ffmpeg!,
+ [
+ "-hide_banner",
+ "-loglevel",
+ "error",
+ "-i",
+ "pipe:0",
+ "-f",
+ "rawvideo",
+ "-pix_fmt",
+ "rgb24",
+ "pipe:1",
+ ],
+ { encoding: "buffer", maxBuffer: 1024 * 1024 },
+ (error, stdout) => (error ? reject(error) : resolve(stdout)),
+ );
+ child.stdin!.end(poster);
+ });
+ expect(decoded.length).toBe(640 * 360 * 3);
+ expect(decoded[0]).toBeGreaterThan(220);
+ expect(decoded[1]).toBeLessThan(30);
+ expect(decoded[2]).toBeLessThan(30);
+ } finally {
+ await rm(dir, { recursive: true, force: true });
+ }
+ }, 20000);
+});
diff --git a/electron/ipc/recording/shareThumbnail.ts b/electron/ipc/recording/shareThumbnail.ts
new file mode 100644
index 000000000..48183fff8
--- /dev/null
+++ b/electron/ipc/recording/shareThumbnail.ts
@@ -0,0 +1,37 @@
+import { execFile } from "node:child_process";
+import { promisify } from "node:util";
+import { getFfmpegBinaryPath } from "../ffmpeg/binary";
+
+const run = promisify(execFile);
+
+/** The caller must validate the local export path before decoding it. */
+export async function createShareThumbnail(filePath: string, signal: AbortSignal): Promise {
+ const { stdout } = await run(
+ getFfmpegBinaryPath(),
+ [
+ "-hide_banner",
+ "-loglevel",
+ "error",
+ "-nostdin",
+ "-threads",
+ "1",
+ "-i",
+ filePath,
+ "-frames:v",
+ "1",
+ "-an",
+ "-vf",
+ "scale=640:640:force_original_aspect_ratio=decrease",
+ "-q:v",
+ "4",
+ "-f",
+ "image2pipe",
+ "-c:v",
+ "mjpeg",
+ "pipe:1",
+ ],
+ { encoding: "buffer", maxBuffer: 1024 * 1024, timeout: 15000, windowsHide: true, signal },
+ );
+ if (!stdout.length) throw new Error("Video has no preview frame");
+ return stdout;
+}
diff --git a/electron/ipc/recording/storagePath.test.ts b/electron/ipc/recording/storagePath.test.ts
new file mode 100644
index 000000000..dced4f52e
--- /dev/null
+++ b/electron/ipc/recording/storagePath.test.ts
@@ -0,0 +1,53 @@
+import path from "node:path";
+import { describe, expect, it } from "vitest";
+import { 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",
+ );
+ });
+});
diff --git a/electron/ipc/recording/storagePath.ts b/electron/ipc/recording/storagePath.ts
new file mode 100644
index 000000000..12c3484f6
--- /dev/null
+++ b/electron/ipc/recording/storagePath.ts
@@ -0,0 +1,24 @@
+import path from "node:path";
+
+const RECORDED_VIDEO_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;
+}
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/capturePicker.test.ts b/electron/ipc/register/capturePicker.test.ts
new file mode 100644
index 000000000..6a12558a0
--- /dev/null
+++ b/electron/ipc/register/capturePicker.test.ts
@@ -0,0 +1,221 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+const mocks = vi.hoisted(() => ({
+ handlers: new Map unknown>(),
+ list: vi.fn(),
+ pickSourceList: vi.fn(),
+ validate: vi.fn(),
+
+ selected: vi.fn(),
+ recording: false,
+ currentSource: null as Record | null,
+ create: vi.fn(),
+ close: vi.fn(),
+ overlay: {},
+ outsider: {},
+ windows: [] as object[],
+}));
+vi.mock("electron", () => ({
+ ipcMain: {
+ handle: (name: string, handler: (...args: unknown[]) => unknown) =>
+ mocks.handlers.set(name, handler),
+ },
+}));
+vi.mock("../../windows", () => ({
+ createCapturePickerWindows: mocks.create,
+ closeCapturePickerWindows: mocks.close,
+ isCapturePickerWebContents: (sender: object) => mocks.windows.includes(sender),
+}));
+vi.mock("../captureSelection", () => ({
+ validateCaptureSource: mocks.validate,
+}));
+vi.mock("../capturePickerSources", () => ({ listCapturePickerWindows: mocks.list }));
+vi.mock("../cursor/bounds", () => ({ stopWindowBoundsCapture: vi.fn() }));
+vi.mock("../state", () => ({
+ setSelectedSource: mocks.selected,
+ get isCursorCaptureActive() {
+ return mocks.recording;
+ },
+ get selectedSource() {
+ return mocks.currentSource;
+ },
+}));
+vi.mock("../utils", () => ({
+ getScreen: () => ({
+ getAllDisplays: () => [{ id: 1, bounds: { x: 0, y: 0, width: 1440, height: 1000 } }],
+ getPrimaryDisplay: () => ({ id: 1 }),
+ getCursorScreenPoint: () => ({ x: 0, y: 0 }),
+ }),
+}));
+vi.mock("./sources", () => ({
+ bringSelectedWindowForward: vi.fn(),
+ broadcastSelectedSourceChange: vi.fn(),
+ getDesktopSources: vi.fn().mockResolvedValue([{ id: "screen:1:0", display_id: "1" }]),
+}));
+import { registerCapturePickerHandlers } from "./capturePicker";
+const platform = process.platform;
+afterEach(() => {
+ Object.defineProperty(process, "platform", { value: platform });
+ vi.unstubAllEnvs();
+});
+beforeEach(() => {
+ vi.clearAllMocks();
+ mocks.handlers.clear();
+ mocks.currentSource = null;
+ mocks.recording = false;
+ mocks.windows = [mocks.overlay];
+ Object.defineProperty(process, "platform", { value: "darwin" });
+ mocks.list.mockResolvedValue([]);
+ mocks.validate.mockImplementation(async (source) => source);
+ registerCapturePickerHandlers({ pickSourceList: mocks.pickSourceList });
+});
+const call = (name: string, sender: object, input?: unknown) =>
+ mocks.handlers.get(name)!({ sender }, input);
+describe("selection-only picker IPC", () => {
+ it("ignores completion from other windows and forces selection-only completion", async () => {
+ const pending = call("pick-capture-target", mocks.outsider);
+ await vi.waitFor(() => expect(mocks.create).toHaveBeenCalledOnce());
+ await call("complete-capture-pick", mocks.outsider, {
+ kind: "screen",
+ displayId: 1,
+ record: true,
+ });
+ expect(mocks.selected).not.toHaveBeenCalled();
+ await call("complete-capture-pick", mocks.overlay, {
+ kind: "screen",
+ displayId: 1,
+ record: true,
+ });
+ expect(await pending).toMatchObject({ success: true, source: { id: "screen:1:0" } });
+ expect(await pending).not.toHaveProperty("record");
+ });
+ it("coalesces simultaneous opens into one picker", async () => {
+ const first = call("pick-capture-target", mocks.outsider);
+ const second = call("pick-capture-target", mocks.outsider);
+ await vi.waitFor(() => expect(mocks.create).toHaveBeenCalledOnce());
+ await call("complete-capture-pick", mocks.overlay, null);
+ expect(await first).toEqual({ success: false, canceled: true });
+ expect(await second).toEqual({ success: false, canceled: true });
+ });
+ it("keeps Wayland on the existing portal path without overlays or geometry probes", async () => {
+ Object.defineProperty(process, "platform", { value: "linux" });
+ vi.stubEnv("XDG_SESSION_TYPE", "wayland");
+ expect(await call("pick-capture-target", mocks.outsider)).toMatchObject({
+ success: true,
+ source: { id: "screen:linux-portal" },
+ });
+ expect(mocks.create).not.toHaveBeenCalled();
+ expect(mocks.list).not.toHaveBeenCalled();
+ });
+});
+
+it("rejects a window that disappeared while the picker was open", async () => {
+ mocks.list.mockResolvedValue([
+ {
+ id: "window:101:0",
+ title: "Document",
+ appName: "Notes",
+ x: 0,
+ y: 0,
+ width: 400,
+ height: 300,
+ },
+ ]);
+ mocks.validate.mockResolvedValue(null);
+ const pending = call("pick-capture-target", mocks.outsider);
+ await vi.waitFor(() => expect(mocks.create).toHaveBeenCalledOnce());
+ await call("complete-capture-pick", mocks.overlay, {
+ kind: "window",
+ windowId: "window:101:0",
+ displayId: 1,
+ });
+ expect(await pending).toMatchObject({
+ success: false,
+ message: "That window or screen is no longer available.",
+ });
+ expect(mocks.selected).not.toHaveBeenCalled();
+});
+it("surfaces detection failures instead of opening a screen-only overlay", async () => {
+ mocks.list.mockRejectedValue(new Error("Unable to detect windows"));
+ expect(await call("pick-capture-target", mocks.outsider)).toMatchObject({
+ success: false,
+ message: "Unable to detect windows",
+ });
+ expect(mocks.create).not.toHaveBeenCalled();
+ expect(mocks.selected).not.toHaveBeenCalled();
+});
+
+it("opens a fresh picker even when an area is already selected", async () => {
+ mocks.currentSource = {
+ id: "area:1",
+ display_id: "1",
+ area: { x: 100, y: 200, width: 400, height: 300 },
+ };
+ expect(await call("get-capture-picker-context", mocks.overlay, 1)).toMatchObject({
+ lastArea: null,
+ });
+ mocks.currentSource = { id: "window:101:0", display_id: "1" };
+ expect(await call("get-capture-picker-context", mocks.overlay, 1)).toMatchObject({
+ lastArea: null,
+ });
+});
+
+it("does not open any source picker during recording or pause", async () => {
+ mocks.recording = true;
+ expect(await call("pick-capture-target", mocks.outsider)).toEqual({
+ success: false,
+ canceled: true,
+ });
+ expect(mocks.list).not.toHaveBeenCalled();
+ expect(mocks.create).not.toHaveBeenCalled();
+ expect(mocks.selected).not.toHaveBeenCalled();
+});
+
+it("rejects a pending selection if recording starts while the picker is open", async () => {
+ const pending = call("pick-capture-target", mocks.outsider);
+ await vi.waitFor(() => expect(mocks.create).toHaveBeenCalledOnce());
+ mocks.recording = true;
+ await call("complete-capture-pick", mocks.overlay, { kind: "screen", displayId: 1 });
+ expect(await pending).toEqual({ success: false, canceled: true });
+ expect(mocks.selected).not.toHaveBeenCalled();
+});
+
+it("does not open overlays if recording starts while listing windows", async () => {
+ mocks.list.mockImplementation(async () => {
+ mocks.recording = true;
+ return [];
+ });
+ expect(await call("pick-capture-target", mocks.outsider)).toEqual({
+ success: false,
+ canceled: true,
+ });
+ expect(mocks.create).not.toHaveBeenCalled();
+});
+
+it("keeps X11 on the source list and coalesces concurrent opens", async () => {
+ Object.defineProperty(process, "platform", { value: "linux" });
+ vi.stubEnv("XDG_SESSION_TYPE", "x11");
+ let finish!: (result: unknown) => void;
+ mocks.pickSourceList.mockReturnValue(
+ new Promise((resolve) => {
+ finish = resolve;
+ }),
+ );
+ const first = call("pick-capture-target", mocks.outsider);
+ const second = call("pick-capture-target", mocks.outsider);
+ expect(mocks.pickSourceList).toHaveBeenCalledOnce();
+ expect(mocks.list).not.toHaveBeenCalled();
+ expect(mocks.create).not.toHaveBeenCalled();
+ finish({ success: false, canceled: true });
+ expect(await first).toEqual({ success: false, canceled: true });
+ expect(await second).toEqual({ success: false, canceled: true });
+});
+it("keeps the X11 source list closed during recording", async () => {
+ Object.defineProperty(process, "platform", { value: "linux" });
+ vi.stubEnv("XDG_SESSION_TYPE", "x11");
+ mocks.recording = true;
+ expect(await call("pick-capture-target", mocks.outsider)).toEqual({
+ success: false,
+ canceled: true,
+ });
+ expect(mocks.pickSourceList).not.toHaveBeenCalled();
+});
diff --git a/electron/ipc/register/capturePicker.ts b/electron/ipc/register/capturePicker.ts
new file mode 100644
index 000000000..5f471b03c
--- /dev/null
+++ b/electron/ipc/register/capturePicker.ts
@@ -0,0 +1,199 @@
+import { validateCaptureSource } from "../captureSelection";
+import { listCapturePickerWindows } from "../capturePickerSources";
+import { ipcMain } from "electron";
+import type { SourceListPickResult } from "../../sourceListPicker";
+import {
+ closeCapturePickerWindows,
+ createCapturePickerWindows,
+ isCapturePickerWebContents,
+} from "../../windows";
+import { stopWindowBoundsCapture } from "../cursor/bounds";
+import { isCursorCaptureActive, setSelectedSource } from "../state";
+import { createAreaSource, createWindowSource, normalizeCapturePick } from "../sourceArea";
+import type { CapturePick, CapturePickerWindow, SelectedSource } from "../types";
+import { getScreen } from "../utils";
+import { getScreenSourceIdForDisplay, isLikelyLinuxWaylandSession } from "./sourceMapping";
+import { broadcastSelectedSourceChange, getDesktopSources } from "./sources";
+
+/** The same source the screen list offers for this display. */
+async function createScreenSource(displayId: number): Promise {
+ const displays = getScreen().getAllDisplays();
+ const index = displays.findIndex((display) => display.id === displayId);
+ if (index < 0) return null;
+ const sources = await getDesktopSources(
+ {
+ types: ["screen"],
+ thumbnailSize: { width: 0, height: 0 },
+ },
+ { strict: true },
+ );
+ const matched =
+ sources.find((source) => source.display_id === String(displayId)) ??
+ (sources.length === displays.length ? sources[index] : undefined);
+ const isPrimary = getScreen().getPrimaryDisplay().id === displayId;
+ return {
+ id: getScreenSourceIdForDisplay({
+ displayId: String(displayId),
+ platform: process.platform,
+ matchedSourceId: matched?.id,
+ }),
+ name: isPrimary ? `Screen ${index + 1} (Primary)` : `Screen ${index + 1}`,
+ display_id: String(displayId),
+ sourceType: "screen",
+ };
+}
+
+/**
+ * The capture picker covers every display: hovering highlights the window under
+ * the cursor, a click picks that window or the whole screen, and a drag draws an
+ * area. The pick becomes the selected source.
+ */
+export function registerCapturePickerHandlers({
+ pickSourceList,
+}: {
+ pickSourceList: () => Promise;
+}) {
+ let resolvePendingPick: ((pick: CapturePick | null) => void) | null = null;
+ let pickableWindows: Promise = Promise.resolve([]);
+
+ const finishPick = (pick: CapturePick | null) => {
+ const resolve = resolvePendingPick;
+ resolvePendingPick = null;
+ resolve?.(pick);
+ };
+
+ let activePick: Promise | null = null;
+ const pickTarget = async () => {
+ if (isCursorCaptureActive) return { success: false, canceled: true };
+ if (process.platform === "linux" && isLikelyLinuxWaylandSession(process.env)) {
+ // Preserve the existing portal recording path: its system picker opens
+ // when recording starts; overlay selection cannot identify Wayland windows.
+ const source = {
+ id: "screen:linux-portal",
+ name: "System picker",
+ sourceType: "screen" as const,
+ };
+ setSelectedSource(source);
+ broadcastSelectedSourceChange();
+ return {
+ success: true,
+ source,
+ message: "Choose a screen or window in the system picker when you start recording.",
+ };
+ }
+
+ if (process.platform === "linux") return pickSourceList();
+
+ finishPick(null);
+ // List windows before the overlays cover them.
+ pickableWindows = Promise.resolve(await listCapturePickerWindows());
+ if (isCursorCaptureActive) return { success: false, canceled: true };
+ const pick = await new Promise((resolve) => {
+ resolvePendingPick = resolve;
+ // Closing an overlay without confirming cancels the pick.
+ createCapturePickerWindows(() => finishPick(null));
+ });
+ closeCapturePickerWindows();
+ if (!pick || isCursorCaptureActive) {
+ return { success: false, canceled: true };
+ }
+
+ let source: SelectedSource | null;
+ if (pick.kind === "area") {
+ source = createAreaSource(pick);
+ } else if (pick.kind === "window") {
+ const window = (await pickableWindows).find((entry) => entry.id === pick.windowId);
+ source = window ? createWindowSource(window) : null;
+ } else {
+ source = await createScreenSource(pick.displayId);
+ }
+ if (source) source = await validateCaptureSource(source);
+ if (!source) {
+ return { success: false, message: "That window or screen is no longer available." };
+ }
+
+ if (isCursorCaptureActive) return { success: false, canceled: true };
+ // Recording brings the selected window forward; selection need not wait for it.
+ setSelectedSource(source);
+ broadcastSelectedSourceChange();
+ stopWindowBoundsCapture();
+ return { success: true, source };
+ };
+ ipcMain.handle("pick-capture-target", () => {
+ if (isCursorCaptureActive) return { success: false, canceled: true };
+ if (!activePick) {
+ activePick = pickTarget()
+ .catch((error) => {
+ finishPick(null);
+ closeCapturePickerWindows();
+ return {
+ success: false,
+ message:
+ error instanceof Error
+ ? error.message
+ : "Unable to open the source picker.",
+ };
+ })
+ .finally(() => {
+ activePick = null;
+ });
+ }
+ return activePick;
+ });
+
+ ipcMain.handle("complete-capture-pick", async (event, input: unknown) => {
+ if (!isCapturePickerWebContents(event.sender)) return;
+ if (
+ input &&
+ typeof input === "object" &&
+ (input as { kind?: unknown }).kind === "area" &&
+ process.platform !== "darwin"
+ )
+ return;
+ if (!input || isCursorCaptureActive) {
+ finishPick(null);
+ return;
+ }
+ const displays = getScreen()
+ .getAllDisplays()
+ .map((display) => ({ id: display.id, bounds: display.bounds }));
+ finishPick(
+ normalizeCapturePick(
+ input && typeof input === "object" ? { ...input, record: false } : input,
+ displays,
+ await pickableWindows,
+ ),
+ );
+ });
+
+ ipcMain.handle("get-capture-picker-context", async (event, displayId: unknown) => {
+ if (!isCapturePickerWebContents(event.sender)) throw new Error("Unknown picker window.");
+ const display = getScreen()
+ .getAllDisplays()
+ .find((entry) => entry.id === Number(displayId));
+ if (!display) {
+ return {
+ displayBounds: null,
+ lastArea: null,
+ allowArea: process.platform === "darwin",
+ windows: [],
+ cursor: null,
+ };
+ }
+ const { x, y, width, height } = display.bounds;
+ const windows = (await pickableWindows).filter(
+ (window) =>
+ window.x < x + width &&
+ window.x + window.width > x &&
+ window.y < y + height &&
+ window.y + window.height > y,
+ );
+ return {
+ displayBounds: display.bounds,
+ allowArea: process.platform === "darwin",
+ lastArea: null,
+ windows,
+ cursor: getScreen().getCursorScreenPoint(),
+ };
+ });
+}
diff --git a/electron/ipc/register/cloudShare.test.ts b/electron/ipc/register/cloudShare.test.ts
new file mode 100644
index 000000000..3346e9b44
--- /dev/null
+++ b/electron/ipc/register/cloudShare.test.ts
@@ -0,0 +1,169 @@
+import { describe, expect, it } from "vitest";
+import {
+ normalizeCloudEndpoint,
+ parseCloudShareTicket,
+ parseCloudRecordingList,
+} from "../cloudShareContract";
+
+describe("cloud share contract", () => {
+ it("accepts only Recordly production, isolated staging, and local development endpoints", () => {
+ expect(
+ normalizeCloudEndpoint(
+ "https://recordly-share-staging.youngchen3442.workers.dev/api/upload",
+ ),
+ ).toBe("https://recordly-share-staging.youngchen3442.workers.dev/api/upload");
+ 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("https://other.youngchen3442.workers.dev/api/upload"),
+ ).toThrow("only allowed through the Recordly service");
+ expect(() =>
+ normalizeCloudEndpoint(
+ "https://recordly-share-staging.youngchen3442.workers.dev.attacker.example/api/upload",
+ ),
+ ).toThrow("only allowed through the Recordly service");
+ expect(() => normalizeCloudEndpoint("https://untrusted.example.test/api/upload")).toThrow(
+ "only allowed through the Recordly service",
+ );
+ expect(() =>
+ normalizeCloudEndpoint("https://videos.recordly.dev.attacker.example/api/upload"),
+ ).toThrow("only allowed through the Recordly service");
+ 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");
+ });
+});
+
+describe("cloud recording library responses", () => {
+ it.each([
+ null,
+ false,
+ "invalid",
+ [],
+ {},
+ { videos: null },
+ ])("rejects malformed lists (%j)", (data) => {
+ expect(() =>
+ parseCloudRecordingList(data, "https://videos.recordly.dev/api/upload"),
+ ).toThrow("Invalid recordings response.");
+ });
+ it.each([
+ null,
+ false,
+ "invalid",
+ [],
+ {},
+ { share_code: "abc", title: 1 },
+ ])("rejects malformed rows (%j)", (row) => {
+ expect(() =>
+ parseCloudRecordingList({ videos: [row] }, "https://videos.recordly.dev/api/upload"),
+ ).toThrow("Invalid recording response.");
+ });
+ it("uses the validated endpoint for links and normalizes readiness and size", () => {
+ expect(
+ parseCloudRecordingList(
+ {
+ videos: [
+ {
+ share_code: "abc123",
+ title: "Fixture",
+ file_size: 8,
+ upload_completed: 1,
+ created_at: "2026-01-01",
+ },
+ ],
+ },
+ "https://videos.recordly.dev/api/upload",
+ ),
+ ).toEqual([
+ {
+ code: "abc123",
+ title: "Fixture",
+ size: 8,
+ ready: true,
+ createdAt: "2026-01-01",
+ url: "https://videos.recordly.dev/s/abc123",
+ },
+ ]);
+ });
+});
diff --git a/electron/ipc/register/cloudShare.ts b/electron/ipc/register/cloudShare.ts
new file mode 100644
index 000000000..cb9ed7ea4
--- /dev/null
+++ b/electron/ipc/register/cloudShare.ts
@@ -0,0 +1,528 @@
+import { createShareThumbnail } from "../recording/shareThumbnail";
+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,
+ parseCloudRecordingList,
+ 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-manage",
+ async (
+ _event,
+ input: { endpoint?: unknown; token?: unknown; action?: unknown; shareCode?: unknown },
+ ) => {
+ try {
+ const endpoint = normalizeCloudEndpoint(input?.endpoint);
+ if (
+ typeof input.token !== "string" ||
+ !input.token ||
+ Buffer.byteLength(input.token) > MAX_AUTH_TOKEN_BYTES
+ )
+ throw new Error("Sign in to manage shared recordings.");
+ if (input.action !== "list" && input.action !== "delete")
+ throw new Error("Invalid recording action.");
+ if (
+ input.action === "delete" &&
+ (typeof input.shareCode !== "string" ||
+ !/^[a-z0-9]{1,128}$/.test(input.shareCode))
+ )
+ throw new Error("Invalid recording ID.");
+ const response = await fetch(
+ new URL(
+ input.action === "list" ? "/api/videos" : `/api/delete/${input.shareCode}`,
+ endpoint,
+ ),
+ {
+ method: input.action === "list" ? "GET" : "DELETE",
+ headers: { authorization: `Bearer ${input.token}` },
+ redirect: "error",
+ signal: AbortSignal.timeout(30000),
+ },
+ );
+ if (!response.ok)
+ throw new Error(
+ await responseError(response, "Could not manage shared recordings"),
+ );
+ if (input.action === "delete") return { success: true };
+ return {
+ success: true,
+ videos: parseCloudRecordingList(await readJsonResponse(response), endpoint),
+ };
+ } catch (error) {
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : String(error),
+ };
+ }
+ },
+ );
+
+ 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}).`,
+ );
+ }
+ }
+ if (ticket.shareCode) {
+ try {
+ const thumbnail = await createShareThumbnail(
+ resolvedPath,
+ controller.signal,
+ );
+ const response = await fetch(
+ new URL(`/api/upload-thumbnail/${ticket.shareCode}`, endpointOrigin),
+ {
+ method: "PUT",
+ headers: {
+ authorization: `Bearer ${token}`,
+ "content-type": "image/jpeg",
+ "content-length": String(thumbnail.length),
+ },
+ body: new Uint8Array(thumbnail),
+ redirect: "error",
+ signal: controller.signal,
+ },
+ );
+ if (!response.ok)
+ throw new Error(`Thumbnail upload failed (${response.status})`);
+ } catch {
+ // The recording is already published. Older/missing posters use the
+ // video's first frame in the library instead of failing the share.
+ console.warn(
+ "Shared video poster unavailable; using video preview fallback.",
+ );
+ }
+ }
+
+ 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.test.ts b/electron/ipc/register/permissions.test.ts
new file mode 100644
index 000000000..4491267b8
--- /dev/null
+++ b/electron/ipc/register/permissions.test.ts
@@ -0,0 +1,67 @@
+import { afterEach, beforeEach, expect, it, vi } from "vitest";
+const mocks = vi.hoisted(() => ({
+ handlers: new Map unknown>(),
+ status: vi.fn(),
+ sources: vi.fn(),
+ open: vi.fn(),
+}));
+vi.mock("electron", () => ({
+ ipcMain: {
+ handle: (key: string, handler: (...args: unknown[]) => unknown) =>
+ mocks.handlers.set(key, handler),
+ },
+ desktopCapturer: { getSources: mocks.sources },
+ systemPreferences: { getMediaAccessStatus: mocks.status },
+ shell: { openExternal: mocks.open },
+}));
+vi.mock("../utils", () => ({ getMacPrivacySettingsUrl: (pane: string) => `settings:${pane}` }));
+import { registerPermissionHandlers } from "./permissions";
+const platform = process.platform;
+afterEach(() => Object.defineProperty(process, "platform", { value: platform }));
+beforeEach(() => {
+ vi.resetAllMocks();
+ Object.defineProperty(process, "platform", { value: "darwin" });
+ mocks.handlers.clear();
+ registerPermissionHandlers();
+});
+it("does not prompt or open settings merely by registering permission handlers", () => {
+ expect(mocks.sources).not.toHaveBeenCalled();
+ expect(mocks.open).not.toHaveBeenCalled();
+});
+it("registers first-time Screen Recording access before opening its settings", async () => {
+ mocks.status.mockReturnValue("not-determined");
+ mocks.sources.mockResolvedValue([]);
+ expect(await mocks.handlers.get("open-screen-recording-preferences")!()).toEqual({
+ success: true,
+ });
+ expect(mocks.sources).toHaveBeenCalledWith({
+ types: ["screen"],
+ thumbnailSize: { width: 0, height: 0 },
+ });
+ expect(mocks.sources.mock.invocationCallOrder[0]).toBeLessThan(
+ mocks.open.mock.invocationCallOrder[0],
+ );
+ expect(mocks.open).toHaveBeenCalledWith("settings:screen");
+});
+it("opens settings even when the first-time capture request is rejected", async () => {
+ mocks.status.mockReturnValue("not-determined");
+ mocks.sources.mockRejectedValue(new Error("Denied"));
+ expect(await mocks.handlers.get("open-screen-recording-preferences")!()).toEqual({
+ success: true,
+ });
+ expect(mocks.open).toHaveBeenCalledWith("settings:screen");
+});
+it("does not enumerate screens for an already determined permission", async () => {
+ mocks.status.mockReturnValue("denied");
+ await mocks.handlers.get("open-screen-recording-preferences")!();
+ expect(mocks.sources).not.toHaveBeenCalled();
+ expect(mocks.open).toHaveBeenCalledWith("settings:screen");
+});
+it("leaves native macOS permission controls unused on other platforms", async () => {
+ Object.defineProperty(process, "platform", { value: "win32" });
+ expect(await mocks.handlers.get("open-screen-recording-preferences")!()).toEqual({
+ success: true,
+ });
+ expect(mocks.sources).not.toHaveBeenCalled();
+ expect(mocks.open).not.toHaveBeenCalled();
+});
diff --git a/electron/ipc/register/permissions.ts b/electron/ipc/register/permissions.ts
new file mode 100644
index 000000000..b529c739d
--- /dev/null
+++ b/electron/ipc/register/permissions.ts
@@ -0,0 +1,98 @@
+import { desktopCapturer, 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 {
+ // First-time access must register Recordly in macOS before its settings row can be enabled.
+ if (systemPreferences.getMediaAccessStatus("screen") === "not-determined") {
+ try {
+ await desktopCapturer.getSources({
+ types: ["screen"],
+ thumbnailSize: { width: 0, height: 0 },
+ });
+ } catch {
+ // A denied prompt still needs the settings page so access can be enabled later.
+ }
+ }
+ 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..88dfb71d9
--- /dev/null
+++ b/electron/ipc/register/project.ts
@@ -0,0 +1,958 @@
+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 (event) => {
+ try {
+ const library = await listProjectLibraryEntries((ready) => {
+ if (!event.sender.isDestroyed())
+ event.sender.send("project-library-thumbnail-ready", ready);
+ });
+ 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..faef32d6e
--- /dev/null
+++ b/electron/ipc/register/recording.ts
@@ -0,0 +1,2090 @@
+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 {
+ beginHudCaptureProtection,
+ getHudOverlayCaptureProtectionEnabled,
+ hideAreaRecordingBorder,
+ showAreaRecordingBorder,
+} 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,
+ getRecordingDiagnosticsPath,
+ 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 {
+ createHudCrashRecoveryDeps,
+ createStartTracker,
+ stopRecordingWhenHudGoes,
+} from "../recording/hudCrashRecovery";
+import { 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,
+ nativeCaptureStopRequested,
+ 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,
+ windowsCaptureStopRequested,
+ 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 { getSourceArea } from "../sourceArea";
+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