diff --git a/.coderabbit.yaml b/.coderabbit.yaml new file mode 100644 index 000000000..2045b00d1 --- /dev/null +++ b/.coderabbit.yaml @@ -0,0 +1,5 @@ +reviews: + auto_review: + enabled: true + base_branches: + - ".*" 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 +![Screenshot Description](path/to/screenshot.png) +``` + +**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..5f8549ec9 --- /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..45e5f6512 --- /dev/null +++ b/.github/workflows/quality.yml @@ -0,0 +1,65 @@ +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 + + - name: Test + if: ${{ !cancelled() && steps.install.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..3784c0cf8 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ lerna-debug.log* node_modules dist +dist-electron dist-ssr *.local @@ -23,5 +24,24 @@ 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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..62a6ea1b5 --- /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 [MIT License](./LICENSE). + +Thank you for your contributions! diff --git a/EXTENSIONS.md b/EXTENSIONS.md new file mode 100644 index 000000000..1104e9526 --- /dev/null +++ b/EXTENSIONS.md @@ -0,0 +1,310 @@ +# Recordly Extension API + +Go to https://www.marketplace.recordly.dev/extensions for full, regularly updated documentation + +Recordly extensions run in the editor renderer and use a permission-gated host API. They can draw into the render pipeline, react to playback and export events, register cursor effects, add settings panels, and contribute packaged assets such as frames, wallpapers, and cursor styles. + +## Quick Start + +For local user-installed extensions, use `Extensions -> Open Directory` in the app. Recordly stores them in the app `userData/extensions` directory. This repo also includes installable example bundles under `extension-examples/`. + +### Minimum Extension + +```text +my-extension/ + recordly-extension.json + index.js +``` + +```json +{ + "id": "com.example.my-extension", + "name": "My Extension", + "version": "1.0.0", + "description": "A short description", + "author": "Your Name", + "license": "MIT", + "main": "index.js", + "permissions": ["render"] +} +``` + +```js +export function activate(api) { + api.log("Hello from my extension"); +} + +export function deactivate() {} +``` + +### TypeScript + +Extensions can be authored in TypeScript. Import the `RecordlyExtensionAPI` type from `types.ts` in this repo for full IDE auto-complete and compile-time checks: + +```ts +import type { RecordlyExtensionAPI } from "./types"; + +export function activate(api: RecordlyExtensionAPI) { + api.registerRenderHook("final", (ctx) => { + ctx.ctx.fillStyle = "rgba(255,255,255,0.1)"; + ctx.ctx.fillRect(0, 0, ctx.width, 30); + }); +} + +export function deactivate() {} +``` + +Recordly loads the `main` entry from the manifest, which must be a `.js` file. If you author in TypeScript, compile or bundle to JavaScript before packaging. A `tsconfig.json` with `"module": "ESNext"` and `"target": "ESNext"` works well since extensions run in a Chromium renderer. + +### Manifest Screenshots + +Extensions can include `screenshots` in the manifest to show preview images in the marketplace: + +```json +{ + "id": "com.example.my-extension", + "screenshots": [ + "screenshots/preview-1.png", + "screenshots/preview-2.png" + ] +} +``` + +Paths are relative to the extension root. The marketplace displays screenshots in a carousel on both the in-app detail modal and the web detail page. + +## Manifest + +`recordly-extension.json` is validated both when the extension loads and when a zip is uploaded to the marketplace. + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `id` | `string` | Yes | Unique identifier, for example `yourname.cool-effect` | +| `name` | `string` | Yes | Human-readable name shown in the UI | +| `version` | `string` | Yes | Strict semver version, for example `1.0.0` | +| `description` | `string` | Yes | One-line summary | +| `author` | `string` | No | Author or organisation | +| `homepage` | `string` | No | HTTPS homepage or repository URL | +| `license` | `string` | No | SPDX license identifier | +| `engine` | `string` | No | Minimum supported Recordly version | +| `icon` | `string` | No | Relative path to a PNG icon | +| `screenshots` | `string[]` | No | Relative paths to preview images shown in the marketplace | +| `main` | `string` | Yes | Relative entry point JS file | +| `permissions` | `string[]` | Yes | Required capabilities | +| `contributes` | `object` | No | Metadata for packaged frames, cursor styles, sounds, wallpapers, and webcam frames | + +`contributes` is metadata only today. Recordly does not auto-register runtime behavior from the manifest. Use `activate()` to call APIs such as `registerFrame()`, `registerWallpaper()`, `registerCursorStyle()`, `registerSettingsPanel()`, and `playSound()`. + +## Permissions + +| Permission | Grants access to | +|-----------|------------------| +| `render` | Render hook registration | +| `cursor` | Cursor telemetry and cursor effect registration | +| `audio` | Bundled sound playback | +| `timeline` | Playback and timeline events | +| `ui` | Settings panels and device frame registration | +| `assets` | Bundled asset resolution plus wallpaper and cursor style registration | +| `export` | Export lifecycle events | + +## Render Pipeline + +Render hooks draw into `hookCtx.ctx`, a `CanvasRenderingContext2D`, at specific phases. + +| Phase | Preview | Export | Notes | +|-------|---------|--------|-------| +| `background` | Reserved | Reserved | Exists in the type surface but is not dispatched yet | +| `post-video` | Yes | Yes | Runs inside the scene transform | +| `post-zoom` | Yes | Yes | Runs inside the scene transform | +| `post-cursor` | Yes | Yes | Runs inside the scene transform | +| `post-webcam` | Yes | Yes | Runs after the built-in transform | +| `post-annotations` | Yes | Yes | Runs after the built-in transform | +| `final` | Yes | Yes | Last pass for HUD-style overlays | + +### Inside vs Outside the Scene Transform + +- `post-video`, `post-zoom`, and `post-cursor` already follow zoom and motion in preview and export. +- `post-webcam`, `post-annotations`, and `final` run after Recordly restores the canvas transform. Use `sceneTransform` manually if you want those overlays to move with the scene. + +### RenderHookContext + +```ts +{ + width: number; + height: number; + timeMs: number; + durationMs: number; + cursor: { cx: number; cy: number; interactionType?: string } | null; + smoothedCursor?: { + cx: number; + cy: number; + trail: Array<{ cx: number; cy: number }>; + } | null; + ctx: CanvasRenderingContext2D; + videoLayout?: { + maskRect: { x: number; y: number; width: number; height: number }; + borderRadius: number; + padding: number; + }; + zoom?: { scale: number; focusX: number; focusY: number; progress: number }; + sceneTransform?: { scale: number; x: number; y: number }; + shadow?: { enabled: boolean; intensity: number }; + getPixelColor(x: number, y: number): { r: number; g: number; b: number; a: number }; + getAverageSceneColor(): { r: number; g: number; b: number; a: number }; + getEdgeAverageColor(edgeWidth?: number): { r: number; g: number; b: number; a: number }; + getDominantColors(count?: number): Array<{ r: number; g: number; b: number; frequency: number }>; +} +``` + +Use `videoLayout.maskRect` and `videoLayout.borderRadius` for scene-relative sizing. That gives you true scene borders and correctly shaped rounded corners instead of canvas-wide overlays. + +## Cursor Effects + +Cursor effect callbacks run each frame after a click until they return `false`. + +```js +api.registerCursorEffect((ctx) => { + const progress = ctx.elapsedMs / 400; + if (progress >= 1) return false; + + const sceneWidth = ctx.videoLayout?.maskRect.width ?? ctx.width; + const x = ctx.cx * ctx.width; + const y = ctx.cy * ctx.height; + const radius = sceneWidth * 0.03 * progress; + + ctx.ctx.beginPath(); + ctx.ctx.arc(x, y, radius, 0, Math.PI * 2); + ctx.ctx.stroke(); + return true; +}); +``` + +`CursorEffectContext` now includes `videoLayout`, `zoom`, and `sceneTransform`, so effects can scale relative to the scene instead of the full canvas. + +## API Surface + +### Registration + +```js +api.registerRenderHook(phase, hook); +api.registerCursorEffect(effect); +api.registerFrame(frame); +api.registerWallpaper(wallpaper); +api.registerCursorStyle(cursorStyle); +api.registerSettingsPanel(panel); +api.on(event, handler); +``` + +Every registration returns a dispose function. + +### Settings + +```js +api.getSetting(settingId); +api.setSetting(settingId, value); +api.onSettingChange((settingId, value) => {}); +api.getAllSettings(); +``` + +Supported settings field types are `toggle`, `slider`, `select`, `color`, and `text`. + +### Assets and Audio + +```js +api.resolveAsset("images/overlay.png"); +api.playSound("sounds/click.mp3", { volume: 0.8 }); +api.log("hello", payload); +``` + +### Read-only Queries + +```js +api.getVideoInfo(); +api.getVideoLayout(); +api.getCursorAt(timeMs); +api.getSmoothedCursor(); +api.getZoomState(); +api.getShadowConfig(); +api.getKeystrokesInRange(startMs, endMs); +api.getAspectRatio(); +api.getActiveFrame(); +api.isExtensionActive(extensionId); +api.getPlaybackState(); +api.getCanvasDimensions(); +api.drawIcon(ctx, "Sparkle", 100, 100, 20, "#2563EB", "regular"); +``` + +### Drawing Icons + +Extensions can draw icons from Recordly's bundled Phosphor icon set directly on a canvas context: + +```js +api.drawIcon( + ctx, + "ArrowClockwise", // icon name from @phosphor-icons/react + 120, // x (center) + 80, // y (center) + 18, // size in px + "#ffffff", // color + "bold", // optional weight: thin | light | regular | bold | fill +); +``` + +This is useful for lightweight overlays and avoids bundling your own icon assets. + +## Settings Panels + +```js +api.registerSettingsPanel({ + id: "my-settings", + label: "My Extension", + icon: "sparkles", + parentSection: "cursor", + fields: [ + { id: "enabled", label: "Enable", type: "toggle", defaultValue: true }, + { id: "size", label: "Size", type: "slider", defaultValue: 1, min: 0.1, max: 3, step: 0.1 }, + { + id: "style", + label: "Style", + type: "select", + defaultValue: "ripple", + options: [{ label: "Ripple", value: "ripple" }, { label: "Pulse", value: "pulse" }], + }, + { id: "color", label: "Color", type: "color", defaultValue: "#2563EB" }, + ], +}); +``` + +Use `parentSection` to nest your panel inside an existing area such as `cursor` or `scene`. + +## Events + +| Event | Permission | Description | +|-------|------------|-------------| +| `playback:timeupdate` | `timeline` | Fires each playback tick | +| `playback:play` | `timeline` | Playback started | +| `playback:pause` | `timeline` | Playback paused | +| `cursor:click` | `cursor` | Cursor click detected | +| `cursor:move` | `cursor` | Cursor move detected | +| `timeline:region-added` | `timeline` | Region added | +| `timeline:region-removed` | `timeline` | Region removed | +| `export:start` | `export` | Export started | +| `export:frame` | `export` | A frame was rendered during export | +| `export:complete` | `export` | Export finished | + +## Frames, Wallpapers, and Cursor Styles + +- `registerFrame()` is the runtime API for device frames. Use a `draw(ctx, width, height)` function when possible for resolution-independent output. +- `registerWallpaper()` contributes scene backgrounds. +- `registerCursorStyle()` contributes cursor image packs. +- All three rely on packaged files relative to the extension root. + +## Lifecycle + +1. Discovery: Recordly scans built-in extensions and the user extensions directory. +2. Activation: `activate(api)` runs and you register hooks, effects, panels, and assets. +3. Runtime: Registered callbacks execute in preview and export according to their phase. +4. Deactivation: `deactivate()` runs and all registrations are automatically disposed. + +## Examples + +- `extension-examples/webadderall.more-wallpapers` shows a user-installable wallpaper bundle that registers 180 packaged wallpapers through `registerWallpaper()`. diff --git a/LICENSE b/LICENSE deleted file mode 100644 index 45139414b..000000000 --- a/LICENSE +++ /dev/null @@ -1,8 +0,0 @@ -OpenScreen Free Use License -Copyright (c) 2025 Siddharth Vaddem - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 000000000..e7ad2a920 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,252 @@ +## GNU AFFERO GENERAL PUBLIC LICENSE +Version 3, 19 November 2007 + +### QUICK SUMMARY: +- You ARE allowed to **use, distribute, and modify this code for all intents and purposes.** +- You ARE allowed to self-host this for your own personal use. +- RECIPROCAL LICENSING: **If you modify this code or use it + to power a website or service (SaaS)**, you MUST make your **entire + source code** (including all edits) p**ublicly available** under this + same AGPLv3 license. +- You CANNOT use the "Recordly" name or branding for your own project. +- If you use Recordly's code or create code derived from Recordly you must attribute Recordly in the user-facing UI and the repo. + +Copyright (C) 2026 webadderall + +Recordly is an independent application that started as a fork of the OpenScreen project, authored by Siddharth Vaddem (2025). + +--- +PART 1: THE AGPLv3 LICENSE +--- +(The following is the full text of the AGPLv3. This license governs the +entirety of this project from 2026 onwards.) + +GNU AFFERO GENERAL PUBLIC LICENSE +Version 3, 19 November 2007 + +Copyright © 2007 Free Software Foundation, Inc. +Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. + +Preamble +The GNU Affero General Public License is a free, copyleft license for software and other kinds of works, specifically designed to ensure cooperation with the community in the case of network server software. + +The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, our General Public Licenses are intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. + +When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. + +Developers that use our General Public Licenses protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License which gives you legal permission to copy, distribute and/or modify the software. + +A secondary benefit of defending all users' freedom is that improvements made in alternate versions of the program, if they receive widespread use, become available for other developers to incorporate. Many developers of free software are heartened and encouraged by the resulting cooperation. However, in the case of software used on network servers, this result may fail to come about. The GNU General Public License permits making a modified version and letting the public access it on a server without ever releasing its source code to the public. + +The GNU Affero General Public License is designed specifically to ensure that, in such cases, the modified source code becomes available to the community. It requires the operator of a network server to provide the source code of the modified version running there to the users of that server. Therefore, public use of a modified version, on a publicly accessible server, gives the public access to the source code of the modified version. + +An older license, called the Affero General Public License and published by Affero, was designed to accomplish similar goals. This is a different license, not a version of the Affero GPL, but Affero has released a new version of the Affero GPL which permits relicensing under this license. + +The precise terms and conditions for copying, distribution and modification follow. + +TERMS AND CONDITIONS +0. Definitions. +"This License" refers to version 3 of the GNU Affero General Public License. + +"Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. + +"The Program" refers to any copyrightable work licensed under this License. Each licensee is addressed as "you". "Licensees" and "recipients" may be individuals or organizations. + +To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a work "based on" the earlier work. + +A "covered work" means either the unmodified Program or a work based on the Program. + +To "propagate" a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well. + +To "convey" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying. + +An interactive user interface displays "Appropriate Legal Notices" to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion. + +1. Source Code. +The "source code" for a work means the preferred form of the work for making modifications to it. "Object code" means any non-source form of a work. + +A "Standard Interface" means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language. + +The "System Libraries" of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A "Major Component", in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it. + +The "Corresponding Source" for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work. + +The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source. + +The Corresponding Source for a work in source code form is that same work. + +2. Basic Permissions. +All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law. + +You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you. + +Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary. + +3. Protecting Users' Legal Rights From Anti-Circumvention Law. +No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures. + +When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures. + +4. Conveying Verbatim Copies. +You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program. + +You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee. + +5. Conveying Modified Source Versions. +You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions: + +a) The work must carry prominent notices stating that you modified it, and giving a relevant date. +b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to "keep intact all notices". +c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it. +d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so. +A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an "aggregate" if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate. + +6. Conveying Non-Source Forms. +You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways: + +a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange. +b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge. +c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b. +d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements. +e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d. +A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work. + +A "User Product" is either (1) a "consumer product", which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, "normally used" refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product. + +"Installation Information" for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made. + +If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM). + +The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network. + +Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying. + +7. Additional Terms. +"Additional permissions" are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions. + +When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission. + +Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms: + +a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or +b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or +c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or +d) Limiting the use for publicity purposes of names of licensors or authors of the material; or +e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or +f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors. +All other non-permissive additional terms are considered "further restrictions" within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying. + +If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms. + +Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way. + +8. Termination. +You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11). + +However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation. + +Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice. + +Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10. + +9. Acceptance Not Required for Having Copies. +You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so. + +10. Automatic Licensing of Downstream Recipients. +Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License. + +An "entity transaction" is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts. + +You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it. + +11. Patents. +A "contributor" is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's "contributor version". + +A contributor's "essential patent claims" are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, "control" includes the right to grant patent sublicenses in a manner consistent with the requirements of this License. + +Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version. + +In the following three paragraphs, a "patent license" is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To "grant" such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party. + +If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. "Knowingly relying" means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid. + +If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it. + +A patent license is "discriminatory" if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007. + +Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law. + +12. No Surrender of Others' Freedom. +If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. + +13. Remote Network Interaction; Use with the GNU General Public License. +Notwithstanding any other provision of this License, if you modify the Program, your modified version must prominently offer all users interacting with it remotely through a computer network (if your version supports such interaction) an opportunity to receive the Corresponding Source of your version by providing access to the Corresponding Source from a network server at no charge, through some standard or customary means of facilitating copying of software. This Corresponding Source shall include the Corresponding Source for any work covered by version 3 of the GNU General Public License that is incorporated pursuant to the following paragraph. + +Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the work with which it is combined will remain governed by version 3 of the GNU General Public License. + +14. Revised Versions of this License. +The Free Software Foundation may publish revised and/or new versions of the GNU Affero General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. + +Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU Affero General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU Affero General Public License, you may choose any version ever published by the Free Software Foundation. + +If the Program specifies that a proxy can decide which future versions of the GNU Affero General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. + +Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version. + +15. Disclaimer of Warranty. +THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + +16. Limitation of Liability. +IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. + +17. Interpretation of Sections 15 and 16. +If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee. + +END OF TERMS AND CONDITIONS + +How to Apply These Terms to Your New Programs +If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. + +To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as + published by the Free Software Foundation, either version 3 of the + License, or (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +======================================================================= +PART 2: ORIGINAL MIT ATTRIBUTION (Pursuant to MIT License requirements) +======================================================================= +The original code upon which this project is based is used under the +following terms: + +Copyright (c) 2025 Siddharth Vaddem + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 39872a264..121ef9343 100644 --- a/README.md +++ b/README.md @@ -1,63 +1,409 @@ - - - -

- OpenScreen Logo -

- - -#

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!).** - -

-

- OpenScreen App Preview - OpenScreen App Preview 2 - OpenScreen App Preview 3 -

-

- -## 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) + +

+ Recordly Logo +

+ +

+ macOS Windows Linux + AGPL 3.0 license +

+ +### 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.** + +MP4 to GIF export (4) + + +--- +### Backed by the community +CodeRabbit Typemark + +--- + +## What is Recordly? + +Recordly is a desktop app for recording and editing screen captures with motion-driven presentation tools built in. Instead of sending raw footage to a motion designer just to add zooms, cursor polish, or a styled background, Recordly handles that workflow in one place for free. + +Recordly runs on: + +- **macOS** 14.0+ +- **Windows** 10 Build 19041+ +- **Linux** on modern distros + +Platform notes: + +- **macOS** uses native ScreenCaptureKit-based capture helpers. +- **Windows** uses a native Windows Graphics Capture (WGC) helper on supported builds, with native WASAPI audio support. +- **Linux** records through Electron capture APIs. Cursor hiding is not supported on Linux today. + +--- + +# Core Features + +## Auto-zooms, cursor polish, and styled frames +Recordly can automatically emphasize activity with zoom suggestions, smooth cursor movement, add motion effects, and place the final composition inside a styled frame with wallpapers, colors, gradients, blur, padding, and shadows. + +

+ Recordly cursor and zoom demo video +

+ +## 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. + +

+ Recordly webcam overlay demo video +

+ +## 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. + +

+ timeline editor +

+ +## 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 + +

+ Recordly recording interface screenshot +

+ +

+ Recordly editor screenshot +

+ +

+ Recordly timeline screenshot +

+ +--- + +# 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 + +[![Ko-Fi](https://img.shields.io/badge/Ko--fi-F16061?style=for-the-badge&logo=ko-fi&logoColor=white)](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 logo +

+ +

+ macOS Windows Linux + AGPL 3.0 license +

+ +### 无需额外剪辑,也能做出精致的屏幕录制。 +[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 cursor and zoom demo video +

+ +## 动态摄像头气泡叠加 +你可以把摄像头素材作为气泡叠加层加入画面,使用预设位置或自定义坐标摆放,支持镜像、阴影和圆角调节,也可以让它跟随缩放变化,保证动态镜头里整体视觉更协调。 + +

+ Recordly webcam overlay demo video +

+ +## 为演示设计的时间线编辑 +使用拖拽式时间线工具处理缩放、裁剪、变速区域、注释、额外音频区域以及裁切感知编辑,并将工作保存为 `.recordly` 项目文件,之后随时回来继续编辑。 + +

+ timeline editor +

+ +## 扩展与市场 + +Recordly 拥有一个社区驱动的扩展系统。任何人都可以构建和发布扩展来为 Recordly 添加新功能,例如光标点击音效、设备边框、浏览器模拟外壳、壁纸、渲染钩子、设置面板等等。 + +浏览并安装社区扩展:[Recordly 扩展市场](https://marketplace.recordly.dev/extensions)。 + +--- + +## 全部功能 + +### 录制 + +- 录制整个显示器或单个应用窗口 +- 录制完成后直接进入编辑器 +- 录制麦克风音频和系统音频 +- 在支持的平台上使用原生捕获后端 +- 从保存的 `.recordly` 项目文件继续编辑 +- 可在应用中打开已有录像或已有项目文件 + +### 时间线与编辑 + +- 拖拽式时间线编辑 +- 裁掉不需要的片段 +- 添加手动缩放区域 +- 根据光标活动生成自动缩放建议 +- 添加加速和减速区域 +- 添加文本、图片和图形注释 +- 在时间线上添加额外音频区域 +- 裁切录制画面 +- 保存并重新打开项目,保留编辑状态 + +### 光标控制 + +- 显示或隐藏渲染后的光标叠加层 +- 调整光标大小 +- 光标平滑 +- 光标运动模糊 +- 点击弹跳效果 +- 光标摆动效果 +- 光标循环模式,方便导出更自然的循环片段 +- 使用 macOS 风格的渲染光标素材 + +### 摄像头叠加 + +- 启用或禁用摄像头叠加素材 +- 上传、替换或移除摄像头素材 +- 镜像摄像头画面 +- 调整尺寸 +- 使用预设位置或自定义 X/Y 坐标 +- 调整边距 +- 调整圆角程度 +- 调整阴影强度 +- 可选的缩放联动摄像头缩放效果 + +### 画面样式与背景 + +- 内置壁纸 +- 运行时自动发现 wallpapers 目录中的壁纸 +- 上传自定义背景图片 +- 纯色背景 +- 渐变背景 +- 画面留白 +- 圆角 +- 背景模糊 +- 投影阴影 +- 最终画面的宽高比预设 + +### 导出 + +- MP4 导出 +- GIF 导出 +- 导出质量选择 +- GIF 帧率选择 +- GIF 循环开关 +- GIF 尺寸预设 +- 宽高比和输出尺寸控制 +- 在系统文件管理器中定位导出文件 + +### 工作流与易用性 + +- 可自定义键盘快捷键 +- 应用内快捷键说明 +- 在编辑器中直接打开反馈和问题链接 +- 编辑器偏好设置持久化 +- 导出后更快恢复预览 + +--- + +# 截图 + +

+ Recordly recording interface screenshot +

+ +

+ Recordly editor screenshot +

+ +

+ Recordly timeline screenshot +

+ +--- + +# 安装 + +## 下载构建版本 + +预构建发布版本请见: + +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。 + +--- + +# 支持者名单 + +[![Ko-Fi](https://img.shields.io/badge/Ko--fi-F16061?style=for-the-badge&logo=ko-fi&logoColor=white)](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/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..6c8695eb4 --- /dev/null +++ b/biome.json @@ -0,0 +1,190 @@ +{ + "$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": ["*.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 index b4f439552..f6dc1d599 100644 --- a/components.json +++ b/components.json @@ -1,22 +1,22 @@ { - "$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": {} + "$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/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/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/electron-builder.json5 b/electron-builder.json5 index 327778bfe..a333a8723 100644 --- a/electron-builder.json5 +++ b/electron-builder.json5 @@ -1,52 +1,114 @@ -// @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/**" + ], + "productName": "Recordly", + "npmRebuild": true, + "buildDependenciesFromSource": true, + "compression": "normal", + "directories": { + "output": "release" + }, + "files": [ + "dist", + "dist-electron", + "electron/native", + "!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}" + } +} + 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/cursorHider.ts b/electron/cursorHider.ts new file mode 100644 index 000000000..266d111a9 --- /dev/null +++ b/electron/cursorHider.ts @@ -0,0 +1,144 @@ +import { spawnSync } from "node:child_process"; + +const PY_HIDE_WIN = ` +import ctypes, sys + +class POINT(ctypes.Structure): + _fields_ = [("x", ctypes.c_long), ("y", ctypes.c_long)] + +class CURSORINFO(ctypes.Structure): + _fields_ = [ + ("cbSize", ctypes.c_uint), + ("flags", ctypes.c_uint), + ("hCursor", ctypes.c_void_p), + ("ptScreenPos", POINT), + ] + +user32 = ctypes.windll.user32 +CURSOR_SHOWING = 0x00000001 + +for _ in range(32): + info = CURSORINFO() + info.cbSize = ctypes.sizeof(CURSORINFO) + if user32.GetCursorInfo(ctypes.byref(info)) and not (info.flags & CURSOR_SHOWING): + sys.exit(0) + user32.ShowCursor(False) + +sys.exit(0) +`.trim(); + +const PY_SHOW_WIN = ` +import ctypes, sys + +class POINT(ctypes.Structure): + _fields_ = [("x", ctypes.c_long), ("y", ctypes.c_long)] + +class CURSORINFO(ctypes.Structure): + _fields_ = [ + ("cbSize", ctypes.c_uint), + ("flags", ctypes.c_uint), + ("hCursor", ctypes.c_void_p), + ("ptScreenPos", POINT), + ] + +user32 = ctypes.windll.user32 +CURSOR_SHOWING = 0x00000001 + +for _ in range(32): + info = CURSORINFO() + info.cbSize = ctypes.sizeof(CURSORINFO) + if user32.GetCursorInfo(ctypes.byref(info)) and (info.flags & CURSOR_SHOWING): + sys.exit(0) + user32.ShowCursor(True) + +sys.exit(0) +`.trim(); + +function getPowerShellCommand(show: boolean) { + const desiredFlag = show ? 1 : 0; + const showLiteral = show ? "$true" : "$false"; + + return [ + '$signature = @"', + "using System;", + "using System.Runtime.InteropServices;", + "public struct POINT { public int X; public int Y; }", + "public struct CURSORINFO { public int cbSize; public int flags; public IntPtr hCursor; public POINT ptScreenPos; }", + "public static class CursorNative {", + ' [DllImport("user32.dll")] public static extern int ShowCursor(bool show);', + ' [DllImport("user32.dll")] public static extern bool GetCursorInfo(ref CURSORINFO info);', + "}", + '"@;', + "Add-Type -TypeDefinition $signature -Language CSharp -ErrorAction SilentlyContinue | Out-Null;", + "$info = New-Object CURSORINFO;", + "$info.cbSize = [Runtime.InteropServices.Marshal]::SizeOf([type]CURSORINFO);", + "for ($i = 0; $i -lt 32; $i++) {", + " if ([CursorNative]::GetCursorInfo([ref]$info) -and (($info.flags -band 1) -eq " + + desiredFlag + + ")) { exit 0 }", + " [CursorNative]::ShowCursor(" + showLiteral + ") | Out-Null;", + "}", + "exit 0", + ].join(" "); +} + +function runPythonSnippet(code: string) { + for (const executable of ["python", "python3", "py"]) { + const result = spawnSync(executable, ["-c", code], { timeout: 5000 }); + if (!result.error && result.status === 0) { + return true; + } + } + + return false; +} + +function runPowerShellSnippet(command: string) { + const result = spawnSync( + "powershell.exe", + ["-NoProfile", "-NonInteractive", "-WindowStyle", "Hidden", "-Command", command], + { timeout: 8000 }, + ); + + return !result.error && result.status === 0; +} + +let cursorHidden = false; + +export function hideCursor() { + if (process.platform !== "win32" || cursorHidden) { + return false; + } + + try { + const didHide = + runPythonSnippet(PY_HIDE_WIN) || runPowerShellSnippet(getPowerShellCommand(false)); + + if (didHide) { + cursorHidden = true; + } + + return didHide; + } catch (error) { + console.error("[cursorHider] Failed to hide Windows cursor:", error); + return false; + } +} + +export function showCursor() { + if (process.platform !== "win32" || !cursorHidden) { + return false; + } + + try { + const didShow = + runPythonSnippet(PY_SHOW_WIN) || runPowerShellSnippet(getPowerShellCommand(true)); + if (didShow) { + cursorHidden = false; + } + return didShow; + } catch (error) { + console.error("[cursorHider] Failed to show Windows cursor:", error); + return false; + } +} diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index 0853fc160..c00ef3226 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -1,50 +1,965 @@ -/// - -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 Window { + electronAPI: { + hudOverlaySetIgnoreMouse: (ignore: boolean) => void; + hudOverlaySetSourceSelectionActive: (active: boolean) => void; + hudOverlayDrag: (phase: "start" | "move" | "end", screenX: number, screenY: number) => void; + hudOverlayHide: () => void; + hudOverlayClose: () => 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; + switchToEditor: () => Promise; + openSourceSelector: () => Promise; + selectSource: (source: ProcessedDesktopSource) => Promise; + showSourceHighlight: (source: ProcessedDesktopSource) => Promise<{ success: boolean }>; + getSelectedSource: () => Promise; + onSelectedSourceChanged: ( + callback: (source: ProcessedDesktopSource | null) => void, + ) => () => void; + startNativeScreenRecording: ( + source: ProcessedDesktopSource, + options?: { + capturesSystemAudio?: boolean; + capturesMicrophone?: boolean; + microphoneDeviceId?: string; + microphoneLabel?: string; + }, + ) => Promise<{ + success: boolean; + path?: string; + message?: string; + error?: string; + userNotified?: boolean; + microphoneFallbackRequired?: boolean; + }>; + stopNativeScreenRecording: () => Promise<{ + success: boolean; + path?: string; + message?: string; + error?: string; + }>; + recoverNativeScreenRecording: () => Promise<{ + success: boolean; + path?: string; + message?: string; + error?: string; + }>; + getLastNativeCaptureDiagnostics: () => Promise<{ + success: boolean; + diagnostics?: NativeCaptureDiagnostics | null; + }>; + pauseNativeScreenRecording: () => Promise<{ + success: boolean; + message?: string; + error?: string; + }>; + resumeNativeScreenRecording: () => Promise<{ + success: boolean; + message?: string; + error?: string; + }>; + pauseCursorCapture: (pausedAtMs?: number) => Promise<{ + success: boolean; + message?: string; + error?: string; + }>; + resumeCursorCapture: (resumedAtMs?: number) => Promise<{ + success: boolean; + message?: string; + error?: string; + }>; + startFfmpegRecording: ( + source: ProcessedDesktopSource, + ) => Promise<{ success: boolean; path?: string; message?: string; error?: string }>; + stopFfmpegRecording: () => Promise<{ + success: boolean; + path?: string; + message?: string; + error?: string; + }>; + storeRecordedVideo: ( + videoData: ArrayBuffer, + fileName: string, + ) => Promise<{ success: boolean; path?: string; message?: string }>; + storeMicrophoneSidecar: ( + audioData: ArrayBuffer, + videoPath: string, + options?: { + startDelayMs?: number; + browserMicrophoneProfile?: string; + requestedBrowserMicrophoneProfile?: string | null; + requestedConstraints?: unknown; + mediaTrackSettings?: Record; + audioInputDevices?: Array<{ + deviceId: string; + groupId?: string; + label: string; + }>; + mediaRecorder?: { + mimeType?: string; + audioBitsPerSecond?: number; + timesliceMs?: number; + }; + chunkEvents?: Array<{ + index: number; + size: number; + elapsedMs: number; + deltaMs: number | null; + recordedElapsedMs?: number; + recordedDeltaMs?: number | null; + }>; + pauseIntervals?: Array<{ + startElapsedMs: number; + endElapsedMs?: number; + durationMs?: number; + }>; + }, + ) => Promise<{ success: boolean; path?: string; error?: string }>; + getRecordedVideoPath: () => Promise<{ success: boolean; path?: string; message?: string }>; + listAssetDirectory: (relativeDir: string) => Promise<{ + success: boolean; + files?: string[]; + error?: string; + }>; + readLocalFile: ( + filePath: string, + ) => Promise<{ success: boolean; data?: Uint8Array; error?: string }>; + generateWallpaperThumbnail: ( + filePath: string, + ) => Promise<{ success: boolean; data?: Uint8Array; error?: string }>; + probeNativeVideoMetadata: (filePath: string) => Promise<{ + success: boolean; + metadata?: RendererNativeVideoMetadataProbe; + error?: string; + }>; + getNativeExportCapabilities: () => Promise<{ + success: boolean; + capabilities?: RendererNativeExportCapabilities; + error?: string; + }>; + 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 }>; + 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; + }>; + listProjectFiles: () => Promise<{ + success: boolean; + projectsDir?: string | null; + entries: Array<{ + path: string; + name: string; + 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; + getPlatform: () => Promise; + getLinuxWindowSystem: () => Promise<"wayland" | "x11" | null>; + revealInFolder: ( + filePath: string, + ) => Promise<{ success: boolean; error?: string; message?: string }>; + openRecordingsFolder: () => Promise<{ success: boolean; error?: string; message?: string }>; + getRecordingsDirectory: () => Promise<{ + success: boolean; + path: string; + isDefault: boolean; + error?: string; + }>; + chooseRecordingsDirectory: () => Promise<{ + success: boolean; + canceled?: boolean; + path?: string; + isDefault?: boolean; + message?: string; + error?: string; + }>; + getShortcuts: () => Promise | null>; + saveShortcuts: (shortcuts: unknown) => Promise<{ success: boolean; error?: string }>; + getAppSetting: (key: string) => unknown; + setAppSetting: (key: string, value: unknown) => boolean; + setHasUnsavedChanges: (hasChanges: boolean) => void; + onRequestSaveBeforeClose: (callback: () => Promise) => () => void; + isNativeWindowsCaptureAvailable: () => Promise<{ available: boolean }>; + muxNativeWindowsRecording: (expectedDurationMs?: number) => Promise<{ + success: boolean; + path?: string; + message?: string; + error?: string; + }>; + /** Returns the app version from package.json */ + getAppVersion: () => Promise; + /** Returns the configured remote announcement feed, or null when unavailable. */ + getAnnouncements: () => Promise; + /** Hide the OS cursor before browser capture starts. */ + hideOsCursor: () => Promise<{ success: boolean }>; + /** Recording preferences (mic, system audio, webcam) */ + getRecordingPreferences: () => Promise<{ + success: boolean; + microphoneEnabled: boolean; + microphoneDeviceId?: string; + systemAudioEnabled: boolean; + webcamEnabled: boolean; + webcamDeviceId?: string; + }>; + getRecordingAudioLabConfig: () => Promise<{ + browserMicrophoneProfile: string; + requestedBrowserMicrophoneProfile: string | null; + }>; + setRecordingPreferences: (prefs: { + microphoneEnabled?: boolean; + microphoneDeviceId?: string; + systemAudioEnabled?: boolean; + webcamEnabled?: boolean; + webcamDeviceId?: string; + }) => Promise<{ success: boolean; error?: string }>; + /** Countdown timer before recording */ + getCountdownDelay: () => Promise<{ success: boolean; delay: number }>; + setCountdownDelay: (delay: number) => Promise<{ success: boolean; error?: string }>; + startCountdown: (seconds: number) => Promise<{ success: boolean; cancelled?: boolean }>; + cancelCountdown: () => Promise<{ success: boolean }>; + getActiveCountdown: () => Promise<{ success: boolean; seconds: number | null }>; + onCountdownTick: (callback: (seconds: number) => void) => () => void; + }; +} + +interface ProcessedDesktopSource { + id: string; + name: string; + display_id: string; + thumbnail: string | null; + appIcon: string | null; + originalName?: string; + sourceType?: "screen" | "window"; + appName?: string; + windowTitle?: string; +} + +interface CursorTelemetryPoint { + timeMs: number; + cx: number; + cy: number; + pressure?: number; + interactionType?: + | "move" + | "click" + | "double-click" + | "right-click" + | "middle-click" + | "mouseup"; + cursorType?: + | "arrow" + | "text" + | "pointer" + | "crosshair" + | "open-hand" + | "closed-hand" + | "resize-ew" + | "resize-ns" + | "not-allowed"; +} + +interface SystemCursorAsset { + dataUrl: string; + hotspotX: number; + hotspotY: number; + width: number; + height: number; +} + +interface AutoCaptionCue { + id: string; + startMs: number; + endMs: number; + text: string; + words?: Array<{ + text: string; + startMs: number; + endMs: number; + leadingSpace?: boolean; + }>; +} diff --git a/electron/gpuSwitches.test.ts b/electron/gpuSwitches.test.ts new file mode 100644 index 000000000..4cbb1cdf4 --- /dev/null +++ b/electron/gpuSwitches.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest"; + +import { getGpuSwitches, shouldForceLinuxEgl } from "./gpuSwitches"; + +describe("shouldForceLinuxEgl", () => { + it("does not force EGL in a Wayland session", () => { + expect( + shouldForceLinuxEgl({ + XDG_SESSION_TYPE: "wayland", + WAYLAND_DISPLAY: "wayland-0", + }), + ).toBe(false); + }); + + it("does not force EGL when Wayland is explicitly requested via Ozone", () => { + expect( + shouldForceLinuxEgl({ + OZONE_PLATFORM: "wayland", + XDG_SESSION_TYPE: "x11", + }), + ).toBe(false); + }); + + it("falls back to Electron's ozone hint when OZONE_PLATFORM is invalid", () => { + expect( + shouldForceLinuxEgl({ + OZONE_PLATFORM: "auto", + ELECTRON_OZONE_PLATFORM_HINT: "wayland", + XDG_SESSION_TYPE: "x11", + }), + ).toBe(false); + }); + + it("forces EGL in an X11 session", () => { + expect(shouldForceLinuxEgl({ XDG_SESSION_TYPE: "x11" })).toBe(true); + }); + + it("forces EGL when x11 is explicitly requested via Electron's ozone hint", () => { + expect( + shouldForceLinuxEgl({ + ELECTRON_OZONE_PLATFORM_HINT: "x11", + WAYLAND_DISPLAY: "wayland-0", + }), + ).toBe(true); + }); +}); + +describe("getGpuSwitches", () => { + it("returns the Linux VAAPI workaround without forcing EGL on Wayland", () => { + expect( + getGpuSwitches("linux", { + XDG_SESSION_TYPE: "wayland", + WAYLAND_DISPLAY: "wayland-0", + }), + ).toEqual({ + useGl: undefined, + disableFeatures: ["VaapiVideoDecoder", "VaapiVideoEncoder"], + }); + }); + + it("returns the X11 EGL workaround on Linux X11", () => { + expect(getGpuSwitches("linux", { XDG_SESSION_TYPE: "x11" })).toEqual({ + useGl: "egl", + disableFeatures: ["VaapiVideoDecoder", "VaapiVideoEncoder"], + }); + }); +}); diff --git a/electron/gpuSwitches.ts b/electron/gpuSwitches.ts new file mode 100644 index 000000000..7b7c81ee8 --- /dev/null +++ b/electron/gpuSwitches.ts @@ -0,0 +1,66 @@ +export interface GpuSwitches { + useAngle?: string; + useGl?: string; + disableFeatures?: string[]; +} + +function normalizeLinuxWindowSystem(value: string | undefined): "wayland" | "x11" | null { + const normalized = value?.trim().toLowerCase(); + if (normalized === "wayland" || normalized === "x11") { + return normalized; + } + + return null; +} + +function getForcedLinuxWindowSystem(env: NodeJS.ProcessEnv): "wayland" | "x11" | null { + return ( + normalizeLinuxWindowSystem(env.OZONE_PLATFORM) ?? + normalizeLinuxWindowSystem(env.ELECTRON_OZONE_PLATFORM_HINT) + ); +} + +export function shouldForceLinuxEgl(env: NodeJS.ProcessEnv): boolean { + const forcedWindowSystem = getForcedLinuxWindowSystem(env); + if (forcedWindowSystem === "wayland") { + return false; + } + if (forcedWindowSystem === "x11") { + return true; + } + + const sessionType = env.XDG_SESSION_TYPE?.toLowerCase(); + if (sessionType === "wayland") { + return false; + } + if (sessionType === "x11") { + return true; + } + + return !env.WAYLAND_DISPLAY; +} + +export function getGpuSwitches( + platform: NodeJS.Platform, + env: NodeJS.ProcessEnv = process.env, +): GpuSwitches { + if (platform === "darwin") { + return { + useAngle: "metal", + disableFeatures: ["MacCatapLoopbackAudioForScreenShare"], + }; + } + + if (platform === "win32") { + return { useAngle: "d3d11" }; + } + + if (platform === "linux") { + return { + useGl: shouldForceLinuxEgl(env) ? "egl" : undefined, + disableFeatures: ["VaapiVideoDecoder", "VaapiVideoEncoder"], + }; + } + + return {}; +} diff --git a/electron/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..246c4f1aa --- /dev/null +++ b/electron/ipc/captions/generate.ts @@ -0,0 +1,323 @@ +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 { getUsableCompanionAudioCandidates } from "../recording/diagnostics"; +import { normalizeVideoSourcePath } from "../utils"; +import { getCaptionCompanionAudioCandidates } from "./audioCandidates"; +import { parseSrtCues, parseWhisperJsonCues, shouldRetryWhisperWithoutJson } from "./parser"; +import { isMissingWindowsWhisperRuntimeDependency } from "./runtimeErrors"; +import { segmentCuesIntoPhrases } from "./segment"; +import { + parseSilenceIntervals, + SILENCE_DETECT_MIN_S, + SILENCE_NOISE_DB, + type SilenceInterval, +} from "./silence"; + +const execFileAsync = promisify(execFile); + +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 }); + }; + + pushCandidate(videoPath, "recording"); + const companionAudio = await getUsableCompanionAudioCandidates(videoPath); + for (const candidate of getCaptionCompanionAudioCandidates(companionAudio)) { + 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; +}) { + const 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); + await execFileAsync( + options.ffmpegPath, + [ + "-y", + "-i", + candidate.path, + "-map", + "0:a:0", + "-vn", + "-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 Error( + "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 ?? ""); +} + +export async function generateAutoCaptionsFromVideo(options: { + videoPath: string; + whisperExecutablePath?: string; + whisperModelPath: string; + language?: string; +}) { + 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, + }); + + const language = + options.language && options.language.trim() ? options.language.trim() : "auto"; + const whisperBaseArgs = [ + "-m", + whisperModelPath, + "-f", + wavPath, + "-osrt", + "-of", + outputBase, + "-l", + language, + "-np", + ]; + + 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 timedCues = jsonEnabled + ? parseWhisperJsonCues(await fs.readFile(jsonPath, "utf-8")) + : []; + if (jsonEnabled && timedCues.length === 0) { + // JSON ran but yielded no word-timed cues (empty/malformed output). We fall back + // to SRT, which has no word timings — captions are then split by sentence text and + // silence rather than precise word timing. Surface it for diagnosis. + console.warn( + "[auto-captions] Whisper JSON produced no word-timed cues; falling back to SRT (no word timings).", + ); + } + const cues = + timedCues.length > 0 ? timedCues : parseSrtCues(await fs.readFile(srtPath, "utf-8")); + if (cues.length === 0) { + throw new Error("Whisper completed, but no caption cues were produced."); + } + + // 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 }), + ]); + } +} diff --git a/electron/ipc/captions/parser.ts b/electron/ipc/captions/parser.ts new file mode 100644 index 000000000..ff4c62ce6 --- /dev/null +++ b/electron/ipc/captions/parser.ts @@ -0,0 +1,183 @@ +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) { + 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; + } + + if (tokenStartMs == null || tokenEndMs == null || tokenEndMs <= tokenStartMs) { + return []; + } + + const previousWord = words.length > 0 ? words[words.length - 1] : null; + 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 argument|output-json-full|output-json|ojf|\boj\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..e6ebaf006 --- /dev/null +++ b/electron/ipc/captions/segment.test.ts @@ -0,0 +1,371 @@ +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([]); + }); +}); diff --git a/electron/ipc/captions/segment.ts b/electron/ipc/captions/segment.ts new file mode 100644 index 000000000..60407a00a --- /dev/null +++ b/electron/ipc/captions/segment.ts @@ -0,0 +1,459 @@ +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, non-overlapping cues with fresh ids. 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 []; + } + + // 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/constants.ts b/electron/ipc/constants.ts new file mode 100644 index 000000000..2c5cf8f17 --- /dev/null +++ b/electron/ipc/constants.ts @@ -0,0 +1,31 @@ +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 AUTO_RECORDING_RETENTION_COUNT = 20; +export const AUTO_RECORDING_MAX_AGE_MS = 14 * 24 * 60 * 60 * 1000; +export const ALLOW_RECORDLY_WINDOW_CAPTURE = Boolean(process.env["VITE_DEV_SERVER_URL"]); +export const RECORDING_SESSION_MANIFEST_SUFFIX = ".recordly-session.json"; +export const WHISPER_MODEL_DOWNLOAD_URL = + "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-small.bin"; +export const WHISPER_MODEL_DIR = path.join(USER_DATA_PATH, "whisper"); +export const WHISPER_SMALL_MODEL_PATH = path.join(WHISPER_MODEL_DIR, "ggml-small.bin"); +export const COMPANION_AUDIO_LAYOUTS = [ + { platform: "mac" as const, systemSuffix: ".system.m4a", micSuffix: ".mic.m4a" }, + { platform: "win" as const, systemSuffix: ".system.wav", micSuffix: ".mic.wav" }, + { platform: "mac" as const, systemSuffix: ".system.webm", micSuffix: ".mic.webm" }, +]; + +export const CURSOR_TELEMETRY_VERSION = 2; +export const CURSOR_SAMPLE_INTERVAL_MS = 33; +export const MAX_CURSOR_SAMPLES = 60 * 60 * 30; // 1 hour @ 30Hz diff --git a/electron/ipc/cursor/bounds.ts b/electron/ipc/cursor/bounds.ts new file mode 100644 index 000000000..fbc7d2d50 --- /dev/null +++ b/electron/ipc/cursor/bounds.ts @@ -0,0 +1,271 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import type { NativeMacWindowSource, WindowBounds, SelectedSource } from "../types"; +import { + selectedSource, + setSelectedWindowBounds, + interactionCaptureCleanup, + setInteractionCaptureCleanup, + windowBoundsCaptureInterval, + setWindowBoundsCaptureInterval, + cachedNativeMacWindowSources, + setCachedNativeMacWindowSources, + cachedNativeMacWindowSourcesAtMs, + setCachedNativeMacWindowSourcesAtMs, +} from "../state"; +import { parseWindowId } from "../utils"; +import { ensureNativeWindowListBinary } from "../paths/binaries"; + +const execFileAsync = promisify(execFile); + +export async function getNativeMacWindowSources(options?: { maxAgeMs?: number }) { + if (process.platform !== "darwin") { + return [] as NativeMacWindowSource[]; + } + + const maxAgeMs = options?.maxAgeMs ?? 5000; + const now = Date.now(); + if (cachedNativeMacWindowSources && now - cachedNativeMacWindowSourcesAtMs < maxAgeMs) { + return cachedNativeMacWindowSources; + } + + try { + const binaryPath = await ensureNativeWindowListBinary(); + const { stdout } = await execFileAsync(binaryPath, [], { + timeout: 30000, + maxBuffer: 10 * 1024 * 1024, + }); + + const parsed = JSON.parse(stdout); + if (!Array.isArray(parsed)) { + return [] as NativeMacWindowSource[]; + } + + const entries = parsed.filter((entry: unknown): entry is NativeMacWindowSource => { + if (!entry || typeof entry !== "object") { + return false; + } + + const candidate = entry as Partial; + return typeof candidate.id === "string" && typeof candidate.name === "string"; + }); + + setCachedNativeMacWindowSources(entries); + setCachedNativeMacWindowSourcesAtMs(now); + return entries; + } catch { + return cachedNativeMacWindowSources ?? ([] as NativeMacWindowSource[]); + } +} + +export function getWindowBoundsFromNativeSource( + source?: NativeMacWindowSource | null, +): WindowBounds | null { + if (!source) { + return null; + } + + const { x, y, width, height } = source; + if ( + typeof x !== "number" || + !Number.isFinite(x) || + typeof y !== "number" || + !Number.isFinite(y) || + typeof width !== "number" || + !Number.isFinite(width) || + typeof height !== "number" || + !Number.isFinite(height) + ) { + return null; + } + + if (width <= 0 || height <= 0) { + return null; + } + + return { x, y, width, height }; +} + +export async function resolveMacWindowBounds(source: SelectedSource): Promise { + const windowId = parseWindowId(source.id); + if (!windowId) { + return null; + } + + try { + const nativeSources = await getNativeMacWindowSources({ maxAgeMs: 250 }); + const matchedSource = nativeSources.find((entry) => parseWindowId(entry.id) === windowId); + return getWindowBoundsFromNativeSource(matchedSource); + } catch { + return null; + } +} + +export function parseXwininfoBounds(stdout: string): WindowBounds | null { + const absX = stdout.match(/Absolute upper-left X:\s+(-?\d+)/); + const absY = stdout.match(/Absolute upper-left Y:\s+(-?\d+)/); + const width = stdout.match(/Width:\s+(\d+)/); + const height = stdout.match(/Height:\s+(\d+)/); + + if (!absX || !absY || !width || !height) { + return null; + } + + return { + x: Number.parseInt(absX[1], 10), + y: Number.parseInt(absY[1], 10), + width: Number.parseInt(width[1], 10), + height: Number.parseInt(height[1], 10), + }; +} + +export async function resolveLinuxWindowBounds( + source: SelectedSource, +): Promise { + const windowId = parseWindowId(source?.id); + + if (windowId) { + try { + const { stdout } = await execFileAsync("xwininfo", ["-id", String(windowId)], { + timeout: 1500, + }); + const bounds = parseXwininfoBounds(stdout); + if (bounds && bounds.width > 0 && bounds.height > 0) { + return bounds; + } + } catch { + // fall back to title lookup below + } + } + + const windowTitle = + typeof source.windowTitle === "string" ? source.windowTitle.trim() : source.name.trim(); + if (!windowTitle) { + return null; + } + + try { + const { stdout } = await execFileAsync("xwininfo", ["-name", windowTitle], { + timeout: 1500, + }); + const bounds = parseXwininfoBounds(stdout); + return bounds && bounds.width > 0 && bounds.height > 0 ? bounds : null; + } catch { + return null; + } +} + +export async function resolveWindowsWindowBounds( + source: SelectedSource, +): Promise { + const windowId = parseWindowId(source?.id); + const windowTitle = + typeof source.windowTitle === "string" ? source.windowTitle.trim() : source.name.trim(); + + if (!windowId && !windowTitle) { + return null; + } + + const script = [ + "param([string]$windowId, [string]$windowTitle)", + 'Add-Type -TypeDefinition @"', + "using System;", + "using System.Runtime.InteropServices;", + "public static class RecordlyWindowBounds {", + " [StructLayout(LayoutKind.Sequential)]", + " public struct RECT {", + " public int Left;", + " public int Top;", + " public int Right;", + " public int Bottom;", + " }", + ' [DllImport("user32.dll")]', + " [return: MarshalAs(UnmanagedType.Bool)]", + " public static extern bool GetWindowRect(IntPtr hWnd, out RECT rect);", + "}", + '"@', + "$handle = [Int64]0", + "if ($windowId) {", + " $handle = [Int64]$windowId", + "}", + "$escapedWindowTitle = if ($windowTitle) { [WildcardPattern]::Escape($windowTitle) } else { $null }", + "if ($handle -le 0 -and $windowTitle) {", + ' $matchingProcess = Get-Process | Where-Object { $_.MainWindowTitle -eq $windowTitle -or ($escapedWindowTitle -and $_.MainWindowTitle -like "*$escapedWindowTitle*") } | Select-Object -First 1', + " if ($matchingProcess) {", + " $handle = $matchingProcess.MainWindowHandle.ToInt64()", + " }", + "}", + "if ($handle -le 0) {", + " exit 1", + "}", + "$rect = New-Object RecordlyWindowBounds+RECT", + "if (-not [RecordlyWindowBounds]::GetWindowRect([IntPtr]$handle, [ref]$rect)) {", + " exit 1", + "}", + "@{ x = $rect.Left; y = $rect.Top; width = $rect.Right - $rect.Left; height = $rect.Bottom - $rect.Top } | ConvertTo-Json -Compress", + ].join("\n"); + + try { + const { stdout } = await execFileAsync( + "powershell.exe", + ["-NoProfile", "-Command", script, String(windowId ?? ""), windowTitle], + { timeout: 1500 }, + ); + const bounds = JSON.parse(stdout) as WindowBounds; + 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..fd598b102 --- /dev/null +++ b/electron/ipc/cursor/telemetry.ts @@ -0,0 +1,331 @@ +import fs from "node:fs/promises"; +import { + CURSOR_SAMPLE_INTERVAL_MS, + CURSOR_TELEMETRY_VERSION, + MAX_CURSOR_SAMPLES, +} from "../constants"; +import { + activeCursorSamples, + currentCursorVisualType, + cursorCaptureAccumulatedPausedMs, + cursorCaptureInterval, + cursorCapturePauseStartedAtMs, + cursorCaptureStartTimeMs, + isCursorCaptureActive, + linuxCursorScreenPoint, + pendingCursorSamples, + selectedSource, + selectedWindowBounds, + setActiveCursorSamples, + setCursorCaptureAccumulatedPausedMs, + setCursorCaptureInterval, + setCursorCapturePauseStartedAtMs, + setPendingCursorSamples, +} from "../state"; +import type { CursorInteractionType, CursorTelemetryPoint, CursorVisualType } from "../types"; +import { getScreen, getTelemetryPathForVideo } from "../utils"; + +export function clamp(value: number, min: number, max: number) { + return Math.min(max, Math.max(min, value)); +} + +export function normalizeCursorTelemetrySamples(rawSamples: unknown): CursorTelemetryPoint[] { + const samples = Array.isArray(rawSamples) + ? rawSamples + : Array.isArray((rawSamples as { samples?: unknown[] } | null | undefined)?.samples) + ? ((rawSamples as { samples: unknown[] }).samples ?? []) + : []; + const boundedSamples = samples.slice(0, MAX_CURSOR_SAMPLES); + + return boundedSamples + .filter((sample: unknown) => Boolean(sample && typeof sample === "object")) + .map((sample: unknown) => { + const point = sample as Partial; + return { + timeMs: + typeof point.timeMs === "number" && Number.isFinite(point.timeMs) + ? Math.max(0, point.timeMs) + : 0, + cx: + typeof point.cx === "number" && Number.isFinite(point.cx) + ? clamp(point.cx, 0, 1) + : 0.5, + cy: + typeof point.cy === "number" && Number.isFinite(point.cy) + ? clamp(point.cy, 0, 1) + : 0.5, + interactionType: + point.interactionType === "click" || + point.interactionType === "double-click" || + point.interactionType === "right-click" || + point.interactionType === "middle-click" || + point.interactionType === "move" || + point.interactionType === "mouseup" + ? point.interactionType + : undefined, + cursorType: + point.cursorType === "arrow" || + point.cursorType === "text" || + point.cursorType === "pointer" || + point.cursorType === "crosshair" || + point.cursorType === "open-hand" || + point.cursorType === "closed-hand" || + point.cursorType === "resize-ew" || + point.cursorType === "resize-ns" || + point.cursorType === "not-allowed" + ? point.cursorType + : undefined, + }; + }) + .sort((a, b) => a.timeMs - b.timeMs); +} + +export async function writeCursorTelemetry(videoPath: string, samples: unknown) { + const telemetryPath = getTelemetryPathForVideo(videoPath); + const normalizedSamples = normalizeCursorTelemetrySamples(samples); + + if (normalizedSamples.length === 0) { + await fs.rm(telemetryPath, { force: true }); + return normalizedSamples; + } + + await fs.writeFile( + telemetryPath, + JSON.stringify({ version: CURSOR_TELEMETRY_VERSION, samples: normalizedSamples }, null, 2), + "utf-8", + ); + + return normalizedSamples; +} + +export function stopCursorCapture() { + if (cursorCaptureInterval) { + clearTimeout(cursorCaptureInterval); + setCursorCaptureInterval(null); + } +} + +export function resetCursorCaptureClock() { + setCursorCaptureAccumulatedPausedMs(0); + setCursorCapturePauseStartedAtMs(null); +} + +export function isCursorCapturePaused() { + return cursorCapturePauseStartedAtMs !== null; +} + +export function pauseCursorCapture(pausedAtMs: number) { + if (cursorCapturePauseStartedAtMs !== null) { + return; + } + + setCursorCapturePauseStartedAtMs(pausedAtMs); +} + +export function pauseCursorCaptureAtBoundary(pausedAtMs: number) { + if (cursorCapturePauseStartedAtMs !== null) { + return; + } + + const pausedElapsedMs = getCursorCaptureElapsedMs(pausedAtMs); + setActiveCursorSamples( + activeCursorSamples.filter((sample) => sample.timeMs <= pausedElapsedMs), + ); + setCursorCapturePauseStartedAtMs(pausedAtMs); +} + +export function resumeCursorCapture(resumedAtMs: number) { + if (cursorCapturePauseStartedAtMs === null) { + return; + } + + const pauseDurationMs = Math.max(0, resumedAtMs - cursorCapturePauseStartedAtMs); + setCursorCaptureAccumulatedPausedMs(cursorCaptureAccumulatedPausedMs + pauseDurationMs); + setCursorCapturePauseStartedAtMs(null); +} + +export function getCursorCaptureElapsedMs(nowMs = Date.now()) { + if (!Number.isFinite(cursorCaptureStartTimeMs) || cursorCaptureStartTimeMs <= 0) { + return 0; + } + + const safeNowMs = Math.max(cursorCaptureStartTimeMs, nowMs); + const activePauseDurationMs = + cursorCapturePauseStartedAtMs === null + ? 0 + : Math.max(0, safeNowMs - cursorCapturePauseStartedAtMs); + + return Math.max( + 0, + safeNowMs - + cursorCaptureStartTimeMs - + Math.max(0, cursorCaptureAccumulatedPausedMs) - + activePauseDurationMs, + ); +} + +export function getNormalizedCursorPoint() { + const fallbackCursor = getScreen().getCursorScreenPoint(); + const linuxCursorCache = process.platform === "linux" ? linuxCursorScreenPoint : null; + const isLinuxCacheFresh = !!linuxCursorCache && Date.now() - linuxCursorCache.updatedAt <= 1000; + + const primarySf = + process.platform !== "darwin" ? getScreen().getPrimaryDisplay().scaleFactor || 1 : 1; + + const cursor = isLinuxCacheFresh + ? { x: linuxCursorCache.x / primarySf, y: linuxCursorCache.y / primarySf } + : fallbackCursor; + + const windowBounds = selectedSource?.id?.startsWith("window:") ? selectedWindowBounds : null; + if (windowBounds) { + const sf = + process.platform !== "darwin" + ? getScreen().getDisplayNearestPoint({ + x: windowBounds.x / primarySf, + y: windowBounds.y / primarySf, + }).scaleFactor || 1 + : 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..ee31dcdd7 --- /dev/null +++ b/electron/ipc/export/native-video.test.ts @@ -0,0 +1,1453 @@ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("electron", () => ({ + app: { + getAppPath: vi.fn(() => process.cwd()), + 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, + getNativeExportCapabilities, + getNativeGpuCompositorStallTimeoutMs, + getNativeStaticLayoutSourceProxyBitrate, + getNvidiaCudaAudioExportSkipReason, + getNvidiaCudaAutoStallTimeoutMs, + hasNativeStaticLayoutProgressAdvanced, + hasNvidiaGpuDeviceInGpuInfo, + mapNvidiaCudaWrapperProgressPercentage, + muxExportedVideoAudioBuffer, + type NativeStaticLayoutExportOptions, + normalizeNativeStaticLayoutBackground, + parseFfmpegDurationSeconds, + parseFfmpegFrameRate, + parseFfmpegProgressLineSeconds, + parseNativeVideoMetadataProbeOutput, + parseNativeVideoStreamStatsProbeOutput, + parseNvidiaCudaExportSummary, + parseWindowsGpuExportProgressLine, + parseWindowsGpuExportSummary, + resolveExperimentalNvidiaCudaExportScriptPath, + shouldCreateNativeStaticLayoutSourceProxy, + validateNativeStaticLayoutSourceProxyMetadata, + validateNativeVideoStreamStats, + validateNvidiaCudaExportSummary, + validateWindowsGpuExportSummary, +} from "./native-video"; + +const electronAppMock = app as unknown as { + getAppPath: 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("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..16b2993e8 --- /dev/null +++ b/electron/ipc/export/native-video.ts @@ -0,0 +1,4198 @@ +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 = { + vendorId?: number | string; + vendorString?: string; + deviceString?: string; +}; + +type ElectronGpuInfoLike = { + gpuDevice?: ElectronGpuDeviceLike[]; +}; + +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 NativeVideoMetadataProbe { + width: number; + height: number; + duration: number; + mediaStartTime?: number; + streamStartTime?: number; + streamDuration?: number; + frameRate: number; + codec: string; + hasAudio: boolean; + audioCodec?: string; + audioSampleRate?: number; +} + +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 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, +): Promise { + let output = ""; + try { + const result = await execFileAsync(ffmpegPath, ["-hide_banner", "-i", inputPath], { + timeout: 30_000, + maxBuffer: 4 * 1024 * 1024, + }); + output = `${result.stdout}\n${result.stderr}`; + } catch (error) { + 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; +} + +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); +} + +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/handlers.ts b/electron/ipc/handlers.ts index 81e8af5ae..2a0f998eb 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -1,181 +1,74 @@ -import { ipcMain, desktopCapturer, BrowserWindow, shell, app } from 'electron' -import { startMouseTracking, stopMouseTracking, getTrackingData } from './mouseTracking' -import fs from 'node:fs/promises' -import path from 'node:path' -import { RECORDINGS_DIR } from '../main' +import { BrowserWindow } from "electron"; +import { registerAnnouncementHandlers } from "./register/announcements"; +import { registerAssetHandlers } from "./register/assets"; +import { registerCaptionHandlers } from "./register/captions"; +import { registerExportHandlers } from "./register/export"; +import { registerPermissionHandlers } from "./register/permissions"; +import { registerProjectHandlers } from "./register/project"; +import { registerRecordingHandlers } from "./register/recording"; +import { registerSettingsHandlers } from "./register/settings"; +import { registerSourceHandlers } from "./register/sources"; +import { + selectedSource, + setNativeScreenRecordingActive, + setWindowsCapturePaused, + setWindowsCaptureProcess, + setWindowsCaptureStopRequested, + setWindowsCaptureTargetPath, + setWindowsMicAudioPath, + setWindowsNativeCaptureActive, + setWindowsOrphanedMicAudioPath, + setWindowsPendingVideoPath, + setWindowsSystemAudioPath, + windowsCaptureProcess, +} from "./state"; + +export { cleanupAllExportStreams } from "./export/exportStream"; +export { cleanupNativeVideoExportSessions } from "./export/native-video"; + +/** Returns the currently selected source ID for setDisplayMediaRequestHandler */ +export function getSelectedSourceId(): string | null { + return (selectedSource?.id as string | null) ?? null; +} -let selectedSource: any = null +export function killWindowsCaptureProcess() { + if (windowsCaptureProcess) { + try { + windowsCaptureProcess.kill(); + } catch { + /* ignore */ + } + setWindowsCaptureProcess(null); + setWindowsCaptureTargetPath(null); + setWindowsNativeCaptureActive(false); + setNativeScreenRecordingActive(false); + setWindowsCaptureStopRequested(false); + setWindowsCapturePaused(false); + setWindowsSystemAudioPath(null); + setWindowsMicAudioPath(null); + setWindowsOrphanedMicAudioPath(null); + setWindowsPendingVideoPath(null); + } +} export function registerIpcHandlers( - createEditorWindow: () => void, - createSourceSelectorWindow: () => BrowserWindow, - getMainWindow: () => BrowserWindow | null, - getSourceSelectorWindow: () => BrowserWindow | null, - onRecordingStateChange?: (recording: boolean, sourceName: string) => void + createEditorWindow: () => void, + createSourceSelectorWindow: () => BrowserWindow, + _getMainWindow: () => BrowserWindow | null, + getSourceSelectorWindow: () => BrowserWindow | null, + onRecordingStateChange?: (recording: boolean, sourceName: string) => void, ) { - ipcMain.handle('get-sources', async (_, opts) => { - const sources = await desktopCapturer.getSources(opts) - return sources.map(source => ({ - id: source.id, - name: source.name, - display_id: source.display_id, - thumbnail: source.thumbnail ? source.thumbnail.toDataURL() : null, - appIcon: source.appIcon ? source.appIcon.toDataURL() : null - })) - }) - - ipcMain.handle('select-source', (_, source) => { - selectedSource = source - const sourceSelectorWin = getSourceSelectorWindow() - if (sourceSelectorWin) { - sourceSelectorWin.close() - } - return selectedSource - }) - - ipcMain.handle('get-selected-source', () => { - return selectedSource - }) - - ipcMain.handle('open-source-selector', () => { - const sourceSelectorWin = getSourceSelectorWindow() - if (sourceSelectorWin) { - sourceSelectorWin.focus() - return - } - createSourceSelectorWindow() - }) - - ipcMain.handle('switch-to-editor', () => { - const mainWin = getMainWindow() - if (mainWin) { - mainWin.close() - } - createEditorWindow() - }) - - ipcMain.handle('start-mouse-tracking', () => { - return startMouseTracking() - }) - - ipcMain.handle('stop-mouse-tracking', () => { - return stopMouseTracking() - }) - - ipcMain.handle('store-recorded-video', async (_, videoData: ArrayBuffer, fileName: string) => { - try { - const videoPath = path.join(RECORDINGS_DIR, fileName) - await fs.writeFile(videoPath, Buffer.from(videoData)) - - return { - success: true, - path: videoPath, - message: 'Video stored successfully' - } - } catch (error) { - console.error('Failed to store video:', error) - return { - success: false, - message: 'Failed to store video', - error: String(error) - } - } - }) - - ipcMain.handle('store-mouse-tracking-data', async (_, fileName: string) => { - try { - const data = getTrackingData() - - if (data.length === 0) { - return { success: false, message: 'No tracking data to save' } - } - - const trackingPath = path.join(RECORDINGS_DIR, fileName) - await fs.writeFile(trackingPath, JSON.stringify(data, null, 2), 'utf-8') - - return { - success: true, - path: trackingPath, - eventCount: data.length, - message: 'Mouse tracking data stored successfully' - } - } catch (error) { - console.error('Failed to store mouse tracking data:', error) - return { - success: false, - message: 'Failed to store mouse tracking data', - error: String(error) - } - } - }) - - ipcMain.handle('get-recorded-video-path', async () => { - try { - const files = await fs.readdir(RECORDINGS_DIR) - const videoFiles = files.filter(file => file.endsWith('.webm')) - - if (videoFiles.length === 0) { - return { success: false, message: 'No recorded video found' } - } - - const latestVideo = videoFiles.sort().reverse()[0] - const videoPath = path.join(RECORDINGS_DIR, latestVideo) - - return { success: true, path: videoPath } - } catch (error) { - console.error('Failed to get video path:', error) - return { success: false, message: 'Failed to get video path', error: String(error) } - } - }) - - ipcMain.handle('set-recording-state', (_, recording: boolean) => { - const source = selectedSource || { name: 'Screen' } - if (onRecordingStateChange) { - onRecordingStateChange(recording, source.name) - } - }) - - ipcMain.handle('open-external-url', async (_, url: string) => { - try { - await shell.openExternal(url) - return { success: true } - } catch (error) { - console.error('Failed to open URL:', error) - return { success: false, error: String(error) } - } - }) - - // Return base path for assets so renderer can resolve file:// paths in production - ipcMain.handle('get-asset-base-path', () => { - try { - if (app.isPackaged) { - return path.join(process.resourcesPath, 'assets') - } - return path.join(app.getAppPath(), 'public', 'assets') - } catch (err) { - console.error('Failed to resolve asset base path:', err) - return null - } - }) - - ipcMain.handle('save-exported-video', async (_, videoData: ArrayBuffer, fileName: string) => { - try { - const downloadsPath = app.getPath('downloads') - const videoPath = path.join(downloadsPath, fileName) - await fs.writeFile(videoPath, Buffer.from(videoData)) - - return { - success: true, - path: videoPath, - message: 'Video exported successfully' - } - } catch (error) { - console.error('Failed to save exported video:', error) - return { - success: false, - message: 'Failed to save exported video', - error: String(error) - } - } - }) + registerSourceHandlers({ + createEditorWindow, + createSourceSelectorWindow, + getSourceSelectorWindow, + }); + registerRecordingHandlers(onRecordingStateChange); + registerPermissionHandlers(); + registerAnnouncementHandlers(); + registerAssetHandlers(); + registerExportHandlers(); + registerCaptionHandlers(); + 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..04bc577c5 --- /dev/null +++ b/electron/ipc/paths/binaries.ts @@ -0,0 +1,263 @@ +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); + 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/manager.test.ts b/electron/ipc/project/manager.test.ts new file mode 100644 index 000000000..508730924 --- /dev/null +++ b/electron/ipc/project/manager.test.ts @@ -0,0 +1,232 @@ +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"); + if (tempRoot) { + await fs.rm(tempRoot, { recursive: true, force: true }); + } + }); + + it("rejects existing media files outside allowed directories until they are approved", async () => { + const downloadsPath = path.join(tempRoot, "Downloads"); + const exportPath = path.join(downloadsPath, "export-test.mp4"); + await fs.mkdir(downloadsPath, { recursive: true }); + await fs.writeFile(exportPath, "test-video"); + + const { isAllowedLocalMediaPath, rememberApprovedLocalReadPath } = await import( + "./manager" + ); + + await expect(isAllowedLocalMediaPath(exportPath)).resolves.toBe(false); + + await rememberApprovedLocalReadPath(exportPath); + + await expect(isAllowedLocalMediaPath(exportPath)).resolves.toBe(true); + }); + + it("rejects missing media files outside the allowed directories", async () => { + const missingPath = path.join(tempRoot, "Downloads", "missing.mp4"); + const { isAllowedLocalMediaPath } = await import("./manager"); + + await expect(isAllowedLocalMediaPath(missingPath)).resolves.toBe(false); + }); + + it("allows approved media paths before the file exists", async () => { + const pendingExportPath = path.join(tempRoot, "Downloads", "pending-export.mp4"); + const { isAllowedLocalMediaPath, rememberApprovedLocalReadPath } = await import( + "./manager" + ); + + await rememberApprovedLocalReadPath(pendingExportPath); + + await expect(isAllowedLocalMediaPath(pendingExportPath)).resolves.toBe(true); + }); + + it("approves media-server access for approved external files resolved through the URL policy", async () => { + const downloadsPath = path.join(tempRoot, "Downloads"); + const videoPath = path.join(downloadsPath, "external-video.mp4"); + await fs.mkdir(downloadsPath, { recursive: true }); + await fs.writeFile(videoPath, "test-video"); + const resolvedVideoPath = await fs.realpath(videoPath); + + const { resolveApprovedLocalMediaPath, rememberApprovedLocalReadPath } = await import( + "./manager" + ); + const { isAllowedMediaPath } = await import("../../mediaServer"); + + // Unapproved external paths are rejected before they ever reach the media server. + expect(isAllowedMediaPath(videoPath)).toBe(false); + await expect(resolveApprovedLocalMediaPath(videoPath)).resolves.toBeNull(); + + // Once the user opts in (via dialog/export/etc.) the path is approved. + await rememberApprovedLocalReadPath(videoPath); + + await expect(resolveApprovedLocalMediaPath(videoPath)).resolves.toBe(resolvedVideoPath); + expect(isAllowedMediaPath(videoPath)).toBe(true); + }); + + it("rejects existing non-media files when resolving local media URLs", async () => { + const downloadsPath = path.join(tempRoot, "Downloads"); + const textPath = path.join(downloadsPath, "notes.txt"); + await fs.mkdir(downloadsPath, { recursive: true }); + await fs.writeFile(textPath, "not media"); + + const { resolveApprovedLocalMediaPath } = await import("./manager"); + const { isAllowedMediaPath } = await import("../../mediaServer"); + + await expect(resolveApprovedLocalMediaPath(textPath)).resolves.toBeNull(); + expect(isAllowedMediaPath(textPath)).toBe(false); + }); + + it("rejects symlinks under allowed prefixes that point outside the allowlist", async () => { + const outsideTarget = path.join(tempRoot, "outside-secret.mp4"); + const symlinkInsideUserData = path.join(userDataPath, "shortcut-to-secret.mp4"); + await fs.writeFile(outsideTarget, "secret-bytes"); + + try { + await fs.symlink(outsideTarget, symlinkInsideUserData); + } catch (error) { + // Windows requires Developer Mode or admin to create file symlinks. If + // we can't create one, the bypass we're guarding against also can't be + // crafted on this machine, so skipping is safe. + if ((error as NodeJS.ErrnoException).code === "EPERM") { + return; + } + throw error; + } + + const { isAllowedLocalMediaPath, resolveApprovedLocalMediaPath } = await import( + "./manager" + ); + + await expect(isAllowedLocalMediaPath(symlinkInsideUserData)).resolves.toBe(false); + await expect(resolveApprovedLocalMediaPath(symlinkInsideUserData)).resolves.toBeNull(); + }); + + it("preserves an existing project thumbnail when no replacement is provided", async () => { + const projectPath = path.join(tempRoot, "Projects", "demo.recordly"); + const thumbnailDataUrl = `data:image/png;base64,${Buffer.from("png-thumbnail").toString("base64")}`; + await fs.mkdir(path.dirname(projectPath), { recursive: true }); + + const { getProjectThumbnailPath, saveProjectThumbnail } = await import("./manager"); + const thumbnailPath = getProjectThumbnailPath(projectPath); + + await saveProjectThumbnail(projectPath, thumbnailDataUrl); + await saveProjectThumbnail(projectPath, undefined); + + await expect(fs.readFile(thumbnailPath, "utf8")).resolves.toBe("png-thumbnail"); + }); + + it("loads project files that start with a UTF-8 byte order mark", async () => { + const videoPath = path.join(tempPath, "recording.mp4"); + const projectPath = path.join(tempPath, "recording.recordly"); + await fs.writeFile(videoPath, "test-video"); + await fs.writeFile( + projectPath, + `\uFEFF${JSON.stringify({ + version: 1, + videoPath, + editor: {}, + })}`, + "utf-8", + ); + + const { loadProjectFromPath } = await import("./manager"); + + const result = await loadProjectFromPath(projectPath); + expect(result.success).toBe(true); + expect(result.path).toBe(projectPath); + expect(result.project).toMatchObject({ videoPath }); + }); + + it("rejects invalid project payloads before approving media paths", async () => { + const downloadsPath = path.join(tempRoot, "Downloads"); + const videoPath = path.join(downloadsPath, "recording.mp4"); + const projectPath = path.join(tempPath, "invalid.recordly"); + await fs.mkdir(downloadsPath, { recursive: true }); + await fs.writeFile(videoPath, "test-video"); + await fs.writeFile( + projectPath, + JSON.stringify({ + videoPath, + editor: {}, + }), + "utf-8", + ); + + const { loadProjectFromPath, resolveApprovedLocalMediaPath } = await import("./manager"); + + const result = await loadProjectFromPath(projectPath); + expect(result.success).toBe(false); + expect(result.message).toBe("Invalid project file format"); + await expect(resolveApprovedLocalMediaPath(videoPath)).resolves.toBeNull(); + }); + + it("approves editor audioRegions audioPath entries when loading a project", async () => { + const downloadsPath = path.join(tempRoot, "Downloads"); + const videoPath = path.join(tempPath, "recording.mp4"); + const audioPath = path.join(downloadsPath, "music.ogg"); + const projectPath = path.join(tempPath, "recording.recordly"); + await fs.mkdir(downloadsPath, { recursive: true }); + await fs.writeFile(videoPath, "test-video"); + await fs.writeFile(audioPath, "test-audio"); + await fs.writeFile( + projectPath, + JSON.stringify({ + version: 1, + videoPath, + editor: { + audioRegions: [{ id: "a1", startMs: 0, endMs: 1000, audioPath, volume: 1 }], + }, + }), + "utf-8", + ); + + const { loadProjectFromPath, resolveApprovedLocalMediaPath } = await import("./manager"); + const resolvedAudioPath = await fs.realpath(audioPath); + + const result = await loadProjectFromPath(projectPath); + expect(result.success).toBe(true); + await expect(resolveApprovedLocalMediaPath(audioPath)).resolves.toBe(resolvedAudioPath); + }); +}); diff --git a/electron/ipc/project/manager.ts b/electron/ipc/project/manager.ts new file mode 100644 index 000000000..7828fa8b6 --- /dev/null +++ b/electron/ipc/project/manager.ts @@ -0,0 +1,501 @@ +import { existsSync, constants as fsConstants, realpathSync } from "node:fs"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { app } from "electron"; +import { RECORDINGS_DIR, USER_DATA_PATH } from "../../appPaths"; +import { isSupportedLocalMediaPath } from "../../mediaTypes"; +import { + LEGACY_PROJECT_FILE_EXTENSIONS, + MAX_RECENT_PROJECTS, + PROJECT_FILE_EXTENSION, + PROJECT_THUMBNAIL_SUFFIX, + PROJECTS_DIRECTORY_NAME, + RECENT_PROJECTS_FILE, + RECORDINGS_SETTINGS_FILE, +} from "../constants"; +import { + approvedLocalReadPaths, + currentProjectPath, + setCurrentProjectPath, + setCurrentRecordingSession, + setCurrentVideoPath, + setCustomRecordingsDir, + setRecordingsDirLoaded, +} from "../state"; +import type { ProjectLibraryEntry, RecordingSessionData } from "../types"; +import { + getRecordingsDir, + normalizePath, + normalizeVideoSourcePath, + parseJsonWithByteOrderMark, +} from "../utils"; + +export { normalizePath, normalizeVideoSourcePath }; + +export function getAssetRootPath() { + if (app.isPackaged) { + return path.join(process.resourcesPath, "assets"); + } + + return path.join(app.getAppPath(), "public"); +} + +export function isPathInsideDirectory(candidatePath: string, directoryPath: string) { + const normalizedCandidatePath = normalizePath(candidatePath); + const normalizedDirectoryPath = normalizePath(directoryPath); + return ( + normalizedCandidatePath === normalizedDirectoryPath || + normalizedCandidatePath.startsWith(`${normalizedDirectoryPath}${path.sep}`) + ); +} + +export function isAllowedLocalReadPath(candidatePath: string) { + const allowedPrefixes = [ + RECORDINGS_DIR, + USER_DATA_PATH, + getAssetRootPath(), + app.getPath("temp"), + ]; + const normalizedCandidatePath = normalizePath(candidatePath); + + // Canonicalize so a symlink placed under an allowed prefix can't smuggle in a + // target that lives outside it. realpathSync throws when the path doesn't + // exist yet (e.g. a pending export approved before the file is written) — in + // that case fall back to the lexical path, which can only succeed via the + // approvedLocalReadPaths check below since no symlink target exists yet. + let canonicalCandidatePath = normalizedCandidatePath; + try { + canonicalCandidatePath = normalizePath(realpathSync(normalizedCandidatePath)); + } catch { + // File may not exist yet; keep the lexical path. + } + + // Security: only allow paths under app-managed directories or paths the user + // has explicitly opted into (recording session sources, files chosen via + // dialog, app-produced exports). The lexical path must satisfy the policy + // AND the canonical (real) path must satisfy it too, so a symlink under an + // allowed prefix that points outside the allowlist is rejected. Previously + // this returned true for any existing file, which made the allowlist a no-op + // for read-local-file and the local media URL handler. + const lexicalAllowed = + allowedPrefixes.some((prefix) => isPathInsideDirectory(normalizedCandidatePath, prefix)) || + approvedLocalReadPaths.has(normalizedCandidatePath); + if (!lexicalAllowed) { + return false; + } + + if (canonicalCandidatePath === normalizedCandidatePath) { + return true; + } + + return ( + allowedPrefixes.some((prefix) => isPathInsideDirectory(canonicalCandidatePath, prefix)) || + approvedLocalReadPaths.has(canonicalCandidatePath) + ); +} + +// Keep loopback media-server access restricted to allowlisted or explicitly +// approved files. Direct renderer-side read-local-file calls can be more +// permissive, but URL-based serving must stay scoped so arbitrary paths do not +// become fetchable inside the app. +export async function isAllowedLocalMediaPath(candidatePath: string) { + const normalizedCandidatePath = normalizePath(candidatePath); + return isAllowedLocalReadPath(normalizedCandidatePath); +} + +async function collectApprovedLocalReadPaths(filePath?: string | null): Promise { + const normalizedPath = normalizeVideoSourcePath(filePath); + if (!normalizedPath) { + return []; + } + + const approvedPaths = [normalizePath(normalizedPath)]; + + try { + const realPath = await fs.realpath(approvedPaths[0]); + const normalizedRealPath = normalizePath(realPath); + if (!approvedPaths.includes(normalizedRealPath)) { + approvedPaths.push(normalizedRealPath); + } + } catch { + // Ignore missing files; the eventual read will surface the real error. + } + + return approvedPaths; +} + +export async function rememberApprovedLocalReadPath(filePath?: string | null) { + const normalizedPath = normalizeVideoSourcePath(filePath); + if (!normalizedPath) { + return; + } + + const approvedPaths = await collectApprovedLocalReadPaths(normalizedPath); + for (const approvedPath of approvedPaths) { + approvedLocalReadPaths.add(approvedPath); + } +} + +export async function resolveApprovedLocalMediaPath(candidatePath: string): Promise { + const normalizedCandidatePath = normalizePath(candidatePath); + const realPath = await fs.realpath(normalizedCandidatePath).catch(() => null); + + if (!realPath) { + return null; + } + + const stat = await fs.stat(realPath).catch(() => null); + if (!stat?.isFile() || !isSupportedLocalMediaPath(realPath)) { + return null; + } + + if (!(await isAllowedLocalMediaPath(realPath))) { + return null; + } + + await rememberApprovedLocalReadPath(candidatePath); + return realPath; +} + +export async function replaceApprovedSessionLocalReadPaths( + filePaths: Array, +) { + const nextApprovedPaths = new Set(); + const approvedPathLists = await Promise.all( + filePaths.map((filePath) => collectApprovedLocalReadPaths(filePath)), + ); + + for (const approvedPathList of approvedPathLists) { + for (const approvedPath of approvedPathList) { + nextApprovedPaths.add(approvedPath); + } + } + + approvedLocalReadPaths.clear(); + for (const approvedPath of nextApprovedPaths) { + approvedLocalReadPaths.add(approvedPath); + } +} + +export async function resolveProjectMediaSources( + project: unknown, +): Promise< + | { success: true; videoPath: string; webcamPath: string | null } + | { success: false; message: string } +> { + if (!project || typeof project !== "object") { + return { success: false, message: "Invalid project file format" }; + } + + const rawVideoPath = (project as { videoPath?: unknown }).videoPath; + if (typeof rawVideoPath !== "string") { + return { success: false, message: "Project file is missing a video path" }; + } + + const normalizedVideoPath = normalizeVideoSourcePath(rawVideoPath); + if (!normalizedVideoPath) { + return { success: false, message: "Project file is missing a valid video path" }; + } + + try { + await fs.access(normalizedVideoPath, fsConstants.F_OK); + } catch { + return { + success: false, + message: `Project video file not found: ${normalizedVideoPath}`, + }; + } + + const rawWebcamPath = + typeof (project as { editor?: { webcam?: { sourcePath?: unknown } } }).editor?.webcam + ?.sourcePath === "string" + ? ((project as { editor?: { webcam?: { sourcePath?: string } } }).editor?.webcam + ?.sourcePath ?? null) + : null; + const normalizedWebcamPath = normalizeVideoSourcePath(rawWebcamPath); + + if (!normalizedWebcamPath) { + return { + success: true, + videoPath: normalizedVideoPath, + webcamPath: null, + }; + } + + try { + await fs.access(normalizedWebcamPath, fsConstants.F_OK); + return { + success: true, + videoPath: normalizedVideoPath, + webcamPath: normalizedWebcamPath, + }; + } catch { + return { + success: true, + videoPath: normalizedVideoPath, + webcamPath: null, + }; + } +} + +export async function getProjectsDir() { + const projectsDir = path.join(await getRecordingsDir(), PROJECTS_DIRECTORY_NAME); + await fs.mkdir(projectsDir, { recursive: true }); + return projectsDir; +} + +export async function persistRecordingsDirectorySetting(nextDir: string) { + setCustomRecordingsDir(path.resolve(nextDir)); + setRecordingsDirLoaded(true); + await fs.writeFile( + RECORDINGS_SETTINGS_FILE, + JSON.stringify({ recordingsDir: path.resolve(nextDir) }, null, 2), + "utf-8", + ); +} + +export function hasProjectFileExtension(filePath: string) { + const extension = path.extname(filePath).replace(/^\./, "").toLowerCase(); + return [PROJECT_FILE_EXTENSION, ...LEGACY_PROJECT_FILE_EXTENSIONS].includes(extension); +} + +export function getProjectThumbnailPath(projectPath: string) { + return `${projectPath}${PROJECT_THUMBNAIL_SUFFIX}`; +} + +export async function saveProjectThumbnail(projectPath: string, thumbnailDataUrl?: string | null) { + const thumbnailPath = getProjectThumbnailPath(projectPath); + if (thumbnailDataUrl === undefined) { + return existsSync(thumbnailPath) ? thumbnailPath : null; + } + + if (!thumbnailDataUrl) { + await fs.rm(thumbnailPath, { force: true }).catch(() => undefined); + return null; + } + + const match = thumbnailDataUrl.match(/^data:image\/png;base64,(.+)$/); + if (!match) { + throw new Error("Project thumbnail must be a PNG data URL."); + } + + await fs.writeFile(thumbnailPath, Buffer.from(match[1], "base64")); + return thumbnailPath; +} + +export async function loadRecentProjectPaths() { + try { + const content = await fs.readFile(RECENT_PROJECTS_FILE, "utf-8"); + const parsed = parseJsonWithByteOrderMark<{ paths?: unknown }>(content); + return Array.isArray(parsed.paths) + ? parsed.paths.filter( + (value): value is string => + typeof value === "string" && value.trim().length > 0, + ) + : []; + } catch { + return []; + } +} + +export async function saveRecentProjectPaths(paths: string[]) { + const normalizedPaths = Array.from(new Set(paths.map((value) => normalizePath(value)))).slice( + 0, + MAX_RECENT_PROJECTS, + ); + await fs.writeFile( + RECENT_PROJECTS_FILE, + JSON.stringify({ paths: normalizedPaths }, null, 2), + "utf-8", + ); +} + +export async function rememberRecentProject(projectPath: string) { + if (!hasProjectFileExtension(projectPath)) { + return; + } + + const existingPaths = await loadRecentProjectPaths(); + await saveRecentProjectPaths([projectPath, ...existingPaths]); +} + +export async function buildProjectLibraryEntry( + projectPath: string, + projectsDir: string, +): Promise { + try { + const normalizedPath = normalizePath(projectPath); + if (!hasProjectFileExtension(normalizedPath)) { + return null; + } + + const stats = await fs.stat(normalizedPath); + if (!stats.isFile()) { + return null; + } + + const thumbnailPath = getProjectThumbnailPath(normalizedPath); + const thumbnailExists = await fs + .access(thumbnailPath, fsConstants.R_OK) + .then(() => true) + .catch(() => false); + + return { + path: normalizedPath, + name: path + .basename(normalizedPath) + .replace( + new RegExp( + `\\.(${[PROJECT_FILE_EXTENSION, ...LEGACY_PROJECT_FILE_EXTENSIONS].join("|")})$`, + "i", + ), + "", + ), + updatedAt: stats.mtimeMs, + thumbnailPath: thumbnailExists ? thumbnailPath : null, + isCurrent: Boolean( + currentProjectPath && normalizePath(currentProjectPath) === normalizedPath, + ), + isInProjectsDirectory: path.dirname(normalizedPath) === normalizePath(projectsDir), + }; + } catch { + return null; + } +} + +export async function listProjectLibraryEntries() { + const projectsDir = await getProjectsDir(); + const projectPaths: string[] = []; + + try { + const entries = await fs.readdir(projectsDir, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isFile()) { + continue; + } + + const entryPath = path.join(projectsDir, entry.name); + if (hasProjectFileExtension(entryPath)) { + projectPaths.push(entryPath); + } + } + } catch { + // Ignore directory read failures and fall back to recent files. + } + + const recentProjectPaths = await loadRecentProjectPaths(); + const candidatePaths = Array.from(new Set([...projectPaths, ...recentProjectPaths])); + const entries = ( + await Promise.all( + candidatePaths.map((candidatePath) => + buildProjectLibraryEntry(candidatePath, projectsDir), + ), + ) + ) + .filter((entry): entry is ProjectLibraryEntry => entry != null) + .sort((left, right) => right.updatedAt - left.updatedAt); + + await saveRecentProjectPaths(entries.map((entry) => entry.path)); + + return { + projectsDir, + entries, + }; +} + +function isLoadableProjectData(projectData: unknown) { + if (!projectData || typeof projectData !== "object" || Array.isArray(projectData)) { + return false; + } + + const candidate = projectData as { + version?: unknown; + projectId?: unknown; + videoPath?: unknown; + editor?: unknown; + }; + + return ( + typeof candidate.version === "number" && + (candidate.projectId === undefined || typeof candidate.projectId === "string") && + typeof candidate.videoPath === "string" && + candidate.videoPath.trim().length > 0 && + candidate.editor != null && + typeof candidate.editor === "object" && + !Array.isArray(candidate.editor) + ); +} + +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/session.ts b/electron/ipc/project/session.ts new file mode 100644 index 000000000..d5f83a839 --- /dev/null +++ b/electron/ipc/project/session.ts @@ -0,0 +1,141 @@ +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/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..9f7ddeb08 --- /dev/null +++ b/electron/ipc/recording/diagnostics.test.ts @@ -0,0 +1,410 @@ +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("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..985209f22 --- /dev/null +++ b/electron/ipc/recording/diagnostics.ts @@ -0,0 +1,614 @@ +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; +} + +export async function getCompanionAudioFallbackInfo(videoPath: string) { + const companionCandidates = await getUsableCompanionAudioCandidates(videoPath); + if (companionCandidates.length === 0) { + return { paths: [], startDelayMsByPath: {} }; + } + + let paths: string[]; + 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) { + paths = [videoPath]; + } 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/mac.ts b/electron/ipc/recording/mac.ts new file mode 100644 index 000000000..57a9e6881 --- /dev/null +++ b/electron/ipc/recording/mac.ts @@ -0,0 +1,352 @@ +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 { isAutoRecordingPath, moveFileWithOverwrite } from "../utils"; +import { + getFileSizeIfPresent, + recordNativeCaptureDiagnostics, + validateRecordedVideo, +} from "./diagnostics"; +import { emitRecordingInterrupted } from "./events"; +import { getFinalMacCompanionAudioPath } from "./macCompanionAudio"; +import { pruneAutoRecordings } from "./prune"; + +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 (isAutoRecordingPath(videoPath)) { + await pruneAutoRecordings([videoPath]); + } + + 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/prune.test.ts b/electron/ipc/recording/prune.test.ts new file mode 100644 index 000000000..8c1c66839 --- /dev/null +++ b/electron/ipc/recording/prune.test.ts @@ -0,0 +1,130 @@ +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("pruneAutoRecordings", () => { + 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-prune-")); + appDataPath = path.join(tempRoot, "AppData"); + userDataPath = path.join(tempRoot, "UserData"); + tempPath = path.join(tempRoot, "Temp"); + appPath = path.join(tempRoot, "App"); + + await Promise.all( + [appDataPath, userDataPath, tempPath, appPath].map((dirPath) => + fs.mkdir(dirPath, { recursive: true }), + ), + ); + + vi.resetModules(); + vi.doMock("electron", () => ({ + app: { + isPackaged: false, + getAppPath: () => appPath, + getPath: (name: string) => { + if (name === "appData") return appDataPath; + if (name === "userData") return userDataPath; + if (name === "temp") return tempPath; + return tempRoot; + }, + setPath: () => undefined, + }, + })); + }); + + afterEach(async () => { + vi.resetModules(); + vi.doUnmock("electron"); + if (tempRoot) { + await fs.rm(tempRoot, { recursive: true, force: true }); + } + }); + + it("preserves recordings referenced by saved projects in the Projects directory", async () => { + const { getRecordingsDir } = await import("../utils"); + const { PROJECTS_DIRECTORY_NAME, PROJECT_FILE_EXTENSION } = await import("../constants"); + const { pruneAutoRecordings } = await import("./prune"); + + const recordingsDir = await getRecordingsDir(); + const projectsDir = path.join(recordingsDir, PROJECTS_DIRECTORY_NAME); + await fs.mkdir(projectsDir, { recursive: true }); + + const recordingPaths: string[] = []; + for (let index = 0; index < 23; index += 1) { + const recordingPath = path.join(recordingsDir, `recording-${index}.mp4`); + recordingPaths.push(recordingPath); + await fs.writeFile(recordingPath, `video-${index}`); + const timestamp = new Date(Date.now() - index * 60_000); + await fs.utimes(recordingPath, timestamp, timestamp); + } + + const protectedVideoRecordingPath = recordingPaths.at(-3); + const protectedWebcamRecordingPath = recordingPaths.at(-2); + const prunableRecordingPath = recordingPaths.at(-1); + + await fs.writeFile( + path.join(projectsDir, `saved-project-video.${PROJECT_FILE_EXTENSION}`), + JSON.stringify( + { + videoPath: protectedVideoRecordingPath, + }, + null, + 2, + ), + "utf-8", + ); + + await fs.writeFile( + path.join(projectsDir, `saved-project-webcam.${PROJECT_FILE_EXTENSION}`), + JSON.stringify( + { + editor: { + webcam: { + sourcePath: protectedWebcamRecordingPath, + }, + }, + }, + null, + 2, + ), + "utf-8", + ); + + await pruneAutoRecordings(); + + await expect(fs.access(protectedVideoRecordingPath!)).resolves.toBeUndefined(); + await expect(fs.access(protectedWebcamRecordingPath!)).resolves.toBeUndefined(); + await expect(fs.access(prunableRecordingPath!)).rejects.toThrow(); + }); + + it("aborts pruning when a saved project cannot be parsed", async () => { + const { getRecordingsDir } = await import("../utils"); + const { PROJECTS_DIRECTORY_NAME, PROJECT_FILE_EXTENSION } = await import("../constants"); + const { pruneAutoRecordings } = await import("./prune"); + + const recordingsDir = await getRecordingsDir(); + const projectsDir = path.join(recordingsDir, PROJECTS_DIRECTORY_NAME); + await fs.mkdir(projectsDir, { recursive: true }); + + const recordingPath = path.join(recordingsDir, "recording-stale.mp4"); + await fs.writeFile(recordingPath, "video"); + const staleTimestamp = new Date(Date.now() - 48 * 60 * 60 * 1_000); + await fs.utimes(recordingPath, staleTimestamp, staleTimestamp); + + await fs.writeFile( + path.join(projectsDir, `broken-project.${PROJECT_FILE_EXTENSION}`), + "{ invalid json", + "utf-8", + ); + + await expect(pruneAutoRecordings()).rejects.toThrow(); + await expect(fs.access(recordingPath)).resolves.toBeUndefined(); + }); +}); diff --git a/electron/ipc/recording/prune.ts b/electron/ipc/recording/prune.ts new file mode 100644 index 000000000..29787e8e2 --- /dev/null +++ b/electron/ipc/recording/prune.ts @@ -0,0 +1,190 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { + AUTO_RECORDING_MAX_AGE_MS, + AUTO_RECORDING_RETENTION_COUNT, + COMPANION_AUDIO_LAYOUTS, + LEGACY_PROJECT_FILE_EXTENSIONS, + PROJECT_FILE_EXTENSION, + PROJECTS_DIRECTORY_NAME, +} from "../constants"; +import { currentVideoPath } from "../state"; +import { + getRecordingsDir, + getTelemetryPathForVideo, + isAutoRecordingPath, + normalizePath, + normalizeVideoSourcePath, + parseJsonWithByteOrderMark, +} from "../utils"; + +export async function hasSiblingProjectFile(videoPath: string) { + const baseName = path.basename(videoPath, path.extname(videoPath)); + const candidateExtensions = [PROJECT_FILE_EXTENSION, ...LEGACY_PROJECT_FILE_EXTENSIONS]; + + for (const extension of candidateExtensions) { + const projectPath = path.join(path.dirname(videoPath), `${baseName}.${extension}`); + + try { + await fs.access(projectPath); + return true; + } catch { + continue; + } + } + + return false; +} + +export { isAutoRecordingPath }; + +async function loadSavedProjectMediaPaths() { + const recordingsDir = await getRecordingsDir(); + const projectsDir = path.join(recordingsDir, PROJECTS_DIRECTORY_NAME); + const protectedPaths = new Set(); + const candidateExtensions = new Set([ + PROJECT_FILE_EXTENSION, + ...LEGACY_PROJECT_FILE_EXTENSIONS, + ]); + + let projectEntries: Array<{ isFile(): boolean; name: string }>; + try { + projectEntries = await fs.readdir(projectsDir, { withFileTypes: true }); + } catch (error) { + const code = + typeof error === "object" && error !== null && "code" in error ? error.code : undefined; + if (code === "ENOENT") { + return protectedPaths; + } + + throw error; + } + + await Promise.all( + projectEntries + .filter((entry) => { + if (!entry.isFile()) { + return false; + } + + const extension = path.extname(entry.name).replace(/^\./, "").toLowerCase(); + return candidateExtensions.has(extension); + }) + .map(async (entry) => { + const projectPath = path.join(projectsDir, entry.name); + let rawProject: { + videoPath?: unknown; + editor?: { webcam?: { sourcePath?: unknown } }; + }; + try { + rawProject = parseJsonWithByteOrderMark<{ + videoPath?: unknown; + editor?: { webcam?: { sourcePath?: unknown } }; + }>(await fs.readFile(projectPath, "utf-8")); + } catch (error) { + console.warn( + "[prune] Aborting recording prune because a saved project is unreadable", + { + projectPath, + error, + }, + ); + throw error; + } + const candidatePaths = [ + rawProject.videoPath, + rawProject.editor?.webcam?.sourcePath, + ]; + + for (const candidatePath of candidatePaths) { + if (typeof candidatePath !== "string" || candidatePath.trim().length === 0) { + continue; + } + + const normalizedCandidatePath = normalizePath( + normalizeVideoSourcePath(candidatePath) ?? candidatePath, + ); + protectedPaths.add(normalizedCandidatePath); + try { + protectedPaths.add(await fs.realpath(normalizedCandidatePath)); + } catch { + // Ignore missing project media; project loading already surfaces that error. + } + } + }), + ); + + return protectedPaths; +} + +export async function pruneAutoRecordings(exemptPaths: string[] = []) { + const recordingsDir = await getRecordingsDir(); + await fs.mkdir(recordingsDir, { recursive: true }); + const protectedProjectMediaPaths = await loadSavedProjectMediaPaths(); + const exempt = new Set( + [currentVideoPath, ...exemptPaths] + .filter((value): value is string => Boolean(value)) + .map((value) => normalizePath(value)), + ); + + const entries = await fs.readdir(recordingsDir, { withFileTypes: true }); + const autoRecordingStats = await Promise.all( + entries + .filter((entry) => entry.isFile() && /^recording-.*\.(mp4|mov|webm)$/i.test(entry.name)) + .map(async (entry) => { + const filePath = path.join(recordingsDir, entry.name); + const stats = await fs.stat(filePath); + return { filePath, stats }; + }), + ); + + const sorted = autoRecordingStats.sort( + (left, right) => right.stats.mtimeMs - left.stats.mtimeMs, + ); + const now = Date.now(); + + for (const [index, entry] of sorted.entries()) { + const normalizedFilePath = normalizePath(entry.filePath); + if (exempt.has(normalizedFilePath)) { + continue; + } + + if (await hasSiblingProjectFile(entry.filePath)) { + continue; + } + + const resolvedFilePath = await fs.realpath(entry.filePath).catch(() => normalizedFilePath); + if ( + protectedProjectMediaPaths.has(normalizedFilePath) || + protectedProjectMediaPaths.has(resolvedFilePath) + ) { + continue; + } + + const tooOld = now - entry.stats.mtimeMs > AUTO_RECORDING_MAX_AGE_MS; + const overLimit = index >= AUTO_RECORDING_RETENTION_COUNT; + if (!tooOld && !overLimit) { + continue; + } + + try { + await fs.rm(entry.filePath, { force: true }); + await fs.rm(getTelemetryPathForVideo(entry.filePath), { force: true }); + // Clean up companion audio files left from recording (macOS .m4a, Windows .wav) + const base = entry.filePath.replace(/\.(mp4|mov|webm)$/i, ""); + const companionSuffixes = Array.from( + new Set( + COMPANION_AUDIO_LAYOUTS.flatMap((layout) => [ + layout.systemSuffix, + layout.micSuffix, + ]), + ), + ); + for (const suffix of companionSuffixes) { + await fs.rm(base + suffix, { force: true }).catch(() => undefined); + } + } catch (error) { + console.warn("Failed to prune old auto recording:", entry.filePath, error); + } + } +} 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/windows.test.ts b/electron/ipc/recording/windows.test.ts new file mode 100644 index 000000000..73a1e0c24 --- /dev/null +++ b/electron/ipc/recording/windows.test.ts @@ -0,0 +1,102 @@ +import { EventEmitter } from "node:events"; +import { PassThrough } from "node:stream"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { setWindowsCaptureOutputBuffer, setWindowsCaptureTargetPath } from "../state"; +import { waitForWindowsCaptureStop } from "./windows"; + +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); + }); +}); 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..e1c567522 --- /dev/null +++ b/electron/ipc/recording/windowsFallbacks.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from "vitest"; + +import { + shouldStartWindowsBrowserMicrophoneFallback, + shouldUseWindowsBrowserMicrophoneFallback, + WINDOWS_MIC_CAPTURE_MODE_ENV, +} from "./windowsFallbacks"; + +describe("shouldUseWindowsBrowserMicrophoneFallback", () => { + it("defaults Windows microphone capture to the browser fallback path", () => { + expect(shouldStartWindowsBrowserMicrophoneFallback({ capturesMicrophone: true }, {})).toBe( + true, + ); + }); + + it("can be forced before native capture starts", () => { + expect( + shouldStartWindowsBrowserMicrophoneFallback( + { capturesMicrophone: true }, + { [WINDOWS_MIC_CAPTURE_MODE_ENV]: "browser" }, + ), + ).toBe(true); + }); + + it("allows lab runs to force native WASAPI microphone capture", () => { + 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("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 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 when lab mode forces native capture", () => { + expect( + shouldUseWindowsBrowserMicrophoneFallback( + "Recording started", + { capturesMicrophone: true }, + { [WINDOWS_MIC_CAPTURE_MODE_ENV]: "native" }, + ), + ).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..8a3f581c7 --- /dev/null +++ b/electron/ipc/recording/windowsFallbacks.ts @@ -0,0 +1,34 @@ +const WINDOWS_MIC_CAPTURE_INIT_WARNING = "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(); + if (mode === "native" || mode === "wasapi") { + return false; + } + + if (!mode) { + return true; + } + + 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) || + captureOutput.includes(WINDOWS_MIC_CAPTURE_INIT_WARNING)) + ); +} 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..fae8d708d --- /dev/null +++ b/electron/ipc/register/assets.ts @@ -0,0 +1,129 @@ +import { existsSync } from "node:fs"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { ipcMain } from "electron"; +import { USER_DATA_PATH } from "../../appPaths"; +import { normalizePath } from "../utils"; +import { getAssetRootPath } from "../project/manager"; + +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 resolved = await resolveReadableLocalFilePath(filePath); + + // Deterministic cache key from file path + mtime + const stat = await fs.stat(resolved); + const cacheKey = Buffer.from(`${resolved}:${stat.mtimeMs}`).toString("base64url"); + 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", () => { + 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/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..2eabb9786 --- /dev/null +++ b/electron/ipc/register/export.ts @@ -0,0 +1,1077 @@ +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 { + parseCaptionSidecarPayload, + type CaptionSidecarPayload, + withCaptionSidecarMessage, + writeCaptionSidecarsBestEffort, +} from "./exportCaptionSidecars"; +import { + closeExportStream, + isOwnedExportPath, + openExportStream, + registerOwnedExportPath, + releaseOwnedExportPath, + writeToExportStream, +} from "../export/exportStream"; +import { + enqueueNativeVideoExportFrameWrite, + enqueueNativeVideoExportFrameWrites, + exportNativeStaticLayoutVideo, + flushNativeVideoExportPendingWriteRequests, + 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"; + +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( + "native-static-layout-export", + async (event, options: NativeStaticLayoutExportOptions) => { + try { + if (!options || typeof options.inputPath !== "string") { + throw new Error("Native static layout export requires an input path"); + } + const sanitizedOptions = await sanitizeNativeStaticLayoutExportOptions(options); + + const result = await exportNativeStaticLayoutVideo( + getFfmpegBinaryPath(), + sanitizedOptions, + (progress) => { + if (event.sender.isDestroyed()) { + return; + } + + event.sender.send("native-static-layout-export-progress", progress); + }, + ); + registerOwnedExportPath(result.outputPath); + const primaryBackend = result.metrics.chunks[0]?.backend; + return { + success: true, + tempPath: result.outputPath, + encoderName: + primaryBackend === "nvidia-cuda-compositor" + ? "nvidia-cuda-compositor" + : primaryBackend === "windows-d3d11-compositor" + ? "windows-d3d11-compositor" + : result.metrics.chunkCount > 1 + ? "chunked-h264-nvenc" + : "static-layout-h264-nvenc", + metrics: result.metrics, + }; + } catch (error) { + console.warn("[native-static-layout-export] Failed:", error); + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } + }, + ); + + ipcMain.handle("native-static-layout-export-cancel", async (_, sessionId: string) => { + const session = nativeStaticLayoutExportSessions.get(sessionId); + if (!session) { + return { success: true }; + } + + session.terminating = true; + try { + session.currentProcess?.kill("SIGKILL"); + } catch { + // Process may already be closed. + } + + return { success: true }; + }); + + ipcMain.on( + "native-video-export-write-frames-async", + ( + event, + payload: { + sessionId: string; + requestId: number; + frameDataList: Uint8Array[]; + }, + ) => { + const sessionId = payload?.sessionId; + const requestId = payload?.requestId; + const frameDataList = payload?.frameDataList; + + if ( + typeof sessionId !== "string" || + typeof requestId !== "number" || + !Array.isArray(frameDataList) || + frameDataList.length === 0 + ) { + return; + } + + const session = nativeVideoExportSessions.get(sessionId); + if (!session) { + sendNativeVideoExportWriteFrameResult(event.sender, sessionId, requestId, { + success: false, + error: "Invalid native export session", + }); + return; + } + + session.sender = event.sender; + session.pendingWriteRequestIds.add(requestId); + + if (session.terminating) { + settleNativeVideoExportWriteFrameRequest(sessionId, session, requestId, { + success: false, + error: "Native video export session was cancelled", + }); + return; + } + + if ( + session.inputMode !== "h264-stream" && + frameDataList.some((frameData) => frameData.byteLength !== session.inputByteSize) + ) { + settleNativeVideoExportWriteFrameRequest(sessionId, session, requestId, { + success: false, + error: "Native video export batch contained invalid frame sizes", + }); + return; + } + + void enqueueNativeVideoExportFrameWrites(session, frameDataList) + .then(() => { + settleNativeVideoExportWriteFrameRequest(sessionId, session, requestId, { + success: true, + }); + }) + .catch((error) => { + session.stdinError = error instanceof Error ? error : new Error(String(error)); + settleNativeVideoExportWriteFrameRequest(sessionId, session, requestId, { + success: false, + error: getNativeVideoExportSessionError( + session, + session.stdinError.message, + ), + }); + }); + }, + ); + + ipcMain.on( + "native-video-export-write-frame-async", + ( + event, + payload: { + sessionId: string; + requestId: number; + frameData: Uint8Array; + }, + ) => { + const sessionId = payload?.sessionId; + const requestId = payload?.requestId; + const frameData = payload?.frameData; + + if (typeof sessionId !== "string" || typeof requestId !== "number" || !frameData) { + return; + } + + const session = nativeVideoExportSessions.get(sessionId); + if (!session) { + sendNativeVideoExportWriteFrameResult(event.sender, sessionId, requestId, { + success: false, + error: "Invalid native export session", + }); + return; + } + + session.sender = event.sender; + session.pendingWriteRequestIds.add(requestId); + + if (session.terminating) { + settleNativeVideoExportWriteFrameRequest(sessionId, session, requestId, { + success: false, + error: "Native video export session was cancelled", + }); + return; + } + + if ( + session.inputMode !== "h264-stream" && + frameData.byteLength !== session.inputByteSize + ) { + settleNativeVideoExportWriteFrameRequest(sessionId, session, requestId, { + success: false, + error: `Native video export expected ${session.inputByteSize} bytes per frame but received ${frameData.byteLength}`, + }); + return; + } + + void enqueueNativeVideoExportFrameWrite(session, frameData) + .then(() => { + settleNativeVideoExportWriteFrameRequest(sessionId, session, requestId, { + success: true, + }); + }) + .catch((error) => { + session.stdinError = error instanceof Error ? error : new Error(String(error)); + settleNativeVideoExportWriteFrameRequest(sessionId, session, requestId, { + success: false, + error: getNativeVideoExportSessionError( + session, + session.stdinError.message, + ), + }); + }); + }, + ); + + ipcMain.handle( + "native-video-export-finish", + async (_, sessionId: string, options?: NativeVideoExportFinishOptions) => { + const session = nativeVideoExportSessions.get(sessionId); + if (!session) { + return { success: false, error: "Invalid native export session" }; + } + + try { + await session.writeSequence; + if ( + !session.ffmpegProcess.stdin.destroyed && + !session.ffmpegProcess.stdin.writableEnded + ) { + session.ffmpegProcess.stdin.end(); + } + await session.completionPromise; + + const finalized = await muxNativeVideoExportAudio( + session.outputPath, + options ?? {}, + ); + nativeVideoExportSessions.delete(sessionId); + // Register the finalized path so only app-produced paths can flow back + // through finalize-exported-video / discard-exported-temp. + registerOwnedExportPath(finalized.outputPath); + if (finalized.outputPath !== session.outputPath) { + // muxNativeVideoExportAudio removes the intermediate on success, but + // clear our registry entry defensively in case a future refactor + // changes that contract. + releaseOwnedExportPath(session.outputPath); + } + + // Return a temp path instead of reading the file back into memory so we + // never hit V8's per-ArrayBuffer limit on >2 GiB exports. The renderer + // uses finalize-exported-video to move the file to its final path. + return { + success: true, + tempPath: finalized.outputPath, + encoderName: session.encoderName, + metrics: finalized.metrics, + }; + } catch (error) { + flushNativeVideoExportPendingWriteRequests(sessionId, session, String(error)); + nativeVideoExportSessions.delete(sessionId); + await removeTemporaryExportFile(session.outputPath); + const finalizedSuffix = session.outputPath.replace(/\.mp4$/, "-final.mp4"); + await removeTemporaryExportFile(finalizedSuffix); + return { + success: false, + error: String(error), + }; + } + }, + ); + + ipcMain.handle( + "mux-exported-video-audio-from-path", + async (_, videoPath: string, options?: NativeVideoExportFinishOptions) => { + if (typeof videoPath !== "string" || !isOwnedExportPath(videoPath)) { + return { + success: false, + error: "Video path is not an app-managed export temp", + }; + } + try { + const finalized = await muxNativeVideoExportAudio(videoPath, options ?? {}); + if (finalized.outputPath !== videoPath) { + registerOwnedExportPath(finalized.outputPath); + // muxNativeVideoExportAudio removes the intermediate on success, so + // the input is no longer owned by the registry after the call + // returns. + releaseOwnedExportPath(videoPath); + } + return { + success: true, + tempPath: finalized.outputPath, + metrics: finalized.metrics, + }; + } catch (error) { + // Only clean up the input path if it is still an owned temp (i.e. + // muxNativeVideoExportAudio failed before consuming it). + if (isOwnedExportPath(videoPath)) { + await removeTemporaryExportFile(videoPath); + releaseOwnedExportPath(videoPath); + } + return { success: false, error: String(error) }; + } + }, + ); + + ipcMain.handle( + "mux-exported-video-audio", + async (_, videoData: ArrayBuffer, options?: NativeVideoExportFinishOptions) => { + try { + const sizeError = getInMemoryExportTooLargeMessage(videoData.byteLength); + if (sizeError) { + return { + success: false, + error: sizeError, + }; + } + + const result = await muxExportedVideoAudioBuffer(videoData, options ?? {}); + // Register the muxed output so finalize-exported-video / discard- + // exported-temp accept it. Returning a temp path (instead of the + // muxed bytes) keeps us off Node's >2 GiB fs.readFile cap and + // avoids a redundant copy through the renderer. + registerOwnedExportPath(result.outputPath); + return { + success: true, + tempPath: result.outputPath, + metrics: result.metrics, + }; + } catch (error) { + return { + success: false, + error: String(error), + }; + } + }, + ); + + ipcMain.handle("export-stream-open", async (_event, options?: { extension?: string }) => { + try { + const result = await openExportStream(options); + return { success: true, streamId: result.streamId, tempPath: result.tempPath }; + } catch (error) { + return { success: false, error: String(error) }; + } + }); + + ipcMain.handle( + "export-stream-write", + async (_event, streamId: string, position: number, chunk: Uint8Array) => { + try { + await writeToExportStream(streamId, position, chunk); + return { success: true }; + } catch (error) { + return { success: false, error: String(error) }; + } + }, + ); + + ipcMain.handle( + "export-stream-close", + async (_event, streamId: string, options?: { abort?: boolean }) => { + try { + const result = await closeExportStream(streamId, options); + return { + success: true, + tempPath: result.tempPath, + bytesWritten: result.bytesWritten, + }; + } catch (error) { + return { success: false, error: String(error) }; + } + }, + ); + + ipcMain.handle("native-video-export-cancel", async (_, sessionId: string) => { + const session = nativeVideoExportSessions.get(sessionId); + if (!session) { + return { success: true }; + } + + session.terminating = true; + nativeVideoExportSessions.delete(sessionId); + flushNativeVideoExportPendingWriteRequests( + sessionId, + session, + "Native video export session was cancelled", + ); + + try { + if ( + !session.ffmpegProcess.stdin.destroyed && + !session.ffmpegProcess.stdin.writableEnded + ) { + session.ffmpegProcess.stdin.destroy(); + } + } catch { + // Stream may already be closed. + } + + try { + session.ffmpegProcess.kill("SIGKILL"); + } catch { + // Process may already be closed. + } + + await session.completionPromise.catch(() => undefined); + await removeTemporaryExportFile(session.outputPath); + return { success: true }; + }); + + ipcMain.handle( + "save-exported-video", + async ( + event, + videoData: ArrayBuffer, + fileName: string, + captionSidecar?: CaptionSidecarPayload, + ) => { + try { + const sidecarPayload = parseCaptionSidecarPayload(captionSidecar); + const sizeError = getInMemoryExportTooLargeMessage(videoData.byteLength); + if (sizeError) { + return { + success: false, + message: sizeError, + error: sizeError, + }; + } + + // Determine file type from extension + const isGif = fileName.toLowerCase().endsWith(".gif"); + const filters = isGif + ? [{ name: "GIF Image", extensions: ["gif"] }] + : [{ name: "MP4 Video", extensions: ["mp4"] }]; + const parentWindow = BrowserWindow.fromWebContents(event.sender); + const saveDialogOptions: SaveDialogOptions = { + title: isGif ? "Save Exported GIF" : "Save Exported Video", + defaultPath: path.join(app.getPath("downloads"), fileName), + filters, + properties: ["createDirectory", "showOverwriteConfirmation"], + }; + + const result = parentWindow + ? await dialog.showSaveDialog(parentWindow, saveDialogOptions) + : await dialog.showSaveDialog(saveDialogOptions); + + if (result.canceled || !result.filePath) { + return { + success: false, + canceled: true, + message: "Export canceled", + }; + } + + await fs.writeFile(result.filePath, Buffer.from(videoData)); + const captionSidecarResult = await writeCaptionSidecarsBestEffort( + result.filePath, + sidecarPayload, + ); + approveUserPath(result.filePath); + + return { + success: true, + path: result.filePath, + message: withCaptionSidecarMessage( + "Video exported successfully", + captionSidecarResult, + ), + }; + } catch (error) { + console.error("Failed to save exported video:", error); + return { + success: false, + message: "Failed to save exported video", + error: String(error), + }; + } + }, + ); + + ipcMain.handle( + "write-exported-video-to-path", + async ( + _event, + videoData: ArrayBuffer, + outputPath: string, + captionSidecar?: CaptionSidecarPayload, + ) => { + try { + const sidecarPayload = parseCaptionSidecarPayload(captionSidecar); + const sizeError = getInMemoryExportTooLargeMessage(videoData.byteLength); + if (sizeError) { + return { + success: false, + message: sizeError, + canceled: false, + error: sizeError, + }; + } + + const resolvedPath = path.resolve(outputPath); + await fs.mkdir(path.dirname(resolvedPath), { recursive: true }); + await fs.writeFile(resolvedPath, Buffer.from(videoData)); + const captionSidecarResult = await writeCaptionSidecarsBestEffort( + resolvedPath, + sidecarPayload, + ); + approveUserPath(resolvedPath); + + return { + success: true, + path: resolvedPath, + message: withCaptionSidecarMessage( + "Video exported successfully", + captionSidecarResult, + ), + canceled: false, + }; + } catch (error) { + console.error("Failed to write exported video to path:", error); + return { + success: false, + message: "Failed to write exported video", + canceled: false, + error: String(error), + }; + } + }, + ); + + ipcMain.handle( + "finalize-exported-video", + async ( + event, + payload: { + tempPath: string; + fileName: string; + outputPath?: string | null; + captionSidecar?: CaptionSidecarPayload; + }, + ) => { + const tempPath = payload?.tempPath; + const fileName = payload?.fileName; + if (typeof tempPath !== "string" || typeof fileName !== "string") { + return { success: false, error: "Invalid finalize-exported-video payload" }; + } + + if (!isTempPathSafe(tempPath) || !isOwnedExportPath(tempPath)) { + return { + success: false, + error: "Temp path is not an app-managed export temp", + }; + } + + try { + await fs.access(tempPath); + } catch { + return { + success: false, + error: `Exported video temp file is missing: ${tempPath}`, + }; + } + + try { + const sidecarPayload = parseCaptionSidecarPayload(payload.captionSidecar); + if (payload.outputPath) { + const resolvedPath = path.resolve(payload.outputPath); + await moveExportedTempFile(tempPath, resolvedPath); + releaseOwnedExportPath(tempPath); + const captionSidecarResult = await writeCaptionSidecarsBestEffort( + resolvedPath, + sidecarPayload, + ); + approveUserPath(resolvedPath); + return { + success: true, + path: resolvedPath, + canceled: false, + message: withCaptionSidecarMessage( + "Video exported successfully", + captionSidecarResult, + ), + }; + } + + const isGif = fileName.toLowerCase().endsWith(".gif"); + const filters = isGif + ? [{ name: "GIF Image", extensions: ["gif"] }] + : [{ name: "MP4 Video", extensions: ["mp4"] }]; + const parentWindow = BrowserWindow.fromWebContents(event.sender); + const saveDialogOptions: SaveDialogOptions = { + title: isGif ? "Save Exported GIF" : "Save Exported Video", + defaultPath: path.join(app.getPath("downloads"), fileName), + filters, + properties: ["createDirectory", "showOverwriteConfirmation"], + }; + + const result = parentWindow + ? await dialog.showSaveDialog(parentWindow, saveDialogOptions) + : await dialog.showSaveDialog(saveDialogOptions); + + if (result.canceled || !result.filePath) { + // Leave the temp file in place so the renderer can offer "Save Again" + // without re-rendering. The renderer owns cleanup on discard. + return { + success: false, + canceled: true, + message: "Export canceled", + }; + } + + await moveExportedTempFile(tempPath, result.filePath); + releaseOwnedExportPath(tempPath); + const captionSidecarResult = await writeCaptionSidecarsBestEffort( + result.filePath, + sidecarPayload, + ); + approveUserPath(result.filePath); + + return { + success: true, + path: result.filePath, + canceled: false, + message: withCaptionSidecarMessage( + "Video exported successfully", + captionSidecarResult, + ), + }; + } catch (error) { + console.error("Failed to finalize exported video:", error); + return { + success: false, + canceled: false, + message: "Failed to save exported video", + error: String(error), + }; + } + }, + ); + + ipcMain.handle("discard-exported-temp", async (_event, tempPath: string) => { + if (typeof tempPath !== "string" || tempPath.length === 0) { + return { success: false, error: "Invalid temp path" }; + } + if (!isTempPathSafe(tempPath) || !isOwnedExportPath(tempPath)) { + return { + success: false, + error: "Temp path is not an app-managed export temp", + }; + } + try { + await removeTemporaryExportFile(tempPath); + releaseOwnedExportPath(tempPath); + return { success: true }; + } catch (error) { + return { success: false, error: String(error) }; + } + }); +} diff --git a/electron/ipc/register/exportCaptionSidecars.test.ts b/electron/ipc/register/exportCaptionSidecars.test.ts new file mode 100644 index 000000000..2fc571e13 --- /dev/null +++ b/electron/ipc/register/exportCaptionSidecars.test.ts @@ -0,0 +1,114 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + parseCaptionSidecarPayload, + serializeSrt, + serializeVtt, + withCaptionSidecarMessage, + writeCaptionSidecarsBestEffort, +} from "./exportCaptionSidecars"; + +describe("exportCaptionSidecars", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("serializes SRT cues with stable numbering and timestamps", () => { + expect( + serializeSrt([ + { + startMs: 1234, + endMs: 5678, + text: "Hello\nworld", + }, + ]), + ).toBe("1\n00:00:01,234 --> 00:00:05,678\nHello\nworld"); + }); + + it("serializes VTT cues with header and dot timestamps", () => { + expect( + serializeVtt([ + { + startMs: 0, + endMs: 2000, + text: "Caption", + }, + ]), + ).toBe("WEBVTT\n\n00:00:00.000 --> 00:00:02.000\nCaption"); + }); + + it("drops malformed cues when parsing sidecar payloads", () => { + expect( + parseCaptionSidecarPayload({ + format: "both", + cues: [ + { startMs: 0, endMs: 1000, text: "ok" }, + { startMs: 2000, endMs: 1000, text: "bad range" }, + { startMs: 1000, endMs: 2000, text: " " }, + ], + }), + ).toEqual({ + format: "both", + cues: [{ startMs: 0, endMs: 1000, text: "ok" }], + }); + }); + + it("returns a warning result instead of throwing when sidecar writes fail", async () => { + const writeFileSpy = vi + .spyOn(fs, "writeFile") + .mockRejectedValueOnce(new Error("disk full")); + + await expect( + writeCaptionSidecarsBestEffort("/tmp/export.mp4", { + format: "srt", + cues: [{ startMs: 0, endMs: 1000, text: "Caption" }], + }), + ).resolves.toEqual({ + wroteAny: false, + error: "disk full", + }); + + expect(writeFileSpy).toHaveBeenCalledTimes(1); + }); + + it("writes requested caption sidecars when the filesystem succeeds", async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "recordly-sidecar-test-")); + const videoPath = path.join(tempDir, "clip.mp4"); + + try { + await expect( + writeCaptionSidecarsBestEffort(videoPath, { + format: "both", + cues: [{ startMs: 0, endMs: 1000, text: "Caption" }], + }), + ).resolves.toEqual({ wroteAny: true, error: null }); + + await expect(fs.readFile(path.join(tempDir, "clip.srt"), "utf8")).resolves.toContain( + "00:00:00,000 --> 00:00:01,000", + ); + await expect(fs.readFile(path.join(tempDir, "clip.vtt"), "utf8")).resolves.toContain( + "WEBVTT", + ); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } + }); + + it("appends a non-fatal caption warning only when sidecar writes fail", () => { + expect( + withCaptionSidecarMessage("Video exported successfully", { + wroteAny: false, + error: "disk full", + }), + ).toBe("Video exported successfully Captions could not be saved alongside the video."); + + expect( + withCaptionSidecarMessage("Video exported successfully", { + wroteAny: true, + error: null, + }), + ).toBe("Video exported successfully"); + }); +}); diff --git a/electron/ipc/register/exportCaptionSidecars.ts b/electron/ipc/register/exportCaptionSidecars.ts new file mode 100644 index 000000000..0560a9a96 --- /dev/null +++ b/electron/ipc/register/exportCaptionSidecars.ts @@ -0,0 +1,157 @@ +import fs from "node:fs/promises"; +import path from "node:path"; + +export type CaptionSidecarCue = { + startMs: number; + endMs: number; + text: string; +}; + +export type CaptionSidecarPayload = { + format: "srt" | "vtt" | "both"; + cues: CaptionSidecarCue[]; +}; + +export type CaptionSidecarWriteResult = { + wroteAny: boolean; + error: string | null; +}; + +function toSrtTimestamp(totalMs: number): string { + const ms = Math.max(0, Math.round(totalMs)); + const hours = Math.floor(ms / 3_600_000); + const minutes = Math.floor((ms % 3_600_000) / 60_000); + const seconds = Math.floor((ms % 60_000) / 1000); + const millis = ms % 1000; + return `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")},${String(millis).padStart(3, "0")}`; +} + +function toVttTimestamp(totalMs: number): string { + const ms = Math.max(0, Math.round(totalMs)); + const hours = Math.floor(ms / 3_600_000); + const minutes = Math.floor((ms % 3_600_000) / 60_000); + const seconds = Math.floor((ms % 60_000) / 1000); + const millis = ms % 1000; + return `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}.${String(millis).padStart(3, "0")}`; +} + +function normalizeCaptionSidecarCues(cues: unknown): CaptionSidecarCue[] { + if (!Array.isArray(cues)) { + return []; + } + + return cues + .filter((cue): cue is CaptionSidecarCue => { + return ( + typeof cue === "object" && + cue !== null && + typeof cue.startMs === "number" && + typeof cue.endMs === "number" && + typeof cue.text === "string" && + Number.isFinite(cue.startMs) && + Number.isFinite(cue.endMs) && + cue.endMs > cue.startMs && + cue.text.trim().length > 0 + ); + }) + .map((cue) => ({ + startMs: cue.startMs, + endMs: cue.endMs, + text: cue.text.replace(/\r\n/g, "\n").trim(), + })); +} + +export function parseCaptionSidecarPayload(payload: unknown): CaptionSidecarPayload | null { + if (typeof payload !== "object" || payload === null) { + return null; + } + + const candidate = payload as { + format?: unknown; + cues?: unknown; + }; + + const format = + candidate.format === "srt" || candidate.format === "vtt" || candidate.format === "both" + ? candidate.format + : null; + if (!format) { + return null; + } + + const cues = normalizeCaptionSidecarCues(candidate.cues); + if (cues.length === 0) { + return null; + } + + return { format, cues }; +} + +export function serializeSrt(cues: CaptionSidecarCue[]): string { + return cues + .map((cue, index) => { + return `${index + 1}\n${toSrtTimestamp(cue.startMs)} --> ${toSrtTimestamp(cue.endMs)}\n${cue.text}`; + }) + .join("\n\n"); +} + +export function serializeVtt(cues: CaptionSidecarCue[]): string { + const body = cues + .map((cue) => { + return `${toVttTimestamp(cue.startMs)} --> ${toVttTimestamp(cue.endMs)}\n${cue.text}`; + }) + .join("\n\n"); + return `WEBVTT\n\n${body}`; +} + +export async function writeCaptionSidecars( + videoPath: string, + payload: CaptionSidecarPayload | null, +) { + if (!payload) { + return; + } + + const parsed = path.parse(videoPath); + const basePath = path.join(parsed.dir, parsed.name); + + if (payload.format === "srt" || payload.format === "both") { + await fs.writeFile(`${basePath}.srt`, serializeSrt(payload.cues), "utf8"); + } + + if (payload.format === "vtt" || payload.format === "both") { + await fs.writeFile(`${basePath}.vtt`, serializeVtt(payload.cues), "utf8"); + } +} + +export async function writeCaptionSidecarsBestEffort( + videoPath: string, + payload: CaptionSidecarPayload | null, +): Promise { + if (!payload) { + return { wroteAny: false, error: null }; + } + + try { + await writeCaptionSidecars(videoPath, payload); + return { wroteAny: true, error: null }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.warn("[export] Failed to write caption sidecars:", { + videoPath, + message, + }); + return { wroteAny: false, error: message }; + } +} + +export function withCaptionSidecarMessage( + baseMessage: string, + captionSidecarResult: CaptionSidecarWriteResult, +) { + if (!captionSidecarResult.error) { + return baseMessage; + } + + return `${baseMessage} Captions could not be saved alongside the video.`; +} diff --git a/electron/ipc/register/permissions.ts b/electron/ipc/register/permissions.ts new file mode 100644 index 000000000..f3b8b86f1 --- /dev/null +++ b/electron/ipc/register/permissions.ts @@ -0,0 +1,87 @@ +import { ipcMain, shell, systemPreferences } from "electron"; +import { getMacPrivacySettingsUrl } from "../utils"; + +export function registerPermissionHandlers() { + ipcMain.handle("open-external-url", async (_, url: string) => { + try { + // Security: only allow http/https URLs to prevent file:// or custom protocol abuse + const parsed = new URL(url); + if (parsed.protocol !== "https:" && parsed.protocol !== "http:") { + return { success: false, error: `Blocked non-HTTP URL: ${parsed.protocol}` }; + } + await shell.openExternal(url); + return { success: true }; + } catch (error) { + console.error("Failed to open URL:", error); + return { success: false, error: String(error) }; + } + }); + + ipcMain.handle("get-accessibility-permission-status", () => { + if (process.platform !== "darwin") { + return { success: true, trusted: true, prompted: false }; + } + + return { + success: true, + trusted: systemPreferences.isTrustedAccessibilityClient(false), + prompted: false, + }; + }); + + ipcMain.handle("request-accessibility-permission", () => { + if (process.platform !== "darwin") { + return { success: true, trusted: true, prompted: false }; + } + + return { + success: true, + trusted: systemPreferences.isTrustedAccessibilityClient(true), + prompted: true, + }; + }); + + ipcMain.handle("get-screen-recording-permission-status", () => { + if (process.platform !== "darwin") { + return { success: true, status: "granted" }; + } + + try { + return { + success: true, + status: systemPreferences.getMediaAccessStatus("screen"), + }; + } catch (error) { + console.error("Failed to get screen recording permission status:", error); + return { success: false, status: "unknown", error: String(error) }; + } + }); + + ipcMain.handle("open-screen-recording-preferences", async () => { + if (process.platform !== "darwin") { + return { success: true }; + } + + try { + await shell.openExternal(getMacPrivacySettingsUrl("screen")); + return { success: true }; + } catch (error) { + console.error("Failed to open Screen Recording preferences:", error); + return { success: false, error: String(error) }; + } + }); + + ipcMain.handle("open-accessibility-preferences", async () => { + if (process.platform !== "darwin") { + return { success: true }; + } + + try { + await shell.openExternal(getMacPrivacySettingsUrl("accessibility")); + return { success: true }; + } catch (error) { + console.error("Failed to open Accessibility preferences:", error); + return { success: false, error: String(error) }; + } + }); +} diff --git a/electron/ipc/register/project.ts b/electron/ipc/register/project.ts new file mode 100644 index 000000000..a3a0cd078 --- /dev/null +++ b/electron/ipc/register/project.ts @@ -0,0 +1,776 @@ +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, + loadRecentProjectPaths, + persistRecordingsDirectorySetting, + rememberRecentProject, + replaceApprovedSessionLocalReadPaths, + rememberApprovedLocalReadPath, + 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, + 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("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("list-project-files", async () => { + try { + const library = await listProjectLibraryEntries(); + return { + success: true, + projectsDir: library.projectsDir, + entries: library.entries, + }; + } catch (error) { + return { + success: false, + projectsDir: null, + entries: [], + error: String(error), + }; + } + }); + + ipcMain.handle("open-project-file-at-path", async (_, filePath: string) => { + try { + return await loadProjectFromPath(filePath); + } catch (error) { + console.error("Failed to open project file at path:", error); + return { + success: false, + message: "Failed to open project file", + error: String(error), + }; + } + }); + + ipcMain.handle("open-projects-directory", async () => { + try { + const projectsDir = await getProjectsDir(); + const openPathResult = await shell.openPath(projectsDir); + if (openPathResult) { + return { + success: false, + error: openPathResult, + message: "Failed to open projects folder.", + }; + } + + return { success: true, path: projectsDir }; + } catch (error) { + console.error("Failed to open projects folder:", error); + return { + success: false, + error: String(error), + message: "Failed to open projects folder.", + }; + } + }); + ipcMain.handle( + "set-current-video-path", + async ( + _, + path: string, + options?: { preserveProjectPath?: boolean; hideOverlayCursorByDefault?: boolean }, + ) => { + setCurrentVideoPath(normalizeVideoSourcePath(path) ?? path); + approveUserPath(currentVideoPath); + const resolvedSession = (await resolveRecordingSession(currentVideoPath)) ?? { + videoPath: currentVideoPath!, + webcamPath: null, + timeOffsetMs: 0, + }; + + const nextSession = { + ...resolvedSession, + hideOverlayCursorByDefault: + normalizeBoolean(options?.hideOverlayCursorByDefault) || + normalizeBoolean(resolvedSession.hideOverlayCursorByDefault), + }; + + setCurrentRecordingSession(nextSession); + await replaceApprovedSessionLocalReadPaths([ + resolvedSession.videoPath, + resolvedSession.webcamPath, + ]); + + if (nextSession.webcamPath) { + await persistRecordingSessionManifest(nextSession); + } + + if (!options?.preserveProjectPath) { + setCurrentProjectPath(null); + } + + for (const window of BrowserWindow.getAllWindows()) { + if (!window.isDestroyed()) { + window.webContents.send("recording-session-changed", nextSession); + } + } + + return { success: true, webcamPath: nextSession.webcamPath ?? null }; + }, + ); + + ipcMain.handle( + "set-current-recording-session", + async ( + _, + session: { + videoPath: string; + webcamPath?: string | null; + timeOffsetMs?: number; + hideOverlayCursorByDefault?: boolean; + }, + options?: { preserveProjectPath?: boolean }, + ) => { + const normalizedVideoPath = + normalizeVideoSourcePath(session.videoPath) ?? session.videoPath; + setCurrentVideoPath(normalizedVideoPath); + setCurrentRecordingSession({ + videoPath: normalizedVideoPath, + webcamPath: normalizeVideoSourcePath(session.webcamPath ?? null), + timeOffsetMs: normalizeRecordingTimeOffsetMs(session.timeOffsetMs), + hideOverlayCursorByDefault: normalizeBoolean(session.hideOverlayCursorByDefault), + }); + await rememberApprovedLocalReadPath(currentRecordingSession!.videoPath); + await rememberApprovedLocalReadPath(currentRecordingSession!.webcamPath); + if (!options?.preserveProjectPath) { + setCurrentProjectPath(null); + } + await persistRecordingSessionManifest(currentRecordingSession!); + + for (const window of BrowserWindow.getAllWindows()) { + if (!window.isDestroyed()) { + window.webContents.send("recording-session-changed", currentRecordingSession); + } + } + + return { success: true }; + }, + ); + + ipcMain.handle("get-current-recording-session", () => { + if (!currentRecordingSession) { + return { success: false }; + } + + return { + success: true, + session: currentRecordingSession, + }; + }); + + ipcMain.handle("get-current-video-path", () => { + return currentVideoPath ? { success: true, path: currentVideoPath } : { success: false }; + }); + + ipcMain.handle("clear-current-video-path", () => { + setCurrentVideoPath(null); + setCurrentRecordingSession(null); + return { success: true }; + }); + + ipcMain.handle("delete-recording-file", async (_, filePath: string) => { + try { + if (!filePath) { + return { success: false, error: "Only auto-generated recordings can be deleted" }; + } + const resolvedPath = await fs.realpath(filePath).catch(() => path.resolve(filePath)); + const recordingsDirRaw = await getRecordingsDir(); + const recordingsDir = await fs + .realpath(recordingsDirRaw) + .catch(() => path.resolve(recordingsDirRaw)); + if ( + !isPathInsideDirectory(resolvedPath, recordingsDir) || + !isAutoRecordingPath(resolvedPath) + ) { + return { success: false, error: "Only auto-generated recordings can be deleted" }; + } + await fs.unlink(resolvedPath); + // Also delete the cursor telemetry sidecar if it exists + const telemetryPath = getTelemetryPathForVideo(resolvedPath); + await fs.unlink(telemetryPath).catch(() => undefined); + const currentResolved = currentVideoPath + ? await fs.realpath(currentVideoPath).catch(() => currentVideoPath) + : null; + if (currentResolved === resolvedPath) { + setCurrentVideoPath(null); + setCurrentRecordingSession(null); + } + return { success: true }; + } catch (error) { + return { success: false, error: String(error) }; + } + }); + + ipcMain.handle("get-local-media-url", async (_, filePath: string) => { + const baseUrl = getMediaServerBaseUrl(); + if (!baseUrl || !filePath) { + return { success: false as const }; + } + const resolved = await resolveApprovedLocalMediaPath(filePath); + if (!resolved) { + const normalized = path.resolve(filePath); + console.warn(`[get-local-media-url] Blocked disallowed path: ${normalized}`); + return { success: false as const }; + } + return { success: true as const, url: buildMediaUrl(baseUrl, resolved) }; + }); +} diff --git a/electron/ipc/register/recording.ts b/electron/ipc/register/recording.ts new file mode 100644 index 000000000..3e03d9709 --- /dev/null +++ b/electron/ipc/register/recording.ts @@ -0,0 +1,1945 @@ +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 { showCursor } from "../../cursorHider"; +import { getMonitorHandles } from "../monitorResolver"; +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 { + ensureNativeCaptureHelperBinary, + ensureSwiftHelperBinary, + getNativeCaptureHelperBinaryPath, + getSystemCursorHelperBinaryPath, + getSystemCursorHelperSourcePath, + getWindowsCaptureExePath, +} from "../paths/binaries"; +import { rememberApprovedLocalReadPath } from "../project/manager"; +import { + getBrowserMicSidecarFilters, + shouldKeepRecordingAudioSidecars, +} from "../recording/audioFilters"; +import { + getCompanionAudioFallbackInfo, + getFileSizeIfPresent, + type MicrophoneChunkTimingEvent, + type MicrophonePauseInterval, + type RecordingDiagnosticsSnapshot, + recordNativeCaptureDiagnostics, + summarizeMicrophoneChunkTiming, + validateRecordedVideo, + writeRecordingDiagnosticsSnapshot, +} from "../recording/diagnostics"; +import { + buildFfmpegCaptureArgs, + waitForFfmpegCaptureStart, + waitForFfmpegCaptureStop, +} from "../recording/ffmpeg"; +import { + attachNativeCaptureLifecycle, + finalizeStoredVideo, + muxNativeMacRecordingWithAudio, + recoverNativeMacCaptureOutput, + waitForNativeCaptureCommand, + waitForNativeCaptureStart, + waitForNativeCaptureStop, +} from "../recording/mac"; +import { resolveRecordedVideoStoragePath } from "../recording/storagePath"; +import { + attachWindowsCaptureLifecycle, + isNativeWindowsCaptureAvailable, + muxNativeWindowsVideoWithAudio, + waitForWindowsCaptureStart, + waitForWindowsCaptureStop, +} from "../recording/windows"; +import { + shouldStartWindowsBrowserMicrophoneFallback, + shouldUseWindowsBrowserMicrophoneFallback, +} from "../recording/windowsFallbacks"; +import { + cachedSystemCursorAssets, + cachedSystemCursorAssetsSourceMtimeMs, + currentVideoPath, + ffmpegCaptureOutputBuffer, + ffmpegCaptureProcess, + ffmpegCaptureTargetPath, + ffmpegScreenRecordingActive, + lastNativeCaptureDiagnostics, + nativeCaptureMicrophonePath, + nativeCaptureOutputBuffer, + nativeCapturePaused, + nativeCaptureProcess, + nativeCaptureSystemAudioPath, + nativeCaptureTargetPath, + nativeScreenRecordingActive, + selectedSource, + setActiveCursorSamples, + setCachedSystemCursorAssets, + setCachedSystemCursorAssetsSourceMtimeMs, + setCursorCaptureStartTimeMs, + setFfmpegCaptureOutputBuffer, + setFfmpegCaptureProcess, + setFfmpegCaptureTargetPath, + setFfmpegScreenRecordingActive, + setIsCursorCaptureActive, + setLastLeftClick, + setLinuxCursorScreenPoint, + setNativeCaptureMicrophonePath, + setNativeCaptureOutputBuffer, + setNativeCapturePaused, + setNativeCaptureProcess, + setNativeCaptureStopRequested, + setNativeCaptureSystemAudioPath, + setNativeCaptureTargetPath, + setNativeScreenRecordingActive, + setPendingCursorSamples, + setWindowsCaptureOutputBuffer, + setWindowsCapturePaused, + setWindowsCaptureProcess, + setWindowsCaptureStopRequested, + setWindowsCaptureTargetPath, + setWindowsMicAudioPath, + setWindowsNativeCaptureActive, + setWindowsOrphanedMicAudioPath, + setWindowsPendingVideoPath, + setWindowsSystemAudioPath, + windowsCaptureOutputBuffer, + windowsCapturePaused, + windowsCaptureProcess, + windowsCaptureTargetPath, + windowsMicAudioPath, + windowsNativeCaptureActive, + windowsOrphanedMicAudioPath, + windowsPendingVideoPath, + windowsSystemAudioPath, +} from "../state"; +import type { CursorTelemetryPoint, NativeMacRecordingOptions, SelectedSource } from "../types"; +import { + getMacPrivacySettingsUrl, + getRecordingsDir, + getScreen, + getTelemetryPathForVideo, + moveFileWithOverwrite, + normalizeVideoSourcePath, + parseJsonWithByteOrderMark, + parseWindowId, +} from "../utils"; +import { resolveWindowsCaptureTarget } from "../windowsCaptureSelection"; + +const execFileAsync = promisify(execFile); + +async function writeWindowsRecordingDiagnostics( + videoPath: string | null | undefined, + snapshot: Omit, +) { + if (!videoPath) { + return null; + } + + try { + return await writeRecordingDiagnosticsSnapshot(videoPath, { + backend: "windows-wgc", + ...snapshot, + }); + } catch (error) { + console.warn("Failed to write Windows recording diagnostics:", error); + return null; + } +} + +function isRecord(value: unknown): value is Record { + return Boolean(value && typeof value === "object" && !Array.isArray(value)); +} + +function pickPrimitiveRecord(value: unknown) { + if (!isRecord(value)) { + return null; + } + + const entries = Object.entries(value).filter( + (entry): entry is [string, boolean | number | string] => { + const primitive = entry[1]; + return ( + typeof primitive === "boolean" || + typeof primitive === "number" || + typeof primitive === "string" + ); + }, + ); + + return entries.length > 0 ? Object.fromEntries(entries) : null; +} + +function normalizeRendererTimestampMs(value: unknown) { + const nowMs = Date.now(); + if (typeof value !== "number" || !Number.isFinite(value)) { + return nowMs; + } + + return Math.min(Math.max(0, Math.round(value)), nowMs); +} + +function pickMicrophoneChunkEvents(value: unknown): MicrophoneChunkTimingEvent[] | null { + if (!Array.isArray(value)) { + return null; + } + + const events = value + .map((event) => { + if (!isRecord(event)) { + return null; + } + + const { index, size, elapsedMs, deltaMs, recordedElapsedMs, recordedDeltaMs } = event; + if ( + typeof index !== "number" || + !Number.isFinite(index) || + index < 0 || + typeof size !== "number" || + !Number.isFinite(size) || + size < 0 || + typeof elapsedMs !== "number" || + !Number.isFinite(elapsedMs) || + elapsedMs < 0 + ) { + return null; + } + + return { + index: Math.round(index), + size: Math.round(size), + elapsedMs: Math.round(elapsedMs), + deltaMs: + typeof deltaMs === "number" && Number.isFinite(deltaMs) + ? Math.max(0, Math.round(deltaMs)) + : null, + ...(typeof recordedElapsedMs === "number" && + Number.isFinite(recordedElapsedMs) && + recordedElapsedMs >= 0 + ? { recordedElapsedMs: Math.round(recordedElapsedMs) } + : {}), + recordedDeltaMs: + typeof recordedDeltaMs === "number" && Number.isFinite(recordedDeltaMs) + ? Math.max(0, Math.round(recordedDeltaMs)) + : null, + }; + }) + .filter((event): event is NonNullable => event !== null); + + return events.length > 0 ? events : null; +} + +function pickMicrophonePauseIntervals(value: unknown): MicrophonePauseInterval[] | null { + if (!Array.isArray(value)) { + return null; + } + + const intervals = value + .map((interval) => { + if ( + !isRecord(interval) || + typeof interval.startElapsedMs !== "number" || + !Number.isFinite(interval.startElapsedMs) || + interval.startElapsedMs < 0 + ) { + return null; + } + + const startElapsedMs = Math.max(0, Math.round(interval.startElapsedMs)); + return { + startElapsedMs, + ...(typeof interval.endElapsedMs === "number" && + Number.isFinite(interval.endElapsedMs) && + interval.endElapsedMs >= startElapsedMs + ? { endElapsedMs: Math.round(interval.endElapsedMs) } + : {}), + ...(typeof interval.durationMs === "number" && + Number.isFinite(interval.durationMs) && + interval.durationMs >= 0 + ? { durationMs: Math.round(interval.durationMs) } + : {}), + }; + }) + .filter((interval): interval is NonNullable => interval !== null); + + return intervals.length > 0 ? intervals : null; +} + +function pickAudioInputDevices(value: unknown) { + if (!Array.isArray(value)) { + return null; + } + + const devices = value + .map((device) => { + if (!isRecord(device) || typeof device.deviceId !== "string") { + return null; + } + + return { + deviceId: device.deviceId, + ...(typeof device.groupId === "string" ? { groupId: device.groupId } : {}), + label: typeof device.label === "string" ? device.label : "", + }; + }) + .filter((device): device is NonNullable => device !== null); + + return devices.length > 0 ? devices : null; +} + +async function getSystemCursorAssets() { + if (process.platform !== "darwin") { + setCachedSystemCursorAssets({}); + setCachedSystemCursorAssetsSourceMtimeMs(null); + return cachedSystemCursorAssets ?? {}; + } + const sourcePath = getSystemCursorHelperSourcePath(); + const sourceStat = await fs.stat(sourcePath); + if (cachedSystemCursorAssets && cachedSystemCursorAssetsSourceMtimeMs === sourceStat.mtimeMs) { + return cachedSystemCursorAssets; + } + const binaryPath = await ensureSwiftHelperBinary( + sourcePath, + getSystemCursorHelperBinaryPath(), + "system cursor helper", + "recordly-system-cursors", + ); + const { stdout } = await execFileAsync(binaryPath, [], { + timeout: 15000, + maxBuffer: 20 * 1024 * 1024, + }); + const parsed = JSON.parse(stdout) as Record< + string, + Partial + >; + const result = Object.fromEntries( + Object.entries(parsed).filter( + ([, asset]) => + typeof asset?.dataUrl === "string" && + typeof asset?.hotspotX === "number" && + typeof asset?.hotspotY === "number" && + typeof asset?.width === "number" && + typeof asset?.height === "number", + ), + ) as Record; + setCachedSystemCursorAssets(result); + setCachedSystemCursorAssetsSourceMtimeMs(sourceStat.mtimeMs); + return result; +} + +function normalizeDesktopSourceName(value: string) { + return value.trim().replace(/\s+/g, " ").toLowerCase(); +} + +async function cleanupWindowsOrphanedMicAudioPath(filePath: string | null) { + if (!filePath) { + return; + } + + if (shouldKeepRecordingAudioSidecars()) { + console.log(`[recording] Keeping orphaned native mic sidecar for diagnostics: ${filePath}`); + return; + } + + await fs.rm(filePath, { force: true }).catch(() => undefined); +} + +async function pathExists(filePath: string | null | undefined) { + if (!filePath) { + return false; + } + + try { + await fs.access(filePath); + return true; + } catch { + return false; + } +} + +async function resolveExistingPath(...candidates: Array) { + for (const candidate of candidates) { + if (await pathExists(candidate)) { + return candidate ?? null; + } + } + + return null; +} + +export function registerRecordingHandlers( + onRecordingStateChange?: (recording: boolean, sourceName: string) => void, +) { + ipcMain.handle( + "start-native-screen-recording", + async (_, source: SelectedSource, options?: NativeMacRecordingOptions) => { + // Windows native capture path + if (process.platform === "win32") { + const windowsCaptureAvailable = await isNativeWindowsCaptureAvailable(); + if (!windowsCaptureAvailable) { + return { + success: false, + message: "Native Windows capture is not available on this system.", + }; + } + + if (windowsCaptureProcess && !windowsNativeCaptureActive) { + try { + windowsCaptureProcess.kill(); + } catch { + /* ignore */ + } + setWindowsCaptureProcess(null); + setWindowsCaptureTargetPath(null); + setWindowsCaptureStopRequested(false); + } + + if (windowsCaptureProcess) { + return { + success: false, + message: "A native Windows screen recording is already active.", + }; + } + + let wcProc: ChildProcessWithoutNullStreams | null = null; + let tempVideoPath: string | null = null; + let tempSystemAudioPath: string | null = null; + let tempMicPath: string | null = null; + try { + const exePath = getWindowsCaptureExePath(); + const recordingsDir = await getRecordingsDir(); + const timestamp = Date.now(); + const outputPath = path.join(recordingsDir, `recording-${timestamp}.mp4`); + tempVideoPath = path.join( + app.getPath("temp"), + `recordly-native-${timestamp}.mp4`, + ); + + let captureOutput = ""; + let systemAudioPath: string | null = null; + let microphonePath: string | null = null; + let orphanedMicAudioPath: string | null = null; + + const browserMicFallbackRequested = + shouldStartWindowsBrowserMicrophoneFallback(options); + const captureTarget = resolveWindowsCaptureTarget( + source, + getScreen().getAllDisplays(), + getScreen().getPrimaryDisplay(), + ); + const displayBounds = + captureTarget.kind === "display" ? captureTarget.bounds : null; + setWindowsOrphanedMicAudioPath(null); + + const config: Record = { + outputPath: tempVideoPath, + fps: 60, + }; + + if (captureTarget.kind === "invalid-window") { + return { + success: false, + message: + "Selected window is no longer available. Please choose the window again.", + }; + } + + if (captureTarget.kind === "window") { + config.windowHandle = captureTarget.windowHandle; + } else { + // Windows Graphics Capture (WGC) requires a raw HMONITOR handle. + // We attempt to resolve the handle by matching the physical coordinates of the target display. + const monitors = getMonitorHandles(); + const matchedMonitor = monitors.find( + (monitor) => + monitor.x === Math.round(captureTarget.bounds.x) && + monitor.y === Math.round(captureTarget.bounds.y), + ); + + if (matchedMonitor) { + config.displayId = matchedMonitor.handle; + } else { + // Fallback to coordinate-based matching if handle resolution fails + config.displayId = captureTarget.displayId; + } + + config.displayX = Math.round(captureTarget.bounds.x); + config.displayY = Math.round(captureTarget.bounds.y); + config.displayW = Math.round(captureTarget.bounds.width); + config.displayH = Math.round(captureTarget.bounds.height); + } + + if (options?.capturesSystemAudio) { + systemAudioPath = path.join( + recordingsDir, + `recording-${timestamp}.system.wav`, + ); + tempSystemAudioPath = path.join( + app.getPath("temp"), + `recordly-native-${timestamp}.system.wav`, + ); + config.captureSystemAudio = true; + config.audioOutputPath = tempSystemAudioPath; + setWindowsSystemAudioPath(systemAudioPath); + } else { + setWindowsSystemAudioPath(null); + } + + if (options?.capturesMicrophone && !browserMicFallbackRequested) { + microphonePath = path.join(recordingsDir, `recording-${timestamp}.mic.wav`); + tempMicPath = path.join( + app.getPath("temp"), + `recordly-native-${timestamp}.mic.wav`, + ); + config.captureMic = true; + config.micOutputPath = tempMicPath; + if (options.microphoneLabel) { + config.micDeviceName = options.microphoneLabel; + } + setWindowsMicAudioPath(microphonePath); + } else if (browserMicFallbackRequested) { + config.captureMic = false; + setWindowsMicAudioPath(null); + } else { + setWindowsMicAudioPath(null); + } + + recordNativeCaptureDiagnostics({ + backend: "windows-wgc", + phase: "start", + sourceId: source?.id ?? null, + sourceType: source?.sourceType ?? "unknown", + displayId: typeof config.displayId === "number" ? config.displayId : null, + displayBounds, + windowHandle: + typeof config.windowHandle === "number" ? config.windowHandle : null, + helperPath: exePath, + outputPath, + systemAudioPath, + microphonePath, + }); + + setWindowsCaptureOutputBuffer(""); + setWindowsCaptureTargetPath(outputPath); + setWindowsCaptureStopRequested(false); + setWindowsCapturePaused(false); + + // The native helper currently does not declare DPI awareness in its own + // manifest or process setup, so we keep the compatibility flag here until + // scaled-display capture is verified without it on Windows. + wcProc = spawn(exePath, [JSON.stringify(config)], { + cwd: recordingsDir, + stdio: ["pipe", "pipe", "pipe"], + env: { ...process.env, __COMPAT_LAYER: "HighDpiAware" }, + }); + setWindowsCaptureProcess(wcProc); + attachWindowsCaptureLifecycle(wcProc); + + wcProc.stdout.on("data", (chunk: Buffer) => { + const msg = chunk.toString(); + captureOutput += msg; + setWindowsCaptureOutputBuffer(captureOutput); + }); + wcProc.stderr.on("data", (chunk: Buffer) => { + const msg = chunk.toString(); + captureOutput += msg; + setWindowsCaptureOutputBuffer(captureOutput); + }); + + await waitForWindowsCaptureStart(wcProc); + const microphoneFallbackRequired = + browserMicFallbackRequested || + shouldUseWindowsBrowserMicrophoneFallback(captureOutput, options); + if (microphoneFallbackRequired) { + orphanedMicAudioPath = tempMicPath ?? microphonePath; + setWindowsOrphanedMicAudioPath(orphanedMicAudioPath); + microphonePath = null; + setWindowsMicAudioPath(null); + } + setWindowsNativeCaptureActive(true); + setNativeScreenRecordingActive(true); + recordNativeCaptureDiagnostics({ + backend: "windows-wgc", + phase: "start", + sourceId: source?.id ?? null, + sourceType: source?.sourceType ?? "unknown", + displayId: typeof config.displayId === "number" ? config.displayId : null, + displayBounds, + windowHandle: + typeof config.windowHandle === "number" ? config.windowHandle : null, + helperPath: exePath, + outputPath, + systemAudioPath, + microphonePath, + processOutput: captureOutput.trim() || undefined, + }); + return { success: true, microphoneFallbackRequired }; + } catch (error) { + recordNativeCaptureDiagnostics({ + backend: "windows-wgc", + phase: "start", + sourceId: source?.id ?? null, + sourceType: source?.sourceType ?? "unknown", + helperPath: windowsCaptureTargetPath ? getWindowsCaptureExePath() : null, + outputPath: windowsCaptureTargetPath, + systemAudioPath: windowsSystemAudioPath, + microphonePath: windowsMicAudioPath, + processOutput: windowsCaptureOutputBuffer.trim() || undefined, + error: String(error), + }); + console.error("Failed to start native Windows capture:", error); + try { + if (wcProc) wcProc.kill(); + } catch { + /* ignore */ + } + await Promise.allSettled([ + tempVideoPath + ? fs.rm(tempVideoPath, { force: true }).catch(() => undefined) + : Promise.resolve(), + tempSystemAudioPath + ? fs.rm(tempSystemAudioPath, { force: true }).catch(() => undefined) + : Promise.resolve(), + tempMicPath + ? fs.rm(tempMicPath, { force: true }).catch(() => undefined) + : Promise.resolve(), + ]); + setWindowsNativeCaptureActive(false); + setNativeScreenRecordingActive(false); + setWindowsCaptureProcess(null); + setWindowsCaptureTargetPath(null); + setWindowsSystemAudioPath(null); + setWindowsMicAudioPath(null); + setWindowsOrphanedMicAudioPath(null); + setWindowsCaptureStopRequested(false); + setWindowsCapturePaused(false); + return { + success: false, + message: "Failed to start native Windows capture", + error: String(error), + }; + } + } + + if (process.platform !== "darwin") { + return { + success: false, + message: "Native screen recording is only available on macOS.", + }; + } + + if (nativeCaptureProcess && !nativeScreenRecordingActive) { + try { + nativeCaptureProcess.kill(); + } catch { + // ignore stale helper cleanup failures + } + setNativeCaptureProcess(null); + setNativeCaptureTargetPath(null); + setNativeCaptureStopRequested(false); + } + + if (nativeCaptureProcess) { + return { success: false, message: "A native screen recording is already active." }; + } + + let captProc: ChildProcessWithoutNullStreams | null = null; + try { + const recordingsDir = await getRecordingsDir(); + + // Warm up TCC: trigger an Electron-level screen capture API call so macOS + // activates the screen-recording grant for this process tree before the + // native helper binary spawns and calls SCStream.startCapture(). + try { + await desktopCapturer.getSources({ + types: ["screen"], + thumbnailSize: { width: 1, height: 1 }, + }); + } catch { + // non-fatal – the helper will report its own TCC status + } + + // Ensure microphone TCC is granted for this process tree when mic capture + // is requested, so the child helper inherits the grant. + if (options?.capturesMicrophone) { + const micStatus = systemPreferences.getMediaAccessStatus("microphone"); + if (micStatus !== "granted") { + await systemPreferences.askForMediaAccess("microphone"); + } + } + + const appName = normalizeDesktopSourceName(String(source?.appName ?? "")); + const ownAppName = normalizeDesktopSourceName(app.getName()); + if ( + !ALLOW_RECORDLY_WINDOW_CAPTURE && + source?.id?.startsWith("window:") && + appName && + (appName === ownAppName || appName === "recordly") + ) { + return { + success: false, + message: + "Cannot record Recordly windows. Please select another app window.", + }; + } + + const helperPath = await ensureNativeCaptureHelperBinary(); + const timestamp = Date.now(); + const outputPath = path.join(recordingsDir, `recording-${timestamp}.mp4`); + const capturesSystemAudio = Boolean(options?.capturesSystemAudio); + const capturesMicrophone = Boolean(options?.capturesMicrophone); + const systemAudioOutputPath = capturesSystemAudio + ? path.join(recordingsDir, `recording-${timestamp}.system.m4a`) + : null; + const microphoneOutputPath = capturesMicrophone + ? path.join(recordingsDir, `recording-${timestamp}.mic.m4a`) + : null; + const config: Record = { + fps: 60, + outputPath, + capturesSystemAudio, + capturesMicrophone, + }; + + if (options?.microphoneDeviceId) { + config.microphoneDeviceId = options.microphoneDeviceId; + } + + if (options?.microphoneLabel) { + config.microphoneLabel = options.microphoneLabel; + } + + if (systemAudioOutputPath) { + config.systemAudioOutputPath = systemAudioOutputPath; + } + + if (microphoneOutputPath) { + config.microphoneOutputPath = microphoneOutputPath; + } + + const windowId = parseWindowId(source?.id); + const screenId = Number(source?.display_id); + + if (Number.isFinite(windowId) && windowId && source?.id?.startsWith("window:")) { + config.windowId = windowId; + } else if (Number.isFinite(screenId) && screenId > 0) { + config.displayId = screenId; + } else { + config.displayId = Number(getScreen().getPrimaryDisplay().id); + } + + setNativeCaptureOutputBuffer(""); + setNativeCaptureTargetPath(outputPath); + setNativeCaptureSystemAudioPath(systemAudioOutputPath); + setNativeCaptureMicrophonePath(microphoneOutputPath); + setNativeCaptureStopRequested(false); + setNativeCapturePaused(false); + captProc = spawn(helperPath, [JSON.stringify(config)], { + cwd: recordingsDir, + stdio: ["pipe", "pipe", "pipe"], + }); + setNativeCaptureProcess(captProc); + attachNativeCaptureLifecycle(captProc); + + captProc.stdout.on("data", (chunk: Buffer) => { + setNativeCaptureOutputBuffer(nativeCaptureOutputBuffer + chunk.toString()); + }); + captProc.stderr.on("data", (chunk: Buffer) => { + setNativeCaptureOutputBuffer(nativeCaptureOutputBuffer + chunk.toString()); + }); + + await waitForNativeCaptureStart(captProc); + setNativeScreenRecordingActive(true); + + // If the native helper reported MICROPHONE_CAPTURE_UNAVAILABLE, it started + // capture without microphone. Clear the mic path so the renderer can fall + // back to a browser-side sidecar recording for the microphone track. + const micUnavailableNatively = nativeCaptureOutputBuffer.includes( + "MICROPHONE_CAPTURE_UNAVAILABLE", + ); + if (micUnavailableNatively) { + setNativeCaptureMicrophonePath(null); + } + + recordNativeCaptureDiagnostics({ + backend: "mac-screencapturekit", + phase: "start", + sourceId: source?.id ?? null, + sourceType: source?.sourceType ?? "unknown", + displayId: typeof config.displayId === "number" ? config.displayId : null, + helperPath, + outputPath, + systemAudioPath: systemAudioOutputPath, + microphonePath: nativeCaptureMicrophonePath, + processOutput: nativeCaptureOutputBuffer.trim() || undefined, + }); + return { success: true, microphoneFallbackRequired: micUnavailableNatively }; + } catch (error) { + console.error("Failed to start native ScreenCaptureKit recording:", error); + const errorStr = String(error); + + // Detect TCC (screen recording permission) errors and show a helpful dialog + if ( + errorStr.includes("declined TCC") || + errorStr.includes("declined TCCs") || + errorStr.includes("SCREEN_RECORDING_PERMISSION_DENIED") + ) { + const { response } = await dialog.showMessageBox({ + type: "warning", + title: "Screen Recording Permission Required", + message: + "Recordly needs screen recording permission to capture your screen.", + detail: "Please open System Settings > Privacy & Security > Screen Recording, make sure Recordly is toggled ON, then try recording again.", + buttons: ["Open System Settings", "Cancel"], + defaultId: 0, + cancelId: 1, + }); + if (response === 0) { + await shell.openExternal(getMacPrivacySettingsUrl("screen")); + } + try { + if (captProc) captProc.kill(); + } catch { + /* ignore */ + } + setNativeScreenRecordingActive(false); + setNativeCaptureProcess(null); + setNativeCaptureTargetPath(null); + setNativeCaptureSystemAudioPath(null); + setNativeCaptureMicrophonePath(null); + setNativeCaptureStopRequested(false); + setNativeCapturePaused(false); + return { + success: false, + message: + "Screen recording permission not granted. Please allow access in System Settings and restart the app.", + userNotified: true, + }; + } + + if (errorStr.includes("MICROPHONE_PERMISSION_DENIED")) { + const { response } = await dialog.showMessageBox({ + type: "warning", + title: "Microphone Permission Required", + message: "Recordly needs microphone permission to record audio.", + detail: "Please open System Settings > Privacy & Security > Microphone, make sure Recordly is toggled ON, then try recording again.", + buttons: ["Open System Settings", "Cancel"], + defaultId: 0, + cancelId: 1, + }); + if (response === 0) { + await shell.openExternal(getMacPrivacySettingsUrl("microphone")); + } + try { + if (captProc) captProc.kill(); + } catch { + /* ignore */ + } + setNativeScreenRecordingActive(false); + setNativeCaptureProcess(null); + setNativeCaptureTargetPath(null); + setNativeCaptureSystemAudioPath(null); + setNativeCaptureMicrophonePath(null); + setNativeCaptureStopRequested(false); + setNativeCapturePaused(false); + return { + success: false, + message: + "Microphone permission not granted. Please allow access in System Settings.", + userNotified: true, + }; + } + + recordNativeCaptureDiagnostics({ + backend: "mac-screencapturekit", + phase: "start", + sourceId: source?.id ?? null, + sourceType: source?.sourceType ?? "unknown", + helperPath: getNativeCaptureHelperBinaryPath(), + outputPath: nativeCaptureTargetPath, + systemAudioPath: nativeCaptureSystemAudioPath, + microphonePath: nativeCaptureMicrophonePath, + processOutput: nativeCaptureOutputBuffer.trim() || undefined, + fileSizeBytes: await getFileSizeIfPresent(nativeCaptureTargetPath), + error: String(error), + }); + try { + if (captProc) captProc.kill(); + } catch { + // ignore cleanup failures + } + setNativeScreenRecordingActive(false); + setNativeCaptureProcess(null); + setNativeCaptureTargetPath(null); + setNativeCaptureSystemAudioPath(null); + setNativeCaptureMicrophonePath(null); + setNativeCaptureStopRequested(false); + setNativeCapturePaused(false); + return { + success: false, + message: "Failed to start native ScreenCaptureKit recording", + error: String(error), + }; + } + }, + ); + + ipcMain.handle("stop-native-screen-recording", async () => { + const start = Date.now(); + console.log("[PERF:MAIN] Handler: stop-native-screen-recording: STARTED"); + try { + // Windows native capture stop path + if (process.platform === "win32" && windowsNativeCaptureActive) { + let stagedTempVideoPath: string | null = null; + let stagedTempSystemAudioPath: string | null = null; + let stagedTempMicAudioPath: string | null = null; + try { + if (!windowsCaptureProcess) { + throw new Error("Native Windows capture process is not running"); + } + + const proc = windowsCaptureProcess; + const preferredVideoPath = windowsCaptureTargetPath; + const preferredOrphanedMicAudioPath = windowsOrphanedMicAudioPath; + const diagnosticsSystemAudioPath = windowsSystemAudioPath; + const diagnosticsMicAudioPath = windowsMicAudioPath; + setWindowsCaptureStopRequested(true); + proc.stdin.write("stop\n"); + const tempVideoPath = await waitForWindowsCaptureStop(proc); + stagedTempVideoPath = tempVideoPath; + const finalVideoPath = preferredVideoPath ?? tempVideoPath; + + // Native Windows capture results are initially written to a safe temporary path + // (to avoid encoding failures with non-ASCII characters). We move them to the final + // destination now using Node.js, which handles Unicode paths correctly. + if (tempVideoPath !== finalVideoPath) { + await moveFileWithOverwrite(tempVideoPath, finalVideoPath); + } + + if (windowsSystemAudioPath && tempVideoPath.endsWith(".mp4")) { + const tempAudioPath = tempVideoPath.replace(".mp4", ".system.wav"); + stagedTempSystemAudioPath = tempAudioPath; + const finalAudioPath = windowsSystemAudioPath; + if (await pathExists(tempAudioPath)) { + await moveFileWithOverwrite(tempAudioPath, finalAudioPath); + const tempJson = tempAudioPath + ".json"; + if (await pathExists(tempJson)) { + await moveFileWithOverwrite(tempJson, finalAudioPath + ".json"); + } + } + } + + if (windowsMicAudioPath && tempVideoPath.endsWith(".mp4")) { + const tempMicPath = tempVideoPath.replace(".mp4", ".mic.wav"); + stagedTempMicAudioPath = tempMicPath; + const finalMicPath = windowsMicAudioPath; + if (await pathExists(tempMicPath)) { + await moveFileWithOverwrite(tempMicPath, finalMicPath); + const tempJson = tempMicPath + ".json"; + if (await pathExists(tempJson)) { + await moveFileWithOverwrite(tempJson, finalMicPath + ".json"); + } + } + } + const validation = await validateRecordedVideo(finalVideoPath); + + setWindowsCaptureProcess(null); + setWindowsNativeCaptureActive(false); + setNativeScreenRecordingActive(false); + setWindowsCaptureTargetPath(null); + setWindowsCaptureStopRequested(false); + setWindowsCapturePaused(false); + setWindowsOrphanedMicAudioPath(null); + await cleanupWindowsOrphanedMicAudioPath(preferredOrphanedMicAudioPath); + setWindowsPendingVideoPath(finalVideoPath); + recordNativeCaptureDiagnostics({ + backend: "windows-wgc", + phase: "stop", + outputPath: finalVideoPath, + systemAudioPath: diagnosticsSystemAudioPath, + microphonePath: diagnosticsMicAudioPath, + processOutput: windowsCaptureOutputBuffer.trim() || undefined, + fileSizeBytes: validation.fileSizeBytes, + }); + await writeWindowsRecordingDiagnostics(finalVideoPath, { + phase: "stop", + outputPath: finalVideoPath, + systemAudioPath: diagnosticsSystemAudioPath, + microphonePath: diagnosticsMicAudioPath, + processOutput: windowsCaptureOutputBuffer.trim() || undefined, + details: { + fileSizeBytes: validation.fileSizeBytes, + durationSeconds: validation.durationSeconds, + }, + }); + + // Persist cursor telemetry before returning so the editor can find it immediately + snapshotCursorTelemetryForPersistence(); + try { + await persistPendingCursorTelemetry(finalVideoPath); + } catch (error) { + console.warn( + "Failed to persist cursor telemetry during native stop:", + error, + ); + } + + return { success: true, path: finalVideoPath }; + } catch (error) { + console.error("Failed to stop native Windows capture:", error); + const fallbackPath = await resolveExistingPath( + windowsCaptureTargetPath, + stagedTempVideoPath, + ); + const recoveredSystemAudioPath = await resolveExistingPath( + windowsSystemAudioPath, + stagedTempSystemAudioPath, + ); + const recoveredMicAudioPath = await resolveExistingPath( + windowsMicAudioPath, + stagedTempMicAudioPath, + ); + const fallbackOrphanedMicAudioPath = windowsOrphanedMicAudioPath; + const diagnosticsSystemAudioPath = + recoveredSystemAudioPath ?? windowsSystemAudioPath; + const diagnosticsMicAudioPath = recoveredMicAudioPath ?? windowsMicAudioPath; + setWindowsNativeCaptureActive(false); + setNativeScreenRecordingActive(false); + setWindowsCaptureProcess(null); + setWindowsCaptureTargetPath(null); + setWindowsCaptureStopRequested(false); + setWindowsCapturePaused(false); + setWindowsOrphanedMicAudioPath(null); + + if (fallbackPath) { + try { + const validation = await validateRecordedVideo(fallbackPath); + setWindowsPendingVideoPath(fallbackPath); + setWindowsSystemAudioPath(recoveredSystemAudioPath); + setWindowsMicAudioPath(recoveredMicAudioPath); + await cleanupWindowsOrphanedMicAudioPath(fallbackOrphanedMicAudioPath); + recordNativeCaptureDiagnostics({ + backend: "windows-wgc", + phase: "stop", + outputPath: fallbackPath, + systemAudioPath: diagnosticsSystemAudioPath, + microphonePath: diagnosticsMicAudioPath, + processOutput: windowsCaptureOutputBuffer.trim() || undefined, + fileSizeBytes: validation.fileSizeBytes, + error: String(error), + }); + await writeWindowsRecordingDiagnostics(fallbackPath, { + phase: "stop", + outputPath: fallbackPath, + systemAudioPath: diagnosticsSystemAudioPath, + microphonePath: diagnosticsMicAudioPath, + processOutput: windowsCaptureOutputBuffer.trim() || undefined, + error: String(error), + details: { + fileSizeBytes: validation.fileSizeBytes, + durationSeconds: validation.durationSeconds, + recoveredAfterStopFailure: true, + }, + }); + return { success: true, path: fallbackPath }; + } catch { + // File is absent or failed validation. + } + } + + setWindowsSystemAudioPath(null); + setWindowsMicAudioPath(null); + setWindowsPendingVideoPath(null); + await cleanupWindowsOrphanedMicAudioPath(fallbackOrphanedMicAudioPath); + + recordNativeCaptureDiagnostics({ + backend: "windows-wgc", + phase: "stop", + outputPath: fallbackPath, + systemAudioPath: diagnosticsSystemAudioPath, + microphonePath: diagnosticsMicAudioPath, + processOutput: windowsCaptureOutputBuffer.trim() || undefined, + fileSizeBytes: await getFileSizeIfPresent(fallbackPath), + error: String(error), + }); + await writeWindowsRecordingDiagnostics(fallbackPath, { + phase: "stop", + outputPath: fallbackPath, + systemAudioPath: diagnosticsSystemAudioPath, + microphonePath: diagnosticsMicAudioPath, + processOutput: windowsCaptureOutputBuffer.trim() || undefined, + error: String(error), + details: { + fileSizeBytes: await getFileSizeIfPresent(fallbackPath), + }, + }); + + return { + success: false, + message: "Failed to stop native Windows capture", + error: String(error), + }; + } + } + + if (process.platform !== "darwin") { + return { + success: false, + message: "Native screen recording is only available on macOS.", + }; + } + + if (!nativeScreenRecordingActive) { + const recovered = await recoverNativeMacCaptureOutput(); + if (recovered) { + return recovered; + } + + return { success: false, message: "No native screen recording is active." }; + } + + try { + if (!nativeCaptureProcess) { + throw new Error("Native capture helper process is not running"); + } + + const process = nativeCaptureProcess; + const preferredVideoPath = nativeCaptureTargetPath; + const preferredSystemAudioPath = nativeCaptureSystemAudioPath; + const preferredMicrophonePath = nativeCaptureMicrophonePath; + console.log( + "[stop-native] Audio paths — system:", + preferredSystemAudioPath, + "mic:", + preferredMicrophonePath, + ); + setNativeCaptureStopRequested(true); + process.stdin.write("stop\n"); + const tempVideoPath = await waitForNativeCaptureStop(process); + console.log("[stop-native] Helper stopped, tempVideoPath:", tempVideoPath); + setNativeCaptureProcess(null); + setNativeScreenRecordingActive(false); + setNativeCaptureTargetPath(null); + setNativeCaptureSystemAudioPath(null); + setNativeCaptureMicrophonePath(null); + setNativeCaptureStopRequested(false); + setNativeCapturePaused(false); + + const finalVideoPath = preferredVideoPath ?? tempVideoPath; + if (tempVideoPath !== finalVideoPath) { + await moveFileWithOverwrite(tempVideoPath, finalVideoPath); + } + + if (preferredSystemAudioPath || preferredMicrophonePath) { + console.log( + "[stop-native] Attempting audio mux (merging separate tracks) into:", + finalVideoPath, + ); + try { + await muxNativeMacRecordingWithAudio( + finalVideoPath, + preferredSystemAudioPath, + preferredMicrophonePath, + ); + console.log("[stop-native] Audio mux completed successfully"); + } catch (error) { + console.warn( + "[stop-native] Audio mux failed (video still has inline audio):", + error, + ); + } + } else { + console.log("[stop-native] No separate audio tracks to mux"); + } + + return await finalizeStoredVideo(finalVideoPath); + } catch (error) { + console.error("Failed to stop native ScreenCaptureKit recording:", error); + const fallbackPath = nativeCaptureTargetPath; + const fallbackSystemAudioPath = nativeCaptureSystemAudioPath; + const fallbackMicrophonePath = nativeCaptureMicrophonePath; + const fallbackFileSizeBytes = await getFileSizeIfPresent(fallbackPath); + setNativeScreenRecordingActive(false); + setNativeCaptureProcess(null); + setNativeCaptureTargetPath(null); + setNativeCaptureSystemAudioPath(null); + setNativeCaptureMicrophonePath(null); + setNativeCaptureStopRequested(false); + setNativeCapturePaused(false); + + recordNativeCaptureDiagnostics({ + backend: "mac-screencapturekit", + phase: "stop", + sourceId: lastNativeCaptureDiagnostics?.sourceId ?? null, + sourceType: lastNativeCaptureDiagnostics?.sourceType ?? "unknown", + displayId: lastNativeCaptureDiagnostics?.displayId ?? null, + displayBounds: lastNativeCaptureDiagnostics?.displayBounds ?? null, + windowHandle: lastNativeCaptureDiagnostics?.windowHandle ?? null, + helperPath: lastNativeCaptureDiagnostics?.helperPath ?? null, + outputPath: fallbackPath, + systemAudioPath: fallbackSystemAudioPath, + microphonePath: fallbackMicrophonePath, + osRelease: lastNativeCaptureDiagnostics?.osRelease, + supported: lastNativeCaptureDiagnostics?.supported, + helperExists: lastNativeCaptureDiagnostics?.helperExists, + processOutput: nativeCaptureOutputBuffer.trim() || undefined, + fileSizeBytes: fallbackFileSizeBytes, + error: String(error), + }); + + // Try to recover: if the target file exists on disk, finalize with it + if (fallbackPath) { + try { + await fs.access(fallbackPath); + console.log( + "[stop-native-screen-recording] Recovering with fallback path:", + fallbackPath, + ); + if (fallbackSystemAudioPath || fallbackMicrophonePath) { + try { + await muxNativeMacRecordingWithAudio( + fallbackPath, + fallbackSystemAudioPath, + fallbackMicrophonePath, + ); + } catch (muxError) { + console.warn( + "Failed to mux recovered native macOS audio into capture:", + muxError, + ); + } + } + return await finalizeStoredVideo(fallbackPath); + } catch { + // File doesn't exist or isn't accessible + } + } + + const recovered = await recoverNativeMacCaptureOutput(); + if (recovered) { + return recovered; + } + + return { + success: false, + message: "Failed to stop native ScreenCaptureKit recording", + error: String(error), + }; + } + } finally { + console.log( + `[PERF:MAIN] Handler: stop-native-screen-recording: COMPLETED in ${Date.now() - start}ms`, + ); + } + }); + + ipcMain.handle("recover-native-screen-recording", async () => { + if (process.platform !== "darwin") { + return { + success: false, + message: "Native screen recording recovery is only available on macOS.", + }; + } + + const recovered = await recoverNativeMacCaptureOutput(); + if (recovered) { + return recovered; + } + + return { + success: false, + message: "No recoverable native macOS recording output was found.", + }; + }); + + ipcMain.handle("pause-native-screen-recording", async () => { + if (process.platform === "win32") { + if (!windowsNativeCaptureActive || !windowsCaptureProcess) { + return { success: false, message: "No native Windows screen recording is active." }; + } + + if (windowsCapturePaused) { + return { success: true }; + } + + try { + windowsCaptureProcess.stdin.write("pause\n"); + setWindowsCapturePaused(true); + return { success: true }; + } catch (error) { + return { + success: false, + message: "Failed to pause native Windows capture", + error: String(error), + }; + } + } + + if (process.platform !== "darwin") { + return { + success: false, + message: "Native screen recording is only available on macOS.", + }; + } + + if (!nativeScreenRecordingActive || !nativeCaptureProcess) { + return { success: false, message: "No native screen recording is active." }; + } + + if (nativeCapturePaused) { + return { success: true }; + } + + try { + const commandApplied = waitForNativeCaptureCommand( + nativeCaptureProcess, + "Recording paused", + ); + nativeCaptureProcess.stdin.write("pause\n"); + await commandApplied; + setNativeCapturePaused(true); + return { success: true }; + } catch (error) { + return { + success: false, + message: "Failed to pause native screen recording", + error: String(error), + }; + } + }); + + ipcMain.handle("resume-native-screen-recording", async () => { + if (process.platform === "win32") { + if (!windowsNativeCaptureActive || !windowsCaptureProcess) { + return { success: false, message: "No native Windows screen recording is active." }; + } + + if (!windowsCapturePaused) { + return { success: true }; + } + + try { + windowsCaptureProcess.stdin.write("resume\n"); + setWindowsCapturePaused(false); + return { success: true }; + } catch (error) { + return { + success: false, + message: "Failed to resume native Windows capture", + error: String(error), + }; + } + } + + if (process.platform !== "darwin") { + return { + success: false, + message: "Native screen recording is only available on macOS.", + }; + } + + if (!nativeScreenRecordingActive || !nativeCaptureProcess) { + return { success: false, message: "No native screen recording is active." }; + } + + if (!nativeCapturePaused) { + return { success: true }; + } + + try { + const commandApplied = waitForNativeCaptureCommand( + nativeCaptureProcess, + "Recording resumed", + ); + nativeCaptureProcess.stdin.write("resume\n"); + await commandApplied; + setNativeCapturePaused(false); + return { success: true }; + } catch (error) { + return { + success: false, + message: "Failed to resume native screen recording", + error: String(error), + }; + } + }); + + ipcMain.handle("get-system-cursor-assets", async () => { + try { + return { success: true, cursors: await getSystemCursorAssets() }; + } catch (error) { + console.error("Failed to load system cursor assets:", error); + return { success: false, cursors: {}, error: String(error) }; + } + }); + + ipcMain.handle("is-native-windows-capture-available", async () => { + return { available: await isNativeWindowsCaptureAvailable() }; + }); + + ipcMain.handle("get-last-native-capture-diagnostics", async () => { + return { success: true, diagnostics: lastNativeCaptureDiagnostics }; + }); + + ipcMain.handle("get-video-audio-fallback-paths", async (_event, videoPath: string) => { + if (!videoPath) { + return { success: true, paths: [], startDelayMsByPath: {} }; + } + + try { + const { paths, startDelayMsByPath } = await getCompanionAudioFallbackInfo(videoPath); + await Promise.all([ + rememberApprovedLocalReadPath(videoPath), + ...paths.map((fallbackPath) => rememberApprovedLocalReadPath(fallbackPath)), + ]); + return { success: true, paths, startDelayMsByPath }; + } catch (error) { + console.error("Failed to resolve companion audio fallback paths:", error); + return { success: false, paths: [], startDelayMsByPath: {}, error: String(error) }; + } + }); + + ipcMain.handle("mux-native-windows-recording", async (_event, expectedDurationMs?: number) => { + const start = Date.now(); + console.log("[PERF:MAIN] Handler: mux-native-windows-recording: STARTED"); + try { + const videoPath = windowsPendingVideoPath; + const orphanedMicAudioPath = windowsOrphanedMicAudioPath; + const diagnosticsSystemAudioPath = windowsSystemAudioPath; + const diagnosticsMicAudioPath = windowsMicAudioPath; + setWindowsPendingVideoPath(null); + setWindowsOrphanedMicAudioPath(null); + + if (!videoPath) { + return { success: false, message: "No native Windows video pending for mux" }; + } + + try { + await writeWindowsRecordingDiagnostics(videoPath, { + phase: "mux-start", + expectedDurationMs, + outputPath: videoPath, + systemAudioPath: diagnosticsSystemAudioPath, + microphonePath: diagnosticsMicAudioPath, + details: { + hasSystemAudio: Boolean(diagnosticsSystemAudioPath), + hasMicrophone: Boolean(diagnosticsMicAudioPath), + hasOrphanedMicrophone: Boolean(orphanedMicAudioPath), + }, + }); + console.log("[mux-win] Optimization active: skipping video padding."); + + let muxDetails: unknown = null; + if (diagnosticsSystemAudioPath || diagnosticsMicAudioPath) { + muxDetails = await muxNativeWindowsVideoWithAudio( + videoPath, + diagnosticsSystemAudioPath, + diagnosticsMicAudioPath, + ); + setWindowsSystemAudioPath(null); + setWindowsMicAudioPath(null); + } + + recordNativeCaptureDiagnostics({ + backend: "windows-wgc", + phase: "mux", + outputPath: videoPath, + fileSizeBytes: await getFileSizeIfPresent(videoPath), + }); + await writeWindowsRecordingDiagnostics(videoPath, { + phase: "mux-complete", + expectedDurationMs, + outputPath: videoPath, + systemAudioPath: diagnosticsSystemAudioPath, + microphonePath: diagnosticsMicAudioPath, + details: { + fileSizeBytes: await getFileSizeIfPresent(videoPath), + mux: muxDetails, + }, + }); + await cleanupWindowsOrphanedMicAudioPath(orphanedMicAudioPath); + return await finalizeStoredVideo(videoPath); + } catch (error) { + console.error("Failed to mux native Windows recording:", error); + recordNativeCaptureDiagnostics({ + backend: "windows-wgc", + phase: "mux", + outputPath: videoPath, + systemAudioPath: diagnosticsSystemAudioPath, + microphonePath: diagnosticsMicAudioPath, + fileSizeBytes: await getFileSizeIfPresent(videoPath), + error: String(error), + }); + await writeWindowsRecordingDiagnostics(videoPath, { + phase: "mux-error", + expectedDurationMs, + outputPath: videoPath, + systemAudioPath: diagnosticsSystemAudioPath, + microphonePath: diagnosticsMicAudioPath, + error: String(error), + details: { + fileSizeBytes: await getFileSizeIfPresent(videoPath), + }, + }); + setWindowsSystemAudioPath(null); + setWindowsMicAudioPath(null); + await cleanupWindowsOrphanedMicAudioPath(orphanedMicAudioPath); + try { + return await finalizeStoredVideo(videoPath); + } catch { + try { + await validateRecordedVideo(videoPath); + return { + success: false, + path: videoPath, + message: "Failed to mux native Windows recording", + error: String(error), + }; + } catch { + // The fallback path is not safely playable; surface the original mux error. + } + + return { + success: false, + message: "Failed to mux native Windows recording", + error: String(error), + }; + } + } + } finally { + console.log( + `[PERF:MAIN] Handler: mux-native-windows-recording: COMPLETED in ${Date.now() - start}ms`, + ); + } + }); + + ipcMain.handle("start-ffmpeg-recording", async (_, source: SelectedSource) => { + if (ffmpegCaptureProcess) { + return { success: false, message: "An FFmpeg recording is already active." }; + } + + try { + const recordingsDir = await getRecordingsDir(); + const ffmpegPath = getFfmpegBinaryPath(); + const outputPath = path.join(recordingsDir, `recording-${Date.now()}.mp4`); + const args = await buildFfmpegCaptureArgs(source, outputPath); + + setFfmpegCaptureOutputBuffer(""); + setFfmpegCaptureTargetPath(outputPath); + const ffProc = spawn(ffmpegPath, args, { + cwd: recordingsDir, + stdio: ["pipe", "pipe", "pipe"], + }); + setFfmpegCaptureProcess(ffProc); + + ffProc.stdout.on("data", (chunk: Buffer) => { + setFfmpegCaptureOutputBuffer(ffmpegCaptureOutputBuffer + chunk.toString()); + }); + ffProc.stderr.on("data", (chunk: Buffer) => { + setFfmpegCaptureOutputBuffer(ffmpegCaptureOutputBuffer + chunk.toString()); + }); + + await waitForFfmpegCaptureStart(ffProc); + setFfmpegScreenRecordingActive(true); + return { success: true }; + } catch (error) { + console.error("Failed to start FFmpeg recording:", error); + setFfmpegScreenRecordingActive(false); + setFfmpegCaptureProcess(null); + setFfmpegCaptureTargetPath(null); + return { + success: false, + message: "Failed to start FFmpeg recording", + error: String(error), + }; + } + }); + + ipcMain.handle("stop-ffmpeg-recording", async () => { + if (!ffmpegScreenRecordingActive) { + return { success: false, message: "No FFmpeg recording is active." }; + } + + try { + if (!ffmpegCaptureProcess || !ffmpegCaptureTargetPath) { + throw new Error("FFmpeg process is not running"); + } + + const process = ffmpegCaptureProcess; + const outputPath = ffmpegCaptureTargetPath; + process.stdin.write("q\n"); + const finalVideoPath = await waitForFfmpegCaptureStop(process, outputPath); + + setFfmpegCaptureProcess(null); + setFfmpegCaptureTargetPath(null); + setFfmpegScreenRecordingActive(false); + + return await finalizeStoredVideo(finalVideoPath); + } catch (error) { + console.error("Failed to stop FFmpeg recording:", error); + try { + ffmpegCaptureProcess?.kill(); + } catch { + // ignore cleanup failures + } + setFfmpegCaptureProcess(null); + setFfmpegCaptureTargetPath(null); + setFfmpegScreenRecordingActive(false); + return { + success: false, + message: "Failed to stop FFmpeg recording", + error: String(error), + }; + } + }); + + ipcMain.handle( + "store-microphone-sidecar", + async ( + _, + audioData: ArrayBuffer, + videoPath: string, + options?: { + startDelayMs?: number; + browserMicrophoneProfile?: string; + requestedBrowserMicrophoneProfile?: string | null; + requestedConstraints?: unknown; + mediaTrackSettings?: Record; + audioInputDevices?: unknown; + mediaRecorder?: unknown; + chunkEvents?: unknown; + pauseIntervals?: unknown; + }, + ) => { + const baseName = videoPath.replace(/\.[^.]+$/, ""); + const sidecarPath = `${baseName}.mic.wav`; + const sourceWebmPath = `${baseName}.mic.source.webm`; + const tempWebmPath = `${sourceWebmPath}.tmp`; + + try { + await fs.writeFile(tempWebmPath, Buffer.from(audioData)); + await execFileAsync( + getFfmpegBinaryPath(), + [ + "-y", + "-hide_banner", + "-nostdin", + "-nostats", + "-i", + tempWebmPath, + "-vn", + "-ac", + "1", + "-ar", + "48000", + "-af", + [ + ...getBrowserMicSidecarFilters(options?.browserMicrophoneProfile), + "aresample=async=1:first_pts=0", + ].join(","), + "-c:a", + "pcm_s16le", + sidecarPath, + ], + { timeout: 120000, maxBuffer: 10 * 1024 * 1024 }, + ); + if (shouldKeepRecordingAudioSidecars()) { + await fs.rename(tempWebmPath, sourceWebmPath).catch(async () => { + await fs.copyFile(tempWebmPath, sourceWebmPath); + await fs.rm(tempWebmPath, { force: true }); + }); + } else { + await fs.rm(tempWebmPath, { force: true }); + } + const startDelayMs = options?.startDelayMs; + const mediaTrackSettings = pickPrimitiveRecord(options?.mediaTrackSettings); + const audioInputDevices = pickAudioInputDevices(options?.audioInputDevices); + const mediaRecorder = isRecord(options?.mediaRecorder) + ? { + ...(typeof options.mediaRecorder.mimeType === "string" + ? { mimeType: options.mediaRecorder.mimeType } + : {}), + ...(typeof options.mediaRecorder.audioBitsPerSecond === "number" + ? { + audioBitsPerSecond: Math.round( + options.mediaRecorder.audioBitsPerSecond, + ), + } + : {}), + ...(typeof options.mediaRecorder.timesliceMs === "number" + ? { timesliceMs: Math.round(options.mediaRecorder.timesliceMs) } + : {}), + } + : null; + const chunkEvents = pickMicrophoneChunkEvents(options?.chunkEvents); + const pauseIntervals = pickMicrophonePauseIntervals(options?.pauseIntervals); + const chunkTiming = + chunkEvents || pauseIntervals + ? summarizeMicrophoneChunkTiming( + chunkEvents, + pauseIntervals, + mediaRecorder?.timesliceMs, + ) + : null; + const metadata = { + ...(Number.isFinite(startDelayMs) && (startDelayMs ?? 0) >= 0 + ? { startDelayMs: Math.round(startDelayMs ?? 0) } + : {}), + ...(typeof options?.browserMicrophoneProfile === "string" + ? { browserMicrophoneProfile: options.browserMicrophoneProfile } + : {}), + ...(typeof options?.requestedBrowserMicrophoneProfile === "string" + ? { + requestedBrowserMicrophoneProfile: + options.requestedBrowserMicrophoneProfile, + } + : {}), + ...(isRecord(options?.requestedConstraints) + ? { requestedConstraints: options.requestedConstraints } + : {}), + ...(mediaTrackSettings ? { mediaTrackSettings } : {}), + ...(audioInputDevices ? { audioInputDevices } : {}), + ...(mediaRecorder && Object.keys(mediaRecorder).length > 0 + ? { mediaRecorder } + : {}), + ...(chunkEvents ? { chunkEvents } : {}), + ...(pauseIntervals ? { pauseIntervals } : {}), + ...(chunkTiming ? { chunkTiming } : {}), + }; + if (Object.keys(metadata).length > 0) { + try { + await fs.writeFile(`${sidecarPath}.json`, JSON.stringify(metadata)); + } catch (metadataError) { + console.warn( + "Failed to store microphone sidecar timing metadata:", + metadataError, + ); + } + } + await writeRecordingDiagnosticsSnapshot(videoPath, { + backend: "browser-store", + phase: "mic-sidecar", + outputPath: videoPath, + microphonePath: sidecarPath, + details: { + sourceBytes: audioData.byteLength, + sourceWebmPath: shouldKeepRecordingAudioSidecars() ? sourceWebmPath : null, + metadata, + }, + }).catch((diagnosticsError) => { + console.warn( + "Failed to write microphone sidecar diagnostics:", + diagnosticsError, + ); + }); + return { success: true, path: sidecarPath }; + } catch (error) { + await Promise.all([ + fs.rm(tempWebmPath, { force: true }).catch(() => undefined), + fs.rm(sidecarPath, { force: true }).catch(() => undefined), + ]); + console.error("Failed to store microphone sidecar:", error); + return { success: false, error: String(error) }; + } + }, + ); + + ipcMain.handle("store-recorded-video", async (_, videoData: ArrayBuffer, fileName: unknown) => { + try { + const recordingsDir = await getRecordingsDir(); + const videoPath = resolveRecordedVideoStoragePath(recordingsDir, fileName); + await fs.writeFile(videoPath, Buffer.from(videoData)); + return await finalizeStoredVideo(videoPath); + } catch (error) { + console.error("Failed to store video:", error); + return { + success: false, + message: "Failed to store video", + error: String(error), + }; + } + }); + + ipcMain.handle("get-recorded-video-path", async () => { + try { + const recordingsDir = await getRecordingsDir(); + const entries = await fs.readdir(recordingsDir, { withFileTypes: true }); + const candidates = await Promise.all( + entries + .filter( + (entry) => + entry.isFile() && /^recording-\d+\.(webm|mov|mp4)$/i.test(entry.name), + ) + .map(async (entry) => { + const fullPath = path.join(recordingsDir, entry.name); + const stat = await fs.stat(fullPath).catch(() => null); + return stat ? { path: fullPath, mtimeMs: stat.mtimeMs } : null; + }), + ); + const sortedCandidates = candidates + .filter( + (candidate): candidate is { path: string; mtimeMs: number } => + candidate !== null, + ) + .sort((left, right) => right.mtimeMs - left.mtimeMs); + + for (const candidate of sortedCandidates) { + try { + await validateRecordedVideo(candidate.path); + return { success: true, path: candidate.path }; + } catch (error) { + console.warn( + "Skipping unusable recovered recording candidate:", + candidate.path, + error, + ); + } + } + + if (sortedCandidates.length === 0) { + return { success: false, message: "No recorded video found" }; + } + + return { success: false, message: "No usable recorded video found" }; + } catch (error) { + console.error("Failed to get video path:", error); + return { success: false, message: "Failed to get video path", error: String(error) }; + } + }); + + ipcMain.handle("set-recording-state", (_, recording: boolean) => { + if (recording) { + stopCursorCapture(); + stopInteractionCapture(); + startWindowBoundsCapture(); + void startNativeCursorMonitor(); + setIsCursorCaptureActive(true); + setActiveCursorSamples([]); + setPendingCursorSamples([]); + setCursorCaptureStartTimeMs(Date.now()); + resetCursorCaptureClock(); + setLinuxCursorScreenPoint(null); + setLastLeftClick(null); + sampleCursorPoint(); + startCursorSampling(); + void startInteractionCapture(); + } else { + setIsCursorCaptureActive(false); + stopCursorCapture(); + stopInteractionCapture(); + stopWindowBoundsCapture(); + stopNativeCursorMonitor(); + showCursor(); + setLinuxCursorScreenPoint(null); + resetCursorCaptureClock(); + snapshotCursorTelemetryForPersistence(); + setActiveCursorSamples([]); + } + + const source = selectedSource || { name: "Screen" }; + BrowserWindow.getAllWindows().forEach((window) => { + if (!window.isDestroyed()) { + window.webContents.send("recording-state-changed", { + recording, + sourceName: source.name, + }); + } + }); + + if (onRecordingStateChange) { + onRecordingStateChange(recording, source.name); + } + }); + + ipcMain.handle("pause-cursor-capture", (_, pausedAtMs?: unknown) => { + pauseCursorCaptureAtBoundary(normalizeRendererTimestampMs(pausedAtMs)); + return { success: true }; + }); + + ipcMain.handle("resume-cursor-capture", (_, resumedAtMs?: unknown) => { + resumeCursorCapture(normalizeRendererTimestampMs(resumedAtMs)); + sampleCursorPoint(); + return { success: true }; + }); + + ipcMain.handle("get-cursor-telemetry", async (_, videoPath?: string) => { + const targetVideoPath = normalizeVideoSourcePath(videoPath ?? currentVideoPath); + if (!targetVideoPath) { + return { success: true, samples: [] }; + } + + const telemetryPath = getTelemetryPathForVideo(targetVideoPath); + try { + const content = await fs.readFile(telemetryPath, "utf-8"); + const parsed = parseJsonWithByteOrderMark(content); + const samples = normalizeCursorTelemetrySamples(parsed); + + return { success: true, samples }; + } catch (error) { + const nodeError = error as NodeJS.ErrnoException; + if (nodeError.code === "ENOENT") { + return { success: true, samples: [] }; + } + console.error("Failed to load cursor telemetry:", error); + return { + success: false, + message: "Failed to load cursor telemetry", + error: String(error), + samples: [], + }; + } + }); + + ipcMain.handle( + "set-cursor-telemetry", + async (_, videoPath: string | undefined, samples: CursorTelemetryPoint[]) => { + const targetVideoPath = normalizeVideoSourcePath(videoPath ?? currentVideoPath); + if (!targetVideoPath) { + return { + success: false, + samples: [], + message: "No video path available for cursor telemetry", + error: "Missing video path", + }; + } + + try { + const normalizedSamples = await writeCursorTelemetry(targetVideoPath, samples); + return { success: true, samples: normalizedSamples }; + } catch (error) { + console.error("Failed to save cursor telemetry:", error); + return { + success: false, + samples: [], + message: "Failed to save cursor telemetry", + error: String(error), + }; + } + }, + ); +} diff --git a/electron/ipc/register/settings.ts b/electron/ipc/register/settings.ts new file mode 100644 index 000000000..93aee8783 --- /dev/null +++ b/electron/ipc/register/settings.ts @@ -0,0 +1,268 @@ +import fs from "node:fs/promises"; +import { app, ipcMain } from "electron"; +import { hasAppSetting, readAppSettingsStore, writeAppSettingsStore } from "../../appSettingsStore"; +import { hideCursor } from "../../cursorHider"; +import { closeCountdownWindow, createCountdownWindow, getCountdownWindow } from "../../windows"; +import { COUNTDOWN_SETTINGS_FILE, RECORDINGS_SETTINGS_FILE, SHORTCUTS_FILE } from "../constants"; +import { + createRecordingPreferencesStore, + type RecordingPreferencesPatch, +} from "../settings/recordingPreferencesStore"; +import { + countdownCancelled, + countdownInProgress, + countdownRemaining, + countdownTimer, + setCountdownCancelled, + setCountdownInProgress, + setCountdownRemaining, + setCountdownTimer, +} from "../state"; +import { parseJsonWithByteOrderMark } from "../utils"; + +const BROWSER_MICROPHONE_PROFILE_ENV = "RECORDLY_BROWSER_MIC_PROFILE"; +const DEFAULT_BROWSER_MICROPHONE_PROFILE = "processed"; +const recordingPreferencesStore = createRecordingPreferencesStore(RECORDINGS_SETTINGS_FILE); +const BROWSER_MICROPHONE_PROFILES = new Set([ + "processed", + "no-agc", + "no-echo", + "no-noise-suppression", + "raw", +]); + +function getBrowserMicrophoneProfileFromEnv() { + const requested = process.env[BROWSER_MICROPHONE_PROFILE_ENV]?.trim() || null; + const normalized = requested?.toLowerCase() ?? DEFAULT_BROWSER_MICROPHONE_PROFILE; + return { + browserMicrophoneProfile: BROWSER_MICROPHONE_PROFILES.has(normalized) + ? normalized + : DEFAULT_BROWSER_MICROPHONE_PROFILE, + requestedBrowserMicrophoneProfile: requested, + }; +} + +export function registerSettingsHandlers() { + ipcMain.handle("app:getVersion", () => { + return app.getVersion(); + }); + + ipcMain.handle("get-platform", () => { + return process.platform; + }); + + ipcMain.on("app-settings:get", (event, key: unknown) => { + try { + if (typeof key !== "string" || key.length === 0) { + event.returnValue = { success: false, value: null }; + return; + } + + const store = readAppSettingsStore(); + event.returnValue = { + success: true, + value: hasAppSetting(store, key) ? store[key] : null, + }; + } catch (error) { + console.error("Failed to read app setting:", error); + event.returnValue = { success: false, value: null }; + } + }); + + ipcMain.on("app-settings:set", (event, key: unknown, value: unknown) => { + try { + if (typeof key !== "string" || key.length === 0) { + event.returnValue = { success: false }; + return; + } + + const store = readAppSettingsStore(); + store[key] = value; + writeAppSettingsStore(store); + event.returnValue = { success: true }; + } catch (error) { + console.error("Failed to save app setting:", error); + event.returnValue = { success: false }; + } + }); + + // --------------------------------------------------------------------------- + // Cursor hiding for the browser-capture fallback. + // The IPC promise resolves only after the cursor hide attempt completes. + // --------------------------------------------------------------------------- + ipcMain.handle("hide-cursor", () => { + if (process.platform !== "win32") { + return { success: true }; + } + + return { success: hideCursor() }; + }); + + ipcMain.handle("get-shortcuts", async () => { + try { + const data = await fs.readFile(SHORTCUTS_FILE, "utf-8"); + return parseJsonWithByteOrderMark(data); + } catch { + return null; + } + }); + + ipcMain.handle("save-shortcuts", async (_, shortcuts: unknown) => { + try { + await fs.writeFile(SHORTCUTS_FILE, JSON.stringify(shortcuts, null, 2), "utf-8"); + return { success: true }; + } catch (error) { + console.error("Failed to save shortcuts:", error); + return { success: false, error: String(error) }; + } + }); + + // --------------------------------------------------------------------------- + // Countdown timer before recording + // --------------------------------------------------------------------------- + ipcMain.handle("get-recording-preferences", async () => { + try { + const parsed = await recordingPreferencesStore.read(); + return { + success: true, + microphoneEnabled: parsed.microphoneEnabled === true, + microphoneDeviceId: + typeof parsed.microphoneDeviceId === "string" + ? parsed.microphoneDeviceId + : undefined, + systemAudioEnabled: parsed.systemAudioEnabled === true, + webcamEnabled: parsed.webcamEnabled === true, + webcamDeviceId: + typeof parsed.webcamDeviceId === "string" ? parsed.webcamDeviceId : undefined, + }; + } catch { + return { + success: true, + microphoneEnabled: false, + microphoneDeviceId: undefined, + systemAudioEnabled: false, + webcamEnabled: false, + webcamDeviceId: undefined, + }; + } + }); + + ipcMain.handle("get-recording-audio-lab-config", () => { + return getBrowserMicrophoneProfileFromEnv(); + }); + + ipcMain.handle("set-recording-preferences", async (_, prefs: RecordingPreferencesPatch) => { + try { + await recordingPreferencesStore.update(prefs); + return { success: true }; + } catch (error) { + console.error("Failed to save recording preferences:", error); + return { success: false, error: String(error) }; + } + }); + + ipcMain.handle("get-countdown-delay", async () => { + try { + const content = await fs.readFile(COUNTDOWN_SETTINGS_FILE, "utf-8"); + const parsed = parseJsonWithByteOrderMark<{ delay?: number }>(content); + return { success: true, delay: parsed.delay ?? 3 }; + } catch { + return { success: true, delay: 3 }; + } + }); + + ipcMain.handle("set-countdown-delay", async (_, delay: number) => { + try { + await fs.writeFile( + COUNTDOWN_SETTINGS_FILE, + JSON.stringify({ delay }, null, 2), + "utf-8", + ); + return { success: true }; + } catch (error) { + console.error("Failed to save countdown delay:", error); + return { success: false, error: String(error) }; + } + }); + + ipcMain.handle("start-countdown", async (_, seconds: number) => { + if (countdownInProgress) { + return { success: false, error: "Countdown already in progress" }; + } + + setCountdownInProgress(true); + setCountdownCancelled(false); + setCountdownRemaining(seconds); + + const countdownWin = createCountdownWindow(); + + if (countdownWin.webContents.isLoadingMainFrame()) { + await new Promise((resolve) => { + countdownWin.webContents.once("did-finish-load", () => { + resolve(); + }); + }); + } + + return new Promise<{ success: boolean; cancelled?: boolean }>((resolve) => { + let remaining = seconds; + setCountdownRemaining(remaining); + + countdownWin.webContents.send("countdown-tick", remaining); + + setCountdownTimer( + setInterval(() => { + if (countdownCancelled) { + if (countdownTimer) { + clearInterval(countdownTimer); + setCountdownTimer(null); + } + closeCountdownWindow(); + setCountdownInProgress(false); + setCountdownRemaining(null); + resolve({ success: false, cancelled: true }); + return; + } + + remaining--; + setCountdownRemaining(remaining); + + if (remaining <= 0) { + if (countdownTimer) { + clearInterval(countdownTimer); + setCountdownTimer(null); + } + closeCountdownWindow(); + setCountdownInProgress(false); + setCountdownRemaining(null); + resolve({ success: true }); + } else { + const win = getCountdownWindow(); + if (win && !win.isDestroyed()) { + win.webContents.send("countdown-tick", remaining); + } + } + }, 1000), + ); + }); + }); + + ipcMain.handle("cancel-countdown", () => { + setCountdownCancelled(true); + setCountdownInProgress(false); + setCountdownRemaining(null); + if (countdownTimer) { + clearInterval(countdownTimer); + setCountdownTimer(null); + } + closeCountdownWindow(); + return { success: true }; + }); + + ipcMain.handle("get-active-countdown", () => { + return { + success: true, + seconds: countdownInProgress ? countdownRemaining : null, + }; + }); +} diff --git a/electron/ipc/register/sourceMapping.test.ts b/electron/ipc/register/sourceMapping.test.ts new file mode 100644 index 000000000..84351a506 --- /dev/null +++ b/electron/ipc/register/sourceMapping.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; + +import { getScreenSourceIdForDisplay, LINUX_PORTAL_SCREEN_SOURCE_ID } from "./sourceMapping"; + +describe("getScreenSourceIdForDisplay", () => { + it("keeps the live Electron screen source when one is available", () => { + expect( + getScreenSourceIdForDisplay({ + displayId: "42", + matchedSourceId: "screen:42:0", + platform: "linux", + }), + ).toBe("screen:42:0"); + }); + + it("routes unmatched Linux Wayland screens through the portal sentinel", () => { + expect( + getScreenSourceIdForDisplay({ + displayId: "42", + env: { XDG_SESSION_TYPE: "wayland", WAYLAND_DISPLAY: "wayland-0" }, + matchedSourceId: null, + platform: "linux", + }), + ).toBe(LINUX_PORTAL_SCREEN_SOURCE_ID); + }); + + it("keeps unmatched Linux X11 screens on the explicit fallback id", () => { + expect( + getScreenSourceIdForDisplay({ + displayId: "42", + env: { XDG_SESSION_TYPE: "x11", DISPLAY: ":0" }, + matchedSourceId: null, + platform: "linux", + }), + ).toBe("screen:fallback:42"); + }); + + it("keeps non-Linux unmatched screens on the explicit fallback id", () => { + expect( + getScreenSourceIdForDisplay({ + displayId: "42", + matchedSourceId: undefined, + platform: "win32", + }), + ).toBe("screen:fallback:42"); + }); +}); diff --git a/electron/ipc/register/sourceMapping.ts b/electron/ipc/register/sourceMapping.ts new file mode 100644 index 000000000..8b13501a2 --- /dev/null +++ b/electron/ipc/register/sourceMapping.ts @@ -0,0 +1,35 @@ +export const LINUX_PORTAL_SCREEN_SOURCE_ID = "screen:linux-portal"; + +export function isLikelyLinuxWaylandSession(env: NodeJS.ProcessEnv) { + const sessionType = env.XDG_SESSION_TYPE?.trim().toLowerCase(); + if (sessionType === "wayland") { + return true; + } + if (sessionType === "x11") { + return false; + } + + return Boolean(env.WAYLAND_DISPLAY); +} + +export function getScreenSourceIdForDisplay({ + displayId, + env = process.env, + matchedSourceId, + platform, +}: { + displayId: string; + env?: NodeJS.ProcessEnv; + matchedSourceId?: string | null; + platform: NodeJS.Platform | string; +}) { + if (matchedSourceId) { + return matchedSourceId; + } + + if (platform === "linux" && isLikelyLinuxWaylandSession(env)) { + return LINUX_PORTAL_SCREEN_SOURCE_ID; + } + + return `screen:fallback:${displayId}`; +} diff --git a/electron/ipc/register/sources.ts b/electron/ipc/register/sources.ts new file mode 100644 index 000000000..a92e55808 --- /dev/null +++ b/electron/ipc/register/sources.ts @@ -0,0 +1,544 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { app, BrowserWindow, desktopCapturer, ipcMain } from "electron"; +import { ALLOW_RECORDLY_WINDOW_CAPTURE } from "../constants"; +import { selectedSource, setSelectedSource } from "../state"; +import type { SelectedSource } from "../types"; +import { getScreen, parseWindowId } from "../utils"; +import { getDisplayBoundsForSource, getDisplayWorkAreaForSource } from "../recording/ffmpeg"; +import { getScreenSourceIdForDisplay } from "./sourceMapping"; +import { + getNativeMacWindowSources, + resolveMacWindowBounds, + resolveWindowsWindowBounds, + resolveLinuxWindowBounds, + stopWindowBoundsCapture, +} from "../cursor/bounds"; +import { reassertHudOverlayMousePassthrough } from "../../windows"; + +const execFileAsync = promisify(execFile); +const SOURCE_LIST_CACHE_TTL_MS = 1200; +let sourceListCache: { + key: string; + expiresAt: number; + value: Array>; +} | null = null; + +function normalizeDesktopSourceName(value: string) { + return value.trim().replace(/\s+/g, " ").toLowerCase(); +} + +function broadcastSelectedSourceChange() { + for (const window of BrowserWindow.getAllWindows()) { + if (!window.isDestroyed()) { + window.webContents.send("selected-source-changed", selectedSource); + } + } +} + +export function registerSourceHandlers({ + createEditorWindow, + createSourceSelectorWindow, + getSourceSelectorWindow, +}: { + createEditorWindow: () => void; + createSourceSelectorWindow: () => BrowserWindow; + getSourceSelectorWindow: () => BrowserWindow | null; +}) { + ipcMain.handle("get-sources", async (_, opts) => { + const cacheKey = JSON.stringify({ + types: opts?.types, + thumbnailSize: opts?.thumbnailSize, + fetchWindowIcons: opts?.fetchWindowIcons, + }); + if ( + sourceListCache && + sourceListCache.key === cacheKey && + sourceListCache.expiresAt > Date.now() + ) { + return sourceListCache.value; + } + + const includeScreens = Array.isArray(opts?.types) ? opts.types.includes("screen") : true; + const includeWindows = Array.isArray(opts?.types) ? opts.types.includes("window") : true; + const includeWindowIcons = Boolean(opts?.fetchWindowIcons); + const electronTypes = [ + ...(includeScreens ? ["screen" as const] : []), + ...(includeWindows ? ["window" as const] : []), + ]; + const electronSources = + electronTypes.length > 0 + ? await desktopCapturer + .getSources({ + ...opts, + types: electronTypes, + }) + .catch((error) => { + console.warn( + "desktopCapturer.getSources failed (screen recording permission may be missing):", + error, + ); + return []; + }) + : []; + const ownWindowNames = new Set( + [ + app.getName(), + "Recordly", + ...BrowserWindow.getAllWindows().flatMap((win) => { + const title = win.getTitle().trim(); + return title ? [title] : []; + }), + ] + .map((name) => normalizeDesktopSourceName(name)) + .filter(Boolean), + ); + const ownAppName = normalizeDesktopSourceName(app.getName()); + + const displays = includeScreens + ? [...getScreen().getAllDisplays()].sort( + (left, right) => + left.bounds.x - right.bounds.x || + left.bounds.y - right.bounds.y || + left.id - right.id, + ) + : []; + const primaryDisplayId = includeScreens ? String(getScreen().getPrimaryDisplay().id) : ""; + const electronScreenSourcesByDisplayId = new Map( + electronSources + .filter((source) => source.id.startsWith("screen:")) + .map((source) => [String(source.display_id ?? ""), source] as const), + ); + // On Linux, desktopCapturer display_id values may not match screen.getAllDisplays() IDs. + // Keep an ordered list so we can fall back to position-based matching. + const electronScreenSourcesByIndex = electronSources.filter((source) => + source.id.startsWith("screen:"), + ); + + const screenSources = displays.map((display, index) => { + const displayId = String(display.id); + const matchedSource = + electronScreenSourcesByDisplayId.get(displayId) ?? + (electronScreenSourcesByIndex.length === displays.length + ? electronScreenSourcesByIndex[index] + : undefined); + const displayName = + displayId === primaryDisplayId + ? `Screen ${index + 1} (Primary)` + : `Screen ${index + 1}`; + + return { + id: getScreenSourceIdForDisplay({ + displayId, + env: process.env, + matchedSourceId: matchedSource?.id, + platform: process.platform, + }), + name: displayName, + originalName: matchedSource?.name ?? displayName, + display_id: displayId, + thumbnail: matchedSource?.thumbnail ? matchedSource.thumbnail.toDataURL() : null, + appIcon: null, + sourceType: "screen" as const, + }; + }); + + if (process.platform !== "darwin" || !includeWindows) { + const windowSources = electronSources + .filter((source) => source.id.startsWith("window:")) + .filter((source) => { + const normalizedName = normalizeDesktopSourceName(source.name); + if (!normalizedName) { + return true; + } + + if (ALLOW_RECORDLY_WINDOW_CAPTURE && normalizedName.includes("recordly")) { + return true; + } + + for (const ownName of ownWindowNames) { + if (!ownName) continue; + if (normalizedName === ownName) { + return false; + } + } + + return true; + }) + .map((source) => ({ + id: source.id, + name: source.name, + originalName: source.name, + display_id: source.display_id, + thumbnail: source.thumbnail ? source.thumbnail.toDataURL() : null, + appIcon: + includeWindowIcons && source.appIcon ? source.appIcon.toDataURL() : null, + sourceType: "window" as const, + })); + const result = [...screenSources, ...windowSources]; + sourceListCache = { + key: cacheKey, + expiresAt: Date.now() + SOURCE_LIST_CACHE_TTL_MS, + value: result, + }; + return result; + } + + try { + const nativeWindowSources = await getNativeMacWindowSources(); + const electronWindowSourceMap = new Map( + electronSources + .filter((source) => source.id.startsWith("window:")) + .map((source) => [source.id, source] as const), + ); + + const mergedWindowSources = nativeWindowSources + .filter((source) => { + const normalizedWindowName = normalizeDesktopSourceName( + source.windowTitle ?? source.name, + ); + const normalizedAppName = normalizeDesktopSourceName(source.appName ?? ""); + + if ( + !ALLOW_RECORDLY_WINDOW_CAPTURE && + normalizedAppName && + normalizedAppName === ownAppName + ) { + return false; + } + + if ( + ALLOW_RECORDLY_WINDOW_CAPTURE && + (normalizedAppName === "recordly" || + normalizedWindowName?.includes("recordly")) + ) { + return true; + } + + if (!normalizedWindowName) { + return true; + } + + for (const ownName of ownWindowNames) { + if (!ownName) continue; + if (normalizedWindowName === ownName) { + return false; + } + } + + return true; + }) + .map((source) => { + const electronWindowSource = electronWindowSourceMap.get(source.id); + return { + id: source.id, + name: source.name, + originalName: source.name, + display_id: source.display_id ?? electronWindowSource?.display_id ?? "", + thumbnail: electronWindowSource?.thumbnail + ? electronWindowSource.thumbnail.toDataURL() + : null, + appIcon: includeWindowIcons + ? (source.appIcon ?? + (electronWindowSource?.appIcon + ? electronWindowSource.appIcon.toDataURL() + : null)) + : null, + appName: source.appName, + windowTitle: source.windowTitle, + sourceType: "window" as const, + }; + }); + + const result = [...screenSources, ...mergedWindowSources]; + sourceListCache = { + key: cacheKey, + expiresAt: Date.now() + SOURCE_LIST_CACHE_TTL_MS, + value: result, + }; + return result; + } catch (error) { + console.warn("Falling back to Electron window enumeration on macOS:", error); + + const windowSources = electronSources + .filter((source) => source.id.startsWith("window:")) + .filter((source) => { + const normalizedName = normalizeDesktopSourceName(source.name); + if (!normalizedName) { + return true; + } + + if (ALLOW_RECORDLY_WINDOW_CAPTURE && normalizedName.includes("recordly")) { + return true; + } + + for (const ownName of ownWindowNames) { + if (!ownName) continue; + if ( + normalizedName === ownName || + normalizedName.includes(ownName) || + ownName.includes(normalizedName) + ) { + return false; + } + } + + return true; + }) + .map((source) => ({ + id: source.id, + name: source.name, + originalName: source.name, + display_id: source.display_id, + thumbnail: source.thumbnail ? source.thumbnail.toDataURL() : null, + appIcon: + includeWindowIcons && source.appIcon ? source.appIcon.toDataURL() : null, + sourceType: "window" as const, + })); + + const result = [...screenSources, ...windowSources]; + sourceListCache = { + key: cacheKey, + expiresAt: Date.now() + SOURCE_LIST_CACHE_TTL_MS, + value: result, + }; + return result; + } + }); + + ipcMain.handle("select-source", (_, source: SelectedSource) => { + setSelectedSource(source); + broadcastSelectedSourceChange(); + stopWindowBoundsCapture(); + const sourceSelectorWin = getSourceSelectorWindow(); + if (sourceSelectorWin) { + sourceSelectorWin.close(); + } + return selectedSource; + }); + + ipcMain.handle("show-source-highlight", async (_, source: SelectedSource) => { + try { + const isWindow = source.id?.startsWith("window:"); + const windowId = isWindow ? parseWindowId(source.id) : null; + + // ── 1. Bring window to front ── + if (isWindow && process.platform === "darwin") { + const rawAppName = source.appName || source.name?.split(" — ")[0]?.trim(); + const appName = + rawAppName && /^[\w .&()+'-]{1,64}$/.test(rawAppName) ? rawAppName : null; + if (appName) { + try { + await execFileAsync( + "osascript", + [ + "-e", + "on run argv", + "-e", + "tell application (item 1 of argv) to activate", + "-e", + "end run", + "--", + appName, + ], + { timeout: 2000 }, + ); + await new Promise((resolve) => setTimeout(resolve, 350)); + } catch { + /* ignore */ + } + } + } else if (windowId && process.platform === "linux") { + try { + await execFileAsync("wmctrl", ["-i", "-a", `0x${windowId.toString(16)}`], { + timeout: 1500, + }); + } catch { + try { + await execFileAsync("xdotool", ["windowactivate", String(windowId)], { + timeout: 1500, + }); + } catch { + /* not available */ + } + } + await new Promise((resolve) => setTimeout(resolve, 250)); + } + + // ── 2. Resolve bounds ── + let bounds: { x: number; y: number; width: number; height: number } | null = null; + + if (source.id?.startsWith("screen:")) { + bounds = + process.platform === "darwin" + ? getDisplayWorkAreaForSource(source) + : getDisplayBoundsForSource(source); + } else if (isWindow) { + if (process.platform === "darwin") { + bounds = await resolveMacWindowBounds(source); + } else if (process.platform === "win32") { + bounds = await resolveWindowsWindowBounds(source); + } else if (process.platform === "linux") { + bounds = await resolveLinuxWindowBounds(source); + } + } + + if (!bounds || bounds.width <= 0 || bounds.height <= 0) { + bounds = getDisplayBoundsForSource(source); + } + + if (!bounds || bounds.width <= 0 || bounds.height <= 0) { + const primaryBounds = getScreen().getPrimaryDisplay().bounds; + if (primaryBounds.width <= 0 || primaryBounds.height <= 0) { + return { success: false }; + } + bounds = primaryBounds; + } + + const resolvedBounds = bounds; + + // ── 3. Show traveling wave highlight ── + // On macOS, screen highlights use workArea and no outward padding — + // macOS clamps window positions below the menu bar so outward + // padding only works on the left/top while right/bottom run off-screen. + const isScreen = source.id?.startsWith("screen:"); + const isMacScreen = isScreen && process.platform === "darwin"; + const pad = isMacScreen ? 0 : 6; + const highlightWin = new BrowserWindow({ + x: resolvedBounds.x - pad, + y: resolvedBounds.y - pad, + width: resolvedBounds.width + pad * 2, + height: resolvedBounds.height + pad * 2, + frame: false, + transparent: true, + alwaysOnTop: true, + skipTaskbar: true, + hasShadow: false, + resizable: false, + focusable: false, + webPreferences: { nodeIntegration: false, contextIsolation: true }, + }); + + highlightWin.setIgnoreMouseEvents(true); + + const borderRadius = isMacScreen ? 0 : 10; + const glowInset = isMacScreen ? 0 : -4; + const glowRadius = isMacScreen ? 0 : 14; + const glowPad = isMacScreen ? 3 : 6; + + const html = ` + +
+
+`; + + try { + await highlightWin.loadURL( + `data:text/html;charset=utf-8,${encodeURIComponent(html)}`, + ); + } catch (loadError) { + if (!highlightWin.isDestroyed()) { + highlightWin.close(); + } + throw loadError; + } + + // The highlight window appearing (even with focusable:false) can corrupt + // the WS_EX_TRANSPARENT flag on the HUD on Windows 11+, breaking hover + // detection until the user moves their mouse over the bar again. + // Re-assert passthrough immediately so click-through is restored at once. + reassertHudOverlayMousePassthrough(); + + const highlightCloseTimer = setTimeout(() => { + if (!highlightWin.isDestroyed()) highlightWin.close(); + }, 1700); + + highlightWin.on("closed", () => { + clearTimeout(highlightCloseTimer); + // Re-assert once more when the window is actually destroyed so the + // native flag is clean regardless of timing. + reassertHudOverlayMousePassthrough(); + }); + + return { success: true }; + } catch (error) { + console.error("Failed to show source highlight:", error); + return { success: false }; + } + }); + + ipcMain.handle("get-selected-source", () => { + return selectedSource; + }); + + ipcMain.handle("open-source-selector", () => { + const sourceSelectorWin = getSourceSelectorWindow(); + if (sourceSelectorWin) { + sourceSelectorWin.focus(); + return; + } + createSourceSelectorWindow(); + }); + ipcMain.handle("switch-to-editor", () => { + console.log("[switch-to-editor] Opening editor window"); + const sourceSelectorWin = getSourceSelectorWindow(); + if (sourceSelectorWin && !sourceSelectorWin.isDestroyed()) { + sourceSelectorWin.close(); + } + createEditorWindow(); + }); +} diff --git a/electron/ipc/settings/recordingPreferencesStore.test.ts b/electron/ipc/settings/recordingPreferencesStore.test.ts new file mode 100644 index 000000000..9caaeaae8 --- /dev/null +++ b/electron/ipc/settings/recordingPreferencesStore.test.ts @@ -0,0 +1,46 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createRecordingPreferencesStore } from "./recordingPreferencesStore"; + +vi.mock("electron", () => ({ + app: { + getPath: () => "", + }, +})); + +const temporaryDirectories: string[] = []; + +afterEach(async () => { + await Promise.all( + temporaryDirectories.splice(0).map((directory) => + fs.rm(directory, { + recursive: true, + force: true, + }), + ), + ); +}); + +describe("recording preferences store", () => { + it("preserves concurrent microphone and webcam preference updates", async () => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), "recordly-preferences-")); + temporaryDirectories.push(directory); + const store = createRecordingPreferencesStore(path.join(directory, "recording.json")); + + await Promise.all([ + store.update({ microphoneEnabled: true }), + store.update({ microphoneDeviceId: "preferred-mic" }), + store.update({ webcamEnabled: true }), + store.update({ webcamDeviceId: "preferred-camera" }), + ]); + + await expect(store.read()).resolves.toEqual({ + microphoneEnabled: true, + microphoneDeviceId: "preferred-mic", + webcamEnabled: true, + webcamDeviceId: "preferred-camera", + }); + }); +}); diff --git a/electron/ipc/settings/recordingPreferencesStore.ts b/electron/ipc/settings/recordingPreferencesStore.ts new file mode 100644 index 000000000..26eced169 --- /dev/null +++ b/electron/ipc/settings/recordingPreferencesStore.ts @@ -0,0 +1,45 @@ +import fs from "node:fs/promises"; +import { parseJsonWithByteOrderMark } from "../utils"; + +export interface RecordingPreferencesPatch { + microphoneEnabled?: boolean; + microphoneDeviceId?: string; + systemAudioEnabled?: boolean; + webcamEnabled?: boolean; + webcamDeviceId?: string; +} + +export function createRecordingPreferencesStore(filePath: string) { + let operationQueue: Promise = Promise.resolve(); + + const readFile = async (): Promise> => { + try { + const content = await fs.readFile(filePath, "utf-8"); + const parsed = parseJsonWithByteOrderMark(content); + return parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? (parsed as Record) + : {}; + } catch { + return {}; + } + }; + + return { + async read(): Promise> { + await operationQueue; + return readFile(); + }, + async update(patch: RecordingPreferencesPatch): Promise { + const operation = operationQueue.then(async () => { + const existing = await readFile(); + await fs.writeFile( + filePath, + JSON.stringify({ ...existing, ...patch }, null, 2), + "utf-8", + ); + }); + operationQueue = operation.catch(() => undefined); + await operation; + }, + }; +} diff --git a/electron/ipc/state.ts b/electron/ipc/state.ts new file mode 100644 index 000000000..a0a41744e --- /dev/null +++ b/electron/ipc/state.ts @@ -0,0 +1,293 @@ +import type { ChildProcessWithoutNullStreams } from "node:child_process"; +import type { + CursorInteractionType, + CursorTelemetryPoint, + CursorVisualType, + NativeCaptureDiagnostics, + RecordingSessionData, + SelectedSource, + SystemCursorAsset, + WindowBounds, +} from "./types"; + +// ── Source selection ────────────────────────────────────────────────────────── +export let selectedSource: SelectedSource | null = null; + +// ── Project / video state ───────────────────────────────────────────────────── +export let currentProjectPath: string | null = null; +export let currentVideoPath: string | null = null; +export let currentRecordingSession: RecordingSessionData | null = null; + +// ── Security: approved read paths ───────────────────────────────────────────── +export const approvedLocalReadPaths = new Set(); + +// ── Native macOS capture ────────────────────────────────────────────────────── +export let nativeScreenRecordingActive = false; +export let nativeCaptureProcess: ChildProcessWithoutNullStreams | null = null; +export let nativeCaptureOutputBuffer = ""; +export let nativeCaptureTargetPath: string | null = null; +export let nativeCaptureStopRequested = false; +export let nativeCaptureSystemAudioPath: string | null = null; +export let nativeCaptureMicrophonePath: string | null = null; +export let nativeCapturePaused = false; + +// ── Native cursor monitor ───────────────────────────────────────────────────── +export let nativeCursorMonitorProcess: ChildProcessWithoutNullStreams | null = null; +export let nativeCursorMonitorOutputBuffer = ""; + +// ── Windows native capture ──────────────────────────────────────────────────── +export let windowsCaptureProcess: ChildProcessWithoutNullStreams | null = null; +export let windowsCaptureOutputBuffer = ""; +export let windowsCaptureTargetPath: string | null = null; +export let windowsNativeCaptureActive = false; +export let windowsCaptureStopRequested = false; +export let windowsCapturePaused = false; +export let windowsSystemAudioPath: string | null = null; +export let windowsMicAudioPath: string | null = null; +export let windowsOrphanedMicAudioPath: string | null = null; +export let windowsPendingVideoPath: string | null = null; + +// ── Diagnostics ─────────────────────────────────────────────────────────────── +export let lastNativeCaptureDiagnostics: NativeCaptureDiagnostics | null = null; + +// ── FFmpeg capture ──────────────────────────────────────────────────────────── +export let ffmpegScreenRecordingActive = false; +export let ffmpegCaptureProcess: ChildProcessWithoutNullStreams | null = null; +export let ffmpegCaptureOutputBuffer = ""; +export let ffmpegCaptureTargetPath: string | null = null; + +// ── Recordings directory ────────────────────────────────────────────────────── +export let customRecordingsDir: string | null = null; +export let recordingsDirLoaded = false; + +// ── System cursor assets cache ──────────────────────────────────────────────── +export let cachedSystemCursorAssets: Record | null = null; +export let cachedSystemCursorAssetsSourceMtimeMs: number | null = null; + +// ── Countdown ───────────────────────────────────────────────────────────────── +export let countdownTimer: ReturnType | null = null; +export let countdownCancelled = false; +export let countdownInProgress = false; +export let countdownRemaining: number | null = null; + +// ── Cursor visual type ──────────────────────────────────────────────────────── +export let currentCursorVisualType: CursorVisualType | undefined = undefined; + +// ── Cursor telemetry ────────────────────────────────────────────────────────── +export let cursorCaptureInterval: NodeJS.Timeout | null = null; +export let cursorCaptureStartTimeMs = 0; +export let cursorCaptureAccumulatedPausedMs = 0; +export let cursorCapturePauseStartedAtMs: number | null = null; +export let activeCursorSamples: CursorTelemetryPoint[] = []; +export let pendingCursorSamples: CursorTelemetryPoint[] = []; +export let isCursorCaptureActive = false; +export let interactionCaptureCleanup: (() => void) | null = null; +export let hasLoggedInteractionHookFailure = false; +export let lastLeftClick: { timeMs: number; cx: number; cy: number } | null = null; +export let linuxCursorScreenPoint: { x: number; y: number; updatedAt: number } | null = null; +export let selectedWindowBounds: WindowBounds | null = null; +export let windowBoundsCaptureInterval: NodeJS.Timeout | null = null; + +// ── Native macOS window source cache ───────────────────────────────────────── +export let cachedNativeMacWindowSources: import("./types").NativeMacWindowSource[] | null = null; +export let cachedNativeMacWindowSourcesAtMs = 0; + +// ── Native video export ─────────────────────────────────────────────────────── +export let cachedNativeVideoEncoder: { + ffmpegPath: string; + encodingMode: string; + encoderName: string; +} | null = null; + +// ── Native helper migration ─────────────────────────────────────────────────── +export let nativeHelperMigrationPromise: Promise | null = null; + +// ── Cursor interaction capture types ───────────────────────────────────────── +export type { CursorInteractionType, CursorTelemetryPoint }; + +// ── Setters (for modules that need to reassign exported lets) ───────────────── +// TypeScript exported `let` can be reassigned by the owning module but importers +// cannot assign to them directly. Provide simple setters for cross-module writes. + +export function setSelectedSource(v: SelectedSource | null) { + selectedSource = v; +} +export function setCurrentProjectPath(v: string | null) { + currentProjectPath = v; +} +export function setCurrentVideoPath(v: string | null) { + currentVideoPath = v; +} +export function setCurrentRecordingSession(v: RecordingSessionData | null) { + currentRecordingSession = v; +} + +export function setNativeScreenRecordingActive(v: boolean) { + nativeScreenRecordingActive = v; +} +export function setNativeCaptureProcess(v: ChildProcessWithoutNullStreams | null) { + nativeCaptureProcess = v; +} +export function setNativeCaptureOutputBuffer(v: string) { + nativeCaptureOutputBuffer = v; +} +export function setNativeCaptureTargetPath(v: string | null) { + nativeCaptureTargetPath = v; +} +export function setNativeCaptureStopRequested(v: boolean) { + nativeCaptureStopRequested = v; +} +export function setNativeCaptureSystemAudioPath(v: string | null) { + nativeCaptureSystemAudioPath = v; +} +export function setNativeCaptureMicrophonePath(v: string | null) { + nativeCaptureMicrophonePath = v; +} +export function setNativeCapturePaused(v: boolean) { + nativeCapturePaused = v; +} + +export function setNativeCursorMonitorProcess(v: ChildProcessWithoutNullStreams | null) { + nativeCursorMonitorProcess = v; +} +export function setNativeCursorMonitorOutputBuffer(v: string) { + nativeCursorMonitorOutputBuffer = v; +} + +export function setWindowsCaptureProcess(v: ChildProcessWithoutNullStreams | null) { + windowsCaptureProcess = v; +} +export function setWindowsCaptureOutputBuffer(v: string) { + windowsCaptureOutputBuffer = v; +} +export function setWindowsCaptureTargetPath(v: string | null) { + windowsCaptureTargetPath = v; +} +export function setWindowsNativeCaptureActive(v: boolean) { + windowsNativeCaptureActive = v; +} +export function setWindowsCaptureStopRequested(v: boolean) { + windowsCaptureStopRequested = v; +} +export function setWindowsCapturePaused(v: boolean) { + windowsCapturePaused = v; +} +export function setWindowsSystemAudioPath(v: string | null) { + windowsSystemAudioPath = v; +} +export function setWindowsMicAudioPath(v: string | null) { + windowsMicAudioPath = v; +} +export function setWindowsOrphanedMicAudioPath(v: string | null) { + windowsOrphanedMicAudioPath = v; +} +export function setWindowsPendingVideoPath(v: string | null) { + windowsPendingVideoPath = v; +} + +export function setLastNativeCaptureDiagnostics(v: NativeCaptureDiagnostics | null) { + lastNativeCaptureDiagnostics = v; +} + +export function setFfmpegScreenRecordingActive(v: boolean) { + ffmpegScreenRecordingActive = v; +} +export function setFfmpegCaptureProcess(v: ChildProcessWithoutNullStreams | null) { + ffmpegCaptureProcess = v; +} +export function setFfmpegCaptureOutputBuffer(v: string) { + ffmpegCaptureOutputBuffer = v; +} +export function setFfmpegCaptureTargetPath(v: string | null) { + ffmpegCaptureTargetPath = v; +} + +export function setCustomRecordingsDir(v: string | null) { + customRecordingsDir = v; +} +export function setRecordingsDirLoaded(v: boolean) { + recordingsDirLoaded = v; +} + +export function setCachedSystemCursorAssets(v: Record | null) { + cachedSystemCursorAssets = v; +} +export function setCachedSystemCursorAssetsSourceMtimeMs(v: number | null) { + cachedSystemCursorAssetsSourceMtimeMs = v; +} + +export function setCountdownTimer(v: ReturnType | null) { + countdownTimer = v; +} +export function setCountdownCancelled(v: boolean) { + countdownCancelled = v; +} +export function setCountdownInProgress(v: boolean) { + countdownInProgress = v; +} +export function setCountdownRemaining(v: number | null) { + countdownRemaining = v; +} + +export function setCurrentCursorVisualType(v: CursorVisualType | undefined) { + currentCursorVisualType = v; +} + +export function setCursorCaptureInterval(v: NodeJS.Timeout | null) { + cursorCaptureInterval = v; +} +export function setCursorCaptureStartTimeMs(v: number) { + cursorCaptureStartTimeMs = v; +} +export function setCursorCaptureAccumulatedPausedMs(v: number) { + cursorCaptureAccumulatedPausedMs = v; +} +export function setCursorCapturePauseStartedAtMs(v: number | null) { + cursorCapturePauseStartedAtMs = v; +} +export function setActiveCursorSamples(v: CursorTelemetryPoint[]) { + activeCursorSamples = v; +} +export function setPendingCursorSamples(v: CursorTelemetryPoint[]) { + pendingCursorSamples = v; +} +export function setIsCursorCaptureActive(v: boolean) { + isCursorCaptureActive = v; +} +export function setInteractionCaptureCleanup(v: (() => void) | null) { + interactionCaptureCleanup = v; +} +export function setHasLoggedInteractionHookFailure(v: boolean) { + hasLoggedInteractionHookFailure = v; +} +export function setLastLeftClick(v: { timeMs: number; cx: number; cy: number } | null) { + lastLeftClick = v; +} +export function setLinuxCursorScreenPoint(v: { x: number; y: number; updatedAt: number } | null) { + linuxCursorScreenPoint = v; +} +export function setSelectedWindowBounds(v: WindowBounds | null) { + selectedWindowBounds = v; +} +export function setWindowBoundsCaptureInterval(v: NodeJS.Timeout | null) { + windowBoundsCaptureInterval = v; +} + +export function setCachedNativeMacWindowSources( + v: import("./types").NativeMacWindowSource[] | null, +) { + cachedNativeMacWindowSources = v; +} +export function setCachedNativeMacWindowSourcesAtMs(v: number) { + cachedNativeMacWindowSourcesAtMs = v; +} + +export function setCachedNativeVideoEncoder( + v: { ffmpegPath: string; encodingMode: string; encoderName: string } | null, +) { + cachedNativeVideoEncoder = v; +} + +export function setNativeHelperMigrationPromise(v: Promise | null) { + nativeHelperMigrationPromise = v; +} diff --git a/electron/ipc/types.ts b/electron/ipc/types.ts new file mode 100644 index 000000000..58f5425bd --- /dev/null +++ b/electron/ipc/types.ts @@ -0,0 +1,203 @@ +export type SelectedSource = { + id?: string; + name: string; + display_id?: string; + sourceType?: "screen" | "window"; + appName?: string; + windowTitle?: string; + [key: string]: unknown; +}; + +export type NativeMacRecordingOptions = { + capturesSystemAudio?: boolean; + capturesMicrophone?: boolean; + microphoneDeviceId?: string; + microphoneLabel?: string; +}; + +export type WindowBounds = { + x: number; + y: number; + width: number; + height: number; +}; + +export type NativeCaptureDiagnostics = { + backend: "windows-wgc" | "mac-screencapturekit" | "browser-store" | "ffmpeg"; + phase: "availability" | "start" | "stop" | "mux"; + timestamp: string; + sourceId?: string | null; + sourceType?: SelectedSource["sourceType"] | "unknown"; + displayId?: number | null; + displayBounds?: WindowBounds | null; + windowHandle?: number | null; + helperPath?: string | null; + outputPath?: string | null; + systemAudioPath?: string | null; + microphonePath?: string | null; + osRelease?: string; + supported?: boolean; + helperExists?: boolean; + fileSizeBytes?: number | null; + processOutput?: string; + error?: string; +}; + +export type RecordingSessionData = { + videoPath: string; + webcamPath?: string | null; + timeOffsetMs?: number; + hideOverlayCursorByDefault?: boolean; +}; + +export type PauseSegment = { + startMs: number; + endMs: number; +}; + +export type RecordingSessionManifest = { + version: 1 | 2; + videoFileName: string; + webcamFileName?: string | null; + timeOffsetMs?: number; +}; + +export type ProjectLibraryEntry = { + path: string; + name: string; + updatedAt: number; + thumbnailPath: string | null; + isCurrent: boolean; + isInProjectsDirectory: boolean; +}; + +export type SystemCursorAsset = { + dataUrl: string; + hotspotX: number; + hotspotY: number; + width: number; + height: number; +}; + +export type CursorVisualType = + | "arrow" + | "text" + | "pointer" + | "crosshair" + | "open-hand" + | "closed-hand" + | "resize-ew" + | "resize-ns" + | "not-allowed"; + +export type CursorInteractionType = + | "move" + | "click" + | "double-click" + | "right-click" + | "middle-click" + | "mouseup"; + +export interface CursorTelemetryPoint { + timeMs: number; + cx: number; + cy: number; + interactionType?: CursorInteractionType; + cursorType?: CursorVisualType; +} + +export type NativeMacWindowSource = { + id: string; + name: string; + display_id?: string; + appName?: string; + windowTitle?: string; + bundleId?: string; + appIcon?: string | null; + x?: number; + y?: number; + width?: number; + height?: number; +}; + +export type HookEventName = "mousedown" | "mouseup" | "mousemove"; + +export type HookMouseEvent = { + button?: number; + mouseButton?: number; + x?: number; + y?: number; + screenX?: number; + screenY?: number; + data?: { + button?: number; + mouseButton?: number; + x?: number; + y?: number; + screenX?: number; + screenY?: number; + }; +}; + +export type HookEventListener = (event: HookMouseEvent) => void; + +export type UiohookLike = { + on: (eventName: HookEventName, listener: HookEventListener) => void; + off?: (eventName: HookEventName, listener: HookEventListener) => void; + removeListener?: (eventName: HookEventName, listener: HookEventListener) => void; + start: () => void; + stop?: () => void; +}; + +export type UiohookModuleNamespace = { + uIOhook?: UiohookLike; + uiohook?: UiohookLike; + Uiohook?: UiohookLike; + default?: UiohookLike | UiohookModuleNamespace; +}; + +export type AudioSyncAdjustment = { + mode: "none" | "tempo" | "delay" | "pad"; + delayMs: number; + tempoRatio: number; + durationDeltaMs: number; +}; + +export type CompanionAudioCandidate = { + platform: "mac" | "win"; + systemPath: string; + micPath: string; + usablePaths: string[]; +}; + +export type CaptionWordPayload = { + text: string; + startMs: number; + endMs: number; + leadingSpace?: boolean; +}; + +export type CaptionCuePayload = { + id: string; + startMs: number; + endMs: number; + text: string; + words?: CaptionWordPayload[]; +}; + +export type WhisperJsonToken = { + text?: unknown; + offsets?: { + from?: unknown; + to?: unknown; + }; +}; + +export type WhisperJsonSegment = { + text?: unknown; + offsets?: { + from?: unknown; + to?: unknown; + }; + tokens?: unknown; +}; diff --git a/electron/ipc/utils.ts b/electron/ipc/utils.ts new file mode 100644 index 000000000..23960f209 --- /dev/null +++ b/electron/ipc/utils.ts @@ -0,0 +1,131 @@ +import fs from "node:fs/promises"; +import { createRequire } from "node:module"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { app } from "electron"; +import { RECORDINGS_DIR } from "../appPaths"; +import { AUTO_RECORDING_PREFIX, RECORDINGS_SETTINGS_FILE } from "./constants"; +import { + approvedLocalReadPaths, + customRecordingsDir, + recordingsDirLoaded, + setCustomRecordingsDir, + setRecordingsDirLoaded, +} from "./state"; + +const nodeRequire = createRequire(import.meta.url); + +export function getScreen() { + if (!app.isReady()) { + throw new Error( + "getScreen() called before app is ready. Ensure all screen access happens after app.whenReady().", + ); + } + return nodeRequire("electron").screen as typeof import("electron").screen; +} + +export function normalizePath(filePath: string) { + return path.resolve(filePath); +} + +export function normalizeVideoSourcePath(videoPath?: string | null): string | null { + if (typeof videoPath !== "string") { + return null; + } + + const trimmed = videoPath.trim(); + if (!trimmed) { + return null; + } + + if (/^file:\/\//i.test(trimmed)) { + try { + return fileURLToPath(trimmed); + } catch { + // Fall through and keep best-effort string path below. + } + } + + return trimmed; +} + +export function stripJsonByteOrderMark(content: string) { + return content.charCodeAt(0) === 0xfeff ? content.slice(1) : content; +} + +export function parseJsonWithByteOrderMark(content: string): T { + return JSON.parse(stripJsonByteOrderMark(content)) as T; +} + +export function parseWindowId(sourceId?: string) { + if (!sourceId) return null; + const match = sourceId.match(/^window:(\d+)/); + return match ? Number.parseInt(match[1], 10) : null; +} + +export function getTelemetryPathForVideo(videoPath: string) { + return `${videoPath}.cursor.json`; +} + +export function isAutoRecordingPath(filePath: string) { + return path.basename(filePath).startsWith(AUTO_RECORDING_PREFIX); +} + +export async function moveFileWithOverwrite(sourcePath: string, destinationPath: string) { + await fs.mkdir(path.dirname(destinationPath), { recursive: true }); + await fs.rm(destinationPath, { force: true }); + + try { + await fs.rename(sourcePath, destinationPath); + } catch (error) { + const nodeError = error as NodeJS.ErrnoException; + if (nodeError.code !== "EXDEV") { + throw error; + } + + await fs.copyFile(sourcePath, destinationPath); + await fs.unlink(sourcePath); + } +} + +async function loadRecordingsDirectorySetting() { + if (recordingsDirLoaded) { + return; + } + + setRecordingsDirLoaded(true); + + try { + const content = await fs.readFile(RECORDINGS_SETTINGS_FILE, "utf-8"); + const parsed = parseJsonWithByteOrderMark<{ recordingsDir?: unknown }>(content); + if (typeof parsed.recordingsDir === "string" && parsed.recordingsDir.trim()) { + setCustomRecordingsDir(path.resolve(parsed.recordingsDir)); + } + } catch { + setCustomRecordingsDir(null); + } +} + +export async function getRecordingsDir() { + await loadRecordingsDirectorySetting(); + const targetDir = customRecordingsDir ?? RECORDINGS_DIR; + await fs.mkdir(targetDir, { recursive: true }); + return targetDir; +} + +export function getMacPrivacySettingsUrl(pane: "screen" | "accessibility" | "microphone"): string { + if (pane === "screen") + return "x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture"; + if (pane === "microphone") + return "x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone"; + return "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility"; +} + +export function approveUserPath(filePath: string | null | undefined): void { + if (!filePath) return; + try { + approvedLocalReadPaths.add(path.resolve(filePath)); + } catch { + // Ignore invalid paths; later reads will surface the underlying error. + } +} diff --git a/electron/ipc/windowsCaptureSelection.test.ts b/electron/ipc/windowsCaptureSelection.test.ts new file mode 100644 index 000000000..17039d4df --- /dev/null +++ b/electron/ipc/windowsCaptureSelection.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it } from "vitest"; + +import { + resolveWindowsCaptureDisplay, + resolveWindowsCaptureTarget, +} from "./windowsCaptureSelection"; + +describe("resolveWindowsCaptureDisplay", () => { + const primaryDisplay = { + id: 101, + bounds: { + x: 0, + y: 0, + width: 1920, + height: 1080, + }, + }; + + const secondaryDisplay = { + id: 202, + bounds: { + x: 1920, + y: -40, + width: 2560, + height: 1440, + }, + }; + + it("uses the requested secondary display bounds for WGC fallback metadata", () => { + const resolved = resolveWindowsCaptureDisplay( + { display_id: String(secondaryDisplay.id) }, + [primaryDisplay, secondaryDisplay], + primaryDisplay, + ); + + expect(resolved).toEqual({ + displayId: secondaryDisplay.id, + bounds: secondaryDisplay.bounds, + }); + }); + + it("falls back to the primary display when the source has no display id", () => { + const resolved = resolveWindowsCaptureDisplay( + undefined, + [primaryDisplay, secondaryDisplay], + primaryDisplay, + ); + + expect(resolved).toEqual({ + displayId: primaryDisplay.id, + bounds: primaryDisplay.bounds, + }); + }); + + it("keeps the requested display id even if Electron cannot rematch it, while using primary bounds", () => { + const resolved = resolveWindowsCaptureDisplay( + { display_id: "303" }, + [primaryDisplay, secondaryDisplay], + primaryDisplay, + ); + + expect(resolved).toEqual({ + displayId: 303, + bounds: primaryDisplay.bounds, + }); + }); +}); + +describe("resolveWindowsCaptureTarget", () => { + const primaryDisplay = { + id: 101, + bounds: { + x: 0, + y: 0, + width: 1920, + height: 1080, + }, + }; + + const secondaryDisplay = { + id: 202, + bounds: { + x: 1920, + y: -40, + width: 2560, + height: 1440, + }, + }; + + it("uses a window handle when a Windows window source is selected", () => { + const resolved = resolveWindowsCaptureTarget( + { id: "window:123456:0", sourceType: "window" }, + [primaryDisplay, secondaryDisplay], + primaryDisplay, + ); + + expect(resolved).toEqual({ + kind: "window", + windowHandle: 123456, + }); + }); + + it("does not silently turn an invalid window source into display capture", () => { + const resolved = resolveWindowsCaptureTarget( + { id: "window:0:0", sourceType: "window", display_id: String(secondaryDisplay.id) }, + [primaryDisplay, secondaryDisplay], + primaryDisplay, + ); + + expect(resolved).toEqual({ + kind: "invalid-window", + }); + }); + + it("keeps display capture behavior for selected screens", () => { + const resolved = resolveWindowsCaptureTarget( + { id: "screen:202:0", sourceType: "screen", display_id: String(secondaryDisplay.id) }, + [primaryDisplay, secondaryDisplay], + primaryDisplay, + ); + + expect(resolved).toEqual({ + kind: "display", + displayId: secondaryDisplay.id, + bounds: secondaryDisplay.bounds, + }); + }); +}); diff --git a/electron/ipc/windowsCaptureSelection.ts b/electron/ipc/windowsCaptureSelection.ts new file mode 100644 index 000000000..3fdf3c008 --- /dev/null +++ b/electron/ipc/windowsCaptureSelection.ts @@ -0,0 +1,102 @@ +export type WindowsCaptureSourceLike = { + id?: string; + display_id?: string; + sourceType?: string; +}; + +export type WindowsCaptureDisplayBounds = { + x: number; + y: number; + width: number; + height: number; +}; + +export type WindowsCaptureDisplayLike = { + id: number; + bounds: WindowsCaptureDisplayBounds; +}; + +export type ResolvedWindowsCaptureDisplay = { + displayId: number; + bounds: WindowsCaptureDisplayBounds; +}; + +export type ResolvedWindowsCaptureTarget = + | { + kind: "window"; + windowHandle: number; + } + | { + kind: "display"; + displayId: number; + bounds: WindowsCaptureDisplayBounds; + } + | { + kind: "invalid-window"; + }; + +function parseDesktopCapturerWindowHandle(sourceId?: string) { + if (!sourceId) { + return null; + } + + const match = sourceId.match(/^window:(\d+)/); + if (!match) { + return null; + } + + const handle = Number.parseInt(match[1], 10); + return Number.isFinite(handle) && handle > 0 ? handle : null; +} + +function isWindowCaptureSource(source: WindowsCaptureSourceLike | null | undefined) { + return source?.sourceType === "window" || source?.id?.startsWith("window:") === true; +} + +export function resolveWindowsCaptureDisplay( + source: WindowsCaptureSourceLike | null | undefined, + allDisplays: WindowsCaptureDisplayLike[], + primaryDisplay: WindowsCaptureDisplayLike, +): ResolvedWindowsCaptureDisplay { + const requestedDisplayId = Number(source?.display_id); + const primaryDisplayId = Number(primaryDisplay.id); + const requestedOrPrimaryDisplayId = + Number.isFinite(requestedDisplayId) && requestedDisplayId > 0 + ? requestedDisplayId + : primaryDisplayId; + + const matchedDisplay = + allDisplays.find((display) => String(display.id) === String(requestedOrPrimaryDisplayId)) ?? + primaryDisplay; + + return { + displayId: requestedOrPrimaryDisplayId, + bounds: matchedDisplay.bounds, + }; +} + +export function resolveWindowsCaptureTarget( + source: WindowsCaptureSourceLike | null | undefined, + allDisplays: WindowsCaptureDisplayLike[], + primaryDisplay: WindowsCaptureDisplayLike, +): ResolvedWindowsCaptureTarget { + if (isWindowCaptureSource(source)) { + const windowHandle = parseDesktopCapturerWindowHandle(source?.id); + if (windowHandle !== null) { + return { + kind: "window", + windowHandle, + }; + } + + return { + kind: "invalid-window", + }; + } + + const resolvedDisplay = resolveWindowsCaptureDisplay(source, allDisplays, primaryDisplay); + return { + kind: "display", + ...resolvedDisplay, + }; +} diff --git a/electron/macosDistributionPolicy.test.mjs b/electron/macosDistributionPolicy.test.mjs new file mode 100644 index 000000000..bd744802c --- /dev/null +++ b/electron/macosDistributionPolicy.test.mjs @@ -0,0 +1,124 @@ +import { describe, expect, it } from "vitest"; +import { + assertValidAppleTeamId, + collectArchitectureErrors, + collectCodeSigningMetadataErrors, + collectEntitlementErrors, + expectedMachOArchitecture, + parseLipoArchitectures, +} from "../scripts/macos-distribution-policy.mjs"; + +const validCodeSigningDetails = ` +Identifier=dev.recordly.app +CodeDirectory v=20500 size=123 flags=0x10000(runtime) hashes=3+7 location=embedded +Authority=Developer ID Application: Recordly Developer (A1B2C3D4E5) +Authority=Developer ID Certification Authority +Authority=Apple Root CA +Timestamp=Aug 9, 2026 at 10:00:00 +TeamIdentifier=A1B2C3D4E5 +`; + +describe("macOS distribution signing policy", () => { + it("accepts a valid Developer ID signature", () => { + expect(collectCodeSigningMetadataErrors(validCodeSigningDetails, "A1B2C3D4E5")).toEqual([]); + }); + + it("rejects a wrong identity, team, timestamp, runtime, and bundle identifier", () => { + const details = ` +Identifier=dev.example.app +CodeDirectory v=20400 size=123 flags=0x0(none) +Authority=Apple Development: Example (Z9Y8X7W6V5) +Timestamp=none +TeamIdentifier=Z9Y8X7W6V5 +`; + + expect(collectCodeSigningMetadataErrors(details, "A1B2C3D4E5")).toEqual([ + "unexpected bundle identifier: dev.example.app", + "the leaf signing authority is not Developer ID Application", + "unexpected TeamIdentifier: Z9Y8X7W6V5", + "secure signing timestamp is missing", + "hardened runtime flag is missing", + ]); + }); + + it("validates the expected Apple team ID shape", () => { + expect(() => assertValidAppleTeamId("A1B2C3D4E5")).not.toThrow(); + expect(() => assertValidAppleTeamId("short")).toThrow(/exactly 10/); + }); +}); + +describe("macOS distribution entitlement policy", () => { + const validEntitlements = { + "com.apple.security.cs.allow-jit": true, + "com.apple.security.cs.allow-unsigned-executable-memory": true, + "com.apple.security.cs.disable-library-validation": true, + "com.apple.security.device.audio-input": true, + "com.apple.security.device.camera": true, + }; + + it("accepts the intended production entitlements", () => { + expect(collectEntitlementErrors(validEntitlements)).toEqual([]); + }); + + it("rejects missing capabilities and debug attachment", () => { + expect( + collectEntitlementErrors({ + ...validEntitlements, + "com.apple.security.cs.allow-jit": false, + "com.apple.security.get-task-allow": true, + }), + ).toEqual([ + "required entitlement is missing or disabled: com.apple.security.cs.allow-jit", + "distribution build must not enable com.apple.security.get-task-allow", + ]); + }); + + it("rejects unreviewed root runtime exceptions", () => { + expect( + collectEntitlementErrors({ + ...validEntitlements, + "com.apple.security.cs.allow-dyld-environment-variables": true, + }), + ).toEqual([ + "unexpected root application entitlement: com.apple.security.cs.allow-dyld-environment-variables", + ]); + }); + + it("rejects disabled but unreviewed entitlement keys", () => { + expect( + collectEntitlementErrors({ + ...validEntitlements, + "com.apple.security.get-task-allow": false, + }), + ).toEqual(["unexpected root application entitlement: com.apple.security.get-task-allow"]); + }); +}); + +describe("macOS distribution architecture policy", () => { + it("parses thin and fat lipo output", () => { + expect(parseLipoArchitectures("Non-fat file: App is architecture: arm64")).toEqual([ + "arm64", + ]); + expect( + parseLipoArchitectures("Architectures in the fat file: App are: x86_64 arm64"), + ).toEqual(["x86_64", "arm64"]); + }); + + it("uses path-specific helper architecture before the build architecture", () => { + expect( + expectedMachOArchitecture("app/electron/native/bin/darwin-arm64/helper", "x64"), + ).toBe("arm64"); + expect( + expectedMachOArchitecture("app/electron/native/bin/darwin-x64/helper", "arm64"), + ).toBe("x86_64"); + expect(expectedMachOArchitecture("Recordly.app/Contents/MacOS/Recordly", "x64")).toBe( + "x86_64", + ); + }); + + it("reports a binary that lacks the required architecture", () => { + expect( + collectArchitectureErrors("Recordly.app/Contents/MacOS/Recordly", "arm64", "x64"), + ).toEqual(["Recordly.app/Contents/MacOS/Recordly does not contain x86_64 (found: arm64)"]); + }); +}); diff --git a/electron/main.ts b/electron/main.ts index 04d15b69e..913ca05df 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -1,43 +1,125 @@ -import { app, BrowserWindow, Tray, Menu, nativeImage } from 'electron' -import { fileURLToPath } from 'node:url' -import path from 'node:path' -import fs from 'node:fs/promises' -import { createHudOverlayWindow, createEditorWindow, createSourceSelectorWindow } from './windows' -import { registerIpcHandlers } from './ipc/handlers' -import { cleanupMouseTracking } from './ipc/mouseTracking' - -const __dirname = path.dirname(fileURLToPath(import.meta.url)) - -export const RECORDINGS_DIR = path.join(app.getPath('userData'), 'recordings') - -// Cleanup old recordings (older than 1 day) -async function cleanupOldRecordings() { - try { - const files = await fs.readdir(RECORDINGS_DIR) - const now = Date.now() - const maxAge = 1 * 24 * 60 * 60 * 1000 - - for (const file of files) { - const filePath = path.join(RECORDINGS_DIR, file) - const stats = await fs.stat(filePath) - - if (now - stats.mtimeMs > maxAge) { - await fs.unlink(filePath) - console.log(`Deleted old recording: ${file}`) - } - } - } catch (error) { - console.error('Failed to cleanup old recordings:', error) - } +import fs from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { + app, + BrowserWindow, + desktopCapturer, + dialog, + webContents as electronWebContents, + ipcMain, + Menu, + nativeImage, + session, + shell, + systemPreferences, + Tray, +} from "electron"; +import { RECORDINGS_DIR } from "./appPaths"; +import { showCursor } from "./cursorHider"; +import { getGpuSwitches } from "./gpuSwitches"; +import { + cleanupAllExportStreams, + cleanupNativeVideoExportSessions, + getSelectedSourceId, + killWindowsCaptureProcess, + registerIpcHandlers, +} from "./ipc/handlers"; +import { ensureMediaServer } from "./mediaServer"; +import { hardenWebContentsNavigation, shouldHardenWebContentsType } from "./navigationPolicy"; +import { shouldGrantDisplayCapture, shouldGrantMediaPermission } from "./permissionPolicy"; +import { ensurePackagedRendererServer, getPackagedRendererBaseUrl } from "./rendererServer"; +import { + checkForAppUpdates, + deferUpdateReminder, + dismissUpdateToast, + downloadAvailableUpdate, + getCurrentUpdateToastPayload, + getExperimentalUpdatesEnabled, + getUpdaterLogPath, + getUpdateStatusSummary, + installDownloadedUpdateNow, + previewUpdateToast, + setExperimentalUpdatesEnabled, + setupAutoUpdates, + skipAvailableUpdateVersion, +} from "./updater"; +import { + createEditorWindow, + createHudOverlayWindow, + createSourceSelectorWindow, + getHudOverlayWindow, + getUpdateToastWindow, + hideUpdateToastWindow, + isHudOverlayMousePassthroughSupported, + reassertHudOverlayMousePassthrough as reassertHudOverlayMouseState, + setHudOverlayRecordingActive, + showUpdateToastWindow, +} from "./windows"; + +const electronMainDir = path.dirname(fileURLToPath(import.meta.url)); +const IS_SMOKE_EXPORT = process.env.RECORDLY_SMOKE_EXPORT === "1"; + +function ignoreBrokenConsolePipe(stream: NodeJS.WritableStream | undefined) { + stream?.on("error", (error: NodeJS.ErrnoException) => { + if (error.code === "EPIPE") { + return; + } + throw error; + }); +} + +ignoreBrokenConsolePipe(process.stdout); +ignoreBrokenConsolePipe(process.stderr); + +app.commandLine.appendSwitch("ignore-gpu-blocklist"); +app.commandLine.appendSwitch("enable-unsafe-webgpu"); +app.commandLine.appendSwitch("enable-gpu-rasterization"); + +app.on("web-contents-created", (_event, contents) => { + if (!shouldHardenWebContentsType(contents.getType())) { + return; + } + + hardenWebContentsNavigation(contents, (url) => shell.openExternal(url)); +}); + +function configureGpuAccelerationSwitches() { + const { useAngle, useGl, disableFeatures } = getGpuSwitches(process.platform, process.env); + if (useAngle) { + app.commandLine.appendSwitch("use-angle", useAngle); + } + if (useGl) { + app.commandLine.appendSwitch("use-gl", useGl); + } + if (disableFeatures && disableFeatures.length > 0) { + app.commandLine.appendSwitch("disable-features", disableFeatures.join(",")); + } +} + +async function logSmokeExportGpuDiagnostics() { + if (!IS_SMOKE_EXPORT) { + return; + } + + try { + console.log("[smoke-export] GPU feature status", JSON.stringify(app.getGPUFeatureStatus())); + console.log("[smoke-export] GPU info", JSON.stringify(await app.getGPUInfo("basic"))); + } catch (error) { + console.warn("[smoke-export] Failed to read GPU diagnostics:", error); + } } +configureGpuAccelerationSwitches(); + async function ensureRecordingsDir() { - try { - await fs.mkdir(RECORDINGS_DIR, { recursive: true }) - console.log('Recordings directory ready:', RECORDINGS_DIR) - } catch (error) { - console.error('Failed to create recordings directory:', error) - } + try { + await fs.mkdir(RECORDINGS_DIR, { recursive: true }); + console.log("RECORDINGS_DIR:", RECORDINGS_DIR); + console.log("User Data Path:", app.getPath("userData")); + } catch (error) { + console.error("Failed to create recordings directory:", error); + } } // The built directory structure @@ -49,117 +131,988 @@ async function ensureRecordingsDir() { // │ │ ├── main.js // │ │ └── preload.mjs // │ -process.env.APP_ROOT = path.join(__dirname, '..') +process.env.APP_ROOT = path.join(electronMainDir, ".."); // Use ['ENV_NAME'] avoid vite:define plugin - Vite@2.x -export const VITE_DEV_SERVER_URL = process.env['VITE_DEV_SERVER_URL'] -export const MAIN_DIST = path.join(process.env.APP_ROOT, 'dist-electron') -export const RENDERER_DIST = path.join(process.env.APP_ROOT, 'dist') +export const VITE_DEV_SERVER_URL = process.env["VITE_DEV_SERVER_URL"]; +export const MAIN_DIST = path.join(process.env.APP_ROOT, "dist-electron"); +export const RENDERER_DIST = path.join(process.env.APP_ROOT, "dist"); +const IS_DEV = Boolean(VITE_DEV_SERVER_URL); + +process.env.VITE_PUBLIC = VITE_DEV_SERVER_URL + ? path.join(process.env.APP_ROOT, "public") + : RENDERER_DIST; + +function getTrustedCaptureDocumentBaseUrls(): string[] { + const trustedUrls = [pathToFileURL(path.join(RENDERER_DIST, "index.html")).href]; + + if (VITE_DEV_SERVER_URL) { + trustedUrls.push(VITE_DEV_SERVER_URL); + } + + const packagedRendererBaseUrl = getPackagedRendererBaseUrl(); + if (packagedRendererBaseUrl) { + trustedUrls.push(new URL("/", packagedRendererBaseUrl).href); + } + + return trustedUrls; +} -process.env.VITE_PUBLIC = VITE_DEV_SERVER_URL ? path.join(process.env.APP_ROOT, 'public') : RENDERER_DIST +function isHudWebContents(webContents: Electron.WebContents | null): boolean { + if (!webContents || webContents.isDestroyed()) { + return false; + } + + const hudWindow = getHudOverlayWindow(); + return Boolean(hudWindow && hudWindow.webContents === webContents); +} // Window references -let mainWindow: BrowserWindow | null = null -let sourceSelectorWindow: BrowserWindow | null = null -let tray: Tray | null = null -let selectedSourceName = '' +let mainWindow: BrowserWindow | null = null; +let sourceSelectorWindow: BrowserWindow | null = null; +let tray: Tray | null = null; +let trayContextMenu: Menu | null = null; +let selectedSourceName = ""; +let editorHasUnsavedChanges = false; +let isForceClosing = false; +let isAppQuitting = false; +let isCreatingMainWindow = false; +let isCreatingEditorWindow = false; +const shouldEnforceSingleInstanceLock = !IS_DEV; +const hasSingleInstanceLock = shouldEnforceSingleInstanceLock + ? app.requestSingleInstanceLock() + : true; + +if (!hasSingleInstanceLock) { + app.quit(); +} + +function closeEditorWindowBypassingUnsavedPrompt(window: BrowserWindow | null) { + if (!window || window.isDestroyed()) { + return; + } + + if (isEditorWindow(window)) { + isForceClosing = true; + editorHasUnsavedChanges = false; + } + window.close(); +} + +function closeEditorWindowToHud(window: BrowserWindow | null) { + if (!window || window.isDestroyed()) { + return; + } + + // The HUD renderer normally remains hidden while the editor is open so + // recording finalization can continue. Restore that HUD before destroying + // the editor, keeping Recordly in its ready-to-record state on the taskbar. + window.hide(); + if (mainWindow === window) { + mainWindow = null; + } + createWindow(); + closeEditorWindowBypassingUnsavedPrompt(window); +} + +function restoreWindowSafely(window: BrowserWindow | null) { + if (!window || window.isDestroyed()) { + return; + } + + if (!isEditorWindow(window) && process.platform === "win32") { + showHudOverlayFromTray(); + return; + } + + if (window.isMinimized()) { + window.restore(); + } + + if (!window.isVisible()) { + window.show(); + } + + window.moveTop(); + window.focus(); +} + +function getExistingEditorWindow(): BrowserWindow | null { + return ( + BrowserWindow.getAllWindows().find( + (window) => !window.isDestroyed() && isEditorWindow(window), + ) ?? null + ); +} + +// Tray Icons (lazily created after app is ready to avoid accessing Electron APIs too early) +let defaultTrayIcon: ReturnType | null = null; +let recordingTrayIcon: ReturnType | null = null; + +function getPlatformAppIconFilename(size: 32 | 128 | 512) { + const baseName = process.platform === "darwin" ? "recordlymac" : "recordly"; + return `app-icons/${baseName}-${size}.png`; +} + +function getDefaultTrayIcon() { + if (!defaultTrayIcon) { + defaultTrayIcon = getTrayIcon(getPlatformAppIconFilename(32)); + } + return defaultTrayIcon; +} + +function getRecordingTrayIcon() { + if (!recordingTrayIcon) { + recordingTrayIcon = getTrayIcon("rec-button.png"); + } + return recordingTrayIcon; +} + +function showHudOverlayFromTray() { + const updateToast = getUpdateToastWindow(); + if (updateToast?.isVisible()) { + updateToast.show(); + updateToast.moveTop(); + updateToast.focus(); + return true; + } + + const hud = getHudOverlayWindow(); + if (!hud) { + return false; + } + + if (hud.isMinimized()) { + hud.restore(); + } + + if (process.platform === "win32" && isHudOverlayMousePassthroughSupported()) { + hud.showInactive(); + hud.moveTop(); + reassertHudOverlayMouseState(); + return true; + } + + hud.show(); + hud.moveTop(); + hud.focus(); + return true; +} + +ipcMain.on("set-has-unsaved-changes", (_event, hasChanges: boolean) => { + editorHasUnsavedChanges = hasChanges; +}); function createWindow() { - mainWindow = createHudOverlayWindow() + if (!app.isReady()) { + void app.whenReady().then(() => { + if (!mainWindow || mainWindow.isDestroyed()) { + createWindow(); + } + }); + return; + } + + if (isCreatingMainWindow) { + return; + } + + if (mainWindow && !mainWindow.isDestroyed()) { + restoreWindowSafely(mainWindow); + return; + } + + const existingHudWindow = getHudOverlayWindow(); + if (existingHudWindow) { + mainWindow = existingHudWindow; + restoreWindowSafely(existingHudWindow); + return; + } + + isCreatingMainWindow = true; + const createdHudWindow = createHudOverlayWindow(); + mainWindow = createdHudWindow; + createdHudWindow.once("closed", () => { + if (mainWindow === createdHudWindow) { + mainWindow = null; + } + }); + isCreatingMainWindow = false; +} + +function focusOrCreateMainWindow() { + if (!app.isReady()) { + void app.whenReady().then(() => { + focusOrCreateMainWindow(); + }); + return; + } + + const updateToast = getUpdateToastWindow(); + if (updateToast?.isVisible()) { + updateToast.show(); + updateToast.moveTop(); + updateToast.focus(); + return; + } + + if (!mainWindow || mainWindow.isDestroyed()) { + const existingHud = getHudOverlayWindow(); + if (existingHud && !existingHud.isDestroyed()) { + mainWindow = existingHud; + } else { + createWindow(); + return; + } + } + + if (mainWindow && !mainWindow.isDestroyed()) { + // On Linux/Wayland, focus() often doesn't take effect (compositor ignores it). Apps like Telegram + // work because they receive an XDG activation token via StatusNotifierItem.ProvideXdgActivationToken; + // Electron's tray doesn't handle that yet. Workaround: destroy and recreate the HUD so the new + // window gets focus (creation path works). Only for HUD, not editor. + if ( + process.platform === "linux" && + !mainWindow.isFocused() && + !isEditorWindow(mainWindow) + ) { + const win = mainWindow; + mainWindow = null; + win.once("closed", () => createWindow()); + win.destroy(); + return; + } + + // On Win32 with mouse passthrough enabled (Win11+), calling + // show/moveTop/focus on the transparent HUD overlay permanently corrupts + // setIgnoreMouseEvents forwarding, making it click-through. Only focus + // the editor window; the HUD is alwaysOnTop so it doesn't need explicit + // focus. On Win10 (passthrough disabled), the HUD is always interactive + // and can be safely shown/restored. + if ( + process.platform === "win32" && + !isEditorWindow(mainWindow) && + isHudOverlayMousePassthroughSupported() + ) { + showHudOverlayFromTray(); + return; + } + + mainWindow.show(); + if (mainWindow.isMinimized()) mainWindow.restore(); + mainWindow.moveTop(); + mainWindow.focus(); + } +} + +function isEditorWindow(window: BrowserWindow) { + return window.webContents.getURL().includes("windowType=editor"); +} + +function sendEditorMenuAction( + channel: "menu-load-project" | "menu-save-project" | "menu-save-project-as", +) { + let targetWindow = BrowserWindow.getFocusedWindow() ?? mainWindow; + + if (!targetWindow || targetWindow.isDestroyed() || !isEditorWindow(targetWindow)) { + createEditorWindowWrapper(); + targetWindow = mainWindow; + if (!targetWindow || targetWindow.isDestroyed()) return; + + targetWindow.webContents.once("did-finish-load", () => { + if (!targetWindow || targetWindow.isDestroyed()) return; + targetWindow.webContents.send(channel); + }); + return; + } + + targetWindow.webContents.send(channel); +} + +function setupApplicationMenu() { + const isMac = process.platform === "darwin"; + const template: Electron.MenuItemConstructorOptions[] = []; + if (isMac) { + template.push({ + label: app.name, + submenu: [ + { role: "about" }, + { type: "separator" }, + { role: "services" }, + { type: "separator" }, + { role: "hide" }, + { role: "hideOthers" }, + { role: "unhide" }, + { type: "separator" }, + { role: "quit" }, + ], + }); + } + + template.push( + { + label: "File", + submenu: [ + { + label: "Open Projects…", + accelerator: "CmdOrCtrl+O", + click: () => sendEditorMenuAction("menu-load-project"), + }, + { + label: "Save Project…", + accelerator: "CmdOrCtrl+S", + click: () => sendEditorMenuAction("menu-save-project"), + }, + { + label: "Save Project As…", + accelerator: "CmdOrCtrl+Shift+S", + click: () => sendEditorMenuAction("menu-save-project-as"), + }, + ...(isMac + ? [] + : [ + { type: "separator" as const }, + { role: "quit" as const, accelerator: "CmdOrCtrl+Q" }, + ]), + ], + }, + { + label: "Edit", + submenu: [ + { role: "undo" }, + { role: "redo" }, + { type: "separator" }, + { role: "cut" }, + { role: "copy" }, + { role: "paste" }, + { role: "selectAll" }, + ], + }, + { + label: "View", + submenu: [ + { role: "reload" }, + { role: "forceReload" }, + { role: "toggleDevTools" }, + { type: "separator" }, + { role: "resetZoom" }, + { role: "zoomIn" }, + { role: "zoomOut" }, + { type: "separator" }, + { role: "togglefullscreen" }, + ], + }, + { + label: "Window", + submenu: isMac + ? [{ role: "minimize" }, { role: "zoom" }, { type: "separator" }, { role: "front" }] + : [{ role: "minimize" }, { role: "close" }], + }, + { + label: "Help", + submenu: [ + { + label: "Check for Updates…", + click: () => { + void checkForAppUpdates(getUpdateDialogWindow, { manual: true }); + }, + }, + ], + }, + ); + + const menu = Menu.buildFromTemplate(template); + Menu.setApplicationMenu(menu); +} + +function isPrimaryTrayClick(event: unknown) { + const button = + event && typeof event === "object" && "button" in event + ? (event as { button?: number | string }).button + : undefined; + return button === undefined || button === 0 || button === "left"; } function createTray() { - const iconPath = path.join(process.env.VITE_PUBLIC || RENDERER_DIST, 'rec-button.png'); - let icon = nativeImage.createFromPath(iconPath); - icon = icon.resize({ width: 24, height: 24, quality: 'best' }); - tray = new Tray(icon); - updateTrayMenu(); -} - -function updateTrayMenu() { - if (!tray) return; - const menuTemplate = [ - { - label: 'Stop Recording', - click: () => { - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send('stop-recording-from-tray'); - } - } - } - ]; - const contextMenu = Menu.buildFromTemplate(menuTemplate); - tray.setContextMenu(contextMenu); - tray.setToolTip(`Recording: ${selectedSourceName}`); + tray = new Tray(getDefaultTrayIcon()); + tray.on("click", (event) => { + if (process.platform === "win32" && !isPrimaryTrayClick(event)) { + return; + } + + focusOrCreateMainWindow(); + }); + + if (process.platform === "win32") { + tray.on("right-click", () => { + if (!tray || !trayContextMenu) { + return; + } + + tray.popUpContextMenu(trayContextMenu); + }); + return; + } + + tray.on("double-click", () => focusOrCreateMainWindow()); +} + +function shouldUseTray() { + // macOS and Windows expose Recordly through their Dock/taskbar. Keep the + // tray entry only on Linux, where it remains the primary app entry point. + return process.platform === "linux"; +} + +function getPublicAssetPath(filename: string) { + return path.join(process.env.VITE_PUBLIC || RENDERER_DIST, filename); +} + +function getAppImage(filename: string) { + return nativeImage.createFromPath(getPublicAssetPath(filename)); +} + +function getTrayIcon(filename: string) { + return getAppImage(filename).resize({ + width: 24, + height: 24, + quality: "best", + }); +} + +function syncDockIcon() { + if (process.platform !== "darwin" || !app.dock) { + return; + } + + const dockIcon = getAppImage(getPlatformAppIconFilename(512)); + if (!dockIcon.isEmpty()) { + app.dock.setIcon(dockIcon); + } +} + +function sendUpdateToastToWindows(channel: "update-toast-state", payload: unknown) { + if (!payload) { + const existingWindow = getUpdateToastWindow(); + if (existingWindow) { + existingWindow.webContents.send(channel, null); + } + hideUpdateToastWindow(); + return true; + } + + const toastWindow = showUpdateToastWindow(); + const sendPayload = () => { + toastWindow.webContents.send(channel, payload); + showUpdateToastWindow(); + }; + + if (toastWindow.webContents.isLoadingMainFrame()) { + toastWindow.webContents.once("did-finish-load", sendPayload); + } else { + sendPayload(); + } + + return true; +} + +function getUpdateDialogWindow() { + const focusedWindow = BrowserWindow.getFocusedWindow(); + if (focusedWindow && !focusedWindow.isDestroyed()) { + return focusedWindow; + } + + if (mainWindow && !mainWindow.isDestroyed()) { + return mainWindow; + } + + return getHudOverlayWindow(); +} + +ipcMain.handle("install-downloaded-update", () => { + installDownloadedUpdateNow(sendUpdateToastToWindows); + return { success: true }; +}); + +ipcMain.handle("download-available-update", (_event, installAfterDownload?: boolean) => { + return downloadAvailableUpdate(sendUpdateToastToWindows, { + installAfterDownload: Boolean(installAfterDownload), + }); +}); + +ipcMain.handle("defer-downloaded-update", (_event, delayMs?: number) => { + return deferUpdateReminder(getUpdateDialogWindow, sendUpdateToastToWindows, delayMs); +}); + +ipcMain.handle("dismiss-update-toast", () => { + return dismissUpdateToast(getUpdateDialogWindow, sendUpdateToastToWindows); +}); + +ipcMain.handle("skip-update-version", () => { + return skipAvailableUpdateVersion(sendUpdateToastToWindows); +}); + +ipcMain.handle("get-current-update-toast-payload", () => { + return getCurrentUpdateToastPayload(); +}); + +ipcMain.handle("get-update-status-summary", () => { + return getUpdateStatusSummary(); +}); + +ipcMain.handle("get-experimental-updates-enabled", () => { + return getExperimentalUpdatesEnabled(); +}); + +ipcMain.handle("set-experimental-updates-enabled", async (_event, enabled: unknown) => { + if (typeof enabled !== "boolean") { + return { success: false, enabled: getExperimentalUpdatesEnabled() }; + } + + try { + const savedValue = setExperimentalUpdatesEnabled(enabled); + await checkForAppUpdates(getUpdateDialogWindow); + return { success: true, enabled: savedValue }; + } catch (error) { + console.error("Failed to update experimental updates preference:", error); + return { + success: false, + enabled: getExperimentalUpdatesEnabled(), + error: String(error), + }; + } +}); + +ipcMain.handle("preview-update-toast", () => { + return { success: previewUpdateToast(sendUpdateToastToWindows) }; +}); + +ipcMain.handle("check-for-app-updates", async () => { + await checkForAppUpdates(getUpdateDialogWindow, { manual: true }); + return { success: true, logPath: getUpdaterLogPath() }; +}); + +function updateTrayMenu(recording: boolean = false) { + if (!tray) return; + const trayIcon = recording ? getRecordingTrayIcon() : getDefaultTrayIcon(); + const trayToolTip = recording ? `Recording: ${selectedSourceName}` : "Recordly"; + const menuTemplate = recording + ? [ + { + label: "Show Controls", + click: () => { + if (!showHudOverlayFromTray()) { + focusOrCreateMainWindow(); + } + }, + }, + { + label: "Stop Recording", + click: () => { + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send("stop-recording-from-tray"); + } + }, + }, + ] + : [ + { + label: "Open", + click: () => { + if (!showHudOverlayFromTray()) { + focusOrCreateMainWindow(); + } + }, + }, + { + label: "Quit", + click: () => { + app.quit(); + }, + }, + ]; + const menu = Menu.buildFromTemplate(menuTemplate); + trayContextMenu = menu; + tray.setImage(trayIcon); + tray.setToolTip(trayToolTip); + if (process.platform !== "win32") { + tray.setContextMenu(menu); + } } function createEditorWindowWrapper() { - if (mainWindow) { - mainWindow.close() - mainWindow = null - } - mainWindow = createEditorWindow() + const existingEditorWindow = getExistingEditorWindow(); + if (existingEditorWindow) { + mainWindow = existingEditorWindow; + restoreWindowSafely(existingEditorWindow); + return existingEditorWindow; + } + + if (isCreatingEditorWindow) { + const currentWindow = mainWindow; + if (currentWindow && !currentWindow.isDestroyed()) { + return currentWindow; + } + + const currentEditorWindow = getExistingEditorWindow(); + if (currentEditorWindow) { + mainWindow = currentEditorWindow; + return currentEditorWindow; + } + } + + isCreatingEditorWindow = true; + const previousWindow = mainWindow; + if (previousWindow && !previousWindow.isDestroyed()) { + const closingEditorWindow = isEditorWindow(previousWindow); + + if (closingEditorWindow) { + closeEditorWindowBypassingUnsavedPrompt(previousWindow); + } else { + // It's the HUD or another window. Hide it instead of closing so background + // tasks (like webcam finalizing) can finish in its renderer process. + previousWindow.hide(); + } + + if (!closingEditorWindow) { + isForceClosing = false; + } + if (mainWindow === previousWindow) { + mainWindow = null; + } + } + const editorWindow = createEditorWindow(); + mainWindow = editorWindow; + editorHasUnsavedChanges = false; + + editorWindow.on("closed", () => { + if (mainWindow === editorWindow) { + mainWindow = null; + } + isCreatingEditorWindow = false; + isForceClosing = false; + editorHasUnsavedChanges = false; + }); + + editorWindow.on("close", (event) => { + if (isForceClosing || !editorHasUnsavedChanges) { + if (process.platform === "win32" && !isForceClosing && !isAppQuitting) { + event.preventDefault(); + closeEditorWindowToHud(editorWindow); + } + return; + } + + event.preventDefault(); + + const choice = dialog.showMessageBoxSync(editorWindow, { + type: "warning", + buttons: ["Save & Close", "Discard & Close", "Cancel"], + defaultId: 0, + cancelId: 2, + title: "Unsaved Changes", + message: "You have unsaved changes.", + detail: "Do you want to save your project before closing?", + }); + + if (choice === 0) { + editorWindow.webContents.send("request-save-before-close"); + ipcMain.once("save-before-close-done", (_event, saved: boolean) => { + if (!saved) { + isAppQuitting = false; + return; + } + + if (process.platform === "win32" && !isAppQuitting) { + closeEditorWindowToHud(editorWindow); + } else { + closeEditorWindowBypassingUnsavedPrompt(editorWindow); + } + }); + } else if (choice === 1) { + if (process.platform === "win32" && !isAppQuitting) { + closeEditorWindowToHud(editorWindow); + } else { + closeEditorWindowBypassingUnsavedPrompt(editorWindow); + } + } else { + isAppQuitting = false; + } + }); + + return editorWindow; } function createSourceSelectorWindowWrapper() { - sourceSelectorWindow = createSourceSelectorWindow() - sourceSelectorWindow.on('closed', () => { - sourceSelectorWindow = null - }) - return sourceSelectorWindow + sourceSelectorWindow = createSourceSelectorWindow(); + sourceSelectorWindow.on("closed", () => { + sourceSelectorWindow = null; + }); + return sourceSelectorWindow; } -// Quit when all windows are closed, except on macOS. There, it's common -// for applications and their menu bar to stay active until the user quits +// On macOS, applications and their menu bar stay active until the user quits // explicitly with Cmd + Q. -app.on('window-all-closed', () => { - if (process.platform !== 'darwin') { - cleanupMouseTracking() - app.quit() - mainWindow = null - } -}) - -app.on('activate', () => { - // On OS X it's common to re-create a window in the app when the - // dock icon is clicked and there are no other windows open. - if (BrowserWindow.getAllWindows().length === 0) { - createWindow() - } -}) - -// Cleanup old recordings on quit (both macOS and other platforms) -app.on('before-quit', async (event) => { - event.preventDefault() - cleanupMouseTracking() - await cleanupOldRecordings() - app.exit(0) -}) +app.on("before-quit", () => { + isAppQuitting = true; + killWindowsCaptureProcess(); + showCursor(); + cleanupNativeVideoExportSessions(); + void cleanupAllExportStreams(); +}); + +app.on("window-all-closed", () => { + if (IS_SMOKE_EXPORT || process.platform !== "darwin") { + app.quit(); + } +}); + +app.on("activate", () => { + // On OS X it's common to re-create a window in the app when the + // dock icon is clicked and there are no other windows open. + focusOrCreateMainWindow(); +}); + +app.on("second-instance", () => { + focusOrCreateMainWindow(); +}); // Register all IPC handlers when app is ready app.whenReady().then(async () => { - // Ensure recordings directory exists - await ensureRecordingsDir() - - registerIpcHandlers( - createEditorWindowWrapper, - createSourceSelectorWindowWrapper, - () => mainWindow, - () => sourceSelectorWindow, - (recording: boolean, sourceName: string) => { - selectedSourceName = sourceName - if (recording) { - if (!tray) createTray(); - updateTrayMenu(); - if (mainWindow) mainWindow.minimize(); - } else { - if (tray) { - tray.destroy(); - tray = null; - } - if (mainWindow) mainWindow.restore(); - } - } - ) - createWindow() -}) + if (process.platform === "win32") { + app.setAppUserModelId("dev.recordly.app"); + } + + session.defaultSession.setPermissionCheckHandler( + (webContents, permission, requestingOrigin, details) => { + return shouldGrantMediaPermission( + { + permission, + isTrustedCaptureWindow: isHudWebContents(webContents), + isMainFrame: details.isMainFrame, + currentDocumentUrl: webContents?.getURL() ?? "", + // Electron 39 may supply the last committed document URL, including its + // query, in the requestingOrigin argument for media checks. + requestingUrl: details.requestingUrl ?? requestingOrigin, + securityOrigins: + details.securityOrigin === undefined ? [] : [details.securityOrigin], + }, + getTrustedCaptureDocumentBaseUrls(), + ); + }, + ); + + session.defaultSession.setPermissionRequestHandler( + (webContents, permission, callback, details) => { + const securityOrigin = "securityOrigin" in details ? details.securityOrigin : undefined; + + callback( + shouldGrantMediaPermission( + { + permission, + isTrustedCaptureWindow: isHudWebContents(webContents), + isMainFrame: details.isMainFrame, + currentDocumentUrl: webContents.getURL(), + requestingUrl: details.requestingUrl, + securityOrigins: securityOrigin === undefined ? [] : [securityOrigin], + }, + getTrustedCaptureDocumentBaseUrls(), + ), + ); + }, + ); + + // Recordly does not use WebHID, Web Serial, or WebUSB. Do not grant devices by default. + session.defaultSession.setDevicePermissionHandler(() => false); + + if (process.platform === "darwin") { + const cameraStatus = systemPreferences.getMediaAccessStatus("camera"); + if (cameraStatus !== "granted") { + await systemPreferences.askForMediaAccess("camera"); + } + + const micStatus = systemPreferences.getMediaAccessStatus("microphone"); + if (micStatus !== "granted") { + await systemPreferences.askForMediaAccess("microphone"); + } + } else if (process.platform === "win32") { + const cameraStatus = systemPreferences.getMediaAccessStatus("camera"); + const micStatus = systemPreferences.getMediaAccessStatus("microphone"); + if (cameraStatus !== "granted") { + console.warn( + `[permissions] Camera access is "${cameraStatus}" — webcam may not work. Check Windows Settings > Privacy > Camera.`, + ); + } + if (micStatus !== "granted") { + console.warn( + `[permissions] Microphone access is "${micStatus}" — mic recording may not work. Check Windows Settings > Privacy > Microphone.`, + ); + } + } + + ipcMain.on("hud-overlay-close", () => { + const hud = getHudOverlayWindow(); + if (hud) { + console.log("[main] Closing HUD window via hud-overlay-close"); + hud.close(); + } + + // If this was the last window (or we are in a state where we should quit), do it. + // We use a small delay to allow window.close() to propagate. + setTimeout(() => { + const windows = BrowserWindow.getAllWindows().filter((w) => !w.isDestroyed()); + if (windows.length === 0) { + console.log("[main] No windows left, quitting app"); + app.quit(); + } + }, 100); + }); + if (process.platform === "darwin" && app.dock) { + await app.dock.show(); + } + syncDockIcon(); + if (shouldUseTray()) { + createTray(); + updateTrayMenu(); + } + setupApplicationMenu(); + // Ensure recordings directory exists + await ensureRecordingsDir(); + + if (!VITE_DEV_SERVER_URL) { + try { + await ensurePackagedRendererServer(RENDERER_DIST); + } catch (error) { + console.warn("[renderer-server] Failed to start packaged renderer server:", error); + } + } + + try { + await ensureMediaServer(); + } catch (error) { + console.warn("[media-server] Failed to start media server:", error); + } + + registerIpcHandlers( + createEditorWindowWrapper, + createSourceSelectorWindowWrapper, + () => mainWindow, + () => sourceSelectorWindow, + (recording: boolean, sourceName: string) => { + selectedSourceName = sourceName; + setHudOverlayRecordingActive(recording); + if (shouldUseTray()) { + if (!tray) createTray(); + updateTrayMenu(recording); + } + if (recording) { + reassertHudOverlayMouseState(); + } + if (!recording) { + restoreWindowSafely(mainWindow); + } + }, + ); + + if (IS_SMOKE_EXPORT || process.env.RECORDLY_DEV_OPEN_RECORDING_INPUT) { + await logSmokeExportGpuDiagnostics(); + if (IS_SMOKE_EXPORT) { + const smokeSource = + process.env.RECORDLY_SMOKE_EXPORT_PROJECT ?? + process.env.RECORDLY_SMOKE_EXPORT_INPUT ?? + ""; + console.log(`[smoke-export] Starting editor smoke export for ${smokeSource}`); + } else { + console.log( + `[dev-open-recording] Starting editor for ${process.env.RECORDLY_DEV_OPEN_RECORDING_INPUT}`, + ); + } + createEditorWindowWrapper(); + return; + } + + createWindow(); + setupAutoUpdates(getUpdateDialogWindow, sendUpdateToastToWindows); + if (IS_DEV && process.env.RECORDLY_DEV_PREVIEW_UPDATE === "1") { + setTimeout(() => { + previewUpdateToast(sendUpdateToastToWindows); + }, 750); + } + + // Register the display media handler so that renderer's getDisplayMedia() + // calls land on the pre-selected source without showing a system picker. + // + // IMPORTANT: The callback must receive a plain { id, name } Video object. + // Passing the full DesktopCapturerSource (with thumbnail, appIcon, etc.) + // via an unsafe cast breaks Electron's internal cursor-constraint + // propagation and causes cursor: 'never' from the renderer to be silently + // ignored by the native capture pipeline. + session.defaultSession.setDisplayMediaRequestHandler(async (request, callback) => { + try { + const frame = request.frame; + const isLiveFrame = Boolean(frame && !frame.isDestroyed()); + const requestingWebContents = + isLiveFrame && frame ? electronWebContents.fromFrame(frame) : undefined; + const isHudMainFrame = Boolean( + isLiveFrame && + requestingWebContents && + isHudWebContents(requestingWebContents) && + frame === requestingWebContents.mainFrame, + ); + + if ( + !shouldGrantDisplayCapture( + { + isTrustedCaptureWindow: isHudMainFrame, + isMainFrame: Boolean(isLiveFrame && frame?.parent === null), + currentDocumentUrl: isLiveFrame ? (frame?.url ?? "") : "", + securityOrigin: request.securityOrigin, + videoRequested: request.videoRequested, + }, + getTrustedCaptureDocumentBaseUrls(), + ) + ) { + callback({}); + return; + } + + const sourceId = getSelectedSourceId(); + // On Linux/Wayland, calling desktopCapturer.getSources() itself + // invokes the xdg-desktop-portal picker. If we then return one of + // those sources, Chromium triggers a SECOND portal because the + // pre-enumerated source IDs are stale on Wayland. To collapse this + // into a single portal invocation, when the Linux portal sentinel + // is set we skip getSources entirely and hand back a synthetic + // source id; Chromium then opens the portal once to actually + // resolve the capture. + // Default to the sentinel on Linux when no source has been + // pre-selected (e.g. fresh session where the renderer skipped the + // source picker entirely). This avoids calling getSources() which + // would itself trigger an extra portal dialog. + const isLinuxPortalSentinel = + process.platform === "linux" && (sourceId === "screen:linux-portal" || !sourceId); + if (isLinuxPortalSentinel) { + callback({ video: { id: "screen:0:0", name: "Entire screen" } }); + return; + } + const sources = await desktopCapturer.getSources({ types: ["screen", "window"] }); + const source = sourceId + ? (sources.find((s) => s.id === sourceId) ?? sources[0]) + : sources[0]; + if (source) { + callback({ + video: { id: source.id, name: source.name }, + }); + } else { + callback({}); + } + } catch (error) { + console.error("setDisplayMediaRequestHandler error:", error); + callback({}); + } + }); + + const currentToastPayload = getCurrentUpdateToastPayload(); + if (currentToastPayload) { + sendUpdateToastToWindows("update-toast-state", currentToastPayload); + } +}); diff --git a/electron/mainCjsNormalize.test.mjs b/electron/mainCjsNormalize.test.mjs new file mode 100644 index 000000000..9ccf91da7 --- /dev/null +++ b/electron/mainCjsNormalize.test.mjs @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vitest"; +import { + findElectronMainCjsEsmSyntax, + normalizeElectronMainCjsSource, +} from "../scripts/normalize-electron-main-cjs.mjs"; + +describe("Electron main CJS normalizer", () => { + it("converts Rollup named export blocks to CommonJS assignments", () => { + const source = [ + 'const MAIN_DIST = "dist-electron";', + 'const RENDERER_DIST = "dist";', + "const VITE_DEV_SERVER_URL = process.env.VITE_DEV_SERVER_URL;", + "export {", + " MAIN_DIST,", + " RENDERER_DIST,", + " VITE_DEV_SERVER_URL", + "};", + ].join("\n"); + + const result = normalizeElectronMainCjsSource(source); + + expect(result.changed).toBe(true); + expect(result.source).toContain("exports.MAIN_DIST = MAIN_DIST;"); + expect(result.source).toContain("exports.RENDERER_DIST = RENDERER_DIST;"); + expect(result.source).toContain("exports.VITE_DEV_SERVER_URL = VITE_DEV_SERVER_URL;"); + expect(findElectronMainCjsEsmSyntax(result.source)).toEqual([]); + }); + + it("converts single-line aliased named exports to CommonJS assignments", () => { + const source = "export { VITE_DEV_SERVER_URL as devServerUrl };"; + + const result = normalizeElectronMainCjsSource(source); + + expect(result.changed).toBe(true); + expect(result.source).toBe("exports.devServerUrl = VITE_DEV_SERVER_URL;"); + expect(findElectronMainCjsEsmSyntax(result.source)).toEqual([]); + }); + + it("reports unsupported ESM export syntax that cannot be normalized safely", () => { + const source = "export default MAIN_DIST;"; + + expect(findElectronMainCjsEsmSyntax(source)).toEqual([ + { line: 1, text: "export default MAIN_DIST;" }, + ]); + }); + + it("preserves an unsupported export block exactly while normalizing other syntax", () => { + const source = [ + 'import fs from "node:fs";', + "export {", + ' MAIN_DIST as "main-dist",', + "};", + ].join("\n"); + + const result = normalizeElectronMainCjsSource(source); + + expect(result.changed).toBe(true); + expect(result.source).toBe( + [ + 'const fs = require("node:fs");', + "export {", + ' MAIN_DIST as "main-dist",', + "};", + ].join("\n"), + ); + expect(findElectronMainCjsEsmSyntax(result.source)).toEqual([ + { line: 2, text: "export {" }, + ]); + }); +}); diff --git a/electron/mediaServer.test.ts b/electron/mediaServer.test.ts new file mode 100644 index 000000000..cf6cd01ba --- /dev/null +++ b/electron/mediaServer.test.ts @@ -0,0 +1,105 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +describe("media server path policy", () => { + let tempRoot: string; + let appDataPath: string; + let userDataPath: string; + let tempPath: string; + let appPath: string; + + beforeEach(async () => { + tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "recordly-media-server-")); + appDataPath = path.join(tempRoot, "AppData"); + userDataPath = path.join(tempRoot, "UserData"); + tempPath = path.join(tempRoot, "Temp"); + appPath = path.join(tempRoot, "App"); + + await Promise.all( + [appDataPath, userDataPath, tempPath, appPath].map((dirPath) => + fs.mkdir(dirPath, { recursive: true }), + ), + ); + + vi.resetModules(); + vi.doMock("electron", () => ({ + app: { + isPackaged: false, + getAppPath: () => appPath, + getPath: (name: string) => { + if (name === "appData") return appDataPath; + if (name === "userData") return userDataPath; + if (name === "temp") return tempPath; + return tempRoot; + }, + setPath: () => undefined, + }, + })); + }); + + afterEach(async () => { + vi.resetModules(); + vi.doUnmock("electron"); + if (tempRoot) { + await fs.rm(tempRoot, { recursive: true, force: true }); + } + }); + + it("rejects existing media files outside the session directories until they are approved", async () => { + const downloadsPath = path.join(tempRoot, "Downloads"); + const videoPath = path.join(downloadsPath, "personal-video.mp4"); + await fs.mkdir(downloadsPath, { recursive: true }); + await fs.writeFile(videoPath, "test-video"); + + const { isAllowedMediaPath } = await import("./mediaServer"); + const { rememberApprovedLocalReadPath } = await import("./ipc/project/manager"); + + expect(isAllowedMediaPath(videoPath)).toBe(false); + + await rememberApprovedLocalReadPath(videoPath); + + expect(isAllowedMediaPath(videoPath)).toBe(true); + }); + + it("rejects missing media files outside the allowed directories", async () => { + const missingPath = path.join(tempRoot, "Downloads", "missing.mp4"); + const { isAllowedMediaPath } = await import("./mediaServer"); + + expect(isAllowedMediaPath(missingPath)).toBe(false); + }); +}); + +describe("resolveHttpByteRange", () => { + it("rejects malformed and multi-range headers", async () => { + const { resolveHttpByteRange } = await import("./mediaServer"); + + expect(resolveHttpByteRange("bytes=0-1,2-3", 100)).toBeNull(); + expect(resolveHttpByteRange("bytes=0-1foo", 100)).toBeNull(); + }); + + it("clamps oversized explicit end offsets to EOF", async () => { + const { resolveHttpByteRange } = await import("./mediaServer"); + + expect(resolveHttpByteRange("bytes=0-9999999999", 3_221_225_472)).toEqual({ + start: 0, + end: 3_221_225_471, + }); + }); + + it("rejects ranges that start beyond EOF", async () => { + const { resolveHttpByteRange } = await import("./mediaServer"); + + expect(resolveHttpByteRange("bytes=500-999", 500)).toBeNull(); + }); + + it("preserves suffix range semantics", async () => { + const { resolveHttpByteRange } = await import("./mediaServer"); + + expect(resolveHttpByteRange("bytes=-500", 1_000)).toEqual({ + start: 500, + end: 999, + }); + }); +}); diff --git a/electron/mediaServer.ts b/electron/mediaServer.ts new file mode 100644 index 000000000..c979a84e8 --- /dev/null +++ b/electron/mediaServer.ts @@ -0,0 +1,237 @@ +import { createReadStream } from "node:fs"; +import fs from "node:fs/promises"; +import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; +import path from "node:path"; +import { approvedLocalReadPaths } from "./ipc/state"; +import { getMediaContentType } from "./mediaTypes"; + +let mediaServerBaseUrl: string | null = null; +let mediaServerStartPromise: Promise | null = null; + +export function resolveHttpByteRange( + rangeHeader: string, + fileSize: number, +): { start: number; end: number } | null { + const match = rangeHeader.trim().match(/^bytes=(\d*)-(\d*)$/); + if (!match || (!match[1] && !match[2])) { + return null; + } + + if (fileSize === 0) { + return null; + } + + if (!match[1] && match[2]) { + // Suffix range: bytes=-500 + const suffixLength = Number.parseInt(match[2], 10); + if (Number.isNaN(suffixLength) || suffixLength <= 0) { + return null; + } + + return { + start: Math.max(0, fileSize - suffixLength), + end: fileSize - 1, + }; + } + + const start = Number.parseInt(match[1], 10); + if (Number.isNaN(start) || start < 0 || start >= fileSize) { + return null; + } + + const requestedEnd = match[2] ? Number.parseInt(match[2], 10) : fileSize - 1; + if (Number.isNaN(requestedEnd) || requestedEnd < start) { + return null; + } + + return { + start, + end: Math.min(requestedEnd, fileSize - 1), + }; +} + +async function resolveRealPath(filePath: string): Promise { + try { + return await fs.realpath(path.resolve(filePath)); + } catch { + return null; + } +} + +export function isAllowedMediaPath(realPath: string): boolean { + return approvedLocalReadPaths.has(realPath); +} + +async function handleMediaRequest( + request: IncomingMessage, + response: ServerResponse, +): Promise { + try { + const url = new URL(request.url ?? "/", "http://127.0.0.1"); + + if (url.pathname !== "/video") { + response.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" }); + response.end("Not Found"); + return; + } + + const rawPath = url.searchParams.get("path"); + if (!rawPath) { + response.writeHead(400, { "Content-Type": "text/plain; charset=utf-8" }); + response.end("Missing path parameter"); + return; + } + + const resolvedPath = await resolveRealPath(rawPath); + if (!resolvedPath || !isAllowedMediaPath(resolvedPath)) { + console.warn(`[media-server] Blocked access to unapproved path: ${rawPath}`); + response.writeHead(403, { "Content-Type": "text/plain; charset=utf-8" }); + response.end("Forbidden"); + return; + } + + const stat = await fs.stat(resolvedPath); + if (!stat.isFile()) { + response.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" }); + response.end("Not Found"); + return; + } + + const contentType = getMediaContentType(resolvedPath); + const fileSize = stat.size; + const rangeHeader = request.headers.range; + + const corsHeaders = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Credentials": "false", + "Access-Control-Expose-Headers": "Content-Range, Content-Length, Accept-Ranges", + }; + + if (request.method === "OPTIONS") { + response.writeHead(204, { + ...corsHeaders, + "Access-Control-Allow-Methods": "GET, HEAD, OPTIONS", + "Access-Control-Allow-Headers": "Range", + }); + response.end(); + return; + } + + if (rangeHeader) { + if (fileSize === 0) { + response.writeHead(416, { ...corsHeaders, "Content-Range": `bytes */0` }); + response.end(); + return; + } + + const byteRange = resolveHttpByteRange(rangeHeader, fileSize); + if (!byteRange) { + response.writeHead(416, { ...corsHeaders, "Content-Range": `bytes */${fileSize}` }); + response.end(); + return; + } + + const { start, end } = byteRange; + + const chunkSize = end - start + 1; + response.writeHead(206, { + ...corsHeaders, + "Content-Range": `bytes ${start}-${end}/${fileSize}`, + "Accept-Ranges": "bytes", + "Content-Length": String(chunkSize), + "Content-Type": contentType, + "Cache-Control": "no-cache", + }); + + if (request.method === "HEAD") { + response.end(); + return; + } + + const stream = createReadStream(resolvedPath, { start, end }); + stream.pipe(response); + stream.on("error", () => { + if (!response.headersSent) { + response.writeHead(500, { "Content-Type": "text/plain" }); + } + response.end(); + }); + } else { + response.writeHead(200, { + ...corsHeaders, + "Accept-Ranges": "bytes", + "Content-Length": String(fileSize), + "Content-Type": contentType, + "Cache-Control": "no-cache", + }); + + if (request.method === "HEAD") { + response.end(); + return; + } + + const stream = createReadStream(resolvedPath); + stream.pipe(response); + stream.on("error", () => { + if (!response.headersSent) { + response.writeHead(500, { "Content-Type": "text/plain" }); + } + response.end(); + }); + } + } catch (error) { + if ((error as NodeJS.ErrnoException)?.code === "ENOENT") { + response.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" }); + response.end("Not Found"); + return; + } + + console.error("[media-server] Error handling request:", error); + response.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" }); + response.end("Internal Server Error"); + } +} + +export function getMediaServerBaseUrl(): string | null { + return mediaServerBaseUrl; +} + +export async function ensureMediaServer(): Promise { + if (mediaServerBaseUrl) { + return mediaServerBaseUrl; + } + + if (mediaServerStartPromise) { + return mediaServerStartPromise; + } + + mediaServerStartPromise = new Promise((resolve, reject) => { + const server = createServer((request, response) => { + void handleMediaRequest(request, response); + }); + + server.once("error", (error) => { + reject(error); + }); + + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (!address || typeof address === "string") { + server.close(); + reject(new Error("Media server did not expose a TCP address")); + return; + } + + mediaServerBaseUrl = `http://127.0.0.1:${address.port}`; + console.log(`[media-server] Listening at ${mediaServerBaseUrl}`); + resolve(mediaServerBaseUrl); + }); + }); + + return mediaServerStartPromise; +} + +export function buildMediaUrl(baseUrl: string, filePath: string): string { + const resolved = path.resolve(filePath); + return `${baseUrl}/video?path=${encodeURIComponent(resolved)}`; +} diff --git a/electron/mediaTypes.ts b/electron/mediaTypes.ts new file mode 100644 index 000000000..5c4307b63 --- /dev/null +++ b/electron/mediaTypes.ts @@ -0,0 +1,23 @@ +import path from "node:path"; + +export const MEDIA_CONTENT_TYPES: Record = { + ".mp4": "video/mp4", + ".webm": "video/webm", + ".mov": "video/quicktime", + ".mkv": "video/x-matroska", + ".avi": "video/x-msvideo", + ".wav": "audio/wav", + ".mp3": "audio/mpeg", + ".ogg": "audio/ogg", + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", +}; + +export function getMediaContentType(filePath: string): string { + return MEDIA_CONTENT_TYPES[path.extname(filePath).toLowerCase()] ?? "application/octet-stream"; +} + +export function isSupportedLocalMediaPath(filePath: string): boolean { + return path.extname(filePath).toLowerCase() in MEDIA_CONTENT_TYPES; +} diff --git a/electron/native/NativeCursorMonitor.swift b/electron/native/NativeCursorMonitor.swift new file mode 100644 index 000000000..18c644150 --- /dev/null +++ b/electron/native/NativeCursorMonitor.swift @@ -0,0 +1,505 @@ +import Foundation +import AppKit +import ApplicationServices + +// Force AppKit initialization so cursor images are populated in CLI context +let _ = NSApplication.shared + +func knownCursorCandidates() -> [(String, NSCursor)] { + var candidates: [(String, NSCursor)] = [ + ("arrow", .arrow), + ("text", .iBeam), + ("pointer", .pointingHand), + ("pointer", .dragCopy), + ("pointer", .dragLink), + ("pointer", .contextualMenu), + ("crosshair", .crosshair), + ("open-hand", .openHand), + ("closed-hand", .closedHand), + ("resize-ew", .resizeLeft), + ("resize-ew", .resizeRight), + ("resize-ew", .resizeLeftRight), + ("resize-ns", .resizeUp), + ("resize-ns", .resizeDown), + ("resize-ns", .resizeUpDown), + ("not-allowed", .operationNotAllowed), + ] + + if #available(macOS 10.13, *) { + candidates.append(("text", .iBeamCursorForVerticalLayout)) + } + + return candidates +} + +let signatureAcceptanceThreshold = 12000 +let relaxedSignatureAcceptanceThresholds: [String: Int] = [ + "text": 28000, + "crosshair": 32000, +] +let strictSignatureAcceptanceThresholds: [String: Int] = [ + "open-hand": 3200, + "closed-hand": 3200, +] + +let systemWideElement = AXUIElementCreateSystemWide() +let totalScreenHeight = NSScreen.screens.reduce(CGFloat(0)) { max($0, $1.frame.maxY) } +let axEditableAttribute = "AXEditable" +let axLinkRole = "AXLink" + +struct CursorSignature { + let aspectRatio: Double + let hotspotXRatio: Double + let hotspotYRatio: Double + let shapeSamples: [UInt8] +} + +func signature(for cursor: NSCursor, sampleSize: Int = 32) -> CursorSignature? { + let image = cursor.image + let sourceSize = image.size + guard sourceSize.width > 0, sourceSize.height > 0 else { + return nil + } + + guard let bitmap = NSBitmapImageRep( + bitmapDataPlanes: nil, + pixelsWide: sampleSize, + pixelsHigh: sampleSize, + bitsPerSample: 8, + samplesPerPixel: 4, + hasAlpha: true, + isPlanar: false, + colorSpaceName: .deviceRGB, + bytesPerRow: 0, + bitsPerPixel: 0 + ) else { + return nil + } + + bitmap.size = NSSize(width: sampleSize, height: sampleSize) + + NSGraphicsContext.saveGraphicsState() + guard let context = NSGraphicsContext(bitmapImageRep: bitmap) else { + NSGraphicsContext.restoreGraphicsState() + return nil + } + + NSGraphicsContext.current = context + context.imageInterpolation = .high + + let scale = min(CGFloat(sampleSize) / sourceSize.width, CGFloat(sampleSize) / sourceSize.height) + let drawWidth = sourceSize.width * scale + let drawHeight = sourceSize.height * scale + let drawRect = NSRect( + x: (CGFloat(sampleSize) - drawWidth) / 2, + y: (CGFloat(sampleSize) - drawHeight) / 2, + width: drawWidth, + height: drawHeight + ) + + image.draw(in: drawRect, from: .zero, operation: .copy, fraction: 1) + context.flushGraphics() + NSGraphicsContext.restoreGraphicsState() + + guard let data = bitmap.bitmapData else { + return nil + } + + var alphaSamples: [UInt8] = [] + alphaSamples.reserveCapacity(sampleSize * sampleSize) + let bytesPerRow = bitmap.bytesPerRow + + for y in 0.. 24 ? 255 : 0) + } + } + + let hotspot = cursor.hotSpot + return CursorSignature( + aspectRatio: Double(sourceSize.width / max(1, sourceSize.height)), + hotspotXRatio: Double(hotspot.x / max(1, sourceSize.width)), + hotspotYRatio: Double(hotspot.y / max(1, sourceSize.height)), + shapeSamples: alphaSamples + ) +} + +func signatureScore(_ lhs: CursorSignature, _ rhs: CursorSignature) -> Int { + let count = min(lhs.shapeSamples.count, rhs.shapeSamples.count) + var imageDifference = 0 + for index in 0.. String? { + var value: CFTypeRef? + let error = AXUIElementCopyAttributeValue(element, attribute as CFString, &value) + guard error == .success else { + return nil + } + + return value as? String +} + +func attributeBool(_ element: AXUIElement, _ attribute: String) -> Bool? { + var value: CFTypeRef? + let error = AXUIElementCopyAttributeValue(element, attribute as CFString, &value) + guard error == .success else { + return nil + } + + return value as? Bool +} + +func actionNames(_ element: AXUIElement) -> [String] { + var names: CFArray? + let error = AXUIElementCopyActionNames(element, &names) + guard error == .success, let actions = names as? [String] else { + return [] + } + + return actions +} + +func currentElement() -> AXUIElement? { + guard let location = CGEvent(source: nil)?.location else { + return nil + } + + var element: AXUIElement? + let y = totalScreenHeight > 0 ? totalScreenHeight - location.y : location.y + let error = AXUIElementCopyElementAtPosition(systemWideElement, Float(location.x), Float(y), &element) + guard error == .success else { + return nil + } + + return element +} + +func focusedElement() -> AXUIElement? { + var value: CFTypeRef? + let error = AXUIElementCopyAttributeValue(systemWideElement, kAXFocusedUIElementAttribute as CFString, &value) + guard error == .success, let value else { + return nil + } + + return unsafeBitCast(value, to: AXUIElement.self) +} + +func parentElement(of element: AXUIElement) -> AXUIElement? { + var value: CFTypeRef? + let error = AXUIElementCopyAttributeValue(element, kAXParentAttribute as CFString, &value) + guard error == .success, let value else { + return nil + } + + return unsafeBitCast(value, to: AXUIElement.self) +} + +func hasAttribute(_ element: AXUIElement, _ attribute: String) -> Bool { + var value: CFTypeRef? + return AXUIElementCopyAttributeValue(element, attribute as CFString, &value) == .success +} + +func ancestorChain(startingAt element: AXUIElement?, maxDepth: Int = 4) -> [AXUIElement] { + guard let element else { + return [] + } + + var elements: [AXUIElement] = [element] + var current = element + var depth = 0 + + while depth < maxDepth, let parent = parentElement(of: current) { + elements.append(parent) + current = parent + depth += 1 + } + + return elements +} + +func metadataString(for element: AXUIElement) -> String { + return [ + attributeString(element, kAXRoleAttribute), + attributeString(element, kAXSubroleAttribute), + attributeString(element, kAXRoleDescriptionAttribute), + attributeString(element, kAXDescriptionAttribute), + attributeString(element, kAXHelpAttribute), + attributeString(element, kAXTitleAttribute), + ] + .compactMap { $0?.lowercased() } + .joined(separator: " ") +} + +func elementLooksTextual(_ element: AXUIElement) -> Bool { + let role = attributeString(element, kAXRoleAttribute) + let subrole = attributeString(element, kAXSubroleAttribute) + let editable = attributeBool(element, axEditableAttribute) + let metadata = metadataString(for: element) + let actions = actionNames(element) + + let textRoles: Set = [ + kAXTextFieldRole as String, + kAXTextAreaRole as String, + kAXComboBoxRole as String, + kAXSearchFieldSubrole as String, + ] + + if editable == true || textRoles.contains(role ?? "") || textRoles.contains(subrole ?? "") { + return true + } + + if metadata.contains("text field") + || metadata.contains("search field") + || metadata.contains("editor") + || metadata.contains("insertion point") + || metadata.contains("caret") + || metadata.contains("source editor") { + return true + } + + return hasAttribute(element, kAXSelectedTextRangeAttribute as String) + || hasAttribute(element, kAXNumberOfCharactersAttribute as String) + || (actions.contains(kAXPressAction as String) && metadata.contains("text")) +} + +func accessibilityCursorMatch() -> String? { + let hoveredChain = ancestorChain(startingAt: currentElement()) + let focusedChain = ancestorChain(startingAt: focusedElement()) + + for element in hoveredChain + focusedChain { + if elementLooksTextual(element) { + return "text" + } + } + + guard let element = hoveredChain.first else { + return nil + } + + let role = attributeString(element, kAXRoleAttribute) + let enabled = attributeBool(element, kAXEnabledAttribute) + let actions = actionNames(element) + let metadata = hoveredChain + .map { metadataString(for: $0) } + .filter { !$0.isEmpty } + .joined(separator: " ") + if metadata.contains("crosshair") || metadata.contains("cross hair") || metadata.contains("precision") || metadata.contains("crop") { + return "crosshair" + } + + if role == kAXSplitterRole as String { + return "resize-ew" + } + + let pressableRoles: Set = [ + kAXButtonRole as String, + axLinkRole, + kAXMenuItemRole as String, + kAXPopUpButtonRole as String, + kAXRadioButtonRole as String, + kAXCheckBoxRole as String, + kAXTabGroupRole as String, + ] + let hasPressAction = actions.contains(kAXPressAction as String) + if enabled == false && (hasPressAction || pressableRoles.contains(role ?? "")) { + return "not-allowed" + } + if hasPressAction || pressableRoles.contains(role ?? "") { + return "pointer" + } + + return nil +} + +func currentSystemCursorType() -> String { + let resolvedCursor: NSCursor? = DispatchQueue.main.sync { + if #available(macOS 14.0, *) { + return NSCursor.currentSystem ?? NSCursor.current + } + + return NSCursor.current + } + + guard let resolvedCursor else { + return accessibilityCursorMatch() ?? "arrow" + } + + guard let currentSignature = signature(for: resolvedCursor) else { + return accessibilityCursorMatch() ?? "arrow" + } + + guard let bestMatch = knownCursorSignatures.min(by: { lhs, rhs in + signatureScore(currentSignature, lhs.1) < signatureScore(currentSignature, rhs.1) + }) else { + return accessibilityCursorMatch() ?? "arrow" + } + + let bestScore = signatureScore(currentSignature, bestMatch.1) + let primaryThreshold = strictSignatureAcceptanceThresholds[bestMatch.0] ?? signatureAcceptanceThreshold + let matchedCursorType: String + if bestScore > primaryThreshold { + if let relaxedThreshold = relaxedSignatureAcceptanceThresholds[bestMatch.0], bestScore <= relaxedThreshold { + matchedCursorType = bestMatch.0 + } else { + matchedCursorType = "arrow" + } + } else { + matchedCursorType = bestMatch.0 + } + + return matchedCursorType +} + +func exportCursorImages() { + let cursorForType: [(String, NSCursor)] = [ + ("arrow", .arrow), + ("text", .iBeam), + ("pointer", .pointingHand), + ("crosshair", .crosshair), + ("open-hand", .openHand), + ("closed-hand", .closedHand), + ("resize-ew", .resizeLeftRight), + ("resize-ns", .resizeUpDown), + ("not-allowed", .operationNotAllowed), + ] + + for (name, cursor) in cursorForType { + let image = cursor.image + let hotspot = cursor.hotSpot + let size = image.size + guard size.width > 0, size.height > 0 else { continue } + + guard let tiffData = image.tiffRepresentation, + let bitmapRep = NSBitmapImageRep(data: tiffData), + let pngData = bitmapRep.representation(using: .png, properties: [:]) else { + continue + } + + let base64 = pngData.base64EncodedString() + let hotspotXRatio = hotspot.x / size.width + let hotspotYRatio = hotspot.y / size.height + let aspectRatio = size.width / size.height + print("CURSOR_IMAGE:\(name):\(hotspotXRatio):\(hotspotYRatio):\(aspectRatio):\(base64)") + fflush(stdout) + } +} + +if CommandLine.arguments.contains("--export-images") { + exportCursorImages() + exit(0) +} + +func mouseInteractionCallback( + proxy: CGEventTapProxy, + type: CGEventType, + event: CGEvent, + refcon: UnsafeMutableRawPointer? +) -> Unmanaged? { + let action: String + let button: Int + switch type { + case .leftMouseDown: + action = "mousedown" + button = 1 + case .leftMouseUp: + action = "mouseup" + button = 1 + case .rightMouseDown: + action = "mousedown" + button = 2 + case .rightMouseUp: + action = "mouseup" + button = 2 + case .otherMouseDown: + guard event.getIntegerValueField(.mouseEventButtonNumber) == 2 else { + return Unmanaged.passUnretained(event) + } + action = "mousedown" + button = 3 + case .otherMouseUp: + guard event.getIntegerValueField(.mouseEventButtonNumber) == 2 else { + return Unmanaged.passUnretained(event) + } + action = "mouseup" + button = 3 + default: + return Unmanaged.passUnretained(event) + } + + print("INTERACTION:\(action):\(button)") + fflush(stdout) + return Unmanaged.passUnretained(event) +} + +let mouseEventTypes: [CGEventType] = [ + .leftMouseDown, + .leftMouseUp, + .rightMouseDown, + .rightMouseUp, + .otherMouseDown, + .otherMouseUp, +] +let mouseEventMask = mouseEventTypes.reduce(CGEventMask(0)) { mask, type in + mask | (CGEventMask(1) << type.rawValue) +} +let mouseEventTap = CGEvent.tapCreate( + tap: .cgSessionEventTap, + place: .headInsertEventTap, + options: .listenOnly, + eventsOfInterest: mouseEventMask, + callback: mouseInteractionCallback, + userInfo: nil +) +if let mouseEventTap, + let eventTapSource = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, mouseEventTap, 0) { + CFRunLoopAddSource(CFRunLoopGetMain(), eventTapSource, .commonModes) + CGEvent.tapEnable(tap: mouseEventTap, enable: true) +} else { + fputs("Mouse interaction event tap unavailable; click telemetry disabled\n", stderr) + fflush(stderr) +} + +var lastState = "" +func emitStateIfNeeded() { + let state = currentSystemCursorType() + if state != lastState { + lastState = state + print("STATE:\(state)") + fflush(stdout) + } +} + +let timer = DispatchSource.makeTimerSource(queue: DispatchQueue.global(qos: .utility)) +timer.schedule(deadline: .now(), repeating: .milliseconds(50)) +timer.setEventHandler { + emitStateIfNeeded() +} +timer.resume() + +DispatchQueue.global(qos: .utility).async { + while let line = readLine(strippingNewline: true)?.lowercased() { + if line == "stop" { + exit(0) + } + } + exit(0) +} + +RunLoop.main.run() diff --git a/electron/native/ScreenCaptureKitRecorder.swift b/electron/native/ScreenCaptureKitRecorder.swift new file mode 100644 index 000000000..29253a753 --- /dev/null +++ b/electron/native/ScreenCaptureKitRecorder.swift @@ -0,0 +1,934 @@ +import Foundation +import ScreenCaptureKit +import AVFoundation +import CoreGraphics + +struct CaptureConfig: Codable { + let fps: Int? + let displayId: CGDirectDisplayID? + let windowId: UInt32? + let outputPath: String? + let capturesSystemAudio: Bool? + let capturesMicrophone: Bool? + let systemAudioOutputPath: String? + let microphoneDeviceId: String? + let microphoneLabel: String? + let microphoneOutputPath: String? +} + +let targetCaptureFPS = 60 +let maxInlineAudioTailExtension = CMTime(seconds: 2.0, preferredTimescale: 600) +/// How long finalization waits for a backed-up encoder queue before giving up on +/// the optional tail frame: 100 polls x 10 ms = 1 s. +let writerReadinessPollAttempts = 100 +let writerReadinessPollInterval: UInt64 = 10_000_000 + +final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate { + private struct CaptureFinalizationResult { + let outputResult: Result + let interactiveStopParticipated: Bool + } + + private let queue = DispatchQueue(label: "recordly.screencapturekit.video") + private var assetWriter: AVAssetWriter? + private var videoInput: AVAssetWriterInput? + private var systemAudioWriter: AVAssetWriter? + private var systemAudioInput: AVAssetWriterInput? + private var microphoneOnlyWriter: AVAssetWriter? + private var microphoneOnlyInput: AVAssetWriterInput? + private var stream: SCStream? + private var firstSampleTime: CMTime = .zero + private var firstSystemAudioSampleTime: CMTime? + private var firstMicrophoneSampleTime: CMTime? + private var lastSystemAudioPresentationTime: CMTime = .invalid + private var lastMicrophonePresentationTime: CMTime = .invalid + private var lastSampleBuffer: CMSampleBuffer? + private var lastVideoPresentationTime: CMTime = .zero + private var lastVideoDuration: CMTime = .zero + private var lastInlineAudioPresentationTime: CMTime = .invalid + private var lastInlineAudioDuration: CMTime = .zero + private var isRecording = false + private var isPaused = false + private var pauseStartedHostTime: CMTime? + private var pendingResumeAdjustment = false + private var accumulatedPausedDuration: CMTime = .zero + private var sessionStarted = false + private var frameCount = 0 + private var outputURL: URL? + private var microphoneOutputURL: URL? + private var trackedWindowId: UInt32? + private var windowValidationTask: Task? + private var isFinalizing = false + private var interactiveStopParticipated = false + private var finalizationWaiters: [CheckedContinuation] = [] + private var inlineAudioInput: AVAssetWriterInput? + private var firstInlineAudioSampleTime: CMTime? + private var capturesSystemAudio = false + private var capturesMicrophone = false + private var writesSystemAudioToSeparateTrack = false + private var writesMicrophoneToSeparateTrack = false + + private let microphoneOutputTypeRawValue = 2 + + func startCapture(configJSON: String) async throws { + guard !isRecording else { + throw NSError(domain: "RecordlyCapture", code: 1, userInfo: [NSLocalizedDescriptionKey: "Recording is already in progress"]) + } + + guard let data = configJSON.data(using: .utf8) else { + throw NSError(domain: "RecordlyCapture", code: 2, userInfo: [NSLocalizedDescriptionKey: "Invalid JSON input"]) + } + + let config = try JSONDecoder().decode(CaptureConfig.self, from: data) + let availableContent = try await SCShareableContent.excludingDesktopWindows(false, onScreenWindowsOnly: true) + let streamConfig = SCStreamConfiguration() + capturesSystemAudio = config.capturesSystemAudio ?? false + capturesMicrophone = config.capturesMicrophone ?? false + if capturesMicrophone && !supportsNativeMicrophoneCapture(streamConfig: streamConfig) { + fputs("MICROPHONE_CAPTURE_UNAVAILABLE\n", stderr) + fflush(stderr) + capturesMicrophone = false + } + writesSystemAudioToSeparateTrack = capturesSystemAudio + writesMicrophoneToSeparateTrack = capturesSystemAudio && capturesMicrophone + let requestedFPS = max(targetCaptureFPS, config.fps ?? targetCaptureFPS) + streamConfig.minimumFrameInterval = CMTime(value: 1, timescale: CMTimeScale(requestedFPS)) + streamConfig.queueDepth = 6 + streamConfig.pixelFormat = kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange + streamConfig.colorSpaceName = CGColorSpace.sRGB + streamConfig.colorMatrix = CGDisplayStream.yCbCrMatrix_ITU_R_709_2 + streamConfig.showsCursor = false + streamConfig.capturesAudio = capturesSystemAudio || capturesMicrophone + streamConfig.sampleRate = 48000 + streamConfig.channelCount = 2 + streamConfig.excludesCurrentProcessAudio = true + + if capturesMicrophone { + streamConfig.setValue(true, forKey: "captureMicrophone") + if let microphoneDeviceId = Self.resolveMicrophoneCaptureDeviceID(config: config) { + streamConfig.setValue(microphoneDeviceId, forKey: "microphoneCaptureDeviceID") + } + } + + let filter: SCContentFilter + let outputWidth: Int + let outputHeight: Int + + if let windowId = config.windowId { + trackedWindowId = windowId + guard let window = availableContent.windows.first(where: { $0.windowID == windowId }) else { + throw NSError(domain: "RecordlyCapture", code: 3, userInfo: [NSLocalizedDescriptionKey: "Window not found"]) + } + + filter = SCContentFilter(desktopIndependentWindow: window) + + let candidateDisplay = availableContent.displays.first(where: { + $0.frame.intersects(window.frame) || $0.frame.contains(CGPoint(x: window.frame.midX, y: window.frame.midY)) + }) + let scaleFactor = ScreenCaptureRecorder.scaleFactor(for: candidateDisplay?.displayID ?? CGMainDisplayID()) + outputWidth = max(2, Int(window.frame.width) * scaleFactor) + outputHeight = max(2, Int(window.frame.height) * scaleFactor) + if #available(macOS 14.0, *) { + streamConfig.ignoreShadowsSingleWindow = true + } + streamConfig.width = outputWidth + streamConfig.height = outputHeight + } else { + trackedWindowId = nil + let displayId = config.displayId ?? CGMainDisplayID() + guard let display = availableContent.displays.first(where: { $0.displayID == displayId }) else { + throw NSError(domain: "RecordlyCapture", code: 4, userInfo: [NSLocalizedDescriptionKey: "Display not found"]) + } + + filter = SCContentFilter(display: display, excludingApplications: [], exceptingWindows: []) + let displayBounds = CGDisplayBounds(display.displayID) + let scaleFactor = ScreenCaptureRecorder.scaleFactor(for: display.displayID) + outputWidth = max(2, Int(displayBounds.width) * scaleFactor) + outputHeight = max(2, Int(displayBounds.height) * scaleFactor) + streamConfig.width = outputWidth + streamConfig.height = outputHeight + } + + let destinationURL: URL + if let outputPath = config.outputPath, !outputPath.isEmpty { + destinationURL = URL(fileURLWithPath: outputPath) + } else { + destinationURL = URL(fileURLWithPath: FileManager.default.currentDirectoryPath) + .appendingPathComponent("output_\(Int(Date().timeIntervalSince1970)).mp4") + } + + outputURL = destinationURL + let outputFileType: AVFileType = destinationURL.pathExtension.lowercased() == "mp4" ? .mp4 : .mov + assetWriter = try AVAssetWriter(url: destinationURL, fileType: outputFileType) + microphoneOutputURL = nil + firstSystemAudioSampleTime = nil + firstMicrophoneSampleTime = nil + lastSystemAudioPresentationTime = .invalid + lastMicrophonePresentationTime = .invalid + + guard let assistant = AVOutputSettingsAssistant(preset: .preset3840x2160) else { + throw NSError(domain: "RecordlyCapture", code: 5, userInfo: [NSLocalizedDescriptionKey: "Unable to create output settings assistant"]) + } + + let sourceVideoFormat = try CMVideoFormatDescription( + videoCodecType: CMFormatDescription.MediaSubType( + rawValue: kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange + ), + width: outputWidth, + height: outputHeight + ) + assistant.sourceVideoFormat = sourceVideoFormat + + guard var outputSettings = assistant.videoSettings else { + throw NSError(domain: "RecordlyCapture", code: 6, userInfo: [NSLocalizedDescriptionKey: "Output settings unavailable"]) + } + + outputSettings[AVVideoWidthKey] = outputWidth + outputSettings[AVVideoHeightKey] = outputHeight + outputSettings[AVVideoColorPropertiesKey] = [ + AVVideoColorPrimariesKey: AVVideoColorPrimaries_ITU_R_709_2, + AVVideoTransferFunctionKey: AVVideoTransferFunction_ITU_R_709_2, + AVVideoYCbCrMatrixKey: AVVideoYCbCrMatrix_ITU_R_709_2, + ] + + let videoInput = AVAssetWriterInput( + mediaType: .video, + outputSettings: outputSettings, + sourceFormatHint: sourceVideoFormat + ) + videoInput.expectsMediaDataInRealTime = true + + guard let assetWriter = assetWriter, assetWriter.canAdd(videoInput) else { + throw NSError(domain: "RecordlyCapture", code: 7, userInfo: [NSLocalizedDescriptionKey: "Unable to add video writer input"]) + } + + assetWriter.add(videoInput) + self.videoInput = videoInput + + // Add inline audio track directly to the video so the .mp4 always contains audio. + // This eliminates the dependency on the post-recording ffmpeg mux step. + if capturesSystemAudio || capturesMicrophone { + let inlineAudio = AVAssetWriterInput(mediaType: .audio, outputSettings: Self.audioOutputSettings(bitRate: 192_000)) + inlineAudio.expectsMediaDataInRealTime = true + if assetWriter.canAdd(inlineAudio) { + assetWriter.add(inlineAudio) + self.inlineAudioInput = inlineAudio + } + } + + if writesSystemAudioToSeparateTrack { + guard let systemAudioOutputPath = config.systemAudioOutputPath, !systemAudioOutputPath.isEmpty else { + throw NSError(domain: "RecordlyCapture", code: 11, userInfo: [NSLocalizedDescriptionKey: "Missing system audio output path for audio capture"]) + } + + let systemAudioURL = URL(fileURLWithPath: systemAudioOutputPath) + let systemAudioWriter = try AVAssetWriter(url: systemAudioURL, fileType: .m4a) + let systemAudioInput = AVAssetWriterInput(mediaType: .audio, outputSettings: Self.audioOutputSettings(bitRate: 160_000)) + systemAudioInput.expectsMediaDataInRealTime = true + + guard systemAudioWriter.canAdd(systemAudioInput) else { + throw NSError(domain: "RecordlyCapture", code: 12, userInfo: [NSLocalizedDescriptionKey: "Unable to add system audio writer input"]) + } + + systemAudioWriter.add(systemAudioInput) + self.systemAudioWriter = systemAudioWriter + self.systemAudioInput = systemAudioInput + + guard systemAudioWriter.startWriting() else { + throw NSError(domain: "RecordlyCapture", code: 13, userInfo: [NSLocalizedDescriptionKey: systemAudioWriter.error?.localizedDescription ?? "Unable to start system audio writing"]) + } + + systemAudioWriter.startSession(atSourceTime: .zero) + } + + if writesMicrophoneToSeparateTrack { + guard let microphoneOutputPath = config.microphoneOutputPath, !microphoneOutputPath.isEmpty else { + throw NSError(domain: "RecordlyCapture", code: 14, userInfo: [NSLocalizedDescriptionKey: "Missing microphone output path for microphone capture"]) + } + + let microphoneURL = URL(fileURLWithPath: microphoneOutputPath) + microphoneOutputURL = microphoneURL + let microphoneWriter = try AVAssetWriter(url: microphoneURL, fileType: .m4a) + let microphoneInput = AVAssetWriterInput(mediaType: .audio, outputSettings: Self.audioOutputSettings(bitRate: 128_000)) + microphoneInput.expectsMediaDataInRealTime = true + + guard microphoneWriter.canAdd(microphoneInput) else { + throw NSError(domain: "RecordlyCapture", code: 15, userInfo: [NSLocalizedDescriptionKey: "Unable to add microphone writer input"]) + } + + microphoneWriter.add(microphoneInput) + self.microphoneOnlyWriter = microphoneWriter + self.microphoneOnlyInput = microphoneInput + + guard microphoneWriter.startWriting() else { + throw NSError(domain: "RecordlyCapture", code: 16, userInfo: [NSLocalizedDescriptionKey: microphoneWriter.error?.localizedDescription ?? "Unable to start microphone audio writing"]) + } + + microphoneWriter.startSession(atSourceTime: .zero) + } + + let stream = SCStream(filter: filter, configuration: streamConfig, delegate: self) + self.stream = stream + try stream.addStreamOutput(self, type: .screen, sampleHandlerQueue: queue) + if capturesSystemAudio { + try stream.addStreamOutput(self, type: .audio, sampleHandlerQueue: queue) + } + if capturesMicrophone { + guard let microphoneOutputType = SCStreamOutputType(rawValue: microphoneOutputTypeRawValue) else { + throw NSError( + domain: "RecordlyCapture", + code: 17, + userInfo: [NSLocalizedDescriptionKey: "Microphone stream output type is unavailable"] + ) + } + try stream.addStreamOutput(self, type: microphoneOutputType, sampleHandlerQueue: queue) + } + try await stream.startCapture() + + guard assetWriter.startWriting() else { + throw NSError(domain: "RecordlyCapture", code: 8, userInfo: [NSLocalizedDescriptionKey: assetWriter.error?.localizedDescription ?? "Unable to start video writing"]) + } + + assetWriter.startSession(atSourceTime: .zero) + sessionStarted = true + isRecording = true + isPaused = false + pauseStartedHostTime = nil + pendingResumeAdjustment = false + accumulatedPausedDuration = .zero + frameCount = 0 + firstSampleTime = .zero + lastVideoPresentationTime = .zero + lastVideoDuration = .zero + startWindowValidationIfNeeded() + } + + func stopCapture() async throws -> String { + let finalization = await finalizeCapture(interactive: true) + return try finalization.outputResult.get() + } + + func pauseCapture() async -> Bool { + await withCheckedContinuation { continuation in + queue.async { + guard self.isRecording, !self.isPaused else { + continuation.resume(returning: self.isRecording && self.isPaused) + return + } + self.isPaused = true + self.pauseStartedHostTime = CMClockGetTime(CMClockGetHostTimeClock()) + self.pendingResumeAdjustment = false + continuation.resume(returning: true) + } + } + } + + func resumeCapture() async -> Bool { + await withCheckedContinuation { continuation in + queue.async { + guard self.isRecording, self.isPaused else { + continuation.resume(returning: self.isRecording && !self.isPaused) + return + } + self.isPaused = false + self.pendingResumeAdjustment = true + continuation.resume(returning: true) + } + } + } + + func stream(_ stream: SCStream, didOutputSampleBuffer sampleBuffer: CMSampleBuffer, of outputType: SCStreamOutputType) { + guard sessionStarted, sampleBuffer.isValid, isRecording else { return } + guard let presentationTime = adjustedPresentationTime(for: sampleBuffer, outputType: outputType) else { return } + + if outputType == .screen { + if frameCount > 0 && CMTimeCompare(presentationTime, lastVideoPresentationTime) <= 0 { + return + } + + guard let attachments = CMSampleBufferGetSampleAttachmentsArray(sampleBuffer, createIfNecessary: false) as? [[SCStreamFrameInfo: Any]], + let attachment = attachments.first, + let statusRawValue = attachment[SCStreamFrameInfo.status] as? Int, + let status = SCFrameStatus(rawValue: statusRawValue), + status == .complete else { + return + } + + guard let videoInput = videoInput, + assetWriter?.status == .writing, + videoInput.isReadyForMoreMediaData else { return } + + if firstSampleTime == .zero { + firstSampleTime = sampleBuffer.presentationTimeStamp + } + + lastSampleBuffer = sampleBuffer + let timing = CMSampleTimingInfo(duration: sampleBuffer.duration, presentationTimeStamp: presentationTime, decodeTimeStamp: sampleBuffer.decodeTimeStamp) + if let retimedSampleBuffer = try? CMSampleBuffer(copying: sampleBuffer, withNewTiming: [timing]) { + if videoInput.append(retimedSampleBuffer) { + lastVideoPresentationTime = presentationTime + lastVideoDuration = sampleBuffer.duration + frameCount += 1 + if frameCount == 1 { + // Signal readiness only after AVAssetWriter has accepted a + // real frame, so countdown warm-start cannot pause too early. + print("Recording started") + fflush(stdout) + } + } + } + return + } + + if outputType == .audio { + guard let systemAudioInput else { return } + appendAudioSampleBuffer(sampleBuffer, to: systemAudioInput, of: systemAudioWriter, firstSampleTime: &firstSystemAudioSampleTime, lastPresentationTime: &lastSystemAudioPresentationTime, presentationTime: presentationTime) + // Also write system audio to the inline video track + if let inlineAudioInput, inlineAudioInput.isReadyForMoreMediaData { + appendAudioSampleBuffer(sampleBuffer, to: inlineAudioInput, of: assetWriter, firstSampleTime: &firstInlineAudioSampleTime, lastPresentationTime: &lastInlineAudioPresentationTime, presentationTime: presentationTime) + } + return + } + + if outputType.rawValue == microphoneOutputTypeRawValue { + if let microphoneOnlyInput { + appendAudioSampleBuffer(sampleBuffer, to: microphoneOnlyInput, of: microphoneOnlyWriter, firstSampleTime: &firstMicrophoneSampleTime, lastPresentationTime: &lastMicrophonePresentationTime, presentationTime: presentationTime) + } + // Write mic to inline video track only if there's no system audio (avoids double-writing) + if !capturesSystemAudio, let inlineAudioInput, inlineAudioInput.isReadyForMoreMediaData { + appendAudioSampleBuffer(sampleBuffer, to: inlineAudioInput, of: assetWriter, firstSampleTime: &firstInlineAudioSampleTime, lastPresentationTime: &lastInlineAudioPresentationTime, presentationTime: presentationTime) + } + return + } + + return + } + + func stream(_ stream: SCStream, didStopWithError error: Error) { + fputs("Error: \(error.localizedDescription)\n", stderr) + fflush(stderr) + } + + /// Starts one finalization operation after all previously delivered samples on + /// the recorder queue have drained. Manual stop and automatic window-close + /// detection join the same operation instead of racing the asset writers. + private func finalizeCapture(interactive: Bool) async -> CaptureFinalizationResult { + await withCheckedContinuation { continuation in + queue.async { + if self.isFinalizing { + self.interactiveStopParticipated = self.interactiveStopParticipated || interactive + self.finalizationWaiters.append(continuation) + return + } + + guard self.isRecording else { + continuation.resume(returning: CaptureFinalizationResult( + outputResult: .failure(NSError( + domain: "RecordlyCapture", + code: 9, + userInfo: [NSLocalizedDescriptionKey: "No recording in progress"] + )), + interactiveStopParticipated: interactive + )) + return + } + + self.isFinalizing = true + self.interactiveStopParticipated = interactive + self.isRecording = false + self.windowValidationTask = nil + self.trackedWindowId = nil + self.finalizationWaiters.append(continuation) + + Task { + let outputResult: Result + do { + outputResult = .success(try await self.finishCapture()) + } catch { + outputResult = .failure(error) + } + + self.queue.async { + let finalizationResult = CaptureFinalizationResult( + outputResult: outputResult, + interactiveStopParticipated: self.interactiveStopParticipated + ) + let waiters = self.finalizationWaiters + self.finalizationWaiters.removeAll() + self.isFinalizing = false + self.interactiveStopParticipated = false + for waiter in waiters { + waiter.resume(returning: finalizationResult) + } + } + } + } + } + } + + private func finishCapture() async throws -> String { + + if let activeStream = stream { + do { + try await activeStream.stopCapture() + } catch { + // Stream may have already been stopped by the system — continue with file finalization + } + } + stream = nil + + // The tail frame only gives the last captured frame its full duration, so + // it must never put the file at risk. Appending to an input whose encoder + // queue is still backed up — routine after a long high-resolution capture — + // raises an Objective-C exception that Swift cannot catch, aborting the + // helper before `finishWriting()` and leaving an mdat with no moov atom: + // an unplayable recording. Wait briefly for the queue to drain, then skip + // the frame rather than lose the recording. + if let originalBuffer = lastSampleBuffer, + let videoInput = videoInput, + await waitUntilReady(videoInput, of: assetWriter) { + let additionalTime = lastVideoPresentationTime + frameDuration(for: originalBuffer) + let timing = CMSampleTimingInfo(duration: originalBuffer.duration, presentationTimeStamp: additionalTime, decodeTimeStamp: originalBuffer.decodeTimeStamp) + if let additionalSampleBuffer = try? CMSampleBuffer(copying: originalBuffer, withNewTiming: [timing]) { + videoInput.append(additionalSampleBuffer) + } + } + + // `endSession`, `markAsFinished` and `finishWriting` all raise when the + // writer is no longer in the `.writing` state (a mid-capture failure, for + // example a full disk), which would abort the helper the same way. + let videoEndTime = lastVideoPresentationTime + (lastSampleBuffer.map { frameDuration(for: $0) } ?? .zero) + let endTime = resolvedCaptureEndTime(videoEndTime: videoEndTime) + if let assetWriter, assetWriter.status == .writing { + assetWriter.endSession(atSourceTime: endTime) + videoInput?.markAsFinished() + inlineAudioInput?.markAsFinished() + await assetWriter.finishWriting() + } + + if let systemAudioWriter, systemAudioWriter.status == .writing { + systemAudioInput?.markAsFinished() + await systemAudioWriter.finishWriting() + } + + if let microphoneOnlyWriter, microphoneOnlyWriter.status == .writing { + microphoneOnlyInput?.markAsFinished() + await microphoneOnlyWriter.finishWriting() + } + + let finalizeFailure: Error? = [assetWriter, systemAudioWriter, microphoneOnlyWriter] + .compactMap { $0 } + .compactMap { writer in + writer.status == .completed + ? nil + : (writer.error ?? unfinalizedWriterError(status: writer.status)) + } + .first + let path = outputURL?.path ?? "" + assetWriter = nil + videoInput = nil + systemAudioWriter = nil + systemAudioInput = nil + microphoneOnlyWriter = nil + microphoneOnlyInput = nil + inlineAudioInput = nil + outputURL = nil + microphoneOutputURL = nil + sessionStarted = false + firstSampleTime = .zero + firstSystemAudioSampleTime = nil + firstMicrophoneSampleTime = nil + lastSystemAudioPresentationTime = .invalid + lastMicrophonePresentationTime = .invalid + firstInlineAudioSampleTime = nil + lastSampleBuffer = nil + lastVideoPresentationTime = .zero + lastVideoDuration = .zero + lastInlineAudioPresentationTime = .invalid + lastInlineAudioDuration = .zero + frameCount = 0 + isPaused = false + pauseStartedHostTime = nil + pendingResumeAdjustment = false + accumulatedPausedDuration = .zero + capturesSystemAudio = false + capturesMicrophone = false + writesSystemAudioToSeparateTrack = false + writesMicrophoneToSeparateTrack = false + + // Report a half-written file as a failure instead of handing the editor a + // path it cannot decode. + if let finalizeFailure { + throw finalizeFailure + } + + return path + } + + /// Waits briefly for an input's encoder queue to drain. Returns false when the + /// input stays backed up or its writer is no longer accepting data, in which + /// case the caller must skip the append: `AVAssetWriterInput.append` raises an + /// uncatchable Objective-C exception in both cases. + private func waitUntilReady(_ input: AVAssetWriterInput, of writer: AVAssetWriter?) async -> Bool { + guard let writer else { return false } + + var attemptsRemaining = writerReadinessPollAttempts + while writer.status == .writing { + if input.isReadyForMoreMediaData { + return true + } + guard attemptsRemaining > 0 else { return false } + attemptsRemaining -= 1 + do { + try await Task.sleep(nanoseconds: writerReadinessPollInterval) + } catch is CancellationError { + return false + } catch { + return false + } + } + + return false + } + + private func unfinalizedWriterError(status: AVAssetWriter.Status) -> Error { + NSError(domain: "RecordlyCapture", code: 10, userInfo: [ + NSLocalizedDescriptionKey: "Recording could not be finalized (writer status \(status.rawValue))", + ]) + } + + private func adjustedPresentationTime(for sampleBuffer: CMSampleBuffer, outputType: SCStreamOutputType) -> CMTime? { + if isPaused { + return nil + } + + let sampleTime = sampleBuffer.presentationTimeStamp + if pendingResumeAdjustment { + // Audio and video callbacks share this queue but their timestamps can be + // offset slightly. Anchor the post-countdown adjustment to video and drop + // audio until that anchor exists; otherwise the first audio callback can + // make the following video timestamp move backwards and fail the writer. + guard outputType == .screen, let pauseStartedHostTime else { + return nil + } + let pauseGap = sampleTime - pauseStartedHostTime + if pauseGap > .zero { + accumulatedPausedDuration = accumulatedPausedDuration + pauseGap + } + self.pauseStartedHostTime = nil + pendingResumeAdjustment = false + } + + if outputType == .screen { + if firstSampleTime == .zero { + firstSampleTime = sampleTime + } + } + + // Use video's first sample time as the common time base for ALL tracks. + // This ensures audio files contain leading silence when audio hardware + // delivers its first sample after the first video frame (e.g. iPhone mic + // over Continuity Camera can lag 1-2 seconds behind). + if firstSampleTime == .zero { + // Video hasn't started yet — drop this audio sample to avoid + // negative timestamps. + return nil + } + + return max(.zero, sampleTime - firstSampleTime - accumulatedPausedDuration) + } + + private func frameDuration(for sampleBuffer: CMSampleBuffer) -> CMTime { + if sampleBuffer.duration.isValid && sampleBuffer.duration > .zero { + return sampleBuffer.duration + } + + if lastVideoDuration.isValid && lastVideoDuration > .zero { + return lastVideoDuration + } + + return CMTime(value: 1, timescale: CMTimeScale(targetCaptureFPS)) + } + + private func latestInlineAudioEndTime() -> CMTime { + guard lastInlineAudioPresentationTime.isValid else { + return .invalid + } + + if lastInlineAudioDuration.isValid && lastInlineAudioDuration > .zero { + return lastInlineAudioPresentationTime + lastInlineAudioDuration + } + + return lastInlineAudioPresentationTime + } + + private func resolvedCaptureEndTime(videoEndTime: CMTime) -> CMTime { + let inlineAudioEndTime = latestInlineAudioEndTime() + guard inlineAudioEndTime.isValid else { + return videoEndTime + } + + if CMTimeCompare(inlineAudioEndTime, videoEndTime) <= 0 { + return videoEndTime + } + + // Prevent a stray inline-audio timestamp from forcing finishWriting + // to finalize an arbitrarily long tail. + let tailExtension = CMTimeSubtract(inlineAudioEndTime, videoEndTime) + return videoEndTime + CMTimeMinimum(tailExtension, maxInlineAudioTailExtension) + } + + private func appendAudioSampleBuffer(_ sampleBuffer: CMSampleBuffer, to input: AVAssetWriterInput, of writer: AVAssetWriter?, firstSampleTime: inout CMTime?, lastPresentationTime: inout CMTime, presentationTime: CMTime) { + // A writer that failed mid-capture (a full disk, say) raises on every + // further append, which would abort the helper and lose the whole file. + guard writer?.status == .writing, input.isReadyForMoreMediaData else { return } + guard !lastPresentationTime.isValid || CMTimeCompare(presentationTime, lastPresentationTime) > 0 else { return } + + if firstSampleTime == nil { + firstSampleTime = presentationTime + } + + // presentationTime is already relative to the video's first frame + // (computed by adjustedPresentationTime), so use it directly. + let timing = CMSampleTimingInfo(duration: sampleBuffer.duration, presentationTimeStamp: presentationTime, decodeTimeStamp: sampleBuffer.decodeTimeStamp) + if let retimedSampleBuffer = try? CMSampleBuffer(copying: sampleBuffer, withNewTiming: [timing]) { + let appended = input.append(retimedSampleBuffer) + if appended { + lastPresentationTime = presentationTime + if input === inlineAudioInput { + lastInlineAudioDuration = sampleBuffer.duration + } + } + } + } + + private static func audioOutputSettings(bitRate: Int) -> [String: Any] { + [ + AVFormatIDKey: kAudioFormatMPEG4AAC, + AVSampleRateKey: 48_000, + AVNumberOfChannelsKey: 2, + AVEncoderBitRateKey: bitRate, + ] + } + + private static func resolveMicrophoneCaptureDeviceID(config: CaptureConfig) -> String? { + let audioDevices = AVCaptureDevice.devices(for: .audio) + + if let microphoneLabel = config.microphoneLabel?.trimmingCharacters(in: .whitespacesAndNewlines), !microphoneLabel.isEmpty { + if let matchedDevice = audioDevices.first(where: { $0.localizedName == microphoneLabel }) { + return matchedDevice.uniqueID + } + } + + if let microphoneDeviceId = config.microphoneDeviceId?.trimmingCharacters(in: .whitespacesAndNewlines), !microphoneDeviceId.isEmpty { + if audioDevices.contains(where: { $0.uniqueID == microphoneDeviceId }) { + return microphoneDeviceId + } + } + + return nil + } + + private func supportsNativeMicrophoneCapture(streamConfig: SCStreamConfiguration) -> Bool { + let supportsConfigSelector = streamConfig.responds(to: Selector(("setCaptureMicrophone:"))) + let supportsDeviceSelector = streamConfig.responds(to: Selector(("setMicrophoneCaptureDeviceID:"))) + let supportsOutputType = SCStreamOutputType(rawValue: microphoneOutputTypeRawValue) != nil + return supportsConfigSelector && supportsDeviceSelector && supportsOutputType + } + + private func startWindowValidationIfNeeded() { + guard let trackedWindowId else { + windowValidationTask?.cancel() + windowValidationTask = nil + return + } + + windowValidationTask?.cancel() + windowValidationTask = Task.detached(priority: .utility) { [weak self] in + guard let self else { return } + while !Task.isCancelled { + try? await Task.sleep(nanoseconds: 500_000_000) + if Task.isCancelled { return } + guard self.isRecording else { return } + + let availableContent: SCShareableContent + do { + availableContent = try await SCShareableContent.excludingDesktopWindows(false, onScreenWindowsOnly: true) + } catch { + continue + } + + let windowStillAvailable = availableContent.windows.contains(where: { $0.windowID == trackedWindowId }) + if !windowStillAvailable { + print("WINDOW_UNAVAILABLE") + fflush(stdout) + let finalization = await self.finalizeCapture(interactive: false) + if finalization.interactiveStopParticipated { + return + } + do { + let outputPath = try finalization.outputResult.get() + print("Recording stopped. Output path: \(outputPath)") + fflush(stdout) + exit(0) + } catch { + fputs("Error stopping capture: \(error.localizedDescription)\n", stderr) + fflush(stderr) + exit(1) + } + } + } + } + } + + private static func scaleFactor(for displayId: CGDirectDisplayID) -> Int { + guard let mode = CGDisplayCopyDisplayMode(displayId) else { + return 1 + } + return max(1, mode.pixelWidth / max(1, mode.width)) + } +} + +final class RecorderService { + private let recorder = ScreenCaptureRecorder() + private let queue = DispatchQueue(label: "recordly.screencapturekit.commands") + private let completionGroup = DispatchGroup() + private var succeeded = true + + private func enqueue(_ operation: @escaping () async -> Void) { + queue.async { + let semaphore = DispatchSemaphore(value: 0) + Task { + await operation() + semaphore.signal() + } + semaphore.wait() + } + } + + func start(configJSON: String) { + completionGroup.enter() + enqueue { + do { + try await self.recorder.startCapture(configJSON: configJSON) + } catch { + self.succeeded = false + fputs("Error starting capture: \(error.localizedDescription)\n", stderr) + fflush(stderr) + self.completionGroup.leave() + } + } + } + + func stop() { + enqueue { + do { + let outputPath = try await self.recorder.stopCapture() + print("Recording stopped. Output path: \(outputPath)") + fflush(stdout) + self.completionGroup.leave() + } catch { + self.succeeded = false + fputs("Error stopping capture: \(error.localizedDescription)\n", stderr) + fflush(stderr) + self.completionGroup.leave() + } + } + } + + func pause() { + enqueue { + if await self.recorder.pauseCapture() { + print("Recording paused") + fflush(stdout) + } + } + } + + func resume() { + enqueue { + if await self.recorder.resumeCapture() { + print("Recording resumed") + fflush(stdout) + } + } + } + + func waitUntilFinished() -> Bool { + completionGroup.wait() + return succeeded + } +} + +guard CommandLine.arguments.count >= 2 else { + fputs("Missing config JSON\n", stderr) + fflush(stderr) + exit(1) +} + +// Force CoreGraphics Services initialization on the main thread. +// Without this, SCContentFilter(desktopIndependentWindow:) crashes with +// CGS_REQUIRE_INIT because CGS is never initialised in a CLI tool. +let _ = CGMainDisplayID() + +// Pre-flight check: ensure screen recording permission is granted before +// attempting capture. On macOS 15+, a one-session grant may expire after the +// parent app restarts. CGRequestScreenCaptureAccess() will trigger the +// system-level permission dialog (or open System Settings) when not yet granted. +if !CGPreflightScreenCaptureAccess() { + let granted = CGRequestScreenCaptureAccess() + if !granted { + fputs("SCREEN_RECORDING_PERMISSION_DENIED\n", stderr) + fflush(stderr) + exit(1) + } +} + +// Pre-flight check for microphone access when mic capture is requested. +if let configData = CommandLine.arguments[1].data(using: .utf8), + let config = try? JSONDecoder().decode(CaptureConfig.self, from: configData), + config.capturesMicrophone == true { + switch AVCaptureDevice.authorizationStatus(for: .audio) { + case .authorized: + break + case .notDetermined: + let sem = DispatchSemaphore(value: 0) + AVCaptureDevice.requestAccess(for: .audio) { _ in sem.signal() } + sem.wait() + if AVCaptureDevice.authorizationStatus(for: .audio) != .authorized { + fputs("MICROPHONE_PERMISSION_DENIED\n", stderr) + fflush(stderr) + exit(1) + } + default: + fputs("MICROPHONE_PERMISSION_DENIED\n", stderr) + fflush(stderr) + exit(1) + } +} + +let service = RecorderService() +service.start(configJSON: CommandLine.arguments[1]) + +DispatchQueue.global(qos: .utility).async { + while let input = readLine(strippingNewline: true)?.lowercased() { + if input == "pause" { + service.pause() + continue + } + + if input == "resume" { + service.resume() + continue + } + + if input == "stop" { + service.stop() + break + } + } +} + +if !service.waitUntilFinished() { + exit(1) +} diff --git a/electron/native/ScreenCaptureKitRecorder.test.ts b/electron/native/ScreenCaptureKitRecorder.test.ts new file mode 100644 index 000000000..0956bf6fd --- /dev/null +++ b/electron/native/ScreenCaptureKitRecorder.test.ts @@ -0,0 +1,66 @@ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +const recorderSource = readFileSync( + fileURLToPath(new URL("./ScreenCaptureKitRecorder.swift", import.meta.url)), + "utf8", +); + +describe("ScreenCaptureKitRecorder finalization coordination", () => { + it("marks manual stops as participants in the shared finalization", () => { + expect(recorderSource).toContain("finalizeCapture(interactive: true)"); + expect(recorderSource).toContain("finalization.outputResult.get()"); + expect(recorderSource).toContain( + "self.interactiveStopParticipated = self.interactiveStopParticipated || interactive", + ); + }); + + it("does not let automatic window-close exit preempt a joined manual stop", () => { + expect(recorderSource).toContain("self.finalizeCapture(interactive: false)"); + expect(recorderSource).toMatch( + /if finalization\.interactiveStopParticipated\s*\{\s*return\s*\}/, + ); + }); +}); + +describe("ScreenCaptureKitRecorder resume timing", () => { + it("anchors warm-start resume timing to video before accepting audio", () => { + expect(recorderSource).toContain( + "guard outputType == .screen, let pauseStartedHostTime else", + ); + }); + + it("drops non-monotonic video and audio samples", () => { + expect(recorderSource).toContain( + "CMTimeCompare(presentationTime, lastVideoPresentationTime) <= 0", + ); + expect(recorderSource).toContain( + "CMTimeCompare(presentationTime, lastPresentationTime) > 0", + ); + }); +}); + +describe("ScreenCaptureKitRecorder colour metadata", () => { + it("asks ScreenCaptureKit for BT.709-compatible video-range frames", () => { + expect(recorderSource).toContain( + "streamConfig.pixelFormat = kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange", + ); + expect(recorderSource).toContain("streamConfig.colorSpaceName = CGColorSpace.sRGB"); + expect(recorderSource).toContain( + "streamConfig.colorMatrix = CGDisplayStream.yCbCrMatrix_ITU_R_709_2", + ); + expect(recorderSource).toContain( + "rawValue: kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange", + ); + expect(recorderSource).toContain("sourceFormatHint: sourceVideoFormat"); + expect(recorderSource).not.toContain("videoCodecType: .h264"); + }); + + it("tags recordings as BT.709", () => { + expect(recorderSource).toContain("AVVideoColorPropertiesKey"); + expect(recorderSource).toContain("AVVideoColorPrimaries_ITU_R_709_2"); + expect(recorderSource).toContain("AVVideoTransferFunction_ITU_R_709_2"); + expect(recorderSource).toContain("AVVideoYCbCrMatrix_ITU_R_709_2"); + }); +}); diff --git a/electron/native/ScreenCaptureKitWindowList.swift b/electron/native/ScreenCaptureKitWindowList.swift new file mode 100644 index 000000000..cdb2f5757 --- /dev/null +++ b/electron/native/ScreenCaptureKitWindowList.swift @@ -0,0 +1,155 @@ +import Foundation +import CoreGraphics +import ScreenCaptureKit + +struct WindowListEntry: Codable { + let id: String + let name: String + let display_id: String + let appName: String? + let windowTitle: String? + let bundleId: String? + let x: Double + let y: Double + let width: Double + let height: Double +} + +func normalize(_ value: String?) -> String? { + guard let rawValue = value?.trimmingCharacters(in: .whitespacesAndNewlines), !rawValue.isEmpty else { + return nil + } + + return rawValue +} + +let excludedBundleIds: Set = [ + "com.apple.controlcenter", + "com.apple.dock", + "com.apple.WindowManager", + "com.apple.wallpaper.agent", +] + +let excludedWindowTitles: Set = [ + "Display 1 Backstop", + "Event Shield Window", + "Menubar", + "Offscreen Wallpaper Window", + "Wallpaper-", +] + +// Force CoreGraphics Services initialization before asking ScreenCaptureKit for +// shareable content. Without this, the helper can stall sporadically when run +// as a standalone CLI process from Electron. +let _ = CGMainDisplayID() + +let group = DispatchGroup() +group.enter() + +Task { + do { + let shareableContent = try await SCShareableContent.excludingDesktopWindows(false, onScreenWindowsOnly: true) + + struct RawWindowEntry { + let entry: WindowListEntry + let hasRawTitle: Bool + let bundleId: String? + } + + let rawEntries = shareableContent.windows.compactMap { window -> RawWindowEntry? in + let appName = normalize(window.owningApplication?.applicationName) + let windowTitle = normalize(window.title) + let bundleId = normalize(window.owningApplication?.bundleIdentifier) + let frame = window.frame + + guard window.windowLayer == 0 else { + return nil + } + + guard frame.width >= 50, frame.height >= 50 else { + return nil + } + + guard appName != nil || windowTitle != nil else { + return nil + } + + if let bundleId, excludedBundleIds.contains(bundleId) { + return nil + } + + if let windowTitle, excludedWindowTitles.contains(windowTitle) { + return nil + } + + let matchedDisplay = shareableContent.displays.first(where: { display in + display.frame.intersects(frame) || display.frame.contains(CGPoint(x: frame.midX, y: frame.midY)) + }) + + let resolvedWindowTitle = windowTitle ?? appName ?? "Window" + let resolvedName: String + if let appName, let windowTitle { + resolvedName = "\(appName) — \(windowTitle)" + } else { + resolvedName = resolvedWindowTitle + } + + let entry = WindowListEntry( + id: "window:\(window.windowID):0", + name: resolvedName, + display_id: matchedDisplay.map { String($0.displayID) } ?? "", + appName: appName, + windowTitle: resolvedWindowTitle, + bundleId: bundleId, + x: Double(frame.origin.x), + y: Double(frame.origin.y), + width: Double(frame.width), + height: Double(frame.height) + ) + + return RawWindowEntry(entry: entry, hasRawTitle: windowTitle != nil, bundleId: bundleId) + } + + // For apps with multiple windows, drop auxiliary windows that lack a + // distinct title (e.g. Arc's sidebar/tab-bar chrome). If ALL windows + // from an app lack titles, keep them all. + var titledCountByBundle: [String: Int] = [:] + for raw in rawEntries { + if let bid = raw.bundleId, raw.hasRawTitle { + titledCountByBundle[bid, default: 0] += 1 + } + } + + let entries = rawEntries + .filter { raw in + guard let bid = raw.bundleId else { return true } + if let titled = titledCountByBundle[bid], titled > 0 { + return raw.hasRawTitle + } + return true + } + .map { $0.entry } + .sorted { lhs, rhs in + let lhsApp = lhs.appName ?? lhs.name + let rhsApp = rhs.appName ?? rhs.name + if lhsApp != rhsApp { + return lhsApp.localizedCaseInsensitiveCompare(rhsApp) == .orderedAscending + } + + return (lhs.windowTitle ?? lhs.name).localizedCaseInsensitiveCompare(rhs.windowTitle ?? rhs.name) == .orderedAscending + } + + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + let data = try encoder.encode(entries) + FileHandle.standardOutput.write(data) + } catch { + fputs("Error listing windows: \(error.localizedDescription)\n", stderr) + fflush(stderr) + exit(1) + } + + group.leave() +} + +group.wait() diff --git a/electron/native/SystemCursorAssets.swift b/electron/native/SystemCursorAssets.swift new file mode 100644 index 000000000..b3adee519 --- /dev/null +++ b/electron/native/SystemCursorAssets.swift @@ -0,0 +1,210 @@ +import Foundation +import AppKit + +// Force AppKit initialization so all system cursor images are populated +let _ = NSApplication.shared + +struct CursorAsset: Codable { + let dataUrl: String + let hotspotX: Double + let hotspotY: Double + let width: Double + let height: Double +} + +let minimumCursorRenderScale: CGFloat = 12 + +/// Reference pixel height that all cursor images are normalized to after trimming. +/// Kept high so large in-app cursor scaling still preserves raster detail up to 10x. +let referencePixelHeight: CGFloat = 1024 + +func bestCursorCGImage(for image: NSImage) -> CGImage? { + var proposedRect = CGRect(origin: .zero, size: image.size) + if let cgImage = image.cgImage(forProposedRect: &proposedRect, context: nil, hints: nil) { + return cgImage + } + + let sortedRepresentations = image.representations.sorted { lhs, rhs in + let lhsPixels = lhs.pixelsWide * lhs.pixelsHigh + let rhsPixels = rhs.pixelsWide * rhs.pixelsHigh + return lhsPixels > rhsPixels + } + + for representation in sortedRepresentations { + if let bitmap = representation as? NSBitmapImageRep, let cgImage = bitmap.cgImage { + return cgImage + } + } + + return nil +} + +func makeRetinaImage(from cgImage: CGImage, pointSize: NSSize) -> NSImage { + let bitmap = NSBitmapImageRep(cgImage: cgImage) + bitmap.size = pointSize + let image = NSImage(size: pointSize) + image.addRepresentation(bitmap) + return image +} + +/// Find the bounding box of non-transparent pixels in a bitmap. +/// Returns (x, y, width, height) in pixel coordinates (origin top-left). +func findOpaqueBounds(_ bitmap: NSBitmapImageRep) -> (x: Int, y: Int, width: Int, height: Int)? { + let w = bitmap.pixelsWide + let h = bitmap.pixelsHigh + guard let data = bitmap.bitmapData else { return nil } + let bpr = bitmap.bytesPerRow + var minX = w, minY = h, maxX = -1, maxY = -1 + for y in 0.. 2 { + if x < minX { minX = x } + if x > maxX { maxX = x } + if y < minY { minY = y } + if y > maxY { maxY = y } + } + } + } + if maxX < 0 { return nil } + return (minX, minY, maxX - minX + 1, maxY - minY + 1) +} + +func makeAsset(for cursor: NSCursor) -> CursorAsset? { + let baseImage = cursor.image + let pointSize = baseImage.size + guard let sourceCGImage = bestCursorCGImage(for: baseImage) else { return nil } + let sourcePixelWidth = max(1, sourceCGImage.width) + let sourcePixelHeight = max(1, sourceCGImage.height) + let sourceScaleX = CGFloat(sourcePixelWidth) / max(1, pointSize.width) + let sourceScaleY = CGFloat(sourcePixelHeight) / max(1, pointSize.height) + let sourceScale = max(sourceScaleX, sourceScaleY) + let image = makeRetinaImage(from: sourceCGImage, pointSize: pointSize) + let renderScale = max( + minimumCursorRenderScale, + referencePixelHeight / max(1, CGFloat(sourcePixelHeight)) + ) + let pixelWidth = max(1, Int((CGFloat(sourcePixelWidth) * renderScale).rounded(.up))) + let pixelHeight = max(1, Int((CGFloat(sourcePixelHeight) * renderScale).rounded(.up))) + + // Render at full resolution + guard let bitmap = NSBitmapImageRep( + bitmapDataPlanes: nil, + pixelsWide: pixelWidth, + pixelsHigh: pixelHeight, + bitsPerSample: 8, + samplesPerPixel: 4, + hasAlpha: true, + isPlanar: false, + colorSpaceName: .deviceRGB, + bytesPerRow: 0, + bitsPerPixel: 0 + ) else { return nil } + + bitmap.size = NSSize(width: CGFloat(pixelWidth), height: CGFloat(pixelHeight)) + + NSGraphicsContext.saveGraphicsState() + guard let context = NSGraphicsContext(bitmapImageRep: bitmap) else { + NSGraphicsContext.restoreGraphicsState() + return nil + } + NSGraphicsContext.current = context + context.imageInterpolation = .none + context.cgContext.interpolationQuality = .none + image.draw( + in: NSRect(x: 0, y: 0, width: CGFloat(pixelWidth), height: CGFloat(pixelHeight)), + from: .zero, + operation: .copy, + fraction: 1 + ) + context.flushGraphics() + NSGraphicsContext.restoreGraphicsState() + + // Find opaque bounds and crop away transparent padding + guard let bounds = findOpaqueBounds(bitmap) else { return nil } + guard let fullCG = bitmap.cgImage else { return nil } + let cropRect = CGRect(x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height) + guard let croppedCG = fullCG.cropping(to: cropRect) else { return nil } + + // Scale cropped image to referencePixelHeight + let aspect = CGFloat(bounds.width) / CGFloat(bounds.height) + let outH = Int(referencePixelHeight) + let outW = max(1, Int((referencePixelHeight * aspect).rounded(.up))) + + guard let outBitmap = NSBitmapImageRep( + bitmapDataPlanes: nil, + pixelsWide: outW, + pixelsHigh: outH, + bitsPerSample: 8, + samplesPerPixel: 4, + hasAlpha: true, + isPlanar: false, + colorSpaceName: .deviceRGB, + bytesPerRow: 0, + bitsPerPixel: 0 + ) else { return nil } + + outBitmap.size = NSSize(width: outW, height: outH) + + NSGraphicsContext.saveGraphicsState() + guard let outCtx = NSGraphicsContext(bitmapImageRep: outBitmap) else { + NSGraphicsContext.restoreGraphicsState() + return nil + } + NSGraphicsContext.current = outCtx + outCtx.imageInterpolation = .none + outCtx.cgContext.interpolationQuality = .none + let nsImage = NSImage(cgImage: croppedCG, size: NSSize(width: bounds.width, height: bounds.height)) + nsImage.draw( + in: NSRect(x: 0, y: 0, width: outW, height: outH), + from: .zero, + operation: .copy, + fraction: 1 + ) + outCtx.flushGraphics() + NSGraphicsContext.restoreGraphicsState() + + guard let pngData = outBitmap.representation(using: .png, properties: [:]) else { return nil } + let base64 = pngData.base64EncodedString() + + // Adjust hotspot using the extracted source image scale so 2x cursor reps keep the correct anchor. + let hotspot = cursor.hotSpot + let hotspotPxX = hotspot.x * sourceScale * renderScale - CGFloat(bounds.x) + let hotspotPxY = hotspot.y * sourceScale * renderScale - CGFloat(bounds.y) + let scaleToOut = referencePixelHeight / CGFloat(bounds.height) + let outHotspotX = hotspotPxX * scaleToOut + let outHotspotY = hotspotPxY * scaleToOut + + return CursorAsset( + dataUrl: "data:image/png;base64,\(base64)", + hotspotX: Double(outHotspotX), + hotspotY: Double(outHotspotY), + width: Double(outW), + height: Double(outH) + ) +} + +let cursorMap: [String: NSCursor] = [ + "arrow": .arrow, + "text": .iBeam, + "pointer": .pointingHand, + "crosshair": .crosshair, + "open-hand": .openHand, + "closed-hand": .closedHand, + "resize-ew": .resizeLeftRight, + "resize-ns": .resizeUpDown, + "not-allowed": .operationNotAllowed, +] + +var assets: [String: CursorAsset] = [:] +for (key, cursor) in cursorMap { + if let asset = makeAsset(for: cursor) { + assets[key] = asset + } +} + +let encoder = JSONEncoder() +encoder.outputFormatting = [.sortedKeys] +let data = try encoder.encode(assets) +FileHandle.standardOutput.write(data) + diff --git a/electron/native/bin/darwin-arm64/openscreen-window-list b/electron/native/bin/darwin-arm64/openscreen-window-list new file mode 100755 index 000000000..4cf4957bc Binary files /dev/null and b/electron/native/bin/darwin-arm64/openscreen-window-list differ diff --git a/electron/native/bin/darwin-arm64/recordly-native-cursor-monitor b/electron/native/bin/darwin-arm64/recordly-native-cursor-monitor new file mode 100755 index 000000000..962161c1c Binary files /dev/null and b/electron/native/bin/darwin-arm64/recordly-native-cursor-monitor differ diff --git a/electron/native/bin/darwin-arm64/recordly-screencapturekit-helper b/electron/native/bin/darwin-arm64/recordly-screencapturekit-helper new file mode 100755 index 000000000..233cfd172 Binary files /dev/null and b/electron/native/bin/darwin-arm64/recordly-screencapturekit-helper differ diff --git a/electron/native/bin/darwin-arm64/recordly-system-cursors b/electron/native/bin/darwin-arm64/recordly-system-cursors new file mode 100755 index 000000000..f4b41ab66 Binary files /dev/null and b/electron/native/bin/darwin-arm64/recordly-system-cursors differ diff --git a/electron/native/bin/darwin-arm64/recordly-window-list b/electron/native/bin/darwin-arm64/recordly-window-list new file mode 100755 index 000000000..76a7dab4a Binary files /dev/null and b/electron/native/bin/darwin-arm64/recordly-window-list differ diff --git a/electron/native/bin/darwin-arm64/whisper-bench b/electron/native/bin/darwin-arm64/whisper-bench new file mode 100755 index 000000000..5d33dbf00 Binary files /dev/null and b/electron/native/bin/darwin-arm64/whisper-bench differ diff --git a/electron/native/bin/darwin-arm64/whisper-cli b/electron/native/bin/darwin-arm64/whisper-cli new file mode 100755 index 000000000..695c8c9a4 Binary files /dev/null and b/electron/native/bin/darwin-arm64/whisper-cli differ diff --git a/electron/native/bin/darwin-arm64/whisper-quantize b/electron/native/bin/darwin-arm64/whisper-quantize new file mode 100755 index 000000000..d51722d9b Binary files /dev/null and b/electron/native/bin/darwin-arm64/whisper-quantize differ diff --git a/electron/native/bin/darwin-arm64/whisper-runtime.json b/electron/native/bin/darwin-arm64/whisper-runtime.json new file mode 100644 index 000000000..380d1adf6 --- /dev/null +++ b/electron/native/bin/darwin-arm64/whisper-runtime.json @@ -0,0 +1,6 @@ +{ + "version": "v1.8.4", + "platform": "darwin", + "arch": "arm64", + "binary": "whisper-cli" +} diff --git a/electron/native/bin/darwin-arm64/whisper-server b/electron/native/bin/darwin-arm64/whisper-server new file mode 100755 index 000000000..790c1fe82 Binary files /dev/null and b/electron/native/bin/darwin-arm64/whisper-server differ diff --git a/electron/native/bin/darwin-arm64/whisper-vad-speech-segments b/electron/native/bin/darwin-arm64/whisper-vad-speech-segments new file mode 100755 index 000000000..65e963b39 Binary files /dev/null and b/electron/native/bin/darwin-arm64/whisper-vad-speech-segments differ diff --git a/electron/native/bin/darwin-x64/recordly-native-cursor-monitor b/electron/native/bin/darwin-x64/recordly-native-cursor-monitor new file mode 100755 index 000000000..3e55919f1 Binary files /dev/null and b/electron/native/bin/darwin-x64/recordly-native-cursor-monitor differ diff --git a/electron/native/bin/darwin-x64/recordly-screencapturekit-helper b/electron/native/bin/darwin-x64/recordly-screencapturekit-helper new file mode 100755 index 000000000..8982873fb Binary files /dev/null and b/electron/native/bin/darwin-x64/recordly-screencapturekit-helper differ diff --git a/electron/native/bin/darwin-x64/recordly-system-cursors b/electron/native/bin/darwin-x64/recordly-system-cursors new file mode 100755 index 000000000..545613624 Binary files /dev/null and b/electron/native/bin/darwin-x64/recordly-system-cursors differ diff --git a/electron/native/bin/darwin-x64/recordly-window-list b/electron/native/bin/darwin-x64/recordly-window-list new file mode 100755 index 000000000..e165257ae Binary files /dev/null and b/electron/native/bin/darwin-x64/recordly-window-list differ diff --git a/electron/native/bin/win32-x64/cursor-monitor.exe b/electron/native/bin/win32-x64/cursor-monitor.exe new file mode 100644 index 000000000..70738d118 Binary files /dev/null and b/electron/native/bin/win32-x64/cursor-monitor.exe differ diff --git a/electron/native/bin/win32-x64/helpers-manifest.json b/electron/native/bin/win32-x64/helpers-manifest.json new file mode 100644 index 000000000..62ee78b5a --- /dev/null +++ b/electron/native/bin/win32-x64/helpers-manifest.json @@ -0,0 +1,35 @@ +{ + "version": 1, + "platform": "win32", + "arch": "x64", + "helpers": { + "wgc-capture": { + "binaryName": "wgc-capture.exe", + "binarySha256": "4d89fdff8e3343998c7a3b4d75d964c1f75f93594aa097c6681e477d25ec9f01", + "sourceDir": "electron/native/wgc-capture", + "sourceFingerprint": "c6dac250c9d16f7aa881998353441b4ae3fb59731b802e3b8491a136e79726b0", + "updatedAt": "2026-07-11T11:58:45.856Z" + }, + "cursor-monitor": { + "binaryName": "cursor-monitor.exe", + "binarySha256": "f1d8f30e8d7bee19ecea91c9a90a95ea4824138b8336b18030fa0602a641d70d", + "sourceDir": "electron/native/cursor-monitor", + "sourceFingerprint": "45bb72e4039e061a354af87317d7c42ec3d2118369b3f4379046ff497b701e72", + "updatedAt": "2026-07-11T11:58:56.534Z" + }, + "recordly-gpu-export": { + "binaryName": "recordly-gpu-export.exe", + "binarySha256": "49a2ac588206305d0129e6263ce4be49780c50a9dc4efc7df63aad09178a919f", + "sourceDir": "electron/native/gpu-export-probe", + "sourceFingerprint": "37a5842eba63cdeccfddd02cc278a98207248fb0aa6b56153fb85ed152c908c1", + "updatedAt": "2026-07-11T11:58:51.659Z" + }, + "recordly-nvidia-cuda-compositor": { + "binaryName": "recordly-nvidia-cuda-compositor.exe", + "binarySha256": "250a3f8cac7c6ea38a873434d23d4b2be7d6555e42cc0b405aa26f774169159c", + "sourceDir": "electron/native/nvidia-cuda-compositor", + "sourceFingerprint": "de1219228ce326e96d1f4815a3763b10d5f235cc1286bc6542c99707a85d5947", + "updatedAt": "2026-05-27T11:29:32.957Z" + } + } +} diff --git a/electron/native/bin/win32-x64/recordly-gpu-export.exe b/electron/native/bin/win32-x64/recordly-gpu-export.exe new file mode 100644 index 000000000..b9d1efc9e Binary files /dev/null and b/electron/native/bin/win32-x64/recordly-gpu-export.exe differ diff --git a/electron/native/bin/win32-x64/recordly-nvidia-cuda-compositor.exe b/electron/native/bin/win32-x64/recordly-nvidia-cuda-compositor.exe new file mode 100644 index 000000000..c23c14f90 Binary files /dev/null and b/electron/native/bin/win32-x64/recordly-nvidia-cuda-compositor.exe differ diff --git a/electron/native/bin/win32-x64/wgc-capture.exe b/electron/native/bin/win32-x64/wgc-capture.exe new file mode 100644 index 000000000..a882ee87a Binary files /dev/null and b/electron/native/bin/win32-x64/wgc-capture.exe differ diff --git a/electron/native/common/bt709_video.h b/electron/native/common/bt709_video.h new file mode 100644 index 000000000..f4758dcce --- /dev/null +++ b/electron/native/common/bt709_video.h @@ -0,0 +1,66 @@ +#pragma once + +#include +#include +#include +#include + +inline int clampVideoSample(int value, int minimum, int maximum) { + return value < minimum ? minimum : (value > maximum ? maximum : value); +} + +inline HRESULT setBt709LimitedVideoAttributes(IMFMediaType* mediaType) { + HRESULT result = mediaType->SetUINT32(MF_MT_VIDEO_PRIMARIES, MFVideoPrimaries_BT709); + if (FAILED(result)) return result; + result = mediaType->SetUINT32(MF_MT_TRANSFER_FUNCTION, MFVideoTransFunc_709); + if (FAILED(result)) return result; + result = mediaType->SetUINT32(MF_MT_YUV_MATRIX, MFVideoTransferMatrix_BT709); + if (FAILED(result)) return result; + return mediaType->SetUINT32(MF_MT_VIDEO_NOMINAL_RANGE, MFNominalRange_16_235); +} + +inline void convertBgraToBt709LimitedNv12( + const uint8_t* bgra, + int bgraPitch, + int width, + int height, + std::vector& nv12Buffer) { + for (int y = 0; y < height; ++y) { + for (int x = 0; x < width; ++x) { + const uint8_t* pixel = bgra + y * bgraPitch + x * 4; + const int blue = pixel[0]; + const int green = pixel[1]; + const int red = pixel[2]; + const int luma = ((47 * red + 157 * green + 16 * blue + 128) >> 8) + 16; + nv12Buffer[y * width + x] = static_cast(clampVideoSample(luma, 16, 235)); + } + } + + uint8_t* uvPlane = nv12Buffer.data() + width * height; + for (int y = 0; y < height; y += 2) { + for (int x = 0; x < width; x += 2) { + int red = 0; + int green = 0; + int blue = 0; + for (int offsetY = 0; offsetY < 2; ++offsetY) { + for (int offsetX = 0; offsetX < 2; ++offsetX) { + const uint8_t* pixel = + bgra + (y + offsetY) * bgraPitch + (x + offsetX) * 4; + blue += pixel[0]; + green += pixel[1]; + red += pixel[2]; + } + } + red = (red + 2) / 4; + green = (green + 2) / 4; + blue = (blue + 2) / 4; + + // Coefficients sum to zero so neutral greys remain neutral after quantization. + const int chromaBlue = ((-26 * red - 87 * green + 113 * blue + 128) >> 8) + 128; + const int chromaRed = ((112 * red - 102 * green - 10 * blue + 128) >> 8) + 128; + const int uvIndex = (y / 2) * width + x; + uvPlane[uvIndex] = static_cast(clampVideoSample(chromaBlue, 16, 240)); + uvPlane[uvIndex + 1] = static_cast(clampVideoSample(chromaRed, 16, 240)); + } + } +} diff --git a/electron/native/cursor-monitor/CMakeLists.txt b/electron/native/cursor-monitor/CMakeLists.txt new file mode 100644 index 000000000..8155aed95 --- /dev/null +++ b/electron/native/cursor-monitor/CMakeLists.txt @@ -0,0 +1,9 @@ +cmake_minimum_required(VERSION 3.20) +project(cursor-monitor LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +add_executable(cursor-monitor src/main.cpp) + +target_link_libraries(cursor-monitor PRIVATE user32) diff --git a/electron/native/cursor-monitor/src/main.cpp b/electron/native/cursor-monitor/src/main.cpp new file mode 100644 index 000000000..9a811f7be --- /dev/null +++ b/electron/native/cursor-monitor/src/main.cpp @@ -0,0 +1,60 @@ +#include +#include +#include +#include +#include +#include +#include + +static std::atomic g_running{true}; + +static void stdinListener() { + std::string line; + while (std::getline(std::cin, line)) { + if (line == "stop") { + g_running.store(false); + return; + } + } + g_running.store(false); +} + +int main() { + std::setvbuf(stdout, nullptr, _IONBF, 0); + + std::unordered_map cursorMap; + cursorMap[LoadCursor(NULL, IDC_ARROW)] = "arrow"; + cursorMap[LoadCursor(NULL, IDC_IBEAM)] = "text"; + cursorMap[LoadCursor(NULL, IDC_HAND)] = "pointer"; + cursorMap[LoadCursor(NULL, IDC_CROSS)] = "crosshair"; + cursorMap[LoadCursor(NULL, IDC_NO)] = "not-allowed"; + cursorMap[LoadCursor(NULL, IDC_SIZEWE)] = "resize-ew"; + cursorMap[LoadCursor(NULL, IDC_SIZENS)] = "resize-ns"; + cursorMap[LoadCursor(NULL, IDC_SIZEALL)] = "open-hand"; + cursorMap[LoadCursor(NULL, IDC_WAIT)] = "arrow"; + cursorMap[LoadCursor(NULL, IDC_APPSTARTING)] = "arrow"; + + std::thread listener(stdinListener); + listener.detach(); + + std::string lastType; + + while (g_running.load()) { + CURSORINFO ci = {}; + ci.cbSize = sizeof(ci); + + if (GetCursorInfo(&ci) && (ci.flags & CURSOR_SHOWING)) { + auto it = cursorMap.find(ci.hCursor); + std::string type = (it != cursorMap.end()) ? it->second : "arrow"; + + if (type != lastType) { + lastType = type; + std::cout << "STATE:" << type << std::endl; + } + } + + Sleep(50); + } + + return 0; +} diff --git a/electron/native/gpu-export-probe/CMakeLists.txt b/electron/native/gpu-export-probe/CMakeLists.txt new file mode 100644 index 000000000..31a718a0c --- /dev/null +++ b/electron/native/gpu-export-probe/CMakeLists.txt @@ -0,0 +1,37 @@ +cmake_minimum_required(VERSION 3.20) +project(gpu-export-probe LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +add_executable(gpu-export-probe + src/main.cpp +) + +set(NVIDIA_VIDEO_SDK_SAMPLES_DIR "${CMAKE_CURRENT_LIST_DIR}/../../../.tmp/video-sdk-samples/Samples") +set(NVIDIA_NVENC_SDK_DIR "${NVIDIA_VIDEO_SDK_SAMPLES_DIR}/NvCodec") +if(EXISTS "${NVIDIA_NVENC_SDK_DIR}/NvEncoder/NvEncoderD3D11.cpp") + target_sources(gpu-export-probe PRIVATE + "${NVIDIA_NVENC_SDK_DIR}/NvEncoder/NvEncoder.cpp" + "${NVIDIA_NVENC_SDK_DIR}/NvEncoder/NvEncoderD3D11.cpp" + ) + target_include_directories(gpu-export-probe PRIVATE + "${NVIDIA_VIDEO_SDK_SAMPLES_DIR}" + "${NVIDIA_NVENC_SDK_DIR}" + ) + target_compile_definitions(gpu-export-probe PRIVATE RECORDLY_GPU_EXPORT_ENABLE_NVENC_SDK=1) +endif() + +target_compile_options(gpu-export-probe PRIVATE /EHsc /W4 /utf-8) + +target_link_libraries(gpu-export-probe PRIVATE + d3d11 + d3dcompiler + dxgi + mfplat + mfreadwrite + mf + mfuuid + ole32 + windowscodecs +) diff --git a/electron/native/gpu-export-probe/src/main.cpp b/electron/native/gpu-export-probe/src/main.cpp new file mode 100644 index 000000000..158f103f7 --- /dev/null +++ b/electron/native/gpu-export-probe/src/main.cpp @@ -0,0 +1,3390 @@ +#define NOMINMAX +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifdef RECORDLY_GPU_EXPORT_ENABLE_NVENC_SDK +#include "NvEncoder/NvEncoderD3D11.h" +#endif + +#pragma comment(lib, "d3d11.lib") +#pragma comment(lib, "d3dcompiler.lib") +#pragma comment(lib, "dxgi.lib") +#pragma comment(lib, "mfplat.lib") +#pragma comment(lib, "mfreadwrite.lib") +#pragma comment(lib, "mf.lib") +#pragma comment(lib, "mfuuid.lib") +#pragma comment(lib, "windowscodecs.lib") + +using Microsoft::WRL::ComPtr; + +namespace { + +struct Options { + std::wstring inputPath; + std::wstring outputPath = L"gpu-export-probe.mp4"; + UINT width = 1920; + UINT height = 1080; + UINT fps = 30; + double seconds = 60.0; + UINT bitrate = 12'000'000; + bool shaderComposite = false; + float radius = 32.0f; + float shadow = 36.0f; + float padding = 0.0f; + LONG contentLeft = -1; + LONG contentTop = -1; + LONG contentWidth = 0; + LONG contentHeight = 0; + LONG sourceCropLeft = 0; + LONG sourceCropTop = 0; + LONG sourceCropWidth = 0; + LONG sourceCropHeight = 0; + float backgroundR = 0.035f; + float backgroundG = 0.035f; + float backgroundB = 0.045f; + float backgroundBlurPx = 0.0f; + std::wstring backgroundImagePath; + std::wstring webcamInputPath; + LONG webcamLeft = -1; + LONG webcamTop = -1; + LONG webcamSize = 0; + float webcamRadius = 18.0f; + float webcamShadow = 0.0f; + bool webcamMirror = false; + double webcamTimeOffsetMs = 0.0; + std::wstring cursorTelemetryPath; + std::wstring cursorAtlasPath; + std::wstring cursorAtlasMetadataPath; + float cursorSize = 84.0f; + std::wstring zoomTelemetryPath; + std::wstring timelineMapPath; + bool preferHighPerformanceAdapter = false; + int adapterIndex = -1; + bool fastEncoderTuning = false; + UINT surfacePoolSize = 4; + bool nvencSdk = false; +}; + +struct ShaderConstants { + float outputWidth; + float outputHeight; + float radius; + float shadowSize; + float contentLeft; + float contentTop; + float contentRight; + float contentBottom; + float backgroundR; + float backgroundG; + float backgroundB; + float backgroundA; + float shadowR; + float shadowG; + float shadowB; + float shadowA; + float backgroundImageEnabled; + float backgroundImageWidth; + float backgroundImageHeight; + float webcamEnabled; + float webcamLeft; + float webcamTop; + float webcamRight; + float webcamBottom; + float webcamRadius; + float webcamShadowSize; + float webcamShadowA; + float webcamMirror; + float cursorEnabled; + float cursorX; + float cursorY; + float cursorSize; + float cursorAtlasEnabled; + float cursorAtlasLeft; + float cursorAtlasTop; + float cursorAtlasRight; + float cursorAtlasBottom; + float cursorAtlasAnchorX; + float cursorAtlasAnchorY; + float cursorAtlasAspect; + float cursorBounceScale; + float backgroundBlurPx; + float backgroundBlurPadding0; + float backgroundBlurPadding1; + float zoomEnabled; + float zoomScale; + float zoomX; + float zoomY; +}; + +struct CursorSample { + double timeMs = 0.0; + float cx = 0.0f; + float cy = 0.0f; + int cursorTypeIndex = 0; + float bounceScale = 1.0f; + bool visible = true; +}; + +struct CursorAtlasEntry { + float x = 0.0f; + float y = 0.0f; + float width = 1.0f; + float height = 1.0f; + float anchorX = 0.0f; + float anchorY = 0.0f; + float aspectRatio = 1.0f; + bool valid = false; +}; + +struct ZoomSample { + double timeMs = 0.0; + float scale = 1.0f; + float x = 0.0f; + float y = 0.0f; +}; + +struct TimelineSegment { + double sourceStartMs = 0.0; + double sourceEndMs = 0.0; + double outputStartMs = 0.0; + double outputEndMs = 0.0; + double speed = 1.0; +}; + +struct Timer { + std::chrono::steady_clock::time_point start = std::chrono::steady_clock::now(); + + double elapsedMs() const { + const auto now = std::chrono::steady_clock::now(); + return std::chrono::duration(now - start).count(); + } +}; + +std::wstring getArgValue(const std::vector& args, const std::wstring& key) { + for (size_t i = 0; i + 1 < args.size(); ++i) { + if (args[i] == key) { + return args[i + 1]; + } + } + return L""; +} + +UINT parseUIntArg(const std::vector& args, const std::wstring& key, UINT fallback) { + const auto value = getArgValue(args, key); + if (value.empty()) { + return fallback; + } + + try { + return static_cast(std::stoul(value)); + } catch (...) { + return fallback; + } +} + +LONG parseLongArg(const std::vector& args, const std::wstring& key, LONG fallback) { + const auto value = getArgValue(args, key); + if (value.empty()) { + return fallback; + } + + try { + return static_cast(std::stol(value)); + } catch (...) { + return fallback; + } +} + +float parseFloatArg(const std::vector& args, const std::wstring& key, float fallback) { + const auto value = getArgValue(args, key); + if (value.empty()) { + return fallback; + } + + try { + return std::stof(value); + } catch (...) { + return fallback; + } +} + +double parseDoubleArg(const std::vector& args, const std::wstring& key, double fallback) { + const auto value = getArgValue(args, key); + if (value.empty()) { + return fallback; + } + + try { + return std::stod(value); + } catch (...) { + return fallback; + } +} + +bool hasArg(const std::vector& args, const std::wstring& key) { + return std::find(args.begin(), args.end(), key) != args.end(); +} + +bool parseHexColor(const std::wstring& value, float& red, float& green, float& blue) { + std::wstring trimmed = value; + trimmed.erase( + std::remove_if(trimmed.begin(), trimmed.end(), [](wchar_t ch) { + return ch == L' ' || ch == L'\t' || ch == L'\r' || ch == L'\n'; + }), + trimmed.end()); + if (!trimmed.empty() && trimmed[0] == L'#') { + trimmed.erase(trimmed.begin()); + } + if (trimmed.size() != 6) { + return false; + } + + try { + const auto number = std::stoul(trimmed, nullptr, 16); + red = static_cast((number >> 16) & 0xff) / 255.0f; + green = static_cast((number >> 8) & 0xff) / 255.0f; + blue = static_cast(number & 0xff) / 255.0f; + return true; + } catch (...) { + return false; + } +} + +std::vector loadTimelineMap(const std::wstring& path) { + std::vector segments; + if (path.empty()) { + return segments; + } + + FILE* file = nullptr; + if (_wfopen_s(&file, path.c_str(), L"rb") != 0 || !file) { + std::cerr << "[gpu-export] Unable to open timeline map" << std::endl; + return segments; + } + + char line[512] = {}; + double expectedOutputStartMs = 0.0; + while (fgets(line, sizeof(line), file)) { + TimelineSegment segment; + if (sscanf_s( + line, + "%lf,%lf,%lf,%lf,%lf", + &segment.sourceStartMs, + &segment.sourceEndMs, + &segment.outputStartMs, + &segment.outputEndMs, + &segment.speed) != 5) { + segments.clear(); + break; + } + if ( + !std::isfinite(segment.sourceStartMs) || + !std::isfinite(segment.sourceEndMs) || + !std::isfinite(segment.outputStartMs) || + !std::isfinite(segment.outputEndMs) || + !std::isfinite(segment.speed) || + segment.sourceEndMs <= segment.sourceStartMs || + segment.outputEndMs <= segment.outputStartMs || + segment.speed <= 0.0 || + std::abs(segment.outputStartMs - expectedOutputStartMs) > 2.0) { + segments.clear(); + break; + } + expectedOutputStartMs = segment.outputEndMs; + segments.push_back(segment); + } + fclose(file); + return segments; +} + +bool sourceToOutputMs( + const std::vector& segments, + double sourceMs, + double& outputMs) { + if (segments.empty()) { + outputMs = sourceMs; + return true; + } + + constexpr double kToleranceMs = 1.0; + for (const auto& segment : segments) { + if (sourceMs + kToleranceMs < segment.sourceStartMs) { + return false; + } + if (sourceMs <= segment.sourceEndMs + kToleranceMs) { + const double clampedSourceMs = + std::min(segment.sourceEndMs, std::max(segment.sourceStartMs, sourceMs)); + outputMs = segment.outputStartMs + (clampedSourceMs - segment.sourceStartMs) / segment.speed; + outputMs = std::min(segment.outputEndMs, std::max(segment.outputStartMs, outputMs)); + return true; + } + } + + return false; +} + +double outputToSourceMs(const std::vector& segments, double outputMs) { + if (segments.empty()) { + return outputMs; + } + + for (const auto& segment : segments) { + if (outputMs <= segment.outputEndMs + 1.0) { + const double clampedOutputMs = + std::min(segment.outputEndMs, std::max(segment.outputStartMs, outputMs)); + return segment.sourceStartMs + (clampedOutputMs - segment.outputStartMs) * segment.speed; + } + } + + return segments.back().sourceEndMs; +} + +Options parseOptions(int argc, wchar_t** argv) { + std::vector args; + args.reserve(static_cast(argc)); + for (int i = 0; i < argc; ++i) { + args.emplace_back(argv[i]); + } + + Options options; + const auto input = getArgValue(args, L"--input"); + if (!input.empty()) { + options.inputPath = input; + } + const auto output = getArgValue(args, L"--output"); + if (!output.empty()) { + options.outputPath = output; + } + options.width = parseUIntArg(args, L"--width", options.width); + options.height = parseUIntArg(args, L"--height", options.height); + options.fps = parseUIntArg(args, L"--fps", options.fps); + options.seconds = parseDoubleArg(args, L"--seconds", options.seconds); + options.bitrate = parseUIntArg(args, L"--bitrate", options.bitrate); + options.shaderComposite = hasArg(args, L"--shader-composite"); + options.radius = parseFloatArg(args, L"--radius", options.radius); + options.shadow = parseFloatArg(args, L"--shadow", options.shadow); + options.padding = parseFloatArg(args, L"--padding", options.padding); + options.backgroundBlurPx = std::max( + 0.0f, + parseFloatArg(args, L"--background-blur", options.backgroundBlurPx)); + options.contentLeft = parseLongArg(args, L"--content-left", options.contentLeft); + options.contentTop = parseLongArg(args, L"--content-top", options.contentTop); + options.contentWidth = parseLongArg(args, L"--content-width", options.contentWidth); + options.contentHeight = parseLongArg(args, L"--content-height", options.contentHeight); + options.sourceCropLeft = parseLongArg(args, L"--source-crop-x", options.sourceCropLeft); + options.sourceCropTop = parseLongArg(args, L"--source-crop-y", options.sourceCropTop); + options.sourceCropWidth = parseLongArg(args, L"--source-crop-width", options.sourceCropWidth); + options.sourceCropHeight = parseLongArg(args, L"--source-crop-height", options.sourceCropHeight); + const auto backgroundColor = getArgValue(args, L"--background-color"); + if (!backgroundColor.empty()) { + parseHexColor( + backgroundColor, + options.backgroundR, + options.backgroundG, + options.backgroundB); + } + const auto backgroundImage = getArgValue(args, L"--background-image"); + if (!backgroundImage.empty()) { + options.backgroundImagePath = backgroundImage; + } + const auto webcamInput = getArgValue(args, L"--webcam-input"); + if (!webcamInput.empty()) { + options.webcamInputPath = webcamInput; + } + options.webcamLeft = parseLongArg(args, L"--webcam-left", options.webcamLeft); + options.webcamTop = parseLongArg(args, L"--webcam-top", options.webcamTop); + options.webcamSize = parseLongArg(args, L"--webcam-size", options.webcamSize); + options.webcamRadius = parseFloatArg(args, L"--webcam-radius", options.webcamRadius); + options.webcamShadow = parseFloatArg(args, L"--webcam-shadow", options.webcamShadow); + options.webcamMirror = hasArg(args, L"--webcam-mirror"); + options.webcamTimeOffsetMs = parseDoubleArg( + args, + L"--webcam-time-offset-ms", + options.webcamTimeOffsetMs); + const auto cursorTelemetry = getArgValue(args, L"--cursor-telemetry"); + if (!cursorTelemetry.empty()) { + options.cursorTelemetryPath = cursorTelemetry; + } + const auto cursorAtlas = getArgValue(args, L"--cursor-atlas"); + if (!cursorAtlas.empty()) { + options.cursorAtlasPath = cursorAtlas; + } + const auto cursorAtlasMetadata = getArgValue(args, L"--cursor-atlas-metadata"); + if (!cursorAtlasMetadata.empty()) { + options.cursorAtlasMetadataPath = cursorAtlasMetadata; + } + options.cursorSize = parseFloatArg(args, L"--cursor-size", options.cursorSize); + const auto zoomTelemetry = getArgValue(args, L"--zoom-telemetry"); + if (!zoomTelemetry.empty()) { + options.zoomTelemetryPath = zoomTelemetry; + } + const auto timelineMap = getArgValue(args, L"--timeline-map"); + if (!timelineMap.empty()) { + options.timelineMapPath = timelineMap; + } + options.preferHighPerformanceAdapter = hasArg(args, L"--prefer-high-performance-adapter"); + options.adapterIndex = static_cast(parseLongArg(args, L"--adapter-index", options.adapterIndex)); + options.fastEncoderTuning = hasArg(args, L"--fast-encoder-tuning"); + options.nvencSdk = hasArg(args, L"--nvenc-sdk"); + options.surfacePoolSize = parseUIntArg(args, L"--surface-pool-size", options.surfacePoolSize); + options.width = std::max(2, options.width & ~1U); + options.height = std::max(2, options.height & ~1U); + options.fps = std::max(1, options.fps); + options.seconds = std::max(0.001, options.seconds); + options.surfacePoolSize = std::min(32, std::max(4, options.surfacePoolSize)); + options.radius = std::max(0.0f, options.radius); + options.shadow = std::max(0.0f, options.shadow); + options.webcamSize = std::max(0, options.webcamSize & ~1L); + options.webcamRadius = std::max(0.0f, options.webcamRadius); + options.webcamShadow = std::max(0.0f, options.webcamShadow); + options.cursorSize = std::max(0.0f, options.cursorSize); + if (options.padding > 1.0f) { + options.padding /= 100.0f; + } + options.padding = std::min(0.45f, std::max(0.0f, options.padding)); + return options; +} + +std::string hrToHex(HRESULT hr) { + std::ostringstream stream; + stream << "0x" << std::hex << static_cast(hr); + return stream.str(); +} + +bool succeeded(HRESULT hr, const char* label) { + if (SUCCEEDED(hr)) { + return true; + } + std::cerr << "ERROR: " << label << " failed: " << hrToHex(hr) << std::endl; + return false; +} + +DWORD firstVideoStreamIndex() { + return static_cast(MF_SOURCE_READER_FIRST_VIDEO_STREAM); +} + +class GpuProbe { +public: + bool initialize(const Options& options) { + const Timer initializeTimer; + options_ = options; + loadCursorTelemetry(); + loadZoomTelemetry(); + timelineSegments_ = loadTimelineMap(options_.timelineMapPath); + if (!options_.timelineMapPath.empty() && timelineSegments_.empty()) { + std::cerr << "[gpu-export] Timeline map is invalid or empty" << std::endl; + return false; + } + + { + const Timer timer; + const HRESULT coInit = CoInitializeEx(nullptr, COINIT_MULTITHREADED); + coInitialized_ = SUCCEEDED(coInit); + initCoInitializeMs_ = timer.elapsedMs(); + if (!coInitialized_ && coInit != RPC_E_CHANGED_MODE) { + return succeeded(coInit, "CoInitializeEx"); + } + } + + { + const Timer timer; + const HRESULT hr = MFStartup(MF_VERSION); + initMfStartupMs_ = timer.elapsedMs(); + if (!succeeded(hr, "MFStartup")) { + return false; + } + mfStarted_ = true; + } + + { + const Timer timer; + if (!createD3DDevice()) { + return false; + } + initD3DDeviceMs_ = timer.elapsedMs(); + } + + initSourceReaderMs_ = 0.0; + if (!options_.inputPath.empty()) { + const Timer timer; + if (!createSourceReader()) { + return false; + } + initSourceReaderMs_ = timer.elapsedMs(); + } + + initWebcamReaderMs_ = 0.0; + if (hasWebcamOverlay()) { + const Timer timer; + if (!createWebcamSourceReader()) { + return false; + } + initWebcamReaderMs_ = timer.elapsedMs(); + } + + { + const Timer timer; + if (!createVideoProcessor()) { + return false; + } + initVideoProcessorMs_ = timer.elapsedMs(); + } + + { + const Timer timer; + if (!createTextures()) { + return false; + } + initTexturesMs_ = timer.elapsedMs(); + } + + initShaderPipelineMs_ = 0.0; + if (options_.shaderComposite) { + const Timer timer; + if (!createShaderPipeline()) { + return false; + } + initShaderPipelineMs_ = timer.elapsedMs(); + } + + { + const Timer timer; + if (options_.nvencSdk ? !createNvencSdkEncoder() : !createSinkWriter()) { + return false; + } + initSinkWriterMs_ = timer.elapsedMs(); + } + + initializeMs_ = initializeTimer.elapsedMs(); + return true; + } + + bool run() { + if (!options_.inputPath.empty()) { + return runSourceVideo(); + } + + const UINT frameCount = + static_cast(std::ceil(static_cast(options_.fps) * options_.seconds)); + const Timer totalTimer; + double clearMs = 0; + double processMs = 0; + double writeMs = 0; + + for (UINT frameIndex = 0; frameIndex < frameCount; ++frameIndex) { + const Timer clearTimer; + renderSyntheticFrame(frameIndex); + clearMs += clearTimer.elapsedMs(); + + const Timer processTimer; + if (!convertBgraToNv12(frameIndex)) { + return false; + } + processMs += processTimer.elapsedMs(); + + const Timer writeTimer; + if (!writeFrame(frameIndex)) { + return false; + } + writeMs += writeTimer.elapsedMs(); + emitProgress(frameIndex + 1, frameCount); + } + emitProgress(frameCount, frameCount, true); + + const Timer finalizeTimer; + const bool finalized = options_.nvencSdk ? finalizeNvencSdk() : SUCCEEDED(sinkWriter_->Finalize()); + const double finalizeMs = finalizeTimer.elapsedMs(); + if (!finalized) { + if (!options_.nvencSdk) { + std::cerr << "ERROR: IMFSinkWriter::Finalize failed" << std::endl; + } + return false; + } + + const double totalMs = totalTimer.elapsedMs(); + const double realtime = (options_.seconds * 1000.0) / totalMs; + std::cout + << "{" + << "\"success\":true," + << "\"width\":" << options_.width << "," + << "\"height\":" << options_.height << "," + << "\"fps\":" << options_.fps << "," + << "\"surfacePoolSize\":" << options_.surfacePoolSize << "," + << "\"adapterIndex\":" << selectedAdapterIndex_ << "," + << "\"adapterVendorId\":" << selectedAdapterVendorId_ << "," + << "\"adapterDeviceId\":" << selectedAdapterDeviceId_ << "," + << "\"adapterDedicatedVideoMemoryMB\":" << selectedAdapterDedicatedVideoMemoryMB_ << "," + << "\"seconds\":" << options_.seconds << "," + << "\"frames\":" << frameCount << "," + << "\"initializeMs\":" << initializeMs_ << "," + << "\"initCoInitializeMs\":" << initCoInitializeMs_ << "," + << "\"initMfStartupMs\":" << initMfStartupMs_ << "," + << "\"initD3DDeviceMs\":" << initD3DDeviceMs_ << "," + << "\"initSourceReaderMs\":" << initSourceReaderMs_ << "," + << "\"initWebcamReaderMs\":" << initWebcamReaderMs_ << "," + << "\"initVideoProcessorMs\":" << initVideoProcessorMs_ << "," + << "\"initTexturesMs\":" << initTexturesMs_ << "," + << "\"initShaderPipelineMs\":" << initShaderPipelineMs_ << "," + << "\"initSinkWriterMs\":" << initSinkWriterMs_ << "," + << "\"encoderBackend\":\"" << (options_.nvencSdk ? "nvenc-sdk-d3d11" : "media-foundation") << "\"," + << "\"encoderTuningApplied\":" << (encoderTuningApplied_ ? "true" : "false") << "," + << "\"nvencOutputBytes\":" << nvencOutputBytes_ << "," + << "\"totalMs\":" << totalMs << "," + << "\"clearMs\":" << clearMs << "," + << "\"videoProcessMs\":" << processMs << "," + << "\"writeSampleMs\":" << writeMs << "," + << "\"finalizeMs\":" << finalizeMs << "," + << "\"realtimeMultiplier\":" << realtime + << "}" << std::endl; + return true; + } + + void emitProgress(UINT currentFrame, UINT totalFrames, bool force = false) { + if (totalFrames == 0) { + return; + } + + const UINT cadence = std::max(1, static_cast(options_.fps)); + if (!force && currentFrame < totalFrames && (currentFrame % cadence) != 0) { + return; + } + + const double percentage = + std::min(100.0, (static_cast(currentFrame) / totalFrames) * 100.0); + std::cerr + << "PROGRESS {" + << "\"currentFrame\":" << currentFrame << "," + << "\"totalFrames\":" << totalFrames << "," + << "\"percentage\":" << percentage + << "}" << std::endl; + } + + ~GpuProbe() { + if (nvencOutputFile_) { + std::fclose(nvencOutputFile_); + nvencOutputFile_ = nullptr; + } +#ifdef RECORDLY_GPU_EXPORT_ENABLE_NVENC_SDK + nvencEncoder_.reset(); +#endif + sinkWriter_.Reset(); + sourceReader_.Reset(); + webcamReader_.Reset(); + pendingWebcamSample_.Reset(); + nv12Textures_.clear(); + nv12OutputViews_.clear(); + bgraNv12OutputViews_.clear(); + compositorConstants_.Reset(); + samplerState_.Reset(); + pixelShader_.Reset(); + vertexShader_.Reset(); + backgroundShaderResourceView_.Reset(); + webcamShaderResourceView_.Reset(); + webcamOutputView_.Reset(); + webcamTexture_.Reset(); + contentShaderResourceView_.Reset(); + contentOutputView_.Reset(); + contentTexture_.Reset(); + bgraInputView_.Reset(); + bgraRenderTargetView_.Reset(); + bgraTexture_.Reset(); + bgraVideoProcessor_.Reset(); + bgraVideoProcessorEnumerator_.Reset(); + webcamVideoProcessor_.Reset(); + webcamVideoProcessorEnumerator_.Reset(); + videoProcessor_.Reset(); + videoProcessorEnumerator_.Reset(); + videoContext_.Reset(); + videoDevice_.Reset(); + deviceContext_.Reset(); + device_.Reset(); + deviceManager_.Reset(); + if (mfStarted_) { + MFShutdown(); + } + if (coInitialized_) { + CoUninitialize(); + } + } + +private: + void captureSelectedAdapterInfo(IDXGIAdapter1* adapter) { + if (!adapter) { + return; + } + + DXGI_ADAPTER_DESC1 desc = {}; + if (FAILED(adapter->GetDesc1(&desc))) { + return; + } + selectedAdapterVendorId_ = desc.VendorId; + selectedAdapterDeviceId_ = desc.DeviceId; + selectedAdapterDedicatedVideoMemoryMB_ = + static_cast(desc.DedicatedVideoMemory / (1024 * 1024)); + } + + ComPtr selectHighPerformanceAdapter() { + ComPtr factory; + HRESULT hr = CreateDXGIFactory1(IID_PPV_ARGS(&factory)); + if (FAILED(hr)) { + return nullptr; + } + + ComPtr bestAdapter; + SIZE_T bestDedicatedMemory = 0; + for (UINT index = 0;; ++index) { + ComPtr adapter; + hr = factory->EnumAdapters1(index, &adapter); + if (hr == DXGI_ERROR_NOT_FOUND) { + break; + } + if (FAILED(hr)) { + continue; + } + + DXGI_ADAPTER_DESC1 desc = {}; + if (FAILED(adapter->GetDesc1(&desc))) { + continue; + } + if ((desc.Flags & DXGI_ADAPTER_FLAG_SOFTWARE) != 0) { + continue; + } + if (!bestAdapter || desc.DedicatedVideoMemory > bestDedicatedMemory) { + bestDedicatedMemory = desc.DedicatedVideoMemory; + bestAdapter = adapter; + } + } + + return bestAdapter; + } + + ComPtr selectAdapterByIndex(UINT requestedIndex) { + ComPtr factory; + HRESULT hr = CreateDXGIFactory1(IID_PPV_ARGS(&factory)); + if (FAILED(hr)) { + return nullptr; + } + + ComPtr adapter; + hr = factory->EnumAdapters1(requestedIndex, &adapter); + if (FAILED(hr)) { + return nullptr; + } + return adapter; + } + + bool createD3DDevice() { + const D3D_FEATURE_LEVEL levels[] = { + D3D_FEATURE_LEVEL_11_1, + D3D_FEATURE_LEVEL_11_0, + }; + D3D_FEATURE_LEVEL selectedLevel = D3D_FEATURE_LEVEL_11_0; + UINT flags = D3D11_CREATE_DEVICE_BGRA_SUPPORT | D3D11_CREATE_DEVICE_VIDEO_SUPPORT; + ComPtr preferredAdapter; + if (options_.adapterIndex >= 0) { + preferredAdapter = selectAdapterByIndex(static_cast(options_.adapterIndex)); + if (!preferredAdapter) { + std::cerr << "ERROR: adapter index " << options_.adapterIndex << " was not found" << std::endl; + return false; + } + selectedAdapterIndex_ = options_.adapterIndex; + } else if (options_.preferHighPerformanceAdapter) { + preferredAdapter = selectHighPerformanceAdapter(); + } + + HRESULT hr = D3D11CreateDevice( + preferredAdapter.Get(), + preferredAdapter ? D3D_DRIVER_TYPE_UNKNOWN : D3D_DRIVER_TYPE_HARDWARE, + nullptr, + flags, + levels, + ARRAYSIZE(levels), + D3D11_SDK_VERSION, + &device_, + &selectedLevel, + &deviceContext_); + if (!succeeded(hr, "D3D11CreateDevice")) { + return false; + } + + ComPtr dxgiDevice; + if (SUCCEEDED(device_.As(&dxgiDevice))) { + ComPtr adapter; + if (SUCCEEDED(dxgiDevice->GetAdapter(&adapter))) { + ComPtr adapter1; + if (SUCCEEDED(adapter.As(&adapter1))) { + captureSelectedAdapterInfo(adapter1.Get()); + } + } + } + + hr = device_.As(&videoDevice_); + if (!succeeded(hr, "Query ID3D11VideoDevice")) { + return false; + } + hr = deviceContext_.As(&videoContext_); + if (!succeeded(hr, "Query ID3D11VideoContext")) { + return false; + } + + UINT resetToken = 0; + hr = MFCreateDXGIDeviceManager(&resetToken, &deviceManager_); + if (!succeeded(hr, "MFCreateDXGIDeviceManager")) { + return false; + } + hr = deviceManager_->ResetDevice(device_.Get(), resetToken); + if (!succeeded(hr, "IMFDXGIDeviceManager::ResetDevice")) { + return false; + } + + return true; + } + + bool createVideoProcessor() { + D3D11_VIDEO_PROCESSOR_CONTENT_DESC desc = {}; + desc.InputFrameFormat = D3D11_VIDEO_FRAME_FORMAT_PROGRESSIVE; + desc.InputFrameRate.Numerator = options_.fps; + desc.InputFrameRate.Denominator = 1; + desc.InputWidth = sourceWidth_; + desc.InputHeight = sourceHeight_; + desc.OutputFrameRate.Numerator = options_.fps; + desc.OutputFrameRate.Denominator = 1; + desc.OutputWidth = options_.width; + desc.OutputHeight = options_.height; + desc.Usage = D3D11_VIDEO_USAGE_PLAYBACK_NORMAL; + + HRESULT hr = videoDevice_->CreateVideoProcessorEnumerator(&desc, &videoProcessorEnumerator_); + if (!succeeded(hr, "CreateVideoProcessorEnumerator")) { + return false; + } + hr = videoDevice_->CreateVideoProcessor(videoProcessorEnumerator_.Get(), 0, &videoProcessor_); + if (!succeeded(hr, "CreateVideoProcessor")) { + return false; + } + + RECT rect = getSourceCropRect(); + RECT outputRect = { + 0, + 0, + static_cast(options_.width), + static_cast(options_.height), + }; + RECT contentRect = getContentRect(); + videoContext_->VideoProcessorSetStreamSourceRect(videoProcessor_.Get(), 0, TRUE, &rect); + videoContext_->VideoProcessorSetStreamDestRect(videoProcessor_.Get(), 0, TRUE, &contentRect); + videoContext_->VideoProcessorSetOutputTargetRect(videoProcessor_.Get(), TRUE, &outputRect); + videoContext_->VideoProcessorSetStreamAutoProcessingMode(videoProcessor_.Get(), 0, FALSE); + D3D11_VIDEO_COLOR background = {}; + background.RGBA.A = 1.0f; + background.RGBA.R = 0.04f; + background.RGBA.G = 0.04f; + background.RGBA.B = 0.05f; + videoContext_->VideoProcessorSetOutputBackgroundColor( + videoProcessor_.Get(), + FALSE, + &background); + + D3D11_VIDEO_PROCESSOR_CONTENT_DESC bgraDesc = {}; + bgraDesc.InputFrameFormat = D3D11_VIDEO_FRAME_FORMAT_PROGRESSIVE; + bgraDesc.InputFrameRate.Numerator = options_.fps; + bgraDesc.InputFrameRate.Denominator = 1; + bgraDesc.InputWidth = options_.width; + bgraDesc.InputHeight = options_.height; + bgraDesc.OutputFrameRate.Numerator = options_.fps; + bgraDesc.OutputFrameRate.Denominator = 1; + bgraDesc.OutputWidth = options_.width; + bgraDesc.OutputHeight = options_.height; + bgraDesc.Usage = D3D11_VIDEO_USAGE_PLAYBACK_NORMAL; + + hr = videoDevice_->CreateVideoProcessorEnumerator( + &bgraDesc, + &bgraVideoProcessorEnumerator_); + if (!succeeded(hr, "Create BGRA video processor enumerator")) { + return false; + } + hr = videoDevice_->CreateVideoProcessor( + bgraVideoProcessorEnumerator_.Get(), + 0, + &bgraVideoProcessor_); + if (!succeeded(hr, "Create BGRA video processor")) { + return false; + } + + RECT bgraRect = { + 0, + 0, + static_cast(options_.width), + static_cast(options_.height), + }; + videoContext_->VideoProcessorSetStreamSourceRect( + bgraVideoProcessor_.Get(), + 0, + TRUE, + &bgraRect); + videoContext_->VideoProcessorSetStreamDestRect( + bgraVideoProcessor_.Get(), + 0, + TRUE, + &bgraRect); + videoContext_->VideoProcessorSetOutputTargetRect( + bgraVideoProcessor_.Get(), + TRUE, + &bgraRect); + videoContext_->VideoProcessorSetStreamAutoProcessingMode( + bgraVideoProcessor_.Get(), + 0, + FALSE); + + if (hasWebcamOverlay()) { + D3D11_VIDEO_PROCESSOR_CONTENT_DESC webcamDesc = {}; + webcamDesc.InputFrameFormat = D3D11_VIDEO_FRAME_FORMAT_PROGRESSIVE; + webcamDesc.InputFrameRate.Numerator = options_.fps; + webcamDesc.InputFrameRate.Denominator = 1; + webcamDesc.InputWidth = webcamWidth_; + webcamDesc.InputHeight = webcamHeight_; + webcamDesc.OutputFrameRate.Numerator = options_.fps; + webcamDesc.OutputFrameRate.Denominator = 1; + webcamDesc.OutputWidth = options_.width; + webcamDesc.OutputHeight = options_.height; + webcamDesc.Usage = D3D11_VIDEO_USAGE_PLAYBACK_NORMAL; + + hr = videoDevice_->CreateVideoProcessorEnumerator( + &webcamDesc, + &webcamVideoProcessorEnumerator_); + if (!succeeded(hr, "Create webcam video processor enumerator")) { + return false; + } + hr = videoDevice_->CreateVideoProcessor( + webcamVideoProcessorEnumerator_.Get(), + 0, + &webcamVideoProcessor_); + if (!succeeded(hr, "Create webcam video processor")) { + return false; + } + + RECT webcamSourceRect = { + 0, + 0, + static_cast(webcamWidth_), + static_cast(webcamHeight_), + }; + RECT webcamOutputRect = { + 0, + 0, + static_cast(options_.width), + static_cast(options_.height), + }; + RECT webcamDestRect = getWebcamRect(); + videoContext_->VideoProcessorSetStreamSourceRect( + webcamVideoProcessor_.Get(), + 0, + TRUE, + &webcamSourceRect); + videoContext_->VideoProcessorSetStreamDestRect( + webcamVideoProcessor_.Get(), + 0, + TRUE, + &webcamDestRect); + videoContext_->VideoProcessorSetOutputTargetRect( + webcamVideoProcessor_.Get(), + TRUE, + &webcamOutputRect); + videoContext_->VideoProcessorSetStreamAutoProcessingMode( + webcamVideoProcessor_.Get(), + 0, + FALSE); + } + return true; + } + + bool createTextures() { + HRESULT hr = S_OK; + if (options_.inputPath.empty() || options_.shaderComposite) { + D3D11_TEXTURE2D_DESC bgraDesc = {}; + bgraDesc.Width = options_.width; + bgraDesc.Height = options_.height; + bgraDesc.MipLevels = 1; + bgraDesc.ArraySize = 1; + bgraDesc.Format = DXGI_FORMAT_B8G8R8A8_UNORM; + bgraDesc.SampleDesc.Count = 1; + bgraDesc.Usage = D3D11_USAGE_DEFAULT; + bgraDesc.BindFlags = D3D11_BIND_RENDER_TARGET | D3D11_BIND_SHADER_RESOURCE; + + hr = device_->CreateTexture2D(&bgraDesc, nullptr, &bgraTexture_); + if (!succeeded(hr, "Create BGRA texture")) { + return false; + } + hr = device_->CreateRenderTargetView(bgraTexture_.Get(), nullptr, &bgraRenderTargetView_); + if (!succeeded(hr, "Create BGRA render target view")) { + return false; + } + + D3D11_VIDEO_PROCESSOR_INPUT_VIEW_DESC inputViewDesc = {}; + inputViewDesc.FourCC = 0; + inputViewDesc.ViewDimension = D3D11_VPIV_DIMENSION_TEXTURE2D; + inputViewDesc.Texture2D.MipSlice = 0; + inputViewDesc.Texture2D.ArraySlice = 0; + hr = videoDevice_->CreateVideoProcessorInputView( + bgraTexture_.Get(), + bgraVideoProcessorEnumerator_.Get(), + &inputViewDesc, + &bgraInputView_); + if (!succeeded(hr, "Create video processor input view")) { + return false; + } + } + + if (options_.shaderComposite) { + D3D11_TEXTURE2D_DESC contentDesc = {}; + contentDesc.Width = options_.width; + contentDesc.Height = options_.height; + contentDesc.MipLevels = 1; + contentDesc.ArraySize = 1; + contentDesc.Format = DXGI_FORMAT_B8G8R8A8_UNORM; + contentDesc.SampleDesc.Count = 1; + contentDesc.Usage = D3D11_USAGE_DEFAULT; + contentDesc.BindFlags = D3D11_BIND_RENDER_TARGET | D3D11_BIND_SHADER_RESOURCE; + + hr = device_->CreateTexture2D(&contentDesc, nullptr, &contentTexture_); + if (!succeeded(hr, "Create compositor content texture")) { + return false; + } + + D3D11_VIDEO_PROCESSOR_OUTPUT_VIEW_DESC contentOutputDesc = {}; + contentOutputDesc.ViewDimension = D3D11_VPOV_DIMENSION_TEXTURE2D; + contentOutputDesc.Texture2D.MipSlice = 0; + hr = videoDevice_->CreateVideoProcessorOutputView( + contentTexture_.Get(), + videoProcessorEnumerator_.Get(), + &contentOutputDesc, + &contentOutputView_); + if (!succeeded(hr, "Create compositor content output view")) { + return false; + } + + D3D11_SHADER_RESOURCE_VIEW_DESC srvDesc = {}; + srvDesc.Format = DXGI_FORMAT_B8G8R8A8_UNORM; + srvDesc.ViewDimension = D3D11_SRV_DIMENSION_TEXTURE2D; + srvDesc.Texture2D.MipLevels = 1; + hr = device_->CreateShaderResourceView( + contentTexture_.Get(), + &srvDesc, + &contentShaderResourceView_); + if (!succeeded(hr, "Create compositor content shader resource view")) { + return false; + } + + if (hasWebcamOverlay()) { + D3D11_TEXTURE2D_DESC webcamDesc = contentDesc; + hr = device_->CreateTexture2D(&webcamDesc, nullptr, &webcamTexture_); + if (!succeeded(hr, "Create compositor webcam texture")) { + return false; + } + + D3D11_VIDEO_PROCESSOR_OUTPUT_VIEW_DESC webcamOutputDesc = {}; + webcamOutputDesc.ViewDimension = D3D11_VPOV_DIMENSION_TEXTURE2D; + webcamOutputDesc.Texture2D.MipSlice = 0; + hr = videoDevice_->CreateVideoProcessorOutputView( + webcamTexture_.Get(), + webcamVideoProcessorEnumerator_.Get(), + &webcamOutputDesc, + &webcamOutputView_); + if (!succeeded(hr, "Create compositor webcam output view")) { + return false; + } + + hr = device_->CreateShaderResourceView( + webcamTexture_.Get(), + &srvDesc, + &webcamShaderResourceView_); + if (!succeeded(hr, "Create compositor webcam shader resource view")) { + return false; + } + } + } + + D3D11_TEXTURE2D_DESC nv12Desc = {}; + nv12Desc.Width = options_.width; + nv12Desc.Height = options_.height; + nv12Desc.MipLevels = 1; + nv12Desc.ArraySize = 1; + nv12Desc.Format = DXGI_FORMAT_NV12; + nv12Desc.SampleDesc.Count = 1; + nv12Desc.Usage = D3D11_USAGE_DEFAULT; + nv12Desc.BindFlags = D3D11_BIND_RENDER_TARGET; + nv12Desc.MiscFlags = D3D11_RESOURCE_MISC_SHARED; + + D3D11_VIDEO_PROCESSOR_OUTPUT_VIEW_DESC outputViewDesc = {}; + outputViewDesc.ViewDimension = D3D11_VPOV_DIMENSION_TEXTURE2D; + outputViewDesc.Texture2D.MipSlice = 0; + + const size_t surfaceCount = static_cast(options_.surfacePoolSize); + nv12Textures_.reserve(surfaceCount); + nv12OutputViews_.reserve(surfaceCount); + bgraNv12OutputViews_.reserve(surfaceCount); + + for (size_t index = 0; index < surfaceCount; ++index) { + ComPtr texture; + hr = device_->CreateTexture2D(&nv12Desc, nullptr, &texture); + if (!succeeded(hr, "Create NV12 texture")) { + return false; + } + + ComPtr outputView; + hr = videoDevice_->CreateVideoProcessorOutputView( + texture.Get(), + videoProcessorEnumerator_.Get(), + &outputViewDesc, + &outputView); + if (!succeeded(hr, "Create video processor output view")) { + return false; + } + + ComPtr bgraOutputView; + hr = videoDevice_->CreateVideoProcessorOutputView( + texture.Get(), + bgraVideoProcessorEnumerator_.Get(), + &outputViewDesc, + &bgraOutputView); + if (!succeeded(hr, "Create BGRA video processor output view")) { + return false; + } + + nv12Textures_.push_back(texture); + nv12OutputViews_.push_back(outputView); + bgraNv12OutputViews_.push_back(bgraOutputView); + } + + return true; + } + + bool compileShader( + const char* source, + const char* entryPoint, + const char* target, + ID3DBlob** bytecode) { + ComPtr errors; + const HRESULT hr = D3DCompile( + source, + std::strlen(source), + nullptr, + nullptr, + nullptr, + entryPoint, + target, + D3DCOMPILE_ENABLE_STRICTNESS, + 0, + bytecode, + &errors); + if (FAILED(hr)) { + if (errors) { + std::cerr + << "ERROR: D3DCompile " + << target + << " failed: " + << static_cast(errors->GetBufferPointer()) + << std::endl; + } + return succeeded(hr, "D3DCompile"); + } + return true; + } + + bool createBgraShaderResource( + UINT width, + UINT height, + const std::uint8_t* pixels, + UINT stride, + ComPtr& shaderResourceView) { + D3D11_TEXTURE2D_DESC desc = {}; + desc.Width = width; + desc.Height = height; + desc.MipLevels = 1; + desc.ArraySize = 1; + desc.Format = DXGI_FORMAT_B8G8R8A8_UNORM; + desc.SampleDesc.Count = 1; + desc.Usage = D3D11_USAGE_DEFAULT; + desc.BindFlags = D3D11_BIND_SHADER_RESOURCE; + + D3D11_SUBRESOURCE_DATA data = {}; + data.pSysMem = pixels; + data.SysMemPitch = stride; + + ComPtr texture; + HRESULT hr = device_->CreateTexture2D(&desc, &data, &texture); + if (!succeeded(hr, "Create BGRA shader texture")) { + return false; + } + + D3D11_SHADER_RESOURCE_VIEW_DESC srvDesc = {}; + srvDesc.Format = desc.Format; + srvDesc.ViewDimension = D3D11_SRV_DIMENSION_TEXTURE2D; + srvDesc.Texture2D.MipLevels = 1; + hr = device_->CreateShaderResourceView( + texture.Get(), + &srvDesc, + &shaderResourceView); + return succeeded(hr, "Create BGRA shader resource view"); + } + + bool loadWicBgraShaderResource( + const std::wstring& imagePath, + ComPtr& shaderResourceView, + UINT& width, + UINT& height, + const char* label) { + ComPtr factory; + HRESULT hr = CoCreateInstance( + CLSID_WICImagingFactory, + nullptr, + CLSCTX_INPROC_SERVER, + IID_PPV_ARGS(&factory)); + if (!succeeded(hr, "Create WIC imaging factory")) { + return false; + } + + ComPtr decoder; + hr = factory->CreateDecoderFromFilename( + imagePath.c_str(), + nullptr, + GENERIC_READ, + WICDecodeMetadataCacheOnLoad, + &decoder); + if (!succeeded(hr, label)) { + return false; + } + + ComPtr frame; + hr = decoder->GetFrame(0, &frame); + if (!succeeded(hr, "Get WIC frame")) { + return false; + } + + ComPtr converter; + hr = factory->CreateFormatConverter(&converter); + if (!succeeded(hr, "Create WIC format converter")) { + return false; + } + hr = converter->Initialize( + frame.Get(), + GUID_WICPixelFormat32bppBGRA, + WICBitmapDitherTypeNone, + nullptr, + 0.0, + WICBitmapPaletteTypeCustom); + if (!succeeded(hr, "Initialize WIC format converter")) { + return false; + } + + hr = converter->GetSize(&width, &height); + if (!succeeded(hr, "Get WIC image size")) { + return false; + } + if (width == 0 || height == 0) { + std::cerr << "ERROR: WIC image has an invalid size." << std::endl; + return false; + } + + const UINT stride = width * 4; + std::vector pixels(static_cast(stride) * height); + hr = converter->CopyPixels( + nullptr, + stride, + static_cast(pixels.size()), + pixels.data()); + if (!succeeded(hr, "Copy WIC image pixels")) { + return false; + } + + return createBgraShaderResource( + width, + height, + pixels.data(), + stride, + shaderResourceView); + } + + bool createSolidBackgroundTexture() { + const std::uint8_t pixel[] = { + static_cast(std::round(options_.backgroundB * 255.0f)), + static_cast(std::round(options_.backgroundG * 255.0f)), + static_cast(std::round(options_.backgroundR * 255.0f)), + 255, + }; + backgroundImageWidth_ = 1; + backgroundImageHeight_ = 1; + hasBackgroundImage_ = false; + return createBgraShaderResource( + 1, + 1, + pixel, + 4, + backgroundShaderResourceView_); + } + + bool createBackgroundTexture() { + if (options_.backgroundImagePath.empty()) { + return createSolidBackgroundTexture(); + } + + if (!loadWicBgraShaderResource( + options_.backgroundImagePath, + backgroundShaderResourceView_, + backgroundImageWidth_, + backgroundImageHeight_, + "Create WIC background decoder")) { + return false; + } + + hasBackgroundImage_ = true; + return true; + } + + bool loadCursorAtlasMetadata() { + for (auto& entry : cursorAtlasEntries_) { + entry = CursorAtlasEntry{}; + } + if (options_.cursorAtlasMetadataPath.empty()) { + return false; + } + + FILE* file = nullptr; + if (_wfopen_s(&file, options_.cursorAtlasMetadataPath.c_str(), L"rb") != 0 || !file) { + std::cerr << "[gpu-export] Unable to open cursor atlas metadata file" << std::endl; + return false; + } + + char line[256]; + bool sawEntry = false; + while (std::fgets(line, sizeof(line), file)) { + int index = 0; + CursorAtlasEntry entry; + if (sscanf_s( + line, + "%d,%f,%f,%f,%f,%f,%f,%f", + &index, + &entry.x, + &entry.y, + &entry.width, + &entry.height, + &entry.anchorX, + &entry.anchorY, + &entry.aspectRatio) != 8) { + continue; + } + if ( + index < 0 || + index >= static_cast(cursorAtlasEntries_.size()) || + !std::isfinite(entry.x) || + !std::isfinite(entry.y) || + !std::isfinite(entry.width) || + !std::isfinite(entry.height) || + !std::isfinite(entry.anchorX) || + !std::isfinite(entry.anchorY) || + !std::isfinite(entry.aspectRatio) || + entry.width <= 0.0f || + entry.height <= 0.0f) { + continue; + } + entry.valid = true; + cursorAtlasEntries_[static_cast(index)] = entry; + sawEntry = true; + } + std::fclose(file); + return sawEntry; + } + + bool createCursorAtlasTexture() { + if (options_.cursorAtlasPath.empty() || options_.cursorAtlasMetadataPath.empty()) { + hasCursorAtlas_ = false; + return true; + } + + if (!loadCursorAtlasMetadata()) { + hasCursorAtlas_ = false; + return true; + } + + if (!loadWicBgraShaderResource( + options_.cursorAtlasPath, + cursorAtlasShaderResourceView_, + cursorAtlasWidth_, + cursorAtlasHeight_, + "Create WIC cursor atlas decoder")) { + hasCursorAtlas_ = false; + return true; + } + + hasCursorAtlas_ = true; + return true; + } + + bool createShaderPipeline() { + static const char* vertexShaderSource = R"( +struct VSOut { + float4 position : SV_POSITION; + float2 uv : TEXCOORD0; +}; + +VSOut main(uint vertexId : SV_VertexID) { + float2 positions[3] = { + float2(-1.0, -1.0), + float2(-1.0, 3.0), + float2( 3.0, -1.0) + }; + + VSOut output; + float2 position = positions[vertexId]; + output.position = float4(position, 0.0, 1.0); + output.uv = float2((position.x + 1.0) * 0.5, 1.0 - ((position.y + 1.0) * 0.5)); + return output; +} +)"; + + static const char* pixelShaderSource = R"( +cbuffer CompositorConstants : register(b0) { + float outputWidth; + float outputHeight; + float radius; + float shadowSize; + float contentLeft; + float contentTop; + float contentRight; + float contentBottom; + float backgroundR; + float backgroundG; + float backgroundB; + float backgroundA; + float shadowR; + float shadowG; + float shadowB; + float shadowA; + float backgroundImageEnabled; + float backgroundImageWidth; + float backgroundImageHeight; + float webcamEnabled; + float webcamLeft; + float webcamTop; + float webcamRight; + float webcamBottom; + float webcamRadius; + float webcamShadowSize; + float webcamShadowA; + float webcamMirror; + float cursorEnabled; + float cursorX; + float cursorY; + float cursorSize; + float cursorAtlasEnabled; + float cursorAtlasLeft; + float cursorAtlasTop; + float cursorAtlasRight; + float cursorAtlasBottom; + float cursorAtlasAnchorX; + float cursorAtlasAnchorY; + float cursorAtlasAspect; + float cursorBounceScale; + float backgroundBlurPx; + float backgroundBlurPadding0; + float backgroundBlurPadding1; + float zoomEnabled; + float zoomScale; + float zoomX; + float zoomY; +}; + +Texture2D contentTexture : register(t0); +Texture2D backgroundTexture : register(t1); +Texture2D webcamTexture : register(t2); +Texture2D cursorAtlasTexture : register(t3); +SamplerState linearSampler : register(s0); + +struct PSIn { + float4 position : SV_POSITION; + float2 uv : TEXCOORD0; +}; + +float roundedBoxDistance(float2 p, float2 halfSize, float cornerRadius) { + float2 q = abs(p) - halfSize + cornerRadius; + return length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - cornerRadius; +} + +float cross2(float2 a, float2 b) { + return a.x * b.y - a.y * b.x; +} + +float insideTriangle(float2 p, float2 a, float2 b, float2 c) { + float ab = cross2(b - a, p - a); + float bc = cross2(c - b, p - b); + float ca = cross2(a - c, p - c); + return (ab >= 0.0 && bc >= 0.0 && ca >= 0.0) || + (ab <= 0.0 && bc <= 0.0 && ca <= 0.0) ? 1.0 : 0.0; +} + +float cursorArrowMask(float2 p, float scale) { + float2 a = float2(0.0, 0.0) * scale; + float2 b = float2(0.0, 58.0) * scale; + float2 c = float2(15.0, 44.0) * scale; + float2 d = float2(25.0, 66.0) * scale; + float2 e = float2(37.0, 61.0) * scale; + float2 f = float2(27.0, 40.0) * scale; + float2 g = float2(45.0, 40.0) * scale; + + return max( + insideTriangle(p, a, b, c), + max( + insideTriangle(p, a, c, g), + max( + insideTriangle(p, c, d, e), + insideTriangle(p, c, e, f) + ) + ) + ); +} + +float sampleCursorAtlasAlpha(float2 cursorLocal, float cursorWidth, float cursorHeight) { + float2 cursorUv = float2( + cursorLocal.x / cursorWidth + cursorAtlasAnchorX, + cursorLocal.y / cursorHeight + cursorAtlasAnchorY + ); + if ( + cursorUv.x < 0.0 || cursorUv.x > 1.0 || + cursorUv.y < 0.0 || cursorUv.y > 1.0 + ) { + return 0.0; + } + + float2 atlasMin = float2(cursorAtlasLeft, cursorAtlasTop); + float2 atlasMax = float2(cursorAtlasRight, cursorAtlasBottom); + return cursorAtlasTexture.Sample(linearSampler, lerp(atlasMin, atlasMax, cursorUv)).a; +} + +float sampleCursorAtlasShadow(float2 cursorLocal, float cursorWidth, float cursorHeight) { + float2 shadowLocal = cursorLocal - float2(0.0, 2.0); + float alpha = sampleCursorAtlasAlpha(shadowLocal, cursorWidth, cursorHeight) * 0.20; + alpha += sampleCursorAtlasAlpha(shadowLocal - float2(1.5, 0.0), cursorWidth, cursorHeight) * 0.06; + alpha += sampleCursorAtlasAlpha(shadowLocal + float2(1.5, 0.0), cursorWidth, cursorHeight) * 0.06; + alpha += sampleCursorAtlasAlpha(shadowLocal - float2(0.0, 1.5), cursorWidth, cursorHeight) * 0.04; + alpha += sampleCursorAtlasAlpha(shadowLocal + float2(0.0, 1.5), cursorWidth, cursorHeight) * 0.04; + return saturate(alpha); +} + +float2 getBackgroundCoverUv(float2 uv) { + float2 backgroundUv = uv; + float outputAspect = outputWidth / outputHeight; + float backgroundAspect = backgroundImageWidth / backgroundImageHeight; + if (backgroundAspect > outputAspect) { + backgroundUv.x = 0.5 + ((backgroundUv.x - 0.5) * (outputAspect / backgroundAspect)); + } else { + backgroundUv.y = 0.5 + ((backgroundUv.y - 0.5) * (backgroundAspect / outputAspect)); + } + return saturate(backgroundUv); +} + +float4 sampleBackground(float2 uv) { + if (backgroundImageEnabled <= 0.5) { + return float4(backgroundR, backgroundG, backgroundB, backgroundA); + } + + float2 backgroundUv = getBackgroundCoverUv(uv); + float safeBlur = min(max(backgroundBlurPx, 0.0), 96.0); + if (safeBlur <= 0.001) { + return backgroundTexture.Sample(linearSampler, backgroundUv); + } + + float2 texel = float2(1.0 / max(outputWidth, 1.0), 1.0 / max(outputHeight, 1.0)); + float2 r1 = texel * safeBlur * 0.35; + float2 r2 = texel * safeBlur * 0.70; + float4 color = backgroundTexture.Sample(linearSampler, backgroundUv) * 0.20; + color += backgroundTexture.Sample(linearSampler, saturate(backgroundUv + float2( r1.x, 0.0))) * 0.10; + color += backgroundTexture.Sample(linearSampler, saturate(backgroundUv + float2(-r1.x, 0.0))) * 0.10; + color += backgroundTexture.Sample(linearSampler, saturate(backgroundUv + float2(0.0, r1.y))) * 0.10; + color += backgroundTexture.Sample(linearSampler, saturate(backgroundUv + float2(0.0, -r1.y))) * 0.10; + color += backgroundTexture.Sample(linearSampler, saturate(backgroundUv + float2( r2.x, r2.y))) * 0.05; + color += backgroundTexture.Sample(linearSampler, saturate(backgroundUv + float2(-r2.x, r2.y))) * 0.05; + color += backgroundTexture.Sample(linearSampler, saturate(backgroundUv + float2( r2.x, -r2.y))) * 0.05; + color += backgroundTexture.Sample(linearSampler, saturate(backgroundUv + float2(-r2.x, -r2.y))) * 0.05; + color += backgroundTexture.Sample(linearSampler, saturate(backgroundUv + float2( r2.x, 0.0))) * 0.05; + color += backgroundTexture.Sample(linearSampler, saturate(backgroundUv + float2(-r2.x, 0.0))) * 0.05; + color += backgroundTexture.Sample(linearSampler, saturate(backgroundUv + float2(0.0, r2.y))) * 0.05; + color += backgroundTexture.Sample(linearSampler, saturate(backgroundUv + float2(0.0, -r2.y))) * 0.05; + return color; +} + +float4 main(PSIn input) : SV_Target { + float2 outputSize = float2(outputWidth, outputHeight); + float2 pixel = input.uv * outputSize; + float safeZoomScale = max(zoomScale, 0.01); + float2 zoomOffset = float2(zoomX, zoomY); + float2 contentPixel = zoomEnabled > 0.5 + ? (pixel - zoomOffset) / safeZoomScale + : pixel; + float2 rectMin = float2(contentLeft, contentTop); + float2 rectMax = float2(contentRight, contentBottom); + float2 halfSize = max((rectMax - rectMin) * 0.5, float2(1.0, 1.0)); + float2 center = (rectMin + rectMax) * 0.5; + float distanceToRect = roundedBoxDistance(contentPixel - center, halfSize, radius); + + float contentAlpha = 1.0 - smoothstep(-0.75, 0.75, distanceToRect); + float outsideAlpha = smoothstep(-0.75, 0.75, distanceToRect); + float shadowAlpha = + (1.0 - smoothstep(0.0, max(shadowSize, 1.0), max(distanceToRect, 0.0))) * + outsideAlpha * + shadowA; + + float4 background = sampleBackground(input.uv); + float4 shadow = float4(shadowR, shadowG, shadowB, shadowAlpha); + float4 content = contentTexture.Sample(linearSampler, saturate(contentPixel / outputSize)); + + float3 withShadow = lerp(background.rgb, shadow.rgb, saturate(shadow.a)); + float3 rgb = lerp(withShadow, content.rgb, saturate(contentAlpha)); + if (webcamEnabled > 0.5) { + float2 webcamMin = float2(webcamLeft, webcamTop); + float2 webcamMax = float2(webcamRight, webcamBottom); + float webcamInfluence = max(webcamShadowSize, 1.0) + 2.0; + bool nearWebcam = + pixel.x >= webcamMin.x - webcamInfluence && + pixel.x <= webcamMax.x + webcamInfluence && + pixel.y >= webcamMin.y - webcamInfluence && + pixel.y <= webcamMax.y + webcamInfluence; + if (nearWebcam) { + float2 webcamHalfSize = max((webcamMax - webcamMin) * 0.5, float2(1.0, 1.0)); + float2 webcamCenter = (webcamMin + webcamMax) * 0.5; + float webcamDistance = + roundedBoxDistance(pixel - webcamCenter, webcamHalfSize, webcamRadius); + float webcamAlpha = 1.0 - smoothstep(-0.75, 0.75, webcamDistance); + float webcamOutsideAlpha = smoothstep(-0.75, 0.75, webcamDistance); + float webcamShadowAlpha = + (1.0 - smoothstep(0.0, max(webcamShadowSize, 1.0), max(webcamDistance, 0.0))) * + webcamOutsideAlpha * + webcamShadowA; + rgb = lerp(rgb, shadow.rgb, saturate(webcamShadowAlpha)); + + if (webcamAlpha > 0.001) { + float2 webcamUv = input.uv; + if (webcamMirror > 0.5) { + float mirroredX = webcamLeft + (webcamRight - pixel.x); + webcamUv.x = mirroredX / outputWidth; + } + float4 webcam = webcamTexture.Sample(linearSampler, saturate(webcamUv)); + rgb = lerp(rgb, webcam.rgb, saturate(webcamAlpha)); + } + } + } + if (cursorEnabled > 0.5) { + float safeBounceScale = max(cursorBounceScale, 0.1); + float cursorHeight = max(cursorSize, 1.0) * safeBounceScale; + float cursorWidth = cursorHeight * max(cursorAtlasAspect, 0.01); + float2 cursorPixel = float2(cursorX, cursorY); + if (zoomEnabled > 0.5) { + cursorPixel = cursorPixel * safeZoomScale + zoomOffset; + } + float2 cursorLocal = pixel - cursorPixel; + if (cursorAtlasEnabled > 0.5) { + float shadowAlpha = sampleCursorAtlasShadow(cursorLocal, cursorWidth, cursorHeight); + rgb = lerp(rgb, float3(0.0, 0.0, 0.0), shadowAlpha); + + float2 cursorUv = float2( + cursorLocal.x / cursorWidth + cursorAtlasAnchorX, + cursorLocal.y / cursorHeight + cursorAtlasAnchorY + ); + if ( + cursorUv.x >= 0.0 && cursorUv.x <= 1.0 && + cursorUv.y >= 0.0 && cursorUv.y <= 1.0 + ) { + float2 atlasMin = float2(cursorAtlasLeft, cursorAtlasTop); + float2 atlasMax = float2(cursorAtlasRight, cursorAtlasBottom); + float4 cursorSample = cursorAtlasTexture.Sample( + linearSampler, + lerp(atlasMin, atlasMax, cursorUv) + ); + rgb = lerp(rgb, cursorSample.rgb, saturate(cursorSample.a)); + } + } else { + float scale = max(cursorSize, 1.0) / 72.0; + float cursorExtent = max(cursorSize, 1.0) * safeBounceScale * 1.15 + 8.0; + if (abs(cursorLocal.x) <= cursorExtent && abs(cursorLocal.y) <= cursorExtent) { + float shadowMask = cursorArrowMask(cursorLocal - float2(3.0, 3.0), scale); + rgb = lerp(rgb, float3(0.0, 0.0, 0.0), shadowMask * 0.35); + float outlineMask = cursorArrowMask(cursorLocal / 1.08, scale * 1.08); + rgb = lerp(rgb, float3(0.0, 0.0, 0.0), outlineMask * 0.75); + float cursorMask = cursorArrowMask(cursorLocal, scale); + rgb = lerp(rgb, float3(1.0, 1.0, 1.0), cursorMask * 0.95); + } + } + } + return float4(rgb, 1.0); +} +)"; + + ComPtr vertexBytecode; + if (!compileShader(vertexShaderSource, "main", "vs_5_0", &vertexBytecode)) { + return false; + } + HRESULT hr = device_->CreateVertexShader( + vertexBytecode->GetBufferPointer(), + vertexBytecode->GetBufferSize(), + nullptr, + &vertexShader_); + if (!succeeded(hr, "CreateVertexShader")) { + return false; + } + + ComPtr pixelBytecode; + if (!compileShader(pixelShaderSource, "main", "ps_5_0", &pixelBytecode)) { + return false; + } + hr = device_->CreatePixelShader( + pixelBytecode->GetBufferPointer(), + pixelBytecode->GetBufferSize(), + nullptr, + &pixelShader_); + if (!succeeded(hr, "CreatePixelShader")) { + return false; + } + + D3D11_BUFFER_DESC constantDesc = {}; + constantDesc.ByteWidth = sizeof(ShaderConstants); + constantDesc.Usage = D3D11_USAGE_DEFAULT; + constantDesc.BindFlags = D3D11_BIND_CONSTANT_BUFFER; + hr = device_->CreateBuffer(&constantDesc, nullptr, &compositorConstants_); + if (!succeeded(hr, "Create compositor constant buffer")) { + return false; + } + + D3D11_SAMPLER_DESC samplerDesc = {}; + samplerDesc.Filter = D3D11_FILTER_MIN_MAG_MIP_LINEAR; + samplerDesc.AddressU = D3D11_TEXTURE_ADDRESS_CLAMP; + samplerDesc.AddressV = D3D11_TEXTURE_ADDRESS_CLAMP; + samplerDesc.AddressW = D3D11_TEXTURE_ADDRESS_CLAMP; + samplerDesc.MaxLOD = D3D11_FLOAT32_MAX; + hr = device_->CreateSamplerState(&samplerDesc, &samplerState_); + if (!succeeded(hr, "Create compositor sampler state")) { + return false; + } + return createBackgroundTexture() && createCursorAtlasTexture(); + } + + bool createSinkWriter() { + ComPtr attributes; + HRESULT hr = MFCreateAttributes(&attributes, 4); + if (!succeeded(hr, "MFCreateAttributes")) { + return false; + } + attributes->SetUINT32(MF_READWRITE_ENABLE_HARDWARE_TRANSFORMS, TRUE); + attributes->SetUINT32(MF_SINK_WRITER_DISABLE_THROTTLING, TRUE); + attributes->SetUnknown(MF_SINK_WRITER_D3D_MANAGER, deviceManager_.Get()); + + hr = MFCreateSinkWriterFromURL(options_.outputPath.c_str(), nullptr, attributes.Get(), &sinkWriter_); + if (!succeeded(hr, "MFCreateSinkWriterFromURL")) { + return false; + } + + ComPtr outputType; + hr = MFCreateMediaType(&outputType); + if (!succeeded(hr, "MFCreateMediaType output")) { + return false; + } + outputType->SetGUID(MF_MT_MAJOR_TYPE, MFMediaType_Video); + outputType->SetGUID(MF_MT_SUBTYPE, MFVideoFormat_H264); + outputType->SetUINT32(MF_MT_AVG_BITRATE, options_.bitrate); + outputType->SetUINT32(MF_MT_INTERLACE_MODE, MFVideoInterlace_Progressive); + MFSetAttributeSize(outputType.Get(), MF_MT_FRAME_SIZE, options_.width, options_.height); + MFSetAttributeRatio(outputType.Get(), MF_MT_FRAME_RATE, options_.fps, 1); + MFSetAttributeRatio(outputType.Get(), MF_MT_PIXEL_ASPECT_RATIO, 1, 1); + + hr = sinkWriter_->AddStream(outputType.Get(), &streamIndex_); + if (!succeeded(hr, "IMFSinkWriter::AddStream")) { + return false; + } + + ComPtr inputType; + hr = MFCreateMediaType(&inputType); + if (!succeeded(hr, "MFCreateMediaType input")) { + return false; + } + inputType->SetGUID(MF_MT_MAJOR_TYPE, MFMediaType_Video); + inputType->SetGUID(MF_MT_SUBTYPE, MFVideoFormat_NV12); + inputType->SetUINT32(MF_MT_INTERLACE_MODE, MFVideoInterlace_Progressive); + inputType->SetUINT32(MF_MT_DEFAULT_STRIDE, options_.width); + MFSetAttributeSize(inputType.Get(), MF_MT_FRAME_SIZE, options_.width, options_.height); + MFSetAttributeRatio(inputType.Get(), MF_MT_FRAME_RATE, options_.fps, 1); + MFSetAttributeRatio(inputType.Get(), MF_MT_PIXEL_ASPECT_RATIO, 1, 1); + + ComPtr encoderAttributes; + if (options_.fastEncoderTuning && SUCCEEDED(MFCreateAttributes(&encoderAttributes, 8))) { + encoderAttributes->SetUINT32(CODECAPI_AVLowLatencyMode, TRUE); + encoderAttributes->SetUINT32(CODECAPI_AVEncCommonQualityVsSpeed, 0); + encoderAttributes->SetUINT32( + CODECAPI_AVEncCommonRateControlMode, + eAVEncCommonRateControlMode_LowDelayVBR); + encoderAttributes->SetUINT32(CODECAPI_AVEncCommonMeanBitRate, options_.bitrate); + encoderAttributes->SetUINT32( + CODECAPI_AVEncCommonMaxBitRate, + static_cast(std::min( + 0xffffffffu, + static_cast(options_.bitrate) * 3 / 2))); + encoderAttributes->SetUINT32(CODECAPI_AVEncMPVDefaultBPictureCount, 0); + encoderAttributes->SetUINT32(CODECAPI_AVEncH264CABACEnable, FALSE); + } + + hr = sinkWriter_->SetInputMediaType(streamIndex_, inputType.Get(), encoderAttributes.Get()); + encoderTuningApplied_ = SUCCEEDED(hr) && encoderAttributes; + if (FAILED(hr) && encoderAttributes) { + hr = sinkWriter_->SetInputMediaType(streamIndex_, inputType.Get(), nullptr); + encoderTuningApplied_ = false; + } + if (!succeeded(hr, "IMFSinkWriter::SetInputMediaType")) { + return false; + } + hr = sinkWriter_->BeginWriting(); + return succeeded(hr, "IMFSinkWriter::BeginWriting"); + } + + bool createNvencSdkEncoder() { +#ifdef RECORDLY_GPU_EXPORT_ENABLE_NVENC_SDK + try { + nvencEncoder_ = std::make_unique( + device_.Get(), + options_.width, + options_.height, + NV_ENC_BUFFER_FORMAT_NV12); + + NV_ENC_INITIALIZE_PARAMS initializeParams = {NV_ENC_INITIALIZE_PARAMS_VER}; + NV_ENC_CONFIG encodeConfig = {NV_ENC_CONFIG_VER}; + initializeParams.encodeConfig = &encodeConfig; + nvencEncoder_->CreateDefaultEncoderParams( + &initializeParams, + NV_ENC_CODEC_H264_GUID, + NV_ENC_PRESET_HP_GUID); + + initializeParams.frameRateNum = options_.fps; + initializeParams.frameRateDen = 1; + initializeParams.enableEncodeAsync = 1; + encodeConfig.profileGUID = NV_ENC_H264_PROFILE_HIGH_GUID; + encodeConfig.gopLength = options_.fps * 2; + encodeConfig.frameIntervalP = 1; + encodeConfig.rcParams.rateControlMode = NV_ENC_PARAMS_RC_VBR; + encodeConfig.rcParams.averageBitRate = options_.bitrate; + encodeConfig.rcParams.maxBitRate = static_cast(std::min( + 0xffffffffu, + static_cast(options_.bitrate) * 3 / 2)); + encodeConfig.rcParams.vbvBufferSize = options_.bitrate; + encodeConfig.rcParams.vbvInitialDelay = options_.bitrate; + encodeConfig.encodeCodecConfig.h264Config.idrPeriod = encodeConfig.gopLength; + + nvencEncoder_->CreateEncoder(&initializeParams); + + if (_wfopen_s(&nvencOutputFile_, options_.outputPath.c_str(), L"wb") != 0 || !nvencOutputFile_) { + std::cerr << "[gpu-export] Failed to open NVENC SDK output" << std::endl; + return false; + } + encoderTuningApplied_ = true; + return true; + } catch (const std::exception& error) { + std::cerr << "[gpu-export] NVENC SDK init failed: " << error.what() << std::endl; + return false; + } +#else + std::cerr << "[gpu-export] --nvenc-sdk requested, but this build was not compiled with NVENC SDK support" + << std::endl; + return false; +#endif + } + + bool createSourceReaderForPath( + const std::wstring& path, + ComPtr& reader, + UINT& width, + UINT& height, + const char* label) { + ComPtr attributes; + HRESULT hr = MFCreateAttributes(&attributes, 4); + if (!succeeded(hr, "MFCreateAttributes source reader")) { + return false; + } + const bool useD3DSourceReader = !options_.preferHighPerformanceAdapter || options_.nvencSdk; + if (useD3DSourceReader) { + attributes->SetUnknown(MF_SOURCE_READER_D3D_MANAGER, deviceManager_.Get()); + } + attributes->SetUINT32(MF_READWRITE_ENABLE_HARDWARE_TRANSFORMS, TRUE); + attributes->SetUINT32( + MF_SOURCE_READER_DISABLE_DXVA, + useD3DSourceReader ? FALSE : TRUE); + if (options_.preferHighPerformanceAdapter && !useD3DSourceReader) { + attributes->SetUINT32(MF_SOURCE_READER_ENABLE_VIDEO_PROCESSING, TRUE); + } + + hr = MFCreateSourceReaderFromURL(path.c_str(), attributes.Get(), &reader); + if (!succeeded(hr, label)) { + return false; + } + + ComPtr mediaType; + hr = MFCreateMediaType(&mediaType); + if (!succeeded(hr, "MFCreateMediaType source output")) { + return false; + } + mediaType->SetGUID(MF_MT_MAJOR_TYPE, MFMediaType_Video); + mediaType->SetGUID( + MF_MT_SUBTYPE, + (options_.preferHighPerformanceAdapter && !options_.nvencSdk) ? MFVideoFormat_RGB32 : MFVideoFormat_NV12); + hr = reader->SetCurrentMediaType( + firstVideoStreamIndex(), + nullptr, + mediaType.Get()); + if (!succeeded(hr, "IMFSourceReader::SetCurrentMediaType")) { + return false; + } + + ComPtr currentType; + hr = reader->GetCurrentMediaType(firstVideoStreamIndex(), ¤tType); + if (!succeeded(hr, "IMFSourceReader::GetCurrentMediaType")) { + return false; + } + + UINT32 detectedWidth = 0; + UINT32 detectedHeight = 0; + hr = MFGetAttributeSize(currentType.Get(), MF_MT_FRAME_SIZE, &detectedWidth, &detectedHeight); + if (!succeeded(hr, "MFGetAttributeSize source frame")) { + return false; + } + width = std::max(2, detectedWidth & ~1U); + height = std::max(2, detectedHeight & ~1U); + return true; + } + + bool createSourceReader() { + return createSourceReaderForPath( + options_.inputPath, + sourceReader_, + sourceWidth_, + sourceHeight_, + "MFCreateSourceReaderFromURL input"); + } + + bool createWebcamSourceReader() { + return createSourceReaderForPath( + options_.webcamInputPath, + webcamReader_, + webcamWidth_, + webcamHeight_, + "MFCreateSourceReaderFromURL webcam"); + } + + bool hasWebcamOverlay() const { + return !options_.webcamInputPath.empty() && + options_.webcamLeft >= 0 && + options_.webcamTop >= 0 && + options_.webcamSize >= 2; + } + + bool hasCursorOverlay() const { + return !cursorSamples_.empty() && options_.cursorSize > 0.0f; + } + + bool hasZoomOverlay() const { + return !zoomSamples_.empty(); + } + + void loadCursorTelemetry() { + cursorSamples_.clear(); + if (options_.cursorTelemetryPath.empty()) { + return; + } + + FILE* file = nullptr; + if (_wfopen_s(&file, options_.cursorTelemetryPath.c_str(), L"rb") != 0 || !file) { + std::cerr << "[gpu-export] Unable to open cursor telemetry file" << std::endl; + return; + } + + char line[256]; + while (std::fgets(line, sizeof(line), file)) { + double timeMs = 0.0; + float cx = 0.0f; + float cy = 0.0f; + int cursorTypeIndex = 0; + float bounceScale = 1.0f; + int visible = 1; + const int parsed = sscanf_s( + line, + "%lf,%f,%f,%d,%f,%d", + &timeMs, + &cx, + &cy, + &cursorTypeIndex, + &bounceScale, + &visible); + if (parsed < 3) { + continue; + } + if (!std::isfinite(timeMs) || !std::isfinite(cx) || !std::isfinite(cy)) { + continue; + } + cursorSamples_.push_back(CursorSample{ + std::max(0.0, timeMs), + std::min(1.0f, std::max(0.0f, cx)), + std::min(1.0f, std::max(0.0f, cy)), + std::min(8, std::max(0, cursorTypeIndex)), + std::isfinite(bounceScale) + ? std::min(2.0f, std::max(0.1f, bounceScale)) + : 1.0f, + parsed >= 6 ? visible != 0 : true, + }); + } + std::fclose(file); + + std::sort(cursorSamples_.begin(), cursorSamples_.end(), [](const auto& left, const auto& right) { + return left.timeMs < right.timeMs; + }); + } + + CursorSample getCursorSampleAt(double timeMs) const { + if (cursorSamples_.empty()) { + return {}; + } + if (timeMs <= cursorSamples_.front().timeMs) { + return cursorSamples_.front(); + } + if (timeMs >= cursorSamples_.back().timeMs) { + return cursorSamples_.back(); + } + + const auto upper = std::upper_bound( + cursorSamples_.begin(), + cursorSamples_.end(), + timeMs, + [](double value, const CursorSample& sample) { + return value < sample.timeMs; + }); + const auto& b = *upper; + const auto& a = *(upper - 1); + const double span = std::max(1.0, b.timeMs - a.timeMs); + const float t = static_cast((timeMs - a.timeMs) / span); + return CursorSample{ + timeMs, + a.cx + (b.cx - a.cx) * t, + a.cy + (b.cy - a.cy) * t, + a.cursorTypeIndex, + a.bounceScale + (b.bounceScale - a.bounceScale) * t, + a.visible && b.visible, + }; + } + + const CursorAtlasEntry* getCursorAtlasEntry(int cursorTypeIndex) const { + if (!hasCursorAtlas_ || cursorAtlasWidth_ == 0 || cursorAtlasHeight_ == 0) { + return nullptr; + } + + const size_t index = static_cast( + std::min(8, std::max(0, cursorTypeIndex))); + if (index >= cursorAtlasEntries_.size() || !cursorAtlasEntries_[index].valid) { + return nullptr; + } + + return &cursorAtlasEntries_[index]; + } + + void loadZoomTelemetry() { + zoomSamples_.clear(); + if (options_.zoomTelemetryPath.empty()) { + return; + } + + FILE* file = nullptr; + if (_wfopen_s(&file, options_.zoomTelemetryPath.c_str(), L"rb") != 0 || !file) { + std::cerr << "[gpu-export] Unable to open zoom telemetry file" << std::endl; + return; + } + + char line[256]; + while (std::fgets(line, sizeof(line), file)) { + double timeMs = 0.0; + float scale = 1.0f; + float x = 0.0f; + float y = 0.0f; + if (sscanf_s(line, "%lf,%f,%f,%f", &timeMs, &scale, &x, &y) != 4) { + continue; + } + if (!std::isfinite(timeMs) || !std::isfinite(scale) || !std::isfinite(x) || !std::isfinite(y)) { + continue; + } + zoomSamples_.push_back(ZoomSample{ + std::max(0.0, timeMs), + std::max(0.01f, scale), + x, + y, + }); + } + std::fclose(file); + + std::sort(zoomSamples_.begin(), zoomSamples_.end(), [](const auto& left, const auto& right) { + return left.timeMs < right.timeMs; + }); + } + + ZoomSample getZoomSampleAt(double timeMs) const { + if (zoomSamples_.empty()) { + return {}; + } + if (timeMs <= zoomSamples_.front().timeMs) { + return zoomSamples_.front(); + } + if (timeMs >= zoomSamples_.back().timeMs) { + return zoomSamples_.back(); + } + + const auto upper = std::upper_bound( + zoomSamples_.begin(), + zoomSamples_.end(), + timeMs, + [](double value, const ZoomSample& sample) { + return value < sample.timeMs; + }); + const auto& b = *upper; + const auto& a = *(upper - 1); + const double span = std::max(1.0, b.timeMs - a.timeMs); + const float t = static_cast((timeMs - a.timeMs) / span); + return ZoomSample{ + timeMs, + a.scale + (b.scale - a.scale) * t, + a.x + (b.x - a.x) * t, + a.y + (b.y - a.y) * t, + }; + } + + RECT getWebcamRect() const { + if (!hasWebcamOverlay()) { + return {0, 0, 0, 0}; + } + + const LONG left = std::min( + std::max(0, options_.webcamLeft), + static_cast(options_.width) - 2); + const LONG top = std::min( + std::max(0, options_.webcamTop), + static_cast(options_.height) - 2); + const LONG size = std::min( + options_.webcamSize & ~1L, + std::min( + static_cast(options_.width) - left, + static_cast(options_.height) - top)); + const LONG safeSize = std::max(2, size); + return {left, top, left + safeSize, top + safeSize}; + } + + RECT getSourceCropRect() const { + if ( + options_.sourceCropWidth >= 2 && + options_.sourceCropHeight >= 2 && + sourceWidth_ >= 2 && + sourceHeight_ >= 2 + ) { + const LONG left = std::min( + std::max(0, options_.sourceCropLeft), + static_cast(sourceWidth_) - 2); + const LONG top = std::min( + std::max(0, options_.sourceCropTop), + static_cast(sourceHeight_) - 2); + const LONG width = (std::min( + options_.sourceCropWidth & ~1L, + static_cast(sourceWidth_) - left)) & ~1L; + const LONG height = (std::min( + options_.sourceCropHeight & ~1L, + static_cast(sourceHeight_) - top)) & ~1L; + return { + left, + top, + left + std::max(2, width), + top + std::max(2, height), + }; + } + + return { + 0, + 0, + static_cast(sourceWidth_), + static_cast(sourceHeight_), + }; + } + + RECT getContentRect() const { + if ( + options_.contentLeft >= 0 && + options_.contentTop >= 0 && + options_.contentWidth >= 2 && + options_.contentHeight >= 2 + ) { + const LONG left = std::min( + std::max(0, options_.contentLeft), + static_cast(options_.width) - 2); + const LONG top = std::min( + std::max(0, options_.contentTop), + static_cast(options_.height) - 2); + const LONG width = std::min( + options_.contentWidth & ~1L, + static_cast(options_.width) - left); + const LONG height = std::min( + options_.contentHeight & ~1L, + static_cast(options_.height) - top); + return { + left, + top, + left + std::max(2, width), + top + std::max(2, height), + }; + } + + const double availableWidth = + static_cast(options_.width) * (1.0 - (2.0 * options_.padding)); + const double availableHeight = + static_cast(options_.height) * (1.0 - (2.0 * options_.padding)); + const double scale = std::min( + availableWidth / static_cast(sourceWidth_), + availableHeight / static_cast(sourceHeight_)); + const LONG contentWidth = static_cast( + std::max(2, static_cast(sourceWidth_ * scale) & ~1U)); + const LONG contentHeight = static_cast( + std::max(2, static_cast(sourceHeight_ * scale) & ~1U)); + const LONG x = (static_cast(options_.width) - contentWidth) / 2; + const LONG y = (static_cast(options_.height) - contentHeight) / 2; + return {x, y, x + contentWidth, y + contentHeight}; + } + + void renderSyntheticFrame(UINT frameIndex) { + const float t = static_cast(frameIndex % options_.fps) / static_cast(options_.fps); + const float color[4] = { + 0.05f + 0.45f * t, + 0.18f, + 0.42f + 0.3f * (1.0f - t), + 1.0f, + }; + deviceContext_->ClearRenderTargetView(bgraRenderTargetView_.Get(), color); + } + + bool convertBgraToNv12(UINT frameIndex) { + const size_t surfaceIndex = static_cast(frameIndex) % nv12OutputViews_.size(); + D3D11_VIDEO_PROCESSOR_STREAM stream = {}; + stream.Enable = TRUE; + stream.OutputIndex = 0; + stream.InputFrameOrField = 0; + stream.PastFrames = 0; + stream.FutureFrames = 0; + stream.pInputSurface = bgraInputView_.Get(); + + const HRESULT hr = videoContext_->VideoProcessorBlt( + bgraVideoProcessor_.Get(), + bgraNv12OutputViews_[surfaceIndex].Get(), + frameIndex, + 1, + &stream); + if (!succeeded(hr, "VideoProcessorBlt")) { + return false; + } + return true; + } + + bool convertSourceTextureToNv12( + ID3D11Texture2D* texture, + UINT subresourceIndex, + UINT frameIndex) { + ComPtr inputView; + D3D11_VIDEO_PROCESSOR_INPUT_VIEW_DESC inputViewDesc = {}; + inputViewDesc.FourCC = 0; + inputViewDesc.ViewDimension = D3D11_VPIV_DIMENSION_TEXTURE2D; + inputViewDesc.Texture2D.MipSlice = 0; + inputViewDesc.Texture2D.ArraySlice = subresourceIndex; + + HRESULT hr = videoDevice_->CreateVideoProcessorInputView( + texture, + videoProcessorEnumerator_.Get(), + &inputViewDesc, + &inputView); + if (!succeeded(hr, "Create source video processor input view")) { + return false; + } + + const size_t surfaceIndex = static_cast(frameIndex) % nv12OutputViews_.size(); + D3D11_VIDEO_PROCESSOR_STREAM stream = {}; + stream.Enable = TRUE; + stream.OutputIndex = 0; + stream.InputFrameOrField = 0; + stream.pInputSurface = inputView.Get(); + + hr = videoContext_->VideoProcessorBlt( + videoProcessor_.Get(), + nv12OutputViews_[surfaceIndex].Get(), + frameIndex, + 1, + &stream); + if (!succeeded(hr, "VideoProcessorBlt source")) { + return false; + } + return true; + } + + bool convertSourceTextureToBgra( + ID3D11Texture2D* texture, + UINT subresourceIndex, + UINT frameIndex) { + ComPtr inputView; + D3D11_VIDEO_PROCESSOR_INPUT_VIEW_DESC inputViewDesc = {}; + inputViewDesc.FourCC = 0; + inputViewDesc.ViewDimension = D3D11_VPIV_DIMENSION_TEXTURE2D; + inputViewDesc.Texture2D.MipSlice = 0; + inputViewDesc.Texture2D.ArraySlice = subresourceIndex; + + HRESULT hr = videoDevice_->CreateVideoProcessorInputView( + texture, + videoProcessorEnumerator_.Get(), + &inputViewDesc, + &inputView); + if (!succeeded(hr, "Create source BGRA video processor input view")) { + return false; + } + + D3D11_VIDEO_PROCESSOR_STREAM stream = {}; + stream.Enable = TRUE; + stream.OutputIndex = 0; + stream.InputFrameOrField = 0; + stream.pInputSurface = inputView.Get(); + + hr = videoContext_->VideoProcessorBlt( + videoProcessor_.Get(), + contentOutputView_.Get(), + frameIndex, + 1, + &stream); + if (!succeeded(hr, "VideoProcessorBlt source BGRA")) { + return false; + } + return true; + } + + bool ensureNv12UploadTexture( + ComPtr& texture, + UINT width, + UINT height, + const char* label) { + if (texture) { + return true; + } + + D3D11_TEXTURE2D_DESC desc = {}; + desc.Width = width; + desc.Height = height; + desc.MipLevels = 1; + desc.ArraySize = 1; + desc.Format = DXGI_FORMAT_NV12; + desc.SampleDesc.Count = 1; + desc.Usage = D3D11_USAGE_DEFAULT; + desc.BindFlags = D3D11_BIND_DECODER | D3D11_BIND_SHADER_RESOURCE; + + const HRESULT hr = device_->CreateTexture2D(&desc, nullptr, &texture); + return succeeded(hr, label); + } + + bool uploadNv12BufferToTexture( + IMFMediaBuffer* buffer, + ComPtr& texture, + UINT width, + UINT height, + const char* label) { + if (!ensureNv12UploadTexture(texture, width, height, label)) { + return false; + } + + const DWORD expectedLength = width * height * 3 / 2; + ComPtr buffer2D; + HRESULT hr = buffer->QueryInterface(IID_PPV_ARGS(&buffer2D)); + if (SUCCEEDED(hr)) { + DWORD contiguousLength = 0; + hr = buffer2D->GetContiguousLength(&contiguousLength); + if (SUCCEEDED(hr) && contiguousLength >= expectedLength) { + uploadPaddedScratch_.resize(contiguousLength); + hr = buffer2D->ContiguousCopyTo(uploadPaddedScratch_.data(), contiguousLength); + if (!succeeded(hr, "IMF2DBuffer::ContiguousCopyTo")) { + return false; + } + + const UINT nv12Rows = height + (height / 2); + const UINT sourcePitch = + contiguousLength % nv12Rows == 0 + ? contiguousLength / nv12Rows + : width; + if (sourcePitch < width) { + std::cerr << "ERROR: Unsupported contiguous NV12 pitch." << std::endl; + return false; + } + + uploadScratch_.resize(expectedLength); + const BYTE* yPlane = uploadPaddedScratch_.data(); + const BYTE* uvPlane = uploadPaddedScratch_.data() + (sourcePitch * height); + BYTE* yOut = uploadScratch_.data(); + BYTE* uvOut = uploadScratch_.data() + (width * height); + for (UINT row = 0; row < height; row += 1) { + std::memcpy(yOut + (row * width), yPlane + (row * sourcePitch), width); + } + for (UINT row = 0; row < height / 2; row += 1) { + std::memcpy(uvOut + (row * width), uvPlane + (row * sourcePitch), width); + } + + deviceContext_->UpdateSubresource( + texture.Get(), + 0, + nullptr, + uploadScratch_.data(), + width, + expectedLength); + return true; + } + + BYTE* scanline0 = nullptr; + LONG pitch = 0; + hr = buffer2D->Lock2D(&scanline0, &pitch); + if (!succeeded(hr, "IMF2DBuffer::Lock2D")) { + return false; + } + if (pitch <= 0 || static_cast(pitch) < width) { + buffer2D->Unlock2D(); + std::cerr << "ERROR: Unsupported NV12 pitch." << std::endl; + return false; + } + + uploadScratch_.resize(expectedLength); + const BYTE* yPlane = scanline0; + const BYTE* uvPlane = scanline0 + (pitch * height); + BYTE* yOut = uploadScratch_.data(); + BYTE* uvOut = uploadScratch_.data() + (width * height); + for (UINT row = 0; row < height; row += 1) { + std::memcpy(yOut + (row * width), yPlane + (row * pitch), width); + } + for (UINT row = 0; row < height / 2; row += 1) { + std::memcpy(uvOut + (row * width), uvPlane + (row * pitch), width); + } + buffer2D->Unlock2D(); + + deviceContext_->UpdateSubresource( + texture.Get(), + 0, + nullptr, + uploadScratch_.data(), + width, + expectedLength); + return true; + } + + BYTE* data = nullptr; + DWORD maxLength = 0; + DWORD currentLength = 0; + hr = buffer->Lock(&data, &maxLength, ¤tLength); + if (!succeeded(hr, "IMFMediaBuffer::Lock")) { + return false; + } + if (currentLength < expectedLength) { + buffer->Unlock(); + std::cerr << "ERROR: NV12 buffer shorter than expected." << std::endl; + return false; + } + deviceContext_->UpdateSubresource(texture.Get(), 0, nullptr, data, width, expectedLength); + buffer->Unlock(); + return true; + } + + bool ensureBgraUploadTexture( + ComPtr& texture, + UINT width, + UINT height, + const char* label) { + if (texture) { + return true; + } + + D3D11_TEXTURE2D_DESC desc = {}; + desc.Width = width; + desc.Height = height; + desc.MipLevels = 1; + desc.ArraySize = 1; + desc.Format = DXGI_FORMAT_B8G8R8A8_UNORM; + desc.SampleDesc.Count = 1; + desc.Usage = D3D11_USAGE_DEFAULT; + desc.BindFlags = D3D11_BIND_RENDER_TARGET | D3D11_BIND_SHADER_RESOURCE; + + const HRESULT hr = device_->CreateTexture2D(&desc, nullptr, &texture); + return succeeded(hr, label); + } + + bool uploadBgraBufferToTexture( + IMFMediaBuffer* buffer, + ComPtr& texture, + UINT width, + UINT height, + const char* label) { + if (!ensureBgraUploadTexture(texture, width, height, label)) { + return false; + } + + const DWORD rowBytes = width * 4; + const DWORD expectedLength = rowBytes * height; + ComPtr buffer2D; + HRESULT hr = buffer->QueryInterface(IID_PPV_ARGS(&buffer2D)); + if (SUCCEEDED(hr)) { + BYTE* scanline0 = nullptr; + LONG pitch = 0; + hr = buffer2D->Lock2D(&scanline0, &pitch); + if (!succeeded(hr, "IMF2DBuffer::Lock2D BGRA")) { + return false; + } + + const LONG absPitch = pitch < 0 ? -pitch : pitch; + if (absPitch < static_cast(rowBytes)) { + buffer2D->Unlock2D(); + std::cerr << "ERROR: Unsupported BGRA pitch." << std::endl; + return false; + } + + uploadScratch_.resize(expectedLength); + for (UINT row = 0; row < height; row += 1) { + const BYTE* sourceRow = pitch > 0 + ? scanline0 + (row * pitch) + : scanline0 + ((height - 1 - row) * absPitch); + std::memcpy(uploadScratch_.data() + (row * rowBytes), sourceRow, rowBytes); + } + buffer2D->Unlock2D(); + + deviceContext_->UpdateSubresource( + texture.Get(), + 0, + nullptr, + uploadScratch_.data(), + rowBytes, + expectedLength); + return true; + } + + BYTE* data = nullptr; + DWORD maxLength = 0; + DWORD currentLength = 0; + hr = buffer->Lock(&data, &maxLength, ¤tLength); + if (!succeeded(hr, "IMFMediaBuffer::Lock BGRA")) { + return false; + } + if (currentLength < expectedLength) { + buffer->Unlock(); + std::cerr << "ERROR: BGRA buffer shorter than expected." << std::endl; + return false; + } + deviceContext_->UpdateSubresource( + texture.Get(), + 0, + nullptr, + data, + rowBytes, + expectedLength); + buffer->Unlock(); + return true; + } + + bool convertWebcamTextureToBgra( + ID3D11Texture2D* texture, + UINT subresourceIndex, + UINT frameIndex) { + ComPtr inputView; + D3D11_VIDEO_PROCESSOR_INPUT_VIEW_DESC inputViewDesc = {}; + inputViewDesc.FourCC = 0; + inputViewDesc.ViewDimension = D3D11_VPIV_DIMENSION_TEXTURE2D; + inputViewDesc.Texture2D.MipSlice = 0; + inputViewDesc.Texture2D.ArraySlice = subresourceIndex; + + HRESULT hr = videoDevice_->CreateVideoProcessorInputView( + texture, + webcamVideoProcessorEnumerator_.Get(), + &inputViewDesc, + &inputView); + if (!succeeded(hr, "Create webcam video processor input view")) { + return false; + } + + D3D11_VIDEO_PROCESSOR_STREAM stream = {}; + stream.Enable = TRUE; + stream.OutputIndex = 0; + stream.InputFrameOrField = 0; + stream.pInputSurface = inputView.Get(); + + hr = videoContext_->VideoProcessorBlt( + webcamVideoProcessor_.Get(), + webcamOutputView_.Get(), + frameIndex, + 1, + &stream); + if (!succeeded(hr, "VideoProcessorBlt webcam BGRA")) { + return false; + } + webcamFrameReady_ = true; + return true; + } + + bool convertWebcamSampleToBgra( + IMFSample* sample, + UINT frameIndex) { + ComPtr buffer; + HRESULT hr = sample->GetBufferByIndex(0, &buffer); + if (!succeeded(hr, "IMFSample::GetBufferByIndex webcam")) { + return false; + } + + ComPtr dxgiBuffer; + hr = buffer.As(&dxgiBuffer); + if (FAILED(hr)) { + if (options_.preferHighPerformanceAdapter) { + if (!uploadBgraBufferToTexture( + buffer.Get(), + webcamUploadBgraTexture_, + webcamWidth_, + webcamHeight_, + "Create webcam BGRA upload texture")) { + return false; + } + return convertWebcamTextureToBgra(webcamUploadBgraTexture_.Get(), 0, frameIndex); + } else { + if (!uploadNv12BufferToTexture( + buffer.Get(), + webcamUploadTexture_, + webcamWidth_, + webcamHeight_, + "Create webcam NV12 upload texture")) { + return false; + } + return convertWebcamTextureToBgra(webcamUploadTexture_.Get(), 0, frameIndex); + } + } + + ComPtr webcamTexture; + hr = dxgiBuffer->GetResource(IID_PPV_ARGS(&webcamTexture)); + if (!succeeded(hr, "IMFDXGIBuffer::GetResource webcam")) { + return false; + } + UINT subresourceIndex = 0; + dxgiBuffer->GetSubresourceIndex(&subresourceIndex); + return convertWebcamTextureToBgra(webcamTexture.Get(), subresourceIndex, frameIndex); + } + + bool readWebcamFrameForTimestamp(LONGLONG outputTimestamp, UINT frameIndex) { + if (!hasWebcamOverlay() || webcamEnded_) { + return true; + } + + const LONGLONG targetTimestamp = std::max( + 0, + outputTimestamp - static_cast(options_.webcamTimeOffsetMs * 10'000.0)); + + if (pendingWebcamSample_) { + const LONGLONG pendingTimestamp = std::max( + 0, + pendingWebcamTimestamp_ - webcamFirstTimestamp_); + if (pendingTimestamp > targetTimestamp && webcamFrameReady_) { + return true; + } + if (!convertWebcamSampleToBgra(pendingWebcamSample_.Get(), frameIndex)) { + return false; + } + pendingWebcamSample_.Reset(); + if (pendingTimestamp >= targetTimestamp) { + return true; + } + } + + while (true) { + DWORD streamIndex = 0; + DWORD flags = 0; + LONGLONG timestamp = 0; + ComPtr sample; + + const HRESULT hr = webcamReader_->ReadSample( + firstVideoStreamIndex(), + 0, + &streamIndex, + &flags, + ×tamp, + &sample); + if (!succeeded(hr, "IMFSourceReader::ReadSample webcam")) { + return false; + } + if (flags & MF_SOURCE_READERF_ENDOFSTREAM) { + webcamEnded_ = true; + return true; + } + if (!sample) { + return true; + } + if (webcamFirstTimestamp_ < 0) { + webcamFirstTimestamp_ = timestamp; + } + + const LONGLONG adjustedTimestamp = std::max( + 0, + timestamp - webcamFirstTimestamp_); + if (adjustedTimestamp > targetTimestamp && webcamFrameReady_) { + pendingWebcamSample_ = sample; + pendingWebcamTimestamp_ = timestamp; + return true; + } + if (!convertWebcamSampleToBgra(sample.Get(), frameIndex)) { + return false; + } + if (adjustedTimestamp >= targetTimestamp) { + return true; + } + } + } + + bool renderShaderComposite(LONGLONG outputTimestamp) { + const RECT contentRect = getContentRect(); + const RECT webcamRect = getWebcamRect(); + const bool webcamEnabled = hasWebcamOverlay() && webcamFrameReady_; + const bool cursorEnabled = hasCursorOverlay(); + const bool zoomEnabled = hasZoomOverlay(); + const ZoomSample zoom = zoomEnabled + ? getZoomSampleAt(static_cast(outputTimestamp) / 10'000.0) + : ZoomSample{}; + const CursorSample cursor = cursorEnabled + ? getCursorSampleAt(static_cast(outputTimestamp) / 10'000.0) + : CursorSample{}; + const bool cursorVisible = cursorEnabled && cursor.visible; + const CursorAtlasEntry* cursorAtlasEntry = cursorVisible + ? getCursorAtlasEntry(cursor.cursorTypeIndex) + : nullptr; + const bool cursorAtlasEnabled = cursorAtlasEntry != nullptr; + const float cursorX = cursorVisible + ? static_cast(contentRect.left) + + cursor.cx * static_cast(contentRect.right - contentRect.left) + : 0.0f; + const float cursorY = cursorVisible + ? static_cast(contentRect.top) + + cursor.cy * static_cast(contentRect.bottom - contentRect.top) + : 0.0f; + const ShaderConstants constants = { + static_cast(options_.width), + static_cast(options_.height), + std::min(options_.radius, static_cast( + std::min(contentRect.right - contentRect.left, contentRect.bottom - contentRect.top)) * 0.5f), + options_.shadow, + static_cast(contentRect.left), + static_cast(contentRect.top), + static_cast(contentRect.right), + static_cast(contentRect.bottom), + options_.backgroundR, + options_.backgroundG, + options_.backgroundB, + 1.0f, + 0.0f, + 0.0f, + 0.0f, + 0.42f, + hasBackgroundImage_ ? 1.0f : 0.0f, + static_cast(backgroundImageWidth_), + static_cast(backgroundImageHeight_), + webcamEnabled ? 1.0f : 0.0f, + static_cast(webcamRect.left), + static_cast(webcamRect.top), + static_cast(webcamRect.right), + static_cast(webcamRect.bottom), + webcamEnabled ? std::min(options_.webcamRadius, static_cast( + std::min(webcamRect.right - webcamRect.left, webcamRect.bottom - webcamRect.top)) * 0.5f) : 0.0f, + webcamEnabled ? options_.webcamShadow : 0.0f, + webcamEnabled ? 0.42f : 0.0f, + options_.webcamMirror ? 1.0f : 0.0f, + cursorVisible ? 1.0f : 0.0f, + cursorX, + cursorY, + options_.cursorSize, + cursorAtlasEnabled ? 1.0f : 0.0f, + cursorAtlasEnabled + ? cursorAtlasEntry->x / static_cast(cursorAtlasWidth_) + : 0.0f, + cursorAtlasEnabled + ? cursorAtlasEntry->y / static_cast(cursorAtlasHeight_) + : 0.0f, + cursorAtlasEnabled + ? (cursorAtlasEntry->x + cursorAtlasEntry->width) / + static_cast(cursorAtlasWidth_) + : 1.0f, + cursorAtlasEnabled + ? (cursorAtlasEntry->y + cursorAtlasEntry->height) / + static_cast(cursorAtlasHeight_) + : 1.0f, + cursorAtlasEnabled ? cursorAtlasEntry->anchorX : 0.0f, + cursorAtlasEnabled ? cursorAtlasEntry->anchorY : 0.0f, + cursorAtlasEnabled ? cursorAtlasEntry->aspectRatio : 1.0f, + cursorEnabled ? cursor.bounceScale : 1.0f, + hasBackgroundImage_ ? options_.backgroundBlurPx : 0.0f, + 0.0f, + 0.0f, + zoomEnabled ? 1.0f : 0.0f, + zoomEnabled ? zoom.scale : 1.0f, + zoomEnabled ? zoom.x : 0.0f, + zoomEnabled ? zoom.y : 0.0f, + }; + + deviceContext_->UpdateSubresource(compositorConstants_.Get(), 0, nullptr, &constants, 0, 0); + + const float clearColor[4] = {constants.backgroundR, constants.backgroundG, constants.backgroundB, 1.0f}; + deviceContext_->ClearRenderTargetView(bgraRenderTargetView_.Get(), clearColor); + + D3D11_VIEWPORT viewport = {}; + viewport.Width = static_cast(options_.width); + viewport.Height = static_cast(options_.height); + viewport.MinDepth = 0.0f; + viewport.MaxDepth = 1.0f; + + ID3D11RenderTargetView* renderTargets[] = {bgraRenderTargetView_.Get()}; + deviceContext_->OMSetRenderTargets(1, renderTargets, nullptr); + deviceContext_->RSSetViewports(1, &viewport); + deviceContext_->IASetInputLayout(nullptr); + deviceContext_->IASetPrimitiveTopology(D3D11_PRIMITIVE_TOPOLOGY_TRIANGLELIST); + deviceContext_->VSSetShader(vertexShader_.Get(), nullptr, 0); + deviceContext_->PSSetShader(pixelShader_.Get(), nullptr, 0); + ID3D11Buffer* constantBuffers[] = {compositorConstants_.Get()}; + deviceContext_->PSSetConstantBuffers(0, 1, constantBuffers); + ID3D11ShaderResourceView* shaderResources[] = { + contentShaderResourceView_.Get(), + backgroundShaderResourceView_.Get(), + webcamShaderResourceView_ ? webcamShaderResourceView_.Get() : contentShaderResourceView_.Get(), + cursorAtlasShaderResourceView_ + ? cursorAtlasShaderResourceView_.Get() + : backgroundShaderResourceView_.Get(), + }; + deviceContext_->PSSetShaderResources(0, 4, shaderResources); + ID3D11SamplerState* samplers[] = {samplerState_.Get()}; + deviceContext_->PSSetSamplers(0, 1, samplers); + deviceContext_->Draw(3, 0); + + ID3D11ShaderResourceView* nullResources[] = {nullptr, nullptr, nullptr, nullptr}; + deviceContext_->PSSetShaderResources(0, 4, nullResources); + return true; + } + + bool writeNvencSdkPackets(const std::vector>& packets) { + if (!nvencOutputFile_) { + return false; + } + for (const auto& packet : packets) { + if (packet.empty()) { + continue; + } + const size_t written = std::fwrite(packet.data(), 1, packet.size(), nvencOutputFile_); + if (written != packet.size()) { + std::cerr << "[gpu-export] Failed to write NVENC SDK packet" << std::endl; + return false; + } + nvencOutputBytes_ += packet.size(); + } + return true; + } + + bool writeNvencSdkFrame(UINT frameIndex) { +#ifdef RECORDLY_GPU_EXPORT_ENABLE_NVENC_SDK + if (!nvencEncoder_) { + std::cerr << "[gpu-export] NVENC SDK encoder is not initialized" << std::endl; + return false; + } + try { + const size_t surfaceIndex = static_cast(frameIndex) % nv12Textures_.size(); + const NvEncInputFrame* inputFrame = nvencEncoder_->GetNextInputFrame(); + auto* encoderTexture = reinterpret_cast(inputFrame->inputPtr); + deviceContext_->CopyResource(encoderTexture, nv12Textures_[surfaceIndex].Get()); + deviceContext_->Flush(); + + std::vector> packets; + nvencEncoder_->EncodeFrame(packets); + return writeNvencSdkPackets(packets); + } catch (const std::exception& error) { + std::cerr << "[gpu-export] NVENC SDK encode failed: " << error.what() << std::endl; + return false; + } +#else + (void)frameIndex; + return false; +#endif + } + + bool finalizeNvencSdk() { +#ifdef RECORDLY_GPU_EXPORT_ENABLE_NVENC_SDK + if (!nvencEncoder_) { + return true; + } + try { + std::vector> packets; + nvencEncoder_->EndEncode(packets); + if (!writeNvencSdkPackets(packets)) { + return false; + } + nvencEncoder_->DestroyEncoder(); + nvencEncoder_.reset(); + if (nvencOutputFile_) { + std::fclose(nvencOutputFile_); + nvencOutputFile_ = nullptr; + } + return true; + } catch (const std::exception& error) { + std::cerr << "[gpu-export] NVENC SDK finalize failed: " << error.what() << std::endl; + return false; + } +#else + return false; +#endif + } + + bool writeFrame( + UINT frameIndex, + LONGLONG sampleTimeOverride = -1, + LONGLONG sampleDurationOverride = -1) { + if (options_.nvencSdk) { + return writeNvencSdkFrame(frameIndex); + } + + const size_t surfaceIndex = static_cast(frameIndex) % nv12Textures_.size(); + ComPtr buffer; + HRESULT hr = MFCreateDXGISurfaceBuffer( + __uuidof(ID3D11Texture2D), + nv12Textures_[surfaceIndex].Get(), + 0, + FALSE, + &buffer); + if (!succeeded(hr, "MFCreateDXGISurfaceBuffer")) { + return false; + } + const DWORD nv12ByteLength = options_.width * options_.height * 3 / 2; + hr = buffer->SetCurrentLength(nv12ByteLength); + if (!succeeded(hr, "IMFMediaBuffer::SetCurrentLength")) { + return false; + } + + ComPtr sample; + hr = MFCreateSample(&sample); + if (!succeeded(hr, "MFCreateSample")) { + return false; + } + hr = sample->AddBuffer(buffer.Get()); + if (!succeeded(hr, "IMFSample::AddBuffer")) { + return false; + } + + const LONGLONG defaultSampleTime = + static_cast(frameIndex) * 10'000'000LL / options_.fps; + const LONGLONG defaultSampleDuration = 10'000'000LL / options_.fps; + const LONGLONG sampleTime = + sampleTimeOverride >= 0 ? sampleTimeOverride : defaultSampleTime; + const LONGLONG sampleDuration = + sampleDurationOverride > 0 ? sampleDurationOverride : defaultSampleDuration; + sample->SetSampleTime(sampleTime); + sample->SetSampleDuration(sampleDuration); + + hr = sinkWriter_->WriteSample(streamIndex_, sample.Get()); + return succeeded(hr, "IMFSinkWriter::WriteSample"); + } + + bool runSourceVideo() { + const UINT maxFrames = std::max( + static_cast(std::ceil(static_cast(options_.fps) * options_.seconds * 4.0)), + static_cast(std::ceil(options_.seconds * 240.0))); + const Timer totalTimer; + double readMs = 0; + double processMs = 0; + double writeMs = 0; + UINT frameIndex = 0; + bool sawDxgiSurface = false; + LONGLONG firstSourceTimestamp = -1; + LONGLONG lastOutputTimestamp = 0; + const LONGLONG maxOutputTimestamp = + static_cast(options_.seconds * 10'000'000.0); + const LONGLONG outputFrameDuration = 10'000'000LL / options_.fps; + LONGLONG nextOutputTimestamp = 0; + const UINT expectedOutputFrames = std::max( + 1, + static_cast(std::ceil(options_.seconds * static_cast(options_.fps)))); + + while (frameIndex < maxFrames && frameIndex < expectedOutputFrames) { + DWORD streamIndex = 0; + DWORD flags = 0; + LONGLONG timestamp = 0; + ComPtr sample; + + const Timer readTimer; + HRESULT hr = sourceReader_->ReadSample( + firstVideoStreamIndex(), + 0, + &streamIndex, + &flags, + ×tamp, + &sample); + readMs += readTimer.elapsedMs(); + if (!succeeded(hr, "IMFSourceReader::ReadSample")) { + return false; + } + if (flags & MF_SOURCE_READERF_ENDOFSTREAM) { + break; + } + if (!sample) { + continue; + } + if (firstSourceTimestamp < 0) { + firstSourceTimestamp = timestamp; + } + const LONGLONG sourceTimestamp = std::max(0, timestamp - firstSourceTimestamp); + const double sourceTimestampMs = static_cast(sourceTimestamp) / 10'000.0; + double mappedOutputMs = sourceTimestampMs; + if (!sourceToOutputMs(timelineSegments_, mappedOutputMs, mappedOutputMs)) { + continue; + } + const LONGLONG outputTimestamp = + static_cast(std::llround(mappedOutputMs * 10'000.0)); + if ( + (timelineSegments_.empty() && outputTimestamp >= maxOutputTimestamp) || + (!timelineSegments_.empty() && outputTimestamp > maxOutputTimestamp + outputFrameDuration) + ) { + break; + } + const LONGLONG sampleWindowEnd = outputTimestamp + (outputFrameDuration / 2); + const bool timelineSampleNearEnd = + !timelineSegments_.empty() && + sourceTimestampMs >= + (timelineSegments_.back().sourceEndMs - std::max(1.0, 2000.0 / options_.fps)); + const UINT expectedFramesForSample = timelineSegments_.empty() + ? expectedOutputFrames + : timelineSampleNearEnd + ? expectedOutputFrames + : std::min( + expectedOutputFrames, + static_cast(std::floor( + (static_cast(outputTimestamp) / 10'000'000.0) * + static_cast(options_.fps))) + 1); + if (sampleWindowEnd < nextOutputTimestamp) { + continue; + } + + ComPtr buffer; + hr = sample->GetBufferByIndex(0, &buffer); + if (!succeeded(hr, "IMFSample::GetBufferByIndex")) { + return false; + } + + ComPtr dxgiBuffer; + hr = buffer.As(&dxgiBuffer); + if (FAILED(hr)) { + ID3D11Texture2D* uploadedSourceTexture = nullptr; + if (options_.preferHighPerformanceAdapter) { + if (!uploadBgraBufferToTexture( + buffer.Get(), + sourceUploadBgraTexture_, + sourceWidth_, + sourceHeight_, + "Create source BGRA upload texture")) { + return false; + } + uploadedSourceTexture = sourceUploadBgraTexture_.Get(); + } else { + if (!uploadNv12BufferToTexture( + buffer.Get(), + sourceUploadTexture_, + sourceWidth_, + sourceHeight_, + "Create source NV12 upload texture")) { + return false; + } + uploadedSourceTexture = sourceUploadTexture_.Get(); + } + + while ( + frameIndex < maxFrames && + frameIndex < expectedOutputFrames && + ( + timelineSegments_.empty() + ? (nextOutputTimestamp <= sampleWindowEnd && + nextOutputTimestamp < maxOutputTimestamp) + : (frameIndex < expectedFramesForSample) + ) + ) { + const LONGLONG overlayTimestamp = timelineSegments_.empty() + ? nextOutputTimestamp + : static_cast(std::llround( + outputToSourceMs( + timelineSegments_, + static_cast(nextOutputTimestamp) / 10'000.0) * + 10'000.0)); + const Timer processTimer; + if (options_.shaderComposite) { + if (!convertSourceTextureToBgra(uploadedSourceTexture, 0, frameIndex)) { + return false; + } + if (!readWebcamFrameForTimestamp(overlayTimestamp, frameIndex)) { + return false; + } + if (!renderShaderComposite(overlayTimestamp)) { + return false; + } + if (!convertBgraToNv12(frameIndex)) { + return false; + } + } else if (!convertSourceTextureToNv12(uploadedSourceTexture, 0, frameIndex)) { + return false; + } + processMs += processTimer.elapsedMs(); + + const Timer writeTimer; + lastOutputTimestamp = nextOutputTimestamp; + if (!writeFrame(frameIndex, nextOutputTimestamp, outputFrameDuration)) { + return false; + } + writeMs += writeTimer.elapsedMs(); + frameIndex++; + nextOutputTimestamp += outputFrameDuration; + emitProgress(std::min(frameIndex, expectedOutputFrames), expectedOutputFrames); + } + + continue; + } + sawDxgiSurface = true; + + ComPtr sourceTexture; + hr = dxgiBuffer->GetResource(IID_PPV_ARGS(&sourceTexture)); + if (!succeeded(hr, "IMFDXGIBuffer::GetResource")) { + return false; + } + UINT subresourceIndex = 0; + dxgiBuffer->GetSubresourceIndex(&subresourceIndex); + + while ( + frameIndex < maxFrames && + frameIndex < expectedOutputFrames && + ( + timelineSegments_.empty() + ? (nextOutputTimestamp <= sampleWindowEnd && + nextOutputTimestamp < maxOutputTimestamp) + : (frameIndex < expectedFramesForSample) + ) + ) { + const LONGLONG overlayTimestamp = timelineSegments_.empty() + ? nextOutputTimestamp + : static_cast(std::llround( + outputToSourceMs( + timelineSegments_, + static_cast(nextOutputTimestamp) / 10'000.0) * + 10'000.0)); + const Timer processTimer; + if (options_.shaderComposite) { + if (!convertSourceTextureToBgra(sourceTexture.Get(), subresourceIndex, frameIndex)) { + return false; + } + if (!readWebcamFrameForTimestamp(overlayTimestamp, frameIndex)) { + return false; + } + if (!renderShaderComposite(overlayTimestamp)) { + return false; + } + if (!convertBgraToNv12(frameIndex)) { + return false; + } + } else { + if (!convertSourceTextureToNv12(sourceTexture.Get(), subresourceIndex, frameIndex)) { + return false; + } + } + processMs += processTimer.elapsedMs(); + + const Timer writeTimer; + lastOutputTimestamp = nextOutputTimestamp; + if (!writeFrame(frameIndex, nextOutputTimestamp, outputFrameDuration)) { + return false; + } + writeMs += writeTimer.elapsedMs(); + ++frameIndex; + emitProgress(std::min(frameIndex, expectedOutputFrames), expectedOutputFrames); + nextOutputTimestamp += outputFrameDuration; + } + } + emitProgress(std::min(frameIndex, expectedOutputFrames), expectedOutputFrames, true); + + const Timer finalizeTimer; + const bool finalized = options_.nvencSdk ? finalizeNvencSdk() : SUCCEEDED(sinkWriter_->Finalize()); + const double finalizeMs = finalizeTimer.elapsedMs(); + if (!finalized) { + if (!options_.nvencSdk) { + std::cerr << "ERROR: IMFSinkWriter::Finalize failed" << std::endl; + } + return false; + } + + const double totalMs = totalTimer.elapsedMs(); + const double mediaMs = + (static_cast(lastOutputTimestamp) / 10'000.0) + + (1000.0 / static_cast(options_.fps)); + const double realtime = mediaMs / totalMs; + std::cout + << "{" + << "\"success\":true," + << "\"mode\":\"source-video\"," + << "\"shaderComposite\":" << (options_.shaderComposite ? "true" : "false") << "," + << "\"webcamOverlay\":" << (hasWebcamOverlay() ? "true" : "false") << "," + << "\"cursorOverlay\":" << (hasCursorOverlay() ? "true" : "false") << "," + << "\"cursorAtlas\":" << (hasCursorAtlas_ ? "true" : "false") << "," + << "\"zoomOverlay\":" << (hasZoomOverlay() ? "true" : "false") << "," + << "\"timelineMap\":" << (!timelineSegments_.empty() ? "true" : "false") << "," + << "\"timelineSegments\":" << timelineSegments_.size() << "," + << "\"gpuDecodeSurface\":" << (sawDxgiSurface ? "true" : "false") << "," + << "\"sourceWidth\":" << sourceWidth_ << "," + << "\"sourceHeight\":" << sourceHeight_ << "," + << "\"width\":" << options_.width << "," + << "\"height\":" << options_.height << "," + << "\"fps\":" << options_.fps << "," + << "\"surfacePoolSize\":" << options_.surfacePoolSize << "," + << "\"adapterIndex\":" << selectedAdapterIndex_ << "," + << "\"adapterVendorId\":" << selectedAdapterVendorId_ << "," + << "\"adapterDeviceId\":" << selectedAdapterDeviceId_ << "," + << "\"adapterDedicatedVideoMemoryMB\":" << selectedAdapterDedicatedVideoMemoryMB_ << "," + << "\"frames\":" << frameIndex << "," + << "\"mediaMs\":" << mediaMs << "," + << "\"initializeMs\":" << initializeMs_ << "," + << "\"initCoInitializeMs\":" << initCoInitializeMs_ << "," + << "\"initMfStartupMs\":" << initMfStartupMs_ << "," + << "\"initD3DDeviceMs\":" << initD3DDeviceMs_ << "," + << "\"initSourceReaderMs\":" << initSourceReaderMs_ << "," + << "\"initWebcamReaderMs\":" << initWebcamReaderMs_ << "," + << "\"initVideoProcessorMs\":" << initVideoProcessorMs_ << "," + << "\"initTexturesMs\":" << initTexturesMs_ << "," + << "\"initShaderPipelineMs\":" << initShaderPipelineMs_ << "," + << "\"initSinkWriterMs\":" << initSinkWriterMs_ << "," + << "\"encoderBackend\":\"" << (options_.nvencSdk ? "nvenc-sdk-d3d11" : "media-foundation") << "\"," + << "\"encoderTuningApplied\":" << (encoderTuningApplied_ ? "true" : "false") << "," + << "\"nvencOutputBytes\":" << nvencOutputBytes_ << "," + << "\"totalMs\":" << totalMs << "," + << "\"readMs\":" << readMs << "," + << "\"videoProcessMs\":" << processMs << "," + << "\"writeSampleMs\":" << writeMs << "," + << "\"finalizeMs\":" << finalizeMs << "," + << "\"realtimeMultiplier\":" << realtime + << "}" << std::endl; + return true; + } + + Options options_; + UINT sourceWidth_ = 1920; + UINT sourceHeight_ = 1080; + double initializeMs_ = 0.0; + double initCoInitializeMs_ = 0.0; + double initMfStartupMs_ = 0.0; + double initD3DDeviceMs_ = 0.0; + double initSourceReaderMs_ = 0.0; + double initWebcamReaderMs_ = 0.0; + double initVideoProcessorMs_ = 0.0; + double initTexturesMs_ = 0.0; + double initShaderPipelineMs_ = 0.0; + double initSinkWriterMs_ = 0.0; + UINT selectedAdapterVendorId_ = 0; + UINT selectedAdapterDeviceId_ = 0; + UINT64 selectedAdapterDedicatedVideoMemoryMB_ = 0; + int selectedAdapterIndex_ = -1; + bool coInitialized_ = false; + bool mfStarted_ = false; + DWORD streamIndex_ = 0; + ComPtr device_; + ComPtr deviceContext_; + ComPtr videoDevice_; + ComPtr videoContext_; + ComPtr deviceManager_; + ComPtr sourceReader_; + ComPtr webcamReader_; + ComPtr videoProcessorEnumerator_; + ComPtr videoProcessor_; + ComPtr bgraVideoProcessorEnumerator_; + ComPtr bgraVideoProcessor_; + ComPtr webcamVideoProcessorEnumerator_; + ComPtr webcamVideoProcessor_; + ComPtr bgraTexture_; + ComPtr bgraRenderTargetView_; + ComPtr bgraInputView_; + ComPtr contentTexture_; + ComPtr contentOutputView_; + ComPtr contentShaderResourceView_; + ComPtr webcamTexture_; + ComPtr webcamOutputView_; + ComPtr webcamShaderResourceView_; + ComPtr backgroundShaderResourceView_; + ComPtr cursorAtlasShaderResourceView_; + ComPtr vertexShader_; + ComPtr pixelShader_; + ComPtr samplerState_; + ComPtr compositorConstants_; + UINT backgroundImageWidth_ = 1; + UINT backgroundImageHeight_ = 1; + bool hasBackgroundImage_ = false; + UINT cursorAtlasWidth_ = 1; + UINT cursorAtlasHeight_ = 1; + bool hasCursorAtlas_ = false; + std::array cursorAtlasEntries_; + UINT webcamWidth_ = 640; + UINT webcamHeight_ = 480; + bool webcamFrameReady_ = false; + bool webcamEnded_ = false; + LONGLONG webcamFirstTimestamp_ = -1; + LONGLONG pendingWebcamTimestamp_ = 0; + ComPtr pendingWebcamSample_; + std::vector cursorSamples_; + std::vector zoomSamples_; + ComPtr sourceUploadTexture_; + ComPtr webcamUploadTexture_; + ComPtr sourceUploadBgraTexture_; + ComPtr webcamUploadBgraTexture_; + std::vector uploadScratch_; + std::vector uploadPaddedScratch_; + std::vector timelineSegments_; + std::vector> nv12Textures_; + std::vector> nv12OutputViews_; + std::vector> bgraNv12OutputViews_; + ComPtr sinkWriter_; +#ifdef RECORDLY_GPU_EXPORT_ENABLE_NVENC_SDK + std::unique_ptr nvencEncoder_; +#endif + FILE* nvencOutputFile_ = nullptr; + uint64_t nvencOutputBytes_ = 0; + bool encoderTuningApplied_ = false; +}; + +} // namespace + +int wmain(int argc, wchar_t** argv) { + const Options options = parseOptions(argc, argv); + GpuProbe probe; + if (!probe.initialize(options)) { + std::cerr << "{\"success\":false,\"phase\":\"initialize\"}" << std::endl; + return 1; + } + if (!probe.run()) { + std::cerr << "{\"success\":false,\"phase\":\"run\"}" << std::endl; + return 1; + } + return 0; +} diff --git a/electron/native/nvidia-cuda-compositor/CMakeLists.txt b/electron/native/nvidia-cuda-compositor/CMakeLists.txt new file mode 100644 index 000000000..e79d9630c --- /dev/null +++ b/electron/native/nvidia-cuda-compositor/CMakeLists.txt @@ -0,0 +1,47 @@ +cmake_minimum_required(VERSION 3.24) + +project(recordly_nvidia_cuda_compositor LANGUAGES CXX CUDA) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CUDA_STANDARD 17) +set(CMAKE_CUDA_STANDARD_REQUIRED ON) + +set(RECORDLY_NVIDIA_VIDEO_CODEC_SDK_ROOT + "${CMAKE_CURRENT_LIST_DIR}/../../../.tmp/video-sdk-samples" + CACHE PATH + "Path to the NVIDIA Video Codec SDK samples checkout" +) +set(NVIDIA_SAMPLES_DIR "${RECORDLY_NVIDIA_VIDEO_CODEC_SDK_ROOT}/Samples") +set(NVCODEC_DIR "${NVIDIA_SAMPLES_DIR}/NvCodec") + +if(NOT EXISTS "${NVCODEC_DIR}") + message(FATAL_ERROR "NVIDIA Video Codec SDK NvCodec directory not found: ${NVCODEC_DIR}. Set RECORDLY_NVIDIA_VIDEO_CODEC_SDK_ROOT.") +endif() + +add_executable(recordly-nvidia-cuda-compositor + src/main.cu + "${NVCODEC_DIR}/NvDecoder/NvDecoder.cpp" + "${NVCODEC_DIR}/NvEncoder/NvEncoder.cpp" + "${NVCODEC_DIR}/NvEncoder/NvEncoderCuda.cpp" +) + +target_include_directories(recordly-nvidia-cuda-compositor PRIVATE + "${NVIDIA_SAMPLES_DIR}" + "${NVCODEC_DIR}" +) + +target_compile_definitions(recordly-nvidia-cuda-compositor PRIVATE + NOMINMAX + WIN32_LEAN_AND_MEAN +) + +find_package(CUDAToolkit REQUIRED) +target_link_libraries(recordly-nvidia-cuda-compositor PRIVATE + CUDA::cuda_driver + "${NVCODEC_DIR}/Lib/x64/nvcuvid.lib" +) + +set_target_properties(recordly-nvidia-cuda-compositor PROPERTIES + CUDA_SEPARABLE_COMPILATION OFF +) diff --git a/electron/native/nvidia-cuda-compositor/render-tahoe-cursor-atlas.cjs b/electron/native/nvidia-cuda-compositor/render-tahoe-cursor-atlas.cjs new file mode 100644 index 000000000..96c72c4b1 --- /dev/null +++ b/electron/native/nvidia-cuda-compositor/render-tahoe-cursor-atlas.cjs @@ -0,0 +1,145 @@ +const { app, BrowserWindow, ipcMain } = require("electron"); +const path = require("node:path"); + +const drawHeight = 256; +const padding = 2; +const cursorTypes = [ + "arrow", + "text", + "pointer", + "crosshair", + "open-hand", + "closed-hand", + "resize-ew", + "resize-ns", + "not-allowed", +]; +const tahoeAssets = { + arrow: ["pointer-1__14-6.svg", 0.14, 0.06], + text: ["ibeam-1__50-44.svg", 0.5, 0.44], + pointer: ["pointinghand-1__40-10.svg", 0.4, 0.1], + crosshair: ["crosshair-1__50-50.svg", 0.5, 0.5], + "open-hand": ["openhand-1__55-57.svg", 0.55, 0.57], + "closed-hand": ["closedhand-1__50-46.svg", 0.5, 0.46], + "resize-ew": ["resizeeastwest-1__50-50.svg", 0.5, 0.5], + "resize-ns": ["resizenorthsouth-1__50-49.svg", 0.5, 0.49], + "not-allowed": ["notallowed-1__23-0.svg", 0.23, 0], +}; + +function arg(name, fallback = "") { + const index = process.argv.indexOf(name); + return index >= 0 && index + 1 < process.argv.length ? process.argv[index + 1] : fallback; +} + +const repoRoot = arg("--repo-root"); +const atlasRgbaPath = arg("--output-rgba"); +const atlasMetadataPath = arg("--output-metadata"); + +if (!repoRoot || !atlasRgbaPath || !atlasMetadataPath) { + console.error( + "Usage: electron render-tahoe-cursor-atlas.cjs --repo-root --output-rgba --output-metadata ", + ); + process.exit(1); +} + +const assets = cursorTypes.map((type, index) => { + const [fileName, anchorX, anchorY] = tahoeAssets[type]; + return { + type, + index, + filePath: path.join(repoRoot, "src", "assets", "cursors", "tahoe", fileName), + anchorX, + anchorY, + }; +}); + +app.disableHardwareAcceleration(); + +app.whenReady().then(async () => { + const window = new BrowserWindow({ + show: false, + width: 1, + height: 1, + webPreferences: { + nodeIntegration: true, + contextIsolation: false, + backgroundThrottling: false, + }, + }); + + ipcMain.once("atlas-ready", (_event, result) => { + console.log(JSON.stringify(result)); + app.quit(); + }); + + const html = ` + + +`; + + await window.loadURL("data:text/html;charset=utf-8," + encodeURIComponent(html)); +}); diff --git a/electron/native/nvidia-cuda-compositor/run-mp4-pipeline.mjs b/electron/native/nvidia-cuda-compositor/run-mp4-pipeline.mjs new file mode 100644 index 000000000..43324b051 --- /dev/null +++ b/electron/native/nvidia-cuda-compositor/run-mp4-pipeline.mjs @@ -0,0 +1,1600 @@ +import { spawn, spawnSync } from "node:child_process"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import os from "node:os"; +import { basename, dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const scriptDir = dirname(fileURLToPath(import.meta.url)); +const require = createRequire(import.meta.url); +const repoRoot = resolve(scriptDir, "..", "..", ".."); +const unpackAsarPath = (candidate) => candidate.replace(/\.asar([/\\])/, ".asar.unpacked$1"); + +function resolvePackageBinary(moduleName) { + try { + const moduleExports = require(moduleName); + const candidate = + typeof moduleExports === "string" + ? moduleExports + : typeof moduleExports?.path === "string" + ? moduleExports.path + : typeof moduleExports?.default === "string" + ? moduleExports.default + : typeof moduleExports?.default?.path === "string" + ? moduleExports.default.path + : ""; + if (!candidate) { + return ""; + } + + const unpacked = unpackAsarPath(candidate); + return existsSync(unpacked) ? unpacked : candidate; + } catch { + return ""; + } +} + +function resolveToolCommand(envNames, moduleName, fallbackName) { + for (const envName of envNames) { + const configured = process.env[envName]; + if (configured) { + return configured; + } + } + + return resolvePackageBinary(moduleName) || fallbackName; +} + +const ffmpegCommand = resolveToolCommand(["RECORDLY_FFMPEG_EXE"], "ffmpeg-static", "ffmpeg"); +const ffprobeCommand = resolveToolCommand(["RECORDLY_FFPROBE_EXE"], "ffprobe-static", "ffprobe"); + +const cursorTypes = [ + "arrow", + "text", + "pointer", + "crosshair", + "open-hand", + "closed-hand", + "resize-ew", + "resize-ns", + "not-allowed", +]; +const cursorTypeIndexes = new Map(cursorTypes.map((type, index) => [type, index])); + +function fail(message) { + throw new Error(message); +} + +function getArg(name, fallback = "") { + const index = process.argv.indexOf(name); + if (index === -1) { + return fallback; + } + if (index + 1 >= process.argv.length) { + fail(`Missing value for ${name}`); + } + return process.argv[index + 1]; +} + +function getNumberArg(name, fallback) { + const value = getArg(name, ""); + if (!value) { + return fallback; + } + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed <= 0) { + fail(`Invalid ${name}: ${value}`); + } + return parsed; +} + +function getNonNegativeNumberArg(name, fallback) { + const value = getArg(name, ""); + if (!value) { + return fallback; + } + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed < 0) { + fail(`Invalid ${name}: ${value}`); + } + return parsed; +} + +function getFiniteNumberArg(name, fallback) { + const value = getArg(name, ""); + if (!value) { + return fallback; + } + const parsed = Number(value); + if (!Number.isFinite(parsed)) { + fail(`Invalid ${name}: ${value}`); + } + return parsed; +} + +function hasArg(name) { + return process.argv.includes(name); +} + +function shouldRaiseChildPriority() { + return process.env.RECORDLY_NVIDIA_CUDA_EXPORT_HIGH_PRIORITY !== "0"; +} + +function raiseChildPriority(child, label) { + if (!shouldRaiseChildPriority() || !child.pid) { + return false; + } + + try { + os.setPriority(child.pid, os.constants.priority.PRIORITY_HIGH); + return true; + } catch (error) { + console.warn(`[nvidia-cuda-export] Failed to raise ${label} priority: ${error}`); + return false; + } +} + +function run(command, args, options = {}) { + const startedAt = performance.now(); + const result = spawnSync(command, args, { + encoding: "utf8", + maxBuffer: 64 * 1024 * 1024, + windowsHide: true, + ...options, + }); + const elapsedMs = performance.now() - startedAt; + if (result.error) { + fail(`${command} failed to start: ${result.error.message}`); + } + if (result.status !== 0) { + const stderr = result.stderr?.trim(); + const stdout = result.stdout?.trim(); + fail( + `${command} exited with ${result.status}` + + (stderr ? `\nSTDERR:\n${stderr}` : "") + + (stdout ? `\nSTDOUT:\n${stdout}` : ""), + ); + } + return { + elapsedMs, + stdout: result.stdout ?? "", + stderr: result.stderr ?? "", + }; +} + +function runAsync(command, args, options = {}) { + const startedAt = performance.now(); + + return new Promise((resolvePromise, rejectPromise) => { + const child = spawn(command, args, { + windowsHide: true, + stdio: ["ignore", "pipe", "pipe"], + ...options, + }); + let stdout = ""; + let stderr = ""; + + child.stdout.on("data", (chunk) => { + stdout += chunk.toString(); + }); + child.stderr.on("data", (chunk) => { + stderr += chunk.toString(); + }); + child.once("error", (error) => { + rejectPromise(new Error(`${command} failed to start: ${error.message}`)); + }); + child.once("close", (status, signal) => { + const elapsedMs = performance.now() - startedAt; + if (status !== 0) { + const suffix = signal ? ` (signal ${signal})` : ""; + rejectPromise( + new Error( + `${command} exited with ${status}${suffix}` + + (stderr.trim() ? `\nSTDERR:\n${stderr.trim()}` : "") + + (stdout.trim() ? `\nSTDOUT:\n${stdout.trim()}` : ""), + ), + ); + return; + } + + resolvePromise({ elapsedMs, stdout, stderr }); + }); + }); +} + +function parseProgressTimeSeconds(text) { + const matches = [...text.matchAll(/\btime=(\d+):(\d+):(\d+(?:\.\d+)?)/g)]; + const latest = matches.at(-1); + if (!latest) { + return null; + } + + const hours = Number(latest[1]); + const minutes = Number(latest[2]); + const seconds = Number(latest[3]); + if (![hours, minutes, seconds].every(Number.isFinite)) { + return null; + } + + return hours * 3600 + minutes * 60 + seconds; +} + +function runWithProgress(command, args, progressOptions, options = {}) { + const startedAt = performance.now(); + const { totalDurationSec, totalFrames, startPercentage, endPercentage, stage } = + progressOptions; + + return new Promise((resolvePromise, rejectPromise) => { + const child = spawn(command, args, { + windowsHide: true, + ...options, + }); + let stdout = ""; + let stderr = ""; + let lastEmittedPercentage = startPercentage; + + const maybeEmitProgress = (text) => { + if (!Number.isFinite(totalDurationSec) || totalDurationSec <= 0) { + return; + } + const elapsedSec = parseProgressTimeSeconds(text); + if (elapsedSec === null) { + return; + } + const ratio = Math.max(0, Math.min(1, elapsedSec / totalDurationSec)); + const percentage = startPercentage + (endPercentage - startPercentage) * ratio; + if (percentage >= lastEmittedPercentage + 0.25 || percentage >= endPercentage) { + lastEmittedPercentage = Math.min(endPercentage, percentage); + emitPreparationProgress(totalFrames, lastEmittedPercentage, stage); + } + }; + + child.stdout.on("data", (chunk) => { + stdout += chunk.toString(); + }); + child.stderr.on("data", (chunk) => { + const text = chunk.toString(); + stderr += text; + maybeEmitProgress(text); + }); + child.once("error", (error) => { + rejectPromise(new Error(`${command} failed to start: ${error.message}`)); + }); + child.once("close", (status, signal) => { + const elapsedMs = performance.now() - startedAt; + if (status !== 0) { + const suffix = signal ? ` (signal ${signal})` : ""; + rejectPromise( + new Error( + `${command} exited with ${status}${suffix}` + + (stderr.trim() ? `\nSTDERR:\n${stderr.trim()}` : "") + + (stdout.trim() ? `\nSTDOUT:\n${stdout.trim()}` : ""), + ), + ); + return; + } + + emitPreparationProgress(totalFrames, endPercentage, stage); + resolvePromise({ elapsedMs, stdout, stderr }); + }); + }); +} + +function emitPreparationProgress(totalFrames, percentage, stage) { + const progressStage = stage ?? "preparing"; + const finalizing = progressStage === "finalizing"; + const payload = { + currentFrame: finalizing ? Math.max(1, Math.floor(totalFrames)) : 0, + totalFrames: Math.max(1, Math.floor(totalFrames)), + percentage: Number(Math.min(99, Math.max(0, percentage)).toFixed(2)), + stage: progressStage, + }; + process.stderr.write(`PROGRESS ${JSON.stringify(payload)}\n`); +} + +function parseFfmpegStatsFrameCount(stderr) { + const matches = [...String(stderr ?? "").matchAll(/frame=\s*(\d+)/g)]; + if (!matches.length) { + return 0; + } + const frameCount = Number(matches[matches.length - 1][1]); + return Number.isFinite(frameCount) && frameCount > 0 ? Math.floor(frameCount) : 0; +} + +const gpuSampleFields = [ + "timestamp", + "temperature.gpu", + "power.draw", + "pstate", + "utilization.gpu", + "utilization.decoder", + "utilization.encoder", + "clocks.sm", + "clocks.mem", +]; + +const gpuThrottleReasonFields = [ + { key: "gpuIdle", query: "clocks_throttle_reasons.gpu_idle" }, + { + key: "applicationsClocksSetting", + query: "clocks_throttle_reasons.applications_clocks_setting", + }, + { key: "swPowerCap", query: "clocks_throttle_reasons.sw_power_cap" }, + { key: "hwSlowdown", query: "clocks_throttle_reasons.hw_slowdown" }, + { key: "hwThermalSlowdown", query: "clocks_throttle_reasons.hw_thermal_slowdown" }, + { + key: "hwPowerBrakeSlowdown", + query: "clocks_throttle_reasons.hw_power_brake_slowdown", + }, + { key: "swThermalSlowdown", query: "clocks_throttle_reasons.sw_thermal_slowdown" }, + { key: "syncBoost", query: "clocks_throttle_reasons.sync_boost" }, +]; + +function runNvidiaSmiGpuQuery(fields) { + const result = spawnSync( + "nvidia-smi", + [`--query-gpu=${fields.join(",")}`, "--format=csv,noheader,nounits"], + { encoding: "utf8", windowsHide: true }, + ); + if (result.status !== 0 || !result.stdout.trim()) { + return null; + } + return result.stdout + .trim() + .split(",") + .map((part) => part.trim()); +} + +function parseNvidiaSmiNumber(value) { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; +} + +function parseNvidiaSmiActiveFlag(value) { + if (/^active$/i.test(value)) { + return true; + } + if (/^not active$/i.test(value)) { + return false; + } + return null; +} + +function sampleGpu() { + const throttleFields = [ + "clocks_throttle_reasons.active", + ...gpuThrottleReasonFields.map((field) => field.query), + ]; + const parts = + runNvidiaSmiGpuQuery([...gpuSampleFields, ...throttleFields]) ?? + runNvidiaSmiGpuQuery(gpuSampleFields); + if (!parts) { + return null; + } + + const sample = { + timestamp: parts[0], + temperatureC: parseNvidiaSmiNumber(parts[1]), + powerW: parseNvidiaSmiNumber(parts[2]), + pstate: parts[3], + gpuUtilizationPct: parseNvidiaSmiNumber(parts[4]), + decoderUtilizationPct: parseNvidiaSmiNumber(parts[5]), + encoderUtilizationPct: parseNvidiaSmiNumber(parts[6]), + smClockMhz: parseNvidiaSmiNumber(parts[7]), + memoryClockMhz: parseNvidiaSmiNumber(parts[8]), + }; + if (parts.length >= gpuSampleFields.length + throttleFields.length) { + const throttleStart = gpuSampleFields.length; + const clockThrottleReasons = {}; + for (const [index, field] of gpuThrottleReasonFields.entries()) { + const active = parseNvidiaSmiActiveFlag(parts[throttleStart + 1 + index]); + if (active !== null) { + clockThrottleReasons[field.key] = active; + } + } + sample.clockThrottleReasonsActiveMask = parts[throttleStart]; + sample.clockThrottleReasons = clockThrottleReasons; + sample.activeClockThrottleReasons = Object.entries(clockThrottleReasons) + .filter(([, active]) => active) + .map(([key]) => key); + } + return sample; +} + +function summarizeGpuSamples(samples) { + if (!samples.length) { + return null; + } + const numeric = [ + "temperatureC", + "powerW", + "gpuUtilizationPct", + "decoderUtilizationPct", + "encoderUtilizationPct", + "smClockMhz", + "memoryClockMhz", + ]; + const summary = { + samples: samples.length, + pstateValues: [...new Set(samples.map((sample) => sample.pstate))], + }; + for (const key of numeric) { + const values = samples.map((sample) => sample[key]).filter(Number.isFinite); + if (!values.length) { + continue; + } + summary[key] = { + min: Math.min(...values), + max: Math.max(...values), + avg: Number((values.reduce((sum, value) => sum + value, 0) / values.length).toFixed(2)), + }; + } + const activeMasks = [ + ...new Set( + samples + .map((sample) => sample.clockThrottleReasonsActiveMask) + .filter((mask) => typeof mask === "string" && mask.length > 0), + ), + ]; + if (activeMasks.length) { + summary.clockThrottleReasonsActiveMasks = activeMasks; + } + const throttleReasonCounts = {}; + for (const sample of samples) { + for (const reason of sample.activeClockThrottleReasons ?? []) { + throttleReasonCounts[reason] = (throttleReasonCounts[reason] ?? 0) + 1; + } + } + if (Object.keys(throttleReasonCounts).length) { + summary.clockThrottleReasonCounts = throttleReasonCounts; + } + return summary; +} + +function cursorBounceScale(interactionType, ageMs, durationMs = 180) { + if (!["click", "double-click", "right-click", "middle-click"].includes(interactionType)) { + return 1; + } + if (ageMs < 0 || ageMs > durationMs) { + return 1; + } + const progress = 1 - ageMs / durationMs; + return Math.max(0.72, 1 - Math.sin(progress * Math.PI) * 0.08); +} + +function latestClickSample(samples, sampleIndex) { + for (let index = sampleIndex; index >= 0; index -= 1) { + const sample = samples[index]; + if ( + ["click", "double-click", "right-click", "middle-click"].includes( + sample?.interactionType, + ) + ) { + return sample; + } + } + return null; +} + +function writeCursorSamples(cursorPayload, outputPath) { + const samples = Array.isArray(cursorPayload.samples) ? cursorPayload.samples : []; + const cursorLines = samples + .map((sample, index) => { + if ( + !Number.isFinite(sample?.timeMs) || + !Number.isFinite(sample?.cx) || + !Number.isFinite(sample?.cy) + ) { + return null; + } + const clickSample = latestClickSample(samples, index); + const bounceScale = Number.isFinite(sample.bounceScale) + ? sample.bounceScale + : clickSample + ? cursorBounceScale( + clickSample.interactionType, + sample.timeMs - clickSample.timeMs, + ) + : 1; + return [ + sample.timeMs, + sample.cx, + sample.cy, + cursorTypeIndexes.get(sample.cursorType) ?? + (Number.isFinite(sample.cursorTypeIndex) + ? Math.max(0, Math.min(8, Math.round(sample.cursorTypeIndex))) + : 0), + Number(bounceScale.toFixed(4)), + sample.visible === false ? 0 : 1, + ].join("\t"); + }) + .filter(Boolean) + .join("\n"); + writeFileSync(outputPath, cursorLines ? `${cursorLines}\n` : ""); + return samples.length; +} + +function renderTahoeCursorAtlas(workDir) { + const rgbaPath = join(workDir, "tahoe-cursor-atlas.rgba"); + const metadataPath = join(workDir, "tahoe-cursor-atlas.tsv"); + const electronPath = require("electron"); + const render = run( + electronPath, + [ + join(scriptDir, "render-tahoe-cursor-atlas.cjs"), + "--repo-root", + repoRoot, + "--output-rgba", + rgbaPath, + "--output-metadata", + metadataPath, + ], + { + env: { + ...process.env, + ELECTRON_DISABLE_SECURITY_WARNINGS: "1", + }, + }, + ); + const resultLine = render.stdout.trim().split(/\r?\n/).filter(Boolean).pop(); + if (!resultLine) { + fail("Cursor atlas renderer did not report a result"); + } + const result = JSON.parse(resultLine); + if (result.error) { + fail(`Cursor atlas renderer failed: ${result.error}`); + } + return { + rgbaPath, + metadataPath, + width: result.width, + height: result.height, + entries: result.entries, + elapsedMs: render.elapsedMs, + }; +} + +function prepareExternalCursorAtlas(workDir, pngPath, metadataPath) { + const startedAt = performance.now(); + const resolvedPngPath = resolve(pngPath); + const resolvedMetadataPath = resolve(metadataPath); + if (!existsSync(resolvedPngPath)) { + fail(`Cursor atlas PNG does not exist: ${resolvedPngPath}`); + } + if (!existsSync(resolvedMetadataPath)) { + fail(`Cursor atlas metadata does not exist: ${resolvedMetadataPath}`); + } + + const json = ffprobeJson([ + "-select_streams", + "v:0", + "-show_entries", + "stream=width,height", + resolvedPngPath, + ]); + const stream = json.streams?.[0]; + const width = Number(stream?.width); + const height = Number(stream?.height); + if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) { + fail(`Invalid cursor atlas dimensions: ${resolvedPngPath}`); + } + + const rgbaPath = join(workDir, "external-cursor-atlas.rgba"); + run(ffmpegCommand, [ + "-y", + "-hide_banner", + "-loglevel", + "error", + "-i", + resolvedPngPath, + "-f", + "rawvideo", + "-pix_fmt", + "rgba", + rgbaPath, + ]); + const entries = readFileSync(resolvedMetadataPath, "utf8") + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean).length; + if (entries === 0) { + fail(`Cursor atlas metadata is empty: ${resolvedMetadataPath}`); + } + + return { + rgbaPath, + metadataPath: resolvedMetadataPath, + width, + height, + entries, + elapsedMs: performance.now() - startedAt, + }; +} + +async function runWithGpuMonitor(command, args, sampleIntervalMs) { + const samples = []; + const startedAt = performance.now(); + const shouldSampleGpu = Number.isFinite(sampleIntervalMs) && sampleIntervalMs > 0; + let stdout = ""; + let stderr = ""; + + const child = spawn(command, args, { + windowsHide: true, + stdio: ["ignore", "pipe", "pipe"], + }); + const priorityBoosted = raiseChildPriority(child, "native CUDA encoder"); + + const collectSample = () => { + if (!shouldSampleGpu) { + return; + } + const sample = sampleGpu(); + if (sample) { + samples.push({ + elapsedMs: Number((performance.now() - startedAt).toFixed(2)), + ...sample, + }); + } + }; + collectSample(); + const interval = shouldSampleGpu ? setInterval(collectSample, sampleIntervalMs) : null; + + child.stdout.on("data", (chunk) => { + stdout += chunk.toString(); + }); + child.stderr.on("data", (chunk) => { + const text = chunk.toString(); + stderr += text; + process.stderr.write(text); + }); + + const status = await new Promise((resolve, reject) => { + child.on("error", reject); + child.on("close", resolve); + }); + if (interval) { + clearInterval(interval); + } + collectSample(); + + const elapsedMs = performance.now() - startedAt; + if (status !== 0) { + fail( + `${command} exited with ${status}` + + (stderr.trim() ? `\nSTDERR:\n${stderr.trim()}` : "") + + (stdout.trim() ? `\nSTDOUT:\n${stdout.trim()}` : ""), + ); + } + return { + elapsedMs, + stdout, + stderr, + gpuSamples: samples, + gpuSummary: summarizeGpuSamples(samples), + priorityBoosted, + }; +} + +function ffprobeJson(args) { + const result = run(ffprobeCommand, ["-v", "error", ...args, "-of", "json"]); + return JSON.parse(result.stdout); +} + +async function ffprobeCsvAsync(args) { + const result = await runAsync(ffprobeCommand, ["-v", "error", ...args, "-of", "csv=p=0"]); + return result.stdout; +} + +function getVideoInfo(inputPath) { + const json = ffprobeJson([ + "-select_streams", + "v:0", + "-show_entries", + "stream=codec_name,width,height,duration,avg_frame_rate,nb_frames", + inputPath, + ]); + let stream = json.streams?.[0]; + if (!stream) { + fail(`No video stream found in ${inputPath}`); + } + if (stream.codec_name !== "h264") { + fail(`The NVIDIA CUDA compositor currently expects H.264 input, got ${stream.codec_name}`); + } + const durationSec = Number(stream.duration); + if (!Number.isFinite(durationSec) || durationSec <= 0) { + fail("ffprobe did not return a valid video duration"); + } + let sourceFrames = Number(stream.nb_frames); + if (!Number.isFinite(sourceFrames) || sourceFrames <= 0) { + const countedJson = ffprobeJson([ + "-count_frames", + "-select_streams", + "v:0", + "-show_entries", + "stream=codec_name,width,height,duration,avg_frame_rate,nb_frames,nb_read_frames", + inputPath, + ]); + stream = countedJson.streams?.[0] ?? stream; + sourceFrames = Number(stream.nb_read_frames || stream.nb_frames); + } + if (!Number.isFinite(sourceFrames) || sourceFrames <= 0) { + fail("ffprobe did not return a valid source frame count"); + } + return { + codec: stream.codec_name, + width: Number(stream.width), + height: Number(stream.height), + durationSec, + sourceFrames, + avgFrameRate: stream.avg_frame_rate, + }; +} + +function normalizeMonotonicTimestamps(timestamps) { + if (timestamps.length < 2) { + return []; + } + + const first = timestamps[0]; + const normalized = timestamps + .map((value) => Math.max(0, value - first)) + .filter((value, index, values) => index === 0 || value >= values[index - 1]); + return normalized.length === timestamps.length ? normalized : []; +} + +function parseTimestampCsv(csv) { + return csv + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => { + const columns = line.split(","); + const value = columns + .map((column) => Number(column.trim())) + .find((candidate) => Number.isFinite(candidate)); + return value ?? Number.NaN; + }) + .filter((value) => Number.isFinite(value)); +} + +function readTimelineSegments(timelineMapPath) { + if (!timelineMapPath) { + return []; + } + const resolvedPath = resolve(timelineMapPath); + if (!existsSync(resolvedPath)) { + fail(`Timeline map does not exist: ${resolvedPath}`); + } + + const segments = readFileSync(resolvedPath, "utf8") + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => { + const [sourceStartMs, sourceEndMs, outputStartMs, outputEndMs, speed] = line + .split(",") + .map((value) => Number(value.trim())); + return { sourceStartMs, sourceEndMs, outputStartMs, outputEndMs, speed }; + }); + + let outputCursorMs = 0; + for (const segment of segments) { + if ( + !Number.isFinite(segment.sourceStartMs) || + !Number.isFinite(segment.sourceEndMs) || + !Number.isFinite(segment.outputStartMs) || + !Number.isFinite(segment.outputEndMs) || + !Number.isFinite(segment.speed) || + segment.sourceEndMs <= segment.sourceStartMs || + segment.outputEndMs <= segment.outputStartMs || + segment.speed <= 0 + ) { + fail(`Invalid timeline map segment in ${resolvedPath}`); + } + if (Math.abs(segment.outputStartMs - outputCursorMs) > 2) { + fail(`Timeline map output ranges must be contiguous: ${resolvedPath}`); + } + outputCursorMs = segment.outputEndMs; + } + + return segments; +} + +async function getVideoPacketPtsAsync(inputPath, durationSec) { + const csv = await ffprobeCsvAsync([ + "-select_streams", + "v:0", + "-read_intervals", + `%+${durationSec}`, + "-show_packets", + "-show_entries", + "packet=pts_time,dts_time", + inputPath, + ]); + return normalizeMonotonicTimestamps(parseTimestampCsv(csv)); +} + +async function getVideoFramePtsAsync(inputPath, durationSec) { + const csv = await ffprobeCsvAsync([ + "-select_streams", + "v:0", + "-read_intervals", + `%+${durationSec}`, + "-show_frames", + "-show_entries", + "frame=best_effort_timestamp_time", + inputPath, + ]); + return normalizeMonotonicTimestamps(parseTimestampCsv(csv)); +} + +async function writeFramePtsSidecarAsync(inputPath, durationSec, outputPath) { + const startedAt = performance.now(); + let source = "packet-pts"; + let timestamps = await getVideoPacketPtsAsync(inputPath, durationSec); + if (timestamps.length === 0) { + source = "frame-pts"; + timestamps = await getVideoFramePtsAsync(inputPath, durationSec); + } + const elapsedMs = performance.now() - startedAt; + if (timestamps.length === 0) { + return { path: null, frames: 0, elapsedMs, source: "none" }; + } + + writeFileSync(outputPath, timestamps.map((value) => value.toFixed(9)).join("\n")); + return { path: outputPath, frames: timestamps.length, elapsedMs, source }; +} + +function roundedRectMaskExpression({ x, y, width, height, radius }) { + const right = x + width; + const bottom = y + height; + const cornerRight = right - radius; + const cornerBottom = bottom - radius; + const radiusSquared = radius * radius; + const centerBand = `between(X,${x + radius},${cornerRight})*between(Y,${y},${bottom})`; + const middleBand = `between(X,${x},${right})*between(Y,${y + radius},${cornerBottom})`; + const topLeft = `lte((X-${x + radius})*(X-${x + radius})+(Y-${y + radius})*(Y-${y + radius}),${radiusSquared})*lte(X,${x + radius})*lte(Y,${y + radius})`; + const topRight = `lte((X-${cornerRight})*(X-${cornerRight})+(Y-${y + radius})*(Y-${y + radius}),${radiusSquared})*gte(X,${cornerRight})*lte(Y,${y + radius})`; + const bottomLeft = `lte((X-${x + radius})*(X-${x + radius})+(Y-${cornerBottom})*(Y-${cornerBottom}),${radiusSquared})*lte(X,${x + radius})*gte(Y,${cornerBottom})`; + const bottomRight = `lte((X-${cornerRight})*(X-${cornerRight})+(Y-${cornerBottom})*(Y-${cornerBottom}),${radiusSquared})*gte(X,${cornerRight})*gte(Y,${cornerBottom})`; + return `${centerBand}+${middleBand}+${topLeft}+${topRight}+${bottomLeft}+${bottomRight}`; +} + +function createBackgroundFilter(outputSize, shadowOptions, blurPx = 0) { + const safeBlurPx = Math.max(0, Math.min(96, Math.round(Number.isFinite(blurPx) ? blurPx : 0))); + const scaled = `[0:v]scale=${outputSize.width}:${outputSize.height}:force_original_aspect_ratio=increase,crop=${outputSize.width}:${outputSize.height},format=rgba[bg_scaled]`; + const blurFilter = + safeBlurPx > 0 + ? `;[bg_scaled]boxblur=luma_radius=${safeBlurPx}:luma_power=1:chroma_radius=${safeBlurPx}:chroma_power=1:alpha_radius=${safeBlurPx}:alpha_power=1[bg]` + : ";[bg_scaled]null[bg]"; + if (!shadowOptions) { + return { + filterArgs: [ + "-filter_complex", + `${scaled}${blurFilter};[bg]format=nv12[out]`, + "-map", + "[out]", + ], + bakedShadow: false, + backgroundBlur: safeBlurPx, + }; + } + + const shadowAlpha = Math.round(255 * Math.min(0.5, shadowOptions.intensityPct / 200)); + const shadowBlur = Math.max(12, Math.round(shadowOptions.radius * 1.5)); + const mask = roundedRectMaskExpression({ + x: shadowOptions.x, + y: shadowOptions.y, + width: shadowOptions.width, + height: shadowOptions.height, + radius: shadowOptions.radius, + }); + const shadow = `[1:v]format=rgba,geq=r='0':g='0':b='0':a='if(${mask},${shadowAlpha},0)',boxblur=luma_radius=${shadowBlur}:luma_power=1:chroma_radius=${shadowBlur}:chroma_power=1:alpha_radius=${shadowBlur}:alpha_power=1[shadow]`; + return { + filterArgs: [ + "-f", + "lavfi", + "-i", + `color=c=black@0.0:s=${outputSize.width}x${outputSize.height}:d=1`, + "-filter_complex", + `${scaled}${blurFilter};${shadow};[bg][shadow]overlay=format=auto,format=nv12[out]`, + "-map", + "[out]", + ], + bakedShadow: true, + shadowAlpha, + shadowBlur, + backgroundBlur: safeBlurPx, + }; +} + +function parseProbeSummary(stdout) { + const lines = stdout + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); + const jsonLine = lines.find((line) => line.startsWith("{") && line.includes('"success"')); + if (!jsonLine) { + fail(`Native probe did not emit a JSON summary:\n${stdout}`); + } + try { + return JSON.parse(jsonLine); + } catch { + // The helper currently prints raw Windows paths with backslashes. + // Keep parsing resilient while this remains a throwaway benchmark tool. + return JSON.parse(jsonLine.replace(/,"outputPath":".*"\}$/, "}")); + } +} + +const inputPath = resolve(getArg("--input")); +const outputPath = resolve( + getArg("--output", join(scriptDir, "recordly-nvdec-nvenc-mp4-output.mp4")), +); +const requestedOutputWidth = Math.round(getNumberArg("--width", 0)); +const requestedOutputHeight = Math.round(getNumberArg("--height", 0)); +const fps = Math.round(getNumberArg("--fps", 30)); +const bitrateMbps = Math.round(getNumberArg("--bitrate-mbps", 18)); +const encodingMode = getArg("--encoding-mode", "balanced"); +if (!["fast", "balanced", "quality"].includes(encodingMode)) { + throw new Error(`Unsupported --encoding-mode: ${encodingMode}`); +} +const workDir = resolve(getArg("--work-dir", join(scriptDir, "mp4-work"))); +const reuseIntermediates = hasArg("--reuse-intermediates"); +const reuseDemux = hasArg("--reuse-demux") || reuseIntermediates; +const sampleGpuDuringEncode = hasArg("--sample-gpu"); +const gpuSampleIntervalMs = Math.round(getNumberArg("--gpu-sample-interval-ms", 1000)); +const streamSync = hasArg("--stream-sync"); +const prewarmMs = Math.round(getNumberArg("--prewarm-ms", 0)); +const maxOutputFrames = Math.round(getNumberArg("--max-output-frames", 0)); +const requestedDurationSec = getNumberArg("--duration-sec", 0); +const chunkMb = Math.round(getNumberArg("--chunk-mb", 4)); +const skipMux = hasArg("--skip-mux"); +const videoOnly = hasArg("--video-only"); +const contentX = Math.round(getNonNegativeNumberArg("--content-x", 0)); +const contentY = Math.round(getNonNegativeNumberArg("--content-y", 0)); +const contentWidth = Math.round(getNumberArg("--content-width", 0)); +const contentHeight = Math.round(getNumberArg("--content-height", 0)); +const sourceCropX = Math.round(getNonNegativeNumberArg("--source-crop-x", 0)); +const sourceCropY = Math.round(getNonNegativeNumberArg("--source-crop-y", 0)); +const sourceCropWidth = Math.round(getNumberArg("--source-crop-width", 0)); +const sourceCropHeight = Math.round(getNumberArg("--source-crop-height", 0)); +const radius = Math.round(getNonNegativeNumberArg("--radius", 0)); +const backgroundY = Math.round(getNonNegativeNumberArg("--background-y", 16)); +const backgroundU = Math.round(getNonNegativeNumberArg("--background-u", 128)); +const backgroundV = Math.round(getNonNegativeNumberArg("--background-v", 128)); +const backgroundImage = getArg("--background-image", ""); +const backgroundBlurPx = getNonNegativeNumberArg("--background-blur", 0); +const backgroundNv12 = getArg("--background-nv12", ""); +const shadowOffsetY = Math.round(getNonNegativeNumberArg("--shadow-offset-y", 0)); +const shadowIntensityPct = Math.round(getNonNegativeNumberArg("--shadow-intensity-pct", 0)); +const webcamInput = getArg("--webcam-input", ""); +const webcamX = Math.round(getNonNegativeNumberArg("--webcam-x", 0)); +const webcamY = Math.round(getNonNegativeNumberArg("--webcam-y", 0)); +const webcamSize = Math.round(getNumberArg("--webcam-size", 0)); +const webcamRadius = Math.round(getNonNegativeNumberArg("--webcam-radius", 0)); +const webcamTimeOffsetMs = getFiniteNumberArg("--webcam-time-offset-ms", 0); +const webcamMirror = hasArg("--webcam-mirror"); +const webcamStream = hasArg("--webcam-stream"); +const cursorJson = getArg("--cursor-json", ""); +const cursorHeight = Math.round(getNumberArg("--cursor-height", 0)); +const cursorStyle = getArg("--cursor-style", "vector"); +const cursorAtlasPng = getArg("--cursor-atlas-png", ""); +const cursorAtlasMetadata = getArg("--cursor-atlas-metadata", ""); +const zoomTelemetry = getArg("--zoom-telemetry", ""); +const timelineMap = getArg("--timeline-map", ""); + +if (!existsSync(inputPath)) { + fail(`Input does not exist: ${inputPath}`); +} +mkdirSync(workDir, { recursive: true }); +mkdirSync(dirname(outputPath), { recursive: true }); + +function resolveNativeProbePath() { + const configuredPath = process.env.RECORDLY_NVIDIA_CUDA_EXPORT_EXE; + const platformArch = process.arch === "arm64" ? "win32-arm64" : "win32-x64"; + const candidates = [ + configuredPath, + join(scriptDir, "build", "Release", "recordly-nvidia-cuda-compositor.exe"), + join( + repoRoot, + "electron", + "native", + "bin", + platformArch, + "recordly-nvidia-cuda-compositor.exe", + ), + // Backward-compatible legacy helper path while old work dirs are being retired. + join(scriptDir, "build", "Release", "recordly-nvdec-nvenc-probe.exe"), + ].filter(Boolean); + + for (const candidate of candidates) { + const unpackedCandidate = unpackAsarPath(candidate); + if (unpackedCandidate !== candidate && existsSync(unpackedCandidate)) { + return unpackedCandidate; + } + if (existsSync(candidate)) { + return candidate; + } + } + + fail(`Native NVIDIA CUDA compositor is not built. Checked: ${candidates.join(", ")}`); +} +const nativeProbe = resolveNativeProbePath(); + +const baseName = basename(inputPath).replace(/\.[^.]+$/, ""); +const webcamBaseName = webcamInput + ? basename(webcamInput).replace(/\.[^.]+$/, "") + : `${baseName}.webcam`; +const annexBPath = join(workDir, `${baseName}.annexb.h264`); +const webcamAnnexBPath = join(workDir, `${webcamBaseName}.annexb.h264`); +const cursorSamplesPath = join(workDir, `${baseName}.cursor.tsv`); +const encodedPath = join(workDir, `${baseName}.mapped-callback.h264`); +const shouldBakeStaticShadow = + Boolean(backgroundImage) && + contentWidth > 0 && + contentHeight > 0 && + shadowOffsetY > 0 && + shadowIntensityPct > 0; +const backgroundSuffixParts = []; +if (shouldBakeStaticShadow) { + backgroundSuffixParts.push(`shadow-${shadowOffsetY}-${shadowIntensityPct}`); +} +if (backgroundBlurPx > 0) { + backgroundSuffixParts.push(`blur-${Math.round(backgroundBlurPx)}`); +} +const backgroundSuffix = backgroundSuffixParts.length ? `.${backgroundSuffixParts.join(".")}` : ""; +const generatedBackgroundNv12Path = join(workDir, `${baseName}${backgroundSuffix}.background.nv12`); +const generatedWebcamNv12Path = join( + workDir, + `${baseName}.webcam-${webcamSize}${webcamMirror ? "-mirror" : ""}.nv12`, +); +const sourcePtsPath = join(workDir, `${baseName}.source-pts.csv`); + +const videoInfo = getVideoInfo(inputPath); +const webcamInfo = webcamInput ? getVideoInfo(webcamInput) : null; +if (requestedOutputWidth > 0 !== requestedOutputHeight > 0) { + fail("--width and --height must be specified together"); +} +if ( + requestedOutputWidth > 0 && + (requestedOutputWidth % 2 !== 0 || requestedOutputHeight % 2 !== 0) +) { + fail("--width and --height must be even numbers for NV12 encoding"); +} +const outputWidth = requestedOutputWidth > 0 ? requestedOutputWidth : videoInfo.width; +const outputHeight = requestedOutputHeight > 0 ? requestedOutputHeight : videoInfo.height; +const timelineSegments = readTimelineSegments(timelineMap); +const timelineOutputDurationSec = timelineSegments.length + ? Math.max(...timelineSegments.map((segment) => segment.outputEndMs)) / 1000 + : 0; +const durationSec = + requestedDurationSec > 0 + ? timelineSegments.length + ? requestedDurationSec + : Math.min(videoInfo.durationSec, requestedDurationSec) + : timelineSegments.length + ? timelineOutputDurationSec + : videoInfo.durationSec; +if (timelineSegments.length && Math.abs(durationSec - timelineOutputDurationSec) > 0.05) { + fail( + `Timeline map output duration ${timelineOutputDurationSec.toFixed( + 3, + )}s does not match requested duration ${durationSec.toFixed(3)}s`, + ); +} +const timelineSourceDurationSec = timelineSegments.length + ? Math.max(...timelineSegments.map((segment) => segment.sourceEndMs)) / 1000 + : durationSec; +if (timelineSourceDurationSec > videoInfo.durationSec + 0.1) { + fail( + `Timeline map source duration ${timelineSourceDurationSec.toFixed( + 3, + )}s exceeds input duration ${videoInfo.durationSec.toFixed(3)}s`, + ); +} +const sourceDurationSec = Math.min(videoInfo.durationSec, timelineSourceDurationSec); +const webcamSourceDurationSec = webcamInfo + ? Math.min(sourceDurationSec, webcamInfo.durationSec) + : sourceDurationSec; +const targetFrames = Math.ceil(durationSec * fps); +emitPreparationProgress(targetFrames, 1); +const pipelineStartedAt = performance.now(); +const zeroElapsed = () => ({ elapsedMs: 0, stdout: "", stderr: "" }); +let sourceWindowFrames = Math.max( + 1, + Math.min( + videoInfo.sourceFrames, + Math.ceil((videoInfo.sourceFrames * sourceDurationSec) / videoInfo.durationSec), + ), +); +let webcamSourceWindowFrames = webcamInfo + ? Math.max( + 1, + Math.min( + webcamInfo.sourceFrames, + Math.ceil((webcamInfo.sourceFrames * sourceDurationSec) / webcamInfo.durationSec), + ), + ) + : 0; +const backgroundNv12Path = backgroundImage ? generatedBackgroundNv12Path : backgroundNv12; +const backgroundFilter = createBackgroundFilter( + { width: outputWidth, height: outputHeight }, + shouldBakeStaticShadow + ? { + x: contentX, + y: contentY + shadowOffsetY, + width: contentWidth, + height: contentHeight, + radius: radius + 8, + intensityPct: shadowIntensityPct, + } + : null, + backgroundBlurPx, +); + +const backgroundConvertPromise = + backgroundImage && !(reuseIntermediates && existsSync(backgroundNv12Path)) + ? runAsync(ffmpegCommand, [ + "-y", + "-hide_banner", + "-loglevel", + "error", + "-i", + resolve(backgroundImage), + ...backgroundFilter.filterArgs, + "-frames:v", + "1", + "-f", + "rawvideo", + backgroundNv12Path, + ]) + : Promise.resolve(zeroElapsed()); + +const webcamConvertPromise = + webcamInput && + !webcamStream && + webcamSize > 0 && + !(reuseIntermediates && existsSync(generatedWebcamNv12Path)) + ? runAsync(ffmpegCommand, [ + "-y", + "-hide_banner", + "-loglevel", + "error", + "-i", + resolve(webcamInput), + "-vf", + `${webcamMirror ? "hflip," : ""}scale=${webcamSize}:${webcamSize}:force_original_aspect_ratio=increase,crop=${webcamSize}:${webcamSize},format=nv12`, + "-frames:v", + "1", + "-f", + "rawvideo", + generatedWebcamNv12Path, + ]) + : Promise.resolve(zeroElapsed()); + +const webcamDemuxPromise = + webcamInput && webcamStream && !(reuseDemux && existsSync(webcamAnnexBPath)) + ? runWithProgress( + ffmpegCommand, + [ + "-y", + "-hide_banner", + "-loglevel", + "error", + "-stats", + "-i", + resolve(webcamInput), + "-t", + String(sourceDurationSec), + "-map", + "0:v:0", + "-c:v", + "copy", + "-bsf:v", + "h264_mp4toannexb", + "-an", + webcamAnnexBPath, + ], + { + totalDurationSec: sourceDurationSec, + totalFrames: targetFrames, + startPercentage: 1, + endPercentage: 2, + }, + ) + : Promise.resolve(zeroElapsed()); + +const demuxPromise = + reuseDemux && existsSync(annexBPath) + ? Promise.resolve(zeroElapsed()) + : runWithProgress( + ffmpegCommand, + [ + "-y", + "-hide_banner", + "-loglevel", + "error", + "-stats", + "-i", + inputPath, + "-t", + String(sourceDurationSec), + "-map", + "0:v:0", + "-c:v", + "copy", + "-bsf:v", + "h264_mp4toannexb", + "-an", + annexBPath, + ], + { + totalDurationSec: sourceDurationSec, + totalFrames: targetFrames, + startPercentage: 1, + endPercentage: 2, + }, + ); +const sourcePtsPromise = writeFramePtsSidecarAsync(inputPath, sourceDurationSec, sourcePtsPath); + +if (cursorJson) { + const cursorPayload = JSON.parse(readFileSync(resolve(cursorJson), "utf8")); + writeCursorSamples(cursorPayload, cursorSamplesPath); +} +const cursorAtlas = + cursorJson && cursorHeight > 0 && cursorAtlasPng && cursorAtlasMetadata + ? prepareExternalCursorAtlas(workDir, cursorAtlasPng, cursorAtlasMetadata) + : cursorJson && cursorHeight > 0 && cursorStyle === "tahoe" + ? renderTahoeCursorAtlas(workDir) + : null; + +const [backgroundConvert, webcamConvert, webcamDemux, demux, sourcePts] = await Promise.all([ + backgroundConvertPromise, + webcamConvertPromise, + webcamDemuxPromise, + demuxPromise, + sourcePtsPromise, +]); +const preparationWallMs = performance.now() - pipelineStartedAt; +const demuxFrameCount = parseFfmpegStatsFrameCount(demux.stderr); +if (demuxFrameCount > 0) { + sourceWindowFrames = demuxFrameCount; +} +const webcamDemuxFrameCount = parseFfmpegStatsFrameCount(webcamDemux.stderr); +if (webcamDemuxFrameCount > 0) { + webcamSourceWindowFrames = webcamDemuxFrameCount; +} +if (timelineSegments.length && (!sourcePts.path || sourcePts.frames < sourceWindowFrames)) { + fail("Timeline-map CUDA export requires source frame PTS for the full source window"); +} +emitPreparationProgress(targetFrames, 3); + +const encodeArgs = [ + "--input", + annexBPath, + "--output", + encodedPath, + "--fps", + String(fps), + "--input-frames", + String(sourceWindowFrames), + "--target-frames", + String(targetFrames), + "--bitrate-mbps", + String(bitrateMbps), + "--encoding-mode", + encodingMode, + "--callback-encode", + "--chunk-mb", + String(chunkMb), +]; +if (requestedOutputWidth > 0 && requestedOutputHeight > 0) { + encodeArgs.push("--width", String(outputWidth), "--height", String(outputHeight)); +} +if (sourcePts.path && sourcePts.frames >= sourceWindowFrames) { + encodeArgs.push("--source-pts", sourcePts.path); +} +if (timelineMap) { + encodeArgs.push("--timeline-map", resolve(timelineMap)); +} +if (maxOutputFrames > 0) { + encodeArgs.push("--max-frames", String(maxOutputFrames)); +} +if (cursorJson && cursorHeight > 0) { + encodeArgs.push("--cursor-samples", cursorSamplesPath, "--cursor-height", String(cursorHeight)); + if (cursorAtlas) { + encodeArgs.push( + "--cursor-atlas-rgba", + cursorAtlas.rgbaPath, + "--cursor-atlas-metadata", + cursorAtlas.metadataPath, + "--cursor-atlas-width", + String(cursorAtlas.width), + "--cursor-atlas-height", + String(cursorAtlas.height), + ); + } +} +if (streamSync) { + encodeArgs.push("--stream-sync"); +} +if (prewarmMs > 0) { + encodeArgs.push("--prewarm-ms", String(prewarmMs)); +} +if (contentWidth > 0 && contentHeight > 0) { + encodeArgs.push( + "--content-x", + String(contentX), + "--content-y", + String(contentY), + "--content-width", + String(contentWidth), + "--content-height", + String(contentHeight), + "--radius", + String(radius), + "--background-y", + String(backgroundY), + "--background-u", + String(backgroundU), + "--background-v", + String(backgroundV), + ); + if (backgroundNv12Path) { + encodeArgs.push("--background-nv12", backgroundNv12Path); + } + if (sourceCropWidth >= 2 && sourceCropHeight >= 2) { + encodeArgs.push( + "--source-crop-x", + String(sourceCropX), + "--source-crop-y", + String(sourceCropY), + "--source-crop-width", + String(sourceCropWidth), + "--source-crop-height", + String(sourceCropHeight), + ); + } + if (!shouldBakeStaticShadow && shadowOffsetY > 0 && shadowIntensityPct > 0) { + encodeArgs.push( + "--shadow-offset-y", + String(shadowOffsetY), + "--shadow-intensity-pct", + String(shadowIntensityPct), + ); + } + if (webcamInput && webcamSize > 0) { + encodeArgs.push( + "--webcam-x", + String(webcamX), + "--webcam-y", + String(webcamY), + "--webcam-size", + String(webcamSize), + "--webcam-radius", + String(webcamRadius), + "--webcam-time-offset-ms", + String(webcamTimeOffsetMs), + ); + if (webcamMirror) { + encodeArgs.push("--webcam-mirror"); + } + if (webcamStream) { + encodeArgs.push( + "--webcam-annexb", + webcamAnnexBPath, + "--webcam-input-frames", + String(webcamSourceWindowFrames), + "--webcam-target-frames", + String(targetFrames), + "--webcam-source-duration-ms", + String(webcamSourceDurationSec * 1000), + "--webcam-source-width", + String(webcamInfo.width), + "--webcam-source-height", + String(webcamInfo.height), + ); + } else { + encodeArgs.push("--webcam-nv12", generatedWebcamNv12Path); + } + } +} +if (zoomTelemetry) { + encodeArgs.push("--zoom-samples", resolve(zoomTelemetry)); +} +const encode = + reuseIntermediates && existsSync(encodedPath) + ? { elapsedMs: 0, stdout: "", gpuSummary: null } + : await runWithGpuMonitor( + nativeProbe, + encodeArgs, + sampleGpuDuringEncode ? gpuSampleIntervalMs : 0, + ); +const nativeSummary = encode.stdout ? parseProbeSummary(encode.stdout) : null; + +const mux = skipMux + ? { elapsedMs: 0 } + : videoOnly + ? await runWithProgress( + ffmpegCommand, + [ + "-y", + "-hide_banner", + "-loglevel", + "error", + "-stats", + "-framerate", + String(fps), + "-i", + encodedPath, + "-map", + "0:v:0", + "-c:v", + "copy", + outputPath, + ], + { + totalDurationSec: durationSec, + totalFrames: targetFrames, + startPercentage: 96, + endPercentage: 97.25, + stage: "finalizing", + }, + ) + : await runWithProgress( + ffmpegCommand, + [ + "-y", + "-hide_banner", + "-loglevel", + "error", + "-stats", + "-framerate", + String(fps), + "-i", + encodedPath, + "-i", + inputPath, + "-map", + "0:v:0", + "-map", + "1:a?", + "-c:v", + "copy", + "-c:a", + "copy", + "-t", + String(durationSec), + outputPath, + ], + { + totalDurationSec: durationSec, + totalFrames: targetFrames, + startPercentage: 96, + endPercentage: 99, + stage: "finalizing", + }, + ); + +const finalOutputPath = skipMux ? encodedPath : outputPath; +const outputInfo = skipMux + ? { streams: [] } + : ffprobeJson([ + "-show_entries", + "stream=index,codec_type,codec_name,width,height,duration,avg_frame_rate,nb_frames", + finalOutputPath, + ]); +const outputStreams = outputInfo.streams ?? []; +const outputVideo = outputStreams.find((stream) => stream.codec_type === "video") ?? null; +const outputAudio = outputStreams.find((stream) => stream.codec_type === "audio") ?? null; + +console.log( + JSON.stringify( + { + success: true, + inputPath, + outputPath: finalOutputPath, + requestedOutputPath: outputPath, + encodedPath, + fps, + bitrateMbps, + encodingMode, + streamSync, + prewarmMs, + maxOutputFrames, + chunkMb, + skipMux, + videoOnly, + durationSec, + sourceDurationSec, + timelineMap: timelineSegments.length + ? { + inputPath: resolve(timelineMap), + segments: timelineSegments.length, + sourceDurationSec, + outputDurationSec: durationSec, + } + : null, + staticLayout: + contentWidth > 0 && contentHeight > 0 + ? { + contentX, + contentY, + contentWidth, + contentHeight, + radius, + backgroundY, + backgroundU, + backgroundV, + shadowOffsetY, + shadowIntensityPct, + shadowBakedIntoBackground: shouldBakeStaticShadow, + backgroundShadowAlpha: backgroundFilter.shadowAlpha ?? null, + backgroundShadowBlur: backgroundFilter.shadowBlur ?? null, + webcam: + webcamInput && webcamSize > 0 + ? { + inputPath: resolve(webcamInput), + x: webcamX, + y: webcamY, + size: webcamSize, + radius: webcamRadius, + timeOffsetMs: webcamTimeOffsetMs, + sourceDurationSec: webcamSourceDurationSec, + mirror: webcamMirror, + staticFrameOnly: !webcamStream, + stream: webcamStream, + } + : null, + cursor: + cursorJson && cursorHeight > 0 + ? { + inputPath: resolve(cursorJson), + height: cursorHeight, + style: cursorStyle, + atlas: cursorAtlas + ? { + width: cursorAtlas.width, + height: cursorAtlas.height, + entries: cursorAtlas.entries, + } + : null, + } + : null, + zoom: zoomTelemetry + ? { + inputPath: resolve(zoomTelemetry), + } + : null, + } + : null, + gpuSampleIntervalMs: sampleGpuDuringEncode ? gpuSampleIntervalMs : null, + videoInfo, + sourceWindowFrames, + sourcePtsFrames: sourcePts.frames, + sourcePtsSource: sourcePts.source, + targetFrames, + timingsMs: { + preparationWall: Number(preparationWallMs.toFixed(2)), + demux: Number(demux.elapsedMs.toFixed(2)), + backgroundConvert: Number(backgroundConvert.elapsedMs.toFixed(2)), + cursorAtlas: Number((cursorAtlas?.elapsedMs ?? 0).toFixed(2)), + webcamConvert: Number(webcamConvert.elapsedMs.toFixed(2)), + webcamDemux: Number(webcamDemux.elapsedMs.toFixed(2)), + sourcePtsProbe: Number(sourcePts.elapsedMs.toFixed(2)), + nativeEncode: Number(encode.elapsedMs.toFixed(2)), + mux: Number(mux.elapsedMs.toFixed(2)), + endToEnd: Number((performance.now() - pipelineStartedAt).toFixed(2)), + }, + nativeSummary, + nativeProcessPriorityBoosted: encode.priorityBoosted ?? false, + gpuSamples: encode.gpuSamples ?? [], + gpuSummary: encode.gpuSummary ?? null, + outputVideo, + outputAudio, + outputStreams, + }, + null, + 2, + ), +); diff --git a/electron/native/nvidia-cuda-compositor/src/main.cu b/electron/native/nvidia-cuda-compositor/src/main.cu new file mode 100644 index 000000000..28c72123d --- /dev/null +++ b/electron/native/nvidia-cuda-compositor/src/main.cu @@ -0,0 +1,3313 @@ +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "NvDecoder/NvDecoder.h" +#include "NvEncoder/NvEncoderCuda.h" +#include "Utils/Logger.h" + +simplelogger::Logger* logger = simplelogger::LoggerFactory::CreateConsoleLogger(ERROR); + +namespace { + +struct TimelineSegment { + double sourceStartMs = 0.0; + double sourceEndMs = 0.0; + double outputStartMs = 0.0; + double outputEndMs = 0.0; + double speed = 1.0; +}; + +struct Options { + std::string inputPath; + std::string outputPath = "recordly-nvidia-cuda-compositor.h264"; + std::string sourcePtsPath; + std::string timelineMapPath; + std::vector timelineSegments; + int width = 0; + int height = 0; + int fps = 30; + int maxFrames = 0; + int inputFrames = 0; + int targetFrames = 0; + int bitrateMbps = 18; + std::string encodingMode = "balanced"; + bool postSelect = false; + bool callbackEncode = false; + bool streamSync = false; + int prewarmMs = 0; + int chunkMb = 4; + int contentX = 0; + int contentY = 0; + int contentWidth = 0; + int contentHeight = 0; + int sourceCropX = 0; + int sourceCropY = 0; + int sourceCropWidth = 0; + int sourceCropHeight = 0; + int radius = 0; + int backgroundY = 16; + int backgroundU = 128; + int backgroundV = 128; + std::string backgroundNv12Path; + int shadowOffsetY = 0; + int shadowIntensityPct = 0; + std::string webcamNv12Path; + std::string webcamAnnexbPath; + int webcamInputFrames = 0; + int webcamTargetFrames = 0; + double webcamSourceDurationMs = 0.0; + int webcamSourceWidth = 0; + int webcamSourceHeight = 0; + int webcamX = 0; + int webcamY = 0; + int webcamSize = 0; + int webcamRadius = 0; + double webcamTimeOffsetMs = 0.0; + bool webcamMirror = false; + std::string cursorSamplesPath; + int cursorHeight = 0; + std::string cursorAtlasRgbaPath; + std::string cursorAtlasMetadataPath; + int cursorAtlasWidth = 0; + int cursorAtlasHeight = 0; + std::string zoomSamplesPath; +}; + +constexpr int kMaxCursorAtlasEntries = 16; +constexpr int kWebcamPrefetchOutputFrames = 900; + +[[noreturn]] void fail(const std::string& message) { + throw std::runtime_error(message); +} + +void checkCuda(cudaError_t status, const char* expression) { + if (status != cudaSuccess) { + std::ostringstream stream; + stream << expression << " failed: " << cudaGetErrorString(status); + fail(stream.str()); + } +} + +void checkCu(CUresult status, const char* expression) { + if (status != CUDA_SUCCESS) { + const char* name = nullptr; + const char* message = nullptr; + cuGetErrorName(status, &name); + cuGetErrorString(status, &message); + std::ostringstream stream; + stream << expression << " failed: " << (name ? name : "CUDA_ERROR") + << " (" << (message ? message : "no detail") << ")"; + fail(stream.str()); + } +} + +int parsePositiveInt(const char* value, const char* name) { + char* end = nullptr; + const long parsed = std::strtol(value, &end, 10); + if (!end || *end != '\0' || parsed <= 0 || parsed > 1000000) { + std::ostringstream stream; + stream << "Invalid " << name << ": " << value; + fail(stream.str()); + } + return static_cast(parsed); +} + +int parseNonNegativeInt(const char* value, const char* name) { + char* end = nullptr; + const long parsed = std::strtol(value, &end, 10); + if (!end || *end != '\0' || parsed < 0 || parsed > 1000000) { + std::ostringstream stream; + stream << "Invalid " << name << ": " << value; + fail(stream.str()); + } + return static_cast(parsed); +} + +double parseFiniteDouble(const char* value, const char* name) { + char* end = nullptr; + const double parsed = std::strtod(value, &end); + if (!end || *end != '\0' || !std::isfinite(parsed)) { + std::ostringstream stream; + stream << "Invalid " << name << ": " << value; + fail(stream.str()); + } + return parsed; +} + +Options parseOptions(int argc, char** argv) { + Options options; + for (int index = 1; index < argc; ++index) { + const std::string arg = argv[index]; + auto requireValue = [&](const char* name) -> const char* { + if (index + 1 >= argc) { + std::ostringstream stream; + stream << "Missing value for " << name; + fail(stream.str()); + } + return argv[++index]; + }; + + if (arg == "--input") { + options.inputPath = requireValue("--input"); + } else if (arg == "--output") { + options.outputPath = requireValue("--output"); + } else if (arg == "--source-pts") { + options.sourcePtsPath = requireValue("--source-pts"); + } else if (arg == "--width") { + options.width = parsePositiveInt(requireValue("--width"), "--width"); + } else if (arg == "--height") { + options.height = parsePositiveInt(requireValue("--height"), "--height"); + } else if (arg == "--timeline-map") { + options.timelineMapPath = requireValue("--timeline-map"); + } else if (arg == "--fps") { + options.fps = parsePositiveInt(requireValue("--fps"), "--fps"); + } else if (arg == "--max-frames") { + options.maxFrames = parsePositiveInt(requireValue("--max-frames"), "--max-frames"); + } else if (arg == "--input-frames") { + options.inputFrames = parsePositiveInt(requireValue("--input-frames"), "--input-frames"); + } else if (arg == "--target-frames") { + options.targetFrames = parsePositiveInt(requireValue("--target-frames"), "--target-frames"); + } else if (arg == "--bitrate-mbps") { + options.bitrateMbps = parsePositiveInt(requireValue("--bitrate-mbps"), "--bitrate-mbps"); + } else if (arg == "--encoding-mode") { + options.encodingMode = requireValue("--encoding-mode"); + if ( + options.encodingMode != "fast" && + options.encodingMode != "balanced" && + options.encodingMode != "quality") { + fail("Unsupported --encoding-mode: " + options.encodingMode); + } + } else if (arg == "--post-select") { + options.postSelect = true; + } else if (arg == "--callback-encode") { + options.callbackEncode = true; + } else if (arg == "--stream-sync") { + options.streamSync = true; + } else if (arg == "--prewarm-ms") { + options.prewarmMs = parsePositiveInt(requireValue("--prewarm-ms"), "--prewarm-ms"); + } else if (arg == "--chunk-mb") { + options.chunkMb = parsePositiveInt(requireValue("--chunk-mb"), "--chunk-mb"); + } else if (arg == "--content-x") { + options.contentX = parseNonNegativeInt(requireValue("--content-x"), "--content-x"); + } else if (arg == "--content-y") { + options.contentY = parseNonNegativeInt(requireValue("--content-y"), "--content-y"); + } else if (arg == "--content-width") { + options.contentWidth = parsePositiveInt(requireValue("--content-width"), "--content-width"); + } else if (arg == "--content-height") { + options.contentHeight = parsePositiveInt(requireValue("--content-height"), "--content-height"); + } else if (arg == "--source-crop-x") { + options.sourceCropX = parseNonNegativeInt(requireValue("--source-crop-x"), "--source-crop-x"); + } else if (arg == "--source-crop-y") { + options.sourceCropY = parseNonNegativeInt(requireValue("--source-crop-y"), "--source-crop-y"); + } else if (arg == "--source-crop-width") { + options.sourceCropWidth = parsePositiveInt(requireValue("--source-crop-width"), "--source-crop-width"); + } else if (arg == "--source-crop-height") { + options.sourceCropHeight = parsePositiveInt(requireValue("--source-crop-height"), "--source-crop-height"); + } else if (arg == "--radius") { + options.radius = parseNonNegativeInt(requireValue("--radius"), "--radius"); + } else if (arg == "--background-y") { + options.backgroundY = parseNonNegativeInt(requireValue("--background-y"), "--background-y"); + } else if (arg == "--background-u") { + options.backgroundU = parseNonNegativeInt(requireValue("--background-u"), "--background-u"); + } else if (arg == "--background-v") { + options.backgroundV = parseNonNegativeInt(requireValue("--background-v"), "--background-v"); + } else if (arg == "--background-nv12") { + options.backgroundNv12Path = requireValue("--background-nv12"); + } else if (arg == "--shadow-offset-y") { + options.shadowOffsetY = parseNonNegativeInt(requireValue("--shadow-offset-y"), "--shadow-offset-y"); + } else if (arg == "--shadow-intensity-pct") { + options.shadowIntensityPct = parseNonNegativeInt(requireValue("--shadow-intensity-pct"), "--shadow-intensity-pct"); + } else if (arg == "--webcam-nv12") { + options.webcamNv12Path = requireValue("--webcam-nv12"); + } else if (arg == "--webcam-annexb") { + options.webcamAnnexbPath = requireValue("--webcam-annexb"); + } else if (arg == "--webcam-input-frames") { + options.webcamInputFrames = parsePositiveInt(requireValue("--webcam-input-frames"), "--webcam-input-frames"); + } else if (arg == "--webcam-target-frames") { + options.webcamTargetFrames = + parsePositiveInt(requireValue("--webcam-target-frames"), "--webcam-target-frames"); + } else if (arg == "--webcam-source-duration-ms") { + options.webcamSourceDurationMs = + parseFiniteDouble(requireValue("--webcam-source-duration-ms"), "--webcam-source-duration-ms"); + } else if (arg == "--webcam-source-width") { + options.webcamSourceWidth = parsePositiveInt(requireValue("--webcam-source-width"), "--webcam-source-width"); + } else if (arg == "--webcam-source-height") { + options.webcamSourceHeight = + parsePositiveInt(requireValue("--webcam-source-height"), "--webcam-source-height"); + } else if (arg == "--webcam-x") { + options.webcamX = parseNonNegativeInt(requireValue("--webcam-x"), "--webcam-x"); + } else if (arg == "--webcam-y") { + options.webcamY = parseNonNegativeInt(requireValue("--webcam-y"), "--webcam-y"); + } else if (arg == "--webcam-size") { + options.webcamSize = parsePositiveInt(requireValue("--webcam-size"), "--webcam-size"); + } else if (arg == "--webcam-radius") { + options.webcamRadius = parseNonNegativeInt(requireValue("--webcam-radius"), "--webcam-radius"); + } else if (arg == "--webcam-time-offset-ms") { + options.webcamTimeOffsetMs = + parseFiniteDouble(requireValue("--webcam-time-offset-ms"), "--webcam-time-offset-ms"); + } else if (arg == "--webcam-mirror") { + options.webcamMirror = true; + } else if (arg == "--cursor-samples") { + options.cursorSamplesPath = requireValue("--cursor-samples"); + } else if (arg == "--cursor-height") { + options.cursorHeight = parsePositiveInt(requireValue("--cursor-height"), "--cursor-height"); + } else if (arg == "--cursor-atlas-rgba") { + options.cursorAtlasRgbaPath = requireValue("--cursor-atlas-rgba"); + } else if (arg == "--cursor-atlas-metadata") { + options.cursorAtlasMetadataPath = requireValue("--cursor-atlas-metadata"); + } else if (arg == "--cursor-atlas-width") { + options.cursorAtlasWidth = parsePositiveInt(requireValue("--cursor-atlas-width"), "--cursor-atlas-width"); + } else if (arg == "--cursor-atlas-height") { + options.cursorAtlasHeight = + parsePositiveInt(requireValue("--cursor-atlas-height"), "--cursor-atlas-height"); + } else if (arg == "--zoom-samples") { + options.zoomSamplesPath = requireValue("--zoom-samples"); + } else if (arg == "--help") { + std::cout << "Usage: recordly-nvidia-cuda-compositor --input input.annexb.h264 " + "[--output out.h264] [--source-pts source-pts.csv] [--width N --height N] [--fps 30] " + "[--max-frames N] [--bitrate-mbps N] [--encoding-mode fast|balanced|quality] " + "[--post-select] [--callback-encode] [--stream-sync] [--prewarm-ms N] [--chunk-mb N] " + "[--content-x N --content-y N --content-width N --content-height N --radius N] " + "[--background-nv12 background.nv12] [--shadow-offset-y N --shadow-intensity-pct N] " + "[--webcam-nv12 webcam.nv12 --webcam-x N --webcam-y N --webcam-size N --webcam-radius N] " + "[--webcam-annexb webcam.h264 --webcam-input-frames N --webcam-target-frames N " + "--webcam-source-duration-ms N --webcam-time-offset-ms N] " + "[--cursor-samples cursor.tsv --cursor-height N] " + "[--cursor-atlas-rgba cursor.rgba --cursor-atlas-metadata cursor.tsv " + "--cursor-atlas-width N --cursor-atlas-height N] " + "[--zoom-samples zoom.csv]\n"; + std::exit(0); + } else { + std::ostringstream stream; + stream << "Unknown argument: " << arg; + fail(stream.str()); + } + } + if (options.inputPath.empty()) { + fail("--input is required"); + } + if ((options.width > 0) != (options.height > 0)) { + fail("--width and --height must be specified together"); + } + if (options.width > 0 && (options.width % 2 != 0 || options.height % 2 != 0)) { + fail("--width and --height must be even numbers for NV12 encoding"); + } + return options; +} + +bool shouldEncodeFrame(int sourceFrameIndex, int encodedFrames, const Options& options) { + if (options.inputFrames <= 0 || options.targetFrames <= 0) { + return true; + } + if (encodedFrames >= options.targetFrames) { + return false; + } + + const int expectedEncodedFrames = + ((sourceFrameIndex + 1) * options.targetFrames + options.inputFrames - 1) / options.inputFrames; + return encodedFrames < expectedEncodedFrames; +} + +bool hasStaticLayout(const Options& options) { + return options.contentWidth > 0 && options.contentHeight > 0; +} + +bool hasWebcamOverlay(const Options& options) { + return (!options.webcamNv12Path.empty() || !options.webcamAnnexbPath.empty()) && options.webcamSize > 0; +} + +int outputWidthForSource(const Options& options, int sourceWidth) { + return options.width > 0 ? options.width : sourceWidth; +} + +int outputHeightForSource(const Options& options, int sourceHeight) { + return options.height > 0 ? options.height : sourceHeight; +} + +std::vector loadFramePts(const std::string& path) { + std::vector timestamps; + if (path.empty()) { + return timestamps; + } + + std::ifstream input(path); + if (!input) { + fail("Failed to open source PTS sidecar: " + path); + } + + std::string line; + double lastTimestamp = -std::numeric_limits::infinity(); + while (std::getline(input, line)) { + if (line.empty()) { + continue; + } + char* end = nullptr; + const double timestamp = std::strtod(line.c_str(), &end); + if (!end || *end != '\0' || !std::isfinite(timestamp) || timestamp < lastTimestamp) { + fail("Invalid source PTS sidecar entry: " + line); + } + timestamps.push_back(timestamp); + lastTimestamp = timestamp; + } + + return timestamps; +} + +std::vector loadTimelineMap(const std::string& path) { + std::vector segments; + if (path.empty()) { + return segments; + } + + std::ifstream input(path); + if (!input) { + fail("Failed to open timeline map: " + path); + } + + std::string line; + double expectedOutputStartMs = 0.0; + while (std::getline(input, line)) { + if (line.empty()) { + continue; + } + TimelineSegment segment; + if (std::sscanf( + line.c_str(), + "%lf,%lf,%lf,%lf,%lf", + &segment.sourceStartMs, + &segment.sourceEndMs, + &segment.outputStartMs, + &segment.outputEndMs, + &segment.speed) != 5) { + fail("Invalid timeline map row: " + line); + } + if ( + !std::isfinite(segment.sourceStartMs) || + !std::isfinite(segment.sourceEndMs) || + !std::isfinite(segment.outputStartMs) || + !std::isfinite(segment.outputEndMs) || + !std::isfinite(segment.speed) || + segment.sourceEndMs <= segment.sourceStartMs || + segment.outputEndMs <= segment.outputStartMs || + segment.speed <= 0.0 || + std::abs(segment.outputStartMs - expectedOutputStartMs) > 2.0) { + fail("Invalid timeline map segment: " + line); + } + expectedOutputStartMs = segment.outputEndMs; + segments.push_back(segment); + } + + if (!path.empty() && segments.empty()) { + fail("Timeline map is empty: " + path); + } + return segments; +} + +bool sourceToOutputMs( + const std::vector& segments, + double sourceMs, + double& outputMs) { + if (segments.empty()) { + outputMs = sourceMs; + return true; + } + + constexpr double kToleranceMs = 1.0; + for (const auto& segment : segments) { + if (sourceMs + kToleranceMs < segment.sourceStartMs) { + return false; + } + if (sourceMs <= segment.sourceEndMs + kToleranceMs) { + const double clampedSourceMs = + std::min(segment.sourceEndMs, std::max(segment.sourceStartMs, sourceMs)); + outputMs = segment.outputStartMs + (clampedSourceMs - segment.sourceStartMs) / segment.speed; + outputMs = std::min(segment.outputEndMs, std::max(segment.outputStartMs, outputMs)); + return true; + } + } + + return false; +} + +double outputToSourceMs(const std::vector& segments, double outputMs) { + if (segments.empty()) { + return outputMs; + } + + for (const auto& segment : segments) { + if (outputMs <= segment.outputEndMs + 1.0) { + const double clampedOutputMs = + std::min(segment.outputEndMs, std::max(segment.outputStartMs, outputMs)); + return segment.sourceStartMs + (clampedOutputMs - segment.outputStartMs) * segment.speed; + } + } + + return segments.back().sourceEndMs; +} + +int webcamFrameIndexForSourceTimeMs(double sourceTimeMs, const Options& options) { + const double adjustedSourceMs = std::max(0.0, sourceTimeMs - options.webcamTimeOffsetMs); + if (options.webcamInputFrames > 0) { + const double durationMs = options.webcamSourceDurationMs > 0.0 + ? options.webcamSourceDurationMs + : (options.webcamTargetFrames > 0 && options.fps > 0 + ? (static_cast(options.webcamTargetFrames) * 1000.0) / options.fps + : 0.0); + if (durationMs > 0.0) { + const double ratio = std::min(1.0, std::max(0.0, adjustedSourceMs / durationMs)); + const int index = ratio >= 1.0 + ? options.webcamInputFrames - 1 + : static_cast(std::floor(ratio * options.webcamInputFrames)); + return std::max(0, std::min(options.webcamInputFrames - 1, index)); + } + } + return std::max(0, static_cast(std::floor(adjustedSourceMs * options.fps / 1000.0))); +} + +int webcamFrameIndexForOutputFrame(int outputFrameIndex, const Options& options) { + const double outputTimeMs = + static_cast(std::max(0, outputFrameIndex)) * 1000.0 / static_cast(options.fps); + return webcamFrameIndexForSourceTimeMs( + outputToSourceMs(options.timelineSegments, outputTimeMs), + options); +} + +int maxSelectedFramesForTimeline(int targetFrames, int maxFrames) { + if (targetFrames <= 0) { + return maxFrames > 0 ? maxFrames : std::numeric_limits::max(); + } + return maxFrames > 0 ? std::min(maxFrames, targetFrames) : targetFrames; +} + +int expectedOutputFramesForSourceFrame( + int sourceFrameIndex, + int inputFrames, + int targetFrames, + int maxFrames, + int fps, + const std::vector* sourcePts, + const std::vector* timelineSegments) { + const int maxOutputFrames = maxSelectedFramesForTimeline(targetFrames, maxFrames); + if (sourcePts && sourceFrameIndex >= 0 && sourceFrameIndex < static_cast(sourcePts->size())) { + const bool hasTimelineMap = timelineSegments && !timelineSegments->empty(); + if (!hasTimelineMap && inputFrames > 0 && sourceFrameIndex + 1 >= inputFrames) { + return maxOutputFrames; + } + if (hasTimelineMap && inputFrames > 0 && sourceFrameIndex + 1 >= inputFrames) { + return maxOutputFrames; + } + const double frameTimeSec = std::max(0.0, (*sourcePts)[sourceFrameIndex]); + double outputTimeMs = frameTimeSec * 1000.0; + if (hasTimelineMap && !sourceToOutputMs(*timelineSegments, outputTimeMs, outputTimeMs)) { + return 0; + } + const int64_t expected = + static_cast(std::floor((outputTimeMs / 1000.0) * fps)) + 1; + return static_cast(std::min(std::max(1, expected), maxOutputFrames)); + } + if (inputFrames <= 0 || targetFrames <= 0) { + return maxOutputFrames; + } + + const int64_t expected = + (static_cast(sourceFrameIndex + 1) * targetFrames + inputFrames - 1) / inputFrames; + return static_cast(std::min(expected, maxOutputFrames)); +} + +unsigned char clampByte(int value) { + return static_cast(std::max(0, std::min(255, value))); +} + +struct FrameSelectionState { + int inputFrames = 0; + int targetFrames = 0; + int maxFrames = 0; + int sourceFrames = 0; + int selectedFrames = 0; + int fps = 30; + const std::vector* sourcePts = nullptr; + const std::vector* timelineSegments = nullptr; +}; + +bool shouldCopyDisplayFrame(int displayFrameIndex, void* userData) { + auto* state = static_cast(userData); + state->sourceFrames = displayFrameIndex + 1; + + const int maxSelectedFrames = + state->maxFrames > 0 ? std::min(state->maxFrames, state->targetFrames) : state->targetFrames; + if (state->selectedFrames >= maxSelectedFrames) { + return false; + } + + // maxFrames is a smoke-test stop cap; it must not spread the sampled frames + // across the full source because that hides the true first-window performance. + const int expectedSelectedFrames = expectedOutputFramesForSourceFrame( + displayFrameIndex, + state->inputFrames, + state->targetFrames, + state->maxFrames, + state->fps, + state->sourcePts, + state->timelineSegments); + if (state->selectedFrames >= expectedSelectedFrames) { + return false; + } + + ++state->selectedFrames; + return true; +} + +double elapsedMs(std::chrono::steady_clock::time_point start, std::chrono::steady_clock::time_point end); +struct ProgressCounters { + double decodeWallMs = 0.0; + double encodeMs = 0.0; + double compositeMs = 0.0; + double nvencMs = 0.0; + double packetWriteMs = 0.0; + double webcamDecodeMs = 0.0; + double webcamCopyMs = 0.0; + int roiCompositeFrames = 0; + int monolithicCompositeFrames = 0; + int copyCompositeFrames = 0; +}; + +struct ProgressReportState { + std::chrono::steady_clock::time_point startedAt; + std::chrono::steady_clock::time_point lastReportAt; + int lastReportedFrame = 0; + ProgressCounters lastCounters; +}; + +void reportEncodingProgress( + int encodedFrames, + int totalFrames, + ProgressReportState& state, + const ProgressCounters& counters, + bool force = false); + +struct WebcamFrameCache { + std::vector frames; + double decodeMs = 0.0; + double copyMs = 0.0; + int sourceFrames = 0; + int baseFrameIndex = 0; + int decodedFrames = 0; + int peakFrames = 0; + int width = 0; + int height = 0; + + ~WebcamFrameCache() { + for (unsigned char* frame : frames) { + cudaFree(frame); + } + } + + void pushFrame(unsigned char* frame) { + frames.push_back(frame); + decodedFrames = baseFrameIndex + static_cast(frames.size()); + sourceFrames = decodedFrames; + peakFrames = std::max(peakFrames, static_cast(frames.size())); + } + + void dropBefore(int minFrameIndex) { + const int dropCount = std::min( + std::max(0, minFrameIndex - baseFrameIndex), + std::max(0, static_cast(frames.size()) - 1)); + if (dropCount <= 0) { + return; + } + for (int index = 0; index < dropCount; ++index) { + cudaFree(frames[index]); + } + frames.erase(frames.begin(), frames.begin() + dropCount); + baseFrameIndex += dropCount; + } + + const unsigned char* frameAt(int frameIndex) const { + if (frames.empty()) { + return nullptr; + } + const int clampedFrameIndex = + std::max(baseFrameIndex, std::min(frameIndex, baseFrameIndex + static_cast(frames.size()) - 1)); + return frames[clampedFrameIndex - baseFrameIndex]; + } +}; + +struct CursorSample { + double timeMs = 0.0; + double cx = 0.0; + double cy = 0.0; + int typeIndex = 0; + double bounceScale = 1.0; + bool visible = true; +}; + +struct CursorPosition { + bool visible = false; + double cx = 0.0; + double cy = 0.0; + int typeIndex = 0; + double bounceScale = 1.0; +}; + +struct CursorTrack { + std::vector samples; + + CursorPosition positionAt(double timeMs) const { + if (samples.empty()) { + return {}; + } + if (timeMs <= samples.front().timeMs) { + return { + samples.front().visible, + samples.front().cx, + samples.front().cy, + samples.front().typeIndex, + samples.front().bounceScale, + }; + } + if (timeMs >= samples.back().timeMs) { + return { + samples.back().visible, + samples.back().cx, + samples.back().cy, + samples.back().typeIndex, + samples.back().bounceScale, + }; + } + + int low = 0; + int high = static_cast(samples.size()) - 1; + while (low < high - 1) { + const int mid = (low + high) / 2; + if (samples[mid].timeMs <= timeMs) { + low = mid; + } else { + high = mid; + } + } + + const CursorSample& left = samples[low]; + const CursorSample& right = samples[high]; + const double span = right.timeMs - left.timeMs; + if (span <= 0.0) { + return {left.visible, left.cx, left.cy, left.typeIndex, left.bounceScale}; + } + + const double t = (timeMs - left.timeMs) / span; + return { + left.visible && right.visible, + left.cx + (right.cx - left.cx) * t, + left.cy + (right.cy - left.cy) * t, + t < 0.5 ? left.typeIndex : right.typeIndex, + left.bounceScale + (right.bounceScale - left.bounceScale) * t, + }; + } +}; + +std::unique_ptr loadCursorTrack(const Options& options) { + if (options.cursorSamplesPath.empty()) { + return nullptr; + } + if (options.cursorHeight <= 0) { + fail("--cursor-height is required with --cursor-samples"); + } + + std::ifstream input(options.cursorSamplesPath); + if (!input) { + fail("Failed to open cursor samples: " + options.cursorSamplesPath); + } + + auto track = std::make_unique(); + std::string line; + while (std::getline(input, line)) { + if (line.empty()) { + continue; + } + std::istringstream row(line); + CursorSample sample; + if (!(row >> sample.timeMs >> sample.cx >> sample.cy)) { + continue; + } + if (!(row >> sample.typeIndex)) { + sample.typeIndex = 0; + } + if (!(row >> sample.bounceScale)) { + sample.bounceScale = 1.0; + } + int visible = 1; + if (row >> visible) { + sample.visible = visible != 0; + } + if (sample.cx < -1.0 || sample.cx > 2.0 || sample.cy < -1.0 || sample.cy > 2.0) { + continue; + } + sample.typeIndex = std::max(0, std::min(kMaxCursorAtlasEntries - 1, sample.typeIndex)); + sample.bounceScale = std::max(0.5, std::min(2.0, sample.bounceScale)); + track->samples.push_back(sample); + } + if (track->samples.empty()) { + fail("No cursor samples were loaded: " + options.cursorSamplesPath); + } + return track; +} + +struct ZoomSample { + double timeMs = 0.0; + double scale = 1.0; + double x = 0.0; + double y = 0.0; +}; + +struct ZoomTrack { + std::vector samples; + + ZoomSample sampleAt(double timeMs) const { + if (samples.empty()) { + return {}; + } + if (timeMs <= samples.front().timeMs) { + return samples.front(); + } + if (timeMs >= samples.back().timeMs) { + return samples.back(); + } + + int low = 0; + int high = static_cast(samples.size()) - 1; + while (low < high - 1) { + const int mid = (low + high) / 2; + if (samples[mid].timeMs <= timeMs) { + low = mid; + } else { + high = mid; + } + } + + const ZoomSample& left = samples[low]; + const ZoomSample& right = samples[high]; + const double span = right.timeMs - left.timeMs; + if (span <= 0.0) { + return left; + } + + const double t = (timeMs - left.timeMs) / span; + return { + timeMs, + left.scale + (right.scale - left.scale) * t, + left.x + (right.x - left.x) * t, + left.y + (right.y - left.y) * t, + }; + } +}; + +std::unique_ptr loadZoomTrack(const Options& options) { + if (options.zoomSamplesPath.empty()) { + return nullptr; + } + + std::ifstream input(options.zoomSamplesPath); + if (!input) { + fail("Failed to open zoom samples: " + options.zoomSamplesPath); + } + + auto track = std::make_unique(); + std::string line; + while (std::getline(input, line)) { + if (line.empty()) { + continue; + } + std::replace(line.begin(), line.end(), ',', ' '); + std::istringstream row(line); + ZoomSample sample; + if (!(row >> sample.timeMs >> sample.scale >> sample.x >> sample.y)) { + continue; + } + if (!std::isfinite(sample.timeMs) || !std::isfinite(sample.scale) || + !std::isfinite(sample.x) || !std::isfinite(sample.y)) { + continue; + } + sample.timeMs = std::max(0.0, sample.timeMs); + sample.scale = std::max(0.01, sample.scale); + track->samples.push_back(sample); + } + if (track->samples.empty()) { + fail("No zoom samples were loaded: " + options.zoomSamplesPath); + } + std::sort(track->samples.begin(), track->samples.end(), [](const auto& left, const auto& right) { + return left.timeMs < right.timeMs; + }); + return track; +} + +struct CursorAtlasEntry { + int x = 0; + int y = 0; + int width = 0; + int height = 0; + double anchorX = 0.0; + double anchorY = 0.0; + double aspectRatio = 0.0; + bool valid = false; +}; + +struct WebcamCacheState { + WebcamFrameCache* cache = nullptr; +}; + +void cacheMappedWebcamFrame( + CUdeviceptr dpSrcFrame, + unsigned int nSrcPitch, + int width, + int height, + int surfaceHeight, + int64_t, + void* userData) { + auto* state = static_cast(userData); + if (state->cache->width == 0) { + state->cache->width = width; + state->cache->height = height; + } + if (width != state->cache->width || height != state->cache->height) { + std::ostringstream stream; + stream << "Decoded webcam frame size changed from " << state->cache->width << "x" << state->cache->height + << " to " << width << "x" << height; + fail(stream.str()); + } + + const auto copyStart = std::chrono::steady_clock::now(); + const size_t expectedBytes = static_cast(width) * static_cast(height) * 3 / 2; + unsigned char* frame = nullptr; + checkCuda(cudaMalloc(&frame, expectedBytes), "cudaMalloc webcam cached frame"); + + CUDA_MEMCPY2D copy = {}; + copy.srcMemoryType = CU_MEMORYTYPE_DEVICE; + copy.srcDevice = dpSrcFrame; + copy.srcPitch = nSrcPitch; + copy.dstMemoryType = CU_MEMORYTYPE_DEVICE; + copy.dstDevice = reinterpret_cast(frame); + copy.dstPitch = width; + copy.WidthInBytes = width; + copy.Height = height; + checkCu(cuMemcpy2D(©), "cuMemcpy2D webcam luma"); + + copy.srcDevice = dpSrcFrame + nSrcPitch * surfaceHeight; + copy.dstDevice = reinterpret_cast(frame + width * height); + copy.Height = height / 2; + checkCu(cuMemcpy2D(©), "cuMemcpy2D webcam chroma"); + + state->cache->pushFrame(frame); + const auto copyEnd = std::chrono::steady_clock::now(); + state->cache->copyMs += elapsedMs(copyStart, copyEnd); +} + +class WebcamStreamDecoder { +public: + WebcamStreamDecoder(CUcontext context, const Options& options) + : options_(options), + chunk_(static_cast(options.chunkMb) * 1024 * 1024) { + if (options_.webcamInputFrames <= 0 || options_.webcamTargetFrames <= 0) { + fail("--webcam-input-frames and --webcam-target-frames are required with --webcam-annexb"); + } + if (options_.webcamSourceWidth <= 0 || options_.webcamSourceHeight <= 0) { + fail("--webcam-source-width and --webcam-source-height are required with --webcam-annexb"); + } + + const int cropSide = std::min(options_.webcamSourceWidth, options_.webcamSourceHeight) & ~1; + const int cropLeft = ((options_.webcamSourceWidth - cropSide) / 2) & ~1; + const int cropTop = ((options_.webcamSourceHeight - cropSide) / 2) & ~1; + crop_ = Rect{cropLeft, cropTop, cropLeft + cropSide, cropTop + cropSide}; + + cacheState_.cache = &cache_; + decoder_ = + std::make_unique(context, 0, 0, true, cudaVideoCodec_H264, nullptr, true, true, &crop_, nullptr); + decoder_->SetMappedFrameHandler(cacheMappedWebcamFrame, &cacheState_); + + input_.open(options_.webcamAnnexbPath, std::ios::binary); + if (!input_) { + fail("Failed to open webcam input: " + options_.webcamAnnexbPath); + } + } + + WebcamFrameCache* cache() { + return &cache_; + } + + void ensureFrame(int frameIndex) { + if (frameIndex < 0) { + return; + } + while (!flushed_ && cache_.decodedFrames <= frameIndex) { + input_.read(reinterpret_cast(chunk_.data()), static_cast(chunk_.size())); + const int bytesRead = static_cast(input_.gcount()); + const auto decodeStart = std::chrono::steady_clock::now(); + if (bytesRead > 0) { + decoder_->Decode(chunk_.data(), bytesRead, &frames_, &returnedFrames_); + } else { + decoder_->Decode(nullptr, 0, &frames_, &returnedFrames_); + flushed_ = true; + } + const auto decodeEnd = std::chrono::steady_clock::now(); + cache_.decodeMs += elapsedMs(decodeStart, decodeEnd); + } + if (cache_.frames.empty()) { + fail("No webcam frames were decoded"); + } + } + + void dropBefore(int frameIndex) { + cache_.dropBefore(frameIndex); + } + +private: + const Options& options_; + WebcamFrameCache cache_; + WebcamCacheState cacheState_{}; + Rect crop_{}; + std::unique_ptr decoder_; + std::ifstream input_; + std::vector chunk_; + uint8_t** frames_ = nullptr; + int returnedFrames_ = 0; + bool flushed_ = false; +}; + +std::unique_ptr createWebcamStreamDecoder(CUcontext context, const Options& options) { + if (options.webcamAnnexbPath.empty()) { + return nullptr; + } + return std::make_unique(context, options); +} + +__global__ void copyNv12Kernel( + const unsigned char* src, + int srcPitch, + int srcWidth, + int srcHeight, + int srcSurfaceHeight, + unsigned char* dst, + int dstPitch, + int dstChromaOffset, + int dstWidth, + int dstHeight) { + const int x = blockIdx.x * blockDim.x + threadIdx.x; + const int y = blockIdx.y * blockDim.y + threadIdx.y; + if (x >= dstWidth || y >= dstHeight) { + return; + } + + const int sx = min(srcWidth - 1, (x * srcWidth) / dstWidth); + const int sy = min(srcHeight - 1, (y * srcHeight) / dstHeight); + dst[y * dstPitch + x] = src[sy * srcPitch + sx]; + + if ((x % 2) == 0 && (y % 2) == 0) { + const int suvX = min(srcWidth - 2, ((x * srcWidth) / dstWidth) & ~1); + const int suvY = min((srcHeight / 2) - 1, (y * srcHeight / dstHeight) / 2); + const unsigned char* srcUv = src + srcPitch * srcSurfaceHeight + suvY * srcPitch + suvX; + unsigned char* dstUv = dst + dstChromaOffset + (y / 2) * dstPitch + x; + dstUv[0] = srcUv[0]; + dstUv[1] = srcUv[1]; + } +} + +__global__ void fillNv12Kernel( + unsigned char* dst, + int dstPitch, + int dstChromaOffset, + int dstWidth, + int dstHeight, + unsigned char yValue, + unsigned char uValue, + unsigned char vValue) { + const int x = blockIdx.x * blockDim.x + threadIdx.x; + const int y = blockIdx.y * blockDim.y + threadIdx.y; + if (x >= dstWidth || y >= dstHeight) { + return; + } + + dst[y * dstPitch + x] = yValue; + if ((x % 2) == 0 && (y % 2) == 0) { + unsigned char* dstUv = dst + dstChromaOffset + (y / 2) * dstPitch + x; + dstUv[0] = uValue; + dstUv[1] = vValue; + } +} + +__device__ bool isInsideRoundedRect( + int x, + int y, + int left, + int top, + int width, + int height, + int radius) { + if (x < left || y < top || x >= left + width || y >= top + height) { + return false; + } + if (radius <= 0) { + return true; + } + + const int right = left + width - 1; + const int bottom = top + height - 1; + const int innerLeft = left + radius; + const int innerRight = right - radius; + const int innerTop = top + radius; + const int innerBottom = bottom - radius; + if ((x >= innerLeft && x <= innerRight) || (y >= innerTop && y <= innerBottom)) { + return true; + } + + const int cx = x < innerLeft ? innerLeft : innerRight; + const int cy = y < innerTop ? innerTop : innerBottom; + const int dx = x - cx; + const int dy = y - cy; + return dx * dx + dy * dy <= radius * radius; +} + +__global__ void overlayContentRectNv12Kernel( + const unsigned char* src, + int srcPitch, + int srcWidth, + int srcHeight, + int srcSurfaceHeight, + unsigned char* dst, + int dstPitch, + int dstChromaOffset, + int dstWidth, + int dstHeight, + int contentX, + int contentY, + int contentWidth, + int contentHeight, + int sourceCropX, + int sourceCropY, + int sourceCropWidth, + int sourceCropHeight) { + const int localX = blockIdx.x * blockDim.x + threadIdx.x; + const int localY = blockIdx.y * blockDim.y + threadIdx.y; + if (localX >= contentWidth || localY >= contentHeight) { + return; + } + + const int x = contentX + localX; + const int y = contentY + localY; + if (x < 0 || y < 0 || x >= dstWidth || y >= dstHeight) { + return; + } + + const int cropWidth = max(1, min(sourceCropWidth > 0 ? sourceCropWidth : srcWidth, srcWidth - sourceCropX)); + const int cropHeight = max(1, min(sourceCropHeight > 0 ? sourceCropHeight : srcHeight, srcHeight - sourceCropY)); + const int cropX = max(0, min(sourceCropX, srcWidth - 1)); + const int cropY = max(0, min(sourceCropY, srcHeight - 1)); + const int srcX = min(srcWidth - 1, cropX + (localX * cropWidth) / contentWidth); + const int srcY = min(srcHeight - 1, cropY + (localY * cropHeight) / contentHeight); + dst[y * dstPitch + x] = src[srcY * srcPitch + srcX]; + + if ((x % 2) == 0 && (y % 2) == 0) { + const int localUvX = max(0, min(contentWidth - 1, localX + 1)); + const int localUvY = max(0, min(contentHeight - 1, localY + 1)); + const int srcUvX = min(srcWidth - 2, (cropX + ((localUvX * cropWidth) / contentWidth)) & ~1); + const int srcUvY = min((srcHeight / 2) - 1, (cropY + ((localUvY * cropHeight) / contentHeight)) / 2); + const unsigned char* srcUv = src + srcPitch * srcSurfaceHeight + srcUvY * srcPitch + srcUvX; + unsigned char* dstUv = dst + dstChromaOffset + (y / 2) * dstPitch + x; + dstUv[0] = srcUv[0]; + dstUv[1] = srcUv[1]; + } +} + +__global__ void overlayContentTransformNv12Kernel( + const unsigned char* src, + int srcPitch, + int srcWidth, + int srcHeight, + int srcSurfaceHeight, + unsigned char* dst, + int dstPitch, + int dstChromaOffset, + int dstWidth, + int dstHeight, + int regionX, + int regionY, + int regionWidth, + int regionHeight, + int contentX, + int contentY, + int contentWidth, + int contentHeight, + int radius, + float zoomScale, + float invZoomScale, + float srcScaleX, + float srcScaleY, + int sourceCropX, + int sourceCropY, + float zoomX, + float zoomY) { + const int localX = blockIdx.x * blockDim.x + threadIdx.x; + const int localY = blockIdx.y * blockDim.y + threadIdx.y; + if (localX >= regionWidth || localY >= regionHeight) { + return; + } + + const int x = regionX + localX; + const int y = regionY + localY; + if (x < 0 || y < 0 || x >= dstWidth || y >= dstHeight) { + return; + } + + const float layoutXf = (static_cast(x) - zoomX) * invZoomScale; + const float layoutYf = (static_cast(y) - zoomY) * invZoomScale; + const int layoutX = __float2int_rd(layoutXf); + const int layoutY = __float2int_rd(layoutYf); + if (!isInsideRoundedRect(layoutX, layoutY, contentX, contentY, contentWidth, contentHeight, radius)) { + return; + } + + const float localContentX = + fminf(static_cast(contentWidth - 1), fmaxf(0.0f, layoutXf - contentX)); + const float localContentY = + fminf(static_cast(contentHeight - 1), fmaxf(0.0f, layoutYf - contentY)); + const int cropX = max(0, min(sourceCropX, srcWidth - 1)); + const int cropY = max(0, min(sourceCropY, srcHeight - 1)); + const int sx = min(srcWidth - 1, cropX + __float2int_rd(localContentX * srcScaleX)); + const int sy = min(srcHeight - 1, cropY + __float2int_rd(localContentY * srcScaleY)); + dst[y * dstPitch + x] = src[sy * srcPitch + sx]; + + if ((x % 2) == 0 && (y % 2) == 0 && x + 1 < dstWidth && y + 1 < dstHeight) { + const float uvLayoutXf = (static_cast(x + 1) - zoomX) * invZoomScale; + const float uvLayoutYf = (static_cast(y + 1) - zoomY) * invZoomScale; + const int uvLayoutX = __float2int_rd(uvLayoutXf); + const int uvLayoutY = __float2int_rd(uvLayoutYf); + if (isInsideRoundedRect( + uvLayoutX, + uvLayoutY, + contentX, + contentY, + contentWidth, + contentHeight, + radius)) { + const float uvLocalContentX = + fminf(static_cast(contentWidth - 1), fmaxf(0.0f, uvLayoutXf - contentX)); + const float uvLocalContentY = + fminf(static_cast(contentHeight - 1), fmaxf(0.0f, uvLayoutYf - contentY)); + const int suvX = + min(srcWidth - 2, (cropX + __float2int_rd(uvLocalContentX * srcScaleX)) & ~1); + const int suvY = + min((srcHeight / 2) - 1, (cropY + __float2int_rd(uvLocalContentY * srcScaleY)) / 2); + const unsigned char* srcUv = src + srcPitch * srcSurfaceHeight + suvY * srcPitch + suvX; + unsigned char* dstUv = dst + dstChromaOffset + (y / 2) * dstPitch + x; + dstUv[0] = srcUv[0]; + dstUv[1] = srcUv[1]; + } + } +} + +__global__ void restoreRoundedContentCornersNv12Kernel( + unsigned char* dst, + int dstPitch, + int dstChromaOffset, + int dstWidth, + int dstHeight, + int contentX, + int contentY, + int contentWidth, + int contentHeight, + int radius, + unsigned char backgroundY, + unsigned char backgroundU, + unsigned char backgroundV, + const unsigned char* background) { + const int localX = blockIdx.x * blockDim.x + threadIdx.x; + const int localY = blockIdx.y * blockDim.y + threadIdx.y; + if (localX >= radius || localY >= radius) { + return; + } + + const int corner = blockIdx.z; + const bool right = corner == 1 || corner == 3; + const bool bottom = corner >= 2; + const int x = right ? contentX + contentWidth - radius + localX : contentX + localX; + const int y = bottom ? contentY + contentHeight - radius + localY : contentY + localY; + if (x < 0 || y < 0 || x >= dstWidth || y >= dstHeight) { + return; + } + + if (!isInsideRoundedRect(x, y, contentX, contentY, contentWidth, contentHeight, radius)) { + dst[y * dstPitch + x] = background ? background[y * dstWidth + x] : backgroundY; + } + + if ((x % 2) == 0 && (y % 2) == 0 && + !isInsideRoundedRect(x + 1, y + 1, contentX, contentY, contentWidth, contentHeight, radius)) { + unsigned char* dstUv = dst + dstChromaOffset + (y / 2) * dstPitch + x; + if (background) { + const unsigned char* bgUv = background + dstWidth * dstHeight + (y / 2) * dstWidth + x; + dstUv[0] = bgUv[0]; + dstUv[1] = bgUv[1]; + } else { + dstUv[0] = backgroundU; + dstUv[1] = backgroundV; + } + } +} + +__device__ bool pointInCursorPolygon(float x, float y, bool inner) { + constexpr int kCount = 7; + const float outerX[kCount] = {2.0f, 61.0f, 45.0f, 57.0f, 38.0f, 27.0f, 13.0f}; + const float outerY[kCount] = {2.0f, 61.0f, 63.0f, 91.0f, 95.0f, 67.0f, 79.0f}; + const float innerX[kCount] = {10.0f, 52.0f, 37.0f, 49.0f, 40.0f, 28.0f, 18.0f}; + const float innerY[kCount] = {11.0f, 53.0f, 53.0f, 78.0f, 83.0f, 57.0f, 66.0f}; + const float* px = inner ? innerX : outerX; + const float* py = inner ? innerY : outerY; + + bool inside = false; + for (int index = 0, previous = kCount - 1; index < kCount; previous = index++) { + const bool crosses = ((py[index] > y) != (py[previous] > y)) && + (x < (px[previous] - px[index]) * (y - py[index]) / (py[previous] - py[index]) + px[index]); + if (crosses) { + inside = !inside; + } + } + return inside; +} + +__device__ int cursorMaskAt( + int x, + int y, + int cursorX, + int cursorY, + int cursorWidth, + int cursorHeight) { + if (cursorWidth <= 0 || cursorHeight <= 0 || x < cursorX || y < cursorY || + x >= cursorX + cursorWidth || y >= cursorY + cursorHeight) { + return 0; + } + + const float localX = static_cast(x - cursorX) * 64.0f / static_cast(cursorWidth); + const float localY = static_cast(y - cursorY) * 96.0f / static_cast(cursorHeight); + if (pointInCursorPolygon(localX, localY, true)) { + return 2; + } + if (pointInCursorPolygon(localX, localY, false)) { + return 1; + } + return 0; +} + +__device__ unsigned char clampByteDevice(int value) { + return static_cast(min(255, max(0, value))); +} + +__device__ unsigned char blendByte(unsigned char base, unsigned char overlay, int alpha) { + return static_cast( + (static_cast(base) * (255 - alpha) + static_cast(overlay) * alpha + 127) / 255); +} + +__device__ bool sampleCursorAtlasNv12( + const unsigned char* atlas, + int atlasWidth, + int atlasHeight, + int entryX, + int entryY, + int entryWidth, + int entryHeight, + int cursorX, + int cursorY, + int cursorWidth, + int cursorHeight, + int x, + int y, + unsigned char* outY, + unsigned char* outU, + unsigned char* outV, + int* outAlpha) { + if (!atlas || cursorWidth <= 0 || cursorHeight <= 0 || entryWidth <= 0 || entryHeight <= 0 || + x < cursorX || y < cursorY || x >= cursorX + cursorWidth || y >= cursorY + cursorHeight) { + return false; + } + + const int localX = max(0, min(cursorWidth - 1, x - cursorX)); + const int localY = max(0, min(cursorHeight - 1, y - cursorY)); + const int sampleX = entryX + min(entryWidth - 1, (localX * entryWidth) / cursorWidth); + const int sampleY = entryY + min(entryHeight - 1, (localY * entryHeight) / cursorHeight); + if (sampleX < 0 || sampleY < 0 || sampleX >= atlasWidth || sampleY >= atlasHeight) { + return false; + } + + const int offset = (sampleY * atlasWidth + sampleX) * 4; + const int alpha = atlas[offset + 3]; + if (alpha <= 0) { + return false; + } + + const int r = atlas[offset]; + const int g = atlas[offset + 1]; + const int b = atlas[offset + 2]; + *outY = clampByteDevice(((66 * r + 129 * g + 25 * b + 128) >> 8) + 16); + *outU = clampByteDevice(((-38 * r - 74 * g + 112 * b + 128) >> 8) + 128); + *outV = clampByteDevice(((112 * r - 94 * g - 18 * b + 128) >> 8) + 128); + *outAlpha = alpha; + return true; +} + +__device__ int sampleCursorAtlasAlpha( + const unsigned char* atlas, + int atlasWidth, + int atlasHeight, + int entryX, + int entryY, + int entryWidth, + int entryHeight, + int cursorX, + int cursorY, + int cursorWidth, + int cursorHeight, + int x, + int y) { + if (!atlas || cursorWidth <= 0 || cursorHeight <= 0 || entryWidth <= 0 || entryHeight <= 0 || + x < cursorX || y < cursorY || x >= cursorX + cursorWidth || y >= cursorY + cursorHeight) { + return 0; + } + + const int localX = max(0, min(cursorWidth - 1, x - cursorX)); + const int localY = max(0, min(cursorHeight - 1, y - cursorY)); + const int sampleX = entryX + min(entryWidth - 1, (localX * entryWidth) / cursorWidth); + const int sampleY = entryY + min(entryHeight - 1, (localY * entryHeight) / cursorHeight); + if (sampleX < 0 || sampleY < 0 || sampleX >= atlasWidth || sampleY >= atlasHeight) { + return 0; + } + + return atlas[(sampleY * atlasWidth + sampleX) * 4 + 3]; +} + +__device__ int sampleCursorAtlasShadowAlpha( + const unsigned char* atlas, + int atlasWidth, + int atlasHeight, + int entryX, + int entryY, + int entryWidth, + int entryHeight, + int cursorX, + int cursorY, + int cursorWidth, + int cursorHeight, + int x, + int y) { + int weightedAlpha = 0; + weightedAlpha += sampleCursorAtlasAlpha( + atlas, + atlasWidth, + atlasHeight, + entryX, + entryY, + entryWidth, + entryHeight, + cursorX, + cursorY + 2, + cursorWidth, + cursorHeight, + x, + y) * 20; + weightedAlpha += sampleCursorAtlasAlpha( + atlas, + atlasWidth, + atlasHeight, + entryX, + entryY, + entryWidth, + entryHeight, + cursorX - 2, + cursorY + 2, + cursorWidth, + cursorHeight, + x, + y) * 6; + weightedAlpha += sampleCursorAtlasAlpha( + atlas, + atlasWidth, + atlasHeight, + entryX, + entryY, + entryWidth, + entryHeight, + cursorX + 2, + cursorY + 2, + cursorWidth, + cursorHeight, + x, + y) * 6; + weightedAlpha += sampleCursorAtlasAlpha( + atlas, + atlasWidth, + atlasHeight, + entryX, + entryY, + entryWidth, + entryHeight, + cursorX, + cursorY, + cursorWidth, + cursorHeight, + x, + y) * 4; + weightedAlpha += sampleCursorAtlasAlpha( + atlas, + atlasWidth, + atlasHeight, + entryX, + entryY, + entryWidth, + entryHeight, + cursorX, + cursorY + 4, + cursorWidth, + cursorHeight, + x, + y) * 4; + return min(255, weightedAlpha / 100); +} + +__global__ void compositeStaticNv12Kernel( + const unsigned char* src, + int srcPitch, + int srcWidth, + int srcHeight, + int srcSurfaceHeight, + unsigned char* dst, + int dstPitch, + int dstChromaOffset, + int dstWidth, + int dstHeight, + int contentX, + int contentY, + int contentWidth, + int contentHeight, + int sourceCropX, + int sourceCropY, + int sourceCropWidth, + int sourceCropHeight, + int radius, + unsigned char backgroundY, + unsigned char backgroundU, + unsigned char backgroundV, + const unsigned char* background, + int shadowOffsetY, + int shadowIntensityPct, + const unsigned char* webcam, + int webcamX, + int webcamY, + int webcamSize, + int webcamFrameWidth, + int webcamFrameHeight, + int webcamRadius, + bool webcamMirror, + bool cursorVisible, + int cursorX, + int cursorY, + int cursorWidth, + int cursorHeight, + const unsigned char* cursorAtlasRgba, + int cursorAtlasWidth, + int cursorAtlasHeight, + int cursorAtlasEntryX, + int cursorAtlasEntryY, + int cursorAtlasEntryWidth, + int cursorAtlasEntryHeight, + bool zoomEnabled, + float zoomScale, + float zoomX, + float zoomY) { + const int x = blockIdx.x * blockDim.x + threadIdx.x; + const int y = blockIdx.y * blockDim.y + threadIdx.y; + if (x >= dstWidth || y >= dstHeight) { + return; + } + + const bool zoomActive = zoomEnabled && zoomScale > 0.01f; + const float safeZoomScale = fmaxf(zoomScale, 0.01f); + const float layoutXf = zoomActive ? (static_cast(x) - zoomX) / safeZoomScale : static_cast(x); + const float layoutYf = zoomActive ? (static_cast(y) - zoomY) / safeZoomScale : static_cast(y); + const int layoutX = static_cast(floorf(layoutXf)); + const int layoutY = static_cast(floorf(layoutYf)); + + const int cropX = max(0, min(sourceCropX, srcWidth - 1)); + const int cropY = max(0, min(sourceCropY, srcHeight - 1)); + const int cropWidth = max(1, min(sourceCropWidth > 0 ? sourceCropWidth : srcWidth, srcWidth - cropX)); + const int cropHeight = max(1, min(sourceCropHeight > 0 ? sourceCropHeight : srcHeight, srcHeight - cropY)); + const bool inside = isInsideRoundedRect(layoutX, layoutY, contentX, contentY, contentWidth, contentHeight, radius); + unsigned char outY = background ? background[y * dstWidth + x] : backgroundY; + if (inside) { + const float localX = fminf(static_cast(contentWidth - 1), fmaxf(0.0f, layoutXf - contentX)); + const float localY = fminf(static_cast(contentHeight - 1), fmaxf(0.0f, layoutYf - contentY)); + const int sx = min(srcWidth - 1, cropX + static_cast((localX * cropWidth) / contentWidth)); + const int sy = min(srcHeight - 1, cropY + static_cast((localY * cropHeight) / contentHeight)); + outY = src[sy * srcPitch + sx]; + } else { + const bool shadowInside = + shadowIntensityPct > 0 && + isInsideRoundedRect( + layoutX, + layoutY, + contentX, + contentY + shadowOffsetY, + contentWidth, + contentHeight, + radius + 8); + if (shadowInside) { + const int darkenPct = min(75, max(0, shadowIntensityPct / 2)); + outY = static_cast((static_cast(outY) * (100 - darkenPct)) / 100); + } + } + if (webcam && isInsideRoundedRect(x, y, webcamX, webcamY, webcamSize, webcamSize, webcamRadius)) { + const int localX = max(0, min(webcamSize - 1, x - webcamX)); + const int localY = max(0, min(webcamSize - 1, y - webcamY)); + const int sampleX = min(webcamFrameWidth - 1, (localX * webcamFrameWidth) / webcamSize); + const int sampleY = min(webcamFrameHeight - 1, (localY * webcamFrameHeight) / webcamSize); + const int mirroredX = webcamMirror ? webcamFrameWidth - 1 - sampleX : sampleX; + outY = webcam[sampleY * webcamFrameWidth + mirroredX]; + } + unsigned char cursorYValue = 0; + unsigned char cursorUValue = 128; + unsigned char cursorVValue = 128; + int cursorAlpha = 0; + const int cursorShadowAlpha = cursorVisible && cursorAtlasRgba + ? sampleCursorAtlasShadowAlpha( + cursorAtlasRgba, + cursorAtlasWidth, + cursorAtlasHeight, + cursorAtlasEntryX, + cursorAtlasEntryY, + cursorAtlasEntryWidth, + cursorAtlasEntryHeight, + cursorX, + cursorY, + cursorWidth, + cursorHeight, + x, + y) + : 0; + if (cursorShadowAlpha > 0) { + outY = blendByte(outY, 16, cursorShadowAlpha); + } + const bool cursorAtlasHit = + cursorVisible && + sampleCursorAtlasNv12( + cursorAtlasRgba, + cursorAtlasWidth, + cursorAtlasHeight, + cursorAtlasEntryX, + cursorAtlasEntryY, + cursorAtlasEntryWidth, + cursorAtlasEntryHeight, + cursorX, + cursorY, + cursorWidth, + cursorHeight, + x, + y, + &cursorYValue, + &cursorUValue, + &cursorVValue, + &cursorAlpha); + if (cursorAtlasHit) { + outY = blendByte(outY, cursorYValue, cursorAlpha); + } else { + const int cursorMask = cursorVisible && !cursorAtlasRgba + ? cursorMaskAt(x, y, cursorX, cursorY, cursorWidth, cursorHeight) + : 0; + if (cursorMask == 1) { + outY = 235; + } else if (cursorMask == 2) { + outY = 16; + } + } + dst[y * dstPitch + x] = outY; + + if ((x % 2) == 0 && (y % 2) == 0) { + unsigned char* dstUv = dst + dstChromaOffset + (y / 2) * dstPitch + x; + const float uvLayoutXf = + zoomActive ? (static_cast(x + 1) - zoomX) / safeZoomScale : static_cast(x + 1); + const float uvLayoutYf = + zoomActive ? (static_cast(y + 1) - zoomY) / safeZoomScale : static_cast(y + 1); + const int uvLayoutX = static_cast(floorf(uvLayoutXf)); + const int uvLayoutY = static_cast(floorf(uvLayoutYf)); + const bool uvInside = isInsideRoundedRect( + uvLayoutX, + uvLayoutY, + contentX, + contentY, + contentWidth, + contentHeight, + radius); + if (uvInside) { + const float localX = fminf(static_cast(contentWidth - 1), fmaxf(0.0f, uvLayoutXf - contentX)); + const float localY = fminf(static_cast(contentHeight - 1), fmaxf(0.0f, uvLayoutYf - contentY)); + const int suvX = + min(srcWidth - 2, (cropX + static_cast((localX * cropWidth) / contentWidth)) & ~1); + const int suvY = + min((srcHeight / 2) - 1, (cropY + static_cast(localY * cropHeight / contentHeight)) / 2); + const unsigned char* srcUv = src + srcPitch * srcSurfaceHeight + suvY * srcPitch + suvX; + dstUv[0] = srcUv[0]; + dstUv[1] = srcUv[1]; + } else { + if (background) { + const unsigned char* bgUv = background + dstWidth * dstHeight + (y / 2) * dstWidth + x; + dstUv[0] = bgUv[0]; + dstUv[1] = bgUv[1]; + } else { + dstUv[0] = backgroundU; + dstUv[1] = backgroundV; + } + } + if (webcam && + isInsideRoundedRect( + x + 1, + y + 1, + webcamX, + webcamY, + webcamSize, + webcamSize, + webcamRadius)) { + const int localX = max(0, min(webcamSize - 1, x + 1 - webcamX)); + const int localY = max(0, min(webcamSize - 1, y + 1 - webcamY)); + const int sampleX = min(webcamFrameWidth - 1, (localX * webcamFrameWidth) / webcamSize); + const int sampleY = min(webcamFrameHeight - 1, (localY * webcamFrameHeight) / webcamSize); + const int mirroredX = webcamMirror ? webcamFrameWidth - 1 - sampleX : sampleX; + const int webcamUvX = min(webcamFrameWidth - 2, mirroredX & ~1); + const int webcamUvY = min((webcamFrameHeight / 2) - 1, sampleY / 2); + const unsigned char* webcamUv = + webcam + webcamFrameWidth * webcamFrameHeight + webcamUvY * webcamFrameWidth + webcamUvX; + dstUv[0] = webcamUv[0]; + dstUv[1] = webcamUv[1]; + } + unsigned char cursorUvY = 0; + unsigned char cursorUvU = 128; + unsigned char cursorUvV = 128; + int cursorUvAlpha = 0; + const int cursorUvShadowAlpha = cursorVisible && cursorAtlasRgba + ? sampleCursorAtlasShadowAlpha( + cursorAtlasRgba, + cursorAtlasWidth, + cursorAtlasHeight, + cursorAtlasEntryX, + cursorAtlasEntryY, + cursorAtlasEntryWidth, + cursorAtlasEntryHeight, + cursorX, + cursorY, + cursorWidth, + cursorHeight, + x + 1, + y + 1) + : 0; + if (cursorUvShadowAlpha > 0) { + dstUv[0] = blendByte(dstUv[0], 128, cursorUvShadowAlpha); + dstUv[1] = blendByte(dstUv[1], 128, cursorUvShadowAlpha); + } + const bool cursorAtlasUvHit = + cursorVisible && + sampleCursorAtlasNv12( + cursorAtlasRgba, + cursorAtlasWidth, + cursorAtlasHeight, + cursorAtlasEntryX, + cursorAtlasEntryY, + cursorAtlasEntryWidth, + cursorAtlasEntryHeight, + cursorX, + cursorY, + cursorWidth, + cursorHeight, + x + 1, + y + 1, + &cursorUvY, + &cursorUvU, + &cursorUvV, + &cursorUvAlpha); + if (cursorAtlasUvHit) { + dstUv[0] = blendByte(dstUv[0], cursorUvU, cursorUvAlpha); + dstUv[1] = blendByte(dstUv[1], cursorUvV, cursorUvAlpha); + } else { + const int cursorUvMask = + cursorVisible && !cursorAtlasRgba + ? cursorMaskAt(x + 1, y + 1, cursorX, cursorY, cursorWidth, cursorHeight) + : 0; + if (cursorUvMask > 0) { + dstUv[0] = 128; + dstUv[1] = 128; + } + } + } +} + +__global__ void overlayWebcamNv12Kernel( + unsigned char* dst, + int dstPitch, + int dstChromaOffset, + int dstWidth, + int dstHeight, + const unsigned char* webcam, + int regionX, + int regionY, + int regionWidth, + int regionHeight, + int webcamX, + int webcamY, + int webcamSize, + int webcamFrameWidth, + int webcamFrameHeight, + int webcamRadius, + bool webcamMirror) { + const int localX = blockIdx.x * blockDim.x + threadIdx.x; + const int localY = blockIdx.y * blockDim.y + threadIdx.y; + if (!webcam || localX >= regionWidth || localY >= regionHeight) { + return; + } + + const int x = regionX + localX; + const int y = regionY + localY; + if (x < 0 || y < 0 || x >= dstWidth || y >= dstHeight) { + return; + } + + if (isInsideRoundedRect(x, y, webcamX, webcamY, webcamSize, webcamSize, webcamRadius)) { + const int webcamLocalX = max(0, min(webcamSize - 1, x - webcamX)); + const int webcamLocalY = max(0, min(webcamSize - 1, y - webcamY)); + const int sampleX = min(webcamFrameWidth - 1, (webcamLocalX * webcamFrameWidth) / webcamSize); + const int sampleY = min(webcamFrameHeight - 1, (webcamLocalY * webcamFrameHeight) / webcamSize); + const int mirroredX = webcamMirror ? webcamFrameWidth - 1 - sampleX : sampleX; + dst[y * dstPitch + x] = webcam[sampleY * webcamFrameWidth + mirroredX]; + } + + if ((x % 2) == 0 && (y % 2) == 0 && x + 1 < dstWidth && y + 1 < dstHeight && + isInsideRoundedRect(x + 1, y + 1, webcamX, webcamY, webcamSize, webcamSize, webcamRadius)) { + const int uvLocalX = max(0, min(webcamSize - 1, x + 1 - webcamX)); + const int uvLocalY = max(0, min(webcamSize - 1, y + 1 - webcamY)); + const int uvSampleX = min(webcamFrameWidth - 1, (uvLocalX * webcamFrameWidth) / webcamSize); + const int uvSampleY = min(webcamFrameHeight - 1, (uvLocalY * webcamFrameHeight) / webcamSize); + const int uvMirroredX = webcamMirror ? webcamFrameWidth - 1 - uvSampleX : uvSampleX; + const int webcamUvX = min(webcamFrameWidth - 2, uvMirroredX & ~1); + const int webcamUvY = min((webcamFrameHeight / 2) - 1, uvSampleY / 2); + const unsigned char* webcamUv = + webcam + webcamFrameWidth * webcamFrameHeight + webcamUvY * webcamFrameWidth + webcamUvX; + unsigned char* dstUv = dst + dstChromaOffset + (y / 2) * dstPitch + x; + dstUv[0] = webcamUv[0]; + dstUv[1] = webcamUv[1]; + } +} + +__global__ void overlayCursorNv12Kernel( + unsigned char* dst, + int dstPitch, + int dstChromaOffset, + int dstWidth, + int dstHeight, + int regionX, + int regionY, + int regionWidth, + int regionHeight, + bool cursorVisible, + int cursorX, + int cursorY, + int cursorWidth, + int cursorHeight, + const unsigned char* cursorAtlasRgba, + int cursorAtlasWidth, + int cursorAtlasHeight, + int cursorAtlasEntryX, + int cursorAtlasEntryY, + int cursorAtlasEntryWidth, + int cursorAtlasEntryHeight) { + const int localX = blockIdx.x * blockDim.x + threadIdx.x; + const int localY = blockIdx.y * blockDim.y + threadIdx.y; + if (!cursorVisible || localX >= regionWidth || localY >= regionHeight) { + return; + } + + const int x = regionX + localX; + const int y = regionY + localY; + if (x < 0 || y < 0 || x >= dstWidth || y >= dstHeight) { + return; + } + + unsigned char outY = dst[y * dstPitch + x]; + unsigned char cursorYValue = 0; + unsigned char cursorUValue = 128; + unsigned char cursorVValue = 128; + int cursorAlpha = 0; + const int cursorShadowAlpha = cursorAtlasRgba + ? sampleCursorAtlasShadowAlpha( + cursorAtlasRgba, + cursorAtlasWidth, + cursorAtlasHeight, + cursorAtlasEntryX, + cursorAtlasEntryY, + cursorAtlasEntryWidth, + cursorAtlasEntryHeight, + cursorX, + cursorY, + cursorWidth, + cursorHeight, + x, + y) + : 0; + if (cursorShadowAlpha > 0) { + outY = blendByte(outY, 16, cursorShadowAlpha); + } + const bool cursorAtlasHit = + sampleCursorAtlasNv12( + cursorAtlasRgba, + cursorAtlasWidth, + cursorAtlasHeight, + cursorAtlasEntryX, + cursorAtlasEntryY, + cursorAtlasEntryWidth, + cursorAtlasEntryHeight, + cursorX, + cursorY, + cursorWidth, + cursorHeight, + x, + y, + &cursorYValue, + &cursorUValue, + &cursorVValue, + &cursorAlpha); + if (cursorAtlasHit) { + outY = blendByte(outY, cursorYValue, cursorAlpha); + } else { + const int cursorMask = !cursorAtlasRgba + ? cursorMaskAt(x, y, cursorX, cursorY, cursorWidth, cursorHeight) + : 0; + if (cursorMask == 1) { + outY = 235; + } else if (cursorMask == 2) { + outY = 16; + } + } + dst[y * dstPitch + x] = outY; + + if ((x % 2) == 0 && (y % 2) == 0 && x + 1 < dstWidth && y + 1 < dstHeight) { + unsigned char* dstUv = dst + dstChromaOffset + (y / 2) * dstPitch + x; + unsigned char cursorUvY = 0; + unsigned char cursorUvU = 128; + unsigned char cursorUvV = 128; + int cursorUvAlpha = 0; + const int cursorUvShadowAlpha = cursorAtlasRgba + ? sampleCursorAtlasShadowAlpha( + cursorAtlasRgba, + cursorAtlasWidth, + cursorAtlasHeight, + cursorAtlasEntryX, + cursorAtlasEntryY, + cursorAtlasEntryWidth, + cursorAtlasEntryHeight, + cursorX, + cursorY, + cursorWidth, + cursorHeight, + x + 1, + y + 1) + : 0; + if (cursorUvShadowAlpha > 0) { + dstUv[0] = blendByte(dstUv[0], 128, cursorUvShadowAlpha); + dstUv[1] = blendByte(dstUv[1], 128, cursorUvShadowAlpha); + } + const bool cursorAtlasUvHit = + sampleCursorAtlasNv12( + cursorAtlasRgba, + cursorAtlasWidth, + cursorAtlasHeight, + cursorAtlasEntryX, + cursorAtlasEntryY, + cursorAtlasEntryWidth, + cursorAtlasEntryHeight, + cursorX, + cursorY, + cursorWidth, + cursorHeight, + x + 1, + y + 1, + &cursorUvY, + &cursorUvU, + &cursorUvV, + &cursorUvAlpha); + if (cursorAtlasUvHit) { + dstUv[0] = blendByte(dstUv[0], cursorUvU, cursorUvAlpha); + dstUv[1] = blendByte(dstUv[1], cursorUvV, cursorUvAlpha); + } else { + const int cursorUvMask = + !cursorAtlasRgba ? cursorMaskAt(x + 1, y + 1, cursorX, cursorY, cursorWidth, cursorHeight) : 0; + if (cursorUvMask > 0) { + dstUv[0] = 128; + dstUv[1] = 128; + } + } + } +} + +__global__ void prewarmKernel(unsigned int* state, unsigned int seed) { + const unsigned int index = blockIdx.x * blockDim.x + threadIdx.x; + unsigned int value = seed ^ (index * 747796405u + 2891336453u); + for (int iteration = 0; iteration < 256; ++iteration) { + value = value * 1664525u + 1013904223u; + value ^= value >> 13; + } + state[index] = value; +} + +void prewarmCuda(int durationMs) { + if (durationMs <= 0) { + return; + } + + constexpr int blockSize = 256; + constexpr int blockCount = 256; + unsigned int* state = nullptr; + checkCuda(cudaMalloc(&state, blockSize * blockCount * sizeof(unsigned int)), "cudaMalloc prewarm"); + + const auto start = std::chrono::steady_clock::now(); + int iteration = 0; + while (elapsedMs(start, std::chrono::steady_clock::now()) < durationMs) { + prewarmKernel<<>>(state, static_cast(iteration++)); + checkCuda(cudaGetLastError(), "prewarmKernel"); + checkCuda(cudaDeviceSynchronize(), "cudaDeviceSynchronize prewarm"); + } + + checkCuda(cudaFree(state), "cudaFree prewarm"); +} + +GUID getNvencPresetGuid(const std::string& encodingMode) { + return encodingMode == "fast" ? NV_ENC_PRESET_HP_GUID : NV_ENC_PRESET_HQ_GUID; +} + +uint32_t getNvencMaxBitrate(uint32_t bitrate, const std::string& encodingMode) { + const uint64_t multiplier = encodingMode == "fast" ? 3 : 2; + const uint64_t divisor = encodingMode == "fast" ? 2 : 1; + return static_cast( + std::min(0xffffffffu, (static_cast(bitrate) * multiplier) / divisor)); +} + +uint32_t getNvencBufferSize(uint32_t bitrate, const std::string& encodingMode) { + const uint64_t multiplier = encodingMode == "fast" ? 1 : 2; + return static_cast( + std::min(0xffffffffu, static_cast(bitrate) * multiplier)); +} + +class NvencSink { +public: + NvencSink( + CUcontext context, + int width, + int height, + int fps, + uint32_t bitrate, + const std::string& outputPath, + bool streamSync, + Options layoutOptions, + const WebcamFrameCache* webcamCache, + const CursorTrack* cursorTrack, + const ZoomTrack* zoomTrack) + : encoder_(context, width, height, NV_ENC_BUFFER_FORMAT_NV12), + width_(width), + height_(height), + fps_(fps), + streamSync_(streamSync), + layoutOptions_(layoutOptions), + webcamCache_(webcamCache), + cursorTrack_(cursorTrack), + zoomTrack_(zoomTrack) { + loadBackgroundFrame(); + loadWebcamFrame(); + loadCursorAtlas(); + if (streamSync_) { + checkCuda(cudaStreamCreateWithFlags(©Stream_, cudaStreamNonBlocking), "cudaStreamCreateWithFlags"); + } + + NV_ENC_INITIALIZE_PARAMS initializeParams = {NV_ENC_INITIALIZE_PARAMS_VER}; + NV_ENC_CONFIG encodeConfig = {NV_ENC_CONFIG_VER}; + initializeParams.encodeConfig = &encodeConfig; + encoder_.CreateDefaultEncoderParams( + &initializeParams, + NV_ENC_CODEC_H264_GUID, + getNvencPresetGuid(layoutOptions_.encodingMode)); + + initializeParams.frameRateNum = static_cast(fps); + initializeParams.frameRateDen = 1; + initializeParams.enableEncodeAsync = 1; + encodeConfig.profileGUID = NV_ENC_H264_PROFILE_HIGH_GUID; + encodeConfig.gopLength = static_cast(fps * 2); + encodeConfig.frameIntervalP = 1; + encodeConfig.rcParams.rateControlMode = NV_ENC_PARAMS_RC_VBR; + encodeConfig.rcParams.averageBitRate = bitrate; + encodeConfig.rcParams.maxBitRate = getNvencMaxBitrate(bitrate, layoutOptions_.encodingMode); + encodeConfig.rcParams.vbvBufferSize = getNvencBufferSize(bitrate, layoutOptions_.encodingMode); + encodeConfig.rcParams.vbvInitialDelay = bitrate; + if (layoutOptions_.encodingMode != "fast") { + encodeConfig.rcParams.enableAQ = 1; + encodeConfig.rcParams.aqStrength = layoutOptions_.encodingMode == "quality" ? 10 : 8; + } + encodeConfig.encodeCodecConfig.h264Config.idrPeriod = encodeConfig.gopLength; + encoder_.CreateEncoder(&initializeParams); + + output_.open(outputPath, std::ios::binary); + if (!output_) { + fail("Failed to open output: " + outputPath); + } + } + + void encodeFrame( + const unsigned char* srcFrame, + int srcPitch, + int srcWidth, + int srcHeight, + int srcSurfaceHeight, + int outputFrameIndex) { + const NvEncInputFrame* inputFrame = encoder_.GetNextInputFrame(); + const dim3 block(16, 16); + const dim3 grid((width_ + block.x - 1) / block.x, (height_ + block.y - 1) / block.y); + const double outputFrameTimeMs = + static_cast(outputFrameIndex) * 1000.0 / static_cast(fps_); + const double frameTimeMs = + outputToSourceMs(layoutOptions_.timelineSegments, outputFrameTimeMs); + const unsigned char* webcamFrame = selectWebcamFrame(frameTimeMs); + const ZoomSample zoomSample = zoomTrack_ ? zoomTrack_->sampleAt(frameTimeMs) : ZoomSample{}; + const bool zoomEnabled = zoomTrack_ && zoomSample.scale > 0.01; + const CursorPosition cursorPosition = cursorTrack_ + ? cursorTrack_->positionAt(frameTimeMs) + : CursorPosition{}; + const CursorAtlasEntry* cursorEntry = cursorAtlasEntryFor(cursorPosition.typeIndex); + const bool useCursorAtlas = cursorEntry && cursorAtlasDevice_; + const int cursorHeight = layoutOptions_.cursorHeight > 0 + ? std::max(1, static_cast(std::round(layoutOptions_.cursorHeight * cursorPosition.bounceScale))) + : 0; + const double cursorAspectRatio = useCursorAtlas ? cursorEntry->aspectRatio : (618.0 / 958.0); + const int cursorWidth = + cursorHeight > 0 ? std::max(1, static_cast(std::round(cursorHeight * cursorAspectRatio))) : 0; + const int cursorHotspotX = useCursorAtlas + ? static_cast(std::round(cursorWidth * cursorEntry->anchorX)) + : cursorWidth * 14 / 100; + const int cursorHotspotY = useCursorAtlas + ? static_cast(std::round(cursorHeight * cursorEntry->anchorY)) + : cursorHeight * 6 / 100; + const double cursorHotspotContentX = + layoutOptions_.contentX + cursorPosition.cx * layoutOptions_.contentWidth; + const double cursorHotspotContentY = + layoutOptions_.contentY + cursorPosition.cy * layoutOptions_.contentHeight; + const double cursorHotspotOutputX = + zoomEnabled ? cursorHotspotContentX * zoomSample.scale + zoomSample.x : cursorHotspotContentX; + const double cursorHotspotOutputY = + zoomEnabled ? cursorHotspotContentY * zoomSample.scale + zoomSample.y : cursorHotspotContentY; + const int cursorX = cursorPosition.visible + ? static_cast(std::round(cursorHotspotOutputX)) - cursorHotspotX + : 0; + const int cursorY = cursorPosition.visible + ? static_cast(std::round(cursorHotspotOutputY)) - cursorHotspotY + : 0; + const bool hasSourceCrop = + layoutOptions_.sourceCropWidth >= 2 && + layoutOptions_.sourceCropHeight >= 2; + const int sourceCropX = hasSourceCrop + ? std::max(0, std::min(layoutOptions_.sourceCropX, srcWidth - 2)) & ~1 + : 0; + const int sourceCropY = hasSourceCrop + ? std::max(0, std::min(layoutOptions_.sourceCropY, srcHeight - 2)) & ~1 + : 0; + const int sourceCropWidth = hasSourceCrop + ? std::max(2, std::min(layoutOptions_.sourceCropWidth, srcWidth - sourceCropX)) & ~1 + : srcWidth; + const int sourceCropHeight = hasSourceCrop + ? std::max(2, std::min(layoutOptions_.sourceCropHeight, srcHeight - sourceCropY)) & ~1 + : srcHeight; + const bool zoomChangesLayout = + zoomTrack_ && + (std::abs(zoomSample.scale - 1.0) > 0.001 || + std::abs(zoomSample.x) > 0.5 || + std::abs(zoomSample.y) > 0.5); + const bool useFastRoiComposite = + canUseFastRoiComposite(zoomChangesLayout); + const bool useLayeredStaticRoiComposite = + !useFastRoiComposite && canUseLayeredStaticRoiComposite(zoomChangesLayout); + const auto compositeStart = std::chrono::steady_clock::now(); + if (useFastRoiComposite) { + copyNv12Kernel<<>>( + srcFrame, + srcPitch, + srcWidth, + srcHeight, + srcSurfaceHeight, + static_cast(inputFrame->inputPtr), + static_cast(inputFrame->pitch), + static_cast(inputFrame->chromaOffsets[0]), + width_, + height_); + checkCuda(cudaGetLastError(), "copyNv12Kernel fast ROI base"); + + if (webcamFrame && layoutOptions_.webcamSize > 0) { + const int webcamRegionX = std::max(0, layoutOptions_.webcamX - 1); + const int webcamRegionY = std::max(0, layoutOptions_.webcamY - 1); + const int webcamRegionRight = std::min( + width_, + layoutOptions_.webcamX + layoutOptions_.webcamSize); + const int webcamRegionBottom = std::min( + height_, + layoutOptions_.webcamY + layoutOptions_.webcamSize); + const int webcamRegionWidth = webcamRegionRight - webcamRegionX; + const int webcamRegionHeight = webcamRegionBottom - webcamRegionY; + if (webcamRegionWidth > 0 && webcamRegionHeight > 0) { + const dim3 webcamGrid( + (webcamRegionWidth + block.x - 1) / block.x, + (webcamRegionHeight + block.y - 1) / block.y); + overlayWebcamNv12Kernel<<>>( + static_cast(inputFrame->inputPtr), + static_cast(inputFrame->pitch), + static_cast(inputFrame->chromaOffsets[0]), + width_, + height_, + webcamFrame, + webcamRegionX, + webcamRegionY, + webcamRegionWidth, + webcamRegionHeight, + layoutOptions_.webcamX, + layoutOptions_.webcamY, + layoutOptions_.webcamSize, + webcamFrameWidth(), + webcamFrameHeight(), + layoutOptions_.webcamRadius, + layoutOptions_.webcamMirror); + checkCuda(cudaGetLastError(), "overlayWebcamNv12Kernel"); + } + } + + if (cursorPosition.visible && cursorWidth > 0 && cursorHeight > 0) { + const int cursorPadding = useCursorAtlas ? 4 : 2; + const int regionX = std::max(0, cursorX - cursorPadding); + const int regionY = std::max(0, cursorY - cursorPadding); + const int regionRight = std::min(width_, cursorX + cursorWidth + cursorPadding); + const int regionBottom = std::min(height_, cursorY + cursorHeight + cursorPadding); + const int regionWidth = regionRight - regionX; + const int regionHeight = regionBottom - regionY; + if (regionWidth > 0 && regionHeight > 0) { + const dim3 cursorGrid( + (regionWidth + block.x - 1) / block.x, + (regionHeight + block.y - 1) / block.y); + overlayCursorNv12Kernel<<>>( + static_cast(inputFrame->inputPtr), + static_cast(inputFrame->pitch), + static_cast(inputFrame->chromaOffsets[0]), + width_, + height_, + regionX, + regionY, + regionWidth, + regionHeight, + cursorPosition.visible, + cursorX, + cursorY, + cursorWidth, + cursorHeight, + useCursorAtlas ? cursorAtlasDevice_ : nullptr, + cursorAtlasWidth_, + cursorAtlasHeight_, + useCursorAtlas ? cursorEntry->x : 0, + useCursorAtlas ? cursorEntry->y : 0, + useCursorAtlas ? cursorEntry->width : 0, + useCursorAtlas ? cursorEntry->height : 0); + checkCuda(cudaGetLastError(), "overlayCursorNv12Kernel"); + } + } + ++roiCompositeFrames_; + } else if (useLayeredStaticRoiComposite) { + if (backgroundDevice_) { + checkCuda( + cudaMemcpy2DAsync( + static_cast(inputFrame->inputPtr), + static_cast(inputFrame->pitch), + backgroundDevice_, + static_cast(width_), + static_cast(width_), + static_cast(height_), + cudaMemcpyDeviceToDevice, + copyStream_), + "cudaMemcpy2DAsync layered ROI background Y"); + checkCuda( + cudaMemcpy2DAsync( + static_cast(inputFrame->inputPtr) + + static_cast(inputFrame->chromaOffsets[0]), + static_cast(inputFrame->pitch), + backgroundDevice_ + width_ * height_, + static_cast(width_), + static_cast(width_), + static_cast(height_ / 2), + cudaMemcpyDeviceToDevice, + copyStream_), + "cudaMemcpy2DAsync layered ROI background UV"); + } else { + fillNv12Kernel<<>>( + static_cast(inputFrame->inputPtr), + static_cast(inputFrame->pitch), + static_cast(inputFrame->chromaOffsets[0]), + width_, + height_, + clampByte(layoutOptions_.backgroundY), + clampByte(layoutOptions_.backgroundU), + clampByte(layoutOptions_.backgroundV)); + checkCuda(cudaGetLastError(), "fillNv12Kernel layered ROI background"); + } + + const float safeZoomScale = std::max(0.01f, static_cast(zoomSample.scale)); + const float invZoomScale = 1.0f / safeZoomScale; + const float srcScaleX = + static_cast(sourceCropWidth) / static_cast(std::max(1, layoutOptions_.contentWidth)); + const float srcScaleY = + static_cast(sourceCropHeight) / static_cast(std::max(1, layoutOptions_.contentHeight)); + const int transformedContentX = zoomChangesLayout + ? static_cast(std::floor(layoutOptions_.contentX * safeZoomScale + zoomSample.x)) + : layoutOptions_.contentX; + const int transformedContentY = zoomChangesLayout + ? static_cast(std::floor(layoutOptions_.contentY * safeZoomScale + zoomSample.y)) + : layoutOptions_.contentY; + const int transformedContentRight = zoomChangesLayout + ? static_cast( + std::ceil( + (layoutOptions_.contentX + layoutOptions_.contentWidth) * + safeZoomScale + + zoomSample.x)) + : layoutOptions_.contentX + layoutOptions_.contentWidth; + const int transformedContentBottom = zoomChangesLayout + ? static_cast( + std::ceil( + (layoutOptions_.contentY + layoutOptions_.contentHeight) * + safeZoomScale + + zoomSample.y)) + : layoutOptions_.contentY + layoutOptions_.contentHeight; + const int contentRegionLeft = std::max(0, transformedContentX); + const int contentRegionTop = std::max(0, transformedContentY); + const int contentRegionRight = std::min(width_, transformedContentRight); + const int contentRegionBottom = std::min(height_, transformedContentBottom); + const int contentRegionWidth = contentRegionRight - contentRegionLeft; + const int contentRegionHeight = contentRegionBottom - contentRegionTop; + if (contentRegionWidth > 0 && contentRegionHeight > 0) { + const dim3 contentGrid( + (contentRegionWidth + block.x - 1) / block.x, + (contentRegionHeight + block.y - 1) / block.y); + if (zoomChangesLayout) { + overlayContentTransformNv12Kernel<<>>( + srcFrame, + srcPitch, + srcWidth, + srcHeight, + srcSurfaceHeight, + static_cast(inputFrame->inputPtr), + static_cast(inputFrame->pitch), + static_cast(inputFrame->chromaOffsets[0]), + width_, + height_, + contentRegionLeft, + contentRegionTop, + contentRegionWidth, + contentRegionHeight, + layoutOptions_.contentX, + layoutOptions_.contentY, + layoutOptions_.contentWidth, + layoutOptions_.contentHeight, + layoutOptions_.radius, + safeZoomScale, + invZoomScale, + srcScaleX, + srcScaleY, + sourceCropX, + sourceCropY, + static_cast(zoomSample.x), + static_cast(zoomSample.y)); + checkCuda(cudaGetLastError(), "overlayContentTransformNv12Kernel"); + } else { + overlayContentRectNv12Kernel<<>>( + srcFrame, + srcPitch, + srcWidth, + srcHeight, + srcSurfaceHeight, + static_cast(inputFrame->inputPtr), + static_cast(inputFrame->pitch), + static_cast(inputFrame->chromaOffsets[0]), + width_, + height_, + layoutOptions_.contentX, + layoutOptions_.contentY, + layoutOptions_.contentWidth, + layoutOptions_.contentHeight, + sourceCropX, + sourceCropY, + sourceCropWidth, + sourceCropHeight); + checkCuda(cudaGetLastError(), "overlayContentRectNv12Kernel"); + + const int cornerRadius = std::min( + layoutOptions_.radius, + std::min(layoutOptions_.contentWidth, layoutOptions_.contentHeight) / 2); + if (cornerRadius > 0) { + const dim3 cornerGrid( + (cornerRadius + block.x - 1) / block.x, + (cornerRadius + block.y - 1) / block.y, + 4); + restoreRoundedContentCornersNv12Kernel<<>>( + static_cast(inputFrame->inputPtr), + static_cast(inputFrame->pitch), + static_cast(inputFrame->chromaOffsets[0]), + width_, + height_, + layoutOptions_.contentX, + layoutOptions_.contentY, + layoutOptions_.contentWidth, + layoutOptions_.contentHeight, + cornerRadius, + clampByte(layoutOptions_.backgroundY), + clampByte(layoutOptions_.backgroundU), + clampByte(layoutOptions_.backgroundV), + backgroundDevice_); + checkCuda(cudaGetLastError(), "restoreRoundedContentCornersNv12Kernel"); + } + } + } + + if (webcamFrame && layoutOptions_.webcamSize > 0) { + const int webcamRegionX = std::max(0, layoutOptions_.webcamX - 1); + const int webcamRegionY = std::max(0, layoutOptions_.webcamY - 1); + const int webcamRegionRight = std::min( + width_, + layoutOptions_.webcamX + layoutOptions_.webcamSize); + const int webcamRegionBottom = std::min( + height_, + layoutOptions_.webcamY + layoutOptions_.webcamSize); + const int webcamRegionWidth = webcamRegionRight - webcamRegionX; + const int webcamRegionHeight = webcamRegionBottom - webcamRegionY; + if (webcamRegionWidth > 0 && webcamRegionHeight > 0) { + const dim3 webcamGrid( + (webcamRegionWidth + block.x - 1) / block.x, + (webcamRegionHeight + block.y - 1) / block.y); + overlayWebcamNv12Kernel<<>>( + static_cast(inputFrame->inputPtr), + static_cast(inputFrame->pitch), + static_cast(inputFrame->chromaOffsets[0]), + width_, + height_, + webcamFrame, + webcamRegionX, + webcamRegionY, + webcamRegionWidth, + webcamRegionHeight, + layoutOptions_.webcamX, + layoutOptions_.webcamY, + layoutOptions_.webcamSize, + webcamFrameWidth(), + webcamFrameHeight(), + layoutOptions_.webcamRadius, + layoutOptions_.webcamMirror); + checkCuda(cudaGetLastError(), "overlayWebcamNv12Kernel layered ROI"); + } + } + + if (cursorPosition.visible && cursorWidth > 0 && cursorHeight > 0) { + const int cursorPadding = useCursorAtlas ? 4 : 2; + const int regionX = std::max(0, cursorX - cursorPadding); + const int regionY = std::max(0, cursorY - cursorPadding); + const int regionRight = std::min(width_, cursorX + cursorWidth + cursorPadding); + const int regionBottom = std::min(height_, cursorY + cursorHeight + cursorPadding); + const int regionWidth = regionRight - regionX; + const int regionHeight = regionBottom - regionY; + if (regionWidth > 0 && regionHeight > 0) { + const dim3 cursorGrid( + (regionWidth + block.x - 1) / block.x, + (regionHeight + block.y - 1) / block.y); + overlayCursorNv12Kernel<<>>( + static_cast(inputFrame->inputPtr), + static_cast(inputFrame->pitch), + static_cast(inputFrame->chromaOffsets[0]), + width_, + height_, + regionX, + regionY, + regionWidth, + regionHeight, + cursorPosition.visible, + cursorX, + cursorY, + cursorWidth, + cursorHeight, + useCursorAtlas ? cursorAtlasDevice_ : nullptr, + cursorAtlasWidth_, + cursorAtlasHeight_, + useCursorAtlas ? cursorEntry->x : 0, + useCursorAtlas ? cursorEntry->y : 0, + useCursorAtlas ? cursorEntry->width : 0, + useCursorAtlas ? cursorEntry->height : 0); + checkCuda(cudaGetLastError(), "overlayCursorNv12Kernel layered ROI"); + } + } + ++roiCompositeFrames_; + } else if (hasStaticLayout(layoutOptions_)) { + compositeStaticNv12Kernel<<>>( + srcFrame, + srcPitch, + srcWidth, + srcHeight, + srcSurfaceHeight, + static_cast(inputFrame->inputPtr), + static_cast(inputFrame->pitch), + static_cast(inputFrame->chromaOffsets[0]), + width_, + height_, + layoutOptions_.contentX, + layoutOptions_.contentY, + layoutOptions_.contentWidth, + layoutOptions_.contentHeight, + sourceCropX, + sourceCropY, + sourceCropWidth, + sourceCropHeight, + layoutOptions_.radius, + clampByte(layoutOptions_.backgroundY), + clampByte(layoutOptions_.backgroundU), + clampByte(layoutOptions_.backgroundV), + backgroundDevice_, + layoutOptions_.shadowOffsetY, + layoutOptions_.shadowIntensityPct, + webcamFrame, + layoutOptions_.webcamX, + layoutOptions_.webcamY, + layoutOptions_.webcamSize, + webcamFrameWidth(), + webcamFrameHeight(), + layoutOptions_.webcamRadius, + layoutOptions_.webcamMirror, + cursorPosition.visible, + cursorX, + cursorY, + cursorWidth, + cursorHeight, + useCursorAtlas ? cursorAtlasDevice_ : nullptr, + cursorAtlasWidth_, + cursorAtlasHeight_, + useCursorAtlas ? cursorEntry->x : 0, + useCursorAtlas ? cursorEntry->y : 0, + useCursorAtlas ? cursorEntry->width : 0, + useCursorAtlas ? cursorEntry->height : 0, + zoomEnabled, + static_cast(zoomSample.scale), + static_cast(zoomSample.x), + static_cast(zoomSample.y)); + checkCuda(cudaGetLastError(), "compositeStaticNv12Kernel"); + ++monolithicCompositeFrames_; + } else { + copyNv12Kernel<<>>( + srcFrame, + srcPitch, + srcWidth, + srcHeight, + srcSurfaceHeight, + static_cast(inputFrame->inputPtr), + static_cast(inputFrame->pitch), + static_cast(inputFrame->chromaOffsets[0]), + width_, + height_); + checkCuda(cudaGetLastError(), "copyNv12Kernel"); + ++copyCompositeFrames_; + } + if (streamSync_) { + checkCuda(cudaStreamSynchronize(copyStream_), "cudaStreamSynchronize copy"); + } else { + checkCuda(cudaDeviceSynchronize(), "cudaDeviceSynchronize"); + } + const auto compositeEnd = std::chrono::steady_clock::now(); + compositeMs_ += elapsedMs(compositeStart, compositeEnd); + + std::vector> packets; + const auto nvencStart = std::chrono::steady_clock::now(); + encoder_.EncodeFrame(packets); + const auto nvencEnd = std::chrono::steady_clock::now(); + nvencMs_ += elapsedMs(nvencStart, nvencEnd); + const auto writeStart = std::chrono::steady_clock::now(); + writePackets(packets); + const auto writeEnd = std::chrono::steady_clock::now(); + packetWriteMs_ += elapsedMs(writeStart, writeEnd); + ++frames_; + } + + void finish() { + std::vector> packets; + encoder_.EndEncode(packets); + writePackets(packets); + encoder_.DestroyEncoder(); + output_.close(); + if (copyStream_) { + checkCuda(cudaStreamDestroy(copyStream_), "cudaStreamDestroy"); + copyStream_ = nullptr; + } + if (backgroundDevice_) { + checkCuda(cudaFree(backgroundDevice_), "cudaFree backgroundDevice"); + backgroundDevice_ = nullptr; + } + if (webcamDevice_) { + checkCuda(cudaFree(webcamDevice_), "cudaFree webcamDevice"); + webcamDevice_ = nullptr; + } + if (cursorAtlasDevice_) { + checkCuda(cudaFree(cursorAtlasDevice_), "cudaFree cursorAtlasDevice"); + cursorAtlasDevice_ = nullptr; + } + } + + uint64_t outputBytes() const { + return outputBytes_; + } + + double compositeMs() const { + return compositeMs_; + } + + double nvencMs() const { + return nvencMs_; + } + + double packetWriteMs() const { + return packetWriteMs_; + } + + int roiCompositeFrames() const { + return roiCompositeFrames_; + } + + int monolithicCompositeFrames() const { + return monolithicCompositeFrames_; + } + + int copyCompositeFrames() const { + return copyCompositeFrames_; + } + +private: + bool canUseFastRoiComposite(bool zoomChangesLayout) const { + return hasStaticLayout(layoutOptions_) && + layoutOptions_.contentX == 0 && + layoutOptions_.contentY == 0 && + layoutOptions_.contentWidth == width_ && + layoutOptions_.contentHeight == height_ && + layoutOptions_.radius == 0 && + layoutOptions_.shadowIntensityPct == 0 && + backgroundDevice_ == nullptr && + layoutOptions_.sourceCropWidth <= 0 && + layoutOptions_.sourceCropHeight <= 0 && + !zoomChangesLayout; + } + + bool canUseLayeredStaticRoiComposite(bool zoomChangesLayout) const { + return hasStaticLayout(layoutOptions_) && + layoutOptions_.contentWidth > 0 && + layoutOptions_.contentHeight > 0 && + layoutOptions_.contentX < width_ && + layoutOptions_.contentY < height_ && + layoutOptions_.contentX + layoutOptions_.contentWidth > 0 && + layoutOptions_.contentY + layoutOptions_.contentHeight > 0 && + layoutOptions_.shadowIntensityPct == 0; + } + + const unsigned char* selectWebcamFrame(double sourceTimeMs) const { + if (webcamCache_ && !webcamCache_->frames.empty()) { + return webcamCache_->frameAt(webcamFrameIndexForSourceTimeMs(sourceTimeMs, layoutOptions_)); + } + return webcamDevice_; + } + + int webcamFrameWidth() const { + return webcamCache_ ? webcamCache_->width : layoutOptions_.webcamSize; + } + + int webcamFrameHeight() const { + return webcamCache_ ? webcamCache_->height : layoutOptions_.webcamSize; + } + + void loadBackgroundFrame() { + if (layoutOptions_.backgroundNv12Path.empty()) { + return; + } + + const size_t expectedBytes = static_cast(width_) * static_cast(height_) * 3 / 2; + std::vector bytes(expectedBytes); + std::ifstream input(layoutOptions_.backgroundNv12Path, std::ios::binary); + if (!input) { + fail("Failed to open background NV12: " + layoutOptions_.backgroundNv12Path); + } + input.read(reinterpret_cast(bytes.data()), static_cast(bytes.size())); + if (static_cast(input.gcount()) != expectedBytes) { + fail("Background NV12 has an unexpected size: " + layoutOptions_.backgroundNv12Path); + } + + checkCuda(cudaMalloc(&backgroundDevice_, expectedBytes), "cudaMalloc backgroundDevice"); + checkCuda( + cudaMemcpy(backgroundDevice_, bytes.data(), expectedBytes, cudaMemcpyHostToDevice), + "cudaMemcpy backgroundDevice"); + } + + void loadWebcamFrame() { + if (layoutOptions_.webcamNv12Path.empty()) { + return; + } + + const size_t expectedBytes = + static_cast(layoutOptions_.webcamSize) * static_cast(layoutOptions_.webcamSize) * 3 / 2; + std::vector bytes(expectedBytes); + std::ifstream input(layoutOptions_.webcamNv12Path, std::ios::binary); + if (!input) { + fail("Failed to open webcam NV12: " + layoutOptions_.webcamNv12Path); + } + input.read(reinterpret_cast(bytes.data()), static_cast(bytes.size())); + if (static_cast(input.gcount()) != expectedBytes) { + fail("Webcam NV12 has an unexpected size: " + layoutOptions_.webcamNv12Path); + } + + checkCuda(cudaMalloc(&webcamDevice_, expectedBytes), "cudaMalloc webcamDevice"); + checkCuda( + cudaMemcpy(webcamDevice_, bytes.data(), expectedBytes, cudaMemcpyHostToDevice), + "cudaMemcpy webcamDevice"); + } + + const CursorAtlasEntry* cursorAtlasEntryFor(int typeIndex) const { + if (typeIndex < 0 || typeIndex >= kMaxCursorAtlasEntries) { + return nullptr; + } + const CursorAtlasEntry& entry = cursorAtlasEntries_[typeIndex]; + return entry.valid ? &entry : nullptr; + } + + void loadCursorAtlas() { + if (layoutOptions_.cursorAtlasRgbaPath.empty()) { + return; + } + if (layoutOptions_.cursorAtlasMetadataPath.empty() || + layoutOptions_.cursorAtlasWidth <= 0 || + layoutOptions_.cursorAtlasHeight <= 0) { + fail("Cursor atlas requires metadata, width, and height"); + } + + std::ifstream metadata(layoutOptions_.cursorAtlasMetadataPath); + if (!metadata) { + fail("Failed to open cursor atlas metadata: " + layoutOptions_.cursorAtlasMetadataPath); + } + + int loadedEntries = 0; + int index = 0; + CursorAtlasEntry entry; + while (metadata >> index >> entry.x >> entry.y >> entry.width >> entry.height >> + entry.anchorX >> entry.anchorY >> entry.aspectRatio) { + if (index < 0 || index >= kMaxCursorAtlasEntries || entry.width <= 0 || entry.height <= 0) { + continue; + } + entry.valid = true; + cursorAtlasEntries_[index] = entry; + ++loadedEntries; + } + if (loadedEntries == 0) { + fail("No cursor atlas entries were loaded: " + layoutOptions_.cursorAtlasMetadataPath); + } + + cursorAtlasWidth_ = layoutOptions_.cursorAtlasWidth; + cursorAtlasHeight_ = layoutOptions_.cursorAtlasHeight; + const size_t expectedBytes = + static_cast(cursorAtlasWidth_) * static_cast(cursorAtlasHeight_) * 4; + std::vector bytes(expectedBytes); + std::ifstream input(layoutOptions_.cursorAtlasRgbaPath, std::ios::binary); + if (!input) { + fail("Failed to open cursor atlas RGBA: " + layoutOptions_.cursorAtlasRgbaPath); + } + input.read(reinterpret_cast(bytes.data()), static_cast(bytes.size())); + if (static_cast(input.gcount()) != expectedBytes) { + fail("Cursor atlas RGBA has an unexpected size: " + layoutOptions_.cursorAtlasRgbaPath); + } + + checkCuda(cudaMalloc(&cursorAtlasDevice_, expectedBytes), "cudaMalloc cursorAtlasDevice"); + checkCuda( + cudaMemcpy(cursorAtlasDevice_, bytes.data(), expectedBytes, cudaMemcpyHostToDevice), + "cudaMemcpy cursorAtlasDevice"); + } + + void writePackets(const std::vector>& packets) { + for (const auto& packet : packets) { + if (packet.empty()) { + continue; + } + output_.write(reinterpret_cast(packet.data()), static_cast(packet.size())); + outputBytes_ += packet.size(); + } + } + + NvEncoderCuda encoder_; + std::ofstream output_; + int width_ = 0; + int height_ = 0; + int fps_ = 30; + int frames_ = 0; + uint64_t outputBytes_ = 0; + double compositeMs_ = 0.0; + double nvencMs_ = 0.0; + double packetWriteMs_ = 0.0; + int roiCompositeFrames_ = 0; + int monolithicCompositeFrames_ = 0; + int copyCompositeFrames_ = 0; + bool streamSync_ = false; + Options layoutOptions_; + unsigned char* backgroundDevice_ = nullptr; + unsigned char* webcamDevice_ = nullptr; + unsigned char* cursorAtlasDevice_ = nullptr; + CursorAtlasEntry cursorAtlasEntries_[kMaxCursorAtlasEntries]; + int cursorAtlasWidth_ = 0; + int cursorAtlasHeight_ = 0; + const WebcamFrameCache* webcamCache_ = nullptr; + const CursorTrack* cursorTrack_ = nullptr; + const ZoomTrack* zoomTrack_ = nullptr; + cudaStream_t copyStream_ = nullptr; +}; + +struct CallbackEncodeState { + CUcontext context = nullptr; + const Options* options = nullptr; + uint32_t bitrate = 0; + std::unique_ptr* sink = nullptr; + double* decodeMs = nullptr; + double* encodeMs = nullptr; + int* encodedFrames = nullptr; + int displayFrameIndex = 0; + const WebcamFrameCache* webcamCache = nullptr; + const CursorTrack* cursorTrack = nullptr; + const ZoomTrack* zoomTrack = nullptr; + ProgressReportState* progress = nullptr; + bool oneFramePerMappedDisplayFrame = false; + int mappedFrames = 0; + const std::vector* sourcePts = nullptr; + const std::vector* timelineSegments = nullptr; +}; + +ProgressCounters collectProgressCounters( + const NvencSink* sink, + const WebcamFrameCache* webcamCache, + double decodeWallMs, + double encodeMs) { + ProgressCounters counters; + counters.decodeWallMs = decodeWallMs; + counters.encodeMs = encodeMs; + if (sink) { + counters.compositeMs = sink->compositeMs(); + counters.nvencMs = sink->nvencMs(); + counters.packetWriteMs = sink->packetWriteMs(); + counters.roiCompositeFrames = sink->roiCompositeFrames(); + counters.monolithicCompositeFrames = sink->monolithicCompositeFrames(); + counters.copyCompositeFrames = sink->copyCompositeFrames(); + } + if (webcamCache) { + counters.webcamDecodeMs = webcamCache->decodeMs; + counters.webcamCopyMs = webcamCache->copyMs; + } + return counters; +} + +int maxCallbackOutputFrames(const Options& options) { + int maxOutputFrames = options.targetFrames > 0 + ? options.targetFrames + : std::numeric_limits::max(); + if (options.maxFrames > 0) { + maxOutputFrames = std::min(maxOutputFrames, options.maxFrames); + } + return maxOutputFrames; +} + +bool shouldContinueEncoding(int encodedFrames, const Options& options) { + return encodedFrames < maxCallbackOutputFrames(options); +} + +int expectedCallbackOutputFramesForSourceFrame( + int sourceFrameIndex, + const Options& options, + const std::vector* sourcePts, + const std::vector* timelineSegments) { + return expectedOutputFramesForSourceFrame( + sourceFrameIndex, + options.inputFrames, + options.targetFrames, + options.maxFrames, + options.fps, + sourcePts, + timelineSegments); +} + +void encodeMappedDisplayFrame( + CUdeviceptr dpSrcFrame, + unsigned int nSrcPitch, + int width, + int height, + int surfaceHeight, + int64_t, + void* userData) { + auto* state = static_cast(userData); + ++state->mappedFrames; + const int sourceFrameIndex = state->displayFrameIndex++; + const int maxOutputFrames = maxCallbackOutputFrames(*state->options); + if (*state->encodedFrames >= maxOutputFrames) { + return; + } + const int expectedOutputFrames = state->oneFramePerMappedDisplayFrame + ? *state->encodedFrames + 1 + : expectedCallbackOutputFramesForSourceFrame( + sourceFrameIndex, + *state->options, + state->sourcePts, + state->timelineSegments); + if (*state->encodedFrames >= expectedOutputFrames) { + return; + } + + if (!*state->sink) { + *state->sink = std::make_unique( + state->context, + outputWidthForSource(*state->options, width), + outputHeightForSource(*state->options, height), + state->options->fps, + state->bitrate, + state->options->outputPath, + state->options->streamSync, + *state->options, + state->webcamCache, + state->cursorTrack, + state->zoomTrack); + } + + while (*state->encodedFrames < expectedOutputFrames && *state->encodedFrames < maxOutputFrames) { + const auto encodeStart = std::chrono::steady_clock::now(); + (*state->sink)->encodeFrame( + reinterpret_cast(dpSrcFrame), + static_cast(nSrcPitch), + width, + height, + surfaceHeight, + *state->encodedFrames); + const auto encodeEnd = std::chrono::steady_clock::now(); + *state->encodeMs += elapsedMs(encodeStart, encodeEnd); + ++*state->encodedFrames; + if (state->progress) { + const NvencSink* activeSink = state->sink && *state->sink ? state->sink->get() : nullptr; + reportEncodingProgress( + *state->encodedFrames, + maxOutputFrames, + *state->progress, + collectProgressCounters( + activeSink, + state->webcamCache, + state->decodeMs ? *state->decodeMs : 0.0, + state->encodeMs ? *state->encodeMs : 0.0)); + } + } +} + +double elapsedMs(std::chrono::steady_clock::time_point start, std::chrono::steady_clock::time_point end) { + return std::chrono::duration(end - start).count(); +} + +void reportEncodingProgress( + int encodedFrames, + int totalFrames, + ProgressReportState& state, + const ProgressCounters& counters, + bool force) { + if (totalFrames <= 0) { + return; + } + + const auto now = std::chrono::steady_clock::now(); + if (!force && encodedFrames < totalFrames && encodedFrames % 30 != 0 && elapsedMs(state.lastReportAt, now) < 500.0) { + return; + } + + const double percentage = std::min(100.0, std::max(0.0, static_cast(encodedFrames) * 100.0 / totalFrames)); + const double elapsedSeconds = std::max(elapsedMs(state.startedAt, now) / 1000.0, 0.001); + const double averageFps = encodedFrames > 0 ? static_cast(encodedFrames) / elapsedSeconds : 0.0; + const double intervalMs = std::max(elapsedMs(state.lastReportAt, now), 0.0); + const int intervalFrames = std::max(0, encodedFrames - state.lastReportedFrame); + const double instantFps = + intervalMs > 0.0 && intervalFrames > 0 ? static_cast(intervalFrames) * 1000.0 / intervalMs : 0.0; + const double intervalEncodeMs = std::max(0.0, counters.encodeMs - state.lastCounters.encodeMs); + const double intervalCompositeMs = std::max(0.0, counters.compositeMs - state.lastCounters.compositeMs); + const double intervalNvencMs = std::max(0.0, counters.nvencMs - state.lastCounters.nvencMs); + const double intervalPacketWriteMs = std::max(0.0, counters.packetWriteMs - state.lastCounters.packetWriteMs); + const double intervalWebcamDecodeMs = std::max(0.0, counters.webcamDecodeMs - state.lastCounters.webcamDecodeMs); + const double intervalWebcamCopyMs = std::max(0.0, counters.webcamCopyMs - state.lastCounters.webcamCopyMs); + const double intervalDecodeWallMs = std::max(0.0, counters.decodeWallMs - state.lastCounters.decodeWallMs); + const double intervalPipelineWaitMs = std::max(0.0, intervalMs - intervalEncodeMs); + const int intervalRoiCompositeFrames = + std::max(0, counters.roiCompositeFrames - state.lastCounters.roiCompositeFrames); + const int intervalMonolithicCompositeFrames = + std::max(0, counters.monolithicCompositeFrames - state.lastCounters.monolithicCompositeFrames); + const int intervalCopyCompositeFrames = + std::max(0, counters.copyCompositeFrames - state.lastCounters.copyCompositeFrames); + std::cerr << std::fixed << std::setprecision(2) + << "PROGRESS {\"currentFrame\":" << encodedFrames + << ",\"totalFrames\":" << totalFrames + << ",\"percentage\":" << percentage + << ",\"averageFps\":" << averageFps + << ",\"instantFps\":" << instantFps + << ",\"intervalMs\":" << intervalMs + << ",\"intervalFrames\":" << intervalFrames + << ",\"intervalDecodeWallMs\":" << intervalDecodeWallMs + << ",\"intervalEncodeMs\":" << intervalEncodeMs + << ",\"intervalPipelineWaitMs\":" << intervalPipelineWaitMs + << ",\"intervalCompositeMs\":" << intervalCompositeMs + << ",\"intervalNvencMs\":" << intervalNvencMs + << ",\"intervalPacketWriteMs\":" << intervalPacketWriteMs + << ",\"intervalWebcamDecodeMs\":" << intervalWebcamDecodeMs + << ",\"intervalWebcamCopyMs\":" << intervalWebcamCopyMs + << ",\"intervalRoiCompositeFrames\":" << intervalRoiCompositeFrames + << ",\"intervalMonolithicCompositeFrames\":" << intervalMonolithicCompositeFrames + << ",\"intervalCopyCompositeFrames\":" << intervalCopyCompositeFrames + << "}" << std::endl; + state.lastReportAt = now; + state.lastReportedFrame = encodedFrames; + state.lastCounters = counters; +} + +} // namespace + +int main(int argc, char** argv) { + try { + Options options = parseOptions(argc, argv); + options.timelineSegments = loadTimelineMap(options.timelineMapPath); + if (!options.timelineSegments.empty() && !options.callbackEncode) { + fail("Timeline-map CUDA export requires --callback-encode"); + } + const uint32_t bitrate = static_cast(options.bitrateMbps) * 1000U * 1000U; + + checkCuda(cudaSetDevice(0), "cudaSetDevice"); + checkCu(cuInit(0), "cuInit"); + CUdevice device = 0; + checkCu(cuDeviceGet(&device, 0), "cuDeviceGet"); + CUcontext context = nullptr; + checkCu(cuCtxCreate(&context, 0, device), "cuCtxCreate"); + checkCu(cuCtxSetCurrent(context), "cuCtxSetCurrent"); + prewarmCuda(options.prewarmMs); + + std::ifstream input(options.inputPath, std::ios::binary); + if (!input) { + fail("Failed to open input: " + options.inputPath); + } + + std::unique_ptr webcamStream = createWebcamStreamDecoder(context, options); + const WebcamFrameCache* webcamCachePtr = webcamStream ? webcamStream->cache() : nullptr; + std::unique_ptr cursorTrack = loadCursorTrack(options); + const CursorTrack* cursorTrackPtr = cursorTrack.get(); + std::unique_ptr zoomTrack = loadZoomTrack(options); + const ZoomTrack* zoomTrackPtr = zoomTrack.get(); + const std::vector sourcePts = loadFramePts(options.sourcePtsPath); + const bool useSourcePts = + options.inputFrames > 0 && + sourcePts.size() >= static_cast(options.inputFrames); + if (!options.timelineSegments.empty() && !useSourcePts) { + fail("Timeline-map CUDA export requires source frame PTS"); + } + auto decoder = std::make_unique(context, 0, 0, true, cudaVideoCodec_H264, nullptr, true, true); + std::unique_ptr sink; + const bool useDecoderFramePolicy = + options.inputFrames > 0 && + options.targetFrames > 0 && + options.inputFrames >= options.targetFrames && + !useSourcePts && + !options.postSelect; + FrameSelectionState selectionState{ + options.inputFrames, + options.targetFrames, + options.maxFrames, + 0, + 0, + options.fps, + useSourcePts ? &sourcePts : nullptr, + options.timelineSegments.empty() ? nullptr : &options.timelineSegments, + }; + if (useDecoderFramePolicy) { + decoder->SetDisplayFramePolicy(shouldCopyDisplayFrame, &selectionState); + } + + std::vector chunk(static_cast(options.chunkMb) * 1024 * 1024); + uint8_t** frames = nullptr; + int returnedFrames = 0; + int sourceFrames = 0; + int encodedFrames = 0; + const auto totalStart = std::chrono::steady_clock::now(); + double decodeMs = 0.0; + double encodeMs = 0.0; + ProgressReportState progressState; + progressState.startedAt = std::chrono::steady_clock::now(); + progressState.lastReportAt = progressState.startedAt; + const int progressTotalFrames = maxCallbackOutputFrames(options); + reportEncodingProgress(0, progressTotalFrames, progressState, ProgressCounters{}, true); + CallbackEncodeState callbackState{ + context, + &options, + bitrate, + &sink, + &decodeMs, + &encodeMs, + &encodedFrames, + 0, + webcamCachePtr, + cursorTrackPtr, + zoomTrackPtr, + &progressState, + useDecoderFramePolicy, + 0, + useSourcePts ? &sourcePts : nullptr, + options.timelineSegments.empty() ? nullptr : &options.timelineSegments, + }; + if (options.callbackEncode) { + decoder->SetMappedFrameHandler(encodeMappedDisplayFrame, &callbackState); + } + + auto prepareWebcamFrames = [&]() { + if (!webcamStream) { + return; + } + int outputFrameIndex = encodedFrames + kWebcamPrefetchOutputFrames; + if (options.maxFrames > 0) { + outputFrameIndex = std::min(outputFrameIndex, options.maxFrames - 1); + } + if (options.targetFrames > 0) { + outputFrameIndex = std::min(outputFrameIndex, options.targetFrames - 1); + } + webcamStream->ensureFrame(webcamFrameIndexForOutputFrame(outputFrameIndex, options)); + + const int keepFromOutputFrame = std::max(0, encodedFrames - 8); + webcamStream->dropBefore(webcamFrameIndexForOutputFrame(keepFromOutputFrame, options)); + }; + + while (input && shouldContinueEncoding(encodedFrames, options)) { + prepareWebcamFrames(); + input.read(reinterpret_cast(chunk.data()), static_cast(chunk.size())); + const int bytesRead = static_cast(input.gcount()); + if (bytesRead <= 0) { + break; + } + + const auto decodeStart = std::chrono::steady_clock::now(); + decoder->Decode(chunk.data(), bytesRead, &frames, &returnedFrames); + const auto decodeEnd = std::chrono::steady_clock::now(); + decodeMs += elapsedMs(decodeStart, decodeEnd); + + for (int index = 0; index < returnedFrames; ++index) { + if (!shouldContinueEncoding(encodedFrames, options)) { + break; + } + const int sourceFrameIndex = sourceFrames++; + if (!useDecoderFramePolicy && !shouldEncodeFrame(sourceFrameIndex, encodedFrames, options)) { + continue; + } + if (!sink) { + sink = std::make_unique( + context, + outputWidthForSource(options, decoder->GetWidth()), + outputHeightForSource(options, decoder->GetHeight()), + options.fps, + bitrate, + options.outputPath, + options.streamSync, + options, + webcamCachePtr, + cursorTrackPtr, + zoomTrackPtr); + } + const auto encodeStart = std::chrono::steady_clock::now(); + sink->encodeFrame( + frames[index], + decoder->GetDeviceFramePitch(), + decoder->GetWidth(), + decoder->GetHeight(), + decoder->GetHeight(), + encodedFrames); + const auto encodeEnd = std::chrono::steady_clock::now(); + encodeMs += elapsedMs(encodeStart, encodeEnd); + ++encodedFrames; + reportEncodingProgress( + encodedFrames, + progressTotalFrames, + progressState, + collectProgressCounters(sink.get(), webcamCachePtr, decodeMs, encodeMs)); + } + } + + if (shouldContinueEncoding(encodedFrames, options)) { + const auto decodeStart = std::chrono::steady_clock::now(); + decoder->Decode(nullptr, 0, &frames, &returnedFrames); + const auto decodeEnd = std::chrono::steady_clock::now(); + decodeMs += elapsedMs(decodeStart, decodeEnd); + for (int index = 0; index < returnedFrames; ++index) { + if (!shouldContinueEncoding(encodedFrames, options)) { + break; + } + const int sourceFrameIndex = sourceFrames++; + if (!useDecoderFramePolicy && !shouldEncodeFrame(sourceFrameIndex, encodedFrames, options)) { + continue; + } + if (!sink) { + sink = std::make_unique( + context, + outputWidthForSource(options, decoder->GetWidth()), + outputHeightForSource(options, decoder->GetHeight()), + options.fps, + bitrate, + options.outputPath, + options.streamSync, + options, + webcamCachePtr, + cursorTrackPtr, + zoomTrackPtr); + } + const auto encodeStart = std::chrono::steady_clock::now(); + sink->encodeFrame( + frames[index], + decoder->GetDeviceFramePitch(), + decoder->GetWidth(), + decoder->GetHeight(), + decoder->GetHeight(), + encodedFrames); + const auto encodeEnd = std::chrono::steady_clock::now(); + encodeMs += elapsedMs(encodeStart, encodeEnd); + ++encodedFrames; + reportEncodingProgress( + encodedFrames, + progressTotalFrames, + progressState, + collectProgressCounters(sink.get(), webcamCachePtr, decodeMs, encodeMs)); + } + } + + if (!sink) { + fail("No decoded frames were produced"); + } + const auto flushStart = std::chrono::steady_clock::now(); + sink->finish(); + const auto flushEnd = std::chrono::steady_clock::now(); + const auto totalEnd = std::chrono::steady_clock::now(); + reportEncodingProgress( + encodedFrames, + progressTotalFrames, + progressState, + collectProgressCounters(sink.get(), webcamCachePtr, decodeMs, encodeMs), + true); + + const double totalMs = elapsedMs(totalStart, totalEnd); + const double mediaMs = static_cast(encodedFrames) * 1000.0 / options.fps; + const double measuredFps = static_cast(encodedFrames) / (totalMs / 1000.0); + const double realtime = mediaMs / totalMs; + const int reportedSourceFrames = + useDecoderFramePolicy ? selectionState.sourceFrames : + (options.callbackEncode ? decoder->GetDisplayFrameCount() : sourceFrames); + const int mappedDisplayFrames = options.callbackEncode ? callbackState.mappedFrames : encodedFrames; + const int selectedDisplayFrames = + useDecoderFramePolicy ? selectionState.selectedFrames : mappedDisplayFrames; + const int skippedDisplayFrames = + useDecoderFramePolicy ? std::max(0, selectionState.sourceFrames - selectionState.selectedFrames) : 0; + const double decodeOnlyApproxMs = options.callbackEncode ? std::max(0.0, decodeMs - encodeMs) : decodeMs; + + std::cout << std::fixed << std::setprecision(2) + << "{" + << "\"success\":true," + << "\"mode\":\"nvdec-cuda-nvenc-annexb\"," + << "\"selectionStage\":\"" + << (options.callbackEncode + ? (useDecoderFramePolicy ? "decoder-policy-mapped-callback" : "mapped-callback") + : (useDecoderFramePolicy ? "decoder" : "post")) + << "\"," + << "\"sourceTimestampMode\":\"" << (useSourcePts ? "pts" : "ordinal") << "\"," + << "\"timelineMap\":" << (!options.timelineSegments.empty() ? "true" : "false") << "," + << "\"timelineSegments\":" << options.timelineSegments.size() << "," + << "\"syncMode\":\"" << (options.streamSync ? "stream" : "device") << "\"," + << "\"prewarmMs\":" << options.prewarmMs << "," + << "\"chunkMb\":" << options.chunkMb << "," + << "\"width\":" << outputWidthForSource(options, decoder->GetWidth()) << "," + << "\"height\":" << outputHeightForSource(options, decoder->GetHeight()) << "," + << "\"fps\":" << options.fps << "," + << "\"encodingMode\":\"" << options.encodingMode << "\"," + << "\"staticLayout\":" << (hasStaticLayout(options) ? "true" : "false") << "," + << "\"contentX\":" << options.contentX << "," + << "\"contentY\":" << options.contentY << "," + << "\"contentWidth\":" << options.contentWidth << "," + << "\"contentHeight\":" << options.contentHeight << "," + << "\"radius\":" << options.radius << "," + << "\"backgroundImage\":" << (!options.backgroundNv12Path.empty() ? "true" : "false") << "," + << "\"shadowOffsetY\":" << options.shadowOffsetY << "," + << "\"shadowIntensityPct\":" << options.shadowIntensityPct << "," + << "\"webcamOverlay\":" << (hasWebcamOverlay(options) ? "true" : "false") << "," + << "\"webcamX\":" << options.webcamX << "," + << "\"webcamY\":" << options.webcamY << "," + << "\"webcamSize\":" << options.webcamSize << "," + << "\"webcamRadius\":" << options.webcamRadius << "," + << "\"webcamStream\":" << (!options.webcamAnnexbPath.empty() ? "true" : "false") << "," + << "\"webcamMirror\":" << (options.webcamMirror ? "true" : "false") << "," + << "\"webcamTimeOffsetMs\":" << options.webcamTimeOffsetMs << "," + << "\"webcamSourceDurationMs\":" << options.webcamSourceDurationMs << "," + << "\"webcamCachedFrames\":" << (webcamCachePtr ? webcamCachePtr->frames.size() : 0) << "," + << "\"webcamPeakCachedFrames\":" << (webcamCachePtr ? webcamCachePtr->peakFrames : 0) << "," + << "\"webcamCacheBaseFrame\":" << (webcamCachePtr ? webcamCachePtr->baseFrameIndex : 0) << "," + << "\"webcamDecodedFrames\":" << (webcamCachePtr ? webcamCachePtr->decodedFrames : 0) << "," + << "\"webcamDecodeMs\":" << (webcamCachePtr ? webcamCachePtr->decodeMs : 0.0) << "," + << "\"webcamCopyMs\":" << (webcamCachePtr ? webcamCachePtr->copyMs : 0.0) << "," + << "\"cursorOverlay\":" << (cursorTrackPtr ? "true" : "false") << "," + << "\"cursorSamples\":" << (cursorTrackPtr ? cursorTrackPtr->samples.size() : 0) << "," + << "\"cursorHeight\":" << options.cursorHeight << "," + << "\"cursorAtlas\":" << (!options.cursorAtlasRgbaPath.empty() ? "true" : "false") << "," + << "\"zoomOverlay\":" << (zoomTrackPtr ? "true" : "false") << "," + << "\"zoomSamples\":" << (zoomTrackPtr ? zoomTrackPtr->samples.size() : 0) << "," + << "\"sourceFrames\":" << reportedSourceFrames << "," + << "\"mappedDisplayFrames\":" << mappedDisplayFrames << "," + << "\"selectedDisplayFrames\":" << selectedDisplayFrames << "," + << "\"skippedDisplayFrames\":" << skippedDisplayFrames << "," + << "\"frames\":" << encodedFrames << "," + << "\"totalMs\":" << totalMs << "," + << "\"decodeMs\":" << decodeOnlyApproxMs << "," + << "\"decodeWallMs\":" << decodeMs << "," + << "\"encodeMs\":" << encodeMs << "," + << "\"compositeMs\":" << sink->compositeMs() << "," + << "\"roiCompositeFrames\":" << sink->roiCompositeFrames() << "," + << "\"monolithicCompositeFrames\":" << sink->monolithicCompositeFrames() << "," + << "\"copyCompositeFrames\":" << sink->copyCompositeFrames() << "," + << "\"nvencMs\":" << sink->nvencMs() << "," + << "\"packetWriteMs\":" << sink->packetWriteMs() << "," + << "\"flushMs\":" << elapsedMs(flushStart, flushEnd) << "," + << "\"measuredFps\":" << measuredFps << "," + << "\"realtimeMultiplier\":" << realtime << "," + << "\"outputBytes\":" << sink->outputBytes() << "," + << "\"outputPath\":\"" << options.outputPath << "\"" + << "}" << std::endl; + + sink.reset(); + decoder.reset(); + webcamStream.reset(); + checkCu(cuCtxDestroy(context), "cuCtxDestroy"); + return 0; + } catch (const std::exception& error) { + std::cerr << "{\"success\":false,\"error\":\"" << error.what() << "\"}" << std::endl; + return 1; + } +} diff --git a/electron/native/wgc-capture/CMakeLists.txt b/electron/native/wgc-capture/CMakeLists.txt new file mode 100644 index 000000000..6b6c07aea --- /dev/null +++ b/electron/native/wgc-capture/CMakeLists.txt @@ -0,0 +1,27 @@ +cmake_minimum_required(VERSION 3.20) +project(wgc-capture LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +add_executable(wgc-capture + src/main.cpp + src/wgc_session.cpp + src/mf_encoder.cpp + src/monitor_utils.cpp + src/wasapi_loopback.cpp +) + +target_compile_options(wgc-capture PRIVATE /EHsc /W3 /utf-8) + +target_link_libraries(wgc-capture PRIVATE + windowsapp + d3d11 + dxgi + mfplat + mfreadwrite + mf + mfuuid + ole32 + shcore +) diff --git a/electron/native/wgc-capture/src/main.cpp b/electron/native/wgc-capture/src/main.cpp new file mode 100644 index 000000000..0f007826b --- /dev/null +++ b/electron/native/wgc-capture/src/main.cpp @@ -0,0 +1,516 @@ +#include "wgc_session.h" +#include "mf_encoder.h" +#include "monitor_utils.h" +#include "wasapi_loopback.h" + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +static std::atomic g_stopRequested{false}; +static std::atomic g_pauseRequested{false}; +static std::atomic g_lastFrameTimestampHns{0}; +static std::atomic g_pauseStartTimestampHns{0}; +static std::atomic g_accumulatedPausedHns{0}; +static std::mutex g_stopMutex; +static std::condition_variable g_stopCv; + +struct CaptureConfig { + int64_t displayId = 0; + int64_t windowHandle = 0; + std::string outputPath; + std::string audioOutputPath; + std::string micOutputPath; + std::string micDeviceName; + int fps = 60; + int width = 0; + int height = 0; + int displayX = 0; + int displayY = 0; + int displayW = 0; + int displayH = 0; + bool hasDisplayBounds = false; + bool captureSystemAudio = false; + bool captureMic = false; +}; + +static bool parseSimpleJson(const std::string& json, CaptureConfig& config) { + auto findInt = [&](const std::string& key) -> int { + auto pos = json.find("\"" + key + "\""); + if (pos == std::string::npos) return -1; + pos = json.find(':', pos); + if (pos == std::string::npos) return -1; + pos++; + while (pos < json.size() && (json[pos] == ' ' || json[pos] == '\t')) pos++; + try { + return std::stoi(json.substr(pos)); + } catch (...) { + return -1; + } + }; + + auto findInt64 = [&](const std::string& key) -> int64_t { + auto pos = json.find("\"" + key + "\""); + if (pos == std::string::npos) return -1; + pos = json.find(':', pos); + if (pos == std::string::npos) return -1; + pos++; + while (pos < json.size() && (json[pos] == ' ' || json[pos] == '\t')) pos++; + try { + return std::stoll(json.substr(pos)); + } catch (...) { + return -1; + } + }; + + auto findString = [&](const std::string& key) -> std::string { + auto pos = json.find("\"" + key + "\""); + if (pos == std::string::npos) return ""; + pos = json.find(':', pos); + if (pos == std::string::npos) return ""; + pos++; + while (pos < json.size() && (json[pos] == ' ' || json[pos] == '\t')) pos++; + if (pos >= json.size() || json[pos] != '"') return ""; + pos++; + std::string result; + while (pos < json.size() && json[pos] != '"') { + if (json[pos] == '\\' && pos + 1 < json.size()) { + pos++; + if (json[pos] == 'n') result += '\n'; + else if (json[pos] == 't') result += '\t'; + else if (json[pos] == '\\') result += '\\'; + else if (json[pos] == '"') result += '"'; + else if (json[pos] == '/') result += '/'; + else result += json[pos]; + } else { + result += json[pos]; + } + pos++; + } + return result; + }; + + config.outputPath = findString("outputPath"); + if (config.outputPath.empty()) return false; + + int64_t displayId = findInt64("displayId"); + if (displayId >= 0) config.displayId = displayId; + + int64_t windowHandle = findInt64("windowHandle"); + if (windowHandle > 0) config.windowHandle = windowHandle; + + int fps = findInt("fps"); + if (fps > 0) config.fps = fps; + + int width = findInt("width"); + if (width > 0) config.width = width; + + int height = findInt("height"); + if (height > 0) config.height = height; + + config.audioOutputPath = findString("audioOutputPath"); + config.micOutputPath = findString("micOutputPath"); + config.micDeviceName = findString("micDeviceName"); + + auto findBool = [&](const std::string& key) -> bool { + auto pos = json.find("\"" + key + "\""); + if (pos == std::string::npos) return false; + auto colonPos = json.find(':', pos); + if (colonPos == std::string::npos) return false; + auto valStart = json.find_first_not_of(" \t", colonPos + 1); + return valStart != std::string::npos && json.substr(valStart, 4) == "true"; + }; + + config.captureSystemAudio = findBool("captureSystemAudio"); + config.captureMic = findBool("captureMic"); + + int dx = findInt("displayX"); + int dy = findInt("displayY"); + int dw = findInt("displayW"); + int dh = findInt("displayH"); + if (dw > 0 && dh > 0) { + config.displayX = dx; + config.displayY = dy; + config.displayW = dw; + config.displayH = dh; + config.hasDisplayBounds = true; + } + + return true; +} + +static std::wstring utf8ToWide(const std::string& str) { + if (str.empty()) return L""; + int len = MultiByteToWideChar(CP_UTF8, 0, str.c_str(), static_cast(str.size()), nullptr, 0); + std::wstring wstr(len, L'\0'); + MultiByteToWideChar(CP_UTF8, 0, str.c_str(), static_cast(str.size()), &wstr[0], len); + return wstr; +} + +static int64_t queryPerformanceCounterHns() { + LARGE_INTEGER counter; + LARGE_INTEGER frequency; + if (!QueryPerformanceCounter(&counter) || !QueryPerformanceFrequency(&frequency) || frequency.QuadPart <= 0) { + return g_lastFrameTimestampHns.load(); + } + + return static_cast( + (static_cast(counter.QuadPart) * 10000000.0L) / + static_cast(frequency.QuadPart)); +} + +static int64_t adjustedVideoTimestampHns(int64_t timestampHns) { + int64_t accumulatedPausedHns = g_accumulatedPausedHns.load(); + if (g_pauseRequested.load()) { + const int64_t pauseStart = g_pauseStartTimestampHns.load(); + if (pauseStart > 0 && timestampHns > pauseStart) { + accumulatedPausedHns += (timestampHns - pauseStart); + } + } + + int64_t adjustedTimestampHns = timestampHns - accumulatedPausedHns; + if (adjustedTimestampHns < 0) { + adjustedTimestampHns = 0; + } + return adjustedTimestampHns; +} + +static void openActivePauseAt(int64_t timestampHns) { + g_pauseStartTimestampHns.store(timestampHns); + g_pauseRequested.store(true); +} + +static void closeActivePauseAt(int64_t timestampHns) { + const int64_t pauseStart = g_pauseStartTimestampHns.exchange(0); + if (pauseStart > 0 && timestampHns > pauseStart) { + g_accumulatedPausedHns.fetch_add(timestampHns - pauseStart); + } + g_pauseRequested.store(false); +} + +static void writeCompanionAudioTimingMetadata( + const std::string& audioPath, + int64_t firstVideoTimestampHns, + const WasapiCapture& capture +) { + if (audioPath.empty() || firstVideoTimestampHns < 0) { + return; + } + + const int64_t firstPacketQpcHns = capture.firstPacketQpcHns(); + if (firstPacketQpcHns < 0) { + return; + } + + int64_t startDelayMs = (firstPacketQpcHns - firstVideoTimestampHns + 5000) / 10000; + if (startDelayMs < 0) { + startDelayMs = 0; + } + + std::ofstream metadataFile(audioPath + ".json", std::ios::trunc); + if (!metadataFile) { + std::cerr << "WARNING: Failed to write audio timing metadata for " << audioPath << std::endl; + return; + } + + metadataFile << "{\"startDelayMs\":" << startDelayMs; + metadataFile << ",\"capturedDurationMs\":" << capture.capturedDurationMs(); + metadataFile << ",\"dataBytes\":" << capture.totalDataBytes(); + metadataFile << ",\"sampleRate\":" << capture.sampleRate(); + metadataFile << ",\"channels\":" << capture.channelCount(); + metadataFile << ",\"gapFillCount\":" << capture.gapFillCount(); + metadataFile << ",\"insertedSilenceFrames\":" << capture.insertedSilenceFrames(); + metadataFile << ",\"compactedDiscontinuityCount\":" << capture.compactedDiscontinuityCount(); + metadataFile << ",\"compactedDiscontinuityFrames\":" << capture.compactedDiscontinuityFrames(); + metadataFile << ",\"compactedSilentDiscontinuityCount\":" << capture.compactedSilentDiscontinuityCount(); + metadataFile << ",\"compactedSilentDiscontinuityFrames\":" << capture.compactedSilentDiscontinuityFrames(); + const uint32_t discontinuityCount = capture.dataDiscontinuityCount(); + if (discontinuityCount > 0) { + metadataFile << ",\"dataDiscontinuityCount\":" << discontinuityCount; + } + const uint32_t timestampErrorCount = capture.timestampErrorCount(); + if (timestampErrorCount > 0) { + metadataFile << ",\"timestampErrorCount\":" << timestampErrorCount; + } + metadataFile << "}"; +} + +static void stdinListenerThread() { + std::string line; + while (std::getline(std::cin, line)) { + // Trim whitespace + while (!line.empty() && (line.back() == '\r' || line.back() == '\n' || line.back() == ' ')) { + line.pop_back(); + } + + if (line == "pause") { + openActivePauseAt(queryPerformanceCounterHns()); + continue; + } + + if (line == "resume") { + closeActivePauseAt(queryPerformanceCounterHns()); + continue; + } + + if (line == "stop") { + g_stopRequested = true; + g_stopCv.notify_all(); + return; + } + } + + // stdin closed (parent process died) + g_stopRequested = true; + g_stopCv.notify_all(); +} + +int main(int argc, char* argv[]) { + if (argc < 2) { + std::cerr << "ERROR: Missing JSON config argument" << std::endl; + return 1; + } + + winrt::init_apartment(winrt::apartment_type::multi_threaded); + + CaptureConfig config; + if (!parseSimpleJson(argv[1], config)) { + std::cerr << "ERROR: Failed to parse config JSON" << std::endl; + return 1; + } + + WgcSession session; + + if (config.windowHandle > 0) { + HWND hwnd = reinterpret_cast(static_cast(config.windowHandle)); + if (!IsWindow(hwnd)) { + std::cerr << "ERROR: Invalid window handle " << config.windowHandle << std::endl; + return 1; + } + if (!session.initialize(hwnd, config.fps)) { + std::cerr << "ERROR: Failed to initialize WGC window capture session" << std::endl; + return 1; + } + } else { + HMONITOR monitor = findMonitorByDisplayId(config.displayId); + if (!monitor && config.hasDisplayBounds) { + std::cerr << "Monitor ID match failed, attempting coordinate-based match: " + << config.displayX << "," << config.displayY << " " << config.displayW << "x" << config.displayH << std::endl; + monitor = findMonitorByBounds(config.displayX, config.displayY, config.displayW, config.displayH); + } + + if (!monitor) { + std::cerr << "ERROR: Could not find monitor for displayId " << config.displayId << std::endl; + return 1; + } + if (!session.initialize(monitor, config.fps)) { + std::cerr << "ERROR: Failed to initialize WGC capture session" << std::endl; + return 1; + } + } + + int captureWidth = config.width > 0 ? config.width : session.captureWidth(); + int captureHeight = config.height > 0 ? config.height : session.captureHeight(); + + // Ensure even dimensions for H.264 + captureWidth = (captureWidth / 2) * 2; + captureHeight = (captureHeight / 2) * 2; + + // Initialize encoder + MFEncoder encoder; + std::wstring outputPathW = utf8ToWide(config.outputPath); + if (!encoder.initialize(outputPathW, captureWidth, captureHeight, config.fps, + session.device(), session.context())) { + std::cerr << "ERROR: Failed to initialize Media Foundation encoder" << std::endl; + return 1; + } + + // Set up frame callback + std::atomic frameCount{0}; + std::atomic firstVideoTimestampHns{-1}; + std::atomic recordingStartedAnnounced{false}; + session.setFrameCallback([&](ID3D11Texture2D* texture, int64_t timestampHns) { + g_lastFrameTimestampHns = timestampHns; + int64_t expectedFirstVideoTimestampHns = -1; + firstVideoTimestampHns.compare_exchange_strong(expectedFirstVideoTimestampHns, timestampHns); + if (g_stopRequested) return; + + if (g_pauseRequested) return; + + const int64_t adjustedTimestampHns = adjustedVideoTimestampHns(timestampHns); + + if (encoder.writeFrame(texture, adjustedTimestampHns)) { + const int64_t writtenFrames = frameCount.fetch_add(1) + 1; + if (writtenFrames == 1 && !recordingStartedAnnounced.exchange(true)) { + std::cout << "Recording started" << std::endl; + std::cout.flush(); + } + } + }); + + // Start stdin listener + std::thread stdinThread(stdinListenerThread); + stdinThread.detach(); + + // Initialize WASAPI captures (but don't start yet) + WasapiCapture loopback; + WasapiCapture micCapture; + bool audioActive = false; + bool audioInitialized = false; + bool micActive = false; + bool micInitialized = false; + + if (config.captureSystemAudio && !config.audioOutputPath.empty()) { + audioInitialized = loopback.initializeLoopback(config.audioOutputPath); + if (!audioInitialized) { + std::cerr << "WARNING: Failed to initialize WASAPI loopback" << std::endl; + } + } + + if (config.captureMic && !config.micOutputPath.empty()) { + micInitialized = micCapture.initializeMic(config.micOutputPath, config.micDeviceName); + if (!micInitialized) { + std::cerr << "WARNING: Failed to initialize WASAPI mic capture" << std::endl; + } + } + + // Start video capture, then audio immediately after for sync + if (!session.startCapture()) { + std::cerr << "ERROR: Failed to start WGC capture" << std::endl; + return 1; + } + + if (audioInitialized) { + audioActive = loopback.start(); + } + if (micInitialized) { + micActive = micCapture.start(); + } + + // Wait for stop signal while pausing/resuming audio tracks in lockstep. + while (!g_stopRequested && !session.hasFatalError()) { + if (g_pauseRequested) { + if (audioActive) loopback.pause(); + if (micActive) micCapture.pause(); + } else { + if (audioActive) loopback.resume(); + if (micActive) micCapture.resume(); + } + + std::unique_lock lock(g_stopMutex); + g_stopCv.wait_for(lock, std::chrono::milliseconds(20), [] { return g_stopRequested.load(); }); + } + + // Stop capture and finalize + const int64_t adjustedStopTimestampHns = adjustedVideoTimestampHns(queryPerformanceCounterHns()); + session.stopCapture(); + if (audioActive) loopback.stop(); + if (micActive) micCapture.stop(); + + if (session.hasFatalError()) { + std::cerr << "ERROR: WGC capture session failed during recording" << std::endl; + encoder.finalize(); + DeleteFileW(outputPathW.c_str()); + if (!config.audioOutputPath.empty()) { + const std::wstring audioPathW = utf8ToWide(config.audioOutputPath); + const std::wstring audioMetadataPathW = utf8ToWide(config.audioOutputPath + ".json"); + DeleteFileW(audioPathW.c_str()); + DeleteFileW(audioMetadataPathW.c_str()); + } + if (!config.micOutputPath.empty()) { + const std::wstring micPathW = utf8ToWide(config.micOutputPath); + const std::wstring micMetadataPathW = utf8ToWide(config.micOutputPath + ".json"); + DeleteFileW(micPathW.c_str()); + DeleteFileW(micMetadataPathW.c_str()); + } + return 1; + } + + if (audioActive) { + writeCompanionAudioTimingMetadata( + config.audioOutputPath, + firstVideoTimestampHns.load(), + loopback); + } + if (micActive) { + writeCompanionAudioTimingMetadata( + config.micOutputPath, + firstVideoTimestampHns.load(), + micCapture); + } + + if (frameCount.load() <= 0) { + std::cerr << "ERROR: No video frames were captured before stop" << std::endl; + DeleteFileW(outputPathW.c_str()); + return 1; + } + + if (!encoder.extendLastFrameTo(adjustedStopTimestampHns)) { + std::cerr << "WARNING: Failed to extend the last video frame to the stop timestamp" << std::endl; + } + + if (!encoder.finalize()) { + std::cerr << "ERROR: Failed to finalize Media Foundation encoder" << std::endl; + return 1; + } + + std::cout << "Recording stopped. Output path: " << config.outputPath << std::endl; + if (audioActive) { + if ( + loopback.dataDiscontinuityCount() > 0 || + loopback.timestampErrorCount() > 0 || + loopback.gapFillCount() > 0 || + loopback.compactedDiscontinuityCount() > 0 || + loopback.compactedSilentDiscontinuityCount() > 0 + ) { + std::cerr << "WARNING: System audio timing metadata includes discontinuities=" + << loopback.dataDiscontinuityCount() + << " timestampErrors=" << loopback.timestampErrorCount() + << " gapFills=" << loopback.gapFillCount() + << " insertedSilenceFrames=" << loopback.insertedSilenceFrames() + << " compactedDiscontinuities=" << loopback.compactedDiscontinuityCount() + << " compactedDiscontinuityFrames=" << loopback.compactedDiscontinuityFrames() + << " compactedSilentDiscontinuities=" << loopback.compactedSilentDiscontinuityCount() + << " compactedSilentDiscontinuityFrames=" << loopback.compactedSilentDiscontinuityFrames() + << std::endl; + } + std::cout << "Audio path: " << config.audioOutputPath << std::endl; + } + if (micActive) { + if ( + micCapture.dataDiscontinuityCount() > 0 || + micCapture.timestampErrorCount() > 0 || + micCapture.gapFillCount() > 0 || + micCapture.compactedDiscontinuityCount() > 0 || + micCapture.compactedSilentDiscontinuityCount() > 0 + ) { + std::cerr << "WARNING: Microphone timing metadata includes discontinuities=" + << micCapture.dataDiscontinuityCount() + << " timestampErrors=" << micCapture.timestampErrorCount() + << " gapFills=" << micCapture.gapFillCount() + << " insertedSilenceFrames=" << micCapture.insertedSilenceFrames() + << " compactedDiscontinuities=" << micCapture.compactedDiscontinuityCount() + << " compactedDiscontinuityFrames=" << micCapture.compactedDiscontinuityFrames() + << " compactedSilentDiscontinuities=" << micCapture.compactedSilentDiscontinuityCount() + << " compactedSilentDiscontinuityFrames=" << micCapture.compactedSilentDiscontinuityFrames() + << std::endl; + } + std::cout << "Mic path: " << config.micOutputPath << std::endl; + } + std::cout.flush(); + + // Allow pipe buffers to drain before forceful exit + Sleep(100); + + // Fast exit to avoid WinRT/COM teardown crashes during apartment cleanup + ExitProcess(0); +} diff --git a/electron/native/wgc-capture/src/mf_encoder.cpp b/electron/native/wgc-capture/src/mf_encoder.cpp new file mode 100644 index 000000000..17b11effa --- /dev/null +++ b/electron/native/wgc-capture/src/mf_encoder.cpp @@ -0,0 +1,372 @@ +#include "mf_encoder.h" +#include +#include +#include +#include +#include +#include +#include +#include "../../common/bt709_video.h" + +#pragma comment(lib, "mfplat.lib") +#pragma comment(lib, "mfreadwrite.lib") +#pragma comment(lib, "mf.lib") +#pragma comment(lib, "mfuuid.lib") + +static UINT32 calculateScreenRecordingBitrate(int width, int height, int fps) { + constexpr uint64_t kFourKPixels = 3840ULL * 2160ULL; + constexpr uint64_t kQhdPixels = 2560ULL * 1440ULL; + constexpr UINT32 kBitrate4K = 45000000; + constexpr UINT32 kBitrateQhd = 28000000; + constexpr UINT32 kBitrateBase = 18000000; + constexpr double kHighFrameRateBoost = 1.35; + + const uint64_t pixels = + static_cast((std::max)(width, 1)) * + static_cast((std::max)(height, 1)); + const UINT32 baseBitrate = + pixels >= kFourKPixels ? kBitrate4K : + pixels >= kQhdPixels ? kBitrateQhd : + kBitrateBase; + const double boost = fps >= 60 ? kHighFrameRateBoost : 1.0; + return static_cast(static_cast(baseBitrate) * boost + 0.5); +} + +MFEncoder::MFEncoder() {} + +MFEncoder::~MFEncoder() { + finalize(); +} + +bool MFEncoder::initialize(const std::wstring& outputPath, int width, int height, int fps, + ID3D11Device* device, ID3D11DeviceContext* context) { + std::lock_guard lock(mutex_); + + if (initialized_) return false; + + if (fps <= 0) { + std::cerr << "ERROR: Encoder fps must be positive, got " << fps << std::endl; + return false; + } + + if (width % 2 != 0 || height % 2 != 0) { + std::cerr << "ERROR: Encoder dimensions must be even, got " << width << "x" << height << std::endl; + return false; + } + + width_ = width; + height_ = height; + fps_ = fps; + device_ = device; + context_ = context; + + HRESULT hr = MFStartup(MF_VERSION); + if (FAILED(hr)) { + std::cerr << "ERROR: MFStartup failed: 0x" << std::hex << hr << std::endl; + return false; + } + + // Output media type (H.264) + ComPtr outputType; + hr = MFCreateMediaType(&outputType); + if (FAILED(hr)) return false; + + outputType->SetGUID(MF_MT_MAJOR_TYPE, MFMediaType_Video); + outputType->SetGUID(MF_MT_SUBTYPE, MFVideoFormat_H264); + const UINT32 videoBitrate = calculateScreenRecordingBitrate(width_, height_, fps_); + outputType->SetUINT32(MF_MT_AVG_BITRATE, videoBitrate); + MFSetAttributeSize(outputType.Get(), MF_MT_FRAME_SIZE, width_, height_); + MFSetAttributeRatio(outputType.Get(), MF_MT_FRAME_RATE, fps_, 1); + MFSetAttributeRatio(outputType.Get(), MF_MT_PIXEL_ASPECT_RATIO, 1, 1); + outputType->SetUINT32(MF_MT_INTERLACE_MODE, MFVideoInterlace_Progressive); + hr = setBt709LimitedVideoAttributes(outputType.Get()); + if (FAILED(hr)) return false; + std::cerr << "Encoder bitrate: " << videoBitrate << " bps for " + << width_ << "x" << height_ << "@" << fps_ << "fps" << std::endl; + + // Input media type (NV12) + ComPtr inputType; + hr = MFCreateMediaType(&inputType); + if (FAILED(hr)) return false; + + inputType->SetGUID(MF_MT_MAJOR_TYPE, MFMediaType_Video); + inputType->SetGUID(MF_MT_SUBTYPE, MFVideoFormat_NV12); + MFSetAttributeSize(inputType.Get(), MF_MT_FRAME_SIZE, width_, height_); + MFSetAttributeRatio(inputType.Get(), MF_MT_FRAME_RATE, fps_, 1); + MFSetAttributeRatio(inputType.Get(), MF_MT_PIXEL_ASPECT_RATIO, 1, 1); + inputType->SetUINT32(MF_MT_INTERLACE_MODE, MFVideoInterlace_Progressive); + hr = setBt709LimitedVideoAttributes(inputType.Get()); + if (FAILED(hr)) return false; + + // Create SinkWriter with MPEG4 container + ComPtr writerAttrs; + hr = MFCreateAttributes(&writerAttrs, 1); + if (FAILED(hr)) return false; + + writerAttrs->SetUINT32(MF_READWRITE_ENABLE_HARDWARE_TRANSFORMS, TRUE); + + hr = MFCreateSinkWriterFromURL(outputPath.c_str(), nullptr, writerAttrs.Get(), &sinkWriter_); + if (FAILED(hr)) { + std::cerr << "ERROR: MFCreateSinkWriterFromURL failed: 0x" << std::hex << hr << std::endl; + return false; + } + + hr = sinkWriter_->AddStream(outputType.Get(), &streamIndex_); + if (FAILED(hr)) { + std::cerr << "ERROR: AddStream failed: 0x" << std::hex << hr << std::endl; + return false; + } + + hr = sinkWriter_->SetInputMediaType(streamIndex_, inputType.Get(), nullptr); + if (FAILED(hr)) { + std::cerr << "ERROR: SetInputMediaType failed: 0x" << std::hex << hr << std::endl; + return false; + } + + hr = sinkWriter_->BeginWriting(); + if (FAILED(hr)) { + std::cerr << "ERROR: BeginWriting failed: 0x" << std::hex << hr << std::endl; + return false; + } + + // Pre-allocate staging texture + D3D11_TEXTURE2D_DESC stagingDesc = {}; + stagingDesc.Width = width_; + stagingDesc.Height = height_; + stagingDesc.MipLevels = 1; + stagingDesc.ArraySize = 1; + stagingDesc.Format = DXGI_FORMAT_B8G8R8A8_UNORM; + stagingDesc.SampleDesc.Count = 1; + stagingDesc.Usage = D3D11_USAGE_STAGING; + stagingDesc.CPUAccessFlags = D3D11_CPU_ACCESS_READ; + + hr = device_->CreateTexture2D(&stagingDesc, nullptr, &stagingTexture_); + if (FAILED(hr)) { + std::cerr << "ERROR: Failed to create staging texture: 0x" << std::hex << hr << std::endl; + return false; + } + + // WGC window captures can change frame size while recording. Keep the muxer + // output dimensions stable by compositing resized frames into this fixed + // BGRA surface before CPU readback. + D3D11_TEXTURE2D_DESC compositeDesc = {}; + compositeDesc.Width = width_; + compositeDesc.Height = height_; + compositeDesc.MipLevels = 1; + compositeDesc.ArraySize = 1; + compositeDesc.Format = DXGI_FORMAT_B8G8R8A8_UNORM; + compositeDesc.SampleDesc.Count = 1; + compositeDesc.Usage = D3D11_USAGE_DEFAULT; + compositeDesc.BindFlags = D3D11_BIND_RENDER_TARGET; + + hr = device_->CreateTexture2D(&compositeDesc, nullptr, &resizeCompositeTexture_); + if (FAILED(hr)) { + std::cerr << "ERROR: Failed to create resize composite texture: 0x" << std::hex << hr << std::endl; + return false; + } + + hr = device_->CreateRenderTargetView(resizeCompositeTexture_.Get(), nullptr, &resizeCompositeView_); + if (FAILED(hr)) { + std::cerr << "ERROR: Failed to create resize composite view: 0x" << std::hex << hr << std::endl; + return false; + } + + // Pre-allocate NV12 buffer + const int ySize = width_ * height_; + const int uvSize = (width_ / 2) * (height_ / 2) * 2; + nv12Buffer_.resize(ySize + uvSize); + lastFrameBuffer_.clear(); + firstSampleTimeHns_ = -1; + lastSampleTimeHns_ = -1; + + initialized_ = true; + return true; +} + +bool MFEncoder::writeFrame(ID3D11Texture2D* texture, int64_t timestampHns) { + std::lock_guard lock(mutex_); + + if (!initialized_ || !sinkWriter_) return false; + + D3D11_TEXTURE2D_DESC sourceDesc = {}; + texture->GetDesc(&sourceDesc); + + if (sourceDesc.Width == static_cast(width_) && + sourceDesc.Height == static_cast(height_)) { + context_->CopyResource(stagingTexture_.Get(), texture); + } else { + if (!resizeCompositeTexture_ || !resizeCompositeView_) return false; + + const FLOAT clearColor[4] = {0.0f, 0.0f, 0.0f, 1.0f}; + context_->ClearRenderTargetView(resizeCompositeView_.Get(), clearColor); + + D3D11_BOX sourceBox = {}; + sourceBox.left = 0; + sourceBox.top = 0; + sourceBox.front = 0; + sourceBox.right = (std::min)(sourceDesc.Width, static_cast(width_)); + sourceBox.bottom = (std::min)(sourceDesc.Height, static_cast(height_)); + sourceBox.back = 1; + + if (sourceBox.right == 0 || sourceBox.bottom == 0) return false; + + context_->CopySubresourceRegion( + resizeCompositeTexture_.Get(), + 0, + 0, + 0, + 0, + texture, + 0, + &sourceBox); + context_->CopyResource(stagingTexture_.Get(), resizeCompositeTexture_.Get()); + } + + D3D11_MAPPED_SUBRESOURCE mapped; + HRESULT hr = context_->Map(stagingTexture_.Get(), 0, D3D11_MAP_READ, 0, &mapped); + if (FAILED(hr)) return false; + + // Convert full-range desktop BGRA to explicitly tagged BT.709 video-range NV12. + const uint8_t* bgra = static_cast(mapped.pData); + const int bgraPitch = static_cast(mapped.RowPitch); + convertBgraToBt709LimitedNv12(bgra, bgraPitch, width_, height_, nv12Buffer_); + + context_->Unmap(stagingTexture_.Get(), 0); + + // WGC may stop delivering frames while the scene is static; keep the MP4 + // timeline continuous by repeating the previous frame before writing a new one. + int64_t normalizedTimestampHns = 0; + normalizeWriteTimestampHnsLocked(timestampHns, normalizedTimestampHns); + + if (!lastFrameBuffer_.empty() && !extendLastFrameToLocked(normalizedTimestampHns)) { + return false; + } + + bool wroteSample = writeNv12SampleLocked(nv12Buffer_, normalizedTimestampHns); + if (wroteSample) { + lastFrameBuffer_ = nv12Buffer_; + lastSampleTimeHns_ = normalizedTimestampHns; + } + return wroteSample; +} + +bool MFEncoder::extendLastFrameTo(int64_t timestampHns) { + std::lock_guard lock(mutex_); + + int64_t normalizedTimestampHns = 0; + if (!normalizeTimelineTimestampHnsLocked(timestampHns, normalizedTimestampHns)) { + return false; + } + + return extendLastFrameToLocked(normalizedTimestampHns); +} + +void MFEncoder::normalizeWriteTimestampHnsLocked(int64_t timestampHns, int64_t& normalizedTimestampHns) { + if (firstSampleTimeHns_ < 0) { + firstSampleTimeHns_ = timestampHns < 0 ? 0 : timestampHns; + } + + normalizedTimestampHns = timestampHns - firstSampleTimeHns_; + if (normalizedTimestampHns < 0) { + normalizedTimestampHns = 0; + } +} + +bool MFEncoder::normalizeTimelineTimestampHnsLocked( + int64_t timestampHns, + int64_t& normalizedTimestampHns +) const { + if (firstSampleTimeHns_ < 0) return false; + + normalizedTimestampHns = timestampHns - firstSampleTimeHns_; + if (normalizedTimestampHns < 0) { + normalizedTimestampHns = 0; + } + return true; +} + +bool MFEncoder::extendLastFrameToLocked(int64_t timestampHns) { + if (!initialized_ || !sinkWriter_) return false; + if (lastFrameBuffer_.empty()) return false; + if (lastSampleTimeHns_ < 0) return false; + + if (fps_ <= 0) return false; + const int64_t frameDurationHns = 10000000LL / fps_; + if (frameDurationHns <= 0) return false; + if (timestampHns <= lastSampleTimeHns_ + frameDurationHns) { + return true; + } + + int64_t nextSampleTimeHns = lastSampleTimeHns_ + frameDurationHns; + while (nextSampleTimeHns + frameDurationHns <= timestampHns) { + if (!writeNv12SampleLocked(lastFrameBuffer_, nextSampleTimeHns)) { + return false; + } + lastSampleTimeHns_ = nextSampleTimeHns; + nextSampleTimeHns += frameDurationHns; + } + + return true; +} + +bool MFEncoder::writeNv12SampleLocked(const std::vector& frameBuffer, int64_t timestampHns) { + if (frameBuffer.empty()) return false; + if (fps_ <= 0) return false; + + const int64_t frameDurationHns = 10000000LL / fps_; + if (frameDurationHns <= 0) return false; + + // Create MF sample + DWORD bufferSize = static_cast(frameBuffer.size()); + ComPtr buffer; + HRESULT hr = MFCreateMemoryBuffer(bufferSize, &buffer); + if (FAILED(hr)) return false; + + BYTE* bufferData = nullptr; + hr = buffer->Lock(&bufferData, nullptr, nullptr); + if (FAILED(hr)) return false; + + std::memcpy(bufferData, frameBuffer.data(), bufferSize); + buffer->Unlock(); + buffer->SetCurrentLength(bufferSize); + + ComPtr sample; + hr = MFCreateSample(&sample); + if (FAILED(hr)) return false; + + sample->AddBuffer(buffer.Get()); + sample->SetSampleTime(timestampHns); + sample->SetSampleDuration(frameDurationHns); + + hr = sinkWriter_->WriteSample(streamIndex_, sample.Get()); + if (FAILED(hr)) { + std::cerr << "ERROR: WriteSample failed: 0x" << std::hex << hr << std::endl; + } + return SUCCEEDED(hr); +} + +bool MFEncoder::finalize() { + std::lock_guard lock(mutex_); + + if (!initialized_) return false; + if (!sinkWriter_) return false; + + HRESULT hr = sinkWriter_->Finalize(); + if (FAILED(hr)) { + std::cerr << "ERROR: SinkWriter Finalize failed: 0x" << std::hex << hr << std::endl; + } + + initialized_ = false; + sinkWriter_.Reset(); + stagingTexture_.Reset(); + resizeCompositeView_.Reset(); + resizeCompositeTexture_.Reset(); + nv12Buffer_.clear(); + lastFrameBuffer_.clear(); + nv12Buffer_.shrink_to_fit(); + lastFrameBuffer_.shrink_to_fit(); + firstSampleTimeHns_ = -1; + lastSampleTimeHns_ = -1; + MFShutdown(); + return SUCCEEDED(hr); +} diff --git a/electron/native/wgc-capture/src/mf_encoder.h b/electron/native/wgc-capture/src/mf_encoder.h new file mode 100644 index 000000000..3217389af --- /dev/null +++ b/electron/native/wgc-capture/src/mf_encoder.h @@ -0,0 +1,48 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using Microsoft::WRL::ComPtr; + +class MFEncoder { +public: + MFEncoder(); + ~MFEncoder(); + + bool initialize(const std::wstring& outputPath, int width, int height, int fps, + ID3D11Device* device, ID3D11DeviceContext* context); + bool writeFrame(ID3D11Texture2D* texture, int64_t timestampHns); + bool extendLastFrameTo(int64_t timestampHns); + bool finalize(); + +private: + void normalizeWriteTimestampHnsLocked(int64_t timestampHns, int64_t& normalizedTimestampHns); + bool normalizeTimelineTimestampHnsLocked(int64_t timestampHns, int64_t& normalizedTimestampHns) const; + bool extendLastFrameToLocked(int64_t timestampHns); + bool writeNv12SampleLocked(const std::vector& frameBuffer, int64_t timestampHns); + + ComPtr sinkWriter_; + ID3D11Device* device_ = nullptr; + ID3D11DeviceContext* context_ = nullptr; + ComPtr stagingTexture_; + ComPtr resizeCompositeTexture_; + ComPtr resizeCompositeView_; + std::vector nv12Buffer_; + std::vector lastFrameBuffer_; + DWORD streamIndex_ = 0; + int width_ = 0; + int height_ = 0; + int fps_ = 60; + int64_t firstSampleTimeHns_ = -1; + int64_t lastSampleTimeHns_ = -1; + bool initialized_ = false; + std::mutex mutex_; +}; diff --git a/electron/native/wgc-capture/src/monitor_utils.cpp b/electron/native/wgc-capture/src/monitor_utils.cpp new file mode 100644 index 000000000..a175bd7f0 --- /dev/null +++ b/electron/native/wgc-capture/src/monitor_utils.cpp @@ -0,0 +1,85 @@ +#include "monitor_utils.h" +#include +#include + +static BOOL CALLBACK enumMonitorCallback(HMONITOR hMonitor, HDC, LPRECT, LPARAM lParam) { + auto* monitors = reinterpret_cast*>(lParam); + + MONITORINFOEXW mi = {}; + mi.cbSize = sizeof(mi); + if (GetMonitorInfoW(hMonitor, &mi)) { + MonitorInfo info; + info.handle = hMonitor; + info.x = mi.rcMonitor.left; + info.y = mi.rcMonitor.top; + info.width = mi.rcMonitor.right - mi.rcMonitor.left; + info.height = mi.rcMonitor.bottom - mi.rcMonitor.top; + info.deviceName = mi.szDevice; + monitors->push_back(info); + } + + return TRUE; +} + +std::vector enumerateMonitors() { + std::vector monitors; + EnumDisplayMonitors(nullptr, nullptr, enumMonitorCallback, reinterpret_cast(&monitors)); + return monitors; +} + +// Electron uses the HMONITOR handle value cast to a number as the display ID. +HMONITOR findMonitorByDisplayId(int64_t displayId) { + auto monitors = enumerateMonitors(); + + for (const auto& m : monitors) { + if (static_cast(reinterpret_cast(m.handle)) == displayId) { + return m.handle; + } + } + + return nullptr; +} + +HMONITOR findMonitorByBounds(int x, int y, int width, int height) { + auto monitors = enumerateMonitors(); + + for (const auto& m : monitors) { + if (m.x == x && m.y == y && m.width == width && m.height == height) { + std::cerr << "Found monitor by exact bounds: " << x << "," << y << " " << width << "x" << height << std::endl; + return m.handle; + } + } + + for (const auto& m : monitors) { + if (m.x == x && m.y == y) { + std::cerr << "Found monitor by top-left point match: " << x << "," << y << std::endl; + return m.handle; + } + } + + RECT rect = { x, y, x + width, y + height }; + HMONITOR monitor = MonitorFromRect(&rect, MONITOR_DEFAULTTONULL); + if (monitor) { + std::cerr << "Found monitor via Windows OS MonitorFromRect fallback" << std::endl; + return monitor; + } + + return nullptr; +} + +MonitorInfo getMonitorInfo(HMONITOR monitor) { + MonitorInfo info; + info.handle = monitor; + + MONITORINFOEXW mi = {}; + mi.cbSize = sizeof(mi); + if (GetMonitorInfoW(monitor, &mi)) { + info.x = mi.rcMonitor.left; + info.y = mi.rcMonitor.top; + info.width = mi.rcMonitor.right - mi.rcMonitor.left; + info.height = mi.rcMonitor.bottom - mi.rcMonitor.top; + info.deviceName = mi.szDevice; + } + + return info; +} diff --git a/electron/native/wgc-capture/src/monitor_utils.h b/electron/native/wgc-capture/src/monitor_utils.h new file mode 100644 index 000000000..90f3ac396 --- /dev/null +++ b/electron/native/wgc-capture/src/monitor_utils.h @@ -0,0 +1,20 @@ +#pragma once + +#include +#include +#include +#include + +struct MonitorInfo { + HMONITOR handle; + int x; + int y; + int width; + int height; + std::wstring deviceName; +}; + +std::vector enumerateMonitors(); +HMONITOR findMonitorByDisplayId(int64_t displayId); +HMONITOR findMonitorByBounds(int x, int y, int width, int height); +MonitorInfo getMonitorInfo(HMONITOR monitor); diff --git a/electron/native/wgc-capture/src/wasapi_loopback.cpp b/electron/native/wgc-capture/src/wasapi_loopback.cpp new file mode 100644 index 000000000..2a8a5d177 --- /dev/null +++ b/electron/native/wgc-capture/src/wasapi_loopback.cpp @@ -0,0 +1,573 @@ +#include "wasapi_loopback.h" +#include +#include +#include +#include +#include + +#pragma comment(lib, "ole32.lib") + +namespace { +constexpr int64_t kHundredNanosecondsPerSecond = 10000000; +constexpr uint64_t kSilenceWriteChunkFrames = 4096; +constexpr uint32_t kMinimumGapFillThresholdMs = 50; +// Wireless headsets can flag short WASAPI discontinuities that are less audible +// when compacted and later duration-corrected than when rendered as hard silence. +constexpr uint32_t kDiscontinuityGapFillThresholdMs = 140; +constexpr uint32_t kSilentDiscontinuityCompactThresholdMs = 40; +constexpr uint32_t kBoundaryFadeInMs = 5; + +int64_t queryPerformanceCounterHns() { + LARGE_INTEGER counter; + LARGE_INTEGER frequency; + if (!QueryPerformanceCounter(&counter) || !QueryPerformanceFrequency(&frequency) || frequency.QuadPart <= 0) { + return 0; + } + + return static_cast( + (static_cast(counter.QuadPart) * 10000000.0L) / + static_cast(frequency.QuadPart)); +} + +bool isFloatFormat(const WAVEFORMATEX* format) { + if (!format) return false; + if (format->wFormatTag == WAVE_FORMAT_IEEE_FLOAT) return true; + if (format->wFormatTag != WAVE_FORMAT_EXTENSIBLE) return false; + return reinterpret_cast(format)->SubFormat == + KSDATAFORMAT_SUBTYPE_IEEE_FLOAT; +} + +bool isPcmFormat(const WAVEFORMATEX* format) { + if (!format) return false; + if (format->wFormatTag == WAVE_FORMAT_PCM) return true; + if (format->wFormatTag != WAVE_FORMAT_EXTENSIBLE) return false; + return reinterpret_cast(format)->SubFormat == + KSDATAFORMAT_SUBTYPE_PCM; +} + +int16_t pcm24ToInt16(const BYTE* sample) { + int32_t value = static_cast(sample[0]) | + (static_cast(sample[1]) << 8) | + (static_cast(sample[2]) << 16); + if ((value & 0x800000) != 0) { + value |= ~0xFFFFFF; + } + return static_cast(value >> 8); +} +} + +static const CLSID CLSID_MMDeviceEnumerator_ = __uuidof(MMDeviceEnumerator); +static const IID IID_IMMDeviceEnumerator_ = __uuidof(IMMDeviceEnumerator); +static const IID IID_IAudioClient_ = __uuidof(IAudioClient); +static const IID IID_IAudioCaptureClient_ = __uuidof(IAudioCaptureClient); + +WasapiCapture::WasapiCapture() {} + +WasapiCapture::~WasapiCapture() { + stop(); + if (mixFormat_) CoTaskMemFree(mixFormat_); + if (captureClient_) captureClient_->Release(); + if (audioClient_) audioClient_->Release(); + if (device_) device_->Release(); + if (enumerator_) enumerator_->Release(); +} + +static std::wstring utf8ToWide(const std::string& str) { + if (str.empty()) return L""; + int len = MultiByteToWideChar(CP_UTF8, 0, str.c_str(), static_cast(str.size()), nullptr, 0); + std::wstring wstr(len, L'\0'); + MultiByteToWideChar(CP_UTF8, 0, str.c_str(), static_cast(str.size()), &wstr[0], len); + return wstr; +} + +IMMDevice* WasapiCapture::findCaptureDeviceByName(const std::wstring& targetName) { + IMMDeviceCollection* collection = nullptr; + HRESULT hr = enumerator_->EnumAudioEndpoints(eCapture, DEVICE_STATE_ACTIVE, &collection); + if (FAILED(hr)) return nullptr; + + UINT count = 0; + collection->GetCount(&count); + + for (UINT i = 0; i < count; i++) { + IMMDevice* dev = nullptr; + collection->Item(i, &dev); + + IPropertyStore* store = nullptr; + dev->OpenPropertyStore(STGM_READ, &store); + PROPVARIANT pv; + PropVariantInit(&pv); + store->GetValue(PKEY_Device_FriendlyName, &pv); + std::wstring name = pv.pwszVal ? pv.pwszVal : L""; + PropVariantClear(&pv); + store->Release(); + + if (name.find(targetName) != std::wstring::npos || targetName.find(name) != std::wstring::npos) { + collection->Release(); + return dev; + } + dev->Release(); + } + + collection->Release(); + return nullptr; +} + +bool WasapiCapture::initializeLoopback(const std::string& outputPath) { + outputPath_ = outputPath; + streamFlags_ = AUDCLNT_STREAMFLAGS_LOOPBACK; + + HRESULT hr = CoCreateInstance( + CLSID_MMDeviceEnumerator_, nullptr, CLSCTX_ALL, + IID_IMMDeviceEnumerator_, reinterpret_cast(&enumerator_)); + if (FAILED(hr)) return false; + + hr = enumerator_->GetDefaultAudioEndpoint(eRender, eConsole, &device_); + if (FAILED(hr)) return false; + + return initializeCommon(); +} + +bool WasapiCapture::initializeMic(const std::string& outputPath, const std::string& deviceName) { + outputPath_ = outputPath; + streamFlags_ = 0; + + HRESULT hr = CoCreateInstance( + CLSID_MMDeviceEnumerator_, nullptr, CLSCTX_ALL, + IID_IMMDeviceEnumerator_, reinterpret_cast(&enumerator_)); + if (FAILED(hr)) return false; + + if (!deviceName.empty()) { + device_ = findCaptureDeviceByName(utf8ToWide(deviceName)); + } + if (!device_) { + hr = enumerator_->GetDefaultAudioEndpoint(eCapture, eCommunications, &device_); + if (FAILED(hr)) { + hr = enumerator_->GetDefaultAudioEndpoint(eCapture, eConsole, &device_); + if (FAILED(hr)) return false; + } + } + + return initializeCommon(); +} + +bool WasapiCapture::initializeCommon() { + HRESULT hr = device_->Activate(IID_IAudioClient_, CLSCTX_ALL, nullptr, + reinterpret_cast(&audioClient_)); + if (FAILED(hr)) return false; + + hr = audioClient_->GetMixFormat(&mixFormat_); + if (FAILED(hr)) return false; + + REFERENCE_TIME bufferDuration = 200000; // 20ms + hr = audioClient_->Initialize( + AUDCLNT_SHAREMODE_SHARED, + streamFlags_, + bufferDuration, 0, mixFormat_, nullptr); + if (FAILED(hr)) return false; + + hr = audioClient_->GetBufferSize(&bufferFrameCount_); + if (FAILED(hr)) return false; + + hr = audioClient_->GetService(IID_IAudioCaptureClient_, + reinterpret_cast(&captureClient_)); + if (FAILED(hr)) return false; + + return true; +} + +bool WasapiCapture::start() { + if (capturing_) return true; + + outputFile_ = CreateFileA( + outputPath_.c_str(), GENERIC_WRITE, 0, nullptr, + CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr); + + if (outputFile_ == INVALID_HANDLE_VALUE) { + std::cerr << "ERROR: Cannot create audio output file" << std::endl; + return false; + } + + totalDataBytes_ = 0; + framesWritten_ = 0; + firstPacketQpcHns_ = -1; + pauseStartQpcHns_ = 0; + accumulatedPausedQpcHns_ = 0; + dataDiscontinuityCount_ = 0; + timestampErrorCount_ = 0; + gapFillCount_ = 0; + insertedSilenceFrames_ = 0; + compactedDiscontinuityCount_ = 0; + compactedDiscontinuityFrames_ = 0; + compactedSilentDiscontinuityCount_ = 0; + compactedSilentDiscontinuityFrames_ = 0; + fadeInFramesRemaining_ = 0; + writeWavHeader(outputFile_, 0); + + HRESULT hr = audioClient_->Start(); + if (FAILED(hr)) { + CloseHandle(outputFile_); + outputFile_ = INVALID_HANDLE_VALUE; + return false; + } + + capturing_ = true; + paused_ = false; + thread_ = std::thread(&WasapiCapture::captureThread, this); + return true; +} + +bool WasapiCapture::pause() { + if (!capturing_ || paused_) return true; + pauseStartQpcHns_ = queryPerformanceCounterHns(); + paused_ = true; + return audioClient_ ? SUCCEEDED(audioClient_->Stop()) : false; +} + +bool WasapiCapture::resume() { + if (!capturing_ || !paused_) return true; + const int64_t resumedQpcHns = queryPerformanceCounterHns(); + const int64_t pauseStartQpcHns = pauseStartQpcHns_.load(); + HRESULT hr = audioClient_ ? audioClient_->Start() : E_FAIL; + if (FAILED(hr)) return false; + if (pauseStartQpcHns > 0 && resumedQpcHns > pauseStartQpcHns) { + accumulatedPausedQpcHns_.fetch_add(resumedQpcHns - pauseStartQpcHns); + } + pauseStartQpcHns_ = 0; + paused_ = false; + fadeInFramesRemaining_ = boundaryFadeInFrameCount(); + return true; +} + +void WasapiCapture::stop() { + if (!capturing_) return; + capturing_ = false; + if (thread_.joinable()) thread_.join(); + if (audioClient_) audioClient_->Stop(); + + if (outputFile_ != INVALID_HANDLE_VALUE) { + SetFilePointer(outputFile_, 0, nullptr, FILE_BEGIN); + const uint64_t dataBytes = totalDataBytes_.load(); + const DWORD wavDataBytes = + static_cast(std::min(dataBytes, 0xFFFFFFFFu)); + writeWavHeader(outputFile_, wavDataBytes); + CloseHandle(outputFile_); + outputFile_ = INVALID_HANDLE_VALUE; + } + + paused_ = false; + pauseStartQpcHns_ = 0; + accumulatedPausedQpcHns_ = 0; + fadeInFramesRemaining_ = 0; +} + +static int16_t floatToInt16(float v) { + v = v < -1.0f ? -1.0f : (v > 1.0f ? 1.0f : v); + return static_cast(v * 32767.0f); +} + +bool WasapiCapture::writeWavHeader(HANDLE file, DWORD dataSize) { + WORD channels = static_cast(mixFormat_->nChannels); + DWORD sampleRate = mixFormat_->nSamplesPerSec; + WORD bitsPerSample = 16; + WORD blockAlign = channels * (bitsPerSample / 8); + DWORD byteRate = sampleRate * blockAlign; + + DWORD written; + WriteFile(file, "RIFF", 4, &written, nullptr); + DWORD chunkSize = 36 + dataSize; + WriteFile(file, &chunkSize, 4, &written, nullptr); + WriteFile(file, "WAVE", 4, &written, nullptr); + WriteFile(file, "fmt ", 4, &written, nullptr); + DWORD fmtSize = 16; + WriteFile(file, &fmtSize, 4, &written, nullptr); + WORD audioFormat = 1; + WriteFile(file, &audioFormat, 2, &written, nullptr); + WriteFile(file, &channels, 2, &written, nullptr); + WriteFile(file, &sampleRate, 4, &written, nullptr); + WriteFile(file, &byteRate, 4, &written, nullptr); + WriteFile(file, &blockAlign, 2, &written, nullptr); + WriteFile(file, &bitsPerSample, 2, &written, nullptr); + WriteFile(file, "data", 4, &written, nullptr); + WriteFile(file, &dataSize, 4, &written, nullptr); + return true; +} + +void WasapiCapture::writePcmFrames(const int16_t* samples, UINT32 frameCount, WORD channels) { + if (!samples || frameCount == 0 || channels == 0 || outputFile_ == INVALID_HANDLE_VALUE) { + return; + } + + const DWORD bytesToWrite = frameCount * channels * sizeof(int16_t); + const uint32_t fadeFrames = fadeInFramesRemaining_.load(); + if (fadeFrames > 0) { + const uint32_t totalFadeFrames = boundaryFadeInFrameCount(); + const uint32_t remainingFadeFrames = (std::min)(fadeFrames, totalFadeFrames); + const uint32_t framesToFade = + (std::min)(static_cast(frameCount), remainingFadeFrames); + const uint32_t completedFadeFrames = totalFadeFrames - remainingFadeFrames; + std::vector faded(samples, samples + frameCount * channels); + for (uint32_t frame = 0; frame < framesToFade; frame++) { + const double scale = + static_cast(completedFadeFrames + frame + 1) / + static_cast(totalFadeFrames); + for (WORD channel = 0; channel < channels; channel++) { + const size_t index = static_cast(frame) * channels + channel; + const long scaled = std::lround(static_cast(faded[index]) * scale); + faded[index] = static_cast( + std::clamp(scaled, -32768, 32767)); + } + } + fadeInFramesRemaining_ = remainingFadeFrames - framesToFade; + + DWORD written = 0; + WriteFile(outputFile_, faded.data(), bytesToWrite, &written, nullptr); + totalDataBytes_.fetch_add(written); + framesWritten_.fetch_add(written / (channels * sizeof(int16_t))); + return; + } + + DWORD written = 0; + WriteFile(outputFile_, samples, bytesToWrite, &written, nullptr); + totalDataBytes_.fetch_add(written); + framesWritten_.fetch_add(written / (channels * sizeof(int16_t))); +} + +void WasapiCapture::writeSilenceFrames(uint64_t frameCount, WORD channels) { + if (frameCount == 0 || channels == 0 || outputFile_ == INVALID_HANDLE_VALUE) { + return; + } + + gapFillCount_.fetch_add(1); + insertedSilenceFrames_.fetch_add(frameCount); + std::vector silence(static_cast(kSilenceWriteChunkFrames * channels), 0); + while (frameCount > 0) { + const uint64_t chunkFrames = (std::min)(frameCount, kSilenceWriteChunkFrames); + writePcmFrames(silence.data(), static_cast(chunkFrames), channels); + frameCount -= chunkFrames; + } + fadeInFramesRemaining_ = boundaryFadeInFrameCount(); +} + +uint64_t WasapiCapture::capturedDurationMs() const { + if (!mixFormat_ || mixFormat_->nSamplesPerSec == 0) { + return 0; + } + + return (framesWritten_.load() * 1000) / mixFormat_->nSamplesPerSec; +} + +uint32_t WasapiCapture::sampleRate() const { + return mixFormat_ ? mixFormat_->nSamplesPerSec : 0; +} + +uint16_t WasapiCapture::channelCount() const { + return mixFormat_ ? mixFormat_->nChannels : 0; +} + +uint32_t WasapiCapture::boundaryFadeInFrameCount() const { + const uint32_t rate = sampleRate(); + if (rate == 0) { + return 1; + } + const uint32_t fadeFrames = (rate * kBoundaryFadeInMs) / 1000; + return fadeFrames > 0 ? fadeFrames : 1; +} + +void WasapiCapture::captureThread() { + // COM must be initialized on every thread that uses COM objects. + // The main thread calls winrt::init_apartment(MTA) but that only covers + // the main thread. Windows 11 enforces per-thread COM init more strictly + // than Windows 10, causing silent audio capture failures without this. + CoInitializeEx(nullptr, COINIT_MULTITHREADED); + + WORD channels = static_cast(mixFormat_->nChannels); + const bool isFloat = isFloatFormat(mixFormat_); + const bool isPcm = isPcmFormat(mixFormat_); + const WORD bitsPerSample = mixFormat_->wBitsPerSample; + const WORD sourceBlockAlign = mixFormat_->nBlockAlign; + const WORD sourceBytesPerSample = + channels > 0 ? static_cast(sourceBlockAlign / channels) : 0; + + std::vector pcmBuffer; + + DWORD sleepMs = static_cast((static_cast(bufferFrameCount_) / mixFormat_->nSamplesPerSec) * 500.0); + if (sleepMs < 5) sleepMs = 5; + + while (capturing_) { + if (paused_) { + Sleep(10); + continue; + } + + Sleep(sleepMs); + + UINT32 packetLength = 0; + HRESULT hr = captureClient_->GetNextPacketSize(&packetLength); + if (FAILED(hr)) { + std::cerr << "WASAPI: GetNextPacketSize failed hr=0x" << std::hex << hr << std::dec << std::endl; + break; + } + + while (packetLength > 0) { + BYTE* data = nullptr; + UINT32 numFrames = 0; + DWORD flags = 0; + UINT64 devicePosition = 0; + UINT64 qpcPosition = 0; + + hr = captureClient_->GetBuffer( + &data, + &numFrames, + &flags, + &devicePosition, + &qpcPosition); + if (FAILED(hr)) { + std::cerr << "WASAPI: GetBuffer failed hr=0x" << std::hex << hr << std::dec << std::endl; + break; + } + + const bool hasDataDiscontinuity = + (flags & AUDCLNT_BUFFERFLAGS_DATA_DISCONTINUITY) != 0; + if (hasDataDiscontinuity) { + dataDiscontinuityCount_.fetch_add(1); + } + if ((flags & AUDCLNT_BUFFERFLAGS_TIMESTAMP_ERROR) != 0) { + timestampErrorCount_.fetch_add(1); + } + const bool hasReliableTimestamp = + numFrames > 0 && + qpcPosition > 0 && + (flags & AUDCLNT_BUFFERFLAGS_TIMESTAMP_ERROR) == 0; + if (hasReliableTimestamp) { + int64_t expected = -1; + firstPacketQpcHns_.compare_exchange_strong( + expected, + static_cast(qpcPosition)); + + const int64_t firstPacketQpcHns = firstPacketQpcHns_.load(); + if ( + firstPacketQpcHns >= 0 && + static_cast(qpcPosition) > firstPacketQpcHns + ) { + const int64_t elapsedHns = + static_cast(qpcPosition) - firstPacketQpcHns; + const int64_t adjustedElapsedHns = + elapsedHns > accumulatedPausedQpcHns_.load() + ? elapsedHns - accumulatedPausedQpcHns_.load() + : 0; + const uint64_t expectedStartFrame = + (static_cast(adjustedElapsedHns) * mixFormat_->nSamplesPerSec + + kHundredNanosecondsPerSecond / 2) / + kHundredNanosecondsPerSecond; + const uint64_t writtenFrames = framesWritten_.load(); + const uint32_t gapThresholdMs = hasDataDiscontinuity + ? kDiscontinuityGapFillThresholdMs + : kMinimumGapFillThresholdMs; + const uint32_t gapThresholdFrameCount = + (mixFormat_->nSamplesPerSec * gapThresholdMs) / 1000; + const uint64_t gapThresholdFrames = + gapThresholdFrameCount > 0 ? gapThresholdFrameCount : 1; + + if (expectedStartFrame > writtenFrames) { + const uint64_t missingFrames = expectedStartFrame - writtenFrames; + if (missingFrames > gapThresholdFrames) { + writeSilenceFrames(missingFrames, channels); + } else if (hasDataDiscontinuity) { + compactedDiscontinuityCount_.fetch_add(1); + compactedDiscontinuityFrames_.fetch_add(missingFrames); + } + } + } + } + + UINT32 totalSamples = numFrames * channels; + const bool isSilentPacket = (flags & AUDCLNT_BUFFERFLAGS_SILENT) != 0; + const uint32_t silentCompactThresholdFrames = + (mixFormat_->nSamplesPerSec * kSilentDiscontinuityCompactThresholdMs) / 1000; + if ( + hasDataDiscontinuity && + isSilentPacket && + framesWritten_.load() > 0 && + numFrames > 0 && + numFrames <= (silentCompactThresholdFrames > 0 ? silentCompactThresholdFrames : 1) + ) { + compactedSilentDiscontinuityCount_.fetch_add(1); + compactedSilentDiscontinuityFrames_.fetch_add(numFrames); + captureClient_->ReleaseBuffer(numFrames); + hr = captureClient_->GetNextPacketSize(&packetLength); + if (FAILED(hr)) { + std::cerr << "WASAPI: GetNextPacketSize failed hr=0x" << std::hex << hr << std::dec << std::endl; + break; + } + continue; + } + + if (isSilentPacket) { + pcmBuffer.assign(totalSamples, 0); + } else if (isFloat && bitsPerSample == 32 && sourceBytesPerSample >= 4) { + pcmBuffer.resize(totalSamples); + const float* src = reinterpret_cast(data); + for (UINT32 i = 0; i < totalSamples; i++) { + pcmBuffer[i] = floatToInt16(src[i]); + } + } else if (isPcm && bitsPerSample == 16 && sourceBytesPerSample >= 2) { + pcmBuffer.resize(totalSamples); + if (sourceBytesPerSample == sizeof(int16_t)) { + std::memcpy(pcmBuffer.data(), data, totalSamples * sizeof(int16_t)); + } else { + for (UINT32 frame = 0; frame < numFrames; frame++) { + const BYTE* frameData = data + frame * sourceBlockAlign; + for (WORD channel = 0; channel < channels; channel++) { + const BYTE* sample = frameData + channel * sourceBytesPerSample; + pcmBuffer[frame * channels + channel] = + *reinterpret_cast(sample); + } + } + } + } else if (isPcm && bitsPerSample == 24 && sourceBytesPerSample >= 3) { + pcmBuffer.resize(totalSamples); + for (UINT32 frame = 0; frame < numFrames; frame++) { + const BYTE* frameData = data + frame * sourceBlockAlign; + for (WORD channel = 0; channel < channels; channel++) { + const BYTE* sample = frameData + channel * sourceBytesPerSample; + pcmBuffer[frame * channels + channel] = pcm24ToInt16(sample); + } + } + } else if (isPcm && bitsPerSample == 32 && sourceBytesPerSample >= 4) { + pcmBuffer.resize(totalSamples); + for (UINT32 frame = 0; frame < numFrames; frame++) { + const BYTE* frameData = data + frame * sourceBlockAlign; + for (WORD channel = 0; channel < channels; channel++) { + const BYTE* sample = frameData + channel * sourceBytesPerSample; + const int32_t value = *reinterpret_cast(sample); + pcmBuffer[frame * channels + channel] = static_cast(value >> 16); + } + } + } else if (isPcm && bitsPerSample == 8 && sourceBytesPerSample >= 1) { + pcmBuffer.resize(totalSamples); + for (UINT32 frame = 0; frame < numFrames; frame++) { + const BYTE* frameData = data + frame * sourceBlockAlign; + for (WORD channel = 0; channel < channels; channel++) { + const BYTE value = *(frameData + channel * sourceBytesPerSample); + pcmBuffer[frame * channels + channel] = + static_cast((static_cast(value) - 128) << 8); + } + } + } else { + pcmBuffer.assign(totalSamples, 0); + } + + captureClient_->ReleaseBuffer(numFrames); + + writePcmFrames(pcmBuffer.data(), numFrames, channels); + + hr = captureClient_->GetNextPacketSize(&packetLength); + if (FAILED(hr)) { + std::cerr << "WASAPI: GetNextPacketSize failed hr=0x" << std::hex << hr << std::dec << std::endl; + break; + } + } + } + + CoUninitialize(); +} diff --git a/electron/native/wgc-capture/src/wasapi_loopback.h b/electron/native/wgc-capture/src/wasapi_loopback.h new file mode 100644 index 000000000..bf88cb169 --- /dev/null +++ b/electron/native/wgc-capture/src/wasapi_loopback.h @@ -0,0 +1,73 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +class WasapiCapture { +public: + WasapiCapture(); + ~WasapiCapture(); + + bool initializeLoopback(const std::string& outputPath); + bool initializeMic(const std::string& outputPath, const std::string& deviceName = ""); + bool start(); + bool pause(); + bool resume(); + void stop(); + int64_t firstPacketQpcHns() const { return firstPacketQpcHns_.load(); } + uint64_t capturedDurationMs() const; + uint64_t totalDataBytes() const { return totalDataBytes_.load(); } + uint32_t sampleRate() const; + uint16_t channelCount() const; + uint32_t dataDiscontinuityCount() const { return dataDiscontinuityCount_.load(); } + uint32_t timestampErrorCount() const { return timestampErrorCount_.load(); } + uint32_t gapFillCount() const { return gapFillCount_.load(); } + uint64_t insertedSilenceFrames() const { return insertedSilenceFrames_.load(); } + uint32_t compactedDiscontinuityCount() const { return compactedDiscontinuityCount_.load(); } + uint64_t compactedDiscontinuityFrames() const { return compactedDiscontinuityFrames_.load(); } + uint32_t compactedSilentDiscontinuityCount() const { return compactedSilentDiscontinuityCount_.load(); } + uint64_t compactedSilentDiscontinuityFrames() const { return compactedSilentDiscontinuityFrames_.load(); } + +private: + bool initializeCommon(); + void captureThread(); + bool writeWavHeader(HANDLE file, DWORD dataSize); + void writePcmFrames(const int16_t* samples, UINT32 frameCount, WORD channels); + void writeSilenceFrames(uint64_t frameCount, WORD channels); + uint32_t boundaryFadeInFrameCount() const; + IMMDevice* findCaptureDeviceByName(const std::wstring& name); + + std::string outputPath_; + std::thread thread_; + std::atomic capturing_{false}; + std::atomic paused_{false}; + HANDLE outputFile_ = INVALID_HANDLE_VALUE; + std::atomic totalDataBytes_{0}; + std::atomic framesWritten_{0}; + + IMMDeviceEnumerator* enumerator_ = nullptr; + IMMDevice* device_ = nullptr; + IAudioClient* audioClient_ = nullptr; + IAudioCaptureClient* captureClient_ = nullptr; + WAVEFORMATEX* mixFormat_ = nullptr; + DWORD streamFlags_ = 0; + + UINT32 bufferFrameCount_ = 0; + std::atomic firstPacketQpcHns_{-1}; + std::atomic pauseStartQpcHns_{0}; + std::atomic accumulatedPausedQpcHns_{0}; + std::atomic dataDiscontinuityCount_{0}; + std::atomic timestampErrorCount_{0}; + std::atomic gapFillCount_{0}; + std::atomic insertedSilenceFrames_{0}; + std::atomic compactedDiscontinuityCount_{0}; + std::atomic compactedDiscontinuityFrames_{0}; + std::atomic compactedSilentDiscontinuityCount_{0}; + std::atomic compactedSilentDiscontinuityFrames_{0}; + std::atomic fadeInFramesRemaining_{0}; +}; diff --git a/electron/native/wgc-capture/src/wgc_session.cpp b/electron/native/wgc-capture/src/wgc_session.cpp new file mode 100644 index 000000000..898a9fdc4 --- /dev/null +++ b/electron/native/wgc-capture/src/wgc_session.cpp @@ -0,0 +1,290 @@ +#include "wgc_session.h" + +#include +#include +#include + +#include +#include + +#include +#include + +// IDirect3DDxgiInterfaceAccess is a COM interface for getting the DXGI interface +// from a WinRT IDirect3DSurface +MIDL_INTERFACE("A9B3D012-3DF2-4EE3-B8D1-8695F457D3C1") +IDirect3DDxgiInterfaceAccess : public IUnknown { + virtual HRESULT STDMETHODCALLTYPE GetInterface(REFIID iid, void** p) = 0; +}; + +// Convert ID3D11Device → IDirect3DDevice (WinRT interop) +extern "C" { + HRESULT __stdcall CreateDirect3D11DeviceFromDXGIDevice( + IDXGIDevice* dxgiDevice, + IInspectable** graphicsDevice); +} + +static int normalizeFramePoolExtent(int value) { + int normalized = value < 2 ? 2 : value; + if ((normalized % 2) != 0) ++normalized; + return normalized; +} + +WgcSession::WgcSession() {} + +WgcSession::~WgcSession() { + stopCapture(); +} + +bool WgcSession::createD3DDevice() { + D3D_FEATURE_LEVEL featureLevels[] = { + D3D_FEATURE_LEVEL_11_1, + D3D_FEATURE_LEVEL_11_0, + }; + + HRESULT hr = D3D11CreateDevice( + nullptr, + D3D_DRIVER_TYPE_HARDWARE, + nullptr, + D3D11_CREATE_DEVICE_BGRA_SUPPORT, + featureLevels, + ARRAYSIZE(featureLevels), + D3D11_SDK_VERSION, + &d3dDevice_, + nullptr, + &d3dContext_); + + if (FAILED(hr)) { + std::cerr << "ERROR: D3D11CreateDevice failed: 0x" << std::hex << hr << std::endl; + return false; + } + + return true; +} + +winrt::Windows::Graphics::DirectX::Direct3D11::IDirect3DDevice WgcSession::createWinRTDevice() { + ComPtr dxgiDevice; + HRESULT hr = d3dDevice_.As(&dxgiDevice); + if (FAILED(hr)) return nullptr; + + winrt::com_ptr inspectable; + hr = CreateDirect3D11DeviceFromDXGIDevice(dxgiDevice.Get(), inspectable.put()); + if (FAILED(hr)) return nullptr; + + return inspectable.as(); +} + +winrt::Windows::Graphics::Capture::GraphicsCaptureItem WgcSession::createCaptureItemForMonitor(HMONITOR monitor) { + auto factory = winrt::get_activation_factory< + winrt::Windows::Graphics::Capture::GraphicsCaptureItem>(); + + auto interop = factory.as(); + + winrt::Windows::Graphics::Capture::GraphicsCaptureItem item{nullptr}; + HRESULT hr = interop->CreateForMonitor( + monitor, + winrt::guid_of(), + winrt::put_abi(item)); + + if (FAILED(hr)) { + std::cerr << "ERROR: CreateForMonitor failed: 0x" << std::hex << hr << std::endl; + return nullptr; + } + + return item; +} + +winrt::Windows::Graphics::Capture::GraphicsCaptureItem WgcSession::createCaptureItemForWindow(HWND hwnd) { + auto factory = winrt::get_activation_factory< + winrt::Windows::Graphics::Capture::GraphicsCaptureItem>(); + + auto interop = factory.as(); + + winrt::Windows::Graphics::Capture::GraphicsCaptureItem item{nullptr}; + HRESULT hr = interop->CreateForWindow( + hwnd, + winrt::guid_of(), + winrt::put_abi(item)); + + if (FAILED(hr)) { + std::cerr << "ERROR: CreateForWindow failed: 0x" << std::hex << hr << std::endl; + return nullptr; + } + + return item; +} + +bool WgcSession::initializeWithItem(int fps) { + if (!captureItem_) return false; + + auto size = captureItem_.Size(); + captureWidth_ = size.Width; + captureHeight_ = size.Height; + framePoolWidth_ = size.Width; + framePoolHeight_ = size.Height; + + framePool_ = winrt::Windows::Graphics::Capture::Direct3D11CaptureFramePool::CreateFreeThreaded( + winrtDevice_, + winrt::Windows::Graphics::DirectX::DirectXPixelFormat::B8G8R8A8UIntNormalized, + 2, + size); + + session_ = framePool_.CreateCaptureSession(captureItem_); + + session_.IsCursorCaptureEnabled(false); + + // IsBorderRequired is only available on Windows 11+ (build 22000). propagating an hresult_error results in Native Windows capture failure + try { + session_.IsBorderRequired(false); + } catch (winrt::hresult_error const&) { + } + + return true; +} + +bool WgcSession::recreateFramePoolIfNeeded( + winrt::Windows::Graphics::SizeInt32 const& contentSize) { + if (!framePool_) return false; + + const int normalizedWidth = normalizeFramePoolExtent(contentSize.Width); + const int normalizedHeight = normalizeFramePoolExtent(contentSize.Height); + if (normalizedWidth == framePoolWidth_ && normalizedHeight == framePoolHeight_) { + return false; + } + + winrt::Windows::Graphics::SizeInt32 normalizedSize{ + normalizedWidth, + normalizedHeight, + }; + + try { + framePool_.Recreate( + winrtDevice_, + winrt::Windows::Graphics::DirectX::DirectXPixelFormat::B8G8R8A8UIntNormalized, + 2, + normalizedSize); + framePoolWidth_ = normalizedWidth; + framePoolHeight_ = normalizedHeight; + std::cerr << "INFO: Recreated WGC frame pool for resized content " + << framePoolWidth_ << "x" << framePoolHeight_ << std::endl; + } catch (winrt::hresult_error const& e) { + fatalError_ = true; + capturing_ = false; + std::cerr << "ERROR: Failed to recreate WGC frame pool after resize: 0x" + << std::hex << e.code() << std::dec << std::endl; + } + + return true; +} + +bool WgcSession::initialize(HMONITOR monitor, int fps) { + fps_ = fps; + frameIntervalHns_ = 10000000LL / fps_; + + if (!createD3DDevice()) return false; + + winrtDevice_ = createWinRTDevice(); + if (!winrtDevice_) { + std::cerr << "ERROR: Failed to create WinRT D3D device" << std::endl; + return false; + } + + captureItem_ = createCaptureItemForMonitor(monitor); + return initializeWithItem(fps); +} + +bool WgcSession::initialize(HWND hwnd, int fps) { + fps_ = fps; + frameIntervalHns_ = 10000000LL / fps_; + + if (!createD3DDevice()) return false; + + winrtDevice_ = createWinRTDevice(); + if (!winrtDevice_) { + std::cerr << "ERROR: Failed to create WinRT D3D device" << std::endl; + return false; + } + + captureItem_ = createCaptureItemForWindow(hwnd); + return initializeWithItem(fps); +} + +void WgcSession::setFrameCallback(FrameCallback callback) { + frameCallback_ = std::move(callback); +} + +bool WgcSession::startCapture() { + if (!session_ || !framePool_) return false; + + capturing_ = true; + fatalError_ = false; + lastFrameTimeHns_ = 0; + + frameArrivedRevoker_ = framePool_.FrameArrived( + winrt::auto_revoke, + [this](auto const& sender, auto const& args) { + onFrameArrived(sender, args); + }); + + session_.StartCapture(); + return true; +} + +void WgcSession::stopCapture() { + capturing_ = false; + + frameArrivedRevoker_.revoke(); + + if (session_) { + session_.Close(); + session_ = nullptr; + } + if (framePool_) { + framePool_.Close(); + framePool_ = nullptr; + } +} + +void WgcSession::onFrameArrived( + winrt::Windows::Graphics::Capture::Direct3D11CaptureFramePool const& sender, + winrt::Windows::Foundation::IInspectable const&) { + + if (!capturing_ || fatalError_) return; + + auto frame = sender.TryGetNextFrame(); + if (!frame) return; + auto contentSize = frame.ContentSize(); + if (recreateFramePoolIfNeeded(contentSize)) { + frame.Close(); + return; + } + + auto timestamp = frame.SystemRelativeTime(); + int64_t frameTimeHns = std::chrono::duration_cast>>(timestamp).count(); + + // Frame rate limiting: skip frames that arrive too soon + if (lastFrameTimeHns_ > 0 && (frameTimeHns - lastFrameTimeHns_) < (frameIntervalHns_ * 7 / 10)) { + frame.Close(); + return; + } + lastFrameTimeHns_ = frameTimeHns; + + auto surface = frame.Surface(); + + // Get the underlying D3D texture from the WinRT surface via COM interop + winrt::com_ptr access; + try { + access = surface.as(); + } catch (...) { + frame.Close(); + return; + } + ComPtr texture; + HRESULT hr = access->GetInterface(IID_PPV_ARGS(&texture)); + + if (SUCCEEDED(hr) && texture && frameCallback_) { + frameCallback_(texture.Get(), frameTimeHns); + } + + frame.Close(); +} diff --git a/electron/native/wgc-capture/src/wgc_session.h b/electron/native/wgc-capture/src/wgc_session.h new file mode 100644 index 000000000..1755f4b16 --- /dev/null +++ b/electron/native/wgc-capture/src/wgc_session.h @@ -0,0 +1,68 @@ +#pragma once + +#include +#include +#include +#include + +#include +#include +#include +#include + +#include +#include +#include + +using Microsoft::WRL::ComPtr; + +class WgcSession { +public: + using FrameCallback = std::function; + + WgcSession(); + ~WgcSession(); + + bool initialize(HMONITOR monitor, int fps); + bool initialize(HWND hwnd, int fps); + void setFrameCallback(FrameCallback callback); + bool startCapture(); + void stopCapture(); + bool hasFatalError() const { return fatalError_.load(); } + + int captureWidth() const { return captureWidth_; } + int captureHeight() const { return captureHeight_; } + ID3D11Device* device() const { return d3dDevice_.Get(); } + ID3D11DeviceContext* context() const { return d3dContext_.Get(); } + +private: + ComPtr d3dDevice_; + ComPtr d3dContext_; + winrt::Windows::Graphics::DirectX::Direct3D11::IDirect3DDevice winrtDevice_{nullptr}; + winrt::Windows::Graphics::Capture::GraphicsCaptureItem captureItem_{nullptr}; + winrt::Windows::Graphics::Capture::Direct3D11CaptureFramePool framePool_{nullptr}; + winrt::Windows::Graphics::Capture::GraphicsCaptureSession session_{nullptr}; + winrt::Windows::Graphics::Capture::Direct3D11CaptureFramePool::FrameArrived_revoker frameArrivedRevoker_; + + FrameCallback frameCallback_; + std::atomic capturing_{false}; + std::atomic fatalError_{false}; + int fps_ = 60; + int captureWidth_ = 0; + int captureHeight_ = 0; + int framePoolWidth_ = 0; + int framePoolHeight_ = 0; + int64_t frameIntervalHns_ = 0; + int64_t lastFrameTimeHns_ = 0; + + bool createD3DDevice(); + winrt::Windows::Graphics::DirectX::Direct3D11::IDirect3DDevice createWinRTDevice(); + winrt::Windows::Graphics::Capture::GraphicsCaptureItem createCaptureItemForMonitor(HMONITOR monitor); + winrt::Windows::Graphics::Capture::GraphicsCaptureItem createCaptureItemForWindow(HWND hwnd); + bool initializeWithItem(int fps); + bool recreateFramePoolIfNeeded( + winrt::Windows::Graphics::SizeInt32 const& contentSize); + void onFrameArrived( + winrt::Windows::Graphics::Capture::Direct3D11CaptureFramePool const& sender, + winrt::Windows::Foundation::IInspectable const& args); +}; diff --git a/electron/native/windows-capture/CMakeLists.txt b/electron/native/windows-capture/CMakeLists.txt new file mode 100644 index 000000000..773452c48 --- /dev/null +++ b/electron/native/windows-capture/CMakeLists.txt @@ -0,0 +1,27 @@ +cmake_minimum_required(VERSION 3.20) +project(windows-capture LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +add_executable(windows-capture + src/main.cpp + src/dxgi_session.cpp + src/mf_encoder.cpp + src/monitor_utils.cpp + src/wasapi_loopback.cpp +) + +target_compile_options(windows-capture PRIVATE /EHsc /W3 /utf-8) + +target_link_libraries(windows-capture PRIVATE + d3d11 + dxgi + dwmapi + mfplat + mfreadwrite + mf + mfuuid + ole32 + shcore +) diff --git a/electron/native/windows-capture/src/dxgi_session.cpp b/electron/native/windows-capture/src/dxgi_session.cpp new file mode 100644 index 000000000..76c914021 --- /dev/null +++ b/electron/native/windows-capture/src/dxgi_session.cpp @@ -0,0 +1,343 @@ +#include "dxgi_session.h" + +#include + +#include +#include +#include + +namespace { + +bool intersectRectChecked(const RECT& lhs, const RECT& rhs, RECT& result) { + return IntersectRect(&result, &lhs, &rhs) != FALSE; +} + +int evenFloor(int value) { + return value > 1 ? (value & ~1) : value; +} + +RECT getExtendedWindowBounds(HWND hwnd) { + RECT bounds{}; + if (SUCCEEDED(DwmGetWindowAttribute(hwnd, DWMWA_EXTENDED_FRAME_BOUNDS, &bounds, sizeof(bounds)))) { + return bounds; + } + + GetWindowRect(hwnd, &bounds); + return bounds; +} + +} // namespace + +DxgiSession::DxgiSession() {} + +DxgiSession::~DxgiSession() { + stopCapture(); +} + +bool DxgiSession::findOutputForMonitor(HMONITOR monitor, ComPtr& adapter, ComPtr& output) { + ComPtr factory; + HRESULT hr = CreateDXGIFactory1(IID_PPV_ARGS(&factory)); + if (FAILED(hr)) { + std::cerr << "ERROR: CreateDXGIFactory1 failed: 0x" << std::hex << hr << std::endl; + return false; + } + + for (UINT adapterIndex = 0;; ++adapterIndex) { + ComPtr candidateAdapter; + hr = factory->EnumAdapters1(adapterIndex, &candidateAdapter); + if (hr == DXGI_ERROR_NOT_FOUND) { + break; + } + if (FAILED(hr)) { + continue; + } + + for (UINT outputIndex = 0;; ++outputIndex) { + ComPtr candidateOutput; + hr = candidateAdapter->EnumOutputs(outputIndex, &candidateOutput); + if (hr == DXGI_ERROR_NOT_FOUND) { + break; + } + if (FAILED(hr)) { + continue; + } + + DXGI_OUTPUT_DESC desc{}; + if (FAILED(candidateOutput->GetDesc(&desc))) { + continue; + } + + if (!desc.AttachedToDesktop || desc.Monitor != monitor) { + continue; + } + + ComPtr output1; + hr = candidateOutput.As(&output1); + if (FAILED(hr)) { + continue; + } + + adapter = candidateAdapter; + output = output1; + outputDesc_ = desc; + return true; + } + } + + return false; +} + +bool DxgiSession::createD3DDevice(IDXGIAdapter1* adapter) { + UINT creationFlags = D3D11_CREATE_DEVICE_BGRA_SUPPORT; + D3D_FEATURE_LEVEL featureLevels[] = { + D3D_FEATURE_LEVEL_11_1, + D3D_FEATURE_LEVEL_11_0, + }; + + HRESULT hr = D3D11CreateDevice( + adapter, + D3D_DRIVER_TYPE_UNKNOWN, + nullptr, + creationFlags, + featureLevels, + ARRAYSIZE(featureLevels), + D3D11_SDK_VERSION, + &d3dDevice_, + nullptr, + &d3dContext_); + + if (FAILED(hr)) { + std::cerr << "ERROR: D3D11CreateDevice failed: 0x" << std::hex << hr << std::endl; + return false; + } + + return true; +} + +bool DxgiSession::createDuplication(IDXGIOutput1* output) { + HRESULT hr = output->DuplicateOutput(d3dDevice_.Get(), &duplication_); + if (FAILED(hr)) { + std::cerr << "ERROR: DuplicateOutput failed: 0x" << std::hex << hr << std::endl; + return false; + } + + return true; +} + +bool DxgiSession::createCaptureTexture() { + if (captureWidth_ <= 0 || captureHeight_ <= 0) { + return false; + } + + D3D11_TEXTURE2D_DESC desc{}; + desc.Width = static_cast(captureWidth_); + desc.Height = static_cast(captureHeight_); + desc.MipLevels = 1; + desc.ArraySize = 1; + desc.Format = DXGI_FORMAT_B8G8R8A8_UNORM; + desc.SampleDesc.Count = 1; + desc.Usage = D3D11_USAGE_DEFAULT; + + HRESULT hr = d3dDevice_->CreateTexture2D(&desc, nullptr, &captureTexture_); + if (FAILED(hr)) { + std::cerr << "ERROR: CreateTexture2D failed: 0x" << std::hex << hr << std::endl; + return false; + } + + return true; +} + +bool DxgiSession::initializeFullMonitorRect() { + const int width = outputDesc_.DesktopCoordinates.right - outputDesc_.DesktopCoordinates.left; + const int height = outputDesc_.DesktopCoordinates.bottom - outputDesc_.DesktopCoordinates.top; + + captureWidth_ = evenFloor(width); + captureHeight_ = evenFloor(height); + if (captureWidth_ <= 0 || captureHeight_ <= 0) { + return false; + } + + captureRect_.left = 0; + captureRect_.top = 0; + captureRect_.right = captureWidth_; + captureRect_.bottom = captureHeight_; + return true; +} + +bool DxgiSession::updateWindowCaptureRect() { + if (!captureWindow_ || !windowHandle_) { + return false; + } + + RECT windowRect = getExtendedWindowBounds(windowHandle_); + RECT monitorRect = outputDesc_.DesktopCoordinates; + RECT clippedRect{}; + if (!intersectRectChecked(windowRect, monitorRect, clippedRect)) { + return false; + } + + int left = clippedRect.left - monitorRect.left; + int top = clippedRect.top - monitorRect.top; + const int monitorWidth = monitorRect.right - monitorRect.left; + const int monitorHeight = monitorRect.bottom - monitorRect.top; + + left = std::clamp(left, 0, std::max(0, monitorWidth - captureWidth_)); + top = std::clamp(top, 0, std::max(0, monitorHeight - captureHeight_)); + + captureRect_.left = left; + captureRect_.top = top; + captureRect_.right = left + captureWidth_; + captureRect_.bottom = top + captureHeight_; + return true; +} + +bool DxgiSession::initializeWindowRect(HWND hwnd) { + windowHandle_ = hwnd; + captureWindow_ = true; + + RECT windowRect = getExtendedWindowBounds(hwnd); + RECT monitorRect = outputDesc_.DesktopCoordinates; + RECT clippedRect{}; + if (!intersectRectChecked(windowRect, monitorRect, clippedRect)) { + return false; + } + + captureWidth_ = evenFloor(clippedRect.right - clippedRect.left); + captureHeight_ = evenFloor(clippedRect.bottom - clippedRect.top); + if (captureWidth_ <= 0 || captureHeight_ <= 0) { + return false; + } + + return updateWindowCaptureRect(); +} + +bool DxgiSession::initializeForMonitor(HMONITOR monitor, int fps) { + fps_ = fps; + frameIntervalHns_ = 10000000LL / fps_; + + ComPtr adapter; + ComPtr output; + if (!findOutputForMonitor(monitor, adapter, output)) { + std::cerr << "ERROR: Failed to find DXGI output for monitor" << std::endl; + return false; + } + + if (!createD3DDevice(adapter.Get())) { + return false; + } + + if (!createDuplication(output.Get())) { + return false; + } + + return true; +} + +bool DxgiSession::initialize(HMONITOR monitor, int fps) { + captureWindow_ = false; + windowHandle_ = nullptr; + + if (!initializeForMonitor(monitor, fps)) { + return false; + } + + return initializeFullMonitorRect() && createCaptureTexture(); +} + +bool DxgiSession::initialize(HWND hwnd, int fps) { + HMONITOR monitor = MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST); + if (!monitor) { + std::cerr << "ERROR: MonitorFromWindow failed" << std::endl; + return false; + } + + if (!initializeForMonitor(monitor, fps)) { + return false; + } + + return initializeWindowRect(hwnd) && createCaptureTexture(); +} + +void DxgiSession::setFrameCallback(FrameCallback callback) { + frameCallback_ = std::move(callback); +} + +bool DxgiSession::startCapture() { + if (!duplication_ || !captureTexture_) { + return false; + } + + capturing_ = true; + lastFrameTimeHns_ = 0; + captureThread_ = std::thread(&DxgiSession::captureLoop, this); + return true; +} + +void DxgiSession::stopCapture() { + capturing_ = false; + + if (captureThread_.joinable()) { + captureThread_.join(); + } + + duplication_.Reset(); + captureTexture_.Reset(); +} + +int64_t DxgiSession::nowHns() const { + return std::chrono::duration_cast>>( + std::chrono::steady_clock::now().time_since_epoch()).count(); +} + +void DxgiSession::captureLoop() { + while (capturing_) { + DXGI_OUTDUPL_FRAME_INFO frameInfo{}; + ComPtr desktopResource; + HRESULT hr = duplication_->AcquireNextFrame(100, &frameInfo, &desktopResource); + + if (hr == DXGI_ERROR_WAIT_TIMEOUT) { + continue; + } + + if (FAILED(hr)) { + if (hr != DXGI_ERROR_ACCESS_LOST) { + std::cerr << "ERROR: AcquireNextFrame failed: 0x" << std::hex << hr << std::endl; + } + break; + } + + ComPtr sourceTexture; + hr = desktopResource.As(&sourceTexture); + if (SUCCEEDED(hr) && sourceTexture) { + if (!captureWindow_ || updateWindowCaptureRect()) { + const int64_t timestampHns = nowHns(); + if (lastFrameTimeHns_ == 0 || (timestampHns - lastFrameTimeHns_) >= (frameIntervalHns_ * 7 / 10)) { + D3D11_BOX sourceBox{}; + sourceBox.left = static_cast(captureRect_.left); + sourceBox.top = static_cast(captureRect_.top); + sourceBox.front = 0; + sourceBox.right = static_cast(captureRect_.right); + sourceBox.bottom = static_cast(captureRect_.bottom); + sourceBox.back = 1; + + d3dContext_->CopySubresourceRegion( + captureTexture_.Get(), + 0, + 0, + 0, + 0, + sourceTexture.Get(), + 0, + &sourceBox); + + if (frameCallback_) { + lastFrameTimeHns_ = timestampHns; + frameCallback_(captureTexture_.Get(), timestampHns); + } + } + } + } + + duplication_->ReleaseFrame(); + } +} \ No newline at end of file diff --git a/electron/native/windows-capture/src/dxgi_session.h b/electron/native/windows-capture/src/dxgi_session.h new file mode 100644 index 000000000..37cd04b71 --- /dev/null +++ b/electron/native/windows-capture/src/dxgi_session.h @@ -0,0 +1,62 @@ +#pragma once + +#include +#include +#include +#include + +#include +#include +#include + +using Microsoft::WRL::ComPtr; + +class DxgiSession { +public: + using FrameCallback = std::function; + + DxgiSession(); + ~DxgiSession(); + + bool initialize(HMONITOR monitor, int fps); + bool initialize(HWND hwnd, int fps); + void setFrameCallback(FrameCallback callback); + bool startCapture(); + void stopCapture(); + + int captureWidth() const { return captureWidth_; } + int captureHeight() const { return captureHeight_; } + ID3D11Device* device() const { return d3dDevice_.Get(); } + ID3D11DeviceContext* context() const { return d3dContext_.Get(); } + +private: + ComPtr d3dDevice_; + ComPtr d3dContext_; + ComPtr duplication_; + ComPtr captureTexture_; + + FrameCallback frameCallback_; + std::atomic capturing_{false}; + std::thread captureThread_; + + DXGI_OUTPUT_DESC outputDesc_{}; + RECT captureRect_{}; + HWND windowHandle_ = nullptr; + bool captureWindow_ = false; + int fps_ = 60; + int captureWidth_ = 0; + int captureHeight_ = 0; + int64_t frameIntervalHns_ = 0; + int64_t lastFrameTimeHns_ = 0; + + bool initializeForMonitor(HMONITOR monitor, int fps); + bool findOutputForMonitor(HMONITOR monitor, ComPtr& adapter, ComPtr& output); + bool createD3DDevice(IDXGIAdapter1* adapter); + bool createDuplication(IDXGIOutput1* output); + bool createCaptureTexture(); + bool initializeFullMonitorRect(); + bool initializeWindowRect(HWND hwnd); + bool updateWindowCaptureRect(); + void captureLoop(); + int64_t nowHns() const; +}; \ No newline at end of file diff --git a/electron/native/windows-capture/src/main.cpp b/electron/native/windows-capture/src/main.cpp new file mode 100644 index 000000000..2aac86a71 --- /dev/null +++ b/electron/native/windows-capture/src/main.cpp @@ -0,0 +1,324 @@ +#include "dxgi_session.h" +#include "mf_encoder.h" +#include "monitor_utils.h" +#include "wasapi_loopback.h" + +#include +#include +#include +#include +#include +#include +#include + +static std::atomic g_stopRequested{false}; +static std::atomic g_pauseRequested{false}; +static std::atomic g_resumePending{false}; +static std::atomic g_lastFrameTimestampHns{0}; +static std::atomic g_pauseStartTimestampHns{0}; +static std::atomic g_accumulatedPausedHns{0}; +static std::mutex g_stopMutex; +static std::condition_variable g_stopCv; + +struct CaptureConfig { + int64_t displayId = 0; + int64_t windowHandle = 0; + std::string outputPath; + std::string audioOutputPath; + std::string micOutputPath; + std::string micDeviceName; + int fps = 60; + int width = 0; + int height = 0; + bool captureSystemAudio = false; + bool captureMic = false; +}; + +static bool parseSimpleJson(const std::string& json, CaptureConfig& config) { + auto findInt = [&](const std::string& key) -> int { + auto pos = json.find("\"" + key + "\""); + if (pos == std::string::npos) return -1; + pos = json.find(':', pos); + if (pos == std::string::npos) return -1; + pos++; + while (pos < json.size() && (json[pos] == ' ' || json[pos] == '\t')) pos++; + try { + return std::stoi(json.substr(pos)); + } catch (...) { + return -1; + } + }; + + auto findInt64 = [&](const std::string& key) -> int64_t { + auto pos = json.find("\"" + key + "\""); + if (pos == std::string::npos) return -1; + pos = json.find(':', pos); + if (pos == std::string::npos) return -1; + pos++; + while (pos < json.size() && (json[pos] == ' ' || json[pos] == '\t')) pos++; + try { + return std::stoll(json.substr(pos)); + } catch (...) { + return -1; + } + }; + + auto findString = [&](const std::string& key) -> std::string { + auto pos = json.find("\"" + key + "\""); + if (pos == std::string::npos) return ""; + pos = json.find(':', pos); + if (pos == std::string::npos) return ""; + pos++; + while (pos < json.size() && (json[pos] == ' ' || json[pos] == '\t')) pos++; + if (pos >= json.size() || json[pos] != '"') return ""; + pos++; + std::string result; + while (pos < json.size() && json[pos] != '"') { + if (json[pos] == '\\' && pos + 1 < json.size()) { + pos++; + if (json[pos] == 'n') result += '\n'; + else if (json[pos] == 't') result += '\t'; + else if (json[pos] == '\\') result += '\\'; + else if (json[pos] == '"') result += '"'; + else if (json[pos] == '/') result += '/'; + else result += json[pos]; + } else { + result += json[pos]; + } + pos++; + } + return result; + }; + + config.outputPath = findString("outputPath"); + if (config.outputPath.empty()) return false; + + int64_t displayId = findInt64("displayId"); + if (displayId >= 0) config.displayId = displayId; + + int64_t windowHandle = findInt64("windowHandle"); + if (windowHandle > 0) config.windowHandle = windowHandle; + + int fps = findInt("fps"); + if (fps > 0) config.fps = fps; + + int width = findInt("width"); + if (width > 0) config.width = width; + + int height = findInt("height"); + if (height > 0) config.height = height; + + config.audioOutputPath = findString("audioOutputPath"); + config.micOutputPath = findString("micOutputPath"); + config.micDeviceName = findString("micDeviceName"); + + auto findBool = [&](const std::string& key) -> bool { + auto pos = json.find("\"" + key + "\""); + if (pos == std::string::npos) return false; + auto colonPos = json.find(':', pos); + if (colonPos == std::string::npos) return false; + auto valStart = json.find_first_not_of(" \t", colonPos + 1); + return valStart != std::string::npos && json.substr(valStart, 4) == "true"; + }; + + config.captureSystemAudio = findBool("captureSystemAudio"); + config.captureMic = findBool("captureMic"); + + return true; +} + +static std::wstring utf8ToWide(const std::string& str) { + if (str.empty()) return L""; + int len = MultiByteToWideChar(CP_UTF8, 0, str.c_str(), static_cast(str.size()), nullptr, 0); + std::wstring wstr(len, L'\0'); + MultiByteToWideChar(CP_UTF8, 0, str.c_str(), static_cast(str.size()), &wstr[0], len); + return wstr; +} + +static void stdinListenerThread() { + std::string line; + while (std::getline(std::cin, line)) { + // Trim whitespace + while (!line.empty() && (line.back() == '\r' || line.back() == '\n' || line.back() == ' ')) { + line.pop_back(); + } + + if (line == "pause") { + g_pauseRequested = true; + g_pauseStartTimestampHns = g_lastFrameTimestampHns.load(); + continue; + } + + if (line == "resume") { + g_pauseRequested = false; + g_resumePending = true; + continue; + } + + if (line == "stop") { + g_stopRequested = true; + g_stopCv.notify_all(); + return; + } + } + + // stdin closed (parent process died) + g_stopRequested = true; + g_stopCv.notify_all(); +} + +int main(int argc, char* argv[]) { + if (argc < 2) { + std::cerr << "ERROR: Missing JSON config argument" << std::endl; + return 1; + } + + CaptureConfig config; + if (!parseSimpleJson(argv[1], config)) { + std::cerr << "ERROR: Failed to parse config JSON" << std::endl; + return 1; + } + + DxgiSession session; + + if (config.windowHandle > 0) { + HWND hwnd = reinterpret_cast(static_cast(config.windowHandle)); + if (!IsWindow(hwnd)) { + std::cerr << "ERROR: Invalid window handle " << config.windowHandle << std::endl; + return 1; + } + if (!session.initialize(hwnd, config.fps)) { + std::cerr << "ERROR: Failed to initialize DXGI window capture session" << std::endl; + return 1; + } + } else { + HMONITOR monitor = findMonitorByDisplayId(config.displayId); + if (!monitor) { + std::cerr << "ERROR: Could not find monitor for displayId " << config.displayId << std::endl; + return 1; + } + if (!session.initialize(monitor, config.fps)) { + std::cerr << "ERROR: Failed to initialize DXGI capture session" << std::endl; + return 1; + } + } + + int captureWidth = config.width > 0 ? config.width : session.captureWidth(); + int captureHeight = config.height > 0 ? config.height : session.captureHeight(); + + // Ensure even dimensions for H.264 + captureWidth = (captureWidth / 2) * 2; + captureHeight = (captureHeight / 2) * 2; + + // Initialize encoder + MFEncoder encoder; + std::wstring outputPathW = utf8ToWide(config.outputPath); + if (!encoder.initialize(outputPathW, captureWidth, captureHeight, config.fps, + session.device(), session.context())) { + std::cerr << "ERROR: Failed to initialize Media Foundation encoder" << std::endl; + return 1; + } + + // Set up frame callback + std::atomic frameCount{0}; + session.setFrameCallback([&](ID3D11Texture2D* texture, int64_t timestampHns) { + g_lastFrameTimestampHns = timestampHns; + if (g_stopRequested) return; + + if (g_pauseRequested) return; + + int64_t adjustedTimestampHns = timestampHns; + if (g_resumePending.exchange(false)) { + const int64_t pauseStart = g_pauseStartTimestampHns.load(); + if (pauseStart > 0 && timestampHns > pauseStart) { + g_accumulatedPausedHns += (timestampHns - pauseStart); + } + } + + adjustedTimestampHns -= g_accumulatedPausedHns.load(); + if (adjustedTimestampHns < 0) { + adjustedTimestampHns = 0; + } + + if (encoder.writeFrame(texture, adjustedTimestampHns)) { + frameCount++; + } + }); + + // Start stdin listener + std::thread stdinThread(stdinListenerThread); + stdinThread.detach(); + + // Initialize WASAPI captures (but don't start yet) + WasapiCapture loopback; + WasapiCapture micCapture; + bool audioActive = false; + bool audioInitialized = false; + bool micActive = false; + bool micInitialized = false; + + if (config.captureSystemAudio && !config.audioOutputPath.empty()) { + audioInitialized = loopback.initializeLoopback(config.audioOutputPath); + if (!audioInitialized) { + std::cerr << "WARNING: Failed to initialize WASAPI loopback" << std::endl; + } + } + + if (config.captureMic && !config.micOutputPath.empty()) { + micInitialized = micCapture.initializeMic(config.micOutputPath, config.micDeviceName); + if (!micInitialized) { + std::cerr << "WARNING: Failed to initialize WASAPI mic capture" << std::endl; + } + } + + // Start video capture, then audio immediately after for sync + if (!session.startCapture()) { + std::cerr << "ERROR: Failed to start DXGI capture" << std::endl; + return 1; + } + + if (audioInitialized) { + audioActive = loopback.start(); + } + if (micInitialized) { + micActive = micCapture.start(); + } + + std::cout << "Recording started" << std::endl; + std::cout.flush(); + + // Wait for stop signal + while (!g_stopRequested) { + if (g_pauseRequested) { + if (audioActive) loopback.pause(); + if (micActive) micCapture.pause(); + } else { + if (audioActive) loopback.resume(); + if (micActive) micCapture.resume(); + } + + std::unique_lock lock(g_stopMutex); + g_stopCv.wait_for(lock, std::chrono::milliseconds(20), [] { return g_stopRequested.load(); }); + } + + // Stop capture and finalize + session.stopCapture(); + if (audioActive) loopback.stop(); + if (micActive) micCapture.stop(); + encoder.finalize(); + + std::cout << "Recording stopped. Output path: " << config.outputPath << std::endl; + if (audioActive) { + std::cout << "Audio path: " << config.audioOutputPath << std::endl; + } + if (micActive) { + std::cout << "Mic path: " << config.micOutputPath << std::endl; + } + std::cout.flush(); + + // Allow pipe buffers to drain before forceful exit + Sleep(100); + + // Fast exit to avoid WinRT/COM teardown crashes during apartment cleanup + ExitProcess(0); +} diff --git a/electron/native/windows-capture/src/mf_encoder.cpp b/electron/native/windows-capture/src/mf_encoder.cpp new file mode 100644 index 000000000..bfd62f33b --- /dev/null +++ b/electron/native/windows-capture/src/mf_encoder.cpp @@ -0,0 +1,182 @@ +#include "mf_encoder.h" +#include +#include +#include +#include +#include +#include "../../common/bt709_video.h" + +#pragma comment(lib, "mfplat.lib") +#pragma comment(lib, "mfreadwrite.lib") +#pragma comment(lib, "mf.lib") +#pragma comment(lib, "mfuuid.lib") + +MFEncoder::MFEncoder() {} + +MFEncoder::~MFEncoder() { + finalize(); +} + +bool MFEncoder::initialize(const std::wstring& outputPath, int width, int height, int fps, + ID3D11Device* device, ID3D11DeviceContext* context) { + if (initialized_) return false; + + if (width % 2 != 0 || height % 2 != 0) { + std::cerr << "ERROR: Encoder dimensions must be even, got " << width << "x" << height << std::endl; + return false; + } + + width_ = width; + height_ = height; + fps_ = fps; + device_ = device; + context_ = context; + + HRESULT hr = MFStartup(MF_VERSION); + if (FAILED(hr)) { + std::cerr << "ERROR: MFStartup failed: 0x" << std::hex << hr << std::endl; + return false; + } + + // Output media type (H.264) + ComPtr outputType; + hr = MFCreateMediaType(&outputType); + if (FAILED(hr)) return false; + + outputType->SetGUID(MF_MT_MAJOR_TYPE, MFMediaType_Video); + outputType->SetGUID(MF_MT_SUBTYPE, MFVideoFormat_H264); + outputType->SetUINT32(MF_MT_AVG_BITRATE, 20000000); + MFSetAttributeSize(outputType.Get(), MF_MT_FRAME_SIZE, width_, height_); + MFSetAttributeRatio(outputType.Get(), MF_MT_FRAME_RATE, fps_, 1); + MFSetAttributeRatio(outputType.Get(), MF_MT_PIXEL_ASPECT_RATIO, 1, 1); + outputType->SetUINT32(MF_MT_INTERLACE_MODE, MFVideoInterlace_Progressive); + hr = setBt709LimitedVideoAttributes(outputType.Get()); + if (FAILED(hr)) return false; + + // Input media type (NV12) + ComPtr inputType; + hr = MFCreateMediaType(&inputType); + if (FAILED(hr)) return false; + + inputType->SetGUID(MF_MT_MAJOR_TYPE, MFMediaType_Video); + inputType->SetGUID(MF_MT_SUBTYPE, MFVideoFormat_NV12); + MFSetAttributeSize(inputType.Get(), MF_MT_FRAME_SIZE, width_, height_); + MFSetAttributeRatio(inputType.Get(), MF_MT_FRAME_RATE, fps_, 1); + MFSetAttributeRatio(inputType.Get(), MF_MT_PIXEL_ASPECT_RATIO, 1, 1); + inputType->SetUINT32(MF_MT_INTERLACE_MODE, MFVideoInterlace_Progressive); + hr = setBt709LimitedVideoAttributes(inputType.Get()); + if (FAILED(hr)) return false; + + // Create SinkWriter with MPEG4 container + ComPtr writerAttrs; + hr = MFCreateAttributes(&writerAttrs, 1); + if (FAILED(hr)) return false; + + writerAttrs->SetUINT32(MF_READWRITE_ENABLE_HARDWARE_TRANSFORMS, TRUE); + + hr = MFCreateSinkWriterFromURL(outputPath.c_str(), nullptr, writerAttrs.Get(), &sinkWriter_); + if (FAILED(hr)) { + std::cerr << "ERROR: MFCreateSinkWriterFromURL failed: 0x" << std::hex << hr << std::endl; + return false; + } + + hr = sinkWriter_->AddStream(outputType.Get(), &streamIndex_); + if (FAILED(hr)) { + std::cerr << "ERROR: AddStream failed: 0x" << std::hex << hr << std::endl; + return false; + } + + hr = sinkWriter_->SetInputMediaType(streamIndex_, inputType.Get(), nullptr); + if (FAILED(hr)) { + std::cerr << "ERROR: SetInputMediaType failed: 0x" << std::hex << hr << std::endl; + return false; + } + + hr = sinkWriter_->BeginWriting(); + if (FAILED(hr)) { + std::cerr << "ERROR: BeginWriting failed: 0x" << std::hex << hr << std::endl; + return false; + } + + // Pre-allocate staging texture + D3D11_TEXTURE2D_DESC stagingDesc = {}; + stagingDesc.Width = width_; + stagingDesc.Height = height_; + stagingDesc.MipLevels = 1; + stagingDesc.ArraySize = 1; + stagingDesc.Format = DXGI_FORMAT_B8G8R8A8_UNORM; + stagingDesc.SampleDesc.Count = 1; + stagingDesc.Usage = D3D11_USAGE_STAGING; + stagingDesc.CPUAccessFlags = D3D11_CPU_ACCESS_READ; + + hr = device_->CreateTexture2D(&stagingDesc, nullptr, &stagingTexture_); + if (FAILED(hr)) { + std::cerr << "ERROR: Failed to create staging texture: 0x" << std::hex << hr << std::endl; + return false; + } + + // Pre-allocate NV12 buffer + const int ySize = width_ * height_; + const int uvSize = (width_ / 2) * (height_ / 2) * 2; + nv12Buffer_.resize(ySize + uvSize); + + initialized_ = true; + return true; +} + +bool MFEncoder::writeFrame(ID3D11Texture2D* texture, int64_t timestampHns) { + if (!initialized_ || !sinkWriter_) return false; + + context_->CopyResource(stagingTexture_.Get(), texture); + + D3D11_MAPPED_SUBRESOURCE mapped; + HRESULT hr = context_->Map(stagingTexture_.Get(), 0, D3D11_MAP_READ, 0, &mapped); + if (FAILED(hr)) return false; + + // Convert full-range desktop BGRA to explicitly tagged BT.709 video-range NV12. + const uint8_t* bgra = static_cast(mapped.pData); + const int bgraPitch = static_cast(mapped.RowPitch); + convertBgraToBt709LimitedNv12(bgra, bgraPitch, width_, height_, nv12Buffer_); + + context_->Unmap(stagingTexture_.Get(), 0); + + // Create MF sample + DWORD bufferSize = static_cast(nv12Buffer_.size()); + ComPtr buffer; + hr = MFCreateMemoryBuffer(bufferSize, &buffer); + if (FAILED(hr)) return false; + + BYTE* bufferData = nullptr; + hr = buffer->Lock(&bufferData, nullptr, nullptr); + if (FAILED(hr)) return false; + + std::memcpy(bufferData, nv12Buffer_.data(), bufferSize); + buffer->Unlock(); + buffer->SetCurrentLength(bufferSize); + + ComPtr sample; + hr = MFCreateSample(&sample); + if (FAILED(hr)) return false; + + sample->AddBuffer(buffer.Get()); + sample->SetSampleTime(timestampHns); + sample->SetSampleDuration(10000000LL / fps_); + + hr = sinkWriter_->WriteSample(streamIndex_, sample.Get()); + return SUCCEEDED(hr); +} + +bool MFEncoder::finalize() { + if (!initialized_) return false; + initialized_ = false; + + stagingTexture_.Reset(); + nv12Buffer_.clear(); + nv12Buffer_.shrink_to_fit(); + + if (!sinkWriter_) return false; + HRESULT hr = sinkWriter_->Finalize(); + sinkWriter_.Reset(); + MFShutdown(); + return SUCCEEDED(hr); +} diff --git a/electron/native/windows-capture/src/mf_encoder.h b/electron/native/windows-capture/src/mf_encoder.h new file mode 100644 index 000000000..7eb8e6420 --- /dev/null +++ b/electron/native/windows-capture/src/mf_encoder.h @@ -0,0 +1,35 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +using Microsoft::WRL::ComPtr; + +class MFEncoder { +public: + MFEncoder(); + ~MFEncoder(); + + bool initialize(const std::wstring& outputPath, int width, int height, int fps, + ID3D11Device* device, ID3D11DeviceContext* context); + bool writeFrame(ID3D11Texture2D* texture, int64_t timestampHns); + bool finalize(); + +private: + ComPtr sinkWriter_; + ID3D11Device* device_ = nullptr; + ID3D11DeviceContext* context_ = nullptr; + ComPtr stagingTexture_; + std::vector nv12Buffer_; + DWORD streamIndex_ = 0; + int width_ = 0; + int height_ = 0; + int fps_ = 60; + bool initialized_ = false; +}; diff --git a/electron/native/windows-capture/src/monitor_utils.cpp b/electron/native/windows-capture/src/monitor_utils.cpp new file mode 100644 index 000000000..56962aeb7 --- /dev/null +++ b/electron/native/windows-capture/src/monitor_utils.cpp @@ -0,0 +1,61 @@ +#include "monitor_utils.h" +#include + +static BOOL CALLBACK enumMonitorCallback(HMONITOR hMonitor, HDC, LPRECT, LPARAM lParam) { + auto* monitors = reinterpret_cast*>(lParam); + + MONITORINFOEXW mi = {}; + mi.cbSize = sizeof(mi); + if (GetMonitorInfoW(hMonitor, &mi)) { + MonitorInfo info; + info.handle = hMonitor; + info.x = mi.rcMonitor.left; + info.y = mi.rcMonitor.top; + info.width = mi.rcMonitor.right - mi.rcMonitor.left; + info.height = mi.rcMonitor.bottom - mi.rcMonitor.top; + info.deviceName = mi.szDevice; + monitors->push_back(info); + } + + return TRUE; +} + +std::vector enumerateMonitors() { + std::vector monitors; + EnumDisplayMonitors(nullptr, nullptr, enumMonitorCallback, reinterpret_cast(&monitors)); + return monitors; +} + +// Electron uses the HMONITOR handle value cast to a number as the display ID. +HMONITOR findMonitorByDisplayId(int64_t displayId) { + auto monitors = enumerateMonitors(); + + for (const auto& m : monitors) { + if (static_cast(reinterpret_cast(m.handle)) == displayId) { + return m.handle; + } + } + + if (!monitors.empty()) { + return monitors[0].handle; + } + + return MonitorFromPoint({0, 0}, MONITOR_DEFAULTTOPRIMARY); +} + +MonitorInfo getMonitorInfo(HMONITOR monitor) { + MonitorInfo info; + info.handle = monitor; + + MONITORINFOEXW mi = {}; + mi.cbSize = sizeof(mi); + if (GetMonitorInfoW(monitor, &mi)) { + info.x = mi.rcMonitor.left; + info.y = mi.rcMonitor.top; + info.width = mi.rcMonitor.right - mi.rcMonitor.left; + info.height = mi.rcMonitor.bottom - mi.rcMonitor.top; + info.deviceName = mi.szDevice; + } + + return info; +} diff --git a/electron/native/windows-capture/src/monitor_utils.h b/electron/native/windows-capture/src/monitor_utils.h new file mode 100644 index 000000000..8889410dd --- /dev/null +++ b/electron/native/windows-capture/src/monitor_utils.h @@ -0,0 +1,19 @@ +#pragma once + +#include +#include +#include +#include + +struct MonitorInfo { + HMONITOR handle; + int x; + int y; + int width; + int height; + std::wstring deviceName; +}; + +std::vector enumerateMonitors(); +HMONITOR findMonitorByDisplayId(int64_t displayId); +MonitorInfo getMonitorInfo(HMONITOR monitor); diff --git a/electron/native/windows-capture/src/wasapi_loopback.cpp b/electron/native/windows-capture/src/wasapi_loopback.cpp new file mode 100644 index 000000000..8ae15ad1f --- /dev/null +++ b/electron/native/windows-capture/src/wasapi_loopback.cpp @@ -0,0 +1,274 @@ +#include "wasapi_loopback.h" +#include +#include +#include + +#pragma comment(lib, "ole32.lib") + +static const CLSID CLSID_MMDeviceEnumerator_ = __uuidof(MMDeviceEnumerator); +static const IID IID_IMMDeviceEnumerator_ = __uuidof(IMMDeviceEnumerator); +static const IID IID_IAudioClient_ = __uuidof(IAudioClient); +static const IID IID_IAudioCaptureClient_ = __uuidof(IAudioCaptureClient); + +WasapiCapture::WasapiCapture() {} + +WasapiCapture::~WasapiCapture() { + stop(); + if (mixFormat_) CoTaskMemFree(mixFormat_); + if (captureClient_) captureClient_->Release(); + if (audioClient_) audioClient_->Release(); + if (device_) device_->Release(); + if (enumerator_) enumerator_->Release(); +} + +static std::wstring utf8ToWide(const std::string& str) { + if (str.empty()) return L""; + int len = MultiByteToWideChar(CP_UTF8, 0, str.c_str(), static_cast(str.size()), nullptr, 0); + std::wstring wstr(len, L'\0'); + MultiByteToWideChar(CP_UTF8, 0, str.c_str(), static_cast(str.size()), &wstr[0], len); + return wstr; +} + +IMMDevice* WasapiCapture::findCaptureDeviceByName(const std::wstring& targetName) { + IMMDeviceCollection* collection = nullptr; + HRESULT hr = enumerator_->EnumAudioEndpoints(eCapture, DEVICE_STATE_ACTIVE, &collection); + if (FAILED(hr)) return nullptr; + + UINT count = 0; + collection->GetCount(&count); + + for (UINT i = 0; i < count; i++) { + IMMDevice* dev = nullptr; + collection->Item(i, &dev); + + IPropertyStore* store = nullptr; + dev->OpenPropertyStore(STGM_READ, &store); + PROPVARIANT pv; + PropVariantInit(&pv); + store->GetValue(PKEY_Device_FriendlyName, &pv); + std::wstring name = pv.pwszVal ? pv.pwszVal : L""; + PropVariantClear(&pv); + store->Release(); + + if (name.find(targetName) != std::wstring::npos || targetName.find(name) != std::wstring::npos) { + collection->Release(); + return dev; + } + dev->Release(); + } + + collection->Release(); + return nullptr; +} + +bool WasapiCapture::initializeLoopback(const std::string& outputPath) { + outputPath_ = outputPath; + streamFlags_ = AUDCLNT_STREAMFLAGS_LOOPBACK; + + HRESULT hr = CoCreateInstance( + CLSID_MMDeviceEnumerator_, nullptr, CLSCTX_ALL, + IID_IMMDeviceEnumerator_, reinterpret_cast(&enumerator_)); + if (FAILED(hr)) return false; + + hr = enumerator_->GetDefaultAudioEndpoint(eRender, eConsole, &device_); + if (FAILED(hr)) return false; + + return initializeCommon(); +} + +bool WasapiCapture::initializeMic(const std::string& outputPath, const std::string& deviceName) { + outputPath_ = outputPath; + streamFlags_ = 0; + + HRESULT hr = CoCreateInstance( + CLSID_MMDeviceEnumerator_, nullptr, CLSCTX_ALL, + IID_IMMDeviceEnumerator_, reinterpret_cast(&enumerator_)); + if (FAILED(hr)) return false; + + if (!deviceName.empty()) { + device_ = findCaptureDeviceByName(utf8ToWide(deviceName)); + } + if (!device_) { + hr = enumerator_->GetDefaultAudioEndpoint(eCapture, eCommunications, &device_); + if (FAILED(hr)) { + hr = enumerator_->GetDefaultAudioEndpoint(eCapture, eConsole, &device_); + if (FAILED(hr)) return false; + } + } + + return initializeCommon(); +} + +bool WasapiCapture::initializeCommon() { + HRESULT hr = device_->Activate(IID_IAudioClient_, CLSCTX_ALL, nullptr, + reinterpret_cast(&audioClient_)); + if (FAILED(hr)) return false; + + hr = audioClient_->GetMixFormat(&mixFormat_); + if (FAILED(hr)) return false; + + REFERENCE_TIME bufferDuration = 200000; // 20ms + hr = audioClient_->Initialize( + AUDCLNT_SHAREMODE_SHARED, + streamFlags_, + bufferDuration, 0, mixFormat_, nullptr); + if (FAILED(hr)) return false; + + hr = audioClient_->GetBufferSize(&bufferFrameCount_); + if (FAILED(hr)) return false; + + hr = audioClient_->GetService(IID_IAudioCaptureClient_, + reinterpret_cast(&captureClient_)); + if (FAILED(hr)) return false; + + return true; +} + +bool WasapiCapture::start() { + if (capturing_) return true; + + outputFile_ = CreateFileA( + outputPath_.c_str(), GENERIC_WRITE, 0, nullptr, + CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr); + + if (outputFile_ == INVALID_HANDLE_VALUE) { + std::cerr << "ERROR: Cannot create audio output file" << std::endl; + return false; + } + + totalDataBytes_ = 0; + writeWavHeader(outputFile_, 0); + + HRESULT hr = audioClient_->Start(); + if (FAILED(hr)) { + CloseHandle(outputFile_); + outputFile_ = INVALID_HANDLE_VALUE; + return false; + } + + capturing_ = true; + paused_ = false; + thread_ = std::thread(&WasapiCapture::captureThread, this); + return true; +} + +bool WasapiCapture::pause() { + if (!capturing_ || paused_) return true; + paused_ = true; + return audioClient_ ? SUCCEEDED(audioClient_->Stop()) : false; +} + +bool WasapiCapture::resume() { + if (!capturing_ || !paused_) return true; + HRESULT hr = audioClient_ ? audioClient_->Start() : E_FAIL; + if (FAILED(hr)) return false; + paused_ = false; + return true; +} + +void WasapiCapture::stop() { + if (!capturing_) return; + capturing_ = false; + if (thread_.joinable()) thread_.join(); + if (audioClient_) audioClient_->Stop(); + + if (outputFile_ != INVALID_HANDLE_VALUE) { + SetFilePointer(outputFile_, 0, nullptr, FILE_BEGIN); + writeWavHeader(outputFile_, totalDataBytes_); + CloseHandle(outputFile_); + outputFile_ = INVALID_HANDLE_VALUE; + } + + paused_ = false; +} + +static int16_t floatToInt16(float v) { + v = v < -1.0f ? -1.0f : (v > 1.0f ? 1.0f : v); + return static_cast(v * 32767.0f); +} + +bool WasapiCapture::writeWavHeader(HANDLE file, DWORD dataSize) { + WORD channels = static_cast(mixFormat_->nChannels); + DWORD sampleRate = mixFormat_->nSamplesPerSec; + WORD bitsPerSample = 16; + WORD blockAlign = channels * (bitsPerSample / 8); + DWORD byteRate = sampleRate * blockAlign; + + DWORD written; + WriteFile(file, "RIFF", 4, &written, nullptr); + DWORD chunkSize = 36 + dataSize; + WriteFile(file, &chunkSize, 4, &written, nullptr); + WriteFile(file, "WAVE", 4, &written, nullptr); + WriteFile(file, "fmt ", 4, &written, nullptr); + DWORD fmtSize = 16; + WriteFile(file, &fmtSize, 4, &written, nullptr); + WORD audioFormat = 1; + WriteFile(file, &audioFormat, 2, &written, nullptr); + WriteFile(file, &channels, 2, &written, nullptr); + WriteFile(file, &sampleRate, 4, &written, nullptr); + WriteFile(file, &byteRate, 4, &written, nullptr); + WriteFile(file, &blockAlign, 2, &written, nullptr); + WriteFile(file, &bitsPerSample, 2, &written, nullptr); + WriteFile(file, "data", 4, &written, nullptr); + WriteFile(file, &dataSize, 4, &written, nullptr); + return true; +} + +void WasapiCapture::captureThread() { + WORD channels = static_cast(mixFormat_->nChannels); + bool isFloat = (mixFormat_->wFormatTag == WAVE_FORMAT_IEEE_FLOAT) || + (mixFormat_->wFormatTag == WAVE_FORMAT_EXTENSIBLE && + reinterpret_cast(mixFormat_)->SubFormat == KSDATAFORMAT_SUBTYPE_IEEE_FLOAT); + + std::vector pcmBuffer; + + DWORD sleepMs = static_cast((static_cast(bufferFrameCount_) / mixFormat_->nSamplesPerSec) * 500.0); + if (sleepMs < 5) sleepMs = 5; + + while (capturing_) { + if (paused_) { + Sleep(10); + continue; + } + + Sleep(sleepMs); + + UINT32 packetLength = 0; + HRESULT hr = captureClient_->GetNextPacketSize(&packetLength); + if (FAILED(hr)) break; + + while (packetLength > 0) { + BYTE* data = nullptr; + UINT32 numFrames = 0; + DWORD flags = 0; + + hr = captureClient_->GetBuffer(&data, &numFrames, &flags, nullptr, nullptr); + if (FAILED(hr)) break; + + UINT32 totalSamples = numFrames * channels; + + if (flags & AUDCLNT_BUFFERFLAGS_SILENT) { + pcmBuffer.assign(totalSamples, 0); + } else if (isFloat) { + pcmBuffer.resize(totalSamples); + const float* src = reinterpret_cast(data); + for (UINT32 i = 0; i < totalSamples; i++) { + pcmBuffer[i] = floatToInt16(src[i]); + } + } else { + pcmBuffer.resize(totalSamples); + std::memcpy(pcmBuffer.data(), data, totalSamples * sizeof(int16_t)); + } + + captureClient_->ReleaseBuffer(numFrames); + + DWORD bytesToWrite = totalSamples * sizeof(int16_t); + DWORD written; + WriteFile(outputFile_, pcmBuffer.data(), bytesToWrite, &written, nullptr); + totalDataBytes_ += written; + + hr = captureClient_->GetNextPacketSize(&packetLength); + if (FAILED(hr)) break; + } + } +} diff --git a/electron/native/windows-capture/src/wasapi_loopback.h b/electron/native/windows-capture/src/wasapi_loopback.h new file mode 100644 index 000000000..a4facf139 --- /dev/null +++ b/electron/native/windows-capture/src/wasapi_loopback.h @@ -0,0 +1,44 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +class WasapiCapture { +public: + WasapiCapture(); + ~WasapiCapture(); + + bool initializeLoopback(const std::string& outputPath); + bool initializeMic(const std::string& outputPath, const std::string& deviceName = ""); + bool start(); + bool pause(); + bool resume(); + void stop(); + +private: + bool initializeCommon(); + void captureThread(); + bool writeWavHeader(HANDLE file, DWORD dataSize); + IMMDevice* findCaptureDeviceByName(const std::wstring& name); + + std::string outputPath_; + std::thread thread_; + std::atomic capturing_{false}; + std::atomic paused_{false}; + HANDLE outputFile_ = INVALID_HANDLE_VALUE; + DWORD totalDataBytes_ = 0; + + IMMDeviceEnumerator* enumerator_ = nullptr; + IMMDevice* device_ = nullptr; + IAudioClient* audioClient_ = nullptr; + IAudioCaptureClient* captureClient_ = nullptr; + WAVEFORMATEX* mixFormat_ = nullptr; + DWORD streamFlags_ = 0; + + UINT32 bufferFrameCount_ = 0; +}; diff --git a/electron/navigationPolicy.test.ts b/electron/navigationPolicy.test.ts new file mode 100644 index 000000000..5c23c7375 --- /dev/null +++ b/electron/navigationPolicy.test.ts @@ -0,0 +1,269 @@ +import { describe, expect, it, vi } from "vitest"; +import { + createWillNavigateHandler, + createWillRedirectHandler, + createWindowOpenHandler, + hardenWebContentsNavigation, + isInternalRendererTarget, + normalizeExternalHttpUrl, + shouldHardenWebContentsType, +} from "./navigationPolicy"; + +describe("normalizeExternalHttpUrl", () => { + it.each([ + ["https://example.com/docs", "https://example.com/docs"], + ["http://127.0.0.1:3000/path?q=1", "http://127.0.0.1:3000/path?q=1"], + ["HTTPS://Example.COM:443/docs", "https://example.com/docs"], + ])("normalizes an external HTTP(S) URL: %s", (value, expected) => { + expect(normalizeExternalHttpUrl(value)).toBe(expected); + }); + + it.each([ + "", + "not a URL", + "file:///tmp/recordly.html", + "data:text/html,hello", + "javascript:alert(1)", + "mailto:security@example.com", + "https://user:password@example.com/", + ])("rejects an unsafe external URL: %s", (value) => { + expect(normalizeExternalHttpUrl(value)).toBeNull(); + }); +}); + +describe("shouldHardenWebContentsType", () => { + it("selects BrowserWindow contents only", () => { + expect(shouldHardenWebContentsType("window")).toBe(true); + expect(shouldHardenWebContentsType("webview")).toBe(false); + expect(shouldHardenWebContentsType("offscreen")).toBe(false); + }); +}); + +describe("isInternalRendererTarget", () => { + it.each([ + ["http://localhost:5173/?windowType=editor", "http://localhost:5173/editor?reload=1"], + ["http://127.0.0.1:43123/?windowType=editor", "http://127.0.0.1:43123/assets/index.js"], + [ + "file:///opt/Recordly/dist/index.html?windowType=editor", + "file:///opt/Recordly/dist/index.html?windowType=hud-overlay#status", + ], + ])("identifies the current renderer origin/file", (currentUrl, targetUrl) => { + expect(isInternalRendererTarget(currentUrl, targetUrl)).toBe(true); + }); + + it.each([ + ["http://localhost:5173/?windowType=editor", "http://localhost.example.com:5173/"], + ["http://localhost:5173/", "http://localhost:5174/"], + ["https://recordly.example/", "http://recordly.example/"], + ["https://recordly.example/", "https://user:pass@recordly.example/"], + ["file:///opt/Recordly/dist/index.html", "file:///etc/passwd"], + ["file:///opt/Recordly/dist/index.html", "data:text/html,hello"], + ["not a URL", "https://example.com/"], + ])("distinguishes a target outside the current renderer", (currentUrl, targetUrl) => { + expect(isInternalRendererTarget(currentUrl, targetUrl)).toBe(false); + }); +}); + +describe("navigation event handlers", () => { + it("preserves an exact renderer reload", () => { + const preventDefault = vi.fn(); + const openExternal = vi.fn(async () => undefined); + const handler = createWillNavigateHandler( + () => "http://localhost:5173/editor?windowType=editor", + openExternal, + ); + + handler({ + url: "http://localhost:5173/editor?windowType=editor", + preventDefault, + }); + + expect(preventDefault).not.toHaveBeenCalled(); + expect(openExternal).not.toHaveBeenCalled(); + }); + + it("stops same-origin renderer navigation without externalizing it", () => { + const preventDefault = vi.fn(); + const openExternal = vi.fn(async () => undefined); + const handler = createWillNavigateHandler( + () => "http://localhost:5173/editor", + openExternal, + ); + + handler({ url: "http://localhost:5173/settings", preventDefault }); + + expect(preventDefault).toHaveBeenCalledOnce(); + expect(openExternal).not.toHaveBeenCalled(); + }); + + it("stops same-file query mutation without externalizing it", () => { + const preventDefault = vi.fn(); + const openExternal = vi.fn(async () => undefined); + const handler = createWillNavigateHandler( + () => "file:///opt/Recordly/dist/index.html?windowType=editor", + openExternal, + ); + + handler({ + url: "file:///opt/Recordly/dist/index.html?smokeExport=1", + preventDefault, + }); + + expect(preventDefault).toHaveBeenCalledOnce(); + expect(openExternal).not.toHaveBeenCalled(); + }); + + it("stops cross-origin navigation and opens HTTP(S) in the system browser", () => { + const preventDefault = vi.fn(); + const openExternal = vi.fn(async () => undefined); + const handler = createWillNavigateHandler( + () => "http://localhost:5173/editor", + openExternal, + ); + + handler({ url: "https://example.com/docs", preventDefault }); + + expect(preventDefault).toHaveBeenCalledOnce(); + expect(openExternal).toHaveBeenCalledWith("https://example.com/docs"); + }); + + it("stops unsafe schemes without opening them externally", () => { + const preventDefault = vi.fn(); + const openExternal = vi.fn(async () => undefined); + const handler = createWillNavigateHandler( + () => "file:///opt/Recordly/dist/index.html", + openExternal, + ); + + handler({ url: "file:///etc/passwd", preventDefault }); + + expect(preventDefault).toHaveBeenCalledOnce(); + expect(openExternal).not.toHaveBeenCalled(); + }); + + it("stops server redirects without externalizing the target", () => { + const preventDefault = vi.fn(); + createWillRedirectHandler()({ url: "https://example.com/redirect", preventDefault }); + expect(preventDefault).toHaveBeenCalledOnce(); + }); + + it("always denies Electron child windows while preserving safe external links", () => { + const openExternal = vi.fn(async () => undefined); + const handler = createWindowOpenHandler(() => "http://localhost:5173/editor", openExternal); + + expect(handler({ url: "https://example.com/docs" })).toEqual({ action: "deny" }); + expect(handler({ url: "http://localhost:5173/settings" })).toEqual({ action: "deny" }); + expect(handler({ url: "javascript:alert(1)" })).toEqual({ action: "deny" }); + expect(openExternal).toHaveBeenCalledOnce(); + expect(openExternal).toHaveBeenCalledWith("https://example.com/docs"); + }); + + it("reports a system-browser failure without allowing the child window", async () => { + const error = new Error("browser unavailable"); + const reportOpenError = vi.fn(); + const handler = createWindowOpenHandler( + () => "http://localhost:5173/editor", + vi.fn(async () => { + throw error; + }), + reportOpenError, + ); + + expect(handler({ url: "https://example.com/docs" })).toEqual({ action: "deny" }); + await vi.waitFor(() => { + expect(reportOpenError).toHaveBeenCalledWith("https://example.com/docs", error); + }); + }); + + it("reports a synchronous browser-launch failure while still denying the child", () => { + const error = new Error("browser launch threw"); + const reportOpenError = vi.fn(); + const handler = createWindowOpenHandler( + () => "http://localhost:5173/editor", + vi.fn(() => { + throw error; + }), + reportOpenError, + ); + + expect(handler({ url: "https://example.com/docs" })).toEqual({ action: "deny" }); + expect(reportOpenError).toHaveBeenCalledWith("https://example.com/docs", error); + }); + + it("attaches navigation, redirect, and window-open policies", () => { + const on = vi.fn(); + const setWindowOpenHandler = vi.fn(); + const webContents = { + getURL: () => "http://localhost:5173/", + on, + setWindowOpenHandler, + }; + + hardenWebContentsNavigation( + webContents, + vi.fn(async () => undefined), + ); + + expect(on).toHaveBeenCalledWith("will-navigate", expect.any(Function)); + expect(on).toHaveBeenCalledWith("will-redirect", expect.any(Function)); + expect(on).toHaveBeenCalledWith("did-navigate", expect.any(Function)); + expect(setWindowOpenHandler).toHaveBeenCalledWith(expect.any(Function)); + }); + + it("does not trust a renderer-mutated URL as an exact reload", () => { + let currentUrl = "file:///opt/Recordly/dist/index.html?windowType=editor"; + const on = vi.fn(); + const webContents = { + getURL: () => currentUrl, + on, + setWindowOpenHandler: vi.fn(), + }; + const openExternal = vi.fn(async () => undefined); + + hardenWebContentsNavigation(webContents, openExternal); + + // history.replaceState() changes getURL() without crossing a document-navigation boundary. + currentUrl = "file:///opt/Recordly/dist/index.html?windowType=source-selector"; + const willNavigate = on.mock.calls.find( + ([eventName]) => eventName === "will-navigate", + )?.[1]; + if (typeof willNavigate !== "function") { + throw new Error("will-navigate handler was not registered"); + } + + const preventDefault = vi.fn(); + willNavigate({ url: currentUrl, preventDefault }); + + expect(preventDefault).toHaveBeenCalledOnce(); + expect(openExternal).not.toHaveBeenCalled(); + }); + + it("trusts an exact reload after a completed document navigation", () => { + const on = vi.fn(); + const webContents = { + getURL: () => "", + on, + setWindowOpenHandler: vi.fn(), + }; + + hardenWebContentsNavigation( + webContents, + vi.fn(async () => undefined), + ); + + const didNavigate = on.mock.calls.find(([eventName]) => eventName === "did-navigate")?.[1]; + const willNavigate = on.mock.calls.find( + ([eventName]) => eventName === "will-navigate", + )?.[1]; + if (typeof didNavigate !== "function" || typeof willNavigate !== "function") { + throw new Error("navigation handlers were not registered"); + } + + const loadedUrl = "file:///opt/Recordly/dist/index.html?windowType=editor"; + didNavigate({}, loadedUrl); + const preventDefault = vi.fn(); + willNavigate({ url: loadedUrl, preventDefault }); + + expect(preventDefault).not.toHaveBeenCalled(); + }); +}); diff --git a/electron/navigationPolicy.ts b/electron/navigationPolicy.ts new file mode 100644 index 000000000..1bc9df4ee --- /dev/null +++ b/electron/navigationPolicy.ts @@ -0,0 +1,151 @@ +import type { WebContents } from "electron"; + +type OpenExternal = (url: string) => Promise; +type ReportOpenError = (url: string, error: unknown) => void; + +export type NavigationEvent = { + url: string; + preventDefault: () => void; +}; + +export function shouldHardenWebContentsType(type: ReturnType): boolean { + return type === "window"; +} + +export function normalizeExternalHttpUrl(value: string): string | null { + try { + const url = new URL(value); + if ( + (url.protocol !== "http:" && url.protocol !== "https:") || + !url.hostname || + url.username || + url.password + ) { + return null; + } + + return url.href; + } catch { + return null; + } +} + +export function isInternalRendererTarget(currentValue: string, targetValue: string): boolean { + try { + const currentUrl = new URL(currentValue); + const targetUrl = new URL(targetValue); + + if (targetUrl.username || targetUrl.password) { + return false; + } + + if ( + (currentUrl.protocol === "http:" || currentUrl.protocol === "https:") && + (targetUrl.protocol === "http:" || targetUrl.protocol === "https:") + ) { + return currentUrl.origin === targetUrl.origin; + } + + if (currentUrl.protocol === "file:" && targetUrl.protocol === "file:") { + return currentUrl.host === targetUrl.host && currentUrl.pathname === targetUrl.pathname; + } + + return currentUrl.href === targetUrl.href; + } catch { + return false; + } +} + +function isExactRendererLocation(currentValue: string, targetValue: string): boolean { + try { + const currentUrl = new URL(currentValue); + const targetUrl = new URL(targetValue); + return !targetUrl.username && !targetUrl.password && currentUrl.href === targetUrl.href; + } catch { + return false; + } +} + +function openExternalIfSafe( + value: string, + openExternal: OpenExternal, + reportOpenError: ReportOpenError, +): void { + const safeUrl = normalizeExternalHttpUrl(value); + if (!safeUrl) { + return; + } + + try { + void openExternal(safeUrl).catch((error) => reportOpenError(safeUrl, error)); + } catch (error) { + reportOpenError(safeUrl, error); + } +} + +const defaultReportOpenError: ReportOpenError = (url, error) => { + console.error("[navigation-policy] Failed to open external URL", { url, error }); +}; + +export function createWillNavigateHandler( + getTrustedRendererUrl: () => string, + openExternal: OpenExternal, + reportOpenError: ReportOpenError = defaultReportOpenError, +) { + return (event: NavigationEvent): void => { + const trustedRendererUrl = getTrustedRendererUrl(); + // Preserve an exact reload, but freeze all renderer-selected destination changes, + // including same-origin query mutations that can carry privileged local paths. + if (isExactRendererLocation(trustedRendererUrl, event.url)) { + return; + } + + // The internal-target check only prevents app URLs from leaking into the system browser. + event.preventDefault(); + if (isInternalRendererTarget(trustedRendererUrl, event.url)) { + return; + } + + openExternalIfSafe(event.url, openExternal, reportOpenError); + }; +} + +export function createWillRedirectHandler() { + return (event: NavigationEvent): void => { + event.preventDefault(); + }; +} + +export function createWindowOpenHandler( + getCurrentUrl: () => string, + openExternal: OpenExternal, + reportOpenError: ReportOpenError = defaultReportOpenError, +) { + return (details: { url: string }) => { + if (!isInternalRendererTarget(getCurrentUrl(), details.url)) { + openExternalIfSafe(details.url, openExternal, reportOpenError); + } + return { action: "deny" as const }; + }; +} + +export function hardenWebContentsNavigation( + webContents: Pick, + openExternal: OpenExternal, + reportOpenError: ReportOpenError = defaultReportOpenError, +): void { + // Renderer history APIs mutate getURL() without a document navigation. Keep the last + // main-frame document URL as the reload trust boundary instead of trusting that live value. + let trustedRendererUrl = webContents.getURL(); + webContents.on("did-navigate", (_event, url) => { + trustedRendererUrl = url; + }); + webContents.on( + "will-navigate", + createWillNavigateHandler(() => trustedRendererUrl, openExternal, reportOpenError), + ); + webContents.on("will-redirect", createWillRedirectHandler()); + webContents.setWindowOpenHandler( + createWindowOpenHandler(() => webContents.getURL(), openExternal, reportOpenError), + ); +} diff --git a/electron/permissionPolicy.test.ts b/electron/permissionPolicy.test.ts new file mode 100644 index 000000000..36b5dce63 --- /dev/null +++ b/electron/permissionPolicy.test.ts @@ -0,0 +1,198 @@ +import { describe, expect, it } from "vitest"; + +import { + isTrustedCaptureDocumentUrl, + shouldGrantDisplayCapture, + shouldGrantMediaPermission, +} from "./permissionPolicy"; + +const TRUSTED_DOCUMENT_BASE_URLS = [ + "http://localhost:5173/", + "http://127.0.0.1:43127/", + "file:///C:/Program%20Files/Recordly/resources/app.asar/dist/index.html", +]; + +const DEV_HUD_URL = "http://localhost:5173/?windowType=hud-overlay"; +const PACKAGED_HUD_URL = "http://127.0.0.1:43127/?windowType=hud-overlay"; +const FILE_HUD_URL = + "file:///C:/Program%20Files/Recordly/resources/app.asar/dist/index.html?windowType=hud-overlay"; + +describe("isTrustedCaptureDocumentUrl", () => { + it.each([ + DEV_HUD_URL, + PACKAGED_HUD_URL, + FILE_HUD_URL, + `${DEV_HUD_URL}#microphone`, + ])("accepts a Recordly HUD document: %s", (candidateUrl) => { + expect(isTrustedCaptureDocumentUrl(candidateUrl, TRUSTED_DOCUMENT_BASE_URLS)).toBe(true); + }); + + it.each([ + "http://localhost:5173/", + "http://localhost:5173/?windowType=editor", + "http://localhost:5173/?windowType=HUD-OVERLAY", + "http://localhost:5173/?windowType=hud-overlay&debug=1", + "http://localhost:5173/?windowType=hud-overlay&windowType=hud-overlay", + "http://localhost:5174/?windowType=hud-overlay", + "http://localhost.evil.test:5173/?windowType=hud-overlay", + "http://localhost:5173.evil.test/?windowType=hud-overlay", + "http://user@localhost:5173/?windowType=hud-overlay", + "https://localhost:5173/?windowType=hud-overlay", + "http://127.0.0.1:43127/nested/?windowType=hud-overlay", + "file:///C:/Program%20Files/Recordly/resources/app.asar/dist/other.html?windowType=hud-overlay", + "file:///C:/Program%20Files/Recordly/resources/app.asar/dist/index.html/child?windowType=hud-overlay", + "data:text/html,recordly?windowType=hud-overlay", + "not a url", + ])("rejects a non-Recordly capture document: %s", (candidateUrl) => { + expect(isTrustedCaptureDocumentUrl(candidateUrl, TRUSTED_DOCUMENT_BASE_URLS)).toBe(false); + }); + + it("ignores malformed trusted base URLs instead of throwing", () => { + expect( + isTrustedCaptureDocumentUrl(DEV_HUD_URL, ["not a url", ...TRUSTED_DOCUMENT_BASE_URLS]), + ).toBe(true); + }); +}); + +describe("shouldGrantMediaPermission", () => { + const makeRequest = ( + overrides: Partial[0]> = {}, + ): Parameters[0] => ({ + permission: "media", + isTrustedCaptureWindow: true, + isMainFrame: true, + currentDocumentUrl: DEV_HUD_URL, + requestingUrl: DEV_HUD_URL, + securityOrigins: ["http://localhost:5173"], + ...overrides, + }); + + it("grants camera or microphone media to the trusted HUD main frame", () => { + expect(shouldGrantMediaPermission(makeRequest(), TRUSTED_DOCUMENT_BASE_URLS)).toBe(true); + }); + + it("accepts Chromium's trailing-slash HTTP origin serialization", () => { + expect( + shouldGrantMediaPermission( + makeRequest({ securityOrigins: ["http://localhost:5173/"] }), + TRUSTED_DOCUMENT_BASE_URLS, + ), + ).toBe(true); + }); + + it("accepts the packaged loopback renderer with its exact origin", () => { + expect( + shouldGrantMediaPermission( + makeRequest({ + currentDocumentUrl: PACKAGED_HUD_URL, + requestingUrl: PACKAGED_HUD_URL, + securityOrigins: ["http://127.0.0.1:43127"], + }), + TRUSTED_DOCUMENT_BASE_URLS, + ), + ).toBe(true); + }); + + it.each([ + "null", + "file://", + "file:///", + ])("accepts Chromium's packaged file origin form: %s", (securityOrigin) => { + expect( + shouldGrantMediaPermission( + makeRequest({ + currentDocumentUrl: FILE_HUD_URL, + requestingUrl: FILE_HUD_URL, + securityOrigins: [securityOrigin], + }), + TRUSTED_DOCUMENT_BASE_URLS, + ), + ).toBe(true); + }); + + it.each([ + ["another permission", { permission: "display-capture" }], + ["another BrowserWindow", { isTrustedCaptureWindow: false }], + ["a subframe", { isMainFrame: false }], + ["an untrusted current document", { currentDocumentUrl: "https://example.com/" }], + ["a missing requesting document", { requestingUrl: "" }], + ["an untrusted requesting document", { requestingUrl: "https://example.com/" }], + ["a different trusted document", { requestingUrl: PACKAGED_HUD_URL }], + ["a mismatched origin", { securityOrigins: ["http://localhost:5174"] }], + ["an origin lookalike", { securityOrigins: ["http://localhost:5173.evil.test"] }], + ["an origin with credentials", { securityOrigins: ["http://user@localhost:5173/"] }], + ["an origin with a path", { securityOrigins: ["http://localhost:5173/other"] }], + ["an origin with a query", { securityOrigins: ["http://localhost:5173/?debug=1"] }], + ["a missing security origin", { securityOrigins: [] }], + ["an empty origin", { securityOrigins: [""] }], + ] as const)("denies %s", (_label, overrides) => { + expect(shouldGrantMediaPermission(makeRequest(overrides), TRUSTED_DOCUMENT_BASE_URLS)).toBe( + false, + ); + }); +}); + +describe("shouldGrantDisplayCapture", () => { + const makeRequest = ( + overrides: Partial[0]> = {}, + ): Parameters[0] => ({ + isTrustedCaptureWindow: true, + isMainFrame: true, + currentDocumentUrl: DEV_HUD_URL, + securityOrigin: "http://localhost:5173", + videoRequested: true, + ...overrides, + }); + + it("grants selected-display video to the trusted HUD main frame", () => { + expect(shouldGrantDisplayCapture(makeRequest(), TRUSTED_DOCUMENT_BASE_URLS)).toBe(true); + }); + + it("accepts a trailing slash on the display security origin", () => { + expect( + shouldGrantDisplayCapture( + makeRequest({ securityOrigin: "http://localhost:5173/" }), + TRUSTED_DOCUMENT_BASE_URLS, + ), + ).toBe(true); + }); + + it("accepts the packaged loopback renderer with its exact origin", () => { + expect( + shouldGrantDisplayCapture( + makeRequest({ + currentDocumentUrl: PACKAGED_HUD_URL, + securityOrigin: "http://127.0.0.1:43127", + }), + TRUSTED_DOCUMENT_BASE_URLS, + ), + ).toBe(true); + }); + + it.each([ + "null", + "file://", + "file:///", + ])("accepts Chromium's packaged file origin form: %s", (securityOrigin) => { + expect( + shouldGrantDisplayCapture( + makeRequest({ currentDocumentUrl: FILE_HUD_URL, securityOrigin }), + TRUSTED_DOCUMENT_BASE_URLS, + ), + ).toBe(true); + }); + + it.each([ + ["another BrowserWindow", { isTrustedCaptureWindow: false }], + ["a subframe", { isMainFrame: false }], + ["a non-Recordly document", { currentDocumentUrl: "https://example.com/" }], + ["a mismatched origin", { securityOrigin: "http://127.0.0.1:43127" }], + ["a malformed origin", { securityOrigin: "not an origin" }], + ["an origin with a path", { securityOrigin: "http://localhost:5173/other" }], + ["an audio-only request", { videoRequested: false }], + ] as const)("denies %s", (_label, overrides) => { + expect(shouldGrantDisplayCapture(makeRequest(overrides), TRUSTED_DOCUMENT_BASE_URLS)).toBe( + false, + ); + }); +}); diff --git a/electron/permissionPolicy.ts b/electron/permissionPolicy.ts new file mode 100644 index 000000000..0c9ea07c5 --- /dev/null +++ b/electron/permissionPolicy.ts @@ -0,0 +1,157 @@ +const CAPTURE_WINDOW_TYPE = "hud-overlay"; +const TRUSTED_RENDERER_PROTOCOLS = new Set(["http:", "https:", "file:"]); +const FILE_SECURITY_ORIGINS = new Set(["null", "file://", "file:///"]); + +export interface MediaPermissionPolicyRequest { + permission: string; + isTrustedCaptureWindow: boolean; + isMainFrame: boolean; + currentDocumentUrl: string; + requestingUrl: string; + securityOrigins: readonly string[]; +} + +export interface DisplayCapturePolicyRequest { + isTrustedCaptureWindow: boolean; + isMainFrame: boolean; + currentDocumentUrl: string; + securityOrigin: string; + videoRequested: boolean; +} + +function parseUrl(value: string): URL | null { + try { + return new URL(value); + } catch { + return null; + } +} + +function hasExactCaptureWindowQuery(url: URL): boolean { + const entries = [...url.searchParams.entries()]; + return ( + entries.length === 1 && + entries[0]?.[0] === "windowType" && + entries[0][1] === CAPTURE_WINDOW_TYPE + ); +} + +function isValidTrustedBaseUrl(url: URL): boolean { + return ( + TRUSTED_RENDERER_PROTOCOLS.has(url.protocol) && + url.username === "" && + url.password === "" && + url.search === "" && + url.hash === "" + ); +} + +function hasSameBaseLocation(candidate: URL, trustedBase: URL): boolean { + return ( + candidate.protocol === trustedBase.protocol && + candidate.hostname === trustedBase.hostname && + candidate.port === trustedBase.port && + candidate.pathname === trustedBase.pathname + ); +} + +export function isTrustedCaptureDocumentUrl( + candidateUrl: string, + trustedDocumentBaseUrls: readonly string[], +): boolean { + const candidate = parseUrl(candidateUrl); + if ( + !candidate || + !TRUSTED_RENDERER_PROTOCOLS.has(candidate.protocol) || + candidate.username !== "" || + candidate.password !== "" || + !hasExactCaptureWindowQuery(candidate) + ) { + return false; + } + + return trustedDocumentBaseUrls.some((trustedBaseUrl) => { + const trustedBase = parseUrl(trustedBaseUrl); + return Boolean( + trustedBase && + isValidTrustedBaseUrl(trustedBase) && + hasSameBaseLocation(candidate, trustedBase), + ); + }); +} + +function isSameDocument(firstUrl: string, secondUrl: string): boolean { + const first = parseUrl(firstUrl); + const second = parseUrl(secondUrl); + if (!first || !second) { + return false; + } + + first.hash = ""; + second.hash = ""; + return first.href === second.href; +} + +function isSecurityOriginForDocument(securityOrigin: string, documentUrl: string): boolean { + const document = parseUrl(documentUrl); + if (!document) { + return false; + } + + if (document.protocol === "file:") { + return FILE_SECURITY_ORIGINS.has(securityOrigin); + } + + const origin = parseUrl(securityOrigin); + return Boolean( + origin && + (origin.protocol === "http:" || origin.protocol === "https:") && + origin.username === "" && + origin.password === "" && + origin.origin === document.origin && + origin.pathname === "/" && + origin.search === "" && + origin.hash === "", + ); +} + +export function shouldGrantMediaPermission( + request: MediaPermissionPolicyRequest, + trustedDocumentBaseUrls: readonly string[], +): boolean { + if ( + request.permission !== "media" || + !request.isTrustedCaptureWindow || + !request.isMainFrame || + !isTrustedCaptureDocumentUrl(request.currentDocumentUrl, trustedDocumentBaseUrls) + ) { + return false; + } + + if ( + !isTrustedCaptureDocumentUrl(request.requestingUrl, trustedDocumentBaseUrls) || + !isSameDocument(request.currentDocumentUrl, request.requestingUrl) + ) { + return false; + } + + return ( + request.securityOrigins.length > 0 && + request.securityOrigins.every((securityOrigin) => + isSecurityOriginForDocument(securityOrigin, request.currentDocumentUrl), + ) + ); +} + +export function shouldGrantDisplayCapture( + request: DisplayCapturePolicyRequest, + trustedDocumentBaseUrls: readonly string[], +): boolean { + return ( + request.isTrustedCaptureWindow && + request.isMainFrame && + request.videoRequested && + isTrustedCaptureDocumentUrl(request.currentDocumentUrl, trustedDocumentBaseUrls) && + isSecurityOriginForDocument(request.securityOrigin, request.currentDocumentUrl) + ); +} diff --git a/electron/preload.ts b/electron/preload.ts index 2b94f790e..a30372314 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -1,52 +1,995 @@ -import { contextBridge, ipcRenderer } from 'electron' - -contextBridge.exposeInMainWorld('electronAPI', { - getAssetBasePath: async () => { - // ask main process for the correct base path (production vs dev) - return await ipcRenderer.invoke('get-asset-base-path') - }, - getSources: async (opts: Electron.SourcesOptions) => { - return await ipcRenderer.invoke('get-sources', opts) - }, - switchToEditor: () => { - return ipcRenderer.invoke('switch-to-editor') - }, - openSourceSelector: () => { - return ipcRenderer.invoke('open-source-selector') - }, - selectSource: (source: any) => { - return ipcRenderer.invoke('select-source', source) - }, - getSelectedSource: () => { - return ipcRenderer.invoke('get-selected-source') - }, - startMouseTracking: () => { - return ipcRenderer.invoke('start-mouse-tracking') - }, - stopMouseTracking: () => { - return ipcRenderer.invoke('stop-mouse-tracking') - }, - storeRecordedVideo: (videoData: ArrayBuffer, fileName: string) => { - return ipcRenderer.invoke('store-recorded-video', videoData, fileName) - }, - storeMouseTrackingData: (fileName: string) => { - return ipcRenderer.invoke('store-mouse-tracking-data', fileName) - }, - getRecordedVideoPath: () => { - return ipcRenderer.invoke('get-recorded-video-path') - }, - setRecordingState: (recording: boolean) => { - return ipcRenderer.invoke('set-recording-state', recording) - }, - onStopRecordingFromTray: (callback: () => void) => { - const listener = () => callback() - ipcRenderer.on('stop-recording-from-tray', listener) - return () => ipcRenderer.removeListener('stop-recording-from-tray', listener) - }, - openExternalUrl: (url: string) => { - return ipcRenderer.invoke('open-external-url', url) - }, - saveExportedVideo: (videoData: ArrayBuffer, fileName: string) => { - return ipcRenderer.invoke('save-exported-video', videoData, fileName) - }, -}) \ No newline at end of file +import { contextBridge, ipcRenderer } from "electron"; +import type { RecordingSessionData } from "./ipc/types"; + +type NativeVideoExportWriteResult = { success: boolean; error?: string }; +type NativeVideoAudioMuxMetrics = { + tempVideoWriteMs?: number; + tempEditedAudioWriteMs?: number; + ffmpegExecMs?: number; + muxedVideoReadMs?: number; + tempVideoBytes?: number; + tempEditedAudioBytes?: number; + muxedVideoBytes?: number; +}; +type WindowsGpuExportSummary = { + success?: boolean; + width?: number; + height?: number; + fps?: number; + seconds?: number; + mediaMs?: number; + frames?: number; + cursorOverlay?: boolean; + zoomOverlay?: boolean; + adapterVendorId?: number; + adapterDeviceId?: number; + adapterDedicatedVideoMemoryMB?: 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; +}; +type NativeStaticLayoutChunkMetric = { + 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?: WindowsGpuExportSummary; +}; +type NativeStaticLayoutMetrics = NativeVideoAudioMuxMetrics & { + chunkCount: number; + chunkDurationSec: number; + chunkExecMs: number; + concatExecMs?: number; + staticAssetExecMs?: number; + fallbackChunkCount: number; + videoOnlyBytes?: number; + chunks: NativeStaticLayoutChunkMetric[]; +}; +type NativeStaticLayoutProgress = { + sessionId?: string; + backend?: NativeStaticLayoutChunkMetric["backend"]; + stage?: "preparing" | "finalizing"; + elapsedMs?: number; + averageFps?: number; + currentFrame: number; + totalFrames: number; + percentage: number; +}; +type NativeVideoMetadataProbe = { + width: number; + height: number; + duration: number; + mediaStartTime?: number; + streamStartTime?: number; + streamDuration?: number; + frameRate: number; + codec: string; + hasAudio: boolean; + audioCodec?: string; + audioSampleRate?: number; +}; +type NativeExportCapabilities = { + platform: NodeJS.Platform; + nvidiaCuda: { + available: boolean; + skipReason: string | null; + hasNvidiaGpu: boolean | null; + hasWrapper: boolean; + explicitEnabled: boolean; + explicitDisabled: boolean; + userOptInRequired: boolean; + }; +}; + +const nativeVideoExportWriteRequests = new Map< + number, + { + sessionId: string; + resolve: (result: NativeVideoExportWriteResult) => void; + } +>(); + +let nextNativeVideoExportWriteRequestId = 1; +let nativeVideoExportWriteResultListenerAttached = false; + +function ensureNativeVideoExportWriteResultListener() { + if (nativeVideoExportWriteResultListenerAttached) { + return; + } + + nativeVideoExportWriteResultListenerAttached = true; + ipcRenderer.on( + "native-video-export-write-frame-result", + ( + _event, + payload: { + sessionId?: string; + requestId?: number; + success?: boolean; + error?: string; + }, + ) => { + if (typeof payload?.requestId !== "number") { + return; + } + + const pendingRequest = nativeVideoExportWriteRequests.get(payload.requestId); + if (!pendingRequest) { + return; + } + + nativeVideoExportWriteRequests.delete(payload.requestId); + pendingRequest.resolve({ + success: payload.success === true, + error: payload.error, + }); + }, + ); +} + +function settleNativeVideoExportPendingRequests( + sessionId: string, + result: NativeVideoExportWriteResult, +) { + for (const [requestId, pendingRequest] of nativeVideoExportWriteRequests.entries()) { + if (pendingRequest.sessionId !== sessionId) { + continue; + } + + nativeVideoExportWriteRequests.delete(requestId); + pendingRequest.resolve(result); + } +} + +contextBridge.exposeInMainWorld("electronAPI", { + hudOverlaySetIgnoreMouse: (ignore: boolean) => { + ipcRenderer.send("hud-overlay-set-ignore-mouse", ignore); + }, + hudOverlaySetSourceSelectionActive: (active: boolean) => { + ipcRenderer.send("hud-overlay-set-source-selection-active", active); + }, + hudOverlayDrag: (phase: "start" | "move" | "end", screenX: number, screenY: number) => { + ipcRenderer.send("hud-overlay-drag", phase, screenX, screenY); + }, + hudOverlayHide: () => { + ipcRenderer.send("hud-overlay-hide"); + }, + hudOverlayClose: () => { + ipcRenderer.send("hud-overlay-close"); + }, + hudOverlayRendererReady: () => { + ipcRenderer.send("hud-overlay-renderer-ready"); + }, + hudOverlaySetWebcamPreviewVisible: (visible: boolean) => { + ipcRenderer.send("hud-overlay-set-webcam-preview-visible", visible); + }, + getHudOverlayCaptureProtection: () => { + return ipcRenderer.invoke("get-hud-overlay-capture-protection"); + }, + getHudOverlayMousePassthroughSupported: () => { + return ipcRenderer.invoke("get-hud-overlay-mouse-passthrough-supported"); + }, + setHudOverlayCaptureProtection: (enabled: boolean) => { + return ipcRenderer.invoke("set-hud-overlay-capture-protection", enabled); + }, + getAssetBasePath: async () => { + return await ipcRenderer.invoke("get-asset-base-path"); + }, + listAssetDirectory: (relativeDir: string) => { + return ipcRenderer.invoke("list-asset-directory", relativeDir); + }, + readLocalFile: (filePath: string) => { + return ipcRenderer.invoke("read-local-file", filePath); + }, + generateWallpaperThumbnail: (filePath: string) => { + return ipcRenderer.invoke("generate-wallpaper-thumbnail", filePath); + }, + probeNativeVideoMetadata: (filePath: string) => { + return ipcRenderer.invoke("probe-native-video-metadata", filePath) as Promise<{ + success: boolean; + metadata?: NativeVideoMetadataProbe; + error?: string; + }>; + }, + getNativeExportCapabilities: () => { + return ipcRenderer.invoke("get-native-export-capabilities") as Promise<{ + success: boolean; + capabilities?: NativeExportCapabilities; + 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; + }; + }) => { + return ipcRenderer.invoke("native-static-layout-export", options) as Promise<{ + success: boolean; + tempPath?: string; + encoderName?: string; + error?: string; + metrics?: NativeStaticLayoutMetrics; + }>; + }, + nativeStaticLayoutExportCancel: (sessionId: string) => { + return ipcRenderer.invoke("native-static-layout-export-cancel", sessionId) as Promise<{ + success: boolean; + }>; + }, + onNativeStaticLayoutExportProgress: ( + callback: (progress: NativeStaticLayoutProgress) => void, + ) => { + const listener = (_event: Electron.IpcRendererEvent, payload: NativeStaticLayoutProgress) => + callback(payload); + ipcRenderer.on("native-static-layout-export-progress", listener); + return () => ipcRenderer.removeListener("native-static-layout-export-progress", listener); + }, + nativeVideoExportStart: (options: { + width: number; + height: number; + frameRate: number; + bitrate: number; + encodingMode: "fast" | "balanced" | "quality"; + inputMode?: "rawvideo" | "h264-stream"; + }) => { + return ipcRenderer.invoke("native-video-export-start", options); + }, + nativeVideoExportWriteFrame: (sessionId: string, frameData: Uint8Array) => { + ensureNativeVideoExportWriteResultListener(); + + return new Promise((resolve) => { + const requestId = nextNativeVideoExportWriteRequestId++; + nativeVideoExportWriteRequests.set(requestId, { + sessionId, + resolve, + }); + + ipcRenderer.send("native-video-export-write-frame-async", { + sessionId, + requestId, + frameData, + }); + }); + }, + nativeVideoExportWriteFrames: (sessionId: string, frameDataList: Uint8Array[]) => { + ensureNativeVideoExportWriteResultListener(); + + return new Promise((resolve) => { + const requestId = nextNativeVideoExportWriteRequestId++; + nativeVideoExportWriteRequests.set(requestId, { + sessionId, + resolve, + }); + + ipcRenderer.send("native-video-export-write-frames-async", { + sessionId, + requestId, + frameDataList, + }); + }); + }, + 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; + }, + ) => { + return ipcRenderer + .invoke("native-video-export-finish", sessionId, options) + .then((result) => { + settleNativeVideoExportPendingRequests( + sessionId, + result?.success + ? { success: true } + : { + success: false, + error: + typeof result?.error === "string" + ? result.error + : "Native video export session finished before all frame writes settled.", + }, + ); + + return result; + }) as Promise<{ + success: boolean; + data?: Uint8Array; + encoderName?: string; + error?: string; + metrics?: NativeVideoAudioMuxMetrics; + }>; + }, + nativeVideoExportCancel: (sessionId: string) => { + return ipcRenderer.invoke("native-video-export-cancel", sessionId).finally(() => { + settleNativeVideoExportPendingRequests(sessionId, { + success: false, + error: "Native video export session was cancelled", + }); + }); + }, + 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; + }, + ) => { + return ipcRenderer.invoke("mux-exported-video-audio", videoData, options) as Promise<{ + success: boolean; + tempPath?: string; + error?: string; + metrics?: NativeVideoAudioMuxMetrics; + }>; + }, + 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; + }, + ) => { + return ipcRenderer.invoke("mux-exported-video-audio-from-path", videoPath, options); + }, + openExportStream: (options?: { extension?: string }) => { + return ipcRenderer.invoke("export-stream-open", options); + }, + writeExportStreamChunk: (streamId: string, position: number, chunk: Uint8Array) => { + return ipcRenderer.invoke("export-stream-write", streamId, position, chunk); + }, + closeExportStream: (streamId: string, options?: { abort?: boolean }) => { + return ipcRenderer.invoke("export-stream-close", streamId, options); + }, + finalizeExportedVideo: (payload: { + tempPath: string; + fileName: string; + outputPath?: string | null; + captionSidecar?: { + format: "srt" | "vtt" | "both"; + cues: Array<{ + startMs: number; + endMs: number; + text: string; + }>; + }; + }) => { + return ipcRenderer.invoke("finalize-exported-video", payload); + }, + discardExportedTemp: (tempPath: string) => { + return ipcRenderer.invoke("discard-exported-temp", tempPath); + }, + getVideoAudioFallbackPaths: (videoPath: string) => { + return ipcRenderer.invoke("get-video-audio-fallback-paths", videoPath); + }, + getSources: async (opts: Electron.SourcesOptions) => { + return await ipcRenderer.invoke("get-sources", opts); + }, + switchToEditor: () => { + return ipcRenderer.invoke("switch-to-editor"); + }, + openSourceSelector: () => { + return ipcRenderer.invoke("open-source-selector"); + }, + selectSource: (source: ProcessedDesktopSource) => { + return ipcRenderer.invoke("select-source", source); + }, + showSourceHighlight: (source: ProcessedDesktopSource) => { + return ipcRenderer.invoke("show-source-highlight", source); + }, + getSelectedSource: () => { + return ipcRenderer.invoke("get-selected-source"); + }, + onSelectedSourceChanged: (callback: (source: ProcessedDesktopSource | null) => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + payload: ProcessedDesktopSource | null, + ) => callback(payload); + ipcRenderer.on("selected-source-changed", listener); + return () => ipcRenderer.removeListener("selected-source-changed", listener); + }, + startNativeScreenRecording: ( + source: ProcessedDesktopSource, + options?: { + capturesSystemAudio?: boolean; + capturesMicrophone?: boolean; + microphoneDeviceId?: string; + microphoneLabel?: string; + }, + ) => { + return ipcRenderer.invoke("start-native-screen-recording", source, options); + }, + stopNativeScreenRecording: () => { + return ipcRenderer.invoke("stop-native-screen-recording"); + }, + recoverNativeScreenRecording: () => { + return ipcRenderer.invoke("recover-native-screen-recording"); + }, + getLastNativeCaptureDiagnostics: () => { + return ipcRenderer.invoke("get-last-native-capture-diagnostics"); + }, + pauseNativeScreenRecording: () => { + return ipcRenderer.invoke("pause-native-screen-recording"); + }, + resumeNativeScreenRecording: () => { + return ipcRenderer.invoke("resume-native-screen-recording"); + }, + pauseCursorCapture: (pausedAtMs?: number) => { + return ipcRenderer.invoke("pause-cursor-capture", pausedAtMs); + }, + resumeCursorCapture: (resumedAtMs?: number) => { + return ipcRenderer.invoke("resume-cursor-capture", resumedAtMs); + }, + startFfmpegRecording: (source: ProcessedDesktopSource) => { + return ipcRenderer.invoke("start-ffmpeg-recording", source); + }, + stopFfmpegRecording: () => { + return ipcRenderer.invoke("stop-ffmpeg-recording"); + }, + storeRecordedVideo: (videoData: ArrayBuffer, fileName: string) => { + return ipcRenderer.invoke("store-recorded-video", videoData, fileName); + }, + 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; + }>; + }, + ) => { + return ipcRenderer.invoke("store-microphone-sidecar", audioData, videoPath, options); + }, + getRecordedVideoPath: () => { + return ipcRenderer.invoke("get-recorded-video-path"); + }, + setRecordingState: (recording: boolean) => { + return ipcRenderer.invoke("set-recording-state", recording); + }, + setCursorScale: (scale: number) => { + return ipcRenderer.invoke("set-cursor-scale", scale); + }, + getCursorTelemetry: (videoPath?: string) => { + return ipcRenderer.invoke("get-cursor-telemetry", videoPath); + }, + setCursorTelemetry: (videoPath: string | undefined, samples: CursorTelemetryPoint[]) => { + return ipcRenderer.invoke("set-cursor-telemetry", videoPath, samples); + }, + getSystemCursorAssets: () => { + return ipcRenderer.invoke("get-system-cursor-assets"); + }, + onStopRecordingFromTray: (callback: () => void) => { + const listener = () => callback(); + ipcRenderer.on("stop-recording-from-tray", listener); + return () => ipcRenderer.removeListener("stop-recording-from-tray", listener); + }, + onRecordingStateChanged: ( + callback: (state: { recording: boolean; sourceName: string }) => void, + ) => { + const listener = ( + _event: Electron.IpcRendererEvent, + payload: { recording: boolean; sourceName: string }, + ) => callback(payload); + ipcRenderer.on("recording-state-changed", listener); + return () => ipcRenderer.removeListener("recording-state-changed", listener); + }, + onRecordingInterrupted: (callback: (state: { reason: string; message: string }) => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + payload: { reason: string; message: string }, + ) => callback(payload); + ipcRenderer.on("recording-interrupted", listener); + return () => ipcRenderer.removeListener("recording-interrupted", listener); + }, + onCursorStateChanged: ( + callback: (state: { cursorType: CursorTelemetryPoint["cursorType"] }) => void, + ) => { + const listener = ( + _event: Electron.IpcRendererEvent, + payload: { cursorType: CursorTelemetryPoint["cursorType"] }, + ) => callback(payload); + ipcRenderer.on("cursor-state-changed", listener); + return () => ipcRenderer.removeListener("cursor-state-changed", listener); + }, + openExternalUrl: (url: string) => { + return ipcRenderer.invoke("open-external-url", url); + }, + getAccessibilityPermissionStatus: () => { + return ipcRenderer.invoke("get-accessibility-permission-status"); + }, + requestAccessibilityPermission: () => { + return ipcRenderer.invoke("request-accessibility-permission"); + }, + getScreenRecordingPermissionStatus: () => { + return ipcRenderer.invoke("get-screen-recording-permission-status"); + }, + openScreenRecordingPreferences: () => { + return ipcRenderer.invoke("open-screen-recording-preferences"); + }, + openAccessibilityPreferences: () => { + return ipcRenderer.invoke("open-accessibility-preferences"); + }, + saveExportedVideo: ( + videoData: ArrayBuffer, + fileName: string, + captionSidecar?: { + format: "srt" | "vtt" | "both"; + cues: Array<{ + startMs: number; + endMs: number; + text: string; + }>; + }, + ) => { + return ipcRenderer.invoke("save-exported-video", videoData, fileName, captionSidecar); + }, + writeExportedVideoToPath: ( + videoData: ArrayBuffer, + outputPath: string, + captionSidecar?: { + format: "srt" | "vtt" | "both"; + cues: Array<{ + startMs: number; + endMs: number; + text: string; + }>; + }, + ) => { + return ipcRenderer.invoke( + "write-exported-video-to-path", + videoData, + outputPath, + captionSidecar, + ); + }, + openVideoFilePicker: (options?: { includeProjects?: boolean }) => { + return ipcRenderer.invoke("open-video-file-picker", options); + }, + openAudioFilePicker: () => { + return ipcRenderer.invoke("open-audio-file-picker"); + }, + openWhisperExecutablePicker: () => { + return ipcRenderer.invoke("open-whisper-executable-picker"); + }, + openWhisperModelPicker: () => { + return ipcRenderer.invoke("open-whisper-model-picker"); + }, + getWhisperSmallModelStatus: () => { + return ipcRenderer.invoke("get-whisper-small-model-status"); + }, + downloadWhisperSmallModel: () => { + return ipcRenderer.invoke("download-whisper-small-model"); + }, + deleteWhisperSmallModel: () => { + return ipcRenderer.invoke("delete-whisper-small-model"); + }, + onWhisperSmallModelDownloadProgress: ( + callback: (state: { + status: "idle" | "downloading" | "downloaded" | "error"; + progress: number; + path?: string | null; + error?: string; + }) => void, + ) => { + const listener = ( + _event: Electron.IpcRendererEvent, + payload: { + status: "idle" | "downloading" | "downloaded" | "error"; + progress: number; + path?: string | null; + error?: string; + }, + ) => callback(payload); + ipcRenderer.on("whisper-small-model-download-progress", listener); + return () => ipcRenderer.removeListener("whisper-small-model-download-progress", listener); + }, + generateAutoCaptions: (options: { + videoPath: string; + whisperExecutablePath?: string; + whisperModelPath: string; + language?: string; + }) => { + return ipcRenderer.invoke("generate-auto-captions", options); + }, + setCurrentVideoPath: ( + path: string, + options?: { + preserveProjectPath?: boolean; + hideOverlayCursorByDefault?: boolean; + }, + ) => { + return ipcRenderer.invoke("set-current-video-path", path, options); + }, + setCurrentRecordingSession: ( + session: { + videoPath: string; + webcamPath?: string | null; + timeOffsetMs?: number; + hideOverlayCursorByDefault?: boolean; + }, + options?: { preserveProjectPath?: boolean }, + ) => { + return ipcRenderer.invoke("set-current-recording-session", session, options); + }, + onRecordingSessionChanged: (callback: (session: RecordingSessionData | null) => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + payload: RecordingSessionData | null, + ) => callback(payload); + ipcRenderer.on("recording-session-changed", listener); + return () => ipcRenderer.removeListener("recording-session-changed", listener); + }, + getCurrentRecordingSession: () => { + return ipcRenderer.invoke("get-current-recording-session"); + }, + getCurrentVideoPath: () => { + return ipcRenderer.invoke("get-current-video-path"); + }, + clearCurrentVideoPath: () => { + return ipcRenderer.invoke("clear-current-video-path"); + }, + deleteRecordingFile: (filePath: string) => { + return ipcRenderer.invoke("delete-recording-file", filePath); + }, + getLocalMediaUrl: (filePath: string) => { + return ipcRenderer.invoke("get-local-media-url", filePath) as Promise< + { success: true; url: string } | { success: false } + >; + }, + saveProjectFile: ( + projectData: unknown, + suggestedName?: string, + existingProjectPath?: string, + thumbnailDataUrl?: string | null, + ) => { + return ipcRenderer.invoke( + "save-project-file", + projectData, + suggestedName, + existingProjectPath, + thumbnailDataUrl, + ); + }, + saveProjectFileNamed: ( + projectData: unknown, + projectName: string, + thumbnailDataUrl?: string | null, + mode?: "rename" | "copy", + ) => { + return ipcRenderer.invoke( + "save-project-file-named", + projectData, + projectName, + thumbnailDataUrl, + mode, + ); + }, + loadProjectFile: () => { + return ipcRenderer.invoke("load-project-file"); + }, + loadCurrentProjectFile: () => { + return ipcRenderer.invoke("load-current-project-file"); + }, + getProjectsDirectory: () => { + return ipcRenderer.invoke("get-projects-directory"); + }, + listProjectFiles: () => { + return ipcRenderer.invoke("list-project-files"); + }, + openProjectFileAtPath: (filePath: string) => { + return ipcRenderer.invoke("open-project-file-at-path", filePath); + }, + openProjectsDirectory: () => { + return ipcRenderer.invoke("open-projects-directory"); + }, + installDownloadedUpdate: () => { + return ipcRenderer.invoke("install-downloaded-update"); + }, + downloadAvailableUpdate: (installAfterDownload?: boolean) => { + return ipcRenderer.invoke("download-available-update", installAfterDownload); + }, + deferDownloadedUpdate: (delayMs?: number) => { + return ipcRenderer.invoke("defer-downloaded-update", delayMs); + }, + dismissUpdateToast: () => { + return ipcRenderer.invoke("dismiss-update-toast"); + }, + skipUpdateVersion: () => { + return ipcRenderer.invoke("skip-update-version"); + }, + getCurrentUpdateToastPayload: () => { + return ipcRenderer.invoke("get-current-update-toast-payload"); + }, + getUpdateStatusSummary: () => { + return ipcRenderer.invoke("get-update-status-summary"); + }, + getExperimentalUpdatesEnabled: () => { + return ipcRenderer.invoke("get-experimental-updates-enabled"); + }, + setExperimentalUpdatesEnabled: (enabled: boolean) => { + return ipcRenderer.invoke("set-experimental-updates-enabled", enabled); + }, + previewUpdateToast: () => { + return ipcRenderer.invoke("preview-update-toast"); + }, + checkForAppUpdates: () => { + return ipcRenderer.invoke("check-for-app-updates"); + }, + onUpdateToastStateChanged: ( + callback: ( + payload: { + version: string; + detail: string; + phase: "available" | "downloading" | "ready" | "error"; + delayMs: number; + isPreview?: boolean; + progressPercent?: number; + transferredBytes?: number; + totalBytes?: number; + remainingBytes?: number; + bytesPerSecond?: number; + primaryAction?: "install-and-restart" | "retry-check"; + } | null, + ) => void, + ) => { + const listener = ( + _event: Electron.IpcRendererEvent, + payload: { + version: string; + detail: string; + phase: "available" | "downloading" | "ready" | "error"; + delayMs: number; + isPreview?: boolean; + progressPercent?: number; + transferredBytes?: number; + totalBytes?: number; + remainingBytes?: number; + bytesPerSecond?: number; + primaryAction?: "install-and-restart" | "retry-check"; + } | null, + ) => callback(payload); + ipcRenderer.on("update-toast-state", listener); + return () => ipcRenderer.removeListener("update-toast-state", listener); + }, + onUpdateReadyToast: ( + callback: (payload: { + version: string; + detail: string; + delayMs: number; + isPreview?: boolean; + }) => void, + ) => { + const listener = ( + _event: Electron.IpcRendererEvent, + payload: { version: string; detail: string; delayMs: number; isPreview?: boolean }, + ) => callback(payload); + ipcRenderer.on("update-ready-toast", listener); + return () => ipcRenderer.removeListener("update-ready-toast", listener); + }, + onMenuLoadProject: (callback: () => void) => { + const listener = () => callback(); + ipcRenderer.on("menu-load-project", listener); + return () => ipcRenderer.removeListener("menu-load-project", listener); + }, + onMenuSaveProject: (callback: () => void) => { + const listener = () => callback(); + ipcRenderer.on("menu-save-project", listener); + return () => ipcRenderer.removeListener("menu-save-project", listener); + }, + onMenuSaveProjectAs: (callback: () => void) => { + const listener = () => callback(); + ipcRenderer.on("menu-save-project-as", listener); + return () => ipcRenderer.removeListener("menu-save-project-as", listener); + }, + getPlatform: () => { + return ipcRenderer.invoke("get-platform"); + }, + getLinuxWindowSystem: () => { + return ipcRenderer.invoke("get-linux-window-system"); + }, + revealInFolder: (filePath: string) => { + return ipcRenderer.invoke("reveal-in-folder", filePath); + }, + openRecordingsFolder: () => { + return ipcRenderer.invoke("open-recordings-folder"); + }, + getRecordingsDirectory: () => { + return ipcRenderer.invoke("get-recordings-directory"); + }, + chooseRecordingsDirectory: () => { + return ipcRenderer.invoke("choose-recordings-directory"); + }, + getShortcuts: () => { + return ipcRenderer.invoke("get-shortcuts"); + }, + saveShortcuts: (shortcuts: unknown) => { + return ipcRenderer.invoke("save-shortcuts", shortcuts); + }, + getAppSetting: (key: string) => { + const result = ipcRenderer.sendSync("app-settings:get", key) as { + success?: boolean; + value?: unknown; + }; + return result?.success ? (result.value ?? null) : null; + }, + setAppSetting: (key: string, value: unknown) => { + const result = ipcRenderer.sendSync("app-settings:set", key, value) as { + success?: boolean; + }; + return result?.success === true; + }, + setHasUnsavedChanges: (hasChanges: boolean) => { + ipcRenderer.send("set-has-unsaved-changes", hasChanges); + }, + onRequestSaveBeforeClose: (callback: () => Promise) => { + const listener = async () => { + let saved = false; + try { + saved = await callback(); + } catch { + saved = false; + } + ipcRenderer.send("save-before-close-done", saved); + }; + ipcRenderer.on("request-save-before-close", listener); + return () => ipcRenderer.removeListener("request-save-before-close", listener); + }, + isNativeWindowsCaptureAvailable: () => + ipcRenderer.invoke("is-native-windows-capture-available"), + muxNativeWindowsRecording: (expectedDurationMs?: number) => + ipcRenderer.invoke("mux-native-windows-recording", expectedDurationMs), + hideOsCursor: () => ipcRenderer.invoke("hide-cursor"), + getAppVersion: () => ipcRenderer.invoke("app:getVersion"), + getAnnouncements: () => ipcRenderer.invoke("announcements:get"), + getRecordingPreferences: () => ipcRenderer.invoke("get-recording-preferences"), + getRecordingAudioLabConfig: () => ipcRenderer.invoke("get-recording-audio-lab-config"), + setRecordingPreferences: (prefs: { + microphoneEnabled?: boolean; + microphoneDeviceId?: string; + systemAudioEnabled?: boolean; + webcamEnabled?: boolean; + webcamDeviceId?: string; + }) => ipcRenderer.invoke("set-recording-preferences", prefs), + getCountdownDelay: () => ipcRenderer.invoke("get-countdown-delay"), + setCountdownDelay: (delay: number) => ipcRenderer.invoke("set-countdown-delay", delay), + startCountdown: (seconds: number) => ipcRenderer.invoke("start-countdown", seconds), + cancelCountdown: () => ipcRenderer.invoke("cancel-countdown"), + getActiveCountdown: () => ipcRenderer.invoke("get-active-countdown"), + onCountdownTick: (callback: (seconds: number) => void) => { + const listener = (_event: Electron.IpcRendererEvent, seconds: number) => callback(seconds); + ipcRenderer.on("countdown-tick", listener); + return () => ipcRenderer.removeListener("countdown-tick", listener); + }, +}); diff --git a/electron/rendererServer.ts b/electron/rendererServer.ts new file mode 100644 index 000000000..8f0d56627 --- /dev/null +++ b/electron/rendererServer.ts @@ -0,0 +1,137 @@ +import fs from "node:fs/promises"; +import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; +import path from "node:path"; + +const MIME_TYPES: Record = { + ".css": "text/css; charset=utf-8", + ".gif": "image/gif", + ".html": "text/html; charset=utf-8", + ".ico": "image/x-icon", + ".js": "text/javascript; charset=utf-8", + ".json": "application/json; charset=utf-8", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".mjs": "text/javascript; charset=utf-8", + ".png": "image/png", + ".svg": "image/svg+xml", + ".txt": "text/plain; charset=utf-8", + ".wasm": "application/wasm", + ".webp": "image/webp", + ".woff": "font/woff", + ".woff2": "font/woff2", +}; + +let packagedRendererBaseUrl: string | null = null; +let packagedRendererServerStartPromise: Promise | null = null; + +function getContentType(filePath: string): string { + return MIME_TYPES[path.extname(filePath).toLowerCase()] ?? "application/octet-stream"; +} + +function resolveRequestedFilePath(rootDir: string, requestPathname: string): string | null { + const trimmedPathname = requestPathname === "/" ? "/index.html" : requestPathname; + + // path.normalize() on Windows converts / to \, making the leading-slash + // regex fail and causing path.resolve to escape to the drive root. + const normalizedPosix = path.posix.normalize(decodeURIComponent(trimmedPathname)); + const relativePath = normalizedPosix.replace(/^\/+/, ""); + + if (!relativePath) { + return null; + } + + const resolvedRootDir = path.resolve(rootDir); + const resolvedFilePath = path.resolve(resolvedRootDir, relativePath); + + if ( + resolvedFilePath !== resolvedRootDir && + !resolvedFilePath.startsWith(`${resolvedRootDir}${path.sep}`) + ) { + return null; + } + + return resolvedFilePath; +} + +async function servePackagedRendererRequest( + rootDir: string, + request: IncomingMessage, + response: ServerResponse, +): Promise { + try { + const requestUrl = new URL(request.url ?? "/", "http://127.0.0.1"); + const resolvedFilePath = resolveRequestedFilePath(rootDir, requestUrl.pathname); + + if (!resolvedFilePath) { + response.writeHead(403, { "Content-Type": "text/plain; charset=utf-8" }); + response.end("Forbidden"); + return; + } + + const filePath = resolvedFilePath; + const fileContents = await fs.readFile(filePath); + + response.writeHead(200, { + "Cache-Control": "no-cache", + "Content-Type": getContentType(filePath), + }); + + if (request.method === "HEAD") { + response.end(); + return; + } + + response.end(fileContents); + } catch (error) { + if ((error as NodeJS.ErrnoException)?.code === "ENOENT") { + response.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" }); + response.end("Not Found"); + return; + } + + response.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" }); + response.end("Internal Server Error"); + } +} + +export function getPackagedRendererBaseUrl(): string | null { + return packagedRendererBaseUrl; +} + +export async function ensurePackagedRendererServer(rootDir: string): Promise { + if (packagedRendererBaseUrl) { + return packagedRendererBaseUrl; + } + + if (packagedRendererServerStartPromise) { + return packagedRendererServerStartPromise; + } + + packagedRendererServerStartPromise = new Promise((resolve, reject) => { + const server = createServer((request, response) => { + void servePackagedRendererRequest(rootDir, request, response); + }); + + server.once("error", (error) => { + reject(error); + }); + + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (!address || typeof address === "string") { + server.close(); + reject(new Error("Renderer server did not expose a TCP address")); + return; + } + + packagedRendererBaseUrl = `http://127.0.0.1:${address.port}`; + resolve(packagedRendererBaseUrl); + }); + }); + + try { + return await packagedRendererServerStartPromise; + } finally { + packagedRendererServerStartPromise = null; + } +} diff --git a/electron/uiohook-napi.d.ts b/electron/uiohook-napi.d.ts new file mode 100644 index 000000000..a6e65fcdd --- /dev/null +++ b/electron/uiohook-napi.d.ts @@ -0,0 +1 @@ +declare module "uiohook-napi"; diff --git a/electron/updateChannel.test.ts b/electron/updateChannel.test.ts new file mode 100644 index 000000000..646c65c3d --- /dev/null +++ b/electron/updateChannel.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import { EXPERIMENTAL_UPDATE_DESCRIPTION, getUpdateChannelConfiguration } from "./updateChannel"; + +describe("getUpdateChannelConfiguration", () => { + it("keeps regular clients on stable metadata", () => { + expect(getUpdateChannelConfiguration(false)).toEqual({ + channel: "latest", + allowPrerelease: false, + allowDowngrade: false, + }); + }); + + it("uses beta metadata only after the client opts in", () => { + expect(getUpdateChannelConfiguration(true)).toEqual({ + channel: "beta", + allowPrerelease: true, + allowDowngrade: false, + }); + }); + + it("uses the approved experimental update description", () => { + expect(EXPERIMENTAL_UPDATE_DESCRIPTION).toBe( + "You've opted into experimental updates so you have the choice to test the latest update of Recordly before it's widely available.", + ); + }); +}); diff --git a/electron/updateChannel.ts b/electron/updateChannel.ts new file mode 100644 index 000000000..9ca9ce96e --- /dev/null +++ b/electron/updateChannel.ts @@ -0,0 +1,20 @@ +export const STABLE_UPDATE_CHANNEL = "latest"; +export const EXPERIMENTAL_UPDATE_CHANNEL = "beta"; +export const EXPERIMENTAL_UPDATE_DESCRIPTION = + "You've opted into experimental updates so you have the choice to test the latest update of Recordly before it's widely available."; + +export interface UpdateChannelConfiguration { + channel: typeof STABLE_UPDATE_CHANNEL | typeof EXPERIMENTAL_UPDATE_CHANNEL; + allowPrerelease: boolean; + allowDowngrade: false; +} + +export function getUpdateChannelConfiguration( + experimentalUpdatesEnabled: boolean, +): UpdateChannelConfiguration { + return { + channel: experimentalUpdatesEnabled ? EXPERIMENTAL_UPDATE_CHANNEL : STABLE_UPDATE_CHANNEL, + allowPrerelease: experimentalUpdatesEnabled, + allowDowngrade: false, + }; +} diff --git a/electron/updater.ts b/electron/updater.ts new file mode 100644 index 000000000..8fb241774 --- /dev/null +++ b/electron/updater.ts @@ -0,0 +1,872 @@ +import fs from "node:fs"; +import path from "node:path"; +import type { MessageBoxOptions, MessageBoxReturnValue } from "electron"; +import { app, BrowserWindow, dialog } from "electron"; +import { autoUpdater } from "electron-updater"; +import { USER_DATA_PATH } from "./appPaths"; +import { readAppSetting, writeAppSetting } from "./appSettingsStore"; +import { EXPERIMENTAL_UPDATE_DESCRIPTION, getUpdateChannelConfiguration } from "./updateChannel"; + +const UPDATE_CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000; +export const UPDATE_REMINDER_DELAY_MS = 3 * 60 * 60 * 1000; +const DISMISSED_READY_REMINDER_DELAY_MS = 5 * 60 * 1000; +const AUTO_UPDATES_DISABLED = process.env.RECORDLY_DISABLE_AUTO_UPDATES === "1"; +const UPDATE_FEED_URL_OVERRIDE = process.env.RECORDLY_UPDATE_FEED_URL?.trim() ?? ""; +const UPDATER_LOG_PATH = + process.env.RECORDLY_UPDATER_LOG_PATH?.trim() || path.join(USER_DATA_PATH, "updater.log"); +const DEV_UPDATE_PREVIEW_VERSION = "9.9.9"; +const DEV_UPDATE_PREVIEW_IS_EXPERIMENTAL = + process.env.RECORDLY_DEV_PREVIEW_EXPERIMENTAL_UPDATE === "1"; +const DEV_UPDATE_PREVIEW_PROGRESS_STEP_MS = 300; +const DEV_UPDATE_PREVIEW_PROGRESS_INCREMENT = 20; +const ONE_MEGABYTE = 1024 * 1024; +const EXPERIMENTAL_UPDATES_SETTING_KEY = "experimentalUpdatesEnabled"; + +export type UpdateToastPhase = "available" | "downloading" | "ready" | "error"; + +export type UpdateStatusKind = + | "idle" + | "checking" + | "up-to-date" + | "available" + | "downloading" + | "ready" + | "error"; + +export interface UpdateStatusSummary { + status: UpdateStatusKind; + currentVersion: string; + availableVersion: string | null; + detail?: string; +} + +export interface UpdateToastPayload { + version: string; + detail: string; + phase: UpdateToastPhase; + delayMs: number; + isPreview?: boolean; + isExperimental?: boolean; + progressPercent?: number; + transferredBytes?: number; + totalBytes?: number; + remainingBytes?: number; + bytesPerSecond?: number; + primaryAction?: "install-and-restart" | "retry-check"; +} + +interface DownloadProgressSnapshot { + progressPercent?: number; + transferredBytes?: number; + totalBytes?: number; + bytesPerSecond?: number; +} + +type UpdateToastSender = ( + channel: "update-toast-state", + payload: UpdateToastPayload | null, +) => boolean; + +let updaterInitialized = false; +let updateCheckInProgress = false; +let manualCheckRequested = false; +let periodicCheckTimer: NodeJS.Timeout | null = null; +let deferredReminderTimer: NodeJS.Timeout | null = null; +let devPreviewProgressTimer: NodeJS.Timeout | null = null; +let currentToastPayload: UpdateToastPayload | null = null; +let availableVersion: string | null = null; +let pendingDownloadedVersion: string | null = null; +let downloadInProgress = false; +let downloadToastDismissed = false; +let skippedVersion: string | null = null; +let installAfterDownloadRequested = false; +let updateStatusSummary: UpdateStatusSummary = { + status: "idle", + currentVersion: app.getVersion(), + availableVersion: null, +}; + +function setUpdateStatusSummary(summary: Partial) { + updateStatusSummary = { + ...updateStatusSummary, + currentVersion: app.getVersion(), + ...summary, + }; +} + +function summarizeError(error: unknown) { + if (error instanceof Error) { + return error.stack || `${error.name}: ${error.message}`; + } + + return String(error); +} + +function writeUpdaterLog(message: string, detail?: unknown) { + try { + fs.mkdirSync(path.dirname(UPDATER_LOG_PATH), { recursive: true }); + const suffix = detail === undefined ? "" : ` ${summarizeError(detail)}`; + fs.appendFileSync( + UPDATER_LOG_PATH, + `${new Date().toISOString()} ${message}${suffix}\n`, + "utf8", + ); + } catch (logError) { + console.error("Failed to write updater log:", logError); + } +} + +function configureUpdateFeed() { + if (!UPDATE_FEED_URL_OVERRIDE) { + writeUpdaterLog("Using published GitHub update feed."); + return; + } + + autoUpdater.setFeedURL({ + provider: "generic", + url: UPDATE_FEED_URL_OVERRIDE, + channel: "latest", + }); + writeUpdaterLog(`Using overridden update feed: ${UPDATE_FEED_URL_OVERRIDE}`); +} + +export function getExperimentalUpdatesEnabled() { + return readAppSetting(EXPERIMENTAL_UPDATES_SETTING_KEY) === true; +} + +function applyExperimentalUpdatesPreference() { + const enabled = getExperimentalUpdatesEnabled(); + const { channel, allowPrerelease, allowDowngrade } = getUpdateChannelConfiguration(enabled); + autoUpdater.channel = channel; + autoUpdater.allowPrerelease = allowPrerelease; + // Changing channels enables downgrades inside electron-updater. Recordly never + // needs that behaviour: opting out waits for the next stable version instead. + autoUpdater.allowDowngrade = allowDowngrade; + writeUpdaterLog( + `Update channel configured: ${enabled ? "experimental" : "stable"} (${channel}).`, + ); + return enabled; +} + +export function setExperimentalUpdatesEnabled(enabled: boolean) { + writeAppSetting(EXPERIMENTAL_UPDATES_SETTING_KEY, enabled); + applyExperimentalUpdatesPreference(); + skippedVersion = null; + writeUpdaterLog(`Experimental updates ${enabled ? "enabled" : "disabled"} by user.`); + return enabled; +} + +function canUseAutoUpdates() { + return !AUTO_UPDATES_DISABLED && app.isPackaged && !process.mas; +} + +export function isAutoUpdateFeatureEnabled() { + return !AUTO_UPDATES_DISABLED; +} + +function getDialogWindow(getMainWindow: () => BrowserWindow | null) { + const window = getMainWindow(); + return window && !window.isDestroyed() ? window : undefined; +} + +function showMessageBox( + getMainWindow: () => BrowserWindow | null, + options: MessageBoxOptions, +): Promise { + const window = getDialogWindow(getMainWindow); + return window ? dialog.showMessageBox(window, options) : dialog.showMessageBox(options); +} + +function clearDeferredReminderTimer() { + if (deferredReminderTimer) { + clearTimeout(deferredReminderTimer); + deferredReminderTimer = null; + } +} + +function clearDevPreviewProgressTimer() { + if (devPreviewProgressTimer) { + clearInterval(devPreviewProgressTimer); + devPreviewProgressTimer = null; + } +} + +function emitUpdateToastState( + sendToRenderer: UpdateToastSender | undefined, + payload: UpdateToastPayload | null, +) { + currentToastPayload = payload; + if (!sendToRenderer) { + return false; + } + + return sendToRenderer("update-toast-state", payload); +} + +function getCurrentToastExperimentalFlag() { + return currentToastPayload?.isExperimental ?? getExperimentalUpdatesEnabled(); +} + +function createAvailableUpdateToastPayload( + version: string, + isExperimental = getExperimentalUpdatesEnabled(), +): UpdateToastPayload { + return { + version, + phase: "available", + detail: isExperimental + ? EXPERIMENTAL_UPDATE_DESCRIPTION + : "Install the latest version now, or remind yourself to come back to it later.", + delayMs: UPDATE_REMINDER_DELAY_MS, + isExperimental, + primaryAction: "install-and-restart", + }; +} + +function createDownloadingUpdateToastPayload( + version: string, + progress: DownloadProgressSnapshot = {}, + isExperimental = getCurrentToastExperimentalFlag(), +): UpdateToastPayload { + const normalizedProgress = Math.max( + 0, + Math.min(100, Math.round(progress.progressPercent ?? 0)), + ); + const transferredBytes = + typeof progress.transferredBytes === "number" && Number.isFinite(progress.transferredBytes) + ? Math.max(0, progress.transferredBytes) + : undefined; + const totalBytes = + typeof progress.totalBytes === "number" && Number.isFinite(progress.totalBytes) + ? Math.max(0, progress.totalBytes) + : undefined; + const remainingBytes = + totalBytes !== undefined && transferredBytes !== undefined + ? Math.max(totalBytes - transferredBytes, 0) + : undefined; + const bytesPerSecond = + typeof progress.bytesPerSecond === "number" && Number.isFinite(progress.bytesPerSecond) + ? Math.max(0, progress.bytesPerSecond) + : undefined; + const remainingMb = + remainingBytes !== undefined ? Math.max(0, remainingBytes / ONE_MEGABYTE) : null; + return { + version, + phase: "downloading", + detail: + normalizedProgress >= 100 + ? "Finishing the update download. Recordly will restart as soon as the installer is ready." + : remainingMb !== null + ? `${remainingMb.toFixed(1)} MB left before Recordly restarts.` + : "Downloading the update now. Recordly will restart when it finishes.", + delayMs: UPDATE_REMINDER_DELAY_MS, + isExperimental, + progressPercent: normalizedProgress, + transferredBytes, + totalBytes, + remainingBytes, + bytesPerSecond, + primaryAction: "install-and-restart", + }; +} + +function createDownloadedUpdateToastPayload( + version: string, + isExperimental = getCurrentToastExperimentalFlag(), +): UpdateToastPayload { + return { + version, + phase: "ready", + detail: "The update is ready. Install and restart now, or remind yourself later.", + delayMs: UPDATE_REMINDER_DELAY_MS, + isExperimental, + primaryAction: "install-and-restart", + }; +} + +function createUpdateErrorToastPayload( + version: string, + error: unknown, + isExperimental = getCurrentToastExperimentalFlag(), +): UpdateToastPayload { + return { + version, + phase: "error", + detail: `The update could not be downloaded. ${String(error)}`, + delayMs: UPDATE_REMINDER_DELAY_MS, + isExperimental, + primaryAction: "install-and-restart", + }; +} + +function getReminderPayload(): UpdateToastPayload | null { + if (pendingDownloadedVersion) { + return createDownloadedUpdateToastPayload(pendingDownloadedVersion); + } + + if (availableVersion && !downloadInProgress) { + return createAvailableUpdateToastPayload(availableVersion); + } + + return null; +} + +function clearVisibleUpdateToast(sendToRenderer?: UpdateToastSender) { + emitUpdateToastState(sendToRenderer, null); +} + +export function getCurrentUpdateToastPayload() { + return currentToastPayload; +} + +export function getUpdaterLogPath() { + return UPDATER_LOG_PATH; +} + +export function getUpdateStatusSummary() { + return updateStatusSummary; +} + +function resetDevPreviewState(sendToRenderer?: UpdateToastSender) { + clearDevPreviewProgressTimer(); + availableVersion = null; + pendingDownloadedVersion = null; + downloadInProgress = false; + downloadToastDismissed = false; + skippedVersion = null; + installAfterDownloadRequested = false; + clearVisibleUpdateToast(sendToRenderer); +} + +function simulateDevPreviewDownload(sendToRenderer?: UpdateToastSender) { + availableVersion = DEV_UPDATE_PREVIEW_VERSION; + pendingDownloadedVersion = null; + downloadInProgress = true; + downloadToastDismissed = false; + clearDeferredReminderTimer(); + clearDevPreviewProgressTimer(); + + let progressPercent = 0; + emitUpdateToastState(sendToRenderer, { + ...createDownloadingUpdateToastPayload(DEV_UPDATE_PREVIEW_VERSION, { + progressPercent, + transferredBytes: 0, + totalBytes: 20 * ONE_MEGABYTE, + bytesPerSecond: 5 * ONE_MEGABYTE, + }), + isPreview: true, + }); + + devPreviewProgressTimer = setInterval(() => { + progressPercent = Math.min(100, progressPercent + DEV_UPDATE_PREVIEW_PROGRESS_INCREMENT); + + if (progressPercent >= 100) { + clearDevPreviewProgressTimer(); + downloadInProgress = false; + pendingDownloadedVersion = DEV_UPDATE_PREVIEW_VERSION; + emitUpdateToastState(sendToRenderer, { + ...createDownloadedUpdateToastPayload(DEV_UPDATE_PREVIEW_VERSION), + isPreview: true, + detail: "Development preview: the update is ready to install. No real update will be installed.", + }); + return; + } + + if (downloadToastDismissed) { + return; + } + + emitUpdateToastState(sendToRenderer, { + ...createDownloadingUpdateToastPayload(DEV_UPDATE_PREVIEW_VERSION, { + progressPercent, + transferredBytes: (progressPercent / 100) * 20 * ONE_MEGABYTE, + totalBytes: 20 * ONE_MEGABYTE, + bytesPerSecond: 5 * ONE_MEGABYTE, + }), + isPreview: true, + }); + }, DEV_UPDATE_PREVIEW_PROGRESS_STEP_MS); + + return { success: true }; +} + +export function dismissUpdateToast( + getMainWindow: () => BrowserWindow | null, + sendToRenderer?: UpdateToastSender, +) { + if (currentToastPayload?.isPreview) { + resetDevPreviewState(sendToRenderer); + return { success: true }; + } + + if (downloadInProgress) { + installAfterDownloadRequested = false; + downloadToastDismissed = true; + clearVisibleUpdateToast(sendToRenderer); + return { success: true }; + } + + if (currentToastPayload?.phase === "ready") { + return deferUpdateReminder( + getMainWindow, + sendToRenderer, + DISMISSED_READY_REMINDER_DELAY_MS, + ); + } + + if (currentToastPayload?.phase === "available" || currentToastPayload?.phase === "error") { + return deferUpdateReminder(getMainWindow, sendToRenderer, UPDATE_REMINDER_DELAY_MS); + } + + clearVisibleUpdateToast(sendToRenderer); + return { success: true }; +} + +export function installDownloadedUpdateNow(sendToRenderer?: UpdateToastSender) { + if (currentToastPayload?.isPreview) { + resetDevPreviewState(sendToRenderer); + return; + } + + clearDeferredReminderTimer(); + downloadToastDismissed = false; + installAfterDownloadRequested = false; + clearVisibleUpdateToast(sendToRenderer); + setUpdateStatusSummary({ status: "ready", availableVersion: pendingDownloadedVersion }); + writeUpdaterLog("Installing downloaded update."); + autoUpdater.quitAndInstall(); +} + +export async function downloadAvailableUpdate( + sendToRenderer?: UpdateToastSender, + options?: { installAfterDownload?: boolean }, +) { + if (currentToastPayload?.isPreview) { + return simulateDevPreviewDownload(sendToRenderer); + } + + if (!availableVersion) { + return { success: false, message: "No update is ready to download." }; + } + + if (pendingDownloadedVersion === availableVersion) { + return { success: false, message: "This update has already been downloaded." }; + } + + if (downloadInProgress) { + return { success: false, message: "This update is already downloading." }; + } + + clearDeferredReminderTimer(); + downloadInProgress = true; + downloadToastDismissed = false; + installAfterDownloadRequested = + Boolean(options?.installAfterDownload) || installAfterDownloadRequested; + setUpdateStatusSummary({ + status: "downloading", + availableVersion, + detail: `Downloading Recordly ${availableVersion}`, + }); + emitUpdateToastState( + sendToRenderer, + createDownloadingUpdateToastPayload(availableVersion, { + progressPercent: 0, + transferredBytes: 0, + }), + ); + writeUpdaterLog(`Starting update download for ${availableVersion}.`); + + try { + await autoUpdater.downloadUpdate(); + writeUpdaterLog(`Update download requested for ${availableVersion}.`); + return { success: true }; + } catch (error) { + downloadInProgress = false; + setUpdateStatusSummary({ + status: "error", + availableVersion, + detail: String(error), + }); + writeUpdaterLog(`Update download failed for ${availableVersion}.`, error); + emitUpdateToastState( + sendToRenderer, + createUpdateErrorToastPayload(availableVersion, error), + ); + return { success: false, message: String(error) }; + } +} + +export function deferUpdateReminder( + getMainWindow: () => BrowserWindow | null, + sendToRenderer?: UpdateToastSender, + delayMs = UPDATE_REMINDER_DELAY_MS, +) { + const payload = getReminderPayload(); + if (!payload) { + return { success: false, message: "No update reminder is ready yet." }; + } + + clearDeferredReminderTimer(); + installAfterDownloadRequested = false; + clearVisibleUpdateToast(sendToRenderer); + deferredReminderTimer = setTimeout(() => { + const nextPayload = getReminderPayload(); + if (!nextPayload) { + return; + } + + if (sendToRenderer && emitUpdateToastState(sendToRenderer, nextPayload)) { + return; + } + + if (nextPayload.phase === "ready") { + void showDownloadedUpdateDialog(getMainWindow, nextPayload.version); + return; + } + + void showAvailableUpdateDialog(getMainWindow, nextPayload.version, sendToRenderer); + }, delayMs); + + return { success: true }; +} + +export function skipAvailableUpdateVersion(sendToRenderer?: UpdateToastSender) { + const versionToSkip = pendingDownloadedVersion ?? availableVersion; + if (!versionToSkip) { + return { success: false, message: "No update is available to skip." }; + } + + skippedVersion = versionToSkip; + if (pendingDownloadedVersion === versionToSkip) { + pendingDownloadedVersion = null; + } + if (availableVersion === versionToSkip) { + availableVersion = null; + } + downloadInProgress = false; + downloadToastDismissed = false; + installAfterDownloadRequested = false; + clearDeferredReminderTimer(); + clearVisibleUpdateToast(sendToRenderer); + + return { success: true }; +} + +export function previewUpdateToast(sendToRenderer: UpdateToastSender) { + clearDeferredReminderTimer(); + clearDevPreviewProgressTimer(); + availableVersion = DEV_UPDATE_PREVIEW_VERSION; + pendingDownloadedVersion = null; + downloadInProgress = false; + downloadToastDismissed = false; + installAfterDownloadRequested = false; + return emitUpdateToastState(sendToRenderer, { + version: DEV_UPDATE_PREVIEW_VERSION, + phase: "available", + detail: DEV_UPDATE_PREVIEW_IS_EXPERIMENTAL + ? EXPERIMENTAL_UPDATE_DESCRIPTION + : "This is a development preview of the in-app update toast.", + delayMs: UPDATE_REMINDER_DELAY_MS, + isPreview: true, + isExperimental: DEV_UPDATE_PREVIEW_IS_EXPERIMENTAL, + }); +} + +async function showAvailableUpdateDialog( + getMainWindow: () => BrowserWindow | null, + version: string, + sendToRenderer?: UpdateToastSender, +) { + const result = await showMessageBox(getMainWindow, { + type: "info", + title: "Update Available", + message: `Recordly ${version} is available.`, + detail: "Install and restart now, or remind me later.", + buttons: ["Install & Restart", "Later"], + defaultId: 0, + cancelId: 1, + noLink: true, + }); + + if (result.response === 0) { + await downloadAvailableUpdate(sendToRenderer, { installAfterDownload: true }); + return; + } + + deferUpdateReminder(getMainWindow, sendToRenderer, UPDATE_REMINDER_DELAY_MS); +} + +async function showDownloadedUpdateDialog( + getMainWindow: () => BrowserWindow | null, + version: string, + options?: { isPreview?: boolean }, +) { + const isPreview = Boolean(options?.isPreview); + const result = await showMessageBox(getMainWindow, { + type: "info", + title: "Update Ready", + message: isPreview + ? `Recordly ${version} is ready to install.` + : `Recordly ${version} has been downloaded.`, + detail: isPreview + ? "Development preview of the native update prompt. No real update will be installed." + : "Install and restart now, or remind me later.", + buttons: ["Install & Restart", "Later"], + defaultId: 0, + cancelId: 1, + noLink: true, + }); + + if (result.response === 0) { + if (isPreview) { + await showMessageBox(getMainWindow, { + type: "info", + title: "Preview Only", + message: "No real update was installed.", + detail: "This was only a manual development preview of the update prompt.", + }); + return; + } + + clearDeferredReminderTimer(); + setImmediate(() => { + installDownloadedUpdateNow(); + }); + return; + } + + if (result.response === 1) { + if (isPreview) { + return; + } + + deferUpdateReminder(getMainWindow, undefined, UPDATE_REMINDER_DELAY_MS); + } +} + +export async function checkForAppUpdates( + getMainWindow: () => BrowserWindow | null, + options?: { manual?: boolean }, +) { + if (!canUseAutoUpdates()) { + writeUpdaterLog( + `Skipped update check because auto-updates are unavailable. packaged=${app.isPackaged} mas=${process.mas ? "yes" : "no"} disabled=${AUTO_UPDATES_DISABLED ? "yes" : "no"}`, + ); + if (options?.manual) { + await showMessageBox(getMainWindow, { + type: "info", + title: "Updates Not Enabled", + message: "Auto-updates are only available in packaged releases.", + detail: AUTO_UPDATES_DISABLED + ? "This build disabled auto-updates through RECORDLY_DISABLE_AUTO_UPDATES=1." + : "Development builds do not ship the packaged update metadata required by electron-updater.", + }); + } + return; + } + + if (updateCheckInProgress) { + writeUpdaterLog("Skipped update check because a previous check is still running."); + return; + } + + manualCheckRequested = Boolean(options?.manual); + updateCheckInProgress = true; + applyExperimentalUpdatesPreference(); + setUpdateStatusSummary({ status: "checking", detail: "Checking for updates..." }); + writeUpdaterLog(`Starting ${manualCheckRequested ? "manual" : "automatic"} update check.`); + + try { + await autoUpdater.checkForUpdates(); + writeUpdaterLog("Update check request completed."); + } catch (error) { + updateCheckInProgress = false; + manualCheckRequested = false; + setUpdateStatusSummary({ + status: "error", + availableVersion, + detail: String(error), + }); + writeUpdaterLog("Update check failed.", error); + console.error("Auto-update check failed:", error); + } +} + +export function setupAutoUpdates( + getMainWindow: () => BrowserWindow | null, + sendToRenderer: UpdateToastSender, +) { + if (updaterInitialized) { + return; + } + + if (!canUseAutoUpdates()) { + setUpdateStatusSummary({ status: "idle", availableVersion: null, detail: undefined }); + return; + } + + updaterInitialized = true; + configureUpdateFeed(); + applyExperimentalUpdatesPreference(); + autoUpdater.autoDownload = false; + autoUpdater.autoInstallOnAppQuit = false; + writeUpdaterLog(`Updater initialized. logPath=${UPDATER_LOG_PATH}`); + + autoUpdater.on("checking-for-update", () => { + setUpdateStatusSummary({ + status: "checking", + availableVersion: null, + detail: "Checking for updates...", + }); + writeUpdaterLog("electron-updater emitted checking-for-update."); + }); + + autoUpdater.on("update-available", (info) => { + writeUpdaterLog(`Update available: version=${info.version}`); + updateCheckInProgress = false; + availableVersion = info.version; + pendingDownloadedVersion = null; + downloadInProgress = false; + downloadToastDismissed = false; + installAfterDownloadRequested = false; + setUpdateStatusSummary({ + status: "available", + availableVersion: info.version, + detail: `Recordly ${info.version} is available.`, + }); + if (skippedVersion === info.version) { + manualCheckRequested = false; + return; + } + + const payload = createAvailableUpdateToastPayload(info.version); + if (emitUpdateToastState(sendToRenderer, payload)) { + manualCheckRequested = false; + return; + } + + if (manualCheckRequested) { + void showAvailableUpdateDialog(getMainWindow, info.version, sendToRenderer); + manualCheckRequested = false; + } + }); + + autoUpdater.on("update-not-available", () => { + writeUpdaterLog("No update available."); + updateCheckInProgress = false; + availableVersion = null; + pendingDownloadedVersion = null; + downloadInProgress = false; + downloadToastDismissed = false; + installAfterDownloadRequested = false; + setUpdateStatusSummary({ + status: "up-to-date", + availableVersion: null, + detail: `Recordly ${app.getVersion()} is up to date.`, + }); + clearVisibleUpdateToast(sendToRenderer); + manualCheckRequested = false; + }); + + autoUpdater.on("download-progress", (progress) => { + if (!availableVersion) { + return; + } + + downloadInProgress = true; + setUpdateStatusSummary({ + status: "downloading", + availableVersion, + detail: `Downloading Recordly ${availableVersion}`, + }); + writeUpdaterLog( + `Download progress for ${availableVersion}: ${progress.percent.toFixed(1)}%`, + ); + if (downloadToastDismissed) { + return; + } + + emitUpdateToastState( + sendToRenderer, + createDownloadingUpdateToastPayload(availableVersion, { + progressPercent: progress.percent, + transferredBytes: progress.transferred, + totalBytes: progress.total, + bytesPerSecond: progress.bytesPerSecond, + }), + ); + }); + + autoUpdater.on("error", (error) => { + updateCheckInProgress = false; + manualCheckRequested = false; + setUpdateStatusSummary({ + status: "error", + availableVersion, + detail: String(error), + }); + writeUpdaterLog("electron-updater emitted error.", error); + console.error("Auto-updater error:", error); + if (downloadInProgress && availableVersion) { + downloadInProgress = false; + downloadToastDismissed = false; + installAfterDownloadRequested = false; + emitUpdateToastState( + sendToRenderer, + createUpdateErrorToastPayload(availableVersion, error), + ); + } + }); + + autoUpdater.on("update-downloaded", (info) => { + writeUpdaterLog(`Update downloaded: version=${info.version}`); + updateCheckInProgress = false; + manualCheckRequested = false; + downloadInProgress = false; + downloadToastDismissed = false; + if (skippedVersion === info.version) { + installAfterDownloadRequested = false; + return; + } + availableVersion = info.version; + pendingDownloadedVersion = info.version; + setUpdateStatusSummary({ + status: "ready", + availableVersion: info.version, + detail: `Recordly ${info.version} is ready to install.`, + }); + clearDeferredReminderTimer(); + + if (installAfterDownloadRequested && !currentToastPayload?.isPreview) { + installAfterDownloadRequested = false; + clearVisibleUpdateToast(sendToRenderer); + writeUpdaterLog(`Auto-installing downloaded update: version=${info.version}`); + setImmediate(() => { + installDownloadedUpdateNow(sendToRenderer); + }); + return; + } + + if ( + emitUpdateToastState(sendToRenderer, createDownloadedUpdateToastPayload(info.version)) + ) { + return; + } + + void showDownloadedUpdateDialog(getMainWindow, info.version); + }); + + void checkForAppUpdates(getMainWindow); + periodicCheckTimer = setInterval(() => { + void checkForAppUpdates(getMainWindow); + }, UPDATE_CHECK_INTERVAL_MS); + + app.on("before-quit", () => { + clearDeferredReminderTimer(); + clearDevPreviewProgressTimer(); + if (periodicCheckTimer) { + clearInterval(periodicCheckTimer); + periodicCheckTimer = null; + } + }); +} diff --git a/electron/windows.ts b/electron/windows.ts index cf6366974..bb3d6d071 100644 --- a/electron/windows.ts +++ b/electron/windows.ts @@ -1,118 +1,1074 @@ -import { BrowserWindow, screen } from 'electron' -import path from 'node:path' -import { fileURLToPath } from 'node:url' +import fs from "node:fs"; +import { createRequire } from "node:module"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { app, BrowserWindow, ipcMain } from "electron"; +import { USER_DATA_PATH } from "./appPaths"; +import { + getHudOverlayWindowBounds, + resizeHudOverlayFallbackBounds, + shouldExpandHudOverlayFallback, +} from "./hudOverlayBounds"; +import { getHudOverlayTaskbarOptions } from "./hudOverlayWindowOptions"; +import { getPackagedRendererBaseUrl } from "./rendererServer"; -const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const electronWindowsDir = path.dirname(fileURLToPath(import.meta.url)); +const nodeRequire = createRequire(import.meta.url); -const APP_ROOT = path.join(__dirname, '..') -const VITE_DEV_SERVER_URL = process.env['VITE_DEV_SERVER_URL'] -const RENDERER_DIST = path.join(APP_ROOT, 'dist') +const APP_ROOT = path.join(electronWindowsDir, ".."); +const VITE_DEV_SERVER_URL = process.env["VITE_DEV_SERVER_URL"]; +const RENDERER_DIST = path.join(APP_ROOT, "dist"); +const WINDOW_ICON_FILENAME = + process.platform === "darwin" ? "recordlymac-512.png" : "recordly-512.png"; +const WINDOW_ICON_PATH = path.join( + process.env.VITE_PUBLIC || RENDERER_DIST, + "app-icons", + WINDOW_ICON_FILENAME, +); + +let hudOverlayWindow: BrowserWindow | null = null; +let hudOverlayHiddenFromCapture = true; +let hudOverlayCaptureProtectionLoaded = false; +let hudOverlayFallbackExpanded = false; +let hudOverlayIgnoringMouse = true; +let hudOverlaySourceSelectionActive = false; +let hudOverlayMouseReassertTimer: NodeJS.Timeout | null = null; +let hudOverlayRecordingActive = false; +let hudOverlayWebcamPreviewVisible = false; +let countdownWindow: BrowserWindow | null = null; +let updateToastWindow: BrowserWindow | null = null; +let hudWasVisibleBeforeUpdateToast = false; + +const HUD_OVERLAY_SETTINGS_FILE = path.join(USER_DATA_PATH, "hud-overlay-settings.json"); +const HUD_EDGE_MARGIN_DIP = 16; +const UPDATE_TOAST_WIDTH = 420; +const UPDATE_TOAST_HEIGHT = 172; + +function getEditorWindowQuery(): Record { + const query: Record = { + windowType: "editor", + }; + + if (process.env.RECORDLY_DEV_OPEN_RECORDING_INPUT) { + query.devOpenInput = process.env.RECORDLY_DEV_OPEN_RECORDING_INPUT; + } + if (process.env.RECORDLY_DEV_OPEN_RECORDING_WEBCAM) { + query.devOpenWebcam = process.env.RECORDLY_DEV_OPEN_RECORDING_WEBCAM; + } + + if (process.env.RECORDLY_SMOKE_EXPORT === "1") { + query.smokeExport = "1"; + if (process.env.RECORDLY_SMOKE_EXPORT_INPUT) { + query.smokeInput = process.env.RECORDLY_SMOKE_EXPORT_INPUT; + } + if (process.env.RECORDLY_SMOKE_EXPORT_OUTPUT) { + query.smokeOutput = process.env.RECORDLY_SMOKE_EXPORT_OUTPUT; + } + if (process.env.RECORDLY_SMOKE_EXPORT_USE_NATIVE === "1") { + query.smokeUseNativeExport = "1"; + } + if (process.env.RECORDLY_SMOKE_EXPORT_ENCODING_MODE) { + query.smokeEncodingMode = process.env.RECORDLY_SMOKE_EXPORT_ENCODING_MODE; + } + if (process.env.RECORDLY_SMOKE_EXPORT_SHADOW_INTENSITY) { + query.smokeShadowIntensity = process.env.RECORDLY_SMOKE_EXPORT_SHADOW_INTENSITY; + } + if (process.env.RECORDLY_SMOKE_EXPORT_WEBCAM_INPUT) { + query.smokeWebcamInput = process.env.RECORDLY_SMOKE_EXPORT_WEBCAM_INPUT; + } + if (process.env.RECORDLY_SMOKE_EXPORT_WEBCAM_SHADOW) { + query.smokeWebcamShadow = process.env.RECORDLY_SMOKE_EXPORT_WEBCAM_SHADOW; + } + if (process.env.RECORDLY_SMOKE_EXPORT_WEBCAM_SIZE) { + query.smokeWebcamSize = process.env.RECORDLY_SMOKE_EXPORT_WEBCAM_SIZE; + } + if (process.env.RECORDLY_SMOKE_EXPORT_PIPELINE) { + query.smokePipelineModel = process.env.RECORDLY_SMOKE_EXPORT_PIPELINE; + } + if (process.env.RECORDLY_SMOKE_EXPORT_BACKEND) { + query.smokeBackendPreference = process.env.RECORDLY_SMOKE_EXPORT_BACKEND; + } + if (process.env.RECORDLY_SMOKE_EXPORT_RENDER_BACKEND) { + query.smokeRenderBackend = process.env.RECORDLY_SMOKE_EXPORT_RENDER_BACKEND; + } + if (process.env.RECORDLY_SMOKE_EXPORT_MAX_ENCODE_QUEUE) { + query.smokeMaxEncodeQueue = process.env.RECORDLY_SMOKE_EXPORT_MAX_ENCODE_QUEUE; + } + if (process.env.RECORDLY_SMOKE_EXPORT_MAX_DECODE_QUEUE) { + query.smokeMaxDecodeQueue = process.env.RECORDLY_SMOKE_EXPORT_MAX_DECODE_QUEUE; + } + if (process.env.RECORDLY_SMOKE_EXPORT_MAX_PENDING_FRAMES) { + query.smokeMaxPendingFrames = process.env.RECORDLY_SMOKE_EXPORT_MAX_PENDING_FRAMES; + } + if (process.env.RECORDLY_SMOKE_EXPORT_PROJECT) { + query.smokeProject = process.env.RECORDLY_SMOKE_EXPORT_PROJECT; + } + if (process.env.RECORDLY_SMOKE_EXPORT_QUALITY) { + query.smokeQuality = process.env.RECORDLY_SMOKE_EXPORT_QUALITY; + } + if (process.env.RECORDLY_SMOKE_EXPORT_FPS) { + query.smokeFps = process.env.RECORDLY_SMOKE_EXPORT_FPS; + } + } + + return query; +} + +function isHudOverlayCaptureProtectionSupported(): boolean { + return process.platform !== "linux"; +} + +export function isHudOverlayMousePassthroughSupported(): boolean { + return process.platform !== "linux"; +} + +function loadHudOverlayCaptureProtectionSetting(): boolean { + if (hudOverlayCaptureProtectionLoaded) { + return hudOverlayHiddenFromCapture; + } + + hudOverlayCaptureProtectionLoaded = true; + + try { + if (!fs.existsSync(HUD_OVERLAY_SETTINGS_FILE)) { + return hudOverlayHiddenFromCapture; + } + + const raw = fs.readFileSync(HUD_OVERLAY_SETTINGS_FILE, "utf-8"); + const parsed = JSON.parse(raw) as { hiddenFromCapture?: unknown }; + if (typeof parsed.hiddenFromCapture === "boolean") { + hudOverlayHiddenFromCapture = parsed.hiddenFromCapture; + } + } catch { + // Ignore settings read failures and fall back to defaults. + } + + return hudOverlayHiddenFromCapture; +} + +function persistHudOverlayCaptureProtectionSetting(enabled: boolean): void { + try { + fs.writeFileSync( + HUD_OVERLAY_SETTINGS_FILE, + JSON.stringify({ hiddenFromCapture: enabled }, null, 2), + "utf-8", + ); + } catch { + // Ignore settings write failures and keep runtime state working. + } +} + +function getScreen() { + if (!app.isReady()) { + throw new Error( + "getScreen() called before app is ready. Ensure all screen access happens after app.whenReady().", + ); + } + return nodeRequire("electron").screen as typeof import("electron").screen; +} + +function getHudOverlayDisplay() { + const hudWindow = getHudOverlayWindow(); + if (hudWindow) { + return getScreen().getDisplayMatching(hudWindow.getBounds()); + } + return getScreen().getPrimaryDisplay(); +} + +function getHudOverlayBounds() { + const { workArea } = getHudOverlayDisplay(); + const fallbackExpanded = shouldExpandHudOverlayFallback({ + fallbackExpanded: hudOverlayFallbackExpanded, + recordingActive: hudOverlayRecordingActive, + webcamPreviewVisible: hudOverlayWebcamPreviewVisible, + }); + return getHudOverlayWindowBounds( + workArea, + isHudOverlayMousePassthroughSupported(), + fallbackExpanded, + ); +} + +function applyHudOverlayBounds() { + if (!hudOverlayWindow || hudOverlayWindow.isDestroyed()) { + return; + } + hudOverlayWindow.setBounds(getHudOverlayBounds(), false); + + positionUpdateToastWindow(); + if (!hudOverlayWindow.isVisible()) { + return; + } + hudOverlayWindow.moveTop(); +} + +function getUpdateToastBounds() { + const hudWindow = getHudOverlayWindow(); + if (hudWindow) { + const hudBounds = hudWindow.getBounds(); + const display = getScreen().getDisplayMatching(hudBounds); + const { workArea } = display; + const x = Math.round(workArea.x + (workArea.width - UPDATE_TOAST_WIDTH) / 2); + const y = Math.round( + workArea.y + workArea.height - UPDATE_TOAST_HEIGHT - HUD_EDGE_MARGIN_DIP, + ); + + return { + x, + y, + width: UPDATE_TOAST_WIDTH, + height: UPDATE_TOAST_HEIGHT, + }; + } + + const primaryDisplay = getScreen().getPrimaryDisplay(); + const { workArea } = primaryDisplay; + return { + x: Math.round(workArea.x + (workArea.width - UPDATE_TOAST_WIDTH) / 2), + y: Math.round(workArea.y + workArea.height - UPDATE_TOAST_HEIGHT - HUD_EDGE_MARGIN_DIP), + width: UPDATE_TOAST_WIDTH, + height: UPDATE_TOAST_HEIGHT, + }; +} + +function positionUpdateToastWindow() { + if (!updateToastWindow || updateToastWindow.isDestroyed()) { + return; + } + + updateToastWindow.setBounds(getUpdateToastBounds(), false); + updateToastWindow.moveTop(); +} + +function setHudOverlayFallbackExpanded(expanded: boolean) { + if (hudOverlayRecordingActive) { + hudOverlayFallbackExpanded = false; + return; + } + + hudOverlayFallbackExpanded = expanded; + if ( + !hudOverlayWindow || + hudOverlayWindow.isDestroyed() || + isHudOverlayMousePassthroughSupported() + ) { + return; + } + + const { workArea } = getHudOverlayDisplay(); + const nextBounds = resizeHudOverlayFallbackBounds( + workArea, + hudOverlayWindow.getBounds(), + expanded, + ); + hudOverlayWindow.setBounds(nextBounds, false); + positionUpdateToastWindow(); + if (hudOverlayWindow.isVisible()) { + hudOverlayWindow.moveTop(); + } +} + +function setHudOverlayMousePassthrough(ignore: boolean) { + hudOverlayIgnoringMouse = + hudOverlaySourceSelectionActive && !hudOverlayRecordingActive ? true : ignore; + + if (hudOverlayMouseReassertTimer) { + clearTimeout(hudOverlayMouseReassertTimer); + hudOverlayMouseReassertTimer = null; + } + + if (!hudOverlayWindow || hudOverlayWindow.isDestroyed()) { + return; + } + + if (hudOverlayRecordingActive) { + hudOverlayFallbackExpanded = false; + applyHudOverlayBounds(); + } + + if (!isHudOverlayMousePassthroughSupported()) { + if (process.platform !== "linux") { + setHudOverlayFallbackExpanded(!ignore); + } + hudOverlayWindow.setIgnoreMouseEvents(false); + return; + } + + if (ignore) { + hudOverlayWindow.setIgnoreMouseEvents(true, { forward: true }); + return; + } + + hudOverlayWindow.setIgnoreMouseEvents(false); +} + +ipcMain.on("hud-overlay-set-ignore-mouse", (_event, ignore: boolean) => { + setHudOverlayMousePassthrough(Boolean(ignore)); +}); + +ipcMain.on("hud-overlay-set-source-selection-active", (_event, active: boolean) => { + hudOverlaySourceSelectionActive = Boolean(active); + if (hudOverlaySourceSelectionActive) { + hudOverlayFallbackExpanded = false; + applyHudOverlayBounds(); + return; + } + + setHudOverlayMousePassthrough(hudOverlayIgnoringMouse); +}); + +// Keep compatibility with existing drag IPC/state. +let hudUserPosition: { x: number; y: number } | null = null; +let hudDragOffset: { x: number; y: number } | null = null; +let hudDragLastCursor: { x: number; y: number } | null = null; +let hudDragFixedSize: { width: number; height: number } | null = null; + +ipcMain.on("hud-overlay-drag", (_event, phase: string, screenX: number, screenY: number) => { + if (!hudOverlayWindow || hudOverlayWindow.isDestroyed()) return; + + // On Linux the compositor (especially Wayland) refuses programmatic window + // placement, so BrowserWindow.setBounds() with x/y is silently ignored and + // the HUD appears "stuck". The renderer marks the drag handle as + // -webkit-app-region: drag on Linux, letting the OS move the window for us. + // The resulting position is captured by the win.on("moved", ...) listener + // below so `hudUserPosition` stays in sync. + if (process.platform === "linux") { + return; + } + + if (phase === "start") { + const bounds = hudOverlayWindow.getBounds(); + hudDragOffset = { x: screenX - bounds.x, y: screenY - bounds.y }; + hudDragLastCursor = { x: screenX, y: screenY }; + hudDragFixedSize = { width: bounds.width, height: bounds.height }; + } else if (phase === "move" && hudDragOffset) { + if ( + hudDragLastCursor && + hudDragLastCursor.x === screenX && + hudDragLastCursor.y === screenY + ) { + return; + } + + hudDragLastCursor = { x: screenX, y: screenY }; + const targetX = Math.round(screenX - hudDragOffset.x); + const targetY = Math.round(screenY - hudDragOffset.y); + const fixedWidth = hudDragFixedSize?.width ?? hudOverlayWindow.getBounds().width; + const fixedHeight = hudDragFixedSize?.height ?? hudOverlayWindow.getBounds().height; + hudOverlayWindow.setBounds( + { + x: targetX, + y: targetY, + width: fixedWidth, + height: fixedHeight, + }, + false, + ); + } else if (phase === "end") { + const finalBounds = hudOverlayWindow.getBounds(); + hudUserPosition = { x: finalBounds.x, y: finalBounds.y }; + + hudDragOffset = null; + hudDragLastCursor = null; + hudDragFixedSize = null; + } +}); + +ipcMain.on("hud-overlay-hide", () => { + if (hudOverlayWindow && !hudOverlayWindow.isDestroyed()) { + hudOverlayWindow.minimize(); + } +}); + +ipcMain.handle("get-hud-overlay-capture-protection", () => { + const enabled = loadHudOverlayCaptureProtectionSetting(); + + return { + success: true, + enabled, + }; +}); + +ipcMain.handle("get-hud-overlay-mouse-passthrough-supported", () => { + return { + success: true, + supported: isHudOverlayMousePassthroughSupported(), + }; +}); + +ipcMain.on("hud-overlay-set-webcam-preview-visible", (_event, visible: boolean) => { + const nextVisible = Boolean(visible); + if (hudOverlayWebcamPreviewVisible === nextVisible) { + return; + } + + hudOverlayWebcamPreviewVisible = nextVisible; + if (hudOverlayRecordingActive) { + applyHudOverlayBounds(); + } +}); + +ipcMain.handle("set-hud-overlay-capture-protection", (_event, enabled: boolean) => { + loadHudOverlayCaptureProtectionSetting(); + hudOverlayHiddenFromCapture = Boolean(enabled); + persistHudOverlayCaptureProtectionSetting(hudOverlayHiddenFromCapture); + + if ( + isHudOverlayCaptureProtectionSupported() && + hudOverlayWindow && + !hudOverlayWindow.isDestroyed() + ) { + hudOverlayWindow.setContentProtection(hudOverlayHiddenFromCapture); + } + + return { + success: true, + enabled: hudOverlayHiddenFromCapture, + }; +}); export function createHudOverlayWindow(): BrowserWindow { - const win = new BrowserWindow({ - width: 250, - height: 80, - minWidth: 250, - maxWidth: 250, - minHeight: 80, - maxHeight: 80, - frame: false, - transparent: true, - resizable: false, - alwaysOnTop: true, - skipTaskbar: true, - hasShadow: false, - webPreferences: { - preload: path.join(__dirname, 'preload.mjs'), - nodeIntegration: false, - contextIsolation: true, - backgroundThrottling: false, - }, - }) - - - win.webContents.on('did-finish-load', () => { - win?.webContents.send('main-process-message', (new Date).toLocaleString()) - }) - - if (VITE_DEV_SERVER_URL) { - win.loadURL(VITE_DEV_SERVER_URL + '?windowType=hud-overlay') - } else { - win.loadFile(path.join(RENDERER_DIST, 'index.html'), { - query: { windowType: 'hud-overlay' } - }) - } - - return win + loadHudOverlayCaptureProtectionSetting(); + hudOverlayFallbackExpanded = false; + hudOverlayWebcamPreviewVisible = false; + const initialBounds = getHudOverlayBounds(); + let hasShownHudWindow = false; + + const win = new BrowserWindow({ + width: initialBounds.width, + height: initialBounds.height, + x: initialBounds.x, + y: initialBounds.y, + frame: false, + transparent: true, + backgroundColor: "#00000000", + resizable: false, + alwaysOnTop: true, + // The HUD is Recordly's persistent top-level window, so it owns the + // Windows taskbar entry while auxiliary overlays stay hidden there. + ...getHudOverlayTaskbarOptions(process.platform), + hasShadow: false, + show: false, + webPreferences: { + preload: path.join(electronWindowsDir, "preload.mjs"), + nodeIntegration: false, + contextIsolation: true, + webSecurity: false, + backgroundThrottling: false, + }, + }); + // Keep the recording controls and webcam above normal and full-screen apps. + // Transparent regions remain click-through via setIgnoreMouseEvents(). + win.setAlwaysOnTop(true, "screen-saver"); + if (process.platform === "darwin") { + win.setVisibleOnAllWorkspaces(true, { + visibleOnFullScreen: true, + }); + } + + const showHudWindow = () => { + if (hasShownHudWindow || win.isDestroyed()) { + return; + } + if ( + updateToastWindow && + !updateToastWindow.isDestroyed() && + updateToastWindow.isVisible() + ) { + hudWasVisibleBeforeUpdateToast = true; + return; + } + hasShownHudWindow = true; + if (process.platform === "win32") { + // A focusable window is required for a Windows taskbar entry, but the + // always-on-top HUD must not steal focus when Recordly starts. + win.showInactive(); + } else { + win.show(); + } + win.moveTop(); + if (process.platform === "win32" && isHudOverlayMousePassthroughSupported()) { + win.setIgnoreMouseEvents(false); + setTimeout(() => { + if (!win.isDestroyed()) { + setHudOverlayMousePassthrough(hudOverlayIgnoringMouse); + } + }, 50); + } + }; + + if (isHudOverlayCaptureProtectionSupported()) { + win.setContentProtection(hudOverlayHiddenFromCapture); + } + + if (isHudOverlayMousePassthroughSupported()) { + if (hudOverlayRecordingActive) { + hudOverlayIgnoringMouse = false; + win.setIgnoreMouseEvents(false); + } else { + hudOverlayIgnoringMouse = true; + win.setIgnoreMouseEvents(true, { forward: true }); + } + } + + // On Windows 11+, focus changes (e.g. showing a native notification) can break + // setIgnoreMouseEvents forwarding on a transparent always-on-top window, making + // it permanently click-through without hover detection. Re-initialise the + // pass-through-with-forwarding state whenever the window gains focus by toggling + // the flag off then back on so the native WS_EX_TRANSPARENT flag is fully reset. + if (process.platform === "win32" && isHudOverlayMousePassthroughSupported()) { + win.on("focus", () => { + if (!win.isDestroyed()) { + win.setIgnoreMouseEvents(false); + setTimeout(() => { + if (!win.isDestroyed()) { + setHudOverlayMousePassthrough(hudOverlayIgnoringMouse); + } + }, 50); + } + }); + } + + win.webContents.on("did-finish-load", () => { + win?.webContents.send("main-process-message", new Date().toLocaleString()); + // Safety fallback if renderer-ready signal never arrives. + setTimeout(() => { + showHudWindow(); + }, 1800); + }); + + // Safety net: on Linux the renderer may fail to fire did-finish-load + // (for example due to GPU/VAAPI startup issues). Show the window after + // ready-to-show as a fallback so the HUD still appears. + win.once("ready-to-show", () => { + setTimeout(() => { + if (!win.isDestroyed() && !win.isVisible()) { + showHudWindow(); + } + }, 500); + }); + + const handleHudRendererReady = () => { + if (!win.isDestroyed()) { + showHudWindow(); + } + }; + ipcMain.on("hud-overlay-renderer-ready", handleHudRendererReady); + + hudOverlayWindow = win; + + // On Linux the HUD is dragged by the OS via -webkit-app-region (Wayland + // forbids client-side positioning). Mirror moved bounds into drag state. + if (process.platform === "linux") { + win.on("moved", () => { + if (win.isDestroyed()) return; + const { x, y } = win.getBounds(); + hudUserPosition = { x, y }; + }); + } + + // Reset the user's saved HUD position when displays change so the bar + // doesn't end up stranded off-screen after a monitor is disconnected. + const screen = getScreen(); + const handleDisplayRemoved = () => { + hudUserPosition = null; + }; + const handleDisplayMetricsChanged = () => { + if (hudUserPosition) { + const displays = screen.getAllDisplays(); + const onScreen = displays.some( + (d) => + hudUserPosition!.x >= d.workArea.x && + hudUserPosition!.x < d.workArea.x + d.workArea.width && + hudUserPosition!.y >= d.workArea.y && + hudUserPosition!.y < d.workArea.y + d.workArea.height, + ); + if (!onScreen) { + hudUserPosition = null; + } + } + applyHudOverlayBounds(); + }; + screen.on("display-removed", handleDisplayRemoved); + screen.on("display-metrics-changed", handleDisplayMetricsChanged); + + win.on("closed", () => { + ipcMain.removeListener("hud-overlay-renderer-ready", handleHudRendererReady); + screen.removeListener("display-removed", handleDisplayRemoved); + screen.removeListener("display-metrics-changed", handleDisplayMetricsChanged); + if (hudOverlayWindow === win) { + hudOverlayWindow = null; + } + }); + + if (VITE_DEV_SERVER_URL) { + win.loadURL(VITE_DEV_SERVER_URL + "?windowType=hud-overlay"); + } else { + win.loadFile(path.join(RENDERER_DIST, "index.html"), { + query: { windowType: "hud-overlay" }, + }); + } + + return win; +} + +export function getHudOverlayWindow(): BrowserWindow | null { + return hudOverlayWindow && !hudOverlayWindow.isDestroyed() ? hudOverlayWindow : null; +} + +/** + * Re-initialise the HUD overlay's mouse passthrough state. + * + * On Windows 11+, any new BrowserWindow appearing (even focusable:false ones + * like the source highlight overlay) can silently corrupt the + * WS_EX_TRANSPARENT flag that backs setIgnoreMouseEvents forwarding. Call + * this after any operation that creates or destroys a sibling window so that + * hover detection on the HUD is immediately restored without requiring the + * user to move their mouse over the bar. + */ +export function reassertHudOverlayMousePassthrough(): void { + if (process.platform !== "win32" || !isHudOverlayMousePassthroughSupported()) { + return; + } + + const hud = getHudOverlayWindow(); + if (!hud) { + return; + } + + // Toggle off then back on so the native WS_EX_TRANSPARENT flag is fully + // re-initialised rather than merely re-asserted in a potentially broken state. + hud.setIgnoreMouseEvents(false); + if (hudOverlayMouseReassertTimer) { + clearTimeout(hudOverlayMouseReassertTimer); + } + hudOverlayMouseReassertTimer = setTimeout(() => { + hudOverlayMouseReassertTimer = null; + if (!hud.isDestroyed()) { + setHudOverlayMousePassthrough(hudOverlayIgnoringMouse); + } + }, 50); +} + +export function setHudOverlayRecordingActive(recording: boolean): void { + hudOverlayRecordingActive = Boolean(recording); + hudOverlayFallbackExpanded = false; + applyHudOverlayBounds(); + // Start in passthrough mode. Forwarded pointer movement lets the renderer + // make the visible HUD controls interactive when the pointer reaches them, + // while transparent parts never block the recorded application. + setHudOverlayMousePassthrough(true); +} + +export function createUpdateToastWindow(): BrowserWindow { + const initialBounds = getUpdateToastBounds(); + + const win = new BrowserWindow({ + width: initialBounds.width, + height: initialBounds.height, + x: initialBounds.x, + y: initialBounds.y, + frame: false, + transparent: true, + resizable: false, + alwaysOnTop: true, + skipTaskbar: true, + hasShadow: false, + show: false, + focusable: true, + backgroundColor: "#00000000", + webPreferences: { + preload: path.join(electronWindowsDir, "preload.mjs"), + nodeIntegration: false, + contextIsolation: true, + backgroundThrottling: false, + }, + }); + + if (process.platform === "darwin") { + win.setAlwaysOnTop(true, "status"); + } + + win.setVisibleOnAllWorkspaces(true, { + visibleOnFullScreen: true, + // Keep Recordly a foreground application so macOS does not temporarily + // remove its Dock icon while showing an overlay window. + skipTransformProcessType: process.platform === "darwin", + }); + updateToastWindow = win; + + win.on("closed", () => { + if (updateToastWindow === win) { + updateToastWindow = null; + } + restoreHudAfterUpdateToast(); + }); + + if (VITE_DEV_SERVER_URL) { + win.loadURL(VITE_DEV_SERVER_URL + "?windowType=update-toast"); + } else { + win.loadFile(path.join(RENDERER_DIST, "index.html"), { + query: { windowType: "update-toast" }, + }); + } + + return win; +} + +export function getUpdateToastWindow(): BrowserWindow | null { + return updateToastWindow && !updateToastWindow.isDestroyed() ? updateToastWindow : null; +} + +export function showUpdateToastWindow(): BrowserWindow { + const win = getUpdateToastWindow() ?? createUpdateToastWindow(); + const hud = getHudOverlayWindow(); + if (!win.isVisible()) { + hudWasVisibleBeforeUpdateToast = Boolean(hud?.isVisible()); + } + if (hud?.isVisible()) { + hud.hide(); + } + positionUpdateToastWindow(); + if (!win.isVisible()) { + if (process.platform === "win32") { + win.show(); + } else { + win.showInactive(); + } + } + win.moveTop(); + + return win; +} + +function restoreHudAfterUpdateToast(): void { + if (!hudWasVisibleBeforeUpdateToast) { + return; + } + + hudWasVisibleBeforeUpdateToast = false; + const hud = getHudOverlayWindow(); + if (!hud) { + return; + } + + if (process.platform === "win32") { + hud.showInactive(); + } else { + hud.show(); + } + hud.moveTop(); + setHudOverlayMousePassthrough(hudOverlayIgnoringMouse); +} + +export function hideUpdateToastWindow(): void { + if (updateToastWindow && !updateToastWindow.isDestroyed()) { + updateToastWindow.hide(); + } + restoreHudAfterUpdateToast(); +} + +function loadPackagedEditorWindow(win: BrowserWindow) { + const query = getEditorWindowQuery(); + const queryString = new URLSearchParams(query).toString(); + const indexHtmlPath = path.join(RENDERER_DIST, "index.html"); + const packagedRendererBaseUrl = getPackagedRendererBaseUrl(); + const webContents = win.webContents; + + const loadFromFile = () => { + if (win.isDestroyed()) { + return; + } + + console.log("[editor-window] load-file", indexHtmlPath); + void win.loadFile(indexHtmlPath, { query }); + }; + + if (!packagedRendererBaseUrl) { + loadFromFile(); + return; + } + + const targetUrl = `${packagedRendererBaseUrl}/?${queryString}`; + let settled = false; + let timeoutId: NodeJS.Timeout | null = setTimeout(() => { + fallbackToFile("load-timeout"); + }, 5000); + + const clearTimeoutIfNeeded = () => { + if (timeoutId) { + clearTimeout(timeoutId); + timeoutId = null; + } + }; + + const detachLoadListeners = () => { + clearTimeoutIfNeeded(); + if (webContents.isDestroyed()) { + return; + } + + webContents.removeListener("did-fail-load", handleDidFailLoad); + webContents.removeListener("did-finish-load", handleDidFinishLoad); + }; + + const fallbackToFile = (reason: string, details?: Record) => { + if (settled || win.isDestroyed()) { + return; + } + + settled = true; + detachLoadListeners(); + console.warn("[editor-window] packaged renderer URL failed, falling back to file", { + reason, + targetUrl, + ...details, + }); + loadFromFile(); + }; + + const handleDidFailLoad = ( + _event: Electron.Event, + errorCode: number, + errorDescription: string, + validatedURL: string, + isMainFrame: boolean, + ) => { + if (!isMainFrame || validatedURL !== targetUrl) { + return; + } + + fallbackToFile("did-fail-load", { + errorCode, + errorDescription, + validatedURL, + }); + }; + + const handleDidFinishLoad = () => { + if (webContents.getURL() !== targetUrl) { + return; + } + + settled = true; + detachLoadListeners(); + }; + + webContents.on("did-fail-load", handleDidFailLoad); + webContents.on("did-finish-load", handleDidFinishLoad); + win.once("closed", clearTimeoutIfNeeded); + + console.log("[editor-window] load-url", targetUrl); + void win.loadURL(targetUrl).catch((error) => { + fallbackToFile("load-url-rejected", { + error: error instanceof Error ? error.message : String(error), + }); + }); } export function createEditorWindow(): BrowserWindow { - const win = new BrowserWindow({ - width: 1200, - height: 800, - minWidth: 800, - minHeight: 600, - frame: true, - transparent: false, - resizable: true, - alwaysOnTop: false, - skipTaskbar: false, - title: '', - webPreferences: { - preload: path.join(__dirname, 'preload.mjs'), - nodeIntegration: false, - contextIsolation: true, - webSecurity: false, - }, - }) - - // Maximize the window by default - win.maximize(); - - win.webContents.on('did-finish-load', () => { - win?.webContents.send('main-process-message', (new Date).toLocaleString()) - }) - - if (VITE_DEV_SERVER_URL) { - win.loadURL(VITE_DEV_SERVER_URL + '?windowType=editor') - } else { - win.loadFile(path.join(RENDERER_DIST, 'index.html'), { - query: { windowType: 'editor' } - }) - } - - return win + const perfStart = Date.now(); + console.log("[PERF:MAIN] createEditorWindow: STARTED"); + const isMac = process.platform === "darwin"; + const { workArea, workAreaSize } = getScreen().getPrimaryDisplay(); + const initialWidth = isMac ? Math.round(workAreaSize.width * 0.85) : workArea.width; + const initialHeight = isMac ? Math.round(workAreaSize.height * 0.85) : workArea.height; + + const win = new BrowserWindow({ + width: initialWidth, + height: initialHeight, + ...(!isMac && { + x: workArea.x, + y: workArea.y, + }), + minWidth: 800, + minHeight: 600, + ...(process.platform !== "darwin" && { + icon: WINDOW_ICON_PATH, + }), + ...(isMac && { + titleBarStyle: "hiddenInset", + trafficLightPosition: { x: 12, y: 12 }, + }), + autoHideMenuBar: !isMac, + transparent: false, + resizable: true, + alwaysOnTop: false, + skipTaskbar: false, + title: "Recordly", + show: false, + backgroundColor: "#000000", + webPreferences: { + preload: path.join(electronWindowsDir, "preload.mjs"), + nodeIntegration: false, + contextIsolation: true, + webSecurity: false, + backgroundThrottling: false, + }, + }); + + win.once("ready-to-show", () => { + console.log(`[PERF:MAIN] Editor Window: ready-to-show in ${Date.now() - perfStart}ms`); + win.show(); + }); + + win.webContents.on("did-finish-load", () => { + console.log(`[PERF:MAIN] Editor Window: did-finish-load in ${Date.now() - perfStart}ms`); + win?.webContents.send("main-process-message", new Date().toLocaleString()); + // Fallback for Linux/Wayland where `ready-to-show` may not fire reliably. + if (!win.isDestroyed() && !win.isVisible()) { + console.log("[editor-window] forcing show after did-finish-load"); + win.show(); + } + }); + + win.webContents.on("did-fail-load", (_event, errorCode, errorDescription, validatedURL) => { + console.error("[editor-window] did-fail-load", { + errorCode, + errorDescription, + validatedURL, + }); + }); + + win.webContents.on("render-process-gone", (_event, details) => { + console.error("[editor-window] render-process-gone", details); + }); + + win.on("show", () => { + console.log("[editor-window] show"); + }); + + win.on("focus", () => { + console.log("[editor-window] focus"); + }); + + if (VITE_DEV_SERVER_URL) { + const query = new URLSearchParams(getEditorWindowQuery()); + win.loadURL(`${VITE_DEV_SERVER_URL}?${query.toString()}`); + } else { + loadPackagedEditorWindow(win); + } + + return win; } export function createSourceSelectorWindow(): BrowserWindow { - const { width, height } = screen.getPrimaryDisplay().workAreaSize - - const win = new BrowserWindow({ - width: 620, - height: 420, - minHeight: 350, - maxHeight: 500, - x: Math.round((width - 620) / 2), - y: Math.round((height - 420) / 2), - frame: false, - resizable: false, - alwaysOnTop: true, - transparent: true, - backgroundColor: '#00000000', - webPreferences: { - preload: path.join(__dirname, 'preload.mjs'), - nodeIntegration: false, - contextIsolation: true, - }, - }) - - if (VITE_DEV_SERVER_URL) { - win.loadURL(VITE_DEV_SERVER_URL + '?windowType=source-selector') - } else { - win.loadFile(path.join(RENDERER_DIST, 'index.html'), { - query: { windowType: 'source-selector' } - }) - } - - return win + const { width, height } = getScreen().getPrimaryDisplay().workAreaSize; + + const win = new BrowserWindow({ + width: 620, + height: 420, + minHeight: 350, + maxHeight: 500, + x: Math.round((width - 620) / 2), + y: Math.round((height - 420) / 2), + frame: false, + resizable: false, + alwaysOnTop: true, + transparent: true, + show: false, + ...(process.platform !== "darwin" && { + icon: WINDOW_ICON_PATH, + }), + backgroundColor: "#00000000", + webPreferences: { + preload: path.join(electronWindowsDir, "preload.mjs"), + nodeIntegration: false, + contextIsolation: true, + }, + }); + + win.webContents.on("did-finish-load", () => { + setTimeout(() => { + if (!win.isDestroyed()) { + win.show(); + } + }, 100); + }); + + if (VITE_DEV_SERVER_URL) { + win.loadURL(VITE_DEV_SERVER_URL + "?windowType=source-selector"); + } else { + win.loadFile(path.join(RENDERER_DIST, "index.html"), { + query: { windowType: "source-selector" }, + }); + } + + return win; +} + +export function createCountdownWindow(): BrowserWindow { + const primaryDisplay = getScreen().getPrimaryDisplay(); + const { width, height } = primaryDisplay.workAreaSize; + + const windowSize = 200; + const x = Math.floor((width - windowSize) / 2); + const y = Math.floor((height - windowSize) / 2); + + const win = new BrowserWindow({ + width: windowSize, + height: windowSize, + x: x, + y: y, + frame: false, + transparent: true, + resizable: false, + alwaysOnTop: true, + skipTaskbar: true, + hasShadow: false, + focusable: true, + show: false, + webPreferences: { + preload: path.join(electronWindowsDir, "preload.mjs"), + nodeIntegration: false, + contextIsolation: true, + }, + }); + + countdownWindow = win; + + win.setVisibleOnAllWorkspaces(true, { + visibleOnFullScreen: true, + // Keep Recordly a foreground application so macOS does not temporarily + // remove its Dock icon while showing the countdown. + skipTransformProcessType: process.platform === "darwin", + }); + + win.webContents.on("did-finish-load", () => { + if (!win.isDestroyed()) { + if (process.platform === "win32") { + win.showInactive(); + win.moveTop(); + } else { + win.show(); + } + } + }); + + win.on("closed", () => { + if (countdownWindow === win) { + countdownWindow = null; + } + }); + + if (VITE_DEV_SERVER_URL) { + win.loadURL(VITE_DEV_SERVER_URL + "?windowType=countdown"); + } else { + win.loadFile(path.join(RENDERER_DIST, "index.html"), { + query: { windowType: "countdown" }, + }); + } + + return win; +} + +export function getCountdownWindow(): BrowserWindow | null { + return countdownWindow; +} + +export function closeCountdownWindow(): void { + if (countdownWindow && !countdownWindow.isDestroyed()) { + countdownWindow.close(); + countdownWindow = null; + } } diff --git a/electron/windowsCmakeGenerators.test.mjs b/electron/windowsCmakeGenerators.test.mjs new file mode 100644 index 000000000..2f651ba92 --- /dev/null +++ b/electron/windowsCmakeGenerators.test.mjs @@ -0,0 +1,66 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + configureWithWindowsCmakeGenerator, + WINDOWS_CMAKE_GENERATORS, + WINDOWS_VISUAL_STUDIO_INSTALL_DIRS, +} from "../scripts/windows-cmake-generators.mjs"; + +describe("configureWithWindowsCmakeGenerator", () => { + it("uses Visual Studio 2026 first", () => { + const configure = vi.fn(); + const clearCache = vi.fn(); + + const selected = configureWithWindowsCmakeGenerator({ + prefix: "test", + configure, + clearCache, + }); + + expect(selected).toBe("Visual Studio 18 2026"); + expect(WINDOWS_VISUAL_STUDIO_INSTALL_DIRS[0]).toBe("18"); + expect(configure).toHaveBeenCalledWith("Visual Studio 18 2026", "v143"); + expect(clearCache).toHaveBeenCalledTimes(1); + }); + + it("falls back in supported-version order and clears stale CMake state", () => { + const attempted = []; + const clearCache = vi.fn(); + const log = vi.fn(); + + const selected = configureWithWindowsCmakeGenerator({ + prefix: "test", + clearCache, + log, + configure: (generator, toolset) => { + attempted.push({ name: generator, toolset }); + if (generator !== "Visual Studio 16 2019") { + throw new Error(`${generator} unavailable`); + } + }, + }); + + expect(attempted).toEqual( + WINDOWS_CMAKE_GENERATORS.map(({ name, toolset }) => ({ name, toolset })), + ); + expect(clearCache).toHaveBeenCalledTimes(3); + expect(log).toHaveBeenCalledTimes(2); + expect(selected).toBe("Visual Studio 16 2019"); + }); + + it("rethrows the final configure error when no supported generator exists", () => { + const finalError = new Error("VS 2019 unavailable"); + + expect(() => + configureWithWindowsCmakeGenerator({ + prefix: "test", + clearCache: vi.fn(), + log: vi.fn(), + configure: (generator) => { + if (generator === "Visual Studio 16 2019") throw finalError; + throw new Error(`${generator} unavailable`); + }, + }), + ).toThrow(finalError); + }); +}); diff --git a/icons/icons/mac/icon.icns b/icons/icons/mac/icon.icns index bb9f0c497..b9f34ea75 100644 Binary files a/icons/icons/mac/icon.icns and b/icons/icons/mac/icon.icns differ diff --git a/icons/icons/png/1024x1024.png b/icons/icons/png/1024x1024.png index d4536ea02..7cc41c028 100644 Binary files a/icons/icons/png/1024x1024.png and b/icons/icons/png/1024x1024.png differ diff --git a/icons/icons/png/128x128.png b/icons/icons/png/128x128.png index 350021397..82b456e9d 100644 Binary files a/icons/icons/png/128x128.png and b/icons/icons/png/128x128.png differ diff --git a/icons/icons/png/16x16.png b/icons/icons/png/16x16.png index ee106dfdc..e1f45c1f4 100644 Binary files a/icons/icons/png/16x16.png and b/icons/icons/png/16x16.png differ diff --git a/icons/icons/png/24x24.png b/icons/icons/png/24x24.png index 2650b7166..e146b337a 100644 Binary files a/icons/icons/png/24x24.png and b/icons/icons/png/24x24.png differ diff --git a/icons/icons/png/256x256.png b/icons/icons/png/256x256.png index db4cb3d56..0592161ab 100644 Binary files a/icons/icons/png/256x256.png and b/icons/icons/png/256x256.png differ diff --git a/icons/icons/png/32x32.png b/icons/icons/png/32x32.png index 47994b94b..6fb056f8a 100644 Binary files a/icons/icons/png/32x32.png and b/icons/icons/png/32x32.png differ diff --git a/icons/icons/png/48x48.png b/icons/icons/png/48x48.png index 92cd97e6d..48af277bf 100644 Binary files a/icons/icons/png/48x48.png and b/icons/icons/png/48x48.png differ diff --git a/icons/icons/png/512x512.png b/icons/icons/png/512x512.png index 8f2ac4aa8..4016ff592 100644 Binary files a/icons/icons/png/512x512.png and b/icons/icons/png/512x512.png differ diff --git a/icons/icons/png/64x64.png b/icons/icons/png/64x64.png index 7944bc00c..e132d69a4 100644 Binary files a/icons/icons/png/64x64.png and b/icons/icons/png/64x64.png differ diff --git a/icons/icons/win/icon.ico b/icons/icons/win/icon.ico index 5793cce79..52e47c894 100644 Binary files a/icons/icons/win/icon.ico and b/icons/icons/win/icon.ico differ diff --git a/index.html b/index.html index ce1c274aa..c36792f9d 100644 --- a/index.html +++ b/index.html @@ -2,12 +2,13 @@ - + - + Recordly
+ diff --git a/openscreen.png b/openscreen.png deleted file mode 100644 index 7e44bbb80..000000000 Binary files a/openscreen.png and /dev/null differ diff --git a/package-lock.json b/package-lock.json index 23fb3cfbe..40b49f261 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,10407 +1,9859 @@ { - "name": "openscreen", - "version": "0.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "openscreen", - "version": "0.0.0", - "dependencies": { - "@fix-webm-duration/fix": "^1.0.1", - "@pixi/filter-drop-shadow": "^5.2.0", - "@radix-ui/react-dropdown-menu": "^2.1.16", - "@radix-ui/react-slider": "^1.3.6", - "@radix-ui/react-slot": "^1.2.3", - "@radix-ui/react-switch": "^1.2.6", - "@radix-ui/react-tabs": "^1.1.13", - "@uiw/react-color-colorful": "^2.9.0", - "class-variance-authority": "^0.7.1", - "clsx": "^2.1.1", - "dnd-timeline": "^2.2.0", - "lucide-react": "^0.545.0", - "mp4-muxer": "^5.2.2", - "mp4box": "^2.2.0", - "pixi.js": "^8.14.0", - "react": "^18.2.0", - "react-dom": "^18.2.0", - "react-icons": "^5.5.0", - "sonner": "^2.0.7", - "tailwind-merge": "^3.3.1", - "tailwindcss-animate": "^1.0.7", - "uiohook-napi": "^1.5.4" - }, - "devDependencies": { - "@types/react": "^18.2.64", - "@types/react-dom": "^18.2.21", - "@typescript-eslint/eslint-plugin": "^7.1.1", - "@typescript-eslint/parser": "^7.1.1", - "@vitejs/plugin-react": "^4.2.1", - "autoprefixer": "^10.4.21", - "electron": "^30.0.1", - "electron-builder": "^24.13.3", - "electron-rebuild": "^3.2.9", - "eslint": "^8.57.0", - "eslint-plugin-react-hooks": "^4.6.0", - "eslint-plugin-react-refresh": "^0.4.5", - "postcss": "^8.5.6", - "tailwindcss": "^3.4.18", - "typescript": "^5.2.2", - "vite": "^5.1.6", - "vite-plugin-electron": "^0.28.6", - "vite-plugin-electron-renderer": "^0.14.5" - } - }, - "node_modules/@alloc/quick-lru": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", - "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@babel/code-frame": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", - "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^7.27.1", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/compat-data": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.4.tgz", - "integrity": "sha512-YsmSKC29MJwf0gF8Rjjrg5LQCmyh+j/nD8/eP7f+BeoQTKYqs9RoWbjGOdy0+1Ekr68RJZMUOPVQaQisnIo4Rw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/core": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.4.tgz", - "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.3", - "@babel/helper-compilation-targets": "^7.27.2", - "@babel/helper-module-transforms": "^7.28.3", - "@babel/helpers": "^7.28.4", - "@babel/parser": "^7.28.4", - "@babel/template": "^7.27.2", - "@babel/traverse": "^7.28.4", - "@babel/types": "^7.28.4", - "@jridgewell/remapping": "^2.3.5", - "convert-source-map": "^2.0.0", - "debug": "^4.1.0", - "gensync": "^1.0.0-beta.2", - "json5": "^2.2.3", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/babel" - } - }, - "node_modules/@babel/core/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/@babel/generator": { - "version": "7.28.3", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.3.tgz", - "integrity": "sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.28.3", - "@babel/types": "^7.28.2", - "@jridgewell/gen-mapping": "^0.3.12", - "@jridgewell/trace-mapping": "^0.3.28", - "jsesc": "^3.0.2" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-compilation-targets": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", - "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/compat-data": "^7.27.2", - "@babel/helper-validator-option": "^7.27.1", - "browserslist": "^4.24.0", - "lru-cache": "^5.1.1", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-compilation-targets/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-imports": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", - "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/traverse": "^7.27.1", - "@babel/types": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-transforms": { - "version": "7.28.3", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", - "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-module-imports": "^7.27.1", - "@babel/helper-validator-identifier": "^7.27.1", - "@babel/traverse": "^7.28.3" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-plugin-utils": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", - "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", - "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helpers": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", - "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/template": "^7.27.2", - "@babel/types": "^7.28.4" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/parser": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.4.tgz", - "integrity": "sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.28.4" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/plugin-transform-react-jsx-self": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", - "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-react-jsx-source": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", - "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/runtime": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", - "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/template": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", - "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/parser": "^7.27.2", - "@babel/types": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/traverse": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.4.tgz", - "integrity": "sha512-YEzuboP2qvQavAcjgQNVgsvHIDv6ZpwXvcvjmyySP2DIMuByS/6ioU5G9pYrWHM6T2YDfc7xga9iNzYOs12CFQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.3", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.28.4", - "@babel/template": "^7.27.2", - "@babel/types": "^7.28.4", - "debug": "^4.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/types": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.4.tgz", - "integrity": "sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@develar/schema-utils": { - "version": "2.6.5", - "resolved": "https://registry.npmjs.org/@develar/schema-utils/-/schema-utils-2.6.5.tgz", - "integrity": "sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig==", - "dev": true, - "license": "MIT", - "dependencies": { - "ajv": "^6.12.0", - "ajv-keywords": "^3.4.1" - }, - "engines": { - "node": ">= 8.9.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, - "node_modules/@dnd-kit/accessibility": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", - "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", - "license": "MIT", - "dependencies": { - "tslib": "^2.0.0" - }, - "peerDependencies": { - "react": ">=16.8.0" - } - }, - "node_modules/@dnd-kit/core": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", - "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", - "license": "MIT", - "dependencies": { - "@dnd-kit/accessibility": "^3.1.1", - "@dnd-kit/utilities": "^3.2.2", - "tslib": "^2.0.0" - }, - "peerDependencies": { - "react": ">=16.8.0", - "react-dom": ">=16.8.0" - } - }, - "node_modules/@dnd-kit/utilities": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", - "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", - "license": "MIT", - "dependencies": { - "tslib": "^2.0.0" - }, - "peerDependencies": { - "react": ">=16.8.0" - } - }, - "node_modules/@electron/asar": { - "version": "3.4.1", - "resolved": "https://registry.npmjs.org/@electron/asar/-/asar-3.4.1.tgz", - "integrity": "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA==", - "dev": true, - "license": "MIT", - "dependencies": { - "commander": "^5.0.0", - "glob": "^7.1.6", - "minimatch": "^3.0.4" - }, - "bin": { - "asar": "bin/asar.js" - }, - "engines": { - "node": ">=10.12.0" - } - }, - "node_modules/@electron/asar/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/@electron/asar/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/@electron/get": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@electron/get/-/get-2.0.3.tgz", - "integrity": "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^4.1.1", - "env-paths": "^2.2.0", - "fs-extra": "^8.1.0", - "got": "^11.8.5", - "progress": "^2.0.3", - "semver": "^6.2.0", - "sumchecker": "^3.0.1" - }, - "engines": { - "node": ">=12" - }, - "optionalDependencies": { - "global-agent": "^3.0.0" - } - }, - "node_modules/@electron/get/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/@electron/notarize": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/@electron/notarize/-/notarize-2.2.1.tgz", - "integrity": "sha512-aL+bFMIkpR0cmmj5Zgy0LMKEpgy43/hw5zadEArgmAMWWlKc5buwFvFT9G/o/YJkvXAJm5q3iuTuLaiaXW39sg==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^4.1.1", - "fs-extra": "^9.0.1", - "promise-retry": "^2.0.1" - }, - "engines": { - "node": ">= 10.0.0" - } - }, - "node_modules/@electron/notarize/node_modules/fs-extra": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", - "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "at-least-node": "^1.0.0", - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/@electron/notarize/node_modules/jsonfile": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", - "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", - "dev": true, - "license": "MIT", - "dependencies": { - "universalify": "^2.0.0" - }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/@electron/notarize/node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 10.0.0" - } - }, - "node_modules/@electron/osx-sign": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/@electron/osx-sign/-/osx-sign-1.0.5.tgz", - "integrity": "sha512-k9ZzUQtamSoweGQDV2jILiRIHUu7lYlJ3c6IEmjv1hC17rclE+eb9U+f6UFlOOETo0JzY1HNlXy4YOlCvl+Lww==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "compare-version": "^0.1.2", - "debug": "^4.3.4", - "fs-extra": "^10.0.0", - "isbinaryfile": "^4.0.8", - "minimist": "^1.2.6", - "plist": "^3.0.5" - }, - "bin": { - "electron-osx-flat": "bin/electron-osx-flat.js", - "electron-osx-sign": "bin/electron-osx-sign.js" - }, - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/@electron/osx-sign/node_modules/fs-extra": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", - "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@electron/osx-sign/node_modules/isbinaryfile": { - "version": "4.0.10", - "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-4.0.10.tgz", - "integrity": "sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/gjtorikian/" - } - }, - "node_modules/@electron/osx-sign/node_modules/jsonfile": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", - "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", - "dev": true, - "license": "MIT", - "dependencies": { - "universalify": "^2.0.0" - }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/@electron/osx-sign/node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 10.0.0" - } - }, - "node_modules/@electron/universal": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/@electron/universal/-/universal-1.5.1.tgz", - "integrity": "sha512-kbgXxyEauPJiQQUNG2VgUeyfQNFk6hBF11ISN2PNI6agUgPl55pv4eQmaqHzTAzchBvqZ2tQuRVaPStGf0mxGw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@electron/asar": "^3.2.1", - "@malept/cross-spawn-promise": "^1.1.0", - "debug": "^4.3.1", - "dir-compare": "^3.0.0", - "fs-extra": "^9.0.1", - "minimatch": "^3.0.4", - "plist": "^3.0.4" - }, - "engines": { - "node": ">=8.6" - } - }, - "node_modules/@electron/universal/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/@electron/universal/node_modules/fs-extra": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", - "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "at-least-node": "^1.0.0", - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/@electron/universal/node_modules/jsonfile": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", - "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", - "dev": true, - "license": "MIT", - "dependencies": { - "universalify": "^2.0.0" - }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/@electron/universal/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/@electron/universal/node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 10.0.0" - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", - "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", - "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", - "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", - "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", - "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", - "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", - "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", - "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", - "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", - "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", - "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", - "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", - "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", - "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", - "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", - "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", - "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", - "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", - "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", - "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", - "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", - "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", - "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@eslint-community/eslint-utils": { - "version": "4.9.0", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", - "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", - "dev": true, - "license": "MIT", - "dependencies": { - "eslint-visitor-keys": "^3.4.3" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - }, - "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" - } - }, - "node_modules/@eslint-community/regexpp": { - "version": "4.12.1", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", - "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.0.0 || ^14.0.0 || >=16.0.0" - } - }, - "node_modules/@eslint/eslintrc": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", - "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ajv": "^6.12.4", - "debug": "^4.3.2", - "espree": "^9.6.0", - "globals": "^13.19.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.1.0", - "minimatch": "^3.1.2", - "strip-json-comments": "^3.1.1" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/@eslint/eslintrc/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/@eslint/js": { - "version": "8.57.1", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz", - "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - } - }, - "node_modules/@fix-webm-duration/fix": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@fix-webm-duration/fix/-/fix-1.0.1.tgz", - "integrity": "sha512-rRN4CpWQaXRbCXYqKIxnsUq8OSWSGq/SVlnxxkx0HxMZZ1u0qnB24P766o8QS5YaMMqAOFAzmsMmfZ2OWucOLQ==", - "license": "MIT", - "dependencies": { - "@fix-webm-duration/parser": "1.0.1", - "tslib": "^2.3.0" - } - }, - "node_modules/@fix-webm-duration/parser": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@fix-webm-duration/parser/-/parser-1.0.1.tgz", - "integrity": "sha512-Z6el7ohBBpym4iOmxDxumN715cHzLcAbtOtYfIbvoyToVtAuxvHt3ELXGff83iAXEU1Yj7g+obQBdiKJYVhbWw==", - "license": "MIT", - "dependencies": { - "tslib": "^2.3.0" - } - }, - "node_modules/@floating-ui/core": { - "version": "1.7.3", - "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz", - "integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==", - "license": "MIT", - "dependencies": { - "@floating-ui/utils": "^0.2.10" - } - }, - "node_modules/@floating-ui/dom": { - "version": "1.7.4", - "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz", - "integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==", - "license": "MIT", - "dependencies": { - "@floating-ui/core": "^1.7.3", - "@floating-ui/utils": "^0.2.10" - } - }, - "node_modules/@floating-ui/react-dom": { - "version": "2.1.6", - "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.6.tgz", - "integrity": "sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw==", - "license": "MIT", - "dependencies": { - "@floating-ui/dom": "^1.7.4" - }, - "peerDependencies": { - "react": ">=16.8.0", - "react-dom": ">=16.8.0" - } - }, - "node_modules/@floating-ui/utils": { - "version": "0.2.10", - "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", - "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", - "license": "MIT" - }, - "node_modules/@gar/promisify": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz", - "integrity": "sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@humanwhocodes/config-array": { - "version": "0.13.0", - "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", - "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==", - "deprecated": "Use @eslint/config-array instead", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@humanwhocodes/object-schema": "^2.0.3", - "debug": "^4.3.1", - "minimatch": "^3.0.5" - }, - "engines": { - "node": ">=10.10.0" - } - }, - "node_modules/@humanwhocodes/config-array/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/@humanwhocodes/config-array/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/@humanwhocodes/module-importer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", - "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.22" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@humanwhocodes/object-schema": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", - "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", - "deprecated": "Use @eslint/object-schema instead", - "dev": true, - "license": "BSD-3-Clause" - }, - "node_modules/@isaacs/cliui": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", - "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", - "license": "ISC", - "dependencies": { - "string-width": "^5.1.2", - "string-width-cjs": "npm:string-width@^4.2.0", - "strip-ansi": "^7.0.1", - "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", - "wrap-ansi": "^8.1.0", - "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@isaacs/cliui/node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/@isaacs/cliui/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/@isaacs/cliui/node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "license": "MIT" - }, - "node_modules/@isaacs/cliui/node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "license": "MIT", - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@isaacs/cliui/node_modules/strip-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", - "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", - "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.13", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/remapping": { - "version": "2.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", - "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "license": "MIT" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, - "node_modules/@malept/cross-spawn-promise": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@malept/cross-spawn-promise/-/cross-spawn-promise-1.1.1.tgz", - "integrity": "sha512-RTBGWL5FWQcg9orDOCcp4LvItNzUPcyEU9bwaeJX0rJ1IQxzucC48Y0/sQLp/g6t99IQgAlGIaesJS+gTn7tVQ==", - "dev": true, - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/malept" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/subscription/pkg/npm-.malept-cross-spawn-promise?utm_medium=referral&utm_source=npm_fund" - } - ], - "license": "Apache-2.0", - "dependencies": { - "cross-spawn": "^7.0.1" - }, - "engines": { - "node": ">= 10" - } - }, - "node_modules/@malept/flatpak-bundler": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@malept/flatpak-bundler/-/flatpak-bundler-0.4.0.tgz", - "integrity": "sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^4.1.1", - "fs-extra": "^9.0.0", - "lodash": "^4.17.15", - "tmp-promise": "^3.0.2" - }, - "engines": { - "node": ">= 10.0.0" - } - }, - "node_modules/@malept/flatpak-bundler/node_modules/fs-extra": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", - "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "at-least-node": "^1.0.0", - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/@malept/flatpak-bundler/node_modules/jsonfile": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", - "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", - "dev": true, - "license": "MIT", - "dependencies": { - "universalify": "^2.0.0" - }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/@malept/flatpak-bundler/node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 10.0.0" - } - }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "license": "MIT", - "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@npmcli/fs": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-2.1.2.tgz", - "integrity": "sha512-yOJKRvohFOaLqipNtwYB9WugyZKhC/DZC4VYPmpaCzDBrA8YpK3qHZ8/HGscMnE4GqbkLNuVcCnxkeQEdGt6LQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "@gar/promisify": "^1.1.3", - "semver": "^7.3.5" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" - } - }, - "node_modules/@npmcli/move-file": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@npmcli/move-file/-/move-file-2.0.1.tgz", - "integrity": "sha512-mJd2Z5TjYWq/ttPLLGqArdtnC74J6bOzg4rMDnN+p1xTacZ2yPRCk2y0oSWQtygLR9YVQXgOcONrwtnk3JupxQ==", - "deprecated": "This functionality has been moved to @npmcli/fs", - "dev": true, - "license": "MIT", - "dependencies": { - "mkdirp": "^1.0.4", - "rimraf": "^3.0.2" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" - } - }, - "node_modules/@pixi/color": { - "version": "7.4.3", - "resolved": "https://registry.npmjs.org/@pixi/color/-/color-7.4.3.tgz", - "integrity": "sha512-a6R+bXKeXMDcRmjYQoBIK+v2EYqxSX49wcjAY579EYM/WrFKS98nSees6lqVUcLKrcQh2DT9srJHX7XMny3voQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "@pixi/colord": "^2.9.6" - } - }, - "node_modules/@pixi/colord": { - "version": "2.9.6", - "resolved": "https://registry.npmjs.org/@pixi/colord/-/colord-2.9.6.tgz", - "integrity": "sha512-nezytU2pw587fQstUu1AsJZDVEynjskwOL+kibwcdxsMBFqPsFFNA7xl0ii/gXuDi6M0xj3mfRJj8pBSc2jCfA==", - "license": "MIT" - }, - "node_modules/@pixi/constants": { - "version": "7.4.3", - "resolved": "https://registry.npmjs.org/@pixi/constants/-/constants-7.4.3.tgz", - "integrity": "sha512-QGmwJUNQy/vVEHzL6VGQvnwawLZ1wceZMI8HwJAT4/I2uAzbBeFDdmCS8WsTpSWLZjF/DszDc1D8BFp4pVJ5UQ==", - "license": "MIT", - "peer": true - }, - "node_modules/@pixi/core": { - "version": "7.4.3", - "resolved": "https://registry.npmjs.org/@pixi/core/-/core-7.4.3.tgz", - "integrity": "sha512-5YDs11faWgVVTL8VZtLU05/Fl47vaP5Tnsbf+y/WRR0VSW3KhRRGTBU1J3Gdc2xEWbJhUK07KGP7eSZpvtPVgA==", - "license": "MIT", - "peer": true, - "dependencies": { - "@pixi/color": "7.4.3", - "@pixi/constants": "7.4.3", - "@pixi/extensions": "7.4.3", - "@pixi/math": "7.4.3", - "@pixi/runner": "7.4.3", - "@pixi/settings": "7.4.3", - "@pixi/ticker": "7.4.3", - "@pixi/utils": "7.4.3" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/pixijs" - } - }, - "node_modules/@pixi/extensions": { - "version": "7.4.3", - "resolved": "https://registry.npmjs.org/@pixi/extensions/-/extensions-7.4.3.tgz", - "integrity": "sha512-FhoiYkHQEDYHUE7wXhqfsTRz6KxLXjuMbSiAwnLb9uG1vAgp6q6qd6HEsf4X30YaZbLFY8a4KY6hFZWjF+4Fdw==", - "license": "MIT", - "peer": true - }, - "node_modules/@pixi/filter-drop-shadow": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@pixi/filter-drop-shadow/-/filter-drop-shadow-5.2.0.tgz", - "integrity": "sha512-cYS2KDER7cwCu0V4VNSxTHGvzmNcEXdC9j3031YBOkUAE3+p17LMS/TAt6XeMfJV7KaPuusvXy2NFgGkv3RDbw==", - "license": "MIT", - "dependencies": { - "@pixi/filter-kawase-blur": "5.1.1" - }, - "peerDependencies": { - "@pixi/core": "^7.0.0-X" - } - }, - "node_modules/@pixi/filter-kawase-blur": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/@pixi/filter-kawase-blur/-/filter-kawase-blur-5.1.1.tgz", - "integrity": "sha512-nPnJ1ChBFP+4pgFCwC0RJgHAJCetiHcQU3INH7zCdq88cFABmVmhN+wCKRNg4H7lF1EJjaXgFDkTrTreOD/bnw==", - "license": "MIT", - "peerDependencies": { - "@pixi/core": "^7.0.0-X" - } - }, - "node_modules/@pixi/math": { - "version": "7.4.3", - "resolved": "https://registry.npmjs.org/@pixi/math/-/math-7.4.3.tgz", - "integrity": "sha512-/uJOVhR2DOZ+zgdI6Bs/CwcXT4bNRKsS+TqX3ekRIxPCwaLra+Qdm7aDxT5cTToDzdxbKL5+rwiLu3Y1egILDw==", - "license": "MIT", - "peer": true - }, - "node_modules/@pixi/runner": { - "version": "7.4.3", - "resolved": "https://registry.npmjs.org/@pixi/runner/-/runner-7.4.3.tgz", - "integrity": "sha512-TJyfp7y23u5vvRAyYhVSa7ytq0PdKSvPLXu4G3meoFh1oxTLHH6g/RIzLuxUAThPG2z7ftthuW3qWq6dRV+dhw==", - "license": "MIT", - "peer": true - }, - "node_modules/@pixi/settings": { - "version": "7.4.3", - "resolved": "https://registry.npmjs.org/@pixi/settings/-/settings-7.4.3.tgz", - "integrity": "sha512-SmGK8smc0PxRB9nr0UJioEtE9hl4gvj9OedCvZx3bxBwA3omA5BmP3CyhQfN8XJ29+o2OUL01r3zAPVol4l4lA==", - "license": "MIT", - "peer": true, - "dependencies": { - "@pixi/constants": "7.4.3", - "@types/css-font-loading-module": "^0.0.12", - "ismobilejs": "^1.1.0" - } - }, - "node_modules/@pixi/ticker": { - "version": "7.4.3", - "resolved": "https://registry.npmjs.org/@pixi/ticker/-/ticker-7.4.3.tgz", - "integrity": "sha512-tHsAD0iOUb6QSGGw+c8cyRBvxsq/NlfzIFBZLEHhWZ+Bx4a0MmXup6I/yJDGmyPCYE+ctCcAfY13wKAzdiVFgQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "@pixi/extensions": "7.4.3", - "@pixi/settings": "7.4.3", - "@pixi/utils": "7.4.3" - } - }, - "node_modules/@pixi/utils": { - "version": "7.4.3", - "resolved": "https://registry.npmjs.org/@pixi/utils/-/utils-7.4.3.tgz", - "integrity": "sha512-NO3Y9HAn2UKS1YdxffqsPp+kDpVm8XWvkZcS/E+rBzY9VTLnNOI7cawSRm+dacdET3a8Jad3aDKEDZ0HmAqAFA==", - "license": "MIT", - "peer": true, - "dependencies": { - "@pixi/color": "7.4.3", - "@pixi/constants": "7.4.3", - "@pixi/settings": "7.4.3", - "@types/earcut": "^2.1.0", - "earcut": "^2.2.4", - "eventemitter3": "^4.0.0", - "url": "^0.11.0" - } - }, - "node_modules/@pixi/utils/node_modules/@types/earcut": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@types/earcut/-/earcut-2.1.4.tgz", - "integrity": "sha512-qp3m9PPz4gULB9MhjGID7wpo3gJ4bTGXm7ltNDsmOvsPduTeHp8wSW9YckBj3mljeOh4F0m2z/0JKAALRKbmLQ==", - "license": "MIT", - "peer": true - }, - "node_modules/@pixi/utils/node_modules/earcut": { - "version": "2.2.4", - "resolved": "https://registry.npmjs.org/earcut/-/earcut-2.2.4.tgz", - "integrity": "sha512-/pjZsA1b4RPHbeWZQn66SWS8nZZWLQQ23oE3Eam7aroEFGEvwKAsJfZ9ytiEMycfzXWpca4FA9QIOehf7PocBQ==", - "license": "ISC", - "peer": true - }, - "node_modules/@pixi/utils/node_modules/eventemitter3": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", - "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", - "license": "MIT", - "peer": true - }, - "node_modules/@pkgjs/parseargs": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", - "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=14" - } - }, - "node_modules/@radix-ui/number": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", - "integrity": "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==", - "license": "MIT" - }, - "node_modules/@radix-ui/primitive": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", - "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", - "license": "MIT" - }, - "node_modules/@radix-ui/react-arrow": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", - "integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-primitive": "2.1.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-collection": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", - "integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-slot": "1.2.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-compose-refs": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", - "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-context": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", - "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-direction": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", - "integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-dismissable-layer": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz", - "integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==", - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-callback-ref": "1.1.1", - "@radix-ui/react-use-escape-keydown": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-dropdown-menu": { - "version": "2.1.16", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.16.tgz", - "integrity": "sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==", - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-menu": "2.1.16", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-controllable-state": "1.2.2" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-focus-guards": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz", - "integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-focus-scope": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz", - "integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-callback-ref": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-id": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", - "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-menu": { - "version": "2.1.16", - "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.16.tgz", - "integrity": "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==", - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-collection": "1.1.7", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-direction": "1.1.1", - "@radix-ui/react-dismissable-layer": "1.1.11", - "@radix-ui/react-focus-guards": "1.1.3", - "@radix-ui/react-focus-scope": "1.1.7", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-popper": "1.2.8", - "@radix-ui/react-portal": "1.1.9", - "@radix-ui/react-presence": "1.1.5", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-roving-focus": "1.1.11", - "@radix-ui/react-slot": "1.2.3", - "@radix-ui/react-use-callback-ref": "1.1.1", - "aria-hidden": "^1.2.4", - "react-remove-scroll": "^2.6.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-popper": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz", - "integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==", - "license": "MIT", - "dependencies": { - "@floating-ui/react-dom": "^2.0.0", - "@radix-ui/react-arrow": "1.1.7", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-callback-ref": "1.1.1", - "@radix-ui/react-use-layout-effect": "1.1.1", - "@radix-ui/react-use-rect": "1.1.1", - "@radix-ui/react-use-size": "1.1.1", - "@radix-ui/rect": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-portal": { - "version": "1.1.9", - "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", - "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-layout-effect": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-presence": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", - "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-use-layout-effect": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-primitive": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", - "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-slot": "1.2.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-roving-focus": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz", - "integrity": "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==", - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-collection": "1.1.7", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-direction": "1.1.1", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-callback-ref": "1.1.1", - "@radix-ui/react-use-controllable-state": "1.2.2" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-slider": { - "version": "1.3.6", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.3.6.tgz", - "integrity": "sha512-JPYb1GuM1bxfjMRlNLE+BcmBC8onfCi60Blk7OBqi2MLTFdS+8401U4uFjnwkOr49BLmXxLC6JHkvAsx5OJvHw==", - "license": "MIT", - "dependencies": { - "@radix-ui/number": "1.1.1", - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-collection": "1.1.7", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-direction": "1.1.1", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-controllable-state": "1.2.2", - "@radix-ui/react-use-layout-effect": "1.1.1", - "@radix-ui/react-use-previous": "1.1.1", - "@radix-ui/react-use-size": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-slot": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", - "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-switch": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.2.6.tgz", - "integrity": "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==", - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-controllable-state": "1.2.2", - "@radix-ui/react-use-previous": "1.1.1", - "@radix-ui/react-use-size": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-tabs": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz", - "integrity": "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==", - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-direction": "1.1.1", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-presence": "1.1.5", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-roving-focus": "1.1.11", - "@radix-ui/react-use-controllable-state": "1.2.2" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-use-callback-ref": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", - "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-use-controllable-state": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", - "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-use-effect-event": "0.0.2", - "@radix-ui/react-use-layout-effect": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-use-effect-event": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", - "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-use-escape-keydown": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz", - "integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-use-callback-ref": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-use-layout-effect": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", - "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-use-previous": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz", - "integrity": "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-use-rect": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz", - "integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==", - "license": "MIT", - "dependencies": { - "@radix-ui/rect": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-use-size": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz", - "integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/rect": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz", - "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", - "license": "MIT" - }, - "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-beta.27", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", - "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.52.4.tgz", - "integrity": "sha512-BTm2qKNnWIQ5auf4deoetINJm2JzvihvGb9R6K/ETwKLql/Bb3Eg2H1FBp1gUb4YGbydMA3jcmQTR73q7J+GAA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.52.4.tgz", - "integrity": "sha512-P9LDQiC5vpgGFgz7GSM6dKPCiqR3XYN1WwJKA4/BUVDjHpYsf3iBEmVz62uyq20NGYbiGPR5cNHI7T1HqxNs2w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.52.4.tgz", - "integrity": "sha512-QRWSW+bVccAvZF6cbNZBJwAehmvG9NwfWHwMy4GbWi/BQIA/laTIktebT2ipVjNncqE6GLPxOok5hsECgAxGZg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.52.4.tgz", - "integrity": "sha512-hZgP05pResAkRJxL1b+7yxCnXPGsXU0fG9Yfd6dUaoGk+FhdPKCJ5L1Sumyxn8kvw8Qi5PvQ8ulenUbRjzeCTw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.52.4.tgz", - "integrity": "sha512-xmc30VshuBNUd58Xk4TKAEcRZHaXlV+tCxIXELiE9sQuK3kG8ZFgSPi57UBJt8/ogfhAF5Oz4ZSUBN77weM+mQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.52.4.tgz", - "integrity": "sha512-WdSLpZFjOEqNZGmHflxyifolwAiZmDQzuOzIq9L27ButpCVpD7KzTRtEG1I0wMPFyiyUdOO+4t8GvrnBLQSwpw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.52.4.tgz", - "integrity": "sha512-xRiOu9Of1FZ4SxVbB0iEDXc4ddIcjCv2aj03dmW8UrZIW7aIQ9jVJdLBIhxBI+MaTnGAKyvMwPwQnoOEvP7FgQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.52.4.tgz", - "integrity": "sha512-FbhM2p9TJAmEIEhIgzR4soUcsW49e9veAQCziwbR+XWB2zqJ12b4i/+hel9yLiD8pLncDH4fKIPIbt5238341Q==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.52.4.tgz", - "integrity": "sha512-4n4gVwhPHR9q/g8lKCyz0yuaD0MvDf7dV4f9tHt0C73Mp8h38UCtSCSE6R9iBlTbXlmA8CjpsZoujhszefqueg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.52.4.tgz", - "integrity": "sha512-u0n17nGA0nvi/11gcZKsjkLj1QIpAuPFQbR48Subo7SmZJnGxDpspyw2kbpuoQnyK+9pwf3pAoEXerJs/8Mi9g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.52.4.tgz", - "integrity": "sha512-0G2c2lpYtbTuXo8KEJkDkClE/+/2AFPdPAbmaHoE870foRFs4pBrDehilMcrSScrN/fB/1HTaWO4bqw+ewBzMQ==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.52.4.tgz", - "integrity": "sha512-teSACug1GyZHmPDv14VNbvZFX779UqWTsd7KtTM9JIZRDI5NUwYSIS30kzI8m06gOPB//jtpqlhmraQ68b5X2g==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.52.4.tgz", - "integrity": "sha512-/MOEW3aHjjs1p4Pw1Xk4+3egRevx8Ji9N6HUIA1Ifh8Q+cg9dremvFCUbOX2Zebz80BwJIgCBUemjqhU5XI5Eg==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.52.4.tgz", - "integrity": "sha512-1HHmsRyh845QDpEWzOFtMCph5Ts+9+yllCrREuBR/vg2RogAQGGBRC8lDPrPOMnrdOJ+mt1WLMOC2Kao/UwcvA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.52.4.tgz", - "integrity": "sha512-seoeZp4L/6D1MUyjWkOMRU6/iLmCU2EjbMTyAG4oIOs1/I82Y5lTeaxW0KBfkUdHAWN7j25bpkt0rjnOgAcQcA==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.52.4.tgz", - "integrity": "sha512-Wi6AXf0k0L7E2gteNsNHUs7UMwCIhsCTs6+tqQ5GPwVRWMaflqGec4Sd8n6+FNFDw9vGcReqk2KzBDhCa1DLYg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.52.4.tgz", - "integrity": "sha512-dtBZYjDmCQ9hW+WgEkaffvRRCKm767wWhxsFW3Lw86VXz/uJRuD438/XvbZT//B96Vs8oTA8Q4A0AfHbrxP9zw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.52.4.tgz", - "integrity": "sha512-1ox+GqgRWqaB1RnyZXL8PD6E5f7YyRUJYnCqKpNzxzP0TkaUh112NDrR9Tt+C8rJ4x5G9Mk8PQR3o7Ku2RKqKA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ] - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.52.4.tgz", - "integrity": "sha512-8GKr640PdFNXwzIE0IrkMWUNUomILLkfeHjXBi/nUvFlpZP+FA8BKGKpacjW6OUUHaNI6sUURxR2U2g78FOHWQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.52.4.tgz", - "integrity": "sha512-AIy/jdJ7WtJ/F6EcfOb2GjR9UweO0n43jNObQMb6oGxkYTfLcnN7vYYpG+CN3lLxrQkzWnMOoNSHTW54pgbVxw==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.52.4.tgz", - "integrity": "sha512-UF9KfsH9yEam0UjTwAgdK0anlQ7c8/pWPU2yVjyWcF1I1thABt6WXE47cI71pGiZ8wGvxohBoLnxM04L/wj8mQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.52.4.tgz", - "integrity": "sha512-bf9PtUa0u8IXDVxzRToFQKsNCRz9qLYfR/MpECxl4mRoWYjAeFjgxj1XdZr2M/GNVpT05p+LgQOHopYDlUu6/w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@sindresorhus/is": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", - "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sindresorhus/is?sponsor=1" - } - }, - "node_modules/@szmarczak/http-timer": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz", - "integrity": "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==", - "dev": true, - "license": "MIT", - "dependencies": { - "defer-to-connect": "^2.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/@tootallnate/once": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", - "integrity": "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 10" - } - }, - "node_modules/@types/babel__core": { - "version": "7.20.5", - "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", - "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.20.7", - "@babel/types": "^7.20.7", - "@types/babel__generator": "*", - "@types/babel__template": "*", - "@types/babel__traverse": "*" - } - }, - "node_modules/@types/babel__generator": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", - "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__template": { - "version": "7.4.4", - "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", - "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.1.0", - "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__traverse": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", - "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.28.2" - } - }, - "node_modules/@types/cacheable-request": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz", - "integrity": "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/http-cache-semantics": "*", - "@types/keyv": "^3.1.4", - "@types/node": "*", - "@types/responselike": "^1.0.0" - } - }, - "node_modules/@types/css-font-loading-module": { - "version": "0.0.12", - "resolved": "https://registry.npmjs.org/@types/css-font-loading-module/-/css-font-loading-module-0.0.12.tgz", - "integrity": "sha512-x2tZZYkSxXqWvTDgveSynfjq/T2HyiZHXb00j/+gy19yp70PHCizM48XFdjBCWH7eHBD0R5i/pw9yMBP/BH5uA==", - "license": "MIT" - }, - "node_modules/@types/debug": { - "version": "4.1.12", - "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", - "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/ms": "*" - } - }, - "node_modules/@types/dom-webcodecs": { - "version": "0.1.17", - "resolved": "https://registry.npmjs.org/@types/dom-webcodecs/-/dom-webcodecs-0.1.17.tgz", - "integrity": "sha512-IwKW5uKL0Zrv5ccUJpjIlqf7ppk2v29l/ZLQxLlwHxljBfnDD9Gxm+hzMkGM0AOAL/21H0pp7cTUYLiiVUGchA==", - "license": "MIT" - }, - "node_modules/@types/earcut": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@types/earcut/-/earcut-3.0.0.tgz", - "integrity": "sha512-k/9fOUGO39yd2sCjrbAJvGDEQvRwRnQIZlBz43roGwUZo5SHAmyVvSFyaVVZkicRVCaDXPKlbxrUcBuJoSWunQ==", - "license": "MIT" - }, - "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/fs-extra": { - "version": "9.0.13", - "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-9.0.13.tgz", - "integrity": "sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/http-cache-semantics": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.4.tgz", - "integrity": "sha512-1m0bIFVc7eJWyve9S0RnuRgcQqF/Xd5QsUZAZeQFr1Q3/p9JWoQQEqmVy+DPTNpGXwhgIetAoYF8JSc33q29QA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/keyv": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.4.tgz", - "integrity": "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/ms": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", - "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/node": { - "version": "20.19.20", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.20.tgz", - "integrity": "sha512-2Q7WS25j4pS1cS8yw3d6buNCVJukOTeQ39bAnwR6sOJbaxvyCGebzTMypDFN82CxBLnl+lSWVdCCWbRY6y9yZQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" - } - }, - "node_modules/@types/plist": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@types/plist/-/plist-3.0.5.tgz", - "integrity": "sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@types/node": "*", - "xmlbuilder": ">=11.0.1" - } - }, - "node_modules/@types/prop-types": { - "version": "15.7.15", - "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", - "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/@types/react": { - "version": "18.3.26", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.26.tgz", - "integrity": "sha512-RFA/bURkcKzx/X9oumPG9Vp3D3JUgus/d0b67KB0t5S/raciymilkOa66olh78MUI92QLbEJevO7rvqU/kjwKA==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "@types/prop-types": "*", - "csstype": "^3.0.2" - } - }, - "node_modules/@types/react-dom": { - "version": "18.3.7", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", - "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", - "devOptional": true, - "license": "MIT", - "peerDependencies": { - "@types/react": "^18.0.0" - } - }, - "node_modules/@types/responselike": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.3.tgz", - "integrity": "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/verror": { - "version": "1.10.11", - "resolved": "https://registry.npmjs.org/@types/verror/-/verror-1.10.11.tgz", - "integrity": "sha512-RlDm9K7+o5stv0Co8i8ZRGxDbrTxhJtgjqjFyVh/tXQyl/rYtTKlnTvZ88oSTeYREWurwx20Js4kTuKCsFkUtg==", - "dev": true, - "license": "MIT", - "optional": true - }, - "node_modules/@types/wicg-file-system-access": { - "version": "2020.9.8", - "resolved": "https://registry.npmjs.org/@types/wicg-file-system-access/-/wicg-file-system-access-2020.9.8.tgz", - "integrity": "sha512-ggMz8nOygG7d/stpH40WVaNvBwuyYLnrg5Mbyf6bmsj/8+gb6Ei4ZZ9/4PNpcPNTT8th9Q8sM8wYmWGjMWLX/A==", - "license": "MIT" - }, - "node_modules/@types/yauzl": { - "version": "2.10.3", - "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", - "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@typescript-eslint/eslint-plugin": { - "version": "7.18.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.18.0.tgz", - "integrity": "sha512-94EQTWZ40mzBc42ATNIBimBEDltSJ9RQHCC8vc/PDbxi4k8dVwUAv4o98dk50M1zB+JGFxp43FP7f8+FP8R6Sw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "7.18.0", - "@typescript-eslint/type-utils": "7.18.0", - "@typescript-eslint/utils": "7.18.0", - "@typescript-eslint/visitor-keys": "7.18.0", - "graphemer": "^1.4.0", - "ignore": "^5.3.1", - "natural-compare": "^1.4.0", - "ts-api-utils": "^1.3.0" - }, - "engines": { - "node": "^18.18.0 || >=20.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "@typescript-eslint/parser": "^7.0.0", - "eslint": "^8.56.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/@typescript-eslint/parser": { - "version": "7.18.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.18.0.tgz", - "integrity": "sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "@typescript-eslint/scope-manager": "7.18.0", - "@typescript-eslint/types": "7.18.0", - "@typescript-eslint/typescript-estree": "7.18.0", - "@typescript-eslint/visitor-keys": "7.18.0", - "debug": "^4.3.4" - }, - "engines": { - "node": "^18.18.0 || >=20.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.56.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/@typescript-eslint/scope-manager": { - "version": "7.18.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.18.0.tgz", - "integrity": "sha512-jjhdIE/FPF2B7Z1uzc6i3oWKbGcHb87Qw7AWj6jmEqNOfDFbJWtjt/XfwCpvNkpGWlcJaog5vTR+VV8+w9JflA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "7.18.0", - "@typescript-eslint/visitor-keys": "7.18.0" - }, - "engines": { - "node": "^18.18.0 || >=20.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/type-utils": { - "version": "7.18.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.18.0.tgz", - "integrity": "sha512-XL0FJXuCLaDuX2sYqZUUSOJ2sG5/i1AAze+axqmLnSkNEVMVYLF+cbwlB2w8D1tinFuSikHmFta+P+HOofrLeA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/typescript-estree": "7.18.0", - "@typescript-eslint/utils": "7.18.0", - "debug": "^4.3.4", - "ts-api-utils": "^1.3.0" - }, - "engines": { - "node": "^18.18.0 || >=20.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.56.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/@typescript-eslint/types": { - "version": "7.18.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.18.0.tgz", - "integrity": "sha512-iZqi+Ds1y4EDYUtlOOC+aUmxnE9xS/yCigkjA7XpTKV6nCBd3Hp/PRGGmdwnfkV2ThMyYldP1wRpm/id99spTQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || >=20.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/typescript-estree": { - "version": "7.18.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.18.0.tgz", - "integrity": "sha512-aP1v/BSPnnyhMHts8cf1qQ6Q1IFwwRvAQGRvBFkWlo3/lH29OXA3Pts+c10nxRxIBrDnoMqzhgdwVe5f2D6OzA==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "@typescript-eslint/types": "7.18.0", - "@typescript-eslint/visitor-keys": "7.18.0", - "debug": "^4.3.4", - "globby": "^11.1.0", - "is-glob": "^4.0.3", - "minimatch": "^9.0.4", - "semver": "^7.6.0", - "ts-api-utils": "^1.3.0" - }, - "engines": { - "node": "^18.18.0 || >=20.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/@typescript-eslint/utils": { - "version": "7.18.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.18.0.tgz", - "integrity": "sha512-kK0/rNa2j74XuHVcoCZxdFBMF+aq/vH83CXAOHieC+2Gis4mF8jJXT5eAfyD3K0sAxtPuwxaIOIOvhwzVDt/kw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.4.0", - "@typescript-eslint/scope-manager": "7.18.0", - "@typescript-eslint/types": "7.18.0", - "@typescript-eslint/typescript-estree": "7.18.0" - }, - "engines": { - "node": "^18.18.0 || >=20.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.56.0" - } - }, - "node_modules/@typescript-eslint/visitor-keys": { - "version": "7.18.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.18.0.tgz", - "integrity": "sha512-cDF0/Gf81QpY3xYyJKDV14Zwdmid5+uuENhjH2EqFaF0ni+yAyq/LzMaIJdhNJXZI7uLzwIlA+V7oWoyn6Curg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "7.18.0", - "eslint-visitor-keys": "^3.4.3" - }, - "engines": { - "node": "^18.18.0 || >=20.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@uiw/color-convert": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/@uiw/color-convert/-/color-convert-2.9.0.tgz", - "integrity": "sha512-tFm6iac8iN3VL9IIAXKAVpBX6ZQH7ucCvb+EgZNM5y8prRWlk53cERnP4RycE3wnYb/HUKW0lrngoygQnEWAHg==", - "license": "MIT", - "funding": { - "url": "https://jaywcjlove.github.io/#/sponsor" - }, - "peerDependencies": { - "@babel/runtime": ">=7.19.0" - } - }, - "node_modules/@uiw/react-color-alpha": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/@uiw/react-color-alpha/-/react-color-alpha-2.9.0.tgz", - "integrity": "sha512-tP9zs2KUPzIGvdtTBTNlDEdwHWcNJ3Ju/nnpPTsKERRJF2vzucb3v18JzihX0ACiOXU9+JVqgpAVgvoZXMeITA==", - "license": "MIT", - "dependencies": { - "@uiw/color-convert": "2.9.0", - "@uiw/react-drag-event-interactive": "2.9.0" - }, - "funding": { - "url": "https://jaywcjlove.github.io/#/sponsor" - }, - "peerDependencies": { - "@babel/runtime": ">=7.19.0", - "react": ">=16.9.0", - "react-dom": ">=16.9.0" - } - }, - "node_modules/@uiw/react-color-colorful": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/@uiw/react-color-colorful/-/react-color-colorful-2.9.0.tgz", - "integrity": "sha512-qw7zhgX9J4aydK0hKKLroXMYp0tw3ASNuZM1fQNwXXIpOkEW4zO+PHTkzUBpbII68wGS8tAxsZessTWNpAVi0w==", - "license": "MIT", - "dependencies": { - "@uiw/color-convert": "2.9.0", - "@uiw/react-color-alpha": "2.9.0", - "@uiw/react-color-hue": "2.9.0", - "@uiw/react-color-saturation": "2.9.0" - }, - "funding": { - "url": "https://jaywcjlove.github.io/#/sponsor" - }, - "peerDependencies": { - "@babel/runtime": ">=7.19.0", - "react": ">=16.9.0", - "react-dom": ">=16.9.0" - } - }, - "node_modules/@uiw/react-color-hue": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/@uiw/react-color-hue/-/react-color-hue-2.9.0.tgz", - "integrity": "sha512-2Ix027ref3ppiVq+OQkbyKr6O8fLTH+n0wEW4xhHfa3kV8mBa2bEu8jAjrkSwO6uElaE6OJcuqYe2llyKRlIFg==", - "license": "MIT", - "dependencies": { - "@uiw/color-convert": "2.9.0", - "@uiw/react-color-alpha": "2.9.0" - }, - "funding": { - "url": "https://jaywcjlove.github.io/#/sponsor" - }, - "peerDependencies": { - "@babel/runtime": ">=7.19.0", - "react": ">=16.9.0", - "react-dom": ">=16.9.0" - } - }, - "node_modules/@uiw/react-color-saturation": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/@uiw/react-color-saturation/-/react-color-saturation-2.9.0.tgz", - "integrity": "sha512-tBARqsW0ap+gwByGKSid1nYaGJd6dw2MTMwCABF9rebPQHtYbN/YgBjl9dNnp6XDGd+VBhH4RbpM38WV7J1F4g==", - "license": "MIT", - "dependencies": { - "@uiw/color-convert": "2.9.0", - "@uiw/react-drag-event-interactive": "2.9.0" - }, - "funding": { - "url": "https://jaywcjlove.github.io/#/sponsor" - }, - "peerDependencies": { - "@babel/runtime": ">=7.19.0", - "react": ">=16.9.0", - "react-dom": ">=16.9.0" - } - }, - "node_modules/@uiw/react-drag-event-interactive": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/@uiw/react-drag-event-interactive/-/react-drag-event-interactive-2.9.0.tgz", - "integrity": "sha512-oBqrClsSuYA3IjkSHp4uHOBLgPkR98KeKeheqCRltF8w/a1CPaFCl43pyKyxwXaTV6qM6cb3VkT3JRCHMNqTzA==", - "license": "MIT", - "funding": { - "url": "https://jaywcjlove.github.io/#/sponsor" - }, - "peerDependencies": { - "@babel/runtime": ">=7.19.0", - "react": ">=16.9.0", - "react-dom": ">=16.9.0" - } - }, - "node_modules/@ungap/structured-clone": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", - "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", - "dev": true, - "license": "ISC" - }, - "node_modules/@vitejs/plugin-react": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", - "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/core": "^7.28.0", - "@babel/plugin-transform-react-jsx-self": "^7.27.1", - "@babel/plugin-transform-react-jsx-source": "^7.27.1", - "@rolldown/pluginutils": "1.0.0-beta.27", - "@types/babel__core": "^7.20.5", - "react-refresh": "^0.17.0" - }, - "engines": { - "node": "^14.18.0 || >=16.0.0" - }, - "peerDependencies": { - "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" - } - }, - "node_modules/@webgpu/types": { - "version": "0.1.66", - "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.66.tgz", - "integrity": "sha512-YA2hLrwLpDsRueNDXIMqN9NTzD6bCDkuXbOSe0heS+f8YE8usA6Gbv1prj81pzVHrbaAma7zObnIC+I6/sXJgA==", - "license": "BSD-3-Clause" - }, - "node_modules/@xmldom/xmldom": { - "version": "0.8.11", - "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.11.tgz", - "integrity": "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==", - "license": "MIT", - "engines": { - "node": ">=10.0.0" - } - }, - "node_modules/7zip-bin": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/7zip-bin/-/7zip-bin-5.2.0.tgz", - "integrity": "sha512-ukTPVhqG4jNzMro2qA9HSCSSVJN3aN7tlb+hfqYCt3ER0yWroeA2VR38MNrOHLQ/cVj+DaIMad0kFCtWWowh/A==", - "dev": true, - "license": "MIT" - }, - "node_modules/abbrev": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", - "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", - "dev": true, - "license": "ISC" - }, - "node_modules/acorn": { - "version": "8.15.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", - "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", - "dev": true, - "license": "MIT", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/acorn-jsx": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", - "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" - } - }, - "node_modules/agent-base": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", - "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "4" - }, - "engines": { - "node": ">= 6.0.0" - } - }, - "node_modules/agentkeepalive": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz", - "integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "humanize-ms": "^1.2.1" - }, - "engines": { - "node": ">= 8.0.0" - } - }, - "node_modules/aggregate-error": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", - "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", - "dev": true, - "license": "MIT", - "dependencies": { - "clean-stack": "^2.0.0", - "indent-string": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/ajv-keywords": { - "version": "3.5.2", - "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", - "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "ajv": "^6.9.1" - } - }, - "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/any-promise": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", - "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", - "license": "MIT" - }, - "node_modules/anymatch": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", - "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "license": "ISC", - "dependencies": { - "normalize-path": "^3.0.0", - "picomatch": "^2.0.4" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/app-builder-bin": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/app-builder-bin/-/app-builder-bin-4.0.0.tgz", - "integrity": "sha512-xwdG0FJPQMe0M0UA4Tz0zEB8rBJTRA5a476ZawAqiBkMv16GRK5xpXThOjMaEOFnZ6zabejjG4J3da0SXG63KA==", - "dev": true, - "license": "MIT" - }, - "node_modules/app-builder-lib": { - "version": "24.13.3", - "resolved": "https://registry.npmjs.org/app-builder-lib/-/app-builder-lib-24.13.3.tgz", - "integrity": "sha512-FAzX6IBit2POXYGnTCT8YHFO/lr5AapAII6zzhQO3Rw4cEDOgK+t1xhLc5tNcKlicTHlo9zxIwnYCX9X2DLkig==", - "dev": true, - "license": "MIT", - "dependencies": { - "@develar/schema-utils": "~2.6.5", - "@electron/notarize": "2.2.1", - "@electron/osx-sign": "1.0.5", - "@electron/universal": "1.5.1", - "@malept/flatpak-bundler": "^0.4.0", - "@types/fs-extra": "9.0.13", - "async-exit-hook": "^2.0.1", - "bluebird-lst": "^1.0.9", - "builder-util": "24.13.1", - "builder-util-runtime": "9.2.4", - "chromium-pickle-js": "^0.2.0", - "debug": "^4.3.4", - "ejs": "^3.1.8", - "electron-publish": "24.13.1", - "form-data": "^4.0.0", - "fs-extra": "^10.1.0", - "hosted-git-info": "^4.1.0", - "is-ci": "^3.0.0", - "isbinaryfile": "^5.0.0", - "js-yaml": "^4.1.0", - "lazy-val": "^1.0.5", - "minimatch": "^5.1.1", - "read-config-file": "6.3.2", - "sanitize-filename": "^1.6.3", - "semver": "^7.3.8", - "tar": "^6.1.12", - "temp-file": "^3.4.0" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "dmg-builder": "24.13.3", - "electron-builder-squirrel-windows": "24.13.3" - } - }, - "node_modules/app-builder-lib/node_modules/fs-extra": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", - "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/app-builder-lib/node_modules/jsonfile": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", - "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", - "dev": true, - "license": "MIT", - "dependencies": { - "universalify": "^2.0.0" - }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/app-builder-lib/node_modules/minimatch": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", - "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/app-builder-lib/node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 10.0.0" - } - }, - "node_modules/aproba": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.1.0.tgz", - "integrity": "sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==", - "dev": true, - "license": "ISC" - }, - "node_modules/archiver": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/archiver/-/archiver-5.3.2.tgz", - "integrity": "sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "archiver-utils": "^2.1.0", - "async": "^3.2.4", - "buffer-crc32": "^0.2.1", - "readable-stream": "^3.6.0", - "readdir-glob": "^1.1.2", - "tar-stream": "^2.2.0", - "zip-stream": "^4.1.0" - }, - "engines": { - "node": ">= 10" - } - }, - "node_modules/archiver-utils": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-2.1.0.tgz", - "integrity": "sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "glob": "^7.1.4", - "graceful-fs": "^4.2.0", - "lazystream": "^1.0.0", - "lodash.defaults": "^4.2.0", - "lodash.difference": "^4.5.0", - "lodash.flatten": "^4.4.0", - "lodash.isplainobject": "^4.0.6", - "lodash.union": "^4.6.0", - "normalize-path": "^3.0.0", - "readable-stream": "^2.0.0" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/archiver-utils/node_modules/readable-stream": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", - "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" - } - }, - "node_modules/archiver-utils/node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/archiver-utils/node_modules/string_decoder": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "safe-buffer": "~5.1.0" - } - }, - "node_modules/are-we-there-yet": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-3.0.1.tgz", - "integrity": "sha512-QZW4EDmGwlYur0Yyf/b2uGucHQMa8aFUP7eu9ddR73vvhFyt4V0Vl3QHPcTNJ8l6qYOBdxgXdnBXQrHilfRQBg==", - "deprecated": "This package is no longer supported.", - "dev": true, - "license": "ISC", - "dependencies": { - "delegates": "^1.0.0", - "readable-stream": "^3.6.0" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" - } - }, - "node_modules/arg": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", - "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", - "license": "MIT" - }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, - "license": "Python-2.0" - }, - "node_modules/aria-hidden": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", - "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", - "license": "MIT", - "dependencies": { - "tslib": "^2.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/array-union": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", - "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/assert-plus": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", - "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">=0.8" - } - }, - "node_modules/astral-regex": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", - "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/async": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", - "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", - "dev": true, - "license": "MIT" - }, - "node_modules/async-exit-hook": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/async-exit-hook/-/async-exit-hook-2.0.1.tgz", - "integrity": "sha512-NW2cX8m1Q7KPA7a5M2ULQeZ2wR5qI5PAbw5L0UOMxdioVk9PMZ0h1TmyZEkPYrCvYjDlFICusOu1dlEKAAeXBw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } - }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/at-least-node": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", - "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">= 4.0.0" - } - }, - "node_modules/autoprefixer": { - "version": "10.4.21", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz", - "integrity": "sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/autoprefixer" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "browserslist": "^4.24.4", - "caniuse-lite": "^1.0.30001702", - "fraction.js": "^4.3.7", - "normalize-range": "^0.1.2", - "picocolors": "^1.1.1", - "postcss-value-parser": "^4.2.0" - }, - "bin": { - "autoprefixer": "bin/autoprefixer" - }, - "engines": { - "node": "^10 || ^12 || >=14" - }, - "peerDependencies": { - "postcss": "^8.1.0" - } - }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "license": "MIT" - }, - "node_modules/base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/baseline-browser-mapping": { - "version": "2.8.15", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.15.tgz", - "integrity": "sha512-qsJ8/X+UypqxHXN75M7dF88jNK37dLBRW7LeUzCPz+TNs37G8cfWy9nWzS+LS//g600zrt2le9KuXt0rWfDz5Q==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "baseline-browser-mapping": "dist/cli.js" - } - }, - "node_modules/binary-extensions": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", - "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/bl": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", - "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "buffer": "^5.5.0", - "inherits": "^2.0.4", - "readable-stream": "^3.4.0" - } - }, - "node_modules/bluebird": { - "version": "3.7.2", - "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", - "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", - "dev": true, - "license": "MIT" - }, - "node_modules/bluebird-lst": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/bluebird-lst/-/bluebird-lst-1.0.9.tgz", - "integrity": "sha512-7B1Rtx82hjnSD4PGLAjVWeYH3tHAcVUmChh85a3lltKQm6FresXh9ErQo6oAv6CqxttczC3/kEg8SY5NluPuUw==", - "dev": true, - "license": "MIT", - "dependencies": { - "bluebird": "^3.5.5" - } - }, - "node_modules/boolean": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz", - "integrity": "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==", - "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", - "dev": true, - "license": "MIT", - "optional": true - }, - "node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "license": "MIT", - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/browserslist": { - "version": "4.26.3", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.26.3.tgz", - "integrity": "sha512-lAUU+02RFBuCKQPj/P6NgjlbCnLBMp4UtgTx7vNHd3XSIJF87s9a5rA3aH2yw3GS9DqZAUbOtZdCCiZeVRqt0w==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "baseline-browser-mapping": "^2.8.9", - "caniuse-lite": "^1.0.30001746", - "electron-to-chromium": "^1.5.227", - "node-releases": "^2.0.21", - "update-browserslist-db": "^1.1.3" - }, - "bin": { - "browserslist": "cli.js" - }, - "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" - } - }, - "node_modules/buffer": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.1.13" - } - }, - "node_modules/buffer-crc32": { - "version": "0.2.13", - "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", - "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "*" - } - }, - "node_modules/buffer-equal": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/buffer-equal/-/buffer-equal-1.0.1.tgz", - "integrity": "sha512-QoV3ptgEaQpvVwbXdSO39iqPQTCxSF7A5U99AxbHYqUdCizL/lH2Z0A2y6nbZucxMEOtNyZfG2s6gsVugGpKkg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/buffer-from": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", - "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/builder-util": { - "version": "24.13.1", - "resolved": "https://registry.npmjs.org/builder-util/-/builder-util-24.13.1.tgz", - "integrity": "sha512-NhbCSIntruNDTOVI9fdXz0dihaqX2YuE1D6zZMrwiErzH4ELZHE6mdiB40wEgZNprDia+FghRFgKoAqMZRRjSA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/debug": "^4.1.6", - "7zip-bin": "~5.2.0", - "app-builder-bin": "4.0.0", - "bluebird-lst": "^1.0.9", - "builder-util-runtime": "9.2.4", - "chalk": "^4.1.2", - "cross-spawn": "^7.0.3", - "debug": "^4.3.4", - "fs-extra": "^10.1.0", - "http-proxy-agent": "^5.0.0", - "https-proxy-agent": "^5.0.1", - "is-ci": "^3.0.0", - "js-yaml": "^4.1.0", - "source-map-support": "^0.5.19", - "stat-mode": "^1.0.0", - "temp-file": "^3.4.0" - } - }, - "node_modules/builder-util-runtime": { - "version": "9.2.4", - "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.2.4.tgz", - "integrity": "sha512-upp+biKpN/XZMLim7aguUyW8s0FUpDvOtK6sbanMFDAMBzpHDqdhgVYm6zc9HJ6nWo7u2Lxk60i2M6Jd3aiNrA==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^4.3.4", - "sax": "^1.2.4" - }, - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/builder-util/node_modules/fs-extra": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", - "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/builder-util/node_modules/jsonfile": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", - "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", - "dev": true, - "license": "MIT", - "dependencies": { - "universalify": "^2.0.0" - }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/builder-util/node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 10.0.0" - } - }, - "node_modules/cacache": { - "version": "16.1.3", - "resolved": "https://registry.npmjs.org/cacache/-/cacache-16.1.3.tgz", - "integrity": "sha512-/+Emcj9DAXxX4cwlLmRI9c166RuL3w30zp4R7Joiv2cQTtTtA+jeuCAjH3ZlGnYS3tKENSrKhAzVVP9GVyzeYQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "@npmcli/fs": "^2.1.0", - "@npmcli/move-file": "^2.0.0", - "chownr": "^2.0.0", - "fs-minipass": "^2.1.0", - "glob": "^8.0.1", - "infer-owner": "^1.0.4", - "lru-cache": "^7.7.1", - "minipass": "^3.1.6", - "minipass-collect": "^1.0.2", - "minipass-flush": "^1.0.5", - "minipass-pipeline": "^1.2.4", - "mkdirp": "^1.0.4", - "p-map": "^4.0.0", - "promise-inflight": "^1.0.1", - "rimraf": "^3.0.2", - "ssri": "^9.0.0", - "tar": "^6.1.11", - "unique-filename": "^2.0.0" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" - } - }, - "node_modules/cacache/node_modules/glob": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz", - "integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==", - "deprecated": "Glob versions prior to v9 are no longer supported", - "dev": true, - "license": "ISC", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^5.0.1", - "once": "^1.3.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/cacache/node_modules/lru-cache": { - "version": "7.18.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", - "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/cacache/node_modules/minimatch": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", - "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/cacache/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/cacache/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true, - "license": "ISC" - }, - "node_modules/cacheable-lookup": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz", - "integrity": "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10.6.0" - } - }, - "node_modules/cacheable-request": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-7.0.4.tgz", - "integrity": "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==", - "dev": true, - "license": "MIT", - "dependencies": { - "clone-response": "^1.0.2", - "get-stream": "^5.1.0", - "http-cache-semantics": "^4.0.0", - "keyv": "^4.0.0", - "lowercase-keys": "^2.0.0", - "normalize-url": "^6.0.1", - "responselike": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/call-bound": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "license": "MIT", - "peer": true, - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "get-intrinsic": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/camelcase-css": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", - "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, - "node_modules/caniuse-lite": { - "version": "1.0.30001749", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001749.tgz", - "integrity": "sha512-0rw2fJOmLfnzCRbkm8EyHL8SvI2Apu5UbnQuTsJ0ClgrH8hcwFooJ1s5R0EP8o8aVrFu8++ae29Kt9/gZAZp/Q==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/caniuse-lite" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "CC-BY-4.0" - }, - "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/chokidar": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", - "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", - "license": "MIT", - "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" - }, - "engines": { - "node": ">= 8.10.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, - "node_modules/chokidar/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/chownr": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", - "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=10" - } - }, - "node_modules/chromium-pickle-js": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/chromium-pickle-js/-/chromium-pickle-js-0.2.0.tgz", - "integrity": "sha512-1R5Fho+jBq0DDydt+/vHWj5KJNJCKdARKOCwZUen84I5BreWoLqRLANH1U87eJy1tiASPtMnGqJJq0ZsLoRPOw==", - "dev": true, - "license": "MIT" - }, - "node_modules/ci-info": { - "version": "3.9.0", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", - "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/sibiraj-s" - } - ], - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/class-variance-authority": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", - "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", - "license": "Apache-2.0", - "dependencies": { - "clsx": "^2.1.1" - }, - "funding": { - "url": "https://polar.sh/cva" - } - }, - "node_modules/clean-stack": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", - "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/cli-cursor": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", - "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", - "dev": true, - "license": "MIT", - "dependencies": { - "restore-cursor": "^3.1.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/cli-spinners": { - "version": "2.9.2", - "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", - "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/cli-truncate": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-2.1.0.tgz", - "integrity": "sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "slice-ansi": "^3.0.0", - "string-width": "^4.2.0" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/cliui": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", - "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/clone": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", - "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8" - } - }, - "node_modules/clone-response": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.3.tgz", - "integrity": "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==", - "dev": true, - "license": "MIT", - "dependencies": { - "mimic-response": "^1.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/clsx": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", - "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "license": "MIT" - }, - "node_modules/color-support": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", - "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", - "dev": true, - "license": "ISC", - "bin": { - "color-support": "bin.js" - } - }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dev": true, - "license": "MIT", - "dependencies": { - "delayed-stream": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/commander": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz", - "integrity": "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, - "node_modules/compare-version": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/compare-version/-/compare-version-0.1.2.tgz", - "integrity": "sha512-pJDh5/4wrEnXX/VWRZvruAGHkzKdr46z11OlTPN+VrATlWWhSKewNCJ1futCO5C7eJB3nPMFZA1LeYtcFboZ2A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/compress-commons": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-4.1.2.tgz", - "integrity": "sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "buffer-crc32": "^0.2.13", - "crc32-stream": "^4.0.2", - "normalize-path": "^3.0.0", - "readable-stream": "^3.6.0" - }, - "engines": { - "node": ">= 10" - } - }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, - "license": "MIT" - }, - "node_modules/config-file-ts": { - "version": "0.2.6", - "resolved": "https://registry.npmjs.org/config-file-ts/-/config-file-ts-0.2.6.tgz", - "integrity": "sha512-6boGVaglwblBgJqGyxm4+xCmEGcWgnWHSWHY5jad58awQhB6gftq0G8HbzU39YqCIYHMLAiL1yjwiZ36m/CL8w==", - "dev": true, - "license": "MIT", - "dependencies": { - "glob": "^10.3.10", - "typescript": "^5.3.3" - } - }, - "node_modules/config-file-ts/node_modules/glob": { - "version": "10.4.5", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", - "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", - "dev": true, - "license": "ISC", - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/config-file-ts/node_modules/minipass": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", - "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/console-control-strings": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", - "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true, - "license": "MIT" - }, - "node_modules/core-util-is": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", - "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/crc": { - "version": "3.8.0", - "resolved": "https://registry.npmjs.org/crc/-/crc-3.8.0.tgz", - "integrity": "sha512-iX3mfgcTMIq3ZKLIsVFAbv7+Mc10kxabAGQb8HvjA1o3T1PIYprbakQ65d3I+2HGHt6nSKkM9PYjgoJO2KcFBQ==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "buffer": "^5.1.0" - } - }, - "node_modules/crc-32": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", - "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", - "dev": true, - "license": "Apache-2.0", - "peer": true, - "bin": { - "crc32": "bin/crc32.njs" - }, - "engines": { - "node": ">=0.8" - } - }, - "node_modules/crc32-stream": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-4.0.3.tgz", - "integrity": "sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "crc-32": "^1.2.0", - "readable-stream": "^3.4.0" - }, - "engines": { - "node": ">= 10" - } - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/cssesc": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", - "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", - "license": "MIT", - "bin": { - "cssesc": "bin/cssesc" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/csstype": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", - "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/decompress-response": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", - "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "mimic-response": "^3.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/decompress-response/node_modules/mimic-response": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", - "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/deep-is": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", - "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/defaults": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", - "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "clone": "^1.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/defer-to-connect": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", - "integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - } - }, - "node_modules/define-data-property": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", - "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "es-define-property": "^1.0.0", - "es-errors": "^1.3.0", - "gopd": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/define-properties": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", - "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "define-data-property": "^1.0.1", - "has-property-descriptors": "^1.0.0", - "object-keys": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/delegates": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", - "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=8" - } - }, - "node_modules/detect-node": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", - "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", - "dev": true, - "license": "MIT", - "optional": true - }, - "node_modules/detect-node-es": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", - "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", - "license": "MIT" - }, - "node_modules/didyoumean": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", - "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", - "license": "Apache-2.0" - }, - "node_modules/dir-compare": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/dir-compare/-/dir-compare-3.3.0.tgz", - "integrity": "sha512-J7/et3WlGUCxjdnD3HAAzQ6nsnc0WL6DD7WcwJb7c39iH1+AWfg+9OqzJNaI6PkBwBvm1mhZNL9iY/nRiZXlPg==", - "dev": true, - "license": "MIT", - "dependencies": { - "buffer-equal": "^1.0.0", - "minimatch": "^3.0.4" - } - }, - "node_modules/dir-compare/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/dir-compare/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/dir-glob": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", - "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-type": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/dlv": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", - "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", - "license": "MIT" - }, - "node_modules/dmg-builder": { - "version": "24.13.3", - "resolved": "https://registry.npmjs.org/dmg-builder/-/dmg-builder-24.13.3.tgz", - "integrity": "sha512-rcJUkMfnJpfCboZoOOPf4L29TRtEieHNOeAbYPWPxlaBw/Z1RKrRA86dOI9rwaI4tQSc/RD82zTNHprfUHXsoQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "app-builder-lib": "24.13.3", - "builder-util": "24.13.1", - "builder-util-runtime": "9.2.4", - "fs-extra": "^10.1.0", - "iconv-lite": "^0.6.2", - "js-yaml": "^4.1.0" - }, - "optionalDependencies": { - "dmg-license": "^1.0.11" - } - }, - "node_modules/dmg-builder/node_modules/fs-extra": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", - "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/dmg-builder/node_modules/jsonfile": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", - "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", - "dev": true, - "license": "MIT", - "dependencies": { - "universalify": "^2.0.0" - }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/dmg-builder/node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 10.0.0" - } - }, - "node_modules/dmg-license": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/dmg-license/-/dmg-license-1.0.11.tgz", - "integrity": "sha512-ZdzmqwKmECOWJpqefloC5OJy1+WZBBse5+MR88z9g9Zn4VY+WYUkAyojmhzJckH5YbbZGcYIuGAkY5/Ys5OM2Q==", - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "dependencies": { - "@types/plist": "^3.0.1", - "@types/verror": "^1.10.3", - "ajv": "^6.10.0", - "crc": "^3.8.0", - "iconv-corefoundation": "^1.1.7", - "plist": "^3.0.4", - "smart-buffer": "^4.0.2", - "verror": "^1.10.0" - }, - "bin": { - "dmg-license": "bin/dmg-license.js" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/dnd-timeline": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/dnd-timeline/-/dnd-timeline-2.2.0.tgz", - "integrity": "sha512-bQ/2bm70eA7YeztgdxoSpdpTwPzj8VT2/wTlYrnFpqJ71et7EVJZR35XPZIVzBqSyKK9T/QyUzE26gYck9ldxg==", - "license": "MIT", - "dependencies": { - "@dnd-kit/core": "^6.1.0", - "@dnd-kit/utilities": "^3.2.2", - "resize-observer-polyfill": "^1.5.1" - } - }, - "node_modules/doctrine": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", - "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "esutils": "^2.0.2" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/dotenv": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-9.0.2.tgz", - "integrity": "sha512-I9OvvrHp4pIARv4+x9iuewrWycX6CcZtoAu1XrzPxc5UygMJXJZYmBsynku8IkrJwgypE5DGNjDPmPRhDCptUg==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=10" - } - }, - "node_modules/dotenv-expand": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-5.1.0.tgz", - "integrity": "sha512-YXQl1DSa4/PQyRfgrv6aoNjhasp/p4qs9FjJ4q4cQk+8m4r6k4ZSiEyytKG8f8W9gi8WsQtIObNmKd+tMzNTmA==", - "dev": true, - "license": "BSD-2-Clause" - }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/earcut": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/earcut/-/earcut-3.0.2.tgz", - "integrity": "sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==", - "license": "ISC" - }, - "node_modules/eastasianwidth": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "license": "MIT" - }, - "node_modules/ejs": { - "version": "3.1.10", - "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", - "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "jake": "^10.8.5" - }, - "bin": { - "ejs": "bin/cli.js" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/electron": { - "version": "30.5.1", - "resolved": "https://registry.npmjs.org/electron/-/electron-30.5.1.tgz", - "integrity": "sha512-AhL7+mZ8Lg14iaNfoYTkXQ2qee8mmsQyllKdqxlpv/zrKgfxz6jNVtcRRbQtLxtF8yzcImWdfTQROpYiPumdbw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "@electron/get": "^2.0.0", - "@types/node": "^20.9.0", - "extract-zip": "^2.0.1" - }, - "bin": { - "electron": "cli.js" - }, - "engines": { - "node": ">= 12.20.55" - } - }, - "node_modules/electron-builder": { - "version": "24.13.3", - "resolved": "https://registry.npmjs.org/electron-builder/-/electron-builder-24.13.3.tgz", - "integrity": "sha512-yZSgVHft5dNVlo31qmJAe4BVKQfFdwpRw7sFp1iQglDRCDD6r22zfRJuZlhtB5gp9FHUxCMEoWGq10SkCnMAIg==", - "dev": true, - "license": "MIT", - "dependencies": { - "app-builder-lib": "24.13.3", - "builder-util": "24.13.1", - "builder-util-runtime": "9.2.4", - "chalk": "^4.1.2", - "dmg-builder": "24.13.3", - "fs-extra": "^10.1.0", - "is-ci": "^3.0.0", - "lazy-val": "^1.0.5", - "read-config-file": "6.3.2", - "simple-update-notifier": "2.0.0", - "yargs": "^17.6.2" - }, - "bin": { - "electron-builder": "cli.js", - "install-app-deps": "install-app-deps.js" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/electron-builder-squirrel-windows": { - "version": "24.13.3", - "resolved": "https://registry.npmjs.org/electron-builder-squirrel-windows/-/electron-builder-squirrel-windows-24.13.3.tgz", - "integrity": "sha512-oHkV0iogWfyK+ah9ZIvMDpei1m9ZRpdXcvde1wTpra2U8AFDNNpqJdnin5z+PM1GbQ5BoaKCWas2HSjtR0HwMg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "app-builder-lib": "24.13.3", - "archiver": "^5.3.1", - "builder-util": "24.13.1", - "fs-extra": "^10.1.0" - } - }, - "node_modules/electron-builder-squirrel-windows/node_modules/fs-extra": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", - "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/electron-builder-squirrel-windows/node_modules/jsonfile": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", - "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "universalify": "^2.0.0" - }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/electron-builder-squirrel-windows/node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 10.0.0" - } - }, - "node_modules/electron-builder/node_modules/fs-extra": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", - "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/electron-builder/node_modules/jsonfile": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", - "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", - "dev": true, - "license": "MIT", - "dependencies": { - "universalify": "^2.0.0" - }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/electron-builder/node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 10.0.0" - } - }, - "node_modules/electron-publish": { - "version": "24.13.1", - "resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-24.13.1.tgz", - "integrity": "sha512-2ZgdEqJ8e9D17Hwp5LEq5mLQPjqU3lv/IALvgp+4W8VeNhryfGhYEQC/PgDPMrnWUp+l60Ou5SJLsu+k4mhQ8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/fs-extra": "^9.0.11", - "builder-util": "24.13.1", - "builder-util-runtime": "9.2.4", - "chalk": "^4.1.2", - "fs-extra": "^10.1.0", - "lazy-val": "^1.0.5", - "mime": "^2.5.2" - } - }, - "node_modules/electron-publish/node_modules/fs-extra": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", - "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/electron-publish/node_modules/jsonfile": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", - "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", - "dev": true, - "license": "MIT", - "dependencies": { - "universalify": "^2.0.0" - }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/electron-publish/node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 10.0.0" - } - }, - "node_modules/electron-rebuild": { - "version": "3.2.9", - "resolved": "https://registry.npmjs.org/electron-rebuild/-/electron-rebuild-3.2.9.tgz", - "integrity": "sha512-FkEZNFViUem3P0RLYbZkUjC8LUFIK+wKq09GHoOITSJjfDAVQv964hwaNseTTWt58sITQX3/5fHNYcTefqaCWw==", - "deprecated": "Please use @electron/rebuild moving forward. There is no API change, just a package name change", - "dev": true, - "license": "MIT", - "dependencies": { - "@malept/cross-spawn-promise": "^2.0.0", - "chalk": "^4.0.0", - "debug": "^4.1.1", - "detect-libc": "^2.0.1", - "fs-extra": "^10.0.0", - "got": "^11.7.0", - "lzma-native": "^8.0.5", - "node-abi": "^3.0.0", - "node-api-version": "^0.1.4", - "node-gyp": "^9.0.0", - "ora": "^5.1.0", - "semver": "^7.3.5", - "tar": "^6.0.5", - "yargs": "^17.0.1" - }, - "bin": { - "electron-rebuild": "lib/src/cli.js" - }, - "engines": { - "node": ">=12.13.0" - } - }, - "node_modules/electron-rebuild/node_modules/@malept/cross-spawn-promise": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@malept/cross-spawn-promise/-/cross-spawn-promise-2.0.0.tgz", - "integrity": "sha512-1DpKU0Z5ThltBwjNySMC14g0CkbyhCaz9FkhxqNsZI6uAPJXFS8cMXlBKo26FJ8ZuW6S9GCMcR9IO5k2X5/9Fg==", - "dev": true, - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/malept" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/subscription/pkg/npm-.malept-cross-spawn-promise?utm_medium=referral&utm_source=npm_fund" - } - ], - "license": "Apache-2.0", - "dependencies": { - "cross-spawn": "^7.0.1" - }, - "engines": { - "node": ">= 12.13.0" - } - }, - "node_modules/electron-rebuild/node_modules/fs-extra": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", - "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/electron-rebuild/node_modules/jsonfile": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", - "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", - "dev": true, - "license": "MIT", - "dependencies": { - "universalify": "^2.0.0" - }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/electron-rebuild/node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 10.0.0" - } - }, - "node_modules/electron-to-chromium": { - "version": "1.5.234", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.234.tgz", - "integrity": "sha512-RXfEp2x+VRYn8jbKfQlRImzoJU01kyDvVPBmG39eU2iuRVhuS6vQNocB8J0/8GrIMLnPzgz4eW6WiRnJkTuNWg==", - "dev": true, - "license": "ISC" - }, - "node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "license": "MIT" - }, - "node_modules/encoding": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", - "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "iconv-lite": "^0.6.2" - } - }, - "node_modules/end-of-stream": { - "version": "1.4.5", - "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", - "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", - "dev": true, - "license": "MIT", - "dependencies": { - "once": "^1.4.0" - } - }, - "node_modules/env-paths": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", - "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/err-code": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", - "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", - "dev": true, - "license": "MIT" - }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-set-tostringtag": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", - "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es6-error": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", - "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==", - "dev": true, - "license": "MIT", - "optional": true - }, - "node_modules/esbuild": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", - "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=12" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.21.5", - "@esbuild/android-arm": "0.21.5", - "@esbuild/android-arm64": "0.21.5", - "@esbuild/android-x64": "0.21.5", - "@esbuild/darwin-arm64": "0.21.5", - "@esbuild/darwin-x64": "0.21.5", - "@esbuild/freebsd-arm64": "0.21.5", - "@esbuild/freebsd-x64": "0.21.5", - "@esbuild/linux-arm": "0.21.5", - "@esbuild/linux-arm64": "0.21.5", - "@esbuild/linux-ia32": "0.21.5", - "@esbuild/linux-loong64": "0.21.5", - "@esbuild/linux-mips64el": "0.21.5", - "@esbuild/linux-ppc64": "0.21.5", - "@esbuild/linux-riscv64": "0.21.5", - "@esbuild/linux-s390x": "0.21.5", - "@esbuild/linux-x64": "0.21.5", - "@esbuild/netbsd-x64": "0.21.5", - "@esbuild/openbsd-x64": "0.21.5", - "@esbuild/sunos-x64": "0.21.5", - "@esbuild/win32-arm64": "0.21.5", - "@esbuild/win32-ia32": "0.21.5", - "@esbuild/win32-x64": "0.21.5" - } - }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/eslint": { - "version": "8.57.1", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", - "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", - "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.2.0", - "@eslint-community/regexpp": "^4.6.1", - "@eslint/eslintrc": "^2.1.4", - "@eslint/js": "8.57.1", - "@humanwhocodes/config-array": "^0.13.0", - "@humanwhocodes/module-importer": "^1.0.1", - "@nodelib/fs.walk": "^1.2.8", - "@ungap/structured-clone": "^1.2.0", - "ajv": "^6.12.4", - "chalk": "^4.0.0", - "cross-spawn": "^7.0.2", - "debug": "^4.3.2", - "doctrine": "^3.0.0", - "escape-string-regexp": "^4.0.0", - "eslint-scope": "^7.2.2", - "eslint-visitor-keys": "^3.4.3", - "espree": "^9.6.1", - "esquery": "^1.4.2", - "esutils": "^2.0.2", - "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^6.0.1", - "find-up": "^5.0.0", - "glob-parent": "^6.0.2", - "globals": "^13.19.0", - "graphemer": "^1.4.0", - "ignore": "^5.2.0", - "imurmurhash": "^0.1.4", - "is-glob": "^4.0.0", - "is-path-inside": "^3.0.3", - "js-yaml": "^4.1.0", - "json-stable-stringify-without-jsonify": "^1.0.1", - "levn": "^0.4.1", - "lodash.merge": "^4.6.2", - "minimatch": "^3.1.2", - "natural-compare": "^1.4.0", - "optionator": "^0.9.3", - "strip-ansi": "^6.0.1", - "text-table": "^0.2.0" - }, - "bin": { - "eslint": "bin/eslint.js" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint-plugin-react-hooks": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.6.2.tgz", - "integrity": "sha512-QzliNJq4GinDBcD8gPB5v0wh6g8q3SUi6EFF0x8N/BL9PoVs0atuGc47ozMRyOWAKdwaZ5OnbOEa3WR+dSGKuQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0" - } - }, - "node_modules/eslint-plugin-react-refresh": { - "version": "0.4.23", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.23.tgz", - "integrity": "sha512-G4j+rv0NmbIR45kni5xJOrYvCtyD3/7LjpVH8MPPcudXDcNu8gv+4ATTDXTtbRR8rTCM5HxECvCSsRmxKnWDsA==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "eslint": ">=8.40" - } - }, - "node_modules/eslint-scope": { - "version": "7.2.2", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", - "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^5.2.0" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/eslint/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/espree": { - "version": "9.6.1", - "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", - "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "acorn": "^8.9.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^3.4.1" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/esquery": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", - "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "estraverse": "^5.1.0" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/esrecurse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", - "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "estraverse": "^5.2.0" - }, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=4.0" - } - }, - "node_modules/esutils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/eventemitter3": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", - "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", - "license": "MIT" - }, - "node_modules/exponential-backoff": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", - "integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==", - "dev": true, - "license": "Apache-2.0" - }, - "node_modules/extract-zip": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", - "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "debug": "^4.1.1", - "get-stream": "^5.1.0", - "yauzl": "^2.10.0" - }, - "bin": { - "extract-zip": "cli.js" - }, - "engines": { - "node": ">= 10.17.0" - }, - "optionalDependencies": { - "@types/yauzl": "^2.9.1" - } - }, - "node_modules/extsprintf": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.4.1.tgz", - "integrity": "sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA==", - "dev": true, - "engines": [ - "node >=0.6.0" - ], - "license": "MIT", - "optional": true - }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-glob": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", - "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.8" - }, - "engines": { - "node": ">=8.6.0" - } - }, - "node_modules/fast-glob/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-levenshtein": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", - "dev": true, - "license": "MIT" - }, - "node_modules/fastq": { - "version": "1.19.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", - "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", - "license": "ISC", - "dependencies": { - "reusify": "^1.0.4" - } - }, - "node_modules/fd-slicer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", - "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", - "dev": true, - "license": "MIT", - "dependencies": { - "pend": "~1.2.0" - } - }, - "node_modules/file-entry-cache": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", - "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", - "dev": true, - "license": "MIT", - "dependencies": { - "flat-cache": "^3.0.4" - }, - "engines": { - "node": "^10.12.0 || >=12.0.0" - } - }, - "node_modules/filelist": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz", - "integrity": "sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "minimatch": "^5.0.1" - } - }, - "node_modules/filelist/node_modules/minimatch": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", - "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "license": "MIT", - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/find-up": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", - "dev": true, - "license": "MIT", - "dependencies": { - "locate-path": "^6.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/flat-cache": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", - "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", - "dev": true, - "license": "MIT", - "dependencies": { - "flatted": "^3.2.9", - "keyv": "^4.5.3", - "rimraf": "^3.0.2" - }, - "engines": { - "node": "^10.12.0 || >=12.0.0" - } - }, - "node_modules/flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", - "dev": true, - "license": "ISC" - }, - "node_modules/foreground-child": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", - "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", - "license": "ISC", - "dependencies": { - "cross-spawn": "^7.0.6", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/form-data": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", - "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", - "dev": true, - "license": "MIT", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/fraction.js": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", - "integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==", - "dev": true, - "license": "MIT", - "engines": { - "node": "*" - }, - "funding": { - "type": "patreon", - "url": "https://github.com/sponsors/rawify" - } - }, - "node_modules/fs-constants": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", - "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/fs-extra": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", - "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^4.0.0", - "universalify": "^0.1.0" - }, - "engines": { - "node": ">=6 <7 || >=8" - } - }, - "node_modules/fs-minipass": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", - "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", - "dev": true, - "license": "ISC", - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/fs-minipass/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/fs-minipass/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true, - "license": "ISC" - }, - "node_modules/fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "dev": true, - "license": "ISC" - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/gauge": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/gauge/-/gauge-4.0.4.tgz", - "integrity": "sha512-f9m+BEN5jkg6a0fZjleidjN51VE1X+mPFQ2DJ0uv1V39oCLCbsGe6yjbBnp7eK7z/+GAon99a3nHuqbuuthyPg==", - "deprecated": "This package is no longer supported.", - "dev": true, - "license": "ISC", - "dependencies": { - "aproba": "^1.0.3 || ^2.0.0", - "color-support": "^1.1.3", - "console-control-strings": "^1.1.0", - "has-unicode": "^2.0.1", - "signal-exit": "^3.0.7", - "string-width": "^4.2.3", - "strip-ansi": "^6.0.1", - "wide-align": "^1.1.5" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" - } - }, - "node_modules/gauge/node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/gensync": { - "version": "1.0.0-beta.2", - "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", - "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/get-caller-file": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true, - "license": "ISC", - "engines": { - "node": "6.* || 8.* || >= 10.*" - } - }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-nonce": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", - "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/get-stream": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", - "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", - "dev": true, - "license": "MIT", - "dependencies": { - "pump": "^3.0.0" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/gifuct-js": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/gifuct-js/-/gifuct-js-2.1.2.tgz", - "integrity": "sha512-rI2asw77u0mGgwhV3qA+OEgYqaDn5UNqgs+Bx0FGwSpuqfYn+Ir6RQY5ENNQ8SbIiG/m5gVa7CD5RriO4f4Lsg==", - "license": "MIT", - "dependencies": { - "js-binary-schema-parser": "^2.0.3" - } - }, - "node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Glob versions prior to v9 are no longer supported", - "dev": true, - "license": "ISC", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.3" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/glob/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/glob/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/global-agent": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz", - "integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==", - "dev": true, - "license": "BSD-3-Clause", - "optional": true, - "dependencies": { - "boolean": "^3.0.1", - "es6-error": "^4.1.1", - "matcher": "^3.0.0", - "roarr": "^2.15.3", - "semver": "^7.3.2", - "serialize-error": "^7.0.1" - }, - "engines": { - "node": ">=10.0" - } - }, - "node_modules/globals": { - "version": "13.24.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", - "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "type-fest": "^0.20.2" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/globalthis": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", - "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "define-properties": "^1.2.1", - "gopd": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/globby": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", - "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", - "dev": true, - "license": "MIT", - "dependencies": { - "array-union": "^2.1.0", - "dir-glob": "^3.0.1", - "fast-glob": "^3.2.9", - "ignore": "^5.2.0", - "merge2": "^1.4.1", - "slash": "^3.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/got": { - "version": "11.8.6", - "resolved": "https://registry.npmjs.org/got/-/got-11.8.6.tgz", - "integrity": "sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@sindresorhus/is": "^4.0.0", - "@szmarczak/http-timer": "^4.0.5", - "@types/cacheable-request": "^6.0.1", - "@types/responselike": "^1.0.0", - "cacheable-lookup": "^5.0.3", - "cacheable-request": "^7.0.2", - "decompress-response": "^6.0.0", - "http2-wrapper": "^1.0.0-beta.5.2", - "lowercase-keys": "^2.0.0", - "p-cancelable": "^2.0.0", - "responselike": "^2.0.0" - }, - "engines": { - "node": ">=10.19.0" - }, - "funding": { - "url": "https://github.com/sindresorhus/got?sponsor=1" - } - }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/graphemer": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", - "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", - "dev": true, - "license": "MIT" - }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/has-property-descriptors": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", - "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "es-define-property": "^1.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-symbols": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-unicode": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", - "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/hosted-git-info": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", - "integrity": "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==", - "dev": true, - "license": "ISC", - "dependencies": { - "lru-cache": "^6.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/hosted-git-info/node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/hosted-git-info/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true, - "license": "ISC" - }, - "node_modules/http-cache-semantics": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", - "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", - "dev": true, - "license": "BSD-2-Clause" - }, - "node_modules/http-proxy-agent": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", - "integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@tootallnate/once": "2", - "agent-base": "6", - "debug": "4" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/http2-wrapper": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz", - "integrity": "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==", - "dev": true, - "license": "MIT", - "dependencies": { - "quick-lru": "^5.1.1", - "resolve-alpn": "^1.0.0" - }, - "engines": { - "node": ">=10.19.0" - } - }, - "node_modules/https-proxy-agent": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", - "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", - "dev": true, - "license": "MIT", - "dependencies": { - "agent-base": "6", - "debug": "4" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/humanize-ms": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", - "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.0.0" - } - }, - "node_modules/iconv-corefoundation": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/iconv-corefoundation/-/iconv-corefoundation-1.1.7.tgz", - "integrity": "sha512-T10qvkw0zz4wnm560lOEg0PovVqUXuOFhhHAkixw8/sycy7TJt7v/RrkEKEQnAw2viPSJu6iAkErxnzR0g8PpQ==", - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "dependencies": { - "cli-truncate": "^2.1.0", - "node-addon-api": "^1.6.3" - }, - "engines": { - "node": "^8.11.2 || >=10" - } - }, - "node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "dev": true, - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/ieee754": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "BSD-3-Clause" - }, - "node_modules/ignore": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/import-fresh": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", - "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/imurmurhash": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8.19" - } - }, - "node_modules/indent-string": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", - "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/infer-owner": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/infer-owner/-/infer-owner-1.0.4.tgz", - "integrity": "sha512-IClj+Xz94+d7irH5qRyfJonOdfTzuDaifE6ZPWfx0N0+/ATZCbuTPq2prFl526urkQd90WyUKIh1DfBQ2hMz9A==", - "dev": true, - "license": "ISC" - }, - "node_modules/inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", - "dev": true, - "license": "ISC", - "dependencies": { - "once": "^1.3.0", - "wrappy": "1" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/ip-address": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz", - "integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 12" - } - }, - "node_modules/is-binary-path": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", - "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", - "license": "MIT", - "dependencies": { - "binary-extensions": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/is-ci": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/is-ci/-/is-ci-3.0.1.tgz", - "integrity": "sha512-ZYvCgrefwqoQ6yTyYUbQu64HsITZ3NfKX1lzaEYdkTDcfKzzCI/wthRRYKkdjHKFVgNiXKAKm65Zo1pk2as/QQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ci-info": "^3.2.0" - }, - "bin": { - "is-ci": "bin.js" - } - }, - "node_modules/is-core-module": { - "version": "2.16.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", - "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", - "license": "MIT", - "dependencies": { - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "license": "MIT", - "dependencies": { - "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-interactive": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", - "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/is-lambda": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-lambda/-/is-lambda-1.0.1.tgz", - "integrity": "sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } - }, - "node_modules/is-path-inside": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", - "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/is-unicode-supported": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", - "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/isarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/isbinaryfile": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-5.0.6.tgz", - "integrity": "sha512-I+NmIfBHUl+r2wcDd6JwE9yWje/PIVY/R5/CmV8dXLZd5K+L9X2klAOwfAHNnondLXkbHyTAleQAWonpTJBTtw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 18.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/gjtorikian/" - } - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "license": "ISC" - }, - "node_modules/ismobilejs": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/ismobilejs/-/ismobilejs-1.1.1.tgz", - "integrity": "sha512-VaFW53yt8QO61k2WJui0dHf4SlL8lxBofUuUmwBo0ljPk0Drz2TiuDW4jo3wDcv41qy/SxrJ+VAzJ/qYqsmzRw==", - "license": "MIT" - }, - "node_modules/jackspeak": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", - "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" - } - }, - "node_modules/jake": { - "version": "10.9.4", - "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz", - "integrity": "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "async": "^3.2.6", - "filelist": "^1.0.4", - "picocolors": "^1.1.1" - }, - "bin": { - "jake": "bin/cli.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/jiti": { - "version": "1.21.7", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", - "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", - "license": "MIT", - "bin": { - "jiti": "bin/jiti.js" - } - }, - "node_modules/js-binary-schema-parser": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/js-binary-schema-parser/-/js-binary-schema-parser-2.0.3.tgz", - "integrity": "sha512-xezGJmOb4lk/M1ZZLTR/jaBHQ4gG/lqQnJqdIv4721DMggsa1bDVlHXNeHYogaIEHD9vCRv0fcL4hMA+Coarkg==", - "license": "MIT" - }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "license": "MIT" - }, - "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dev": true, - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/jsesc": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", - "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", - "dev": true, - "license": "MIT", - "bin": { - "jsesc": "bin/jsesc" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/json-buffer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", - "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-stable-stringify-without-jsonify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-stringify-safe": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", - "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", - "dev": true, - "license": "ISC", - "optional": true - }, - "node_modules/json5": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", - "dev": true, - "license": "MIT", - "bin": { - "json5": "lib/cli.js" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/jsonfile": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", - "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", - "dev": true, - "license": "MIT", - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/keyv": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", - "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", - "dev": true, - "license": "MIT", - "dependencies": { - "json-buffer": "3.0.1" - } - }, - "node_modules/lazy-val": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/lazy-val/-/lazy-val-1.0.5.tgz", - "integrity": "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/lazystream": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", - "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "readable-stream": "^2.0.5" - }, - "engines": { - "node": ">= 0.6.3" - } - }, - "node_modules/lazystream/node_modules/readable-stream": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", - "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" - } - }, - "node_modules/lazystream/node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/lazystream/node_modules/string_decoder": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "safe-buffer": "~5.1.0" - } - }, - "node_modules/levn": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", - "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1", - "type-check": "~0.4.0" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/lightningcss": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.1.tgz", - "integrity": "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg==", - "dev": true, - "license": "MPL-2.0", - "optional": true, - "peer": true, - "dependencies": { - "detect-libc": "^2.0.3" - }, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - }, - "optionalDependencies": { - "lightningcss-darwin-arm64": "1.30.1", - "lightningcss-darwin-x64": "1.30.1", - "lightningcss-freebsd-x64": "1.30.1", - "lightningcss-linux-arm-gnueabihf": "1.30.1", - "lightningcss-linux-arm64-gnu": "1.30.1", - "lightningcss-linux-arm64-musl": "1.30.1", - "lightningcss-linux-x64-gnu": "1.30.1", - "lightningcss-linux-x64-musl": "1.30.1", - "lightningcss-win32-arm64-msvc": "1.30.1", - "lightningcss-win32-x64-msvc": "1.30.1" - } - }, - "node_modules/lightningcss-darwin-arm64": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.1.tgz", - "integrity": "sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-x64": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.1.tgz", - "integrity": "sha512-k1EvjakfumAQoTfcXUcHQZhSpLlkAuEkdMBsI/ivWw9hL+7FtilQc0Cy3hrx0AAQrVtQAbMI7YjCgYgvn37PzA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-freebsd-x64": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.1.tgz", - "integrity": "sha512-kmW6UGCGg2PcyUE59K5r0kWfKPAVy4SltVeut+umLCFoJ53RdCUWxcRDzO1eTaxf/7Q2H7LTquFHPL5R+Gjyig==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.1.tgz", - "integrity": "sha512-MjxUShl1v8pit+6D/zSPq9S9dQ2NPFSQwGvxBCYaBYLPlCWuPh9/t1MRS8iUaR8i+a6w7aps+B4N0S1TYP/R+Q==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.1.tgz", - "integrity": "sha512-gB72maP8rmrKsnKYy8XUuXi/4OctJiuQjcuqWNlJQ6jZiWqtPvqFziskH3hnajfvKB27ynbVCucKSm2rkQp4Bw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.1.tgz", - "integrity": "sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.1.tgz", - "integrity": "sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-musl": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.1.tgz", - "integrity": "sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.1.tgz", - "integrity": "sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.1.tgz", - "integrity": "sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lilconfig": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", - "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", - "license": "MIT", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/antonk52" - } - }, - "node_modules/lines-and-columns": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", - "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", - "license": "MIT" - }, - "node_modules/locate-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-locate": "^5.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "dev": true, - "license": "MIT" - }, - "node_modules/lodash.defaults": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", - "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/lodash.difference": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/lodash.difference/-/lodash.difference-4.5.0.tgz", - "integrity": "sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/lodash.flatten": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz", - "integrity": "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/lodash.isplainobject": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", - "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/lodash.merge": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/lodash.union": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/lodash.union/-/lodash.union-4.6.0.tgz", - "integrity": "sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/log-symbols": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", - "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", - "dev": true, - "license": "MIT", - "dependencies": { - "chalk": "^4.1.0", - "is-unicode-supported": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/loose-envify": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", - "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "license": "MIT", - "dependencies": { - "js-tokens": "^3.0.0 || ^4.0.0" - }, - "bin": { - "loose-envify": "cli.js" - } - }, - "node_modules/lowercase-keys": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", - "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/lru-cache": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", - "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^3.0.2" - } - }, - "node_modules/lucide-react": { - "version": "0.545.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.545.0.tgz", - "integrity": "sha512-7r1/yUuflQDSt4f1bpn5ZAocyIxcTyVyBBChSVtBKn5M+392cPmI5YJMWOJKk/HUWGm5wg83chlAZtCcGbEZtw==", - "license": "ISC", - "peerDependencies": { - "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, - "node_modules/lzma-native": { - "version": "8.0.6", - "resolved": "https://registry.npmjs.org/lzma-native/-/lzma-native-8.0.6.tgz", - "integrity": "sha512-09xfg67mkL2Lz20PrrDeNYZxzeW7ADtpYFbwSQh9U8+76RIzx5QsJBMy8qikv3hbUPfpy6hqwxt6FcGK81g9AA==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "node-addon-api": "^3.1.0", - "node-gyp-build": "^4.2.1", - "readable-stream": "^3.6.0" - }, - "bin": { - "lzmajs": "bin/lzmajs" - }, - "engines": { - "node": ">=10.0.0" - } - }, - "node_modules/lzma-native/node_modules/node-addon-api": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-3.2.1.tgz", - "integrity": "sha512-mmcei9JghVNDYydghQmeDX8KoAm0FAiYyIcUt/N4nhyAipB17pllZQDOJD2fotxABnt4Mdz+dKTO7eftLg4d0A==", - "dev": true, - "license": "MIT" - }, - "node_modules/make-fetch-happen": { - "version": "10.2.1", - "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-10.2.1.tgz", - "integrity": "sha512-NgOPbRiaQM10DYXvN3/hhGVI2M5MtITFryzBGxHM5p4wnFxsVCbxkrBrDsk+EZ5OB4jEOT7AjDxtdF+KVEFT7w==", - "dev": true, - "license": "ISC", - "dependencies": { - "agentkeepalive": "^4.2.1", - "cacache": "^16.1.0", - "http-cache-semantics": "^4.1.0", - "http-proxy-agent": "^5.0.0", - "https-proxy-agent": "^5.0.0", - "is-lambda": "^1.0.1", - "lru-cache": "^7.7.1", - "minipass": "^3.1.6", - "minipass-collect": "^1.0.2", - "minipass-fetch": "^2.0.3", - "minipass-flush": "^1.0.5", - "minipass-pipeline": "^1.2.4", - "negotiator": "^0.6.3", - "promise-retry": "^2.0.1", - "socks-proxy-agent": "^7.0.0", - "ssri": "^9.0.0" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" - } - }, - "node_modules/make-fetch-happen/node_modules/lru-cache": { - "version": "7.18.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", - "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/make-fetch-happen/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/make-fetch-happen/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true, - "license": "ISC" - }, - "node_modules/matcher": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", - "integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "escape-string-regexp": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "license": "MIT", - "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" - } - }, - "node_modules/mime": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", - "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", - "dev": true, - "license": "MIT", - "bin": { - "mime": "cli.js" - }, - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dev": true, - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mimic-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", - "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/mimic-response": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz", - "integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/minipass": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", - "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", - "license": "ISC", - "engines": { - "node": ">=8" - } - }, - "node_modules/minipass-collect": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-1.0.2.tgz", - "integrity": "sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA==", - "dev": true, - "license": "ISC", - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/minipass-collect/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/minipass-collect/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true, - "license": "ISC" - }, - "node_modules/minipass-fetch": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-2.1.2.tgz", - "integrity": "sha512-LT49Zi2/WMROHYoqGgdlQIZh8mLPZmOrN2NdJjMXxYe4nkN6FUyuPuOAOedNJDrx0IRGg9+4guZewtp8hE6TxA==", - "dev": true, - "license": "MIT", - "dependencies": { - "minipass": "^3.1.6", - "minipass-sized": "^1.0.3", - "minizlib": "^2.1.2" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" - }, - "optionalDependencies": { - "encoding": "^0.1.13" - } - }, - "node_modules/minipass-fetch/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/minipass-fetch/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true, - "license": "ISC" - }, - "node_modules/minipass-flush": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.5.tgz", - "integrity": "sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw==", - "dev": true, - "license": "ISC", - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/minipass-flush/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/minipass-flush/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true, - "license": "ISC" - }, - "node_modules/minipass-pipeline": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz", - "integrity": "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==", - "dev": true, - "license": "ISC", - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/minipass-pipeline/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/minipass-pipeline/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true, - "license": "ISC" - }, - "node_modules/minipass-sized": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/minipass-sized/-/minipass-sized-1.0.3.tgz", - "integrity": "sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g==", - "dev": true, - "license": "ISC", - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/minipass-sized/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/minipass-sized/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true, - "license": "ISC" - }, - "node_modules/minizlib": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", - "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", - "dev": true, - "license": "MIT", - "dependencies": { - "minipass": "^3.0.0", - "yallist": "^4.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/minizlib/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/minizlib/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true, - "license": "ISC" - }, - "node_modules/mkdirp": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", - "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", - "dev": true, - "license": "MIT", - "bin": { - "mkdirp": "bin/cmd.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/mp4-muxer": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/mp4-muxer/-/mp4-muxer-5.2.2.tgz", - "integrity": "sha512-dhozjTywI0h2qFzeShagt8YYw811fh1XlwiDCE2f6Aeqf6xG2CyuShoSa5E0AZDO8pPF0JOZ3wOmWBNWIGdSpQ==", - "deprecated": "This library is superseded by Mediabunny. Please migrate to it.", - "license": "MIT", - "dependencies": { - "@types/dom-webcodecs": "^0.1.6", - "@types/wicg-file-system-access": "^2020.9.5" - } - }, - "node_modules/mp4box": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/mp4box/-/mp4box-2.2.0.tgz", - "integrity": "sha512-tE+L7wdhSuwBKZGjUzj03Qzj4lWyOw8pHSPyLnvHTKx92NJGkJls0pcEusUHWEh5gWVBlhdu79STJh4Bubz9mQ==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=20.8.1" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/mz": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", - "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", - "license": "MIT", - "dependencies": { - "any-promise": "^1.0.0", - "object-assign": "^4.0.1", - "thenify-all": "^1.0.0" - } - }, - "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/natural-compare": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", - "dev": true, - "license": "MIT" - }, - "node_modules/negotiator": { - "version": "0.6.4", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", - "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/node-abi": { - "version": "3.78.0", - "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.78.0.tgz", - "integrity": "sha512-E2wEyrgX/CqvicaQYU3Ze1PFGjc4QYPGsjUrlYkqAE0WjHEZwgOsGMPMzkMse4LjJbDmaEuDX3CM036j5K2DSQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "semver": "^7.3.5" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/node-addon-api": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-1.7.2.tgz", - "integrity": "sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg==", - "dev": true, - "license": "MIT", - "optional": true - }, - "node_modules/node-api-version": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/node-api-version/-/node-api-version-0.1.4.tgz", - "integrity": "sha512-KGXihXdUChwJAOHO53bv9/vXcLmdUsZ6jIptbvYvkpKfth+r7jw44JkVxQFA3kX5nQjzjmGu1uAu/xNNLNlI5g==", - "dev": true, - "license": "MIT", - "dependencies": { - "semver": "^7.3.5" - } - }, - "node_modules/node-gyp": { - "version": "9.4.1", - "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-9.4.1.tgz", - "integrity": "sha512-OQkWKbjQKbGkMf/xqI1jjy3oCTgMKJac58G2+bjZb3fza6gW2YrCSdMQYaoTb70crvE//Gngr4f0AgVHmqHvBQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "env-paths": "^2.2.0", - "exponential-backoff": "^3.1.1", - "glob": "^7.1.4", - "graceful-fs": "^4.2.6", - "make-fetch-happen": "^10.0.3", - "nopt": "^6.0.0", - "npmlog": "^6.0.0", - "rimraf": "^3.0.2", - "semver": "^7.3.5", - "tar": "^6.1.2", - "which": "^2.0.2" - }, - "bin": { - "node-gyp": "bin/node-gyp.js" - }, - "engines": { - "node": "^12.13 || ^14.13 || >=16" - } - }, - "node_modules/node-gyp-build": { - "version": "4.8.4", - "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", - "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", - "license": "MIT", - "bin": { - "node-gyp-build": "bin.js", - "node-gyp-build-optional": "optional.js", - "node-gyp-build-test": "build-test.js" - } - }, - "node_modules/node-releases": { - "version": "2.0.23", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.23.tgz", - "integrity": "sha512-cCmFDMSm26S6tQSDpBCg/NR8NENrVPhAJSf+XbxBG4rPFaaonlEoE9wHQmun+cls499TQGSb7ZyPBRlzgKfpeg==", - "dev": true, - "license": "MIT" - }, - "node_modules/nopt": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/nopt/-/nopt-6.0.0.tgz", - "integrity": "sha512-ZwLpbTgdhuZUnZzjd7nb1ZV+4DoiC6/sfiVKok72ym/4Tlf+DFdlHYmT2JPmcNNWV6Pi3SDf1kT+A4r9RTuT9g==", - "dev": true, - "license": "ISC", - "dependencies": { - "abbrev": "^1.0.0" - }, - "bin": { - "nopt": "bin/nopt.js" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" - } - }, - "node_modules/normalize-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/normalize-range": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", - "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/normalize-url": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz", - "integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/npmlog": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-6.0.2.tgz", - "integrity": "sha512-/vBvz5Jfr9dT/aFWd0FIRf+T/Q2WBsLENygUaFUqstqsycmZAP/t5BvFJTK0viFmSUxiUKTUplWy5vt+rvKIxg==", - "deprecated": "This package is no longer supported.", - "dev": true, - "license": "ISC", - "dependencies": { - "are-we-there-yet": "^3.0.0", - "console-control-strings": "^1.1.0", - "gauge": "^4.0.3", - "set-blocking": "^2.0.0" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" - } - }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/object-hash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", - "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, - "node_modules/object-inspect": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", - "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/object-keys": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", - "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, - "license": "ISC", - "dependencies": { - "wrappy": "1" - } - }, - "node_modules/onetime": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", - "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "mimic-fn": "^2.1.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/optionator": { - "version": "0.9.4", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", - "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", - "dev": true, - "license": "MIT", - "dependencies": { - "deep-is": "^0.1.3", - "fast-levenshtein": "^2.0.6", - "levn": "^0.4.1", - "prelude-ls": "^1.2.1", - "type-check": "^0.4.0", - "word-wrap": "^1.2.5" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/ora": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", - "integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "bl": "^4.1.0", - "chalk": "^4.1.0", - "cli-cursor": "^3.1.0", - "cli-spinners": "^2.5.0", - "is-interactive": "^1.0.0", - "is-unicode-supported": "^0.1.0", - "log-symbols": "^4.1.0", - "strip-ansi": "^6.0.0", - "wcwidth": "^1.0.1" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-cancelable": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz", - "integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "yocto-queue": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-locate": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-limit": "^3.0.2" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-map": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz", - "integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "aggregate-error": "^3.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/package-json-from-dist": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", - "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", - "license": "BlueOak-1.0.0" - }, - "node_modules/parent-module": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dev": true, - "license": "MIT", - "dependencies": { - "callsites": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/parse-svg-path": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/parse-svg-path/-/parse-svg-path-0.1.2.tgz", - "integrity": "sha512-JyPSBnkTJ0AI8GGJLfMXvKq42cj5c006fnLz6fXy6zfoVjJizi8BNTpu8on8ziI1cKy9d9DGNuY17Ce7wuejpQ==", - "license": "MIT" - }, - "node_modules/path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-is-absolute": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-parse": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "license": "MIT" - }, - "node_modules/path-scurry": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", - "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" - }, - "engines": { - "node": ">=16 || 14 >=14.18" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/path-scurry/node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "license": "ISC" - }, - "node_modules/path-type": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", - "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/pend": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", - "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", - "dev": true, - "license": "MIT" - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/pify": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", - "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/pirates": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", - "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, - "node_modules/pixi.js": { - "version": "8.14.0", - "resolved": "https://registry.npmjs.org/pixi.js/-/pixi.js-8.14.0.tgz", - "integrity": "sha512-ituDiEBb1Oqx56RYwTtC6MjPUhPfF/i15fpUv5oEqmzC/ce3SaSumulJcOjKG7+y0J0Ekl9Rl4XTxaUw+MVFZw==", - "license": "MIT", - "dependencies": { - "@pixi/colord": "^2.9.6", - "@types/css-font-loading-module": "^0.0.12", - "@types/earcut": "^3.0.0", - "@webgpu/types": "^0.1.40", - "@xmldom/xmldom": "^0.8.10", - "earcut": "^3.0.2", - "eventemitter3": "^5.0.1", - "gifuct-js": "^2.1.2", - "ismobilejs": "^1.1.1", - "parse-svg-path": "^0.1.2", - "tiny-lru": "^11.4.5" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/pixijs" - } - }, - "node_modules/plist": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/plist/-/plist-3.1.0.tgz", - "integrity": "sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@xmldom/xmldom": "^0.8.8", - "base64-js": "^1.5.1", - "xmlbuilder": "^15.1.1" - }, - "engines": { - "node": ">=10.4.0" - } - }, - "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.11", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/postcss-import": { - "version": "15.1.0", - "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", - "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", - "license": "MIT", - "dependencies": { - "postcss-value-parser": "^4.0.0", - "read-cache": "^1.0.0", - "resolve": "^1.1.7" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "postcss": "^8.0.0" - } - }, - "node_modules/postcss-js": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", - "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "camelcase-css": "^2.0.1" - }, - "engines": { - "node": "^12 || ^14 || >= 16" - }, - "peerDependencies": { - "postcss": "^8.4.21" - } - }, - "node_modules/postcss-load-config": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", - "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "lilconfig": "^3.1.1" - }, - "engines": { - "node": ">= 18" - }, - "peerDependencies": { - "jiti": ">=1.21.0", - "postcss": ">=8.0.9", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "jiti": { - "optional": true - }, - "postcss": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } - } - }, - "node_modules/postcss-nested": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", - "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "postcss-selector-parser": "^6.1.1" - }, - "engines": { - "node": ">=12.0" - }, - "peerDependencies": { - "postcss": "^8.2.14" - } - }, - "node_modules/postcss-selector-parser": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", - "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", - "license": "MIT", - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/postcss-value-parser": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", - "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", - "license": "MIT" - }, - "node_modules/prelude-ls": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", - "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/process-nextick-args": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", - "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/progress": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", - "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/promise-inflight": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz", - "integrity": "sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g==", - "dev": true, - "license": "ISC" - }, - "node_modules/promise-retry": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", - "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", - "dev": true, - "license": "MIT", - "dependencies": { - "err-code": "^2.0.2", - "retry": "^0.12.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/pump": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", - "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", - "dev": true, - "license": "MIT", - "dependencies": { - "end-of-stream": "^1.1.0", - "once": "^1.3.1" - } - }, - "node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/qs": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", - "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", - "license": "BSD-3-Clause", - "peer": true, - "dependencies": { - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/queue-microtask": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/quick-lru": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", - "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/react": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", - "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/react-dom": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", - "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0", - "scheduler": "^0.23.2" - }, - "peerDependencies": { - "react": "^18.3.1" - } - }, - "node_modules/react-icons": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.5.0.tgz", - "integrity": "sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==", - "license": "MIT", - "peerDependencies": { - "react": "*" - } - }, - "node_modules/react-refresh": { - "version": "0.17.0", - "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", - "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/react-remove-scroll": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.1.tgz", - "integrity": "sha512-HpMh8+oahmIdOuS5aFKKY6Pyog+FNaZV/XyJOq7b4YFwsFHe5yYfdbIalI4k3vU2nSDql7YskmUseHsRrJqIPA==", - "license": "MIT", - "dependencies": { - "react-remove-scroll-bar": "^2.3.7", - "react-style-singleton": "^2.2.3", - "tslib": "^2.1.0", - "use-callback-ref": "^1.3.3", - "use-sidecar": "^1.1.3" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/react-remove-scroll-bar": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", - "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", - "license": "MIT", - "dependencies": { - "react-style-singleton": "^2.2.2", - "tslib": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/react-style-singleton": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", - "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", - "license": "MIT", - "dependencies": { - "get-nonce": "^1.0.0", - "tslib": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/read-cache": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", - "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", - "license": "MIT", - "dependencies": { - "pify": "^2.3.0" - } - }, - "node_modules/read-config-file": { - "version": "6.3.2", - "resolved": "https://registry.npmjs.org/read-config-file/-/read-config-file-6.3.2.tgz", - "integrity": "sha512-M80lpCjnE6Wt6zb98DoW8WHR09nzMSpu8XHtPkiTHrJ5Az9CybfeQhTJ8D7saeBHpGhLPIVyA8lcL6ZmdKwY6Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "config-file-ts": "^0.2.4", - "dotenv": "^9.0.2", - "dotenv-expand": "^5.1.0", - "js-yaml": "^4.1.0", - "json5": "^2.2.0", - "lazy-val": "^1.0.4" - }, - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "dev": true, - "license": "MIT", - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/readdir-glob": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-1.1.3.tgz", - "integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==", - "dev": true, - "license": "Apache-2.0", - "peer": true, - "dependencies": { - "minimatch": "^5.1.0" - } - }, - "node_modules/readdir-glob/node_modules/minimatch": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", - "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", - "dev": true, - "license": "ISC", - "peer": true, - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "license": "MIT", - "dependencies": { - "picomatch": "^2.2.1" - }, - "engines": { - "node": ">=8.10.0" - } - }, - "node_modules/require-directory": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", - "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/resize-observer-polyfill": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz", - "integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==", - "license": "MIT" - }, - "node_modules/resolve": { - "version": "1.22.10", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", - "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", - "license": "MIT", - "dependencies": { - "is-core-module": "^2.16.0", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/resolve-alpn": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz", - "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==", - "dev": true, - "license": "MIT" - }, - "node_modules/resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/responselike": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/responselike/-/responselike-2.0.1.tgz", - "integrity": "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==", - "dev": true, - "license": "MIT", - "dependencies": { - "lowercase-keys": "^2.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/restore-cursor": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", - "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", - "dev": true, - "license": "MIT", - "dependencies": { - "onetime": "^5.1.0", - "signal-exit": "^3.0.2" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/restore-cursor/node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/retry": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", - "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/reusify": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", - "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", - "license": "MIT", - "engines": { - "iojs": ">=1.0.0", - "node": ">=0.10.0" - } - }, - "node_modules/rimraf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "deprecated": "Rimraf versions prior to v4 are no longer supported", - "dev": true, - "license": "ISC", - "dependencies": { - "glob": "^7.1.3" - }, - "bin": { - "rimraf": "bin.js" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/roarr": { - "version": "2.15.4", - "resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz", - "integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==", - "dev": true, - "license": "BSD-3-Clause", - "optional": true, - "dependencies": { - "boolean": "^3.0.1", - "detect-node": "^2.0.4", - "globalthis": "^1.0.1", - "json-stringify-safe": "^5.0.1", - "semver-compare": "^1.0.0", - "sprintf-js": "^1.1.2" - }, - "engines": { - "node": ">=8.0" - } - }, - "node_modules/rollup": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.4.tgz", - "integrity": "sha512-CLEVl+MnPAiKh5pl4dEWSyMTpuflgNQiLGhMv8ezD5W/qP8AKvmYpCOKRRNOh7oRKnauBZ4SyeYkMS+1VSyKwQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "1.0.8" - }, - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.52.4", - "@rollup/rollup-android-arm64": "4.52.4", - "@rollup/rollup-darwin-arm64": "4.52.4", - "@rollup/rollup-darwin-x64": "4.52.4", - "@rollup/rollup-freebsd-arm64": "4.52.4", - "@rollup/rollup-freebsd-x64": "4.52.4", - "@rollup/rollup-linux-arm-gnueabihf": "4.52.4", - "@rollup/rollup-linux-arm-musleabihf": "4.52.4", - "@rollup/rollup-linux-arm64-gnu": "4.52.4", - "@rollup/rollup-linux-arm64-musl": "4.52.4", - "@rollup/rollup-linux-loong64-gnu": "4.52.4", - "@rollup/rollup-linux-ppc64-gnu": "4.52.4", - "@rollup/rollup-linux-riscv64-gnu": "4.52.4", - "@rollup/rollup-linux-riscv64-musl": "4.52.4", - "@rollup/rollup-linux-s390x-gnu": "4.52.4", - "@rollup/rollup-linux-x64-gnu": "4.52.4", - "@rollup/rollup-linux-x64-musl": "4.52.4", - "@rollup/rollup-openharmony-arm64": "4.52.4", - "@rollup/rollup-win32-arm64-msvc": "4.52.4", - "@rollup/rollup-win32-ia32-msvc": "4.52.4", - "@rollup/rollup-win32-x64-gnu": "4.52.4", - "@rollup/rollup-win32-x64-msvc": "4.52.4", - "fsevents": "~2.3.2" - } - }, - "node_modules/run-parallel": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "queue-microtask": "^1.2.2" - } - }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true, - "license": "MIT" - }, - "node_modules/sanitize-filename": { - "version": "1.6.3", - "resolved": "https://registry.npmjs.org/sanitize-filename/-/sanitize-filename-1.6.3.tgz", - "integrity": "sha512-y/52Mcy7aw3gRm7IrcGDFx/bCk4AhRh2eI9luHOQM86nZsqwiRkkq2GekHXBBD+SmPidc8i2PqtYZl+pWJ8Oeg==", - "dev": true, - "license": "WTFPL OR ISC", - "dependencies": { - "truncate-utf8-bytes": "^1.0.0" - } - }, - "node_modules/sax": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.1.tgz", - "integrity": "sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==", - "dev": true, - "license": "ISC" - }, - "node_modules/scheduler": { - "version": "0.23.2", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", - "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0" - } - }, - "node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/semver-compare": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz", - "integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==", - "dev": true, - "license": "MIT", - "optional": true - }, - "node_modules/serialize-error": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz", - "integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "type-fest": "^0.13.1" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/serialize-error/node_modules/type-fest": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz", - "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==", - "dev": true, - "license": "(MIT OR CC0-1.0)", - "optional": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/set-blocking": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", - "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", - "dev": true, - "license": "ISC" - }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "license": "MIT", - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", - "license": "MIT", - "peer": true, - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", - "side-channel-map": "^1.0.1", - "side-channel-weakmap": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", - "license": "MIT", - "peer": true, - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-map": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", - "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "license": "MIT", - "peer": true, - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-weakmap": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", - "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "license": "MIT", - "peer": true, - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3", - "side-channel-map": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/simple-update-notifier": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", - "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "semver": "^7.5.3" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/slash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", - "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/slice-ansi": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-3.0.0.tgz", - "integrity": "sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "ansi-styles": "^4.0.0", - "astral-regex": "^2.0.0", - "is-fullwidth-code-point": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/smart-buffer": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", - "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 6.0.0", - "npm": ">= 3.0.0" - } - }, - "node_modules/socks": { - "version": "2.8.7", - "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", - "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ip-address": "^10.0.1", - "smart-buffer": "^4.2.0" - }, - "engines": { - "node": ">= 10.0.0", - "npm": ">= 3.0.0" - } - }, - "node_modules/socks-proxy-agent": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-7.0.0.tgz", - "integrity": "sha512-Fgl0YPZ902wEsAyiQ+idGd1A7rSFx/ayC1CQVMw5P+EQx2V0SgpGtf6OKFhVjPflPUl9YMmEOnmfjCdMUsygww==", - "dev": true, - "license": "MIT", - "dependencies": { - "agent-base": "^6.0.2", - "debug": "^4.3.3", - "socks": "^2.6.2" - }, - "engines": { - "node": ">= 10" - } - }, - "node_modules/sonner": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz", - "integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==", - "license": "MIT", - "peerDependencies": { - "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", - "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" - } - }, - "node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/source-map-support": { - "version": "0.5.21", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", - "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", - "dev": true, - "license": "MIT", - "dependencies": { - "buffer-from": "^1.0.0", - "source-map": "^0.6.0" - } - }, - "node_modules/sprintf-js": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", - "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", - "dev": true, - "license": "BSD-3-Clause", - "optional": true - }, - "node_modules/ssri": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/ssri/-/ssri-9.0.1.tgz", - "integrity": "sha512-o57Wcn66jMQvfHG1FlYbWeZWW/dHZhJXjpIcTfXldXEk5nz5lStPo3mK0OJQfGR3RbZUlbISexbljkJzuEj/8Q==", - "dev": true, - "license": "ISC", - "dependencies": { - "minipass": "^3.1.1" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" - } - }, - "node_modules/ssri/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/ssri/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true, - "license": "ISC" - }, - "node_modules/stat-mode": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/stat-mode/-/stat-mode-1.0.0.tgz", - "integrity": "sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, - "node_modules/string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "dev": true, - "license": "MIT", - "dependencies": { - "safe-buffer": "~5.2.0" - } - }, - "node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width-cjs": { - "name": "string-width", - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi-cjs": { - "name": "strip-ansi", - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-json-comments": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", - "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/sucrase": { - "version": "3.35.0", - "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz", - "integrity": "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==", - "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.2", - "commander": "^4.0.0", - "glob": "^10.3.10", - "lines-and-columns": "^1.1.6", - "mz": "^2.7.0", - "pirates": "^4.0.1", - "ts-interface-checker": "^0.1.9" - }, - "bin": { - "sucrase": "bin/sucrase", - "sucrase-node": "bin/sucrase-node" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/sucrase/node_modules/commander": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", - "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, - "node_modules/sucrase/node_modules/glob": { - "version": "10.4.5", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", - "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", - "license": "ISC", - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/sucrase/node_modules/minipass": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", - "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", - "license": "ISC", - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/sumchecker": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz", - "integrity": "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "debug": "^4.1.0" - }, - "engines": { - "node": ">= 8.0" - } - }, - "node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/supports-preserve-symlinks-flag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/tailwind-merge": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.3.1.tgz", - "integrity": "sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/dcastil" - } - }, - "node_modules/tailwindcss": { - "version": "3.4.18", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.18.tgz", - "integrity": "sha512-6A2rnmW5xZMdw11LYjhcI5846rt9pbLSabY5XPxo+XWdxwZaFEn47Go4NzFiHu9sNNmr/kXivP1vStfvMaK1GQ==", - "license": "MIT", - "dependencies": { - "@alloc/quick-lru": "^5.2.0", - "arg": "^5.0.2", - "chokidar": "^3.6.0", - "didyoumean": "^1.2.2", - "dlv": "^1.1.3", - "fast-glob": "^3.3.2", - "glob-parent": "^6.0.2", - "is-glob": "^4.0.3", - "jiti": "^1.21.7", - "lilconfig": "^3.1.3", - "micromatch": "^4.0.8", - "normalize-path": "^3.0.0", - "object-hash": "^3.0.0", - "picocolors": "^1.1.1", - "postcss": "^8.4.47", - "postcss-import": "^15.1.0", - "postcss-js": "^4.0.1", - "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", - "postcss-nested": "^6.2.0", - "postcss-selector-parser": "^6.1.2", - "resolve": "^1.22.8", - "sucrase": "^3.35.0" - }, - "bin": { - "tailwind": "lib/cli.js", - "tailwindcss": "lib/cli.js" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/tailwindcss-animate": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/tailwindcss-animate/-/tailwindcss-animate-1.0.7.tgz", - "integrity": "sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==", - "license": "MIT", - "peerDependencies": { - "tailwindcss": ">=3.0.0 || insiders" - } - }, - "node_modules/tar": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", - "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", - "dev": true, - "license": "ISC", - "dependencies": { - "chownr": "^2.0.0", - "fs-minipass": "^2.0.0", - "minipass": "^5.0.0", - "minizlib": "^2.1.1", - "mkdirp": "^1.0.3", - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/tar-stream": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", - "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "bl": "^4.0.3", - "end-of-stream": "^1.4.1", - "fs-constants": "^1.0.0", - "inherits": "^2.0.3", - "readable-stream": "^3.1.1" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/tar/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true, - "license": "ISC" - }, - "node_modules/temp-file": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/temp-file/-/temp-file-3.4.0.tgz", - "integrity": "sha512-C5tjlC/HCtVUOi3KWVokd4vHVViOmGjtLwIh4MuzPo/nMYTV/p1urt3RnMz2IWXDdKEGJH3k5+KPxtqRsUYGtg==", - "dev": true, - "license": "MIT", - "dependencies": { - "async-exit-hook": "^2.0.1", - "fs-extra": "^10.0.0" - } - }, - "node_modules/temp-file/node_modules/fs-extra": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", - "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/temp-file/node_modules/jsonfile": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", - "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", - "dev": true, - "license": "MIT", - "dependencies": { - "universalify": "^2.0.0" - }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/temp-file/node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 10.0.0" - } - }, - "node_modules/text-table": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", - "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", - "dev": true, - "license": "MIT" - }, - "node_modules/thenify": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", - "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", - "license": "MIT", - "dependencies": { - "any-promise": "^1.0.0" - } - }, - "node_modules/thenify-all": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", - "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", - "license": "MIT", - "dependencies": { - "thenify": ">= 3.1.0 < 4" - }, - "engines": { - "node": ">=0.8" - } - }, - "node_modules/tiny-lru": { - "version": "11.4.5", - "resolved": "https://registry.npmjs.org/tiny-lru/-/tiny-lru-11.4.5.tgz", - "integrity": "sha512-hkcz3FjNJfKXjV4mjQ1OrXSLAehg8Hw+cEZclOVT+5c/cWQWImQ9wolzTjth+dmmDe++p3bme3fTxz6Q4Etsqw==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=12" - } - }, - "node_modules/tmp": { - "version": "0.2.5", - "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", - "integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.14" - } - }, - "node_modules/tmp-promise": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/tmp-promise/-/tmp-promise-3.0.3.tgz", - "integrity": "sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "tmp": "^0.2.0" - } - }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "license": "MIT", - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, - "node_modules/truncate-utf8-bytes": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/truncate-utf8-bytes/-/truncate-utf8-bytes-1.0.2.tgz", - "integrity": "sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ==", - "dev": true, - "license": "WTFPL", - "dependencies": { - "utf8-byte-length": "^1.0.1" - } - }, - "node_modules/ts-api-utils": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz", - "integrity": "sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=16" - }, - "peerDependencies": { - "typescript": ">=4.2.0" - } - }, - "node_modules/ts-interface-checker": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", - "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", - "license": "Apache-2.0" - }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD" - }, - "node_modules/type-check": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", - "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", - "dev": true, - "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/type-fest": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", - "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", - "dev": true, - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/uiohook-napi": { - "version": "1.5.4", - "resolved": "https://registry.npmjs.org/uiohook-napi/-/uiohook-napi-1.5.4.tgz", - "integrity": "sha512-7vPVDNwgb6MwTgviA/dnF2MrW0X5xm76fAqaOAC3cEKkswqAZOPw1USu14Sr6383s5qhXegcJaR63CpJOPCNAg==", - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "node-gyp-build": "4.x.x" - }, - "engines": { - "node": ">= 16" - } - }, - "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/unique-filename": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-2.0.1.tgz", - "integrity": "sha512-ODWHtkkdx3IAR+veKxFV+VBkUMcN+FaqzUUd7IZzt+0zhDZFPFxhlqwPF3YQvMHx1TD0tdgYl+kuPnJ8E6ql7A==", - "dev": true, - "license": "ISC", - "dependencies": { - "unique-slug": "^3.0.0" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" - } - }, - "node_modules/unique-slug": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-3.0.0.tgz", - "integrity": "sha512-8EyMynh679x/0gqE9fT9oilG+qEt+ibFyqjuVTsZn1+CMxH+XLlpvr2UZx4nVcCwTpx81nICr2JQFkM+HPLq4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "imurmurhash": "^0.1.4" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" - } - }, - "node_modules/universalify": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", - "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4.0.0" - } - }, - "node_modules/update-browserslist-db": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", - "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "escalade": "^3.2.0", - "picocolors": "^1.1.1" - }, - "bin": { - "update-browserslist-db": "cli.js" - }, - "peerDependencies": { - "browserslist": ">= 4.21.0" - } - }, - "node_modules/uri-js": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "punycode": "^2.1.0" - } - }, - "node_modules/url": { - "version": "0.11.4", - "resolved": "https://registry.npmjs.org/url/-/url-0.11.4.tgz", - "integrity": "sha512-oCwdVC7mTuWiPyjLUz/COz5TLk6wgp0RCsN+wHZ2Ekneac9w8uuV0njcbbie2ME+Vs+d6duwmYuR3HgQXs1fOg==", - "license": "MIT", - "peer": true, - "dependencies": { - "punycode": "^1.4.1", - "qs": "^6.12.3" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/url/node_modules/punycode": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz", - "integrity": "sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==", - "license": "MIT", - "peer": true - }, - "node_modules/use-callback-ref": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", - "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", - "license": "MIT", - "dependencies": { - "tslib": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/use-sidecar": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", - "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", - "license": "MIT", - "dependencies": { - "detect-node-es": "^1.1.0", - "tslib": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/utf8-byte-length": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.5.tgz", - "integrity": "sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA==", - "dev": true, - "license": "(WTFPL OR MIT)" - }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "license": "MIT" - }, - "node_modules/verror": { - "version": "1.10.1", - "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.1.tgz", - "integrity": "sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "assert-plus": "^1.0.0", - "core-util-is": "1.0.2", - "extsprintf": "^1.2.0" - }, - "engines": { - "node": ">=0.6.0" - } - }, - "node_modules/vite": { - "version": "5.4.20", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.20.tgz", - "integrity": "sha512-j3lYzGC3P+B5Yfy/pfKNgVEg4+UtcIJcVRt2cDjIOmhLourAqPqf8P7acgxeiSgUB7E3p2P8/3gNIgDLpwzs4g==", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "^0.21.3", - "postcss": "^8.4.43", - "rollup": "^4.20.0" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^18.0.0 || >=20.0.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^18.0.0 || >=20.0.0", - "less": "*", - "lightningcss": "^1.21.0", - "sass": "*", - "sass-embedded": "*", - "stylus": "*", - "sugarss": "*", - "terser": "^5.4.0" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "less": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - } - } - }, - "node_modules/vite-plugin-electron": { - "version": "0.28.8", - "resolved": "https://registry.npmjs.org/vite-plugin-electron/-/vite-plugin-electron-0.28.8.tgz", - "integrity": "sha512-ir+B21oSGK9j23OEvt4EXyco9xDCaF6OGFe0V/8Zc0yL2+HMyQ6mmNQEIhXsEsZCSfIowBpwQBeHH4wVsfraeg==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "vite-plugin-electron-renderer": "*" - }, - "peerDependenciesMeta": { - "vite-plugin-electron-renderer": { - "optional": true - } - } - }, - "node_modules/vite-plugin-electron-renderer": { - "version": "0.14.6", - "resolved": "https://registry.npmjs.org/vite-plugin-electron-renderer/-/vite-plugin-electron-renderer-0.14.6.tgz", - "integrity": "sha512-oqkWFa7kQIkvHXG7+Mnl1RTroA4sP0yesKatmAy0gjZC4VwUqlvF9IvOpHd1fpLWsqYX/eZlVxlhULNtaQ78Jw==", - "dev": true, - "license": "MIT" - }, - "node_modules/wcwidth": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", - "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==", - "dev": true, - "license": "MIT", - "dependencies": { - "defaults": "^1.0.3" - } - }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/wide-align": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", - "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^1.0.2 || 2 || 3 || 4" - } - }, - "node_modules/word-wrap": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", - "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs": { - "name": "wrap-ansi", - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/xmlbuilder": { - "version": "15.1.1", - "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", - "integrity": "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.0" - } - }, - "node_modules/y18n": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", - "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=10" - } - }, - "node_modules/yallist": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", - "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", - "dev": true, - "license": "ISC" - }, - "node_modules/yargs": { - "version": "17.7.2", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", - "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "cliui": "^8.0.1", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.3", - "y18n": "^5.0.5", - "yargs-parser": "^21.1.1" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/yargs-parser": { - "version": "21.1.1", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", - "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/yauzl": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", - "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", - "dev": true, - "license": "MIT", - "dependencies": { - "buffer-crc32": "~0.2.3", - "fd-slicer": "~1.1.0" - } - }, - "node_modules/yocto-queue": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", - "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/zip-stream": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-4.1.1.tgz", - "integrity": "sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "archiver-utils": "^3.0.4", - "compress-commons": "^4.1.2", - "readable-stream": "^3.6.0" - }, - "engines": { - "node": ">= 10" - } - }, - "node_modules/zip-stream/node_modules/archiver-utils": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-3.0.4.tgz", - "integrity": "sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "glob": "^7.2.3", - "graceful-fs": "^4.2.0", - "lazystream": "^1.0.0", - "lodash.defaults": "^4.2.0", - "lodash.difference": "^4.5.0", - "lodash.flatten": "^4.4.0", - "lodash.isplainobject": "^4.0.6", - "lodash.union": "^4.6.0", - "normalize-path": "^3.0.0", - "readable-stream": "^3.6.0" - }, - "engines": { - "node": ">= 10" - } - } - } + "name": "recordly", + "version": "1.3.5-beta.2", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "recordly", + "version": "1.3.5-beta.2", + "hasInstallScript": true, + "dependencies": { + "@phosphor-icons/react": "^2.1.10", + "capturekit": "^1.0.13", + "electron-updater": "^6.8.3", + "ffmpeg-static": "^5.3.0", + "ffprobe-static": "^3.1.0", + "uiohook-napi": "^1.5.4" + }, + "devDependencies": { + "@biomejs/biome": "2.3.13", + "@electron/rebuild": "^4.2.0", + "@fix-webm-duration/fix": "^1.0.1", + "@radix-ui/react-accordion": "^1.2.12", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-dropdown-menu": "^2.1.16", + "@radix-ui/react-popover": "^1.1.15", + "@radix-ui/react-select": "^2.2.6", + "@radix-ui/react-slider": "^1.3.6", + "@radix-ui/react-slot": "^1.2.3", + "@radix-ui/react-switch": "^1.2.6", + "@radix-ui/react-tabs": "^1.1.13", + "@radix-ui/react-toggle": "^1.1.10", + "@radix-ui/react-toggle-group": "^1.1.11", + "@types/gif.js": "^0.2.5", + "@types/node": "^25.0.3", + "@types/react": "^18.2.64", + "@types/react-dom": "^18.2.21", + "@uiw/color-convert": "^2.9.2", + "@uiw/react-color-block": "^2.9.2", + "@vitejs/plugin-react": "^4.2.1", + "autoprefixer": "^10.4.21", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "dnd-timeline": "^2.2.0", + "electron": "^43.1.0", + "electron-builder": "^26.7.0", + "emoji-picker-react": "^4.16.1", + "fast-check": "^4.5.2", + "gif.js": "^0.2.0", + "mediabunny": "^1.25.1", + "motion": "^12.23.24", + "mp4box": "^2.2.0", + "pixi-filters": "^6.1.5", + "pixi.js": "^8.14.0", + "postcss": "^8.5.6", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-icons": "^5.5.0", + "react-resizable-panels": "^3.0.6", + "react-rnd": "^10.5.2", + "sonner": "^2.0.7", + "tailwind-merge": "^3.3.1", + "tailwindcss": "^3.4.18", + "tailwindcss-animate": "^1.0.7", + "terser": "^5.44.1", + "typescript": "^5.2.2", + "vite": "^5.1.6", + "vite-plugin-electron": "^0.28.6", + "vite-plugin-electron-renderer": "^0.14.5", + "vitest": "^3.2.7", + "web-demuxer": "^4.0.0" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.27.1", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.4.tgz", + "integrity": "sha512-YsmSKC29MJwf0gF8Rjjrg5LQCmyh+j/nD8/eP7f+BeoQTKYqs9RoWbjGOdy0+1Ekr68RJZMUOPVQaQisnIo4Rw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.4.tgz", + "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.3", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-module-transforms": "^7.28.3", + "@babel/helpers": "^7.28.4", + "@babel/parser": "^7.28.4", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.4", + "@babel/types": "^7.28.4", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.3.tgz", + "integrity": "sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.3", + "@babel/types": "^7.28.2", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", + "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.27.2", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", + "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", + "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.28.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", + "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", + "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", + "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.4" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.4.tgz", + "integrity": "sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.4" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", + "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.4.tgz", + "integrity": "sha512-YEzuboP2qvQavAcjgQNVgsvHIDv6ZpwXvcvjmyySP2DIMuByS/6ioU5G9pYrWHM6T2YDfc7xga9iNzYOs12CFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.3", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.4", + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.4", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.4.tgz", + "integrity": "sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@biomejs/biome": { + "version": "2.3.13", + "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.3.13.tgz", + "integrity": "sha512-Fw7UsV0UAtWIBIm0M7g5CRerpu1eKyKAXIazzxhbXYUyMkwNrkX/KLkGI7b+uVDQ5cLUMfOC9vR60q9IDYDstA==", + "dev": true, + "license": "MIT OR Apache-2.0", + "bin": { + "biome": "bin/biome" + }, + "engines": { + "node": ">=14.21.3" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/biome" + }, + "optionalDependencies": { + "@biomejs/cli-darwin-arm64": "2.3.13", + "@biomejs/cli-darwin-x64": "2.3.13", + "@biomejs/cli-linux-arm64": "2.3.13", + "@biomejs/cli-linux-arm64-musl": "2.3.13", + "@biomejs/cli-linux-x64": "2.3.13", + "@biomejs/cli-linux-x64-musl": "2.3.13", + "@biomejs/cli-win32-arm64": "2.3.13", + "@biomejs/cli-win32-x64": "2.3.13" + } + }, + "node_modules/@biomejs/cli-darwin-arm64": { + "version": "2.3.13", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.3.13.tgz", + "integrity": "sha512-0OCwP0/BoKzyJHnFdaTk/i7hIP9JHH9oJJq6hrSCPmJPo8JWcJhprK4gQlhFzrwdTBAW4Bjt/RmCf3ZZe59gwQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-darwin-x64": { + "version": "2.3.13", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.3.13.tgz", + "integrity": "sha512-AGr8OoemT/ejynbIu56qeil2+F2WLkIjn2d8jGK1JkchxnMUhYOfnqc9sVzcRxpG9Ycvw4weQ5sprRvtb7Yhcw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64": { + "version": "2.3.13", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.3.13.tgz", + "integrity": "sha512-xvOiFkrDNu607MPMBUQ6huHmBG1PZLOrqhtK6pXJW3GjfVqJg0Z/qpTdhXfcqWdSZHcT+Nct2fOgewZvytESkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64-musl": { + "version": "2.3.13", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.3.13.tgz", + "integrity": "sha512-TUdDCSY+Eo/EHjhJz7P2GnWwfqet+lFxBZzGHldrvULr59AgahamLs/N85SC4+bdF86EhqDuuw9rYLvLFWWlXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64": { + "version": "2.3.13", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.3.13.tgz", + "integrity": "sha512-s+YsZlgiXNq8XkgHs6xdvKDFOj/bwTEevqEY6rC2I3cBHbxXYU1LOZstH3Ffw9hE5tE1sqT7U23C00MzkXztMw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64-musl": { + "version": "2.3.13", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.3.13.tgz", + "integrity": "sha512-0bdwFVSbbM//Sds6OjtnmQGp4eUjOTt6kHvR/1P0ieR9GcTUAlPNvPC3DiavTqq302W34Ae2T6u5VVNGuQtGlQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-arm64": { + "version": "2.3.13", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.3.13.tgz", + "integrity": "sha512-QweDxY89fq0VvrxME+wS/BXKmqMrOTZlN9SqQ79kQSIc3FrEwvW/PvUegQF6XIVaekncDykB5dzPqjbwSKs9DA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-x64": { + "version": "2.3.13", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.3.13.tgz", + "integrity": "sha512-trDw2ogdM2lyav9WFQsdsfdVy1dvZALymRpgmWsvSez0BJzBjulhOT/t+wyKeh3pZWvwP3VMs1SoOKwO3wecMQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@derhuerst/http-basic": { + "version": "8.2.4", + "resolved": "https://registry.npmjs.org/@derhuerst/http-basic/-/http-basic-8.2.4.tgz", + "integrity": "sha512-F9rL9k9Xjf5blCz8HsJRO4diy111cayL2vkY2XE4r4t3n0yPXVYy3KD3nJ1qbrSn9743UWSXH4IwuCa/HWlGFw==", + "license": "MIT", + "dependencies": { + "caseless": "^0.12.0", + "concat-stream": "^2.0.0", + "http-response-object": "^3.0.1", + "parse-cache-control": "^1.0.1" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@derhuerst/http-basic/node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/@develar/schema-utils": { + "version": "2.6.5", + "resolved": "https://registry.npmjs.org/@develar/schema-utils/-/schema-utils-2.6.5.tgz", + "integrity": "sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.0", + "ajv-keywords": "^3.4.1" + }, + "engines": { + "node": ">= 8.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@electron-internal/extract-zip": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@electron-internal/extract-zip/-/extract-zip-1.0.4.tgz", + "integrity": "sha512-Zr1Vs7E9tpCNhZHDAbFVXc2gEVCG9RqPDjrno5+bdgB6LRAuvgyMHJut4NCVyYwtAieapMzc3fiQ3CSTi75ARg==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@electron/asar": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/@electron/asar/-/asar-3.4.1.tgz", + "integrity": "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "commander": "^5.0.0", + "glob": "^7.1.6", + "minimatch": "^3.0.4" + }, + "bin": { + "asar": "bin/asar.js" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/@electron/asar/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@electron/asar/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@electron/fuses": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@electron/fuses/-/fuses-1.8.0.tgz", + "integrity": "sha512-zx0EIq78WlY/lBb1uXlziZmDZI4ubcCXIMJ4uGjXzZW0nS19TjSPeXPAjzzTmKQlJUZm0SbmZhPKP7tuQ1SsEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.1", + "fs-extra": "^9.0.1", + "minimist": "^1.2.5" + }, + "bin": { + "electron-fuses": "dist/bin.js" + } + }, + "node_modules/@electron/fuses/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@electron/fuses/node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@electron/fuses/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@electron/get": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@electron/get/-/get-5.0.0.tgz", + "integrity": "sha512-pjoBpru1KdEtcExBnuHAP1cAc/5faoedw0hzJkL3o4/IJp7HNF1+fbrdxT3gMYRX2oJfvnA/WXeCTVQpYYxyJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "env-paths": "^3.0.0", + "graceful-fs": "^4.2.11", + "progress": "^2.0.3", + "semver": "^7.6.3", + "sumchecker": "^3.0.1" + }, + "engines": { + "node": ">=22.12.0" + }, + "optionalDependencies": { + "undici": "^7.24.4" + } + }, + "node_modules/@electron/get/node_modules/env-paths": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz", + "integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@electron/notarize": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@electron/notarize/-/notarize-2.5.0.tgz", + "integrity": "sha512-jNT8nwH1f9X5GEITXaQ8IF/KdskvIkOFfB2CvwumsveVidzpSc+mvhhTMdAGSYF3O+Nq49lJ7y+ssODRXu06+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "fs-extra": "^9.0.1", + "promise-retry": "^2.0.1" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@electron/notarize/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@electron/notarize/node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@electron/notarize/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@electron/osx-sign": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@electron/osx-sign/-/osx-sign-1.3.3.tgz", + "integrity": "sha512-KZ8mhXvWv2rIEgMbWZ4y33bDHyUKMXnx4M0sTyPNK/vcB81ImdeY9Ggdqy0SWbMDgmbqyQ+phgejh6V3R2QuSg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "compare-version": "^0.1.2", + "debug": "^4.3.4", + "fs-extra": "^10.0.0", + "isbinaryfile": "^4.0.8", + "minimist": "^1.2.6", + "plist": "^3.0.5" + }, + "bin": { + "electron-osx-flat": "bin/electron-osx-flat.js", + "electron-osx-sign": "bin/electron-osx-sign.js" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/@electron/osx-sign/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@electron/osx-sign/node_modules/isbinaryfile": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-4.0.10.tgz", + "integrity": "sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/gjtorikian/" + } + }, + "node_modules/@electron/osx-sign/node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@electron/osx-sign/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@electron/rebuild": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@electron/rebuild/-/rebuild-4.2.0.tgz", + "integrity": "sha512-RKL/O+jGoXJMxrx/5771y1n0xTKmFuOYGO3gMmwypBM6rsH0kou0mswwdXA2JrhIkE4xyC7v9vGk0n6NPzgOxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@malept/cross-spawn-promise": "^2.0.0", + "debug": "^4.1.1", + "node-abi": "^4.2.0", + "node-api-version": "^0.2.1", + "node-gyp": "^12.2.0", + "read-binary-file-arch": "^1.0.6" + }, + "bin": { + "electron-rebuild": "lib/cli.js" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@electron/rebuild/node_modules/node-api-version": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/node-api-version/-/node-api-version-0.2.1.tgz", + "integrity": "sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + } + }, + "node_modules/@electron/universal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@electron/universal/-/universal-2.0.3.tgz", + "integrity": "sha512-Wn9sPYIVFRFl5HmwMJkARCCf7rqK/EurkfQ/rJZ14mHP3iYTjZSIOSVonEAnhWeAXwtw7zOekGRlc6yTtZ0t+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@electron/asar": "^3.3.1", + "@malept/cross-spawn-promise": "^2.0.0", + "debug": "^4.3.1", + "dir-compare": "^4.2.0", + "fs-extra": "^11.1.1", + "minimatch": "^9.0.3", + "plist": "^3.1.0" + }, + "engines": { + "node": ">=16.4" + } + }, + "node_modules/@electron/universal/node_modules/fs-extra": { + "version": "11.3.3", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.3.tgz", + "integrity": "sha512-VWSRii4t0AFm6ixFFmLLx1t7wS1gh+ckoa84aOeapGum0h+EZd1EhEumSB+ZdDLnEPuucsVB9oB7cxJHap6Afg==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/@electron/universal/node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@electron/universal/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@electron/windows-sign": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@electron/windows-sign/-/windows-sign-1.2.2.tgz", + "integrity": "sha512-dfZeox66AvdPtb2lD8OsIIQh12Tp0GNCRUDfBHIKGpbmopZto2/A8nSpYYLoedPIHpqkeblZ/k8OV0Gy7PYuyQ==", + "dev": true, + "license": "BSD-2-Clause", + "optional": true, + "peer": true, + "dependencies": { + "cross-dirname": "^0.1.0", + "debug": "^4.3.4", + "fs-extra": "^11.1.1", + "minimist": "^1.2.8", + "postject": "^1.0.0-alpha.6" + }, + "bin": { + "electron-windows-sign": "bin/electron-windows-sign.js" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/@electron/windows-sign/node_modules/fs-extra": { + "version": "11.3.3", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.3.tgz", + "integrity": "sha512-VWSRii4t0AFm6ixFFmLLx1t7wS1gh+ckoa84aOeapGum0h+EZd1EhEumSB+ZdDLnEPuucsVB9oB7cxJHap6Afg==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/@electron/windows-sign/node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@electron/windows-sign/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@fix-webm-duration/fix": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@fix-webm-duration/fix/-/fix-1.0.1.tgz", + "integrity": "sha512-rRN4CpWQaXRbCXYqKIxnsUq8OSWSGq/SVlnxxkx0HxMZZ1u0qnB24P766o8QS5YaMMqAOFAzmsMmfZ2OWucOLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@fix-webm-duration/parser": "1.0.1", + "tslib": "^2.3.0" + } + }, + "node_modules/@fix-webm-duration/parser": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@fix-webm-duration/parser/-/parser-1.0.1.tgz", + "integrity": "sha512-Z6el7ohBBpym4iOmxDxumN715cHzLcAbtOtYfIbvoyToVtAuxvHt3ELXGff83iAXEU1Yj7g+obQBdiKJYVhbWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz", + "integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz", + "integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.3", + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.6.tgz", + "integrity": "sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.7.4" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", + "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", + "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@malept/cross-spawn-promise": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@malept/cross-spawn-promise/-/cross-spawn-promise-2.0.0.tgz", + "integrity": "sha512-1DpKU0Z5ThltBwjNySMC14g0CkbyhCaz9FkhxqNsZI6uAPJXFS8cMXlBKo26FJ8ZuW6S9GCMcR9IO5k2X5/9Fg==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/malept" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/subscription/pkg/npm-.malept-cross-spawn-promise?utm_medium=referral&utm_source=npm_fund" + } + ], + "license": "Apache-2.0", + "dependencies": { + "cross-spawn": "^7.0.1" + }, + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/@malept/flatpak-bundler": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@malept/flatpak-bundler/-/flatpak-bundler-0.4.0.tgz", + "integrity": "sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "fs-extra": "^9.0.0", + "lodash": "^4.17.15", + "tmp-promise": "^3.0.2" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@malept/flatpak-bundler/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@malept/flatpak-bundler/node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@malept/flatpak-bundler/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@phosphor-icons/react": { + "version": "2.1.10", + "resolved": "https://registry.npmjs.org/@phosphor-icons/react/-/react-2.1.10.tgz", + "integrity": "sha512-vt8Tvq8GLjheAZZYa+YG/pW7HDbov8El/MANW8pOAz4eGxrwhnbfrQZq0Cp4q8zBEu8NIhHdnr+r8thnfRSNYA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": ">= 16.8", + "react-dom": ">= 16.8" + } + }, + "node_modules/@pixi/colord": { + "version": "2.9.6", + "resolved": "https://registry.npmjs.org/@pixi/colord/-/colord-2.9.6.tgz", + "integrity": "sha512-nezytU2pw587fQstUu1AsJZDVEynjskwOL+kibwcdxsMBFqPsFFNA7xl0ii/gXuDi6M0xj3mfRJj8pBSc2jCfA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@radix-ui/number": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", + "integrity": "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", + "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@radix-ui/react-accordion": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.2.12.tgz", + "integrity": "sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collapsible": "1.1.12", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-arrow": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", + "integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collapsible": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.12.tgz", + "integrity": "sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", + "integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz", + "integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-direction": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", + "integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz", + "integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-escape-keydown": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dropdown-menu": { + "version": "2.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.16.tgz", + "integrity": "sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-menu": "2.1.16", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz", + "integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz", + "integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", + "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu": { + "version": "2.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.16.tgz", + "integrity": "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.15.tgz", + "integrity": "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz", + "integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-rect": "1.1.1", + "@radix-ui/react-use-size": "1.1.1", + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", + "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-presence": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", + "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz", + "integrity": "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.6.tgz", + "integrity": "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-visually-hidden": "1.2.3", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slider": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.3.6.tgz", + "integrity": "sha512-JPYb1GuM1bxfjMRlNLE+BcmBC8onfCi60Blk7OBqi2MLTFdS+8401U4uFjnwkOr49BLmXxLC6JHkvAsx5OJvHw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-switch": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.2.6.tgz", + "integrity": "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz", + "integrity": "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toggle": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.10.tgz", + "integrity": "sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toggle-group": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle-group/-/react-toggle-group-1.1.11.tgz", + "integrity": "sha512-5umnS0T8JQzQT6HbPyO7Hh9dgd82NmS36DQr+X/YJ9ctFNCiiQd6IJAYYZ33LUwm8M+taCz5t2ui29fHZc4Y6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-toggle": "1.1.10", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", + "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", + "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", + "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-escape-keydown": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz", + "integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", + "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-previous": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz", + "integrity": "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-rect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz", + "integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-size": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz", + "integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-visually-hidden": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz", + "integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/rect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz", + "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.52.4.tgz", + "integrity": "sha512-BTm2qKNnWIQ5auf4deoetINJm2JzvihvGb9R6K/ETwKLql/Bb3Eg2H1FBp1gUb4YGbydMA3jcmQTR73q7J+GAA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.52.4.tgz", + "integrity": "sha512-P9LDQiC5vpgGFgz7GSM6dKPCiqR3XYN1WwJKA4/BUVDjHpYsf3iBEmVz62uyq20NGYbiGPR5cNHI7T1HqxNs2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.52.4.tgz", + "integrity": "sha512-QRWSW+bVccAvZF6cbNZBJwAehmvG9NwfWHwMy4GbWi/BQIA/laTIktebT2ipVjNncqE6GLPxOok5hsECgAxGZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.52.4.tgz", + "integrity": "sha512-hZgP05pResAkRJxL1b+7yxCnXPGsXU0fG9Yfd6dUaoGk+FhdPKCJ5L1Sumyxn8kvw8Qi5PvQ8ulenUbRjzeCTw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.52.4.tgz", + "integrity": "sha512-xmc30VshuBNUd58Xk4TKAEcRZHaXlV+tCxIXELiE9sQuK3kG8ZFgSPi57UBJt8/ogfhAF5Oz4ZSUBN77weM+mQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.52.4.tgz", + "integrity": "sha512-WdSLpZFjOEqNZGmHflxyifolwAiZmDQzuOzIq9L27ButpCVpD7KzTRtEG1I0wMPFyiyUdOO+4t8GvrnBLQSwpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.52.4.tgz", + "integrity": "sha512-xRiOu9Of1FZ4SxVbB0iEDXc4ddIcjCv2aj03dmW8UrZIW7aIQ9jVJdLBIhxBI+MaTnGAKyvMwPwQnoOEvP7FgQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.52.4.tgz", + "integrity": "sha512-FbhM2p9TJAmEIEhIgzR4soUcsW49e9veAQCziwbR+XWB2zqJ12b4i/+hel9yLiD8pLncDH4fKIPIbt5238341Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.52.4.tgz", + "integrity": "sha512-4n4gVwhPHR9q/g8lKCyz0yuaD0MvDf7dV4f9tHt0C73Mp8h38UCtSCSE6R9iBlTbXlmA8CjpsZoujhszefqueg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.52.4.tgz", + "integrity": "sha512-u0n17nGA0nvi/11gcZKsjkLj1QIpAuPFQbR48Subo7SmZJnGxDpspyw2kbpuoQnyK+9pwf3pAoEXerJs/8Mi9g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.52.4.tgz", + "integrity": "sha512-0G2c2lpYtbTuXo8KEJkDkClE/+/2AFPdPAbmaHoE870foRFs4pBrDehilMcrSScrN/fB/1HTaWO4bqw+ewBzMQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.52.4.tgz", + "integrity": "sha512-teSACug1GyZHmPDv14VNbvZFX779UqWTsd7KtTM9JIZRDI5NUwYSIS30kzI8m06gOPB//jtpqlhmraQ68b5X2g==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.52.4.tgz", + "integrity": "sha512-/MOEW3aHjjs1p4Pw1Xk4+3egRevx8Ji9N6HUIA1Ifh8Q+cg9dremvFCUbOX2Zebz80BwJIgCBUemjqhU5XI5Eg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.52.4.tgz", + "integrity": "sha512-1HHmsRyh845QDpEWzOFtMCph5Ts+9+yllCrREuBR/vg2RogAQGGBRC8lDPrPOMnrdOJ+mt1WLMOC2Kao/UwcvA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.52.4.tgz", + "integrity": "sha512-seoeZp4L/6D1MUyjWkOMRU6/iLmCU2EjbMTyAG4oIOs1/I82Y5lTeaxW0KBfkUdHAWN7j25bpkt0rjnOgAcQcA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.52.4.tgz", + "integrity": "sha512-Wi6AXf0k0L7E2gteNsNHUs7UMwCIhsCTs6+tqQ5GPwVRWMaflqGec4Sd8n6+FNFDw9vGcReqk2KzBDhCa1DLYg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.52.4.tgz", + "integrity": "sha512-dtBZYjDmCQ9hW+WgEkaffvRRCKm767wWhxsFW3Lw86VXz/uJRuD438/XvbZT//B96Vs8oTA8Q4A0AfHbrxP9zw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.52.4.tgz", + "integrity": "sha512-1ox+GqgRWqaB1RnyZXL8PD6E5f7YyRUJYnCqKpNzxzP0TkaUh112NDrR9Tt+C8rJ4x5G9Mk8PQR3o7Ku2RKqKA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.52.4.tgz", + "integrity": "sha512-8GKr640PdFNXwzIE0IrkMWUNUomILLkfeHjXBi/nUvFlpZP+FA8BKGKpacjW6OUUHaNI6sUURxR2U2g78FOHWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.52.4.tgz", + "integrity": "sha512-AIy/jdJ7WtJ/F6EcfOb2GjR9UweO0n43jNObQMb6oGxkYTfLcnN7vYYpG+CN3lLxrQkzWnMOoNSHTW54pgbVxw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.52.4.tgz", + "integrity": "sha512-UF9KfsH9yEam0UjTwAgdK0anlQ7c8/pWPU2yVjyWcF1I1thABt6WXE47cI71pGiZ8wGvxohBoLnxM04L/wj8mQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.52.4.tgz", + "integrity": "sha512-bf9PtUa0u8IXDVxzRToFQKsNCRz9qLYfR/MpECxl4mRoWYjAeFjgxj1XdZr2M/GNVpT05p+LgQOHopYDlUu6/w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@sindresorhus/is": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", + "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@szmarczak/http-timer": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz", + "integrity": "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==", + "dev": true, + "license": "MIT", + "dependencies": { + "defer-to-connect": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/cacheable-request": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz", + "integrity": "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-cache-semantics": "*", + "@types/keyv": "^3.1.4", + "@types/node": "*", + "@types/responselike": "^1.0.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/css-font-loading-module": { + "version": "0.0.12", + "resolved": "https://registry.npmjs.org/@types/css-font-loading-module/-/css-font-loading-module-0.0.12.tgz", + "integrity": "sha512-x2tZZYkSxXqWvTDgveSynfjq/T2HyiZHXb00j/+gy19yp70PHCizM48XFdjBCWH7eHBD0R5i/pw9yMBP/BH5uA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/debug": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", + "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/dom-mediacapture-transform": { + "version": "0.1.11", + "resolved": "https://registry.npmjs.org/@types/dom-mediacapture-transform/-/dom-mediacapture-transform-0.1.11.tgz", + "integrity": "sha512-Y2p+nGf1bF2XMttBnsVPHUWzRRZzqUoJAKmiP10b5umnO6DDrWI0BrGDJy1pOHoOULVmGSfFNkQrAlC5dcj6nQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/dom-webcodecs": "*" + } + }, + "node_modules/@types/dom-webcodecs": { + "version": "0.1.17", + "resolved": "https://registry.npmjs.org/@types/dom-webcodecs/-/dom-webcodecs-0.1.17.tgz", + "integrity": "sha512-IwKW5uKL0Zrv5ccUJpjIlqf7ppk2v29l/ZLQxLlwHxljBfnDD9Gxm+hzMkGM0AOAL/21H0pp7cTUYLiiVUGchA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/earcut": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/earcut/-/earcut-3.0.0.tgz", + "integrity": "sha512-k/9fOUGO39yd2sCjrbAJvGDEQvRwRnQIZlBz43roGwUZo5SHAmyVvSFyaVVZkicRVCaDXPKlbxrUcBuJoSWunQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/events": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/events/-/events-3.0.3.tgz", + "integrity": "sha512-trOc4AAUThEz9hapPtSd7wf5tiQKvTtu5b371UxXdTuqzIh0ArcRspRP0i0Viu+LXstIQ1z96t1nsPxT9ol01g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/fs-extra": { + "version": "9.0.13", + "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-9.0.13.tgz", + "integrity": "sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/gif.js": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/@types/gif.js/-/gif.js-0.2.5.tgz", + "integrity": "sha512-OdDQYh9v7td9ztjaooBSqjUBAyAuui2xwDDmQcyRLd6c9T0iWgkebAoCBEdEEBoZG3ekJE/6UnH63Dzq0S3bvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/events": "*" + } + }, + "node_modules/@types/gradient-parser": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/@types/gradient-parser/-/gradient-parser-0.1.5.tgz", + "integrity": "sha512-r7K3NkJz3A95WkVVmjs0NcchhHstC2C/VIYNX4JC6tieviUNo774FFeOHjThr3Vw/WCeMP9kAT77MKbIRlO/4w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/http-cache-semantics": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.4.tgz", + "integrity": "sha512-1m0bIFVc7eJWyve9S0RnuRgcQqF/Xd5QsUZAZeQFr1Q3/p9JWoQQEqmVy+DPTNpGXwhgIetAoYF8JSc33q29QA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/keyv": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.4.tgz", + "integrity": "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.0.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.3.tgz", + "integrity": "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/plist": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/plist/-/plist-3.0.5.tgz", + "integrity": "sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@types/node": "*", + "xmlbuilder": ">=11.0.1" + } + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.26", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.26.tgz", + "integrity": "sha512-RFA/bURkcKzx/X9oumPG9Vp3D3JUgus/d0b67KB0t5S/raciymilkOa66olh78MUI92QLbEJevO7rvqU/kjwKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.0.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@types/responselike": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.3.tgz", + "integrity": "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/verror": { + "version": "1.10.11", + "resolved": "https://registry.npmjs.org/@types/verror/-/verror-1.10.11.tgz", + "integrity": "sha512-RlDm9K7+o5stv0Co8i8ZRGxDbrTxhJtgjqjFyVh/tXQyl/rYtTKlnTvZ88oSTeYREWurwx20Js4kTuKCsFkUtg==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/@uiw/color-convert": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/@uiw/color-convert/-/color-convert-2.9.2.tgz", + "integrity": "sha512-ibw9OS29S7GlL+vDwU3p5XG3vhR7XdzUecydpZbakUeg2Td6nfsnrCAX9sbLwQ73p0abO42v+V4qRaWq+7/BjQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://jaywcjlove.github.io/#/sponsor" + }, + "peerDependencies": { + "@babel/runtime": ">=7.19.0" + } + }, + "node_modules/@uiw/react-color-block": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/@uiw/react-color-block/-/react-color-block-2.9.2.tgz", + "integrity": "sha512-0EIZTELA5pnxyMlBOFo3hrpy73db+Qeq6E+QptNfD/8izor8OvY1Uquj2VqD6gDz+iVHMELIoKxpaQ8sZR7NOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@uiw/color-convert": "2.9.2", + "@uiw/react-color-editable-input": "2.9.2", + "@uiw/react-color-swatch": "2.9.2" + }, + "funding": { + "url": "https://jaywcjlove.github.io/#/sponsor" + }, + "peerDependencies": { + "@babel/runtime": ">=7.19.0", + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@uiw/react-color-editable-input": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/@uiw/react-color-editable-input/-/react-color-editable-input-2.9.2.tgz", + "integrity": "sha512-DY7pu12+LDRn6cxDMvsy1/quaPTxicAPz/kfODV7KBf8+Hq4rFWeJ4KS6m22IKIbQxrBQgmQG0WFJLaPeY7cPw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://jaywcjlove.github.io/#/sponsor" + }, + "peerDependencies": { + "@babel/runtime": ">=7.19.0", + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@uiw/react-color-swatch": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/@uiw/react-color-swatch/-/react-color-swatch-2.9.2.tgz", + "integrity": "sha512-6zBy+E9NzZR672M2wPsbbNRqKy9Wi9jOuuxxyzov1CEZp+pPX7UwMlCX6RUhKdO0PzTSPCVQmbz5bplu5vsW0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@uiw/color-convert": "2.9.2" + }, + "funding": { + "url": "https://jaywcjlove.github.io/#/sponsor" + }, + "peerDependencies": { + "@babel/runtime": ">=7.19.0", + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/@vitest/expect": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz", + "integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz", + "integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.7", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz", + "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz", + "integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.7", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz", + "integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz", + "integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz", + "integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@webgpu/types": { + "version": "0.1.66", + "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.66.tgz", + "integrity": "sha512-YA2hLrwLpDsRueNDXIMqN9NTzD6bCDkuXbOSe0heS+f8YE8usA6Gbv1prj81pzVHrbaAma7zObnIC+I6/sXJgA==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@xmldom/xmldom": { + "version": "0.8.11", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.11.tgz", + "integrity": "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/7zip-bin": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/7zip-bin/-/7zip-bin-5.2.0.tgz", + "integrity": "sha512-ukTPVhqG4jNzMro2qA9HSCSSVJN3aN7tlb+hfqYCt3ER0yWroeA2VR38MNrOHLQ/cVj+DaIMad0kFCtWWowh/A==", + "dev": true, + "license": "MIT" + }, + "node_modules/abbrev": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-4.0.0.tgz", + "integrity": "sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-keywords": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", + "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "ajv": "^6.9.1" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/app-builder-bin": { + "version": "5.0.0-alpha.12", + "resolved": "https://registry.npmjs.org/app-builder-bin/-/app-builder-bin-5.0.0-alpha.12.tgz", + "integrity": "sha512-j87o0j6LqPL3QRr8yid6c+Tt5gC7xNfYo6uQIQkorAC6MpeayVMZrEDzKmJJ/Hlv7EnOQpaRm53k6ktDYZyB6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/app-builder-lib": { + "version": "26.7.0", + "resolved": "https://registry.npmjs.org/app-builder-lib/-/app-builder-lib-26.7.0.tgz", + "integrity": "sha512-/UgCD8VrO79Wv8aBNpjMfsS1pIUfIPURoRn0Ik6tMe5avdZF+vQgl/juJgipcMmH3YS0BD573lCdCHyoi84USg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@develar/schema-utils": "~2.6.5", + "@electron/asar": "3.4.1", + "@electron/fuses": "^1.8.0", + "@electron/get": "^3.0.0", + "@electron/notarize": "2.5.0", + "@electron/osx-sign": "1.3.3", + "@electron/rebuild": "^4.0.3", + "@electron/universal": "2.0.3", + "@malept/flatpak-bundler": "^0.4.0", + "@types/fs-extra": "9.0.13", + "async-exit-hook": "^2.0.1", + "builder-util": "26.4.1", + "builder-util-runtime": "9.5.1", + "chromium-pickle-js": "^0.2.0", + "ci-info": "4.3.1", + "debug": "^4.3.4", + "dotenv": "^16.4.5", + "dotenv-expand": "^11.0.6", + "ejs": "^3.1.8", + "electron-publish": "26.6.0", + "fs-extra": "^10.1.0", + "hosted-git-info": "^4.1.0", + "isbinaryfile": "^5.0.0", + "jiti": "^2.4.2", + "js-yaml": "^4.1.0", + "json5": "^2.2.3", + "lazy-val": "^1.0.5", + "minimatch": "^10.0.3", + "plist": "3.1.0", + "proper-lockfile": "^4.1.2", + "resedit": "^1.7.0", + "semver": "~7.7.3", + "tar": "^7.5.7", + "temp-file": "^3.4.0", + "tiny-async-pool": "1.3.0", + "which": "^5.0.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "dmg-builder": "26.7.0", + "electron-builder-squirrel-windows": "26.7.0" + } + }, + "node_modules/app-builder-lib/node_modules/@electron/get": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@electron/get/-/get-3.1.0.tgz", + "integrity": "sha512-F+nKc0xW+kVbBRhFzaMgPy3KwmuNTYX1fx6+FxxoSnNgwYX6LD7AKBTWkU0MQ6IBoe7dz069CNkR673sPAgkCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "env-paths": "^2.2.0", + "fs-extra": "^8.1.0", + "got": "^11.8.5", + "progress": "^2.0.3", + "semver": "^6.2.0", + "sumchecker": "^3.0.1" + }, + "engines": { + "node": ">=14" + }, + "optionalDependencies": { + "global-agent": "^3.0.0" + } + }, + "node_modules/app-builder-lib/node_modules/@electron/get/node_modules/fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/app-builder-lib/node_modules/@electron/get/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/app-builder-lib/node_modules/@isaacs/cliui": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-9.0.0.tgz", + "integrity": "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/app-builder-lib/node_modules/balanced-match": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.2.tgz", + "integrity": "sha512-x0K50QvKQ97fdEz2kPehIerj+YTeptKF9hyYkKf6egnwmMWAkADiO0QCzSp0R5xN8FTZgYaBfSaue46Ej62nMg==", + "dev": true, + "license": "MIT", + "dependencies": { + "jackspeak": "^4.2.3" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/app-builder-lib/node_modules/brace-expansion": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.2.tgz", + "integrity": "sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/app-builder-lib/node_modules/ci-info": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.3.1.tgz", + "integrity": "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/app-builder-lib/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/app-builder-lib/node_modules/fs-extra/node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/app-builder-lib/node_modules/fs-extra/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/app-builder-lib/node_modules/isexe": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz", + "integrity": "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/app-builder-lib/node_modules/jackspeak": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.2.3.tgz", + "integrity": "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^9.0.0" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/app-builder-lib/node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/app-builder-lib/node_modules/minimatch": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.0.tgz", + "integrity": "sha512-ugkC31VaVg9cF0DFVoADH12k6061zNZkZON+aX8AWsR9GhPcErkcMBceb6znR8wLERM2AkkOxy2nWRLpT9Jq5w==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.2" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/app-builder-lib/node_modules/which": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz", + "integrity": "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^3.1.1" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/aria-hidden": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/assert-plus": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/astral-regex": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", + "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "dev": true, + "license": "MIT" + }, + "node_modules/async-exit-hook": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/async-exit-hook/-/async-exit-hook-2.0.1.tgz", + "integrity": "sha512-NW2cX8m1Q7KPA7a5M2ULQeZ2wR5qI5PAbw5L0UOMxdioVk9PMZ0h1TmyZEkPYrCvYjDlFICusOu1dlEKAAeXBw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/at-least-node": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", + "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/autoprefixer": { + "version": "10.4.21", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz", + "integrity": "sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.24.4", + "caniuse-lite": "^1.0.30001702", + "fraction.js": "^4.3.7", + "normalize-range": "^0.1.2", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.8.15", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.15.tgz", + "integrity": "sha512-qsJ8/X+UypqxHXN75M7dF88jNK37dLBRW7LeUzCPz+TNs37G8cfWy9nWzS+LS//g600zrt2le9KuXt0rWfDz5Q==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/boolean": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz", + "integrity": "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.26.3", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.26.3.tgz", + "integrity": "sha512-lAUU+02RFBuCKQPj/P6NgjlbCnLBMp4UtgTx7vNHd3XSIJF87s9a5rA3aH2yw3GS9DqZAUbOtZdCCiZeVRqt0w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.8.9", + "caniuse-lite": "^1.0.30001746", + "electron-to-chromium": "^1.5.227", + "node-releases": "^2.0.21", + "update-browserslist-db": "^1.1.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true, + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/builder-util": { + "version": "26.4.1", + "resolved": "https://registry.npmjs.org/builder-util/-/builder-util-26.4.1.tgz", + "integrity": "sha512-FlgH43XZ50w3UtS1RVGDWOz8v9qMXPC7upMtKMtBEnYdt1OVoS61NYhKm/4x+cIaWqJTXua0+VVPI+fSPGXNIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/debug": "^4.1.6", + "7zip-bin": "~5.2.0", + "app-builder-bin": "5.0.0-alpha.12", + "builder-util-runtime": "9.5.1", + "chalk": "^4.1.2", + "cross-spawn": "^7.0.6", + "debug": "^4.3.4", + "fs-extra": "^10.1.0", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.0", + "js-yaml": "^4.1.0", + "sanitize-filename": "^1.6.3", + "source-map-support": "^0.5.19", + "stat-mode": "^1.0.0", + "temp-file": "^3.4.0", + "tiny-async-pool": "1.3.0" + } + }, + "node_modules/builder-util-runtime": { + "version": "9.5.1", + "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.5.1.tgz", + "integrity": "sha512-qt41tMfgHTllhResqM5DcnHyDIWNgzHvuY2jDcYP9iaGpkWxTUzV6GQjDeLnlR1/DtdlcsWQbA7sByMpmJFTLQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.3.4", + "sax": "^1.2.4" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/builder-util/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/builder-util/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/builder-util/node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/builder-util/node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/builder-util/node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/builder-util/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cacheable-lookup": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz", + "integrity": "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.6.0" + } + }, + "node_modules/cacheable-request": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-7.0.4.tgz", + "integrity": "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==", + "dev": true, + "license": "MIT", + "dependencies": { + "clone-response": "^1.0.2", + "get-stream": "^5.1.0", + "http-cache-semantics": "^4.0.0", + "keyv": "^4.0.0", + "lowercase-keys": "^2.0.0", + "normalize-url": "^6.0.1", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001749", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001749.tgz", + "integrity": "sha512-0rw2fJOmLfnzCRbkm8EyHL8SvI2Apu5UbnQuTsJ0ClgrH8hcwFooJ1s5R0EP8o8aVrFu8++ae29Kt9/gZAZp/Q==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/capturekit": { + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/capturekit/-/capturekit-1.0.13.tgz", + "integrity": "sha512-bSSXB9JDZEkGa06QnBEc+AdghqMZY7tJBD8K63E06xBptrPzHobRLBKHuc4RbzyqWI/eRAnagfHKkONyqamasA==", + "license": "MIT", + "dependencies": { + "execa": "^5.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/caseless": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", + "integrity": "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==", + "license": "Apache-2.0" + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/chromium-pickle-js": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/chromium-pickle-js/-/chromium-pickle-js-0.2.0.tgz", + "integrity": "sha512-1R5Fho+jBq0DDydt+/vHWj5KJNJCKdARKOCwZUen84I5BreWoLqRLANH1U87eJy1tiASPtMnGqJJq0ZsLoRPOw==", + "dev": true, + "license": "MIT" + }, + "node_modules/ci-info": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", + "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "clsx": "^2.1.1" + }, + "funding": { + "url": "https://polar.sh/cva" + } + }, + "node_modules/cli-truncate": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-2.1.0.tgz", + "integrity": "sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "slice-ansi": "^3.0.0", + "string-width": "^4.2.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/clone-response": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.3.tgz", + "integrity": "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-response": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz", + "integrity": "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/compare-version": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/compare-version/-/compare-version-0.1.2.tgz", + "integrity": "sha512-pJDh5/4wrEnXX/VWRZvruAGHkzKdr46z11OlTPN+VrATlWWhSKewNCJ1futCO5C7eJB3nPMFZA1LeYtcFboZ2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/core-util-is": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", + "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/crc": { + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/crc/-/crc-3.8.0.tgz", + "integrity": "sha512-iX3mfgcTMIq3ZKLIsVFAbv7+Mc10kxabAGQb8HvjA1o3T1PIYprbakQ65d3I+2HGHt6nSKkM9PYjgoJO2KcFBQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "buffer": "^5.1.0" + } + }, + "node_modules/cross-dirname": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/cross-dirname/-/cross-dirname-0.1.0.tgz", + "integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/decompress-response/node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/defer-to-connect": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", + "integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/detect-node": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", + "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dir-compare": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/dir-compare/-/dir-compare-4.2.0.tgz", + "integrity": "sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimatch": "^3.0.5", + "p-limit": "^3.1.0 " + } + }, + "node_modules/dir-compare/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/dir-compare/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/dmg-builder": { + "version": "26.7.0", + "resolved": "https://registry.npmjs.org/dmg-builder/-/dmg-builder-26.7.0.tgz", + "integrity": "sha512-uOOBA3f+kW3o4KpSoMQ6SNpdXU7WtxlJRb9vCZgOvqhTz4b3GjcoWKstdisizNZLsylhTMv8TLHFPFW0Uxsj/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "app-builder-lib": "26.7.0", + "builder-util": "26.4.1", + "fs-extra": "^10.1.0", + "iconv-lite": "^0.6.2", + "js-yaml": "^4.1.0" + }, + "optionalDependencies": { + "dmg-license": "^1.0.11" + } + }, + "node_modules/dmg-builder/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/dmg-builder/node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/dmg-builder/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/dmg-license": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/dmg-license/-/dmg-license-1.0.11.tgz", + "integrity": "sha512-ZdzmqwKmECOWJpqefloC5OJy1+WZBBse5+MR88z9g9Zn4VY+WYUkAyojmhzJckH5YbbZGcYIuGAkY5/Ys5OM2Q==", + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "dependencies": { + "@types/plist": "^3.0.1", + "@types/verror": "^1.10.3", + "ajv": "^6.10.0", + "crc": "^3.8.0", + "iconv-corefoundation": "^1.1.7", + "plist": "^3.0.4", + "smart-buffer": "^4.0.2", + "verror": "^1.10.0" + }, + "bin": { + "dmg-license": "bin/dmg-license.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/dnd-timeline": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/dnd-timeline/-/dnd-timeline-2.2.0.tgz", + "integrity": "sha512-bQ/2bm70eA7YeztgdxoSpdpTwPzj8VT2/wTlYrnFpqJ71et7EVJZR35XPZIVzBqSyKK9T/QyUzE26gYck9ldxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@dnd-kit/core": "^6.1.0", + "@dnd-kit/utilities": "^3.2.2", + "resize-observer-polyfill": "^1.5.1" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dotenv-expand": { + "version": "11.0.7", + "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-11.0.7.tgz", + "integrity": "sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dotenv": "^16.4.5" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/earcut": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/earcut/-/earcut-3.0.2.tgz", + "integrity": "sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/ejs": { + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", + "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "jake": "^10.8.5" + }, + "bin": { + "ejs": "bin/cli.js" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/electron": { + "version": "43.1.0", + "resolved": "https://registry.npmjs.org/electron/-/electron-43.1.0.tgz", + "integrity": "sha512-DPfxpQLd4NL3BJ8DBxYAfmLUKKesF5Rx9dQx5FyczAP8bhOPScjHE48GArVeXu68LlAainuwkmQTQvdZwpIIAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@electron-internal/extract-zip": "^1.0.1", + "@electron/get": "^5.0.0", + "@types/node": "^24.9.0" + }, + "bin": { + "electron": "cli.js", + "install-electron": "install.js" + }, + "engines": { + "node": ">= 22.12.0" + } + }, + "node_modules/electron-builder": { + "version": "26.7.0", + "resolved": "https://registry.npmjs.org/electron-builder/-/electron-builder-26.7.0.tgz", + "integrity": "sha512-LoXbCvSFxLesPneQ/fM7FB4OheIDA2tjqCdUkKlObV5ZKGhYgi5VHPHO/6UUOUodAlg7SrkPx7BZJPby+Vrtbg==", + "dev": true, + "license": "MIT", + "dependencies": { + "app-builder-lib": "26.7.0", + "builder-util": "26.4.1", + "builder-util-runtime": "9.5.1", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "dmg-builder": "26.7.0", + "fs-extra": "^10.1.0", + "lazy-val": "^1.0.5", + "simple-update-notifier": "2.0.0", + "yargs": "^17.6.2" + }, + "bin": { + "electron-builder": "cli.js", + "install-app-deps": "install-app-deps.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/electron-builder-squirrel-windows": { + "version": "26.7.0", + "resolved": "https://registry.npmjs.org/electron-builder-squirrel-windows/-/electron-builder-squirrel-windows-26.7.0.tgz", + "integrity": "sha512-3EqkQK+q0kGshdPSKEPb2p5F75TENMKu6Fe5aTdeaPfdzFK4Yjp5L0d6S7K8iyvqIsGQ/ei4bnpyX9wt+kVCKQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "app-builder-lib": "26.7.0", + "builder-util": "26.4.1", + "electron-winstaller": "5.4.0" + } + }, + "node_modules/electron-builder/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/electron-builder/node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/electron-builder/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/electron-publish": { + "version": "26.6.0", + "resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-26.6.0.tgz", + "integrity": "sha512-LsyHMMqbvJ2vsOvuWJ19OezgF2ANdCiHpIucDHNiLhuI+/F3eW98ouzWSRmXXi82ZOPZXC07jnIravY4YYwCLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/fs-extra": "^9.0.11", + "builder-util": "26.4.1", + "builder-util-runtime": "9.5.1", + "chalk": "^4.1.2", + "form-data": "^4.0.5", + "fs-extra": "^10.1.0", + "lazy-val": "^1.0.5", + "mime": "^2.5.2" + } + }, + "node_modules/electron-publish/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/electron-publish/node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/electron-publish/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.234", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.234.tgz", + "integrity": "sha512-RXfEp2x+VRYn8jbKfQlRImzoJU01kyDvVPBmG39eU2iuRVhuS6vQNocB8J0/8GrIMLnPzgz4eW6WiRnJkTuNWg==", + "dev": true, + "license": "ISC" + }, + "node_modules/electron-updater": { + "version": "6.8.3", + "resolved": "https://registry.npmjs.org/electron-updater/-/electron-updater-6.8.3.tgz", + "integrity": "sha512-Z6sgw3jgbikWKXei1ENdqFOxBP0WlXg3TtKfz0rgw2vIZFJUyI4pD7ZN7jrkm7EoMK+tcm/qTnPUdqfZukBlBQ==", + "license": "MIT", + "dependencies": { + "builder-util-runtime": "9.5.1", + "fs-extra": "^10.1.0", + "js-yaml": "^4.1.0", + "lazy-val": "^1.0.5", + "lodash.escaperegexp": "^4.1.2", + "lodash.isequal": "^4.5.0", + "semver": "~7.7.3", + "tiny-typed-emitter": "^2.1.0" + } + }, + "node_modules/electron-updater/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/electron-updater/node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/electron-updater/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/electron-winstaller": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/electron-winstaller/-/electron-winstaller-5.4.0.tgz", + "integrity": "sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@electron/asar": "^3.2.1", + "debug": "^4.1.1", + "fs-extra": "^7.0.1", + "lodash": "^4.17.21", + "temp": "^0.9.0" + }, + "engines": { + "node": ">=8.0.0" + }, + "optionalDependencies": { + "@electron/windows-sign": "^1.1.2" + } + }, + "node_modules/electron-winstaller/node_modules/fs-extra": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.1.tgz", + "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "graceful-fs": "^4.1.2", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/electron/node_modules/@types/node": { + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", + "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/electron/node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/emoji-picker-react": { + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/emoji-picker-react/-/emoji-picker-react-4.16.1.tgz", + "integrity": "sha512-MrPX0tOCfRL3uYI4of/2GRZ7S6qS7YlacKiF78uFH84/C62vcuHE2DZyv5b4ZJMk0e06es1jjB4e31Bb+YSM8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "flairup": "1.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": ">=16" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/err-code": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", + "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es6-error": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", + "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/eventemitter3": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", + "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", + "dev": true, + "license": "MIT" + }, + "node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/execa/node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/execa/node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/execa/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/exponential-backoff": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", + "integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/extsprintf": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.4.1.tgz", + "integrity": "sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA==", + "dev": true, + "engines": [ + "node >=0.6.0" + ], + "license": "MIT", + "optional": true + }, + "node_modules/fast-check": { + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.5.2.tgz", + "integrity": "sha512-tOzL01LMrDIWPLfvMiGUMH0AjqnOelHQPmgvYkW/aRO4Yaw+pBQqWmyebNzAEbKOigoCN8HkRWUZXFkjmiaXMQ==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "dependencies": { + "pure-rand": "^7.0.0" + }, + "engines": { + "node": ">=12.17.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.19.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", + "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/ffmpeg-static": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/ffmpeg-static/-/ffmpeg-static-5.3.0.tgz", + "integrity": "sha512-H+K6sW6TiIX6VGend0KQwthe+kaceeH/luE8dIZyOP35ik7ahYojDuqlTV1bOrtEwl01sy2HFNGQfi5IDJvotg==", + "hasInstallScript": true, + "license": "GPL-3.0-or-later", + "dependencies": { + "@derhuerst/http-basic": "^8.2.0", + "env-paths": "^2.2.0", + "https-proxy-agent": "^5.0.0", + "progress": "^2.0.3" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/ffprobe-static": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/ffprobe-static/-/ffprobe-static-3.1.0.tgz", + "integrity": "sha512-Dvpa9uhVMOYivhHKWLGDoa512J751qN1WZAIO+Xw4L/mrUSPxS4DApzSUDbCFE/LUq2+xYnznEahTd63AqBSpA==", + "license": "MIT" + }, + "node_modules/filelist": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz", + "integrity": "sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.0.1" + } + }, + "node_modules/filelist/node_modules/minimatch": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", + "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/flairup": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/flairup/-/flairup-1.0.0.tgz", + "integrity": "sha512-IKlE+pNvL2R+kVL1kEhUYqRxVqeFnjiIvHWDMLFXNaqyUdFXQM2wte44EfMYJNHkW16X991t2Zg8apKkhv7OBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fraction.js": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", + "integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "patreon", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/framer-motion": { + "version": "12.23.24", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.23.24.tgz", + "integrity": "sha512-HMi5HRoRCTou+3fb3h9oTLyJGBxHfW+HnNE25tAXOvVx/IvwMHK0cx7IR4a2ZU6sh3IX1Z+4ts32PcYBOqka8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "motion-dom": "^12.23.23", + "motion-utils": "^12.23.6", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gif.js": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/gif.js/-/gif.js-0.2.0.tgz", + "integrity": "sha512-bYxCoT8OZKmbxY8RN4qDiYuj4nrQDTzgLRcFVovyona1PTWNePzI4nzOmotnlOFIzTk/ZxAHtv+TfVLiBWj/hw==", + "dev": true, + "license": "MIT" + }, + "node_modules/gifuct-js": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/gifuct-js/-/gifuct-js-2.1.2.tgz", + "integrity": "sha512-rI2asw77u0mGgwhV3qA+OEgYqaDn5UNqgs+Bx0FGwSpuqfYn+Ir6RQY5ENNQ8SbIiG/m5gVa7CD5RriO4f4Lsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-binary-schema-parser": "^2.0.3" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/global-agent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz", + "integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "boolean": "^3.0.1", + "es6-error": "^4.1.1", + "matcher": "^3.0.0", + "roarr": "^2.15.3", + "semver": "^7.3.2", + "serialize-error": "^7.0.1" + }, + "engines": { + "node": ">=10.0" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/got": { + "version": "11.8.6", + "resolved": "https://registry.npmjs.org/got/-/got-11.8.6.tgz", + "integrity": "sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sindresorhus/is": "^4.0.0", + "@szmarczak/http-timer": "^4.0.5", + "@types/cacheable-request": "^6.0.1", + "@types/responselike": "^1.0.0", + "cacheable-lookup": "^5.0.3", + "cacheable-request": "^7.0.2", + "decompress-response": "^6.0.0", + "http2-wrapper": "^1.0.0-beta.5.2", + "lowercase-keys": "^2.0.0", + "p-cancelable": "^2.0.0", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=10.19.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/got?sponsor=1" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hosted-git-info": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", + "integrity": "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==", + "dev": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/hosted-git-info/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/hosted-git-info/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC" + }, + "node_modules/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/http-response-object": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/http-response-object/-/http-response-object-3.0.2.tgz", + "integrity": "sha512-bqX0XTF6fnXSQcEJ2Iuyr75yVakyjIDCqroJQ/aHfSdlM743Cwqoi2nDYMzLGWUcuTWGWy8AAvOKXTfiv6q9RA==", + "license": "MIT", + "dependencies": { + "@types/node": "^10.0.3" + } + }, + "node_modules/http-response-object/node_modules/@types/node": { + "version": "10.17.60", + "resolved": "https://registry.npmjs.org/@types/node/-/node-10.17.60.tgz", + "integrity": "sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw==", + "license": "MIT" + }, + "node_modules/http2-wrapper": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz", + "integrity": "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "quick-lru": "^5.1.1", + "resolve-alpn": "^1.0.0" + }, + "engines": { + "node": ">=10.19.0" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "license": "Apache-2.0", + "engines": { + "node": ">=10.17.0" + } + }, + "node_modules/iconv-corefoundation": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/iconv-corefoundation/-/iconv-corefoundation-1.1.7.tgz", + "integrity": "sha512-T10qvkw0zz4wnm560lOEg0PovVqUXuOFhhHAkixw8/sycy7TJt7v/RrkEKEQnAw2viPSJu6iAkErxnzR0g8PpQ==", + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "dependencies": { + "cli-truncate": "^2.1.0", + "node-addon-api": "^1.6.3" + }, + "engines": { + "node": "^8.11.2 || >=10" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/isbinaryfile": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-5.0.7.tgz", + "integrity": "sha512-gnWD14Jh3FzS3CPhF0AxNOJ8CxqeblPTADzI38r0wt8ZyQl5edpy75myt08EG2oKvpyiqSqsx+Wkz9vtkbTqYQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/gjtorikian/" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/ismobilejs": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ismobilejs/-/ismobilejs-1.1.1.tgz", + "integrity": "sha512-VaFW53yt8QO61k2WJui0dHf4SlL8lxBofUuUmwBo0ljPk0Drz2TiuDW4jo3wDcv41qy/SxrJ+VAzJ/qYqsmzRw==", + "dev": true, + "license": "MIT" + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/jake": { + "version": "10.9.4", + "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz", + "integrity": "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "async": "^3.2.6", + "filelist": "^1.0.4", + "picocolors": "^1.1.1" + }, + "bin": { + "jake": "bin/cli.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/js-binary-schema-parser": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/js-binary-schema-parser/-/js-binary-schema-parser-2.0.3.tgz", + "integrity": "sha512-xezGJmOb4lk/M1ZZLTR/jaBHQ4gG/lqQnJqdIv4721DMggsa1bDVlHXNeHYogaIEHD9vCRv0fcL4hMA+Coarkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", + "dev": true, + "license": "ISC", + "optional": true + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "dev": true, + "license": "MIT", + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/lazy-val": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/lazy-val/-/lazy-val-1.0.5.tgz", + "integrity": "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==", + "license": "MIT" + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "optional": true, + "peer": true, + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.escaperegexp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz", + "integrity": "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==", + "license": "MIT" + }, + "node_modules/lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", + "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lowercase-keys": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", + "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/matcher": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", + "integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "escape-string-regexp": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mediabunny": { + "version": "1.25.1", + "resolved": "https://registry.npmjs.org/mediabunny/-/mediabunny-1.25.1.tgz", + "integrity": "sha512-0Rrd47PMCVJbTPA7IJaXPCupV5/RZ/icgr+a0qExRJAr0n5vB4fsGSo+fdHIehG0CrddXtVRvNZwFtJz709yfA==", + "dev": true, + "license": "MPL-2.0", + "workspaces": [ + "packages/*" + ], + "dependencies": { + "@types/dom-mediacapture-transform": "^0.1.11", + "@types/dom-webcodecs": "0.1.13" + }, + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/Vanilagy" + } + }, + "node_modules/mediabunny/node_modules/@types/dom-webcodecs": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/@types/dom-webcodecs/-/dom-webcodecs-0.1.13.tgz", + "integrity": "sha512-O5hkiFIcjjszPIYyUSyvScyvrBoV3NOEEZx/pMlsu44TKzWNkLVBBxnxJz42in5n3QIolYOcBYFCPZZ0h8SkwQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "license": "MIT" + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/mimic-response": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz", + "integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/motion": { + "version": "12.23.24", + "resolved": "https://registry.npmjs.org/motion/-/motion-12.23.24.tgz", + "integrity": "sha512-Rc5E7oe2YZ72N//S3QXGzbnXgqNrTESv8KKxABR20q2FLch9gHLo0JLyYo2hZ238bZ9Gx6cWhj9VO0IgwbMjCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "framer-motion": "^12.23.24", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "node_modules/motion-dom": { + "version": "12.23.23", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.23.23.tgz", + "integrity": "sha512-n5yolOs0TQQBRUFImrRfs/+6X4p3Q4n1dUEqt/H58Vx7OW6RF+foWEgmTVDhIWJIMXOuNNL0apKH2S16en9eiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "motion-utils": "^12.23.6" + } + }, + "node_modules/motion-utils": { + "version": "12.23.6", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.23.6.tgz", + "integrity": "sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/mp4box": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/mp4box/-/mp4box-2.2.0.tgz", + "integrity": "sha512-tE+L7wdhSuwBKZGjUzj03Qzj4lWyOw8pHSPyLnvHTKx92NJGkJls0pcEusUHWEh5gWVBlhdu79STJh4Bubz9mQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=20.8.1" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-abi": { + "version": "4.33.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-4.33.0.tgz", + "integrity": "sha512-vLBWCKb+7LWsX+TbfzWOkw0W81m377tyx3hOweBTjO43CXZnRGS1/JPWs20fr0PgZyDXk6ROYrylsEycK8raDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.6.3" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/node-addon-api": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-1.7.2.tgz", + "integrity": "sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/node-gyp": { + "version": "12.4.0", + "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-12.4.0.tgz", + "integrity": "sha512-OMcPNvqTCFUnNaBlmdgq+lfNqY7gTiSmNRDjY3uAXRyudeKZEZxu3CLtjMQrx4zZxCX2b/mpNqTtwuCJgXhHkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.0", + "exponential-backoff": "^3.1.1", + "graceful-fs": "^4.2.6", + "nopt": "^9.0.0", + "proc-log": "^6.0.0", + "semver": "^7.3.5", + "tar": "^7.5.4", + "tinyglobby": "^0.2.12", + "undici": "^6.25.0", + "which": "^6.0.0" + }, + "bin": { + "node-gyp": "bin/node-gyp.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, + "node_modules/node-gyp/node_modules/isexe": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz", + "integrity": "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=20" + } + }, + "node_modules/node-gyp/node_modules/undici": { + "version": "6.27.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.27.0.tgz", + "integrity": "sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, + "node_modules/node-gyp/node_modules/which": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/which/-/which-6.0.1.tgz", + "integrity": "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^4.0.0" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/node-releases": { + "version": "2.0.23", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.23.tgz", + "integrity": "sha512-cCmFDMSm26S6tQSDpBCg/NR8NENrVPhAJSf+XbxBG4rPFaaonlEoE9wHQmun+cls499TQGSb7ZyPBRlzgKfpeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/nopt": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-9.0.0.tgz", + "integrity": "sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw==", + "dev": true, + "license": "ISC", + "dependencies": { + "abbrev": "^4.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/normalize-range": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", + "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/normalize-url": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz", + "integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "license": "MIT", + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-cancelable": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz", + "integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/parse-cache-control": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parse-cache-control/-/parse-cache-control-1.0.1.tgz", + "integrity": "sha512-60zvsJReQPX5/QP0Kzfd/VrpjScIQ7SHBW6bFCYfEP+fp0Eppr1SHhIO5nd1PjZtvclzSzES9D/p5nFJurwfWg==" + }, + "node_modules/parse-svg-path": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/parse-svg-path/-/parse-svg-path-0.1.2.tgz", + "integrity": "sha512-JyPSBnkTJ0AI8GGJLfMXvKq42cj5c006fnLz6fXy6zfoVjJizi8BNTpu8on8ziI1cKy9d9DGNuY17Ce7wuejpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/pe-library": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/pe-library/-/pe-library-0.4.1.tgz", + "integrity": "sha512-eRWB5LBz7PpDu4PUlwT0PhnQfTQJlDDdPa35urV4Osrm0t0AqQFGn+UIkU3klZvwJ8KPO3VbBFsXquA6p6kqZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jet2jet" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/pixi-filters": { + "version": "6.1.5", + "resolved": "https://registry.npmjs.org/pixi-filters/-/pixi-filters-6.1.5.tgz", + "integrity": "sha512-Ewb/J+kxAbaNN+0/ATJbglAJG+skGJfh7BIDP3ILIDdD6wWk1p0pGa25pVf1T8hGBOQSUNVAmwwJBwkj+cyLLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/gradient-parser": "^0.1.2" + }, + "peerDependencies": { + "pixi.js": ">=8.0.0-0" + } + }, + "node_modules/pixi.js": { + "version": "8.14.0", + "resolved": "https://registry.npmjs.org/pixi.js/-/pixi.js-8.14.0.tgz", + "integrity": "sha512-ituDiEBb1Oqx56RYwTtC6MjPUhPfF/i15fpUv5oEqmzC/ce3SaSumulJcOjKG7+y0J0Ekl9Rl4XTxaUw+MVFZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@pixi/colord": "^2.9.6", + "@types/css-font-loading-module": "^0.0.12", + "@types/earcut": "^3.0.0", + "@webgpu/types": "^0.1.40", + "@xmldom/xmldom": "^0.8.10", + "earcut": "^3.0.2", + "eventemitter3": "^5.0.1", + "gifuct-js": "^2.1.2", + "ismobilejs": "^1.1.1", + "parse-svg-path": "^0.1.2", + "tiny-lru": "^11.4.5" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/pixijs" + } + }, + "node_modules/plist": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/plist/-/plist-3.1.0.tgz", + "integrity": "sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@xmldom/xmldom": "^0.8.8", + "base64-js": "^1.5.1", + "xmlbuilder": "^15.1.1" + }, + "engines": { + "node": ">=10.4.0" + } + }, + "node_modules/postcss": { + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/postject": { + "version": "1.0.0-alpha.6", + "resolved": "https://registry.npmjs.org/postject/-/postject-1.0.0-alpha.6.tgz", + "integrity": "sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "commander": "^9.4.0" + }, + "bin": { + "postject": "dist/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/postject/node_modules/commander": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", + "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": "^12.20.0 || >=14" + } + }, + "node_modules/proc-log": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz", + "integrity": "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/promise-retry": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", + "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "err-code": "^2.0.2", + "retry": "^0.12.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/proper-lockfile": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", + "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "retry": "^0.12.0", + "signal-exit": "^3.0.2" + } + }, + "node_modules/proper-lockfile/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/pump": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", + "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/pure-rand": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-7.0.1.tgz", + "integrity": "sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/quick-lru": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", + "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/re-resizable": { + "version": "6.11.2", + "resolved": "https://registry.npmjs.org/re-resizable/-/re-resizable-6.11.2.tgz", + "integrity": "sha512-2xI2P3OHs5qw7K0Ud1aLILK6MQxW50TcO+DetD9eIV58j84TqYeHoZcL9H4GXFXXIh7afhH8mv5iUCXII7OW7A==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "react": "^16.13.1 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.13.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-draggable": { + "version": "4.4.6", + "resolved": "https://registry.npmjs.org/react-draggable/-/react-draggable-4.4.6.tgz", + "integrity": "sha512-LtY5Xw1zTPqHkVmtM3X8MUOxNDOUhv/khTgBgrUvwaS064bwVvxT+q5El0uUFNx5IEPKXuRejr7UqLwBIg5pdw==", + "dev": true, + "license": "MIT", + "dependencies": { + "clsx": "^1.1.1", + "prop-types": "^15.8.1" + }, + "peerDependencies": { + "react": ">= 16.3.0", + "react-dom": ">= 16.3.0" + } + }, + "node_modules/react-draggable/node_modules/clsx": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz", + "integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react-icons": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.5.0.tgz", + "integrity": "sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "react": "*" + } + }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-remove-scroll": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.1.tgz", + "integrity": "sha512-HpMh8+oahmIdOuS5aFKKY6Pyog+FNaZV/XyJOq7b4YFwsFHe5yYfdbIalI4k3vU2nSDql7YskmUseHsRrJqIPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.3", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.3" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-remove-scroll-bar": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "react-style-singleton": "^2.2.2", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-resizable-panels": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/react-resizable-panels/-/react-resizable-panels-3.0.6.tgz", + "integrity": "sha512-b3qKHQ3MLqOgSS+FRYKapNkJZf5EQzuf6+RLiq1/IlTHw99YrZ2NJZLk4hQIzTnnIkRg2LUqyVinu6YWWpUYew==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "react": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + } + }, + "node_modules/react-rnd": { + "version": "10.5.2", + "resolved": "https://registry.npmjs.org/react-rnd/-/react-rnd-10.5.2.tgz", + "integrity": "sha512-0Tm4x7k7pfHf2snewJA8x7Nwgt3LV+58MVEWOVsFjk51eYruFEa6Wy7BNdxt4/lH0wIRsu7Gm3KjSXY2w7YaNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "re-resizable": "6.11.2", + "react-draggable": "4.4.6", + "tslib": "2.6.2" + }, + "peerDependencies": { + "react": ">=16.3.0", + "react-dom": ">=16.3.0" + } + }, + "node_modules/react-rnd/node_modules/tslib": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", + "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==", + "dev": true, + "license": "0BSD" + }, + "node_modules/react-style-singleton": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-nonce": "^1.0.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/read-binary-file-arch": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/read-binary-file-arch/-/read-binary-file-arch-1.0.6.tgz", + "integrity": "sha512-BNg9EN3DD3GsDXX7Aa8O4p92sryjkmzYYgmgTAc6CA4uGLEDzFfxOxugu21akOxpcXHiEgsYkC6nPsQvLLLmEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.4" + }, + "bin": { + "read-binary-file-arch": "cli.js" + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resedit": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/resedit/-/resedit-1.7.2.tgz", + "integrity": "sha512-vHjcY2MlAITJhC0eRD/Vv8Vlgmu9Sd3LX9zZvtGzU5ZImdTN3+d6e/4mnTyV8vEbyf1sgNIrWxhWlrys52OkEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pe-library": "^0.4.1" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jet2jet" + } + }, + "node_modules/resize-observer-polyfill": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz", + "integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==", + "dev": true, + "license": "MIT" + }, + "node_modules/resolve": { + "version": "1.22.10", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", + "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-alpn": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz", + "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/responselike": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/responselike/-/responselike-2.0.1.tgz", + "integrity": "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lowercase-keys": "^2.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/roarr": { + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz", + "integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "boolean": "^3.0.1", + "detect-node": "^2.0.4", + "globalthis": "^1.0.1", + "json-stringify-safe": "^5.0.1", + "semver-compare": "^1.0.0", + "sprintf-js": "^1.1.2" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/rollup": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.4.tgz", + "integrity": "sha512-CLEVl+MnPAiKh5pl4dEWSyMTpuflgNQiLGhMv8ezD5W/qP8AKvmYpCOKRRNOh7oRKnauBZ4SyeYkMS+1VSyKwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.52.4", + "@rollup/rollup-android-arm64": "4.52.4", + "@rollup/rollup-darwin-arm64": "4.52.4", + "@rollup/rollup-darwin-x64": "4.52.4", + "@rollup/rollup-freebsd-arm64": "4.52.4", + "@rollup/rollup-freebsd-x64": "4.52.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.52.4", + "@rollup/rollup-linux-arm-musleabihf": "4.52.4", + "@rollup/rollup-linux-arm64-gnu": "4.52.4", + "@rollup/rollup-linux-arm64-musl": "4.52.4", + "@rollup/rollup-linux-loong64-gnu": "4.52.4", + "@rollup/rollup-linux-ppc64-gnu": "4.52.4", + "@rollup/rollup-linux-riscv64-gnu": "4.52.4", + "@rollup/rollup-linux-riscv64-musl": "4.52.4", + "@rollup/rollup-linux-s390x-gnu": "4.52.4", + "@rollup/rollup-linux-x64-gnu": "4.52.4", + "@rollup/rollup-linux-x64-musl": "4.52.4", + "@rollup/rollup-openharmony-arm64": "4.52.4", + "@rollup/rollup-win32-arm64-msvc": "4.52.4", + "@rollup/rollup-win32-ia32-msvc": "4.52.4", + "@rollup/rollup-win32-x64-gnu": "4.52.4", + "@rollup/rollup-win32-x64-msvc": "4.52.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/sanitize-filename": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/sanitize-filename/-/sanitize-filename-1.6.3.tgz", + "integrity": "sha512-y/52Mcy7aw3gRm7IrcGDFx/bCk4AhRh2eI9luHOQM86nZsqwiRkkq2GekHXBBD+SmPidc8i2PqtYZl+pWJ8Oeg==", + "dev": true, + "license": "WTFPL OR ISC", + "dependencies": { + "truncate-utf8-bytes": "^1.0.0" + } + }, + "node_modules/sax": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.1.tgz", + "integrity": "sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==", + "license": "ISC" + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/semver-compare": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz", + "integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/serialize-error": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz", + "integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "type-fest": "^0.13.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/serialize-error/node_modules/type-fest": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz", + "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/simple-update-notifier": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/slice-ansi": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-3.0.0.tgz", + "integrity": "sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "astral-regex": "^2.0.0", + "is-fullwidth-code-point": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/sonner": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz", + "integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/sprintf-js": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", + "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/stat-mode": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/stat-mode/-/stat-mode-1.0.0.tgz", + "integrity": "sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/sucrase": { + "version": "3.35.0", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz", + "integrity": "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "glob": "^10.3.10", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/sucrase/node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/sucrase/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/sumchecker": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz", + "integrity": "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "debug": "^4.1.0" + }, + "engines": { + "node": ">= 8.0" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwind-merge": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.3.1.tgz", + "integrity": "sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.18", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.18.tgz", + "integrity": "sha512-6A2rnmW5xZMdw11LYjhcI5846rt9pbLSabY5XPxo+XWdxwZaFEn47Go4NzFiHu9sNNmr/kXivP1vStfvMaK1GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tailwindcss-animate": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/tailwindcss-animate/-/tailwindcss-animate-1.0.7.tgz", + "integrity": "sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "tailwindcss": ">=3.0.0 || insiders" + } + }, + "node_modules/tar": { + "version": "7.5.19", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.19.tgz", + "integrity": "sha512-4LeEWl96twnS2Q7Bz4MGqgazLqO+hJN63GZxXoIqh1T3VweYD997gbU1ItNsQafqqXTXd5WFyFdReLtwvRBNiw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tar/node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/temp": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/temp/-/temp-0.9.4.tgz", + "integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "mkdirp": "^0.5.1", + "rimraf": "~2.6.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/temp-file": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/temp-file/-/temp-file-3.4.0.tgz", + "integrity": "sha512-C5tjlC/HCtVUOi3KWVokd4vHVViOmGjtLwIh4MuzPo/nMYTV/p1urt3RnMz2IWXDdKEGJH3k5+KPxtqRsUYGtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-exit-hook": "^2.0.1", + "fs-extra": "^10.0.0" + } + }, + "node_modules/temp-file/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/temp-file/node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/temp-file/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/temp/node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "node_modules/temp/node_modules/rimraf": { + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.3.tgz", + "integrity": "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "license": "ISC", + "peer": true, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + } + }, + "node_modules/terser": { + "version": "5.44.1", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.44.1.tgz", + "integrity": "sha512-t/R3R/n0MSwnnazuPpPNVO60LX0SKL45pyl9YlvxIdkH0Of7D5qM2EVe+yASRIlY5pZ73nclYJfNANGWPwFDZw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.15.0", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/terser/node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tiny-async-pool": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tiny-async-pool/-/tiny-async-pool-1.3.0.tgz", + "integrity": "sha512-01EAw5EDrcVrdgyCLgoSPvqznC0sVxDSVeiOz09FUpjh71G79VCqneOr+xvt7T1r76CF6ZZfPjHorN2+d+3mqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^5.5.0" + } + }, + "node_modules/tiny-async-pool/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/tiny-lru": { + "version": "11.4.5", + "resolved": "https://registry.npmjs.org/tiny-lru/-/tiny-lru-11.4.5.tgz", + "integrity": "sha512-hkcz3FjNJfKXjV4mjQ1OrXSLAehg8Hw+cEZclOVT+5c/cWQWImQ9wolzTjth+dmmDe++p3bme3fTxz6Q4Etsqw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/tiny-typed-emitter": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/tiny-typed-emitter/-/tiny-typed-emitter-2.1.0.tgz", + "integrity": "sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==", + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tmp": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", + "integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.14" + } + }, + "node_modules/tmp-promise": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tmp-promise/-/tmp-promise-3.0.3.tgz", + "integrity": "sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tmp": "^0.2.0" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/truncate-utf8-bytes": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/truncate-utf8-bytes/-/truncate-utf8-bytes-1.0.2.tgz", + "integrity": "sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ==", + "dev": true, + "license": "WTFPL", + "dependencies": { + "utf8-byte-length": "^1.0.1" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/uiohook-napi": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/uiohook-napi/-/uiohook-napi-1.5.4.tgz", + "integrity": "sha512-7vPVDNwgb6MwTgviA/dnF2MrW0X5xm76fAqaOAC3cEKkswqAZOPw1USu14Sr6383s5qhXegcJaR63CpJOPCNAg==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-gyp-build": "4.x.x" + }, + "engines": { + "node": ">= 16" + } + }, + "node_modules/undici": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, + "node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", + "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/use-callback-ref": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sidecar": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/utf8-byte-length": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.5.tgz", + "integrity": "sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA==", + "dev": true, + "license": "(WTFPL OR MIT)" + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/verror": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.1.tgz", + "integrity": "sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "assert-plus": "^1.0.0", + "core-util-is": "1.0.2", + "extsprintf": "^1.2.0" + }, + "engines": { + "node": ">=0.6.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite-plugin-electron": { + "version": "0.28.8", + "resolved": "https://registry.npmjs.org/vite-plugin-electron/-/vite-plugin-electron-0.28.8.tgz", + "integrity": "sha512-ir+B21oSGK9j23OEvt4EXyco9xDCaF6OGFe0V/8Zc0yL2+HMyQ6mmNQEIhXsEsZCSfIowBpwQBeHH4wVsfraeg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "vite-plugin-electron-renderer": "*" + }, + "peerDependenciesMeta": { + "vite-plugin-electron-renderer": { + "optional": true + } + } + }, + "node_modules/vite-plugin-electron-renderer": { + "version": "0.14.6", + "resolved": "https://registry.npmjs.org/vite-plugin-electron-renderer/-/vite-plugin-electron-renderer-0.14.6.tgz", + "integrity": "sha512-oqkWFa7kQIkvHXG7+Mnl1RTroA4sP0yesKatmAy0gjZC4VwUqlvF9IvOpHd1fpLWsqYX/eZlVxlhULNtaQ78Jw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vitest": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz", + "integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.7", + "@vitest/mocker": "3.2.7", + "@vitest/pretty-format": "^3.2.7", + "@vitest/runner": "3.2.7", + "@vitest/snapshot": "3.2.7", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.7", + "@vitest/ui": "3.2.7", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/web-demuxer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/web-demuxer/-/web-demuxer-4.0.0.tgz", + "integrity": "sha512-QFsKe8SNjP6MDtAw2lWfyVmX2wXIpDUT+9p2KHXJb5OPWdhVbjBHcV06tDMXzuU1T6Y1P9TRm9bkeVXEwy0dVw==", + "dev": true, + "engines": { + "node": ">=18", + "pnpm": ">=8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/xmlbuilder": { + "version": "15.1.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", + "integrity": "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } } diff --git a/package.json b/package.json index 24377b9b6..f1ef508f5 100644 --- a/package.json +++ b/package.json @@ -1,57 +1,111 @@ { - "name": "openscreen", - "private": true, - "version": "0.0.0", - "type": "module", - "scripts": { - "dev": "vite", - "build": "tsc && vite build && electron-builder", - "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", - "preview": "vite preview" - }, - "dependencies": { - "@fix-webm-duration/fix": "^1.0.1", - "@pixi/filter-drop-shadow": "^5.2.0", - "@radix-ui/react-dropdown-menu": "^2.1.16", - "@radix-ui/react-slider": "^1.3.6", - "@radix-ui/react-slot": "^1.2.3", - "@radix-ui/react-switch": "^1.2.6", - "@radix-ui/react-tabs": "^1.1.13", - "@uiw/react-color-colorful": "^2.9.0", - "class-variance-authority": "^0.7.1", - "clsx": "^2.1.1", - "dnd-timeline": "^2.2.0", - "lucide-react": "^0.545.0", - "mp4-muxer": "^5.2.2", - "mp4box": "^2.2.0", - "pixi.js": "^8.14.0", - "react": "^18.2.0", - "react-dom": "^18.2.0", - "react-icons": "^5.5.0", - "sonner": "^2.0.7", - "tailwind-merge": "^3.3.1", - "tailwindcss-animate": "^1.0.7", - "uiohook-napi": "^1.5.4" - }, - "devDependencies": { - "@types/react": "^18.2.64", - "@types/react-dom": "^18.2.21", - "@typescript-eslint/eslint-plugin": "^7.1.1", - "@typescript-eslint/parser": "^7.1.1", - "@vitejs/plugin-react": "^4.2.1", - "autoprefixer": "^10.4.21", - "electron": "^30.0.1", - "electron-builder": "^24.13.3", - "electron-rebuild": "^3.2.9", - "eslint": "^8.57.0", - "eslint-plugin-react-hooks": "^4.6.0", - "eslint-plugin-react-refresh": "^0.4.5", - "postcss": "^8.5.6", - "tailwindcss": "^3.4.18", - "typescript": "^5.2.2", - "vite": "^5.1.6", - "vite-plugin-electron": "^0.28.6", - "vite-plugin-electron-renderer": "^0.14.5" - }, - "main": "dist-electron/main.js" + "name": "recordly", + "productName": "Recordly", + "description": "A free, creator-focused screen recorder with auto-zoom, cursor effects, backgrounds, annotations, and more - built for polished videos out of the box.", + "author": "webadderall", + "homepage": "https://github.com/webadderallorg/Recordly", + "repository": { + "type": "git", + "url": "https://github.com/webadderallorg/Recordly.git" + }, + "bugs": { + "url": "https://github.com/webadderallorg/Recordly/issues" + }, + "private": true, + "version": "1.3.5-beta.2", + "type": "module", + "scripts": { + "dev": "vite --config vite.config.ts", + "postinstall": "node scripts/postinstall.mjs", + "build": "npm run build:platform-native-helpers && tsc && vite build --config vite.config.ts && npm run normalize:electron-main-cjs && npm run smoke:electron-main-cjs && electron-builder", + "lint": "biome lint .", + "lint:fix": "biome lint --write .", + "format": "biome format --write .", + "format:check": "biome format .", + "preview": "vite preview --config vite.config.ts", + "rebuild:native": "node ./node_modules/@electron/rebuild/lib/cli.js --force --only uiohook-napi", + "build:native-helpers": "node scripts/build-native-helpers.mjs", + "build:whisper-runtime": "node scripts/build-whisper-runtime.mjs", + "build:platform-native-helpers": "npm run build:whisper-runtime && npm run build:native-helpers && npm run build:windows-capture && npm run build:windows-gpu-export && npm run build:nvidia-cuda-compositor && npm run build:cursor-monitor", + "build:windows-gpu-export": "node scripts/build-windows-gpu-export.mjs", + "build:nvidia-cuda-compositor": "node scripts/build-nvidia-cuda-compositor.mjs", + "build:windows-capture": "node scripts/build-windows-capture.mjs", + "build:cursor-monitor": "node scripts/build-cursor-monitor.mjs", + "build:mac": "npm run build:platform-native-helpers && tsc && vite build --config vite.config.ts && npm run normalize:electron-main-cjs && npm run smoke:electron-main-cjs && electron-builder --mac", + "build:win": "npm run build:platform-native-helpers && tsc && vite build --config vite.config.ts && npm run normalize:electron-main-cjs && npm run smoke:electron-main-cjs && electron-builder --win", + "build:linux": "npm run build:platform-native-helpers && tsc && vite build --config vite.config.ts && npm run normalize:electron-main-cjs && npm run smoke:electron-main-cjs && electron-builder --linux", + "i18n:check": "node scripts/i18n-check.mjs", + "benchmark:export-queues": "node scripts/benchmark-export-queues.mjs", + "normalize:electron-main-cjs": "node scripts/normalize-electron-main-cjs.mjs", + "smoke:electron-main-cjs": "node scripts/smoke-electron-main-cjs.mjs", + "smoke:packaged-binaries": "node scripts/smoke-packaged-binaries.mjs", + "verify:macos-distribution": "node scripts/verify-macos-distribution.mjs", + "checksums:release": "node scripts/write-release-checksums.mjs", + "release:create": "node scripts/create-release.mjs", + "test": "vitest --run", + "test:watch": "vitest" + }, + "dependencies": { + "@phosphor-icons/react": "^2.1.10", + "capturekit": "^1.0.13", + "electron-updater": "^6.8.3", + "ffmpeg-static": "^5.3.0", + "ffprobe-static": "^3.1.0", + "uiohook-napi": "^1.5.4" + }, + "devDependencies": { + "@biomejs/biome": "2.3.13", + "@electron/rebuild": "^4.2.0", + "@fix-webm-duration/fix": "^1.0.1", + "@radix-ui/react-accordion": "^1.2.12", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-dropdown-menu": "^2.1.16", + "@radix-ui/react-popover": "^1.1.15", + "@radix-ui/react-select": "^2.2.6", + "@radix-ui/react-slider": "^1.3.6", + "@radix-ui/react-slot": "^1.2.3", + "@radix-ui/react-switch": "^1.2.6", + "@radix-ui/react-tabs": "^1.1.13", + "@radix-ui/react-toggle": "^1.1.10", + "@radix-ui/react-toggle-group": "^1.1.11", + "@types/gif.js": "^0.2.5", + "@types/node": "^25.0.3", + "@types/react": "^18.2.64", + "@types/react-dom": "^18.2.21", + "@uiw/color-convert": "^2.9.2", + "@uiw/react-color-block": "^2.9.2", + "@vitejs/plugin-react": "^4.2.1", + "autoprefixer": "^10.4.21", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "dnd-timeline": "^2.2.0", + "electron": "^43.1.0", + "electron-builder": "^26.7.0", + "emoji-picker-react": "^4.16.1", + "fast-check": "^4.5.2", + "gif.js": "^0.2.0", + "mediabunny": "^1.25.1", + "motion": "^12.23.24", + "mp4box": "^2.2.0", + "pixi-filters": "^6.1.5", + "pixi.js": "^8.14.0", + "postcss": "^8.5.6", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-icons": "^5.5.0", + "react-resizable-panels": "^3.0.6", + "react-rnd": "^10.5.2", + "sonner": "^2.0.7", + "tailwind-merge": "^3.3.1", + "tailwindcss": "^3.4.18", + "tailwindcss-animate": "^1.0.7", + "terser": "^5.44.1", + "typescript": "^5.2.2", + "vite": "^5.1.6", + "vite-plugin-electron": "^0.28.6", + "vite-plugin-electron-renderer": "^0.14.5", + "vitest": "^3.2.7", + "web-demuxer": "^4.0.0" + }, + "main": "dist-electron/main.cjs" } diff --git a/postcss.config.cjs b/postcss.config.cjs index 96bb01e7d..e873f1a4f 100644 --- a/postcss.config.cjs +++ b/postcss.config.cjs @@ -1,6 +1,6 @@ module.exports = { - plugins: { - tailwindcss: {}, - autoprefixer: {}, - }, -} \ No newline at end of file + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/preview.png b/preview.png deleted file mode 100644 index b65ad87c2..000000000 Binary files a/preview.png and /dev/null differ diff --git a/preview2.png b/preview2.png deleted file mode 100644 index a3fd12091..000000000 Binary files a/preview2.png and /dev/null differ diff --git a/preview3.png b/preview3.png deleted file mode 100644 index edc736929..000000000 Binary files a/preview3.png and /dev/null differ diff --git a/public/app-icons/recordly-1024.png b/public/app-icons/recordly-1024.png new file mode 100644 index 000000000..c194c3fff Binary files /dev/null and b/public/app-icons/recordly-1024.png differ diff --git a/public/app-icons/recordly-128.png b/public/app-icons/recordly-128.png new file mode 100644 index 000000000..8dc2b50fd Binary files /dev/null and b/public/app-icons/recordly-128.png differ diff --git a/public/app-icons/recordly-16.png b/public/app-icons/recordly-16.png new file mode 100644 index 000000000..66ea3f137 Binary files /dev/null and b/public/app-icons/recordly-16.png differ diff --git a/public/app-icons/recordly-256.png b/public/app-icons/recordly-256.png new file mode 100644 index 000000000..6d6eb25e7 Binary files /dev/null and b/public/app-icons/recordly-256.png differ diff --git a/public/app-icons/recordly-32.png b/public/app-icons/recordly-32.png new file mode 100644 index 000000000..dee1d807a Binary files /dev/null and b/public/app-icons/recordly-32.png differ diff --git a/public/app-icons/recordly-512.png b/public/app-icons/recordly-512.png new file mode 100644 index 000000000..1d1a8fa60 Binary files /dev/null and b/public/app-icons/recordly-512.png differ diff --git a/public/app-icons/recordly-64.png b/public/app-icons/recordly-64.png new file mode 100644 index 000000000..fc3c7a673 Binary files /dev/null and b/public/app-icons/recordly-64.png differ diff --git a/public/app-icons/recordlymac-1024.png b/public/app-icons/recordlymac-1024.png new file mode 100644 index 000000000..c8b9fe951 Binary files /dev/null and b/public/app-icons/recordlymac-1024.png differ diff --git a/public/app-icons/recordlymac-128.png b/public/app-icons/recordlymac-128.png new file mode 100644 index 000000000..31d83516a Binary files /dev/null and b/public/app-icons/recordlymac-128.png differ diff --git a/public/app-icons/recordlymac-16.png b/public/app-icons/recordlymac-16.png new file mode 100644 index 000000000..ea827a787 Binary files /dev/null and b/public/app-icons/recordlymac-16.png differ diff --git a/public/app-icons/recordlymac-256.png b/public/app-icons/recordlymac-256.png new file mode 100644 index 000000000..0d472c863 Binary files /dev/null and b/public/app-icons/recordlymac-256.png differ diff --git a/public/app-icons/recordlymac-32.png b/public/app-icons/recordlymac-32.png new file mode 100644 index 000000000..e4107c884 Binary files /dev/null and b/public/app-icons/recordlymac-32.png differ diff --git a/public/app-icons/recordlymac-512.png b/public/app-icons/recordlymac-512.png new file mode 100644 index 000000000..132874bf8 Binary files /dev/null and b/public/app-icons/recordlymac-512.png differ diff --git a/public/app-icons/recordlymac-64.png b/public/app-icons/recordlymac-64.png new file mode 100644 index 000000000..58945af1c Binary files /dev/null and b/public/app-icons/recordlymac-64.png differ diff --git a/public/electron-vite.animate.svg b/public/electron-vite.animate.svg deleted file mode 100644 index ea3e77707..000000000 --- a/public/electron-vite.animate.svg +++ /dev/null @@ -1,34 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/public/electron-vite.svg b/public/electron-vite.svg deleted file mode 100644 index 8a6aefe65..000000000 --- a/public/electron-vite.svg +++ /dev/null @@ -1,26 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/public/vite.svg b/public/vite.svg index e7b8dfb1b..ee9fadaf9 100644 --- a/public/vite.svg +++ b/public/vite.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/public/wallpaper.png b/public/wallpaper.png deleted file mode 100644 index 0e51d83d3..000000000 Binary files a/public/wallpaper.png and /dev/null differ diff --git a/public/wallpapers/bluerays.jpeg b/public/wallpapers/bluerays.jpeg new file mode 100644 index 000000000..a73d3b6bd Binary files /dev/null and b/public/wallpapers/bluerays.jpeg differ diff --git a/public/wallpapers/cherrypop.jpg b/public/wallpapers/cherrypop.jpg new file mode 100644 index 000000000..13a1695ad Binary files /dev/null and b/public/wallpapers/cherrypop.jpg differ diff --git a/public/wallpapers/cityscape.jpg b/public/wallpapers/cityscape.jpg new file mode 100644 index 000000000..2bde6982e Binary files /dev/null and b/public/wallpapers/cityscape.jpg differ diff --git a/public/wallpapers/energy-17.jpg b/public/wallpapers/energy-17.jpg new file mode 100644 index 000000000..713695fbb Binary files /dev/null and b/public/wallpapers/energy-17.jpg differ diff --git a/public/wallpapers/energy-19.jpg b/public/wallpapers/energy-19.jpg new file mode 100644 index 000000000..dfabececa Binary files /dev/null and b/public/wallpapers/energy-19.jpg differ diff --git a/public/wallpapers/farmvalley.jpg b/public/wallpapers/farmvalley.jpg new file mode 100644 index 000000000..69dd795ee Binary files /dev/null and b/public/wallpapers/farmvalley.jpg differ diff --git a/public/wallpapers/glassmorphism-3.jpg b/public/wallpapers/glassmorphism-3.jpg new file mode 100644 index 000000000..986c69a34 Binary files /dev/null and b/public/wallpapers/glassmorphism-3.jpg differ diff --git a/public/wallpapers/glassmorphism-4.jpg b/public/wallpapers/glassmorphism-4.jpg new file mode 100644 index 000000000..1f9aaffde Binary files /dev/null and b/public/wallpapers/glassmorphism-4.jpg differ diff --git a/public/wallpapers/ipad-17-dark.jpg b/public/wallpapers/ipad-17-dark.jpg new file mode 100644 index 000000000..f1782e17f Binary files /dev/null and b/public/wallpapers/ipad-17-dark.jpg differ diff --git a/public/wallpapers/ipad-17-light.jpg b/public/wallpapers/ipad-17-light.jpg new file mode 100644 index 000000000..738ecf990 Binary files /dev/null and b/public/wallpapers/ipad-17-light.jpg differ diff --git a/public/wallpapers/iridescent-9.jpg b/public/wallpapers/iridescent-9.jpg new file mode 100644 index 000000000..b33c7f83c Binary files /dev/null and b/public/wallpapers/iridescent-9.jpg differ diff --git a/public/wallpapers/lemonade.jpeg b/public/wallpapers/lemonade.jpeg new file mode 100644 index 000000000..fc67e2a23 Binary files /dev/null and b/public/wallpapers/lemonade.jpeg differ diff --git a/public/wallpapers/levels.jpg b/public/wallpapers/levels.jpg new file mode 100644 index 000000000..c181df856 Binary files /dev/null and b/public/wallpapers/levels.jpg differ diff --git a/public/wallpapers/luisdelrio.jpg b/public/wallpapers/luisdelrio.jpg new file mode 100644 index 000000000..7b6365b9b Binary files /dev/null and b/public/wallpapers/luisdelrio.jpg differ diff --git a/public/wallpapers/midnight-8.jpg b/public/wallpapers/midnight-8.jpg new file mode 100644 index 000000000..21932e706 Binary files /dev/null and b/public/wallpapers/midnight-8.jpg differ diff --git a/public/wallpapers/mountaintrees.jpg b/public/wallpapers/mountaintrees.jpg new file mode 100644 index 000000000..ae1098fd9 Binary files /dev/null and b/public/wallpapers/mountaintrees.jpg differ diff --git a/public/wallpapers/sequoia-blue-orange.jpg b/public/wallpapers/sequoia-blue-orange.jpg new file mode 100644 index 000000000..904fab1bf Binary files /dev/null and b/public/wallpapers/sequoia-blue-orange.jpg differ diff --git a/public/wallpapers/sequoia-blue.jpg b/public/wallpapers/sequoia-blue.jpg new file mode 100644 index 000000000..a0dfb46d0 Binary files /dev/null and b/public/wallpapers/sequoia-blue.jpg differ diff --git a/public/wallpapers/sonoma-clouds.jpg b/public/wallpapers/sonoma-clouds.jpg new file mode 100644 index 000000000..e60330f13 Binary files /dev/null and b/public/wallpapers/sonoma-clouds.jpg differ diff --git a/public/wallpapers/sonoma-dark.jpg b/public/wallpapers/sonoma-dark.jpg new file mode 100644 index 000000000..e11d78a21 Binary files /dev/null and b/public/wallpapers/sonoma-dark.jpg differ diff --git a/public/wallpapers/sonoma-evening.jpg b/public/wallpapers/sonoma-evening.jpg new file mode 100644 index 000000000..d61e60d6b Binary files /dev/null and b/public/wallpapers/sonoma-evening.jpg differ diff --git a/public/wallpapers/sonoma-horizon.jpg b/public/wallpapers/sonoma-horizon.jpg new file mode 100644 index 000000000..1e237181c Binary files /dev/null and b/public/wallpapers/sonoma-horizon.jpg differ diff --git a/public/wallpapers/sonoma-light.jpg b/public/wallpapers/sonoma-light.jpg new file mode 100644 index 000000000..44ef9efd4 Binary files /dev/null and b/public/wallpapers/sonoma-light.jpg differ diff --git a/public/wallpapers/tahoe-dark.jpg b/public/wallpapers/tahoe-dark.jpg new file mode 100644 index 000000000..e83fce96c Binary files /dev/null and b/public/wallpapers/tahoe-dark.jpg differ diff --git a/public/wallpapers/tahoe-light.jpg b/public/wallpapers/tahoe-light.jpg new file mode 100644 index 000000000..93001c56e Binary files /dev/null and b/public/wallpapers/tahoe-light.jpg differ diff --git a/public/wallpapers/ventura-dark.jpg b/public/wallpapers/ventura-dark.jpg new file mode 100644 index 000000000..09267bccf Binary files /dev/null and b/public/wallpapers/ventura-dark.jpg differ diff --git a/public/wallpapers/ventura.jpg b/public/wallpapers/ventura.jpg new file mode 100644 index 000000000..77e3c5c9e Binary files /dev/null and b/public/wallpapers/ventura.jpg differ diff --git a/public/wallpapers/wallpaper1.jpg b/public/wallpapers/wallpaper1.jpg index d91b53073..98c93cb14 100644 Binary files a/public/wallpapers/wallpaper1.jpg and b/public/wallpapers/wallpaper1.jpg differ diff --git a/public/wallpapers/wallpaper1.png b/public/wallpapers/wallpaper1.png deleted file mode 100644 index 0e51d83d3..000000000 Binary files a/public/wallpapers/wallpaper1.png and /dev/null differ diff --git a/public/wallpapers/wallpaper13.jpg b/public/wallpapers/wallpaper13.jpg new file mode 100644 index 000000000..052f0ff7f Binary files /dev/null and b/public/wallpapers/wallpaper13.jpg differ diff --git a/public/wallpapers/wallpaper15.jpg b/public/wallpapers/wallpaper15.jpg new file mode 100644 index 000000000..5742aab45 Binary files /dev/null and b/public/wallpapers/wallpaper15.jpg differ diff --git a/public/wallpapers/wallpaper4.jpg b/public/wallpapers/wallpaper4.jpg index 3620da9a5..49f281643 100644 Binary files a/public/wallpapers/wallpaper4.jpg and b/public/wallpapers/wallpaper4.jpg differ diff --git a/public/wallpapers/wallpaper5.jpg b/public/wallpapers/wallpaper5.jpg deleted file mode 100644 index dec36ce04..000000000 Binary files a/public/wallpapers/wallpaper5.jpg and /dev/null differ diff --git a/public/wallpapers/wallpaper6.jpg b/public/wallpapers/wallpaper6.jpg deleted file mode 100644 index 23314b220..000000000 Binary files a/public/wallpapers/wallpaper6.jpg and /dev/null differ diff --git a/public/wallpapers/wallpaper7.jpg b/public/wallpapers/wallpaper7.jpg index 6c23515f3..aa9d818d8 100644 Binary files a/public/wallpapers/wallpaper7.jpg and b/public/wallpapers/wallpaper7.jpg differ diff --git a/public/wallpapers/wispysky.mp4 b/public/wallpapers/wispysky.mp4 new file mode 100644 index 000000000..3454d9160 Binary files /dev/null and b/public/wallpapers/wispysky.mp4 differ diff --git a/public/wasm/web-demuxer.wasm b/public/wasm/web-demuxer.wasm new file mode 100755 index 000000000..41d4f7355 Binary files /dev/null and b/public/wasm/web-demuxer.wasm differ diff --git a/recordly.rb b/recordly.rb new file mode 100644 index 000000000..f037c928b --- /dev/null +++ b/recordly.rb @@ -0,0 +1,25 @@ +cask "recordly" do + arch arm: "arm64", intel: "x64" + + version "1.1.14" + sha256 arm: "e669ab7c8bdd4596211937183ee2374545da5482702cab0dfe477c1466422b0f", + intel: "85f5183219de0b656400625797ff9299893bd8fbda8455b0f745878b2c729526" + + url "https://github.com/webadderallorg/Recordly/releases/download/v#{version}/Recordly-#{arch}.dmg" + name "Recordly" + desc "Creator-focused screen recorder with auto-zoom, cursor effects, and more" + homepage "https://github.com/webadderallorg/Recordly" + + livecheck do + url :url + strategy :github_latest + end + + app "Recordly.app" + + zap trash: [ + "~/Library/Application Support/Recordly", + "~/Library/Preferences/dev.recordly.app.plist", + "~/Library/Saved Application State/dev.recordly.app.savedState", + ] +end diff --git a/scripts/benchmark-export-queues.mjs b/scripts/benchmark-export-queues.mjs new file mode 100644 index 000000000..57b030c69 --- /dev/null +++ b/scripts/benchmark-export-queues.mjs @@ -0,0 +1,1051 @@ +import { execFile, spawn } from "node:child_process"; +import { once } from "node:events"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; +import electron from "electron"; +import ffmpegStatic from "ffmpeg-static"; + +const execFileAsync = promisify(execFile); +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(__dirname, ".."); +const mainEntry = path.join(repoRoot, "dist-electron", "main.cjs"); +const rendererEntry = path.join(repoRoot, "dist", "index.html"); + +const width = parseEvenInteger(process.env.RECORDLY_BENCH_EXPORT_WIDTH ?? "1280", "Width"); +const height = parseEvenInteger(process.env.RECORDLY_BENCH_EXPORT_HEIGHT ?? "720", "Height"); +const frameRate = parsePositiveInteger(process.env.RECORDLY_BENCH_EXPORT_FPS ?? "60", "Frame rate"); +const durationSeconds = parsePositiveInteger( + process.env.RECORDLY_BENCH_EXPORT_DURATION ?? "15", + "Duration", +); +const timeoutMs = parsePositiveInteger( + process.env.RECORDLY_BENCH_EXPORT_TIMEOUT_MS ?? "180000", + "Timeout", +); +const runsPerVariant = parsePositiveInteger(process.env.RECORDLY_BENCH_EXPORT_RUNS ?? "2", "Runs"); +const useNativeExport = process.env.RECORDLY_BENCH_EXPORT_USE_NATIVE === "1"; +const useWebcamOverlay = process.env.RECORDLY_BENCH_EXPORT_ENABLE_WEBCAM === "1"; +const providedInputPath = process.env.RECORDLY_BENCH_EXPORT_INPUT ?? null; +const providedWebcamInputPath = process.env.RECORDLY_BENCH_EXPORT_WEBCAM_INPUT ?? null; +const keepTempArtifacts = process.env.RECORDLY_BENCH_EXPORT_KEEP_TEMP === "1"; +const exportEncodingMode = parseExportEncodingMode( + process.env.RECORDLY_BENCH_EXPORT_ENCODING_MODE ?? null, +); +const exportQuality = parseExportQuality(process.env.RECORDLY_BENCH_EXPORT_QUALITY ?? null); +const exportShadowIntensity = parseExportShadowIntensity( + process.env.RECORDLY_BENCH_EXPORT_SHADOW_INTENSITY ?? null, +); +const webcamWidth = parseEvenInteger( + process.env.RECORDLY_BENCH_EXPORT_WEBCAM_WIDTH ?? "640", + "Webcam width", +); +const webcamHeight = parseEvenInteger( + process.env.RECORDLY_BENCH_EXPORT_WEBCAM_HEIGHT ?? "360", + "Webcam height", +); +const webcamShadowIntensity = parseExportShadowIntensity( + process.env.RECORDLY_BENCH_EXPORT_WEBCAM_SHADOW ?? null, +); +const webcamSize = parseExportWebcamSize(process.env.RECORDLY_BENCH_EXPORT_WEBCAM_SIZE ?? null); +const MODERN_BACKEND_SWEEP = ["auto", "webcodecs", "breeze"]; +const exportPipeline = parseExportPipeline(process.env.RECORDLY_BENCH_EXPORT_PIPELINE ?? null); +const exportBackend = parseExportBackend(process.env.RECORDLY_BENCH_EXPORT_BACKEND ?? null); +const exportRenderBackend = parseRenderBackend( + process.env.RECORDLY_BENCH_EXPORT_RENDER_BACKEND ?? null, +); +const exportBackendList = parseExportBackendList( + process.env.RECORDLY_BENCH_EXPORT_BACKENDS ?? null, +); + +const VARIANT_PRESETS = { + adaptive: { name: "adaptive" }, + baseline: { name: "baseline", maxEncodeQueue: 120, maxDecodeQueue: 10, maxPendingFrames: 24 }, + tuned: { name: "tuned", maxEncodeQueue: 240, maxDecodeQueue: 12, maxPendingFrames: 32 }, +}; + +const variantNameList = parseBenchmarkVariantList( + process.env.RECORDLY_BENCH_EXPORT_VARIANTS ?? null, +); + +const variants = variantNameList + ? variantNameList.map((variantName) => VARIANT_PRESETS[variantName]) + : [VARIANT_PRESETS.baseline, VARIANT_PRESETS.tuned]; + +function collectUniqueStrings(values) { + return [...new Set(values.filter((value) => typeof value === "string" && value.length > 0))]; +} + +function parsePositiveInteger(rawValue, label) { + const parsed = Number.parseInt(rawValue, 10); + if (!Number.isInteger(parsed) || parsed <= 0) { + throw new Error(`${label} must be a positive integer`); + } + + return parsed; +} + +function parseEvenInteger(rawValue, label) { + const parsed = parsePositiveInteger(rawValue, label); + if (parsed % 2 !== 0) { + throw new Error(`${label} must be even`); + } + + return parsed; +} + +function parseExportPipeline(rawValue) { + if (rawValue === null || rawValue === "") { + return null; + } + + if (rawValue === "legacy" || rawValue === "modern") { + return rawValue; + } + + throw new Error("RECORDLY_BENCH_EXPORT_PIPELINE must be 'legacy' or 'modern'"); +} + +function parseExportBackend(rawValue) { + if (rawValue === null || rawValue === "") { + return null; + } + + if (rawValue === "auto" || rawValue === "webcodecs" || rawValue === "breeze") { + return rawValue; + } + + throw new Error("RECORDLY_BENCH_EXPORT_BACKEND must be 'auto', 'webcodecs', or 'breeze'"); +} + +function parseRenderBackend(rawValue) { + if (rawValue === null || rawValue === "") { + return null; + } + + if (rawValue === "webgl" || rawValue === "webgpu") { + return rawValue; + } + + throw new Error("RECORDLY_BENCH_EXPORT_RENDER_BACKEND must be 'webgl' or 'webgpu'"); +} + +function parseExportBackendList(rawValue) { + if (rawValue === null || rawValue === "") { + return null; + } + + if (rawValue === "all") { + return [...MODERN_BACKEND_SWEEP]; + } + + const values = rawValue + .split(",") + .map((value) => value.trim()) + .filter((value) => value.length > 0) + .map((value) => parseExportBackend(value)) + .filter((value) => value !== null); + + if (values.length === 0) { + throw new Error( + "RECORDLY_BENCH_EXPORT_BACKENDS must include at least one of: auto, webcodecs, breeze", + ); + } + + return [...new Set(values)]; +} + +function parseBenchmarkVariantList(rawValue) { + if (rawValue === null || rawValue === "") { + return null; + } + + const values = rawValue + .split(",") + .map((value) => value.trim()) + .filter((value) => value.length > 0); + + if (values.length === 0) { + throw new Error( + "RECORDLY_BENCH_EXPORT_VARIANTS must include at least one of: adaptive, baseline, tuned", + ); + } + + for (const value of values) { + if (!(value in VARIANT_PRESETS)) { + throw new Error( + "RECORDLY_BENCH_EXPORT_VARIANTS must include only: adaptive, baseline, tuned", + ); + } + } + + return [...new Set(values)]; +} + +function parseExportEncodingMode(rawValue) { + if (rawValue === null || rawValue === "") { + return null; + } + + if (rawValue === "fast" || rawValue === "balanced" || rawValue === "quality") { + return rawValue; + } + + throw new Error("RECORDLY_BENCH_EXPORT_ENCODING_MODE must be 'fast', 'balanced', or 'quality'"); +} + +function parseExportQuality(rawValue) { + if (rawValue === null || rawValue === "") { + return null; + } + + if ( + rawValue === "medium" || + rawValue === "good" || + rawValue === "high" || + rawValue === "source" + ) { + return rawValue; + } + + throw new Error("RECORDLY_BENCH_EXPORT_QUALITY must be 'medium', 'good', 'high', or 'source'"); +} + +function parseExportShadowIntensity(rawValue) { + if (rawValue === null || rawValue === "") { + return null; + } + + const parsed = Number.parseFloat(rawValue); + if (!Number.isFinite(parsed) || parsed < 0) { + throw new Error("RECORDLY_BENCH_EXPORT_SHADOW_INTENSITY must be a non-negative number"); + } + + return parsed; +} + +function parseExportWebcamSize(rawValue) { + if (rawValue === null || rawValue === "") { + return null; + } + + const parsed = Number.parseFloat(rawValue); + if (!Number.isFinite(parsed) || parsed <= 0 || parsed > 100) { + throw new Error("RECORDLY_BENCH_EXPORT_WEBCAM_SIZE must be a number between 0 and 100"); + } + + return parsed; +} + +function summarizeSmokeProgress(progressSamples) { + if (!Array.isArray(progressSamples) || progressSamples.length === 0) { + return null; + } + + const extractingSamples = progressSamples.filter( + (sample) => + sample?.phase === "extracting" && + typeof sample?.currentFrame === "number" && + sample.currentFrame > 1, + ); + const fpsSource = extractingSamples.length > 0 ? extractingSamples : progressSamples; + const renderFpsSamples = fpsSource + .map((sample) => sample?.renderFps) + .filter((value) => typeof value === "number" && Number.isFinite(value)); + const firstSample = progressSamples[0] ?? null; + const lastSample = progressSamples.at(-1) ?? null; + const firstExtractingSample = extractingSamples[0] ?? null; + const lastExtractingSample = extractingSamples.at(-1) ?? null; + + return { + samples: progressSamples.length, + extractingSamples: extractingSamples.length, + firstElapsedMs: typeof firstSample?.elapsedMs === "number" ? firstSample.elapsedMs : null, + lastElapsedMs: typeof lastSample?.elapsedMs === "number" ? lastSample.elapsedMs : null, + firstExtractingElapsedMs: + typeof firstExtractingSample?.elapsedMs === "number" + ? firstExtractingSample.elapsedMs + : null, + lastExtractingElapsedMs: + typeof lastExtractingSample?.elapsedMs === "number" + ? lastExtractingSample.elapsedMs + : null, + firstRenderFps: renderFpsSamples[0] ?? null, + lastRenderFps: renderFpsSamples.at(-1) ?? null, + minRenderFps: renderFpsSamples.length > 0 ? Math.min(...renderFpsSamples) : null, + maxRenderFps: renderFpsSamples.length > 0 ? Math.max(...renderFpsSamples) : null, + }; +} + +async function ensureBuildArtifacts() { + await fs.access(mainEntry); + await fs.access(rendererEntry); +} + +async function createFixtureVideo( + ffmpegPath, + targetPath, + { + fixtureWidth = width, + fixtureHeight = height, + includeAudio = true, + videoFilter = `testsrc2=size=${fixtureWidth}x${fixtureHeight}:rate=${frameRate}`, + } = {}, +) { + const args = ["-y", "-hide_banner", "-loglevel", "error", "-f", "lavfi", "-i", videoFilter]; + + if (includeAudio) { + args.push( + "-f", + "lavfi", + "-i", + "sine=frequency=880:sample_rate=48000", + "-c:a", + "aac", + "-b:a", + "128k", + ); + } else { + args.push("-an"); + } + + args.push( + "-t", + String(durationSeconds), + "-c:v", + "libx264", + "-preset", + "veryfast", + "-pix_fmt", + "yuv420p", + "-movflags", + "+faststart", + targetPath, + ); + + await execFileAsync(ffmpegPath, args, { + timeout: 60_000, + maxBuffer: 20 * 1024 * 1024, + }); +} + +function parseDurationSeconds(ffmpegOutput) { + const match = ffmpegOutput.match(/Duration:\s*(\d+):(\d+):(\d+(?:\.\d+)?)/i); + if (!match) { + return null; + } + + return ( + Number.parseInt(match[1], 10) * 3600 + + Number.parseInt(match[2], 10) * 60 + + Number.parseFloat(match[3]) + ); +} + +async function inspectOutput(ffmpegPath, targetPath) { + try { + const { stderr } = await execFileAsync( + ffmpegPath, + ["-hide_banner", "-i", targetPath, "-f", "null", "-"], + { + timeout: 30_000, + maxBuffer: 20 * 1024 * 1024, + }, + ); + return parseDurationSeconds(stderr); + } catch (error) { + return parseDurationSeconds(String(error?.stderr ?? "")); + } +} + +async function readSmokeExportReport(outputPath) { + const reportPath = `${outputPath}.report.json`; + + try { + const reportContent = await fs.readFile(reportPath, "utf8"); + return { + reportPath, + report: JSON.parse(reportContent), + }; + } catch { + return null; + } +} + +function buildBenchmarkRequests() { + if (exportBackendList) { + return exportBackendList.map((backend) => ({ + pipeline: exportPipeline, + backend, + label: backend, + slug: backend, + })); + } + + if (exportBackend) { + return [ + { + pipeline: exportPipeline, + backend: exportBackend, + label: exportBackend, + slug: exportBackend, + }, + ]; + } + + if (exportPipeline === "modern") { + return MODERN_BACKEND_SWEEP.map((backend) => ({ + pipeline: exportPipeline, + backend, + label: backend, + slug: backend, + })); + } + + return [ + { + pipeline: exportPipeline, + backend: null, + label: "default", + slug: "default", + }, + ]; +} + +function formatTableCell(value) { + if (Array.isArray(value)) { + return value.length > 0 ? value.join(", ") : "-"; + } + + if (value === null || value === undefined || value === "") { + return "-"; + } + + return String(value).replace(/\s+/g, " ").trim(); +} + +function printTable(title, columns, rows) { + if (!Array.isArray(rows) || rows.length === 0) { + return; + } + + const formattedRows = rows.map((row) => + columns.map((column) => formatTableCell(column.getValue(row))), + ); + const widths = columns.map((column, columnIndex) => { + const headerWidth = column.header.length; + const rowWidth = Math.max(...formattedRows.map((row) => row[columnIndex].length)); + return Math.max(headerWidth, rowWidth); + }); + const divider = `| ${widths.map((width) => "-".repeat(width)).join(" | ")} |`; + + console.log(`[benchmark-export-queues] ${title}`); + console.log( + `| ${columns + .map((column, columnIndex) => column.header.padEnd(widths[columnIndex])) + .join(" | ")} |`, + ); + console.log(divider); + for (const row of formattedRows) { + console.log( + `| ${row.map((value, columnIndex) => value.padEnd(widths[columnIndex])).join(" | ")} |`, + ); + } +} + +function formatMs(value) { + return typeof value === "number" && Number.isFinite(value) ? `${Math.round(value)} ms` : "-"; +} + +function formatDeltaMs(value) { + if (typeof value !== "number" || !Number.isFinite(value)) { + return "-"; + } + + const roundedValue = Math.round(value); + return `${roundedValue > 0 ? "+" : ""}${roundedValue} ms`; +} + +function formatPercent(value) { + return typeof value === "number" && Number.isFinite(value) ? `${value.toFixed(1)}%` : "-"; +} + +function formatSeconds(value) { + return typeof value === "number" && Number.isFinite(value) ? `${value.toFixed(2)} s` : "-"; +} + +function formatMegabytes(value) { + return typeof value === "number" && Number.isFinite(value) + ? `${(value / (1024 * 1024)).toFixed(2)} MB` + : "-"; +} + +function formatBoolean(value) { + return value ? "Yes" : "No"; +} + +function calculateDelta(referenceValue, nextValue) { + if ( + typeof referenceValue !== "number" || + !Number.isFinite(referenceValue) || + typeof nextValue !== "number" || + !Number.isFinite(nextValue) + ) { + return { deltaMs: null, deltaPercent: null }; + } + + return { + deltaMs: nextValue - referenceValue, + deltaPercent: + referenceValue > 0 ? ((nextValue - referenceValue) / referenceValue) * 100 : null, + }; +} + +function buildRequestedConfigRows(benchmarkRequests) { + const rows = [ + { key: "Width", value: width }, + { key: "Height", value: height }, + { key: "Frame rate", value: `${frameRate} FPS` }, + { key: "Duration", value: `${durationSeconds} s` }, + { key: "Timeout", value: formatMs(timeoutMs) }, + { key: "Runs per variant", value: runsPerVariant }, + { key: "Pipeline", value: exportPipeline ?? "default" }, + { key: "Requested backends", value: benchmarkRequests.map((request) => request.label) }, + { key: "Render backend", value: exportRenderBackend ?? "default" }, + { key: "Backend sweep", value: formatBoolean(benchmarkRequests.length > 1) }, + { key: "Encoding mode", value: exportEncodingMode ?? "default" }, + { key: "Quality", value: exportQuality ?? "default" }, + { key: "Shadow intensity", value: exportShadowIntensity ?? "default" }, + { key: "Webcam enabled", value: formatBoolean(useWebcamOverlay) }, + { key: "Experimental native override", value: formatBoolean(useNativeExport) }, + ]; + + if (useWebcamOverlay) { + rows.push( + { key: "Webcam width", value: webcamWidth }, + { key: "Webcam height", value: webcamHeight }, + { key: "Webcam shadow", value: webcamShadowIntensity ?? "default" }, + { key: "Webcam size", value: webcamSize ?? "default" }, + ); + } + + return rows; +} + +function printRequestedConfigTable(benchmarkRequests) { + printTable( + "Requested config", + [ + { header: "Setting", getValue: (row) => row.key }, + { header: "Value", getValue: (row) => row.value }, + ], + buildRequestedConfigRows(benchmarkRequests), + ); +} + +function buildTimingTableRows(benchmarkResults) { + return benchmarkResults.flatMap((result) => + result.summaries.map((summary) => ({ + backend: result.request.backend ?? "default", + pipeline: result.request.pipeline ?? "default", + variant: summary.variant.name, + averageElapsedMs: summary.averageElapsedMs, + medianElapsedMs: summary.medianElapsedMs, + averageSmokeElapsedMs: summary.averageSmokeElapsedMs, + minElapsedMs: summary.minElapsedMs, + maxElapsedMs: summary.maxElapsedMs, + averageOutputDurationSeconds: summary.averageOutputDurationSeconds, + averageSizeBytes: summary.averageSizeBytes, + webcamEnabled: summary.webcamEnabled, + })), + ); +} + +function printTimingSummaryTable(benchmarkResults) { + printTable( + "Timing summary", + [ + { header: "Pipeline", getValue: (row) => row.pipeline }, + { header: "Backend", getValue: (row) => row.backend }, + { header: "Variant", getValue: (row) => row.variant }, + { header: "Avg total", getValue: (row) => formatMs(row.averageElapsedMs) }, + { header: "Median total", getValue: (row) => formatMs(row.medianElapsedMs) }, + { header: "Avg export", getValue: (row) => formatMs(row.averageSmokeElapsedMs) }, + { header: "Min", getValue: (row) => formatMs(row.minElapsedMs) }, + { header: "Max", getValue: (row) => formatMs(row.maxElapsedMs) }, + { + header: "Avg output", + getValue: (row) => formatSeconds(row.averageOutputDurationSeconds), + }, + { header: "Avg size", getValue: (row) => formatMegabytes(row.averageSizeBytes) }, + { header: "Webcam", getValue: (row) => formatBoolean(row.webcamEnabled) }, + ], + buildTimingTableRows(benchmarkResults), + ); +} + +function buildBackendDetailTableRows(benchmarkResults) { + return benchmarkResults.flatMap((result) => + result.summaries.map((summary) => ({ + backend: result.request.backend ?? "default", + pipeline: result.request.pipeline ?? "default", + variant: summary.variant.name, + encodeQueue: summary.variant.maxEncodeQueue, + decodeQueue: summary.variant.maxDecodeQueue, + pendingFrames: summary.variant.maxPendingFrames, + observedRenderBackends: summary.observedRenderBackends, + observedEncodeBackends: summary.observedEncodeBackends, + observedEncoders: summary.observedEncoders, + })), + ); +} + +function printBackendDetailTable(benchmarkResults) { + printTable( + "Observed backends", + [ + { header: "Pipeline", getValue: (row) => row.pipeline }, + { header: "Backend", getValue: (row) => row.backend }, + { header: "Variant", getValue: (row) => row.variant }, + { header: "Encode Q", getValue: (row) => row.encodeQueue }, + { header: "Decode Q", getValue: (row) => row.decodeQueue }, + { header: "Pending", getValue: (row) => row.pendingFrames }, + { header: "Render", getValue: (row) => row.observedRenderBackends }, + { header: "Encode", getValue: (row) => row.observedEncodeBackends }, + { header: "Encoder", getValue: (row) => row.observedEncoders }, + ], + buildBackendDetailTableRows(benchmarkResults), + ); +} + +function buildDeltaTableRows(benchmarkResults) { + return benchmarkResults + .map((result) => { + const baseline = result.summaries.find( + (summary) => summary.variant.name === "baseline", + ); + const tuned = result.summaries.find((summary) => summary.variant.name === "tuned"); + if (!baseline || !tuned) { + return null; + } + + const averageDelta = calculateDelta(baseline.averageElapsedMs, tuned.averageElapsedMs); + const medianDelta = calculateDelta(baseline.medianElapsedMs, tuned.medianElapsedMs); + const exportDelta = calculateDelta( + baseline.averageSmokeElapsedMs, + tuned.averageSmokeElapsedMs, + ); + + return { + pipeline: result.request.pipeline ?? "default", + backend: result.request.backend ?? "default", + averageDeltaMs: averageDelta.deltaMs, + averageDeltaPercent: averageDelta.deltaPercent, + medianDeltaMs: medianDelta.deltaMs, + medianDeltaPercent: medianDelta.deltaPercent, + exportDeltaMs: exportDelta.deltaMs, + exportDeltaPercent: exportDelta.deltaPercent, + }; + }) + .filter(Boolean); +} + +function printDeltaTable(benchmarkResults) { + printTable( + "Tuned vs baseline", + [ + { header: "Pipeline", getValue: (row) => row.pipeline }, + { header: "Backend", getValue: (row) => row.backend }, + { + header: "Avg delta", + getValue: (row) => + `${formatDeltaMs(row.averageDeltaMs)} (${formatPercent(row.averageDeltaPercent)})`, + }, + { + header: "Median delta", + getValue: (row) => + `${formatDeltaMs(row.medianDeltaMs)} (${formatPercent(row.medianDeltaPercent)})`, + }, + { + header: "Export delta", + getValue: (row) => + `${formatDeltaMs(row.exportDeltaMs)} (${formatPercent(row.exportDeltaPercent)})`, + }, + ], + buildDeltaTableRows(benchmarkResults), + ); +} + +async function runVariant( + ffmpegPath, + inputPath, + webcamInputPath, + benchmarkRequest, + variant, + runIndex, +) { + const outputPath = path.join( + path.dirname(inputPath), + `${benchmarkRequest.slug}-${variant.name}-${runIndex + 1}-${Date.now()}.mp4`, + ); + const startedAt = performance.now(); + const runLabel = `${benchmarkRequest.label}/${variant.name}#${runIndex + 1}`; + const child = spawn(electron, [repoRoot], { + cwd: repoRoot, + env: { + ...process.env, + RECORDLY_SMOKE_EXPORT: "1", + RECORDLY_SMOKE_EXPORT_INPUT: inputPath, + RECORDLY_SMOKE_EXPORT_OUTPUT: outputPath, + ...(useNativeExport ? { RECORDLY_SMOKE_EXPORT_USE_NATIVE: "1" } : {}), + ...(exportEncodingMode + ? { RECORDLY_SMOKE_EXPORT_ENCODING_MODE: exportEncodingMode } + : {}), + ...(exportQuality ? { RECORDLY_SMOKE_EXPORT_QUALITY: exportQuality } : {}), + ...(exportShadowIntensity !== null + ? { RECORDLY_SMOKE_EXPORT_SHADOW_INTENSITY: String(exportShadowIntensity) } + : {}), + ...(webcamInputPath ? { RECORDLY_SMOKE_EXPORT_WEBCAM_INPUT: webcamInputPath } : {}), + ...(webcamShadowIntensity !== null + ? { RECORDLY_SMOKE_EXPORT_WEBCAM_SHADOW: String(webcamShadowIntensity) } + : {}), + ...(webcamSize !== null + ? { RECORDLY_SMOKE_EXPORT_WEBCAM_SIZE: String(webcamSize) } + : {}), + ...(benchmarkRequest.pipeline + ? { RECORDLY_SMOKE_EXPORT_PIPELINE: benchmarkRequest.pipeline } + : {}), + ...(benchmarkRequest.backend + ? { RECORDLY_SMOKE_EXPORT_BACKEND: benchmarkRequest.backend } + : {}), + ...(exportRenderBackend + ? { RECORDLY_SMOKE_EXPORT_RENDER_BACKEND: exportRenderBackend } + : {}), + ...(typeof variant.maxEncodeQueue === "number" + ? { RECORDLY_SMOKE_EXPORT_MAX_ENCODE_QUEUE: String(variant.maxEncodeQueue) } + : {}), + ...(typeof variant.maxDecodeQueue === "number" + ? { RECORDLY_SMOKE_EXPORT_MAX_DECODE_QUEUE: String(variant.maxDecodeQueue) } + : {}), + ...(typeof variant.maxPendingFrames === "number" + ? { RECORDLY_SMOKE_EXPORT_MAX_PENDING_FRAMES: String(variant.maxPendingFrames) } + : {}), + }, + stdio: ["ignore", "pipe", "pipe"], + }); + + let combinedOutput = ""; + child.stdout.on("data", (chunk) => { + const text = chunk.toString(); + combinedOutput += text; + process.stdout.write(`[${runLabel}] ${text}`); + }); + child.stderr.on("data", (chunk) => { + const text = chunk.toString(); + combinedOutput += text; + process.stderr.write(`[${runLabel}] ${text}`); + }); + + const timeout = setTimeout(() => { + child.kill("SIGKILL"); + }, timeoutMs); + + const [exitCode, signal] = await once(child, "close"); + clearTimeout(timeout); + + if (exitCode !== 0) { + const signalText = signal ? ` (signal ${signal})` : ""; + throw new Error( + `${variant.name} run ${runIndex + 1} failed with code ${exitCode ?? "unknown"}${signalText}\n${combinedOutput.trim()}`, + ); + } + + const smokeExportReport = await readSmokeExportReport(outputPath); + let outputStats; + try { + outputStats = await fs.stat(outputPath); + } catch (error) { + const reportSuffix = smokeExportReport + ? `\n${JSON.stringify(smokeExportReport.report)}` + : ""; + throw new Error( + `${variant.name} run ${runIndex + 1} did not produce an output file: ${error instanceof Error ? error.message : String(error)}${reportSuffix}`, + ); + } + if (outputStats.size <= 0) { + const reportSuffix = smokeExportReport + ? `\n${JSON.stringify(smokeExportReport.report)}` + : ""; + throw new Error( + `${variant.name} run ${runIndex + 1} produced an empty output file${reportSuffix}`, + ); + } + + const elapsedMs = Math.round(performance.now() - startedAt); + const outputDuration = await inspectOutput(ffmpegPath, outputPath); + + return { + elapsedMs, + outputPath, + sizeBytes: outputStats.size, + outputDuration, + webcamEnabled: !!webcamInputPath, + smokeExportReport: smokeExportReport?.report ?? null, + smokeProgressSummary: summarizeSmokeProgress(smokeExportReport?.report?.progressSamples), + }; +} + +async function runBenchmarkRequest(ffmpegPath, inputPath, webcamInputPath, benchmarkRequest) { + const summaries = []; + for (const variant of variants) { + const runs = []; + for (let index = 0; index < runsPerVariant; index += 1) { + console.log( + `[benchmark-export-queues] Running ${benchmarkRequest.label}/${variant.name} (${index + 1}/${runsPerVariant}) with encode=${variant.maxEncodeQueue ?? "auto"} decode=${variant.maxDecodeQueue ?? "auto"} pending=${variant.maxPendingFrames ?? "auto"}`, + ); + runs.push( + await runVariant( + ffmpegPath, + inputPath, + webcamInputPath, + benchmarkRequest, + variant, + index, + ), + ); + } + + const runSummary = summarizeVariantRuns(runs); + summaries.push({ + variant, + runs, + ...runSummary, + webcamEnabled: useWebcamOverlay, + }); + } + + return { + request: benchmarkRequest, + summaries, + }; +} + +function average(values) { + return values.reduce((sum, value) => sum + value, 0) / values.length; +} + +function median(values) { + if (values.length === 0) { + return 0; + } + + const sorted = [...values].sort((left, right) => left - right); + const middleIndex = Math.floor(sorted.length / 2); + if (sorted.length % 2 === 0) { + return (sorted[middleIndex - 1] + sorted[middleIndex]) / 2; + } + + return sorted[middleIndex]; +} + +function summarizeVariantRuns(runs) { + const elapsedValues = runs.map((run) => run.elapsedMs); + const sizeValues = runs.map((run) => run.sizeBytes); + const outputDurationValues = runs + .map((run) => run.outputDuration) + .filter((value) => typeof value === "number" && Number.isFinite(value)); + const smokeElapsedValues = runs + .map((run) => run.smokeExportReport?.elapsedMs) + .filter((value) => typeof value === "number" && Number.isFinite(value)); + + return { + averageElapsedMs: Math.round(average(elapsedValues)), + medianElapsedMs: Math.round(median(elapsedValues)), + minElapsedMs: Math.min(...elapsedValues), + maxElapsedMs: Math.max(...elapsedValues), + averageSizeBytes: Math.round(average(sizeValues)), + averageOutputDurationSeconds: + outputDurationValues.length > 0 ? average(outputDurationValues) : null, + averageSmokeElapsedMs: + smokeElapsedValues.length > 0 ? Math.round(average(smokeElapsedValues)) : null, + observedRenderBackends: collectUniqueStrings( + runs.map((run) => run.smokeExportReport?.metrics?.renderBackend), + ), + observedEncodeBackends: collectUniqueStrings( + runs.map((run) => run.smokeExportReport?.metrics?.encodeBackend), + ), + observedEncoders: collectUniqueStrings( + runs.map((run) => run.smokeExportReport?.metrics?.encoderName), + ), + }; +} + +async function main() { + if (typeof ffmpegStatic !== "string" || ffmpegStatic.length === 0) { + throw new Error("ffmpeg-static is unavailable for this platform"); + } + + if (typeof electron !== "string" || electron.length === 0) { + throw new Error("The Electron binary is unavailable in this workspace"); + } + + await ensureBuildArtifacts(); + const benchmarkRequests = buildBenchmarkRequests(); + + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "recordly-export-queue-bench-")); + const inputExtension = providedInputPath ? path.extname(providedInputPath) || ".mp4" : ".mp4"; + const inputPath = path.join(tempDir, `input${inputExtension}`); + const webcamExtension = providedWebcamInputPath + ? path.extname(providedWebcamInputPath) || ".mp4" + : ".mp4"; + const webcamInputPath = useWebcamOverlay + ? path.join(tempDir, `webcam${webcamExtension}`) + : null; + + try { + console.log("[benchmark-export-queues] Config"); + console.log( + JSON.stringify({ + width, + height, + frameRate, + durationSeconds, + timeoutMs, + runsPerVariant, + requestedPipeline: exportPipeline, + requestedBackend: exportBackend, + requestedBackends: benchmarkRequests.map((request) => request.label), + requestedRenderBackend: exportRenderBackend, + backendSweepEnabled: benchmarkRequests.length > 1, + requestedEncodingMode: exportEncodingMode, + requestedQuality: exportQuality, + requestedShadowIntensity: exportShadowIntensity, + webcamEnabled: useWebcamOverlay, + providedInput: providedInputPath, + providedWebcamInput: providedWebcamInputPath, + keepTempArtifacts, + requestedWebcamShadowIntensity: webcamShadowIntensity, + requestedWebcamSize: webcamSize, + }), + ); + printRequestedConfigTable(benchmarkRequests); + + if (providedInputPath) { + console.log( + `[benchmark-export-queues] Using provided input video: ${providedInputPath}`, + ); + await fs.copyFile(providedInputPath, inputPath); + } else { + console.log(`[benchmark-export-queues] Generating fixture video: ${inputPath}`); + await createFixtureVideo(ffmpegStatic, inputPath); + } + if (webcamInputPath) { + if (providedWebcamInputPath) { + console.log( + `[benchmark-export-queues] Using provided webcam video: ${providedWebcamInputPath}`, + ); + await fs.copyFile(providedWebcamInputPath, webcamInputPath); + } else { + console.log( + `[benchmark-export-queues] Generating webcam fixture video: ${webcamInputPath}`, + ); + await createFixtureVideo(ffmpegStatic, webcamInputPath, { + fixtureWidth: webcamWidth, + fixtureHeight: webcamHeight, + includeAudio: false, + videoFilter: `testsrc=size=${webcamWidth}x${webcamHeight}:rate=${frameRate}`, + }); + } + } + + const benchmarkResults = []; + for (const benchmarkRequest of benchmarkRequests) { + benchmarkResults.push( + await runBenchmarkRequest( + ffmpegStatic, + inputPath, + webcamInputPath, + benchmarkRequest, + ), + ); + } + + console.log("[benchmark-export-queues] Summary"); + for (const result of benchmarkResults) { + for (const summary of result.summaries) { + console.log( + JSON.stringify({ + requestedPipeline: result.request.pipeline, + requestedBackend: result.request.backend, + name: summary.variant.name, + webcamEnabled: useWebcamOverlay, + webcamShadowIntensity, + webcamSize, + maxEncodeQueue: summary.variant.maxEncodeQueue, + maxDecodeQueue: summary.variant.maxDecodeQueue, + maxPendingFrames: summary.variant.maxPendingFrames, + averageElapsedMs: summary.averageElapsedMs, + medianElapsedMs: summary.medianElapsedMs, + minElapsedMs: summary.minElapsedMs, + maxElapsedMs: summary.maxElapsedMs, + averageSizeBytes: summary.averageSizeBytes, + averageOutputDurationSeconds: summary.averageOutputDurationSeconds, + averageSmokeElapsedMs: summary.averageSmokeElapsedMs, + observedRenderBackends: summary.observedRenderBackends, + observedEncodeBackends: summary.observedEncodeBackends, + observedEncoders: summary.observedEncoders, + runs: summary.runs.map((run) => ({ + elapsedMs: run.elapsedMs, + sizeBytes: run.sizeBytes, + outputDuration: run.outputDuration, + smokeExportReport: run.smokeExportReport, + smokeProgressSummary: run.smokeProgressSummary, + })), + }), + ); + } + } + printTimingSummaryTable(benchmarkResults); + printBackendDetailTable(benchmarkResults); + printDeltaTable(benchmarkResults); + + for (const result of benchmarkResults) { + if (result.summaries.length < 2) { + continue; + } + + const baseline = result.summaries[0]; + const tuned = result.summaries[1]; + const { deltaMs, deltaPercent: percent } = calculateDelta( + baseline.averageElapsedMs, + tuned.averageElapsedMs, + ); + const { deltaMs: medianDeltaMs, deltaPercent: medianPercent } = calculateDelta( + baseline.medianElapsedMs, + tuned.medianElapsedMs, + ); + const backendLabel = result.request.backend ?? "default"; + console.log( + `[benchmark-export-queues] ${backendLabel} tuned vs baseline: ${deltaMs}ms (${typeof percent === "number" ? percent.toFixed(1) : "-"}%)`, + ); + console.log( + `[benchmark-export-queues] ${backendLabel} tuned vs baseline (median): ${medianDeltaMs}ms (${typeof medianPercent === "number" ? medianPercent.toFixed(1) : "-"}%)`, + ); + } + } finally { + if (keepTempArtifacts) { + console.log(`[benchmark-export-queues] Preserved temp artifacts: ${tempDir}`); + } else { + await fs.rm(tempDir, { recursive: true, force: true }); + } + } +} + +main().catch((error) => { + console.error( + `[benchmark-export-queues] ${error instanceof Error ? error.message : String(error)}`, + ); + process.exitCode = 1; +}); diff --git a/scripts/build-cursor-monitor.mjs b/scripts/build-cursor-monitor.mjs new file mode 100644 index 000000000..37fea5a4e --- /dev/null +++ b/scripts/build-cursor-monitor.mjs @@ -0,0 +1,168 @@ +import { execSync } from "node:child_process"; +import { copyFileSync, existsSync, mkdirSync, rmSync } from "node:fs"; +import path from "node:path"; + +import { + formatNativeHelperManifestWarning, + updateNativeHelperManifest, + verifyNativeHelperManifest, +} from "./native-helper-manifest.mjs"; +import { + configureWithWindowsCmakeGenerator, + WINDOWS_VISUAL_STUDIO_INSTALL_DIRS, +} from "./windows-cmake-generators.mjs"; + +const projectRoot = process.cwd(); +const sourceDir = path.join(projectRoot, "electron", "native", "cursor-monitor"); +const buildDir = path.join(sourceDir, "build"); +const bundledDir = path.join( + projectRoot, + "electron", + "native", + "bin", + process.arch === "arm64" ? "win32-arm64" : "win32-x64", +); +const bundledExePath = path.join(bundledDir, "cursor-monitor.exe"); +const helperId = "cursor-monitor"; + +if (process.platform !== "win32") { + console.log("[build-cursor-monitor] Skipping: host platform is not Windows."); + process.exit(0); +} + +if (!existsSync(path.join(sourceDir, "CMakeLists.txt"))) { + console.error("[build-cursor-monitor] CMakeLists.txt not found at", sourceDir); + process.exit(1); +} + +function findCmake() { + // Check PATH first + try { + execSync("cmake --version", { stdio: "pipe" }); + return "cmake"; + } catch { + // not on PATH + } + + const standaloneCmakePaths = [ + path.join("C:", "Program Files", "CMake", "bin", "cmake.exe"), + path.join("C:", "Program Files (x86)", "CMake", "bin", "cmake.exe"), + ]; + for (const cmakePath of standaloneCmakePaths) { + if (existsSync(cmakePath)) { + return `"${cmakePath}"`; + } + } + + // VS 2022 bundled CMake + const vsRoots = [ + path.join("C:", "Program Files", "Microsoft Visual Studio"), + path.join("C:", "Program Files (x86)", "Microsoft Visual Studio"), + ]; + const vsEditions = ["Community", "Professional", "Enterprise", "BuildTools"]; + const vsVersions = WINDOWS_VISUAL_STUDIO_INSTALL_DIRS; + for (const root of vsRoots) { + for (const version of vsVersions) { + for (const edition of vsEditions) { + const cmakePath = path.join( + root, + version, + edition, + "Common7", + "IDE", + "CommonExtensions", + "Microsoft", + "CMake", + "CMake", + "bin", + "cmake.exe", + ); + if (existsSync(cmakePath)) { + return `"${cmakePath}"`; + } + } + } + } + + return null; +} + +const cmake = findCmake(); +if (!cmake) { + if (existsSync(bundledExePath)) { + const verification = verifyNativeHelperManifest({ + projectRoot, + helperId, + sourceDir, + binaryPath: bundledExePath, + binaryName: "cursor-monitor.exe", + }); + if (!verification.ok) { + console.warn(formatNativeHelperManifestWarning("build-cursor-monitor", verification)); + } + console.log(`[build-cursor-monitor] Using bundled helper: ${bundledExePath}`); + process.exit(0); + } + + console.error( + "[build-cursor-monitor] CMake not found. Install Visual Studio with C++ CMake tools or standalone CMake.", + ); + process.exit(1); +} + +mkdirSync(buildDir, { recursive: true }); +const cacheFile = path.join(buildDir, "CMakeCache.txt"); +const cacheDir = path.join(buildDir, "CMakeFiles"); + +function clearCmakeCache() { + rmSync(cacheFile, { force: true }); + rmSync(cacheDir, { recursive: true, force: true }); +} + +console.log("[build-cursor-monitor] Configuring CMake..."); +try { + configureWithWindowsCmakeGenerator({ + prefix: "build-cursor-monitor", + clearCache: clearCmakeCache, + configure: (generator, toolset) => + execSync(`${cmake} .. -G "${generator}" -A x64${toolset ? ` -T ${toolset}` : ""}`, { + cwd: buildDir, + stdio: "inherit", + timeout: 120000, + }), + }); +} catch (error) { + console.error("[build-cursor-monitor] CMake configure failed:", error.message); + process.exit(1); +} + +console.log("[build-cursor-monitor] Building..."); +try { + execSync(`${cmake} --build . --config Release`, { + cwd: buildDir, + stdio: "inherit", + timeout: 300000, + }); +} catch (error) { + console.error("[build-cursor-monitor] Build failed:", error.message); + process.exit(1); +} + +const exePath = path.join(buildDir, "Release", "cursor-monitor.exe"); +if (existsSync(exePath)) { + console.log(`[build-cursor-monitor] Built successfully: ${exePath}`); + mkdirSync(bundledDir, { recursive: true }); + copyFileSync(exePath, bundledExePath); + console.log(`[build-cursor-monitor] Staged bundled helper: ${bundledExePath}`); + const manifestPath = updateNativeHelperManifest({ + projectRoot, + helperId, + sourceDir, + binaryPath: bundledExePath, + binaryName: "cursor-monitor.exe", + }); + console.log(`[build-cursor-monitor] Updated helper manifest: ${manifestPath}`); +} else { + console.error("[build-cursor-monitor] Expected exe not found at", exePath); + process.exit(1); +} diff --git a/scripts/build-native-helpers.mjs b/scripts/build-native-helpers.mjs new file mode 100644 index 000000000..778b19b87 --- /dev/null +++ b/scripts/build-native-helpers.mjs @@ -0,0 +1,78 @@ +import { spawnSync } from "node:child_process"; +import { chmod, mkdir } from "node:fs/promises"; +import path from "node:path"; + +const projectRoot = process.cwd(); +const nativeRoot = path.join(projectRoot, "electron", "native"); + +if (process.platform !== "darwin") { + console.log("[build-native-helpers] Skipping: host platform is not macOS."); + process.exit(0); +} + +function getTargetConfigs() { + return [ + { + archTag: "darwin-arm64", + swiftTarget: "arm64-apple-macos14.0", + }, + { + archTag: "darwin-x64", + swiftTarget: "x86_64-apple-macos14.0", + }, + ]; +} + +const helpers = [ + { + source: "ScreenCaptureKitRecorder.swift", + output: "recordly-screencapturekit-helper", + }, + { + source: "ScreenCaptureKitWindowList.swift", + output: "recordly-window-list", + }, + { + source: "SystemCursorAssets.swift", + output: "recordly-system-cursors", + }, + { + source: "NativeCursorMonitor.swift", + output: "recordly-native-cursor-monitor", + }, +]; + +const swiftcCheck = spawnSync("swiftc", ["--version"], { encoding: "utf8" }); +if (swiftcCheck.status !== 0) { + const details = [swiftcCheck.stderr, swiftcCheck.stdout].filter(Boolean).join("\n").trim(); + throw new Error(details || "swiftc is unavailable; install Xcode Command Line Tools."); +} + +for (const target of getTargetConfigs()) { + const outputDir = path.join(nativeRoot, "bin", target.archTag); + await mkdir(outputDir, { recursive: true }); + + for (const helper of helpers) { + const sourcePath = path.join(nativeRoot, helper.source); + const outputPath = path.join(outputDir, helper.output); + + const result = spawnSync( + "swiftc", + ["-O", "-target", target.swiftTarget, sourcePath, "-o", outputPath], + { + encoding: "utf8", + timeout: 120000, + }, + ); + + if (result.status !== 0) { + const details = [result.stderr, result.stdout].filter(Boolean).join("\n").trim(); + throw new Error(details || `Failed to compile ${helper.source} for ${target.archTag}`); + } + + await chmod(outputPath, 0o755); + console.log( + `[build-native-helpers] Built ${helper.output} (${target.archTag}) -> ${outputPath}`, + ); + } +} diff --git a/scripts/build-nvidia-cuda-compositor.mjs b/scripts/build-nvidia-cuda-compositor.mjs new file mode 100644 index 000000000..f1447c4b7 --- /dev/null +++ b/scripts/build-nvidia-cuda-compositor.mjs @@ -0,0 +1,292 @@ +import { execSync } from "node:child_process"; +import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import path from "node:path"; + +import { + formatNativeHelperManifestWarning, + updateNativeHelperManifest, + verifyNativeHelperManifest, +} from "./native-helper-manifest.mjs"; +import { + configureWithWindowsCmakeGenerator, + WINDOWS_VISUAL_STUDIO_INSTALL_DIRS, +} from "./windows-cmake-generators.mjs"; + +const projectRoot = process.cwd(); +const sourceDir = path.join(projectRoot, "electron", "native", "nvidia-cuda-compositor"); +const buildDir = path.join(sourceDir, "build"); +const bundledDir = path.join( + projectRoot, + "electron", + "native", + "bin", + process.arch === "arm64" ? "win32-arm64" : "win32-x64", +); +const bundledExePath = path.join(bundledDir, "recordly-nvidia-cuda-compositor.exe"); +const helperId = "recordly-nvidia-cuda-compositor"; +const generatorArch = process.arch === "arm64" ? "ARM64" : "x64"; +const videoCodecSdkRoot = + process.env.RECORDLY_NVIDIA_VIDEO_CODEC_SDK_ROOT?.trim() || + path.join(projectRoot, ".tmp", "video-sdk-samples"); + +if (process.platform !== "win32") { + console.log("[build-nvidia-cuda-compositor] Skipping NVIDIA CUDA compositor build."); + process.exit(0); +} + +if (!existsSync(path.join(sourceDir, "CMakeLists.txt"))) { + console.error("[build-nvidia-cuda-compositor] CMakeLists.txt not found at", sourceDir); + process.exit(1); +} + +function fallbackToBundledHelperOrExit(reason) { + if (existsSync(bundledExePath)) { + const verification = verifyNativeHelperManifest({ + projectRoot, + helperId, + sourceDir, + binaryPath: bundledExePath, + binaryName: "recordly-nvidia-cuda-compositor.exe", + }); + if (!verification.ok) { + console.warn( + formatNativeHelperManifestWarning("build-nvidia-cuda-compositor", verification), + ); + } + console.log(`[build-nvidia-cuda-compositor] ${reason}`); + console.log(`[build-nvidia-cuda-compositor] Using bundled helper: ${bundledExePath}`); + process.exit(0); + } + + console.error(`[build-nvidia-cuda-compositor] ${reason}`); + console.error( + "[build-nvidia-cuda-compositor] No bundled helper is available; install CUDA Toolkit + NVIDIA Video Codec SDK or provide a staged helper.", + ); + process.exit(1); +} + +function findCmake() { + try { + execSync("cmake --version", { stdio: "pipe" }); + return "cmake"; + } catch { + // Continue probing common Windows install locations. + } + + const standaloneCmakePaths = [ + path.join("C:", "Program Files", "CMake", "bin", "cmake.exe"), + path.join("C:", "Program Files (x86)", "CMake", "bin", "cmake.exe"), + ]; + for (const cmakePath of standaloneCmakePaths) { + if (existsSync(cmakePath)) { + return `"${cmakePath}"`; + } + } + + const vsRoots = [ + path.join("C:", "Program Files", "Microsoft Visual Studio"), + path.join("C:", "Program Files (x86)", "Microsoft Visual Studio"), + ]; + const vsEditions = ["Preview", "Community", "Professional", "Enterprise", "BuildTools"]; + const vsVersions = WINDOWS_VISUAL_STUDIO_INSTALL_DIRS; + for (const root of vsRoots) { + for (const version of vsVersions) { + for (const edition of vsEditions) { + const cmakePath = path.join( + root, + version, + edition, + "Common7", + "IDE", + "CommonExtensions", + "Microsoft", + "CMake", + "CMake", + "bin", + "cmake.exe", + ); + if (existsSync(cmakePath)) { + return `"${cmakePath}"`; + } + } + } + } + + return null; +} + +if (!existsSync(path.join(videoCodecSdkRoot, "Samples", "NvCodec"))) { + fallbackToBundledHelperOrExit( + `NVIDIA Video Codec SDK samples not found at ${videoCodecSdkRoot}. Set RECORDLY_NVIDIA_VIDEO_CODEC_SDK_ROOT to build from source.`, + ); +} + +function replaceOrThrow(filePath, content, pattern, replacement, label) { + const updated = content.replace(pattern, replacement); + if (updated === content) { + throw new Error(`Unable to patch ${label} in ${filePath}`); + } + return updated; +} + +function patchNvDecoderForRecordlyCallbacks() { + const nvDecoderDir = path.join(videoCodecSdkRoot, "Samples", "NvCodec", "NvDecoder"); + const headerPath = path.join(nvDecoderDir, "NvDecoder.h"); + const sourcePath = path.join(nvDecoderDir, "NvDecoder.cpp"); + + let header = readFileSync(headerPath, "utf8"); + if (!header.includes("RecordlyMappedFrameHandler")) { + header = replaceOrThrow( + headerPath, + header, + /#include "nvcuvid\.h"\r?\n/, + `#include "nvcuvid.h" + +using RecordlyMappedFrameHandler = void (*)(CUdeviceptr, unsigned int, int, int, int, int64_t, void*); +using RecordlyDisplayFramePolicy = bool (*)(int, void*); +`, + "NvDecoder callback aliases", + ); + header = replaceOrThrow( + headerPath, + header, + / {4}int setReconfigParams\(const Rect \* pCropRect, const Dim \* pResizeDim\);\r?\n/, + ` int setReconfigParams(const Rect * pCropRect, const Dim * pResizeDim); + void SetMappedFrameHandler(RecordlyMappedFrameHandler handler, void* userData) { m_recordlyMappedFrameHandler = handler; m_recordlyMappedFrameUserData = userData; } + void SetDisplayFramePolicy(RecordlyDisplayFramePolicy policy, void* userData) { m_recordlyDisplayFramePolicy = policy; m_recordlyDisplayFramePolicyUserData = userData; } + int GetDisplayFrameCount() const { return m_nDisplayFrameCount; } +`, + "NvDecoder public callback methods", + ); + header = replaceOrThrow( + headerPath, + header, + / {4}int m_nDecodedFrame = 0, m_nDecodedFrameReturned = 0;\r?\n/, + ` int m_nDecodedFrame = 0, m_nDecodedFrameReturned = 0; + int m_nDisplayFrameCount = 0; + RecordlyMappedFrameHandler m_recordlyMappedFrameHandler = nullptr; + void* m_recordlyMappedFrameUserData = nullptr; + RecordlyDisplayFramePolicy m_recordlyDisplayFramePolicy = nullptr; + void* m_recordlyDisplayFramePolicyUserData = nullptr; +`, + "NvDecoder callback state", + ); + writeFileSync(headerPath, header); + } + + let source = readFileSync(sourcePath, "utf8"); + if (!source.includes("Recordly mapped frame callback")) { + source = replaceOrThrow( + sourcePath, + source, + / {4}if \(result == CUDA_SUCCESS && \(DecodeStatus\.decodeStatus == cuvidDecodeStatus_Error \|\| DecodeStatus\.decodeStatus == cuvidDecodeStatus_Error_Concealed\)\)\r?\n {4}\{\r?\n {8}printf\("Decode Error occurred for picture %d\\n", m_nPicNumInDecodeOrder\[pDispInfo->picture_index\]\);\r?\n {4}\}\r?\n {4}uint8_t \*pDecodedFrame = nullptr;\r?\n/, + ` if (result == CUDA_SUCCESS && (DecodeStatus.decodeStatus == cuvidDecodeStatus_Error || DecodeStatus.decodeStatus == cuvidDecodeStatus_Error_Concealed)) + { + printf("Decode Error occurred for picture %d\\n", m_nPicNumInDecodeOrder[pDispInfo->picture_index]); + } + + const int displayFrameIndex = m_nDisplayFrameCount++; + if (m_recordlyDisplayFramePolicy && + !m_recordlyDisplayFramePolicy(displayFrameIndex, m_recordlyDisplayFramePolicyUserData)) + { + NVDEC_API_CALL(cuvidUnmapVideoFrame(m_hDecoder, dpSrcFrame)); + return 1; + } + + // Recordly mapped frame callback keeps the CUDA helper from making an + // extra device-to-device copy when the caller can consume mapped NV12. + if (m_recordlyMappedFrameHandler) + { + m_recordlyMappedFrameHandler( + dpSrcFrame, + nSrcPitch, + m_nWidth, + m_nHeight, + m_nSurfaceHeight, + pDispInfo->timestamp, + m_recordlyMappedFrameUserData); + NVDEC_API_CALL(cuvidUnmapVideoFrame(m_hDecoder, dpSrcFrame)); + return 1; + } + + uint8_t *pDecodedFrame = nullptr; +`, + "NvDecoder mapped frame callback hook", + ); + writeFileSync(sourcePath, source); + } +} + +try { + patchNvDecoderForRecordlyCallbacks(); +} catch (error) { + fallbackToBundledHelperOrExit( + `Failed to patch NVIDIA Video Codec SDK samples: ${error instanceof Error ? error.message : String(error)}`, + ); +} + +const cmake = findCmake(); +if (!cmake) { + fallbackToBundledHelperOrExit( + "CMake not found. Install Visual Studio with C++ CMake tools or standalone CMake.", + ); +} + +mkdirSync(buildDir, { recursive: true }); + +function clearCmakeCache() { + rmSync(path.join(buildDir, "CMakeCache.txt"), { force: true }); + rmSync(path.join(buildDir, "CMakeFiles"), { recursive: true, force: true }); +} + +console.log("[build-nvidia-cuda-compositor] Configuring CMake..."); +try { + configureWithWindowsCmakeGenerator({ + prefix: "build-nvidia-cuda-compositor", + clearCache: clearCmakeCache, + configure: (generator, toolset) => + execSync( + `${cmake} .. -G "${generator}" -A ${generatorArch}${toolset ? ` -T ${toolset}` : ""} -DRECORDLY_NVIDIA_VIDEO_CODEC_SDK_ROOT="${videoCodecSdkRoot}"`, + { + cwd: buildDir, + stdio: "inherit", + timeout: 120000, + }, + ), + }); +} catch (error) { + fallbackToBundledHelperOrExit( + `CMake configure failed: ${error instanceof Error ? error.message : String(error)}`, + ); +} + +console.log("[build-nvidia-cuda-compositor] Building NVIDIA CUDA compositor..."); +try { + execSync(`${cmake} --build . --config Release`, { + cwd: buildDir, + stdio: "inherit", + timeout: 300000, + }); +} catch (error) { + fallbackToBundledHelperOrExit( + `Build failed: ${error instanceof Error ? error.message : String(error)}`, + ); +} + +const exePath = path.join(buildDir, "Release", "recordly-nvidia-cuda-compositor.exe"); +if (!existsSync(exePath)) { + console.error("[build-nvidia-cuda-compositor] Expected exe not found at", exePath); + process.exit(1); +} + +mkdirSync(bundledDir, { recursive: true }); +copyFileSync(exePath, bundledExePath); +console.log(`[build-nvidia-cuda-compositor] Staged bundled helper: ${bundledExePath}`); +const manifestPath = updateNativeHelperManifest({ + projectRoot, + helperId, + sourceDir, + binaryPath: bundledExePath, + binaryName: "recordly-nvidia-cuda-compositor.exe", +}); +console.log(`[build-nvidia-cuda-compositor] Updated helper manifest: ${manifestPath}`); diff --git a/scripts/build-whisper-runtime.mjs b/scripts/build-whisper-runtime.mjs new file mode 100644 index 000000000..f1f76f7ab --- /dev/null +++ b/scripts/build-whisper-runtime.mjs @@ -0,0 +1,562 @@ +import { execFileSync, execSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { createWriteStream, existsSync, rmSync } from "node:fs"; +import { chmod, cp, mkdir, readdir, readFile, rm, stat, writeFile } from "node:fs/promises"; +import { get as httpsGet } from "node:https"; +import path from "node:path"; + +import { + configureWithWindowsCmakeGenerator, + WINDOWS_VISUAL_STUDIO_INSTALL_DIRS, +} from "./windows-cmake-generators.mjs"; + +const projectRoot = process.cwd(); +const whisperVersion = "v1.8.4"; +const nativeRoot = path.join(projectRoot, "electron", "native"); +const cacheRoot = path.join(projectRoot, ".tmp", "whisper-runtime"); +const archivePath = path.join(cacheRoot, `${whisperVersion}.tar.gz`); +const extractRoot = path.join(cacheRoot, `src-${whisperVersion}`); +const windowsX64ArchivePath = path.join(cacheRoot, `${whisperVersion}-windows-x64.zip`); +const windowsX64ArchiveSha256 = "74f973345cb52ef5ba3ec9e7e7af8e48cc8c71722d1528603b80588a11f82e3e"; + +function getHostArch() { + return process.arch === "arm64" ? "arm64" : "x64"; +} + +function getNativeArchTag(platform, arch) { + if (platform === "darwin") { + return arch === "arm64" ? "darwin-arm64" : "darwin-x64"; + } + + if (platform === "win32") { + return arch === "arm64" ? "win32-arm64" : "win32-x64"; + } + + if (platform === "linux") { + return arch === "arm64" ? "linux-arm64" : "linux-x64"; + } + + throw new Error(`[build-whisper-runtime] Unsupported platform: ${platform}/${arch}`); +} + +function getRequestedArchitectures(platform) { + const hostArch = getHostArch(); + const configured = process.env.WHISPER_RUNTIME_ARCHS?.trim(); + + if (!configured) { + return [hostArch]; + } + + if (configured === "all") { + return ["arm64", "x64"]; + } + + const supported = new Set(["arm64", "x64"]); + const requested = configured + .split(",") + .map((entry) => entry.trim()) + .filter(Boolean); + + if (requested.length === 0) { + return [hostArch]; + } + + const invalid = requested.filter((arch) => !supported.has(arch)); + if (invalid.length > 0) { + throw new Error( + `[build-whisper-runtime] Unsupported ${platform} target architecture request: ${invalid.join(", ")}`, + ); + } + + return [...new Set(requested)]; +} + +function createDarwinTarget(arch) { + const targetArch = arch === "arm64" ? "arm64" : "x64"; + const isCrossCompile = targetArch !== getHostArch(); + const configureArgs = [ + "-DCMAKE_BUILD_TYPE=Release", + `-DCMAKE_OSX_ARCHITECTURES=${targetArch === "arm64" ? "arm64" : "x86_64"}`, + ]; + + if (isCrossCompile) { + configureArgs.push("-DGGML_NATIVE=OFF"); + } + + return { + platform: "darwin", + arch: targetArch, + archTag: getNativeArchTag("darwin", targetArch), + buildRoot: path.join(cacheRoot, `build-darwin-${targetArch}`), + outputDir: path.join(nativeRoot, "bin", getNativeArchTag("darwin", targetArch)), + configureArgs, + }; +} + +function getTargetConfigs() { + if (process.platform === "darwin") { + return getRequestedArchitectures("darwin").map((arch) => createDarwinTarget(arch)); + } + + const arch = getHostArch(); + const archTag = getNativeArchTag(process.platform, arch); + + if (process.platform === "win32") { + return [ + { + platform: "win32", + arch, + archTag, + buildRoot: path.join(cacheRoot, `build-${archTag}`), + outputDir: path.join(nativeRoot, "bin", archTag), + configureArgs: ["-A", arch === "arm64" ? "ARM64" : "x64"], + }, + ]; + } + + if (process.platform === "linux") { + return [ + { + platform: "linux", + arch, + archTag, + buildRoot: path.join(cacheRoot, `build-${archTag}`), + outputDir: path.join(nativeRoot, "bin", archTag), + configureArgs: ["-DCMAKE_BUILD_TYPE=Release"], + }, + ]; + } + + throw new Error( + `[build-whisper-runtime] Unsupported platform: ${process.platform}/${process.arch}`, + ); +} + +function getSourceArchiveUrl() { + return `https://github.com/ggml-org/whisper.cpp/archive/refs/tags/${whisperVersion}.tar.gz`; +} + +function findCmake() { + try { + execSync("cmake --version", { stdio: "pipe" }); + return "cmake"; + } catch { + // not on PATH + } + + if (process.platform === "win32") { + const vsEditions = ["Community", "Professional", "Enterprise", "BuildTools"]; + for (const version of WINDOWS_VISUAL_STUDIO_INSTALL_DIRS) { + for (const edition of vsEditions) { + const cmakePath = path.join( + "C:", + "Program Files", + "Microsoft Visual Studio", + version, + edition, + "Common7", + "IDE", + "CommonExtensions", + "Microsoft", + "CMake", + "CMake", + "bin", + "cmake.exe", + ); + if (existsSync(cmakePath)) { + return cmakePath; + } + } + } + } + + return null; +} + +function ensureTarAvailable() { + try { + execSync("tar --version", { stdio: "pipe" }); + } catch { + throw new Error("[build-whisper-runtime] tar is required to unpack whisper.cpp sources."); + } +} + +async function downloadFile(url, destinationPath) { + await mkdir(path.dirname(destinationPath), { recursive: true }); + + await new Promise((resolve, reject) => { + const request = (currentUrl, redirectCount = 0) => { + const req = httpsGet(currentUrl, (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( + "[build-whisper-runtime] Too many redirects while downloading whisper.cpp source.", + ), + ); + return; + } + request(new URL(location, currentUrl).toString(), redirectCount + 1); + return; + } + + if (statusCode < 200 || statusCode >= 300) { + response.resume(); + reject( + new Error( + `[build-whisper-runtime] Failed to download whisper.cpp source: HTTP ${statusCode}`, + ), + ); + return; + } + + const fileStream = createWriteStream(destinationPath); + fileStream.on("finish", resolve); + fileStream.on("error", reject); + response.on("error", reject); + response.pipe(fileStream); + }); + + req.on("error", reject); + }; + + request(url); + }); +} + +async function getFileSha256(filePath) { + return createHash("sha256") + .update(await readFile(filePath)) + .digest("hex"); +} + +async function findDirectoryContaining(rootPath, fileName) { + const pending = [rootPath]; + while (pending.length > 0) { + const currentPath = pending.shift(); + const entries = await readdir(currentPath, { withFileTypes: true }); + if (entries.some((entry) => entry.isFile() && entry.name === fileName)) { + return currentPath; + } + for (const entry of entries) { + if (entry.isDirectory()) { + pending.push(path.join(currentPath, entry.name)); + } + } + } + return null; +} + +async function stageWindowsX64PrebuiltRuntime(target) { + if (target.platform !== "win32" || target.arch !== "x64") { + return false; + } + + await mkdir(cacheRoot, { recursive: true }); + const archiveUrl = `https://github.com/ggml-org/whisper.cpp/releases/download/${whisperVersion}/whisper-bin-x64.zip`; + let archiveIsValid = + existsSync(windowsX64ArchivePath) && + (await getFileSha256(windowsX64ArchivePath)) === windowsX64ArchiveSha256; + if (!archiveIsValid) { + await rm(windowsX64ArchivePath, { force: true }); + console.log( + `[build-whisper-runtime] Downloading official whisper.cpp ${whisperVersion} Windows x64 runtime...`, + ); + await downloadFile(archiveUrl, windowsX64ArchivePath); + archiveIsValid = (await getFileSha256(windowsX64ArchivePath)) === windowsX64ArchiveSha256; + } + + if (!archiveIsValid) { + throw new Error( + "[build-whisper-runtime] Windows x64 runtime archive failed its SHA-256 integrity check.", + ); + } + + const prebuiltExtractRoot = path.join(cacheRoot, `prebuilt-${target.archTag}`); + await rm(prebuiltExtractRoot, { recursive: true, force: true }); + await mkdir(prebuiltExtractRoot, { recursive: true }); + execFileSync("tar", ["-xf", windowsX64ArchivePath, "-C", prebuiltExtractRoot], { + stdio: "inherit", + }); + + const runtimeDir = await findDirectoryContaining(prebuiltExtractRoot, "whisper-cli.exe"); + if (!runtimeDir) { + throw new Error( + "[build-whisper-runtime] Official Windows archive did not contain whisper-cli.exe.", + ); + } + + const runtimeEntries = (await readdir(runtimeDir)).filter( + (entry) => /^(whisper|ggml)/i.test(entry) || entry.toLowerCase().endsWith(".dll"), + ); + await stageRuntimeArtifacts(target, runtimeDir, runtimeEntries); + return true; +} + +async function ensureSourceTree() { + const extractedSourceDir = path.join( + extractRoot, + `whisper.cpp-${whisperVersion.replace(/^v/, "")}`, + ); + if (existsSync(path.join(extractedSourceDir, "CMakeLists.txt"))) { + return extractedSourceDir; + } + + await rm(extractRoot, { recursive: true, force: true }); + await mkdir(extractRoot, { recursive: true }); + + if (!existsSync(archivePath)) { + console.log(`[build-whisper-runtime] Downloading whisper.cpp ${whisperVersion} source...`); + await downloadFile(getSourceArchiveUrl(), archivePath); + } + + ensureTarAvailable(); + execFileSync("tar", ["-xzf", archivePath, "-C", extractRoot], { stdio: "inherit" }); + + if (!existsSync(path.join(extractedSourceDir, "CMakeLists.txt"))) { + throw new Error( + `[build-whisper-runtime] Extracted whisper.cpp source not found at ${extractedSourceDir}`, + ); + } + + return extractedSourceDir; +} + +async function shouldSkipBuild(target) { + const manifestPath = path.join(target.outputDir, "whisper-runtime.json"); + if (!existsSync(manifestPath)) { + return false; + } + + try { + const manifest = JSON.parse(await readFile(manifestPath, "utf8")); + const binaryName = target.platform === "win32" ? "whisper-cli.exe" : "whisper-cli"; + const binaryPath = path.join(target.outputDir, binaryName); + return ( + manifest.version === whisperVersion && + manifest.arch === target.arch && + existsSync(binaryPath) + ); + } catch { + return false; + } +} + +function getConfigureArgs(sourceDir, target, generator, toolset) { + const args = [ + "-S", + sourceDir, + "-B", + target.buildRoot, + "-DWHISPER_BUILD_TESTS=OFF", + "-DWHISPER_BUILD_SERVER=OFF", + "-DBUILD_SHARED_LIBS=OFF", + ...(generator ? ["-G", generator] : []), + ...(toolset ? ["-T", toolset] : []), + ...target.configureArgs, + ]; + + return args; +} + +function getBuildArgs(target) { + const args = ["--build", target.buildRoot, "--config", "Release"]; + + if (target.platform !== "win32") { + args.push("--parallel"); + } + + return args; +} + +async function findRuntimeArtifacts(target) { + const candidateDirs = + target.platform === "win32" + ? [path.join(target.buildRoot, "bin", "Release"), path.join(target.buildRoot, "bin")] + : [path.join(target.buildRoot, "bin")]; + + for (const candidateDir of candidateDirs) { + if (!existsSync(candidateDir)) { + continue; + } + + const entries = await readdir(candidateDir); + const runtimeEntries = entries.filter((entry) => + /^(whisper|ggml|libwhisper|libggml)/i.test(entry), + ); + if (runtimeEntries.length > 0) { + return { + candidateDir, + runtimeEntries, + }; + } + } + + throw new Error("[build-whisper-runtime] Built whisper runtime artifacts were not found."); +} + +async function stageRuntimeArtifacts(target, candidateDir, runtimeEntries) { + await mkdir(target.outputDir, { recursive: true }); + + for (const entry of runtimeEntries) { + const sourcePath = path.join(candidateDir, entry); + const destinationPath = path.join(target.outputDir, entry); + const entryStats = await stat(sourcePath); + + if (entryStats.isDirectory()) { + await rm(destinationPath, { recursive: true, force: true }); + await cp(sourcePath, destinationPath, { recursive: true }); + continue; + } + + await cp(sourcePath, destinationPath, { force: true }); + if (target.platform !== "win32") { + await chmod(destinationPath, 0o755).catch(() => undefined); + } + } + + const manifestPath = path.join(target.outputDir, "whisper-runtime.json"); + await writeFile( + manifestPath, + JSON.stringify( + { + version: whisperVersion, + platform: target.platform, + arch: target.arch, + binary: target.platform === "win32" ? "whisper-cli.exe" : "whisper-cli", + }, + null, + 2, + ), + "utf8", + ); +} + +async function main() { + const targets = getTargetConfigs(); + + // Official whisper.cpp releases include a signed, portable Windows x64 + // runtime. Prefer it so developers and packaged builds do not require a full + // Visual Studio C++ installation just to enable captions. + for (const target of targets) { + if (!(await shouldSkipBuild(target))) { + try { + await stageWindowsX64PrebuiltRuntime(target); + } catch (error) { + console.warn( + "[build-whisper-runtime] Failed to stage the official Windows runtime; falling back to a source build:", + error, + ); + } + } + } + + const stagedChecks = await Promise.all(targets.map((target) => shouldSkipBuild(target))); + if (stagedChecks.every(Boolean)) { + console.log( + `[build-whisper-runtime] Whisper runtime ${whisperVersion} is staged for ${targets.map((target) => target.archTag).join(", ")}.`, + ); + return; + } + + const cmake = findCmake(); + + if (!cmake) { + // Soft-fail only when this script runs as part of `npm install`/`npm ci`, + // in CI, or when the developer explicitly opted in. Direct invocations + // (e.g. via `npm run build`, `build:win`, `build:mac`, `build:linux`) + // must still fail loudly so we never ship a release build that is + // missing the whisper runtime and silently ships broken auto-captions. + const isPostinstall = process.env.npm_lifecycle_event === "postinstall"; + const isCI = process.env.CI === "true"; + const allowMissing = process.env.WHISPER_RUNTIME_ALLOW_MISSING === "1"; + const softFailAllowed = isPostinstall || isCI || allowMissing; + + const skipChecks = await Promise.all(targets.map((target) => shouldSkipBuild(target))); + const allTargetsStaged = skipChecks.every(Boolean); + + if (allTargetsStaged) { + console.log( + "[build-whisper-runtime] CMake not found; using bundled whisper runtime artifacts.", + ); + return; + } + + const missing = targets + .filter((_target, index) => !skipChecks[index]) + .map((target) => target.archTag) + .join(", "); + + if (softFailAllowed) { + console.warn( + `[build-whisper-runtime] CMake not found and no bundled runtime is staged for: ${missing}. ` + + "Auto-caption features that rely on whisper.cpp will be unavailable until you install CMake " + + "and rerun `npm run build:whisper-runtime`.", + ); + return; + } + + throw new Error( + `[build-whisper-runtime] CMake is required to stage the whisper runtime for: ${missing}. ` + + "Install CMake and retry, or set WHISPER_RUNTIME_ALLOW_MISSING=1 to build without auto-caption support.", + ); + } + + const sourceDir = await ensureSourceTree(); + + console.log( + `[build-whisper-runtime] Target architectures for ${process.platform}: ${targets.map((target) => target.archTag).join(", ")}`, + ); + + for (const target of targets) { + if (await shouldSkipBuild(target)) { + console.log( + `[build-whisper-runtime] Whisper runtime ${whisperVersion} already staged for ${target.archTag}.`, + ); + continue; + } + + await mkdir(target.buildRoot, { recursive: true }); + + console.log( + `[build-whisper-runtime] Configuring whisper.cpp ${whisperVersion} for ${target.archTag}...`, + ); + if (target.platform === "win32") { + configureWithWindowsCmakeGenerator({ + prefix: "build-whisper-runtime", + clearCache: () => { + rmSync(path.join(target.buildRoot, "CMakeCache.txt"), { force: true }); + rmSync(path.join(target.buildRoot, "CMakeFiles"), { + recursive: true, + force: true, + }); + }, + configure: (generator, toolset) => + execFileSync(cmake, getConfigureArgs(sourceDir, target, generator, toolset), { + stdio: "inherit", + timeout: 300000, + }), + }); + } else { + execFileSync(cmake, getConfigureArgs(sourceDir, target), { + stdio: "inherit", + timeout: 300000, + }); + } + + console.log( + `[build-whisper-runtime] Building bundled whisper runtime for ${target.archTag}...`, + ); + execFileSync(cmake, getBuildArgs(target), { stdio: "inherit", timeout: 900000 }); + + const { candidateDir, runtimeEntries } = await findRuntimeArtifacts(target); + await stageRuntimeArtifacts(target, candidateDir, runtimeEntries); + console.log(`[build-whisper-runtime] Staged whisper runtime -> ${target.outputDir}`); + } +} + +await main(); diff --git a/scripts/build-windows-capture.mjs b/scripts/build-windows-capture.mjs new file mode 100644 index 000000000..14cc83461 --- /dev/null +++ b/scripts/build-windows-capture.mjs @@ -0,0 +1,174 @@ +import { execSync } from "node:child_process"; +import { copyFileSync, existsSync, mkdirSync, rmSync } from "node:fs"; +import path from "node:path"; + +import { + formatNativeHelperManifestWarning, + updateNativeHelperManifest, + verifyNativeHelperManifest, +} from "./native-helper-manifest.mjs"; +import { + configureWithWindowsCmakeGenerator, + WINDOWS_VISUAL_STUDIO_INSTALL_DIRS, +} from "./windows-cmake-generators.mjs"; + +const projectRoot = process.cwd(); +const sourceDir = path.join(projectRoot, "electron", "native", "wgc-capture"); +const buildDir = path.join(sourceDir, "build"); +const bundledDir = path.join( + projectRoot, + "electron", + "native", + "bin", + process.arch === "arm64" ? "win32-arm64" : "win32-x64", +); +const bundledExePath = path.join(bundledDir, "wgc-capture.exe"); +const helperId = "wgc-capture"; +const generatorArch = process.arch === "arm64" ? "ARM64" : "x64"; + +if (process.platform !== "win32") { + console.log( + "[build-windows-capture] Skipping native Windows capture build: host platform is not Windows.", + ); + process.exit(0); +} + +if (!existsSync(path.join(sourceDir, "CMakeLists.txt"))) { + console.error("[build-windows-capture] CMakeLists.txt not found at", sourceDir); + process.exit(1); +} + +function findCmake() { + // Check PATH first + try { + execSync("cmake --version", { stdio: "pipe" }); + return "cmake"; + } catch { + // not on PATH + } + + const standaloneCmakePaths = [ + path.join("C:", "Program Files", "CMake", "bin", "cmake.exe"), + path.join("C:", "Program Files (x86)", "CMake", "bin", "cmake.exe"), + ]; + for (const cmakePath of standaloneCmakePaths) { + if (existsSync(cmakePath)) { + return `"${cmakePath}"`; + } + } + + // VS 2022 bundled CMake + const vsRoots = [ + path.join("C:", "Program Files", "Microsoft Visual Studio"), + path.join("C:", "Program Files (x86)", "Microsoft Visual Studio"), + ]; + const vsEditions = ["Community", "Professional", "Enterprise", "BuildTools"]; + const vsVersions = WINDOWS_VISUAL_STUDIO_INSTALL_DIRS; + for (const root of vsRoots) { + for (const version of vsVersions) { + for (const edition of vsEditions) { + const cmakePath = path.join( + root, + version, + edition, + "Common7", + "IDE", + "CommonExtensions", + "Microsoft", + "CMake", + "CMake", + "bin", + "cmake.exe", + ); + if (existsSync(cmakePath)) { + return `"${cmakePath}"`; + } + } + } + } + + return null; +} + +const cmake = findCmake(); +if (!cmake) { + if (existsSync(bundledExePath)) { + const verification = verifyNativeHelperManifest({ + projectRoot, + helperId, + sourceDir, + binaryPath: bundledExePath, + binaryName: "wgc-capture.exe", + }); + if (!verification.ok) { + console.warn(formatNativeHelperManifestWarning("build-windows-capture", verification)); + } + console.log(`[build-windows-capture] Using bundled helper: ${bundledExePath}`); + process.exit(0); + } + + console.error( + "[build-windows-capture] CMake not found. Install Visual Studio with C++ CMake tools or standalone CMake.", + ); + process.exit(1); +} + +mkdirSync(buildDir, { recursive: true }); +const cacheFile = path.join(buildDir, "CMakeCache.txt"); +const cacheDir = path.join(buildDir, "CMakeFiles"); + +function clearCmakeCache() { + rmSync(cacheFile, { force: true }); + rmSync(cacheDir, { recursive: true, force: true }); +} + +console.log("[build-windows-capture] Configuring CMake..."); +try { + configureWithWindowsCmakeGenerator({ + prefix: "build-windows-capture", + clearCache: clearCmakeCache, + configure: (generator, toolset) => + execSync( + `${cmake} .. -G "${generator}" -A ${generatorArch}${toolset ? ` -T ${toolset}` : ""}`, + { + cwd: buildDir, + stdio: "inherit", + timeout: 120000, + }, + ), + }); +} catch (error) { + console.error("[build-windows-capture] CMake configure failed:", error.message); + process.exit(1); +} + +console.log("[build-windows-capture] Building native Windows capture helper..."); +try { + execSync(`${cmake} --build . --config Release`, { + cwd: buildDir, + stdio: "inherit", + timeout: 300000, + }); +} catch (error) { + console.error("[build-windows-capture] Build failed:", error.message); + process.exit(1); +} + +const exePath = path.join(buildDir, "Release", "wgc-capture.exe"); +if (existsSync(exePath)) { + console.log(`[build-windows-capture] Built successfully: ${exePath}`); + mkdirSync(bundledDir, { recursive: true }); + copyFileSync(exePath, bundledExePath); + console.log(`[build-windows-capture] Staged bundled helper: ${bundledExePath}`); + const manifestPath = updateNativeHelperManifest({ + projectRoot, + helperId, + sourceDir, + binaryPath: bundledExePath, + binaryName: "wgc-capture.exe", + }); + console.log(`[build-windows-capture] Updated helper manifest: ${manifestPath}`); +} else { + console.error("[build-windows-capture] Expected exe not found at", exePath); + process.exit(1); +} diff --git a/scripts/build-windows-gpu-export.mjs b/scripts/build-windows-gpu-export.mjs new file mode 100644 index 000000000..63e756be3 --- /dev/null +++ b/scripts/build-windows-gpu-export.mjs @@ -0,0 +1,170 @@ +import { execSync } from "node:child_process"; +import { copyFileSync, existsSync, mkdirSync, rmSync } from "node:fs"; +import path from "node:path"; + +import { + formatNativeHelperManifestWarning, + updateNativeHelperManifest, + verifyNativeHelperManifest, +} from "./native-helper-manifest.mjs"; +import { + configureWithWindowsCmakeGenerator, + WINDOWS_VISUAL_STUDIO_INSTALL_DIRS, +} from "./windows-cmake-generators.mjs"; + +const projectRoot = process.cwd(); +const sourceDir = path.join(projectRoot, "electron", "native", "gpu-export-probe"); +const buildDir = path.join(sourceDir, "build"); +const bundledDir = path.join( + projectRoot, + "electron", + "native", + "bin", + process.arch === "arm64" ? "win32-arm64" : "win32-x64", +); +const bundledExePath = path.join(bundledDir, "recordly-gpu-export.exe"); +const helperId = "recordly-gpu-export"; +const generatorArch = process.arch === "arm64" ? "ARM64" : "x64"; + +if (process.platform !== "win32") { + console.log("[build-windows-gpu-export] Skipping Windows GPU export helper build."); + process.exit(0); +} + +if (!existsSync(path.join(sourceDir, "CMakeLists.txt"))) { + console.error("[build-windows-gpu-export] CMakeLists.txt not found at", sourceDir); + process.exit(1); +} + +function findCmake() { + try { + execSync("cmake --version", { stdio: "pipe" }); + return "cmake"; + } catch { + // Continue probing common Windows install locations. + } + + const standaloneCmakePaths = [ + path.join("C:", "Program Files", "CMake", "bin", "cmake.exe"), + path.join("C:", "Program Files (x86)", "CMake", "bin", "cmake.exe"), + ]; + for (const cmakePath of standaloneCmakePaths) { + if (existsSync(cmakePath)) { + return `"${cmakePath}"`; + } + } + + const vsRoots = [ + path.join("C:", "Program Files", "Microsoft Visual Studio"), + path.join("C:", "Program Files (x86)", "Microsoft Visual Studio"), + ]; + const vsEditions = ["Community", "Professional", "Enterprise", "BuildTools"]; + const vsVersions = WINDOWS_VISUAL_STUDIO_INSTALL_DIRS; + for (const root of vsRoots) { + for (const version of vsVersions) { + for (const edition of vsEditions) { + const cmakePath = path.join( + root, + version, + edition, + "Common7", + "IDE", + "CommonExtensions", + "Microsoft", + "CMake", + "CMake", + "bin", + "cmake.exe", + ); + if (existsSync(cmakePath)) { + return `"${cmakePath}"`; + } + } + } + } + + return null; +} + +const cmake = findCmake(); +if (!cmake) { + if (existsSync(bundledExePath)) { + const verification = verifyNativeHelperManifest({ + projectRoot, + helperId, + sourceDir, + binaryPath: bundledExePath, + binaryName: "recordly-gpu-export.exe", + }); + if (!verification.ok) { + console.error( + formatNativeHelperManifestWarning("build-windows-gpu-export", verification), + ); + process.exit(1); + } + console.log(`[build-windows-gpu-export] Using bundled helper: ${bundledExePath}`); + process.exit(0); + } + + console.error( + "[build-windows-gpu-export] CMake not found. Install Visual Studio with C++ CMake tools or standalone CMake.", + ); + process.exit(1); +} + +mkdirSync(buildDir, { recursive: true }); + +function clearCmakeCache() { + rmSync(path.join(buildDir, "CMakeCache.txt"), { force: true }); + rmSync(path.join(buildDir, "CMakeFiles"), { recursive: true, force: true }); +} + +console.log("[build-windows-gpu-export] Configuring CMake..."); +try { + configureWithWindowsCmakeGenerator({ + prefix: "build-windows-gpu-export", + clearCache: clearCmakeCache, + configure: (generator, toolset) => + execSync( + `${cmake} .. -G "${generator}" -A ${generatorArch}${toolset ? ` -T ${toolset}` : ""}`, + { + cwd: buildDir, + stdio: "inherit", + timeout: 120000, + }, + ), + }); +} catch (error) { + console.error("[build-windows-gpu-export] CMake configure failed:", error.message); + process.exit(1); +} + +console.log("[build-windows-gpu-export] Building Windows GPU export helper..."); +try { + execSync(`${cmake} --build . --config Release`, { + cwd: buildDir, + stdio: "inherit", + timeout: 300000, + }); +} catch (error) { + console.error("[build-windows-gpu-export] Build failed:", error.message); + process.exit(1); +} + +const exePath = path.join(buildDir, "Release", "gpu-export-probe.exe"); +if (!existsSync(exePath)) { + console.error("[build-windows-gpu-export] Expected exe not found at", exePath); + process.exit(1); +} + +mkdirSync(bundledDir, { recursive: true }); +copyFileSync(exePath, bundledExePath); +console.log(`[build-windows-gpu-export] Staged bundled helper: ${bundledExePath}`); +const manifestPath = updateNativeHelperManifest({ + projectRoot, + helperId, + sourceDir, + binaryPath: bundledExePath, + binaryName: "recordly-gpu-export.exe", +}); +console.log(`[build-windows-gpu-export] Updated helper manifest: ${manifestPath}`); diff --git a/scripts/create-release.mjs b/scripts/create-release.mjs new file mode 100644 index 000000000..25c36c6d2 --- /dev/null +++ b/scripts/create-release.mjs @@ -0,0 +1,123 @@ +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; + +function printUsage() { + console.error(`Usage: + node scripts/create-release.mjs --tag [--title ] [--notes <text> | --notes-file <path>] [--prerelease] [--draft] + +Examples: + node scripts/create-release.mjs --tag v1.2.0-beta.2 --title "v1.2.0 beta-2" --prerelease --notes-file ./release-notes.md + node scripts/create-release.mjs --tag v1.2.0 --notes "Stable release summary" +`); +} + +function parseArgs(argv) { + const parsed = { + tag: "", + title: "", + notes: "", + notesFile: "", + prerelease: false, + draft: false, + }; + + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + switch (arg) { + case "--tag": + parsed.tag = argv[++i] ?? ""; + break; + case "--title": + parsed.title = argv[++i] ?? ""; + break; + case "--notes": + parsed.notes = argv[++i] ?? ""; + break; + case "--notes-file": + parsed.notesFile = argv[++i] ?? ""; + break; + case "--prerelease": + parsed.prerelease = true; + break; + case "--draft": + parsed.draft = true; + break; + case "--help": + case "-h": + printUsage(); + process.exit(0); + break; + default: + throw new Error(`Unknown argument: ${arg}`); + } + } + + if (!parsed.tag) { + throw new Error("Missing required --tag argument"); + } + + if (parsed.notes && parsed.notesFile) { + throw new Error("Use either --notes or --notes-file, not both"); + } + + return parsed; +} + +function loadNotes({ notes, notesFile }) { + if (notesFile) { + return fs.readFileSync(notesFile, "utf8"); + } + + return notes; +} + +function resolveGhBinary() { + const candidates = [ + process.env.GH_BIN, + "gh", + "/opt/homebrew/bin/gh", + "/usr/local/bin/gh", + ].filter(Boolean); + + for (const candidate of candidates) { + try { + execFileSync(candidate, ["--version"], { stdio: "ignore" }); + return candidate; + } catch { + continue; + } + } + + throw new Error( + "Could not find the GitHub CLI. Install `gh`, add it to PATH, or set GH_BIN to its full path.", + ); +} + +try { + const options = parseArgs(process.argv.slice(2)); + const notes = loadNotes(options).trim(); + const ghBinary = resolveGhBinary(); + const commandArgs = ["release", "create", options.tag, "--verify-tag", "--generate-notes"]; + + if (options.title) { + commandArgs.push("--title", options.title); + } + + if (notes) { + commandArgs.push("--notes", notes); + } + + if (options.prerelease) { + commandArgs.push("--prerelease"); + } + + if (options.draft) { + commandArgs.push("--draft"); + } + + execFileSync(ghBinary, commandArgs, { stdio: "inherit" }); +} catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + printUsage(); + process.exit(1); +} diff --git a/scripts/i18n-check.mjs b/scripts/i18n-check.mjs new file mode 100644 index 000000000..bb93a4c67 --- /dev/null +++ b/scripts/i18n-check.mjs @@ -0,0 +1,97 @@ +import fs from "node:fs"; +import path from "node:path"; + +const root = process.cwd(); +const localesDir = path.join(root, "src", "i18n", "locales"); + +const locales = fs + .readdirSync(localesDir) + .filter((entry) => { + const fullPath = path.join(localesDir, entry); + return fs.statSync(fullPath).isDirectory(); + }) + .sort((left, right) => left.localeCompare(right)); + +if (!locales.includes("en")) { + console.error('i18n-check: expected base locale directory "en"'); + process.exit(1); +} + +function loadJson(filePath) { + try { + return JSON.parse(fs.readFileSync(filePath, "utf8")); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`i18n-check: failed to load ${path.relative(root, filePath)}: ${message}`); + } +} + +function collectKeyPaths(obj, prefix = "") { + if (!obj || typeof obj !== "object" || Array.isArray(obj)) { + return prefix ? [prefix] : []; + } + + const keys = Object.keys(obj); + if (keys.length === 0) { + return prefix ? [prefix] : []; + } + + const paths = []; + for (const key of keys) { + const nextPrefix = prefix ? `${prefix}.${key}` : key; + const value = obj[key]; + if (value && typeof value === "object" && !Array.isArray(value)) { + paths.push(...collectKeyPaths(value, nextPrefix)); + } else { + paths.push(nextPrefix); + } + } + return paths; +} + +const baseLocaleDir = path.join(localesDir, "en"); +const namespaceFiles = fs + .readdirSync(baseLocaleDir) + .filter((file) => file.endsWith(".json")) + .sort((left, right) => left.localeCompare(right)); + +let hasErrors = false; + +for (const namespaceFile of namespaceFiles) { + const baseData = loadJson(path.join(baseLocaleDir, namespaceFile)); + const baseKeys = new Set(collectKeyPaths(baseData)); + + for (const locale of locales) { + if (locale === "en") continue; + + const localeFile = path.join(localesDir, locale, namespaceFile); + if (!fs.existsSync(localeFile)) { + console.error(`i18n-check: missing namespace file ${locale}/${namespaceFile}`); + hasErrors = true; + continue; + } + + const localeData = loadJson(localeFile); + const localeKeys = new Set(collectKeyPaths(localeData)); + + for (const key of baseKeys) { + if (!localeKeys.has(key)) { + console.error(`i18n-check: missing key ${locale}/${namespaceFile}:${key}`); + hasErrors = true; + } + } + + for (const key of localeKeys) { + if (!baseKeys.has(key)) { + console.error(`i18n-check: extra key ${locale}/${namespaceFile}:${key}`); + hasErrors = true; + } + } + } +} + +if (hasErrors) { + process.exit(1); +} + +console.log("i18n-check: locale files are structurally consistent"); diff --git a/scripts/install-ffmpeg-static.mjs b/scripts/install-ffmpeg-static.mjs new file mode 100644 index 000000000..80fa37731 --- /dev/null +++ b/scripts/install-ffmpeg-static.mjs @@ -0,0 +1,32 @@ +import { spawnSync } from "node:child_process"; + +const maxAttempts = Number.parseInt(process.env.FFMPEG_STATIC_INSTALL_ATTEMPTS ?? "3", 10); +const baseDelayMs = Number.parseInt(process.env.FFMPEG_STATIC_INSTALL_DELAY_MS ?? "2000", 10); + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function main() { + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + const result = spawnSync(process.execPath, ["node_modules/ffmpeg-static/install.js"], { + stdio: "inherit", + }); + + if (result.status === 0) { + return; + } + + if (attempt === maxAttempts) { + process.exit(result.status ?? 1); + } + + const delayMs = baseDelayMs * attempt; + console.warn( + `[ffmpeg-static] Install attempt ${attempt}/${maxAttempts} failed; retrying in ${delayMs}ms...`, + ); + await sleep(delayMs); + } +} + +await main(); diff --git a/scripts/launch-recordly-cuda-auto.ps1 b/scripts/launch-recordly-cuda-auto.ps1 new file mode 100644 index 000000000..5ce72ae34 --- /dev/null +++ b/scripts/launch-recordly-cuda-auto.ps1 @@ -0,0 +1,83 @@ +param( + [string]$AppPath, + [string]$CudaScriptPath, + [switch]$CloseExisting, + [switch]$NoDiagnostics, + [switch]$AllowCudaAudio +) + +$ErrorActionPreference = "Stop" + +$repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..") + +if (-not $AppPath) { + $packagedAppPath = Join-Path $repoRoot "release\win-unpacked\Recordly.exe" + $installedAppPath = Join-Path $env:LOCALAPPDATA "Programs\recordly\Recordly.exe" + if (Test-Path $packagedAppPath) { + $AppPath = $packagedAppPath + } elseif (Test-Path $installedAppPath) { + $AppPath = $installedAppPath + } else { + throw "Recordly.exe was not found. Build the Windows package first or pass -AppPath." + } +} + +if (-not $CudaScriptPath) { + $CudaScriptPath = Join-Path $repoRoot "electron\native\nvidia-cuda-compositor\run-mp4-pipeline.mjs" +} + +if (-not (Test-Path $AppPath)) { + throw "Recordly app not found: $AppPath" +} + +if (-not (Test-Path $CudaScriptPath)) { + throw "NVIDIA CUDA/NVENC wrapper script not found: $CudaScriptPath" +} + +$existingRecordly = @(Get-Process -Name "Recordly" -ErrorAction SilentlyContinue) +if ($existingRecordly.Count -gt 0) { + if (-not $CloseExisting) { + Write-Host "Recordly is already running. Close it first, or rerun with -CloseExisting so the CUDA env is inherited by the new app process." + $existingRecordly | Select-Object Id, ProcessName, Path | Format-Table -AutoSize + exit 2 + } + + $existingRecordly | Stop-Process -Force + Start-Sleep -Milliseconds 500 +} + +$env:RECORDLY_EXPERIMENTAL_NVIDIA_CUDA_EXPORT = "1" +$env:RECORDLY_NVIDIA_CUDA_EXPORT_SCRIPT = (Resolve-Path $CudaScriptPath).Path +$env:RECORDLY_NVIDIA_CUDA_EXPORT_HIGH_PRIORITY = "1" +$env:RECORDLY_NVIDIA_CUDA_SAMPLE_GPU = "1" + +if ($AllowCudaAudio) { + $env:RECORDLY_NVIDIA_CUDA_ALLOW_AUDIO_EXPORT = "1" + Remove-Item Env:\RECORDLY_NVIDIA_CUDA_FORCE_VIDEO_ONLY -ErrorAction SilentlyContinue +} else { + Remove-Item Env:\RECORDLY_NVIDIA_CUDA_ALLOW_AUDIO_EXPORT -ErrorAction SilentlyContinue + $env:RECORDLY_NVIDIA_CUDA_FORCE_VIDEO_ONLY = "1" +} + +if ($NoDiagnostics) { + Remove-Item Env:\RECORDLY_NVIDIA_CUDA_EXPORT_DIAGNOSTICS -ErrorAction SilentlyContinue +} else { + $env:RECORDLY_NVIDIA_CUDA_EXPORT_DIAGNOSTICS = "1" +} + +$resolvedAppPath = (Resolve-Path $AppPath).Path +$appDirectory = Split-Path $resolvedAppPath -Parent +if ($AllowCudaAudio) { + $cudaAudioMode = "inline CUDA audio candidate; app still requires timestamp-aligned CUDA output" +} else { + $cudaAudioMode = "CUDA video-only, then shared app audio mux" +} + +Write-Host "Launching Recordly with guarded NVIDIA CUDA/NVENC auto export enabled:" +Write-Host " App: $resolvedAppPath" +Write-Host " CUDA wrapper: $env:RECORDLY_NVIDIA_CUDA_EXPORT_SCRIPT" +Write-Host " Force CUDA video-only: $($env:RECORDLY_NVIDIA_CUDA_FORCE_VIDEO_ONLY -eq '1')" +Write-Host " CUDA audio exports: $cudaAudioMode" +Write-Host " Diagnostics: $($env:RECORDLY_NVIDIA_CUDA_EXPORT_DIAGNOSTICS -eq '1')" + +Start-Process -FilePath $resolvedAppPath -WorkingDirectory $appDirectory diff --git a/scripts/macos-distribution-policy.mjs b/scripts/macos-distribution-policy.mjs new file mode 100644 index 000000000..5a6a4db90 --- /dev/null +++ b/scripts/macos-distribution-policy.mjs @@ -0,0 +1,125 @@ +const TEAM_ID_PATTERN = /^[A-Z0-9]{10}$/; + +export const REQUIRED_MACOS_ENTITLEMENTS = Object.freeze([ + "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.audio-input", + "com.apple.security.device.camera", +]); + +const ALLOWED_MACOS_ENTITLEMENTS = new Set(REQUIRED_MACOS_ENTITLEMENTS); + +function readCodeSignValue(details, key) { + const prefix = `${key}=`; + return details + .split(/\r?\n/) + .find((line) => line.startsWith(prefix)) + ?.slice(prefix.length) + .trim(); +} + +export function assertValidAppleTeamId(teamId) { + if (!TEAM_ID_PATTERN.test(teamId)) { + throw new Error("APPLE_TEAM_ID must be exactly 10 uppercase letters or digits"); + } +} + +export function collectCodeSigningMetadataErrors(details, expectedTeamId) { + const errors = []; + const authorities = details + .split(/\r?\n/) + .filter((line) => line.startsWith("Authority=")) + .map((line) => line.slice("Authority=".length).trim()); + const identifier = readCodeSignValue(details, "Identifier"); + const teamIdentifier = readCodeSignValue(details, "TeamIdentifier"); + const timestamp = readCodeSignValue(details, "Timestamp"); + const codeDirectory = details.split(/\r?\n/).find((line) => line.startsWith("CodeDirectory ")); + + if (identifier !== "dev.recordly.app") { + errors.push(`unexpected bundle identifier: ${identifier ?? "missing"}`); + } + + if (!authorities[0]?.startsWith("Developer ID Application:")) { + errors.push("the leaf signing authority is not Developer ID Application"); + } + + if (teamIdentifier !== expectedTeamId) { + errors.push(`unexpected TeamIdentifier: ${teamIdentifier ?? "missing"}`); + } + + if (!timestamp || timestamp.toLowerCase() === "none") { + errors.push("secure signing timestamp is missing"); + } + + if (!codeDirectory?.includes("runtime")) { + errors.push("hardened runtime flag is missing"); + } + + return errors; +} + +export function collectEntitlementErrors(entitlements) { + const errors = []; + + for (const entitlement of REQUIRED_MACOS_ENTITLEMENTS) { + if (entitlements[entitlement] !== true) { + errors.push(`required entitlement is missing or disabled: ${entitlement}`); + } + } + + for (const entitlement of Object.keys(entitlements).sort()) { + if (ALLOWED_MACOS_ENTITLEMENTS.has(entitlement)) { + continue; + } + + if ( + entitlement === "com.apple.security.get-task-allow" && + entitlements[entitlement] === true + ) { + errors.push("distribution build must not enable com.apple.security.get-task-allow"); + continue; + } + + errors.push(`unexpected root application entitlement: ${entitlement}`); + } + + return errors; +} + +export function expectedMachOArchitecture(filePath, buildArch) { + const normalizedPath = filePath.replaceAll("\\", "/"); + if (normalizedPath.includes("/darwin-arm64/")) { + return "arm64"; + } + + if (normalizedPath.includes("/darwin-x64/")) { + return "x86_64"; + } + + return buildArch === "arm64" ? "arm64" : "x86_64"; +} + +export function parseLipoArchitectures(output) { + const trimmed = output.trim(); + const architectureList = trimmed.match(/are:\s+(.+)$/i)?.[1]; + if (architectureList) { + return architectureList.trim().split(/\s+/); + } + + const singleArchitecture = trimmed.match(/architecture:\s+([^\s]+)$/i)?.[1]; + return singleArchitecture ? [singleArchitecture] : trimmed.split(/\s+/).filter(Boolean); +} + +export function collectArchitectureErrors(filePath, lipoOutput, buildArch) { + const expectedArchitecture = expectedMachOArchitecture(filePath, buildArch); + const architectures = parseLipoArchitectures(lipoOutput); + + if (architectures.includes(expectedArchitecture)) { + return []; + } + + return [ + `${filePath} does not contain ${expectedArchitecture} (found: ${architectures.join(", ") || "none"})`, + ]; +} diff --git a/scripts/native-helper-manifest.mjs b/scripts/native-helper-manifest.mjs new file mode 100644 index 000000000..0cf96e0f3 --- /dev/null +++ b/scripts/native-helper-manifest.mjs @@ -0,0 +1,188 @@ +import { createHash } from "node:crypto"; +import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import path from "node:path"; + +const MANIFEST_FILE_NAME = "helpers-manifest.json"; + +function getPlatformArchDir(platform, arch) { + if (platform !== "win32") { + throw new Error(`Unsupported native helper manifest platform: ${platform}`); + } + + return arch === "arm64" ? "win32-arm64" : "win32-x64"; +} + +function hashBuffer(buffer) { + return createHash("sha256").update(buffer).digest("hex"); +} + +function collectSourceFiles(sourceDir, rootDir = sourceDir) { + const entries = readdirSync(sourceDir, { withFileTypes: true }); + const files = []; + + for (const entry of entries) { + if (entry.name === "build" || entry.name === "bin") { + continue; + } + + const absolutePath = path.join(sourceDir, entry.name); + if (entry.isDirectory()) { + files.push(...collectSourceFiles(absolutePath, rootDir)); + continue; + } + + if (!entry.isFile()) { + continue; + } + + files.push(path.relative(rootDir, absolutePath).replaceAll("\\", "/")); + } + + files.sort((left, right) => left.localeCompare(right)); + return files; +} + +function hashSourceTree(sourceDir) { + const hash = createHash("sha256"); + for (const relativePath of collectSourceFiles(sourceDir)) { + const absolutePath = path.join(sourceDir, relativePath); + hash.update(relativePath); + hash.update("\n"); + hash.update(readFileSync(absolutePath)); + hash.update("\n"); + } + + return hash.digest("hex"); +} + +function hashFile(filePath) { + return hashBuffer(readFileSync(filePath)); +} + +export function getNativeHelperManifestPath({ + projectRoot, + platform = process.platform, + arch = process.arch, +}) { + return path.join( + projectRoot, + "electron", + "native", + "bin", + getPlatformArchDir(platform, arch), + MANIFEST_FILE_NAME, + ); +} + +function readManifest(manifestPath, platform, arch) { + if (!existsSync(manifestPath)) { + return { + version: 1, + platform, + arch, + helpers: {}, + }; + } + + const manifestContent = JSON.parse(readFileSync(manifestPath, "utf8")); + return { + version: 1, + platform, + arch, + helpers: {}, + ...manifestContent, + }; +} + +export function updateNativeHelperManifest({ + projectRoot, + helperId, + sourceDir, + binaryPath, + binaryName, + platform = process.platform, + arch = process.arch, +}) { + const manifestPath = getNativeHelperManifestPath({ projectRoot, platform, arch }); + const manifestDir = path.dirname(manifestPath); + mkdirSync(manifestDir, { recursive: true }); + + const manifest = readManifest(manifestPath, platform, arch); + manifest.helpers[helperId] = { + binaryName, + binarySha256: hashFile(binaryPath), + sourceDir: path.relative(projectRoot, sourceDir).replaceAll("\\", "/"), + sourceFingerprint: hashSourceTree(sourceDir), + updatedAt: new Date().toISOString(), + }; + + writeFileSync(`${manifestPath}`, `${JSON.stringify(manifest, null, 2)}\n`); + return manifestPath; +} + +export function verifyNativeHelperManifest({ + projectRoot, + helperId, + sourceDir, + binaryPath, + binaryName, + platform = process.platform, + arch = process.arch, +}) { + const manifestPath = getNativeHelperManifestPath({ projectRoot, platform, arch }); + if (!existsSync(manifestPath)) { + return { + ok: false, + manifestPath, + reasons: ["manifest missing"], + }; + } + + if (!existsSync(binaryPath)) { + return { + ok: false, + manifestPath, + reasons: ["bundled helper missing"], + }; + } + + const manifest = readManifest(manifestPath, platform, arch); + const helperManifest = manifest.helpers?.[helperId]; + if (!helperManifest) { + return { + ok: false, + manifestPath, + reasons: [`${helperId} entry missing`], + }; + } + + const reasons = []; + if (helperManifest.binaryName !== binaryName) { + reasons.push( + `expected binary ${binaryName}, found ${helperManifest.binaryName ?? "unknown"}`, + ); + } + + const expectedBinaryHash = helperManifest.binarySha256; + const actualBinaryHash = hashFile(binaryPath); + if (expectedBinaryHash !== actualBinaryHash) { + reasons.push("binary hash mismatch"); + } + + const expectedSourceFingerprint = helperManifest.sourceFingerprint; + const actualSourceFingerprint = hashSourceTree(sourceDir); + if (expectedSourceFingerprint !== actualSourceFingerprint) { + reasons.push("source fingerprint mismatch"); + } + + return { + ok: reasons.length === 0, + manifestPath, + reasons, + }; +} + +export function formatNativeHelperManifestWarning(helperLabel, verificationResult) { + const reasonText = verificationResult.reasons.join(", "); + return `[${helperLabel}] Bundled helper provenance check failed (${reasonText}). Rebuild the helper to refresh ${path.basename(verificationResult.manifestPath)}.`; +} diff --git a/scripts/normalize-electron-main-cjs.mjs b/scripts/normalize-electron-main-cjs.mjs new file mode 100644 index 000000000..082ab4262 --- /dev/null +++ b/scripts/normalize-electron-main-cjs.mjs @@ -0,0 +1,467 @@ +import fs from "node:fs/promises"; +import { pathToFileURL } from "node:url"; + +const mainBundleUrl = new URL("../dist-electron/main.cjs", import.meta.url); +const IMPORT_META_URL_CJS_REPLACEMENT = 'require("node:url").pathToFileURL(__filename).href'; + +function convertNamedImports(namedSpec) { + return namedSpec.replace(/\s+as\s+/g, ": "); +} + +function convertImportLine(line) { + const importFromMatch = line.match( + /^([ \t]*)import\s+([^;\n]+?)\s+from\s+(["'][^"']+["'])\s*;?[ \t]*$/, + ); + if (importFromMatch) { + const [, indent, rawSpec, moduleLiteral] = importFromMatch; + const spec = rawSpec.trim(); + if (spec.startsWith("* as ")) { + return `${indent}const ${spec.slice(5).trim()} = require(${moduleLiteral});`; + } + + if (spec.startsWith("{")) { + return `${indent}const ${convertNamedImports(spec)} = require(${moduleLiteral});`; + } + + const commaIndex = spec.indexOf(","); + if (commaIndex >= 0) { + const defaultName = spec.slice(0, commaIndex).trim(); + const namedSpec = spec.slice(commaIndex + 1).trim(); + return [ + `${indent}const ${defaultName} = require(${moduleLiteral});`, + `${indent}const ${convertNamedImports(namedSpec)} = ${defaultName};`, + ].join("\n"); + } + + return `${indent}const ${spec} = require(${moduleLiteral});`; + } + + const sideEffectImportMatch = line.match(/^([ \t]*)import\s+(["'][^"']+["'])\s*;?[ \t]*$/); + if (sideEffectImportMatch) { + const [, indent, moduleLiteral] = sideEffectImportMatch; + return `${indent}require(${moduleLiteral});`; + } + + if (/^[ \t]*export\s*\{\s*\}\s*;?[ \t]*$/.test(line)) { + return ""; + } + + return null; +} + +function convertNamedExports(namedSpec, indent = "") { + const statements = []; + for (const rawSpecifier of namedSpec.split(",")) { + const specifier = rawSpecifier.trim(); + if (!specifier) { + continue; + } + + const aliasMatch = specifier.match( + /^([A-Za-z_$][A-Za-z0-9_$]*)\s+as\s+([A-Za-z_$][A-Za-z0-9_$]*)$/, + ); + const localName = aliasMatch ? aliasMatch[1] : specifier; + const exportName = aliasMatch ? aliasMatch[2] : specifier; + if ( + !/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(localName) || + !/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(exportName) + ) { + return null; + } + + statements.push(`${indent}exports.${exportName} = ${localName};`); + } + + return statements; +} + +function convertExportLine(line) { + const singleLineMatch = line.match(/^([ \t]*)export\s*\{\s*([^}]*)\s*\}\s*;?[ \t]*$/); + if (singleLineMatch) { + const [, indent, namedSpec] = singleLineMatch; + return convertNamedExports(namedSpec, indent); + } + + const blockStartMatch = line.match(/^([ \t]*)export\s*\{\s*$/); + if (blockStartMatch) { + return { indent: blockStartMatch[1], specifiers: [], rawLines: [line] }; + } + + return null; +} + +function updateLexicalState(line, state) { + let mode = state.mode; + let escaped = false; + + for (let index = 0; index < line.length; index += 1) { + const char = line[index]; + const next = line[index + 1]; + + if (mode === "block-comment") { + if (char === "*" && next === "/") { + mode = null; + index += 1; + } + continue; + } + + if (mode === "single-quote" || mode === "double-quote" || mode === "template") { + if (escaped) { + escaped = false; + continue; + } + + if (char === "\\") { + escaped = true; + continue; + } + + if ( + (mode === "single-quote" && char === "'") || + (mode === "double-quote" && char === '"') || + (mode === "template" && char === "`") + ) { + mode = null; + } + continue; + } + + if (char === "/" && next === "/") { + break; + } + + if (char === "/" && next === "*") { + mode = "block-comment"; + index += 1; + continue; + } + + if (char === "'") { + mode = "single-quote"; + } else if (char === '"') { + mode = "double-quote"; + } else if (char === "`") { + mode = "template"; + } + } + + if (mode === "single-quote" || mode === "double-quote") { + mode = null; + } + + return { mode }; +} + +function hasTokenBoundary(line, startIndex, endIndex) { + const before = line[startIndex - 1]; + const after = line[endIndex]; + const isIdentifier = (char) => Boolean(char && /[A-Za-z0-9_$]/.test(char)); + return !isIdentifier(before) && !isIdentifier(after); +} + +function replaceImportMetaUrlInCode(line, state) { + const token = "import.meta.url"; + let mode = state.mode; + let escaped = false; + let changed = false; + let normalizedLine = ""; + + for (let index = 0; index < line.length; index += 1) { + const char = line[index]; + const next = line[index + 1]; + + if (mode === "block-comment") { + normalizedLine += char; + if (char === "*" && next === "/") { + normalizedLine += next; + mode = null; + index += 1; + } + continue; + } + + if (mode === "single-quote" || mode === "double-quote" || mode === "template") { + normalizedLine += char; + if (escaped) { + escaped = false; + continue; + } + + if (char === "\\") { + escaped = true; + continue; + } + + if ( + (mode === "single-quote" && char === "'") || + (mode === "double-quote" && char === '"') || + (mode === "template" && char === "`") + ) { + mode = null; + } + continue; + } + + if (char === "/" && next === "/") { + normalizedLine += line.slice(index); + break; + } + + if (char === "/" && next === "*") { + normalizedLine += char + next; + mode = "block-comment"; + index += 1; + continue; + } + + if (char === "'") { + normalizedLine += char; + mode = "single-quote"; + continue; + } + + if (char === '"') { + normalizedLine += char; + mode = "double-quote"; + continue; + } + + if (char === "`") { + normalizedLine += char; + mode = "template"; + continue; + } + + if (line.startsWith(token, index) && hasTokenBoundary(line, index, index + token.length)) { + normalizedLine += IMPORT_META_URL_CJS_REPLACEMENT; + changed = true; + index += token.length - 1; + continue; + } + + normalizedLine += char; + } + + if (mode === "single-quote" || mode === "double-quote") { + mode = null; + } + + return { + line: normalizedLine, + state: { mode }, + changed, + }; +} + +function containsImportMetaInCode(line, state) { + const token = "import.meta"; + let mode = state.mode; + let escaped = false; + + for (let index = 0; index < line.length; index += 1) { + const char = line[index]; + const next = line[index + 1]; + + if (mode === "block-comment") { + if (char === "*" && next === "/") { + mode = null; + index += 1; + } + continue; + } + + if (mode === "single-quote" || mode === "double-quote" || mode === "template") { + if (escaped) { + escaped = false; + continue; + } + + if (char === "\\") { + escaped = true; + continue; + } + + if ( + (mode === "single-quote" && char === "'") || + (mode === "double-quote" && char === '"') || + (mode === "template" && char === "`") + ) { + mode = null; + } + continue; + } + + if (char === "/" && next === "/") { + break; + } + + if (char === "/" && next === "*") { + mode = "block-comment"; + index += 1; + continue; + } + + if (char === "'") { + mode = "single-quote"; + continue; + } + + if (char === '"') { + mode = "double-quote"; + continue; + } + + if (char === "`") { + mode = "template"; + continue; + } + + if (line.startsWith(token, index) && hasTokenBoundary(line, index, index + token.length)) { + return true; + } + } + + return false; +} + +export function normalizeElectronMainCjsSource(source) { + let changed = false; + const lineBreak = source.includes("\r\n") ? "\r\n" : "\n"; + const lines = source.split(/\r?\n/); + const normalizedLines = []; + let state = { mode: null }; + let exportBlock = null; + + for (const line of lines) { + if (exportBlock) { + if (/^[ \t]*\}\s*;?[ \t]*$/.test(line)) { + const statements = convertNamedExports( + exportBlock.specifiers.join(","), + exportBlock.indent, + ); + if (statements === null) { + normalizedLines.push(...exportBlock.rawLines, line); + } else { + normalizedLines.push(...statements); + changed = true; + } + exportBlock = null; + continue; + } + + exportBlock.specifiers.push(line.trim().replace(/,$/, "")); + exportBlock.rawLines.push(line); + continue; + } + + if (state.mode === null) { + const converted = convertImportLine(line); + if (converted !== null) { + normalizedLines.push(converted); + changed = true; + continue; + } + + const convertedExport = convertExportLine(line); + if (convertedExport !== null) { + if (Array.isArray(convertedExport)) { + normalizedLines.push(...convertedExport); + changed = true; + } else { + exportBlock = convertedExport; + } + continue; + } + } + + const normalized = replaceImportMetaUrlInCode(line, state); + if (normalized.changed) { + changed = true; + } + + normalizedLines.push(normalized.line); + state = normalized.state; + } + + if (!changed) { + return { source, changed }; + } + + const normalized = normalizedLines.join(lineBreak); + + return { source: normalized, changed }; +} + +export function findElectronMainCjsEsmSyntax(source) { + const lines = source.split(/\r?\n/); + const matches = []; + let state = { mode: null }; + + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index]; + if (state.mode === null) { + const converted = convertImportLine(line); + if (converted !== null) { + matches.push({ + line: index + 1, + text: line.trim(), + }); + continue; + } + if (containsImportMetaInCode(line, state)) { + matches.push({ + line: index + 1, + text: line.trim(), + }); + continue; + } + if (/^[ \t]*export\b/.test(line)) { + matches.push({ + line: index + 1, + text: line.trim(), + }); + continue; + } + } + + state = updateLexicalState(line, state); + } + + return matches; +} + +export async function normalizeElectronMainCjs(bundleUrl = mainBundleUrl) { + let source; + try { + source = await fs.readFile(bundleUrl, "utf8"); + } catch (error) { + throw new Error(`Unable to read dist-electron/main.cjs: ${error}`); + } + + const normalized = normalizeElectronMainCjsSource(source); + if (normalized.changed) { + await fs.writeFile(bundleUrl, normalized.source, "utf8"); + } + + return normalized; +} + +const isDirectRun = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href; + +if (isDirectRun) { + const result = await normalizeElectronMainCjs(); + const remainingImports = findElectronMainCjsEsmSyntax(result.source); + if (remainingImports.length > 0) { + const details = remainingImports + .map((match) => `line ${match.line}: ${match.text}`) + .join("\n"); + throw new Error(`dist-electron/main.cjs still contains ESM import syntax:\n${details}`); + } + + console.log( + result.changed + ? "Electron main CJS normalized: dist-electron/main.cjs" + : "Electron main CJS already normalized: dist-electron/main.cjs", + ); +} diff --git a/scripts/postinstall.mjs b/scripts/postinstall.mjs new file mode 100644 index 000000000..b0715a831 --- /dev/null +++ b/scripts/postinstall.mjs @@ -0,0 +1,49 @@ +import { spawnSync } from "node:child_process"; + +const npmExecPath = process.env.npm_execpath; +const hasNpmExecPath = typeof npmExecPath === "string" && npmExecPath.length > 0; +const npmInvoker = hasNpmExecPath + ? { + command: process.execPath, + argsPrefix: [npmExecPath], + shell: false, + } + : { + command: process.platform === "win32" ? "npm.cmd" : "npm", + argsPrefix: [], + shell: process.platform === "win32", + }; + +function runScript(scriptName) { + console.log(`[postinstall] Running npm script: ${scriptName}`); + const result = spawnSync(npmInvoker.command, [...npmInvoker.argsPrefix, "run", scriptName], { + stdio: "inherit", + env: process.env, + shell: npmInvoker.shell, + }); + + if (result.error) { + console.error(`[postinstall] Failed to start "${scriptName}" (${result.error.message}).`); + return false; + } + + if (result.signal) { + console.error(`[postinstall] "${scriptName}" was terminated by signal ${result.signal}.`); + return false; + } + + if (result.status !== 0) { + console.error(`[postinstall] "${scriptName}" exited with code ${result.status}.`); + return false; + } + + return true; +} + +if (!runScript("rebuild:native")) { + process.exit(1); +} + +if (!runScript("build:platform-native-helpers")) { + process.exit(1); +} diff --git a/scripts/smoke-electron-main-cjs.mjs b/scripts/smoke-electron-main-cjs.mjs new file mode 100644 index 000000000..f0e3457ff --- /dev/null +++ b/scripts/smoke-electron-main-cjs.mjs @@ -0,0 +1,30 @@ +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { + findElectronMainCjsEsmSyntax, + normalizeElectronMainCjs, +} from "./normalize-electron-main-cjs.mjs"; + +const mainBundleUrl = new URL("../dist-electron/main.cjs", import.meta.url); +const mainBundlePath = fileURLToPath(mainBundleUrl); + +const { source } = await normalizeElectronMainCjs(mainBundleUrl); +const matches = findElectronMainCjsEsmSyntax(source); + +if (matches.length > 0) { + const details = matches.map((match) => `line ${match.line}: ${match.text}`).join("\n"); + throw new Error(`dist-electron/main.cjs contains ESM import syntax:\n${details}`); +} + +const checkResult = spawnSync(process.execPath, ["--check", mainBundlePath], { + encoding: "utf8", +}); + +if (checkResult.status !== 0) { + const details = [checkResult.stdout, checkResult.stderr].filter(Boolean).join("\n"); + throw new Error( + `dist-electron/main.cjs does not parse as CommonJS:${details ? `\n${details}` : ""}`, + ); +} + +console.log(`Electron main CJS smoke passed: ${mainBundlePath}`); diff --git a/scripts/smoke-packaged-binaries.mjs b/scripts/smoke-packaged-binaries.mjs new file mode 100644 index 000000000..eeb01c17f --- /dev/null +++ b/scripts/smoke-packaged-binaries.mjs @@ -0,0 +1,282 @@ +import { execFileSync } from "node:child_process"; +import { accessSync, constants, existsSync, readdirSync, readFileSync, statSync } from "node:fs"; +import path from "node:path"; + +const projectRoot = process.cwd(); +const releaseRoot = path.join(projectRoot, "release"); +const packageJson = JSON.parse(readFileSync(path.join(projectRoot, "package.json"), "utf8")); +const productName = packageJson.productName ?? packageJson.name ?? "Recordly"; +const packageName = packageJson.name ?? "recordly"; + +function relativePath(filePath) { + return path.relative(projectRoot, filePath).replaceAll("\\", "/"); +} + +function fail(message) { + throw new Error(`[packaged-smoke] ${message}`); +} + +function assertFile(filePath, label, { executable = false } = {}) { + if (!existsSync(filePath)) { + fail(`${label} is missing at ${relativePath(filePath)}`); + } + + if (!statSync(filePath).isFile()) { + fail(`${label} is not a file at ${relativePath(filePath)}`); + } + + if (executable && process.platform !== "win32") { + try { + accessSync(filePath, constants.X_OK); + } catch { + fail(`${label} is not executable at ${relativePath(filePath)}`); + } + } + + console.log(`[packaged-smoke] ${label}: ${relativePath(filePath)}`); +} + +function findDirectoriesByName(rootDir, directoryName, maxDepth = 8) { + if (!existsSync(rootDir)) { + return []; + } + + const matches = []; + const queue = [{ dir: rootDir, depth: 0 }]; + + while (queue.length > 0) { + const current = queue.shift(); + if (!current || current.depth > maxDepth) { + continue; + } + + for (const entry of readdirSync(current.dir, { withFileTypes: true })) { + if (!entry.isDirectory()) { + continue; + } + + const childDir = path.join(current.dir, entry.name); + if (entry.name === directoryName) { + matches.push(childDir); + continue; + } + + queue.push({ dir: childDir, depth: current.depth + 1 }); + } + } + + return matches; +} + +function findAppBundleDir(startDir) { + let current = startDir; + while (current !== path.dirname(current)) { + if (current.endsWith(".app")) { + return current; + } + current = path.dirname(current); + } + + return null; +} + +function findFirstExistingFile(candidates) { + return candidates.find((candidate) => existsSync(candidate) && statSync(candidate).isFile()); +} + +function assertPackagedAppExecutable(unpackedRoot) { + const resourcesDir = path.dirname(unpackedRoot); + + if (process.platform === "darwin") { + const appBundleDir = findAppBundleDir(resourcesDir); + if (!appBundleDir) { + fail(`macOS app bundle not found for ${relativePath(unpackedRoot)}`); + } + + const executablePath = findFirstExistingFile([ + path.join(appBundleDir, "Contents", "MacOS", productName), + path.join(appBundleDir, "Contents", "MacOS", packageName), + ]); + assertFile( + executablePath ?? path.join(appBundleDir, "Contents", "MacOS", productName), + "packaged app executable", + { executable: true }, + ); + return; + } + + const appDir = path.dirname(resourcesDir); + const executableCandidates = + process.platform === "win32" + ? [ + path.join(appDir, `${productName}.exe`), + path.join(appDir, `${packageName}.exe`), + path.join(appDir, "Recordly.exe"), + ] + : [ + path.join(appDir, packageName), + path.join(appDir, productName), + path.join(appDir, productName.toLowerCase()), + ]; + + const executablePath = findFirstExistingFile(executableCandidates); + assertFile(executablePath ?? executableCandidates[0], "packaged app executable", { + executable: true, + }); +} + +function getNativeArchTag(platform = process.platform, arch = process.arch) { + if (platform === "darwin") { + return arch === "arm64" ? "darwin-arm64" : "darwin-x64"; + } + + if (platform === "win32") { + return arch === "arm64" ? "win32-arm64" : "win32-x64"; + } + + if (platform === "linux") { + return arch === "arm64" ? "linux-arm64" : "linux-x64"; + } + + return `${platform}-${arch}`; +} + +function getRequiredArchTags() { + const configured = process.env.PACKAGED_SMOKE_ARCH_TAGS?.trim(); + if (!configured) { + return [getNativeArchTag()]; + } + + return [ + ...new Set( + configured + .split(",") + .map((entry) => entry.trim()) + .filter(Boolean), + ), + ]; +} + +function getExpectedNativeHelperFiles(archTag) { + if (archTag.startsWith("win32-")) { + const helpers = [ + { name: "wgc-capture.exe", label: "Windows capture helper", executable: true }, + { + name: "cursor-monitor.exe", + label: "Windows cursor monitor helper", + executable: true, + }, + { + name: "recordly-gpu-export.exe", + label: "Windows GPU export helper", + executable: true, + }, + { name: "helpers-manifest.json", label: "Windows helper manifest" }, + { name: "whisper-cli.exe", label: "Whisper CLI runtime", executable: true }, + { name: "whisper-runtime.json", label: "Whisper runtime manifest" }, + ]; + if (archTag === "win32-x64") { + helpers.push({ + name: "recordly-nvidia-cuda-compositor.exe", + label: "NVIDIA CUDA compositor helper", + executable: true, + }); + } + return helpers; + } + + if (archTag.startsWith("darwin-")) { + return [ + { + name: "recordly-screencapturekit-helper", + label: "ScreenCaptureKit helper", + executable: true, + }, + { name: "recordly-window-list", label: "Window list helper", executable: true }, + { name: "recordly-system-cursors", label: "System cursor helper", executable: true }, + { + name: "recordly-native-cursor-monitor", + label: "Native cursor monitor helper", + executable: true, + }, + { name: "whisper-cli", label: "Whisper CLI runtime", executable: true }, + { name: "whisper-runtime.json", label: "Whisper runtime manifest" }, + ]; + } + + if (archTag.startsWith("linux-")) { + return [ + { name: "whisper-cli", label: "Whisper CLI runtime", executable: true }, + { name: "whisper-runtime.json", label: "Whisper runtime manifest" }, + ]; + } + + return []; +} + +function verifyFfmpeg(unpackedRoot) { + const binaryName = process.platform === "win32" ? "ffmpeg.exe" : "ffmpeg"; + const ffmpegPath = path.join(unpackedRoot, "node_modules", "ffmpeg-static", binaryName); + + assertFile(ffmpegPath, "packaged FFmpeg binary", { executable: true }); + + const output = execFileSync(ffmpegPath, ["-version"], { + encoding: "utf8", + timeout: 15000, + windowsHide: true, + }); + + if (!output.startsWith("ffmpeg version")) { + fail(`FFmpeg version smoke returned unexpected output from ${relativePath(ffmpegPath)}`); + } + + console.log(output.split(/\r?\n/, 1)[0]); +} + +function verifyNativeHelpers(unpackedRoot) { + const nativeBinRoot = path.join(unpackedRoot, "electron", "native", "bin"); + if (!existsSync(nativeBinRoot)) { + fail(`native helper bin directory is missing at ${relativePath(nativeBinRoot)}`); + } + + for (const archTag of getRequiredArchTags()) { + const archDir = path.join(nativeBinRoot, archTag); + if (!existsSync(archDir)) { + fail(`native helper arch directory is missing at ${relativePath(archDir)}`); + } + + const expectedFiles = getExpectedNativeHelperFiles(archTag); + if (expectedFiles.length === 0) { + fail(`no packaged helper expectations are defined for ${archTag}`); + } + + for (const expectedFile of expectedFiles) { + assertFile( + path.join(archDir, expectedFile.name), + `${expectedFile.label} (${archTag})`, + { + executable: expectedFile.executable, + }, + ); + } + } +} + +const unpackedRoots = findDirectoriesByName(releaseRoot, "app.asar.unpacked"); + +if (unpackedRoots.length === 0) { + fail("no packaged app.asar.unpacked directory found under release/"); +} + +console.log( + `[packaged-smoke] verifying ${unpackedRoots.length} packaged app root(s) for ${process.platform}/${process.arch}`, +); + +for (const unpackedRoot of unpackedRoots) { + console.log(`[packaged-smoke] root: ${relativePath(unpackedRoot)}`); + assertPackagedAppExecutable(unpackedRoot); + verifyFfmpeg(unpackedRoot); + verifyNativeHelpers(unpackedRoot); +} + +console.log("[packaged-smoke] packaged binary path smoke passed"); diff --git a/scripts/verify-macos-distribution.mjs b/scripts/verify-macos-distribution.mjs new file mode 100644 index 000000000..0bd85e6e2 --- /dev/null +++ b/scripts/verify-macos-distribution.mjs @@ -0,0 +1,493 @@ +#!/usr/bin/env node + +import { spawnSync } from "node:child_process"; +import { + existsSync, + lstatSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { + assertValidAppleTeamId, + collectArchitectureErrors, + collectCodeSigningMetadataErrors, + collectEntitlementErrors, +} from "./macos-distribution-policy.mjs"; + +const projectRoot = process.cwd(); +const packageJson = JSON.parse(readFileSync(path.join(projectRoot, "package.json"), "utf8")); +const productName = packageJson.productName ?? packageJson.name ?? "Recordly"; +const expectedBundleId = "dev.recordly.app"; +const commandTimeoutMs = 5 * 60 * 1000; +const fileClassificationBatchSize = 100; +const maxReportDetailLength = 4_000; + +function parseArguments(argv) { + const options = {}; + for (let index = 0; index < argv.length; index += 2) { + const key = argv[index]; + const value = argv[index + 1]; + if (!key?.startsWith("--") || value === undefined) { + throw new Error(`Invalid argument near ${key ?? "end of input"}`); + } + options[key.slice(2)] = value; + } + + const arch = options.arch; + if (arch !== "x64" && arch !== "arm64") { + throw new Error("--arch must be x64 or arm64"); + } + + if (!options["team-id"]) { + throw new Error("--team-id is required"); + } + assertValidAppleTeamId(options["team-id"]); + + return { + arch, + releaseDir: path.resolve(options["release-dir"] ?? "release"), + reportPath: path.resolve( + options.report ?? `release/macos-distribution-report-${arch}.json`, + ), + summaryPath: options.summary ? path.resolve(options.summary) : null, + teamId: options["team-id"], + }; +} + +function formatCommand(command, args) { + return [command, ...args] + .map((part) => (/^[\w./:=@+-]+$/.test(part) ? part : JSON.stringify(part))) + .join(" "); +} + +function runProcess(command, args, { timeout = commandTimeoutMs } = {}) { + const result = spawnSync(command, args, { + cwd: projectRoot, + encoding: "utf8", + timeout, + }); + const stdout = result.stdout?.trim() ?? ""; + const stderr = result.stderr?.trim() ?? ""; + const output = [stdout, stderr].filter(Boolean).join("\n"); + + if (result.error) { + throw new Error(`${formatCommand(command, args)} failed: ${result.error.message}`); + } + + if (result.status !== 0) { + throw new Error( + `${formatCommand(command, args)} exited with ${result.status}${output ? `\n${output}` : ""}`, + ); + } + + return { output, stderr, stdout }; +} + +function assertPolicy(errors, label) { + if (errors.length > 0) { + throw new Error(`${label}:\n- ${errors.join("\n- ")}`); + } +} + +function assertFile(filePath, label) { + if (!existsSync(filePath) || !statSync(filePath).isFile()) { + throw new Error(`${label} is missing: ${filePath}`); + } + + if (statSync(filePath).size === 0) { + throw new Error(`${label} is empty: ${filePath}`); + } +} + +function findAppBundles(rootPath) { + if (!existsSync(rootPath)) { + return []; + } + + const matches = []; + const queue = [rootPath]; + while (queue.length > 0) { + const currentPath = queue.shift(); + if (!currentPath) { + continue; + } + + for (const entry of readdirSync(currentPath, { withFileTypes: true })) { + if (!entry.isDirectory() || entry.isSymbolicLink()) { + continue; + } + + const childPath = path.join(currentPath, entry.name); + if (entry.name === `${productName}.app`) { + matches.push(childPath); + continue; + } + + queue.push(childPath); + } + } + + return matches; +} + +function findSingleAppBundle(rootPath, label) { + const appBundles = findAppBundles(rootPath); + if (appBundles.length !== 1) { + throw new Error( + `${label} must contain exactly one ${productName}.app; found ${appBundles.length}`, + ); + } + return appBundles[0]; +} + +function walkRegularFiles(rootPath) { + const files = []; + const queue = [rootPath]; + while (queue.length > 0) { + const currentPath = queue.shift(); + if (!currentPath) { + continue; + } + + for (const entry of readdirSync(currentPath, { withFileTypes: true })) { + const childPath = path.join(currentPath, entry.name); + const childStats = lstatSync(childPath); + if (childStats.isSymbolicLink()) { + continue; + } + + if (childStats.isDirectory()) { + queue.push(childPath); + } else if (childStats.isFile()) { + files.push(childPath); + } + } + } + + return files; +} + +function extractPlist(commandResult) { + const output = [commandResult.stdout, commandResult.stderr].filter(Boolean).join("\n"); + const xmlStart = output.indexOf("<?xml"); + const plistStart = output.indexOf("<plist"); + const start = xmlStart >= 0 ? xmlStart : plistStart; + const end = output.lastIndexOf("</plist>"); + if (start < 0 || end < start) { + throw new Error("codesign did not return an XML entitlement plist"); + } + return output.slice(start, end + "</plist>".length); +} + +function shortenDetail(value) { + const detail = String(value ?? "").trim(); + if (detail.length <= maxReportDetailLength) { + return detail; + } + return `${detail.slice(0, maxReportDetailLength)}\n[truncated]`; +} + +function createRecorder(report) { + return function check(name, operation) { + const startedAt = Date.now(); + try { + const detail = operation(); + report.checks.push({ + durationMs: Date.now() - startedAt, + name, + status: "passed", + ...(detail ? { detail: shortenDetail(detail) } : {}), + }); + console.log(`[macos-distribution] PASS ${name}`); + return detail; + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + report.checks.push({ + detail: shortenDetail(detail), + durationMs: Date.now() - startedAt, + name, + status: "failed", + }); + console.error(`[macos-distribution] FAIL ${name}: ${detail}`); + throw error; + } + }; +} + +function verifyInfoPlist(appPath, label, check) { + const infoPlistPath = path.join(appPath, "Contents", "Info.plist"); + check(`${label}: Info.plist policy`, () => { + assertFile(infoPlistPath, "Info.plist"); + const plistJson = runProcess("plutil", [ + "-convert", + "json", + "-o", + "-", + infoPlistPath, + ]).stdout; + const info = JSON.parse(plistJson); + const errors = []; + if (info.CFBundleIdentifier !== expectedBundleId) { + errors.push(`unexpected CFBundleIdentifier: ${info.CFBundleIdentifier ?? "missing"}`); + } + if (info.CFBundleShortVersionString !== packageJson.version) { + errors.push( + `unexpected CFBundleShortVersionString: ${info.CFBundleShortVersionString ?? "missing"}`, + ); + } + for (const usageKey of [ + "NSAudioCaptureUsageDescription", + "NSCameraUsageDescription", + "NSMicrophoneUsageDescription", + ]) { + if (typeof info[usageKey] !== "string" || info[usageKey].trim().length === 0) { + errors.push(`${usageKey} is missing or empty`); + } + } + assertPolicy(errors, `${label} Info.plist policy failed`); + return `${info.CFBundleIdentifier} ${info.CFBundleShortVersionString}`; + }); +} + +function verifyEntitlements(appPath, label, tempRoot, check) { + check(`${label}: signed entitlements`, () => { + const result = runProcess("codesign", [ + "--display", + "--entitlements", + "-", + "--xml", + appPath, + ]); + const entitlementsPath = path.join( + tempRoot, + `${label.toLowerCase().replaceAll(/[^a-z0-9]+/g, "-")}-entitlements.plist`, + ); + writeFileSync(entitlementsPath, extractPlist(result)); + runProcess("plutil", ["-lint", entitlementsPath]); + const entitlements = JSON.parse( + runProcess("plutil", ["-convert", "json", "-o", "-", entitlementsPath]).stdout, + ); + assertPolicy(collectEntitlementErrors(entitlements), `${label} entitlement policy failed`); + return "required runtime, camera, and audio entitlements are present; get-task-allow is absent"; + }); +} + +function verifyMachOBinaries(appPath, arch, check) { + check("packaged app: nested Mach-O signatures and architectures", () => { + const machOBinaries = []; + const regularFiles = walkRegularFiles(appPath); + for (let index = 0; index < regularFiles.length; index += fileClassificationBatchSize) { + const batch = regularFiles.slice(index, index + fileClassificationBatchSize); + const fileTypes = runProcess("file", ["-b", ...batch]).stdout.split(/\r?\n/); + if (fileTypes.length !== batch.length) { + throw new Error( + `file classification returned ${fileTypes.length} rows for ${batch.length} paths`, + ); + } + + for (let batchIndex = 0; batchIndex < batch.length; batchIndex += 1) { + if (fileTypes[batchIndex].includes("Mach-O")) { + machOBinaries.push(batch[batchIndex]); + } + } + } + + if (machOBinaries.length === 0) { + throw new Error("no Mach-O binaries were found in the app bundle"); + } + + for (const binaryPath of machOBinaries) { + runProcess("codesign", ["--verify", "--strict", "--verbose=2", binaryPath]); + const lipoOutput = runProcess("lipo", ["-info", binaryPath]).output; + assertPolicy( + collectArchitectureErrors(path.relative(appPath, binaryPath), lipoOutput, arch), + "Mach-O architecture policy failed", + ); + } + + return `${machOBinaries.length} Mach-O binaries verified`; + }); +} + +function verifyAppBundle(appPath, label, { arch, check, full, teamId, tempRoot }) { + check(`${label}: strict deep code signature`, () => { + runProcess("codesign", ["--verify", "--deep", "--strict", "--verbose=2", appPath]); + }); + + check(`${label}: Developer ID metadata`, () => { + const details = runProcess("codesign", ["--display", "--verbose=4", appPath]).output; + assertPolicy( + collectCodeSigningMetadataErrors(details, teamId), + `${label} code-signing policy failed`, + ); + return `Developer ID Application; TeamIdentifier=${teamId}; hardened runtime; secure timestamp`; + }); + + verifyInfoPlist(appPath, label, check); + verifyEntitlements(appPath, label, tempRoot, check); + + if (full) { + verifyMachOBinaries(appPath, arch, check); + } + + check(`${label}: stapled notarization ticket`, () => { + runProcess("xcrun", ["stapler", "validate", appPath]); + }); + + check(`${label}: Gatekeeper execution assessment`, () => { + return runProcess("spctl", ["--assess", "--type", "execute", "--verbose=4", appPath]) + .output; + }); + + check(`${label}: distribution policy assessment`, () => { + return runProcess("syspolicy_check", ["distribution", appPath], { + timeout: 10 * 60 * 1000, + }).output; + }); +} + +function writeReport(report, reportPath, summaryPath) { + report.completedAt = new Date().toISOString(); + report.result = report.checks.some((item) => item.status === "failed") ? "failed" : "passed"; + writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`); + + if (!summaryPath) { + return; + } + + const lines = [ + `## macOS ${report.arch} distribution verification`, + "", + `Result: **${report.result.toUpperCase()}**`, + "", + "| Check | Result |", + "| --- | --- |", + ...report.checks.map( + (item) => + `| ${item.name.replaceAll("|", "\\|")} | ${item.status === "passed" ? "PASS" : "FAIL"} |`, + ), + "", + ]; + writeFileSync(summaryPath, `${lines.join("\n")}\n`, { flag: "a" }); +} + +export function verifyMacOSDistribution(argv = process.argv.slice(2)) { + if (process.platform !== "darwin") { + throw new Error("macOS distribution verification must run on macOS"); + } + + const options = parseArguments(argv); + const report = { + arch: options.arch, + checks: [], + packageVersion: packageJson.version, + schemaVersion: 1, + sourceCommit: process.env.GITHUB_SHA ?? null, + startedAt: new Date().toISOString(), + }; + const check = createRecorder(report); + const tempRoot = mkdtempSync(path.join(os.tmpdir(), "recordly-macos-distribution-")); + let mountedDmgPath = null; + + try { + const artifactSuffix = options.arch === "arm64" ? "arm64" : "x64"; + const dmgPath = path.join(options.releaseDir, `${productName}-${artifactSuffix}.dmg`); + const zipPath = path.join(options.releaseDir, `${productName}-${artifactSuffix}.zip`); + + check("release artifacts exist", () => { + assertFile(dmgPath, "DMG artifact"); + assertFile(zipPath, "ZIP artifact"); + return `${path.basename(dmgPath)}, ${path.basename(zipPath)}`; + }); + + const packagedAppPath = check("packaged app bundle exists", () => + findSingleAppBundle(options.releaseDir, "release directory"), + ); + verifyAppBundle(packagedAppPath, "packaged app", { + ...options, + check, + full: true, + tempRoot, + }); + + check("DMG filesystem integrity", () => runProcess("hdiutil", ["verify", dmgPath]).output); + const dmgMountPath = path.join(tempRoot, "dmg"); + check("DMG attaches read-only for inspection", () => { + runProcess("mkdir", ["-p", dmgMountPath]); + runProcess("hdiutil", [ + "attach", + "-nobrowse", + "-readonly", + "-mountpoint", + dmgMountPath, + dmgPath, + ]); + mountedDmgPath = dmgMountPath; + }); + const dmgAppPath = check("DMG contains one app bundle", () => + findSingleAppBundle(dmgMountPath, "DMG"), + ); + verifyAppBundle(dmgAppPath, "DMG app", { + ...options, + check, + full: false, + tempRoot, + }); + check("DMG detaches cleanly", () => { + runProcess("hdiutil", ["detach", dmgMountPath]); + mountedDmgPath = null; + }); + + const zipExtractPath = path.join(tempRoot, "zip"); + check("ZIP extracts cleanly", () => { + runProcess("mkdir", ["-p", zipExtractPath]); + runProcess("ditto", ["-x", "-k", "--sequesterRsrc", zipPath, zipExtractPath]); + }); + const zipAppPath = check("ZIP contains one app bundle", () => + findSingleAppBundle(zipExtractPath, "ZIP"), + ); + verifyAppBundle(zipAppPath, "ZIP app", { + ...options, + check, + full: false, + tempRoot, + }); + + writeReport(report, options.reportPath, options.summaryPath); + console.log(`[macos-distribution] verification report: ${options.reportPath}`); + return report; + } catch (error) { + try { + writeReport(report, options.reportPath, options.summaryPath); + } catch (reportError) { + console.error( + `[macos-distribution] failed to write report: ${ + reportError instanceof Error ? reportError.message : String(reportError) + }`, + ); + } + throw error; + } finally { + if (mountedDmgPath) { + spawnSync("hdiutil", ["detach", "-force", mountedDmgPath], { encoding: "utf8" }); + } + rmSync(tempRoot, { force: true, recursive: true }); + } +} + +const invokedPath = process.argv[1] ? path.resolve(process.argv[1]) : null; +if (invokedPath === fileURLToPath(import.meta.url)) { + try { + verifyMacOSDistribution(); + } catch (error) { + console.error(error instanceof Error ? error.message : error); + process.exitCode = 1; + } +} diff --git a/scripts/windows-cmake-generators.mjs b/scripts/windows-cmake-generators.mjs new file mode 100644 index 000000000..132621639 --- /dev/null +++ b/scripts/windows-cmake-generators.mjs @@ -0,0 +1,36 @@ +export const WINDOWS_CMAKE_GENERATORS = Object.freeze([ + { name: "Visual Studio 18 2026", label: "VS 2026", toolset: "v143" }, + { name: "Visual Studio 17 2022", label: "VS 2022" }, + { name: "Visual Studio 16 2019", label: "VS 2019" }, +]); + +export const WINDOWS_VISUAL_STUDIO_INSTALL_DIRS = Object.freeze(["18", "2022", "2019"]); + +export function configureWithWindowsCmakeGenerator({ + prefix, + configure, + clearCache, + log = console.log, +}) { + let lastError; + + for (let index = 0; index < WINDOWS_CMAKE_GENERATORS.length; index += 1) { + const generator = WINDOWS_CMAKE_GENERATORS[index]; + clearCache(); + + try { + configure(generator.name, generator.toolset); + return generator.name; + } catch (error) { + lastError = error; + const nextGenerator = WINDOWS_CMAKE_GENERATORS[index + 1]; + if (nextGenerator) { + log( + `[${prefix}] ${generator.label} generator unavailable, trying ${nextGenerator.label}...`, + ); + } + } + } + + throw lastError; +} diff --git a/scripts/write-release-checksums.mjs b/scripts/write-release-checksums.mjs new file mode 100644 index 000000000..747fefb3c --- /dev/null +++ b/scripts/write-release-checksums.mjs @@ -0,0 +1,64 @@ +import { createHash } from "node:crypto"; +import { createReadStream, existsSync, readdirSync, statSync, writeFileSync } from "node:fs"; +import path from "node:path"; + +const projectRoot = process.cwd(); +const releaseRoot = path.join(projectRoot, "release"); +const outputFileName = process.argv[2] ?? "SHA256SUMS.txt"; +const outputPath = path.join(releaseRoot, outputFileName); +const releaseArtifactExtensions = new Set([".AppImage", ".blockmap", ".dmg", ".exe", ".zip"]); + +function relativePath(filePath) { + return path.relative(projectRoot, filePath).replaceAll("\\", "/"); +} + +function artifactSubjectName(filePath) { + return path.basename(filePath); +} + +function isReleaseArtifact(fileName) { + if (fileName.startsWith("SHA256SUMS")) { + return false; + } + + if (fileName.startsWith("latest") && fileName.endsWith(".yml")) { + return true; + } + + return releaseArtifactExtensions.has(path.extname(fileName)); +} + +function sha256(filePath) { + return new Promise((resolve, reject) => { + const hash = createHash("sha256"); + createReadStream(filePath) + .on("error", reject) + .on("data", (chunk) => hash.update(chunk)) + .on("end", () => resolve(hash.digest("hex"))); + }); +} + +if (!existsSync(releaseRoot)) { + throw new Error("[release-checksums] release directory is missing"); +} + +const releaseArtifacts = readdirSync(releaseRoot) + .map((entry) => path.join(releaseRoot, entry)) + .filter((filePath) => statSync(filePath).isFile()) + .filter((filePath) => isReleaseArtifact(path.basename(filePath))) + .sort((left, right) => relativePath(left).localeCompare(relativePath(right))); + +if (releaseArtifacts.length === 0) { + throw new Error("[release-checksums] no release artifacts found"); +} + +const checksums = []; +for (const filePath of releaseArtifacts) { + checksums.push(`${await sha256(filePath)} ${artifactSubjectName(filePath)}`); +} + +writeFileSync(outputPath, `${checksums.join("\n")}\n`); +console.log(`[release-checksums] wrote ${relativePath(outputPath)}`); +for (const artifact of releaseArtifacts) { + console.log(`[release-checksums] ${relativePath(artifact)}`); +} diff --git a/src/App.css b/src/App.css index fe59efcb9..df674c0d8 100644 --- a/src/App.css +++ b/src/App.css @@ -1,42 +1,42 @@ -#root { - max-width: 1280px; - margin: 0 auto; - padding: 2rem; - text-align: center; -} - -.logo { - height: 6em; - padding: 1.5em; - will-change: filter; - transition: filter 300ms; -} -.logo:hover { - filter: drop-shadow(0 0 2em #646cffaa); -} -.logo.react:hover { - filter: drop-shadow(0 0 2em #61dafbaa); -} - -@keyframes logo-spin { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); - } -} - -@media (prefers-reduced-motion: no-preference) { - a:nth-of-type(2) .logo { - animation: logo-spin infinite 20s linear; - } -} - -.card { - padding: 2em; -} - -.read-the-docs { - color: #888; -} +#root { + max-width: 1280px; + margin: 0 auto; + padding: 2rem; + text-align: center; +} + +.logo { + height: 6em; + padding: 1.5em; + will-change: filter; + transition: filter 300ms; +} +.logo:hover { + filter: drop-shadow(0 0 2em #646cffaa); +} +.logo.react:hover { + filter: drop-shadow(0 0 2em #61dafbaa); +} + +@keyframes logo-spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +@media (prefers-reduced-motion: no-preference) { + a:nth-of-type(2) .logo { + animation: logo-spin infinite 20s linear; + } +} + +.card { + padding: 2em; +} + +.read-the-docs { + color: #888; +} diff --git a/src/App.tsx b/src/App.tsx index 3ba821322..6970efb16 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,34 +1,106 @@ -import { useEffect, useState } from "react"; -import { LaunchWindow } from "./components/launch/LaunchWindow"; -import { SourceSelector } from "./components/launch/SourceSelector"; -import VideoEditor from "./components/video-editor/VideoEditor"; - -export default function App() { - const [windowType, setWindowType] = useState(''); - - useEffect(() => { - const params = new URLSearchParams(window.location.search); - const type = params.get('windowType') || ''; - setWindowType(type); - if (type === 'hud-overlay' || type === 'source-selector') { - document.body.style.background = 'transparent'; - document.documentElement.style.background = 'transparent'; - document.getElementById('root')?.style.setProperty('background', 'transparent'); - } - }, []); - - switch (windowType) { - case 'hud-overlay': - return <LaunchWindow />; - case 'source-selector': - return <SourceSelector />; - case 'editor': - return <VideoEditor />; - default: - return ( - <div className="w-full h-full bg-background text-foreground"> - <h1>Openscreen</h1> - </div> - ); - } -} +import { useEffect, useState } from "react"; +import { AnnouncementDialog } from "./components/announcements/AnnouncementDialog"; +import { LiveAnnouncementNotifications } from "./components/announcements/LiveAnnouncementNotifications"; +import { CountdownOverlay } from "./components/countdown/CountdownOverlay"; +import { LaunchWindow } from "./components/launch/LaunchWindow"; +import { SourceSelector } from "./components/launch/SourceSelector"; +import { UpdateToastWindow } from "./components/launch/UpdateToastWindow"; +import { Toaster } from "./components/ui/sonner"; +import { ShortcutsConfigDialog } from "./components/video-editor/ShortcutsConfigDialog"; +import VideoEditor from "./components/video-editor/VideoEditor"; +import { useI18n } from "./contexts/I18nContext"; +import { ShortcutsProvider } from "./contexts/ShortcutsContext"; +import { loadAllCustomFonts } from "./lib/customFonts"; + +export default function App() { + const [windowType, setWindowType] = useState(""); + const { t } = useI18n(); + const appIconSrc = "/app-icons/recordly-128.png"; + + useEffect(() => { + const params = new URLSearchParams(window.location.search); + const type = params.get("windowType") || ""; + setWindowType(type); + document.documentElement.dataset.windowType = type; + + if ( + type === "hud-overlay" || + type === "source-selector" || + type === "countdown" || + type === "update-toast" + ) { + document.body.style.background = "transparent"; + document.documentElement.style.background = "transparent"; + document.getElementById("root")?.style.setProperty("background", "transparent"); + } + + if (type === "hud-overlay") { + document.documentElement.classList.add("hud-overlay-window"); + document.body.classList.add("hud-overlay-window"); + document.getElementById("root")?.classList.add("hud-overlay-window"); + window.electronAPI?.hudOverlaySetIgnoreMouse?.(true); + } else if (type === "update-toast") { + document.documentElement.style.overflow = "visible"; + document.body.style.overflow = "visible"; + document.getElementById("root")?.style.setProperty("overflow", "visible"); + } + + loadAllCustomFonts().catch((error) => { + console.error("Failed to load custom fonts:", error); + }); + }, []); + + useEffect(() => { + document.title = + windowType === "editor" + ? t("app.editorTitle", "Recordly Editor") + : t("app.name", "Recordly"); + }, [windowType, t]); + + switch (windowType) { + case "hud-overlay": + return ( + <> + <LaunchWindow /> + <Toaster className="pointer-events-auto" /> + </> + ); + case "source-selector": + return <SourceSelector />; + case "countdown": + return <CountdownOverlay />; + case "update-toast": + return <UpdateToastWindow />; + case "editor": + return ( + <> + <ShortcutsProvider> + <VideoEditor /> + <ShortcutsConfigDialog /> + </ShortcutsProvider> + <AnnouncementDialog audience="editor" /> + <LiveAnnouncementNotifications audience="editor" /> + </> + ); + default: + return ( + <div className="flex h-full w-full items-center justify-center bg-editor-bg text-foreground"> + <div className="flex items-center gap-4 rounded-2xl border border-foreground/10 bg-foreground/5 px-6 py-5 shadow-2xl shadow-black/30 backdrop-blur-xl"> + <img + src={appIconSrc} + alt={t("app.name", "Recordly")} + className="h-12 w-12 rounded-xl" + /> + <div> + <h1 className="text-xl font-semibold tracking-tight"> + {t("app.name", "Recordly")} + </h1> + <p className="text-sm text-foreground/65"> + {t("app.subtitle", "Screen recording and editing")} + </p> + </div> + </div> + </div> + ); + } +} diff --git a/src/assets/cursors/custom/minimal-cursor.svg b/src/assets/cursors/custom/minimal-cursor.svg new file mode 100644 index 000000000..2a74013ff --- /dev/null +++ b/src/assets/cursors/custom/minimal-cursor.svg @@ -0,0 +1,17 @@ +<svg width="396" height="433" viewBox="0 0 396 433" fill="none" xmlns="http://www.w3.org/2000/svg"> +<g filter="url(#filter0_d_2_20)"> +<path d="M39.9744 31.8759C38.2182 23.4825 47.2034 16.9545 54.6432 21.2183L351.11 191.127C358.653 195.45 357.401 206.692 349.09 209.248L205.199 253.511C202.971 254.196 201.054 255.643 199.785 257.599L127.77 368.534C122.94 375.973 111.523 373.84 109.707 365.158L39.9744 31.8759Z" fill="#000000"/> +<path d="M346.169 199.749L202.277 244.012C197.821 245.383 193.988 248.277 191.449 252.188L119.434 363.121L49.7012 29.8407L346.169 199.749Z" stroke="white" stroke-width="19.8759"/> +</g> +<defs> +<filter id="filter0_d_2_20" x="0" y="0" width="395.86" height="432.694" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> +<feOffset dy="19.8759"/> +<feGaussianBlur stdDeviation="19.8759"/> +<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.28 0"/> +<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_2_20"/> +<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_2_20" result="shape"/> +</filter> +</defs> +</svg> diff --git a/src/assets/cursors/macos/beachball-1__50-50.svg b/src/assets/cursors/macos/beachball-1__50-50.svg new file mode 100644 index 000000000..d6b3ae45d --- /dev/null +++ b/src/assets/cursors/macos/beachball-1__50-50.svg @@ -0,0 +1,36 @@ +<?xml version="1.0" encoding="UTF-8" standalone="yes"?> +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="768" height="768" viewBox="0 0 768 768"> + <defs> + <linearGradient id="linear-pattern-0" gradientUnits="userSpaceOnUse" x1="18.2728" y1="7.5" x2="18.2728" y2="17.5" gradientTransform="matrix(21.942857, 0, 0, 21.942857, 0, 0)"> + <stop offset="0" stop-color="rgb(98.431373%, 69.411765%, 7.843137%)" stop-opacity="1"/> + <stop offset="1" stop-color="rgb(100%, 58.431373%, 3.137255%)" stop-opacity="1"/> + </linearGradient> + <linearGradient id="linear-pattern-1" gradientUnits="userSpaceOnUse" x1="22.5" y1="9.16641" x2="22.5" y2="20.500999" gradientTransform="matrix(21.942857, 0, 0, 21.942857, 0, 0)"> + <stop offset="0" stop-color="rgb(100%, 39.215686%, 36.470588%)" stop-opacity="1"/> + <stop offset="1" stop-color="rgb(100%, 26.27451%, 19.607843%)" stop-opacity="1"/> + </linearGradient> + <linearGradient id="linear-pattern-2" gradientUnits="userSpaceOnUse" x1="22.0881" y1="17.5" x2="22.0881" y2="26.4555" gradientTransform="matrix(21.942857, 0, 0, 21.942857, 0, 0)"> + <stop offset="0" stop-color="rgb(79.215686%, 43.921569%, 88.235294%)" stop-opacity="1"/> + <stop offset="1" stop-color="rgb(70.588235%, 32.156863%, 79.607843%)" stop-opacity="1"/> + </linearGradient> + <linearGradient id="linear-pattern-3" gradientUnits="userSpaceOnUse" x1="16.7272" y1="17.5" x2="16.7272" y2="27.5" gradientTransform="matrix(21.942857, 0, 0, 21.942857, 0, 0)"> + <stop offset="0" stop-color="rgb(7.843137%, 67.843137%, 96.470588%)" stop-opacity="1"/> + <stop offset="1" stop-color="rgb(6.666667%, 56.862745%, 95.686275%)" stop-opacity="1"/> + </linearGradient> + <linearGradient id="linear-pattern-4" gradientUnits="userSpaceOnUse" x1="12.5" y1="14.499" x2="12.5" y2="25.833599" gradientTransform="matrix(21.942857, 0, 0, 21.942857, 0, 0)"> + <stop offset="0" stop-color="rgb(32.156863%, 81.176471%, 18.823529%)" stop-opacity="1"/> + <stop offset="1" stop-color="rgb(23.137255%, 74.117647%, 10.980392%)" stop-opacity="1"/> + </linearGradient> + <linearGradient id="linear-pattern-5" gradientUnits="userSpaceOnUse" x1="12.9119" y1="8.54446" x2="12.9119" y2="17.5" gradientTransform="matrix(21.942857, 0, 0, 21.942857, 0, 0)"> + <stop offset="0" stop-color="rgb(100%, 82.745098%, 1.960784%)" stop-opacity="1"/> + <stop offset="1" stop-color="rgb(99.215686%, 81.176471%, 0.392157%)" stop-opacity="1"/> + </linearGradient> + </defs> + <path fill-rule="nonzero" fill="rgb(58.431373%, 58.431373%, 58.431373%)" fill-opacity="1" d="M 384 603.429688 C 505.1875 603.429688 603.429688 505.1875 603.429688 384 C 603.429688 262.8125 505.1875 164.570312 384 164.570312 C 262.8125 164.570312 164.570312 262.8125 164.570312 384 C 164.570312 505.1875 262.8125 603.429688 384 603.429688 Z M 384 603.429688 "/> + <path fill-rule="evenodd" fill="url(#linear-pattern-0)" d="M 505.328125 201.136719 C 511.980469 216.878906 515.65625 234.179688 515.65625 252.34375 C 515.65625 325.054688 456.710938 384 384 384 C 420.355469 321.027344 398.78125 240.507812 335.808594 204.152344 C 320.066406 195.0625 303.226562 189.597656 286.253906 187.488281 C 315.691406 172.820312 348.878906 164.570312 384 164.570312 C 428.859375 164.570312 470.574219 178.03125 505.328125 201.136719 Z M 505.328125 201.136719 "/> + <path fill-rule="evenodd" fill="url(#linear-pattern-1)" d="M 603.007812 397.664062 C 592.707031 411.285156 579.566406 423.113281 563.847656 432.1875 C 500.875 468.546875 420.355469 446.96875 384 384 C 456.710938 384 515.65625 325.054688 515.65625 252.34375 C 515.65625 234.179688 511.980469 216.878906 505.328125 201.136719 C 564.457031 240.449219 603.429688 307.671875 603.429688 384 C 603.429688 388.589844 603.289062 393.144531 603.007812 397.664062 Z M 603.007812 397.664062 "/> + <path fill-rule="evenodd" fill="url(#linear-pattern-2)" d="M 481.746094 580.507812 C 464.773438 578.40625 447.933594 572.9375 432.191406 563.847656 C 369.21875 527.492188 347.644531 446.972656 384 384 C 420.355469 446.972656 500.875 468.546875 563.847656 432.191406 C 579.566406 423.113281 592.707031 411.285156 603.011719 397.664062 C 598.082031 477.902344 550.03125 546.480469 481.746094 580.507812 Z M 481.746094 580.507812 "/> + <path fill-rule="evenodd" fill="url(#linear-pattern-3)" d="M 262.671875 566.863281 C 256.019531 551.125 252.34375 533.820312 252.34375 515.65625 C 252.34375 442.945312 311.289062 384 384 384 C 347.644531 446.972656 369.21875 527.492188 432.191406 563.847656 C 447.933594 572.9375 464.773438 578.40625 481.746094 580.507812 C 452.308594 595.179688 419.121094 603.429688 384 603.429688 C 339.140625 603.429688 297.425781 589.964844 262.671875 566.863281 Z M 262.671875 566.863281 "/> + <path fill-rule="evenodd" fill="url(#linear-pattern-4)" d="M 164.988281 370.335938 C 175.292969 356.714844 188.433594 344.886719 204.152344 335.808594 C 267.125 299.453125 347.644531 321.027344 384 384 C 311.289062 384 252.34375 442.945312 252.34375 515.65625 C 252.34375 533.820312 256.019531 551.121094 262.671875 566.863281 C 203.542969 527.550781 164.570312 460.324219 164.570312 384 C 164.570312 379.410156 164.710938 374.855469 164.988281 370.335938 Z M 164.988281 370.335938 "/> + <path fill-rule="evenodd" fill="url(#linear-pattern-5)" d="M 286.253906 187.488281 C 303.226562 189.597656 320.066406 195.0625 335.808594 204.152344 C 398.78125 240.507812 420.355469 321.03125 384 384 C 347.644531 321.03125 267.125 299.453125 204.152344 335.8125 C 188.433594 344.886719 175.292969 356.714844 164.988281 370.335938 C 169.917969 290.097656 217.96875 221.523438 286.253906 187.488281 Z M 286.253906 187.488281 "/> +</svg> \ No newline at end of file diff --git a/src/assets/cursors/macos/busybutclickable-1__23-0.svg b/src/assets/cursors/macos/busybutclickable-1__23-0.svg new file mode 100644 index 000000000..3dfed7915 --- /dev/null +++ b/src/assets/cursors/macos/busybutclickable-1__23-0.svg @@ -0,0 +1,40 @@ +<?xml version="1.0" encoding="UTF-8" standalone="yes"?> +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="767.314286" height="746" viewBox="0 0 767.314286 746"> + <defs> + <filter id="filter-remove-color" x="0%" y="0%" width="100%" height="100%"> + <feColorMatrix color-interpolation-filters="sRGB" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"/> + </filter> + <clipPath id="clip-0"> + <path clip-rule="nonzero" d="M 16.578125 0 L 762.578125 0 L 762.578125 746 L 16.578125 746 Z M 16.578125 0 "/> + </clipPath> + <clipPath id="clip-1"> + <path clip-rule="nonzero" d="M 0.578125 0 L 746.578125 0 L 746.578125 746 L 0.578125 746 Z M 0.578125 0 "/> + </clipPath> + <clipPath id="clip-2"> + <rect x="0" y="0" width="768" height="746"/> + </clipPath> + <clipPath id="clip-3"> + <rect x="0" y="0" width="747" height="746"/> + </clipPath> + <image id="source-44" x="0" y="0" width="768" height="746" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAwAAAALqCAYAAACCKTPeAAAABmJLR0QA/wD/AP+gvaeTAAAPHklEQVR4nO3XsQ2AQAwEQUO9dIPol4CImJcIdqYAy+HtNt8dM3MuuAMAAKxzzbPVX/YfHgEAAH4iAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABByA9F1B9TDupt7AAAAAElFTkSuQmCC"/> + <image id="source-37" x="0" y="0" width="768" height="746" xlink:href="data:image/png;base64,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+eabqzg8IQAAgB4gAACYZMWKFdp///3129/+torDEwIAAOgyAgCAKZoh4JZbbqni8IQAAAC6iAAAwOuRRx7R/vvvr1tvvbWKwxMCAADoEgIAgKCHH35Y++23n2677bYqDk8IAACgCwgAAKIefvhhzZ07V//93/9dxeEJAQAAdBgBAEDSH//4R82aNUu/+93vqjg8IQAAgA4iAADI0gwBv//976s4PCEAAIAOIQAAyHbfffdp1qxZuvvuu6s4PCEAAIAOIAAAKOXee+/VrFmz9Ic//KGKwxMCAABoEwEAQGn33HOP9t13X91zzz1VHJ4QAABAGwgAAFpyzz33aM6cObr//vurODwhAACAFhEAALTsrrvu0qxZs/TAAw9UcXhCAAAALSAAAGhLMwQ8+OCDVRyeEAAAQEkEAABtu/POOzVr1iw99NBDVRyeEAAAQAkEAAAdcccddxACpm73bUshBAAAuooAAKBjbr/9ds2bN08rV66s4vCEAAAAMhAAAHTUTTfdpNmzZ2vVqlVVHJ4QAABAAgEAQMf95je/IQRM3e7blkIIAAB0HAEAQFf8+te/1pw5c7R69eoqDk8IAAAggAAAoGt+9atfac6cOVqzZk0VhycEAADgQQAA0FU33nijDjroID3++ONVHJ4QAACAgwAAoOuuv/56HXDAAXriiSeqODwhAACAAgIAgJ742c9+RgiYut23LYUQAABoCwEAQM/89Kc/1YEHHqgnn3yyisMTAgAAEAEAQI/95Cc/IQRM3e7blkIIAAC0hAAAoOd+/OMf66CDDtJTTz1VxeEJAQCAkUYAAFCJ6667TocccojWrl1bxeEJAQCAkUUAAFCZa665RgcffLCeeeaZKg5PCAAAjCQCAIBKLVu2jBAwdbtvWwohAACQhQAAoHJXX321DjnkEK1bt66KwxMCAAAjhQAAoC9cddVVhICp233bUggBAIAoAgCAvvG9731PRxxxhDZs2FDF4QkBAICRQAAA0Fcuu+wyHX744YSAqcuEAABARxAAAPSdSy+9VEceeaQmJiaqODwhAAAw1MarPgEA8LnkkktkjNFFF12k8fGev1SdKEnW2kXGmD/dtK6hyY0mjU23tcJ8DqsthbnddGsK64u3cra78znK7g8AGHIEAAB9a+nSpTLG6MILLyQEEAIAAB1CAADQ15YsWbI5BIyNjfX68IQAAMDQ4RoAAH3vm9/8pk488UQ1GmXq647hmgAAwFAhAAAYCBdccIHe+ta3EgKmLhMCAAClMAQIwMD46le/KmOMzjnnHNVqPW+/YDgQAGAoEAAADJTzzjtvcwgwpueN2YQAAMDAIwAAGDjnnnuujDE6++yzCQGEAABASVwDAGAgfeUrX9Gpp55a1eG5JgAAMLDoAQAwsD73uc/JGKOzzjqrisPTEwAAGEgEAAAD7bOf/ayMMfrMZz5TxeEJAQCAgcMQIAAD76yzztJ73vOeqg7PcCAAwEAhAAAYCmeeeabe+973VnV4QgAAYGAQAAAMjTPOOEOLFy+u6vCEAADAQCAAABgqH/nIR/TRj360qsMTAgAAfY8AAGDofPjDH9bHPvaxqg5PCAAA9DU+BQjAUPrQhz4kY4w+8IEPVHF4Ph0IANC36AEAMLQ++MEP6uMf/3hVh6cnAADQl+gBADDUTj/9dBljdNppp1VxeHoCAAB9hx4AAEPv/e9/vz71qU9VdXh6AgAAfYUAAGAknHbaafr85z9f1eEJAQCAvsEQIAAjwVqrd77znTLG6JRTTqniFBgOBADoCwQAACPDWqt3vOMdMsbo5JNPruIUCAEAgMoxBAjASLHW6u1vf7u+/OUvV3UKDAcCAFSKHgAAI8daq5NPPlnGGC1atKiKU6AnAABQGXoAAIykZgj4+te/XtUp0BMAAKgEPQAARlaj0dDxxx8vY4wWLlxYxSnQEwAA6Dl6AACMtHq9ruOOO04XXnhhVadATwAAoKfoAQAw8ur1uo499lgZY3TkkUdWcQr0BAAAeoYeAADQxhBwzDHH6OKLL67qFOgJAAD0BD0AALBJvV7X0UcfLWOM3vzmN1dxCvQEAAC6jh4AACio1+tauHChvv3tb1d1CvQEAAC6igAAAI4NGzbosMMO03e+852qToEQAADoGgIAAHisX79eCxYs0He/+92qToEQAADoCgIAAASsX79ehx56qC6//PKqToEQAADoOAIAAEQ0Q8CVV15Z1SkQAgAAHUUAAICEdevW6dBDD9Xy5curOgVCAACgYwgAAJBh7dq1euMb36gf/OAHVZ0CIQAA0BEEAADI9PTTT2v+/Pm69tprqzoFQgAAoG0EAAAo4emnn9Yb3vAG/fCHP6zqFAgBAIC2EAAAoKRmCPjRj35U1SkQAgAALSMAAEALnnrqKc2fP1/XX399VadACAAAtIQAAAAtevzxx3XAAQfo5z//eVWnQAgAAJRGAACANjz22GOaN2+efvGLX1R1CoQAAEApBAAAaNNjjz2muXPn6pe//GVVp0AIAABkIwAAQAc8+uijmjNnjm644YaqToEQAADIQgAAgA5phoAbb7yxqlMgBAAAkggAANBBa9as0QEHHKCbb765qlMgBAAAoggAANBhK1as0P7776/f/va3VZ0CIQAAEEQAAIAuaIaAW265papTIAQAALwIAADQJY888oj2339/3XrrrVWdAiEAADAFAQAAuujhhx/Wfvvtp9tuu62qUyAEAAAmIQAAQJc9/PDDmjt3rn73u99VdQqEAADAZgQAAOiBP/7xj5o1axYhYOp237YUQgAAtIEAAAA9ct9992nWrFn6/e9/X9UpEAIAAAQAAOilZgi4++67qzoFQgAAjDgCAAD02L333qtZs2bpD3/4Q1WnQAgAgBFGAACACtxzzz3ad999dc8991R1CoQAABhRBAAAqMg999yjOXPm6P7776/qFAgBADCCCAAAUKG77rpLs2bN0gMPPFDVKRACAGDEEAAAoGLNEPDggw9WdQqEAAAYIQQAAOgDd955p2bNmqWHHnqoqlMgBADAiCAAAECfuOOOOwgBkxECAKALCAAA0Eduv/12zZs3TytXrqzqFAgBADDkCAAA0GduuukmzZ49W6tWrarqFAgBADDECAAA0Id+85vfEAImIwQAQIcQAACgT/3617/WnDlztHr16qpOgRAAAEOIAAAAfexXv/qV5syZozVr1lR1CoQAABgyBAAA6HM33nijDjroID3++ONVnQIhAACGCAEAAAbA9ddfrwMOOEBPPPFEVadACACAIUEAAIAB8bOf/YwQMBkhAABaQAAAgAHy05/+VAceeKCefPLJqk6BEAAAA44AAAAD5ic/+QkhYDJCAACUQAAAgAH04x//WAcddJCeeuqpqk6BEAAAA4oAAAAD6rrrrtMhhxyitWvXVnUKhAAAGEAEAAAYYNdcc40OPvhgPfPMM1WdAiEAAAYMAQAABtyyZcsIAZMRAgAgggAAAEPg6quv1iGHHKJ169ZVdQrDFAIAYKgRAABgSFx11VWjFgJUWC/PtlZDAIEBwFAjAADAEPne976nI444Qhs2bKjqFHoZAmI9Au5637YYQgCAoUUAAIAhc9ll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"/> + <mask id="mask-0"> + <g filter="url(#filter-remove-color)"> + <use xlink:href="#source-44" transform="matrix(1, 0, 0, 1, -16, 0)"/> + </g> + </mask> + <g id="source-43" clip-path="url(#clip-2)"> + <use xlink:href="#source-37"/> + </g> + <g id="source-46" clip-path="url(#clip-3)"> + <g clip-path="url(#clip-1)"> + <g mask="url(#mask-0)"> + <use xlink:href="#source-43" transform="matrix(1, 0, 0, 1, -16, 0)"/> + </g> + </g> + </g> + </defs> + <g clip-path="url(#clip-0)"> + <use xlink:href="#source-46" transform="matrix(1, 0, 0, 1, 16, 0)"/> + </g> +</svg> \ No newline at end of file diff --git a/src/assets/cursors/macos/cell-1__50-50.svg b/src/assets/cursors/macos/cell-1__50-50.svg new file mode 100644 index 000000000..e7119c0b8 --- /dev/null +++ b/src/assets/cursors/macos/cell-1__50-50.svg @@ -0,0 +1,7 @@ +<?xml version="1.0" encoding="UTF-8" standalone="yes"?> +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="767.314286" height="746" viewBox="0 0 767.314286 746"> + <defs> + <image id="source-26" x="0" y="0" width="768" height="746" xlink:href="data:image/png;base64,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AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOBp/f8q/7BqXdY3sQAAAABJRU5ErkJggg=="/> + </defs> + <use xlink:href="#source-26"/> +</svg> \ No newline at end of file diff --git a/src/assets/cursors/macos/closedhand-1__50-50.svg b/src/assets/cursors/macos/closedhand-1__50-50.svg new file mode 100644 index 000000000..12dc7d189 --- /dev/null +++ b/src/assets/cursors/macos/closedhand-1__50-50.svg @@ -0,0 +1,7 @@ +<?xml version="1.0" encoding="UTF-8" standalone="yes"?> +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="767.314286" height="746" viewBox="0 0 767.314286 746"> + <defs> + <image id="source-27" x="0" y="0" width="768" height="746" xlink:href="data:image/png;base64,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AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAKT+PwrmDYf7t9yLAAAAAElFTkSuQmCC"/> + </defs> + <use xlink:href="#source-27"/> +</svg> \ No newline at end of file diff --git a/src/assets/cursors/macos/contextualmenu-1__26-21.svg b/src/assets/cursors/macos/contextualmenu-1__26-21.svg new file mode 100644 index 000000000..ff27b69ec --- /dev/null +++ b/src/assets/cursors/macos/contextualmenu-1__26-21.svg @@ -0,0 +1,7 @@ +<?xml version="1.0" encoding="UTF-8" standalone="yes"?> +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="767.314286" height="746" viewBox="0 0 767.314286 746"> + <defs> + <image id="source-28" x="0" y="0" width="768" height="746" xlink:href="data:image/png;base64,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xWWnnnqqhoeHa2xVd2699Vb95je/qbsZAADUigAAwMsYowULFugTn/jEpOU77LCD9t9//5pa1b3R0VEtXry47mYAAFArAgAAp+HhYV1xxRU67bTTnOt9y5tuZGREa9asqbsZAADUhgAAYIoZM2boqquu0nHHHefd5n3ve5923nnn3jWqJE888YRWrFhRdzMAAKgNAQDAJBtuuKGWLVumo446KridMUannHJKj1pVLg4GBgAMMgIAgBdttNFGuvnmm3XQQQclbX/88cdriy22qLhV5XvooYf0ve99r+5mAABQixl1NwBAM2y22Wa67bbbtM8++yQ/ZostttBHPvIRjYyMVNiyalx++eVasmRJ3c3oW6Ojoxxr0QJr166tuwkAakAAAKCtttpKK1eu1Dve8Y7Cj50/f74WLlzYul/Z/drXvqbzzjtPr3jFK6rY/XpJl0l6ruT9vl7SoSXvsxJHH320jj766LqbAQBwYAoQMOC23npr3X333V0V/5L0l3/5l9p7771LblX1nn/+eV155ZVV7X5Y0mPGmG3LvEiqrMEAgMFBAAAG2DbbbKNvfetbevvb3z6t/bT1lKCXXHKJ1q9fX9XuT8my7ERrWTaNCwAApSAAAANq22231X333addd9112vs66KCDtP3225fQqt765S9/qbvuuquq3e8gaZamV/QTAgAApSMAAAPoz//8z/Xggw/qzW9+cyn7mzFjhubOnVvKvnqt4lOCnqLyCngCAACgFAQAYMC89rWv1QMPPKA3vOENpe533rx52mijjUrdZy/cddddeuSRR6ra/awsy87N3af3HwBQOwIAMEB23nlnrV69WjvttFPp+95mm2108MEHl77fqmVZVuVpTI2kE8UUIABAgxAAgAGxyy676P777690rn5bDwa+8sor9eyzz1a1+xOyLPuY3AV8kUKfAAAAKAUBABgAu+22m1atWqXXvOY1lT7PzJkz9da3vrXS56jCH//4R11//fVV7f4VkuZoapE/5liWX+daDwDAtBEAgD731re+Vffee69e+cpX9uT5TjnllJ48T9m++tWvVrl7+2Dg1GKeAAAAKB0BAOhje+21l+6///6qfu3W6cgjj+zp85XlJz/5iR588MGqdv//ZVm2KHefuf8AgNoQAIA+9Td/8ze68847teWWW/b0eTfZZBMdd9xxPX3OslR8StCTFZ/6E5oSRBAAAJSCAAD0oVmzZunOO+/U5ptvXsvzf+xjH9Pw8HAtzz0dt9xyi377299WtfuPZFn2P61lRXr+CQAAgFIQAIA+84EPfEC33nqrNtlkk9rasOOOO2q//far7fm7NTo6qsWLF1e1+00kHaO0g4B92wAAMG0EAKCPHHbYYVqxYkUjfpCrracEXbRokdauXVvV7k8bHh5eqWLFPAEAAFAqAgDQJ4488khdd9112mCDDepuiiRpv/320xvf+Ma6m1HY7373O61YsaKq3f/F6OjoC+JHwAAANSIAAH1g7ty5uvbaazVjxoy6m/IiY4zmzZtXdzO6UvHBwKfmbnMMAACg5wgAQMudeuqpGhkZ0dBQ8/47H3/88dpss83qbkZhq1ev1ve///2qdv/BLMu+LI4BAADUpHkVA4BkZ511li699FIZY+puitPWW2+tI488su5mdGVkZKSqXQ9LOqHA9gQAAECpCABAS5111lk699xz625GVFsPBr7uuuv0hz/8oardn5Rl2af0Ui+/fWH+PwCgMgQAoIW+8IUvtKL4l6Rdd91Ve+21V93NKOz555/XVVddVdXuXyXpIPmLe34IDABQmeYcMQggyhijiy66SKeffnrdTSnktNNO0+rVq+tuRmGXXXaZPvGJT1R1fMUpkn6ocGGfn9vVqgDw6U9/WnvvvXfdzUDEU089pSOOOKLuZgDoMQIA0BLGGC1YsKCVU2oOOeQQbbfddlX+ym4lfv7zn+uuu+7S7Nmzq9j932RZNt8Y89HE7Zt5oIfHrrvuqlmzZtXdDEQ88cQTdTcBQA2YAgS0wPDwsK688spWFv+StMEGG+jEE0+suxldqfiUoLHzpHIsAACgdAQAoOE22GAD3XjjjTr22GPrbsq0zJs3rzE/UlbEnXfeqUceeaSq3R+TZdnnxO8AAAB6iAAANNiGG26oZcuW6cMf/nDdTZm2V7/61TrooIPqbkZhWZZp0aJFVe1+C0kfEb8EDADoIQIA0FCbbrqpbr/9dn3oQx+quymlaesUpqVLl+rZZ5+tavcfmzFjxr2edYQAAEDpCABAA2222Wb6xje+oX333beuJvxU0pqyd7r33nvrr//6r8vebeX++Mc/6oYbbqhq93+5bt265xQv9AkAAIBSEACAhtlqq610zz336L3vfW9dTfiBpL0kLa9i56eeemoVu63cJZdcUuXuT7Hu0+sPAKgMAQBokJe97GW6++679Y53vKOuJnxP0j7GmAskVVLxHn300Xr5y19exa4r9S//8i9V/pbBIVmWnS+OAQAA9AABAGiIbbbZRg888IDe/va319WE1ZLeY4y5UFJmjDlF0nfLfpJNN91UxxxzTNm77YkKTwm6oSTfeVIz6xoAgGkhAAAN8KpXvUr33Xefdt1117qasErSbGPMVzS517mSive0006r6td1K3XLLbdU+WNm87Is+x+edRT/AIDStO8vMNBndthhB61atUpvfvOb62rC3ZL2cxT/knSjpNJ/KvQv/uIv6jzAuWvr1q3TkiVLqtr9n0v6gPgdAABAxQgAQI123HFHPfDAA3rDG95QVxNWSvqQMeYSTZ1zPmaMuUzS0iqeuK2nBF24cKHWrVtX1e5dBwMDAFAqAgBQk5133lkPPvigXv/619fVhNslHWyMiU3zGZE0WvaTz549u87X3rXf/e53uvXWW6va/b5ZllUSuAAA6CAAADXYZZdddP/992v77bevqwk3STpkoud/zHPJNH4w8Gc1HhZKNTQ0pJNPPrns3fZEhQcDG0lzNXkKEGcBAgCUigAA9Nhuu+2mVatW6TWveU1dTfiapKMmpveETjVZ+cHAJ554ojbddNMqdl2pVatW6Uc/+lFVuz8hy7LPV7VzAAAIAEAP7bHHHvrmN7+pV77ylXU1YfHy5cuPmZj282Ivf+7iGwU4TNKPy27My172Mh1++OFl77YnLrvssqp2vbWkzptCzz8AoHQEAKBH3vnOd+q+++6r80ewRpYvX37KnDlzLlX4B6dc008yjR8LULr58+dXsdvKXXvttfrDH/5Q1e4/VtWOAQAgAAA98K53vUsrV67UlltuWVcTLpgxY8bKOXPmuM7247vYIwFXS3qq7Ia95S1v0Z577ln2biv33HPP6eqrr65q97tlWbZC48cEAABQKgIAULFZs2Zp5cqV2nzzzetqwnnGmE3Xr18/S+5pPt4DgPMXY8yXJV1TRQPbekrQSy+9VGNjY1Xt3j4lKAAApSAAABU64IADdOutt2qTTTapqwnnGWO20ORe/dTef9dowIKJ61Ideuih2nbbbcvebeV+/vOf65577qlq94dlWfYljY8CMBIAACjNjLobAPSrWbNm6dOf/rRmzKjtv9lnjDGv0uT5/C6ZEgtMY8wn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"/> + </defs> + <use xlink:href="#source-28"/> +</svg> \ No newline at end of file diff --git a/src/assets/cursors/macos/copy-1__23-0.svg b/src/assets/cursors/macos/copy-1__23-0.svg new file mode 100644 index 000000000..6f7085e98 --- /dev/null +++ b/src/assets/cursors/macos/copy-1__23-0.svg @@ -0,0 +1,40 @@ +<?xml version="1.0" encoding="UTF-8" standalone="yes"?> +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="767.314286" height="746" viewBox="0 0 767.314286 746"> + <defs> + <filter id="filter-remove-color" x="0%" y="0%" width="100%" height="100%"> + <feColorMatrix color-interpolation-filters="sRGB" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"/> + </filter> + <clipPath id="clip-0"> + <path clip-rule="nonzero" d="M 18.953125 0 L 764.953125 0 L 764.953125 746 L 18.953125 746 Z M 18.953125 0 "/> + </clipPath> + <clipPath id="clip-1"> + <path clip-rule="nonzero" d="M 0.953125 0 L 746.953125 0 L 746.953125 746 L 0.953125 746 Z M 0.953125 0 "/> + </clipPath> + <clipPath id="clip-2"> + <rect x="0" y="0" width="768" height="746"/> + </clipPath> + <clipPath id="clip-3"> + <rect x="0" y="0" width="747" height="746"/> + </clipPath> + <image id="source-35" x="0" y="0" width="768" height="746" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAwAAAALqCAYAAACCKTPeAAAABmJLR0QA/wD/AP+gvaeTAAAPH0lEQVR4nO3XsQ3CABAEwcMx/TeKnDugBCwc7EwBrw9vX7vPe9vnxnsAAMDvzn23+rbtePARAADgzwQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQi7BdQfUtXfFEwAAAABJRU5ErkJggg=="/> + <image id="source-28" x="0" y="0" width="768" height="746" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAwAAAALqCAYAAACCKTPeAAAABmJLR0QA/wD/AP+gvaeTAAAgAElEQVR4nOzdd7hkVZ3v/8+qczqRBFtEMSAqTRJQ20QSGpqM5G4EcwBHmRFHZ/TeOzoyhhm89/50/KnjjIIwoyDQggm6ySAgqEOLYiCI2owjgtqCTYY+te4f5xTss2qlXVW7doX363nqqZ2qatcJu76ftdbeZQRUz/T4sWWer21ba+2bJa0yxtzT6U4Vn67Lbcs8vtvXBwAAUKPuHcDI63fxb5ybb/kLJV1lrd3Cs32Zm/u8Oe/H3S73sbHnBAAAyEYAQJV6WdimCuVYce173DaaDgFP73gPOw8DoX3tZj8AAACyEAAwaMoWx7GW/pRiCCjT6h/bj04eE5ovgxAAAACyEAAwyGIFcZniOVaQt0LA5iX3KVXg+3oGYs8Zm89FCAAAAEkEAAwKX0Ef2zb0OHd5mZ6AzeUv8HOL/dh+xPbVt67T3gBCAAAAiCIAYBDktoCniumcQjz03K0Q8DSlQ0AsFKSWh/bdt50i28UQAgAAQBABAHUrU/zHHpMq+nN6BFoh4KmRbToJA6FlqSDgm85FCAAAAF4EAAySVDEcKqjd+TJFv6sYAhozt9Rz5q737Wdofe42MYQAAADQhgCAOuW0cpvA+pwi3Lcu529+kaZDwKaR5y4TBkL77C6rojeAEAAAAGYhAKAuOYVprPh350PFttuCn6sYAkJFvRssUmHAXe57T7HeAN90DkIAAAB4AgEAdUgV9MVlsW1zWuBj26a0QsBTPM/rCxahMJAbBFK9AYQAAADQNQIA6lZV8e9b1sk4+lYI2CTwXGWK/lgQkGeeEAAAAHqOAIB+SxWwZYr/4nxD7YV2qCgva5GkK621G2t2AR96ft++hIJA7D1J8Z9RaH0MIQAAgDFHAMAg6bT4j/UChIr1srbVdAjYKPP5uwkCvveS+pmUQQgAAGCMEQDQTzlFa6fFf27B3c3ffCsEbFB4Lt9lQt39CO2Xb14K/wwIAQAAoGsEANQhNazFtyxWNIfWhYrybmyr6XMCFkReIzcI5AQDFeal9vdACAAAAKUQADAIfMVtL4t/tyjvVisEzC88Z6j4zy38fe9NgWlCAAAA6BgBAP2SKjRzi3/3MTlBoNPvAohphYB5ideKBYGcUKDANCEAAAB0hACAfosVrr5tY+PhY0V2aohNL7TOCZjrPH9sCFKZUBAKP4QAAADQMQIA+qHT1n93m1ALf2i9u22vzgMo2k7TIWBO4fl9Jwbn7GNs3yX/z4kQAAAASiEAoJ86bf2PTbvLYlflaU33WisETGh2CEi1+semy77/IkIAAAAIIgCgbqFW7VCrd6j4Lxb3vjDQ7fcApGyn6XMCfIV/rOAvvgf3/YQKf8n/c5BnvW9dCiEAAIARRgDAMIgV/aFCufW3HRpvX4VWCMhp3Q/tX/E9xKYl/89FnvW+dSmEAAAARhQBAFULFZKxYjZ3uE8qGLgFdVVDgIrcEFAs/mNXCPL1WhACAABAzxEA0C/dFJO+or/h3Id6BXzFd9VaIaC1f7lBIHT+gtT+MyAEAACAjhAAUIdQgRorct3tfI/LDQH90Dox2Gr2eQC+Ij+2XCIEAACAHiIAYNClivuyt37+zW+v6RDQ1JMhwHfL2d/U0CA5y+Wsc+cJAQAAjCkCAPrBVzyGitTWslQR6yuEY63+/RwCVLS9pEustY969iOnx0Jq7wEgBAAAgI4RAFClMgVjashPKBTETvT1Ff91/M3vLOkKJwT4egGKQ5Tc4j82RIgQAAAAshEA0G+pVvhU639ojHxqmVtg99vOki631j6icDCJLZP8758QAAAASiEAYFCFWv/dbXzbpU60rcsumg4BD2t2cR/7srDUeyIEAACAUggAqFOocA1tGyp6G860r3B2W9Pr4oaAnMuE5t6k9p8lIQAAAMxSdzEEFPmG/8Ra/90x8+5jBuUcAFcrBDyk9PkAhAAAANBTg1AMYbR1UlzGClVfcev2BviK4WJxPQhaIeABhYf/5F4qlBAAAACyEQBQt9jwn1gxGyr8i+t9w38G6W/eDQGxXgBCAAAA6IlBKoYwHkLFZ2idW8z65n3LQucCDFqh+mK19wQQAgAAQGUIABgUocI0p/XfV+irMO+7TOggaYWA+5W+KhAhAAAAdIUAgLqVKRhTrf/F7UJj5wf1b74YAor7T08AAADoqUEthjB6YkN+fMtCxauvN8A3dCY0LGiQ/+Z9IcAXBggBAACgY4NcDGF0pcJAbDpnKI9b8LvTgywUAnwBhxAAAABKIwCgn1IFYaz4dJf7hvbkFsSDLnZOACEAAAB0hQCAQZDTmu8r3mPLYsOAhsGLJV1mrf2zwpczJQQAAIDShqUYwnDqtFAMFfaxdaGb7/r6w+Ilmu4JaIWA0LcGEwIAAEA2AgDq1EkR2YvbMCmGAF8vACEAAACUQgDAoPEVpr7CsWxPwLANASoiBAAAgJ4ZxmIIoyXVKp8qGMsUu8M2BKjINxyIEAAAAEojAGCQucVp7tCeVKE7rF6i6ROD7xMhAAAAdIgAgLqEislQj4CvwHfXu0N8fK3/w154vlSEAAAA0AUCAPqhV4VeTkt/a7lb/EqD/03AuQgBAACgY6NQDAGh3oHUUKFh1goB94oQAAAASiAAoF9yirxYwenbNlakpgrcnvr1r3+t3/zmN1U8dQwhAAAAlEYAQN18hV+o0AwV8TlFbKUB4J577tGee+6pNWvWVPH0MYtFCAAAACUQADAoYi397nrfkJ/Qdn0bAnTnnXdq7733JgQQAgAAGGgEAPRbboGXCgS+bUI9BX0JANJ0CFiyZAkhgBAAAMDAIgBgGPiK91DLf+7yyqxZs6bOEHCRtfaPIgQAAIAAAgAGSadFX87woVCRWokaQ8Cuki4mBAAAgBACAOoQa4nPKfp8j88Z9tPwPK4ya9as0X777aff/va3/XrJFkIAAAAIIgBg2MWG+7jLivd9cccdd2jJkiWEAEIAAAADgwCAOnVS2KUKyVrH//v84he/IAS0zxMCAACoCQEAgyB3OFCo4I9tGytY+6bmELDKWvsHEQIAAIAIABh8Zc4V8BWjtQ4BKqoxBOym6Z4AQgAAACAAYCiECsZYgekLArUXj4SAtvW+dSm1/x4BABhmBAAMi5yCMRQEuik2e44Q0Lbety6l9t8jAADDigCAYRUrHmPDgwaicGyFgLvuuqvfL72bOCcAAICxRgDAoIkV6bGCL1RohgrT2tUYAnYXIQAAgLFFAMCwKxMKUtv33e23304IaJ8nBAAAUCECAAZVTmHvWzZ0xSAhoG29b13K0P3eAQCoCwEAg6zToi40/GfghgG1EALa1vvWpQzk7xYAgEFDAMCgK9MTECs0B17NIWAlIQAAgPFAAMAwiLXclyn4Br44vP3227XPPvvUEQL20HQI+L0IAQAAjDQCAEZBmeJx4IvD2267rc4QsIoQAADAaCMAYFR1UzzWjhDQtt63LmXofu8AAPQDAQCjZKTOCWiFgN/97nf9fmlCAAAAI4wAgGGTKuhi64euGLztttu0ZMmSukIA5wQAADCCCAAYB7GTiAdejSFgTxECAAAYOQQADKuhLurLIgS0rfetSxmbvxcAAGIIABhlseJx6BAC2tb71qUM9d8AAAC9QADAqAgVdiMXAmo6MZgQAADAiCAAAEPm1ltvrTMEXEQIAABguBEAgCHUCgF33313v1/61ZoOAfeIEAAAwFAiAGDUjE1Rd+utt2rJkiV1hYCVhAAAAIYTAQCjKFbUjVTBRwhoW+9blzJSfxMAAKQQADCqxqaoqzkEfN1a+zsRAgAAGBoEAGAE1BgC9pP0TUIAAADDgwCAUTc2RV2NJwYTAgAAGCIEAIwDt4AcWbfccgshoH2eEAAAQAEBABgxt9xyiw444AD98Y9/7PdL7yfpG4QAAAAGGwEAGEE333yz9t133zpCwP4iBAAAMNAIABgnY1XQDUAIuEuEAAAABg4BABhhN998s5YuXVpXCPgmIQAAgMFDAABG3I9//GNCQPs8IQAAMLYIAMAYqDkEMBwIAIABQgAAxkSNIeAAEQIAABgYBABgjLRCwNq1a/v90oQAAAAGBAEAGDM1h4CvEwIAAKgXAQAYQz/60Y/qCgEHihAAAECtCADAmCIEtK33rUshBAAAhg4BABhjhIC29b51KYQAAMBQIQAAY44Q0Lbety6FEAAAGBoEAAB1h4ALCAEAAPQPAQCApFpDwEGaDgG/FSEAAIDKEQAAPKHmEPB1QgAAANUjAACY5Uc/+pH2228//elPf+r3SxMCAADoAwIAgDY33XSTli5dSgggBAAARhABAIBXzSGAcwIAAKgIAQBAUI0h4GARAgAAqAQBAEAUIaBtvW9dCiEAADAwCAAAkm666aa6Tgw+WNL5hAAAAHqHAAAgyw9/+MO6QsAhIgQAANAzBAAA2WoOAWdba38jQgAAAF0hAAAopcYQcJSkcwgBAAB0hwAAoLRWCLj33nv7/dKEAAAAukQAANARQkDbet+6FEIAAKDvCAAAOrZ69WpCQPs8IQAAMNAIAAC6UnMI+CohAACAcggAALpWYwg4WoQAAABKIQAA6InVq1frkEMO0bp16/r90oQAAABKIAAA6JkbbrhBBx54YF0h4Gxr7X+JEAAAQBQBAEBP1RgCjtF0TwAhAACACAIAgJ4jBLSt961LIQQAACpBAABQCUJA23rfuhRCAACg5wgAACpzww036KCDDiIEEAIAAAOEAACgUtdff70OOugg3X///f1+6WPEicEAALQhAACo3PXXX68DDzywjhCwTIQAAABmIQAA6AtCQNt637oUQgAAoGsEAAB9U3MIOIsQAAAAAQBAn9V4TsByEQIAACAAAOi/7373u4SA9nlCAACgLwgAAGpBCGhb71uXQggAAJRGAABQG0JA23rfOgAAeooAAKBWNYeAr4xACCAwAABKIQAAqF2NIeBYEQIAAGOGAABgIHz3u9/VwQcfTAggBAAAKkYAADAwrrvuOh188MF64IEH+v3Sx0r6MiEAADAOCAAABsp1112ngw46qI4Q8FpNh4A71Z8QoMJyedYRAgAAlSAAABg4NYeAr/QpBMR6BNzlvnUxhAAAQBABAMBAIgS0LfetiyEEAAC8CAAABlaN5wS8VtJp1to1IgQAAEYMAQDAQLv22mvrCgFvlnQ6IQAAMGoIAAAGHiGgbblvXQwhAADwBAIAgKFQcwhgOBAAYGQQAAAMjRpDwFtECAAAjAgCAIChcu211+qQQw4hBBACAAAdIgAAGDrXXHONDjnkED344IP9fum3SPoiIQAAMMwIAACG0jXXXKODDz64jhDwVhECAABDjAAAYGgRAtqW+9bFEAIAYAwRAAAMNUJA23LfuhhCAACMGQIAgKF3zTXX6Mgjj9TDDz/c75cmBAAAhg4BAMBIuOyyy3T44YfXFQK+4AkBJjBPCAAA1IoAAGBk1BgC3qb2EOD2BuSEACWWSYQAAECXCAAARsqAhAC38M8NAa1jMiEAAFAZAgCAkXPZZZfpiCOOqCsE/Ju19k4RAgAAA4oAAGAkXXrppXWFgLeLEAAAGGAEAAAja4hDgEQIAABUhAAAYKS1QsAjjzzS75d+u6R/7TAEtI7NhAAAQM8RAACMvEsvvVSHH354HSHgBEn/uvHGG69Tb0KARAgAAHSJAABgLNQZAtatW/dPhRBQLPbLhgC3R8A3LWeaEAAAmIUAAGBs1DgcqBgCisU/IQAA0HcEAABj5ZJLLiEEhLfJQQgAgCFHAAAwdmoOAf+YGQLcW9UhoAxCAAAMMQIAgLF0ySWX6Mgjj6wjBJwYCQFuIOhnCChb1BMCAGBIEQAAjK2LL7540EJArPgnBAAAeoIAAGCs1RwCPu6EgNwhQVWEAHd9LkIAAAwZAgCAsVdjCHiHEwJyzwuQeh8COj0puJPtAQA1IgAAgGoPAR8qGQJCXxJGCAAAJBEAAGBGjSHg5HXr1n2wixDg6xkgBAAAvAgAAFBw8cUX66ijjtKjjz7a75d+z7p16z6o8AnBZUJAqPBPFfy+ZYQAABgxBAAAcKxatUpHHnlkLSHAWvtXCl8SNDcExAr/UAgIXRnInc5BCACAAUYAAACPAQgBoUuCEgIAAF0hAABAwICFAPdSoYQAAEBHCAAAEFFzCPhLdRYC3ABACAAAPIEAAAAJq1atquvE4L9OhIDQECFfACAEAAAkEQAAIMvKlSsHMQTEzhMgBAAAvAgAAJBpxEOAPNOpeUIAAAwhAgAAlLBy5UodffTRdYWAk9TfEOBbF9o2FyEAAGpGAACAki666KK6QsB7KwwB7pAfQgAAjCgCAAB0YEBDQLcnBhMCAGAMEAAAoEMXXXSRjj/+eD3++OP9fun3WmvfpfYQ4F4mlBAAAGhDAACALlxwwQV67WtfW0cIeF9FIUAiBADASCMAAECXRiwEuF8mpsK8795d71uXQggAgD4iAABAD9QcAt6pvBAgEQIAYOwRAACgR2oMAX+TGQKKhT0hAADGFAEAAHroggsu0HHHHUcI8E/nIAQAQMUIAADQY+eff36dIeAvlB8CJEIAAIwdAgAAVKDGEPC3JUJAscgnBADAmCAAAEBFzj///Lq+J4AQAAAIIgAAQIW+9rWv6fjjj9f69ev7/dJ/a619h3obAor3hAAAGFKTde8AAIy6r33ta5Kkr371q5qc7Oth9/3WWhlj/k1SU08W/ZqZlzNvlVdwF7ezM/emsLx4L2e9O52j7PYAgAgCAAD0wYCEAGm60I+FgFyEAAAYUgQAAOiTAQ8Brd4BQgAAjDgCAAD0Uc0hwBpjvjAz74aAhnOfixAAAEOGk4ABoM9qPDH4A9baEzX75F93OvUFYbEThDkxGACGAAEAAGqwYsWKOkPAG0QIAICxxRAgAKjJihUrJElnn312v4cDnTJzTsCXZ+YZDgQAY4QeAACo0YoVK/S6172ujp6AU+gJAIDxRA8AANTsvPPOkySdddZZdfQEWGPMV2bm6QkAgDFAAACAAVBjCPiHmeFAhAAAGBMEAAAYEISAWevd6RyEAADIQAAAgAFScwiwxpizZuYJAQAwojgJGAAGzHnnnafXv/71dZwY/BFr7evEicEAMNLoAQCAAXTuuedKkr7yla/0uyfgIzPDgegJAIARRQAAgAFFCJi13p3OQQgAAA8CAAAMsJpDgDXGnD0zTwgAgBFBAACAAXfuuefKGKMvf/nL/Q4BH53pCQiFgFYhTwgAgCFCAACAIXDOOedImu4JmJiY6OdLx0JAa14iBADA0CAAAMCQqDkEWGPMV2fmCQEAMMQIAAAwRGoMAR+b6QkgBADAkON7AABgyJxzzjl6+9vfrmazTJ3dEx+z1h4n//cEFL8bgO8JAIABRg8AAAyhM888U5J0+umnq9Hoa1vOx2aGA50zM09PAAAMGQIAAAypGkPAx2eGAxECAGAIEQAAYIideeaZMsbotNNOIwS0T+cgBAAYOwQAABhyZ5xxhiTVFQKsMebcmXlCAAAMAQIAAIyAGkPAP870BBACAGBIEAAAYEQQAmatd6dzEAIAjAUCAACMkDPOOEPGGH3xi18kBLRP5yAEABh5BAAAGDFf+tKXJKmuEGCNMefNzBMCAGAAEQAAYATVGAL+aaYngBAAAAOKAAAAI2pMQkBLFSEAAEYSAQAARljNIcAaY1bMzFcVAnwFfq9CAIEBwEjq66cBAKD/vvSlL+nEE09Us1mmzu6JU621yzRdSDcK943CvHGmc27yTMuZLi4LbZOjzLYAMBQIAAAwBk4//fS6QsA/WWsPU3uhTwgAgJoQAABgTNQUAoykT1lrX6P2wr9MCFBimUQIAIAsBAAAGCM1hoB/LoQA35CgWAiQnvy86iQE+PYntU3qcQAwtAgAADBmTj/9dL3jHe+oMwQUi39f4e+GgNZnVTEESPkhwFe4U8wDGFtcBQhAT+2yyy7afPPN694NJKxZs0Zf+9rXtHz58n6+bCsEWGPMhZq++k+rwG8W7otX+tHMfKOwvTsvpa8OJIWv6MOVgQCMFQIAgJ5597vfrU996lP9vtwkhouR9OmZ7wn4ttoL+dYfT7F7wg0IUvsXgLWWua8VuhyoO08IADA2+JQG0DVjjE499VR9+tOfpvhHjlYI8J0YnBoGFDtBWM4yd7mcde485wMAGAv0AADoyoIFC3T22WfriCOOqHtXMFx8PQFSe29Aa0iQFC+6y/QIpHoGyrwHegIADB0CAICObbDBBvrmN7+ppUuX1r0rGE6tENA6J6DY0t8sbBP7tmD3fAF3WejbgmP7RFEPYKTRVw+gIxtttJEuvPBCin90y0j6/621hyp9JaDU5UHlrHOXu68bmmd4D4CRRg8AgNI23XRTrVq1Sq961avq3hWMhlYIaA0HKhbtsSsDuUN6Qve+1+vVUCB6DAAMHXoAAJTy1Kc+VZdeeinFP3ot1BNQpkcgdVJwmVZ+TggGMLLoAQCQbYstttBll12mnXbaqe5dwWgykj4z0xNw4cyy4knB7sm9vnH+vhOBQ+cDKDFP6z6AkUQPAIAsz3zmM3XllVdS/KNqrRBwqPJa/yX/eQDF+9hr5WwHACOFAAAgaautttK1116rHXbYoe5dwXjwhYBi4S9nmZztuh0KxAnBAEYaAQBA1KJFi3TttdfqBS94Qd27gvFiJH228GVh7onBbpEeav13rwokZ31oHgBGFgEAQNB2222nq666Ss95znPq3hWMJzcE+HoAcoYCyXmM73Viy3LCAQECwNAgAADwevGLX6xrrrlGW265Zd27gvHWCgGHFebdcwIkf4t/bEiQO50zn7OvADDwCAAA2ixevFiXX365Nt9887p3BZCeDAGHyz/0J1Xou/O5vQBl1pfdDgBqQwAAMMvuu++uK6+8UgsXLqx7V4CiYk9AbDiQO1/mhGDfPACMHAIAgCfstddeWrVqlTbZZJO6dwXwMZI+N9MT0Jp3x/WHxvkrsjy2nkAAYOQQAABIkg488ECtWrVKG2+8cd27AsS0QsARCrf6u/O96gUgDAAYCQQAADr00EP19a9/XQsWLKh7V4AcxZ6Asr0Anb4eAIwMAgAw5pYvX64LLrhA8+fPr3tXgDKMpH8p9AS0lvl6ARRYFnpeABhpBABgjB1//PE666yzNGfOnLp3BehEKwQcqXgvQM73AuReEpSAAGDoEQCAMXXCCSfoy1/+siYnJ+veFaAbxRAQWp8KAr7HdLtPADCw+OQHxtA73/lOfe5zn5MxtdUpV0paW9eLYyQtsdZeYYy5r7DMFqbNzHzoXs50TM52uc8FAH1HAADGzPvf/3594hOfqOvlpyT9nTHmeTPz1rlXYj5WUKWeYxCU2X/fOussC81bZ1nq1nTuW9PFm2+Z7zYVmfdNh9bHlvn2pzXvFvTuz80nVKx3W8QTAgAMJAIAMEY+8IEP6NRTT63r5f8k6VhjzFGaLtRacov9VAgY9gAQW+8W+sVp3zo3DKRu7mt1W/SG5n3ToRN45VlevMVa90P7FGr5D72PXvwNEQIADBwCADAmPvKRj+hDH/pQXS9/p6T9jDHvUbzlWppduCpjubs+NF+X3P3I7R1ITftCQNNZFusFcLfrhm/Mve9E3WJRXlzubhO6SbML7eJr5AwDKvN+OvmZEAIADBQCADDijDH65Cc/qfe85z117cIaSfsaY96r8DCV4r3kLz5jASBUFJfV7yKtm3CQ+rmFivlQGPA9PtVb0KlUL4GvqA89j683QJ5lqedpPSbUu+B7DEU9gKFEAABGmDFGn/nMZ3TSSSfVtQu3SVpqjPmA4oVm7D60zF0XWu8zqIVb6v25y1LDd1IFvfRkIHDH/8eCRCwMhJbnXmazOF/2LHVfUU6hDgAOAgAwoiYmJnTaaafpzW9+c127cKumW/7/h9ItzWXuFZj3LR+kwq/MvqS27TYAuPO+oUDudOpxsV6bnPcUEgoCZc8DiD0m9tqD9DcEAD1BAABG0MTEhM4880y9/vWvr2sXfiRpf2PMB9U+5CQ2HZp3p3tRaPa7sOv29VLvsUyQ8vUCxIKAr2cgtB+pfS/72JayJwG75wGELvdJkQ9g7BAAgBEzd+5cnXPOOTryyND3IlXuh5ou/j+kJ4tGKS8ApIJAqPAvOxxl0Au+MvudG5ZSAcC9DxX+vl4CX1gI9QoMCk7oBTC2CADACJk3b57OO+88HXbYYXXtwo2SDpgp/nNONk0NV5HnPrQstE0Zg1DY9aJVPffnGgoAoZ4AX7Hvvk6s6I+t7/Rn348r+XAiMICRQgAARsSGG26ob3zjG1q6dGldu3CtpEOMMf+g2S3GsR6AnB6BMoX/MLb45+xbbJtY6MkNWbHLhMbOCYiFgjK9Ab34/fTiS7ta+0JhD2CkEQCAEfCUpzxFK1eu1G677VbXLnxH0qFO8R8rLt35nCBQvHenQ9u4UkXdMBR9Ob0cZXsCistCN9+woNTQn9jvOBUKAAAVIQAAQ27TTTfVxRdfrFe+8pV17cLFko4yxnxU5a8aI+WdIyDPcgW28T2mbt3uQ5leglQwCv08U18WFjovwBcIOg0HsdcPhYVB+P0CwFAhAABDbPPNN9dll12mXXbZpa5duEjSMcaYj8lf/PtOLpXixV2qNyA075uOLYstH2SxsBNantsjEPvdxUKAr9gPPW9Ibm8AQQAAukQAAIbUFltsocsuu0w77bRTXbuwQtLrjDEf1+wWX6m7oSGtx7vLcublWR8zrEVjbgEcKvzd+1jB7hb8Ut5VgJqedd30DOS8XwBABgIAMISe85zn6IorrtA222xT1y6cI+kNxph/VLyFOFQ8KrJdqgegTNGf26I8iDrZr9h7z+0FKE77egN8v1dfj0Dob8F9/l6EtEH9HQLAQCIAAENmq6220hVXXKEXvOAFde3CWZLeVCj+Q4V/rGjMLf7dZXLWh+aLy33Tw6AXha/vZ9eaD/UKhH4HsdpwMNEAACAASURBVFvqSkG+noHUslggSb1fAEAEAQAYIosWLdIVV1yhZz/72XXtwhdWrFjxruXLl7eG/YQKQMlf5PmWx4o+FR7nToeKWHe67LJh0+l7jYWoskEg9rfgK/B9r+97LXeZb99i77kqXCYUwFAjAABDYvvtt9fll1+uLbfcsq5d+PycOXO+sX79+o8rr+iLtQiXHR4SKvxThWCsSBulAq5sCAi1/rfufdOdfldAqsU/9eVi7v7Hfve+9zFKv2cA6AkCADAEXvKSl+iSSy7R5ptvXtcu/B9jTFPSErUXbzmFf6yHQM6y4n2qVVqFeXe62+K/3y3KVbxmqpj2bRNraQ+FgFCgyz0J2H3OWED07XvO79x9LACMLQIAMOAWL16sSy65RAsXLqxrFz5hjJFmF2axAJDbMxBbLoVDgLvMN+9um2MUCsPUe8hpNXfvfQW6L7jl9Azk/g3E9iH0nlLLOtkGAEYSAQAYYHvssYcuuugibbLJJnXtwoeNMQuU36LbTc9AqMU3Z9o3X1zumx5FofeXUyTnFP/F6dDvq0wAzD1BPPS6ctaV/RmUWQ8AI4MAAAyovfbaSxdeeKE22mijunbhg8aYjRQu+FPLO+0R6DYESP5ibpwKvDLv3/czK/OzLxb+rftUoR86LyAUQnwFfijwhd4fAGAGAQAYQAcddJDOP/98LViwoI6Xt5L+2hjzDIUL+1ix34thQTmtwMX9dafdom/cA0FL6ueSClSpFvnQLacXyH3O0PLQ/vpCQux99ApXBAIwdAgAwIA59NBDtWLFCs2fP7+Ol7eS/soY8yw9WcznFvyx4UG+IBAKAKETgUMtv6miNfQ+O1kXY9Kb1Cb3/eb+XFMhQMo/LyAUFkP74AsCqffVDQp8ACOHAAAMkGOPPVZf/vKXNWfOnDpefkrS240xi9Re1Kda/7vtFfAV/6EiUwoXhnKWybPcVUVxN0xhIDYfajkvFvmteV8AaK3zDQ8KTcszXfx78K2P/V0AABwEAGBAHH/88fr3f/93TU7W8m85JektxpjtlC76U/Ox5aGAIM0u8tyCMHRFoJyWfwJAWKxY9v08U4V3qncgFQRCfyvu8/lev2puT0Bxnl4CAEOFAAAMgBNPPFGf//zn1Wg06nj5xyQdb4x5qcoX/allOT0BoXnfTZ5peZbLWe5uO6i6DQ5lCtHQdqFQEOt1ySn+Q4Eg1ZIfe92c95OLIh7A2CAAADV717vepc9+9rOaudZ+vz0m6VhjzMuV3/KfGwZSPQSp4j/nHIDcXoAyyzpV9hc4iL0EqV6UnHv39+X+HlM9PrHeHt9+5SwbBMXf9yDuH4AxQgAAavSBD3xAp556al0v/7CkI4wxeym/wC+7LtQDEBsLLs0uDnN7AXKDQFE3hVhVBXzO8/bqtUOFvm9ZTs9ArPD33Vrb5Sxz96nMvnciZ4iPu03uaxIGANSKAADUpObi/yFJhxtj9lb5Ar+4zVRg29gy66z3tfwWg4DUXky607754vKY3AKs1wV/r56vF88T+hmkegJa06nemZww4At5oceE9rEXxXRsKJBvXezcgNzXayEMAOgLAgBQg49+9KP64Ac/WNfLPyjpNcaYfRUfrlMs7qeUDgOd9ga4N99yKRwC5EynWoarKLJSRXinRXrO8/by/aSK6VQAcOdjvTdlQkBoH2LbxRRb62Mt/b51OSGgU4QBAH1BAAD6yBijT33qUzr55JPr2oX7JB1kjDlU5Vv8y9x8gcKd9xX8oVDgKx7lLCvOS+kCqtOisVOhx3fyvLHHdLOfOa3pqZ+3u74ZWF/m5j5vr4vj3AI+NwT0opAnDACoDAEA6BNjjD7zmc/opJNOqmsX7pV0oDHmNeq8mE/1BOT0CLg9ATnFf6gIjAWB4nLfdDd6WYD7tu/1CcU5z5fbU5LT++Ir1nN6BlLPX1Ynw3Pcsfyh5wiFAHm2d5eVRRgA0FMEAKAPJiYmdPrpp+tNb3pTXbvwe0n7GWOWqbsW/ynP432hINQDEOoJSBX/ZQr/sq3EoSKuE9228ncTBqoIAallvtAQKuBjv0dfL0HOfSeKBbw80+527rT7HL59qfKSooQBAF0jAAAVmzNnjs4++2wdc8wxde3CPZKWGmOOVfkhP6kW/6nM58wt/HOHALnTypzulVChnvNanRTq3VwZqNMW6JwegVRrfc7vLrYs9Lpl5f4M3HBQfEwsBPie232OXvQEuKp4TgBjgAAAVGju3Lk699xzdcQRR9S1C7+RtK8x5o3KH6YTK/pzAoHveW1kulj8y7PcN2xEzrLivJRfEPmGapTlPq6TYj3VqtvJycC9Lg5jRXmsJ8CdzwkCOa8RkztMx3cycJlv/A2dTBzan+JjpN79bugVAFAKAQCoyAYbbKCvf/3r2n///evahTs1Xfy/Weniv2zR3825AG5LvzstZ5kK06nW/1grdae6LehT26d6DkLFY1G3xWUnjwv9rGO/l1Tx363YUJ5Y4V+cdu9VWF/mNXL3zX3+btErACCJAABUYMMNN9Q3v/lN7bvvvnXtwq81Xfy/VeECvJuiv9XSXzYI+O5DIcDtBYgV/zmFf25B1Ok4freoj20XalH2PV9uYelumyP2vGWDQJlegNhz5bxurCXe3Sa38Heni8+XEwRS4czdV9/+hh7TCYIAgCACANBjT3nKU7Ry5Urttttude3CbZou/v9CecW/73r/xZN93WWxoOBr8Y8N/WnNy1nuFv1uKFBgXp71nSg7Dj+nqE9dXSb2Grnbudum3kfZn09OqPIFglg4KFP0u8+VWtZJCMiZL75mqmXf3b4oFQYIAgAqQQAAemizzTbTqlWr9MpXvrKuXbhF08X/u5Rule+0yA+1+qeuBuS2/ocK/mIgkMLFYqr474av4E4V9amTQXNal0P7EBsy5HuenJb9Ts95iP2MQ70BqcfFhtDkjONPFfa9CAGxZe4++fbV3d6V2/PQKc4TAPAEAgDQI09/+tN16aWXapdddqlrF26StL8x5mT1pvh3ewlyCv5YAPC1/ruFvy8QhIr/otxW5LJCxWHxdVvb5bTS57YSG+feLfhTxX5om9C+9ELsdxMTC1fuz7ebIj+1vrVNaN59T6kgUFzmLvetL6r6fAF6BYAxRwAAemDhwoW6+uqrtf3229e1C6slHWCMeY/yCv/cFv7QOl+AyBn242v5jxX+vqLfLfZjrc6dKtOyHyr+3f0KFfW25Dr3OUMt+qFwUqrl//ibd/qdJFlj5xprNpx5gg2t5sydeZnNWttO2Kn7p8zk+mZzqtloNP8sSZPNxqPrG82HJOmutQvvv3rJ1c/3vM+iTlrvfe+zWMg3I9umCv/cIBD7nfuW+XocXFUPEaJXABhTnXYBAzkmZu5bH6LFad+tMXMrTru3icK9Oz2h6VAbmp+UNGGtfaWkD1TzlmvxXUkHG2P+VuUK/5xi3z3Zt5vzANyC31f4+4b/qLDenZZnu06lWsxz17vrOtm2zGNS62Ytf/3PF9+nqamt1TTPlGk+zVrzNGO0sGm00FgtlOxCGbNQVk+TtFng+brxgKzWWqM/GNk/Gpm11tq1Mo21kl1rrVlrjX5np+ydd9+36X9fveTqZyv9d9DJdOjeDRGpsBlb5k775nO3KfP4bhAEgDFAAECVCADVu1bSIcaY96v3rf45PQFlhv+4Rb87rcK9r/U/JwCE5l2pAt+3LFbY50yH1uU+b9Zjjrtx8Vozuf75jQk9r2m1tbF6noyeJ7VuZr6Gx3pJv5HVGmu0piHz66bRGjvV/HXD2DVn7XJzsXVeHU4rsjz3PrQsZ7krFh5i2+Y+JhdBABhhBABUiQBQraslvcYY8wF1Xvz3ovDv5uTfYuFvC9NSuGjzzReXp6SOe50W5qWL9YxtgstPvHHxr++fXL/INMwOstpR0mIZLZbVMzUujNbJ6heS/blkVsuYn9nHGj/66stWz1W5IKDINvJs149egdRjUo8nCAAIIgCgSgSA6qySdJQx5n+pvaW901b/MoV/r6//L/lb/3Na/bspULoZ1pNTtMeWZT/X6773yt/b+Y/saox5qZXZRcbuJKtF4jyukF9b6ScN6SfW2h8+Pmfu9St2/E8pL1DGlucU+Tl/p7G/3dSyOoIAIQAYMQQAVIkAUI0LJR1jjPk7VV/855wEXGYIkBsAYjdFpotiy5Jj4j3LyrTGd7IsGRBe+8OXPNyYtLvL2j0kLZb0CklzPPuMXEa/k9V1kv2ujL1u0U4/uekUo81Vvlcgd14Zy3KWh5YRBAB0jACAKhEAeu88Sa83xnxI7QV22RN9Y0N+ivc2sG2Zlv9eBAApXCyVVXacfZllZXoAjDbRn197zS5bNYx9tTFmt6bV7kZ6VvIdoFv3SfZ6q8b1xkxdu9Fjc2/4wstWb6b6w0DZkEsQAFAaAQBVGrQAMCFpcogDwFclvdEY8/fKK8JTxX835wL4XsMt9n1hIBYCpPZhQPLcKzCf0m2rf6q4z37Msp/tuG7O1Jx91bRLrdEhFPwD4SHJXi/pQk1NfOPsl970sMI9UfIs72S++HxlegtCy0L/EwQBALMQAFAlAkDvnLZixYq/WL58+YdVvvAvFvxW6eK/0y8A8w35CQWAYhBQ4T6n9d/lLk8d17pp8e94/rgf7bKRaehQa+1SyewlhvQMul9Zq8uNsRfet8FDl67a5o5NZpbnhIFOw4L7GN927va++X4GAUIAMIQIAKjSIAWAxsz0MAaAf1uxYsVJFRf/3V760y3+fYV/ztAfeaaLclpEQwV+apsyrfvJ4n/Zz3a8e+7jk/s3rVkmYw+U9HTPfmA4PGiNvVzWnD/xyPxvnvWq78+bWd5JGEjdu49XZLvUPEEAgBcBAFUalADQKEwPWwD4lzlz5lywfv36vdRd8Z8a3tPtNf9DPQC+4t9t9Y8N+1FgWa7UEJ/ifG5rfjAMLDtv2V2Ti27f1UjLrHScpM072GcMtketdFnD2BXm4QXf6CAMdNsrMMhBgBAADAkCAKo0CAHAXTdMAeB/G2MeU7iFPVb857bs96r4LxMAUq3/cqa7KSpyAkDZ4n/Wc55idddtP95lt5mi/1hJW3Sxvxguj1jp8oaxK+bPe/TrX9ruttaXrIV6ADrtJQg93t0mNU8QACCJAIBq1RkAGvL3BgxLAPiEMeZxzS6mW8N4clr9uy3+c64o5Bb97rSv8Hdb+3OKIAXmXbkn+aaG+mQV/8ff9OKnNY19m6TlYngPpAdlzbea1nxph5fcdOUpRgtnlqfCQJmgEJqX0v8vnQQBQgAwoggAqFJdAaA45GcYA8DfG2PmKm+cfSfFf2ybUPGfc+Kv9dy7Q36KxUsxDPju3ekcnZzgG2rdb5s+7NZt1y14dN6RDekN1pqlJfcN4+O/JZ01tX7yX1a8bPXDM8vKDhHKCQax53K3880TBIAxRQBAlQYxAExYa1+lwQwAVtL7jDGta5F3UvznXtozFRBs4DVCASBW/OcM+QkVN+60K9SyX5wPFf+5w3zM8at3ekaz0TjRyB5npY0j+wMUNY10ZVP2C/fct/DrVy+5erOZ5Tm9Ar0cLuSuDz0uZ/tOEQSAAUIAQJXqCADJKwINaACwkk42xmyu+NCanFb/2LKcgGA9r5ETAHxDgFLFf6zVP6dgSI3xD4WAZKv/6773ygfWz3/ojcY23i7ZXTL2BYj5b2Psl+yEPe2cnX76qNKFf5n1vnt3e3nWhx6Xs30nCAHAgCAAoEoEgDxNSScYY7ZSe0t6cTo2NKfK4j907kGq9d8dAhQLAFK6EHF1Uvw3nO3a/i5ff+Niu37i8XdIerekzQT0VlPSStvUx89dfPOv1HnxTxAA0DECAKrUzwAQOul30APAlKS3GWOeL38xHbv1qvgvfpOvb7vQvrjDfNxl8tz3o/hPDfnxTi+76cVbNEzzvbJ6rfiSLvTHdyX76eYvtrtgxfIVC/Xk33/s/6XXQ4Vc/QoChACgRgQAVGkQAkDbcmvtrhqMADAl6c3GmBcq3vLva4lPjeXv5Ft/fdvEiv/QWP9i0Z9q1XSX5+jdib1P1YPLL9/pFcaYkyUdmvn6QI+ZOyT72ebDG35hxW43bKx4sd9JEEj9v8WK+lhIiG2TgxAA1IQAgCpVHQDcIBD6DoBBDACPSTrOGLOT/C3poaI799KebvGf6iXoZfHvDgGSyhUjIb1o9TeSzIk3Lv7jusbjb7RG75O0fcZrA/1wj6TPNKfmfHbFy1bPV/vwORXuQ+uqCAL0BgAjhgCAKlUZANyx/6kA8MT6AQgAj0pabox5idoL5rqK/9j1/esu/n3FfWu61HCfU6x+f8uPdjpa1nxc0jaJ1wXqYXW/NeZfbHPyn2aCQOz/KhUEcv4Pc3sA6A0ARgQBAFUalAAwKwzUHAAeknSEMWZXhQvo1Jj/nOE8o1D8xwr/0H208LdN81EZbRt5TWCQrJXs/3m4Ofcz337Z6tbQoG6CQKzwD/2PuvNV9gYQAoA+IQCgSlUEAN/wn2IAKAYB73kBNQaAByUdZozZXe2t/rEAkHP1n7Lf+lu2+Pf1VHRb/LvTLaHCvzWfP87/qXpo+WU772Vl/8EYvcTzWsAw+IOk/2+DP9//6TOXrHmKZv9PFQt/dz4VBFL37nRom9S2ZREEgIoRAFClfgeA2JeBFYcA7ab+B4A/SzrYGLOP2ovlnOI7VNQPUvEvtRcjOcMLfMehTsb5N4rLjl39osXWmH+Q9FLP8wPD6DfW6mOb2blnfOFlq5+q3vUIdDosiN4AYEgRAFClfgWA1HcBzAoDNQSA+yQdaIxZqvYPbLfozh0C1Mm3/sZ6FDot/uUsk8JFhTzzRaEgULbw39Qa80lJB0ZeCxhmtxtr33fu4p/eqPYA4Bb+ZYr/Xg4LIgQAA4wAgCr1MgAYxa8AlBr7X7wK0O7qXwC4V9IBxpj9ZuZDBXWsEPcV8INQ/Kda/ov3CsxLnRf+s/5+ll2/46Nm/sSHZe1JevJvDxhll5um+etzX37zH+VvXFBivhfDgsr0BjAkCBgQBABUqRcBoDjtCwCh4j94UnAfA8DvJS01xhyq2YVyqKBuzfuK9NRQn05O9k0FgJyW/+J9qvjPUbrwP/HGxX+81zz2Fsl+XDJP6+A1gWH2uKQzJu3cv/vqy1ZPyh8EyhT/9AYAY6CR3gSoTeqAn/MBFBuOUqW7Je1jjDlE6QI81ZpfbPUftOK/TEt/TCvo+Yr8hme6Icks/+FOO96rx26S9G8U/xhTcySduL7x2G3Lbtz52L2v2nut/EMnvf9DmdPS7P9JOdNylrt85/SURYMl0EP8Q6FK3fQA+IYBuZcA9Q0DmlS4V6DVA7CHqu0B+C9J+xpjjpqZD7XI5Qy/KXvt/9i2uQGgTPGfM1wgxFcwpK7s05CkZat33FAyn5XMARmvA4yTn8g23rXiZT/+peK9dZ32DITm5ZlP9QbQEwDUhACAKtURAFK3hrV2T1UXAO7UdMv/0Qp/uPqK7NAt59r/Vu29A1UV/6FCQc76mFjh35r2tjbufdXef9h84z+dJGM/LmnDxOsA48pK5it6dOo9K3b72TzN/l+Wwv/PZYYFxXpcQ+t924W2SSEIAF0gAKBKnQSAUPEfOg+geP3/0HkAs9ZVGABu13TL/7Ez86EA4Gv9L07nXLIzdiJwr4v/0K34Ht3364oV+8Vp75V9JJmjV7/oaQ2Z06zVyz3PD6Dd76w1f3X+y2++Xun/5VDPXiwchOYVWe+uCz0uByEA6BABAFWqMgD4wsCkMgJBRQHgVk0X/6+Vv4Ust/hP9QLkDPNJFf++1+y2+PfNu3IK/7a/i2XX73qvnXP/B2T0PyXNTbwGgHYXmsmJv1jx4h/n9OrlBALfdPE+NJ3qDSAEAH3CScAYFjkfErEhKbEPoG79XNPDfpYrXXjntO4XW/hzTwTud/Hviv1cQ+P5Y6HPSGocs3rnF9i5998kow+L4h/o1KF2/dRPl63e6TV6qh6S/+Tg1EnCUvx/OXRSsJztXKHH5KIhE+gA/zioUq96AFrzrZZ8o/ax/+75AL5egIamhwDtpd71ANwkaX9jzPGFZcViuFm49xXZqWI91Oqf6gWooviX597VWh77oI+e4CvJHHbdtvfPnT/nk5LeFnguAB0xVzYb6996wUtveUzpsJ87FCg1REjOfKonwDefQk8AUAI9ABh0oQ+FnO7n0AdSr9yo6ev8v1b5rf2+1nzfzbeN+9h+Ff8tOb0obitgcZkv5LW1Oh71gx2fNXf+nNWS3u55LgBdsfs0mhM3H736RfvKf6lQ33exuP/DUjjIF5/DXVec9x0nYvMpHCuAEviHQZVyewCk+LWqc88BaGvt981ba/dW9z0A10k6xBjzes+6UKuZr+guM1wod/hQr4v/VIueTygEFKdn/f6Xnbfsbvv8W//Gyn5U09c2B1CtFfPmmnecvfNP5s/Mh44FrXXFe986dzp27OC8AKBGBABUKRUApCdbkzoJAL7LgMaCQGsI0BJ1FwCuUbr4b023CmxpdiHuK8hjBf2wFP85hb/ktCIe9cPtJ83UxH9IenXi+QH01p2m0XjD1xbf/Eu1HxtUuM8dJhSbVmCaEAD0GUOAMGxi40lj9znDV3JcLOlAY8xxSrfep07W9Q33ST1PbNuqi/8YXzd/cRhAMNwdc+NOu5mpiZtE8Q/UYSvbbF519H/u+O4Tb1z8R7UP//ENAwqdLJxzgnBoOFBsCJBvfQoNnEAE/yCoUqwHoDXt9gAUp309AKGhQLETgt0hQPuosx6AlZKONsa8rrD/Rb7W/1DBHSvSy/QClAkA3Rb/vjAQ+tCODveRpGU3Ln6gaR/7vGSP8zwvgH6z+p6Med35L//JIxqd3gB6AgAPAgCqFAoAxWXFor+4LBUAYucDxMJAw1q7VOUDwLclLZtp+fe1ZEnhnge38A8NA4r1JpQt/Lst/ov3uXJaAI0kc/SNO21kbfMCSduXfA0AFbLSOtM0b77glT/9gfKOGe7xw3eOACEAGDAMAcKwyP1ACBWyTXX+IXCupKNmvuE31GrfSUt+7ApAuUN9QtuUKf7dn1XxPkfoqh/eczmO/M8d97C2eaMo/oGBY6RN1LDnH/2fO/7VKVZ3q70hJjYUSJ5tFJlWYNrXOBl6TObbAlDEPwWq5OsBKM7HegBiPQHF7wLI6QmYtY21dn/l9wCcLelNM8V/aBxri6+VK9TyX7b133qWxQr+ssV/bOhPSKhXR2q/VKDZ+6q971640R8+Zq3eL449wMAz0qpHHm287qI9f7KB/MePskOCfK3/vkYHegKAitEDgEEROjCnPhxC3cmxgjfXF1esWPFGY8wxKj9MJ3Zyb+o7ANzeABt5rjqK/9C4/tDJgY3X3Ljokc02/MPF1uoDovgHhoKVDpo3r/mjo7//omfK/30BoROE3QYeX49Bcbk0+7hQ5uTgMjj2ADP4Z0CVcnoAfB8M3iJSntb8wLJg67+mewAOVLoH4F/nzZv32ccee2wHtX8Que/HN3wm1AOQM3So07H+/Sz+ffPe3+sR39vp6Q3TPF9GW2U8P4DB84gx5qTzX/7TS+U/nrnHlqZzX/b4k9MbQE8A0AUCAKpUDADufdkQkDoJOCcItL4HIBUA/q8x5vrC6xf32X0foeK/OO1roQ+12tdV/BfvfXJb6WYN6TryBzseIOmzkuZGnhvAcPj05Jod3rdi+YpnKNy4IbUfc1JDg9xpBaYJAUCPMAQIwyw2BKg17SuIYz5hjLlWeS30U859cfvQECDf8tyhQL7X8QWCXhb/oeATPxlwU91/1Pdf9FZJXxDFPzAqTl6/9c9XHvS9F96veKNN6uYODXKnFZhmOBDQI/wDoEoThenkUBGV7wForXNb/Y38Jwi3hgAdIn8PwCnGmBvl/xBz34Mr1PIf6i5Ptein1oV6FTop/t3pllirv9T+Id6QpIN+8cLfz//TvDNkxPX9gVFkdHPTmkO/+Yqfxo5xsZZ/X0+BPNPF+9Cy0Da56AnAWCIAoEqxACDNLrTdebfIjwWB1LAg9ypAh6o9AHxopvgPtWK576Eo9AEWKvzdYUFli3/f0J9eFv+hlrdQgJNmfmbLrt/xgfUT+oakPT0/JwCjwuguKx3yjVf8bK2ePC5J4SFB7nkBbghITcuZJgQAXSAAoErdBgBfj0CZot+3zlhrD9OTAcBKeq8x5mee1/YFgOJ96/HufawYz23NL1v8+z58uyn+Q63+wZ6bI2580aSm7EWS3VYAxsED1tpjv/mqW36ivF6Abk4OJgQAPcQ5AKhb8QAfWp/z+NDNV0wXH/tuY8zNav/wSo35943N943rb2bM+84RSPUKdFr8uz87KRx0JH9Aa7s/8vvbbakpewPFPzBWNjLGfPPI7+9wiOJDNWMNO7Ge1thxyV0W2iYXDaIYKwQADJpUy0/oMamb+5xTkt5mjCm2XIUKf18ISJ3UG3pscZ0NrIt9+29oSFFu8Z8a7lP8YHV7XyRv8b/DK6waV0l2cwEYN5NW+vwR39v+BIV7bmPnfRECgBrwx44q5VxG020dKj7O29qs9tal0DAg77ChmSFAtxljfuV5neJ+uB9KMa3C2jfO1Tf8xy3eU8N7QucQ5AYeqb34Lyo75MdIMkf8YIc9ZXWGpMnIcwMYC+bzL37Fz/7yFKNnyX/sk+LHLfc4mnM8YzgQ0AECAKpUNgD4AkHOVYFi5we0bWet/YUxZnvnOX1BJLTPUviDKDQMyTcsKVXk+9YNQvE/fY3/7+9woLX6nOhJBPCkr9z38NPfcvWSq5+pcINFcThmqveSEABUgACAKuUGgOK2vvGibiDILf7ddb4QEeqBCO2zKzRkKVSkh4r6nCDQz+Lfe4nPmXtzxPe3X26t+UTkeQGMLXPuFpPz3/CFl61ufWFYqAdTznTZk4TlLJfaj3edhgACAEYaAQBVcgNA6z4UAIqPCQ3N8RXw6JFuUgAAIABJREFUqUDg26a4TEoHADnTvg+Z1C00HChnmFCVxb977/4cZv1ODr9hx2Nl7KmR5wUw9sxFf374wWOuXrJmc/kbOnzHR3nuCQFABQgAqFIoALTufWEgdh5AmWFAvpAQG1YUa/1OiXVbh7q+Y61iqQ/Kbov/1JAfydcj8xStO+LSHT5ord6b/pEAgL3arm8e9q09bttMeecxEQKAPiEAoErFACD5h9T4TriN9QDkFPahcBB7Tvcmz72P+4FUXBY6Edj9wEsFhDIBQJ7porLj/Y0kc4rVb276/nb/Ipl3RH4WADCLla57XI8fsupVd2yqvF5O9/jZSaMHIQBIIACgSjkBIFp4qr1ADw3j8QWC0NCfWOHvhgAp/H/ifpjk3MoEgFjLmO/D0LdfRWWL/+mf0SZad9gl239M0jsDzwsAQcbohrkPN/ZfseRnTxUhABgIBABUKRYAivM5Y89DPQGxoj+2jVvo+vYnFgJ8xX9xOlX457T4l+kO9+1XUWfFv2QOu2H7v5D0/sDzAkCatVeue/SRQ65esmYL+QNAmR5PQgDQJQIAquQLAO69LwC4hXluL4AvKMSG+6Su+pP7/1H88IkV7bGrAhWnFdg25wPPp/Pi//rt3yKjD0WeGwDyGHPJ43967PBVB9/xDFUbAmJhwF3uWxdCAMDIIACgSjkBoHWfGgZUfL4yvQGxaV+Lf9kAEPoACl3iLrdnoN/Ff9vP+DXXb3+YMfpU5LkBoKwL1j2yxbFXL7m69WVhZc+Byj0xmBAARDTSmwBd6eYA67sPHfBzWtaLrUyxb+Ht9jY181xTmdu7+xm6ApCcaSn+My1T/M+6P/x72x9E8Q+gAkdtsuCe00+x+o3ivbShxpoyjThypt1GndgwzxAaTjESJureAYy00IE41AOQ87jYwd2d9q2zhXsV5lWYdwvsnJARW1/2kp6pXoXivhXvYz+TVPH/xPyh12+/h6R/Ex90AKqxy92/2fxpt53+xxsU7hl2p1vcY7Yr9dkQ+4zIxbERQ48/YlSpOLZehenQQTnnVhxWlNtylHvFn9C++fhCQnHaV7inTubNHfIjtRf/7v7GhjWFi//v7vhSY5rniMYBABWzMv904W4//4LSDSa9Hg7EUCCMPQIAqtRNACg+PnQycNluY3ef+h0AUoW+u507Lfk/0Nx97az4v37Hpxk1r5K0QeA9A0BPWekvL9ztlotECAD6igCAKrkFtrtcyitWQ8V8qODPafGPvW5xP0NSAUCKF/6x9e7z+F7PVebn2Vb8H3LtIjUmGjdIerr33QJANaaMsUd9a9fbfqL287FSISDVu+qb9t3LM08IwEgjAKBKZQKAu73bC+BryTfOtG99rOgPBRF5pot8HxqpAODOp1qqclr93f0s7m+op6Ot+D/y+9v9+fH1ul5GiwLPDwBVur9pJ169cvef3afyPQGEAKADBABUKVboh+5zewFyeghC9zmt/+4+F+UGgJyhPd0U/sX9jAWpYPG/91XPu2vDefMvN9LuidcAgCrdZZoTu357j59NKN4TkLqcspT+0sTcEFCmsCcEYKhM1r0DQILVdLFa5uDanHlM6D70/L0KAL5lsR6C2HOkhHpXksW/NtafN5o7/zRZin8AtdvSmqmVS298/u6Xv+xXm2n6mNX03PuO4a11rfnQMd09phr5P2NCy4GRwfcAoA6x1pYyXbah1vTQLdWilPudAL51odao0Dddul8U5tvflFAPSrr4l8yhq7b7G0mvy3gdAOiHHec/Ovfcva/a+7/l/4Z393sDij2+sSGfCixzp31yR0owogJDhQCAfummFSUWBnIvqRkr8n1FeJkv78q9hfbJ3f8ycoZPFZc1JJnXXLf93jL6HyVfCwCqdsCGc+7+rMINGMFGjcBNCh8n5VmuwPochAAMDf5YUaXQWP/iet9BNjQ2P9bSE2sFjz1naN98y0LFuW98aWxcf2w+9Fqxn53vZ+S7NGpDUuPQ6xc91drGNZLmBd4PANTKWnPCyj1uuVLtjTephptUY5A80/Isl7M+t3GGIUMYCgQAVKmbAFBcXyYMpB6XU/yn/i9yPiDKDGEKPW9on9z9jn1fwqwu9CO/v919j63XjZKeF3ktAKjbow019/z27revVToEFHtT3R5aKT8EhI7lvukYQgAGHgEAVUqNt4ytTxXwOctSz+O+dmxfpfgHQe4HSZmiP7ZfofceLP6Xnac1Dz5r25VG2j/zNQGgNlb6jZ2aWrzq1XdsrPLDON0Q0M2VgcqGAAIABh4BAFVKBYDWfCwglA0BvunYstDrxqSCQOxDxfeYHDk/h2Lh7waBxiHfXfQOWfO+Dl4bAGphpCsfePyZB1y95OrnqrNzuRS572QokDsdQgjAQJuoewcw0lKt/aHlvmJXzrKc4t197tzW95wxpMXH+7qW3eePvZYr1SsRu7lXzTCSGgdds+hFMuafPc8NAAPLSltPTj4w744vrb2psDh0HPMdO8sOrQw9f9ljJ8daDDT+QFGl3INrmV6A0DL3nICyzx0SCg9FZbqKc1qFYiHJDUC+IT+zgsCB12070ZB+IGmTjNcGgEFjjdWxF+15202KX5LZ7QXo5qTg1LlanA+AoUYAQJVyWvtDy3OGAsXWlQkCsWUhuS36vTrR1y363XvfZfEaB33vhb836yd+IGnbyOsCwKC7vym9/OI9bluvcOGf+l6WXoQAhgJhJPA9ABgEOQdZ30E6tq544Hfn+3XzvV7OzyEWRHw9H25IaAWChpma+Kwo/gEMv40b0tf2vup5v1X++U9Se+OIFG4ocjEUCCOLcwBQpdjBs9PegdT4eKm9kA4Nxem2ZSZ10lhoWyk+hjU05l/KGPLTuh10zbY7G+lj6bcBAEPh6ZNmcv4dZ6z9YWK70PHV6snzAnz3vseX+Rwruz9AbfijRJVSrSedDAUqc9+azg0bvRAbGpTTmhQ7vyF0nX/3xN/GQVe98GFNTvxY0lPLvgEAGGDNpuwBl+x5+xrlnQtQZkiQPNO++9h0CEOBMFAYAoRBknuQdQ/UvvnQ0J/idqExo6HW/NhQn9jzhPbXfX9FoVAT6gF4YtkpVr+0kxP/IYp/AKOn0ZD5932/v929am8ACZ0PFRsS5BselNu45JsOocEVA4UhQKhSbJhLbJuyPQGp1ywuyx2K09o2d/x+J2LjUWMfVm0t/oX7xoKl27zDSG+rYH8BYBBsPDFlF91xxtorPOvcY7p11rkNML4hQD6cD4CRwh8jqtRpACguTw0Zyl1WZp+qkhqKFGqJyh73f8i1223cVPN6SXMreg8AMBCM1QkrX337NZrdCxu6OlBrWprdW9vLLwljKBCGBgEAVYr9fXUaAkKPLTPOv2zXbS+kzk/wTYe6sb3d3vtfsvNvGwseWS2u+gNgPDyohll88R63pQKAGwQ4HwBjj3MAUJcyB8xQa0trOjS2Pmddaux+6paz/8VtUtumhgUFzwGYWPDo/xXFP4DxsaGa9j+WnadfafY5AL5zAXznA4TOBchtvIotAwYaf7SoUtkTo7rpCUhtm/v4TpV5PV9x736TcWy4z6yWf0mNg7+zzRZNY64O7AfQE0+ft6V22fRV2mGTl+q5G7xAz5z/XG0wuaHmNRbo8eZjenjqQd3z6F3674d+pdvuv1k//vMNWvPgL+rebYy+91786tsvUvzLwVLfFBwaApQ7FIheAAwVigVUqWwAiD2mTGGf2j71Wrnrcx4XG64UanVKFf6zAsBBK194Z3Ojxk1G2r7D/QWC5jUWaOkWh2vpFkdqu41fXPrxv334Tl35+29o1e/O072P/7GCPQTMQxPG7nTRnrdPqtz5AL7iv1jYx84HiA0Fyi3uCQGoDQEAVcr9++pFCAgt77bVv5uQ4HYlh072jbX+uwV/W+v/QdcseoOV/lfOmwFyTZo5OvJZb9JRz36rNp2zsOvne6z5iFbdfZ7O/q/Pad3j9/VgD4FZrrz44Ntfqwe0maYL9ynFi/+y5wMUw4AK05wQjKFEAECVyvx9lQ0BoW06Kdh70dIfm8890TcnALTmJyQ1Dv7OdnOmTHO1pDkdvgegzYue8jK9Z5uP6dkLtu75c697/F598def0GX3fL3nz40xZ/TGS/a8/QcK9wKEviSszHlgitzHpkMIAagFAQBV6ra1PXf4TjfDfFLblX287z3ExvwXW/+zx/xr+gu/fnHDNdtcJ2nXzH0EooyMjnvuu/T6rf5SDVPt18Rc+ftv6TO/+Hs9PPVQpa+DsbLWTjZ3uHT3X26mcA9A6IsbO700aLdDgQgAqAVfBIYqdRIwyw7ZyQ0CZbZJbVtmiFKZVv9SQ3/mL1n0Bhm+8Au9MWEmdPKij+noZ79VxlR/gbitN9xWL91sD92w9nI90ny48tfDWNig0TRb3nHmn66NbOMev2NXmfM91l3f6TlosccDleMPD1XrRQhIPU+n4/y73bfUkB9p9qV23bH+sdb/YOEvqXHwFVs/PDU5+VNJG3XwHoBZJsyEPrjDZ7TrwqV9f+3/euiX+psfH691j9/b99fGaLLWHnbp3nf8Qu3nAvh6A1JDgfpxVSB6AdB3BABUrZu/sU4K+16M8S/zOqGTfFv3ZVv/k0N/JE3s/51tTpX0mpLvC/B676J/1AHPOKa217/t/pv1tz9+gx6lJwC98evHmo/vcPWSNVvpyUJ/Sp1dGjRU+PuGAHFCMIYGXwSGqnVzUPMdTHNaUnwtMTmP7XT74uNyucGhofaw4P0im/2uWfRMUfyjRw7d8vhai39J2nbjnXXSCz9U6z5gpGw9d2LuXyt8XlXoy8IUmPZt4zKB5aHtgVrxR4l+6PbvrFcH1W6eJ3ecZ6dj/rOG/ux91d53zDW/vUnSizL2GYh67gYv0OcWf0NzG/Pq3hVJ0j/+/D36zh9W1r0bGA0P2MnGtpftcdsmil8VKPUdAWWGAnFCMIYGPQDoh24PaqHW+LKt9KnniT1frHu3LF9rUuor6huSGnPNb98pin/0yEnbfHhgin9JeucL/04bTm5c925gNGzUWN/8uPwt/aFW/VDvqwr3crZXZJvUOqA2BAD0Sy9aNnIK+DLPE3pcJ8N/pHg3srusOJ8a+tOQZA659rm/k/ThDvYLaLPr05bqxZu+qu7dmGWzuZtr2XPeXvduYERY6U0HXLNoI4WPq90OBQrptLeZkIC+IQCgn3rVvRl7nm7G7/eqhb8oFAJyiv5ZH0yPNed+xEoLfemFG7eyt+XPOUGD6PBnvUEbTj6l9p8Pt5G4Gdu0/6yNtFaB46raj7uxnoFYo05OLwAwMPgeAPRbFQfC3Ocs89puS31sne/DwP2ir1CBb5z7tiv+SGos/c421lidJv5n0QPbbbKL3vi8k+veDa85jbla+9g9uvX+H9e9KxgNz3nB8Qtv+tWZf/p9YZkNbOsud4/9vseVLfjpBcBAoAcA/dZqnKniOVPPW+a1u9nHUItQbNypr1XqiWDQsPaTkuZ0sU/AE/bb4si6dyFq6RZH1L0LGCn2f+9/yc53Ktzq7+sZaCkzFCjVWAQMjMm6dwBjy6qaA2KqBae4Tbevnxrz79uuOJ/6MDKSzP5Xb/NMa+2BXe4r8IRdF+5b9y5ELdp4Jz1t7hb646P31L0rGA3P1ryH3i3pW5o+/hcbP5sKH4uLV/4xznTxs6a4rrgs1JAUW5ezHugaPQCoUz8OcLHegSp6I1Jj/VOtSW1BoGntRwZgLC23EbltueB5etq8Z2jQ7bzpq2r/WXEbnVvT6v17X/W83yrvqkBlxv/TC4ChRABA3VrH5zpfq8zrhw7gseW+1v+sD6F9r9rmeZIG61ItGGrbbrJT3buQZduNh2M/MTQ2ndTkyWo/9yrVCyv5i/7QOndZCOcCoFYEAAyKfgeBTpe1pFqEQuP8Y8OBZn/wbKi1Rvr71JsBynjOgufXvQtZnr3B1nXvAkbPXx9w8Y53yX/hhdRQzdh3A5Qp5insMRAIABg0/QoCOa/R6YE61SIU+uCZtW7pt7fZXbIv73AfAK9hGP4jSZvPe2bdu4DR85T18x99n8K9sLHvBZDaj+mp4UOS/3Okk+AA9BQBAINqUEJAiu8DQZ5lOeP9n1h2yin6uaS/r33gLLeRu20wsaGGwQYTG9b+s+I2ejfT1Mn7X/KC3yt81bXQMTp1rFdg3t0eGAhcBQiDzBamB+Hg6Tv4u+vkbJPbYjQrCFyz5/OPNWru3Os3AMydmFf3LmSZN7FAdtYhAOiJDZtz9beS/kNPHnel6c+b0LG5tU6Fe+tZ51se+iPO2UYZ64GO0AOAYVFsxxkkoeLeXZc64feJManLztMtMg3G/mOsmYHI/BhF1uov97li0Z/k/0JG33TshODiMgXm3e2B2hEAMIzcjt0qxQ7YsYN/bg9A2/CfP23+wuOM7HY92XsAgGt+o9H8G2UcjwuP8TXYSP7ju7s8JGebnPVAaQQAjIJ+hIFYq09qXax1qf2DxzbfV81bAABIkrXNE/a9fLu75C/8VViWe5Wg2OeB+xigdpwDgFHjjsfsVOpAHQsBOVeQ8F4BaN/Lt97Cyry4670HRsCgjffDKDEbyjx2gqSVMwuK5wC0vgU41Gvb+obg3LH/nAuAgUMPAEZRrw+SOS38sdaeUEuS2/LUkBrvq/sqGdzG4DYs6v45cRvtm8xfLTtvx1sVHwKUc4z3TbvLfI8BakMAAKa5LfU52/oO7L4Pj9Q2DUlm76teMGWN9i+/6wCADmy5duEjxyu/+PcN45RnunivwLxvHcEAfUMAwKiyGdvkHJCL86FlqXXF1n/J/2HTaDT1Ps/zAACq8z5toD8o3DNbZqx/1b0AfD6gZwgAQGdBILf1P9Zy9MT8vpdvfbek13b1LgAAZb1oyYUv3Fn+y4HmHsflmS7eKzDvW0eRj77gJGBgttDBN3Rgz+kZSH14mKbMyZKd34s3AMTkdI0NCr4IDP1grX2fpPfLf6y2nuWtP0xTWK/AtLtMhce6y3IUXx/oGAEAoyrVilKmJSbnPnfsaFsX8/6XbHHH49I7OKQDDv4n0AdG2m/JlVvbq/b5davg9xX+7rLilYKkePHvFv78ZaN2DAHCuDOB6dA2xXnfcKDSQ4Aem9zgOElP7fwtAAC60tS75D9Wu+dv+Y75cpaHhgP5PmNCzxXDMCF0jQCAcdRp63/soF629f+Je2PNCd29HQBAd8zxu1+37S/Vdmnmtp7e2Jc6yjNdvFdiOdA3DAHCOOvk4BtrzSnb+m9efdnz11vpFR3sBzDyGCeBPtpo7sOPL5N0k9qHAbUaS6dm7lvH8NxzAeRs5wo9F/8CqAw9AMC0VJdtqJu2k7H/T9w3ZE6s4L0AAEqyxpygjGGbSvcCFMV6j4v3ZdF7gK7QA4BRlHNgLHPwzBnLmSr624LCrtc/+1Y9ZF9HGw8QwP8G+sq+asnFW09ddWDwZOCG8noB3J4AegEwcOgBwLgJtdD4lqV6BHJvre1ntf7Pf3DeMllt1s2bAQD0TrOhtyqvFyA6vHPm6XyfF+7y4j3QN/QAYNR0253qa9l372MH96zWf0kNqyYn/wJBVjR+ot+M0Rtf8+0t/+e3X3PXtor3AhSP5+4fqtsLUNnuVvz8GGEEAIwTX5Hfq9Z/97HR1v8lV279QHNKu3f8ToAxQGWDGmz65wXzj5R0i9obc4pDdEINPs3CNsWTgVtyvxiMYUCoFEOAgN60/gdb+n3LbFMnqP31AAA1M1ZvU+T4HVieakwqLovNl97dLh+PMUUAwCjp1YHQd4CO9QT4Hu8LAdP3C3S3tVrWo30FAPSU3WufK559t8LHcnnWhW7yTLvLinw91BT56DmGAGFchIb/xFr9yxT4xVu09X/vbz1/a2vtc7t7O0CHhmkwwTDtK0ZJY/3U5DGSrtOTw3p8w4FCRb917uVZ5rsyEMN90DcEAIyKqrpRcwKBu72v5eeJW9PaY7vbVaBzw1RdDNO+YuQsl3S9/CcC59y6+WKwsggOKI0hQBgnvq7V0Dahbtho676zXful4k7RDyUd1dW7AABUzOy6zxVb/17liv7cIUChIUHutr71QE8QADAOcov93AOynOXF9aHrQzckmb13f96rJW1Z/i0AAPrIrJ/SMXLP4Sp3ArAC64DaEQCAJ8UK/7KtPd4Piammllf7FgAAPbJc8R7f5DFf/s8RBZYV5fRYAx3jHACMgtjBMTSkx31MaD41FCj1ofDEsmXn6Yf3SN+WZagmkIX/FdTrFfusfM7aKw/+zWZ68lheHM/fWjZVmG/dfF8OJnV3MnBoeWod0IYAgHGXW/jHunNzegAav9vk+UuMmlv0YqeBcUA1g7o91pg4RtKVar8aUO6JwVJ78d+aVmE9f+7oK4YAAdPKtPgXx4KGxvy33UyjeXS1bwEA0EsNY8ucB5AKAEW527jTDAdCTxAAMMpyW/Nj96GDre85fQHhyeVWB5d/CwCAuliZxXtevNV/qVzBX+RrSIrpptAnHCAbAeD/tffm4ZIUdbr/G1Xn9L7IjoDYreDGgEiDiK2ACyKbXBcaGRVEEFxA1HHG63aHGe8w6nUbl5/iMl51dEaQuaMi0Igjo7QOKjMIgsjaLAJN0/R++pxTVRm/P/pUd544sXwjM6syK+v9PE8+GRkRmRWV3Sfy/S6RRQadXkx4ppEg9fDYjIsGALX8mv23AOCPfxFCyGChNBrHwT/nmw4gX4QAlnJ6T0hf4BoAMuy4JmWzT7o9tM0MF6vmCZopnoSI0WBSNKkISp8A4I/YuQ6gm/9vWwew46ypLWYxsKu/Nsq2foREQQOA1BVp+o/Z3+wrzeH0b4k+gf4dQiKhxCHV4PjTLsMbL1+BZbC/CciXEmp7jpiC3iSP0KdxQEQwBYgMMkVIalsYVuLxl4Z41StW7nWrUlhewFgJIYT0n13+tPCpz4f/OSD9HQB4ynQTkb7BCAAZFnypPeaxLxXI5e1Jl6elAG1NZr1MKT0747gJKZhBcg4O0lhJnVFKvRLA1Qjn87ueH9MuN7X3pQGl992+sLQTkglGAEgdCaX5hNJ/bP1dUYFuXcOo6/5tNRpKnSAeOSGEkMrRAE6ALCqMQD+X8VAkjCSQIIwAkEGlyPSf9HHMBG5O+tZ3RSdavYLTMSHx0L1JqoKGXvbClU+/55fH37MUMxcDm86gDuzPC99CX3jaCSkcGgBkWAml8cSe0zDqFQC1/Oql64DkaZzOCckA/25IdWiopH0cgHuxU/zbfhkY8D9HkOoDTP9fHjISQouHCRHDFCBSN1yLrWLSfySRALPN2lfrztGZvgUhhJBqofSLp0oNY+96BkgXBkvSUH3thERDA4CQ7ZiTsDQFyDvR8+0/hBBSE7RajulzPuD65feduES/SaidkEJhChAZRPJOji7vi3RBVuj8nREAKBoApFIMUt7AII2VDAEKBx11zX53/OqVDz0T09N+Qs8D9xW3kyUNKDxa/gkRD4wAkLpjemJcbbb6UNjWG9594cqn3wXggJzjJ4QQUg1UQzdfAHlqqGSDUZ7xmcY+VE+ICEYASJ0ITZS+87JM3t7+OmkvV/S/kKoxSP8nB2msZCjQwHIAV8GdCqox/RkBSz/f235C7YQUAg0AMowUnf5j3ZROltM5Q0geqH9I1VDLMfMHwSRpoc4LTu19PwZGSOEwBYjUGdukGwqbStN/JJEA5v8TQkit0M8/6LKDfg13BACOtlA02YfLUSV9nhEyA0YAyKBRdJ6kKeJtddHbCVcd8JsNmHye4PsQQhzQ9UkqyNxFizYeCreQz5oGlI4EpM/hnwHpCYwAkGEjxmMSiiA4jYQn1OQRAGbnGyohhJCqoae/DlSaBuTD159efNITaACQYcLm6e/uQzmakhzPHVtD4/CCxkwIIaRCNLRahnBKjySSDLifPaFnEiG5YAoQqSuxHpQsOZvu14Am+mAoztGEZEaDyQ+kkmjgEISFfvpYw/8sSfeZ+ogZ2NKGYDkmRAQNAFIHXJ6UmLCreR1zgjbr/Yu9lDqEMzIh+eDfEKkozzrosoP+87YVtz0fO/+bNgAk8EcCYnAJ/lB/QkQwBYgMEnld6llzLKPCuqddhlUaeE7OsRJCCKkmowsWbnxW6rhh7NOOIp8hYHMwwbMnpDAYASDDQihKIM3VDPVTDy/c5wAkel7RX4CQ4YMOTVJNlMbBAB7Edq8/EE4Dsm3m24Ji/sPT409ywQgAqSMS777L2+LzyPgm+B3nJJ2RQzKMmRBCyOBw8NRe6jCCo7+r3UbWdFdCZsAIAKkLeSdE3wQsEf8N7DCo9cH0yxBSAPw7IlUlUYfA/YvAPseRj7RXPx0V4DoAUjg0AMigIxX6eSbkdNnlyenSSLa/IYKQSjJI6mCQxkqGDLXjTUC+DY6yKxXI95YfintSKEwBIoNCnhBnaCFVbP6/+/Wf26EBQAgh9Wbf5//rvrdCnibqczyFnlGEFA4NADKM2NYIxEzeZp8d27If7fNrAEt6MmpCCCGVQY02n43tc3/6bT+2V0QDdtGfdx0ADQaSGRoApA748vWl59jKtut5jYGR5siSwOcSQgipAw0shduhlK7LmnbquqbkPEK8cA0AGQZck2/e/H9guhGtdKKWMk2TkGLgXxKpMkonS7H9VaBFrQNAqk/WX/3lWgEiggYAGWQkHo9QGDV9nCX/f1q70skSzryk0gzSf9BBGisZOhKoJQB+Af9zwzw23/0Pox2Y+T+fop4UDg0AMggUEdq0XcOcoM16W9k8L701EqWXKE7TpNIM0n/QQRorGUK6KUDms6SB7T8QFrMOwPT4u4wBWPrxD4VEwzUApE5I0ntcqTw+Y8DWbq/XWBo7aEIIIYOHmm4ApKqn7QG/EZBzCIRkgwYAqRvSlB/JdUJrB2ZM8ApYEvk5hBBCBpP9lv122c+nylnXAdgIRaFd0CAgYpgCRAadmAnRJejN/j7PjXeC12AJCdC/AAAgAElEQVQEgJCiYF4DqTjN5sOP7wd5ZDm0VsD3g1+hdltfQpzQACCDisTTEfKaZJ2kYSsv/8HuN05q7CoaPek5DdXA0xc+Cwc96VA8dcHTsdfcJ2PB6CI0hjzw+YzFB5U9BBHzRxbiC0d+t+xhlM7m9iasn1iH1Vvuxp0bb8NtG/4braRV9rDIDtppp4/C9Pz/dH0oNah7zF/9JX2BBgCpOr0Kafpz+e1tvnrVxhx6/0tGQeF5ux2Jk55yGo7e6zgsnrVL2UMiGRlpjOD5e7y47GFUjvHONty49he4+qErcMOa6zCZTJY9pCFHLQVwH8JpO7ZnSPrYJv67/bgQmBQODQBSR2LSe1z9fXXWiT4B9okbJikKBYWX7XMyzj7wAhy46DllD4eQnjGnORfH7P0KHLP3K7B2/FH80z2X4orV38ZkMlH20IYSrdU+AO6H/60/EiMgRCg1iJAohjsWTupEKK8/9lrmdV0Rg265gQZ2i/wcUgAHLHoWLl1+BS5Z9v9R/JOhYo85e+M9B/01/uXYn+KFe76k7OEMJw3dnfdd68dckQHbMRxtMYZDr6LmpGYwAkDqjCSdJ3SOzxiY1icBduPM219O2u91+MAhf4/ZzTllD4WQ0thv/lPx2SO/haseugKX3PI/MdEZL3tIw4PGbgivFzP3vjVmkjUA9PqT3NAAIIOMVMj72s3JODQ5w9mmsRs05+R+0FQj+PChn8DJT1lR9lAIqQwn7vdaPGX+Erz7xrOwaXJD2cMZFroGAOAW/wjUwzh2GQExwp9GAvHCFCBSZYpI6ZHm90vr3aHcRO8eMS6SkZHGCD5+xKUU/4RYOHiXZfja8n/FrrP3KHsow0J33nct7LW1mUgNB0IKgwYAGRZC+Zm2ydu1txoBimsAeo6Cwoee+wkcs/fxZQ+FkMqydOEz8IWjvouFo4vKHsowYEYAuntJFCCvsKdhQDJDA4DUjSImRN+CLfMzdvZJsLvG9pgrt95sbz7wAnr+CRFw4KJn4+Ln/QO2ZyeW/7db302ZEYAsC4GlSBYMEyKCBgCpA0VOfr6UIe86AK0YAeglh+x6OM5/1l+UPQxCBoaj9z4Opy19c9nDqDl60UGXHfSzqQOfuJdEmk2k6wZs5xHihQYAqSux3hbJQl/XOV1oAPSIphrBBw75ezQV31tASAwXPPv92HPO3mUPo9bMm791V8Q9P2zC3rcOwAZFPskFDQAyqGSZ/FyTsHRytp2z81hj1wrEo2u5rVhyFg5c9GwQQuKYN7IAFz7nQ6X/Ddd66yS+NwGlkXr0fUZAESlDhNAAIJUlz8QVE1b11ZnXCBkBc6NHSoLMaszCmw58e9nDIGRgOX7fV2H/BU8rexi1pa11d+63RZxDBoGtrw2KeVIojKeTQaPotyZI8jJDqUBYdumy6zQeoUHdA47b91VMYSAkBw3VxBlPOwcfu+WDZQ+llmjdmQVXZHgm0jVrCtvjC2aZkEKgYCHDgGsdgNTj7+urAKC15KFZeQZI3Lxq/9PLHgIhA8/x+56KWQ1OU72gqdRsT3Mor79opxYhImgAELITaU7mDE/P/I2jvgcAycius3fH83Y7suxhEDLwLJr1JByxx4vKHkYt0TpJW1Yx6T62Nim9MCbIEMEUIFInilg30C2HFmpN+6w2JmZBj+b4eGLj8N1eiIain4KQIjhi9+VY9ei/lz2M2qEbajaASYTTgHzHZr0t/afbx5YOxDQhEgUNADLoSHL4zXrftSTXn/E5ydzRWboduDqJ5uBdl5U9BEJqw3N3PYIKsQfojpoNoOXp4ntOpcvdfqb4p7gnhUPXGqk7IWNA4pEJvf0HnYkGU4B6wNKFB5Y9BEJqw5KFB5Q9hJqyIwUodhFw1jQg6SJiQpzQACDDRswiX9t5VoNBz2pzdV0P2Hfe/mUPgZDasGh0MRaNLi57GLVDYdoiYGmaj/1ScedQ5JPM0AAgVSRLqo7ZltWbEnzlp+36Iy1GAHrBgtGFZQ+BkFoxn39TPUB1XwPq7SQo244J6Qk0AMgwkSXsKkkVQrvR4HqaHjC7OafsIRBSK+Y255U9hBqi0ylAkjQgeOpdfUJOKUKioGghg0TWyU+6uDfrRK10krS14hqtopnsTGD+yIKyh0FIbRhvb4PWnKuKRCndQpwzydVGSN9gBIDUnSzh1dDirZlRAdWZzDg+4mFre3PZQyCkVmzh31ThJLoxYVTZIsaw1BUl/GlAkGhoABCyHZ9XJujZaSSj5gOAFMDDYw+VPQRCasPW9hZsmtxQ9jDqh9JdB1Cet/PQGCB9hQYAqQtFTp6hEO2MPooRgJ6wevPdZQ+BkNqwevPd0HydfOEordMOIMk6AOk6Ads1gd4bEWQIoAFASAGvWWugNamx/ZdauBW33bL+vyS3nxAi4HdP3FT633QdN6DR/RXgEFnfFJT1eoQ44SJgUldiJ0ap19/qyRnvzJkYaXYiP5KE+PVjN5Q9BEJqw2/WruoqVlIgidIT2K6nfN7/2DfO8V+K9BRGAEjdkL7xx9Y/drJO9ZlgClAPeGzbo7j1CUYBCMnLWHsrbnzsF2UPo5ZMpQDl/e2Z2HMZISC5oAFABo0s7/CX1mfpO2UI7EkDoEf86IHvlz0EQgae6/50JcbaW8seRi1R21OA4k/LXkdIbmgAkGElT2h2xnm3rbjtBEDr8rNR67f96IHvYcPkE75/H0KIBw2Nf77n6yj7b7mum7EIGLAv2o3x3BPSc2gAkEGnV5NpbJRAaWBb+Y+i+m1j7TF85+6vRvxzEELS/Mcj1+L2DbeW/rdc163TSMZj/j0ioLFAegYNAFJH8v5icLbzNZ4o/UlU0+2bd34ZD229P+qfgxACtJIWPnPrR0v/G67zNqo766Zud2xaqu8HwqTprjQSSCZoAJBBoYhJLo9hEDxXQa0L9SHZmOiM45L//gD4DnNC4vjKHZ/B6s33lD2MWjPe2Ss990t/4ZfCnZQKDQAy7GTxrFgneA39eCEjIlZWrfkZvn3XpWUPg5CB4bdrf4Wv3fG5sodRazSw6bYVt53kaJb+0BchfYe/A0CGjZ6tGdAAIwA95rO/vwRPX/RMLN/rJWUPhZBK88CW+/C+G89DR/P3SXqMNP2HkErBCACpE6VOwDQAek87aeG9/3kubl73m7KHQkhleWzbo3jbDWfgiQkGJfuAmf7TS2hkkMKgAUCGBdcr2WztkvoZ7UqBT9s+sK09hvN+cTp+/uh1ZQ+FkMpx7+a78KbrT+ai+X6ho+Z934smKO5JX6EBQIaZ2F8N9l+so9eV/TaKYdnG29vwrlVvxpdu/xQSnYj/jQipMz97+Bqc+bNT8MjWP5X+Nzo0GzQjv2Qg4RoAQgoiaeh1Spc9iuEh0W186fb/g5vW/hIfPuwTWLLw6WUPiZBS2DDxBD5z60fx/1b/c9lDGTp0vsivxOGkMGVqEFIkNAAIkRGMCuhEPw7FKG6/uXHtKrzmJ8fi9Ke/GWc/4x3Yc+6Tyx4SIX1hrL0V37vnm/jHP34eGybXlz2coURpcQSADwdSKWgAEFIcj5Y9gGGllbTwT3d9FZfd800ct98pOHn/1+KovY5GU3GKI/VCQ+O29b/Dlfd/H1c+8H1snNxQ9pCGG63WeFop+kll4dOREDneybzRmnu/njXRr7EQC5PJJH78wBX48QNXYP7IAhy2+5E4eNfnYcnCA7DPvP0wb2Q+5o7MK3uYpbL7nD0xpzm37GEESXQHD489VPYwSmWyM4mxzlY8tu0RrN58D+7Y8Hv8eu0qrBtfW/bQyBQa+j4ACyxNFP+k0vA/KKkivjckuN7mozxbY2pLl82tmdqb5Sa2G8vd+hFL/QiA5p9dttdXATwpx3cnpKd84UXfxjFPPq7sYQTZMLkeL/7Bs8seBiFeNJIDb1ux9mAAnamtndonqeOOUd9J7TtGXXrTlrJzSbJRxtQ1CZkBIwCEyAkuxNLAagCH9n4ohNQfrnwkFSeZWLD4QWDtwY52/hcmlYWvASWkQNR2A4AQQkjt0Q/ffeLdryp7FIRkgQYAITJEnhytcF+vB0IIIaQS3Ae5l9/WjxECUhpMASJkJpknZZUkqzVfBUpIQVAfkQqjC3H4hP6T84+A9ARGAMiwYi6U0sY+20VVY3We8wkhhAwIKnPKJ0U9KR1GAEid0HC/2Srd5utnO0/c3gHua3BqJ1VmgP5/6gEaKxk+lNJmBCCPN1/yv51/EaQwGAEgw0ZPJ9AmkpicUEIIIQOK6jRXlz0GQrJCA4AMO1nEupk+tKPuthVrTwLwYO5REUIIqTSN2fr21CEdP2SgoAFAiBvbGoHwSUrf0oOxEEIIqQ6P3vKaNS+11Nt+lIvGAakcXANABoWYvP2817B598WfoTVuAXByxDmE9I1BUiKDNFYyZCjcgnz/RV2GQew1+WdCMkEDgNSRrMZCJo+/2Vfpxq2ckwkpAP4ZkYqiAVukN+siYGtKacR1CYmGKUBk0Mnrgclyju2VoTvKjYZiChAhhNQYpXHrVLEoT74NCn/SMxgBIINMyNMf81rQ0Oe4+s2o22OPR+9cs2aPcQBzAtclpAQGSVMM0ljJMDFlABzoaPZ59GNfBco/AtITGAEgg0ZWr73ruIhfYZzW5/qX4DQAfxCcRwghZPBoj43PN+f42LTRvMKehgHJBSMApG50Pfum918SLQh5aWxvdzDbtxc0boHC8+TDJoSYUOGQaqLuXH326tcAaE9VuH5VXiL0e7nol39CxAkNAEJkC7PiFm9pfWv+lxYRMsTw5YmkqmhtewNQ6H9sTKop//eTnsMUIFIXipwsQ8Lf+UNgOw6a+K8Cx0MIIaQiKIWbU4dZPPKuSLPP0cT1AaRQaAAQMh2pp9/VpgFg9kTz1wBaRQ2KEEJINUga6gb4HUE+b35s5MBGlucUIdNgChCpO9K1AD7vistL45zUbzlzzf945j/v8TsAh8uHSghJQzVDKsjExNi8mwA8Zeq4KO98Ua+05p8NEcEIABkGskzEmVJ/ptVprIr8XEIIIdXmt6vPXv3aqbJLeIciA7AcE9JXGAEgg0ToTT4x50tCs776sDHQwCpofVGmkRIy9FAfkQqirI4d11uAbDCXn1QCGgCkiuQR+q7XgPquL8nvj3nDw/ay1qs4rZPKMUj/JwdprGRYWAVgAWKiwTvLvr42GDEgPYMpQGQQKSpX0tceSgOy9ZvW/49nPP4yQK/ONkxCCCEVQ3eaI7/qlhEW/bC0z7im5Vzm9JOewwgAqTu+Rb8+74ytv6/O+hBIoFYpYIlsqIT0nkFSE4M0VjIU3Hn3ikdfCaCTqpN45s3njOS5ExshICQKRgBIHZDkWvomaZfX3zZZhxZyTevT0JoLgQkhpA4o5Hn9Z7qvlJh1BHk+hwwhNADIoFPUgiqXqA+lAtnqdtQrPXJDhrEQQgipGFqrtEPH6/zxlG3nhwwHinlSOEwBInUiz+Jh3zW7+9Ai4BmT9h1nr2k+45u7/wka+xY8LkLqD2UPqQ5atZNrAbwEYWHvOvb1j41k00gguaABQIYR37qAdDlmEbDdu9PCIRp6JaDekm/IhAwfVDSkMijcctdZ614OoD1VIzUCXPn+vVjoyz8ZIoYpQGQYiE3jcV3DbBelAWnduEY8UkIIIZVDJepqhBw+bseQz8ufV7RT9JNMMAJAqorPSy9N87H17dal2ySLf2Hp48rfnPZ5yWj72mar0QIwKhw3IQQAtQ2pCkkD1wB46tRhyBAwyZLGI4lIE5IZRgDIoBLKx5eeGwrDSkK53b3N+6PvXbH+1YC6MTAmQggh1WTT4s2P/xIywZ81Z9/1LDGhIUAKgQYAGQZCE3AodBvK+be1T7++0kwDIoSQweS6m87HG+CP/EqEvyRC4KuLaZf2IUMKU4BIHehOcsqoM1N9YDm2Xadblnj/RWlBuqOuhtL/2/MdCCEGmvKFVAANdQ1CTp5sjiTfNWC0E1IojACQupF3svR5YmKiAul63P2Wx6GBR3KOjRBCSJ/RSWclPGme6a7wP0PMPq5nh+s8Wzl0HiFWaACQYcHl0Xf19Z0TmvBnGggtHKIUmAZECCGDxa33nrn+ZVNl2XxvNxCKyOf3GQoU/SQKGgCkyvRqQgt5ccw+rn2UR0gl6ooCxk4IIaRPKK0vR2TEN3UMS53PIDDPtV3X1Y+QKLgGgAwivjx+s4+rr60+NIH7cjiDOZ+Lxh6/duP83Z7QwK6BsRPSMwZJMQzSWEk9UVp9H8CRsD8fEgTm/UA9LO0SA4GQ3NAAIIOMxBAw+5rn+MS72Sd97Gozr7nj+Kbz8cYDvqX/DQr8VWBSIgOkK7gKmJSIAn5315lPHAWgM1Xli/JKHEUmLu9+yOMv+cPgHw/xwhQgUidCaTyufq42n5ffNeknnmsAwGWezyaEEFIVtPoess/1IaeRL4Jsnhcj5in8iQgaAKQuFDnphaIB5uf5PELTrrPfU574KYDHChwrIYSQHqBV4/L0IYTRXsdmXsPl/U9f23ZMgU8KgQYAqTpZJjtf+NQl5n3eF1t/23kuT86O4+tfgrMA/FvwGxBCCCmTm+5509pjkM/774sou5A+86SGBCFWuAaA1BkN/49++dYEmH19Hh6z3tZvR51W6jJofV6G70PIUEFVQ8pCQX8vdWgT75JngK/evLbtvzvFPekZNADIoOIT96G+pviPFfTA9ElZnAIEQN87a931TxvfdQ2AvYTjJ6Q4BklODNJYSZ3QnY6+HMBLIXDqePrYjAEYfWGpcxkD5jUIyQxTgMigE5oMXWHSUAjWdl4WD9DMcPEKnA3o7wfGTQghpAQ08J+rz95wHGbO4b7niSsFyPXcSJddxgAs9cKvQIgfGgCkTmQJl4YMAfO6rglbcrzjIaI1vhI5TkIIIf3h65C949+1wbF3GRAmWY0BCn8ihilAZBCISfcJndOdIH2/BRDy5Jj1CbYb092ywk7j2vrQuO+s9Ucu/fauN0FjWdzXImQ4yGLNE1IAW2a1GpcBWDF17HXmCDZYznch7Zfuwz8TkgkaAKTudA0Bm0HgMga6e2mKj1nuHqcNgxnn60R/VSkaAIS4obYhfeef/3jO46/H9h//8qVzhp4HENZnMRb4h0FywxQgMsjkyY30pexI2nyTt+14xmvj5s8d+Q6AzRm+AyGEkB6gGo2vThXNuVvyfMBUf3jOk0YK0khTgAgRwwgAqQOhFCFfFCB03dCkLzEU0u07ogK3rVj750v/75Mug1LnRIyJkOGBcof0l1vufeO6wwC0IXfsuNrNfUwaUGzqkHk+IUEYASB1Q+pNsU2sNtHua0/v094eX37otOvoRrPrbSKEEFIuX0XYqWN69qXzvVk2yVtP4U+iYASADDvpidn1g2AST5BZNvumHx471gWsPnPdoUu+ucvNAA4t5usQUh+oaEgf2ZaMJN8B8Dpsn6ddTh3A/RwIRYXNep9TSSL8+SdCMsMIABkU8kx0roiA7TjGi+PqK31YJACgoP8x29cihBBSBBr4/gNv2LgCbgHfPbZ5/NPtQDj/36xLn2sZGiHFQwOADDqhydPWL8aDIjUCunVmTqgrPLzj+jrBtzWwJTAOQgghPUN9aargmrtnvMjB0g5Mfwak+5nn2PAZBDQESKEwBYjUFQ3/wt/uZKos/X2eHddk3n3/v1lvHpv9G6vP3vD6Jd/Y9etQ+qIsX5SQ2kLJQ/qAAlatfvMTh0K2+NdMD3Kl8AAzXxsaSxYHFyEiaACQOuIS/a42bbSFUnx8odt0jr+2HNsMCw0kn9XAO8G/SdJjBkkh6IEaLRlUtMKnAOzZPUS2CIA5v5vRYOl5oQhBqA8hIpgCRIYFVzjV5emHpT0d5jUnedsPxLiuMePhsvrsDSdora6QfhlCCCGFcO/9czf8cKoctX7LaA85i0L9bFDkk55BA4AMEhKviOT8PKFYwP4auG4f880R4mPdTD6ZYVyEEEIyoqA+iRU4D26Pv+0YsDt9pFFjG74oAA0BUjg0AEidkRgEUk+Nr7+rzfY5zuMHz9x4pAZ+HvxWhBBCiuCJbY1Z30J4Xjc3l9PH9TxIEyvue+nYIkMMDQBSV3wTrCSSEGMQ+B4SUcca+FT0NyWEEBKN0vj8mjPXnAXZ/GzO/6EIgK8eln5mnYmkDyFiuOCQDBPdSVN5yr5zQ5EAlxGgsHMhsO14xyLhB9+6Yc3+ly7+A5R6duZvSYiPAZIOeoDGSgaOCYXRLwN4NTI4a1LXcb32U5IiZJ6Xhp590lMYASB1Ic8kaYsW+Lw23b10IRgCxzv7t7AMDXw2x3chhBASQqtvrj577Wthn8clx0nq3NA1gqMxyhT/pOfQACCDRmhCjJ1Ibf0lXn0zDBzqKz3We0xs/AaA+wLfkxBCSDZaaiT5GNzzuPS4e655DTjKoUhArOCnoUAyQwOADBuusGto8vVN1hKDQfw2oJvOx3lK60syf0NCCCFOlNJfu//MjSchm5PGlv4j/fVf23PEhlmf1UigYUCccA0AGUY0wr8SbP6qr9lutoW8O7b+3fz/LjvWA9y/YOM39t+6+H0aeGbUNyMkwERnvOwhiBhvj4M/BEZ6wHin1bwEwKnIEKGduoYv/cf3mzA2XAZBlogAIWJoAJA64RL2EsEPo09I3Cepfgn8vyDs+nVgWI63l1fg7fiG/jto9a3420CIm62trWUPQcSW1ibKH1I4GvrSP731iVcDaMOfiuOa/6XpP741YsD0/92SqMDMr0JIDpgCRAaRvCFQ10QrmaB9k7Zvk6YBoVv/wPxN3wXwB9nXJETGmm2PlD0EEWvGHi17CKR+bFN61ifgXruVNfcfmPksAMLPBcmzLGQY0BAgmaABQIaVUIjWFPpmm9TTk+UBAwAJVuAdAP4m/qsR4uaejXeVPQQRd2+8s+whkJqhFL7w4DmPvxb5xH+63Iv0H5/gz+v8ImQHNABI3cjqJXFNxC6Pvc0bJDEAooyAB8/feD+A3wXGToiY368bjP9Ot627pewhkHqxZbTZ+STyi3/fucD0OVzi+beVKd5Jz+EaAFJXNKYv5DXL5t48z9duTvZmuZvTb/7ol/nQsK0LSB8rtHCE1upvoPS/Zr4ThKT44/o78Pi2x7D73D3LHoqXGx75OVUQKQwN9dl7ztzyegAduB07vtRMn5CPcQrBKMMow1Lv6s8/EZIZRgDIMCOdXH2eG3Pit+WFZs031QD0Q+ds2B8aPxfHF7hx82xaa/z0wWtRZf64/g94aPMDpd8rbrXZHpvdUl3vf2LsteBYGuGFUfZhnmPbE9IzaACQQaUXE2RoQpZM/ID9AeMr+441AJ1o9W5Mf/80IZn513u+V/YQvFxx97+UPQRSJxQ+dO/5689CNvEf2mL6IrWHo76IZxsNCBKEBgCpI7ETrTlZ+iZsV7vkIeDzKLmOEwD64XM3HK2Bb8qfM9y4ubdfPfIL3LXhDlSR8c42XH73d1H2PeJWk03rmx9asPEbnk5S8S9J6bEduzYTs05H1hMSRbPsARCSA9s7/c02ZdS56s0+tr5mWyNV37C0u7aGo+y9ztxTRv6hqRrnaWC253sTEkQD2DS5CScueVXZQ5nBt+74On68+gdlD4PUBNVIXr/pjZOnYKeI7xjlJLWZx2a9Ns5PR2wlTiA4jm37NBT7pHAYASCDTJ5J0XVujBdHkudv8x5JIwHT2h5769YzAPX3Ob4zITv4wb3fx81rbyp7GNPYOLEBn7v5k2UPg9QFhcsfesuW52HmnCpd/Ouak21v/rHN+dKFwEV69WksEBE0AEhdsU2ottCrzwsjmUjNSdz1XuiY0LLrgZMsXrjx0wAG40XupNIkOsGHfvU+tJN22UPZwd/fdDEeH19b9jBIPRjvqMZfoZj5V+LcgVEPow5GPYw+tmNpf0KiYQoQGXSKTgMy233pQbZUoXRaUPocV2qPLQXI+VlrL8eRi0+d9X1odbrnexMiYs3YI9DQWL7P0WUPBdc9cA0+euOHyx4GqQ8fe+ScjU/FdG+8L80n1GZutogCYDcagJlGgq+cxjQmCCkERgDIoJNnQpR4V0JhXlsEQOJlCnmfnP0fOmfzUgA/yfytCUnx+d99Ctc+cFWpY7h7w5246OfnQ1PfkALQwKMTatYnMDMdJ8bbH3qbmy0C4IsYwNjzPzspFUYASB2IjQLY6l17V1toMbCtXzpa4Fs0HIwWLDx11ucAdS74Y34kJxoaK++/Ci988ouw74L9+v75j449jNOuOhmPbXus759N6olS6pw156w/Fn4Pvm0BsHa06VR7up80VQiOY8kejmMbNCqIGEYACNlOKEybrrN5gIDpDwJg5sPBFzqOiRYkD5+z+SRofLyQb06GnvHONvz5Na/Gzx66rq+fe//m1Xjdj0/GQ1se7Ovnkvqigav+dM7G/SCbf319XO1TH7NjL1knkBbmtrJLuNvqKfJJIdAAIMNKyMvi8tr4rucLH/seKq4Hku81c8kuizddAuA2+VcmxM1Yewxn/+QMfPMPX+vL5/3i4etxyg9fhvs23dOXzyNDwSbVGHkb/ILf9sYfn8PF5/gJOYdsDiUYZUJKwZc6QcggUcRiYOn7+hup4wa2p9I1UlvTsk9vDWxP3enuzfbu5mtrAmjud+miVUlDrwLT+UiBnLT0f+DvXvhJ7DF3z8KvPd7ehs/+9yfwxVs+g0Qn4RMIkaL1Ox8+b8sc7Ezj6W7tVF3baOt46pPUuelrmHsN+wJilxMovWA4vQ8ZDD7DgUYFiYKigdSFXhgAtmuH8vtDbwqy5fc3Au3mWoMd/TddObFk/qtm3QWoIz3fn5Ao7txwB777x29idnMODtr1zzDSGM19zUQnuHL1v+Hc696Aax+4GlzwS4pEKdz48LVb3oHbcSTkb/KJeetPlrRNXyRAIuoJ6RmMAJC6EPq/LDUCGpa9KdTTe9eW9va7IgEhz38oQtAE0NzrW3td2oB9SeMAACAASURBVJgYuxXA0sA9ICSaPebuiTc/56047cA/x34LnhJ9/saJDfjBvVfgH2+7FHduuKMHIyQEk1rrwx49b8vxyOf9Tyxl83qJUadTx2b6pivlCKm9zUhI7111JjQkSBQ0AEid6EUUwJcO5BP/LkPAdewT/SOp/unUoR3X2Psri25XSpf7LkdSaxqqgeftsQwv2udYPH/vo3DA4mdg3wX7oaGmLyV7bNsa3L3hTty89ibc8PB/4JeP3IDJzkRJoybDgIa++NG3btkKu7h3benUnlBfM+XH9gYh75otR9kl/GPFv6SdkGnQACB1IosBYO4l6Tq+SEBzqs7m/e9FFGCHMfDkry44DFBnBO4RIYUx0hjB/NEFWDxrMba0tmBraysmOuNlD4sMF7ftunjzYbetwAWYKdKl3v/EUg4ZAdJ0IUmaEJDPAKD4J9HwHeJkWNDYLsy7exjldD+k+qbp1nXrE2wX4GYuaNM4VnBP/N1rmNfqnqeMsmmM7Og7e2TkHRPt9nJA7R95bwjJRDtpY+PEBmyc2FD2UMgQooBWotXZt63Au7BdvEtScGwLc139Q29vC4l7OMqudB9T/MNoJ6QwGAEgdaOXaUCuKIBt80UBpqXveDYz9SccBfjygl/rhvp3cIE/IaT+/OWj521WCL+1p0jvvyQCQO8/qTz8HQAyTLg8K65J2Df5mt4fcwPck3/IG2XzWoneTPHI27a8AMCnRHeDEEIGl/949CebPwO76O6l9z/0xh8Ye5e3H4F6QnoKU4BI3bCl9WTpY/a1pQaZKUXmea7Qry/Nx5f6Y6YKpdOHdpzzqN784b3V/GMB9XzhdySEkEFifWOk8yZcjvdgpnh3pQCF3swTMhJ8c3nIGMiS6kPDgPQURgAI2U4oJ9M1mfsiAb6Hi6vO9+YIiZcqwfl4d6fRfAOALXlvCiGEVJC3PfyWbWfAPR/6hL5vLnXNq3DU2wQ/Uv2l3v+s0DggmWGeMKkjRf0mQLq+iB8GM89pRJzr+lEwa/vYDyeeM++UWVcCONl5FwghZPD46prztvjEvG8zf7VXp+pCEQSJAeCKFsBRB0cdhT3pOTQASF0p+pWgMefAOJYYCWZ7WuC7yqYhkW7D1h9Nzl1wyqyt0HgWCCFk8Lm7MzLntWM/HFuGsPBPC33pZhoEvqitLUIAS30efNegkUBywRQgMozYwrGufEyf9yYUBg55iGyeJl9Y2tfHttdNrc4F8KDgnhBCSJUZbyI5/fFzHn873KmRvrTKkPCXpBCF5nSJQRBKCyKkL/A1oKSu5E0DSh/bPO1pT71rb/tlYNuvA8e8GlSyTfuMPb407yeNRmMVgLmBe0IIIdVEq3PXvG3zHpj+us7uPvRrv67XfNrqzGun9740oRiDAZhpIMBS770jgXZCvDACQOpKlskxZiGXLwrguq4rCuCKCkhzUr3erLVvHzseSl0UeS8IIaQqXDol/oueL20e/Jh8/yK8/1mfVYTkgmsASJ0pajGwK0/fdR3b54bWA9j6hHL/bdeYtg6g27b1R5ML5p0y6z4Ah1nGRggh1UTpXy+a2Hr6E1fjBZDn8EvbJamYWXP/bc6kEEwLIn2DvwNA6oxGfJpbd+JVqfPNujTpuvTe/NzuA6XbP32cru+WbfVwtKcNgG6/7m8F7Ljm4oktF2wcXfhcpfQRojtBCCHlsq6RjJ5+97vwHkx/c0+sZ9/XXqb3n5DS4BoAUnd6EQVIrwFwrQUw1wG41gKY6wJsefxFrAVoAmjs88W532k3GjdBYY/AfSGEkDLpQOHEx87fehh2CnUz/1+a12+rs60hkOb9M/efDDw0AEjdyWoAuPa2zbUoOF1uWo5NA2CGYEe+xcG26zX3+tKCG7XSK8EIICGkuvzlY2/bOgt2cd6G2wCQbj7xLzEAXJEDGG1I1fuiADQASF/hGgAyDBQdBbC1ucqhNwu5+toMDbNe+kNi0z5j648n959/yqyfaOA4EEJI9bhs7QVb/w0J9oNdeMe+39+1MDhL3r/Nc58l95+Lf0mp8C1AhLjx5XTawrihPFAzd9R8yLjyU0MPqlDbjOs+dv7WUa31l7LfGkII6QEavxlVW89GGy9EeL6LEfwu4e/K+5cYAd0+MPaxuf9cF0D6Dg0AMgxIQ6q2cGzo2GcISLeYh5rvISY9JwGQPL7b2IUArgzcG0II6Rf3qRGc8vD5eDdkTpFYsS+ZP21zs9kGuOf+NBLvP0U/KQWmAJFhoei1ALbrhs51pefY2s063zUk6Uoz2y/Hi+e9evH7Vad9IoC9QQghJaGAJ7RuvHTt27a8AdnTexJMz+cPRQ9MwyDdhtQ+lAqUx/sPQZuknZAoGAEgZDuSKICrry8K4Asjh7xNeTbR9R4/5/ELdVufCODB0A0ihJBeoIDJDtRpj79j8+vQ2zkwlD5pm5/NvoB7zk9D7z+pNHwLEBkm+vVGIGD6W4BsbwWyvRbU9jYg1xuCXG/9GYH77UHO6+7ypQU/aELfAGBx4B4RQkiRaA2cte7tW/fHdA9+umx7249ZH3ozUGIp2/YhY8I0GHxOHnj2rjrrPQq0ExINDQAybOR9I1D6uFuXFv2uV4SmRb6C/bWgtt8GMMV8rCHgq5/2OXt8ef5NWusfga8HJYT0CaXxobXvGGvAL8pDr/tM93EZC3lf+WmL2KbLSO1DawVgafNBA4AUDlOACAnjmrh9+Z+uNps3yVUfE8bOGybXa9+29XBovNPyXQkhpBd8de07xpoodm7zXSM0D7vq0thSPSGoM6H4J6XCRcBkGPFFAXyLen2pQb76mPf8u/qZ5wDu3wGwfWYolQkAMPbj1sL5J478EFCvBCGE9AgNXLFu97E343IsR1jUuzz0Gv4UHpcxIDEEXOsBALnIN76ytUxIKdAAIMNIljQgX5tLZLvKvjf0hMS/bQyS64kMAABq7Kr2nHknjv5cKxwLQggpnh+ua46djrPwftiFunnsS9HJmsPvMwTMdqT2tuhArPcfgjZJOyGZoQFAhpWi1gJIP8N3HdfnmfUSA8BVH2WwjF3VwryTR34DqOUzvhUhhGRF6Z8unLvtNRvOxV9BlvLjep2nxDiQpgn5FvDGLuwNHVPUk0pAA4AMK0VGAXzi3HWez/Nv1vmEv+t6tmuE+k/7LttWtv993gmj2wAcYfkuhBASh8avGnrbSQ+fh/eiuLx/aeqP6em3Leb1rQWApWzuQ4aAtE3STkguaACQYSVkAKT7uNJ4Qm2ha3WPfULddr5U0Ieu6zcoEjx128rW1fNPGO0AOMzynQghRIQGbu6o0eOfeOfERZB7/kNCP+T9j10D4Er7yRIVgOVYul6AkJ7DtwCRYSVmAo4J34ZyQ10PElsoOquHTPqgDD9g23jx47uPna+gvhe6SYQQ4uD3raT58o3v2HgB4sV6SOjHzI+h1B9g+vybNQUofZ0s0EAgPYcRADLsZF0L4GsLefFtfV3jkKYb+aICrvUHofbtx5fj6LGzWu+bu3X0zwA82zFOQgix8fsm1HHrL9jyNsQ5MbKmBvkW84ZSf9L9YWnPs/CX3n9SKWgAkGFHagDYytL0n269tvTz5f37xL95LEkLkl575ve6HC/e9ubWu+aOje4P6EMd35EQQnaggf9qj4y8fP3bd4h/mziX/iCXDrS7DAFbhFWS+uOL2AJhIU/vP6k0NADIsNOPtQCm+Hf18a0HsI03lNcf4+0PRy0ux9Hbrm39aM4rRzcphSNBCCFubsDInOM3nr/xQshSdWwpiS7jIPbNP77UHxjHLmMAjj29/2QgoQFASD4jwBch8J1nXjsmHcj87FAkIpQiJL9egiXj17ZXzn3l6DooHOUYJyFkiNEK149g20nr3j6efttPPzdXuo/NADDPg9GWPraV03tYjrOuNyOkp9AAICS7AWA7vwgRb2vzCXWXYI9JD7J9rv16CZZsu6rdmnPy6DUAXm5+QULIEKNw5eK52059+Dz8JfxpP66IQOjlBL5fBY4xCEKRgG5d2giw7dP4BDzFPakUNAAI2U6/UoFC15REFWznSgS+ZB/qs4PxH7dnzz1h9CoAx5lthJCh5F/Wj2x7/YZz8QGEPfTpFJ9sbynLnvZj2/Km/sByTO8/qSx8DSgh2ylqwVbo4eHyOEkfUBJvV5YHqtQbN63/+gu3zYdS75zqRwgZWvSl6/fc9kacjw8in3C3RQiyiv+QEWCm/iBVD0v/kNh3PUco7knlkHg9CRkW8qQCubznNq+8wnbj21buHptbur6Z2qupfdPR3rQcS9pcdeZnNAA0dv3i/F9pnVwGYKHgHhJC6oNWWv3tExeOKYSdCKbHP723RQTawn6uSILk9wNs+7QRYO5DkQF46lzQQCB9hxEAQnaSN1zrCxPb+oRCzy7PVUwkIPQQlHrWvA/WJ9659SidNF4M4CHvXSOE1IkJaLzRI/4lc4prDgq9FUgaCah66g8hpcA1AIRMp6gogOtakrqYBby2a0iiEba92c91beu4x69uPWf+KaNvT3TyUgB7gxBSZ54AcPKGC8ePgNspIX11Z2g9QIzglxoD3ToYdbD09Qn6vKk/NBZIKdAAIGQ60rS4PAt7zb4admHte1uP7fPSxxIDwFYfsyh5xvG2H7cPX3BM562d0ZFDFXCgY7yEkMHm3k5Hv3TTuyZOxUyRHRLwErGfxyBwRU8lkQFguhFg7n0pP/DUEVI5aAAQMpOYKICr7BPb6TaX+A+d6xurz2gICX7f2ENlAMDYdVg+cWT77XPnj+wF4HDL+AghA4oG/rMzMvLyLRduOwd+4d0P778tb98U+LEpQXDsJak/kvrYPoT0BBoAhNjpVyqQNIJgu37IuLBdT9Lu+1xf/U6ux7Hj17avnnP8yF1QONbahxAyUGjgX3ZZMP7qx946af7AV1do24S8pOwyHszFv6E1TxJhbxoNEoMgdQumlX1rvAipNHwoE2InbyqQJA3HtrneCOQq297QY9bFvuknVBd8K1B6W/T52b9UUN8BsIvkhhJCKkcbSn1447u3/RZtHA25+PeJ+ax1MdEFX0qQLXoAyz4UGUCg7IJGAikVvgWIEDuxk7MvF1T6dglbH4m3Shoqz/vg9T10nQ/hTRdOvLCRqEMB/NZ9+wghFWUtFF658YJtCwTi35bekyW1J0sakE/kd+sA97wKx56pP6SWMAWIED+9SgUKpfm4+qev6xub5LNiFgs3sPOhJU1v2sH4Ne3DJ1a0z57TGtkHwPM84yaEVIffKoXjNl4w/hq4hbckxSddl8XhEBL+rrLL429696VrAWC0w9OPkErDFCBC/PQiFQjYGX3zpQJ1+ynHXpoSZEvnSbfFpAApZE8HUgAai/9hzgNQ+DyAWb4bSggpEYWvbFw3fiEuxkfgjiia4t6MNGaJNobqJZFHn3FgS/nxrReAZ+8ru6CRQCoBDQBCwvTirUC+9QCm+HetBzCFv20dQFewS9YF5FkH4BL93TFMq1/4+blXK60vU8A+vptKCOk741C4YOOF40+DP51QIspDKYSx6YYxqUQ+4e/K+/dFAWDUw2in+CcDBVOACJHRy1Qg0xjQxt72OTZDwtVPY+bYQm/48fWXlL1MXt1+xsjJs89VSfJcKDxdeh4hpIco/KHRSU7ceNHkcsjWF/VC/Es8/CHhLxH/rnz/Xol/QioFDQBCZORNBTLrXO02sd4th9KLzC30uRLR7zvXtibAdmxDt65qHTlxXee7c14xugoKLwMwKjiPENIT9LfnTkyeuu4vkvNhz6UPCXOXyLf18b37P4/oDy36BWYKfVP0W2+OsM4HjQRSKZgCRIicfqcC2VKDzLJvLYArJcdVlqYC5XotqG2sC74w67sqUd9VwCHeu0sIKZq1WuHczRdOHAn3G3V8wlzy2s+YiIC2nOMzDmyLfs29KwLQFeWmcQBMF+xM/SG1gxEAQuQUsSDY1R66ti86kO4juY70WFrWjjbbMWB5GE5e1TlkckXnTbPHRxYBeL7jPEJIsVzXbjRfufXC8VfDLailKUC+CID0NwJ84t9M7/Gl+rjEPzBT/PsW/Zr1MNop7MnAwocsIXEUYQS4IgPdsvmGIN+i4NhogORHw2IXC/vqXJ9jG58C0Fj8udnXa43/C+DJthtLCMnNhFL6rzf+YvKTuBx/i7BXXer1D3n4XcaAJH3Il/7j8v4jdRwyBnxv+OFbf0jtYASAkHjypAKF2m1RAt+iYDOFKDTG0Fhc4/CNNXQN12LmNDsekhNXd5aMvHLOW5RKngXgGYHzCCFx3JJofeLmiyYPx+04Fv60nxjh3wvx7zNEXNEAM7XHlesvzfvPI/4JqSyMABAST2wUwFX2LfaNWQ8QEw2QeOezvDbUtw7AfBWo2U85yo0Fn531XwrqC1DY032bCSECxhXUxzetH78EF+OvMdNbbhP/Pi98yGufVfx3y65x2Dz+tjQgl/j3GQKuPQL1LmgkkMrCCAAh2ejVegBJHr9rDL5oQOz6AElUITZKEM3kNZ0nzzu+c4ZWI7sCOCzv9QgZUm7QiT5587snluF6HAN3yoxkSwt5W86+JB3IZ1yY4t8n7NPtgFvcx4h/BI4p/kkt4MOUkOz0Yz1AlkiA16vu2Wwe+5DHP+aHwaLeDGR8t8aCz825Rmn9FTAtiBApGxXw15tumPgCLsdHEZfuk26P/eGvWPGf9Qe+XOk/5hoA2z7Lol+z7ILin1QeRgAIyUc/1gP4rmvm17uMiFhCaUq2cdjapWsRzO8x4wE6eXX7wMlXdM4YVc0RAEdh5+JoQoiBBq7sNJonbb1o/KVGrr8v5Ufi+ZcIf2mqT4z4t3n9Y8V/KP8/VEdhT2oDIwCE5KPo9QBmXbdsvhkotBYgTzTA5b2Xvvff9TagUATAFg2wjV3N+/ToFc2G+rIGls2404QMNw8ohXdvvmjSfK9/rAEQ+95/m9e/SPEvNQZcRkCWvP8s4p9GAhkIGAEgJD+9XBTsa7dFAHznd8uxUQtfmzS64YoQSN7CMYPWyuSgyes6Xxt9ReM6BXUUgAWe6xAyDIxp4NPzRidfv/7CzhmwC37fAtosEQDfD3dJ3hjUK/FvE/VZFv0iUJ+1HyGlQwOAkGLo16LgtOi3iX/f9bIszs278FcaZg999swHdIKlrWuSPXZ5Wed1rUazBYUXABgRXpuQ2qCBK0d14+TN75k4fOwqvBhusRwS/uk2m0CXvsEnJPSzvuPfTPlJ3YLgBkvZuI3eY0JqBQ0AQoohbxQgfRzKuw9FAHxjyvqGHt9nSPP/s6Ycer1zW3+CYyZXdkabx806s6n07lB4bsbPIWSgUMBNSiWnb3l3a5fxle1lmOn1D0UBJB5/l4e/iOMY8W/uXfn/seLfth6AqT+k9nANACHFUZQRIFnUK91C+fRFrA3wrRWIfSOQbxzm+G3HasGn5qzUjeQzAA0BUlse0dAXj/2y9XVcjr+D3VNeRNpPXqFvpgglRn0vxT+m2gG/+HfVmWUfFP9k4GAEgJBiKcII8LVnMdpDEYWs13PVmRGBLPn/Pnznqclr2we0zumcPrq5cb+Cei6AJ2X8HEKqxgYNXDJ/1uQbNr4rORW346UIe/2lQjtL2o5P+JvRg6zi3yX2TXEvEf9pisr7J2QgoQFASLFkSauR5Nlnzd8304R6YViE6tLkeajKvXSX42Wtlcnurdd0XjOr3bgbWh0KhV1yfDYhpaGBLQr4zMjsyRVb3tV5yVSev00Y+7zmRXj+QyJf8uagWPHvMgZgqTdFvSn+be3wtEugoUAGEhoAhBRP1YyAdLmovPzQNWKMA98YQg9X3wNd4Uq8tHVNskfrqM5rR+c0HlLQywAsDFyTkKowBo0vqUbrtK3v6bxw4sdYDrvoL0rwu4R7SORL6/ol/n15/SHxHwPFPxlYaAAQ0ht6sSjYVQ59Vkj0h66jA+2+a8fUhZBGAEwhoHA9Xtpamezaek3y2pHJ5lo01KHgq0NJdZkE8HWg9Zqx9yaHt67BC+BOh3EJaV+b1NsfK/BD13F9fhbxD8R7/M162zHz/slQQAOAkN7R6zcD+eolY5EYE6GHXLe/rZ+Z/2/2t7X5Pk8SDfD3uRLHtlcmixa/pHNaWzU2A42DQEOAVIetCviyxsiKbe+dOLS1Et0f83Kl+MR4/W3tsSI+i+iXCH7Xd/GJf5sxYDMETHFP8U8IaAAQ0mv6YQRIPytL9KBoJALf9nrT2Ie2SwAAAMauw9Gta5N5raM6r5o1q3k3gAOhsKd35IT0jseg1Sebs1tnbLkoeUF7Zfv5cHv7XZ5/l9CPSQdK/7iXKezNxbuxot82Jqn47/4N5xX/JlnEPyG1gK8BJaS3ZH3XvtQIsL0eNF22vRYUqbLtdZquV4eGXtfperWnpN3WJn0lqG3srleGuu4D5n5y5Ieq0Xg/oE+y3H9CCkcBdyXAF7eh9RW8F3+DcK67LU3GJfhjhH+sV9+2qNfVXzo+m5EjiQDEiH9fuqAUGgqkFvAhR0jvKdIIcHnxe2UExIrwrEaBijjHNlbbeMzv7CujW57/qdF/ShT+QgGvBzAKQopnFdD4+NhfTUyijZfBLXh9ZYk3PSTIpV78PN5+6Xgp/gnpI0wBIqQ/FGUE+NpD50k+L891TCQPy5icW1v+ru0asQ/2dLqRbl2bPLe9Mtk6cuLsNyroMa30AYDim4NIXjZr4FtK4byx97ae1FrZ2R8JlsIudiWCPquHv5PapF78GKPAN9YY8e8S/UWK/xgo/kmtoAFASP8YJCMg1OZ7oKZz+G0PzaIij7Yx+IyBkFiYdl77mvaRrZWdZntRcvLI/o1fKKXmAngmdkZQCAmjcJNS+Jtts1tvbl+UvKC1MvkzuL3cNhFsE8oh73qMsWAzCIrw9EsMgLLFP0U9GVqYAkRIf8kiyl0GQBHpQNI0Gde6AEkaUNGbJC3JNVbJd0WqDqk6Ne8z+LRORs4E1HkAngZC7GzUCt9rKnxp63tab4RbwJpiN5TyI0n38Ylw6Q9yxXj3YyMALu8/UF3xT0OB1A5GAAjpL7FGt88IMOuLigToVNn1WYD9odjPB6X04e4SCBIRMS2K0VqJo9rXJvPbi5NTZ+/b+KVWajagnw5gJON3IPUhAXC9hv5f45vbb2l/IFneWpkcDLuHOyTkXRGAGO9/TIpQ3giA77Ml4r/7N2a7T916YObfKsU/IRlhBICQ/lPkomBXnzyRgFAUwOZdz/P2HtvCYAX7AmHJ9SRRALPd5v0374X9/n0aH5mN5ssVGqdB69cCmAcyTNyktfp2s9O6fOz9eC/s6T2A3dPvS4GxCWZXFEAaAcgq8GOFv8vAcUU3XPclfd8o/gkpEBoAhJRDv40AwCdi49KCbIaB5K08edN7Qn1jjAGXYeP67rZt5j39GD46Z7R5qoI6TQOvADALpI7cDuByNEa+Nf6e8XdM1ZlCVbJJhb9L9BcVAZAK/FCbywDwif/QhkAZRpninxABNAAIKY9+GQHm3idmpUaAy7ue1xBIe/5jDAPb59kMhKyGAIx9+r5Z7+eiT+OSSYy+GlqvAHAMgNkgg0oC4LdK4fs6aV82/j5cOFXvE6suwd+9nk/4hzzpIfGf1RCQ9JUYHSEjRrohUIZRpvgnRAgNAELKpR9GQLfsEqyAW+Smyz5vekh4Z/H4F9HmM1R83wmWfpLNfn8/jY/M6YwsRwMvR4JXQeHZIFVnHaD+HcB1jU7ryqn0HsAt9oHpAt/chyIALuFsq4/x+sdGAlz9XcI/NF6Kf0IqCA0AQsqnKCOgW1ekEdDtK4kCmN50SSRAGh2Qnuv7PJ/B4iubUYBQFMW8vzPu/ZzPzPkiOp2XA3g5oE8AsACkbBIA/w2F65DguvGt7etxMT421eYT/VJBGzICQqkzMRGAvBGBGGND+h1c3912D133HKk9HHWuPhIo/snQQAOAkGpQFSPA5922iWBb6kxMWk4vogExEYBYQyB2M+/pzH+Di/GR2Quaxyioo7XCcmgcAWAOSK/pALgF0DdojRtmtzo/3fxBfBAyAWoTrEV4/W1GgU9cZ/Hex4r9kKFhi1rYvg+MMsU/ISVCA4CQ6lCmEQDYvdc+Y0ASEZAK9JDQl0QAQue6DBSJIWDWwdGevo+iaIBRBi7G+0cXjD63AbxIA8sUcCygnwKSl61QuFkBN+hEr5pod27AB/ARyIVnFq9/EcLfJ/p9nn9pVEBiHPgMAtMAgGPsZj1S/V1GgG3vqnP1kUDxT4YOGgCEVIuyjYAsXm5pRCDkkc9iCGQxLiT7IqMBwExjAMbeW579KXxZoflCrXE4gIMVcLAGdgGxozCmEtyuG/gdEn1z0miuaj2ldQtW4P/ALfLN4zyiH3CLfJuod9VJUm+KiAhIPP6+8fnKofvkq0v/u5jlosR/lv6EDDw0AAipHkUbAa5+UiMAmJn+ky6HhHKM+Je2ST3+obQgm9HiMmJs39l2T2z3MHR/bf8O5vG0/byP45NJY+Q5WiUHaahlDeA5GjgIw5c+9AiAm5RStwH69kQ1b5rcb/KOKbEPyEQ+PHU2YW/bu7z9rjpTMMekAPU6IhDj8Y8V/bZ7SfFPSJ+hAUBINamKEQDMFLXpY5cwNss2ge3zwmfx8vvO9RkFPkPAFQ3w1dmMJjj6+MpmXfj4YvzPWYtmPVPpzlJovTTRjSUKeimglwBqKYBFGDzGAbUa0PcpYDWg79NK3ac7jdWtBa078E78baqvSzzGCv502Vfn23xpMDEpQLEeeqnnP/Q5PgPFNe5uGZayywig+CekBGgAEFJd+mkEmPuQ19q2+YwBqcc9j8iXev0lhoBv/CHxH2MM+O53lr2rbjufxv8eTUaXqiRZopTeV+vGbrqhd1Mau2mN3ZTSewBqNwC7obe/aDwBYB20XqeVWqeAdUpjrVZqnVLJOq3VmqShVs9qtO8bew/+Cm7RFxKIPoNAKvptIrZbLxH/PtHsWcGewQAAEhZJREFU86rHCnabeI+JBLiuaWt3iX7T0LHtKf4JqQA0AAipNrF/o27x5xeL3bLN8xxKa0n3sxkDLk+6TYjb6vOmEPmMgtDnu4wCWOpjjQGfoWWWbW0wyhIjwPf/Y3qfT+ODc1vYTc/CrE5rdBEaaKKDZrOhFwGAbuh5urP9x810A7soYDMStNFAp6HVJgBoNzCmtJoAgGaztR7jSMYX4XG8Ex9NfZ5LiLlEn6stJCCziH+XMWDbSwS/r2wzDkICPY9h4OubroOlzSb6Q5vk3iO1h6XNrLeVJVD8k6GHBgAh1acKRoDUqy1NCbKJb4mHPovQ90UbfHtpOevmuq++smSP1LH0/4CtXVovJSTiXPUur69E7JvHRYt/n+CViH+Xtz2vURBjOPjGIRH9oTQf8/7ZxL7v35Tin5AeMFL2AAghQTTixFe3v+08W5tZRqqPWe72aWD7A12l9rDUhYyJ9PW6+/RnpsfbsNQlxrmu9kaqPX1O+l6Y9cood9vNsmuTRE5sIt5nENjuJRzt2tIGo873/8p3Xgwxwt9sc4n/dNkn9s19rCEgFf9mnc04kKTRFBUNsLW72iRjC7XZ7pHtPrrKcJQp/gnpETQACBkM8hgBMM5NPwSlwt8sp0W/6/O7AthlDKSNiHR/nWpPf7bNGEifYzMG0kZB2hiwGQJmvU3oxxgBiadN4vmHcQ/MvcQIgFHX3dv+D7jI6/lPExJgLsEfqveJffPYJfjNY4nA9XnEQwLa5223iXef6Je0xXj9QwaAbzPvEcU/IRWEBgAhg0NWI8B1rssQMAWi7VoNzHwYm6I8LYBDxoD5+TZjwGYk6FR9+jjk5U/3t3n+fULf1S9rFACWft2yacBJIwPmORDUu/r2QjhJhJxNCLrqJGI/fZ5PoErFrcvLb2uTpNbECH/pOVlTfXyiX5ryU6T4zyP8s55DSK0p0rNDCOkPsX+3NlFoa3f183mpXQJWsoXWBtj2vjZbfr+03nbs+nzXMSz1oc1370L3HpZ617F5jq/ebA/VhQh5cl31EuEv3fvKISMACKcApcWy2R4qS8V5SNyH0nmkRojEoLHdE4kRYNvD0test5WlUPwTYoERAEIGD404IZbubzu3W+fq132Amh7pdNmWEqRhT7FpGOeaAtf0gJvXSLeZfczvEqp3pQUpy2cg1WbrB0u9ZLOlCrnuja8Mox6BNrPe1mZSlAHgand5fmOMAbM+RvDb6qRbyINuiwpkMQjyCPzQeOD5Hq57A0u7rwxHmeKfkD6SZTInhFSD2L9fn/BLH/vqXZ5mn4CVbDYPPzDTA++LBHTLRfVxtbnG7auzbd3vl95L7qfZz2xzHcNybPs/FGMQZMEl8m3HUtFo24fKiXGOJLUlq/h3GQOu9Buf8PedH+vlN9sAtyHgu0fSew5HuWjxT+FPSAAaAIQMNln+hkMCz+c97palnumQYWBLnXGl04ReyVmUISBtjzECut/HtvcZBtL7DOGxqw6OOklbDC5h5hOARQt/s04icG31NqEcswZAKtxjPfuSz/MZLr7v7LtXZtnWBqPNVm/WUfwT0gNoABAy+FTBCEjvbQtaY9YIZDUAXGJf6u23iX5bu2tsZr1U8PsMJJfot9WFDAEYdbZ+Jrb+sYQEnq0+5CUOCc1YwyAkcHuxBiAk5GMNhRjhHzJYJEaR5L4idRwqS45DUPwTIoQGACH1oGwjwDz2CVpXm00wm22xhkARfSVGgGt8kg1wGweS+4qIvatO0lY0EiFoqw95mH3i3+a1tgncbr3LQHCJaV9dEVEB33XSe9v4Q4t8XffCZzCZ99J1bJZtQt33/0ECxT8hEdAAIKRexP5NSzy8Ni9xjPA3yzaxm673CWhJGk5IvMf0jflcs076ncx7YrtHWcpwlF3/lq7/Oz5DwUdI5LnqJaLf1+bzSLvqbH2KFP9ZBH1R/UL1tnuT1euf3rvKFP+EVAAaAITUj14ZAWabywgwj21lifc7qwEQEuaxBkNWI8D8btLvaZ4nuZ9m2XUMRx0cdb14RtgEfujYJyQlRoDUKHB5xF3pQDaRbWsrw0CQjs1nAJn3rHsN171Mt8HR14Tin5ASoAFASD3J8rctFYZSD3OMaHUJYN8+JMZj+8QaES7BLzFkfOLfVye9v769q85sc7UXQUgsuvr4hH6ozidopR7x0KJZ7emXRdRLhX9obC5DwHZ/Qot7XfcYnrJNpLv+3aVQ+BOSAxoAhNSXXhgB6XqfIeA6lgpdiUHgE9KxbxXKakCYn5HFGJDei9C9NOtsx746W9kk6zPDJ9ZcIjEk+s1jiVh1ebsBmTHgEtMhIR4bEZAaAa7Pk2627+26P777CaMMo5+J7zwJFP+E5IQGACH1Jq8R4LqGxAgw9666WINAsvlEuDQy4Drfdw2fMWDbZzEIQmXbPlRnll11Wf4/Sby/6TqbkPR5mPMKf6khEEoH6u6lAl1iKEj6+j7DJ/x9db6y69gsS/7dKf4JKQkaAIQMB3kNAakRYDvPdRwqF2EI+AR9FpEf+7afmPUNsPQ3zw3dN3MfMgBCx714Rki8xuZxSHxmNQLMY5+X3DQQpB74ooyDrMI/PdYsRoC5l/z7UfwTUnFoABAyPOQ1AlzXCHmWu8cSoRoS/7Y+eQ2BWHFvXsN1PanYl7wC1HX/zHseI/5dBoGrXxFkEfzd46xGgFknecONywCI3SQLhiWCP30N6XjM72r77uZ3dt0z27+LpCxtl0DxT0iB0AAgZLjopxFgHkuEv6Ts8pbnNQzyGhBZDYBQvVnuttv6mHW2Y1edeQ0Jtn5SoRbrSZYYAWa91Nstqc8j/rv7PP1D10q3h+qlZdcxLMch4e/qE4LCn5AeQAOAkOEj69+9xCssNQQknuuQAO6FIWAT+nDUhYwAs69krGZblvvj2rvqfPW2PlnwibiQJ9nXLvVauwS+rd0npvMaAy7Bn+farrHGfHfzftn20rKrjuKfkApBA4CQ4aQII8B1HakRYO5jjAKb4M1iCKTPixX2MZ8hHYPv++UR/657C0u7q75IAyBGMIa80VLxb6sH/ClBkl/ILWtzjcv1XUJiP3SfYTnulfDPcx4hRAANAEKGmyIMgZB4DBkC0uOiDYGYvlm39Ge4Un4k47KVfXWSPQL1MXU+YsR/L40AV9lX56uPFequ49jP6p5v9jW/X/pHu2J+wEsi/G1tof4xUPwT0mNoABBCijACXNdxGQq2c33C39znMQR8bSHxHvs6z9iFvqE+vjKE9b46s83VnhWpgLQJSYlQDYl9V1liBGT50bBQ/yznmW2+7+S7N769WXb1CfWNhcKfkD5BA4AQ0iXLfCAxAtL1vv4Sz7XLSHB5zNN1MQaAr00q7rMYImY/V1vofvgEv6vd179oJIZAyPvsOs5jBJjHEvEfapeIeMm1zLas39W3N8uhtlD/GCj+CekjNAAIIWnKiAa42rIYAr6IgETkA/K38+Q513c923dyfU+zXbJ31dmOpW0hfOJO4ml21WUxAlztMXWAXPRnOdc3hpjvGhL6FP6EDCk0AAghJnnmhRhDINTXJvTNvcRIMN8aJDEY8hoFkrpQn1DZrJMc++psZZMs/zdCAs8mTl31LtEaK/YlZZ+AjxHuvuvaXs/pemVnlu8iOYbn2PVvR/FPyIBDA4AQ4qKX0YB0fZGGgHkcKucxCrIYDtLPNsu+d/+7xHvovvnK8NTH/L+QeI7Nepu49Il+s84ldl1lieCOMRBcRkOM2PcZHq7vKxH+cNT5+tjaXccxUPwTUiI0AAghPnodDUjXl2UIuNpDYrwIz750LJLv59u7yhLRX+RzQiIgpULVJ/TNvaTsao81HGKu4WqXjtVscx3DUWc7lhpt9PoTMsDQACCESCgqGiCpy2IISI+zGAKucuhakld52jz8prffd570GII6WNpdSP4/SIRerAHgqwsJaEmfLMLdd53YX+CV9pUew1FnO6bXn5AhggYAIURKP6MBoXMkojZPhCDUHmsMxFzLV5Z+H7Ofr87Vx0XM/4OQ4MuSjhISur52qZEQEvi2uljjITRmaRss7a46BPrZ2n3XkELhT0jFoAFACImlSoZA9zjWEDD3MWI7bznveb69ry6L6Jf+e/mIEZI+0W/Wu4S+qy1GjLvaQj+olfczpEK/n8Lf1zcEhT8hFYUGACEkC0UaAb7rFWkIuPpIDQNJnyyiHvAv8nV9nuvYvLbrvoX+HXr5fAgZBT5hb/aLTYuJFdwxYj0mwmDWSccPQZ3vuB/CP++5hJAeQwOAEJKHfkQDzDaJIWA7JzZKEGMo2PpIr5XXUHH1N/tI6m0U+ZyQeP9tdbEiOBQxiPH6Z/HWx3xOlmNfnUlI+Nva8op3in9CKg4NAEJIXvLOI1nEqCSKkDU9JuY4qwER0xYzLts5Zp2kzVaf599ZKjBD3n5XnxiveYxRkDd6IL1e6DxXH7PeVkfhTwiZAQ0AQkhRlGEISM6LMQRsdbHiX3JeXgMk9nv46l3nS7D1jRWBPtGfrpMKYdf1JII7j3FQxD6mznbs6+s7j8KfkCGDBgAhpGiqagh066QGQoxxIBHiMdGCrONBoI+v3tbHdZyFGNEZigSErhUS6K4+scdZ9rZr+erMc23Hvr6+84oQ7hT/hAwgNAAIIb2giLmlKEPAdy1bfdGRAklf3z5vne3YdZ6PIlOAQn18Atc8lgrpIgyCvNez9ZHU2cbgOs8GhT8hZBo0AAghvaQXhkDoutJUl3Rdr9OGXOf5Pl8q7ItO9enncyFPFEBaH1PnE/zS/nnTeWK8/a72LPVSKPwJqQE0AAgh/aBqhoCtPiZlphcRhKyfF1Pv+1wXRT8nQgJSGglwteU1ClzXymtcuD7DVi+pixH4RYl2in9CagINAEJIvyhqvslqCMScm0VwFxlFiO0T8x181w+R5d9QKhpjxG2MYSDxrPdCyPdT9MdcLwsU/oTUDBoAhJB+U1VDwFbvO6dXRoKkPtQv1Ca5dj+R5qhnMQB818+bmpPH2HAdS84JnU/hTwjxQgOAEFIWZRkCZntsVMDW3osUnaxpPZL+MdfpNTEC1ifybfVFrSPIU29rk9TFpEpJz5FC4U9IzaEBQAgpmyLnoTxRgdjzixTxEuMits1VJ7lWv5F6xX1teaMDRXvzsxg2vj6x18wChT8hQwINAEJIVSjTEDDbY7zm6fqiIgeS8RSZ4lOFZ0HexcHp+n4ZCZJx5RX9sdfNAoU/IUNGFSZ9QghJ02tDQPIZ0gWyvRTuWRfvFiH8+/FsiHmLjas9q7iWtmdN0+mV6JecFwOFPyFDCg0AQkiVKTsqYPbJK6azGAzp+ryLnF3tVXgWxHq5i1pMW0RkIc/nS65D0U8IKZQqTPqEECKhasaA75y8Qj2L0ZEnUpGF0Pl5hGYWEZzFM98LwS8dS+haFP2EkJ5BA4AQMmhUIUXI1idrjn1WEd/LBb5lpgBJ+8em0PRiAW7sZ8WOJy8U/oQQKzQACCGDStHzV5HGQOi8IsV7VrFf9qLgrGLb168oo6Doa4X6FC3UKfwJIV5oABBCBp1ezGN5F8fmFfGS9l4YJlWjiIXCec7xtWc1UmLPj4HCnxAiYpAeBIQQEqKKxoCrb16jwNenlyk/sdfOK5Rj++fxxPv6xIyPop8QUmloABBC6kiv5rai0mbyeOaLEutVn/97kSIU06+fBkkWKPwJIZmp+gOAEELy0st5rpcGQVHX6PW5RVH0W4Ni+xYl+LNcq4jPJIQQMVWY9AkhpF+UZQxk+eyivfeDPN9nEb5FCvS86wfyQuFPCCmUQX4gEEJIHno9/xVtEEjO6+caiCz0Owc+6+dR8BNCak2z7AEQQkgF6IczJM+i3iIZNMdPUYt6y7q+FIp+QkjfGLQHASGE9Jp+zou9WPjbj+tIrlXFH7Sqitgv47MIIWQHNAAIIcRNGXNkvxb+xpLns/otdItYFNwrKPoJIaVDA4AQQmSUOV8O26LfEEUvCu41FP2EkEpR5wcEIYT0kqrMn3nHUZXvAeQXylUS2lUaCyGETKNKEz8hhAwqVZ5Lqzy2WKosqqs8NkIIIYQQQgghhBBCCCGEEEIIIYQQQgghhBBCCCGEEEIIIYQQQgghhBBCCCGEEEIIIYQQQgghhBBCCCGEEEIIIYQQQgghhBBCCCGEEEIIIYQQQgghhBBCCCGEEEKGmv8f4iufju7kpFkAAAAASUVORK5CYII="/> + <mask id="mask-0"> + <g filter="url(#filter-remove-color)"> + <use xlink:href="#source-35" transform="matrix(1, 0, 0, 1, -18, 0)"/> + </g> + </mask> + <g id="source-34" clip-path="url(#clip-2)"> + <use xlink:href="#source-28"/> + </g> + <g id="source-37" clip-path="url(#clip-3)"> + <g clip-path="url(#clip-1)"> + <g mask="url(#mask-0)"> + <use xlink:href="#source-34" transform="matrix(1, 0, 0, 1, -18, 0)"/> + </g> + </g> + </g> + </defs> + <g clip-path="url(#clip-0)"> + <use xlink:href="#source-37" transform="matrix(1, 0, 0, 1, 18, 0)"/> + </g> +</svg> \ No newline at end of file diff --git a/src/assets/cursors/macos/cross-1__50-50.svg b/src/assets/cursors/macos/cross-1__50-50.svg new file mode 100644 index 000000000..5f2f55226 --- /dev/null +++ b/src/assets/cursors/macos/cross-1__50-50.svg @@ -0,0 +1,7 @@ +<?xml version="1.0" encoding="UTF-8" standalone="yes"?> +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="767.314286" height="746" viewBox="0 0 767.314286 746"> + <defs> + <image id="source-24" x="0" y="0" width="768" height="746" xlink:href="data:image/png;base64,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"/> + </defs> + <use xlink:href="#source-24"/> +</svg> \ No newline at end of file diff --git a/src/assets/cursors/macos/crosshair-1__50-50.svg b/src/assets/cursors/macos/crosshair-1__50-50.svg new file mode 100644 index 000000000..4f76ba3d9 --- /dev/null +++ b/src/assets/cursors/macos/crosshair-1__50-50.svg @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="UTF-8" standalone="yes"?> +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="767.314286" height="746" viewBox="0 0 767.314286 746"> + <path fill-rule="nonzero" fill="rgb(100%, 100%, 100%)" fill-opacity="1" d="M 428.796875 326.375 L 428.796875 139.875 L 336.167969 139.875 L 336.167969 326.375 L 149.355469 326.375 L 149.355469 419.625 L 336.167969 419.625 L 336.167969 606.125 L 428.796875 606.125 L 428.796875 419.625 L 615.605469 419.625 L 615.605469 326.375 Z M 428.796875 326.375 "/> + <path fill-rule="nonzero" fill="rgb(13.72549%, 12.54902%, 12.54902%)" fill-opacity="1" d="M 398.019531 357.457031 L 398.019531 170.957031 L 366.9375 170.957031 L 366.9375 357.457031 L 180.4375 357.457031 L 180.4375 388.542969 L 366.9375 388.542969 L 366.9375 575.042969 L 398.019531 575.042969 L 398.019531 388.542969 L 584.519531 388.542969 L 584.519531 357.457031 Z M 398.019531 357.457031 "/> +</svg> \ No newline at end of file diff --git a/src/assets/cursors/macos/help-1__50-50.svg b/src/assets/cursors/macos/help-1__50-50.svg new file mode 100644 index 000000000..7b292313f --- /dev/null +++ b/src/assets/cursors/macos/help-1__50-50.svg @@ -0,0 +1,7 @@ +<?xml version="1.0" encoding="UTF-8" standalone="yes"?> +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="767.314286" height="746" viewBox="0 0 767.314286 746"> + <defs> + <image id="source-26" x="0" y="0" width="768" height="746" xlink:href="data:image/png;base64,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"/> + </defs> + <use xlink:href="#source-26"/> +</svg> \ No newline at end of file diff --git a/src/assets/cursors/macos/ibeam-1__50-50.svg b/src/assets/cursors/macos/ibeam-1__50-50.svg new file mode 100644 index 000000000..557830481 --- /dev/null +++ b/src/assets/cursors/macos/ibeam-1__50-50.svg @@ -0,0 +1,4 @@ +<?xml version="1.0" encoding="UTF-8" standalone="yes"?> +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="768" height="768" viewBox="0 0 768 768"> + <path fill-rule="nonzero" fill="rgb(0%, 0%, 0%)" fill-opacity="1" d="M 459.496094 241.371094 L 459.496094 219.429688 C 429.679688 217.621094 400.996094 231.09375 383.351562 255.195312 C 365.710938 231.09375 337.023438 217.621094 307.210938 219.429688 L 307.210938 241.371094 C 335.90625 237.71875 363.285156 254.511719 373.039062 281.746094 L 373.039062 393.65625 L 351.097656 393.65625 L 351.097656 415.597656 L 373.039062 415.597656 L 373.039062 506.878906 C 363.21875 534.058594 335.890625 550.820312 307.210938 547.253906 L 307.210938 569.199219 C 336.984375 570.804688 365.578125 557.371094 383.351562 533.429688 C 401.125 557.371094 429.71875 570.804688 459.496094 569.199219 L 459.496094 547.253906 C 431.277344 550.175781 404.691406 533.535156 394.980469 506.878906 L 394.980469 415.160156 L 416.925781 415.160156 L 416.925781 393.214844 L 394.980469 393.214844 L 394.980469 281.746094 C 404.625 255.039062 431.257812 238.371094 459.496094 241.371094 Z M 459.496094 241.371094 "/> +</svg> \ No newline at end of file diff --git a/src/assets/cursors/macos/ibeamstroke-1__50-50.svg b/src/assets/cursors/macos/ibeamstroke-1__50-50.svg new file mode 100644 index 000000000..7d40f01d1 --- /dev/null +++ b/src/assets/cursors/macos/ibeamstroke-1__50-50.svg @@ -0,0 +1,7 @@ +<?xml version="1.0" encoding="UTF-8" standalone="yes"?> +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="768" height="768" viewBox="0 0 768 768"> + <defs> + <image id="source-24" x="0" y="0" width="768" height="768" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAwAAAAMACAYAAACTgQCOAAAABmJLR0QA/wD/AP+gvaeTAAAgAElEQVR4nOzde7QlVXn3+1/tvtBNEBBjt0hAsQVthCMEHHiNCQi+gmBevCXmIPgeIw7HSMRc+x2v51VzXo/BS6K+x6hooraBkfGqYRwjGGODBJUjIl4CAQQhXAbQNIpAc2vovef5Y/ei5577mZdaq9ZaVau+nzHWqKpZsy57Va29n2fOWWtLAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPCEatonAKD12vB7YlLn4CZ0nLrael6hNtwrlkmfVxuuVxvOAUBLtfWXNYDpm/Tvhy79PppmcNW2wG6a161L98zApK9f2+4XAC3QxV+eAMZrEr8X6hxjWr+nhgmc+hLcdSE5nPbftzrXZhLXkUQAwBOm/QsSQHuM8/dByb679vuoNKCaZOA1rmO1MeDv2v0yUHKNxnnPkAgA6OwvUADNGsfvgtQ+J30837gDoGkHeE0cZ1J/G6Yd6A+776avX2p/47hXSAKAniMBANDk74HYvoY9Rlt+R40aMOW271NAlrumo1zzUe8Xa/tRrs2w28a2a/I+6dM9ByDQlj+uAKajqd8B1n7q7nvSPQYlxhW4T7rFd5rGcV3HmUQ0pakg3qrf1D0ya/cagEJt+CUJYDrG0VpaZ79N9xaU7mcSwVMTQd4w+2mLJoP+cd0ndbdv4lqMEsyP6x7p6j0GYAQkAED/NPG5H7bFP6wzarIwKaN+o0tT3wjT5mCtiUB91J6kad8nA8MG9cM+PzKu5ATAjGrLL0sAk9P0GOu6wzGaGr4xjt9fTbXc5+o18Q1C0w7YmmjlbyqRbLp+HU19G1Tde6TpRGDa9xOACSIBAPqlyeC/TiA/bLA47mFCMaMMtximTlcSglGD/mF6gEapM8m/cXUD8tJ1TScOdfYFYEaRAAD90dQDl3WCwLqBWWnSUHo+dY2rRb9OIDiuFuVhjKOVf5QeoGGHkNWtW2KY61Q3saxzbzSVCJAEAD1AAgD0w6SD/zpDO6rM+rrn0ZRhhliUttxOIyFoQtNBf1O9SHWOOQ7DBOp1koFh7yuSAAAmEgBg9o07+B8m8M8F/cMkBaV1mhjGUxpojdL6W+c4dfdbahyt/KP2/oySFJSuC41rKE8qGRg2ESAJAJBFAgDMvmE+5yUBXd3APxXU19nXNH5v1W2BHUdC0ORY7yY0GfSPkhSMcm+U1G9yWFhJwF9nPyXrY3Vypn1/ARgjEgBgto0a/A8TdJUG/iUJQZ3EoinjGM4xbEIwieEepYYZ1pUqH6VnqM6xx/V3rm4vUKx+3XJreZSEM4UkAJhRJADAbBulRbRuq22dwL+JJCFVnls3MOqwnKZaeevsN3dOpdvk1An4U+uaSPSGSRZK9ltHnWsyTHA/aiIwjiSABACYUSQAwOxqOvgvbfW3ArPSslzd2LmVrMuZdNCf2ucoyUDJ+lLDjrEfJugfV6JQsq6OYQLt0gC/blnJsUvWp5AEADOIBACYXXU+33XG/OcSgWSZc+73JG2ocW5N+KWknZK2S3pU0s8l3SPpbkk/r6pqTWS7cQb+dVpy67TyjtOoQ8JG7fkZKRFwzt0n6VeN12pJvyJpraTYvTBON1VVdZ63HEsYSgL/pp8JIAEAZhAJADCbxhH8D9Pqv2zZOff/SjquxvlNwsOSbpX0H5Ju8abXSbqhqqonGds0EeCPIxnIraujtEV9XIG9dd8ly51zTtIRkg6R9EzvdbCk/SPbTdslVVW9RsuDfGtZkbLYNql6pUgCgBmzctonAKA1hn3I0wr0w2kbgy7fnpI27nqFHnPO/VTStZL+TdJVkq6oqmqFFgMj/2cbLPvlYfBURcoGdf1t/bouqGvtJ6zTpGHH5I9lSNj69esf3bp1669JOlbSUZIOl/Q8LQb5XTX4rIT3gSLLUv5+SpUB6CkSAKDfhmnhzbbyB/W6lAhYVmuxRfkISW/cVeacc9dL+p6k/0/Sv1ZV9QvZwX8qIagTqIXlYUBoGTbYK+kV8stLh4KFdbNDxgZlzrmHJL1E0ku1GPQfK2m/yHl10ZzswN8XJnhWEhCbD6XWAZhxJABAf2XHTBvlwwT7Vr2uq7S7x+AtkuScu03SFkkXS7q4qqqdigf/dXsHrKA/1TuQ2l9OSVI47BCekiSgkiTn3C8l/bqk4yW9QovB/9rUiXdcrmcn1jsQC/z9deFxShIEADOMBACYPSVBdq5OSQtuKsDPJQKz6CBJ/2XXS865ayV9SdL/qqrq7l11rGA+1TtQJxkItx91ONAwQ37qlFlB//2SXijp9ZJeJ+np9U6508Jeo3C+dF3dYT8lSQCJAjBjSACA/skN7ygJ9kpa+me5B6DEYZLeI+k9u4YLXSDp/Kqqtiod/NdJBvx6YUBYEvxZSnqDhgrwrbrOuQclnSTpdyS9SpL1wHUfWD0Aqd4Af96ahvtO3U8E+EDPkAAA/VYylGPY4L9Prf85z5X0XyX9V69n4HNVVT2kdIttLNAblJX0AgzzvpcmibmgP5o0OOeeIenNkn5X0lOHOMdZ4w/xUTAvxVv/c0lALDEg6Ad6bG7aJwBgauoE/1Yda37OW04lBH026Bm4yTm32Tn3kl3fT++/d3PeqwrKw+WwzFqfqlOnrr9+ziiLndOcFoP+Fc65tznnvi/pB5L+UAT/A6nEefAeKrJe3lTGckkdAD1CDwDQL3X+4Mdac0tb+2NlWLRCi0NeXiXpTufcFyX9P1VV7VC8pTbWExC2+Er1W3dLgsNcy79Z7pw7SNLbJJ2u2X6QdxSDbwEasHoAwjqpXgFrHyn0CAA9Qg8A0E8lrf/+utLgP9Z67LcWY7mnS/pzLfYKfMQ59zSlewBi68KW+FgLv/Uq6R2wzsE8jnPuPufcCc65f9Fia//bRPCfEr6/1jWQUa7EvI9eAABP4I8x0B+pID8szwUWVqtvLEAJAxvErdZiK/nVzrkLnHMv1PJAOxXYh+uHCfJjiUTROTjnHnDOvUrSv0s6T9KRjb9Ls8n6rFgJtrzlOp/PcD48dmo9gBlDAgCg7h99q1XSn6YCTAKMci+R9NVdicDztTwAL+khqNMLkEoEUusHgf9DzrkzJd0u6QuSDh3LuzK7Uq3/JZ+1uscC0GMkAEA/5P7gl7b+p1r+rR4Aa9gI6nmxpG86577inNuofBBf90HhXL1kouCc+6Vz7jWSbpR0jniod1jWZyXWAxDrCRilF6B0PYAZwB9joF/CP+6x5VwgkZpaLZZhGep7iaRLnXOfdc6tV7w1PtYDUPoMQMk+55xz9zjnXinpBkmfVr/+adc4xIZdxRLv3GfQmvfllgHMMP4YA7OvdNxvactgrhU5NmUIUDNeLelHzrkPOucWlA/sc88BlNQPg/8DJX1Hi0N9njnuH7gnYr0wqecCYslB6hjWNFYPwIwiAQD6o/SPeknrfyoIsVoy/eAGo5vT4sPCP3XOvdk59wuVB/+5lv9oUuCcq5xz50i6QtKLJvKT9of1/oc9Z6UJQOwznEPgD/QEf4yBfkv9wc8NO0j1AMRa/wkwmrWvpL/UYo/AwarfGzB4rYjMz0la4Zzb5px7kxaH+5wuruM4pD5DuR4ARaa5zzeAnuIfgQGzrbSLPxU0lLY2lg4LQvMOk3SZc+7vJZ1dVdWveOusf+4U/sMpxZadc0+W9I+SXtDEiSJqTovXZTCsa2FXuX89/Hnrn8IpWB9uF/7TOBnLuXIAM4AeAKAfrABhmNZ/KwmoE/yTAIxPpcXW+Wucc8dqaYv+slb9RPlguM/PnXP/h6QrRfA/CanPTK7lv2QoUOyYJWUAZgw9AED/pHoFUj0D4TQMOGLjycOEAOO1v6QvOee+JOmsqqr22VVePA7cObeXpH+SdMQ4ThAmq9V/8HlZCOqErf5hb0CspV+itR+A+GMMIN0KaLUkhg8kzimeCITBP79zJuf1kn7snHumlrf0m88BPOlJT9runDtV0g9E8D9psaQ59xkbbKugLJz6aOUHeo4eAGB21Q0AUq39Vp1YD0C4Pgw0x+L973+/brvtNj388MPasWOHdu7cqe3bt2vt2rVas2aN9tprL61atUp77723fuVXfkX777+/9t9/fz3taU/TAQccoHXr1mnVqlXjOr1pOUjSJc65/ynpT6uqekasonNuh6SvSHr5pE5ukh566CHdcccduvvuu3XnnXdq69at2rZtmx588EE9+uijeuihh/TYY49p+/btWlhY0D77LHacPOlJT9LKlSv1mte8RieddNI4TzH8bIS9AYPPUtgDYLX4x3oDrJb+VBk9A8CMIgEA+q0kSciNNY59b7kV/I+t5fGCCy7QVVddNfT2K1as0DOf+Uwddthh2rhxozZu3KjDDjtMhx9+uPbcc88Gz3TiKkl/KOklzrnXVVW1JqzgnNsg6YuSnjzpk2va3XffrauvvlrXXXedrr32Wl1//fX693//d91zzz0j7ffAAw8cdwJgDZFbkP05jA0FqhPwE9wDPUYCAPRLyUOBJcMIwv2EwX/nngGYn5/XTTfdpJtuukn/9E//9ET5ihUr9JznPEcvfelL9ZKXvERHH320nve8503xTId2tKQfOOd+r6qq2yVpr732+vn27dvfIun/VouvTcz8/Lyuv/56XXXVVfrud7+r73znO7ruuuvkXCfj2kFQb31eFoL1fku/ZH+rjz+VUc+Xej4AwAwiAQD6q6Q1vqT1v+TbSsbeAzAu8/Pzuvbaa3Xttdfq3HPPlSQ94xnP0Kte9SqdfPLJOu6447rUQ/AUSV93zn1Q0l9J2izpldM9pXruvPNOXXjhhbrooou0ZcsWPfjgg9M+pab4AX84xCec9xODsK6/fS6gpxcA6CkSAGC2lQb5VnCfeiYg1gMQro/9R9NOu/XWW/WpT31Kn/rUp7R27Vr95m/+pk455RS99rWv1bp166Z9ejmVpD+X9E5Jy4YDtdFPfvITffnLX9aFF16oH//4x11t4c/xW/j9AH/w2XHBvLT0cxcb8y8t7wkYlKWQHAAzjAQAQE4sGYi19FvDgaykYSY88sgj+vrXv66vf/3r+oM/+AP91m/9lk4//XSddtpp2muvvaZ9eimtDv7vuOMOffnLX9bmzZv1wx/+cNqnMwmxoXLOKwuHCA22iwX9AGCaidY4AMtYgXZp8G31AvjLsdZ+azl8JmCmf+fMz89ry5YtOuOMM/T0pz9db3nLW/T9739/2qfVGY8//rjOP/98HX/88TrooIN09tln9yX4l5Y/LF/6GbM+lzLqlBjl9waADpnpP8YAisSG+lh1BvOp1n8rkOlFAuDbvn27Pv/5z+vYY4/VMccco82bN+vxxx+f9mm10v3336+Pfexj2rBhg37v935Pl1xyiRYWFvIbzpbc58bqTYsF/qGSzziAHunNH2MAkvKBQGyoT6xuqiUyliT0zlVXXaUzzjhDGzZs0DnnnKMHHnhg2qfUCtdff71+//d/X/vvv7/OPvts3X777dM+pWkqeZg+1gsgr1xGXWXqWusBzLBe/jEGeij3xz0W3PvzqWAkFbzM9DMAddx+++3atGmTDj74YL33ve/tbSJwyy236KyzztIRRxyhz372s3rkkUemfUptEPai5T5PStSxEgH/OLnzADDjSACA2TXMH/qSbVIt+qmgpZNfAzoO9957r973vvc90SPw8MMPT/uUJuK2227TWWedpUMOOUTnnnuudu7cOe1TapNYwhxLnq16uf2XlNVZD6CjSACA/rC6+q35VOthbL9W4GKNZyag8Pz85z/Xpk2b9OxnP1ubN2+e1a+31P333693vetdBP5p4WfI+h8b4QPCuf2lhvmkPvsAZhwJAAAp/oe/ZExyamyyFdQgcNddd+mMM87QscceO1PfGuSc0+bNm/Xc5z5XH/3oR/XYY49N+5TaLJYspz5jJZ/JEEE+AP4YAygaGlAS9KdaK/2xzYi48sor9aIXvUhvfvObdc8990z7dEby4x//WC972ct0xhlnaOvWrdM+nS6wxv/negBKAv9hhv4AmHH8MQb6KxYo1BkOMEzrJBIWFhb0xS9+UYcffrguuOCCaZ9ObY8//rje+9736phjjtF3v/vdaZ9Ol4Sfk9gY/zqfpdxnms8j0FMkAMDsq/NHvmSscCygzyUCPANQw7Zt23TaaafpDW94g+69995pn06Rq6++Wscee6ze9773aX5+ftqn0zWpFv5w3l+OfRbD+WF+DwCYUSQAQD/FgoWwTir4COsNynJDGFDDl770JT3vec/T1772tWmfStTOnTt1zjnn6JhjjtGPfvSjaZ9OV+WG0EllwX7sM6ugnjUPoCf4YwzMnlHG/Ma2zQ1FiK0L62EIW7du1amnnqo/+7M/a9036Nxxxx16+ctfrk2bNvGQ7+hKP0cln0Vr36XnMOy2ADqCBACANFyLYEkAQg9AQ5xz+tCHPqTjjz9ed91117RPR5J02WWX6ZhjjtHll18+7VOZBbHPSkmSnUOLP4Al+GMM9NswwUDpcKBYAIMRXHbZZTryyCP1rW99a2rn4JzTxz72Mb3iFa/gG36aU5JA+3XD7YY5HoCeIgEA+iX1Rz/24GCq9TD3ECLDgMZg27ZteuUrX6lPfvKTEz/2ww8/rNe//vU6++yz9fjjj0/8+DPMauH3p2Ed67MY1rPKU8cH0BMrp30CAMaiiT/muYAi9byAvz7WijlWK1as0N57722ue/jhh7Vjx45JncpYPP7443rHO96hW2+9VR/4wAdUVeN/a++55x6dcsopuuKKK8Z+rB4Ke88WlB/uE0u0nbc86r+XbmIfAFqGBADon9xDfrkW/9w0Nl55rD7wgQ9o1apVeupTn/rEKxUU33fffdq6davuuecebdu2TTfffLOuvvpqXXPNNbr22ms7kyCcc845uv322/W5z31Oq1evHttxfvazn+lVr3qVfvazn43tGOOydu1aHXHEEXrOc56jAw44QE9/+tN1wAEHaP/999f69eslSWvWrNHatWuf2Gb79u2677779Mtf/lL33XefDjnkkEmdbulnyZ862Z9VKxGwAnqCfKBn6PIDZk/4ffvWg4PhcviVg3PG/Apvec5bXhHM+2XLXs65P5V0XLM/crN27typG2+8UVdeeaUuvvhibdmyRXfeeee0TyvpuOOO0z/+4z9qn332aXzfV1xxhU455ZRO/Hfiubk5HX300fqN3/gNHXnkkTrqqKP0nOc8RytXtrq965Kqqj4kaT7xWgimYdmCt+y85XDeBfPy5sNlecsLzf7IAKaJBACYPaUJgF82COolOwEIX34ysFLx4H+w3k8A/kQtTwAs1113nbZs2aILL7xQW7Zs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"/> + </defs> + <use xlink:href="#source-24"/> +</svg> \ No newline at end of file diff --git a/src/assets/cursors/macos/ibeamvertical-1__50-50.svg b/src/assets/cursors/macos/ibeamvertical-1__50-50.svg new file mode 100644 index 000000000..238e8f628 --- /dev/null +++ b/src/assets/cursors/macos/ibeamvertical-1__50-50.svg @@ -0,0 +1,9 @@ +<?xml version="1.0" encoding="UTF-8" standalone="yes"?> +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="767.314286" height="746" viewBox="0 0 767.314286 746"> + <path fill-rule="evenodd" fill="rgb(0%, 0%, 0%)" fill-opacity="1" d="M 261.457031 383.738281 L 517.082031 383.738281 L 517.082031 362.722656 L 261.457031 362.722656 Z M 261.457031 383.738281 "/> + <path fill-rule="evenodd" fill="rgb(0%, 0%, 0%)" fill-opacity="1" d="M 389.363281 405.09375 L 410.339844 405.09375 L 410.339844 341.449219 L 389.363281 341.449219 Z M 389.363281 405.09375 "/> + <path fill="none" stroke-width="1" stroke-linecap="butt" stroke-linejoin="miter" stroke="rgb(0%, 0%, 0%)" stroke-opacity="1" stroke-miterlimit="4" d="M 13.005325 17.503849 C 12.505367 17.33689 11.73527 16.766924 11.480343 16.396903 C 11.237328 16.046859 11.030234 15.686919 10.921372 15.342923 C 10.786303 14.921954 10.827355 14.034931 10.827355 14.034931 " transform="matrix(21.314286, 0, 0, 21.314286, 0, 0)"/> + <path fill="none" stroke-width="1" stroke-linecap="butt" stroke-linejoin="miter" stroke="rgb(0%, 0%, 0%)" stroke-opacity="1" stroke-miterlimit="4" d="M 13.007158 17.53812 C 12.482092 17.69115 11.677174 18.322145 11.458168 18.603097 C 11.198109 18.939029 11.030051 19.312165 10.921189 19.656161 C 10.78612 20.078046 10.827172 20.965069 10.827172 20.965069 " transform="matrix(21.314286, 0, 0, 21.314286, 0, 0)"/> + <path fill="none" stroke-width="1" stroke-linecap="butt" stroke-linejoin="miter" stroke="rgb(0%, 0%, 0%)" stroke-opacity="1" stroke-miterlimit="4" d="M 23.551546 17.503849 C 24.051687 17.33689 24.820684 16.766924 25.074512 16.396903 C 25.318626 16.046859 25.525537 15.686919 25.632566 15.342923 C 25.768552 14.921954 25.727683 14.034931 25.727683 14.034931 " transform="matrix(21.314286, 0, 0, 21.314286, 0, 0)"/> + <path fill="none" stroke-width="1" stroke-linecap="butt" stroke-linejoin="miter" stroke="rgb(0%, 0%, 0%)" stroke-opacity="1" stroke-miterlimit="4" d="M 23.547697 17.53812 C 24.072763 17.69115 24.876765 18.322145 25.096688 18.603097 C 25.357663 18.939029 25.525721 19.312165 25.633666 19.656161 C 25.769652 20.078046 25.728783 20.965069 25.728783 20.965069 " transform="matrix(21.314286, 0, 0, 21.314286, 0, 0)"/> +</svg> \ No newline at end of file diff --git a/src/assets/cursors/macos/ibeamverticalstroke-1__50-50.svg b/src/assets/cursors/macos/ibeamverticalstroke-1__50-50.svg new file mode 100644 index 000000000..bc0dd69f8 --- /dev/null +++ b/src/assets/cursors/macos/ibeamverticalstroke-1__50-50.svg @@ -0,0 +1,7 @@ +<?xml version="1.0" encoding="UTF-8" standalone="yes"?> +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="767.314286" height="746" viewBox="0 0 767.314286 746"> + <defs> + <image id="source-24" x="0" y="0" width="768" height="746" xlink:href="data:image/png;base64,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uYluSWfJH6ib9Xpjurhclue4EoEYBP7oSCQAaLWylaHrMp6Y8l3PNSv80HPM22WS/hzYj8q6urp05ZVXapVVVkm1iQHyPNfFF1+sPfbYQ88//3zLtgugdX79619rm2220a233trS7U6ePFlHHXVU6s3MyLJsd9k7dEKXefZoYPsQurzHtzwmUaiSTAC1IQFAu7h6933rxyYCvt79YuVe7AnqsUxby8my7GuSzg7sb1M++tGP6tJLk/31gNO9996rbbbZRjNnzmz5tgGk8d577+m0007TnnvuqRdeSHYFo9V6662nGTNmKMuylJtZqt462da7H9PB43rsW1bkShJsCPrREUgA0AnKXidZ5RIg2/PMx65ltkZlhqQnIva1skMOOURHH310yk1Yvfbaa5o2bZomTZqkp556quXbB1Cfe+65Rx//+Md1zjnnqLu7u6XbHjFihK699trUv/ojSZdnWbZX3+OYSzpt07FtiW+94r0C84G2IgFApwpVmmWTgFBl72sczO0oy7IjJX2ridcX5aKLLtLEiRNTb8bqrrvu0rbbbqszzjhD7733Xlv2AUA1Cxcu1JFHHqnddttNjz/+eFv24V//9V81efLk1Jt5U729/6XrcfnbBHkeu7g6s2KDf5IEtAwJAAaLspVwmd6bUONgbVSyLBsv6f9VfkURRo0apRtuuEHrrLNOys04LVq0SGeeeaY+9rGP6Uc/+pF6enrCTwLQNu+8847OP/98bbLJJrr88stb+kXfon333VennHJKKzZ1ZpZlX1DzQX5oNFiOZeZyAn8MCiQAaIfQUGloPdv6tsQg1IvjawRie45OVO/1p8mMHz9eN910k1ZaaaWUm/F66qmndMABB2jrrbfWzJkzSQSADrN48WJdeOGF2mijjfTNb35Tr776atv2ZauttmrFdf+S9CdJ/6m4kVzfyK8K976635Uk+BICWebHdGQBSZEAoFPEVp5mZR3qtXEF8K4vA0ePAKh3FGCypOTf1t11111b8RvaQY899pimTZumbbfdVjNmzNDixYvbuj/AcLdw4UJdcMEF2njjjXXCCSdowYIFbd2f9ddfX7fccovWWGONVmzu+CzLvi5/O9CYdtXz0or2oNguuMpxlSHPNME+Og4JADpZ7AhAzPBucX2zsreV4eo5UuG5jXlnSEre6k6bNk3nn39+6s1EeeSRR3TI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ydcU7dprr9Xee++tN998s1WbBDCMPfvss9p11101b968Vm729DzPH1T/Hn/JXp/7RnFd9b8C68tYT8ZzbNOx6wMtRwKATuLrhTfnx5bjaiTM5eY6viTBOZ3n+eOSvhm5n0370Y9+pK9+9atatmxZqzYJAFqwYIEmT56sP/7xj63a5Ej1Xgp0Q990MdDPjenicslep0v2Oj0klDzY1vctB9qCBACDka23v3EfW4EXH9cygpDn+fXq/dLviNgX0oxrrrlGBx54IL/0A6At/va3v2nSpEl69NFHW7XJTSSdoXKdM+bPObueI8uyMh1QdQT3JAhoGX4FCO1S978Cm78CZN7bfgnI9gtAoX/1df3yz8g8zz8q6dslj0Ml11xzjQ4//HD19PSEV26B0aNHa9KkSVpttZZ97QE1uOOOO7Rw4cJay9xss8205ZZb1lom+nv22Wf1wAMPtHs3llt33XV19913a8KECa3YXLekHbMs20z+f/r1/fOv7x+Ai/8EnMv/C0AxlxXZRgC4NAhtRwKAdimbADTuQz8J6gv+zVvxZ0DNx7GB/wj1Bv9/lDRX0qiSx6G0H//4xzrggAM6IvjfaKONdMQRR+iwww7TBz7wgXbvDkraYYcdag8kTz31VJ199tm1lomB5s6dq0svvVQ//OEP9e6777Z7d/TBD35Qc+bM0Yc+9KFWbO4RSdtnWfZ5xSUBPcayHg1MBHw/AWoG/eZjyZ8ESHEjCSQAaBkuAUKn8fWamI9jy/Fd/+narutazgHz8zz/b0lXqAXB/+zZs3XooYe2NfjPskz77ruvbr/9dj3zzDM65ZRTCP6BFttuu+105ZVXav78+brwwgu14YYbtnV/XnjhBe299956/fXXW7G5rSWd0Pc4dAmQNLDeDl2r7+K7JKjZ4J3gHy1FAoB2qauyrFIJ+74EZt7bgn5z+dfUgl/9eeKJJzR16lQtXrw49aacJk+erN///vf6+c9/rj333FNZxiAi0E5rrrmmpk+frqefflqXXXaZ1l9//bbty+OPP679999f7733Xis2d0ae53MK064v+Ja9TMcmdMkOwTsGHRIADBVlRwlcSYCrLNcXf29T75fSknrxxRe1995767XXXku9KatJkyZpzpw5mjVrlnbYYYe27AMAt1GjRumII47QM888o+9973taZ5112rIfs2fP1te//nXlefKYeIyk71Z8bqgn39X5Y1sfGJRIADCYlf1CVZlh4NC1nI3pMySNC+1oM9577z3tv//+eu6551JuxmqDDTbQT3/6U91xxx3aaaedWr59AOWMGTNGJ510kv70pz/pqKOOUldX65v5GTNm6N/+7d9asakD8jxfoEBHjWNZUUzAH9PJxJd7MWiQAKCTla00Q5W7bR3X/PDGer/4e0zJfSzt2GOP1f333596M/10dXXpiCOO0GOPPab999+/pdsG0Lw11lhDl156qe655x5tscUWLd/+d77zHd1+++2pN5NJuijP858rflS3OO26xDP0/JDS7QnQaiQAGEyqVKi+58T09vh6kL4naaUK+xTt6quv1pVXXplyEwN87GMf0x/+8AdddtllWn311Vu6bQD1+uQnP6kHH3xQZ599tkaPHt2y7fb09Ojggw/W/PnzU29qW0kHRq5b9lr/uoN4RgjQMUgA0ImqfiErtE7oOv/o8vM8f0nSfhH7Udm8efN07LHHptzEAAcffLDmzJmj7bbbrqXbBZDO6NGjdeqpp2rOnDn6yEc+0rLtvvzyy/rCF76gJUuWpN7UWXme+4YbQiPDrnVC5fAlYAxaJABopzKVZjO9/6EvfLm24Zv+rhL+j8Zbb72lz33ucy37fe/VVltN119/va699lqtssoqLdkmgNbabrvtdP/99+uzn/1sy7Z533336dvfTv7/iBtK+oriL/esgiAfQwoJAIaCKtdnVt5GnufvSJpUoYxoJ554ov785z+n3MRym2++ue6//359+ctfbsn2ALTP2muvrZ///Oc677zzNGLEiJZs84ILLtA999yTejPfyfP8N5b5BO6ABQkAUN65KQu//fbb9V//9V8pN7HcTjvtpNmzZ2vChAkt2R6A9suyTKeccopuvvlmjRkzJvn2enp6dOihh+rtt99OuZkPSjoy5QYKSCow6JEAACXkeb5UUrLfw3z99dd1+OGHt+I3tDV16lTdeeedet/73pd8WwA6z3777adf//rXLfnPgGeffVbf+ta3Um/mG45RAAAGEgCgnG+mLPyf/umf9Ne//jXlJiRJxx9/vG688caW9P4B6Fw77rij7r77bm2wwQbJt3XJJZforrvuSrmJ9SUdlHIDffgLdAx6JAAYClpSGed5/jdJn05V/qxZs3TDDTekKn65U045RRdddFFb/iAIQOfZYost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"/> + </defs> + <use xlink:href="#source-24"/> +</svg> \ No newline at end of file diff --git a/src/assets/cursors/macos/makealias-1__63-29.svg b/src/assets/cursors/macos/makealias-1__63-29.svg new file mode 100644 index 000000000..1e188e753 --- /dev/null +++ b/src/assets/cursors/macos/makealias-1__63-29.svg @@ -0,0 +1,7 @@ +<?xml version="1.0" encoding="UTF-8" standalone="yes"?> +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="767.314286" height="746" viewBox="0 0 767.314286 746"> + <defs> + <image id="source-24" x="0" y="0" width="768" height="746" xlink:href="data:image/png;base64,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1+nxD03/cS349S0CnlG+KIpNJL21RjuC7r//fn3sYx/LVd1AOeKII3T77bdr11137biuMgkYxluEdogEAEDfIAEAkCrltpyxfbHyobv/lKMApiiKuzQ99Sfb/2Hvec979Pvf/z5XdQPjLW95i77xjW9o8803z1bn5ptvrptuuokkYCYSAAB9gwQAQClH738osJdmB/WxL2sEWjwAACAASURBVPoa85R7s6Rs80zuuOMOXXHFFbmqGwhz5szRJz/5SX3605/W+HiW706bobw70L777pu97jYxBQjAKCABANCJ0KiAazuUDIxVnu05/9Xe//9Qxnv+S9L73ve+NgO/vjN//nxde+21etvb3tbqdbbcckutWbNmoEYCuAsQgFFAAgBAivf+x+bx+8qk9P772uFb/PshSQsj7U22YsUKrVq1Kld1fW+HHXbQd7/7XR133HFduR5rAjYgAQDQN0gAADQV6/0PrQtw9u57tjfsK4riSUnn5Gi8ND3d4/zzz89VXd874IAD9P3vf1/Pf/7zu3rdLbbYQt/61re0zz77dPW6TYzSSBCA0UUCAKCTuf++3v/Ygl67vtCtP6uPD0vaKLG9Uddff71++MMf5qqurx1//PFatWqVFi7MNnhSy1ZbbaU1a9Zov/3268n1+wCZBYC+QQIAICYU+Nepw5UcuIJ8X++/kXRy3caHfOQjH8lZXd8666yzdM0112jTTTftaTu22GIL3XTTTaOaBJAAAOgbJADAaEud+x86Fur19wX+of3VRcDVx0eV8f+sG264QXfccUeu6vrWueeeq89+9rPaaKNsAycdGeEkgAQAQN8gAQCQwp6uEytj7w/d+af6cN4ZqCiKSUnHNGq5x7D3/s+ZM0eXXnppX365WbkmYK+99up1U2bhNqAARgEJADC6cvf+l891ev9tvt7/P09ob7JbbrlFt956a67q+s7cuXN15ZVX6s1vfnOvm+K1cOFCrVq1qu+SAG4DCmAUkAAAiGnS+x9a9JvS+79hX1EUv5F0etPGu1x44YU5q+srm222mb72ta/p5JOzLpdoRT8mAYwAABgFJAAAXDrt/fft9/X+++b+j2n6W383q/0OPO68806tWLEiV3V9pZxff+SRR/a6Kcn6MQkAgGFHAgCMpia3/myz998+ZiSNFUXxE2W8778kffSjHx3Ke70vXrxYN998sw488MBeN6W2EUkChu+XDsDAIgEAYMvd+++a0y/rPN+Xgb1a0g4N38csv/jFL3T11Vfnqq5v7LDDDvr2t7+tvffeu9dNaWwEkgASAAB9gwQAGD3d7v2XtS803cfe9/bEtib52Mc+psnJyZxV9twee+yh2267TTvvvHOvm9KxhQsXauXKlV3/puIq1gAAGAUkAABS5er9jz2qt/58ea7G33vvvfrCF76Qq7q+sNtuu2nlypVavHhxr5uSzaJFi7R69WrtueeePbk+dwECMApIAIDRknrrz5QoqGnvvzfotx5vTWxHkosuukjr1q3LVV3P7b777lqzZo2e/exn97op2S1atEirVq3qSRIwjOtDAMBGAgAgxte7Xz2Wo/e/euvPuyWdkusNPPjgg7r00ktzVddz++yzj2655Zah6vm3LVq0SCtXrtQee+zR66bkMtXrBgBAiQQAQKlfev/HJB0vaat6zff727/9Wz322GO5quupfffdVytXrtRWW2X7ePrW1ltvrTVr1vRsOlBmDC0A6BskAMDoaDKdppu9/9XnP27QVqcnnnhCl1xySa7qemrffffVqlWrRiL4Ly1atEg33XSTdt99965cjylAAEYBCQAAqbO59lnn/hdF8R+Sju6gPTNcfvnluv/++3NV1zO77LKLVqxYoWc961m9bkrXLV68WKtXr+5aEtASMgsAfYMEAECVsV77AnpX2erxTnr/Xy9pPMu7kfSpT30qV1U985znPEc33nijFi1a1OumSNL3Jb1DUldXVJdfdNb2LUK5CxCAUUACAIyGTqOa0Jz/8jk0RSj1zj9jks7ssK0b3HHHHfrRj36Uq7qe2GSTTXTddddp++2373VTJOlSSYcYY/bV9CjNo928eHmL0DaTAL4HAMAoIAEAEOrRt8vFAv+Oev+LonhC0gs6fkfrXXbZZbmq6gljjD73uc/pRS96Ua+bslbS640xGxlj/k5SYYw5TdJBkv6jmw0p1wTstttu3bxsDiQAAPoGCQAAH99dgTrt/Xd942+5/6xcjX/qqad09dVX56quJ8477zwtXbq01824W9JLjDGHaTqI3fAwxrxT0sGSftbNBj372c/WzTffPEy3CAWAriIBAIZfaPqPL8hPKevq/bdfV7ftMnbv/52STktoR5LVq1frwQcfzFVd173yla/Uhz/84V434wZJBxpj/oes4F9P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"/> + </defs> + <use xlink:href="#source-24"/> +</svg> \ No newline at end of file diff --git a/src/assets/cursors/macos/move-1__50-50.svg b/src/assets/cursors/macos/move-1__50-50.svg new file mode 100644 index 000000000..7e358e29f --- /dev/null +++ b/src/assets/cursors/macos/move-1__50-50.svg @@ -0,0 +1,7 @@ +<?xml version="1.0" encoding="UTF-8" standalone="yes"?> +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="767.314286" height="746" viewBox="0 0 767.314286 746"> + <defs> + <image id="source-25" x="0" y="0" width="768" height="746" xlink:href="data:image/png;base64,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4TKg0OVokDcVAxMKAEqPAoAiqKQA1Gr2v9u57/v7qA7h/6677tLYsWO1fv16l8MCKLm99tpLDzzwgHbaaSfXQwcl4C113wMQt0dAhvOsewEoAGh6vAcARVfpJ0LYFITosqAE7CRpq0qebDV23HFHbb311q6HBVByPXv2VI8ePeoxdNyeVyl9nWw6jz520rhAU6MAoNHZrqhtVvZxewhs3gC8+ec8z1si6ShJGyyfW03sv//+uu222ygBAGpmjz320KJFi7TDDju4Hvo1SUM9z3tH3dfNNutlGX4uuiyqFtsToBQoAGhkla6Ea/GeAdPs/+b3AXie90dJR8txCRg6dKiuvfZatbTwpwugOgMGDNDdd9+tXXbZxfXQr0k61PO8tTK/zyppL4AMtyXJazsCFBopAo2q2pVv2huC425Lmv0P37/F87w/qQ4l4Mgjj9RPf/pTl0MCKJntt99e8+fP1+677+566CD8r9OWwB+VthfAJKkkAIigAKDIKtnlm2XDEvcpQJt/xvO8J1SHEnDqqafqe9/7nsshAZREr169NHfuXA0aNMj10EH436iUvayKXx+HL8dN1GQ9JBRoOhQAlE3WlXraMaam+3fZSHme94zqUALOOeccTZ061eWQAAqutbVVN954ow444ADXQwfhP/rG37i9AJJ5nZy0fo5D2AciKAAoI5tPhEibIQo2TolvCNaW9wT8RY5LgOd5uuyyy3T44Ye7GhJAgXmep8svv1wTJ050PXQQ/nuo67p181MznOL2BCjyc0nX45YBTY8CgKKyWaknfVJE9Pa0NwbHbZw238fzvBfluAQEs3nDhg1zNSSAgvrf//1fnXjiia6HDcJ/L3Vf18ZNsiS94TfLej0N5QBNiwKAosuyAk/aMNi+HyDukKFgT8Df5LgEbL311po9e7b2228/V0MCKJhTTjlF3/3ud10PG4T/PjIHfik9+Kcd+lPJMf0EfzQ9CgDKpJrj/6PL0jZKsRsyz/P+IcclYPvtt9eCBQu01157uRoSQEEcccQR+tnPfuZ62CD876CUiZPIz9mug3kfAFAFCgDKzrTL2PYNZVaH/kSWe5Lked6rclwCdt55Z919993abbfdXA0JoMEdeuih9fjukCD879R53bSetDkUyCRu/W2zTgfQiQKAsqlkd3D4Z202OtFxopdbJMnzvDfkuATstttuuvPO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"/> + </defs> + <use xlink:href="#source-25"/> +</svg> \ No newline at end of file diff --git a/src/assets/cursors/macos/notallowed-1__23-0.svg b/src/assets/cursors/macos/notallowed-1__23-0.svg new file mode 100644 index 000000000..7ee2ef758 --- /dev/null +++ b/src/assets/cursors/macos/notallowed-1__23-0.svg @@ -0,0 +1,40 @@ +<?xml version="1.0" encoding="UTF-8" standalone="yes"?> +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="767.314286" height="746" viewBox="0 0 767.314286 746"> + <defs> + <filter id="filter-remove-color" x="0%" y="0%" width="100%" height="100%"> + <feColorMatrix color-interpolation-filters="sRGB" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"/> + </filter> + <clipPath id="clip-0"> + <path clip-rule="nonzero" d="M 18.953125 0 L 764.953125 0 L 764.953125 746 L 18.953125 746 Z M 18.953125 0 "/> + </clipPath> + <clipPath id="clip-1"> + <path clip-rule="nonzero" d="M 0.953125 0 L 746.953125 0 L 746.953125 746 L 0.953125 746 Z M 0.953125 0 "/> + </clipPath> + <clipPath id="clip-2"> + <rect x="0" y="0" width="768" height="746"/> + </clipPath> + <clipPath id="clip-3"> + <rect x="0" y="0" width="747" height="746"/> + </clipPath> + <image id="source-34" x="0" y="0" width="768" height="746" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAwAAAALqCAYAAACCKTPeAAAABmJLR0QA/wD/AP+gvaeTAAAPH0lEQVR4nO3XsQ3CABAEwcMx/TeKnDugBCwc7EwBrw9vX7vPe9vnxnsAAMDvzn23+rbtePARAADgzwQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQi7BdQfUtXfFEwAAAABJRU5ErkJggg=="/> + <image id="source-27" x="0" y="0" width="768" height="746" xlink:href="data:image/png;base64,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"/> + <mask id="mask-0"> + <g filter="url(#filter-remove-color)"> + <use xlink:href="#source-34" transform="matrix(1, 0, 0, 1, -18, 0)"/> + </g> + </mask> + <g id="source-33" clip-path="url(#clip-2)"> + <use xlink:href="#source-27"/> + </g> + <g id="source-36" clip-path="url(#clip-3)"> + <g clip-path="url(#clip-1)"> + <g mask="url(#mask-0)"> + <use xlink:href="#source-33" transform="matrix(1, 0, 0, 1, -18, 0)"/> + </g> + </g> + </g> + </defs> + <g clip-path="url(#clip-0)"> + <use xlink:href="#source-36" transform="matrix(1, 0, 0, 1, 18, 0)"/> + </g> +</svg> \ No newline at end of file diff --git a/src/assets/cursors/macos/openhand-1__50-50.svg b/src/assets/cursors/macos/openhand-1__50-50.svg new file mode 100644 index 000000000..2afa27244 --- /dev/null +++ b/src/assets/cursors/macos/openhand-1__50-50.svg @@ -0,0 +1,7 @@ +<?xml version="1.0" encoding="UTF-8" standalone="yes"?> +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="767.314286" height="746" viewBox="0 0 767.314286 746"> + <defs> + <image id="source-27" x="0" y="0" width="768" height="746" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAwAAAALqCAYAAACCKTPeAAAABmJLR0QA/wD/AP+gvaeTAAAgAElEQVR4nOzdd7hU1d33/885h0OVXlSkWQA7KiixIbaAiDWWqLFgFEN81J/mttxpahIfo4/eRmM0yh17LDH2iIrS1UTUYImCRmMFNSiEIiByzvn9MYxsNnvtttaemT3zfl3XXDOzy9rrwDkz3+9qWwIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACQgbpyVwAAakSlfN4mrUdLJrVIrlLqAQC5VylfSABQjUr9GVuuz/RSBuckAgBgiQQAANwq1edq0uvY1itp4F2KQJ1kAABSIAEAAHey+kyNKrcSPsvjBONZBewkAgCQQCV8aQBA3rn+LM1DwB9HWGCeRdBOIgAAMeTlSwQAKlGpAn+b67iuo02QbTrXZeBOEgAAEUgAACAdV5+fLoL+UvUYRAXXSYLvrJMBEgEAMCABAIBkXHxu2gb9QceV+/M8KOCOG4TbnJu0XACoeeX+wgCAPLH9zEwb+NsE/OUaAuQ/Lu0kYXoEAMAxEgAAiJZF4J806K/0icFJhgeVMxkgEQBQ88r9hQEAlSqroT5h5dYZXsct2+baJmnH6sddAShpOSQBAGCJBAAA1lepgb+rxMG1OMG8i2SAHgEAcIQEAADWySL4twnc4yQDSRIGVz0ANgF9nCShHImAy3IAoKKRAACoNEk/l5IGbaW8W2/aAD5sW9TxriYHJ53sGxXYxw3c0yYCroN3kgEAVYsEAEApVPtnTdxW/6SBf5ygP0k5tpIM9wkL7uME75WSCERdDwByp9q/lAFkr5Y/R1wE/kmD/qTJQJrtaYf9mILxsCA9KoCv1EQgDEkCgIpWy1/cANKr9c+ONOP80wb+YdvDyrRdRchmWc8WwzbTsWHb41wv7Lyo48ulEusEoEbU+pc4gHj4rChIM84/7pj9NNuK7+P0CnwlaVtJAyV1W/vouvbxtQoB6X8kNUlaLOlDSe9IetdTRpyW+7TJQCkSgahzyqUS6wSgivGlDsCEz4d1XE3wTRv4pzmvv6SDJA2VtKOkLSU1GOocZaGkf0iasfbxgqQ2nv1xA3p/MlCKRCBsf6UG3pVaLwBVgi94AH58LhTYrMcfZ7hPWAAfNxHwvv9S0mhJYyWNkdQnpJ62vpI0W9JDku6VtNK3PyjQd5EcBL0POzZIml6DcqrEOgHIOb7oAUjl+Swo1TX9AVSdYVuUNOv52wb+UUF/naS2ksavffQOqWNWmiRNlXSXCglBK0UH9knfS8kSAdP+IK6Py1Il1AFAFSABAGpXqf7+XVwnaAx6KSSZSJtmuE+cwN+0r7ekn0g6UlLriHqWyhJJ10q6RlK91g/mg55LkQiYjjFJ8ztW6t9LEgEAVkgAgNqU1d++bUt6qQQFUEnqlXRZz7hDecJeF5/rJf1M0g9VaG2vRMsk3SDpCq3f4xIU/MdJDBSwLW6Q73J50KwTiTRIBgAkVglfxABKx/XfvO1Sk3kTtcxmkjH7cQP/4uulki6Q9GNJHZJWvEwWSTpD0jSZg39TYB/VIyAFJwdB78P2uQqgbecflOL6ACCp+r6cAQQrVeDvcrhPElkGP1Et/P5tScb1x33dQYUx9nsnqXgFuVOFHos2it8bENZD4H02bQt6HyTrFYJKPemYRABAJBIAoPq5+jt3EfQnrYvpeNsgx3R+0p8xyXCfsESgPuT4/SXdJKmLoQ558bak41S4v0BUwB+3t0CGbf7XirE97jFZDSFyGbiTBAAIRQIAVK8sA/+4ZWfZU5BG3MAo6dAm21b/oMC/XtKpkn4ds8558KUKy5S+oXgt/3F6BPz7ZdhvkjRYLkVZpRyWBKAGkQAA1cn2b9tl0O+q1d+klEFOVOBffJ1muI838F+pwkTfC5zUurJ8KekQrUsCJKl57XOaXoA4Q4H827IYr59Fj4OL322SAAAbIAEAqksWY/DTtoYnOadUkgRDYXU0Bf7+5yTDfYqPlZL+n6QfJKhr3hR7AuZq/SDeJhFQjG0K2G96b2JzXNpkgEQAgFMkAED1sPl7TtrinyToL3VCUIoJwWkC/7AkwPv4vqTLnNa6Mi2TNEyF1Y1aFBzgp+0FCAuio34/skoMkpabRSJAEgBAEgkAUC1cBv9xg/k0Qb/t8KCk0g6tiJMQxR3+EzXcx/u8k6THJTVE1K9avCxpT0ndtH7QHzcR8D+bguwshgSlCeDTXj9tUhK3PAA1pla+ZIBqljaIDluxJuxY03H+4Na/rV7BQXHQsWkfYXWKOi+snqafyXtufUgZCthW73vuLOkpSe0Dfo5q1VtSR0l/VfS/t/99VALmP9bUUxP2u+Ble7xfnL8p77Gmc9PIOvEGUOH4EADyzSb4j1OOP5iKc/2gwCxJmS65GEYRVt+4Aac/EfA/fybpb5J2TVi3atAi6VBJr2jDXoCguQFRLf9pJwabjkl6bJyWfZseCZe9AfQEADWKBADIrzR/v1kF/lFBcti14tbDz8XKKWHHRtXJ1PIcFOyH9T7Uq7Aqzo0J6p1KY2OjBg8erO23315bbrmlunbtqq5du6pdu3ZatmyZ1qxZo3//+99655139M4772ju3LlaunRp1tWSpH9K2k7SJgpOAuIkAv7tMhwTtt2/3/TetC9NYJ82GXCVCJAEADWIBADIp1IE/3ED/yS9AElb/03H2Ab5Sc6P2wOQtNW/uP9rSfMkdU1Qp9gGDBigww47TGPGjNGIESPUtm3b2Oc2NTVpzpw5mjlzpp566ilNmTJFTU1NWVRTks5UYf6DKQGIMyk4ajKw7ZyAJMckvUaaicskAQBSIQEA8ier4N828I8K+m2TgbjSBEX+Y+L0VoQF/t7XUa3/l6hwwy9n6urqNGrUKJ111lkaPXq06uvro0+K4dNPP9W9996rW265Ra+//rqTMj0+kzRQUnfFTwDiTA5WzO1B++LsDyvbtj5Jrx91XBiSAKCGkAAA+ZP07zZOoB0nSC++t9lmqoN3W3tJO0raQlIXFSbHdl37ukHSfyQtlrRo7eMjSa9KWr72/CTjp8OOj6pnWOAvhbf6Fx8tkv4lqTGkzonsvffeuuqqq7Tbbru5KnIDLS0teuSRR/Tzn//cdSLwU0n3KFkCEJQEmN4r4H3a1n5XQb+rRIAkAEBsJABAvrgO/l0G/qax8WHbvpJ0gKR9JA1Z++gZUM84PpM0RdJjkiZpXeDtapiEFNz6X3yO2/LvTQQmSLrQoj7faN++va655hqNHz/eRXGxrF69Wj/96U919dVXq7m5OfqEaB+okPhtLrsEIE6g/bWk/pIGqJBkeldfaiupSdKHax8fKfgmZX5xhx25SARshimFIQkAagAJAJAf5Qj+TQF8kvf+bY0q3AX2UEnfVjbLXi6TdLuk6yX9W8l7BbxM/3ZhSUBY63/x9ecqBJfWY//79OmjJ598Utttt51tUan88Y9/1Mknn+xqfsBoSe+pEHDbJAD+QHgjSQdL2kOF1Za2TFivLyW9K+k5STMlzVDhrs3F8r3PSbfFPS/smKD9YceYkAAANYAEAMiHUgT/cVr4474PKn9rSedLOkyluwdJi6TJkn4paa5vu/84E9O/UZLW/6BEYLSkG+L8EGEGDBigadOmacCAAbZFWbnttts0btw4F0X9WdLPtC4wLra8+5/jJABrVJhfcZKkbV1UzudtFXqdbpf0lufa3uewbVkmAiQBAIxIAIB8SPK3ahv8h7Vs+99HJQXFIT7nS9o7quIZapF0r6QLtK7Vtrg9iah/hziPYhIwUdK+Ca+/ng4dOui5557TkCFDbIpxZvz48Zo4caJtMaslbSppY5mHAYX1CEiFBPNiScdJamNboZhek/S/ku5S8kA/TWLgfx+VBJiOMSEJAKoYCQBQ+VwH/1FDfuKOc4/atrOka1VY371SfCnpcklXqjDuuyhOsBM1HMq05n/QHYAXS/pUUqvEP4HHTTfdVNIx/1FWrFihQYMGaf78+bZFHSvpTa3f6h8U+Pt7A1ZK+omkMyS1tq1ESqsk/VGFXqelMgfzYYF+1LPptcueABIAoIqVqhseQDqugn9vABp0jn+/qfU6bF8xuG1QYez9lZJ6Jah/KbSWtJ8Kcw8eV6G1OU3rvffZP77f/wjavp+k79j8ILvvvruuv/561dVVTjtOY2OjOnfurEcffdS2qCWSXlTw71/QakofSPq+pAdV6Gkq53dbK0m7qDDBu72kab76hPW8xd1nKiesx8+036RyfrEAOMcfOFC5XAb/YcfEHeJjeu1t+d5OhdbPPgnqXi7LJJ0l6cmE55l6POIkDsXX/1fSERZ115QpU7TffvvZFJGJpqYmbb755vroo49sivlAhX8f00Rgb2v5Gkm3SdrT5oIZ+ljSD1WYPCzFm7gc1TsQNSSIngAAodzcHQZAObkK/sNaWuO8PlrSVOUj+JekjioEjr9S4X4CQa32Qa33US39Yb0BxcdeNhUfMmRIRQb/ktTQ0KBTTjnFtpj+KgzXavA96n3vR0p6RZUb/EuFv4dHVZiTsEzx/56Kr6Xwv9OwBF6+7UGvo9BQCFQhEgAg3+J8qScN/ovBh/8YU6BSp0IL543K57DC0yQ9pcKE5ajgP+oR55wvlf5eB5JUUeP+gzhIACRpqIKD/3pJ8ySdqcLvXAcXFyuBs1UYDhTWMxQW3IclAQp47TIJAFBlSACAyuTiyzlugBA2tMefEAS1cI+T9AsH9S2nfSX9TVI7JW/xT5osDLOpaF1dnQ477DCbIjK3xRZbaNCgQbbFbK/glv/3VZi/McH2AmWwpwpJQCuFB/9pegYU8DooEUiTBJAsAFWGBACoPGm+lMO+5IOOjWpVDFrRJigI2VPSr2PWt9INVOEGTx2VrMU/SfDfoELLdmpDhw7VZpttZlNESRx44IG2RWyvdf9urVT4t/tA0rMqTOLOqx1USAKaFfx7lLY3QJ73Qa8VYzuAGkECAORTnODf/1x8HRX8+3sA/MFI8X0nSXequj5HNpU0XVIXBQf6QcF+Q8C2sN6CnWwquNdeVtMHSsZBPbfXusC/OOznqbXb824bSQ+osNpRWMu/KSEPe62A12EBP70AQA2qpi9uoBok/ZKNCv79++IE/6aWSO+2z1WYQLtRwvrmQS8VJjMH9QTECfajHlvZVG7oUKsOhJLZYYcdbIsYIOl5Ff7N50j6iwrLa1aLkZJuUniiaRp+5yIJSDMUCECVIAEA8ieqWz/oi90UKAS1NsZJCM6T5Uo2Fa67CsuDNil6SE/Qtgat+/fyjmN/RYXANrVddslHDDxo0CC1bm11L656Fe4G/JGke1Sdv2+nSPqewpNuyfx3qpDX8r2Oeh8nCSBRAKoEf8xAZUnyJRz0ZZ4k+I8T+AclAg2S3lZhwmy1e0uFwLO7Z5upZdU7TMPUc7JQ0r/SVqZVq1ZasWKFGhsb0xZRUltvvbXeeustmyKGqTDe//+6qVFFWi3pW5I+Ufg9D0z3QfDfDTnotXyvg+4dELTdhHsDADlndRt6AE7ZBv+m48KSgiSt/8XHL5Vx8N+5c2ftuOOO2nHHHbX55purS5cu6tKli1q3bq1FixZp8eLFevfddzVjxgy9/vrrWVZlsApruO8raQuZky5/EhA0QbhubRmp9evXLzfBvyRtsskmtgnAdyX9f46qU6laq3DzvGGSOmtdQB9Hiwq/W81a9zvYEvC6GLB7XweJ2g+gSpAAAPkRlSDE3Z82+K9XoSX8pBR1D1VfX6/hw4fr0EMP1dixY7X99vHneb700ku67rrrdPfdd6upqcl11SRpd0nXSfqtb3vUv2dQL8CWNhXZYgur/KHkNtlkE9si/stFPUwaGhq0zTbbaOutt1bfvn2/GbLU1NSkTz/9VB988IE++OADffjhh1lWQypMCr5ShZ6Oeq1rwY+TDKRJAqT1A/0kSULcYwBUMBIAoDIkGY5nM/THJvgvtv47mzvUoUMHnXrqqTr33HO1+eabpypj2LBhuuOOO3T++efrnHPO0bRp01xVz2u8pJckvbD2vfff279CS9DQn+I2qwRgq62s5g+XnIMEIBMjR47U+PHjddBBB6lLly6Rxy9YsEDTp0/XjBkz9MADD+iLL77IoloTJP1B0r+1LvD3BvZhyYA3yI+TBCjGewBVjEnAQD6YEoQ4Q3/8r73bwoJ///KVbSWNtfkhihoaGnTOOefoww8/1HXXXZc6+PfaYYcdNHXqVE2cOFEdOmRyc9jfqhAgeSf+Br1ukHlyMD0AZTR8+HBNnTpV06ZN03HHHRcr+Jek3r176/jjj9dNN92k+fPn65577tGIESNcV69ehZ6m5drwb8+UkAc9pOjPi7jHhUnSaAGgwpAAAPkS9KUb9kUcFBSYWqxNLf/F92fKwWdGjx49NGXKFP3mN79Rt27dbIvbwGmnnaa///3v2mknq+X2g7SRdLukf2jDQN//bFotyCrT2XJLq/yh5ColAdhoo430+9//Xn/961+17777WpXVpk0bffe739WMGTP05JNPuu6V2UvSwYoO/CXz368p2feL2kYSAFQxEgCg/NJ+icZt/fdui+oJMK1FvkjSuJT1/EaHDh00ZcoU7bPPPrZFhRo0aJCeffZZHX744a6L3lnSz7Vh8B+2HKj3fT+bi5MAJLfTTjvptdde0xlnnKG6Orfx6qhRozRnzhx95zvfcVnsr1RYEcjU4h936d6oXkD/dgXsj4MkAMghEgCg8oV114dti2opNAX7QUMPDlVhhRIrV1xxhXbccUfbYmLp0KGDHnjgAZ133nmui75Q64ZpBLXym5KD57X+cqKJuRgqVUrlTgAOOeQQzZo1K9N/t4022kj33XefjjjiCFdFbi7pWG14c7CkQ4DiJgFh7+MG96ZeBgAVigQAyCf/F25Yt39YUBC0PWj4wSG2FR4xYoR++MMf2haTSH19va6++mr97Gc/c1lsowrzAYKG/AQNBSomAX1lESR16dJFnTp1sqp4qZUzATjssMP04IMPaqONsr9ZdUNDg+666y4NGzbMVZEXSFqq5EmAtP4QP8kcnAd9PgRJ8jsb9PkCoAKRAACVLU5LXNgXfdohP97XH0sanfonkFRXV6err77a+RCMuH7xi1/o0ksvdVnkfpI2U3iLf/G5+G9pNfynb9++NqeXRc+ePdXQ0FDy644cOVL33XefWrUq3UJ37du318MPP6zu3a06eYp2lLSHNkzIvX+XUvDfsxT8d+893i9qW5o/XFODA4AKQAIA5E/Ul6lpaEBQ0FB8H9bKuLekeMulGHznO99x2Tqays9//nOdffbZLou8WtLLCp8A7E0IetlcLI8JQENDg3r1svqxE+vTp4/+9Kc/qU2bNiW9riRtttlmmjhxoqvivq/g1YCC3ocN/4nTO+jfLsN+G2GfSyQKQImRAADlFadlP2xfVMte2DGmYQT+h/WM3XPOOce2CCeuueYaHXPMMa6K6yPpp1o/0A+bB2C15FGfPn1sTi+bjTfeuGTXqq+v1z333KOePXuW7Jp+RxxxhL73ve+5KOpgSUtkTgKievDiNAJIG34mZJkExEEyAJQACQBQ+ZJ8AZu+8P1LfwYFCkFr/9dLGmJT+R122EF77bWXTRHO1NfX69Zbb3U5Efk8SR8qeB6Af5tVAlDqlnRXSlnvM844oyJ+16644gp17NjRtpjW2nAycFgSUJSk9T9tElCq4JzeASAjJABA/iRt/Q8b+mNqRXSWADhcHcWJ9u3b68EHH1TnztaLGkmFIO06me8J4E0GutpcyNHY8pIrVWt8z549ddlll5XkWlF69+6tCy+80EVRJyh6CJAU/HcclPxL5s+LOElAuRIB/zVJBgBLJABAZTK1zEWdE9bdHxUsBD0+ldTf4ufQ2LFObh7s1JZbbqnrrrvOVXGjJK1S8CpA3iTAalB6Rnc3zlyPHj1Kcp0f/ehH6trVKsdy6qyzznKRZA6X9IWCA39Tj0BYz1+coN60L+yccgTkJAOABRIAoHxcfXElbf0P2mYaEjTIpp5t27bVzjvvnPb0TJ100kk65BDr1U2LLtH68wCCbgJm9XlbjtV0XChFD0C3bt1KvsRslE6dOmnChAm2xTRI2l/hgX7Y369CzjH1CAQF90kSAXoGgBwgAQDyxd8zEPTFHfXlHhYweB91shy3vt1225V0Kcakrr32WrVr185FUXtLWqYN/w398wBSq6/P58d1KRKA8ePHuxhz79z48eNdLH07SsmC/rDEP20S4N8m3/6wBKHUwTmJABBDPr9RgPwL+4JK8+UVlBiEBQGm7f5kwGr5z8GDB9ucnrnNN9/c1VhtSbpU4UOArBIAegDMTjnllMyvkcbmm2/uYlKytwfAn6BH9QwE/c0rZF+cnoCoRCDJMVkG6iQCQAgSAKByBX0B+/eHBf6mMrwthVGtiFaDqrt1s+pAKIkLLrhAW2yxhYui9pS0WMFJAAlARvbYY4+KTjSPP/542yIGSPpM5iTAlAgEvZfnOapRQDJ/5sQJ4MMSkbRlpkESAAQgAQDyJ+hL2bQ97IvV/6UfFFRY9QB06WJ1ekm0a9dO11xzjaviLlHwjcFqdg5A1kng4Ycfnmn5tkaPtrqJdtHOCg/+gwJ/acO//aAVgeQ7JqpnIOr4KEl6ClyhNwDwIQEAqk/YF7Z/+EBQkuDdvsamIs3NzTanl8yhhx6qMWPGuChqD0n/ljkJSC2vcwCyTgJHjRqVafm2BgwYoEGDBtkWs7M2/NsMG/5j6hGQNgy+TcuHBvUmhjUy+N+7SAhc9wqQCABr5fMbBci3qG5z/zFh3eb+c+J+EXuPCxs+sDSkrpGWLrU6vaSuvfZatW7d2kVR5yqDScB57QHIcmnOjTfeWDvssENm5buy99572xYxRPES96h7A0QF+0HbvWX6j5Vhe1S5YUqRDJAEoOaRAAD5EJQYmLabEgPTw3uMf3LhMptKL1myxOb0ktpqq61cTSYdJek5BfcCpJbXBKBdu3auEqsN7Lbbbi5W2cncTjvtZFvEAAVP/jVNCjYF+grY5+8ZUMB5/uRBhuNk2Je2lyAqGbBR+b84QIZIAIDqYvpSC0sAwloUrRKAd955x+b0kvvxj3/sIlitk/R/FLwaUGp5TQCk7IYvDR06NJNyXXNwL4y+Cg/+404CLn4+eMuS71jT54FCtgftC2KTEGSRCLjqUQByhwQAyCdTK7/3dZIvcH/rf3HfIptK/uMf/1BLS4tNESXVv39/jRs3zkVRJ0u6W9wHQJIy+x3YZZddMinXNQdzAHpLelXxAnX/vqDXknlVINNr07agnoGg8+L0Evi3BckqEQBqSn6/UYDaYPrSDHo2HROVDPgDCXm2zbOou5YtW6b33nvPpoiSc9QL0EnSKXI4CTjPPQBZTQav1LtM+/Xo0cP2hnOtJG2i4CE/pr9xU1Lvf62I/WHJRVHYZ4kCjouzXyHHmfaRBAAxkQAApRXVxR10TFig7z8uTvneY4OSCW9QsLWkBRHXDTVr1iyb00uuX79++v73v++iqDMlvaj1g7bU8pwAZNED0LFjR2222WbOy81CXV2d+vbta1tMJ0WP+w9LDoICf1PQHpZcyFCmfPtMZchwrF/aRMCmN4AkADWDBACoLlFd6WFf9KYA4XWbCk2ZMsXm9LK46KKL1KpVK9tiBkvqrBq/EZiUTQ/A4MGDczEBuMjBakhttX7Abwr+g/bHad33fw4o4rigZMCUTIQlBQo4TgHb/a/9XPUG5OeXCrBAAgDkV9iXYpzA39QD4P+y/odNJadMmZKreQBSoRfA0Q2mzhJDgDJJALbeemvnZWapbdu21kXIPOk37L0pCYga3hOVCASVrZDyTJ9HYftl2Ban1yDsuCgkAah6JABA6bj4Ugn6ggu7TtAXrOmL17Q84BybCi9YsEBz5zX9BsoAACAASURBVM61KaIszjrrLBfFHCTpATkYApTXScBZJX+DBw/OpNysWM4BkKR2Mrf+hyUCcZKAqOFCQftdJQRxEgFTUJ9lbwBJAKpaPr9RAES1+IedZ/pi9m8rbp9mW9k8DgMaMWKEhgwZYltMvaTTVMM9AFlNAN5yyy0zKTcrDnsAggJ7U5AeFJxHDQtK8t50rTQJgQK2e5k+85L0BiRFEoCqRQIAVK40X15hrWpRrWimc7aV9GbCuq/nmWeesTm9bBz1AoyTdLUkq0g4q0A6a1nVu3///pmUmxVHCUBQIB/WKxA2MdhUhj9wXy6ph6Shkg6XdIykEySNX/t+mKRNJa00nJ8kIfB/RpmOle84BWz37w87Bqg51rPcAGQq6gsr6Mst6oswSfBffExRIRFIZcaMGVqzZo2LibUldfzxx+vCCy/UF198YVPMppIOUY0mAFkNARowYEAm5WbFQQJQHAIkSS1avwHP/76oWev+xuvXvq9fe3yLZ7/3eaWknSQdKGmEpN3WXjvKl5JekvRXSY9J+pukjdbuK9aheF3v547/F6TOc5z3XAVsCzrGu91fpumaYZIeD+QCPQBA/oV1f/tb1sJa3oKOLT5bjeFZsmSJnn32WZsiyqJdu3Y67bTTXBQ1XjWaAGRR7zZt2miTTTZxXm6WHMzhSLraj+m4oNb4ehV6GH4o6TVJMyX9TNI+ihf8S1KHtcdfJOk5SR9KuliF3gNTL4B8273b/MOATL0ECnhv+kw0nReFngNUHRIAoLLEbfGP090dtT8o0PfvK35GTJe0JqLsUI899pjN6WUzYcIEF+PvD5TUz6YAEoB1+vbtm7tJ0atXr7YuQuHDdcKG/4Qd01bSryS9LennkgbYVnStzSSdt7bcP0jawldvUzIQ9T4oCYjbE0oSAKyVr09QoHql+XKx+QILazkL6gnYS4Xu/dT+8pe/2JxeNv3799fYsWNti6mXNNCmgLwmAFkMAXJwU62S++qrr6yLULyAPm4S0ErSpZLmqnDX6qzG5zVI+o4Knx//o0JPgb83Ik7gH6eHwP/adRIAVA0SACA/wlr9k7b8m1rUwoYEWc3kffvtt/XWW2/ZFFE2p59+ermrkNsEIIt6b7zxxs7LzFoGPQBxHkFJwEJJ56gQ+J+s0s0FrFdhQvxcFSYPe4N4f49GVA+AQt7797lMAkgYUDVIAIDqFvaFaeoFCDq/TtJTtpXJ6zCg0aNHq18/qxE81kgA1snb+H/JWQIQNuwn6NHge7+tCgH4WSrfIiCdJN0q6Ratm7zsD/7DAv+oYUFBn2tBiQA9AahpJABA/kSN/zeNkzWdY/qS9Z//V0mfJ62sV14TgIaGBp166qllrUNeE4C6OvexVR4TgBIMAQp7/FOFiegPqLAqVSU4XNKLknrJHPwnSQriNG64SAJIFlAVSACA6hcW1Ie99n+pHirpCZuKPP/881q0aJFNEWXz/e9/v6w342pqairbtW1kMVmXIUCJHktUCLTPtq1ABraS9LykwYoX/EvRgX+czzvF2A5UNRIAoDok/XLzt4iFjav1smrCX7NmjSZNmmRTRNn06dNHY8aMKdv189oDkEUCkMcegBUrVtgWEdUD0OB5Lj5aqbAe//a2F89QV0lPqpAERAX/pv1xA/+wzzb/sWFIGpB7JABAdbH5YoozTOgpFVoiU3v44YdtTi+r8ePHl+3aeU0AshgClMcegOXLl9sW8aWSjfnvIGmqCkNsKl0nFZKAfgpeHUgB2xXzdRCGAqHmkQAA1SvuF2DY8B/53o9S4SZBqT3xxBP68ssvbYoom4MOOqhsS1DmNQGgB6DAwe/8csVf6adO0iOKfxOvStBR0qOSGmVeIjQoMYibEMj32kUSAOQWCQCQX0m/pExffEmvYTUMaMWKFXr88cdtiiibck4GJgFYV17Pnj2dllkKGfcAeJOApZIeV6EHIG82UyFxWaL1fzZT8J+0V0C+12FJQBwkCsgtEgCg+qT9Uop7nvUdve6//37bIsqmXJOB85oAuB4C1KNHD7VqVa4VLNNz0ANQTAD8Lf7F8f71kv4h6WEVWtPzapikXyo48PcH/wrYr4jX8r1mcjBqEgkAAK+oYUN1koZLesPmIpMmTcrtMKC+fftq9OjRJb9uXhMA1z0AeRz+s2rVKq1Zs8aqCEnHKnzYzzQVlvnM322SN/RfkrbWhj9vVA9AnMnAUUN9GAqEmkACAMAk7MvvQZuCV6xYkdvVgKTyTAb++uuvS35NF1z3AOQxAchw/L/3cZmkPWwvVCHqJd2gwn1HvMF/1CpAQUmA91kp34chSUAu5a8fFUBRi4K/fEzbXbpf0s9sCvjzn/+so48+2lF1SmvMmDHadNNN9cknn5Tsmg7WkS+Luro61dXVqaWlxUl53bt3d1JOKTm4CdgKrWuw8/9D1klaJOlc24uE6dq1q3bffXcNHDhQbdu21YoVK/TPf/5TL7zwghYvXpzFJbeV9AMVGhtaJMXtAvN+/hVf+7d51QVsS7IfyCUSAABeLYbXfkMkvaXC2t2pPP7441qxYoXat2+ftoiyadWqlU444QRdddVVJbtmXhMAqTAMyNWNzLp27eqknFJyeBdgacO/yyclvaqMevQPOOAAnXvuuRo1alTg3JempiZNnTpVN9xwgx555BFnid5aF0u6RVIXFX6+ZhUCclMy0OI7zr8vKDEoinoPVBWGAAHVy//l1WLYbjo+itVM3i+//FKPPPKITRFldfLJJ5f0enlOAFwOA6rxBCDocbmkAbYX8Bs1apSee+45Pf300xozZoxx4ntDQ4MOPPBAPfTQQ5o9e7aGDRvmsho9JI1T8IpHcScIS+sPC/KKGirkPw6oGiQAQHWICu5Nxye9hvc866V8br31Vtsiymb77bfX0KFDS3a9PCcALlftIQFY746/H6swTMaZQYMG6ZlnntGTTz6pPfZINqVg2LBhev7553Xeeee5rNJ5khYo3hwARTz7j/WKuw2oCiQAQP4kCd7jHJs0GSgev7MKw4BSmzJlij788EObIsqqlL0AJAAFJADfBP9/kjRRDr/HTzvtNM2ZM0f7779/6jIaGxt19dVX6/rrr3fV87OFCp81QYF/nBuGBQX8pqVBg94DVYkEAKgMWY41DRvX79/X4nsdVa8/21SsublZd9xxh00RZXXcccepdevWJbkWCUBBDScA/qU/f6LCUpnWGhoadN1112nixInO5uSceeaZ+vWvf+2kLEnfVnDgL20Y4IctCWpq/feea9pGYoCqQgIAlI5ta3zU+VHBe9xEwFS2P0GQHAwDuu2221xPHCyZHj166OCDDy7JtfKcADQ2NjorK48JgOU9ACRpjdYP/mdLusi2UKkwP2PixIk666yzXBS3ngsuuEDHHnusi6K+reDW/zhDgkxxDr0AqGkkAED+xUkago4xtfD7t4cdt4ukeTHqaPTuu+9q5syZNkWU1UknnVSS6+Q5Aaj1HgAHCW5xdZt6Sf+rwtAfJ1nVr371K40bN85FUYFuvPFG9erVy7aYPSR9pGTDfiRzD0DY5GB6AVATSACAfDG14sftOTD1DASVFdaT4N12W8i1Y7n55pttiyib0aNHq1OnTplfJ88JQK33ADi4i3Oz1iUAZ6lwN25rRx99tP77v//bRVFGXbt21aWXXmpbTGtJGyt6FSB/y3/UBGEvegFQU0gAgHxKO3E3bFvUmH9TQnCHCkMUUvvzn/+sBQsW2BRRNm3bttXYsWMzvw4JQEGNJgDFHoDHJP3KukKS+vTpo5tuusn5nZqDjBs3Tj179rQtpo82XAo0atJv3NWB/Aj+UfVIAIDSSrqCj4tA378vrMXfdO2wnoAxkp5KWM/1rF69WjfeeKNNEWV11FFHZX6NPCcAroYANTY2aqONNnJSVik5GAJU7AH4jaQO1hWSNHHixJIlU23atNHOO+9sW0xvmcf+x00ETOP+48wFYBgQqgoJAJAPYTfvSjq+P+o5bDUgUxJhvaD/TTfdpFWrVtkWUxajR4/OPDAlAZC6dOlSkhZr1xwNAZor6TD72kgnnniiRo8e7aKo2DbeeGPbItoreuiPd39RnF4AoOaQAADVI05LftDx/nPjJAP+bY9J+jxtxSVp4cKFuvvuu22KKJt27dplPgwozwmAqyFAeRz+IznpAWgt6VoHVVGvXr30P//zPy6KSqRNmza2RTQoPPj3t/b7k4Gw1vy48wSAqkECAORL1B1/0wTvSYcABZ1/sqQ/Jvg5Al17rZMYpyyyHgaU5wTAVQ9Ax44dnZRTag56APaXNNBBVfSb3/xGPXr0cFFUIvX11uFG8Q7I/odpKJAUL8BPEuiTHKBqkAAAlSNNM2FUQlDcFxbMmxID/37T8cXtf0he/fW99tprmjp1qm0xZXHQQQepQwcnw7MD5XV4lOSuB6CGEwAnGdTBBx+s4447zkVRiTm4GZp3JSRT678M++ImAkFzAAj2UZVIAID8MQX9UQlEVAt/nGPCegH2kDQtog6RyjE8wYX27dvrgAMOyKz8PCcA9ABYJwDWOnbsWNaJ9suXL7ct4ksFD+8JmwwsxQ/+vQj6UfVIAIDSSxKoxzk+SflRE4PjDhVqUaFFzltesxyMU540aZLmzp1rW0xZZHlX4JUrV2ZWdtZc9QDkcQUgyckcAGuXX365+vbtW7brO0oAgob8RK0CFNYL4JdkO0kCco0EAKhsQa30QceEtcyHtfanDf69772JwF8kvRv9Y5m1tLTkthdgzJgxma1Sk+cegFofAlTuBGDPPffUhAkTylqHhQsX2haxXOHLfipgW1QvQNgE4bjvgVwiAQCqT9CwnSQBftC58r32vy++Pk3S9bY/wF133aXPPvvMtpiS22yzzVysdx4ozz0AroYA5bUHoKmpqWzXbmxs1M033+xiEq4VB3/PCxVvCdCisF4A+Y5TwD4CfVQ1EgAgf+LOAQh6b2r99z+HPZoNZRXf/0HSktg/TYBVq1bpd7/7nU0RZZPVMKA89wDU+hyAr7/+umzXPuecc7TtttuW7fpSoQfk3//+t20xnyr6TsAK2e5lav0HagYJAFBZ4o4VCGuVT3Nump4C//Cf4vvvSbo95s9hdOONN+ay1XvMmDGZlJvnBKDWhwCtWbOmLNfddNNN9fOf/7ws1/ZatGiRbRL0laROincjsCBRQ4Nk2OYvA6gaJABA9TAlAv4kwbvfNKzHZh5Ai6TrJFmNe/j8889zeWOw3XbbTb169XJebh6ToaJanwRcrh6Ayy67rCKSJgfDfz6T1EfBqwAFrf0fNQnYL2p1IIJ/VB0SAKAyhQXsUccnGbPvfd3seV/kHe7jH/5jmmDcImmUpEcMdY3tt7/9rW0RJVdfX6/999/febl57gFoaGhwUk4lBLNplKMHYPDgwTrxxBNLft0gjhKAsCVAo/YFCdsXhcQAuUcCAFS+NEuIRA3tUcDroPP8r037ghKDK1LUez2vvvqqZs2aZVtMye23337Oy2xqairrWHIbrlZGymsCUI67OF966aXO5l7YWrBggW0RxQTA1PJv6gHwv49aDQioGSQAQL6FTfSNc46LeQCm83eV9Nf4P0qwPPYCZHVDsLwOA3K1Ak1ehwCVugdg22231dFHH13Sa4b55JNPbIvw9gBE9QJ4A/uw44KQFKBmkAAApefii8U0RCjJ+VETib3bw4YCSeak4eqE9drAQw89pI8//ti2mJIaMGCAtthiC+flfvXVV87LLIVa7wEodc/Nj370o7Iv++k1f/586yK0/uo/Ua38cX/hguYGEPijJlTOJwSAtKLmBZjG65uOkdbNB/C+jloJKGj/w7K8MdiaNWv0+9//3qaIssiiFyCvPQCuEgB6AKJtvPHGOv7440t2vTgcDAH6RPHH/SvgfZAkSULQ8SQKyDUSAKCyJPlSidPqH3RMUKBu2h72OmwoUTEpmKDCikBWbrvttrLeTCmNLCYC5zUBcNUandcegFImAGeeeabatm1bsuvF4SABmK/1l/k0rewTFPjHWREobq8BQT+qBgkAUDpJu6aTCgrq/fvChvuYjo0aCtTse+9PFG6TtCzFz/ON+fPn65lnnrEpouRGjhzprOW7KK8rATEEqDRDgNq1a6cJEyaU5FpJOBgCtEDr4hV/i783KZDMAb8p+PdjKBBqAgkAkK2kY1JNZaRhSgaK20zBv/85aFhQWA9A8djitpPl4MZgd9xxh20RJdWrVy8NGjTIaZm1nADU1dWpQ4cODmpTeqVKAI477jj16NGjJNeKq6WlxcUk4AUK7wHw3wtASve5mWaYD4kCcokEAMhGmqDfxXjVpExDfLzPknkegGlysP/Y3yk4EYntoYce0tKlS22KKLk999zTaXkrVqxwWl6puBgC1L59e2f3Eyi1Ui0D+sMf/rAk10nis88+s01c12jdKkBS+DAg0/CfMKz4g5pEAgC4Y9Pan6blyvWXlmnojylJiJoc7H18W9LTNpVbuXKl7r//fpsiSs51ApDXOQAuegDyOvxHKs3qTd/61rc0dOjQzK+T1AcffGBbxHxJB2r9HgDTPQAUc1vcYUBA1SIBAOxl2YJk6pJ2dT3TnAD/+6BWf+/2qOVAWyRZL+h/++3WI4lKaq+99nJaXl57AFwkAHldAUgqTQ9AJbb+S9KHH35oXYQ2HO5TZGr9j+oVUMj7ICQEqDokAEB6rgL/qNb/OF3YcY6TopcD9R8X9DroGH/ZQXMHnlBhLG9qzz77rN5912pV0ZIaOHCgevXq5ay8vCYALoYA5bkHIOsEoGfPnjrmmGMyvUZaDhMAacPAPmib6fMwaLiQH4E+agYJAJCOq8A/7AspTctUknqZxvyHPYcNDYpaZehMSXclqN+GFW5p0W233WZTREnV1dXpW9/6lrPy8poA0AOQbQJwyimnqE2bNpleI6333nvPtogPFL6ST9i4/6gg33bSMJBbJABAMqVu9Q86Lm2yEMY0QTfukqGm1/6kwHopn9tvv13Nzc3RB1aIXXfd1VlZzAHIpywTgLq6Op1++umZlW9r3rx5tkV4hwCZGk3iBvxJ0UuAqkUCAMTjMvC3Cf5Nx0ZdJ640vQEyvA465kBJL6Wo1zc++uijXN0TwGUCkNceAIYAZZcA7Lvvvho4cGBm5duaO3eubRHFWcRJgv84n7NhCPJR9UgAgGhZBP6mcqOCf1P3dtx5ADZJgfd10uDf22Rv3Qtw66232hZRMrvuuquzG2HRA5BPWSYA48ePz6xsW0uXLnVxF+B5chPchx0XtzzXPa9A2ZAAAOFsP+BNgb+L4N/UAhY2PtZfplecicH+7XEnCxefmyXdI8kqInr44Ye1ePFimyJKplu3btpyyy2dlJXXHoBanwOQ1Y3AevbsqSOOOCKTsl1w0Pq/QutPAvYyzQVIO1SSYB41hQQACGY75Mc0MS3OF5kiXkedG2fcatSXY9jSoArYF9ZL4O8NOFHS4yFlRlq1alWu7gy80047OSknrwmAiyFAeU4AsuoBOPnkk9W6detMynbh9ddfty3iLUmHrX0d9hkahbH8gA8JALAhl4F/WHlhrVVhyUPc1v+o6ycRZzJw8TmsR6C47RbbCt1www1qaQnLTSrHDjvs4KScvCYAtd4DkMWNwCp98q8kvfjii7ZFFLsQTA0q3vdx9gXtNyFBQFUjAQDWZxv8+9+HBf9BxyR5b0oIwrrDo3ogwph6BcISAtP+JyV9EuOaRm+//bYmT55sU0TJuEoA8joHgEnA7nsARo4cqUGDBjkv16XZs2fbFhFnDFFUsG/aF7UdqGokAIC9qKA96Ni4gb4pgA8qp96zL6hcU2Lgr1/Q9qKoJndTi79/29mS7owoK9L1119vW0RJkACQALh26qmnOi/TpZUrV+qNN96wLeYVJespBRATCQBQ4GpsadLA339OVOt9nQp/t6ZAPqxXwH89U29B1M/iF7VkqH9b8WG9lM+kSZP0r3/9y7aYzG2xxRbq0KGDdTlffvmlg9qUXkNDg3UZeR4C5DoB6NixY0VP/pWkOXPmuJj8bFoymGAfsEQCAKQP/OO0+ke1tke1ztcHHON/7U8I4gwBivqZw3ojvIKC/SBBx42V9HzEeaGam5t1ww032BRREvX19dpuu+2sy6nlHgASgHW+853vOEkos+Rg/P+HkkaIYB/IBAkAal3Wrf7FZ9vhPt5EwBvw1wecE9VjEOdhEuffK26PQIuk/41RXqhbb71Vq1atsi0mcy6GAeV1EnCtJwCulwE9+eSTnZaXBQcJgL+AqM/ZtMcCNYkEAEgmTvBvCqajAv+gc4MSAX9ZcXoAlktqLamNpJ6SuktqJWmJpPdi1MXEtC+sZ8C77V5JX4SUH2nRokV64IEHbIooiR133NG6jLwmAC6GADEHoGDAgAEaMWKEs/Ky4mACcFAGYRrOCCChVuWuAFBGSb884nzxhA33CTsmLFmIeu1931nS3pKGSNpW0mBJm6oQ/IdZJOl9FZKB9yW9K+lVFSbhFZuuTT0aUUzJQIuk/yPpD5IuSFDeBm6++WadcMIJNkVkzsW9AGp5CFDnzp0d1KQ8XC4DeuKJJzr598zS4sWL9c4779gW86KkHiH70wT/+Vg3GCgBEgDUKtfBfykCf++24vslkvaTdIykgyRtZvwJwnVb+9jFt71J0puSXpY0TYXlO4MGs8f99wz6Ar5R0o8kpW4mnjVrlubNm6ett946bRGZGzJkiOrq6qzuXZDXHgAXAWvXrl0d1KQ8XPUA1NXV6cQTT3RSVpZmz55te4+OZhU+c0aFHBPnBoUADCq7GQGoDK6C/7Bx/f6x+kGTf73b26gQNC+QNFnSaUof/IdpUKEH4BRJt6uwdv8VkvaS9JGnflGCWv+Lz8fK8s7ALS0tmjhxok0RmevcubMGDBhgVUZeEwDbIUCtW7fO7RyA5uZmrVmzxklZw4cP18CBA52UlaXp06fbFvG2pDGGfUFziWyQLKAmkQCgFiVp/U8T/EcN04lKBIL21a99tJf0/1QYpvNjSb0S/Cwu1EvaVdLFKvQM/GRtHaZ4jkk6REiSfmdbsdtvvz2TO666ZDsMaPXq1c6CyVKy7QGg9b/gu9/9rrOysjRt2jTbIkwziEsZrJMYoKqRAKDWlCL49742Bf9hiUDQ65Uq3DzrLUmnKnpMf6nsqcIY/gWSviVphuG4qH/3lZL+aVORL774Qg8++KBNEZmr1XkArVvb/brmOQFwtQJQQ0ODjjnmGCdlZWnp0qV6+eWXbYsJSgBo6QccIgFALSlH8O99bUoEwgL/eklbSfq7Cq3u7RP8DKXUUYWJvO9JGibpGW34c5u0qDCkyHoMz2233WZbRKZ23nln6zLyOAyobdu2VueTAEgjRozQpptu6qSsLM2cOdNFL5X3/iBxA/eoFce829ImA6ahjEDukAAAG4oT/AdN3g3alyTY9wf+X0k6Q9Jzkip3duv6Okj6L0nvqDA06I8Jzr1NktV4iSlTpujTTz+1KSJTQ4YMsS4jjz0A7dq1szqfBEA67rjjnJSTNQfDfxarsPqYFB3A+4P5sLuQB5UT9D7uTQ2BXCMBQK2I2/ofN/gPOscU/JtehyUByyXdJOky5XO1ro6SrlKh58I7tCfs/2G8pIdsLtrU1KT77rvPpohM9evXT927dy93NUquT58+Vuf37t3bUU3yqbGxUUceeWS5qxHL1KlTbYuYIek4RQf2CthvOibJ+WHHAVWDBAAIljb4974uvjcF+aYkYLWkpyVV9sL28Wyvwhf6LpLujHH8zbYX/OMfk3Q6lJ7NMKCGhgb16lXqed/2dt11V7VqlT6PHTZsmMPalFb37t3Vpk0bqzK+/e1v5yJx/OKLL/Taa6/ZFuPvQog7tCdKqc4BcoEEALUgTet/0L6oMf7e196A3r/ddOfeekmrJE1S4WZe1aJO0g8kvaR1Y3tNPS3NspwM/OKLL+q9996zKSJT++yzT+pzd9ppJ7VvX6nTQMw22mgj7bbbbqnObdWqlQ4++GDHNSqdVq1aafjw4VZl5GX1n+nTp6u5udm2mGnasMU+aNx+VGLgYolQoGqRAKDauRj6EzcxML2OavUvvl8i6TEVJsRWo20k/U1So6QX1m7zT6beW4VVhaw8/PDDtkVk5nvf+17qdfFPPvlkx7UpnfPPPz/Ved/73veshxCV20knnZT63E6dOumwww5zWJvsTJ482baIfyvdDQH98wKC9tsgkUDVIQEA4gX/YRN+Ta9NPQD+yb7F17+TNDL9j5ELrSVdLeksSb8P2F8n6W4VegJSq+TlQAcMGKAJEyYkPm+rrbbS6aefnkGNSuPwww9PPJG1X79+uvLKKzOqUelst13QzbPjOeWUU9SxY0eHtcnOk08+aVvEdEk7ytziHzQB2GalH3oMULPito4CeWTb+m8b/Ht7ALzPQcnA0ZJ+G7O+1WKOpCNUmPBX73vsI4tkqL6+XgsWLNDGG2/soJrurVixQvvuu69mz54d6/iOHTtq5syZTu4jUE4rV67Uscceq8ceeyzy2P79+2vSpEnadtttS1CzbH399dfacccdNW/evETntW/fXm+++ab69++fUc3cefPNN60SnbUmSFohac3aR5Pnscb37H+sUaHhwLut2fPc7Hvf4nsuvjY9pA2ThOJr63FPQKnRA4Bal2Xw7x3q4w3+/Xf37aXCijm1ZmcVbvgzw7e9TsmWD91Ac3OzHn30UZsiMtW+fXtNnjxZY8eOjTy2f//+mj59eu6Df6mwHOhDDz2ka6+9Vj169Ag8prGxUaeeeqpeeumlqgj+pcLPdPvttydaDrWurk7XXHNNLoJ/yUnrv7T++P+iqJb5oOE/puVA464slKQngF4D5BI9AKhmcX6/gwL9oO1pgn9Tb4B322IVguAdYtS1Wq1QoRdgiNYlRddK+kwWS6AeccQRFT0UqOjBBx/Uddddd3XBQwAAIABJREFUp+eee269GyhttdVWOvXUU3X22WerQ4cOZaxhNr766is99dRTmj17thYuXKj27dtrhx120EEHHZSLG16l8cwzz+ioo47SkiVLQo/r2LGj7r777lgJYqUYNWqU7RyADyRdouCWf1NvQNjD3/rv7wnwt/r7X8vzHNYDwNAh5BIJAKpVFsF/8bV/aE+c4N/UM3CqpF/GqKuVtm3bqmfPnurevfs3E1AXL16sL774IjIYKZEmSWdK2kTrkoDdJe2ftsBOnTrp888/V2Njo5saZmzZsmV6//33tXr1avXp06dihy/Bzscff6z//u//1p/+9CetXr3+fe8aGxt11FFH6fLLL89Ny79UGNLWvXt3rVq1yqaYmyX9VeaAP2hbs9ZPCJq14VCgFgUnBEFBf5IEIGyFIqDikQCgWkX9brsa+pMm6C8+WkmaK8n5uo59+vTR4Ycfrn322Ue77LKLBgwYoPr64BF/y5cv1/vvv6/33ntPc+fO1csvv6yXX35Z7777rutqxfFLFSYC7ivpExUmRqc2bdo0jRw50r5WgGNffPGFZs2apQ8++EAtLS3q37+/9t57b+PQqEr25JNP6qCDDrIt5khJnRTd6p+k9T+sByBo7L838I8K9kkAkGskAKhGtq3/aYN/KXyNf//rK1UY+uLM3nvvrYsuukijR482BvxxLViwQE888YSeeOIJTZo0SStXrnRUy0g3qdAbsLGkj2QxV+nCCy/Ur3/9a1f1AhDgoosu0hVXXGFTxNeSekg6XNEBv6knwNsLEBX8hyUAYZN/TXMISACQOyQAqEauWv+jxv37V/dJEvx3kvS6HE3E79Wrl66//nodffTRLorbwH/+8x/98Y9/1E033aTXX389k2v43KHC8Ki/SUp9G9ghQ4bolVdecVYpABsaPnx47BWtDGZIul3mAD/tHICosf9xEoCoFYBIAJBLJACoNlm2/of1ApiSAH/wX3z/azlq/d9999310EMPlWTMeEtLix555BH94he/0Jw5c7K+3J8kvSfpwrQF1NXV6eOPP1bv3r3d1QrAN5YuXaru3buvN4E9hR+rMOQvKMA3vY7T+h+0BKg/8LddApQEALnEMqCoNaYEIWwisClRiBr+Ywr+6yQdk/on8DjggAM0ZcqUkk0Yraur0+GHH66XX35Zt956q3r27Jnl5Y6RZZLU0tLianlCAAFmzpxpG/xLUvGPNGyVHdNwHNNSn2Hbw5YATYLAH7lFAoBaFWfoj39b1ARfKV7wf4bS3e5+PTvvvLMefvjhRGuLu1JXV6dTTjlF8+bN07hx47K8VD/bAkgAgOxMnz7dtogvJL3meW8abx8W3PtF7Y+zneAeVY0hQKg2Yb/Tacf+Jxn3HxT4e7d9osLE1l5xf6AgXbp00SuvvFIxSwX++c9/1hlnnKFFixaVuyob6NKlixYuXKhWrVLfUgCAwdChQ/X3v//dpoiHJP1F8cb8u14ByP+sgOeoFYC8z0Bu0AOAWhSUJIRN/DUdE3YfAH/gX9w2UpbBvyRdf/31FRP8S9JRRx2lV155RcOHDy93VTbwn//8R3/729/KXQ2g6ixevNjFJPtpCh7qI8P2uEOEFHC8n3+fKaAnwEfVIQFArQsK9E374y77aVoNqE7SGNsK77PPPjrhhBNsi3Gub9++mjFjRtZDglJ5+umny10FoOrMmDFDzc3N0QeGm+55HRbwm8b0K2S7yzH+JAGoKiQAqBWmlX9Mx0a1/getGBSWDBR7BKzvlnPllVfaFpGZNm3a6JZbbtG11177zR2HK8HUqVPLXQWg6jz//PO2RSyU1FrBAX3cJCAsGYgqz78dqBkkAKhlpiDf+960BKh3f71hmz/4b5E0yKbCY8aM0W677WZTREmcffbZuv/++8syQTnI7NmztXz58nJXA6gqL7zwgm0RMyTttPZ11Io/cYcDhW2X4Zg0SBqQayQAqHVJx/4HDfORNgz6gyYC721b2bPPPtu2iJI54ogjNHnyZHXr1q3cVdHq1av17LPPlrsaQNVYs2aNXn75Zdtipis84JfhOe7+OEuHejGpFzWDBAC1IGx5z7Bzwlr9vceFjfn3PjZPV/2CAQMG6MADD7QpouT22msvzZo1S/36Wa/maY1hQIA7b7zxhr788kvbYqYregKwAp7928ImAIcF+2Er/JiQHKAqkAAA6zMlBlGTgeNMArZKAMaOHav6+vz9yW677baaNWuWBg4cWNZ6TJs2razXB6qJg5W1lkqa53lvMwTIlEC4wBwBVKX8RROAO1Hr/4e99r43Tf71PwbYVHa//fazOb2s+vXrp+nTp2ubbbYpWx3mzJmjxYsXl+36QDV58cUXrYuQdIrSt/5nPQGYoB9VjQQA1S7J6j9h54a1/gdtD0oG+qeowze22GILm9PLrnfv3poxY4aGDBlSlus3NTW5uGspADmZAOwtIE7rv7Tupl1SdMLg3y7DMUBNIgFALTJN9I3bE+B9HzTx1/Roa1Ppvn372pxeEXr27Klp06Zp1113Lcv1mQcA2Fu2bJnefPNN22JeUPzJumHj9eMOGXI5AZgEArlHAoBaF7biT9CxQa3//n3+oT/FbVZfGo2NjTanV4yuXbtq8uTJGjp0aMmvTQ8AYO+ll15ycQMwfwIQNcQnqncgyRwAJgCj5pEAAAVRS3/695kC/aD7AQSVUdO6dOmiyZMnl3w40BtvvKHPP/+8pNcEqo2D4T/vSzpE0a33kjmwDxsC5HICMFCVSABQzeIu/2nabxr3H7ZSkOleAHWy/DJZuXKlzekVp1u3bnr66ae13XbbleyaLS0tmjlzZsmuB1SjOXPm2BYxW+EBfpyhPdKGn6mmyb82E4BJAlCVSABQi0zDfKLmAHi3mR7eY/x3AraK4D/++GOb0ytSz5499cwzz2jw4MElu+aMGTNKdi2gGjlIALzDf6TooF8xXkcF92HDfAjyUXNIAABzImB6X9xmWvHHtN0qgq/GBECSNtlkE02ePFm9e/cuyfVIAID0li1bpnfffde2mKgJwGHBfHPA8UHHhvUYxGn5NyFZQFUgAQDWMQ3/KT4nedRrw4TgI5vKvf322zanV7R+/fpp0qRJ6tSpU+bXev3117Vo0aLMrwNUo1dffdV2AnCzpFc875O0/psC+SRzAMIShjAE/qgqJACoJVEBftCzf1vUsJ+gfcUkwCoBeOmll2xOr3hDhgzRI488ojZt2mR6nebmZs2aNSvTawDVysHwn7clfVfmgD6s9T9qknDcwD9MFpOJgYpDAoBqFTXOP0kZcY4zJQXFv7F6WSYADu68WfFGjhypiRMnZn4dhgEB6bzyyivRB0UU4XndonU390ra+q8Y24OGCfnPA2oSCQCwvqBWfe/ruL0A9b7X0vpffIm99957mj9/vk0RuXDiiSfqvPPOy/QaJABAOo4SgLhDfpoVHMjHmT8QZ2KwFJxAmJA0oGqQAKDWmXoK/MeYAv3i+3ptWJY/SZgjaU3aira0tOjxxx9Pe3quXHHFFdpvv/0yK//VV1/Vf/7zn8zKB6rR6tWr9cYbb9gWM0fhE35NPQIynBdn2FDQPtMxQE0gAUAtCxv/H3cIkSkRCLoXwDBJb9pU+LHHHrM5PTdatWqle++9V5tuumkm5Tc1NenZZ5/NpGygWr355pv66quvbIvx9gBI4cmAafUf0zFJA/m4qwGRIKDqkAAA6YUNF/Ju804GtprJO2XKFC1ZssSmiNzo2bOn7rjjDtXXZ/MxxTAgIBkHw3/mq3AHYCnZBN+w1X9keB1n3oAfvQKoGSQAqBVhrfpxjjeN+zeVHRT8S5JV1Lly5Urde++9NkXkygEHHKDzzz8/k7JJAIBkHKwAZGr9bw7YZuohCEoCwlbuCRvjT5CPmkUCAKzjD+wV8N5/XFTw718J6AlJTTaVvOWWW2xOz51f/vKXGjZsmPNy58yZo2XLljkvF6hWDnoA/OP/JXPwbhreEzVcKCxZMLGZAEwSgVwiAUA1SrLUZ5zj47b8h/USFP/WdlbhLpipzZ49W3//+99tisiVxsZG3X333dpoo42clrtmzRq9/PLLTssEqlVLS0sWS4AmWd1Hhuew1n+vsInFYQjwUZVIAFDNkiYCSc839Q74n/3brJfyufzyy22LyJWBAwfquuuuc15utd9cDXDlX//6l5YuXWpbTLEHIGyln7D3aYL4uBN64yYSQFUgAUCtCmrND3oddn5UWaZegYeTVtbvwQcf1Lx582yLyZVx48bpmGOOcVomPQBAPA7G/y+R1EXJxvr75wbId6xpe1TPAVDzSACA9dkE/0H3Bwg6bnNJsy3qqObmZl1yySU2ReTS73//e/Xr189ZeSQAQDyOJgDvpPhDfsKG+YQlDaa5A34ukgISCuQWCQAQLGryr+kY/z7TOdYzee+77z5NmTLFtphc6dq1q+688041NDQ4Ke+dd95xMawBqHqvvvqqbRHFFYCk4CDdxQ3AZHgf1mMQB4E+qg4JABDONPnX/9r07C+j+LhX0grbyp155pkubsyTKyNGjNBPf/pTJ2W1tLSoubk5+kCgxr322mu2RXhXAJKCW+sVsC3qBmBpxuzHXdGHwB9ViwQAtS7tROG4E4BN5+4r6f6U1/7GW2+9VXMTgiXpZz/7mUaMGGFdTmNjozp37uygRkD1Wrx4sT766CPbYoorACVp/Tet+mMavhO2PyxRSNMrAOQaCQCwoThDe7yvg+4fEHSMf/tVcvBl86tf/armbmrV0NCgu+66S926dbMqZ8stt1Rdne1iUUB1e/31122LWC1pruIN6Qkb3+8V1QtgOk8yJxBJkCgg10gAgPhcLCvqLWNrSY9alqmmpiadcMIJWrhwoW1RudK3b1/dddddVvMBvv3tbzusEZJasWKFXnzxRT3zzDN68cUXtWKF9ai4TH344Yf661//qldeeaWmbiLnIAGYJ+kERY/rl+F90PFhvQMuh/IQ6KMqkQAAZmlvKBa2RKifk/E78+fP10knnVRz49kPOuggXXbZZanObdWqlSZMmOC4RuU3f/58/eEPf9C5556rM844QxdffLEmTZqk1atXl7tq33jppZd05JFHqmvXrtptt9104IEHarfddlPXrl115JFHVtT9GZYsWaKLL75Y/fr1U//+/bXHHnto5513VteuXbXPPvvooYceKncVM+dg/L+3gLhDfkxBvvdZCg745dsfdGxUYE/gj6pG3zeqTZxlOE3r89epkBTXe157tzV4XnvfN3ge9ZJaeba38u33b2sl6VRJB7r44S+++OKaWx60paVFJ5xwgu65555E51100UVVNX/is88+0/nnn6977rlHa9as2WD/JptsoksvvVSnn3562YY9tbS06JJLLtFll12mpqYm43ENDQ36yU9+oksuuaSsQ7See+45HX300frkk09CjzvssMN05513qmPHjiWqWWntvvvu+tvf/mZTxIWS/i2pSdKatc/+13HeNwe8bg55tHheS+snHnFXFYo7YRjIFRIAVJs8JgCvq7BCRqP1D19Xp3vuuUfHHnusbVG5snLlSh1yyCGxl0U9+uijdc899zhbTrTcXn31VY0ZM0YLFiyIPPaYY47RnXfeqdatW5egZuu78MILdeWVV8Y+/oILLtAVV1yRYY3Mnn/+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/C36vp5C3znCnfbV456rrPNX6Nwm4zkkZ11goJEAAM3USQLccisp8F04rd6A7nq+4NyXEPTiYb3G36jT+jgtdpByzJgxQx/84Ae1bNmyktW2ypZbbjmu26fadNNNG22/2WabFdqTuC222GJct3/hhRd0zDHHaHh4uFE9FfdI+oz8SXrK/7Kv9d/XO5DS2u8L8mOBP0E+UAAJANBciS7oUJn7PFKZD7WqxXoDQt35OT0DKY9T1fmNgKJ38N5777069NBDS34/eqtst9122nzzzWtvv/POOxfcG78tt9xSr3jFK2pv/6Y3vang3oRtvfXW2mqrrWpvv9NOOzV6/S9+8Ysl73GZp87/3WmyA//YeSAlSQi1/uc+ZEy7ZVVW4wmABCQAQBk53dOh6ep8qDXMumCGWuBC3f65AX9OUlB9rSmS9pX0aOgg5brhhht03HHHDeQ48cmTJ+uQQw6pte3666+vAw88sPAe2SZNmqTdd9+99vZ77LFHwb2JO/jgg8dl24cfflhf+cpXam/vGJV0mKSPyA78YwF/bH2rUSGn9d/a3xL/xKE6Bu8kAXiQAADl5CYBbrmVFMRa9kecbX2t/SmJQelWf2s/jpb0PklPxQ9VuksvvVRnnXVWySpb49Of/rQ22WST7O2OO+44vepVr+rBHtlOPvnkWuPj3/jGN2rPPffswR75nXrqqbWO6X777ae3vvWttV5zZGREH/vYx/Tiiy/W2t5wgaSd1TzJT+kxqNP6764n5zm1AQRADc3vVgJQlXpX5STn2SpLWRZbx3r49iNl+9T6J3vWlaQdJZ0l6UOSNlYht99+uzbffHO9853vLFVlK2y66aZ61atepalTpyZv87a3vU1XXnll45uIc7zyla/U8uXLdddddyVvs8EGG+gnP/mJXve61/Vwz9a16aabauutt9b111+fvM0222yjqVOn6mUve1mt1/zGN76hSy+9tNa2humSDlXnR/mqwfuw/AG/b5m13EruU3sFUoN9eeYtvm0BeJAAAOWNdRJgvWZq8B8K7K16qutNTljHV+eb1PlmkkMlra9Cbr75Zr35zW/WDjvsUKrKVthxxx21zTbb6KabborePPqud71LP//5zxvdO1DXe97zHj355JN66KGHoutuuOGGuvzyy8d8+E/XjjvuqFe84hW6+eabo7+XsN122+nGG2/UtttuW+u1/vjHP+qggw4q1fq/XNKuko5UvJV/WGuCdmvatzy3dyC39T/lORfJAVBBAgD0RkoSYAXy3flQ4G/VM2qsZ63vSwjcaV8SYNWf0ktglb1G0nclHaBCwxFHR0c1depUvf71r689FKOtdtppJx144IGaO3euHnvssXWC1te97nU6++yz9e1vf7t2K3VTkyZN0j777KOtttpK99xzj5YvX26ut9NOO+naa6/VBz7wgTHew7XtvPPO2muvvTRr1izNnDlzneWbbbaZpkyZoh/96Efaeuuta7/O4YcfrunTpzfZ1aopknZSuFU+1NpffYR6DKyW/lKt/7FgPzRcEkCCsfkSaGDwNOkF6M6HgunJlWc3AJ9sPMce61We1/OUrWcs9z3c5b7XmKzON5V8J/F4JZk0aZLOP/98nXrqqSWrbY1Fixbp3nvv1dy5c7XJJpto++231w477DBm3/ufYsWKFbr++ut155136umnn9ZGG22kN7zhDdptt920yy67jPfurWPOnDm64447NHfuXG244Ybafvvt9a53vUsbb9xsFNstt9yi973vfYX2UtMkfUWdr94d1poAvvu8qjJf9+Fu7yYKKcmFdd+AAs+hxCA1ASBBAComztUA6D+9GArkG9rjBv/VeetRXeYG5L5koEQCYCUDkyVtIOnMxOOV7DOf+YzOOeec0tUCRaxatUo77rijZsyYUaK65yS9RZ0b7avBuC9wzwn4rcRhJPCcGvyn3DAsrdtjUJWSABD8Aw6GAAG9VXookDWWv7p81FgvZyiQuz+xYT4pw3/cXgt3Ha3e78cl/bXnvdVyxx136JlnntEHP/hBTZ7Ml55hYrn44ov1gx/8oFR1R6kz9t/6lh9fWXWsvzs8yDdUqO64f/ceAMkf5FeFAnyG/wA1kQAAvdckCfAtC/UQhNbxBe3WNilDkHISAN8+duc/LeltkrZXQQ888IBmzJihffbZR+uvX+x+Y6CRoaEhHXTQQVqyZEmJ6m6QtEJ263rqGP9qK34oYYglBKHW/a5qsiDPcyjgz0FyABhoEgMmhpxWrtjNdG5Z7Ns4fGNyYy2IuY/Y1wx+Wp2vBr0zerQyXXvttdpzzz21bNmy0lUDtVxyySWaM2dOiaoWSzpeaf+j1s26Of/jKecTN9APreO7GVjOcgut/0AD3AMAjI1SQ4FShudMjkzHbgROuTF4fdnj/K3ylBuMq6/5TUm3qdMbUNTOO++sadOmaYsttihdNZBsaGhI2223XakE4ARJm2vdcf/utDWOP/WmYGv8v5XUh3oIrOdej/1PWQ4MJHoAgLGRchGq2wtgreNr/XeXW70BsdbA3N6A1K8c7NZ7kqQPSnrSf6jquf/++7Xrrrtq3rx5pasGkl155ZWlgv97JF0s/9Cf2P9qynLfkB5r+1BPZGq5Kst9CP6BhugBAMZOTi+Ab7rJtwKFegDcbwWynkPf7JPTMxD7RqDu41pJd0j6/xKOW5btt99ev/jFL7TNNtuUrhqI2nnnnfWf//mfTatZqU4v2f5aO8mutur7Wv5zv/nH6llITfDdhgha/4EJgB4AYOzk9ALkbhtrZXPX8bX0hVoOU1r+fYFBai9A9XUOlLS7pOID93//+9/r3e9+tx5//PHSVQNB9913X4ngX5K+pc6P6OW20qf2CKT2AtYJ5FO3sRD8AwWQAABjqw1DgUoN/8kdKmQNXdhd0qHqtEYW9eSTT+pv//ZvS30HO5DksssuK1HNAklnq9n/m/u/ZiUMvvNHahJQXSZjuvpssc5vAApgCBAwPmL/e70cCpTzA2Hd50lK+5XglGFCqb8QXF32iKQrJG0YOW7ZXv7yl2vatGl6+9vfXrpqYB2vec1r9NRTTzWt5hRJf6HwEJ26P/Dlu/nX6snz9exZw35ivQW+xKCaAKS2/qeuAwwsegCAiamXQ4Gq89aF21oWGxIU+3YQ9+GuPxrZbgd1egOKfGl61fz58/X3f//3uuWWW0pXDazl4YcfLhH8z9WaG39Th+34AvJQ74DV8p8S0MtYNyW4t9QN4gn+gQgSAGB8TJShQKGA37rY5wwxCCUEoeTB99hF0vskzQ8cs1qWLl2qvfbaS9OmTStdNfD/3HjjjSWq+YqkkxUP/kPBfp3heVYiERv2kzL0J3VYUE7rP4AIEgBg/ORcyKxucHc+1hLnloUu1E0CBmuYwKj8wwhyHrurkwjMTjhmWVasWKF9991XV199demqAUnSPffc07SKJZIu07r/w6H/Vd96sTLfa8QaFKR1zycy1osF/aFzXggJApCABACY2HwXM6vF31oeaomrzqe2/Pt6DGKBRerY4ZT6DpX0bkn/FXj/tbz44ov68Ic/rO9973ulqwb00EMPNa3iSkkfl/3/UapV31d3qJfQlxTImLaeq2LnNQAFkAAA46tpy1aoxc1aJ9Ry57vApwQYsYA/1luQcv9AdR8+qk5PwL2RY5ZteHhYRx99tC644ILSVWOALV68WDNnzmxazU8V/p8MtfantP6ntvyHlstYx0oC5CnzrZOCBAFIRAIATHyhXgBrPqX7vVoWas1r0rIYavm3gvzcXoETJe0q6VbP8altdHRUp5xyis466yyNjhJToLnf/e53TT9LqyTdKX+vXSjAz+2xC9UT6130BfyhREDOOhb+EYGC1hvvHQAgKe9Xgq11rWW+Ot1ydxv3kbK972tJrX2z9nGysc4kdS76of34G0nHSnqLpO2NdRq5/fbb9cc//lF77LGH1luP0yXqu/7665veZP5fWvPVn6HAfVRrJ9qjzvRwYDtfT0IsCQj1AkjrBu+hYUHuer5lvnUBJKAHAJgYxnIoUKzF3zckwNdjkNsbEGr9d797fDSwTfdxhqSDJfXk7t3LL79cBxxwgJYvX96L6jEgCoz//638/3+hgN0X1Kc+Us8PXbm9AlUE8cAYWX+8dwBAsm5reKy8O+9eTCcZy9z1qsu7F3uLrxch1JPh9gpYPQ9uXaEejer8ZyV9WNJSSUcG9qGWn/3sZ3rf+96nn/3sZ9piiy1KV48EixYt0owZMzRnzhxJ0qabbqoddthB22yzzTjvWZoCCcBDWvO/mRrYpwT7oaGAoUTACvLd+uSsV32uCjVY0PoP9AC/BAxMLE2GAqUMxcn9leDQrwa7v9br/qqv71d+u8/rJ6zj/jqwb7r7uF3SXpKmJBzHbG9605s0bdo0vfa1r+1F9ahYuXKlrrnmGl155ZWaMWOGnnzySXO9l7/85dprr7100EEHabfddtPkyROvY3t0dFQvfelLm/YivV/S29QJrH2/4NtdFvv1X9+vBru9bSn35ViBf6iH0ZcQuA0R1rQPCQCQiQQAmFhyEgDfdCgJmGw8pyQCoYSgbgLglvnWsZ59CUD3MSrpTP8hrO81r3mNbrvtNm277ba9qH7gLV68WJdccom+8Y1vZP9q7lve8hZ9/vOf1wEHHNCjvatn/vz52nLLLZtW8wpJR2jdYN5NCKzpWNmI/EmAlQxY4/5jSYC0bq+AnOVylhP8Az0y8ZpKgMHW5IIXuoiGWt2si7a09kXeN6QgdVxxLLjwrRP6/YBQ6+QkSacHjlVts2fP1nvf+97s4BRxt956q97whjfotNNOq3V8p0+frgMPPFD77bef5s8v/oPRtRX4rPxJ0keUNubfN+1rsc8Z/tMVGw6kwHNVqIzAHughEgCgnUI30YUunCnd8rGAIBQkhFoN6yQF7no5j03V+YagbqtjMTNnztR73/tePffcc6WrHlgXXHCBdt111yLHdOrUqdpxxx31yCOPFNiz5p5++ummVcyUv5U9JfCPJehWkmAlCCnr+JKA0DnLmk5BkgDURAIATDwlLoIpF2Crez6UIIQSgtzgvGlPgTVt7cdrJP2DpJXhQ5jvscce07HHHlu62oEzMjKiww47TKeccopWrVpVrN45c+bo7/7u7/Tb3/62WJ11FegBeFrh1v/YdJ2W/5TGAkXKYsG+71xHYA/0GAkAMDGlXABTL54p3fMjnnWsgMA3Xye4r1MWSgisgOfNkvaXtMJzvGqbOnWqrrjiitLVDpQvfOELuvLKK3tS97x587Tnnntq7ty5Pak/1Z/+9KemVTyl9NZ/X2u+tU2oVT+UBFSXyZiuiiUCobIQkgSgARIAoN1SL7pWy1yo1S61ZTA18LeCFF9Ab9Wbsq31Ot39fKekD0paEjiWtZx00kkMBarpxhtv1DnnnNPT15gzZ44OOuggDQ8P9/R1QpYuXdq0ijmyA3nf/6svQfD974bqsf7vu6wkIHR+qbLOT9Z6FoJ/oCESAGDiKtEiFqojpYvf1/qXE6yHWuzdstQegdjD2of3rX78OXBMsj3//PO64IILSlY5EGbPnq3DDjtMIyMjPX+tO++8UxdddFHPX8enwI/ILVU8AbA+/76EwArq3bokO9i3kgV3fV/QX63HQmAPjBESAGBia9Ia5mtxi7X+S2nDAXxBREqAXg38mwwLshKI0OvtLWkXSbM9x6yWCy+8UAsXLixZZd/72Mc+pgULFozZ65155pnjNhRoxYrGo89WyA7+rf9BX+t/SmIQqsfqBQi19Kf0AsSWWUgSgAJIAID+EOpmt7rXQxfnEWedugG/uzz3B4ZSehGqw35S6/ywpL+RNN06kHUsXrxY3/nOd0pV1/euueYaTZs2bUxfc+HChfrUpz41pq/ZVSgBCLXYx3oHQstjiX7K+cNaP3ROsqZTEPwDhZAAABNfiYtk6CLutvz7lnXLfa2JKS2QpXoBrO1HnfVDwx2OU6cn4Hb7EOa7/PLLS1XV15YsWaKTTz55XF77X//1X3XHHXeM+esWGAK0XPGAPhT0x/5PrSA+lBjImLaeq9xzibsMwBgiAQDaIeUCGbu4huqIteiFAggr0MhpxQ+Vl7gfwPc4XdJukn4aOC7Jfv/732v69GKdCn3rk5/8pObMmTMurz06OqpPfOITY35DcOEhQJL/fzIlKQj14vkSADnPsSQgNSHwLbOQJAAFkQAA/SWl291dLxT4u70DvsDBanHMDfpDv/qbkkyE1vft15mSDpZU5A7Ra665pkQ1feuXv/ylLr744nHdhwcffFCXXHLJmL5mwSFAkv//LaU1310vpfU/lDTIKasKzecG8wT/QGEkAEB7NL1ouhfplFa8avBfLfe1/uf0AKT0AtT9VqCcZOKfJS2SdJ7vQKa69dZbm1bRt5YuXaqjjjpKo6PjH8t99rOf1fz588fs9XpwE7D1f+hbFmvdjz2qfL0C7vJYWXUZgHFAAgD0n1BLW24SIK3d8hhqFaybBHTXj43xd5OAULJQ53GGpN+ogfvuu08vvPBCkyr61mmnnaZZs2aN925IkhYsWKATTjhhzF6vYAIQa5X3tfSXaP3vPst5Dp07LCnrWOsDKIgEAGiXOhdNaxtfma/l36SAglgAACAASURBVOru97Uu5iYBvpt4Y8F83eW+/T5bDXsBhoaG9OCDDzapoi9dd911JYf+FGm6v/rqq3X11VeXqCqqwD0HbgLgC95jrf3V/2NF6oq15lvLXL4Gh1QE/0CPkAAA7dO05Syltc7XqmcFBqHeACtgyQnY6/w2QEovgG9frlfnR5dq40bgtT311FM66qijSlX3gqT/KemeEpWdcMIJevbZZ0tUFbRq1arGVcgO4lN7BEIt+1ZdoWQgZXn12UJgD4wzEgCgnXKTgFhrnXtxr07HAoiSvQFWMtDtHXADfrfHwHqdnF8OHpX0WUm3eo9mgt///vdNNu8rw8PDOuyww/TnPxf78eXzJX1K0se1JhCubf78+TriiCN6/mvEBXoAup/37v+m9X+WG/jHtq0KlYdYrf9NGzAAFEACAAyOUHd+dXlK639KEpDS6u8G8aGbemPBfejmYN8+WPt3d+xAhjz66KNNNu8rn/nMZ3T77cV+auEJSV9S5+91oqRLS1R6880368tf/nKJqrwK3Pi8kfIDeN9wIEXmQ8lAqC55yuRMpyD4B3qMBABorzotadY2sSTAN+Qg1BqZM/zHV+72AvTyNwGqj0ZjeObNm9dk875xzTXX6LzzGn+xUtUJki7Ums/SZyQV6Vr43Oc+p9tuu61EVaa//Mu/bFrF5soL4KvDe1ISBovvtdzlvvlQnQDGGQkA0G5Nu9ND3fIpPQGh5MAK8K3l3fLQN/zkDB2qe19A99GoCf/5559vsnlfePjhh/WP//iPJb/y8/uSDnXKzlfndxwaGx4e1oc+9CE9/fTTJapbx2abbda0imoCEAvoq+OZRirPKQmDVZ8qz/LUI89yya4jhCQBGAMkAMDg8F2IrSDfWuZbLzT+ODTUJjafG9zX7QVw97nRt8wMegLw7LPPat9999WyZctKVTlH0imyP18XS/ptiRd55plntOeee2rJkiUlqlvL5ptv3rSKreX///L1ylWDfxnbyNkmlPC7065SQTvBPzBGSACA9qtz0WyaBLjBRahVMifIjw0FqtsLkPNYaByfZM8///yE+LGr8bBo0SJ94AMf0MyZM0tWe4ykr8kOgL8r6Xh1/s6NPfTQQzrkkENKfGvPWrbYYoumVbxW9vuXUWb1AFjbVNez6nWX+epx17Wk/EMM5j8NME5IAID+UKd7PXbxTm0l9A0D8g35sRKDXg/xybkp+NOSajcDr1q1qietyBPdihUrtPfee+u3vy3SIN/1fUn7y9/DNCrpo5L+pdQLTps2Tccee2zRbwZ69atf3bSKagLQ5UsEutOh4N9dL3R83W2qr2+x9hHABEMCAAyeWBIQa/1PaYV0k4DcIT91h/OEfvwrlgRU5xcHjl9UweEvrbBs2TLtv//+Jb/xR5Iek3SSwsFp93GWpEdKvfBll12mj3zkI8V6ArbddtumVfz/8ifSbhBvDfGJJfOWWCu/9Rw6t4SQJABjbP3x3gEAxYxKmlRjXWs7a3n3Il2dTjGiTmOD22Og1eW+plbrvUxKWH+Ss+2kwDJ3u+5zo8aRDTbYoMnmrbJgwQLtueeeuueeIr/N1fWipA+pM/QnpSn+G5L+l6Rfq9B17YorrtCKFSt05ZVXaqONNmpU12tf+9qmu/Nm+XsArPmUJN3dzpf4++ovheAfGAf0AAD9pUSrm+/i332OtfpbgYev1dLXExDqHUht4ff1CoR+OKw7v6H/sMUNSgLwyCOP6N3vfnfp4F+SzlBn7L+U/jk7StI/l9yJa6+9VrvssoueeOKJRvW8/vWvb7orW0u6UXaLf3de8h+f2DCf2P9vyrmiTus/wT8wTugBAPpPnZ4AdxurvE5PQLf1v7t9d776LK3pIRiRv5U/1HJvteJXn4c92/h6ChpF8P2aAAwNDWnevHl65plndOONN+rcc8/VihUrSr/MNHW+inW7jG26n8MvSdpF0q6ldub+++/XW9/6Vh111FH6q7/6K2211Vbaeuuttemmm0qSXvrSl2qDDTbQypUrtXTpUknSCy+8oCVLlmjx4sVatGiRFi1apEmTJjW9Ofyv5U/AJX+QL2d9a9vqOinzBPhAy6UGCQDaJed/2w2CrWXWOtbQGt9jsmd+sjHte6znee5Or+cs8827Ze70ZEnnqPPrq7W8853v1Etf+tK1yl72spdp/fXXtLmst9566/xA1CabbKINN1zT+TBp0qR1vkP+JS95iTbeeOO1yjbeeGO95CUvCe7T0NDQWsH6yMiIFi1a9P/mlyxZoqGhIS1ZskRLly7V0NCQFi9erGXLlmnBggV65plnxuLm5ickvUOd4y/lBZDdz+LnJf1GnVbzfvJZSX/QmpvehyWt8syPrJ4ecZb77pNxf2nb7TWwkgtp3YRCRrkPyQEwjkgAgP411knAZKcsJQmIJQLryZ8IuMmAL6BfP2Gd6vSlavAtQKhtqaT/Ieljq+frBIjdz+LVkm5R52/aL/5d0g+1Jsh3g//Yo5oMuEPmqgmA9RwaIiSt22MXGjakyDIAY4B7AID+Vfd+gJxuf3cIQrUsNg7ZvS/ACjZKfAtQ7i8Lbxk4TuiNUUn/KOlY2a3PKfeAVLc7SNKnxvQd9N47FB7akzOWP9aab/Etj80DmIBIAID+1qskwDe2OJQEdJf7gvw6AX+T7Xx1kQCMvS9L+p+y/6YpAa613WvU+aGwfvFKSQ+tnq4T3PuG61isxCAHrf/ABEcCAPS/iZIEjFSWhwK4UMtvyWDf93hN8AihtMsl3aXmf2tr/RMk3TR2b6Xn/lb2/1d3vrpMznpuua8OS6nWf4J/YIIgAQAGw1gkAb7loeQgFPT3Iti36nTLGn9nI5L9TJ2v79xVaX+b3L/r+ZIOlvS7sXpDPfbXq5+tJLrL978X6wXw/e9aQucIH4J/YAIhAQAGR6+TgFDgIfl7AGI9AiOBdUNBofvNJtXy0Hb/PXJsUMYdkg6RdJ5626PzeUl7qvMNQ233Ctmt+6GHPNO+9arPLoJ4oE+QAACDpXQS4M77AosR5zk14PctT20JTrnp191uq9iBQWP3S9pL0tnqbfDffUxR5x6DR8bizfXQMqUH9zKWS/FzQKg3IXW7nGUAxgEJADB4SiYBscDClwT4AhW3tT/W4h9KCEK9BKHt+vNXvCaOmyS9R53vtY8F7qkJXMp6n5D0d+r8RkBbzVBaC79bJmdZDnoDgD5EAgAMpl4nAanBSWhYj68XILU3IPfRre+ppKOCOn6oTsv/WYonZqnBv7W+77NxmjrJx929fqM9MKrOPROpw3zkTMeGAKX2EuT0JqSuA2CM9dOPpADIV+rHwkLLU1/D+sGx1PqtXyV264yVdx8zJe2fuM9Id56kqZI+qjVBumQng1YCmPqQUbcq8++WdLSk10r6q7JvsadukDRL6cmvezzdXjFfQiCte9xyEPADLcAvAQODLfccEPvF4NivBdf5lWD314F9vxLs+4Vf91eAY78S/BNJsyVtkXNg4LVY0nGS3rp6PhRo1h22Evv1auuzOUnSHyVdIGnjhNcYTwskvV3Sm7XmF4BHjOnqs/vrv6GelVDyNbJ6H7rPob+dVO/vB2CM0QMAoGQSEFoee51QXVbSEHpNNwCc7CwL9QS8WdKt6gxVQTO3SNpD0u6Kt9xbQ79SegQUmFZg3VFJm0k6RtIbJW1X6k0XtkCdbzF6ndY+DtVvufIdL1/Lf0qrv9Wb4pOyDoAJhAQAgFQ/CagzXCf03J32DfEJBf+pQ39S1huVNE9rvncdeWaqM8xmRNJOsoN833TToUBKnO/aUdIzkv5FnWFBry5xAAoYlfTvkvZTJzmxWu19X3VrHV8rsZL8x8VXDqAPkAAA6BqPJGDUs31K3VYg724b6gmw1q3O/x9Jf1JnzDjnyrhhSb9S50bbj0v6kMIt+ymBaqzlOtYDYAWxvmVbq5O4fHb1e9l89WMsDEl6UtLDkm6X9H11vrXoWUmv1NrDd3zHLzb+X/Ifxy6rzJKyDoAJjHsAAFSVTAJCAXroEbofwLoXwHdfgHUvgDXmPzZ/l6QTJX1Q/EiYJD2nTu/Ic+q0nD8h6derH5/UmsCwOmbcDSzrjieP8fUeKVLuu1fgckk7q5McvFqd34jYRp17Bl6y+nmypE0lLazs71JJK1eXLVn9WKzOcZunznHrHsO5kg7SumP2Q2P7rUfK+P/QtyWlJFt1/l4kCsAERAIAwJVzXgiN8Q8N9wmN7Q8lAe6jusy68TflpuDQvLvN5ZL+YvVjI+f9uq3FG61er+ovtXZvwvqSXuass4mkDSvz3QCzqhuA1jUiaZFTtlidluil6gSsQ6ufl0paoTWB/9mr1/cF9HWH58h4dqd9rM9eytCwrupXYsd6n6p878sNpmNfX1oN2H1Bfrc8lAjk3vxr9byQAAADgAQAgGUsk4DJxrOVCKT0Arg9ACk9ATkJgFV/LDHxJTXWfQ2hFmzruKYIBWWhIM4XmOe08FvrWOXutDUfE/qsdZ9DQ8F8w8hCchKAaiLga5W3gvlYS38s+I/9noKvF0Dy//1y/lYkAMAEtP547wCACWlU6UFm9wI/yZm26hk11uuWjagTmPlaf6vb+h7dgGWSM+0GetVl1fLqcmubULJTNTny+k0TgNjrd4WCMndZqFU3FLiHgsVQchDqBbD2LSQUvPv+/lZC4K5v1W/tT+jzaCUAsV4Ba1vreFvHzXeDr7Wf1ntx32NuIgagBUgAAPjkJAGh9X1JgRtUWGVu8FJNEnyBvy8Qm+RMd9d3A8LYcBHf/rnlkyvPsQTAfR3fsyV0zEP7HEq0YoF5KNivLvc9xwLR1CTA1zuSkgRUp92EILUHwN3X1AQgNgwntH318+6uV90PKxHwHfOcYJ9kAOgDJAAAQuomAdZ2obqqQUV1+1GnrLvuiNY2uVI+yVkeC7Ct4Nxdp8oXcFcf1eDffU/WUKfQ/oVeN4cvoM4N/mNBv5uUxZIF37RvHy2+4xX628amffVWhRIkN5C3kgB3ubUs5SGFj2EooUoN6H3rkRAALUQCACCmRBLgllkBfpc1FKhb5tZlPdzeALf1v/p67lAd61me+ZBq8F8N/K1jkJME5O5H9/V9ZU2Cf3c+9yH5hwNZ++ZjHZ+cQD8l8UtJXEPvNTS8J1Ye6gUY9ZRV9ydFzrru+wbQQiQAAFLUTQKsMl8S0GW1/LvPoaFAvsB/klNnNSGQ1iQDw8b7aRL8+4YASWv3EOQkAe6+5bbiWsF1SvAvpY31z0kCrITAt68hucN+Uo95k2FAvlZ+K8gP9RBY9VkBv/v6Kcfe915yjz+AFiEBAJAqNwmwtonV4Qb7bm+AmxhYQbwbIHXX992U29V9LesegWFj/e7+uA83+K8+u8Fn9XikDEWx9iHGCvar025ZKCEoGfzHEo2cADTUap8S8LvL3XXcOmOBs/XIuQE4lCBYxy60LDX4J9AHBggJAIAcOUmAG8xXy+Us85VZy6S1EwN3OJB7f4Ab/LsBfpfvvgGp85Wf3SQgFCi5iYAb/Ftfedp9ndQW6NzeCF9ZKBnwBZBWWS8SgNzgNBS4h77yM9YDY8273GMWukE3JQGwgn3fjcKhewXc/wPfPlufA998rD4ALUECACBXThKQUo8b6IfWV+W1q0GONRzIFxS5CUFXdeiP9f7Wkz08qLsv3WC/WuYG/yPOc/VYpgz9qXvcfYGdG/zFAvLSPQBWndb+pLIC+RFPeWiZOy35j721z77PnpsAjCr+Pf2++wPcQD81WbP2PzXwJ9AH+gQJAIA6UpMAN8hP3d5KDqoBo9uqGwu8VFnf6g1wA0EfNwlwA//q61b339ezEWqVlrO8hJTAv1oWSgjGKglw99flG/6TOtwnNDzImreEjpU1rKc6nRr8++pxX6O6DynH3n0fvvcXmwbQIiQAAOpqmgRYy7oBRepQoG7wE/ptAFW28w3zccf+S/73ZgX71UTAnZ4k/z0AoRZrax9S9q+7bynzoedYMpDz6B73WA9CbP8svgSg+5wa/Id6Ytx6q3z77b7/nCTAXWbVUz2uVt0+ob+x7/0A6DMkAACaSE0CrPVj27qBv5x5NzlwA37rhuBJxryVFIT2KbTMTQJSgn9r2E9uC3RMqAU3FMC6075APhTYu9tYdYRe09rvKitIjyUA7nyTIUDuvoWSIHc6tRfA1xPg1lvluzE4FcE/0MdIAAA0lZIE+NbxBfNWma8XoNsD0GQ4ULUsNF/db/dRTS6qQX91/1KSgFjQmZtwhcqslvbQtJUMxIJ961hZ21vTvvdgCSVOuUN/fIlZSChpsgL23F6AUM/AqLPM93dz/wZNjjeAFiMBAFBCNTAPreMbCuRbxwr43aC6u53vhmA3yK/WYZVb+xQLirpBf3VfJlf2sft+coP/XiYAVqt1yrQbwKcG/XWSAGs/u3yJkq9HJSUhkNa9ByOlF8B3LKvvLzQMKJYIuMlD6JgqUu7uY8pnO2U9AC1CAgCgpFBw71vuSx58CUO13L0h2Ar6U+4PSOULvNz9mezM5wT/VuCfOhTF2l9ruvperHlfy3DphxS/Adjad1coGcgd+mMlGSm9ALEgPJYE+AL/WFkoYfAlAu779P2trfdoTQNoGRIAAKXFkoDYOlbg7ws2rKFA1e1THrFhQL59rD6sJGSS1v7Kz1gCIKfMLW/Kasm1Ar/uszUdu4m3bhJgBam+INQndCxzegNGZP8dUj7T7nMsCQgF87FlqfVb++Lb5zpIBIAWIgEAMF6sAD/WC2A9V7erBkEpQ4GkNUF6V0qg537ff/U1rfH/3fnc1v9eDgGqTscCfyuATEkGQjf+hhKAnCDVSpJixze1N8BXt8vdN9/x8gXqpYJ/3+v7EqvQNIA+RgIAoBeqwX2dZVaAH1tWraP77A4FCiUB3fVjvQduYjHJKO+2/lf3Nyf4D7X8pyYAoeC/aRIghe8BsJ5jgasVgOYGoqGW+9Cv/uYkY6HPrvtsJUtu4O97zukVcBOJWPLmJgXW+7DeE4A+QQIAYKIIJQbV5dVgxJ2X1g5aqsF8tTU+lAgMa+0W/mq91STA3Qc3QQkN/VFkuvocmvbxHRN3PvTsK8t5SPFEwFrm2++QWA+AO7Qnp/W/Tq9L6LiFkoBQkJ/SE2C9ppxy336mvK+UdXPXAzDGSAAAjKdQ0O8G/KFeADnrdLevBv3dMnc69R4AtwehGvR3X8P98S9fEiBj2i1z9yUnCK3yBW5W4Bc6TlJ6a35ub4Dvtd3983GPTezYpvYGuOv4XtNKsFITp5RegFGtG/j7koDU5EDGs6/MhwAfaCkSAAC9khLcW+WKbNddHgr+q+u6iYA1nTJUqBrgVxMQ98bjaotzNQmQ1g08U4LNuoG/tf/WfCjw9gX+VlnKw7dd7PVThI6lO53aG2A9u9PV/a0+d6dDSZEbuPuG87jTVvLQnZbWPbbuvviCfOt4E+QDfYgEAMB4cYPm0DIrMfCt0+W2/ncDLyv4jyUB1UA/9I0/1WlrOFA18LRa+3PGnOfwtaj7AkJf63V1Wd0EoEmrtMWXMMUSgcmJ61nzMbHj6Ab+VkDvSwR8ZfLMpxznWJBPEgD0GRIAABONmxBY81J8KFB33WoLfHdeWjf4930bkBX8u0G/+00/7tAfd79VqUtaN/h098F6byG+gC3U8lsts4Y6hQL4UDIQWu6+ttVqHXo/Xb5jFwrufb0Abn0pQ7JCCZYvcbJ6Aqzx/bGeASuRsOatv7k7n3K8SQaAPkACAKCX3ODdt9xar07gL6O8GpC7QVi1VV6yA7zqUJ/qfrk9AdVW/WpC4L7OJNn7aD27y33zVbFAr1rmC7Z9QWzOjbx1kwDffoWEegGspCCWHLh1WK8RE0pw3EcoePfdI5Aa7IcSNysxsN5D7jIAExwJAIBeiyUBKdu4Ab+bBKQOBar2BlSTgOp63fJq4Nctd4N/K/CvJhxSeOhP6rCfusOAfEGaLwFwy61AMTaGv07w776Gbx99Ygmc+5w77dbtzoeOc+z4xBKC2I2/seXS2sff2l/r7xF7bwBajAQAwHhzA3hrmW87Odv6ghU38HeTgGqQ72v5d4N3NxlwhwF1p0eMOtxpa17G8lyhlvRYEuDO+4JZKa9nwNq+SfBflTsUKLY8ZfiPK3ZcU+6dSB3a40776rX2yz3mvvcRK8vZHsAEQQIAYKJyg383yE8J/KvrhZIAt5VeznQ12J/kzLsJjPWo7rNbd0rrdQm+ZCAWsMYSgFCZ1fKcEvznBJ+h4T/VspyW/lhiZr2utY85x7AaxPsC+1Bi4G4XeoT2KfR+YuUAWoIEAMBE4gb9KWVuAF4NTtwW+JTp7nwsaOz+aJg7BEiyew1CQ1IUKJOxPEWsdTcU7PkC9Ni0r8xd3zckJbZfIVYi0GQYkFtnblJmJTbVcut4+L4iNJQguAmB9RrWcY7te0pZzvYAJpCSrUsA4JNyromNtU4J4qplbjA+2VnmbjPZmVag3PcLv9brpwabvjJ3eapYAuCuE2uJTwn+3fk6SUTqe7CUSgLcumJ/F5+cHgBp3QC/Op2SELh1VJ9T/i4yyn3vxYfgH2gBegAATDRui79vvhtouK3+3TLrKz6tm3S73G8KmlQprw7lqe6LlWTIWDelByA0zKRuY00soM5NBHytybFW/pTgP7RPOarHzP17hYb3xIb++KYtKe8rJTD3Bfvd51hPQiy5atrSD6Cl6AEAMJZi55zcXoDutK/VPdQjYA3ZibXspz7c/fIlALH3GWIlPjFWQFqdjyUCbnlKr0Bua3PJBKBaZgXwsb9J7PMYEnpPvmPlzqc+pHot/rmt/+60hQQCaAESAABjrUkSEAvirKA7lgSUCvhzhvykBJk5ZVI48Iq19IamU3sDUoNMX5n1XFdqb0udZCFXyntNSQCk3vzgWkrylfp3IfgHWoIEAMBYS00AfNO+sth0SvBvrRfaziqvloX2x/ee3GWl5CYBJRKBnG3dbZpIDexTAv9QnTEpQXYoUM8dJmRtE5sO7atvHR8SAKAlSAAAjLW6CUB1vmkSYAX+uS38Vou+lQxY+5P63kKq6+UEXqGALmVISGoLfmqLf93WZp/Q8Q0lXyV6Z6pi7yunB8U6hnVb+1OTPmt5DAkA0BIkAADGWsp5JycJCAXaoWlfcO/OxxIFq/5Qme99yFjmW54qFNBZ5bEW4VDrcWqLf+jZV5Yj9Vin9BL41stVJ7GyAv3QsqbBv1vmWyeEBABoCRIAAGOtTgt3amuuu6xOr0B1Onf90L41bfnPPV/nDNewAr/UYDHWqp+bAFjzuXJ7jqplY5kAuM+xXpMS07H9CK0TQwIAtAQJAIDxULoXwJr2tcjnJAd1gv6UwLKXrf4hsaAuZThOKMCvO5+zjyEpn5nqfGqZtX2O1ATA95w77ds+tg+hdWII/oEWIQEAMB5K9QKE1rGC7tzgPzasJ9TqnzLcp1et/11NewF865QK+H0BaGh/XLFAv1oWWrfp3ygktzelO53aq5KyXuy1U/Y3hAQAaBESAADjoUQCUC1rmgSElsUSgdT52HvwLe+F1J4AKwiPlTVpcQ4FkakJgLWszmcnpY4cqa3tqfNjkXgR/AN9igQAwHjJOf807QmIPecmBan1xvYztL+9kNqq3iQBcLdL3cbaro6UoT855b6yOur0sjR99r1uyeNPAgC0DAkAgPHUy54Aa7uc3gH3OaWFP2VffO85ZShLXSlDbNzy1J6BUklC6v7FxI5vbit/qeQsdlxTelbc7WOt/tb6oX2wplOQAAAtQwIAYDyV6gWoluUmAaFn3zaxgL/OOPKxbHn2lbnLUrYLBZ25wWfpQLJJYN+rXpncYVBNkoOU+mPbxRD8Ay1EAgBgvI1HEpBalrtO6j64UnsF6qqbAFhlOcvqjvnP2d/cY1eqtT/1b5PyHusmULHjG1svZdsYEgCghUgAAIy33PNQSpBWt1W+SVl3fqwCy1wpgVpKS3CdoL5Xrc8xKUlY6t+mTg9NaiJTp+U+NtwnVm9s/RQE/0BLkQAAGG91zkN1k4DQfN1x/ClJhrXMlXocUtaLBWY5y+sE7U2H+zQNLFMD+RLr5UoJuuv2qKSsm7MfIQT/QIuRAACYCHqdBFjr5A7dyRnX38tW5bpyhtv4lpdOBqzykoFlL4P9nL9T6jGu2+viK6sz/CgFwT/QciQAACaKpkmAr46cmz2r8zn3GMSW5bz+WGpyb0Dd4LJu8Jl7D0DKurm9Mk3+VjnHOvc4p26Ts14ICQDQciQAACaSXiUBbnlqIN60lb/UMJI625YK7nqdEKS+Vl1jFeS72zVJbnJ7a5omDDkI/oE+QAIAYCIpFXw1Ge5R59tjctZJea2661Xl3vSbs16JFubxDCRTjmcvemtye1xCy5skVgT/wIAjAQAw0ZRsKW867rtEy35u3eOhTmDqrlOidX88egBy16uzXYmhTtbysQr8m24LYIKZSBcgAOgaqyTAXZ47fr9OPal19MJYDAtKXafuTck5SiRfvfyb9KIHxre86XElAQD6CAkAgImq6fmp1zd81h1GkrN9r5Uak183IRiPoHKi98g0OU69OsYE/0CfmSgnPACwlE4CUussuV2qsTgfj1Ur8Fi07pc0XolaqeNE4A8gCwkAgImu1HmqyU2dJW4IbcP5tvTNoeP9HfO9SCBLmwjHvJf1AJiA2nBBAoCS56peBvP9fE7txTf6TKQhQBPdWH6jEsE/0OfWG+8dAIBEpQO38Rr2MVED0LH4lp6x/iagiXKzbx3j8ZsJvawXwAQy0U54ABDTi/NWiRt+m77GRFAy+Buvr/1MMVGGb5X8etAmCPqBAdOGCxIAuCZCCz3nz46S3xQ03ibK33Qsj1Ub/i4ACpsoJzsAqGOszmFtHkpSQolvpWm70n/X8T5G4/36AMZRP16oAAye8TiXtXlY0FgP9cHEwd8LAAAAAAAAAAAAAAAAAAAAAAAAR2QHGQAAAnlJREFUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAM/xehhuzADKpVqQAAAABJRU5ErkJggg=="/> + </defs> + <use xlink:href="#source-27"/> +</svg> \ No newline at end of file diff --git a/src/assets/cursors/macos/pointer-1__34-24.svg b/src/assets/cursors/macos/pointer-1__34-24.svg new file mode 100644 index 000000000..fd4f6cb49 --- /dev/null +++ b/src/assets/cursors/macos/pointer-1__34-24.svg @@ -0,0 +1,7 @@ +<?xml version="1.0" encoding="UTF-8" standalone="yes"?> +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="767.314286" height="746" viewBox="0 0 767.314286 746"> + <defs> + <image id="source-26" x="0" y="0" width="768" height="746" xlink:href="data:image/png;base64,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6IMf/KA+/elPD3q345K+RAgAAKQgAABAyT7wgQ8QAggBANBYBAAAqEDNIeC1IgQAADwIAABQkRpDwDWEAACADwEAACr0gQ98QGefffagd0sIAAB4EQAAoGJnnnlmXSFgmbX2FSIEAAAyCAAAMAA1hYDpkm4gBAAAsggAADAgZ555ps4555xB77YTAl4uQgAAQAQAABio97///XWFgOWEAACARAAAgIEjBHQ9JwQAwAARAACgBmeeeaYuvPDCQe+WEAAAIAAAQB2stTrttNN00UUXDXrXnBMAACOOAAAANbHW6l3velcdIWCGJkPAriIEAMDIIQAAQI1qDgHLCQEAMHoIAABQs04IuPjiiwe9a0IAAIwgAgAANIC1Vn/7t39bZwj4cxECAGAkEAAAoCFqDgErCAEAMBoIAADQIISArvWudTGEAAAIIAAAQMN0QsAll1wy6F0TAgBgBBAAAKCBrLU69dRTCQGEAAAoHQEAABqqASHgZSIEAMDQIQAAQIN1QsCll1466F3PkPQVQgAADB8CAAA0nLVWixYtIgQQAgCgFAQAAGgBQkDXete6GEIAAIgAAACtQQjoWu9aF0MIADDyCAAA0CKdELB48eJB77oTAl4qQgAAtBoBAABaxlqrU045pa4QcCMhAADajQAAAC1UcwhgJAAAWowAAAAtVWMImKnJEPASEQIAoHUIAADQYjWHgBsJAQDQPgQAAGi5TghYsmTJoHdNCACAFiIAAMAQsNbq5JNPJgQQAgAgigAAAEOi5hCwwlr7AhECAKDxCAAAMERqDAGzJN1KCACA5iMAAMCQ6YSAyy67bNC7JgQAQAsQAABgCFlrddJJJxECCAEA0IUAAABDqgEhYCcRAgCgcQgAADDEOiFg6dKlg941IQAAGooAAABDzlqrhQsX1hECNhYhAAAahwAAACOgASHgT0UIAIBGIAAAwIjoTAf64he/OOhdbyxpJSEAAJqBAAAAI2T9+vU67rjjCAGEAAAjjAAAACOGENC13rUuhhAAoLUIAAAwgggBXetd62IIAQBaiQAAACOqASFgRxECAGDgCAAAMMI6IWDZsmWD3vXGkm4jBADA4BEAAGDEEQK61rvWxRACALQGAQAAoImJCUJA93NCAIChRAAAAEh6JgR86UtfGvSuCQEAMEAEAADA0yYmJnTsscfWGQJ2ECEAACpFAAAAbIAQ0LXetS6GEACgsQgAAIAuNYaATUQIAIBKEQAAAE6dEHDNNdcMeteEAACoEAEAAOA1MTGhY445hhBACAAwRAgAAICgBoSA54sQAAClIQAAAKJqDgFfIwQAQHkIAACAJISArvWudTGEAAC1IwAAAJJ1QsC111476F13QsCfiBAAAH0hAAAACpmYmNDRRx9dVwi4nRAAAP0hAAAACiMEdK13rYshBACoBQEAANCTmkPASmvt1iIEAEBhBAAAQM86IeC6664b9K6fJekbhAAAKI4AAADoy8TEhObOnUsIIAQAaAkCAACgbzWHgK9ba58rQgAAJCEAAABK0QkB119//aB3vbkmRwIIAQCQgAAAACjNxMSEjjrqKEIAIQBAgxEAAAClIgR0rXetiyEEAKgMAQAAUDpCQNd617oYQgCAShAAAACV6ISAL3/5y4PedScEPEeEAADoQgAAAFRmYmJCRx55ZF0h4E5CAAB0IwAAACpFCOha71oXQwgAUBoCAACgcp1LhH71q18d9K4JAQCQQwAAAAzEU089pcMOO4wQQAgAUDMCAABgYDoh4Oabbx70rjsnBj9bhAAAI44AAAAYqKeeekpz5sypIwRsocmRAEIAgJFGAAAADBwhoGu9a10MIQBATwgAAIBaEAK61rvWxRACABRGAAAA1IYQ0LXetS6GEACgEAIAAKBWnRODb7nllkHvuhMCthIhAMAIIQAAAGq3du1azZkzp64QsIoQAGCUEAAAAI1ACOha71oXQwgAEEUAAAA0BiGga71rXQwhAEAQAQAA0CidEHDrrbcOetecEwBgJBAAAACNs3btWs2ePbuOELClJkPAliIEABhSBAAAQCPVHAJWEQIADCsCAACgsQgBXetd62IIAQA2QAAAADQaIaBrvWtdDCEAwNMIAACAxuuEgJUrVw5611tKusNau6kIAQCGBAEAANAKa9eu1aGHHlpHCHiupLustZuIEABgCBAAAACtUXMIuJsQAGAYEAAAAK1CCOha71oXQwgARhgBAADQOoSArvWudTGEAGBEEQAAAK1ECOha71oXQwgARhABAADQWp0QcNtttw1614QAAK1FAAAAtNratWt1yCGH1BkCNhYhAECLEAAAAK3XgJEAQgCA1iAAAACGwpo1a3TooYfqa1/72qB3vbUIAQBahAAAABgaa9as0SGHHEIIIAQA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"/> + </defs> + <use xlink:href="#source-26"/> +</svg> \ No newline at end of file diff --git a/src/assets/cursors/macos/pointinghand-1__39-26.svg b/src/assets/cursors/macos/pointinghand-1__39-26.svg new file mode 100644 index 000000000..11ed47b77 --- /dev/null +++ b/src/assets/cursors/macos/pointinghand-1__39-26.svg @@ -0,0 +1,7 @@ +<?xml version="1.0" encoding="UTF-8" standalone="yes"?> +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="767.314286" height="746" viewBox="0 0 767.314286 746"> + <defs> + <image id="source-27" x="0" y="0" width="768" height="746" xlink:href="data:image/png;base64,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u0z4TZv8m9xXdKJwqAnGQMMj0wbqT1prWnI52RIX32ZrPUybDByfD+AyByA+Mdg0/r+ybYCkC/O9DRv07NlTb775pjbZZJOiRVTF4sWLvSfhZrn88st16qmnlvoaaW699VYdeOCBamtr67I6xE2bNk1nnXWWDjnkEA0YMMC7vBUrVujSSy/V9773PS1d6tVxZfKOpPGS+ig7CTDNDUgG5rbE27aetk0p27P2uRzvep4rkgAgBUOAgMZTZmJva/k3je3PCizi53g14b/33nu69957fYqoilGjRmn48OGllb/33nt3afAvSfvuu2/oOQGFbLPNNrruuut0//3368QTTwwS/EvSpptuqi9+8Yt66qmnNHPmzCBlxmwi6dNKHwbk+kibN2BbV8o212FCMuxLbksuK3GMy7YsNHACKUgAgPpSxpda2pCerNZAW2udy9yB+LYFkpYXq37k73//u8/pVVPmMKDvf//7pZWdx9e+9jX17t27S167W7du+vrXv64FCxboYx/7WGmvM2TIEF1xxRW65pprNHjw4JBFnyFpsfwC/7SJwGnHhEwG4rISgSTfCcEAMpAAAIhL6+p3ad3PE/zHzz1ZktelfG655Raf06umrARgzz331NSpU0spO68xY8bohBNOqPrrjhgxQrfddpu+/e1vq1evXlV5zRkzZuj+++/XdtttF6rIYZIOUufL7+Zt8U9LDky9Az49A6ZEIJkgSPbEIK03wGUysQ1JBGDBJGCg9tTyl5bLGOI8x8YTgjskFW6ynT9/vt58800NGjSoaBFVUVYC0NVDf5LOOOMMXXHFFVV7vYkTJ+rGG2/UuHHjqvaaFRMmTNDdd9+tj3/846EuSXuspG9qw4TbdtmT7IoWw763FE2y30nSUEkDY48WSa9LWqLoKlyvSbpf0htKn5RrmkCcrINixym2niwreZ6pzGS5tmNs8h4PNIVaDjSAZuL7v2hrZXOZBJxssTO1ApomAcfvBOxyM7Csm4M9rigAKeyaa67RjBkzfIoo3aOPPlrK/QAWL16skSNHBi/Xxy677KL58+eX/jpjxozRPffco7Fjx5b+WmlWrVqlgw8+OMRVqd6RNELSWG0c1Fcm/Maf4wH365IOlnSIorspf0DR/1YeixVdlesuRXcpXp54DcWWs9ZdzpFh3WUicN6gniQAiGEIENA1srq+fcr1FfKL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1e1FAjAm6VWSzpH0HhUckZw3b5523HFHvepVryrV5FB4/etfr+XLl2vhwrT7rGfPnq3LL79cO+ywQ4+PbEM77LCDbrvtNv3sZz9L3me//fbTpz71qR4elW277bbTE088oX/7t39L3uess87Snnvu2ej1nn76ac2ZM0dPP/10o/0NfyVpd20cyLvr9XLrYQX7oWlCvtGBpr3/brlEIA80RgIA9FZKEpDSE5/aVr1eqOc/lETEEoDUJCC0X33bNpKuktQsYjJMTU3p2muv1dvf/va+3dw6KPbYYw/NmjVLt9xyS/Cz/V/60pfqyiuv1Jw5c/p4dF3j4+N697vfrR/+8Id6+OGHo/X33XdfffOb32z9CUJN7bHHHlq3bp1uvfXW4I3mW2yxhc4991x96EMfavxaxxxzTMkbf8+R9Bvq7T09vsC/VO+/POtWOYkBkIgEAOi9nCQgdypQbKQgtK0fU4HcOtbPMSbphep+WdhfqJC1a9fqsssu05ve9KbGc+OH0djYmHbddVftvffempiY0KOPPqo1a9b8evtrXvMaHX300brooov0B3/wB9N2nJtvvrkOPPBAvfSlL9W9995r9ni/6lWv0pe//GV9+tOf7tvHlFrGxsa022676Z3vfKeWL1+un/3sZxt87OxWW22lQw45RJdccol22223xq9z55136vDDDy9xyJL0gKR9Jf2ZwnP5Q4G9OyoQm/7jmwrUtPc/5TkXyQGg9B5FAM31YipQLPAeN9bdh1s+K/DsLlvr1sO3j9XuuKSt1f2o0GI222wzXXTRRXrPe95Tstmh0el0tGTJEj3zzDN6yUteohe/+MXTfUgbWbdunRYuXKgf/ehHWrVqlV70ohdp++231/bbb9/6m4N74dlnn9WiRYu0cuVKveQlL9GrX/1qzZrVrj+t0+lol1120S233FLiENeqG/i/UxtO8XGX62Xras+TzrJVzy33PackCLGRgpTpQe42HxIAQCQAQL/0YipQKPiXNgzyQ4mAmwBYCUFuAjAuaXZgH18CcIakKyX9z4TzlWzWrFk677zz9MEPfrBks0Axl156qQ444IBSzR2v7o319fn7vgSgWq8nAG6ZmzBYbblBfywBsO4RqOas1bepVh4aBUgdGSABAMQUIKCf+jUVqCN/wlDfzzcNyH390HSecaM89HrWer1sZ0kflbSLpN9TIZ1OR/PmzdOmm26qnXfeuVSzQBFr167VXnvtpRUrVpRo7i5JH5D05wpP+7Gm+DSZ9+8G8jm9+TLK2sz9jyH4B9YjAQD6q2kS0Gauv9t2avDvC9x9rxkK7FMTgDF1A5dj1Z2+0OxzFD3mz5+vFStWaPfdd9fYGAOgGAwXXnihLrnkkhJNrVP34z7fJ3+g3ybw9wX/vmk9sSSgqiPnOTS9J4QAH0hEAgD0Vy+mAoXaqY8GxEYXrADf3S8lAZDiIwO+BKDyZ+qOBOwjaSvrh2vqjjvu0COPPKI999xzIOeXY7SsXbtW++23X6mP/fyCuiNnvk/9aRv4u4mEb9l3c3CT3v+UMgCZuPoB/dWmFyt24Qt9Wkbuw9fbV/LhC0aq13y/ul9g9GTgZ27k61//ug444AA999xzpZsGsvzjP/6jHnnkkRJN/UTSKcrrvc/t8Y9N9amXSxu/Z/Wi95+5/0ADjIED0yOl9z60nDqvv37zr7se+2Sg+g26Y3r+pt76Nt+NvinlvhuB3ZuRr5P0fXU/KrSoOXPm6KqrrtIWW2xRumkgyetf/3o98MADbZvpSHq7pB21YXIduvnXd1Nv7CbgKWM5ltRbU4RSpwlJGycI7s8uo9w6RwDWYwQAGEwpN7751q0LqDv87humt3roYvN8Y737uZ8M4r7OOyS9V1Lx7vrvfve7mjNnjlauXFm6aSDq+9//fongX5IuUnfanO//1fe/HhsZSO39t94/UgP5lLo+BP9AQyQAwPTImQpk1Q3Ni207FSgUNDSZStBR/txjN5D575IO9ZyLVhYsWKC3vvWtevLJ4jONgKBzzjmnRDNrJH1CaYm5tT11P1/g73vvCL2vyFlWrUxGua8+gIaYAgRMr0GcCmSVV1N1xhT/TP/Q5//nfkGYO+XoGUmfjZyzRt74xjfq+uuv1zbbFP3gIcC0YsUKbb311iXuQzlJG4+w5Xzpl1XH93n/9X2sET3rm4NDSUMoYVDg2VdmIWkADIwAAIMtdSqQtT00Fcit55sGZA3xx3oUQ1N+UgMG34jBFpI+lvDzZ7vnnnu0yy67aPHixaWbBjYyf/78EsH/LyV9UekjeKGpfbGefl8g73vvkOz3kSZTfVK2A8hAAgBMr1JTgaxh9dBzbKg+FATkTBUIBfNVW7nTg35D3c85X+c/Zc0sWrRIO++8sxYtWlS6aWAD1113XYlmPi/pfylvyk7KIyXoT5lKWO9w8CUF1ja3zJWaNADwIAEApl/ORSw0BB67iPp652IX85zgPCWw8AX8k4FtblDySkn7SppIO23pFi9erF122UX33ntv6aaBX7v++uvbNvGEpHPl/z9uEviHkgDfttB7hwJl7raQpoE+CQLgQQIADAffhSzlwhm6KNfr+S7ovl7AWGAx6dRxA/xYwB8LPP5Y0u6SVkTOQbZly5Zp11131W233Va6aUC//OUvS0w1+5qkY+T/f2wS+IdG+6zkIpYE1LfJWK6j9x/oIxIAYDCkXMxyLopWr5rV6+a7QOcEBaGgIzXAb/LxoFOS3ippV0lLPeessaeeekq777675s+fX7ppjLgf/ehHJZr5quwgPzXwD/0fx9qLjRJaHQy+EYFY0O+2k4oEAQggAQCGU+iiGBt695XFhvNTev1TA/yUaUCp++wt6c8l/SzpzGVYtWqV9txzT1177bWlm8YIK5AAPChpP6WPyoXqhHr0fb3/oZ5/GfVjwX6oU8NCcA+0RAIADI6cUQCrPHbBtC7U1ghASg9fTk9jLBGYjLTZMfZz6/+1pJ0k/UfgHDQyMTGhvffeWxdccEHppjGi7r777rZN3Kp44B/roU/9nw0lAr4OgtSOB3nKZKzT+w8URAIADJa2F7mUi2s9+K/XCQX9vp7GJqMCvoDf6v2vylJe43B1pwPdGT5t+SYnJzV37lydcsoppZvGCCowAvAj5fXyh5LrUDspSYBvNECeZbeOJbSd4B4ogAQAGD6pF8aUJCClly4UCMR6FEM9/KHpPqkjCO7jGHVvDC5+926n09Gpp56qD3zgA1q3rvgnkGJErFmzRv/5n//Ztpm7tXEintuz7xsxCAX1vm0y6sZ6/RXZVhfa5qsLIGD2dB8AgI10FP+G4KqOW7e6+Pn2910cp7Rhh0A1OjAu/wV+Shsaq5WNOWW+4xnTxt9mLM96rLzyvyW9XdI1knaL1M329a9/XStWrNDFF1+szTffvHTzCHj22Wf10EMP6f7779e9996r1atXa4stttA222yjP/zDP9QOO+ygrbbaaroPM+i+++7T5ORkmyamJN2n7pS30AhcSk++lTSE2ouNFuYmAb7AniAe6DESAGD4WQmDmyDUEwNrm1unasNNBHzBf32/+rL1HAv4xzzbchKCj0naU9IVkt7l2a+xq6++Wo8//riuueaagQ84h93U1JTmzZunL33pS7r11luD3567ySabaLfddtNBBx2k/fffX7Nmzerjkab58Y9/3LaJn6g73c0aWUsN6lPrx0YEcoL7plN/6P0HeoApQMBganOxazMVqH7hr2+LzR1OnYIQmsLjzvv3laU+TpC0j6RLPeeplZtuukl77LGHnn766V40P/LWrVun888/X9ttt53e/e53a8GCBcHgX5Kee+45XXfddTrooIO03Xbb6eqrr+7T0aZ75JFH2jZRzf/3BfG5Pf7WFKAmvfzW+0aoh7/01B8AGUgAgMGVkwSELrBWvZSePF8wYAUCsSkE7heAtQ3urfas1/6EpIMl9eQjfBYuXKg5c+Zo1apVvWh+ZC1btkxve9vbNHfuXD300EON2li0aJH23XdfHXzwwVqxovh3xTVWIAG4Vxv/b6YE/qFHKMn3/f+77wOVUBLhGzGQsZ4b9JMkABlIAICZI9SDZpX5LspWcB+6kLuBSJtH01EAXxLQkXSyulOBvhA6eU3deuut2muvvTQ1NdWL5kfOwoULtcMOO+jGG28s0t7FF1+snXfeWcuWLSvSXlsFEoCfy9+rbyXpsdGAlKTe9x4gYx9fz34oQHeTCHdbDME/kIkEABhsbS5+vt4162JbD/6tOlaQkNPD6NYNBfexz/wP1ZuUfUy7Slor6ZOec9XK/Pnz9aUvfakXTY+UefPmadddd9Wjjz5atN377rtPu+yyy0AkAQUSgEdkB92hXv2UEQNfMhFL/t1RAKuHP2VbHQE90GOxT9MAMP1S/09DN9f6btBNeYw7z9Vy/WGVzfKsW89tynzt+x7LJH3FOE+tbL755rrrrrv0ute9rmSzI+Phhx/W9ttvr6eeeqpnr7Hzzjtr/vz52mSTTXr2GjFbb721nnjiiTZNvFLdG9vribP7WFfbvi5Qz2rDGlWLjbL5EgxrdFHaMMAPdUjQ+w/0CCMAwOArMRc2Zfg9NsQfmiLQZvpPbLpP7P4B3zbfYxtJ719fr5iJiQl96EMfKtnkyJiYmNC+++7b0+Bfkm6++WZ9/OMf7+lrxKxZs6ZtE0sV7sn3BeihHnzrf9tto85qp74sbfj+UX+22nHLfPUBFEICAAyHXk8FsubzSv7AIDQNwDdFKNSjmJoohMpzkpBXS3qvutOCirn55pu1YMGCkk2OhGOOOUZ33313X17ry1/+su65556+vJar0+nomWeeadPElKTD5J+uY/XK+x6haUGhRMGa8qNAWWxbXYnODgAJSACAmcV3cXV72lIuzFNOndREIBRstB0FCI0MhKYvWEnKn0h6h6SiHxFz+umnl2xuxrvxxht14YUX9u311q1bpw9/+MPqdPofO05MTLR93WcU730P/Y9agX5OUm91FKSUq7Y9tB4rB1AICQAwPEr0joWSALfn37ctJUhIHQmI9epbn/yTsn/q423q3iD8mPcsZrrhhht03333lWpuRnv22Wd1+OGH9z0Yv/XWW3XRRRf19TUlte39l55PAEI9+CnJeKz3Xwr/P/veE+R5jnVIWPvFkCQALZAAAMMl5aIX61ULtRHrzUvpLWwajMe+DyAncchJDvaTtJOk1l/RWrn00p5899iM8/nPf14PPvjgtLz28ccf3/cvcSuUAMSC/1Dvvy/gT+39DwX31na3rrvNrZOK4B9oiQQAmJl8F9tQr1us5y+WGDRNBHw9/LlTgdqMBhwm6c2SbrdPZ54rrriiRDMz2oMPPqhPf/rT0/b6S5cu1amnntrX1yw4AhAK6K3/wVBSENue2gkgpQX7TcoBFEYCAAyftr1l7vB7bBi/Xh7raXQTgSajADk9+7EvC8tJCo5Vd0rQtYFzmeSnP/2p7r333rbNzFiTk5M69NBDNTExMa3HcdZZZ+mBBx7o2+sV+ASg0D0Aof9NK0HwBfKxBKASGhVQYFuoEyIFSQJQAAkAMJxSLoJWkF9fz0kCpA17+UonAfVP8ol9BGhKcB8L/H3HeaKkfSR9Vy3ddNNNbZuYsc4880wtXLhwug9Dzz33XF9vCC4wArBGdu9+biDvJgGpvf/u/vJsl/FsIfgHpgkJADCzxS6Y1nYrGZiqPft6A32JgLUeCtzd7b2aCuQLgD4l6UOSno2cu6Bbbrmlze4z1n333aeTTz55ug/j1xYsWKBzzjmnL69VYMTDvQk4JfhPDfJTe/9Dvfly6rhlbQJ4gn+gIBIAYHjl9prFht5zRgRiwUZKIpA7HcgdHajXC5WFRhF8IwFHSLrSOJfJ7rjjjja7z0hr1qzR/vvvX2rqz5SkO0s0dPzxx+unP/1piaaCJidbf/fcWoV7363/RznlvuTXai8lGUhNFly5vf8ACiIBAEZDLAmw6qUGBaGe/5TAv9oW6sGvAvqOU+YG8U2+FMx3vJf7T2fc4sWLtWrVqjZNzDjHHntsyTn3X5W0l6TWH+WzevVqHXrooSUC9KAC7VcN+P633HUZ5fXEoONpzwrm3fqx9xKXr50UJAlAYSQAwHBrcmG0LtxNev/r21Om/6QkAbFRgLafEOQmG6HXX6DnA65snU5HixYtarr7jHP11Vfr/PPPL9XcLyX9raQz1J2y1drNN9+s0047rURTXlNTU/FKYfW/45RRuJRpPSkjAG47Vl0Zy/Xnpgj+gR4gAQCGX5NetNCQfH05lgRIG/YmuvV9veuhEQPftJ22X/qV+/iYpFbd1f2YVjIMfvzjH+v9739/ySY/KulL6v7N/L2kIl8mcNppp2n+/PklmjKNjY21beIF8vfC+9ZTA3/r/9j3PhHr/Q+tl0oMALRAAgDMDG2H0kMXZV9Pn2RPM7B6IVOmAqU8cnr8U+4VCI1IdNQyAXjyySfb7D4jrFixQnvttVfJL92aL2mVnv8dXSDpmBINT01N6cADD9SSJUtKNLeRrbbaqm0TL1H6/1csAZDTljL3C/X+y1OWG/yTJAA9QgIAjB7fRThl6N7q+c9JAmJTgUKBf6i8zUeCWgFTVfawcQ6SLV++vM3uQ29qakoHH3ywHnrooVJNrpL0QUnv0oa/rwMk/XOJF1i2bJne85739OQ7Cl784he3baJKAKz/MatXPxTEW3UtVnvWdt96UwT/QA+RAAAzR5MLZmoS4Ovpc5MB3zSDJqMBoaDfd6NwalKQmgz8KnTyYn71q1a7D73jjjtO3/nOd0o2+TFJn5T9d/YRdT8ms7Vbb71V73vf+4p/P0CBBGBbSRfJ7lmvr8d68uv/r7G60oZtW6+ZkhjEygH0EQkAMLM0GVpP7dFLnQLgSwKs3v8mowE5PfyxZCD2+q268Ed5BOCMM87QF7/4xZJN/kBSaG7OJyWdUurFLr/8cp144omlmpPUTQBmz57dpolNJf2O4gl1KKD3Be2xfVJGC+pt1Z9T93P3B9AjJADAzFMqCbC2pwT8oSTA6vlPTQJ83xZsjQxYXyjmSxhCx0MC0MBXv/pVffzjHy/Z5ApJH5D0ToX/Rs6UdHupFz399NOLJjGzZs3Sy1/+8rbNvELxEYCcIN73v+6yRgR8z6HlGIJ/oA9IAIDRltqTV19ukgRI4ek/OSMBvm8Ltspi7YRGBjqSVkfOT9DatWvb7D6UzjvvPM2dO7f09JnDJdUzCuvvS5LOk/Q+SWtKvfBHPvKRoh8P+nu/93ttm3jt+udQci2l/V9a+/r+f+WsW0F/WwT/QJ+QAAAzU5MeN2sUwC1vmwSERgOs9VjvfcrHhba5J6DVfI1NNtmkze5D59RTT9Xhhx9e+gu1viHpL2QnaFYy+RFtmCy0dtJJJ+m4444r8Tn+JRKA7bThOZA2/j+bCmyLBfbuurutXsdaTx1RADCNSACAmavfSUC9LLen0QrucoP1WLLQJAloFcFvuummbXYfGk8//bQOOuggnXLKKaWbXiTpKKUnmdXjbEnfK3kgX/jCF/SOd7xDjzzySKt2fv/3f7/tofzh+udYYO+OBoT28Qkl/G6dmFg9EgWgj1r1bgGYkTqSxoz1enm9TOuXp9TtVPBdyKvt9XXVyuoBi2ustt39NqUx57m+PGnUqT+mjG31slYR/EwbAVi7dq0ef/xxLV26VMuWLdPjjz+uxYsX64ILLtAvfvGL0i/3jKT9JX1e+cHh2ZIOkXS3pNaT7is33HCDXvva12qPPfbQ7/7u72rbbbfVVlttpRe96EUaHx/XZpttprGxsV9/hOgzzzyjiYkJPfXUU1q5cqVWrlypu+++u+1h7KDuKMd/kz+hrp+vqdqzL5APjdrJqF8vK4HgH+iz1l9LCGDg5fyfW0G0u+4LuKv1cafMfYwb6+O18vrzrPXLs5x6407ZrNqzu+xbnx3Zd1zdjwH9x9AJS7HpppvqBS94wQZlW265pWbNmvXr9U022UQvfOELs+v85m/+ZqtPlnFvVF69erUmJia0YsUKrVmz5tcBbBXM9tHBknZS8+BwTNIl6n560Ezr7Pp9SW+QtE7P38syaaxbD+v+F2tkzE0s3GVp42QhNXFwkQAAfUYCAIyGfiYBY872WBJQD/jd5dCjHwnAbHV7oNFfX5K0mdoHhmOS/p+kz7Y+osFykLojJFbQH0oC3OA/NCWu4yzXA39rNEHaONj3TS2UUQdAH3EPADAamtwPYO1nXfDd7bGpBVL4Rs6UewCs7bn3DcQ+AagjaevAeUJvfF/dL/xqcz9Ifb/7JV3e15+g9/5U4ak/qct1VhuW3ICd4B8YQCQAwOgonQS46+4+vt5Bd/qAL9BrGvw1efhe56X26UGP/ETSeyV9UfG/j1jgX63vru5Hg97Wvx+j53aWP8mu30cTm6rjC/pjPft1oYQihOAfmEYkAMBoKZkE+Ib6rWX300jcnktr3Rect+3pD31JmPt4RegEoagl6gbrn1B+ohare4akd6ubYMwEb1T33oZQ0p0azIeSAOv9IjRCQFAPDAkSAGD0THcSUH8OJQFuT25KWenHq8KnB4UsVzf4P0rxoN/3+3b/NtztJ6r7TcJP9uln6qVxSa9XWs9+KDFIeS/wjfCl7NN0O4AeIwEARtOgJAGhgD9nVKBNT7Fvv69Jelnk3KC9NZLeJelQtfvdpTzmSpqjbsIx7F6kvCDf6uWXsy3Uu9+kvG1dAD1CAgCMrulKAnzTgOr717el9Pj3ImjcWrxH9tpqSf9z/cP3ewh9+7OvPPTpNvtJeoukX/bh5+ulX8nf0y9t/L/n68m3/p+t/S2xUQGCfWBAcXEDRtt0JAG+Ml+Q34+pPtZjXeqJQSOPS3qrpP+htADf+nvwjSDFHgdK2lVS8W8v65NnJd1XW0+Z+hOrlzMC0DSwJyEABgQJAIAcsQt4bhLgBh5TRn0r+LN6/0MBoJtQxALEjqQnJK2I/Lxo5mFJfy5pH/kD/Fhvv/v59qERAau9Q9X9NJ2He/yz9sJ31B3FqH4Wyf9/JgWsQBMAABl7SURBVGO7lPa/TMAOzFCz4lUAjIDcLwW0vgjM117oi8Vix+F+oZicdeuLyer1xo16sW8trtb/SN33yO0ix4w8/y7pbZKOkH8EyNfTHxsBUKCs/lwtb69uIvAGSa8u+lP2zqS635L8Qj3/M7qfbOUmxann1zftJ2V0QInbAQwAEgAAlVJJgO9bgmOvM6Zu8GDVjX27sO/1fQlCavkN6gaIaK8j6e8lHSDpeIWn8YQC1lgSEOsFd593kvQ3ktZKerO63/48yE5S98Zpd9SkSgJygn/3XEobn796WZ0vWXC3AxhAJAAA6nKSgFBQn5oE1J99wb/bbijwt0YJ3Nex2vAlAC+R9LS6n7uO5h5SN/B/uaRdtHHAb93rEdqe+pBnXc62t0jaVNLhkv5Ag/n9Dx1Jp0m6QtKLFZ7mFpsiJ/nPqbTxuQMww5AAAHBNRxLgBv+hNtztselBVllqAjAm6XPq9hL/V88xwG+JpFPVHUV5n+JBfWgKiy9BsAL9lCTASgjeIOmbki6TtK0G53d+s7rTflbIDv5j975Y59w6r9KG58O3HEPSAAy43CF/AKOhVBJQlfmSgdBj3LM+bizPqi27ZbOc5XqZtW6VXybp05L+l6RNMs7NKFop6TZJ35D0z+pOWYkF6zKW5SznBJVWIlffFppaVq/3fyT9pbr3LOyo3v3u16r7qUhL1z8elfSIpJ9Kun39a7s3PvseU+p+glX9pmf3ZmnfjdOhxKs+ciD5RwlCowYkBsCAIAEA4FNyJKDJvH43CXCDfmu9HvzPkp0MWAF/rKxa/r+S/lrdaSwvk7T5+uPfXNIWzs+9lXEuhl1H3W/SfVzdz9FfKmnZ+vXHJN0t6V51e/2rYM/9lJqOZ5tkJwFu0OgLIlOTT9+0sFj9z6k7Nejlkv6LpN+VtI26ScEm6t6U66q+cGyVulPJVq5/PKHuOauC/gO0caDuBvVNg/9qPRT4W9OIrMA/ZZoQCQAwBGbaxQlAWb2aDmQFXOPGc2oi4Ov9d5dny58IhNZ9Iwq+0QcrSTlD0m8458VNEjYz6ryodk6k7lz1Fzh1tlSzKZ3uN+KukvScukHjyvXra9WderJG3Tnooak1vmA/VkeBZcmfDLhC085yAv7QCIIldk7cKU6hjyz1JQDrjPJYHau9juxEIDRdyPe7dH92a909TwAGwKB/2gGA6dVRehJQr+vu51uvAoL6fQBT6ga89WChKnP3narVqfYdq5WHlqvn0EjElLGtXibFz0+1/W9rbbjTm6xEKDRyEnodVyjoSplyUw/oJo2yUJBvTRtJmf7jSwR8P1Os999dT11223ZfJ3TerJ5zN+C2gm5fYO625T5iyVdKz737M8WEgn0AA4wEAEBMySRAxnYrmAuVVYFOFURXbbnb68F6RxsH81UbVfmYsyynrlueqnoNtw3ftKdJz2uWTgB8AZ8VLFrlub3/vuQgFqSGfgaftoF/k9956Py4CYAV8Lv1rH3dNnyJlG/dPU7ruNsgGQCGBAkAgBS9SgKsfV1uQlCt13s9qyB73NlmjQBY+zcJBlNHATq146p6/+vtWaMA1bJ7/pomIdYxpSzHeuVjPfs5IwGpPdTushT+vVjrbUcCLKEEwArwY8lA6KZcedr0nVP3+HJ+jvozgBmCBABAqlJJQL2s/iyjrArax5193Wc36LcCfF+wb5WlTP9wWUFSFfxX+1eJSvWzjdXK5ZRZgaubCDXhHqcV5KUE/6EA3v2d+OqH9rOOJVXu79eadhVL+ELn0V1uMuWnSVIQSqRSznMMiQAwQ5AAAMiRmwTIqG8F/7EkwN0mbdzjX6875dS3kgQpHBS6290y62erfr7qMW4sVwF/PdCvn9fQPQC+c980ObG25Qb/7nruQ9o4SXCXQ8dbSRkF8I22dDx13TIZ+9TFzkfoZtvcKUG+tmNJlu/4rO1uGYAZggQAQK6cJMC3T8q6yw3Uqnr1gL/iJga+m4V9ScFYoCxHFfhXy/Xe/uo43cC/fi5ivdBN+Hp9Y0Gguz2lZ79EEuAep7VunQv33IV6+VNHgELn3EpYQglAtRz6RJ7Yww38U+4N8J3L3AC/VDsApgEJAIAmcpKAqq4vyE8pc3tc3WWrp99KDHxTgeqvmdoLXGcFVvX968F/RxufE/f8hKaiWMcb4zt39XUreHW39yL49wWq1rGksn5/7g3eKcF/6u+/fqz159AnAeUE+VYSYZVJG5/P+raU44/VJcAHZgASAABN5SQBKftYgb48ZW6AaE0HqspDyYEbIFbLOcG19XO4Qf54bdkN/H0P9/VjQWjovIbKreTAF/yHepVLJgDWcuhnqcTOmy8BcNdjIzDuuQ4lUb7gPSX49yUDbu+/PO3Hjid0XlPOPckAMKRIAAC0kZoE+OpVAYTbU+7uZz3LKKsCn0r9Yz7dbVbQ7/YSy6nv+9nc9ephTf8Zc9al+E2o1jE1Sb58ZVagGFruxQhA7DVDP0clFKyHAvxSIwD1Yww9Yp/+Eyt320qd+hM67/Vjt5YBzCAkAADayk0CrPpW4C+nvi/4lzYcAagnA1VbsalAVeBfTxjqda2fpf5cLdeD/vprV98K7CYC7nFXrxfrgfYdVwrfsfueY8F5P5IA93hjrPNWrYc+cSm0bD3X+c5dfdkK2FPn+Vvrbrty1mPJlDzbcpIuAEOIBABACW2SACv4twL+OjcRqParB9zW8piz7E4DkrFs/QyVWerexOkLZuu9/PX1atk9F1ZQKuUfY+y462WxRCCWDPQyCbCeU/gSAGuUJzUhkNLPeewcWTfxxoL/0LIv6JdR7p7ner3UwJ+EABhyJAAASklNAqz6bjDva9NKCqogv95OrGe5nhzUk4FKynSbqs1xz7b6sVavNeYsWw/JH4C623L5gulQIuA7p1LaVKApp67v2deme0zW8YdGSUI9+u43QaeOAFiv5Tun7vlyg3U34J/0bPMF8aFpQe7Il3VcKYmh72eMlQMYUCQAAErKTQJ8+1uBviJl9W2+IMcKiNzgvyrLOWYrsehow97l6hjdEYDUaSix4wolKbFyq7e9TRJgBfmx34kvYQsdb8X62VPOqZt4pn4hmO/3YB1v7FzkTvsJJQNW4O7WtY7Z+l1bP1esDMCQIAEAUFosCfBtT9mv4gv663Xrgaj0fG+v24YvKYgdg4/bK+z2+LvBf3VsUjhoDR1X6nmzyq3zlrLsBvCpQX9KEmAth36Wim+qlHVOrd5/NyEIJQIxocSp+rtwn3NuBE65lyCWEMcCfwXK29YFMI1IAAD0QmoSYNVzt7lBmVtHznK1vV6nHoi7yYGVCMQCvVBA6077cW/+dROAagTCGg2Qse4eX8qxWsvuz2Kth4LFXjx8r+m+vo8vAaieU865lZj52qwv+85z6OctkQTEEg3f+fUF/b7zTHAPzCAkAAB6JTUJyN3HZSUJ9YB+XOEAqV6vnhSkctsbd57bzP93jyU2EpByrPVnX7kvGK+WU+b+l0oAcgLR0LlKCfTd7VOecuv13GMLnTffvQBugB8K+Due+ikJgXXMscTLtw5gCJEAAOilWEDvq1OV1Z/laau+rX5DcErvv8v6GNDQcVeBvls2VtvmTvupH2PsewDqz+6yK3RuYuVuYBgLyHOTgdCNv6Gg1Xd8IaFEykoA3NEAyf59WOshbjCdcy9Ax7Mce7jtxRICKXxuU8p9SSWAAUYCAKDXcpMAt761bqkH+dZ6PRAdd9br3HsFfMHTuFFeD/zriUcVOFb7VOVV4OkGoFbgKaUnALFgLNTTG+uJ9yUAqc+hRCH0uu5xWqypUtY5DU39yfl91Nd9veaxHnnfPP6O/AlA6BzGXs93XNYzgBmKBADAdPIlB1UAYgW1VnJQf67XqQfh9TpuuW9EoB7oWz39biLgBv7u/P8pp351rLFg0zpHudOAfIF0ThJQL7MCS19wWm2znlOSgNjPItlBeX05Nt0n9HsIjcr4pJ43KwmoB/3usrUeasf3+jKWreOtl7VBUgEMEBIAAIPClwy4deqs4F+ypwL57gkIJQK+46mPElSBkjXtx53/X58CFLoJtf76vsA2JwHw9U7X130JQUoAmZIQVOvWc2pQah1/Xewchnr3Y6MFvvZDfD9X6F4AN8jPuVHYaqdeZh1XvSzl5wEwA5AAAJhuvsC/3kNu1akHI24iUE8I6kF4SiJQTwLc9aqu9VrjzjZr/r81xSS39z+35989dqssFBzGkoAmNwT79rNezzpuKxF0hc5lasDvfgpQaBqQK/UcusmAr6c/drOw9agH/rHkzT3O1J8LwBAiAQAwSHzJQH1bPTGwtsWmAtXXpQ2n5sSSgHqgP+5st46hHmi6U09CU1J869ZyjlhAHXq2gkWpeQIQC0CtRMRaroTOVWqg725zRwvc/WJ8yVToEZrqkxr8W4G/dUyhv4dYHbc+gCFCAgCgH0KBfWx7bJuPOy1Ism8UluJJQD0wrAf/VaBk9fi7CYDV6++bklJ/9pW1YQV1vqBfznpKAO8L+mPJgu913WONsc5laDl0069vdMZ9LVdK0B0K/K1tvnsCfIG/VSd2PABGAAkAgH6JJQFWPXefenCtwLYqqKtP4VBte32bmwT4AvAq8K8fm/uZ/7GpP9ZDxrKc5ZzA02IFdqHe3+q5vi00Xz81yA9tt9q2jj+W9NWfreXYCEAsCbDaDfH9bKGHLwHwBf++kQLfa7nH5R5v7G8jF4kFMGBIAAAMilCCENsm2cG/G3i4gb+VBFTrvsCxnihYwX99ub5vavBfOgHwBV85AaAVxMbm8DcJ/t3XCB1niC9g9/Xqx0YAQolATMo5dO8DqC9bCYCvzLdcf92Uc+77GUI/F4AhQgIAYBDVA343+K/WrcDfLavz9f67SUAV8LnfDOwej9vTH/rIT9/8/1jwHwr8U0dTQuv1Mjfo9q37gn13PSX4j7UdO+Y637lKGQFoEviHRgOsBKZajvXM53y8Z2ybam26r+kem3V8vp8JwJAjAQDQT24wn7Pd2hZKAlzux336kgBrFCAWtLuJQP216tvcfSrup81I6cFmDl9gWq3HkgC33NerHAr+Y/u59ULHWwmdn5wkwBfwtxkBqB9ryjm0HrGbfWN1JPt34B5L7s9Tui6APiEBADCo6kGzG0jnJAPW1CBFlt1RACtQrAf8ViLQccpDwWYsmPUFnLFkKiSlpze1pz41IYjVsV7XOiafUNJUKgkILbtCiUxOElAP3mMjBL7za712LAFzl30/X9PtAKYJCQCAQWMF91Z5PdCWZ7ke9LvPVkJQD/zdMqsH3+rFD839d8sVePaVydhuaZMAxILxWCAZ2pazHPo5rKTJrds2CfCtu23HhHrd68spH6nq3i/Q9GEdX+y4AcwQJAAAhk09EQglAXLKrGSgXtZROBlwy309/h2jjhKXFSiT7EA0pE2glxL4u/umBLUpbVmvn/JzWOexeu4YZSkJgbXuvpYvYQ0thxKlJt+tkLKP9bq+47KO3VoHMIRyejAAoJSU9x5fT2usR7Z69vXE17eFeuvrdaztvkfstVOffT9fW6He9dREwC2PLackD6Fn3/G7QklTk+fUnn/f7yZ2flPPm5sYTHnKY8F/SjLmO9ZYuYtEARhgjAAAmA71Xvzcum5Prtvz7u5njQjURwJ8xzFVq5sa8Ne/IMyXDCjwrEi5uz1HTu+/u54zGpDay+/bHjomX1mlSQJQLfvKQ3VzxJKrtiMCoXpW++5y6Jh96wCGFCMAAKZL21EAtzxnqk1snr67vcln+se+XMpa95W5P2NTTZKAEolAzr6h4/GVVVISgGo5d933OrHfifVzWcuhBCA12M/Z3/fsHk/s2H1IFoABRgIAYDrF3oNSgrCUHt2cJMAqS9mnvi12DKHjdX8+d7mEWDCXGyRagWWsTiyx8B2bVR46P6kJQJM6vtdK6VUPPacG87E6kj3tx3od3/G55dY2CwkAMMBIAABMpzajAFWZL1BOSQJiwX9KnVhy4Tsea7tbZv1coTJXTg96LPBLDR6b9vinvG6u1POcm5Q1vXamBNypoyop2+qvM1VbJvgHRhwJAIDplJMA+OqXSgLcZV+Pfps23fasnymn59+3PScACwXavp55a7+UXv2U3m/rtXzHXEnplXfX2yQAsdf0yU0A3PWUwD+0bG1zjy+UNKb+XZEAAAOOBADAdEp9DwoFXqGArlr2Bd0pwX/qPqHl0LP1M1llpd6vQ727oTqxshLr1jGUGAEILVfrKWX1fXN/H7GfNTdZCvXo+5ZDxxEL/q3j9iEBAAYcCQCA6dbvJMBdbxPQh3r5Y+uhn8Pa5ko5b7nTNVJGAVLLcgL+UlNPpHjilHPuSyYAUvzn9AXpsRGBWD2rbs5xWdt8CP6BIUACAGC6lUgA6mVNkgB3vcl0npzX8pX5jrsXUoK71KkiqYFrrH7KsaQIndcmfztWWdPfS2qwnRvMhxKBlNeNjQyRAAAzCAkAgOmW8z5UIgmIPackBda22DGk9kSHjruE1MCuSW99zjSXUFns2HLkjhzF9vGV5UgZackdDUhdt46j1NSf3LoApgkJAIBB0OskwNqv1+upx5OSAKSUx+T2rKcEhyUD/lgQGiqrxKb/uOW5U3zaJmWpvewpPfgpgT7BPwATCQCAQZD7XlQqCbDKmgT6qWWh43D1IwEIBWwpwaO73iSgTWmnhDaBfS9GY3KD85TAP1SW8rop20NIAIAhQQIAYFD0KgmI1U2Zn58zrSenpz/0M0/3CIBvWyyAb9LTXKL3v5Jznkv29vvqpBxzk/n5uYF/qD2rPsE/MIORAAAYJE2nAvn2ze2Fb5Ic+PYv2avci17n1Hq5U4RKBPy9CiZL/U5ytpWaetW0PFbPqt/k/JMAAEOEBADAIGnynpQznSaWNOTM18+ZZpTyGpZevkfn9Ez76qdubzrdJyd4bjJi0jTgL/F7iQXs9bLUKT25dVOPI4bgHxgyJAAABk3bJMDXRupoQKhuShu96O3v9QhALIDLCVZj23vVAx3T62A/Vq9kwtUkkCf4B/BrJAAABtF0JAGxsqY9/b7tucfRK03m2reZIpTTRs4x1TUZXelXYpZ7vnN78FP2scqbBvIkAMAQIgEAMIiavjelBnVtEoGm263yNu/BoX1LBGU5AWJuwNlk2k8JuUF+v6+Rqee8zTkuFfi33RfANCIBADCoSgbHTROBlPISc8xTXzO1Tk5g1uTG4JT9ehXwh+qXGDnKqdP0b7TtqEuoPOdcE/wDI4oEAMAg60cSkFO3VM/+dE/7sTS9P8Ct0zSQbVOn1Dz93Hqp+/Zibn3bKVQE/8AIm+4LDgDE9CsJsOo0/SjIpq/d9LVylJzr3ctgv3SQWaIXv1fXzH6MwuS+Vk6bAIYMCQCAYdD2vapJj3uJXv0m9abjfblEIJ9arxdBaVOlp/aUVGpEJme/Ju0CGEKD8CYHAClKvF81nXrTZspOL5KXNvoZCPajd7+jsudoOhK0UvdI9DK5IvgHZhASAADDpNR7Vts5+KXm8A/qe3DpOeuD8PnyvUogSyn9MZylkywAM8igXnwAwKcfvb25r9HmXoFh04uP8By0KUCDrN8foUrwD8xAw/jmBwC9eO/qRRDfr2lCpZSc6992/+kcARiU34dU7v6M0q8LYIgN0pscAOTq1XtYP3v0B+19uHTgNx3Bfo5BmLrVi+9taIrAHxgBg3bhAYAmev1e1vaLuUbBoAf6uQbh99nv8zVMvx8ALQzCGxwAlNDP97OS9wgMi5Lf2DsTlPidDsI5GoRjANBnM+GiBACu6Xpv6+fHUbYxHdN8MFj4nQEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABtj/B85LW22eGlwvAAAAAElFTkSuQmCC"/> + </defs> + <use xlink:href="#source-27"/> +</svg> \ No newline at end of file diff --git a/src/assets/cursors/macos/proof-1__23-0.svg b/src/assets/cursors/macos/proof-1__23-0.svg new file mode 100644 index 000000000..86ac7db9c --- /dev/null +++ b/src/assets/cursors/macos/proof-1__23-0.svg @@ -0,0 +1,40 @@ +<?xml version="1.0" encoding="UTF-8" standalone="yes"?> +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="768" height="768" viewBox="0 0 768 768"> + <defs> + <filter id="filter-remove-color" x="0%" y="0%" width="100%" height="100%"> + <feColorMatrix color-interpolation-filters="sRGB" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"/> + </filter> + <clipPath id="clip-0"> + <path clip-rule="nonzero" d="M 0.0117188 0 L 768 0 L 768 768 L 0.0117188 768 Z M 0.0117188 0 "/> + </clipPath> + <clipPath id="clip-1"> + <path clip-rule="nonzero" d="M 0.0117188 0 L 768 0 L 768 768 L 0.0117188 768 Z M 0.0117188 0 "/> + </clipPath> + <clipPath id="clip-2"> + <rect x="0" y="0" width="768" height="768"/> + </clipPath> + <clipPath id="clip-3"> + <rect x="0" y="0" width="768" height="768"/> + </clipPath> + <image id="source-35" x="0" y="0" width="768" height="768" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAwAAAAMACAYAAACTgQCOAAAABmJLR0QA/wD/AP+gvaeTAAAPfUlEQVR4nO3XAQ3AMADDsH78KU86j8VGkXzb7rYzAADgecIfAABCDAAAAIQYAAAACDEAAAAQYgAAACDEAAAAQIgBAACAEAMAAAAhBgAAAEIMAAAAhBgAAAAIMQAAABBiAAAAIMQAAABAiAEAAIAQAwAAACEGAAAAQgwAAACEGAAAAAgxAAAAEGIAAAAgxAAAAECIAQAAgBADAAAAIQYAAABCDAAAAIQYAAAACDEAAAAQYgAAACDEAAAAQIgBAACAEAMAAAAhBgAAAEIMAAAAhBgAAAAIMQAAABBiAAAAIMQAAABAiAEAAIAQAwAAACEGAAAAQgwAAACEGAAAAAgxAAAAEGIAAAAgxAAAAECIAQAAgBADAAAAIQYAAABCDAAAAIQYAAAACDEAAAAQYgAAACDEAAAAQIgBAACAEAMAAAAhBgAAAEIMAAAAhBgAAAAIMQAAABBiAAAAIMQAAABAiAEAAIAQAwAAACEGAAAAQgwAAACEGAAAAAgxAAAAEGIAAAAgxAAAAECIAQAAgBADAAAAIQYAAABCDAAAAIQYAAAACDEAAAAQYgAAACDEAAAAQIgBAACAEAMAAAAhBgAAAEIMAAAAhBgAAAAIMQAAABBiAAAAIMQAAABAiAEAAIAQAwAAACEGAAAAQgwAAACEGAAAAAgxAAAAEGIAAAAgxAAAAECIAQAAgBADAAAAIQYAAABCDAAAAIQYAAAACDEAAAAQYgAAACDEAAAAQIgBAACAEAMAAAAhBgAAAEIMAAAAhBgAAAAIMQAAABBiAAAAIMQAAABAiAEAAIAQAwAAACEGAAAAQgwAAACEGAAAAAgxAAAAEGIAAAAgxAAAAECIAQAAgBADAAAAIQYAAABCDAAAAIQYAAAACDEAAAAQYgAAACDEAAAAQIgBAACAEAMAAAAhBgAAAEIMAAAAhBgAAAAIMQAAABBiAAAAIMQAAABAiAEAAIAQAwAAACEGAAAAQgwAAACEGAAAAAgxAAAAEGIAAAAgxAAAAECIAQAAgBADAAAAIQYAAABCDAAAAIQYAAAACDEAAAAQYgAAACDEAAAAQIgBAACAEAMAAAAhBgAAAEIMAAAAhBgAAAAIMQAAABBiAAAAIMQAAABAiAEAAIAQAwAAACEGAAAAQgwAAACEGAAAAAgxAAAAEGIAAAAgxAAAAECIAQAAgBADAAAAIQYAAABCDAAAAIQYAAAACDEAAAAQYgAAACDEAAAAQIgBAACAEAMAAAAhBgAAAEIMAAAAhBgAAAAIMQAAABBiAAAAIMQAAABAiAEAAIAQAwAAACEGAAAAQgwAAACEGAAAAAgxAAAAEGIAAAAgxAAAAECIAQAAgBADAAAAIQYAAABCDAAAAIQYAAAACDEAAAAQYgAAACDEAAAAQIgBAACAEAMAAAAhBgAAAEIMAAAAhBgAAAAIMQAAABBiAAAAIMQAAABAiAEAAIAQAwAAACEGAAAAQgwAAACEGAAAAAgxAAAAEGIAAAAgxAAAAECIAQAAgBADAAAAIQYAAABCDAAAAIQYAAAACDEAAAAQYgAAACDEAAAAQIgBAACAEAMAAAAhBgAAAEIMAAAAhBgAAAAIMQAAABBiAAAAIMQAAABAiAEAAIAQAwAAACEGAAAAQgwAAACEGAAAAAgxAAAAEGIAAAAgxAAAAECIAQAAgBADAAAAIQYAAABCDAAAAIQYAAAACDEAAAAQYgAAACDEAAAAQIgBAACAEAMAAAAhBgAAAEIMAAAAhBgAAAAIMQAAABBiAAAAIMQAAABAiAEAAIAQAwAAACEGAAAAQgwAAACEGAAAAAgxAAAAEGIAAAAgxAAAAECIAQAAgBADAAAAIQYAAABCDAAAAIQYAAAACDEAAAAQYgAAACDEAAAAQIgBAACAEAMAAAAhBgAAAEIMAAAAhBgAAAAIMQAAABBiAAAAIMQAAABAiAEAAIAQAwAAACEGAAAAQgwAAACEGAAAAAgxAAAAEGIAAAAgxAAAAECIAQAAgBADAAAAIQYAAABCDAAAAIQYAAAACDEAAAAQYgAAACDEAAAAQIgBAACAEAMAAAAhBgAAAEIMAAAAhBgAAAAIMQAAABBiAAAAIMQAAABAiAEAAIAQAwAAACEGAAAAQgwAAACEGAAAAAgxAAAAEGIAAAAgxAAAAECIAQAAgBADAAAAIQYAAABCDAAAAIQYAAAACDEAAAAQYgAAACDEAAAAQIgBAACAEAMAAAAhBgAAAEIMAAAAhBgAAAAIMQAAABBiAAAAIMQAAABAiAEAAIAQAwAAACEGAAAAQgwAAACEGAAAAAgxAAAAEGIAAAAgxAAAAECIAQAAgBADAAAAIQYAAABCDAAAAIQYAAAACDEAAAAQYgAAACDEAAAAQIgBAACAEAMAAAAhBgAAAEIMAAAAhBgAAAAIMQAAABBiAAAAIMQAAABAiAEAAIAQAwAAACEGAAAAQgwAAACEGAAAAAgxAAAAEGIAAAAgxAAAAECIAQAAgBADAAAAIQYAAABCDAAAAIQYAAAACDEAAAAQYgAAACDEAAAAQIgBAACAEAMAAAAhBgAAAEIMAAAAhBgAAAAIMQAAABBiAAAAIMQAAABAiAEAAIAQAwAAACEGAAAAQgwAAACEGAAAAAgxAAAAEGIAAAAgxAAAAECIAQAAgBADAAAAIQYAAABCDAAAAIQYAAAACDEAAAAQYgAAACDEAAAAQIgBAACAEAMAAAAhBgAAAEIMAAAAhBgAAAAIMQAAABBiAAAAIMQAAABAiAEAAIAQAwAAACEGAAAAQgwAAACEGAAAAAgxAAAAEGIAAAAgxAAAAECIAQAAgBADAAAAIQYAAABCDAAAAIQYAAAACDEAAAAQYgAAACDEAAAAQIgBAACAEAMAAAAhBgAAAEIMAAAAhBgAAAAIMQAAABBiAAAAIMQAAABAiAEAAIAQAwAAACEGAAAAQgwAAACEGAAAAAgxAAAAEGIAAAAgxAAAAECIAQAAgBADAAAAIQYAAABCDAAAAIQYAAAACDEAAAAQYgAAACDEAAAAQIgBAACAEAMAAAAhBgAAAEIMAAAAhBgAAAAIMQAAABBiAAAAIMQAAABAiAEAAIAQAwAAACEGAAAAQgwAAACEGAAAAAgxAAAAEGIAAAAgxAAAAECIAQAAgBADAAAAIQYAAABCDAAAAIQYAAAACDEAAAAQYgAAACDEAAAAQIgBAACAEAMAAAAhBgAAAEIMAAAAhBgAAAAIMQAAABBiAAAAIMQAAABAiAEAAIAQAwAAACEGAAAAQgwAAACEGAAAAAgxAAAAEGIAAAAgxAAAAECIAQAAgBADAAAAIQYAAABCDAAAAIQYAAAACDEAAAAQYgAAACDEAAAAQIgBAACAEAMAAAAhBgAAAEIMAAAAhBgAAAAIMQAAABBiAAAAIMQAAABAiAEAAIAQAwAAACEGAAAAQgwAAACEGAAAAAgxAAAAEGIAAAAgxAAAAECIAQAAgBADAAAAIQYAAABCDAAAAIQYAAAACDEAAAAQYgAAACDEAAAAQIgBAACAEAMAAAAhBgAAAEIMAAAAhBgAAAAIMQAAABBiAAAAIMQAAABAiAEAAIAQAwAAACEGAAAAQgwAAACEGAAAAAgxAAAAEGIAAAAgxAAAAECIAQAAgBADAAAAIQYAAABCDAAAAIQYAAAACDEAAAAQYgAAACDEAAAAQIgBAACAEAMAAAAhBgAAAEIMAAAAhBgAAAAIMQAAABBiAAAAIMQAAABAiAEAAIAQAwAAACEGAAAAQgwAAACEGAAAAAgxAAAAEGIAAAAgxAAAAECIAQAAgBADAAAAIQYAAABCDAAAAIQYAAAACDEAAAAQYgAAACDEAAAAQIgBAACAEAMAAAAhBgAAAEIMAAAAhBgAAAAIMQAAABBiAAAAIMQAAABAiAEAAIAQAwAAACEGAAAAQgwAAACEGAAAAAgxAAAAEGIAAAAgxAAAAECIAQAAgBADAAAAIQYAAABCDAAAAIQYAAAACDEAAAAQYgAAACDEAAAAQIgBAACAEAMAAAAhBgAAAEIMAAAAhBgAAAAIMQAAABBiAAAAIMQAAABAiAEAAIAQAwAAACEGAAAAQgwAAACEGAAAAAgxAAAAEGIAAAAgxAAAAECIAQAAgBADAAAAIQYAAABCDAAAAIQYAAAACDEAAAAQYgAAACDEAAAAQIgBAACAEAMAAAAhBgAAAEIMAAAAhBgAAAAIMQAAABBiAAAAIMQAAABAiAEAAIAQAwAAACEGAAAAQgwAAACEGAAAAAgxAAAAEGIAAAAgxAAAAECIAQAAgBADAAAAIQYAAABCDAAAAIQYAAAACDEAAAAQYgAAACDEAAAAQIgBAACAEAMAAAAhBgAAAEIMAAAAhBgAAAAIMQAAABBiAAAAIMQAAABAiAEAAIAQAwAAACEGAAAAQgwAAACEGAAAAAgxAAAAEGIAAAAgxAAAAECIAQAAgBADAAAAIQYAAABCDAAAAIQYAAAACDEAAAAQYgAAACDEAAAAQIgBAACAEAMAAAAhBgAAAEIMAAAAhBgAAAAIMQAAABBiAAAAIMQAAABAiAEAAIAQAwAAACEGAAAAQgwAAACEGAAAAAgxAAAAEGIAAAAgxAAAAECIAQAAgBADAAAAIQYAAABCDAAAAIQYAAAACDEAAAAQYgAAACDEAAAAQIgBAACAEAMAAAAhBgAAAEIMAAAAhBgAAAAIMQAAABBiAAAAIMQAAABAiAEAAIAQAwAAACEGAAAAQgwAAACEGAAAAAgxAAAAEGIAAAAgxAAAAECIAQAAgBADAAAAIQYAAABCDAAAAIQYAAAACDEAAAAQYgAAACDEAAAAQIgBAACAEAMAAAAhBgAAAEIMAAAAhBgAAAAIMQAAABBiAAAAIMQAAABAiAEAAIAQAwAAACEGAAAAQgwAAACEGAAAAAgxAAAAEGIAAAAgxAAAAECIAQAAgBADAAAAIQYAAABCDAAAAIQYAAAACDEAAAAQYgAAACDEAAAAQIgBAACAEAMAAAAhBgAAAEIMAAAAhBgAAAAIMQAAABBiAAAAIMQAAABAiAEAAIAQAwAAACEGAAAAQgwAAACEGAAAAAgxAAAAEGIAAAAgxAAAAECIAQAAgBADAAAAIQYAAABCDAAAAIQYAAAACDEAAAAQYgAAACDEAAAAQIgBAACAEAMAAAAhBgAAAEIMAAAAhBgAAAAIMQAAABBiAAAAIMQAAABAiAEAAIAQAwAAACEGAAAAQgwAAACEGAAAAAgxAAAAEGIAAAAgxAAAAECIAQAAgJAfbSgG/5krOwoAAAAASUVORK5CYII="/> + <image id="source-28" x="0" y="0" width="768" height="768" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAwAAAAMACAYAAACTgQCOAAAABmJLR0QA/wD/AP+gvaeTAAAgAElEQVR4nOzde7hkVX3n/8+q093Y2E039A0YUbxga2LiEIxM8AY0twaJISEyMToYHXUSZ9QkjjxPJplcTDI6+SUmo0/iDYxj4mhQQ8RgRIIIiMaIIEa5qDSI0t3n9KlzurlI031q/f7oU7DPOuu2d132rqr363nOU3uvvatq1+nTVd/PWmvvMgLymAE9RpnHXbKvtfYiSbcYY+7q6agk24d9yzzGIO4PAACQpVX3AWAiGC0v9H1tofuF9t0i6Vpr7TM9++b85D5PznHn3Df1uAAAAANHAECOfha2VYvsUAHfDQEnVDy2VCAoc4yx9tzjAQAAGCgCAAYlVMSn9i9biEtLQ0BOj3/OMRhPW+w+vvYqCAEAAGCgCABIqVKQlu31L1P0h7Z3Q8AzEs9TJhiEwkBs31RbDkIAAAAYGAIA+q3sSb2p4rpML343BDy95P1y9u01CJRFCAAAAANBAEA/lekBzy38Q/cNbeuGgKdpaVHfWvzJGQHI2Vb2GKuMBhACAABA3xEA0C+5vd5uAR3b7mvP6dU/WodCwFMzH8cNBqH9Y68jFXRC6ymEAAAA0FcEAPRDmeI/dp9Y8Z07naerGALK9vrnFP2+tjKjAWUQAgAAQN8QANBvVab85BT+vm0p3RBwvOfxfFOCYs+fms6TE1IIAQAAoHYEAPQqp6gN9ZCnCu9Qe9mRgKuttccG7hOaChR77tDxS0tfa+j5fMs5CAEAAKBnBAD0IqcgjRX/7nrZ4jvXkyR9wVp7jB7v+U/1/ueEgdRrCR0nIQAAANSGAIB+iRW6seI/p/B321sqXwh3Q8DRgedxQ0Hs+aqMBrgIAQAAoBYEAFSVKmBzi39fW6wIjxXVKd0QsEXhUYBY0V8lCMSmBBECAADA0BEAUEWq+KzS8x9bTxXnZXRDwCbP8/hCRqwtdJzyLJcJRLkIAQAAoDQCAHoVKkJzin93PRYG+jEC0HWcHg8BoS8HSxX5sX3c11Vc9x07IQAAAAwNAQBl5U79id0n1jueU2T3MgLQ1Q0BG5zndQNB6tuDU+FFgWVCAAAAqAUBAP3kK257Kf5DIwDu41RVDAG+x4+FgVjvPyEAAAA0FgEAVeVM/Slb/HeXcwrtfhW83RBwpOc5Q8fQcpZzQowCy4QAAAAwVAQAlBErMMtsixX/wxwB6PKFgFSvfyyYpEY25CwTAgAAwNAQAFBFqGCNFa5lC/5Uod3vv91uCFgnfzHvCwKxYFImBPgCDSEAAAAMBAEAg5A71SdW8LtTbXy98P3WDQFHyP89Ab7iP/ZafFOAcrYV23zLOQgBAADAiwCAXKmC0rc9dJ9Q73isoHan3wzqb/fJOhQC1nqe29fTnzMy4BvFUGFdgW2+7WUQAgAAwDIEAJQVK1Bj66Hi1p0W4yu0i9vc7wMYhG4IWKP4dJ/YNmUuy7NMCAAAAANDAEA/hHr/U/Phi73o7jZ3fZAnAft0Q8ATPc/vCwK+4y22xZblWSYEAACAgSAAIEdsKk/Zx0mNELhTa2LTagatGwIO9xxPajRAnn0UWZZnmRAAAAD6jgCAMlJFZE6RW2wLTfVpObduoT3IcwBcT5Z0nbX2CVpe5PtOFA4V/znThORZJgQAAIC+IgCgV7lFpVv0ureh4j/U4z7MYtYNAaGTgt3RC9+2WJsK6wps820vgxAAAMCEIwCgqpyiNDXlJ3Q/X/Hvrg+7kO2GgMM8xxELKb7RgVibCusKbPNtL4MQAADABCMAoBe+QjLUVuZHWl4Y+4rsYeueE7BKy6cApUJAaFpTqE2eZUIAAADoGQEAuaoUmKH7+EYIcufRu/sM21P0eAjoHocvCMSKf0IAAACoDQEAKTlFYmrKT2jee5fv5Njc6TR16IaAFfJfpch3gnDo3AZCAAAAGCoCAKooEwpi22MjALHlOs4BcD1Fh84JmFJ8FCB2/DlTn1RYV2Cbb3sZdf8uAQDAEBEAMAi+YtRX7MpZjy27vetNKFqLIcA9ttQIRuhHIgQAAIABIgCgKl/BGCpIY9OC5FlOfclW3VOAinwhoHisqcuGEgIAAMBQNaWIQrPFCkNfkR8LArHe/5Zn3zq/CThX95yAYsHvOweAEAAAAGrXpCIKoyFVIPqm/KTaUyMAoSDQJMfrUAhIhRZCAAAAqBUBAMMSmgZUXA6NEISCQNMcL+nz1toFLR8JiI0OEAIAAMDQEABQRahXP7Zv7H6+9dQoQFML1Gfo0EjAQeVNYyIEAACAoSIAoN/cIt9ti4WAUJvvpOAm/+0WQ0DqfABCAAAAGKomF1EYPb7ivnsbGwko7pMaBShua7ITdCgEHNDS0EIIAAAAtWp6EYVm6WchGCpwfYVtsUhu0vcApIRCQOoLwwgBAABgYAgA6IdQoRmb1qNAuzvNJzYiMAp8IcAXBggBAABgKAgAKKvXIjDU0x967NAIwSj97YZCQGpqECEAAAD03SgVUWimWOHZvfUVoKGRgdSXaY3SFKCibgh4VIQAAABQIwIAUvpZ7IWCQOo5YgXwKCmGAN/5AIQAAAAwcAQAVBUqJt3b7rKv3b1/mZ9R/dt9pg6FgP1afjIwIQAAAAzcqBZRGA2xkNCPn1FFCAAAALUhAGDY3KK1lxGBUUYIAAAAtSAAoBex4j21j28/t+B1291ieNQRAgAAwNARANCr3CIyJwj4CtZQgTsuf7vPlHQtIQAAAAzLuBRRaLbYdB/flB7fNKHcqUOjaKsIAQAAYEgIAMiV6sFPTQeKFfOhxwoVtwP9HoCDBw8O6qFjuiHgERECAADAABEAUEaqsAtN5Qntm9vrH1ofiHe961364he/OKiHj9mqQ+cEEAIAAMDAEAAwTLHpPvK0++438BGAhx56SOeddx4hwL+9DEIAAAANRABAE4SK0tAogDTg4rIbAq6//vpBPk0IIQAAAAwMAQBV9LOoCxWboSk/Ax8B6HrooYd07rnnEgL828sgBAAA0CAEAAxDqAD0FZmhaUJy2odSVDYgBHBiMAAA6CsCAAYlVfCFin93m6+A9RWnA/PQQw/p/PPP11e+8pVhPWXRs0QIAAAAfUQAQC/KFPmxwjE2QuCbAjT0QnLfvn06++yz6w4BPxIhAAAA9IgAgGEKFfSh21DbpIaALxACAABArwgAGLScYs+3T6r4r6WI3Ldvn8455xxCgH97GYQAAABqQgBAP/jm7vu2p/aPtedOGxq4vXv36pxzztG//Mu/1PH0hAAAANATAgCGJTccFNt8xWhtvf9Fe/fu1dlnn11nCOCcAAAAUAkBAP0WK+hS20LFvW9koPbCseYQ8GwRAgAAQAUEAAxbbqEYKkBT042GqgEh4HPW2gdECAAAAJkIABiEXoq51HkBjZgCVFRzCPhJSf9MCAAAALkIABik3IIut5BsVO9/UTcEfPWrX63j6QkBAAAgGwEAdal6rkCZfYZq7969Ouuss+oMAdcQAgAAQAoBAE1SZvpPI4vEmkPAc0UIAAAACQQA1C1V6PkK/kYXh4SAJQgBAAA0DAEATRArGkcSIWAJQgAAAA1CAEBTlS0yG6fmE4O7IWCfCAEAAKCAAICmSRWOI1UMzs/P6+yzz9a//uu/1vH0hAAAALAMAQB16EfxNzIF4fz8vM4666y6QsC/FyEAAAAUEAAwakayCCQELEEIAACgRgQANFWs138ki0BCwBKEAAAAakIAQJ0mrpAjBCxBCAAAoAYEANQtp5Abq2KvGwK+9rWv1fH0hAAAACYcAQBNkSrmeikUG2d+fl5nnnlmnSHg84QAAAAmEwEAo2rkC7+aQ8CJOhQC9ooQAADARCEAoEl8heFYa0AIuIYQAADAZCEAoIlixdzYFXqEgCUIAQAADBgBAE02MQVd98Tgm2++uY6nJwQAADBBCAAYVWNX6M3NzenMM8+sMwRwTgAAABOAAICmm6hiruYQ8FMiBAAAMPYIAEDDEAKWIAQAANBnBACMCl9xOLYIAUsQAgAA6CMCANBQc3Nz2r59u2677bY6nr4bAuZFCAAAYKwQAIAGm5mZ0RlnnKFvfvObdTw9IQAAgDFEAMComaipQNKhELBt27a6QsBJIgQAADBWCADACCAELEEIAACgBwQAYEQQApYgBAAAUBEBABghDQgB/2itnRUhAACAkUUAAEZMzSHgZyT9EyEAAIDRRQAARhAhYAlCAAAAJRAAgBHVDQH/9m//VsfTEwIAABhRBABghDUgBHyWEAAAwGghAAAjbnp6us4QcIoOhYA9IgQAADASCADAGGhACPgnQgAAAKOBAACMCULAEoQAAAACCADAGOmGgG9961t1PD0hAACAEUAAAMbM9PS0Tj/99DpDAOcEAADQYAQAYAzVHAJeIEIAAACNRQAAxhQhYAlCAAAAiwgAwBgjBCxBCAAAQAQAYOzVfGIwIQAAgIYhAAATYPfu3dq2bZu+/e1v1/H0L5B0lbV2RoQAAABqRwAAJsTu3bt1+umn1xUCXqhDIwGEAAAAakYAACYIIWAJQgAAYCIRAIAJQwhYghAAAJg4BABgAhECliAEAAAmCgEAmFANCAGcGAwAQA0IAMAEqzkEvEiEAAAAho4AAEy4bgi4/fbb63h6QgAAAENGAABACFiKEAAAGGsEAACSpF27dhECwtvLIAQAABqNAADgMQ0IAf9orZ0WIQAAgIEhAABYouYQ8GIdGgkgBAAAMCAEAADL7Nq1S2eddZa++93v1vH0hAAAAAaIAADA6wc/+IFOO+20OkPA31trd4kQAABAXxEAAATVHALOlPQPhAAAAPqLAAAgihCwBCEAADDyCAAAkroh4Hvf+14dT08IAACgjwgAALIQApYgBAAARhYBAEC2++67r+4QcAUhAACA3hAAAJRScwg4S4QAAAB6QgAAUFpDQsBOEQIAACiNAACgkgaEgH8gBAAAUB4BAEBlhIAlCAEAgJFAAADQk24IuPvuu+t4eqYDAQBQEgEAQM9qDgFnixAAAEA2AgCAvvj+979PCAhvL4MQAAAYKAIAgL5pQAj4e0IAAABxBAAAfVVzCDhHhAAAAKIIAAD6rhsCduzYUcfTEwIAAIggAAAYiO9///s69dRTCQH+7WUQAgAAfUUAADAwhIAlCAEAgEYgAAAYqAaEgE9Za+8XIQAAAEkEAABDUHMI2K5DIwGEAAAARAAAMCQ1nxhMCAAAYBEBAMDQ3HvvvYSA8PYyCAEAgMoIAACGqhsC7rnnnjqenhAAAJh4BAAAQ3fvvffq1FNPrTMEcGIwAGBiEQAA1KLmEHCuCAEAgAlFAABQG0LAEoQAAMBQEAAA1KoBIeCThAAAwCQhAACoXc0nBp8nQgAAYIIQAAA0wj333FN3CPiotfYHIgQAAMYcAQBAY9xzzz0688wz9cMf/rCOp/95SR8jBAAAxh0BAECjfPe739Vpp51GCPBvL4MQAADwIgAAaJzvfOc7hIDw9jIIAQCAZQgAABqpASHg/xECAADjiAAAoLFqDgG/IEIAAGAMEQAANBohYAlCAACgZwQAAI1HCFiCEAAA6AkBAMBI6IaA+++/v46n/wUd+p6A+0QIAACMOAIAgJFRcwi4UIdGAggBAICRRgAAMFLuuusuQkB4exmEAACYUAQAACOHELAEIQAAUAoBAMBIakAI4JwAAMBIIgAAGFk1h4BfFCEAADCCCAAARhohYAlCAAAgiQAAYOTdddddOv300wkB/u1lEAIAYAIQAACMhTvvvLPuEPC3hAAAwCggAAAYG90QsHPnzjqe/uUiBAAARgABAMBYufPOO3XaaacRAvzbyyAEAMCYIgAAGDuEgCUIAQCAJQgAAMYSIWAJQgAA4DEEAABjqwEh4G8IAQCApiEAABhrNZ8YfJEIAQCAhiEAABh7d9xxByEgvL0MQgAAjAECAICJ0A0Bu3btquPpL5L0EWvt90UIAADUjAAAYGLccccdOu200+oKAf9Rh0YCCAEAgFoRAABMFELAEoQAAJhABAAAE4cQsAQhAAAmDAEAwERqQAj4oLX2XhECAABDRgAAMLFqPjH41ZIuJQQAAIaNAABgot1+++11hwBGAgAAQ0UAADDxbr/9dp199tnas2dPHU//KxpMCCjexgp7QgAATBgCAABIuu2227Rt27ZxCgFuGPAV9u6tu923LYUQAAANRwAAgEWEgCUIAQAwpggAAFBw22236YwzzqgzBHyAEAAAGCQCAAA4vvGNb9QZAl6j8QkBAIAGIgAAgAchYImqIYDAAAANRAAAgABCwBKEAAAYEwQAAIhoQAh4PyEAANBPBAAASOiGgNnZ2Tqe/rVaHgKKRT8hAABQCgEAADI0LAS4RT8hAACQjQAAAJluvfXWcQgBSrSpsO67dbf7tsUQAgCgZgQAACihASHgfT2GgO77PiEAACYUAQAASqo5BPxn9RYCpHIhQIV2ebYRAgBgxBAAAKCCEQ4B3ff93BAQGxFw233bYggBAFADAgAAVNSAEPDeQAhwC39CAADgMQQAAOhBzSHgdZLeu3bt2n1aGgLcW0IAAOAxBAAA6NGtt96qM888U+12u46nf92+ffv+FyEAAJCLAAAAfXDLLbfojDPOGIUQELoqECEAACYEAQAA+oQQsAQhAAAaigAAAH3UgBDwx4shwFf8+04WJgQAwIQhAABAn9UcAl6/GAIeECEAAOBBAACAAbjlllvqPDG43yFAIgQAwNggAADAgHz961+vOwT8kScEFAv93BDgjgj4luUsEwIAoKEIAAAwQDWHgDd4QkDsW4MJAQAwAQgAADBgDQgBv0MIAAB0EQAAYAi6IWBubq6Op3/zvn37frtCCJCWnwtACACAEUcAAIAhqTkEvGXfvn2/rfiJwLFvCg6FAkIAAIwYAgAADNHNN99cawiw1v43+UNA6AvDCAEAMGYIAAAwZISAJQgBADBkBAAAqMEIhwD3HIFQ4U8IAICGIgAAQE1uvvlmnXfeedq3b18dT/8Wa+1/VfkQ4LtaECEAAEYIAQAAavTlL39Z55xzTl0h4NcJAQAweQgAAFCzBoeA2NWBCAEAMKIIAADQAA0IAW/U8hDgflcAIQAAxgABAAAaouYQ8BuEAACYDAQAAGiQL3/5y9q+fTshILxPDkIAAEQQAACgYW666SZt375dDzzwQB1P3+QQUAYhAAACCAAA0EA33XSTzjnnnDpDwK8pLwRI/Q8BrlA4SCEEAIAHAQAAGqrmEPCbmSGg+DnSrxDgK9wJAQDQJwQAAGiwEQoB3UJ7kCGguL0MQgAAFBAAAKDhGhACflXhon+YIaDqScFV9geAsUUAAIARUPOJwW8thICcLwyTZxshAAAaggAAACPiS1/6UpNDgHsrhQMAIQAAakQAAIAR0oAQ8F+UHwJSP0osy9OuwPYyCAEAJhoBAABGTM0h4L8TAgBgtBEAAGAEjXkIkAgBADAwBAAAGFFf+tKXdO6559YZAt6g5QV/mW8JDoUAt/iPFf3uOiEAABIIAAAwwm688Uade+65evDBB+t4+rctjgT4QoDvW4NzQ0DxVoF2QgAAVEQAAIARd+ONN2r79u1NCAFuz3/VEND9bEpNCyIEAEAFBAAAGAMNCQG+cwD6MRIwrBAAABOBAAAAY6IBIeAN6l8IiJ0grEK7PNuqhgACA4CJQAAAgDFS8zkBlzghIHQycK8hIDYi4Lb7tsUQAgCMPQIAAIyZG264oe4Q8J+0fBSgeCvlT/3pVwgogxAAYKytqPsAADTb05/+dL31rW9Vq0V/waj5zne+oxNPPLGOp/49a62MMR/WoaK/o+Un+HY89+vuW7yPLdzXt2wX14vLRcazLwBMNAIAgKCTTz5Zn/70p7V58+a6DwWjxw0B0uNFf3c0wA0BVsuLfzdApEKAFC70y4QAAgOAsUWXHgCvCy64QNdeey3FP3rxe850oNSXhLmX/0xd8UeBds4HAIAIAgCAZd70pjfpE5/4hA4//PC6DwWj7/cLISB2VaCu2AnCcm65MhAAVEAAALDE2972Nv3FX/wFc/7RT90QECr8c04QrnJSsItiHgBEAABQcMkll+id73xn3YeB8fT71tpXqfzlQRW4zQkBsYKfMABgYhEAAEiS3v72t+sd73hH3YeB8fYHnpGAWO+/tPS8AN+taxDnAxAWAIwVrgIETDhjjP7sz/5Mb3nLW+o+FEyGP1i8OtD/XVzv6PGr+bhXBnKvAFRUvAJQ6Oo//bySD1cFAjA2CADABDPG6N3vfrfe+MY31n0omCx/ELhEqK+Yd4v/UOFf5tKgOd8fAABjiwAATKipqSl98IMf1Ktf/eq6DwWT6Q+stXZxJKA4Faj4XQEut/gv0/ufWgeAiUEAACbQihUr9KEPfUivfOUr6z4UTLa3F0YCUnP8raet295VdSoQYQDARCEAABNm1apV+tjHPqYLLrig7kMBpKUhoDjfPzQVKDUKEJvewygAAIgAAEyUww47TJdffrnOP//8ug8FKHr74nSg1EhAqPjP6fHvxygAgQHAWCAAABPiiU98oq644gqdccYZdR8K4POHiyMBf724nvMFX75pQbERAWWspxACAIw8AgAwAdatW6errrpKp5xySt2HAsQUQ0DsSj/F9TLFfL/OBSAEABhpfBEYMObWr1+vz33ucxT/GBV/aK29WOkvCHO/MTj1bcFFqXUAGGsEAGCMbd68Wdddd51OPvnkug8FKOOPrLWvLqwXi3nfupxtMRT7ACYeU4CAMbVlyxZdc801es5znlP3oQBV/NHidKAPafmXhMmz7rZXPReA6T0Axh4jAMAYOu6443TDDTdQ/GPUFUcChjkKwCgBgLFGAADGzFOe8hR94Qtf0AknnFD3oQD98MfW2tcofS6Ab7l46y771gFgIhAAgDGydetW3XjjjXr6059e96EA/eSGgKLQib6+fVIIBAAmAgEAGBPPfvazde211+pJT3pS3YcCDMIfW2tfK3+PfuoqQD4U+wAmFicBA2PgxBNP1NVXX62NGzfWeRgPSXq0zgPA2Pvv1trdxphPF9pyTuQtrud+d0C/vjMAABqHAACMuJNOOkmf+9zntGHDhjoP4x8lvcIY885Cm9XyAslXMNnE9pxtOdvrkvM7KLa7v4/QunXWO57b7vaOs3+n0OZbTv0sBNYXMvYJrft+3GPqrrsFvft78xlEwU4IADCSmAIEjLAXvvCFuvbaa+ss/q2kd15++eUvM8a8Q8uLNbfo9BWZuUVomQK1ST+hIja0n/v78K37frfy3MYChCLLKaGTaUMn6obuk5qmE3sO3/Z+TAMqOzWIqUQARg4jAMCIeslLXqLPfOYzWrNmTV2H8Iiki40xL5H0f5QuNlVYj/WKp3rIy25rgtTIh6/Nt+z+Tn2Fvm8EwDdyIM96v0KAlb9gDxXkvm/1LT6O+9ju45a9nn+/pgGV3Q8AGoEAAIyg7du365Of/KRWr15d1yE8LOkCY8zLtLx4zOlx7lcAGIWiq8xx5wSmUDEfGh2IhYDQsYW2peSMDHRvfcEgFgLcgl2FtpxpQLlThbr7ln39hAAAI4MAAIyYl770pfrEJz6hww47rK5DeEjSy4wxF+jxXuau3CknuSMDsX2botfjiYWf3FDl9v7HAkBsKpHv+asUwr71UI9+2ZNtB70/AIw9AgAwQi666CJ95CMf0cqVK+s6hAclnW+M+QX5p5/4blMBoEzxn7utzD51yQk2Ob+vWM9+rPjvJPZ1jyMVFmL6Mde+ex/fiIC73V3uBQEBwNghAAAj4hWveIU+/OEPa8WK2v7bzkvabox5hZb2/JcNAGV7vEP7htqaKHScOQEn1Ssf+v2nfor7+f493ZGCXGWDQeok3VhBnzPfP3RfAJhYBABgBLz+9a/XX/3VX6nVqu3CXXOSzjbGvFLhnmN32bfN167APu620PaUJhR+VQvoVECK/a7d4j009Sc1KuA+fmqUoIoyowE5U3xyvg8AACYWAQBouF/7tV/Te97zHhlT29UGpyWdYYx5rfw9xbEw4LaF1iV/Qelr9+2T295Eodcd269qACjexor/4npsJCD275/zmkJyzg2oinAAYOIRAIAGu+SSS/SOd7yjzkPYpUPF/39W3pzx1KhA1TDga6uzWBvkc6dGPHKLf3c5doJwLAi494l9J4F7PLmjAql9cqf6AAAyEACAhmpA8X+fpNONMb8mf1FYLCglf9FXtvjPKfL70cNcpyojGsVtoV5433ossIUCgC8QuOHPfbzQ8caCQOz+Of+esak/AIAIAgDQQG9/+9v127/923Uewr2StgWK/1hB6WtXZFme5VgISBV3wyr+hvE8/RgJ8LWFAoDkDwKxcODu5wsK7jH3a1TARfEPAJkIAECDGGP0rne9S29+85vrPIy7dKj4f4vCxb9vPnluEJCWF4GhUJDTOzxKRV+vhW8sOBWXQz31ZUcDQiMBuecFxI6j1zAwSv/uANAoBACgIYwxeve73603vvGNdR7GHTpU/P+64tN+UsEgdwQgFQTk7BvSlJGBfsp5vaHi27ee+ndKBYHYF4jFtncKz1dWmbDXy78x04kATBQCANAAU1NTuvTSS3XxxRfXeRjf1qETft3iX4oXf6EeXd8XhfnaQutS/wvAUSzoQj397j6x318qgOWOBKTODYj16udsixnFfzsAaCQCAFCzlStX6qMf/aguvPDCOg/jFklnGWPepvBlImMFYm7PsiLLctql5UVfTjGc0uRCMvfYYr+H1CiA2+ZO54qdD5A7IpC7T+xvI+e1l9kGAFhEAABqtGrVKn384x/Xz/3cz9V5GDfrUPF/iZb29ErLiz63rZdRAWl5wZc7EuBuD62PqtzX67aHgkHuCIBU/gvDin8zsUuIuscb+rf33d/3mgAAFREAgJocfvjh+vu//3udddZZdR7GjZLOM8b8luLXgg+1xdpTPbyx5eJtbNnVS+/xqKgSAnJ//zlhIDU1KCc0hAr82Iv5EdcAACAASURBVHHHXjMAoAQCAFCDJz7xifqHf/gHbdu2rc7DuF7SS40x/0N5Pbyxoj80OiAt7fXPDQRSuPib9AAgpQNRKAwU133nY+QEudQ3Boem/uQ8fuo1+F6H774AgAgCADBk69at01VXXaVTTjmlzsP4nKQLjDH/U+ETPH0FnrT0qi6+/eRsL97mhAA5y7mFYcyoFoi5xx0LBKHedF9R7q6XCQCx4t/dz3dsoSCgSDsAoAICADBERx55pD772c/q5JNPrvMwrpL0C8aY31V8OkcvhZ4C26WlYUDOtlBPcKyX210eR6HXl2qPhQFfL3osCLgnh0t5fxO5bb7nzD3+1OsGABQQAIAh2bx5s66++mo997nPrfMwrpT0i8aY39Pygs23HhsNKFvgxXp73eXibahNnu2ptnFVJgiEiv/iciiwxUKeLzCGQqR7bL5j8h1bWZP0NwAA2QgAwBAcffTR+vznP6/nPOc5dR7GxyW9yhjz+3q8OAv1+sdGA3KnDPmKyNypQL51edbLtI07XwHt2x67DRXlvjAXC3qpwj/UXlyXs7+77HttAIAMBABgwJ785Cfrmmuu0QknnFDnYXxU0sWF4t/toY2dyJkKA75LhcaKw1DPsrS0kAv1CrvLrkkqBlOvNdR7nhoFcNtCwS003atMOAwdS85rC40aSOn7A8DEIgAAA3T88cfrn//5n/W0pz2tzsP4wOWXX/6rL3/5y/9A5Qt93/ZQ4Z/TM5zq+c8ZBZB6K/Jy72MqPHadfEV+aJ+cwjkntKV6/1NTxWK9/2X+RnJVDQWmh/sCQOMQAIAB2bp1q6655ho96UlPqvMw3nv55Zf/10Lxn1Ps92NUINRTnBsCYrdFOUVvE/UjXOS+1pwgECr+3eWcABAq/hXZ5gsdvuOoEyEAwNggAAAD8OxnP1vXXHONjj322DoP40+NMY9KKhb/oWI+1uufEwTc9lhRKE+7PMvFW3c51NbUAm1Yowmp1x8qpn296qkAUFwPTQUK/ZvHnsO3zT3+qsoW8RT9AMYSAQDosxNPPFFXX321Nm7cWOdhvNMYs6DlxXnZwj81HShnBMBXJBZvc3r/Q22u3AI4pF+FepOmD8UK6NDvOTUykBoR8P0b554LkFP4p8JiVRT8ACYCAQDoo+c973n63Oc+p6OOOqrOwygW/zkFfWpblZEAXyEY6/XP6QkutvmWc1nVW6AP+7lDv6OckOX7twj9+4V+pHTBXzaADFOZUND9tyVEAGg0AgDQJy960Yv0mc98RkcccUSdh/E7xpjVKt/LXyYElDkxWPIX/7EiMGc0QIG2QRdeZYr3Jo0CuEK/01j4yvl3y/1JnRMSOp7Q66iqWNznFPq5BX7x354wAKBxCABAH5x66qm68sortWbNmroOwUr6DWPMJuVN80n9VB0JiI0GxHqHpeUFYKogLNtWRh2F/qADQ5mRgO56KgAUl8uEAWn5+QSxx0rpZ5HtCwJuW5VRAZW4DwAMFAEA6NH27dv1yU9+UqtXr67rEKykNxljjlFeId/LSEBOGHB7enOKwZwRgNhyzu/IeJb7LfW4vT5v2fuXKZ7LBoBim9se6+GPPU7suMoEgpRQz393OScEVH3eIgIBgFoQAIAenH/++br88st12GGH1XUIC5Jeb4x5uqoV8QuF29wgEBpZKNv7n+pRVmFd8hdL/e79zymwqxbxsfvVOV0oFgDc9pzQViYExIKf71jKCBXsuYV8ar9+zPdndABALQgAQEUXXXSRPvKRj2jlypV1HcKCpNcYY56p8sW/+7OQeR/f9ljhP8gpQHUVTP0s5EP7DzMQxH6nqV74VIDLKfhzHj8lVez7ivXUKIDvcX0Fe7+KeMIAgKEhAAAV/PIv/7L++q//WitW1PZf6KCkVxtjnq14Ue4r8nv9CfX6x84FGNQIQKy9qirFt+8+/TiPoI7vD8gdCejeFtt9YS7Wy1+24E/1yJcNDFVCwDBwNSEAA0UAAEp6wxveoL/8y79Uq9Wq6xAelfQfjTEnqVwvf6r3v+y5AG57qPCPBQEllovK9gq7Pb+9FNOpAj11TL1MLWraaEB3ObSeU/ynAl7o91n2Sj2h5dBjuYV/8b6+fd3j7XdYIAgAGAgCAFDCG9/4Rr373e+WMbVN2d4v6eXGmOcrf5rPgrNfquCvcj6AbwSg2yZPu1sM1t3730tvfZXi3ncf32up8s21ucr2tocK9TJBIBbwco8rVKTn8IUA9/6hK/7E/i1CQXMQf5+EAQA9IwAAmS655BK94x3vqPMQHpb0c8aYl6jcVB/raV8ItJedAuQLAr5leW5zin9fsRPrmS6jlx73siGg6v5uoZriFq45++Xs4/udVwkCvufod5Gc0/PvW8/dx93XPYbQv8EgXicAVEIAADI0oPh/SNLPGmNOV/+n+lQ9N8Dt/Y+FgNiPFC8gi22+5V5VKc5jPcax/WPFm1ss5hSPvQ5FlemFD/Xclyn+U73/IbHpOjmFf5UQ4Hve2GO6+/gey9deFUEAQGUEACDCGKM/+ZM/0W/+5m/WeRh7JW03xpyn/hX+uW25Pf+x3v9ugRIbAUj1JvuW+6VMAMgt/kPFfKpYjBWJvuOM/T768btKjQL49ikTKnxivytfYV82BITWlWhLBYHQMfu29bN4Z3oQgNIIAECAMUZ//ud/rje96U11Hsa8pHOMMS/VYAr/WNHv2+aGAOu09dr7n+r5z2kPCRX2ZQr6VE9+TmGYMzUnp9gP7ZvzPDE5gStU/Icep6rcwr54m3M/33qszX1M9/jcY4m9Ft9juo9bFWEAQBYCAODRarX0gQ98QK95zWvqPIwZSWcaYy6Uv/e934V/7lQgXwjwTQHqrstpl7NPrJjsR/Gf09saKhIVaM8p6H1Fnlsguvvk9gwPutDLGVnIOcZYMVy25z4nBLT0+N9Umd7/XoOA7zX59inu625nihCAoSEAAI6pqSlddtlluvjii+s8jN06VPy/XOHpN70U/r5vAC5+GVgoDLi9/b7RgNwRADm3ZQJAalvZ+fNuwejbJ/QY7nGnCn632C8+p+/+7jH5jsEn53Wn9q26X+x5c0ZNegkBbltqqlRX1SAQa0v9HTJFCEAtqg4RY3JMLd52P0hVuG15bk3gtvgzVbh1l0M/K5zbKWvtUyX9aT9frCQ98MADWrt2bb8ftoxdkrYZY35J8fn3ZYr/hcB+uScG+57fLfTLTAFSZFlOez/5iusy+6TuH9o/tW+v22NtkqR9+/bNSNooacPBgwc3GGM2SNpgjHmCpLWSZK093Bhz2OJd1unQ/80V3e2S9unxv429i/d5xBjzo+52a+0jkmanpqZmJc1KmrXW7lm3bt36xX1ioz6pv40qfz++sOm7TT2OSrS7fI8Zk/u4VREEgAnHCADgqLn4/74OFf8XK12Al+n178cUoLIhoNgmp02RZRXa5GkvI2f6TXG/VI99bi9uqrfb3Te393nZ9unp6R+uWLHi+BUrVhy/GIqPt9Yeb63d0i3yJW3odDqPfXNd6Evsqny/hXuf7nqn01nSPj8/v6DFQGCMmbXW7pa0wxhzj6R7Wq3WjgceeOCeY489doPivfipZXc0IDUC4N7Pt39xn+66+/yxHv+qozSx0YZeMD0ImHAEAKA57tGh4v9XlC6+YwW8O73HNxJQZgTAfW7raQ+NAqiwngoAxVsF1n3tsZ78nILfff7UfPzc4t63Hivqg9t27dr1w9WrV/+4tfa5kk6QdHzhZ5O0vOCu8cvqQqYkbZa02drHf3Xd5YWFBR1++OGan5+flrRDh/4/3GOtvcsYc9sDDzzwreOOO+7IwuPlhAIF9i+u5xb+7j6hMJAq2suEgdi0NIIAgMoIAEAz3CnpDGPMG9S/4j9W7Fc9Cdgt/ovHpkJb6EeFfUPFf24xUiyqQ9ul+Jz6qgV/TnGf6sF3GUmamZl5eGpq6qRWq/Vj1tofl3SSMWartXYqcf9xsXnx52Tp8SCzdu1azc3N7ZR0s6SbjTHfWlhY+PZRRx11uzFm8+J9ewkCvn1ywoGc54yFhGJbsd0qXowTBAD0VeO6iNA4E3cOQA3u0KGe/19V78V/qtiPnQRcbM+ZBmQD66kAEFpWoU2e9pTYfH23LXeuvq89ty25z549e/avWLHiBcaYU6y1J0t6jqQ1nuNG2D5r7TdbrdZXO53OjStXrrxpzZo1OX9rsfbQfqF9QvvltLtCo1ax/XL2z0UIACYAAQApBIDBulXSWcaYNylebOcW/rnFf2y6T870n9BogArLocK/TM9/qhiJvYf5im933deeaisbCiTJWGu/3263n9VqtU4yxrzAWvtCY8yPRY4fFVlrdxpjbrTWfskYc+O6detuWTwf4rFdNPgwUCbYptoIAgD6igCAFALA4Hxdh4r/tyhc/Kfm6Jft9S875Se38C/+FKcDybNdnlt3uapee/17CQDLCv69e/eeKOkcSS+S9DOSjogePQZlzlp7kzHmBmvtPz3zmc/83szMzOrFbb6/S7c9tJ5zH3efnPZQG0EAQF8QAJBCABiMr0k62xjz66pW/PfS65976c/cwr/q9B8pvwDK7ekPtZXpta+yj5Gkffv2PdjpdE6XdIak8yT9u8hxoz7Tkr5orf2MMebK9evXd8+H61cYiAXcnICQaiMIAOgJAQApTQsAj62PcAC4QdJ5xpi3Ka/4X3DayxT/qV7/2ChAaPqPr+gPTftxRwGk5YVETlETkjPNp7heubj3rVtrf7jYy3+GtfYMSS+RtDL76NEEC5JutdZeI+kzRx555E2L04V6CQNVpw25+/nWfY/lM4ggQAgAxgQBACkEgP76oqSXGmMukb/gDp2U2+uUn7LTf9ziP9XzX2bajwJt8mzPeY+qdAKuyhX7j91aa+/fu3fvaZ1O5+XGmJ/T4mU4MR6MMbskfcpa+3fr16+/oYcwUHWKUJUgMOwRAYIAMOIIAEghAPTPP0n6eWPMbylv3n3V4r/s5T/LjACEiv/YdJ/cns8cqek+seXSxX731lp7/9zc3CnGmF80xrzcWnt0iWPG6Noj6bPGmP+7bt26ayNhwHfb66hA04MAIQAYYQQApDQlACw7F2DEAsA/Srpwsfj39a77Cvbc4j907f9eTvotEwBCP1K4IHK3pZS9ok+s9z9V/D+2PD8/v8Va+58kXSjp2IzjxPj6gQ6NDFy+devWWxdPIs75W68ySlCmTYG23KlBjAYAE4gAgBQCQO8ul/TLxpjfUXh6TW7RHuvtz1mOPbav4M/p/VfhNjQKUFSm8O+qGgByi/3H1ufn558g6bXW2ldJelKJY8TkuEfSh621lx111FE/WmxLhYF+jRKE2pTYL7RP7HHKIAgAI4QAgJS6A0BLS6f/jFoA+JikVxlj/qfi8+sHXfy7JxKnev3d4j815Se3J1SqViik5vn3Uvwba+2evXv3vsxa+3pJ2zzPA/h0JF1rjHn/unXrrjDGrJf//4I87TnTiHy3qTYl9vNtT7XlIgQAI4IPOaTUGQC67aMaAP5W0sWLxb+voI710Oee4Jsz3z+3+PcFgCrFfz8L/65Uoe/b5i32i8v79u1bc/DgwdcYY35F0uYejg/YLenjnU7n/Rs2bJhebOtlVCAVImL7uAgCAJZopXcBGqnpHzDvv/zyyy92ev5zev2rFv++EYHY+QGx4r9Kz78vDCjQ5kr9W5Yp/o0Sxb+1tj03N3fm3NzcxxcWFu5cvCITxT96tUXSm1qt1jfn5uY+3m63z7DW7tPSzg9fh0ixs8RdLq4rsB77m1dkP992t60qOheBhuM/KVLqGAHoTvvxjQA8tm6tfZqaOQLwVytXrvzEwYMHX6L0tJ9+Ff+h/ULP4TseX8HvK/o7i6+ze5vbe1nktofei1K9++4+weV2u/2wMebVkv67+IIuDMduSe9ttVr/Z926dd330pxRgdg0ujpGBBgNAMYMAQApwwwAxbZRDQB/Yoz5kfxFdC/Ff9Xr//seP1X8+8JAqvjvrhfbcqYVxN6DYoW+extcnp+fX22tfbOk10p6YuT5gEF50Fr70RUrVvzpEUccMa/l/29ygkBov9C6Am2+9Zz/q4QAYIwQAJBCAMj3TmPMfoWL6dxpQP0u/ottoWNxp/m4bfLc5vRYFvWr1z81179b+B+7WPi/Qo//HQN16ki6qtPp/PGGDRu+p/j/oX4FgVQhP6zRAEIA0CCcA4CmSH04pD7U6va7xphHlJ5XP6jiP/QFYP0s/ovrZYv/rpx5yG5R7855dtsfW7bWzszNzZ0yPz//KWvt1yS9ShT/aI6WpJe2Wq2b5ubm/q7dbp+8cePGR+T/e/adK+D7CZ0TIM92afn/v7LnB4T2S6l6PwADwH9GpAx6BMDX+++OAHi/Ddha+3TVPwJgJb3VGLN2cT02naZM4V+1LfT4seLfFwJ8xX+oh1Ke9bJiJy5KywucZcvz8/PPsda+U9JP9XAcwLDdZoz5w/Xr11+v5VPuJH/Adqfc5YwWuNvdbb59XUwLAsYEIwCo06h/AFhJv75Y/Md6+hc8P7nF/4KnbVjFv9smz620vKgow9fz6PaGuj2dS5bn5+d/Ym5u7tPW2s+L4h+j5yettX83Pz9/RbvdPlnxXn+3UyXW4x/6vxUK0e76IEcDQo8PYEhW1H0AQECs96kJwcFK+m/GmA1aWiS7xXaZKUC9zvf3hYFeiv/YXOTi76GK1Hx+d31Z4dJut58h6e2dTufUiscANIa19j9I+nS73f6SMeZ3jjzyyG/LP9omHfp/EGqzevz/ig1se+xpC9vddetpDz2/73hyVL0fgB4xAoBR04QPiwVJrzHGHKV4ce8r6mPFftOLfznLVQJZ7jz/YO//7Ozs02ZnZ/9G0g2STi3x3MAoeIG19tq5ubm/2bNnz/FKjwi423z/lxTZ5oZuOdvddt9233ouzg0AasAIAOpQtojPmYs6LAuSfsUY8xQ9XixLywvrUCGeGwbcbVbDLf4VuK06CpPb4++2dzspzMzMzBFTU1N/KumlJZ8bGDnW2jNardYZc3NznzDGvG39+vUPKz4i0JG/17+47I4ALHvawnZ3dEBOe/G5GQ0ARgwjAGia1ElpdX5APCrp5caYJ8tfZMeuv++by+87JyBW/KdGDXot/qWlgaa4XuV37+vJd9vd9WU9mdPT0w/Nzs7+l6mpqdtE8Y8JY629sNPp3DE7O/tbs7Ozjyh8daCyy7H/kwqsy2lL7VsWIwHAkBAA0FRlrlYxDI9KusgY8ywtn+uf0yOf05vvCwbF4j/nOXop/t3bKsW/r+iPTe0JFSmtxUt6nrVy5crvGmN+V9JhmccAjJtVxpg3GWPuaLfbL7PW7tHyq60VpwTlFv++aUG+y4YWl31hIbZvWUwJAoaAAIAmKFvcD7v4/5Gk840xz5a/uPYV5rEr//hGBHyjA6Fe/lDxn/qpWvy7fFMAfIW+b3uy8JfUmpube9bc3NzXrbUfttZu8RwDMImOkfS+ubm5r7bb7RMU/66A0FWCyozKKbIupy21b1mEAGCAOAcAdcst9mOjAYP0sKSXGWOep8cL5+4xhHrVcwJCaMpPqC2n+I/1/ucU/7G5/zncD2y3SPAVF8Xl1vz8/BMWFhb+P2vtz2c+JzCJTpJ0fbvd/n/W2ks2bNhwUMtP3vfN/4+dH1DUPRfAJO7n3qco9Rw5qt4PQAIjAGiS0EhAP05CreJBSS81xvyU8gv51PSfWPEfOx+gas9/meLfN9XH93uOTfOJbQ9NSWhZa2fn5uZe3ul0bjPGUPwDaUbSK4wxd7bb7YsXpwUt+X+l9P/BUCgPXUkotC6nzd3mu18uRgKAASAAoC6xAj6nuB90AJiXdGah579Mb37OF3+lLgFatvh3e//dnsCc4r+oam9dKBgE5yW32+1N8/PzN1lr/1zSmgrPC0yywyW9Y25u7obZ2dmNCoeA0PkBVS4hKs9yqMAPjQyWQQgA+owAgEEo20vv69n3TfkZVu//nKSzjTEvUPke99CVeWKFfqrXfxBz/mOjKlWm/sTmFnsLjR07dszPzs6+QdK/Wmt/OvO5APj9jDHmlna7/RZr7bzyzg+InTAsxUOB5C/ufUGgH+cFVB1BAOBBAEDTxaYBDcK0pFONMS9UvMiPXQWozPSffkz5ifX+u+ty1nsJVaGCv3sb/OKidrv9jHXr1t2yeHWfVRWeG8ByKyVd0m63vzk3N/csLQ8AsS8UC43aScv/j+eEAqYEAQ1GAMCw5Z7M6xb6wxgF2C1pmzHmdOX19IcK+dg0IN8Vf4ZV/IdOEixzgnWspz/Uk/hY0dFutx+enZ29RNJ1kp6VeC4AFRhjTrDWXttut39vZmbmIYUL/1hQ72U0wPce4W7z3S/7JVa4D4ACAgD6rerc/tBJqMX7DXIU4D5JLwoU/6Gr68Tm/Kcu/5n68q86in9Xqth394nNL24t9kj+mzHmTeK9Bxg0I+n1U1NT32y328/Q8ilBOecE5IwG+EYA5aznjAaURQgAesCHMPqpSvGfKvh9U1f67V5Jpxljzlb+/Hq3SA+FhNh9UvP/B138l1E5BFhrZ2ZnZ19nrb1G0pMqPDeA6p4i6QvtdvvN1tpZecK58kcDckNA1SlBZRECgIoIAOiHVEEZusJMbru7rZ9BYIfyiv/QtJ3UF3bFrgRUZcpPL8W/K+d36Psw980FdguGlqTWnj17jpibm/uXxbn+vN8A9ZiSdMn8/PyNe/fufaLiISDWHgoMUrjwl8qFgLJFPSEAqIAvAsOgWS19g7ae9rLTfvoVAO7UoTn/5+tQ4Rw7htBPr6MAwyz+c6b+hD5MYyGgOE3gsbY9e/ZsM8b8pbX2iYHHBDBE1trndzqdr7fb7bceddRRn9Hy9w+jx98LW1r+PmEK+0nL38fd93pTeAz3vaX42CaxnhL7LAHgQQBAL9w3/Cr7+wr97m1OW1W361Dx/1L5i3/f84aK7VSRHrssaG7hP4zi3yfWi7ek4O/+PPDAA48eOHDgg9bany3xPACGY6219n2zs7OfXLFixevXrVt3mJYW0L7CX3q8+C9uLwYCNxx0H88tzk2h3S36ffuVUeU+wERiSB6D4vbSh5Zj66kQUNUtkl5sjDlXeT31qev6l5n6k1P8+7b1q/gvw9frH5oi0NKhXv9nP/roo7dR/AON9wsHDx68td1uP13O1D0t/7+de4LwIKYElcWUICADAQDDkDP/PDZPvV8FrSTdrEPf8Nvt+c+ZsmMVnuufcyWgVGAYZvGf83uLzfP3zgk+8sgjH5ydnb3QGPNZSUdnPAeA+h1nrf1Cu91+lfIKfl9HgLT8PYMQADQcU4BQRdmpP7HHcddDRaxve1lfknSuMebnlFcUhwprd/qPbxpQqsBPTRMKPfYgi//Yh7P7wS8tfvjPzMzsa7Val0m6MPH4AJpnhbX2HbOzs889cODA644++uh1OvR/2z0noKs41cedMhQ7R8Bd921zn4fpQMCAMAKAuqWm/8RCQBnXS9peOOE3p1CPXbbTt5w7VcgmnicUKAY17cfXS+eb7rOsbWZmZk2r1fqKKP6BUfdLK1euvGl+fn61/N8enBoJdKcBhfbrCr3nyNPuLudiJAAIIABg2GK9/r59its6Wh4Iclwn6TxjzHnK76lPTduxge2xKUA5U4FsZLlM8Z8jVvi768vmBc/MzPxUq9X6F0k/lvl8AJrtJxcWFr6+Z8+en1a5wr/JU4IIAYAHAQBlpArL1HST2NSUUFt3ueMs5xa5n9Whnv/uCb8503Vy5unnXNu/l59ei//Y7yfW05bs9bfW7tqzZ88bW63WFZLWRZ4HwOg5whjzqT179vw3a+0u+b85OPQNwtLy9w5FlhVYJgQAA8Z/CqRMLd7mzAmPfTC4Q8mhn6nA+pTbZq39KUl/Gjn2z0i6cPGE3yXz1j2vpys2BalYfMfm6ecGjdB9Bln8u1LTf6TCB/vc3NwDnU7nE5K2lXgOACPIWntlp9N5xebNm9fp8U4Y931QCr8vFUdvpeXvV7GRX7cttl8uzgkAFjECgFyxN84yb6q+Dwff9tD+xeI45u8k/bwxZrvyi3Lft/1a5c39j13xJ+eyoL0W/8XfV45UeFsW4ubm5qY6nc5NovgHJoIx5vypqambZmdnU73/sR9l7KfCrQJt7rbQ9uhLKrk/MLYIABiGVG9OqJBNBYGQ/yfpl40x5yg9vcZX9OdM64ldzafspT5DIwix4l/y/35yhEZzpPD1/Y/udDpfkfTszOcAMB5+QtJX5ubmNss/apsKAN4vDPT8SIQAYGgIAOhFqgc698TU2JSW3J7vrg9KeqUx5izlT73JOVHXXU4V+7HRhtA23+sNFf/FEZCyxb9vPm5oypbZs2fPScaY6yRtyXwOAOPl2E6nc3273f5J+UcBQicLVzkvIPT+5OolBAATjwCAQcgpRn2FvFv4+tpjAeB9kt5gjDlT8WI75wTf0NSg3G2pqT6hqT++2370/OdO+VkSCGZnZy8wxnxC0uEZzwFgfK2x1v5Du90+X+FRgNA3Ccd6/H3L8iz3MwQQGDDxCAAYttCJX6npQakA8KerV6/+38aY01Wu1z0WDNzi3ipc7PumEqWm+lQt/n3LMbEPXe+HtbX2/tnZ2d+Q9Od6/ERwAJNthbX2L2dnZ99qrd2p+HcF5PwosizPsi8IEAKACvgmYPSL1dI31G5h6msrFrXGuS3z0/W/jTFXSXqKDhXOxWDbHUkoHp/vQ8QNIrHnjRXtvmI/tk+q6O+l+A/Nq3U/SJd8aN93330zs7Ozn5L0s4nHBzCZ3tRutzdZa19tjNmspaO2rcJyilnct/teZJ3l7j7ucrEttE/u8+fuC4wVEjBSusWh5O+dcQvJ4n18w7+hy3/Gtnn3sda+QNJmY8xnEs/pFsKhv/tUCIgV7WV6+Oso/kPzapf82+zatWvPihUrrpB0RuLxo3BJPwAAIABJREFUAUw4Y8xVDz/88IXHHXfcRi1/b/O9b6be32Lvczkjx6H9UwgBmDgEAKT0GgDcgjxU0OcEgCX7WWvvMcY83fM4OUPNIcUPnU7hNvRh5ivoQyf1xtrLFP/FW59KxX+73Z7vdDpXSTol8tgAUPRFa+35mzZtWq/w+1p3WfK/r0rh9z7fbagttE8OQgAmCgEAKW4A8N26l3mLjQTEvhys7AiBL1z0EgBiH0axMFCmt98XIGK9YzkfeEWhkCb5/52MJLN3794HDhw48FlJz488NgD4/OvKlSvPWbdu3RMVDwHS8vdTOdvcoEAIAAaAAICUKgHAFwRSU4FSbanl0HPnTgEK9bj7Cv9Yz35OMBhE8e97bb4QtCycTU9P72+1Wlfr0PW+AaCKWw4cOHD2Mccc8wTFOz18nSrybHe3+W5DbaF9chACMBEIAEjJCQChXvdQz3yoFz81EhCaTuQ+T/e4i8dWPN7icuiDJPUTK+5z24ZZ/HsDUrvdtp1O5xpJJwQeGwBy3SHpjI0bN7YU7izJff/zvR/LcxtqC+2TgxCAsUcAQEqxqJbCPerG2T8WBHKnAcXCgRsCfM/tHpsU/pv39f7HCv9Yj37Zef79LP59r9377zA/P28OHDjwz8aYpwYeGwDKumdhYeGMLVu2HFB8hLRKCPAty1kOhQACAFBAAEBK2QDgm/6TOxKQM82n6tx/eZa7ckYBck8Izg0KUnzoW551V+Xif/fu3VNTU1PXSzo28vgAUMW9xpgXbdiwwah/IaB4aVFCANAjAgBSYgHAXa8yDSg1LSjV+5+a6iLPrU+s51+Kz2PNKfpDxb/v5LecD6syxf+SMDY7O3uw0+lcT88/gAH6rrX2xZs2bVqp9Ahp2ZGA2HslIQDIQABAii8AhG5TAaD4eGWuEOQr+H2BQoXbMgHA92GS+5N7xYtYj9agi//H1nft2vXwihUrvijpWZHHB4B++LdVq1adesQRR6xR+KpoUn4YUGC9eCtnuwLbcxECMJYIAEgpGwC693FvQ4Vpzkm9uSHAVwj7jrXI9+ERmpqTEwCkah9moWNxVS7+5+bm5judzhestf8+8vgA0DfGmFunpqZOX79+/RGKTwUq876Ze3UgQgAQQABAihsApHBx7QaAnGlAoeXcfcsEADnroWHjUA99KgBUHc52l0Nyin9v6JqdnW1ba/9Z0vMSzwEA/fblTqdz1ubNmzdoMCEg9F4uLX9fJQQAerxYAGJib3yxnmzfvqEeGnc55wTb0Lfyuj8LnvWFyHb3G3tTP9azHDvOYpvv9+JTufi///77pyVdKYp/APX4mVardcWOHTt2q9zUzlgnj2+6p+R/r5Rnu28bMDEIAKgqpzck1BOT2xMe6233Feg5YSAWDnJCQaj4D4WTUAgo/o5ye5ZKF/87duzYtWrVqk9ba1+c+RwAMAjb1q5d+3fW2vuVvuhDbgjwvSe67VJ/QgBhAWOFAIBB8I0GhHq7UwV+KAT4blOFeaiALxMYUiEgdNy+30tu8R+b2hQs/teuXfvA2rVrL5W0LeM5AGDQzp+dnX2flhb6vYSA0HtjUU4IyEUIwNjgjxkpxWHWotAc+9hQbeykYN8HQGzf3Hn/uX/juaMQOdty5qiW6fUvXfxLas3MzPyqpLdmPg8ADIW19nc2b978N0qPlpY9x0qeZTnL7ntvqD36EkrsCzQSAQApbpHtthfX3fbQycBu8Rrq8Yl9w2/osUPHl5IbAHxtsYI/NhqSkir+Q8HJzMzMnC7pA5nPAwDDZI0xF2/cuPEGVQ8BoYs0yLMsT7sC27NfQ4l9gcYhACAl1tMfug0VqlK6qI+FgOJtv3v/3ducaTu5H0C+9RT3NWRfWWl6enqrMebTklaUeD4AGKYDxpjtGzdu/J7Kh4Due2+dIYAAgJFGAEBKKAC426TlxXgsDPTjx33c0HHlCPXUlx0ViD1WGTm/u2U9/+12+/CFhYUbJa0r+XwAMGxtSads2rRpv3oPAbkjsG67AttzEAIwsggASOklABSXY9N3UlOFUsW/+9y+Y03x9di77TkfMGU+ZELHWbb4Nzo05/8RSV+RdJzvBQJAA+3odDo/s2XLltUKh4DUNKBYCOi2ScvDQOj9malAGHsEAKSkAkDqtsyUIN8+qfDgLvuOM0eocC+77Ft3H78oFqrKFP8zkm6Q9NzA8wBAU31tYWHh1KOPPnqT8kNAsejPnZop9T8EEAAwkrgMKHLkviGmhlpzpsj4enlyhoRDl//M+Ql9p0Dsudxledbd15pSqfi31t4j6aOi+Acwmp43NTX1AfkvCdpd97XJaVPgNjTSqsC2WHvV/YBGIQCgF7HCNjb3srjd10sTGub1Fe2h4r/Kdfxj62UKf1+vUxmhIOD9UNyzZ8/vSHppxecCgCb4pZmZmZ/V0hDge8+LrcfCgK/gjwWD0HYfQgBGDn+0SEm9aca2x+brh6b0hIrf2GP41kOvIze0VF0vI3T8se9AWPLhODs7+9ROp/NZSVMlnxsAmuagpDM2bdp0n/I7Y3JHfeVZlqddznamAmEsEQCQktNrEpuDnyrgc9pij+NbDx2nK+cNPzV/1Pc4oTbf7617G/od+L4dsyWptXPnzoNTU1NfM8Zs8DwXAIyi3caYkzZu3HiY0iGgX18UFir2CQEYW0wBQq7UG1tOT3jojdc3dcb3xp7qAUr1EuVMIfLto8Tjua8v50Ogl+Lf3HfffT9csWLFJyn+AYyZLZ1O5xPf+c53fqjl04FypgApcJtzHkCskyYHnaoYGQQAVBHqJQntGwsE7j45PTWh4j9U6Ifm/ceCgO/qE77Xk9PjlCM2h9U9Ma61evXqv5T0UyUeHwBGgjHmP6xbt+7PtLzozz0vQAqHgKojxhT3GCv8QSMlNfffXa86Fci37N6mjqXXv+fUMHBqP5/Ycabm/fs+/Mz09PS5xpi/yHhuABhZ1trXbN68+Xr5R3ljV2yT/J1CKmz3dTC57XK2MxUIY4MAgJRQoR/blip6u7c5c/pz5vtX6c2R0iMTvvWyb+yh30lO8b9s+s/u3bs3t1qt6yStLHkcADBqfrT4JWEPKl789/Ok4FRHECEAY4EAgJR+BQDf9l4K/1Svf78CQGp6U+p5UwHI/Vk23ad72263Zzqdzq3W2uMjxwQA4+T2AwcOPO/YY489RuHvahlGCCjbEUQAQKOtqPsAMHKswsV1d1txn9Byd13O9u66LezvrrvPKS0PB1V6acoW+9129zXlzCWNFf++9tbCwsJ7KP4BTJhnr1ix4n9L+nMtfU/saGmHSrfN97liPLdS+H3d9xlSbMv5jCnzOQQMHSMASElNr4ltLzMSUOY29jz9kDoHICQ23afYnrrO/7Ivwpmenn6upMszjwMAxok1xrxs06ZNtyv9BY6+3n/feQES5wNgghEAkJIzxSa3MC8TArrLZYr+Xv+e+/FGnTOtKdTr7xb/Roe+7OtHCwsLt0k6sg/HBwCjaEbST27evHmNwiHAd4W32EnBTAXCxOIyoOiH3B4S3xun++br7uP22ITmd7qPUeXHPdYy+/ukin93+HpZOLDWfm9hYeEjovgHMNk2SfrrtWvXzit8OdDQqKo87b73YTnLuaPZMXS0opEIAKgqVQBXCQHdZV8QiPXYFHuBeg0BqccJvZYY34dJat6/kWRmZmZ+S9Kpmc8DAOPs7O9973u/pPRIqlvc+9rc9+NiW1E/RpsJAWgc/iiREvsbiU3xcdtS++b0wpR97kEKHWds2feB5bttSWrNzMystdZ+WVzyEwC69ltrn79ly5YfKfwlj2WvDiTFzwdgKhDGDlcBQi+slhbC7nqxLbWvbz/r2dfdJmd9EEHA95i+1xq7b6zXf1nxf999991nrb1VFP8AUHSYMeb/Wmt/2hjzFC2f6tO9OpAttHcK93fXpfDnjwncylnOUXZ/YKCYAoRelen5CE3tcddjc/J9c/99+/Xrx3ecOXJGCILTfw477LDflfTMks8JAJPguTMzM29TodNETidKoU3yT/+JnQ/gU2aEu8xjALXgjxEpVd7YQveJ7ZOa5lP2jbnM9hypKUo5HyzFDyh3vfhtv08wxnxN9P4DQMj+Vqt14saNGxfknwoUmgYUu0Ro8dY3Fchtl7OdqUAYGYwAoF9y5kOG3lDdbW5bzsm4qRN7y/T4p15fUZlA4PZILZsKZK292xhzmSj+ASDmsE6n8941a9bMKa/jJTYaELoN9fDndHKF0PGKRiAAYFBiBXWst6SXqT05x5O6b+7jheRO/fEOWU9PT/+GpJMqPjcATJIX79ix4+cVHlkNBYHcqUCpkelYG9Bo/NEipezfSJk3x35N8cndr9fpTO4cUd8HRsuzHCr6lwSAnTt37p+amvo3SU/MPE4AmHT7pqamfnzDhg2HKzz9x3eFoNDocNWpQDmj4EVMBUKtGAFAv4V670P75pxo249e/tD20OOk+E4YK64Xv3wmNPz82M+aNWvmpqam3i+KfwAo44iFhYW/kv8E4Fjvv5z1XqcC5XZoAY3AHylyVP07yZ03WXX/svvF7hcauYh9WBR7+ru3oQ+c6NUqpqenT5F0acXXAQATzVp74ZYtW74lKXRSsHtCcM73A4Q6klRY5oRgjCQCAHL08ndSZb5kv+ZYlg0QvgCSCgCxE86i8/27P/fff/+eqampuyQdmfvCAABL7Gy1Wls3bdp0rA4V890gUHYqUJmrAoVCgW85hBCAWjAFCDl6eYMqMyWouD12cm7u8ZS9wk/u44aGjX0n+yYDw9TU1NtF8Q8AvThmYWHhtxQ+ETg0PUjOcnH6pnsbmiZURMcqRgJ/qMjVj7+VfrxZDupkYd8beugDIqf339frv2wUYOfOna1Wq/UN8a3cANCrR40xz9m8efOUHu/tD40EuFOCmAqEicIIAHL14w0qdnnPMo8Ruk+ZHv9e3qxzev/dqUDeKUCtVutdovgHgH5YZa19p/IuCapEmwK3OScEA41HAEAZ/QoB/QgCxfuEiv4qj5mS+mDIvc50a/fu3VslndXn4wOASXbB7t27/53CVwKKXSkoVfzL2UfONl9bTjAgPGDoCAAoq18Fda+9+KnHTQ3PKtAWeiOO9frHRgC8H0Lf+ta3vivpnemXAwAo6V3W2u8or+D3nauVCgcq7B9bT7UDtSEAoIp+9qrnTtfpZYSguF5Gbk9QcT2r12nTpk2/LumEkscDAEj78enp6f+sdE9/6L1bnmX3NrStqEzhT0jAUPEHh14M4u9nEG+YJrDs2+Z7g295bn09SNHLfS7+TO3atWuPMeYuSesyjx8AUE770UcfPeG4447borzvBhjUCcFlOqE4GRhDwwgAejGIN6syPf65owKDGgWITfsJ9jq1Wq0/EsU/AAzSUatWrfofyp/7726TZx95thXb5FlnFACNxB8b+mWYf0ux58qZg+lbTvX+h+aGupf4dD9sporr09PTB6y13xZX/gGAQXtkxYoVJ2zYsOEILb0k6IKqfUFYqPc/dd4ZowBoHEYA0C/DfNOK9fyXbY9J9fbHRgC8PUzW2t8TxT8ADMMTDh48WPxysJyOnNS0UPe9v7hvaJ3OVjQOf5Tot2H/TZU58Sp20las1z/1oZE193/nzp221Wp9UwRvABiWA9barUcfffRqLe3td88LcL8YLDQiIKdNnmW3TZ5tMYwCYOAoRNBvg7j2fur5ctpiQnM5Yz39oZ6i4LzSVqv1++L/HAAM08pWq/U/FLkss6ddWv6e72tLdXhV7RCjcxYDxx8ZBm1Yf2Op4dhuW+78/+KHg5Txrb6e9u78/6ldu3YZY8ytIgAAwLAttFqtH9+0adMK+Xv/Y1cHCs3/L44GSIwCYMRQjGDQhjUikHqOnCAS690vbvfNFXW3LwkMi1f+4f8bAAzfVKfT+W0t76gJjdxKyzt8QlNIY58tOaMEsfsCA8MfGOowqL+72ElXsTfv2ElebsEfm+/fbZ8q3t5///2HtVqtf/UcEwBgOBY6nc5PHHvssVNaejWgYk9/7OpAoSsDSYwCYATRI4k6VP1m317lztcsEw5Sc0mNMeaPMp4bADA4U61W6/cU7sgJnddV9huCXb2MAgADwx8lmqaXv8nUCIDvzT23998dBYhe8ae7vHPnztXGmK/28JoAAP3R6XQ6z1kcBXCvBhTq/c/9hmBGATBSuB45msZ9s+tHSM09Qbh76wsH3dvYvNFlowHGmLf24fhRI2ut9u3bpwcffFD79+9Xq9XSqlWrdMQRR2jNmjV1Hx4G4IEHHtC+ffv06KOPSpJWrVqlNWvWaN06vsB7xLVardavS3q3/O/ZHU97cZt06DPKFJa7TGKb2wbUihEANF2Zv9HQMKyv91+KX++/uz13/r/7rb9TO3fufMAY8x0RtEfKwsKC7rrrLt15553asWOHdu3apQMHDnj3Xb16tY4++mg94xnP0NatW/XUpz5VxvC2Okqstbr77rt1xx136O6779b999+v/fv3e/dduXKljj32WD31qU/V1q1b9YxnPENTU1NDPmL0aH+r1Tp+8+bNm7T0HIDcKwPFRgFC3xQsMQqAhuGTCqMg9+80JwD45nGGLvUZmysaCgCPhYBdu3a9RtKbM48dNWu32/riF7+om2++WQ8//HClxzjyyCP1/Oc/Xy960Yt0+OGH9/kI0U8PPvigbrjhBn31q1/V3r17Kz3GmjVr9LznPU8vfvGLtX79+j4fIQbFWvv2Y4455hNaPgWoymVBfUFAWjpioMKyu+7u4z3kUi8QyEAAwKjIPYE3tBwKALlz/1OF/5Ll2dnZew8cOHCfpCPKvEgM3759+3TVVVfp5ptv1sLCQl8ec9WqVXrJS16ibdu2adWqVX15TPTHI488oquvvlo33nijDh482JfHnJqa0sknn6xzzz2X4Dca2pKefPTRRz9Nywv/VBAIfUswowAYKQQAjIpeAkBu73/3NnfKT3fblHu7a9eu7ZL+V95LQ12+8pWv6Morr9SPfvSjgTz++vXrddFFF2nr1q0DeXyU861vfUuXX3659u3bN5DHP/zww3XBBRfopJNOGsjjo3+stf/1mGOOuUH+k4FDvf+MAmBsEAAwKnIDwDB7/6d869baO6anp++21j6p9KvEUDz66KP62Mc+pltvvXXgz2WM0bZt27R9+3bOD6jJwsKCrrzySt1www2ydvB11E//9E/rF3/xF7ViBaf/NNiOLVu2nGCM+TH5C3/fSICv939YowAEAPQVn0YYFbG/1Zze/+J6qvc/55Kfxd7/JVOAdu/e/Vxr7YdLvDYM0cMPP6xLL71UO3bsGOrz/sRP/IRe9apXURQO2cGDB/W3f/u3+sY3vjHU533KU56i173udUwJajBjzC9s2bLle1re+9+9TV0WNPalYL4vCVNhmVEA1IovAsMoKHsScM62sqMDcrYHv0reWvsbmceLIXv44Yf1nve8Z+jFvyR985vf1GWXXda3eedI279/v9773vcOvfiXpHvvvVfvfe97g1cUQv2stb+p8Chw9/3dFfp8yL2NoVMWQ0MAwLhyC/diu2/dt3/sA8GdLmQktaanp9dIem6vB4/+O3DggC699FLt2rWrtmO44447dOmllxIChmD//v36wAc+oLvvvru2Y/jBD36gD33oQ307uRx9d8r09LRvmmfxs8A3+hs7ryz0GeMKjVzHEBDQNwQAjLqc3pVYL7/b7uvVl3M/d5/HPhwWFhZeV/WFYLCuuOKKWnr+XXfeeacuvfTS4HcLoHfdsFdn8d9111136aqrrqr7MBBgrX2t4u/rsY4ho+UdQ+4+bnvxFqgNAQDjKKfHpbhepvff98ZvJJl2u323MebCno8efXfbbbfpy1/+ct2H8Zg777yT6UADsn//fr3vfe/Td7/73boP5THXXXed7rjjjroPAx7W2lfef//9d2npe7rb0+8bAQh9Ziiwzcc3CkA4wFAQANB0Vd8Mc+b4h9pTb/C+ENDav3//KyVxxl/DHDhwQFdccUXdh7EMIwH916Se/yJrrT71qU8R+Jpp3dTU1IXyF/9uD39RzrkA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"/> + <mask id="mask-0"> + <g filter="url(#filter-remove-color)"> + <use xlink:href="#source-35"/> + </g> + </mask> + <g id="source-34" clip-path="url(#clip-2)"> + <use xlink:href="#source-28"/> + </g> + <g id="source-37" clip-path="url(#clip-3)"> + <g clip-path="url(#clip-1)"> + <g mask="url(#mask-0)"> + <use xlink:href="#source-34"/> + </g> + </g> + </g> + </defs> + <g clip-path="url(#clip-0)"> + <use xlink:href="#source-37"/> + </g> +</svg> \ No newline at end of file diff --git a/src/assets/cursors/macos/resizedown-1__50-50.svg b/src/assets/cursors/macos/resizedown-1__50-50.svg new file mode 100644 index 000000000..9416c506e --- /dev/null +++ b/src/assets/cursors/macos/resizedown-1__50-50.svg @@ -0,0 +1,7 @@ +<?xml version="1.0" encoding="UTF-8" standalone="yes"?> +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="768" height="768" viewBox="0 0 768 768"> + <defs> + <image id="source-24" x="0" y="0" width="768" height="768" xlink:href="data:image/png;base64,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"/> + </defs> + <use xlink:href="#source-24"/> +</svg> \ No newline at end of file diff --git a/src/assets/cursors/macos/resizeeast-1__50-50.svg b/src/assets/cursors/macos/resizeeast-1__50-50.svg new file mode 100644 index 000000000..d52b7cc31 --- /dev/null +++ b/src/assets/cursors/macos/resizeeast-1__50-50.svg @@ -0,0 +1,7 @@ +<?xml version="1.0" encoding="UTF-8" standalone="yes"?> +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="767.314286" height="746" viewBox="0 0 767.314286 746"> + <defs> + <image id="source-24" x="0" y="0" width="768" height="746" xlink:href="data:image/png;base64,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"/> + </defs> + <use xlink:href="#source-24"/> +</svg> \ No newline at end of file diff --git a/src/assets/cursors/macos/resizeeastwest-1__50-50.svg b/src/assets/cursors/macos/resizeeastwest-1__50-50.svg new file mode 100644 index 000000000..4db1b5f5e --- /dev/null +++ b/src/assets/cursors/macos/resizeeastwest-1__50-50.svg @@ -0,0 +1,7 @@ +<?xml version="1.0" encoding="UTF-8" standalone="yes"?> +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="767.314286" height="746" viewBox="0 0 767.314286 746"> + <defs> + <image id="source-24" x="0" y="0" width="768" height="746" xlink:href="data:image/png;base64,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"/> + </defs> + <use xlink:href="#source-24"/> +</svg> \ No newline at end of file diff --git a/src/assets/cursors/macos/resizeleft-1__50-50.svg b/src/assets/cursors/macos/resizeleft-1__50-50.svg new file mode 100644 index 000000000..d64ecab29 --- /dev/null +++ b/src/assets/cursors/macos/resizeleft-1__50-50.svg @@ -0,0 +1,7 @@ +<?xml version="1.0" encoding="UTF-8" standalone="yes"?> +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="767.314286" height="746" viewBox="0 0 767.314286 746"> + <defs> + <image id="source-24" x="0" y="0" width="768" height="746" xlink:href="data:image/png;base64,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/Pvrc0/7SQX/35C0X9JpFdcwCOecXvKSl+hNb3rT2E0BAGA0JADA9HUt/bHW1QT/G8Z7bc//IUl3SDq14hoGsbm5qRe84AW66qqrxm4KAACjIgEApq3Puv8uwb81Hwb+saf9bEq6VdIjK65hEIcOHdLll1+uq6++euymAAAwOhIAYDWU3PQbm2+XxYL91I2/seBf2gr+HwOg/JoAACAASURBVFF5Hb07dOiQnv/85+vd73732E0BAGASSACA6arp/Y/tU3o/gPUeJgWx4H9bGZBz7hht1fx/a4f29+ree+/VZZddpve+971jNwUAgMkgAQDmL5colJT++Mti9f2p+Tb4P1ZbT/s5qfvl9OPgwYO65JJLdN11143dFAAAJoUEAJi3vur+U8G/tDP433azr7aC//tpK/h/+KIXtaiDBw/q4osv1h/8wR+M3RQAACaHBACYptryn9K6/1Twn1uW6vn/Jm39wu9DK9vduwMHDujCCy/UDTfcMHZTAACYJBIAYHpKg//YdrG6/nCbcH3qST/+thvaHvyfIOljkh5S2O7B3H333TrvvPN08803j90UAAAmiwQAmL9YgG8tS930K6Xr/bcF/toK/h+ireD/hMUuYXF33XWXzj33XO3fv3/spgAAMGkkAMA85Xr/w/nSm37D5e18+NSfDefct0r6iKTjO19FT+666y6dc845uu2228ZuCgAAk0cCAMxP6sbf2DJ/XSoRyD3xp+35P1lbwf8Du11Cf+68806dddZZ+tM//dOxmwIAwCyQAADTUnPzb9en/vjvYd1/eKwd9wM4575N0oclHVfR1kF89atf1Zlnnqk///M/H7spAADMxkZ+EwATUnuDcK7H31oee9rPhnPuuyT9oSYQ/H/lK1/R0572NIJ/AAAqMQIATEefvf/W9qWlP9L2wL8t+zlN0gckHVvRzkF88Ytf1N69e/WJT3xi7KYAADA7jAAAq8fq/bfWWduYyYBz7jGaSPD/hS98QaeffjrBPwAAHZEAANNQ0vsfe65/ae9/7qk/1g2/G865J0i6ThMI/j//+c9rz549+pu/+ZuxmwIAwGxRAgSsllTvf2obMxlwzj1J0rslHd1/U+t87nOf0+mnn65PfepTYzcFAIBZIwEA5i03chDr9Y/96u+RbZxzT5b0Lk3g78RnP/tZ7dmzR5/+9KfHbgoAALNHCRAw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"/> + </defs> + <use xlink:href="#source-24"/> +</svg> \ No newline at end of file diff --git a/src/assets/cursors/macos/resizeleftright-1__50-50.svg b/src/assets/cursors/macos/resizeleftright-1__50-50.svg new file mode 100644 index 000000000..eeab5ecb9 --- /dev/null +++ b/src/assets/cursors/macos/resizeleftright-1__50-50.svg @@ -0,0 +1,7 @@ +<?xml version="1.0" encoding="UTF-8" standalone="yes"?> +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="767.314286" height="746" viewBox="0 0 767.314286 746"> + <defs> + <image id="source-24" x="0" y="0" width="768" height="746" xlink:href="data:image/png;base64,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"/> + </defs> + <use xlink:href="#source-24"/> +</svg> \ No newline at end of file diff --git a/src/assets/cursors/macos/resizenorth-1__50-50.svg b/src/assets/cursors/macos/resizenorth-1__50-50.svg new file mode 100644 index 000000000..3a44a0bc5 --- /dev/null +++ b/src/assets/cursors/macos/resizenorth-1__50-50.svg @@ -0,0 +1,7 @@ +<?xml version="1.0" encoding="UTF-8" standalone="yes"?> +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="767.314286" height="746" viewBox="0 0 767.314286 746"> + <defs> + <image id="source-24" x="0" y="0" width="768" height="746" xlink:href="data:image/png;base64,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3OvxjvbavlcB/epCXOEPAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACwu/8fupDc8ISVbIoAAAAASUVORK5CYII="/> + </defs> + <use xlink:href="#source-24"/> +</svg> \ No newline at end of file diff --git a/src/assets/cursors/macos/resizenortheast-1__50-50.svg b/src/assets/cursors/macos/resizenortheast-1__50-50.svg new file mode 100644 index 000000000..cec926f0e --- /dev/null +++ b/src/assets/cursors/macos/resizenortheast-1__50-50.svg @@ -0,0 +1,7 @@ +<?xml version="1.0" encoding="UTF-8" standalone="yes"?> +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="767.314286" height="746" viewBox="0 0 767.314286 746"> + <defs> + <image id="source-24" x="0" y="0" width="768" height="746" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAwAAAALqCAYAAACCKTPeAAAABmJLR0QA/wD/AP+gvaeTAAAgAElEQVR4nO3d289t610f9t+zbCUktBKHbrdGQWmjIlGo3diAlTZtMbu4CYVAAGMwEM5gSENoE3qIqvI3VEJc2L7qBRe9iKoKVYC9bf6C3oC46hVJGlpoI5QitVW81+jF2mPt8T7vcxpjnsaY8/ORpuac4zDnfOd6D9/vM54xVwQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABcU7r1CwAYdIvfV1ue0+9V1phutO+enwu4MH+ogCO49O+qNY/v9ybXNhq+rxHSFQG4A/6QAXt3qd9TI497jd+Rfg/fpz2E8Uu9BiUADs4fHmCvLvH7qfWY53i+Ux7D7+P7siUknxqsW/ufO7QrAXBg/uAAe3XO30+1x7rEHH+/V9niEqP5tX3OGd4VATggf6iAvTnX76VzjfaXtr1GOeH4zhWOzxHkS9te+vUBO+UPD7Anlwz/o499auD3e5UtrhXmL3VUQAmAA/GHCtiLS83B33Ky76VOEPaxovdnbfC91LkB+TZbphQpAfAg/GEB9uLcJ9Cunat/jrn9l54uxP3ZEsTXrL92EVAC4AD8YQL24JrhfzT4n+Mx1jzmlu3Yly0j9aPbbfmEn1MeQwmAO+aPDHBr5wz/p4b20TJwiXLQ4nf1Pp0j6G4N6Wvn8o8eFThXEVACYMf8UQFubevvoS0j+WtG+9fut3UKkd/D9+/UaT69UL+mDGwpEbf4Pw2AC/KHB7ilW4f/1rK160afs7fPKdtwXZea8rMluLfKQCvQKwHwgPxBAW7pHJ+K0wv/I8G/FdpHH+ccRwlq/K7ev1M+DWjLtJ1a4D9nEVAC4E75owLc0trfQaeG/zWj+qXl5ywDre228Pv8vM4dXEfD9EiAX1MG1hxNuEQJUABgh/zBAG7l1uF/TcBPg/uXnmvreQcc35q5/1vCfb58TRFYc+Sg9jyjlADYGX9sgFu4ZfgfDf5rC8Lo85acYyoU13POqTC9Ef/RMN9b1ioMI6+lt2+LAgA74w8IcAunFIC1I+7FAD9N04uI+LMrXwfs2f+dUvqz8TzIrykCo+cQOAoAB/beW78AgI6tH7XZm8bztRHxxYj4mu0vDXbloxHxvzTWT/H0Z2AO5Sm7ne8Tle2EejioF7d+AcDDOfeRx9ZUnfl2Hv5TSukfR8SbEfFPz/x64JZSPP+eL/4MFO7XHq92v3RuDHAACgCwZ6NTf9Zs9zq0pJT+SSgB3JcUr/62p+x2vi6iXBQiW5cvL90HDkYBAPZqS/jP17dGQVO8OhLwv4USwP3Iv9+Xt2vrStf549Wep7UNsFMKAHBN5woKo5/GM/Kxniml9IehBHAfakcATikBtWVr1gM7ogAA17I1IIzu1yoDreAzHwn4P0IJ4Pha4T8Gbrem1ZkKBHdCAQCuYW1QaG0/etJvfjvi6fznZ+EopfRHoQRwbMvwv7w/UgiWjxFR/xkrbdvaBtgZBQC4tFPC/9rQUQoz+XalcBTvLEsppf8zlACOqxb4S4WgNR0oCvdbP1/AgSgAwJGsCR+16Q2lZU/CUUrpn4USwDGVRvrn7+/S+QC9qUER7VJQug/snAIAXNIpwWBk35GpP7WTHVuFIKWU/iSUAI6ndtJv7QjAaAmIxfKS2hEEYIcUAGBP1s79X66rnbRYC/+lZXkJ+OehBHAscwHIr/PbEc+/56NzOwrLgQNSAIB70yoIa0ZE5xLwp6EEcByl8D9yQnDrPIDWc5XuKwewcwoAcAS1Ucj5unW7NuJf2qYUkF6klP6fUAI4hhdRDvelE9/XHB1bLivtCxyIAgDsxbmDRGt0snd5Nn0ipfT/hRLA/lW/hwu35+2jcH9kKlDvdQA7pQAAe9QLD7X1oyOaka1vjZS+Xp5S+hehBLBvL7JLabrPctvez0iLkA8HpQAAR9Ga25+v781N7k0VKn2M4lwCXoYSwH7V5vmXbufbl6bMrfn5Kq0DdkgBAPZubaBozVNuhZtSUCoFpxcppRRKAPuUnwNQ+1jQVujPr1tHBlo/n8oA7JQCANyDfGpDRD3Q5Mt6hWC+/yRUpZTeG0oA+9Ob+98rBVFYHtnyHsEfdk4BAPamN8rYm24wOpI5Mh2oNF1ieWLwnwklgH1pfQpQLfhHZfmakX+hHw5EAQCOas10hNHR/9ayUoB6kVL686EEsB/L79XSSb61z/8fmQoU2TaRrQMOQgEA7lFv5HJ09L92FOBJIUgp/UuhBLAPyylApU8CihgL/6ceBWjtB9zYe2/9AgDOoBcyegGnNte5NnpaOhLwFdM0vRkRX4yIr9n8lWzw8uXL+OVf/uX4vd/7vWs+LRt92Zd9WfzGb/zGpR5+Hth7Ge9+r87383WxuJ4K91PnGjgozRy4pDW/Y2ojhqW5ysvly/UvCtetEf2R69Ioam19mqbpn8UNSsAf//Efx7d/+7fH7/7u717zadngy7/8y+NP//RPL/HQH00p/V/xKuDPl6lxP789Zcsiu57XL5fHYtlyfWs5cGOmAAF7du5BijVznHvFo3ZOwBtxg+lAb7zxRrz11lvxwQ9+8JpPy/6U/iOw3nSf5fSgKKyPzm3gYBQA4IjWzkfuPU4t7OTTfUZOoHyRUnp/KAHcRl5QW4VgJPiPnAvQei3ADikAwJGsCfWt+62TFWvnBOT7l6YTLU8M/guhBHB9IyP/pZ+BVvjPb5fu15YBO6QAAEe1diSyF+rz7UqhqDWN4tklpfQXQwngukan/5SmsUXUfxaisbw3NUgxgJ1RAIBH1AskI6OnQ8EqpfSXQgngevJQ3yoES72jA1HYBzgoBQB4ZOcINN1CkFL6ulACuI7S9LSRwlqbGld7jpHtgJ1SAABeKY2K5uvWXJ7sl1L6+lACuLza9J7astaUndL3cmm72v7ATikAwJGc87PEa9MaWsvXTKF4NvUipfSNoQRwWa35/7MtRTYKt+OE5cANKQDAUV3yPxZ6NoLf2K5XDJ5sl1L6QCgBXE4+pad0PkCvDNQet3RfwIcDUgCAPdv7/x46ejTgyZGBlNKHQgngMvKPpa2N6M/re9acGzCyHtgBBQA4onMUg1MfY+v5Aq82SumbQgng/GphPz8iUNq+d0SrN20OOAgFANibrcF8yq4v+Xxb5zs/CVYppY+EEsB59f7n6ujc700FmtWmugEHoAAA926Kesi/xEnFpeXVsJVS+ndDCeB8asE/H70fDfrLx10T8JUB2DEFADiCVlCfBm731s3LSpe1r2dp6ETJlNJfDSWA81ieA1Cb/rOcBtQa9e9N+dl6JAy4MQUAOJpaeO9tk68bDfGtIrBFcbpFSuk/DCWA86gddYrKstLy0vYRwj3cBQUA2Istc/d7++Tb1ML/yHP39hl5jN6RgI+GEsBpSv/51+g0oF74r1EK4GAUAOCSTj2hd+1jrA3ypYJQKha1aUG1gjF6/1lwSim9GUoA2/VO+m0dHcgfZ/T5gINRAIBLO9f0mTXnAeQj/bXwXlvX2qdUBkbOLxiWUvr2uGEJ+MAHPnDNp+W8Rk7+jXge/EdP8i1NG1qzD7ADCgBwDdf4D73WngcwMtLf2672WKX1pddVfY0ppf84blQCvvCFLygBx7VmSk/rxN/S/Vb4F/LhQBQA4FpOKQG1cF8L5/n91rSd2rLcy8pjLW8vt+lt230/Ukp/PZQA1pvn/s+3e5/vP3LCr4APd0QBAK5p5KTdretbJWG+HpnS8zK71Eb752Uvox/0W0cDml9zSuk/CSWAcflHfEZ2e8uJv0b74c4oAMDe1D6pZ8u+vSMFvZN48+3yx3u5uF8b/R+ZVtQqAlNK6TtDCWCd1nkAEfWjAaXtSrdby4CdUwCAo9gyDag3x7+0rHepHRUoPdbLge1aX8vr2ymlvxFKAH29T/tpFYLSY7XWj7wWYIcUAOBoetOAWqG6NS2oNzWodgSgdDvftlUEXmbLS68nIiJSSt8TSgBttRN7zzHaX9sPOBgFANij1gh+bbveUYDa0YD8finI56P6I9uUHqtUGFqv5dnXlVL63lACaBv5zP+twX/NawB2SgEA7s3IUYDaCP3oUYBaIWiVg3x56bmHXkdK6ftCCaBs5H/v7f0nYKPPARyUAgDsXW/kvrV8bREYHeEf2a53FGD0KEL+WucS8P2hBPBc7UTf2uh/vq60vvb4W9YDO6AAANdWCu9btqtN7ZmXtcL/yLz70SMArek9o6G/NzWo+F6klD4eSgDPnfJ5/qcEeOEfDkIBAG5htASMbl8K+aV1pW1LU3LyQF77vwF6U4DydaVCMDI1qLptSukToQTw1MiI/sj/AtxbvnYbYCcUAOBWRkpAbZvRfUdC/7y8OeWmculNASqtr5WJ3nkE+de1nA70g6EE8Eor1LdC/2hJAO6AAgAcRWlkPw/GpeXLZa1CMN+vjci3Rv1bRwp6RxB6U4NqxeDJ15ZS+qFQAhgfxe/N818T/JUEOBgFANi70ek/tXWtKUGlEB7RDuC10fve1KDSNKDapXd0IX/8iIhIKf1wKAGUPwI0X1/bb+3zAAekAABHUgvz+f3W6H3pstx/5BOAauWhF/RL90ePBJRuz14vSyn9SCgBPNUqA9FYDtwpBQA4gtJUn9r65bJWQcive1OBRqbojB4paE0VKj1Wr3g8+RpSSj8aSsCjG53GI/zDA1IAgFvqTe/Z8hi9MjBf1z4KNA/VW0J8a/s1RwhqI//dcpJS+luhBDyiXvBv/X8AI4+rMMAdUACAWxstAa2jAKWpPPny0rSfkalAy+1GPvWnNqI/EvTXTg1qrksp/VgoAZwe2oV+uDMKALAHW0rA6PrS6H8pMEesD9+9UD5SCGr79E427r2uiIhIKf1EKAGPbGt4N+IPd0wBAI6qNvUnv67tNzolqDYyP/IpPiNz/Uen/LTOB6gVl5cR8fKdEvBblffjYt544434xCc+ce2nfXRG+4Gu9976BQCsNMW7IWV5u3V/Dvml2/n10st4NVAyr5uDdbyz/GW2fcq2WS6bly/nX6fFstTYZrndcpuX2ePnU5levQnTNEXET+XLL+3Tn/50/Mqv/Mq1n5b1jPTDg1EAgL3Iw/sp25ZKQqkEtCz3n4vAVFmWl4NlOM8D/XKbUglYrpuiXhBKI/55AUnTNKWI+AcDX+9ZffrTn45f+IVfiFfdgx3ZcgIwcGcUAOCo8tH73vJ5XWTrawl1DvjLfUtFoDYlZz5CkIf+5fPXRvtbwb92FGD5el7dmab3hvDPK0b5gdcUAGBP1hwFaO3Xul87OtCbEtQa/c+nAs3y8F87KlAK+fmRgdL96hGAaZr+XAj/j6r3PwEDD85JwMDerEmOpYC+vF9bn4/gL5fVTgZunWhbOxKw3K92XTrJt3VCcOuxp3g15f/LQ/gHoMIRAGCPtp4PUJv2s+Z8gNqyef/aNKDSib+lqUC18wBqc/nz0f/mUYBpmr4ihH8AGhQA4B7UCkPpfIAtJwX3zgdYPmZpSlAp9OeBvzfNp3TS8ZNzAaZp+ldC+AegQwEA9mrt+QCnnhScL1vqnQ8wW47214J66QTe0km+pYKQP/br+9M0vS+EfwAGOAcA2LOtqbJ0PkDper69vNS2r50PUFrf2r52LkF3bn9hm4hX4f/9IfwDMEgBAPZuTbosjdKP3p+X9cJ/vm0e0iPG/3ffVjkY+V9/X07T9LUh/AOwgilAwBGsmQ5Umu+/XBeF9aPnA5SmApXm/i/n6keUQ3xtqk9tCtHyPICX8eo/+fo3QvgHYCVHAICj2HokoLc+D+ml7WrblKYDRbZtbXrPyFSffNuYr6dp+roQ/gHYQAEAjmRLCeiF+tq+rfDfOgcgn/aTr494Hu7zbfLAv3zemKbp60P4B2AjBQA4mi3pc/R8gN45ALV9S+cIzOtqo/ulfWvP9XrZNE3/dgj/AJxAAQCOaDSFlqb6rHmcWthf3s6fY2T5aAl4cpmm6S+H8A/AiRQA4KhOTaNrjwLUtintUwv7W59nmqbpm0P4B+AMFADg3p3rKMBIiG89fu+8gta0n78Swj8AZ6IAAEd2jaMAI89ZW7Yq6JfWT9P0V0P4B+CMFADgEZwjxbbOBxhZXnuM0v05/H9rCP8AnJkCANAuCJdIwbVR/3cXTNObIfwDcAEKAPBozj1tqLZuzRSiJ8unafpYCP8AXIgCAHAZaeXyiIiYpumvh/APwAUpAMCjaQbwE/c/5bHTNE3fGcI/ABemAACMhfp8m5PC/uIS8Sr8/40Q/gG4AgUAOLLREL4lrI/sU9omxfOAn697YpqmvxnCPwBXogAAj6w2qr9mmk8x1BfW59uliIhpmr4/hH8ArkgBAI5qy+h/bcS+tW+q3M6vW0WgFv5/IIR/AK5MAQCO6NQTeUuPUQr6rW1ry0qX5TYvIiKmafrBEP4BuAEFADiaNeH/nKP/pWW1kN8sA9M0fTKEfwBuRAEAjuRc4X/L6H9tm17gz8P/D8cNwv+v/uqvPlT4/8hHPhIf+MAHbv0yAHZJAQCOYuu0n5FAP1/XRveffWzn4LLl8hfTNP1o3CD8f+Yzn4lf+qVfepjw/+EPfzh+8zd/M77whS/EBz/4wVu/HIDdUQCAI1gb/nuf5rNl6k++bnn/RTwvAC+Wy28Z/n/+53/+ocL/5z//+fiqr/qqeOONN+Ktt95SAgAyCgCwd1vDf21daWpQL/RHPA/5Lwr7FS/TNP1YCP8Xtwz/MyUA4DkFANizU8J/75N7euG/N81nef/JaH88Df8/HsL/xZXC/0wJAHhKAQD26hbhf828/jzw59u/mKbpJ0L4v7hW+J8pAQDvUgCAPbpW+C9ts5zys1zXmu+fLxf+r2Qk/M+UAIBXFABgb64R/vP1tfDfmve/nPrzYnl/mqafDOH/4taE/5kSAKAAAPty7vDf2rY17z+ifgSgNud/Dv8/FcL/xW0J/zMlAHh0CgCwF5cI/73pPq2QP69vTvVZrhP+r+OU8D9TAoBHpgAAe7C38N+a7lMqAS+mafrpEP4v7hzhf6YEAI9KAQBu7Sjhv7qN8H8d5wz/MyUAeEQKAHBLtwj/pTn/a8L/kyMB0zT9TAj/F3eJ8D9TAoBHowAAt3Kr8N+6vMi2fTbVJ4T/q7tk+J8pAcAjUQCAW9hj+F/ul3/Kj/B/I9cI/zMlAHgUCgBwbXsN/6UpPsL/DV0z/M+UAOARKADANR0h/JfKgPB/ZbcI/zMlALh3CgBwLUcJ/0/+V9/leuH/Om4Z/mdKAHDPFADgGo4c/ufP+Rf+r2AP4X+mBAD3SgEALk3430j4vz0lALhHCgBwScL/RsL/figBwL1RAIBLEf43Ev73RwkA7okCAFzCUcL/8r7wfwNHCP8zJQC4FwoAcG5HCv/PPuYzhP+rOVL4nykBwD1QAIBzEv43Ev6PQwkAjk4BAM5F+N9I+D8eJQA4MgUAOAfhfyPh/7iUAOCoFADgVML/RsL/8SkBwBEpAMAphP+NhP/7oQQAR6MAAFsJ/xsJ//dHCQCORAEAtriH8P+3Q/i/uBuG/z+IiP/9mk+oBABHoQAAa91D+P+ZiPj7K7+Okwn/V/OPIuLNiPhoRPzTaz6xEgAcgQIArHEv4d/I/4XdOPx/W0rpv00p/dfxqggoAQALCgAwSvjfSPi/mjn8/zcR8TIiXqaU/stQAgCeUACAEcL/RsL/1SzD/xNKAMBTCgDQI/xvJPxfzRz+/0G8M/KfX1JKvxxKAEBEKABAm/C/kfB/NcvwvzS9c3lNCQB4RQEAaoT/jYT/q5nD/38Vz0f95wKQHwn4e6EEAA9OAQBKhP+NhP+rWYb/ac1FCQAe3do/8sD9E/43+rVf+7X4xV/8xYcJ/9/yLd8Sn/vc5+IrvuIrrv3Uc/j/L055kGma/ruI+GJEfM1ZXtWgP/mTP7nUe/bRlNJXRcTbEfGld67z26X7Lwu3i+dSxNOjKq2iFdntty/xBQPbOAIALAn/G33mM595qPD/4Q9/OH7rt37rluH/l6MwxWfFZUop/VLc4EjADd4zgCcUAGAm/G9k2s/VzOH/78fKaT+1S0rp78YNSgDALSkAQITwv5nwfzVz+P978fQE3y1HAZb7TimlXwwlAHggCgAg/G8k/F/NHP7/82gE+RgL/sXikFL6O6EEAA9CAYDHJvxv9NnPflb4v445/P9nUQ76pZNSex8JWnyMlNJ/GkoA8AAUAHhcwv9Gn/3sZ+NTn/qU8H95c/j/u1EP/adenhwRSCn9Qhy/BDzGNyawmQIAjBD+3yH8X80y/PcC/5pC0H2sOykBAFUKADymNaP/wv87hP+rmcP/34mxUN/63PpN26aUPhXHLgGP8U0KbKIAwOMR/jcQ/q9mbfhvnew7enJwrQT8XLwqAX944a/5Uh7jmxVYTQGAx3Kp8J+vE/4PbAfh/2/H+FSe2oj+2511Q1OHUko/GxHfFkoAcEcUAHgclwz/eeBf3hb+D2TH4T8P66PBf6QINM8PSCn9dBy7BEQoAsCCAgCPQfjfQPi/mjn8/3ysO2G3FvTfzi61YjByhGEuAT8VSgBwJxQAuH/C/wbC/9WUwn8p+I+E+dpof77vyGM+KwMppZ+M/ZeAx/iGBU6iAMB9E/43eLTw/6EPfejW4f9TMTgnP56H9Hykv7dvadvaYz4rAimln4j9l4CWx/imBpree+sXAFyM8L/BI4b/t95665bh/+fi3fA/F4BoXM/bReO6Jv9eXvN9/O6DpPTj0zR9W0T8TkS8v/OcALujAAB7CP9P1gn/17GD8P+z8fR/4s0LQP5xnsugX7o9qvS9u7w933+RXb/eP6X0YwcvAY/xTQ4UKQBwn9aM/vf2WRv+S8t64f/17WmafjaE/4vbSfgvhfxSGRgpAlG5XSu4ve/X+TFS4TrFqxLwozssAa33BCAiFAC4R1un/pTWjYb/5foX2Trhf2d2EP5/Jp6eYJsH/1ohiKhPBRr9x6t9/9a+b1uXSCn9yA5LQK733jzGNz7wmgIA92Vr+C9N/VkT/vOpEsuw3wv/KYT/q9lJ+F+e7FsrALVCsOYowFJv9H+K8vdzswDEqyMBP7zDErD2G/oxfgCAiFAA4FFdM/znRSAP/y+E/+vYQfj/6Xj+UZ954B85KtA6ETiy+6Xv7/w6v+RTfvLv7YjsiEFK6ZM7KgGjJ0UDD0oBgPtxjnn/vfvzslKIimhPo3h2sm8I/1ezo/Cfh/3SyH+rBES0PxEovx3RL7Sl79tl6F8eHZiXvZ1tn1JKP7SjElBjOhCgAMCd2DL1pzcymq8bGTmNKE//eTblJ4T/q9lR+C8VgFrob50TEPE8/K8pAPn38Mt4/n1bOgrQWjaXgE/cuASMFKLH+MYHqhQAeCy9ojAy9We5rDXKX53yE8L/1ewg/P9UVP5n3cJ1b0rQlqlAs9738FRYtvw+Xob+1j6RUvqBnRwJyEuAIgBEhAIAj2p06k9ptHRN+C9uI/xfx47C/zLglwJ/a1mvBETjelabstb6Xo5493u4NOLfOtdlSil9/EYl4Fwh/zF+SOBBKQBwfKPTf2rb1cJRvk0vPC23rR4JmKbp50L4v7gdhP+fjHbgHz0q0CsBkd2OxbKIcrntFYB5SlAe9me9IhDxajrQ9++kBNSWtZbP6x7jBwYejAIAx7Yl/NcCfmlZrRyMfNb/MvgL/1e0o/DfCv4j9/P9I8pFYDZ6BGC+XRr1L43053P/l/tP2f75pwN97w1KQO/k6LVHCR7jBwceiAIAj6U1Grpc3xvxj+j/x0nL+f/C/5XsKPzXRvt7y2pFoHYUIKJeBGal4L+8vRz1b035icX63knDr58npfQ9VywBI4F/zTLgDikAcP96U3/y+yNTJJYloBn849UJv8L/Fews/Pem/IxOB1puE9EuAUutKUDzdf59m38aUF4E1pwLEPFuaYiU0ndfqQRcajT/MX6I4EEoAPA4Rqf+LNe1jgLko6rFICT8X8cBw//W6UB58J+LQWTLc/n3bH4EqzTvf4rnU4Bq04Py7/9YrIt4NR3ou65YAlrlKN92zeMCd0ABgONqhffeNiOj/6V1y+Do6aAAACAASURBVG2WI6fFKUDTNH0qhP+LO1D4f/ud5W8PbF87GXi+H4vrLVOA5sAf8e738LysFPrn26VtaicMPyvRKaXvvEIJaE0DOrUUPMYPFdw5BQAew9bR/9rUhtq61yf9Cv/XsdPwv/XSOj+gFP5Lo9ytIwD59Rzm8/n/echf3s9H9/MjCfk5AM+eN6X0HRcsAa33Y2Qd8AAUADimS4/+l7ZplYAnRwGE/+u4g/DfOxJQOwpQCv9bTwIuTf2pFYHSUYEo7Fu6PDknIaX01y5UAlqlaM35Aa3pVMDBKQBw/0ZH/1tTffL71UIwTdPPh/B/cQcJ/3PAn6Ie+NecF1A6AhBxWgHIjwAsb9eCf2TLStOAonD95LWklD524SMBvfdn5EjAY/xAwYNRAOA+bRn9ry0bnvozTdMvhPB/cQcI/7Wgv1xeOxdg7XkAraC7VPo+nvepBfll4F9uX5r+U9t/eb18nFd3UvqPpml6MyK+GOcpAbUTgHtHAbaUgMf4gYM7pADAfTtl9D+/Lo2iLj/nX/i/gp2H/5Hg31peOgegVgBqJSDieTCtHQFYhvX5Upr2k08TmuL50YF5n5fZ4+VHyvLXEymlj56xBPRG/kuF4NQfnsf44YM7ogDA4zjX6P+LfPk0TT8VNwj/v/7rv/5Q4f+bv/mb43Of+1x85Vd+5bWf+g8i4qMppZ+O0wL+milAI0cBeqPds9r3dR7+l/P/a9N+RqYF5f+r8PI5l6/j3ReY0n8wTdPH4lUJeF++foVSsF/zA/IYP0zw4F70NwEOpjb9p7Zuzeh/rQz8DxHxh1te7Ck+9rGPxTd8wzdc+2lv4kMf+lD89m//9i3C/z+KiDcr4f/t2B7+3x645Nv19quVjDX75o8zL/tS4+ttvYbW63lySSn9lYj41jj9Z6k30j967gRwpxQAOJ5WwB/drjf6P3IC8Ov9Ukr/b0R8W1y5BLzvfe+LL37xi/GN3/iN13zaq9vptJ9W8M8DcOl+bdloOC8F9S81HuNLlX1GS8fIa11Tilol4M3Y/rNUmgLUWxYxHv5b5wMoEHAQCgAcy2j4X7tfbWpCb/T/9Wejp5T+RSgBZ3fA8N8KvCOj+SNHCeb1X8q2az1Xvt2XKs+VF4XR1zpSUpbTmvLzHV5PcUopfSS2l4De1KiRT/6pLRtZBxyAAgDHsTX81/bPR/Jr61qj/8vtX6SUplACzubA4X/tqH8r9LdG7EdC+prte6+jNfWnVn5qX0Pp8ZZHAr4ptpWA1vkRraBfKgSCPtwpBQDuS/Ukw8I2y/utaT+lbZ+M/i/3SSm9CCXgZAcK/6VgPxL8R6f/9IJ07X4tqNfury0EpXMCemVjpEgtS8BfjvUloDba3wr4rZKwtgwoDXAACgDcv7VHDlqj/7Xtn5SBlNKfCSVgs4OF/94UmNrI98hI+NR4rNLrmBqPP3X2HQn9I0cCautb19WvPaX0wVhXApaflhTZ7ZFPTMq3q20r6MOBKQDwGFqf/tPaZrku/wjQ2uebz+cE/LlQAla7g/A/RTnsjxwVaB0BaB11GH3stY/Tev2tIwatx62VglYJ+MYYLwEjJ/7WTgLecmKwIgAHpADA/RgZ6W99+k++vndicH55ke+fUvqXQwkYdtDw35v20grLvbC/5ihC74jC1scZfa0j78XUeexqIUgpfX2MlYC5OPTC/vL2yAm/igHcEQUA7s+aKT+tcwZGzweoTRmajwR8ZSgBXQcO/6csa4X+kZH60fVbH2fN1J/S9J7e1J/8k4CW4b1UAr4uxkpA7yhAbRvgQSgAcN9Omf+fL2udD1CaHvR635TSG6EEVD1A+O+N1LeODpSC8sj0mZGwX3qM/LyD2j4jRwJ6o/utYlF83SmlvxTtErAsEcsysWYKUO8Tg0YpFrBTCgA8ntZn++e3a/s2p/7E80IQKaX3hxLwzJ2E/1rIrU2n6YX03jSckfDfDdONx219/SNHA2pFoHW9pgT8xaiXgN7HgK4Z8R/92NDatsBOKQBw/0Y+GrS178j/FZA/T377RURESulrQwl47Y7Cf2uEvBboe1N2ap/a09v+1G3WhPvW+uVz9o4IjBSWJ689pfQXolwCRk747W3X+gjR0mMAB6MAwOManf+/vF/75J/e+QAR75aAfz2UgHsP/61tWlNr1l62BvravqOvbXQ600i47x15yF/z62k9KaV/LZ6XgFaYX37dI0cDhHu4UwoAMOvN/18uy2/XphQ9Cf/zspTSvxkPXALuPPz3wu/bUZ5nvzY4X/qyZSpQa2rUyHtbu18rOlNK6X3xtAS0ykNEOdT3ykB+RAA4uC1TAoDbaP28tgJ6K5zX5uy/WFy3Lu8p3H5P4XbxMk3T/xoRX4yIf3X0TTiHP/qjP4o333wzfv/3f/+aTxsRNw3/fxARH00p/XRcNvzX9p/v5x+FmZeCfJQ6D7DL0eso3I7s9qppalH+Wej9bNS2LZ0cXztSlsu/ptaJvS+nafrnEREppT8fY+WhVRRe5o9fuV2bMhSFZS8LXyNwI44AwP1qzd1fc3/k8VvBqhp6Ukr/VkR8azzIkYAbj/y/eYPw35o7v1xX+5SfUhBtjoifuM9oYF5u0yo+rWW1/dcE9deBPKX05e+E/1ZRGP2kn1KRMt8f7ogCAESUzwcYPeJQW98rIPOnA/078QDTgR5g2s/IPqXte4G7FtBLI9Nr728J/yPBvnUkpBb6384uveJQej9KX0/pay8dQcnD/Ujgb00ZAnZMAYD7MhLMR7cdfY7aycG1daUjAd8Ud1wChP+hkfBTL73w2wr4vZKxJviveW9GzifYEv5ro/ytIwEjRcDRALgTCgDct1ND/pbpQiOj/89XpPSRuMMSIPwXt+29llLwbgX8kakuvSMHrXA98rp703x6Yb50BKC2f+91l77ufF3E8yCfB/2S2rSh3n7AjigAQE9tdH+5fvRxWrdTSunfizsqAcL/cPjtjWqXAn4t8I4G41qRWDsNqBf6W+/Tlven9z6tLUWtKUGnjPgrA7BjCgBwLrWjA6uOQqSU/v24gxLwoQ99KD7/+c8L/9tC7ch2vSLQOgpQeoyR6UKjr6/1Pvf+Dc5RQmrhf14W8TSgj0z/yRn1hwNTAIA9eFISUkofjQOXgDn8f/VXf/WZXtmwI4b/WtBujWBvKQIj+5Rurwn++dfQm/pU+zc55VOBakczSiP8y+XLdSW1krBcH431wI4oAMAePAsNKaU344Al4EHDf23dllHr0ZDbGuXeum9v/1qBqAX85X6tTwDqLW+9T3nIL5WYqLz+fHlU1peODERhOXAQCgBwLr2QkI80tm5PEREppY/FgUrAA4f/3vNtGckeCe1RWB6L5aX7vdHwLaWgd3k73j0i0Av9W4+i1ML/yNGR1vsV2bLc6DJgRxQAoKQUBGp/6EdCwtrnfC2l9NfiACVA+G8G1try1lSbVhhvhflSKM4LQissl56r9HiXvIxMAxo94rF8j5bv0/J26/7yegtlAHZIAYD71vrjO/KHuRTu1+5XGmGsHQEo7RMppe+IHZcA4f8s4b+0fPRIQB7wS0b2bT1/L3zXwnjr5N+psa4X/mvvV+3rGClP+XsVlWuhHg5OAYD70vvDXFu/dqSvdoSgdjRgpBA0Rx9TSt8VOywBwn/z+daE/1pIjWxZb/S6NyLe2nf5XKXiUQv+W48M1EpB7zlbhWAk/Pd+BpdqvxtqhR44AAUAGP2D39qvFg5ao4cjReDJc6SUvjt2VAKE/6Hna41Y98J/rQTko9d5wC8Z2XckgJf26QXy0fDfu9SCfu29bX1dpUIQhe0iux55j4GdUwDgftX+EJ/jD/SaUcHSti8b2y+XPwsrKaXviR2UAOF/U/ivFYKR8B3x/PtlGeajsX8p5OZFoFRC8xJSKi+9UjNSBrYUg9Z7OPK+1t6vpXOFeaUAdkYBgPtw6T+wvVHB/LoVMEa2y5c92T6l9L1xoxLw1ltvxSc/+cn4whe+IPyvD6+l4NwL/6VtSuF97SUaj7vcphSqa6+tVXh6ReDUT0+qFY+1par3c1pbBhzIqv+hE7ip3s9r6X/eTYXly2UvFvfny4vCde/ynncuL7LrfPnopbT9s8eepukfRsTvRMT7O+/NPThi+M9HqdeMUPfCaOn+Gsufg/x+6XbpEvHuQFpru3yf0vPky3P51728nYfxPNyPlICX2b61x6g9Z6kQ1P7dgBtzBAAeR+0PcG95L5SVtlkuWzu6Ozxym1L6eNzgSMANCP/1keqlkccpbVt7/Npjjn4Na4+G1I4g1N7P1qh/631fE+ZbwV2oh4NSAOD+jf4BbwWC2r61oPSysa4WzJZBJSqP9ex+SukH4r5LgPDfDv+1gF/S23fkNdYCdS2018L6SOjvva+15y49R/719t7v/HqkFCgEcBAKADye0h/wteGpFuyjsW4kSPVGL4vXKaVPxH2WgDn8/0Q8D933FP5bZWBeF4tlsbifawXbfLvSPvnz5K+l9Ry1UN4rCaXbtZDfKxS94rIm9Ofv1ZrgrwzAjikAcN/W/hGulYNeqGqFjVbYawX7UnCqbnuHJaAV/ktB8sjhPwrLayE8stul9SWt79vecyxvt75/W9+7tfelVgpKQf8SJWD+mvL3oBb6e8tzigDskAIA92fNH9zWH/Na2F96Gc/DQut2KTC1gkst4BQfJ6X0g3EfJaAU/lv/sdY9hP/I1rWC+VKtCLS+h0ceo/R4+eucX3vr6yt97468Z61ikD/HaAnIv6YoLG8tE+bhTigAcD9G/jiXQlTtD3zp8VoBrRdCaiOStcDYG+ksBagppfRDcewSUBv5r10eIfzH4n5Uth/9/h8pBa3A3Po+rz3HSMHthf/ae762BLQKTv4+1Iz8ngB2TAGAx7Dmj3RrJHDtpRX6RwNPbb9aEXh54BJwj+E//zc7V/jPl61VeoxW8B+53QvetVLb+lkoff/X3tPS8+dfay/8924vnePfAbgBBQDuX++Pc++PeB4S8mWjI52lZaMjo6Mjoq8fJ6X0yThWCbjX8F/6HonF9Zogmm/fsmabWtnova7e93vtPWgVpV6wLxWC0uPmr7/1mkpfW+v9b1mzLXAjCgAcx8gf05ERudI2a4JXLdCUQkcpqNRGNlvb9oJ/8f6BSoDwX/8ejMK2Ja0wP7Jv6fnyx5hvl0L2cl2+3cj70vq56l1a+7Ze68jPfel9Wt4W8uGAFAA4llP+2Jb27YWp5e1SMOgFmdL9kWBf2mZ02ZPLAUrAHP5/PMaC/6OG/3zZct2an4va9qMloBX85/t56M7fj17YHy2+pUvp9WwpAqX7ua3rgBt7761fALDaFBHpzPvM60vXsdg3D3Mvoh5EagEwLa7ny7x+XlfbtrYsovz1pXinBEzT9G0R8TsR8f7qu3B9efif4lXgLoXAUlgvBftHC/8jyyKef3+Ufiby7//l4y1/Hpb3523z9aXnKN0vvba1au9RL9CvCf+126XXAeycAgD3qxX6SwG/Ff7zP/55IagFvnn/ZaDPtyuF+3MWgBQRscMSUBv5z8N/LZg/cvjv3S/Jw/tyWSmUl5bn+83bbAm+tQKy1CvtreUjYX5L+O89f28dsAMKANyfkeDfWlcLN6WjAPkRgDz450F/OcrfCvdvZ6+tVgDybUpeT3XcUQm4RPjPlwn/db2R+fxxU2G7VglolYLSEYNSERkpFiMh/ZSjAqXHNfoPd0ABgMdRCz2lowDLbSKeh5Hlvsug9yJbNof+F9n9+THn65FSULNq+kRK6YduXALm8P9jUQ7Yvf/sS/gvr1urVGzXTgnKX8fItKDlNsvHKf2MrdUL7r33eDT8X/LfBbgCJwHDfar9oa4tq+1XCmrz/ZfZ8jzg5aPZrRDZGgXvLatNhalebvj/BNTCfyu4C//1bc5hZBR95PW0fkaW29R+bl5G+b0cuZT2LT1n7/VuCf/AATkCAI8lH+VcjnBGPB3tzNdFvDuav9x2WizPA+Byu3xZ7XrL0YA1RwHmcwKufSRgDv9/K56f6Lu8/fZiWSnkC//nt/y5yH9GSs/ZOhow719aNm+7/DrXTvvpaQX1POTXtl8T/i/9bwNcgAIAx1QKKWu2be1f+iNeKgTL0D/vN4e+UhlYFoE5IC3XrZWfBDxqno4UKaUfvFIJmMP/j0b7qMZyXWvUfxnspxD+z6FXApbLa0V6eX9WmipUW77m57r02lrL1xaC0f2BA1IA4H71Qkx+v3UUIA/7y/CyXLc8KlALh8vzASJ7vPl26yjAqaZYHMVIKX3iwiVgDv8/EuUQXbu0gnst/LeOCAj/fXkJiHj+fVf6eSlt3yrS+XP19ltrZDS/t255fyT8KwNwIAoAHNcljwIs1+eBJ6I8Fag1+l8yUgBG9u3Jg+iLxfIXcdkS0Av/pRLQC+1bPgVI+B9XCvK1KUG9E+eX95fOPfpfem2t5SPhvRX+R58T2CkFAO7buY4CRGP5sgzkof9Fdn/5OLXlpY8AXas13SEvAikippTSx89cAubw/8l4d7Q+D/xrjgJsPRFY+N8mPxrQmxIU0S4Cy2VLvfWnGHkft4R/gR8O7lyH1IHbGPkZro20z/dL61N2iXg3ML8orH/RuB69vOedy/J+frt2Xdq/9Dj5a8pfa0zT9A/j9BIwh/8feud+a9Q/v7/1P/4S/tv7bP17V/qZ6W239mT10efoWTMtZ+uo/5rn2LIdcAWOAMD9600taK3vHRGIbNl8NCAPf7WpQFtPBO59Pcvr5et4kS1Li+VTvJoO9H0nHglYhv9laO5N/WkdARD++3rbl0bk1zzuyNGAebvac/WWL+9vfZ1bthl937eGf2BnHAGA4xv9Oc5PrF0ubx0FWN4ujfxHPD0qkI+010bd85H50qh+a7S/tq50v3TJX+Pytadpmv7HWF8C5vD/iXge/Eshu3SZov/xn6Vy8Kjhf2sI3fL3b81I/cgRgVNey6jRYtTatnW04FyvA7giBQCOb20BqN0enQrUCv8pngfqWhHoTQcavV1b3ysCpdf65Oudpul/ivESMIf/j0c5MJeOBPRG/kdG/YX/7c5RAnqPs2V6z7mmAI1ut3Wq0BoKAOyIAgD34RxHAWrXpdut8wF65wKMFIFWkO+VgFOOAORFIKZp+o3ol4A5/H/fO/dLwX/0CECvCCw//rNVDoT/Mec6N2Dksc4113+rtVN4zjXlR/iHnVEA4D6cehRgvr91KlCtBJxyNGC0BKwpAqXnrR4BmL/uaZr+56iXgDn8/8137uehuDYNqFYGWqP3tf/4S/g/zSl/C7cUgVP2G7VmOs/INqe85woA7IwCAPdjLyWgNqJeGnU/pQT0jgykwva1KUB5Eci/zpim6TfjeQmYw/93v3M/D8S1qT+tIwBvL9aPTvER/k93ixJwjucecer7K/zDnVEA4H5sCRy9qUD5sr2XgFOnAD2Z/hPvloGIiDRN02/HuyVgDv/fFU9DTh6SS6P/p0wBGgn+wv82p/5NrO1/7nLRcs5pOud4vxUA2CEFAO7LuY4ClK7n23k4XlMCSlOCtpSA0SLQmwaUl5KIp69x+XVGRMQ0TW9FxH8fET+cUvqO7L0rhf95eWn0v1QERk/gXTPqL/yPO8ffxdZj7Onv7inThE55fODG9vSLCDiPa5WA1hGBUpi+RgloLa89R6u0lI5+lJQCcekIwMg5AFsDfutogPC/3qWLwLmeY63eeyn8wwN4z61fAHB2W+cel0pAbypQvjzff6q8nlrh6DnnyOoyvObLSkG3FJJrU3zy8D2vezueB/LRy9rpPsL/6c4Z0Ece6xKFYPT9O+f7LPzDzikAcJ/OVQJa6/Pby7DfCv/5/rUC0bLctvQ88/PX9IL/ctnLwrJSYK5N8ykF8VZgn7ctzfdf8xjC/3lcIpTv5ej7Jd7fPfybAR0KANyvW5aA/Lr3vFtKQP68o/KQ2gv3rXV5SRgdwR/ZJw/4+VEE4f+6Lhnar1kILvm+7u3fDKhQAOC+bSkBI59ksrcSMKoWTnsBvxaY8+UjgXztKP6Wi/B/Odcevd/yfNd+//b87wUUKABw39aGh9Z8/nz5LUtAKXD0nqcVWEeCf2u7UvDeEtJLRwGm6H+kZ69sCP/ntZcpPACbKABw/855UnBrm2uWgDVf0zK4RuV2qxDM99ceASiN+Nem+Kw9EiD878OjF4Gj/XsB71AA4DE8YglohZNa4I+oB+HeEYJ8RL81et+bNjQS8IX//Xi0InDkfysgFAB4JLcqAfN1rwT0ph/lSiGkt6wWYPPwnwf00pGBPEiXpvXkn9yTB/Laib6nLstfu/B/HfdeBO7l3wkengIAj+UWJWC5bO2RgNbjLbWCaO1+KdRHPA/E8+01U4BGjwBsvd9bV7ov/F/XPZWBe/03goelAMDjubcSsCZ4TvF8+9L+pVH01uh/fl0aec+X5/8pWGlUv/WYwv9xHLUMPMq/DzwcBQAe01FKwMjj1baNqAfWeVlt1L8WiFuhuzUq35sKtHakv1c4hP/92nMZeNR/E3g4CgA8riOUgLVhqRdYS0F35H5reWuUP58SdI4R/tZ2wv+x3LoM+HeAB6UAwGM7YgkYec2j4T9fVwvOrVKwZmS/F+zXjPIL/49n9OfVeww0KQDAEUtASyn8tIJuL9zP61pTfkaOAKwN/q3HrR19EP4B6FIAgIjblYA8/LfKwJpikT9Hbfm8rhSOe/dLRwla039aJWJ0ZH80/Oehv1YCSrcjuy38A9wZBQCY7e0/C2sdEWi9ntnIkYDadW271mXNSbsjIX/kaMPIiL/wD8ATCgCwtOcSkBeCtecC1AJua0pMLWxHtEfja+cB9Eb7e6P6tduR3Rb+AahSAIDcHkrAmudpbVtSOzIwX7duj4y+907crY34rwn9+bKorJ+X97620vtQen9K61qEf4AdUgCAkluXgC3PPaIWdlPUw3GrCNTCfB7+o7PNSAHolQ/hH4AhCgBQc5QSkO9beu5aqK1d58t6o/69UfuR+f2129F4buEfgNUUAKDlliVgRK0srHmM+XHm61JIHjkK0BrdXxPgW6G/9lGetRIg/APwjAIA9NyqBJTMgT8P/msD//Lx5uvWUYFe+D9lHv/IY8bi/nx7eS38AzBMAQBG7KUE9E4WXms0JK+9jJ4IvPUSIfwDsJECAIzaQwnohf+1gTU/+Xe5rhaqR0J/L7ivDfujJaB3O7Lbwj/AA9pyoh3wuK5RAlJhecqWl5a9qKyrra8ta72umlbInm+3Qntrbn9rfW1Z/vjCPwCvOQIArHWtIwGl+6WwObpsZNtSaF4zkl/bfsul9Fi9ZfnrLn19pfdB+Ad4II4AAFtcczpQ6chA72jAyBGD2u3a6xpVC96tKTq1Zb1tWrdLryFfVttmhPAPcFAKALDVHktAfns5xWd0n9prGrWlACy3XzuvX/gHYBUFADjFrUpAft0L/619avdLr3NErwDk90sBfvQ/8RL+AVhNAQBOda4SMC/rlYOtRwNaIX+kkIxqBe5Tjg6sne+fb1t6jaV9WoR/gDugAADncK0SkK/fUgpq9/Pbp/x+bJ2AO9/eOkVoZNS/tL72OkYJ/wB3QgEAzuXcJaC13UigXxP+W4WjtWzWC9rz/S0lYPR+flv4B6BIAQDO6dolYO11b6T/HKP/s9ZRgJHzAUauRx6/td0I4R/gzigAwLldugSU9ltTDEr7tJ6r9fpmtZA8Ojd/NOj3pvzUXovwD8BrCgBwCbcoAaPL1gT/kalBrU/XyZetLQK9Zb3H7+3XIvwD3CkFALiUc5aA5fJzFYHac428lpZe4F4b8rcG/5HXUiP8A9wxBQC4pLW/Y85xNKC23ZoCMfL6Zr2wvDbAt44W9PYdec4e4R/gzikAwKVdqgSU1o9O79ky3//UTwHKl48G+tZ94R+A1RQA4BrOXQJ626wd5T912k/NKWF9bfAf3aZG+Ad4EO+59QsAHsYpJaC1/5qTeEfWjwThNLDd1pC+5iTi2j7CPwBVjgAA13RqCRhZdsq0nkv8TlzzKUGt5WvLwijhH+DBOAIAXNuWkL0lqJ/z8/y37nfqUYLW/qeO+m/dB4CDcwQAuIVLl4BTtr2kcwV64R+AzRQA4Fa2/v5ZezTg1O1PsfYIwLm3H30cAB6IAgDc0rlKwMhjbdnnErbM2z/HXP9T9wPgTigAwK2d8ntoa6i/1pSgNdN4Rrc5JcAL/wAoAMAunPq7aMsJv+d+DS1rgvfWE4rP+RoAuGMKALAn5/iddI4yMPIc5wzUlwr953oMAO6IAgDszbl+L+3lk39qTp0edOpzAPCg9vBHECB37t9N1/zkn5qt/yfApZ4PgAelAAB7donfUef85KHZnj6RR/AHoEkBAPbu0r+n9vIfgR3h8QG4AwoAcBS3/H018ty3DN+CPwDDFADgaPzeepfgD8Bq/pACR/XIv78EfwA2e+Q/oMB9eKTfY4I/ACd7pD+cwH27599ngj8AZ3PPfzABIo71e07QB+DijvSHEeBUe/ydJ/QDAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA4F90PAAAARJJREFUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAC79f8DAKMRR8OjyAAAAAAASUVORK5CYII="/> + </defs> + <use xlink:href="#source-24"/> +</svg> \ No newline at end of file diff --git a/src/assets/cursors/macos/resizenortheastsouthwest-1__50-50.svg b/src/assets/cursors/macos/resizenortheastsouthwest-1__50-50.svg new file mode 100644 index 000000000..a15f61a8b --- /dev/null +++ b/src/assets/cursors/macos/resizenortheastsouthwest-1__50-50.svg @@ -0,0 +1,7 @@ +<?xml version="1.0" encoding="UTF-8" standalone="yes"?> +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="767.314286" height="746" viewBox="0 0 767.314286 746"> + <defs> + <image id="source-24" x="0" y="0" width="768" height="746" xlink:href="data:image/png;base64,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"/> + </defs> + <use xlink:href="#source-24"/> +</svg> \ No newline at end of file diff --git a/src/assets/cursors/macos/resizenorthsouth-1__50-50.svg b/src/assets/cursors/macos/resizenorthsouth-1__50-50.svg new file mode 100644 index 000000000..40d4718be --- /dev/null +++ b/src/assets/cursors/macos/resizenorthsouth-1__50-50.svg @@ -0,0 +1,7 @@ +<?xml version="1.0" encoding="UTF-8" standalone="yes"?> +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="767.314286" height="746" viewBox="0 0 767.314286 746"> + <defs> + <image id="source-24" x="0" y="0" width="768" height="746" xlink:href="data:image/png;base64,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"/> + </defs> + <use xlink:href="#source-24"/> +</svg> \ No newline at end of file diff --git a/src/assets/cursors/macos/resizenorthwest-1__50-50.svg b/src/assets/cursors/macos/resizenorthwest-1__50-50.svg new file mode 100644 index 000000000..0fb5cca6f --- /dev/null +++ b/src/assets/cursors/macos/resizenorthwest-1__50-50.svg @@ -0,0 +1,7 @@ +<?xml version="1.0" encoding="UTF-8" standalone="yes"?> +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="768" height="768" viewBox="0 0 768 768"> + <defs> + <image id="source-24" x="0" y="0" width="768" height="768" xlink:href="data:image/png;base64,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"/> + </defs> + <use xlink:href="#source-24"/> +</svg> \ No newline at end of file diff --git a/src/assets/cursors/macos/resizenorthwestsoutheast-1__50-50.svg b/src/assets/cursors/macos/resizenorthwestsoutheast-1__50-50.svg new file mode 100644 index 000000000..dc8d3aede --- /dev/null +++ b/src/assets/cursors/macos/resizenorthwestsoutheast-1__50-50.svg @@ -0,0 +1,7 @@ +<?xml version="1.0" encoding="UTF-8" standalone="yes"?> +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="768" height="768" viewBox="0 0 768 768"> + <defs> + <image id="source-24" x="0" y="0" width="768" height="768" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAwAAAAMACAYAAACTgQCOAAAABmJLR0QA/wD/AP+gvaeTAAAgAElEQVR4nOzd6dMl7V0f9t+ZZx4DQeh5JOEFLywONl4JkUGSXTZYg8QjJLxVVrLwB6RKtstvbOMdhKRUxYUgiV2uyotU5UVeJFVeSEC7HVMBnJQkpITSZid2XEjILEIC2cbMTOfFTD/Tc93X1n36nO77nM+n6tQ5vfc5M/d9/77XdXWfCAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADisPUJwC13zp+hY47lZ51jDGfe7hhbHBPgVlEUwDLn+NnpPYafY/ZgTuF96iJdCACoUDjAPKf8mWnt+1w/r34vXIdzFMk9xzjleQgCABn+0EO/U/y81Pa5xvGW7sPvhst0TEG8VjFd2s8pinUBACDDH3nos+bPSmlfS47Rs42fc47VKqTXvkZg7cJdEACYUBhA21o/J7n9zN33muHhFPvgdlizIF6jiD9HEBACAB7zBx/qjv0ZObZgPzY0+BlnDXOL53T9Y8PAqYcfAVwVxQHknapFfcmQnVMM89FjcHmOLW6XbL/0It9TbrdkvwBXxR9wyFv7nvtz7/Bz7MXBpxwqxOU7pviee71Abf1TDQ0SAoCrphiAm9Ys/ucU/kuL/iU9Bqdel+0cO1xn7vpLCvh02ZKLjPdwRyOAW8kfdHjaWrfNXKNg75k/NzTs6QvM6HfOgnVusd0bCHrmr9l70CIEAFfLH2p44tzFf2+B3lPw94aC3n1wmZa21ueWH1PQt0LGGoGjhxAAXCV/8OGRUxf/xxT+c5fNOa+ebefye+V4e/hSrLmF/Nyifs0Asfb3EABcNH+o4ZE1voRrSet9aR97CgQlfn9s71R3xTm24J+zbGlvwBohQAAArpI/4HB88b+k1b+38J87v+fctro2gH2YWzgvKepry5YEgVOFAAEAuEr+6MNx99BvFf/HFP5rhYTcsto5tdbn9lhyG81WUb20sF8aBIQAgJX5I8+1W7v47231zxXtx8zrmZ9bVlqPyzPnIt9jW/R7543TQgDAGfmDz7U75uLY3rH4va37L84bhuHZiPjaGecGt9EXDofDP415ISC3rGd5ixAAXI27W58AbOgUxf+SVv/c9B+KiB+acX5w23whIr4jIv5pPP0zMBbih3i6KE+na+asC3B1BACYZ854+Vahnz7rkeNafCEivuNwOPwf8aRYL4WAdHqIfIF/TGAAuCp3tj4BuAV6x83XXuem08e4zM8ll2xa/EeUfz5Keu+ABUCBHgCom3u7z9w2pVb+0jJFDJfqCxHxpsPh8IF4unU/19KfTuda91s9AXoBADIEAChrFeI9t+qsFfilZQIAl2gs/j/4eDod8jM3BERjPgAFAgDktS767bkDUKvA1wPAtRiL/5+MJ//Xxzv/LO0JqBX+S3oB0mMBXCxjjaGt1cqfm9db8I9j/tPXcCnG4v/DMT8Ml6bT12v+zPj5Ay6eAAB1c4r/3Dq519OCPxcIFCBcirH4/7+i/v++9jom83Lmzu/hZxC4aIYAwU1z/vj3XtTbU/ALAFySsfj/qXj6ot1ROjSnNhwoXTc3rKd3SBDA1RMAoKyn9X+6rLf4v5NZNp2vZ47bbiz+PxrlQDst7GuFf+51qlTgH1P4Cw3AxVJowNNaY4pL4497Xkfki//p2H/XAHDbjcX/x+Pm//GIcvDt7f1qBfHWdgBXTwCAPnOLh1zhM31OA0AaBOA2Gov/fxw3g21EOQTknmtBOgrTAHQQAOCJVjHR2/pfa/nPFUBp67+fS26jsfj/f6IcfKfPuQAckf/ZmqrNj8KydB2Aq6bQgJtarYy9rZSt1s1c8S8AcBuNxf8/jXbvV0Q9TOcK/N7hQTmKfoCEQgMeaY3572ldTNdrDfNJhwLdCUOAuH2+EBFvPBwO/zxu/h9vDf9p/Vz1FP65MAFAhbsAwdN6C4ie1v/SIyL/5V+uAeC2GYv/T8WT/78P4+YtP8f5c/9vp7f+HOcdMssjWdcdfAAKBABoa40p7hnqk+sBqL2GvRuL/8/EkwI/ktejNBBM57WK+FaxD8BMAgC0h/+k07Uxyq0LGnuHBcGejcX/z8XTvVqlEJBO574DIFX6ArCIeV8CBkBCAIAncoXIktb/XAiYe00A7NVY/P9CPD3sJ5LX0//HpS/1mi6fa04YEBAAJgQAyJtzUWLPEKBxOh3yk/tOABfns1dj8f+5yI/xj7j5czBdXhrSkwsLpW8KPpYwAFw9hQb0qfUO5IYGpXc8uRPlIJAW/34u2aOx+P/lKIfXXM/WKPdz0PtztYSeNIACPQBcu1qRMac4SQud8bnWA5Ar/E8aAD796U/HT/3UT51q95zQS1/60njVq1611eE/HxEvHA6HB3Ez5JZa/seW+7R1P7dOuqzWQq8FH+BIAgC09YSE1jUAtdbStPg/Wcvle9/73viu7/quU+2eE3nuuefiXe9611aHH4v/iHzRPw7vSW/1OX2eFvel4n9OEBACAI5gqAHc1LoQN1fgT59L+0mHRrgGgKax+H/1q1+9xeHH4v+ZyA/v6QnAUVm3pNSjllvv2KFCAFdHoQF1PUVFT+t/a7x0uh7spfj/NdH3fzb3fz837n+q93qA0vQcfq4AHhMAoL/ILxU4UXhutYKWiig/l+yl+P+SuPl/NNf6X3ukastKFO8AK1JowDp6hkPkhk+kY//nFkZcoJ0U/y+J/P/X0v/lWmt+bp10WWl6CT9DABUCANesZ8hBa9tS8VJqIa1Nn/wuQOzfTor/56IeTOcM2Sn1kpXWB+DE3AUI+tSKmHSd8XWt9T/X8i8AXLmdFP8vj6fv8pMbmvYw8v+/022G5HVObRkAJ6DQgJtaxX5pqE9p3TnjpV0DcKV2Uvz/2qj3Vk3VxvsbzgawYwoNeKJVrLSGN5Qe4/LWdwFMn7kiGxf/n4tHxf9viJu9Ua3/18fo2cfS4+R6FPQyADwmAHDt5hb9vdvUWvRbIUAAuCI7KP7fcDgcflOUi/ueXqvecDD3/7aiHeAEXAMAT2tdqFgb/tPab64XIHcdgABwJXZS/H9lPD2mPw2s02/3TQmsALeQHgDo1yqCeltGW8OD/FxegZ0U/189mdfTet/TSwDAzik0oE/PUKCeor92H/XpdQBcsJ0U//92tMf7j0rz9mBIngFoUGhAXa7Izz239lHqAciFgr0UVpzATor/3x71lvzSdGSm5xgKr9fYX+8yQQG4egIAPDKnqOm5TqDVklp67RqAC7aT4v93PJ6e2+J/KmkoGOJmkX5sK7+iH2BCAICynuEOrYJ+ut60uM8N/XENwAXbQfH/wuFw+N0xv7DvXTdXuPds07t87d4DgKul0IAnegud0vUAPS2prdeGAF2gnRT/Xz+ZV7teJdVTbB9TkLeCQ6k3AICFBADot+QCyFqR7xqAK7CT4v8bVtxnOhxn+pwW86XhPK0v6lrSm5DbDwAZAgC0LSnIe8b+6wG4cDsp/l/5eHrJ/6/WWPzS2PxSEBgK2ywp2o8JCb37B7hIAgDcVCuQcr0AtbH/ueWlbQSAC7KT4v+bou//VOvC21wBX9uu9MitU5rXChm5c81NLwkain/govkmYK7ZMYV27U5ArYK+Z1gQt9hzzz0X7373u+NVr3rVFocfi/9Xx6Nv8W1pFfC59XoK+jnHaq2fhpHe/S+h+Acunh4AyCsV7qV1S+v0DvspHZNbZifF/+8vLC8V9a3hObX1lj5q53TqC4/PuU+A3REAYJ5Ssd8TGGrTbgN6AXZS/P+BqBft6bxWsV9a/2FjnVMM/+m5fqBUxM/tnQC4WAoNqCvd8nPOdj2t/lr/b7mdFP9/MJlfK4Z7WvlzRXwrGJTMCQJRea7tH4AOAgC0C++5hXnrdqG5EJAu4xbZSfH/h6JeiJcK7odRL/qnj4eF10Oyv/TRChO58y4V/q332LMPgKsmAECftS4YbvUMHHsszmwnxf+3JPN7WvMfVtarFfq5Aj4dDpRqtfznzrk0r7RfADoJAPDI0qK7Z1x/bT13ALrFdlL8v7awvDUGf3z9MHldKsTTUFC6BqDU+l+7ZmB6bhHzC3vhAGAGAQCOV2vZb607dzk7sZPi/160x+xHYdm02E9b+R9GvtBvtf63CvHcPtLn3p6AUg9C7fgAhAAAqbkX+Nbm10JB7fafAsDO7aT4/9aoF/9zhvPMWZZr/c+N+2+1/peCRapVwCvwAWYSAGCZ3hCQmzfnVqLszE6K/9dV1jkmFEwL99bFwbULfGst+rVhP6V95PaVvufaNAATAgAs1xrKs2R9AWDHdlL8f9vj6VJRHsl0qYW/VuznhgCVCv6euwj1BIXac8lavQMCA3BVBADI6y3Ee8b0t6Z7v3GYDe2k+H8h6oV2RHsoT63Yr4WB6fZzi/7InGc6r9Xif6rWf8U/cHUEAKhbIwiUivzanYHYkR0U/992OBzeEDcL6Ih8wV1r/c+1/KeFfW0Mf2+rf+7corBO7jl9f6k1Wv8V/8BVEgDgvHq+GIwd2Unx/6Zkfq5Ij+gPAbXW/NyFvD3r5UJDWvjX7iiUPvcU/ce2/gNcJQEA+swpzlst+bWCXwjYkR0V/7nhORHlQr+nxb9W8JeGBvUM/6nd8Scm81o9B1GYl77OEQQAKgQA6LekOG9tY9jPTu2k+P8jk3m1or82nSvge1r/S/s49dCf3uJd6z/AQgIAzHNMod77HQPCwMaef/75PRT/fzTaxXZpmE9Pq30tGLTCwDmG/tRa/2vFvyAA0CAAwDKnDgJs5Pnnn493vetdWxf/fyzqBX/rTj+tYT69BXxtee91Ar1Df0rFfyTb18wp/gUF4GoJALAtLf47spPi/49HX4FcKtSX9Ai0tu0dAhSdy3PPMZnOtejX1p9L8Q9cNQEA1rVGz4BAsIGdFP9/ItrDfUrj6lsX9vYM5akV/D3j/3uvEXiYPPe2+Bv6A7ACAQBOQ8v+LfM1X/M18XVf93VbHf7/i4h/nJnf2/q/xvCfOfNrw4Z6wkAkz6XXkXkNwJEEADg9YeAW+NCHPhT37t2LX/iFX9ji8L83It43DMN/FfWCulRUt1r/SwV5T+9Aa5jQMeP+I3muFf9rtf4LE8DVEwDgfKZBQCjYoQ9+8IPx+te/fqsQ8A0R8d5hGL5/Mq/U+l9rmc8V972PWi9CT+t+br30fbSK/xzFP8CKBADYnm8C3pGdhIAfjHZLe61VvzVUp3fsf63XodTqnxb50xCQPucK8lzvQW15L8U/wGMCAOyLELADOwkBfyPqrf+1lv5SIKi11PeO/c+FkvT8ekJA79CfEsU/wEICAEDGTkLA34p6S39pujasp7eHoNbaP3fcf2teZLZP5+WmAVhAAAAo2EkI+O+iXuiXegZavQNzh/mUive17v4TmeWRmZ9bViM0ACQEAICKnYSA/z7qrem1FvqeAn3O0J/S8J9akd87xEfxD3AGAgBAw05CwP8Q9Vb/1tj/3rsG9QaLqGwX0R73H5nl6bLSugAcQQAA6LCTEPA/xpOiO+L44TxzQ0DumJHM7xnu01Psp9Nzi39hAaBAAADotJMQ8D9Fu1hv9RK0hgUdM/RnThCIzPwcxT/AigQAgBl2EgL+drRb7XPFe3Rsl2uFT+fXhv7UnnOFealYL/UStCj+ARoEAICZdhICfihuFvW1ITtp0R6TedPXtVuBptvnjh3RX/T3DgXqpfgH6CAAACwwhoDPfvazWxx+DAE/En1DgWrLIm6O5x+1AsWxQ39a03MKesU/QCcBAGChHYSA9wzD8J7o7wGIjtet4NA7dn/J0B/FP8AZCAAAR/jABz6wZQj4d+NRCHh/1Av03lb7UjiY7i9d1uoFyG1fml5C8Q8wkwAAcKSdhIB/GDeH9JSK+tydfCKzzdJhPZFZJ51fm9db1Cv+ARYQAABWsJMQ8GORL9Snz1M9RXdvoT/nWLV9AXBiAgDASnYSAn5iMq+n1b5n6E5EPiys1Wqv9R/gjAQAgBXtIAS8dxiG//PxdOmC3dy1AlFYtzW/Nq1IB9ghAQBgZTsJAR+czOtp1V+yvHfbtYOAYAFwBAEA4AR2EgI+0rHuKUPA3H0q7AHOQAAAOJGdhID/+8j9HNY4mTPsE4BOAgDACe0kBHysss6eivE9nQvAxRIAAE5sJyHgE5llh+S5tHyJQ8f+j9k3AAsJAABnsJMQ8E8eT0+L85jMi8rynu1y0wDsjAAAcCY7CQH/7+Ppaet87nUqDQileS2lYLHGfgDoIAAAnNFOQsA/ezyda/XPtejnluWG+OSeS8OAFPAAGxEAAM5sJyHgnz+e7in6Wy30c4t7vQAAGxIAADawgxDwnmEYfjrKxX5vb0C6fqk3IH3dM91DCACYSQAAduPu3btbn8JZfeADH4g3vvGN8fnPf36Lw78yHoWATz2enhbwd+JmsT/Om+od4tPTazB3+dJ1Aa6eAADswnPPPRc/+qM/Gn/yT/7JrU/lrH7iJ34i7t27t1VPwCsj4p2PQ0CrxX86706UW/tr86fz0vlRWN5LCADoJAAAm3vuuefine98Z7zmNa+Jd7zjHVcXAjYeDvRNEfG+YRg+E0+K+/FR6wXIFf2twr/3gmAhAOCEBABgU88991y8613vite85jUREXE4HISA83tlPLow+F9Euxcgd81Azx2BSta+S5AQANAgAACbGYv/V7/61U/NFwI2vTvQz0a+FyCdjugLB5FZPp2XLo+O+S1CAECFAABsolT8j4SATUPAz0X9zkC5IUK5IUO1fUT0hYB0eS8hAKBAAADOrlX8j4SATUPAz0e5wI+4WdhHZnn6vCQELL0eYMn6AFdBAADOqrf4HwkBm4aAz8bTrf2lC4Rz05F5nhMCojBPCAA4kgAAnM3c4n90zSHgTW9601bfEzB+Wdhno1zkl4b9RDxd9KfTPSFgrTsDLVkf4KIJAMBZLC3+R9caAn78x3883vCGN2wdAn4xyuP6S7cLjbgZCnKF/5QQAHAGAgBwcscW/yMhYLMQ8N5JCGh9R0DPRcFRmBeZ10IAwMoEAOCk1ir+R0LAJiHgG+JRCPhc5IcApc9CAMCOCQDAyaxd/I+EgM1DQHpRcK7oH/WEgAgh4DYo/Tuu8QDOSAAATuK5556Ld7/73asX/yMhYHchoBYEWiEgd4FwZF4LAae3VZEuEMAZCQDA6sbi/1WvetVJjyME7CYEtIYERbQLSyFgG3stvPd6XnARBABgVecq/kdCwC5CQM+QoJ4hIELAedzGwvo2njPslgAArObcxf9ICNg8BKSt/mkQEAK2d0kF9KW8D9iMAACsYqvifyQEbBoCPh83C30hYB8uuVi+5PcGJyUAAEfbuvgfCQGbhoBfivy1AELA+V1Sa3+Pa3qvsAoBADjK888/v4vifyQE7CIE5IYECQGnd+2F8DW/d5hFAAAWe/755+Nd73rXbor/kRCwSQj4d+JJCMjdDUgIOJ1rL/ynfBbQQQAAFtlr8T8SAoSAyro9bkMRee5it+ffq/dxjnMFCgQAYLa9F/8jIUAIqKzbY89F5CnP7RxF+znCwJ7//WBTAgAwy97G/LcIAUJAZd0eeysiT12ML/l80m2P2cfaDAmCDAEA6Payl70s3vve98Y3fdM3bX0qs4wh4M1vfvPWp3JWP/7jPx7f/u3fHr/0S7+0xeGFgPWteR49RfeSoTyHzm2POa8l9vJvCLsgAABdxmE/v+/3/b6tT2WRw+EQP/ADP3B1PQE/9mM/Fi+88IKegPzyObYuINc6fq24PuWQnyXHWvs8tv43hN0QAICmcdjPbWv5TxkOtFkIeJ8QsNhaRXCtxX4Pw2RavQpbnx9cFAEAqLqU4n8kBGwSAr4+hIAlTlX4Ly36e/5d5gz3mXPOtflz9w1XTwAAii6t+B8JAUJAZd0e5ygi1yh0S4V/77ZzCvkby4dh+PphGL5pwb5a5ysEwJEEACDrUov/kRCwWQj4h8Mw/KsQAk6176WFf644n9vK/+L0MAy/JyLeHRHPd+57yXsCFhIAgBsuvfgfXXMIuHfvXnz2s5/d4vC/MyLeLwSsvs9jC/90evF7G4bhd0fEeyPiFRExFI5XOm7rXHP7WkKA4KoJAMBTNi7+H577gNcaAj7wgQ/E61//+i1DwLuGYfjlEALW2NfcwrjYct9xnOpjGIbfGxHvi0fFf+7cSvuce+7pPGAGAQB40cbF/xci4nUR8TfPfWAhYJMQ8PUR8b/pCThqH3OK5zlFf+3zLRqG4XfFo2E/L5/O7txX6fxK1ggBggNXSwAAIuJR8f+e97xny+L/Ow6Hw3fdvXv33SEEnM0OegL+vhCwSvHaKvxL0+n83p6DtOX/d8WTYT9T6RCgnuPNCTW1aaBAAABeLP6/8Ru/cYvDv1j8R8Tw4MGDPyoEnNfGIeB3xGWFgDnbzF0/t01vgVx6r3N7AXLF/++MfPHfc5z0PEvnKwTAigQAuHI7KP7fdDgc/vN41FI4xKMQ8EeEgPMSAp5yTAgo7XPO8tp2tenc/muFf2m70md8wzAMvyPqxX9pCFApyNTOVQiAlQgAcMX2WPzHkxDwHXfv3n1XCAFnIwQ8pbeVvaZV9M7dV+t8lhT+uc80tzzX8t8q/kvvo+c8c9uVluXmCwFQIQDAldpJ8f+fRab4DyFgM2MI+IVf+IUtDj+GgH8d+wkBx/YGrKHnHOYU/r0t8cXPeBiGr4v+4r/nWKVlpfdYOg7QQQCAK7ST4v8/jUrxH09CwJvu3r37zhACzmYHIeD9OwoBuXXOpSeApK3pre1zn01PT8W0+P/t0V/8LxkCVHvPc0KAQAAFAgBcmZe97GV7KP7/k+go/kMI2MwHP/hBIeBp5y4sS4Vvbl6r9XxuC3zaC/DiZzwMw2+L5cN+WueTvqd0Xm2d3PLaOnDVBAC4Iq94xSvi/e9//9bF/3fGo8L+4YzH8ODBgzfevXv3R2LDEPDmN7/53Ife1Ac/+MF44xvfGJ/73Oe2OPw1h4A559IKCqXiPh3zX/osX9zPMAxfG/OL/2kPQO46g+n89D0cEwKACj84XLNnHj+nfwQjnhQJ0+e0AJkWIuPjmclz+rr0uJs8PzMMw69GxA+t+WY3Nhb///FkXun+4Dkv/q565plnfvj+/ftviIj/Yq2T6zUMQ/zpP/2n4wd/8AfPfehNvfKVr4z3vOc98fKXv7y98vo+FhH3DofDF8fjMJg8j6+H5HXtEfHkW6en8yLzOvf/tLT+GlohoxZacuuVnkvzsvOHYfitMb/4j4h4/eFw+Edx83MakufavNrnndsmt27p32ntfz+4FfQAAKc2Fv//USSt+jMeL2734MGDNzzuCfgbZ34fhgMZDlRaZy29xX/POZeG9LTWudEzcETxPz2nUo9D7TyjMJ3uOzc/dw7AYwIAcEpj8f8fRr3oLw79yT0ePHjw7ULAee0gBOzp7kC5dY41p/ifzisV/+P0ncL09FGad2cYhq+J44r/cQjQ9PzScyoFgdr7jMx0aT6QEACAUxmL//8g2sX+nGAwxJOegB8OIeBsNg4BXxfbh4BagVkKCi2t4nY6r/f8eqZbQSCGYfjqOK74n55frgegFARq09N99h577jZw8QQA4BTG4v/fj6Rwj3qLf/VC4HQfDx48eOHu3bv/awgBZ3PlISCdV9qmp9Ds2S4tgEvn1Vv8d/cIrFj8524D2lP4R+F57r8PkCEAAGsbi/9/Lwqt97E8BNy4+PNxT4AQcEYf/OAH43Wve93WIeBXYj8hIDev55ilbdNjtYr/9Hjj9LTQbhX+01t9fmWsU/yn59kTPnLvK7evyCwvFf9CASQEANiX235Hii9ExBsPh8OfiJut9sc8qsHhwYMH3yYEnNeHPvShrUPA+88QAmqt76WifU6xWVq/dNzeVv5cEJi+Ln42Jyj+c+ee6wHofT3dZ+14S5bB1RAAYD8urfg/9SO9O9Dr7969+7+EEHA2FxwCcsV/bwgY57eK0FZrdavVv1Yo59538/MYhuG3xPrF/5A53niuuV6X3tdTPf82Cn+YEACANYzF/x+P9Vr9F4WAZ5999odCCDibCw0BpR6AUqHZCgK5Y5TWLR0nt05PS3rrMxjv9vObY/3ifyp3/Nz51d5LrVdAgQ8zCADAscbi/4/Fsot6e24D2n2B8P3794WAM7uCENBbnC/Vs89aIVxr6W93jWkAACAASURBVJ+un+0dGIbhN8Xpiv+xB6B0jun7KfUW1IJTz7w5y+HiCQCwvdK3jd4GY/H/R2NGS33yeDBj2YOO/T28f//+64SA8xpDwM///M9vcfhTh4Dc62is06OnRXvOOZVCQHHZMAy/MU7b8p+eWy1gpZ9v7vMs/TssmYarJQAAS43F/3dEf9FfKugfZB69ASEbBu7fv/+tzz777N8LIeBsPvShD8XrX//6LUPAKe4O1PO6tKzUOr10P3NazauFfzxq+f+KOH3xPz3X0uefrjOef27bqLxOjwkUCACwndva6h8R8fmIeN3hcHhTJLfljHKxXgoDpQL/QWGdWkB46vhCwPltHAJ+e5w3BETcLDRLBX2t0M/tKz1ma5hPT6v/i89nLP7Ti4Bzr1t3X2oV+zWl9QUErpoAANuoFf97Dwafj4gXJhf89hb5uSK+1rrfGxSqQeD+/fv3hIDzuqIQUCtMe/aZrtvad2m9XAiYvt902M+vi/MU/6PceaQt/bXrFSL6PrPceimFP4QAAMwzFv9/LPqL/1rLfi4Q1IYBlQJF6TFeGPzaZ5999u/GhiHgzW9+87kPvakPfehD8cILL8RnP/vZLQ6/RQhIl/eo9RaUjheFc269j/FuP+cu/qfnXmr5L4WAKDz3fsaKfSgQAOC8LqHlv3a3n1pxn7uIt1XIl/bX2n+6zvC4J2CzEPADP/ADV9cT8IEPfCDu3bt3KT0Budbr6Jg3d346L11eaynPtfpPh/382nhU/H958VNb35Cex+P5pRCQfga55+ny3LLcesCEAADncZvv9BPxpPj/I9E/vr/ngt9aD0Brfz3Dgp56PO4J+DthONDZ/ORP/uSWdwdaGgJygSDiZqGdm58rNlvz02P03Lu/u9U/HhX/Xx7nL/5HrfMuDWFqPXLH6ZkHV08AgP3ZW1AYi//c3X5aLe+loTu1u/+Mj/uFdecEghvrPO4J2DQE/Kk/9afOfehN7SQE/JvoDwER5SI1Fwgime4JA72t/qVtShfT3ugF2Lj4z30TcO79tz7fUiFf+pxrhAKungAAp9cq6IfC6z0Yi/83RV+r/xDtlv9cq3+rJ6EWAqaPoWOf056A/3bVT6vD4XCI7//+7xcCzmtJCKgNWym12MdkXk/PQhT2Udq+FgRyret3hmF4RWxX/I963kMU5o3z0+W5wr8nMKTrw1USAOB0eor5vRX8U2Px/+3R3+rfatkfl93PbNe6CLgUAnLPuSCShoA//PiaACHgTDYOAb8t8iEgfeSK9oh8EZsW7dN1pmrzavtIeyNqhfSN9zIMw8tj++I/ItMrEfnPOReuelr4FfswkwAAp7Hnwr5Hqfjv6QXoee4ZDtTT4l/qYWgOBQohYBM7CwG5XoDc7SpLhXdUplvFes8+SgEkO9RnOm9HxX96EXCpt6P2ejpdauVX4MMMAgCcXy4cDJVl5/a5eFT8vxDzCv/e4j9X1E/H+9fm9RT9pSCQDQePQ8DfiYi/tdLn100I2CwEvP/xhcG5Yrp36E0rGERlu9ry1i0+S70U0zH/L4+I98X2xf+odM613pRWL0AuBOSmS/PgqgkAsB97CAGfi4g3HA6Hb4v5hX9tuM44XSvqe64T6Cnua8OCasOB/ufQE3A2O7gm4B887gmYGwLmFLNzwsOS8HHjfIdheFlEvCfOe5//mp5vAp4W86UgkE7XCv/SOqX14eoIALCuucX7Hlr8R2Px//p4ukDOFc49F/Tej6cL8PuZdaet/fcL0+l1A7VHrbW/9DrtCdgkBLzjHe+IP/tn/+y5D72pHfQE1IYDlaYjbhboEeXitdYLUNtmTvgYh/08H/sY9pPqCTilz6E0XZtfOw8gBAA4p7TYT+/+s2UPwFj8f2vkC/9cC3upmE5b/HPFf+u6gJ4ehZ5rBkqBINsbcP/+/W/ZKgRERLz97W+/2hDwcz/3c1scfgwB4/cElIr/tNhuFa+l9XL7iWQfpVb+2oXLey7+I9o9G+l6ueI/3R9wBAEAzi/9UrCtbwM6Fv/34mZhXGs1z7W6twr91jj/dN+t5a3iv9RDUAwwj0PA3w4h4Gx2FgJKhX9p2E5PQOgJAq3ti3fRGYbhudhv8T9eBNzT+t/b6h+Z15HMr20HV08AgNOqtfrvwVj8vzbq4/x7hv9Mh/7MbamfDvtJhwPlQkOt56Cn4C8tmw4HEgLO6MMf/vBeQ0BrSFCrsC3tp7ZtbZv0gt89F/+j3s8rKvOiMC9dDnQQAOB0er8AbKsegLH4/8NxfOE/ba0vtdLnWvXTb/vNHS/XC1C7nqAVDGrvJxcC/pvlH/FyQsDZlb4noDV8paegP3a70rCf21D8t74JeM6woHReVJ6BCgEAzqPUEzB9Puc1AOOtPr85KmPioz3uv7foLoWA1rj+3Dql4j93zq3zmz5ufAaTC4M3CwF/7s/9uS0OvZkdhIAfHYbhYTTG3MfTrdrF4TmFR7FFv3KM6XZ3hmF4aey/+B+lw5um72m6Tq0XYM1zgasnAMD6agX8Hr4deCz+/1CUW/trF8zmivNaa/uc1vl0+E9p+1yvQC5YFIf5FB67CwFve9vbhIDz+up41BMwhoBW4V/rKWi18teuMygecxiGL4vbU/xH9A13KoWl3D5aQ4Jy08CEAADzzG2lTy/4TbfN9QSc0lj8/8Foj/kfon8ITa4ILxXkpdt95vZd6inI9Qr09iiUhv6UegaEgA3sMATkivNaD0Fp+E51WE9r/7ew+E+HAEXkC/zomHdsUS8UwGMCAJxP7zCgU4WAsfj/A1Ev/muFf60lvxYWWrcDnYaBOd8K3DqPUq9FKcAUH5NbhAoBZ7KTEPAg6iGg1qLf6kHoGQ704vxbWPyPap9Xbn6tpT8y89KegtxrYEIAgPX0Xsxbug3oKVv/x+L/NdEe/lIqhltFdavInoaAXGFfG8Nfatmv9USUwkDpvQkBO7SDEPAPhmG4H+2CvrfYX/QYhuElcTuL//Q2oL1j+2u9BGsRELhaAgD0Wzq2v9ayX7sYeC1j8f+qeHqM+5BMl4rwUmHdGqqTFvppD0Cp2J/z6Pl24J6hQNPwU+oVGEII2MTGIeCrIh8CSi3Ys4b09MwbhuFL43YW/6PaGP854aDUI5D2BLgeABoEAOizpPhvFfylbwJe01j8f2Pk7/RTa31f0ro+p3W+Z9x+ax+lY85p8S+9j+J7FQLO78Mf/nB867d+655CwDNRHg40p2eguu0FFP8R84v/WihQ3MMKBACo672Xfzo9Z34aBB52n13dWPy/MvLFf2uIT65lPG257xmu0zN8p7e1f9qLUAsCrZ6CUmDpfkxCwH89499kNdcYAj7ykY/sIQT8ahw3HKjn8Uw8Kv7/rbj9xX9tCNDcFn/FPqxEAIDllt7Ss3bx71q9AGPx/w0xr/hvFcet1vlckZ8bptNT/OeO1/oCsJ5zLRX8s4PA4xDwd0MIOJsdhoBnIlO8R99dgErb3hmG4Uvi9hf/o9wFz2mrfrpsum1tv631hAbIEAAgb24hnvsir9rzKb8IbCz+vz76h8HUhseUegdqRXtrmE6pRb5nmE7PMUvXBpTOv9UTIgTsyEc+8pH45m/+5viZn/mZLQ7/VfH0Nwav/hiG4Yvjcor/iCfvLaLe+l8b6hPJeq3XrX3AVRMAYL7SXXzS17XpVghYaiz+f0/0D/PJLettqa8NB2qFiVJrfam4rxX/va33rTCwqDdACDi/j370o3Hv3r2tQ8CvRLsnoDjEJzc9DMMXxWUV/+MQoIj6LT5zQSBXwPcEBKBBAID1tcb5p/PTMLA0BIzF/++MecV/rpDv6SHoKdRbPQGtYUC1IDEnqJQK/Fbrf3cQ2EMI+PN//s9vcejN7CQE/Ou4Wcinz7li/8Y6wzD8mris4n9UGv6TCwK5bUsXAteG/fT2IMBVEgDgOL13AEqL/bWvAxiL/6+LJ0VpepvP6XSp8G0NlWkN1ykV7aUegFbLeytI9JxjK1SUzrMnPO0qBLz1rW+NP/Nn/swWh97MRz/60XjhhRfiZ3/2Z7c4/BgC/lXUC/3pIxsMhmF4NjYq/j/+8Y/H+973vlPtvtQD0DMdmemc3qFDPcvhKggA8LQ1b8XZ882/6fOSADAW/18bnUNWor+47hmm02pZz+3v2MK/FlhaLfxzz/lWhYC//tf/+tX1BHzkIx+Jb/mWb9m6J+ALcbO4r/UATO/280xsVPx/4hOfiHv37sVnPvOZUx6mdgFwFF7PGRYEzCQAwGnVvvV3uuxh3AwEPcbi/2tiftHfKmyXtvzXivhSsKidb6tnohVOes6vN1zcihDw1re+9epCwEc/+tF47Wtfu2UI+AfDMPxytMf/p8X/ndiw+H/ta18bn/rUp+JwOGldnSvqa/f3V+jDiQkA0Kfn+wBqX+5Vu2j4YfK6NwCMxf9XxtMFaOm2n6VitjZMJ1eE14bN9BbRuaFIPUNyakN51ghA030PHev3hIAfrPwbnsw1hoCPfexjW4aAr4xHIeDzkR/6c2Mo0DAMETso/k9sHAI09gBE9IWA3PQxBAqYEADgkd6iu2eITikITB9poT/3NqBj8f+bo1zw14r96XNa6Pa2+ude1wrtWot+bd3e99bqFehp/U+n1wgBfy+EgLPZSQj4xXi6pT+9BuDOMAwPYkfF/0Y9AJHMz7X8u78/nIAAAPWCu1bwz72QN3dNwBgE0gt2az4XEd92OBy+YrJ+7gLf2rCb3qK8VUSXAkZPy3vp+LlzbgWK3nVbQacVAlqhRQjYgZ2EgM9G4RqAx18k9r7YSfEfcdIAMPYA1G4FGpnlkVlWIgzATAIAHKfWal8bFlTrIagZi/9fH/mCMw0S6bJSL8Cc4rlWMNda5Eut9HN7AHqK+9b5zw0Bc96fELADOwgBf38Yhp+LpAfg8W1Dd1X8n0npNp69Lfy9X/7V2g8QAgCU5Irx3i//yu0nd/vPtPV/Oj9nLP6/PDpanjOPuS32ueU9Q3+WnFvPebcK7zT41EJBKZT0vr/ZQ4KEgPMbQ8CnP/3pLQ4/hoB/EU+G/fzL2Gnxf4YhQLV7+Zfu7z8nMAAzCAAwz9zCP52uzX+YWT4ai/+XRb1QXqOg7w0JuXWGjv3lHrXvLJhVaGfWa/VqtIKGEHCLfexjH4t79+5tHQJ+5vEdgnZZ/EecbQjQnC/06jkhYQAWurv1CcAFmRbztT9MuV6B2jCgX4xHxf9z8aiQHP+A3omn/7hGlFvI0l6G0uNh5jk3Ly3O0wDzsLDv8fjpOUWyLNf9f0je73R6/CzS5/F17nMo6bkIe7ExBPzqr/7qISLefMpj5bz1rW+NiEffHHwtxhDw/ve/P77iK77i3If/yoh4f0R8aey0+I8420XA0+nSEJ/c/Nzv1HF+7lhDZTkQegBgqVLh2lPczwkA491+XhI3i+o5rd+tYTq1se5DZtvpPmrnUtpPb09AaZ2e995ar9ZLsLR3YE5PgFuEntHGPQFfFTsu/k9sGupr3/I7pxdAyz8cSQCAm2rFfbo8bVkv7a/3MRaJEU+G/XxxlIvJUpFbG/YyRLl4zRW3S4YG9Rbsx2x/7H5bxfs5QsDfi4gfiA289a1vje/+7u/e4tCb2TgEnNXc4v+MPQCtgr81zn/pMmBCAOCaHds9XLseoDScJ11WevxiRLzucDjcjSfF7jEt47liNfc858473cXuEeecrpdbvxaCes6z9y5ApwoBPxQb9QR83/d9nxBwgZa0/J/xewCmz+Pr3qJegQ8rEQCgrFTg9xb+peE6zWWHw+HfPC7+12jl7imMa0OEakOC5hb2rWsN5uxnToBIQ07rzkC9IaAUpuaGgM16AoSAy7KTYT9T6RCgyDxHYXoO4QBmEADgiZ4egdo6raFA6brp8KFpMTstcHMhoaewzxW8PXfHaYWCOUN/WoV66X2WgsKSXpDe97gkBOSK/2N6AoSAM9n4FqEncUzxf+IegIh2L0C6bu8JKfxhAQEA6lrj+qev0yE/aRFbmi71GJQK4t5Hbax+z20xc4V161aerSK/VOyXCv/e8f89YaP1Oa0VAlrBohgCDofDL0XEfxkbuMYQ8PGPf/xiQsCxLf9nuA1o8xQWLgMWEADgkWOvB0j3VQsE6Tq54rinWC4V+0ta/Ev7nHuhb2/R3nMdRG692mfVG0haIWDJNQE9n3XPZzkcDodfDSHgbC4hBIzv4ZhhP2e8BuCUw4CATgIA9MkN2YlkXjo/t22rB6C3YJ3byj3drnbx7/R17zHmDO9p9XCUPo+5n1NPaCoV8ONn1Buk1g4BD4WA87rNIeDjH/943Lt3b09j/kt67wRU2gZYkQAA85UK/lxIaAWHUnFbavFeWoS37gC0NEzUiv5cCKi959y+ensEloSotNifFu2loLRWCGgOpXocAt4eGxACboc1i/8TDwHqOoXGNLAiAQCe1mrhz62bvp5ulwsDuYK4NO/YR2+BP7fVPz3ndNtWS35pvVpPQOvzK63Tel9pSBpf9/aWlAJCq+DvCQH3Qwg4mzWG0pzL2i3/ZxgCVLrod84Fv8BKBADolwsH0+fpeqVegOn8h4XpUvG7tPjvuW1l2hLeKvh7eyNK7yUy81qhoRUEWr0ga4WA0mcrBFyA2zCk5jac48T092MtCOSmgRMRAOCJ3q7qdN1csZ9Oz2ndzhXSvcV+qUAtrTvnGLl1awV8bllk5tc+qyjsqxWWSkV/bwiYfn5rh4A51wUIARvYc4F9qnM74zcB1+bN3QewkADAtasV/bkW/lxr//icrjcnCOSK6VwwqLV09xb9436WXgeQK67nFuW1Vv7StlE5Tu1zyx2rJ+ScKgTUAltx3tYh4C/8hb+wxaE3s8cQcMpzOsP3AEQo4mE3BAC4KRcKaoV/+pwr/tNlvS3WPYV/qWg85gLfUrFfKvprRX4tHEw/z9ayh5P5veGp9PndhhAwpPO2DAFvectbhIALPpczXARcu/OPYABnJgDAPLnegDm9ABH5VvBS63hvoXpMaOgNAbWiv/VeojJdCgfp+4jM+qVtS5/fOUJA74XBvSHgqXMTAs5rDyFgD+ewIeEATkAAgKflWv/nrpMWumnBmq7TU6zOLf5LBWVPsV/rhegtvtP3+DCZnxb2vZ9nLlCVzqEWTE4ZAqZBQAi4AFveHehcxf+ZhgA9dciV1gEWEAC4ZnOK/VyxWis4R2nBP86bW7D29Agc+8i1urcCSqsnoNR63/MZ1gJFur/bEAJ6hg/NDQFviw1cYwj4xCc+cfYQcM6W/zMOAUpf5yj84cQEAOgLAun6uTBQWictQiPqheuconTtwr+1TlqEl95P6TNIP5+5j3Hbh3Fz36XgtJcQUCr4jwkBD0IIOJtzhoALG/ajoIedEQAgLxcKSvNyhWitgE2L5lLre+71qQr/UnFcK6qn8yPa5577jHrVegTS16XPt/VZ7jUEVG/bKgSc1zlCwBbF/w6+CTjCl4LB2QgAcFOtdb/Uop0uy62bK5ZbLdZpwbdGCCi1fM8p/nPnmH4utVb/3LxWcMrtoycEjM+5EDDn8y2FgFZRPycE5L4nIF1PCNjYKUPAVi3/O7gNqMIfzkgAgLLelqu0CM09T1+nxXKp2J8WfL3LSwXtkpbvXBFeCgK5zyEXgHKfWY/a9rmAMp5r+nyKEFAr4HuvCaiFhdx8IWBjpwgBWw772eAi4BcPvdWB4ZoJAPBIqxjtKXKn80pFcq1FPS1IS8VpKzS0wkDvvksFc09vQKn4zxX+rc8sXTe3XTqdFv3nCgG11v7psqFzvdzxhYCdWDMEbHmnoRMbQpEPuyMAcO1Khf+c1ul0m1oYKM1vFf+lYr/n0dquFQZKreutoj03nX5euXm5dUr7rQWMh8m82x4Ccq+FgI2tEQLG4v/Tn/70imc2z0Y9AIIBbEQAgLpcAVsqONPXuW3mFv/peq3egFYR23OcWkv6dF7rc8gtq63bE5ymWvscz3s6b40QMC3gay32a4aA9IJjIWBHxhDw0z/907O33UPxH7Gbi4CBMxEA4IlWa3Rp/VJLdqkInb6eLssV5a0ivqfFP12/VvSWlpXOv/Q6V7TXQkFLLUy09r92CBin0xCQ6w1IrwvouSag1Nq/JAS8tfyRns5b3vKW+It/8S9ucejNLAkBeyn+z8ydfmAHBADoL/Snr0vb1IrQUnH8MJlXavnPFaq5x9zQUCuCe1+n77f2uS1tEZwbLM4VAnI9BLnpni8FWzsEPIyNQsD3fu/3Xl0I+OQnP9kdAvZW/G94ETCwAQEAlpvTIj59XSriW4V/blmroJ8bIJa8r9J0+rpW+JfOpWfd3HHOGQJyj1YIqM1bGgJyPRJCwJn1hIC9Ff8RhgDBtREA4GnpH6tSi3NpnWNftwLCnMeQPM8NBz0t/bXwkH42ObVivxUGav825woBtcfcENA7HKh0TcA0HAgBG6qFgD0W/xFn6QEw9Ad2RACAR1otzr3L1ir2S4V/bb1aYV86ZsTNc6gV+7llpSK/VvzXCvuc0vpbh4BWIJgTAkqBoLQfIWDHciFgr8X/ic35OQfORACAm1otzrnXpelSAd0qzHOPaGzbs49akdtT7Nfec+n9R2FZOq/0ntN1S/MuNQTUQkHtTkJCwMamIWDvxb9rAOC63N36BGBHhih3UY/Lpuukr6MwPS1M0+nUdHntfGpKhfk4XSqUS8tr65de9xTvPS2D6Wc3zks/l9q/T+7f4hCPiuI7k3nj9Pgcyfzx9ejOZPpOsqz1Xtb2TOH4N+YfDocYhuGtEfHdJzyfrO/93u+N+/fvx9vf/vZzH3ozYwj4/Oc/H5/5zGe2Ph2AiBAAoKRWZJbmLQ0BvUX/kgKytyU+FwTS7Wqt/rVj5fazRO2zni4/NgRM58dkfisEHBvWjlXq0d1VCHjb294Wd+/ejbe85S3nPvRmPvnJT259Ck0uAobrYggQPK2nUK21ZPe0oJeG2qSt8OmyOY/0GoDI7DM9Vm5ebrr2GZyq+J9uP/c8cr0b09fpcKDSZ18aDtQ7FKg1nr81HKhn2FBp2Y1zezwc6PtiA9c4HGjvDAGC6yIAwDy1FvXp61bRX5qXFp259Xse6Xa5MFCbVzrP2ns9dfHfu99zh4DS61OEgJ715oSAIYQAQg8AXBsBAG5qFa1zQkBvS3utcJ/bC5DbJpLlPfNKQSb3OeQ+i1MV/7l9XlII6GntFwIAWEwAgGV6Q0BuXk8QKBXfPY/cNrVhRrXgEo3pWkAqTa/pFCGgFKrmhIDeR8+3Bl98CPhLf+kvbXFoJgwBgusiAEBeT+t1bwjoKehL8+e2/qcFaSto5I5bei/pe966+M8dY40QUPtce0PAnDBw9SHge77ne4SAjRkCBNdFAICytULAOF0rvHMFe2nb1iO33dJA0PNec++5FBBO5RJCQOvi31IgEAI4mh4AuC4CAPRbEgJ6g0C6LNeKP+cxN1AsPefcNlu1+G0ZAtKCf0kIKBXwPfOEAPZqq98HQIUAAHWlQrK0Xq3lPJ3XKqxL6/U8Svt42FinNK/1vkvTvXo/j579lM7lVCGgVPDflhBw4xweh4BNbtQvBGxDDwBcFwEA2uaGgNI2va3xS4vgY4JBbn76One82vScc64tW/I5lM7p3CEg1zuwtxCQfTwuCIWAKyEAwHURAKDPnBDQKp5b80tF5hq9Ab3n1VOYt/ZXM3ebPYeAmLzu6REQAjoIARdjScMAcGICAPSb0+JdK0Cn83sK4aWFfmn70vL0dW296by5lhYEty0E5Ar/Y0NA62LhY0PAjfMTAq6DHgC4LgIA12yN4rW3YG4V6nOL+mP3kS7vCRG57ec6tjXwNoWAUuFfCgGlFvnebw2eGwKGwjIh4AoJAHBdBACYr9SKXls/LTJ7C9ne1v+e/ZWK/jnnHpnpXscW/9P9zNnXOUNA6fUpQ8DSW4ROi30h4Mr5HgC4LgIA1+6YYnZuYVwrwE/xRzK3/zlF/1qt/uO2a9tjCKgFglOFgLSIXxoC0ocQAHChBAA4ztIiOVdkz2ntn9MjMLeXoHSuS52yBfC2h4DS41zDgaYt/rVhQrsJAX/5L//lLQ598fQAwHURAOD4P1DHtpa3Cvi55zF3P6do8Z/u49RucwioBYNThoDcumkIaPUEfG9s4K/9tb8mBJyAawDguggA8MgaheraredLW/zn7r93/pL9n4sQMC8ElNYdCse48Z4Oh8OdEAIAbiUBAJ5Yq+itFdVrFddLtM5hzcJ/i/e4ZghI38NtCAG982ohYNZDCLgchgDBdREA4DRaRfA5wkBP6Fj7PLb+Y79WCBjn3aYQkBb2rR6Bnmkh4EoYAgTXRQCAp61dwPYU2HOH+vQ+jjmnJbYu/kdCQDsELO09yA4JOhwOz4QQcKvpAYDrIgDATadqmT9Hq3/ruKd8X3siBLQL+zkXC6fFf64nQAgAuCUEACjbenjOsfs8dWG+x8J/6pwhIP281w4B0+meYTqti4J7g8Xc4UBCwC1lCBBcFwEA6s5V4J5yyM+pzvc2OFcImC5/mEyvEQKKLe9RL+LntP6Px+jpBdATcGEMAYLrIgBA295bus/pNn4W5w4BEacNAWOR3jsUqFTs59bLfTNw7bsBsu/pcDjcjYjvyXx+JycEALQJANDvthW+a7qNhf/UuYcDRZwuBPT0BpSK99aFwK0W/9px0xDwa0IIuDX0AMB1EQBgntteCM+x1fCiUzlHCEiXnTIEHDsUqLRtq8egOxgIAbeHawDguggAsMylFcejS31fo2sJAdNte3oB5vQG9JzHtCfgi2LDEPBX/spf2eLQt44AANdFAIDjXUKxfMlFf+q2h4C5QWBcf5U7/WT23XxsGQL+6l/9q0LAtq7l9wrcKgIArCMtem6D23a+a7qtIaAWBKbzelr2c0FizrxZ8w+HwxeHELBbegDguggAcBp7LK5vY0g5pdsUAnK9AbXlc3oJlgwDKp1LU5qRogAAG2dJREFUq3fgnRHxK5nP7+S+8zu/M17ykpdscWiA3bm79QnAFSgVmms3uSnq5xui/99hum5uu/Hzz60zXfYwHjW+9Px7peuOAWKc9zC3UeJQeJ1b50EyfUhej8d+mFmvGmiGYfi78SgAfFHHOa/qE5/4RNy7dy9++Zd/+dyHvjXcBQiuiwAA2/GHcR/2GgLGonpa9Ec8XfTfiXoIOCTL0zCQFveHzPxp4T89n2nR/zBuhoEXjzsMww/Ho+L/pZVzPYlPfOIT8drXvjY+9alPnfvQt4ohQHBdBACAbULAMHnOGUPCuF1uOqIcAtLif5yXFu7Tcx33dZhsP309XTcNCuO6acv/O0Pxv3t6AOC6CAAAj5w7BMRknZ4QMJ2ePkc8aZ1PrwlIj5O21E+PnwaCXAhIC/2HhXkREYdhGN4Tiv9bQQ8AXBcBAOCJc4WA3Dq5EDAdLjRKhw/VCv40EPS05E/n5abTUJA7xjAMw/tD8Y8eANglAQDgaceEgEi2LYWAXOGfCwG5awWmLe3Tlv+Im8V4Kh2yM75Ox/lPi/n0eNMgkU6Pxf8/DMX/raIHAK6LAABw09wQEHGzyG8tr4WA6XOpB2DcV8+dgW4Mz4mnW+9zvQPpMJ/cOP8b4/6HYfjfQ/F/6wgAcF18DwBA3tyhC0Ph9TidW55rvZ8uL31vQO4++7X77+ceDzP7SuflvmugOH8Yhn8Uin+eZggQ7JAeAICyOT0B6frH9AREY964/XQY0I078MTTBfp0+3TYTm5Mf+5i32IvwDAMHwjF/62lBwCuix4AgLpjegJay2ut/7lCPp0/nS59M3CpFb/V6l9q7c+1/H84FP+3mgAA10UAAGhbGgJyQ4FK+yuFgPS5VMyny3P7rgWByMwrFf8vzh+G4adC8X/r+R4AuC6GAAH0mTscqLTddLjOIbN8XCcy601NhwKlw4FyQ3+m2+S+tCudF5Ev/l8MC8MwfDIU/wC3jh4AgH5zWjNzQ31a00uHAtXmpcd5mLyuDu8pLRuG4Z+E4v9i6AGA6yIAAMyzNAT0Ls+FgNo60+neoT6t19UegGEY/lko/i+KawDguggAAPMtadXs7QVI55Va+qMwL922tJ/eoT5PLR+G4adD8Q9wqwkAAMv0hoDaUKDavFrrf7pOT6Ff2u5hYx8vLh+G4TMR8SOh+L84hgDBdREAALbT2wuQe66t11qn9Lr4PAzDz4fi/2IZAgTXRQAAWO6UvQC9+2nte5w/dyjRi/sahuEXQ/F/0fQAwHURAAD2qVbY18b7t+bnxvbn1h0iIoZh+KVQ/ANcFAEA4Dhrt3D2tOjP3c+c5dOW/38Ziv+rYAgQXBcBAOA8esbln/pY3ccchuHfhOL/ahgCBNdFAADY1ikqr6P2OQzD/VD8A1wsAQDgPPY0xqJ4LsMwRCj+r44eALguAgDAcdaunA6T52P2Xdr2UFo+DMOdUPxfJdcAwHURAAD2L63OctO5Ci6dX9xuGIZnQ/F/tQQAuC4CAMByvVVTrQg/dj+tfY/zcz0LY/H/RaH45zQMAYIdurv1CQBcsVLL/o0ivfCcFv+HzPx02VPHGobhSyLinRHxZfNP/ziK//3QAwDXRQ8AwDLnav1vDf/JHaMnDMQwDF8ain/CRcBwbQQAgPmWVEs94/jT12lrf21+7bqAG6FgGIYvC8U/wFUSAADmmVP8z23dT+f1DBGaLrsTN4v9dF4Mw/DSUPwzYQgQXBcBAKDf0uK/ddee1tj/2vw7cXNfxR6BYRieD8U/CUOA4LoIAAB91iz+c9PVO/XE04V9blmp9X98HIZheFko/snQAwDXRQAAaFtaHdWK/Z7tWiEgFwiyrf/DMLw8FP+cnx4A2CEBAKBubvE/Z9z/nBb/2pCgXA/Ai63/wzC8IhT/VOgBgOsiAACUHVP8Lx33Xyv40+3vNNY9DMPw5aH4p0EAgOsiAACsY63if7r8TrKs1No/bfGfFv+/NhT/bMsQINghAQAg79QX/abzSuP+SyGgVPiPw35+XSj+6aQHAK6LAABw09rFf6voT+elQ35a9/d/alvFP3MJAHBdBACAp53yjj894/5LIWBctxoGhmH49aH4Z6YzfA/AEIYDwW7c3foEAG6xWgt/Or933H9tqE/uzj/j/DvDMPyG2Kj4j4j4zGc+E+94xzu613/22WfjJS95yarncIp93r17N77sy9b9SJ955pl46Utfuvt9nsA0CAgEsBF9fgBPrDn0Z07xfyfzXBru81TRH0/G/G9a/EPBlxwOh2+PiPsR8WDyPH39MJn/MHlO500fQ+E5Ms+53ogHa75ZuC0MAQJ4ZM8X/Y7TueE/B8U/F0YPAZyYAACw/4t+S8N/DsMwfEUo/rlcwgCcgAAAsK61L/pNg8CLQ4CGYfiNofhn34z5hx0SAAD6rTnuv3Xxb+06AMU/t5k7AsHGBADg2vUO/zl23P90fk8IGKdvjPsfhuE3heKf26Gn0D8mDAgSsIAAAFyzNe+ENudi39ptPkvT460+f3NE/Ego/rk9elv8BQE4EwEAoO1UQ39q4/6nr8cx/4p/brve6wGWDBMSAqCTAADQb+1bftYuBE7H/P+WUPxz+9S+CXgozC/tY87xgAoBAKCuNUzo2Ft+Nu8KpPjngpQK/rXvFCQIQIUAANBnTuv/nLv+RDKdjvn/ylD8c3vVbgNaCwNz5gMzCQDAteq5AHhp639p3pwhQAfFPxciLdxrw4GW7K+1LpAQAIBrNPfuP72t/7WhPaV5ufmHYRi+KhT/XJ41L+xV3MNCAgBwbXqL/yWt/7XlpRBwJ102DMNXh+Kfy1C6CLj0Ojddmlebv3Q9uAoCAEDd0tb/6bxaT8A4Pd7q86tD8c9lKQ3/Se8C1Boq1LqAGOgkAADMt7R3oHbP/xiG4WtC8c9lyd3qs6eIn9tDAMwgAADcVCvgc9NHt/4Pw/BbQ/HP5Sq19vcW/muFAOEBQgAASOVu51laPne/2dZ/xT8XLjfEJze0p/alYXOPdew6cNEEAIA+c+77n2vxj8z0YRiGrw3FPxfqi7/4i39blIcA9Y7pn/tNwb4vABoEAIC8Ne4WVLr/f4Tin+sxFuMPoz0UaM61AKX1gIa7W58AwC3SKvZr3/abFv9fFxE/HBEvWfUMYT3/OiL+1ZH7qBX4tWFApXV7A0HPcrhaS8eyAtxGc+7eUxriky67M5lXe9yZTg/D8IqI+NKZ538un4tHrbW3wa9ExL/c+iR63b179/c/ePDghQWbtu6DXxpnn2tpHx8Pk9cPk9fTx4PM6weZ1+P0/cry+5Xt0/0PmeOn55l7P6XgkYaMB4XPFS6aHgDgWpyiwaNnn9lrAA6Hw2fjUaF9Z/I4JNO5xzMzpp/p3Kb2OFRe58LNGIim82Iyv3XnpKjMS5eVpvdqOhRm6bal+WsEgFoQaK1X2u/cgjz3vmrbH0MPAVdLAACuwRoF4jHDf0rrz7nOILd+brpWVOema4bJ+unrKEw/jEfF/vg8TKbTY0+3OyTTQzIvkmXpvNvgmIKzdbFs7U47uQJ6LOCnr9MCv1To5wr8VtFfOofp+eeGAeXe+9zhQAp9SAgAwKVbcjFv7Vagcy76nc7P9QTkjlX6/oDc/kr7X7P4Hyavx/kRNwvxXPH/cDI/DRLTfZb229P6X5q3paUF55ztSi3hva3/Y9EeUW/9L7X858JAz3CctPjv6bHo+Rxa84AJAQC4ZEsKw7mt8nP31VPwlwJArcg/dlhMWqTlWuvHwj7XCp8W+7miP+0dSQv9XKjI2VMIOEex37N9qZCOuFlsp0X6uE7rOoDcUJ/S/Ih8SJguz83Lvbfe+Qp/6CQAALTNLfRbvQlzi/Vc8bxkf7UCKlfwp4XVWKCPQaBmWvTneg/mfi653oJLsqSlezo/HQ6TCwOlwjst6GsBoHRdQK1nIJ1Oz6F3Xun95j6P3DqX+P8GFhEAANbT0wpdKnaX7v/Ylv60+E+LrWmr/LTwT8f4T5/Tor/WkzF9Dz3vb2/DfVrmFJ1L1m31BEznTadLBfrcEFDbT+44uXm5c2ydP3AEAQDgOK1hKscU/D3HyektkHKF2bSIz7X2pyGgNexnPPdaAIhof049116cyjEF51rFak9rdqnlv9TCng7VyQWBuY9S0V8KAem55s4z976XBgLhAUIAAFjLklbs0hCX1vZTtaEQpdbhUsv/9KLfXGt/TKZ7rluojfXPva/ezyN1ygBwzoKxdqzWkJdSy/n4PP3/UCv8jwkBuR6E0r7T86oV/TW1nhCgQAAA6G91LrVon/o8WlrDQEoFf1rsT3sAIsohYIjysJ/S9QoR+cJ/7me/d3MLz6WFau3fPJ0u/X/I9QLknudcB5ALEemxWr0EufOs9Qa05gEJAQBg/3paRXOFfmnoRcSTwn3a8h/J8nE6HfJzmKw3ql2ovGS8/20p+OdYMxz0DI1Jp3MXAa/ZA1AaCtQzPKh0Xq0gUHrfKcEAJgQAgG0sKXBLrb6t4RO1cJAW/DGZnyvk08fDZHn6OjcdUQ8C6Tpzl61lrdb5NbRav9OhMKXW/+m8Y3oAepaVCv7eebXPIhcCcssV/pAhAACcRqtAHTLr5ObltkuL+dw60xb6aWv/uCwd6jOuN04fknV6xvpHYV46P3XpLf8R/YXo0vVqAWB8Tl+vcS3Ag8a6QzK/FlpbPQHHqoUGuCoCAMD+zRkakQsHueI/V/RHPCncp+tM59d6BdJ1p9Ot16meIHCKsHBMYXiqorKncE3XmU4/TNbJ/X9Ji/Pahb21Ij9dpxQqctvkWvZzwSX3HmufDZAQAACe6LmDz6n0tP6Xtis9cuP9Y/JcavlPC/x0/p1kefo6Nx3JuiW3vQdgqxCwpAegFQRqQ36GeNT639PyXyr+e1r+c+8vt37PZwE8JgAAnEZPQV9bp9SaP2fZdHpa7E+H+KQt/60W/vGC4dbwn8gsT13q0J85BefS4rRU3JZa0dNtekJAz/UApeJ/7nUD016KUhCofVaKfZhBAAA4v57C/xDl4iw3pn+Uu7tPbdjPNBDkhvPcSZ6PGe/f0wOQW38PztGyv2S9Us9AqfCfLqv1GtXu4DO3sM/1DORa73uGAZXeW0tuPWGBqyUAAGxjGgKmxfq08M+N4y/NqwWCcd2YLGvd4Sd3TqVW/9YY/yVDfvZU/Eecp1V/zj5bBW2raO7tBciFgDnXBrQu/p0eKwrzSu+h9XmUKPy5egIAQF2ttb53+9FhMp0r/nPHyxX5tbH909e5YT+1Ir50l59TjfnfW5G/li0DQK5HoPT/I9cSX7qot9QD0DO/1UOQrls6x9x7qL0/oEAAAFjXnMCQa10f59eKnkMyPzfEJ51XGt+fLltjzH86L11Wmr4E52iJzhX56XStNyA33n6cn2udz925pycE9A79Sc+zVOQf0+oPTAgAAE8c09qfK+JzF+hG1IfYpAXPtOV/nJ/2AEz3nWv1n66b6w0Yl0+fS2Gg5zky03M+1z0Eg1MWl2uGhFIYKLWSj69zxf/4nBb7tR6B1nCg3H5y+5uuPz3H0nvpDQalsAFXTQAAmG9ayKfze+aly9LiP23hL607bf3PtfDm7vGfXvBbCiLT7Xtb/WuvUz1F/lZBYEnBuGaRWdpX7RitnoC0EM61yJcec4YF9QaDdPvpedV6DOZ8BjVCAVdNAADo11vglwr7iJuFflrgj69zBdx0WE/tm3yn83It/b1j/dOwEJXnaMxP7aGVfw3nKPx7jlMqkHta0ks9TKUW/NoY/9zrUq9AGjKmF6/nzqsWCHKfT+88uDoCAMDxaq38uXUj8kEgbQmtPdda+Q9RHuozXSct/h/E0wX8GuP+S/NvewDoLSTXKjhrw1xy81v/l0r/n1qt/xE3i/qIcgDITZce031Pz731XlvzgIQAAFA2p7Cfrl96TteNeDoI1ObVhvr0XNybnkPa2j9U9pF7TteLZH5t+hqcOiC0AkErCNRe10JAqTegt2egtd/0PErnVXo/KYEAMgQAgPmFfs++csV/WuDXhgONxsJ/XD5Op8tzr6e9AaXzaoWHyDynr1vLSm5LMDhVEblmSJgbAMbnVg9AKxCU7hBUa/0vXSAcmXVbhb3eAVhAAABoa7Xgz91HrfgvXTNQKvTH6VLLf67Yb13km3sdkQ8C6fScon7vAeCUBeOcfc8JALkgUJtXammPKN8ZKFf4t4r72vJIXqfvp9b633rvOYIAV08AAJinNwykRX5Juk5PC//0eIfJ8nR+69Ez5j83HVEOA7l1li7f0tIice3icm4R2xsEStOlwrsWBkqFfm1+blnEzWPl3lOu+M99Bq15cLUEAIB1pS35pSK3FBBy83JDe0rDfdI79yxp9Y/M/Cisky5rzbtka7fqz9m+VvSWCuaenoDeR08vQanlv9bCn3vuCQIKfqgQAACelhbtrSI+4uYQnnTbXDFSKv7THoBROqY/nVd7RDzdS7DGmP/Sei23JRScuoBcOyz0BoBxuhQE0sI8N++YnoHSvJ5H7T2k77lEMIAQAAB6TQvnWigYl0fkewFqLf/pff5Legv/aVE/PY9pL0D6fM1j/lPnKBZ7jzEnBMzpDSi1sJem0+E5rQDQGxTWLPr1BkCDAABwU6vAr20XcbPgn0pDwbSwupN5HuJmGBiSZdPnUggoBZjWc+11+r7mzL9NlhSQaxedpf3VjrOkB6A01KZVpKd38pnOW/rIHbt0vrn3mRIE4DEBAOCRWtE/bbVP18kV/en8Uqt/ujwXAkrj+qfL0v2lrf+9Q32WjPe/hAL/HOa04B+7/zk9ANPXteJ/+rqnUM+1/pemc/vKHTudl6P1HzoIAADLpGEgLfJzr2shIC32x+fSsdMW//RLwXoCQG46Mstzr3PvoeU2hIW1ivC1HdMDkE7XegTS59w1AXOK/1xPQWmb2vFa729uMICrJgAAPNHTC5CbV9tuum5EOQyUwkF6rLQHID23VgAoTUdhXvo6OubzxCkKz1oLf239nkCQ3ou/1ROQTqeFfO+3++bWbb1O30dK0Q8FAgBwyXoK87nb1uZHtHsBxte1Fv7pPqct/bnzyBX56bJ0+bmH/Ny2oHDOwrH3WHPOqVUc14ronmK7VMxHlFv403k9+yq9br2X0jrAYwIAQL9aIZ5OzwkEkZk3Fv+5lv5csZ8uT8+rp9W/Nj9d3pp3LZYUl2sWpKVW/dq6PT0BtSDQU7S3tptT8KfFf8/np+iHimv+pQ1cj7m/62ot3j3DZWot772vl2zTOn7pvHuvASi55r8l5yg0j23drrWS58JArsU/t5+5hf+xr9PzbQWa3LKUoMBVuuZf2sB1mfP7rlUAnysEpMeaW/DPGerT07Lvb8Zxjik25267tCegNSwofe7pKZjzurR9er5rFP+968DF8cscuDa9v/d6egFar3tCQG1ZKwjU1sk9l+b1LMvxN+SRcxWRx7Zm14rj1jCg6bxWgV5bp+ci49q+S+dVW69GAOAquQYAYL4h8sXvdP5YWOSmp0XHobKsdKzavtP95Y43fU7NHf5Tc1sDwlZF4Zqt/LV1a4XznCFBuf32FPQ9+zxH8Q9X67b+cgZY6tRDgXLbLZ3uaf2v7ad2bj0BoLYex9lyONDcAJDOn1u89wwl6j1W7hxr27QIClwlv9iBa7M0AJS2XRoCetZpDeVpDUXqHbpUWqe1Ln3WKDLXDAxzC+g5w4Jy6/RuM/e4tfV6CQBcJb/UgWsz9/fenKK5p+CeEwx6x/Cn++k9lxwBYF1rFZhr9xiU9tfTAzBO9wwXSrftXafnHGrr9lD8c7X8UgeuzZLfe2uFgHG6NW9O4V875tzhPIr/01iz0DzV0KEl1wgcEw5q81rHba3bSwDgavnFDlyjY3oBatvPCQHp/LkF/twW/jkX9/rbcB7nKEDntP6ny9a6VmBOr0LPej3btij+uWp+yQPXai8hoPR6bqv+KYt+fyv6naKwPOUwop4wUBsudGxIaE2fovhfug1cDL/UgWt27hBQ28eSC4yPKfrnXATMaexhaFBvMT33IuF0ujd89ISO1j5aFP9cPb/ogWt27PUAtX3MGZLTWr93Xy7uvS69hewxF8a2CvEly5feyUfxDyvxyx+4Zkt/B865uLZVnM8t6OdckNxar8TfhuPtsdCcOwSotM5aBXzvdr3n0GOP/y5wdn7JA9fsmN+BS0JAbb21hvP0vCcXAZ/HVhf5rrWf1r6XFv292+aWneouSHBV/JIHrt2aIaC1v6VBIDd/bhHv9/11OMV98I+5k1Bt2dx9KP5hJf4gAJw3BKTLj7n7ztZDfS71b8iWxeJWx57bgj9nnd6iv7aO4h9WdKm/vAHmOFUhvHYBf8zwHr/vL9u5xsQv7TXYovBfY3u4SP4gADyyxu/DY+60s6Q1f62LmNmvvfUIzFn3VEOMein+ocAfAYAn1vqdeOwtN9e4Zaff76ROecHw3P2vsY8l+wfCHwiA1Jq/F9cYjnPOb+j1N+F4l1B4rjHuf+42e/hSNLgaftkD3HTOwnqNLyNba79clq0vnJ1zcfAaFP7QyR8IgLJT/I48dYv+KYcxcTuLzHOd86l6DtY6NvCYX/AAZaf8HbnnVnx/G/pdY+F5iu8aOMY1/hvAUfySB2g7x+/Kta4LgGNsPWxoj8eCi+OPCECfLX5frv0FZVyO23B70Es8PgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD8/+3BAQkAAACAoP+v2xGoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABwFh4/EMaoJDHQAAAAASUVORK5CYII="/> + </defs> + <use xlink:href="#source-24"/> +</svg> \ No newline at end of file diff --git a/src/assets/cursors/macos/resizeright-1__50-50.svg b/src/assets/cursors/macos/resizeright-1__50-50.svg new file mode 100644 index 000000000..5c96c8e7d --- /dev/null +++ b/src/assets/cursors/macos/resizeright-1__50-50.svg @@ -0,0 +1,7 @@ +<?xml version="1.0" encoding="UTF-8" standalone="yes"?> +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="767.314286" height="746" viewBox="0 0 767.314286 746"> + <defs> + <image id="source-24" x="0" y="0" width="768" height="746" xlink:href="data:image/png;base64,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"/> + </defs> + <use xlink:href="#source-24"/> +</svg> \ No newline at end of file diff --git a/src/assets/cursors/macos/resizesouth-1__50-50.svg b/src/assets/cursors/macos/resizesouth-1__50-50.svg new file mode 100644 index 000000000..19a3aade1 --- /dev/null +++ b/src/assets/cursors/macos/resizesouth-1__50-50.svg @@ -0,0 +1,7 @@ +<?xml version="1.0" encoding="UTF-8" standalone="yes"?> +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="767.314286" height="746" viewBox="0 0 767.314286 746"> + <defs> + <image id="source-24" x="0" y="0" width="768" height="746" xlink:href="data:image/png;base64,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C/wAO3KWLxeAq9j757awD7Bze/8iAaDs0Z/jAj8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAd/j9IsyOyC+zynwAAAABJRU5ErkJggg=="/> + </defs> + <use xlink:href="#source-24"/> +</svg> \ No newline at end of file diff --git a/src/assets/cursors/macos/resizesoutheast-1__50-50.svg b/src/assets/cursors/macos/resizesoutheast-1__50-50.svg new file mode 100644 index 000000000..121494545 --- /dev/null +++ b/src/assets/cursors/macos/resizesoutheast-1__50-50.svg @@ -0,0 +1,7 @@ +<?xml version="1.0" encoding="UTF-8" standalone="yes"?> +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="767.314286" height="746" viewBox="0 0 767.314286 746"> + <defs> + <image id="source-24" x="0" y="0" width="768" height="746" xlink:href="data:image/png;base64,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AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACe+P8Bl9zOjsSKkTkAAAAASUVORK5CYII="/> + </defs> + <use xlink:href="#source-24"/> +</svg> \ No newline at end of file diff --git a/src/assets/cursors/macos/resizesouthwest-1__50-50.svg b/src/assets/cursors/macos/resizesouthwest-1__50-50.svg new file mode 100644 index 000000000..616167252 --- /dev/null +++ b/src/assets/cursors/macos/resizesouthwest-1__50-50.svg @@ -0,0 +1,7 @@ +<?xml version="1.0" encoding="UTF-8" standalone="yes"?> +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="767.314286" height="746" viewBox="0 0 767.314286 746"> + <defs> + <image id="source-24" x="0" y="0" width="768" height="746" xlink:href="data:image/png;base64,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AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHA1/j/LGrm9Nk4scAAAAABJRU5ErkJggg=="/> + </defs> + <use xlink:href="#source-24"/> +</svg> \ No newline at end of file diff --git a/src/assets/cursors/macos/resizeup-1__50-50.svg b/src/assets/cursors/macos/resizeup-1__50-50.svg new file mode 100644 index 000000000..4a7b5d494 --- /dev/null +++ b/src/assets/cursors/macos/resizeup-1__50-50.svg @@ -0,0 +1,7 @@ +<?xml version="1.0" encoding="UTF-8" standalone="yes"?> +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="767.314286" height="746" viewBox="0 0 767.314286 746"> + <defs> + <image id="source-24" x="0" y="0" width="768" height="746" xlink:href="data:image/png;base64,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"/> + </defs> + <use xlink:href="#source-24"/> +</svg> \ No newline at end of file diff --git a/src/assets/cursors/macos/resizeupdown-1__50-50.svg b/src/assets/cursors/macos/resizeupdown-1__50-50.svg new file mode 100644 index 000000000..ac4f5b15b --- /dev/null +++ b/src/assets/cursors/macos/resizeupdown-1__50-50.svg @@ -0,0 +1,7 @@ +<?xml version="1.0" encoding="UTF-8" standalone="yes"?> +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="767.314286" height="746" viewBox="0 0 767.314286 746"> + <defs> + <image id="source-24" x="0" y="0" width="768" height="746" xlink:href="data:image/png;base64,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"/> + </defs> + <use xlink:href="#source-24"/> +</svg> \ No newline at end of file diff --git a/src/assets/cursors/macos/resizewest-1__50-50.svg b/src/assets/cursors/macos/resizewest-1__50-50.svg new file mode 100644 index 000000000..2c8b9a05a --- /dev/null +++ b/src/assets/cursors/macos/resizewest-1__50-50.svg @@ -0,0 +1,7 @@ +<?xml version="1.0" encoding="UTF-8" standalone="yes"?> +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="767.314286" height="746" viewBox="0 0 767.314286 746"> + <defs> + <image id="source-24" x="0" y="0" width="768" height="746" xlink:href="data:image/png;base64,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AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAATYtvYgAAAQJJREFUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMD6/n/eYB3y9/fXRAAAAABJRU5ErkJggg=="/> + </defs> + <use xlink:href="#source-24"/> +</svg> \ No newline at end of file diff --git a/src/assets/cursors/macos/screenshotselection-1__50-50.svg b/src/assets/cursors/macos/screenshotselection-1__50-50.svg new file mode 100644 index 000000000..5717500b0 --- /dev/null +++ b/src/assets/cursors/macos/screenshotselection-1__50-50.svg @@ -0,0 +1,92 @@ +<?xml version="1.0" encoding="UTF-8" standalone="yes"?> +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="767.314286" height="746" viewBox="0 0 767.314286 746"> + <defs> + <filter id="filter-remove-color" x="0%" y="0%" width="100%" height="100%"> + <feColorMatrix color-interpolation-filters="sRGB" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"/> + </filter> + <mask id="mask-0"> + <g filter="url(#filter-remove-color)"> + <rect x="-76.731429" y="-74.6" width="920.777143" height="895.2" fill="rgb(0%, 0%, 0%)" fill-opacity="0.15"/> + </g> + </mask> + <mask id="mask-1"> + <g filter="url(#filter-remove-color)"> + <rect x="-76.731429" y="-74.6" width="920.777143" height="895.2" fill="rgb(0%, 0%, 0%)" fill-opacity="0.3"/> + </g> + </mask> + <mask id="mask-2"> + <g filter="url(#filter-remove-color)"> + <rect x="-76.731429" y="-74.6" width="920.777143" height="895.2" fill="rgb(0%, 0%, 0%)" fill-opacity="0.85"/> + </g> + </mask> + <mask id="mask-3"> + <g filter="url(#filter-remove-color)"> + <rect x="-76.731429" y="-74.6" width="920.777143" height="895.2" fill="rgb(0%, 0%, 0%)" fill-opacity="0.85"/> + </g> + </mask> + <mask id="mask-4"> + <g filter="url(#filter-remove-color)"> + <rect x="-76.731429" y="-74.6" width="920.777143" height="895.2" fill="rgb(0%, 0%, 0%)" fill-opacity="0.85"/> + </g> + </mask> + <mask id="mask-5"> + <g filter="url(#filter-remove-color)"> + <rect x="-76.731429" y="-74.6" width="920.777143" height="895.2" fill="rgb(0%, 0%, 0%)" fill-opacity="0.85"/> + </g> + </mask> + <clipPath id="clip-0"> + <rect x="0" y="0" width="768" height="746"/> + </clipPath> + <clipPath id="clip-1"> + <rect x="0" y="0" width="768" height="746"/> + </clipPath> + <clipPath id="clip-2"> + <rect x="0" y="0" width="768" height="746"/> + </clipPath> + <clipPath id="clip-3"> + <rect x="0" y="0" width="768" height="746"/> + </clipPath> + <clipPath id="clip-4"> + <rect x="0" y="0" width="768" height="746"/> + </clipPath> + <clipPath id="clip-5"> + <rect x="0" y="0" width="768" height="746"/> + </clipPath> + <g id="source-6" clip-path="url(#clip-0)"> + <path fill-rule="evenodd" fill="rgb(13.72549%, 12.156863%, 12.156863%)" fill-opacity="1" d="M 494.976562 373 C 494.976562 437.730469 442.476562 490.230469 377.746094 490.230469 C 312.992188 490.230469 260.519531 437.730469 260.519531 373 C 260.519531 308.246094 312.992188 255.769531 377.746094 255.769531 C 442.476562 255.769531 494.976562 308.246094 494.976562 373 Z M 494.976562 373 "/> + </g> + <g id="source-10" clip-path="url(#clip-1)"> + <path fill="none" stroke-width="1" stroke-linecap="butt" stroke-linejoin="miter" stroke="rgb(13.72549%, 12.156863%, 12.156863%)" stroke-opacity="1" stroke-miterlimit="4" d="M 23.222761 17.5 C 23.222761 20.536952 20.759624 23.000089 17.722672 23.000089 C 14.68462 23.000089 12.222766 20.536952 12.222766 17.5 C 12.222766 14.461948 14.68462 11.999911 17.722672 11.999911 C 20.759624 11.999911 23.222761 14.461948 23.222761 17.5 Z M 23.222761 17.5 " transform="matrix(21.314286, 0, 0, 21.314286, 0, 0)"/> + </g> + <g id="source-14" clip-path="url(#clip-2)"> + <path fill-rule="evenodd" fill="rgb(13.72549%, 12.156863%, 12.156863%)" fill-opacity="1" d="M 132.632812 383.65625 L 367.132812 383.65625 L 367.132812 362.34375 L 132.632812 362.34375 Z M 132.632812 383.65625 "/> + </g> + <g id="source-18" clip-path="url(#clip-3)"> + <path fill-rule="evenodd" fill="rgb(13.72549%, 12.156863%, 12.156863%)" fill-opacity="1" d="M 388.402344 383.65625 L 622.859375 383.65625 L 622.859375 362.34375 L 388.402344 362.34375 Z M 388.402344 383.65625 "/> + </g> + <g id="source-22" clip-path="url(#clip-4)"> + <path fill-rule="evenodd" fill="rgb(13.72549%, 12.156863%, 12.156863%)" fill-opacity="1" d="M 367.089844 362.34375 L 388.402344 362.34375 L 388.402344 127.886719 L 367.089844 127.886719 Z M 367.089844 362.34375 "/> + </g> + <g id="source-26" clip-path="url(#clip-5)"> + <path fill-rule="evenodd" fill="rgb(13.72549%, 12.156863%, 12.156863%)" fill-opacity="1" d="M 367.089844 618.113281 L 388.402344 618.113281 L 388.402344 383.65625 L 367.089844 383.65625 Z M 367.089844 618.113281 "/> + </g> + </defs> + <g mask="url(#mask-0)"> + <use xlink:href="#source-6"/> + </g> + <g mask="url(#mask-1)"> + <use xlink:href="#source-10"/> + </g> + <g mask="url(#mask-2)"> + <use xlink:href="#source-14"/> + </g> + <g mask="url(#mask-3)"> + <use xlink:href="#source-18"/> + </g> + <g mask="url(#mask-4)"> + <use xlink:href="#source-22"/> + </g> + <g mask="url(#mask-5)"> + <use xlink:href="#source-26"/> + </g> +</svg> \ No newline at end of file diff --git a/src/assets/cursors/macos/screenshotwindow-1__50-50.svg b/src/assets/cursors/macos/screenshotwindow-1__50-50.svg new file mode 100644 index 000000000..25122f5c6 --- /dev/null +++ b/src/assets/cursors/macos/screenshotwindow-1__50-50.svg @@ -0,0 +1,7 @@ +<?xml version="1.0" encoding="UTF-8" standalone="yes"?> +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="768" height="768" viewBox="0 0 768 768"> + <defs> + <image id="source-25" x="0" y="0" width="768" height="768" xlink:href="data:image/png;base64,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svbtt98m7lKgbdu2NR0CDGvXrp3pEELZsmWLli5dajoMAAWAAgBA1mzb1tdff206jFA6dOhgOgQY1r59e9MhhLJo0SKVl5ebDgNAAaAAABCJBQsWmA4hlC5dupgOAYZ169bNdAihLFy40HQIAAoEBQCASCQtOWnXrp123nln02HAkDp16uiggw4yHUYoSSuyAeQvCgAAkUhaclJaWqrOnTubDgOGdOnSRdWqVTMdRihJK7IB5C8KAACR+Oyzz0yHENqxxx5rOgQYcvzxx5sOIbRZs2aZDgFAgaAAABCJTz/91HQIoZ1wwgmmQ4AhSfy/nzlzpukQABQICgAAkVi8eLFWrlxpOoxQDjjgAO27776mw0CONW3aVAcccIDpMEJZs2YNQ4AARIYCAEAkbNtOZAvlmWeeaToE5NhZZ51lOoTQZs6cKdu2TYcBoEBQAACITBILgF69epkOATmWxP/zJG5bAPIXBQCAyEybNs10CKF16NBBLVq0MB0GcqR58+bq2LGj6TBC+/jjj02HAKCAUAAAiMz7779vOoSMXHjhhaZDQI706dPHdAgZee+990yHAKCAUAAAiMy8efO0bNky02GE1qdPH5WVlZkOAzGrVq2aLrjgAtNhhLZixQrNnTvXdBgACggFAIDI2LatSZMmmQ4jtIYNG+r//u//TIeBmPXo0UN77bWX6TBCmzhxoioqKkyHAaCAUAAAiNQHH3xgOoSMXHPNNaZDQMz69etnOoSMJHVoHYD8RQEAIFJJHavctWtXde7c2XQYiMkhhxyiI444wnQYGZk4caLpEAAUGAoAAJGaPHmyfvzxR9NhZOS6664zHQJiMmDAANMhZGTFihWaPHmy6TAAFBgKAACRKi8v1xtvvGE6jIycdtppat++vekwELG2bdvqtNNOMx1GRl5//XVt3brVdBgACgwFAIDITZgwwXQIGbEsS7fccovpMBCx2267TSUlyTzcJXVbApDfkrlHBJDXxo8fL9u2TYeRkZNPPplzAQrI4YcfntgrPNm2rddee810GAAKEAUAgMh99913mj59uukwMmJZloYPH57YFmP8zLIs3XPPPbIsy3QoGZkxY4aWLl1qOgwABYgjHIBYPPvss6ZDyFiHDh3Uu3dv02EgS71791bXrl1Nh5GxZ555xnQIAAoUBQCAWIwbNy6xw4AkaciQIapfv77pMJChevXqaciQIabDyAoFAIC4UAAAiMVXX32ljz/+2HQYGdtzzz315z//2XQYyNDQoUPVqFEj02Fk7OOPP9b8+fNNhwGgQFEAAIjNuHHjTIeQlQsvvFDHHHOM6TAQ0tFHH62+ffuaDiMrSd92AOQ3CgAAsXn66acTPQzIsiw9/vjj2vX/27v3+Cuqev/j7/lyVRENJEK8m2ARmuL9Cz2E6GSWnfKalpmIx9RfRh5TKn+pdU7Iw/QXifbwEvyU+qWQx+p4ygumIeBBjxIIhaaClwQTyBSK696/P2DrsL5rrVmz98yefXk9H4/92DNrZs+svb/fvefzWWvNTL9+RVcFgXbbbTdNnz69aU/8lbZd/YfhPwDyRAIAIDcvvfSSHnnkkaKrUZO99tpLt956a9HVQKCbb75Z++23X9HVqMmjjz6q5cuXF10NAC2MBABArm677baiq1CzU045RePGjSu6Gkhw7rnn6qyzziq6GjVrhe8MgMZGAgAgV//xH/+hN954o+hq1Oymm27SEUccUXQ14HDooYdq6tSpRVejZmvXrtW9995bdDUAtDgSAAC52rRpk2bMmFF0NWrWu3dv/fznP9cee+xRdFVg6N+/v+69917tvPPORVelZjNmzNCGDRuKrgaAFkcCACB3t912W1OfDFyx77776t5771Xv3r2Lrgq222mnnfSLX/xC+++/f9FVycTtt99edBUAtAESAAC5W7Zsme6///6iq5GJkSNH6o477lBHBz+fRevWrZt++tOfauTIkUVXJRMPPviglixZUnQ1ALQBjmAA6uL6668vugqZOf3007lJWMGiKNJtt92mz372s0VXJTPf//73i64CgDZBAgCgLh5++GEtXLiw6Gpk5tJLL9XVV19ddDXa1qRJk3TuuecWXY3MPPPMM5o9e3bR1QDQJkgAANTNDTfcUHQVMnXVVVdp4sSJRVej7Vx++eW64ooriq5Gpq677rqWOE8GQHMgAQBQN3fffbdefvnloquRqUmTJunaa68tuhptIYoiXXXVVZo8eXLRVcnUK6+8orvvvrvoagBoIyQAAOpm8+bN+t73vld0NTJ3xRVX6Prrr1cURUVXpWV1795d06ZNa8lhV//+7/+uTZs2FV0NAG2EBABAXU2bNk0vvvhi0dXI3KWXXqpZs2Zpp512KroqLad37966++679aUvfanoqmTupZde0vTp04uuBoA2QwIAoK42b96sf/u3fyu6Grk45ZRT9NBDD3GzsAz1799fDz/8sE4++eSiq5KLa665htZ/AHVHAgCg7u688049++yzRVcjF52dnVq4cKGOPvrooqvS9A477DA9+eSTOu6444quSi6ef/75lrhLNoDmQwIAoO62bt2qb3/720VXIzd77bWXHn30UZ1//vlFV6VpnXfeeZo/f37L3OHX5sorr9SWLVuKrgaANkQCAKAQM2fO1Jw5c4quRm569+6tW2+9Vffcc4/69+9fdHWaxm677aaf/OQnuv3229W7d++iq5Ob+fPna+bMmUVXA0CbIgEAUJgJEyaoVCoVXY1cnXzyyVq4cKE+9rGPFV2VhvfRj35Uixcv1uc///miq5KrUqmkCRMmcN1/AIUhAQBQmIULF+qOO+4ouhq523vvvfXggw9q5syZnCBssfvuu+uWW27RQw89pH322afo6uRu+vTpevLJJ4uuBoA2RgIAoFDf/OY39fbbbxddjbo47bTTtGTJEo0bN04dHfz8dnR06Nxzz9WyZcv0L//yL21xH4W33npLV155ZdHVANDmOAIBKNSqVavaKiAaOHCgfvzjH+uJJ57QqFGjiq5OYTo7O7VgwQJNmzZNAwcOLLo6dfOtb31Lq1atKroaANocCQCAwk2dOlXz5s0ruhp1NWLECM2ZM0cPPfSQjjzyyKKrUzfDhw/XzJkz9dhjj+mII44oujp1tWDBAv3oRz8quhoAQAIAoHilUknjx4/Xxo0bi65K3Y0dO1YLFizQr371K40cObLo6uSms7NTv/zlL7Vo0SKddtppbTHcJ27Tpk0677zztHXr1qKrAgAkAAAaw7Jly3TttdcWXY1CRFGkk046SY899pj++7//W2eccYZ69uxZdLVq1rNnT5122mmaP3++5s6dq09/+tNtF/hXTJo0SUuXLi26GgAgiQQAQAOZNGmSFi9eXHQ1CnX00Ufrrrvu0quvvqrrr79ew4YNK7pKqX3wgx/Uddddp1deeUUzZ87UscceW3SVCvXMM89o0qRJRVcDAN5BAgCgYWzcuFFnnnmm/vGPfxRdlcINGDBAl156qZYsWaLFixfryiuv1NChQ4uultPQoUP1rW99S4sXL9bSpUt12WWX6b3vfW/R1Srcxo0b9YUvfKEth7cBaFzdi64AAMT94Q9/0OWXX64bb7yx6Ko0jOHDh2v48OH67ne/qxdffFG/+c1v9MADD2ju3Ln661//Wkid3vOe92jkyJH6+Mc/rhNOOEEHHnhgIfVodF//+tfbvlcLQOMhAQDQcG666Sb90z/9k0466aSiq9JwDjjgAF188cW6+OKLVSqV9Mc//lHz5s3T008/rWeeeUZLlizRW2+9lek++/btq2HDhumQQw7RYYcdps7OTn3wgx/kXgYJHnjgAU2dOrXoagBAFyQAABpOuVzW+PHjtWjRIr3vfe8rujoNq6OjQ8OGDetynsBrr72m5cuXa8WKFXrllVf0xhtvaM2aNVqzZo02bNigzZs3a926dZKkPn36qEePHurdu7f69++v/v37a8CAAdprr720//77a//999egQYPa9uTdaq1atUrnnHOOyuVy0VUBgC5IAAA0pL/85S86+eST9eijj7bEFXHqac8999See+6pzs7OoqvSlrZs2aLPfe5zev3114uuCgBY0X8LoGE9/vjjuvzyy4uuBpDKZZddpt/97ndFVwMAnEgAADS0KVOm6I477ii6GkCQu+66S1OmTCm6GgDgRQIAoOFdfPHFXEkFDe+ZZ57R+PHji64GACQiAQDQ8NavX68TTzxRr776atFVAaxWrVqlk046SevXry+6KgCQiAQAQFP485//rH/+539+5+o1QKP4xz/+oc985jN66aWXiq4KAAQhAQDQNJ5++mmdccYZ2rJlS9FVASRJpVJJZ511lhYsWFB0VQAgGAkAgKby61//WhMmTCi6GoAkacKECfrFL35RdDUAIBUSAABN56abbtK3v/3toquBNnf11VfrxhtvLLoaAJAaCQCApvTd735X1157bdHVQJuaMmWKrrnmmqKrAQBVIQEA0LS+8Y1v6IYbbii6Gmgz06dP19e+9rWiqwEAVSMBANDULrvsMv34xz8uuhpoE3fccYfGjx+vcrlcdFUAoGokAACaWrlc1vnnn68f/OAHRVcFLe6WW27RuHHjVCqViq4KANSEBABA0yuXy/ra176m73znO0VXBS1q6tSpuvDCCwn+AbQEEgAALeOqq67SxIkTi64GWszkyZP1la98hWE/AFoGCQCAljJ58mRdfPHF2rp1a9FVQZPbunWrvvKVr5BUAmg5JAAAWs7NN9+sE088UW+99VbRVUGTWr9+vU4++WRNnTq16KoAQOZIAAC0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"/> + </defs> + <use xlink:href="#source-25"/> +</svg> \ No newline at end of file diff --git a/src/assets/cursors/macos/zoomin-1__43-43.svg b/src/assets/cursors/macos/zoomin-1__43-43.svg new file mode 100644 index 000000000..ed08654c4 --- /dev/null +++ b/src/assets/cursors/macos/zoomin-1__43-43.svg @@ -0,0 +1,7 @@ +<?xml version="1.0" encoding="UTF-8" standalone="yes"?> +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="768" height="768" viewBox="0 0 768 768"> + <defs> + <image id="source-26" x="0" y="0" width="768" height="768" xlink:href="data:image/png;base64,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"/> + </defs> + <use xlink:href="#source-26"/> +</svg> \ No newline at end of file diff --git a/src/assets/cursors/macos/zoomout-1__43-43.svg b/src/assets/cursors/macos/zoomout-1__43-43.svg new file mode 100644 index 000000000..b54c6f0ea --- /dev/null +++ b/src/assets/cursors/macos/zoomout-1__43-43.svg @@ -0,0 +1,7 @@ +<?xml version="1.0" encoding="UTF-8" standalone="yes"?> +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="767.314286" height="746" viewBox="0 0 767.314286 746"> + <defs> + <image id="source-26" x="0" y="0" width="768" height="746" xlink:href="data:image/png;base64,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"/> + </defs> + <use xlink:href="#source-26"/> +</svg> \ No newline at end of file diff --git a/src/assets/cursors/tahoe/beachball-1__50-46.svg b/src/assets/cursors/tahoe/beachball-1__50-46.svg new file mode 100644 index 000000000..72f52d7e8 --- /dev/null +++ b/src/assets/cursors/tahoe/beachball-1__50-46.svg @@ -0,0 +1,57 @@ +<svg width="618" height="618" viewBox="0 0 618 618" fill="none" xmlns="http://www.w3.org/2000/svg"> +<g filter="url(#filter0_d_384_4)"> +<g filter="url(#filter1_d_384_4)"> +<path d="M194.966 50.7383C214.766 53.1953 234.412 59.575 252.778 70.1788C326.244 112.594 351.415 206.535 309 280C266.584 206.535 172.644 181.363 99.1784 223.779C80.8387 234.367 65.5086 248.167 53.4883 264.057C59.2403 170.447 115.299 90.441 194.966 50.7383Z" fill="url(#paint0_linear_384_4)"/> +<path d="M53.4884 264.058C65.5087 248.168 80.8388 234.368 99.1785 223.78C172.644 181.364 266.585 206.536 309 280.001C224.169 280.001 155.4 348.771 155.4 433.601C155.4 454.791 159.691 474.978 167.45 493.341C98.4659 447.479 53 369.05 53 280.001C53 274.647 53.1644 269.331 53.4884 264.058Z" fill="url(#paint1_linear_384_4)"/> +<path d="M167.449 493.34C159.689 474.976 155.398 454.789 155.398 433.6C155.398 348.769 224.167 280 308.998 280C266.583 353.466 291.754 447.406 365.22 489.821C383.586 500.425 403.232 506.805 423.033 509.262C388.695 526.375 349.971 536 308.998 536C256.662 536 207.993 520.295 167.449 493.34Z" fill="url(#paint2_linear_384_4)"/> +<path d="M423.037 509.262C403.236 506.805 383.59 500.425 365.224 489.821C291.758 447.406 266.587 353.466 309.003 280C351.418 353.466 445.358 378.637 518.824 336.222C537.164 325.633 552.494 311.834 564.514 295.943C558.762 389.554 502.704 469.559 423.037 509.262Z" fill="url(#paint3_linear_384_4)"/> +<path d="M564.512 295.943C552.491 311.833 537.161 325.633 518.821 336.221C445.356 378.637 351.415 353.466 309 280C393.831 280 462.6 211.231 462.6 126.4C462.6 105.211 458.31 85.0237 450.55 66.6602C519.534 112.523 565 190.952 565 280C565 285.354 564.836 290.67 564.512 295.943Z" fill="url(#paint4_linear_384_4)"/> +<path d="M450.549 66.6604C458.309 85.0239 462.599 105.211 462.599 126.4C462.599 211.231 393.83 280 308.999 280C351.415 206.534 326.243 112.594 252.778 70.1785C234.411 59.5747 214.765 53.195 194.965 50.738C229.303 33.6253 268.027 24 308.999 24C361.336 24 410.005 39.7054 450.549 66.6604Z" fill="url(#paint5_linear_384_4)"/> +</g> +</g> +<defs> +<filter id="filter0_d_384_4" x="0.34" y="0.6" width="617.32" height="617.32" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> +<feOffset dy="29.26"/> +<feGaussianBlur stdDeviation="26.33"/> +<feComposite in2="hardAlpha" operator="out"/> +<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.65 0"/> +<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_384_4"/> +<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_384_4" result="shape"/> +</filter> +<filter id="filter1_d_384_4" x="51" y="23" width="516" height="516" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> +<feOffset dy="1"/> +<feGaussianBlur stdDeviation="1"/> +<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.4049 0"/> +<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_384_4"/> +<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_384_4" result="shape"/> +</filter> +<linearGradient id="paint0_linear_384_4" x1="13859.3" y1="50.7383" x2="13859.3" y2="22976.9" gradientUnits="userSpaceOnUse"> +<stop stop-color="#FFD305"/> +<stop offset="1" stop-color="#FDCF01"/> +</linearGradient> +<linearGradient id="paint1_linear_384_4" x1="12853" y1="203.176" x2="12853" y2="29219.7" gradientUnits="userSpaceOnUse"> +<stop stop-color="#52CF30"/> +<stop offset="1" stop-color="#3BBD1C"/> +</linearGradient> +<linearGradient id="paint2_linear_384_4" x1="13537.1" y1="280" x2="13537.1" y2="25880" gradientUnits="userSpaceOnUse"> +<stop stop-color="#14ADF6"/> +<stop offset="1" stop-color="#1191F4"/> +</linearGradient> +<linearGradient id="paint3_linear_384_4" x1="14094.2" y1="280" x2="14094.2" y2="23206.2" gradientUnits="userSpaceOnUse"> +<stop stop-color="#CA70E1"/> +<stop offset="1" stop-color="#B452CB"/> +</linearGradient> +<linearGradient id="paint4_linear_384_4" x1="13109" y1="66.6602" x2="13109" y2="29083.2" gradientUnits="userSpaceOnUse"> +<stop stop-color="#FF645D"/> +<stop offset="1" stop-color="#FF4332"/> +</linearGradient> +<linearGradient id="paint5_linear_384_4" x1="13576.7" y1="24" x2="13576.7" y2="25624" gradientUnits="userSpaceOnUse"> +<stop stop-color="#FBB114"/> +<stop offset="1" stop-color="#FF9508"/> +</linearGradient> +</defs> +</svg> diff --git a/src/assets/cursors/tahoe/busybutclickable-1__23-0.svg b/src/assets/cursors/tahoe/busybutclickable-1__23-0.svg new file mode 100644 index 000000000..3dfed7915 --- /dev/null +++ b/src/assets/cursors/tahoe/busybutclickable-1__23-0.svg @@ -0,0 +1,40 @@ +<?xml version="1.0" encoding="UTF-8" standalone="yes"?> +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="767.314286" height="746" viewBox="0 0 767.314286 746"> + <defs> + <filter id="filter-remove-color" x="0%" y="0%" width="100%" height="100%"> + <feColorMatrix color-interpolation-filters="sRGB" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"/> + </filter> + <clipPath id="clip-0"> + <path clip-rule="nonzero" d="M 16.578125 0 L 762.578125 0 L 762.578125 746 L 16.578125 746 Z M 16.578125 0 "/> + </clipPath> + <clipPath id="clip-1"> + <path clip-rule="nonzero" d="M 0.578125 0 L 746.578125 0 L 746.578125 746 L 0.578125 746 Z M 0.578125 0 "/> + </clipPath> + <clipPath id="clip-2"> + <rect x="0" y="0" width="768" height="746"/> + </clipPath> + <clipPath id="clip-3"> + <rect x="0" y="0" width="747" height="746"/> + </clipPath> + <image id="source-44" x="0" y="0" width="768" height="746" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAwAAAALqCAYAAACCKTPeAAAABmJLR0QA/wD/AP+gvaeTAAAPHklEQVR4nO3XsQ2AQAwEQUO9dIPol4CImJcIdqYAy+HtNt8dM3MuuAMAAKxzzbPVX/YfHgEAAH4iAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABByA9F1B9TDupt7AAAAAElFTkSuQmCC"/> + <image id="source-37" x="0" y="0" width="768" height="746" xlink:href="data:image/png;base64,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+eabqzg8IQAAgB4gAACYZMWKFdp///3129/+torDEwIAAOgyAgCAKZoh4JZbbqni8IQAAAC6iAAAwOuRRx7R/vvvr1tvvbWKwxMCAADoEgIAgKCHH35Y++23n2677bYqDk8IAACgCwgAAKIefvhhzZ07V//93/9dxeEJAQAAdBgBAEDSH//4R82aNUu/+93vqjg8IQAAgA4iAADI0gwBv//976s4PCEAAIAOIQAAyHbfffdp1qxZuvvuu6s4PCEAAIAOIAAAKOXee+/VrFmz9Ic//KGKwxMCAABoEwEAQGn33HOP9t13X91zzz1VHJ4QAABAGwgAAFpyzz33aM6cObr//vurODwhAACAFhEAALTsrrvu0qxZs/TAAw9UcXhCAAAALSAAAGhLMwQ8+OCDVRyeEAAAQEkEAABtu/POOzVr1iw99NBDVRyeEAAAQAkEAAAdcccddxACpm73bUshBAAAuooAAKBjbr/9ds2bN08rV66s4vCEAAAAMhAAAHTUTTfdpNmzZ2vVqlVVHJ4QAABAAgEAQMf95je/IQRM3e7blkIIAAB0HAEAQFf8+te/1pw5c7R69eoqDk8IAAAggAAAoGt+9atfac6cOVqzZk0VhycEAADgQQAA0FU33nijDjroID3++ONVHJ4QAACAgwAAoOuuv/56HXDAAXriiSeqODwhAACAAgIAgJ742c9+RgiYut23LYUQAABoCwEAQM/89Kc/1YEHHqgnn3yyisMTAgAAEAEAQI/95Cc/IQRM3e7blkIIAAC0hAAAoOd+/OMf66CDDtJTTz1VxeEJAQCAkUYAAFCJ6667TocccojWrl1bxeEJAQCAkUUAAFCZa665RgcffLCeeeaZKg5PCAAAjCQCAIBKLVu2jBAwdbtvWwohAACQhQAAoHJXX321DjnkEK1bt66KwxMCAAAjhQAAoC9cddVVhICp233bUggBAIAoAgCAvvG9731PRxxxhDZs2FDF4QkBAICRQAAA0Fcuu+wyHX744YSAqcuEAABARxAAAPSdSy+9VEceeaQmJiaqODwhAAAw1MarPgEA8LnkkktkjNFFF12k8fGev1SdKEnW2kXGmD/dtK6hyY0mjU23tcJ8DqsthbnddGsK64u3cra78znK7g8AGHIEAAB9a+nSpTLG6MILLyQEEAIAAB1CAADQ15YsWbI5BIyNjfX68IQAAMDQ4RoAAH3vm9/8pk488UQ1GmXq647hmgAAwFAhAAAYCBdccIHe+ta3EgKmLhMCAAClMAQIwMD46le/KmOMzjnnHNVqPW+/YDgQAGAoEAAADJTzzjtvcwgwpueN2YQAAMDAIwAAGDjnnnuujDE6++yzCQGEAABASVwDAGAgfeUrX9Gpp55a1eG5JgAAMLDoAQAwsD73uc/JGKOzzjqrisPTEwAAGEgEAAAD7bOf/ayMMfrMZz5TxeEJAQCAgcMQIAAD76yzztJ73vOeqg7PcCAAwEAhAAAYCmeeeabe+973VnV4QgAAYGAQAAAMjTPOOEOLFy+u6vCEAADAQCAAABgqH/nIR/TRj360qsMTAgAAfY8AAGDofPjDH9bHPvaxqg5PCAAA9DU+BQjAUPrQhz4kY4w+8IEPVHF4Ph0IANC36AEAMLQ++MEP6uMf/3hVh6cnAADQl+gBADDUTj/9dBljdNppp1VxeHoCAAB9hx4AAEPv/e9/vz71qU9VdXh6AgAAfYUAAGAknHbaafr85z9f1eEJAQCAvsEQIAAjwVqrd77znTLG6JRTTqniFBgOBADoCwQAACPDWqt3vOMdMsbo5JNPruIUCAEAgMoxBAjASLHW6u1vf7u+/OUvV3UKDAcCAFSKHgAAI8daq5NPPlnGGC1atKiKU6AnAABQGXoAAIykZgj4+te/XtUp0BMAAKgEPQAARlaj0dDxxx8vY4wWLlxYxSnQEwAA6Dl6AACMtHq9ruOOO04XXnhhVadATwAAoKfoAQAw8ur1uo499lgZY3TkkUdWcQr0BAAAeoYeAADQxhBwzDHH6OKLL67qFOgJAAD0BD0AALBJvV7X0UcfLWOM3vzmN1dxCvQEAAC6jh4AACio1+tauHChvv3tb1d1CvQEAAC6igAAAI4NGzbosMMO03e+852qToEQAADoGgIAAHisX79eCxYs0He/+92qToEQAADoCgIAAASsX79ehx56qC6//PKqToEQAADoOAIAAEQ0Q8CVV15Z1SkQAgAAHUUAAICEdevW6dBDD9Xy5curOgVCAACgYwgAAJBh7dq1euMb36gf/OAHVZ0CIQAA0BEEAADI9PTTT2v+/Pm69tprqzoFQgAAoG0EAAAo4emnn9Yb3vAG/fCHP6zqFAgBAIC2EAAAoKRmCPjRj35U1SkQAgAALSMAAEALnnrqKc2fP1/XX399VadACAAAtIQAAAAtevzxx3XAAQfo5z//eVWnQAgAAJRGAACANjz22GOaN2+efvGLX1R1CoQAAEApBAAAaNNjjz2muXPn6pe//GVVp0AIAABkIwAAQAc8+uijmjNnjm644YaqToEQAADIQgAAgA5phoAbb7yxqlMgBAAAkggAANBBa9as0QEHHKCbb765qlMgBAAAoggAANBhK1as0P7776/f/va3VZ0CIQAAEEQAAIAuaIaAW265papTIAQAALwIAADQJY888oj2339/3XrrrVWdAiEAADAFAQAAuujhhx/Wfvvtp9tuu62qUyAEAAAmIQAAQJc9/PDDmjt3rn73u99VdQqEAADAZgQAAOiBP/7xj5o1axYhYOp237YUQgAAtIEAAAA9ct9992nWrFn6/e9/X9UpEAIAAAQAAOilZgi4++67qzoFQgAAjDgCAAD02L333qtZs2bpD3/4Q1WnQAgAgBFGAACACtxzzz3ad999dc8991R1CoQAABhRBAAAqMg999yjOXPm6P7776/qFAgBADCCCAAAUKG77rpLs2bN0gMPPFDVKRACAGDEEAAAoGLNEPDggw9WdQqEAAAYIQQAAOgDd955p2bNmqWHHnqoqlMgBADAiCAAAECfuOOOOwgBkxECAKALCAAA0Eduv/12zZs3TytXrqzqFAgBADDkCAAA0GduuukmzZ49W6tWrarqFAgBADDECAAA0Id+85vfEAImIwQAQIcQAACgT/3617/WnDlztHr16qpOgRAAAEOIAAAAfexXv/qV5syZozVr1lR1CoQAABgyBAAA6HM33nijDjroID3++ONVnQIhAACGCAEAAAbA9ddfrwMOOEBPPPFEVadACACAIUEAAIAB8bOf/YwQMBkhAABaQAAAgAHy05/+VAceeKCefPLJqk6BEAAAA44AAAAD5ic/+QkhYDJCAACUQAAAgAH04x//WAcddJCeeuqpqk6BEAAAA4oAAAAD6rrrrtMhhxyitWvXVnUKhAAAGEAEAAAYYNdcc40OPvhgPfPMM1WdAiEAAAYMAQAABtyyZcsIAZMRAgAgggAAAEPg6quv1iGHHKJ169ZVdQrDFAIAYKgRAABgSFx11VWjFgJUWC/PtlZDAIEBwFAjAADAEPne976nI444Qhs2bKjqFHoZAmI9Au5637YYQgCAoUUAAIAhc9ll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"/> + <mask id="mask-0"> + <g filter="url(#filter-remove-color)"> + <use xlink:href="#source-44" transform="matrix(1, 0, 0, 1, -16, 0)"/> + </g> + </mask> + <g id="source-43" clip-path="url(#clip-2)"> + <use xlink:href="#source-37"/> + </g> + <g id="source-46" clip-path="url(#clip-3)"> + <g clip-path="url(#clip-1)"> + <g mask="url(#mask-0)"> + <use xlink:href="#source-43" transform="matrix(1, 0, 0, 1, -16, 0)"/> + </g> + </g> + </g> + </defs> + <g clip-path="url(#clip-0)"> + <use xlink:href="#source-46" transform="matrix(1, 0, 0, 1, 16, 0)"/> + </g> +</svg> \ No newline at end of file diff --git a/src/assets/cursors/tahoe/cell-1__50-50.svg b/src/assets/cursors/tahoe/cell-1__50-50.svg new file mode 100644 index 000000000..e7119c0b8 --- /dev/null +++ b/src/assets/cursors/tahoe/cell-1__50-50.svg @@ -0,0 +1,7 @@ +<?xml version="1.0" encoding="UTF-8" standalone="yes"?> +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="767.314286" height="746" viewBox="0 0 767.314286 746"> + <defs> + <image id="source-26" x="0" y="0" width="768" height="746" xlink:href="data:image/png;base64,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AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOBp/f8q/7BqXdY3sQAAAABJRU5ErkJggg=="/> + </defs> + <use xlink:href="#source-26"/> +</svg> \ No newline at end of file diff --git a/src/assets/cursors/tahoe/closedhand-1__50-46.svg b/src/assets/cursors/tahoe/closedhand-1__50-46.svg new file mode 100644 index 000000000..5bff20920 --- /dev/null +++ b/src/assets/cursors/tahoe/closedhand-1__50-46.svg @@ -0,0 +1,18 @@ +<svg width="618" height="584" viewBox="0 0 618 584" fill="none" xmlns="http://www.w3.org/2000/svg"> +<g filter="url(#filter0_d_384_31)"> +<path fill-rule="evenodd" clip-rule="evenodd" d="M545.365 105.579C532.001 95.8223 517.119 92.0258 500.681 94.2657C492.784 95.3287 486.254 97.6446 480.825 100.872C481.015 94.8352 480.56 89.1026 479.193 83.7116C476.346 72.4362 471.031 63.1729 463.248 55.8838C455.465 48.5946 446.126 43.3556 435.23 40.1286C425.359 37.1674 415.299 36.2942 405.049 37.547C394.76 38.7999 385.573 42.2926 377.411 48.1011C374.753 49.9993 372.779 52.4291 370.653 54.7449C369.172 52.1253 367.806 49.4299 365.718 47.0001C359.415 39.711 351.405 34.0923 341.648 30.144C331.891 26.1957 321.527 24.1836 310.669 24.1836C299.546 24.1836 289.068 26.1957 279.349 30.144C269.592 34.0923 261.543 39.711 255.241 47.0001C253.191 49.3539 251.901 52.0115 250.382 54.593C248.977 52.9985 247.8 51.1762 246.168 49.7716C239.486 44.0769 231.628 40.2045 222.592 38.0785C213.595 35.9905 204.521 35.6868 195.372 37.1674C183.755 38.8758 173.314 43.2417 164.013 50.1512C154.75 57.0607 147.916 66.3999 143.436 78.1309C139.298 89.1026 138.805 102.086 141.348 116.627C119.709 113.969 99.5877 120.271 81.175 135.951C62.1549 152.161 52.7397 177.673 53.0055 212.525C53.2333 268.864 63.9392 318.901 85.0473 362.673C106.193 406.408 135.92 440.614 174.226 465.177C212.532 489.778 256.912 502.078 307.328 502.078C353.303 502.078 392.596 493.992 425.246 477.781C457.857 461.608 484.28 438.184 504.401 407.509C524.522 376.91 539.176 338.946 548.326 293.692C553.261 269.775 557.134 246.237 560.019 223.268C562.866 200.262 564.537 175.965 564.992 150.225C565.258 130.218 558.69 115.336 545.365 105.579Z" fill="black"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M148.42 157.252L157.759 205.467C159.506 214.616 164.061 220.462 171.464 222.892C178.905 225.398 185.625 224.183 191.699 219.362C197.736 214.54 199.786 207.706 197.812 198.785L178.526 106.494C176.286 96.8507 179.095 89.068 186.878 83.1455C194.661 77.2231 203.278 74.9832 212.807 76.4638C222.336 77.9444 228.449 83.5252 231.182 93.1681L236.725 113.175C238.699 120.844 243.331 125.666 250.62 127.64C257.947 129.614 264.894 128.741 271.576 125.058C278.258 121.338 281.599 115.909 281.599 108.733V83.1455C281.599 76.4638 284.37 71.1108 289.951 67.0107C295.494 62.9485 302.251 60.8984 310.148 60.8984C317.817 60.8984 324.423 62.9485 330.003 67.0107C335.546 71.1108 338.317 76.4638 338.317 83.1455V108.733C338.317 115.643 341.658 121.034 348.34 124.868C355.022 128.703 361.969 129.614 369.296 127.64C376.585 125.666 381.217 120.844 383.191 113.175L389.114 92.4088C391.619 83.2594 397.542 77.9444 406.919 76.4638C416.296 74.9832 424.914 77.2231 432.697 83.1455C440.479 89.068 443.365 96.8507 441.391 106.494L435.468 139.864C433.722 148.748 435.468 155.695 440.669 160.631C445.832 165.566 452.134 167.92 459.575 167.654C466.979 167.426 472.559 164.693 476.242 159.492L485.885 143.926C489.34 138.725 494.503 135.726 501.26 134.853C508.056 133.98 514.168 135.384 519.635 139.105C525.064 142.825 527.797 148.14 527.797 155.05C527.797 175.589 526.317 195.254 523.318 214.009C520.356 232.801 516.028 254.403 510.372 278.89L508.132 289.254C499.514 327.826 487.1 359.602 471.079 384.544C455.02 409.525 433.684 428.355 407.109 441.073C380.534 453.829 347.695 460.169 308.667 460.169C265.388 460.169 227.462 449.577 194.85 428.279C162.201 407.057 137.107 377.445 119.567 339.481C102.028 301.554 93.2578 258.389 93.2578 209.908C93.2578 192.635 97.0163 179.347 104.571 170.084C112.088 160.783 121.124 155.771 131.64 154.86C137.828 154.367 143.295 155.392 148.42 157.252Z" fill="white"/> +</g> +<defs> +<filter id="filter0_d_384_31" x="0.34" y="0.783594" width="617.32" height="583.215" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> +<feOffset dy="29.26"/> +<feGaussianBlur stdDeviation="26.33"/> +<feComposite in2="hardAlpha" operator="out"/> +<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.65 0"/> +<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_384_31"/> +<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_384_31" result="shape"/> +</filter> +</defs> +</svg> diff --git a/src/assets/cursors/tahoe/contextualmenu-1__12-5.svg b/src/assets/cursors/tahoe/contextualmenu-1__12-5.svg new file mode 100644 index 000000000..e983117a8 --- /dev/null +++ b/src/assets/cursors/tahoe/contextualmenu-1__12-5.svg @@ -0,0 +1,24 @@ +<svg width="618" height="966" viewBox="0 0 618 966" fill="none" xmlns="http://www.w3.org/2000/svg"> +<g filter="url(#filter0_d_384_17)"> +<path fill-rule="evenodd" clip-rule="evenodd" d="M98.2072 32.0329L350.687 285.055C382.401 316.86 359.903 371.132 314.997 371.132L211.18 371.102L136.849 436.91C104.291 465.703 53 442.603 53 399.112V50.7363C53 27.1237 81.5218 15.3174 98.2072 32.0329Z" fill="white"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M83.1016 70.2555V398.452C83.1016 416.222 104.094 425.679 117.406 413.903L199.779 340.987L314.228 341.017C332.6 341.017 341.816 318.82 328.835 305.809L90.6612 67.1232C87.8903 64.3222 83.1016 66.31 83.1016 70.2555Z" fill="black"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M501.42 883.125H267.131C232.044 883.125 203.582 854.663 203.582 819.545V494.933C203.582 459.815 232.044 431.354 267.131 431.354H501.42C536.537 431.354 564.999 459.815 564.999 494.933V819.545C564.999 854.663 536.537 883.125 501.42 883.125Z" fill="white"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M497.228 853.008H271.343C250.561 853.008 233.695 836.142 233.695 815.36V499.12C233.695 478.339 250.561 461.473 271.343 461.473H497.228C518.01 461.473 534.876 478.339 534.876 499.12V815.36C534.876 836.142 518.01 853.008 497.228 853.008Z" fill="black"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M459.579 551.827H308.989C300.676 551.827 293.93 545.081 293.93 536.768C293.93 528.455 300.676 521.709 308.989 521.709H459.579C467.892 521.709 474.638 528.455 474.638 536.768C474.638 545.081 467.892 551.827 459.579 551.827Z" fill="white"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M459.579 792.77H308.989C300.676 792.77 293.93 786.024 293.93 777.711C293.93 769.399 300.676 762.652 308.989 762.652H459.579C467.892 762.652 474.638 769.399 474.638 777.711C474.638 786.024 467.892 792.77 459.579 792.77Z" fill="white"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M476.484 702.417H292.071C276.47 702.417 263.82 689.767 263.82 674.136V640.343C263.82 624.712 276.47 612.062 292.071 612.062H476.484C492.085 612.062 504.765 624.712 504.765 640.343V674.136C504.765 689.767 492.085 702.417 476.484 702.417Z" fill="white"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M399.343 672.298H308.989C300.676 672.298 293.93 665.551 293.93 657.239C293.93 648.926 300.676 642.18 308.989 642.18H399.343C407.656 642.18 414.402 648.926 414.402 657.239C414.402 665.551 407.656 672.298 399.343 672.298Z" fill="black"/> +</g> +<defs> +<filter id="filter0_d_384_17" x="0.34" y="0.799219" width="617.32" height="964.246" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> +<feOffset dy="29.26"/> +<feGaussianBlur stdDeviation="26.33"/> +<feComposite in2="hardAlpha" operator="out"/> +<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.65 0"/> +<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_384_17"/> +<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_384_17" result="shape"/> +</filter> +</defs> +</svg> diff --git a/src/assets/cursors/tahoe/copy-1__23-0.svg b/src/assets/cursors/tahoe/copy-1__23-0.svg new file mode 100644 index 000000000..6f7085e98 --- /dev/null +++ b/src/assets/cursors/tahoe/copy-1__23-0.svg @@ -0,0 +1,40 @@ +<?xml version="1.0" encoding="UTF-8" standalone="yes"?> +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="767.314286" height="746" viewBox="0 0 767.314286 746"> + <defs> + <filter id="filter-remove-color" x="0%" y="0%" width="100%" height="100%"> + <feColorMatrix color-interpolation-filters="sRGB" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"/> + </filter> + <clipPath id="clip-0"> + <path clip-rule="nonzero" d="M 18.953125 0 L 764.953125 0 L 764.953125 746 L 18.953125 746 Z M 18.953125 0 "/> + </clipPath> + <clipPath id="clip-1"> + <path clip-rule="nonzero" d="M 0.953125 0 L 746.953125 0 L 746.953125 746 L 0.953125 746 Z M 0.953125 0 "/> + </clipPath> + <clipPath id="clip-2"> + <rect x="0" y="0" width="768" height="746"/> + </clipPath> + <clipPath id="clip-3"> + <rect x="0" y="0" width="747" height="746"/> + </clipPath> + <image id="source-35" x="0" y="0" width="768" height="746" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAwAAAALqCAYAAACCKTPeAAAABmJLR0QA/wD/AP+gvaeTAAAPH0lEQVR4nO3XsQ3CABAEwcMx/TeKnDugBCwc7EwBrw9vX7vPe9vnxnsAAMDvzn23+rbtePARAADgzwQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQi7BdQfUtXfFEwAAAABJRU5ErkJggg=="/> + <image id="source-28" x="0" y="0" width="768" height="746" xlink:href="data:image/png;base64,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"/> + <mask id="mask-0"> + <g filter="url(#filter-remove-color)"> + <use xlink:href="#source-35" transform="matrix(1, 0, 0, 1, -18, 0)"/> + </g> + </mask> + <g id="source-34" clip-path="url(#clip-2)"> + <use xlink:href="#source-28"/> + </g> + <g id="source-37" clip-path="url(#clip-3)"> + <g clip-path="url(#clip-1)"> + <g mask="url(#mask-0)"> + <use xlink:href="#source-34" transform="matrix(1, 0, 0, 1, -18, 0)"/> + </g> + </g> + </g> + </defs> + <g clip-path="url(#clip-0)"> + <use xlink:href="#source-37" transform="matrix(1, 0, 0, 1, 18, 0)"/> + </g> +</svg> \ No newline at end of file diff --git a/src/assets/cursors/tahoe/cross-1__50-46.svg b/src/assets/cursors/tahoe/cross-1__50-46.svg new file mode 100644 index 000000000..251f624fb --- /dev/null +++ b/src/assets/cursors/tahoe/cross-1__50-46.svg @@ -0,0 +1,18 @@ +<svg width="618" height="618" viewBox="0 0 618 618" fill="none" xmlns="http://www.w3.org/2000/svg"> +<g filter="url(#filter0_d_384_13)"> +<path fill-rule="evenodd" clip-rule="evenodd" d="M309 24C337.262 24 360.2 46.9376 360.2 75.2V228.8H513.8C540.4 228.8 562.283 249.118 564.766 275.071L565 280C565 308.262 542.062 331.2 513.8 331.2H360.2V484.8C360.2 511.4 339.882 533.283 313.929 535.766L309 536C280.738 536 257.8 513.062 257.8 484.8V331.2H104.2C77.6001 331.2 55.717 310.882 53.2345 284.929L53 280C53 251.738 75.9376 228.8 104.2 228.8H257.8V75.2C257.8 48.6001 278.118 26.717 304.071 24.2345L309 24Z" fill="white"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M308.999 58.1328C318.42 58.1328 326.066 65.7787 326.066 75.1995V262.933H513.8C522.174 262.933 529.145 268.974 530.591 276.933L530.866 279.999C530.866 289.42 523.22 297.066 513.8 297.066H326.066V484.8C326.066 493.174 320.025 500.145 312.066 501.591L308.999 501.866C299.579 501.866 291.933 494.22 291.933 484.8V297.066H104.199C95.8254 297.066 88.8538 291.025 87.4079 283.066L87.1328 279.999C87.1328 270.579 94.7787 262.933 104.199 262.933H291.933V75.1995C291.933 66.8254 297.974 59.8538 305.933 58.4079L308.999 58.1328Z" fill="#232020"/> +</g> +<defs> +<filter id="filter0_d_384_13" x="0.34" y="0.6" width="617.32" height="617.32" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> +<feOffset dy="29.26"/> +<feGaussianBlur stdDeviation="26.33"/> +<feComposite in2="hardAlpha" operator="out"/> +<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.65 0"/> +<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_384_13"/> +<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_384_13" result="shape"/> +</filter> +</defs> +</svg> diff --git a/src/assets/cursors/tahoe/crosshair-1__50-50.svg b/src/assets/cursors/tahoe/crosshair-1__50-50.svg new file mode 100644 index 000000000..4f76ba3d9 --- /dev/null +++ b/src/assets/cursors/tahoe/crosshair-1__50-50.svg @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="UTF-8" standalone="yes"?> +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="767.314286" height="746" viewBox="0 0 767.314286 746"> + <path fill-rule="nonzero" fill="rgb(100%, 100%, 100%)" fill-opacity="1" d="M 428.796875 326.375 L 428.796875 139.875 L 336.167969 139.875 L 336.167969 326.375 L 149.355469 326.375 L 149.355469 419.625 L 336.167969 419.625 L 336.167969 606.125 L 428.796875 606.125 L 428.796875 419.625 L 615.605469 419.625 L 615.605469 326.375 Z M 428.796875 326.375 "/> + <path fill-rule="nonzero" fill="rgb(13.72549%, 12.54902%, 12.54902%)" fill-opacity="1" d="M 398.019531 357.457031 L 398.019531 170.957031 L 366.9375 170.957031 L 366.9375 357.457031 L 180.4375 357.457031 L 180.4375 388.542969 L 366.9375 388.542969 L 366.9375 575.042969 L 398.019531 575.042969 L 398.019531 388.542969 L 584.519531 388.542969 L 584.519531 357.457031 Z M 398.019531 357.457031 "/> +</svg> \ No newline at end of file diff --git a/src/assets/cursors/tahoe/help-1__50-50.svg b/src/assets/cursors/tahoe/help-1__50-50.svg new file mode 100644 index 000000000..7b292313f --- /dev/null +++ b/src/assets/cursors/tahoe/help-1__50-50.svg @@ -0,0 +1,7 @@ +<?xml version="1.0" encoding="UTF-8" standalone="yes"?> +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="767.314286" height="746" viewBox="0 0 767.314286 746"> + <defs> + <image id="source-26" x="0" y="0" width="768" height="746" xlink:href="data:image/png;base64,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"/> + </defs> + <use xlink:href="#source-26"/> +</svg> \ No newline at end of file diff --git a/src/assets/cursors/tahoe/ibeam-1__50-44.svg b/src/assets/cursors/tahoe/ibeam-1__50-44.svg new file mode 100644 index 000000000..9b50370a3 --- /dev/null +++ b/src/assets/cursors/tahoe/ibeam-1__50-44.svg @@ -0,0 +1,18 @@ +<svg width="618" height="1350" viewBox="0 0 618 1350" fill="none" xmlns="http://www.w3.org/2000/svg"> +<g filter="url(#filter0_d_384_92)"> +<path fill-rule="evenodd" clip-rule="evenodd" d="M564.801 124.503C564.801 174.2 527.662 216.95 478.499 223.964C438.955 229.642 409.097 264.176 409.097 304.254V987.589C409.097 1027.73 438.888 1062.27 478.365 1067.95C527.461 1074.89 564.6 1117.71 564.6 1167.41C564.4 1197 551.642 1224.59 529.532 1243.49C507.556 1262.39 478.365 1270.94 449.576 1266.67C397.274 1259.19 348.579 1237.08 308.701 1203.54C268.756 1237.08 220.061 1259.19 167.491 1266.73C138.969 1270.94 109.846 1262.46 87.7357 1243.49C65.6259 1224.32 53.0012 1196.8 52.8008 1167.87C52.8008 1117.71 89.94 1074.89 139.103 1067.88C178.78 1062.2 208.705 1027.67 208.705 987.589V304.254C208.705 264.176 178.847 229.642 139.236 223.964C90.1404 216.95 53.068 174.2 53.068 124.503C53.1348 95.1125 65.893 67.4585 88.0029 48.4213C106.038 32.8576 129.35 24.1072 153.531 24.1072H158.941L169.028 25.3763C220.729 32.7908 269.157 54.8339 308.901 88.366C348.912 54.8339 397.608 32.6572 450.11 25.1759C477.831 20.7005 507.355 29.117 529.866 48.4213C551.976 67.5921 564.6 95.1125 564.801 124.036V124.303V124.503Z" fill="white"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M148.788 157.784C221.062 168.138 275.569 231.128 275.569 304.204V987.538C275.569 1060.61 220.929 1123.6 148.521 1133.96C132.022 1136.3 119.598 1150.66 119.598 1167.36C119.664 1177.11 123.873 1186.33 131.22 1192.74C138.635 1199.02 148.32 1201.96 158.073 1200.55C219.66 1191.74 275.101 1155.73 308.7 1103.43C342.366 1155.73 397.741 1191.74 459.395 1200.55C468.88 1201.96 478.766 1199.02 486.113 1192.67C493.528 1186.33 497.736 1177.11 497.803 1167.36C497.803 1150.66 485.379 1136.3 468.88 1133.96C396.739 1123.6 342.366 1060.68 342.366 987.538V304.204C342.366 231.128 396.872 168.138 469.147 157.784C485.646 155.447 498.07 141.085 498.07 124.386C498.003 114.7 493.728 105.415 486.381 99.003C478.966 92.724 469.281 89.6514 459.595 91.1877C398.008 100.072 342.566 136.009 308.901 188.311C275.235 136.009 219.86 100.072 158.273 91.1877C156.737 90.9873 155.134 90.8537 153.531 90.8537C145.515 90.8537 137.7 93.726 131.554 99.003C124.14 105.415 119.932 114.633 119.865 124.453C119.865 141.085 132.289 155.447 148.788 157.784Z" fill="black"/> +</g> +<defs> +<filter id="filter0_d_384_92" x="0.140781" y="0.6" width="617.32" height="1349.11" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> +<feOffset dy="29.26"/> +<feGaussianBlur stdDeviation="26.33"/> +<feComposite in2="hardAlpha" operator="out"/> +<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.65 0"/> +<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_384_92"/> +<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_384_92" result="shape"/> +</filter> +</defs> +</svg> diff --git a/src/assets/cursors/tahoe/ibeamstroke-1__50-44.svg b/src/assets/cursors/tahoe/ibeamstroke-1__50-44.svg new file mode 100644 index 000000000..2022f643d --- /dev/null +++ b/src/assets/cursors/tahoe/ibeamstroke-1__50-44.svg @@ -0,0 +1,23 @@ +<svg width="618" height="1350" viewBox="0 0 618 1350" fill="none" xmlns="http://www.w3.org/2000/svg"> +<g clip-path="url(#clip0_425_83)"> +<g filter="url(#filter0_d_425_83)"> +<path fill-rule="evenodd" clip-rule="evenodd" d="M564.801 124.503C564.801 174.2 527.662 216.95 478.499 223.964C438.955 229.642 409.097 264.176 409.097 304.254V987.589C409.097 1027.73 438.888 1062.27 478.365 1067.95C527.461 1074.89 564.6 1117.71 564.6 1167.41C564.4 1197 551.642 1224.59 529.532 1243.49C507.556 1262.39 478.365 1270.94 449.576 1266.67C397.274 1259.19 348.579 1237.08 308.701 1203.54C268.756 1237.08 220.061 1259.19 167.491 1266.73C138.969 1270.94 109.846 1262.46 87.7357 1243.49C65.6259 1224.32 53.0012 1196.8 52.8008 1167.87C52.8008 1117.71 89.94 1074.89 139.103 1067.88C178.78 1062.2 208.705 1027.67 208.705 987.589V304.254C208.705 264.176 178.847 229.642 139.236 223.964C90.1404 216.95 53.068 174.2 53.068 124.503C53.1348 95.1125 65.893 67.4585 88.0029 48.4213C106.038 32.8576 129.35 24.1072 153.531 24.1072H158.941L169.028 25.3763C220.729 32.7908 269.157 54.8339 308.901 88.366C348.912 54.8339 397.608 32.6572 450.11 25.1759C477.831 20.7005 507.355 29.117 529.866 48.4213C551.976 67.5921 564.6 95.1125 564.801 124.036V124.303V124.503Z" fill="white"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M148.788 157.784C221.062 168.138 275.569 231.128 275.569 304.204V987.538C275.569 1060.61 220.929 1123.6 148.521 1133.96C132.022 1136.3 119.598 1150.66 119.598 1167.36C119.664 1177.11 123.873 1186.33 131.22 1192.74C138.635 1199.02 148.32 1201.96 158.073 1200.55C219.66 1191.74 275.101 1155.73 308.7 1103.43C342.366 1155.73 397.741 1191.74 459.395 1200.55C468.88 1201.96 478.766 1199.02 486.113 1192.67C493.528 1186.33 497.736 1177.11 497.803 1167.36C497.803 1150.66 485.379 1136.3 468.88 1133.96C396.739 1123.6 342.366 1060.68 342.366 987.538V304.204C342.366 231.128 396.872 168.138 469.147 157.784C485.646 155.447 498.07 141.085 498.07 124.386C498.003 114.7 493.728 105.415 486.381 99.003C478.966 92.724 469.281 89.6514 459.595 91.1877C398.008 100.072 342.566 136.009 308.901 188.311C275.235 136.009 219.86 100.072 158.273 91.1877C156.737 90.9873 155.134 90.8537 153.531 90.8537C145.515 90.8537 137.7 93.726 131.554 99.003C124.14 105.415 119.932 114.633 119.865 124.453C119.865 141.085 132.289 155.447 148.788 157.784Z" fill="black"/> +</g> +</g> +<defs> +<filter id="filter0_d_425_83" x="0.140781" y="0.6" width="617.32" height="1349.11" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> +<feOffset dy="29.26"/> +<feGaussianBlur stdDeviation="26.33"/> +<feComposite in2="hardAlpha" operator="out"/> +<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.65 0"/> +<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_425_83"/> +<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_425_83" result="shape"/> +</filter> +<clipPath id="clip0_425_83"> +<rect width="618" height="1350" fill="white"/> +</clipPath> +</defs> +</svg> diff --git a/src/assets/cursors/tahoe/ibeamvertical-1__50-50.svg b/src/assets/cursors/tahoe/ibeamvertical-1__50-50.svg new file mode 100644 index 000000000..b7e1b9bce --- /dev/null +++ b/src/assets/cursors/tahoe/ibeamvertical-1__50-50.svg @@ -0,0 +1,18 @@ +<svg width="1000" height="1000" viewBox="0 0 1000 1000" fill="none" xmlns="http://www.w3.org/2000/svg"> +<g filter="url(#filter0_d_434_5)"> +<path fill-rule="evenodd" clip-rule="evenodd" d="M856.317 675.069C822.354 675.069 793.139 649.676 788.345 616.062C784.465 589.024 760.865 568.61 733.475 568.61L266.487 568.61C239.055 568.61 215.45 588.979 211.568 615.97C206.826 649.538 177.563 674.931 143.598 674.931C123.376 674.795 104.521 666.072 91.6051 650.954C78.6889 635.929 72.8458 615.97 75.7639 596.286C80.8757 560.526 95.9856 527.232 118.907 499.966C95.9856 472.655 80.8758 439.36 75.723 403.417C72.8459 383.916 78.641 364.003 91.6051 348.886C104.706 333.769 123.513 325.137 143.284 325C177.563 325 206.826 350.393 211.616 384.007C215.498 411.135 239.096 431.596 266.487 431.596L733.475 431.596C760.865 431.596 784.465 411.181 788.345 384.098C793.139 350.53 822.354 325.183 856.317 325.183C876.402 325.228 895.301 333.952 908.311 349.069C918.947 361.4 924.927 377.339 924.927 393.872L924.927 397.571L924.059 404.468C918.992 439.817 903.928 472.929 881.012 500.103C903.928 527.46 919.084 560.754 924.196 596.651C927.255 615.605 921.503 635.791 908.311 651.183C895.209 666.3 876.402 674.932 856.636 675.069L856.453 675.069L856.317 675.069Z" fill="white"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M833.573 390.63C826.497 440.046 783.45 477.314 733.51 477.314L266.522 477.314C216.585 477.314 173.538 439.955 166.458 390.447C164.859 379.166 155.045 370.672 143.632 370.672C136.969 370.717 130.668 373.595 126.288 378.618C121.996 383.688 119.987 390.31 120.95 396.978C126.971 439.087 151.58 476.994 187.322 499.966C151.58 522.985 126.971 560.846 120.95 603.001C119.987 609.486 121.996 616.245 126.335 621.268C130.668 626.338 136.969 629.215 143.632 629.261C155.045 629.261 164.859 620.767 166.458 609.486C173.538 560.161 216.537 522.985 266.522 522.985L733.51 522.985C783.45 522.985 826.497 560.252 833.573 609.668C835.17 620.949 844.985 629.444 856.397 629.444C863.016 629.398 869.362 626.475 873.744 621.452C878.035 616.382 880.134 609.76 879.084 603.137C873.013 561.029 848.454 523.121 812.711 500.104C848.454 477.085 873.013 439.224 879.084 397.115C879.221 396.065 879.313 394.969 879.313 393.873C879.313 388.392 877.35 383.049 873.744 378.847C869.362 373.777 863.062 370.9 856.351 370.854C844.985 370.854 835.17 379.349 833.573 390.63Z" fill="black"/> +</g> +<defs> +<filter id="filter0_d_434_5" x="22.34" y="301.6" width="955.32" height="455.388" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> +<feOffset dy="29.26"/> +<feGaussianBlur stdDeviation="26.33"/> +<feComposite in2="hardAlpha" operator="out"/> +<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.65 0"/> +<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_434_5"/> +<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_434_5" result="shape"/> +</filter> +</defs> +</svg> diff --git a/src/assets/cursors/tahoe/ibeamverticalstroke-1__50-50.svg b/src/assets/cursors/tahoe/ibeamverticalstroke-1__50-50.svg new file mode 100644 index 000000000..fd1b6b0e6 --- /dev/null +++ b/src/assets/cursors/tahoe/ibeamverticalstroke-1__50-50.svg @@ -0,0 +1,18 @@ +<svg width="1000" height="1000" viewBox="0 0 1000 1000" fill="none" xmlns="http://www.w3.org/2000/svg"> +<g filter="url(#filter0_d_434_4)"> +<path fill-rule="evenodd" clip-rule="evenodd" d="M856.317 675.069C822.354 675.069 793.139 649.676 788.345 616.062C784.465 589.024 760.865 568.61 733.475 568.61L266.487 568.61C239.055 568.61 215.45 588.979 211.568 615.97C206.826 649.538 177.563 674.931 143.598 674.931C123.376 674.795 104.521 666.072 91.6051 650.954C78.6889 635.929 72.8458 615.97 75.7639 596.286C80.8757 560.526 95.9856 527.232 118.907 499.966C95.9856 472.655 80.8758 439.36 75.723 403.417C72.8459 383.916 78.641 364.003 91.6051 348.886C104.706 333.769 123.513 325.137 143.284 325C177.563 325 206.826 350.393 211.616 384.007C215.498 411.135 239.096 431.596 266.487 431.596L733.475 431.596C760.865 431.596 784.465 411.181 788.345 384.098C793.139 350.53 822.354 325.183 856.317 325.183C876.402 325.228 895.301 333.952 908.311 349.069C918.947 361.4 924.927 377.339 924.927 393.872L924.927 397.571L924.059 404.468C918.992 439.817 903.928 472.929 881.012 500.103C903.928 527.46 919.084 560.754 924.196 596.651C927.255 615.605 921.503 635.791 908.311 651.183C895.209 666.3 876.402 674.932 856.636 675.069L856.453 675.069L856.317 675.069Z" fill="white"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M833.573 390.63C826.497 440.046 783.45 477.314 733.51 477.314L266.522 477.314C216.585 477.314 173.538 439.955 166.458 390.447C164.859 379.166 155.045 370.672 143.632 370.672C136.969 370.717 130.668 373.595 126.288 378.618C121.996 383.688 119.987 390.31 120.95 396.978C126.971 439.087 151.58 476.994 187.322 499.966C151.58 522.985 126.971 560.846 120.95 603.001C119.987 609.486 121.996 616.245 126.335 621.268C130.668 626.338 136.969 629.215 143.632 629.261C155.045 629.261 164.859 620.767 166.458 609.486C173.538 560.161 216.537 522.985 266.522 522.985L733.51 522.985C783.45 522.985 826.497 560.252 833.573 609.668C835.17 620.949 844.985 629.444 856.397 629.444C863.016 629.398 869.362 626.475 873.744 621.452C878.035 616.382 880.134 609.76 879.084 603.137C873.013 561.029 848.454 523.121 812.711 500.104C848.454 477.085 873.013 439.224 879.084 397.115C879.221 396.065 879.313 394.969 879.313 393.873C879.313 388.392 877.35 383.049 873.744 378.847C869.362 373.777 863.062 370.9 856.351 370.854C844.985 370.854 835.17 379.349 833.573 390.63Z" fill="black"/> +</g> +<defs> +<filter id="filter0_d_434_4" x="22.34" y="301.6" width="955.32" height="455.388" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> +<feOffset dy="29.26"/> +<feGaussianBlur stdDeviation="26.33"/> +<feComposite in2="hardAlpha" operator="out"/> +<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.65 0"/> +<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_434_4"/> +<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_434_4" result="shape"/> +</filter> +</defs> +</svg> diff --git a/src/assets/cursors/tahoe/makealias-1__63-29.svg b/src/assets/cursors/tahoe/makealias-1__63-29.svg new file mode 100644 index 000000000..1e188e753 --- /dev/null +++ b/src/assets/cursors/tahoe/makealias-1__63-29.svg @@ -0,0 +1,7 @@ +<?xml version="1.0" encoding="UTF-8" standalone="yes"?> +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="767.314286" height="746" viewBox="0 0 767.314286 746"> + <defs> + <image id="source-24" x="0" y="0" width="768" height="746" xlink:href="data:image/png;base64,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1+nxD03/cS349S0CnlG+KIpNJL21RjuC7r//fn3sYx/LVd1AOeKII3T77bdr11137biuMgkYxluEdogEAEDfIAEAkCrltpyxfbHyobv/lKMApiiKuzQ99Sfb/2Hvec979Pvf/z5XdQPjLW95i77xjW9o8803z1bn5ptvrptuuokkYCYSAAB9gwQAQClH738osJdmB/WxL2sEWjwAACAASURBVPoa85R7s6Rs80zuuOMOXXHFFbmqGwhz5szRJz/5SX3605/W+HiW706bobw70L777pu97jYxBQjAKCABANCJ0KiAazuUDIxVnu05/9Xe//9Qxnv+S9L73ve+NgO/vjN//nxde+21etvb3tbqdbbcckutWbNmoEYCuAsQgFFAAgBAivf+x+bx+8qk9P772uFb/PshSQsj7U22YsUKrVq1Kld1fW+HHXbQd7/7XR133HFduR5rAjYgAQDQN0gAADQV6/0PrQtw9u57tjfsK4riSUnn5Gi8ND3d4/zzz89VXd874IAD9P3vf1/Pf/7zu3rdLbbYQt/61re0zz77dPW6TYzSSBCA0UUCAKCTuf++3v/Ygl67vtCtP6uPD0vaKLG9Uddff71++MMf5qqurx1//PFatWqVFi7MNnhSy1ZbbaU1a9Zov/3268n1+wCZBYC+QQIAICYU+Nepw5UcuIJ8X++/kXRy3caHfOQjH8lZXd8666yzdM0112jTTTftaTu22GIL3XTTTaOaBJAAAOgbJADAaEud+x86Fur19wX+of3VRcDVx0eV8f+sG264QXfccUeu6vrWueeeq89+9rPaaKNsAycdGeEkgAQAQN8gAQCQwp6uEytj7w/d+af6cN4ZqCiKSUnHNGq5x7D3/s+ZM0eXXnppX365WbkmYK+99up1U2bhNqAARgEJADC6cvf+l891ev9tvt7/P09ob7JbbrlFt956a67q+s7cuXN15ZVX6s1vfnOvm+K1cOFCrVq1qu+SAG4DCmAUkAAAiGnS+x9a9JvS+79hX1EUv5F0etPGu1x44YU5q+srm222mb72ta/p5JOzLpdoRT8mAYwAABgFJAAAXDrt/fft9/X+++b+j2n6W383q/0OPO68806tWLEiV3V9pZxff+SRR/a6Kcn6MQkAgGFHAgCMpia3/myz998+ZiSNFUXxE2W8778kffSjHx3Ke70vXrxYN998sw488MBeN6W2EUkChu+XDsDAIgEAYMvd+++a0y/rPN+Xgb1a0g4N38csv/jFL3T11Vfnqq5v7LDDDvr2t7+tvffeu9dNaWwEkgASAAB9gwQAGD3d7v2XtS803cfe9/bEtib52Mc+psnJyZxV9twee+yh2267TTvvvHOvm9KxhQsXauXKlV3/puIq1gAAGAUkAABS5er9jz2qt/58ea7G33vvvfrCF76Qq7q+sNtuu2nlypVavHhxr5uSzaJFi7R69WrtueeePbk+dwECMApIAIDRknrrz5QoqGnvvzfotx5vTWxHkosuukjr1q3LVV3P7b777lqzZo2e/exn97op2S1atEirVq3qSRIwjOtDAMBGAgAgxte7Xz2Wo/e/euvPuyWdkusNPPjgg7r00ktzVddz++yzj2655Zah6vm3LVq0SCtXrtQee+zR66bkMtXrBgBAiQQAQKlfev/HJB0vaat6zff727/9Wz322GO5quupfffdVytXrtRWW2X7ePrW1ltvrTVr1vRsOlBmDC0A6BskAMDoaDKdppu9/9XnP27QVqcnnnhCl1xySa7qemrffffVqlWrRiL4Ly1atEg33XSTdt99965cjylAAEYBCQAAqbO59lnn/hdF8R+Sju6gPTNcfvnluv/++3NV1zO77LKLVqxYoWc961m9bkrXLV68WKtXr+5aEtASMgsAfYMEAECVsV77AnpX2erxTnr/Xy9pPMu7kfSpT30qV1U985znPEc33nijFi1a1OumSNL3Jb1DUldXVJdfdNb2LUK5CxCAUUACAIyGTqOa0Jz/8jk0RSj1zj9jks7ssK0b3HHHHfrRj36Uq7qe2GSTTXTddddp++2373VTJOlSSYcYY/bV9CjNo928eHmL0DaTAL4HAMAoIAEAEOrRt8vFAv+Oev+LonhC0gs6fkfrXXbZZbmq6gljjD73uc/pRS96Ua+bslbS640xGxlj/k5SYYw5TdJBkv6jmw0p1wTstttu3bxsDiQAAPoGCQAAH99dgTrt/Xd942+5/6xcjX/qqad09dVX56quJ8477zwtXbq01824W9JLjDGHaTqI3fAwxrxT0sGSftbNBj372c/WzTffPEy3CAWAriIBAIZfaPqPL8hPKevq/bdfV7ftMnbv/52STktoR5LVq1frwQcfzFVd173yla/Uhz/84V434wZJBxpj/oes4F9P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"/> + </defs> + <use xlink:href="#source-24"/> +</svg> \ No newline at end of file diff --git a/src/assets/cursors/tahoe/move-1__50-46.svg b/src/assets/cursors/tahoe/move-1__50-46.svg new file mode 100644 index 000000000..623a1428a --- /dev/null +++ b/src/assets/cursors/tahoe/move-1__50-46.svg @@ -0,0 +1,18 @@ +<svg width="618" height="618" viewBox="0 0 618 618" fill="none" xmlns="http://www.w3.org/2000/svg"> +<g filter="url(#filter0_d_384_43)"> +<path fill-rule="evenodd" clip-rule="evenodd" d="M282.418 35.0943C297.132 20.38 320.993 20.38 335.707 35.0943L554.027 253.414C568.741 268.128 568.741 291.989 554.027 306.703L335.707 525.023C320.993 539.737 297.132 539.737 282.418 525.023L64.0982 306.703C49.3839 291.989 49.3839 268.128 64.0982 253.414L282.418 35.0943Z" fill="white"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M193.669 362.129C193.669 374.419 178.781 380.594 170.068 371.881L89.976 291.789C83.5132 285.326 83.5132 274.824 89.976 268.332L170.068 188.24C178.781 179.527 193.669 185.701 193.669 197.992L193.686 251.212H280.212V164.657L226.998 164.669C215.526 164.669 209.383 151.7 215.699 142.887L217.246 141.068L297.338 60.976C303.801 54.5132 314.303 54.5132 320.794 60.976L400.886 141.068C409.599 149.781 403.425 164.669 391.134 164.669L337.915 164.657V251.212H424.47V197.998C424.47 186.526 437.438 180.383 446.228 186.699L448.041 188.246L528.162 268.338C534.625 274.801 534.625 285.303 528.162 291.794L448.041 371.886C439.357 380.599 424.47 374.425 424.47 362.134V308.915H337.915V395.441L391.129 395.458C402.6 395.458 408.743 408.427 402.427 417.24L400.881 419.059L320.789 499.151C314.326 505.613 303.824 505.613 297.332 499.151L217.24 419.059C208.527 410.345 214.701 395.458 226.992 395.458L280.212 395.441V308.915H193.686L193.669 362.129Z" fill="black"/> +</g> +<defs> +<filter id="filter0_d_384_43" x="0.4025" y="0.658594" width="617.32" height="617.32" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> +<feOffset dy="29.26"/> +<feGaussianBlur stdDeviation="26.33"/> +<feComposite in2="hardAlpha" operator="out"/> +<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.65 0"/> +<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_384_43"/> +<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_384_43" result="shape"/> +</filter> +</defs> +</svg> diff --git a/src/assets/cursors/tahoe/notallowed-1__23-0.svg b/src/assets/cursors/tahoe/notallowed-1__23-0.svg new file mode 100644 index 000000000..7ee2ef758 --- /dev/null +++ b/src/assets/cursors/tahoe/notallowed-1__23-0.svg @@ -0,0 +1,40 @@ +<?xml version="1.0" encoding="UTF-8" standalone="yes"?> +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="767.314286" height="746" viewBox="0 0 767.314286 746"> + <defs> + <filter id="filter-remove-color" x="0%" y="0%" width="100%" height="100%"> + <feColorMatrix color-interpolation-filters="sRGB" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"/> + </filter> + <clipPath id="clip-0"> + <path clip-rule="nonzero" d="M 18.953125 0 L 764.953125 0 L 764.953125 746 L 18.953125 746 Z M 18.953125 0 "/> + </clipPath> + <clipPath id="clip-1"> + <path clip-rule="nonzero" d="M 0.953125 0 L 746.953125 0 L 746.953125 746 L 0.953125 746 Z M 0.953125 0 "/> + </clipPath> + <clipPath id="clip-2"> + <rect x="0" y="0" width="768" height="746"/> + </clipPath> + <clipPath id="clip-3"> + <rect x="0" y="0" width="747" height="746"/> + </clipPath> + <image id="source-34" x="0" y="0" width="768" height="746" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAwAAAALqCAYAAACCKTPeAAAABmJLR0QA/wD/AP+gvaeTAAAPH0lEQVR4nO3XsQ3CABAEwcMx/TeKnDugBCwc7EwBrw9vX7vPe9vnxnsAAMDvzn23+rbtePARAADgzwQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQgQAAACECAAAAAgRAAAAECIAAAAgRAAAAECIAAAAgBABAAAAIQIAAABCBAAAAIQIAAAACBEAAAAQIgAAACBEAAAAQIgAAACAEAEAAAAhAgAAAEIEAAAAhAgAAAAIEQAAABAiAAAAIEQAAABAiAAAAIAQAQAAACECAAAAQi7BdQfUtXfFEwAAAABJRU5ErkJggg=="/> + <image id="source-27" x="0" y="0" width="768" height="746" xlink:href="data:image/png;base64,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"/> + <mask id="mask-0"> + <g filter="url(#filter-remove-color)"> + <use xlink:href="#source-34" transform="matrix(1, 0, 0, 1, -18, 0)"/> + </g> + </mask> + <g id="source-33" clip-path="url(#clip-2)"> + <use xlink:href="#source-27"/> + </g> + <g id="source-36" clip-path="url(#clip-3)"> + <g clip-path="url(#clip-1)"> + <g mask="url(#mask-0)"> + <use xlink:href="#source-33" transform="matrix(1, 0, 0, 1, -18, 0)"/> + </g> + </g> + </g> + </defs> + <g clip-path="url(#clip-0)"> + <use xlink:href="#source-36" transform="matrix(1, 0, 0, 1, 18, 0)"/> + </g> +</svg> \ No newline at end of file diff --git a/src/assets/cursors/tahoe/openhand-1__55-57.svg b/src/assets/cursors/tahoe/openhand-1__55-57.svg new file mode 100644 index 000000000..a4b8ff007 --- /dev/null +++ b/src/assets/cursors/tahoe/openhand-1__55-57.svg @@ -0,0 +1,18 @@ +<svg width="618" height="686" viewBox="0 0 618 686" fill="none" xmlns="http://www.w3.org/2000/svg"> +<g filter="url(#filter0_d_384_35)"> +<path fill-rule="evenodd" clip-rule="evenodd" d="M328.132 584.862C298.251 584.862 270.9 579.482 246.848 568.881C222.604 558.184 200.793 541.658 182.058 519.816C163.803 498.518 147.661 471.264 134.05 438.853L72.8146 288.167C68.2989 277.246 67.7864 266.773 71.3093 257.165C75.1526 246.82 82.7749 239.486 93.4078 235.931C97.251 234.554 101.543 233.753 105.802 233.753C111.055 233.753 116.147 234.938 120.983 237.276C128.99 241.183 135.619 248.133 140.711 257.965L191.538 349.53C194.869 355.295 197.367 357.408 198.263 358.049C199.641 358.978 200.986 359.362 202.779 359.362C204.925 359.362 206.238 358.754 207.711 357.088C208.32 356.384 209.217 353.341 208.096 346.744L172.802 121.371C170.368 107.248 174.66 97.4154 178.727 91.7147C184.524 83.5478 192.819 78.3275 202.683 76.598C205.181 76.2778 206.911 76.1496 208.64 76.1496C216.647 76.1496 224.109 78.5196 230.771 83.1315C239.097 88.8963 244.574 97.6716 246.56 108.529L281.405 272.89L283.326 79.8007C283.326 68.8796 286.945 59.2075 293.863 51.9054C300.973 44.3471 310.517 40.3438 321.374 40.3438C332.327 40.3438 341.999 44.219 349.334 51.5851C356.7 58.9193 360.575 68.7194 360.575 79.8968L359.454 273.659L389.783 105.646C391.321 95.2055 396.541 86.206 404.772 80.0889C411.401 75.1568 418.8 72.6587 426.774 72.6587C428.888 72.6587 431.034 72.8509 433.212 73.2032C443.588 74.8045 452.46 80.0249 458.545 88.2237C462.9 94.0526 467.576 104.077 465.014 118.617L437.279 288.871L476.384 177.835C479.586 167.746 485.127 159.868 492.717 154.839C498.963 150.676 505.88 148.562 513.278 148.562C515.072 148.562 516.865 148.69 518.691 148.946C529.26 150.772 537.491 156.024 543.063 164.255C548.444 172.262 550.365 181.806 548.796 192.567L512.318 403.143C502.069 462.328 482.373 507.646 453.773 537.847C424.276 569.041 382.001 584.862 328.132 584.862Z" fill="white"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M496.643 400.434L533.057 190.243C534.05 183.485 532.993 177.784 529.855 173.172C526.748 168.528 522.296 165.71 516.532 164.717C511.023 163.949 506.027 165.102 501.543 168.08C497.027 171.091 493.632 176.087 491.391 183.101L453.119 291.96C451.357 296.731 448.923 300.094 445.784 302.08C442.678 304.098 438.963 304.738 434.735 303.97C430.732 303.233 427.401 301.151 424.775 297.788C422.149 294.394 421.092 290.582 421.572 286.323L449.371 115.908C450.621 108.67 449.436 102.585 445.784 97.7173C442.165 92.8492 437.105 89.9028 430.604 88.91C424.583 87.8851 419.138 89.1982 414.27 92.8492C409.402 96.4683 406.456 101.657 405.463 108.414L375.422 278.444C374.685 282.704 372.731 286.003 369.593 288.373C366.486 290.775 362.675 291.575 358.159 290.839C353.163 290.07 349.48 288.18 347.078 285.202C344.708 282.191 343.523 278.316 343.523 273.544L344.644 79.8784C344.644 72.8966 342.466 67.1958 338.079 62.8082C333.691 58.4525 328.118 56.2427 321.393 56.2427C314.859 56.2427 309.543 58.4525 305.443 62.8082C301.312 67.1958 299.23 72.8966 299.23 79.8784L298.109 275.049C298.109 279.565 296.54 283.312 293.433 286.323C290.295 289.333 286.612 290.839 282.352 290.839C278.092 290.839 274.537 289.493 271.655 286.867C268.773 284.241 266.819 280.686 265.826 276.17L230.917 111.425C229.668 104.667 226.625 99.6069 221.725 96.2121C216.857 92.8493 211.413 91.5361 205.392 92.2728C199.659 93.2656 195.079 96.148 191.716 100.92C188.321 105.66 187.265 111.681 188.514 118.919L223.807 344.131C225.537 354.636 224.159 362.45 219.676 367.575C215.16 372.731 209.523 375.293 202.766 375.293C197.769 375.293 193.286 373.916 189.282 371.162C185.279 368.407 181.372 363.763 177.625 357.262L126.606 265.313C123.083 258.556 118.888 253.976 114.019 251.606C109.151 249.236 103.931 249.044 98.4544 251.029C92.4334 253.047 88.366 256.922 86.2522 262.687C84.1064 268.42 84.5548 274.921 87.5653 282.191L148.736 432.717C161.739 463.719 176.888 489.308 194.15 509.453C211.413 529.598 231.109 544.554 253.272 554.291C275.402 564.059 300.351 568.927 328.118 568.927C377.664 568.927 415.711 554.931 442.23 526.908C468.748 498.884 486.907 456.737 496.643 400.434ZM117.959 443.574L58.2929 296.475C54.0333 285.938 52.3359 275.946 53.2327 266.434C54.0974 256.922 57.1079 248.659 62.2322 241.645C67.3565 234.664 73.7938 229.155 81.5443 225.152C89.2948 221.373 97.7498 219.643 106.877 219.899C116.005 220.124 124.844 222.75 133.332 227.778C141.851 232.774 148.993 240.396 154.725 250.677L184.382 307.364C185.151 308.838 186.144 309.478 187.393 309.222C188.642 308.966 189.154 307.973 188.898 306.211L156.999 124.556C154.725 112.802 155.238 102.297 158.505 93.0414C161.739 83.7857 166.992 76.2594 174.262 70.5266C181.5 64.7618 189.891 61.1428 199.403 59.6375C208.915 58.1323 218.042 59.061 226.786 62.4559C235.561 65.8187 243.055 71.3913 249.332 79.1418C255.578 86.8923 259.965 96.5323 262.463 108.03L266.947 132.434V79.8784C266.947 63.3846 272.103 49.8694 282.352 39.3646C292.601 28.8598 305.603 23.6074 321.393 23.6074C337.886 23.6074 351.209 28.9879 361.362 39.7489C371.482 50.5099 376.543 64.3775 376.543 81.3837L375.806 97.5251C379.297 82.5366 386.631 71.3273 397.744 63.9291C408.89 56.5629 421.348 54.0008 435.088 56.2427C444.599 57.7479 453.055 61.3669 460.421 67.1318C467.819 72.8966 473.327 80.3268 476.946 89.4544C480.566 98.582 481.622 109.055 480.149 120.809L476.946 150.514C476.327 147.685 479.765 143.975 487.259 139.384C498.533 132.498 510.927 130.192 524.41 132.434C533.185 134.196 540.872 137.751 547.501 143.131C554.131 148.512 558.999 155.718 562.138 164.717C565.276 173.717 565.821 183.965 563.835 195.495L530.047 403.797C519.542 468.619 497.636 518.1 464.36 552.241C431.084 586.381 385.318 603.484 326.997 603.484C277.708 603.484 236.073 590.032 202.029 563.13C167.984 536.228 139.961 496.386 117.959 443.574Z" fill="black"/> +</g> +<defs> +<filter id="filter0_d_384_35" x="0.34" y="0.207422" width="617.32" height="685.197" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> +<feOffset dy="29.26"/> +<feGaussianBlur stdDeviation="26.33"/> +<feComposite in2="hardAlpha" operator="out"/> +<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.65 0"/> +<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_384_35"/> +<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_384_35" result="shape"/> +</filter> +</defs> +</svg> diff --git a/src/assets/cursors/tahoe/pointer-1__14-6.svg b/src/assets/cursors/tahoe/pointer-1__14-6.svg new file mode 100644 index 000000000..2acf2f638 --- /dev/null +++ b/src/assets/cursors/tahoe/pointer-1__14-6.svg @@ -0,0 +1,18 @@ +<svg width="618" height="958" viewBox="0 0 618 958" fill="none" xmlns="http://www.w3.org/2000/svg"> +<g filter="url(#filter0_d_384_27)"> +<path fill-rule="evenodd" clip-rule="evenodd" d="M127.062 37.0331L540.696 451.555C592.653 503.66 555.794 592.574 482.226 592.574L421.831 592.569L481.821 735.054C492.331 760.021 492.479 787.652 482.265 812.767C472.002 837.932 452.561 857.57 427.496 868.08C414.864 873.359 401.64 876.024 388.121 876.024C347.117 876.024 310.358 851.6 294.47 813.804L231.42 663.918L190.368 700.337C137.029 747.508 53 709.663 53 638.413V67.6744C53 28.9903 99.7268 9.64828 127.062 37.0331Z" fill="white"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M102.316 99.652C102.316 93.1882 110.162 89.9316 114.701 94.5204L504.897 485.555C526.164 506.871 511.065 543.236 480.967 543.236L347.546 543.161L436.342 754.143C447.542 780.788 435.009 811.429 408.414 822.581C381.72 833.781 351.128 821.298 339.977 794.702L250.293 581.352L158.517 662.644C137.991 680.801 106.319 668.145 102.664 642.323L102.316 637.331V99.652Z" fill="black"/> +</g> +<defs> +<filter id="filter0_d_384_27" x="0.34" y="0.799219" width="617.32" height="957.144" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> +<feOffset dy="29.26"/> +<feGaussianBlur stdDeviation="26.33"/> +<feComposite in2="hardAlpha" operator="out"/> +<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.65 0"/> +<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_384_27"/> +<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_384_27" result="shape"/> +</filter> +</defs> +</svg> diff --git a/src/assets/cursors/tahoe/pointinghand-1__40-10.svg b/src/assets/cursors/tahoe/pointinghand-1__40-10.svg new file mode 100644 index 000000000..5ea2bd867 --- /dev/null +++ b/src/assets/cursors/tahoe/pointinghand-1__40-10.svg @@ -0,0 +1,18 @@ +<svg width="618" height="767" viewBox="0 0 618 767" fill="none" xmlns="http://www.w3.org/2000/svg"> +<g filter="url(#filter0_d_384_39)"> +<path fill-rule="evenodd" clip-rule="evenodd" d="M345.305 647.032C382.56 647.032 414.957 639.285 442.423 623.756C469.96 608.227 491.228 585.514 506.194 555.513C521.195 525.511 528.695 488.819 528.695 445.331V387.229C528.695 360.22 524.47 339.48 516.018 324.901C507.567 310.358 495.383 303.069 479.573 303.069V341.592C479.573 346.945 477.777 351.241 474.256 354.445C470.699 357.65 466.72 359.234 462.213 359.234C457.494 359.234 453.374 357.65 449.853 354.445C446.296 351.241 444.536 346.945 444.536 341.592V293.738C444.536 284.124 442.071 276.659 437.176 271.412C432.211 266.166 425.274 263.56 416.259 263.56C409.217 263.56 402.245 265.074 395.413 268.067V329.092C395.413 334.655 393.617 339.022 390.096 342.261C386.539 345.466 382.56 347.05 378.053 347.05C373.335 347.05 369.215 345.466 365.693 342.261C362.137 339.022 360.376 334.655 360.376 329.092V258.419C360.376 249.017 357.876 241.552 352.84 236.094C347.805 230.636 341.009 227.889 332.452 227.889C325.163 227.889 318.085 229.509 311.254 232.713V316.873C311.254 321.803 309.599 325.993 306.253 329.409C302.943 332.824 298.718 334.55 293.577 334.55C288.647 334.55 284.527 332.824 281.217 329.409C277.907 325.993 276.217 321.803 276.217 316.873V84.9938C276.217 77.0708 274.033 70.6972 269.632 65.873C265.265 61.0488 259.314 58.6543 251.814 58.6543C244.313 58.6543 238.292 61.0488 233.679 65.873C229.066 70.6972 226.777 77.0708 226.777 84.9938V411.631C226.777 418.498 224.735 424.097 220.685 428.499C216.601 432.865 211.459 435.048 205.262 435.048C199.663 435.048 194.698 433.675 190.331 430.893C185.93 428.111 182.232 422.97 179.239 415.47L136.209 317.19C130.856 304.337 122.793 297.928 112.123 297.928C105.256 297.928 99.6927 300.111 95.3967 304.513C91.1007 308.879 88.9879 314.197 88.9879 320.394C88.9879 325.535 89.9387 330.676 91.8754 335.817L149.379 497.693C168.182 550.16 194.346 588.12 227.728 611.713C261.145 635.271 300.338 647.032 345.305 647.032ZM344.037 684.287C289.175 684.287 242.553 669.603 204.135 640.306C165.682 610.973 136.314 567.379 115.961 509.559L58.458 347.367C56.7326 342.261 55.4297 336.909 54.4438 331.31C53.493 325.746 53 320.711 53 316.239C53 299.513 58.5637 286.589 69.7263 277.364C80.8184 268.173 93.9177 263.56 108.883 263.56C119.835 263.56 129.659 266.729 138.462 273.032C147.231 279.335 154.273 288.843 159.661 301.449L185.014 364.094C185.648 365.573 186.634 366.312 187.902 366.312C189.627 366.312 190.472 365.361 190.472 363.46V87.2123C190.472 67.7394 196.247 52.2807 207.832 40.8012C219.382 29.3569 234.031 23.6172 251.814 23.6172C269.385 23.6172 283.893 29.3569 295.337 40.8012C306.782 52.2807 312.521 67.7394 312.521 87.2123V196.761C320.867 194.401 329.001 193.204 336.959 193.204C350.657 193.204 362.313 196.796 371.961 203.979C381.574 211.163 388.335 220.952 392.209 233.382C402.033 229.967 411.682 228.241 421.119 228.241C434.359 228.241 445.557 231.586 454.642 238.347C463.762 245.073 470.03 254.228 473.446 265.814C502.532 266.025 525.103 276.307 541.055 296.625C557.007 316.978 565 345.572 565 382.369V449.521C565 499.172 555.668 541.533 537.041 576.535C518.413 611.572 492.496 638.228 459.325 656.68C426.119 675.097 387.701 684.287 344.037 684.287Z" fill="black"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M345.305 647.032C382.561 647.032 414.957 639.285 442.423 623.756C469.96 608.227 491.229 585.514 506.194 555.513C521.195 525.511 528.696 488.819 528.696 445.331V387.229C528.696 360.22 524.47 339.48 516.019 324.901C507.568 310.358 495.384 303.069 479.573 303.069V341.592C479.573 346.945 477.777 351.241 474.256 354.445C470.699 357.65 466.72 359.234 462.213 359.234C457.494 359.234 453.375 357.65 449.853 354.445C446.297 351.241 444.536 346.945 444.536 341.592V293.738C444.536 284.124 442.071 276.659 437.176 271.412C432.211 266.166 425.274 263.56 416.26 263.56C409.217 263.56 402.245 265.074 395.414 268.067V329.092C395.414 334.655 393.618 339.022 390.096 342.261C386.54 345.466 382.561 347.05 378.053 347.05C373.335 347.05 369.215 345.466 365.694 342.261C362.137 339.022 360.376 334.655 360.376 329.092V258.419C360.376 249.017 357.876 241.552 352.841 236.094C347.805 230.636 341.009 227.889 332.452 227.889C325.163 227.889 318.085 229.509 311.254 232.713V316.873C311.254 321.803 309.599 325.993 306.254 329.409C302.944 332.824 298.718 334.55 293.577 334.55C288.647 334.55 284.527 332.824 281.217 329.409C277.907 325.993 276.217 321.803 276.217 316.873V84.9938C276.217 77.0708 274.034 70.6972 269.632 65.873C265.266 61.0488 259.315 58.6543 251.814 58.6543C244.314 58.6543 238.292 61.0488 233.679 65.873C229.066 70.6972 226.778 77.0708 226.778 84.9938V411.631C226.778 418.498 224.735 424.097 220.686 428.499C216.601 432.865 211.46 435.048 205.262 435.048C199.663 435.048 194.698 433.675 190.332 430.893C185.93 428.111 182.233 422.97 179.24 415.47L136.209 317.19C130.857 304.337 122.793 297.928 112.123 297.928C105.257 297.928 99.6931 300.111 95.3971 304.513C91.1011 308.879 88.9883 314.197 88.9883 320.394C88.9883 325.535 89.939 330.676 91.8758 335.817L149.379 497.693C168.183 550.16 194.346 588.12 227.728 611.713C261.146 635.27 300.338 647.032 345.305 647.032Z" fill="white"/> +</g> +<defs> +<filter id="filter0_d_384_39" x="0.34" y="0.217188" width="617.32" height="765.99" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> +<feOffset dy="29.26"/> +<feGaussianBlur stdDeviation="26.33"/> +<feComposite in2="hardAlpha" operator="out"/> +<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.65 0"/> +<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_384_39"/> +<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_384_39" result="shape"/> +</filter> +</defs> +</svg> diff --git a/src/assets/cursors/tahoe/proof-1__23-0.svg b/src/assets/cursors/tahoe/proof-1__23-0.svg new file mode 100644 index 000000000..86ac7db9c --- /dev/null +++ b/src/assets/cursors/tahoe/proof-1__23-0.svg @@ -0,0 +1,40 @@ +<?xml version="1.0" encoding="UTF-8" standalone="yes"?> +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="768" height="768" viewBox="0 0 768 768"> + <defs> + <filter id="filter-remove-color" x="0%" y="0%" width="100%" height="100%"> + <feColorMatrix color-interpolation-filters="sRGB" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"/> + </filter> + <clipPath id="clip-0"> + <path clip-rule="nonzero" d="M 0.0117188 0 L 768 0 L 768 768 L 0.0117188 768 Z M 0.0117188 0 "/> + </clipPath> + <clipPath id="clip-1"> + <path clip-rule="nonzero" d="M 0.0117188 0 L 768 0 L 768 768 L 0.0117188 768 Z M 0.0117188 0 "/> + </clipPath> + <clipPath id="clip-2"> + <rect x="0" y="0" width="768" height="768"/> + </clipPath> + <clipPath id="clip-3"> + <rect x="0" y="0" width="768" height="768"/> + </clipPath> + <image id="source-35" x="0" y="0" width="768" height="768" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAwAAAAMACAYAAACTgQCOAAAABmJLR0QA/wD/AP+gvaeTAAAPfUlEQVR4nO3XAQ3AMADDsH78KU86j8VGkXzb7rYzAADgecIfAABCDAAAAIQYAAAACDEAAAAQYgAAACDEAAAAQIgBAACAEAMAAAAhBgAAAEIMAAAAhBgAAAAIMQAAABBiAAAAIMQAAABAiAEAAIAQAwAAACEGAAAAQgwAAACEGAAAAAgxAAAAEGIAAAAgxAAAAECIAQAAgBADAAAAIQYAAABCDAAAAIQYAAAACDEAAAAQYgAAACDEAAAAQIgBAACAEAMAAAAhBgAAAEIMAAAAhBgAAAAIMQAAABBiAAAAIMQAAABAiAEAAIAQAwAAACEGAAAAQgwAAACEGAAAAAgxAAAAEGIAAAAgxAAAAECIAQAAgBADAAAAIQYAAABCDAAAAIQYAAAACDEAAAAQYgAAACDEAAAAQIgBAACAEAMAAAAhBgAAAEIMAAAAhBgAAAAIMQAAABBiAAAAIMQAAABAiAEAAIAQAwAAACEGAAAAQgwAAACEGAAAAAgxAAAAEGIAAAAgxAAAAECIAQAAgBADAAAAIQYAAABCDAAAAIQYAAAACDEAAAAQYgAAACDEAAAAQIgBAACAEAMAAAAhBgAAAEIMAAAAhBgAAAAIMQAAABBiAAAAIMQAAABAiAEAAIAQAwAAACEGAAAAQgwAAACEGAAAAAgxAAAAEGIAAAAgxAAAAECIAQAAgBADAAAAIQYAAABCDAAAAIQYAAAACDEAAAAQYgAAACDEAAAAQIgBAACAEAMAAAAhBgAAAEIMAAAAhBgAAAAIMQAAABBiAAAAIMQAAABAiAEAAIAQAwAAACEGAAAAQgwAAACEGAAAAAgxAAAAEGIAAAAgxAAAAECIAQAAgBADAAAAIQYAAABCDAAAAIQYAAAACDEAAAAQYgAAACDEAAAAQIgBAACAEAMAAAAhBgAAAEIMAAAAhBgAAAAIMQAAABBiAAAAIMQAAABAiAEAAIAQAwAAACEGAAAAQgwAAACEGAAAAAgxAAAAEGIAAAAgxAAAAECIAQAAgBADAAAAIQYAAABCDAAAAIQYAAAACDEAAAAQYgAAACDEAAAAQIgBAACAEAMAAAAhBgAAAEIMAAAAhBgAAAAIMQAAABBiAAAAIMQAAABAiAEAAIAQAwAAACEGAAAAQgwAAACEGAAAAAgxAAAAEGIAAAAgxAAAAECIAQAAgBADAAAAIQYAAABCDAAAAIQYAAAACDEAAAAQYgAAACDEAAAAQIgBAACAEAMAAAAhBgAAAEIMAAAAhBgAAAAIMQAAABBiAAAAIMQAAABAiAEAAIAQAwAAACEGAAAAQgwAAACEGAAAAAgxAAAAEGIAAAAgxAAAAECIAQAAgBADAAAAIQYAAABCDAAAAIQYAAAACDEAAAAQYgAAACDEAAAAQIgBAACAEAMAAAAhBgAAAEIMAAAAhBgAAAAIMQAAABBiAAAAIMQAAABAiAEAAIAQAwAAACEGAAAAQgwAAACEGAAAAAgxAAAAEGIAAAAgxAAAAECIAQAAgBADAAAAIQYAAABCDAAAAIQYAAAACDEAAAAQYgAAACDEAAAAQIgBAACAEAMAAAAhBgAAAEIMAAAAhBgAAAAIMQAAABBiAAAAIMQAAABAiAEAAIAQAwAAACEGAAAAQgwAAACEGAAAAAgxAAAAEGIAAAAgxAAAAECIAQAAgBADAAAAIQYAAABCDAAAAIQYAAAACDEAAAAQYgAAACDEAAAAQIgBAACAEAMAAAAhBgAAAEIMAAAAhBgAAAAIMQAAABBiAAAAIMQAAABAiAEAAIAQAwAAACEGAAAAQgwAAACEGAAAAAgxAAAAEGIAAAAgxAAAAECIAQAAgBADAAAAIQYAAABCDAAAAIQYAAAACDEAAAAQYgAAACDEAAAAQIgBAACAEAMAAAAhBgAAAEIMAAAAhBgAAAAIMQAAABBiAAAAIMQAAABAiAEAAIAQAwAAACEGAAAAQgwAAACEGAAAAAgxAAAAEGIAAAAgxAAAAECIAQAAgBADAAAAIQYAAABCDAAAAIQYAAAACDEAAAAQYgAAACDEAAAAQIgBAACAEAMAAAAhBgAAAEIMAAAAhBgAAAAIMQAAABBiAAAAIMQAAABAiAEAAIAQAwAAACEGAAAAQgwAAACEGAAAAAgxAAAAEGIAAAAgxAAAAECIAQAAgBADAAAAIQYAAABCDAAAAIQYAAAACDEAAAAQYgAAACDEAAAAQIgBAACAEAMAAAAhBgAAAEIMAAAAhBgAAAAIMQAAABBiAAAAIMQAAABAiAEAAIAQAwAAACEGAAAAQgwAAACEGAAAAAgxAAAAEGIAAAAgxAAAAECIAQAAgBADAAAAIQYAAABCDAAAAIQYAAAACDEAAAAQYgAAACDEAAAAQIgBAACAEAMAAAAhBgAAAEIMAAAAhBgAAAAIMQAAABBiAAAAIMQAAABAiAEAAIAQAwAAACEGAAAAQgwAAACEGAAAAAgxAAAAEGIAAAAgxAAAAECIAQAAgBADAAAAIQYAAABCDAAAAIQYAAAACDEAAAAQYgAAACDEAAAAQIgBAACAEAMAAAAhBgAAAEIMAAAAhBgAAAAIMQAAABBiAAAAIMQAAABAiAEAAIAQAwAAACEGAAAAQgwAAACEGAAAAAgxAAAAEGIAAAAgxAAAAECIAQAAgBADAAAAIQYAAABCDAAAAIQYAAAACDEAAAAQYgAAACDEAAAAQIgBAACAEAMAAAAhBgAAAEIMAAAAhBgAAAAIMQAAABBiAAAAIMQAAABAiAEAAIAQAwAAACEGAAAAQgwAAACEGAAAAAgxAAAAEGIAAAAgxAAAAECIAQAAgBADAAAAIQYAAABCDAAAAIQYAAAACDEAAAAQYgAAACDEAAAAQIgBAACAEAMAAAAhBgAAAEIMAAAAhBgAAAAIMQAAABBiAAAAIMQAAABAiAEAAIAQAwAAACEGAAAAQgwAAACEGAAAAAgxAAAAEGIAAAAgxAAAAECIAQAAgBADAAAAIQYAAABCDAAAAIQYAAAACDEAAAAQYgAAACDEAAAAQIgBAACAEAMAAAAhBgAAAEIMAAAAhBgAAAAIMQAAABBiAAAAIMQAAABAiAEAAIAQAwAAACEGAAAAQgwAAACEGAAAAAgxAAAAEGIAAAAgxAAAAECIAQAAgBADAAAAIQYAAABCDAAAAIQYAAAACDEAAAAQYgAAACDEAAAAQIgBAACAEAMAAAAhBgAAAEIMAAAAhBgAAAAIMQAAABBiAAAAIMQAAABAiAEAAIAQAwAAACEGAAAAQgwAAACEGAAAAAgxAAAAEGIAAAAgxAAAAECIAQAAgBADAAAAIQYAAABCDAAAAIQYAAAACDEAAAAQYgAAACDEAAAAQIgBAACAEAMAAAAhBgAAAEIMAAAAhBgAAAAIMQAAABBiAAAAIMQAAABAiAEAAIAQAwAAACEGAAAAQgwAAACEGAAAAAgxAAAAEGIAAAAgxAAAAECIAQAAgBADAAAAIQYAAABCDAAAAIQYAAAACDEAAAAQYgAAACDEAAAAQIgBAACAEAMAAAAhBgAAAEIMAAAAhBgAAAAIMQAAABBiAAAAIMQAAABAiAEAAIAQAwAAACEGAAAAQgwAAACEGAAAAAgxAAAAEGIAAAAgxAAAAECIAQAAgBADAAAAIQYAAABCDAAAAIQYAAAACDEAAAAQYgAAACDEAAAAQIgBAACAEAMAAAAhBgAAAEIMAAAAhBgAAAAIMQAAABBiAAAAIMQAAABAiAEAAIAQAwAAACEGAAAAQgwAAACEGAAAAAgxAAAAEGIAAAAgxAAAAECIAQAAgBADAAAAIQYAAABCDAAAAIQYAAAACDEAAAAQYgAAACDEAAAAQIgBAACAEAMAAAAhBgAAAEIMAAAAhBgAAAAIMQAAABBiAAAAIMQAAABAiAEAAIAQAwAAACEGAAAAQgwAAACEGAAAAAgxAAAAEGIAAAAgxAAAAECIAQAAgBADAAAAIQYAAABCDAAAAIQYAAAACDEAAAAQYgAAACDEAAAAQIgBAACAEAMAAAAhBgAAAEIMAAAAhBgAAAAIMQAAABBiAAAAIMQAAABAiAEAAIAQAwAAACEGAAAAQgwAAACEGAAAAAgxAAAAEGIAAAAgxAAAAECIAQAAgBADAAAAIQYAAABCDAAAAIQYAAAACDEAAAAQYgAAACDEAAAAQIgBAACAEAMAAAAhBgAAAEIMAAAAhBgAAAAIMQAAABBiAAAAIMQAAABAiAEAAIAQAwAAACEGAAAAQgwAAACEGAAAAAgxAAAAEGIAAAAgxAAAAECIAQAAgBADAAAAIQYAAABCDAAAAIQYAAAACDEAAAAQYgAAACDEAAAAQIgBAACAEAMAAAAhBgAAAEIMAAAAhBgAAAAIMQAAABBiAAAAIMQAAABAiAEAAIAQAwAAACEGAAAAQgwAAACEGAAAAAgxAAAAEGIAAAAgxAAAAECIAQAAgBADAAAAIQYAAABCDAAAAIQYAAAACDEAAAAQYgAAACDEAAAAQIgBAACAEAMAAAAhBgAAAEIMAAAAhBgAAAAIMQAAABBiAAAAIMQAAABAiAEAAIAQAwAAACEGAAAAQgwAAACEGAAAAAgxAAAAEGIAAAAgxAAAAECIAQAAgBADAAAAIQYAAABCDAAAAIQYAAAACDEAAAAQYgAAACDEAAAAQIgBAACAEAMAAAAhBgAAAEIMAAAAhBgAAAAIMQAAABBiAAAAIMQAAABAiAEAAIAQAwAAACEGAAAAQgwAAACEGAAAAAgxAAAAEGIAAAAgxAAAAECIAQAAgBADAAAAIQYAAABCDAAAAIQYAAAACDEAAAAQYgAAACDEAAAAQIgBAACAEAMAAAAhBgAAAEIMAAAAhBgAAAAIMQAAABBiAAAAIMQAAABAiAEAAIAQAwAAACEGAAAAQgwAAACEGAAAAAgxAAAAEGIAAAAgxAAAAECIAQAAgBADAAAAIQYAAABCDAAAAIQYAAAACDEAAAAQYgAAACDEAAAAQIgBAACAEAMAAAAhBgAAAEIMAAAAhBgAAAAIMQAAABBiAAAAIMQAAABAiAEAAIAQAwAAACEGAAAAQgwAAACEGAAAAAgxAAAAEGIAAAAgxAAAAECIAQAAgBADAAAAIQYAAABCDAAAAIQYAAAACDEAAAAQYgAAACDEAAAAQIgBAACAEAMAAAAhBgAAAEIMAAAAhBgAAAAIMQAAABBiAAAAIMQAAABAiAEAAIAQAwAAACEGAAAAQgwAAACEGAAAAAgxAAAAEGIAAAAgxAAAAECIAQAAgJAfbSgG/5krOwoAAAAASUVORK5CYII="/> + <image id="source-28" x="0" y="0" width="768" height="768" xlink:href="data:image/png;base64,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bdu2adWqVX15TPTHI488oquvvlo33nijDh482JfHnJqa0sknn6xzzz2X4Dca2pKefPTRRz9Nywv/VBAIfUswowAYKQQAjIpeAkBu73/3NnfKT3fblHu7a9eu7ZL+V95LQ12+8pWv6Morr9SPfvSjgTz++vXrddFFF2nr1q0DeXyU861vfUuXX3659u3bN5DHP/zww3XBBRfopJNOGsjjo3+stf/1mGOOuUH+k4FDvf+MAmBsEAAwKnIDwDB7/6d869baO6anp++21j6p9KvEUDz66KP62Mc+pltvvXXgz2WM0bZt27R9+3bOD6jJwsKCrrzySt1www2ydvB11E//9E/rF3/xF7ViBaf/NNiOLVu2nGCM+TH5C3/fSICv939YowAEAPQVn0YYFbG/1Zze/+J6qvc/55Kfxd7/JVOAdu/e/Vxr7YdLvDYM0cMPP6xLL71UO3bsGOrz/sRP/IRe9apXURQO2cGDB/W3f/u3+sY3vjHU533KU56i173udUwJajBjzC9s2bLle1re+9+9TV0WNPalYL4vCVNhmVEA1IovAsMoKHsScM62sqMDcrYHv0reWvsbmceLIXv44Yf1nve8Z+jFvyR985vf1GWXXda3eedI279/v9773vcOvfiXpHvvvVfvfe97g1cUQv2stb+p8Chw9/3dFfp8yL2NoVMWQ0MAwLhyC/diu2/dt3/sA8GdLmQktaanp9dIem6vB4/+O3DggC699FLt2rWrtmO44447dOmllxIChmD//v36wAc+oLvvvru2Y/jBD36gD33oQ307uRx9d8r09LRvmmfxs8A3+hs7ryz0GeMKjVzHEBDQNwQAjLqc3pVYL7/b7uvVl3M/d5/HPhwWFhZeV/WFYLCuuOKKWnr+XXfeeacuvfTS4HcLoHfdsFdn8d9111136aqrrqr7MBBgrX2t4u/rsY4ho+UdQ+4+bnvxFqgNAQDjKKfHpbhepvff98ZvJJl2u323MebCno8efXfbbbfpy1/+ct2H8Zg777yT6UADsn//fr3vfe/Td7/73boP5THXXXed7rjjjroPAx7W2lfef//9d2npe7rb0+8bAQh9Ziiwzcc3CkA4wFAQANB0Vd8Mc+b4h9pTb/C+ENDav3//KyVxxl/DHDhwQFdccUXdh7EMIwH916Se/yJrrT71qU8R+Jpp3dTU1IXyF/9uD39RzrkAgxgFICCgL7gcBUaZ74207Jtj6o272PMjLf9QKP68dhiXGEQ5n//85zU/P1/3YXh1Q8BrX/targ7Uo/379+uDH/xg44r/rj179uj666/XaaedVvehwGGtfZ0OfWu7+95vtXQUoKizuM06t1K5q/V071tcLrYBA8EIAMZRqKCPtcV6/rv7hHqGWjt37pySdGL/XgL6Yf/+/brxxhvrPoyou+66S5dddhkjAT04cOCALrvsssYW/13XXXcd/87N9MKdO3c+ov6OAuR+DgG1IACgyWJvjlXeOMsEA/fKD+62JW/6xhhO/m2gr371q3rkkUfqPowkzgmo7tFHH9X73//+Rs35D3nwwQf19a9/ve7DgF/3ZGBf4e87F6DMFYF6+bwquw3IQgDAqHOn/4Tm7UvhN2jfsvsYwd7/6enpu6y1F/XwGjAg/397bx5tx1Xf+X73OedKMjImtmwjmxij4c6SjcEQDGYw89SExLY8ySaQvPSjed0kIenwekqapMN7aR69Op3XvQI0Zkyw6QSSTj8SmsZ0G2MCOMaWB1lXkmXLCCMjBttI6A613x+6Jdfddw+/XcOpqlPfz1q1qmrvXXX2KV3t/f0Nu84dd9xRdxfEMBIQT1Nz/n206W+yY7xtbm7uXjh+3wXhKEDo2CyzXQ9LPSGVQAOANJWig6BtkDX3rhQg853QsNSdLEuS5GoApxXsLymZI0eO4ODBg3V3IwpGAuS0yfOfZf/+/XjiiSfq7gZZzYanP/3pb4U8CmAzEmKiAGXPcYREQQOAtBHbgBryzNjKbIO07X5O7z8ApbXeGdN5Mhz27dtXdxdywUhAmDZ6/lO01q3sdxfQWl8HyxiPlWN/istzH4oqu4S7616EVAINADJK+MS8L73HtZne/ux5D4A6fPjwdwBcWlL/SYkcOHCg7i7khpEAN/Pz8/jwhz/cOs9/lib8IB2x8rqDBw8+iJVi3zcX+KIAWWwRAtuekKFBA4A0kbIGQ18akC86YA7UzsE+SZIdAPol9ZeUyOHDh+vuQiEYCVhN6vlva3Qn5Xvf+17dXSB21gwGg7cgPBfA0QaWOkIaCQ0A0jZc6T++MKvL85J3y4aHdxT9QqQajhw5UncXCsNIwFOMguc/5Qc/+EHdXSBudmB1ClDI4++LBPjKsvhSilzQwCC5oQFARo1QvqXE828T+6vKH3nkke9qrS8ptfekNI4ePVp3F0qBkYDR8fynjMrf5ojy6kceeWQ/wm8DkqT/hJxTtnNChgINANI0JN4OyYDp8vhnj2O8Nas8Qb1eL/UUkYaRJMlIec27HAkYJc9/yvz8fN1dIG7GBoPBL8Ae8Q2l/4TmFVj2PhgFIJVB8ULajm9gDRkB2TKXZ8dZppRi+k9D6fV66PdHa2lGFyMBo+b5Txm1v81RY3ltV+yc4PthMDjKfHMUIZVCA4B0AVfI1TXQhqIC6tChQ98D8MKS+0lKZGxsrO4ulE6XIgGj6PlPWbt2bd1dIB6UUq989NFHH4IsUmy9RWBPSO3QACBtwedNMdtJPf62zXzPszXfUyl1tePzSUP4mZ/5mbq7UAldiASMquc/5fTTT6+7C8RPP0mSX4R7jsiO/ZIFwsDqOcg8zuKLGhBSCoO6O0BIhjwDnGtwtJ37DIZsvRnOXfVGCK31z+foKxkiZ511Fh599NG6u1EJaSTgHe94BwaD0RrG5+fnR1r8A8CZZ54JrXXd3SB+fh7A3+OpsV9n9tmy9Dy7mf+46dyijTLbue36EHmuIR2HEQAyaki8LVJvje2899BDD+0B038az8/+7M/W3YVKGcVIwMLCAm688caRFv8AcN5559XdBRLmFYcOHboH9tz+PBuMY2TKCBk6NABIG3Cl/7gGUl/ItOggrtasWfMaMHrWeLZs2VJ3Fypnz549uPHGG0diTcD8/Dw+8pGPYG5uru6uVE4X/jZHgHUAXoYCc8XyfUKRZ1sbc35jGhApHRoApCkUGdhC17rSf1ztXKk/CoBKkuQN+btKhsWzn/1sPP3pT6+7G5UzCpGArnj+gRP5/xs3bqy7G0TGGyCYExA3v4Ta5IXGAYmCBgAZJYqk//Qs7bL36gFQp5xyynd7vd5rq/oCpDx6vR4uvPDCursxFNocCeiS5x8ALrroIihFrdYSXo+Voh8Ip4nGpAHRs09qgwYAaTpF039cg61vYLZ5ewCgNzc3d4bW+ty8X4YMlxe/+MWdEVttjAR0yfMPnDBKX/hCLh9qERPf+973DiMzB8AdAZCkAfnEP9OAyFChAUDajmswdBkM0nxM87gHQPV6Pab/tIizzz4b09PTdXdjaLQpEtA1zz8AXHjhhTjzzDPr7gaJIEmS1y8fuhYDZ49d8wscdTYo8MlQoAFAmkAZA54rUpA9tnn4TU9OyLPzepBW8cY3vrFTv7zahkhA1zz/wIlf/33ta5k92DaWDQDfvJD9XQDJHALLcXZfBBoPRAwNANJkJKI+tPd5/F1l1oH7yJEj9wK4JKL/pAFs3LgRl1zSrX+2JkcCuuj5B4DLLrsMZ511Vt3dIJEopS578MEH70RY0Js/IimZfyTpQEwDIpVAA4C0kdDgFwrTmu1E3pvjx4+/EsBY0c6T4fPmN78Z55xzTt3dGCpNjAR00fMPnPhNite85jV1d4PkY/3Y2NhLII8AuOYZ8xiQzWWEVAINAFI3VaX/mPWh1B+bV8Zs89IS+kpqYDAY4LrrrsPatWvr7spQaVIkoKue/6c97Wm4/vrrO5WGNmr0+/1LsXoOAfxRY98cA8cx04DI0KABQJpKKBTqahdK/3F9ljlo214L+hLh/UgD2bhxI97+9rdjMOjWb7g1IRLQVc//2NgY3v72t2PDhg11d4UUQGttRgAA/6JgZMp8oCBCAAAgAElEQVRsSNKAXPdhGhApBRoApG1IvP3ZY29ev6U+e4+TxsDBgwfvBHBR0c6Tetm6dSuuvfbaznlj64wELCwsdNLzPxgMcP3112PTpk11d4UU5xKt9TfgdhC50oBcQl+SBkShTyqFBgBpO6FIgU/4+wbv7Huf0e/3XwBgTfndJ8PmggsuwHXXXddJI2DYkYCFhQV89KMf7Zznv9/v44YbbsDMzEzdXSHlcOqhQ4e2Lx/3jL3EyTTsNCBCgtAAIHUSM9BVkf5jemfMkG52gGf6zwjRZSPgxhtvHIoRkIr/rnn++/0+3va2t1H8jx5pGhCw+ofBXAI/xVWWNw2IkMJ0a/YjTSMm9OkLpfq8+ulxzzj21Suc+L+R7nvvec97rtVaT+T/qqRpPPOZz8TZZ5+Ne+65B1rrurszNI4cOYKDBw9i+/bt6PWq8QGl4n/v3r2V3L+p0PM/0jz+wQ9+8JvLx4mx15m9bTDRRhvb3obLIIhJhSXECiMApM1IcivzrANYEeJdt27dd7TW3XqRfEdIIwFVCeGmsmfPHnzsYx+rJBJA8U/xP6K8FPYIACCbY6RrAmA5J6R0ujXrkVHCN2CGwq1mmTf958EHH3wagDMK9ZY0FhoB5RkBFP8U/yPMsw4dOvQw/GlAgH+uyZ7DUhaaxwgpjW7NeKQNSNN/zPau632i33XvFQO71vrFop6T1kIjoLgRQPFP8T/qZF4HaosAZB1J2TLpnBU6p4FASqVbsx1pEmUPXqEFVdJwrG1gv7jkvpIGQiMgvxFA8U/x3wWUUs+He86InW9CUQNxtyLLCQFAA4C0F+kiKNvgC9gHX6tXR2t9QRkdJs2HRkC8EUDxT/HfFZbngjyC3ud0MgnVE1IK3ZrlSBtx5fO79qFB0zcwr7rXunXrDgKYjekwaTc0AuRGAMU/xX/HsBkAIeEfIhS9JqQSujXDkbYQyusPXRsKxaZ1rsVbJ7c9e/b0AZwW/xVIm6EREDYCKP4p/jvIMx999NHdCKf5hH4ZOFbgcx0AKZ1uzW6k6cQOZtJwq89L4x3IB4MB0386Co0AtxFA8U/x31WWlpZ8UYDQ3GKWw1KeBxoCJJpuzWykKRQdrIpcH2UIMP+/29AIWG0EUPxT/HcZrfV2uOcNWI6zhM7Ncgp7UhndmtVI2ygz/98VJcge2wyA7fHdJqMEjYCnjACKf4r/rqOUSg0AM83H3Md49l0RgezebE9IIbo1o5E24PKQSAY8SUgWnrJVdcuDPek4NAIWKP4p/skJ0hSglNCvAmePQxEDH4wKkFLhHxKpA4lnPz12CfR06xnHPePYtvUz+37mfJApHxw6dOhbWusnQEOZLLNr1y586lOfQpIkdXdlqExMTCBJks6J/8FggBtuuAHT09N1d4U0h5+ee+65pyqlXgxgybIlluMksOnM3jxONxj79Ngsh6WckFVQ2JC24fKaxEQIfNdny2fA/yMkw/bt27Fz585ORgK6Jv77/T6uv/56in9isu7QoUNbIffqS9J/fOV01JJK6NYsRtqOdGGV7bo8+f9bC/WWjCRdNQK6BNN+iI8kSbZAlnJa5ToAQgoxqLsDpHNIB7IYD4qtvTT/33ldkiTPUYrjLlnNtm3bsHPnzk6mA406Wc+/1sygIKvp9XqbANy7fCqZU8y67KYz+xDZ9hBeQ4gVurBIk4hV2zavSqxHJj3vmfdQSj0nsj+kQ6RGACMBo0Mq/un5Jz6SJHkOnlprlhJrBNjqzDIp9FSRaDhzkTYRigj4BsHY/H+ltd6Up5OkO9AIGB0o/omUZeeQL+IM2MU9jHrbsa0NBT4pHc5apAlIU3xs59LcyuxxMP8fODnIE+KFRkD7ofgnkaTOoWGsA4Dj3HU9ISI4Y5FhUsbgJPGUSAdj53Vr1659CMCzS+gv6QA0AtoLxT/JwSb4I8nm3uaEskUNbFDUk0rgbEWaSCh06mrjWg/gOrZdpwCoubm5YwDWRfecdBYaAe2D4p/k5Ix9+/Z9a/nYl8cfShOCo72PImsFCDkJZyrSFIoOZK6B11Vvtl1R3u/3mf9PoqER0B4o/kkR1qxZcz7iU4BglIfqY6AxQKLgLEXaQigiILnWdh+r4bD8lgdCoqER0Hwo/klRll8F6hPyLmEfS5G5jxAnnKHIsAjl7sdcKw2BxuT/r7hGKcX8f5IbGgHNheKflEGSJNkIQIovHdV2bMOXAptnviTECmcn0iZCRkSREKzZ7pnldJl0FRoBzYPin5SFUups+NNJJWvQfGlAKz6uzL4TAtAAIM0jNlzq85S4PDOSvMsNgs8mxAuNgOZA8U9KJp0jYuYan9CPSfUpmlZECA0A0khiQqTZ9uaA66r3lafHNABIKdAIqB+Kf1IBGxAXbXaVu1JZQymu5v0IiYIzEmkC0kGs6AIqW+6/Kwx7Zo7PIsQKjYD6oPgnFXEmwpFln6ffRlFBT4OAiOFsRIZBmYOay5sv/dzQwMwUIFIJNAKGD8U/qRBJChACbVxIIgiEFGJQdwdIp4nNdZS28+VemufWtlprRgBI6czOzuK6667Dpz/9aSRJUnd3RppU/E9PT0NrXXd3yOiRRgCkUYA8RoDtD9dVTkgUdEWRtiB5O4Ivzcc8dl77rW9968sAnl6ks4S42LZtG6677jpGAiokK/4JqQhflNi3Ls1lNIS8/DHef0YKSBDOQKRJSLwjoVCr79psvWvgVRs2bNgADqCkQrZt24YbbrgBgwGDsGVD8U+GxNrDhw/ftnwcEvXI1MFyjVkfGx3nfEWioQFAmkbegS804LrWDKwq6/f7TP8hlTM9PY2dO3fSCCgRin8yTH7yk5+40oBMfB5+19zlu5/ZhpBoaACQuqlycPOlAbkGbQXg9AKfSYgYGgHlQfFPhs3atWvPQDjF1LUo2AUFPRkKNABI1ZQ1mOVJ84m9f3rNKZHXEpIbGgHFofgndaC1Xrd8GBL/ZrmtLoQ0PYgGBBFBA4A0mbz5kdIFV9ZIQL/fX5u3w4TkgUZAfij+SV1ordcivMjX3MdEAvKucyMkCA0A0makaT22cydJkqwpq4OESKEREA/FP6kTy1zhW59mEpsaREip0AAgdREz0OUNldqudQ26J8+11jQASC3QCJBD8U8aQDZaLF0D4IoQSAwBGgikNGgAkKYQ6wWJybH0Dcy2KAJTgEht0AgIQ/FPmkCv18umAPkEvnkMT7k01TV0fxoLxAsNANIkJLn9rlCqOMXHc50CoHq9HiMApFZoBLih+CdNYTla7EvvcZXHiH7J3EaxT6KhAUDajPRVar61AqtgChBpAjQCVkPxT5rE8iJgwB9VztZnKfMNQYREQwOAVEnRQayMQVAaRs2uAWAKEGkENAKeguKfNA0jWixJ8ckTqQ7dk5Bc0AAgbaDIImCzzPdmBqYAkcZBI4DinzSTzGtAU2Jz8inkSW3QACB1kmfwC3lQfN4W38IsvgWINJbp6WlceeWVdXejNq655hqKf9JEsilA0jf6xEQBuLCXVEZ3XUqk6UgHPulbf8RvYNBaJ6IeEjIkFhYW8I1vfKPubtTG3/3d32FychJjY2N1d4WQLIuQOaRCL7DwoQDozN4sJyQXjACQOojxYkTn8Auu9RoXSqnjgs8kZCgsLCzgYx/7GPbt21d3V2pjbm4On/jEJ7CwsFB3Vwg5iVJq3ixCPoMgZCgQUjo0AEhbkYp7sy4YJUiSxBzUCakFiv+noBFAGojPWRR6CxCMujxR79A9CXFCA4C0mdjXgIruwwgAaQIU/6uhEUCaRMZZZPtNGViOCWkMNABIE4gNe8Z4RmLbKktYl5ChQvHvhkYAaQrLzqLYuUtiJEgiB4QUggYAaRpSr362TLqQyryHdTBmChCpE4r/MDQCSBPIRItt6aW2V08HbylsR0hhaACQUSQq5cdSxxQgUgsU/3JoBJC6WVpamke808pVX+Q+se0IoQFAWkOe9yGH3sdsv4gpQKQGKP7joRFA6iRivZj0tdTSe+SpJ2QFNABIVRR5g8EwWTUYcxEwGTYU//mhEUDqwuEsKmuOo9efVAoNADJqSBYCe42TpaUlGgBkaFD8F4dGAKkDi7OIv9xLWgMNAFIXVQ+MIUPAOVD3er3HK+kRIQYU/+VBI4AMm8XFxR8vH0rTemLeVEdIpdAAIE2ljBQiqRGwol2SJEciPoOQXFD8lw+NADJM1q1b55orQuvPynjbD9cEkELQACBNZ5j5lAqAWr9+/fdL+kxCrFD8VweNADIk9MaNG3+I8Ismir7Tn2lFpBJoAJBRopSB8ayzzno1AL4JiFQCxX/10AggQ+BHSql/UHcnCMnLoO4OEDIEog0DrfURAOdU0BfSYRYWFvDxj3+c4n8IpEbA9ddfj7Gxsbq7Q0aPbKS46nQeQkqHEQDSJvIussoD1wGQUqH4Hz5zc3P45Cc/yUgAKR2lFOcI0mpoAJAmUqU3RfSDLEoprgMgpUHxXx80AkgVaK19c0TMSyyKOLYYOSC5oQFARpUiC6/UcgoQIYWh+K8fGgGkAo6Ar/UkLYYGACErSQdvRgBIYSj+mwONAFImmShxrODPOqdoLJDaoAFA2kxlgycjAKQoFP/Ng0YAKZEfCNtR5JNGQgOANJmi70+23UvKoRI+k3QUiv/mQiOAlIHW+jt194GQItAAIMTOgbo7QNoJxX/zoRFAiqKUerDuPhBSBBoAhFjg4E7yQPHfHmgEkCIsLCwcqLsPhBSBBgBpMtrYl3EvEWNjYwdK+lzSESj+2weNAJKThc2bNzMFiLQaGgCkzVQm0M8999w3AXisqvuT0YLiv73QCCA5eFgp9Yt1d4KQItAAIGQlWaPiQF2dIO2B4r/90AggkRzIHIccUYwkk0ZCA4CMKmUMulwHQLxQ/I8ONAJIBA/ixByTZ57JprbSOCC1QQOANJGig6J2HNva2erT8gMF+0FGmIWFBXzsYx+j+B8haAQQCUqpA57qYQp7GhAkNzQASJuQDHalhWMDgzzpMKn4379/f91dISVDI4CESJIkb3TY5XDKcx0hhRjU3QFChkCuwXNpaelBpfgjjmQladoPxf/okhoBO3fuxNjYWN3dIc3jAIBz6+4EIUVgBICMEqV6SZIkub/M+5H2Mz8/30nPf7/fR6/XrekiNQIWFxfr7gppGEopc24oM22VkKHQrRGdtJGyBkZpmPVkuy1btrwYwA9L+nzSchYWFvCJT3wCDz7YrbXh/X4f1157LXbu3InBoFtB47179zIdiKxAKXXw/PPPf12mSDpH5Z3LzOtoLJBSoAFAmoprkKtigbDzs5RSuwp+HhkBupr2k4r/6elpTE1N4dprr+2cETA3N4dPfepTNAIIAEBrfTfC81PoRRRD/3FLQkxoAJC6qHrwsr2JwTcoW/uzPNiTDkPxP32yjEYAjYCuo5RyzQmS+Ub6hroyoIFAvNAAIKOGxNsiji4wAtBtKP6nV9XRCKAR0GWSJLHNCUUi1hTqpBZoAJCqqCqFp2xckQINQCdJwghAR6H4Xy3+U2gE0AjoKv1+fxcyc4RRzR/3Iq2BBgBpC3kWQhUejE855ZRdAJIi9yDtg+LfLf5TaATQCOgg80888cSeHNeZqT95IgNtcaqRlkADgIwiMQu0bG1O1m/cuPHnceJn30lHoPgPi/8UGgE0AjrG/bOzs1dkzkNivrQfphRAQ4BEQQOAtAmfsM8z0EqvYxpQR6D4l4v/FBoBNAI6hO8NQCnmvCJxPNmuM+sk0AggYmgAkKYgHSTNNrGvW3N9jiunE0qpuwR9Ii2H4j9e/KfQCKAR0BFsziDfegAboXlqmG8KIh2GBgBpAmX9QIqrrkh0QC8tLd0e2zHSLij+84v/FBoBNAI6wNfgcRY5ylz1RVKHCCkMDQDSNbIRANHCrKWlpa8DWKq4X6QmKP6Li/+UqakpXHXVVej1ujW10AjoBMe11n/vqPPNJ3nXAdAIIJXSrVGaNIWYgU0yOLoGW2n6j+3eJ+85Pj7+FgD3BHtKWgfFf3niP2V2dhbXXHMNjQAyanxz06ZN6QJgadqPOef45qC8bwfK24Z0nG6N0KRN5MmnjPbuC9tqALc5e0paCcV/+eI/hUYAjYBRQ2t9G2Tv/Zd69KWOK9/1hORG1d0BMtL4/r6UZW+W9Sz7tF0vc94ztr5lnx4PMvu+sQ1cxwcOHNBa60/HfHnSXBYWFvCJT3yis+J/ampqKJ9377334jOf+QySpFs/pbF161bs3LkTY2NjdXeFlITW+i2bN28+DcAiTqSELjmOF3Hit2Oyx9mypcxxdp/dtHGcnsOytzm+tNGGkFV0yz1D2koZi4RtIdhQ3ubJ4yRJGAEYESj+hyP+gRORgKuvvrpzkYC9e/cyEjBa6OW1YC6xLfHul/F2H3r9SWl0a1Qmw6boYFWm8A+194ZdN2/e/DKl1CM5+0MawuLiYmfTfq677rqhiv+ULhsBf/qnf9q56MeIsnt8fPz1y8fSdFIXNARII+jWiExGjTyvAZUsBLYO5Frrr8m7RppGkiS46aab8OCDD9bdlaGSiv/Jycna+jA7O9vJtwPt2bMHf/7nfw6tqdfajFLKFgEOCf6Q55+vASW10q3RmDSdIm83kHhfQmlAtvNsOdOAWswtt9yC++67r+5uDJU07adO8Z+ybdu2TkYCvv3tb+O22zh0tJnMAuAic4j3IzxtYuY2QsR0ayQmbUH6FoS0TOLtl7Zx5nImSXKLu8ukyRw4cABf+cpX6u7GUKkz7cdFVyMBf/u3f4vvfOc7dXeD5Ocry3vfHOFbjBsqD7VxUUY6Eeko3RqFSZOIeaWZS/hL2rs+RxK+XdFmy5YtzwNw0HMNaSBJkuDzn/98p3Kxm+T5N+liJKCLf4MjxO5Nmza9HDJnke3c1y5WtMe0p0FAvHRnBCajiG9ADp2b9wm11QC01vqLuXtLauH222/HY489Vnc3hkYTPf8mXYwEHDp0CH//964fkiUN5guQRZ/haGdr60obivH+E1KI7oy+pI3EpAJl6ySGgCtc68vzBIC/CfaaNIbFxUXceuutdXdjaDTZ82/SxUjAV77yFSwtLdXdDRKB1jod82PXAEgdU96Pd7SjgUAK052Rl9RFWQNV3oHTVyaJEKwY3JeWlr4IgC/3bgl33XUXnnjiibq7MRTa4Pk36Vok4Ic//GHnFqK3nGNjY2O3wu+th+XcJDaN1VZP0U9KpRujLmkyeYR87L1DUQDxNePj478I4OsF+kSGyJ133ll3F4ZCmzz/Jl2LBHTlb3IU0Fp/+bzzzrsK4RRSIDzHlJXiU+WcSTpEN0ZcMmq4IgGSgc+a22+5j3N9gVKKaUAt4IknnsCBAwfq7kbltNHzb9KlSMDevXvx05/+tO5uEBl/g3C6TxbfugBp6mrsnEZILkZ/tCVtIzRY2gZH36AcU+7L58xe9wXvNyCNYP/+/SP/A0xt9vybdCUSsLS01AnDdBRIkiSb/5/upXOGxFHlm5ds7WEpH+1BjlTGaI+0pG3E5lD62kgFPiztbOcny8bHxxWA7wr6Q2rkoYceqrsLlTIKnn+T2dnZThgBXfs16pYyt3Xr1pci/3yRLXfNRwgcm/dyQSOARDOouwOk02gAqqR7ZPfaUufLz4zy6CwsLFwI4Ata63cU7DupkMOHD9fdhcro9/u45pprMDExMXJRjpmZGVx11VW46aabRva9+Y899tjI/buNGlrr/y97mtlL54pQaqmrrAz4x0WCjLabhTSFIoORL59SmlMpuZ/tGt+A/VnPZ5MGcOTIkbq7UAlp2s8oef5NRn1NwKj+bY4SWuubERbttvNVt3KUSecv35xFSG5Gc3Qlo0YRD0nIcxOTKnSy7MCBA18C0J1fl2ohx44dq7sLpZN6/kch5z/EKBsBR48erbsLxM8j09PT6Upt31wQ8v6X5emXXkvjgIgZvZGVtJHYQSsUFZBGDXKlAAHQl1122Q0A/jKy32RIaK2xuLhYdzdKpQuef5NRNQLm5+fr7gLxc/PCwsJzsXIeSCBzFNmOfd583z0o6ElljNaoSkYFibfD5pEx611i39xLQrurBn2t9U2e/pGaUaro8pLm0CXPv8koGgGj9F1GlJtgd/4gcO6aM+C5Bp52LopEEwgBQAOANI8y0nwkA63EEDDPk0wZNm/efAuA0V1p2mKUUhgbG6u7G6XQRc+/yagZAWvXrq27C8TNw5OTk0uQpfSE5o/Q3OOLAJjXuaARQHIxGqMpaQN5BjazTcjrb14j8eab95F4exIAWin1S1rrvwj2ntTCaaedVncXCtNlz7/JKBkBo/C3OaporT+znP4DxHn/Af8c44o0r+qC5Xrz/j5oEBAR7R9JSVfIa0BIvTRSD8+K816vxzSghnLGGWfU3YVCpOK/y55/k1ExAjZs2FB3F4iDfr+fTf8B4uYGSXQ5Fqb7kEpo9yhKRgnp4CYxBGxtXN6XmBSgVeff/OY3bwVwSNh3MkSe9axn1d2F3FD8uxkFI+Ccc86puwvEzv7nPOc5z0dOh5DgOIs0/UcKDQQSRXtHUDLqxHr2bWUhsZ83Bejk+Y4dO94O4M8DfSU1cP7559fdhVxQ/IdpuxGwadOmurtA7HwGsvHf5vEH/HOMLwXIN5e5oOAnhWjn6EnaSh4vf+w1vnQeXztzSzx15vUfEfaRDJHzzz8f69atq7sbUXDBr5zZ2Vns2LGjdUbAqaeeinPPPbfubpDVaK31x+Ae632vAXXNEQiUSSIALmPBdw0hQdo1cpIuEwqj+q6TRgV87Z2D/ObNm39OKXWH+JuQoTAYDDAzM1N3N8RwwW8827Zta10kYPv27a3qb1fQWn9ly5Ytr4DbEQRYxn9HncuTHztfmXWElAZHIdIE8nr5i3yGxFMTMgayrwX9cGR/yBC4+OKL6+6CCHr+89OmSIBSCs9//vPr7gaxk47hvrHfdZ5kjmG0g6VOSig64GpPSJDmj5iE+HF580Pe/iJem1XnR48e/TSAJ/J9BVIVz372sxu/FoCe/+K0JRIwPj6OjRs31t0Nspof9Hq9z0E+7kvmF5f4D6X/5HFwERJNs0dLMoqEwp/Z45jcSOmA7BuIQ54eZzh4dnb2WqXUZz3fjdTE6173usb+KjAX/JZH0xcGK6Xwqle9qu5uEAta609u2rTpBthTf2znkg05r/F21bivWU6ImEHdHSAkBxqAT9HpTJuQ6Idjn+CEgayF5xrAh7XW7yjwvUgFnHfeedi+fTvuvvvuuruygqznX2vO32UwMzODK6+8Ep/97GeRJEn4giFy8cUX49xzz+W/dQNRSt0I4IXwO4B8keKQA8ps78KsDznBCMlNM10lhNjxDY6SgdU1cGf3iaWd6HzTpk3PBXCX4HuQIfPmN78Zz3jGM+ruxkn6/T6uvvpqpv1UwLZt2xq3JuD000/Ha1/72rq7QSwopW7fvHnziyCLAtvOzfx/n5PJh8ujT/FPKqE5IyTpOtJBLjSg+gZh87qQ98Yc5LPtrOda648KvwcZIqeccgouv/zyRohCpv1Uz+zsLK644opG/HsPBgNcddVVrXslbVfQWqevcTbnDMmYn5JY7gHHccg4yOv1p6FAoqh/dCRkJbGDoc+bb5bZtlBuZ2Lcy2UkAIAeGxv7JICjjr6SGtm0aRMuv/zyWtcD0PM/PLZv3157JEAphSuuuKLVv0o94vx4/fr1NyFynMfqeSV7HIoEhM5dhAwGQqKgAUCajGQwdJXHDLpmWZ5BOwGgzz///KsAfNz3pUh9XHDBBXjjG99YixEwNjbGV30OmfQVoYPB8Je79Xo9vPWtb8Xs7OzQP5vIUEp9aOPGjdcvnyZY6RAKnUvmDDjOkeNcWkeICBoApA6KDF6+CIHkvi7Rb56nZaLUH+P6f4eVHiXSIF70ohfhyiuvHKooXL9+Pd7+9rdjYmJiaJ9JTjA7O4u3ve1tQ03BGRsbwzXXXIPnPe95Q/tMEs2C1vqPEefxz55LxH/eeclVR0hpNPPdeKQL2P72lKVOWcqVsfUsx9l9uvUd5+l+YOz7y8f9zBY67wPo79u377UA3hr5TMgQOXz4MG666SYcPny40s/ZtGkTrrjiCpx22mmVfg7x8+Mf/xg333wzHn744Uo/5+yzz8aVV17J9/03HK31J7du3Xo7gKXMtig4Tyzl6ZYY+yWcEPBpmbmZ0QXAHmUA3EaFL32IxgNxQgOA1IXrb88l9m1lIUPANAJsBoDPCOgbx1IjYLBv375dAG6NfCZkyMzPz+OWW27B7bffjqWlpVLvvW7dOrzqVa/CC1/4wkYsRiVAkiT42te+hltuuQXz8/Ol3nswGODSSy/Fy172MoyNjZV6b1I+SZI8b3x8/OewUthLjYBU0C8ae1P8J0a5Kf5NI0ASXfDtTWgAECc0AEhdSA2A9Ngm/oGVoj8UAXB5/83jbJnI429rt3///uu01pfEPBRSD9///vdx66234q677ipsCKxduxYveMELcOmll2L9+vUl9ZCUyeOPP46vfvWruOOOOwobAoPBAM997nPxspe9DKeffnpJPSQV89+3bNnyeaz24MdGAGziPz13CX/TALAJf8lbhQAaAKQANABIXRQxAICn1q9k9y7vv8Jq73+ZRoDVKNi/f/9jWmv+OnCLePzxx7Fr1y7s2rULhw4dEv9oU6/Xw3Oe8xxs374d27Zt4ysfW8KxY8dwzz33YNeuXXjooYfEPx7W6/XwrGc9C9u3b8f27dtx6qmnVtxTUia9Xu/1mzZt2gxZeo8kAuBKA8puaZkp/ENRAIAGAKkAGgCkTspcBxAbAbCtAbB59l1b0CjQWn9o//79DwDYEvlcSAM4evQoHn74YRw+fBhHjhzB0aNHsbCwAABYs2YNTj31VGzYsAHnnHMOzjvvPKZ9tJz5+Xk8/PDDePTRR/GDH/wATz755MnowNjYGE499VScccYZOPvss3H++cv/cz8AACAASURBVOfTyGsv90xNTf3KwsLCRcgfAbDl/7vSf1wGgG0vSf1h/j8pBRoApE5i1gGYe+k6AJcR0DeOY4wAm/i3Rgr27t3bU0r9UeRzIYQQUgFa61/aunXresSL/5ARIM39D3n+fek/vv2qrxrxWEgH4co00gZ83o2Qh8R3T99r23yb6xchV7U79dRTPwrgMUF/CCGEVMvB48eP/xnic+5DYz08xz5sAh+Oc1cZIblgBIDUSdF1AHlfB5p6+tO1ATGpQDGvCE3XApyitf63Ec+FEEJIySilfnXz5s1rYPfmSyMAofSf0OLfYbz+01dOCABGAMhoEePJz3pyQq9fs4VqbZ9hvY9S6o8BfKeC70sIIUTGgWPHjn0csjnANSe4yhE490ULYBzboJgnpcMIAKmbMl8Hmu59vwWQ95WgRdYC9Pfu3dsD8O+Fz4QQQki5/NLWrVufDlnuv+ttQInl2Mz9l3j/bZEAWPb0/pPKYASAtBmJB8XnjYHjWLr51gKsqNNa/wmAg/m+JiGEkALMHTx48NNYPV670m5CYzvgngckGzL3MI8p3slQYASA1E3Z6wCqehuQ1bMP/1qAFdfs27dvQWv9n+SPhhBCSFGUUtdu2bLlTLi9+0V//KuMt/+4HFSA23BgBIDkhhEA0lRcA1yoPHsuGQSl0QBAnhea2K754Q9/+J8B7Bf0iRBCSDnce8cdd9wM/2LbUFTXHNOB1WM8gFzzTrbMtWekgJQOIwCkCQzrbUC2SED2bUCuNwFlPfm+SIDzTUDL5b19+/Y9qbX+qPzREEIIyYtS6vItW7Y8C/68/tCv/Zqe/1AEQPL2H18UAMhvANAwICIYASCjSExef+j9z1kPUB4P0oryhx9++JMA9pT5ZQkhhFi5c3Jy8ruQeffNcrMuZoGuL6UnNlpASCUwAkCagO/vUGF1BMDcm28DskUEYt4G5IoAmOV5IgH9vXv3HgTwF6InQwghJBda69eNj49PIezdl9QVee+/9O0/viiAbW/92uEnQwgjAKQd2MKfvnpfm1A0wBYBcHn2XW+R8IZ4t27der5S6ouib04IISQPnxsfH5+BcFz21ElTdCSbjVBEIEbQU/wTMTQASBMoY9Ay7xGTBmQaEEnk9bFGQJIkya/jhIeJEEJIucwrpX4b7rfvxIzhEFzna2MT+D6DgJChQAOANB2J18TXXjrQugbr2Df/iBZ7jY+PvxbARwT9IoQQEoHW+oNbtmx5E+LH6xjxn50rsusGQt5+WwTBbGMrN+sJKQQNANIWTC+9rd7laTHbZAf4PD/i4vIoSd7vfLJ87dq1/1xr/YPYB0EIIcTJ9wC8H+ExWDJ+hyIIZgQgNJ8gs4dR5prXbMeEFIaLgEmTqPtHwczFwOYPg4UWA9teEZptb9b15ubmlFLqg9IHRAghxI1S6h1btmx5Buyv7rS9AlS6+DexnEsWAEudQ7bIgMtYsEEDgUTBCABpM65QamgghVFuix7YPEGA2wsk9SqtiDo88sgj/wHAvbFfnBBCyCruvOOOOz6B/NFa6ToBCNoj0w6WayV78x6ElAYjAKRJhF4HaraJjQJIIgHmq0BtkQDbD4S5XgMa+uGw3r59+/Zorf+b5AERQgixkyTJyycmJp6H1d56m7ffty1a7uH60S9XBMBmgFTl/Q/VEbIKGgCkSVRtAMSkAZkGgCn+s2W+NB9XKtCKdKK5ubmXA3hL+BERQgix8Mnx8fFvI5z6I/klYPMeRd/9L1kfYAp+GgCkUpgCRJpE7OAWG1J1lUlCvr461+KxUPuTZUtLS+8E8CPP9yeEEGLn+wDeg7DolozVknrbfc2XSsCyh3GdWQejznUPE4p/Eg0jAKRpNCkKYEsHsi0CDm1Zj78rLag3Nzf3UwD/b/gREUIISdFaXzcxMbERbu+/LxJgpgfZUn9sXv+mLP4N1RFihREAMkqEFk6ZA2u2zOcByt7L9z5pn3fI1mbFZHHnnXf+idb6q/FfmxBCOssXJiYmzoXf+x8ak03R7hLrcNzP1g6Z9r6IgO2ckMqhAUCahmQgtIVTzXqbN8UsC21p21AI2DWJ2CYTZ7h5x44d706S5FcA/FTwDAghpOscXVpaehfCqT2+MTrkAErfAGdzCsXMIyFPv62M3n9SGUwBIk2kzjQghafSdLLHvgXBoTf9+BYEr9r27NlzilLqfeHHRAgh3UVr/WsTExNj8Kfu+BYA21KGzPuYC3/TY43VaUA+50/IQABoAJAhwggAGRVcIVbfAOvz+CRGG4m3yBVGlkYBNIDkO9/5zvsBfDv/oyCEkJHnG+Pj43+MeO+/azwOjdFZ7z8cbUxhn8Un+rN7QoYCIwCkqTRpMbAtApB3QbAoCvDAAw/8r16v97Xlc0IIIU+xqLV+wcTExKuR3/tvLvwNef+zdTaDgot/SatgBICMEq4BMxQFyJb5PPyhspiFZb5rk8nJyZcrpf6fvA+CEEJGFa31v14W/0W8/zHrAcwIgC3qaxP2IS8/vf+kNmgAkDbiCq/aykLiP3ZzGQLSN1BIJqEEQPKjH/3oX2itvyl9KIQQ0gFuGx8ffz/cTpeQuPcZDb62NpGf4hP+IU8/jPpQGSGlQAOANBXJwFdkcPQN4sOOAlgnn4svvvjdvV7vWgBPFviehBAyKvx4cXFxp1Lq12F3qvgW40q9/5Jx3mUQ+ER+6FxaF9OGECdcA0CaTOw6gPTcrOsZdb1MedEfButbjsvYTt5vbm7uCQD/0fMsCCFk5Fn+wa+fxeo8fek6gFDuv20NgLkOoOrcf7PO+Th8z4qQEIwAkLYiGSDNtrZrXWFXaQQgFAWQTBY2z9TJ8/Hx8dMA/JXgexJCyKjyqYmJifMg9/T7xl2J9x+ONjHpQHmImdsIyQ0NANJk8g6ANg9LaB8T6nWl7cSElX2T16prkiT5ZaXUd3M+D0IIaTMH16xZ809gX3TrHTvhF/4xawekG4y9qzy7j4XGASkMU4BI05GkAdmOi74SVJIO5PpxsLLSgVbcc25u7tsA/ibwTAghZJRIALxyfHz852BP0Yl5/adt86X9hFJ/XMZDrIEAS7kPGgCkMBQSpA0U+U2A7Lm5JsAm/iUGgGQtQPZ3ArLv/PeVBQ2BPXv2bATwbs/zIISQkUFr/b7JycljiBf+aX6/a72A673/5noASVqRNJ3IFw1wla16JJ46QsQwBYiMIqG8TJv3JRS+tYWcJWWS8LJ4DcHjjz/+WwC+muOZEEJIq1BKfXliYuJ9cOfxh9J6Qik/vlSfkDc/i7TehGKe1AYNANJ28nhMJIaAdPMu4BWUSRaqnfysiy+++DcWFxd3AOB6AELIKHMQwNVKqd9EeK2VVPzHGA0xm4lrXpLMUzQKyFBgChBpA6G/05hXglaxFsC1z6bw2NJ8Qqk/1rUAAPq7d+++tdfr3QJgLPBsCCGkbRzXWr90cnLyVVid9uNK35GmBLnWEcSk/tiMBsBvPMDYhxxTNmgckNKgAUDaQh4jwGcAAOG1ACHxX+S3AVKjwLdewGsEPPDAA/NKqQ8EngshhLQKrfX/Njk5eRbci3Tzin3XfUzRH5P7b0spQmZvixTYzoGwwKcBQEqDKUBklAl5XnwpQLb7hFKAfBNCKM0nlCK0qm5ycnKtUuqmHM+FEEIaiVLqw5OTk2fDLbh93nhz/PUJd5eQz5P7bzs2y2zXEFIbNABIW5B6RmIGVokhEJMP6ltgFrsGwDbZrbr30aNHfxnAPRHfmRBCmsqd69atezfcY6jEIJCk7rgcMTHjfEj4S6H3n9QCU4BIm8ibBuTal7kWwJYOZMv/96X5+FKFnClG+/bt+y9LS0vfBPCMwPMhhJCm8oMkSS6empq6Gu4Undj3/4fy/n1pP9lzlzNH4iiCZ+8qs0EDgJQKDQDSNpq2FkBh9QJg6XoAae6/WbfqeG5ubpfW+nPLZYQQ0iYWer3e67Zu3XoJ3Itz8ywADgl/1z6UjulK9URmb0sbsp0DFP+kBpgCRLpCKN3HVmYbuG0hZFv+qDSEHROytt1LA0jGx8e3A3hXridDCCH18qvL4r/IuGnzxIeuc3nvXaI/RZICZIp2injSKGgAkFHF5mUJtTUHb9fkYOLKIY3NWc2T03ry2omJiTMB/PvAdyWEkMagtX7fxMTEsxAeG/PUuRwpLiND6gAKpfg4v67lmIYBqQUaAKRtFAmVxkQBXB4cn7CXRAJ8nn2Jp8rb5tvf/vZ7AHzO8wwIIaQpfGZ2dvbLyB8hFTlHMntT9Jtjt2vMlswToXklLzQQSCXQACCjTNEoQLp3eXx8Hn+fN98n/vNObAmAZMeOHb99yimnXAfg64HvSwghdXKrUuqXFhcXXwLPmGapi/X0x0YFbA4gieCn95+0ChoApI0MIwpgq3OFgF2bzeskFfyxhsHJ9uedd95vKKXeAmCv/zERQkgt7F5YWHjr+Pj4b6LYmOczGFztQmO1rxyW4yz0/pPWwLcAkbYi+dsNvREoe26+Hcj3ZiDpa0F9rwYNveknpq351qEegN6ePXv+AsBtWuvTBc+KEEKGwff7/f4lW7duvQonBLbtVZyht/m43vCT95Wf2TJbhNbmxIFRhky5LXoAo04i7mkAkMpgBIC0lTK9KrbwrS8KEPIkST1NRdJ+gp6xiYmJy5MkuRzAT2MfECGEVMBPlFJv3rp169Vwp08WjYaGPP+2e7rSfUyBH/L+m0hSg1xQ/JNKoQFAuoDN+2Ke21J9XAN/9lwalg6FqG3lS4HrgtvU1NSlSZK8FcBx0ZMihJBqmNdaXzExMfEaFBjTUHBMhN9xY7ZJsc0FtjkCxnUmFPWkMdAAIG0mNJi66l1emVAUwDXwu9rYJiyfF0q62cLXzm16evqFSqlrcSI8Tgghw2ah1+tdMTU19QIUF/CxxoHP6x9y+NgiCPDss/i8/3nnLkJKgwYA6QoSr0xoIrDVuSYTm9j3TU6x3q2oe01MTMwC+JXlc0IIGRZLAG4YHx9/LoqNZ7EeflP8u5wzvrE7NDfAUWaDop40ChoApO2UHQUwr3MN9j5vkW3ikUxqVXnGNIBkcnLyfAD/xPOdCSGkTDSAd05OTk7A7423bVGRTs8WctSYdcDqsd0sy+5d35vef9JoaACQLhDjnQkN+OZxyFsUmojyTmqxk2NqBGzQWv+65zkQQkhZ/Obk5OQ5GIKDI1DuEvuhMR2WY3MvMQwkcxAhQ4UGABkF8g6qkiiAb6Iw29kmF1Ps+zxPVUQEVn3W1NTUM7TWvy9/TIQQEs3/OTk5+XRUI/xDY2ho7HVFAmzjOiztq/Li00AgQ4MGAOkKMd4ZicfHNZGE6ssU92kkINp4mJqaWtRa/9vgUyOEkHh+d3Jyci3iPfcaq6OboXOJ198ck23OGlja0ftPRhYaAGRUiBlcTS+Prc7WxhcNCBkCkknKJfClk1/UNjU19ROt9XsFz4sQQiRopdRvTU5OAvkdGqWMb45N6rCBpR6WumwZLOd55yVCKocGAOkSvgFWGhkw967IgDnZ2LxQ0iiA1Ejw/bql9X5TU1PrAPzjwLMhhJAQWin1axMTE+tRr/D3OWFCoh9YPa7b9tbv76nn+EoaBw0AMkpUEQUIiX/blq03RbdN+Juhaalh4JtMQxPqyc+anJw8Uyn1zuVzQgiJZUkp9csTExNnIM5x4RqnpJFP11gaGnclkVup48cFvf+k0dAAIF3D56GReHckKUC+UHKsd983+RX1mp387ImJiXOUUteDPxZGCIljXmt99cTExPlwOzmq3mzOFpfYB9zjdYxBkIXef9I6aACQUUMy2IYGcnPv8gQljjY+b5NZFpq8bILf5h1b8txDZChMTExMaK0vB3Dc9eAIISTDca31jqmpqW2QCXVzLNJwj2NFnBw+I8AsTwl5/WFpb5ZJ6lxtCRkqNAAIeQpzIJYYAdk6nyfJJvyzE1Wsx8wl+LOTqGuydYbTp6amnq+UuhzAMfFTI4R0kSe11m+Ympp6HuIFeXaM8o1lMWLf59W3GQPwtIdj7xL2FPGkdai6O0BIRUj+tpWxz5abdWZZdksN6Z6lvJcp7wW2vuXYtjfLBoI2fcf9rZ9/3333faHX6/0lgLMFz5EQ0i2+q7X+B9PT02+BX7Cbzgjb3jwOtbEZDrHGgisyEEr98RkFsNSFoOFAaoMRAEL8g3DeVKD03DbR2Lz9sREA3yTraiMOp8/MzLyh1+tdAmC359kQQrrHPUmSXGKI/1CUMnRe2nomS7krEpsiTf2RCHsKetIaaACQUaWI98Um5M1y1wRhepIAu+g328UYAqHJ0pcGJJ5wJyYm3ra0tPQSAP/T8ZwIIR1Ca/2lsbGxS2dmZt6B4g6L2LEsJPpD51nhX0bqj837D0s7FzQWSK3QACDkBLbBOOTpkYSLQ14ol1EQM3lKvfvRYfPZ2dl39/v91wH4tOdZEEJGnxt/8pOfvHHLli2/gbBDwRxnJL9VInFc2AR9KO/fHJdtZb6x3EbsfEFI4+AaADLqFF0L4Nqnxz3L3lwHYK4JkKwHcK0JkOT4F1kLYFsb0BsMBv9r165dL1VK/Y7vQRJCRg6ttX7f9PR0HzLHhMQAKFImdmBgtUPFZyxkRX+SfnesNBRsewTKbdBYILXDCAAh4QHd1k6SCmTbfBGBUPjaF9Y2J0SznXTxnHVSXVxcfNmyAHgHgAXHcySEjBbHAVxfsvi3jVExqT4xUQHXmOuK2pr7PHMDIa2ABgAZdWIH5KpSgXyiP3aC8wl66aSba9Hd1NTUZgCvB/CY55kQQtrPIa31K6empqaQX/zHOCPMsclnMIScIS5jwDc2wzi2YasvY44hZOjQACBdoEhINsY7ZE4m5gTk8k6FyqOFurGF1gqEfljMNAIu6/f7z1NKfd3xzAgh7earSZJcPD09/QbIxb+vXeg1njH3k46bvrFXahzAsndRZJ4hZOjQACDkKfKEe12TRGJpk577Jh9zApMYAdIJ1mYQxE6+CYBkfHz8H/Z6vVcA+CMQQkYGpdSHnnzyyVfOzMz8I+RzNEjHId91IfEv9fSndTDKYdnDaOeqs51T2JPWwUXApEvELAi2tbf9QBjwlCFt/kCY7QfDzOMyfizMteA3z0Jg0aLgbJ8feOCBB7TWfwLgFPdjJYQ0nGNa6380PT29FX7HQyiKGLMAWFIudVC4UoQkXn8zcpviSw0yHTshaCSQRsEIAOkSsYO0rb3N4+PKKQ2lBLm8WHk8bi7PvqteOtEGPXiTk5OT/X7/JQAetD1QQkizUUrt1Vr/XEb8u8aPMsR/7JgkEf6hiEAq6kNjtGtcN6H4J62HBgAhq3EN1rGpQDavkis07fNkSSZA6cTqmsxDk6/XEBgfH79ca/0CAF90PDtCSAPRWv+348ePv3B6evoquP/Ph5wKEvEvdVTEjn0u4Z89B+xGAOAfx+E4D5UT0niYAkS6SJmpQNl6M+UHcP8+gOu3AaS/EWCm6NjSeGJThELpQKFNDQaD/3nPPffMAvhDAGusT5YQ0gSOA/idu++++wM7duz4XcQ7HiTiX+LlT8s1ZMaB7e1CtrUBrjUByOwlUQCm/pCRhAYA6SpV/0BYzHqAIoaAzwCQrgeQXh80ANLj3bt3fw4nfj14wv14CSE1cX+v17t2YmLiSuSLPJYl/l0Rgdg3ArmEv8sQkOb9Z/e+YxcU/6SxMAWIEDfScLArhOzbbBOVb7JyTXyhyTMUfl9CeHKWTMYr2kxNTf3C+vXrnwu+JYiQJqGX3/JzsSH+JaI7dqwIpQC5PtPm4TfHPtf4mEf8+0Q9U3/IyMIIAOky0r9/XyTAlwoEuN8QZIsAmNEASSRAwe7Fl6QCxdSFIgK2fioAvfvuu++bSqkPA9jgeL6EkOp5DMAvT09PXwK3wHaJbsni39j1AD7nhCQaYRP+EiMAeMrjnyf1Ryr+aSSQRsMIAOkysQO0rb3Ne+RbYJadgFwTlc3bJZkEpQv1Yjx40rcCOfs4MzNz8eLi4iyAv7E/VkJIxfx3rfWF09PTL4JsLHF5/CXjhHQxsGuMCo13Lq+/rR5YPfb6xmnz2Ffmg+KfNB5GAEjXKSMK4KrPuyi4rN8JkK4HkC4ijlkQvOp73Hzzzf9q+/btvw7gfeBvBhAyDJ7UWr/3ggsu2LO4uPgKrBTYMTn/IY+9qyw2NVHq8bd9D1dqEDJ7WzQAsBsH8NSHoAFAGg8NAEKqMwJiFgW7FgTHGgGhtJ0YoZ8aCb40o+CCYLPPe/bs+fDS0tKfAHi160ETQoqhtf4CgHfOzMz8Q6z2mIeid2YajpmeU2RRb1niX7J2ypf37xL9FP+kE9AAIOQEVb8aNHsc82YgWzRg2EaA7z6SNQqrygaDwS133333OUqpD4JrAwgpkx8CeO/09PQmuL39Ie9/KvIlol2SNugT+yEDwGa8SPP9peLfLIdRT/FPRg6uASBEji9P1BdS9q0H8E1gvpzXmNC9ZMLOsy4gzw/5LAFIFhcXXzEzMzO9uLg4q5T65OpHTQjJwWcHg8HksviP/b9pGy/y5vtXJf6lY2WZ4p+QkYQRAEKeouxUILPMXA9giwAA9khAnmiA5C0+vnUBofbS9QG+75C+KejWXq/3n7TW55kPmxAS5ACA/316evoyhL3mISeCRKjHvsc/VvxL+m/L87eJ/rQ+r/in95+MJIwAEPIUsQN9TLjY5Y3K1pkTlc3DFTORSyZ3STRA+mYg1+YTCgmAZGZm5tKxsbHtSqn/uFxPCAmzAOCD8/Pz26anp18B+f99yf/XmAjAsMS/bVyEUQfH3jUu+6D4JyMLIwCErCQ2CmC7JmZRMLDS85/ufb8REBMJcHnnpZ7+0CJg2/19UQnbouYV32PPnj0fX1pa+iCA14MQ4uJLWutfn5mZ2Qm7d9wlqn2COyTYs4uA8wj/UJ3PyeFL/4FlH0r9CTlybHUuaACQ1kEDgJDVlGUESBcFA/FGQIwh4EvRiU35iX0bkM8IcPVfAVD333//bVrrf6eU2gJCSMoepdS/mJqaej78a4RsYloaJdRYKdRD4l4q/kPC32cElCn+zXI46iVQ/JNWwhQgQlaTZ+C3TSS2+tDEZE5gtolP4i3zhfF9ddJzczGwK4Ug1A+nIJienn7J0aNHp5VSvwbgcRDSYZRSP9Jav3cwGFyQEf8+ke1ayOv6fyn5P50nRTDUh7zi3zZmAuEx1iyHo14CxT9pLYwAEGIn5v9G1a8HLSsSUDQtKKYsJgXImQ6Ubg888MAHkiT5lwDetfx5hHSFRCn16cFg8Jtbt279Dbi9/iFHgDQSELsI2GfIh+qKiH/fBssxLOVw1EuhAUBaCw0AQtzkMQLyvBkoezwsI8AU7r48f6lx4Kt39UWSDnTy+P77778ZwPsBvAaEjD5/DeCfTU9Pp3n+0migS+xL8v1jDAEz0qCF95b0uyrxb5b5yn1Q/JNWQwOAED9VGAGmMVCnEZAV7AorxXs/c49Q7r/LmJCuAxBHA3DCEPhLpdT7tNavAiGjx21a6382MzPzJtjTAUNe/5AhkBXpEtFuE/lmqpH2XF+X+IdRDqPOV+6D4p+0HhoAhIQpa1GwbZ8em4uByzICpAaAxItvltmMhrypQC6vvzUSkJ7fe++9f6WU+n0ALwchLUdrfVu/3/+XU1NTb8Bq8WsTxjZDwOfxD20xr+yMEftVin8stwXyi39bnQuKfzIS0AAgJEyeKIDtuqYZAdny2JSeoteZfYpKBTKO1X333fdFpdQfaK1fAELax9eVUn8wPT39UoRFf0zqT+qVj03NySv6l7C6b6Mk/mPbEtJYaAAQIqNMI8BWb0sDMs9jjQBJKpCtPK+ol0YCbJ/pMwhCxoACoAaDwZfvvvvuU5RSv6u1vhCENJ9vAPjXMzMzr4Bd4LpEv0/wS73veUS9We5K+/H1Q/q9YtJ9fGLfNAxs9VIo/snIQAOAEDltNgJ8qUChNKEYg0AJ2vs+PxTJsH0/8zlgOTXotwG8yfL8CakTDeB/KKX+aNnjD7hFr0/02yIBUiPAJuQlBkHMwt4i4t/3TCj+CSkBToyExJHHCLBd4zMCfMcxRkCeVCDJ1rdcV8YPg/miA5JIQHqM5WPs3r37U0tLS+9SSt0AYJ3l34GQYTEP4Cat9f89Ozv7tuWybPqKywtuimSbwC5iALgEvsu7L9l8fbTt83r+bcewlMMoN9uGoPgnIwcNAELiKcMIsJXXYQT4jALTs59H7NvaSqMRtj67vpvtGSA93rVr1wd6vd47lVL/GMAZIGR4/Fgp9fFer/eHk5OTv4anxC5gF8GAWyBLIgA+MR4r6M325r1cn+tKTRqm+DfLfOU+KP7JSEIDgJB4Yv/fNMEICKXZ5IkExIp9yW8DxKQD+b4nMmXpMXbv3v17SZL8itb6/1BKbQYh1bEHwB8dPXr0xosvvvh3l8tM4Z+WuTz/VaX9FDEKJPf0HbdJ/OdpT0groAFASD7yRAFs19VtBPgiA31LuS8CoAL1IY+/zxCw9U/yXbPtsFyGwWDwP+66666fALheKXU9gFNASHGOK6X+Smv9oQsuuKC/uLiY/k5FHuFvS/fxGQShFJyQh992jcs4SNODYvpnfh/zGQxD/NvqfFD8k5GFBgAh+Wm6ESBJBXIZAGVEBPIaAL40pZhIQNAQANC78847f3/dunU7tNbvAnABCInnfgAf7/f7/3lycvK9WClwgdWCXpryI0n3KWoElOHxDxkArtQfin9CaoIGACHFaJsR4PKsSzzxVRsIPmPA1k/X9/EZAsjUrXqG999//58lSfKrSqnrAKwHIW5+qpT6r1rrD83MzLwebuHq83a7vP2287yGQJkCX2p0+L4PxT8hDYAGACHFaasR4DIMQl74IoI+pr3rPGQMxBgC1me4b9++3zt+/PiVAHYAeCVOpDURsgDgS0qpm5RSfzE1NfU7cAvXWOEfEv0+j3seIyCPY3FqeQAAF15JREFUwRD6PFf0guKfkIZBA4CQcmizEeAS1KHjKj3+0n6Eoho+Q8A0CLJlSMvvvffeP1BKvRnAlQBeD2AA0iWWtNZfV0p9dmFh4c8uvPDCf7pcngr8GFErFf4u0R8qtwn2JeM4u0/gf5tP6LNdBgvFPyENhwYAIeXRJCMAWJ3y4jIGJHuJAZC3Lo9BII0C2AwB3zOxPb+TZffff//7tdZvwglj4A1gZGBUSbTWtyulPqu1vml2dvY3l8ttHv6Q4E/b+oS/dC9NwSkzrUdiiPj2vmdie37mc6b4J6QCaAAQUi7DNgIAi1DFSqGb7pVxLPWi50nTqToCEBsFCBlBLiPAaRQ88MADH0yS5Be01m8A8AoATwNpM08qpb6cJMkXAHx+dnb2t+AX++a5T/CHvOIx3n+pQSDx6kuv9xkAvv5nn4lN/JvPNVtmllP8E1IiNAAIKZ9hGQHm3hcByO594thnAEiOq4oUSCITMYYALNdlIwQSg+Bk2YMPPvjPjx07dimAVy9vzwdpPFrr/QC+1Ov1/nowGHxxfHz8D+AW97Yyife/iPD3lVXh2feJf1eZz+ufnsOoS899z9m2d5W52kig+CedhAYAIdUwDCMgPfaKU89mS4eRGAB5xH/eMl8bV39s9XmjAJKowKrj3bt3/4elpaXX4IQx8DoAp4E0gZ8AuF0p9dda68/Nzs6mv8zrSz0JCda83n+bWPal00hScmLFveR+MQaAzQhwbbZnaf4bwHFM8U9IQWgAEFINsf+3yjYCAL832yeCJULaFwmQiPiYOmkUwnbuagNLO1u97/mZddZ/i1tuueWfbty48UKt9aVa6+cDeDmAZ4MMg0eVUt9KkuSrSqnbAHxjdnb2/4Jc9NsEakjo+wyCkPB3efolx3m9/iGxH/L05xX/tvQfin9ChgQNAEKqowlGgNSzHTIGXCLbJ/wl6UIxHv/YCIDNEAgdS6MApnFgPmuzbsX57t27P7C0tPQSAJcCeAmAizL3JPnZD+A2AF8FcNuFF1543uLi4mthF5Yh0Q+Evf4Swe8SxyHh7xPjsRGBGKPB5fGXGAAxm+vfAZlz87gs8Z+nPSEjBQ0AQqqlTCMgLStqBACrveDZY5/494ntWK+91FDwGRi2vuY1BMwIQFFjwPXvYNbj/vvv/zdJkrxAKXWB1voCANsATANYA2LjOID7tNa7lFK7kiS5e3Fx8RsXXXTRv1quNwWjT3SaZTGiP9YIkAp/n8j3ifmQuM/j8bf13/W9gNXPwfYsbf8OPvFvE+sU/4QUgAYAIdXTJCMAWJ3ikhWwtvSYMiMCEmHvuy50jc9IcYl+SXqQ63nlNQaC59/61rd+e/369VNJkmw3DIPz0C0OKKXuWRb7d2mtdx0+fHjPZZdd9oeZNjYB6TqPEf3ZOlsbqeB3HYeEfx7RHxL7rrpQn0LHrucVSvWh+CekBmgAEDIcqjACXO1ijACfdzvGGJAYBDHpQBIjItQH397l/feVSwS/5NnHGAQwy+bm5t577NixZw0Gg81a681Kqc1a680AzgVwDoBNxjVN54cA9iulvqu1PqS13q+U2t/r9fYnSfLA7Ozs78Eu+IoIftuxxCBwef1D5b4UG5/4DqX85DEMfNfajAJbP2Fp49okzx6ZPSx1ZrntWALFPyHLtGmSIKTtDNsIMPcugeoTtzEedMk+T7QgZDTEGALS7xK7pc8LgjLz3LV3XWe2WXE+Nzf3O4uLi+cvLi6e0+v1zgSwId2UUhsAbNBan5UpX49yeRLAEQDfX96OZDet9ZFer/d9AN/VWh9YFvgpNhFolrs8xj7Bb57bvNJFU39C4j9G+EtEf542ISPA119Y6kLPy/WsXXtXmVnnqvdB8U9IBhoAhAyXYRoB6bHPI20Tq3lSgrLnedKDyqzPawiY39P8XrZnZIsAuJ6v79i2jynzlZv1q8puvvnm90xNTZ0GAGvWrDllfn5+HQAMBoOfUUopPLUWYT5JkmRpaenHy21/Oj8/fwwALrroohctLS29brldSLzZyiXiPz2PNQBsx64ywJ3G4vP2m/WSVJrYFKAy6239kBgw2e9pe0a+52sew3FM8U9IxdAAIGT4NNkIkHi7Y/LpQ+I8xsMvSRNyCX5bP0LfpQlGQKzYjzUCqpoDXGLe1c4n+KRi39xXIf7TvcsIsB27RHhI+NuMBEn6j6/eJfqlBoBvCz1f8xiOY4p/QoYADQBC6qFpRgDgzmfP1ucxBlwi3Sb2Q4aBpE4SlYgxAtKy7DOyRQxcz9D3zPMYAXn+Fmz1vnsUoUzx72pnikpzH2MIADLRn15nE8QxYtpWL83NtxkKEiMiZHxIRL805Yfin5AWQAOAkPrI8//PJ+5ijIDsechrbRO1IWPAJqylHnpf1CCU9+/z+NvuB4T7bWsD2I0ByTOEUef6dzHb2/YQltvw/Q3FEhJuoTqXAJSIf0mdKfRtwtUn+k0RHBL8aRtpSpDUY++6NibCIDVUzO8peYa2MjjOzWPf31AeIU/xT4gHGgCE1EsRI8B1vSkos+1se5/4N8tsm00kxxgDPhEvFfyu9jHRANPTHzJwfMaA71ma7Vx7l1Fga+8qM+tc51XhEvS2spAoNMtC4h9YLVazxz7xb/Nu+8S/q9wmvF1iPCTaQ+XSz4kV/a7N9SxdxzCObfW2dhT/hFQADQBC6qcqI8DXziVMpeIfcHvHJQZAHmPAd60k2uC7T0jk+4yd0DPLPivpc3ftbULeJe5df1dVGwMS0Z8tl4hCidiXiFCfwLeVS0WxT/yHykJe/VB5jOj3GQC27+x6VpJn7RP/rn97V7sYKP4JEUADgJBm0HQjAAh7uSWCXyK+86YO5blPUSPAtw8ZB75nbauzlYfKbMeuNpJyk5C495XnFf6ucp8IdZUBsvz/POLfVicxDmLz931l5j3N7xPj7fdFU3zHcBxT/BNSIzQACGkOdRsB5rnPg23zcEuMAZ/oLrKGIKaN1AgIfa9sO9e5ywDwPWupUQDjOHuexxiw4WobI7RCot9Xnh7HiH3JsU3cus5dot9sJ1lUK00TijUO4Kn39dlWF3pOvufrOjePKf4JqRkaAIQ0i6JGgO88JAqlYlRqGMQaACFhHrvAVyr0fe1gKfcJfdtme07mtWadq62tDkY7WMpNpO1i8Ql5Sbs8wt9VZ2srSWsJGQIuIR2bdhPrzbfVxX6u6zuFhL7reYb+TWA5D/2NUPwTMgRoABDSTIoaArbrbSIxJCp94j8kdiVrBMz6kCAvK7c/dg1AKBIQK/5dBoGvzKyz1bvKzDpXfZmEPLou0W8rkxgCEsFvK4vZXOI5T859kQhBqC0s5TZDIPQ8XM/Q928Cx7Hvb8BVH4LCn5Cc0AAgpLk01QjIHvtErW9zGQdlC3hf1EB6b8DfZ5/4l9SFjs0ys1xSZh5DUO6r8wkvV12MQAwZAjHeaJ+nWyr6AXkevUt0u4R+mQaEq8+hDXAbAqFnm927jin+CWkYNAAIaTbDMgJs17nEZyidxSd+JdGBKg2BUDtfecgYML+X+QxCz056DMux698y9G9cJT6B5xKGIU9yHuEPrBS32XOXFzwk+l0iHI7yMgS9pF2sAYCIMvOZ+v6tYLQzofgnpGZoABDSfIoaAa57SDzGEqNAKv6BuMW1UqMgj6gvmvZjMwZ838dmAPieo6+Nb28eu9qYlDUX+ISZT/S72tj2rjJfG2naT9rWJ/ol4jtW1McK/xgDwLaPMQLMY9e5eRwS/q42ISj+CSkBGgCEtIO8/1dDHl+JEWDupcchg0AqrmMMgVhjQXIfeOpDBgA89b7nBk+9rQ5GOwjKzfqycAm0kAEQEpghMeoTrr4ylwHg2yTiP1bUS1OMfG0lW3qN5FnZ2tj2CJSbZXlFPMU/ISVBA4CQ9jBMI8B2nUSYhoSveR4yBLL3Col8OMpDYl/6tp/YhcC+7+p6bj5Rb/47SYwB81hyXpSQl9fnOfZ5km1CNFsuNQKkxoBNdCNz7KqP9fQPQ/i7vqvrWZnHrnNYzin+CWkBNAAIaRdlGAGu+7iEpateYhT4PNy+fHmbMM8e+wS9T9jn8fS7+mG2D30nW3neYxjHPmPA19ZG7N9YSJiFhKFP9JtleY9dqS+SNQCwlEnFv0vwS9u6jA/f9aHvLDECzL1L7Nvau9q52oSg8CekAmgAENJO8vzfzWsEmOcS4e87NvPgbQuDXfuQIeCqk4h86b3N71D2GgDpsXkfs8zXxlbvOg8hFXkuoSgxAIoYAUD8GoC0rS2HPiTO8xgH0nubffHtXd9deuw6h+Wc4p+QlkEDgJD2kvf/r08smuWutmZ9rFHgK5MYBj5PfMhzH2s4uLZQBMMm+l3fHZ4yyTM2r4eg3Kwvi7wGQEh4SsV+yNstfe99bG59HiHvu4etvW+fx9Nve3a2ves49G/taxOC4p+QCqEBQEi7KcMIcN0nJCjTY5/wN/chARxrCKTXSV8vahPxMaI/Zgt9Z4nQLyL+fcLf1q4sQuLPJy5jjABbmUT8+srK2qTGgyvCYJbZ9i4DJsYIkJ7Dcu77t3WdS6H4J6RiaAAQMhrk+b8sMQKy5T7RGDIEpMc+kSwxBLLC3tW2KsFv62tR8e8zAOCo85XZjn242kkFWkhIhsp8BkBRIwCQpwbFin/znpJUoiqEv/TYdg5LvVkWahsLhT8hQ4IGACGjQ97/zxJDwOVN9nmcfV7sMg0BU/RXLfBjRL/v+8Q+EwjP4Tl3/Y3ElktEoK085CGO8U7HGgHmuUtwl2kM5N0k/fJ9v9Cxby89dpVR/BPSAmgAEDJaVGkEZMtjDQGXYC3DEJAKc+k+jzEh6Yev73C0ixX/oX8f399H0fnAJ+BCBoBU9JvnLmFrimipEZBHpLsEex5PvqRPZpnr+9mOfXvz2NXGVu86l0LxT8iQoQFAyGhSpSEgMQLSc6l4LWIIFBXnUgMib9tQv0PHruflq4ennVkXKpMg8Qxny2zC0SZEbULWVR469gnovG8JyrNJP6cs4S85h6Xeduwqo/AnpGXQACBkdKnSCMiWFzEEpN7vGG+6VLxn66XRhDyGAjxtpMe2OhjHoTKzzlVfBlIB6RP9ZplLwMYaAea5VNj76sx7SN80JPksX/9tx+Y+Rvjb7mFC8U/ICEADgJDRpsj/cYkh4BOTEpEqFbsSQyCPKM9jFEiuDZUVFf9SAwCBdi6kfzcSEecTlBIDQCJuJYLYJb5tZTEGQ+y1ofvmEf6SOjjKbOehfytXGykU/4TUDA0AQrpB3v/rEiPALI8xBCR76bGtTiribW2KevdjvfyxhoDt3FVm3j/EMA0Am7D0ea1jxL5EWNuMAEkbiaj3XePrU8z3iNmbx6G6mDIJFP6ENAQaAIR0hyL/32MNgVD7kBdb6gH3CegqjILY60Kf7fsOMXtXma/crJeWp7jEXKjcJUB9HuuYvVTwS8R3TJmrjXQhcozwt9XBUw9Lves6Sfs8UPwT0iBoABDSPao2BGyi1HdetSFgHvtEuFTUx9wjpt+S7wrHcageEfVFiPEo+0S/7VgqhmM85z7DICTei97D1pfY7wpPPSz1tvYmFP6EjDg0AAjpJsOMBoSukXizY8WxeX3M4ltJzn5Z98nzXWGpd5Uh0M5H3giAq53UGAhFAqRiOCSyU2GePc7jpZcIfMl9zD7VIfxD18RC8U9IQ6EBQEi3aZIhkJ6HDAFzL40QhOpjRL7vmjyfLfkusNRLylxtfEj/LiQCzyc6JaLfViYVxzFRgjweeonXPtbbH7O3fU+z3tbeBoU/IR2CBgAhpOg4MAxDwFYWI/5dIjnvcda7D0uZrTx0b9ve1X/J83Ldz1YfKvMhSSExy13X2ESoT+xKvf8SQ8D0vmfL8gh9iYiX9NF27itznQ9D+JdxPSFkCNAAIISklBkN8N1vmIaAeS4R3UUMA+nnSLz+rnPzWGoMuK6vCqn4jIkK2K6RCnxfXRFxX6XQH6bw97WVQOFPSIugAUAIyVJ2NMB3T593Oq8hENr7DANJmzwCP+azbXvX9bY2JqEIQN46n9iT1IXEp88IiNn7xL+rbYzol1wr+Wx42ofKTHzPOHRNHij8CWkhNAAIITbKNgQkIlNqCJjledJmYiMBtmuKGBqxfbR9jqvM1cZkmBEAV3mM9z/kKTf3rvq8RkEeQR/r7YegzHbua+u7rqh4p/gnpKXQACCE+CgyRsREA8y6kAFRxBAI7aXtYwwKSZ8kn+8q85WH6nzkiQD42sYI16LGgKuurIhBTLvQ9ZKy0DmFPyFEDA0AQkiIsqMBoXsWNQTS81CZNAWnyghDqG+uetd9bPc1if33iEGaXhIbBXCVx0YFQvuy7+e6l6/MvDZ0HiP8Q+0lUPwTMgLQACCESCljvAgJeled5LqYqICtLMY4kLSpIr0nxusvqXfdNy8hcRhKU5EI4KKRgTK89Hk+L6bMrLOdD9PjX9Y9CCENgQYAISSWJhkCvntVlTbka2Nrn/dzXe1cbW3tfP0bFhLRWiQKkJ7HCu2iEYW8nystd5VR+BNCCkMDgBCSh7LGjioNgWxZ3pSaPKlEruM8Za4++Nr7+mKj7HlAGgVwtQ15xGNSg6Rlvvo8xkZMX2198fXPRhXCv8z7EEIaBg0AQkgRqogGSO5dZVTAVV5GWex1knJbXUwbF6H2seIwNhJQdlTAdf8y03bq9PaH2sdA4U/IiEMDgBBSBlVFBEL3jhXB2fM6jYQyPj9UZ/ucusb8WFEbExmQiuu6xL3UO0/hTwgZGjQACCFl0gRDIOb6Ko2BIuW287ypPjFpVlWQR9jGesarFutFy6VlFP6EkKFAA4AQUgV1GQJmfUx6UR5Pe2x6TmxakKtt6F6h64c19sfmpscYAWaZJN1Hek0Z93KVhe4nuQeFPyGkEDQACCFVUfb4kkfEDtMYyFtX1MvfVPGfUsQIsJXljQ7EGA9l1PnKJcK7qoW9Vd2PENIiaAAQQqqmakNA8hlSEVxEgOety1svbeNqP0xiBW9eES2tLyLq84p+X7s8984LhT8hhAYAIWSolDnmxIh4V33MugKzPE99jKgvc2FvHqOpKHnFbBkLhiX3KWIs+Mol95bcp0yhTtFPCFkBDQBCSB00ISpgaxNrVJThpc9jkDRV9LsYljEg+awiKTplefpdbejtJ4QMBRoAhJC6aZsx4Ksr8608RRb5Sq+ri7xe7zwivQrBH/M5oTYU/YSQodPUyYEQ0k2akCJka5PHGLDVl+XhL7qwt663AMW2L8MgiGkXcx/JvXxtmOJDCKkNGgCEkCYyjKiA9HPKWHTsq6tS7NcdGShD/BZdSFum4Jfcz9eG3n5CSCOgAUAIaTJVjFFFRXHZBkHee/ratGVsLyKU84jzKt/CMyzRX9U9CSEdoi2TBCGENNEYcLUt65WcZS3krSpFqGiKT95rinjhy7h3qC1FPyGk0dAAIIS0jarGrSJ5/qG2RaILedq2ZWyPTb8p2rYqwR97nxgo/AkhpdOWSYIQQkyqHL+KpvRI25cl+Ku4rgqKiNkyog1lRyyqFOcU/oSQymjSxEAIIXmpeiyrwttedv5+28fzvIK37DScOrz8w7o/IYQAaP+EQQghJsMY18qOEFR936rvZWOYr7msQuwXuW+ZfSCEkNKhAUAIGXWaYBBI29T9GXVR1qLeuj9DAgU/IaR22jpZEEJIHoY55g1bsBe5V1n9GGaOf9F7DVOIU/QTQhoFDQBCSFepY/wbxqLfGKr6jKZ50usQ4BT9hJDGQgOAEELqHQurekf/qDCM3xooC4p+Qkgr6NpEQgghIZoyLpbRj6Z8F5MyhHJTxHZT+kEIIWKaOjkQQkhTaOo42dR+lUGTRXWT+0YIIYQQQgghhBBCCCGEEEIIIYQQQgghhBBCCCGEEEIIIYQQQgghhBBCCCGEEEIIIYQQQgghhBBCCCGEEEIIIYQQQgghhBBCCCGEEEIIIYQQQgghhBBCCCGEEEIIIYQQQgghhHSb/x+Epo5MBAPpWAAAAABJRU5ErkJggg=="/> + <mask id="mask-0"> + <g filter="url(#filter-remove-color)"> + <use xlink:href="#source-35"/> + </g> + </mask> + <g id="source-34" clip-path="url(#clip-2)"> + <use xlink:href="#source-28"/> + </g> + <g id="source-37" clip-path="url(#clip-3)"> + <g clip-path="url(#clip-1)"> + <g mask="url(#mask-0)"> + <use xlink:href="#source-34"/> + </g> + </g> + </g> + </defs> + <g clip-path="url(#clip-0)"> + <use xlink:href="#source-37"/> + </g> +</svg> \ No newline at end of file diff --git a/src/assets/cursors/tahoe/resizedown-1__50-36.svg b/src/assets/cursors/tahoe/resizedown-1__50-36.svg new file mode 100644 index 000000000..047c1449a --- /dev/null +++ b/src/assets/cursors/tahoe/resizedown-1__50-36.svg @@ -0,0 +1,18 @@ +<svg width="618" height="600" viewBox="0 0 618 600" fill="none" xmlns="http://www.w3.org/2000/svg"> +<g filter="url(#filter0_d_384_47)"> +<path fill-rule="evenodd" clip-rule="evenodd" d="M565 97.1429C565 137.481 532.195 170.286 491.857 170.286H126.143C85.8046 170.286 53 137.481 53 97.1429C53 56.8046 85.8046 24 126.143 24H491.857C532.195 24 565 56.8046 565 97.1429ZM447.43 293.034C447.43 300.787 445.565 308.614 442.091 315.599L354.941 489.752C346.347 506.977 329.048 517.692 309.775 517.729C290.466 517.729 273.131 507.014 264.536 489.788L177.387 315.635C173.876 308.65 172.011 300.824 172.011 293.034C172.011 265.13 194.722 242.456 222.626 242.456H396.852C410.712 242.456 423.659 247.942 433.24 257.926C442.383 267.398 447.43 279.905 447.43 293.034Z" fill="white"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M528.427 97.1437C528.427 117.331 512.043 133.715 491.856 133.715H126.142C105.954 133.715 89.5703 117.331 89.5703 97.1437C89.5703 76.9563 105.954 60.5723 126.142 60.5723H491.856C512.043 60.5723 528.427 76.9563 528.427 97.1437ZM322.238 473.405L409.351 299.289C413.995 289.999 407.23 279.028 396.843 279.028H222.617C212.194 279.028 205.429 289.999 210.073 299.289L297.223 473.405C302.379 483.718 317.081 483.718 322.238 473.405Z" fill="black"/> +</g> +<defs> +<filter id="filter0_d_384_47" x="0.34" y="0.6" width="617.32" height="599.049" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> +<feOffset dy="29.26"/> +<feGaussianBlur stdDeviation="26.33"/> +<feComposite in2="hardAlpha" operator="out"/> +<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.65 0"/> +<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_384_47"/> +<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_384_47" result="shape"/> +</filter> +</defs> +</svg> diff --git a/src/assets/cursors/tahoe/resizeeast-1__50-50.svg b/src/assets/cursors/tahoe/resizeeast-1__50-50.svg new file mode 100644 index 000000000..0f88973d1 --- /dev/null +++ b/src/assets/cursors/tahoe/resizeeast-1__50-50.svg @@ -0,0 +1,27 @@ +<svg width="1000" height="1000" viewBox="0 0 1000 1000" fill="none" xmlns="http://www.w3.org/2000/svg"> +<g clip-path="url(#clip0_431_20)"> +<g filter="url(#filter0_d_431_20)"> +<g opacity="0.5"> +<path fill-rule="evenodd" clip-rule="evenodd" d="M350.572 318.047C385.35 318.047 413.656 346.309 413.656 381.13V618.836C413.656 653.613 385.394 681.919 350.572 681.919C340.864 681.919 331.155 679.589 322.397 675.231L84.8642 556.356C63.3761 545.612 50 523.995 50 499.962C50 475.927 63.3761 454.31 84.8642 443.566L322.397 324.692C331.113 320.334 340.864 318.047 350.572 318.047Z" fill="white"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M104.156 517.773L341.688 636.648C354.935 643.249 370.511 633.627 370.511 618.827V381.121C370.511 366.321 354.935 356.699 341.688 363.301L104.156 482.175C89.4846 489.511 89.4846 510.438 104.156 517.773Z" fill="black"/> +</g> +<path fill-rule="evenodd" clip-rule="evenodd" d="M649.431 681.873C614.653 681.873 586.348 653.61 586.348 618.789V381.083C586.348 346.306 614.61 318 649.431 318C659.139 318 668.847 320.33 677.607 324.688L915.137 443.563C936.63 454.307 950 475.924 950 499.958C950 523.992 936.63 545.609 915.137 556.353L677.607 675.228C668.891 679.585 659.139 681.873 649.431 681.873Z" fill="white"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M895.848 482.147L658.315 363.273C645.069 356.672 629.492 366.294 629.492 381.093V618.799C629.492 633.6 645.069 643.221 658.315 636.62L895.848 517.745C910.518 510.41 910.518 489.483 895.848 482.147Z" fill="black"/> +</g> +</g> +<defs> +<filter id="filter0_d_431_20" x="-2.66" y="294.6" width="1005.32" height="469.24" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> +<feOffset dy="29.26"/> +<feGaussianBlur stdDeviation="26.33"/> +<feComposite in2="hardAlpha" operator="out"/> +<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.65 0"/> +<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_431_20"/> +<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_431_20" result="shape"/> +</filter> +<clipPath id="clip0_431_20"> +<rect width="1000" height="1000" fill="white"/> +</clipPath> +</defs> +</svg> diff --git a/src/assets/cursors/tahoe/resizeeastwest-1__50-50.svg b/src/assets/cursors/tahoe/resizeeastwest-1__50-50.svg new file mode 100644 index 000000000..e6ce73f8e --- /dev/null +++ b/src/assets/cursors/tahoe/resizeeastwest-1__50-50.svg @@ -0,0 +1,23 @@ +<svg width="1000" height="1000" viewBox="0 0 1000 1000" fill="none" xmlns="http://www.w3.org/2000/svg"> +<g clip-path="url(#clip0_431_22)"> +<g filter="url(#filter0_d_431_22)"> +<path fill-rule="evenodd" clip-rule="evenodd" d="M649.3 318C659.008 318.043 668.761 320.33 677.476 324.689L915.136 443.582C936.623 454.328 950 475.949 950 499.986C950 524.025 936.623 545.646 915.136 556.392L677.476 675.284C668.761 679.642 659.008 681.974 649.3 681.974C614.523 681.974 586.261 653.663 586.261 618.88V381.093C586.261 363.831 593.078 347.648 605.506 335.737C617.327 324.301 632.905 318 649.3 318ZM350.569 318.043C385.347 318.043 413.651 346.31 413.651 381.137V618.88C413.651 653.663 385.389 681.974 350.569 681.974C340.86 681.974 331.154 679.642 322.394 675.284L84.8636 556.392C63.3759 545.646 50 524.025 50 499.986C50 475.949 63.3759 454.328 84.8636 443.582L322.394 324.689C331.11 320.33 340.86 318.043 350.569 318.043Z" fill="white"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M895.844 482.201L658.185 363.307C644.938 356.704 629.404 366.328 629.404 381.13V618.875C629.404 633.676 644.938 643.3 658.185 636.697L895.844 517.804C910.515 510.468 910.515 489.537 895.844 482.201ZM104.159 517.799L341.689 636.693C354.936 643.295 370.512 633.673 370.512 618.87V381.126C370.512 366.324 354.936 356.7 341.689 363.303L104.159 482.197C89.4887 489.532 89.4887 510.462 104.159 517.799Z" fill="black"/> +</g> +</g> +<defs> +<filter id="filter0_d_431_22" x="-2.66" y="294.6" width="1005.32" height="469.295" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> +<feOffset dy="29.26"/> +<feGaussianBlur stdDeviation="26.33"/> +<feComposite in2="hardAlpha" operator="out"/> +<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.65 0"/> +<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_431_22"/> +<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_431_22" result="shape"/> +</filter> +<clipPath id="clip0_431_22"> +<rect width="1000" height="1000" fill="white"/> +</clipPath> +</defs> +</svg> diff --git a/src/assets/cursors/tahoe/resizeleft-1__62-46.svg b/src/assets/cursors/tahoe/resizeleft-1__62-46.svg new file mode 100644 index 000000000..e1294d4ff --- /dev/null +++ b/src/assets/cursors/tahoe/resizeleft-1__62-46.svg @@ -0,0 +1,18 @@ +<svg width="618" height="637" viewBox="0 0 618 637" fill="none" xmlns="http://www.w3.org/2000/svg"> +<g filter="url(#filter0_d_384_56)"> +<path fill-rule="evenodd" clip-rule="evenodd" d="M489.15 554.947C447.319 554.947 413.301 520.929 413.301 479.098V99.8496C413.301 58.0185 447.319 24 489.15 24C530.981 24 565 58.0185 565 99.8496V479.098C565 520.929 530.981 554.947 489.15 554.947ZM286.01 433.027C277.97 433.027 269.854 431.092 262.61 427.49L82.0125 337.115C64.1499 328.202 53.0379 310.264 53 290.278C53 270.253 64.112 252.277 81.9745 243.365L262.572 152.99C269.816 149.349 277.932 147.415 286.01 147.415C314.947 147.415 338.46 170.966 338.46 199.903V380.577C338.46 394.95 332.771 408.375 322.418 418.312C312.595 427.793 299.625 433.027 286.01 433.027Z" fill="white"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M489.148 517.021C468.214 517.021 451.223 500.031 451.223 479.097V99.8486C451.223 78.9141 468.214 61.9238 489.148 61.9238C510.083 61.9238 527.073 78.9141 527.073 99.8486V479.097C527.073 500.031 510.083 517.021 489.148 517.021ZM98.9625 303.201L279.522 393.538C289.155 398.355 300.533 391.339 300.533 380.568V199.894C300.533 189.086 289.155 182.07 279.522 186.886L98.9625 277.261C88.2677 282.608 88.2677 297.854 98.9625 303.201Z" fill="black"/> +</g> +<defs> +<filter id="filter0_d_384_56" x="0.34" y="0.6" width="617.32" height="636.267" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> +<feOffset dy="29.26"/> +<feGaussianBlur stdDeviation="26.33"/> +<feComposite in2="hardAlpha" operator="out"/> +<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.65 0"/> +<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_384_56"/> +<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_384_56" result="shape"/> +</filter> +</defs> +</svg> diff --git a/src/assets/cursors/tahoe/resizeleftright-1__50-43.svg b/src/assets/cursors/tahoe/resizeleftright-1__50-43.svg new file mode 100644 index 000000000..d4bfad199 --- /dev/null +++ b/src/assets/cursors/tahoe/resizeleftright-1__50-43.svg @@ -0,0 +1,18 @@ +<svg width="618" height="418" viewBox="0 0 618 418" fill="none" xmlns="http://www.w3.org/2000/svg"> +<g filter="url(#filter0_d_384_51)"> +<path fill-rule="evenodd" clip-rule="evenodd" d="M309 335.652C284.503 335.652 264.582 315.684 264.582 291.13V68.5217C264.582 43.968 284.503 24 309 24C333.497 24 353.418 43.968 353.418 68.5217V291.13C353.418 315.684 333.497 335.652 309 335.652ZM428.547 264.088C411.602 264.088 397.832 250.286 397.832 233.301V127.25C397.832 110.265 411.602 96.4413 428.547 96.4413C433.278 96.4413 438.053 97.5766 442.295 99.7359L548.01 152.761C558.493 157.993 565 168.544 565 180.298C564.978 192.029 558.471 202.559 547.988 207.812L442.272 260.838C438.031 262.953 433.278 264.088 428.547 264.088ZM189.453 264.088C184.744 264.088 179.992 262.953 175.75 260.838L69.99 207.79C59.5295 202.559 53.0222 192.029 53 180.298C53 168.544 59.5073 157.993 69.9678 152.761L175.728 99.7137C179.97 97.5766 184.722 96.4413 189.453 96.4413C206.398 96.4413 220.168 110.265 220.168 127.25V233.301C220.168 241.738 216.837 249.618 210.774 255.451C205.021 261.016 197.426 264.088 189.453 264.088Z" fill="white"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M308.999 313.392C296.739 313.392 286.789 303.419 286.789 291.131V68.5226C286.789 56.2346 296.739 46.2617 308.999 46.2617C321.258 46.2617 331.208 56.2346 331.208 68.5226V291.131C331.208 303.419 321.258 313.392 308.999 313.392ZM538.081 172.648L432.344 119.622C426.702 116.795 420.04 120.914 420.04 127.236V233.286C420.04 239.631 426.702 243.749 432.344 240.922L538.081 187.874C544.344 184.735 544.344 175.787 538.081 172.648ZM79.916 187.885L185.654 240.911C191.295 243.738 197.957 239.62 197.957 233.298V127.247C197.957 120.902 191.295 116.784 185.654 119.611L79.916 172.659C73.653 175.798 73.653 184.747 79.916 187.885Z" fill="black"/> +</g> +<defs> +<filter id="filter0_d_384_51" x="0.34" y="0.6" width="617.32" height="416.972" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> +<feOffset dy="29.26"/> +<feGaussianBlur stdDeviation="26.33"/> +<feComposite in2="hardAlpha" operator="out"/> +<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.65 0"/> +<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_384_51"/> +<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_384_51" result="shape"/> +</filter> +</defs> +</svg> diff --git a/src/assets/cursors/tahoe/resizenorth-1__50-49.svg b/src/assets/cursors/tahoe/resizenorth-1__50-49.svg new file mode 100644 index 000000000..fd495bd8f --- /dev/null +++ b/src/assets/cursors/tahoe/resizenorth-1__50-49.svg @@ -0,0 +1,22 @@ +<svg width="618" height="1373" viewBox="0 0 618 1373" fill="none" xmlns="http://www.w3.org/2000/svg"> +<g filter="url(#filter0_d_429_21)"> +<g opacity="0.5"> +<path fill-rule="evenodd" clip-rule="evenodd" d="M53.0703 867.356C53.0703 818.426 92.8332 778.602 141.823 778.602L476.257 778.602C525.186 778.602 565.01 818.365 565.01 867.356C565.01 881.015 561.731 894.674 555.6 906.997L388.353 1241.19C373.237 1271.42 342.823 1290.24 309.01 1290.24C275.196 1290.24 244.782 1271.42 229.666 1241.19L62.4191 906.997C56.2877 894.734 53.0703 881.015 53.0703 867.356Z" fill="white"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M334.066 1214.04L501.313 879.853C510.602 861.216 497.064 839.301 476.242 839.301L141.808 839.301C120.986 839.301 107.448 861.216 116.737 879.853L283.984 1214.04C294.304 1234.68 323.746 1234.68 334.066 1214.04Z" fill="black"/> +</g> +<path fill-rule="evenodd" clip-rule="evenodd" d="M564.945 446.881C564.945 495.81 525.182 535.634 476.192 535.634L141.759 535.634C92.8295 535.634 53.0059 495.871 53.0059 446.881C53.0059 433.222 56.2842 419.563 62.4155 407.239L229.662 73.0491C244.778 42.8172 275.192 23.9981 309.006 23.9981C342.82 23.9981 373.234 42.8172 388.35 73.0491L555.597 407.239C561.728 419.502 564.945 433.222 564.945 446.881Z" fill="white"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M283.949 100.193L116.702 434.383C107.414 453.02 120.952 474.935 141.774 474.935L476.207 474.935C497.029 474.935 510.567 453.02 501.279 434.383L334.032 100.193C323.712 79.5529 294.269 79.5529 283.949 100.193Z" fill="black"/> +</g> +<defs> +<filter id="filter0_d_429_21" x="0.34" y="0.598047" width="617.336" height="1371.56" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> +<feOffset dy="29.26"/> +<feGaussianBlur stdDeviation="26.33"/> +<feComposite in2="hardAlpha" operator="out"/> +<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.65 0"/> +<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_429_21"/> +<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_429_21" result="shape"/> +</filter> +</defs> +</svg> diff --git a/src/assets/cursors/tahoe/resizenortheast-1__50-49.svg b/src/assets/cursors/tahoe/resizenortheast-1__50-49.svg new file mode 100644 index 000000000..fc17ef52b --- /dev/null +++ b/src/assets/cursors/tahoe/resizenortheast-1__50-49.svg @@ -0,0 +1,22 @@ +<svg width="1258" height="1258" viewBox="0 0 1258 1258" fill="none" xmlns="http://www.w3.org/2000/svg"> +<g filter="url(#filter0_d_429_50)"> +<g opacity="0.5"> +<path fill-rule="evenodd" clip-rule="evenodd" d="M299.074 596.395C333.672 561.796 389.948 561.753 424.59 596.395L661.07 832.875C695.668 867.473 695.711 923.749 661.07 958.391C651.411 968.049 639.435 975.389 626.385 979.768L271.816 1097.81C239.75 1108.5 204.937 1100.3 181.027 1076.39C157.117 1052.48 148.918 1017.67 159.607 985.606L277.654 631.036C281.989 618.03 289.415 606.053 299.074 596.395Z" fill="white"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M252.623 1040.23L607.193 922.188C626.939 915.577 632.862 890.509 618.139 875.785L381.659 639.305C366.935 624.581 341.866 630.505 335.256 650.251L217.209 1004.82C209.912 1026.71 230.731 1047.53 252.623 1040.23Z" fill="black"/> +</g> +<path fill-rule="evenodd" clip-rule="evenodd" d="M958.344 661.023C923.746 695.622 867.469 695.665 832.828 661.023L596.348 424.543C561.75 389.945 561.707 333.669 596.348 299.027C606.007 289.369 617.983 282.029 631.033 277.65L985.602 159.604C1017.67 148.915 1052.48 157.114 1076.39 181.024C1100.3 204.933 1108.5 239.746 1097.81 271.812L979.764 626.382C975.429 639.388 968.002 651.365 958.344 661.023Z" fill="white"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M1004.79 217.183L650.225 335.23C630.479 341.841 624.556 366.909 639.279 381.633L875.759 618.113C890.483 632.837 915.552 626.913 922.162 607.167L1040.21 252.597C1047.51 230.705 1026.69 209.886 1004.79 217.183Z" fill="black"/> +</g> +<defs> +<filter id="filter0_d_429_50" x="102.328" y="131.592" width="1052.76" height="1052.75" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> +<feOffset dy="29.26"/> +<feGaussianBlur stdDeviation="26.33"/> +<feComposite in2="hardAlpha" operator="out"/> +<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.65 0"/> +<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_429_50"/> +<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_429_50" result="shape"/> +</filter> +</defs> +</svg> diff --git a/src/assets/cursors/tahoe/resizenortheastsouthwest-1__50-46.svg b/src/assets/cursors/tahoe/resizenortheastsouthwest-1__50-46.svg new file mode 100644 index 000000000..b3cd98839 --- /dev/null +++ b/src/assets/cursors/tahoe/resizenortheastsouthwest-1__50-46.svg @@ -0,0 +1,18 @@ +<svg width="619" height="618" viewBox="0 0 619 618" fill="none" xmlns="http://www.w3.org/2000/svg"> +<g filter="url(#filter0_d_384_72)"> +<path fill-rule="evenodd" clip-rule="evenodd" d="M199.122 261.869L326.968 389.68C336.252 398.964 341.239 411.299 340.976 424.388C340.747 436.92 335.793 448.665 326.968 457.489C321.719 462.673 315.256 466.708 308.203 469.037L116.548 532.91C99.1935 538.684 80.3955 534.255 67.4698 521.329C54.5441 508.404 50.1153 489.573 55.8892 472.252L119.763 280.601C122.092 273.548 126.095 267.052 131.311 261.836C150.011 243.17 180.422 243.17 199.122 261.869ZM551.327 37.4715C564.253 50.3969 568.682 69.1946 562.908 86.516L499.132 278.134C496.803 285.187 492.768 291.65 487.552 296.866C468.852 315.565 438.44 315.565 419.741 296.866L291.927 169.055C273.228 150.389 273.228 119.978 291.927 101.246C297.144 96.0297 303.639 92.0602 310.693 89.6982L502.282 25.8911C519.603 20.1173 538.401 24.5461 551.327 37.4715Z" fill="white"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M106.181 501.994L297.952 438.092C308.618 434.547 311.802 420.992 303.859 413.05L175.991 285.181C168.048 277.206 154.493 280.422 150.916 291.089L87.0469 482.859C83.1085 494.675 94.3659 505.932 106.181 501.994ZM512.854 57.0294L321.149 120.865C310.482 124.41 307.299 137.965 315.241 145.94L443.11 273.776C451.053 281.751 464.608 278.535 468.152 267.868L531.988 76.1637C535.927 64.3483 524.669 53.0909 512.854 57.0294Z" fill="black"/> +</g> +<defs> +<filter id="filter0_d_384_72" x="0.738438" y="0.000391006" width="617.32" height="617.32" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> +<feOffset dy="29.26"/> +<feGaussianBlur stdDeviation="26.33"/> +<feComposite in2="hardAlpha" operator="out"/> +<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.65 0"/> +<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_384_72"/> +<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_384_72" result="shape"/> +</filter> +</defs> +</svg> diff --git a/src/assets/cursors/tahoe/resizenorthsouth-1__50-49.svg b/src/assets/cursors/tahoe/resizenorthsouth-1__50-49.svg new file mode 100644 index 000000000..92332cdd0 --- /dev/null +++ b/src/assets/cursors/tahoe/resizenorthsouth-1__50-49.svg @@ -0,0 +1,18 @@ +<svg width="619" height="1372" viewBox="0 0 619 1372" fill="none" xmlns="http://www.w3.org/2000/svg"> +<g filter="url(#filter0_d_384_80)"> +<path fill-rule="evenodd" clip-rule="evenodd" d="M565.641 866.732C565.58 880.391 562.362 894.11 556.231 906.373L388.984 1240.75C373.868 1270.98 343.454 1289.8 309.641 1289.8C275.827 1289.8 245.413 1270.98 230.297 1240.75L63.0501 906.373C56.9188 894.11 53.6406 880.391 53.6406 866.732C53.6406 817.802 93.4642 778.039 142.394 778.039H476.888C501.17 778.039 523.935 787.631 540.69 805.114C556.777 821.748 565.641 843.663 565.641 866.732ZM565.586 446.441C565.586 495.371 525.823 535.194 476.833 535.194H142.4C93.4702 535.194 53.6466 495.431 53.6466 446.441C53.6466 432.782 56.9248 419.123 63.0562 406.8L230.303 72.6096C245.419 42.3777 275.833 23.5586 309.647 23.5586C343.46 23.5586 373.874 42.3777 388.99 72.6096L556.237 406.8C562.369 419.063 565.586 432.782 565.586 446.441Z" fill="white"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M334.667 1213.61L501.914 879.239C511.202 860.602 497.664 838.748 476.842 838.748H142.409C121.586 838.748 108.049 860.602 117.337 879.239L284.584 1213.61C294.904 1234.25 324.347 1234.25 334.667 1213.61ZM284.59 99.7536L117.343 433.944C108.055 452.581 121.592 474.496 142.415 474.496H476.848C497.67 474.496 511.208 452.581 501.92 433.944L334.673 99.7536C324.353 79.1134 294.91 79.1134 284.59 99.7536Z" fill="black"/> +</g> +<defs> +<filter id="filter0_d_384_80" x="0.980625" y="0.158594" width="617.32" height="1371.56" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> +<feOffset dy="29.26"/> +<feGaussianBlur stdDeviation="26.33"/> +<feComposite in2="hardAlpha" operator="out"/> +<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.65 0"/> +<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_384_80"/> +<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_384_80" result="shape"/> +</filter> +</defs> +</svg> diff --git a/src/assets/cursors/tahoe/resizenorthwest-1__50-49.svg b/src/assets/cursors/tahoe/resizenorthwest-1__50-49.svg new file mode 100644 index 000000000..da352ddd2 --- /dev/null +++ b/src/assets/cursors/tahoe/resizenorthwest-1__50-49.svg @@ -0,0 +1,22 @@ +<svg width="1258" height="1258" viewBox="0 0 1258 1258" fill="none" xmlns="http://www.w3.org/2000/svg"> +<g filter="url(#filter0_d_429_43)"> +<g opacity="0.5"> +<path fill-rule="evenodd" clip-rule="evenodd" d="M596.805 958.643C562.207 924.044 562.164 867.768 596.805 833.127L833.285 596.647C867.883 562.049 924.159 562.006 958.801 596.647C968.459 606.305 975.799 618.282 980.178 631.331L1098.22 985.901C1108.91 1017.97 1100.71 1052.78 1076.8 1076.69C1052.89 1100.6 1018.08 1108.8 986.016 1098.11L631.446 980.063C618.44 975.728 606.463 968.301 596.805 958.643Z" fill="white"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M1040.64 1005.09L922.598 650.524C915.988 630.778 890.919 624.854 876.195 639.578L639.715 876.058C624.992 890.782 630.915 915.85 650.661 922.461L1005.23 1040.51C1027.12 1047.81 1047.94 1026.99 1040.64 1005.09Z" fill="black"/> +</g> +<path fill-rule="evenodd" clip-rule="evenodd" d="M661.433 299.375C696.032 333.973 696.075 390.249 661.433 424.891L424.953 661.371C390.355 695.969 334.079 696.012 299.438 661.37C289.779 651.712 282.439 639.736 278.06 626.686L160.014 272.117C149.325 240.051 157.524 205.238 181.434 181.328C205.344 157.418 240.157 149.219 272.222 159.908L626.792 277.955C639.799 282.29 651.775 289.716 661.433 299.375Z" fill="white"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M217.593 252.924L335.64 607.493C342.251 627.239 367.32 633.163 382.043 618.44L618.523 381.96C633.247 367.236 627.323 342.167 607.577 335.557L253.007 217.51C231.115 210.212 210.296 231.032 217.593 252.924Z" fill="black"/> +</g> +<defs> +<filter id="filter0_d_429_43" x="102.742" y="131.889" width="1052.75" height="1052.76" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> +<feOffset dy="29.26"/> +<feGaussianBlur stdDeviation="26.33"/> +<feComposite in2="hardAlpha" operator="out"/> +<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.65 0"/> +<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_429_43"/> +<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_429_43" result="shape"/> +</filter> +</defs> +</svg> diff --git a/src/assets/cursors/tahoe/resizenorthwestsoutheast-1__51-46.svg b/src/assets/cursors/tahoe/resizenorthwestsoutheast-1__51-46.svg new file mode 100644 index 000000000..d621a37eb --- /dev/null +++ b/src/assets/cursors/tahoe/resizenorthwestsoutheast-1__51-46.svg @@ -0,0 +1,18 @@ +<svg width="619" height="618" viewBox="0 0 619 618" fill="none" xmlns="http://www.w3.org/2000/svg"> +<g filter="url(#filter0_d_384_84)"> +<path fill-rule="evenodd" clip-rule="evenodd" d="M419.675 261.869C438.375 243.17 468.786 243.17 487.486 261.836C492.702 267.052 496.705 273.548 499.034 280.601L562.908 472.252C568.682 489.573 564.253 508.404 551.327 521.329C538.401 534.255 519.603 538.684 502.249 532.91L310.594 469.037C303.541 466.708 297.078 462.673 291.829 457.489C283.004 448.665 278.05 436.92 277.821 424.388C277.558 411.299 282.545 398.964 291.829 389.68L419.675 261.869ZM67.4698 37.4715C80.3955 24.5461 99.1936 20.1173 116.515 25.8911L308.104 89.6982C315.158 92.0602 321.653 96.0297 326.869 101.246C345.569 119.978 345.569 150.389 326.869 169.055L199.056 296.866C180.356 315.565 149.945 315.565 131.245 296.866C126.029 291.65 121.994 285.187 119.665 278.134L55.8892 86.516C50.1153 69.1946 54.5441 50.3969 67.4698 37.4715Z" fill="white"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M512.641 501.777L320.954 437.904C310.292 434.361 307.11 420.812 315.049 412.873L442.862 285.062C450.801 277.09 464.35 280.305 467.926 290.967L531.767 482.651C535.704 494.461 524.452 505.713 512.641 501.777ZM106.141 57.0134L297.763 120.82C308.425 124.363 311.64 137.912 303.668 145.884L175.855 273.662C167.916 281.634 154.367 278.419 150.824 267.757L87.0153 76.1391C83.0786 64.3291 94.3311 53.0767 106.141 57.0134Z" fill="black"/> +</g> +<defs> +<filter id="filter0_d_384_84" x="0.738438" y="0.000391006" width="617.32" height="617.32" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> +<feOffset dy="29.26"/> +<feGaussianBlur stdDeviation="26.33"/> +<feComposite in2="hardAlpha" operator="out"/> +<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.65 0"/> +<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_384_84"/> +<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_384_84" result="shape"/> +</filter> +</defs> +</svg> diff --git a/src/assets/cursors/tahoe/resizeright-1__38-45.svg b/src/assets/cursors/tahoe/resizeright-1__38-45.svg new file mode 100644 index 000000000..0451c859f --- /dev/null +++ b/src/assets/cursors/tahoe/resizeright-1__38-45.svg @@ -0,0 +1,18 @@ +<svg width="618" height="637" viewBox="0 0 618 637" fill="none" xmlns="http://www.w3.org/2000/svg"> +<g filter="url(#filter0_d_384_60)"> +<path fill-rule="evenodd" clip-rule="evenodd" d="M128.85 24C170.681 24 204.699 58.0185 204.699 99.8496V479.098C204.699 520.929 170.681 554.947 128.85 554.947C87.0185 554.947 53 520.929 53 479.098V99.8496C53 58.0185 87.0185 24 128.85 24ZM331.99 145.921C340.03 145.921 348.146 147.855 355.39 151.458L535.987 241.832C553.85 250.745 564.962 268.683 565 288.67C565 308.694 553.888 326.67 536.025 335.583L355.428 425.957C348.184 429.598 340.068 431.532 331.99 431.532C303.053 431.532 279.54 407.981 279.54 379.044V198.371C279.54 183.997 285.229 170.572 295.582 160.635C305.405 151.154 318.375 145.921 331.99 145.921Z" fill="white"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M128.851 61.9238C149.785 61.9238 166.775 78.9141 166.775 99.8486V479.097C166.775 500.031 149.785 517.021 128.851 517.021C107.916 517.021 90.9258 500.031 90.9258 479.097V99.8486C90.9258 78.9141 107.916 61.9238 128.851 61.9238ZM519.036 275.744L338.476 185.407C328.843 180.591 317.466 187.607 317.466 198.377V379.051C317.466 389.86 328.843 396.876 338.476 392.059L519.036 301.684C529.731 296.337 529.731 281.091 519.036 275.744Z" fill="black"/> +</g> +<defs> +<filter id="filter0_d_384_60" x="0.34" y="0.6" width="617.32" height="636.267" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> +<feOffset dy="29.26"/> +<feGaussianBlur stdDeviation="26.33"/> +<feComposite in2="hardAlpha" operator="out"/> +<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.65 0"/> +<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_384_60"/> +<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_384_60" result="shape"/> +</filter> +</defs> +</svg> diff --git a/src/assets/cursors/tahoe/resizesouth-1__50-49.svg b/src/assets/cursors/tahoe/resizesouth-1__50-49.svg new file mode 100644 index 000000000..06d1630b1 --- /dev/null +++ b/src/assets/cursors/tahoe/resizesouth-1__50-49.svg @@ -0,0 +1,22 @@ +<svg width="618" height="1373" viewBox="0 0 618 1373" fill="none" xmlns="http://www.w3.org/2000/svg"> +<g filter="url(#filter0_d_429_2)"> +<g opacity="0.5"> +<path fill-rule="evenodd" clip-rule="evenodd" d="M564.945 446.883C564.945 495.812 525.183 535.636 476.192 535.636H141.759C92.8296 535.636 53.006 495.873 53.006 446.883C53.006 433.224 56.2842 419.565 62.4156 407.241L229.662 73.051C244.778 42.8191 275.192 24 309.006 24C342.82 24 373.234 42.8191 388.35 73.051L555.597 407.241C561.728 419.504 564.945 433.224 564.945 446.883Z" fill="white"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M283.949 100.195L116.702 434.385C107.414 453.022 120.952 474.937 141.774 474.937H476.207C497.03 474.937 510.567 453.022 501.279 434.385L334.032 100.195C323.712 79.5548 294.269 79.5548 283.949 100.195Z" fill="black"/> +</g> +<path fill-rule="evenodd" clip-rule="evenodd" d="M53.0703 867.356C53.0703 818.426 92.8332 778.602 141.823 778.602L476.257 778.602C525.186 778.602 565.01 818.365 565.01 867.356C565.01 881.015 561.731 894.674 555.6 906.997L388.353 1241.19C373.237 1271.42 342.823 1290.24 309.01 1290.24C275.196 1290.24 244.782 1271.42 229.666 1241.19L62.4191 906.997C56.2877 894.734 53.0703 881.015 53.0703 867.356Z" fill="white"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M334.066 1214.04L501.313 879.853C510.602 861.216 497.064 839.301 476.242 839.301L141.808 839.301C120.986 839.301 107.448 861.216 116.737 879.853L283.984 1214.04C294.304 1234.68 323.746 1234.68 334.066 1214.04Z" fill="black"/> +</g> +<defs> +<filter id="filter0_d_429_2" x="0.34" y="0.6" width="617.336" height="1371.56" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> +<feOffset dy="29.26"/> +<feGaussianBlur stdDeviation="26.33"/> +<feComposite in2="hardAlpha" operator="out"/> +<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.65 0"/> +<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_429_2"/> +<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_429_2" result="shape"/> +</filter> +</defs> +</svg> diff --git a/src/assets/cursors/tahoe/resizesoutheast-1__50-49.svg b/src/assets/cursors/tahoe/resizesoutheast-1__50-49.svg new file mode 100644 index 000000000..79aa4ab48 --- /dev/null +++ b/src/assets/cursors/tahoe/resizesoutheast-1__50-49.svg @@ -0,0 +1,22 @@ +<svg width="1258" height="1258" viewBox="0 0 1258 1258" fill="none" xmlns="http://www.w3.org/2000/svg"> +<g filter="url(#filter0_d_429_57)"> +<g opacity="0.5"> +<path fill-rule="evenodd" clip-rule="evenodd" d="M661.023 299.074C695.622 333.672 695.665 389.948 661.023 424.59L424.543 661.07C389.945 695.668 333.669 695.711 299.027 661.07C289.369 651.411 282.029 639.435 277.65 626.385L159.604 271.816C148.915 239.75 157.114 204.937 181.024 181.027C204.933 157.117 239.746 148.918 271.812 159.607L626.382 277.654C639.388 281.989 651.365 289.416 661.023 299.074Z" fill="white"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M217.183 252.623L335.23 607.193C341.841 626.939 366.909 632.862 381.633 618.139L618.113 381.659C632.837 366.935 626.913 341.866 607.167 335.256L252.597 217.209C230.705 209.912 209.886 230.731 217.183 252.623Z" fill="black"/> +</g> +<path fill-rule="evenodd" clip-rule="evenodd" d="M596.395 958.342C561.796 923.744 561.753 867.467 596.395 832.826L832.875 596.346C867.473 561.748 923.749 561.705 958.391 596.346C968.049 606.005 975.389 617.981 979.768 631.031L1097.81 985.6C1108.5 1017.67 1100.3 1052.48 1076.39 1076.39C1052.48 1100.3 1017.67 1108.5 985.606 1097.81L631.036 979.762C618.029 975.427 606.053 968 596.395 958.342Z" fill="white"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M1040.23 1004.79L922.188 650.223C915.577 630.477 890.509 624.554 875.785 639.277L639.305 875.757C624.581 890.481 630.505 915.55 650.251 922.16L1004.82 1040.21C1026.71 1047.5 1047.53 1026.69 1040.23 1004.79Z" fill="black"/> +</g> +<defs> +<filter id="filter0_d_429_57" x="102.332" y="131.588" width="1052.75" height="1052.76" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> +<feOffset dy="29.26"/> +<feGaussianBlur stdDeviation="26.33"/> +<feComposite in2="hardAlpha" operator="out"/> +<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.65 0"/> +<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_429_57"/> +<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_429_57" result="shape"/> +</filter> +</defs> +</svg> diff --git a/src/assets/cursors/tahoe/resizesouthwest-1__50-49.svg b/src/assets/cursors/tahoe/resizesouthwest-1__50-49.svg new file mode 100644 index 000000000..5ddb87808 --- /dev/null +++ b/src/assets/cursors/tahoe/resizesouthwest-1__50-49.svg @@ -0,0 +1,22 @@ +<svg width="1258" height="1258" viewBox="0 0 1258 1258" fill="none" xmlns="http://www.w3.org/2000/svg"> +<g filter="url(#filter0_d_429_64)"> +<g opacity="0.5"> +<path fill-rule="evenodd" clip-rule="evenodd" d="M958.344 661.023C923.746 695.622 867.469 695.665 832.828 661.023L596.348 424.543C561.75 389.945 561.707 333.669 596.348 299.027C606.007 289.369 617.983 282.029 631.033 277.65L985.602 159.604C1017.67 148.915 1052.48 157.114 1076.39 181.024C1100.3 204.933 1108.5 239.746 1097.81 271.812L979.764 626.382C975.429 639.388 968.002 651.365 958.344 661.023Z" fill="white"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M1004.79 217.183L650.225 335.23C630.479 341.841 624.556 366.909 639.279 381.633L875.759 618.113C890.483 632.837 915.552 626.913 922.162 607.167L1040.21 252.597C1047.51 230.705 1026.69 209.886 1004.79 217.183Z" fill="black"/> +</g> +<path fill-rule="evenodd" clip-rule="evenodd" d="M299.074 596.395C333.672 561.796 389.949 561.753 424.59 596.395L661.07 832.875C695.668 867.473 695.711 923.749 661.07 958.391C651.411 968.049 639.435 975.389 626.385 979.768L271.816 1097.81C239.75 1108.5 204.937 1100.3 181.027 1076.39C157.117 1052.48 148.918 1017.67 159.607 985.606L277.654 631.036C281.989 618.029 289.416 606.053 299.074 596.395Z" fill="white"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M252.623 1040.23L607.193 922.188C626.939 915.577 632.862 890.509 618.139 875.785L381.659 639.305C366.935 624.581 341.866 630.505 335.256 650.251L217.209 1004.82C209.912 1026.71 230.731 1047.53 252.623 1040.23Z" fill="black"/> +</g> +<defs> +<filter id="filter0_d_429_64" x="102.328" y="131.592" width="1052.76" height="1052.75" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> +<feOffset dy="29.26"/> +<feGaussianBlur stdDeviation="26.33"/> +<feComposite in2="hardAlpha" operator="out"/> +<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.65 0"/> +<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_429_64"/> +<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_429_64" result="shape"/> +</filter> +</defs> +</svg> diff --git a/src/assets/cursors/tahoe/resizeup-1__50-57.svg b/src/assets/cursors/tahoe/resizeup-1__50-57.svg new file mode 100644 index 000000000..71c4ebc17 --- /dev/null +++ b/src/assets/cursors/tahoe/resizeup-1__50-57.svg @@ -0,0 +1,18 @@ +<svg width="618" height="600" viewBox="0 0 618 600" fill="none" xmlns="http://www.w3.org/2000/svg"> +<g filter="url(#filter0_d_384_68)"> +<path fill-rule="evenodd" clip-rule="evenodd" d="M53 444.586C53 404.248 85.8046 371.443 126.143 371.443H491.857C532.195 371.443 565 404.248 565 444.586C565 484.924 532.195 517.729 491.857 517.729H126.143C85.8046 517.729 53 484.924 53 444.586ZM170.57 248.695C170.57 240.942 172.435 233.115 175.909 226.13L263.059 51.9771C271.653 34.752 288.952 24.0366 308.225 24C327.534 24 344.869 34.7154 353.464 51.9406L440.613 226.094C444.124 233.079 445.989 240.905 445.989 248.695C445.989 276.599 423.278 299.273 395.374 299.273H221.148C207.288 299.273 194.341 293.787 184.76 283.803C175.617 274.331 170.57 261.824 170.57 248.695Z" fill="white"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M89.5703 444.586C89.5703 424.399 105.954 408.015 126.142 408.015H491.856C512.043 408.015 528.427 424.399 528.427 444.586C528.427 464.774 512.043 481.158 491.856 481.158H126.142C105.954 481.158 89.5703 464.774 89.5703 444.586ZM295.76 68.3247L208.647 242.441C204.002 251.73 210.768 262.702 221.154 262.702H395.381C405.803 262.702 412.569 251.73 407.925 242.441L320.775 68.3247C315.618 58.0116 300.917 58.0116 295.76 68.3247Z" fill="black"/> +</g> +<defs> +<filter id="filter0_d_384_68" x="0.34" y="0.6" width="617.32" height="599.049" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> +<feOffset dy="29.26"/> +<feGaussianBlur stdDeviation="26.33"/> +<feComposite in2="hardAlpha" operator="out"/> +<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.65 0"/> +<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_384_68"/> +<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_384_68" result="shape"/> +</filter> +</defs> +</svg> diff --git a/src/assets/cursors/tahoe/resizeupdown-1__51-48.svg b/src/assets/cursors/tahoe/resizeupdown-1__51-48.svg new file mode 100644 index 000000000..68b44dec3 --- /dev/null +++ b/src/assets/cursors/tahoe/resizeupdown-1__51-48.svg @@ -0,0 +1,18 @@ +<svg width="618" height="948" viewBox="0 0 618 948" fill="none" xmlns="http://www.w3.org/2000/svg"> +<g filter="url(#filter0_d_384_64)"> +<path fill-rule="evenodd" clip-rule="evenodd" d="M565 444.571C565 484.816 532.195 517.544 491.857 517.544H126.143C85.8046 517.544 53 484.816 53 444.571C53 404.327 85.8046 371.599 126.143 371.599H491.857C532.195 371.599 565 404.327 565 444.571ZM447.43 248.172C447.43 276.012 424.756 298.633 396.852 298.633H222.626C194.722 298.633 172.011 276.012 172.011 248.172C172.011 240.401 173.876 232.556 177.423 225.587L264.536 51.9121C273.131 34.6905 290.466 24 309.775 24C329.048 24.0365 346.347 34.727 354.978 51.9486L442.091 225.624C445.565 232.593 447.43 240.401 447.43 248.172ZM447.43 640.97C447.43 648.706 445.565 656.514 442.091 663.483L354.941 837.231C346.347 854.416 329.048 865.106 309.775 865.143C290.466 865.143 273.131 854.452 264.536 837.267L177.387 663.519C173.876 656.55 172.011 648.742 172.011 640.97C172.011 613.131 194.722 590.51 222.626 590.51H396.852C410.712 590.51 423.659 595.983 433.24 605.944C442.383 615.394 447.43 627.872 447.43 640.97Z" fill="white"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M528.427 444.571C528.427 464.711 512.043 481.057 491.856 481.057H126.142C105.954 481.057 89.5703 464.711 89.5703 444.571C89.5703 424.43 105.954 408.084 126.142 408.084H491.856C512.043 408.084 528.427 424.43 528.427 444.571ZM297.205 68.2208L210.091 241.933C205.447 251.2 212.213 262.146 222.599 262.146H396.825C407.248 262.146 414.014 251.2 409.369 241.933L322.219 68.2208C317.063 57.9316 302.361 57.9316 297.205 68.2208ZM322.238 820.921L409.351 647.209C413.995 637.941 407.23 626.995 396.843 626.995H222.617C212.194 626.995 205.429 637.941 210.073 647.209L297.223 820.921C302.379 831.21 317.081 831.21 322.238 820.921Z" fill="black"/> +</g> +<defs> +<filter id="filter0_d_384_64" x="0.34" y="0.6" width="617.32" height="946.463" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> +<feOffset dy="29.26"/> +<feGaussianBlur stdDeviation="26.33"/> +<feComposite in2="hardAlpha" operator="out"/> +<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.65 0"/> +<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_384_64"/> +<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_384_64" result="shape"/> +</filter> +</defs> +</svg> diff --git a/src/assets/cursors/tahoe/resizewest-1__50-50.svg b/src/assets/cursors/tahoe/resizewest-1__50-50.svg new file mode 100644 index 000000000..6b3537ad5 --- /dev/null +++ b/src/assets/cursors/tahoe/resizewest-1__50-50.svg @@ -0,0 +1,27 @@ +<svg width="1000" height="1000" viewBox="0 0 1000 1000" fill="none" xmlns="http://www.w3.org/2000/svg"> +<g clip-path="url(#clip0_431_21)"> +<g filter="url(#filter0_d_431_21)"> +<g opacity="0.5"> +<path fill-rule="evenodd" clip-rule="evenodd" d="M649.427 681.869C614.649 681.869 586.344 653.607 586.344 618.786V381.083C586.344 346.305 614.606 318 649.427 318C659.136 318 668.844 320.33 677.603 324.688L915.136 443.561C936.623 454.305 950 475.922 950 499.956C950 523.99 936.623 545.607 915.136 556.351L677.603 675.225C668.887 679.582 659.136 681.869 649.427 681.869Z" fill="white"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M895.841 482.145L658.311 363.271C645.065 356.67 629.488 366.292 629.488 381.092V618.795C629.488 633.595 645.065 643.217 658.311 636.616L895.841 517.742C910.511 510.407 910.511 489.48 895.841 482.145Z" fill="black"/> +</g> +<path fill-rule="evenodd" clip-rule="evenodd" d="M350.572 318.047C385.349 318.047 413.654 346.309 413.654 381.129V618.833C413.654 653.611 385.392 681.916 350.572 681.916C340.863 681.916 331.155 679.586 322.395 675.228L84.8639 556.354C63.376 545.611 50 523.993 50 499.96C50 475.926 63.376 454.309 84.8639 443.565L322.395 324.692C331.111 320.334 340.863 318.047 350.572 318.047Z" fill="white"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M104.159 517.767L341.69 636.641C354.937 643.242 370.513 633.621 370.513 618.821V381.117C370.513 366.317 354.937 356.695 341.69 363.297L104.159 482.17C89.4887 489.506 89.4887 510.432 104.159 517.767Z" fill="black"/> +</g> +</g> +<defs> +<filter id="filter0_d_431_21" x="-2.66" y="294.6" width="1005.32" height="469.236" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> +<feOffset dy="29.26"/> +<feGaussianBlur stdDeviation="26.33"/> +<feComposite in2="hardAlpha" operator="out"/> +<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.65 0"/> +<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_431_21"/> +<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_431_21" result="shape"/> +</filter> +<clipPath id="clip0_431_21"> +<rect width="1000" height="1000" fill="white"/> +</clipPath> +</defs> +</svg> diff --git a/src/assets/cursors/tahoe/screenshotselection-1__50-50.svg b/src/assets/cursors/tahoe/screenshotselection-1__50-50.svg new file mode 100644 index 000000000..5717500b0 --- /dev/null +++ b/src/assets/cursors/tahoe/screenshotselection-1__50-50.svg @@ -0,0 +1,92 @@ +<?xml version="1.0" encoding="UTF-8" standalone="yes"?> +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="767.314286" height="746" viewBox="0 0 767.314286 746"> + <defs> + <filter id="filter-remove-color" x="0%" y="0%" width="100%" height="100%"> + <feColorMatrix color-interpolation-filters="sRGB" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"/> + </filter> + <mask id="mask-0"> + <g filter="url(#filter-remove-color)"> + <rect x="-76.731429" y="-74.6" width="920.777143" height="895.2" fill="rgb(0%, 0%, 0%)" fill-opacity="0.15"/> + </g> + </mask> + <mask id="mask-1"> + <g filter="url(#filter-remove-color)"> + <rect x="-76.731429" y="-74.6" width="920.777143" height="895.2" fill="rgb(0%, 0%, 0%)" fill-opacity="0.3"/> + </g> + </mask> + <mask id="mask-2"> + <g filter="url(#filter-remove-color)"> + <rect x="-76.731429" y="-74.6" width="920.777143" height="895.2" fill="rgb(0%, 0%, 0%)" fill-opacity="0.85"/> + </g> + </mask> + <mask id="mask-3"> + <g filter="url(#filter-remove-color)"> + <rect x="-76.731429" y="-74.6" width="920.777143" height="895.2" fill="rgb(0%, 0%, 0%)" fill-opacity="0.85"/> + </g> + </mask> + <mask id="mask-4"> + <g filter="url(#filter-remove-color)"> + <rect x="-76.731429" y="-74.6" width="920.777143" height="895.2" fill="rgb(0%, 0%, 0%)" fill-opacity="0.85"/> + </g> + </mask> + <mask id="mask-5"> + <g filter="url(#filter-remove-color)"> + <rect x="-76.731429" y="-74.6" width="920.777143" height="895.2" fill="rgb(0%, 0%, 0%)" fill-opacity="0.85"/> + </g> + </mask> + <clipPath id="clip-0"> + <rect x="0" y="0" width="768" height="746"/> + </clipPath> + <clipPath id="clip-1"> + <rect x="0" y="0" width="768" height="746"/> + </clipPath> + <clipPath id="clip-2"> + <rect x="0" y="0" width="768" height="746"/> + </clipPath> + <clipPath id="clip-3"> + <rect x="0" y="0" width="768" height="746"/> + </clipPath> + <clipPath id="clip-4"> + <rect x="0" y="0" width="768" height="746"/> + </clipPath> + <clipPath id="clip-5"> + <rect x="0" y="0" width="768" height="746"/> + </clipPath> + <g id="source-6" clip-path="url(#clip-0)"> + <path fill-rule="evenodd" fill="rgb(13.72549%, 12.156863%, 12.156863%)" fill-opacity="1" d="M 494.976562 373 C 494.976562 437.730469 442.476562 490.230469 377.746094 490.230469 C 312.992188 490.230469 260.519531 437.730469 260.519531 373 C 260.519531 308.246094 312.992188 255.769531 377.746094 255.769531 C 442.476562 255.769531 494.976562 308.246094 494.976562 373 Z M 494.976562 373 "/> + </g> + <g id="source-10" clip-path="url(#clip-1)"> + <path fill="none" stroke-width="1" stroke-linecap="butt" stroke-linejoin="miter" stroke="rgb(13.72549%, 12.156863%, 12.156863%)" stroke-opacity="1" stroke-miterlimit="4" d="M 23.222761 17.5 C 23.222761 20.536952 20.759624 23.000089 17.722672 23.000089 C 14.68462 23.000089 12.222766 20.536952 12.222766 17.5 C 12.222766 14.461948 14.68462 11.999911 17.722672 11.999911 C 20.759624 11.999911 23.222761 14.461948 23.222761 17.5 Z M 23.222761 17.5 " transform="matrix(21.314286, 0, 0, 21.314286, 0, 0)"/> + </g> + <g id="source-14" clip-path="url(#clip-2)"> + <path fill-rule="evenodd" fill="rgb(13.72549%, 12.156863%, 12.156863%)" fill-opacity="1" d="M 132.632812 383.65625 L 367.132812 383.65625 L 367.132812 362.34375 L 132.632812 362.34375 Z M 132.632812 383.65625 "/> + </g> + <g id="source-18" clip-path="url(#clip-3)"> + <path fill-rule="evenodd" fill="rgb(13.72549%, 12.156863%, 12.156863%)" fill-opacity="1" d="M 388.402344 383.65625 L 622.859375 383.65625 L 622.859375 362.34375 L 388.402344 362.34375 Z M 388.402344 383.65625 "/> + </g> + <g id="source-22" clip-path="url(#clip-4)"> + <path fill-rule="evenodd" fill="rgb(13.72549%, 12.156863%, 12.156863%)" fill-opacity="1" d="M 367.089844 362.34375 L 388.402344 362.34375 L 388.402344 127.886719 L 367.089844 127.886719 Z M 367.089844 362.34375 "/> + </g> + <g id="source-26" clip-path="url(#clip-5)"> + <path fill-rule="evenodd" fill="rgb(13.72549%, 12.156863%, 12.156863%)" fill-opacity="1" d="M 367.089844 618.113281 L 388.402344 618.113281 L 388.402344 383.65625 L 367.089844 383.65625 Z M 367.089844 618.113281 "/> + </g> + </defs> + <g mask="url(#mask-0)"> + <use xlink:href="#source-6"/> + </g> + <g mask="url(#mask-1)"> + <use xlink:href="#source-10"/> + </g> + <g mask="url(#mask-2)"> + <use xlink:href="#source-14"/> + </g> + <g mask="url(#mask-3)"> + <use xlink:href="#source-18"/> + </g> + <g mask="url(#mask-4)"> + <use xlink:href="#source-22"/> + </g> + <g mask="url(#mask-5)"> + <use xlink:href="#source-26"/> + </g> +</svg> \ No newline at end of file diff --git a/src/assets/cursors/tahoe/screenshotwindow-1__50-50.svg b/src/assets/cursors/tahoe/screenshotwindow-1__50-50.svg new file mode 100644 index 000000000..25122f5c6 --- /dev/null +++ b/src/assets/cursors/tahoe/screenshotwindow-1__50-50.svg @@ -0,0 +1,7 @@ +<?xml version="1.0" encoding="UTF-8" standalone="yes"?> +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="768" height="768" viewBox="0 0 768 768"> + <defs> + <image id="source-25" x="0" y="0" width="768" height="768" xlink:href="data:image/png;base64,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svbtt98m7lKgbdu2NR0CDGvXrp3pEELZsmWLli5dajoMAAWAAgBA1mzb1tdff206jFA6dOhgOgQY1r59e9MhhLJo0SKVl5ebDgNAAaAAABCJBQsWmA4hlC5dupgOAYZ169bNdAihLFy40HQIAAoEBQCASCQtOWnXrp123nln02HAkDp16uiggw4yHUYoSSuyAeQvCgAAkUhaclJaWqrOnTubDgOGdOnSRdWqVTMdRihJK7IB5C8KAACR+Oyzz0yHENqxxx5rOgQYcvzxx5sOIbRZs2aZDgFAgaAAABCJTz/91HQIoZ1wwgmmQ4AhSfy/nzlzpukQABQICgAAkVi8eLFWrlxpOoxQDjjgAO27776mw0CONW3aVAcccIDpMEJZs2YNQ4AARIYCAEAkbNtOZAvlmWeeaToE5NhZZ51lOoTQZs6cKdu2TYcBoEBQAACITBILgF69epkOATmWxP/zJG5bAPIXBQCAyEybNs10CKF16NBBLVq0MB0GcqR58+bq2LGj6TBC+/jjj02HAKCAUAAAiMz7779vOoSMXHjhhaZDQI706dPHdAgZee+990yHAKCAUAAAiMy8efO0bNky02GE1qdPH5WVlZkOAzGrVq2aLrjgAtNhhLZixQrNnTvXdBgACggFAIDI2LatSZMmmQ4jtIYNG+r//u//TIeBmPXo0UN77bWX6TBCmzhxoioqKkyHAaCAUAAAiNQHH3xgOoSMXHPNNaZDQMz69etnOoSMJHVoHYD8RQEAIFJJHavctWtXde7c2XQYiMkhhxyiI444wnQYGZk4caLpEAAUGAoAAJGaPHmyfvzxR9NhZOS6664zHQJiMmDAANMhZGTFihWaPHmy6TAAFBgKAACRKi8v1xtvvGE6jIycdtppat++vekwELG2bdvqtNNOMx1GRl5//XVt3brVdBgACgwFAIDITZgwwXQIGbEsS7fccovpMBCx2267TSUlyTzcJXVbApDfkrlHBJDXxo8fL9u2TYeRkZNPPplzAQrI4YcfntgrPNm2rddee810GAAKEAUAgMh99913mj59uukwMmJZloYPH57YFmP8zLIs3XPPPbIsy3QoGZkxY4aWLl1qOgwABYgjHIBYPPvss6ZDyFiHDh3Uu3dv02EgS71791bXrl1Nh5GxZ555xnQIAAoUBQCAWIwbNy6xw4AkaciQIapfv77pMJChevXqaciQIabDyAoFAIC4UAAAiMVXX32ljz/+2HQYGdtzzz315z//2XQYyNDQoUPVqFEj02Fk7OOPP9b8+fNNhwGgQFEAAIjNuHHjTIeQlQsvvFDHHHOM6TAQ0tFHH62+ffuaDiMrSd92AOQ3CgAAsXn66acTPQzIsiw9/vjj2vX/27v3+Cuqev/j7/lyVRENJEK8m2ARmuL9Cz2E6GSWnfKalpmIx9RfRh5TKn+pdU7Iw/QXifbwEvyU+qWQx+p4ygumIeBBjxIIhaaClwQTyBSK696/P2DrsL5rrVmz98yefXk9H4/92DNrZs+svb/fvefzWWvNTL9+RVcFgXbbbTdNnz69aU/8lbZd/YfhPwDyRAIAIDcvvfSSHnnkkaKrUZO99tpLt956a9HVQKCbb75Z++23X9HVqMmjjz6q5cuXF10NAC2MBABArm677baiq1CzU045RePGjSu6Gkhw7rnn6qyzziq6GjVrhe8MgMZGAgAgV//xH/+hN954o+hq1Oymm27SEUccUXQ14HDooYdq6tSpRVejZmvXrtW9995bdDUAtDgSAAC52rRpk2bMmFF0NWrWu3dv/fznP9cee+xRdFVg6N+/v+69917tvPPORVelZjNmzNCGDRuKrgaAFkcCACB3t912W1OfDFyx77776t5771Xv3r2Lrgq222mnnfSLX/xC+++/f9FVycTtt99edBUAtAESAAC5W7Zsme6///6iq5GJkSNH6o477lBHBz+fRevWrZt++tOfauTIkUVXJRMPPviglixZUnQ1ALQBjmAA6uL6668vugqZOf3007lJWMGiKNJtt92mz372s0VXJTPf//73i64CgDZBAgCgLh5++GEtXLiw6Gpk5tJLL9XVV19ddDXa1qRJk3TuuecWXY3MPPPMM5o9e3bR1QDQJkgAANTNDTfcUHQVMnXVVVdp4sSJRVej7Vx++eW64ooriq5Gpq677rqWOE8GQHMgAQBQN3fffbdefvnloquRqUmTJunaa68tuhptIYoiXXXVVZo8eXLRVcnUK6+8orvvvrvoagBoIyQAAOpm8+bN+t73vld0NTJ3xRVX6Prrr1cURUVXpWV1795d06ZNa8lhV//+7/+uTZs2FV0NAG2EBABAXU2bNk0vvvhi0dXI3KWXXqpZs2Zpp512KroqLad37966++679aUvfanoqmTupZde0vTp04uuBoA2QwIAoK42b96sf/u3fyu6Grk45ZRT9NBDD3GzsAz1799fDz/8sE4++eSiq5KLa665htZ/AHVHAgCg7u688049++yzRVcjF52dnVq4cKGOPvrooqvS9A477DA9+eSTOu6444quSi6ef/75lrhLNoDmQwIAoO62bt2qb3/720VXIzd77bWXHn30UZ1//vlFV6VpnXfeeZo/f37L3OHX5sorr9SWLVuKrgaANkQCAKAQM2fO1Jw5c4quRm569+6tW2+9Vffcc4/69+9fdHWaxm677aaf/OQnuv3229W7d++iq5Ob+fPna+bMmUVXA0CbIgEAUJgJEyaoVCoVXY1cnXzyyVq4cKE+9rGPFV2VhvfRj35Uixcv1uc///miq5KrUqmkCRMmcN1/AIUhAQBQmIULF+qOO+4ouhq523vvvfXggw9q5syZnCBssfvuu+uWW27RQw89pH322afo6uRu+vTpevLJJ4uuBoA2RgIAoFDf/OY39fbbbxddjbo47bTTtGTJEo0bN04dHfz8dnR06Nxzz9WyZcv0L//yL21xH4W33npLV155ZdHVANDmOAIBKNSqVavaKiAaOHCgfvzjH+uJJ57QqFGjiq5OYTo7O7VgwQJNmzZNAwcOLLo6dfOtb31Lq1atKroaANocCQCAwk2dOlXz5s0ruhp1NWLECM2ZM0cPPfSQjjzyyKKrUzfDhw/XzJkz9dhjj+mII44oujp1tWDBAv3oRz8quhoAQAIAoHilUknjx4/Xxo0bi65K3Y0dO1YLFizQr371K40cObLo6uSms7NTv/zlL7Vo0SKddtppbTHcJ27Tpk0677zztHXr1qKrAgAkAAAaw7Jly3TttdcWXY1CRFGkk046SY899pj++7//W2eccYZ69uxZdLVq1rNnT5122mmaP3++5s6dq09/+tNtF/hXTJo0SUuXLi26GgAgiQQAQAOZNGmSFi9eXHQ1CnX00Ufrrrvu0quvvqrrr79ew4YNK7pKqX3wgx/Uddddp1deeUUzZ87UscceW3SVCvXMM89o0qRJRVcDAN5BAgCgYWzcuFFnnnmm/vGPfxRdlcINGDBAl156qZYsWaLFixfryiuv1NChQ4uultPQoUP1rW99S4sXL9bSpUt12WWX6b3vfW/R1Srcxo0b9YUvfKEth7cBaFzdi64AAMT94Q9/0OWXX64bb7yx6Ko0jOHDh2v48OH67ne/qxdffFG/+c1v9MADD2ju3Ln661//Wkid3vOe92jkyJH6+Mc/rhNOOEEHHnhgIfVodF//+tfbvlcLQOMhAQDQcG666Sb90z/9k0466aSiq9JwDjjgAF188cW6+OKLVSqV9Mc//lHz5s3T008/rWeeeUZLlizRW2+9lek++/btq2HDhumQQw7RYYcdps7OTn3wgx/kXgYJHnjgAU2dOrXoagBAFyQAABpOuVzW+PHjtWjRIr3vfe8rujoNq6OjQ8OGDetynsBrr72m5cuXa8WKFXrllVf0xhtvaM2aNVqzZo02bNigzZs3a926dZKkPn36qEePHurdu7f69++v/v37a8CAAdprr720//77a//999egQYPa9uTdaq1atUrnnHOOyuVy0VUBgC5IAAA0pL/85S86+eST9eijj7bEFXHqac8999See+6pzs7OoqvSlrZs2aLPfe5zev3114uuCgBY0X8LoGE9/vjjuvzyy4uuBpDKZZddpt/97ndFVwMAnEgAADS0KVOm6I477ii6GkCQu+66S1OmTCm6GgDgRQIAoOFdfPHFXEkFDe+ZZ57R+PHji64GACQiAQDQ8NavX68TTzxRr776atFVAaxWrVqlk046SevXry+6KgCQiAQAQFP485//rH/+539+5+o1QKP4xz/+oc985jN66aWXiq4KAAQhAQDQNJ5++mmdccYZ2rJlS9FVASRJpVJJZ511lhYsWFB0VQAgGAkAgKby61//WhMmTCi6GoAkacKECfrFL35RdDUAIBUSAABN56abbtK3v/3toquBNnf11VfrxhtvLLoaAJAaCQCApvTd735X1157bdHVQJuaMmWKrrnmmqKrAQBVIQEA0LS+8Y1v6IYbbii6Gmgz06dP19e+9rWiqwEAVSMBANDULrvsMv34xz8uuhpoE3fccYfGjx+vcrlcdFUAoGokAACaWrlc1vnnn68f/OAHRVcFLe6WW27RuHHjVCqViq4KANSEBABA0yuXy/ra176m73znO0VXBS1q6tSpuvDCCwn+AbQEEgAALeOqq67SxIkTi64GWszkyZP1la98hWE/AFoGCQCAljJ58mRdfPHF2rp1a9FVQZPbunWrvvKVr5BUAmg5JAAAWs7NN9+sE088UW+99VbRVUGTWr9+vU4++WRNnTq16KoAQOZIAAC0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"/> + </defs> + <use xlink:href="#source-25"/> +</svg> \ No newline at end of file diff --git a/src/assets/cursors/tahoe/zoomin-1__42-39.svg b/src/assets/cursors/tahoe/zoomin-1__42-39.svg new file mode 100644 index 000000000..7a026cbb8 --- /dev/null +++ b/src/assets/cursors/tahoe/zoomin-1__42-39.svg @@ -0,0 +1,21 @@ +<svg width="618" height="618" viewBox="0 0 618 618" fill="none" xmlns="http://www.w3.org/2000/svg"> +<g filter="url(#filter0_d_384_96)"> +<path fill-rule="evenodd" clip-rule="evenodd" d="M469.163 232.082C469.163 347.002 376.002 440.163 261.082 440.163C146.161 440.163 53 347.002 53 232.082C53 117.161 146.161 24 261.082 24C376.002 24 469.163 117.161 469.163 232.082Z" fill="black"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M417.238 232.082C417.238 318.317 347.323 388.232 261.088 388.232C174.823 388.232 104.938 318.317 104.938 232.082C104.938 145.847 174.823 75.9316 261.088 75.9316C347.323 75.9316 417.238 145.847 417.238 232.082Z" fill="white"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M261.081 336.123C244.673 336.123 231.355 322.805 231.355 306.397V157.767C231.355 141.358 244.673 128.041 261.081 128.041C277.49 128.041 290.807 141.358 290.807 157.767V306.397C290.807 322.805 277.49 336.123 261.081 336.123Z" fill="black"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M157.039 232.081C157.039 215.673 170.356 202.355 186.765 202.355H335.395C351.803 202.355 365.121 215.673 365.121 232.081C365.121 248.49 351.803 261.807 335.395 261.807H186.765C170.356 261.807 157.039 248.49 157.039 232.081Z" fill="black"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M556.305 527.307C567.899 515.714 567.899 496.868 556.305 485.275L415.345 344.314L373.312 386.347L514.273 527.307C525.866 538.9 544.712 538.9 556.305 527.307Z" fill="black"/> +</g> +<defs> +<filter id="filter0_d_384_96" x="0.34" y="0.6" width="617.32" height="617.322" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> +<feOffset dy="29.26"/> +<feGaussianBlur stdDeviation="26.33"/> +<feComposite in2="hardAlpha" operator="out"/> +<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.65 0"/> +<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_384_96"/> +<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_384_96" result="shape"/> +</filter> +</defs> +</svg> diff --git a/src/assets/cursors/tahoe/zoomout-1__43-39.svg b/src/assets/cursors/tahoe/zoomout-1__43-39.svg new file mode 100644 index 000000000..d220d21f5 --- /dev/null +++ b/src/assets/cursors/tahoe/zoomout-1__43-39.svg @@ -0,0 +1,19 @@ +<svg width="618" height="618" viewBox="0 0 618 618" fill="none" xmlns="http://www.w3.org/2000/svg"> +<g filter="url(#filter0_d_384_103)"> +<path fill-rule="evenodd" clip-rule="evenodd" d="M469.164 232.082C469.164 347.002 376.002 440.164 261.082 440.164C146.161 440.164 53 347.002 53 232.082C53 117.161 146.161 24 261.082 24C376.002 24 469.164 117.161 469.164 232.082Z" fill="black"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M417.238 232.082C417.238 318.317 347.323 388.233 261.088 388.233C174.823 388.233 104.938 318.317 104.938 232.082C104.938 145.847 174.823 75.9316 261.088 75.9316C347.323 75.9316 417.238 145.847 417.238 232.082Z" fill="white"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M157.039 232.081C157.039 215.673 170.356 202.355 186.765 202.355H335.395C351.804 202.355 365.121 215.673 365.121 232.081C365.121 248.49 351.804 261.807 335.395 261.807H186.765C170.356 261.807 157.039 248.49 157.039 232.081ZM556.303 527.308C567.896 515.715 567.896 496.868 556.303 485.275L415.343 344.315L373.31 386.347L514.271 527.308C525.864 538.901 544.71 538.901 556.303 527.308Z" fill="black"/> +</g> +<defs> +<filter id="filter0_d_384_103" x="0.34" y="0.6" width="617.32" height="617.322" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> +<feOffset dy="29.26"/> +<feGaussianBlur stdDeviation="26.33"/> +<feComposite in2="hardAlpha" operator="out"/> +<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.65 0"/> +<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_384_103"/> +<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_384_103" result="shape"/> +</filter> +</defs> +</svg> diff --git a/src/assets/cursors/windows11/arrow__31-22.svg b/src/assets/cursors/windows11/arrow__31-22.svg new file mode 100644 index 000000000..f17b15897 --- /dev/null +++ b/src/assets/cursors/windows11/arrow__31-22.svg @@ -0,0 +1,3 @@ +<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M10 7L10 22.8L13.38 19.51L15.61 24.16L18.57 22.66L16.54 18.55H21.5L10 7Z" fill="white" stroke="black" stroke-miterlimit="3.8637"/> +</svg> diff --git a/src/assets/cursors/windows11/block__50-50.svg b/src/assets/cursors/windows11/block__50-50.svg new file mode 100644 index 000000000..7e49badcd --- /dev/null +++ b/src/assets/cursors/windows11/block__50-50.svg @@ -0,0 +1,3 @@ +<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M24 16C24 20.4111 20.4111 24 16 24C11.5889 24 8 20.4111 8 16C8 11.5889 11.5889 8 16 8C20.4111 8 24 11.5889 24 16ZM16 10C14.7546 10 13.5969 10.3818 12.637 11.0339L20.9662 19.3627C21.6182 18.4029 22 17.2454 22 16C22 12.6914 19.3086 10 16 10ZM16 22C17.3424 22 18.5788 21.5516 19.5794 20.8041L11.1957 12.4208C10.4484 13.4214 10 14.6578 10 16C10 19.3086 12.6914 22 16 22Z" fill="#FFFFFE" stroke="#000001" stroke-miterlimit="10"/> +</svg> diff --git a/src/assets/cursors/windows11/busy__50-50.svg b/src/assets/cursors/windows11/busy__50-50.svg new file mode 100644 index 000000000..c47a7ae10 --- /dev/null +++ b/src/assets/cursors/windows11/busy__50-50.svg @@ -0,0 +1,7 @@ +<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M27.7361 11H17.7361V9H27.7361V11Z" fill="#FFFFFE"/> +<path d="M27.7361 25H17.7361V23H27.7361V25Z" fill="#FFFFFE"/> +<path d="M20.7361 16L18.7361 14V11H26.7361V14L24.7361 16V18L26.7361 20V23H18.7361V20L20.7361 18V16Z" fill="#FFFFFE"/> +<path d="M15.4721 18.5341H10.5377L12.5642 22.6566L9.61947 24.1497L7.35907 19.5161L4 22.7919V7L15.4721 18.5341Z" fill="#FFFFFE"/> +<path d="M20.2361 13H21.2361M21.2361 14H22.2361M22.2361 15H23.2361M22.2361 17H23.2361M22.2361 20H23.2361M21.2361 21H22.2361M23.2361 21H24.2361M24.2361 22H25.2361M20.2361 22H21.2361M22.2361 22H23.2361M22.2361 13H23.2361M24.2361 13H25.2361M17.7361 11H27.7361V9H17.7361V11ZM17.7361 25H27.7361V23H17.7361V25ZM20.7361 16L18.7361 14V11H26.7361V14L24.7361 16V18L26.7361 20V23H18.7361V20L20.7361 18V16ZM15.4721 18.5341H10.5377L12.5642 22.6566L9.61947 24.1497L7.35907 19.5161L4 22.7919V7L15.4721 18.5341Z" stroke="#000001" stroke-miterlimit="10"/> +</svg> diff --git a/src/assets/cursors/windows11/cross__50-50.svg b/src/assets/cursors/windows11/cross__50-50.svg new file mode 100644 index 000000000..5e0d5e524 --- /dev/null +++ b/src/assets/cursors/windows11/cross__50-50.svg @@ -0,0 +1,3 @@ +<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M15 15V7H17V15H25V17H17V25H15V17H7V15H15Z" fill="#FFFFFE" stroke="#000001" stroke-miterlimit="10"/> +</svg> diff --git a/src/assets/cursors/windows11/grab__50-50.svg b/src/assets/cursors/windows11/grab__50-50.svg new file mode 100644 index 000000000..7408fdfe2 --- /dev/null +++ b/src/assets/cursors/windows11/grab__50-50.svg @@ -0,0 +1,4 @@ +<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M13.029 22.5V24H20.029V21C21.029 20 22.029 18.5 22.029 18.5C22.029 18.5 23.029 17 23.029 15L23.029 13C23.029 11 20.029 11 20.029 13.5L20.029 11C20.029 9 17.029 9 17.029 11L17.029 9.5C17.029 8 14.029 8 14.029 9.5L14.029 11.5C14.029 13 13.529 11.5 13.029 10.5C12.029 8.5 9.52899 10 10.029 11.5L11.5 14.5C12 15.5 11.3827 15.8537 11.029 15.5C8.99026 13.4613 7.02899 15 8.52898 17C9.52899 18.3333 11.029 20.5 13.029 22.5Z" fill="white"/> +<path d="M13.029 22.5H13.529C13.529 22.3674 13.4763 22.2402 13.3825 22.1464L13.029 22.5ZM8.52898 17L8.12898 17.3H8.12898L8.52898 17ZM11.029 15.5L10.6754 15.8536H10.6754L11.029 15.5ZM10.029 11.5L10.4762 11.2764L10.4736 11.2713L10.029 11.5ZM14.029 11.5L13.529 11.5V11.5L14.029 11.5ZM14.029 9.5L14.529 9.5L14.5289 9.49275L14.029 9.5ZM17.029 9.5L17.529 9.5L17.5289 9.49275L17.029 9.5ZM17.029 11L17.529 11V11H17.029ZM16.529 14C16.529 14.2761 16.7528 14.5 17.029 14.5C17.3051 14.5 17.529 14.2761 17.529 14L16.529 14ZM20.029 11L19.529 11V11L20.029 11ZM20.029 13.5H20.529V13.5L20.029 13.5ZM19.529 15C19.529 15.2761 19.7528 15.5 20.029 15.5C20.3051 15.5 20.529 15.2761 20.529 15H19.529ZM23 13.5H22.4999L22.5001 13.5097L23 13.5ZM22.029 18.5L22.445 18.7774V18.7774L22.029 18.5ZM20.029 21L19.6754 20.6464C19.5817 20.7402 19.529 20.8674 19.529 21H20.029ZM23.029 15L23.5291 15L23.5289 14.9903L23.029 15ZM11.529 14.5L11.0818 14.7236L11.0818 14.7236L11.529 14.5ZM13.529 24V22.5H12.529V24H13.529ZM13.3825 22.1464C11.4146 20.1785 9.94216 18.0509 8.92899 16.7L8.12898 17.3C9.11582 18.6158 10.6433 20.8215 12.6754 22.8536L13.3825 22.1464ZM8.92899 16.7C8.601 16.2627 8.49696 15.8965 8.50007 15.6413C8.50306 15.3949 8.60225 15.2381 8.73344 15.1487C8.99536 14.9703 9.72616 14.9043 10.6754 15.8536L11.3825 15.1464C10.2931 14.057 9.0239 13.7409 8.17049 14.3222C7.74402 14.6127 7.50672 15.0882 7.50014 15.6291C7.49367 16.1611 7.70697 16.7373 8.12898 17.3L8.92899 16.7ZM10.4736 11.2713C10.2779 10.8909 10.2902 10.574 10.3865 10.336C10.487 10.0876 10.696 9.88368 10.9596 9.77437C11.2209 9.66604 11.5187 9.65764 11.7923 9.77799C12.0613 9.89629 12.3548 10.1607 12.5648 10.6857L13.4932 10.3143C13.2032 9.58927 12.7431 9.10371 12.195 8.86263C11.6516 8.62361 11.069 8.64646 10.5766 8.85063C10.0866 9.05382 9.66878 9.4437 9.45951 9.9609C9.24604 10.4885 9.2656 11.1091 9.58438 11.7287L10.4736 11.2713ZM14.529 11.5L14.529 9.5L13.529 9.5L13.529 11.5L14.529 11.5ZM14.5289 9.49275C14.5237 9.12849 14.6517 8.89452 14.816 8.74656C14.9898 8.59004 15.2387 8.5 15.5072 8.5C15.7756 8.5 16.031 8.59007 16.2141 8.75081C16.3884 8.90373 16.5238 9.14275 16.529 9.50725L17.5289 9.49275C17.5197 8.85725 17.2692 8.34627 16.8737 7.99919C16.4871 7.65993 15.9889 7.5 15.5072 7.5C15.0258 7.5 14.5283 7.65996 14.1469 8.00344C13.7559 8.35548 13.5198 8.87151 13.529 9.50725L14.5289 9.49275ZM16.529 9.5L16.529 11L17.529 11L17.529 9.5L16.529 9.5ZM16.529 11L16.529 14L17.529 14L17.529 11L16.529 11ZM17.529 11C17.529 10.6357 17.6606 10.3992 17.8299 10.2487C18.0084 10.0901 18.2605 10 18.529 10C18.7975 10 19.0496 10.0901 19.2281 10.2487C19.3973 10.3992 19.529 10.6357 19.529 11L20.529 11C20.529 10.3643 20.2856 9.85082 19.8924 9.5013C19.5084 9.15995 19.0105 9 18.529 9C18.0475 9 17.5496 9.15995 17.1656 9.5013C16.7723 9.85082 16.529 10.3643 16.529 11H17.529ZM19.529 11L19.529 13.5L20.529 13.5L20.529 11L19.529 11ZM19.529 13.5V15H20.529V13.5H19.529ZM20.529 13.5C20.529 12.4705 21.1004 12.125 21.5145 12.125C21.9286 12.125 22.5 12.4705 22.5 13.5H23.5C23.5 12.0295 22.5859 11.125 21.5145 11.125C20.4431 11.125 19.529 12.0295 19.529 13.5L20.529 13.5ZM22.029 18.5C21.613 18.2226 21.613 18.2226 21.613 18.2226C21.613 18.2226 21.613 18.2226 21.613 18.2226C21.613 18.2226 21.613 18.2226 21.6129 18.2227C21.6129 18.2228 21.6128 18.223 21.6126 18.2232C21.6122 18.2238 21.6116 18.2247 21.6107 18.226C21.6091 18.2285 21.6064 18.2324 21.6029 18.2377C21.5958 18.2482 21.5851 18.264 21.571 18.2847C21.5427 18.3262 21.5009 18.387 21.4475 18.4633C21.3406 18.616 21.1876 18.8302 21.004 19.075C20.6342 19.5681 20.1507 20.1712 19.6754 20.6464L20.3825 21.3536C20.9073 20.8288 21.4238 20.1819 21.804 19.675C21.9954 19.4198 22.1549 19.1965 22.2667 19.0367C22.3227 18.9568 22.3668 18.8926 22.3972 18.8481C22.4124 18.8258 22.4241 18.8085 22.4321 18.7965C22.4362 18.7905 22.4393 18.7859 22.4414 18.7827C22.4425 18.7811 22.4434 18.7798 22.4439 18.7789C22.4442 18.7785 22.4445 18.7781 22.4447 18.7779C22.4448 18.7777 22.4448 18.7776 22.4449 18.7775C22.4449 18.7775 22.4449 18.7775 22.445 18.7774C22.445 18.7774 22.445 18.7774 22.029 18.5ZM19.529 21V24H20.529V21H19.529ZM22.5001 13.5097L22.5291 15.0097L23.5289 14.9903L23.4999 13.4903L22.5001 13.5097ZM22.529 15C22.529 15.9185 22.2988 16.7291 22.0648 17.3143C21.9482 17.6057 21.832 17.8377 21.7463 17.9949C21.7035 18.0735 21.6684 18.133 21.6449 18.1717C21.6331 18.191 21.6242 18.2051 21.6188 18.2136C21.616 18.2179 21.6141 18.2209 21.6131 18.2224C21.6126 18.2231 21.6124 18.2235 21.6123 18.2236C21.6123 18.2236 21.6124 18.2235 21.6125 18.2234C21.6125 18.2233 21.6126 18.2232 21.6127 18.2231C21.6127 18.223 21.6128 18.2229 21.6128 18.2229C21.6129 18.2228 21.613 18.2226 22.029 18.5C22.445 18.7774 22.4451 18.7772 22.4452 18.7771C22.4452 18.777 22.4453 18.7769 22.4454 18.7768C22.4455 18.7766 22.4457 18.7763 22.4459 18.7761C22.4462 18.7756 22.4466 18.7749 22.4471 18.7742C22.448 18.7728 22.4492 18.771 22.4506 18.7689C22.4534 18.7646 22.4571 18.7589 22.4617 18.7517C22.4708 18.7373 22.4835 18.7172 22.4991 18.6916C22.5302 18.6404 22.5733 18.5672 22.6242 18.4738C22.7259 18.2873 22.8598 18.0193 22.9932 17.6857C23.2592 17.0209 23.529 16.0815 23.529 15L22.529 15ZM11.9762 14.2764L10.4762 11.2764L9.58178 11.7236L11.0818 14.7236L11.9762 14.2764ZM10.6754 15.8536C11.023 16.2011 11.6169 16.2603 11.9807 15.8308C12.3194 15.4307 12.2587 14.8413 11.9762 14.2764L11.0818 14.7236C11.1838 14.9277 11.2098 15.0647 11.2117 15.1403C11.2135 15.2142 11.1935 15.213 11.2176 15.1845C11.2436 15.1538 11.2877 15.1332 11.3313 15.1336C11.3717 15.134 11.3871 15.151 11.3825 15.1464L10.6754 15.8536ZM12.5648 10.6857C12.8084 11.2949 12.9918 11.8446 13.1303 12.2834C13.2615 12.6987 13.3681 13.069 13.4398 13.2482C13.4565 13.2902 13.4887 13.3677 13.5386 13.4375C13.5585 13.4654 13.6247 13.5561 13.7469 13.6143C13.8177 13.648 13.9155 13.6744 14.0294 13.6607C14.1438 13.6469 14.2338 13.5973 14.2964 13.5452C14.4038 13.4557 14.4452 13.3471 14.4574 13.314C14.475 13.2664 14.4845 13.2222 14.4902 13.1917C14.5017 13.1293 14.508 13.0597 14.5123 12.9931C14.5293 12.7302 14.529 12.2386 14.529 11.5L13.529 11.5C13.529 12.2614 13.5287 12.7073 13.5144 12.9287C13.5128 12.9542 13.5111 12.9738 13.5095 12.9885C13.5088 12.9957 13.5081 13.0012 13.5076 13.0051C13.507 13.009 13.5067 13.0106 13.5068 13.0102C13.5068 13.01 13.5075 13.0064 13.5091 13.0001C13.5106 12.9942 13.5137 12.9827 13.5194 12.9674C13.5242 12.9543 13.5575 12.8592 13.6562 12.777C13.7143 12.7286 13.7997 12.6812 13.9094 12.6679C14.0185 12.6547 14.1113 12.6802 14.177 12.7115C14.2888 12.7648 14.3439 12.8445 14.3519 12.8557C14.3779 12.892 14.3827 12.913 14.3682 12.8768C14.3149 12.7435 14.234 12.4575 14.0839 11.9822C13.9412 11.5304 13.7495 10.9551 13.4932 10.3143L12.5648 10.6857Z" fill="black"/> +</svg> diff --git a/src/assets/cursors/windows11/grabbing__50-50.svg b/src/assets/cursors/windows11/grabbing__50-50.svg new file mode 100644 index 000000000..c338b955b --- /dev/null +++ b/src/assets/cursors/windows11/grabbing__50-50.svg @@ -0,0 +1,4 @@ +<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M13.3798 20.2398V21.7398H20.3798V20.2398V19.2398C20.3798 18.4455 21.1019 17.7045 21.2872 17.5192C21.6777 17.1287 22.7872 17.0192 22.7872 16.0192V14.0192C22.7872 14.0192 22.5718 12.5192 21.2872 12.5192C20.4231 12.5192 20.3798 13.2398 20.3798 13.2398V12.2398C20.3798 12.2398 20.2872 11.5192 19.3798 11.2398C18.6632 11.0192 17.3798 11.1751 17.3798 11.466V12.2398C17.3798 12.2398 16.8202 11.5192 16.4128 11.2398C15.9303 10.9089 14.8696 10.9315 14.5516 11.2398C14.2123 11.5689 14.459 12.2398 14.2872 12.2398C14.1153 12.2398 13.7872 11.5192 13.3798 11.2398C12.9724 10.9604 11.5977 11.1108 11.2872 11.5192C10.8546 12.0881 10.8432 12.7074 11.2263 13.2398C11.7872 14.0192 12.2872 13.962 12.3798 14.2398C12.4724 14.5177 12.3798 15.2398 12.3798 15.2398H11.3798C11.0159 15.2398 10.4775 15.2398 10.2872 15.5192C9.86835 16.2398 9.91479 16.6744 10.3798 17.2398C10.427 17.2971 11.3798 18.2398 11.3798 18.2398L12.3798 19.2398L13.3798 20.2398Z" fill="white"/> +<path d="M13.3798 21.7398V20.2398L12.3798 19.2398L11.3798 18.2398C11.3798 18.2398 10.427 17.2971 10.3798 17.2398C9.91479 16.6744 9.86835 16.2398 10.2872 15.5192C10.4775 15.2398 11.0159 15.2398 11.3798 15.2398C11.7872 15.2398 12.3798 15.2398 12.3798 15.2398C12.3798 15.2398 12.4724 14.5177 12.3798 14.2398C12.2872 13.962 11.7872 14.0192 11.2263 13.2398C10.8432 12.7074 10.8546 12.0881 11.2872 11.5192C11.5977 11.1108 12.9724 10.9604 13.3798 11.2398C13.7872 11.5192 14.1153 12.2398 14.2872 12.2398C14.459 12.2398 14.2123 11.5689 14.5516 11.2398C14.8696 10.9315 15.9303 10.9089 16.4128 11.2398C16.8202 11.5192 17.3798 12.2398 17.3798 12.2398V11.466C17.3798 11.1751 18.6632 11.0192 19.3798 11.2398C20.2872 11.5192 20.3798 12.2398 20.3798 12.2398V13.2398C20.3798 13.2398 20.4231 12.5192 21.2872 12.5192C22.7872 12.5192 22.7872 14.0192 22.7872 14.0192V16.0192C22.7872 17.0192 21.6777 17.1287 21.2872 17.5192C21.1019 17.7045 20.3798 18.4455 20.3798 19.2398V20.2398V21.7398" stroke="black" stroke-linejoin="round"/> +</svg> diff --git a/src/assets/cursors/windows11/help__31-22.svg b/src/assets/cursors/windows11/help__31-22.svg new file mode 100644 index 000000000..d7a4ebb51 --- /dev/null +++ b/src/assets/cursors/windows11/help__31-22.svg @@ -0,0 +1,5 @@ +<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M24.1837 23.3573C24.1342 23.2811 24.0948 23.1865 24.0656 23.0741C24.0364 22.9623 24.0224 22.8518 24.0224 22.7438C24.0224 22.5863 24.0428 22.446 24.0834 22.3222C24.124 22.199 24.1825 22.0821 24.2612 21.9716C24.34 21.8618 24.439 21.7506 24.5584 21.6382C24.6778 21.5258 24.8162 21.4039 24.9737 21.2744C25.2074 21.0807 25.4132 20.8933 25.5935 20.7105C25.7739 20.5288 25.9237 20.339 26.0457 20.1411C26.1663 19.9433 26.259 19.7331 26.3213 19.5106C26.3848 19.288 26.4165 19.0397 26.4165 18.7657C26.4165 18.3338 26.3403 17.9642 26.1904 17.6562C26.0393 17.3486 25.831 17.0955 25.563 16.8977C25.2951 16.6999 24.9826 16.5551 24.6219 16.4627C24.2625 16.3706 23.8738 16.3246 23.456 16.3246C23.02 16.3246 22.5974 16.3764 22.1881 16.4795C21.7792 16.5831 21.4017 16.7383 21.0556 16.945V18.9746C21.3928 18.678 21.75 18.4564 22.1275 18.3103C22.5053 18.1643 22.8692 18.0913 23.2198 18.0913C23.3823 18.0913 23.5284 18.116 23.6617 18.1656C23.7938 18.2148 23.9069 18.2824 23.9983 18.3678C24.091 18.4532 24.1621 18.5555 24.2117 18.6745C24.2612 18.7936 24.2853 18.9228 24.2853 19.0622C24.2853 19.2242 24.2625 19.3715 24.218 19.5039C24.1723 19.6366 24.105 19.7646 24.0161 19.8881C23.9259 20.0119 23.8167 20.1354 23.6884 20.2593C23.5601 20.3828 23.4115 20.5165 23.2401 20.6603C23.0785 20.7956 22.9368 20.9315 22.8152 21.0686C22.6939 21.2058 22.593 21.3493 22.512 21.4998C22.431 21.6503 22.3704 21.811 22.3298 21.9818C22.2894 22.1526 22.2691 22.3419 22.2691 22.5483C22.2691 22.6791 22.2815 22.8169 22.3063 22.9629C22.331 23.109 22.3659 23.2404 22.4107 23.3573L24.1837 23.3573ZM24.2764 26.6831C24.5063 26.4723 24.6206 26.2043 24.6206 25.8811C24.6206 25.5616 24.5037 25.2987 24.2701 25.0917C24.0364 24.8809 23.7417 24.7748 23.3861 24.7748C23.0267 24.7748 22.7279 24.8809 22.4894 25.0917C22.2513 25.3032 22.1322 25.5661 22.1322 25.8811C22.1322 26.1865 22.2513 26.452 22.4894 26.6768C22.7279 26.892 23.0267 27 23.3861 27C23.7455 27 24.0427 26.8946 24.2764 26.6831Z" fill="#FFFFFE"/> +<path d="M17.4721 17.8587H12.5377L14.5642 21.9812L11.6195 23.4743L9.35907 18.8407L6 22.1165V6.3246L17.4721 17.8587Z" fill="#FFFFFE"/> +<path d="M24.1837 23.3573L24.1838 23.8573C24.3671 23.8573 24.5358 23.7569 24.6232 23.5958C24.7107 23.4346 24.7029 23.2385 24.603 23.0848L24.1837 23.3573ZM24.0656 23.0741L24.5496 22.9483L24.5494 22.9476L24.0656 23.0741ZM24.0834 22.3222L23.6086 22.1655L23.6083 22.1663L24.0834 22.3222ZM24.2612 21.9716L23.8548 21.6804L23.854 21.6815L24.2612 21.9716ZM24.9737 21.2744L25.2914 21.6605L25.2928 21.6593L24.9737 21.2744ZM25.5935 20.7105L25.2387 20.3581L25.2375 20.3594L25.5935 20.7105ZM26.0457 20.1411L26.4713 20.4035L26.4725 20.4015L26.0457 20.1411ZM26.3213 19.5106L25.8404 19.3734L25.8397 19.3759L26.3213 19.5106ZM26.1904 17.6562L26.64 17.4375L26.6392 17.4358L26.1904 17.6562ZM24.6219 16.4627L24.746 15.9783L24.746 15.9783L24.6219 16.4627ZM22.1881 16.4795L22.0659 15.9947L22.0655 15.9948L22.1881 16.4795ZM21.0556 16.945L20.7992 16.5158C20.6481 16.606 20.5556 16.769 20.5556 16.945H21.0556ZM21.0556 18.9746H20.5556C20.5556 19.171 20.6706 19.3492 20.8495 19.4302C21.0285 19.5111 21.2383 19.4798 21.3858 19.35L21.0556 18.9746ZM22.1275 18.3103L21.9472 17.844L21.9471 17.844L22.1275 18.3103ZM23.6617 18.1656L23.8363 17.697L23.8358 17.6968L23.6617 18.1656ZM23.9983 18.3678L23.657 18.7332L23.6595 18.7356L23.9983 18.3678ZM24.218 19.5039L24.6908 19.6668L24.692 19.663L24.218 19.5039ZM24.0161 19.8881L24.4203 20.1824L24.4219 20.1802L24.0161 19.8881ZM23.6884 20.2593L24.0352 20.6195L24.0357 20.619L23.6884 20.2593ZM23.2401 20.6603L23.561 21.0437L23.5614 21.0434L23.2401 20.6603ZM22.8152 21.0686L22.4411 20.7369L22.4407 20.7374L22.8152 21.0686ZM22.3298 21.9818L21.8433 21.8661L21.8431 21.8669L22.3298 21.9818ZM22.4107 23.3573L21.9438 23.5362C22.0179 23.7296 22.2036 23.8573 22.4107 23.8573L22.4107 23.3573ZM24.2764 26.6831L24.6119 27.0539L24.6144 27.0516L24.2764 26.6831ZM24.2701 25.0917L23.9351 25.463L23.9385 25.466L24.2701 25.0917ZM22.4894 25.0917L22.1582 24.7171L22.1574 24.7178L22.4894 25.0917ZM22.4894 26.6768L22.1461 27.0404L22.1544 27.0479L22.4894 26.6768ZM17.4721 17.8587V18.3587H18.6746L17.8266 17.5061L17.4721 17.8587ZM12.5377 17.8587V17.3587H11.7347L12.0889 18.0793L12.5377 17.8587ZM14.5642 21.9812L14.7903 22.4271L15.2308 22.2038L15.0129 21.7606L14.5642 21.9812ZM11.6195 23.4743L11.1701 23.6935L11.3927 24.1499L11.8456 23.9202L11.6195 23.4743ZM9.35907 18.8407L9.80845 18.6215L9.50518 17.9998L9.00998 18.4828L9.35907 18.8407ZM6 22.1165H5.5V23.3025L6.34909 22.4745L6 22.1165ZM6 6.3246L6.3545 5.972L5.5 5.11288V6.3246H6ZM24.603 23.0848C24.5898 23.0645 24.5689 23.0228 24.5496 22.9483L23.5817 23.1998C23.6208 23.3501 23.6786 23.4976 23.7645 23.6298L24.603 23.0848ZM24.5494 22.9476C24.5308 22.8764 24.5224 22.8084 24.5224 22.7438H23.5224C23.5224 22.8952 23.5421 23.0482 23.5819 23.2005L24.5494 22.9476ZM24.5224 22.7438C24.5224 22.6288 24.5373 22.5425 24.5585 22.4781L23.6083 22.1663C23.5482 22.3495 23.5224 22.5439 23.5224 22.7438H24.5224ZM24.5582 22.4788C24.5833 22.4027 24.6191 22.3309 24.6684 22.2618L23.854 21.6815C23.7458 21.8333 23.6647 21.9953 23.6086 22.1655L24.5582 22.4788ZM24.6676 22.2629C24.7241 22.1842 24.8005 22.0971 24.9012 22.0023L24.2157 21.2742C24.0776 21.4042 23.9559 21.5394 23.8548 21.6804L24.6676 22.2629ZM24.9012 22.0023C25.0116 21.8983 25.1416 21.7837 25.2914 21.6605L24.6561 20.8882C24.4909 21.0241 24.344 21.1534 24.2157 21.2742L24.9012 22.0023ZM25.2928 21.6593C25.5373 21.4567 25.7557 21.2581 25.9495 21.0615L25.2375 20.3594C25.0707 20.5286 24.8776 20.7046 24.6547 20.8894L25.2928 21.6593ZM25.9483 21.0628C26.1541 20.8556 26.3281 20.6358 26.4713 20.4035L25.62 19.8788C25.5193 20.0421 25.3937 20.2021 25.2387 20.3581L25.9483 21.0628ZM26.4725 20.4015C26.6161 20.1661 26.7277 19.9137 26.8028 19.6452L25.8397 19.3759C25.7903 19.5525 25.7165 19.7206 25.6188 19.8808L26.4725 20.4015ZM26.8021 19.6477C26.8804 19.3732 26.9165 19.0778 26.9165 18.7657H25.9165C25.9165 19.0015 25.8891 19.2027 25.8405 19.3734L26.8021 19.6477ZM26.9165 18.7657C26.9165 18.2759 26.8301 17.8281 26.64 17.4375L25.7408 17.875C25.8505 18.1004 25.9165 18.3917 25.9165 18.7657H26.9165ZM26.6392 17.4358C26.4544 17.0595 26.1953 16.7429 25.86 16.4954L25.2661 17.3C25.4667 17.4481 25.6242 17.6376 25.7417 17.8767L26.6392 17.4358ZM25.86 16.4954C25.5348 16.2554 25.1622 16.085 24.746 15.9783L24.4978 16.9471C24.803 17.0252 25.0553 17.1444 25.2661 17.3L25.86 16.4954ZM24.746 15.9783C24.3413 15.8747 23.9105 15.8246 23.456 15.8246V16.8246C23.8372 16.8246 24.1837 16.8666 24.4979 16.9471L24.746 15.9783ZM23.456 15.8246C22.98 15.8246 22.5163 15.8812 22.0659 15.9947L22.3104 16.9644C22.6785 16.8716 23.06 16.8246 23.456 16.8246V15.8246ZM22.0655 15.9948C21.6116 16.1097 21.1887 16.2831 20.7992 16.5158L21.3119 17.3743C21.6146 17.1935 21.9468 17.0564 22.3108 16.9643L22.0655 15.9948ZM20.5556 16.945V18.9746H21.5556V16.945H20.5556ZM21.3858 19.35C21.6838 19.0879 21.9912 18.8992 22.3079 18.7767L21.9471 17.844C21.5087 18.0136 21.1017 18.2681 20.7254 18.5991L21.3858 19.35ZM22.3078 18.7767C22.637 18.6495 22.9397 18.5913 23.2198 18.5913V17.5913C22.7987 17.5913 22.3737 17.6791 21.9472 17.844L22.3078 18.7767ZM23.2198 18.5913C23.3305 18.5913 23.4167 18.6079 23.4876 18.6343L23.8358 17.6968C23.6401 17.6241 23.4341 17.5913 23.2198 17.5913V18.5913ZM23.4872 18.6341C23.5662 18.6635 23.6202 18.6988 23.657 18.7332L24.3396 18.0024C24.1935 17.866 24.0215 17.766 23.8363 17.697L23.4872 18.6341ZM23.6595 18.7356C23.6978 18.7708 23.7276 18.8127 23.75 18.8666L24.6733 18.4825C24.5967 18.2982 24.4842 18.1356 24.3371 18.0001L23.6595 18.7356ZM23.75 18.8666C23.7717 18.9186 23.7853 18.9817 23.7853 19.0622H24.7853C24.7853 18.864 24.7508 18.6686 24.6733 18.4825L23.75 18.8666ZM23.7853 19.0622C23.7853 19.177 23.7692 19.2699 23.744 19.3448L24.692 19.663C24.7558 19.4731 24.7853 19.2713 24.7853 19.0622H23.7853ZM23.7453 19.341C23.7168 19.4238 23.6732 19.5086 23.6103 19.596L24.4219 20.1802C24.5368 20.0205 24.6278 19.8495 24.6908 19.6667L23.7453 19.341ZM23.6119 19.5938C23.5415 19.6904 23.4522 19.7923 23.3411 19.8996L24.0357 20.619C24.1812 20.4786 24.3103 20.3334 24.4203 20.1824L23.6119 19.5938ZM23.3416 19.8991C23.2224 20.0139 23.0824 20.14 22.9187 20.2772L23.5614 21.0434C23.7407 20.893 23.8979 20.7517 24.0352 20.6195L23.3416 19.8991ZM22.9192 20.2769C22.7416 20.4255 22.5816 20.5785 22.4411 20.7369L23.1894 21.4003C23.2921 21.2845 23.4153 21.1657 23.561 21.0437L22.9192 20.2769ZM22.4407 20.7374C22.2958 20.9013 22.1722 21.0762 22.0717 21.2629L22.9523 21.7367C23.0138 21.6225 23.0921 21.5103 23.1898 21.3998L22.4407 20.7374ZM22.0717 21.2629C21.9695 21.4529 21.8937 21.6545 21.8433 21.8661L22.8162 22.0975C22.8471 21.9675 22.8926 21.8477 22.9523 21.7367L22.0717 21.2629ZM21.8431 21.8669C21.7926 22.0812 21.7691 22.3096 21.7691 22.5483H22.7691C22.7691 22.3742 22.7863 22.2241 22.8164 22.0967L21.8431 21.8669ZM21.7691 22.5483C21.7691 22.7106 21.7845 22.8765 21.8133 23.0465L22.7992 22.8793C22.7785 22.7572 22.7691 22.6476 22.7691 22.5483H21.7691ZM21.8133 23.0465C21.8427 23.2198 21.8854 23.3838 21.9438 23.5362L22.8776 23.1784C22.8465 23.0971 22.8194 22.9982 22.7992 22.8793L21.8133 23.0465ZM22.4107 23.8573L24.1838 23.8573L24.1837 22.8573L22.4107 22.8573L22.4107 23.8573ZM24.6144 27.0516C24.9525 26.7415 25.1206 26.3374 25.1206 25.8811H24.1206C24.1206 26.0712 24.0601 26.2031 23.9385 26.3146L24.6144 27.0516ZM25.1206 25.8811C25.1206 25.4221 24.9451 25.0217 24.6016 24.7175L23.9385 25.466C24.0624 25.5757 24.1206 25.7012 24.1206 25.8811H25.1206ZM24.605 24.7205C24.2679 24.4163 23.8489 24.2748 23.3861 24.2748V25.2748C23.6345 25.2748 23.8049 25.3455 23.9351 25.463L24.605 24.7205ZM23.3861 24.2748C22.9208 24.2748 22.4995 24.4154 22.1582 24.7171L22.8206 25.4663C22.9563 25.3463 23.1326 25.2748 23.3861 25.2748V24.2748ZM22.1574 24.7178C21.8139 25.0229 21.6322 25.4212 21.6322 25.8811H22.6322C22.6322 25.711 22.6887 25.5835 22.8214 25.4656L22.1574 24.7178ZM21.6322 25.8811C21.6322 26.3347 21.8159 26.7286 22.1462 27.0404L22.8327 26.3132C22.6866 26.1753 22.6322 26.0384 22.6322 25.8811H21.6322ZM22.1544 27.0479C22.4956 27.3559 22.9183 27.5 23.3861 27.5V26.5C23.135 26.5 22.9602 26.4282 22.8245 26.3056L22.1544 27.0479ZM23.3861 27.5C23.8502 27.5 24.2729 27.3606 24.6119 27.0539L23.9409 26.3124C23.8125 26.4286 23.6408 26.5 23.3861 26.5V27.5ZM17.4721 17.3587H12.5377V18.3587H17.4721V17.3587ZM12.0889 18.0793L14.1155 22.2018L15.0129 21.7606L12.9864 17.6381L12.0889 18.0793ZM14.3381 21.5352L11.3934 23.0284L11.8456 23.9202L14.7903 22.4271L14.3381 21.5352ZM12.0688 23.2551L9.80845 18.6215L8.90969 19.06L11.1701 23.6935L12.0688 23.2551ZM9.00998 18.4828L5.65091 21.7585L6.34909 22.4745L9.70816 19.1987L9.00998 18.4828ZM6.5 22.1165V6.3246H5.5V22.1165H6.5ZM5.6455 6.6772L17.1176 18.2113L17.8266 17.5061L6.3545 5.972L5.6455 6.6772Z" fill="#000001"/> +</svg> diff --git a/src/assets/cursors/windows11/move__51-51.svg b/src/assets/cursors/windows11/move__51-51.svg new file mode 100644 index 000000000..459ebcb9d --- /dev/null +++ b/src/assets/cursors/windows11/move__51-51.svg @@ -0,0 +1,3 @@ +<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M10.2941 17.5735V20.8824H9.63235L5 16.25L9.63235 11.6177H10.2941V14.9265H14.9265V10.2941H11.6177V9.63235L16.25 5L20.8824 9.63235V10.2941H17.5735V14.9265H22.2059V11.6177H22.8676L27.5 16.25L22.8676 20.8824H22.2059V17.5735H17.5735V22.2059H20.8824V22.8676L16.25 27.5L11.6177 22.8676V22.2059H14.9265V17.5735H10.2941Z" fill="#FFFFFE" stroke="#000001" stroke-miterlimit="10"/> +</svg> diff --git a/src/assets/cursors/windows11/pen__19-19.svg b/src/assets/cursors/windows11/pen__19-19.svg new file mode 100644 index 000000000..bc05a6a32 --- /dev/null +++ b/src/assets/cursors/windows11/pen__19-19.svg @@ -0,0 +1,4 @@ +<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M6 6C6.38276 10.4329 9.60454 13.246 9.60454 13.246L22.1665 25.8079C22.4226 26.064 22.8379 26.064 23.094 25.8079L24.4509 24.4509L25.8079 23.094C26.064 22.8379 26.064 22.4226 25.8079 22.1665L13.246 9.60454C13.246 9.60454 10.4329 6.38276 6 6Z" fill="#FFFFFE" stroke="#000001" stroke-width="1.018" stroke-miterlimit="10"/> +<path d="M14.5446 18.1861L18.1919 14.5389" stroke="black" stroke-width="1.018" stroke-miterlimit="10"/> +</svg> diff --git a/src/assets/cursors/windows11/person__50-50.svg b/src/assets/cursors/windows11/person__50-50.svg new file mode 100644 index 000000000..f4fded619 --- /dev/null +++ b/src/assets/cursors/windows11/person__50-50.svg @@ -0,0 +1,8 @@ +<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M10.9536 11V6.98606C10.9536 6.98606 10.9536 5.64808 12.2915 5.64808C13.6295 5.64808 13.6295 6.98606 13.6295 6.98606V11" fill="#FFFFFE"/> +<path d="M13.6295 11L13.6295 7.65505C13.6295 7.65505 13.6295 6.31707 14.9675 6.31707C16.3055 6.31707 16.3055 7.65505 16.3055 7.65505V11" fill="#FFFFFE"/> +<path d="M16.3055 11L16.3055 8.32404C16.3055 8.32404 16.3055 6.98606 17.6434 6.98606C18.9814 6.98606 18.9814 8.32404 18.9814 8.32404V14.6794C18.9814 14.6794 19.6504 21.3693 12.9605 21.3693C12.9605 21.3693 10.5 21.5 8.27759 19.3624L3.59466 14.0104C3.59466 14.0104 2.25668 12.6725 3.59466 11.3345C3.59466 11.3345 4.59814 10.331 6.27062 12.0035L7.43223 13.1651C7.74419 13.477 8.27758 13.2561 8.27758 12.8149V1.96863C8.27758 1.96863 8.27758 0.630646 9.61556 0.630646C10.9535 0.630646 10.9535 1.96863 10.9535 1.96863L10.9536 11" fill="#FFFFFE"/> +<path d="M29.4742 30C29.4742 26.9767 27.0233 24.5258 24 24.5258C20.9767 24.5258 18.5258 26.9767 18.5258 30C18.5258 30 19.5 31 24 31C28.5 31 29.4742 30 29.4742 30Z" fill="#FFFFFE"/> +<path d="M24 17C26.0782 17 27.7629 18.6847 27.7629 20.7629C27.7629 22.8411 26.0782 24.5258 24 24.5258C21.9218 24.5258 20.2371 22.8411 20.2371 20.7629C20.2371 18.6847 21.9218 17 24 17Z" fill="#FFFFFE"/> +<path d="M24 24.5258C27.0233 24.5258 29.4742 26.9767 29.4742 30C29.4742 30 28.5 31 24 31C19.5 31 18.5258 30 18.5258 30C18.5258 26.9767 20.9767 24.5258 24 24.5258ZM24 24.5258C26.0782 24.5258 27.7629 22.8411 27.7629 20.7629C27.7629 18.6847 26.0782 17 24 17C21.9218 17 20.2371 18.6847 20.2371 20.7629C20.2371 22.8411 21.9218 24.5258 24 24.5258ZM10.9536 11V6.98606C10.9536 6.98606 10.9536 5.64808 12.2915 5.64808C13.6295 5.64808 13.6295 6.98606 13.6295 6.98606V11L13.6295 7.65505C13.6295 7.65505 13.6295 6.31707 14.9675 6.31707C16.3055 6.31707 16.3055 7.65505 16.3055 7.65505V11L16.3055 8.32404C16.3055 8.32404 16.3055 6.98606 17.6434 6.98606C18.9814 6.98606 18.9814 8.32404 18.9814 8.32404V14.6794C18.9814 14.6794 19.6504 21.3693 12.9605 21.3693C12.9605 21.3693 10.5 21.5 8.27759 19.3624L3.59466 14.0104C3.59466 14.0104 2.25668 12.6725 3.59466 11.3345C3.59466 11.3345 4.59814 10.331 6.27062 12.0035L7.43223 13.1651C7.74419 13.477 8.27758 13.2561 8.27758 12.8149V1.96863C8.27758 1.96863 8.27758 0.630646 9.61556 0.630646C10.9535 0.630646 10.9535 1.96863 10.9535 1.96863L10.9536 11Z" stroke="#000001" stroke-miterlimit="10"/> +</svg> diff --git a/src/assets/cursors/windows11/pin__75-97.svg b/src/assets/cursors/windows11/pin__75-97.svg new file mode 100644 index 000000000..f7242aec7 --- /dev/null +++ b/src/assets/cursors/windows11/pin__75-97.svg @@ -0,0 +1,8 @@ +<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M28.4775 21.8065C28.4775 24.2793 24 31 24 31C24 31 19.5225 24.2793 19.5225 21.8065C19.5225 19.3336 21.5272 17.329 24 17.329C26.4728 17.329 28.4775 19.3336 28.4775 21.8065Z" fill="#FFFFFE"/> +<path d="M24.0218 19.7807C25.1373 19.7807 26.0415 20.685 26.0415 21.8004C26.0415 22.9159 25.1373 23.8201 24.0218 23.8201C22.9064 23.8201 22.0021 22.9159 22.0021 21.8004C22.0021 20.685 22.9064 19.7807 24.0218 19.7807Z" fill="black"/> +<path d="M10.9535 11.1645V7.15057C10.9535 7.15057 10.9535 5.81259 12.2915 5.81259C13.6295 5.81259 13.6295 7.15057 13.6295 7.15057V11.1645" fill="#FFFFFE"/> +<path d="M13.6295 11.1645L13.6295 7.81956C13.6295 7.81956 13.6295 6.48158 14.9675 6.48158C16.3055 6.48158 16.3055 7.81956 16.3055 7.81956V11.1645" fill="#FFFFFE"/> +<path d="M16.3055 11.1645L16.3055 8.48855C16.3055 8.48855 16.3055 7.15057 17.6434 7.15057C18.9814 7.15057 18.9814 8.48855 18.9814 8.48855V14.8439C18.9814 14.8439 19.5226 21.5338 12.9605 21.5338C12.9605 21.5338 10.5 21.6645 8.27759 19.5269L3.59466 14.175C3.59466 14.175 2.25668 12.837 3.59466 11.499C3.59466 11.499 4.59814 10.4955 6.27062 12.168L7.43223 13.3296C7.74419 13.6416 8.27758 13.4206 8.27758 12.9794V2.13315C8.27758 2.13315 8.27758 0.795166 9.61556 0.795166C10.9535 0.795166 10.9535 2.13315 10.9535 2.13315L10.9535 11.1645" fill="#FFFFFE"/> +<path d="M28.4775 21.8065C28.4775 24.2793 24 31 24 31C24 31 19.5225 24.2793 19.5225 21.8065C19.5225 19.3336 21.5272 17.329 24 17.329C26.4728 17.329 28.4775 19.3336 28.4775 21.8065ZM24.0218 19.7807C25.1373 19.7807 26.0415 20.685 26.0415 21.8004C26.0415 22.9159 25.1373 23.8201 24.0218 23.8201C22.9064 23.8201 22.0021 22.9159 22.0021 21.8004C22.0021 20.685 22.9064 19.7807 24.0218 19.7807ZM10.9535 11.1645V7.15057C10.9535 7.15057 10.9535 5.81259 12.2915 5.81259C13.6295 5.81259 13.6295 7.15057 13.6295 7.15057V11.1645L13.6295 7.81956C13.6295 7.81956 13.6295 6.48158 14.9675 6.48158C16.3055 6.48158 16.3055 7.81956 16.3055 7.81956V11.1645L16.3055 8.48855C16.3055 8.48855 16.3055 7.15057 17.6434 7.15057C18.9814 7.15057 18.9814 8.48855 18.9814 8.48855V14.8439C18.9814 14.8439 19.5226 21.5338 12.9605 21.5338C12.9605 21.5338 10.5 21.6645 8.27759 19.5269L3.59466 14.175C3.59466 14.175 2.25668 12.837 3.59466 11.499C3.59466 11.499 4.59814 10.4955 6.27062 12.168L7.43223 13.3296C7.74419 13.6416 8.27758 13.4206 8.27758 12.9794V2.13315C8.27758 2.13315 8.27758 0.795166 9.61556 0.795166C10.9535 0.795166 10.9535 2.13315 10.9535 2.13315L10.9535 11.1645Z" stroke="#000001" stroke-miterlimit="10"/> +</svg> diff --git a/src/assets/cursors/windows11/pointer__54-35.svg b/src/assets/cursors/windows11/pointer__54-35.svg new file mode 100644 index 000000000..42e241f79 --- /dev/null +++ b/src/assets/cursors/windows11/pointer__54-35.svg @@ -0,0 +1,6 @@ +<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M15.9535 16.6291V12.6152C15.9535 12.6152 15.9535 11.2772 17.2915 11.2772C18.6295 11.2772 18.6295 12.6152 18.6295 12.6152V16.6291" fill="#FFFFFE"/> +<path d="M18.6295 16.6291L18.6295 13.2842C18.6295 13.2842 18.6295 11.9462 19.9675 11.9462C21.3055 11.9462 21.3055 13.2842 21.3055 13.2842V16.6291" fill="#FFFFFE"/> +<path d="M21.3055 16.6291L21.3055 13.9531C21.3055 13.9531 21.3055 12.6152 22.6434 12.6152C23.9814 12.6152 23.9814 13.9531 23.9814 13.9531V20.3085C23.9814 20.3085 24.6504 26.9984 17.9605 26.9984C17.9605 26.9984 15.5 27.1291 13.2776 24.9915L8.59466 19.6396C8.59466 19.6396 7.25668 18.3016 8.59466 16.9636C8.59466 16.9636 9.59814 15.9601 11.2706 17.6326L12.4322 18.7942C12.7442 19.1062 13.2776 18.8852 13.2776 18.444V7.59775C13.2776 7.59775 13.2776 6.25977 14.6156 6.25977C15.9535 6.25977 15.9535 7.59775 15.9535 7.59775V16.6291" fill="#FFFFFE"/> +<path d="M15.9535 16.6291V12.6152C15.9535 12.6152 15.9535 11.2772 17.2915 11.2772C18.6295 11.2772 18.6295 12.6152 18.6295 12.6152V16.6291L18.6295 13.2842C18.6295 13.2842 18.6295 11.9462 19.9675 11.9462C21.3055 11.9462 21.3055 13.2842 21.3055 13.2842V16.6291L21.3055 13.9531C21.3055 13.9531 21.3055 12.6152 22.6434 12.6152C23.9814 12.6152 23.9814 13.9531 23.9814 13.9531V20.3085C23.9814 20.3085 24.6504 26.9984 17.9605 26.9984C17.9605 26.9984 15.5 27.1291 13.2776 24.9915L8.59466 19.6396C8.59466 19.6396 7.25668 18.3016 8.59466 16.9636C8.59466 16.9636 9.59814 15.9601 11.2706 17.6326L12.4322 18.7942C12.7442 19.1062 13.2776 18.8852 13.2776 18.444V7.59775C13.2776 7.59775 13.2776 6.25977 14.6156 6.25977C15.9535 6.25977 15.9535 7.59775 15.9535 7.59775V16.6291Z" stroke="#000001" stroke-miterlimit="10"/> +</svg> diff --git a/src/assets/cursors/windows11/resize-diagonal-backslash__50-50.svg b/src/assets/cursors/windows11/resize-diagonal-backslash__50-50.svg new file mode 100644 index 000000000..2119d5fa8 --- /dev/null +++ b/src/assets/cursors/windows11/resize-diagonal-backslash__50-50.svg @@ -0,0 +1,3 @@ +<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M10.7452 18.9809L8.45754 16.6932L8 17.1508V23.5563H14.4056L14.8631 23.0988L12.5754 20.8111L20.8111 12.5754L23.0988 14.8631L23.5563 14.4056V8H17.1508L16.6932 8.45754L18.9809 10.7452L10.7452 18.9809Z" fill="#FFFFFE" stroke="#000001" stroke-miterlimit="10"/> +</svg> diff --git a/src/assets/cursors/windows11/resize-diagonal-slash__50-50.svg b/src/assets/cursors/windows11/resize-diagonal-slash__50-50.svg new file mode 100644 index 000000000..ebd706fc5 --- /dev/null +++ b/src/assets/cursors/windows11/resize-diagonal-slash__50-50.svg @@ -0,0 +1,3 @@ +<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M18.9809 20.8111L16.6932 23.0988L17.1508 23.5563H23.5563V17.1508L23.0988 16.6932L20.8111 18.9809L12.5754 10.7452L14.8631 8.45754L14.4056 8H8V14.4056L8.45754 14.8631L10.7452 12.5754L18.9809 20.8111Z" fill="#FFFFFE" stroke="#000001" stroke-miterlimit="10"/> +</svg> diff --git a/src/assets/cursors/windows11/resize-horizontal__51-49.svg b/src/assets/cursors/windows11/resize-horizontal__51-49.svg new file mode 100644 index 000000000..25cde2eee --- /dev/null +++ b/src/assets/cursors/windows11/resize-horizontal__51-49.svg @@ -0,0 +1,3 @@ +<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M10.2941 16.9559H22.2059V20.2647H22.8676L27.5 15.6324L22.8676 11H22.2059V14.3088H10.2941V11H9.63235L5 15.6324L9.63235 20.2647H10.2941V16.9559Z" fill="#FFFFFE" stroke="#000001" stroke-miterlimit="10"/> +</svg> diff --git a/src/assets/cursors/windows11/resize-vertical__48-50.svg b/src/assets/cursors/windows11/resize-vertical__48-50.svg new file mode 100644 index 000000000..73149d2e3 --- /dev/null +++ b/src/assets/cursors/windows11/resize-vertical__48-50.svg @@ -0,0 +1,3 @@ +<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M14.2143 10.1429H11V9.5L15.5 5L20 9.5V10.1429H16.7857V21.7143H20V22.3571L15.5 26.8571L11 22.3571V21.7143H14.2143V10.1429Z" fill="#FFFFFE" stroke="#000001" stroke-width="0.99351" stroke-miterlimit="10"/> +</svg> diff --git a/src/assets/cursors/windows11/text__50-50.svg b/src/assets/cursors/windows11/text__50-50.svg new file mode 100644 index 000000000..bf38ba24d --- /dev/null +++ b/src/assets/cursors/windows11/text__50-50.svg @@ -0,0 +1,3 @@ +<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M21 9.5V7.5H17V8H15V7.5H11V9.5H15V22.5H11V24.5H15V24H17V24.5H21V22.5H17V9.5H21Z" fill="#FFFFFE" stroke="#000001" stroke-miterlimit="10"/> +</svg> diff --git a/src/assets/cursors/windows11/up__49-21.svg b/src/assets/cursors/windows11/up__49-21.svg new file mode 100644 index 000000000..f87afc9d6 --- /dev/null +++ b/src/assets/cursors/windows11/up__49-21.svg @@ -0,0 +1,3 @@ +<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M14.3929 12.1072V25H17.1071V12.1072H20.5V11.4286L15.75 6.67857L11 11.4286V12.1072H14.3929Z" fill="#FFFFFE" stroke="#000001" stroke-miterlimit="10"/> +</svg> diff --git a/src/assets/cursors/windows11/wait__50-50.svg b/src/assets/cursors/windows11/wait__50-50.svg new file mode 100644 index 000000000..cb00c79ef --- /dev/null +++ b/src/assets/cursors/windows11/wait__50-50.svg @@ -0,0 +1,39 @@ +<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M10 7H22V11L18 15V17L22 21V26H10V21L14 17V15L10 11V7Z" fill="#FFFFFE"/> +<path d="M22 7V11L18 15V17L22 21V26" stroke="#000001" stroke-miterlimit="10"/> +<path d="M10 26V21L14 17V15L10 11V7" stroke="#000001" stroke-miterlimit="10"/> +<path d="M23 28H9V26H23V28Z" fill="#FFFFFE" stroke="#000001" stroke-miterlimit="10"/> +<path d="M23 7H9V5H23V7Z" fill="#FFFFFE" stroke="#000001" stroke-miterlimit="10"/> +<path d="M15.5 18H16.5" stroke="#000001" stroke-miterlimit="10"/> +<path d="M16.5 19H17.5" stroke="#000001" stroke-miterlimit="10"/> +<path d="M16.5 21H17.5" stroke="#000001" stroke-miterlimit="10"/> +<path d="M14.5 21H15.5" stroke="#000001" stroke-miterlimit="10"/> +<path d="M15.5 22H16.5" stroke="#000001" stroke-miterlimit="10"/> +<path d="M13.5 22H14.5" stroke="#000001" stroke-miterlimit="10"/> +<path d="M14.5 23H15.5" stroke="#000001" stroke-miterlimit="10"/> +<path d="M16.5 23H17.5" stroke="#000001" stroke-miterlimit="10"/> +<path d="M17.5 22H18.5" stroke="#000001" stroke-miterlimit="10"/> +<path d="M18.5 23H19.5" stroke="#000001" stroke-miterlimit="10"/> +<path d="M19.5 24H20.5" stroke="#000001" stroke-miterlimit="10"/> +<path d="M17.5 24H18.5" stroke="#000001" stroke-miterlimit="10"/> +<path d="M15.5 24H16.5" stroke="#000001" stroke-miterlimit="10"/> +<path d="M13.5 24H14.5" stroke="#000001" stroke-miterlimit="10"/> +<path d="M12.5 23H13.5" stroke="#000001" stroke-miterlimit="10"/> +<path d="M11.5 24H12.5" stroke="#000001" stroke-miterlimit="10"/> +<path d="M11.5 9H12.5" stroke="#000001" stroke-miterlimit="10"/> +<path d="M13.5 9H14.5" stroke="#000001" stroke-miterlimit="10"/> +<path d="M15.5 9H16.5" stroke="#000001" stroke-miterlimit="10"/> +<path d="M17.5 9H18.5" stroke="#000001" stroke-miterlimit="10"/> +<path d="M19.5 9H20.5" stroke="#000001" stroke-miterlimit="10"/> +<path d="M12.5 10H13.5" stroke="#000001" stroke-miterlimit="10"/> +<path d="M14.5 10H15.5" stroke="#000001" stroke-miterlimit="10"/> +<path d="M16.5 10H17.5" stroke="#000001" stroke-miterlimit="10"/> +<path d="M18.5 10H19.5" stroke="#000001" stroke-miterlimit="10"/> +<path d="M13.5 11H14.5" stroke="#000001" stroke-miterlimit="10"/> +<path d="M15.5 11H16.5" stroke="#000001" stroke-miterlimit="10"/> +<path d="M14.5 12H15.5" stroke="#000001" stroke-miterlimit="10"/> +<path d="M16.5 12H17.5" stroke="#000001" stroke-miterlimit="10"/> +<path d="M15.5 13H16.5" stroke="#000001" stroke-miterlimit="10"/> +<path d="M17.5 11H18.5" stroke="#000001" stroke-miterlimit="10"/> +<path d="M15.5 16H16.5" stroke="#000001" stroke-miterlimit="10"/> +</svg> diff --git a/src/assets/cursors/windows11/zoom-in__44-44.svg b/src/assets/cursors/windows11/zoom-in__44-44.svg new file mode 100644 index 000000000..b05350983 --- /dev/null +++ b/src/assets/cursors/windows11/zoom-in__44-44.svg @@ -0,0 +1,5 @@ +<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg"> +<circle cx="14" cy="14" r="5.5" fill="white" stroke="black"/> +<path d="M18 18L23.5 23.5" stroke="black" stroke-width="1.75"/> +<path d="M11 14H14M17 14H14M14 14V11M14 14V17" stroke="black" stroke-width="1.75"/> +</svg> diff --git a/src/assets/cursors/windows11/zoom-out__44-44.svg b/src/assets/cursors/windows11/zoom-out__44-44.svg new file mode 100644 index 000000000..5bfec5cba --- /dev/null +++ b/src/assets/cursors/windows11/zoom-out__44-44.svg @@ -0,0 +1,5 @@ +<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg"> +<circle cx="14" cy="14" r="5.5" fill="white" stroke="black"/> +<path d="M11 14H17" stroke="black" stroke-width="2"/> +<path d="M18 18L23.5 23.5" stroke="black" stroke-width="1.75"/> +</svg> diff --git a/src/assets/react.svg b/src/assets/react.svg index 6c87de9bb..8e0e0f15c 100644 --- a/src/assets/react.svg +++ b/src/assets/react.svg @@ -1 +1 @@ -<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg> \ No newline at end of file +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg> diff --git a/src/components/announcements/AnnouncementDialog.tsx b/src/components/announcements/AnnouncementDialog.tsx new file mode 100644 index 000000000..a9bc0fcfb --- /dev/null +++ b/src/components/announcements/AnnouncementDialog.tsx @@ -0,0 +1,358 @@ +import { ArrowLeft, ArrowRight, ArrowSquareOut, Megaphone } from "@phosphor-icons/react"; +import { useEffect, useRef, useState } from "react"; +import { toast } from "sonner"; +import { BUNDLED_ANNOUNCEMENT_FEED } from "@/content/announcements"; +import { useI18n } from "@/contexts/I18nContext"; +import { runAnnouncementAction } from "@/lib/announcementActions"; +import { + dismissAnnouncements, + readAnnouncementImpressionCounts, + readDismissedAnnouncementIds, + recordAnnouncementImpression, +} from "@/lib/announcementState"; +import { + type Announcement, + type AnnouncementAudience, + parseAnnouncementFeed, + selectAnnouncements, +} from "@/lib/announcements"; +import { cn } from "@/lib/utils"; +import { Button } from "../ui/button"; +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "../ui/dialog"; + +function AnnouncementMediaBanner({ + announcement, + cover = false, +}: { + announcement: Announcement; + cover?: boolean; +}) { + const [failed, setFailed] = useState(false); + const media = announcement.media; + + if (!media || failed) { + return ( + <div className="flex h-32 items-center justify-center bg-gradient-to-br from-primary/25 via-primary/10 to-transparent"> + <Megaphone className="h-12 w-12 text-primary/70" weight="duotone" /> + </div> + ); + } + + if (media.type === "video") { + return ( + <video + key={media.url} + className={cn( + "w-full bg-black", + cover ? "h-full object-cover" : "max-h-72 object-contain", + )} + controls + playsInline + preload="metadata" + poster={media.posterUrl} + onError={() => setFailed(true)} + > + <source src={media.url} /> + </video> + ); + } + + return ( + <img + src={media.url} + alt={media.alt || ""} + className={cn("w-full bg-black/20 object-cover", cover ? "h-full" : "max-h-72")} + onError={() => setFailed(true)} + /> + ); +} + +export function AnnouncementDialog({ audience }: { audience: AnnouncementAudience }) { + const { t } = useI18n(); + const [announcements, setAnnouncements] = useState<Announcement[]>([]); + const [currentIndex, setCurrentIndex] = useState(0); + const [open, setOpen] = useState(false); + const [popupAspectRatio, setPopupAspectRatio] = useState( + BUNDLED_ANNOUNCEMENT_FEED.settings.aspectRatio, + ); + const countedThisSessionRef = useRef(new Set<string>()); + + useEffect(() => { + let cancelled = false; + + const loadAnnouncements = async () => { + const dismissedIds = new Set(readDismissedAnnouncementIds()); + const impressionCounts = readAnnouncementImpressionCounts(); + const [appVersion, remoteFeed] = await Promise.all([ + window.electronAPI.getAppVersion().catch(() => "0.0.0"), + window.electronAPI.getAnnouncements().catch(() => null), + ]); + if (cancelled) { + return; + } + + const parsedRemoteFeed = parseAnnouncementFeed(remoteFeed); + const eligible = selectAnnouncements({ + bundled: BUNDLED_ANNOUNCEMENT_FEED.announcements, + remote: parsedRemoteFeed.announcements, + dismissedIds, + impressionCounts, + appVersion, + audience, + }).filter( + (announcement) => + announcement.presentation !== "notification" && + announcement.presentation !== "banner" && + announcement.presentation !== "export", + ); + setPopupAspectRatio( + parsedRemoteFeed.settings.aspectRatio ?? + BUNDLED_ANNOUNCEMENT_FEED.settings.aspectRatio, + ); + setAnnouncements(eligible); + setCurrentIndex(0); + setOpen(eligible.length > 0); + }; + + void loadAnnouncements(); + return () => { + cancelled = true; + }; + }, [audience]); + + const current = announcements[currentIndex]; + const currentId = current?.id; + const usesCoverMedia = current?.mediaMode === "cover" && Boolean(current.media); + const controls = { + close: current?.controls?.close !== false, + dismiss: current?.controls?.dismiss !== false, + action: current?.controls?.action !== false, + navigation: current?.controls?.navigation !== false, + indicators: current?.controls?.indicators !== false, + }; + + useEffect(() => { + if (!open || !currentId || countedThisSessionRef.current.has(currentId)) { + return; + } + + countedThisSessionRef.current.add(currentId); + recordAnnouncementImpression(currentId); + }, [currentId, open]); + + useEffect(() => { + if (!open || !current?.displayDurationSeconds) { + return; + } + + const timeout = window.setTimeout(() => { + if (currentIndex >= announcements.length - 1) { + setOpen(false); + return; + } + setCurrentIndex((index) => index + 1); + }, current.displayDurationSeconds * 1_000); + + return () => window.clearTimeout(timeout); + }, [announcements.length, current?.displayDurationSeconds, currentIndex, open]); + + const dismissCurrent = () => { + if (!current) { + setOpen(false); + return; + } + + dismissAnnouncements([current.id]); + + const remaining = announcements.filter((announcement) => announcement.id !== current.id); + setAnnouncements(remaining); + if (remaining.length === 0) { + setOpen(false); + setCurrentIndex(0); + return; + } + + setCurrentIndex((index) => Math.min(index, remaining.length - 1)); + }; + + const openAction = async () => { + if (!current?.action) { + return; + } + + try { + const result = await runAnnouncementAction(current.action); + if (!result.success) { + toast.error(result.error || t("announcements.openFailed", "Failed to open link.")); + return; + } + dismissCurrent(); + } catch (error) { + toast.error( + `${t("announcements.openFailed", "Failed to open link.")} ${String(error)}`, + ); + } + }; + + if (!current) { + return null; + } + + return ( + <Dialog + open={open} + onOpenChange={(nextOpen) => { + if (!nextOpen) { + if (controls.dismiss) { + dismissCurrent(); + } else { + setOpen(false); + } + } + }} + > + <DialogContent + className={cn( + "flex max-h-[90vh] max-w-xl flex-col gap-0 overflow-hidden border-foreground/10 bg-editor-dialog p-0 text-foreground", + !controls.close && "[&>button]:hidden", + usesCoverMedia && + "isolate min-h-80 text-white [&>button]:bg-black/35 [&>button]:text-white [&>button:hover]:bg-black/55", + )} + style={ + popupAspectRatio + ? { aspectRatio: popupAspectRatio.replace(":", " / ") } + : undefined + } + > + {usesCoverMedia ? ( + <> + <div className="absolute inset-0 z-0"> + <AnnouncementMediaBanner + key={current.id} + announcement={current} + cover + /> + </div> + <div + className="pointer-events-none absolute inset-0 z-[1] bg-black/15" + aria-hidden="true" + /> + </> + ) : ( + <AnnouncementMediaBanner key={current.id} announcement={current} /> + )} + <div + className={cn( + "space-y-5 overflow-y-auto p-6", + usesCoverMedia && + "relative z-10 mt-auto w-full bg-gradient-to-t from-black/90 via-black/65 to-transparent pt-20", + )} + > + <DialogHeader> + <DialogTitle className="cursor-text select-text text-xl leading-tight"> + {current.title} + </DialogTitle> + <DialogDescription + className={cn( + "cursor-text select-text whitespace-pre-line pt-2 text-sm leading-relaxed text-muted-foreground", + usesCoverMedia && "text-white/80", + )} + > + {current.body} + </DialogDescription> + </DialogHeader> + + {announcements.length > 1 && (controls.navigation || controls.indicators) && ( + <div + className={cn( + "flex items-center", + controls.navigation ? "justify-between" : "justify-center", + )} + aria-label={t("announcements.carousel", "Announcements")} + > + {controls.navigation && ( + <Button + type="button" + variant="ghost" + size="icon" + className={ + usesCoverMedia ? "text-white hover:bg-white/15" : undefined + } + onClick={() => + setCurrentIndex( + (index) => + (index - 1 + announcements.length) % + announcements.length, + ) + } + aria-label={t( + "announcements.previous", + "Previous announcement", + )} + > + <ArrowLeft /> + </Button> + )} + {controls.indicators && ( + <div className="flex gap-1.5"> + {announcements.map((announcement, index) => ( + <button + type="button" + key={announcement.id} + className={`h-1.5 rounded-full transition-all ${ + index === currentIndex + ? `w-6 ${usesCoverMedia ? "bg-white" : "bg-primary"}` + : `w-1.5 ${usesCoverMedia ? "bg-white/40" : "bg-foreground/25"}` + }`} + onClick={() => setCurrentIndex(index)} + aria-label={`${t("announcements.show", "Show announcement")} ${index + 1}`} + /> + ))} + </div> + )} + {controls.navigation && ( + <Button + type="button" + variant="ghost" + size="icon" + className={ + usesCoverMedia ? "text-white hover:bg-white/15" : undefined + } + onClick={() => + setCurrentIndex( + (index) => (index + 1) % announcements.length, + ) + } + aria-label={t("announcements.next", "Next announcement")} + > + <ArrowRight /> + </Button> + )} + </div> + )} + + <div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end"> + {controls.dismiss && ( + <Button + type="button" + variant="ghost" + className={ + usesCoverMedia ? "text-white hover:bg-white/15" : undefined + } + onClick={dismissCurrent} + > + {t("announcements.dismiss", "Dismiss")} + </Button> + )} + {current.action && controls.action && ( + <Button type="button" onClick={() => void openAction()}> + {current.action.label} + {current.action.url ? <ArrowSquareOut /> : <ArrowRight />} + </Button> + )} + </div> + </div> + </DialogContent> + </Dialog> + ); +} diff --git a/src/components/announcements/EditorAnnouncementBanner.tsx b/src/components/announcements/EditorAnnouncementBanner.tsx new file mode 100644 index 000000000..6180c17b9 --- /dev/null +++ b/src/components/announcements/EditorAnnouncementBanner.tsx @@ -0,0 +1,143 @@ +import { ArrowRight, ArrowSquareOut, X } from "@phosphor-icons/react"; +import { useEffect, useRef, useState } from "react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { BUNDLED_ANNOUNCEMENT_FEED } from "@/content/announcements"; +import { useI18n } from "@/contexts/I18nContext"; +import { runAnnouncementAction } from "@/lib/announcementActions"; +import type { Announcement } from "@/lib/announcements"; +import { parseAnnouncementFeed, selectAnnouncements } from "@/lib/announcements"; +import { + dismissAnnouncements, + readAnnouncementImpressionCounts, + readDismissedAnnouncementIds, + recordAnnouncementImpression, +} from "@/lib/announcementState"; + +export function EditorAnnouncementBanner() { + const { t } = useI18n(); + const [announcements, setAnnouncements] = useState<Announcement[]>([]); + const countedThisSessionRef = useRef(new Set<string>()); + const current = announcements[0]; + + useEffect(() => { + let cancelled = false; + + const loadBanners = async () => { + const dismissedIds = new Set(readDismissedAnnouncementIds()); + const impressionCounts = readAnnouncementImpressionCounts(); + const [appVersion, remoteFeed] = await Promise.all([ + window.electronAPI.getAppVersion().catch(() => "0.0.0"), + window.electronAPI.getAnnouncements().catch(() => null), + ]); + if (cancelled) { + return; + } + + setAnnouncements( + selectAnnouncements({ + bundled: BUNDLED_ANNOUNCEMENT_FEED.announcements, + remote: parseAnnouncementFeed(remoteFeed).announcements, + dismissedIds, + impressionCounts, + appVersion, + audience: "editor", + }).filter((announcement) => announcement.presentation === "banner"), + ); + }; + + void loadBanners(); + return () => { + cancelled = true; + }; + }, []); + + useEffect(() => { + if (!current || countedThisSessionRef.current.has(current.id)) { + return; + } + + countedThisSessionRef.current.add(current.id); + recordAnnouncementImpression(current.id); + }, [current]); + + useEffect(() => { + if (!current?.displayDurationSeconds) { + return; + } + + const timeout = window.setTimeout(() => { + setAnnouncements((items) => items.slice(1)); + }, current.displayDurationSeconds * 1_000); + return () => window.clearTimeout(timeout); + }, [current]); + + if (!current) { + return null; + } + + const dismissCurrent = () => { + dismissAnnouncements([current.id]); + setAnnouncements((items) => items.slice(1)); + }; + + const openAction = async () => { + if (!current.action) { + return; + } + + try { + const result = await runAnnouncementAction(current.action); + if (!result.success) { + toast.error(result.error || t("announcements.openFailed", "Failed to open link.")); + return; + } + dismissCurrent(); + } catch (error) { + toast.error( + `${t("announcements.openFailed", "Failed to open link.")} ${String(error)}`, + ); + } + }; + + const showClose = current.controls?.close !== false; + const showAction = current.action && current.controls?.action !== false; + + return ( + <div className="relative z-40 flex flex-shrink-0 items-center justify-center gap-3 border-b border-black bg-black px-12 py-2 text-white shadow-sm dark:border-white dark:bg-white dark:text-black"> + <div className="min-w-0 cursor-text select-text text-center text-xs leading-relaxed"> + <span className="font-semibold">{current.title}</span> + <span className="mx-1.5 opacity-65" aria-hidden="true"> + — + </span> + <span className="opacity-80">{current.body}</span> + </div> + {showAction ? ( + <Button + type="button" + variant="link" + size="sm" + onClick={() => void openAction()} + className="h-auto shrink-0 gap-1 px-1 py-0 text-xs text-white underline decoration-white/50 underline-offset-2 hover:text-white dark:text-black dark:decoration-black/50 dark:hover:text-black" + > + {current.action?.label} + {current.action?.url ? ( + <ArrowSquareOut className="h-3.5 w-3.5" /> + ) : ( + <ArrowRight className="h-3.5 w-3.5" /> + )} + </Button> + ) : null} + {showClose ? ( + <button + type="button" + onClick={dismissCurrent} + className="absolute right-4 inline-flex h-6 w-6 items-center justify-center rounded-md text-white/70 transition-colors hover:bg-white/15 hover:text-white dark:text-black/70 dark:hover:bg-black/10 dark:hover:text-black" + aria-label={t("announcements.dismiss", "Dismiss")} + > + <X className="h-3.5 w-3.5" /> + </button> + ) : null} + </div> + ); +} diff --git a/src/components/announcements/LiveAnnouncementNotifications.tsx b/src/components/announcements/LiveAnnouncementNotifications.tsx new file mode 100644 index 000000000..3d6bce30f --- /dev/null +++ b/src/components/announcements/LiveAnnouncementNotifications.tsx @@ -0,0 +1,118 @@ +import { useEffect, useRef } from "react"; +import { toast } from "sonner"; +import { BUNDLED_ANNOUNCEMENT_FEED } from "@/content/announcements"; +import { useI18n } from "@/contexts/I18nContext"; +import { runAnnouncementAction } from "@/lib/announcementActions"; +import type { AnnouncementAudience } from "@/lib/announcements"; +import { parseAnnouncementFeed, selectAnnouncements } from "@/lib/announcements"; +import { + dismissAnnouncements, + readAnnouncementImpressionCounts, + readDismissedAnnouncementIds, + recordAnnouncementImpression, +} from "@/lib/announcementState"; + +const DEFAULT_NOTIFICATION_DURATION_SECONDS = 10; +const MAX_NOTIFICATIONS_PER_LOAD = 5; + +export function LiveAnnouncementNotifications({ audience }: { audience: AnnouncementAudience }) { + const { t } = useI18n(); + const shownThisSessionRef = useRef(new Set<string>()); + + useEffect(() => { + let cancelled = false; + + const showNotifications = async () => { + const dismissedIds = new Set(readDismissedAnnouncementIds()); + const impressionCounts = readAnnouncementImpressionCounts(); + const [appVersion, remoteFeed] = await Promise.all([ + window.electronAPI.getAppVersion().catch(() => "0.0.0"), + window.electronAPI.getAnnouncements().catch(() => null), + ]); + if (cancelled) { + return; + } + + const notifications = selectAnnouncements({ + bundled: BUNDLED_ANNOUNCEMENT_FEED.announcements, + remote: parseAnnouncementFeed(remoteFeed).announcements, + dismissedIds, + impressionCounts, + appVersion, + audience, + }) + .filter((announcement) => announcement.presentation === "notification") + .slice(0, MAX_NOTIFICATIONS_PER_LOAD); + + for (const announcement of notifications) { + if (shownThisSessionRef.current.has(announcement.id)) { + continue; + } + + shownThisSessionRef.current.add(announcement.id); + recordAnnouncementImpression(announcement.id); + const dismiss = () => dismissAnnouncements([announcement.id]); + const controls = { + close: announcement.controls?.close !== false, + action: announcement.controls?.action !== false, + }; + const action = announcement.action; + + toast( + <span className="cursor-text select-text font-semibold"> + {announcement.title} + </span>, + { + id: `live-announcement:${announcement.id}`, + description: ( + <div className="cursor-text select-text"> + <p className="whitespace-pre-line leading-relaxed"> + {announcement.body} + </p> + </div> + ), + duration: + (announcement.displayDurationSeconds ?? + DEFAULT_NOTIFICATION_DURATION_SECONDS) * 1_000, + closeButton: controls.close, + onDismiss: dismiss, + action: + action && controls.action + ? { + label: action.label, + onClick: () => { + void runAnnouncementAction(action) + .then((result) => { + if (result.success) { + dismiss(); + return; + } + toast.error( + result.error || + t( + "announcements.openFailed", + "Failed to open link.", + ), + ); + }) + .catch((error) => { + toast.error( + `${t("announcements.openFailed", "Failed to open link.")} ${String(error)}`, + ); + }); + }, + } + : undefined, + }, + ); + } + }; + + void showNotifications(); + return () => { + cancelled = true; + }; + }, [audience, t]); + + return null; +} diff --git a/src/components/countdown/CountdownOverlay.tsx b/src/components/countdown/CountdownOverlay.tsx new file mode 100644 index 000000000..53e652f3b --- /dev/null +++ b/src/components/countdown/CountdownOverlay.tsx @@ -0,0 +1,70 @@ +import { useCallback, useEffect, useState } from "react"; + +export function CountdownOverlay() { + const [countdown, setCountdown] = useState<number | null>(null); + + useEffect(() => { + void window.electronAPI.getActiveCountdown().then((result) => { + if (result.success && typeof result.seconds === "number") { + setCountdown(result.seconds); + } + }); + + const cleanup = window.electronAPI.onCountdownTick((seconds: number) => { + setCountdown(seconds); + }); + + return cleanup; + }, []); + + const handleCancel = useCallback(() => { + window.electronAPI.cancelCountdown(); + }, []); + + const handleKeyDown = useCallback( + (event: KeyboardEvent) => { + if (event.key === "Escape") { + handleCancel(); + } + }, + [handleCancel], + ); + + useEffect(() => { + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [handleKeyDown]); + + if (countdown === null) { + return null; + } + + return ( + <div + className="fixed inset-0 flex items-center justify-center select-none cursor-pointer" + onClick={handleCancel} + onKeyDown={(e) => e.key === "Escape" && handleCancel()} + > + <div + className="flex items-center justify-center rounded-3xl" + style={{ + width: 180, + height: 180, + background: "rgba(0, 0, 0, 0.85)", + backdropFilter: "blur(20px)", + }} + > + <span + className="text-white font-bold tabular-nums" + style={{ + fontSize: "100px", + lineHeight: 1, + textShadow: "0 0 30px rgba(255,255,255,0.2)", + }} + > + {countdown} + </span> + </div> + </div> + ); +} diff --git a/src/components/launch/LaunchWindow.module.css b/src/components/launch/LaunchWindow.module.css index 0f1f63d5a..196f86ec6 100644 --- a/src/components/launch/LaunchWindow.module.css +++ b/src/components/launch/LaunchWindow.module.css @@ -1,6 +1,313 @@ .electronDrag { - -webkit-app-region: drag; + -webkit-app-region: drag; } + .electronNoDrag { - -webkit-app-region: no-drag; + -webkit-app-region: no-drag; +} + +.bar { + display: flex; + align-items: center; + gap: 10px; + width: max-content; + max-width: 1200px; + background: var(--launch-bar-bg); + border: 1px solid var(--launch-bar-border); + border-radius: 20px; + padding: 11px 20px 11px 18px; + box-shadow: var(--launch-bar-shadow); + overflow: visible; + position: relative; + transform-origin: center bottom; +} + +.barStateViewport { + position: relative; + display: flex; + align-items: center; + flex: 0 1 auto; + min-height: 46px; + min-width: fit-content; + padding-right: 2px; +} + +.barState { + display: flex; + align-items: center; + gap: 8px; + flex: 0 1 auto; + white-space: nowrap; + min-width: fit-content; + pointer-events: auto; +} + +.sep { + width: 1px; + height: 26px; + background: var(--launch-border-strong); + margin: 0 6px; + flex-shrink: 0; +} + +.ib { + position: relative; + width: 40px; + height: 40px; + border-radius: 11px; + border: none; + background: transparent; + color: var(--launch-text-muted); + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + transition: all 0.15s ease; + flex-shrink: 0; +} + +.ib:hover { + background: var(--launch-hover); + color: var(--launch-text); +} + +.ibActive { + color: #3d8bff; +} + +.ibActive:hover { + color: #62a4ff; +} + +.ibRed { + color: #f43f5e; +} + +.ibRed:hover { + color: #ff5a75; +} + +.ibGreen { + color: #34d399; +} + +.ibGreen:hover { + color: #4eeeb0; +} + +.menuCard { + width: 300px; + max-height: 400px; + overflow-y: auto; + background: var(--launch-surface); + border: 1px solid var(--launch-border); + border-radius: 16px; + backdrop-filter: blur(var(--launch-popover-blur, 24px)) + saturate(var(--launch-popover-saturate, 145%)); + -webkit-backdrop-filter: blur(var(--launch-popover-blur, 24px)) + saturate(var(--launch-popover-saturate, 145%)); + padding: 8px; + margin-top: auto; + margin-bottom: 8px; + box-shadow: var(--launch-bar-shadow); + pointer-events: auto; + animation: menuCardIn 0.18s ease; +} + +@keyframes menuCardIn { + from { + opacity: 0; + transform: translateY(12px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.menuCard::-webkit-scrollbar { + width: 4px; +} + +.menuCard::-webkit-scrollbar-track { + background: transparent; +} + +.menuCard::-webkit-scrollbar-thumb { + background: var(--launch-border-strong); + border-radius: 10px; +} + +.ddLabel { + font-size: 9px; + font-weight: 600; + color: var(--launch-label); + text-transform: uppercase; + letter-spacing: 0.08em; + padding: 6px 10px 4px; +} + +.ddItem { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + padding: 8px 10px; + border-radius: 8px; + border: none; + background: transparent; + font-size: 12px; + color: var(--launch-text-muted); + cursor: pointer; + transition: all 0.12s ease; + text-align: left; +} + +.ddItem:hover { + background: var(--launch-hover); + color: var(--launch-text); +} + +.ddItemSelected { + background: var(--launch-selected); + color: var(--launch-accent); +} + +.finalizingState { + display: inline-flex; + align-items: center; + gap: 11px; + min-width: 238px; + color: #eeeef2; +} + +.finalizingSpin { + color: #3d8bff; + animation: finalizingSpin 0.9s linear infinite; +} + +.finalizingCopy { + display: flex; + flex-direction: column; + gap: 2px; + font-size: 12px; + font-weight: 700; + line-height: 1.15; +} + +.finalizingCopy small { + color: #8a8a96; + font-size: 10px; + font-weight: 600; +} + +@keyframes finalizingSpin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +.recBtn { + position: relative; + width: 46px; + height: 46px; + border-radius: 50%; + border: none; + background: #f43f5e; + color: #fff; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + transition: all 0.2s ease; + flex-shrink: 0; + box-shadow: 0 0 0 0 rgba(244, 63, 94, 0.3); +} + +.recBtn:hover { + background: #ff5a75; + box-shadow: 0 0 0 6px rgba(244, 63, 94, 0.15); +} + +.recBtn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.recBtn:disabled:hover { + background: #f43f5e; + box-shadow: none; +} + +.recDot { + width: 14px; + height: 14px; + border-radius: 50%; + background: #fff; + transition: all 0.2s ease; +} + +.stopSquare { + width: 15px; + height: 15px; + border-radius: 3px; + background: #fff; +} + +.recDotBlink { + animation: blink 1.2s ease-in-out infinite; +} + +.recordingWebcamPreview { + position: fixed; + right: 32px; + bottom: 120px; + height: 288px; + width: 288px; + overflow: hidden; + border-radius: 24px; + background: var(--launch-surface); + border: 1px solid var(--launch-border-strong); + box-shadow: var(--launch-bar-shadow); + cursor: grab; + touch-action: none; + user-select: none; + will-change: transform; + z-index: 120; +} + +.recordingWebcamPreview:active { + cursor: grabbing; +} + +.recordingWebcamPreviewVideo { + display: block; + height: 100%; + width: 100%; + object-fit: cover; + border-radius: inherit; + overflow: hidden; + transform: translateZ(0); +} + +@keyframes blink { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.2; + } +} + +.micSelect { + color-scheme: dark; +} + +.micSelect option { + background-color: #1a1a22; + color: #eeeef2; } diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index ae576f3d0..cb43ead1b 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -1,93 +1,551 @@ -import { useState, useEffect } from "react"; -import styles from "./LaunchWindow.module.css"; +import { + ArrowClockwiseIcon, + CaretUpIcon, + DotsThreeVerticalIcon, + MicrophoneIcon, + MicrophoneSlashIcon, + MinusIcon, + MonitorIcon, + TimerIcon, + VideoCameraIcon, + VideoCameraSlashIcon, + XIcon, +} from "@phosphor-icons/react"; +import { AnimatePresence, motion } from "motion/react"; +import { useEffect, useRef } from "react"; +import { RxDragHandleDots2 } from "react-icons/rx"; +import { Separator } from "@/components/ui/separator"; +import { useScopedT } from "../../contexts/I18nContext"; +import { useMicrophoneDevices } from "../../hooks/useMicrophoneDevices"; import { useScreenRecorder } from "../../hooks/useScreenRecorder"; +import { useVideoDevices } from "../../hooks/useVideoDevices"; import { Button } from "../ui/button"; -import { BsRecordCircle } from "react-icons/bs"; -import { FaRegStopCircle } from "react-icons/fa"; -import { MdMonitor } from "react-icons/md"; +import { HudInteractionContext } from "./contexts/HudInteractionContext"; +import { canToggleFloatingWebcamPreview } from "./floatingWebcamPreview"; +import { useHudBarDrag } from "./hooks/useHudBarDrag"; +import { useLaunchHudInteractionState } from "./hooks/useLaunchHudInteractionState"; +import { useLaunchWindowActions } from "./hooks/useLaunchWindowActions"; +import { useLaunchWindowSystemState } from "./hooks/useLaunchWindowSystemState"; +import { useRecordingTimer } from "./hooks/useRecordingTimer"; +import { useWebcamPreviewOverlay } from "./hooks/useWebcamPreviewOverlay"; +import styles from "./LaunchWindow.module.css"; +import { CountdownPopover } from "./popovers/CountdownPopover"; +import { + LaunchPopoverCoordinatorProvider, + useLaunchPopoverCoordinator, +} from "./popovers/LaunchPopoverCoordinator"; +import { MicPopover } from "./popovers/MicPopover"; +import { MorePopover } from "./popovers/MorePopover"; +import { ProjectPopover } from "./popovers/ProjectPopover"; +import { SourcePopover } from "./popovers/SourcePopover"; +import { WebcamPopover } from "./popovers/WebcamPopover"; +import { RecordingControls } from "./RecordingControls"; +import { MarqueeText } from "./SourceSelector"; + +const SHOW_DEV_UPDATE_PREVIEW = import.meta.env.DEV; export function LaunchWindow() { - const { recording, toggleRecording } = useScreenRecorder(); - const [selectedSource, setSelectedSource] = useState("Screen"); - const [hasSelectedSource, setHasSelectedSource] = useState(false); - - useEffect(() => { - const checkSelectedSource = async () => { - if (window.electronAPI) { - const source = await window.electronAPI.getSelectedSource(); - if (source) { - setSelectedSource(source.name); - setHasSelectedSource(true); - } else { - setSelectedSource("Screen"); - setHasSelectedSource(false); - } - } - }; - - checkSelectedSource(); - - const interval = setInterval(checkSelectedSource, 500); - return () => clearInterval(interval); - }, []); - - const truncateText = (text: string, maxLength: number = 6) => { - if (text.length <= maxLength) return text; - return text.substring(0, maxLength) + "..."; - }; - - const openSourceSelector = () => { - if (window.electronAPI) { - window.electronAPI.openSourceSelector(); - } - }; - - return ( - <div className="w-full h-full flex items-center bg-transparent"> - <div - className={`w-full max-w-2xl mx-auto flex items-center justify-between px-3 py-1.5 ${styles.electronDrag}`} - style={{ - borderRadius: 14, - background: 'linear-gradient(135deg, rgba(30,30,40,0.85) 0%, rgba(20,20,30,0.75) 100%)', - backdropFilter: 'blur(24px) saturate(180%)', - WebkitBackdropFilter: 'blur(24px) saturate(180%)', - boxShadow: '0 4px 16px 0 rgba(0,0,0,0.24), 0 1px 3px 0 rgba(0,0,0,0.12) inset', - border: '1px solid rgba(80,80,120,0.18)', - minHeight: 36, - }} - > - <Button - variant="link" - size="sm" - className={`gap-1 text-white bg-transparent hover:bg-transparent px-0 flex-1 text-left text-xs ${styles.electronNoDrag}`} - onClick={openSourceSelector} - > - <MdMonitor size={13} className="text-white" /> - {truncateText(selectedSource)} - </Button> - - <div className="w-px h-5 bg-white/30" /> - - <Button - variant="link" - size="sm" - onClick={hasSelectedSource ? toggleRecording : openSourceSelector} - disabled={!hasSelectedSource && !recording} - className={`gap-1 bg-transparent hover:bg-transparent px-0 flex-1 text-right text-xs ${styles.electronNoDrag}`} - > - {recording ? ( - <> - <FaRegStopCircle size={13} className="text-red-400" /> - <span className="text-red-400">Stop</span> - </> - ) : ( - <> - <BsRecordCircle size={13} className={hasSelectedSource ? "text-white" : "text-white/50"} /> - <span className={hasSelectedSource ? "text-white" : "text-white/50"}>Record</span> - </> - )} - </Button> - </div> - </div> - ); + return ( + <LaunchPopoverCoordinatorProvider> + <LaunchWindowContent /> + </LaunchPopoverCoordinatorProvider> + ); +} + +function LaunchWindowContent() { + const t = useScopedT("launch"); + const { openId, requestClose, requestOpen } = useLaunchPopoverCoordinator(); + + const { + recording, + paused, + finalizing, + countdownActive, + toggleRecording, + pauseRecording, + resumeRecording, + cancelRecording, + microphoneEnabled, + setMicrophoneEnabled, + microphoneDeviceId, + setMicrophoneDeviceId, + systemAudioEnabled, + setSystemAudioEnabled, + webcamEnabled, + setWebcamEnabled, + webcamDeviceId, + setWebcamDeviceId, + countdownDelay, + setCountdownDelay, + preparePermissions, + } = useScreenRecorder(); + + const { elapsed, formatTime } = useRecordingTimer(recording, paused); + const hudContentRef = useRef<HTMLDivElement>(null); + const hudBarRef = useRef<HTMLDivElement>(null); + + const { + selectedSource, + hasSelectedSource, + projectLibraryEntries, + handleSourceSelect, + openVideoFile, + openProjectFromLibrary, + syncSelectedSource, + refreshProjectLibrary, + } = useLaunchWindowActions(); + + const showWebcamControls = webcamEnabled && !recording; + const { devices, selectedDeviceId, setSelectedDeviceId } = useMicrophoneDevices( + microphoneEnabled || openId === "mic", + microphoneDeviceId, + ); + const { + devices: videoDevices, + selectedDeviceId: selectedVideoDeviceId, + setSelectedDeviceId: setSelectedVideoDeviceId, + } = useVideoDevices(webcamEnabled || openId === "webcam"); + + const { + hudOverlayMousePassthroughSupported, + platform, + appVersion, + hideHudFromCapture, + chooseRecordingsDirectory, + toggleHudCaptureProtection, + } = useLaunchWindowSystemState(preparePermissions); + + const supportsHudCaptureProtection = platform !== "linux"; + + useEffect(() => { + if (!selectedDeviceId) { + return; + } + + setMicrophoneDeviceId(selectedDeviceId === "default" ? undefined : selectedDeviceId); + }, [selectedDeviceId, setMicrophoneDeviceId]); + + useEffect(() => { + if (selectedVideoDeviceId && selectedVideoDeviceId !== "default") { + setWebcamDeviceId(selectedVideoDeviceId); + } + }, [selectedVideoDeviceId, setWebcamDeviceId]); + + const { + showFloatingWebcamPreview, + setShowFloatingWebcamPreview, + showRecordingWebcamPreview, + webcamPreviewOffset, + recordingWebcamPreviewContainerRef, + isWebcamPreviewDraggingRef, + webcamPreviewDragStartRef, + handleWebcamPreviewPointerDown, + handleWebcamPreviewPointerMove, + handleWebcamPreviewPointerUp, + setWebcamPreviewNode, + setRecordingWebcamPreviewNode, + } = useWebcamPreviewOverlay({ + webcamEnabled, + webcamDeviceId, + showWebcamControls, + webcamPopoverOpen: openId === "webcam", + hudOverlayMousePassthroughSupported, + }); + + useEffect(() => { + window.electronAPI?.hudOverlaySetWebcamPreviewVisible?.(showRecordingWebcamPreview); + }, [showRecordingWebcamPreview]); + + useEffect(() => { + return () => { + window.electronAPI?.hudOverlaySetWebcamPreviewVisible?.(false); + }; + }, []); + + const { + recordingHudOffset, + isHudDragging, + hudBarTransformRef, + isHudDraggingRef, + handleHudBarPointerDown, + handleHudBarPointerMove, + handleHudBarPointerUp, + } = useHudBarDrag({ + hudContentRef, + hudBarRef, + recordingWebcamPreviewContainerRef, + }); + + const { handleHudMouseEnter, handleHudMouseLeave, beginInteractiveHudAction } = + useLaunchHudInteractionState({ + openId, + isHudDraggingRef, + isWebcamPreviewDraggingRef, + webcamPreviewDragStartRef, + }); + + useEffect(() => { + let mounted = true; + + void window.electronAPI.getSelectedSource().then((source) => { + if (mounted) syncSelectedSource(source); + }); + + const cleanup = window.electronAPI.onSelectedSourceChanged((source) => { + if (mounted) syncSelectedSource(source); + }); + + return () => { + mounted = false; + cleanup?.(); + }; + }, [syncSelectedSource]); + + const hudStateTransition = { + duration: 0.24, + ease: [0.22, 1, 0.36, 1] as const, + }; + + const recordingControls = ( + <RecordingControls + paused={paused} + microphoneEnabled={microphoneEnabled} + elapsed={elapsed} + onToggleMicrophone={() => setMicrophoneEnabled(!microphoneEnabled)} + onPauseResume={paused ? resumeRecording : pauseRecording} + onStopRecording={toggleRecording} + onHideHud={() => window.electronAPI?.hudOverlayHide?.()} + onCancelRecording={cancelRecording} + formatTime={formatTime} + /> + ); + + const idleControls = ( + <> + {platform !== "linux" && ( + <> + <SourcePopover + selectedSource={selectedSource} + onSourceSelect={handleSourceSelect} + onOpen={beginInteractiveHudAction} + trigger={ + <Button + variant="outline" + size="lg" + className={`${styles.electronNoDrag} group gap-2 px-3 min-w-0 max-w-[180px] rounded-[11px] font-medium text-[12px] shrink-0 border-[var(--launch-border)] bg-[var(--launch-surface)] text-[var(--launch-text)] hover:border-[var(--launch-border-strong)] hover:bg-[var(--launch-hover)] transition-all ${openId === "sources" ? "border-[var(--launch-border-strong)] bg-[var(--launch-hover)]" : ""}`} + title={selectedSource} + > + <MonitorIcon size={16} className="shrink-0" /> + <div className="flex-1 min-w-0 overflow-hidden"> + <MarqueeText text={selectedSource} /> + </div> + <CaretUpIcon + size={10} + className={`text-[#6b6b78] ml-0.5 shrink-0 transition-transform duration-200 ${ + openId === "sources" ? "" : "rotate-180" + }`} + /> + </Button> + } + /> + + <Separator orientation="vertical" className="mx-[5px] h-6" /> + </> + )} + + <MicPopover + disabled={recording} + systemAudioEnabled={systemAudioEnabled} + onToggleSystemAudio={() => setSystemAudioEnabled(!systemAudioEnabled)} + microphoneEnabled={microphoneEnabled} + onDisableMicrophone={() => setMicrophoneEnabled(false)} + devices={devices} + microphoneDeviceId={microphoneDeviceId} + selectedDeviceId={selectedDeviceId} + onSelectDevice={(deviceId) => { + setMicrophoneEnabled(true); + setSelectedDeviceId(deviceId); + setMicrophoneDeviceId(deviceId === "default" ? undefined : deviceId); + }} + trigger={ + <Button + variant="ghost" + size="icon" + iconSize="lg" + title={ + microphoneEnabled + ? t("recording.disableMicrophone") + : t("recording.enableMicrophone") + } + className={microphoneEnabled ? styles.ibActive : ""} + > + {microphoneEnabled ? ( + <MicrophoneIcon size={18} /> + ) : ( + <MicrophoneSlashIcon size={18} /> + )} + </Button> + } + /> + + <WebcamPopover + disabled={recording} + webcamEnabled={webcamEnabled} + onDisableWebcam={() => setWebcamEnabled(false)} + canToggleFloatingPreview={canToggleFloatingWebcamPreview( + hudOverlayMousePassthroughSupported, + )} + showFloatingWebcamPreview={showFloatingWebcamPreview} + onToggleFloatingPreview={() => setShowFloatingWebcamPreview((current) => !current)} + showWebcamControls={showWebcamControls} + setWebcamPreviewNode={setWebcamPreviewNode} + videoDevices={videoDevices} + webcamDeviceId={webcamDeviceId} + selectedVideoDeviceId={selectedVideoDeviceId} + onSelectVideoDevice={(deviceId) => { + setWebcamEnabled(true); + setSelectedVideoDeviceId(deviceId); + setWebcamDeviceId(deviceId); + }} + trigger={ + <Button + variant="ghost" + size="icon" + iconSize="lg" + title={ + webcamEnabled + ? t("recording.disableWebcam") + : t("recording.enableWebcam") + } + className={webcamEnabled ? styles.ibActive : ""} + > + {webcamEnabled ? ( + <VideoCameraIcon size={18} /> + ) : ( + <VideoCameraSlashIcon size={18} /> + )} + </Button> + } + /> + + <CountdownPopover + countdownDelay={countdownDelay} + onSelectDelay={setCountdownDelay} + trigger={ + <Button + variant="ghost" + size="icon" + iconSize="lg" + title={t("recording.countdownDelay")} + className={countdownDelay > 0 ? styles.ibActive : ""} + > + <TimerIcon size={18} /> + </Button> + } + /> + + <button + type="button" + className={`${styles.recBtn} ${styles.electronNoDrag}`} + onClick={ + hasSelectedSource || platform === "linux" + ? toggleRecording + : () => { + beginInteractiveHudAction(); + requestOpen("sources"); + } + } + disabled={countdownActive} + title={t("recording.record")} + > + <div className={styles.recDot} /> + </button> + + <Separator orientation="vertical" className="mx-[5px] h-6" /> + + <div className="relative w-0 h-0"> + <ProjectPopover + entries={projectLibraryEntries} + onOpenProject={openProjectFromLibrary} + trigger={<div className="absolute inset-0 pointer-events-none opacity-0" />} + /> + </div> + + <MorePopover + supportsHudCaptureProtection={supportsHudCaptureProtection} + hideHudFromCapture={hideHudFromCapture} + onToggleHudCaptureProtection={() => { + void toggleHudCaptureProtection(); + }} + onChooseRecordingsDirectory={() => { + void chooseRecordingsDirectory(); + }} + onOpenVideoFile={() => { + void openVideoFile(); + }} + onOpenProjectBrowser={() => { + refreshProjectLibrary().then(() => { + requestOpen("projects"); + }); + }} + showDevUpdatePreview={SHOW_DEV_UPDATE_PREVIEW} + onPreviewUpdateUi={() => { + if (openId) requestClose(openId); + void window.electronAPI.previewUpdateToast().catch((error) => { + console.warn("Failed to preview update toast:", error); + }); + }} + appVersion={appVersion} + trigger={ + <Button variant="ghost" size="icon" iconSize="lg" title={t("recording.more")}> + <DotsThreeVerticalIcon size={18} /> + </Button> + } + /> + + <Button + variant="ghost" + size="icon" + iconSize="lg" + onClick={() => window.electronAPI?.hudOverlayHide?.()} + title={t("recording.hideHud")} + > + <MinusIcon size={16} /> + </Button> + + <Button + variant="ghost" + size="icon" + iconSize="lg" + onClick={() => window.electronAPI?.hudOverlayClose?.()} + title={t("recording.closeApp")} + > + <XIcon size={16} /> + </Button> + </> + ); + + const finalizingControls = ( + <div className={styles.finalizingState}> + <ArrowClockwiseIcon size={15} className={styles.finalizingSpin} /> + <div className={styles.finalizingCopy}> + <span>{t("recording.preparing", "Preparing recording")}</span> + <small>{t("recording.preparingSubtitle", "Opening the editor in a moment")}</small> + </div> + </div> + ); + + const hudMode = finalizing ? "finalizing" : recording ? "recording" : "idle"; + const useNativeHudBarDrag = + platform === "linux" || hudOverlayMousePassthroughSupported === false; + const shouldAnimateHudLayout = !recording && !showRecordingWebcamPreview && !isHudDragging; + + return ( + <HudInteractionContext.Provider + value={{ onMouseEnter: handleHudMouseEnter, onMouseLeave: handleHudMouseLeave }} + > + <div + className="w-full flex justify-center bg-transparent overflow-visible items-end pb-5 pointer-events-none" + style={{ height: "100vh" }} + > + <div + ref={hudContentRef} + className="flex items-center overflow-visible flex-col-reverse pointer-events-none" + > + <div className="flex flex-col items-center pointer-events-none p-2"> + <div + ref={hudBarTransformRef} + style={{ + transform: `translate3d(${recordingHudOffset.x}px, ${recordingHudOffset.y}px, 0)`, + }} + > + <motion.div + ref={hudBarRef} + layout={shouldAnimateHudLayout} + transition={hudStateTransition} + className={`${styles.bar} launch-theme mb-2 pointer-events-auto`} + onMouseEnter={handleHudMouseEnter} + onMouseLeave={handleHudMouseLeave} + > + <div + // Linux compositors and non-passthrough Windows fallback windows + // need native window dragging; the JS drag path only translates + // content inside the HUD window. + className={`flex items-center px-0.5 cursor-grab active:cursor-grabbing ${ + useNativeHudBarDrag ? styles.electronDrag : "" + }`} + onPointerDown={handleHudBarPointerDown} + onPointerMove={handleHudBarPointerMove} + onPointerUp={handleHudBarPointerUp} + onPointerCancel={handleHudBarPointerUp} + > + <RxDragHandleDots2 size={14} className="text-[#6b6b78]" /> + </div> + + <div className={styles.barStateViewport}> + <AnimatePresence initial={false} mode="wait"> + <motion.div + key={hudMode} + layout={shouldAnimateHudLayout} + className={styles.barState} + initial={{ + opacity: 0, + y: 10, + scale: 0.985, + filter: "blur(8px)", + }} + animate={{ + opacity: 1, + y: 0, + scale: 1, + filter: "blur(0px)", + }} + exit={{ + opacity: 0, + y: -10, + scale: 0.985, + filter: "blur(6px)", + }} + transition={hudStateTransition} + > + {finalizing + ? finalizingControls + : recording + ? recordingControls + : idleControls} + </motion.div> + </AnimatePresence> + </div> + </motion.div> + </div> + {showRecordingWebcamPreview && ( + <div + ref={recordingWebcamPreviewContainerRef} + className={`${styles.recordingWebcamPreview} ${styles.electronNoDrag} pointer-events-auto`} + data-hud-interactive + title={t("recording.webcam")} + style={{ + transform: `translate(${webcamPreviewOffset.x}px, ${webcamPreviewOffset.y}px)`, + }} + onMouseEnter={handleHudMouseEnter} + onMouseLeave={handleHudMouseLeave} + onPointerDown={handleWebcamPreviewPointerDown} + onPointerMove={handleWebcamPreviewPointerMove} + onPointerUp={handleWebcamPreviewPointerUp} + onPointerCancel={handleWebcamPreviewPointerUp} + > + <video + ref={setRecordingWebcamPreviewNode} + className={styles.recordingWebcamPreviewVideo} + muted + playsInline + style={{ transform: "scaleX(-1)" }} + /> + </div> + )} + </div> + </div> + </div> + </HudInteractionContext.Provider> + ); } diff --git a/src/components/launch/RecordingControls.tsx b/src/components/launch/RecordingControls.tsx new file mode 100644 index 000000000..d7ed41a75 --- /dev/null +++ b/src/components/launch/RecordingControls.tsx @@ -0,0 +1,151 @@ +import { + MicrophoneIcon, + MicrophoneSlashIcon, + MinusIcon, + PauseIcon, + PlayIcon, + XIcon, +} from "@phosphor-icons/react"; +import { useMemo } from "react"; +import { useScopedT } from "@/contexts/I18nContext"; +import { Button } from "@/components/ui/button"; +import { Separator } from "@/components/ui/separator"; +import styles from "./LaunchWindow.module.css"; + +interface RecordingControlsProps { + paused: boolean; + microphoneEnabled: boolean; + elapsed: number; + onToggleMicrophone: () => void; + onPauseResume: () => void; + onStopRecording: () => void; + onHideHud: () => void; + onCancelRecording: () => void; + formatTime: (seconds: number) => string; +} + +export const RecordingControls = ({ + paused, + microphoneEnabled, + elapsed, + onToggleMicrophone, + onPauseResume, + onStopRecording, + onHideHud, + onCancelRecording, + formatTime, +}: RecordingControlsProps) => { + const t = useScopedT("launch"); + + const memoizedControls = useMemo(() => { + return ( + <> + <div className="flex items-center gap-[5px]"> + <div + className={`w-[7px] h-[7px] rounded-full ${ + paused ? "bg-[#fbbf24]" : `bg-[#f43f5e] ${styles.recDotBlink}` + }`} + /> + <span + className={`text-[10px] font-bold tracking-[0.06em] ${ + paused ? "text-[#fbbf24]" : "text-[#f43f5e]" + }`} + > + {paused ? t("recording.paused") : t("recording.rec")} + </span> + </div> + + <span + className={`font-mono text-xs font-semibold min-w-[52px] text-center tracking-[0.02em] ${ + paused ? "text-[#fbbf24]" : "text-[var(--launch-text)]" + }`} + > + {formatTime(elapsed)} + </span> + + <Separator orientation="vertical" className="mx-[5px] h-6" /> + + <span title={t("recording.micToggleDisabledTip")}> + <Button + variant="ghost" + size="icon" + iconSize="lg" + className={microphoneEnabled ? styles.ibActive : ""} + aria-label={t("recording.micToggleDisabledTip")} + disabled + onClick={onToggleMicrophone} + > + {microphoneEnabled ? ( + <MicrophoneIcon size={18} /> + ) : ( + <MicrophoneSlashIcon size={18} /> + )} + </Button> + </span> + + <Separator orientation="vertical" className="mx-[5px] h-6" /> + + <Button + variant={paused ? "default" : "ghost"} + size="icon" + iconSize="lg" + onClick={onPauseResume} + title={paused ? t("recording.resume") : t("recording.pause")} + aria-label={paused ? t("recording.resume") : t("recording.pause")} + className={paused ? styles.ibGreen : ""} + > + {paused ? ( + <PlayIcon size={18} fill="currentColor" strokeWidth={0} /> + ) : ( + <PauseIcon size={18} /> + )} + </Button> + + <button + type="button" + onClick={onStopRecording} + title={t("recording.stop")} + aria-label={t("recording.stop")} + className={`${styles.recBtn} ${styles.electronNoDrag}`} + > + <span className={styles.stopSquare} /> + </button> + + <Button + variant="ghost" + size="icon" + iconSize="lg" + onClick={onHideHud} + title={t("recording.hideHud")} + aria-label={t("recording.hideHud")} + > + <MinusIcon size={16} /> + </Button> + + <Button + variant="ghost" + size="icon" + iconSize="lg" + onClick={onCancelRecording} + title={t("recording.cancel")} + aria-label={t("recording.cancel")} + > + <XIcon size={18} /> + </Button> + </> + ); + }, [ + paused, + microphoneEnabled, + elapsed, + onToggleMicrophone, + onPauseResume, + onStopRecording, + onHideHud, + onCancelRecording, + formatTime, + t, + ]); + + return memoizedControls; +}; diff --git a/src/components/launch/SourceSelector.css b/src/components/launch/SourceSelector.css new file mode 100644 index 000000000..56fbc2a02 --- /dev/null +++ b/src/components/launch/SourceSelector.css @@ -0,0 +1,142 @@ +.source-selector-popover { + background: var(--launch-surface); + border: 1px solid var(--launch-border); + border-radius: 16px; + backdrop-filter: blur(var(--launch-popover-blur, 24px)) + saturate(var(--launch-popover-saturate, 145%)); + -webkit-backdrop-filter: blur(var(--launch-popover-blur, 24px)) + saturate(var(--launch-popover-saturate, 145%)); + box-shadow: var(--launch-bar-shadow); +} + +.source-selector-item { + color: var(--launch-text-muted); +} + +.source-selector-item:hover { + background: var(--launch-hover); + color: var(--launch-text); +} + +.source-selector-item-selected { + background: var(--launch-selected); + color: var(--launch-accent); +} + +.source-selector-thumb-fallback { + background: var(--launch-panel); +} + +.source-selector-text { + color: var(--launch-text); +} + +.source-selector-muted { + color: var(--launch-text-muted); +} + +.source-selector-subtle { + color: var(--launch-text-muted); + opacity: 0.7; +} + +.source-selector-label { + color: var(--launch-label); +} + +.source-selector-dot { + background: var(--launch-accent); +} + +.source-selector-dot-inner { + background: #f8fbff; +} + +.source-selector-accent-border { + border-color: var(--launch-accent); +} + +/* Custom Scrollbar for Source Selector */ +.source-selector-scroll::-webkit-scrollbar { + width: 5px; + height: 5px; +} + +.source-selector-scroll::-webkit-scrollbar-track { + background: transparent; +} + +.source-selector-scroll::-webkit-scrollbar-thumb { + background: rgba(107, 107, 120, 0.2); + border-radius: 20px; + transition: background 0.2s ease; +} + +.source-selector-scroll::-webkit-scrollbar-thumb:hover { + background: rgba(107, 107, 120, 0.4); +} + +/* Support for Firefox */ +.source-selector-scroll { + scrollbar-width: thin; + scrollbar-color: rgba(107, 107, 120, 0.2) transparent; +} + +.source-selector-marquee { + position: relative; + overflow: hidden; + white-space: nowrap; +} + +.source-selector-marquee-static { + display: block; + width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.source-selector-marquee-animated { + position: absolute; + inset: 0; + opacity: 0; + pointer-events: none; +} + +.source-selector-marquee-track { + display: inline-flex; + max-width: none; + white-space: nowrap; + will-change: transform; + animation: source-selector-marquee 10s linear infinite; + animation-play-state: paused; +} + +.source-selector-marquee-segment { + display: inline-block; +} + +.source-selector-marquee-duplicate { + padding-left: 20px; +} + +.group:hover .source-selector-marquee[data-overflowing="true"] .source-selector-marquee-static { + opacity: 0; +} + +.group:hover .source-selector-marquee[data-overflowing="true"] .source-selector-marquee-animated { + opacity: 1; +} + +.group:hover .source-selector-marquee[data-overflowing="true"] .source-selector-marquee-track { + animation-play-state: running; +} + +@keyframes source-selector-marquee { + from { + transform: translateX(0); + } + to { + transform: translateX(-50%); + } +} diff --git a/src/components/launch/SourceSelector.module.css b/src/components/launch/SourceSelector.module.css index e1aabf1de..79cf78532 100644 --- a/src/components/launch/SourceSelector.module.css +++ b/src/components/launch/SourceSelector.module.css @@ -1,41 +1,74 @@ - .glassContainer { - background: linear-gradient(135deg, rgba(28,28,34,0.92) 0%, rgba(18,18,22,0.88) 100%); - backdrop-filter: blur(20px) saturate(160%); - -webkit-backdrop-filter: blur(20px) saturate(160%); - border-radius: 14px; - box-shadow: 0 4px 16px 0 rgba(0,0,0,0.32), 0 1px 3px 0 rgba(0,0,0,0.18) inset; - border: 1px solid rgba(60,60,80,0.18); + background: linear-gradient(135deg, rgba(28, 28, 34, 0.92) 0%, rgba(18, 18, 22, 0.88) 100%); + backdrop-filter: blur(20px) saturate(160%); + -webkit-backdrop-filter: blur(20px) saturate(160%); + border-radius: 14px; + box-shadow: + 0 4px 16px 0 rgba(0, 0, 0, 0.32), + 0 1px 3px 0 rgba(0, 0, 0, 0.18) inset; + border: 1px solid rgba(60, 60, 80, 0.18); } .sourceCard { - border-radius: 10px; - background: linear-gradient(120deg, rgba(38,38,48,0.98) 0%, rgba(24,24,32,0.96) 100%); - border: 1px solid rgba(60,60,80,0.22); - box-shadow: 0 2px 8px 0 rgba(0,0,0,0.18); - transition: box-shadow 0.2s, border 0.2s, background 0.2s; + border-radius: 10px; + background: linear-gradient(120deg, rgba(38, 38, 48, 0.98) 0%, rgba(24, 24, 32, 0.96) 100%); + border: 1px solid rgba(60, 60, 80, 0.22); + box-shadow: 0 2px 8px 0 rgba(0, 0, 0, 0.18); + transition: + box-shadow 0.2s, + border 0.2s, + background 0.2s; } .selected { - border: 2px solid #34B27B; - background: linear-gradient(120deg, rgba(91,33,182,0.18) 0%, rgba(38,38,48,0.98) 100%); - box-shadow: 0 0 0 2px #34B27B33; + border: 2px solid #2563eb; + background: linear-gradient(120deg, rgba(91, 33, 182, 0.18) 0%, rgba(38, 38, 48, 0.98) 100%); + box-shadow: 0 0 0 2px #2563eb33; } .icon { - width: 13px; - height: 13px; - color: #c7d2fe; + width: 13px; + height: 13px; + color: #c7d2fe; } .name { - font-size: 0.85rem; - color: #f3f4f6; - font-weight: 500; - letter-spacing: 0.01em; + font-size: 0.85rem; + color: #f3f4f6; + font-weight: 500; + letter-spacing: 0.01em; } .cardText { - color: #a1a1aa; - font-size: 0.75rem; + color: #a1a1aa; + font-size: 0.75rem; +} + +/* scrollbar */ +.sourceGridScroll { + scrollbar-width: thin; + scrollbar-color: rgba(37, 99, 235, 0.5) rgba(40, 40, 50, 0.6); +} + +.sourceGridScroll::-webkit-scrollbar { + width: 8px; +} + +.sourceGridScroll::-webkit-scrollbar-track { + background: rgba(30, 30, 38, 0.5); + border-radius: 4px; + margin: 4px 0; +} + +.sourceGridScroll::-webkit-scrollbar-thumb { + background: rgba(80, 80, 100, 0.6); + border-radius: 4px; +} + +.sourceGridScroll::-webkit-scrollbar-thumb:hover { + background: rgba(37, 99, 235, 0.6); +} + +.sourceGridScroll::-webkit-scrollbar-thumb:active { + background: rgba(37, 99, 235, 0.8); } diff --git a/src/components/launch/SourceSelector.tsx b/src/components/launch/SourceSelector.tsx index 61bc2ebd3..dcbde5ed2 100644 --- a/src/components/launch/SourceSelector.tsx +++ b/src/components/launch/SourceSelector.tsx @@ -1,159 +1,383 @@ -import { useState, useEffect } from "react"; -import { Button } from "../ui/button"; -import { MdCheck } from "react-icons/md"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs"; -import { Card } from "../ui/card"; -import styles from "./SourceSelector.module.css"; - -interface DesktopSource { - id: string; - name: string; - thumbnail: string | null; - display_id: string; - appIcon: string | null; +import * as React from "react"; +import { MonitorIcon, AppWindowIcon, CaretUpIcon } from "@phosphor-icons/react"; +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { useScopedT } from "@/contexts/I18nContext"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import { + mapRawSource, + isScreenSource, + isWindowSource, + type DesktopSource, +} from "./popovers/launchPopoverTypes"; +import "./launchTheme.css"; +import "./SourceSelector.css"; +import { useHudInteraction } from "./contexts/HudInteractionContext"; + +interface SourceSelectorProps { + /** List of available screen sources */ + screenSources?: DesktopSource[]; + /** List of available window sources */ + windowSources?: DesktopSource[]; + /** Currently selected source name */ + selectedSource?: string; + /** Loading state */ + loading?: boolean; + /** Callback when a source is selected */ + onSourceSelect?: (source: DesktopSource) => void; + /** Callback to fetch sources */ + onFetchSources?: () => Promise<void>; + /** Whether the popover is open */ + open?: boolean; + /** Callback when open state changes */ + onOpenChange?: (open: boolean) => void; + /** Optional custom trigger element */ + children?: React.ReactNode; } -export function SourceSelector() { - const [sources, setSources] = useState<DesktopSource[]>([]); - const [selectedSource, setSelectedSource] = useState<DesktopSource | null>(null); - const [loading, setLoading] = useState(true); - - useEffect(() => { - async function fetchSources() { - setLoading(true); - try { - const rawSources = await window.electronAPI.getSources({ - types: ['screen', 'window'], - thumbnailSize: { width: 320, height: 180 }, - fetchWindowIcons: true - }); - setSources( - rawSources.map(source => ({ - id: source.id, - name: - source.id.startsWith('window:') && source.name.includes(' — ') - ? source.name.split(' — ')[1] || source.name - : source.name, - thumbnail: source.thumbnail, - display_id: source.display_id, - appIcon: source.appIcon - })) - ); - } catch (error) { - console.error('Error loading sources:', error); - } finally { - setLoading(false); - } - } - fetchSources(); - }, []); - - const screenSources = sources.filter(s => s.id.startsWith('screen:')); - const windowSources = sources.filter(s => s.id.startsWith('window:')); - - const handleSourceSelect = (source: DesktopSource) => setSelectedSource(source); - const handleShare = async () => { - if (selectedSource) await window.electronAPI.selectSource(selectedSource); - }; - - if (loading) { - return ( - <div className={`h-full flex items-center justify-center ${styles.glassContainer}`} style={{ minHeight: '100vh' }}> - <div className="text-center"> - <div className="animate-spin rounded-full h-6 w-6 border-b-2 border-zinc-600 mx-auto mb-2" /> - <p className="text-xs text-zinc-300">Loading sources...</p> - </div> - </div> - ); - } - - return ( - <div className={`min-h-screen flex flex-col items-center justify-center ${styles.glassContainer}`}> - <div className="flex-1 flex flex-col w-full max-w-xl" style={{ padding: 0 }}> - <Tabs defaultValue="screens"> - <TabsList className="grid grid-cols-2 mb-3 bg-zinc-900/40 rounded-full"> - <TabsTrigger value="screens" className="data-[state=active]:bg-[#34B27B] data-[state=active]:text-white text-zinc-200 rounded-full text-xs py-1">Screens</TabsTrigger> - <TabsTrigger value="windows" className="data-[state=active]:bg-[#34B27B] data-[state=active]:text-white text-zinc-200 rounded-full text-xs py-1">Windows</TabsTrigger> - </TabsList> - <div className="h-60 flex flex-col justify-stretch"> - <TabsContent value="screens" className="h-full"> - <div className="grid grid-cols-2 gap-2 h-full overflow-y-auto pr-1 relative"> - {screenSources.map(source => ( - <Card - key={source.id} - className={`${styles.sourceCard} ${selectedSource?.id === source.id ? styles.selected : ''} cursor-pointer h-fit p-2 scale-95`} - style={{ margin: 8, width: '90%', maxWidth: 220 }} - onClick={() => handleSourceSelect(source)} - > - <div className="p-1"> - <div className="relative mb-1"> - <img - src={source.thumbnail || ''} - alt={source.name} - className="w-full aspect-video object-cover rounded border border-zinc-800" - /> - {selectedSource?.id === source.id && ( - <div className="absolute -top-1 -right-1"> - <div className="w-4 h-4 bg-[#34B27B] rounded-full flex items-center justify-center shadow-md"> - <MdCheck className={styles.icon} /> - </div> - </div> - )} - </div> - <div className={styles.name + " truncate"}>{source.name}</div> - </div> - </Card> - ))} - </div> - </TabsContent> - <TabsContent value="windows" className="h-full"> - <div className="grid grid-cols-2 gap-2 h-full overflow-y-auto pr-1 relative"> - {windowSources.map(source => ( - <Card - key={source.id} - className={`${styles.sourceCard} ${selectedSource?.id === source.id ? styles.selected : ''} cursor-pointer h-fit p-2 scale-95`} - style={{ margin: 8, width: '90%', maxWidth: 220 }} - onClick={() => handleSourceSelect(source)} - > - <div className="p-1"> - <div className="relative mb-1"> - <img - src={source.thumbnail || ''} - alt={source.name} - className="w-full aspect-video object-cover rounded border border-gray-700" - /> - {selectedSource?.id === source.id && ( - <div className="absolute -top-1 -right-1"> - <div className="w-4 h-4 bg-blue-600 rounded-full flex items-center justify-center shadow-md"> - <MdCheck className={styles.icon} /> - </div> - </div> - )} - </div> - <div className="flex items-center gap-1"> - {source.appIcon && ( - <img - src={source.appIcon} - alt="App icon" - className={styles.icon + " flex-shrink-0"} - /> - )} - <div className={styles.name + " truncate"}>{source.name}</div> - </div> - </div> - </Card> - ))} - </div> - </TabsContent> - </div> - </Tabs> - </div> - <div className="border-t border-zinc-800 p-2 w-full max-w-xl"> - <div className="flex justify-center gap-2"> - <Button variant="outline" onClick={() => window.close()} className="px-4 py-1 text-xs bg-zinc-800 border-zinc-700 text-zinc-200 hover:bg-zinc-700">Cancel</Button> - <Button onClick={handleShare} disabled={!selectedSource} className="px-4 py-1 text-xs bg-[#34B27B] text-white hover:bg-[#34B27B]/80 disabled:opacity-50 disabled:bg-zinc-700">Share</Button> - </div> - </div> - </div> - ); +export function MarqueeText({ text }: { text: string }) { + const staticRef = useRef<HTMLSpanElement>(null); + const [overflowing, setOverflowing] = useState(false); + + useLayoutEffect(() => { + const node = staticRef.current; + if (!node || node.textContent !== text) return; + const checkOverflow = () => { + setOverflowing(node.scrollWidth > node.clientWidth + 1); + }; + checkOverflow(); + const observer = new ResizeObserver(checkOverflow); + observer.observe(node); + return () => observer.disconnect(); + }, [text]); + + return ( + <div + className="w-full source-selector-marquee" + data-overflowing={overflowing ? "true" : "false"} + > + <span ref={staticRef} className="source-selector-marquee-static"> + {text} + </span> + <span className="source-selector-marquee-animated"> + <span className="source-selector-marquee-track"> + <span className="source-selector-marquee-segment">{text}</span> + <span className="source-selector-marquee-segment source-selector-marquee-duplicate"> + {text} + </span> + </span> + </span> + </div> + ); } + +/** + * SourceSelectorContent - The actual list of sources + */ +export const SourceSelectorContent = ({ + screenSources = [], + windowSources = [], + selectedSource = "Screen", + loading = false, + onSourceSelect = () => undefined, +}: Pick< + SourceSelectorProps, + "screenSources" | "windowSources" | "selectedSource" | "loading" | "onSourceSelect" +>) => { + const t = useScopedT("launch"); + const renderSourceItem = (source: DesktopSource, index: number) => { + const isSelected = selectedSource === source.name; + return ( + <button + key={`${source.id}-${index}`} + type="button" + className={cn( + "source-selector-item group min-h-[46px] w-full rounded-[11px] px-3 py-2.5 text-left font-medium flex items-center justify-start gap-3", + isSelected && "source-selector-item-selected", + )} + onClick={() => onSourceSelect(source)} + > + <div className="relative flex-shrink-0"> + {source.thumbnail ? ( + <img + src={source.thumbnail} + alt="" + className="w-12 h-8 rounded-[8px] object-cover bg-black/50" + onError={(e) => { + (e.target as HTMLImageElement).style.display = "none"; + }} + /> + ) : ( + <div className="source-selector-thumb-fallback w-12 h-8 rounded-[8px] flex items-center justify-center"> + {source.sourceType === "window" ? ( + <AppWindowIcon className="w-5 h-5 source-selector-muted" /> + ) : ( + <MonitorIcon className="w-5 h-5 source-selector-muted" /> + )} + </div> + )} + </div> + + <div className="flex-1 min-w-0 flex flex-col items-start text-left"> + <div className="text-sm font-medium source-selector-text w-full"> + <MarqueeText text={source.windowTitle || source.name} /> + </div> + <div className="text-xs source-selector-subtle truncate w-full text-left"> + {source.sourceType === "screen" + ? t("recording.screen") + : t("recording.window")} + </div> + </div> + </button> + ); + }; + + const hasAnySources = screenSources.length > 0 || windowSources.length > 0; + + if (loading && !hasAnySources) { + return ( + <div className="flex items-center justify-center py-8"> + <div className="animate-spin rounded-full h-5 w-5 border-b-2 source-selector-accent-border" /> + </div> + ); + } + + return ( + <div className="max-h-[320px] overflow-y-auto overflow-x-hidden p-2 source-selector-scroll"> + {hasAnySources ? ( + <> + {screenSources.length > 0 ? ( + <div className="space-y-1"> + <div className="px-3 py-2 text-[10px] font-semibold uppercase tracking-[0.08em] source-selector-label flex items-center gap-2"> + {t("recording.screens")} + <span + className={cn( + "normal-case tracking-normal text-[10px] source-selector-muted transition-opacity duration-150", + loading ? "opacity-100" : "opacity-0", + )} + > + {t("common.loading", "Refreshing...")} + </span> + </div> + <div className="space-y-0.5"> + {screenSources.map((source, index) => + renderSourceItem(source, index), + )} + </div> + </div> + ) : null} + {windowSources.length > 0 ? ( + <div className="space-y-1"> + <div className="px-3 py-2 text-[10px] font-semibold uppercase tracking-[0.08em] source-selector-label"> + {t("recording.windows")} + </div> + <div className="space-y-0.5"> + {windowSources.map((source, index) => + renderSourceItem(source, index), + )} + </div> + </div> + ) : null} + </> + ) : ( + <div className="text-center py-8 text-sm source-selector-muted"> + {t("recording.noSourcesFound")} + </div> + )} + </div> + ); +}; + +/** + * SourceSelector - A rich source selection component with thumbnails + * Uses Radix UI Popover for positioning and accessibility + */ +export const SourceSelector = React.memo(function SourceSelector({ + screenSources: propsScreenSources, + windowSources: propsWindowSources, + selectedSource: propsSelectedSource, + loading: propsLoading, + onSourceSelect: propsOnSourceSelect, + onFetchSources: propsOnFetchSources, + open: propsOpen, + onOpenChange: propsOnOpenChange, + children, +}: SourceSelectorProps) { + // Internal state for standalone/uncontrolled use + const [internalOpen, setInternalOpen] = useState(false); + const [internalSources, setInternalSources] = useState<DesktopSource[]>([]); + const [internalLoading, setInternalLoading] = useState(false); + const [internalSelectedSource, setInternalSelectedSource] = useState("Screen"); + + // Determine if we should use internal or external state/logic + const isAutonomous = propsOpen === undefined; + const open = propsOpen ?? internalOpen; + const onOpenChange = propsOnOpenChange ?? setInternalOpen; + const loading = propsLoading ?? internalLoading; + const selectedSource = propsSelectedSource ?? internalSelectedSource; + + // Default fetching logic + const defaultFetchSources = useCallback(async () => { + if (!window.electronAPI) return; + setInternalLoading(true); + try { + const rawSources = await window.electronAPI.getSources({ + types: ["screen", "window"], + thumbnailSize: { width: 160, height: 90 }, + fetchWindowIcons: true, + }); + setInternalSources(rawSources.map((s) => mapRawSource(s as DesktopSource))); + } catch (error) { + console.error("Failed to fetch sources:", error); + } finally { + setInternalLoading(false); + } + }, []); + + const onFetchSources = propsOnFetchSources ?? defaultFetchSources; + + // Default selection logic + const onSourceSelect = useCallback( + async (source: DesktopSource) => { + if (propsOnSourceSelect) { + propsOnSourceSelect(source); + return; + } + if (!window.electronAPI) return; + try { + const result = await window.electronAPI.selectSource(source); + if (result) { + setInternalSelectedSource(source.name); + } + } catch (error) { + console.error("Failed to select source:", error); + } + }, + [propsOnSourceSelect], + ); + + // Split sources for internal use + const internalScreenSources = useMemo( + () => internalSources.filter(isScreenSource), + [internalSources], + ); + const internalWindowSources = useMemo( + () => internalSources.filter(isWindowSource), + [internalSources], + ); + + const screenSources = propsScreenSources ?? internalScreenSources; + const windowSources = propsWindowSources ?? internalWindowSources; + + const hasPrefetchedRef = useRef(false); + const fetchInFlightRef = useRef(false); + const lastFetchedAtRef = useRef(0); + + const fetchSourcesOnce = useCallback( + async (allowRecentSkip: boolean) => { + if (fetchInFlightRef.current) { + return; + } + if (allowRecentSkip && Date.now() - lastFetchedAtRef.current < 750) { + return; + } + fetchInFlightRef.current = true; + try { + await onFetchSources(); + lastFetchedAtRef.current = Date.now(); + } finally { + fetchInFlightRef.current = false; + } + }, + [onFetchSources], + ); + + const prefetchSources = React.useCallback(() => { + if (hasPrefetchedRef.current) { + return; + } + hasPrefetchedRef.current = true; + void fetchSourcesOnce(false); + }, [fetchSourcesOnce]); + + // Fetch sources when popover opens + useEffect(() => { + if (open) { + void fetchSourcesOnce(true); + } + }, [open, fetchSourcesOnce]); + + // In autonomous mode, we might want to start open + useEffect(() => { + if (isAutonomous) { + setInternalOpen(true); + } + }, [isAutonomous]); + + const trigger = children ? ( + React.isValidElement(children) ? ( + React.cloneElement(children as React.ReactElement<React.HTMLAttributes<HTMLElement>>, { + onPointerEnter: prefetchSources, + onFocusCapture: prefetchSources, + }) + ) : ( + children + ) + ) : ( + <Button + variant="outline" + size="lg" + onPointerEnter={prefetchSources} + onFocusCapture={prefetchSources} + className={cn( + "group gap-2 px-3 min-w-0 max-w-[180px] rounded-[11px] font-medium text-[12px] [ -webkit-app-region:no-drag ] shrink-0", + "border-[#2a2a34] bg-[#1a1a22] text-[#eeeef2] hover:border-[#3e3e4c] hover:bg-[#20202a] transition-all", + "data-[state=open]:border-[#3e3e4c] data-[state=open]:bg-[#20202a]", + )} + title={selectedSource} + > + <MonitorIcon size={16} className="shrink-0" /> + <div className="flex-1 min-w-0"> + <MarqueeText text={selectedSource} /> + </div> + <CaretUpIcon + size={10} + className={cn( + "text-[#6b6b78] ml-0.5 shrink-0 transition-transform duration-200", + open ? "" : "rotate-180", + )} + /> + </Button> + ); + + const { onMouseEnter } = useHudInteraction(); + + return ( + <Popover open={open} onOpenChange={onOpenChange} modal={false}> + <PopoverTrigger asChild>{trigger}</PopoverTrigger> + <PopoverContent + className="launch-theme w-80 p-0 source-selector-popover" + unstyled + align="start" + sideOffset={8} + side="top" + alignOffset={-8} + avoidCollisions={true} + collisionPadding={10} + usePortal={false} + onMouseEnter={onMouseEnter} + > + <SourceSelectorContent + screenSources={screenSources} + windowSources={windowSources} + selectedSource={selectedSource} + loading={loading} + onSourceSelect={onSourceSelect} + /> + </PopoverContent> + </Popover> + ); +}); + +SourceSelector.displayName = "SourceSelector"; diff --git a/src/components/launch/UpdateToastWindow.module.css b/src/components/launch/UpdateToastWindow.module.css new file mode 100644 index 000000000..5b0c76dd5 --- /dev/null +++ b/src/components/launch/UpdateToastWindow.module.css @@ -0,0 +1,174 @@ +.window { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + padding: 8px; + box-sizing: border-box; + background: transparent; +} + +.card { + display: flex; + align-items: flex-start; + gap: 12px; + width: 100%; + padding: 14px; + border: 1px solid hsl(var(--border)); + border-radius: 8px; + background: hsl(var(--background)); + box-shadow: none; + color: hsl(var(--foreground)); +} + +.icon { + display: flex; + align-items: center; + justify-content: center; + flex: 0 0 32px; + width: 32px; + height: 32px; + border-radius: 7px; + background: hsl(var(--muted)); + color: hsl(var(--muted-foreground)); +} + +.iconError { + background: hsl(var(--destructive) / 0.14); + color: hsl(var(--destructive)); +} + +.content { + min-width: 0; + flex: 1; +} + +.headingRow { + display: flex; + align-items: center; + gap: 6px; + min-height: 19px; +} + +.headingRow h1 { + margin: 0; + font-size: 13px; + font-weight: 600; + line-height: 1.35; + letter-spacing: 0; +} + +.content > p { + margin: 4px 0 0; + color: hsl(var(--muted-foreground)); + font-size: 12px; + line-height: 1.45; +} + +.version, +.preview { + padding: 2px 6px; + border-radius: 999px; + background: hsl(var(--muted)); + color: hsl(var(--muted-foreground)); + font-size: 10px; + font-weight: 600; + line-height: 1; +} + +.preview { + background: hsl(var(--primary) / 0.12); + color: hsl(var(--primary)); + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.actions { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 12px; +} + +.primaryButton, +.secondaryButton { + height: 30px; + padding: 0 12px; + border-radius: 6px; + font-size: 12px; + font-weight: 600; + cursor: pointer; + transition: + background 140ms ease, + border-color 140ms ease, + transform 140ms ease; +} + +.primaryButton:active, +.secondaryButton:active { + transform: translateY(1px); +} + +.primaryButton { + border: 1px solid hsl(var(--primary)); + background: hsl(var(--primary)); + color: hsl(var(--primary-foreground)); +} + +.primaryButton:hover { + background: hsl(var(--primary) / 0.9); +} + +.secondaryButton { + border: 1px solid hsl(var(--input)); + background: hsl(var(--background)); + color: hsl(var(--muted-foreground)); +} + +.secondaryButton:hover { + background: hsl(var(--accent)); + color: hsl(var(--accent-foreground)); +} + +.progressBlock { + margin-top: 12px; +} + +.progressTrack { + height: 5px; + overflow: hidden; + border-radius: 999px; + background: hsl(var(--muted)); +} + +.progressFill { + height: 100%; + border-radius: inherit; + background: hsl(var(--primary)); + transition: width 220ms ease; +} + +.progressMeta { + display: flex; + justify-content: space-between; + gap: 12px; + margin-top: 6px; + color: hsl(var(--muted-foreground)); + font-size: 10px; +} + +.progressMeta strong { + color: hsl(var(--foreground)); + font-weight: 600; +} + +.spin { + animation: spin 900ms linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} diff --git a/src/components/launch/UpdateToastWindow.tsx b/src/components/launch/UpdateToastWindow.tsx new file mode 100644 index 000000000..9716ed30c --- /dev/null +++ b/src/components/launch/UpdateToastWindow.tsx @@ -0,0 +1,212 @@ +import { + ArrowClockwiseIcon, + CheckCircleIcon, + DownloadSimpleIcon, + WarningCircleIcon, +} from "@phosphor-icons/react"; +import { useEffect, useState } from "react"; +import { useI18n } from "@/contexts/I18nContext"; +import styles from "./UpdateToastWindow.module.css"; + +type UpdateToastPayload = { + version: string; + detail: string; + phase: "available" | "downloading" | "ready" | "error"; + delayMs: number; + isPreview?: boolean; + isExperimental?: boolean; + progressPercent?: number; + transferredBytes?: number; + totalBytes?: number; + bytesPerSecond?: number; + primaryAction?: "install-and-restart" | "retry-check"; +}; + +function formatBytes(value: number | undefined) { + if (value === undefined || !Number.isFinite(value) || value <= 0) { + return null; + } + + const megabytes = value / (1024 * 1024); + return megabytes >= 1024 + ? `${(megabytes / 1024).toFixed(1)} GB` + : `${megabytes.toFixed(megabytes >= 100 ? 0 : 1)} MB`; +} + +type Translate = ReturnType<typeof useI18n>["t"]; + +function getTitle(payload: UpdateToastPayload, t: Translate) { + switch (payload.phase) { + case "available": + return payload.isExperimental + ? t( + "launch.updateToast.experimentalAvailableTitle", + "Experimental update available", + ) + : t("launch.updateToast.availableTitle", "Update available"); + case "downloading": + return t("launch.updateToast.downloadingTitle", "Downloading your update"); + case "ready": + return t("launch.updateToast.readyTitle", "Ready to restart"); + case "error": + return payload.primaryAction === "retry-check" + ? t("launch.updateToast.checkErrorTitle", "Couldn’t check for updates") + : t("launch.updateToast.downloadErrorTitle", "Couldn’t download the update"); + } +} + +function getDetail(payload: UpdateToastPayload, t: Translate) { + if (payload.phase === "available" && payload.isExperimental) { + return t( + "launch.updateToast.experimentalDescription", + "You've opted into experimental updates so you have the choice to test the latest update of Recordly before it's widely available.", + ); + } + + return payload.detail; +} + +function getPrimaryLabel(payload: UpdateToastPayload, t: Translate) { + if (payload.primaryAction === "retry-check") { + return t("launch.updateToast.tryAgain", "Try again"); + } + return payload.phase === "ready" + ? t("launch.updateToast.restartToUpdate", "Restart to update") + : t("launch.updateToast.updateNow", "Update now"); +} + +function PhaseIcon({ payload }: { payload: UpdateToastPayload }) { + switch (payload.phase) { + case "available": + return <DownloadSimpleIcon size={20} weight="bold" />; + case "downloading": + return <ArrowClockwiseIcon size={20} weight="bold" className={styles.spin} />; + case "ready": + return <CheckCircleIcon size={20} weight="fill" />; + case "error": + return <WarningCircleIcon size={20} weight="fill" />; + } +} + +export function UpdateToastWindow() { + const [payload, setPayload] = useState<UpdateToastPayload | null>(null); + const { t } = useI18n(); + + useEffect(() => { + let mounted = true; + const refresh = () => { + void window.electronAPI.getCurrentUpdateToastPayload().then((nextPayload) => { + if (mounted) setPayload(nextPayload); + }); + }; + + refresh(); + const pollTimer = setInterval(refresh, 750); + const dispose = window.electronAPI.onUpdateToastStateChanged(setPayload); + + return () => { + mounted = false; + clearInterval(pollTimer); + dispose(); + }; + }, []); + + if (!payload) { + return <div className={styles.window} />; + } + + const progress = Math.max(0, Math.min(100, Math.round(payload.progressPercent ?? 0))); + const transferred = formatBytes(payload.transferredBytes); + const total = formatBytes(payload.totalBytes); + const speed = formatBytes(payload.bytesPerSecond); + const progressDetail = [ + transferred && total ? `${transferred} of ${total}` : transferred, + speed ? `${speed}/s` : null, + ] + .filter(Boolean) + .join(" · "); + + const handlePrimaryAction = async () => { + if (payload.phase === "downloading") return; + + if (payload.primaryAction === "retry-check") { + await window.electronAPI.checkForAppUpdates(); + return; + } + if (payload.phase === "ready") { + await window.electronAPI.installDownloadedUpdate(); + return; + } + await window.electronAPI.downloadAvailableUpdate(true); + }; + + const handleNotNow = async () => { + if (payload.isPreview) { + await window.electronAPI.dismissUpdateToast(); + return; + } + await window.electronAPI.deferDownloadedUpdate(payload.delayMs); + }; + + return ( + <div className={`${styles.window} launch-theme`}> + <section className={styles.card} aria-live="polite" aria-label="Recordly update"> + <div + className={`${styles.icon} ${payload.phase === "error" ? styles.iconError : ""}`} + > + <PhaseIcon payload={payload} /> + </div> + + <div className={styles.content}> + <div className={styles.headingRow}> + <h1>{getTitle(payload, t)}</h1> + <span className={styles.version}>v{payload.version.replace(/^v/, "")}</span> + {payload.isExperimental ? ( + <span className={styles.preview}> + {t("launch.updateToast.experimentalBadge", "Experimental")} + </span> + ) : null} + {payload.isPreview ? ( + <span className={styles.preview}> + {t("launch.updateToast.previewBadge", "Preview")} + </span> + ) : null} + </div> + <p>{getDetail(payload, t)}</p> + + {payload.phase === "downloading" ? ( + <div className={styles.progressBlock}> + <div className={styles.progressTrack}> + <div + className={styles.progressFill} + style={{ width: `${progress}%` }} + /> + </div> + <div className={styles.progressMeta}> + <strong>{progress}%</strong> + {progressDetail ? <span>{progressDetail}</span> : null} + </div> + </div> + ) : ( + <div className={styles.actions}> + <button + type="button" + className={styles.secondaryButton} + onClick={handleNotNow} + > + {t("launch.updateToast.notNow", "Not now")} + </button> + <button + type="button" + className={styles.primaryButton} + onClick={handlePrimaryAction} + > + {getPrimaryLabel(payload, t)} + </button> + </div> + )} + </div> + </section> + </div> + ); +} diff --git a/src/components/launch/contexts/HudInteractionContext.tsx b/src/components/launch/contexts/HudInteractionContext.tsx new file mode 100644 index 000000000..66b5159e0 --- /dev/null +++ b/src/components/launch/contexts/HudInteractionContext.tsx @@ -0,0 +1,16 @@ +import { createContext, type MouseEvent, useContext } from "react"; + +interface HudInteractionContextType { + onMouseEnter: () => void; + onMouseLeave: (event: MouseEvent<HTMLDivElement>) => void; +} + +export const HudInteractionContext = createContext<HudInteractionContextType | null>(null); + +export function useHudInteraction() { + const context = useContext(HudInteractionContext); + if (!context) { + throw new Error("useHudInteraction must be used within a HudInteractionProvider"); + } + return context; +} diff --git a/src/components/launch/floatingWebcamPreview.test.ts b/src/components/launch/floatingWebcamPreview.test.ts new file mode 100644 index 000000000..6e876f1d0 --- /dev/null +++ b/src/components/launch/floatingWebcamPreview.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; + +import { + canShowFloatingWebcamPreview, + canToggleFloatingWebcamPreview, +} from "./floatingWebcamPreview"; + +describe("canShowFloatingWebcamPreview", () => { + it("shows the floating preview only when it was requested and passthrough is supported", () => { + expect(canShowFloatingWebcamPreview(true, true)).toBe(true); + expect(canShowFloatingWebcamPreview(false, true)).toBe(false); + expect(canShowFloatingWebcamPreview(true, false)).toBe(false); + expect(canShowFloatingWebcamPreview(true, null)).toBe(false); + }); +}); + +describe("canToggleFloatingWebcamPreview", () => { + it("keeps the toggle visible while support is unknown or available", () => { + expect(canToggleFloatingWebcamPreview(null)).toBe(true); + expect(canToggleFloatingWebcamPreview(true)).toBe(true); + }); + + it("hides the toggle when the platform cannot support the floating preview", () => { + expect(canToggleFloatingWebcamPreview(false)).toBe(false); + }); +}); diff --git a/src/components/launch/floatingWebcamPreview.ts b/src/components/launch/floatingWebcamPreview.ts new file mode 100644 index 000000000..5e421550e --- /dev/null +++ b/src/components/launch/floatingWebcamPreview.ts @@ -0,0 +1,12 @@ +export function canShowFloatingWebcamPreview( + requested: boolean, + hudOverlayMousePassthroughSupported: boolean | null, +): boolean { + return requested && hudOverlayMousePassthroughSupported === true; +} + +export function canToggleFloatingWebcamPreview( + hudOverlayMousePassthroughSupported: boolean | null, +): boolean { + return hudOverlayMousePassthroughSupported !== false; +} diff --git a/src/components/launch/hooks/useHudBarDrag.ts b/src/components/launch/hooks/useHudBarDrag.ts new file mode 100644 index 000000000..9c93aa7f1 --- /dev/null +++ b/src/components/launch/hooks/useHudBarDrag.ts @@ -0,0 +1,250 @@ +import { type PointerEvent, type RefObject, useCallback, useEffect, useRef, useState } from "react"; +import { + mergeHudInteractiveBounds, + shouldRestoreHudMousePassthroughAfterDrag, +} from "../hudMousePassthrough"; +import { clampHudOffsetToViewport } from "../hudViewportBounds"; + +const DEFAULT_RECORDING_HUD_OFFSET = { x: 0, y: 0 }; + +export function useHudBarDrag({ + hudContentRef, + hudBarRef, + recordingWebcamPreviewContainerRef, +}: { + hudContentRef: RefObject<HTMLDivElement>; + hudBarRef: RefObject<HTMLDivElement>; + recordingWebcamPreviewContainerRef: RefObject<HTMLDivElement>; +}) { + const [recordingHudOffset, setRecordingHudOffset] = useState(DEFAULT_RECORDING_HUD_OFFSET); + const [isHudDragging, setIsHudDragging] = useState(false); + const hudBarTransformRef = useRef<HTMLDivElement | null>(null); + const recordingHudOffsetRef = useRef(DEFAULT_RECORDING_HUD_OFFSET); + const hudDragStartRef = useRef<{ + pointerId: number; + startX: number; + startY: number; + originX: number; + originY: number; + initialLeft: number; + initialTop: number; + hudWidth: number; + hudHeight: number; + } | null>(null); + const isHudDraggingRef = useRef(false); + const hudDragMoveRafRef = useRef<number | null>(null); + const hudDragPendingPointerRef = useRef<{ clientX: number; clientY: number } | null>(null); + + useEffect(() => { + recordingHudOffsetRef.current = recordingHudOffset; + if (!isHudDraggingRef.current && hudBarTransformRef.current) { + hudBarTransformRef.current.style.transform = `translate3d(${recordingHudOffset.x}px, ${recordingHudOffset.y}px, 0)`; + } + }, [recordingHudOffset]); + + const keepHudBarInsideViewport = useCallback(() => { + if (isHudDraggingRef.current || !hudBarRef.current) { + return; + } + + const bounds = hudBarRef.current.getBoundingClientRect(); + const nextOffset = clampHudOffsetToViewport(recordingHudOffsetRef.current, bounds, { + width: window.innerWidth, + height: window.innerHeight, + }); + if ( + nextOffset.x === recordingHudOffsetRef.current.x && + nextOffset.y === recordingHudOffsetRef.current.y + ) { + return; + } + + recordingHudOffsetRef.current = nextOffset; + if (hudBarTransformRef.current) { + hudBarTransformRef.current.style.transform = `translate3d(${nextOffset.x}px, ${nextOffset.y}px, 0)`; + } + setRecordingHudOffset(nextOffset); + }, [hudBarRef]); + + useEffect(() => { + const resizeObserver = + typeof ResizeObserver === "undefined" + ? null + : new ResizeObserver(keepHudBarInsideViewport); + if (hudBarRef.current) { + resizeObserver?.observe(hudBarRef.current); + } + window.addEventListener("resize", keepHudBarInsideViewport); + + return () => { + window.removeEventListener("resize", keepHudBarInsideViewport); + resizeObserver?.disconnect(); + }; + }, [hudBarRef, keepHudBarInsideViewport]); + + const handleHudBarPointerDown = useCallback( + (event: PointerEvent<HTMLDivElement>) => { + if (event.button !== 0) { + return; + } + + event.preventDefault(); + event.currentTarget.setPointerCapture(event.pointerId); + isHudDraggingRef.current = true; + setIsHudDragging(true); + window.electronAPI?.hudOverlaySetIgnoreMouse?.(false); + if (!hudBarRef.current) { + return; + } + const hudRect = hudBarRef.current.getBoundingClientRect(); + hudDragStartRef.current = { + pointerId: event.pointerId, + startX: event.clientX, + startY: event.clientY, + originX: recordingHudOffsetRef.current.x, + originY: recordingHudOffsetRef.current.y, + initialLeft: hudRect.left, + initialTop: hudRect.top, + hudWidth: hudRect.width, + hudHeight: hudRect.height, + }; + }, + [hudBarRef], + ); + + const handleHudBarPointerMove = useCallback((event: PointerEvent<HTMLDivElement>) => { + const dragState = hudDragStartRef.current; + if (!dragState || dragState.pointerId !== event.pointerId) { + return; + } + + hudDragPendingPointerRef.current = { clientX: event.clientX, clientY: event.clientY }; + if (hudDragMoveRafRef.current !== null) { + return; + } + + hudDragMoveRafRef.current = requestAnimationFrame(() => { + hudDragMoveRafRef.current = null; + const latestDragState = hudDragStartRef.current; + const pointer = hudDragPendingPointerRef.current; + if (!latestDragState || !pointer) { + return; + } + + const deltaX = pointer.clientX - latestDragState.startX; + const deltaY = pointer.clientY - latestDragState.startY; + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + const unclampedLeft = latestDragState.initialLeft + deltaX; + const unclampedTop = latestDragState.initialTop + deltaY; + const clampedLeft = Math.min( + Math.max(0, unclampedLeft), + Math.max(0, viewportWidth - latestDragState.hudWidth), + ); + const clampedTop = Math.min( + Math.max(0, unclampedTop), + Math.max(0, viewportHeight - latestDragState.hudHeight), + ); + + const nextOffset = { + x: latestDragState.originX + (clampedLeft - latestDragState.initialLeft), + y: latestDragState.originY + (clampedTop - latestDragState.initialTop), + }; + recordingHudOffsetRef.current = nextOffset; + if (hudBarTransformRef.current) { + hudBarTransformRef.current.style.transform = `translate3d(${nextOffset.x}px, ${nextOffset.y}px, 0)`; + } + }); + }, []); + + const handleHudBarPointerUp = useCallback( + (event: PointerEvent<HTMLDivElement>) => { + const dragState = hudDragStartRef.current; + if (!dragState || dragState.pointerId !== event.pointerId) { + return; + } + + const pointer = hudDragPendingPointerRef.current || { + clientX: event.clientX, + clientY: event.clientY, + }; + const deltaX = pointer.clientX - dragState.startX; + const deltaY = pointer.clientY - dragState.startY; + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + + const clampedLeft = Math.min( + Math.max(0, dragState.initialLeft + deltaX), + Math.max(0, viewportWidth - dragState.hudWidth), + ); + const clampedTop = Math.min( + Math.max(0, dragState.initialTop + deltaY), + Math.max(0, viewportHeight - dragState.hudHeight), + ); + + recordingHudOffsetRef.current = { + x: dragState.originX + (clampedLeft - dragState.initialLeft), + y: dragState.originY + (clampedTop - dragState.initialTop), + }; + + if (hudDragMoveRafRef.current !== null) { + cancelAnimationFrame(hudDragMoveRafRef.current); + hudDragMoveRafRef.current = null; + } + hudDragPendingPointerRef.current = null; + + hudDragStartRef.current = null; + const wasDragging = isHudDraggingRef.current; + isHudDraggingRef.current = false; + setRecordingHudOffset({ ...recordingHudOffsetRef.current }); + setIsHudDragging(false); + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } + const hudBounds = mergeHudInteractiveBounds( + [ + hudContentRef.current?.getBoundingClientRect(), + hudBarRef.current?.getBoundingClientRect(), + recordingWebcamPreviewContainerRef.current?.getBoundingClientRect(), + ].map((bounds) => + bounds + ? { + left: bounds.left, + top: bounds.top, + right: bounds.right, + bottom: bounds.bottom, + } + : null, + ), + ); + if ( + wasDragging && + shouldRestoreHudMousePassthroughAfterDrag(hudBounds, event.clientX, event.clientY) + ) { + window.electronAPI?.hudOverlaySetIgnoreMouse?.(true); + } + }, + [hudBarRef, hudContentRef, recordingWebcamPreviewContainerRef], + ); + + useEffect(() => { + return () => { + if (hudDragMoveRafRef.current !== null) { + cancelAnimationFrame(hudDragMoveRafRef.current); + } + hudDragMoveRafRef.current = null; + hudDragPendingPointerRef.current = null; + hudDragStartRef.current = null; + }; + }, []); + + return { + recordingHudOffset, + isHudDragging, + hudBarTransformRef, + isHudDraggingRef, + handleHudBarPointerDown, + handleHudBarPointerMove, + handleHudBarPointerUp, + }; +} diff --git a/src/components/launch/hooks/useLaunchHudInteractionState.ts b/src/components/launch/hooks/useLaunchHudInteractionState.ts new file mode 100644 index 000000000..7e5bb905d --- /dev/null +++ b/src/components/launch/hooks/useLaunchHudInteractionState.ts @@ -0,0 +1,106 @@ +import { type MouseEvent, type RefObject, useCallback, useEffect, useRef } from "react"; + +export function useLaunchHudInteractionState({ + openId, + isHudDraggingRef, + isWebcamPreviewDraggingRef, + webcamPreviewDragStartRef, +}: { + openId: string | null; + isHudDraggingRef: RefObject<boolean>; + isWebcamPreviewDraggingRef: RefObject<boolean>; + webcamPreviewDragStartRef: RefObject<unknown>; +}) { + const isMouseOverHudRef = useRef(false); + const timeoutRef = useRef<NodeJS.Timeout | null>(null); + + useEffect(() => { + if (openId !== null) { + if (timeoutRef.current) clearTimeout(timeoutRef.current); + window.electronAPI?.hudOverlaySetIgnoreMouse?.(false); + } else { + // Proactively check if we should ignore mouse when popover closes + setTimeout(() => { + if (!isMouseOverHudRef.current) { + window.electronAPI?.hudOverlaySetIgnoreMouse?.(true); + } + }, 150); + } + }, [openId]); + + useEffect(() => { + const handleMouseOver = (e: globalThis.MouseEvent) => { + const target = e.target as HTMLElement | null; + if (!target) return; + const isInteractive = !!target.closest( + ".pointer-events-auto, [data-hud-interactive], [data-radix-popper-content-wrapper]", + ); + + if (isInteractive) { + isMouseOverHudRef.current = true; + if (timeoutRef.current) clearTimeout(timeoutRef.current); + window.electronAPI?.hudOverlaySetIgnoreMouse?.(false); + } else if (openId === null) { + isMouseOverHudRef.current = false; + if (timeoutRef.current) clearTimeout(timeoutRef.current); + timeoutRef.current = setTimeout(() => { + if ( + openId === null && + !isHudDraggingRef.current && + !isWebcamPreviewDraggingRef.current && + !webcamPreviewDragStartRef.current && + !isMouseOverHudRef.current + ) { + window.electronAPI?.hudOverlaySetIgnoreMouse?.(true); + } + }, 0); + } + }; + + window.addEventListener("mouseover", handleMouseOver); + return () => window.removeEventListener("mouseover", handleMouseOver); + }, [openId, isHudDraggingRef, isWebcamPreviewDraggingRef, webcamPreviewDragStartRef]); + + const beginInteractiveHudAction = useCallback(() => { + isMouseOverHudRef.current = true; + window.electronAPI?.hudOverlaySetIgnoreMouse?.(false); + }, []); + + const handleHudMouseEnter = useCallback(() => { + isMouseOverHudRef.current = true; + if (timeoutRef.current) clearTimeout(timeoutRef.current); + window.electronAPI?.hudOverlaySetIgnoreMouse?.(false); + }, []); + + const handleHudMouseLeave = useCallback( + (event: MouseEvent<HTMLDivElement>) => { + const nextTarget = event.relatedTarget; + if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) { + return; + } + + isMouseOverHudRef.current = false; + + if (timeoutRef.current) clearTimeout(timeoutRef.current); + + timeoutRef.current = setTimeout(() => { + if ( + openId === null && + !isHudDraggingRef.current && + !isWebcamPreviewDraggingRef.current && + !webcamPreviewDragStartRef.current && + !isMouseOverHudRef.current + ) { + window.electronAPI?.hudOverlaySetIgnoreMouse?.(true); + } + }, 0); + }, + [openId, isHudDraggingRef, isWebcamPreviewDraggingRef, webcamPreviewDragStartRef], + ); + + return { + handleHudMouseEnter, + handleHudMouseLeave, + beginInteractiveHudAction, + }; +} diff --git a/src/components/launch/hooks/useLaunchWindowActions.ts b/src/components/launch/hooks/useLaunchWindowActions.ts new file mode 100644 index 000000000..7dba168e7 --- /dev/null +++ b/src/components/launch/hooks/useLaunchWindowActions.ts @@ -0,0 +1,75 @@ +import { useCallback, useState } from "react"; +import type { ProjectLibraryEntry } from "@/components/video-editor/ProjectBrowserDialog"; +import type { DesktopSource } from "../popovers/launchPopoverTypes"; + +export function useLaunchWindowActions() { + const [selectedSource, setSelectedSource] = useState("Screen"); + const [hasSelectedSource, setHasSelectedSource] = useState(false); + const [projectLibraryEntries, setProjectLibraryEntries] = useState<ProjectLibraryEntry[]>([]); + + const handleSourceSelect = useCallback(async (source: DesktopSource) => { + await window.electronAPI.selectSource(source); + setSelectedSource(source.name); + setHasSelectedSource(true); + window.electronAPI.showSourceHighlight?.({ + ...source, + name: source.appName ? `${source.appName} — ${source.name}` : source.name, + appName: source.appName, + }); + }, []); + + const openVideoFile = useCallback(async () => { + const result = await window.electronAPI.openVideoFilePicker({ includeProjects: true }); + if (result.canceled) return; + if (result.success && result.kind === "project") { + await window.electronAPI.switchToEditor(); + return; + } + if (result.success && result.path) { + await window.electronAPI.setCurrentVideoPath(result.path); + await window.electronAPI.switchToEditor(); + } + }, []); + + const refreshProjectLibrary = useCallback(async () => { + try { + const result = await window.electronAPI.listProjectFiles(); + if (!result.success) return; + setProjectLibraryEntries(result.entries); + } catch (error) { + console.error("Failed to load project library:", error); + } + }, []); + const openProjectFromLibrary = useCallback(async (projectPath: string) => { + try { + const result = await window.electronAPI.openProjectFileAtPath(projectPath); + if (result.canceled || !result.success) { + return; + } + await window.electronAPI.switchToEditor(); + } catch (error) { + console.error("Failed to open project from library:", error); + } + }, []); + + const syncSelectedSource = useCallback((source: { name?: string } | null | undefined) => { + if (source?.name) { + setSelectedSource(source.name); + setHasSelectedSource(true); + return; + } + setSelectedSource("Screen"); + setHasSelectedSource(false); + }, []); + + return { + selectedSource, + hasSelectedSource, + projectLibraryEntries, + handleSourceSelect, + openVideoFile, + openProjectFromLibrary, + syncSelectedSource, + refreshProjectLibrary, + }; +} diff --git a/src/components/launch/hooks/useLaunchWindowSystemState.ts b/src/components/launch/hooks/useLaunchWindowSystemState.ts new file mode 100644 index 000000000..56793fa36 --- /dev/null +++ b/src/components/launch/hooks/useLaunchWindowSystemState.ts @@ -0,0 +1,142 @@ +import { useCallback, useEffect, useState } from "react"; + +export function useLaunchWindowSystemState( + preparePermissions: (args: { startup?: boolean }) => Promise<unknown>, +) { + const [recordingsDirectory, setRecordingsDirectory] = useState<string | null>(null); + const [hudOverlayMousePassthroughSupported, setHudOverlayMousePassthroughSupported] = useState< + boolean | null + >(null); + const [platform, setPlatform] = useState<string | null>(null); + const [appVersion, setAppVersion] = useState<string | null>(null); + const [hideHudFromCapture, setHideHudFromCapture] = useState(true); + + useEffect(() => { + window.electronAPI?.hudOverlayRendererReady?.(); + }, []); + + useEffect(() => { + let cancelled = false; + const load = async () => { + try { + const result = await window.electronAPI.getRecordingsDirectory(); + if (!cancelled && result.success) setRecordingsDirectory(result.path); + } catch (error) { + console.error("Failed to load recordings directory:", error); + } + }; + void load(); + return () => { + cancelled = true; + }; + }, []); + + useEffect(() => { + let cancelled = false; + const loadPlatform = async () => { + try { + const nextPlatform = await window.electronAPI.getPlatform(); + if (!cancelled) setPlatform(nextPlatform); + } catch (error) { + console.error("Failed to load platform:", error); + } + }; + void loadPlatform(); + return () => { + cancelled = true; + }; + }, []); + + useEffect(() => { + let cancelled = false; + const loadSupport = async () => { + try { + const result = await window.electronAPI.getHudOverlayMousePassthroughSupported(); + if (!cancelled && result.success) { + setHudOverlayMousePassthroughSupported(result.supported); + } + } catch (error) { + console.error("Failed to load HUD overlay mouse passthrough support:", error); + } + }; + void loadSupport(); + return () => { + cancelled = true; + }; + }, []); + + useEffect(() => { + void preparePermissions({ startup: true }); + }, [preparePermissions]); + + useEffect(() => { + let cancelled = false; + const loadVersion = async () => { + try { + const version = await window.electronAPI.getAppVersion(); + if (!cancelled) setAppVersion(version); + } catch (error) { + console.error("Failed to load app version:", error); + } + }; + void loadVersion(); + return () => { + cancelled = true; + }; + }, []); + + useEffect(() => { + let cancelled = false; + const loadCaptureProtection = async () => { + try { + const result = await window.electronAPI.getHudOverlayCaptureProtection(); + if (!cancelled && result.success) { + setHideHudFromCapture(result.enabled); + } + } catch (error) { + console.error("Failed to load HUD capture protection state:", error); + } + }; + void loadCaptureProtection(); + return () => { + cancelled = true; + }; + }, []); + + const chooseRecordingsDirectory = useCallback(async () => { + try { + const result = await window.electronAPI.chooseRecordingsDirectory(); + if (result.canceled) return; + if (result.success && result.path) setRecordingsDirectory(result.path); + } catch (error) { + console.error("Failed to choose recordings directory:", error); + } + }, []); + + const toggleHudCaptureProtection = useCallback(async () => { + const nextValue = !hideHudFromCapture; + setHideHudFromCapture(nextValue); + try { + const result = await window.electronAPI.setHudOverlayCaptureProtection(nextValue); + if (!result.success) { + setHideHudFromCapture(!nextValue); + return; + } + setHideHudFromCapture(result.enabled); + } catch (error) { + console.error("Failed to update HUD capture protection:", error); + setHideHudFromCapture(!nextValue); + } + }, [hideHudFromCapture]); + + return { + recordingsDirectory, + hudOverlayMousePassthroughSupported, + platform, + appVersion, + hideHudFromCapture, + setHideHudFromCapture, + chooseRecordingsDirectory, + toggleHudCaptureProtection, + }; +} diff --git a/src/components/launch/hooks/useRecordingTimer.ts b/src/components/launch/hooks/useRecordingTimer.ts new file mode 100644 index 000000000..ccdccfe81 --- /dev/null +++ b/src/components/launch/hooks/useRecordingTimer.ts @@ -0,0 +1,54 @@ +import { useEffect, useMemo, useState } from "react"; + +export function useRecordingTimer(recording: boolean, paused: boolean) { + const [recordingStart, setRecordingStart] = useState<number | null>(null); + const [elapsed, setElapsed] = useState(0); + const [pausedAt, setPausedAt] = useState<number | null>(null); + const [pausedTotal, setPausedTotal] = useState(0); + + useEffect(() => { + let timer: NodeJS.Timeout | null = null; + if (recording) { + if (!recordingStart) { + setRecordingStart(Date.now()); + setPausedTotal(0); + } + if (paused) { + if (!pausedAt) setPausedAt(Date.now()); + if (timer) clearInterval(timer); + } else { + if (pausedAt) { + setPausedTotal((prev) => prev + (Date.now() - pausedAt)); + setPausedAt(null); + } + timer = setInterval(() => { + if (recordingStart) { + setElapsed(Math.floor((Date.now() - recordingStart - pausedTotal) / 1000)); + } + }, 1000); + } + } else { + setRecordingStart(null); + setElapsed(0); + setPausedAt(null); + setPausedTotal(0); + if (timer) clearInterval(timer); + } + return () => { + if (timer) clearInterval(timer); + }; + }, [recording, recordingStart, paused, pausedAt, pausedTotal]); + + const formatTime = useMemo( + () => (seconds: number) => { + const m = Math.floor(seconds / 60) + .toString() + .padStart(2, "0"); + const s = (seconds % 60).toString().padStart(2, "0"); + return `${m}:${s}`; + }, + [], + ); + + return { elapsed, formatTime }; +} diff --git a/src/components/launch/hooks/useWebcamPreviewOverlay.ts b/src/components/launch/hooks/useWebcamPreviewOverlay.ts new file mode 100644 index 000000000..680e3dac0 --- /dev/null +++ b/src/components/launch/hooks/useWebcamPreviewOverlay.ts @@ -0,0 +1,284 @@ +import { type PointerEvent, useCallback, useEffect, useRef, useState } from "react"; +import { canShowFloatingWebcamPreview } from "../floatingWebcamPreview"; + +const WEBCAM_PREVIEW_DRAG_THRESHOLD = 6; +const DEFAULT_WEBCAM_PREVIEW_OFFSET = { x: 0, y: 0 }; + +export function useWebcamPreviewOverlay({ + webcamEnabled, + webcamDeviceId, + showWebcamControls, + webcamPopoverOpen, + hudOverlayMousePassthroughSupported, +}: { + webcamEnabled: boolean; + webcamDeviceId?: string; + showWebcamControls: boolean; + webcamPopoverOpen: boolean; + hudOverlayMousePassthroughSupported: boolean | null; +}) { + const [showFloatingWebcamPreview, setShowFloatingWebcamPreview] = useState(true); + const [webcamPreviewOffset, setWebcamPreviewOffset] = useState(DEFAULT_WEBCAM_PREVIEW_OFFSET); + const webcamPreviewOffsetRef = useRef(DEFAULT_WEBCAM_PREVIEW_OFFSET); + const webcamPreviewRef = useRef<HTMLVideoElement | null>(null); + const recordingWebcamPreviewRef = useRef<HTMLVideoElement | null>(null); + const recordingWebcamPreviewContainerRef = useRef<HTMLDivElement | null>(null); + const previewStreamRef = useRef<MediaStream | null>(null); + const previewDragMoveRafRef = useRef<number | null>(null); + const previewDragPendingPointerRef = useRef<{ clientX: number; clientY: number } | null>(null); + const webcamPreviewDragStartRef = useRef<{ + pointerId: number; + startX: number; + startY: number; + originX: number; + originY: number; + initialLeft: number; + initialTop: number; + previewWidth: number; + previewHeight: number; + dragging: boolean; + } | null>(null); + const isWebcamPreviewDraggingRef = useRef(false); + const showRecordingWebcamPreview = + webcamEnabled && + canShowFloatingWebcamPreview( + showFloatingWebcamPreview, + hudOverlayMousePassthroughSupported, + ); + const shouldStreamWebcamPreview = + webcamEnabled && (showRecordingWebcamPreview || (showWebcamControls && webcamPopoverOpen)); + + useEffect(() => { + if (!webcamEnabled) { + webcamPreviewOffsetRef.current = DEFAULT_WEBCAM_PREVIEW_OFFSET; + setWebcamPreviewOffset(DEFAULT_WEBCAM_PREVIEW_OFFSET); + if (recordingWebcamPreviewContainerRef.current) { + recordingWebcamPreviewContainerRef.current.style.transform = "translate(0px, 0px)"; + } + webcamPreviewDragStartRef.current = null; + isWebcamPreviewDraggingRef.current = false; + setShowFloatingWebcamPreview(true); + } + }, [webcamEnabled]); + + const handleWebcamPreviewPointerDown = useCallback((event: PointerEvent<HTMLDivElement>) => { + if (event.button !== 0) { + return; + } + + const previewRect = event.currentTarget.getBoundingClientRect(); + + event.preventDefault(); + window.electronAPI?.hudOverlaySetIgnoreMouse?.(false); + webcamPreviewDragStartRef.current = { + pointerId: event.pointerId, + startX: event.clientX, + startY: event.clientY, + originX: webcamPreviewOffsetRef.current.x, + originY: webcamPreviewOffsetRef.current.y, + initialLeft: previewRect.left, + initialTop: previewRect.top, + previewWidth: previewRect.width, + previewHeight: previewRect.height, + dragging: false, + }; + event.currentTarget.setPointerCapture(event.pointerId); + }, []); + + const handleWebcamPreviewPointerMove = useCallback((event: PointerEvent<HTMLDivElement>) => { + const dragState = webcamPreviewDragStartRef.current; + if (!dragState || dragState.pointerId !== event.pointerId) { + return; + } + + const deltaX = event.clientX - dragState.startX; + const deltaY = event.clientY - dragState.startY; + + if (!dragState.dragging && Math.hypot(deltaX, deltaY) < WEBCAM_PREVIEW_DRAG_THRESHOLD) { + return; + } + + if (!dragState.dragging) { + dragState.dragging = true; + isWebcamPreviewDraggingRef.current = true; + } + + previewDragPendingPointerRef.current = { clientX: event.clientX, clientY: event.clientY }; + if (previewDragMoveRafRef.current !== null) { + return; + } + + previewDragMoveRafRef.current = requestAnimationFrame(() => { + previewDragMoveRafRef.current = null; + const latestDragState = webcamPreviewDragStartRef.current; + const pointer = previewDragPendingPointerRef.current; + if (!latestDragState || !pointer) { + return; + } + + const latestDeltaX = pointer.clientX - latestDragState.startX; + const latestDeltaY = pointer.clientY - latestDragState.startY; + // Pointer coordinates and getBoundingClientRect() are relative to the HUD + // viewport. `screen.width` can use a different display/DPI coordinate space. + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + const unclampedLeft = latestDragState.initialLeft + latestDeltaX; + const unclampedTop = latestDragState.initialTop + latestDeltaY; + const clampedLeft = Math.min( + Math.max(0, unclampedLeft), + Math.max(0, viewportWidth - latestDragState.previewWidth), + ); + const clampedTop = Math.min( + Math.max(0, unclampedTop), + Math.max(0, viewportHeight - latestDragState.previewHeight), + ); + + const nextOffset = { + x: latestDragState.originX + (clampedLeft - latestDragState.initialLeft), + y: latestDragState.originY + (clampedTop - latestDragState.initialTop), + }; + webcamPreviewOffsetRef.current = nextOffset; + if (recordingWebcamPreviewContainerRef.current) { + recordingWebcamPreviewContainerRef.current.style.transform = `translate(${nextOffset.x}px, ${nextOffset.y}px)`; + } + }); + }, []); + + const handleWebcamPreviewPointerUp = useCallback((event: PointerEvent<HTMLDivElement>) => { + const dragState = webcamPreviewDragStartRef.current; + if (!dragState || dragState.pointerId !== event.pointerId) { + return; + } + if (previewDragMoveRafRef.current !== null) { + cancelAnimationFrame(previewDragMoveRafRef.current); + previewDragMoveRafRef.current = null; + } + previewDragPendingPointerRef.current = null; + + const wasDragging = dragState.dragging; + webcamPreviewDragStartRef.current = null; + isWebcamPreviewDraggingRef.current = false; + setWebcamPreviewOffset({ ...webcamPreviewOffsetRef.current }); + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } + if (wasDragging) { + window.electronAPI?.hudOverlaySetIgnoreMouse?.(true); + } + }, []); + + const attachPreviewStreamToNode = useCallback((videoElement: HTMLVideoElement | null) => { + const previewStream = previewStreamRef.current; + if (!videoElement || !previewStream || videoElement.srcObject === previewStream) { + return; + } + + videoElement.srcObject = previewStream; + const playPromise = videoElement.play(); + if (playPromise) { + playPromise.catch(() => { + // Ignore autoplay interruptions while the preview element mounts. + }); + } + }, []); + + const setWebcamPreviewNode = useCallback( + (node: HTMLVideoElement | null) => { + webcamPreviewRef.current = node; + attachPreviewStreamToNode(node); + }, + [attachPreviewStreamToNode], + ); + + const setRecordingWebcamPreviewNode = useCallback( + (node: HTMLVideoElement | null) => { + recordingWebcamPreviewRef.current = node; + attachPreviewStreamToNode(node); + }, + [attachPreviewStreamToNode], + ); + + useEffect(() => { + return () => { + if (previewDragMoveRafRef.current !== null) { + cancelAnimationFrame(previewDragMoveRafRef.current); + } + previewDragMoveRafRef.current = null; + previewDragPendingPointerRef.current = null; + }; + }, []); + + useEffect(() => { + let mounted = true; + + const startPreview = async () => { + if (!shouldStreamWebcamPreview) { + return; + } + + try { + const previewStream = await navigator.mediaDevices.getUserMedia({ + video: webcamDeviceId + ? { + deviceId: { exact: webcamDeviceId }, + width: { ideal: 320 }, + height: { ideal: 320 }, + frameRate: { ideal: 24, max: 30 }, + } + : { + width: { ideal: 320 }, + height: { ideal: 320 }, + frameRate: { ideal: 24, max: 30 }, + }, + audio: false, + }); + + if (!mounted) { + previewStream.getTracks().forEach((track) => track.stop()); + return; + } + + previewStreamRef.current = previewStream; + attachPreviewStreamToNode(webcamPreviewRef.current); + attachPreviewStreamToNode(recordingWebcamPreviewRef.current); + } catch (error) { + console.warn("Failed to start live webcam preview:", error); + } + }; + + void startPreview(); + + return () => { + mounted = false; + const previewNode = webcamPreviewRef.current; + const recordingPreviewNode = recordingWebcamPreviewRef.current; + const previewStream = previewStreamRef.current; + + [previewNode, recordingPreviewNode] + .filter((node): node is HTMLVideoElement => Boolean(node)) + .forEach((videoElement) => { + videoElement.pause(); + videoElement.srcObject = null; + }); + previewStream?.getTracks().forEach((track) => track.stop()); + if (previewStreamRef.current === previewStream) { + previewStreamRef.current = null; + } + }; + }, [attachPreviewStreamToNode, shouldStreamWebcamPreview, webcamDeviceId]); + + return { + showFloatingWebcamPreview, + setShowFloatingWebcamPreview, + webcamPreviewOffset, + recordingWebcamPreviewContainerRef, + isWebcamPreviewDraggingRef, + webcamPreviewDragStartRef, + handleWebcamPreviewPointerDown, + handleWebcamPreviewPointerMove, + handleWebcamPreviewPointerUp, + setWebcamPreviewNode, + setRecordingWebcamPreviewNode, + showRecordingWebcamPreview, + }; +} diff --git a/src/components/launch/hudMousePassthrough.test.ts b/src/components/launch/hudMousePassthrough.test.ts new file mode 100644 index 000000000..9490b0547 --- /dev/null +++ b/src/components/launch/hudMousePassthrough.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; + +import { + mergeHudInteractiveBounds, + shouldRestoreHudMousePassthroughAfterDrag, +} from "./hudMousePassthrough"; + +const hudBounds = { left: 100, top: 200, right: 300, bottom: 260 }; + +describe("mergeHudInteractiveBounds", () => { + it("returns null when there are no interactive bounds", () => { + expect(mergeHudInteractiveBounds([null, undefined])).toBeNull(); + }); + + it("merges the dropdown, bar, and webcam preview bounds", () => { + expect( + mergeHudInteractiveBounds([ + { left: 120, top: 220, right: 260, bottom: 320 }, + { left: 100, top: 200, right: 300, bottom: 260 }, + { left: 140, top: 280, right: 340, bottom: 430 }, + ]), + ).toEqual({ left: 100, top: 200, right: 340, bottom: 430 }); + }); +}); + +describe("shouldRestoreHudMousePassthroughAfterDrag", () => { + it("keeps the HUD interactive when the pointer is still inside the HUD", () => { + expect(shouldRestoreHudMousePassthroughAfterDrag(hudBounds, 180, 230)).toBe(false); + }); + + it("keeps the HUD interactive when the pointer ends on the HUD edge", () => { + expect(shouldRestoreHudMousePassthroughAfterDrag(hudBounds, 100, 200)).toBe(false); + expect(shouldRestoreHudMousePassthroughAfterDrag(hudBounds, 300, 260)).toBe(false); + }); + + it("restores passthrough when the pointer ends outside the HUD", () => { + expect(shouldRestoreHudMousePassthroughAfterDrag(hudBounds, 99, 230)).toBe(true); + expect(shouldRestoreHudMousePassthroughAfterDrag(hudBounds, 180, 199)).toBe(true); + expect(shouldRestoreHudMousePassthroughAfterDrag(hudBounds, 301, 230)).toBe(true); + expect(shouldRestoreHudMousePassthroughAfterDrag(hudBounds, 180, 261)).toBe(true); + }); + + it("restores passthrough when no HUD bounds are available", () => { + expect(shouldRestoreHudMousePassthroughAfterDrag(null, 180, 230)).toBe(true); + }); +}); diff --git a/src/components/launch/hudMousePassthrough.ts b/src/components/launch/hudMousePassthrough.ts new file mode 100644 index 000000000..5d972f559 --- /dev/null +++ b/src/components/launch/hudMousePassthrough.ts @@ -0,0 +1,39 @@ +export interface HudInteractiveBounds { + left: number; + top: number; + right: number; + bottom: number; +} + +export function mergeHudInteractiveBounds( + bounds: Array<HudInteractiveBounds | null | undefined>, +): HudInteractiveBounds | null { + const presentBounds = bounds.filter((value): value is HudInteractiveBounds => Boolean(value)); + if (presentBounds.length === 0) { + return null; + } + + return presentBounds.reduce((merged, current) => ({ + left: Math.min(merged.left, current.left), + top: Math.min(merged.top, current.top), + right: Math.max(merged.right, current.right), + bottom: Math.max(merged.bottom, current.bottom), + })); +} + +export function shouldRestoreHudMousePassthroughAfterDrag( + bounds: HudInteractiveBounds | null, + clientX: number, + clientY: number, +): boolean { + if (!bounds) { + return true; + } + + return ( + clientX < bounds.left || + clientX > bounds.right || + clientY < bounds.top || + clientY > bounds.bottom + ); +} diff --git a/src/components/launch/hudPointerSurface.test.ts b/src/components/launch/hudPointerSurface.test.ts new file mode 100644 index 000000000..704cb2904 --- /dev/null +++ b/src/components/launch/hudPointerSurface.test.ts @@ -0,0 +1,76 @@ +import { readFileSync } from "node:fs"; +import ts from "typescript"; +import { describe, expect, it } from "vitest"; + +const launchWindowSource = readFileSync(new URL("./LaunchWindow.tsx", import.meta.url), "utf8"); +const sourceFile = ts.createSourceFile( + "LaunchWindow.tsx", + launchWindowSource, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TSX, +); + +function findElementByRef(refName: string): ts.JsxElement { + let match: ts.JsxElement | undefined; + + function visit(node: ts.Node) { + if (match) return; + + if (ts.isJsxOpeningElement(node)) { + const ref = node.attributes.properties.find( + (attribute): attribute is ts.JsxAttribute => + ts.isJsxAttribute(attribute) && + attribute.name.getText(sourceFile) === "ref" && + attribute.initializer?.getText(sourceFile) === `{${refName}}`, + ); + if (ref && ts.isJsxElement(node.parent)) { + match = node.parent; + return; + } + } + + ts.forEachChild(node, visit); + } + + visit(sourceFile); + if (!match) throw new Error(`Could not find JSX element with ref ${refName}`); + return match; +} + +function getAttributeNames(element: ts.JsxElement): string[] { + return element.openingElement.attributes.properties.flatMap((attribute) => + ts.isJsxAttribute(attribute) ? [attribute.name.getText(sourceFile)] : [], + ); +} + +function getClassNameSource(element: ts.JsxElement): string { + const className = element.openingElement.attributes.properties.find( + (attribute): attribute is ts.JsxAttribute => + ts.isJsxAttribute(attribute) && attribute.name.getText(sourceFile) === "className", + ); + return className?.initializer?.getText(sourceFile) ?? ""; +} + +describe("HUD pointer surface", () => { + it("keeps the stationary layout wrapper click-through after the HUD moves", () => { + const transformElement = findElementByRef("hudBarTransformRef"); + const layoutWrapper = transformElement.parent; + + expect(ts.isJsxElement(layoutWrapper)).toBe(true); + if (!ts.isJsxElement(layoutWrapper)) return; + + expect(getClassNameSource(layoutWrapper)).toContain("pointer-events-none"); + expect(getClassNameSource(layoutWrapper)).not.toContain("pointer-events-auto"); + expect(getAttributeNames(layoutWrapper)).not.toContain("onMouseEnter"); + expect(getAttributeNames(layoutWrapper)).not.toContain("onMouseLeave"); + }); + + it("makes the visibly transformed HUD bar interactive", () => { + const hudBar = findElementByRef("hudBarRef"); + + expect(getClassNameSource(hudBar)).toContain("pointer-events-auto"); + expect(getAttributeNames(hudBar)).toContain("onMouseEnter"); + expect(getAttributeNames(hudBar)).toContain("onMouseLeave"); + }); +}); diff --git a/src/components/launch/hudViewportBounds.test.ts b/src/components/launch/hudViewportBounds.test.ts new file mode 100644 index 000000000..6d998a5d1 --- /dev/null +++ b/src/components/launch/hudViewportBounds.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; + +import { clampHudOffsetToViewport } from "./hudViewportBounds"; + +describe("clampHudOffsetToViewport", () => { + it("moves a dragged HUD back onscreen after the recording window becomes compact", () => { + expect( + clampHudOffsetToViewport( + { x: -463, y: -710 }, + { left: -244, top: -656, right: 179, bottom: -586 }, + { width: 860, height: 160 }, + ), + ).toEqual({ x: -219, y: -54 }); + }); + + it("leaves an already visible HUD unchanged", () => { + expect( + clampHudOffsetToViewport( + { x: 12, y: -8 }, + { left: 218, top: 54, right: 641, bottom: 124 }, + { width: 860, height: 160 }, + ), + ).toEqual({ x: 12, y: -8 }); + }); + + it("clamps the right and bottom edges after a viewport shrink", () => { + expect( + clampHudOffsetToViewport( + { x: 500, y: 200 }, + { left: 700, top: 140, right: 1120, bottom: 210 }, + { width: 860, height: 160 }, + ), + ).toEqual({ x: 240, y: 150 }); + }); +}); diff --git a/src/components/launch/hudViewportBounds.ts b/src/components/launch/hudViewportBounds.ts new file mode 100644 index 000000000..506e5b3d1 --- /dev/null +++ b/src/components/launch/hudViewportBounds.ts @@ -0,0 +1,39 @@ +export interface HudOffset { + x: number; + y: number; +} + +export interface HudViewportBounds { + left: number; + top: number; + right: number; + bottom: number; +} + +export interface HudViewportSize { + width: number; + height: number; +} + +function clampOffsetAxis(offset: number, start: number, end: number, viewportSize: number) { + if (start < 0) { + return offset - start; + } + + if (end > viewportSize) { + return offset + viewportSize - end; + } + + return offset; +} + +export function clampHudOffsetToViewport( + offset: HudOffset, + bounds: HudViewportBounds, + viewport: HudViewportSize, +): HudOffset { + return { + x: clampOffsetAxis(offset.x, bounds.left, bounds.right, viewport.width), + y: clampOffsetAxis(offset.y, bounds.top, bounds.bottom, viewport.height), + }; +} diff --git a/src/components/launch/launchTheme.css b/src/components/launch/launchTheme.css new file mode 100644 index 000000000..4adca96dd --- /dev/null +++ b/src/components/launch/launchTheme.css @@ -0,0 +1,40 @@ +.launch-theme { + /* Ultra-Refined Light Mode (Premium OS style) */ + --launch-surface: #ffffff; + --launch-panel: #f8fafc; + --launch-border: rgba(0, 0, 0, 0.08); + --launch-border-strong: rgba(0, 0, 0, 0.16); + --launch-text: #020617; /* Deepest Navy/Black */ + --launch-text-muted: #475569; /* Slate 600 */ + --launch-label: #64748b; /* Slate 500 - increased contrast */ + --launch-hover: rgba(0, 0, 0, 0.045); + --launch-selected: rgba(37, 99, 235, 0.12); /* Blue 600 at 12% */ + --launch-accent: #2563eb; /* Blue 600 */ + --launch-accent-soft: #f1f5f9; /* Slate 100 */ + --launch-popover-blur: 40px; + --launch-popover-saturate: 200%; + --launch-bar-bg: rgba(255, 255, 255, 0.98); + --launch-bar-border: rgba(0, 0, 0, 0.08); + --launch-bar-shadow: 0 12px 40px rgba(0, 0, 0, 0.12), 0 4px 12px rgba(0, 0, 0, 0.06); + + color: var(--launch-text); +} + +.dark .launch-theme, +.launch-theme.dark { + /* Dark Mode values */ + --launch-surface: rgba(18, 18, 24, 0.97); + --launch-panel: rgba(18, 18, 24, 0.82); + --launch-border: rgba(255, 255, 255, 0.07); + --launch-border-strong: rgba(255, 255, 255, 0.12); + --launch-text: #f3f6ff; + --launch-text-muted: #aab3c7; + --launch-label: #8f99b1; + --launch-hover: rgba(255, 255, 255, 0.1); + --launch-selected: rgba(92, 153, 255, 0.22); + --launch-accent: #3d8bff; + --launch-accent-soft: #dbeafe; + --launch-bar-bg: rgba(18, 18, 24, 0.97); + --launch-bar-border: rgba(255, 255, 255, 0.07); + --launch-bar-shadow: 0 10px 30px rgba(0, 0, 0, 0.24), 0 2px 10px rgba(0, 0, 0, 0.12); +} diff --git a/src/components/launch/popovers/CountdownPopover.tsx b/src/components/launch/popovers/CountdownPopover.tsx new file mode 100644 index 000000000..a0c4b6698 --- /dev/null +++ b/src/components/launch/popovers/CountdownPopover.tsx @@ -0,0 +1,53 @@ +import { TimerIcon } from "@phosphor-icons/react"; +import type { ReactElement } from "react"; +import { useScopedT } from "@/contexts/I18nContext"; +import styles from "../LaunchWindow.module.css"; +import { DropdownItem, HudPopover } from "./PopoverScaffold"; +import { useLaunchPopoverCoordinator } from "./LaunchPopoverCoordinator"; + +const POPOVER_ID = "countdown"; +const COUNTDOWN_OPTIONS = [0, 3, 5, 10]; + +export function CountdownPopover({ + trigger, + countdownDelay, + onSelectDelay, +}: { + trigger: ReactElement; + countdownDelay: number; + onSelectDelay: (delay: number) => void; +}) { + const t = useScopedT("launch"); + const { isOpen, requestOpen, requestClose } = useLaunchPopoverCoordinator(); + const open = isOpen(POPOVER_ID); + + return ( + <HudPopover + open={open} + onOpenChange={(nextOpen) => { + if (!nextOpen) { + requestClose(POPOVER_ID); + return; + } + requestOpen(POPOVER_ID); + }} + trigger={trigger} + align="center" + > + <div className={styles.ddLabel}>{t("recording.countdownDelay")}</div> + {COUNTDOWN_OPTIONS.map((delay) => ( + <DropdownItem + key={delay} + icon={<TimerIcon size={16} />} + selected={countdownDelay === delay} + onClick={() => { + onSelectDelay(delay); + requestClose(POPOVER_ID); + }} + > + {delay === 0 ? t("recording.noDelay") : `${delay}s`} + </DropdownItem> + ))} + </HudPopover> + ); +} diff --git a/src/components/launch/popovers/LaunchPopoverCoordinator.tsx b/src/components/launch/popovers/LaunchPopoverCoordinator.tsx new file mode 100644 index 000000000..7087f78c3 --- /dev/null +++ b/src/components/launch/popovers/LaunchPopoverCoordinator.tsx @@ -0,0 +1,64 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, + type ReactNode, +} from "react"; + +interface LaunchPopoverCoordinatorValue { + openId: string | null; + requestOpen: (id: string) => void; + requestClose: (id: string) => void; + isOpen: (id: string) => boolean; +} + +const LaunchPopoverCoordinatorContext = createContext<LaunchPopoverCoordinatorValue | null>(null); + +export function LaunchPopoverCoordinatorProvider({ children }: { children: ReactNode }) { + const [openId, setOpenId] = useState<string | null>(null); + + const requestOpen = useCallback((id: string) => { + setOpenId(id); + }, []); + + const requestClose = useCallback((id: string) => { + setOpenId((currentId) => (currentId === id ? null : currentId)); + }, []); + + const isOpen = useCallback((id: string) => openId === id, [openId]); + + useEffect(() => { + const handleBlur = () => setOpenId(null); + window.addEventListener("blur", handleBlur); + return () => window.removeEventListener("blur", handleBlur); + }, []); + + const value = useMemo( + () => ({ + openId, + requestOpen, + requestClose, + isOpen, + }), + [isOpen, openId, requestClose, requestOpen], + ); + + return ( + <LaunchPopoverCoordinatorContext.Provider value={value}> + {children} + </LaunchPopoverCoordinatorContext.Provider> + ); +} + +export function useLaunchPopoverCoordinator() { + const context = useContext(LaunchPopoverCoordinatorContext); + if (!context) { + throw new Error( + "useLaunchPopoverCoordinator must be used within LaunchPopoverCoordinatorProvider", + ); + } + return context; +} diff --git a/src/components/launch/popovers/MicPopover.tsx b/src/components/launch/popovers/MicPopover.tsx new file mode 100644 index 000000000..cc5eb0f0f --- /dev/null +++ b/src/components/launch/popovers/MicPopover.tsx @@ -0,0 +1,101 @@ +import { MicrophoneSlashIcon, SpeakerHighIcon, SpeakerXIcon } from "@phosphor-icons/react"; +import { useScopedT } from "@/contexts/I18nContext"; +import { DropdownItem, HudPopover, MicDeviceRow } from "./PopoverScaffold"; +import { useLaunchPopoverCoordinator } from "./LaunchPopoverCoordinator"; +import type { DeviceOption } from "./launchPopoverTypes"; +import type { ReactElement } from "react"; +import styles from "../LaunchWindow.module.css"; + +const POPOVER_ID = "mic"; + +export function MicPopover({ + trigger, + disabled, + systemAudioEnabled, + onToggleSystemAudio, + microphoneEnabled, + onDisableMicrophone, + devices, + microphoneDeviceId, + selectedDeviceId, + onSelectDevice, +}: { + trigger: ReactElement; + disabled?: boolean; + systemAudioEnabled: boolean; + onToggleSystemAudio: () => void; + microphoneEnabled: boolean; + onDisableMicrophone: () => void; + devices: DeviceOption[]; + microphoneDeviceId?: string; + selectedDeviceId?: string; + onSelectDevice: (deviceId: string) => void; +}) { + const t = useScopedT("launch"); + const { isOpen, requestOpen, requestClose } = useLaunchPopoverCoordinator(); + const open = isOpen(POPOVER_ID); + + return ( + <HudPopover + open={open} + onOpenChange={(nextOpen) => { + if (!nextOpen) { + requestClose(POPOVER_ID); + return; + } + if (disabled) { + return; + } + requestOpen(POPOVER_ID); + }} + trigger={trigger} + align="start" + > + <div className={styles.ddLabel}>{t("recording.microphone")}</div> + <DropdownItem + icon={ + systemAudioEnabled ? <SpeakerHighIcon size={16} /> : <SpeakerXIcon size={16} /> + } + selected={systemAudioEnabled} + onClick={onToggleSystemAudio} + > + {systemAudioEnabled + ? t("recording.disableSystemAudio") + : t("recording.enableSystemAudio")} + </DropdownItem> + {microphoneEnabled && ( + <DropdownItem + icon={<MicrophoneSlashIcon size={16} />} + onClick={() => { + onDisableMicrophone(); + requestClose(POPOVER_ID); + }} + > + {t("recording.turnOffMicrophone")} + </DropdownItem> + )} + {!microphoneEnabled && ( + <div className="px-3 py-2 text-xs text-[var(--launch-text-muted)]"> + {t("recording.selectMicToEnable")} + </div> + )} + {devices.map((device) => ( + <MicDeviceRow + key={device.deviceId} + device={device} + selected={ + microphoneEnabled && + (microphoneDeviceId === device.deviceId || + selectedDeviceId === device.deviceId) + } + onSelect={() => onSelectDevice(device.deviceId)} + /> + ))} + {devices.length === 0 && ( + <div className="text-center text-xs text-[var(--launch-text-muted)] py-4"> + {t("recording.noMicrophonesFound")} + </div> + )} + </HudPopover> + ); +} diff --git a/src/components/launch/popovers/MorePopover.tsx b/src/components/launch/popovers/MorePopover.tsx new file mode 100644 index 000000000..9a5a52905 --- /dev/null +++ b/src/components/launch/popovers/MorePopover.tsx @@ -0,0 +1,192 @@ +import { + EyeIcon, + EyeSlashIcon, + FolderOpenIcon, + TranslateIcon, + VideoCameraIcon, + ArrowClockwiseIcon, + SunIcon, + MoonIcon, + DesktopIcon, +} from "@phosphor-icons/react"; +import type { ReactElement } from "react"; +import { useI18n } from "@/contexts/I18nContext"; +import { useScopedT } from "@/contexts/I18nContext"; +import { useTheme } from "@/contexts/ThemeContext"; +import type { AppLocale } from "@/i18n/config"; +import { SUPPORTED_LOCALES } from "@/i18n/config"; +import styles from "../LaunchWindow.module.css"; +import { useLaunchPopoverCoordinator } from "./LaunchPopoverCoordinator"; +import { DropdownItem, HudPopover } from "./PopoverScaffold"; + +const POPOVER_ID = "more"; + +const LOCALE_LABELS: Record<string, string> = { + en: "English", + es: "Español", + fr: "Français", + it: "Italiano", + nl: "Nederlands", + ko: "한국어", + "pt-BR": "Português", + "zh-CN": "簡體中文", + "zh-TW": "繁體中文", +}; + +export function MorePopover({ + trigger, + supportsHudCaptureProtection, + hideHudFromCapture, + onToggleHudCaptureProtection, + onChooseRecordingsDirectory, + onOpenVideoFile, + onOpenProjectBrowser, + showDevUpdatePreview, + onPreviewUpdateUi, + appVersion, +}: { + trigger: ReactElement; + supportsHudCaptureProtection: boolean; + hideHudFromCapture: boolean; + onToggleHudCaptureProtection: () => void; + onChooseRecordingsDirectory: () => void; + onOpenVideoFile: () => void; + onOpenProjectBrowser: () => void; + showDevUpdatePreview: boolean; + onPreviewUpdateUi: () => void; + appVersion: string | null; +}) { + const t = useScopedT("launch"); + const { locale, setLocale } = useI18n(); + const { preference, setPreference } = useTheme(); + const { isOpen, requestOpen, requestClose } = useLaunchPopoverCoordinator(); + const open = isOpen(POPOVER_ID); + + return ( + <HudPopover + open={open} + onOpenChange={(nextOpen) => { + if (!nextOpen) { + requestClose(POPOVER_ID); + return; + } + requestOpen(POPOVER_ID); + }} + trigger={trigger} + align="end" + > + {supportsHudCaptureProtection && ( + <DropdownItem + icon={hideHudFromCapture ? <EyeSlashIcon size={16} /> : <EyeIcon size={16} />} + selected={hideHudFromCapture} + onClick={onToggleHudCaptureProtection} + > + {hideHudFromCapture + ? t("recording.hideHudFromVideo") + : t("recording.showHudInVideo")} + </DropdownItem> + )} + <DropdownItem + icon={<FolderOpenIcon size={16} />} + onClick={() => { + requestClose(POPOVER_ID); + onChooseRecordingsDirectory(); + }} + > + {t("recording.recordingsFolder")} + </DropdownItem> + <DropdownItem + icon={<VideoCameraIcon size={16} />} + onClick={() => { + requestClose(POPOVER_ID); + onOpenVideoFile(); + }} + > + {t("recording.openVideoFile")} + </DropdownItem> + <DropdownItem + icon={<FolderOpenIcon size={16} />} + onClick={() => { + requestClose(POPOVER_ID); + onOpenProjectBrowser(); + }} + > + {t("recording.openProject")} + </DropdownItem> + {showDevUpdatePreview ? ( + <DropdownItem + icon={<ArrowClockwiseIcon size={16} />} + onClick={() => { + requestClose(POPOVER_ID); + onPreviewUpdateUi(); + }} + > + {t("recording.previewUpdateUi", "Preview Update UI")} + </DropdownItem> + ) : null} + <div className={styles.ddLabel} style={{ marginTop: 4 }}> + {t("recording.appearance", "Appearance")} + </div> + <DropdownItem + icon={<SunIcon size={16} />} + selected={preference === "light"} + onClick={() => { + setPreference("light"); + requestClose(POPOVER_ID); + }} + > + {t("common.light", "Light")} + </DropdownItem> + <DropdownItem + icon={<MoonIcon size={16} />} + selected={preference === "dark"} + onClick={() => { + setPreference("dark"); + requestClose(POPOVER_ID); + }} + > + {t("common.dark", "Dark")} + </DropdownItem> + <DropdownItem + icon={<DesktopIcon size={16} />} + selected={preference === "system"} + onClick={() => { + setPreference("system"); + requestClose(POPOVER_ID); + }} + > + {t("common.system", "System")} + </DropdownItem> + <div className={styles.ddLabel} style={{ marginTop: 4 }}> + {t("recording.language")} + </div> + {SUPPORTED_LOCALES.map((code) => ( + <DropdownItem + key={code} + icon={<TranslateIcon size={16} />} + selected={locale === code} + onClick={() => { + setLocale(code as AppLocale); + requestClose(POPOVER_ID); + }} + > + {LOCALE_LABELS[code] ?? code} + </DropdownItem> + ))} + {appVersion && ( + <div + style={{ + marginTop: 8, + padding: "4px 12px", + fontSize: 11, + color: "var(--launch-text-muted)", + textAlign: "center", + userSelect: "text", + }} + > + v{appVersion} + </div> + )} + </HudPopover> + ); +} diff --git a/src/components/launch/popovers/PopoverScaffold.tsx b/src/components/launch/popovers/PopoverScaffold.tsx new file mode 100644 index 000000000..3be3dfccc --- /dev/null +++ b/src/components/launch/popovers/PopoverScaffold.tsx @@ -0,0 +1,99 @@ +import { MicrophoneIcon, MicrophoneSlashIcon } from "@phosphor-icons/react"; +import type { ReactElement, ReactNode } from "react"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { useAudioLevelMeter } from "@/hooks/useAudioLevelMeter"; +import { AudioLevelMeter } from "@/components/ui/audio-level-meter"; +import styles from "../LaunchWindow.module.css"; +import "../launchTheme.css"; +import type { DeviceOption } from "./launchPopoverTypes"; +import { useHudInteraction } from "../contexts/HudInteractionContext"; + +export function DropdownItem({ + onClick, + selected, + icon, + children, + trailing, +}: { + onClick: () => void; + selected?: boolean; + icon: ReactNode; + children: ReactNode; + trailing?: ReactNode; +}) { + return ( + <button + type="button" + className={`${styles.ddItem} ${selected ? styles.ddItemSelected : ""}`} + onClick={onClick} + > + <span className="shrink-0">{icon}</span> + <span className="truncate">{children}</span> + {trailing} + </button> + ); +} + +export function MicDeviceRow({ + device, + selected, + onSelect, +}: { + device: DeviceOption; + selected: boolean; + onSelect: () => void; +}) { + const { level } = useAudioLevelMeter({ + enabled: true, + deviceId: device.deviceId, + }); + + return ( + <button + type="button" + className={`${styles.ddItem} ${selected ? styles.ddItemSelected : ""}`} + onClick={onSelect} + > + <span className="shrink-0"> + {selected ? <MicrophoneIcon size={16} /> : <MicrophoneSlashIcon size={16} />} + </span> + <span className="truncate flex-1">{device.label}</span> + <AudioLevelMeter level={level} className="w-16 shrink-0" /> + </button> + ); +} + +export function HudPopover({ + open, + onOpenChange, + trigger, + children, + align = "center", +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + trigger: ReactElement; + children: ReactNode; + align?: "start" | "center" | "end"; +}) { + const { onMouseEnter } = useHudInteraction(); + return ( + <Popover open={open} onOpenChange={onOpenChange} modal={false}> + <PopoverTrigger asChild>{trigger}</PopoverTrigger> + <PopoverContent + className={`launch-theme ${styles.menuCard} ${styles.electronNoDrag}`} + data-hud-interactive + unstyled + side="top" + align={align} + sideOffset={8} + avoidCollisions + collisionPadding={10} + usePortal={false} + onMouseEnter={onMouseEnter} + > + {children} + </PopoverContent> + </Popover> + ); +} diff --git a/src/components/launch/popovers/ProjectPopover.tsx b/src/components/launch/popovers/ProjectPopover.tsx new file mode 100644 index 000000000..22df11d05 --- /dev/null +++ b/src/components/launch/popovers/ProjectPopover.tsx @@ -0,0 +1,48 @@ +import type { ReactElement } from "react"; +import { useLaunchPopoverCoordinator } from "./LaunchPopoverCoordinator"; +import { HudPopover } from "./PopoverScaffold"; +import ProjectBrowserDialog from "../../video-editor/ProjectBrowserDialog"; +import type { ProjectLibraryEntry } from "../../video-editor/ProjectBrowserDialog"; + +const POPOVER_ID = "projects"; + +export function ProjectPopover({ + trigger, + entries, + onOpenProject, +}: { + trigger: ReactElement; + entries: ProjectLibraryEntry[]; + onOpenProject: (projectPath: string) => void; +}) { + const { isOpen, requestOpen, requestClose } = useLaunchPopoverCoordinator(); + const open = isOpen(POPOVER_ID); + + return ( + <HudPopover + open={open} + onOpenChange={(nextOpen) => { + if (!nextOpen) { + requestClose(POPOVER_ID); + return; + } + requestOpen(POPOVER_ID); + }} + trigger={trigger} + align="center" + > + <ProjectBrowserDialog + open={open} + onOpenChange={(nextOpen) => { + if (!nextOpen) requestClose(POPOVER_ID); + }} + entries={entries} + renderMode="inline" + onOpenProject={(path) => { + onOpenProject(path); + requestClose(POPOVER_ID); + }} + /> + </HudPopover> + ); +} diff --git a/src/components/launch/popovers/SourcePopover.tsx b/src/components/launch/popovers/SourcePopover.tsx new file mode 100644 index 000000000..5459fae14 --- /dev/null +++ b/src/components/launch/popovers/SourcePopover.tsx @@ -0,0 +1,77 @@ +import { useCallback, useMemo, type ReactNode, useState } from "react"; +import { SourceSelector } from "../SourceSelector"; +import { useLaunchPopoverCoordinator } from "./LaunchPopoverCoordinator"; +import { + mapRawSource, + isScreenSource, + isWindowSource, + type DesktopSource, +} from "./launchPopoverTypes"; + +const POPOVER_ID = "sources"; + +export function SourcePopover({ + trigger, + selectedSource, + onSourceSelect, + onOpen, +}: { + trigger: ReactNode; + selectedSource: string; + onSourceSelect: (source: DesktopSource) => Promise<void> | void; + onOpen?: () => void; +}) { + const { isOpen, requestOpen, requestClose } = useLaunchPopoverCoordinator(); + const [sources, setSources] = useState<DesktopSource[]>([]); + const [loading, setLoading] = useState(false); + const open = isOpen(POPOVER_ID); + + const fetchSources = useCallback(async () => { + if (!window.electronAPI) return; + setLoading(true); + try { + const rawSources = await window.electronAPI.getSources({ + types: ["screen", "window"], + thumbnailSize: { width: 160, height: 90 }, + fetchWindowIcons: true, + }); + setSources(rawSources.map((s) => mapRawSource(s as DesktopSource))); + } catch (error) { + console.error("Failed to fetch sources:", error); + } finally { + setLoading(false); + } + }, []); + + const screenSources = useMemo(() => sources.filter(isScreenSource), [sources]); + const windowSources = useMemo(() => sources.filter(isWindowSource), [sources]); + + return ( + <SourceSelector + screenSources={screenSources} + windowSources={windowSources} + selectedSource={selectedSource} + loading={loading} + onSourceSelect={async (source) => { + try { + await onSourceSelect(source); + requestClose(POPOVER_ID); + } catch (error) { + console.error("Failed to select source:", error); + } + }} + onFetchSources={fetchSources} + open={open} + onOpenChange={(nextOpen) => { + if (!nextOpen) { + requestClose(POPOVER_ID); + return; + } + onOpen?.(); + requestOpen(POPOVER_ID); + }} + > + {trigger} + </SourceSelector> + ); +} diff --git a/src/components/launch/popovers/WebcamPopover.tsx b/src/components/launch/popovers/WebcamPopover.tsx new file mode 100644 index 000000000..0c04ed89c --- /dev/null +++ b/src/components/launch/popovers/WebcamPopover.tsx @@ -0,0 +1,140 @@ +import { + Eye, + EyeSlash as EyeOff, + VideoCamera as Video, + VideoCameraSlash as VideoOff, +} from "@phosphor-icons/react"; +import { useScopedT } from "@/contexts/I18nContext"; +import { DropdownItem, HudPopover } from "./PopoverScaffold"; +import { useLaunchPopoverCoordinator } from "./LaunchPopoverCoordinator"; +import type { DeviceOption } from "./launchPopoverTypes"; +import type { ReactElement } from "react"; + +const POPOVER_ID = "webcam"; + +export function WebcamPopover({ + trigger, + disabled, + webcamEnabled, + onDisableWebcam, + canToggleFloatingPreview, + showFloatingWebcamPreview, + onToggleFloatingPreview, + showWebcamControls, + setWebcamPreviewNode, + videoDevices, + webcamDeviceId, + selectedVideoDeviceId, + onSelectVideoDevice, +}: { + trigger: ReactElement; + disabled?: boolean; + webcamEnabled: boolean; + onDisableWebcam: () => void; + canToggleFloatingPreview: boolean; + showFloatingWebcamPreview: boolean; + onToggleFloatingPreview: () => void; + showWebcamControls: boolean; + setWebcamPreviewNode: (node: HTMLVideoElement | null) => void; + videoDevices: DeviceOption[]; + webcamDeviceId?: string; + selectedVideoDeviceId?: string; + onSelectVideoDevice: (deviceId: string) => void; +}) { + const t = useScopedT("launch"); + const { isOpen, requestOpen, requestClose } = useLaunchPopoverCoordinator(); + const open = isOpen(POPOVER_ID); + + return ( + <HudPopover + open={open} + onOpenChange={(nextOpen) => { + if (!nextOpen) { + requestClose(POPOVER_ID); + return; + } + if (disabled) { + return; + } + requestOpen(POPOVER_ID); + }} + trigger={trigger} + align="center" + > + <div className="px-3 pb-1 pt-2 text-[10px] font-semibold uppercase tracking-[0.08em] text-[var(--launch-label)]"> + {t("recording.webcam")} + </div> + {webcamEnabled && ( + <> + <DropdownItem + icon={<VideoOff size={16} />} + onClick={() => { + onDisableWebcam(); + requestClose(POPOVER_ID); + }} + > + {t("recording.turnOffWebcam")} + </DropdownItem> + {canToggleFloatingPreview ? ( + <DropdownItem + icon={ + showFloatingWebcamPreview ? <EyeOff size={16} /> : <Eye size={16} /> + } + selected={showFloatingWebcamPreview} + onClick={onToggleFloatingPreview} + > + {showFloatingWebcamPreview + ? t("recording.hideFloatingWebcamPreview") + : t("recording.showFloatingWebcamPreview")} + </DropdownItem> + ) : null} + </> + )} + {!webcamEnabled && ( + <div className="px-3 py-2 text-xs text-[var(--launch-text-muted)]"> + {t("recording.selectWebcamToEnable")} + </div> + )} + {showWebcamControls && ( + <div className="flex justify-center px-3 py-2"> + <div className="h-24 w-24 overflow-hidden rounded-2xl bg-[var(--launch-hover)] ring-1 ring-[var(--launch-border-strong)]"> + <video + ref={setWebcamPreviewNode} + className="h-full w-full object-cover" + muted + playsInline + style={{ transform: "scaleX(-1)" }} + /> + </div> + </div> + )} + {videoDevices.map((device) => ( + <DropdownItem + key={device.deviceId} + icon={ + webcamEnabled && + (webcamDeviceId === device.deviceId || + selectedVideoDeviceId === device.deviceId) ? ( + <Video size={16} /> + ) : ( + <VideoOff size={16} /> + ) + } + selected={ + webcamEnabled && + (webcamDeviceId === device.deviceId || + selectedVideoDeviceId === device.deviceId) + } + onClick={() => onSelectVideoDevice(device.deviceId)} + > + {device.label} + </DropdownItem> + ))} + {videoDevices.length === 0 && ( + <div className="text-center text-xs text-[var(--launch-text-muted)] py-4"> + {t("recording.noWebcamsFound")} + </div> + )} + </HudPopover> + ); +} diff --git a/src/components/launch/popovers/launchPopoverTypes.ts b/src/components/launch/popovers/launchPopoverTypes.ts new file mode 100644 index 000000000..1585aaed5 --- /dev/null +++ b/src/components/launch/popovers/launchPopoverTypes.ts @@ -0,0 +1,53 @@ +export interface DesktopSource { + id: string; + name: string; + thumbnail: string | null; + display_id: string; + appIcon: string | null; + sourceType?: "screen" | "window"; + appName?: string; + windowTitle?: string; +} + +/** + * Check if a source is a screen/display + */ +export function isScreenSource(s: DesktopSource): boolean { + return s.sourceType === "screen" || s.id.startsWith("screen:"); +} + +/** + * Check if a source is an application window + */ +export function isWindowSource(s: DesktopSource): boolean { + return s.sourceType === "window" || s.id.startsWith("window:"); +} + +export function mapRawSource(s: DesktopSource): DesktopSource { + const isWindow = isWindowSource(s); + const type = s.sourceType ?? (isWindow ? "window" : "screen"); + let displayName = s.name; + let appName = s.appName; + if (isWindow && s.windowTitle) { + displayName = s.windowTitle; + } else if (isWindow && !appName && s.name.includes(" — ")) { + const parts = s.name.split(" — "); + appName = parts[0]?.trim(); + displayName = parts.slice(1).join(" — ").trim() || s.name; + } + return { + id: s.id, + name: displayName, + thumbnail: s.thumbnail, + display_id: s.display_id, + appIcon: s.appIcon, + sourceType: type, + appName, + windowTitle: s.windowTitle ?? displayName, + }; +} + +export interface DeviceOption { + deviceId: string; + label: string; +} diff --git a/src/components/ui/accordion.tsx b/src/components/ui/accordion.tsx new file mode 100644 index 000000000..a9ee8293f --- /dev/null +++ b/src/components/ui/accordion.tsx @@ -0,0 +1,55 @@ +import * as AccordionPrimitive from "@radix-ui/react-accordion"; +import { CaretDown as ChevronDown } from "@phosphor-icons/react"; +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +const Accordion = AccordionPrimitive.Root; + +const AccordionItem = React.forwardRef< + React.ElementRef<typeof AccordionPrimitive.Item>, + React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item> +>(({ className, ...props }, ref) => ( + <AccordionPrimitive.Item + ref={ref} + className={cn("border-b border-foreground/5", className)} + {...props} + /> +)); +AccordionItem.displayName = "AccordionItem"; + +const AccordionTrigger = React.forwardRef< + React.ElementRef<typeof AccordionPrimitive.Trigger>, + React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger> +>(({ className, children, ...props }, ref) => ( + <AccordionPrimitive.Header className="flex"> + <AccordionPrimitive.Trigger + ref={ref} + className={cn( + "flex flex-1 items-center justify-between py-3 text-sm font-medium text-foreground transition-all hover:text-foreground [&[data-state=open]>svg]:rotate-180", + className, + )} + {...props} + > + {children} + <ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-200" /> + </AccordionPrimitive.Trigger> + </AccordionPrimitive.Header> +)); +AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName; + +const AccordionContent = React.forwardRef< + React.ElementRef<typeof AccordionPrimitive.Content>, + React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content> +>(({ className, children, ...props }, ref) => ( + <AccordionPrimitive.Content + ref={ref} + className="overflow-hidden text-sm data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down" + {...props} + > + <div className={cn("pb-4 pt-0", className)}>{children}</div> + </AccordionPrimitive.Content> +)); +AccordionContent.displayName = AccordionPrimitive.Content.displayName; + +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }; diff --git a/src/components/ui/audio-level-meter.tsx b/src/components/ui/audio-level-meter.tsx new file mode 100644 index 000000000..55e4bd1e2 --- /dev/null +++ b/src/components/ui/audio-level-meter.tsx @@ -0,0 +1,37 @@ +interface AudioLevelMeterProps { + level: number; + className?: string; +} + +const bars = [ + { threshold: 10, height: "30%" }, + { threshold: 25, height: "45%" }, + { threshold: 45, height: "60%" }, + { threshold: 65, height: "75%" }, + { threshold: 85, height: "90%" }, +]; + +function getBarColor(level: number, threshold: number) { + if (!level || level < threshold) return "bg-slate-700"; + if (threshold > 80) return "bg-red-500"; + if (threshold > 60) return "bg-yellow-500"; + if (threshold > 40) return "bg-blue-500"; + return "bg-blue-400"; +} + +export function AudioLevelMeter({ level, className = "" }: AudioLevelMeterProps) { + return ( + <div className={`flex items-end justify-between gap-1.5 h-6 ${className}`}> + {bars.map((bar, index) => ( + <div + key={index} + className={`flex-1 rounded-sm transition-all duration-100 ease-out ${getBarColor(level, bar.threshold)}`} + style={{ + height: level >= bar.threshold ? bar.height : "15%", + opacity: level >= bar.threshold ? 1 : 0.4, + }} + /> + ))} + </div> + ); +} diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index 65d4fcd9c..4ad994787 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -1,57 +1,66 @@ -import * as React from "react" -import { Slot } from "@radix-ui/react-slot" -import { cva, type VariantProps } from "class-variance-authority" +import { Slot } from "@radix-ui/react-slot"; +import { cva, type VariantProps } from "class-variance-authority"; +import * as React from "react"; -import { cn } from "@/lib/utils" +import { cn } from "@/lib/utils"; const buttonVariants = cva( - "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", - { - variants: { - variant: { - default: - "bg-primary text-primary-foreground shadow hover:bg-primary/90", - destructive: - "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", - outline: - "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground", - secondary: - "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", - ghost: "hover:bg-accent hover:text-accent-foreground", - link: "text-primary underline-offset-4 hover:underline", - }, - size: { - default: "h-9 px-4 py-2", - sm: "h-8 rounded-md px-3 text-xs", - lg: "h-10 rounded-md px-8", - icon: "h-9 w-9", - }, - }, - defaultVariants: { - variant: "default", - size: "default", - }, - } -) + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground shadow hover:bg-primary/90", + destructive: + "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", + outline: + "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground", + secondary: "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", + ghost: "hover:bg-accent hover:text-accent-foreground", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-9 px-4 py-2", + sm: "h-8 rounded-md px-3 text-xs", + lg: "h-10 rounded-md px-8", + icon: "h-9 w-9", + }, + // Special variant for icon buttons with consistent sizing + iconSize: { + default: "[&_svg]:size-4", + sm: "[&_svg]:size-3.5", + lg: "[&_svg]:size-5", + xl: "[&_svg]:size-6", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + iconSize: "default", + }, + }, +); export interface ButtonProps - extends React.ButtonHTMLAttributes<HTMLButtonElement>, - VariantProps<typeof buttonVariants> { - asChild?: boolean + extends React.ButtonHTMLAttributes<HTMLButtonElement>, + VariantProps<typeof buttonVariants> { + /** Whether to render the button as a child component (useful for composition) */ + asChild?: boolean; + /** Size of the icon inside the button */ + iconSize?: "default" | "sm" | "lg" | "xl"; } const Button = React.forwardRef<HTMLButtonElement, ButtonProps>( - ({ className, variant, size, asChild = false, ...props }, ref) => { - const Comp = asChild ? Slot : "button" - return ( - <Comp - className={cn(buttonVariants({ variant, size, className }))} - ref={ref} - {...props} - /> - ) - } -) -Button.displayName = "Button" + ({ className, variant, size, iconSize, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "button"; + return ( + <Comp + className={cn(buttonVariants({ variant, size, iconSize, className }))} + ref={ref} + {...props} + /> + ); + }, +); +Button.displayName = "Button"; -export { Button, buttonVariants } +export { Button, buttonVariants }; diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx index cabfbfc59..2935ed463 100644 --- a/src/components/ui/card.tsx +++ b/src/components/ui/card.tsx @@ -1,76 +1,55 @@ -import * as React from "react" - -import { cn } from "@/lib/utils" - -const Card = React.forwardRef< - HTMLDivElement, - React.HTMLAttributes<HTMLDivElement> ->(({ className, ...props }, ref) => ( - <div - ref={ref} - className={cn( - "rounded-xl border bg-card text-card-foreground shadow", - className - )} - {...props} - /> -)) -Card.displayName = "Card" - -const CardHeader = React.forwardRef< - HTMLDivElement, - React.HTMLAttributes<HTMLDivElement> ->(({ className, ...props }, ref) => ( - <div - ref={ref} - className={cn("flex flex-col space-y-1.5 p-6", className)} - {...props} - /> -)) -CardHeader.displayName = "CardHeader" - -const CardTitle = React.forwardRef< - HTMLDivElement, - React.HTMLAttributes<HTMLDivElement> ->(({ className, ...props }, ref) => ( - <div - ref={ref} - className={cn("font-semibold leading-none tracking-tight", className)} - {...props} - /> -)) -CardTitle.displayName = "CardTitle" - -const CardDescription = React.forwardRef< - HTMLDivElement, - React.HTMLAttributes<HTMLDivElement> ->(({ className, ...props }, ref) => ( - <div - ref={ref} - className={cn("text-sm text-muted-foreground", className)} - {...props} - /> -)) -CardDescription.displayName = "CardDescription" - -const CardContent = React.forwardRef< - HTMLDivElement, - React.HTMLAttributes<HTMLDivElement> ->(({ className, ...props }, ref) => ( - <div ref={ref} className={cn("p-6 pt-0", className)} {...props} /> -)) -CardContent.displayName = "CardContent" - -const CardFooter = React.forwardRef< - HTMLDivElement, - React.HTMLAttributes<HTMLDivElement> ->(({ className, ...props }, ref) => ( - <div - ref={ref} - className={cn("flex items-center p-6 pt-0", className)} - {...props} - /> -)) -CardFooter.displayName = "CardFooter" - -export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>( + ({ className, ...props }, ref) => ( + <div + ref={ref} + className={cn("rounded-xl border bg-card text-card-foreground shadow", className)} + {...props} + /> + ), +); +Card.displayName = "Card"; + +const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>( + ({ className, ...props }, ref) => ( + <div ref={ref} className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} /> + ), +); +CardHeader.displayName = "CardHeader"; + +const CardTitle = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>( + ({ className, ...props }, ref) => ( + <div + ref={ref} + className={cn("font-semibold leading-none tracking-tight", className)} + {...props} + /> + ), +); +CardTitle.displayName = "CardTitle"; + +const CardDescription = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>( + ({ className, ...props }, ref) => ( + <div ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} /> + ), +); +CardDescription.displayName = "CardDescription"; + +const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>( + ({ className, ...props }, ref) => ( + <div ref={ref} className={cn("p-6 pt-0", className)} {...props} /> + ), +); +CardContent.displayName = "CardContent"; + +const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>( + ({ className, ...props }, ref) => ( + <div ref={ref} className={cn("flex items-center p-6 pt-0", className)} {...props} /> + ), +); +CardFooter.displayName = "CardFooter"; + +export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }; diff --git a/src/components/ui/content-clamp.tsx b/src/components/ui/content-clamp.tsx new file mode 100644 index 000000000..cb20ae41e --- /dev/null +++ b/src/components/ui/content-clamp.tsx @@ -0,0 +1,112 @@ +"use client"; + +import * as React from "react"; + +import { cn } from "@/lib/utils"; +import { Popover, PopoverArrow, PopoverContent, PopoverTrigger } from "./popover"; + +interface ContentClampProps extends React.HTMLAttributes<HTMLDivElement> { + children: React.ReactNode; + truncateLength?: number; +} + +function ContentClamp({ children, className, truncateLength = 50, ...props }: ContentClampProps) { + const text = typeof children === "string" ? children : String(children ?? ""); + const isTruncated = text.length > truncateLength; + + const [open, setOpen] = React.useState(false); + const timeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null); + + const clearScheduledClose = () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + }; + + const openPopover = () => { + clearScheduledClose(); + setOpen(true); + }; + + const scheduleClose = () => { + clearScheduledClose(); + timeoutRef.current = setTimeout(() => { + setOpen(false); + timeoutRef.current = null; + }, 100); + }; + + const togglePopover = () => { + clearScheduledClose(); + setOpen((currentOpen) => !currentOpen); + }; + + const handleKeyDown = (event: React.KeyboardEvent<HTMLSpanElement>) => { + if (event.key !== "Enter" && event.key !== " ") { + return; + } + + event.preventDefault(); + togglePopover(); + }; + + React.useEffect(() => { + return () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + }; + }, []); + + if (!isTruncated) { + return ( + <div className={cn("inline", className)} {...props}> + {children} + </div> + ); + } + + const truncatedText = text.slice(0, truncateLength) + "..."; + + return ( + <Popover open={open} onOpenChange={setOpen}> + <PopoverTrigger asChild> + <span + className={cn("cursor-help", className)} + onMouseEnter={openPopover} + onMouseLeave={scheduleClose} + onFocus={openPopover} + onBlur={scheduleClose} + onClick={(event) => { + event.preventDefault(); + togglePopover(); + }} + onKeyDown={handleKeyDown} + role="button" + tabIndex={0} + aria-haspopup="dialog" + aria-expanded={open} + {...props} + > + {truncatedText} + </span> + </PopoverTrigger> + <PopoverContent + className="w-auto max-w-sm rounded-lg border border-white bg-popover p-3 text-sm text-popover-foreground" + sideOffset={8} + animated={false} + onMouseEnter={openPopover} + onMouseLeave={scheduleClose} + onPointerDownOutside={(e) => e.preventDefault()} + onClick={(e) => e.stopPropagation()} + > + <PopoverArrow className="fill-white" /> + {children} + </PopoverContent> + </Popover> + ); +} + +export { ContentClamp }; diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx index 9dbeaa096..fa1ff0c63 100644 --- a/src/components/ui/dialog.tsx +++ b/src/components/ui/dialog.tsx @@ -1,120 +1,105 @@ -import * as React from "react" -import * as DialogPrimitive from "@radix-ui/react-dialog" -import { X } from "lucide-react" +import * as DialogPrimitive from "@radix-ui/react-dialog"; +import { X } from "@phosphor-icons/react"; +import * as React from "react"; -import { cn } from "@/lib/utils" +import { cn } from "@/lib/utils"; -const Dialog = DialogPrimitive.Root +const Dialog = DialogPrimitive.Root; -const DialogTrigger = DialogPrimitive.Trigger +const DialogTrigger = DialogPrimitive.Trigger; -const DialogPortal = DialogPrimitive.Portal +const DialogPortal = DialogPrimitive.Portal; -const DialogClose = DialogPrimitive.Close +const DialogClose = DialogPrimitive.Close; const DialogOverlay = React.forwardRef< - React.ElementRef<typeof DialogPrimitive.Overlay>, - React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay> + React.ElementRef<typeof DialogPrimitive.Overlay>, + React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay> >(({ className, ...props }, ref) => ( - <DialogPrimitive.Overlay - ref={ref} - className={cn( - "fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0", - className - )} - {...props} - /> -)) -DialogOverlay.displayName = DialogPrimitive.Overlay.displayName + <DialogPrimitive.Overlay + ref={ref} + className={cn( + "fixed inset-0 z-[9999] bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0", + className, + )} + {...props} + /> +)); +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; const DialogContent = React.forwardRef< - React.ElementRef<typeof DialogPrimitive.Content>, - React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> + React.ElementRef<typeof DialogPrimitive.Content>, + React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> >(({ className, children, ...props }, ref) => ( - <DialogPortal> - <DialogOverlay /> - <DialogPrimitive.Content - ref={ref} - className={cn( - "fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg", - className - )} - {...props} - > - {children} - <DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground"> - <X className="h-4 w-4" /> - <span className="sr-only">Close</span> - </DialogPrimitive.Close> - </DialogPrimitive.Content> - </DialogPortal> -)) -DialogContent.displayName = DialogPrimitive.Content.displayName + <DialogPortal> + <DialogOverlay /> + <DialogPrimitive.Content + ref={ref} + className={cn( + "fixed left-[50%] top-[50%] z-[10000] grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg", + className, + )} + {...props} + > + {children} + <DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground"> + <X className="h-4 w-4" /> + <span className="sr-only">Close</span> + </DialogPrimitive.Close> + </DialogPrimitive.Content> + </DialogPortal> +)); +DialogContent.displayName = DialogPrimitive.Content.displayName; -const DialogHeader = ({ - className, - ...props -}: React.HTMLAttributes<HTMLDivElement>) => ( - <div - className={cn( - "flex flex-col space-y-1.5 text-center sm:text-left", - className - )} - {...props} - /> -) -DialogHeader.displayName = "DialogHeader" +const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => ( + <div + className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)} + {...props} + /> +); +DialogHeader.displayName = "DialogHeader"; -const DialogFooter = ({ - className, - ...props -}: React.HTMLAttributes<HTMLDivElement>) => ( - <div - className={cn( - "flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", - className - )} - {...props} - /> -) -DialogFooter.displayName = "DialogFooter" +const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => ( + <div + className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)} + {...props} + /> +); +DialogFooter.displayName = "DialogFooter"; const DialogTitle = React.forwardRef< - React.ElementRef<typeof DialogPrimitive.Title>, - React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title> + React.ElementRef<typeof DialogPrimitive.Title>, + React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title> >(({ className, ...props }, ref) => ( - <DialogPrimitive.Title - ref={ref} - className={cn( - "text-lg font-semibold leading-none tracking-tight", - className - )} - {...props} - /> -)) -DialogTitle.displayName = DialogPrimitive.Title.displayName + <DialogPrimitive.Title + ref={ref} + className={cn("text-lg font-semibold leading-none tracking-tight", className)} + {...props} + /> +)); +DialogTitle.displayName = DialogPrimitive.Title.displayName; const DialogDescription = React.forwardRef< - React.ElementRef<typeof DialogPrimitive.Description>, - React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description> + React.ElementRef<typeof DialogPrimitive.Description>, + React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description> >(({ className, ...props }, ref) => ( - <DialogPrimitive.Description - ref={ref} - className={cn("text-sm text-muted-foreground", className)} - {...props} - /> -)) -DialogDescription.displayName = DialogPrimitive.Description.displayName + <DialogPrimitive.Description + ref={ref} + className={cn("text-sm text-muted-foreground", className)} + {...props} + /> +)); +DialogDescription.displayName = DialogPrimitive.Description.displayName; export { - Dialog, - DialogPortal, - DialogOverlay, - DialogTrigger, - DialogClose, - DialogContent, - DialogHeader, - DialogFooter, - DialogTitle, - DialogDescription, -} + Dialog, + DialogPortal, + DialogOverlay, + DialogTrigger, + DialogClose, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +}; diff --git a/src/components/ui/dropdown-menu.tsx b/src/components/ui/dropdown-menu.tsx index e804bca11..079b52512 100644 --- a/src/components/ui/dropdown-menu.tsx +++ b/src/components/ui/dropdown-menu.tsx @@ -1,199 +1,186 @@ -import * as React from "react" -import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" -import { Check, ChevronRight, Circle } from "lucide-react" +import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"; +import { CaretRight as ChevronRight, Check, Circle } from "@phosphor-icons/react"; +import * as React from "react"; -import { cn } from "@/lib/utils" +import { cn } from "@/lib/utils"; -const DropdownMenu = DropdownMenuPrimitive.Root +const DropdownMenu = DropdownMenuPrimitive.Root; -const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger +const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger; -const DropdownMenuGroup = DropdownMenuPrimitive.Group +const DropdownMenuGroup = DropdownMenuPrimitive.Group; -const DropdownMenuPortal = DropdownMenuPrimitive.Portal +const DropdownMenuPortal = DropdownMenuPrimitive.Portal; -const DropdownMenuSub = DropdownMenuPrimitive.Sub +const DropdownMenuSub = DropdownMenuPrimitive.Sub; -const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup +const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup; const DropdownMenuSubTrigger = React.forwardRef< - React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>, - React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & { - inset?: boolean - } + React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>, + React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & { + inset?: boolean; + } >(({ className, inset, children, ...props }, ref) => ( - <DropdownMenuPrimitive.SubTrigger - ref={ref} - className={cn( - "flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", - inset && "pl-8", - className - )} - {...props} - > - {children} - <ChevronRight className="ml-auto" /> - </DropdownMenuPrimitive.SubTrigger> -)) -DropdownMenuSubTrigger.displayName = - DropdownMenuPrimitive.SubTrigger.displayName + <DropdownMenuPrimitive.SubTrigger + ref={ref} + className={cn( + "flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", + inset && "pl-8", + className, + )} + {...props} + > + {children} + <ChevronRight className="ml-auto" /> + </DropdownMenuPrimitive.SubTrigger> +)); +DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName; const DropdownMenuSubContent = React.forwardRef< - React.ElementRef<typeof DropdownMenuPrimitive.SubContent>, - React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent> + React.ElementRef<typeof DropdownMenuPrimitive.SubContent>, + React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent> >(({ className, ...props }, ref) => ( - <DropdownMenuPrimitive.SubContent - ref={ref} - className={cn( - "z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]", - className - )} - {...props} - /> -)) -DropdownMenuSubContent.displayName = - DropdownMenuPrimitive.SubContent.displayName + <DropdownMenuPrimitive.SubContent + ref={ref} + className={cn( + "z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]", + className, + )} + {...props} + /> +)); +DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName; const DropdownMenuContent = React.forwardRef< - React.ElementRef<typeof DropdownMenuPrimitive.Content>, - React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content> + React.ElementRef<typeof DropdownMenuPrimitive.Content>, + React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content> >(({ className, sideOffset = 4, ...props }, ref) => ( - <DropdownMenuPrimitive.Portal> - <DropdownMenuPrimitive.Content - ref={ref} - sideOffset={sideOffset} - className={cn( - "z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md", - "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]", - className - )} - {...props} - /> - </DropdownMenuPrimitive.Portal> -)) -DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName + <DropdownMenuPrimitive.Portal> + <DropdownMenuPrimitive.Content + ref={ref} + sideOffset={sideOffset} + className={cn( + "z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md", + "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]", + className, + )} + {...props} + /> + </DropdownMenuPrimitive.Portal> +)); +DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName; const DropdownMenuItem = React.forwardRef< - React.ElementRef<typeof DropdownMenuPrimitive.Item>, - React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & { - inset?: boolean - } + React.ElementRef<typeof DropdownMenuPrimitive.Item>, + React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & { + inset?: boolean; + } >(({ className, inset, ...props }, ref) => ( - <DropdownMenuPrimitive.Item - ref={ref} - className={cn( - "relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0", - inset && "pl-8", - className - )} - {...props} - /> -)) -DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName + <DropdownMenuPrimitive.Item + ref={ref} + className={cn( + "relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0", + inset && "pl-8", + className, + )} + {...props} + /> +)); +DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName; const DropdownMenuCheckboxItem = React.forwardRef< - React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>, - React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem> + React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>, + React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem> >(({ className, children, checked, ...props }, ref) => ( - <DropdownMenuPrimitive.CheckboxItem - ref={ref} - className={cn( - "relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", - className - )} - checked={checked} - {...props} - > - <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center"> - <DropdownMenuPrimitive.ItemIndicator> - <Check className="h-4 w-4" /> - </DropdownMenuPrimitive.ItemIndicator> - </span> - {children} - </DropdownMenuPrimitive.CheckboxItem> -)) -DropdownMenuCheckboxItem.displayName = - DropdownMenuPrimitive.CheckboxItem.displayName + <DropdownMenuPrimitive.CheckboxItem + ref={ref} + className={cn( + "relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", + className, + )} + checked={checked} + {...props} + > + <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center"> + <DropdownMenuPrimitive.ItemIndicator> + <Check className="h-4 w-4" /> + </DropdownMenuPrimitive.ItemIndicator> + </span> + {children} + </DropdownMenuPrimitive.CheckboxItem> +)); +DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName; const DropdownMenuRadioItem = React.forwardRef< - React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>, - React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem> + React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>, + React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem> >(({ className, children, ...props }, ref) => ( - <DropdownMenuPrimitive.RadioItem - ref={ref} - className={cn( - "relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", - className - )} - {...props} - > - <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center"> - <DropdownMenuPrimitive.ItemIndicator> - <Circle className="h-2 w-2 fill-current" /> - </DropdownMenuPrimitive.ItemIndicator> - </span> - {children} - </DropdownMenuPrimitive.RadioItem> -)) -DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName + <DropdownMenuPrimitive.RadioItem + ref={ref} + className={cn( + "relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", + className, + )} + {...props} + > + <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center"> + <DropdownMenuPrimitive.ItemIndicator> + <Circle className="h-2 w-2 fill-current" /> + </DropdownMenuPrimitive.ItemIndicator> + </span> + {children} + </DropdownMenuPrimitive.RadioItem> +)); +DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName; const DropdownMenuLabel = React.forwardRef< - React.ElementRef<typeof DropdownMenuPrimitive.Label>, - React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & { - inset?: boolean - } + React.ElementRef<typeof DropdownMenuPrimitive.Label>, + React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & { + inset?: boolean; + } >(({ className, inset, ...props }, ref) => ( - <DropdownMenuPrimitive.Label - ref={ref} - className={cn( - "px-2 py-1.5 text-sm font-semibold", - inset && "pl-8", - className - )} - {...props} - /> -)) -DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName + <DropdownMenuPrimitive.Label + ref={ref} + className={cn("px-2 py-1.5 text-sm font-semibold", inset && "pl-8", className)} + {...props} + /> +)); +DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName; const DropdownMenuSeparator = React.forwardRef< - React.ElementRef<typeof DropdownMenuPrimitive.Separator>, - React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator> + React.ElementRef<typeof DropdownMenuPrimitive.Separator>, + React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator> >(({ className, ...props }, ref) => ( - <DropdownMenuPrimitive.Separator - ref={ref} - className={cn("-mx-1 my-1 h-px bg-muted", className)} - {...props} - /> -)) -DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName - -const DropdownMenuShortcut = ({ - className, - ...props -}: React.HTMLAttributes<HTMLSpanElement>) => { - return ( - <span - className={cn("ml-auto text-xs tracking-widest opacity-60", className)} - {...props} - /> - ) -} -DropdownMenuShortcut.displayName = "DropdownMenuShortcut" + <DropdownMenuPrimitive.Separator + ref={ref} + className={cn("-mx-1 my-1 h-px bg-muted", className)} + {...props} + /> +)); +DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName; + +const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => { + return ( + <span className={cn("ml-auto text-xs tracking-widest opacity-60", className)} {...props} /> + ); +}; +DropdownMenuShortcut.displayName = "DropdownMenuShortcut"; export { - DropdownMenu, - DropdownMenuTrigger, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuCheckboxItem, - DropdownMenuRadioItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuShortcut, - DropdownMenuGroup, - DropdownMenuPortal, - DropdownMenuSub, - DropdownMenuSubContent, - DropdownMenuSubTrigger, - DropdownMenuRadioGroup, -} + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuGroup, + DropdownMenuPortal, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuRadioGroup, +}; diff --git a/src/components/ui/input.tsx b/src/components/ui/input.tsx new file mode 100644 index 000000000..68ac869ee --- /dev/null +++ b/src/components/ui/input.tsx @@ -0,0 +1,23 @@ +import * as React from "react"; +import { cn } from "@/lib/utils"; + +export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {} + +const Input = React.forwardRef<HTMLInputElement, InputProps>( + ({ className, type, ...props }, ref) => { + return ( + <input + type={type} + className={cn( + "flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50", + className, + )} + ref={ref} + {...props} + /> + ); + }, +); +Input.displayName = "Input"; + +export { Input }; diff --git a/src/components/ui/item-content.tsx b/src/components/ui/item-content.tsx index 9d6027199..8e2aef023 100644 --- a/src/components/ui/item-content.tsx +++ b/src/components/ui/item-content.tsx @@ -7,11 +7,12 @@ interface ItemContentProps extends PropsWithChildren { function ItemContent({ children, classes }: ItemContentProps) { return ( <div - className={`border-2 rounded-sm shadow-md w-full overflow-hidden flex flex-row pl-3 h-items-center ${classes}`} + className={`bg-foreground/5 border border-foreground/10 rounded-md shadow-sm w-full flex flex-row items-center px-3 py-1 gap-2 transition-all duration-150 hover:bg-[#2563EB]/10 hover:shadow-lg ${classes}`} + style={{ minHeight: 40 }} > {children} </div> ); } -export default ItemContent; \ No newline at end of file +export default ItemContent; diff --git a/src/components/ui/label.tsx b/src/components/ui/label.tsx new file mode 100644 index 000000000..fd6cbcc8b --- /dev/null +++ b/src/components/ui/label.tsx @@ -0,0 +1,20 @@ +import * as React from "react"; +import { cn } from "@/lib/utils"; + +export interface LabelProps extends React.LabelHTMLAttributes<HTMLLabelElement> {} + +const Label = React.forwardRef<HTMLLabelElement, LabelProps>(({ className, ...props }, ref) => { + return ( + <label + ref={ref} + className={cn( + "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70", + className, + )} + {...props} + /> + ); +}); +Label.displayName = "Label"; + +export { Label }; diff --git a/src/components/ui/popover.tsx b/src/components/ui/popover.tsx new file mode 100644 index 000000000..cd6bb4ae7 --- /dev/null +++ b/src/components/ui/popover.tsx @@ -0,0 +1,70 @@ +"use client"; + +import * as PopoverPrimitive from "@radix-ui/react-popover"; +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +function Popover({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Root>) { + return <PopoverPrimitive.Root data-slot="popover" {...props} />; +} + +function PopoverTrigger({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Trigger>) { + return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />; +} + +function PopoverContent({ + className, + align = "center", + sideOffset = 4, + animated = true, + usePortal = true, + unstyled = false, + ...props +}: React.ComponentProps<typeof PopoverPrimitive.Content> & { + animated?: boolean; + usePortal?: boolean; + unstyled?: boolean; +}) { + const content = ( + <PopoverPrimitive.Content + data-slot="popover-content" + align={align} + sideOffset={sideOffset} + className={cn( + !unstyled && + "bg-popover text-popover-foreground z-50 w-72 rounded-md border p-4 shadow-md outline-hidden", + unstyled && "z-50 outline-hidden", + animated && + "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-(--radix-popover-content-transform-origin)", + className, + )} + {...props} + /> + ); + + if (usePortal === false) { + return content; + } + + return <PopoverPrimitive.Portal>{content}</PopoverPrimitive.Portal>; +} + +function PopoverAnchor({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Anchor>) { + return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />; +} + +function PopoverArrow({ + className, + ...props +}: React.ComponentProps<typeof PopoverPrimitive.Arrow>) { + return ( + <PopoverPrimitive.Arrow + data-slot="popover-arrow" + className={cn("fill-popover", className)} + {...props} + /> + ); +} + +export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor, PopoverArrow }; diff --git a/src/components/ui/select.tsx b/src/components/ui/select.tsx new file mode 100644 index 000000000..55597aeb2 --- /dev/null +++ b/src/components/ui/select.tsx @@ -0,0 +1,153 @@ +"use client"; + +import * as SelectPrimitive from "@radix-ui/react-select"; +import { CaretDown as ChevronDown, CaretUp as ChevronUp, Check } from "@phosphor-icons/react"; +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +const Select = SelectPrimitive.Root; + +const SelectGroup = SelectPrimitive.Group; + +const SelectValue = SelectPrimitive.Value; + +const SelectTrigger = React.forwardRef< + React.ElementRef<typeof SelectPrimitive.Trigger>, + React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger> +>(({ className, children, ...props }, ref) => ( + <SelectPrimitive.Trigger + ref={ref} + className={cn( + "flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1", + className, + )} + {...props} + > + {children} + <SelectPrimitive.Icon asChild> + <ChevronDown className="h-4 w-4 opacity-50" /> + </SelectPrimitive.Icon> + </SelectPrimitive.Trigger> +)); +SelectTrigger.displayName = SelectPrimitive.Trigger.displayName; + +const SelectScrollUpButton = React.forwardRef< + React.ElementRef<typeof SelectPrimitive.ScrollUpButton>, + React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton> +>(({ className, ...props }, ref) => ( + <SelectPrimitive.ScrollUpButton + ref={ref} + className={cn("flex cursor-default items-center justify-center py-1", className)} + {...props} + > + <ChevronUp className="h-4 w-4" /> + </SelectPrimitive.ScrollUpButton> +)); +SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName; + +const SelectScrollDownButton = React.forwardRef< + React.ElementRef<typeof SelectPrimitive.ScrollDownButton>, + React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton> +>(({ className, ...props }, ref) => ( + <SelectPrimitive.ScrollDownButton + ref={ref} + className={cn("flex cursor-default items-center justify-center py-1", className)} + {...props} + > + <ChevronDown className="h-4 w-4" /> + </SelectPrimitive.ScrollDownButton> +)); +SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName; + +const SelectContent = React.forwardRef< + React.ElementRef<typeof SelectPrimitive.Content>, + React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content> +>(({ className, children, position = "popper", ...props }, ref) => ( + <SelectPrimitive.Portal> + <SelectPrimitive.Content + ref={ref} + className={cn( + "relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", + position === "popper" && + "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", + className, + )} + position={position} + {...props} + > + <SelectScrollUpButton /> + <SelectPrimitive.Viewport + className={cn( + "p-1", + position === "popper" && + "h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]", + )} + > + {children} + </SelectPrimitive.Viewport> + <SelectScrollDownButton /> + </SelectPrimitive.Content> + </SelectPrimitive.Portal> +)); +SelectContent.displayName = SelectPrimitive.Content.displayName; + +const SelectLabel = React.forwardRef< + React.ElementRef<typeof SelectPrimitive.Label>, + React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label> +>(({ className, ...props }, ref) => ( + <SelectPrimitive.Label + ref={ref} + className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)} + {...props} + /> +)); +SelectLabel.displayName = SelectPrimitive.Label.displayName; + +const SelectItem = React.forwardRef< + React.ElementRef<typeof SelectPrimitive.Item>, + React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item> +>(({ className, children, ...props }, ref) => ( + <SelectPrimitive.Item + ref={ref} + className={cn( + "relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", + className, + )} + {...props} + > + <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center"> + <SelectPrimitive.ItemIndicator> + <Check className="h-4 w-4" /> + </SelectPrimitive.ItemIndicator> + </span> + + <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText> + </SelectPrimitive.Item> +)); +SelectItem.displayName = SelectPrimitive.Item.displayName; + +const SelectSeparator = React.forwardRef< + React.ElementRef<typeof SelectPrimitive.Separator>, + React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator> +>(({ className, ...props }, ref) => ( + <SelectPrimitive.Separator + ref={ref} + className={cn("-mx-1 my-1 h-px bg-muted", className)} + {...props} + /> +)); +SelectSeparator.displayName = SelectPrimitive.Separator.displayName; + +export { + Select, + SelectGroup, + SelectValue, + SelectTrigger, + SelectContent, + SelectLabel, + SelectItem, + SelectSeparator, + SelectScrollUpButton, + SelectScrollDownButton, +}; diff --git a/src/components/ui/separator.tsx b/src/components/ui/separator.tsx new file mode 100644 index 000000000..918798b3f --- /dev/null +++ b/src/components/ui/separator.tsx @@ -0,0 +1,26 @@ +import { cn } from "@/lib/utils"; + +interface SeparatorProps { + /** Orientation of the separator */ + orientation?: "horizontal" | "vertical"; + /** Additional CSS classes */ + className?: string; +} + +/** + * A reusable separator component that can be used as a horizontal or vertical divider. + * Replaces the inline div separators used throughout the application. + */ +export function Separator({ orientation = "horizontal", className }: SeparatorProps) { + return ( + <div + role="separator" + aria-orientation={orientation} + className={cn( + "bg-separator", + orientation === "horizontal" ? "h-px w-full" : "w-px h-full", + className, + )} + /> + ); +} diff --git a/src/components/ui/skeleton.tsx b/src/components/ui/skeleton.tsx new file mode 100644 index 000000000..93a647497 --- /dev/null +++ b/src/components/ui/skeleton.tsx @@ -0,0 +1,82 @@ +import * as React from "react"; +import { cva, type VariantProps } from "class-variance-authority"; +import { cn } from "@/lib/utils"; + +const skeletonVariants = cva( + "relative overflow-hidden rounded-md transition-shadow flex items-center justify-center", + { + variants: { + variant: { + default: "bg-muted/40", + glass: "bg-white/5 backdrop-blur-sm border border-white/10", + dark: "bg-black/20", + subtle: "bg-foreground/[0.03]", + clip: "bg-primary/5 border border-primary/10 shadow-inner", + }, + animation: { + none: "", + pulse: "animate-pulse", + shimmer: + "before:absolute before:inset-0 before:-translate-x-full before:animate-shimmer before:bg-gradient-to-r before:from-transparent before:via-foreground/[0.05] before:to-transparent", + "shimmer-glass": + "before:absolute before:inset-0 before:-translate-x-full before:animate-shimmer before:bg-gradient-to-r before:from-transparent before:via-white/[0.08] before:to-transparent", + "shimmer-premium": + "before:absolute before:inset-0 before:-translate-x-full before:animate-[shimmer_1.5s_infinite] before:bg-gradient-to-r before:from-transparent before:via-white/[0.1] before:to-transparent", + }, + }, + defaultVariants: { + variant: "default", + animation: "shimmer", + }, + }, +); + +export interface SkeletonProps + extends React.HTMLAttributes<HTMLDivElement>, + VariantProps<typeof skeletonVariants> { + label?: string; +} + +/** + * A magnificent Skeleton component designed with Apple-inspired aesthetics. + * Supports shimmer animations, pulse effects, and labels. + */ +const Skeleton = React.forwardRef<HTMLDivElement, SkeletonProps>( + ({ className, variant, animation, label, children, ...props }, ref) => { + return ( + <div + ref={ref} + className={cn(skeletonVariants({ variant, animation, className }))} + {...props} + > + {(label || children) && ( + <div className="relative z-10 flex flex-col items-center gap-2 px-4 py-2"> + {label && ( + <div className="flex items-center"> + {label.split("").map((char, i) => ( + <span + key={i} + className={cn( + "text-[11px] font-medium tracking-tight bg-gradient-to-r from-foreground/30 via-foreground/70 to-foreground/30 bg-clip-text text-transparent animate-text-shimmer whitespace-pre", + )} + style={{ + animationDelay: `${i * 0.05}s`, + animationDuration: "2.5s", + }} + > + {char} + </span> + ))} + </div> + )} + {children} + </div> + )} + </div> + ); + }, +); + +Skeleton.displayName = "Skeleton"; + +export { Skeleton, skeletonVariants }; diff --git a/src/components/ui/slider.tsx b/src/components/ui/slider.tsx index 9398b3315..ecf720059 100644 --- a/src/components/ui/slider.tsx +++ b/src/components/ui/slider.tsx @@ -1,26 +1,23 @@ -import * as React from "react" -import * as SliderPrimitive from "@radix-ui/react-slider" +import * as SliderPrimitive from "@radix-ui/react-slider"; +import * as React from "react"; -import { cn } from "@/lib/utils" +import { cn } from "@/lib/utils"; const Slider = React.forwardRef< - React.ElementRef<typeof SliderPrimitive.Root>, - React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root> + React.ElementRef<typeof SliderPrimitive.Root>, + React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root> >(({ className, ...props }, ref) => ( - <SliderPrimitive.Root - ref={ref} - className={cn( - "relative flex w-full touch-none select-none items-center", - className - )} - {...props} - > - <SliderPrimitive.Track className="relative h-1.5 w-full grow overflow-hidden rounded-full bg-primary/20"> - <SliderPrimitive.Range className="absolute h-full bg-primary" /> - </SliderPrimitive.Track> - <SliderPrimitive.Thumb className="block h-4 w-4 rounded-full border border-primary/50 bg-background shadow transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50" /> - </SliderPrimitive.Root> -)) -Slider.displayName = SliderPrimitive.Root.displayName + <SliderPrimitive.Root + ref={ref} + className={cn("relative flex w-full touch-none select-none items-center", className)} + {...props} + > + <SliderPrimitive.Track className="relative h-2.5 w-full grow overflow-hidden rounded-full bg-foreground/10"> + <SliderPrimitive.Range className="absolute h-full bg-[#2563EB]" /> + </SliderPrimitive.Track> + <SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-[#2563EB] bg-[#2563EB] shadow transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#2563EB]/50 disabled:pointer-events-none disabled:opacity-50" /> + </SliderPrimitive.Root> +)); +Slider.displayName = SliderPrimitive.Root.displayName; -export { Slider } +export { Slider }; diff --git a/src/components/ui/sonner.tsx b/src/components/ui/sonner.tsx index 415988a86..fe1e39054 100644 --- a/src/components/ui/sonner.tsx +++ b/src/components/ui/sonner.tsx @@ -3,25 +3,22 @@ import { Toaster as Sonner } from "sonner"; type ToasterProps = React.ComponentProps<typeof Sonner>; const Toaster = ({ ...props }: ToasterProps) => { - return ( - <Sonner - theme="light" - className="toaster group" - duration={3000} - toastOptions={{ - classNames: { - toast: - "group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg", - description: "group-[.toast]:text-muted-foreground", - actionButton: - "group-[.toast]:bg-primary group-[.toast]:text-primary-foreground", - cancelButton: - "group-[.toast]:bg-muted group-[.toast]:text-muted-foreground", - }, - }} - {...props} - /> - ); + return ( + <Sonner + className="toaster group" + duration={3000} + toastOptions={{ + classNames: { + toast: "group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg", + description: "group-[.toast]:text-muted-foreground", + actionButton: + "group-[.toast]:bg-primary group-[.toast]:text-primary-foreground", + cancelButton: "group-[.toast]:bg-muted group-[.toast]:text-muted-foreground", + }, + }} + {...props} + /> + ); }; export { Toaster }; diff --git a/src/components/ui/switch.tsx b/src/components/ui/switch.tsx index 15859bb5a..4e9b838d1 100644 --- a/src/components/ui/switch.tsx +++ b/src/components/ui/switch.tsx @@ -1,30 +1,30 @@ -import * as React from "react" -import * as SwitchPrimitives from "@radix-ui/react-switch" +import * as SwitchPrimitives from "@radix-ui/react-switch"; +import * as React from "react"; -import { cn } from "@/lib/utils" +import { cn } from "@/lib/utils"; const Switch = React.forwardRef< - React.ElementRef<typeof SwitchPrimitives.Root>, - React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root> + React.ElementRef<typeof SwitchPrimitives.Root>, + React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root> >(({ className, ...props }, ref) => ( - <SwitchPrimitives.Root - className={cn( - "peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50", - "data-[state=checked]:bg-[#34B27B] data-[state=unchecked]:bg-[#23232a]", - className - )} - {...props} - ref={ref} - > - <SwitchPrimitives.Thumb - className={cn( - "pointer-events-none block h-4 w-4 rounded-full shadow-lg ring-0 transition-transform", - "bg-[#f5f5f7] dark:bg-[#23232a]", - "data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0" - )} - /> - </SwitchPrimitives.Root> -)) -Switch.displayName = SwitchPrimitives.Root.displayName + <SwitchPrimitives.Root + className={cn( + "peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50", + "data-[state=checked]:bg-[#2563EB] data-[state=unchecked]:bg-muted", + className, + )} + {...props} + ref={ref} + > + <SwitchPrimitives.Thumb + className={cn( + "pointer-events-none block h-4 w-4 rounded-full shadow-lg ring-0 transition-transform", + "bg-background", + "data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0", + )} + /> + </SwitchPrimitives.Root> +)); +Switch.displayName = SwitchPrimitives.Root.displayName; -export { Switch } +export { Switch }; diff --git a/src/components/ui/tabs.tsx b/src/components/ui/tabs.tsx index 85d83beab..dbee684c5 100644 --- a/src/components/ui/tabs.tsx +++ b/src/components/ui/tabs.tsx @@ -1,53 +1,53 @@ -import * as React from "react" -import * as TabsPrimitive from "@radix-ui/react-tabs" +import * as TabsPrimitive from "@radix-ui/react-tabs"; +import * as React from "react"; -import { cn } from "@/lib/utils" +import { cn } from "@/lib/utils"; -const Tabs = TabsPrimitive.Root +const Tabs = TabsPrimitive.Root; const TabsList = React.forwardRef< - React.ElementRef<typeof TabsPrimitive.List>, - React.ComponentPropsWithoutRef<typeof TabsPrimitive.List> + React.ElementRef<typeof TabsPrimitive.List>, + React.ComponentPropsWithoutRef<typeof TabsPrimitive.List> >(({ className, ...props }, ref) => ( - <TabsPrimitive.List - ref={ref} - className={cn( - "inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground", - className - )} - {...props} - /> -)) -TabsList.displayName = TabsPrimitive.List.displayName + <TabsPrimitive.List + ref={ref} + className={cn( + "inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground", + className, + )} + {...props} + /> +)); +TabsList.displayName = TabsPrimitive.List.displayName; const TabsTrigger = React.forwardRef< - React.ElementRef<typeof TabsPrimitive.Trigger>, - React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger> + React.ElementRef<typeof TabsPrimitive.Trigger>, + React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger> >(({ className, ...props }, ref) => ( - <TabsPrimitive.Trigger - ref={ref} - className={cn( - "inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow", - className - )} - {...props} - /> -)) -TabsTrigger.displayName = TabsPrimitive.Trigger.displayName + <TabsPrimitive.Trigger + ref={ref} + className={cn( + "inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow", + className, + )} + {...props} + /> +)); +TabsTrigger.displayName = TabsPrimitive.Trigger.displayName; const TabsContent = React.forwardRef< - React.ElementRef<typeof TabsPrimitive.Content>, - React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content> + React.ElementRef<typeof TabsPrimitive.Content>, + React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content> >(({ className, ...props }, ref) => ( - <TabsPrimitive.Content - ref={ref} - className={cn( - "mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2", - className - )} - {...props} - /> -)) -TabsContent.displayName = TabsPrimitive.Content.displayName + <TabsPrimitive.Content + ref={ref} + className={cn( + "mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2", + className, + )} + {...props} + /> +)); +TabsContent.displayName = TabsPrimitive.Content.displayName; -export { Tabs, TabsList, TabsTrigger, TabsContent } +export { Tabs, TabsList, TabsTrigger, TabsContent }; diff --git a/src/components/ui/toggle-group.tsx b/src/components/ui/toggle-group.tsx new file mode 100644 index 000000000..9077e473e --- /dev/null +++ b/src/components/ui/toggle-group.tsx @@ -0,0 +1,58 @@ +"use client"; + +import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group"; +import { type VariantProps } from "class-variance-authority"; +import * as React from "react"; +import { toggleVariants } from "@/components/ui/toggle"; +import { cn } from "@/lib/utils"; + +const ToggleGroupContext = React.createContext<VariantProps<typeof toggleVariants>>({ + size: "default", + variant: "default", +}); + +const ToggleGroup = React.forwardRef< + React.ElementRef<typeof ToggleGroupPrimitive.Root>, + React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root> & + VariantProps<typeof toggleVariants> +>(({ className, variant, size, children, ...props }, ref) => ( + <ToggleGroupPrimitive.Root + ref={ref} + className={cn("flex items-center justify-center gap-1", className)} + {...props} + > + <ToggleGroupContext.Provider value={{ variant, size }}> + {children} + </ToggleGroupContext.Provider> + </ToggleGroupPrimitive.Root> +)); + +ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName; + +const ToggleGroupItem = React.forwardRef< + React.ElementRef<typeof ToggleGroupPrimitive.Item>, + React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Item> & + VariantProps<typeof toggleVariants> +>(({ className, children, variant, size, ...props }, ref) => { + const context = React.useContext(ToggleGroupContext); + + return ( + <ToggleGroupPrimitive.Item + ref={ref} + className={cn( + toggleVariants({ + variant: context.variant || variant, + size: context.size || size, + }), + className, + )} + {...props} + > + {children} + </ToggleGroupPrimitive.Item> + ); +}); + +ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName; + +export { ToggleGroup, ToggleGroupItem }; diff --git a/src/components/ui/toggle.tsx b/src/components/ui/toggle.tsx new file mode 100644 index 000000000..4a2cc68cb --- /dev/null +++ b/src/components/ui/toggle.tsx @@ -0,0 +1,45 @@ +"use client"; + +import * as TogglePrimitive from "@radix-ui/react-toggle"; +import { cva, type VariantProps } from "class-variance-authority"; +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +const toggleVariants = cva( + "inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground", + { + variants: { + variant: { + default: "bg-transparent", + outline: + "border border-input bg-transparent hover:bg-accent hover:text-accent-foreground", + }, + size: { + default: "h-10 px-3", + sm: "h-9 px-2.5", + lg: "h-11 px-5", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + }, +); + +const Toggle = React.forwardRef< + React.ElementRef<typeof TogglePrimitive.Root>, + React.ComponentPropsWithoutRef<typeof TogglePrimitive.Root> & + VariantProps<typeof toggleVariants> +>(({ className, variant, size, ...props }, ref) => ( + <TogglePrimitive.Root + ref={ref} + className={cn(toggleVariants({ variant, size, className }))} + {...props} + /> +)); + +Toggle.displayName = TogglePrimitive.Root.displayName; + +export { Toggle, toggleVariants }; diff --git a/src/components/video-editor/AddCustomFontDialog.tsx b/src/components/video-editor/AddCustomFontDialog.tsx new file mode 100644 index 000000000..2fe3aac6a --- /dev/null +++ b/src/components/video-editor/AddCustomFontDialog.tsx @@ -0,0 +1,200 @@ +import { Plus } from "@phosphor-icons/react"; +import { useState } from "react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + addCustomFont, + type CustomFont, + DuplicateFontError, + generateFontId, + isValidGoogleFontsUrl, + parseFontFamilyFromImport, +} from "@/lib/customFonts"; +import { useScopedT } from "../../contexts/I18nContext"; + +interface AddCustomFontDialogProps { + onFontAdded?: (font: CustomFont) => void; +} + +export function AddCustomFontDialog({ onFontAdded }: AddCustomFontDialogProps) { + const t = useScopedT("dialogs"); + const [open, setOpen] = useState(false); + const [importUrl, setImportUrl] = useState(""); + const [fontName, setFontName] = useState(""); + const [loading, setLoading] = useState(false); + + const resetForm = () => { + setImportUrl(""); + setFontName(""); + setLoading(false); + }; + + const handleOpenChange = (nextOpen: boolean) => { + setOpen(nextOpen); + if (!nextOpen) { + resetForm(); + } + }; + + const handleImportUrlChange = (url: string) => { + setImportUrl(url); + + // Auto-extract font name if valid Google Fonts URL + if (isValidGoogleFontsUrl(url)) { + const extracted = parseFontFamilyFromImport(url); + if (extracted && !fontName) { + setFontName(extracted); + } + } + }; + + const handleAdd = async () => { + const normalizedImportUrl = importUrl.trim(); + const normalizedFontName = fontName.trim(); + + // Validate inputs + if (!normalizedImportUrl) { + toast.error(t("addFont.enterUrl")); + return; + } + + if (!isValidGoogleFontsUrl(normalizedImportUrl)) { + toast.error(t("addFont.invalidUrl")); + return; + } + + if (!normalizedFontName) { + toast.error(t("addFont.enterName")); + return; + } + + setLoading(true); + + try { + // Extract font family from URL + const fontFamily = parseFontFamilyFromImport(normalizedImportUrl); + if (!fontFamily) { + toast.error(t("addFont.extractFailed")); + return; + } + + // Create custom font object + const newFont: CustomFont = { + id: generateFontId(normalizedFontName), + name: normalizedFontName, + fontFamily: fontFamily, + importUrl: normalizedImportUrl, + }; + + // Add font (this will load and verify it) - throws if it fails + await addCustomFont(newFont); + + // Notify parent + if (onFontAdded) { + onFontAdded(newFont); + } + + toast.success(t("addFont.addSuccess", undefined, { name: normalizedFontName })); + + // Reset and close + handleOpenChange(false); + } catch (error) { + console.error("Failed to add custom font:", error); + if (error instanceof DuplicateFontError) { + toast.error(t("addFont.addFailed"), { + description: t("addFont.alreadyAdded", "This font has already been added."), + }); + return; + } + + const errorMessage = error instanceof Error ? error.message : "Failed to load font"; + toast.error(t("addFont.addFailed"), { + description: errorMessage.includes("timeout") + ? t("addFont.loadTimeout") + : t("addFont.loadFailed"), + }); + } finally { + setLoading(false); + } + }; + + return ( + <Dialog open={open} onOpenChange={handleOpenChange}> + <DialogTrigger asChild> + <Button + variant="outline" + size="sm" + className="w-full bg-foreground/5 border-foreground/10 text-foreground hover:bg-foreground/10 h-9 text-xs" + > + <Plus className="w-3 h-3 mr-1" /> + {t("addFont.title")} + </Button> + </DialogTrigger> + <DialogContent className="bg-editor-surface-alt border-foreground/10 text-foreground"> + <DialogHeader> + <DialogTitle>{t("addFont.heading")}</DialogTitle> + <DialogDescription className="text-muted-foreground"> + {t("addFont.description")} + </DialogDescription> + </DialogHeader> + + <div className="space-y-4 mt-4"> + <div className="space-y-2"> + <Label htmlFor="import-url" className="text-foreground"> + {t("addFont.urlLabel")} + </Label> + <Input + id="import-url" + placeholder={t("addFont.urlPlaceholder")} + value={importUrl} + onChange={(e) => handleImportUrlChange(e.target.value)} + className="bg-foreground/5 border-foreground/10 text-foreground" + /> + <p className="text-xs text-muted-foreground">{t("addFont.urlHelp")}</p> + </div> + + <div className="space-y-2"> + <Label htmlFor="font-name" className="text-foreground"> + {t("addFont.nameLabel")} + </Label> + <Input + id="font-name" + placeholder={t("addFont.namePlaceholder")} + value={fontName} + onChange={(e) => setFontName(e.target.value)} + className="bg-foreground/5 border-foreground/10 text-foreground" + /> + <p className="text-xs text-muted-foreground">{t("addFont.nameHelp")}</p> + </div> + + <div className="flex justify-end gap-2 mt-6"> + <Button + variant="outline" + onClick={() => handleOpenChange(false)} + className="bg-foreground/5 border-foreground/10 text-foreground hover:bg-foreground/10" + > + {t("addFont.cancel")} + </Button> + <Button + onClick={handleAdd} + disabled={loading} + className="bg-blue-600 hover:bg-blue-700 text-white" + > + {loading ? t("addFont.adding") : t("addFont.addFont")} + </Button> + </div> + </div> + </DialogContent> + </Dialog> + ); +} diff --git a/src/components/video-editor/AnnotationOverlay.tsx b/src/components/video-editor/AnnotationOverlay.tsx new file mode 100644 index 000000000..aa6078f9a --- /dev/null +++ b/src/components/video-editor/AnnotationOverlay.tsx @@ -0,0 +1,306 @@ +import { useRef } from "react"; +import { Rnd } from "react-rnd"; +import { cn } from "@/lib/utils"; +import { getArrowComponent } from "./ArrowSvgs"; +import { type AnnotationRegion, BASE_PREVIEW_WIDTH, BLUR_ANNOTATION_STRENGTH } from "./types"; + +type Rect = { + x: number; + y: number; + width: number; + height: number; +}; + +type SceneTransform = { + scale: number; + x: number; + y: number; +}; + +interface AnnotationOverlayProps { + annotation: AnnotationRegion; + isSelected: boolean; + containerWidth: number; + containerHeight: number; + recordingRect: Rect; + sceneTransform: SceneTransform; + interactionScale?: number; + onPositionChange: (id: string, position: { x: number; y: number }) => void; + onSizeChange: (id: string, size: { width: number; height: number }) => void; + onClick: (id: string) => void; + zIndex: number; + isSelectedBoost: boolean; // Boost z-index when selected for easy editing +} + +function clampPercent(value: number) { + if (!Number.isFinite(value)) { + return 0; + } + + return Math.min(100, Math.max(0, value)); +} + +export function AnnotationOverlay({ + annotation, + isSelected, + containerWidth, + containerHeight, + recordingRect, + sceneTransform, + interactionScale = 1, + onPositionChange, + onSizeChange, + onClick, + zIndex, + isSelectedBoost, +}: AnnotationOverlayProps) { + const safeRecordingRect = + recordingRect.width > 0 && recordingRect.height > 0 + ? recordingRect + : { x: 0, y: 0, width: containerWidth, height: containerHeight }; + const sceneX = safeRecordingRect.x + (annotation.position.x / 100) * safeRecordingRect.width; + const sceneY = safeRecordingRect.y + (annotation.position.y / 100) * safeRecordingRect.height; + const sceneWidth = (annotation.size.width / 100) * safeRecordingRect.width; + const sceneHeight = (annotation.size.height / 100) * safeRecordingRect.height; + const x = sceneX * sceneTransform.scale + sceneTransform.x; + const y = sceneY * sceneTransform.scale + sceneTransform.y; + const width = sceneWidth * sceneTransform.scale; + const height = sceneHeight * sceneTransform.scale; + const sizeScale = safeRecordingRect.width / BASE_PREVIEW_WIDTH; + const blurScaleFactor = sizeScale * sceneTransform.scale; + + const isDraggingRef = useRef(false); + + const screenRectToRecordingPercent = (rect: Rect) => { + const nextSceneX = (rect.x - sceneTransform.x) / sceneTransform.scale; + const nextSceneY = (rect.y - sceneTransform.y) / sceneTransform.scale; + const nextSceneWidth = rect.width / sceneTransform.scale; + const nextSceneHeight = rect.height / sceneTransform.scale; + + return { + position: { + x: clampPercent( + ((nextSceneX - safeRecordingRect.x) / Math.max(1, safeRecordingRect.width)) * + 100, + ), + y: clampPercent( + ((nextSceneY - safeRecordingRect.y) / Math.max(1, safeRecordingRect.height)) * + 100, + ), + }, + size: { + width: clampPercent((nextSceneWidth / Math.max(1, safeRecordingRect.width)) * 100), + height: clampPercent( + (nextSceneHeight / Math.max(1, safeRecordingRect.height)) * 100, + ), + }, + }; + }; + + const renderArrow = () => { + const direction = annotation.figureData?.arrowDirection || "right"; + const color = annotation.figureData?.color || "#2563EB"; + const strokeWidth = annotation.figureData?.strokeWidth || 4; + + const ArrowComponent = getArrowComponent(direction); + return <ArrowComponent color={color} strokeWidth={strokeWidth} />; + }; + + const renderContent = () => { + switch (annotation.type) { + case "text": + return ( + <div + className="w-full h-full flex items-center overflow-hidden" + style={{ + justifyContent: + annotation.style.textAlign === "left" + ? "flex-start" + : annotation.style.textAlign === "right" + ? "flex-end" + : "center", + alignItems: "center", + padding: `${8 * sceneTransform.scale}px`, + }} + > + <span + style={{ + color: annotation.style.color, + backgroundColor: annotation.style.backgroundColor, + fontSize: `${annotation.style.fontSize * sceneTransform.scale}px`, + fontFamily: annotation.style.fontFamily, + fontWeight: annotation.style.fontWeight, + fontStyle: annotation.style.fontStyle, + textDecoration: annotation.style.textDecoration, + textAlign: annotation.style.textAlign, + wordBreak: "break-word", + whiteSpace: "pre-wrap", + boxDecorationBreak: "clone", + WebkitBoxDecorationBreak: "clone", + padding: "0.1em 0.2em", + borderRadius: `${4 * sceneTransform.scale}px`, + lineHeight: "1.4", + }} + > + {annotation.content} + </span> + </div> + ); + + case "image": + if (annotation.content && annotation.content.startsWith("data:image")) { + return ( + <img + src={annotation.content} + alt="Annotation" + className="w-full h-full object-contain" + draggable={false} + /> + ); + } + return ( + <div className="w-full h-full flex items-center justify-center text-muted-foreground text-sm"> + No image + </div> + ); + + case "figure": + if (!annotation.figureData) { + return ( + <div className="w-full h-full flex items-center justify-center text-muted-foreground text-sm"> + No arrow data + </div> + ); + } + + return ( + <div className="w-full h-full flex items-center justify-center p-2"> + {renderArrow()} + </div> + ); + + case "blur": { + const currentBlurStrength = annotation.blurIntensity ?? BLUR_ANNOTATION_STRENGTH; + const blurPx = currentBlurStrength * blurScaleFactor; + const blurStyle = `blur(${blurPx}px)`; + + return ( + <div + className="h-full w-full bg-slate-400/10" + style={{ + backdropFilter: blurStyle, + WebkitBackdropFilter: blurStyle, + backgroundColor: annotation.blurColor || "transparent", + borderRadius: `${(annotation.style.borderRadius ?? 0) * blurScaleFactor}px`, + }} + /> + ); + } + + default: + return null; + } + }; + + return ( + <Rnd + position={{ x, y }} + size={{ width, height }} + scale={interactionScale} + onDragStart={() => { + isDraggingRef.current = true; + }} + onDragStop={(_e, d) => { + const next = screenRectToRecordingPercent({ x: d.x, y: d.y, width, height }); + onPositionChange(annotation.id, next.position); + + // Reset dragging flag after a short delay to prevent click event + setTimeout(() => { + isDraggingRef.current = false; + }, 100); + }} + onResizeStop={(_e, _direction, ref, _delta, position) => { + const next = screenRectToRecordingPercent({ + x: position.x, + y: position.y, + width: ref.offsetWidth, + height: ref.offsetHeight, + }); + onPositionChange(annotation.id, next.position); + onSizeChange(annotation.id, next.size); + }} + onClick={() => { + if (isDraggingRef.current) return; + onClick(annotation.id); + }} + bounds="parent" + className={cn( + "cursor-move transition-all", + isSelected && "ring-2 ring-[#2563EB] ring-offset-2 ring-offset-transparent", + )} + style={{ + zIndex: isSelectedBoost ? zIndex + 1000 : zIndex, // Boost selected annotation to ensure it's on top + pointerEvents: "auto", + border: isSelected ? "2px solid rgba(37, 99, 235, 0.8)" : "none", + backgroundColor: isSelected ? "rgba(37, 99, 235, 0.1)" : "transparent", + boxShadow: isSelected ? "0 0 0 1px rgba(37, 99, 235, 0.35)" : "none", + }} + enableResizing={isSelected} + disableDragging={!isSelected} + resizeHandleStyles={{ + topLeft: { + width: "12px", + height: "12px", + backgroundColor: isSelected ? "white" : "transparent", + border: isSelected ? "2px solid #2563EB" : "none", + borderRadius: "50%", + left: "-6px", + top: "-6px", + cursor: "nwse-resize", + }, + topRight: { + width: "12px", + height: "12px", + backgroundColor: isSelected ? "white" : "transparent", + border: isSelected ? "2px solid #2563EB" : "none", + borderRadius: "50%", + right: "-6px", + top: "-6px", + cursor: "nesw-resize", + }, + bottomLeft: { + width: "12px", + height: "12px", + backgroundColor: isSelected ? "white" : "transparent", + border: isSelected ? "2px solid #2563EB" : "none", + borderRadius: "50%", + left: "-6px", + bottom: "-6px", + cursor: "nesw-resize", + }, + bottomRight: { + width: "12px", + height: "12px", + backgroundColor: isSelected ? "white" : "transparent", + border: isSelected ? "2px solid #2563EB" : "none", + borderRadius: "50%", + right: "-6px", + bottom: "-6px", + cursor: "nwse-resize", + }, + }} + > + <div + className={cn( + "w-full h-full rounded-lg", + annotation.type === "text" && "bg-transparent", + annotation.type === "image" && "bg-transparent", + annotation.type === "figure" && "bg-transparent", + isSelected && "shadow-lg", + )} + > + {renderContent()} + </div> + </Rnd> + ); +} diff --git a/src/components/video-editor/AnnotationSettingsPanel.tsx b/src/components/video-editor/AnnotationSettingsPanel.tsx new file mode 100644 index 000000000..af5326320 --- /dev/null +++ b/src/components/video-editor/AnnotationSettingsPanel.tsx @@ -0,0 +1,818 @@ +import { + AlignCenterHorizontal as AlignCenter, + AlignLeft, + AlignRight, + TextB as Bold, + CaretDown as ChevronDown, + ImageSquare as ImageIcon, + Info, + TextItalic as Italic, + BoundingBox as SquareDashed, + Trash as Trash2, + TextT as Type, + TextUnderline as Underline, + UploadSimple as Upload, +} from "@phosphor-icons/react"; +import Block from "@uiw/react-color-block"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Slider } from "@/components/ui/slider"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; +import { type CustomFont, getCustomFonts } from "@/lib/customFonts"; +import { cn } from "@/lib/utils"; +import { useScopedT } from "../../contexts/I18nContext"; +import { AddCustomFontDialog } from "./AddCustomFontDialog"; +import { getArrowComponent } from "./ArrowSvgs"; +import type { AnnotationRegion, AnnotationType, ArrowDirection, FigureData } from "./types"; + +interface AnnotationSettingsPanelProps { + annotation: AnnotationRegion; + onContentChange: (content: string) => void; + onTypeChange: (type: AnnotationType) => void; + onStyleChange: (style: Partial<AnnotationRegion["style"]>) => void; + onFigureDataChange?: (figureData: FigureData) => void; + onBlurIntensityChange?: (intensity: number) => void; + onBlurColorChange?: (color: string) => void; + onDelete: () => void; +} + +export const FONT_FAMILY_VALUES = [ + { value: "system-ui, -apple-system, sans-serif", labelKey: "fontStyles.classic" }, + { value: "Georgia, serif", labelKey: "fontStyles.editor" }, + { value: "Impact, Arial Black, sans-serif", labelKey: "fontStyles.strong" }, + { value: "Courier New, monospace", labelKey: "fontStyles.typewriter" }, + { value: "Brush Script MT, cursive", labelKey: "fontStyles.deco" }, + { value: "Arial, sans-serif", labelKey: "fontStyles.simple" }, + { value: "Verdana, sans-serif", labelKey: "fontStyles.modern" }, + { value: "Trebuchet MS, sans-serif", labelKey: "fontStyles.clean" }, +]; + +export const FONT_SIZES = [12, 14, 16, 18, 20, 24, 28, 32, 36, 40, 48, 56, 64, 72, 80, 96, 128]; + +export function AnnotationSettingsPanel({ + annotation, + onContentChange, + onTypeChange, + onStyleChange, + onFigureDataChange, + onBlurIntensityChange, + onBlurColorChange, + onDelete, +}: AnnotationSettingsPanelProps) { + const t = useScopedT("editor"); + const fileInputRef = useRef<HTMLInputElement>(null); + const [customFonts, setCustomFonts] = useState<CustomFont[]>([]); + + const fontFamilies = useMemo( + () => FONT_FAMILY_VALUES.map((f) => ({ value: f.value, label: t(f.labelKey) })), + [t], + ); + + // Load custom fonts on mount + useEffect(() => { + setCustomFonts(getCustomFonts()); + }, []); + + const colorPalette = [ + "#FF0000", // Red + "#FFD700", // Yellow/Gold + "#00FF00", // Green + "#FFFFFF", // White + "#0000FF", // Blue + "#FF6B00", // Orange + "#9B59B6", // Purple + "#E91E63", // Pink + "#00BCD4", // Cyan + "#FF5722", // Deep Orange + "#8BC34A", // Light Green + "#FFC107", // Amber + "#2563EB", // Brand Blue + "#000000", // Black + "#607D8B", // Blue Grey + "#795548", // Brown + ]; + + const handleImageUpload = (event: React.ChangeEvent<HTMLInputElement>) => { + const files = event.target.files; + if (!files || files.length === 0) return; + + const file = files[0]; + + // Validate file type + const validTypes = ["image/jpeg", "image/jpg", "image/png", "image/gif", "image/webp"]; + if (!validTypes.includes(file.type)) { + toast.error(t("annotations.imageUploadError"), { + description: t("annotations.imageUploadErrorDescription"), + }); + event.target.value = ""; + return; + } + + const reader = new FileReader(); + + reader.onload = (e) => { + const dataUrl = e.target?.result as string; + if (dataUrl) { + onContentChange(dataUrl); + toast.success(t("annotations.imageUploadSuccess")); + } + }; + + reader.onerror = () => { + toast.error(t("annotations.imageUploadFailed"), { + description: t("annotations.imageUploadFailedDescription"), + }); + }; + + reader.readAsDataURL(file); + event.target.value = ""; + }; + + return ( + <div className="flex-[2] min-w-0 bg-editor-panel border border-foreground/10 rounded-2xl flex flex-col shadow-xl h-full overflow-hidden"> + <div className="flex-1 min-h-0 p-4 overflow-y-auto custom-scrollbar"> + <div className="mb-6"> + <div className="flex items-center justify-between mb-4"> + <span className="text-sm font-medium text-foreground"> + {t("annotations.settings")} + </span> + <span className="text-[10px] uppercase tracking-wider font-medium text-[#2563EB] bg-[#2563EB]/10 px-2 py-1 rounded-full"> + {t("annotations.active")} + </span> + </div> + + {/* Type Selector */} + <Tabs + value={annotation.type} + onValueChange={(value) => onTypeChange(value as AnnotationType)} + className="mb-6" + > + <TabsList className="mb-4 bg-foreground/5 border border-foreground/5 p-1 w-full grid grid-cols-4 h-auto rounded-xl"> + <TabsTrigger + value="text" + className="data-[state=active]:bg-[#2563EB] data-[state=active]:text-white text-muted-foreground py-2 rounded-lg transition-all gap-2" + > + <Type className="w-4 h-4" /> + {t("annotations.text")} + </TabsTrigger> + <TabsTrigger + value="image" + className="data-[state=active]:bg-[#2563EB] data-[state=active]:text-white text-muted-foreground py-2 rounded-lg transition-all gap-2" + > + <ImageIcon className="w-4 h-4" /> + {t("annotations.image")} + </TabsTrigger> + <TabsTrigger + value="figure" + className="data-[state=active]:bg-[#2563EB] data-[state=active]:text-white text-muted-foreground py-2 rounded-lg transition-all gap-2" + > + <svg + className="w-4 h-4" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + strokeWidth="2" + > + <path + d="M4 12h16m0 0l-6-6m6 6l-6 6" + strokeLinecap="round" + strokeLinejoin="round" + /> + </svg> + {t("annotations.arrow")} + </TabsTrigger> + <TabsTrigger + value="blur" + className="data-[state=active]:bg-[#2563EB] data-[state=active]:text-white text-muted-foreground py-2 rounded-lg transition-all gap-2" + > + <SquareDashed className="w-4 h-4" /> + {t("annotations.blur")} + </TabsTrigger> + </TabsList> + + {/* Text Content */} + <TabsContent value="text" className="mt-0 space-y-4"> + <div> + <label className="text-xs font-medium text-foreground mb-2 block"> + {t("annotations.textContent")} + </label> + <textarea + value={annotation.textContent || annotation.content} + onChange={(e) => onContentChange(e.target.value)} + placeholder={t("annotations.textPlaceholder")} + rows={5} + className="w-full px-3 py-2 bg-foreground/5 border border-foreground/10 rounded-lg text-foreground text-sm placeholder:text-muted-foreground/70 focus:outline-none focus:ring-2 focus:ring-[#2563EB] focus:border-transparent resize-none" + /> + </div> + + {/* Styling Controls */} + <div className="space-y-4"> + {/* Font Family & Size */} + <div className="grid grid-cols-2 gap-2"> + <div> + <label className="text-xs font-medium text-foreground mb-2 block"> + {t("annotations.fontStyle")} + </label> + <Select + value={annotation.style.fontFamily} + onValueChange={(value) => + onStyleChange({ fontFamily: value }) + } + > + <SelectTrigger className="w-full bg-foreground/5 border-foreground/10 text-foreground h-9 text-xs"> + <SelectValue + placeholder={t("annotations.selectStyle")} + /> + </SelectTrigger> + <SelectContent className="bg-editor-surface-alt border-foreground/10 text-foreground max-h-[300px]"> + {fontFamilies.map((font) => ( + <SelectItem + key={font.value} + value={font.value} + style={{ fontFamily: font.value }} + > + {font.label} + </SelectItem> + ))} + {customFonts.length > 0 && ( + <> + <div className="px-2 py-1.5 text-[10px] font-medium text-muted-foreground uppercase tracking-wider"> + Custom Fonts + </div> + {customFonts.map((font) => ( + <SelectItem + key={font.id} + value={font.fontFamily} + style={{ + fontFamily: font.fontFamily, + }} + > + {font.name} + </SelectItem> + ))} + </> + )} + </SelectContent> + </Select> + </div> + <div> + <label className="text-xs font-medium text-foreground mb-2 block"> + {t("annotations.size")} + </label> + <Select + value={annotation.style.fontSize.toString()} + onValueChange={(value) => + onStyleChange({ fontSize: parseInt(value) }) + } + > + <SelectTrigger className="w-full bg-foreground/5 border-foreground/10 text-foreground h-9 text-xs"> + <SelectValue placeholder={t("annotations.size")} /> + </SelectTrigger> + <SelectContent className="bg-editor-surface-alt border-foreground/10 text-foreground max-h-[200px]"> + {FONT_SIZES.map((size) => ( + <SelectItem key={size} value={size.toString()}> + {size}px + </SelectItem> + ))} + </SelectContent> + </Select> + </div> + </div> + + {/* Add Custom Font Button */} + <div> + <AddCustomFontDialog + onFontAdded={(font) => { + setCustomFonts(getCustomFonts()); + onStyleChange({ fontFamily: font.fontFamily }); + }} + /> + </div> + + {/* Formatting Toggles */} + <div className="flex items-center justify-between gap-2"> + <ToggleGroup + type="multiple" + className="justify-start bg-foreground/5 p-1 rounded-lg border border-foreground/5" + > + <ToggleGroupItem + value="bold" + aria-label={t("annotations.toggleBold")} + data-state={ + annotation.style.fontWeight === "bold" + ? "on" + : "off" + } + onClick={() => + onStyleChange({ + fontWeight: + annotation.style.fontWeight === "bold" + ? "normal" + : "bold", + }) + } + className="h-8 w-8 data-[state=on]:bg-[#2563EB] data-[state=on]:text-white text-muted-foreground hover:bg-foreground/5 hover:text-foreground" + > + <Bold className="h-4 w-4" /> + </ToggleGroupItem> + <ToggleGroupItem + value="italic" + aria-label={t("annotations.toggleItalic")} + data-state={ + annotation.style.fontStyle === "italic" + ? "on" + : "off" + } + onClick={() => + onStyleChange({ + fontStyle: + annotation.style.fontStyle === "italic" + ? "normal" + : "italic", + }) + } + className="h-8 w-8 data-[state=on]:bg-[#2563EB] data-[state=on]:text-white text-muted-foreground hover:bg-foreground/5 hover:text-foreground" + > + <Italic className="h-4 w-4" /> + </ToggleGroupItem> + <ToggleGroupItem + value="underline" + aria-label={t("annotations.toggleUnderline")} + data-state={ + annotation.style.textDecoration === "underline" + ? "on" + : "off" + } + onClick={() => + onStyleChange({ + textDecoration: + annotation.style.textDecoration === + "underline" + ? "none" + : "underline", + }) + } + className="h-8 w-8 data-[state=on]:bg-[#2563EB] data-[state=on]:text-white text-muted-foreground hover:bg-foreground/5 hover:text-foreground" + > + <Underline className="h-4 w-4" /> + </ToggleGroupItem> + </ToggleGroup> + + <ToggleGroup + type="single" + value={annotation.style.textAlign} + className="justify-start bg-foreground/5 p-1 rounded-lg border border-foreground/5" + > + <ToggleGroupItem + value="left" + aria-label={t("annotations.alignLeft")} + onClick={() => onStyleChange({ textAlign: "left" })} + className="h-8 w-8 data-[state=on]:bg-[#2563EB] data-[state=on]:text-white text-muted-foreground hover:bg-foreground/5 hover:text-foreground" + > + <AlignLeft className="h-4 w-4" /> + </ToggleGroupItem> + <ToggleGroupItem + value="center" + aria-label={t("annotations.alignCenter")} + onClick={() => onStyleChange({ textAlign: "center" })} + className="h-8 w-8 data-[state=on]:bg-[#2563EB] data-[state=on]:text-white text-muted-foreground hover:bg-foreground/5 hover:text-foreground" + > + <AlignCenter className="h-4 w-4" /> + </ToggleGroupItem> + <ToggleGroupItem + value="right" + aria-label={t("annotations.alignRight")} + onClick={() => onStyleChange({ textAlign: "right" })} + className="h-8 w-8 data-[state=on]:bg-[#2563EB] data-[state=on]:text-white text-muted-foreground hover:bg-foreground/5 hover:text-foreground" + > + <AlignRight className="h-4 w-4" /> + </ToggleGroupItem> + </ToggleGroup> + </div> + + {/* Colors */} + <div className="grid grid-cols-2 gap-4"> + <div> + <label className="text-xs font-medium text-foreground mb-2 block"> + {t("annotations.textColor")} + </label> + <Popover> + <PopoverTrigger asChild> + <Button + variant="outline" + className="w-full h-9 justify-start gap-2 bg-foreground/5 border-foreground/10 hover:bg-foreground/10 px-2" + > + <div + className="w-4 h-4 rounded-full border border-foreground/20" + style={{ + backgroundColor: annotation.style.color, + }} + /> + <span className="text-xs text-muted-foreground truncate flex-1 text-left"> + {annotation.style.color} + </span> + <ChevronDown className="h-3 w-3 opacity-50" /> + </Button> + </PopoverTrigger> + <PopoverContent className="w-[260px] p-3 bg-editor-surface-alt border border-foreground/10 rounded-xl shadow-xl"> + <Block + color={annotation.style.color} + colors={colorPalette} + onChange={(color) => { + onStyleChange({ color: color.hex }); + }} + style={{ + borderRadius: "8px", + }} + /> + </PopoverContent> + </Popover> + </div> + <div> + <label className="text-xs font-medium text-foreground mb-2 block"> + {t("annotations.background")} + </label> + <Popover> + <PopoverTrigger asChild> + <Button + variant="outline" + className="w-full h-9 justify-start gap-2 bg-foreground/5 border-foreground/10 hover:bg-foreground/10 px-2" + > + <div className="w-4 h-4 rounded-full border border-foreground/20 relative overflow-hidden"> + <div className="absolute inset-0 checkerboard-bg opacity-50" /> + <div + className="absolute inset-0" + style={{ + backgroundColor: + annotation.style + .backgroundColor, + }} + /> + </div> + <span className="text-xs text-muted-foreground truncate flex-1 text-left"> + {annotation.style.backgroundColor === + "transparent" + ? t("annotations.none") + : "Color"} + </span> + <ChevronDown className="h-3 w-3 opacity-50" /> + </Button> + </PopoverTrigger> + <PopoverContent className="w-[260px] p-3 bg-editor-surface-alt border border-foreground/10 rounded-xl shadow-xl"> + <Block + color={ + annotation.style.backgroundColor === + "transparent" + ? "#000000" + : annotation.style.backgroundColor + } + colors={colorPalette} + onChange={(color) => { + onStyleChange({ + backgroundColor: color.hex, + }); + }} + style={{ + borderRadius: "8px", + }} + /> + <Button + variant="ghost" + size="sm" + className="w-full mt-2 text-xs h-7 hover:bg-foreground/5 text-muted-foreground" + onClick={() => { + onStyleChange({ + backgroundColor: "transparent", + }); + }} + > + {t("annotations.clearBackground")} + </Button> + </PopoverContent> + </Popover> + </div> + </div> + </div> + </TabsContent> + + {/* Image Upload */} + <TabsContent value="image" className="mt-0 space-y-4"> + <input + type="file" + ref={fileInputRef} + onChange={handleImageUpload} + accept=".jpg,.jpeg,.png,.gif,.webp,image/*" + className="hidden" + /> + <Button + onClick={() => fileInputRef.current?.click()} + variant="outline" + className="w-full gap-2 bg-foreground/5 text-foreground border-foreground/10 hover:bg-[#2563EB] hover:text-white hover:border-[#2563EB] transition-all py-8" + > + <Upload className="w-5 h-5" /> + {t("annotations.uploadImage")} + </Button> + + {annotation.content && annotation.content.startsWith("data:image") && ( + <div className="rounded-lg border border-foreground/10 overflow-hidden bg-foreground/5 p-2"> + <img + src={annotation.content} + alt="Uploaded annotation" + className="w-full h-auto rounded-md" + /> + </div> + )} + + <p className="text-xs text-muted-foreground/70 text-center leading-relaxed"> + {t("annotations.supportedFormats")} + </p> + </TabsContent> + + <TabsContent value="figure" className="mt-0 space-y-4"> + <div> + <label className="text-xs font-medium text-foreground mb-3 block"> + {t("annotations.arrowDirection")} + </label> + <div className="grid grid-cols-4 gap-2"> + {( + [ + "up", + "down", + "left", + "right", + "up-right", + "up-left", + "down-right", + "down-left", + ] as ArrowDirection[] + ).map((direction) => { + const ArrowComponent = getArrowComponent(direction); + return ( + <button + key={direction} + onClick={() => { + const newFigureData: FigureData = { + ...annotation.figureData!, + arrowDirection: direction, + }; + onFigureDataChange?.(newFigureData); + }} + aria-label={t( + "annotations.arrowDirectionOption", + "Arrow direction: {{direction}}", + { direction: direction.replace(/-/g, " ") }, + )} + className={cn( + "h-16 rounded-lg border flex items-center justify-center transition-all p-2", + annotation.figureData?.arrowDirection === + direction + ? "bg-[#2563EB] border-[#2563EB]" + : "bg-foreground/5 border-foreground/10 hover:bg-foreground/10 hover:border-foreground/20", + )} + > + <ArrowComponent + color={ + annotation.figureData?.arrowDirection === + direction + ? "#ffffff" + : "#94a3b8" + } + strokeWidth={3} + /> + </button> + ); + })} + </div> + </div> + + <div> + <label className="text-xs font-medium text-foreground mb-2 block"> + {t("annotations.strokeWidth", undefined, { + width: annotation.figureData?.strokeWidth || 4, + })} + </label> + <Slider + value={[annotation.figureData?.strokeWidth || 4]} + onValueChange={([value]) => { + const newFigureData: FigureData = { + ...annotation.figureData!, + strokeWidth: value, + }; + onFigureDataChange?.(newFigureData); + }} + min={1} + max={6} + step={1} + className="w-full" + /> + </div> + + <div> + <label className="text-xs font-medium text-foreground mb-2 block"> + {t("annotations.arrowColor")} + </label> + <Popover> + <PopoverTrigger asChild> + <Button + variant="outline" + className="w-full h-10 justify-start gap-2 bg-foreground/5 border-foreground/10 hover:bg-foreground/10" + > + <div + className="w-5 h-5 rounded-full border border-foreground/20" + style={{ + backgroundColor: + annotation.figureData?.color || "#2563EB", + }} + /> + <span className="text-xs text-muted-foreground truncate flex-1 text-left"> + {annotation.figureData?.color || "#2563EB"} + </span> + <ChevronDown className="h-3 w-3 opacity-50" /> + </Button> + </PopoverTrigger> + <PopoverContent className="w-[260px] p-3 bg-editor-surface-alt border border-foreground/10 rounded-xl shadow-xl"> + <Block + color={annotation.figureData?.color || "#2563EB"} + colors={colorPalette} + onChange={(color) => { + const newFigureData: FigureData = { + ...annotation.figureData!, + color: color.hex, + }; + onFigureDataChange?.(newFigureData); + }} + style={{ + borderRadius: "8px", + }} + /> + </PopoverContent> + </Popover> + </div> + </TabsContent> + + <TabsContent value="blur" className="mt-0 space-y-4"> + <div className="p-4 bg-foreground/5 rounded-xl border border-foreground/10 flex flex-col items-center"> + <div className="w-full space-y-3"> + <div className="flex items-center justify-between"> + <span className="text-xs font-medium text-foreground"> + {t("annotations.blurStrength", undefined, { + strength: annotation.blurIntensity ?? 20, + })} + </span> + </div> + <Slider + value={[annotation.blurIntensity ?? 20]} + onValueChange={([value]) => onBlurIntensityChange?.(value)} + min={1} + max={100} + step={1} + className="w-full" + /> + </div> + + <div className="w-full space-y-3 mt-4"> + <div className="flex items-center justify-between"> + <span className="text-xs font-medium text-foreground"> + {t( + "annotations.solidColor", + "Solid Color (Censorship)", + )} + </span> + </div> + <div className="flex flex-wrap gap-2"> + <button + onClick={() => onBlurColorChange?.("")} + className={cn( + "w-8 h-8 rounded-full border-2 flex items-center justify-center transition-all", + !annotation.blurColor || + annotation.blurColor === "transparent" + ? "border-[#2563EB] scale-110" + : "border-transparent hover:border-foreground/20", + )} + title={t("annotations.none", "None")} + > + <div className="w-5 h-5 rounded-full bg-editor-bg flex items-center justify-center overflow-hidden relative"> + <div className="absolute w-full h-0.5 bg-red-500 rotate-45" /> + </div> + </button> + <button + onClick={() => onBlurColorChange?.("#000000")} + className={cn( + "w-8 h-8 rounded-full border-2 transition-all bg-black", + annotation.blurColor === "#000000" + ? "border-[#2563EB] scale-110" + : "border-transparent hover:border-foreground/20", + )} + title="Black" + /> + <button + onClick={() => onBlurColorChange?.("#FFFFFF")} + className={cn( + "w-8 h-8 rounded-full border-2 transition-all bg-white", + annotation.blurColor === "#FFFFFF" + ? "border-[#2563EB] scale-110" + : "border-transparent hover:border-foreground/20", + )} + title="White" + /> + + <Popover> + <PopoverTrigger asChild> + <button + className={cn( + "w-8 h-8 rounded-full border-2 transition-all flex items-center justify-center overflow-hidden relative", + annotation.blurColor && + ![ + "#000000", + "#FFFFFF", + "transparent", + "", + ].includes(annotation.blurColor) + ? "border-[#2563EB] scale-110" + : "border-transparent hover:border-foreground/20", + )} + style={{ + backgroundColor: + annotation.blurColor && + ![ + "#000000", + "#FFFFFF", + "transparent", + "", + ].includes(annotation.blurColor) + ? annotation.blurColor + : "transparent", + }} + title="Custom Color" + > + {(!annotation.blurColor || + [ + "#000000", + "#FFFFFF", + "transparent", + "", + ].includes(annotation.blurColor)) && ( + <div className="w-full h-full flex items-center justify-center bg-foreground/5"> + <div className="w-full h-full bg-gradient-to-tr from-red-500 via-green-500 to-blue-500 opacity-50" /> + </div> + )} + </button> + </PopoverTrigger> + <PopoverContent className="w-[260px] p-3 bg-editor-surface-alt border border-foreground/10 rounded-xl shadow-xl"> + <Block + color={annotation.blurColor || "#2563EB"} + colors={colorPalette} + onChange={(color) => { + onBlurColorChange?.(color.hex); + }} + style={{ + borderRadius: "8px", + }} + /> + </PopoverContent> + </Popover> + </div> + </div> + </div> + </TabsContent> + </Tabs> + + <div className="mt-6 p-3 bg-foreground/5 rounded-lg border border-foreground/5"> + <div className="flex items-center gap-2 mb-2 text-muted-foreground"> + <Info className="w-3.5 h-3.5" /> + <span className="text-xs font-medium"> + {t("annotations.shortcutsAndTips")} + </span> + </div> + <ul className="text-[10px] text-muted-foreground space-y-1.5 list-disc pl-3 leading-relaxed"> + <li>{t("annotations.tipSelectAnnotation")}</li> + <li>{t("annotations.tipCycleForward")}</li> + <li>{t("annotations.tipCycleBackward")}</li> + </ul> + </div> + </div> + </div> + <div className="flex-shrink-0 border-t border-foreground/10 bg-editor-panel p-4 pt-3"> + <Button + onClick={onDelete} + variant="destructive" + size="sm" + className="w-full gap-2 bg-red-500/10 text-red-400 border border-red-500/20 hover:bg-red-500/20 hover:border-red-500/30 transition-all" + > + <Trash2 className="w-4 h-4" /> + {t("annotations.deleteAnnotation")} + </Button> + </div> + </div> + ); +} diff --git a/src/components/video-editor/ArrowSvgs.tsx b/src/components/video-editor/ArrowSvgs.tsx new file mode 100644 index 000000000..99c542ff7 --- /dev/null +++ b/src/components/video-editor/ArrowSvgs.tsx @@ -0,0 +1,202 @@ +import type { ArrowDirection } from "./types"; + +interface ArrowSvgProps { + color: string; + strokeWidth: number; + className?: string; +} + +/** + * Inline SVG arrow components for 8 directions. + * These match the visual style of the previous icon-based arrows but use + * pure SVG paths for easy replication in export. + */ + +export function ArrowUp({ color, strokeWidth, className }: ArrowSvgProps) { + return ( + <svg viewBox="0 0 100 100" className={className} style={{ width: "100%", height: "100%" }}> + <defs> + <filter id="arrow-shadow"> + <feDropShadow dx="0" dy="2" stdDeviation="4" floodOpacity="0.3" /> + </filter> + </defs> + <path + d="M 50 20 L 50 80 M 50 20 L 35 35 M 50 20 L 65 35" + stroke={color} + strokeWidth={strokeWidth} + strokeLinecap="round" + strokeLinejoin="round" + fill="none" + filter="url(#arrow-shadow)" + /> + </svg> + ); +} + +export function ArrowDown({ color, strokeWidth, className }: ArrowSvgProps) { + return ( + <svg viewBox="0 0 100 100" className={className} style={{ width: "100%", height: "100%" }}> + <defs> + <filter id="arrow-shadow"> + <feDropShadow dx="0" dy="2" stdDeviation="4" floodOpacity="0.3" /> + </filter> + </defs> + <path + d="M 50 20 L 50 80 M 50 80 L 35 65 M 50 80 L 65 65" + stroke={color} + strokeWidth={strokeWidth} + strokeLinecap="round" + strokeLinejoin="round" + fill="none" + filter="url(#arrow-shadow)" + /> + </svg> + ); +} + +export function ArrowLeft({ color, strokeWidth, className }: ArrowSvgProps) { + return ( + <svg viewBox="0 0 100 100" className={className} style={{ width: "100%", height: "100%" }}> + <defs> + <filter id="arrow-shadow"> + <feDropShadow dx="0" dy="2" stdDeviation="4" floodOpacity="0.3" /> + </filter> + </defs> + <path + d="M 80 50 L 20 50 M 20 50 L 35 35 M 20 50 L 35 65" + stroke={color} + strokeWidth={strokeWidth} + strokeLinecap="round" + strokeLinejoin="round" + fill="none" + filter="url(#arrow-shadow)" + /> + </svg> + ); +} + +export function ArrowRight({ color, strokeWidth, className }: ArrowSvgProps) { + return ( + <svg viewBox="0 0 100 100" className={className} style={{ width: "100%", height: "100%" }}> + <defs> + <filter id="arrow-shadow"> + <feDropShadow dx="0" dy="2" stdDeviation="4" floodOpacity="0.3" /> + </filter> + </defs> + <path + d="M 20 50 L 80 50 M 80 50 L 65 35 M 80 50 L 65 65" + stroke={color} + strokeWidth={strokeWidth} + strokeLinecap="round" + strokeLinejoin="round" + fill="none" + filter="url(#arrow-shadow)" + /> + </svg> + ); +} + +export function ArrowUpRight({ color, strokeWidth, className }: ArrowSvgProps) { + return ( + <svg viewBox="0 0 100 100" className={className} style={{ width: "100%", height: "100%" }}> + <defs> + <filter id="arrow-shadow"> + <feDropShadow dx="0" dy="2" stdDeviation="4" floodOpacity="0.3" /> + </filter> + </defs> + <path + d="M 25 75 L 75 25 M 75 25 L 60 30 M 75 25 L 70 40" + stroke={color} + strokeWidth={strokeWidth} + strokeLinecap="round" + strokeLinejoin="round" + fill="none" + filter="url(#arrow-shadow)" + /> + </svg> + ); +} + +export function ArrowUpLeft({ color, strokeWidth, className }: ArrowSvgProps) { + return ( + <svg viewBox="0 0 100 100" className={className} style={{ width: "100%", height: "100%" }}> + <defs> + <filter id="arrow-shadow"> + <feDropShadow dx="0" dy="2" stdDeviation="4" floodOpacity="0.3" /> + </filter> + </defs> + <path + d="M 75 75 L 25 25 M 25 25 L 40 30 M 25 25 L 30 40" + stroke={color} + strokeWidth={strokeWidth} + strokeLinecap="round" + strokeLinejoin="round" + fill="none" + filter="url(#arrow-shadow)" + /> + </svg> + ); +} + +export function ArrowDownRight({ color, strokeWidth, className }: ArrowSvgProps) { + return ( + <svg viewBox="0 0 100 100" className={className} style={{ width: "100%", height: "100%" }}> + <defs> + <filter id="arrow-shadow"> + <feDropShadow dx="0" dy="2" stdDeviation="4" floodOpacity="0.3" /> + </filter> + </defs> + <path + d="M 25 25 L 75 75 M 75 75 L 70 60 M 75 75 L 60 70" + stroke={color} + strokeWidth={strokeWidth} + strokeLinecap="round" + strokeLinejoin="round" + fill="none" + filter="url(#arrow-shadow)" + /> + </svg> + ); +} + +export function ArrowDownLeft({ color, strokeWidth, className }: ArrowSvgProps) { + return ( + <svg viewBox="0 0 100 100" className={className} style={{ width: "100%", height: "100%" }}> + <defs> + <filter id="arrow-shadow"> + <feDropShadow dx="0" dy="2" stdDeviation="4" floodOpacity="0.3" /> + </filter> + </defs> + <path + d="M 75 25 L 25 75 M 25 75 L 30 60 M 25 75 L 40 70" + stroke={color} + strokeWidth={strokeWidth} + strokeLinecap="round" + strokeLinejoin="round" + fill="none" + filter="url(#arrow-shadow)" + /> + </svg> + ); +} + +export function getArrowComponent(direction: ArrowDirection) { + switch (direction) { + case "up": + return ArrowUp; + case "down": + return ArrowDown; + case "left": + return ArrowLeft; + case "right": + return ArrowRight; + case "up-right": + return ArrowUpRight; + case "up-left": + return ArrowUpLeft; + case "down-right": + return ArrowDownRight; + case "down-left": + return ArrowDownLeft; + } +} diff --git a/src/components/video-editor/CaptionListPanel.tsx b/src/components/video-editor/CaptionListPanel.tsx new file mode 100644 index 000000000..ef04e5e7d --- /dev/null +++ b/src/components/video-editor/CaptionListPanel.tsx @@ -0,0 +1,245 @@ +import { ArrowsMerge, Scissors, Trash } from "@phosphor-icons/react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { useScopedT } from "@/contexts/I18nContext"; +import type { CaptionRetimeSpan } from "./captionOps"; +import type { CaptionCue } from "./types"; + +interface CaptionListPanelProps { + cues: CaptionCue[]; + selectedCaptionId: string | null; + currentTimeMs: number; + onBeginCaptionEdit: (id: string) => void; + onCaptionTextEdit: (id: string, text: string) => void; + onCaptionRetime: (id: string, span: CaptionRetimeSpan) => void; + onCaptionSplit: (id: string, atMs: number) => void; + onCaptionMerge: (idA: string, idB: string) => void; + onCaptionDelete: (id: string) => void; +} + +function clampNumber(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)); +} + +function formatTimecode(ms: number): string { + const safeMs = Math.max(0, Math.round(ms)); + const minutes = Math.floor(safeMs / 60_000); + const seconds = Math.floor((safeMs % 60_000) / 1_000); + const millis = safeMs % 1_000; + return `${minutes}:${String(seconds).padStart(2, "0")}.${String(millis).padStart(3, "0")}`; +} + +function parseTimecode(value: string): number | null { + const match = value.trim().match(/^(?:(\d+):)?(\d{1,2})(?:\.(\d{1,3}))?$/); + if (!match) { + return null; + } + const minutes = match[1] ? Number.parseInt(match[1], 10) : 0; + const seconds = Number.parseInt(match[2], 10); + const millis = match[3] ? Number.parseInt(match[3].padEnd(3, "0"), 10) : 0; + return (minutes * 60 + seconds) * 1_000 + millis; +} + +interface CaptionEditorProps { + cue: CaptionCue; + canMerge: boolean; + currentTimeMs: number; + onBeginEdit: (id: string) => void; + onTextEdit: (id: string, text: string) => void; + onRetime: (id: string, span: CaptionRetimeSpan) => void; + onSplit: (id: string, atMs: number) => void; + onMerge: (id: string) => void; + onDelete: (id: string) => void; +} + +function CaptionEditor({ + cue, + canMerge, + currentTimeMs, + onBeginEdit, + onTextEdit, + onRetime, + onSplit, + onMerge, + onDelete, +}: CaptionEditorProps) { + const t = useScopedT("settings"); + const [draftText, setDraftText] = useState(cue.text); + const [startValue, setStartValue] = useState(formatTimecode(cue.startMs)); + const [endValue, setEndValue] = useState(formatTimecode(cue.endMs)); + // Escape resets the draft and blurs, but `setDraftText` is batched so the blur-driven + // `commitText` would still see the stale (edited) draft and save it. This flag lets the + // cancel path tell the next blur to discard instead of commit. + const cancelNextCommitRef = useRef(false); + + useEffect(() => { + setDraftText(cue.text); + setStartValue(formatTimecode(cue.startMs)); + setEndValue(formatTimecode(cue.endMs)); + }, [cue.text, cue.startMs, cue.endMs]); + + const commitText = useCallback(() => { + if (cancelNextCommitRef.current) { + cancelNextCommitRef.current = false; + setDraftText(cue.text); + return; + } + const normalized = draftText.trim(); + if (normalized && normalized !== cue.text) { + onTextEdit(cue.id, normalized); + } else { + setDraftText(cue.text); + } + }, [cue.id, cue.text, draftText, onTextEdit]); + + const commitTiming = useCallback(() => { + const parsedStart = parseTimecode(startValue); + const parsedEnd = parseTimecode(endValue); + if (parsedStart === null || parsedEnd === null || parsedEnd <= parsedStart) { + setStartValue(formatTimecode(cue.startMs)); + setEndValue(formatTimecode(cue.endMs)); + return; + } + if (parsedStart !== cue.startMs || parsedEnd !== cue.endMs) { + onRetime(cue.id, { startMs: parsedStart, endMs: parsedEnd }); + } + }, [cue.endMs, cue.id, cue.startMs, endValue, onRetime, startValue]); + + return ( + <div className="flex flex-col gap-3 rounded-lg bg-foreground/[0.03] px-2.5 py-2.5"> + <label className="flex flex-col gap-1"> + <span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground"> + {t("captions.editor.text", "Text")} + </span> + <textarea + value={draftText} + rows={3} + // Freshly-added captions start empty — focus the field so the + // user can type immediately after dropping one on the timeline. + autoFocus={cue.text === ""} + onFocus={() => onBeginEdit(cue.id)} + onChange={(event) => setDraftText(event.target.value)} + onBlur={commitText} + onKeyDown={(event) => { + if (event.key === "Enter" && !event.shiftKey) { + event.preventDefault(); + event.currentTarget.blur(); + } + if (event.key === "Escape") { + cancelNextCommitRef.current = true; + setDraftText(cue.text); + event.currentTarget.blur(); + } + }} + className="min-h-[4.5rem] w-full resize-none rounded-md border border-foreground/10 bg-background/60 px-2 py-1.5 text-sm text-foreground outline-none focus-visible:border-[#2563EB] focus-visible:ring-1 focus-visible:ring-[#2563EB]" + /> + </label> + + <div className="flex items-center gap-2"> + <label className="flex flex-1 flex-col gap-1"> + <span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground"> + {t("captions.editor.start", "Start")} + </span> + <input + value={startValue} + onChange={(event) => setStartValue(event.target.value)} + onBlur={commitTiming} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.currentTarget.blur(); + } + }} + className="w-full rounded-md border border-foreground/10 bg-background/60 px-2 py-1 font-mono text-xs tabular-nums text-foreground outline-none focus-visible:border-[#2563EB] focus-visible:ring-1 focus-visible:ring-[#2563EB]" + /> + </label> + <label className="flex flex-1 flex-col gap-1"> + <span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground"> + {t("captions.editor.end", "End")} + </span> + <input + value={endValue} + onChange={(event) => setEndValue(event.target.value)} + onBlur={commitTiming} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.currentTarget.blur(); + } + }} + className="w-full rounded-md border border-foreground/10 bg-background/60 px-2 py-1 font-mono text-xs tabular-nums text-foreground outline-none focus-visible:border-[#2563EB] focus-visible:ring-1 focus-visible:ring-[#2563EB]" + /> + </label> + </div> + + <div className="grid grid-cols-3 gap-2"> + <button + type="button" + onClick={() => + onSplit(cue.id, clampNumber(currentTimeMs, cue.startMs, cue.endMs)) + } + className="flex h-9 items-center justify-center gap-1.5 rounded-lg border border-foreground/10 bg-foreground/5 text-xs font-medium text-foreground transition-colors hover:bg-foreground/10" + > + <Scissors className="h-4 w-4" /> + {t("captions.editor.split", "Split")} + </button> + <button + type="button" + disabled={!canMerge} + onClick={() => onMerge(cue.id)} + className="flex h-9 items-center justify-center gap-1.5 rounded-lg border border-foreground/10 bg-foreground/5 text-xs font-medium text-foreground transition-colors hover:bg-foreground/10 disabled:opacity-40" + > + <ArrowsMerge className="h-4 w-4" /> + {t("captions.editor.merge", "Merge")} + </button> + <Button + type="button" + variant="destructive" + size="sm" + onClick={() => onDelete(cue.id)} + className="h-9 gap-1.5 rounded-lg border border-red-500/20 bg-red-500/10 text-xs text-red-400 transition-all hover:border-red-500/30 hover:bg-red-500/20" + > + <Trash className="h-3 w-3" /> + {t("captions.editor.delete", "Delete")} + </Button> + </div> + </div> + ); +} + +export default function CaptionListPanel({ + cues, + selectedCaptionId, + currentTimeMs, + onBeginCaptionEdit, + onCaptionTextEdit, + onCaptionRetime, + onCaptionSplit, + onCaptionMerge, + onCaptionDelete, +}: CaptionListPanelProps) { + const index = cues.findIndex((cue) => cue.id === selectedCaptionId); + if (index < 0) { + return null; + } + + const cue = cues[index]; + const canMerge = index < cues.length - 1; + + return ( + <CaptionEditor + key={cue.id} + cue={cue} + canMerge={canMerge} + currentTimeMs={currentTimeMs} + onBeginEdit={onBeginCaptionEdit} + onTextEdit={onCaptionTextEdit} + onRetime={onCaptionRetime} + onSplit={onCaptionSplit} + onMerge={(id) => { + if (canMerge) { + onCaptionMerge(id, cues[index + 1].id); + } + }} + onDelete={onCaptionDelete} + /> + ); +} diff --git a/src/components/video-editor/CropControl.tsx b/src/components/video-editor/CropControl.tsx index 2dfb23534..0e00d8d9f 100644 --- a/src/components/video-editor/CropControl.tsx +++ b/src/components/video-editor/CropControl.tsx @@ -1,221 +1,275 @@ import { useEffect, useRef, useState } from "react"; import { cn } from "@/lib/utils"; +import { type AspectRatio } from "@/utils/aspectRatioUtils"; interface CropRegion { - x: number; // 0-1 normalized - y: number; // 0-1 normalized - width: number; // 0-1 normalized - height: number; // 0-1 normalized + x: number; // 0-1 normalized + y: number; // 0-1 normalized + width: number; // 0-1 normalized + height: number; // 0-1 normalized } interface CropControlProps { - videoElement: HTMLVideoElement | null; - cropRegion: CropRegion; - onCropChange: (region: CropRegion) => void; + videoElement: HTMLVideoElement | null; + cropRegion: CropRegion; + onCropChange: (region: CropRegion) => void; + aspectRatio: AspectRatio; } -type DragHandle = 'top' | 'right' | 'bottom' | 'left' | null; +type DragHandle = "top" | "right" | "bottom" | "left" | null; export function CropControl({ videoElement, cropRegion, onCropChange }: CropControlProps) { - const canvasRef = useRef<HTMLCanvasElement>(null); - const containerRef = useRef<HTMLDivElement>(null); - const [isDragging, setIsDragging] = useState<DragHandle>(null); - const [dragStart, setDragStart] = useState({ x: 0, y: 0 }); - const [initialCrop, setInitialCrop] = useState<CropRegion>(cropRegion); - - useEffect(() => { - if (!videoElement || !canvasRef.current) return; - - const canvas = canvasRef.current; - const ctx = canvas.getContext('2d', { alpha: false }); - if (!ctx) return; - - canvas.width = videoElement.videoWidth || 1920; - canvas.height = videoElement.videoHeight || 1080; - - const draw = () => { - if (videoElement.readyState >= 2) { - ctx.clearRect(0, 0, canvas.width, canvas.height); - ctx.drawImage(videoElement, 0, 0, canvas.width, canvas.height); - } - requestAnimationFrame(draw); - }; - - const rafId = requestAnimationFrame(draw); - return () => cancelAnimationFrame(rafId); - }, [videoElement]); - - const getContainerRect = () => { - return containerRef.current?.getBoundingClientRect() || { width: 0, height: 0, left: 0, top: 0 }; - }; - - const handlePointerDown = (e: React.PointerEvent, handle: DragHandle) => { - e.stopPropagation(); - e.preventDefault(); - setIsDragging(handle); - const rect = getContainerRect(); - setDragStart({ - x: (e.clientX - rect.left) / rect.width, - y: (e.clientY - rect.top) / rect.height, - }); - setInitialCrop(cropRegion); - - e.currentTarget.setPointerCapture(e.pointerId); - }; - - const handlePointerMove = (e: React.PointerEvent) => { - if (!isDragging) return; - - const rect = getContainerRect(); - const currentX = (e.clientX - rect.left) / rect.width; - const currentY = (e.clientY - rect.top) / rect.height; - const deltaX = currentX - dragStart.x; - const deltaY = currentY - dragStart.y; - - let newCrop = { ...initialCrop }; - - switch (isDragging) { - case 'top': { - const newY = Math.max(0, initialCrop.y + deltaY); - const bottom = initialCrop.y + initialCrop.height; - newCrop.y = Math.min(newY, bottom - 0.1); - newCrop.height = bottom - newCrop.y; - break; - } - case 'bottom': - newCrop.height = Math.max(0.1, Math.min(initialCrop.height + deltaY, 1 - initialCrop.y)); - break; - case 'left': { - const newX = Math.max(0, initialCrop.x + deltaX); - const right = initialCrop.x + initialCrop.width; - newCrop.x = Math.min(newX, right - 0.1); - newCrop.width = right - newCrop.x; - break; - } - case 'right': - newCrop.width = Math.max(0.1, Math.min(initialCrop.width + deltaX, 1 - initialCrop.x)); - break; - } - - onCropChange(newCrop); - }; - - const handlePointerUp = (e: React.PointerEvent) => { - if (isDragging) { - try { - e.currentTarget.releasePointerCapture(e.pointerId); - } catch { - } - } - setIsDragging(null); - }; - - const cropPixelX = cropRegion.x * 100; - const cropPixelY = cropRegion.y * 100; - const cropPixelWidth = cropRegion.width * 100; - const cropPixelHeight = cropRegion.height * 100; - - return ( - <div className="w-full p-8"> - <div - ref={containerRef} - className="relative w-full aspect-video bg-black rounded-lg overflow-visible cursor-default select-none shadow-2xl" - onPointerMove={handlePointerMove} - onPointerUp={handlePointerUp} - onPointerLeave={handlePointerUp} - > - <canvas - ref={canvasRef} - className="w-full h-full rounded-lg" - style={{ imageRendering: 'auto' }} - /> - - <div className="absolute inset-0 pointer-events-none" style={{ transition: 'none' }}> - <svg width="100%" height="100%" className="absolute inset-0" style={{ transition: 'none' }}> - <defs> - <mask id="cropMask"> - <rect width="100%" height="100%" fill="white" /> - <rect - x={`${cropPixelX}%`} - y={`${cropPixelY}%`} - width={`${cropPixelWidth}%`} - height={`${cropPixelHeight}%`} - fill="black" - style={{ transition: 'none' }} - /> - </mask> - </defs> - <rect - width="100%" - height="100%" - fill="black" - fillOpacity="0.6" - mask="url(#cropMask)" - style={{ transition: 'none' }} - /> - </svg> - </div> - - <div - className={cn( - "absolute h-[3px] cursor-ns-resize z-20 pointer-events-auto bg-green-500" - )} - style={{ - left: `${cropPixelX}%`, - top: `${cropPixelY}%`, - width: `${cropPixelWidth}%`, - transform: 'translateY(-50%)', - willChange: 'transform', - transition: 'none', - }} - onPointerDown={(e) => handlePointerDown(e, 'top')} - /> - - <div - className={cn( - "absolute h-[3px] cursor-ns-resize z-20 pointer-events-auto bg-green-500" - )} - style={{ - left: `${cropPixelX}%`, - top: `${cropPixelY + cropPixelHeight}%`, - width: `${cropPixelWidth}%`, - transform: 'translateY(-50%)', - willChange: 'transform', - transition: 'none', - }} - onPointerDown={(e) => handlePointerDown(e, 'bottom')} - /> - - <div - className={cn( - "absolute w-[3px] cursor-ew-resize z-20 pointer-events-auto bg-green-500" - )} - style={{ - left: `${cropPixelX}%`, - top: `${cropPixelY}%`, - height: `${cropPixelHeight}%`, - transform: 'translateX(-50%)', - willChange: 'transform', - transition: 'none', - }} - onPointerDown={(e) => handlePointerDown(e, 'left')} - /> - - <div - className={cn( - "absolute w-[3px] cursor-ew-resize z-20 pointer-events-auto bg-green-500" - )} - style={{ - left: `${cropPixelX + cropPixelWidth}%`, - top: `${cropPixelY}%`, - height: `${cropPixelHeight}%`, - transform: 'translateX(-50%)', - willChange: 'transform', - transition: 'none', - }} - onPointerDown={(e) => handlePointerDown(e, 'right')} - /> - </div> - </div> - ); + const canvasRef = useRef<HTMLCanvasElement>(null); + const containerRef = useRef<HTMLDivElement>(null); + const [isDragging, setIsDragging] = useState<DragHandle>(null); + const [dragStart, setDragStart] = useState({ x: 0, y: 0 }); + const [initialCrop, setInitialCrop] = useState<CropRegion>(cropRegion); + + useEffect(() => { + if (!videoElement || !canvasRef.current) return; + + const canvas = canvasRef.current; + const ctx = canvas.getContext("2d", { alpha: false }); + if (!ctx) return; + + canvas.width = videoElement.videoWidth || 1920; + canvas.height = videoElement.videoHeight || 1080; + + let animationFrameId = 0; + let isCancelled = false; + + const draw = () => { + if (isCancelled) { + return; + } + + if (videoElement.readyState >= 2) { + ctx.clearRect(0, 0, canvas.width, canvas.height); + ctx.drawImage(videoElement, 0, 0, canvas.width, canvas.height); + } + animationFrameId = requestAnimationFrame(draw); + }; + + animationFrameId = requestAnimationFrame(draw); + return () => { + isCancelled = true; + cancelAnimationFrame(animationFrameId); + }; + }, [videoElement]); + + const getContainerRect = () => { + return ( + containerRef.current?.getBoundingClientRect() || { + width: 0, + height: 0, + left: 0, + top: 0, + } + ); + }; + + const handlePointerDown = (e: React.PointerEvent, handle: DragHandle) => { + e.stopPropagation(); + e.preventDefault(); + const rect = getContainerRect(); + if (rect.width <= 0 || rect.height <= 0) { + return; + } + + setIsDragging(handle); + setDragStart({ + x: (e.clientX - rect.left) / rect.width, + y: (e.clientY - rect.top) / rect.height, + }); + setInitialCrop(cropRegion); + + e.currentTarget.setPointerCapture(e.pointerId); + }; + + const handlePointerMove = (e: React.PointerEvent) => { + if (!isDragging) return; + + const rect = getContainerRect(); + if (rect.width <= 0 || rect.height <= 0) { + return; + } + + const currentX = (e.clientX - rect.left) / rect.width; + const currentY = (e.clientY - rect.top) / rect.height; + const deltaX = currentX - dragStart.x; + const deltaY = currentY - dragStart.y; + + let newCrop = { ...initialCrop }; + + switch (isDragging) { + case "top": { + const newY = Math.max(0, initialCrop.y + deltaY); + const bottom = initialCrop.y + initialCrop.height; + newCrop.y = Math.min(newY, bottom - 0.1); + newCrop.height = bottom - newCrop.y; + break; + } + case "bottom": + newCrop.height = Math.max( + 0.1, + Math.min(initialCrop.height + deltaY, 1 - initialCrop.y), + ); + break; + case "left": { + const newX = Math.max(0, initialCrop.x + deltaX); + const right = initialCrop.x + initialCrop.width; + newCrop.x = Math.min(newX, right - 0.1); + newCrop.width = right - newCrop.x; + break; + } + case "right": + newCrop.width = Math.max( + 0.1, + Math.min(initialCrop.width + deltaX, 1 - initialCrop.x), + ); + break; + } + + onCropChange(newCrop); + }; + + const handlePointerUp = (e: React.PointerEvent) => { + if (isDragging) { + try { + e.currentTarget.releasePointerCapture(e.pointerId); + } catch { + /* Pointer capture may already be released while ending the drag. */ + } + } + setIsDragging(null); + }; + + const cropPixelX = cropRegion.x * 100; + const cropPixelY = cropRegion.y * 100; + const cropPixelWidth = cropRegion.width * 100; + const cropPixelHeight = cropRegion.height * 100; + const videoAspectRatio = videoElement + ? videoElement.videoWidth / videoElement.videoHeight + : 16 / 9; + const isVideoPortrait = videoAspectRatio < 1; + const maxContainerWidth = isVideoPortrait ? "40vw" : "75vw"; + const maxContainerHeight = "75vh"; + + return ( + <div className="w-full p-8"> + <div + ref={containerRef} + className="relative w-full bg-black rounded-lg overflow-visible cursor-default select-none shadow-2xl" + style={{ + aspectRatio: videoAspectRatio, + maxWidth: maxContainerWidth, + maxHeight: maxContainerHeight, + margin: "0 auto", + }} + onPointerMove={handlePointerMove} + onPointerUp={handlePointerUp} + onPointerLeave={handlePointerUp} + > + <canvas + ref={canvasRef} + className="w-full h-full rounded-lg" + style={{ imageRendering: "auto" }} + /> + + <div + className="absolute inset-0 pointer-events-none" + style={{ transition: "none" }} + > + <svg + width="100%" + height="100%" + className="absolute inset-0" + style={{ transition: "none" }} + > + <defs> + <mask id="cropMask"> + <rect width="100%" height="100%" fill="white" /> + <rect + x={`${cropPixelX}%`} + y={`${cropPixelY}%`} + width={`${cropPixelWidth}%`} + height={`${cropPixelHeight}%`} + fill="black" + style={{ transition: "none" }} + /> + </mask> + </defs> + <rect + width="100%" + height="100%" + fill="black" + fillOpacity="0.6" + mask="url(#cropMask)" + style={{ transition: "none" }} + /> + </svg> + </div> + + <div + className={cn( + "absolute h-[3px] cursor-ns-resize z-20 pointer-events-auto bg-[#2563EB]", + )} + style={{ + left: `${cropPixelX}%`, + top: `${cropPixelY}%`, + width: `${cropPixelWidth}%`, + transform: "translateY(-50%)", + willChange: "transform", + transition: "none", + }} + onPointerDown={(e) => handlePointerDown(e, "top")} + /> + + <div + className={cn( + "absolute h-[3px] cursor-ns-resize z-20 pointer-events-auto bg-[#2563EB]", + )} + style={{ + left: `${cropPixelX}%`, + top: `${cropPixelY + cropPixelHeight}%`, + width: `${cropPixelWidth}%`, + transform: "translateY(-50%)", + willChange: "transform", + transition: "none", + }} + onPointerDown={(e) => handlePointerDown(e, "bottom")} + /> + + <div + className={cn( + "absolute w-[3px] cursor-ew-resize z-20 pointer-events-auto bg-[#2563EB]", + )} + style={{ + left: `${cropPixelX}%`, + top: `${cropPixelY}%`, + height: `${cropPixelHeight}%`, + transform: "translateX(-50%)", + willChange: "transform", + transition: "none", + }} + onPointerDown={(e) => handlePointerDown(e, "left")} + /> + + <div + className={cn( + "absolute w-[3px] cursor-ew-resize z-20 pointer-events-auto bg-[#2563EB]", + )} + style={{ + left: `${cropPixelX + cropPixelWidth}%`, + top: `${cropPixelY}%`, + height: `${cropPixelHeight}%`, + transform: "translateX(-50%)", + willChange: "transform", + transition: "none", + }} + onPointerDown={(e) => handlePointerDown(e, "right")} + /> + </div> + </div> + ); } diff --git a/src/components/video-editor/ExportDialog.tsx b/src/components/video-editor/ExportDialog.tsx deleted file mode 100644 index b75e58b84..000000000 --- a/src/components/video-editor/ExportDialog.tsx +++ /dev/null @@ -1,136 +0,0 @@ -import { useEffect, useState } from 'react'; -import { X, Download, Loader2 } from 'lucide-react'; -import { Button } from '@/components/ui/button'; -import type { ExportProgress } from '@/lib/exporter'; - -interface ExportDialogProps { - isOpen: boolean; - onClose: () => void; - progress: ExportProgress | null; - isExporting: boolean; - error: string | null; - onCancel?: () => void; -} - -export function ExportDialog({ - isOpen, - onClose, - progress, - isExporting, - error, - onCancel, -}: ExportDialogProps) { - const [showSuccess, setShowSuccess] = useState(false); - - useEffect(() => { - if (!isExporting && progress && progress.percentage >= 100 && !error) { - setShowSuccess(true); - const timer = setTimeout(() => { - setShowSuccess(false); - onClose(); - }, 2000); - return () => clearTimeout(timer); - } - }, [isExporting, progress, error, onClose]); - - if (!isOpen) return null; - - return ( - <> - <div - className="fixed inset-0 bg-black/80 backdrop-blur-sm z-50 animate-in fade-in duration-200" - onClick={isExporting ? undefined : onClose} - /> - <div className="fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 z-50 bg-[#23232a] rounded-2xl shadow-2xl border border-[#3a3a42] p-8 w-[90vw] max-w-md animate-in zoom-in-95 duration-200"> - <div className="flex items-center justify-between mb-6"> - <div className="flex items-center gap-3"> - {showSuccess ? ( - <> - <div className="w-10 h-10 rounded-full bg-[#34B27B] flex items-center justify-center"> - <Download className="w-6 h-6 text-white" /> - </div> - <span className="text-xl font-bold text-slate-200">Export Complete!</span> - </> - ) : ( - <> - {isExporting ? ( - <Loader2 className="w-6 h-6 text-[#34B27B] animate-spin" /> - ) : ( - <Download className="w-6 h-6 text-[#34B27B]" /> - )} - <span className="text-xl font-bold text-slate-200"> - {error ? 'Export Failed' : isExporting ? 'Exporting Video' : 'Export Video'} - </span> - </> - )} - </div> - {!isExporting && ( - <Button - variant="ghost" - size="icon" - onClick={onClose} - className="hover:bg-[#34B27B]/20 text-slate-200" - > - <X className="w-5 h-5" /> - </Button> - )} - </div> - - {error && ( - <div className="mb-6"> - <div className="bg-red-500/10 border border-red-500/30 rounded-lg p-4"> - <p className="text-sm text-red-400">{error}</p> - </div> - </div> - )} - - {isExporting && progress && ( - <div className="space-y-4"> - <div className="space-y-2"> - <div className="flex justify-between text-sm text-slate-400"> - <span>Progress</span> - <span className="font-mono">{progress.percentage.toFixed(1)}%</span> - </div> - <div className="h-3 bg-[#18181b] rounded-full overflow-hidden border border-[#34B27B]/30"> - <div - className="h-full bg-gradient-to-r from-[#34B27B] to-[#2a9964] transition-all duration-300 ease-out" - style={{ width: `${Math.min(progress.percentage, 100)}%` }} - /> - </div> - </div> - - <div className="text-sm"> - <div className="text-slate-400 mb-1">Frame</div> - <div className="text-slate-200 font-mono text-lg"> - {progress.currentFrame} / {progress.totalFrames} - </div> - </div> - - {onCancel && ( - <div className="pt-4 border-t border-[#3a3a42]"> - <Button - onClick={onCancel} - className="w-full py-3 bg-[#34B27B] text-white hover:bg-[#34B27B]/80 transition-all" - > - Cancel Export - </Button> - </div> - )} - </div> - )} - - {showSuccess && ( - <div className="text-center py-8"> - <p className="text-lg text-slate-200">Video saved successfully!</p> - </div> - )} - - {!isExporting && !error && !showSuccess && ( - <div className="text-center py-4"> - <p className="text-slate-400">Ready to export your video</p> - </div> - )} - </div> - </> - ); -} diff --git a/src/components/video-editor/ExportSettingsMenu.tsx b/src/components/video-editor/ExportSettingsMenu.tsx new file mode 100644 index 000000000..c2c469fe4 --- /dev/null +++ b/src/components/video-editor/ExportSettingsMenu.tsx @@ -0,0 +1,465 @@ +import { DownloadSimple as Download, FilmSlate as Film, Image } from "@phosphor-icons/react"; +import { LayoutGroup, motion } from "motion/react"; +import { Button } from "@/components/ui/button"; +import { Switch } from "@/components/ui/switch"; +import { useScopedT } from "@/contexts/I18nContext"; +import type { + ExportEncodingMode, + ExportFormat, + ExportMp4FrameRate, + ExportPipelineModel, + ExportQuality, + GifFrameRate, + GifSizePreset, +} from "@/lib/exporter"; +import { GIF_FRAME_RATES, GIF_SIZE_PRESETS, MP4_FRAME_RATES } from "@/lib/exporter"; +import { cn } from "@/lib/utils"; + +interface ExportSettingsMenuProps { + exportFormat: ExportFormat; + onExportFormatChange?: (format: ExportFormat) => void; + exportQuality: ExportQuality; + onExportQualityChange?: (quality: ExportQuality) => void; + exportEncodingMode: ExportEncodingMode; + onExportEncodingModeChange?: (encodingMode: ExportEncodingMode) => void; + mp4FrameRate: ExportMp4FrameRate; + onMp4FrameRateChange?: (frameRate: ExportMp4FrameRate) => void; + exportPipelineModel?: ExportPipelineModel; + experimentalNvidiaCudaExport?: boolean; + onExperimentalNvidiaCudaExportChange?: (enabled: boolean) => void; + nvidiaCudaExportAvailable?: boolean; + showCaptionSidecarOption?: boolean; + includeCaptionSidecar?: boolean; + onIncludeCaptionSidecarChange?: (enabled: boolean) => void; + mp4OutputDimensions?: Record<ExportQuality, { width: number; height: number }>; + gifFrameRate: GifFrameRate; + onGifFrameRateChange?: (rate: GifFrameRate) => void; + gifLoop: boolean; + onGifLoopChange?: (loop: boolean) => void; + gifSizePreset: GifSizePreset; + onGifSizePresetChange?: (preset: GifSizePreset) => void; + gifOutputDimensions: { width: number; height: number }; + onExport?: () => void; + className?: string; +} + +export function ExportSettingsMenu({ + exportFormat, + onExportFormatChange, + exportQuality, + onExportQualityChange, + exportEncodingMode, + onExportEncodingModeChange, + mp4FrameRate, + onMp4FrameRateChange, + exportPipelineModel = "modern", + experimentalNvidiaCudaExport = false, + onExperimentalNvidiaCudaExportChange, + nvidiaCudaExportAvailable = false, + showCaptionSidecarOption = false, + includeCaptionSidecar = false, + onIncludeCaptionSidecarChange, + mp4OutputDimensions, + gifFrameRate, + onGifFrameRateChange, + gifLoop, + onGifLoopChange, + gifSizePreset, + onGifSizePresetChange, + gifOutputDimensions, + onExport, + className, +}: ExportSettingsMenuProps) { + const tSettings = useScopedT("settings"); + const isLegacyModel = exportPipelineModel === "legacy"; + + return ( + <div + className={cn( + "w-full rounded-2xl border border-foreground/10 bg-editor-surface p-3 text-foreground", + className, + )} + > + <div className="mb-2 flex items-center justify-between"> + <span className="text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-foreground"> + {tSettings("export.title", "Export")} + </span> + </div> + + <div className="mb-3 flex items-center gap-2"> + <LayoutGroup id="header-export-format-toggle"> + {( + [ + { value: "mp4", label: tSettings("export.mp4"), icon: Film }, + { value: "gif", label: tSettings("export.gif"), icon: Image }, + ] as const + ).map((option) => { + const Icon = option.icon; + const isActive = exportFormat === option.value; + return ( + <button + key={option.value} + type="button" + onClick={() => onExportFormatChange?.(option.value)} + aria-pressed={isActive} + className={cn( + "relative flex-1 overflow-hidden rounded-xl border py-2 text-xs font-medium transition-colors", + isActive + ? "border-[#2563EB]/50 text-[#2563EB] dark:text-white" + : "border-foreground/10 bg-foreground/5 text-muted-foreground hover:bg-foreground/10 hover:text-foreground", + )} + > + {isActive ? ( + <motion.span + layoutId="header-export-format-pill" + className="absolute inset-0 rounded-xl bg-[#2563EB]/10" + transition={{ type: "spring", stiffness: 380, damping: 32 }} + /> + ) : null} + <span className="relative z-10 flex items-center justify-center gap-1.5"> + <Icon className="h-3.5 w-3.5" /> + {option.label} + </span> + </button> + ); + })} + </LayoutGroup> + </div> + + {exportFormat === "mp4" ? ( + <LayoutGroup id="header-export-quality-toggle"> + <div className="mb-3 grid min-h-12 w-full grid-cols-4 rounded-xl border border-foreground/5 bg-foreground/5 p-0.5"> + {( + [ + { value: "medium", label: tSettings("export.quality.low") }, + { value: "good", label: tSettings("export.quality.medium") }, + { value: "high", label: tSettings("export.quality.high") }, + { value: "source", label: tSettings("export.quality.original") }, + ] as const + ).map((option) => { + const isActive = exportQuality === option.value; + return ( + <button + key={option.value} + type="button" + onClick={() => onExportQualityChange?.(option.value)} + aria-pressed={isActive} + className="relative rounded-lg px-1 py-1 text-[11px] font-medium transition-colors" + > + {isActive ? ( + <motion.span + layoutId="header-export-quality-pill" + className="absolute inset-0 rounded-lg bg-neutral-800 dark:bg-white" + transition={{ + type: "spring", + stiffness: 420, + damping: 34, + }} + /> + ) : null} + <span className="relative z-10 flex h-full flex-col items-center justify-center leading-tight"> + <span + className={cn( + isActive + ? "text-white dark:text-black" + : "text-muted-foreground hover:text-foreground", + )} + > + {option.label} + </span> + {mp4OutputDimensions ? ( + <span + className={cn( + "mt-0.5 text-[9px]", + isActive + ? "text-white/75 dark:text-black/75" + : "text-muted-foreground/70", + )} + > + {mp4OutputDimensions[option.value].width} x{" "} + {mp4OutputDimensions[option.value].height} + </span> + ) : null} + </span> + </button> + ); + })} + </div> + <div className="mb-1 flex items-center justify-between px-1"> + <span className="text-[10px] font-medium uppercase tracking-[0.14em] text-muted-foreground/70"> + {tSettings("export.encodingTitle", "Encoding")} + </span> + </div> + <div className="mb-3 grid min-h-10 w-full grid-cols-3 rounded-xl border border-foreground/5 bg-foreground/5 p-0.5"> + {( + [ + { value: "fast", label: tSettings("export.encoding.fast", "Fast") }, + { + value: "balanced", + label: tSettings("export.encoding.balanced", "Balanced"), + }, + { + value: "quality", + label: tSettings("export.encoding.quality", "Quality"), + }, + ] as const + ).map((option) => { + const isActive = exportEncodingMode === option.value; + return ( + <button + key={option.value} + type="button" + onClick={() => onExportEncodingModeChange?.(option.value)} + aria-pressed={isActive} + className="relative rounded-lg px-1 py-1 text-[11px] font-medium transition-colors" + > + {isActive ? ( + <motion.span + layoutId="header-export-encoding-pill" + className="absolute inset-0 rounded-lg bg-neutral-800 dark:bg-white" + transition={{ + type: "spring", + stiffness: 420, + damping: 34, + }} + /> + ) : null} + <span + className={cn( + "relative z-10", + isActive + ? "text-white dark:text-black" + : "text-muted-foreground hover:text-foreground", + )} + > + {option.label} + </span> + </button> + ); + })} + </div> + <div className="mb-1 flex items-center justify-between px-1"> + <span className="text-[10px] font-medium uppercase tracking-[0.14em] text-muted-foreground/70"> + {tSettings("export.fpsTitle", "FPS")} + </span> + </div> + <div className="mb-3 grid min-h-10 w-full grid-cols-3 rounded-xl border border-foreground/5 bg-foreground/5 p-0.5"> + {MP4_FRAME_RATES.map((rate) => { + const isActive = mp4FrameRate === rate; + return ( + <button + key={rate} + type="button" + onClick={() => onMp4FrameRateChange?.(rate)} + aria-pressed={isActive} + className="relative rounded-lg px-1 py-1 text-[11px] font-medium transition-colors" + > + {isActive ? ( + <motion.span + layoutId="header-export-fps-pill" + className="absolute inset-0 rounded-lg bg-neutral-800 dark:bg-white" + transition={{ + type: "spring", + stiffness: 420, + damping: 34, + }} + /> + ) : null} + <span + className={cn( + "relative z-10", + isActive + ? "text-white dark:text-black" + : "text-muted-foreground hover:text-foreground", + )} + > + {rate} + </span> + </button> + ); + })} + </div> + {!isLegacyModel && nvidiaCudaExportAvailable ? ( + <div className="mb-3 flex min-h-12 items-center justify-between gap-3 rounded-lg border border-[#2563EB]/20 bg-[#2563EB]/5 px-3 py-2"> + <div className="min-w-0"> + <div className="flex items-center gap-1.5"> + <span className="text-[11px] font-semibold text-foreground"> + {tSettings("export.nvidiaCuda.title", "NVIDIA CUDA")} + </span> + <span className="rounded bg-[#2563EB]/10 px-1.5 py-0.5 text-[9px] font-semibold uppercase text-[#2563EB]"> + {tSettings("export.nvidiaCuda.badge", "Experimental")} + </span> + </div> + <p className="mt-0.5 truncate text-[10px] text-muted-foreground/75"> + {tSettings( + "export.nvidiaCuda.hint", + "Try GPU export on this Windows device.", + )} + </p> + </div> + <Switch + checked={experimentalNvidiaCudaExport} + onCheckedChange={onExperimentalNvidiaCudaExportChange} + aria-label={tSettings( + "export.nvidiaCuda.toggle", + "Enable experimental NVIDIA CUDA export", + )} + className="shrink-0 scale-75 data-[state=checked]:bg-[#2563EB]" + /> + </div> + ) : null} + {showCaptionSidecarOption ? ( + <div className="mb-3 flex min-h-12 items-center justify-between gap-3 rounded-lg border border-foreground/10 bg-foreground/5 px-3 py-2"> + <div className="min-w-0"> + <p className="text-[11px] font-semibold text-foreground"> + {tSettings( + "export.captionSidecar.title", + "Export captions file", + )} + </p> + <p className="mt-0.5 truncate text-[10px] text-muted-foreground/75"> + {tSettings( + "export.captionSidecar.hint", + "Save .srt and .vtt files next to your exported video.", + )} + </p> + </div> + <Switch + checked={includeCaptionSidecar} + onCheckedChange={onIncludeCaptionSidecarChange} + aria-label={tSettings( + "export.captionSidecar.toggle", + "Export captions sidecar files", + )} + className="shrink-0 scale-75 data-[state=checked]:bg-[#2563EB]" + /> + </div> + ) : null} + </LayoutGroup> + ) : ( + <div className="mb-3 space-y-2"> + <div className="flex items-center gap-2"> + <LayoutGroup id="header-gif-frame-rate-toggle"> + <div className="grid h-8 flex-1 grid-cols-4 rounded-xl border border-foreground/5 bg-foreground/5 p-0.5"> + {GIF_FRAME_RATES.map((rate) => { + const isActive = gifFrameRate === rate.value; + return ( + <button + key={rate.value} + type="button" + onClick={() => onGifFrameRateChange?.(rate.value)} + aria-pressed={isActive} + className="relative rounded-lg text-[11px] font-medium transition-colors" + > + {isActive ? ( + <motion.span + layoutId="header-gif-frame-rate-pill" + className="absolute inset-0 rounded-lg bg-neutral-800 dark:bg-white" + transition={{ + type: "spring", + stiffness: 420, + damping: 34, + }} + /> + ) : null} + <span + className={cn( + "relative z-10", + isActive + ? "text-white dark:text-black" + : "text-muted-foreground hover:text-foreground", + )} + > + {rate.value} + </span> + </button> + ); + })} + </div> + </LayoutGroup> + <LayoutGroup id="header-gif-size-toggle"> + <div className="grid h-8 flex-1 grid-cols-3 rounded-xl border border-foreground/5 bg-foreground/5 p-0.5"> + {Object.entries(GIF_SIZE_PRESETS).map(([key]) => { + const isActive = gifSizePreset === key; + return ( + <button + key={key} + type="button" + onClick={() => + onGifSizePresetChange?.(key as GifSizePreset) + } + aria-pressed={isActive} + className="relative rounded-lg text-[11px] font-medium transition-colors" + > + {isActive ? ( + <motion.span + layoutId="header-gif-size-pill" + className="absolute inset-0 rounded-lg bg-neutral-800 dark:bg-white" + transition={{ + type: "spring", + stiffness: 420, + damping: 34, + }} + /> + ) : null} + <span + className={cn( + "relative z-10", + isActive + ? "text-white dark:text-black" + : "text-muted-foreground hover:text-foreground", + )} + > + {key === "original" + ? tSettings( + "export.sizePresetOriginalShort", + "Orig", + ) + : key === "medium" + ? tSettings( + "export.sizePresetMediumShort", + "Med", + ) + : tSettings( + "export.sizePresetLargeShort", + "Lar", + )} + </span> + </button> + ); + })} + </div> + </LayoutGroup> + </div> + <div className="flex items-center justify-between px-1"> + <span className="text-[10px] text-muted-foreground/70"> + {gifOutputDimensions.width} × {gifOutputDimensions.height}px + </span> + <div className="flex items-center gap-2"> + <span className="text-[10px] text-muted-foreground"> + {tSettings("export.loop")} + </span> + <Switch + checked={gifLoop} + onCheckedChange={onGifLoopChange} + className="scale-75 data-[state=checked]:bg-[#2563EB]" + /> + </div> + </div> + </div> + )} + + <Button + type="button" + size="lg" + onClick={onExport} + className="h-11 w-full gap-2 rounded-lg bg-[#2563EB] text-sm font-semibold text-white transition-colors duration-200 hover:bg-[#2563EB]/90" + > + <Download className="h-4 w-4" /> + {tSettings("export.exportVideo", undefined, { + format: exportFormat === "gif" ? "GIF" : "Video", + })} + </Button> + </div> + ); +} diff --git a/src/components/video-editor/ExtensionManager.tsx b/src/components/video-editor/ExtensionManager.tsx new file mode 100644 index 000000000..99fdc3eb1 --- /dev/null +++ b/src/components/video-editor/ExtensionManager.tsx @@ -0,0 +1,26 @@ +import { PuzzlePiece } from "@phosphor-icons/react"; + +export default function ExtensionManager() { + return ( + <div className="flex h-full w-[332px] min-w-[280px] max-w-[332px] flex-[2] flex-col overflow-hidden rounded-2xl border border-foreground/10 bg-editor-panel shadow-xl"> + <div className="flex h-12 shrink-0 items-center border-b border-foreground/10 px-4"> + <h2 className="text-sm font-semibold text-foreground">Extensions</h2> + </div> + + <div className="flex flex-1 items-center justify-center p-6"> + <div className="flex max-w-[240px] flex-col items-center text-center"> + <div className="mb-4 flex h-12 w-12 items-center justify-center rounded-2xl border border-foreground/10 bg-foreground/[0.04]"> + <PuzzlePiece className="h-6 w-6 text-muted-foreground" /> + </div> + <h3 className="text-sm font-semibold text-foreground"> + Extensions are no longer available + </h3> + <p className="mt-2 text-xs leading-relaxed text-muted-foreground"> + Extension installation and marketplace access have been disabled. This area + is kept as a placeholder for existing projects and navigation. + </p> + </div> + </div> + </div> + ); +} diff --git a/src/components/video-editor/FormatSelector.tsx b/src/components/video-editor/FormatSelector.tsx new file mode 100644 index 000000000..c999eeb32 --- /dev/null +++ b/src/components/video-editor/FormatSelector.tsx @@ -0,0 +1,82 @@ +import { FilmSlate as Film, Image } from "@phosphor-icons/react"; +import { useScopedT } from "@/contexts/I18nContext"; +import type { ExportFormat } from "@/lib/exporter/types"; +import { cn } from "@/lib/utils"; + +interface FormatSelectorProps { + selectedFormat: ExportFormat; + onFormatChange: (format: ExportFormat) => void; + disabled?: boolean; +} + +interface FormatOption { + value: ExportFormat; + label: string; + description: string; + icon: React.ReactNode; +} + +export function FormatSelector({ + selectedFormat, + onFormatChange, + disabled = false, +}: FormatSelectorProps) { + const t = useScopedT("editor"); + + const formatOptions: FormatOption[] = [ + { + value: "mp4", + label: t("format.mp4Video"), + description: t("format.mp4Description"), + icon: <Film className="w-5 h-5" />, + }, + { + value: "gif", + label: t("format.gifAnimation"), + description: t("format.gifDescription"), + icon: <Image className="w-5 h-5" />, + }, + ]; + + return ( + <div className="grid grid-cols-2 gap-3"> + {formatOptions.map((option) => { + const isSelected = selectedFormat === option.value; + return ( + <button + key={option.value} + type="button" + disabled={disabled} + onClick={() => onFormatChange(option.value)} + className={cn( + "relative flex flex-col items-center gap-2 p-4 rounded-xl border transition-all duration-200", + "focus:outline-none focus:ring-2 focus:ring-[#2563EB]/50 focus:ring-offset-2 focus:ring-offset-editor-dialog", + isSelected + ? "bg-[#2563EB]/10 border-[#2563EB]/50 text-[#2563EB] dark:text-white" + : "bg-foreground/5 border-foreground/10 text-muted-foreground hover:bg-foreground/10 hover:border-foreground/20 hover:text-foreground", + disabled && "opacity-50 cursor-not-allowed", + )} + > + <div + className={cn( + "w-10 h-10 rounded-full flex items-center justify-center transition-colors", + isSelected ? "bg-[#2563EB]/20 text-[#2563EB]" : "bg-foreground/5", + )} + > + {option.icon} + </div> + <div className="text-center"> + <div className="font-medium text-sm">{option.label}</div> + <div className="text-xs text-muted-foreground/70 mt-0.5"> + {option.description} + </div> + </div> + {isSelected && ( + <div className="absolute top-2 right-2 w-2 h-2 rounded-full bg-[#2563EB]" /> + )} + </button> + ); + })} + </div> + ); +} diff --git a/src/components/video-editor/GifOptionsPanel.tsx b/src/components/video-editor/GifOptionsPanel.tsx new file mode 100644 index 000000000..560683c28 --- /dev/null +++ b/src/components/video-editor/GifOptionsPanel.tsx @@ -0,0 +1,120 @@ +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Switch } from "@/components/ui/switch"; +import { useScopedT } from "@/contexts/I18nContext"; +import { + GIF_FRAME_RATES, + GIF_SIZE_PRESETS, + type GifFrameRate, + type GifSizePreset, +} from "@/lib/exporter/types"; + +interface GifOptionsPanelProps { + frameRate: GifFrameRate; + onFrameRateChange: (rate: GifFrameRate) => void; + loop: boolean; + onLoopChange: (loop: boolean) => void; + sizePreset: GifSizePreset; + onSizePresetChange: (preset: GifSizePreset) => void; + outputDimensions: { width: number; height: number }; + disabled?: boolean; +} + +export function GifOptionsPanel({ + frameRate, + onFrameRateChange, + loop, + onLoopChange, + sizePreset, + onSizePresetChange, + outputDimensions, + disabled = false, +}: GifOptionsPanelProps) { + const t = useScopedT("editor"); + const sizePresetOptions = Object.entries(GIF_SIZE_PRESETS).map(([key, value]) => ({ + value: key as GifSizePreset, + label: value.label, + })); + + return ( + <div className="space-y-4 animate-in slide-in-from-bottom-2 duration-200"> + {/* Frame Rate */} + <div className="space-y-2"> + <label className="text-xs font-medium text-muted-foreground uppercase tracking-wider"> + {t("gifOptions.frameRate")} + </label> + <Select + value={String(frameRate)} + onValueChange={(value) => onFrameRateChange(Number(value) as GifFrameRate)} + disabled={disabled} + > + <SelectTrigger className="w-full bg-foreground/5 border-foreground/10 text-foreground hover:bg-foreground/10"> + <SelectValue /> + </SelectTrigger> + <SelectContent className="bg-editor-surface-alt border-foreground/10 z-[100]"> + {GIF_FRAME_RATES.map((rate) => ( + <SelectItem + key={rate.value} + value={String(rate.value)} + className="text-foreground focus:bg-foreground/10 focus:text-foreground" + > + {rate.label} + </SelectItem> + ))} + </SelectContent> + </Select> + </div> + + {/* Size Preset */} + <div className="space-y-2"> + <label className="text-xs font-medium text-muted-foreground uppercase tracking-wider"> + {t("gifOptions.outputSize")} + </label> + <Select + value={sizePreset} + onValueChange={(value) => onSizePresetChange(value as GifSizePreset)} + disabled={disabled} + > + <SelectTrigger className="w-full bg-foreground/5 border-foreground/10 text-foreground hover:bg-foreground/10"> + <SelectValue /> + </SelectTrigger> + <SelectContent className="bg-editor-surface-alt border-foreground/10 z-[100]"> + {sizePresetOptions.map((option) => ( + <SelectItem + key={option.value} + value={option.value} + className="text-foreground focus:bg-foreground/10 focus:text-foreground" + > + {option.label} + </SelectItem> + ))} + </SelectContent> + </Select> + <div className="text-xs text-muted-foreground/70"> + {t("gifOptions.outputDimensions", undefined, { + width: String(outputDimensions.width), + height: String(outputDimensions.height), + })} + </div> + </div> + + {/* Loop Toggle */} + <div className="flex items-center justify-between py-2"> + <div> + <label className="text-sm font-medium text-foreground"> + {t("gifOptions.loopAnimation")} + </label> + <p className="text-xs text-muted-foreground/70"> + {t("gifOptions.loopDescription")} + </p> + </div> + <Switch checked={loop} onCheckedChange={onLoopChange} disabled={disabled} /> + </div> + </div> + ); +} diff --git a/src/components/video-editor/KeyboardShortcutsHelp.tsx b/src/components/video-editor/KeyboardShortcutsHelp.tsx new file mode 100644 index 000000000..f9c24c636 --- /dev/null +++ b/src/components/video-editor/KeyboardShortcutsHelp.tsx @@ -0,0 +1,83 @@ +import { Gear as Settings2, Question as HelpCircle } from "@phosphor-icons/react"; +import { useEffect, useState } from "react"; +import { useScopedT } from "@/contexts/I18nContext"; +import { useShortcuts } from "@/contexts/ShortcutsContext"; +import { formatBinding, SHORTCUT_ACTIONS, SHORTCUT_LABELS } from "@/lib/shortcuts"; +import { formatShortcut } from "@/utils/platformUtils"; + +export function KeyboardShortcutsHelp() { + const { shortcuts, isMac, openConfig } = useShortcuts(); + const t = useScopedT("editor"); + + const [scrollLabels, setScrollLabels] = useState({ + pan: "Shift + Scroll", + zoom: "Ctrl + Scroll", + }); + + useEffect(() => { + Promise.all([formatShortcut(["shift", "Scroll"]), formatShortcut(["mod", "Scroll"])]).then( + ([pan, zoom]) => setScrollLabels({ pan, zoom }), + ); + }, []); + + return ( + <div className="relative group"> + <HelpCircle className="w-4 h-4 text-muted-foreground/70 hover:text-[#2563EB] transition-colors cursor-help" /> + + <div className="absolute right-0 top-full mt-2 w-64 bg-editor-dialog border border-foreground/10 rounded-lg p-3 opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200 shadow-xl z-50"> + <div className="flex items-center justify-between mb-2"> + <span className="text-xs font-semibold text-foreground"> + {t("keyboardShortcuts.title")} + </span> + <button + type="button" + onClick={openConfig} + title={t("keyboardShortcuts.customizeTooltip")} + className="flex items-center gap-1 text-[10px] text-muted-foreground/70 hover:text-[#2563EB] transition-colors" + > + <Settings2 className="w-3 h-3" /> + {t("keyboardShortcuts.customize")} + </button> + </div> + + <div className="space-y-1.5 text-[10px]"> + {SHORTCUT_ACTIONS.map((action) => ( + <div key={action} className="flex items-center justify-between"> + <span className="text-muted-foreground">{SHORTCUT_LABELS[action]}</span> + <kbd className="px-1 py-0.5 bg-foreground/5 border border-foreground/10 rounded text-[#2563EB] font-mono"> + {formatBinding(shortcuts[action], isMac)} + </kbd> + </div> + ))} + + <div className="pt-1 border-t border-foreground/5 mt-1"> + <div className="flex items-center justify-between"> + <span className="text-muted-foreground"> + {t("keyboardShortcuts.panTimeline")} + </span> + <kbd className="px-1 py-0.5 bg-foreground/5 border border-foreground/10 rounded text-[#2563EB] font-mono"> + {scrollLabels.pan} + </kbd> + </div> + <div className="flex items-center justify-between mt-1.5"> + <span className="text-muted-foreground"> + {t("keyboardShortcuts.zoomTimeline")} + </span> + <kbd className="px-1 py-0.5 bg-foreground/5 border border-foreground/10 rounded text-[#2563EB] font-mono"> + {scrollLabels.zoom} + </kbd> + </div> + <div className="flex items-center justify-between mt-1.5"> + <span className="text-muted-foreground"> + {t("keyboardShortcuts.cycleAnnotations")} + </span> + <kbd className="px-1 py-0.5 bg-foreground/5 border border-foreground/10 rounded text-[#2563EB] font-mono"> + {t("keyboardShortcuts.tab")} + </kbd> + </div> + </div> + </div> + </div> + </div> + ); +} diff --git a/src/components/video-editor/PlaybackControls.tsx b/src/components/video-editor/PlaybackControls.tsx index 24370a18e..82558a7ac 100644 --- a/src/components/video-editor/PlaybackControls.tsx +++ b/src/components/video-editor/PlaybackControls.tsx @@ -1,64 +1,131 @@ +import { Pause, Play, SpeakerHigh as Volume2, SpeakerX as VolumeX } from "@phosphor-icons/react"; +import { useScopedT } from "@/contexts/I18nContext"; +import { cn } from "@/lib/utils"; import { Button } from "../ui/button"; -import { MdPlayArrow, MdPause } from "react-icons/md"; interface PlaybackControlsProps { - isPlaying: boolean; - currentTime: number; - duration: number; - onTogglePlayPause: () => void; - onSeek: (time: number) => void; + isPlaying: boolean; + currentTime: number; + duration: number; + onTogglePlayPause: () => void; + onSeek: (time: number) => void; + volume: number; + onVolumeChange: (volume: number) => void; } export default function PlaybackControls({ - isPlaying, - currentTime, - duration, - onTogglePlayPause, - onSeek, + isPlaying, + currentTime, + duration, + onTogglePlayPause, + onSeek, + volume, + onVolumeChange, }: PlaybackControlsProps) { - function formatTime(seconds: number) { - if (!isFinite(seconds) || isNaN(seconds) || seconds < 0) return '0:00'; - const mins = Math.floor(seconds / 60); - const secs = Math.floor(seconds % 60); - return `${mins}:${secs.toString().padStart(2, '0')}`; - } + const t = useScopedT("editor"); + function formatTime(seconds: number) { + if (!isFinite(seconds) || isNaN(seconds) || seconds < 0) return "0:00"; + const mins = Math.floor(seconds / 60); + const secs = Math.floor(seconds % 60); + return `${mins}:${secs.toString().padStart(2, "0")}`; + } - function handleSeekChange(e: React.ChangeEvent<HTMLInputElement>) { - onSeek(parseFloat(e.target.value)); - } + function handleSeekChange(e: React.ChangeEvent<HTMLInputElement>) { + onSeek(parseFloat(e.target.value)); + } - return ( - <div className="flex items-center gap-4 px-4 rounded-xl py-3"> - <Button - onClick={onTogglePlayPause} - size="icon" - className="w-8 h-8 rounded-full bg-transparent text-slate-200 hover:bg-[#18181b] transition-colors border border-white" - aria-label={isPlaying ? 'Pause' : 'Play'} - > - {isPlaying ? ( - <MdPause width={18} height={18} /> - ) : ( - <MdPlayArrow width={18} height={18} /> - )} - </Button> - <span className="text-xs text-slate-400 font-mono"> - {formatTime(currentTime)} - </span> - <input - type="range" - min="0" - max={duration} - value={currentTime} - onChange={handleSeekChange} - step="0.01" - className="flex-1 h-2 rounded-full transition-all duration-[33ms] custom-playback-range" - style={{ - background: `linear-gradient(to right, #34B27B 0%, #34B27B ${(currentTime / duration) * 100}%, #23232a ${(currentTime / duration) * 100}%, #23232a 100%)`, - }} - /> - <span className="text-xs text-slate-400 font-mono"> - {formatTime(duration)} - </span> - </div> - ); + function handleVolumeChange(e: React.ChangeEvent<HTMLInputElement>) { + onVolumeChange(Number(e.target.value)); + } + + const progress = duration > 0 ? (currentTime / duration) * 100 : 0; + + return ( + <div className="flex items-center gap-2 px-1.5 pr-3 py-0.5 rounded-full bg-black/75 backdrop-blur-md border border-white/10 transition-colors duration-300 hover:bg-black/80 hover:border-white/20"> + <Button + onClick={onTogglePlayPause} + size="icon" + className={cn( + "w-8 h-8 rounded-full transition-all duration-200 border border-foreground/10", + isPlaying + ? "bg-foreground/10 text-foreground hover:bg-foreground/20" + : "bg-foreground text-background hover:bg-foreground/90", + )} + aria-label={isPlaying ? t("playback.pause") : t("playback.play")} + > + {isPlaying ? ( + <Pause className="w-3.5 h-3.5" weight="fill" /> + ) : ( + <Play className="w-3.5 h-3.5" weight="fill" /> + )} + </Button> + + <span className="text-[9px] font-medium text-muted-foreground tabular-nums w-[30px] text-right"> + {formatTime(currentTime)} + </span> + + <div className="flex-1 relative h-6 flex items-center group"> + {/* Custom Track Background */} + <div className="absolute left-0 right-0 h-0.5 bg-foreground/10 rounded-full overflow-hidden"> + <div + className="h-full bg-[#2563EB] rounded-full" + style={{ width: `${progress}%` }} + /> + </div> + + {/* Interactive Input */} + <input + type="range" + min="0" + max={duration || 100} + value={currentTime} + onChange={handleSeekChange} + step="0.01" + className="absolute inset-0 w-full h-full opacity-0 cursor-pointer z-10" + /> + + {/* Custom Thumb (visual only, follows progress) */} + <div + className="absolute w-2.5 h-2.5 bg-foreground rounded-full pointer-events-none group-hover:scale-125 transition-transform duration-100" + style={{ + left: `${progress}%`, + transform: "translateX(-50%)", + }} + /> + </div> + + <span className="text-[9px] font-medium text-muted-foreground tabular-nums w-[30px]"> + {formatTime(duration)} + </span> + + <div className="flex items-center gap-1.5 pl-1"> + {volume <= 0.001 ? ( + <VolumeX className="h-3.5 w-3.5 text-muted-foreground" /> + ) : ( + <Volume2 className="h-3.5 w-3.5 text-muted-foreground" /> + )} + <div className="group relative flex h-6 w-20 items-center"> + <div className="absolute left-0 right-0 h-0.5 rounded-full bg-foreground/10 overflow-hidden"> + <div + className="h-full rounded-full bg-foreground/70" + style={{ width: `${volume * 100}%` }} + /> + </div> + <input + type="range" + min="0" + max="1" + step="0.01" + value={volume} + onChange={handleVolumeChange} + className="absolute inset-0 h-full w-full cursor-pointer opacity-0" + /> + <div + className="pointer-events-none absolute h-2.5 w-2.5 rounded-full bg-foreground transition-transform duration-100 group-hover:scale-125" + style={{ left: `${volume * 100}%`, transform: "translateX(-50%)" }} + /> + </div> + </div> + </div> + ); } diff --git a/src/components/video-editor/ProjectBrowserDialog.tsx b/src/components/video-editor/ProjectBrowserDialog.tsx new file mode 100644 index 000000000..ab71fd6b0 --- /dev/null +++ b/src/components/video-editor/ProjectBrowserDialog.tsx @@ -0,0 +1,330 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { toFileUrl } from "./projectPersistence"; + +export type ProjectLibraryEntry = { + path: string; + name: string; + updatedAt: number; + thumbnailPath: string | null; + isCurrent: boolean; + isInProjectsDirectory: boolean; +}; + +type ProjectBrowserDialogProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + entries: ProjectLibraryEntry[]; + onOpenProject: (projectPath: string) => void; + onImportFile?: () => void; + anchorRef?: React.RefObject<HTMLElement | null>; + preferredDirection?: "up" | "down" | "auto"; + onPanelHeightChange?: (height: number) => void; + renderMode?: "floating" | "inline"; +}; +export default function ProjectBrowserDialog({ + open, + onOpenChange, + entries, + onOpenProject, + onImportFile, + anchorRef, + preferredDirection = "auto", + onPanelHeightChange, + renderMode = "floating", +}: ProjectBrowserDialogProps) { + const panelRef = useRef<HTMLDivElement | null>(null); + const [position, setPosition] = useState({ top: 72, left: 16, maxHeight: 360 }); + const visibleEntries = useMemo(() => entries.slice(0, 24), [entries]); + + const updatePosition = useCallback(() => { + if (typeof window === "undefined") { + return; + } + + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + const margin = 12; + const gap = 8; + const fallbackMaxHeight = Math.min(360, viewportHeight - margin * 2); + const panelWidth = Math.min(280, Math.max(248, viewportWidth - margin * 2)); + const panelHeight = panelRef.current?.offsetHeight ?? fallbackMaxHeight; + const anchorRect = anchorRef?.current?.getBoundingClientRect(); + const availableAbove = anchorRect + ? Math.max(120, anchorRect.top - margin - gap) + : fallbackMaxHeight; + const availableBelow = anchorRect + ? Math.max(120, viewportHeight - anchorRect.bottom - margin - gap) + : fallbackMaxHeight; + const direction = + preferredDirection === "auto" + ? availableAbove > availableBelow + ? "up" + : "down" + : preferredDirection; + const maxHeight = Math.min( + fallbackMaxHeight, + direction === "up" ? availableAbove : availableBelow, + ); + + const nextTop = anchorRect + ? direction === "up" + ? Math.max(margin, anchorRect.top - Math.min(panelHeight, maxHeight) - gap) + : Math.min( + anchorRect.bottom + gap, + Math.max( + margin, + viewportHeight - Math.min(panelHeight, maxHeight) - margin, + ), + ) + : Math.min( + 56, + Math.max(margin, viewportHeight - Math.min(panelHeight, maxHeight) - margin), + ); + const alignedLeft = anchorRect + ? anchorRect.right - panelWidth + : viewportWidth - panelWidth - 16; + + setPosition({ + top: Math.max(margin, nextTop), + left: Math.max(margin, Math.min(alignedLeft, viewportWidth - panelWidth - margin)), + maxHeight, + }); + }, [anchorRef, preferredDirection]); + + useEffect(() => { + if (!open) { + return; + } + + updatePosition(); + + const handleViewportChange = () => updatePosition(); + window.addEventListener("resize", handleViewportChange); + window.addEventListener("scroll", handleViewportChange, true); + + return () => { + window.removeEventListener("resize", handleViewportChange); + window.removeEventListener("scroll", handleViewportChange, true); + }; + }, [open, updatePosition]); + + useEffect(() => { + if (!open) { + return; + } + + const handlePointerDown = (event: PointerEvent) => { + const target = event.target; + if (!(target instanceof Node)) { + return; + } + + if (panelRef.current?.contains(target) || anchorRef?.current?.contains(target)) { + return; + } + + onOpenChange(false); + }; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + onOpenChange(false); + } + }; + + document.addEventListener("pointerdown", handlePointerDown); + window.addEventListener("keydown", handleKeyDown); + + return () => { + document.removeEventListener("pointerdown", handlePointerDown); + window.removeEventListener("keydown", handleKeyDown); + }; + }, [anchorRef, onOpenChange, open]); + + useEffect(() => { + if (!open) { + onPanelHeightChange?.(0); + return; + } + + onPanelHeightChange?.(panelRef.current?.offsetHeight ?? 0); + + if (!panelRef.current || !onPanelHeightChange || typeof ResizeObserver === "undefined") { + return; + } + + const observer = new ResizeObserver(() => { + onPanelHeightChange(panelRef.current?.offsetHeight ?? 0); + }); + observer.observe(panelRef.current); + + return () => { + observer.disconnect(); + onPanelHeightChange(0); + }; + }, [onPanelHeightChange, open]); + + if (!open) { + return null; + } + + if (renderMode === "inline") { + return ( + <div + ref={panelRef} + role="dialog" + aria-label="Projects" + className="pointer-events-auto mb-1.5 w-[300px] max-h-[400px] overflow-hidden rounded-[14px] border border-foreground/[0.07] bg-editor-panel/[0.96] text-foreground shadow-[0_12px_32px_rgba(0,0,0,0.22),0_2px_10px_rgba(0,0,0,0.1)] animate-in fade-in-0 duration-150" + > + <div className="flex items-center justify-between gap-2 border-b border-foreground/10 px-3 py-2.5"> + <div className="text-sm font-medium tracking-tight text-foreground"> + Projects + </div> + {onImportFile ? ( + <button + type="button" + onClick={onImportFile} + className="rounded-md px-2 py-1 text-xs font-medium text-foreground/70 transition hover:bg-foreground/10 hover:text-foreground" + > + Import + </button> + ) : null} + </div> + <div className="max-h-[360px] overflow-y-auto px-2.5 py-2.5"> + {visibleEntries.length > 0 ? ( + <div className="grid grid-cols-2 gap-2"> + {visibleEntries.map((entry) => { + const thumbnailSrc = entry.thumbnailPath + ? toFileUrl(entry.thumbnailPath) + : null; + return ( + <button + key={entry.path} + type="button" + onClick={() => onOpenProject(entry.path)} + className="group flex flex-col gap-1 rounded-lg bg-transparent p-0.5 text-left outline-none transition focus:outline-none focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0" + > + <div className="relative aspect-[16/10] w-full overflow-hidden rounded-[5px] bg-editor-dialog-alt shadow-[0_10px_18px_rgba(0,0,0,0.28)] transition duration-200 group-hover:-translate-y-0.5 group-hover:shadow-[0_16px_30px_rgba(0,0,0,0.38)]"> + {thumbnailSrc ? ( + <img + src={thumbnailSrc} + alt="" + className="h-full w-full object-cover transition duration-200 group-hover:scale-[1.02]" + draggable={false} + /> + ) : ( + <div className="flex h-full w-full items-center justify-center bg-[linear-gradient(180deg,_rgba(37,99,235,0.22),_rgba(13,17,23,0.92))] text-[10px] font-medium text-white/60"> + No preview yet + </div> + )} + {entry.isCurrent ? ( + <div className="absolute right-1.5 top-1.5"> + <span className="rounded-[5px] bg-[#2563EB] px-1.5 py-0.5 text-[8px] font-semibold uppercase tracking-[0.14em] text-white shadow-[0_8px_20px_rgba(37,99,235,0.28)]"> + Current + </span> + </div> + ) : null} + </div> + <div className="flex flex-1 flex-col px-0.5 py-0.5"> + <div className="truncate text-[11px] font-semibold tracking-tight text-foreground"> + {entry.name} + </div> + </div> + </button> + ); + })} + </div> + ) : ( + <div className="flex min-h-[140px] flex-col items-center justify-center gap-3 rounded-xl border border-dashed border-foreground/10 bg-editor-bg px-4 text-center shadow-[inset_0_1px_0_rgba(255,255,255,0.04)]"> + <div className="text-sm font-semibold text-foreground"> + No saved projects yet + </div> + </div> + )} + </div> + </div> + ); + } + + return ( + <div className="pointer-events-none fixed inset-0 z-[90]"> + <div + ref={panelRef} + role="dialog" + aria-label="Projects" + style={{ top: `${position.top}px`, left: `${position.left}px` }} + className="pointer-events-auto fixed w-[min(280px,calc(100vw-24px))] overflow-hidden rounded-2xl border border-foreground/10 bg-editor-surface text-foreground shadow-2xl animate-in fade-in-0 duration-150" + > + <div className="flex items-center justify-between gap-2 border-b border-foreground/10 px-3 py-2.5"> + <div className="text-sm font-medium tracking-tight text-foreground"> + Projects + </div> + {onImportFile ? ( + <button + type="button" + onClick={onImportFile} + className="rounded-md px-2 py-1 text-xs font-medium text-foreground/70 transition hover:bg-foreground/10 hover:text-foreground" + > + Import + </button> + ) : null} + </div> + <div + className="overflow-y-auto px-2.5 py-2.5" + style={{ maxHeight: `${position.maxHeight}px` }} + > + {visibleEntries.length > 0 ? ( + <div className="grid grid-cols-2 gap-2"> + {visibleEntries.map((entry) => { + const thumbnailSrc = entry.thumbnailPath + ? toFileUrl(entry.thumbnailPath) + : null; + return ( + <button + key={entry.path} + type="button" + onClick={() => onOpenProject(entry.path)} + className="group flex flex-col gap-1 rounded-lg bg-transparent p-0.5 text-left outline-none transition focus:outline-none focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0" + > + <div className="relative aspect-[16/10] w-full overflow-hidden rounded-[5px] bg-editor-dialog-alt shadow-[0_10px_18px_rgba(0,0,0,0.28)] transition duration-200 group-hover:-translate-y-0.5 group-hover:shadow-[0_16px_30px_rgba(0,0,0,0.38)]"> + {thumbnailSrc ? ( + <img + src={thumbnailSrc} + alt="" + className="h-full w-full object-cover transition duration-200 group-hover:scale-[1.02]" + draggable={false} + /> + ) : ( + <div className="flex h-full w-full items-center justify-center bg-[linear-gradient(180deg,_rgba(37,99,235,0.22),_rgba(13,17,23,0.92))] text-[10px] font-medium text-white/60"> + No preview yet + </div> + )} + {entry.isCurrent ? ( + <div className="absolute right-1.5 top-1.5"> + <span className="rounded-[5px] bg-[#2563EB] px-1.5 py-0.5 text-[8px] font-semibold uppercase tracking-[0.14em] text-white shadow-[0_8px_20px_rgba(37,99,235,0.28)]"> + Current + </span> + </div> + ) : null} + </div> + <div className="flex flex-1 flex-col px-0.5 py-0.5"> + <div className="truncate text-[11px] font-semibold tracking-tight text-foreground"> + {entry.name} + </div> + </div> + </button> + ); + })} + </div> + ) : ( + <div className="flex min-h-[140px] flex-col items-center justify-center gap-3 rounded-xl border border-dashed border-foreground/10 bg-editor-bg px-4 text-center shadow-[inset_0_1px_0_rgba(255,255,255,0.04)]"> + <div className="text-sm font-semibold text-foreground"> + No saved projects yet + </div> + </div> + )} + </div> + </div> + </div> + ); +} diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx index d70220b3f..b3c4037db 100644 --- a/src/components/video-editor/SettingsPanel.tsx +++ b/src/components/video-editor/SettingsPanel.tsx @@ -1,297 +1,3818 @@ -import { cn } from "@/lib/utils"; -import { useEffect } from "react"; -import { getAssetPath } from "@/lib/assetPath"; -import { Switch } from "@/components/ui/switch"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { + CursorClick, + Palette, + PresentationChart, + Trash as Trash2, + UploadSimple as Upload, + X, +} from "@phosphor-icons/react"; +import { AnimatePresence, LayoutGroup, motion } from "motion/react"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { toast } from "sonner"; +import minimalCursorUrl from "@/assets/cursors/custom/minimal-cursor.svg"; import { Button } from "@/components/ui/button"; -import { useState } from "react"; -import Colorful from '@uiw/react-color-colorful'; -import { hsvaToHex } from '@uiw/color-convert'; -import { Trash2, Download, Crop, X, Bug } from "lucide-react"; -import type { ZoomDepth, CropRegion } from "./types"; -import { CropControl } from "./CropControl"; - -const WALLPAPER_COUNT = 12; -const WALLPAPER_RELATIVE = Array.from({ length: WALLPAPER_COUNT }, (_, i) => `wallpapers/wallpaper${i + 1}.jpg`); +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Switch } from "@/components/ui/switch"; +import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; +import { useTheme } from "@/contexts/ThemeContext"; +import { getAssetPath, getRenderableVideoUrl, getWallpaperThumbnailUrl } from "@/lib/assetPath"; +import { cn } from "@/lib/utils"; +import type { BuiltInWallpaper } from "@/lib/wallpapers"; +import { + BUILT_IN_WALLPAPERS, + getAvailableWallpapers, + isVideoWallpaperSource, +} from "@/lib/wallpapers"; +import { type AspectRatio } from "@/utils/aspectRatioUtils"; +import { useI18n, useScopedT } from "../../contexts/I18nContext"; +import type { AppLocale } from "../../i18n/config"; +import { SUPPORTED_LOCALES } from "../../i18n/config"; +import { AnnotationSettingsPanel } from "./AnnotationSettingsPanel"; +import CaptionListPanel from "./CaptionListPanel"; +import type { CaptionRetimeSpan } from "./captionOps"; +import { + CURSOR_MOTION_PRESETS, + type CursorMotionPresetId, + getMatchingCursorMotionPresetId, +} from "./cursorMotionPresets"; +import { loadEditorPreferences, saveEditorPreferences } from "./editorPreferences"; +import { SliderControl } from "./SliderControl"; +import { KeyboardShortcutsDialog } from "./TutorialHelp"; +import type { + AnnotationRegion, + AnnotationType, + AutoCaptionAnimation, + AutoCaptionSettings, + CaptionCue, + CropRegion, + CursorClickEffectStyle, + CursorStyle, + EditorEffectSection, + FigureData, + Padding, + WebcamOverlaySettings, + WebcamPositionPreset, + ZoomDepth, + ZoomMode, + ZoomTransitionEasing, +} from "./types"; +import { + ADVANCED_VERTICAL_PADDING_MAX, + DEFAULT_AUTO_CAPTION_SETTINGS, + DEFAULT_CROP_REGION, + DEFAULT_CURSOR_CLICK_BOUNCE, + DEFAULT_CURSOR_CLICK_BOUNCE_DURATION, + DEFAULT_CURSOR_CLICK_EFFECT, + DEFAULT_CURSOR_CLICK_EFFECT_COLOR, + DEFAULT_CURSOR_CLICK_EFFECT_DURATION_MS, + DEFAULT_CURSOR_CLICK_EFFECT_OPACITY, + DEFAULT_CURSOR_CLICK_EFFECT_SCALE, + DEFAULT_CURSOR_SIZE, + DEFAULT_CURSOR_STYLE, + DEFAULT_CURSOR_SWAY, + DEFAULT_PADDING, + DEFAULT_WEBCAM_MARGIN, + DEFAULT_WEBCAM_POSITION_PRESET, + DEFAULT_WEBCAM_POSITION_X, + DEFAULT_WEBCAM_POSITION_Y, + DEFAULT_WEBCAM_REACT_TO_ZOOM, + DEFAULT_WEBCAM_ROUNDNESS, + DEFAULT_WEBCAM_SHADOW, + DEFAULT_WEBCAM_SIZE, + DEFAULT_ZOOM_IN_DURATION_MS, + DEFAULT_ZOOM_OUT_DURATION_MS, +} from "./types"; +import { fromCursorSwaySliderValue, toCursorSwaySliderValue } from "./videoPlayback/cursorSway"; +import { isZeroPadding } from "./videoPlayback/layoutUtils"; +import { + cursorSetAssets, + getCursorStyleSizeMultiplier, +} from "./videoPlayback/uploadedCursorAssets"; +import { WebcamCropControl } from "./WebcamCropControl"; +import { + getCropMatchedWebcamHeightPercent, + getWebcamPositionForPreset, + normalizeWebcamCropRegion, + resolveWebcamCorner, +} from "./webcamOverlay"; + +const tahoeCursorUrl = cursorSetAssets.tahoe.arrow.url; +const BUILTIN_CURSOR_PREVIEW_SIZE = 28; +const BUILTIN_CURSOR_PREVIEW_FRAME_SIZE = 48; + const GRADIENTS = [ - "linear-gradient( 111.6deg, rgba(114,167,232,1) 9.4%, rgba(253,129,82,1) 43.9%, rgba(253,129,82,1) 54.8%, rgba(249,202,86,1) 86.3% )", - "linear-gradient(120deg, #d4fc79 0%, #96e6a1 100%)", - "radial-gradient( circle farthest-corner at 3.2% 49.6%, rgba(80,12,139,0.87) 0%, rgba(161,10,144,0.72) 83.6% )", - "linear-gradient( 111.6deg, rgba(0,56,68,1) 0%, rgba(163,217,185,1) 51.5%, rgba(231, 148, 6, 1) 88.6% )", - "linear-gradient( 107.7deg, rgba(235,230,44,0.55) 8.4%, rgba(252,152,15,1) 90.3% )", - "linear-gradient( 91deg, rgba(72,154,78,1) 5.2%, rgba(251,206,70,1) 95.9% )", - "radial-gradient( circle farthest-corner at 10% 20%, rgba(2,37,78,1) 0%, rgba(4,56,126,1) 19.7%, rgba(85,245,221,1) 100.2% )", - "linear-gradient( 109.6deg, rgba(15,2,2,1) 11.2%, rgba(36,163,190,1) 91.1% )", - "linear-gradient(135deg, #FBC8B4, #2447B1)", - "linear-gradient(109.6deg, #F635A6, #36D860)", - "linear-gradient(90deg, #FF0101, #4DFF01)", - "linear-gradient(315deg, #EC0101, #5044A9)", + "linear-gradient( 111.6deg, rgba(114,167,232,1) 9.4%, rgba(253,129,82,1) 43.9%, rgba(253,129,82,1) 54.8%, rgba(249,202,86,1) 86.3% )", + "linear-gradient(120deg, #d4fc79 0%, #96e6a1 100%)", + "radial-gradient( circle farthest-corner at 3.2% 49.6%, rgba(80,12,139,0.87) 0%, rgba(161,10,144,0.72) 83.6% )", + "linear-gradient( 111.6deg, rgba(0,56,68,1) 0%, rgba(163,217,185,1) 51.5%, rgba(231, 148, 6, 1) 88.6% )", + "linear-gradient( 107.7deg, rgba(235,230,44,0.55) 8.4%, rgba(252,152,15,1) 90.3% )", + "linear-gradient( 91deg, rgba(72,154,78,1) 5.2%, rgba(251,206,70,1) 95.9% )", + "radial-gradient( circle farthest-corner at 10% 20%, rgba(2,37,78,1) 0%, rgba(4,56,126,1) 19.7%, rgba(85,245,221,1) 100.2% )", + "linear-gradient( 109.6deg, rgba(15,2,2,1) 11.2%, rgba(36,163,190,1) 91.1% )", + "linear-gradient(135deg, #FBC8B4, #2447B1)", + "linear-gradient(109.6deg, #F635A6, #36D860)", + "linear-gradient(90deg, #FF0101, #4DFF01)", + "linear-gradient(315deg, #EC0101, #5044A9)", + "linear-gradient(45deg, #ff9a9e 0%, #fad0c4 99%, #fad0c4 100%)", + "linear-gradient(to top, #a18cd1 0%, #fbc2eb 100%)", + "linear-gradient(to right, #ff8177 0%, #ff867a 0%, #ff8c7f 21%, #f99185 52%, #cf556c 78%, #b12a5b 100%)", + "linear-gradient(120deg, #84fab0 0%, #8fd3f4 100%)", + "linear-gradient(to right, #4facfe 0%, #00f2fe 100%)", + "linear-gradient(to top, #fcc5e4 0%, #fda34b 15%, #ff7882 35%, #c8699e 52%, #7046aa 71%, #0c1db8 87%, #020f75 100%)", + "linear-gradient(to right, #fa709a 0%, #fee140 100%)", + "linear-gradient(to top, #30cfd0 0%, #330867 100%)", + "linear-gradient(to top, #c471f5 0%, #fa71cd 100%)", + "linear-gradient(to right, #f78ca0 0%, #f9748f 19%, #fd868c 60%, #fe9a8b 100%)", + "linear-gradient(to top, #48c6ef 0%, #6f86d6 100%)", + "linear-gradient(to right, #0acffe 0%, #495aff 100%)", ]; -interface SettingsPanelProps { - selected: string; - onWallpaperChange: (path: string) => void; - selectedZoomDepth?: ZoomDepth | null; - onZoomDepthChange?: (depth: ZoomDepth) => void; - selectedZoomId?: string | null; - onZoomDelete?: (id: string) => void; - showShadow?: boolean; - onShadowChange?: (showShadow: boolean) => void; - showBlur?: boolean; - onBlurChange?: (showBlur: boolean) => void; - cropRegion?: CropRegion; - onCropChange?: (region: CropRegion) => void; - videoElement?: HTMLVideoElement | null; - onExport?: () => void; +const CAPTION_ANIMATION_OPTIONS: Array<{ value: AutoCaptionAnimation; label: string }> = [ + { value: "none", label: "Off" }, + { value: "fade", label: "Fade" }, + { value: "rise", label: "Rise" }, + { value: "pop", label: "Pop" }, +]; + +const CLICK_EFFECT_COLOR_OPTIONS = [ + "#2563EB", + "#EF4444", + "#F59E0B", + "#22C55E", + "#A855F7", + "#EC4899", + "#14B8A6", + "#F97316", +] as const; + +type BackgroundTab = "image" | "video" | "color" | "gradient"; +function isHexWallpaper(value: string): boolean { + return /^#(?:[0-9a-f]{3}){1,2}$/i.test(value); +} + +function hexToRgba(hex: string, alpha: number) { + const normalized = isHexWallpaper(hex) ? hex : DEFAULT_CURSOR_CLICK_EFFECT_COLOR; + const value = + normalized.length === 4 + ? `#${normalized[1]}${normalized[1]}${normalized[2]}${normalized[2]}${normalized[3]}${normalized[3]}` + : normalized; + const color = Number.parseInt(value.slice(1), 16); + const red = (color >> 16) & 255; + const green = (color >> 8) & 255; + const blue = color & 255; + return `rgba(${red}, ${green}, ${blue}, ${alpha})`; +} + +function getBackgroundTabForWallpaper(value: string): BackgroundTab { + if (GRADIENTS.includes(value)) { + return "gradient"; + } + + if (isHexWallpaper(value)) { + return "color"; + } + + if (isVideoWallpaperSource(value)) { + return "video"; + } + + return "image"; +} + +function SectionLabel({ children }: { children: React.ReactNode }) { + return ( + <p className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground"> + {children} + </p> + ); +} + +function WallpaperVideoPreview({ src }: { src: string }) { + const [resolvedSrc, setResolvedSrc] = useState(src); + + useEffect(() => { + let cancelled = false; + setResolvedSrc(src); + + void (async () => { + try { + const nextSrc = await getRenderableVideoUrl(src); + if (!cancelled) { + setResolvedSrc(nextSrc); + } + } catch { + if (!cancelled) { + setResolvedSrc(src); + } + } + })(); + + return () => { + cancelled = true; + }; + }, [src]); + + return ( + <video + src={resolvedSrc} + muted + playsInline + preload="metadata" + className="h-full w-full select-none object-cover [transform:translateZ(0)]" + draggable={false} + onMouseEnter={(e) => e.currentTarget.play().catch(() => undefined)} + onMouseLeave={(e) => { + e.currentTarget.pause(); + e.currentTarget.currentTime = 0; + }} + /> + ); } -export default SettingsPanel; +const MOTION_PRESET_ORDER: CursorMotionPresetId[] = ["focused", "smooth"]; + +const CURSOR_CLICK_EFFECT_OPTIONS: Array<{ + id: CursorClickEffectStyle; + label: string; + description: string; +}> = [ + { + id: "none", + label: "Off", + description: "No click animation. Keeps the pointer steady on every tap.", + }, + { + id: "spotlight", + label: "Spotlight", + description: "A soft pulse that blooms behind the cursor on click.", + }, + { + id: "ripple", + label: "Ripple", + description: "Concentric rings that expand from the click point.", + }, + { + id: "echo", + label: "Echo", + description: "A pair of soft rings that spread outward with a cleaner pulse.", + }, +]; + +function MotionPresetCards({ + title, + activePresetId, + onApply, + tSettings, +}: { + title: string; + activePresetId: CursorMotionPresetId | null; + onApply: (presetId: CursorMotionPresetId) => void; + tSettings: (key: string, fallback?: string) => string; +}) { + return ( + <div className="flex flex-col gap-2"> + <div className="text-[10px] text-muted-foreground">{title}</div> + <div className="grid grid-cols-2 gap-2"> + {MOTION_PRESET_ORDER.map((presetId) => { + const Icon = presetId === "focused" ? CursorClick : PresentationChart; + const isActive = activePresetId === presetId; + + return ( + <button + key={presetId} + type="button" + onClick={() => onApply(presetId)} + className={cn( + "rounded-xl border px-3 py-3 text-left transition-all", + "border-foreground/10 bg-foreground/[0.03] hover:border-foreground/20 hover:bg-foreground/[0.06]", + isActive && + "border-[#2563EB]/70 bg-[#2563EB]/12 shadow-[inset_0_0_0_1px_rgba(37,99,235,0.15)]", + )} + > + <div className="flex items-start gap-3"> + <div + className={cn( + "mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg border border-foreground/10 bg-black/10 text-muted-foreground", + isActive && + "border-[#2563EB]/30 bg-[#2563EB]/10 text-[#75A6FF]", + )} + > + <Icon className="h-4 w-4" /> + </div> + <div className="min-w-0 flex-1"> + <div className="text-[12px] font-medium text-foreground"> + {tSettings(`effects.motionPresets.${presetId}.label`)} + </div> + </div> + </div> + <div className="mt-2 text-[10px] leading-4 text-muted-foreground"> + {tSettings(`effects.motionPresets.${presetId}.description`)} + </div> + </button> + ); + })} + </div> + </div> + ); +} + +function CursorClickEffectPreview({ + effect, + color = DEFAULT_CURSOR_CLICK_EFFECT_COLOR, +}: { + effect: CursorClickEffectStyle; + color?: string; +}) { + return ( + <div + className="relative flex items-center justify-center" + style={{ + width: `${BUILTIN_CURSOR_PREVIEW_FRAME_SIZE}px`, + height: `${BUILTIN_CURSOR_PREVIEW_FRAME_SIZE}px`, + }} + > + {effect === "none" ? ( + <svg + className="absolute h-10 w-10 text-foreground/40" + viewBox="0 0 40 40" + aria-hidden="true" + > + <circle + cx="20" + cy="20" + r="11.5" + fill="none" + stroke="currentColor" + strokeWidth="1.8" + opacity="0.75" + /> + <path + d="M12.5 27.5 27.5 12.5" + fill="none" + stroke="currentColor" + strokeLinecap="round" + strokeWidth="2.2" + opacity="0.92" + /> + </svg> + ) : null} + {effect === "ripple" ? ( + <svg + className="absolute h-12 w-12" + style={{ color }} + viewBox="0 0 48 48" + aria-hidden="true" + > + <circle + cx="24" + cy="24" + r="13" + fill="none" + stroke="currentColor" + strokeWidth="2" + opacity="0.72" + /> + </svg> + ) : null} + {effect === "spotlight" ? ( + <svg + className="absolute h-12 w-12" + style={{ color: hexToRgba(color, 0.92) }} + viewBox="0 0 48 48" + aria-hidden="true" + > + <g fill="none" stroke="currentColor"> + <circle cx="24" cy="24" r="13.5" strokeWidth="1.5" opacity="0.3" /> + <circle cx="24" cy="24" r="9.75" strokeWidth="1.7" opacity="0.56" /> + </g> + </svg> + ) : null} + {effect === "echo" ? ( + <svg + className="absolute h-12 w-12" + style={{ color: hexToRgba(color, 0.92) }} + viewBox="0 0 48 48" + aria-hidden="true" + > + <g fill="none" stroke="currentColor"> + <circle cx="24" cy="24" r="9" strokeWidth="1.8" opacity="0.72" /> + <circle cx="24" cy="24" r="14.5" strokeWidth="1.5" opacity="0.4" /> + <circle + cx="24" + cy="24" + r="4.25" + fill="currentColor" + opacity="0.22" + stroke="none" + /> + </g> + </svg> + ) : null} + </div> + ); +} + +function CursorClickEffectCards({ + title, + activeEffectId, + effectColor, + onApply, + showAdvanced, + onToggleAdvanced, + tSettings, +}: { + title: string; + activeEffectId: CursorClickEffectStyle; + effectColor: string; + onApply: (effectId: CursorClickEffectStyle) => void; + showAdvanced: boolean; + onToggleAdvanced: () => void; + tSettings: (key: string, fallback?: string) => string; +}) { + return ( + <div className="flex flex-col gap-2"> + <div className="flex items-center justify-between"> + <div className="text-[10px] text-muted-foreground">{title}</div> + <button + type="button" + onClick={onToggleAdvanced} + aria-pressed={showAdvanced} + className="text-[10px] text-[#2563EB] transition-opacity hover:opacity-80" + title={ + showAdvanced + ? tSettings( + "effects.cursorClickEffects.advancedHide", + "Hide advanced click effect controls", + ) + : tSettings( + "effects.cursorClickEffects.advancedShow", + "Show advanced click effect controls", + ) + } + > + {tSettings("effects.cursorClickEffects.advanced", "Advanced")} + </button> + </div> + <ToggleGroup + type="single" + value={activeEffectId} + onValueChange={(value) => { + if (value) { + onApply(value as CursorClickEffectStyle); + } + }} + className="grid grid-cols-4 gap-2" + aria-label={title} + > + {CURSOR_CLICK_EFFECT_OPTIONS.map((effect) => { + const label = tSettings( + `effects.cursorClickEffects.${effect.id}.label`, + effect.label, + ); + const description = tSettings( + `effects.cursorClickEffects.${effect.id}.description`, + effect.description, + ); + + return ( + <ToggleGroupItem + key={effect.id} + value={effect.id} + aria-label={label} + title={`${label} - ${description}`} + className={cn( + "group aspect-square h-auto min-w-0 rounded-[10px] border border-foreground/10 bg-foreground/[0.03] p-3 text-left text-foreground shadow-none transition-all hover:border-foreground/20 hover:bg-foreground/[0.06]", + "data-[state=on]:border-[#2563EB]/70 data-[state=on]:bg-[#2563EB]/12 data-[state=on]:text-foreground", + )} + > + <div className="flex h-full flex-col items-center justify-between gap-3"> + <div className="flex min-h-0 flex-1 items-center justify-center overflow-hidden rounded-[8px] px-2 py-1.5"> + <CursorClickEffectPreview + effect={effect.id} + color={effectColor} + /> + </div> + </div> + </ToggleGroupItem> + ); + })} + </ToggleGroup> + </div> + ); +} + +interface SettingsPanelProps { + panelMode?: "editor" | "background"; + activeEffectSection?: EditorEffectSection; + selected: string; + onWallpaperChange: (path: string) => void; + selectedZoomDepth?: ZoomDepth | null; + onZoomDepthChange?: (depth: ZoomDepth) => void; + selectedZoomId?: string | null; + selectedZoomMode?: ZoomMode | null; + onZoomModeChange?: (mode: ZoomMode) => void; + onZoomDelete?: (id: string) => void; + selectedClipId?: string | null; + selectedClipSpeed?: number | null; + selectedClipMuted?: boolean | null; + selectedClipShowSourceAudio?: boolean | null; + hasClipSourceAudio?: boolean; + onClipSpeedChange?: (speed: number) => void; + onClipMutedChange?: (muted: boolean) => void; + onClipShowSourceAudioChange?: (show: boolean) => void; + sourceAudioTrackMeta?: Array<{ id: string; label: string }>; + sourceAudioTrackSettings?: Record<string, { volume: number; normalize: boolean }>; + onSourceAudioTrackVolumeChange?: (id: string, volume: number) => void; + onSourceAudioTrackNormalizeChange?: (id: string, normalize: boolean) => void; + onClipDelete?: (id: string) => void; + selectedAudioId?: string | null; + selectedAudioVolume?: number | null; + selectedAudioNormalize?: boolean | null; + onAudioVolumeChange?: (volume: number) => void; + onAudioNormalizeChange?: (normalize: boolean) => void; + onAudioDelete?: (id: string) => void; + shadowIntensity?: number; + onShadowChange?: (intensity: number) => void; + backgroundBlur?: number; + onBackgroundBlurChange?: (amount: number) => void; + connectZooms?: boolean; + onConnectZoomsChange?: (enabled: boolean) => void; + autoApplyFreshRecordingAutoZooms?: boolean; + onAutoApplyFreshRecordingAutoZoomsChange?: (enabled: boolean) => void; + zoomInDurationMs?: number; + onZoomInDurationMsChange?: (duration: number) => void; + zoomInOverlapMs?: number; + onZoomInOverlapMsChange?: (duration: number) => void; + zoomOutDurationMs?: number; + onZoomOutDurationMsChange?: (duration: number) => void; + connectedZoomGapMs?: number; + onConnectedZoomGapMsChange?: (duration: number) => void; + connectedZoomDurationMs?: number; + onConnectedZoomDurationMsChange?: (duration: number) => void; + zoomInEasing?: ZoomTransitionEasing; + onZoomInEasingChange?: (easing: ZoomTransitionEasing) => void; + zoomOutEasing?: ZoomTransitionEasing; + onZoomOutEasingChange?: (easing: ZoomTransitionEasing) => void; + connectedZoomEasing?: ZoomTransitionEasing; + onConnectedZoomEasingChange?: (easing: ZoomTransitionEasing) => void; + showCursor?: boolean; + onShowCursorChange?: (enabled: boolean) => void; + loopCursor?: boolean; + onLoopCursorChange?: (enabled: boolean) => void; + cursorStyle?: CursorStyle; + onCursorStyleChange?: (style: CursorStyle) => void; + cursorSize?: number; + onCursorSizeChange?: (size: number) => void; + cursorSmoothing?: number; + onCursorSmoothingChange?: (smoothing: number) => void; + cursorSpringStiffnessMultiplier?: number; + onCursorSpringStiffnessMultiplierChange?: (multiplier: number) => void; + cursorSpringDampingMultiplier?: number; + onCursorSpringDampingMultiplierChange?: (multiplier: number) => void; + cursorSpringMassMultiplier?: number; + onCursorSpringMassMultiplierChange?: (multiplier: number) => void; + cameraSpringStiffnessMultiplier?: number; + onCameraSpringStiffnessMultiplierChange?: (multiplier: number) => void; + cameraSpringDampingMultiplier?: number; + onCameraSpringDampingMultiplierChange?: (multiplier: number) => void; + cameraSpringMassMultiplier?: number; + onCameraSpringMassMultiplierChange?: (multiplier: number) => void; + zoomClassicMode?: boolean; + onZoomClassicModeChange?: (enabled: boolean) => void; + cursorClickEffect?: CursorClickEffectStyle; + onCursorClickEffectChange?: (effect: CursorClickEffectStyle) => void; + cursorClickEffectColor?: string; + onCursorClickEffectColorChange?: (color: string) => void; + cursorClickEffectScale?: number; + onCursorClickEffectScaleChange?: (scale: number) => void; + cursorClickEffectOpacity?: number; + onCursorClickEffectOpacityChange?: (opacity: number) => void; + cursorClickEffectDurationMs?: number; + onCursorClickEffectDurationMsChange?: (duration: number) => void; + cursorClickBounce?: number; + onCursorClickBounceChange?: (amount: number) => void; + cursorClickBounceDuration?: number; + onCursorClickBounceDurationChange?: (duration: number) => void; + cursorSway?: number; + onCursorSwayChange?: (amount: number) => void; + borderRadius?: number; + onBorderRadiusChange?: (radius: number) => void; + webcam?: WebcamOverlaySettings; + webcamPreviewSrc?: string | null; + webcamPreviewCurrentTime?: number; + webcamPreviewPlaying?: boolean; + onWebcamChange?: (webcam: WebcamOverlaySettings) => void; + onUploadWebcam?: () => void; + onClearWebcam?: () => void; + padding?: Padding; + onPaddingChange?: (padding: Padding) => void; + cropRegion?: CropRegion; + onCropChange?: (region: CropRegion) => void; + aspectRatio: AspectRatio; + onAspectRatioChange?: (ratio: AspectRatio) => void; + selectedAnnotationId?: string | null; + annotationRegions?: AnnotationRegion[]; + onAnnotationContentChange?: (id: string, content: string) => void; + onAnnotationTypeChange?: (id: string, type: AnnotationType) => void; + onAnnotationStyleChange?: (id: string, style: Partial<AnnotationRegion["style"]>) => void; + onAnnotationFigureDataChange?: (id: string, figureData: FigureData) => void; + onAnnotationBlurIntensityChange?: (id: string, intensity: number) => void; + onAnnotationBlurColorChange?: (id: string, color: string) => void; + onAnnotationDelete?: (id: string) => void; + autoCaptions?: CaptionCue[]; + autoCaptionSettings?: AutoCaptionSettings; + whisperExecutablePath?: string | null; + whisperModelPath?: string | null; + whisperModelDownloadStatus?: "idle" | "downloading" | "downloaded" | "error"; + whisperModelDownloadProgress?: number; + isGeneratingCaptions?: boolean; + onAutoCaptionSettingsChange?: (settings: AutoCaptionSettings) => void; + onPickWhisperExecutable?: () => void; + onPickWhisperModel?: () => void; + onGenerateAutoCaptions?: () => void; + onClearAutoCaptions?: () => void; + onDownloadWhisperSmallModel?: () => void; + onDeleteWhisperSmallModel?: () => void; + captionCurrentTimeMs?: number; + selectedCaptionId?: string | null; + onBeginCaptionEdit?: (id: string) => void; + onCaptionTextEdit?: (id: string, text: string) => void; + onCaptionRetime?: (id: string, span: CaptionRetimeSpan) => void; + onCaptionSplit?: (id: string, atMs: number) => void; + onCaptionMerge?: (idA: string, idB: string) => void; + onCaptionDelete?: (id: string) => void; + nativeCaptureUnavailableSession?: boolean; + onOpenNativeCaptureUnavailableModal?: () => void; +} const ZOOM_DEPTH_OPTIONS: Array<{ depth: ZoomDepth; label: string }> = [ - { depth: 1, label: "1.25×" }, - { depth: 2, label: "1.5×" }, - { depth: 3, label: "1.8×" }, - { depth: 4, label: "2.2×" }, - { depth: 5, label: "3.5×" }, + { depth: 1, label: "1.25×" }, + { depth: 2, label: "1.5×" }, + { depth: 3, label: "1.8×" }, + { depth: 4, label: "2.2×" }, + { depth: 5, label: "3.5×" }, + { depth: 6, label: "5×" }, +]; + +const WEBCAM_POSITION_PRESETS: Array<{ + preset: Exclude<WebcamPositionPreset, "custom">; + label: string; +}> = [ + { preset: "top-left", label: "↖" }, + { preset: "top-center", label: "↑" }, + { preset: "top-right", label: "↗" }, + { preset: "center-left", label: "←" }, + { preset: "center", label: "•" }, + { preset: "center-right", label: "→" }, + { preset: "bottom-left", label: "↙" }, + { preset: "bottom-center", label: "↓" }, + { preset: "bottom-right", label: "↘" }, +]; + +type CursorStyleOption = { value: CursorStyle; label: string }; + +type WallpaperTile = { + key: string; + label: string; + value: string; + previewUrl: string; +}; + +const BUILTIN_CURSOR_STYLE_OPTIONS: CursorStyleOption[] = [ + { value: "macos", label: "macOS" }, + { value: "tahoe", label: "Tahoe" }, + { value: "tahoe-inverted", label: "Tahoe Inverted" }, + { value: "windows11", label: "Windows 11" }, + { value: "dot", label: "Dot" }, + { value: "figma", label: "Minimal" }, ]; -export function SettingsPanel({ selected, onWallpaperChange, selectedZoomDepth, onZoomDepthChange, selectedZoomId, onZoomDelete, showShadow, onShadowChange, showBlur, onBlurChange, cropRegion, onCropChange, videoElement, onExport }: SettingsPanelProps) { - const [wallpaperPaths, setWallpaperPaths] = useState<string[]>([]); - - useEffect(() => { - let mounted = true - ;(async () => { - try { - const resolved = await Promise.all(WALLPAPER_RELATIVE.map(p => getAssetPath(p))) - if (mounted) setWallpaperPaths(resolved) - } catch (err) { - if (mounted) setWallpaperPaths(WALLPAPER_RELATIVE.map(p => `/${p}`)) - } - })() - return () => { mounted = false } - }, []) - const [hsva, setHsva] = useState({ h: 0, s: 0, v: 68, a: 1 }); - const [gradient, setGradient] = useState<string>(GRADIENTS[0]); - const [showCropDropdown, setShowCropDropdown] = useState(false); - - const zoomEnabled = Boolean(selectedZoomDepth); - - const handleDeleteClick = () => { - if (selectedZoomId && onZoomDelete) { - onZoomDelete(selectedZoomId); - } - }; - - return ( - <div className="flex-[3] min-w-0 bg-[#18181b] border border-[#23232a] rounded-xl p-8 flex flex-col shadow-lg"> - <div className="mb-6"> - <div className="flex items-center justify-between mb-3"> - <span className="text-sm font-semibold text-slate-200">Zoom Level</span> - {zoomEnabled && selectedZoomDepth && ( - <span className="text-xs uppercase tracking-wide text-slate-400/80"> - Active · {ZOOM_DEPTH_OPTIONS.find(o => o.depth === selectedZoomDepth)?.label} - </span> - )} - </div> - <div className="grid grid-cols-5 gap-2"> - {ZOOM_DEPTH_OPTIONS.map((option) => { - const isActive = selectedZoomDepth === option.depth; - return ( - <Button - key={option.depth} - type="button" - disabled={!zoomEnabled} - onClick={() => onZoomDepthChange?.(option.depth)} - className={cn( - "h-auto w-full rounded-xl border px-2 py-3 text-center shadow-lg transition-all flex flex-col items-center justify-center gap-1", - "duration-150 ease-in-out", - zoomEnabled ? "opacity-100" : "opacity-60", - isActive - ? "border-[#34B27B] bg-white text-black shadow-[#34B27B]/20 scale-105" - : "border-[#23232a] bg-[#23232a] text-slate-200 hover:border-[#34B27B] hover:scale-105" - )} - style={isActive ? { background: '#fff', color: '#111' } : undefined} - > - <span className={cn("text-sm font-semibold tracking-tight", isActive ? "text-black" : "text-slate-200")}>{option.label}</span> - </Button> - ); - })} - </div> - {!zoomEnabled && ( - <p className="text-xs text-slate-400/80 mt-2">Select a zoom item in the timeline to adjust its depth.</p> - )} - {zoomEnabled && ( - <Button - onClick={handleDeleteClick} - variant="destructive" - size="sm" - className="mt-3 w-full gap-2 bg-[#34B27B] text-white border-none hover:bg-[#34B27B]/80" - > - <Trash2 className="w-4 h-4" /> - Delete Zoom - </Button> - )} - </div> - <div className="mb-6"> - <div className="flex flex-col gap-3 mb-4"> - <div className="flex items-center gap-2"> - <Switch - checked={showShadow} - onCheckedChange={onShadowChange} - /> - <div className="text-sm text-slate-200">Shadow</div> - </div> - <div className="flex items-center gap-2"> - <Switch - checked={showBlur} - onCheckedChange={onBlurChange} - /> - <div className="text-sm text-slate-200">Blur Background</div> - </div> - </div> - </div> - <div className="mb-6"> - <Button - onClick={() => setShowCropDropdown(!showCropDropdown)} - variant="outline" - className="w-full gap-2 bg-[#23232a] text-slate-200 border-none hover:border-[#34B27B]" - > - <Crop className="w-4 h-4" /> - Crop Video - </Button> - </div> - - {showCropDropdown && cropRegion && onCropChange && ( - <> - <div - className="fixed inset-0 bg-black/80 backdrop-blur-sm z-40 animate-in fade-in duration-200" - onClick={() => setShowCropDropdown(false)} - /> - <div className="fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 z-50 bg-[#23232a] rounded-2xl shadow-2xl border border-[#34B27B] p-8 w-[90vw] max-w-5xl animate-in zoom-in-95 duration-200"> - <div className="flex items-center justify-between mb-6"> - <div> - <span className="text-xl font-bold text-slate-200">Crop Video</span> - <p className="text-sm text-slate-400 mt-2">Drag on each side to adjust the crop area</p> - </div> - <Button - variant="ghost" - size="icon" - onClick={() => setShowCropDropdown(false)} - className="hover:bg-[#34B27B]/20 text-slate-200" - > - <X className="w-5 h-5" /> - </Button> - </div> - <CropControl - videoElement={videoElement || null} - cropRegion={cropRegion} - onCropChange={onCropChange} - /> - <div className="mt-6 flex justify-end"> - <Button - onClick={() => setShowCropDropdown(false)} - size="lg" - > - Done - </Button> - </div> - </div> - </> - )} - <Tabs defaultValue="image" className="mb-6 text-slate-200"> - <TabsList className="mb-4 bg-[#23232a] border-none text-slate-200"> - <TabsTrigger value="image" className="text-slate-200">Image</TabsTrigger> - <TabsTrigger value="color" className="text-slate-200">Color</TabsTrigger> - <TabsTrigger value="gradient" className="text-slate-200">Gradient</TabsTrigger> - </TabsList> - - <TabsContent value="image"> - <div className="grid grid-cols-6 gap-3"> - {(wallpaperPaths.length > 0 ? wallpaperPaths : WALLPAPER_RELATIVE.map(p => `/${p}`)).map((path, idx) => { - const isSelected = (() => { - if (!selected) return false; - - if (selected === path) return true; - try { - const clean = (s: string) => s.replace(/^file:\/\//, '').replace(/^\//, '') - if (clean(selected).endsWith(clean(path))) return true; - if (clean(path).endsWith(clean(selected))) return true; - } catch { - } - return false; - })(); - - return ( - <div - key={path} - className={cn( - "aspect-square rounded-lg border-2 overflow-hidden cursor-pointer transition-all w-16 h-16", - isSelected - ? "border-[#34B27B] ring-1 ring-[#34B27B] scale-105" - : "border-[#23232a] hover:border-[#34B27B] hover:scale-105" - )} - style={{ backgroundImage: `url(${path})`, backgroundSize: "cover", backgroundPosition: "center" }} - aria-label={`Wallpaper ${idx + 1}`} - onClick={() => onWallpaperChange(path)} - role="button" - /> - ) - })} - </div> - </TabsContent> - - <TabsContent value="color"> - <div className="p-2"> - <Colorful - color={hsva} - disableAlpha={true} - onChange={(color) => { - setHsva(color.hsva); - onWallpaperChange(hsvaToHex(color.hsva)); - }} - /> - </div> - </TabsContent> - - <TabsContent value="gradient"> - <div className="grid grid-cols-6 gap-3"> - {GRADIENTS.map((g, idx) => ( - <div - key={g} - className={cn( - "aspect-square rounded-lg border-2 overflow-hidden cursor-pointer transition-all w-16 h-16", - gradient === g ? "border-[#34B27B] ring-1 ring-[#34B27B] scale-105" : "border-[#23232a] hover:border-[#34B27B] hover:scale-105" - )} - style={{ background: g }} - aria-label={`Gradient ${idx + 1}`} - onClick={() => { setGradient(g); onWallpaperChange(g); }} - role="button" - /> - ))} - </div> - </TabsContent> - </Tabs> - <div className="mt-auto pt-4"> - <Button - type="button" - size="lg" - onClick={onExport} - className="w-full py-5 text-lg flex items-center justify-center gap-3 bg-[#34B27B] text-white rounded-xl shadow-lg hover:bg-[#34B27B]/80 transition-all" - > - <Download className="w-6 h-6" /> - <span className="text-lg">Export Video</span> - </Button> - <button - type="button" - onClick={() => { - window.electronAPI?.openExternalUrl('https://github.com/siddharthvaddem/openscreen/issues/new'); - }} - className="w-full mt-3 flex items-center justify-center gap-1 text-[10px] text-slate-400/80 hover:text-slate-200 transition-colors py-1" - > - <Bug className="w-3 h-3 text-white" /> - <span>Report Bug</span> - </button> - </div> - </div> - ); +const CAPTION_LANGUAGE_OPTIONS = [ + { value: "auto", label: "Auto Detect" }, + { value: "en", label: "English" }, + { value: "es", label: "Spanish" }, + { value: "fr", label: "French" }, + { value: "de", label: "German" }, + { value: "it", label: "Italian" }, + { value: "pt", label: "Portuguese" }, + { value: "zh", label: "Chinese (Simplified)" }, + { value: "ja", label: "Japanese" }, + { value: "ko", label: "Korean" }, +] as const; + +const APP_LANGUAGE_LABELS: Record<AppLocale, string> = { + en: "English", + es: "Español", + fr: "Français", + de: "Deutsch", + it: "Italiano", + nl: "Nederlands", + ko: "한국어", + "pt-BR": "Português", + "zh-CN": "簡體中文", + "zh-TW": "繁體中文", +}; + +function loadPreviewImage(url: string) { + return new Promise<HTMLImageElement>((resolve, reject) => { + const image = new Image(); + image.onload = () => resolve(image); + image.onerror = () => reject(new Error(`Failed to load preview asset: ${url}`)); + image.src = url; + }); +} + +function trimCanvasToAlpha(canvas: HTMLCanvasElement, hotspot?: { x: number; y: number }) { + const ctx = canvas.getContext("2d"); + if (!ctx) { + return { + dataUrl: canvas.toDataURL("image/png"), + width: canvas.width, + height: canvas.height, + hotspot, + }; + } + + const { width, height } = canvas; + const imageData = ctx.getImageData(0, 0, width, height); + const { data } = imageData; + let minX = width; + let minY = height; + let maxX = -1; + let maxY = -1; + + for (let y = 0; y < height; y += 1) { + for (let x = 0; x < width; x += 1) { + const alpha = data[(y * width + x) * 4 + 3]; + if (alpha === 0) { + continue; + } + + minX = Math.min(minX, x); + minY = Math.min(minY, y); + maxX = Math.max(maxX, x); + maxY = Math.max(maxY, y); + } + } + + if (maxX < minX || maxY < minY) { + return { + dataUrl: canvas.toDataURL("image/png"), + width, + height, + hotspot, + }; + } + + const croppedWidth = maxX - minX + 1; + const croppedHeight = maxY - minY + 1; + const croppedCanvas = document.createElement("canvas"); + croppedCanvas.width = croppedWidth; + croppedCanvas.height = croppedHeight; + const croppedCtx = croppedCanvas.getContext("2d")!; + croppedCtx.drawImage( + canvas, + minX, + minY, + croppedWidth, + croppedHeight, + 0, + 0, + croppedWidth, + croppedHeight, + ); + + return { + dataUrl: croppedCanvas.toDataURL("image/png"), + width: croppedWidth, + height: croppedHeight, + hotspot: hotspot + ? { + x: hotspot.x - minX, + y: hotspot.y - minY, + } + : undefined, + }; +} + +async function createTrimmedSvgPreview( + url: string, + sampleSize: number, + trim?: { x: number; y: number; width: number; height: number }, +) { + const image = await loadPreviewImage(url); + const sourceCanvas = document.createElement("canvas"); + sourceCanvas.width = sampleSize; + sourceCanvas.height = sampleSize; + const sourceCtx = sourceCanvas.getContext("2d")!; + sourceCtx.drawImage(image, 0, 0, sampleSize, sampleSize); + + if (trim) { + const croppedCanvas = document.createElement("canvas"); + croppedCanvas.width = trim.width; + croppedCanvas.height = trim.height; + const croppedCtx = croppedCanvas.getContext("2d")!; + croppedCtx.drawImage( + sourceCanvas, + trim.x, + trim.y, + trim.width, + trim.height, + 0, + 0, + trim.width, + trim.height, + ); + return croppedCanvas.toDataURL("image/png"); + } + + return trimCanvasToAlpha(sourceCanvas).dataUrl; +} + +async function createInvertedPreview(url: string) { + const image = await loadPreviewImage(url); + const canvas = document.createElement("canvas"); + canvas.width = image.naturalWidth; + canvas.height = image.naturalHeight; + const ctx = canvas.getContext("2d")!; + ctx.drawImage(image, 0, 0); + const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); + const { data } = imageData; + for (let index = 0; index < data.length; index += 4) { + if (data[index + 3] === 0) { + continue; + } + data[index] = 255 - data[index]; + data[index + 1] = 255 - data[index + 1]; + data[index + 2] = 255 - data[index + 2]; + } + ctx.putImageData(imageData, 0, 0); + return canvas.toDataURL("image/png"); +} + +function CursorStylePreview({ + style, + previewUrls, + frameSize = BUILTIN_CURSOR_PREVIEW_FRAME_SIZE, + previewSize, +}: { + style: CursorStyle; + previewUrls: Partial<Record<string, string>>; + frameSize?: number; + previewSize?: number; +}) { + const previewSrc = + style === "macos" + ? (previewUrls.macos ?? tahoeCursorUrl) + : style === "tahoe" + ? (previewUrls.tahoe ?? tahoeCursorUrl) + : style === "windows11" + ? (previewUrls.windows11 ?? tahoeCursorUrl) + : style === "figma" + ? (previewUrls.figma ?? minimalCursorUrl) + : style === "tahoe-inverted" + ? (previewUrls["tahoe-inverted"] ?? tahoeCursorUrl) + : previewUrls[style]; + + if ( + style === "macos" || + style === "tahoe" || + style === "tahoe-inverted" || + style === "windows11" + ) { + const resolvedPreviewSize = + (previewSize ?? BUILTIN_CURSOR_PREVIEW_SIZE) * getCursorStyleSizeMultiplier(style); + return ( + <div + className="flex items-center justify-center" + style={{ + width: `${frameSize}px`, + height: `${frameSize}px`, + }} + > + <img + src={previewSrc ?? tahoeCursorUrl} + alt="" + className="max-w-none object-contain drop-shadow-[0_8px_12px_rgba(15,23,42,0.18)]" + draggable={false} + style={{ + width: `${resolvedPreviewSize}px`, + height: `${resolvedPreviewSize}px`, + }} + /> + </div> + ); + } + + if (style === "figma") { + const resolvedPreviewSize = previewSize ?? 28; + return ( + <div + className="flex items-center justify-center" + style={{ width: `${frameSize}px`, height: `${frameSize}px` }} + > + <img + src={previewSrc} + alt="" + className="object-contain" + draggable={false} + style={{ + width: `${resolvedPreviewSize}px`, + height: `${resolvedPreviewSize}px`, + }} + /> + </div> + ); + } + + if (style === "dot") { + const resolvedPreviewSize = previewSize ?? 14; + return ( + <div + className="flex items-center justify-center" + style={{ width: `${frameSize}px`, height: `${frameSize}px` }} + > + <span + className="rounded-full border-[2.5px] border-neutral-800 bg-white shadow-[0_8px_12px_rgba(15,23,42,0.16)]" + style={{ + width: `${resolvedPreviewSize}px`, + height: `${resolvedPreviewSize}px`, + }} + /> + </div> + ); + } + + const resolvedPreviewSize = previewSize ?? 28; + return ( + <div + className="flex items-center justify-center" + style={{ width: `${frameSize}px`, height: `${frameSize}px` }} + > + <img + src={previewSrc ?? tahoeCursorUrl} + alt="" + className="object-contain" + draggable={false} + style={{ width: `${resolvedPreviewSize}px`, height: `${resolvedPreviewSize}px` }} + /> + </div> + ); +} + +export function SettingsPanel({ + panelMode = "editor", + activeEffectSection: activeEffectSectionProp, + selected, + onWallpaperChange, + selectedZoomDepth, + onZoomDepthChange, + selectedZoomId, + selectedZoomMode, + onZoomModeChange, + onZoomDelete, + selectedClipId, + selectedClipSpeed, + selectedClipMuted, + selectedClipShowSourceAudio = false, + hasClipSourceAudio = false, + onClipSpeedChange, + onClipMutedChange, + onClipShowSourceAudioChange, + sourceAudioTrackMeta = [], + sourceAudioTrackSettings = {}, + onSourceAudioTrackVolumeChange, + onSourceAudioTrackNormalizeChange, + onClipDelete, + selectedAudioId, + selectedAudioVolume, + selectedAudioNormalize, + onAudioVolumeChange, + onAudioNormalizeChange, + onAudioDelete, + shadowIntensity = 0.67, + onShadowChange, + backgroundBlur = 0, + onBackgroundBlurChange, + connectZooms = true, + onConnectZoomsChange, + autoApplyFreshRecordingAutoZooms = true, + onAutoApplyFreshRecordingAutoZoomsChange, + zoomInDurationMs = DEFAULT_ZOOM_IN_DURATION_MS, + onZoomInDurationMsChange, + zoomOutDurationMs = DEFAULT_ZOOM_OUT_DURATION_MS, + onZoomOutDurationMsChange, + showCursor = false, + onShowCursorChange, + loopCursor = false, + onLoopCursorChange, + cursorStyle = DEFAULT_CURSOR_STYLE, + onCursorStyleChange, + cursorSize = 5, + onCursorSizeChange, + cursorSmoothing = 2, + onCursorSmoothingChange, + cursorSpringStiffnessMultiplier = 1, + onCursorSpringStiffnessMultiplierChange, + cursorSpringDampingMultiplier = 1, + onCursorSpringDampingMultiplierChange, + cursorSpringMassMultiplier = 1, + onCursorSpringMassMultiplierChange, + cameraSpringStiffnessMultiplier = 1, + onCameraSpringStiffnessMultiplierChange, + cameraSpringDampingMultiplier = 1.13, + onCameraSpringDampingMultiplierChange, + cameraSpringMassMultiplier = 1.12, + onCameraSpringMassMultiplierChange, + zoomClassicMode = false, + onZoomClassicModeChange, + cursorClickEffect = DEFAULT_CURSOR_CLICK_EFFECT, + onCursorClickEffectChange, + cursorClickEffectColor = DEFAULT_CURSOR_CLICK_EFFECT_COLOR, + onCursorClickEffectColorChange, + cursorClickEffectScale = DEFAULT_CURSOR_CLICK_EFFECT_SCALE, + onCursorClickEffectScaleChange, + cursorClickEffectOpacity = DEFAULT_CURSOR_CLICK_EFFECT_OPACITY, + onCursorClickEffectOpacityChange, + cursorClickEffectDurationMs = DEFAULT_CURSOR_CLICK_EFFECT_DURATION_MS, + onCursorClickEffectDurationMsChange, + cursorClickBounce = 1, + onCursorClickBounceChange, + cursorClickBounceDuration = DEFAULT_CURSOR_CLICK_BOUNCE_DURATION, + onCursorClickBounceDurationChange, + cursorSway = DEFAULT_CURSOR_SWAY, + onCursorSwayChange, + borderRadius = 12.5, + onBorderRadiusChange, + webcam, + webcamPreviewSrc = null, + webcamPreviewCurrentTime = 0, + webcamPreviewPlaying = false, + onWebcamChange, + onUploadWebcam, + onClearWebcam, + padding = DEFAULT_PADDING, + onPaddingChange, + cropRegion, + onCropChange, + aspectRatio, + onAspectRatioChange, + selectedAnnotationId, + annotationRegions = [], + onAnnotationContentChange, + onAnnotationTypeChange, + onAnnotationStyleChange, + onAnnotationFigureDataChange, + onAnnotationBlurIntensityChange, + onAnnotationBlurColorChange, + onAnnotationDelete, + autoCaptions = [], + autoCaptionSettings = DEFAULT_AUTO_CAPTION_SETTINGS, + whisperModelPath, + whisperModelDownloadStatus = "idle", + whisperModelDownloadProgress = 0, + isGeneratingCaptions = false, + onAutoCaptionSettingsChange, + onPickWhisperModel, + onGenerateAutoCaptions, + onClearAutoCaptions, + onDownloadWhisperSmallModel, + onDeleteWhisperSmallModel, + captionCurrentTimeMs = 0, + selectedCaptionId = null, + onBeginCaptionEdit, + onCaptionTextEdit, + onCaptionRetime, + onCaptionSplit, + onCaptionMerge, + onCaptionDelete, + nativeCaptureUnavailableSession = false, + onOpenNativeCaptureUnavailableModal, +}: SettingsPanelProps) { + const tSettings = useScopedT("settings"); + const { locale, setLocale, t } = useI18n(); + const { preference: themePreference, setPreference: setThemePreference } = useTheme(); + const isBackgroundPanel = panelMode === "background"; + const initialEditorPreferences = useMemo(() => loadEditorPreferences(), []); + const [builtInWallpapers, setBuiltInWallpapers] = + useState<BuiltInWallpaper[]>(BUILT_IN_WALLPAPERS); + const [wallpaperPreviewPaths, setWallpaperPreviewPaths] = useState<string[]>([]); + const [customImages, setCustomImages] = useState<string[]>( + initialEditorPreferences.customWallpapers, + ); + const [experimentalUpdatesEnabled, setExperimentalUpdatesEnabled] = useState(false); + const [savingExperimentalUpdates, setSavingExperimentalUpdates] = useState(false); + const removeBackgroundStateRef = useRef<{ + aspectRatio: AspectRatio; + padding: Padding; + } | null>(null); + const fileInputRef = useRef<HTMLInputElement>(null); + const builtInWallpaperPaths = useMemo( + () => builtInWallpapers.map((wallpaper) => wallpaper.publicPath), + [builtInWallpapers], + ); + const captionCueCount = autoCaptions.length; + const updateAutoCaptionSettings = (partial: Partial<AutoCaptionSettings>) => { + onAutoCaptionSettingsChange?.({ + ...autoCaptionSettings, + ...partial, + }); + }; + + useEffect(() => { + let cancelled = false; + void window.electronAPI + .getExperimentalUpdatesEnabled() + .then((enabled) => { + if (!cancelled) setExperimentalUpdatesEnabled(enabled); + }) + .catch((error) => { + console.error("Failed to load experimental updates preference:", error); + }); + return () => { + cancelled = true; + }; + }, []); + + const updateExperimentalUpdatesPreference = async (enabled: boolean) => { + const previousValue = experimentalUpdatesEnabled; + setExperimentalUpdatesEnabled(enabled); + setSavingExperimentalUpdates(true); + try { + const result = await window.electronAPI.setExperimentalUpdatesEnabled(enabled); + setExperimentalUpdatesEnabled(result.enabled); + if (!result.success) { + toast.error( + result.error || + tSettings("updates.saveFailed", "Failed to change the update channel."), + ); + } + } catch (error) { + setExperimentalUpdatesEnabled(previousValue); + toast.error( + `${tSettings("updates.saveFailed", "Failed to change the update channel.")} ${String(error)}`, + ); + } finally { + setSavingExperimentalUpdates(false); + } + }; + + useEffect(() => { + let mounted = true; + (async () => { + try { + const availableWallpapers = await getAvailableWallpapers(); + const resolved = await Promise.all( + availableWallpapers.map(async (wallpaper) => { + const assetUrl = await getAssetPath(wallpaper.relativePath); + // Use tiny thumbnails for the grid; full-res loads on selection + if (isVideoWallpaperSource(wallpaper.publicPath)) { + return getRenderableVideoUrl(assetUrl); + } + return getWallpaperThumbnailUrl(assetUrl); + }), + ); + if (mounted) { + setBuiltInWallpapers(availableWallpapers); + setWallpaperPreviewPaths(resolved); + } + } catch { + if (mounted) { + setBuiltInWallpapers(BUILT_IN_WALLPAPERS); + setWallpaperPreviewPaths( + BUILT_IN_WALLPAPERS.map((wallpaper) => wallpaper.publicPath), + ); + } + } + })(); + return () => { + mounted = false; + }; + }, []); + + const colorPalette = [ + "#FF0000", + "#FFD700", + "#00FF00", + "#FFFFFF", + "#0000FF", + "#FF6B00", + "#9B59B6", + "#E91E63", + "#00BCD4", + "#FF5722", + "#8BC34A", + "#FFC107", + "#2563EB", + "#000000", + "#607D8B", + "#795548", + ]; + + const [selectedColor, setSelectedColor] = useState( + isHexWallpaper(selected) ? selected : "#ADADAD", + ); + const [gradient, setGradient] = useState<string>( + GRADIENTS.includes(selected) ? selected : GRADIENTS[0], + ); + const removeBackgroundEnabled = aspectRatio === "native" && isZeroPadding(padding); + + const [backgroundTab, setBackgroundTab] = useState<BackgroundTab>(() => + getBackgroundTabForWallpaper(selected), + ); + const customColorInputRef = useRef<HTMLInputElement | null>(null); + const cursorClickEffectColorInputRef = useRef<HTMLInputElement | null>(null); + const defaultWebcam = initialEditorPreferences.webcam; + const [internalActiveEffectSection] = useState<EditorEffectSection>("scene"); + const activeEffectSection = activeEffectSectionProp ?? internalActiveEffectSection; + const [builtInCursorPreviewUrls, setBuiltInCursorPreviewUrls] = useState< + Partial<Record<string, string>> + >({}); + const [showCursorClickEffectAdvanced, setShowCursorClickEffectAdvanced] = useState(false); + const cursorPreviewUrls = builtInCursorPreviewUrls; + const showDevMotionControls = import.meta.env.DEV; + const cursorStyleOptions = BUILTIN_CURSOR_STYLE_OPTIONS; + + useEffect(() => { + let cancelled = false; + + void (async () => { + try { + const macosPreview = cursorSetAssets.macos.arrow.url; + const tahoePreview = cursorSetAssets.tahoe.arrow.url; + const windows11Preview = cursorSetAssets.windows11.arrow.url; + const minimalPreview = await createTrimmedSvgPreview(minimalCursorUrl, 512); + const invertedPreview = await createInvertedPreview(tahoePreview); + + if (!cancelled) { + setBuiltInCursorPreviewUrls({ + macos: macosPreview, + tahoe: tahoePreview, + windows11: windows11Preview, + figma: minimalPreview, + "tahoe-inverted": invertedPreview, + }); + } + } catch { + if (!cancelled) { + setBuiltInCursorPreviewUrls({ + macos: tahoeCursorUrl, + tahoe: tahoeCursorUrl, + windows11: tahoeCursorUrl, + figma: minimalCursorUrl, + "tahoe-inverted": tahoeCursorUrl, + }); + } + } + })(); + + return () => { + cancelled = true; + }; + }, []); + + useEffect(() => { + setBackgroundTab(getBackgroundTabForWallpaper(selected)); + + if (isHexWallpaper(selected)) { + setSelectedColor(selected); + } + + if (GRADIENTS.includes(selected)) { + setGradient(selected); + } + }, [selected]); + + useEffect(() => { + if (selected.startsWith("data:image")) { + setCustomImages((prev) => (prev.includes(selected) ? prev : [selected, ...prev])); + return; + } + + const isKnownWallpaper = + builtInWallpaperPaths.includes(selected) || wallpaperPreviewPaths.includes(selected); + + if (!isKnownWallpaper && isVideoWallpaperSource(selected)) { + setCustomImages((prev) => (prev.includes(selected) ? prev : [selected, ...prev])); + } + }, [builtInWallpaperPaths, selected, wallpaperPreviewPaths]); + + const imageWallpaperTiles = useMemo<WallpaperTile[]>(() => { + const imageWallpapers = builtInWallpapers.filter( + (wallpaper) => !isVideoWallpaperSource(wallpaper.publicPath), + ); + const builtInTiles = ( + wallpaperPreviewPaths.length > 0 ? wallpaperPreviewPaths : builtInWallpaperPaths + ) + .filter((path) => !isVideoWallpaperSource(path)) + .map((previewPath, index) => { + const wallpaper = imageWallpapers[index]; + return { + key: wallpaper ? `builtin/${wallpaper.id}` : previewPath, + label: wallpaper?.label ?? `Wallpaper ${index + 1}`, + value: wallpaper?.publicPath ?? previewPath, + previewUrl: previewPath, + }; + }); + + return builtInTiles; + }, [builtInWallpaperPaths, builtInWallpapers, wallpaperPreviewPaths]); + + const videoWallpaperTiles = useMemo<WallpaperTile[]>(() => { + const builtInTiles = builtInWallpapers + .filter((wallpaper) => isVideoWallpaperSource(wallpaper.publicPath)) + .map((wallpaper) => ({ + key: `builtin/${wallpaper.id}`, + label: wallpaper.label, + value: wallpaper.publicPath, + previewUrl: wallpaper.publicPath, + })); + + return builtInTiles; + }, [builtInWallpapers]); + + useEffect(() => { + saveEditorPreferences({ customWallpapers: customImages }); + }, [customImages]); + + const handleRemoveBackgroundToggle = (checked: boolean) => { + if (checked) { + removeBackgroundStateRef.current = { + aspectRatio, + padding, + }; + onAspectRatioChange?.("native"); + onPaddingChange?.({ top: 0, bottom: 0, left: 0, right: 0, linked: padding.linked }); + return; + } + + const previousState = removeBackgroundStateRef.current; + if (previousState) { + onAspectRatioChange?.(previousState.aspectRatio); + onPaddingChange?.(previousState.padding); + removeBackgroundStateRef.current = null; + return; + } + + // Fallback if the project loaded in a "background removed" state already + onAspectRatioChange?.(initialEditorPreferences.aspectRatio); + onPaddingChange?.({ ...DEFAULT_PADDING }); + }; + + const togglePaddingLink = () => { + const isLinked = padding.linked !== false; + const nextLinked = !isLinked; + if (nextLinked) { + // Compute average for relinking to avoid sudden shifts + const avg = Math.round( + (padding.top + padding.bottom + padding.left + padding.right) / 4, + ); + onPaddingChange?.({ + top: avg, + bottom: avg, + left: avg, + right: avg, + linked: true, + }); + } else { + onPaddingChange?.({ + ...padding, + linked: false, + }); + } + }; + + const handlePaddingSideChange = (side: keyof Padding, value: number) => { + if (padding.linked !== false) { + onPaddingChange?.({ + top: value, + bottom: value, + left: value, + right: value, + linked: true, + }); + } else { + onPaddingChange?.({ + ...padding, + [side]: value, + }); + } + }; + + const webcamFileName = webcam?.sourcePath?.split(/[\\/]/).pop() ?? null; + const visibleColorPalette = colorPalette.slice(0, 15); + const webcamPositionPreset = webcam?.positionPreset ?? DEFAULT_WEBCAM_POSITION_PRESET; + const webcamPositionX = webcam?.positionX ?? DEFAULT_WEBCAM_POSITION_X; + const webcamPositionY = webcam?.positionY ?? DEFAULT_WEBCAM_POSITION_Y; + const webcamWidth = webcam?.width ?? webcam?.size ?? DEFAULT_WEBCAM_SIZE; + const webcamHeight = webcam?.height ?? webcam?.size ?? DEFAULT_WEBCAM_SIZE; + const webcamCrop = normalizeWebcamCropRegion(webcam?.cropRegion); + + const getWallpaperTileState = (candidateValue: string, previewPath?: string) => { + if (!selected) return false; + if (selected === candidateValue || (previewPath && selected === previewPath)) return true; + try { + const clean = (s: string) => s.replace(/^file:\/\//, "").replace(/^\//, ""); + if (clean(selected).endsWith(clean(candidateValue))) return true; + if (clean(candidateValue).endsWith(clean(selected))) return true; + if (previewPath && clean(selected).endsWith(clean(previewPath))) return true; + if (previewPath && clean(previewPath).endsWith(clean(selected))) return true; + } catch { + return false; + } + return false; + }; + + const wallpaperTileClass = (isSelected: boolean) => + cn( + "group relative aspect-square w-full overflow-hidden rounded-[10px] border bg-editor-bg transition-colors duration-150", + isSelected + ? "border-[#2563EB] bg-foreground/[0.08]" + : "border-foreground/10 bg-foreground/[0.045] hover:border-foreground/20 hover:bg-foreground/[0.07]", + ); + + const renderWallpaperImageTile = ( + wallpaperUrl: string, + isSelected: boolean, + props?: { + key?: string; + ariaLabel?: string; + title?: string; + onClick?: () => void; + children?: React.ReactNode; + }, + ) => ( + <div + key={props?.key} + className={wallpaperTileClass(isSelected)} + aria-label={props?.ariaLabel} + title={props?.title} + onClick={props?.onClick} + role="button" + > + <div className="absolute inset-[1px] overflow-hidden rounded-[8px] bg-editor-dialog"> + {isVideoWallpaperSource(wallpaperUrl) ? ( + <WallpaperVideoPreview src={wallpaperUrl} /> + ) : ( + <img + src={wallpaperUrl} + alt={ + props?.title ?? + props?.ariaLabel ?? + tSettings("background.wallpaperPreview", "Wallpaper preview") + } + className="h-full w-full select-none object-cover [transform:translateZ(0)]" + draggable={false} + /> + )} + </div> + {props?.children} + </div> + ); + + const crop = cropRegion ?? { + x: 0, + y: 0, + width: 1, + height: 1, + }; + const cropTop = Math.round(crop.y * 100); + const cropLeft = Math.round(crop.x * 100); + const cropBottom = Math.round((1 - crop.y - crop.height) * 100); + const cropRight = Math.round((1 - crop.x - crop.width) * 100); + const isCropped = cropTop > 0 || cropLeft > 0 || cropBottom > 0 || cropRight > 0; + + const setCropInset = (side: "top" | "bottom" | "left" | "right", pct: number) => { + if (!onCropChange) return; + + const v = pct / 100; + let { x, y, width, height } = crop; + + if (side === "top") { + const nextY = Math.min(v, 1 - y - height + v); + y = nextY; + height = Math.max(0.05, height - (nextY - crop.y)); + } + + if (side === "left") { + const nextX = Math.min(v, 1 - x - width + v); + x = nextX; + width = Math.max(0.05, width - (nextX - crop.x)); + } + + if (side === "bottom") { + height = Math.max(0.05, 1 - crop.y - v); + } + + if (side === "right") { + width = Math.max(0.05, 1 - crop.x - v); + } + + onCropChange({ x, y, width, height }); + }; + + const resetBackgroundSection = () => { + onBackgroundBlurChange?.(initialEditorPreferences.backgroundBlur); + + const preferredWallpaper = initialEditorPreferences.wallpaper; + const hasPreferredWallpaper = + (preferredWallpaper && builtInWallpaperPaths.includes(preferredWallpaper)) || + (preferredWallpaper && customImages.includes(preferredWallpaper)) || + (preferredWallpaper && isHexWallpaper(preferredWallpaper)) || + (preferredWallpaper && GRADIENTS.includes(preferredWallpaper)); + + onWallpaperChange( + (hasPreferredWallpaper ? preferredWallpaper : "") || + builtInWallpaperPaths[0] || + BUILT_IN_WALLPAPERS[0]?.publicPath || + "", + ); + }; + + const resetZoomSection = () => { + onCameraSpringStiffnessMultiplierChange?.( + initialEditorPreferences.cameraSpringStiffnessMultiplier, + ); + onCameraSpringDampingMultiplierChange?.( + initialEditorPreferences.cameraSpringDampingMultiplier, + ); + onCameraSpringMassMultiplierChange?.(initialEditorPreferences.cameraSpringMassMultiplier); + onZoomInDurationMsChange?.(initialEditorPreferences.zoomInDurationMs); + onZoomOutDurationMsChange?.(initialEditorPreferences.zoomOutDurationMs); + onZoomClassicModeChange?.(false); + }; + + const resetCursorSection = () => { + onShowCursorChange?.(initialEditorPreferences.showCursor); + onLoopCursorChange?.(initialEditorPreferences.loopCursor); + onCursorStyleChange?.(initialEditorPreferences.cursorStyle); + onCursorSizeChange?.(initialEditorPreferences.cursorSize); + onCursorSmoothingChange?.(initialEditorPreferences.cursorSmoothing); + onCursorSpringStiffnessMultiplierChange?.( + initialEditorPreferences.cursorSpringStiffnessMultiplier, + ); + onCursorSpringDampingMultiplierChange?.( + initialEditorPreferences.cursorSpringDampingMultiplier, + ); + onCursorSpringMassMultiplierChange?.(initialEditorPreferences.cursorSpringMassMultiplier); + onCursorClickEffectChange?.(initialEditorPreferences.cursorClickEffect); + onCursorClickEffectColorChange?.(initialEditorPreferences.cursorClickEffectColor); + onCursorClickEffectScaleChange?.(initialEditorPreferences.cursorClickEffectScale); + onCursorClickEffectOpacityChange?.(initialEditorPreferences.cursorClickEffectOpacity); + onCursorClickEffectDurationMsChange?.(initialEditorPreferences.cursorClickEffectDurationMs); + onCursorClickBounceChange?.(initialEditorPreferences.cursorClickBounce); + onCursorClickBounceDurationChange?.(initialEditorPreferences.cursorClickBounceDuration); + onCursorSwayChange?.(initialEditorPreferences.cursorSway); + }; + + const activeMotionPresetId = useMemo(() => { + return ( + getMatchingCursorMotionPresetId({ + zoomInDurationMs, + zoomOutDurationMs, + cursorSize, + cursorSmoothing, + cursorSpringStiffnessMultiplier, + cursorSpringDampingMultiplier, + cursorSpringMassMultiplier, + cursorClickBounce, + cursorClickBounceDuration, + }) ?? "focused" + ); + }, [ + cursorClickBounce, + cursorClickBounceDuration, + cursorSize, + cursorSmoothing, + cursorSpringDampingMultiplier, + cursorSpringMassMultiplier, + cursorSpringStiffnessMultiplier, + zoomInDurationMs, + zoomOutDurationMs, + ]); + + const applyMotionPreset = (presetId: CursorMotionPresetId) => { + const preset = CURSOR_MOTION_PRESETS[presetId]; + onZoomInDurationMsChange?.(preset.zoomInDurationMs); + onZoomOutDurationMsChange?.(preset.zoomOutDurationMs); + onCursorSizeChange?.(preset.cursorSize); + onCursorSmoothingChange?.(preset.cursorSmoothing); + onCursorSpringStiffnessMultiplierChange?.(preset.cursorSpringStiffnessMultiplier); + onCursorSpringDampingMultiplierChange?.(preset.cursorSpringDampingMultiplier); + onCursorSpringMassMultiplierChange?.(preset.cursorSpringMassMultiplier); + onCursorClickBounceChange?.(preset.cursorClickBounce); + onCursorClickBounceDurationChange?.(preset.cursorClickBounceDuration); + }; + + const resetFrameSection = () => { + onShadowChange?.(initialEditorPreferences.shadowIntensity); + onBorderRadiusChange?.(initialEditorPreferences.borderRadius); + onAspectRatioChange?.(initialEditorPreferences.aspectRatio); + onPaddingChange?.({ ...initialEditorPreferences.padding }); + removeBackgroundStateRef.current = null; + }; + + const resetWebcamSection = () => { + if (!onWebcamChange) return; + onWebcamChange({ ...defaultWebcam }); + }; + + const resetCropSection = () => { + onCropChange?.(DEFAULT_CROP_REGION); + }; + + const updateWebcam = (patch: Partial<WebcamOverlaySettings>) => { + if (!webcam || !onWebcamChange) return; + onWebcamChange({ ...webcam, ...patch }); + }; + + const applyWebcamPositionPreset = (preset: WebcamPositionPreset) => { + if (!webcam) return; + + if (preset === "custom") { + updateWebcam({ positionPreset: "custom" }); + return; + } + + const position = getWebcamPositionForPreset(preset); + updateWebcam({ + positionPreset: preset, + positionX: position.x, + positionY: position.y, + corner: resolveWebcamCorner(preset, webcam.corner), + }); + }; + + const handleImageUpload = (event: React.ChangeEvent<HTMLInputElement>) => { + const files = event.target.files; + if (!files || files.length === 0) return; + + const file = files[0]; + + // Validate file type - only allow JPG/JPEG + const validTypes = ["image/jpeg", "image/jpg"]; + if (!validTypes.includes(file.type)) { + toast.error(tSettings("background.uploadError"), { + description: tSettings("background.uploadErrorDescription"), + }); + event.target.value = ""; + return; + } + + const reader = new FileReader(); + + reader.onload = (e) => { + const dataUrl = e.target?.result as string; + if (dataUrl) { + setCustomImages((prev) => [...prev, dataUrl]); + onWallpaperChange(dataUrl); + toast.success(tSettings("background.uploadSuccess")); + } + }; + + reader.onerror = () => { + toast.error(t("common.errors.failedToUploadImage"), { + description: t("common.errors.fileReadError"), + }); + }; + + reader.readAsDataURL(file); + // Reset input so the same file can be selected again + event.target.value = ""; + }; + + const handleVideoUpload = async () => { + try { + const result = await window.electronAPI.openVideoFilePicker(); + if (!result?.success || !result.path) return; + const filePath = result.path; + if (!isVideoWallpaperSource(filePath)) { + toast.error("Unsupported format", { + description: "Please select a video file (mp4, webm, mov, etc.)", + }); + return; + } + setCustomImages((prev) => [filePath, ...prev]); + onWallpaperChange(filePath); + toast.success("Video background added"); + } catch { + toast.error("Failed to import video background"); + } + }; + + const handleRemoveCustomImage = (imageUrl: string, event: React.MouseEvent) => { + event.stopPropagation(); + setCustomImages((prev) => prev.filter((img) => img !== imageUrl)); + // If the removed image was selected, clear selection + if (selected === imageUrl) { + onWallpaperChange(builtInWallpaperPaths[0] ?? BUILT_IN_WALLPAPERS[0]?.publicPath ?? ""); + } + }; + + // Find selected annotation + const selectedAnnotation = selectedAnnotationId + ? annotationRegions.find((a) => a.id === selectedAnnotationId) + : null; + + const backgroundSettingsContent = ( + <div className="space-y-4"> + <section className="flex flex-col gap-2"> + <div className="flex items-center justify-between gap-3"> + <SectionLabel>{tSettings("background.title")}</SectionLabel> + <button + type="button" + onClick={resetBackgroundSection} + className="text-[10px] text-[#2563EB] transition-opacity hover:opacity-80" + > + {t("common.actions.reset", "Reset")} + </button> + </div> + <SliderControl + label={tSettings("effects.backgroundBlur")} + value={backgroundBlur} + defaultValue={initialEditorPreferences.backgroundBlur} + min={0} + max={8} + step={0.25} + onChange={(v) => onBackgroundBlurChange?.(v)} + formatValue={(v) => `${v.toFixed(1)}px`} + parseInput={(text) => parseFloat(text.replace(/px$/, ""))} + /> + </section> + + <div className="w-full"> + <LayoutGroup id="background-picker-switcher"> + <div className="grid h-8 w-full grid-cols-4 rounded-xl border border-foreground/10 bg-foreground/[0.04] p-1"> + {( + [ + { value: "image", label: tSettings("background.image") }, + { value: "video", label: tSettings("background.video", "Video") }, + { value: "color", label: tSettings("background.color") }, + { value: "gradient", label: tSettings("background.gradient") }, + ] as const + ).map((option) => { + const isActive = backgroundTab === option.value; + return ( + <button + key={option.value} + type="button" + onClick={() => setBackgroundTab(option.value)} + className="relative rounded-lg text-[10px] font-semibold tracking-wide transition-colors" + > + {isActive ? ( + <motion.span + layoutId="background-picker-pill" + className="absolute inset-0 rounded-lg bg-[#2563EB]" + transition={{ + type: "spring", + stiffness: 420, + damping: 34, + }} + /> + ) : null} + <span + className={cn( + "relative z-10", + isActive + ? "text-white" + : "text-muted-foreground hover:text-foreground", + )} + > + {option.label} + </span> + </button> + ); + })} + </div> + </LayoutGroup> + + <div className="pt-2"> + <AnimatePresence mode="wait" initial={false}> + <motion.div + key={backgroundTab} + initial={{ opacity: 0, y: 10, filter: "blur(8px)" }} + animate={{ opacity: 1, y: 0, filter: "blur(0px)" }} + exit={{ opacity: 0, y: -8, filter: "blur(6px)" }} + transition={{ duration: 0.2, ease: "easeOut" }} + > + {backgroundTab === "image" ? ( + <div className="mt-0 space-y-2"> + <input + type="file" + ref={fileInputRef} + onChange={handleImageUpload} + accept=".jpg,.jpeg,image/jpeg" + className="hidden" + /> + <Button + onClick={() => fileInputRef.current?.click()} + variant="outline" + className="w-full gap-2 bg-foreground/5 text-foreground border-foreground/10 hover:bg-[#2563EB] hover:text-white hover:border-[#2563EB] transition-all h-7 text-[10px]" + > + <Upload className="w-3 h-3" /> + {tSettings("background.uploadCustom")} + </Button> + + <div className="grid grid-cols-8 gap-1.5"> + {customImages.map((imageUrl, idx) => { + const isSelected = getWallpaperTileState(imageUrl); + return renderWallpaperImageTile(imageUrl, isSelected, { + key: `custom-${idx}`, + ariaLabel: isVideoWallpaperSource(imageUrl) + ? (imageUrl.split(/[\\/]/).pop() ?? + tSettings( + "background.video", + "Video background", + )) + : undefined, + title: isVideoWallpaperSource(imageUrl) + ? imageUrl.split(/[\\/]/).pop() + : undefined, + onClick: () => onWallpaperChange(imageUrl), + children: ( + <button + onClick={(e) => + handleRemoveCustomImage(imageUrl, e) + } + className="absolute top-0.5 right-0.5 w-3 h-3 bg-red-500/90 hover:bg-red-500 rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity z-10" + > + <X className="w-2 h-2 text-white" /> + </button> + ), + }); + })} + + {imageWallpaperTiles.map((tile) => { + const isSelected = getWallpaperTileState( + tile.value, + tile.previewUrl, + ); + return renderWallpaperImageTile( + tile.previewUrl, + isSelected, + { + key: tile.key, + ariaLabel: tile.label, + title: tile.label, + onClick: () => onWallpaperChange(tile.value), + }, + ); + })} + </div> + </div> + ) : backgroundTab === "video" ? ( + <div className="mt-0 space-y-2"> + <Button + onClick={handleVideoUpload} + variant="outline" + className="w-full gap-2 bg-foreground/5 text-foreground border-foreground/10 hover:bg-[#2563EB] hover:text-white hover:border-[#2563EB] transition-all h-7 text-[10px]" + > + <Upload className="w-3 h-3" /> + {tSettings("background.uploadCustomVideo", "Upload Video")} + </Button> + + <div className="grid grid-cols-8 gap-1.5"> + {customImages + .filter(isVideoWallpaperSource) + .map((videoUrl, idx) => { + const isSelected = getWallpaperTileState(videoUrl); + return renderWallpaperImageTile( + videoUrl, + isSelected, + { + key: `custom-video-${idx}`, + ariaLabel: + videoUrl.split(/[\\/]/).pop() ?? + "Video background", + title: videoUrl.split(/[\\/]/).pop(), + onClick: () => onWallpaperChange(videoUrl), + children: ( + <button + onClick={(e) => + handleRemoveCustomImage( + videoUrl, + e, + ) + } + className="absolute top-0.5 right-0.5 w-3 h-3 bg-red-500/90 hover:bg-red-500 rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity z-10" + > + <X className="w-2 h-2 text-white" /> + </button> + ), + }, + ); + })} + + {videoWallpaperTiles.map((wallpaper) => { + const isSelected = getWallpaperTileState( + wallpaper.value, + wallpaper.previewUrl, + ); + return renderWallpaperImageTile( + wallpaper.previewUrl, + isSelected, + { + key: wallpaper.key, + ariaLabel: wallpaper.label, + title: wallpaper.label, + onClick: () => + onWallpaperChange(wallpaper.value), + }, + ); + })} + </div> + </div> + ) : backgroundTab === "color" ? ( + <div className="mt-0 space-y-2"> + <input + ref={customColorInputRef} + type="color" + value={selectedColor} + onChange={(event) => { + setSelectedColor(event.target.value); + onWallpaperChange(event.target.value); + }} + className="sr-only" + /> + <div className="grid grid-cols-8 gap-1.5"> + {visibleColorPalette.map((color) => { + const isSelected = + selected.toLowerCase() === color.toLowerCase(); + return ( + <button + key={color} + type="button" + onClick={() => { + setSelectedColor(color); + onWallpaperChange(color); + }} + className={wallpaperTileClass(isSelected)} + style={{ background: color }} + aria-label={`Color ${color}`} + /> + ); + })} + <button + type="button" + onClick={() => customColorInputRef.current?.click()} + className={wallpaperTileClass( + isHexWallpaper(selected) && + !visibleColorPalette.some( + (color) => + color.toLowerCase() === + selected.toLowerCase(), + ), + )} + style={{ + background: `linear-gradient(135deg, ${selectedColor} 0%, ${selectedColor} 58%, rgba(255,255,255,0.92) 58%, rgba(255,255,255,0.92) 100%)`, + }} + aria-label="Custom color picker" + > + <div className="absolute inset-0 flex items-center justify-center text-[9px] font-semibold uppercase tracking-[0.18em] text-foreground/90"> + Pick + </div> + </button> + </div> + </div> + ) : ( + <div className="mt-0 grid grid-cols-8 gap-1.5"> + {GRADIENTS.map((g, idx) => ( + <div + key={g} + className={wallpaperTileClass(gradient === g)} + aria-label={`Gradient ${idx + 1}`} + onClick={() => { + setGradient(g); + onWallpaperChange(g); + }} + role="button" + > + <div + className="absolute inset-[1px] overflow-hidden rounded-[8px]" + style={{ background: g }} + /> + </div> + ))} + </div> + )} + </motion.div> + </AnimatePresence> + </div> + </div> + </div> + ); + + // If an annotation is selected, show annotation settings instead + if ( + !isBackgroundPanel && + selectedAnnotation && + onAnnotationContentChange && + onAnnotationTypeChange && + onAnnotationStyleChange && + onAnnotationDelete + ) { + return ( + <AnnotationSettingsPanel + annotation={selectedAnnotation} + onContentChange={(content) => + onAnnotationContentChange(selectedAnnotation.id, content) + } + onTypeChange={(type) => onAnnotationTypeChange(selectedAnnotation.id, type)} + onStyleChange={(style) => onAnnotationStyleChange(selectedAnnotation.id, style)} + onFigureDataChange={ + onAnnotationFigureDataChange + ? (figureData) => + onAnnotationFigureDataChange(selectedAnnotation.id, figureData) + : undefined + } + onBlurIntensityChange={ + onAnnotationBlurIntensityChange + ? (intensity) => + onAnnotationBlurIntensityChange(selectedAnnotation.id, intensity) + : undefined + } + onBlurColorChange={ + onAnnotationBlurColorChange + ? (color) => onAnnotationBlurColorChange(selectedAnnotation.id, color) + : undefined + } + onDelete={() => onAnnotationDelete(selectedAnnotation.id)} + /> + ); + } + + if (isBackgroundPanel) { + return ( + <div className="flex-[2] w-[332px] min-w-[280px] max-w-[332px] bg-editor-panel rounded-2xl flex flex-col shadow-xl h-full overflow-hidden"> + <div + className="flex-1 min-h-0 overflow-y-auto custom-scrollbar p-4 pb-0" + style={{ scrollbarGutter: "stable" }} + > + <div className="mb-4 flex items-center gap-2"> + <Palette className="w-4 h-4 text-[#2563EB]" /> + <span className="text-sm font-medium text-foreground"> + {tSettings("background.title")} + </span> + </div> + {backgroundSettingsContent} + </div> + </div> + ); + } + + const frameSectionContent = ( + <section className="flex flex-col gap-2"> + <div className="flex items-center justify-between gap-3"> + <SectionLabel>{tSettings("sections.frame", "Frame")}</SectionLabel> + <button + type="button" + onClick={resetFrameSection} + className="text-[10px] text-[#2563EB] transition-opacity hover:opacity-80" + > + {t("common.actions.reset", "Reset")} + </button> + </div> + <div className="flex flex-col gap-1.5"> + <SliderControl + label={tSettings("effects.shadow")} + value={shadowIntensity} + defaultValue={initialEditorPreferences.shadowIntensity} + min={0} + max={1} + step={0.01} + onChange={(v) => onShadowChange?.(v)} + formatValue={(v) => `${Math.round(v * 100)}%`} + parseInput={(text) => parseFloat(text.replace(/%$/, "")) / 100} + /> + <SliderControl + label={tSettings("effects.radius", "Radius")} + value={borderRadius} + defaultValue={initialEditorPreferences.borderRadius} + min={0} + max={200} + step={0.5} + onChange={(v) => onBorderRadiusChange?.(v)} + formatValue={(v) => `${v}px`} + parseInput={(text) => parseFloat(text.replace(/px$/, ""))} + /> + <div className="flex flex-col gap-1.5 pt-0.5"> + <div className="flex items-center justify-between"> + <SectionLabel>{tSettings("effects.padding")}</SectionLabel> + <button + type="button" + onClick={togglePaddingLink} + aria-pressed={padding.linked === false} + className="text-[10px] text-[#2563EB] transition-opacity hover:opacity-80" + title={ + padding.linked === false + ? tSettings( + "effects.paddingAdvancedHide", + "Hide advanced padding controls", + ) + : tSettings( + "effects.paddingAdvancedShow", + "Show advanced padding controls", + ) + } + > + {tSettings("effects.paddingAdvanced", "Advanced")} + </button> + </div> + + {padding.linked !== false ? ( + <SliderControl + label="" + value={padding.top} + defaultValue={DEFAULT_PADDING.top} + min={0} + max={100} + step={1} + onChange={(v) => handlePaddingSideChange("top", v)} + formatValue={(v) => `${v}%`} + parseInput={(text) => parseFloat(text.replace(/%$/, ""))} + /> + ) : ( + <div className="grid grid-cols-2 gap-x-3 gap-y-1.5"> + <SliderControl + label={tSettings("effects.paddingTop", "Top")} + value={padding.top} + defaultValue={DEFAULT_PADDING.top} + min={0} + max={ADVANCED_VERTICAL_PADDING_MAX} + step={1} + onChange={(v) => handlePaddingSideChange("top", v)} + formatValue={(v) => `${v}%`} + parseInput={(text) => parseFloat(text.replace(/%$/, ""))} + /> + <SliderControl + label={tSettings("effects.paddingBottom", "Bottom")} + value={padding.bottom} + defaultValue={DEFAULT_PADDING.bottom} + min={0} + max={ADVANCED_VERTICAL_PADDING_MAX} + step={1} + onChange={(v) => handlePaddingSideChange("bottom", v)} + formatValue={(v) => `${v}%`} + parseInput={(text) => parseFloat(text.replace(/%$/, ""))} + /> + <SliderControl + label={tSettings("effects.paddingLeft", "Left")} + value={padding.left} + defaultValue={DEFAULT_PADDING.left} + min={0} + max={100} + step={1} + onChange={(v) => handlePaddingSideChange("left", v)} + formatValue={(v) => `${v}%`} + parseInput={(text) => parseFloat(text.replace(/%$/, ""))} + /> + <SliderControl + label={tSettings("effects.paddingRight", "Right")} + value={padding.right} + defaultValue={DEFAULT_PADDING.right} + min={0} + max={100} + step={1} + onChange={(v) => handlePaddingSideChange("right", v)} + formatValue={(v) => `${v}%`} + parseInput={(text) => parseFloat(text.replace(/%$/, ""))} + /> + </div> + )} + </div> + <div className="flex items-center justify-between rounded-lg bg-foreground/[0.03] px-2.5 py-1.5"> + <span className="text-[10px] text-muted-foreground"> + {tSettings("effects.removeBackground")} + </span> + <Switch + checked={removeBackgroundEnabled} + onCheckedChange={handleRemoveBackgroundToggle} + className="data-[state=checked]:bg-[#2563EB] scale-75" + /> + </div> + </div> + </section> + ); + + const cropSectionContent = ( + <section className="flex flex-col gap-2"> + <div className="flex items-center justify-between gap-3"> + <SectionLabel>{tSettings("sections.crop", "Crop")}</SectionLabel> + {isCropped ? ( + <button + type="button" + onClick={resetCropSection} + className="text-[10px] text-[#2563EB] transition-opacity hover:opacity-80" + > + {t("common.actions.reset", "Reset")} + </button> + ) : null} + </div> + <div className="flex flex-col gap-1.5"> + <SliderControl + label={tSettings("crop.top", "Top")} + value={cropTop} + defaultValue={0} + min={0} + max={50} + step={1} + onChange={(v) => setCropInset("top", v)} + formatValue={(v) => `${Math.round(v)}%`} + parseInput={(text) => parseFloat(text.replace(/%$/, ""))} + /> + <SliderControl + label={tSettings("crop.bottom", "Bottom")} + value={cropBottom} + defaultValue={0} + min={0} + max={50} + step={1} + onChange={(v) => setCropInset("bottom", v)} + formatValue={(v) => `${Math.round(v)}%`} + parseInput={(text) => parseFloat(text.replace(/%$/, ""))} + /> + <SliderControl + label={tSettings("crop.left", "Left")} + value={cropLeft} + defaultValue={0} + min={0} + max={50} + step={1} + onChange={(v) => setCropInset("left", v)} + formatValue={(v) => `${Math.round(v)}%`} + parseInput={(text) => parseFloat(text.replace(/%$/, ""))} + /> + <SliderControl + label={tSettings("crop.right", "Right")} + value={cropRight} + defaultValue={0} + min={0} + max={50} + step={1} + onChange={(v) => setCropInset("right", v)} + formatValue={(v) => `${Math.round(v)}%`} + parseInput={(text) => parseFloat(text.replace(/%$/, ""))} + /> + </div> + </section> + ); + + const captionsSectionContent = ( + <section className="flex flex-col gap-2"> + <div className="flex items-center justify-between gap-3"> + <div className="flex items-center gap-3"> + <SectionLabel>{tSettings("sections.captions", "Captions")}</SectionLabel> + <button + type="button" + onClick={() => onAutoCaptionSettingsChange?.(DEFAULT_AUTO_CAPTION_SETTINGS)} + className="text-[10px] text-[#2563EB] transition-opacity hover:opacity-80" + > + {t("common.actions.reset", "Reset")} + </button> + </div> + <div className="flex items-center gap-2 text-[10px] text-muted-foreground"> + <span>{tSettings("captions.enabled", "Show")}</span> + <Switch + checked={autoCaptionSettings.enabled} + onCheckedChange={(enabled) => updateAutoCaptionSettings({ enabled })} + className="data-[state=checked]:bg-[#2563EB] scale-75" + /> + </div> + </div> + + <div className="rounded-lg bg-foreground/[0.03] px-2.5 py-2 space-y-3"> + <div> + <Button + type="button" + variant="outline" + onClick={onPickWhisperModel} + className="h-10 w-full rounded-xl border-foreground/10 bg-foreground/5 px-4 text-sm text-foreground hover:bg-foreground/10 hover:text-foreground" + > + {tSettings("captions.selectModel", "Select Model")} + </Button> + </div> + <div className="flex items-center justify-between gap-3"> + <div className="text-sm font-medium text-foreground"> + {tSettings("captions.language", "Language")} + </div> + <Select + value={autoCaptionSettings.language || "auto"} + onValueChange={(value) => updateAutoCaptionSettings({ language: value })} + > + <SelectTrigger className="h-10 w-[180px] rounded-xl border-foreground/10 bg-foreground/5 text-sm text-foreground hover:bg-foreground/10"> + <SelectValue /> + </SelectTrigger> + <SelectContent className="border-foreground/10 bg-editor-surface-alt text-foreground"> + {CAPTION_LANGUAGE_OPTIONS.map((option) => ( + <SelectItem key={option.value} value={option.value}> + {option.label} + </SelectItem> + ))} + </SelectContent> + </Select> + </div> + <div className="flex flex-wrap items-center gap-2"> + <div className="grid w-full grid-cols-2 gap-2"> + {whisperModelDownloadStatus === "downloading" ? ( + <Button + type="button" + disabled + className="h-10 w-full rounded-xl bg-foreground/10 px-4 text-sm font-medium text-foreground hover:bg-foreground/10" + > + {tSettings("captions.downloading", "Downloading...")}{" "} + {Math.round(whisperModelDownloadProgress)}% + </Button> + ) : whisperModelPath ? ( + <Button + type="button" + variant="outline" + onClick={onDeleteWhisperSmallModel} + className="h-10 w-full rounded-xl border-foreground/10 bg-foreground/5 px-4 text-sm text-foreground hover:bg-foreground/10 hover:text-foreground" + > + {tSettings("captions.deleteModel", "Delete Model")} + </Button> + ) : ( + <Button + type="button" + onClick={onDownloadWhisperSmallModel} + className="h-10 w-full rounded-xl bg-[#2563EB] px-4 text-sm font-medium text-white hover:bg-[#2563EB]/90" + > + {tSettings("captions.downloadModel", "Download Model")} + </Button> + )} + <Button + type="button" + variant="outline" + onClick={onClearAutoCaptions} + disabled={captionCueCount === 0} + className="h-10 w-full rounded-xl border-foreground/10 bg-foreground/5 px-4 text-sm text-foreground hover:bg-foreground/10 hover:text-foreground disabled:opacity-50" + > + {tSettings("captions.clearFull", "Clear Captions")} + </Button> + </div> + </div> + <div className="flex flex-col gap-2"> + <Button + type="button" + onClick={onGenerateAutoCaptions} + disabled={isGeneratingCaptions || !whisperModelPath} + className="h-10 w-full rounded-xl bg-[#2563EB] px-4 text-sm font-medium text-white hover:bg-[#2563EB]/90 disabled:opacity-60" + > + {isGeneratingCaptions + ? tSettings("captions.generating", "Generating...") + : captionCueCount > 0 + ? tSettings("captions.regenerateFull", "Regenerate Captions") + : tSettings("captions.generateFull", "Generate Captions")} + </Button> + {isGeneratingCaptions ? ( + <div className="space-y-1"> + <div className="text-xs text-muted-foreground"> + {tSettings( + "captions.generatingStatus", + "Generating captions. This can take a moment.", + )} + </div> + <div className="indeterminate-progress h-2 rounded-full bg-foreground/5" /> + </div> + ) : null} + </div> + {whisperModelDownloadStatus === "downloading" ? ( + <div className="h-2 overflow-hidden rounded-full bg-foreground/5"> + <div + className="h-full rounded-full bg-[#2196f3] transition-all" + style={{ width: `${whisperModelDownloadProgress}%` }} + /> + </div> + ) : null} + </div> + + <div className="flex flex-col gap-1.5"> + <div className="flex items-center justify-between gap-3 rounded-lg bg-foreground/[0.03] px-2.5 py-2"> + <div className="text-[10px] text-muted-foreground"> + {tSettings("captions.animation", "Animation")} + </div> + <Select + value={autoCaptionSettings.animationStyle} + onValueChange={(value) => + updateAutoCaptionSettings({ + animationStyle: value as AutoCaptionAnimation, + }) + } + > + <SelectTrigger className="h-9 w-[160px] rounded-xl border-foreground/10 bg-foreground/5 text-sm text-foreground hover:bg-foreground/10"> + <SelectValue /> + </SelectTrigger> + <SelectContent className="border-foreground/10 bg-editor-surface-alt text-foreground"> + {CAPTION_ANIMATION_OPTIONS.map((option) => ( + <SelectItem key={option.value} value={option.value}> + {option.label} + </SelectItem> + ))} + </SelectContent> + </Select> + </div> + <div className="flex items-center justify-between gap-3 rounded-lg bg-foreground/[0.03] px-2.5 py-2"> + <div className="text-[10px] text-muted-foreground"> + {tSettings("captions.timelineQuickAdd", "Hover to add on timeline")} + </div> + <Switch + checked={autoCaptionSettings.timelineQuickAdd} + onCheckedChange={(timelineQuickAdd) => + updateAutoCaptionSettings({ timelineQuickAdd }) + } + aria-label={tSettings( + "captions.timelineQuickAdd", + "Hover to add on timeline", + )} + className="data-[state=checked]:bg-[#2563EB] scale-75" + /> + </div> + <label className="flex items-center justify-between rounded-lg bg-foreground/[0.03] px-2.5 py-2"> + <span className="text-[10px] text-muted-foreground"> + {tSettings("captions.textColor", "Text color")} + </span> + <input + type="color" + value={autoCaptionSettings.textColor} + onChange={(event) => + updateAutoCaptionSettings({ textColor: event.target.value }) + } + className="h-7 w-10 rounded border border-foreground/10 bg-transparent" + /> + </label> + <div className="mb-1 text-sm font-medium text-foreground"> + {tSettings("captions.fontSettings", "Font Settings")} + </div> + <SliderControl + label={tSettings("captions.fontSize", "Font size")} + value={autoCaptionSettings.fontSize} + defaultValue={DEFAULT_AUTO_CAPTION_SETTINGS.fontSize} + min={16} + max={72} + step={1} + onChange={(value) => updateAutoCaptionSettings({ fontSize: value })} + formatValue={(value) => `${Math.round(value)}px`} + parseInput={(text) => parseFloat(text.replace(/px$/, ""))} + /> + <SliderControl + label={tSettings("captions.rowCount", "Rows")} + value={autoCaptionSettings.maxRows} + defaultValue={DEFAULT_AUTO_CAPTION_SETTINGS.maxRows} + min={1} + max={4} + step={1} + onChange={(value) => updateAutoCaptionSettings({ maxRows: Math.round(value) })} + formatValue={(value) => `${Math.round(value)}`} + parseInput={(text) => parseFloat(text)} + /> + <SliderControl + label={tSettings("captions.bottomOffset", "Bottom offset")} + value={autoCaptionSettings.bottomOffset} + defaultValue={DEFAULT_AUTO_CAPTION_SETTINGS.bottomOffset} + min={0} + max={30} + step={1} + onChange={(value) => updateAutoCaptionSettings({ bottomOffset: value })} + formatValue={(value) => `${Math.round(value)}%`} + parseInput={(text) => parseFloat(text.replace(/%$/, ""))} + /> + <SliderControl + label={tSettings("captions.maxWidth", "Max width")} + value={autoCaptionSettings.maxWidth} + defaultValue={DEFAULT_AUTO_CAPTION_SETTINGS.maxWidth} + min={40} + max={95} + step={1} + onChange={(value) => updateAutoCaptionSettings({ maxWidth: value })} + formatValue={(value) => `${Math.round(value)}%`} + parseInput={(text) => parseFloat(text.replace(/%$/, ""))} + /> + <SliderControl + label={tSettings("captions.boxRadius", "Box radius")} + value={autoCaptionSettings.boxRadius} + defaultValue={DEFAULT_AUTO_CAPTION_SETTINGS.boxRadius} + min={0} + max={40} + step={0.5} + onChange={(value) => updateAutoCaptionSettings({ boxRadius: value })} + formatValue={(value) => + `${Number.isInteger(value) ? value.toFixed(0) : value.toFixed(1)}px` + } + parseInput={(text) => parseFloat(text.replace(/px$/, ""))} + /> + <SliderControl + label={tSettings("captions.backgroundOpacity", "Background opacity")} + value={autoCaptionSettings.backgroundOpacity} + defaultValue={DEFAULT_AUTO_CAPTION_SETTINGS.backgroundOpacity} + min={0} + max={1} + step={0.01} + onChange={(value) => updateAutoCaptionSettings({ backgroundOpacity: value })} + formatValue={(value) => `${Math.round(value * 100)}%`} + parseInput={(text) => parseFloat(text.replace(/%$/, "")) / 100} + /> + </div> + </section> + ); + + const effectSectionContent = (() => { + const settingsSectionContent = ( + <div className="space-y-4"> + <section className="flex flex-col gap-2"> + <SectionLabel>{t("editor.theme.appearance", "Appearance")}</SectionLabel> + <div className="flex rounded-lg border border-foreground/10 bg-foreground/5 p-0.5"> + {( + [ + { value: "light", label: t("editor.theme.light", "Light") }, + { value: "dark", label: t("editor.theme.dark", "Dark") }, + { value: "system", label: t("editor.theme.system", "System") }, + ] as const + ).map((option) => ( + <button + key={option.value} + type="button" + onClick={() => setThemePreference(option.value)} + className={cn( + "flex-1 rounded-md px-3 py-1.5 text-xs font-medium transition-all", + themePreference === option.value + ? "bg-neutral-800 text-white shadow-sm dark:bg-white dark:text-black" + : "text-muted-foreground hover:text-foreground", + )} + > + {option.label} + </button> + ))} + </div> + </section> + + <section className="flex flex-col gap-2"> + <SectionLabel>{t("common.app.language", "Language")}</SectionLabel> + <Select value={locale} onValueChange={(value) => setLocale(value as AppLocale)}> + <SelectTrigger className="h-10 w-full rounded-xl border-foreground/10 bg-foreground/5 text-sm text-foreground hover:bg-foreground/10"> + <SelectValue /> + </SelectTrigger> + <SelectContent className="border-foreground/10 bg-editor-surface-alt text-foreground"> + {SUPPORTED_LOCALES.map((candidateLocale) => ( + <SelectItem key={candidateLocale} value={candidateLocale}> + {APP_LANGUAGE_LABELS[candidateLocale]} + </SelectItem> + ))} + </SelectContent> + </Select> + </section> + + <section className="flex flex-col gap-2"> + <SectionLabel>{tSettings("updates.title", "Updates")}</SectionLabel> + <div className="flex items-center justify-between gap-3 rounded-lg bg-foreground/[0.03] px-2.5 py-2"> + <div> + <div className="text-[11px] font-medium text-foreground"> + {tSettings("updates.experimental", "Experimental updates")} + </div> + <div className="mt-0.5 text-[10px] text-muted-foreground/70"> + {tSettings( + "updates.experimentalDescription", + "This is the front line of user testing - highly experimental so expect bugs", + )} + </div> + </div> + <Switch + checked={experimentalUpdatesEnabled} + disabled={savingExperimentalUpdates} + onCheckedChange={(enabled) => + void updateExperimentalUpdatesPreference(enabled) + } + aria-label={tSettings("updates.experimental", "Experimental updates")} + className="data-[state=checked]:bg-[#2563EB] scale-75" + /> + </div> + </section> + + <section className="flex flex-col gap-1.5"> + <div className="flex items-center justify-between gap-3 rounded-lg bg-foreground/[0.03] px-2.5 py-2"> + <div> + <div className="text-[11px] font-medium text-foreground"> + {tSettings( + "effects.autoApplyFreshRecordingZooms", + "Auto-apply fresh recording zooms", + )} + </div> + <div className="mt-0.5 text-[10px] text-muted-foreground/70"> + {tSettings( + "effects.autoApplyFreshRecordingZoomsDescription", + "Suggest cursor-follow zooms automatically when you open a new recording.", + )} + </div> + </div> + <Switch + checked={autoApplyFreshRecordingAutoZooms} + onCheckedChange={onAutoApplyFreshRecordingAutoZoomsChange} + className="data-[state=checked]:bg-[#2563EB] scale-75" + /> + </div> + <div className="flex items-center justify-between gap-3 rounded-lg bg-foreground/[0.03] px-2.5 py-2"> + <div> + <div className="text-[11px] font-medium text-foreground"> + {tSettings("effects.connectZooms", "Connect neighboring zooms")} + </div> + <div className="mt-0.5 text-[10px] text-muted-foreground/70"> + {tSettings( + "effects.connectZoomsDescription", + "Smooth consecutive zoom regions into a continuous camera move.", + )} + </div> + </div> + <Switch + checked={connectZooms} + onCheckedChange={onConnectZoomsChange} + className="data-[state=checked]:bg-[#2563EB] scale-75" + /> + </div> + </section> + + <section className="flex flex-col gap-2"> + <MotionPresetCards + title={tSettings("effects.motionPresetsTitle", "Motion Presets")} + activePresetId={activeMotionPresetId} + onApply={applyMotionPreset} + tSettings={tSettings} + /> + </section> + + <section className="flex flex-col gap-2"> + <SectionLabel>{t("editor.keyboardShortcuts.title")}</SectionLabel> + <KeyboardShortcutsDialog + triggerLabel={t("editor.keyboardShortcuts.customize")} + triggerClassName="h-10 w-full justify-start rounded-xl border border-foreground/10 bg-foreground/5 px-3 text-sm text-foreground hover:bg-foreground/10 hover:text-foreground" + /> + </section> + + {showDevMotionControls ? ( + <section className="flex flex-col gap-2 rounded-xl border border-[#2563EB]/15 bg-[#2563EB]/5 p-3"> + <div className="flex items-center justify-between gap-3"> + <div> + <SectionLabel> + {tSettings("effects.devSection", "Dev")} + </SectionLabel> + <div className="mt-0.5 text-[10px] text-muted-foreground"> + {tSettings( + "effects.devSectionHint", + "Temporary testing controls for native capture and motion tuning.", + )} + </div> + </div> + <span className="rounded-full bg-[#2563EB]/10 px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider text-[#2563EB]"> + DEV + </span> + </div> + + <div className="rounded-lg border border-foreground/10 bg-background/60 px-3 py-3"> + <div className="flex items-start justify-between gap-3"> + <div> + <div className="text-[11px] font-medium text-foreground"> + {tSettings( + "effects.nativeCaptureWarningTester", + "Native capture warning", + )} + </div> + <div className="mt-0.5 text-[10px] text-muted-foreground"> + {nativeCaptureUnavailableSession + ? tSettings( + "effects.nativeCaptureWarningTesterUnavailable", + "This project is currently marked as native capture unavailable.", + ) + : tSettings( + "effects.nativeCaptureWarningTesterAvailable", + "This project is not marked as unsupported, but you can still open the modal for UI testing.", + )} + </div> + </div> + <Button + type="button" + variant="outline" + size="sm" + onClick={() => onOpenNativeCaptureUnavailableModal?.()} + className="h-8 shrink-0 border-[#2563EB]/20 bg-[#2563EB]/10 text-[#2563EB] hover:bg-[#2563EB]/15" + > + {tSettings("effects.openNativeCaptureWarning", "Open warning")} + </Button> + </div> + </div> + + <div className="space-y-1.5 rounded-lg border border-foreground/10 bg-background/60 px-3 py-3"> + <div> + <div className="text-[11px] font-medium text-foreground"> + {tSettings("effects.cameraDebugTuning", "Camera Debug Tuning")} + </div> + <div className="mt-0.5 text-[10px] text-muted-foreground"> + {tSettings( + "effects.cameraDebugTuningHint", + "Development-only spring tuning controls for camera motion.", + )} + </div> + </div> + <SliderControl + label={tSettings( + "effects.cameraSpringStiffnessMultiplier", + "Camera stiffness", + )} + value={cameraSpringStiffnessMultiplier} + defaultValue={ + initialEditorPreferences.cameraSpringStiffnessMultiplier + } + min={0.25} + max={3} + step={0.01} + onChange={(value) => + onCameraSpringStiffnessMultiplierChange?.(value) + } + formatValue={(value) => `${value.toFixed(2)}×`} + parseInput={(text) => parseFloat(text.replace(/×$/, ""))} + /> + <SliderControl + label={tSettings( + "effects.cameraSpringDampingMultiplier", + "Camera damping", + )} + value={cameraSpringDampingMultiplier} + defaultValue={ + initialEditorPreferences.cameraSpringDampingMultiplier + } + min={0.25} + max={3} + step={0.01} + onChange={(value) => onCameraSpringDampingMultiplierChange?.(value)} + formatValue={(value) => `${value.toFixed(2)}×`} + parseInput={(text) => parseFloat(text.replace(/×$/, ""))} + /> + <SliderControl + label={tSettings( + "effects.cameraSpringMassMultiplier", + "Camera mass", + )} + value={cameraSpringMassMultiplier} + defaultValue={initialEditorPreferences.cameraSpringMassMultiplier} + min={0.25} + max={3} + step={0.01} + onChange={(value) => onCameraSpringMassMultiplierChange?.(value)} + formatValue={(value) => `${value.toFixed(2)}×`} + parseInput={(text) => parseFloat(text.replace(/×$/, ""))} + /> + </div> + + <div className="space-y-1.5 rounded-lg border border-foreground/10 bg-background/60 px-3 py-3"> + <div> + <div className="text-[11px] font-medium text-foreground"> + {tSettings("effects.cursorDebugTuning", "Cursor Debug Tuning")} + </div> + <div className="mt-0.5 text-[10px] text-muted-foreground"> + {tSettings( + "effects.cursorDebugTuningHint", + "Development-only spring tuning controls.", + )} + </div> + </div> + <SliderControl + label={tSettings( + "effects.cursorSpringStiffnessMultiplier", + "Spring stiffness", + )} + value={cursorSpringStiffnessMultiplier} + defaultValue={ + initialEditorPreferences.cursorSpringStiffnessMultiplier + } + min={0.25} + max={3} + step={0.01} + onChange={(value) => + onCursorSpringStiffnessMultiplierChange?.(value) + } + formatValue={(value) => `${value.toFixed(2)}×`} + parseInput={(text) => parseFloat(text.replace(/×$/, ""))} + /> + <SliderControl + label={tSettings( + "effects.cursorSpringDampingMultiplier", + "Spring damping", + )} + value={cursorSpringDampingMultiplier} + defaultValue={ + initialEditorPreferences.cursorSpringDampingMultiplier + } + min={0.25} + max={3} + step={0.01} + onChange={(value) => onCursorSpringDampingMultiplierChange?.(value)} + formatValue={(value) => `${value.toFixed(2)}×`} + parseInput={(text) => parseFloat(text.replace(/×$/, ""))} + /> + <SliderControl + label={tSettings( + "effects.cursorSpringMassMultiplier", + "Spring mass", + )} + value={cursorSpringMassMultiplier} + defaultValue={initialEditorPreferences.cursorSpringMassMultiplier} + min={0.25} + max={3} + step={0.01} + onChange={(value) => onCursorSpringMassMultiplierChange?.(value)} + formatValue={(value) => `${value.toFixed(2)}×`} + parseInput={(text) => parseFloat(text.replace(/×$/, ""))} + /> + </div> + </section> + ) : null} + </div> + ); + + const sceneSectionContent = ( + <div className="space-y-4"> + {backgroundSettingsContent} + {frameSectionContent} + {cropSectionContent} + </div> + ); + + const zoomItemSectionContent = ( + <section className="flex flex-col gap-2"> + {selectedZoomId && ( + <> + <div className="flex items-center justify-between gap-3"> + <SectionLabel>{tSettings("sections.zoom", "Zoom")}</SectionLabel> + {selectedZoomDepth && ( + <span className="rounded-full bg-[#2563EB]/10 px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider text-[#2563EB]"> + { + ZOOM_DEPTH_OPTIONS.find( + (o) => o.depth === selectedZoomDepth, + )?.label + } + </span> + )} + </div> + <div className="mb-1"> + <div className="flex rounded-lg border border-foreground/10 bg-foreground/5 p-0.5"> + <button + type="button" + onClick={() => onZoomModeChange?.("auto")} + className={cn( + "flex-1 rounded-md px-3 py-1.5 text-xs font-medium transition-all", + selectedZoomMode === "auto" + ? "bg-[#2563EB] text-white shadow-sm" + : "text-muted-foreground hover:text-foreground", + )} + > + {tSettings("zoom.modeAuto", "Auto")} + </button> + <button + type="button" + onClick={() => onZoomModeChange?.("manual")} + className={cn( + "flex-1 rounded-md px-3 py-1.5 text-xs font-medium transition-all", + selectedZoomMode === "manual" + ? "bg-[#2563EB] text-white shadow-sm" + : "text-muted-foreground hover:text-foreground", + )} + > + {tSettings("zoom.modeManual", "Manual")} + </button> + </div> + <p className="mt-1.5 text-[10px] text-muted-foreground/70"> + {selectedZoomMode === "manual" + ? tSettings( + "zoom.modeManualDescription", + "Set a fixed focus point for this zoom", + ) + : tSettings( + "zoom.modeAutoDescription", + "Camera recenters when the cursor nears the edge of the zoomed view", + )} + </p> + </div> + <div className="grid grid-cols-6 gap-1.5"> + {ZOOM_DEPTH_OPTIONS.map((option) => { + const isActive = selectedZoomDepth === option.depth; + return ( + <Button + key={option.depth} + type="button" + onClick={() => onZoomDepthChange?.(option.depth)} + className={cn( + "h-auto w-full rounded-lg border px-1 py-2 text-center shadow-sm transition-all duration-200 ease-out cursor-pointer", + isActive + ? "border-[#2563EB] bg-[#2563EB] text-white" + : "border-foreground/5 bg-foreground/5 text-muted-foreground hover:bg-foreground/10 hover:border-foreground/10 hover:text-foreground", + )} + > + <span className="text-xs font-semibold"> + {option.label} + </span> + </Button> + ); + })} + </div> + <div className="h-px bg-foreground/[0.06] my-1" /> + </> + )} + <div className="flex items-center justify-between gap-3"> + <SectionLabel>{tSettings("zoom.globalSettings", "Animation")}</SectionLabel> + <button + type="button" + onClick={resetZoomSection} + className="text-[10px] text-[#2563EB] transition-opacity hover:opacity-80" + > + {t("common.actions.reset", "Reset")} + </button> + </div> + <div className="flex items-center justify-between rounded-lg bg-foreground/[0.03] px-2.5 py-1.5"> + <span className="text-[10px] text-muted-foreground"> + {tSettings("effects.classicZoom", "Classic Animation")} + </span> + <Switch + checked={zoomClassicMode} + onCheckedChange={(v) => onZoomClassicModeChange?.(v)} + className="data-[state=checked]:bg-[#2563EB] scale-75" + /> + </div> + {!zoomClassicMode && ( + <div className="text-[10px] text-muted-foreground"> + {tSettings( + "effects.motionPresetsZoomHint", + "Zoom motion presets are available in Settings.", + )} + </div> + )} + {selectedZoomId && ( + <Button + onClick={() => { + if (selectedZoomId && onZoomDelete) onZoomDelete(selectedZoomId); + }} + variant="destructive" + size="sm" + className="mt-1 h-8 w-full gap-2 border border-red-500/20 bg-red-500/10 text-xs text-red-400 transition-all hover:border-red-500/30 hover:bg-red-500/20" + > + <Trash2 className="h-3 w-3" /> + {tSettings("zoom.deleteZoom")} + </Button> + )} + </section> + ); + + const audioSectionContent = ( + <section className="flex flex-col gap-3"> + <div className="flex items-center justify-between gap-3"> + <SectionLabel>{tSettings("audio.volumeTitle", "Audio")}</SectionLabel> + <button + type="button" + onClick={() => { + onAudioVolumeChange?.(1); + onAudioNormalizeChange?.(false); + }} + className="text-[10px] text-[#2563EB] transition-opacity hover:opacity-80" + > + {t("common.actions.reset", "Reset")} + </button> + </div> + <SliderControl + label={tSettings("audio.volume", "Volume")} + value={selectedAudioVolume ?? 1} + defaultValue={1} + min={0} + max={1} + step={0.01} + onChange={(v) => onAudioVolumeChange?.(v)} + formatValue={(v) => `${Math.round(v * 100)}%`} + parseInput={(text) => parseFloat(text.replace(/%$/, "")) / 100} + /> + <div className="flex items-center justify-between rounded-lg bg-foreground/[0.03] px-2.5 py-1.5"> + <span className="text-[10px] text-muted-foreground"> + {tSettings("audio.normalize", "Normalize")} + </span> + <Switch + checked={Boolean(selectedAudioNormalize)} + onCheckedChange={(v) => onAudioNormalizeChange?.(v)} + className="data-[state=checked]:bg-[#2563EB] scale-75" + /> + </div> + </section> + ); + + const clipSectionContent = ( + <section className="flex flex-col gap-2"> + <div className="flex items-center justify-between gap-3"> + <SectionLabel>{tSettings("clip.title", "Clip")}</SectionLabel> + {selectedClipSpeed != null && selectedClipSpeed !== 1 && ( + <span className="rounded-full bg-[#06b6d4]/10 px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider text-[#06b6d4]"> + {selectedClipSpeed}× + </span> + )} + </div> + + <div className="flex items-center gap-3"> + <SectionLabel>{tSettings("speed.label", "Speed")}</SectionLabel> + </div> + <div className="grid grid-cols-4 gap-1.5"> + {[ + { speed: 0.25, label: "0.25×" }, + { speed: 0.5, label: "0.5×" }, + { speed: 0.75, label: "0.75×" }, + { speed: 1, label: "1×" }, + { speed: 1.25, label: "1.25×" }, + { speed: 1.5, label: "1.5×" }, + { speed: 2, label: "2×" }, + { speed: 2.5, label: "2.5×" }, + { speed: 3, label: "3×" }, + { speed: 4, label: "4×" }, + { speed: 5, label: "5×" }, + { speed: 8, label: "8×" }, + { speed: 10, label: "10×" }, + { speed: 15, label: "15×" }, + { speed: 20, label: "20×" }, + { speed: 30, label: "30×" }, + ].map((option) => { + const isActive = selectedClipSpeed === option.speed; + return ( + <Button + key={option.speed} + type="button" + onClick={() => onClipSpeedChange?.(option.speed)} + className={cn( + "h-auto w-full rounded-lg border px-0.5 py-2 text-center shadow-sm transition-all duration-200 ease-out cursor-pointer", + isActive + ? "border-[#06b6d4] bg-[#06b6d4] text-white" + : "border-foreground/5 bg-foreground/5 text-muted-foreground hover:bg-foreground/10 hover:border-foreground/10 hover:text-foreground", + )} + > + <span className="text-[10px] font-semibold">{option.label}</span> + </Button> + ); + })} + </div> + + <div className="mt-2 flex flex-col gap-2 border-t border-foreground/5 pt-3"> + <SectionLabel>{tSettings("audio.title", "Audio")}</SectionLabel> + + <div className="flex items-center justify-between rounded-lg bg-foreground/[0.03] px-2.5 py-1.5"> + <div> + <span className="text-[10px] text-muted-foreground"> + {tSettings("clip.mute", "Mute")} + </span> + <p className="text-[9px] text-muted-foreground/50 mt-0.5"> + {selectedClipMuted + ? tSettings("clip.mutedState", "Audio is muted") + : tSettings("clip.unmutedState", "Audio is playing")} + </p> + </div> + <Switch + checked={selectedClipMuted ?? false} + onCheckedChange={(v) => onClipMutedChange?.(v)} + className="data-[state=checked]:bg-[#06b6d4] scale-75" + /> + </div> + {hasClipSourceAudio && ( + <div className="flex items-center justify-between rounded-lg bg-foreground/[0.03] px-2.5 py-1.5"> + <span className="text-[10px] text-muted-foreground"> + {tSettings( + "clip.separateClipFromAudio", + "Separate clip from audio", + )} + </span> + <Switch + checked={selectedClipShowSourceAudio ?? false} + onCheckedChange={(v) => onClipShowSourceAudioChange?.(v)} + className="data-[state=checked]:bg-[#06b6d4] scale-75" + /> + </div> + )} + </div> + + {selectedClipId && hasClipSourceAudio && sourceAudioTrackMeta.length > 0 && ( + <div className="mt-1 flex flex-col gap-3"> + {sourceAudioTrackMeta.map((track) => { + const settings = sourceAudioTrackSettings[track.id] ?? { + volume: 1, + normalize: false, + }; + return ( + <div + key={track.id} + className="rounded-lg border border-foreground/10 bg-foreground/[0.03] px-3 py-2" + > + <div className="mb-2 flex items-center justify-between"> + <span className="text-[11px] font-medium text-foreground"> + {track.label} + </span> + <button + type="button" + onClick={() => { + onSourceAudioTrackVolumeChange?.(track.id, 1); + onSourceAudioTrackNormalizeChange?.( + track.id, + false, + ); + }} + className="text-[10px] text-[#2563EB] transition-opacity hover:opacity-80" + > + {t("common.actions.reset", "Reset")} + </button> + </div> + <div className="mb-2 flex items-center justify-between rounded-lg bg-foreground/[0.03] px-2.5 py-1.5"> + <span className="text-[10px] text-muted-foreground"> + {tSettings("audio.normalize", "Normalize")} + </span> + <Switch + checked={settings.normalize} + onCheckedChange={(v) => + onSourceAudioTrackNormalizeChange?.(track.id, v) + } + className="data-[state=checked]:bg-[#06b6d4] scale-75" + /> + </div> + <SliderControl + label={tSettings("audio.volume", "Volume")} + value={settings.volume} + defaultValue={1} + min={0} + max={1} + step={0.01} + onChange={(v) => + onSourceAudioTrackVolumeChange?.(track.id, v) + } + formatValue={(v) => `${Math.round(v * 100)}%`} + parseInput={(text) => + parseFloat(text.replace(/%$/, "")) / 100 + } + /> + </div> + ); + })} + </div> + )} + </section> + ); + + const captionSectionContent = ( + <section className="flex flex-col gap-2"> + <SectionLabel>{tSettings("sections.caption", "Caption")}</SectionLabel> + {selectedCaptionId !== null ? ( + <CaptionListPanel + cues={autoCaptions} + selectedCaptionId={selectedCaptionId} + currentTimeMs={captionCurrentTimeMs} + onBeginCaptionEdit={(id) => onBeginCaptionEdit?.(id)} + onCaptionTextEdit={(id, text) => onCaptionTextEdit?.(id, text)} + onCaptionRetime={(id, span) => onCaptionRetime?.(id, span)} + onCaptionSplit={(id, atMs) => onCaptionSplit?.(id, atMs)} + onCaptionMerge={(idA, idB) => onCaptionMerge?.(idA, idB)} + onCaptionDelete={(id) => onCaptionDelete?.(id)} + /> + ) : ( + <div className="rounded-lg bg-foreground/[0.03] px-2.5 py-6 text-center"> + <p className="text-[11px] text-muted-foreground"> + {tSettings( + "captions.selectOnTimeline", + "Select a caption on the timeline to edit it.", + )} + </p> + </div> + )} + </section> + ); + + switch (activeEffectSection) { + case "settings": + return settingsSectionContent; + case "scene": + return sceneSectionContent; + case "zoom": + return zoomItemSectionContent; + case "clip": + return clipSectionContent; + case "audio": + return audioSectionContent; + case "frame": + return sceneSectionContent; + case "crop": + return sceneSectionContent; + case "captions": + return captionsSectionContent; + case "caption": + return captionSectionContent; + case "cursor": + return ( + <section className="flex flex-col gap-2"> + <div className="flex items-center justify-between gap-3"> + <div className="flex items-center gap-3"> + <SectionLabel> + {tSettings("sections.cursor", "Cursor")} + </SectionLabel> + <button + type="button" + onClick={resetCursorSection} + className="text-[10px] text-[#2563EB] transition-opacity hover:opacity-80" + > + {t("common.actions.reset", "Reset")} + </button> + </div> + <div className="flex items-center gap-3"> + <label className="flex items-center gap-1.5 text-[10px] text-muted-foreground"> + <span>{tSettings("effects.showCursor")}</span> + <Switch + checked={showCursor} + onCheckedChange={onShowCursorChange} + className="data-[state=checked]:bg-[#2563EB] scale-75" + /> + </label> + <label className="flex items-center gap-1.5 text-[10px] text-muted-foreground"> + <span>{tSettings("effects.loopCursor")}</span> + <Switch + checked={loopCursor} + onCheckedChange={onLoopCursorChange} + className="data-[state=checked]:bg-[#2563EB] scale-75" + /> + </label> + </div> + </div> + <div className="flex flex-col gap-1.5"> + <div className="space-y-1.5"> + <ToggleGroup + type="single" + value={cursorStyle} + onValueChange={(value) => { + if (value) { + onCursorStyleChange?.(value as CursorStyle); + } + }} + className="grid grid-cols-4 gap-2" + aria-label={tSettings("effects.cursorStyle", "Cursor Style")} + > + {cursorStyleOptions.map((option) => ( + <ToggleGroupItem + key={option.value} + value={option.value} + title={option.label} + aria-label={option.label} + className={cn( + "group aspect-square h-auto min-w-0 rounded-[10px] border border-foreground/10 bg-foreground/[0.03] p-3 text-left text-foreground shadow-none transition-all hover:border-foreground/20 hover:bg-foreground/[0.06]", + "data-[state=on]:border-[#2563EB]/70 data-[state=on]:bg-[#2563EB]/12 data-[state=on]:text-foreground", + )} + > + <div className="flex h-full flex-col items-center justify-between gap-3"> + <div className="flex min-h-0 flex-1 items-center justify-center rounded-lg px-2 py-1.5"> + <CursorStylePreview + style={option.value} + previewUrls={cursorPreviewUrls} + /> + </div> + </div> + </ToggleGroupItem> + ))} + </ToggleGroup> + </div> + <SliderControl + label={tSettings("effects.cursorSize")} + value={cursorSize} + defaultValue={DEFAULT_CURSOR_SIZE} + min={0.5} + max={10} + step={0.05} + onChange={(v) => onCursorSizeChange?.(v)} + formatValue={(v) => `${v.toFixed(2)}×`} + parseInput={(text) => parseFloat(text.replace(/×$/, ""))} + /> + <CursorClickEffectCards + title={tSettings( + "effects.cursorClickEffects.title", + "Click Effects", + )} + activeEffectId={cursorClickEffect} + effectColor={cursorClickEffectColor} + onApply={(effectId) => onCursorClickEffectChange?.(effectId)} + showAdvanced={showCursorClickEffectAdvanced} + onToggleAdvanced={() => + setShowCursorClickEffectAdvanced((current) => !current) + } + tSettings={tSettings} + /> + {showCursorClickEffectAdvanced ? ( + <div className="grid gap-1.5"> + <input + ref={cursorClickEffectColorInputRef} + type="color" + value={cursorClickEffectColor} + onChange={(event) => + onCursorClickEffectColorChange?.(event.target.value) + } + className="sr-only" + /> + <div className="grid gap-1"> + <div className="text-[10px] text-muted-foreground"> + {tSettings( + "effects.cursorClickEffects.color", + "Effect Color", + )} + </div> + <div className="flex flex-wrap gap-1.5"> + {CLICK_EFFECT_COLOR_OPTIONS.map((color) => { + const isSelected = + cursorClickEffectColor.toLowerCase() === + color.toLowerCase(); + return ( + <button + key={color} + type="button" + onClick={() => + onCursorClickEffectColorChange?.(color) + } + className={cn( + "h-6 w-6 rounded-[8px] border transition-transform hover:scale-[1.04]", + isSelected + ? "border-foreground/80 ring-1 ring-[#2563EB]/50" + : "border-foreground/10", + )} + style={{ backgroundColor: color }} + aria-label={`Effect color ${color}`} + /> + ); + })} + <button + type="button" + onClick={() => + cursorClickEffectColorInputRef.current?.click() + } + className="relative h-6 w-10 overflow-hidden rounded-[8px] border border-foreground/10 text-[8px] font-semibold uppercase tracking-[0.18em] text-foreground" + style={{ + background: `linear-gradient(135deg, ${cursorClickEffectColor} 0%, ${cursorClickEffectColor} 58%, rgba(255,255,255,0.92) 58%, rgba(255,255,255,0.92) 100%)`, + }} + aria-label="Custom effect color picker" + > + <div className="absolute inset-0 flex items-center justify-center"> + Pick + </div> + </button> + </div> + </div> + <SliderControl + label={tSettings( + "effects.cursorClickEffects.size", + "Effect Size", + )} + value={cursorClickEffectScale} + defaultValue={DEFAULT_CURSOR_CLICK_EFFECT_SCALE} + min={0.5} + max={2} + step={0.05} + onChange={(v) => onCursorClickEffectScaleChange?.(v)} + formatValue={(v) => `${v.toFixed(2)}×`} + parseInput={(text) => parseFloat(text.replace(/×$/, ""))} + /> + <SliderControl + label={tSettings( + "effects.cursorClickEffects.opacity", + "Effect Opacity", + )} + value={cursorClickEffectOpacity} + defaultValue={DEFAULT_CURSOR_CLICK_EFFECT_OPACITY} + min={0} + max={1} + step={0.01} + onChange={(v) => onCursorClickEffectOpacityChange?.(v)} + formatValue={(v) => `${Math.round(v * 100)}%`} + parseInput={(text) => + parseFloat(text.replace(/%$/, "")) / 100 + } + /> + <SliderControl + label={tSettings( + "effects.cursorClickEffects.duration", + "Effect Duration", + )} + value={cursorClickEffectDurationMs} + defaultValue={DEFAULT_CURSOR_CLICK_EFFECT_DURATION_MS} + min={120} + max={1200} + step={10} + onChange={(v) => onCursorClickEffectDurationMsChange?.(v)} + formatValue={(v) => `${Math.round(v)} ms`} + parseInput={(text) => + parseFloat(text.replace(/ms$/i, "").trim()) + } + /> + </div> + ) : null} + <SliderControl + label={tSettings("effects.cursorClickBounce")} + value={cursorClickBounce} + defaultValue={DEFAULT_CURSOR_CLICK_BOUNCE} + min={0} + max={5} + step={0.05} + onChange={(v) => onCursorClickBounceChange?.(v)} + formatValue={(v) => `${v.toFixed(2)}×`} + parseInput={(text) => parseFloat(text.replace(/×$/, ""))} + /> + <SliderControl + label={tSettings( + "effects.cursorClickBounceDuration", + "Bounce Speed", + )} + value={cursorClickBounceDuration} + defaultValue={DEFAULT_CURSOR_CLICK_BOUNCE_DURATION} + min={60} + max={500} + step={5} + onChange={(v) => onCursorClickBounceDurationChange?.(v)} + formatValue={(v) => `${Math.round(v)} ms`} + parseInput={(text) => parseFloat(text.replace(/ms$/i, "").trim())} + /> + <SliderControl + label={tSettings("effects.cursorSway")} + value={toCursorSwaySliderValue(cursorSway)} + defaultValue={toCursorSwaySliderValue(DEFAULT_CURSOR_SWAY)} + min={0} + max={toCursorSwaySliderValue(2)} + step={toCursorSwaySliderValue(0.05)} + onChange={(v) => onCursorSwayChange?.(fromCursorSwaySliderValue(v))} + formatValue={(v) => + v <= 0 ? tSettings("effects.off") : `${v.toFixed(2)}×` + } + parseInput={(text) => { + const normalized = text.trim().toLowerCase(); + if (normalized === "off") return 0; + return parseFloat(text.replace(/×$/, "")); + }} + /> + {showDevMotionControls ? ( + <div className="rounded-lg border border-foreground/10 bg-foreground/[0.03] px-3 py-2"> + <div className="text-[10px] text-muted-foreground"> + {tSettings( + "effects.cursorDebugMovedToDev", + "Cursor spring tuning is available in Settings > Dev.", + )} + </div> + </div> + ) : null} + </div> + </section> + ); + case "webcam": + return ( + <section className="flex flex-col gap-2"> + <div className="flex items-center justify-between gap-3"> + <SectionLabel>{tSettings("sections.webcam", "Webcam")}</SectionLabel> + <button + type="button" + onClick={resetWebcamSection} + className="text-[10px] text-[#2563EB] transition-opacity hover:opacity-80" + > + {t("common.actions.reset", "Reset")} + </button> + </div> + <div className="flex flex-col gap-1.5"> + <div className="flex items-center justify-between rounded-lg bg-foreground/[0.03] px-2.5 py-1.5"> + <span className="text-[10px] text-muted-foreground"> + {tSettings("effects.show", "Show")} + </span> + <Switch + checked={webcam?.enabled ?? false} + onCheckedChange={(enabled) => updateWebcam({ enabled })} + className="data-[state=checked]:bg-[#2563EB] scale-75" + /> + </div> + <div className="flex items-center justify-between rounded-lg bg-foreground/[0.03] px-2.5 py-1.5"> + <span className="text-[10px] text-muted-foreground"> + {tSettings("effects.webcamReactToZoom")} + </span> + <Switch + checked={webcam?.reactToZoom ?? DEFAULT_WEBCAM_REACT_TO_ZOOM} + onCheckedChange={(reactToZoom) => updateWebcam({ reactToZoom })} + className="data-[state=checked]:bg-[#2563EB] scale-75" + /> + </div> + <div className="flex items-center justify-between rounded-lg bg-foreground/[0.03] px-2.5 py-1.5"> + <span className="text-[10px] text-muted-foreground"> + {tSettings("effects.webcamMirror", "Mirror webcam")} + </span> + <Switch + checked={webcam?.mirror ?? true} + onCheckedChange={(mirror) => updateWebcam({ mirror })} + className="data-[state=checked]:bg-[#2563EB] scale-75" + /> + </div> + <SliderControl + label={tSettings("effects.webcamWidth", "Webcam Width")} + value={webcamWidth} + defaultValue={DEFAULT_WEBCAM_SIZE} + min={10} + max={100} + step={1} + onChange={(v) => updateWebcam({ width: v, size: v })} + formatValue={(v) => `${Math.round(v)}%`} + parseInput={(text) => parseFloat(text.replace(/%$/, ""))} + /> + <SliderControl + label={tSettings("effects.webcamHeight", "Webcam Height")} + value={webcamHeight} + defaultValue={DEFAULT_WEBCAM_SIZE} + min={10} + max={100} + step={1} + onChange={(v) => updateWebcam({ height: v })} + formatValue={(v) => `${Math.round(v)}%`} + parseInput={(text) => parseFloat(text.replace(/%$/, ""))} + /> + <div className="rounded-lg bg-foreground/[0.03] px-2.5 py-2"> + <div className="mb-2 flex items-center justify-between gap-2"> + <div className="text-[10px] text-muted-foreground"> + {tSettings("effects.webcamCrop", "Crop")} + </div> + <button + type="button" + onClick={() => + updateWebcam({ cropRegion: DEFAULT_CROP_REGION }) + } + className="text-[10px] text-[#2563EB] transition-opacity hover:opacity-80" + > + {t("common.actions.reset", "Reset")} + </button> + </div> + <WebcamCropControl + cropRegion={webcamCrop} + mirrored={webcam?.mirror ?? true} + previewSrc={webcamPreviewSrc} + previewCurrentTime={webcamPreviewCurrentTime} + previewPlaying={webcamPreviewPlaying} + previewTimeOffsetMs={webcam?.timeOffsetMs} + onCropChange={(cropRegion, previewFrame) => + updateWebcam({ + cropRegion, + height: previewFrame + ? getCropMatchedWebcamHeightPercent( + webcamWidth, + webcamHeight, + previewFrame.width, + previewFrame.height, + cropRegion, + ) + : webcamHeight, + }) + } + /> + </div> + <div className="rounded-lg bg-foreground/[0.03] px-2.5 py-2"> + <div className="mb-2 text-[10px] text-muted-foreground"> + {tSettings("effects.webcamPosition", "Position")} + </div> + <div className="grid grid-cols-3 gap-1.5"> + {WEBCAM_POSITION_PRESETS.map((option) => { + const isActive = webcamPositionPreset === option.preset; + return ( + <Button + key={option.preset} + type="button" + onClick={() => + applyWebcamPositionPreset(option.preset) + } + className={cn( + "h-8 rounded-lg border px-0 text-sm font-semibold transition-all", + isActive + ? "border-[#2563EB] bg-[#2563EB] text-white" + : "border-foreground/10 bg-foreground/5 text-muted-foreground hover:border-foreground/20 hover:bg-foreground/10", + )} + > + {option.label} + </Button> + ); + })} + </div> + <div className="mt-2 flex items-center justify-between rounded-lg bg-black/10 px-2.5 py-1.5"> + <span className="text-[10px] text-muted-foreground"> + {tSettings( + "effects.webcamCustomPosition", + "Custom position", + )} + </span> + <Switch + checked={webcamPositionPreset === "custom"} + onCheckedChange={(checked) => + applyWebcamPositionPreset( + checked ? "custom" : DEFAULT_WEBCAM_POSITION_PRESET, + ) + } + className="data-[state=checked]:bg-[#2563EB] scale-75" + /> + </div> + </div> + {webcamPositionPreset === "custom" ? ( + <> + <SliderControl + label={tSettings("effects.webcamHorizontal", "Horizontal")} + value={webcamPositionX * 100} + defaultValue={DEFAULT_WEBCAM_POSITION_X * 100} + min={0} + max={100} + step={1} + onChange={(v) => + updateWebcam({ + positionPreset: "custom", + positionX: v / 100, + }) + } + formatValue={(v) => `${Math.round(v)}%`} + parseInput={(text) => parseFloat(text.replace(/%$/, ""))} + /> + <SliderControl + label={tSettings("effects.webcamVertical", "Vertical")} + value={webcamPositionY * 100} + defaultValue={DEFAULT_WEBCAM_POSITION_Y * 100} + min={0} + max={100} + step={1} + onChange={(v) => + updateWebcam({ + positionPreset: "custom", + positionY: v / 100, + }) + } + formatValue={(v) => `${Math.round(v)}%`} + parseInput={(text) => parseFloat(text.replace(/%$/, ""))} + /> + </> + ) : null} + <SliderControl + label={tSettings("effects.webcamMargin", "Margin")} + value={webcam?.margin ?? DEFAULT_WEBCAM_MARGIN} + defaultValue={DEFAULT_WEBCAM_MARGIN} + min={0} + max={96} + step={1} + onChange={(v) => updateWebcam({ margin: v })} + formatValue={(v) => `${Math.round(v)}px`} + parseInput={(text) => parseFloat(text.replace(/px$/, ""))} + /> + <SliderControl + label={tSettings("effects.webcamRoundness")} + value={webcam?.roundness ?? DEFAULT_WEBCAM_ROUNDNESS} + defaultValue={DEFAULT_WEBCAM_ROUNDNESS} + min={0} + max={100} + step={1} + onChange={(v) => updateWebcam({ roundness: v })} + formatValue={(v) => `${Math.round(v)}%`} + parseInput={(text) => parseFloat(text.replace(/%$/, ""))} + /> + <SliderControl + label={tSettings("effects.webcamShadow")} + value={webcam?.shadow ?? DEFAULT_WEBCAM_SHADOW} + defaultValue={DEFAULT_WEBCAM_SHADOW} + min={0} + max={1} + step={0.01} + onChange={(v) => updateWebcam({ shadow: v })} + formatValue={(v) => `${Math.round(v * 100)}%`} + parseInput={(text) => parseFloat(text.replace(/%$/, "")) / 100} + /> + <div className="rounded-lg bg-foreground/[0.03] px-2.5 py-2"> + <div className="flex flex-col gap-2"> + <div className="min-w-0"> + <div className="text-[10px] text-muted-foreground"> + {tSettings("effects.webcamFootage")} + </div> + <div className="mt-0.5 break-all text-[10px] leading-4 text-muted-foreground/70"> + {webcamFileName ?? + tSettings("effects.webcamFootageDescription")} + </div> + </div> + <div className="grid grid-cols-1 gap-1.5 sm:grid-cols-2"> + <Button + type="button" + variant="outline" + onClick={onUploadWebcam} + className="h-7 min-w-0 gap-1.5 border-foreground/10 bg-foreground/5 px-2 text-[10px] text-foreground hover:bg-foreground/10 hover:text-foreground" + > + <Upload className="h-3 w-3" /> + <span className="min-w-0 truncate"> + {webcam?.sourcePath + ? tSettings("effects.replaceWebcamFootage") + : tSettings("effects.uploadWebcamFootage")} + </span> + </Button> + {webcam?.sourcePath ? ( + <Button + type="button" + variant="outline" + onClick={onClearWebcam} + className="h-7 min-w-0 gap-1.5 border-foreground/10 bg-foreground/5 px-2 text-[10px] text-foreground hover:bg-foreground/10 hover:text-foreground" + > + <Trash2 className="h-3 w-3" /> + <span className="min-w-0 truncate"> + {tSettings("effects.removeWebcamFootage")} + </span> + </Button> + ) : null} + </div> + </div> + </div> + </div> + </section> + ); + default: { + return sceneSectionContent; + } + } + })(); + + return ( + <div className="flex-[2] w-[332px] min-w-[280px] max-w-[332px] bg-editor-panel rounded-2xl flex flex-col shadow-xl h-full overflow-hidden"> + <div + className="flex-1 min-h-0 overflow-y-auto custom-scrollbar p-4 pb-0" + style={{ scrollbarGutter: "stable" }} + > + <AnimatePresence mode="wait" initial={false}> + <motion.div + key={activeEffectSection} + initial={{ opacity: 0, y: 8 }} + animate={{ opacity: 1, y: 0 }} + exit={{ opacity: 0, y: -8 }} + transition={{ duration: 0.18, ease: "easeOut" }} + > + {effectSectionContent} + </motion.div> + </AnimatePresence> + </div> + + <div + className={cn( + "flex-shrink-0 border-t border-foreground/10 bg-editor-panel p-4 pt-3", + (() => { + if (activeEffectSection === "clip" && selectedClipId) return false; + if (activeEffectSection === "zoom" && selectedZoomId) return false; + if (activeEffectSection === "audio" && selectedAudioId) return false; + if (selectedAnnotationId) return false; // Annotation editor handles its own but let's see + return true; + })() && "hidden", + )} + > + {activeEffectSection === "clip" && selectedClipId && ( + <Button + onClick={() => { + if (selectedClipId && onClipDelete) onClipDelete(selectedClipId); + }} + variant="destructive" + size="sm" + className="h-8 w-full gap-2 border border-red-500/20 bg-red-500/10 text-xs text-red-400 transition-all hover:border-red-500/30 hover:bg-red-500/20" + > + <Trash2 className="h-3 w-3" /> + {tSettings("clip.delete", "Delete Clip")} + </Button> + )} + {activeEffectSection === "zoom" && selectedZoomId && ( + <Button + onClick={() => { + if (selectedZoomId && onZoomDelete) onZoomDelete(selectedZoomId); + }} + variant="destructive" + size="sm" + className="h-8 w-full gap-2 border border-red-500/20 bg-red-500/10 text-xs text-red-400 transition-all hover:border-red-500/30 hover:bg-red-500/20" + > + <Trash2 className="h-3 w-3" /> + {tSettings("zoom.deleteZoom", "Delete Zoom")} + </Button> + )} + {activeEffectSection === "audio" && selectedAudioId && ( + <Button + onClick={() => { + if (selectedAudioId && onAudioDelete) onAudioDelete(selectedAudioId); + }} + variant="destructive" + size="sm" + className="h-8 w-full gap-2 border border-red-500/20 bg-red-500/10 text-xs text-red-400 transition-all hover:border-red-500/30 hover:bg-red-500/20" + > + <Trash2 className="h-3 w-3" /> + {tSettings("audio.deleteRegion", "Delete Audio")} + </Button> + )} + {selectedAnnotationId && ( + <Button + onClick={() => { + if (selectedAnnotationId && onAnnotationDelete) + onAnnotationDelete(selectedAnnotationId); + }} + variant="destructive" + size="sm" + className="h-8 w-full gap-2 border border-red-500/20 bg-red-500/10 text-xs text-red-400 transition-all hover:border-red-500/30 hover:bg-red-500/20" + > + <Trash2 className="h-3 w-3" /> + {tSettings("annotation.delete", "Delete Annotation")} + </Button> + )} + </div> + </div> + ); } diff --git a/src/components/video-editor/ShortcutsConfigDialog.tsx b/src/components/video-editor/ShortcutsConfigDialog.tsx new file mode 100644 index 000000000..c359c1f11 --- /dev/null +++ b/src/components/video-editor/ShortcutsConfigDialog.tsx @@ -0,0 +1,255 @@ +import { Keyboard, ArrowCounterClockwise as RotateCcw } from "@phosphor-icons/react"; +import { useCallback, useEffect, useState } from "react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { useShortcuts } from "@/contexts/ShortcutsContext"; +import { + DEFAULT_SHORTCUTS, + FIXED_SHORTCUTS, + findConflict, + formatBinding, + SHORTCUT_ACTIONS, + SHORTCUT_LABELS, + type ShortcutAction, + type ShortcutBinding, + type ShortcutConflict, + type ShortcutsConfig, +} from "@/lib/shortcuts"; +import { useScopedT } from "../../contexts/I18nContext"; + +const MODIFIER_KEYS = new Set(["Control", "Shift", "Alt", "Meta"]); + +export function ShortcutsConfigDialog() { + const t = useScopedT("dialogs"); + const { shortcuts, isMac, isConfigOpen, closeConfig, setShortcuts, persistShortcuts } = + useShortcuts(); + + const [draft, setDraft] = useState<ShortcutsConfig>(shortcuts); + const [captureFor, setCaptureFor] = useState<ShortcutAction | null>(null); + const [conflict, setConflict] = useState<{ + forAction: ShortcutAction; + pending: ShortcutBinding; + conflictWith: ShortcutConflict; + } | null>(null); + + useEffect(() => { + if (isConfigOpen) { + setDraft(shortcuts); + setCaptureFor(null); + setConflict(null); + } + }, [isConfigOpen, shortcuts]); + + useEffect(() => { + if (!captureFor) return; + + const handleCapture = (e: KeyboardEvent) => { + e.preventDefault(); + e.stopPropagation(); + + if (e.key === "Escape") { + setCaptureFor(null); + return; + } + + if (MODIFIER_KEYS.has(e.key)) return; + + const binding: ShortcutBinding = { + key: e.key.toLowerCase(), + ...(e.ctrlKey || e.metaKey ? { ctrl: true } : {}), + ...(e.shiftKey ? { shift: true } : {}), + ...(e.altKey ? { alt: true } : {}), + }; + + const found = findConflict(binding, captureFor, draft); + setCaptureFor(null); + + if (found?.type === "fixed") { + toast.error(t("shortcutsConfig.reserved", undefined, { label: found.label })); + return; + } + + if (found?.type === "configurable") { + setConflict({ forAction: captureFor, pending: binding, conflictWith: found }); + return; + } + + setDraft((prev: ShortcutsConfig) => ({ ...prev, [captureFor]: binding })); + }; + + window.addEventListener("keydown", handleCapture, { capture: true }); + return () => window.removeEventListener("keydown", handleCapture, { capture: true }); + }, [captureFor, draft, t]); + + const handleSwap = useCallback(() => { + if (!conflict || conflict.conflictWith.type !== "configurable") return; + const { forAction, pending, conflictWith } = conflict; + setDraft((prev: ShortcutsConfig) => ({ + ...prev, + [forAction]: pending, + [conflictWith.action]: prev[forAction], + })); + setConflict(null); + }, [conflict]); + + const handleCancelConflict = useCallback(() => setConflict(null), []); + + const handleSave = useCallback(async () => { + setShortcuts(draft); + await persistShortcuts(draft); + toast.success(t("shortcutsConfig.saved")); + closeConfig(); + }, [draft, setShortcuts, persistShortcuts, closeConfig, t]); + + const handleReset = useCallback(() => { + setDraft({ ...DEFAULT_SHORTCUTS }); + toast.info(t("shortcutsConfig.resetNotice")); + }, [t]); + + const handleClose = useCallback(() => { + setCaptureFor(null); + setConflict(null); + closeConfig(); + }, [closeConfig]); + + return ( + <Dialog + open={isConfigOpen} + onOpenChange={(open: boolean) => { + if (!open) handleClose(); + }} + > + <DialogContent className="bg-editor-dialog border-foreground/10 text-foreground max-w-[460px]"> + <DialogHeader> + <DialogTitle className="flex items-center gap-2 text-sm"> + <Keyboard className="w-4 h-4 text-[#2563EB]" /> + {t("shortcutsConfig.title")} + </DialogTitle> + </DialogHeader> + + <div className="space-y-0.5"> + <p className="text-[10px] text-muted-foreground/70 mb-2 uppercase tracking-wide font-semibold"> + {t("shortcutsConfig.configurable")} + </p> + {SHORTCUT_ACTIONS.map((action) => { + const isCapturing = captureFor === action; + const hasConflict = conflict?.forAction === action; + return ( + <div key={action}> + <div className="flex items-center justify-between py-1.5 px-1 border-b border-foreground/5"> + <span className="text-sm text-muted-foreground"> + {SHORTCUT_LABELS[action]} + </span> + <button + type="button" + onClick={() => { + setConflict(null); + setCaptureFor(isCapturing ? null : action); + }} + title={ + isCapturing + ? t("shortcutsConfig.pressEscToCancel") + : t("shortcutsConfig.clickToChange") + } + className={[ + "px-2 py-1 rounded text-xs font-mono border transition-all min-w-[90px] text-center select-none", + isCapturing + ? "bg-[#2563EB]/20 border-[#2563EB] text-[#2563EB] animate-pulse" + : hasConflict + ? "bg-amber-500/10 border-amber-500/50 text-amber-400" + : "bg-foreground/5 border-foreground/10 text-foreground hover:border-[#2563EB]/50 hover:text-[#2563EB] cursor-pointer", + ].join(" ")} + > + {isCapturing + ? t("shortcutsConfig.pressAKey") + : formatBinding(draft[action], isMac)} + </button> + </div> + {hasConflict && conflict?.conflictWith.type === "configurable" && ( + <div className="flex items-center justify-between px-1 py-1.5 mb-0.5 bg-amber-500/10 border border-amber-500/20 rounded text-xs"> + <span className="text-amber-400"> + {t("shortcutsConfig.alreadyUsedBy", undefined, { + action: SHORTCUT_LABELS[ + conflict.conflictWith.action + ], + })} + </span> + <div className="flex gap-1.5"> + <button + type="button" + onClick={handleSwap} + className="px-2 py-0.5 bg-amber-500/20 hover:bg-amber-500/30 border border-amber-500/40 rounded text-amber-300 font-medium transition-colors" + > + {t("shortcutsConfig.swap")} + </button> + <button + type="button" + onClick={handleCancelConflict} + className="px-2 py-0.5 bg-foreground/5 hover:bg-foreground/10 border border-foreground/10 rounded text-muted-foreground transition-colors" + > + {t("shortcutsConfig.cancel")} + </button> + </div> + </div> + )} + </div> + ); + })} + </div> + + <div className="space-y-0.5 mt-2"> + <p className="text-[10px] text-muted-foreground/70 mb-2 uppercase tracking-wide font-semibold"> + {t("shortcutsConfig.fixed")} + </p> + {FIXED_SHORTCUTS.map(({ label, display }) => ( + <div + key={label} + className="flex items-center justify-between py-1.5 px-1 border-b border-foreground/5 last:border-0" + > + <span className="text-sm text-muted-foreground">{label}</span> + <kbd className="px-2 py-1 bg-foreground/5 border border-foreground/10 rounded text-xs font-mono text-muted-foreground min-w-[90px] text-center"> + {display} + </kbd> + </div> + ))} + </div> + + <p className="text-[10px] text-muted-foreground/70 mt-1"> + {t("shortcutsConfig.instructions")} + </p> + + <DialogFooter className="flex gap-2 sm:justify-between mt-2"> + <Button + title={t("shortcutsConfig.resetToDefaults")} + variant="ghost" + size="sm" + className="text-muted-foreground hover:text-foreground hover:bg-foreground/10 gap-1.5 max-w-[200px]" + onClick={handleReset} + > + <RotateCcw className="w-3 h-3" /> + <span className="truncate">{t("shortcutsConfig.resetToDefaults")}</span> + </Button> + <div className="flex gap-2"> + <Button variant="ghost" size="sm" onClick={handleClose}> + {t("shortcutsConfig.cancel")} + </Button> + <Button + size="sm" + className="bg-[#2563EB] hover:bg-[#1d4ed8] text-white" + onClick={handleSave} + > + {t("shortcutsConfig.save")} + </Button> + </div> + </DialogFooter> + </DialogContent> + </Dialog> + ); +} diff --git a/src/components/video-editor/SliderControl.tsx b/src/components/video-editor/SliderControl.tsx new file mode 100644 index 000000000..dd0aaf0dc --- /dev/null +++ b/src/components/video-editor/SliderControl.tsx @@ -0,0 +1,199 @@ +import type { PointerEvent as ReactPointerEvent } from "react"; +import { useCallback, useRef, memo, useEffect } from "react"; +import { cn } from "@/lib/utils"; + +interface SliderControlProps { + label: string; + value: number; + defaultValue: number; + min: number; + max: number; + step: number; + onChange: (value: number) => void; + formatValue: (value: number) => string; + parseInput: (text: string) => number | null; + accentColor?: "purple" | "blue"; +} + +function clamp(value: number, min: number, max: number) { + return Math.min(max, Math.max(min, value)); +} + +function quantizeToStep(value: number, min: number, step: number) { + if (!(step > 0)) { + return value; + } + + return min + Math.round((value - min) / step) * step; +} + +export const SliderControl = memo(function SliderControl({ + label, + value, + defaultValue: _defaultValue, + min, + max, + step, + onChange, + formatValue, + parseInput: _parseInput, + accentColor = "blue", +}: SliderControlProps) { + const rootRef = useRef<HTMLDivElement | null>(null); + const valueTextRef = useRef<HTMLSpanElement | null>(null); + const boundsRef = useRef<DOMRect | null>(null); + const requestRef = useRef<number | null>(null); + + const pct = Math.min(100, Math.max(0, ((value - min) / (max - min || 1)) * 100)); + + const dividerClass = + accentColor === "purple" + ? "bg-foreground/95 shadow-[0_0_10px_rgba(139,92,246,0.28)]" + : "bg-foreground/95 shadow-[0_0_10px_rgba(37,99,235,0.28)]"; + + // Sync initial and prop-driven changes to CSS variable + useEffect(() => { + if (rootRef.current) { + rootRef.current.style.setProperty("--slider-pct", String(pct / 100)); + } + }, [pct]); + + const updateValue = useCallback( + (clientX: number) => { + const bounds = boundsRef.current; + if (!bounds || bounds.width <= 6) { + return; + } + + const normalized = clamp((clientX - (bounds.left + 3)) / (bounds.width - 6), 0, 1); + const rawValue = min + normalized * (max - min); + const nextValue = clamp(quantizeToStep(rawValue, min, step), min, max); + const finalValue = Number(nextValue.toFixed(6)); + const finalPct = (((finalValue - min) / (max - min || 1)) * 100).toFixed(4); + + // Direct DOM update for instant feedback + if (rootRef.current) { + rootRef.current.style.setProperty("--slider-pct", String(Number(finalPct) / 100)); + rootRef.current.setAttribute("aria-valuenow", String(finalValue)); + rootRef.current.setAttribute("aria-valuetext", formatValue(finalValue)); + } + if (valueTextRef.current) { + valueTextRef.current.textContent = formatValue(finalValue); + } + + // Notify parent + onChange(finalValue); + }, + [max, min, onChange, step, formatValue], + ); + + const handlePointerDown = useCallback( + (event: ReactPointerEvent<HTMLDivElement>) => { + event.preventDefault(); + const pointerId = event.pointerId; + const target = event.currentTarget; + + // Cache bounds to avoid layout thrashing during move + boundsRef.current = target.getBoundingClientRect(); + + target.setPointerCapture(pointerId); + updateValue(event.clientX); + + const handlePointerMove = (moveEvent: PointerEvent) => { + if (moveEvent.pointerId !== pointerId) { + return; + } + + if (requestRef.current) { + cancelAnimationFrame(requestRef.current); + } + + requestRef.current = requestAnimationFrame(() => { + updateValue(moveEvent.clientX); + }); + }; + + const finishPointer = (finishEvent: PointerEvent) => { + if (finishEvent.pointerId !== pointerId) { + return; + } + + if (requestRef.current) { + cancelAnimationFrame(requestRef.current); + requestRef.current = null; + } + + if (finishEvent.type === "pointerup") { + updateValue(finishEvent.clientX); + } + + target.releasePointerCapture(pointerId); + target.removeEventListener("pointermove", handlePointerMove); + target.removeEventListener("pointerup", finishPointer); + target.removeEventListener("pointercancel", finishPointer); + boundsRef.current = null; + }; + + target.addEventListener("pointermove", handlePointerMove); + target.addEventListener("pointerup", finishPointer); + target.addEventListener("pointercancel", finishPointer); + }, + [updateValue], + ); + + return ( + <div + ref={rootRef} + role="slider" + tabIndex={0} + aria-label={label} + aria-valuemin={min} + aria-valuemax={max} + aria-valuenow={value} + aria-valuetext={formatValue(value)} + onPointerDown={handlePointerDown} + onKeyDown={(event) => { + if (event.key === "ArrowLeft" || event.key === "ArrowDown") { + event.preventDefault(); + onChange(clamp(quantizeToStep(value - step, min, step), min, max)); + } + + if (event.key === "ArrowRight" || event.key === "ArrowUp") { + event.preventDefault(); + onChange(clamp(quantizeToStep(value + step, min, step), min, max)); + } + }} + className="relative flex h-10 w-full select-none items-center overflow-hidden rounded-xl bg-editor-bg/80 px-1.5 outline-none focus-visible:ring-1 focus-visible:ring-[#2563EB]/40" + style={ + { + "--slider-pct": String(pct / 100), + } as React.CSSProperties + } + > + <div + className="pointer-events-none absolute inset-y-[3px] left-[3px] right-auto rounded-[10px] bg-foreground/[0.08] shadow-[0_4px_10px_0_rgba(0,0,0,0.18)] transition-none" + style={{ + width: "calc(var(--slider-pct) * (100% - 6px))", + }} + /> + <div + className={cn( + "pointer-events-none absolute bottom-[18%] top-[18%] z-10 w-[2px] rounded-full transition-none", + dividerClass, + )} + style={{ + left: "calc(var(--slider-pct) * (100% - 6px) - 6px)", + }} + /> + <span className="pointer-events-none relative z-10 flex-1 pl-3 text-[12px] font-medium text-muted-foreground"> + {label} + </span> + <span + ref={valueTextRef} + className="pointer-events-none relative z-10 pr-3 text-[12px] font-medium tabular-nums text-foreground" + > + {formatValue(value)} + </span> + </div> + ); +}); diff --git a/src/components/video-editor/TutorialHelp.tsx b/src/components/video-editor/TutorialHelp.tsx new file mode 100644 index 000000000..b38b6f3e8 --- /dev/null +++ b/src/components/video-editor/TutorialHelp.tsx @@ -0,0 +1,415 @@ +import { + ArrowRight, + ArrowSquareOut as ExternalLink, + Question as HelpCircle, + Keyboard, + ChatDots as MessageSquareMore, + Scissors, + GearSix as Settings2, + XLogo as Twitter, +} from "@phosphor-icons/react"; +import { useEffect, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { useScopedT } from "@/contexts/I18nContext"; +import { useShortcuts } from "@/contexts/ShortcutsContext"; +import { formatBinding, SHORTCUT_ACTIONS, SHORTCUT_LABELS } from "@/lib/shortcuts"; +import { formatShortcut } from "@/utils/platformUtils"; +import { toast } from "sonner"; + +export const RECORDLY_ISSUES_URL = "https://github.com/webadderallorg/Recordly/issues"; +const RECORDLY_DISCORD_URL = "https://discord.gg/sdv2FBVNgE"; +const RECORDLY_X_URL = "https://x.com/webadderall"; +const CONTACT_EMAIL = "youngchen3442@gmail.com"; +export const APP_HEADER_ACTION_BUTTON_CLASS = + "h-7 px-2 text-xs text-muted-foreground hover:bg-foreground/10 hover:text-foreground transition-all gap-1.5"; +export const APP_HEADER_ICON_BUTTON_CLASS = + "h-7 w-7 p-0 text-muted-foreground hover:bg-foreground/10 hover:text-foreground transition-all"; + +interface KeyboardShortcutsDialogProps { + triggerLabel?: string; + triggerClassName?: string; +} + +function DiscordIcon(props: React.SVGProps<SVGSVGElement>) { + return ( + <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" {...props}> + <path d="M20.317 4.369A19.791 19.791 0 0 0 15.418 3a13.255 13.255 0 0 0-.615 1.263 18.27 18.27 0 0 0-5.606 0A13.25 13.25 0 0 0 8.582 3a19.736 19.736 0 0 0-4.9 1.369C.533 9.091-.32 13.697.099 18.237a19.917 19.917 0 0 0 5.993 3.024 14.32 14.32 0 0 0 1.284-2.108 12.804 12.804 0 0 1-2.021-.972c.17-.126.335-.258.495-.395a14.135 14.135 0 0 0 12.3 0c.16.137.325.269.495.395a12.736 12.736 0 0 1-2.026.974 14.103 14.103 0 0 0 1.284 2.106 19.883 19.883 0 0 0 5.996-3.024c.489-5.258-.836-9.822-3.682-13.868ZM8.02 15.331c-1.182 0-2.154-1.085-2.154-2.419 0-1.334.953-2.419 2.154-2.419 1.211 0 2.173 1.095 2.154 2.419 0 1.334-.953 2.419-2.154 2.419Zm7.96 0c-1.182 0-2.154-1.085-2.154-2.419 0-1.334.953-2.419 2.154-2.419 1.211 0 2.173 1.095 2.154 2.419 0 1.334-.943 2.419-2.154 2.419Z" /> + </svg> + ); +} + +export async function openExternalLink(url: string, errorMessage: string) { + try { + const result = await window.electronAPI.openExternalUrl(url); + if (!result.success) { + toast.error(result.error || errorMessage); + } + } catch (error) { + toast.error(`${errorMessage} ${String(error)}`); + } +} + +export function DiscordLinkButton() { + const t = useScopedT("editor"); + + return ( + <Button + type="button" + variant="ghost" + size="sm" + onClick={() => + void openExternalLink( + RECORDLY_DISCORD_URL, + t("feedback.openFailed", "Failed to open link."), + ) + } + className={APP_HEADER_ICON_BUTTON_CLASS} + title={t("common.app.discord", "Join Discord")} + aria-label={t("common.app.discord", "Join Discord")} + > + <DiscordIcon className="h-3.5 w-3.5" /> + </Button> + ); +} + +export function FeedbackDialog() { + const t = useScopedT("editor"); + + return ( + <Dialog> + <DialogTrigger asChild> + <Button + variant="ghost" + size="sm" + className={APP_HEADER_ICON_BUTTON_CLASS} + title={t("feedback.trigger", "Feedback")} + aria-label={t("feedback.trigger", "Feedback")} + > + <MessageSquareMore className="h-3.5 w-3.5" /> + </Button> + </DialogTrigger> + <DialogContent className="max-w-lg bg-editor-dialog border-foreground/10 [&>button]:text-muted-foreground [&>button:hover]:text-foreground"> + <DialogHeader> + <DialogTitle className="text-xl font-semibold text-foreground flex items-center gap-2"> + <MessageSquareMore className="h-5 w-5 text-[#2563EB]" />{" "} + {t("feedback.title", "Feedback & contact")} + </DialogTitle> + <DialogDescription className="text-muted-foreground"> + {t( + "feedback.description", + "Reach out directly or open an issue if something is broken or missing.", + )} + </DialogDescription> + </DialogHeader> + <div className="mt-4 space-y-4"> + <div className="rounded-xl border border-foreground/10 bg-foreground/[0.03] p-4 space-y-3"> + <div className="flex items-center justify-between gap-3 rounded-lg border border-foreground/5 bg-foreground/5 px-3 py-3"> + <div> + <p className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground/70"> + {t("feedback.emailLabel", "Email")} + </p> + <p className="mt-1 text-sm font-medium text-foreground"> + {CONTACT_EMAIL} + </p> + </div> + <Button + type="button" + variant="outline" + onClick={() => + void openExternalLink( + `mailto:${CONTACT_EMAIL}`, + t("feedback.openFailed", "Failed to open link."), + ) + } + className="border-foreground/10 bg-foreground/5 text-foreground hover:bg-foreground/10 hover:text-foreground" + > + <ExternalLink className="h-3.5 w-3.5" /> + </Button> + </div> + <div className="flex items-center justify-between gap-3 rounded-lg border border-foreground/5 bg-foreground/5 px-3 py-3"> + <div> + <p className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground/70"> + {t("feedback.xLabel", "X")} + </p> + <p className="mt-1 text-sm font-medium text-foreground"> + @webadderall + </p> + </div> + <Button + type="button" + variant="outline" + onClick={() => + void openExternalLink( + RECORDLY_X_URL, + t("feedback.openFailed", "Failed to open link."), + ) + } + className="border-foreground/10 bg-foreground/5 text-foreground hover:bg-foreground/10 hover:text-foreground" + > + <Twitter className="h-3.5 w-3.5" /> + </Button> + </div> + </div> + <Button + type="button" + variant="outline" + onClick={() => + void openExternalLink( + RECORDLY_ISSUES_URL, + t("feedback.openFailed", "Failed to open link."), + ) + } + className="h-10 w-full justify-between border-foreground/10 bg-foreground/5 px-4 text-foreground hover:bg-foreground/10 hover:text-foreground" + > + <span className="flex items-center gap-2 text-sm font-medium"> + <MessageSquareMore className="h-4 w-4" /> + {t("feedback.reportIssue", "Report issue / send feedback")} + </span> + <ExternalLink className="h-3.5 w-3.5 text-muted-foreground/70" /> + </Button> + </div> + </DialogContent> + </Dialog> + ); +} + +export function KeyboardShortcutsDialog({ + triggerLabel, + triggerClassName, +}: KeyboardShortcutsDialogProps) { + const { shortcuts, isMac, openConfig } = useShortcuts(); + const t = useScopedT("editor"); + const [scrollLabels, setScrollLabels] = useState({ + pan: "Shift + Scroll", + zoom: "Ctrl + Scroll", + }); + + useEffect(() => { + Promise.all([formatShortcut(["shift", "Scroll"]), formatShortcut(["mod", "Scroll"])]).then( + ([pan, zoom]) => setScrollLabels({ pan, zoom }), + ); + }, []); + + return ( + <Dialog> + <DialogTrigger asChild> + <Button + variant="ghost" + size="sm" + className={triggerClassName ?? APP_HEADER_ICON_BUTTON_CLASS} + title={t("keyboardShortcuts.trigger", "Shortcuts")} + aria-label={t("keyboardShortcuts.trigger", "Shortcuts")} + > + <Keyboard className="h-3.5 w-3.5" /> + {triggerLabel ? <span className="font-medium">{triggerLabel}</span> : null} + </Button> + </DialogTrigger> + <DialogContent className="max-w-lg bg-editor-dialog border-foreground/10 [&>button]:text-muted-foreground [&>button:hover]:text-foreground"> + <DialogHeader> + <DialogTitle className="text-xl font-semibold text-foreground flex items-center gap-2"> + <Keyboard className="h-5 w-5 text-[#2563EB]" />{" "} + {t("keyboardShortcuts.title")} + </DialogTitle> + <DialogDescription className="text-muted-foreground"> + {t( + "keyboardShortcuts.description", + "Quick reference for the timeline and editor controls.", + )} + </DialogDescription> + </DialogHeader> + <div className="mt-4 space-y-4"> + <div className="rounded-xl border border-foreground/10 bg-foreground/[0.03] p-4 space-y-2 text-xs"> + {SHORTCUT_ACTIONS.map((action) => ( + <div + key={action} + className="flex items-center justify-between gap-3 rounded-lg border border-foreground/5 bg-foreground/5 px-3 py-2.5" + > + <span className="text-muted-foreground"> + {SHORTCUT_LABELS[action]} + </span> + <kbd className="rounded border border-foreground/10 bg-foreground/10 px-2 py-1 font-mono text-[#2563EB]"> + {formatBinding(shortcuts[action], isMac)} + </kbd> + </div> + ))} + <div className="grid grid-cols-1 gap-2 pt-2 sm:grid-cols-3"> + <div className="rounded-lg border border-foreground/5 bg-foreground/5 px-3 py-2.5"> + <p className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground/70"> + {t("keyboardShortcuts.panTimeline")} + </p> + <kbd className="mt-2 inline-flex rounded border border-foreground/10 bg-foreground/10 px-2 py-1 font-mono text-[#2563EB]"> + {scrollLabels.pan} + </kbd> + </div> + <div className="rounded-lg border border-foreground/5 bg-foreground/5 px-3 py-2.5"> + <p className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground/70"> + {t("keyboardShortcuts.zoomTimeline")} + </p> + <kbd className="mt-2 inline-flex rounded border border-foreground/10 bg-foreground/10 px-2 py-1 font-mono text-[#2563EB]"> + {scrollLabels.zoom} + </kbd> + </div> + <div className="rounded-lg border border-foreground/5 bg-foreground/5 px-3 py-2.5"> + <p className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground/70"> + {t("keyboardShortcuts.cycleAnnotations")} + </p> + <kbd className="mt-2 inline-flex rounded border border-foreground/10 bg-foreground/10 px-2 py-1 font-mono text-[#2563EB]"> + {t("keyboardShortcuts.tab")} + </kbd> + </div> + </div> + </div> + <div className="flex justify-end"> + <Button + type="button" + variant="outline" + onClick={openConfig} + className="border-foreground/10 bg-foreground/5 text-foreground hover:bg-foreground/10 hover:text-foreground" + > + <Settings2 className="h-4 w-4" /> + {t("keyboardShortcuts.customize")} + </Button> + </div> + </div> + </DialogContent> + </Dialog> + ); +} + +export function TutorialHelp() { + const t = useScopedT("editor"); + + return ( + <Dialog> + <DialogTrigger asChild> + <Button variant="ghost" size="sm" className={APP_HEADER_ACTION_BUTTON_CLASS}> + <HelpCircle className="w-3.5 h-3.5" /> + <span className="font-medium">{t("tutorial.howTrimmingWorks")}</span> + </Button> + </DialogTrigger> + <DialogContent className="max-w-2xl bg-editor-dialog border-foreground/10 [&>button]:text-muted-foreground [&>button:hover]:text-foreground"> + <DialogHeader> + <DialogTitle className="text-xl font-semibold text-foreground flex items-center gap-2"> + <Scissors className="w-5 h-5 text-[#ef4444]" /> {t("tutorial.title")} + </DialogTitle> + <DialogDescription className="text-muted-foreground"> + {t("tutorial.understanding")} + </DialogDescription> + </DialogHeader> + <div className="mt-4 space-y-8"> + {/* Explanation */} + <div className="bg-foreground/5 rounded-lg p-4 border border-foreground/5"> + <p className="text-muted-foreground leading-relaxed"> + {t("tutorial.descriptionP1")} + <span className="text-[#ef4444] font-bold"> + {" "} + {t("tutorial.descriptionRemove")} + </span> + . {t("tutorial.descriptionP3")} + </p> + </div> + {/* Visual Illustration */} + <div className="space-y-2"> + <h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wider"> + {t("tutorial.visualExample")} + </h3> + <div className="relative h-24 bg-[#000] rounded-lg border border-foreground/10 flex items-center px-4 overflow-hidden select-none"> + {/* Background track (Kept parts) */} + <div className="absolute inset-x-4 h-2 bg-slate-600 rounded-full overflow-hidden"> + {/* Solid line representing video */} + </div> + {/* Removed Segment 1 */} + <div + className="absolute left-[20%] h-8 bg-[#ef4444]/20 border border-[#ef4444] rounded flex flex-col items-center justify-center z-10" + style={{ width: "20%" }} + > + <span className="text-[10px] font-bold text-[#ef4444] bg-black/50 px-1 rounded"> + {t("tutorial.removed")} + </span> + </div> + {/* Removed Segment 2 */} + <div + className="absolute left-[65%] h-8 bg-[#ef4444]/20 border border-[#ef4444] rounded flex flex-col items-center justify-center z-10" + style={{ width: "15%" }} + > + <span className="text-[10px] font-bold text-[#ef4444] bg-black/50 px-1 rounded"> + {t("tutorial.removed")} + </span> + </div> + {/* Labels for kept parts */} + <div className="absolute left-[5%] text-[10px] text-muted-foreground font-medium"> + {t("tutorial.kept")} + </div> + <div className="absolute left-[50%] text-[10px] text-muted-foreground font-medium"> + {t("tutorial.kept")} + </div> + <div className="absolute left-[90%] text-[10px] text-muted-foreground font-medium"> + {t("tutorial.kept")} + </div> + </div> + <div className="flex justify-center mt-2"> + <ArrowRight className="w-4 h-4 text-muted-foreground rotate-90" /> + </div> + {/* Result */} + <div className="relative h-12 bg-[#000] rounded-lg border border-foreground/10 flex items-center justify-center gap-1 px-4 select-none"> + <div + className="h-8 bg-slate-700 rounded flex items-center justify-center opacity-80" + style={{ width: "30%" }} + > + <span className="text-[10px] text-white font-medium"> + {t("tutorial.part", undefined, { number: "1" })} + </span> + </div> + <div + className="h-8 bg-slate-700 rounded flex items-center justify-center opacity-80" + style={{ width: "30%" }} + > + <span className="text-[10px] text-white font-medium"> + {t("tutorial.part", undefined, { number: "2" })} + </span> + </div> + <div + className="h-8 bg-slate-700 rounded flex items-center justify-center opacity-80" + style={{ width: "30%" }} + > + <span className="text-[10px] text-white font-medium"> + {t("tutorial.part", undefined, { number: "3" })} + </span> + </div> + <span className="absolute right-4 text-xs text-muted-foreground"> + {t("tutorial.finalVideo")} + </span> + </div> + </div> + {/* Steps */} + <div className="grid grid-cols-2 gap-4"> + <div className="p-3 rounded bg-foreground/5 border border-foreground/5"> + <div className="text-[#ef4444] font-bold mb-1"> + {t("tutorial.addTrimStep")} + </div> + <p className="text-xs text-muted-foreground"> + {t("tutorial.addTrimDesc")} + </p> + </div> + <div className="p-3 rounded bg-foreground/5 border border-foreground/5"> + <div className="text-[#ef4444] font-bold mb-1"> + {t("tutorial.adjustStep")} + </div> + <p className="text-xs text-muted-foreground"> + {t("tutorial.adjustDesc")} + </p> + </div> + </div> + </div> + </DialogContent> + </Dialog> + ); +} diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 62d671f82..84c973b4c 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -1,344 +1,386 @@ - - -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { Toaster } from "@/components/ui/sonner"; -import { toast } from "sonner"; - -import VideoPlayback, { VideoPlaybackRef } from "./VideoPlayback"; -import PlaybackControls from "./PlaybackControls"; -import TimelineEditor from "./timeline/TimelineEditor"; -import { SettingsPanel } from "./SettingsPanel"; -import { ExportDialog } from "./ExportDialog"; -import type { Span } from "dnd-timeline"; -import { - DEFAULT_ZOOM_DEPTH, - clampFocusToDepth, - DEFAULT_CROP_REGION, - type ZoomDepth, - type ZoomFocus, - type ZoomRegion, - type CropRegion, -} from "./types"; -import { VideoExporter, type ExportProgress } from "@/lib/exporter"; - -const WALLPAPER_COUNT = 12; -const WALLPAPER_PATHS = Array.from({ length: WALLPAPER_COUNT }, (_, i) => `/wallpapers/wallpaper${i + 1}.jpg`); +/* biome-ignore-all lint/correctness/useExhaustiveDependencies: setters returned by the editor's domain-state hooks are stable React dispatchers. */ +import { useCallback, useEffect, useMemo } from "react"; +import { useI18n } from "@/contexts/I18nContext"; +import { useShortcuts } from "@/contexts/ShortcutsContext"; +import { getAspectRatioValue } from "@/utils/aspectRatioUtils"; +import { loadEditorPreferences } from "./editorPreferences"; +import { useEditorExportController } from "./export/useEditorExportController"; +import { useExportDimensions } from "./export/useExportDimensions"; +import { useExportSession } from "./export/useExportSession"; +import { useExportSettings } from "./export/useExportSettings"; +import { useTimelineEditingController } from "./hooks/useTimelineEditingController"; +import { EditorShell } from "./layout/EditorShell"; +import { useEditorSettingsPanelProps } from "./layout/useEditorSettingsPanelProps"; +import { useVideoEditorPresets } from "./presets/useVideoEditorPresets"; +import { useEditorProjectController } from "./project/useEditorProjectController"; +import { useProjectLibraryController } from "./project/useProjectLibraryController"; +import { getDevOpenRecordingConfig, getSmokeExportConfig } from "./smokeExportConfig"; +import { useAppearanceState } from "./state/useAppearanceState"; +import { useEditorUiState } from "./state/useEditorUiState"; +import { useProjectState } from "./state/useProjectState"; +import { useTimelineState } from "./state/useTimelineState"; +import { useNvidiaCudaExportOptIn } from "./useNvidiaCudaExportOptIn"; export default function VideoEditor() { - const [videoPath, setVideoPath] = useState<string | null>(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState<string | null>(null); - const [isPlaying, setIsPlaying] = useState(false); - const [currentTime, setCurrentTime] = useState(0); - const [duration, setDuration] = useState(0); - const [wallpaper, setWallpaper] = useState<string>(WALLPAPER_PATHS[0]); - const [showShadow, setShowShadow] = useState(false); - const [showBlur, setShowBlur] = useState(false); - const [cropRegion, setCropRegion] = useState<CropRegion>(DEFAULT_CROP_REGION); - const [zoomRegions, setZoomRegions] = useState<ZoomRegion[]>([]); - const [selectedZoomId, setSelectedZoomId] = useState<string | null>(null); - const [isExporting, setIsExporting] = useState(false); - const [exportProgress, setExportProgress] = useState<ExportProgress | null>(null); - const [exportError, setExportError] = useState<string | null>(null); - const [showExportDialog, setShowExportDialog] = useState(false); - - const videoPlaybackRef = useRef<VideoPlaybackRef>(null); - const nextZoomIdRef = useRef(1); - const exporterRef = useRef<VideoExporter | null>(null); - - useEffect(() => { - async function loadVideo() { - try { - const result = await window.electronAPI.getRecordedVideoPath(); - if (result.success && result.path) { - setVideoPath(`file://${result.path}`); - } else { - setError(result.message || 'Failed to load video'); - } - } catch (err) { - setError('Error loading video: ' + String(err)); - } finally { - setLoading(false); - } - } - loadVideo(); - }, []); - - function togglePlayPause() { - const playback = videoPlaybackRef.current; - const video = playback?.video; - console.log('Toggle play/pause:', { hasVideo: !!video, isPlaying, action: isPlaying ? 'pause' : 'play' }); - if (!playback || !video) return; - - if (isPlaying) { - playback.pause(); - } else { - playback.play().catch(err => console.error('Video play failed:', err)); - } - } - - function handleSeek(time: number) { - const video = videoPlaybackRef.current?.video; - if (!video) return; - video.currentTime = time; - } - - const handleSelectZoom = useCallback((id: string | null) => { - setSelectedZoomId(id); - }, []); - - const handleZoomAdded = useCallback((span: Span) => { - const id = `zoom-${nextZoomIdRef.current++}`; - const newRegion: ZoomRegion = { - id, - startMs: Math.round(span.start), - endMs: Math.round(span.end), - depth: DEFAULT_ZOOM_DEPTH, - focus: { cx: 0.5, cy: 0.5 }, - }; - console.log('Zoom region added:', newRegion); - setZoomRegions((prev) => [...prev, newRegion]); - setSelectedZoomId(id); - }, []); - - const handleZoomSpanChange = useCallback((id: string, span: Span) => { - console.log('Zoom span changed:', { id, start: Math.round(span.start), end: Math.round(span.end) }); - setZoomRegions((prev) => - prev.map((region) => - region.id === id - ? { - ...region, - startMs: Math.round(span.start), - endMs: Math.round(span.end), - } - : region, - ), - ); - }, []); - - const handleZoomFocusChange = useCallback((id: string, focus: ZoomFocus) => { - setZoomRegions((prev) => - prev.map((region) => - region.id === id - ? { - ...region, - focus: clampFocusToDepth(focus, region.depth), - } - : region, - ), - ); - }, []); - - const handleZoomDepthChange = useCallback((depth: ZoomDepth) => { - if (!selectedZoomId) return; - setZoomRegions((prev) => - prev.map((region) => - region.id === selectedZoomId - ? { - ...region, - depth, - focus: clampFocusToDepth(region.focus, depth), - } - : region, - ), - ); - }, [selectedZoomId]); - - const handleZoomDelete = useCallback((id: string) => { - console.log('Zoom region deleted:', id); - setZoomRegions((prev) => prev.filter((region) => region.id !== id)); - if (selectedZoomId === id) { - setSelectedZoomId(null); - } - }, [selectedZoomId]); - - const selectedZoom = useMemo(() => { - if (!selectedZoomId) return null; - return zoomRegions.find((region) => region.id === selectedZoomId) ?? null; - }, [selectedZoomId, zoomRegions]); - - useEffect(() => { - if (selectedZoomId && !zoomRegions.some((region) => region.id === selectedZoomId)) { - setSelectedZoomId(null); - } - }, [selectedZoomId, zoomRegions]); - - const handleExport = useCallback(async () => { - if (!videoPath) { - toast.error('No video loaded'); - return; - } - - const video = videoPlaybackRef.current?.video; - if (!video) { - toast.error('Video not ready'); - return; - } - - setShowExportDialog(true); - setIsExporting(true); - setExportProgress(null); - setExportError(null); + const { t } = useI18n(); + const smokeExportConfig = useMemo( + () => getSmokeExportConfig(typeof window === "undefined" ? "" : window.location.search), + [], + ); + const devOpenRecordingConfig = useMemo( + () => + getDevOpenRecordingConfig(typeof window === "undefined" ? "" : window.location.search), + [], + ); + const initialEditorPreferences = useMemo(() => loadEditorPreferences(), []); + const timeline = useTimelineState(); + const project = useProjectState(); + const { videoPath, setVideoPath, videoSourcePath, setVideoSourcePath, loading, error } = + project; + const appearance = useAppearanceState(initialEditorPreferences); + const { showCursor, setShowCursor, cropRegion, setCropRegion } = appearance; + const ui = useEditorUiState(initialEditorPreferences, cropRegion, setCropRegion); + const { + appPlatform, + isPlaying, + setIsPlaying, + currentTime, + setCurrentTime, + duration, + setDuration, + sessionShowCursorOverride, + setSessionShowCursorOverride, + sessionNativeCaptureUnavailable, + setNativeCaptureUnavailableModalOpen, + whisperExecutablePath, + setWhisperExecutablePath, + whisperModelPath, + setWhisperModelPath, + downloadedWhisperModelPath, + setDownloadedWhisperModelPath, + whisperModelDownloadStatus, + setWhisperModelDownloadStatus, + whisperModelDownloadProgress, + setWhisperModelDownloadProgress, + isGeneratingCaptions, + setIsGeneratingCaptions, + previewVolume, + aspectRatio, + setAspectRatio, + activeEffectSection, + setActiveEffectSection, + setPreviewVersion, + isPreviewReady, + setIsPreviewReady, + setAutoSuggestZoomsTrigger, + videoPlaybackRef, + projectNameInputRef, + projectSaveDialogInputRef, + nextZoomIdRef, + nextClipIdRef, + clipInitializedRef, + autoFullTrackClipIdRef, + autoFullTrackClipEndMsRef, + nextAudioIdRef, + nextAnnotationIdRef, + nextAnnotationZIndexRef, + autoSuggestedVideoPathRef, + pendingFreshRecordingAutoZoomPathRef, + pendingFreshRecordingAutoSuggestTimeoutRef, + pendingFreshRecordingAutoSuggestTelemetryCountRef, + timelineRef, + applySessionPresentation, + } = ui; + const effectiveShowCursor = sessionShowCursorOverride ?? showCursor; + const headerLeftControlsPaddingClass = appPlatform === "darwin" ? "pl-[76px]" : ""; + const { cursorTelemetrySourcePath, autoCaptions, autoCaptionSettings } = timeline; + const exportSettings = useExportSettings(initialEditorPreferences, autoCaptions); + const { + includeCaptionSidecar, + mp4FrameRate, + gifSizePreset, + captionSidecarCues, + setExportPipelineModel, + } = exportSettings; + const exportSession = useExportSession(); + const { exporterRef, pendingExportSaveRef } = exportSession; + const enableModernExportPipeline = useCallback(() => { + setExportPipelineModel("modern"); + }, []); + const { + nvidiaCudaExportAvailable, + experimentalNvidiaCudaExport, + setExperimentalNvidiaCudaExport, + } = useNvidiaCudaExportOptIn({ + onEnabled: enableModernExportPipeline, + }); + const hasCaptionsForSidecar = autoCaptionSettings.enabled && autoCaptions.length > 0; + const captionSidecarPayload = + hasCaptionsForSidecar && captionSidecarCues.length > 0 && includeCaptionSidecar + ? { + format: "both" as const, + cues: captionSidecarCues, + } + : undefined; + const { shortcuts, isMac } = useShortcuts(); - try { - const wasPlaying = isPlaying; - if (wasPlaying) { - videoPlaybackRef.current?.pause(); - } + useEffect(() => { + autoSuggestedVideoPathRef.current = null; + pendingFreshRecordingAutoSuggestTelemetryCountRef.current = 0; + if (pendingFreshRecordingAutoSuggestTimeoutRef.current !== null) { + window.clearTimeout(pendingFreshRecordingAutoSuggestTimeoutRef.current); + pendingFreshRecordingAutoSuggestTimeoutRef.current = null; + } + }, []); - const width = 1920; - const height = 1080; + const presets = useVideoEditorPresets({ + t, + appearance, + timeline, + exportSettings, + aspectRatio, + setAspectRatio, + whisperExecutablePath, + setWhisperExecutablePath, + whisperModelPath, + setWhisperModelPath, + }); + const { refreshProjectLibrary, captureProjectThumbnail } = useProjectLibraryController({ + project, + appearance, + timeline, + videoPlaybackRef, + currentTime, + effectiveShowCursor, + }); + const handleShowCursorChange = useCallback((nextShowCursor: boolean) => { + setSessionShowCursorOverride(null); + setShowCursor(nextShowCursor); + }, []); - const exporter = new VideoExporter({ - videoUrl: videoPath, - width, - height, - frameRate: 60, - bitrate: 15_000_000, - codec: 'avc1.640033', - wallpaper, - zoomRegions, - showShadow, - showBlur, - cropRegion, - onProgress: (progress: ExportProgress) => { - setExportProgress(progress); - }, - }); + const remountPreview = useCallback(() => { + setIsPreviewReady(false); + setPreviewVersion((version) => version + 1); + }, []); - exporterRef.current = exporter; - const result = await exporter.export(); + useEffect(() => { + return () => { + exporterRef.current?.cancel(); + exporterRef.current = null; + const pending = pendingExportSaveRef.current; + pendingExportSaveRef.current = null; + if (pending?.tempFilePath && typeof window !== "undefined") { + void window.electronAPI.discardExportedTemp?.(pending.tempFilePath); + } + if (pendingFreshRecordingAutoSuggestTimeoutRef.current !== null) { + window.clearTimeout(pendingFreshRecordingAutoSuggestTimeoutRef.current); + pendingFreshRecordingAutoSuggestTimeoutRef.current = null; + } + }; + }, []); - if (result.success && result.blob) { - const arrayBuffer = await result.blob.arrayBuffer(); - const timestamp = Date.now(); - const fileName = `export-${timestamp}.mp4`; - - const saveResult = await window.electronAPI.saveExportedVideo(arrayBuffer, fileName); - - if (saveResult.success) { - toast.success('Video exported successfully!'); - } else { - setExportError(saveResult.message || 'Failed to save video'); - toast.error(saveResult.message || 'Failed to save video'); - } - } else { - setExportError(result.error || 'Export failed'); - toast.error(result.error || 'Export failed'); - } + useEffect(() => { + void refreshProjectLibrary(); + }, [refreshProjectLibrary]); - if (wasPlaying) { - videoPlaybackRef.current?.play(); - } - } catch (error) { - console.error('Export error:', error); - const errorMessage = error instanceof Error ? error.message : 'Unknown error'; - setExportError(errorMessage); - toast.error(`Export failed: ${errorMessage}`); - } finally { - setIsExporting(false); - exporterRef.current = null; - } - }, [videoPath, wallpaper, zoomRegions, showShadow, showBlur, cropRegion, isPlaying]); + const exportDimensions = useExportDimensions({ + videoPlaybackRef, + isPreviewReady, + aspectRatio, + cropRegion, + mp4FrameRate, + gifSizePreset, + }); - const handleCancelExport = useCallback(() => { - if (exporterRef.current) { - exporterRef.current.cancel(); - toast.info('Export cancelled'); - setShowExportDialog(false); - setIsExporting(false); - setExportProgress(null); - setExportError(null); - } - }, []); + const projectController = useEditorProjectController({ + t, + project, + appearance, + timeline, + exportSettings, + initialPreferences: initialEditorPreferences, + smokeConfig: smokeExportConfig, + devConfig: devOpenRecordingConfig, + aspectRatio, + setAspectRatio, + videoPath, + setVideoPath, + videoSourcePath, + setVideoSourcePath, + currentTime, + setCurrentTime, + setIsPlaying, + setDuration, + whisperExecutablePath, + setWhisperExecutablePath, + whisperModelPath, + setWhisperModelPath, + downloadedWhisperModelPath, + setDownloadedWhisperModelPath, + whisperModelDownloadStatus, + setWhisperModelDownloadStatus, + setWhisperModelDownloadProgress, + isGeneratingCaptions, + setIsGeneratingCaptions, + videoPlaybackRef, + projectNameInputRef, + projectSaveDialogInputRef, + nextZoomIdRef, + nextClipIdRef, + nextAudioIdRef, + nextAnnotationIdRef, + nextAnnotationZIndexRef, + clipInitializedRef, + autoFullTrackClipIdRef, + autoFullTrackClipEndMsRef, + pendingFreshRecordingAutoZoomPathRef, + pendingFreshRecordingAutoSuggestTelemetryCountRef, + autoSuggestedVideoPathRef, + applySessionPresentation, + refreshProjectLibrary, + captureProjectThumbnail, + remountPreview, + }); + const { + snapshot: { currentSourcePath }, + history: { handleUndo, handleRedo }, + lifecycle: { handleUploadWebcam, handleClearWebcam }, + autoCaption: autoCaptionController, + } = projectController; - if (loading) { - return ( - <div className="flex items-center justify-center h-screen bg-background"> - <div className="text-foreground">Loading video...</div> - </div> - ); - } - if (error) { - return ( - <div className="flex items-center justify-center h-screen bg-background"> - <div className="text-destructive">{error}</div> - </div> - ); - } + const editing = useTimelineEditingController({ + t, + shortcuts, + isMac, + timeline, + appearance, + videoPath, + videoSourcePath, + currentSourcePath, + duration, + currentTime, + isPlaying, + previewVolume, + loading, + isPreviewReady, + setActiveEffectSection, + setAutoSuggestZoomsTrigger, + videoPlaybackRef, + timelineRef, + nextZoomIdRef, + nextClipIdRef, + nextAudioIdRef, + nextAnnotationIdRef, + nextAnnotationZIndexRef, + clipInitializedRef, + autoFullTrackClipIdRef, + autoFullTrackClipEndMsRef, + autoSuggestedVideoPathRef, + pendingFreshRecordingAutoZoomPathRef, + pendingFreshRecordingAutoSuggestTimeoutRef, + pendingFreshRecordingAutoSuggestTelemetryCountRef, + handleUndo, + handleRedo, + }); + const { + cursor: { effectiveCursorTelemetry }, + projection, + audio, + captionCommands, + zoomCommands, + clipCommands, + audioCommands, + annotationCommands, + } = editing; + const { effectiveSpeedRegions, effectiveZoomRegions } = projection; - return ( - <div className="flex h-screen bg-background bg-black p-8 gap-8"> - <Toaster position="top-center" /> - <ExportDialog - isOpen={showExportDialog} - onClose={() => setShowExportDialog(false)} - progress={exportProgress} - isExporting={isExporting} - error={exportError} - onCancel={handleCancelExport} - /> - <div className="flex flex-col flex-[7] min-w-0 gap-6"> - <div className="flex flex-col gap-3 flex-1"> - {videoPath && ( - <> - <div className="flex justify-center w-full"> - <VideoPlayback - ref={videoPlaybackRef} - videoPath={videoPath} - onDurationChange={setDuration} - onTimeUpdate={setCurrentTime} - onPlayStateChange={setIsPlaying} - onError={setError} - wallpaper={wallpaper} - zoomRegions={zoomRegions} - selectedZoomId={selectedZoomId} - onSelectZoom={handleSelectZoom} - onZoomFocusChange={handleZoomFocusChange} - isPlaying={isPlaying} - showShadow={showShadow} - showBlur={showBlur} - cropRegion={cropRegion} - /> - </div> - <PlaybackControls - isPlaying={isPlaying} - currentTime={currentTime} - duration={duration} - onTogglePlayPause={togglePlayPause} - onSeek={handleSeek} - /> - </> - )} - </div> - <TimelineEditor - videoDuration={duration} - currentTime={currentTime} - onSeek={handleSeek} - zoomRegions={zoomRegions} - onZoomAdded={handleZoomAdded} - onZoomSpanChange={handleZoomSpanChange} - onZoomDelete={handleZoomDelete} - selectedZoomId={selectedZoomId} - onSelectZoom={handleSelectZoom} - /> - </div> - <SettingsPanel - selected={wallpaper} - onWallpaperChange={setWallpaper} - selectedZoomDepth={selectedZoom?.depth} - onZoomDepthChange={handleZoomDepthChange} - selectedZoomId={selectedZoomId} - onZoomDelete={handleZoomDelete} - showShadow={showShadow} - onShadowChange={setShowShadow} - showBlur={showBlur} - onBlurChange={setShowBlur} - cropRegion={cropRegion} - onCropChange={setCropRegion} - videoElement={videoPlaybackRef.current?.video || null} - onExport={handleExport} - /> - </div> - ); -} \ No newline at end of file + const exportController = useEditorExportController({ + t, + videoPath, + videoSourcePath, + videoPlaybackRef, + isPlaying, + duration, + error, + loading, + isPreviewReady, + appearance, + timeline, + settings: exportSettings, + session: exportSession, + dimensions: exportDimensions, + audio, + smokeConfig: smokeExportConfig, + effectiveSpeedRegions, + effectiveZoomRegions, + effectiveCursorTelemetry, + effectiveShowCursor, + cursorTelemetrySourcePath, + hasCaptionsForSidecar, + captionSidecarPayload, + experimentalNvidiaCudaExport, + nvidiaCudaExportAvailable, + remountPreview, + }); + const previewAspectRatioValue = getAspectRatioValue( + aspectRatio, + (() => { + const previewVideo = videoPlaybackRef.current?.video; + if (previewVideo && previewVideo.videoHeight > 0) { + return previewVideo.videoWidth / previewVideo.videoHeight; + } + return 16 / 9; + })(), + ); + const settingsPanelProps = useEditorSettingsPanelProps({ + activeEffectSection, + appearance, + timeline, + audio, + zoomCommands, + clipCommands, + audioCommands, + captionCommands, + annotationCommands, + autoCaptionController, + effectiveShowCursor, + handleShowCursorChange, + currentTime, + isPlaying, + aspectRatio, + setAspectRatio, + whisperExecutablePath, + whisperModelPath, + whisperModelDownloadStatus, + whisperModelDownloadProgress, + isGeneratingCaptions, + sessionNativeCaptureUnavailable, + setNativeCaptureUnavailableModalOpen, + handleUploadWebcam, + handleClearWebcam, + }); + return ( + <EditorShell + t={t} + project={project} + appearance={appearance} + timeline={timeline} + ui={ui} + presets={presets} + projectController={projectController} + editing={editing} + exportController={exportController} + exportSettings={exportSettings} + exportSession={exportSession} + exportDimensions={exportDimensions} + settingsPanelProps={settingsPanelProps} + headerLeftControlsPaddingClass={headerLeftControlsPaddingClass} + hasCaptionsForSidecar={hasCaptionsForSidecar} + nvidiaCudaExportAvailable={nvidiaCudaExportAvailable} + experimentalNvidiaCudaExport={experimentalNvidiaCudaExport} + setExperimentalNvidiaCudaExport={setExperimentalNvidiaCudaExport} + effectiveShowCursor={effectiveShowCursor} + previewAspectRatioValue={previewAspectRatioValue} + /> + ); +} diff --git a/src/components/video-editor/VideoPlayback.tsx b/src/components/video-editor/VideoPlayback.tsx index 106c6c6b3..c1853badf 100644 --- a/src/components/video-editor/VideoPlayback.tsx +++ b/src/components/video-editor/VideoPlayback.tsx @@ -1,764 +1,2923 @@ +import { Application, Container, Graphics, Rectangle, Sprite, Texture, VideoSource } from "pixi.js"; +import { MotionBlurFilter } from "pixi-filters/motion-blur"; +import { ZoomBlurFilter } from "pixi-filters/zoom-blur"; import type React from "react"; -import { useEffect, useRef, useImperativeHandle, forwardRef, useState, useMemo, useCallback } from "react"; -import { getAssetPath } from "@/lib/assetPath"; -import * as PIXI from 'pixi.js'; -import { ZOOM_DEPTH_SCALES, type ZoomRegion, type ZoomFocus, type ZoomDepth } from "./types"; -import { DEFAULT_FOCUS, SMOOTHING_FACTOR, MIN_DELTA } from "./videoPlayback/constants"; -import { clamp01 } from "./videoPlayback/mathUtils"; -import { findDominantRegion } from "./videoPlayback/zoomRegionUtils"; +import { + forwardRef, + useCallback, + useEffect, + useImperativeHandle, + useMemo, + useRef, + useState, +} from "react"; +import { getAssetPath, getRenderableAssetUrl, getRenderableVideoUrl } from "@/lib/assetPath"; +import { getWebcamShadowFilter } from "@/lib/exporter/shadowProfile"; +import { getSquircleSvgPath } from "@/lib/geometry/squircle"; +import { + clampMediaTimeToDuration, + enablePitchPreservingPlayback, + getMediaSyncPlaybackRate, +} from "@/lib/mediaTiming"; +import { + destroyPixiApplication, + initializePixiApplicationWithTimeout, +} from "@/lib/pixiApplicationLifecycle"; +import { + DEFAULT_WALLPAPER_PATH, + DEFAULT_WALLPAPER_RELATIVE_PATH, + isVideoWallpaperSource, +} from "@/lib/wallpapers"; +import { type AspectRatio, formatAspectRatioForCSS } from "@/utils/aspectRatioUtils"; +import { AnnotationOverlay } from "./AnnotationOverlay"; +import { type CaptionEditTarget, normalizeCaptionEditText } from "./captionEditing"; +import { buildActiveCaptionLayout } from "./captionLayout"; +import { + CAPTION_FONT_WEIGHT, + CAPTION_LINE_HEIGHT, + getCaptionPadding, + getCaptionScaledFontSize, + getCaptionScaledRadius, + getCaptionTextMaxWidth, + getCaptionWordVisualState, +} from "./captionStyle"; +import { + type AnnotationRegion, + type AutoCaptionSettings, + type CaptionCue, + type CursorClickEffectStyle, + type CursorStyle, + DEFAULT_CONNECTED_ZOOM_DURATION_MS, + DEFAULT_CONNECTED_ZOOM_EASING, + DEFAULT_CONNECTED_ZOOM_GAP_MS, + DEFAULT_CURSOR_CLICK_BOUNCE, + DEFAULT_CURSOR_CLICK_BOUNCE_DURATION, + DEFAULT_CURSOR_CLICK_EFFECT, + DEFAULT_CURSOR_CLICK_EFFECT_COLOR, + DEFAULT_CURSOR_CLICK_EFFECT_DURATION_MS, + DEFAULT_CURSOR_CLICK_EFFECT_OPACITY, + DEFAULT_CURSOR_CLICK_EFFECT_SCALE, + DEFAULT_CURSOR_MOTION_BLUR, + DEFAULT_CURSOR_SIZE, + DEFAULT_CURSOR_SMOOTHING, + DEFAULT_CURSOR_STYLE, + DEFAULT_CURSOR_SWAY, + DEFAULT_PADDING, + DEFAULT_WEBCAM_REACT_TO_ZOOM, + DEFAULT_WEBCAM_ROUNDNESS, + DEFAULT_WEBCAM_SHADOW, + DEFAULT_WEBCAM_SIZE, + DEFAULT_ZOOM_IN_DURATION_MS, + DEFAULT_ZOOM_IN_EASING, + DEFAULT_ZOOM_IN_OVERLAP_MS, + DEFAULT_ZOOM_MOTION_BLUR, + DEFAULT_ZOOM_MOTION_BLUR_TUNING, + DEFAULT_ZOOM_OUT_DURATION_MS, + DEFAULT_ZOOM_OUT_EASING, + getDefaultCaptionFontFamily, + type Padding, + type SpeedRegion, + type TrimRegion, + type WebcamOverlaySettings, + ZOOM_DEPTH_SCALES, + type ZoomDepth, + type ZoomFocus, + type ZoomMotionBlurTuning, + type ZoomRegion, + type ZoomTransitionEasing, +} from "./types"; +import { DEFAULT_FOCUS } from "./videoPlayback/constants"; +import { + type CursorFollowCameraState, + computeCursorFollowFocus, + createCursorFollowCameraState, + resetCursorFollowCamera, + SNAP_TO_EDGES_RATIO_AUTO, +} from "./videoPlayback/cursorFollowCamera"; +import { + DEFAULT_CURSOR_CONFIG, + PixiCursorOverlay, + preloadCursorAssets, +} from "./videoPlayback/cursorRenderer"; import { clampFocusToStage as clampFocusToStageUtil } from "./videoPlayback/focusUtils"; -import { updateOverlayIndicator } from "./videoPlayback/overlayUtils"; import { layoutVideoContent as layoutVideoContentUtil } from "./videoPlayback/layoutUtils"; -import { applyZoomTransform } from "./videoPlayback/zoomTransform"; +import { clamp01 } from "./videoPlayback/mathUtils"; +import { + createSpringState, + getZoomSpringConfig, + resetSpringState, + type SpringState, + stepSpringValue, +} from "./videoPlayback/motionSmoothing"; +import { updateOverlayIndicator } from "./videoPlayback/overlayUtils"; import { createVideoEventHandlers } from "./videoPlayback/videoEventHandlers"; +import { + getWebcamMediaTargetTimeSeconds, + isWebcamMediaSynchronized, + shouldSeekWebcamMedia, +} from "./videoPlayback/webcamSync"; +import { findDominantRegion } from "./videoPlayback/zoomRegionUtils"; +import { + applyZoomTransform, + computeZoomTransform, + createMotionBlurState, + type MotionBlurState, +} from "./videoPlayback/zoomTransform"; +import { + getCropMatchedWebcamHeightPercent, + getWebcamCornerRadiusPx, + getWebcamCropSourceRect, + getWebcamOverlayDimensionsPx, + getWebcamOverlayPosition, + scaleWebcamOverlayPixels, +} from "./webcamOverlay"; + +type PlaybackAnimationState = { + scale: number; + appliedScale: number; + focusX: number; + focusY: number; + progress: number; + x: number; + y: number; +}; + +type CaptionEditSession = { + target: CaptionEditTarget; + draft: string; +}; + +type SceneTransformState = { + scale: number; + x: number; + y: number; +}; + +type AnnotationRecordingRect = { + x: number; + y: number; + width: number; + height: number; +}; + +function createPlaybackAnimationState(): PlaybackAnimationState { + return { + scale: 1, + appliedScale: 1, + focusX: DEFAULT_FOCUS.cx, + focusY: DEFAULT_FOCUS.cy, + progress: 0, + x: 0, + y: 0, + }; +} + +type PixiPreviewBackend = "webgpu" | "webgl"; +type PixiRendererAttempt = { + backend: PixiPreviewBackend; + message: string; +}; +const PIXI_RENDERER_INIT_TIMEOUT_MS = 8_000; + +function isCanvasRenderer(application: Application): boolean { + const rendererName = application?.renderer?.constructor?.name?.toLowerCase(); + return Boolean( + rendererName && + (rendererName.includes("canvasrenderer") || rendererName.includes("canvas")), + ); +} + +function toRendererErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error ?? "Unknown renderer init error"); +} + +function isRendererUnavailableError(error: unknown): boolean { + const message = toRendererErrorMessage(error).toLowerCase(); + return ( + message.includes("canvasrenderer is not yet implemented") || + message.includes("no available renderer") + ); +} + +function summarizeRendererAttempts(attempts: readonly PixiRendererAttempt[]): string { + const details = attempts.map((attempt) => `${attempt.backend}: ${attempt.message}`).join(" | "); + return `No supported Pixi preview renderer was available. Attempted: ${details}`; +} + +function getEffectiveNativeAspectRatio( + dimensions: { width: number; height: number } | null | undefined, + cropRegion?: import("./types").CropRegion, +): number { + if (!dimensions || dimensions.height <= 0 || dimensions.width <= 0) { + return 16 / 9; + } + + const cropWidth = cropRegion?.width ?? 1; + const cropHeight = cropRegion?.height ?? 1; + const effectiveWidth = dimensions.width * cropWidth; + const effectiveHeight = dimensions.height * cropHeight; + + if (effectiveWidth <= 0 || effectiveHeight <= 0) { + return dimensions.width / dimensions.height; + } + + return effectiveWidth / effectiveHeight; +} interface VideoPlaybackProps { - videoPath: string; - onDurationChange: (duration: number) => void; - onTimeUpdate: (time: number) => void; - onPlayStateChange: (playing: boolean) => void; - onError: (error: string) => void; - wallpaper?: string; - zoomRegions: ZoomRegion[]; - selectedZoomId: string | null; - onSelectZoom: (id: string | null) => void; - onZoomFocusChange: (id: string, focus: ZoomFocus) => void; - isPlaying: boolean; - showShadow?: boolean; - showBlur?: boolean; - cropRegion?: import('./types').CropRegion; + videoPath: string; + onDurationChange: (duration: number) => void; + onPreviewReadyChange?: (ready: boolean) => void; + onTimeUpdate: (time: number) => void; + currentTime: number; + onPlayStateChange: (playing: boolean) => void; + onError: (error: string) => void; + wallpaper?: string; + zoomRegions: ZoomRegion[]; + selectedZoomId: string | null; + onSelectZoom: (id: string | null) => void; + onZoomFocusChange: (id: string, focus: ZoomFocus) => void; + isPlaying: boolean; + showShadow?: boolean; + shadowIntensity?: number; + backgroundBlur?: number; + connectZooms?: boolean; + zoomInDurationMs?: number; + zoomInOverlapMs?: number; + zoomOutDurationMs?: number; + connectedZoomGapMs?: number; + connectedZoomDurationMs?: number; + zoomInEasing?: ZoomTransitionEasing; + zoomOutEasing?: ZoomTransitionEasing; + connectedZoomEasing?: ZoomTransitionEasing; + borderRadius?: number; + padding?: Padding | number; + cropRegion?: import("./types").CropRegion; + webcam?: WebcamOverlaySettings; + webcamVideoPath?: string | null; + trimRegions?: TrimRegion[]; + speedRegions?: SpeedRegion[]; + aspectRatio: AspectRatio; + annotationRegions?: AnnotationRegion[]; + autoCaptions?: CaptionCue[]; + autoCaptionSettings?: AutoCaptionSettings; + onEditAutoCaption?: (target: CaptionEditTarget, text: string) => void; + selectedAnnotationId?: string | null; + onSelectAnnotation?: (id: string | null) => void; + onAnnotationPositionChange?: (id: string, position: { x: number; y: number }) => void; + onAnnotationSizeChange?: (id: string, size: { width: number; height: number }) => void; + cursorTelemetry?: CursorTelemetryPoint[]; + showCursor?: boolean; + cursorStyle?: CursorStyle; + cursorSize?: number; + cursorSmoothing?: number; + cursorSpringStiffnessMultiplier?: number; + cursorSpringDampingMultiplier?: number; + cursorSpringMassMultiplier?: number; + cameraSpringStiffnessMultiplier?: number; + cameraSpringDampingMultiplier?: number; + cameraSpringMassMultiplier?: number; + zoomSmoothness?: number; + zoomClassicMode?: boolean; + zoomMotionBlur?: number; + zoomMotionBlurTuning?: ZoomMotionBlurTuning; + cursorMotionBlur?: number; + cursorClickEffect?: CursorClickEffectStyle; + cursorClickEffectColor?: string; + cursorClickEffectScale?: number; + cursorClickEffectOpacity?: number; + cursorClickEffectDurationMs?: number; + cursorClickBounce?: number; + cursorClickBounceDuration?: number; + cursorSway?: number; + volume?: number; + suspendRendering?: boolean; } export interface VideoPlaybackRef { - video: HTMLVideoElement | null; - app: PIXI.Application | null; - videoSprite: PIXI.Sprite | null; - videoContainer: PIXI.Container | null; - play: () => Promise<void>; - pause: () => void; + video: HTMLVideoElement | null; + app: Application | null; + videoSprite: Sprite | null; + videoContainer: Container | null; + containerRef: React.RefObject<HTMLDivElement>; + play: () => Promise<void>; + pause: () => void; + refreshFrame: () => Promise<void>; + cancelCaptionEdit: () => void; } -const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(({ - videoPath, - onDurationChange, - onTimeUpdate, - onPlayStateChange, - onError, - wallpaper, - zoomRegions, - selectedZoomId, - onSelectZoom, - onZoomFocusChange, - isPlaying, - showShadow, - showBlur, - cropRegion, -}, ref) => { - const videoRef = useRef<HTMLVideoElement | null>(null); - const containerRef = useRef<HTMLDivElement | null>(null); - const appRef = useRef<PIXI.Application | null>(null); - const videoSpriteRef = useRef<PIXI.Sprite | null>(null); - const videoContainerRef = useRef<PIXI.Container | null>(null); - const cameraContainerRef = useRef<PIXI.Container | null>(null); - const timeUpdateAnimationRef = useRef<number | null>(null); - const [pixiReady, setPixiReady] = useState(false); - const [videoReady, setVideoReady] = useState(false); - const overlayRef = useRef<HTMLDivElement | null>(null); - const focusIndicatorRef = useRef<HTMLDivElement | null>(null); - const currentTimeRef = useRef(0); - const zoomRegionsRef = useRef<ZoomRegion[]>([]); - const selectedZoomIdRef = useRef<string | null>(null); - const animationStateRef = useRef({ scale: 1, focusX: DEFAULT_FOCUS.cx, focusY: DEFAULT_FOCUS.cy }); - const blurFilterRef = useRef<PIXI.BlurFilter | null>(null); - const isDraggingFocusRef = useRef(false); - const stageSizeRef = useRef({ width: 0, height: 0 }); - const videoSizeRef = useRef({ width: 0, height: 0 }); - const baseScaleRef = useRef(1); - const baseOffsetRef = useRef({ x: 0, y: 0 }); - const baseMaskRef = useRef({ x: 0, y: 0, width: 0, height: 0 }); - const cropBoundsRef = useRef({ startX: 0, endX: 0, startY: 0, endY: 0 }); - const maskGraphicsRef = useRef<PIXI.Graphics | null>(null); - const isPlayingRef = useRef(isPlaying); - const isSeekingRef = useRef(false); - const allowPlaybackRef = useRef(false); - const lockedVideoDimensionsRef = useRef<{ width: number; height: number } | null>(null); - const layoutVideoContentRef = useRef<(() => void) | null>(null); - - const clampFocusToStage = useCallback((focus: ZoomFocus, depth: ZoomDepth) => { - return clampFocusToStageUtil(focus, depth, stageSizeRef.current); - }, []); - - const updateOverlayForRegion = useCallback((region: ZoomRegion | null, focusOverride?: ZoomFocus) => { - const overlayEl = overlayRef.current; - const indicatorEl = focusIndicatorRef.current; - - if (!overlayEl || !indicatorEl) { - return; - } - - // Update stage size from overlay dimensions - const stageWidth = overlayEl.clientWidth; - const stageHeight = overlayEl.clientHeight; - if (stageWidth && stageHeight) { - stageSizeRef.current = { width: stageWidth, height: stageHeight }; - } - - updateOverlayIndicator({ - overlayEl, - indicatorEl, - region, - focusOverride, - videoSize: videoSizeRef.current, - baseScale: baseScaleRef.current, - isPlaying: isPlayingRef.current, - }); - }, []); - - const layoutVideoContent = useCallback(() => { - const container = containerRef.current; - const app = appRef.current; - const videoSprite = videoSpriteRef.current; - const maskGraphics = maskGraphicsRef.current; - const videoElement = videoRef.current; - const cameraContainer = cameraContainerRef.current; - - if (!container || !app || !videoSprite || !maskGraphics || !videoElement || !cameraContainer) { - return; - } - - // Lock video dimensions on first layout to prevent resize issues - if (!lockedVideoDimensionsRef.current && videoElement.videoWidth > 0 && videoElement.videoHeight > 0) { - lockedVideoDimensionsRef.current = { - width: videoElement.videoWidth, - height: videoElement.videoHeight, - }; - } - - const result = layoutVideoContentUtil({ - container, - app, - videoSprite, - maskGraphics, - videoElement, - cropRegion, - lockedVideoDimensions: lockedVideoDimensionsRef.current, - }); - - if (result) { - stageSizeRef.current = result.stageSize; - videoSizeRef.current = result.videoSize; - baseScaleRef.current = result.baseScale; - baseOffsetRef.current = result.baseOffset; - baseMaskRef.current = result.maskRect; - cropBoundsRef.current = result.cropBounds; - - // Reset camera container to identity - cameraContainer.scale.set(1); - cameraContainer.position.set(0, 0); - - const selectedId = selectedZoomIdRef.current; - const activeRegion = selectedId - ? zoomRegionsRef.current.find((region) => region.id === selectedId) ?? null - : null; - - updateOverlayForRegion(activeRegion); - } - }, [updateOverlayForRegion, cropRegion]); - - useEffect(() => { - layoutVideoContentRef.current = layoutVideoContent; - }, [layoutVideoContent]); - - const selectedZoom = useMemo(() => { - if (!selectedZoomId) return null; - return zoomRegions.find((region) => region.id === selectedZoomId) ?? null; - }, [zoomRegions, selectedZoomId]); - - useImperativeHandle(ref, () => ({ - video: videoRef.current, - app: appRef.current, - videoSprite: videoSpriteRef.current, - videoContainer: videoContainerRef.current, - play: async () => { - const video = videoRef.current; - if (!video) { - allowPlaybackRef.current = false; - return; - } - allowPlaybackRef.current = true; - try { - await video.play(); - } catch (error) { - allowPlaybackRef.current = false; - throw error; - } - }, - pause: () => { - const video = videoRef.current; - allowPlaybackRef.current = false; - if (!video) { - return; - } - video.pause(); - }, - })); - - const updateFocusFromClientPoint = (clientX: number, clientY: number) => { - const overlayEl = overlayRef.current; - if (!overlayEl) return; - - const regionId = selectedZoomIdRef.current; - if (!regionId) return; - - const region = zoomRegionsRef.current.find((r) => r.id === regionId); - if (!region) return; - - const rect = overlayEl.getBoundingClientRect(); - const stageWidth = rect.width; - const stageHeight = rect.height; - - if (!stageWidth || !stageHeight) { - return; - } - - stageSizeRef.current = { width: stageWidth, height: stageHeight }; - - const localX = clientX - rect.left; - const localY = clientY - rect.top; - - const unclampedFocus: ZoomFocus = { - cx: clamp01(localX / stageWidth), - cy: clamp01(localY / stageHeight), - }; - const clampedFocus = clampFocusToStage(unclampedFocus, region.depth); - - onZoomFocusChange(region.id, clampedFocus); - updateOverlayForRegion({ ...region, focus: clampedFocus }, clampedFocus); - }; - - const handleOverlayPointerDown = (event: React.PointerEvent<HTMLDivElement>) => { - if (isPlayingRef.current) return; - const regionId = selectedZoomIdRef.current; - if (!regionId) return; - const region = zoomRegionsRef.current.find((r) => r.id === regionId); - if (!region) return; - onSelectZoom(region.id); - event.preventDefault(); - isDraggingFocusRef.current = true; - event.currentTarget.setPointerCapture(event.pointerId); - updateFocusFromClientPoint(event.clientX, event.clientY); - }; - - const handleOverlayPointerMove = (event: React.PointerEvent<HTMLDivElement>) => { - if (!isDraggingFocusRef.current) return; - event.preventDefault(); - updateFocusFromClientPoint(event.clientX, event.clientY); - }; - - const endFocusDrag = (event: React.PointerEvent<HTMLDivElement>) => { - if (!isDraggingFocusRef.current) return; - isDraggingFocusRef.current = false; - try { - event.currentTarget.releasePointerCapture(event.pointerId); - } catch { - - } - }; - - const handleOverlayPointerUp = (event: React.PointerEvent<HTMLDivElement>) => { - endFocusDrag(event); - }; - - const handleOverlayPointerLeave = (event: React.PointerEvent<HTMLDivElement>) => { - endFocusDrag(event); - }; - - useEffect(() => { - zoomRegionsRef.current = zoomRegions; - }, [zoomRegions]); - - useEffect(() => { - selectedZoomIdRef.current = selectedZoomId; - }, [selectedZoomId]); - - useEffect(() => { - isPlayingRef.current = isPlaying; - }, [isPlaying]); - - useEffect(() => { - if (!pixiReady || !videoReady) return; - - const app = appRef.current; - const cameraContainer = cameraContainerRef.current; - const video = videoRef.current; - - if (!app || !cameraContainer || !video) return; - - const tickerWasStarted = app.ticker?.started || false; - if (tickerWasStarted && app.ticker) { - app.ticker.stop(); - } - - const wasPlaying = !video.paused; - if (wasPlaying) { - video.pause(); - } - - animationStateRef.current = { - scale: 1, - focusX: DEFAULT_FOCUS.cx, - focusY: DEFAULT_FOCUS.cy, - }; - - if (blurFilterRef.current) { - blurFilterRef.current.blur = 0; - } - - requestAnimationFrame(() => { - const container = cameraContainerRef.current; - const videoStage = videoContainerRef.current; - const sprite = videoSpriteRef.current; - const currentApp = appRef.current; - if (!container || !videoStage || !sprite || !currentApp) { - return; - } - - container.scale.set(1); - container.position.set(0, 0); - videoStage.scale.set(1); - videoStage.position.set(0, 0); - sprite.scale.set(1); - sprite.position.set(0, 0); - - layoutVideoContent(); - - applyZoomTransform({ - cameraContainer: container, - blurFilter: blurFilterRef.current, - stageSize: stageSizeRef.current, - baseMask: baseMaskRef.current, - zoomScale: 1, - focusX: DEFAULT_FOCUS.cx, - focusY: DEFAULT_FOCUS.cy, - motionIntensity: 0, - isPlaying: false, - }); - - requestAnimationFrame(() => { - const finalApp = appRef.current; - if (wasPlaying && video) { - video.play().catch(() => { - }); - } - if (tickerWasStarted && finalApp?.ticker) { - finalApp.ticker.start(); - } - }); - }); - }, [pixiReady, videoReady, layoutVideoContent, cropRegion]); - - useEffect(() => { - if (!pixiReady || !videoReady) return; - const container = containerRef.current; - if (!container) return; - - if (typeof ResizeObserver === 'undefined') { - return; - } - - const observer = new ResizeObserver(() => { - layoutVideoContent(); - }); - - observer.observe(container); - return () => { - observer.disconnect(); - }; - }, [pixiReady, videoReady, layoutVideoContent]); - - useEffect(() => { - if (!pixiReady || !videoReady) return; - updateOverlayForRegion(selectedZoom); - }, [selectedZoom, pixiReady, videoReady, updateOverlayForRegion]); - - useEffect(() => { - const overlayEl = overlayRef.current; - if (!overlayEl) return; - if (!selectedZoom) { - overlayEl.style.cursor = 'default'; - overlayEl.style.pointerEvents = 'none'; - return; - } - overlayEl.style.cursor = isPlaying ? 'not-allowed' : 'grab'; - overlayEl.style.pointerEvents = isPlaying ? 'none' : 'auto'; - }, [selectedZoom, isPlaying]); - - useEffect(() => { - const container = containerRef.current; - if (!container) return; - - let mounted = true; - let app: PIXI.Application | null = null; - - (async () => { - app = new PIXI.Application(); - - await app.init({ - width: container.clientWidth, - height: container.clientHeight, - backgroundAlpha: 0, - antialias: true, - resolution: window.devicePixelRatio || 1, - autoDensity: true, - }); - - app.ticker.maxFPS = 60; - - if (!mounted) { - app.destroy(true, { children: true, texture: true, textureSource: true }); - return; - } - - appRef.current = app; - container.appendChild(app.canvas); - - // Camera container - this will be scaled/positioned for zoom - const cameraContainer = new PIXI.Container(); - cameraContainerRef.current = cameraContainer; - app.stage.addChild(cameraContainer); - - // Video container - holds the masked video sprite - const videoContainer = new PIXI.Container(); - videoContainerRef.current = videoContainer; - cameraContainer.addChild(videoContainer); - - setPixiReady(true); - })(); - - return () => { - mounted = false; - setPixiReady(false); - if (app && app.renderer) { - app.destroy(true, { children: true, texture: true, textureSource: true }); - } - appRef.current = null; - cameraContainerRef.current = null; - videoContainerRef.current = null; - videoSpriteRef.current = null; - }; - }, []); - - useEffect(() => { - const video = videoRef.current; - if (!video) return; - video.pause(); - video.currentTime = 0; - allowPlaybackRef.current = false; - }, [videoPath]); - - - - useEffect(() => { - if (!pixiReady || !videoReady) return; - - const video = videoRef.current; - const app = appRef.current; - const videoContainer = videoContainerRef.current; - - if (!video || !app || !videoContainer) return; - if (video.videoWidth === 0 || video.videoHeight === 0) return; - - const source = PIXI.VideoSource.from(video); - if ('autoPlay' in source) { - (source as { autoPlay?: boolean }).autoPlay = false; - } - if ('autoUpdate' in source) { - (source as { autoUpdate?: boolean }).autoUpdate = true; - } - const videoTexture = PIXI.Texture.from(source); - - const videoSprite = new PIXI.Sprite(videoTexture); - videoSpriteRef.current = videoSprite; - - const maskGraphics = new PIXI.Graphics(); - videoContainer.addChild(videoSprite); - videoContainer.addChild(maskGraphics); - videoContainer.mask = maskGraphics; - maskGraphicsRef.current = maskGraphics; - - animationStateRef.current = { - scale: 1, - focusX: DEFAULT_FOCUS.cx, - focusY: DEFAULT_FOCUS.cy, - }; - - const blurFilter = new PIXI.BlurFilter(); - blurFilter.quality = 3; - blurFilter.resolution = app.renderer.resolution; - blurFilter.blur = 0; - videoContainer.filters = [blurFilter]; - blurFilterRef.current = blurFilter; - - layoutVideoContent(); - video.pause(); - - const { handlePlay, handlePause, handleSeeked, handleSeeking } = createVideoEventHandlers({ - video, - isSeekingRef, - isPlayingRef, - allowPlaybackRef, - currentTimeRef, - timeUpdateAnimationRef, - onPlayStateChange, - onTimeUpdate, - }); - - video.addEventListener('play', handlePlay); - video.addEventListener('pause', handlePause); - video.addEventListener('ended', handlePause); - video.addEventListener('seeked', handleSeeked); - video.addEventListener('seeking', handleSeeking); - - return () => { - video.removeEventListener('play', handlePlay); - video.removeEventListener('pause', handlePause); - video.removeEventListener('ended', handlePause); - video.removeEventListener('seeked', handleSeeked); - video.removeEventListener('seeking', handleSeeking); - - if (timeUpdateAnimationRef.current) { - cancelAnimationFrame(timeUpdateAnimationRef.current); - } - - if (videoSprite) { - videoContainer.removeChild(videoSprite); - videoSprite.destroy(); - } - if (maskGraphics) { - videoContainer.removeChild(maskGraphics); - maskGraphics.destroy(); - } - videoContainer.mask = null; - maskGraphicsRef.current = null; - if (blurFilterRef.current) { - videoContainer.filters = []; - blurFilterRef.current.destroy(); - blurFilterRef.current = null; - } - videoTexture.destroy(true); - - videoSpriteRef.current = null; - }; - }, [pixiReady, videoReady, onTimeUpdate, updateOverlayForRegion]); - - useEffect(() => { - if (!pixiReady || !videoReady) return; - - const app = appRef.current; - const videoSprite = videoSpriteRef.current; - const videoContainer = videoContainerRef.current; - if (!app || !videoSprite || !videoContainer) return; - - const applyTransform = (motionIntensity: number) => { - const cameraContainer = cameraContainerRef.current; - if (!cameraContainer) return; - - const state = animationStateRef.current; - - applyZoomTransform({ - cameraContainer, - blurFilter: blurFilterRef.current, - stageSize: stageSizeRef.current, - baseMask: baseMaskRef.current, - zoomScale: state.scale, - focusX: state.focusX, - focusY: state.focusY, - motionIntensity, - isPlaying: isPlayingRef.current, - }); - }; - - const ticker = () => { - const { region, strength } = findDominantRegion(zoomRegionsRef.current, currentTimeRef.current); - - const defaultFocus = DEFAULT_FOCUS; - let targetScaleFactor = 1; - let targetFocus = defaultFocus; - - // If a zoom is selected but video is not playing, show default unzoomed view - // (the overlay will show where the zoom will be) - const selectedId = selectedZoomIdRef.current; - const hasSelectedZoom = selectedId !== null; - const shouldShowUnzoomedView = hasSelectedZoom && !isPlayingRef.current; - - if (region && strength > 0 && !shouldShowUnzoomedView) { - const zoomScale = ZOOM_DEPTH_SCALES[region.depth]; - const regionFocus = clampFocusToStage(region.focus, region.depth); - - // Interpolate scale and focus based on region strength - targetScaleFactor = 1 + (zoomScale - 1) * strength; - targetFocus = { - cx: defaultFocus.cx + (regionFocus.cx - defaultFocus.cx) * strength, - cy: defaultFocus.cy + (regionFocus.cy - defaultFocus.cy) * strength, - }; - } - - const state = animationStateRef.current; - - const prevScale = state.scale; - const prevFocusX = state.focusX; - const prevFocusY = state.focusY; - - const scaleDelta = targetScaleFactor - state.scale; - const focusXDelta = targetFocus.cx - state.focusX; - const focusYDelta = targetFocus.cy - state.focusY; - - let nextScale = prevScale; - let nextFocusX = prevFocusX; - let nextFocusY = prevFocusY; - - if (Math.abs(scaleDelta) > MIN_DELTA) { - nextScale = prevScale + scaleDelta * SMOOTHING_FACTOR; - } else { - nextScale = targetScaleFactor; - } - - if (Math.abs(focusXDelta) > MIN_DELTA) { - nextFocusX = prevFocusX + focusXDelta * SMOOTHING_FACTOR; - } else { - nextFocusX = targetFocus.cx; - } - - if (Math.abs(focusYDelta) > MIN_DELTA) { - nextFocusY = prevFocusY + focusYDelta * SMOOTHING_FACTOR; - } else { - nextFocusY = targetFocus.cy; - } - - state.scale = nextScale; - state.focusX = nextFocusX; - state.focusY = nextFocusY; - - const motionIntensity = Math.max( - Math.abs(nextScale - prevScale), - Math.abs(nextFocusX - prevFocusX), - Math.abs(nextFocusY - prevFocusY) - ); - - applyTransform(motionIntensity); - }; - - app.ticker.add(ticker); - return () => { - if (app && app.ticker) { - app.ticker.remove(ticker); - } - }; - }, [pixiReady, videoReady, clampFocusToStage]); - - const handleLoadedMetadata = (e: React.SyntheticEvent<HTMLVideoElement, Event>) => { - const video = e.currentTarget; - onDurationChange(video.duration); - video.currentTime = 0; - video.pause(); - allowPlaybackRef.current = false; - currentTimeRef.current = 0; - setVideoReady(true); - }; - - const [resolvedWallpaper, setResolvedWallpaper] = useState<string | null>(null); - - useEffect(() => { - let mounted = true - ;(async () => { - try { - if (!wallpaper) { - const def = await getAssetPath('wallpapers/wallpaper1.jpg') - if (mounted) setResolvedWallpaper(def) - return - } - - if (wallpaper.startsWith('#') || wallpaper.startsWith('linear-gradient') || wallpaper.startsWith('radial-gradient')) { - if (mounted) setResolvedWallpaper(wallpaper) - return - } - - // If it's an absolute web/http or file path, use as-is - if (wallpaper.startsWith('http') || wallpaper.startsWith('file://') || wallpaper.startsWith('/')) { - // If it's an absolute server path (starts with '/'), resolve via getAssetPath as well - if (wallpaper.startsWith('/')) { - const rel = wallpaper.replace(/^\//, '') - const p = await getAssetPath(rel) - if (mounted) setResolvedWallpaper(p) - return - } - if (mounted) setResolvedWallpaper(wallpaper) - return - } - const p = await getAssetPath(wallpaper.replace(/^\//, '')) - if (mounted) setResolvedWallpaper(p) - } catch (err) { - if (mounted) setResolvedWallpaper(wallpaper || '/wallpapers/wallpaper1.jpg') - } - })() - return () => { mounted = false } - }, [wallpaper]) - - const isImageUrl = Boolean(resolvedWallpaper && (resolvedWallpaper.startsWith('file://') || resolvedWallpaper.startsWith('http') || resolvedWallpaper.startsWith('/'))) - const backgroundStyle = isImageUrl - ? { backgroundImage: `url(${resolvedWallpaper || ''})` } - : { background: resolvedWallpaper || '' }; - - return ( - <div className="relative aspect-video rounded-sm overflow-hidden" style={{ width: '100%' }}> - {/* Background layer - always render as DOM element with blur */} - <div - className="absolute inset-0 bg-cover bg-center" - style={{ - ...backgroundStyle, - filter: showBlur ? 'blur(2px)' : 'none', - }} - /> - <div - ref={containerRef} - className="absolute inset-0" - style={{ - filter: showShadow - ? 'drop-shadow(0 12px 48px rgba(0,0,0,0.7)) drop-shadow(0 4px 16px rgba(0,0,0,0.5)) drop-shadow(0 2px 8px rgba(0,0,0,0.3))' - : 'none', - }} - /> - <div - ref={overlayRef} - className="absolute inset-0 select-none" - style={{ pointerEvents: 'none' }} - onPointerDown={handleOverlayPointerDown} - onPointerMove={handleOverlayPointerMove} - onPointerUp={handleOverlayPointerUp} - onPointerLeave={handleOverlayPointerLeave} - > - <div - ref={focusIndicatorRef} - className="absolute rounded-md border border-sky-400/80 bg-sky-400/20 shadow-[0_0_0_1px_rgba(56,189,248,0.35)]" - style={{ display: 'none', pointerEvents: 'none' }} - /> - </div> - <video - ref={videoRef} - src={videoPath} - className="hidden" - preload="metadata" - playsInline - onLoadedMetadata={handleLoadedMetadata} - onDurationChange={e => { - onDurationChange(e.currentTarget.duration); - }} - onError={() => onError('Failed to load video')} - /> - </div> - ); -}); - -VideoPlayback.displayName = 'VideoPlayback'; +const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>( + ( + { + videoPath, + onDurationChange, + onPreviewReadyChange, + onTimeUpdate, + currentTime, + onPlayStateChange, + onError, + wallpaper, + zoomRegions, + selectedZoomId, + onSelectZoom, + onZoomFocusChange, + isPlaying, + showShadow, + shadowIntensity = 0, + backgroundBlur = 0, + connectZooms = true, + zoomInDurationMs = DEFAULT_ZOOM_IN_DURATION_MS, + zoomInOverlapMs = DEFAULT_ZOOM_IN_OVERLAP_MS, + zoomOutDurationMs = DEFAULT_ZOOM_OUT_DURATION_MS, + connectedZoomGapMs = DEFAULT_CONNECTED_ZOOM_GAP_MS, + connectedZoomDurationMs = DEFAULT_CONNECTED_ZOOM_DURATION_MS, + zoomInEasing = DEFAULT_ZOOM_IN_EASING, + zoomOutEasing = DEFAULT_ZOOM_OUT_EASING, + connectedZoomEasing = DEFAULT_CONNECTED_ZOOM_EASING, + borderRadius = 0, + padding = DEFAULT_PADDING, + cropRegion, + webcam, + webcamVideoPath, + trimRegions = [], + speedRegions = [], + aspectRatio, + annotationRegions = [], + autoCaptions = [], + autoCaptionSettings, + onEditAutoCaption, + selectedAnnotationId, + onSelectAnnotation, + onAnnotationPositionChange, + onAnnotationSizeChange, + cursorTelemetry = [], + showCursor = false, + cursorStyle = DEFAULT_CURSOR_STYLE, + cursorSize = DEFAULT_CURSOR_SIZE, + cursorSmoothing = DEFAULT_CURSOR_SMOOTHING, + cursorSpringStiffnessMultiplier = 1, + cursorSpringDampingMultiplier = 1, + cursorSpringMassMultiplier = 1, + cameraSpringStiffnessMultiplier = 1, + cameraSpringDampingMultiplier = 1.13, + cameraSpringMassMultiplier = 1.12, + zoomSmoothness = 0.5, + zoomClassicMode = false, + zoomMotionBlur = DEFAULT_ZOOM_MOTION_BLUR, + zoomMotionBlurTuning = DEFAULT_ZOOM_MOTION_BLUR_TUNING, + cursorMotionBlur = DEFAULT_CURSOR_MOTION_BLUR, + cursorClickEffect = DEFAULT_CURSOR_CLICK_EFFECT, + cursorClickEffectColor = DEFAULT_CURSOR_CLICK_EFFECT_COLOR, + cursorClickEffectScale = DEFAULT_CURSOR_CLICK_EFFECT_SCALE, + cursorClickEffectOpacity = DEFAULT_CURSOR_CLICK_EFFECT_OPACITY, + cursorClickEffectDurationMs = DEFAULT_CURSOR_CLICK_EFFECT_DURATION_MS, + cursorClickBounce = DEFAULT_CURSOR_CLICK_BOUNCE, + cursorClickBounceDuration = DEFAULT_CURSOR_CLICK_BOUNCE_DURATION, + cursorSway = DEFAULT_CURSOR_SWAY, + volume = 1, + suspendRendering = false, + }, + ref, + ) => { + const videoRef = useRef<HTMLVideoElement | null>(null); + const previewFrameRef = useRef<HTMLDivElement | null>(null); + const containerRef = useRef<HTMLDivElement | null>(null); + const appRef = useRef<Application | null>(null); + const videoSpriteRef = useRef<Sprite | null>(null); + const videoEffectsContainerRef = useRef<Container | null>(null); + const videoContainerRef = useRef<Container | null>(null); + const cursorContainerRef = useRef<Container | null>(null); + const zoomBlurFilterRef = useRef<ZoomBlurFilter | null>(null); + const motionBlurFilterRef = useRef<MotionBlurFilter | null>(null); + const cameraContainerRef = useRef<Container | null>(null); + const timeUpdateAnimationRef = useRef<number | null>(null); + const [pixiReady, setPixiReady] = useState(false); + const [videoReady, setVideoReady] = useState(false); + const [pixiRendererError, setPixiRendererError] = useState<string | null>(null); + const [pixiRendererBackend, setPixiRendererBackend] = useState<PixiPreviewBackend | null>( + null, + ); + const [annotationSceneTransform, setAnnotationSceneTransform] = + useState<SceneTransformState>({ + scale: 1, + x: 0, + y: 0, + }); + const [annotationRecordingRect, setAnnotationRecordingRect] = + useState<AnnotationRecordingRect>({ + x: 0, + y: 0, + width: 0, + height: 0, + }); + + const overlayRef = useRef<HTMLDivElement | null>(null); + const focusIndicatorRef = useRef<HTMLDivElement | null>(null); + const webcamVideoRef = useRef<HTMLVideoElement | null>(null); + const webcamBubbleRef = useRef<HTMLDivElement | null>(null); + const webcamBubbleInnerRef = useRef<HTMLDivElement | null>(null); + const [webcamVideoDimensions, setWebcamVideoDimensions] = useState<{ + width: number; + height: number; + } | null>(null); + const webcamSynchronizedPathRef = useRef<string | null>(null); + const [webcamSynchronizedPath, setWebcamSynchronizedPath] = useState<string | null>(null); + const webcamMediaSynchronized = + Boolean(webcamVideoPath) && webcamSynchronizedPath === webcamVideoPath; + const captionBoxRef = useRef<HTMLDivElement | null>(null); + const captionEditInputRef = useRef<HTMLTextAreaElement | null>(null); + const captionEditSessionRef = useRef<CaptionEditSession | null>(null); + const [captionEditSession, setCaptionEditSession] = useState<CaptionEditSession | null>( + null, + ); + const currentTimeRef = useRef(0); + const zoomRegionsRef = useRef<ZoomRegion[]>([]); + const selectedZoomIdRef = useRef<string | null>(null); + const animationStateRef = useRef<PlaybackAnimationState>(createPlaybackAnimationState()); + const isDraggingFocusRef = useRef(false); + const stageSizeRef = useRef({ width: 0, height: 0 }); + const videoSizeRef = useRef({ width: 0, height: 0 }); + const baseScaleRef = useRef(1); + const baseOffsetRef = useRef({ x: 0, y: 0 }); + const baseMaskRef = useRef<{ + x: number; + y: number; + width: number; + height: number; + renderWidth?: number; + renderHeight?: number; + sourceCrop?: { + x: number; + y: number; + width: number; + height: number; + }; + }>({ x: 0, y: 0, width: 0, height: 0 }); + const cropBoundsRef = useRef({ startX: 0, endX: 0, startY: 0, endY: 0 }); + const maskGraphicsRef = useRef<Graphics | null>(null); + const isPlayingRef = useRef(isPlaying); + const suspendRenderingRef = useRef(suspendRendering); + const isSeekingRef = useRef(false); + const allowPlaybackRef = useRef(false); + const lockedVideoDimensionsRef = useRef<{ + width: number; + height: number; + } | null>(null); + const layoutVideoContentRef = useRef<(() => void) | null>(null); + const trimRegionsRef = useRef<TrimRegion[]>([]); + const speedRegionsRef = useRef<SpeedRegion[]>([]); + const lastWebcamSyncTimeRef = useRef<number | null>(null); + const lastBackgroundSyncTimeRef = useRef<number | null>(null); + const bgVideoRef = useRef<HTMLVideoElement | null>(null); + const connectZoomsRef = useRef(connectZooms); + const zoomInDurationMsRef = useRef(zoomInDurationMs); + const zoomInOverlapMsRef = useRef(zoomInOverlapMs); + const zoomOutDurationMsRef = useRef(zoomOutDurationMs); + const connectedZoomGapMsRef = useRef(connectedZoomGapMs); + const connectedZoomDurationMsRef = useRef(connectedZoomDurationMs); + const zoomInEasingRef = useRef(zoomInEasing); + const zoomOutEasingRef = useRef(zoomOutEasing); + const connectedZoomEasingRef = useRef(connectedZoomEasing); + const videoReadyRafRef = useRef<number | null>(null); + const cursorOverlayRef = useRef<PixiCursorOverlay | null>(null); + const cursorTelemetryRef = useRef<CursorTelemetryPoint[]>([]); + const showCursorRef = useRef(showCursor); + const cursorSizeRef = useRef(cursorSize); + const cursorStyleRef = useRef(cursorStyle); + const cursorSmoothingRef = useRef(cursorSmoothing); + const cursorSpringStiffnessMultiplierRef = useRef(cursorSpringStiffnessMultiplier); + const cursorSpringDampingMultiplierRef = useRef(cursorSpringDampingMultiplier); + const cursorSpringMassMultiplierRef = useRef(cursorSpringMassMultiplier); + const cameraSpringStiffnessMultiplierRef = useRef(cameraSpringStiffnessMultiplier); + const cameraSpringDampingMultiplierRef = useRef(cameraSpringDampingMultiplier); + const cameraSpringMassMultiplierRef = useRef(cameraSpringMassMultiplier); + const cursorMotionBlurRef = useRef(cursorMotionBlur); + const cursorClickEffectRef = useRef(cursorClickEffect); + const cursorClickEffectColorRef = useRef(cursorClickEffectColor); + const cursorClickEffectScaleRef = useRef(cursorClickEffectScale); + const cursorClickEffectOpacityRef = useRef(cursorClickEffectOpacity); + const cursorClickEffectDurationMsRef = useRef(cursorClickEffectDurationMs); + const cursorClickBounceRef = useRef(cursorClickBounce); + const cursorClickBounceDurationRef = useRef(cursorClickBounceDuration); + const cursorSwayRef = useRef(cursorSway); + const zoomMotionBlurRef = useRef(zoomMotionBlur); + const zoomMotionBlurTuningRef = useRef(zoomMotionBlurTuning); + + // Spring animation state for smooth zoom transitions + const springScaleRef = useRef<SpringState>(createSpringState(1)); + const springXRef = useRef<SpringState>(createSpringState(0)); + const springYRef = useRef<SpringState>(createSpringState(0)); + const lastTickTimeRef = useRef<number | null>(null); + const zoomSmoothnessRef = useRef(zoomSmoothness); + const zoomClassicModeRef = useRef(zoomClassicMode); + const cursorFollowCameraRef = useRef<CursorFollowCameraState>( + createCursorFollowCameraState(), + ); + + const initializePixiRenderer = useCallback( + async ( + container: HTMLDivElement, + ): Promise<{ + app: Application; + backend: PixiPreviewBackend; + }> => { + const backendOrder: PixiPreviewBackend[] = ["webgl", "webgpu"]; + const attempts: PixiRendererAttempt[] = []; + + for (const backend of backendOrder) { + if ( + backend === "webgpu" && + !(typeof navigator !== "undefined" && "gpu" in navigator) + ) { + attempts.push({ + backend, + message: "WebGPU runtime is unavailable in this browser.", + }); + continue; + } + + const rendererApp = new Application(); + const initStarted = + typeof performance === "undefined" ? Date.now() : performance.now(); + try { + await initializePixiApplicationWithTimeout( + rendererApp, + { + width: container.clientWidth, + height: container.clientHeight, + backgroundAlpha: 0, + antialias: true, + failIfMajorPerformanceCaveat: false, + resolution: window.devicePixelRatio || 1, + autoDensity: true, + preference: backend, + autoStart: true, + sharedTicker: false, + }, + PIXI_RENDERER_INIT_TIMEOUT_MS, + backend, + ); + const elapsed = Math.round( + (typeof performance === "undefined" ? Date.now() : performance.now()) - + initStarted, + ); + if (isCanvasRenderer(rendererApp)) { + throw new Error( + `Renderer initialized with unsupported fallback backend after ${elapsed}ms: ${rendererApp.renderer.constructor?.name ?? "unknown"}`, + ); + } + return { app: rendererApp, backend }; + } catch (error) { + const elapsed = Math.round( + (typeof performance === "undefined" ? Date.now() : performance.now()) - + initStarted, + ); + attempts.push({ + backend, + message: `${toRendererErrorMessage(error)} (after ${elapsed}ms)`, + }); + const statusMessage = isRendererUnavailableError(error) + ? "renderer backend unavailable in this runtime" + : "renderer init failed"; + console.warn( + `[VideoPlayback] Failed to init ${backend} renderer (${statusMessage}) after ${elapsed}ms; trying fallback.`, + error, + ); + destroyPixiApplication( + rendererApp, + `${backend} preview renderer initialization`, + ); + } + } + + throw new Error(summarizeRendererAttempts(attempts)); + }, + [], + ); + + const activeCaptionLayout = useMemo(() => { + if ( + !autoCaptionSettings?.enabled || + autoCaptions.length === 0 || + typeof document === "undefined" + ) { + return null; + } + + const overlayWidth = overlayRef.current?.clientWidth || 960; + const fontSize = getCaptionScaledFontSize( + autoCaptionSettings.fontSize, + overlayWidth, + autoCaptionSettings.maxWidth, + ); + const maxTextWidthPx = getCaptionTextMaxWidth( + overlayWidth, + autoCaptionSettings.maxWidth, + fontSize, + ); + const measurementCanvas = document.createElement("canvas"); + const measurementContext = measurementCanvas.getContext("2d"); + if (!measurementContext) { + return null; + } + + measurementContext.font = `${CAPTION_FONT_WEIGHT} ${fontSize}px ${getDefaultCaptionFontFamily()}`; + + return buildActiveCaptionLayout({ + cues: autoCaptions, + timeMs: Math.round(currentTime * 1000), + settings: autoCaptionSettings, + maxWidthPx: maxTextWidthPx, + measureText: (text) => measurementContext.measureText(text).width, + }); + }, [autoCaptionSettings, autoCaptions, currentTime]); + const isCaptionEditing = captionEditSession !== null; + const captionEditDraft = captionEditSession?.draft ?? ""; + const captionEditTargetId = captionEditSession?.target.id ?? null; + const captionEditTextMetrics = useMemo(() => { + if (!captionEditSession || !autoCaptionSettings || typeof document === "undefined") { + return null; + } + + const overlayWidth = overlayRef.current?.clientWidth || 960; + const fontSize = getCaptionScaledFontSize( + autoCaptionSettings.fontSize, + overlayWidth, + autoCaptionSettings.maxWidth, + ); + const maxTextWidthPx = getCaptionTextMaxWidth( + overlayWidth, + autoCaptionSettings.maxWidth, + fontSize, + ); + const measurementCanvas = document.createElement("canvas"); + const measurementContext = measurementCanvas.getContext("2d"); + if (!measurementContext) { + return null; + } + + measurementContext.font = `${CAPTION_FONT_WEIGHT} ${fontSize}px ${getDefaultCaptionFontFamily()}`; + const measuredWidth = Math.max( + ...captionEditSession.draft + .split(/\r?\n/) + .map((line) => measurementContext.measureText(line || " ").width), + ); + + return { + fontSize, + maxTextWidthPx, + widthPx: Math.ceil( + Math.min(maxTextWidthPx, Math.max(fontSize * 2, measuredWidth + 2)), + ), + }; + }, [autoCaptionSettings, captionEditSession]); + const captionEditSizeKey = captionEditSession + ? `${captionEditTextMetrics?.widthPx ?? 0}:${captionEditDraft}` + : ""; + + const beginCaptionEdit = useCallback(() => { + if (!activeCaptionLayout?.editTarget || !onEditAutoCaption) { + return; + } + + videoRef.current?.pause(); + onPlayStateChange(false); + const nextSession = { + target: activeCaptionLayout.editTarget, + draft: activeCaptionLayout.editTarget.text, + }; + captionEditSessionRef.current = nextSession; + setCaptionEditSession(nextSession); + }, [activeCaptionLayout, onEditAutoCaption, onPlayStateChange]); + + const commitCaptionEdit = useCallback(() => { + const session = captionEditSessionRef.current; + if (!session || !onEditAutoCaption) { + captionEditSessionRef.current = null; + setCaptionEditSession(null); + return; + } + + const normalizedDraft = normalizeCaptionEditText(session.draft); + captionEditSessionRef.current = null; + if (!normalizedDraft) { + setCaptionEditSession(null); + return; + } + + if (normalizedDraft !== normalizeCaptionEditText(session.target.text)) { + onEditAutoCaption(session.target, session.draft); + } + setCaptionEditSession(null); + }, [onEditAutoCaption]); + + const cancelCaptionEdit = useCallback(() => { + captionEditSessionRef.current = null; + setCaptionEditSession(null); + }, []); + + useEffect(() => { + if (!captionEditTargetId) { + return; + } + + const frame = requestAnimationFrame(() => { + const input = captionEditInputRef.current; + if (!input) { + return; + } + + input.focus(); + const cursorPosition = input.value.length; + input.setSelectionRange(cursorPosition, cursorPosition); + }); + + return () => cancelAnimationFrame(frame); + }, [captionEditTargetId]); + + useEffect(() => { + if (!captionEditSizeKey) { + return; + } + + const frame = requestAnimationFrame(() => { + const input = captionEditInputRef.current; + if (!input) { + return; + } + + input.style.height = "auto"; + input.style.height = `${input.scrollHeight}px`; + }); + + return () => cancelAnimationFrame(frame); + }, [captionEditSizeKey]); + + useEffect(() => { + const captionBox = captionBoxRef.current; + if (!captionBox || !activeCaptionLayout || !autoCaptionSettings) { + if (captionBox) { + captionBox.style.clipPath = ""; + captionBox.style.removeProperty("-webkit-clip-path"); + } + return; + } + + const frame = requestAnimationFrame(() => { + const width = captionBox.offsetWidth; + const height = captionBox.offsetHeight; + if (width <= 0 || height <= 0) { + return; + } + + const fontSize = getCaptionScaledFontSize( + autoCaptionSettings.fontSize, + overlayRef.current?.clientWidth || 960, + autoCaptionSettings.maxWidth, + ); + + const squirclePath = getSquircleSvgPath({ + x: 0, + y: 0, + width, + height, + radius: getCaptionScaledRadius(autoCaptionSettings.boxRadius, fontSize), + }); + captionBox.style.clipPath = `path('${squirclePath}')`; + captionBox.style.setProperty("-webkit-clip-path", `path('${squirclePath}')`); + }); + + return () => cancelAnimationFrame(frame); + }, [activeCaptionLayout, autoCaptionSettings]); + const motionBlurStateRef = useRef<MotionBlurState>(createMotionBlurState()); + const webcamEnabled = webcam?.enabled ?? false; + const webcamMargin = webcam?.margin ?? 24; + const webcamWidth = webcam?.width ?? webcam?.size ?? DEFAULT_WEBCAM_SIZE; + const rawWebcamHeight = webcam?.height ?? webcam?.size ?? DEFAULT_WEBCAM_SIZE; + const webcamReactToZoom = webcam?.reactToZoom ?? DEFAULT_WEBCAM_REACT_TO_ZOOM; + const webcamPositionPreset = webcam?.positionPreset ?? webcam?.corner ?? "bottom-right"; + const webcamPositionX = webcam?.positionX ?? 1; + const webcamPositionY = webcam?.positionY ?? 1; + const webcamCorner = webcam?.corner ?? "bottom-right"; + const webcamRoundness = webcam?.roundness ?? DEFAULT_WEBCAM_ROUNDNESS; + const webcamShadow = webcam?.shadow ?? DEFAULT_WEBCAM_SHADOW; + const webcamTimeOffsetMs = webcam?.timeOffsetMs; + const webcamCropRegion = webcam?.cropRegion; + const webcamMirror = webcam?.mirror ?? false; + const webcamHeight = getCropMatchedWebcamHeightPercent( + webcamWidth, + rawWebcamHeight, + webcamVideoDimensions?.width, + webcamVideoDimensions?.height, + webcamCropRegion, + ); + const webcamCropPreviewContentStyle = useMemo<React.CSSProperties>(() => { + if (!webcamVideoDimensions) { + return { opacity: 0 }; + } + + const { sx, sy, sw, sh } = getWebcamCropSourceRect( + webcamCropRegion, + webcamVideoDimensions.width, + webcamVideoDimensions.height, + ); + const targetAspect = Math.max(0.01, webcamWidth) / Math.max(0.01, webcamHeight); + const coverScale = Math.max(targetAspect / sw, 1 / sh); + const drawWidth = webcamVideoDimensions.width * coverScale; + const drawHeight = webcamVideoDimensions.height * coverScale; + const drawX = (targetAspect - sw * coverScale) / 2 - sx * coverScale; + const drawY = (1 - sh * coverScale) / 2 - sy * coverScale; + + return { + left: `${(drawX / targetAspect) * 100}%`, + top: `${drawY * 100}%`, + width: `${(drawWidth / targetAspect) * 100}%`, + height: `${drawHeight * 100}%`, + maxWidth: "none", + willChange: "left, top, width, height", + }; + }, [webcamCropRegion, webcamHeight, webcamVideoDimensions, webcamWidth]); + + const applyWebcamBubbleLayout = useCallback( + (zoomScale: number) => { + const bubble = webcamBubbleRef.current; + const bubbleInner = webcamBubbleInnerRef.current; + const overlay = overlayRef.current; + if (!bubble || !bubbleInner || !overlay || !webcamEnabled || !webcamVideoPath) { + if (bubble) { + bubble.style.display = "none"; + } + return; + } + const scaledMargin = scaleWebcamOverlayPixels(webcamMargin, overlay.clientWidth); + + const scaledDimensions = getWebcamOverlayDimensionsPx({ + containerWidth: overlay.clientWidth, + containerHeight: overlay.clientHeight, + widthPercent: webcamWidth, + heightPercent: webcamHeight, + margin: scaledMargin, + zoomScale, + reactToZoom: webcamReactToZoom, + }); + const scaledRadius = getWebcamCornerRadiusPx( + webcamRoundness, + scaledDimensions.width, + scaledDimensions.height, + ); + const { x, y } = getWebcamOverlayPosition({ + containerWidth: overlay.clientWidth, + containerHeight: overlay.clientHeight, + width: scaledDimensions.width, + height: scaledDimensions.height, + margin: scaledMargin, + positionPreset: webcamPositionPreset, + positionX: webcamPositionX, + positionY: webcamPositionY, + legacyCorner: webcamCorner, + }); + + bubble.style.display = "block"; + bubble.style.left = `${x}px`; + bubble.style.top = `${y}px`; + bubble.style.width = `${scaledDimensions.width}px`; + bubble.style.height = `${scaledDimensions.height}px`; + bubble.style.aspectRatio = `${scaledDimensions.width} / ${scaledDimensions.height}`; + const squirclePath = getSquircleSvgPath({ + x: 0, + y: 0, + width: scaledDimensions.width, + height: scaledDimensions.height, + radius: scaledRadius, + }); + const shadowSize = Math.min(scaledDimensions.width, scaledDimensions.height); + bubble.style.filter = getWebcamShadowFilter(shadowSize, webcamShadow); + bubble.style.borderRadius = "0px"; + bubble.style.boxShadow = "none"; + + bubbleInner.style.borderRadius = "0px"; + bubbleInner.style.overflow = "hidden"; + bubbleInner.style.contain = "paint"; + bubbleInner.style.clipPath = `path('${squirclePath}')`; + bubbleInner.style.setProperty("-webkit-clip-path", `path('${squirclePath}')`); + }, + [ + webcamCorner, + webcamRoundness, + webcamEnabled, + webcamMargin, + webcamPositionPreset, + webcamPositionX, + webcamPositionY, + webcamReactToZoom, + webcamShadow, + webcamHeight, + webcamVideoPath, + webcamWidth, + ], + ); + + const clampFocusToStage = useCallback((focus: ZoomFocus, depth: ZoomDepth) => { + return clampFocusToStageUtil(focus, depth, stageSizeRef.current); + }, []); + + const updateOverlayForRegion = useCallback( + (region: ZoomRegion | null, focusOverride?: ZoomFocus) => { + const overlayEl = overlayRef.current; + const indicatorEl = focusIndicatorRef.current; + + if (!overlayEl || !indicatorEl) { + return; + } + + // Update stage size from overlay dimensions + const stageWidth = overlayEl.clientWidth; + const stageHeight = overlayEl.clientHeight; + if (stageWidth && stageHeight) { + stageSizeRef.current = { width: stageWidth, height: stageHeight }; + } + + updateOverlayIndicator({ + overlayEl, + indicatorEl, + region, + focusOverride, + baseMask: baseMaskRef.current, + isPlaying: isPlayingRef.current, + }); + }, + [], + ); + + const syncPreviewMotionBlurQuality = useCallback(() => { + const app = appRef.current; + const videoEffectsContainer = videoEffectsContainerRef.current; + const zoomBlurFilter = zoomBlurFilterRef.current; + const motionBlurFilter = motionBlurFilterRef.current; + + if (!app || !videoEffectsContainer || !motionBlurFilter || !zoomBlurFilter) { + return; + } + + const filterResolution = Math.max( + 1, + app.renderer.resolution || window.devicePixelRatio || 1, + ); + const stageWidth = Math.max(1, stageSizeRef.current.width || app.screen.width); + const stageHeight = Math.max(1, stageSizeRef.current.height || app.screen.height); + + motionBlurFilter.resolution = filterResolution; + zoomBlurFilter.resolution = filterResolution; + cursorOverlayRef.current?.setFilterResolution(filterResolution); + videoEffectsContainer.filterArea = new Rectangle(0, 0, stageWidth, stageHeight); + }, []); + + const layoutVideoContent = useCallback(() => { + const container = containerRef.current; + const app = appRef.current; + const videoSprite = videoSpriteRef.current; + const maskGraphics = maskGraphicsRef.current; + const videoElement = videoRef.current; + const cameraContainer = cameraContainerRef.current; + + if ( + !container || + !app || + !videoSprite || + !maskGraphics || + !videoElement || + !cameraContainer + ) { + return; + } + + // Lock video dimensions on first layout to prevent resize issues + if ( + !lockedVideoDimensionsRef.current && + videoElement.videoWidth > 0 && + videoElement.videoHeight > 0 + ) { + lockedVideoDimensionsRef.current = { + width: videoElement.videoWidth, + height: videoElement.videoHeight, + }; + } + + const result = layoutVideoContentUtil({ + container, + app, + videoSprite, + maskGraphics, + videoElement, + cropRegion, + lockedVideoDimensions: lockedVideoDimensionsRef.current, + borderRadius, + padding, + frameInsets: null, + }); + + if (result) { + stageSizeRef.current = result.stageSize; + syncPreviewMotionBlurQuality(); + videoSizeRef.current = result.videoSize; + baseScaleRef.current = result.baseScale; + baseOffsetRef.current = result.baseOffset; + const renderResolution = app.renderer.resolution || window.devicePixelRatio || 1; + baseMaskRef.current = { + ...result.maskRect, + renderWidth: result.maskRect.width * renderResolution, + renderHeight: result.maskRect.height * renderResolution, + }; + setAnnotationRecordingRect((current) => { + if ( + Math.abs(current.x - result.maskRect.x) < 0.1 && + Math.abs(current.y - result.maskRect.y) < 0.1 && + Math.abs(current.width - result.maskRect.width) < 0.1 && + Math.abs(current.height - result.maskRect.height) < 0.1 + ) { + return current; + } + + return { + x: result.maskRect.x, + y: result.maskRect.y, + width: result.maskRect.width, + height: result.maskRect.height, + }; + }); + cropBoundsRef.current = result.cropBounds; + + // Reset camera container to identity + cameraContainer.scale.set(1); + cameraContainer.position.set(0, 0); + + const selectedId = selectedZoomIdRef.current; + const activeRegion = selectedId + ? (zoomRegionsRef.current.find((region) => region.id === selectedId) ?? null) + : null; + + updateOverlayForRegion(activeRegion); + applyWebcamBubbleLayout(animationStateRef.current.appliedScale || 1); + } + }, [ + updateOverlayForRegion, + cropRegion, + borderRadius, + padding, + applyWebcamBubbleLayout, + syncPreviewMotionBlurQuality, + ]); + + useEffect(() => { + const video = videoRef.current; + if (!video) return; + + enablePitchPreservingPlayback(video); + const nextVolume = Math.max(0, Math.min(1, volume)); + video.volume = nextVolume; + video.muted = nextVolume <= 0.001; + }, [volume]); + + useEffect(() => { + layoutVideoContentRef.current = layoutVideoContent; + }, [layoutVideoContent]); + + // Always re-run geometric layout when layout props change, even if frame sprite isn't reloaded. + useEffect(() => { + layoutVideoContent(); + }, [layoutVideoContent]); + + const selectedZoom = useMemo(() => { + if (!selectedZoomId) return null; + return zoomRegions.find((region) => region.id === selectedZoomId) ?? null; + }, [zoomRegions, selectedZoomId]); + + useImperativeHandle(ref, () => ({ + video: videoRef.current, + app: appRef.current, + videoSprite: videoSpriteRef.current, + videoContainer: videoContainerRef.current, + containerRef, + play: async () => { + const vid = videoRef.current; + if (!vid) return; + try { + allowPlaybackRef.current = true; + await vid.play(); + } catch (error) { + allowPlaybackRef.current = false; + throw error; + } + }, + pause: () => { + const video = videoRef.current; + allowPlaybackRef.current = false; + if (!video) { + return; + } + video.pause(); + }, + cancelCaptionEdit, + refreshFrame: async () => { + const video = videoRef.current; + if (!video || Number.isNaN(video.currentTime)) { + return; + } + + const restoreTime = video.currentTime; + const duration = Number.isFinite(video.duration) ? video.duration : 0; + const epsilon = + duration > 0 ? Math.min(1 / 120, duration / 1000 || 1 / 120) : 1 / 120; + const nudgeTarget = + restoreTime > epsilon + ? restoreTime - epsilon + : Math.min(duration || restoreTime + epsilon, restoreTime + epsilon); + + if (Math.abs(nudgeTarget - restoreTime) < 0.000001) { + return; + } + + await new Promise<void>((resolve) => { + const handleFirstSeeked = () => { + video.removeEventListener("seeked", handleFirstSeeked); + const handleSecondSeeked = () => { + video.removeEventListener("seeked", handleSecondSeeked); + video.pause(); + resolve(); + }; + + video.addEventListener("seeked", handleSecondSeeked, { + once: true, + }); + video.currentTime = restoreTime; + }; + + video.addEventListener("seeked", handleFirstSeeked, { once: true }); + video.currentTime = nudgeTarget; + }); + }, + })); + + const updateFocusFromClientPoint = (clientX: number, clientY: number) => { + const overlayEl = overlayRef.current; + if (!overlayEl) return; + + const regionId = selectedZoomIdRef.current; + if (!regionId) return; + + const region = zoomRegionsRef.current.find((r) => r.id === regionId); + if (!region) return; + + const rect = overlayEl.getBoundingClientRect(); + const stageWidth = rect.width; + const stageHeight = rect.height; + + if (!stageWidth || !stageHeight) { + return; + } + + stageSizeRef.current = { width: stageWidth, height: stageHeight }; + + const localX = clientX - rect.left; + const localY = clientY - rect.top; + const baseMask = baseMaskRef.current; + + const unclampedFocus: ZoomFocus = { + cx: clamp01((localX - baseMask.x) / Math.max(1, baseMask.width)), + cy: clamp01((localY - baseMask.y) / Math.max(1, baseMask.height)), + }; + const clampedFocus = clampFocusToStage(unclampedFocus, region.depth); + + onZoomFocusChange(region.id, clampedFocus); + updateOverlayForRegion({ ...region, focus: clampedFocus }, clampedFocus); + }; + + const handleOverlayPointerDown = (event: React.PointerEvent<HTMLDivElement>) => { + if (isPlayingRef.current) return; + const regionId = selectedZoomIdRef.current; + if (!regionId) return; + const region = zoomRegionsRef.current.find((r) => r.id === regionId); + if (!region || region.mode !== "manual") return; + onSelectZoom(region.id); + event.preventDefault(); + isDraggingFocusRef.current = true; + event.currentTarget.setPointerCapture(event.pointerId); + updateFocusFromClientPoint(event.clientX, event.clientY); + }; + + const handleOverlayPointerMove = (event: React.PointerEvent<HTMLDivElement>) => { + if (!isDraggingFocusRef.current) return; + event.preventDefault(); + updateFocusFromClientPoint(event.clientX, event.clientY); + }; + + const endFocusDrag = (event: React.PointerEvent<HTMLDivElement>) => { + if (!isDraggingFocusRef.current) return; + isDraggingFocusRef.current = false; + try { + event.currentTarget.releasePointerCapture(event.pointerId); + } catch { + /* Pointer capture may already be released during drag cleanup. */ + } + }; + + const handleOverlayPointerUp = (event: React.PointerEvent<HTMLDivElement>) => { + endFocusDrag(event); + }; + + const handleOverlayPointerLeave = (event: React.PointerEvent<HTMLDivElement>) => { + endFocusDrag(event); + }; + + useEffect(() => { + zoomRegionsRef.current = zoomRegions; + }, [zoomRegions]); + + useEffect(() => { + selectedZoomIdRef.current = selectedZoomId; + }, [selectedZoomId]); + + useEffect(() => { + isPlayingRef.current = isPlaying; + // Snap springs to current position when pausing so scrubbing is instant + if (!isPlaying) { + resetSpringState(springScaleRef.current); + resetSpringState(springXRef.current); + resetSpringState(springYRef.current); + resetCursorFollowCamera(cursorFollowCameraRef.current); + lastTickTimeRef.current = null; + } + const bgVideo = bgVideoRef.current; + if (bgVideo) { + if (isPlaying) { + bgVideo.play().catch(() => undefined); + } else { + bgVideo.pause(); + } + } + }, [isPlaying]); + + useEffect(() => { + suspendRenderingRef.current = suspendRendering; + if (!pixiReady) return; + const app = appRef.current; + if (!app?.ticker) { + return; + } + + if (suspendRendering) { + app.ticker.stop(); + bgVideoRef.current?.pause(); + webcamVideoRef.current?.pause(); + layoutVideoContentRef.current?.(); + const videoTextureSource = videoSpriteRef.current?.texture?.source as + | { update?: () => void } + | undefined; + videoTextureSource?.update?.(); + app.render(); + return; + } + + app.ticker.start(); + const video = videoRef.current; + if (video) { + const targetTime = clampMediaTimeToDuration( + currentTimeRef.current / 1000, + Number.isFinite(video.duration) ? video.duration : null, + ); + if (Math.abs(video.currentTime - targetTime) > 0.001) { + try { + video.currentTime = targetTime; + } catch { + // no-op + } + } + } + layoutVideoContentRef.current?.(); + const videoTextureSource = videoSpriteRef.current?.texture?.source as + | { update?: () => void } + | undefined; + videoTextureSource?.update?.(); + requestAnimationFrame(() => { + appRef.current?.render(); + }); + if (isPlayingRef.current) { + bgVideoRef.current?.play().catch(() => undefined); + webcamVideoRef.current?.play().catch(() => undefined); + } + }, [pixiReady, suspendRendering]); + + // Keep video wallpapers locked to the same source timestamp as the main clip. + useEffect(() => { + const bgVideo = bgVideoRef.current; + if (!bgVideo) return; + + const clipTimelineTime = currentTime; + const videoDuration = + Number.isFinite(bgVideo.duration) && bgVideo.duration > 0 ? bgVideo.duration : null; + const targetTime = videoDuration + ? clipTimelineTime % videoDuration + : clampMediaTimeToDuration(clipTimelineTime, videoDuration); + + const activeSpeedRegion = speedRegionsRef.current.find( + (region) => + currentTime * 1000 >= region.startMs && currentTime * 1000 < region.endMs, + ); + const targetPlaybackRate = activeSpeedRegion ? activeSpeedRegion.speed : 1; + enablePitchPreservingPlayback(bgVideo); + const syncedPlaybackRate = getMediaSyncPlaybackRate({ + basePlaybackRate: targetPlaybackRate, + currentTime: bgVideo.currentTime, + targetTime, + toleranceSeconds: 0.02, + correctionWindowSeconds: 1.5, + maxAdjustment: 0.12, + }); + if (Math.abs(bgVideo.playbackRate - syncedPlaybackRate) > 0.001) { + bgVideo.playbackRate = syncedPlaybackRate; + } + + const previousTimelineTime = lastBackgroundSyncTimeRef.current; + const timelineJumped = + previousTimelineTime === null || + Math.abs(clipTimelineTime - previousTimelineTime) > 0.25; + const driftThreshold = isPlaying ? 0.35 : 0.01; + if (timelineJumped || Math.abs(bgVideo.currentTime - targetTime) > driftThreshold) { + try { + bgVideo.currentTime = targetTime; + } catch { + // no-op + } + } + + if (isPlaying) { + const playPromise = bgVideo.play(); + if (playPromise) { + playPromise.catch(() => undefined); + } + } else { + bgVideo.pause(); + } + + lastBackgroundSyncTimeRef.current = clipTimelineTime; + }, [currentTime, isPlaying]); + + useEffect(() => { + trimRegionsRef.current = trimRegions; + }, [trimRegions]); + + useEffect(() => { + speedRegionsRef.current = speedRegions; + }, [speedRegions]); + + useEffect(() => { + if (!pixiReady) return; + const videoEffectsContainer = videoEffectsContainerRef.current; + const zoomBlurFilter = zoomBlurFilterRef.current; + const motionBlurFilter = motionBlurFilterRef.current; + + if (!videoEffectsContainer || !motionBlurFilter || !zoomBlurFilter) { + return; + } + + videoEffectsContainer.filters = + (zoomMotionBlurRef.current ?? 0) > 0 ? [motionBlurFilter, zoomBlurFilter] : null; + motionBlurFilter.velocity = { x: 0, y: 0 }; + motionBlurFilter.kernelSize = 5; + motionBlurFilter.offset = 0; + zoomBlurFilter.strength = 0; + zoomBlurFilter.innerRadius = 0; + zoomBlurFilter.radius = -1; + motionBlurStateRef.current = createMotionBlurState(); + }, [pixiReady]); + + useEffect(() => { + connectZoomsRef.current = connectZooms; + }, [connectZooms]); + + useEffect(() => { + zoomInDurationMsRef.current = zoomInDurationMs; + }, [zoomInDurationMs]); + + useEffect(() => { + zoomInOverlapMsRef.current = zoomInOverlapMs; + }, [zoomInOverlapMs]); + + useEffect(() => { + zoomOutDurationMsRef.current = zoomOutDurationMs; + }, [zoomOutDurationMs]); + + useEffect(() => { + connectedZoomGapMsRef.current = connectedZoomGapMs; + }, [connectedZoomGapMs]); + + useEffect(() => { + connectedZoomDurationMsRef.current = connectedZoomDurationMs; + }, [connectedZoomDurationMs]); + + useEffect(() => { + zoomInEasingRef.current = zoomInEasing; + }, [zoomInEasing]); + + useEffect(() => { + zoomOutEasingRef.current = zoomOutEasing; + }, [zoomOutEasing]); + + useEffect(() => { + connectedZoomEasingRef.current = connectedZoomEasing; + }, [connectedZoomEasing]); + + useEffect(() => { + cursorTelemetryRef.current = cursorTelemetry; + }, [cursorTelemetry]); + + useEffect(() => { + showCursorRef.current = showCursor; + }, [showCursor]); + + useEffect(() => { + cursorStyleRef.current = cursorStyle; + }, [cursorStyle]); + + useEffect(() => { + cursorSizeRef.current = cursorSize; + }, [cursorSize]); + + useEffect(() => { + cursorSmoothingRef.current = cursorSmoothing; + }, [cursorSmoothing]); + + useEffect(() => { + cursorSpringStiffnessMultiplierRef.current = cursorSpringStiffnessMultiplier; + }, [cursorSpringStiffnessMultiplier]); + + useEffect(() => { + cursorSpringDampingMultiplierRef.current = cursorSpringDampingMultiplier; + }, [cursorSpringDampingMultiplier]); + + useEffect(() => { + cursorSpringMassMultiplierRef.current = cursorSpringMassMultiplier; + }, [cursorSpringMassMultiplier]); + + useEffect(() => { + cameraSpringStiffnessMultiplierRef.current = cameraSpringStiffnessMultiplier; + }, [cameraSpringStiffnessMultiplier]); + + useEffect(() => { + cameraSpringDampingMultiplierRef.current = cameraSpringDampingMultiplier; + }, [cameraSpringDampingMultiplier]); + + useEffect(() => { + cameraSpringMassMultiplierRef.current = cameraSpringMassMultiplier; + }, [cameraSpringMassMultiplier]); + + useEffect(() => { + zoomSmoothnessRef.current = zoomSmoothness; + }, [zoomSmoothness]); + + useEffect(() => { + zoomMotionBlurRef.current = zoomMotionBlur; + + const videoEffectsContainer = videoEffectsContainerRef.current; + const zoomBlurFilter = zoomBlurFilterRef.current; + const motionBlurFilter = motionBlurFilterRef.current; + + if (!videoEffectsContainer || !zoomBlurFilter || !motionBlurFilter) { + return; + } + + motionBlurStateRef.current = createMotionBlurState(); + videoEffectsContainer.filters = + zoomMotionBlur > 0 ? [motionBlurFilter, zoomBlurFilter] : null; + }, [zoomMotionBlur]); + + useEffect(() => { + zoomMotionBlurTuningRef.current = zoomMotionBlurTuning; + }, [zoomMotionBlurTuning]); + + useEffect(() => { + zoomClassicModeRef.current = zoomClassicMode; + }, [zoomClassicMode]); + + useEffect(() => { + cursorMotionBlurRef.current = cursorMotionBlur; + }, [cursorMotionBlur]); + + useEffect(() => { + cursorClickEffectRef.current = cursorClickEffect; + }, [cursorClickEffect]); + + useEffect(() => { + cursorClickEffectColorRef.current = cursorClickEffectColor; + }, [cursorClickEffectColor]); + + useEffect(() => { + cursorClickEffectScaleRef.current = cursorClickEffectScale; + }, [cursorClickEffectScale]); + + useEffect(() => { + cursorClickEffectOpacityRef.current = cursorClickEffectOpacity; + }, [cursorClickEffectOpacity]); + + useEffect(() => { + cursorClickEffectDurationMsRef.current = cursorClickEffectDurationMs; + }, [cursorClickEffectDurationMs]); + + useEffect(() => { + cursorClickBounceRef.current = cursorClickBounce; + }, [cursorClickBounce]); + + useEffect(() => { + cursorClickBounceDurationRef.current = cursorClickBounceDuration; + }, [cursorClickBounceDuration]); + + useEffect(() => { + cursorSwayRef.current = cursorSway; + }, [cursorSway]); + + useEffect(() => { + const timeMs = currentTime * 1000; + currentTimeRef.current = timeMs; + }, [currentTime]); + + useEffect(() => { + if (!pixiReady || !videoReady) return; + + const app = appRef.current; + const cameraContainer = cameraContainerRef.current; + const video = videoRef.current; + + if (!app || !cameraContainer || !video) return; + + const tickerWasStarted = app.ticker?.started || false; + if (tickerWasStarted && app.ticker) { + app.ticker.stop(); + } + + const wasPlaying = !video.paused; + if (wasPlaying) { + video.pause(); + } + + animationStateRef.current = createPlaybackAnimationState(); + cursorOverlayRef.current?.reset(); + motionBlurStateRef.current = createMotionBlurState(); + + requestAnimationFrame(() => { + const container = cameraContainerRef.current; + const videoStage = videoContainerRef.current; + const sprite = videoSpriteRef.current; + const currentApp = appRef.current; + if (!container || !videoStage || !sprite || !currentApp) { + return; + } + + container.scale.set(1); + container.position.set(0, 0); + videoStage.scale.set(1); + videoStage.position.set(0, 0); + sprite.scale.set(1); + sprite.position.set(0, 0); + + layoutVideoContent(); + + applyZoomTransform({ + cameraContainer: container, + zoomBlurFilter: zoomBlurFilterRef.current, + motionBlurFilter: motionBlurFilterRef.current, + stageSize: stageSizeRef.current, + baseMask: baseMaskRef.current, + zoomScale: 1, + focusX: DEFAULT_FOCUS.cx, + focusY: DEFAULT_FOCUS.cy, + isPlaying: false, + motionBlurAmount: 0, + motionBlurState: motionBlurStateRef.current, + }); + + requestAnimationFrame(() => { + const finalApp = appRef.current; + if (wasPlaying && video) { + video.play().catch(() => undefined); + } + if (tickerWasStarted && finalApp?.ticker) { + finalApp.ticker.start(); + } + }); + }); + }, [pixiReady, videoReady, layoutVideoContent]); + + useEffect(() => { + if (!pixiReady || !videoReady) return; + const container = containerRef.current; + if (!container) return; + + if (typeof ResizeObserver === "undefined") { + return; + } + + const observer = new ResizeObserver(() => { + layoutVideoContent(); + }); + + observer.observe(container); + return () => { + observer.disconnect(); + }; + }, [pixiReady, videoReady, layoutVideoContent]); + + useEffect(() => { + if (!pixiReady || !videoReady) return; + updateOverlayForRegion(selectedZoom); + }, [selectedZoom, pixiReady, videoReady, updateOverlayForRegion]); + + useEffect(() => { + if (!pixiReady || !videoReady) return; + applyWebcamBubbleLayout(animationStateRef.current.appliedScale || 1); + }, [applyWebcamBubbleLayout, pixiReady, videoReady]); + + const syncWebcamMedia = useCallback(() => { + const webcamVideo = webcamVideoRef.current; + if (!webcamVideo || !webcamEnabled || !webcamVideoPath) { + return; + } + + const webcamDuration = Number.isFinite(webcamVideo.duration) + ? webcamVideo.duration + : null; + const targetTime = getWebcamMediaTargetTimeSeconds({ + currentTime: currentTimeRef.current / 1000, + webcamDuration, + timeOffsetMs: webcamTimeOffsetMs, + }); + const mediaTargetTime = + targetTime <= 0 && webcamDuration !== null && webcamDuration > 0 + ? Math.min(1 / 60, webcamDuration) + : targetTime; + + if ( + webcamSynchronizedPathRef.current !== webcamVideoPath && + !isWebcamMediaSynchronized({ + currentTime: webcamVideo.currentTime, + targetTime: mediaTargetTime, + readyState: webcamVideo.readyState, + isSeeking: webcamVideo.seeking, + }) + ) { + webcamVideo.pause(); + if ( + webcamVideo.readyState >= HTMLMediaElement.HAVE_METADATA && + !webcamVideo.seeking && + Math.abs(webcamVideo.currentTime - mediaTargetTime) > 0.01 + ) { + try { + webcamVideo.currentTime = mediaTargetTime; + } catch { + // The next media-ready event retries once the source accepts seeks. + } + } + return; + } + + if (webcamSynchronizedPathRef.current !== webcamVideoPath) { + webcamSynchronizedPathRef.current = webcamVideoPath; + setWebcamSynchronizedPath(webcamVideoPath); + lastWebcamSyncTimeRef.current = targetTime; + } + + const timelineTimeMs = currentTime * 1000; + const activeSpeedRegion = speedRegionsRef.current.find( + (region) => timelineTimeMs >= region.startMs && timelineTimeMs < region.endMs, + ); + const targetPlaybackRate = activeSpeedRegion ? activeSpeedRegion.speed : 1; + enablePitchPreservingPlayback(webcamVideo); + if (Math.abs(webcamVideo.playbackRate - targetPlaybackRate) > 0.001) { + webcamVideo.playbackRate = targetPlaybackRate; + } + + const previousTimelineTime = lastWebcamSyncTimeRef.current; + if ( + shouldSeekWebcamMedia({ + desiredTime: mediaTargetTime, + isPlaying, + isSeeking: webcamVideo.seeking, + previousTimelineTime, + timelineTime: targetTime, + webcamCurrentTime: webcamVideo.currentTime, + }) + ) { + try { + webcamVideo.currentTime = mediaTargetTime; + } catch { + // no-op + } + } + + if (isPlaying) { + const playPromise = webcamVideo.play(); + if (playPromise) { + playPromise.catch(() => undefined); + } + } else { + webcamVideo.pause(); + } + + lastWebcamSyncTimeRef.current = targetTime; + }, [currentTime, isPlaying, webcamEnabled, webcamTimeOffsetMs, webcamVideoPath]); + + const handleWebcamMediaReady = useCallback( + (event: React.SyntheticEvent<HTMLVideoElement>) => { + const video = event.currentTarget; + if (video.videoWidth > 0 && video.videoHeight > 0 && video.readyState >= 2) { + setWebcamVideoDimensions({ + width: video.videoWidth, + height: video.videoHeight, + }); + } + syncWebcamMedia(); + }, + [syncWebcamMedia], + ); + + useEffect(() => { + syncWebcamMedia(); + }, [syncWebcamMedia]); + + // biome-ignore lint/correctness/useExhaustiveDependencies: The media path intentionally triggers source-specific state reset. + useEffect(() => { + webcamSynchronizedPathRef.current = null; + setWebcamSynchronizedPath(null); + setWebcamVideoDimensions(null); + lastWebcamSyncTimeRef.current = null; + }, [webcamVideoPath]); + + // biome-ignore lint/correctness/useExhaustiveDependencies: The wallpaper identity intentionally resets media synchronization. + useEffect(() => { + lastBackgroundSyncTimeRef.current = null; + }, [wallpaper]); + + useEffect(() => { + const overlayEl = overlayRef.current; + if (!overlayEl) return; + if (!selectedZoom || selectedZoom.mode !== "manual") { + overlayEl.style.cursor = "default"; + overlayEl.style.pointerEvents = "none"; + return; + } + overlayEl.style.cursor = isPlaying ? "not-allowed" : "crosshair"; + overlayEl.style.pointerEvents = isPlaying ? "none" : "auto"; + }, [selectedZoom, isPlaying]); + + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + let mounted = true; + let app: Application | null = null; + + (async () => { + let cursorOverlayEnabled = true; + try { + await preloadCursorAssets(); + } catch (error) { + cursorOverlayEnabled = false; + console.warn( + "Native cursor assets are unavailable in preview; continuing without cursor overlay.", + error, + ); + } + setPixiRendererError(null); + setPixiRendererBackend(null); + + const result = await initializePixiRenderer(container); + app = result.app; + setPixiRendererBackend(result.backend); + + app.ticker.maxFPS = 60; + + if (!mounted) { + destroyPixiApplication(app, "unmounted preview renderer"); + return; + } + + appRef.current = app; + container.appendChild(app.canvas); + + // Camera container - this will be scaled/positioned for zoom + const cameraContainer = new Container(); + cameraContainerRef.current = cameraContainer; + app.stage.addChild(cameraContainer); + + // Match the export scene graph so zoom motion blur is applied to the + // same layer in preview and export. + const videoEffectsContainer = new Container(); + videoEffectsContainerRef.current = videoEffectsContainer; + zoomBlurFilterRef.current = new ZoomBlurFilter({ strength: 0, maxKernelSize: 13 }); + motionBlurFilterRef.current = new MotionBlurFilter([0, 0], 5, 0); + videoEffectsContainer.filters = [ + motionBlurFilterRef.current, + zoomBlurFilterRef.current, + ]; + cameraContainer.addChild(videoEffectsContainer); + syncPreviewMotionBlurQuality(); + + // Video container - holds the masked video sprite + const videoContainer = new Container(); + videoContainerRef.current = videoContainer; + videoEffectsContainer.addChild(videoContainer); + + const cursorContainer = new Container(); + cursorContainerRef.current = cursorContainer; + cameraContainer.addChild(cursorContainer); + + // Cursor overlay - rendered above the masked video so it can sit in front + // of the content without getting clipped. + if (cursorOverlayEnabled) { + const cursorOverlay = new PixiCursorOverlay({ + dotRadius: DEFAULT_CURSOR_CONFIG.dotRadius * cursorSizeRef.current, + minViewportScale: 0, + style: cursorStyleRef.current, + smoothingFactor: cursorSmoothingRef.current, + springTuning: { + stiffnessMultiplier: cursorSpringStiffnessMultiplierRef.current, + dampingMultiplier: cursorSpringDampingMultiplierRef.current, + massMultiplier: cursorSpringMassMultiplierRef.current, + }, + motionBlur: cursorMotionBlurRef.current, + clickEffect: cursorClickEffectRef.current, + clickEffectColor: cursorClickEffectColorRef.current, + clickEffectScale: cursorClickEffectScaleRef.current, + clickEffectOpacity: cursorClickEffectOpacityRef.current, + clickEffectDurationMs: cursorClickEffectDurationMsRef.current, + clickBounce: cursorClickBounceRef.current, + clickBounceDuration: cursorClickBounceDurationRef.current, + sway: cursorSwayRef.current, + }); + cursorOverlayRef.current = cursorOverlay; + cursorOverlay.setFilterResolution( + app.renderer.resolution || window.devicePixelRatio || 1, + ); + cursorContainer.addChild(cursorOverlay.container); + } else { + cursorOverlayRef.current = null; + } + + setPixiReady(true); + })().catch((error) => { + const errorMessage = + error instanceof Error + ? error.message + : "Failed to initialize preview renderer"; + console.error("Failed to initialize preview renderer:", error); + setPixiRendererError(errorMessage); + onError( + error instanceof Error + ? error.message + : "Failed to initialize preview renderer", + ); + }); + + return () => { + mounted = false; + setPixiReady(false); + setPixiRendererError(null); + setPixiRendererBackend(null); + if (cursorOverlayRef.current) { + cursorOverlayRef.current.destroy(); + cursorOverlayRef.current = null; + } + if (videoEffectsContainerRef.current) { + videoEffectsContainerRef.current.filters = null; + } + zoomBlurFilterRef.current?.destroy(); + motionBlurFilterRef.current?.destroy(); + zoomBlurFilterRef.current = null; + motionBlurFilterRef.current = null; + destroyPixiApplication(app, "preview renderer"); + appRef.current = null; + cameraContainerRef.current = null; + videoEffectsContainerRef.current = null; + videoContainerRef.current = null; + cursorContainerRef.current = null; + videoSpriteRef.current = null; + }; + }, [initializePixiRenderer, onError, syncPreviewMotionBlurQuality]); + + // biome-ignore lint/correctness/useExhaustiveDependencies: A new media path must reset the persistent video element. + useEffect(() => { + const video = videoRef.current; + if (!video) return; + video.pause(); + video.currentTime = 0; + allowPlaybackRef.current = false; + lockedVideoDimensionsRef.current = null; + setVideoReady(false); + if (videoReadyRafRef.current) { + cancelAnimationFrame(videoReadyRafRef.current); + videoReadyRafRef.current = null; + } + }, [videoPath]); + + useEffect(() => { + onPreviewReadyChange?.(videoReady); + }, [onPreviewReadyChange, videoReady]); + + useEffect(() => { + if (!pixiReady || !videoReady) return; + + const video = videoRef.current; + const app = appRef.current; + const videoEffectsContainer = videoEffectsContainerRef.current; + const videoContainer = videoContainerRef.current; + const cursorContainer = cursorContainerRef.current; + + if (!video || !app || !videoEffectsContainer || !videoContainer || !cursorContainer) + return; + if (video.videoWidth === 0 || video.videoHeight === 0) return; + + const source = VideoSource.from(video); + if ("autoPlay" in source) { + (source as { autoPlay?: boolean }).autoPlay = false; + } + if ("autoUpdate" in source) { + (source as { autoUpdate?: boolean }).autoUpdate = true; + } + const videoTexture = Texture.from(source); + + const videoSprite = new Sprite(videoTexture); + videoSpriteRef.current = videoSprite; + + const maskGraphics = new Graphics(); + videoContainer.addChild(videoSprite); + videoContainer.addChild(maskGraphics); + videoContainer.mask = maskGraphics; + maskGraphicsRef.current = maskGraphics; + if (cursorOverlayRef.current) { + cursorContainer.addChild(cursorOverlayRef.current.container); + } + + animationStateRef.current = createPlaybackAnimationState(); + + layoutVideoContent(); + video.pause(); + + const { handlePlay, handlePause, handleSeeked, handleSeeking, dispose } = + createVideoEventHandlers({ + video, + isSeekingRef, + isPlayingRef, + allowPlaybackRef, + currentTimeRef, + timeUpdateAnimationRef, + onPlayStateChange, + onTimeUpdate, + trimRegionsRef, + speedRegionsRef, + }); + + video.addEventListener("play", handlePlay); + video.addEventListener("pause", handlePause); + video.addEventListener("ended", handlePause); + video.addEventListener("seeked", handleSeeked); + video.addEventListener("seeking", handleSeeking); + + return () => { + video.removeEventListener("play", handlePlay); + video.removeEventListener("pause", handlePause); + video.removeEventListener("ended", handlePause); + video.removeEventListener("seeked", handleSeeked); + video.removeEventListener("seeking", handleSeeking); + dispose(); + + if (videoSprite) { + videoContainer.removeChild(videoSprite); + videoSprite.destroy(); + } + if (maskGraphics) { + videoContainer.removeChild(maskGraphics); + maskGraphics.destroy(); + } + videoContainer.mask = null; + maskGraphicsRef.current = null; + videoTexture.destroy(false); + + videoSpriteRef.current = null; + }; + }, [layoutVideoContent, onPlayStateChange, onTimeUpdate, pixiReady, videoReady]); + + useEffect(() => { + if (!pixiReady || !videoReady) return; + + const app = appRef.current; + const videoSprite = videoSpriteRef.current; + const videoEffectsContainer = videoEffectsContainerRef.current; + const videoContainer = videoContainerRef.current; + if (!app || !videoSprite || !videoEffectsContainer || !videoContainer) return; + + const applyTransform = ( + transform: { scale: number; x: number; y: number }, + focus: ZoomFocus, + ) => { + const cameraContainer = cameraContainerRef.current; + if (!cameraContainer) return; + + const state = animationStateRef.current; + + const appliedTransform = applyZoomTransform({ + cameraContainer, + zoomBlurFilter: zoomBlurFilterRef.current, + motionBlurFilter: motionBlurFilterRef.current, + stageSize: stageSizeRef.current, + baseMask: baseMaskRef.current, + zoomScale: state.scale, + zoomProgress: state.progress, + focusX: focus.cx, + focusY: focus.cy, + isPlaying: isPlayingRef.current, + motionBlurAmount: zoomMotionBlurRef.current, + motionBlurTuning: zoomMotionBlurTuningRef.current, + transformOverride: transform, + motionBlurState: motionBlurStateRef.current, + frameTimeMs: performance.now(), + }); + + state.x = appliedTransform.x; + state.y = appliedTransform.y; + state.appliedScale = appliedTransform.scale; + setAnnotationSceneTransform((current) => { + if ( + Math.abs(current.scale - appliedTransform.scale) < 0.001 && + Math.abs(current.x - appliedTransform.x) < 0.1 && + Math.abs(current.y - appliedTransform.y) < 0.1 + ) { + return current; + } + + return { + scale: appliedTransform.scale, + x: appliedTransform.x, + y: appliedTransform.y, + }; + }); + }; + + const ticker = () => { + if (suspendRenderingRef.current) { + return; + } + + const { region, strength, blendedScale } = findDominantRegion( + zoomRegionsRef.current, + currentTimeRef.current, + { + connectZooms: connectZoomsRef.current, + zoomInDurationMs: zoomInDurationMsRef.current, + zoomOutDurationMs: zoomOutDurationMsRef.current, + }, + ); + + const defaultFocus = DEFAULT_FOCUS; + let targetScaleFactor = 1; + let targetFocus = defaultFocus; + let targetProgress = 0; + + // If a zoom is selected but video is not playing, show default unzoomed view + // (the overlay will show where the zoom will be) + const selectedId = selectedZoomIdRef.current; + const hasSelectedZoom = selectedId !== null; + const shouldShowUnzoomedView = hasSelectedZoom && !isPlayingRef.current; + + if (region && strength > 0 && !shouldShowUnzoomedView) { + const zoomScale = blendedScale ?? ZOOM_DEPTH_SCALES[region.depth]; + + // Cursor follow: use cursor-follow camera for non-manual zoom regions + let regionFocus = region.focus; + if ( + !zoomClassicModeRef.current && + region.mode !== "manual" && + cursorTelemetryRef.current.length > 0 + ) { + regionFocus = computeCursorFollowFocus( + cursorFollowCameraRef.current, + cursorTelemetryRef.current, + currentTimeRef.current, + zoomScale, + strength, + region.focus, + { snapToEdgesRatio: SNAP_TO_EDGES_RATIO_AUTO }, + ); + } + + targetScaleFactor = zoomScale; + targetFocus = regionFocus; + targetProgress = strength; + } + + const state = animationStateRef.current; + + state.scale = targetScaleFactor; + state.focusX = targetFocus.cx; + state.focusY = targetFocus.cy; + state.progress = targetProgress; + + const projectedTransform = computeZoomTransform({ + stageSize: stageSizeRef.current, + baseMask: baseMaskRef.current, + zoomScale: state.scale, + zoomProgress: state.progress, + focusX: state.focusX, + focusY: state.focusY, + }); + + // Spring-driven zoom animation + const now = performance.now(); + const deltaMs = + lastTickTimeRef.current !== null ? now - lastTickTimeRef.current : 1000 / 60; + lastTickTimeRef.current = now; + + const zoomSpringConfig = getZoomSpringConfig(zoomSmoothnessRef.current, { + stiffnessMultiplier: cameraSpringStiffnessMultiplierRef.current, + dampingMultiplier: cameraSpringDampingMultiplierRef.current, + massMultiplier: cameraSpringMassMultiplierRef.current, + }); + const useSpring = + isPlayingRef.current && !isSeekingRef.current && !zoomClassicModeRef.current; + + let appliedScale: number; + let appliedX: number; + let appliedY: number; + + if (useSpring) { + appliedScale = stepSpringValue( + springScaleRef.current, + projectedTransform.scale, + deltaMs, + zoomSpringConfig, + ); + appliedX = stepSpringValue( + springXRef.current, + projectedTransform.x, + deltaMs, + zoomSpringConfig, + ); + appliedY = stepSpringValue( + springYRef.current, + projectedTransform.y, + deltaMs, + zoomSpringConfig, + ); + } else { + // Snap instantly when paused, seeking, or in classic mode + appliedScale = projectedTransform.scale; + appliedX = projectedTransform.x; + appliedY = projectedTransform.y; + resetSpringState(springScaleRef.current, appliedScale); + resetSpringState(springXRef.current, appliedX); + resetSpringState(springYRef.current, appliedY); + } + + applyTransform({ scale: appliedScale, x: appliedX, y: appliedY }, targetFocus); + + applyWebcamBubbleLayout(animationStateRef.current.appliedScale || 1); + + const timeMs = currentTimeRef.current; + const cursorOverlay = cursorOverlayRef.current; + if (cursorOverlay) { + cursorOverlay.update( + cursorTelemetryRef.current, + timeMs, + baseMaskRef.current, + showCursorRef.current, + !isPlayingRef.current || isSeekingRef.current, + ); + } + }; + + app.ticker.add(ticker); + return () => { + if (app && app.ticker) { + app.ticker.remove(ticker); + } + }; + }, [pixiReady, videoReady, applyWebcamBubbleLayout]); + + useEffect(() => { + const overlay = cursorOverlayRef.current; + if (!overlay) { + return; + } + + let cancelled = false; + + overlay.setDotRadius(DEFAULT_CURSOR_CONFIG.dotRadius * cursorSize); + overlay.setSmoothingFactor(cursorSmoothing); + overlay.setSpringTuning({ + stiffnessMultiplier: cursorSpringStiffnessMultiplier, + dampingMultiplier: cursorSpringDampingMultiplier, + massMultiplier: cursorSpringMassMultiplier, + }); + overlay.setMotionBlur(cursorMotionBlur); + overlay.setClickEffect(cursorClickEffect); + overlay.setClickEffectColor(cursorClickEffectColor); + overlay.setClickEffectScale(cursorClickEffectScale); + overlay.setClickEffectOpacity(cursorClickEffectOpacity); + overlay.setClickEffectDurationMs(cursorClickEffectDurationMs); + overlay.setClickBounce(cursorClickBounce); + overlay.setClickBounceDuration(cursorClickBounceDuration); + overlay.setSway(cursorSway); + + void (async () => { + try { + await preloadCursorAssets(); + } catch (error) { + console.warn("Failed to refresh cursor assets for preview:", error); + return; + } + + if (cancelled || cursorOverlayRef.current !== overlay) { + return; + } + + overlay.setStyle(cursorStyle); + overlay.reset(); + })(); + + return () => { + cancelled = true; + }; + }, [ + cursorStyle, + cursorSize, + cursorSmoothing, + cursorSpringStiffnessMultiplier, + cursorSpringDampingMultiplier, + cursorSpringMassMultiplier, + cursorMotionBlur, + cursorClickEffect, + cursorClickEffectColor, + cursorClickEffectScale, + cursorClickEffectOpacity, + cursorClickEffectDurationMs, + cursorClickBounce, + cursorClickBounceDuration, + cursorSway, + ]); + + const handleLoadedMetadata = (e: React.SyntheticEvent<HTMLVideoElement, Event>) => { + const video = e.currentTarget; + onDurationChange(video.duration); + + const targetTime = clampMediaTimeToDuration( + currentTime, + Number.isFinite(video.duration) ? video.duration : null, + ); + video.currentTime = targetTime; + video.pause(); + allowPlaybackRef.current = false; + currentTimeRef.current = targetTime * 1000; + + if (videoReadyRafRef.current) { + cancelAnimationFrame(videoReadyRafRef.current); + videoReadyRafRef.current = null; + } + + const waitForRenderableFrame = () => { + const hasDimensions = video.videoWidth > 0 && video.videoHeight > 0; + const hasData = video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA; + if (hasDimensions && hasData) { + videoReadyRafRef.current = null; + setVideoReady(true); + return; + } + videoReadyRafRef.current = requestAnimationFrame(waitForRenderableFrame); + }; + + videoReadyRafRef.current = requestAnimationFrame(waitForRenderableFrame); + }; + + const [resolvedWallpaper, setResolvedWallpaper] = useState<string | null>(null); + const [resolvedWallpaperKind, setResolvedWallpaperKind] = useState< + "image" | "video" | "style" + >("image"); + + useEffect(() => { + let mounted = true; + const revokeResolvedWallpaper = () => undefined; + (async () => { + try { + if (!wallpaper) { + const def = await getAssetPath(DEFAULT_WALLPAPER_RELATIVE_PATH); + if (mounted) { + setResolvedWallpaper(def); + setResolvedWallpaperKind("image"); + } + return; + } + + if ( + wallpaper.startsWith("#") || + wallpaper.startsWith("linear-gradient") || + wallpaper.startsWith("radial-gradient") + ) { + if (mounted) { + setResolvedWallpaper(wallpaper); + setResolvedWallpaperKind("style"); + } + return; + } + + if (isVideoWallpaperSource(wallpaper)) { + const videoSrc = await getRenderableVideoUrl(wallpaper); + if (mounted) { + setResolvedWallpaper(videoSrc); + setResolvedWallpaperKind("video"); + } + return; + } + + // If it's a data URL (custom uploaded image), use as-is + if (wallpaper.startsWith("data:")) { + if (mounted) { + setResolvedWallpaper(wallpaper); + setResolvedWallpaperKind("image"); + } + return; + } + + if ( + wallpaper.startsWith("http") || + wallpaper.startsWith("file://") || + wallpaper.startsWith("/") + ) { + const renderable = await getRenderableAssetUrl(wallpaper); + if (mounted) { + setResolvedWallpaper(renderable); + setResolvedWallpaperKind("image"); + } + return; + } + const p = await getRenderableAssetUrl( + await getAssetPath(wallpaper.replace(/^\//, "")), + ); + if (mounted) { + setResolvedWallpaper(p); + setResolvedWallpaperKind("image"); + } + } catch (_err) { + if (mounted) { + setResolvedWallpaper(wallpaper || DEFAULT_WALLPAPER_PATH); + setResolvedWallpaperKind( + isVideoWallpaperSource(wallpaper || "") ? "video" : "image", + ); + } + } + })(); + return () => { + mounted = false; + revokeResolvedWallpaper(); + }; + }, [wallpaper]); + + useEffect(() => { + return () => { + if (videoReadyRafRef.current) { + cancelAnimationFrame(videoReadyRafRef.current); + videoReadyRafRef.current = null; + } + }; + }, []); + + const isImageUrl = + resolvedWallpaperKind === "image" && + Boolean( + resolvedWallpaper && + (resolvedWallpaper.startsWith("file://") || + resolvedWallpaper.startsWith("http") || + resolvedWallpaper.startsWith("/") || + resolvedWallpaper.startsWith("data:")), + ); + const backgroundStyle = isImageUrl + ? { backgroundImage: `url(${resolvedWallpaper || ""})` } + : resolvedWallpaperKind === "video" + ? {} + : { background: resolvedWallpaper || "" }; + // Overscan blurred wallpaper layers so the browser never samples transparent + // pixels beyond the preview bounds, which otherwise looks like a vignette. + const backgroundBlurOverscan = backgroundBlur > 0 ? Math.ceil(backgroundBlur * 2) : 0; + const fallbackVideoClassName = pixiRendererError + ? "absolute inset-0 h-full w-full object-cover" + : "pointer-events-none absolute left-0 top-0 h-px w-px opacity-0"; + const hasRendererFallback = Boolean(pixiRendererError); + + const nativeAspectRatio = (() => { + const locked = lockedVideoDimensionsRef.current; + if (locked) { + return getEffectiveNativeAspectRatio(locked, cropRegion); + } + const video = videoRef.current; + if (video && video.videoHeight > 0 && video.videoWidth > 0) { + return getEffectiveNativeAspectRatio( + { + width: video.videoWidth, + height: video.videoHeight, + }, + cropRegion, + ); + } + return 16 / 9; + })(); + + return ( + <div + ref={previewFrameRef} + className="relative overflow-hidden" + style={{ + width: "100%", + aspectRatio: formatAspectRatioForCSS(aspectRatio, nativeAspectRatio), + borderRadius: 0, + clipPath: "none", + }} + > + {/* Background layer */} + {resolvedWallpaperKind === "video" && resolvedWallpaper ? ( + <video + key={resolvedWallpaper} + ref={bgVideoRef} + className="absolute object-cover" + src={resolvedWallpaper} + muted + loop + playsInline + style={{ + filter: backgroundBlur > 0 ? `blur(${backgroundBlur}px)` : "none", + inset: -backgroundBlurOverscan, + width: `calc(100% + ${backgroundBlurOverscan * 2}px)`, + height: `calc(100% + ${backgroundBlurOverscan * 2}px)`, + }} + /> + ) : ( + <div + className="absolute inset-0 bg-cover bg-center" + style={{ + ...backgroundStyle, + filter: backgroundBlur > 0 ? `blur(${backgroundBlur}px)` : "none", + inset: -backgroundBlurOverscan, + }} + /> + )} + <div + ref={containerRef} + className="absolute inset-0" + style={{ + filter: + showShadow && shadowIntensity > 0 + ? `drop-shadow(0 ${shadowIntensity * 12}px ${shadowIntensity * 48}px rgba(0,0,0,${shadowIntensity * 0.7})) drop-shadow(0 ${shadowIntensity * 4}px ${shadowIntensity * 16}px rgba(0,0,0,${shadowIntensity * 0.5})) drop-shadow(0 ${shadowIntensity * 2}px ${shadowIntensity * 8}px rgba(0,0,0,${shadowIntensity * 0.3}))` + : "none", + }} + /> + {hasRendererFallback && ( + <div className="absolute inset-0 z-10 flex items-center justify-center bg-black/60 p-2 text-center"> + <div className="rounded-md bg-black/70 px-3 py-1.5 text-xs text-white"> + {`Pixi renderer unavailable on this environment (${pixiRendererBackend ?? "unknown"}).`} + <br /> + Fallback to 2D native preview so you can continue working while the GPU + path is unavailable. + </div> + </div> + )} + {/* Only render overlay after PIXI and video are fully initialized */} + {pixiReady && videoReady && ( + <div + ref={overlayRef} + className="absolute inset-0 select-none" + style={{ pointerEvents: "none" }} + onPointerDown={handleOverlayPointerDown} + onPointerMove={handleOverlayPointerMove} + onPointerUp={handleOverlayPointerUp} + onPointerLeave={handleOverlayPointerLeave} + > + <div + ref={focusIndicatorRef} + className="absolute rounded-md border border-[#2563EB]/80 bg-[#2563EB]/20 shadow-[0_0_0_1px_rgba(37,99,235,0.35)]" + style={{ display: "none", pointerEvents: "none" }} + /> + {webcam && webcamVideoPath ? ( + <div + ref={webcamBubbleRef} + className="absolute" + style={{ + display: webcam.enabled ? "block" : "none", + pointerEvents: "none", + }} + > + <div + ref={webcamBubbleInnerRef} + className="relative h-full w-full overflow-hidden" + > + <div + className="pointer-events-none absolute inset-0 overflow-hidden" + style={{ + opacity: + webcamVideoDimensions && webcamMediaSynchronized + ? 1 + : 0, + transform: webcamMirror ? "scaleX(-1)" : undefined, + }} + > + <div + className="pointer-events-none absolute" + style={webcamCropPreviewContentStyle} + > + <video + ref={webcamVideoRef} + src={webcamVideoPath} + className="pointer-events-none absolute inset-0 block h-full w-full object-fill" + muted + playsInline + preload="auto" + aria-hidden="true" + onLoadedMetadata={handleWebcamMediaReady} + onLoadedData={handleWebcamMediaReady} + onCanPlay={handleWebcamMediaReady} + onSeeked={handleWebcamMediaReady} + /> + </div> + </div> + </div> + </div> + ) : null} + {activeCaptionLayout && autoCaptionSettings ? ( + <div + className="absolute inset-x-0 flex justify-center" + style={{ + bottom: `${autoCaptionSettings.bottomOffset}%`, + pointerEvents: onEditAutoCaption ? "auto" : "none", + }} + > + <div + style={{ + maxWidth: `${autoCaptionSettings.maxWidth}%`, + opacity: activeCaptionLayout.opacity, + transform: `translateY(${activeCaptionLayout.translateY}px) scale(${activeCaptionLayout.scale})`, + transformOrigin: "center bottom", + filter: "drop-shadow(0 12px 30px rgba(0, 0, 0, 0.28))", + }} + > + <div + ref={captionBoxRef} + role={ + onEditAutoCaption && !isCaptionEditing + ? "button" + : undefined + } + tabIndex={ + onEditAutoCaption && !isCaptionEditing ? 0 : undefined + } + aria-label={ + onEditAutoCaption && !isCaptionEditing + ? "Edit current caption" + : undefined + } + onClick={(event) => { + event.stopPropagation(); + if (!isCaptionEditing) { + beginCaptionEdit(); + } + }} + onPointerDown={(event) => { + event.stopPropagation(); + }} + onKeyDown={(event) => { + if (!onEditAutoCaption || isCaptionEditing) { + return; + } + + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + beginCaptionEdit(); + } + }} + style={{ + backgroundColor: `rgba(0, 0, 0, ${autoCaptionSettings.backgroundOpacity})`, + fontFamily: getDefaultCaptionFontFamily(), + fontSize: `${getCaptionScaledFontSize( + autoCaptionSettings.fontSize, + overlayRef.current?.clientWidth || 960, + autoCaptionSettings.maxWidth, + )}px`, + lineHeight: CAPTION_LINE_HEIGHT, + textAlign: "center", + fontWeight: CAPTION_FONT_WEIGHT, + padding: `${ + getCaptionPadding( + getCaptionScaledFontSize( + autoCaptionSettings.fontSize, + overlayRef.current?.clientWidth || 960, + autoCaptionSettings.maxWidth, + ), + ).y + }px ${ + getCaptionPadding( + getCaptionScaledFontSize( + autoCaptionSettings.fontSize, + overlayRef.current?.clientWidth || 960, + autoCaptionSettings.maxWidth, + ), + ).x + }px`, + borderRadius: `${getCaptionScaledRadius( + autoCaptionSettings.boxRadius, + getCaptionScaledFontSize( + autoCaptionSettings.fontSize, + overlayRef.current?.clientWidth || 960, + autoCaptionSettings.maxWidth, + ), + )}px`, + boxSizing: "border-box", + cursor: + onEditAutoCaption && !isCaptionEditing + ? "text" + : undefined, + pointerEvents: onEditAutoCaption ? "auto" : undefined, + }} + > + {captionEditSession ? ( + <textarea + ref={captionEditInputRef} + value={captionEditSession.draft} + onChange={(event) => { + const draft = event.target.value; + setCaptionEditSession((session) => { + const nextSession = session + ? { ...session, draft } + : session; + captionEditSessionRef.current = nextSession; + return nextSession; + }); + }} + onBlur={commitCaptionEdit} + onClick={(event) => event.stopPropagation()} + onKeyDown={(event) => { + if (event.key === "Escape") { + event.preventDefault(); + cancelCaptionEdit(); + return; + } + + if (event.key === "Enter" && !event.shiftKey) { + event.preventDefault(); + event.currentTarget.blur(); + } + }} + rows={Math.max( + 1, + activeCaptionLayout.visibleLines.length, + )} + aria-label="Edit current caption" + style={{ + display: "block", + width: `${ + captionEditTextMetrics?.widthPx ?? + Math.max( + 48, + activeCaptionLayout.visibleLines.reduce( + (width, line) => + Math.max(width, line.width), + 0, + ), + ) + }px`, + maxWidth: `${ + captionEditTextMetrics?.maxTextWidthPx ?? + getCaptionTextMaxWidth( + overlayRef.current?.clientWidth || 960, + autoCaptionSettings.maxWidth, + getCaptionScaledFontSize( + autoCaptionSettings.fontSize, + overlayRef.current?.clientWidth || + 960, + autoCaptionSettings.maxWidth, + ), + ) + }px`, + minHeight: `${ + Math.max( + 1, + activeCaptionLayout.visibleLines.length, + ) * + ( + captionEditTextMetrics?.fontSize ?? + getCaptionScaledFontSize( + autoCaptionSettings.fontSize, + overlayRef.current + ?.clientWidth || 960, + autoCaptionSettings.maxWidth, + ) + ) * + CAPTION_LINE_HEIGHT + }px`, + resize: "none", + border: "0", + outline: "0", + padding: "0", + margin: "0", + overflow: "hidden", + background: "transparent", + color: autoCaptionSettings.textColor, + font: "inherit", + lineHeight: "inherit", + textAlign: "center", + }} + /> + ) : ( + activeCaptionLayout.visibleLines.map((line) => ( + <div + key={`${activeCaptionLayout.blockKey}-${line.startWordIndex}`} + style={{ + display: "flex", + justifyContent: "center", + flexWrap: "nowrap", + whiteSpace: "nowrap", + }} + > + {line.words.map((word) => { + const visualState = + getCaptionWordVisualState( + activeCaptionLayout.hasWordTimings, + word.state, + ); + + return ( + <span + key={`${activeCaptionLayout.blockKey}-${word.index}`} + style={{ + display: "inline-block", + whiteSpace: "pre", + color: visualState.isInactive + ? autoCaptionSettings.inactiveTextColor + : autoCaptionSettings.textColor, + opacity: visualState.opacity, + }} + > + {`${word.leadingSpace ? " " : ""}${word.text}`} + </span> + ); + })} + </div> + )) + )} + </div> + </div> + </div> + ) : null} + <div + className="absolute inset-0" + style={{ + pointerEvents: "none", + transform: `matrix(${annotationSceneTransform.scale}, 0, 0, ${annotationSceneTransform.scale}, ${annotationSceneTransform.x}, ${annotationSceneTransform.y})`, + transformOrigin: "top left", + }} + > + <div + className="absolute" + style={{ + pointerEvents: "none", + left: annotationRecordingRect.x || 0, + top: annotationRecordingRect.y || 0, + width: + annotationRecordingRect.width || + overlayRef.current?.clientWidth || + 800, + height: + annotationRecordingRect.height || + overlayRef.current?.clientHeight || + 600, + }} + > + {(() => { + const filtered = (annotationRegions || []).filter( + (annotation) => { + if ( + typeof annotation.startMs !== "number" || + typeof annotation.endMs !== "number" + ) + return false; + + if (annotation.id === selectedAnnotationId) return true; + + const timeMs = Math.round(currentTime * 1000); + return ( + timeMs >= annotation.startMs && + timeMs <= annotation.endMs + ); + }, + ); + + const sorted = [...filtered].sort( + (a, b) => a.zIndex - b.zIndex, + ); + + const handleAnnotationClick = (clickedId: string) => { + if (!onSelectAnnotation) return; + + if ( + clickedId === selectedAnnotationId && + sorted.length > 1 + ) { + const currentIndex = sorted.findIndex( + (a) => a.id === clickedId, + ); + const nextIndex = (currentIndex + 1) % sorted.length; + onSelectAnnotation(sorted[nextIndex].id); + } else { + onSelectAnnotation(clickedId); + } + }; + + return sorted.map((annotation) => ( + <AnnotationOverlay + key={annotation.id} + annotation={annotation} + isSelected={annotation.id === selectedAnnotationId} + containerWidth={ + annotationRecordingRect.width || + overlayRef.current?.clientWidth || + 800 + } + containerHeight={ + annotationRecordingRect.height || + overlayRef.current?.clientHeight || + 600 + } + recordingRect={{ + x: 0, + y: 0, + width: + annotationRecordingRect.width || + overlayRef.current?.clientWidth || + 800, + height: + annotationRecordingRect.height || + overlayRef.current?.clientHeight || + 600, + }} + sceneTransform={{ scale: 1, x: 0, y: 0 }} + interactionScale={annotationSceneTransform.scale} + onPositionChange={(id, position) => + onAnnotationPositionChange?.(id, position) + } + onSizeChange={(id, size) => + onAnnotationSizeChange?.(id, size) + } + onClick={handleAnnotationClick} + zIndex={annotation.zIndex} + isSelectedBoost={annotation.id === selectedAnnotationId} + /> + )); + })()} + </div> + </div> + </div> + )} + {/* Keep the source video off-screen instead of display:none so the + browser continues producing presented frames for Pixi and preview sync. */} + <video + ref={videoRef} + src={videoPath} + className={fallbackVideoClassName} + preload="metadata" + playsInline + aria-hidden="true" + onLoadedMetadata={handleLoadedMetadata} + onDurationChange={(e) => { + onDurationChange(e.currentTarget.duration); + }} + onError={(e) => { + const mediaError = e.currentTarget.error; + const code = mediaError?.code; + const msg = mediaError?.message; + const detail = + code === MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED + ? "format not supported" + : code === MediaError.MEDIA_ERR_NETWORK + ? "network error" + : code === MediaError.MEDIA_ERR_DECODE + ? "decode error" + : msg || `code ${code ?? "unknown"}`; + console.error( + "[VideoPlayback] Video load error:", + detail, + "src:", + videoPath, + ); + onError(`Failed to load video (${detail})`); + }} + /> + </div> + ); + }, +); + +VideoPlayback.displayName = "VideoPlayback"; export default VideoPlayback; diff --git a/src/components/video-editor/WebcamCropControl.tsx b/src/components/video-editor/WebcamCropControl.tsx new file mode 100644 index 000000000..122601f23 --- /dev/null +++ b/src/components/video-editor/WebcamCropControl.tsx @@ -0,0 +1,490 @@ +import { useCallback, useEffect, useId, useRef, useState } from "react"; +import { cn } from "@/lib/utils"; +import type { CropRegion } from "./types"; +import { + getWebcamPreviewTargetTimeSeconds, + isWebcamMediaSynchronized, +} from "./videoPlayback/webcamSync"; +import { normalizeWebcamCropRegion } from "./webcamOverlay"; + +type CropHandle = "move" | "nw" | "ne" | "sw" | "se"; + +const HANDLE_LABELS: Record<CropHandle, string> = { + move: "Move webcam crop", + nw: "Resize webcam crop from top left", + ne: "Resize webcam crop from top right", + sw: "Resize webcam crop from bottom left", + se: "Resize webcam crop from bottom right", +}; + +interface WebcamCropControlProps { + cropRegion: CropRegion; + mirrored?: boolean; + previewSrc?: string | null; + previewCurrentTime?: number; + previewPlaying?: boolean; + previewTimeOffsetMs?: number | null; + onCropChange: (cropRegion: CropRegion, previewFrame?: PreviewFrame | null) => void; +} + +interface DragState { + handle: CropHandle; + startX: number; + startY: number; + initialCrop: CropRegion; +} + +interface PreviewFrame { + src: string | null; + width: number; + height: number; +} + +const MIN_CROP_SIZE = 0.08; +const KEYBOARD_STEP = 0.01; +const KEYBOARD_FAST_STEP = 0.05; + +const RESIZE_HANDLES: Array<{ + handle: Exclude<CropHandle, "move">; + className: string; + cursorClassName: string; +}> = [ + { + handle: "nw", + className: "left-0 top-0 -translate-x-1/2 -translate-y-1/2", + cursorClassName: "cursor-nwse-resize", + }, + { + handle: "ne", + className: "right-0 top-0 translate-x-1/2 -translate-y-1/2", + cursorClassName: "cursor-nesw-resize", + }, + { + handle: "se", + className: "bottom-0 right-0 translate-x-1/2 translate-y-1/2", + cursorClassName: "cursor-nwse-resize", + }, + { + handle: "sw", + className: "bottom-0 left-0 -translate-x-1/2 translate-y-1/2", + cursorClassName: "cursor-nesw-resize", + }, +]; + +function clamp(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)); +} + +function flipCropHorizontally(cropRegion: CropRegion): CropRegion { + const crop = normalizeWebcamCropRegion(cropRegion); + return { + ...crop, + x: clamp(1 - crop.x - crop.width, 0, 1 - crop.width), + }; +} + +function resizeCrop(cropRegion: CropRegion, handle: CropHandle, deltaX: number, deltaY: number) { + const crop = normalizeWebcamCropRegion(cropRegion); + + if (handle === "move") { + return normalizeWebcamCropRegion({ + ...crop, + x: clamp(crop.x + deltaX, 0, 1 - crop.width), + y: clamp(crop.y + deltaY, 0, 1 - crop.height), + }); + } + + let left = crop.x; + let top = crop.y; + let right = crop.x + crop.width; + let bottom = crop.y + crop.height; + + if (handle === "nw" || handle === "sw") { + left = clamp(left + deltaX, 0, right - MIN_CROP_SIZE); + } + if (handle === "ne" || handle === "se") { + right = clamp(right + deltaX, left + MIN_CROP_SIZE, 1); + } + if (handle === "nw" || handle === "ne") { + top = clamp(top + deltaY, 0, bottom - MIN_CROP_SIZE); + } + if (handle === "sw" || handle === "se") { + bottom = clamp(bottom + deltaY, top + MIN_CROP_SIZE, 1); + } + + return normalizeWebcamCropRegion({ + x: left, + y: top, + width: right - left, + height: bottom - top, + }); +} + +export function WebcamCropControl({ + cropRegion, + mirrored = false, + previewSrc = null, + previewCurrentTime = 0, + previewPlaying = false, + previewTimeOffsetMs = 0, + onCropChange, +}: WebcamCropControlProps) { + const containerRef = useRef<HTMLDivElement | null>(null); + const previewVideoRef = useRef<HTMLVideoElement | null>(null); + const dragStateRef = useRef<DragState | null>(null); + const pendingCropRef = useRef<CropRegion | null>(null); + const pendingFrameRef = useRef<number | null>(null); + const maskId = `webcam-crop-mask-${useId().replace(/:/g, "")}`; + const [activeHandle, setActiveHandle] = useState<CropHandle | null>(null); + const [draftVisualCrop, setDraftVisualCrop] = useState<CropRegion | null>(null); + const [previewFrame, setPreviewFrame] = useState<PreviewFrame | null>(null); + const synchronizedPreviewSrcRef = useRef<string | null>(null); + const [synchronizedPreviewSrc, setSynchronizedPreviewSrc] = useState<string | null>(null); + const previewSynchronized = previewSrc !== null && synchronizedPreviewSrc === previewSrc; + const hasPreviewFrame = previewSrc !== null && previewFrame?.src === previewSrc; + const previewAspectRatio = + hasPreviewFrame && previewFrame && previewFrame.width > 0 && previewFrame.height > 0 + ? previewFrame.width / previewFrame.height + : 1; + const sourceCrop = normalizeWebcamCropRegion(cropRegion); + const propVisualCrop = mirrored ? flipCropHorizontally(sourceCrop) : sourceCrop; + const crop = draftVisualCrop ?? propVisualCrop; + const cropLeft = crop.x * 100; + const cropTop = crop.y * 100; + const cropWidth = crop.width * 100; + const cropHeight = crop.height * 100; + const cancelPendingCommit = () => { + if (pendingFrameRef.current !== null) { + cancelAnimationFrame(pendingFrameRef.current); + pendingFrameRef.current = null; + } + }; + const flushPendingCommit = () => { + const nextCrop = pendingCropRef.current; + if (!nextCrop) { + return; + } + cancelPendingCommit(); + pendingCropRef.current = null; + onCropChange(nextCrop, previewFrame); + }; + const syncPreviewMedia = useCallback(() => { + const video = previewVideoRef.current; + if (!video || !previewSrc) { + return; + } + + const webcamDuration = Number.isFinite(video.duration) ? video.duration : null; + const targetTime = getWebcamPreviewTargetTimeSeconds({ + currentTime: previewCurrentTime, + webcamDuration, + timeOffsetMs: previewTimeOffsetMs, + }); + const mediaTargetTime = + targetTime <= 0 && webcamDuration !== null && webcamDuration > 0 + ? Math.min(1 / 60, webcamDuration) + : targetTime; + + if ( + synchronizedPreviewSrcRef.current !== previewSrc && + !isWebcamMediaSynchronized({ + currentTime: video.currentTime, + targetTime: mediaTargetTime, + readyState: video.readyState, + isSeeking: video.seeking, + }) + ) { + video.pause(); + if ( + video.readyState >= HTMLMediaElement.HAVE_METADATA && + !video.seeking && + Math.abs(video.currentTime - mediaTargetTime) > 0.01 + ) { + try { + video.currentTime = mediaTargetTime; + } catch { + /* The next media-ready event retries once the source accepts seeks. */ + } + } + return; + } + + if (synchronizedPreviewSrcRef.current !== previewSrc) { + synchronizedPreviewSrcRef.current = previewSrc; + setSynchronizedPreviewSrc(previewSrc); + } + const driftThreshold = previewPlaying ? 0.35 : 0.01; + + if (Math.abs(video.currentTime - mediaTargetTime) > driftThreshold) { + try { + video.currentTime = mediaTargetTime; + } catch { + /* Ignore browsers that reject seeks while metadata is settling. */ + } + } + + if (previewPlaying) { + const playPromise = video.play(); + if (playPromise) { + playPromise.catch(() => undefined); + } + } else { + video.pause(); + } + }, [previewCurrentTime, previewPlaying, previewSrc, previewTimeOffsetMs]); + + const handlePreviewFrameReady = useCallback(() => { + const video = previewVideoRef.current; + if (video && video.videoWidth > 0 && video.videoHeight > 0 && video.readyState >= 2) { + setPreviewFrame({ + src: previewSrc, + width: video.videoWidth, + height: video.videoHeight, + }); + } + syncPreviewMedia(); + }, [previewSrc, syncPreviewMedia]); + + useEffect(() => { + syncPreviewMedia(); + }, [syncPreviewMedia]); + + // biome-ignore lint/correctness/useExhaustiveDependencies: The source identity intentionally resets cached synchronization state. + useEffect(() => { + synchronizedPreviewSrcRef.current = null; + setSynchronizedPreviewSrc(null); + setPreviewFrame(null); + }, [previewSrc]); + + const commitVisualCrop = (nextVisualCrop: CropRegion, immediate = false) => { + const nextCrop = mirrored + ? flipCropHorizontally(nextVisualCrop) + : normalizeWebcamCropRegion(nextVisualCrop); + if (immediate) { + cancelPendingCommit(); + pendingCropRef.current = null; + onCropChange(nextCrop, previewFrame); + return; + } + + pendingCropRef.current = nextCrop; + if (pendingFrameRef.current !== null) { + return; + } + pendingFrameRef.current = requestAnimationFrame(() => { + pendingFrameRef.current = null; + flushPendingCommit(); + }); + }; + + useEffect(() => { + return () => { + if (pendingFrameRef.current !== null) { + cancelAnimationFrame(pendingFrameRef.current); + } + }; + }, []); + + const getPointerPosition = (event: React.PointerEvent<HTMLDivElement>) => { + const rect = containerRef.current?.getBoundingClientRect(); + if (!rect || rect.width <= 0 || rect.height <= 0) { + return null; + } + + return { + x: clamp((event.clientX - rect.left) / rect.width, 0, 1), + y: clamp((event.clientY - rect.top) / rect.height, 0, 1), + }; + }; + + const handlePointerDown = (event: React.PointerEvent<HTMLDivElement>, handle: CropHandle) => { + const pointer = getPointerPosition(event); + if (!pointer) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + dragStateRef.current = { + handle, + startX: pointer.x, + startY: pointer.y, + initialCrop: crop, + }; + setActiveHandle(handle); + + try { + containerRef.current?.setPointerCapture(event.pointerId); + } catch { + /* Pointer capture can fail if the drag started outside the control. */ + } + }; + + const handlePointerMove = (event: React.PointerEvent<HTMLDivElement>) => { + const dragState = dragStateRef.current; + if (!dragState) { + return; + } + + const pointer = getPointerPosition(event); + if (!pointer) { + return; + } + + const nextVisualCrop = resizeCrop( + dragState.initialCrop, + dragState.handle, + pointer.x - dragState.startX, + pointer.y - dragState.startY, + ); + setDraftVisualCrop(nextVisualCrop); + commitVisualCrop(nextVisualCrop); + }; + + const endDrag = (event: React.PointerEvent<HTMLDivElement>) => { + if (dragStateRef.current) { + try { + containerRef.current?.releasePointerCapture(event.pointerId); + } catch { + /* Pointer capture may already be released while ending the drag. */ + } + } + flushPendingCommit(); + dragStateRef.current = null; + setDraftVisualCrop(null); + setActiveHandle(null); + }; + + const getKeyboardDelta = (event: React.KeyboardEvent<HTMLElement>) => { + const step = event.shiftKey ? KEYBOARD_FAST_STEP : KEYBOARD_STEP; + if (event.key === "ArrowLeft") { + return { x: -step, y: 0 }; + } + if (event.key === "ArrowRight") { + return { x: step, y: 0 }; + } + if (event.key === "ArrowUp") { + return { x: 0, y: -step }; + } + if (event.key === "ArrowDown") { + return { x: 0, y: step }; + } + return null; + }; + + const handleKeyboardAdjust = (event: React.KeyboardEvent<HTMLElement>, handle: CropHandle) => { + const delta = getKeyboardDelta(event); + if (!delta) { + return; + } + event.preventDefault(); + event.stopPropagation(); + commitVisualCrop(resizeCrop(crop, handle, delta.x, delta.y), true); + }; + + return ( + <div + ref={containerRef} + className="relative w-full touch-none select-none overflow-hidden rounded-lg border border-foreground/10 bg-editor-dialog-alt" + style={{ aspectRatio: previewAspectRatio }} + onPointerMove={handlePointerMove} + onPointerUp={endDrag} + onPointerCancel={endDrag} + > + {previewSrc ? ( + <video + ref={previewVideoRef} + src={previewSrc} + className="pointer-events-none absolute inset-0 block h-full w-full object-fill" + style={{ + opacity: hasPreviewFrame && previewSynchronized ? 1 : 0, + transform: mirrored ? "scaleX(-1)" : undefined, + }} + muted + playsInline + preload="auto" + aria-hidden="true" + onLoadedMetadata={handlePreviewFrameReady} + onLoadedData={handlePreviewFrameReady} + onCanPlay={handlePreviewFrameReady} + onSeeked={handlePreviewFrameReady} + /> + ) : null} + <div + className={cn( + "absolute inset-0 bg-editor-dialog-alt transition-opacity", + previewSrc && hasPreviewFrame ? "opacity-0" : "opacity-100", + )} + /> + + <svg className="pointer-events-none absolute inset-0 h-full w-full"> + <defs> + <mask id={maskId}> + <rect width="100%" height="100%" fill="white" /> + <rect + x={`${cropLeft}%`} + y={`${cropTop}%`} + width={`${cropWidth}%`} + height={`${cropHeight}%`} + fill="black" + /> + </mask> + </defs> + <rect + width="100%" + height="100%" + fill="black" + fillOpacity="0.58" + mask={`url(#${maskId})`} + /> + </svg> + + <div + className={cn( + "absolute border border-white shadow-[0_0_0_1px_rgba(37,99,235,0.9),0_8px_24px_rgba(0,0,0,0.25)] focus:outline-none focus:ring-2 focus:ring-[#2563EB]/60 focus:ring-offset-2 focus:ring-offset-editor-dialog", + activeHandle === "move" ? "cursor-grabbing" : "cursor-move", + )} + style={{ + left: `${cropLeft}%`, + top: `${cropTop}%`, + width: `${cropWidth}%`, + height: `${cropHeight}%`, + }} + tabIndex={0} + aria-label={HANDLE_LABELS.move} + onPointerDown={(event) => handlePointerDown(event, "move")} + onFocus={() => setActiveHandle("move")} + onBlur={() => setActiveHandle(null)} + onKeyDown={(event) => handleKeyboardAdjust(event, "move")} + > + <div className="pointer-events-none absolute left-1/3 top-0 h-full w-px bg-white/45" /> + <div className="pointer-events-none absolute left-2/3 top-0 h-full w-px bg-white/45" /> + <div className="pointer-events-none absolute left-0 top-1/3 h-px w-full bg-white/45" /> + <div className="pointer-events-none absolute left-0 top-2/3 h-px w-full bg-white/45" /> + + {RESIZE_HANDLES.map((handle) => ( + <div + key={handle.handle} + role="slider" + tabIndex={0} + aria-label={HANDLE_LABELS[handle.handle]} + aria-valuemin={Math.round(MIN_CROP_SIZE * 100)} + aria-valuemax={100} + aria-valuenow={Math.round(crop.width * 100)} + aria-valuetext={`${Math.round(crop.width * 100)}%`} + className={cn( + "absolute z-10 h-3.5 w-3.5 rounded-[3px] border-2 border-white bg-[#2563EB] shadow-sm focus:outline-none focus:ring-2 focus:ring-[#2563EB]/60 focus:ring-offset-2 focus:ring-offset-editor-dialog", + handle.className, + handle.cursorClassName, + activeHandle === handle.handle && "scale-110", + )} + onPointerDown={(event) => handlePointerDown(event, handle.handle)} + onFocus={() => setActiveHandle(handle.handle)} + onBlur={() => setActiveHandle(null)} + onKeyDown={(event) => handleKeyboardAdjust(event, handle.handle)} + /> + ))} + </div> + </div> + ); +} diff --git a/src/components/video-editor/audio.test.ts b/src/components/video-editor/audio.test.ts new file mode 100644 index 000000000..86ec67aa5 --- /dev/null +++ b/src/components/video-editor/audio.test.ts @@ -0,0 +1,316 @@ +import * as fc from "fast-check"; +import { describe, expect, it } from "vitest"; +import { CURSOR_MOTION_PRESETS } from "./cursorMotionPresets"; +import { DEFAULT_CURSOR_MOTION_BLUR } from "./types"; +import { + fromFileUrl, + normalizeProjectEditor, + stripPersistedDevMotionBlurSettings, + toFileUrl, +} from "./projectPersistence"; + +describe("Audio path handling", () => { + describe("toFileUrl produces valid file:// URLs for audio paths", () => { + it("should handle Unix absolute paths", () => { + expect(toFileUrl("/Users/music/song.mp3")).toBe("file:///Users/music/song.mp3"); + }); + + it("should handle Windows drive paths", () => { + expect(toFileUrl("C:/Users/music/song.mp3")).toBe("file:///C:/Users/music/song.mp3"); + }); + + it("should handle backslash Windows paths", () => { + const result = toFileUrl("C:\\Users\\music\\song.mp3"); + expect(result).toMatch(/^file:\/\//); + expect(result).toContain("C:"); + expect(result).toContain("song.mp3"); + }); + + it("should encode spaces in path segments", () => { + const result = toFileUrl("/Users/my music/my song.mp3"); + expect(result).toContain("my%20music"); + expect(result).toContain("my%20song.mp3"); + }); + + it("should encode special characters like spaces", () => { + const result = toFileUrl("/Users/music/song file.mp3"); + expect(result).toContain("song%20file.mp3"); + // Result should be a valid file:// URL + expect(result).toMatch(/^file:\/\//); + }); + + it("should roundtrip through fromFileUrl for simple paths", () => { + const paths = [ + "/Users/music/song.mp3", + "/tmp/audio.wav", + "/home/user/my-file.aac", + "/data/recordings/track_01.flac", + ]; + for (const originalPath of paths) { + const fileUrl = toFileUrl(originalPath); + const recovered = fromFileUrl(fileUrl); + expect(recovered).toBe(originalPath); + } + }); + + it("should roundtrip paths with spaces through fromFileUrl", () => { + const originalPath = "/Users/my user/my music/song file.mp3"; + const fileUrl = toFileUrl(originalPath); + const recovered = fromFileUrl(fileUrl); + expect(recovered).toBe(originalPath); + }); + }); +}); + +describe("Audio region normalization", () => { + describe("volume is clamped to [0, 1]", () => { + it("should clamp volume > 1 down to 1", () => { + const result = normalizeProjectEditor({ + audioRegions: [ + { id: "audio-1", startMs: 0, endMs: 1000, audioPath: "/test.mp3", volume: 5 }, + ], + } as any); + expect(result.audioRegions[0].volume).toBe(1); + }); + + it("should clamp negative volume to 0", () => { + const result = normalizeProjectEditor({ + audioRegions: [ + { + id: "audio-1", + startMs: 0, + endMs: 1000, + audioPath: "/test.mp3", + volume: -0.5, + }, + ], + } as any); + expect(result.audioRegions[0].volume).toBe(0); + }); + + it("should preserve valid volume values in [0, 1]", () => { + fc.assert( + fc.property(fc.double({ min: 0, max: 1, noNaN: true }), (volume) => { + const result = normalizeProjectEditor({ + audioRegions: [ + { + id: "audio-1", + startMs: 0, + endMs: 1000, + audioPath: "/test.mp3", + volume, + }, + ], + } as any); + expect(result.audioRegions[0].volume).toBeCloseTo(volume, 10); + }), + ); + }); + + it("should default to 1 when volume is NaN", () => { + const result = normalizeProjectEditor({ + audioRegions: [ + { id: "audio-1", startMs: 0, endMs: 1000, audioPath: "/test.mp3", volume: NaN }, + ], + } as any); + expect(result.audioRegions[0].volume).toBe(1); + }); + + it("should default to 1 when volume is undefined", () => { + const result = normalizeProjectEditor({ + audioRegions: [{ id: "audio-1", startMs: 0, endMs: 1000, audioPath: "/test.mp3" }], + } as any); + expect(result.audioRegions[0].volume).toBe(1); + }); + }); + + describe("startMs and endMs boundaries", () => { + it("should clamp negative startMs to 0", () => { + const result = normalizeProjectEditor({ + audioRegions: [ + { + id: "audio-1", + startMs: -500, + endMs: 1000, + audioPath: "/test.mp3", + volume: 1, + }, + ], + } as any); + expect(result.audioRegions[0].startMs).toBe(0); + }); + + it("should ensure endMs > startMs when endMs < startMs", () => { + const result = normalizeProjectEditor({ + audioRegions: [ + { id: "audio-1", startMs: 1000, endMs: 500, audioPath: "/test.mp3", volume: 1 }, + ], + } as any); + expect(result.audioRegions[0].endMs).toBeGreaterThan(result.audioRegions[0].startMs); + }); + + it("should handle equal startMs and endMs by ensuring minimum gap", () => { + const result = normalizeProjectEditor({ + audioRegions: [ + { + id: "audio-1", + startMs: 1000, + endMs: 1000, + audioPath: "/test.mp3", + volume: 1, + }, + ], + } as any); + expect(result.audioRegions[0].endMs).toBeGreaterThan(result.audioRegions[0].startMs); + }); + + it("should preserve valid startMs/endMs for arbitrary non-negative values", () => { + fc.assert( + fc.property( + fc.nat({ max: 100000 }), + fc.integer({ min: 1, max: 100000 }), + (startMs, duration) => { + const endMs = startMs + duration; + const result = normalizeProjectEditor({ + audioRegions: [ + { + id: "audio-1", + startMs, + endMs, + audioPath: "/test.mp3", + volume: 0.5, + }, + ], + } as any); + expect(result.audioRegions[0].startMs).toBe(startMs); + expect(result.audioRegions[0].endMs).toBe(endMs); + }, + ), + ); + }); + }); + + describe("audioPath normalization", () => { + it("should preserve a valid string path", () => { + const result = normalizeProjectEditor({ + audioRegions: [ + { + id: "audio-1", + startMs: 0, + endMs: 1000, + audioPath: "/Users/music/song.mp3", + volume: 1, + }, + ], + } as any); + expect(result.audioRegions[0].audioPath).toBe("/Users/music/song.mp3"); + }); + + it("should default to empty string for missing audioPath", () => { + const result = normalizeProjectEditor({ + audioRegions: [{ id: "audio-1", startMs: 0, endMs: 1000, volume: 1 }], + } as any); + expect(result.audioRegions[0].audioPath).toBe(""); + }); + + it("should filter out regions without a valid id", () => { + const result = normalizeProjectEditor({ + audioRegions: [ + { startMs: 0, endMs: 1000, audioPath: "/test.mp3", volume: 1 }, + { id: "audio-1", startMs: 0, endMs: 1000, audioPath: "/test.mp3", volume: 1 }, + ], + } as any); + expect(result.audioRegions).toHaveLength(1); + expect(result.audioRegions[0].id).toBe("audio-1"); + }); + }); + + describe("empty or missing audioRegions", () => { + it("should return empty array when audioRegions is undefined", () => { + const result = normalizeProjectEditor({} as any); + expect(result.audioRegions).toEqual([]); + }); + + it("should return empty array when audioRegions is not an array", () => { + const result = normalizeProjectEditor({ audioRegions: "invalid" } as any); + expect(result.audioRegions).toEqual([]); + }); + + it("should return empty array when audioRegions is null", () => { + const result = normalizeProjectEditor({ audioRegions: null } as any); + expect(result.audioRegions).toEqual([]); + }); + }); +}); + +describe("Motion preset normalization", () => { + it("preserves the smooth preset when the saved values match it exactly", () => { + const smooth = CURSOR_MOTION_PRESETS.smooth; + const result = normalizeProjectEditor({ + zoomInDurationMs: smooth.zoomInDurationMs, + zoomOutDurationMs: smooth.zoomOutDurationMs, + cursorSize: smooth.cursorSize, + cursorSmoothing: smooth.cursorSmoothing, + cursorSpringStiffnessMultiplier: smooth.cursorSpringStiffnessMultiplier, + cursorSpringDampingMultiplier: smooth.cursorSpringDampingMultiplier, + cursorSpringMassMultiplier: smooth.cursorSpringMassMultiplier, + cursorClickBounce: smooth.cursorClickBounce, + cursorClickBounceDuration: smooth.cursorClickBounceDuration, + } as any); + + expect(result.zoomInDurationMs).toBe(smooth.zoomInDurationMs); + expect(result.zoomOutDurationMs).toBe(smooth.zoomOutDurationMs); + expect(result.cursorSpringStiffnessMultiplier).toBe(smooth.cursorSpringStiffnessMultiplier); + expect(result.cursorSpringDampingMultiplier).toBe(smooth.cursorSpringDampingMultiplier); + }); + + it("falls back to focused when the saved values do not match a supported preset", () => { + const focused = CURSOR_MOTION_PRESETS.focused; + const result = normalizeProjectEditor({ + zoomInDurationMs: 275, + zoomOutDurationMs: 410, + cursorSize: 3.25, + cursorSmoothing: 0.52, + cursorSpringStiffnessMultiplier: 1.1, + cursorSpringDampingMultiplier: 1.05, + cursorSpringMassMultiplier: 1.6, + cursorMotionBlur: 0.9, + cursorClickBounce: 2.25, + cursorClickBounceDuration: 280, + } as any); + + expect(result.zoomInDurationMs).toBe(focused.zoomInDurationMs); + expect(result.zoomOutDurationMs).toBe(focused.zoomOutDurationMs); + expect(result.cursorSize).toBe(focused.cursorSize); + expect(result.cursorSmoothing).toBe(focused.cursorSmoothing); + expect(result.cursorSpringStiffnessMultiplier).toBe( + focused.cursorSpringStiffnessMultiplier, + ); + expect(result.cursorSpringDampingMultiplier).toBe(focused.cursorSpringDampingMultiplier); + expect(result.cursorSpringMassMultiplier).toBe(focused.cursorSpringMassMultiplier); + expect(result.cursorMotionBlur).toBe(DEFAULT_CURSOR_MOTION_BLUR); + expect(result.cursorClickBounce).toBe(focused.cursorClickBounce); + expect(result.cursorClickBounceDuration).toBe(focused.cursorClickBounceDuration); + }); +}); + +describe("Dev-only motion blur persistence", () => { + it("strips legacy dev-only blur tuning from project data before normalization", () => { + const defaults = normalizeProjectEditor({} as any); + const stripped = stripPersistedDevMotionBlurSettings({ + zoomMotionBlurTuning: { + ...defaults.zoomMotionBlurTuning, + maxDirectionalBlurPx: 96, + maxRadialBlurStrength: 1.5, + }, + wallpaper: "/wallpapers/wallpaper2.jpg", + } as any); + + expect(stripped).not.toHaveProperty("zoomMotionBlurTuning"); + + const result = normalizeProjectEditor(stripped as any); + + expect(result.wallpaper).toBe("/wallpapers/wallpaper2.jpg"); + expect(result.zoomMotionBlurTuning).toEqual(defaults.zoomMotionBlurTuning); + }); +}); diff --git a/src/components/video-editor/audio/audioResourceVersion.test.ts b/src/components/video-editor/audio/audioResourceVersion.test.ts new file mode 100644 index 000000000..b2b73b729 --- /dev/null +++ b/src/components/video-editor/audio/audioResourceVersion.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; + +import { + getAudioResourceCacheScope, + getAudioResourceVersionKey, + getVersionedAudioResourceUrl, + isAudioResourceLoadCurrent, +} from "./audioResourceVersion"; + +describe("getAudioResourceVersionKey", () => { + it("changes identity when a finalized companion replaces a partial file at the same path", () => { + const audioPath = "C:\\Recordly\\recording.mic.wav"; + + expect(getAudioResourceVersionKey(audioPath, 0)).not.toBe( + getAudioResourceVersionKey(audioPath, 1), + ); + }); + + it("is stable for rerenders within the same finalization version", () => { + const audioPath = "C:\\Recordly\\recording.mic.wav"; + + expect(getAudioResourceVersionKey(audioPath, 3)).toBe( + getAudioResourceVersionKey(audioPath, 3), + ); + }); + + it("keeps an in-flight load valid across rerenders and rejects an older version", () => { + const audioPath = "C:\\Recordly\\recording.mic.wav"; + const versionOne = getAudioResourceVersionKey(audioPath, 1); + const versionTwo = getAudioResourceVersionKey(audioPath, 2); + const resources = new Map([[audioPath, versionOne]]); + + expect(isAudioResourceLoadCurrent(resources, audioPath, versionOne)).toBe(true); + resources.set(audioPath, versionTwo); + expect(isAudioResourceLoadCurrent(resources, audioPath, versionOne)).toBe(false); + }); + + it("cache-busts only the trusted loopback media URL after finalization", () => { + const localUrl = "http://127.0.0.1:43123/video?path=C%3A%5CRecordly%5Crecording.mic.wav"; + const versionedUrl = new URL(getVersionedAudioResourceUrl(localUrl, 2)); + + expect(versionedUrl.searchParams.get("path")).toBe("C:\\Recordly\\recording.mic.wav"); + expect(versionedUrl.searchParams.get("recordlyAudioVersion")).toBe("2"); + expect(getVersionedAudioResourceUrl("https://cdn.example/audio.wav?sig=abc", 2)).toBe( + "https://cdn.example/audio.wav?sig=abc", + ); + }); + + it("uses one cache scope for every version of a loopback resource", () => { + const baseUrl = "http://127.0.0.1:43123/video?path=C%3A%5CRecordly%5Crecording.mic.wav"; + const versionedUrl = `${baseUrl}&recordlyAudioVersion=4`; + + expect(getAudioResourceCacheScope(versionedUrl)).toBe(baseUrl); + expect( + getAudioResourceCacheScope( + "https://cdn.example/audio.wav?recordlyAudioVersion=4&sig=abc", + ), + ).toBe("https://cdn.example/audio.wav?recordlyAudioVersion=4&sig=abc"); + }); +}); diff --git a/src/components/video-editor/audio/audioResourceVersion.ts b/src/components/video-editor/audio/audioResourceVersion.ts new file mode 100644 index 000000000..9a6082333 --- /dev/null +++ b/src/components/video-editor/audio/audioResourceVersion.ts @@ -0,0 +1,50 @@ +export function getAudioResourceVersionKey(resource: string, version = 0): string { + const safeVersion = Number.isFinite(version) ? Math.max(0, Math.trunc(version)) : 0; + return `${resource}::recordly-audio-v${safeVersion}`; +} + +function parseLoopbackMediaServerUrl(resourceUrl: string): URL | null { + try { + const url = new URL(resourceUrl); + const isLoopbackMediaServer = + (url.protocol === "http:" || url.protocol === "https:") && + (url.hostname === "127.0.0.1" || url.hostname === "localhost") && + url.pathname === "/video"; + return isLoopbackMediaServer ? url : null; + } catch { + return null; + } +} + +export function getAudioResourceCacheScope(resourceUrl: string): string { + const url = parseLoopbackMediaServerUrl(resourceUrl); + if (!url) { + return resourceUrl; + } + + url.searchParams.delete("recordlyAudioVersion"); + return url.href; +} + +export function getVersionedAudioResourceUrl(resourceUrl: string, version = 0): string { + const safeVersion = Number.isFinite(version) ? Math.max(0, Math.trunc(version)) : 0; + if (safeVersion === 0) { + return resourceUrl; + } + + const url = parseLoopbackMediaServerUrl(resourceUrl); + if (!url) { + return resourceUrl; + } + + url.searchParams.set("recordlyAudioVersion", String(safeVersion)); + return url.href; +} + +export function isAudioResourceLoadCurrent( + resources: ReadonlyMap<string, string>, + resource: string, + expectedKey: string, +): boolean { + return resources.get(resource) === expectedKey; +} diff --git a/src/components/video-editor/audio/audioTypes.ts b/src/components/video-editor/audio/audioTypes.ts new file mode 100644 index 000000000..2eb7ef246 --- /dev/null +++ b/src/components/video-editor/audio/audioTypes.ts @@ -0,0 +1,24 @@ +import type { AudioPeaksData } from "../timeline/core/timelineTypes"; + +export type SourceAudioTrackId = "mixed" | "system" | "mic" | (string & {}); + +export interface SourceAudioTrackSetting { + volume: number; + normalize: boolean; +} + +export type SourceAudioTrackSettings = Record<string, SourceAudioTrackSetting>; + +export interface SourceAudioTrackMetaItem { + id: SourceAudioTrackId; + label: string; +} + +export type SourceAudioTrackMeta = SourceAudioTrackMetaItem[]; + +export interface SourceAudioTrackWithPeaks extends SourceAudioTrackMetaItem { + peaks: AudioPeaksData; +} + +export const SOURCE_AUDIO_FALLBACK_TOAST_ID = "source-audio-fallback-error"; +export const SOURCE_AUDIO_NORMALIZE_GAIN = 1.35; diff --git a/src/components/video-editor/audio/clipAudio.ts b/src/components/video-editor/audio/clipAudio.ts new file mode 100644 index 000000000..ec780ebc3 --- /dev/null +++ b/src/components/video-editor/audio/clipAudio.ts @@ -0,0 +1,18 @@ +import { getClipSourceEndMs, sortClipRegions } from "../types"; +import type { ClipRegion } from "../types"; + +export function getActiveClipIdAtSourceTime( + sourceTimeSeconds: number, + clipRegions: ClipRegion[], +): string | null { + const sourceMs = Math.round(sourceTimeSeconds * 1000); + const activeClip = sortClipRegions(clipRegions).find( + (clip) => sourceMs >= clip.startMs && sourceMs < getClipSourceEndMs(clip), + ); + return activeClip?.id ?? null; +} + +export function isClipMutedById(clipId: string | null, clipRegions: ClipRegion[]): boolean { + if (!clipId) return false; + return clipRegions.find((clip) => clip.id === clipId)?.muted ?? false; +} diff --git a/src/components/video-editor/audio/useAudioPreviewSync.ts b/src/components/video-editor/audio/useAudioPreviewSync.ts new file mode 100644 index 000000000..e7a5c8a5a --- /dev/null +++ b/src/components/video-editor/audio/useAudioPreviewSync.ts @@ -0,0 +1,496 @@ +import { useCallback, useEffect, useMemo, useRef } from "react"; +import { buildResolvedAudioPlan } from "@/lib/exporter/audioRoutingEngine"; +import { resolveMediaElementSource } from "@/lib/exporter/localMediaSource"; +import { + clampMediaTimeToDuration, + enablePitchPreservingPlayback, + estimateCompanionAudioStartDelaySeconds, + getMediaSyncPlaybackRate, + resolvePreviewMediaDuration, +} from "@/lib/mediaTiming"; +import type { AudioRegion, SpeedRegion } from "../types"; +import { + getAudioResourceVersionKey, + getVersionedAudioResourceUrl, + isAudioResourceLoadCurrent, +} from "./audioResourceVersion"; + +const SOURCE_AUDIO_PREVIEW_PLAYING_SEEK_DRIFT_SECONDS = 0.18; +const SOURCE_AUDIO_PREVIEW_PAUSED_SEEK_DRIFT_SECONDS = 0.01; + +interface UseAudioPreviewSyncParams { + audioRegions: AudioRegion[]; + previewVolume: number; + isPlaying: boolean; + currentTime: number; + timelineTime: number; + duration: number; + effectiveSpeedRegions: SpeedRegion[]; + previewSourceAudioFallbackPaths: string[]; + sourceAudioFallbackStartDelayMsByPath: Record<string, number>; + sourceAudioResourceVersion: number; + isCurrentClipMuted: boolean; + getSourceTrackPreviewGain: (audioPath: string) => number; + onSourceFallbackLoadError: (error: unknown) => void; +} + +export function useAudioPreviewSync({ + audioRegions, + previewVolume, + isPlaying, + currentTime, + timelineTime, + duration, + effectiveSpeedRegions, + previewSourceAudioFallbackPaths, + sourceAudioFallbackStartDelayMsByPath, + sourceAudioResourceVersion, + isCurrentClipMuted, + getSourceTrackPreviewGain, + onSourceFallbackLoadError, +}: UseAudioPreviewSyncParams) { + const resolvedPlan = useMemo( + () => + buildResolvedAudioPlan({ + videoResource: null, + sourceAudioFallbackPaths: previewSourceAudioFallbackPaths, + audioRegions, + }), + [audioRegions, previewSourceAudioFallbackPaths], + ); + const resolvedUserTracks = useMemo( + () => resolvedPlan.tracks.filter((track) => track.kind === "user"), + [resolvedPlan], + ); + const resolvedSourceTracks = useMemo( + () => resolvedPlan.tracks.filter((track) => track.kind !== "user"), + [resolvedPlan], + ); + + const audioElementsRef = useRef<Map<string, HTMLAudioElement>>(new Map()); + const audioElementRevokersRef = useRef<Map<string, () => void>>(new Map()); + const audioElementResourcesRef = useRef<Map<string, string>>(new Map()); + const sourceAudioElementsRef = useRef<Map<string, HTMLAudioElement>>(new Map()); + const sourceAudioMediaNodesRef = useRef<Map<string, MediaElementAudioSourceNode>>(new Map()); + const sourceAudioGainNodesRef = useRef<Map<string, GainNode>>(new Map()); + const sourceAudioElementRevokersRef = useRef<Map<string, () => void>>(new Map()); + const sourceAudioElementResourcesRef = useRef<Map<string, string>>(new Map()); + const sourceAudioContextRef = useRef<AudioContext | null>(null); + const sourceAudioMasterGainRef = useRef<GainNode | null>(null); + const sourceAudioResumePromiseRef = useRef<Promise<void> | null>(null); + const lastSourceAudioSyncTimeRef = useRef<number | null>(null); + + const ensureSourceAudioContext = useCallback(() => { + if (!sourceAudioContextRef.current) { + const context = new AudioContext({ latencyHint: "interactive" }); + const masterGain = context.createGain(); + masterGain.gain.value = 1; + masterGain.connect(context.destination); + sourceAudioContextRef.current = context; + sourceAudioMasterGainRef.current = masterGain; + } + return sourceAudioContextRef.current; + }, []); + + const ensureSourceAudioRunning = useCallback(() => { + const context = ensureSourceAudioContext(); + if (context.state === "running") { + return Promise.resolve(); + } + if (!sourceAudioResumePromiseRef.current) { + sourceAudioResumePromiseRef.current = context + .resume() + .catch(() => undefined) + .finally(() => { + sourceAudioResumePromiseRef.current = null; + }); + } + return sourceAudioResumePromiseRef.current; + }, [ensureSourceAudioContext]); + + const playSourceAudioPreview = useCallback(() => { + void ensureSourceAudioRunning(); + for (const audio of sourceAudioElementsRef.current.values()) { + if (!audio.src) continue; + audio.play().catch(() => undefined); + } + }, [ensureSourceAudioRunning]); + + useEffect(() => { + let cancelled = false; + const existing = audioElementsRef.current; + const currentIds = new Set(resolvedUserTracks.map((track) => track.id)); + + for (const [id, audio] of existing) { + if (!currentIds.has(id)) { + audio.pause(); + audio.src = ""; + audioElementRevokersRef.current.get(id)?.(); + audioElementRevokersRef.current.delete(id); + audioElementResourcesRef.current.delete(id); + existing.delete(id); + } + } + + for (const track of resolvedUserTracks) { + let audio = existing.get(track.id); + if (!audio) { + audio = new Audio(); + audio.preload = "auto"; + existing.set(track.id, audio); + } + + if (audioElementResourcesRef.current.get(track.id) !== track.sourceRef.path) { + audio.pause(); + audio.src = ""; + audioElementRevokersRef.current.get(track.id)?.(); + audioElementRevokersRef.current.delete(track.id); + audioElementResourcesRef.current.set(track.id, track.sourceRef.path); + + void (async () => { + const resolved = await resolveMediaElementSource(track.sourceRef.path); + const latestAudio = existing.get(track.id); + + if ( + cancelled || + latestAudio !== audio || + audioElementResourcesRef.current.get(track.id) !== track.sourceRef.path + ) { + resolved.revoke(); + return; + } + + audioElementRevokersRef.current.set(track.id, resolved.revoke); + latestAudio.src = resolved.src; + })(); + } + + audio.volume = Math.max(0, Math.min(1, track.gain * previewVolume)); + } + + return () => { + cancelled = true; + }; + }, [previewVolume, resolvedUserTracks]); + + useEffect(() => { + const existing = sourceAudioElementsRef.current; + const currentIds = new Set(resolvedSourceTracks.map((track) => track.sourceRef.path)); + + for (const [id, audio] of existing) { + if (!currentIds.has(id)) { + audio.pause(); + audio.src = ""; + sourceAudioMediaNodesRef.current.get(id)?.disconnect(); + sourceAudioMediaNodesRef.current.delete(id); + sourceAudioGainNodesRef.current.get(id)?.disconnect(); + sourceAudioGainNodesRef.current.delete(id); + sourceAudioElementRevokersRef.current.get(id)?.(); + sourceAudioElementRevokersRef.current.delete(id); + sourceAudioElementResourcesRef.current.delete(id); + existing.delete(id); + } + } + + for (const track of resolvedSourceTracks) { + const audioPath = track.sourceRef.path; + const resourceKey = getAudioResourceVersionKey(audioPath, sourceAudioResourceVersion); + let audio = existing.get(audioPath); + if (!audio) { + audio = new Audio(); + audio.preload = "auto"; + audio.crossOrigin = "anonymous"; + existing.set(audioPath, audio); + } + audio.volume = 1; + audio.dataset.sourceAudioPath = audioPath; + + // Web Audio API createMediaElementSource breaks preservesPitch on Chromium. + // We route directly through the HTMLAudioElement to ensure pitch preservation works + // during speed changes. Note: this limits maximum preview volume to 1.0 (100%). + + if (sourceAudioElementResourcesRef.current.get(audioPath) !== resourceKey) { + audio.pause(); + audio.src = ""; + sourceAudioElementRevokersRef.current.get(audioPath)?.(); + sourceAudioElementRevokersRef.current.delete(audioPath); + sourceAudioElementResourcesRef.current.set(audioPath, resourceKey); + + // Resource identity, not effect lifetime, decides staleness. Playback and fallback + // rerenders must not cancel the only load for the current finalized sidecar version. + void (async () => { + try { + const resolved = await resolveMediaElementSource(audioPath); + const latestAudio = existing.get(audioPath); + + if ( + latestAudio !== audio || + !isAudioResourceLoadCurrent( + sourceAudioElementResourcesRef.current, + audioPath, + resourceKey, + ) + ) { + resolved.revoke(); + return; + } + + sourceAudioElementRevokersRef.current.set(audioPath, resolved.revoke); + latestAudio.src = getVersionedAudioResourceUrl( + resolved.src, + sourceAudioResourceVersion, + ); + latestAudio.load(); + if (isPlaying) { + playSourceAudioPreview(); + } + } catch (error) { + const latestAudio = existing.get(audioPath); + if ( + latestAudio !== audio || + !isAudioResourceLoadCurrent( + sourceAudioElementResourcesRef.current, + audioPath, + resourceKey, + ) + ) { + return; + } + + sourceAudioElementRevokersRef.current.get(audioPath)?.(); + sourceAudioElementRevokersRef.current.delete(audioPath); + sourceAudioElementResourcesRef.current.delete(audioPath); + if (latestAudio === audio) { + latestAudio.pause(); + latestAudio.src = ""; + } + onSourceFallbackLoadError(error); + } + })(); + } + + audio.volume = Math.max( + 0, + Math.min( + 1, + getSourceTrackPreviewGain(audioPath) * (isCurrentClipMuted ? 0 : previewVolume), + ), + ); + } + + if (sourceAudioMasterGainRef.current) { + sourceAudioMasterGainRef.current.gain.value = isCurrentClipMuted + ? 0 + : Math.max(0, Math.min(1, previewVolume)); + } + + if (resolvedSourceTracks.length === 0) { + lastSourceAudioSyncTimeRef.current = null; + } + }, [ + getSourceTrackPreviewGain, + isPlaying, + isCurrentClipMuted, + onSourceFallbackLoadError, + resolvedSourceTracks, + sourceAudioResourceVersion, + previewVolume, + playSourceAudioPreview, + ]); + + useEffect(() => { + return () => { + for (const audio of audioElementsRef.current.values()) { + audio.pause(); + audio.src = ""; + } + for (const revoke of audioElementRevokersRef.current.values()) { + revoke(); + } + audioElementsRef.current.clear(); + audioElementRevokersRef.current.clear(); + audioElementResourcesRef.current.clear(); + for (const audio of sourceAudioElementsRef.current.values()) { + audio.pause(); + audio.src = ""; + } + for (const node of sourceAudioMediaNodesRef.current.values()) { + node.disconnect(); + } + for (const node of sourceAudioGainNodesRef.current.values()) { + node.disconnect(); + } + for (const revoke of sourceAudioElementRevokersRef.current.values()) { + revoke(); + } + sourceAudioElementsRef.current.clear(); + sourceAudioMediaNodesRef.current.clear(); + sourceAudioGainNodesRef.current.clear(); + sourceAudioElementRevokersRef.current.clear(); + sourceAudioElementResourcesRef.current.clear(); + if (sourceAudioMasterGainRef.current) { + sourceAudioMasterGainRef.current.disconnect(); + sourceAudioMasterGainRef.current = null; + } + const context = sourceAudioContextRef.current; + sourceAudioContextRef.current = null; + sourceAudioResumePromiseRef.current = null; + if (context) { + void context.close(); + } + lastSourceAudioSyncTimeRef.current = null; + }; + }, []); + + useEffect(() => { + const currentTimeMs = timelineTime * 1000; + const activeSpeedRegion = effectiveSpeedRegions.find( + (region) => currentTimeMs >= region.startMs && currentTimeMs < region.endMs, + ); + const targetPlaybackRate = activeSpeedRegion ? activeSpeedRegion.speed : 1; + + for (const track of resolvedUserTracks) { + const audio = audioElementsRef.current.get(track.id); + if (!audio) continue; + + const startMs = track.timelineBinding.startMs; + const endMs = track.timelineBinding.endMs; + const isInRegion = currentTimeMs >= startMs && currentTimeMs < endMs; + + if (isPlaying && isInRegion) { + enablePitchPreservingPlayback(audio); + const audioOffset = (currentTimeMs - startMs) / 1000; + if (Math.abs(audio.currentTime - audioOffset) > 0.2) { + audio.currentTime = audioOffset; + } + const syncedPlaybackRate = getMediaSyncPlaybackRate({ + basePlaybackRate: targetPlaybackRate, + currentTime: audio.currentTime, + targetTime: audioOffset, + }); + if (Math.abs(audio.playbackRate - syncedPlaybackRate) > 0.001) { + audio.playbackRate = syncedPlaybackRate; + } + if (audio.paused) { + audio.play().catch(() => undefined); + } + } else if (!audio.paused) { + audio.pause(); + } + } + }, [effectiveSpeedRegions, isPlaying, resolvedUserTracks, timelineTime]); + + useEffect(() => { + if (resolvedSourceTracks.length === 0) { + lastSourceAudioSyncTimeRef.current = null; + return; + } + + const activeSpeedRegion = effectiveSpeedRegions.find( + (region) => currentTime * 1000 >= region.startMs && currentTime * 1000 < region.endMs, + ); + const targetPlaybackRate = activeSpeedRegion ? activeSpeedRegion.speed : 1; + const previousTimelineTime = lastSourceAudioSyncTimeRef.current; + const timelineJumped = + previousTimelineTime === null || Math.abs(currentTime - previousTimelineTime) > 0.25; + const driftThreshold = isPlaying + ? SOURCE_AUDIO_PREVIEW_PLAYING_SEEK_DRIFT_SECONDS + : SOURCE_AUDIO_PREVIEW_PAUSED_SEEK_DRIFT_SECONDS; + if (sourceAudioMasterGainRef.current) { + sourceAudioMasterGainRef.current.gain.value = isCurrentClipMuted + ? 0 + : Math.max(0, Math.min(1, previewVolume)); + } + + for (const audio of sourceAudioElementsRef.current.values()) { + const sourceAudioPath = audio.dataset.sourceAudioPath ?? ""; + audio.volume = Math.max( + 0, + Math.min( + 1, + getSourceTrackPreviewGain(sourceAudioPath) * + (isCurrentClipMuted ? 0 : previewVolume), + ), + ); + + enablePitchPreservingPlayback(audio); + const audioDuration = resolvePreviewMediaDuration(audio.duration, duration); + const isMicCompanionTrack = /\.mic\./i.test(sourceAudioPath); + const rawStartDelaySeconds = estimateCompanionAudioStartDelaySeconds( + duration, + audioDuration, + sourceAudioFallbackStartDelayMsByPath[sourceAudioPath], + ); + const maxPreviewStartDelaySeconds = isMicCompanionTrack ? 2 : 5; + const startDelaySeconds = isMicCompanionTrack + ? 0 + : Number.isFinite(duration) && + (rawStartDelaySeconds >= Math.max(0, duration - 0.01) || + rawStartDelaySeconds > + Math.max(maxPreviewStartDelaySeconds, duration * 0.9)) + ? 0 + : rawStartDelaySeconds; + const beforeAudioStart = currentTime + 0.001 < startDelaySeconds; + const targetTime = clampMediaTimeToDuration( + currentTime - startDelaySeconds, + audioDuration, + ); + + const shouldSeek = + timelineJumped || + (!isPlaying && Math.abs(audio.currentTime - targetTime) > driftThreshold) || + (isPlaying && Math.abs(audio.currentTime - targetTime) > 0.9); + if (shouldSeek) { + try { + audio.currentTime = targetTime; + } catch { + // no-op + } + } + + // KISS for companion source tracks: fixed playback rate avoids audible flutter/stutter + // from continuous micro-corrections on system audio. + const syncedPlaybackRate = targetPlaybackRate; + if (Math.abs(audio.playbackRate - syncedPlaybackRate) > 0.001) { + audio.playbackRate = syncedPlaybackRate; + } + + const atEnd = audioDuration !== null && targetTime >= audioDuration; + if (isPlaying && !beforeAudioStart && !atEnd) { + void ensureSourceAudioRunning().then(() => { + audio.play().catch(() => undefined); + }); + } else if (!audio.paused) { + audio.pause(); + } + } + + lastSourceAudioSyncTimeRef.current = currentTime; + }, [ + currentTime, + duration, + effectiveSpeedRegions, + getSourceTrackPreviewGain, + isCurrentClipMuted, + isPlaying, + previewVolume, + resolvedSourceTracks, + sourceAudioFallbackStartDelayMsByPath, + ensureSourceAudioRunning, + ]); + + useEffect(() => { + if (!isPlaying || resolvedSourceTracks.length === 0) { + return; + } + void ensureSourceAudioRunning().then(() => { + for (const audio of sourceAudioElementsRef.current.values()) { + if (audio.paused) { + audio.play().catch(() => undefined); + } + } + }); + }, [isPlaying, resolvedSourceTracks.length, ensureSourceAudioRunning]); + + return { playSourceAudioPreview }; +} diff --git a/src/components/video-editor/audio/useClipAudioSettingsController.ts b/src/components/video-editor/audio/useClipAudioSettingsController.ts new file mode 100644 index 000000000..924d8652f --- /dev/null +++ b/src/components/video-editor/audio/useClipAudioSettingsController.ts @@ -0,0 +1,93 @@ +import React, { useCallback, useMemo } from "react"; +import { + SOURCE_AUDIO_NORMALIZE_GAIN, + type SourceAudioTrackSettings, +} from "@/components/video-editor/audio/audioTypes"; +import { useSourceAudioTrackSettings } from "./useSourceAudioTrackSettings"; +import { getSourceTrackIdFromPath } from "@/lib/exporter/audioRoutingEngine"; + +interface UseClipAudioSettingsControllerParams { + selectedClipId: string | null; + activeClipId: string | null; + sourceAudioTrackSettingsByClip: Record<string, SourceAudioTrackSettings>; + setSourceAudioTrackSettingsByClip: React.Dispatch< + React.SetStateAction<Record<string, SourceAudioTrackSettings>> + >; + defaultSourceAudioTrackSettings: SourceAudioTrackSettings; + setDefaultSourceAudioTrackSettings: React.Dispatch< + React.SetStateAction<SourceAudioTrackSettings> + >; +} + +export function useClipAudioSettingsController({ + selectedClipId, + activeClipId, + sourceAudioTrackSettingsByClip, + setSourceAudioTrackSettingsByClip, + defaultSourceAudioTrackSettings, + setDefaultSourceAudioTrackSettings, +}: UseClipAudioSettingsControllerParams) { + const { + sourceAudioTrackMeta, + activeSourceAudioTrackSettings, + selectedClipSourceAudioTrackSettings, + getSourceAudioTrackSettingsForClip, + onSourceAudioTracksMetaChange, + onSelectedClipSourceAudioTrackVolumeChange, + onSelectedClipSourceAudioTrackNormalizeChange, + } = useSourceAudioTrackSettings({ + selectedClipId, + activeClipId, + sourceAudioTrackSettingsByClip, + setSourceAudioTrackSettingsByClip, + defaultSourceAudioTrackSettings, + setDefaultSourceAudioTrackSettings, + }); + + const previewSourceAudioTrackSettings = useMemo( + () => + activeClipId ? activeSourceAudioTrackSettings : selectedClipSourceAudioTrackSettings, + [activeClipId, activeSourceAudioTrackSettings, selectedClipSourceAudioTrackSettings], + ); + + const embeddedTrackId = useMemo<"mixed" | "system">(() => { + const hasMixedTrack = sourceAudioTrackMeta.some((track) => track.id === "mixed"); + if (hasMixedTrack) return "mixed"; + const hasSystemTrack = sourceAudioTrackMeta.some((track) => track.id === "system"); + return hasSystemTrack ? "system" : "mixed"; + }, [sourceAudioTrackMeta]); + + const embeddedSourcePreviewGain = useMemo(() => { + const settings = previewSourceAudioTrackSettings[embeddedTrackId] ?? { + volume: 1, + normalize: false, + }; + const normalizeGain = settings.normalize ? SOURCE_AUDIO_NORMALIZE_GAIN : 1; + return Math.max(0, Math.min(1, settings.volume * normalizeGain)); + }, [embeddedTrackId, previewSourceAudioTrackSettings]); + + const getSourceTrackPreviewGain = useCallback( + (audioPath: string) => { + const trackId = getSourceTrackIdFromPath(audioPath); + const settings = previewSourceAudioTrackSettings[trackId] ?? { + volume: 1, + normalize: false, + }; + const normalizeGain = settings.normalize ? SOURCE_AUDIO_NORMALIZE_GAIN : 1; + return Math.max(0, Math.min(1, settings.volume * normalizeGain)); + }, + [previewSourceAudioTrackSettings], + ); + + return { + sourceAudioTrackMeta, + activeSourceAudioTrackSettings, + selectedClipSourceAudioTrackSettings, + getSourceAudioTrackSettingsForClip, + onSourceAudioTracksMetaChange, + onSelectedClipSourceAudioTrackVolumeChange, + onSelectedClipSourceAudioTrackNormalizeChange, + embeddedSourcePreviewGain, + getSourceTrackPreviewGain, + }; +} diff --git a/src/components/video-editor/audio/useSourceAudioFallback.ts b/src/components/video-editor/audio/useSourceAudioFallback.ts new file mode 100644 index 000000000..f081d2ce6 --- /dev/null +++ b/src/components/video-editor/audio/useSourceAudioFallback.ts @@ -0,0 +1,82 @@ +import { useEffect, useRef, useState } from "react"; +import { toast } from "sonner"; +import { SOURCE_AUDIO_FALLBACK_TOAST_ID } from "@/components/video-editor/audio/audioTypes"; + +interface UseSourceAudioFallbackParams { + currentSourcePath: string | null; + refreshKey?: number; + summarizeErrorMessage: (message: string) => string; +} + +export function useSourceAudioFallback({ + currentSourcePath, + refreshKey = 0, + summarizeErrorMessage, +}: UseSourceAudioFallbackParams) { + const [sourceAudioFallbackPaths, setSourceAudioFallbackPaths] = useState<string[]>([]); + const [sourceAudioFallbackStartDelayMsByPath, setSourceAudioFallbackStartDelayMsByPath] = + useState<Record<string, number>>({}); + const previousSourcePathRef = useRef<string | null>(null); + + useEffect(() => { + let cancelled = false; + // Refetch when late recording sidecars are finalized after the editor opens. + void refreshKey; + const sourceChanged = previousSourcePathRef.current !== currentSourcePath; + previousSourcePathRef.current = currentSourcePath; + if (sourceChanged) { + setSourceAudioFallbackPaths([]); + setSourceAudioFallbackStartDelayMsByPath({}); + } + + if (!currentSourcePath) { + return () => { + cancelled = true; + }; + } + + void (async () => { + try { + const result = + await window.electronAPI.getVideoAudioFallbackPaths(currentSourcePath); + if (cancelled) { + return; + } + if (!result.success) { + if (sourceChanged) { + setSourceAudioFallbackPaths([]); + setSourceAudioFallbackStartDelayMsByPath({}); + } + toast.warning( + result.error + ? `Could not load companion audio sources: ${summarizeErrorMessage(result.error)}` + : "Could not load companion audio sources. Playback and export may miss microphone audio.", + { id: SOURCE_AUDIO_FALLBACK_TOAST_ID, duration: 10000 }, + ); + return; + } + + toast.dismiss(SOURCE_AUDIO_FALLBACK_TOAST_ID); + setSourceAudioFallbackPaths(result.paths ?? []); + setSourceAudioFallbackStartDelayMsByPath(result.startDelayMsByPath ?? {}); + } catch (error) { + if (!cancelled) { + if (sourceChanged) { + setSourceAudioFallbackPaths([]); + setSourceAudioFallbackStartDelayMsByPath({}); + } + toast.warning( + `Could not load companion audio sources: ${summarizeErrorMessage(String(error))}`, + { id: SOURCE_AUDIO_FALLBACK_TOAST_ID, duration: 10000 }, + ); + } + } + })(); + + return () => { + cancelled = true; + }; + }, [currentSourcePath, refreshKey, summarizeErrorMessage]); + + return { sourceAudioFallbackPaths, sourceAudioFallbackStartDelayMsByPath }; +} diff --git a/src/components/video-editor/audio/useSourceAudioTrackSettings.ts b/src/components/video-editor/audio/useSourceAudioTrackSettings.ts new file mode 100644 index 000000000..c1a96d4cc --- /dev/null +++ b/src/components/video-editor/audio/useSourceAudioTrackSettings.ts @@ -0,0 +1,181 @@ +import React, { useCallback, useMemo, useState } from "react"; +import type { + SourceAudioTrackMeta, + SourceAudioTrackSettings, +} from "@/components/video-editor/audio/audioTypes"; + +interface UseSourceAudioTrackSettingsParams { + selectedClipId: string | null; + activeClipId: string | null; + sourceAudioTrackSettingsByClip: Record<string, SourceAudioTrackSettings>; + setSourceAudioTrackSettingsByClip: React.Dispatch< + React.SetStateAction<Record<string, SourceAudioTrackSettings>> + >; + defaultSourceAudioTrackSettings: SourceAudioTrackSettings; + setDefaultSourceAudioTrackSettings: React.Dispatch< + React.SetStateAction<SourceAudioTrackSettings> + >; +} + +export interface UseSourceAudioTrackSettingsResult { + sourceAudioTrackMeta: SourceAudioTrackMeta; + activeSourceAudioTrackSettings: SourceAudioTrackSettings; + selectedClipSourceAudioTrackSettings: SourceAudioTrackSettings; + getSourceAudioTrackSettingsForClip: (clipId: string | null) => SourceAudioTrackSettings; + onSourceAudioTracksMetaChange: (tracks: SourceAudioTrackMeta) => void; + onSelectedClipSourceAudioTrackVolumeChange: (id: string, volume: number) => void; + onSelectedClipSourceAudioTrackNormalizeChange: (id: string, normalize: boolean) => void; +} + +function isSameTrackMeta(left: SourceAudioTrackMeta, right: SourceAudioTrackMeta): boolean { + if (left.length !== right.length) return false; + for (let index = 0; index < left.length; index += 1) { + const leftTrack = left[index]; + const rightTrack = right[index]; + if (!leftTrack || !rightTrack) return false; + if (leftTrack.id !== rightTrack.id || leftTrack.label !== rightTrack.label) { + return false; + } + } + return true; +} + +export function useSourceAudioTrackSettings({ + selectedClipId, + activeClipId, + sourceAudioTrackSettingsByClip, + setSourceAudioTrackSettingsByClip, + defaultSourceAudioTrackSettings, + setDefaultSourceAudioTrackSettings, +}: UseSourceAudioTrackSettingsParams): UseSourceAudioTrackSettingsResult { + const [sourceAudioTrackMeta, setSourceAudioTrackMeta] = useState<SourceAudioTrackMeta>([]); + + const activeSourceAudioTrackSettings = useMemo(() => { + if (!activeClipId) { + return defaultSourceAudioTrackSettings; + } + return { + ...defaultSourceAudioTrackSettings, + ...(sourceAudioTrackSettingsByClip[activeClipId] ?? {}), + }; + }, [activeClipId, defaultSourceAudioTrackSettings, sourceAudioTrackSettingsByClip]); + + const selectedClipSourceAudioTrackSettings = useMemo(() => { + if (!selectedClipId) { + return defaultSourceAudioTrackSettings; + } + return { + ...defaultSourceAudioTrackSettings, + ...(sourceAudioTrackSettingsByClip[selectedClipId] ?? {}), + }; + }, [defaultSourceAudioTrackSettings, selectedClipId, sourceAudioTrackSettingsByClip]); + + const onSourceAudioTracksMetaChange = useCallback( + (tracks: SourceAudioTrackMeta) => { + setSourceAudioTrackMeta((prev) => (isSameTrackMeta(prev, tracks) ? prev : tracks)); + setDefaultSourceAudioTrackSettings((prev) => { + const next: SourceAudioTrackSettings = {}; + for (const track of tracks) { + next[track.id] = prev[track.id] ?? { volume: 1, normalize: false }; + } + const prevKeys = Object.keys(prev); + const nextKeys = Object.keys(next); + if (prevKeys.length !== nextKeys.length) { + return next; + } + for (const key of nextKeys) { + const prevSetting = prev[key]; + const nextSetting = next[key]; + if (!prevSetting || !nextSetting) { + return next; + } + if ( + prevSetting.volume !== nextSetting.volume || + prevSetting.normalize !== nextSetting.normalize + ) { + return next; + } + } + return prev; + }); + }, + [setDefaultSourceAudioTrackSettings], + ); + + const getSourceAudioTrackSettingsForClip = useCallback( + (clipId: string | null): SourceAudioTrackSettings => { + if (!clipId) { + return defaultSourceAudioTrackSettings; + } + return { + ...defaultSourceAudioTrackSettings, + ...(sourceAudioTrackSettingsByClip[clipId] ?? {}), + }; + }, + [defaultSourceAudioTrackSettings, sourceAudioTrackSettingsByClip], + ); + + const onSelectedClipSourceAudioTrackVolumeChange = useCallback( + (id: string, volume: number) => { + if (!selectedClipId) return; + setSourceAudioTrackSettingsByClip((prev) => { + const prevClip = prev[selectedClipId] ?? defaultSourceAudioTrackSettings; + const nextVolume = Number.isFinite(volume) + ? Math.max(0, Math.min(1, volume)) + : (prevClip[id]?.volume ?? 1); + const prevNormalize = prevClip[id]?.normalize ?? false; + if ( + prevClip[id]?.volume === nextVolume && + prevClip[id]?.normalize === prevNormalize + ) { + return prev; + } + return { + ...prev, + [selectedClipId]: { + ...prevClip, + [id]: { + volume: nextVolume, + normalize: prevNormalize, + }, + }, + }; + }); + }, + [defaultSourceAudioTrackSettings, selectedClipId, setSourceAudioTrackSettingsByClip], + ); + + const onSelectedClipSourceAudioTrackNormalizeChange = useCallback( + (id: string, normalize: boolean) => { + if (!selectedClipId) return; + setSourceAudioTrackSettingsByClip((prev) => { + const prevClip = prev[selectedClipId] ?? defaultSourceAudioTrackSettings; + const prevVolume = prevClip[id]?.volume ?? 1; + if (prevClip[id]?.normalize === normalize) { + return prev; + } + return { + ...prev, + [selectedClipId]: { + ...prevClip, + [id]: { + volume: prevVolume, + normalize, + }, + }, + }; + }); + }, + [defaultSourceAudioTrackSettings, selectedClipId, setSourceAudioTrackSettingsByClip], + ); + + return { + sourceAudioTrackMeta, + activeSourceAudioTrackSettings, + selectedClipSourceAudioTrackSettings, + getSourceAudioTrackSettingsForClip, + onSourceAudioTracksMetaChange, + onSelectedClipSourceAudioTrackVolumeChange, + onSelectedClipSourceAudioTrackNormalizeChange, + }; +} diff --git a/src/components/video-editor/audio/useVideoEditorAudio.ts b/src/components/video-editor/audio/useVideoEditorAudio.ts new file mode 100644 index 000000000..f56304340 --- /dev/null +++ b/src/components/video-editor/audio/useVideoEditorAudio.ts @@ -0,0 +1,149 @@ +import React, { useMemo } from "react"; +import type { SourceAudioTrackSettings } from "@/components/video-editor/audio/audioTypes"; +import { resolveSourceTrackRoutingPolicy } from "@/lib/exporter/sourceTrackRoutingPolicy"; +import type { AudioRegion, ClipRegion, SpeedRegion } from "../types"; +import { getActiveClipIdAtSourceTime, isClipMutedById } from "./clipAudio"; +import { useAudioPreviewSync } from "./useAudioPreviewSync"; +import { useClipAudioSettingsController } from "./useClipAudioSettingsController"; +import { useSourceAudioFallback } from "./useSourceAudioFallback"; + +function extractLocalPathFromMediaServerUrl(input: string | null | undefined): string | null { + if (!input) return null; + try { + const url = new URL(input); + const isLocalMediaServer = + (url.protocol === "http:" || url.protocol === "https:") && + (url.hostname === "127.0.0.1" || url.hostname === "localhost") && + url.pathname === "/video"; + if (!isLocalMediaServer) return null; + return url.searchParams.get("path"); + } catch { + return null; + } +} + +interface UseVideoEditorAudioParams { + currentSourcePath: string | null; + selectedClipId: string | null; + clipRegions: ClipRegion[]; + audioRegions: AudioRegion[]; + effectiveSpeedRegions: SpeedRegion[]; + sourceAudioTrackSettingsByClip: Record<string, SourceAudioTrackSettings>; + setSourceAudioTrackSettingsByClip: React.Dispatch< + React.SetStateAction<Record<string, SourceAudioTrackSettings>> + >; + defaultSourceAudioTrackSettings: SourceAudioTrackSettings; + setDefaultSourceAudioTrackSettings: React.Dispatch< + React.SetStateAction<SourceAudioTrackSettings> + >; + currentTime: number; + timelineTime: number; + duration: number; + isPlaying: boolean; + previewVolume: number; + sourceAudioFallbackRefreshKey?: number; + summarizeErrorMessage: (message: string) => string; + onSourceFallbackLoadError: (error: unknown) => void; +} + +export function useVideoEditorAudio({ + currentSourcePath, + selectedClipId, + clipRegions, + audioRegions, + effectiveSpeedRegions, + sourceAudioTrackSettingsByClip, + setSourceAudioTrackSettingsByClip, + defaultSourceAudioTrackSettings, + setDefaultSourceAudioTrackSettings, + currentTime, + timelineTime, + duration, + isPlaying, + previewVolume, + sourceAudioFallbackRefreshKey = 0, + summarizeErrorMessage, + onSourceFallbackLoadError, +}: UseVideoEditorAudioParams) { + const fallbackLookupSourcePath = useMemo( + () => extractLocalPathFromMediaServerUrl(currentSourcePath) ?? currentSourcePath, + [currentSourcePath], + ); + + const { sourceAudioFallbackPaths, sourceAudioFallbackStartDelayMsByPath } = + useSourceAudioFallback({ + currentSourcePath: fallbackLookupSourcePath, + refreshKey: sourceAudioFallbackRefreshKey, + summarizeErrorMessage, + }); + + const sourceTrackRoutingPolicy = useMemo( + () => resolveSourceTrackRoutingPolicy(currentSourcePath, sourceAudioFallbackPaths), + [currentSourcePath, sourceAudioFallbackPaths], + ); + const previewSourceAudioFallbackPaths = sourceTrackRoutingPolicy.playbackPaths; + const shouldMutePreviewVideo = sourceTrackRoutingPolicy.muteEmbeddedPreview; + + const activeClipIdAtCurrentTime = useMemo( + () => getActiveClipIdAtSourceTime(currentTime, clipRegions), + [clipRegions, currentTime], + ); + const isCurrentClipMuted = useMemo( + () => isClipMutedById(activeClipIdAtCurrentTime, clipRegions), + [activeClipIdAtCurrentTime, clipRegions], + ); + + const { + sourceAudioTrackMeta, + activeSourceAudioTrackSettings, + selectedClipSourceAudioTrackSettings, + getSourceAudioTrackSettingsForClip, + onSourceAudioTracksMetaChange, + onSelectedClipSourceAudioTrackVolumeChange, + onSelectedClipSourceAudioTrackNormalizeChange, + embeddedSourcePreviewGain, + getSourceTrackPreviewGain, + } = useClipAudioSettingsController({ + selectedClipId, + activeClipId: activeClipIdAtCurrentTime, + sourceAudioTrackSettingsByClip, + setSourceAudioTrackSettingsByClip, + defaultSourceAudioTrackSettings, + setDefaultSourceAudioTrackSettings, + }); + + const { playSourceAudioPreview } = useAudioPreviewSync({ + audioRegions, + previewVolume, + isPlaying, + currentTime, + timelineTime, + duration, + effectiveSpeedRegions, + previewSourceAudioFallbackPaths, + sourceAudioFallbackStartDelayMsByPath, + sourceAudioResourceVersion: sourceAudioFallbackRefreshKey, + isCurrentClipMuted, + getSourceTrackPreviewGain, + onSourceFallbackLoadError, + }); + + return { + sourceAudioFallbackPaths, + sourceAudioFallbackStartDelayMsByPath, + previewSourceAudioFallbackPaths, + shouldMutePreviewVideo, + activeClipIdAtCurrentTime, + isCurrentClipMuted, + sourceAudioTrackMeta, + activeSourceAudioTrackSettings, + selectedClipSourceAudioTrackSettings, + playSourceAudioPreview, + getSourceAudioTrackSettingsForClip, + onSourceAudioTracksMetaChange, + onSelectedClipSourceAudioTrackVolumeChange, + onSelectedClipSourceAudioTrackNormalizeChange, + embeddedSourcePreviewGain, + getSourceTrackPreviewGain, + }; +} diff --git a/src/components/video-editor/audio/waveform/WaveformGenerator.ts b/src/components/video-editor/audio/waveform/WaveformGenerator.ts new file mode 100644 index 000000000..23fc91a1e --- /dev/null +++ b/src/components/video-editor/audio/waveform/WaveformGenerator.ts @@ -0,0 +1,147 @@ +import { WAVEFORM_DEFAULT_PEAK_COUNT } from "../../timeline/core/constants"; +import type { AudioPeaksData } from "../../timeline/core/timelineTypes"; +import { getAudioResourceCacheScope, getAudioResourceVersionKey } from "../audioResourceVersion"; +import WorkerConstructor from "./waveform.worker?worker"; +import { VersionedWaveformCache } from "./waveformCache"; + +const MAX_WAVEFORM_PEAKS = 200_000; +const MAX_WAVEFORM_CACHE_ENTRIES = 24; + +export class WaveformGenerator { + private audioContext: AudioContext; + private worker: Worker; + private peaksCache = new VersionedWaveformCache<AudioPeaksData>(MAX_WAVEFORM_CACHE_ENTRIES); + private pending = new Map<string, Promise<AudioPeaksData>>(); + private workerRequestSeq = 0; + private workerResolvers = new Map< + number, + { resolve: (peaks: Float32Array) => void; reject: (err: Error) => void } + >(); + + constructor() { + this.audioContext = new ( + window.AudioContext || + (window as typeof window & { webkitAudioContext?: typeof AudioContext }) + .webkitAudioContext + )(); + this.worker = new WorkerConstructor(); + + this.worker.addEventListener( + "message", + (event: MessageEvent<{ requestId: number; peaks?: Float32Array; error?: string }>) => { + const { requestId, peaks, error } = event.data; + const resolver = this.workerResolvers.get(requestId); + if (!resolver) return; + + this.workerResolvers.delete(requestId); + if (error) { + resolver.reject(new Error(error)); + } else if (peaks) { + resolver.resolve(peaks); + } + }, + ); + + this.worker.addEventListener("error", (error: ErrorEvent) => { + console.error("[WaveformGenerator] Worker fatal error:", error); + const fatalError = error.error ?? new Error(error.message || "Worker crashed"); + + // Reject all pending requests if the worker itself crashes + for (const resolver of this.workerResolvers.values()) { + resolver.reject(fatalError); + } + this.workerResolvers.clear(); + }); + } + + private computePeaksWithWorker( + channels: Float32Array[], + samples: number, + ): Promise<Float32Array> { + return new Promise((resolve, reject) => { + const requestId = ++this.workerRequestSeq; + this.workerResolvers.set(requestId, { resolve, reject }); + + this.worker.postMessage( + { + requestId, + channels, + samples, + }, + channels.map((c) => c.buffer), + ); + }); + } + + public async generate( + url: string, + peakCount = WAVEFORM_DEFAULT_PEAK_COUNT, + resourceVersion = 0, + ): Promise<AudioPeaksData> { + const cacheScope = `${getAudioResourceCacheScope(url)}::${peakCount}`; + const cacheKey = getAudioResourceVersionKey(cacheScope, resourceVersion); + this.peaksCache.activate(cacheScope, cacheKey); + const cached = this.peaksCache.get(cacheKey); + if (cached) return cached; + + const inflight = this.pending.get(cacheKey); + if (inflight) return inflight; + + const request = (async () => { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Failed to load media: ${response.status}`); + } + + const arrayBuffer = await response.arrayBuffer(); + const decoded = await this.audioContext.decodeAudioData(arrayBuffer); + const adaptivePeakCount = Math.max(peakCount, Math.floor(decoded.duration * 500)); + const boundedPeakCount = Math.min(adaptivePeakCount, MAX_WAVEFORM_PEAKS); + const channels: Float32Array[] = []; + for (let i = 0; i < decoded.numberOfChannels; i++) { + // We slice to transfer the underlying buffer to the worker + channels.push(decoded.getChannelData(i).slice()); + } + + const peaks = await this.computePeaksWithWorker(channels, boundedPeakCount); + + // Robust Normalization: Use 99.5th percentile to avoid being squashed by a single loud spike/pop + let max = 0; + const sortedPeaks = [...peaks].sort((a, b) => a - b); + const percentileIndex = Math.floor(sortedPeaks.length * 0.995); + const robustMax = sortedPeaks[percentileIndex] || 0; + + // Fallback to absolute max if the percentile is zero (very quiet file) + if (robustMax === 0) { + for (let i = 0; i < peaks.length; i++) { + if (peaks[i] > max) max = peaks[i]; + } + } else { + max = robustMax; + } + + if (max > 0) { + for (let i = 0; i < peaks.length; i++) { + peaks[i] = Math.min(1.0, peaks[i] / max); + } + } + + const result: AudioPeaksData = { + peaks, + durationMs: decoded.duration * 1000, + }; + this.peaksCache.setIfCurrent(cacheScope, cacheKey, result); + this.pending.delete(cacheKey); + return result; + })().catch((error) => { + this.pending.delete(cacheKey); + this.peaksCache.deactivateIfCurrent(cacheScope, cacheKey); + throw error; + }); + + this.pending.set(cacheKey, request); + return request; + } +} + +export const waveformGenerator = new WaveformGenerator(); diff --git a/src/components/video-editor/audio/waveform/waveform.worker.ts b/src/components/video-editor/audio/waveform/waveform.worker.ts new file mode 100644 index 000000000..60e77ed4c --- /dev/null +++ b/src/components/video-editor/audio/waveform/waveform.worker.ts @@ -0,0 +1,53 @@ +type WaveformWorkerRequest = { + requestId: number; + channels: Float32Array[]; + samples: number; +}; + +interface WorkerContext { + onmessage: (e: MessageEvent<WaveformWorkerRequest>) => void; + postMessage: (message: unknown, transfer?: Transferable[]) => void; +} + +const workerScope = self as unknown as WorkerContext; + +workerScope.onmessage = (e: MessageEvent<WaveformWorkerRequest>) => { + const { requestId, channels, samples } = e.data; + + if (!channels || channels.length === 0 || samples <= 0) { + const empty = new Float32Array(0); + workerScope.postMessage({ requestId, peaks: empty }, [empty.buffer]); + return; + } + + try { + const firstChannel = channels[0]; + const result = new Float32Array(samples); + const total = firstChannel.length; + const blockSize = total / samples; + + for (let i = 0; i < samples; i++) { + const start = Math.floor(i * blockSize); + const end = Math.min(total, Math.floor((i + 1) * blockSize)); + + let max = 0; + // Ensure we check at least one sample even if blockSize < 1 + const actualEnd = Math.max(start + 1, end); + + for (let j = start; j < actualEnd && j < total; j++) { + for (let c = 0; c < channels.length; c++) { + const val = Math.abs(channels[c][j]); + if (val > max) max = val; + } + } + result[i] = max; + } + + workerScope.postMessage({ requestId, peaks: result }, [result.buffer]); + } catch (err) { + workerScope.postMessage({ + requestId, + error: err instanceof Error ? err.message : "Unknown worker error", + }); + } +}; diff --git a/src/components/video-editor/audio/waveform/waveformCache.test.ts b/src/components/video-editor/audio/waveform/waveformCache.test.ts new file mode 100644 index 000000000..da3dc9e8d --- /dev/null +++ b/src/components/video-editor/audio/waveform/waveformCache.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; + +import { VersionedWaveformCache } from "./waveformCache"; + +describe("VersionedWaveformCache", () => { + it("prunes superseded versions and rejects stale in-flight results", () => { + const cache = new VersionedWaveformCache<string>(2); + + cache.activate("mic::100", "mic-v1"); + expect(cache.setIfCurrent("mic::100", "mic-v1", "version one")).toBe(true); + + cache.activate("mic::100", "mic-v2"); + expect(cache.get("mic-v1")).toBeUndefined(); + expect(cache.setIfCurrent("mic::100", "mic-v1", "stale version one")).toBe(false); + expect(cache.setIfCurrent("mic::100", "mic-v2", "version two")).toBe(true); + expect(cache.get("mic-v2")).toBe("version two"); + + cache.activate("mic::100", "mic-v3"); + cache.deactivateIfCurrent("mic::100", "mic-v3"); + expect(cache.setIfCurrent("mic::100", "mic-v3", "failed version three")).toBe(false); + }); + + it("evicts the least recently used resource when the cache reaches its bound", () => { + const cache = new VersionedWaveformCache<string>(2); + + cache.activate("mic-a", "mic-a-v1"); + cache.setIfCurrent("mic-a", "mic-a-v1", "a"); + cache.activate("mic-b", "mic-b-v1"); + cache.setIfCurrent("mic-b", "mic-b-v1", "b"); + expect(cache.get("mic-a-v1")).toBe("a"); + + cache.activate("mic-c", "mic-c-v1"); + cache.setIfCurrent("mic-c", "mic-c-v1", "c"); + + expect(cache.get("mic-a-v1")).toBe("a"); + expect(cache.get("mic-b-v1")).toBeUndefined(); + expect(cache.get("mic-c-v1")).toBe("c"); + }); +}); diff --git a/src/components/video-editor/audio/waveform/waveformCache.ts b/src/components/video-editor/audio/waveform/waveformCache.ts new file mode 100644 index 000000000..34c5ba468 --- /dev/null +++ b/src/components/video-editor/audio/waveform/waveformCache.ts @@ -0,0 +1,65 @@ +export class VersionedWaveformCache<T> { + private readonly values = new Map<string, T>(); + private readonly latestKeyByScope = new Map<string, string>(); + private readonly scopeByKey = new Map<string, string>(); + + constructor(private readonly maxEntries: number) { + if (!Number.isInteger(maxEntries) || maxEntries < 1) { + throw new RangeError("Waveform cache size must be a positive integer"); + } + } + + activate(scope: string, key: string): void { + const previousKey = this.latestKeyByScope.get(scope); + if (previousKey && previousKey !== key) { + this.values.delete(previousKey); + this.scopeByKey.delete(previousKey); + } + this.latestKeyByScope.set(scope, key); + } + + get(key: string): T | undefined { + const value = this.values.get(key); + if (value === undefined) { + return undefined; + } + + this.values.delete(key); + this.values.set(key, value); + return value; + } + + setIfCurrent(scope: string, key: string, value: T): boolean { + if (this.latestKeyByScope.get(scope) !== key) { + return false; + } + + this.values.delete(key); + this.values.set(key, value); + this.scopeByKey.set(key, scope); + this.evictOverflow(); + return true; + } + + deactivateIfCurrent(scope: string, key: string): void { + if (this.latestKeyByScope.get(scope) === key && !this.values.has(key)) { + this.latestKeyByScope.delete(scope); + } + } + + private evictOverflow(): void { + while (this.values.size > this.maxEntries) { + const oldestKey = this.values.keys().next().value; + if (oldestKey === undefined) { + return; + } + + this.values.delete(oldestKey); + const scope = this.scopeByKey.get(oldestKey); + this.scopeByKey.delete(oldestKey); + if (scope && this.latestKeyByScope.get(scope) === oldestKey) { + this.latestKeyByScope.delete(scope); + } + } + } +} diff --git a/src/components/video-editor/autoCaptionSource.test.ts b/src/components/video-editor/autoCaptionSource.test.ts new file mode 100644 index 000000000..6a9693974 --- /dev/null +++ b/src/components/video-editor/autoCaptionSource.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; + +import { resolveAutoCaptionSourcePath } from "./autoCaptionSource"; + +describe("resolveAutoCaptionSourcePath", () => { + it("prefers the video loaded in the editor over stale session state", () => { + expect( + resolveAutoCaptionSourcePath({ + videoSourcePath: "/videos/current.mp4", + recordingSessionVideoPath: "/videos/stale.mp4", + currentVideoPath: "/videos/fallback.mp4", + }), + ).toBe("/videos/current.mp4"); + }); + + it("falls back to the loaded file URL when no normalized source path exists", () => { + expect( + resolveAutoCaptionSourcePath({ + videoPath: "file:///Users/test/Desktop/capture.mp4", + recordingSessionVideoPath: "/videos/stale.mp4", + }), + ).toBe("/Users/test/Desktop/capture.mp4"); + }); + + it("extracts local paths from Recordly media-server URLs", () => { + expect( + resolveAutoCaptionSourcePath({ + videoPath: + "http://127.0.0.1:43123/video?path=%2FUsers%2Ftest%2FDesktop%2Fcapture.mp4", + }), + ).toBe("/Users/test/Desktop/capture.mp4"); + }); + + it("rejects remote URLs that native captioning cannot read", () => { + expect( + resolveAutoCaptionSourcePath({ videoPath: "https://example.com/capture.mp4" }), + ).toBeNull(); + }); + + it("uses session and current video fallbacks only when nothing is loaded", () => { + expect( + resolveAutoCaptionSourcePath({ + recordingSessionVideoPath: "/videos/session.mp4", + currentVideoPath: "/videos/fallback.mp4", + }), + ).toBe("/videos/session.mp4"); + + expect( + resolveAutoCaptionSourcePath({ + currentVideoPath: "/videos/fallback.mp4", + }), + ).toBe("/videos/fallback.mp4"); + }); +}); diff --git a/src/components/video-editor/autoCaptionSource.ts b/src/components/video-editor/autoCaptionSource.ts new file mode 100644 index 000000000..c39f9d4b6 --- /dev/null +++ b/src/components/video-editor/autoCaptionSource.ts @@ -0,0 +1,28 @@ +import { getLocalFilePathFromResource } from "@/lib/exporter/mediaResource"; + +type AutoCaptionSourceOptions = { + videoSourcePath?: string | null; + videoPath?: string | null; + recordingSessionVideoPath?: string | null; + currentVideoPath?: string | null; +}; + +export function resolveAutoCaptionSourcePath(options: AutoCaptionSourceOptions): string | null { + if (options.videoSourcePath) { + return getLocalFilePathFromResource(options.videoSourcePath); + } + + if (options.videoPath) { + return getLocalFilePathFromResource(options.videoPath); + } + + if (options.recordingSessionVideoPath) { + return getLocalFilePathFromResource(options.recordingSessionVideoPath); + } + + if (options.currentVideoPath) { + return getLocalFilePathFromResource(options.currentVideoPath); + } + + return null; +} diff --git a/src/components/video-editor/captionEditing.test.ts b/src/components/video-editor/captionEditing.test.ts new file mode 100644 index 000000000..48d3ce950 --- /dev/null +++ b/src/components/video-editor/captionEditing.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, it } from "vitest"; + +import { + type CaptionEditTarget, + normalizeCaptionEditText, + updateCaptionCuesForEditedTarget, +} from "./captionEditing"; +import { buildActiveCaptionLayout } from "./captionLayout"; +import { type CaptionCue, DEFAULT_AUTO_CAPTION_SETTINGS } from "./types"; + +const visibleTarget: CaptionEditTarget = { + id: "visible-page", + startMs: 1_000, + endMs: 2_400, + text: "Hello Hello 你们好啊", + words: [ + { + cueId: "a", + cueWordIndex: 0, + startMs: 1_000, + endMs: 1_500, + text: "Hello", + leadingSpace: false, + }, + { + cueId: "a", + cueWordIndex: 1, + startMs: 1_500, + endMs: 2_000, + text: "Hello", + leadingSpace: true, + }, + { + cueId: "b", + cueWordIndex: 0, + startMs: 2_000, + endMs: 2_400, + text: "你们好啊", + leadingSpace: true, + }, + ], +}; + +describe("captionEditing", () => { + it("normalizes edited caption text", () => { + expect(normalizeCaptionEditText(" hello \n edited\tcaption ")).toBe( + "hello edited caption", + ); + expect(normalizeCaptionEditText(" \n\t ")).toBe(""); + }); + + it("keeps text-only captions text-only after editing", () => { + const updated = updateCaptionCuesForEditedTarget( + [ + { id: "a", startMs: 1_000, endMs: 2_000, text: "Hello Hello" }, + { id: "b", startMs: 2_000, endMs: 3_000, text: "你们好啊 这个是我的屏幕" }, + ], + visibleTarget, + "Hi 大家好", + ); + + expect(updated.map((caption) => caption.text)).toEqual(["Hi", "大家好 这个是我的屏幕"]); + expect(updated.every((caption) => caption.words === undefined)).toBe(true); + + const layout = buildActiveCaptionLayout({ + cues: updated, + timeMs: 1_500, + settings: DEFAULT_AUTO_CAPTION_SETTINGS, + maxWidthPx: 500, + measureText: (text) => text.length * 10, + }); + expect(layout?.hasWordTimings).toBe(false); + }); + + it("preserves cue identity and timing when editing captions with word timings", () => { + const cues: CaptionCue[] = [ + { + id: "a", + startMs: 1_000, + endMs: 2_000, + text: "Hello Hello", + words: [ + { text: "Hello", startMs: 1_000, endMs: 1_500 }, + { text: "Hello", startMs: 1_500, endMs: 2_000, leadingSpace: true }, + ], + }, + { + id: "b", + startMs: 2_000, + endMs: 3_000, + text: "你们好啊 这个是我的屏幕", + words: [ + { text: "你们好啊", startMs: 2_000, endMs: 2_400 }, + { text: "这个是我的屏幕", startMs: 2_400, endMs: 3_000, leadingSpace: true }, + ], + }, + ]; + + const updated = updateCaptionCuesForEditedTarget(cues, visibleTarget, "Hi 大家好"); + + expect(updated.map((caption) => [caption.id, caption.startMs, caption.endMs])).toEqual([ + ["a", 1_000, 2_000], + ["b", 2_000, 3_000], + ]); + expect(updated[0].words).toEqual([{ text: "Hi", startMs: 1_000, endMs: 2_000 }]); + expect(updated[1].words).toEqual([ + { text: "大家好", startMs: 2_000, endMs: 2_400 }, + { text: "这个是我的屏幕", startMs: 2_400, endMs: 3_000, leadingSpace: true }, + ]); + }); + + it("does not update captions when edited text is blank", () => { + const cues: CaptionCue[] = [{ id: "a", startMs: 1_000, endMs: 2_000, text: "Hello Hello" }]; + + expect(updateCaptionCuesForEditedTarget(cues, visibleTarget, " \n\t ")).toBe(cues); + }); + + it("keeps sound-effect style captions editable when word entries are blank", () => { + const cues: CaptionCue[] = [ + { + id: "sound-effect", + startMs: 1_000, + endMs: 2_000, + text: "clears throat", + words: [{ text: "", startMs: 1_000, endMs: 2_000 }], + }, + ]; + + const layout = buildActiveCaptionLayout({ + cues, + timeMs: 1_500, + settings: DEFAULT_AUTO_CAPTION_SETTINGS, + maxWidthPx: 500, + measureText: (text) => text.length * 10, + }); + + expect(layout?.editTarget.text).toBe("clears throat"); + + const updated = updateCaptionCuesForEditedTarget(cues, layout!.editTarget, "coughs"); + + expect(updated[0].text).toBe("coughs"); + expect(updated[0].words).toEqual([{ text: "coughs", startMs: 1_000, endMs: 2_000 }]); + }); +}); diff --git a/src/components/video-editor/captionEditing.ts b/src/components/video-editor/captionEditing.ts new file mode 100644 index 000000000..c410b47a7 --- /dev/null +++ b/src/components/video-editor/captionEditing.ts @@ -0,0 +1,241 @@ +import type { CaptionCue, CaptionCueWord } from "./types"; + +export interface CaptionEditWordRef { + cueId: string; + cueWordIndex: number; + startMs: number; + endMs: number; + text: string; + leadingSpace: boolean; +} + +export interface CaptionEditTarget { + id: string; + startMs: number; + endMs: number; + text: string; + words: CaptionEditWordRef[]; +} + +export function normalizeCaptionEditText(text: string) { + return text.trim().replace(/\s+/g, " "); +} + +function buildCaptionWordsForEditedText( + text: string, + startMs: number, + endMs: number, +): CaptionCueWord[] { + const normalizedText = normalizeCaptionEditText(text); + const tokens = normalizedText.match(/\S+/g) ?? []; + const normalizedStartMs = Math.max(0, Math.round(startMs)); + const normalizedEndMs = Math.max(normalizedStartMs + 1, Math.round(endMs)); + const durationMs = normalizedEndMs - normalizedStartMs; + + return tokens.map((token, index) => { + const wordStartMs = Math.min( + normalizedEndMs - 1, + Math.max( + normalizedStartMs, + Math.round(normalizedStartMs + (durationMs * index) / tokens.length), + ), + ); + const nextBoundaryMs = + index === tokens.length - 1 + ? normalizedEndMs + : Math.round(normalizedStartMs + (durationMs * (index + 1)) / tokens.length); + const wordEndMs = Math.min(normalizedEndMs, Math.max(wordStartMs + 1, nextBoundaryMs)); + + return { + text: token, + startMs: wordStartMs, + endMs: wordEndMs, + ...(index > 0 ? { leadingSpace: true } : {}), + }; + }); +} + +export function normalizeCaptionWords(cue: CaptionCue): CaptionCueWord[] { + const validSourceWords = Array.isArray(cue.words) + ? cue.words.filter( + (word): word is CaptionCueWord => + Boolean(word && typeof word.text === "string") && + normalizeCaptionEditText(word.text).length > 0, + ) + : []; + const sourceWords = + validSourceWords.length > 0 + ? validSourceWords + : buildCaptionWordsForEditedText(cue.text, cue.startMs, cue.endMs); + + return sourceWords + .filter((word): word is CaptionCueWord => Boolean(word && typeof word.text === "string")) + .map((word) => { + const startMs = Math.max( + cue.startMs, + Math.min(cue.endMs - 1, Math.round(word.startMs)), + ); + const endMs = Math.max(startMs + 1, Math.min(cue.endMs, Math.round(word.endMs))); + + return { + text: normalizeCaptionEditText(word.text), + startMs, + endMs, + ...(word.leadingSpace ? { leadingSpace: true } : {}), + }; + }) + .filter((word) => word.text.length > 0); +} + +export function captionWordsToText(words: CaptionCueWord[]) { + return words + .map((word, index) => `${index > 0 && word.leadingSpace ? " " : ""}${word.text}`) + .join("") + .trim(); +} + +export function normalizeCaptionWordSpacing(words: CaptionCueWord[]): CaptionCueWord[] { + return words + .slice() + .sort((a, b) => a.startMs - b.startMs || a.endMs - b.endMs) + .map((word, index) => ({ + text: word.text, + startMs: word.startMs, + endMs: word.endMs, + ...(index > 0 ? { leadingSpace: true } : {}), + })); +} + +function shouldPreserveCaptionWords(cue: CaptionCue) { + return Array.isArray(cue.words) && cue.words.length > 0; +} + +export function updateCaptionCuesForEditedTarget( + cues: CaptionCue[], + target: CaptionEditTarget, + text: string, +): CaptionCue[] { + const normalizedText = normalizeCaptionEditText(text); + if (!normalizedText || target.words.length === 0) { + return cues; + } + + const targetWordsByCue = new Map<string, CaptionEditWordRef[]>(); + for (const word of target.words) { + const words = targetWordsByCue.get(word.cueId) ?? []; + words.push(word); + targetWordsByCue.set(word.cueId, words); + } + + const tokens = normalizedText.match(/\S+/g) ?? []; + const targetCueIds = new Set(targetWordsByCue.keys()); + const cueSegments = cues + .filter((cue) => targetCueIds.has(cue.id)) + .map((cue) => { + const words = targetWordsByCue.get(cue.id) ?? []; + return { + cue, + startMs: Math.min(...words.map((word) => word.startMs)), + endMs: Math.max(...words.map((word) => word.endMs)), + }; + }) + .filter((segment) => Number.isFinite(segment.startMs) && Number.isFinite(segment.endMs)); + const editedWordsByCue = new Map<string, CaptionCueWord[]>(); + const tokenCountsByCue = distributeEditedTokensAcrossCueSegments(tokens.length, cueSegments); + let tokenCursor = 0; + + for (const segment of cueSegments) { + const tokenCount = tokenCountsByCue.get(segment.cue.id) ?? 0; + if (tokenCount <= 0) { + continue; + } + + const segmentTokens = tokens.slice(tokenCursor, tokenCursor + tokenCount); + tokenCursor += tokenCount; + editedWordsByCue.set( + segment.cue.id, + buildCaptionWordsForEditedText(segmentTokens.join(" "), segment.startMs, segment.endMs), + ); + } + + return cues.map((cue) => { + const targetWords = targetWordsByCue.get(cue.id); + if (!targetWords) { + return cue; + } + + const targetIndexes = new Set(targetWords.map((word) => word.cueWordIndex)); + const existingWords = normalizeCaptionWords(cue); + const keptWords = existingWords.filter((_, index) => !targetIndexes.has(index)); + const nextWords = normalizeCaptionWordSpacing([ + ...keptWords, + ...(editedWordsByCue.get(cue.id) ?? []), + ]); + const shouldKeepWords = shouldPreserveCaptionWords(cue); + + return { + id: cue.id, + startMs: cue.startMs, + endMs: cue.endMs, + text: captionWordsToText(nextWords), + ...(shouldKeepWords && nextWords.length > 0 ? { words: nextWords } : {}), + }; + }); +} + +function distributeEditedTokensAcrossCueSegments( + tokenCount: number, + segments: Array<{ cue: CaptionCue; startMs: number; endMs: number }>, +) { + const tokenCountsByCue = new Map<string, number>(); + if (tokenCount <= 0 || segments.length === 0) { + return tokenCountsByCue; + } + + if (tokenCount < segments.length) { + const largestSegments = [...segments] + .sort((a, b) => b.endMs - b.startMs - (a.endMs - a.startMs)) + .slice(0, tokenCount); + const selectedCueIds = new Set(largestSegments.map((segment) => segment.cue.id)); + for (const segment of segments) { + tokenCountsByCue.set(segment.cue.id, selectedCueIds.has(segment.cue.id) ? 1 : 0); + } + return tokenCountsByCue; + } + + const baseTokenCount = 1; + const remainingTokens = tokenCount - segments.length; + const totalDuration = Math.max( + 1, + segments.reduce( + (total, segment) => total + Math.max(1, segment.endMs - segment.startMs), + 0, + ), + ); + const weightedCounts = segments.map((segment) => { + const exactCount = + (Math.max(1, segment.endMs - segment.startMs) / totalDuration) * remainingTokens; + const extraCount = Math.floor(exactCount); + return { + segment, + count: baseTokenCount + extraCount, + remainder: exactCount - extraCount, + }; + }); + let assignedTokens = weightedCounts.reduce((total, item) => total + item.count, 0); + + for (const item of [...weightedCounts].sort((a, b) => b.remainder - a.remainder)) { + if (assignedTokens >= tokenCount) { + break; + } + + item.count += 1; + assignedTokens += 1; + } + + for (const item of weightedCounts) { + tokenCountsByCue.set(item.segment.cue.id, item.count); + } + + return tokenCountsByCue; +} diff --git a/src/components/video-editor/captionLayout.test.ts b/src/components/video-editor/captionLayout.test.ts new file mode 100644 index 000000000..9cdc733b8 --- /dev/null +++ b/src/components/video-editor/captionLayout.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; +import { flattenCaptionWords } from "./captionLayout"; +import type { CaptionCue } from "./types"; + +describe("flattenCaptionWords", () => { + it("forces a break at every cue boundary so each phrase shows on its own", () => { + const cues: CaptionCue[] = [ + { + id: "a", + startMs: 0, + endMs: 1_000, + text: "hello world", + words: [ + { text: "hello", startMs: 0, endMs: 500 }, + { text: "world", startMs: 500, endMs: 1_000, leadingSpace: true }, + ], + }, + { + // back-to-back with cue "a" (no gap) — would previously be re-packed by width + id: "b", + startMs: 1_000, + endMs: 2_000, + text: "next one", + words: [ + { text: "next", startMs: 1_000, endMs: 1_500 }, + { text: "one", startMs: 1_500, endMs: 2_000, leadingSpace: true }, + ], + }, + ]; + + const flattened = flattenCaptionWords(cues); + const firstWordOfSecondCue = flattened.find( + (word) => word.cueId === "b" && word.cueWordIndex === 0, + ); + + expect(firstWordOfSecondCue?.forcedBreakBefore).toBe(true); + expect(firstWordOfSecondCue?.leadingSpace).toBe(false); + // the very first word of the first cue never forces a break + expect(flattened[0].forcedBreakBefore).toBe(false); + }); +}); diff --git a/src/components/video-editor/captionLayout.ts b/src/components/video-editor/captionLayout.ts new file mode 100644 index 000000000..adacaae69 --- /dev/null +++ b/src/components/video-editor/captionLayout.ts @@ -0,0 +1,586 @@ +import type { CaptionEditTarget } from "./captionEditing"; +import type { + AutoCaptionAnimation, + AutoCaptionSettings, + CaptionCue, + CaptionCueWord, +} from "./types"; + +export type CaptionWordState = "spoken" | "active" | "upcoming"; + +export interface CaptionWordLayout { + cueId: string; + cueWordIndex: number; + text: string; + index: number; + forcedBreakBefore: boolean; + leadingSpace: boolean; + startMs: number; + endMs: number; + hasRealTiming: boolean; + state: CaptionWordState; +} + +export interface CaptionLineLayout { + words: CaptionWordLayout[]; + width: number; + startWordIndex: number; + endWordIndex: number; +} + +interface CaptionPageLayout { + lines: CaptionLineLayout[]; + startMs: number; + endMs: number; +} + +export interface ActiveCaptionLayout { + cue: CaptionCue; + blockKey: string; + visibleLines: CaptionLineLayout[]; + hasWordTimings: boolean; + activeWordIndex: number; + activeWordProgress: number; + editTarget: CaptionEditTarget; + visiblePageIndex: number; + opacity: number; + translateY: number; + scale: number; +} + +type CaptionSourceWord = { + cueId: string; + cueWordIndex: number; + text: string; + forcedBreakBefore: boolean; + leadingSpace?: boolean; + startMs?: number; + endMs?: number; +}; + +const CAPTION_ENTER_MS = 180; +// Keep the exit symmetric with the entrance so captions fade out with the +// same opacity/translateY/scale curve and duration they faded in with. +const CAPTION_EXIT_MS = CAPTION_ENTER_MS; +const CAPTION_BLOCK_GAP_BREAK_MS = 500; + +function clamp(value: number, min: number, max: number) { + return Math.min(max, Math.max(min, value)); +} + +function clamp01(value: number) { + return clamp(value, 0, 1); +} + +function splitCaptionWordsFromText(text: string) { + const sourceLines = text + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line.length > 0); + const words: CaptionSourceWord[] = []; + + sourceLines.forEach((line, lineIndex) => { + line.split(/\s+/) + .filter(Boolean) + .forEach((word, wordIndex) => { + words.push({ + cueId: "", + cueWordIndex: words.length, + text: word, + forcedBreakBefore: lineIndex > 0 && wordIndex === 0, + }); + }); + }); + + return words; +} + +function splitCaptionWords(cue: CaptionCue) { + if (Array.isArray(cue.words) && cue.words.length > 0) { + const words = cue.words + .filter((word): word is CaptionCueWord => + Boolean(word && typeof word.text === "string"), + ) + .map((word, cueWordIndex) => ({ + cueId: cue.id, + cueWordIndex, + text: word.text.trim(), + forcedBreakBefore: false, + leadingSpace: Boolean(word.leadingSpace), + startMs: word.startMs, + endMs: word.endMs, + })) + .filter((word) => word.text.length > 0); + + if (words.length > 0) { + return words; + } + } + + return splitCaptionWordsFromText(cue.text).map((word) => ({ + ...word, + cueId: cue.id, + })); +} + +function getActiveCaptionCue(cues: CaptionCue[], timeMs: number) { + for (const cue of cues) { + if (timeMs >= cue.startMs && timeMs <= cue.endMs) { + return cue; + } + } + + return null; +} + +function isWithinCaptionCoverage(cues: CaptionCue[], timeMs: number) { + const sorted = [...cues].sort((left, right) => left.startMs - right.startMs); + for (let index = 0; index < sorted.length; index += 1) { + const cue = sorted[index]; + if (timeMs < cue.startMs) { + return false; + } + const next = sorted[index + 1]; + const bridgesGap = + next !== undefined && next.startMs - cue.endMs < CAPTION_BLOCK_GAP_BREAK_MS; + const effectiveEndMs = bridgesGap ? Math.max(cue.endMs, next.startMs) : cue.endMs; + if (timeMs <= effectiveEndMs) { + return true; + } + } + + return false; +} + +export function flattenCaptionWords(cues: CaptionCue[]) { + const flattened: Array<{ + cueId: string; + cueWordIndex: number; + text: string; + forcedBreakBefore: boolean; + leadingSpace: boolean; + startMs: number; + endMs: number; + hasRealTiming: boolean; + }> = []; + + cues.forEach((cue, cueIndex) => { + const sourceWords = splitCaptionWords(cue); + if (sourceWords.length === 0) { + return; + } + + const cueHasRealWordTimings = sourceWords.every( + (word) => + isFiniteNumber(word.startMs) && + isFiniteNumber(word.endMs) && + word.endMs > word.startMs, + ); + const cueDuration = Math.max(1, cue.endMs - cue.startMs); + const fallbackWordDuration = cueDuration / sourceWords.length; + const previousCue = cueIndex > 0 ? cues[cueIndex - 1] : null; + // Each cue is its own phrase, so always start a new line/page at a cue boundary. + // Otherwise back-to-back phrases (a small gap) get re-packed together by width and + // their boundary disappears on screen — we want one phrase shown at a time. + const shouldForceCueBreak = previousCue !== null; + + sourceWords.forEach((word, wordIndex) => { + const fallbackStartMs = cue.startMs + fallbackWordDuration * wordIndex; + const fallbackEndMs = + wordIndex === sourceWords.length - 1 + ? cue.endMs + : cue.startMs + fallbackWordDuration * (wordIndex + 1); + const startsMergedCue = + wordIndex === 0 && !word.forcedBreakBefore && !shouldForceCueBreak; + const leadingSpace = + wordIndex === 0 + ? flattened.length > 0 && startsMergedCue + : (word.leadingSpace ?? true); + + flattened.push({ + cueId: cue.id, + cueWordIndex: word.cueWordIndex, + text: word.text, + forcedBreakBefore: + word.forcedBreakBefore || (wordIndex === 0 && shouldForceCueBreak), + leadingSpace, + startMs: cueHasRealWordTimings ? word.startMs! : fallbackStartMs, + endMs: cueHasRealWordTimings + ? word.endMs! + : Math.max(fallbackStartMs + 1, fallbackEndMs), + hasRealTiming: cueHasRealWordTimings, + }); + }); + }); + + return flattened; +} + +function getCaptionAnimationState( + animationStyle: AutoCaptionAnimation, + enterProgress: number, + exitProgress: number, +) { + const visibility = Math.min(enterProgress, exitProgress); + + switch (animationStyle) { + case "none": + return { + opacity: 1, + translateY: 0, + scale: 1, + }; + case "fade": + return { + opacity: 0.3 + visibility * 0.7, + translateY: 0, + scale: 1, + }; + case "rise": + return { + opacity: 0.25 + visibility * 0.75, + translateY: (1 - visibility) * 18, + scale: 0.985 + visibility * 0.015, + }; + case "pop": + default: + return { + opacity: 0.35 + visibility * 0.65, + translateY: (1 - visibility) * 8, + scale: 0.94 + visibility * 0.06, + }; + } +} + +function buildCaptionPages(options: { + lines: CaptionLineLayout[]; + words: CaptionWordLayout[]; + maxRows: number; + hasWordTimings: boolean; + cue: CaptionCue; +}) { + const pages: CaptionPageLayout[] = []; + const totalPages = Math.max(1, Math.ceil(options.lines.length / options.maxRows)); + let lineIndex = 0; + let remainingLines = options.lines.length; + + for (let pageIndex = 0; pageIndex < totalPages; pageIndex += 1) { + const remainingPages = totalPages - pageIndex; + const pageSize = Math.min( + options.maxRows, + Math.max(1, Math.ceil(remainingLines / remainingPages)), + ); + const pageLines = options.lines.slice(lineIndex, lineIndex + pageSize); + if (pageLines.length === 0) { + continue; + } + + lineIndex += pageLines.length; + remainingLines -= pageLines.length; + + if (options.hasWordTimings) { + const firstWord = options.words[pageLines[0].startWordIndex]; + const lastWord = options.words[pageLines[pageLines.length - 1].endWordIndex]; + pages.push({ + lines: pageLines, + startMs: firstWord?.startMs ?? options.cue.startMs, + endMs: lastWord?.endMs ?? options.cue.endMs, + }); + continue; + } + + pages.push({ + lines: pageLines, + startMs: 0, + endMs: 0, + }); + } + + if (!options.hasWordTimings) { + const totalWords = Math.max(1, options.words.length); + const cueDuration = Math.max(1, options.cue.endMs - options.cue.startMs); + let elapsedMs = options.cue.startMs; + + pages.forEach((page, pageIndex) => { + const pageWordCount = page.lines.reduce((count, line) => count + line.words.length, 0); + const proportionalDuration = Math.round((cueDuration * pageWordCount) / totalWords); + const nextElapsed = + pageIndex === pages.length - 1 + ? options.cue.endMs + : Math.min(options.cue.endMs, elapsedMs + Math.max(1, proportionalDuration)); + + page.startMs = elapsedMs; + page.endMs = Math.max(elapsedMs + 1, nextElapsed); + elapsedMs = page.endMs; + }); + } + + for (let index = 0; index < pages.length - 1; index += 1) { + pages[index].endMs = Math.max(pages[index].startMs + 1, pages[index + 1].startMs); + } + + if (pages.length > 0) { + pages[pages.length - 1].endMs = Math.max( + pages[pages.length - 1].startMs + 1, + options.cue.endMs, + ); + } + + return pages; +} + +function buildCaptionLines(options: { + words: CaptionWordLayout[]; + maxWidthPx: number; + measureText: (text: string) => number; +}) { + const wordCount = options.words.length; + const segmentWidths = options.words.map((word, index) => { + if (index === 0) { + return options.measureText(word.text); + } + + return options.measureText(`${word.leadingSpace ? " " : ""}${word.text}`); + }); + const plainWordWidths = options.words.map((word) => options.measureText(word.text)); + const bestCost = new Array<number>(wordCount + 1).fill(Number.POSITIVE_INFINITY); + const nextBreak = new Array<number>(wordCount).fill(wordCount); + + bestCost[wordCount] = 0; + + for (let startIndex = wordCount - 1; startIndex >= 0; startIndex -= 1) { + let lineWidth = plainWordWidths[startIndex]; + + for (let endIndex = startIndex; endIndex < wordCount; endIndex += 1) { + if (endIndex > startIndex) { + if (options.words[endIndex].forcedBreakBefore) { + break; + } + + lineWidth += segmentWidths[endIndex]; + } + + if (lineWidth > options.maxWidthPx && endIndex > startIndex) { + break; + } + + const slack = Math.max(0, options.maxWidthPx - lineWidth); + const fullness = options.maxWidthPx <= 0 ? 1 : lineWidth / options.maxWidthPx; + const isLastLine = endIndex === wordCount - 1; + const linePenalty = isLastLine + ? slack * slack * 0.18 + : slack * slack + (fullness < 0.72 ? (0.72 - fullness) * 26000 : 0); + const candidateCost = linePenalty + bestCost[endIndex + 1]; + + if (candidateCost < bestCost[startIndex]) { + bestCost[startIndex] = candidateCost; + nextBreak[startIndex] = endIndex + 1; + } + } + } + + const lines: CaptionLineLayout[] = []; + let startIndex = 0; + + while (startIndex < wordCount) { + const endIndexExclusive = Math.max(startIndex + 1, nextBreak[startIndex]); + const lineWords = options.words.slice(startIndex, endIndexExclusive).map((word, index) => ({ + ...word, + leadingSpace: index === 0 ? false : word.leadingSpace, + })); + const width = lineWords.reduce( + (total, word, index) => + total + + options.measureText( + `${index === 0 ? "" : word.leadingSpace ? " " : ""}${word.text}`, + ), + 0, + ); + + lines.push({ + words: lineWords, + width, + startWordIndex: lineWords[0].index, + endWordIndex: lineWords[lineWords.length - 1].index, + }); + + startIndex = endIndexExclusive; + } + + return lines; +} + +function getVisibleCaptionPageIndex(pages: CaptionPageLayout[], timeMs: number) { + for (let index = 0; index < pages.length; index += 1) { + if (timeMs >= pages[index].startMs && timeMs <= pages[index].endMs) { + return index; + } + } + + return -1; +} + +function getVisibleCaptionText(lines: CaptionLineLayout[]) { + return lines + .map((line) => + line.words + .map((word, index) => `${index > 0 && word.leadingSpace ? " " : ""}${word.text}`) + .join("") + .trim(), + ) + .filter(Boolean) + .join(" "); +} + +export function buildActiveCaptionLayout(options: { + cues: CaptionCue[]; + timeMs: number; + settings: AutoCaptionSettings; + maxWidthPx: number; + measureText: (text: string) => number; +}) { + const sourceWords = flattenCaptionWords(options.cues); + if (sourceWords.length === 0) { + return null; + } + + if (!isWithinCaptionCoverage(options.cues, options.timeMs)) { + return null; + } + + let activeWordIndex = -1; + activeWordIndex = sourceWords.findIndex( + (word) => options.timeMs >= word.startMs && options.timeMs < word.endMs, + ); + if (activeWordIndex < 0) { + activeWordIndex = sourceWords.findIndex((word) => options.timeMs < word.startMs); + activeWordIndex = + activeWordIndex < 0 + ? sourceWords.length - 1 + : clamp(activeWordIndex - 1, 0, sourceWords.length - 1); + } + const maxRows = clamp(Math.round(options.settings.maxRows || 1), 1, 4); + + const words: CaptionWordLayout[] = sourceWords.map((word, index) => { + return { + cueId: word.cueId, + cueWordIndex: word.cueWordIndex, + text: word.text, + index, + forcedBreakBefore: word.forcedBreakBefore, + leadingSpace: word.leadingSpace, + startMs: word.startMs, + endMs: word.endMs, + hasRealTiming: word.hasRealTiming, + state: + index < activeWordIndex + ? "spoken" + : index === activeWordIndex + ? "active" + : "upcoming", + }; + }); + + const lines = buildCaptionLines({ + words, + maxWidthPx: options.maxWidthPx, + measureText: options.measureText, + }); + + const pages = buildCaptionPages({ + lines, + words, + maxRows, + hasWordTimings: true, + cue: { + id: sourceWords[0].cueId, + startMs: sourceWords[0].startMs, + endMs: sourceWords[sourceWords.length - 1].endMs, + text: "", + }, + }); + const visiblePageIndex = getVisibleCaptionPageIndex(pages, options.timeMs); + if (visiblePageIndex < 0) { + return null; + } + const visiblePage = pages[visiblePageIndex] ?? null; + const visibleLines = visiblePage?.lines ?? lines.slice(0, maxRows); + const activeWord = activeWordIndex >= 0 ? words[activeWordIndex] : null; + const activeWordProgress = activeWord + ? clamp01( + (options.timeMs - activeWord.startMs) / + Math.max(1, activeWord.endMs - activeWord.startMs), + ) + : 0; + const animationStartMs = visiblePage?.startMs ?? sourceWords[0].startMs; + const animationEndMs = visiblePage?.endMs ?? sourceWords[sourceWords.length - 1].endMs; + const pageStartWordIndex = visibleLines[0]?.startWordIndex ?? 0; + const visibleWords = visibleLines.flatMap((line) => line.words); + const visibleHasWordTimings = visibleWords.every((word) => word.hasRealTiming); + const pageCueId = sourceWords[pageStartWordIndex]?.cueId ?? sourceWords[0].cueId; + const activeCue = + getActiveCaptionCue(options.cues, options.timeMs) ?? + options.cues.find((candidate) => candidate.id === pageCueId) ?? + options.cues[0]; + // `animationEndMs` is stretched to the next page's start (see buildCaptionPages) + // so consecutive pages stay on screen with no gap. That's right for continuous + // speech, but pages are built from every cue flattened together — so when a + // caption component (cue) is the last before a pause, its stitched end points + // across the silence into the next cue. exitProgress would then stay pinned at + // 1 and the caption would vanish with no fade. Anchor the exit to the real end + // of the visible words instead, and only when nothing follows within the + // gap-break window (otherwise keep the stitched end so continuous page swaps + // don't flicker). + const visibleContentEndMs = visibleWords.reduce( + (latest, word) => Math.max(latest, word.endMs), + animationStartMs, + ); + const willDisappear = + options.settings.animationStyle !== "none" && + !isWithinCaptionCoverage(options.cues, visibleContentEndMs + 1); + const exitAnchorMs = willDisappear ? visibleContentEndMs : animationEndMs; + const enterProgress = clamp01((options.timeMs - animationStartMs) / CAPTION_ENTER_MS); + const exitProgress = clamp01((exitAnchorMs - options.timeMs) / CAPTION_EXIT_MS); + const animation = getCaptionAnimationState( + options.settings.animationStyle, + enterProgress, + exitProgress, + ); + // On a real disappearance the per-style opacity only eases to its readability + // floor (~0.3) before the element unmounts, which reads as a snap. Fade fully + // to 0 across the exit window so the caption leaves smoothly. + const opacity = willDisappear ? animation.opacity * exitProgress : animation.opacity; + + return { + cue: activeCue, + blockKey: `${Math.round(animationStartMs)}-${Math.round(animationEndMs)}`, + visibleLines, + hasWordTimings: visibleHasWordTimings, + activeWordIndex, + activeWordProgress, + editTarget: { + id: `${Math.round(animationStartMs)}-${Math.round(animationEndMs)}:${visibleWords + .map((word) => `${word.cueId}:${word.cueWordIndex}`) + .join("|")}`, + startMs: animationStartMs, + endMs: animationEndMs, + text: getVisibleCaptionText(visibleLines), + words: visibleWords.map((word) => ({ + cueId: word.cueId, + cueWordIndex: word.cueWordIndex, + startMs: word.startMs, + endMs: word.endMs, + text: word.text, + leadingSpace: word.leadingSpace, + })), + }, + visiblePageIndex, + opacity, + translateY: animation.translateY, + scale: animation.scale, + } satisfies ActiveCaptionLayout; +} + +function isFiniteNumber(value: unknown): value is number { + return typeof value === "number" && Number.isFinite(value); +} diff --git a/src/components/video-editor/captionOps.test.ts b/src/components/video-editor/captionOps.test.ts new file mode 100644 index 000000000..deb1f948a --- /dev/null +++ b/src/components/video-editor/captionOps.test.ts @@ -0,0 +1,162 @@ +import { describe, expect, it } from "vitest"; +import { flattenCaptionWords } from "./captionLayout"; +import { deleteCue, mergeCues, retimeCue, splitCue } from "./captionOps"; +import type { CaptionCue } from "./types"; + +function makeCues(): CaptionCue[] { + return [ + { + id: "a", + startMs: 0, + endMs: 2_000, + text: "one two three four", + words: [ + { text: "one", startMs: 0, endMs: 500 }, + { text: "two", startMs: 500, endMs: 1_000, leadingSpace: true }, + { text: "three", startMs: 1_000, endMs: 1_500, leadingSpace: true }, + { text: "four", startMs: 1_500, endMs: 2_000, leadingSpace: true }, + ], + }, + { + id: "b", + startMs: 2_200, + endMs: 3_000, + text: "five six", + words: [ + { text: "five", startMs: 2_200, endMs: 2_600 }, + { text: "six", startMs: 2_600, endMs: 3_000, leadingSpace: true }, + ], + }, + ]; +} + +function assertCuesValid(cues: CaptionCue[]) { + for (let index = 0; index < cues.length - 1; index += 1) { + expect(cues[index].startMs).toBeLessThanOrEqual(cues[index + 1].startMs); + expect(cues[index].endMs).toBeLessThanOrEqual(cues[index + 1].startMs); + } + + const words = flattenCaptionWords(cues); + for (let index = 0; index < words.length; index += 1) { + expect(words[index].endMs).toBeGreaterThan(words[index].startMs); + if (index > 0) { + expect(words[index].startMs).toBeGreaterThanOrEqual(words[index - 1].startMs); + expect(words[index].startMs).toBeGreaterThanOrEqual(words[index - 1].endMs); + } + } +} + +describe("captionOps.retimeCue", () => { + it("extends end and rescales word timings proportionally", () => { + const single: CaptionCue[] = [makeCues()[0]]; + const result = retimeCue(single, "a", { startMs: 0, endMs: 4_000 }); + const cue = result.find((value) => value.id === "a"); + expect(cue?.endMs).toBe(4_000); + expect(cue?.words?.[cue.words.length - 1].endMs).toBe(4_000); + expect(cue?.words?.[1].startMs).toBe(1_000); + expect(cue?.text).toBe("one two three four"); + assertCuesValid(result); + }); + + it("honors requested timing without clamping to neighbors", () => { + const result = retimeCue(makeCues(), "a", { startMs: 1_000, endMs: 2_500 }); + const cue = result.find((value) => value.id === "a"); + const neighbor = result.find((value) => value.id === "b"); + expect(cue?.startMs).toBe(1_000); + expect(cue?.endMs).toBe(2_500); + expect(cue?.words?.[0].startMs).toBe(1_000); + expect(cue?.words?.[cue.words.length - 1].endMs).toBe(2_500); + expect(neighbor?.startMs).toBe(2_200); + }); + + it("returns cue-level timing when the cue has no words", () => { + const cues: CaptionCue[] = [{ id: "a", startMs: 0, endMs: 1_000, text: "hello" }]; + const result = retimeCue(cues, "a", { startMs: 200, endMs: 1_500 }); + expect(result[0].startMs).toBe(200); + expect(result[0].endMs).toBe(1_500); + expect(result[0].words).toBeUndefined(); + }); + + it("keeps words valid and non-overlapping when retimed shorter than its word count", () => { + // Retiming a 4-word cue into a 3ms span can't fit one monotonic 1ms range per word, + // which is where overlapping/reordered word timings appear. The span is widened to + // the minimum viable length so the words stay valid. + const result = retimeCue(makeCues(), "a", { startMs: 0, endMs: 3 }); + const cue = result.find((value) => value.id === "a"); + expect(cue?.words).toHaveLength(4); + assertCuesValid(result); + }); + + it("is a no-op for an unknown id", () => { + const cues = makeCues(); + expect(retimeCue(cues, "missing", { startMs: 0, endMs: 100 })).toBe(cues); + }); +}); + +describe("captionOps.splitCue", () => { + it("splits at the nearest word boundary into two cues", () => { + const result = splitCue(makeCues(), "a", 1_000); + expect(result).toHaveLength(3); + const [left, right] = result; + expect(left.id).toBe("a"); + expect(left.text).toBe("one two"); + expect(right.id).not.toBe("a"); + expect(right.text).toBe("three four"); + expect(right.words?.[0].leadingSpace).toBeUndefined(); + expect(left.endMs).toBeLessThanOrEqual(right.startMs); + assertCuesValid(result); + }); + + it("does not split a single-word cue", () => { + const cues: CaptionCue[] = [ + { + id: "solo", + startMs: 0, + endMs: 500, + text: "word", + words: [{ text: "word", startMs: 0, endMs: 500 }], + }, + ]; + expect(splitCue(cues, "solo", 250)).toBe(cues); + }); +}); + +describe("captionOps.mergeCues", () => { + it("merges adjacent cues and re-derives spacing", () => { + const result = mergeCues(makeCues(), "a", "b"); + expect(result).toHaveLength(1); + const merged = result[0]; + expect(merged.id).toBe("a"); + expect(merged.startMs).toBe(0); + expect(merged.endMs).toBe(3_000); + expect(merged.text).toBe("one two three four five six"); + expect(merged.words?.[4].leadingSpace).toBe(true); + assertCuesValid(result); + }); + + it("rejects a non-adjacent merge", () => { + const cues: CaptionCue[] = [ + ...makeCues(), + { + id: "c", + startMs: 4_000, + endMs: 5_000, + text: "seven", + words: [{ text: "seven", startMs: 4_000, endMs: 5_000 }], + }, + ]; + expect(mergeCues(cues, "a", "c")).toBe(cues); + }); +}); + +describe("captionOps.deleteCue", () => { + it("removes the cue and keeps the rest sorted", () => { + const result = deleteCue(makeCues(), "a"); + expect(result.map((cue) => cue.id)).toEqual(["b"]); + }); + + it("is a no-op for an unknown id", () => { + const cues = makeCues(); + expect(deleteCue(cues, "missing")).toBe(cues); + }); +}); diff --git a/src/components/video-editor/captionOps.ts b/src/components/video-editor/captionOps.ts new file mode 100644 index 000000000..1fc1fe2eb --- /dev/null +++ b/src/components/video-editor/captionOps.ts @@ -0,0 +1,216 @@ +import { + captionWordsToText, + normalizeCaptionWordSpacing, + normalizeCaptionWords, +} from "./captionEditing"; +import type { CaptionCue, CaptionCueWord } from "./types"; + +export interface CaptionRetimeSpan { + startMs: number; + endMs: number; +} + +function sortCaptionCues(cues: CaptionCue[]): CaptionCue[] { + return [...cues].sort( + (left, right) => left.startMs - right.startMs || left.endMs - right.endMs, + ); +} + +function createCaptionCueId(): string { + return `caption-${globalThis.crypto.randomUUID()}`; +} + +/** + * Build a new caption cue with a generated id. Word timings are intentionally + * omitted — the renderer derives placeholder per-word timings from the text + * until the user adds real ones. + */ +export function createCaptionCue(params: { + startMs: number; + endMs: number; + text?: string; +}): CaptionCue { + const startMs = Math.round(params.startMs); + return { + id: createCaptionCueId(), + startMs, + // Preserve the minimum-duration invariant (endMs > startMs) for every caller. + endMs: Math.max(startMs + 1, Math.round(params.endMs)), + text: params.text ?? "", + }; +} + +/** Insert a cue and keep the list ordered, matching the other cue operations. */ +export function addCue(cues: CaptionCue[], cue: CaptionCue): CaptionCue[] { + return sortCaptionCues([...cues, cue]); +} + +function rescaleWordsIntoSpan( + words: CaptionCueWord[], + oldStartMs: number, + oldEndMs: number, + newStartMs: number, + newEndMs: number, +): CaptionCueWord[] { + const oldSpan = oldEndMs - oldStartMs; + const newSpan = newEndMs - newStartMs; + const factor = oldSpan > 0 ? newSpan / oldSpan : 0; + + let cursorMs = newStartMs; + const rescaled: CaptionCueWord[] = []; + + words.forEach((word, index) => { + const mappedStartMs = + oldSpan > 0 + ? newStartMs + (word.startMs - oldStartMs) * factor + : newStartMs + (newSpan * index) / Math.max(1, words.length); + const mappedEndMs = + oldSpan > 0 + ? newStartMs + (word.endMs - oldStartMs) * factor + : newStartMs + (newSpan * (index + 1)) / Math.max(1, words.length); + + const startMs = Math.min(newEndMs - 1, Math.max(cursorMs, Math.round(mappedStartMs))); + const endMs = Math.min(newEndMs, Math.max(startMs + 1, Math.round(mappedEndMs))); + cursorMs = endMs; + + rescaled.push({ + text: word.text, + startMs, + endMs, + ...(word.leadingSpace ? { leadingSpace: true } : {}), + }); + }); + + return normalizeCaptionWordSpacing(rescaled); +} + +export function retimeCue(cues: CaptionCue[], id: string, span: CaptionRetimeSpan): CaptionCue[] { + const sorted = sortCaptionCues(cues); + const index = sorted.findIndex((cue) => cue.id === id); + if (index < 0) { + return cues; + } + + const cue = sorted[index]; + const newStartMs = Math.max(0, Math.round(span.startMs)); + const requestedEndMs = Math.max(newStartMs + 1, Math.round(span.endMs)); + + const words = + Array.isArray(cue.words) && cue.words.length > 0 ? normalizeCaptionWords(cue) : []; + // Each word needs a monotonic, non-overlapping range of at least 1ms, so the span must + // be at least as long as the word count. A shorter span would force later words to pile + // up and overlap/reorder, so widen the end to the minimum viable span in that case. + const newEndMs = + words.length > 0 ? Math.max(requestedEndMs, newStartMs + words.length) : requestedEndMs; + + if (newStartMs === cue.startMs && newEndMs === cue.endMs) { + return cues; + } + + const nextWords = + words.length > 0 + ? rescaleWordsIntoSpan(words, cue.startMs, cue.endMs, newStartMs, newEndMs) + : null; + + const nextCueValue: CaptionCue = { + id: cue.id, + startMs: newStartMs, + endMs: newEndMs, + text: nextWords ? captionWordsToText(nextWords) : cue.text, + ...(nextWords ? { words: nextWords } : {}), + }; + + const nextCues = sorted.map((value, valueIndex) => + valueIndex === index ? nextCueValue : value, + ); + return sortCaptionCues(nextCues); +} + +export function splitCue(cues: CaptionCue[], id: string, atMs: number): CaptionCue[] { + const sorted = sortCaptionCues(cues); + const index = sorted.findIndex((cue) => cue.id === id); + if (index < 0) { + return cues; + } + + const cue = sorted[index]; + const words = normalizeCaptionWords(cue); + if (words.length < 2) { + return cues; + } + + let splitIndex = 1; + let nearestDistance = Number.POSITIVE_INFINITY; + for (let candidate = 1; candidate < words.length; candidate += 1) { + const boundaryMs = (words[candidate - 1].endMs + words[candidate].startMs) / 2; + const distance = Math.abs(boundaryMs - atMs); + if (distance < nearestDistance) { + nearestDistance = distance; + splitIndex = candidate; + } + } + + const leftWords = normalizeCaptionWordSpacing(words.slice(0, splitIndex)); + const rightWords = normalizeCaptionWordSpacing(words.slice(splitIndex)); + + const leftCue: CaptionCue = { + id: cue.id, + startMs: cue.startMs, + endMs: leftWords[leftWords.length - 1].endMs, + text: captionWordsToText(leftWords), + words: leftWords, + }; + const rightCue: CaptionCue = { + id: createCaptionCueId(), + startMs: rightWords[0].startMs, + endMs: cue.endMs, + text: captionWordsToText(rightWords), + words: rightWords, + }; + + const nextCues = sorted.flatMap((value, valueIndex) => + valueIndex === index ? [leftCue, rightCue] : [value], + ); + return sortCaptionCues(nextCues); +} + +export function mergeCues(cues: CaptionCue[], idA: string, idB: string): CaptionCue[] { + const sorted = sortCaptionCues(cues); + const indexA = sorted.findIndex((cue) => cue.id === idA); + const indexB = sorted.findIndex((cue) => cue.id === idB); + if (indexA < 0 || indexB < 0 || Math.abs(indexA - indexB) !== 1) { + return cues; + } + + const leftIndex = Math.min(indexA, indexB); + const left = sorted[leftIndex]; + const right = sorted[leftIndex + 1]; + const mergedWords = normalizeCaptionWordSpacing([ + ...normalizeCaptionWords(left), + ...normalizeCaptionWords(right), + ]); + + const mergedCue: CaptionCue = { + id: left.id, + startMs: left.startMs, + endMs: right.endMs, + text: captionWordsToText(mergedWords), + words: mergedWords, + }; + + const nextCues = sorted.flatMap((value, valueIndex) => { + if (valueIndex === leftIndex) { + return [mergedCue]; + } + if (valueIndex === leftIndex + 1) { + return []; + } + return [value]; + }); + return sortCaptionCues(nextCues); +} + +export function deleteCue(cues: CaptionCue[], id: string): CaptionCue[] { + const next = cues.filter((cue) => cue.id !== id); + return next.length === cues.length ? cues : sortCaptionCues(next); +} diff --git a/src/components/video-editor/captionStyle.ts b/src/components/video-editor/captionStyle.ts new file mode 100644 index 000000000..6a4ed6c6f --- /dev/null +++ b/src/components/video-editor/captionStyle.ts @@ -0,0 +1,57 @@ +import type { CaptionWordState } from "./captionLayout"; +import { DEFAULT_AUTO_CAPTION_SETTINGS } from "./types"; + +export const CAPTION_FONT_WEIGHT = 400; +export const CAPTION_LINE_HEIGHT = 1.32; + +const DEFAULT_CAPTION_REFERENCE_WIDTH = 1920 * (DEFAULT_AUTO_CAPTION_SETTINGS.maxWidth / 100); + +export function getCaptionTargetWidth(containerWidth: number, maxWidthPercent: number) { + return Math.max(1, containerWidth * (maxWidthPercent / 100)); +} + +export function getCaptionScaledFontSize( + fontSize: number, + containerWidth: number, + maxWidthPercent: number, +) { + return Math.max( + 14, + fontSize * + (getCaptionTargetWidth(containerWidth, maxWidthPercent) / + DEFAULT_CAPTION_REFERENCE_WIDTH), + ); +} + +export function getCaptionPadding(fontSize: number) { + return { + x: fontSize * 1.1, + y: fontSize * 0.78, + }; +} + +export function getCaptionScaledRadius(radius: number, fontSize: number) { + const baseline = Math.max(1, DEFAULT_AUTO_CAPTION_SETTINGS.fontSize); + return Math.max(0, radius * (fontSize / baseline)); +} + +export function getCaptionTextMaxWidth( + containerWidth: number, + maxWidthPercent: number, + fontSize: number, +) { + const padding = getCaptionPadding(fontSize); + return Math.max( + fontSize * 4, + getCaptionTargetWidth(containerWidth, maxWidthPercent) - padding.x * 2, + ); +} + +export function getCaptionWordVisualState(_hasWordTimings: boolean, _state: CaptionWordState) { + // Per-word "spoken" highlighting is disabled: word-level timings from the + // transcriber are unreliable, so captions render as a single uniform block. + return { + isInactive: false, + opacity: 1, + }; +} diff --git a/src/components/video-editor/captions/useAutoCaptionController.ts b/src/components/video-editor/captions/useAutoCaptionController.ts new file mode 100644 index 000000000..8ac1c11cf --- /dev/null +++ b/src/components/video-editor/captions/useAutoCaptionController.ts @@ -0,0 +1,243 @@ +import { type Dispatch, type SetStateAction, useCallback, useEffect, useRef } from "react"; +import { toast } from "sonner"; +import { resolveAutoCaptionSourcePath } from "../autoCaptionSource"; +import { type CaptionEditTarget, updateCaptionCuesForEditedTarget } from "../captionEditing"; +import { resolveVideoUrl } from "../projectPersistence"; +import type { AutoCaptionSettings, CaptionCue } from "../types"; +import { getErrorMessage } from "../videoEditorUtils"; + +type DownloadStatus = "idle" | "downloading" | "downloaded" | "error"; +type Translator = ( + key: string, + fallback?: string, + params?: Record<string, string | number>, +) => string; + +interface UseAutoCaptionControllerParams { + t: Translator; + videoPath: string | null; + setVideoPath: Dispatch<SetStateAction<string | null>>; + videoSourcePath: string | null; + setVideoSourcePath: Dispatch<SetStateAction<string | null>>; + webcamSourcePath: string | null; + whisperExecutablePath: string | null; + setWhisperExecutablePath: Dispatch<SetStateAction<string | null>>; + whisperModelPath: string | null; + setWhisperModelPath: Dispatch<SetStateAction<string | null>>; + downloadedWhisperModelPath: string | null; + setDownloadedWhisperModelPath: Dispatch<SetStateAction<string | null>>; + whisperModelDownloadStatus: DownloadStatus; + setWhisperModelDownloadStatus: Dispatch<SetStateAction<DownloadStatus>>; + setWhisperModelDownloadProgress: Dispatch<SetStateAction<number>>; + isGeneratingCaptions: boolean; + setIsGeneratingCaptions: Dispatch<SetStateAction<boolean>>; + autoCaptionSettings: AutoCaptionSettings; + setAutoCaptionSettings: Dispatch<SetStateAction<AutoCaptionSettings>>; + setAutoCaptions: Dispatch<SetStateAction<CaptionCue[]>>; + syncActiveVideoSource: (sourcePath: string, webcamPath?: string | null) => Promise<void>; +} + +export function useAutoCaptionController({ + t, + videoPath, + setVideoPath, + videoSourcePath, + setVideoSourcePath, + webcamSourcePath, + whisperExecutablePath, + setWhisperExecutablePath, + whisperModelPath, + setWhisperModelPath, + downloadedWhisperModelPath, + setDownloadedWhisperModelPath, + whisperModelDownloadStatus, + setWhisperModelDownloadStatus, + setWhisperModelDownloadProgress, + isGeneratingCaptions, + setIsGeneratingCaptions, + autoCaptionSettings, + setAutoCaptionSettings, + setAutoCaptions, + syncActiveVideoSource, +}: UseAutoCaptionControllerParams) { + const captionGenerationInFlightRef = useRef(false); + + useEffect(() => { + const unsubscribe = window.electronAPI.onWhisperSmallModelDownloadProgress((state) => { + setWhisperModelDownloadStatus(state.status); + setWhisperModelDownloadProgress(state.progress); + if (state.status === "downloaded") { + setDownloadedWhisperModelPath(state.path ?? null); + setWhisperModelPath((current) => current ?? state.path ?? null); + } else if (state.status === "idle") { + setDownloadedWhisperModelPath(null); + } else if (state.status === "error" && state.error) { + toast.error(state.error); + } + }); + + void window.electronAPI.getWhisperSmallModelStatus().then((result) => { + if (!result.success) return; + if (result.exists && result.path) { + setDownloadedWhisperModelPath(result.path); + setWhisperModelPath((current) => current ?? result.path ?? null); + setWhisperModelDownloadStatus("downloaded"); + setWhisperModelDownloadProgress(100); + } else { + setDownloadedWhisperModelPath(null); + setWhisperModelDownloadStatus("idle"); + setWhisperModelDownloadProgress(0); + } + }); + + return () => unsubscribe?.(); + }, [ + setDownloadedWhisperModelPath, + setWhisperModelDownloadProgress, + setWhisperModelDownloadStatus, + setWhisperModelPath, + ]); + + const handlePickWhisperExecutable = useCallback(async () => { + const result = await window.electronAPI.openWhisperExecutablePicker(); + if (!result.success || !result.path) return; + setWhisperExecutablePath(result.path); + toast.success("Whisper executable selected"); + }, [setWhisperExecutablePath]); + + const handleDownloadWhisperSmallModel = useCallback(async () => { + if (whisperModelDownloadStatus === "downloading") return; + setWhisperModelDownloadStatus("downloading"); + setWhisperModelDownloadProgress(0); + const result = await window.electronAPI.downloadWhisperSmallModel(); + if (!result.success) { + setWhisperModelDownloadStatus("error"); + toast.error(result.error || "Failed to download Whisper small model"); + return; + } + if (result.path) { + setDownloadedWhisperModelPath(result.path); + setWhisperModelPath(result.path); + } + }, [ + setDownloadedWhisperModelPath, + setWhisperModelDownloadProgress, + setWhisperModelDownloadStatus, + setWhisperModelPath, + whisperModelDownloadStatus, + ]); + + const handlePickWhisperModel = useCallback(async () => { + const result = await window.electronAPI.openWhisperModelPicker(); + if (!result.success || !result.path) return; + setWhisperModelPath(result.path); + toast.success("Whisper model selected"); + }, [setWhisperModelPath]); + + const handleDeleteWhisperSmallModel = useCallback(async () => { + const result = await window.electronAPI.deleteWhisperSmallModel(); + if (!result.success) { + toast.error(result.error || "Failed to delete Whisper small model"); + return; + } + setWhisperModelPath((current) => (current === downloadedWhisperModelPath ? null : current)); + setDownloadedWhisperModelPath(null); + setWhisperModelDownloadStatus("idle"); + setWhisperModelDownloadProgress(0); + toast.success("Whisper small model deleted"); + }, [ + downloadedWhisperModelPath, + setDownloadedWhisperModelPath, + setWhisperModelDownloadProgress, + setWhisperModelDownloadStatus, + setWhisperModelPath, + ]); + + const handleGenerateAutoCaptions = useCallback(async () => { + if (captionGenerationInFlightRef.current || isGeneratingCaptions) return; + captionGenerationInFlightRef.current = true; + setIsGeneratingCaptions(true); + try { + let sourcePath = resolveAutoCaptionSourcePath({ videoSourcePath, videoPath }); + if (!sourcePath) { + const sessionResult = await window.electronAPI.getCurrentRecordingSession?.(); + const currentVideoResult = await window.electronAPI.getCurrentVideoPath(); + sourcePath = resolveAutoCaptionSourcePath({ + recordingSessionVideoPath: + sessionResult?.success && sessionResult.session?.videoPath + ? sessionResult.session.videoPath + : null, + currentVideoPath: currentVideoResult.success + ? (currentVideoResult.path ?? null) + : null, + }); + } + if (!sourcePath) { + toast.error("No source video is loaded"); + return; + } + await syncActiveVideoSource(sourcePath, webcamSourcePath); + if (sourcePath !== videoSourcePath) { + setVideoSourcePath(sourcePath); + setVideoPath(await resolveVideoUrl(sourcePath)); + } + if (!whisperModelPath) { + toast.error("Select a Whisper model or download the small model first"); + return; + } + + const result = await window.electronAPI.generateAutoCaptions({ + videoPath: sourcePath, + whisperExecutablePath: whisperExecutablePath ?? undefined, + whisperModelPath, + language: autoCaptionSettings.language, + }); + if (!result.success || !result.cues) { + const errorMessage = result.error ? getErrorMessage(result.error) : result.message; + toast.error(errorMessage || "Failed to generate captions"); + return; + } + setAutoCaptions(result.cues); + if (result.cues.length > 0) { + setAutoCaptionSettings((current) => ({ ...current, enabled: true })); + } + toast.success(result.message || `Generated ${result.cues.length} captions`); + } catch (error) { + toast.error(getErrorMessage(error)); + } finally { + captionGenerationInFlightRef.current = false; + setIsGeneratingCaptions(false); + } + }, [ + autoCaptionSettings.language, + isGeneratingCaptions, + setAutoCaptionSettings, + setAutoCaptions, + setIsGeneratingCaptions, + setVideoPath, + setVideoSourcePath, + syncActiveVideoSource, + videoPath, + videoSourcePath, + webcamSourcePath, + whisperExecutablePath, + whisperModelPath, + ]); + + const handleSaveAutoCaptionEdit = useCallback( + (target: CaptionEditTarget, text: string) => { + setAutoCaptions((captions) => updateCaptionCuesForEditedTarget(captions, target, text)); + toast.success(t("settings.captions.editSaved", "Caption updated")); + }, + [setAutoCaptions, t], + ); + + return { + handlePickWhisperExecutable, + handleDownloadWhisperSmallModel, + handlePickWhisperModel, + handleDeleteWhisperSmallModel, + handleGenerateAutoCaptions, + handleSaveAutoCaptionEdit, + }; +} diff --git a/src/components/video-editor/clipSpeedChange.test.ts b/src/components/video-editor/clipSpeedChange.test.ts new file mode 100644 index 000000000..bc46b98fc --- /dev/null +++ b/src/components/video-editor/clipSpeedChange.test.ts @@ -0,0 +1,201 @@ +import { describe, expect, it } from "vitest"; + +import { + type BlockedClipSpeedChange, + formatClipSpeedLabel, + planClipSpeedChange, +} from "./clipSpeedChange"; + +describe("formatClipSpeedLabel", () => { + it("returns labels only for non-default positive speeds", () => { + expect(formatClipSpeedLabel(1)).toBeNull(); + expect(formatClipSpeedLabel(0)).toBeNull(); + expect(formatClipSpeedLabel(-1)).toBeNull(); + expect(formatClipSpeedLabel(Number.POSITIVE_INFINITY)).toBeNull(); + expect(formatClipSpeedLabel(Number.NaN)).toBeNull(); + expect(formatClipSpeedLabel(0.5)).toBe("0.5x"); + expect(formatClipSpeedLabel(2)).toBe("2x"); + }); +}); + +describe("planClipSpeedChange", () => { + it("returns null for missing clips and invalid speeds", () => { + const clipRegions = [{ id: "clip-1", startMs: 0, endMs: 5_000, speed: 1 }]; + + expect( + planClipSpeedChange({ + clipRegions, + zoomRegions: [], + selectedClipId: "missing", + speed: 0.5, + }), + ).toBeNull(); + + for (const speed of [0, -1, Number.NaN, Number.POSITIVE_INFINITY]) { + expect( + planClipSpeedChange({ + clipRegions, + zoomRegions: [], + selectedClipId: "clip-1", + speed, + }), + ).toBeNull(); + } + }); + + it("extends an isolated clip when slowing it down", () => { + const result = planClipSpeedChange({ + clipRegions: [{ id: "clip-1", startMs: 0, endMs: 5_000, speed: 1 }], + zoomRegions: [], + selectedClipId: "clip-1", + speed: 0.5, + }); + + expect(result).toEqual({ + clipRegions: [{ id: "clip-1", startMs: 0, endMs: 10_000, speed: 0.5 }], + zoomRegions: [], + }); + }); + + it("shortens an isolated clip when speeding it up", () => { + const result = planClipSpeedChange({ + clipRegions: [{ id: "clip-1", startMs: 0, endMs: 6_000, speed: 1 }], + zoomRegions: [], + selectedClipId: "clip-1", + speed: 2, + }); + + expect(result).toEqual({ + clipRegions: [{ id: "clip-1", startMs: 0, endMs: 3_000, speed: 2 }], + zoomRegions: [], + }); + }); + + it("treats invalid stored clip speed as 1x", () => { + const result = planClipSpeedChange({ + clipRegions: [{ id: "clip-1", startMs: 0, endMs: 4_000, speed: Number.NaN }], + zoomRegions: [], + selectedClipId: "clip-1", + speed: 0.5, + }); + + expect(result).toEqual({ + clipRegions: [{ id: "clip-1", startMs: 0, endMs: 8_000, speed: 0.5 }], + zoomRegions: [], + }); + }); + + it("blocks slow speed changes that would overlap the next clip", () => { + const result = planClipSpeedChange({ + clipRegions: [ + { id: "clip-1", startMs: 0, endMs: 5_000, speed: 1 }, + { id: "clip-2", startMs: 5_000, endMs: 10_000, speed: 1 }, + ], + zoomRegions: [], + selectedClipId: "clip-1", + speed: 0.5, + }) as BlockedClipSpeedChange; + + expect(result.blockedReason).toBe("clip-overlap"); + }); + + it("scales zoom regions inside the changed clip", () => { + const result = planClipSpeedChange({ + clipRegions: [{ id: "clip-1", startMs: 1_000, endMs: 5_000, speed: 1 }], + zoomRegions: [ + { + id: "zoom-1", + startMs: 2_000, + endMs: 3_000, + depth: 2, + focus: { cx: 0.5, cy: 0.5 }, + }, + ], + selectedClipId: "clip-1", + speed: 0.5, + }); + + expect(result).toEqual({ + clipRegions: [{ id: "clip-1", startMs: 1_000, endMs: 9_000, speed: 0.5 }], + zoomRegions: [ + { + id: "zoom-1", + startMs: 3_000, + endMs: 5_000, + depth: 2, + focus: { cx: 0.5, cy: 0.5 }, + }, + ], + }); + }); + + it("does not scale zoom regions that start outside the changed clip", () => { + const result = planClipSpeedChange({ + clipRegions: [{ id: "clip-1", startMs: 2_000, endMs: 6_000, speed: 1 }], + zoomRegions: [ + { + id: "zoom-before", + startMs: 1_000, + endMs: 1_500, + depth: 2, + focus: { cx: 0.5, cy: 0.5 }, + }, + { + id: "zoom-after", + startMs: 6_000, + endMs: 6_500, + depth: 2, + focus: { cx: 0.5, cy: 0.5 }, + }, + ], + selectedClipId: "clip-1", + speed: 0.5, + }); + + expect(result).toEqual({ + clipRegions: [{ id: "clip-1", startMs: 2_000, endMs: 10_000, speed: 0.5 }], + zoomRegions: [ + { + id: "zoom-before", + startMs: 1_000, + endMs: 1_500, + depth: 2, + focus: { cx: 0.5, cy: 0.5 }, + }, + { + id: "zoom-after", + startMs: 6_000, + endMs: 6_500, + depth: 2, + focus: { cx: 0.5, cy: 0.5 }, + }, + ], + }); + }); + + it("blocks speed changes that would make scaled zooms overlap unchanged zooms", () => { + const result = planClipSpeedChange({ + clipRegions: [{ id: "clip-1", startMs: 0, endMs: 5_000, speed: 1 }], + zoomRegions: [ + { + id: "zoom-1", + startMs: 2_000, + endMs: 3_000, + depth: 2, + focus: { cx: 0.5, cy: 0.5 }, + }, + { + id: "zoom-2", + startMs: 5_500, + endMs: 6_500, + depth: 3, + focus: { cx: 0.5, cy: 0.5 }, + }, + ], + selectedClipId: "clip-1", + speed: 0.5, + }) as BlockedClipSpeedChange; + + expect(result.blockedReason).toBe("zoom-overlap"); + }); +}); diff --git a/src/components/video-editor/clipSpeedChange.ts b/src/components/video-editor/clipSpeedChange.ts new file mode 100644 index 000000000..b482c7fe4 --- /dev/null +++ b/src/components/video-editor/clipSpeedChange.ts @@ -0,0 +1,97 @@ +import type { ClipRegion, ZoomRegion } from "./types"; + +export type ClipSpeedChangeBlockReason = "clip-overlap" | "zoom-overlap"; + +export interface ClipSpeedChangePlan { + clipRegions: ClipRegion[]; + zoomRegions: ZoomRegion[]; +} + +export interface BlockedClipSpeedChange { + blockedReason: ClipSpeedChangeBlockReason; +} + +export function formatClipSpeedLabel(speed: number): string | null { + if (!Number.isFinite(speed) || speed <= 0 || speed === 1) { + return null; + } + + return `${Number.isInteger(speed) ? speed.toFixed(0) : speed.toString()}x`; +} + +function spansOverlap( + left: { startMs: number; endMs: number }, + right: { startMs: number; endMs: number }, +): boolean { + return left.startMs < right.endMs && left.endMs > right.startMs; +} + +export function planClipSpeedChange(params: { + clipRegions: ClipRegion[]; + zoomRegions: ZoomRegion[]; + selectedClipId: string; + speed: number; +}): ClipSpeedChangePlan | BlockedClipSpeedChange | null { + const { clipRegions, zoomRegions, selectedClipId, speed } = params; + if (!selectedClipId || !Number.isFinite(speed) || speed <= 0) { + return null; + } + + const clip = clipRegions.find((candidate) => candidate.id === selectedClipId); + if (!clip) { + return null; + } + + const oldSpeed = Number.isFinite(clip.speed) && clip.speed > 0 ? clip.speed : 1; + const sourceDurationMs = Math.max(0, clip.endMs - clip.startMs) * oldSpeed; + const newEndMs = Math.round(clip.startMs + sourceDurationMs / speed); + const nextClip = clipRegions + .filter((candidate) => candidate.id !== selectedClipId && candidate.startMs >= clip.endMs) + .sort((left, right) => left.startMs - right.startMs)[0]; + + if (nextClip && newEndMs > nextClip.startMs) { + return { blockedReason: "clip-overlap" }; + } + + const scaleFactor = oldSpeed / speed; + const nextZoomRegions = zoomRegions.map((zoom) => { + if (zoom.startMs < clip.startMs || zoom.startMs >= clip.endMs) { + return zoom; + } + + return { + ...zoom, + startMs: Math.round(clip.startMs + (zoom.startMs - clip.startMs) * scaleFactor), + endMs: Math.round(clip.startMs + (zoom.endMs - clip.startMs) * scaleFactor), + }; + }); + + const changedZoomIds = new Set( + nextZoomRegions + .filter((zoom, index) => { + const previous = zoomRegions[index]; + return previous.startMs !== zoom.startMs || previous.endMs !== zoom.endMs; + }) + .map((zoom) => zoom.id), + ); + + const hasZoomOverlap = nextZoomRegions.some((zoom, index) => + nextZoomRegions.some( + (other, otherIndex) => + index !== otherIndex && + (changedZoomIds.has(zoom.id) || changedZoomIds.has(other.id)) && + spansOverlap(zoom, other), + ), + ); + + if (hasZoomOverlap) { + return { blockedReason: "zoom-overlap" }; + } + + return { + clipRegions: clipRegions.map((candidate) => + candidate.id === selectedClipId ? { ...candidate, speed, endMs: newEndMs } : candidate, + ), + zoomRegions: nextZoomRegions, + }; +} diff --git a/src/components/video-editor/cursorMotionPresets.ts b/src/components/video-editor/cursorMotionPresets.ts new file mode 100644 index 000000000..57283c2a4 --- /dev/null +++ b/src/components/video-editor/cursorMotionPresets.ts @@ -0,0 +1,91 @@ +import { DEFAULT_ZOOM_IN_DURATION_MS, DEFAULT_ZOOM_OUT_DURATION_MS } from "./types"; + +export type CursorMotionPresetId = "focused" | "smooth"; + +export interface CursorMotionPreset { + id: CursorMotionPresetId; + label: string; + zoomSmoothness: number; + zoomInDurationMs: number; + zoomOutDurationMs: number; + cursorSize: number; + cursorSmoothing: number; + cursorSpringStiffnessMultiplier: number; + cursorSpringDampingMultiplier: number; + cursorSpringMassMultiplier: number; + cursorClickBounce: number; + cursorClickBounceDuration: number; +} + +export interface CursorMotionPresetSelectionInput { + zoomInDurationMs: number; + zoomOutDurationMs: number; + cursorSize: number; + cursorSmoothing: number; + cursorSpringStiffnessMultiplier: number; + cursorSpringDampingMultiplier: number; + cursorSpringMassMultiplier: number; + cursorClickBounce: number; + cursorClickBounceDuration: number; +} + +const SHARED_CURSOR_PRESET_VALUES = { + cursorSize: 2.5, + cursorSmoothing: 0.67, + cursorSpringMassMultiplier: 1.29, + cursorClickBounce: 2, + cursorClickBounceDuration: 350, +} as const; + +export const CURSOR_MOTION_PRESETS: Record<CursorMotionPresetId, CursorMotionPreset> = { + focused: { + id: "focused", + label: "Focused", + zoomSmoothness: 0.5, + zoomInDurationMs: 200, + zoomOutDurationMs: 200, + ...SHARED_CURSOR_PRESET_VALUES, + cursorSpringStiffnessMultiplier: 1.35, + cursorSpringDampingMultiplier: 0.79, + }, + smooth: { + id: "smooth", + label: "Smooth", + zoomSmoothness: 0.5, + zoomInDurationMs: DEFAULT_ZOOM_IN_DURATION_MS, + zoomOutDurationMs: DEFAULT_ZOOM_OUT_DURATION_MS, + ...SHARED_CURSOR_PRESET_VALUES, + cursorSpringStiffnessMultiplier: 0.92, + cursorSpringDampingMultiplier: 1.36, + }, +}; + +export function getMatchingCursorMotionPresetId( + values: CursorMotionPresetSelectionInput, +): CursorMotionPresetId | null { + for (const presetId of Object.keys(CURSOR_MOTION_PRESETS) as CursorMotionPresetId[]) { + const preset = CURSOR_MOTION_PRESETS[presetId]; + if ( + preset.zoomInDurationMs === values.zoomInDurationMs && + preset.zoomOutDurationMs === values.zoomOutDurationMs && + preset.cursorSize === values.cursorSize && + preset.cursorSmoothing === values.cursorSmoothing && + preset.cursorSpringStiffnessMultiplier === values.cursorSpringStiffnessMultiplier && + preset.cursorSpringDampingMultiplier === values.cursorSpringDampingMultiplier && + preset.cursorSpringMassMultiplier === values.cursorSpringMassMultiplier && + preset.cursorClickBounce === values.cursorClickBounce && + preset.cursorClickBounceDuration === values.cursorClickBounceDuration + ) { + return presetId; + } + } + + return null; +} + +export function resolveCursorMotionPresetId( + values: CursorMotionPresetSelectionInput, + fallback: CursorMotionPresetId = "focused", +): CursorMotionPresetId { + return getMatchingCursorMotionPresetId(values) ?? fallback; +} diff --git a/src/components/video-editor/editorHistory.test.ts b/src/components/video-editor/editorHistory.test.ts new file mode 100644 index 000000000..858a4b3b9 --- /dev/null +++ b/src/components/video-editor/editorHistory.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it } from "vitest"; + +import { + areEditorHistorySnapshotsEqual, + cloneEditorHistorySnapshot, + createEditorHistoryStack, + type EditorHistorySnapshot, + recordEditorHistorySnapshot, + redoEditorHistoryStack, + resetEditorHistoryStack, + undoEditorHistoryStack, +} from "./editorHistory"; + +function createSnapshot(id: string | null): EditorHistorySnapshot { + return { + zoomRegions: [], + clipRegions: [], + speedRegions: [], + annotationRegions: [], + audioRegions: [], + autoCaptions: [], + selectedZoomId: id, + selectedClipId: id ? `clip-${id}` : null, + selectedAnnotationId: null, + selectedAudioId: null, + }; +} + +describe("editorHistory", () => { + it("initializes without adding undo history", () => { + const stack = createEditorHistoryStack(); + const snapshot = createSnapshot("first"); + + expect(recordEditorHistorySnapshot(stack, snapshot)).toBe("initialized"); + + expect(stack.current).toEqual(snapshot); + expect(stack.past).toEqual([]); + expect(stack.future).toEqual([]); + }); + + it("does not record unchanged snapshots", () => { + const stack = createEditorHistoryStack(); + const snapshot = createSnapshot("same"); + + recordEditorHistorySnapshot(stack, snapshot); + expect(recordEditorHistorySnapshot(stack, createSnapshot("same"))).toBe("unchanged"); + + expect(stack.past).toEqual([]); + expect(stack.future).toEqual([]); + }); + + it("records changes and clears redo history", () => { + const stack = createEditorHistoryStack(); + + recordEditorHistorySnapshot(stack, createSnapshot("first")); + recordEditorHistorySnapshot(stack, createSnapshot("second")); + stack.future.push(createSnapshot("future")); + + expect(recordEditorHistorySnapshot(stack, createSnapshot("third"))).toBe("recorded"); + + expect(stack.past.map((snapshot) => snapshot.selectedZoomId)).toEqual(["first", "second"]); + expect(stack.current?.selectedZoomId).toBe("third"); + expect(stack.future).toEqual([]); + }); + + it("moves snapshots through undo and redo stacks", () => { + const stack = createEditorHistoryStack(); + const first = createSnapshot("first"); + const second = createSnapshot("second"); + + recordEditorHistorySnapshot(stack, first); + recordEditorHistorySnapshot(stack, second); + + expect(undoEditorHistoryStack(stack, second)?.selectedZoomId).toBe("first"); + expect(stack.current?.selectedZoomId).toBe("first"); + expect(stack.future.map((snapshot) => snapshot.selectedZoomId)).toEqual(["second"]); + + expect(redoEditorHistoryStack(stack, first)?.selectedZoomId).toBe("second"); + expect(stack.current?.selectedZoomId).toBe("second"); + expect(stack.past.map((snapshot) => snapshot.selectedZoomId)).toEqual(["first"]); + }); + + it("updates the current snapshot while applying history without recording a new entry", () => { + const stack = createEditorHistoryStack(); + + recordEditorHistorySnapshot(stack, createSnapshot("first")); + expect( + recordEditorHistorySnapshot(stack, createSnapshot("applied"), { + applyingHistory: true, + }), + ).toBe("applied"); + + expect(stack.current?.selectedZoomId).toBe("applied"); + expect(stack.past).toEqual([]); + }); + + it("caps the past stack at the configured history depth", () => { + const stack = createEditorHistoryStack(); + + recordEditorHistorySnapshot(stack, createSnapshot("0")); + recordEditorHistorySnapshot(stack, createSnapshot("1"), { maxEntries: 2 }); + recordEditorHistorySnapshot(stack, createSnapshot("2"), { maxEntries: 2 }); + recordEditorHistorySnapshot(stack, createSnapshot("3"), { maxEntries: 2 }); + + expect(stack.past.map((snapshot) => snapshot.selectedZoomId)).toEqual(["1", "2"]); + }); + + it("resets all history stacks", () => { + const stack = createEditorHistoryStack(); + + recordEditorHistorySnapshot(stack, createSnapshot("first")); + recordEditorHistorySnapshot(stack, createSnapshot("second")); + undoEditorHistoryStack(stack, createSnapshot("second")); + resetEditorHistoryStack(stack); + + expect(stack).toEqual(createEditorHistoryStack()); + }); + + it("clones snapshots before storing them", () => { + const snapshot = createSnapshot("first"); + const cloned = cloneEditorHistorySnapshot(snapshot); + + cloned.selectedZoomId = "changed"; + + expect(snapshot.selectedZoomId).toBe("first"); + expect(areEditorHistorySnapshotsEqual(snapshot, createSnapshot("first"))).toBe(true); + }); +}); diff --git a/src/components/video-editor/editorHistory.ts b/src/components/video-editor/editorHistory.ts new file mode 100644 index 000000000..6681f4ec2 --- /dev/null +++ b/src/components/video-editor/editorHistory.ts @@ -0,0 +1,159 @@ +import type { + AnnotationRegion, + AudioRegion, + CaptionCue, + ClipRegion, + SpeedRegion, + ZoomRegion, +} from "./types"; + +export type EditorHistorySnapshot = { + zoomRegions: ZoomRegion[]; + clipRegions: ClipRegion[]; + speedRegions: SpeedRegion[]; + annotationRegions: AnnotationRegion[]; + audioRegions: AudioRegion[]; + autoCaptions: CaptionCue[]; + selectedZoomId: string | null; + selectedClipId: string | null; + selectedAnnotationId: string | null; + selectedAudioId: string | null; +}; + +export type EditorHistoryStack = { + past: EditorHistorySnapshot[]; + current: EditorHistorySnapshot | null; + future: EditorHistorySnapshot[]; +}; + +export type EditorHistoryRecordResult = "initialized" | "applied" | "recorded" | "unchanged"; + +export const MAX_EDITOR_HISTORY_ENTRIES = 100; + +export function createEditorHistoryStack(): EditorHistoryStack { + return { + past: [], + current: null, + future: [], + }; +} + +export function resetEditorHistoryStack(stack: EditorHistoryStack): void { + stack.past = []; + stack.current = null; + stack.future = []; +} + +export function cloneEditorHistorySnapshot(snapshot: EditorHistorySnapshot): EditorHistorySnapshot { + return globalThis.structuredClone(snapshot); +} + +function isComparableObject(value: unknown): value is Record<string, unknown> { + return typeof value === "object" && value !== null; +} + +function areDeepEqual(left: unknown, right: unknown): boolean { + if (Object.is(left, right)) { + return true; + } + + if (Array.isArray(left) || Array.isArray(right)) { + if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) { + return false; + } + + return left.every((value, index) => areDeepEqual(value, right[index])); + } + + if (!isComparableObject(left) || !isComparableObject(right)) { + return false; + } + + const leftKeys = Object.keys(left); + const rightKeys = Object.keys(right); + if (leftKeys.length !== rightKeys.length) { + return false; + } + + return leftKeys.every((key) => key in right && areDeepEqual(left[key], right[key])); +} + +export function areEditorHistorySnapshotsEqual( + left: EditorHistorySnapshot, + right: EditorHistorySnapshot, +): boolean { + return areDeepEqual(left, right); +} + +export function recordEditorHistorySnapshot( + stack: EditorHistoryStack, + snapshot: EditorHistorySnapshot, + options: { + applyingHistory?: boolean; + maxEntries?: number; + } = {}, +): EditorHistoryRecordResult { + const clonedSnapshot = cloneEditorHistorySnapshot(snapshot); + + if (!stack.current) { + stack.current = clonedSnapshot; + return "initialized"; + } + + if (options.applyingHistory) { + stack.current = clonedSnapshot; + return "applied"; + } + + if (areEditorHistorySnapshotsEqual(stack.current, snapshot)) { + return "unchanged"; + } + + stack.past.push(cloneEditorHistorySnapshot(stack.current)); + const maxEntries = options.maxEntries ?? MAX_EDITOR_HISTORY_ENTRIES; + if (stack.past.length > maxEntries) { + stack.past.shift(); + } + + stack.current = clonedSnapshot; + stack.future = []; + return "recorded"; +} + +export function undoEditorHistoryStack( + stack: EditorHistoryStack, + fallbackCurrent: EditorHistorySnapshot, +): EditorHistorySnapshot | null { + if (stack.past.length === 0) { + return null; + } + + const current = stack.current ?? cloneEditorHistorySnapshot(fallbackCurrent); + const previous = stack.past.pop(); + if (!previous) { + return null; + } + + stack.future.push(cloneEditorHistorySnapshot(current)); + stack.current = cloneEditorHistorySnapshot(previous); + return cloneEditorHistorySnapshot(previous); +} + +export function redoEditorHistoryStack( + stack: EditorHistoryStack, + fallbackCurrent: EditorHistorySnapshot, +): EditorHistorySnapshot | null { + if (stack.future.length === 0) { + return null; + } + + const current = stack.current ?? cloneEditorHistorySnapshot(fallbackCurrent); + const next = stack.future.pop(); + if (!next) { + return null; + } + + stack.past.push(cloneEditorHistorySnapshot(current)); + stack.current = cloneEditorHistorySnapshot(next); + return cloneEditorHistorySnapshot(next); +} diff --git a/src/components/video-editor/editorPreferences.test.ts b/src/components/video-editor/editorPreferences.test.ts new file mode 100644 index 000000000..497e5e498 --- /dev/null +++ b/src/components/video-editor/editorPreferences.test.ts @@ -0,0 +1,480 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + DEFAULT_EDITOR_PREFERENCES, + EDITOR_PREFERENCES_STORAGE_KEY, + EDITOR_PRESETS_STORAGE_KEY, + loadEditorPreferences, + loadEditorPresets, + normalizeEditorPreferences, + saveEditorPreferences, + saveEditorPresets, +} from "./editorPreferences"; +import { DEFAULT_AUTO_CAPTION_SETTINGS, DEFAULT_CROP_REGION } from "./types"; + +function createStorageMock(initialValues: Record<string, string> = {}): Storage { + const store = new Map(Object.entries(initialValues)); + + return { + get length() { + return store.size; + }, + clear() { + store.clear(); + }, + getItem(key) { + return store.get(key) ?? null; + }, + key(index) { + return Array.from(store.keys())[index] ?? null; + }, + removeItem(key) { + store.delete(key); + }, + setItem(key, value) { + store.set(key, value); + }, + }; +} + +function stubElectronSettings(initialValues: Record<string, unknown> = {}) { + const store = new Map(Object.entries(initialValues)); + + Object.defineProperty(globalThis, "electronAPI", { + configurable: true, + value: { + getAppSetting: (key: string) => (store.has(key) ? store.get(key) : null), + setAppSetting: (key: string, value: unknown) => { + store.set(key, value); + return true; + }, + } as Pick<Window["electronAPI"], "getAppSetting" | "setAppSetting">, + }); + + return store; +} + +describe("editorPreferences", () => { + afterEach(() => { + vi.unstubAllGlobals(); + Reflect.deleteProperty(globalThis, "electronAPI"); + }); + + it("normalizes invalid values back to safe defaults", () => { + expect( + normalizeEditorPreferences({ + wallpaper: 123, + showCursor: "yes", + cropRegion: { x: 2, width: -1 }, + aspectRatio: "bad-value", + customAspectWidth: "0", + customAspectHeight: "", + customWallpapers: "not-an-array", + }), + ).toMatchObject({ + wallpaper: DEFAULT_EDITOR_PREFERENCES.wallpaper, + showCursor: DEFAULT_EDITOR_PREFERENCES.showCursor, + aspectRatio: DEFAULT_EDITOR_PREFERENCES.aspectRatio, + cursorStyle: DEFAULT_EDITOR_PREFERENCES.cursorStyle, + cursorSize: DEFAULT_EDITOR_PREFERENCES.cursorSize, + customAspectWidth: DEFAULT_EDITOR_PREFERENCES.customAspectWidth, + customAspectHeight: DEFAULT_EDITOR_PREFERENCES.customAspectHeight, + customWallpapers: DEFAULT_EDITOR_PREFERENCES.customWallpapers, + }); + }); + + it("defaults MP4 exports to source quality", () => { + expect(DEFAULT_EDITOR_PREFERENCES.exportQuality).toBe("source"); + }); + + it("defaults cursor preferences to Tahoe at 2.5x with gentler sway", () => { + expect(DEFAULT_EDITOR_PREFERENCES.cursorStyle).toBe("tahoe"); + expect(DEFAULT_EDITOR_PREFERENCES.cursorSize).toBe(2.5); + expect(DEFAULT_EDITOR_PREFERENCES.cursorSway).toBe(0.4); + }); + + it("defaults MP4 exports to the Lightning pipeline", () => { + expect(DEFAULT_EDITOR_PREFERENCES.exportPipelineModel).toBe("modern"); + }); + + it("migrates hidden legacy pipeline preferences to Lightning", () => { + expect(normalizeEditorPreferences({ exportPipelineModel: "legacy" })).toMatchObject({ + exportPipelineModel: "modern", + }); + }); + + it("bakes in the stronger split motion blur defaults", () => { + expect(DEFAULT_EDITOR_PREFERENCES.zoomMotionBlurTuning).toMatchObject({ + panVelocityThreshold: 0, + zoomVelocityThreshold: 0, + maxDirectionalBlurPx: 41.8, + maxRadialBlurStrength: 1, + }); + }); + + it("ignores legacy persisted dev-only motion blur overrides", () => { + vi.stubGlobal( + "localStorage", + createStorageMock({ + [EDITOR_PREFERENCES_STORAGE_KEY]: JSON.stringify({ + zoomMotionBlurTuning: { + panVelocityThreshold: 40, + zoomVelocityThreshold: 0.2, + maxDirectionalBlurPx: 96, + maxRadialBlurStrength: 1.5, + panResponsePerSecond: 22, + zoomResponsePerSecond: 18, + zoomSafeZoneRadiusPx: 40, + }, + }), + }), + ); + + const loaded = loadEditorPreferences(); + + expect(loaded.zoomMotionBlurTuning).toEqual( + DEFAULT_EDITOR_PREFERENCES.zoomMotionBlurTuning, + ); + }); + + it("does not save dev-only split blur tuning overrides to editor preferences", () => { + const localStorage = createStorageMock(); + vi.stubGlobal("localStorage", localStorage); + + saveEditorPreferences({ + zoomMotionBlurTuning: { + ...DEFAULT_EDITOR_PREFERENCES.zoomMotionBlurTuning, + maxDirectionalBlurPx: 96, + maxRadialBlurStrength: 1.5, + }, + }); + + const stored = JSON.parse( + localStorage.getItem(EDITOR_PREFERENCES_STORAGE_KEY) ?? "{}", + ) as Record<string, unknown>; + + expect(stored).not.toHaveProperty("zoomMotionBlurTuning"); + }); + + it("loads stored editor control preferences", () => { + vi.stubGlobal( + "localStorage", + createStorageMock({ + [EDITOR_PREFERENCES_STORAGE_KEY]: JSON.stringify({ + wallpaper: "#123456", + backgroundBlur: 3.5, + showCursor: false, + cropRegion: { x: 0.1, y: 0.2, width: 0.7, height: 0.6 }, + aspectRatio: "native", + exportFormat: "gif", + gifFrameRate: 30, + gifLoop: false, + customAspectWidth: "21", + customAspectHeight: "9", + customWallpapers: ["data:image/jpeg;base64,abc"], + }), + }), + ); + + expect(loadEditorPreferences()).toEqual({ + ...DEFAULT_EDITOR_PREFERENCES, + wallpaper: "#123456", + backgroundBlur: 3.5, + showCursor: false, + aspectRatio: "native", + zoomInOverlapMs: 200, + exportFormat: "gif", + gifFrameRate: 30, + gifLoop: false, + customAspectWidth: "21", + customAspectHeight: "9", + customWallpapers: ["data:image/jpeg;base64,abc"], + }); + }); + + it("preserves a stored wallpaper choice on startup", () => { + vi.stubGlobal( + "localStorage", + createStorageMock({ + [EDITOR_PREFERENCES_STORAGE_KEY]: JSON.stringify({ + wallpaper: "/wallpapers/wallpaper1.jpg", + }), + }), + ); + + expect(loadEditorPreferences().wallpaper).toBe("/wallpapers/wallpaper1.jpg"); + }); + + it("preserves the last valid custom aspect inputs while typing", () => { + const localStorage = createStorageMock({ + [EDITOR_PREFERENCES_STORAGE_KEY]: JSON.stringify({ + aspectRatio: "16:9", + customAspectWidth: "21", + customAspectHeight: "9", + }), + }); + vi.stubGlobal("localStorage", localStorage); + + saveEditorPreferences({ customAspectWidth: "", customAspectHeight: "abc" }); + + expect(loadEditorPreferences()).toEqual({ + ...DEFAULT_EDITOR_PREFERENCES, + aspectRatio: "16:9", + zoomInOverlapMs: 200, + customAspectWidth: "21", + customAspectHeight: "9", + }); + }); + + it("preserves custom Whisper paths from stored preferences", () => { + vi.stubGlobal( + "localStorage", + createStorageMock({ + [EDITOR_PREFERENCES_STORAGE_KEY]: JSON.stringify({ + whisperExecutablePath: "/usr/local/bin/whisper-cli", + whisperModelPath: "/Users/test/models/ggml-base.bin", + }), + }), + ); + + expect(loadEditorPreferences()).toMatchObject({ + whisperExecutablePath: "/usr/local/bin/whisper-cli", + whisperModelPath: "/Users/test/models/ggml-base.bin", + }); + }); + + it("saves all editor controls with normalization", () => { + const localStorage = createStorageMock(); + vi.stubGlobal("localStorage", localStorage); + + saveEditorPreferences({ + wallpaper: "linear-gradient(to right, #000000, #ffffff)", + shadowIntensity: 0.4, + backgroundBlur: 1.5, + zoomMotionBlur: 0.75, + connectZooms: false, + zoomInDurationMs: DEFAULT_EDITOR_PREFERENCES.zoomInDurationMs, + zoomInOverlapMs: DEFAULT_EDITOR_PREFERENCES.zoomInOverlapMs, + zoomOutDurationMs: DEFAULT_EDITOR_PREFERENCES.zoomOutDurationMs, + connectedZoomGapMs: DEFAULT_EDITOR_PREFERENCES.connectedZoomGapMs, + connectedZoomDurationMs: DEFAULT_EDITOR_PREFERENCES.connectedZoomDurationMs, + zoomInEasing: DEFAULT_EDITOR_PREFERENCES.zoomInEasing, + zoomOutEasing: DEFAULT_EDITOR_PREFERENCES.zoomOutEasing, + connectedZoomEasing: DEFAULT_EDITOR_PREFERENCES.connectedZoomEasing, + showCursor: false, + loopCursor: true, + cursorStyle: "figma", + cursorSize: 3, + cursorSmoothing: 1.25, + cursorMotionBlur: 0.5, + cursorClickBounce: 2.25, + cursorClickBounceDuration: 350, + cursorSway: 1.5, + borderRadius: 18, + padding: { top: 30, right: 30, bottom: 30, left: 30, linked: true }, + aspectRatio: "4:5", + exportEncodingMode: "quality", + exportBackendPreference: DEFAULT_EDITOR_PREFERENCES.exportBackendPreference, + exportPipelineModel: DEFAULT_EDITOR_PREFERENCES.exportPipelineModel, + exportQuality: "source", + mp4FrameRate: DEFAULT_EDITOR_PREFERENCES.mp4FrameRate, + exportFormat: "gif", + gifFrameRate: 20, + gifLoop: false, + gifSizePreset: "large", + customAspectWidth: "4", + customAspectHeight: "5", + customWallpapers: ["data:image/jpeg;base64,abc", "data:image/jpeg;base64,abc"], + autoApplyFreshRecordingAutoZooms: false, + }); + + expect(loadEditorPreferences()).toMatchObject({ + wallpaper: "linear-gradient(to right, #000000, #ffffff)", + shadowIntensity: 0.4, + backgroundBlur: 1.5, + zoomMotionBlur: 0.75, + connectZooms: false, + zoomInOverlapMs: 200, + showCursor: false, + loopCursor: true, + cursorStyle: "figma", + cursorSize: DEFAULT_EDITOR_PREFERENCES.cursorSize, + cursorSmoothing: DEFAULT_EDITOR_PREFERENCES.cursorSmoothing, + cursorMotionBlur: DEFAULT_EDITOR_PREFERENCES.cursorMotionBlur, + cursorClickBounce: DEFAULT_EDITOR_PREFERENCES.cursorClickBounce, + cursorClickBounceDuration: DEFAULT_EDITOR_PREFERENCES.cursorClickBounceDuration, + cursorSway: 1.5, + borderRadius: 18, + padding: { top: 30, right: 30, bottom: 30, left: 30, linked: true }, + aspectRatio: "4:5", + exportEncodingMode: "quality", + exportFormat: "gif", + gifFrameRate: 20, + gifLoop: false, + gifSizePreset: "large", + customAspectWidth: "4", + customAspectHeight: "5", + customWallpapers: ["data:image/jpeg;base64,abc"], + autoApplyFreshRecordingAutoZooms: false, + }); + }); + + it("saves custom Whisper paths", () => { + const localStorage = createStorageMock(); + vi.stubGlobal("localStorage", localStorage); + + saveEditorPreferences({ + whisperExecutablePath: "/opt/homebrew/bin/whisper-cli", + whisperModelPath: "/Users/test/models/ggml-small.bin", + }); + + expect(loadEditorPreferences()).toMatchObject({ + whisperExecutablePath: "/opt/homebrew/bin/whisper-cli", + whisperModelPath: "/Users/test/models/ggml-small.bin", + }); + }); + + it("loads editor preferences from Electron app settings when available", () => { + stubElectronSettings({ + [EDITOR_PREFERENCES_STORAGE_KEY]: { + wallpaper: "#0f172a", + showCursor: false, + customAspectWidth: "3", + customAspectHeight: "2", + }, + }); + + expect(loadEditorPreferences()).toMatchObject({ + wallpaper: "#0f172a", + showCursor: false, + customAspectWidth: "3", + customAspectHeight: "2", + }); + }); + + it("saves editor presets and reports success", () => { + const localStorage = createStorageMock(); + vi.stubGlobal("localStorage", localStorage); + + expect( + saveEditorPresets([ + { + id: "preset-1", + name: " Demo Preset ", + createdAt: "2026-05-01T00:00:00.000Z", + updatedAt: "2026-05-01T00:00:00.000Z", + snapshot: { + ...DEFAULT_EDITOR_PREFERENCES, + cropRegion: DEFAULT_CROP_REGION, + autoCaptionSettings: DEFAULT_AUTO_CAPTION_SETTINGS, + }, + }, + ]), + ).toBe(true); + + expect(localStorage.getItem(EDITOR_PRESETS_STORAGE_KEY)).not.toBeNull(); + expect(loadEditorPresets()).toMatchObject([ + { + id: "preset-1", + name: "Demo Preset", + }, + ]); + }); + + it("saves editor presets to Electron app settings when available", () => { + const settingsStore = stubElectronSettings(); + + expect( + saveEditorPresets([ + { + id: "preset-1", + name: "Demo Preset", + createdAt: "2026-05-01T00:00:00.000Z", + updatedAt: "2026-05-02T00:00:00.000Z", + snapshot: { + ...DEFAULT_EDITOR_PREFERENCES, + cropRegion: DEFAULT_CROP_REGION, + autoCaptionSettings: DEFAULT_AUTO_CAPTION_SETTINGS, + }, + }, + ]), + ).toBe(true); + + expect(settingsStore.get(EDITOR_PRESETS_STORAGE_KEY)).toMatchObject([ + { + id: "preset-1", + name: "Demo Preset", + }, + ]); + }); + + it("does not let localStorage failures mask Electron preset persistence", () => { + const settingsStore = stubElectronSettings(); + const localStorage = createStorageMock(); + localStorage.setItem = () => { + throw new Error("localStorage unavailable"); + }; + vi.stubGlobal("localStorage", localStorage); + + expect( + saveEditorPresets([ + { + id: "preset-1", + name: "Demo Preset", + createdAt: "2026-05-01T00:00:00.000Z", + updatedAt: "2026-05-02T00:00:00.000Z", + snapshot: { + ...DEFAULT_EDITOR_PREFERENCES, + cropRegion: DEFAULT_CROP_REGION, + autoCaptionSettings: DEFAULT_AUTO_CAPTION_SETTINGS, + }, + }, + ]), + ).toBe(true); + + expect(settingsStore.get(EDITOR_PRESETS_STORAGE_KEY)).toMatchObject([ + { + id: "preset-1", + name: "Demo Preset", + }, + ]); + }); + + it("preserves crop region in editor preset snapshots", () => { + const localStorage = createStorageMock(); + vi.stubGlobal("localStorage", localStorage); + + expect( + saveEditorPresets([ + { + id: "preset-1", + name: "Cropped Demo", + createdAt: "2026-05-01T00:00:00.000Z", + updatedAt: "2026-05-01T00:00:00.000Z", + snapshot: { + ...DEFAULT_EDITOR_PREFERENCES, + cropRegion: { x: 0.08, y: 0.12, width: 0.8, height: 0.7 }, + autoCaptionSettings: DEFAULT_AUTO_CAPTION_SETTINGS, + }, + }, + ]), + ).toBe(true); + + expect(loadEditorPresets()[0]?.snapshot.cropRegion).toEqual({ + x: 0.08, + y: 0.12, + width: 0.8, + height: 0.7, + }); + }); + + it("returns false when preset persistence fails", () => { + const localStorage = createStorageMock(); + localStorage.setItem = () => { + throw new Error("quota exceeded"); + }; + vi.stubGlobal("localStorage", localStorage); + + expect(saveEditorPresets([])).toBe(false); + }); +}); diff --git a/src/components/video-editor/editorPreferences.ts b/src/components/video-editor/editorPreferences.ts new file mode 100644 index 000000000..b087cc88a --- /dev/null +++ b/src/components/video-editor/editorPreferences.ts @@ -0,0 +1,528 @@ +import { loadAppSetting, saveAppSetting } from "../../lib/appSettings"; +import { + normalizeExportBackendPreference, + normalizeExportMp4FrameRate, + normalizeExportPipelineModel, + normalizeProjectEditor, + type ProjectEditorState, + stripPersistedDevMotionBlurSettings, +} from "./projectPersistence"; + +type PersistedEditorControls = Pick< + ProjectEditorState, + | "wallpaper" + | "shadowIntensity" + | "backgroundBlur" + | "zoomMotionBlur" + | "zoomMotionBlurTuning" + | "zoomTemporalMotionBlur" + | "zoomMotionBlurSampleCount" + | "zoomMotionBlurShutterFraction" + | "connectZooms" + | "zoomInDurationMs" + | "zoomInOverlapMs" + | "zoomOutDurationMs" + | "connectedZoomGapMs" + | "connectedZoomDurationMs" + | "zoomInEasing" + | "zoomOutEasing" + | "connectedZoomEasing" + | "showCursor" + | "loopCursor" + | "cursorStyle" + | "cursorSize" + | "cursorSmoothing" + | "cursorSpringStiffnessMultiplier" + | "cursorSpringDampingMultiplier" + | "cursorSpringMassMultiplier" + | "cameraSpringStiffnessMultiplier" + | "cameraSpringDampingMultiplier" + | "cameraSpringMassMultiplier" + | "cursorMotionBlur" + | "cursorClickEffect" + | "cursorClickEffectColor" + | "cursorClickEffectScale" + | "cursorClickEffectOpacity" + | "cursorClickEffectDurationMs" + | "cursorClickBounce" + | "cursorClickBounceDuration" + | "cursorSway" + | "borderRadius" + | "padding" + | "webcam" + | "aspectRatio" + | "exportEncodingMode" + | "exportBackendPreference" + | "exportPipelineModel" + | "exportQuality" + | "mp4FrameRate" + | "exportFormat" + | "gifFrameRate" + | "gifLoop" + | "gifSizePreset" +>; + +type PartialEditorControls = Partial<PersistedEditorControls>; + +type PresetAutoCaptionSettings = ProjectEditorState["autoCaptionSettings"]; +type PresetCropRegion = ProjectEditorState["cropRegion"]; +type PresetWebcamSettings = Omit<ProjectEditorState["webcam"], "sourcePath">; + +export interface EditorPresetSnapshot extends Omit<PersistedEditorControls, "webcam"> { + cropRegion: PresetCropRegion; + webcam: PresetWebcamSettings; + autoCaptionSettings: PresetAutoCaptionSettings; + whisperExecutablePath: string | null; + whisperModelPath: string | null; +} + +export interface EditorPreset { + id: string; + name: string; + createdAt: string; + updatedAt: string; + snapshot: EditorPresetSnapshot; +} + +export interface EditorPreferences extends PersistedEditorControls { + customAspectWidth: string; + customAspectHeight: string; + customWallpapers: string[]; + autoApplyFreshRecordingAutoZooms: boolean; + whisperExecutablePath: string | null; + whisperModelPath: string | null; +} + +export const EDITOR_PREFERENCES_STORAGE_KEY = "recordly.editor.preferences"; +export const EDITOR_PRESETS_STORAGE_KEY = "recordly.editor.presets"; + +const DEFAULT_EDITOR_CONTROLS = normalizeProjectEditor({}); + +export const DEFAULT_EDITOR_PREFERENCES: EditorPreferences = { + wallpaper: DEFAULT_EDITOR_CONTROLS.wallpaper, + shadowIntensity: DEFAULT_EDITOR_CONTROLS.shadowIntensity, + backgroundBlur: DEFAULT_EDITOR_CONTROLS.backgroundBlur, + zoomMotionBlur: DEFAULT_EDITOR_CONTROLS.zoomMotionBlur, + zoomMotionBlurTuning: DEFAULT_EDITOR_CONTROLS.zoomMotionBlurTuning, + zoomTemporalMotionBlur: DEFAULT_EDITOR_CONTROLS.zoomTemporalMotionBlur, + zoomMotionBlurSampleCount: DEFAULT_EDITOR_CONTROLS.zoomMotionBlurSampleCount, + zoomMotionBlurShutterFraction: DEFAULT_EDITOR_CONTROLS.zoomMotionBlurShutterFraction, + connectZooms: DEFAULT_EDITOR_CONTROLS.connectZooms, + zoomInDurationMs: DEFAULT_EDITOR_CONTROLS.zoomInDurationMs, + zoomInOverlapMs: DEFAULT_EDITOR_CONTROLS.zoomInOverlapMs, + zoomOutDurationMs: DEFAULT_EDITOR_CONTROLS.zoomOutDurationMs, + connectedZoomGapMs: DEFAULT_EDITOR_CONTROLS.connectedZoomGapMs, + connectedZoomDurationMs: DEFAULT_EDITOR_CONTROLS.connectedZoomDurationMs, + zoomInEasing: DEFAULT_EDITOR_CONTROLS.zoomInEasing, + zoomOutEasing: DEFAULT_EDITOR_CONTROLS.zoomOutEasing, + connectedZoomEasing: DEFAULT_EDITOR_CONTROLS.connectedZoomEasing, + showCursor: DEFAULT_EDITOR_CONTROLS.showCursor, + loopCursor: DEFAULT_EDITOR_CONTROLS.loopCursor, + cursorStyle: DEFAULT_EDITOR_CONTROLS.cursorStyle, + cursorSize: DEFAULT_EDITOR_CONTROLS.cursorSize, + cursorSmoothing: DEFAULT_EDITOR_CONTROLS.cursorSmoothing, + cursorSpringStiffnessMultiplier: DEFAULT_EDITOR_CONTROLS.cursorSpringStiffnessMultiplier, + cursorSpringDampingMultiplier: DEFAULT_EDITOR_CONTROLS.cursorSpringDampingMultiplier, + cursorSpringMassMultiplier: DEFAULT_EDITOR_CONTROLS.cursorSpringMassMultiplier, + cameraSpringStiffnessMultiplier: DEFAULT_EDITOR_CONTROLS.cameraSpringStiffnessMultiplier, + cameraSpringDampingMultiplier: DEFAULT_EDITOR_CONTROLS.cameraSpringDampingMultiplier, + cameraSpringMassMultiplier: DEFAULT_EDITOR_CONTROLS.cameraSpringMassMultiplier, + cursorMotionBlur: DEFAULT_EDITOR_CONTROLS.cursorMotionBlur, + cursorClickEffect: DEFAULT_EDITOR_CONTROLS.cursorClickEffect, + cursorClickEffectColor: DEFAULT_EDITOR_CONTROLS.cursorClickEffectColor, + cursorClickEffectScale: DEFAULT_EDITOR_CONTROLS.cursorClickEffectScale, + cursorClickEffectOpacity: DEFAULT_EDITOR_CONTROLS.cursorClickEffectOpacity, + cursorClickEffectDurationMs: DEFAULT_EDITOR_CONTROLS.cursorClickEffectDurationMs, + cursorClickBounce: DEFAULT_EDITOR_CONTROLS.cursorClickBounce, + cursorClickBounceDuration: DEFAULT_EDITOR_CONTROLS.cursorClickBounceDuration, + cursorSway: DEFAULT_EDITOR_CONTROLS.cursorSway, + borderRadius: DEFAULT_EDITOR_CONTROLS.borderRadius, + padding: DEFAULT_EDITOR_CONTROLS.padding, + webcam: DEFAULT_EDITOR_CONTROLS.webcam, + aspectRatio: DEFAULT_EDITOR_CONTROLS.aspectRatio, + exportEncodingMode: DEFAULT_EDITOR_CONTROLS.exportEncodingMode, + exportBackendPreference: DEFAULT_EDITOR_CONTROLS.exportBackendPreference, + exportPipelineModel: DEFAULT_EDITOR_CONTROLS.exportPipelineModel, + exportQuality: DEFAULT_EDITOR_CONTROLS.exportQuality, + mp4FrameRate: DEFAULT_EDITOR_CONTROLS.mp4FrameRate, + exportFormat: DEFAULT_EDITOR_CONTROLS.exportFormat, + gifFrameRate: DEFAULT_EDITOR_CONTROLS.gifFrameRate, + gifLoop: DEFAULT_EDITOR_CONTROLS.gifLoop, + gifSizePreset: DEFAULT_EDITOR_CONTROLS.gifSizePreset, + customAspectWidth: "16", + customAspectHeight: "9", + customWallpapers: [], + autoApplyFreshRecordingAutoZooms: true, + whisperExecutablePath: null, + whisperModelPath: null, +}; + +function normalizeBoolean(value: unknown, fallback: boolean): boolean { + return typeof value === "boolean" ? value : fallback; +} + +function normalizePositiveIntegerString(value: unknown, fallback: string): string { + if (typeof value !== "string" || value.trim().length === 0) { + return fallback; + } + + const parsed = Number.parseInt(value, 10); + if (!Number.isFinite(parsed) || parsed <= 0) { + return fallback; + } + + return String(parsed); +} + +function normalizeCustomWallpapers(value: unknown, fallback: string[]): string[] { + if (!Array.isArray(value)) { + return fallback; + } + + return Array.from( + new Set( + value.filter((item): item is string => typeof item === "string" && item.length > 0), + ), + ); +} + +function normalizeNullablePath(value: unknown): string | null { + if (typeof value !== "string") { + return null; + } + + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function normalizePresetAutoCaptionSettings(value: unknown): PresetAutoCaptionSettings { + return normalizeProjectEditor({ + autoCaptionSettings: + value && typeof value === "object" ? (value as PresetAutoCaptionSettings) : undefined, + }).autoCaptionSettings; +} + +function normalizeEditorPresetSnapshot(candidate: unknown): EditorPresetSnapshot { + const normalizedPreferences = normalizeEditorPreferences(candidate); + const raw = + candidate && typeof candidate === "object" + ? (candidate as Partial<EditorPresetSnapshot>) + : {}; + const normalizedCropRegion = normalizeProjectEditor({ + cropRegion: raw.cropRegion, + }).cropRegion; + const normalizedControls = normalizeEditorControls( + normalizedPreferences, + normalizedPreferences, + ); + const { sourcePath: _sourcePath, ...webcam } = normalizedControls.webcam; + + return { + ...normalizedControls, + webcam, + cropRegion: normalizedCropRegion, + autoCaptionSettings: normalizePresetAutoCaptionSettings(raw.autoCaptionSettings), + whisperExecutablePath: + normalizeNullablePath(raw.whisperExecutablePath) ?? + normalizedPreferences.whisperExecutablePath, + whisperModelPath: + normalizeNullablePath(raw.whisperModelPath) ?? normalizedPreferences.whisperModelPath, + }; +} + +function normalizePresetName(value: unknown): string | null { + if (typeof value !== "string") { + return null; + } + + const trimmed = value.trim().replace(/\s+/g, " "); + return trimmed.length > 0 ? trimmed : null; +} + +function normalizePresetTimestamp(value: unknown, fallback: string): string { + if (typeof value !== "string") { + return fallback; + } + + const parsed = Date.parse(value); + return Number.isFinite(parsed) ? new Date(parsed).toISOString() : fallback; +} + +function normalizeEditorPreset(candidate: unknown): EditorPreset | null { + if (!candidate || typeof candidate !== "object") { + return null; + } + + const raw = candidate as Partial<EditorPreset>; + const name = normalizePresetName(raw.name); + if (!name) { + return null; + } + + const timestamp = new Date().toISOString(); + const id = + typeof raw.id === "string" && raw.id.trim().length > 0 ? raw.id : crypto.randomUUID(); + + return { + id, + name, + createdAt: normalizePresetTimestamp(raw.createdAt, timestamp), + updatedAt: normalizePresetTimestamp(raw.updatedAt, timestamp), + snapshot: normalizeEditorPresetSnapshot(raw.snapshot), + }; +} + +function normalizeEditorPresets(candidates: unknown): EditorPreset[] { + if (!Array.isArray(candidates)) { + return []; + } + + return candidates + .map((item) => normalizeEditorPreset(item)) + .filter((preset): preset is EditorPreset => preset !== null) + .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)); +} + +export function serializeEditorPresetSnapshot(snapshot: EditorPresetSnapshot): string { + return JSON.stringify(normalizeEditorPresetSnapshot(snapshot)); +} + +function normalizeEditorControls( + raw: Partial<EditorPreferences>, + fallback: EditorPreferences, +): PersistedEditorControls { + const sanitizedRaw = stripPersistedDevMotionBlurSettings(raw); + const candidate: PartialEditorControls = { + wallpaper: sanitizedRaw.wallpaper ?? fallback.wallpaper, + shadowIntensity: sanitizedRaw.shadowIntensity ?? fallback.shadowIntensity, + backgroundBlur: sanitizedRaw.backgroundBlur ?? fallback.backgroundBlur, + zoomMotionBlur: sanitizedRaw.zoomMotionBlur ?? fallback.zoomMotionBlur, + connectZooms: sanitizedRaw.connectZooms ?? fallback.connectZooms, + zoomInDurationMs: sanitizedRaw.zoomInDurationMs ?? fallback.zoomInDurationMs, + zoomInOverlapMs: sanitizedRaw.zoomInOverlapMs ?? fallback.zoomInOverlapMs, + zoomOutDurationMs: sanitizedRaw.zoomOutDurationMs ?? fallback.zoomOutDurationMs, + connectedZoomGapMs: sanitizedRaw.connectedZoomGapMs ?? fallback.connectedZoomGapMs, + connectedZoomDurationMs: + sanitizedRaw.connectedZoomDurationMs ?? fallback.connectedZoomDurationMs, + zoomInEasing: sanitizedRaw.zoomInEasing ?? fallback.zoomInEasing, + zoomOutEasing: sanitizedRaw.zoomOutEasing ?? fallback.zoomOutEasing, + connectedZoomEasing: sanitizedRaw.connectedZoomEasing ?? fallback.connectedZoomEasing, + showCursor: sanitizedRaw.showCursor ?? fallback.showCursor, + loopCursor: sanitizedRaw.loopCursor ?? fallback.loopCursor, + cursorStyle: sanitizedRaw.cursorStyle ?? fallback.cursorStyle, + cursorSize: sanitizedRaw.cursorSize ?? fallback.cursorSize, + cursorSmoothing: sanitizedRaw.cursorSmoothing ?? fallback.cursorSmoothing, + cursorSpringStiffnessMultiplier: + sanitizedRaw.cursorSpringStiffnessMultiplier ?? + fallback.cursorSpringStiffnessMultiplier, + cursorSpringDampingMultiplier: + sanitizedRaw.cursorSpringDampingMultiplier ?? fallback.cursorSpringDampingMultiplier, + cursorSpringMassMultiplier: + sanitizedRaw.cursorSpringMassMultiplier ?? fallback.cursorSpringMassMultiplier, + cameraSpringStiffnessMultiplier: + sanitizedRaw.cameraSpringStiffnessMultiplier ?? + fallback.cameraSpringStiffnessMultiplier, + cameraSpringDampingMultiplier: + sanitizedRaw.cameraSpringDampingMultiplier ?? fallback.cameraSpringDampingMultiplier, + cameraSpringMassMultiplier: + sanitizedRaw.cameraSpringMassMultiplier ?? fallback.cameraSpringMassMultiplier, + cursorMotionBlur: sanitizedRaw.cursorMotionBlur ?? fallback.cursorMotionBlur, + cursorClickEffect: sanitizedRaw.cursorClickEffect ?? fallback.cursorClickEffect, + cursorClickEffectColor: + sanitizedRaw.cursorClickEffectColor ?? fallback.cursorClickEffectColor, + cursorClickEffectScale: + sanitizedRaw.cursorClickEffectScale ?? fallback.cursorClickEffectScale, + cursorClickEffectOpacity: + sanitizedRaw.cursorClickEffectOpacity ?? fallback.cursorClickEffectOpacity, + cursorClickEffectDurationMs: + sanitizedRaw.cursorClickEffectDurationMs ?? fallback.cursorClickEffectDurationMs, + cursorClickBounce: sanitizedRaw.cursorClickBounce ?? fallback.cursorClickBounce, + cursorClickBounceDuration: + sanitizedRaw.cursorClickBounceDuration ?? fallback.cursorClickBounceDuration, + cursorSway: sanitizedRaw.cursorSway ?? fallback.cursorSway, + borderRadius: sanitizedRaw.borderRadius ?? fallback.borderRadius, + padding: sanitizedRaw.padding ?? fallback.padding, + webcam: sanitizedRaw.webcam ?? fallback.webcam, + aspectRatio: sanitizedRaw.aspectRatio ?? fallback.aspectRatio, + exportEncodingMode: sanitizedRaw.exportEncodingMode ?? fallback.exportEncodingMode, + exportBackendPreference: + sanitizedRaw.exportBackendPreference === undefined + ? fallback.exportBackendPreference + : normalizeExportBackendPreference(sanitizedRaw.exportBackendPreference), + exportPipelineModel: + sanitizedRaw.exportPipelineModel === undefined + ? fallback.exportPipelineModel + : normalizeExportPipelineModel(sanitizedRaw.exportPipelineModel), + exportQuality: sanitizedRaw.exportQuality ?? fallback.exportQuality, + mp4FrameRate: + sanitizedRaw.mp4FrameRate === undefined + ? fallback.mp4FrameRate + : normalizeExportMp4FrameRate(sanitizedRaw.mp4FrameRate), + exportFormat: sanitizedRaw.exportFormat ?? fallback.exportFormat, + gifFrameRate: sanitizedRaw.gifFrameRate ?? fallback.gifFrameRate, + gifLoop: sanitizedRaw.gifLoop ?? fallback.gifLoop, + gifSizePreset: sanitizedRaw.gifSizePreset ?? fallback.gifSizePreset, + }; + + const normalized = normalizeProjectEditor(candidate); + + return { + wallpaper: normalized.wallpaper, + shadowIntensity: normalized.shadowIntensity, + backgroundBlur: normalized.backgroundBlur, + zoomMotionBlur: normalized.zoomMotionBlur, + zoomMotionBlurTuning: normalized.zoomMotionBlurTuning, + zoomTemporalMotionBlur: normalized.zoomTemporalMotionBlur, + zoomMotionBlurSampleCount: normalized.zoomMotionBlurSampleCount, + zoomMotionBlurShutterFraction: normalized.zoomMotionBlurShutterFraction, + connectZooms: normalized.connectZooms, + zoomInDurationMs: normalized.zoomInDurationMs, + zoomInOverlapMs: normalized.zoomInOverlapMs, + zoomOutDurationMs: normalized.zoomOutDurationMs, + connectedZoomGapMs: normalized.connectedZoomGapMs, + connectedZoomDurationMs: normalized.connectedZoomDurationMs, + zoomInEasing: normalized.zoomInEasing, + zoomOutEasing: normalized.zoomOutEasing, + connectedZoomEasing: normalized.connectedZoomEasing, + showCursor: normalized.showCursor, + loopCursor: normalized.loopCursor, + cursorStyle: normalized.cursorStyle, + cursorSize: normalized.cursorSize, + cursorSmoothing: normalized.cursorSmoothing, + cursorSpringStiffnessMultiplier: normalized.cursorSpringStiffnessMultiplier, + cursorSpringDampingMultiplier: normalized.cursorSpringDampingMultiplier, + cursorSpringMassMultiplier: normalized.cursorSpringMassMultiplier, + cameraSpringStiffnessMultiplier: normalized.cameraSpringStiffnessMultiplier, + cameraSpringDampingMultiplier: normalized.cameraSpringDampingMultiplier, + cameraSpringMassMultiplier: normalized.cameraSpringMassMultiplier, + cursorMotionBlur: normalized.cursorMotionBlur, + cursorClickEffect: normalized.cursorClickEffect, + cursorClickEffectColor: normalized.cursorClickEffectColor, + cursorClickEffectScale: normalized.cursorClickEffectScale, + cursorClickEffectOpacity: normalized.cursorClickEffectOpacity, + cursorClickEffectDurationMs: normalized.cursorClickEffectDurationMs, + cursorClickBounce: normalized.cursorClickBounce, + cursorClickBounceDuration: normalized.cursorClickBounceDuration, + cursorSway: normalized.cursorSway, + borderRadius: normalized.borderRadius, + padding: normalized.padding, + webcam: normalized.webcam, + aspectRatio: normalized.aspectRatio, + exportEncodingMode: normalized.exportEncodingMode, + exportBackendPreference: normalized.exportBackendPreference, + exportPipelineModel: normalized.exportPipelineModel, + exportQuality: normalized.exportQuality, + mp4FrameRate: normalized.mp4FrameRate, + exportFormat: normalized.exportFormat, + gifFrameRate: normalized.gifFrameRate, + gifLoop: normalized.gifLoop, + gifSizePreset: normalized.gifSizePreset, + }; +} + +export function normalizeEditorPreferences( + candidate: unknown, + fallback: EditorPreferences = DEFAULT_EDITOR_PREFERENCES, +): EditorPreferences { + const raw = + candidate && typeof candidate === "object" ? (candidate as Partial<EditorPreferences>) : {}; + + return { + ...normalizeEditorControls(raw, fallback), + customAspectWidth: normalizePositiveIntegerString( + raw.customAspectWidth, + fallback.customAspectWidth, + ), + customAspectHeight: normalizePositiveIntegerString( + raw.customAspectHeight, + fallback.customAspectHeight, + ), + customWallpapers: normalizeCustomWallpapers( + raw.customWallpapers, + fallback.customWallpapers, + ), + autoApplyFreshRecordingAutoZooms: normalizeBoolean( + raw.autoApplyFreshRecordingAutoZooms, + fallback.autoApplyFreshRecordingAutoZooms, + ), + whisperExecutablePath: + normalizeNullablePath(raw.whisperExecutablePath) ?? fallback.whisperExecutablePath, + whisperModelPath: normalizeNullablePath(raw.whisperModelPath) ?? fallback.whisperModelPath, + }; +} + +export function loadEditorPreferences(): EditorPreferences { + const persisted = loadAppSetting<unknown>(EDITOR_PREFERENCES_STORAGE_KEY); + if (persisted !== null) { + return normalizeEditorPreferences(persisted); + } + + try { + const stored = globalThis.localStorage?.getItem(EDITOR_PREFERENCES_STORAGE_KEY); + if (!stored) { + return DEFAULT_EDITOR_PREFERENCES; + } + + return normalizeEditorPreferences(JSON.parse(stored)); + } catch { + return DEFAULT_EDITOR_PREFERENCES; + } +} + +export function saveEditorPreferences(preferences: Partial<EditorPreferences>): void { + try { + const current = loadEditorPreferences(); + const merged = normalizeEditorPreferences({ ...current, ...preferences }, current); + const persisted = stripPersistedDevMotionBlurSettings(merged); + saveAppSetting(EDITOR_PREFERENCES_STORAGE_KEY, persisted); + saveLocalStorageJson(EDITOR_PREFERENCES_STORAGE_KEY, persisted); + } catch { + // Ignore storage failures so editor controls still work. + } +} + +function saveLocalStorageJson(key: string, value: unknown): boolean { + try { + if (typeof globalThis.localStorage === "undefined") { + return false; + } + + globalThis.localStorage.setItem(key, JSON.stringify(value)); + return true; + } catch { + return false; + } +} + +export function loadEditorPresets(): EditorPreset[] { + const persisted = loadAppSetting<unknown>(EDITOR_PRESETS_STORAGE_KEY); + if (persisted !== null) { + return normalizeEditorPresets(persisted); + } + + try { + const stored = globalThis.localStorage?.getItem(EDITOR_PRESETS_STORAGE_KEY); + if (!stored) { + return []; + } + + return normalizeEditorPresets(JSON.parse(stored)); + } catch { + return []; + } +} + +export function saveEditorPresets(presets: EditorPreset[]): boolean { + try { + const normalized = normalizeEditorPresets(presets); + const persistedToAppSettings = saveAppSetting(EDITOR_PRESETS_STORAGE_KEY, normalized); + const persistedToLocalStorage = saveLocalStorageJson( + EDITOR_PRESETS_STORAGE_KEY, + normalized, + ); + return persistedToAppSettings || persistedToLocalStorage; + } catch { + // Ignore storage failures so editor controls still work. + return false; + } +} diff --git a/src/components/video-editor/export/buildExportRenderOptions.ts b/src/components/video-editor/export/buildExportRenderOptions.ts new file mode 100644 index 000000000..4ec4214dd --- /dev/null +++ b/src/components/video-editor/export/buildExportRenderOptions.ts @@ -0,0 +1,93 @@ +import type { ExportProgress } from "@/lib/exporter"; +import { toFileUrl } from "../projectPersistence"; +import type { useAppearanceState } from "../state/useAppearanceState"; +import type { useTimelineState } from "../state/useTimelineState"; +import type { CursorTelemetryPoint, SpeedRegion, ZoomRegion } from "../types"; + +type AppearanceState = ReturnType<typeof useAppearanceState>; +type TimelineState = ReturnType<typeof useTimelineState>; + +type BuildExportRenderOptionsInput = { + appearance: AppearanceState; + timeline: TimelineState; + effectiveSpeedRegions: SpeedRegion[]; + effectiveZoomRegions: ZoomRegion[]; + effectiveCursorTelemetry: CursorTelemetryPoint[]; + effectiveShowCursor: boolean; + previewWidth: number; + previewHeight: number; + shadowIntensity: number; + onProgress: (progress: ExportProgress) => void; +}; + +export function buildExportRenderOptions({ + appearance, + timeline, + effectiveSpeedRegions, + effectiveZoomRegions, + effectiveCursorTelemetry, + effectiveShowCursor, + previewWidth, + previewHeight, + shadowIntensity, + onProgress, +}: BuildExportRenderOptionsInput) { + return { + wallpaper: appearance.wallpaper, + trimRegions: timeline.trimRegions, + speedRegions: effectiveSpeedRegions, + showShadow: shadowIntensity > 0, + shadowIntensity, + backgroundBlur: appearance.backgroundBlur, + zoomMotionBlur: appearance.zoomMotionBlur, + zoomMotionBlurTuning: appearance.zoomMotionBlurTuning, + zoomTemporalMotionBlur: appearance.zoomTemporalMotionBlur, + zoomMotionBlurSampleCount: appearance.zoomMotionBlurSampleCount, + zoomMotionBlurShutterFraction: appearance.zoomMotionBlurShutterFraction, + connectZooms: appearance.connectZooms, + zoomInDurationMs: appearance.zoomInDurationMs, + zoomInOverlapMs: appearance.zoomInOverlapMs, + zoomOutDurationMs: appearance.zoomOutDurationMs, + connectedZoomGapMs: appearance.connectedZoomGapMs, + connectedZoomDurationMs: appearance.connectedZoomDurationMs, + zoomInEasing: appearance.zoomInEasing, + zoomOutEasing: appearance.zoomOutEasing, + connectedZoomEasing: appearance.connectedZoomEasing, + borderRadius: appearance.borderRadius, + padding: appearance.padding, + cropRegion: appearance.cropRegion, + webcam: appearance.webcam, + webcamUrl: + appearance.resolvedWebcamVideoUrl ?? + (appearance.webcam.sourcePath ? toFileUrl(appearance.webcam.sourcePath) : null), + annotationRegions: timeline.annotationRegions, + autoCaptions: timeline.autoCaptions, + autoCaptionSettings: timeline.autoCaptionSettings, + zoomRegions: effectiveZoomRegions, + cursorTelemetry: effectiveCursorTelemetry, + showCursor: effectiveShowCursor, + cursorStyle: appearance.cursorStyle, + cursorSize: appearance.cursorSize, + cursorSmoothing: appearance.cursorSmoothing, + cursorSpringStiffnessMultiplier: appearance.cursorSpringStiffnessMultiplier, + cursorSpringDampingMultiplier: appearance.cursorSpringDampingMultiplier, + cursorSpringMassMultiplier: appearance.cursorSpringMassMultiplier, + cameraSpringStiffnessMultiplier: appearance.cameraSpringStiffnessMultiplier, + cameraSpringDampingMultiplier: appearance.cameraSpringDampingMultiplier, + cameraSpringMassMultiplier: appearance.cameraSpringMassMultiplier, + zoomSmoothness: appearance.zoomSmoothness, + zoomClassicMode: appearance.zoomClassicMode, + cursorMotionBlur: appearance.cursorMotionBlur, + cursorClickEffect: appearance.cursorClickEffect, + cursorClickEffectColor: appearance.cursorClickEffectColor, + cursorClickEffectScale: appearance.cursorClickEffectScale, + cursorClickEffectOpacity: appearance.cursorClickEffectOpacity, + cursorClickEffectDurationMs: appearance.cursorClickEffectDurationMs, + cursorClickBounce: appearance.cursorClickBounce, + cursorClickBounceDuration: appearance.cursorClickBounceDuration, + cursorSway: appearance.cursorSway, + previewWidth, + previewHeight, + onProgress, + }; +} diff --git a/src/components/video-editor/export/exportPersistence.ts b/src/components/video-editor/export/exportPersistence.ts new file mode 100644 index 000000000..678564c4c --- /dev/null +++ b/src/components/video-editor/export/exportPersistence.ts @@ -0,0 +1,154 @@ +export interface PendingExportSave { + fileName: string; + arrayBuffer?: ArrayBuffer; + tempFilePath?: string; + captionSidecar?: { + format: "srt" | "vtt" | "both"; + cues: Array<{ + startMs: number; + endMs: number; + text: string; + }>; + }; +} + +const EXPORT_BLOB_STREAM_CHUNK_BYTES = 16 * 1024 * 1024; + +export async function streamExportBlobToTempFile( + blob: Blob, + extension: string, +): Promise<string | null> { + if ( + typeof window === "undefined" || + !window.electronAPI?.openExportStream || + !window.electronAPI?.writeExportStreamChunk || + !window.electronAPI?.closeExportStream + ) { + return null; + } + + const openResult = await window.electronAPI.openExportStream({ extension }); + if (!openResult.success || !openResult.streamId || !openResult.tempPath) { + throw new Error(openResult.error || "Failed to open export stream"); + } + + const { streamId } = openResult; + let position = 0; + try { + while (position < blob.size) { + const chunk = blob.slice(position, position + EXPORT_BLOB_STREAM_CHUNK_BYTES); + const chunkBuffer = await chunk.arrayBuffer(); + const writeResult = await window.electronAPI.writeExportStreamChunk( + streamId, + position, + new Uint8Array(chunkBuffer), + ); + if (!writeResult.success) { + throw new Error(writeResult.error || "Failed to write export stream chunk"); + } + position += chunkBuffer.byteLength; + } + + const closeResult = await window.electronAPI.closeExportStream(streamId); + if (!closeResult.success || !closeResult.tempPath) { + throw new Error(closeResult.error || "Failed to close export stream"); + } + return closeResult.tempPath; + } catch (error) { + try { + await window.electronAPI.closeExportStream(streamId, { abort: true }); + } catch { + // Best-effort cleanup; preserve the original error. + } + throw error; + } +} + +export async function saveExportBlob( + blob: Blob, + fileName: string, + outputPath: string | null = null, + captionSidecar?: PendingExportSave["captionSidecar"], +) { + const extension = fileName.split(".").pop()?.toLowerCase() || "bin"; + const hasExportStreamApi = + typeof window !== "undefined" && + typeof window.electronAPI?.openExportStream === "function" && + typeof window.electronAPI?.writeExportStreamChunk === "function" && + typeof window.electronAPI?.closeExportStream === "function"; + let streamError: unknown = null; + + try { + const tempFilePath = await streamExportBlobToTempFile(blob, extension); + if (tempFilePath) { + return { + saveResult: await window.electronAPI.finalizeExportedVideo({ + tempPath: tempFilePath, + fileName, + outputPath, + captionSidecar, + }), + pendingSave: { fileName, tempFilePath, captionSidecar } satisfies PendingExportSave, + }; + } + } catch (error) { + streamError = error; + console.warn("[export] Temp-file blob save failed", error); + } + + if (!canUseInMemoryExportSaveFallback({ blobSize: blob.size, extension, hasExportStreamApi })) { + const message = describeBlockedInMemoryExportSave({ blobSize: blob.size, extension }); + console.error("[export] Refusing in-memory blob save fallback", { + fileName, + blobSize: blob.size, + extension, + hasExportStreamApi, + streamError, + }); + throw new Error(message); + } + + console.warn("[export] Falling back to in-memory blob save", { + fileName, + blobSize: blob.size, + extension, + hasExportStreamApi, + }); + const arrayBuffer = await blob.arrayBuffer(); + return { + saveResult: outputPath + ? await window.electronAPI.writeExportedVideoToPath( + arrayBuffer, + outputPath, + captionSidecar, + ) + : await window.electronAPI.saveExportedVideo(arrayBuffer, fileName, captionSidecar), + pendingSave: { fileName, arrayBuffer, captionSidecar } satisfies PendingExportSave, + }; +} + +export async function writeSmokeExportReport( + outputPath: string | null, + report: Record<string, unknown>, +): Promise<void> { + if (!outputPath || typeof window === "undefined") return; + + try { + const reportBytes = new TextEncoder().encode(JSON.stringify(report, null, 2)); + const reportBuffer = reportBytes.buffer.slice( + reportBytes.byteOffset, + reportBytes.byteOffset + reportBytes.byteLength, + ) as ArrayBuffer; + await window.electronAPI.writeExportedVideoToPath( + reportBuffer, + `${outputPath}.report.json`, + ); + } catch (error) { + console.error("[smoke-export] Failed to write report", error); + } +} + +import { + canUseInMemoryExportSaveFallback, + describeBlockedInMemoryExportSave, +} from "@/lib/exporter/exportSavePolicy"; diff --git a/src/components/video-editor/export/exportRunnerSupport.ts b/src/components/video-editor/export/exportRunnerSupport.ts new file mode 100644 index 000000000..11c8cf8e6 --- /dev/null +++ b/src/components/video-editor/export/exportRunnerSupport.ts @@ -0,0 +1,69 @@ +import type { RefObject } from "react"; +import { useCallback } from "react"; +import { toast } from "sonner"; +import type { SupportedMp4Dimensions } from "@/lib/exporter"; +import type { useVideoEditorAudio } from "../audio/useVideoEditorAudio"; +import type { getSmokeExportConfig } from "../smokeExportConfig"; +import type { useAppearanceState } from "../state/useAppearanceState"; +import type { useTimelineState } from "../state/useTimelineState"; +import type { CursorTelemetryPoint, SpeedRegion, ZoomRegion } from "../types"; +import type { VideoPlaybackRef } from "../VideoPlayback"; +import { summarizeErrorMessage } from "../videoEditorUtils"; +import type { PendingExportSave } from "./exportPersistence"; +import type { useExportSession } from "./useExportSession"; +import type { useExportSettings } from "./useExportSettings"; + +export type ExportRunnerInput = { + videoPath: string | null; + videoPlaybackRef: RefObject<VideoPlaybackRef | null>; + isPlaying: boolean; + appearance: ReturnType<typeof useAppearanceState>; + timeline: ReturnType<typeof useTimelineState>; + exportSettings: ReturnType<typeof useExportSettings>; + exportSession: ReturnType<typeof useExportSession>; + audio: ReturnType<typeof useVideoEditorAudio>; + smokeExportConfig: ReturnType<typeof getSmokeExportConfig>; + effectiveSpeedRegions: SpeedRegion[]; + effectiveZoomRegions: ZoomRegion[]; + effectiveCursorTelemetry: CursorTelemetryPoint[]; + effectiveShowCursor: boolean; + ensureSupportedMp4SourceDimensions: ( + frameRate: ReturnType<typeof useExportSettings>["mp4FrameRate"], + ) => Promise<SupportedMp4Dimensions>; + captionSidecarPayload?: PendingExportSave["captionSidecar"]; + experimentalNvidiaCudaExport: boolean; + nvidiaCudaExportAvailable: boolean; + remountPreview: () => void; +}; + +export function showExportErrorToast(message: string) { + const summary = summarizeErrorMessage(message); + toast.error(summary, { + description: summary === message ? undefined : message, + duration: 20_000, + }); +} + +export function useExportSuccessToast() { + return useCallback((filePath: string) => { + toast.success(`Exported successfully to ${filePath}`, { + action: { + label: "Show in Folder", + onClick: async () => { + try { + const result = await window.electronAPI.revealInFolder(filePath); + if (!result.success) { + toast.error( + result.error || + result.message || + "Failed to reveal item in folder.", + ); + } + } catch (error) { + toast.error(`Error revealing in folder: ${String(error)}`); + } + }, + }, + }); + }, []); +} diff --git a/src/components/video-editor/export/useEditorExportController.ts b/src/components/video-editor/export/useEditorExportController.ts new file mode 100644 index 000000000..ec6aa3f77 --- /dev/null +++ b/src/components/video-editor/export/useEditorExportController.ts @@ -0,0 +1,99 @@ +import type { RefObject } from "react"; +import type { useI18n } from "@/contexts/I18nContext"; +import type { useVideoEditorAudio } from "../audio/useVideoEditorAudio"; +import type { getSmokeExportConfig } from "../smokeExportConfig"; +import type { useAppearanceState } from "../state/useAppearanceState"; +import type { useTimelineState } from "../state/useTimelineState"; +import type { CursorTelemetryPoint, SpeedRegion, ZoomRegion } from "../types"; +import type { VideoPlaybackRef } from "../VideoPlayback"; +import { useExportDialogActions } from "./useExportDialogActions"; +import type { useExportDimensions } from "./useExportDimensions"; +import { useExportMessages } from "./useExportMessages"; +import { useExportRunner } from "./useExportRunner"; +import type { useExportSession } from "./useExportSession"; +import type { useExportSettings } from "./useExportSettings"; +import { useExportStatusViewModel } from "./useExportStatusViewModel"; +import { useSmokeExportAutomation } from "./useSmokeExportAutomation"; + +type Input = { + t: ReturnType<typeof useI18n>["t"]; + videoPath: string | null; + videoSourcePath: string | null; + videoPlaybackRef: RefObject<VideoPlaybackRef>; + isPlaying: boolean; + duration: number; + error: string | null; + loading: boolean; + isPreviewReady: boolean; + appearance: ReturnType<typeof useAppearanceState>; + timeline: ReturnType<typeof useTimelineState>; + settings: ReturnType<typeof useExportSettings>; + session: ReturnType<typeof useExportSession>; + dimensions: ReturnType<typeof useExportDimensions>; + audio: ReturnType<typeof useVideoEditorAudio>; + smokeConfig: ReturnType<typeof getSmokeExportConfig>; + effectiveSpeedRegions: SpeedRegion[]; + effectiveZoomRegions: ZoomRegion[]; + effectiveCursorTelemetry: CursorTelemetryPoint[]; + effectiveShowCursor: boolean; + cursorTelemetrySourcePath: string | null; + hasCaptionsForSidecar: boolean; + captionSidecarPayload?: Parameters<typeof useExportRunner>[0]["captionSidecarPayload"]; + experimentalNvidiaCudaExport: boolean; + nvidiaCudaExportAvailable: boolean; + remountPreview: () => void; +}; + +export function useEditorExportController(input: Input) { + const runner = useExportRunner({ + videoPath: input.videoPath, + videoPlaybackRef: input.videoPlaybackRef, + isPlaying: input.isPlaying, + appearance: input.appearance, + timeline: input.timeline, + exportSettings: input.settings, + exportSession: input.session, + audio: input.audio, + smokeExportConfig: input.smokeConfig, + effectiveSpeedRegions: input.effectiveSpeedRegions, + effectiveZoomRegions: input.effectiveZoomRegions, + effectiveCursorTelemetry: input.effectiveCursorTelemetry, + effectiveShowCursor: input.effectiveShowCursor, + ensureSupportedMp4SourceDimensions: input.dimensions.ensureSupportedMp4SourceDimensions, + captionSidecarPayload: input.captionSidecarPayload, + experimentalNvidiaCudaExport: input.experimentalNvidiaCudaExport, + nvidiaCudaExportAvailable: input.nvidiaCudaExportAvailable, + remountPreview: input.remountPreview, + }); + const dialogActions = useExportDialogActions({ + videoPath: input.videoPath, + videoPlaybackRef: input.videoPlaybackRef, + hasCaptionsForSidecar: input.hasCaptionsForSidecar, + settings: input.settings, + session: input.session, + handleExport: runner.handleExport, + showExportSuccessToast: runner.showExportSuccessToast, + }); + useSmokeExportAutomation({ + config: input.smokeConfig, + cursorTelemetrySourcePath: input.cursorTelemetrySourcePath, + duration: input.duration, + error: input.error, + isPreviewReady: input.isPreviewReady, + loading: input.loading, + videoPath: input.videoPath, + videoSourcePath: input.videoSourcePath, + handleExport: runner.handleExport, + }); + const status = useExportStatusViewModel({ + t: input.t, + session: input.session, + settings: input.settings, + }); + const exportMessage = useExportMessages({ + t: input.t, + active: status.isLightningExportInProgress, + }); + + return { dialogActions, status, exportMessage }; +} diff --git a/src/components/video-editor/export/useExportDialogActions.ts b/src/components/video-editor/export/useExportDialogActions.ts new file mode 100644 index 000000000..87de829a3 --- /dev/null +++ b/src/components/video-editor/export/useExportDialogActions.ts @@ -0,0 +1,166 @@ +import { type RefObject, useCallback } from "react"; +import { toast } from "sonner"; +import type { ExportSettings } from "@/lib/exporter"; +import { resolveExportStartSettings } from "../exportStartSettings"; +import type { VideoPlaybackRef } from "../VideoPlayback"; +import type { useExportSession } from "./useExportSession"; +import type { useExportSettings } from "./useExportSettings"; + +type ExportSession = ReturnType<typeof useExportSession>; +type ExportSettingsState = ReturnType<typeof useExportSettings>; + +type UseExportDialogActionsInput = { + videoPath: string | null; + videoPlaybackRef: RefObject<VideoPlaybackRef | null>; + hasCaptionsForSidecar: boolean; + settings: ExportSettingsState; + session: ExportSession; + handleExport: (settings: ExportSettings) => void; + showExportSuccessToast: (filePath: string) => void; +}; + +export function useExportDialogActions({ + videoPath, + videoPlaybackRef, + hasCaptionsForSidecar, + settings, + session, + handleExport, + showExportSuccessToast, +}: UseExportDialogActionsInput) { + const handleOpenExportDropdown = useCallback(() => { + if (!videoPath) { + toast.error("No video loaded"); + return; + } + + if (session.hasPendingExportSave) { + session.setShowExportDropdown(true); + session.setExportError( + "Save dialog canceled. Click Save Again to save without re-rendering.", + ); + return; + } + session.setShowExportDropdown(true); + session.setExportProgress(null); + session.setExportError(null); + }, [videoPath, session]); + + const handleStartExportFromDropdown = useCallback(() => { + const video = videoPlaybackRef.current?.video; + if (!videoPath) { + toast.error("No video loaded"); + return; + } + if (!video) { + toast.error("Video not ready"); + return; + } + if (video.videoWidth <= 0 || video.videoHeight <= 0) { + toast.error("Video metadata is still loading"); + return; + } + + const resolvedSettings = resolveExportStartSettings({ + sourceWidth: video.videoWidth, + sourceHeight: video.videoHeight, + exportFormat: settings.exportFormat, + includeCaptionSidecar: hasCaptionsForSidecar && settings.includeCaptionSidecar, + exportEncodingMode: settings.exportEncodingMode, + exportQuality: settings.exportQuality, + mp4FrameRate: settings.mp4FrameRate, + exportBackendPreference: settings.exportBackendPreference, + exportPipelineModel: settings.exportPipelineModel, + gifFrameRate: settings.gifFrameRate, + gifLoop: settings.gifLoop, + gifSizePreset: settings.gifSizePreset, + }); + + session.setExportError(null); + session.setExportedFilePath(undefined); + session.setShowExportDropdown(true); + handleExport(resolvedSettings); + }, [videoPath, videoPlaybackRef, hasCaptionsForSidecar, settings, session, handleExport]); + + const handleCancelExport = useCallback(() => { + if (!session.exporterRef.current) return; + session.exporterRef.current.cancel(); + toast.info("Export canceled"); + session.clearPendingExportSave(); + session.setShowExportDropdown(false); + session.setIsExporting(false); + session.setExportProgress(null); + session.setExportError(null); + session.setExportedFilePath(undefined); + }, [session]); + + const handleExportDropdownClose = useCallback(() => { + session.clearPendingExportSave(); + session.setShowExportDropdown(false); + session.setExportProgress(null); + session.setExportError(null); + session.setExportedFilePath(undefined); + }, [session]); + + const handleRetrySaveExport = useCallback(async () => { + const pendingSave = session.pendingExportSaveRef.current; + if (!pendingSave) return; + + const saveResult = pendingSave.tempFilePath + ? await window.electronAPI.finalizeExportedVideo({ + tempPath: pendingSave.tempFilePath, + fileName: pendingSave.fileName, + outputPath: null, + captionSidecar: pendingSave.captionSidecar, + }) + : pendingSave.arrayBuffer + ? await window.electronAPI.saveExportedVideo( + pendingSave.arrayBuffer, + pendingSave.fileName, + pendingSave.captionSidecar, + ) + : { success: false, message: "No pending export to save" }; + + if (saveResult.canceled) { + session.setExportError( + "Save dialog canceled. Click Save Again to save without re-rendering.", + ); + toast.info("Save canceled. You can try again."); + return; + } + if (saveResult.success && saveResult.path) { + session.pendingExportSaveRef.current = null; + session.setHasPendingExportSave(false); + session.setExportError(null); + session.setExportedFilePath(saveResult.path); + showExportSuccessToast(saveResult.path); + session.setShowExportDropdown(true); + return; + } + + const errorMessage = saveResult.message || "Failed to save video"; + session.setExportError(errorMessage); + toast.error(errorMessage); + }, [session, showExportSuccessToast]); + + const revealExportedFile = useCallback(async () => { + if (!session.exportedFilePath) return; + try { + const result = await window.electronAPI.revealInFolder(session.exportedFilePath); + if (!result.success) { + toast.error(result.error || result.message || "Failed to reveal item in folder."); + } + } catch (error) { + toast.error(`Failed to reveal item in folder: ${String(error)}`); + } + }, [session.exportedFilePath]); + + return { + handleOpenExportDropdown, + handleStartExportFromDropdown, + handleCancelExport, + handleExportDropdownClose, + handleRetrySaveExport, + revealExportedFile, + }; +} diff --git a/src/components/video-editor/export/useExportDimensions.ts b/src/components/video-editor/export/useExportDimensions.ts new file mode 100644 index 000000000..2e6721f9c --- /dev/null +++ b/src/components/video-editor/export/useExportDimensions.ts @@ -0,0 +1,155 @@ +import { type RefObject, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + calculateOutputDimensions, + DEFAULT_MP4_CODEC, + type ExportMp4FrameRate, + GIF_SIZE_PRESETS, + type GifSizePreset, + probeSupportedMp4Dimensions, + type SupportedMp4Dimensions, +} from "@/lib/exporter"; +import { getSourceQualityBitrate } from "@/lib/exporter/exportBitrate"; +import type { AspectRatio } from "@/utils/aspectRatioUtils"; +import { + calculateMp4ExportDimensions, + calculateMp4SourceDimensions, + type Mp4SupportProbeSnapshot, + shouldDebounceMp4SupportProbe, +} from "../exportDimensions"; +import type { CropRegion } from "../types"; +import type { VideoPlaybackRef } from "../VideoPlayback"; + +const MP4_CROP_PROBE_DEBOUNCE_MS = 200; + +type Input = { + videoPlaybackRef: RefObject<VideoPlaybackRef | null>; + isPreviewReady: boolean; + aspectRatio: AspectRatio; + cropRegion: CropRegion; + mp4FrameRate: ExportMp4FrameRate; + gifSizePreset: GifSizePreset; +}; + +export function useExportDimensions({ + videoPlaybackRef, + isPreviewReady, + aspectRatio, + cropRegion, + mp4FrameRate, + gifSizePreset, +}: Input) { + const [supportedSourceDimensions, setSupportedSourceDimensions] = + useState<SupportedMp4Dimensions>({ + width: 1920, + height: 1080, + capped: false, + encoderPath: null, + }); + const requestRef = useRef(0); + const previousProbeRef = useRef<Mp4SupportProbeSnapshot | null>(null); + const sourceDimensions = useMemo(() => { + const video = isPreviewReady ? videoPlaybackRef.current?.video : null; + return { width: video?.videoWidth || 1920, height: video?.videoHeight || 1080 }; + }, [isPreviewReady, videoPlaybackRef]); + const gifOutputDimensions = useMemo( + () => + calculateOutputDimensions( + sourceDimensions.width, + sourceDimensions.height, + gifSizePreset, + GIF_SIZE_PRESETS, + ), + [gifSizePreset, sourceDimensions], + ); + const desiredSourceDimensions = useMemo( + () => + calculateMp4SourceDimensions( + sourceDimensions.width, + sourceDimensions.height, + aspectRatio, + cropRegion, + ), + [sourceDimensions, aspectRatio, cropRegion], + ); + const outputDimensions = useMemo(() => { + const width = supportedSourceDimensions.encoderPath + ? supportedSourceDimensions.width + : desiredSourceDimensions.width; + const height = supportedSourceDimensions.encoderPath + ? supportedSourceDimensions.height + : desiredSourceDimensions.height; + return { + medium: calculateMp4ExportDimensions(width, height, "medium"), + good: calculateMp4ExportDimensions(width, height, "good"), + high: calculateMp4ExportDimensions(width, height, "high"), + source: calculateMp4ExportDimensions(width, height, "source"), + }; + }, [supportedSourceDimensions, desiredSourceDimensions]); + const ensureSupportedSourceDimensions = useCallback( + async (frameRate: ExportMp4FrameRate) => { + const result = await probeSupportedMp4Dimensions({ + width: desiredSourceDimensions.width, + height: desiredSourceDimensions.height, + frameRate, + codec: DEFAULT_MP4_CODEC, + getBitrate: getSourceQualityBitrate, + }); + if (!result.encoderPath) { + throw new Error( + `Video encoding not supported on this system. Tried codec ${DEFAULT_MP4_CODEC} at ${frameRate} FPS up to ${desiredSourceDimensions.width}x${desiredSourceDimensions.height}.`, + ); + } + return result; + }, + [desiredSourceDimensions], + ); + + useEffect(() => { + let cancelled = false; + const requestId = ++requestRef.current; + const snapshot: Mp4SupportProbeSnapshot = { + sourceWidth: sourceDimensions.width, + sourceHeight: sourceDimensions.height, + targetWidth: desiredSourceDimensions.width, + targetHeight: desiredSourceDimensions.height, + aspectRatio, + frameRate: mp4FrameRate, + }; + const shouldDebounce = shouldDebounceMp4SupportProbe(previousProbeRef.current, snapshot); + previousProbeRef.current = snapshot; + const fallback = { ...desiredSourceDimensions, capped: false, encoderPath: null }; + setSupportedSourceDimensions(fallback); + const runProbe = () => { + void ensureSupportedSourceDimensions(mp4FrameRate) + .then((result) => { + if (!cancelled && requestId === requestRef.current) + setSupportedSourceDimensions(result); + }) + .catch(() => { + if (!cancelled && requestId === requestRef.current) + setSupportedSourceDimensions(fallback); + }); + }; + const timeoutId = shouldDebounce + ? window.setTimeout(runProbe, MP4_CROP_PROBE_DEBOUNCE_MS) + : null; + if (timeoutId === null) runProbe(); + return () => { + cancelled = true; + if (timeoutId !== null) window.clearTimeout(timeoutId); + }; + }, [ + sourceDimensions, + desiredSourceDimensions, + aspectRatio, + mp4FrameRate, + ensureSupportedSourceDimensions, + ]); + + return { + gifOutputDimensions, + mp4OutputDimensions: outputDimensions, + supportedMp4SourceDimensions: supportedSourceDimensions, + ensureSupportedMp4SourceDimensions: ensureSupportedSourceDimensions, + }; +} diff --git a/src/components/video-editor/export/useExportMessages.test.ts b/src/components/video-editor/export/useExportMessages.test.ts new file mode 100644 index 000000000..2d815b19e --- /dev/null +++ b/src/components/video-editor/export/useExportMessages.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; +import { buildExportMessageStream, type ExportMessage } from "./useExportMessages"; + +function message(id: string): ExportMessage { + return { id, text: id, durationSeconds: 6 }; +} + +describe("buildExportMessageStream", () => { + it("alternates built-in tips with streamed announcements", () => { + expect( + buildExportMessageStream( + [message("tip-1"), message("tip-2"), message("tip-3")], + [message("announcement-1"), message("announcement-2")], + ).map(({ id }) => id), + ).toEqual(["tip-1", "announcement-1", "tip-2", "announcement-2", "tip-3"]); + }); + + it("uses only built-in tips when the announcement feed is empty", () => { + expect( + buildExportMessageStream([message("tip-1"), message("tip-2")], []).map(({ id }) => id), + ).toEqual(["tip-1", "tip-2"]); + }); +}); diff --git a/src/components/video-editor/export/useExportMessages.ts b/src/components/video-editor/export/useExportMessages.ts new file mode 100644 index 000000000..f89d81527 --- /dev/null +++ b/src/components/video-editor/export/useExportMessages.ts @@ -0,0 +1,141 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { BUNDLED_ANNOUNCEMENT_FEED } from "@/content/announcements"; +import type { useI18n } from "@/contexts/I18nContext"; +import { + readAnnouncementImpressionCounts, + readDismissedAnnouncementIds, + recordAnnouncementImpression, +} from "@/lib/announcementState"; +import type { Announcement } from "@/lib/announcements"; +import { parseAnnouncementFeed, selectAnnouncements } from "@/lib/announcements"; + +const DEFAULT_MESSAGE_DURATION_SECONDS = 6; +const MAX_EXPORT_MESSAGE_LENGTH = 220; + +export type ExportMessage = { + id: string; + text: string; + durationSeconds: number; + announcementId?: string; +}; + +function compactMessage(value: string): string { + const compacted = value.replace(/\s+/g, " ").trim(); + if (compacted.length <= MAX_EXPORT_MESSAGE_LENGTH) return compacted; + return `${compacted.slice(0, MAX_EXPORT_MESSAGE_LENGTH - 1).trimEnd()}…`; +} + +export function buildExportMessageStream( + tips: ExportMessage[], + announcements: ExportMessage[], +): ExportMessage[] { + const stream: ExportMessage[] = []; + const length = Math.max(tips.length, announcements.length); + for (let index = 0; index < length; index += 1) { + if (tips[index]) stream.push(tips[index]); + if (announcements[index]) stream.push(announcements[index]); + } + return stream; +} + +function toExportMessage(announcement: Announcement): ExportMessage { + return { + id: `announcement:${announcement.id}`, + announcementId: announcement.id, + text: compactMessage(`${announcement.title} — ${announcement.body}`), + durationSeconds: announcement.displayDurationSeconds ?? DEFAULT_MESSAGE_DURATION_SECONDS, + }; +} + +export function useExportMessages({ + t, + active, +}: { + t: ReturnType<typeof useI18n>["t"]; + active: boolean; +}): string | null { + const [announcementMessages, setAnnouncementMessages] = useState<ExportMessage[]>([]); + const [currentIndex, setCurrentIndex] = useState(0); + const countedThisSessionRef = useRef(new Set<string>()); + const tips = useMemo<ExportMessage[]>( + () => [ + { + id: "tip:auto-zooms", + text: t( + "editor.exportTips.autoZooms", + "Tip: Turn off auto-applied zooms in settings", + ), + durationSeconds: DEFAULT_MESSAGE_DURATION_SECONDS, + }, + { + id: "tip:experimental-builds", + text: t( + "editor.exportTips.experimentalBuilds", + "Tip: Try experimental builds by turning on access in settings", + ), + durationSeconds: DEFAULT_MESSAGE_DURATION_SECONDS, + }, + { + id: "tip:cursor-appearance", + text: t( + "editor.exportTips.cursorAppearance", + "Tip: You can customise your cursor appearance", + ), + durationSeconds: DEFAULT_MESSAGE_DURATION_SECONDS, + }, + ], + [t], + ); + const messages = useMemo( + () => buildExportMessageStream(tips, announcementMessages), + [tips, announcementMessages], + ); + const current = messages[currentIndex % messages.length]; + + useEffect(() => { + let cancelled = false; + const loadExportAnnouncements = async () => { + const dismissedIds = new Set(readDismissedAnnouncementIds()); + const impressionCounts = readAnnouncementImpressionCounts(); + const [appVersion, remoteFeed] = await Promise.all([ + window.electronAPI.getAppVersion().catch(() => "0.0.0"), + window.electronAPI.getAnnouncements().catch(() => null), + ]); + if (cancelled) return; + setAnnouncementMessages( + selectAnnouncements({ + bundled: BUNDLED_ANNOUNCEMENT_FEED.announcements, + remote: parseAnnouncementFeed(remoteFeed).announcements, + dismissedIds, + impressionCounts, + appVersion, + audience: "editor", + }) + .filter((announcement) => announcement.presentation === "export") + .map(toExportMessage), + ); + }; + void loadExportAnnouncements(); + return () => { + cancelled = true; + }; + }, []); + + useEffect(() => { + if (!active || messages.length < 2 || !current) return; + const timeout = window.setTimeout( + () => setCurrentIndex((index) => (index + 1) % messages.length), + current.durationSeconds * 1_000, + ); + return () => window.clearTimeout(timeout); + }, [active, current, messages.length]); + + useEffect(() => { + const announcementId = active ? current?.announcementId : undefined; + if (!announcementId || countedThisSessionRef.current.has(announcementId)) return; + countedThisSessionRef.current.add(announcementId); + recordAnnouncementImpression(announcementId); + }, [active, current]); + + return active ? (current?.text ?? null) : null; +} diff --git a/src/components/video-editor/export/useExportRunner.ts b/src/components/video-editor/export/useExportRunner.ts new file mode 100644 index 000000000..95aef628f --- /dev/null +++ b/src/components/video-editor/export/useExportRunner.ts @@ -0,0 +1,500 @@ +import { useCallback, useRef } from "react"; +import { toast } from "sonner"; +import { + DEFAULT_MP4_CODEC, + type ExportSettings, + GifExporter, + ModernVideoExporter, + VideoExporter, +} from "@/lib/exporter"; +import { getMp4ExportBitrate } from "@/lib/exporter/exportBitrate"; +import { calculateMp4ExportDimensions } from "../exportDimensions"; +import { resolveMp4ExportRouting } from "../mp4ExportRouting"; +import { resolveMp4ExportSettings } from "../mp4ExportSettings"; +import { createSmokeExportProgressSampler } from "../smokeExportProgress"; +import { buildExportRenderOptions } from "./buildExportRenderOptions"; +import { + type PendingExportSave, + saveExportBlob, + writeSmokeExportReport, +} from "./exportPersistence"; +import { + type ExportRunnerInput, + showExportErrorToast, + useExportSuccessToast, +} from "./exportRunnerSupport"; + +export function useExportRunner(input: ExportRunnerInput) { + const inputRef = useRef(input); + inputRef.current = input; + const showExportSuccessToast = useExportSuccessToast(); + + const handleExport = useCallback( + async (settings: ExportSettings) => { + const { + videoPath, + videoPlaybackRef, + isPlaying, + appearance, + timeline, + exportSettings, + exportSession, + audio, + smokeExportConfig, + effectiveSpeedRegions, + effectiveZoomRegions, + effectiveCursorTelemetry, + effectiveShowCursor, + ensureSupportedMp4SourceDimensions, + captionSidecarPayload, + experimentalNvidiaCudaExport, + nvidiaCudaExportAvailable, + remountPreview, + } = inputRef.current; + const { shadowIntensity, padding } = appearance; + const { audioRegions, clipRegions, selectedClipId } = timeline; + const { + exportQuality, + exportEncodingMode, + exportBackendPreference, + exportPipelineModel, + mp4FrameRate, + } = exportSettings; + const { + setIsExporting, + setExportProgress, + setExportError, + setShowExportDropdown, + setExportedFilePath, + setHasPendingExportSave, + exporterRef, + pendingExportSaveRef, + clearPendingExportSave, + markExportAsSaving, + } = exportSession; + if (!videoPath) { + toast.error("No video loaded"); + return; + } + + const video = videoPlaybackRef.current?.video; + if (!video) { + toast.error("Video not ready"); + return; + } + + setIsExporting(true); + setExportProgress(null); + setExportError(null); + clearPendingExportSave(); + const smokeExportStartedAt = smokeExportConfig.enabled ? performance.now() : null; + + let keepExportDialogOpen = false; + + try { + const wasPlaying = isPlaying; + const restoreTime = video.currentTime; + if (wasPlaying) { + videoPlaybackRef.current?.pause(); + } + + // Get preview CONTAINER dimensions for scaling + const playbackRef = videoPlaybackRef.current; + const containerElement = playbackRef?.containerRef?.current; + const previewWidth = containerElement?.clientWidth || 1920; + const previewHeight = containerElement?.clientHeight || 1080; + const effectiveShadowIntensity = + smokeExportConfig.enabled && smokeExportConfig.shadowIntensity !== undefined + ? smokeExportConfig.shadowIntensity + : shadowIntensity; + const smokeProgressSampler = createSmokeExportProgressSampler({ + enabled: smokeExportConfig.enabled, + startedAtMs: smokeExportStartedAt, + }); + const smokeProgressSamples = smokeProgressSampler.samples; + const recordSmokeProgress = smokeProgressSampler.record; + + if (settings.format === "gif" && settings.gifConfig) { + // GIF Export + const gifExporter = new GifExporter({ + videoUrl: videoPath, + width: settings.gifConfig.width, + height: settings.gifConfig.height, + frameRate: settings.gifConfig.frameRate, + loop: settings.gifConfig.loop, + sizePreset: settings.gifConfig.sizePreset, + ...buildExportRenderOptions({ + appearance, + timeline, + effectiveSpeedRegions, + effectiveZoomRegions, + effectiveCursorTelemetry, + effectiveShowCursor, + previewWidth, + previewHeight, + shadowIntensity: effectiveShadowIntensity, + onProgress: (progress) => { + recordSmokeProgress(progress); + setExportProgress(progress); + }, + }), + videoPadding: padding, + maxDecodeQueue: smokeExportConfig.maxDecodeQueue, + maxPendingFrames: smokeExportConfig.maxPendingFrames, + }); + + exporterRef.current = gifExporter as unknown as VideoExporter; + const result = await gifExporter.export(); + + if (result.success && result.blob) { + const timestamp = Date.now(); + const fileName = `export-${timestamp}.gif`; + markExportAsSaving(); + + const { saveResult, pendingSave } = await saveExportBlob( + result.blob, + fileName, + smokeExportConfig.enabled ? smokeExportConfig.outputPath : null, + ); + + if (saveResult.canceled) { + pendingExportSaveRef.current = pendingSave; + setHasPendingExportSave(true); + setExportError( + "Save dialog canceled. Click Save Again to save without re-rendering.", + ); + toast.info("Save canceled. You can save again without re-exporting."); + keepExportDialogOpen = true; + } else if (saveResult.success && saveResult.path) { + if (smokeExportStartedAt !== null) { + console.log( + `[smoke-export] Completed in ${Math.round(performance.now() - smokeExportStartedAt)}ms (${saveResult.path})`, + ); + } + showExportSuccessToast(saveResult.path); + setExportedFilePath(saveResult.path); + if (smokeExportConfig.enabled) { + window.close(); + return; + } + } else { + setExportError(saveResult.message || "Failed to save GIF"); + toast.error(saveResult.message || "Failed to save GIF"); + if (smokeExportConfig.enabled) { + window.close(); + return; + } + } + } else { + setExportError(result.error || "GIF export failed"); + toast.error(result.error || "GIF export failed"); + if (smokeExportConfig.enabled) { + window.close(); + return; + } + } + } else { + // MP4 Export + const { quality, encodingMode, selectedMp4FrameRate } = + resolveMp4ExportSettings({ + smokeExportConfig: { + enabled: smokeExportConfig.enabled, + quality: smokeExportConfig.quality, + encodingMode: smokeExportConfig.encodingMode, + fps: smokeExportConfig.fps, + }, + settings, + exportQuality, + exportEncodingMode, + mp4FrameRate, + }); + const { + pipelineModel, + useExperimentalNativeExport, + useExperimentalNvidiaCudaExport, + backendPreference, + } = resolveMp4ExportRouting({ + smokeExportConfig: { + enabled: smokeExportConfig.enabled, + pipelineModel: smokeExportConfig.pipelineModel, + useNativeExport: smokeExportConfig.useNativeExport, + backendPreference: smokeExportConfig.backendPreference, + }, + settings, + exportPipelineModel, + exportBackendPreference, + experimentalNvidiaCudaExport, + nvidiaCudaExportAvailable, + }); + const supportedSourceDimensions = + await ensureSupportedMp4SourceDimensions(selectedMp4FrameRate); + const { width: exportWidth, height: exportHeight } = + calculateMp4ExportDimensions( + supportedSourceDimensions.width, + supportedSourceDimensions.height, + quality, + ); + const bitrate = getMp4ExportBitrate({ + width: exportWidth, + height: exportHeight, + frameRate: selectedMp4FrameRate, + quality, + encodingMode, + useModernNativeStaticLayout: useExperimentalNativeExport, + }); + const sourceAudioTrackSettingsForExport = + selectedClipId !== null + ? audio.selectedClipSourceAudioTrackSettings + : audio.activeSourceAudioTrackSettings; + + const exporterConfig = { + videoUrl: videoPath, + width: exportWidth, + height: exportHeight, + frameRate: selectedMp4FrameRate, + bitrate, + codec: DEFAULT_MP4_CODEC, + encodingMode, + preferredEncoderPath: supportedSourceDimensions.encoderPath, + preferredRenderBackend: smokeExportConfig.renderBackend, + experimentalNativeExport: useExperimentalNativeExport, + experimentalNvidiaCudaExport: useExperimentalNvidiaCudaExport, + maxEncodeQueue: smokeExportConfig.maxEncodeQueue, + maxDecodeQueue: smokeExportConfig.maxDecodeQueue, + maxPendingFrames: smokeExportConfig.maxPendingFrames, + ...buildExportRenderOptions({ + appearance, + timeline, + effectiveSpeedRegions, + effectiveZoomRegions, + effectiveCursorTelemetry, + effectiveShowCursor, + previewWidth, + previewHeight, + shadowIntensity: effectiveShadowIntensity, + onProgress: (progress) => { + recordSmokeProgress(progress); + setExportProgress(progress); + }, + }), + audioRegions, + clipRegions, + sourceAudioFallbackPaths: audio.sourceAudioFallbackPaths, + sourceAudioFallbackStartDelayMsByPath: + audio.sourceAudioFallbackStartDelayMsByPath, + sourceAudioTrackSettings: sourceAudioTrackSettingsForExport, + }; + + const exporter = + pipelineModel === "modern" + ? new ModernVideoExporter({ + ...exporterConfig, + backendPreference, + }) + : new VideoExporter(exporterConfig); + + exporterRef.current = exporter; + const result = await exporter.export(); + const smokeExportElapsedMs = + smokeExportStartedAt !== null + ? Math.round(performance.now() - smokeExportStartedAt) + : undefined; + + if (result.success && (result.blob || result.tempFilePath)) { + const timestamp = Date.now(); + const fileName = `export-${timestamp}.mp4`; + const sidecarForThisExport = + settings.includeCaptionSidecar && captionSidecarPayload + ? captionSidecarPayload + : undefined; + markExportAsSaving(); + + let saveResult: { + success: boolean; + path?: string; + message?: string; + canceled?: boolean; + }; + let pendingOnCancel: PendingExportSave; + + if (result.tempFilePath) { + // Preferred path: main process already holds the finished MP4 on + // disk, so we just ask it to move the temp file into place. This + // avoids ever allocating a multi-GiB ArrayBuffer in the renderer. + saveResult = await window.electronAPI.finalizeExportedVideo({ + tempPath: result.tempFilePath, + fileName, + outputPath: + smokeExportConfig.enabled && smokeExportConfig.outputPath + ? smokeExportConfig.outputPath + : null, + captionSidecar: sidecarForThisExport, + }); + pendingOnCancel = { + fileName, + tempFilePath: result.tempFilePath, + captionSidecar: sidecarForThisExport, + }; + } else if (result.blob) { + // Legacy fallback: some export paths still surface a Blob, but in + // Electron we stream it into a temp file first so save/finalize + // never requires a giant renderer ArrayBuffer. + const blobSave = await saveExportBlob( + result.blob, + fileName, + smokeExportConfig.enabled ? smokeExportConfig.outputPath : null, + sidecarForThisExport, + ); + saveResult = blobSave.saveResult; + pendingOnCancel = blobSave.pendingSave; + } else { + saveResult = { success: false, message: "Export produced no output" }; + pendingOnCancel = { fileName }; + } + + if (saveResult.canceled) { + if (smokeExportConfig.enabled) { + await writeSmokeExportReport(smokeExportConfig.outputPath, { + success: false, + phase: "save", + format: "mp4", + pipelineModel, + backendPreference, + encodingMode, + shadowIntensity: effectiveShadowIntensity, + elapsedMs: smokeExportElapsedMs, + error: "Save canceled", + progressSamples: smokeProgressSamples, + metrics: result.metrics, + }); + } + pendingExportSaveRef.current = pendingOnCancel; + setHasPendingExportSave(true); + setExportError( + "Save dialog canceled. Click Save Again to save without re-rendering.", + ); + toast.info("Save canceled. You can save again without re-exporting."); + keepExportDialogOpen = true; + } else if (saveResult.success && saveResult.path) { + if (smokeExportConfig.enabled) { + await writeSmokeExportReport(smokeExportConfig.outputPath, { + success: true, + phase: "saved", + format: "mp4", + pipelineModel, + backendPreference, + encodingMode, + shadowIntensity: effectiveShadowIntensity, + elapsedMs: smokeExportElapsedMs, + outputPath: saveResult.path, + progressSamples: smokeProgressSamples, + metrics: result.metrics, + }); + } + if (smokeExportStartedAt !== null) { + console.log( + `[smoke-export] Completed in ${Math.round(performance.now() - smokeExportStartedAt)}ms (${saveResult.path})`, + ); + } + showExportSuccessToast(saveResult.path); + setExportedFilePath(saveResult.path); + if (smokeExportConfig.enabled) { + window.close(); + return; + } + } else { + if (smokeExportConfig.enabled) { + await writeSmokeExportReport(smokeExportConfig.outputPath, { + success: false, + phase: "save", + format: "mp4", + pipelineModel, + backendPreference, + encodingMode, + shadowIntensity: effectiveShadowIntensity, + elapsedMs: smokeExportElapsedMs, + error: saveResult.message || "Failed to save video", + progressSamples: smokeProgressSamples, + metrics: result.metrics, + }); + } + setExportError(saveResult.message || "Failed to save video"); + showExportErrorToast(saveResult.message || "Failed to save video"); + // Keep the pending-save entry so the user can retry without + // re-rendering. The temp file is still on disk (the main + // process only moves/deletes it on success) and the + // ArrayBuffer fallback still references its in-memory blob. + if (pendingOnCancel.tempFilePath || pendingOnCancel.arrayBuffer) { + pendingExportSaveRef.current = pendingOnCancel; + setHasPendingExportSave(true); + keepExportDialogOpen = true; + } + if (smokeExportConfig.enabled) { + window.close(); + return; + } + } + } else { + if (smokeExportConfig.enabled) { + await writeSmokeExportReport(smokeExportConfig.outputPath, { + success: false, + phase: "export", + format: "mp4", + pipelineModel, + backendPreference, + encodingMode, + shadowIntensity: effectiveShadowIntensity, + elapsedMs: smokeExportElapsedMs, + error: result.error || "Export failed", + progressSamples: smokeProgressSamples, + metrics: result.metrics, + }); + } + setExportError(result.error || "Export failed"); + showExportErrorToast(result.error || "Export failed"); + keepExportDialogOpen = true; + if (smokeExportConfig.enabled) { + window.close(); + return; + } + } + } + + if (wasPlaying) { + await videoPlaybackRef.current?.play().catch(() => undefined); + } else { + video.currentTime = restoreTime; + } + } catch (error) { + console.error("Export error:", error); + const errorMessage = error instanceof Error ? error.message : "Unknown error"; + if (smokeExportConfig.enabled) { + await writeSmokeExportReport(smokeExportConfig.outputPath, { + success: false, + phase: "exception", + format: settings.format, + elapsedMs: + smokeExportStartedAt !== null + ? Math.round(performance.now() - smokeExportStartedAt) + : undefined, + error: errorMessage, + }); + } + setExportError(errorMessage); + showExportErrorToast(`Export failed: ${errorMessage}`); + keepExportDialogOpen = true; + if (smokeExportConfig.enabled) { + window.close(); + } + } finally { + setIsExporting(false); + exporterRef.current = null; + setShowExportDropdown(keepExportDialogOpen); + remountPreview(); + } + }, + [showExportSuccessToast], + ); + + return { handleExport, showExportSuccessToast }; +} diff --git a/src/components/video-editor/export/useExportSession.ts b/src/components/video-editor/export/useExportSession.ts new file mode 100644 index 000000000..b9c92d4e8 --- /dev/null +++ b/src/components/video-editor/export/useExportSession.ts @@ -0,0 +1,49 @@ +import { useCallback, useRef, useState } from "react"; +import type { ExportProgress } from "@/lib/exporter"; +import { resolveSavingExportProgress } from "../exportProgressState"; +import type { PendingExportSave } from "./exportPersistence"; + +export type CancelableExporter = { cancel(): void }; + +export function useExportSession() { + const [isExporting, setIsExporting] = useState(false); + const [exportProgress, setExportProgress] = useState<ExportProgress | null>(null); + const [exportError, setExportError] = useState<string | null>(null); + const [showExportDropdown, setShowExportDropdown] = useState(false); + const [exportedFilePath, setExportedFilePath] = useState<string>(); + const [hasPendingExportSave, setHasPendingExportSave] = useState(false); + const exporterRef = useRef<CancelableExporter | null>(null); + const pendingExportSaveRef = useRef<PendingExportSave | null>(null); + + const clearPendingExportSave = useCallback(() => { + const pendingSave = pendingExportSaveRef.current; + pendingExportSaveRef.current = null; + setHasPendingExportSave(false); + if (pendingSave?.tempFilePath && typeof window !== "undefined") { + void window.electronAPI.discardExportedTemp?.(pendingSave.tempFilePath); + } + }, []); + + const markExportAsSaving = useCallback(() => { + setExportProgress(resolveSavingExportProgress); + }, []); + + return { + isExporting, + setIsExporting, + exportProgress, + setExportProgress, + exportError, + setExportError, + showExportDropdown, + setShowExportDropdown, + exportedFilePath, + setExportedFilePath, + hasPendingExportSave, + setHasPendingExportSave, + exporterRef, + pendingExportSaveRef, + clearPendingExportSave, + markExportAsSaving, + }; +} diff --git a/src/components/video-editor/export/useExportSettings.ts b/src/components/video-editor/export/useExportSettings.ts new file mode 100644 index 000000000..7cd88330b --- /dev/null +++ b/src/components/video-editor/export/useExportSettings.ts @@ -0,0 +1,74 @@ +import { useMemo, useState } from "react"; +import type { + ExportBackendPreference, + ExportEncodingMode, + ExportFormat, + ExportMp4FrameRate, + ExportPipelineModel, + ExportQuality, + GifFrameRate, + GifSizePreset, +} from "@/lib/exporter"; +import type { EditorPreferences } from "../editorPreferences"; +import type { CaptionCue } from "../types"; + +const DEFAULT_MP4_EXPORT_FRAME_RATE: ExportMp4FrameRate = 30; + +export function useExportSettings(preferences: EditorPreferences, autoCaptions: CaptionCue[]) { + const [includeCaptionSidecar, setIncludeCaptionSidecar] = useState(false); + const [exportQuality, setExportQuality] = useState<ExportQuality>(preferences.exportQuality); + const [exportEncodingMode, setExportEncodingMode] = useState<ExportEncodingMode>( + preferences.exportEncodingMode, + ); + const [exportBackendPreference, setExportBackendPreference] = useState<ExportBackendPreference>( + preferences.exportBackendPreference, + ); + const [exportPipelineModel, setExportPipelineModel] = useState<ExportPipelineModel>( + preferences.exportPipelineModel, + ); + const [mp4FrameRate, setMp4FrameRate] = useState<ExportMp4FrameRate>( + preferences.mp4FrameRate ?? DEFAULT_MP4_EXPORT_FRAME_RATE, + ); + const [exportFormat, setExportFormat] = useState<ExportFormat>(preferences.exportFormat); + const [gifFrameRate, setGifFrameRate] = useState<GifFrameRate>(preferences.gifFrameRate); + const [gifLoop, setGifLoop] = useState(preferences.gifLoop); + const [gifSizePreset, setGifSizePreset] = useState<GifSizePreset>(preferences.gifSizePreset); + const captionSidecarCues = useMemo( + () => + autoCaptions + .filter( + (cue) => + Number.isFinite(cue.startMs) && + Number.isFinite(cue.endMs) && + cue.endMs > cue.startMs && + typeof cue.text === "string" && + cue.text.trim().length > 0, + ) + .map(({ startMs, endMs, text }) => ({ startMs, endMs, text })), + [autoCaptions], + ); + + return { + includeCaptionSidecar, + setIncludeCaptionSidecar, + exportQuality, + setExportQuality, + exportEncodingMode, + setExportEncodingMode, + exportBackendPreference, + setExportBackendPreference, + exportPipelineModel, + setExportPipelineModel, + mp4FrameRate, + setMp4FrameRate, + exportFormat, + setExportFormat, + gifFrameRate, + setGifFrameRate, + gifLoop, + setGifLoop, + gifSizePreset, + setGifSizePreset, + captionSidecarCues, + }; +} diff --git a/src/components/video-editor/export/useExportStatusViewModel.ts b/src/components/video-editor/export/useExportStatusViewModel.ts new file mode 100644 index 000000000..1af90e92e --- /dev/null +++ b/src/components/video-editor/export/useExportStatusViewModel.ts @@ -0,0 +1,58 @@ +import type { useI18n } from "@/contexts/I18nContext"; +import { resolveExportStatusModel } from "../exportStatusModel"; +import type { useExportSession } from "./useExportSession"; +import type { useExportSettings } from "./useExportSettings"; + +type Input = { + t: ReturnType<typeof useI18n>["t"]; + session: ReturnType<typeof useExportSession>; + settings: ReturnType<typeof useExportSettings>; +}; + +export function useExportStatusViewModel({ t, session, settings }: Input) { + const status = resolveExportStatusModel({ + isExporting: session.isExporting, + exportProgress: session.exportProgress, + exportFormat: settings.exportFormat, + exportPipelineModel: settings.exportPipelineModel, + }); + const exportRenderSpeedLabel = status.renderSpeedFps + ? t("editor.exportStatus.renderSpeed", "Render speed {{fps}} FPS", { + fps: status.renderSpeedFps, + }) + : null; + const progress = session.exportProgress; + const exportPercentLabel = progress + ? status.isExportPreparing + ? t("editor.exportStatus.preparing", "Preparing export...") + : status.isExportSaving + ? t("editor.exportStatus.saving", "Opening save dialog...") + : status.isRenderingAudio + ? t("editor.exportStatus.renderingAudio", "Rendering audio {{percent}}%", { + percent: Math.round((progress.audioProgress ?? 0) * 100), + }) + : status.isExportFinalizing + ? settings.exportFormat === "mp4" && + settings.exportPipelineModel === "modern" + ? status.isExportFinalSaveIndeterminate + ? t( + "editor.exportStatus.muxingAndSaving", + "Muxing audio and saving file...", + ) + : t( + "editor.exportStatus.muxingAndSavingPercent", + "Muxing and saving {{percent}}%", + { percent: status.exportFinalizingPercent ?? 100 }, + ) + : t( + "editor.exportStatus.finalizingPercent", + "Finalizing {{percent}}%", + { percent: status.exportFinalizingPercent ?? 100 }, + ) + : t("editor.exportStatus.completePercent", "{{percent}}% complete", { + percent: Math.round(progress.percentage), + }) + : t("editor.exportStatus.preparing", "Preparing export..."); + + return { ...status, exportRenderSpeedLabel, exportPercentLabel }; +} diff --git a/src/components/video-editor/export/useSmokeExportAutomation.ts b/src/components/video-editor/export/useSmokeExportAutomation.ts new file mode 100644 index 000000000..a7830e5e3 --- /dev/null +++ b/src/components/video-editor/export/useSmokeExportAutomation.ts @@ -0,0 +1,108 @@ +import { useEffect, useRef } from "react"; +import type { ExportSettings } from "@/lib/exporter"; +import type { getSmokeExportConfig } from "../smokeExportConfig"; +import { writeSmokeExportReport } from "./exportPersistence"; + +const SMOKE_EXPORT_READY_TIMEOUT_MS = 30_000; + +type UseSmokeExportAutomationInput = { + config: ReturnType<typeof getSmokeExportConfig>; + cursorTelemetrySourcePath: string | null; + duration: number; + error: string | null; + isPreviewReady: boolean; + loading: boolean; + videoPath: string | null; + videoSourcePath: string | null; + handleExport: (settings: ExportSettings) => void; +}; + +export function useSmokeExportAutomation({ + config, + cursorTelemetrySourcePath, + duration, + error, + isPreviewReady, + loading, + videoPath, + videoSourcePath, + handleExport, +}: UseSmokeExportAutomationInput) { + const startedRef = useRef(false); + const readyStateRef = useRef<Record<string, unknown>>({}); + + useEffect(() => { + readyStateRef.current = { + cursorTelemetrySourcePath, + duration, + hasVideoPath: Boolean(videoPath), + isPreviewReady, + loading, + projectPath: config.projectPath ?? null, + videoSourcePath, + }; + }, [ + cursorTelemetrySourcePath, + duration, + isPreviewReady, + loading, + config.projectPath, + videoPath, + videoSourcePath, + ]); + + useEffect(() => { + if (!config.enabled) return; + const timeoutId = window.setTimeout(() => { + if (startedRef.current) return; + startedRef.current = true; + void writeSmokeExportReport(config.outputPath, { + success: false, + phase: "ready", + error: `Smoke export did not become ready within ${SMOKE_EXPORT_READY_TIMEOUT_MS}ms.`, + readyState: readyStateRef.current, + }).finally(() => window.close()); + }, SMOKE_EXPORT_READY_TIMEOUT_MS); + return () => window.clearTimeout(timeoutId); + }, [config.enabled, config.outputPath]); + + useEffect(() => { + if (!config.enabled || startedRef.current) return; + if (error) { + startedRef.current = true; + console.error(`[smoke-export] ${error}`); + void writeSmokeExportReport(config.outputPath, { + success: false, + phase: "load", + error, + readyState: readyStateRef.current, + }).finally(() => window.close()); + return; + } + if (!videoPath || loading || !isPreviewReady || duration <= 0) return; + if ( + config.projectPath && + videoSourcePath && + cursorTelemetrySourcePath !== videoSourcePath + ) { + return; + } + + startedRef.current = true; + void handleExport({ + format: "mp4", + quality: "good", + encodingMode: config.encodingMode ?? "balanced", + }); + }, [ + cursorTelemetrySourcePath, + error, + handleExport, + isPreviewReady, + loading, + duration, + config, + videoPath, + videoSourcePath, + ]); +} diff --git a/src/components/video-editor/exportDimensions.test.ts b/src/components/video-editor/exportDimensions.test.ts new file mode 100644 index 000000000..5797daaed --- /dev/null +++ b/src/components/video-editor/exportDimensions.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it } from "vitest"; +import { + calculateMp4ExportDimensions, + calculateMp4SourceDimensions, + shouldDebounceMp4SupportProbe, +} from "./exportDimensions"; + +describe("calculateMp4SourceDimensions", () => { + it("keeps native exports at the source dimensions", () => { + expect(calculateMp4SourceDimensions(1920, 1080, "native")).toEqual({ + width: 1920, + height: 1080, + }); + }); + + it("uses the cropped source bounds for native exports", () => { + expect( + calculateMp4SourceDimensions(320, 180, "native", { + width: 1, + height: 0.8, + }), + ).toEqual({ + width: 320, + height: 144, + }); + }); + + it("uses the rotated source bounds for 9:16 original exports", () => { + expect(calculateMp4SourceDimensions(1920, 1080, "9:16")).toEqual({ + width: 1080, + height: 1920, + }); + }); + + it("ignores crop bounds for fixed-aspect exports", () => { + expect( + calculateMp4SourceDimensions(1920, 1080, "9:16", { + width: 0.5, + height: 0.5, + }), + ).toEqual({ + width: 1080, + height: 1920, + }); + }); + + it("uses the rotated source bounds for portrait social ratios", () => { + expect(calculateMp4SourceDimensions(1920, 1080, "4:5")).toEqual({ + width: 1080, + height: 1350, + }); + }); + + it("keeps landscape aspect-ratio exports inside the source bounds", () => { + expect(calculateMp4SourceDimensions(1920, 1080, "4:3")).toEqual({ + width: 1440, + height: 1080, + }); + }); +}); + +describe("calculateMp4ExportDimensions", () => { + it("normalizes odd source dimensions to even export dimensions", () => { + const sourceDimensions = calculateMp4SourceDimensions(1919, 1079, "native"); + + expect(sourceDimensions).toEqual({ + width: 1918, + height: 1078, + }); + expect( + calculateMp4ExportDimensions(sourceDimensions.width, sourceDimensions.height, "source"), + ).toEqual({ + width: 1918, + height: 1078, + }); + expect( + calculateMp4ExportDimensions(sourceDimensions.width, sourceDimensions.height, "high"), + ).toEqual({ + width: 1726, + height: 970, + }); + }); + + it("scales portrait output dimensions from the aspect target", () => { + const sourceDimensions = calculateMp4SourceDimensions(1920, 1080, "9:16"); + + expect( + calculateMp4ExportDimensions(sourceDimensions.width, sourceDimensions.height, "source"), + ).toEqual({ + width: 1080, + height: 1920, + }); + expect( + calculateMp4ExportDimensions(sourceDimensions.width, sourceDimensions.height, "high"), + ).toEqual({ + width: 972, + height: 1728, + }); + }); +}); + +describe("shouldDebounceMp4SupportProbe", () => { + const baseSnapshot = { + sourceWidth: 1920, + sourceHeight: 1080, + targetWidth: 1920, + targetHeight: 1080, + aspectRatio: "native" as const, + frameRate: 30 as const, + }; + + it("debounces only native crop-driven target changes", () => { + expect( + shouldDebounceMp4SupportProbe(baseSnapshot, { + ...baseSnapshot, + targetHeight: 864, + }), + ).toBe(true); + }); + + it("keeps non-crop probe changes immediate", () => { + expect(shouldDebounceMp4SupportProbe(null, baseSnapshot)).toBe(false); + expect( + shouldDebounceMp4SupportProbe(baseSnapshot, { + ...baseSnapshot, + frameRate: 60, + }), + ).toBe(false); + expect( + shouldDebounceMp4SupportProbe(baseSnapshot, { + ...baseSnapshot, + sourceWidth: 1280, + sourceHeight: 720, + targetWidth: 1280, + targetHeight: 720, + }), + ).toBe(false); + expect( + shouldDebounceMp4SupportProbe(baseSnapshot, { + ...baseSnapshot, + aspectRatio: "16:9", + }), + ).toBe(false); + }); +}); diff --git a/src/components/video-editor/exportDimensions.ts b/src/components/video-editor/exportDimensions.ts new file mode 100644 index 000000000..2f9dcdc23 --- /dev/null +++ b/src/components/video-editor/exportDimensions.ts @@ -0,0 +1,104 @@ +import type { ExportMp4FrameRate, ExportQuality } from "@/lib/exporter"; +import { type AspectRatio, getAspectRatioValue } from "@/utils/aspectRatioUtils"; + +export type Mp4SupportProbeSnapshot = { + sourceWidth: number; + sourceHeight: number; + targetWidth: number; + targetHeight: number; + aspectRatio: AspectRatio; + frameRate: ExportMp4FrameRate; +}; + +export function shouldDebounceMp4SupportProbe( + previous: Mp4SupportProbeSnapshot | null, + current: Mp4SupportProbeSnapshot, +): boolean { + if ( + !previous || + current.aspectRatio !== "native" || + previous.aspectRatio !== current.aspectRatio || + previous.frameRate !== current.frameRate || + previous.sourceWidth !== current.sourceWidth || + previous.sourceHeight !== current.sourceHeight + ) { + return false; + } + + return ( + previous.targetWidth !== current.targetWidth || + previous.targetHeight !== current.targetHeight + ); +} + +function normalizeEvenDimension(value: number): number { + return Math.max(2, Math.floor(value / 2) * 2); +} + +function fitAspectRatioWithinBounds( + maxWidth: number, + maxHeight: number, + aspectRatioValue: number, +): { width: number; height: number } { + const safeMaxWidth = normalizeEvenDimension(maxWidth); + const safeMaxHeight = normalizeEvenDimension(maxHeight); + const safeAspectRatio = + Number.isFinite(aspectRatioValue) && aspectRatioValue > 0 ? aspectRatioValue : 16 / 9; + + if (safeMaxWidth / safeMaxHeight > safeAspectRatio) { + const height = safeMaxHeight; + const width = normalizeEvenDimension(height * safeAspectRatio); + return { width: Math.min(width, safeMaxWidth), height }; + } + + const width = safeMaxWidth; + const height = normalizeEvenDimension(width / safeAspectRatio); + return { width, height: Math.min(height, safeMaxHeight) }; +} + +export function calculateMp4SourceDimensions( + sourceWidth: number, + sourceHeight: number, + aspectRatio: AspectRatio, + cropRegion?: { width: number; height: number }, +): { width: number; height: number } { + const useCroppedBounds = aspectRatio === "native"; + const safeSourceWidth = normalizeEvenDimension( + sourceWidth * (useCroppedBounds ? (cropRegion?.width ?? 1) : 1), + ); + const safeSourceHeight = normalizeEvenDimension( + sourceHeight * (useCroppedBounds ? (cropRegion?.height ?? 1) : 1), + ); + const sourceAspectRatio = safeSourceHeight > 0 ? safeSourceWidth / safeSourceHeight : 16 / 9; + const aspectRatioValue = getAspectRatioValue(aspectRatio, sourceAspectRatio); + + if (aspectRatio === "native") { + return { width: safeSourceWidth, height: safeSourceHeight }; + } + + const longSide = Math.max(safeSourceWidth, safeSourceHeight); + const shortSide = Math.min(safeSourceWidth, safeSourceHeight); + const maxWidth = aspectRatioValue >= 1 ? longSide : shortSide; + const maxHeight = aspectRatioValue >= 1 ? shortSide : longSide; + + return fitAspectRatioWithinBounds(maxWidth, maxHeight, aspectRatioValue); +} + +export function calculateMp4ExportDimensions( + baseWidth: number, + baseHeight: number, + quality: ExportQuality, +): { width: number; height: number } { + if (quality === "source") { + return { + width: normalizeEvenDimension(baseWidth), + height: normalizeEvenDimension(baseHeight), + }; + } + + const qualityScale = quality === "medium" ? 0.6 : quality === "good" ? 0.75 : 0.9; + return { + width: normalizeEvenDimension(baseWidth * qualityScale), + height: normalizeEvenDimension(baseHeight * qualityScale), + }; +} diff --git a/src/components/video-editor/exportProgressState.test.ts b/src/components/video-editor/exportProgressState.test.ts new file mode 100644 index 000000000..a17f2f3fc --- /dev/null +++ b/src/components/video-editor/exportProgressState.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest"; +import type { ExportProgress } from "@/lib/exporter"; +import { resolveSavingExportProgress } from "./exportProgressState"; + +function progress(overrides: Partial<ExportProgress> = {}): ExportProgress { + return { + currentFrame: 24, + totalFrames: 120, + percentage: 20, + estimatedTimeRemaining: 12, + ...overrides, + }; +} + +describe("resolveSavingExportProgress", () => { + it("marks progress as saving while preserving runtime diagnostics", () => { + expect( + resolveSavingExportProgress( + progress({ + renderFps: 144.25, + renderBackend: "webgpu", + encodeBackend: "ffmpeg", + encoderName: "h264_nvenc", + }), + ), + ).toEqual({ + currentFrame: 120, + totalFrames: 120, + percentage: 100, + estimatedTimeRemaining: 0, + renderFps: 144.25, + renderBackend: "webgpu", + encodeBackend: "ffmpeg", + encoderName: "h264_nvenc", + phase: "saving", + }); + }); + + it("preserves the existing zero-total-frame behavior", () => { + expect( + resolveSavingExportProgress( + progress({ + currentFrame: 36, + totalFrames: 0, + }), + ), + ).toMatchObject({ + currentFrame: 0, + totalFrames: 0, + phase: "saving", + }); + }); + + it("uses a minimal fallback when there is no previous progress", () => { + expect(resolveSavingExportProgress(null)).toEqual({ + currentFrame: 1, + totalFrames: 1, + percentage: 100, + estimatedTimeRemaining: 0, + renderFps: undefined, + renderBackend: undefined, + encodeBackend: undefined, + encoderName: undefined, + phase: "saving", + }); + }); +}); diff --git a/src/components/video-editor/exportProgressState.ts b/src/components/video-editor/exportProgressState.ts new file mode 100644 index 000000000..a928fdb0b --- /dev/null +++ b/src/components/video-editor/exportProgressState.ts @@ -0,0 +1,15 @@ +import type { ExportProgress } from "@/lib/exporter"; + +export function resolveSavingExportProgress(previous: ExportProgress | null): ExportProgress { + return { + currentFrame: previous?.totalFrames ?? previous?.currentFrame ?? 1, + totalFrames: previous?.totalFrames ?? previous?.currentFrame ?? 1, + percentage: 100, + estimatedTimeRemaining: 0, + renderFps: previous?.renderFps, + renderBackend: previous?.renderBackend, + encodeBackend: previous?.encodeBackend, + encoderName: previous?.encoderName, + phase: "saving", + }; +} diff --git a/src/components/video-editor/exportStartSettings.test.ts b/src/components/video-editor/exportStartSettings.test.ts new file mode 100644 index 000000000..567807da6 --- /dev/null +++ b/src/components/video-editor/exportStartSettings.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "vitest"; +import { resolveExportStartSettings } from "./exportStartSettings"; + +const baseOptions = { + sourceWidth: 1920, + sourceHeight: 1080, + exportFormat: "mp4" as const, + includeCaptionSidecar: true, + exportEncodingMode: "balanced" as const, + exportQuality: "good" as const, + mp4FrameRate: 30 as const, + exportBackendPreference: "auto" as const, + exportPipelineModel: "modern" as const, + gifFrameRate: 20 as const, + gifLoop: true, + gifSizePreset: "medium" as const, +}; + +describe("resolveExportStartSettings", () => { + it("preserves MP4 dropdown settings", () => { + expect(resolveExportStartSettings(baseOptions)).toEqual({ + format: "mp4", + includeCaptionSidecar: true, + encodingMode: "balanced", + mp4FrameRate: 30, + backendPreference: "auto", + pipelineModel: "modern", + quality: "good", + gifConfig: undefined, + }); + }); + + it("omits MP4-only fields and resolves GIF dimensions for GIF exports", () => { + expect( + resolveExportStartSettings({ + ...baseOptions, + sourceWidth: 2560, + sourceHeight: 1440, + exportFormat: "gif", + gifFrameRate: 15, + gifLoop: false, + gifSizePreset: "medium", + }), + ).toEqual({ + format: "gif", + includeCaptionSidecar: false, + encodingMode: undefined, + mp4FrameRate: undefined, + backendPreference: undefined, + pipelineModel: undefined, + quality: undefined, + gifConfig: { + frameRate: 15, + loop: false, + sizePreset: "medium", + width: 1280, + height: 720, + }, + }); + }); + + it("keeps original GIF dimensions when the original preset is selected", () => { + expect( + resolveExportStartSettings({ + ...baseOptions, + sourceWidth: 1234, + sourceHeight: 678, + exportFormat: "gif", + gifSizePreset: "original", + }).gifConfig, + ).toMatchObject({ + sizePreset: "original", + width: 1234, + height: 678, + }); + }); +}); diff --git a/src/components/video-editor/exportStartSettings.ts b/src/components/video-editor/exportStartSettings.ts new file mode 100644 index 000000000..dc0aeab72 --- /dev/null +++ b/src/components/video-editor/exportStartSettings.ts @@ -0,0 +1,66 @@ +import { + calculateOutputDimensions, + type ExportBackendPreference, + type ExportEncodingMode, + type ExportFormat, + type ExportMp4FrameRate, + type ExportPipelineModel, + type ExportQuality, + type ExportSettings, + GIF_SIZE_PRESETS, + type GifFrameRate, + type GifSizePreset, +} from "@/lib/exporter"; + +export function resolveExportStartSettings({ + sourceWidth, + sourceHeight, + exportFormat, + includeCaptionSidecar, + exportEncodingMode, + exportQuality, + mp4FrameRate, + exportBackendPreference, + exportPipelineModel, + gifFrameRate, + gifLoop, + gifSizePreset, +}: { + sourceWidth: number; + sourceHeight: number; + exportFormat: ExportFormat; + includeCaptionSidecar: boolean; + exportEncodingMode: ExportEncodingMode; + exportQuality: ExportQuality; + mp4FrameRate: ExportMp4FrameRate; + exportBackendPreference: ExportBackendPreference; + exportPipelineModel: ExportPipelineModel; + gifFrameRate: GifFrameRate; + gifLoop: boolean; + gifSizePreset: GifSizePreset; +}): ExportSettings { + const gifDimensions = + exportFormat === "gif" + ? calculateOutputDimensions(sourceWidth, sourceHeight, gifSizePreset, GIF_SIZE_PRESETS) + : null; + + return { + format: exportFormat, + includeCaptionSidecar: exportFormat === "mp4" ? includeCaptionSidecar : false, + encodingMode: exportFormat === "mp4" ? exportEncodingMode : undefined, + mp4FrameRate: exportFormat === "mp4" ? mp4FrameRate : undefined, + backendPreference: exportFormat === "mp4" ? exportBackendPreference : undefined, + pipelineModel: exportFormat === "mp4" ? exportPipelineModel : undefined, + quality: exportFormat === "mp4" ? exportQuality : undefined, + gifConfig: + exportFormat === "gif" && gifDimensions + ? { + frameRate: gifFrameRate, + loop: gifLoop, + sizePreset: gifSizePreset, + width: gifDimensions.width, + height: gifDimensions.height, + } + : undefined, + }; +} diff --git a/src/components/video-editor/exportStatusModel.test.ts b/src/components/video-editor/exportStatusModel.test.ts new file mode 100644 index 000000000..9cb3158ac --- /dev/null +++ b/src/components/video-editor/exportStatusModel.test.ts @@ -0,0 +1,176 @@ +import { describe, expect, it } from "vitest"; +import type { ExportProgress } from "@/lib/exporter"; +import { resolveExportStatusModel } from "./exportStatusModel"; + +function progress(overrides: Partial<ExportProgress> = {}): ExportProgress { + return { + currentFrame: 10, + totalFrames: 100, + percentage: 10, + estimatedTimeRemaining: 30, + ...overrides, + }; +} + +describe("resolveExportStatusModel", () => { + it("marks a modern MP4 export as preparing before the first progress event", () => { + const status = resolveExportStatusModel({ + isExporting: true, + exportProgress: null, + exportFormat: "mp4", + exportPipelineModel: "modern", + }); + + expect(status.isExportPreparing).toBe(true); + expect(status.isLightningExportInProgress).toBe(true); + expect(status.shouldSuspendPreviewRendering).toBe(true); + expect(status.renderSpeedFps).toBeNull(); + }); + + it("marks legacy MP4 progress separately from the modern path", () => { + const status = resolveExportStatusModel({ + isExporting: true, + exportProgress: progress(), + exportFormat: "mp4", + exportPipelineModel: "legacy", + }); + + expect(status.isLegacyExportInProgress).toBe(true); + expect(status.isLightningExportInProgress).toBe(false); + expect(status.shouldSuspendPreviewRendering).toBe(false); + }); + + it("derives finalizing progress from render progress and clamps the display value", () => { + const status = resolveExportStatusModel({ + isExporting: true, + exportProgress: progress({ + phase: "finalizing", + percentage: 96, + renderProgress: 104.4, + }), + exportFormat: "mp4", + exportPipelineModel: "modern", + }); + + expect(status.exportFinalizingProgress).toBe(100); + expect(status.exportFinalizingPercent).toBe(100); + expect(status.isExportMuxingAndSaving).toBe(true); + expect(status.isExportFinalSaveIndeterminate).toBe(true); + }); + + it("sanitizes invalid finalizing progress before rounding", () => { + const negativeStatus = resolveExportStatusModel({ + isExporting: true, + exportProgress: progress({ + phase: "finalizing", + renderProgress: -12.4, + }), + exportFormat: "mp4", + exportPipelineModel: "modern", + }); + const infiniteStatus = resolveExportStatusModel({ + isExporting: true, + exportProgress: progress({ + phase: "finalizing", + renderProgress: Number.POSITIVE_INFINITY, + }), + exportFormat: "mp4", + exportPipelineModel: "modern", + }); + const nanStatus = resolveExportStatusModel({ + isExporting: true, + exportProgress: progress({ + phase: "finalizing", + renderProgress: Number.NaN, + }), + exportFormat: "mp4", + exportPipelineModel: "modern", + }); + + expect(negativeStatus.exportFinalizingProgress).toBe(0); + expect(negativeStatus.exportFinalizingPercent).toBe(0); + expect(infiniteStatus.exportFinalizingProgress).toBe(0); + expect(infiniteStatus.exportFinalizingPercent).toBe(0); + expect(nanStatus.exportFinalizingProgress).toBe(0); + expect(nanStatus.exportFinalizingPercent).toBe(0); + }); + + it("keeps audio finalization out of the muxing-and-saving state", () => { + const status = resolveExportStatusModel({ + isExporting: true, + exportProgress: progress({ + phase: "finalizing", + audioProgress: 0.42, + renderProgress: 80, + }), + exportFormat: "mp4", + exportPipelineModel: "modern", + }); + + expect(status.isRenderingAudio).toBe(true); + expect(status.isExportMuxingAndSaving).toBe(false); + expect(status.isExportFinalSaveIndeterminate).toBe(false); + }); + + it("shows render speed only during active render progress", () => { + const renderingStatus = resolveExportStatusModel({ + isExporting: true, + exportProgress: progress({ renderFps: 123.456 }), + exportFormat: "mp4", + exportPipelineModel: "modern", + }); + const finalizingStatus = resolveExportStatusModel({ + isExporting: true, + exportProgress: progress({ phase: "finalizing", renderFps: 123.456 }), + exportFormat: "mp4", + exportPipelineModel: "modern", + }); + + expect(renderingStatus.renderSpeedFps).toBe("123.5"); + expect(finalizingStatus.renderSpeedFps).toBeNull(); + }); + + it("builds runtime labels from render and encode backends", () => { + const status = resolveExportStatusModel({ + isExporting: true, + exportProgress: progress({ + renderBackend: "webgpu", + encodeBackend: "ffmpeg", + encoderName: "h264_nvenc", + }), + exportFormat: "mp4", + exportPipelineModel: "modern", + }); + + expect(status.runtimeLabel).toBe("WebGPU + Breeze (h264_nvenc)"); + }); + + it("falls back to the encoder name when backend labels are unavailable", () => { + const status = resolveExportStatusModel({ + isExporting: true, + exportProgress: progress({ encoderName: "VideoEncoder" }), + exportFormat: "mp4", + exportPipelineModel: "modern", + }); + + expect(status.runtimeLabel).toBe("VideoEncoder"); + }); + + it("prefers multiple native skip reasons over the single legacy reason", () => { + const status = resolveExportStatusModel({ + isExporting: true, + exportProgress: progress({ + nativeStaticLayoutSkipReason: "legacy-reason", + nativeStaticLayoutSkipReasons: ["timeline-edits-present", "unsupported-background"], + }), + exportFormat: "mp4", + exportPipelineModel: "modern", + }); + + expect(status.nativeSkipReasons).toEqual([ + "timeline-edits-present", + "unsupported-background", + ]); + expect(status.nativeSkipLabel).toBe("Native skipped: timeline-edits-present (+1 more)"); + }); +}); diff --git a/src/components/video-editor/exportStatusModel.ts b/src/components/video-editor/exportStatusModel.ts new file mode 100644 index 000000000..7641717ab --- /dev/null +++ b/src/components/video-editor/exportStatusModel.ts @@ -0,0 +1,134 @@ +import type { ExportFormat, ExportPipelineModel, ExportProgress } from "@/lib/exporter"; + +export type ExportStatusModel = { + isExportSaving: boolean; + isExportPreparing: boolean; + isExportFinalizing: boolean; + isRenderingAudio: boolean; + exportFinalizingProgress: number | null; + exportFinalizingPercent: number | null; + isExportMuxingAndSaving: boolean; + isExportFinalSaveIndeterminate: boolean; + isLightningExportInProgress: boolean; + shouldSuspendPreviewRendering: boolean; + isLegacyExportInProgress: boolean; + renderSpeedFps: string | null; + runtimeLabel: string | null; + nativeSkipReasons: string[]; + nativeSkipLabel: string | null; +}; + +export function resolveExportStatusModel({ + isExporting, + exportProgress, + exportFormat, + exportPipelineModel, +}: { + isExporting: boolean; + exportProgress: ExportProgress | null; + exportFormat: ExportFormat; + exportPipelineModel: ExportPipelineModel; +}): ExportStatusModel { + const isExportSaving = exportProgress?.phase === "saving"; + const isExportPreparing = + isExporting && (!exportProgress || exportProgress.phase === "preparing"); + const isExportFinalizing = exportProgress?.phase === "finalizing"; + const isRenderingAudio = + isExportFinalizing && typeof exportProgress?.audioProgress === "number"; + const rawFinalizingProgress = + typeof exportProgress?.renderProgress === "number" + ? exportProgress.renderProgress + : (exportProgress?.percentage ?? 100); + const exportFinalizingProgress = isExportFinalizing + ? Math.max( + 0, + Math.min(100, Number.isFinite(rawFinalizingProgress) ? rawFinalizingProgress : 0), + ) + : null; + const exportFinalizingPercent = isExportFinalizing + ? Math.round(exportFinalizingProgress ?? 100) + : null; + const isExportMuxingAndSaving = + isExportFinalizing && + exportFormat === "mp4" && + exportPipelineModel === "modern" && + !isRenderingAudio; + const isExportFinalSaveIndeterminate = + isExportMuxingAndSaving && (exportFinalizingPercent ?? 0) >= 98; + const isLightningExportInProgress = + exportFormat === "mp4" && + exportPipelineModel === "modern" && + (isExporting || exportProgress !== null); + const shouldSuspendPreviewRendering = + isExporting && exportFormat === "mp4" && exportPipelineModel === "modern"; + const isLegacyExportInProgress = + exportFormat === "mp4" && + exportPipelineModel === "legacy" && + (isExporting || exportProgress !== null); + const renderSpeedFps = + !isExportPreparing && + !isExportFinalizing && + !isExportSaving && + typeof exportProgress?.renderFps === "number" && + Number.isFinite(exportProgress.renderFps) && + exportProgress.renderFps > 0 + ? exportProgress.renderFps.toFixed(1) + : null; + const runtimeLabel = resolveRuntimeLabel(exportProgress); + const nativeSkipReasons = + exportProgress?.nativeStaticLayoutSkipReasons && + exportProgress.nativeStaticLayoutSkipReasons.length > 0 + ? exportProgress.nativeStaticLayoutSkipReasons + : exportProgress?.nativeStaticLayoutSkipReason + ? [exportProgress.nativeStaticLayoutSkipReason] + : []; + const nativeSkipLabel = + nativeSkipReasons.length > 0 + ? `Native skipped: ${nativeSkipReasons[0]}${ + nativeSkipReasons.length > 1 ? ` (+${nativeSkipReasons.length - 1} more)` : "" + }` + : null; + + return { + isExportSaving, + isExportPreparing, + isExportFinalizing, + isRenderingAudio, + exportFinalizingProgress, + exportFinalizingPercent, + isExportMuxingAndSaving, + isExportFinalSaveIndeterminate, + isLightningExportInProgress, + shouldSuspendPreviewRendering, + isLegacyExportInProgress, + renderSpeedFps, + runtimeLabel, + nativeSkipReasons, + nativeSkipLabel, + }; +} + +function resolveRuntimeLabel(exportProgress: ExportProgress | null): string | null { + const renderBackend = exportProgress?.renderBackend; + const encodeBackend = exportProgress?.encodeBackend; + const encoderName = exportProgress?.encoderName; + + if (!renderBackend && !encodeBackend && !encoderName) { + return null; + } + + const rendererLabel = + renderBackend === "webgpu" ? "WebGPU" : renderBackend === "webgl" ? "WebGL" : null; + const encoderLabel = + encodeBackend === "ffmpeg" ? "Breeze" : encodeBackend === "webcodecs" ? "WebCodecs" : null; + const pathLabel = + rendererLabel && encoderLabel + ? `${rendererLabel} + ${encoderLabel}` + : (rendererLabel ?? encoderLabel); + + if (!pathLabel) { + return encoderName ?? null; + } + + return encoderName ? `${pathLabel} (${encoderName})` : pathLabel; +} diff --git a/src/components/video-editor/hooks/useAnnotationRegionCommands.ts b/src/components/video-editor/hooks/useAnnotationRegionCommands.ts new file mode 100644 index 000000000..e6cd9942c --- /dev/null +++ b/src/components/video-editor/hooks/useAnnotationRegionCommands.ts @@ -0,0 +1,179 @@ +import type { Span } from "dnd-timeline"; +import { type Dispatch, type MutableRefObject, type SetStateAction, useCallback } from "react"; +import { + type AnnotationRegion, + DEFAULT_ANNOTATION_POSITION, + DEFAULT_ANNOTATION_SIZE, + DEFAULT_ANNOTATION_STYLE, + DEFAULT_FIGURE_DATA, + type FigureData, +} from "../types"; + +interface UseAnnotationRegionCommandsParams { + setAnnotationRegions: Dispatch<SetStateAction<AnnotationRegion[]>>; + selectedAnnotationId: string | null; + setSelectedAnnotationId: Dispatch<SetStateAction<string | null>>; + setSelectedZoomId: Dispatch<SetStateAction<string | null>>; + nextAnnotationIdRef: MutableRefObject<number>; + nextAnnotationZIndexRef: MutableRefObject<number>; +} + +export function useAnnotationRegionCommands({ + setAnnotationRegions, + selectedAnnotationId, + setSelectedAnnotationId, + setSelectedZoomId, + nextAnnotationIdRef, + nextAnnotationZIndexRef, +}: UseAnnotationRegionCommandsParams) { + const handleAnnotationAdded = useCallback( + (span: Span, trackIndex = 0) => { + const id = `annotation-${nextAnnotationIdRef.current++}`; + const newRegion: AnnotationRegion = { + id, + startMs: Math.round(span.start), + endMs: Math.round(span.end), + type: "text", + content: "Enter text...", + position: { ...DEFAULT_ANNOTATION_POSITION }, + size: { ...DEFAULT_ANNOTATION_SIZE }, + style: { ...DEFAULT_ANNOTATION_STYLE }, + zIndex: nextAnnotationZIndexRef.current++, + trackIndex, + }; + setAnnotationRegions((current) => [...current, newRegion]); + setSelectedAnnotationId(id); + setSelectedZoomId(null); + }, + [ + nextAnnotationIdRef, + nextAnnotationZIndexRef, + setAnnotationRegions, + setSelectedAnnotationId, + setSelectedZoomId, + ], + ); + + const handleAnnotationSpanChange = useCallback( + (id: string, span: Span, trackIndex?: number) => { + const normalizedTrackIndex = + typeof trackIndex === "number" && Number.isFinite(trackIndex) + ? Math.max(0, Math.floor(trackIndex)) + : undefined; + setAnnotationRegions((current) => + current.map((region) => + region.id === id + ? { + ...region, + startMs: Math.round(span.start), + endMs: Math.round(span.end), + ...(normalizedTrackIndex === undefined + ? {} + : { trackIndex: normalizedTrackIndex }), + } + : region, + ), + ); + }, + [setAnnotationRegions], + ); + + const handleAnnotationDelete = useCallback( + (id: string) => { + setAnnotationRegions((current) => current.filter((region) => region.id !== id)); + if (selectedAnnotationId === id) setSelectedAnnotationId(null); + }, + [selectedAnnotationId, setAnnotationRegions, setSelectedAnnotationId], + ); + + const handleAnnotationContentChange = useCallback( + (id: string, content: string) => { + setAnnotationRegions((current) => + current.map((region) => { + if (region.id !== id) return region; + if (region.type === "text") return { ...region, content, textContent: content }; + if (region.type === "image") + return { ...region, content, imageContent: content }; + return { ...region, content }; + }), + ); + }, + [setAnnotationRegions], + ); + + const handleAnnotationTypeChange = useCallback( + (id: string, type: AnnotationRegion["type"]) => { + setAnnotationRegions((current) => + current.map((region) => { + if (region.id !== id) return region; + const updated = { ...region, type }; + if (type === "text") updated.content = region.textContent || "Enter text..."; + else if (type === "image") updated.content = region.imageContent || ""; + else if (type === "figure") { + updated.content = ""; + if (!region.figureData) updated.figureData = { ...DEFAULT_FIGURE_DATA }; + } else if (type === "blur") { + updated.content = ""; + if (region.blurIntensity === undefined) updated.blurIntensity = 20; + } + return updated; + }), + ); + }, + [setAnnotationRegions], + ); + + const updateRegion = useCallback( + (id: string, patch: Partial<AnnotationRegion>) => { + setAnnotationRegions((current) => + current.map((region) => (region.id === id ? { ...region, ...patch } : region)), + ); + }, + [setAnnotationRegions], + ); + + const handleAnnotationStyleChange = useCallback( + (id: string, style: Partial<AnnotationRegion["style"]>) => { + setAnnotationRegions((current) => + current.map((region) => + region.id === id ? { ...region, style: { ...region.style, ...style } } : region, + ), + ); + }, + [setAnnotationRegions], + ); + const handleAnnotationFigureDataChange = useCallback( + (id: string, figureData: FigureData) => updateRegion(id, { figureData }), + [updateRegion], + ); + const handleAnnotationBlurIntensityChange = useCallback( + (id: string, blurIntensity: number) => updateRegion(id, { blurIntensity }), + [updateRegion], + ); + const handleAnnotationBlurColorChange = useCallback( + (id: string, blurColor: string) => updateRegion(id, { blurColor }), + [updateRegion], + ); + const handleAnnotationPositionChange = useCallback( + (id: string, position: { x: number; y: number }) => updateRegion(id, { position }), + [updateRegion], + ); + const handleAnnotationSizeChange = useCallback( + (id: string, size: { width: number; height: number }) => updateRegion(id, { size }), + [updateRegion], + ); + + return { + handleAnnotationAdded, + handleAnnotationSpanChange, + handleAnnotationDelete, + handleAnnotationContentChange, + handleAnnotationTypeChange, + handleAnnotationStyleChange, + handleAnnotationFigureDataChange, + handleAnnotationBlurIntensityChange, + handleAnnotationBlurColorChange, + handleAnnotationPositionChange, + handleAnnotationSizeChange, + }; +} diff --git a/src/components/video-editor/hooks/useAudioRegionCommands.ts b/src/components/video-editor/hooks/useAudioRegionCommands.ts new file mode 100644 index 000000000..c02a8f6d9 --- /dev/null +++ b/src/components/video-editor/hooks/useAudioRegionCommands.ts @@ -0,0 +1,140 @@ +import type { Span } from "dnd-timeline"; +import { type Dispatch, type MutableRefObject, type SetStateAction, useCallback } from "react"; +import type { AudioRegion, EditorEffectSection } from "../types"; + +interface UseAudioRegionCommandsParams { + setAudioRegions: Dispatch<SetStateAction<AudioRegion[]>>; + selectedAudioId: string | null; + setSelectedAudioId: Dispatch<SetStateAction<string | null>>; + setSelectedZoomId: Dispatch<SetStateAction<string | null>>; + setSelectedAnnotationId: Dispatch<SetStateAction<string | null>>; + setSelectedCaptionId: Dispatch<SetStateAction<string | null>>; + setActiveEffectSection: Dispatch<SetStateAction<EditorEffectSection>>; + nextAudioIdRef: MutableRefObject<number>; +} + +export function useAudioRegionCommands({ + setAudioRegions, + selectedAudioId, + setSelectedAudioId, + setSelectedZoomId, + setSelectedAnnotationId, + setSelectedCaptionId, + setActiveEffectSection, + nextAudioIdRef, +}: UseAudioRegionCommandsParams) { + const handleSelectAudio = useCallback( + (id: string | null) => { + setSelectedAudioId(id); + if (id) { + setSelectedZoomId(null); + setSelectedAnnotationId(null); + setSelectedCaptionId(null); + setActiveEffectSection("audio"); + } + }, + [ + setActiveEffectSection, + setSelectedAnnotationId, + setSelectedAudioId, + setSelectedCaptionId, + setSelectedZoomId, + ], + ); + + const handleAudioAdded = useCallback( + (span: Span, audioPath: string, trackIndex?: number) => { + const id = `audio-${nextAudioIdRef.current++}`; + const newRegion: AudioRegion = { + id, + startMs: Math.round(span.start), + endMs: Math.round(span.end), + audioPath, + volume: 1, + normalize: false, + trackIndex, + }; + setAudioRegions((current) => [...current, newRegion]); + setSelectedAudioId(id); + setSelectedZoomId(null); + setSelectedAnnotationId(null); + setSelectedCaptionId(null); + setActiveEffectSection("audio"); + }, + [ + nextAudioIdRef, + setActiveEffectSection, + setAudioRegions, + setSelectedAnnotationId, + setSelectedAudioId, + setSelectedCaptionId, + setSelectedZoomId, + ], + ); + + const handleAudioSpanChange = useCallback( + (id: string, span: Span, trackIndex?: number) => { + const normalizedTrackIndex = + typeof trackIndex === "number" && Number.isFinite(trackIndex) + ? Math.max(0, Math.floor(trackIndex)) + : undefined; + setAudioRegions((current) => + current.map((region) => + region.id === id + ? { + ...region, + startMs: Math.round(span.start), + endMs: Math.round(span.end), + ...(normalizedTrackIndex === undefined + ? {} + : { trackIndex: normalizedTrackIndex }), + } + : region, + ), + ); + }, + [setAudioRegions], + ); + + const handleAudioVolumeChange = useCallback( + (volume: number) => { + if (!selectedAudioId || !Number.isFinite(volume)) return; + const nextVolume = Math.max(0, Math.min(1, volume)); + setAudioRegions((current) => + current.map((region) => + region.id === selectedAudioId ? { ...region, volume: nextVolume } : region, + ), + ); + }, + [selectedAudioId, setAudioRegions], + ); + + const handleAudioDelete = useCallback( + (id: string) => { + setAudioRegions((current) => current.filter((region) => region.id !== id)); + if (selectedAudioId === id) setSelectedAudioId(null); + }, + [selectedAudioId, setAudioRegions, setSelectedAudioId], + ); + + const handleAudioNormalizeChange = useCallback( + (normalize: boolean) => { + if (!selectedAudioId) return; + setAudioRegions((current) => + current.map((region) => + region.id === selectedAudioId ? { ...region, normalize } : region, + ), + ); + }, + [selectedAudioId, setAudioRegions], + ); + + return { + handleSelectAudio, + handleAudioAdded, + handleAudioSpanChange, + handleAudioVolumeChange, + handleAudioDelete, + handleAudioNormalizeChange, + }; +} diff --git a/src/components/video-editor/hooks/useCaptionCommands.ts b/src/components/video-editor/hooks/useCaptionCommands.ts new file mode 100644 index 000000000..d541cbcbc --- /dev/null +++ b/src/components/video-editor/hooks/useCaptionCommands.ts @@ -0,0 +1,204 @@ +import type { Span } from "dnd-timeline"; +import { type Dispatch, type RefObject, type SetStateAction, useCallback } from "react"; +import { + type CaptionEditTarget, + normalizeCaptionEditText, + normalizeCaptionWords, + updateCaptionCuesForEditedTarget, +} from "../captionEditing"; +import { + addCue, + type CaptionRetimeSpan, + createCaptionCue, + deleteCue, + mergeCues, + retimeCue, + splitCue, +} from "../captionOps"; +import type { AutoCaptionSettings, CaptionCue, EditorEffectSection } from "../types"; +import type { VideoPlaybackRef } from "../VideoPlayback"; + +interface UseCaptionCommandsParams { + autoCaptions: CaptionCue[]; + setAutoCaptions: Dispatch<SetStateAction<CaptionCue[]>>; + setAutoCaptionSettings: Dispatch<SetStateAction<AutoCaptionSettings>>; + setSelectedCaptionId: Dispatch<SetStateAction<string | null>>; + setSelectedZoomId: Dispatch<SetStateAction<string | null>>; + setSelectedClipId: Dispatch<SetStateAction<string | null>>; + setSelectedAnnotationId: Dispatch<SetStateAction<string | null>>; + setSelectedAudioId: Dispatch<SetStateAction<string | null>>; + setActiveEffectSection: Dispatch<SetStateAction<EditorEffectSection>>; + videoPlaybackRef: RefObject<VideoPlaybackRef>; + mapSourceTimeToTimelineTime: (timeMs: number) => number; + mapTimelineTimeToSourceTime: (timeMs: number) => number; + handleSeek: (time: number, options?: { pause?: boolean }) => void; +} + +export function useCaptionCommands({ + autoCaptions, + setAutoCaptions, + setAutoCaptionSettings, + setSelectedCaptionId, + setSelectedZoomId, + setSelectedClipId, + setSelectedAnnotationId, + setSelectedAudioId, + setActiveEffectSection, + videoPlaybackRef, + mapSourceTimeToTimelineTime, + mapTimelineTimeToSourceTime, + handleSeek, +}: UseCaptionCommandsParams) { + const handleSelectCaption = useCallback( + (id: string | null) => { + setSelectedCaptionId(id); + if (!id) { + setActiveEffectSection((section) => (section === "caption" ? "scene" : section)); + return; + } + setActiveEffectSection("caption"); + setSelectedZoomId(null); + setSelectedClipId(null); + setSelectedAnnotationId(null); + setSelectedAudioId(null); + const cue = autoCaptions.find((value) => value.id === id); + if (cue) { + handleSeek(mapSourceTimeToTimelineTime(cue.startMs) / 1000, { pause: true }); + } + }, + [ + autoCaptions, + handleSeek, + mapSourceTimeToTimelineTime, + setActiveEffectSection, + setSelectedAnnotationId, + setSelectedAudioId, + setSelectedCaptionId, + setSelectedClipId, + setSelectedZoomId, + ], + ); + + const handleBeginCaptionEdit = useCallback( + (id: string) => { + videoPlaybackRef.current?.cancelCaptionEdit(); + setSelectedCaptionId(id); + }, + [setSelectedCaptionId, videoPlaybackRef], + ); + + const handleCaptionTextEdit = useCallback( + (id: string, text: string) => { + setAutoCaptions((captions) => { + const cue = captions.find((value) => value.id === id); + if (!cue) return captions; + + const words = normalizeCaptionWords(cue); + if (words.length === 0) { + const normalized = normalizeCaptionEditText(text); + return captions.map((value) => + value.id === id ? { ...value, text: normalized } : value, + ); + } + + const target: CaptionEditTarget = { + id: cue.id, + startMs: cue.startMs, + endMs: cue.endMs, + text: cue.text, + words: words.map((word, index) => ({ + cueId: cue.id, + cueWordIndex: index, + startMs: word.startMs, + endMs: word.endMs, + text: word.text, + leadingSpace: Boolean(word.leadingSpace), + })), + }; + return updateCaptionCuesForEditedTarget(captions, target, text); + }); + }, + [setAutoCaptions], + ); + + const cancelEdit = useCallback( + () => videoPlaybackRef.current?.cancelCaptionEdit(), + [videoPlaybackRef], + ); + const handleCaptionRetime = useCallback( + (id: string, span: CaptionRetimeSpan) => { + cancelEdit(); + setAutoCaptions((captions) => retimeCue(captions, id, span)); + }, + [cancelEdit, setAutoCaptions], + ); + const handleCaptionSplit = useCallback( + (id: string, atMs: number) => { + cancelEdit(); + setAutoCaptions((captions) => splitCue(captions, id, atMs)); + }, + [cancelEdit, setAutoCaptions], + ); + const handleCaptionMerge = useCallback( + (idA: string, idB: string) => { + cancelEdit(); + setAutoCaptions((captions) => mergeCues(captions, idA, idB)); + }, + [cancelEdit, setAutoCaptions], + ); + const handleCaptionDelete = useCallback( + (id: string) => { + cancelEdit(); + setSelectedCaptionId((current) => (current === id ? null : current)); + setAutoCaptions((captions) => deleteCue(captions, id)); + }, + [cancelEdit, setAutoCaptions, setSelectedCaptionId], + ); + + const handleCaptionAdded = useCallback( + (span: Span) => { + cancelEdit(); + const newCue = createCaptionCue({ + startMs: mapTimelineTimeToSourceTime(span.start), + endMs: mapTimelineTimeToSourceTime(span.end), + }); + setAutoCaptions((captions) => addCue(captions, newCue)); + setSelectedCaptionId(newCue.id); + setActiveEffectSection("caption"); + setSelectedZoomId(null); + setSelectedClipId(null); + setSelectedAnnotationId(null); + setSelectedAudioId(null); + handleSeek(span.start / 1000, { pause: true }); + }, + [ + cancelEdit, + handleSeek, + mapTimelineTimeToSourceTime, + setActiveEffectSection, + setAutoCaptions, + setSelectedAnnotationId, + setSelectedAudioId, + setSelectedCaptionId, + setSelectedClipId, + setSelectedZoomId, + ], + ); + + const handleClearAutoCaptions = useCallback(() => { + setAutoCaptions([]); + setAutoCaptionSettings((current) => ({ ...current, enabled: false })); + }, [setAutoCaptionSettings, setAutoCaptions]); + + return { + handleSelectCaption, + handleBeginCaptionEdit, + handleCaptionTextEdit, + handleCaptionRetime, + handleCaptionSplit, + handleCaptionMerge, + handleCaptionDelete, + handleCaptionAdded, + handleClearAutoCaptions, + }; +} diff --git a/src/components/video-editor/hooks/useClipRegionCommands.ts b/src/components/video-editor/hooks/useClipRegionCommands.ts new file mode 100644 index 000000000..e94793e35 --- /dev/null +++ b/src/components/video-editor/hooks/useClipRegionCommands.ts @@ -0,0 +1,248 @@ +import type { Span } from "dnd-timeline"; +import { type Dispatch, type MutableRefObject, type SetStateAction, useCallback } from "react"; +import { toast } from "sonner"; +import { planClipSpeedChange } from "../clipSpeedChange"; +import type { + AnnotationRegion, + AudioRegion, + ClipRegion, + EditorEffectSection, + SpeedRegion, + ZoomRegion, +} from "../types"; + +type Translator = ( + key: string, + fallback?: string, + params?: Record<string, string | number>, +) => string; + +interface UseClipRegionCommandsParams { + clipRegions: ClipRegion[]; + setClipRegions: Dispatch<SetStateAction<ClipRegion[]>>; + zoomRegions: ZoomRegion[]; + setZoomRegions: Dispatch<SetStateAction<ZoomRegion[]>>; + setAnnotationRegions: Dispatch<SetStateAction<AnnotationRegion[]>>; + setSpeedRegions: Dispatch<SetStateAction<SpeedRegion[]>>; + setAudioRegions: Dispatch<SetStateAction<AudioRegion[]>>; + selectedClipId: string | null; + setSelectedClipId: Dispatch<SetStateAction<string | null>>; + setSelectedZoomId: Dispatch<SetStateAction<string | null>>; + setSelectedAnnotationId: Dispatch<SetStateAction<string | null>>; + setSelectedAudioId: Dispatch<SetStateAction<string | null>>; + setSelectedCaptionId: Dispatch<SetStateAction<string | null>>; + setActiveEffectSection: Dispatch<SetStateAction<EditorEffectSection>>; + nextClipIdRef: MutableRefObject<number>; + t: Translator; +} + +export function useClipRegionCommands({ + clipRegions, + setClipRegions, + zoomRegions, + setZoomRegions, + setAnnotationRegions, + setSpeedRegions, + setAudioRegions, + selectedClipId, + setSelectedClipId, + setSelectedZoomId, + setSelectedAnnotationId, + setSelectedAudioId, + setSelectedCaptionId, + setActiveEffectSection, + nextClipIdRef, + t, +}: UseClipRegionCommandsParams) { + const handleSelectClip = useCallback( + (id: string | null) => { + setSelectedClipId(id); + if (id) { + setActiveEffectSection("clip"); + setSelectedZoomId(null); + setSelectedAnnotationId(null); + setSelectedAudioId(null); + setSelectedCaptionId(null); + } else { + setActiveEffectSection((section) => (section === "clip" ? "scene" : section)); + } + }, + [ + setActiveEffectSection, + setSelectedAnnotationId, + setSelectedAudioId, + setSelectedCaptionId, + setSelectedClipId, + setSelectedZoomId, + ], + ); + + const handleClipSplit = useCallback( + (splitMs: number) => { + const target = clipRegions.find( + (clip) => splitMs > clip.startMs && splitMs < clip.endMs, + ); + if (!target) return; + const leftId = `clip-${nextClipIdRef.current++}`; + const rightId = `clip-${nextClipIdRef.current++}`; + const splitAt = Math.round(splitMs); + const left: ClipRegion = { ...target, id: leftId, endMs: splitAt }; + const right: ClipRegion = { ...target, id: rightId, startMs: splitAt }; + setClipRegions((current) => + current.flatMap((clip) => (clip.id === target.id ? [left, right] : [clip])), + ); + if (selectedClipId === target.id) setSelectedClipId(leftId); + }, + [clipRegions, nextClipIdRef, selectedClipId, setClipRegions, setSelectedClipId], + ); + + const handleClipSpanChange = useCallback( + (id: string, span: Span) => { + const oldClip = clipRegions.find((clip) => clip.id === id); + const newStart = Math.round(span.start); + const newEnd = Math.round(span.end); + const removedSegments = oldClip + ? [ + ...(newStart > oldClip.startMs + ? [{ startMs: oldClip.startMs, endMs: newStart }] + : []), + ...(newEnd < oldClip.endMs + ? [{ startMs: newEnd, endMs: oldClip.endMs }] + : []), + ] + : []; + + if (oldClip) { + const startDelta = newStart - oldClip.startMs; + const endDelta = newEnd - oldClip.endMs; + if (Math.abs(startDelta - endDelta) < 1 && Math.abs(startDelta) > 0) { + setZoomRegions((current) => + current.map((zoom) => + zoom.startMs < oldClip.endMs && zoom.endMs > oldClip.startMs + ? { + ...zoom, + startMs: zoom.startMs + startDelta, + endMs: zoom.endMs + startDelta, + } + : zoom, + ), + ); + } + } + + if (removedSegments.length > 0) { + const removeTrimmedRegions = <T extends { startMs: number; endMs: number }>( + regions: T[], + ) => + regions.filter( + (region) => + !removedSegments.some( + (segment) => + region.startMs < segment.endMs && + region.endMs > segment.startMs, + ), + ); + setZoomRegions((current) => removeTrimmedRegions(current)); + setAnnotationRegions((current) => removeTrimmedRegions(current)); + setSpeedRegions((current) => removeTrimmedRegions(current)); + setAudioRegions((current) => removeTrimmedRegions(current)); + } + + setClipRegions((current) => + current.map((clip) => + clip.id === id ? { ...clip, startMs: newStart, endMs: newEnd } : clip, + ), + ); + }, + [ + clipRegions, + setAnnotationRegions, + setAudioRegions, + setClipRegions, + setSpeedRegions, + setZoomRegions, + ], + ); + + const handleClipSpeedChange = useCallback( + (speed: number) => { + if (!selectedClipId || !Number.isFinite(speed) || speed <= 0) return; + const plan = planClipSpeedChange({ clipRegions, zoomRegions, selectedClipId, speed }); + if (!plan) return; + if ("blockedReason" in plan) { + toast.warning( + plan.blockedReason === "clip-overlap" + ? t( + "editor.timeline.speedClipOverlap", + "Speed change would overlap the next clip. Move or split clips before slowing this section.", + ) + : t( + "editor.timeline.speedZoomOverlap", + "Speed change would overlap another zoom. Move or delete the overlapping zoom first.", + ), + ); + return; + } + setClipRegions(plan.clipRegions); + setZoomRegions(plan.zoomRegions); + }, + [clipRegions, selectedClipId, setClipRegions, setZoomRegions, t, zoomRegions], + ); + + const handleClipMutedChange = useCallback( + (muted: boolean) => { + if (!selectedClipId) return; + setClipRegions((current) => + current.map((clip) => (clip.id === selectedClipId ? { ...clip, muted } : clip)), + ); + }, + [selectedClipId, setClipRegions], + ); + const handleClipShowSourceAudioChange = useCallback( + (showSourceAudio: boolean) => { + if (!selectedClipId) return; + setClipRegions((current) => + current.map((clip) => + clip.id === selectedClipId ? { ...clip, showSourceAudio } : clip, + ), + ); + }, + [selectedClipId, setClipRegions], + ); + + const handleClipDelete = useCallback( + (id: string) => { + const deletedClip = clipRegions.find((clip) => clip.id === id); + setClipRegions((current) => current.filter((clip) => clip.id !== id)); + if (deletedClip) { + const outsideDeletedClip = (region: { startMs: number; endMs: number }) => + region.endMs <= deletedClip.startMs || region.startMs >= deletedClip.endMs; + setZoomRegions((current) => current.filter(outsideDeletedClip)); + setAnnotationRegions((current) => current.filter(outsideDeletedClip)); + setSpeedRegions((current) => current.filter(outsideDeletedClip)); + setAudioRegions((current) => current.filter(outsideDeletedClip)); + } + if (selectedClipId === id) setSelectedClipId(null); + }, + [ + clipRegions, + selectedClipId, + setAnnotationRegions, + setAudioRegions, + setClipRegions, + setSelectedClipId, + setSpeedRegions, + setZoomRegions, + ], + ); + + return { + handleSelectClip, + handleClipSplit, + handleClipSpanChange, + handleClipSpeedChange, + handleClipMutedChange, + handleClipShowSourceAudioChange, + handleClipDelete, + }; +} diff --git a/src/components/video-editor/hooks/useCursorTelemetry.ts b/src/components/video-editor/hooks/useCursorTelemetry.ts new file mode 100644 index 000000000..67d64239b --- /dev/null +++ b/src/components/video-editor/hooks/useCursorTelemetry.ts @@ -0,0 +1,125 @@ +import { type MutableRefObject, useEffect, useMemo, useRef } from "react"; +import type { useTimelineState } from "../state/useTimelineState"; +import { normalizeCursorTelemetry } from "../timeline/zoomSuggestionUtils"; +import type { CursorTelemetryPoint } from "../types"; +import { + buildLoopedCursorTelemetry, + getDisplayedTimelineWindowMs, +} from "../videoPlayback/cursorLoopTelemetry"; + +type UseCursorTelemetryInput = { + videoPath: string | null; + videoSourcePath: string | null; + duration: number; + loopCursor: boolean; + timeline: ReturnType<typeof useTimelineState>; + pendingFreshRecordingAutoZoomPathRef: MutableRefObject<string | null>; + autoSuggestedVideoPathRef: MutableRefObject<string | null>; +}; + +export function useCursorTelemetry({ + videoPath, + videoSourcePath, + duration, + loopCursor, + timeline, + pendingFreshRecordingAutoZoomPathRef, + autoSuggestedVideoPathRef, +}: UseCursorTelemetryInput) { + const pendingRetryTimeoutRef = useRef<number | null>(null); + const { setCursorTelemetry, setCursorTelemetrySourcePath } = timeline; + + useEffect(() => { + let mounted = true; + let retryAttempts = 0; + const scheduleRetry = () => { + if ( + pendingFreshRecordingAutoZoomPathRef.current !== videoPath || + autoSuggestedVideoPathRef.current === videoPath || + retryAttempts >= 12 + ) { + return; + } + retryAttempts += 1; + pendingRetryTimeoutRef.current = window.setTimeout(() => { + pendingRetryTimeoutRef.current = null; + if (mounted) void load(); + }, 350); + }; + async function load() { + if (!videoPath || !videoSourcePath) { + if (mounted) { + setCursorTelemetry([]); + setCursorTelemetrySourcePath(null); + } + return; + } + try { + const result = await window.electronAPI.getCursorTelemetry(videoSourcePath); + if (!mounted) return; + setCursorTelemetry(result.success ? result.samples : []); + setCursorTelemetrySourcePath(videoSourcePath); + if (!result.success || result.samples.length === 0) scheduleRetry(); + } catch (error) { + console.warn("Unable to load cursor telemetry:", error); + if (!mounted) return; + setCursorTelemetry([]); + setCursorTelemetrySourcePath(videoSourcePath); + scheduleRetry(); + } + } + + if (pendingRetryTimeoutRef.current !== null) { + window.clearTimeout(pendingRetryTimeoutRef.current); + pendingRetryTimeoutRef.current = null; + } + void load(); + return () => { + mounted = false; + if (pendingRetryTimeoutRef.current !== null) { + window.clearTimeout(pendingRetryTimeoutRef.current); + pendingRetryTimeoutRef.current = null; + } + }; + }, [ + videoPath, + videoSourcePath, + setCursorTelemetry, + setCursorTelemetrySourcePath, + pendingFreshRecordingAutoZoomPathRef, + autoSuggestedVideoPathRef, + ]); + + const normalized = useMemo(() => { + if (timeline.cursorTelemetry.length === 0) return [] as CursorTelemetryPoint[]; + const totalMs = Math.max(0, Math.round(duration * 1000)); + return normalizeCursorTelemetry( + timeline.cursorTelemetry, + totalMs > 0 ? totalMs : Number.MAX_SAFE_INTEGER, + ); + }, [timeline.cursorTelemetry, duration]); + const displayedWindow = useMemo( + () => + getDisplayedTimelineWindowMs( + Math.max(0, Math.round(duration * 1000)), + timeline.trimRegions, + ), + [duration, timeline.trimRegions], + ); + const effective = useMemo(() => { + if ( + !loopCursor || + normalized.length < 2 || + displayedWindow.endMs <= displayedWindow.startMs + ) { + return normalized; + } + return buildLoopedCursorTelemetry( + normalized, + displayedWindow.endMs, + displayedWindow.startMs, + ); + }, [loopCursor, normalized, displayedWindow]); + + return { normalizedCursorTelemetry: normalized, effectiveCursorTelemetry: effective }; +} diff --git a/src/components/video-editor/hooks/useEditorGlobalInteractions.ts b/src/components/video-editor/hooks/useEditorGlobalInteractions.ts new file mode 100644 index 000000000..f5e7ec491 --- /dev/null +++ b/src/components/video-editor/hooks/useEditorGlobalInteractions.ts @@ -0,0 +1,98 @@ +import { type RefObject, useEffect } from "react"; +import type { useShortcuts } from "@/contexts/ShortcutsContext"; +import { matchesShortcut } from "@/lib/shortcuts"; +import type { useTimelineState } from "../state/useTimelineState"; +import type { VideoPlaybackRef } from "../VideoPlayback"; + +type Input = { + timeline: ReturnType<typeof useTimelineState>; + videoPlaybackRef: RefObject<VideoPlaybackRef | null>; + shortcuts: ReturnType<typeof useShortcuts>["shortcuts"]; + isMac: boolean; + handleUndo: () => void; + handleRedo: () => void; + startPlayback: () => void; +}; + +export function useEditorGlobalInteractions({ + timeline, + videoPlaybackRef, + shortcuts, + isMac, + handleUndo, + handleRedo, + startPlayback, +}: Input) { + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + const target = event.target as HTMLElement | null; + const editable = + target instanceof HTMLInputElement || + target instanceof HTMLTextAreaElement || + target?.isContentEditable; + const primaryModifier = isMac ? event.metaKey : event.ctrlKey; + const key = event.key.toLowerCase(); + + if (primaryModifier && !event.altKey && key === "z") { + if (!editable) { + event.preventDefault(); + if (event.shiftKey) handleRedo(); + else handleUndo(); + } + return; + } + if (!isMac && event.ctrlKey && !event.metaKey && !event.altKey && key === "y") { + if (!editable) { + event.preventDefault(); + handleRedo(); + } + return; + } + if (!matchesShortcut(event, shortcuts.playPause, isMac) || editable) return; + event.preventDefault(); + const playback = videoPlaybackRef.current; + if (!playback?.video) return; + if (playback.video.paused) startPlayback(); + else playback.pause(); + }; + window.addEventListener("keydown", handleKeyDown, { capture: true }); + return () => window.removeEventListener("keydown", handleKeyDown, { capture: true }); + }, [shortcuts, isMac, handleUndo, handleRedo, startPlayback, videoPlaybackRef]); + + useEffect(() => { + if ( + timeline.selectedZoomId && + !timeline.zoomRegions.some(({ id }) => id === timeline.selectedZoomId) + ) { + timeline.setSelectedZoomId(null); + } + }, [timeline.selectedZoomId, timeline.zoomRegions, timeline.setSelectedZoomId]); + useEffect(() => { + if ( + timeline.selectedAnnotationId && + !timeline.annotationRegions.some(({ id }) => id === timeline.selectedAnnotationId) + ) { + timeline.setSelectedAnnotationId(null); + } + }, [ + timeline.selectedAnnotationId, + timeline.annotationRegions, + timeline.setSelectedAnnotationId, + ]); + useEffect(() => { + if ( + timeline.selectedAudioId && + !timeline.audioRegions.some(({ id }) => id === timeline.selectedAudioId) + ) { + timeline.setSelectedAudioId(null); + } + }, [timeline.selectedAudioId, timeline.audioRegions, timeline.setSelectedAudioId]); + useEffect(() => { + if ( + timeline.selectedCaptionId && + !timeline.autoCaptions.some(({ id }) => id === timeline.selectedCaptionId) + ) { + timeline.setSelectedCaptionId(null); + } + }, [timeline.selectedCaptionId, timeline.autoCaptions, timeline.setSelectedCaptionId]); +} diff --git a/src/components/video-editor/hooks/useEditorHistory.ts b/src/components/video-editor/hooks/useEditorHistory.ts new file mode 100644 index 000000000..afcb643d3 --- /dev/null +++ b/src/components/video-editor/hooks/useEditorHistory.ts @@ -0,0 +1,177 @@ +import { type MutableRefObject, useCallback, useEffect, useRef, useState } from "react"; +import { + createEditorHistoryStack, + type EditorHistorySnapshot, + recordEditorHistorySnapshot, + redoEditorHistoryStack, + resetEditorHistoryStack, + undoEditorHistoryStack, +} from "../editorHistory"; +import { deriveNextId } from "../projectPersistence"; +import type { useTimelineState } from "../state/useTimelineState"; +import { cloneStructured } from "../videoEditorUtils"; + +type Input = { + timeline: ReturnType<typeof useTimelineState>; + nextZoomIdRef: MutableRefObject<number>; + nextClipIdRef: MutableRefObject<number>; + nextAnnotationIdRef: MutableRefObject<number>; + nextAudioIdRef: MutableRefObject<number>; + nextAnnotationZIndexRef: MutableRefObject<number>; +}; + +export function useEditorHistory({ + timeline, + nextZoomIdRef, + nextClipIdRef, + nextAnnotationIdRef, + nextAudioIdRef, + nextAnnotationZIndexRef, +}: Input) { + const { + zoomRegions, + clipRegions, + speedRegions, + annotationRegions, + audioRegions, + autoCaptions, + selectedZoomId, + selectedClipId, + selectedAnnotationId, + selectedAudioId, + setZoomRegions, + setClipRegions, + setSpeedRegions, + setAnnotationRegions, + setAudioRegions, + setAutoCaptions, + setSelectedZoomId, + setSelectedClipId, + setSelectedAnnotationId, + setSelectedAudioId, + } = timeline; + const historyRef = useRef(createEditorHistoryStack()); + const applyingRef = useRef(false); + const [historyFlags, setHistoryFlags] = useState({ canUndo: false, canRedo: false }); + const syncButtons = useCallback(() => { + const next = { + canUndo: historyRef.current.past.length > 0, + canRedo: historyRef.current.future.length > 0, + }; + setHistoryFlags((current) => + current.canUndo === next.canUndo && current.canRedo === next.canRedo ? current : next, + ); + }, []); + const buildSnapshot = useCallback( + (): EditorHistorySnapshot => ({ + zoomRegions, + clipRegions, + speedRegions, + annotationRegions, + audioRegions, + autoCaptions, + selectedZoomId, + selectedClipId, + selectedAnnotationId, + selectedAudioId, + }), + [ + zoomRegions, + clipRegions, + speedRegions, + annotationRegions, + audioRegions, + autoCaptions, + selectedZoomId, + selectedClipId, + selectedAnnotationId, + selectedAudioId, + ], + ); + const applySnapshot = useCallback( + (snapshot: EditorHistorySnapshot) => { + applyingRef.current = true; + const cloned = cloneStructured(snapshot); + setZoomRegions(cloned.zoomRegions); + setClipRegions(cloned.clipRegions); + setSpeedRegions(cloned.speedRegions); + setAnnotationRegions(cloned.annotationRegions); + setAudioRegions(cloned.audioRegions); + setAutoCaptions(cloned.autoCaptions); + setSelectedZoomId(cloned.selectedZoomId); + setSelectedClipId(cloned.selectedClipId); + setSelectedAnnotationId(cloned.selectedAnnotationId); + setSelectedAudioId(cloned.selectedAudioId); + nextZoomIdRef.current = deriveNextId( + "zoom", + cloned.zoomRegions.map(({ id }) => id), + ); + nextClipIdRef.current = deriveNextId( + "clip", + cloned.clipRegions.map(({ id }) => id), + ); + nextAnnotationIdRef.current = deriveNextId( + "annotation", + cloned.annotationRegions.map(({ id }) => id), + ); + nextAudioIdRef.current = deriveNextId( + "audio", + cloned.audioRegions.map(({ id }) => id), + ); + nextAnnotationZIndexRef.current = + cloned.annotationRegions.reduce((max, region) => Math.max(max, region.zIndex), 0) + + 1; + }, + [ + setZoomRegions, + setClipRegions, + setSpeedRegions, + setAnnotationRegions, + setAudioRegions, + setAutoCaptions, + setSelectedZoomId, + setSelectedClipId, + setSelectedAnnotationId, + setSelectedAudioId, + nextZoomIdRef, + nextClipIdRef, + nextAnnotationIdRef, + nextAudioIdRef, + nextAnnotationZIndexRef, + ], + ); + const handleUndo = useCallback(() => { + const previous = undoEditorHistoryStack(historyRef.current, buildSnapshot()); + if (previous) { + applySnapshot(previous); + syncButtons(); + } + }, [applySnapshot, buildSnapshot, syncButtons]); + const handleRedo = useCallback(() => { + const next = redoEditorHistoryStack(historyRef.current, buildSnapshot()); + if (next) { + applySnapshot(next); + syncButtons(); + } + }, [applySnapshot, buildSnapshot, syncButtons]); + const resetHistory = useCallback(() => { + resetEditorHistoryStack(historyRef.current); + applyingRef.current = false; + syncButtons(); + }, [syncButtons]); + + useEffect(() => { + const result = recordEditorHistorySnapshot(historyRef.current, buildSnapshot(), { + applyingHistory: applyingRef.current, + }); + if (result === "applied") applyingRef.current = false; + if (result !== "unchanged") syncButtons(); + }, [buildSnapshot, syncButtons]); + + return { + ...historyFlags, + handleUndo, + handleRedo, + resetHistory, + }; +} diff --git a/src/components/video-editor/hooks/useEditorPlaybackControls.ts b/src/components/video-editor/hooks/useEditorPlaybackControls.ts new file mode 100644 index 000000000..733dee939 --- /dev/null +++ b/src/components/video-editor/hooks/useEditorPlaybackControls.ts @@ -0,0 +1,82 @@ +import { type RefObject, useCallback } from "react"; +import type { TimelineEditorHandle } from "../timeline/TimelineEditor"; +import type { VideoPlaybackRef } from "../VideoPlayback"; + +interface UseEditorPlaybackControlsParams { + videoPlaybackRef: RefObject<VideoPlaybackRef>; + timelineRef: RefObject<TimelineEditorHandle>; + playSourceAudioPreview: () => void; + mapTimelineTimeToSourceTime: (timeMs: number) => number; + timelinePlayheadTime: number; + timelineDuration: number; +} + +export function useEditorPlaybackControls({ + videoPlaybackRef, + timelineRef, + playSourceAudioPreview, + mapTimelineTimeToSourceTime, + timelinePlayheadTime, + timelineDuration, +}: UseEditorPlaybackControlsParams) { + const getActivePlayback = useCallback(() => videoPlaybackRef.current, [videoPlaybackRef]); + + const startPlayback = useCallback(() => { + const playback = getActivePlayback(); + if (!playback?.video) return; + + playSourceAudioPreview(); + playback.play().catch((error) => console.error("Video play failed:", error)); + }, [getActivePlayback, playSourceAudioPreview]); + + const togglePlayPause = useCallback(() => { + const playback = getActivePlayback(); + const video = playback?.video; + if (!playback || !video) return; + + if (!video.paused && !video.ended) playback.pause(); + else startPlayback(); + }, [getActivePlayback, startPlayback]); + + const handleSeek = useCallback( + (time: number, options: { pause?: boolean } = {}) => { + const playback = getActivePlayback(); + const video = playback?.video; + if (!video) return; + + if (options.pause && !video.paused) playback?.pause(); + video.currentTime = mapTimelineTimeToSourceTime(time * 1000) / 1000; + }, + [getActivePlayback, mapTimelineTimeToSourceTime], + ); + + const handleTimelineSeek = useCallback( + (time: number) => handleSeek(time, { pause: true }), + [handleSeek], + ); + + const handlePreviewSkipBack = useCallback(() => { + const currentMs = timelinePlayheadTime * 1000; + const keyframes = timelineRef.current?.keyframes ?? []; + const previous = [...keyframes] + .reverse() + .find((keyframe) => keyframe.time < currentMs - 50); + handleSeek(previous ? previous.time / 1000 : Math.max(0, timelinePlayheadTime - 5)); + }, [handleSeek, timelinePlayheadTime, timelineRef]); + + const handlePreviewSkipForward = useCallback(() => { + const currentMs = timelinePlayheadTime * 1000; + const keyframes = timelineRef.current?.keyframes ?? []; + const next = keyframes.find((keyframe) => keyframe.time > currentMs + 50); + handleSeek(next ? next.time / 1000 : Math.min(timelineDuration, timelinePlayheadTime + 5)); + }, [handleSeek, timelineDuration, timelinePlayheadTime, timelineRef]); + + return { + startPlayback, + togglePlayPause, + handleSeek, + handleTimelineSeek, + handlePreviewSkipBack, + handlePreviewSkipForward, + }; +} diff --git a/src/components/video-editor/hooks/useFreshRecordingAutoZoom.ts b/src/components/video-editor/hooks/useFreshRecordingAutoZoom.ts new file mode 100644 index 000000000..bdde58335 --- /dev/null +++ b/src/components/video-editor/hooks/useFreshRecordingAutoZoom.ts @@ -0,0 +1,153 @@ +import { + type Dispatch, + type MutableRefObject, + type RefObject, + type SetStateAction, + useCallback, + useEffect, +} from "react"; +import { shouldAutoApplyFreshRecordingZoomsForSource } from "../timeline/zoomSuggestionUtils"; +import type { CursorTelemetryPoint, ZoomRegion } from "../types"; +import type { VideoPlaybackRef } from "../VideoPlayback"; + +interface UseFreshRecordingAutoZoomParams { + videoPath: string | null; + loading: boolean; + isPreviewReady: boolean; + duration: number; + cursorTelemetryCount: number; + normalizedCursorTelemetry: CursorTelemetryPoint[]; + zoomRegions: ZoomRegion[]; + setZoomRegions: Dispatch<SetStateAction<ZoomRegion[]>>; + setAutoSuggestZoomsTrigger: Dispatch<SetStateAction<number>>; + videoPlaybackRef: RefObject<VideoPlaybackRef>; + autoSuggestedVideoPathRef: MutableRefObject<string | null>; + pendingFreshRecordingAutoZoomPathRef: MutableRefObject<string | null>; + pendingFreshRecordingAutoSuggestTimeoutRef: MutableRefObject<number | null>; + pendingFreshRecordingAutoSuggestTelemetryCountRef: MutableRefObject<number>; +} + +export function useFreshRecordingAutoZoom({ + videoPath, + loading, + isPreviewReady, + duration, + cursorTelemetryCount, + normalizedCursorTelemetry, + zoomRegions, + setZoomRegions, + setAutoSuggestZoomsTrigger, + videoPlaybackRef, + autoSuggestedVideoPathRef, + pendingFreshRecordingAutoZoomPathRef, + pendingFreshRecordingAutoSuggestTimeoutRef, + pendingFreshRecordingAutoSuggestTelemetryCountRef, +}: UseFreshRecordingAutoZoomParams) { + const handleAutoSuggestZoomsConsumed = useCallback(() => { + setAutoSuggestZoomsTrigger(0); + }, [setAutoSuggestZoomsTrigger]); + + useEffect(() => { + if ( + videoPath && + pendingFreshRecordingAutoZoomPathRef.current === videoPath && + isPreviewReady && + !shouldAutoApplyFreshRecordingZoomsForSource( + videoPlaybackRef.current?.video?.videoWidth, + videoPlaybackRef.current?.video?.videoHeight, + ) + ) { + pendingFreshRecordingAutoZoomPathRef.current = null; + if (pendingFreshRecordingAutoSuggestTimeoutRef.current !== null) { + window.clearTimeout(pendingFreshRecordingAutoSuggestTimeoutRef.current); + pendingFreshRecordingAutoSuggestTimeoutRef.current = null; + } + return; + } + + if ( + !videoPath || + loading || + !isPreviewReady || + duration <= 0 || + zoomRegions.length > 0 || + normalizedCursorTelemetry.length < 2 + ) { + if (pendingFreshRecordingAutoSuggestTimeoutRef.current !== null) { + window.clearTimeout(pendingFreshRecordingAutoSuggestTimeoutRef.current); + pendingFreshRecordingAutoSuggestTimeoutRef.current = null; + } + return; + } + + if (pendingFreshRecordingAutoZoomPathRef.current !== videoPath) return; + if (autoSuggestedVideoPathRef.current === videoPath) { + pendingFreshRecordingAutoZoomPathRef.current = null; + return; + } + if (pendingFreshRecordingAutoSuggestTelemetryCountRef.current === cursorTelemetryCount) { + return; + } + + pendingFreshRecordingAutoSuggestTelemetryCountRef.current = cursorTelemetryCount; + if (pendingFreshRecordingAutoSuggestTimeoutRef.current !== null) { + window.clearTimeout(pendingFreshRecordingAutoSuggestTimeoutRef.current); + } + + pendingFreshRecordingAutoSuggestTimeoutRef.current = window.setTimeout(() => { + pendingFreshRecordingAutoSuggestTimeoutRef.current = null; + if ( + pendingFreshRecordingAutoZoomPathRef.current !== videoPath || + autoSuggestedVideoPathRef.current === videoPath || + zoomRegions.length > 0 + ) { + return; + } + setAutoSuggestZoomsTrigger((value) => value + 1); + }, 500); + }, [ + videoPath, + loading, + isPreviewReady, + duration, + cursorTelemetryCount, + normalizedCursorTelemetry, + zoomRegions, + autoSuggestedVideoPathRef, + pendingFreshRecordingAutoSuggestTelemetryCountRef, + pendingFreshRecordingAutoSuggestTimeoutRef, + pendingFreshRecordingAutoZoomPathRef, + setAutoSuggestZoomsTrigger, + videoPlaybackRef, + ]); + + useEffect(() => { + if ( + !videoPath || + !isPreviewReady || + zoomRegions.length === 0 || + autoSuggestedVideoPathRef.current !== videoPath || + shouldAutoApplyFreshRecordingZoomsForSource( + videoPlaybackRef.current?.video?.videoWidth, + videoPlaybackRef.current?.video?.videoHeight, + ) + ) { + return; + } + + autoSuggestedVideoPathRef.current = null; + setZoomRegions((current) => { + const next = current.filter((region) => region.mode !== "auto"); + return next.length === current.length ? current : next; + }); + }, [ + autoSuggestedVideoPathRef, + isPreviewReady, + setZoomRegions, + videoPath, + videoPlaybackRef, + zoomRegions, + ]); + + return { handleAutoSuggestZoomsConsumed }; +} diff --git a/src/components/video-editor/hooks/useTimelineEditingController.ts b/src/components/video-editor/hooks/useTimelineEditingController.ts new file mode 100644 index 000000000..a61a29e1f --- /dev/null +++ b/src/components/video-editor/hooks/useTimelineEditingController.ts @@ -0,0 +1,234 @@ +import type { Dispatch, MutableRefObject, RefObject, SetStateAction } from "react"; +import { useCallback } from "react"; +import { toast } from "sonner"; +import type { useI18n } from "@/contexts/I18nContext"; +import type { useShortcuts } from "@/contexts/ShortcutsContext"; +import { useVideoEditorAudio } from "../audio/useVideoEditorAudio"; +import type { useAppearanceState } from "../state/useAppearanceState"; +import type { useTimelineState } from "../state/useTimelineState"; +import type { TimelineEditorHandle } from "../timeline/TimelineEditor"; +import type { EditorEffectSection } from "../types"; +import type { VideoPlaybackRef } from "../VideoPlayback"; +import { getErrorMessage, summarizeErrorMessage } from "../videoEditorUtils"; +import { useAnnotationRegionCommands } from "./useAnnotationRegionCommands"; +import { useAudioRegionCommands } from "./useAudioRegionCommands"; +import { useCaptionCommands } from "./useCaptionCommands"; +import { useClipRegionCommands } from "./useClipRegionCommands"; +import { useCursorTelemetry } from "./useCursorTelemetry"; +import { useEditorGlobalInteractions } from "./useEditorGlobalInteractions"; +import { useEditorPlaybackControls } from "./useEditorPlaybackControls"; +import { useFreshRecordingAutoZoom } from "./useFreshRecordingAutoZoom"; +import { useTimelineProjection } from "./useTimelineProjection"; +import { useZoomRegionCommands } from "./useZoomRegionCommands"; + +type Input = { + t: ReturnType<typeof useI18n>["t"]; + shortcuts: ReturnType<typeof useShortcuts>["shortcuts"]; + isMac: boolean; + timeline: ReturnType<typeof useTimelineState>; + appearance: ReturnType<typeof useAppearanceState>; + videoPath: string | null; + videoSourcePath: string | null; + currentSourcePath: string | null; + duration: number; + currentTime: number; + isPlaying: boolean; + previewVolume: number; + loading: boolean; + isPreviewReady: boolean; + setActiveEffectSection: Dispatch<SetStateAction<EditorEffectSection>>; + setAutoSuggestZoomsTrigger: Dispatch<SetStateAction<number>>; + videoPlaybackRef: RefObject<VideoPlaybackRef>; + timelineRef: RefObject<TimelineEditorHandle>; + nextZoomIdRef: MutableRefObject<number>; + nextClipIdRef: MutableRefObject<number>; + nextAudioIdRef: MutableRefObject<number>; + nextAnnotationIdRef: MutableRefObject<number>; + nextAnnotationZIndexRef: MutableRefObject<number>; + clipInitializedRef: MutableRefObject<boolean>; + autoFullTrackClipIdRef: MutableRefObject<string | null>; + autoFullTrackClipEndMsRef: MutableRefObject<number | null>; + autoSuggestedVideoPathRef: MutableRefObject<string | null>; + pendingFreshRecordingAutoZoomPathRef: MutableRefObject<string | null>; + pendingFreshRecordingAutoSuggestTimeoutRef: MutableRefObject<number | null>; + pendingFreshRecordingAutoSuggestTelemetryCountRef: MutableRefObject<number>; + handleUndo: () => void; + handleRedo: () => void; +}; + +export function useTimelineEditingController(input: Input) { + const { timeline } = input; + const handleSourceFallbackLoadError = useCallback((error: unknown) => { + toast.warning( + `Could not load companion audio source: ${summarizeErrorMessage(getErrorMessage(error))}`, + { duration: 10000 }, + ); + }, []); + const cursor = useCursorTelemetry({ + videoPath: input.videoPath, + videoSourcePath: input.videoSourcePath, + duration: input.duration, + loopCursor: input.appearance.loopCursor, + timeline, + pendingFreshRecordingAutoZoomPathRef: input.pendingFreshRecordingAutoZoomPathRef, + autoSuggestedVideoPathRef: input.autoSuggestedVideoPathRef, + }); + const projection = useTimelineProjection({ + timeline, + duration: input.duration, + currentTime: input.currentTime, + nextClipIdRef: input.nextClipIdRef, + initializedRef: input.clipInitializedRef, + autoFullTrackIdRef: input.autoFullTrackClipIdRef, + autoFullTrackEndRef: input.autoFullTrackClipEndMsRef, + }); + const audio = useVideoEditorAudio({ + currentSourcePath: input.currentSourcePath, + selectedClipId: timeline.selectedClipId, + clipRegions: timeline.clipRegions, + audioRegions: timeline.audioRegions, + effectiveSpeedRegions: projection.effectiveSpeedRegions, + sourceAudioTrackSettingsByClip: timeline.sourceAudioTrackSettingsByClip, + setSourceAudioTrackSettingsByClip: timeline.setSourceAudioTrackSettingsByClip, + defaultSourceAudioTrackSettings: timeline.defaultSourceAudioTrackSettings, + setDefaultSourceAudioTrackSettings: timeline.setDefaultSourceAudioTrackSettings, + currentTime: input.currentTime, + timelineTime: projection.timelinePlayheadTime, + duration: input.duration, + isPlaying: input.isPlaying, + previewVolume: input.previewVolume, + sourceAudioFallbackRefreshKey: timeline.sourceAudioFallbackRefreshKey, + summarizeErrorMessage, + onSourceFallbackLoadError: handleSourceFallbackLoadError, + }); + const playback = useEditorPlaybackControls({ + videoPlaybackRef: input.videoPlaybackRef, + timelineRef: input.timelineRef, + playSourceAudioPreview: audio.playSourceAudioPreview, + mapTimelineTimeToSourceTime: projection.mapTimelineTimeToSourceTime, + timelinePlayheadTime: projection.timelinePlayheadTime, + timelineDuration: projection.timelineDuration, + }); + const captionCommands = useCaptionCommands({ + autoCaptions: timeline.autoCaptions, + setAutoCaptions: timeline.setAutoCaptions, + setAutoCaptionSettings: timeline.setAutoCaptionSettings, + setSelectedCaptionId: timeline.setSelectedCaptionId, + setSelectedZoomId: timeline.setSelectedZoomId, + setSelectedClipId: timeline.setSelectedClipId, + setSelectedAnnotationId: timeline.setSelectedAnnotationId, + setSelectedAudioId: timeline.setSelectedAudioId, + setActiveEffectSection: input.setActiveEffectSection, + videoPlaybackRef: input.videoPlaybackRef, + mapSourceTimeToTimelineTime: projection.mapSourceTimeToTimelineTime, + mapTimelineTimeToSourceTime: projection.mapTimelineTimeToSourceTime, + handleSeek: playback.handleSeek, + }); + const zoomCommands = useZoomRegionCommands({ + videoPath: input.videoPath, + setZoomRegions: timeline.setZoomRegions, + selectedZoomId: timeline.selectedZoomId, + setSelectedZoomId: timeline.setSelectedZoomId, + setSelectedAnnotationId: timeline.setSelectedAnnotationId, + setSelectedAudioId: timeline.setSelectedAudioId, + setSelectedCaptionId: timeline.setSelectedCaptionId, + setActiveEffectSection: input.setActiveEffectSection, + nextZoomIdRef: input.nextZoomIdRef, + autoSuggestedVideoPathRef: input.autoSuggestedVideoPathRef, + pendingFreshRecordingAutoZoomPathRef: input.pendingFreshRecordingAutoZoomPathRef, + }); + const handleSelectAnnotation = useCallback( + (id: string | null) => { + timeline.setSelectedAnnotationId(id); + if (id) { + timeline.setSelectedZoomId(null); + timeline.setSelectedAudioId(null); + timeline.setSelectedCaptionId(null); + } + }, + [ + timeline.setSelectedAnnotationId, + timeline.setSelectedZoomId, + timeline.setSelectedAudioId, + timeline.setSelectedCaptionId, + ], + ); + const freshZoom = useFreshRecordingAutoZoom({ + videoPath: input.videoPath, + loading: input.loading, + isPreviewReady: input.isPreviewReady, + duration: input.duration, + cursorTelemetryCount: timeline.cursorTelemetry.length, + normalizedCursorTelemetry: cursor.normalizedCursorTelemetry, + zoomRegions: timeline.zoomRegions, + setZoomRegions: timeline.setZoomRegions, + setAutoSuggestZoomsTrigger: input.setAutoSuggestZoomsTrigger, + videoPlaybackRef: input.videoPlaybackRef, + autoSuggestedVideoPathRef: input.autoSuggestedVideoPathRef, + pendingFreshRecordingAutoZoomPathRef: input.pendingFreshRecordingAutoZoomPathRef, + pendingFreshRecordingAutoSuggestTimeoutRef: + input.pendingFreshRecordingAutoSuggestTimeoutRef, + pendingFreshRecordingAutoSuggestTelemetryCountRef: + input.pendingFreshRecordingAutoSuggestTelemetryCountRef, + }); + const clipCommands = useClipRegionCommands({ + clipRegions: timeline.clipRegions, + setClipRegions: timeline.setClipRegions, + zoomRegions: timeline.zoomRegions, + setZoomRegions: timeline.setZoomRegions, + setAnnotationRegions: timeline.setAnnotationRegions, + setSpeedRegions: timeline.setSpeedRegions, + setAudioRegions: timeline.setAudioRegions, + selectedClipId: timeline.selectedClipId, + setSelectedClipId: timeline.setSelectedClipId, + setSelectedZoomId: timeline.setSelectedZoomId, + setSelectedAnnotationId: timeline.setSelectedAnnotationId, + setSelectedAudioId: timeline.setSelectedAudioId, + setSelectedCaptionId: timeline.setSelectedCaptionId, + setActiveEffectSection: input.setActiveEffectSection, + nextClipIdRef: input.nextClipIdRef, + t: input.t, + }); + const audioCommands = useAudioRegionCommands({ + setAudioRegions: timeline.setAudioRegions, + selectedAudioId: timeline.selectedAudioId, + setSelectedAudioId: timeline.setSelectedAudioId, + setSelectedZoomId: timeline.setSelectedZoomId, + setSelectedAnnotationId: timeline.setSelectedAnnotationId, + setSelectedCaptionId: timeline.setSelectedCaptionId, + setActiveEffectSection: input.setActiveEffectSection, + nextAudioIdRef: input.nextAudioIdRef, + }); + const annotationCommands = useAnnotationRegionCommands({ + setAnnotationRegions: timeline.setAnnotationRegions, + selectedAnnotationId: timeline.selectedAnnotationId, + setSelectedAnnotationId: timeline.setSelectedAnnotationId, + setSelectedZoomId: timeline.setSelectedZoomId, + nextAnnotationIdRef: input.nextAnnotationIdRef, + nextAnnotationZIndexRef: input.nextAnnotationZIndexRef, + }); + + useEditorGlobalInteractions({ + timeline, + videoPlaybackRef: input.videoPlaybackRef, + shortcuts: input.shortcuts, + isMac: input.isMac, + handleUndo: input.handleUndo, + handleRedo: input.handleRedo, + startPlayback: playback.startPlayback, + }); + + return { + cursor, + projection, + audio, + playback, + captionCommands, + zoomCommands, + clipCommands, + audioCommands, + annotationCommands, + handleSelectAnnotation, + handleAutoSuggestZoomsConsumed: freshZoom.handleAutoSuggestZoomsConsumed, + }; +} diff --git a/src/components/video-editor/hooks/useTimelineProjection.ts b/src/components/video-editor/hooks/useTimelineProjection.ts new file mode 100644 index 000000000..b49f13c8a --- /dev/null +++ b/src/components/video-editor/hooks/useTimelineProjection.ts @@ -0,0 +1,148 @@ +/* biome-ignore-all lint/correctness/useExhaustiveDependencies: mutable timeline bootstrap refs intentionally do not trigger effects. */ +import { type MutableRefObject, useCallback, useEffect, useMemo } from "react"; +import { deriveNextId } from "../projectPersistence"; +import type { useTimelineState } from "../state/useTimelineState"; +import { + type CaptionCue, + clipsToTrims, + extendAutoFullTrackClip, + getClipSourceEndMs, + getTimelineDurationMs, + mapSourceTimeToTimelineTime, + mapTimelineTimeToSourceTime, + type SpeedRegion, + trimsToClips, + type ZoomRegion, +} from "../types"; + +type Input = { + timeline: ReturnType<typeof useTimelineState>; + duration: number; + currentTime: number; + nextClipIdRef: MutableRefObject<number>; + initializedRef: MutableRefObject<boolean>; + autoFullTrackIdRef: MutableRefObject<string | null>; + autoFullTrackEndRef: MutableRefObject<number | null>; +}; + +export function useTimelineProjection({ + timeline, + duration, + currentTime, + nextClipIdRef, + initializedRef, + autoFullTrackIdRef, + autoFullTrackEndRef, +}: Input) { + const { clipRegions, trimRegions, speedRegions, zoomRegions, autoCaptions } = timeline; + + useEffect(() => { + const totalMs = Math.round(duration * 1000); + if (totalMs <= 0) return; + if (!initializedRef.current) { + if (clipRegions.length === 0) { + const nextRegions = + trimRegions.length > 0 + ? trimsToClips(trimRegions, totalMs) + : (() => { + const id = `clip-${nextClipIdRef.current++}`; + autoFullTrackIdRef.current = id; + autoFullTrackEndRef.current = totalMs; + return [{ id, startMs: 0, endMs: totalMs, speed: 1 as const }]; + })(); + if (trimRegions.length > 0) { + nextClipIdRef.current = deriveNextId( + "clip", + nextRegions.map(({ id }) => id), + ); + } + timeline.setClipRegions(nextRegions); + } + initializedRef.current = true; + return; + } + + const extended = extendAutoFullTrackClip( + clipRegions, + autoFullTrackIdRef.current, + autoFullTrackEndRef.current, + totalMs, + ); + if (!extended) return; + autoFullTrackEndRef.current = totalMs; + timeline.setClipRegions(extended); + }, [duration, clipRegions, trimRegions, nextClipIdRef, timeline.setClipRegions]); + + useEffect(() => { + const totalMs = Math.round(duration * 1000); + if (totalMs > 0 && clipRegions.length > 0) { + timeline.setTrimRegions(clipsToTrims(clipRegions, totalMs)); + } + }, [clipRegions, duration, timeline.setTrimRegions]); + + const toSourceTime = useCallback( + (timeMs: number) => mapTimelineTimeToSourceTime(timeMs, clipRegions), + [clipRegions], + ); + const toTimelineTime = useCallback( + (timeMs: number) => mapSourceTimeToTimelineTime(timeMs, clipRegions), + [clipRegions], + ); + const effectiveZoomRegions = useMemo<ZoomRegion[]>( + () => + zoomRegions.map((region) => ({ + ...region, + startMs: toSourceTime(region.startMs), + endMs: toSourceTime(region.endMs), + })), + [zoomRegions, toSourceTime], + ); + const effectiveCaptionRegions = useMemo<CaptionCue[]>( + () => + autoCaptions.map((cue) => ({ + ...cue, + startMs: toTimelineTime(cue.startMs), + endMs: toTimelineTime(cue.endMs), + })), + [autoCaptions, toTimelineTime], + ); + const timelinePlayheadTime = useMemo( + () => toTimelineTime(currentTime * 1000) / 1000, + [currentTime, toTimelineTime], + ); + const timelineDuration = useMemo( + () => getTimelineDurationMs(clipRegions, duration * 1000) / 1000, + [clipRegions, duration], + ); + const effectiveSpeedRegions = useMemo<SpeedRegion[]>(() => { + const clipDerived = clipRegions + .filter(({ speed }) => speed !== 1) + .map((clip) => ({ + id: `clip-speed-${clip.id}`, + startMs: clip.startMs, + endMs: getClipSourceEndMs(clip), + speed: clip.speed as SpeedRegion["speed"], + })); + if (clipDerived.length === 0) return speedRegions; + return [ + ...speedRegions, + ...clipDerived.filter( + (candidate) => + !speedRegions.some( + (region) => + region.endMs > candidate.startMs && region.startMs < candidate.endMs, + ), + ), + ]; + }, [clipRegions, speedRegions]); + + return { + mapTimelineTimeToSourceTime: toSourceTime, + mapSourceTimeToTimelineTime: toTimelineTime, + effectiveZoomRegions, + effectiveCaptionRegions, + timelinePlayheadTime, + timelineDuration, + effectiveSpeedRegions, + }; +} diff --git a/src/components/video-editor/hooks/useZoomRegionCommands.ts b/src/components/video-editor/hooks/useZoomRegionCommands.ts new file mode 100644 index 000000000..0fadb5785 --- /dev/null +++ b/src/components/video-editor/hooks/useZoomRegionCommands.ts @@ -0,0 +1,183 @@ +import type { Span } from "dnd-timeline"; +import { type Dispatch, type MutableRefObject, type SetStateAction, useCallback } from "react"; +import { + clampFocusToDepth, + DEFAULT_AUTO_ZOOM_DEPTH, + type EditorEffectSection, + type ZoomDepth, + type ZoomFocus, + type ZoomMode, + type ZoomRegion, +} from "../types"; + +interface UseZoomRegionCommandsParams { + videoPath: string | null; + setZoomRegions: Dispatch<SetStateAction<ZoomRegion[]>>; + selectedZoomId: string | null; + setSelectedZoomId: Dispatch<SetStateAction<string | null>>; + setSelectedAnnotationId: Dispatch<SetStateAction<string | null>>; + setSelectedAudioId: Dispatch<SetStateAction<string | null>>; + setSelectedCaptionId: Dispatch<SetStateAction<string | null>>; + setActiveEffectSection: Dispatch<SetStateAction<EditorEffectSection>>; + nextZoomIdRef: MutableRefObject<number>; + autoSuggestedVideoPathRef: MutableRefObject<string | null>; + pendingFreshRecordingAutoZoomPathRef: MutableRefObject<string | null>; +} + +export function useZoomRegionCommands({ + videoPath, + setZoomRegions, + selectedZoomId, + setSelectedZoomId, + setSelectedAnnotationId, + setSelectedAudioId, + setSelectedCaptionId, + setActiveEffectSection, + nextZoomIdRef, + autoSuggestedVideoPathRef, + pendingFreshRecordingAutoZoomPathRef, +}: UseZoomRegionCommandsParams) { + const handleSelectZoom = useCallback( + (id: string | null) => { + setSelectedZoomId(id); + if (id) { + setActiveEffectSection("zoom"); + setSelectedAnnotationId(null); + setSelectedAudioId(null); + setSelectedCaptionId(null); + } else { + setActiveEffectSection((section) => (section === "zoom" ? "scene" : section)); + } + }, + [ + setActiveEffectSection, + setSelectedAnnotationId, + setSelectedAudioId, + setSelectedCaptionId, + setSelectedZoomId, + ], + ); + + const markFreshRecordingSuggestion = useCallback(() => { + if (videoPath && pendingFreshRecordingAutoZoomPathRef.current === videoPath) { + autoSuggestedVideoPathRef.current = videoPath; + pendingFreshRecordingAutoZoomPathRef.current = null; + } + }, [autoSuggestedVideoPathRef, pendingFreshRecordingAutoZoomPathRef, videoPath]); + + const handleZoomAdded = useCallback( + (span: Span) => { + const id = `zoom-${nextZoomIdRef.current++}`; + const depth: ZoomDepth = 2; + const newRegion: ZoomRegion = { + id, + startMs: Math.round(span.start), + endMs: Math.round(span.end), + depth, + focus: clampFocusToDepth({ cx: 0.5, cy: 0.5 }, depth), + // Mode describes camera tracking behavior, not how the region was created. + mode: "auto", + }; + markFreshRecordingSuggestion(); + setZoomRegions((current) => [...current, newRegion]); + setSelectedZoomId(id); + setSelectedAnnotationId(null); + setSelectedCaptionId(null); + }, + [ + markFreshRecordingSuggestion, + nextZoomIdRef, + setSelectedAnnotationId, + setSelectedCaptionId, + setSelectedZoomId, + setZoomRegions, + ], + ); + + const handleZoomSuggested = useCallback( + (span: Span, focus: ZoomFocus) => { + const newRegion: ZoomRegion = { + id: `zoom-${nextZoomIdRef.current++}`, + startMs: Math.round(span.start), + endMs: Math.round(span.end), + depth: DEFAULT_AUTO_ZOOM_DEPTH, + focus: clampFocusToDepth(focus, DEFAULT_AUTO_ZOOM_DEPTH), + mode: "auto", + }; + markFreshRecordingSuggestion(); + setZoomRegions((current) => [...current, newRegion]); + }, + [markFreshRecordingSuggestion, nextZoomIdRef, setZoomRegions], + ); + + const handleZoomSpanChange = useCallback( + (id: string, span: Span) => { + setZoomRegions((current) => + current.map((region) => + region.id === id + ? { + ...region, + startMs: Math.round(span.start), + endMs: Math.round(span.end), + } + : region, + ), + ); + }, + [setZoomRegions], + ); + const handleZoomFocusChange = useCallback( + (id: string, focus: ZoomFocus) => { + setZoomRegions((current) => + current.map((region) => + region.id === id + ? { ...region, focus: clampFocusToDepth(focus, region.depth) } + : region, + ), + ); + }, + [setZoomRegions], + ); + const handleZoomDepthChange = useCallback( + (depth: ZoomDepth) => { + if (!selectedZoomId) return; + setZoomRegions((current) => + current.map((region) => + region.id === selectedZoomId + ? { ...region, depth, focus: clampFocusToDepth(region.focus, depth) } + : region, + ), + ); + }, + [selectedZoomId, setZoomRegions], + ); + const handleZoomModeChange = useCallback( + (mode: ZoomMode) => { + if (!selectedZoomId) return; + setZoomRegions((current) => + current.map((region) => + region.id === selectedZoomId ? { ...region, mode } : region, + ), + ); + }, + [selectedZoomId, setZoomRegions], + ); + const handleZoomDelete = useCallback( + (id: string) => { + setZoomRegions((current) => current.filter((region) => region.id !== id)); + if (selectedZoomId === id) setSelectedZoomId(null); + }, + [selectedZoomId, setSelectedZoomId, setZoomRegions], + ); + + return { + handleSelectZoom, + handleZoomAdded, + handleZoomSuggested, + handleZoomSpanChange, + handleZoomFocusChange, + handleZoomDepthChange, + handleZoomModeChange, + handleZoomDelete, + }; +} diff --git a/src/components/video-editor/index.ts b/src/components/video-editor/index.ts index 5fcf1bbc8..ed621d59d 100644 --- a/src/components/video-editor/index.ts +++ b/src/components/video-editor/index.ts @@ -1,5 +1,9 @@ -export { default as VideoEditor } from './VideoEditor'; -export { default as VideoPlayback } from './VideoPlayback'; -export { default as PlaybackControls } from './PlaybackControls'; -export { default as TimelineEditor } from './timeline/TimelineEditor'; -export { default as SettingsPanel } from './SettingsPanel'; +export { default as PlaybackControls } from "./PlaybackControls"; +export { SettingsPanel } from "./SettingsPanel"; +export { default as TimelineEditor } from "./timeline/TimelineEditor"; +export type { + TimelineEditorHandle, + TimelineEditorProps, +} from "./timeline/TimelineEditor"; +export { default as VideoEditor } from "./VideoEditor"; +export { default as VideoPlayback } from "./VideoPlayback"; diff --git a/src/components/video-editor/layout/CropEditorDialog.tsx b/src/components/video-editor/layout/CropEditorDialog.tsx new file mode 100644 index 000000000..420a3b989 --- /dev/null +++ b/src/components/video-editor/layout/CropEditorDialog.tsx @@ -0,0 +1,61 @@ +import type { Dispatch, SetStateAction } from "react"; +import { Button } from "@/components/ui/button"; +import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog"; +import type { useI18n } from "@/contexts/I18nContext"; +import type { AspectRatio } from "@/utils/aspectRatioUtils"; +import { CropControl } from "../CropControl"; +import type { CropRegion } from "../types"; + +type Props = { + open: boolean; + t: ReturnType<typeof useI18n>["t"]; + videoElement: HTMLVideoElement | null; + cropRegion: CropRegion; + setCropRegion: Dispatch<SetStateAction<CropRegion>>; + aspectRatio: AspectRatio; + onCancel: () => void; + onDone: () => void; +}; + +export function CropEditorDialog({ + open, + t, + videoElement, + cropRegion, + setCropRegion, + aspectRatio, + onCancel, + onDone, +}: Props) { + return ( + <Dialog open={open} onOpenChange={(nextOpen) => !nextOpen && onCancel()}> + <DialogContent className="max-h-[90vh] w-[90vw] max-w-5xl overflow-auto rounded-2xl border-foreground/10 bg-editor-dialog p-8 shadow-2xl"> + <div className="mb-6 flex items-center justify-between"> + <div> + <DialogTitle className="text-xl font-bold text-foreground"> + {t("settings.crop.title")} + </DialogTitle> + <p className="mt-2 text-sm text-muted-foreground"> + {t("settings.crop.instruction")} + </p> + </div> + </div> + <CropControl + videoElement={videoElement} + cropRegion={cropRegion} + onCropChange={setCropRegion} + aspectRatio={aspectRatio} + /> + <div className="mt-6 flex justify-end"> + <Button + onClick={onDone} + size="lg" + className="bg-[#2563EB] text-white hover:bg-[#2563EB]/90" + > + {t("common.actions.done")} + </Button> + </div> + </DialogContent> + </Dialog> + ); +} diff --git a/src/components/video-editor/layout/EditorDialogs.tsx b/src/components/video-editor/layout/EditorDialogs.tsx new file mode 100644 index 000000000..3b22a63c2 --- /dev/null +++ b/src/components/video-editor/layout/EditorDialogs.tsx @@ -0,0 +1,209 @@ +import type { Dispatch, FormEvent, RefObject, SetStateAction } from "react"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import ProjectBrowserDialog, { type ProjectLibraryEntry } from "../ProjectBrowserDialog"; + +export type UnsavedChangesDecision = "cancel" | "discard" | "save"; + +type Translator = ( + key: string, + fallback?: string, + params?: Record<string, string | number>, +) => string; + +interface EditorDialogsProps { + t: Translator; + projectSaveDialogOpen: boolean; + setProjectSaveDialogOpen: Dispatch<SetStateAction<boolean>>; + projectSaveDialogDraft: string; + setProjectSaveDialogDraft: Dispatch<SetStateAction<string>>; + projectSaveDialogInputRef: RefObject<HTMLInputElement>; + isSavingProjectDialog: boolean; + resolveProjectSaveDialog: (saved: boolean) => void; + handleProjectSaveDialogSubmit: (event?: FormEvent<HTMLFormElement>) => Promise<void>; + unsavedChangesDialogOpen: boolean; + setUnsavedChangesDialogOpen: Dispatch<SetStateAction<boolean>>; + unsavedChangesDialogActionLabel: string; + resolveUnsavedChangesDialog: (decision: UnsavedChangesDecision) => void; + projectBrowserOpen: boolean; + setProjectBrowserOpen: Dispatch<SetStateAction<boolean>>; + projectLibraryEntries: ProjectLibraryEntry[]; + projectBrowserAnchorRef: RefObject<HTMLButtonElement>; + handleImportMediaOrProject: () => Promise<void>; + handleOpenProjectFromLibrary: (projectPath: string) => Promise<void>; + nativeCaptureUnavailableModalOpen: boolean; + setNativeCaptureUnavailableModalOpen: Dispatch<SetStateAction<boolean>>; +} + +export function EditorDialogs({ + t, + projectSaveDialogOpen, + setProjectSaveDialogOpen, + projectSaveDialogDraft, + setProjectSaveDialogDraft, + projectSaveDialogInputRef, + isSavingProjectDialog, + resolveProjectSaveDialog, + handleProjectSaveDialogSubmit, + unsavedChangesDialogOpen, + setUnsavedChangesDialogOpen, + unsavedChangesDialogActionLabel, + resolveUnsavedChangesDialog, + projectBrowserOpen, + setProjectBrowserOpen, + projectLibraryEntries, + projectBrowserAnchorRef, + handleImportMediaOrProject, + handleOpenProjectFromLibrary, + nativeCaptureUnavailableModalOpen, + setNativeCaptureUnavailableModalOpen, +}: EditorDialogsProps) { + return ( + <> + <Dialog + open={projectSaveDialogOpen} + onOpenChange={(open) => { + if (open) setProjectSaveDialogOpen(true); + else if (!isSavingProjectDialog) resolveProjectSaveDialog(false); + }} + > + <DialogContent className="max-w-sm border-foreground/10 bg-editor-dialog text-foreground"> + <form onSubmit={(event) => void handleProjectSaveDialogSubmit(event)}> + <DialogHeader> + <DialogTitle> + {t("editor.project.saveTitle", "Save Project")} + </DialogTitle> + <DialogDescription className="text-muted-foreground"> + {t( + "editor.project.saveDescription", + "Name this project. It will be saved in your Recordly Projects folder.", + )} + </DialogDescription> + </DialogHeader> + <div className="py-4"> + <label className="mb-2 block text-xs font-medium text-muted-foreground"> + {t("editor.project.saveNameLabel", "Project name")} + </label> + <div className="flex items-center overflow-hidden rounded-md border border-foreground/10 bg-editor-panel"> + <Input + ref={projectSaveDialogInputRef} + value={projectSaveDialogDraft} + onChange={(event) => + setProjectSaveDialogDraft(event.target.value) + } + disabled={isSavingProjectDialog} + className="h-10 flex-1 border-0 bg-transparent focus-visible:ring-0 focus-visible:ring-offset-0" + aria-label={t("editor.project.saveNameLabel", "Project name")} + /> + <span className="shrink-0 px-3 text-xs font-medium text-muted-foreground/70"> + .recordly + </span> + </div> + </div> + <DialogFooter> + <Button + type="button" + variant="ghost" + onClick={() => resolveProjectSaveDialog(false)} + disabled={isSavingProjectDialog} + > + {t("common.actions.cancel", "Cancel")} + </Button> + <Button type="submit" disabled={isSavingProjectDialog}> + {isSavingProjectDialog + ? t("editor.project.saving", "Saving...") + : t("common.actions.save", "Save")} + </Button> + </DialogFooter> + </form> + </DialogContent> + </Dialog> + + <Dialog + open={unsavedChangesDialogOpen} + onOpenChange={(open) => { + if (open) setUnsavedChangesDialogOpen(true); + else resolveUnsavedChangesDialog("cancel"); + }} + > + <DialogContent className="max-w-sm border-foreground/10 bg-editor-dialog text-foreground"> + <DialogHeader> + <DialogTitle> + {t("editor.project.unsavedChangesTitle", "Unsaved changes")} + </DialogTitle> + <DialogDescription className="text-muted-foreground"> + {t( + "editor.project.unsavedChangesDescription", + "Save your current project before you {{action}}?", + { action: unsavedChangesDialogActionLabel }, + )} + </DialogDescription> + </DialogHeader> + <DialogFooter> + <Button + type="button" + variant="ghost" + onClick={() => resolveUnsavedChangesDialog("cancel")} + > + {t("common.actions.cancel", "Cancel")} + </Button> + <Button + type="button" + variant="ghost" + onClick={() => resolveUnsavedChangesDialog("discard")} + > + {t("editor.project.discardChanges", "Discard changes")} + </Button> + <Button type="button" onClick={() => resolveUnsavedChangesDialog("save")}> + {t("editor.project.saveProject", "Save project")} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + + <ProjectBrowserDialog + open={projectBrowserOpen} + onOpenChange={setProjectBrowserOpen} + entries={projectLibraryEntries} + anchorRef={projectBrowserAnchorRef} + onImportFile={() => void handleImportMediaOrProject()} + onOpenProject={(projectPath) => void handleOpenProjectFromLibrary(projectPath)} + /> + + <Dialog + open={nativeCaptureUnavailableModalOpen} + onOpenChange={setNativeCaptureUnavailableModalOpen} + > + <DialogContent className="max-w-md bg-editor-dialog border-foreground/10 text-foreground"> + <DialogHeader> + <DialogTitle> + {t( + "editor.nativeCaptureUnavailable.title", + "Nothing’s broken, but we won’t be able to render an animated cursor overlay.", + )} + </DialogTitle> + <DialogDescription className="text-muted-foreground"> + {t( + "editor.nativeCaptureUnavailable.description", + "Your device does not support native capture. This could be for a variety of reasons we haven’t figured out yet. This doesn’t break Recordly, but it does make cursor smoothing impossible.", + )} + </DialogDescription> + </DialogHeader> + <DialogFooter> + <Button onClick={() => setNativeCaptureUnavailableModalOpen(false)}> + {t("editor.nativeCaptureUnavailable.confirm", "Okay")} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + </> + ); +} diff --git a/src/components/video-editor/layout/EditorExportMenu.tsx b/src/components/video-editor/layout/EditorExportMenu.tsx new file mode 100644 index 000000000..b39a8521e --- /dev/null +++ b/src/components/video-editor/layout/EditorExportMenu.tsx @@ -0,0 +1,287 @@ +import { DownloadSimple as Download } from "@phosphor-icons/react"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import type { useI18n } from "@/contexts/I18nContext"; +import { ExportSettingsMenu } from "../ExportSettingsMenu"; +import type { useExportDimensions } from "../export/useExportDimensions"; +import type { useExportSession } from "../export/useExportSession"; +import type { useExportSettings } from "../export/useExportSettings"; +import type { useExportStatusViewModel } from "../export/useExportStatusViewModel"; + +type Props = { + t: ReturnType<typeof useI18n>["t"]; + exportSettings: ReturnType<typeof useExportSettings>; + exportSession: ReturnType<typeof useExportSession>; + exportDimensions: ReturnType<typeof useExportDimensions>; + exportStatus: ReturnType<typeof useExportStatusViewModel>; + hasCaptionsForSidecar: boolean; + nvidiaCudaExportAvailable: boolean; + experimentalNvidiaCudaExport: boolean; + setExperimentalNvidiaCudaExport: (enabled: boolean) => void; + handleOpenExportDropdown: () => void; + handleExportDropdownClose: () => void; + handleCancelExport: () => void; + handleRetrySaveExport: () => void; + handleStartExportFromDropdown: () => void; + revealExportedFile: () => void; + exportMessage: string | null; +}; + +export function EditorExportMenu(props: Props) { + const { + t, + exportSettings, + exportSession, + exportDimensions, + exportStatus, + hasCaptionsForSidecar, + nvidiaCudaExportAvailable, + experimentalNvidiaCudaExport, + setExperimentalNvidiaCudaExport, + handleOpenExportDropdown, + handleExportDropdownClose, + handleCancelExport, + handleRetrySaveExport, + handleStartExportFromDropdown, + revealExportedFile, + exportMessage, + } = props; + const { + exportQuality, + setExportQuality, + exportEncodingMode, + setExportEncodingMode, + exportPipelineModel, + mp4FrameRate, + setMp4FrameRate, + exportFormat, + setExportFormat, + gifFrameRate, + setGifFrameRate, + gifLoop, + setGifLoop, + gifSizePreset, + setGifSizePreset, + includeCaptionSidecar, + setIncludeCaptionSidecar, + } = exportSettings; + const { + isExporting, + exportProgress, + exportError, + showExportDropdown, + setShowExportDropdown, + exportedFilePath, + hasPendingExportSave, + } = exportSession; + const { gifOutputDimensions, mp4OutputDimensions } = exportDimensions; + const { + isExportPreparing, + isExportSaving, + isRenderingAudio, + isExportFinalSaveIndeterminate, + isLightningExportInProgress, + isLegacyExportInProgress, + exportFinalizingProgress, + exportRenderSpeedLabel, + exportPercentLabel, + runtimeLabel: exportRuntimeLabel, + nativeSkipLabel: exportNativeSkipLabel, + } = exportStatus; + + return ( + <DropdownMenu open={showExportDropdown} onOpenChange={setShowExportDropdown} modal={false}> + <DropdownMenuTrigger asChild> + <Button + type="button" + onClick={handleOpenExportDropdown} + className="inline-flex h-8 min-w-[112px] items-center justify-center gap-2 rounded-[5px] bg-[#2563EB] px-4.5 text-white transition-colors hover:bg-[#2563EB]/92" + > + <Download className="h-4 w-4" /> + <span className="text-sm font-semibold tracking-tight"> + {t("common.actions.export", "Export")} + </span> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent + align="end" + sideOffset={10} + className="w-[360px] border-none bg-transparent p-0 shadow-none" + > + {isExporting ? ( + <div className="rounded-2xl border border-foreground/10 bg-editor-surface p-4 text-foreground shadow-2xl"> + <div className="mb-3 flex items-center justify-between gap-3"> + <div> + <p className="text-sm font-semibold text-foreground"> + {t("editor.exportStatus.exporting", "Exporting")} + </p> + <p className="text-xs text-muted-foreground"> + {t("editor.exportStatus.renderingFile", "Rendering your file.")} + </p> + {isLightningExportInProgress && exportMessage ? ( + <p className="mt-1 text-[11px] leading-relaxed text-muted-foreground/70"> + {exportMessage} + </p> + ) : null} + {isLegacyExportInProgress ? ( + <p className="mt-1 text-[11px] text-muted-foreground/70"> + Export too slow? Cancel and try Lightning export! + </p> + ) : null} + </div> + <Button + type="button" + variant="outline" + onClick={handleCancelExport} + className="h-8 border-red-500/20 bg-red-500/10 px-3 text-xs text-red-400 hover:bg-red-500/20" + > + {t("common.actions.cancel")} + </Button> + </div> + <div className="h-2 overflow-hidden rounded-full border border-foreground/5 bg-foreground/5"> + {isExportPreparing || + isExportSaving || + isExportFinalSaveIndeterminate ? ( + <div className="indeterminate-progress h-full rounded-full bg-transparent" /> + ) : ( + <div + className="h-full bg-[#2563EB] transition-all duration-300 ease-out" + style={{ + width: `${Math.min(isRenderingAudio ? (exportProgress?.audioProgress ?? 0) * 100 : (exportFinalizingProgress ?? exportProgress?.percentage ?? 8), 100)}%`, + }} + /> + )} + </div> + <p className="mt-2 text-xs text-muted-foreground">{exportPercentLabel}</p> + {isRenderingAudio ? ( + <p className="mt-1 text-[11px] text-muted-foreground/70"> + {t( + "editor.export.processingAudioEdits", + "Processing audio with speed/overlay edits", + )} + </p> + ) : exportRenderSpeedLabel ? ( + <p className="mt-1 text-[11px] text-muted-foreground/70"> + {exportRenderSpeedLabel} + </p> + ) : null} + {exportRuntimeLabel ? ( + <p className="mt-1 text-[11px] text-muted-foreground/70"> + Path: {exportRuntimeLabel} + </p> + ) : null} + {exportNativeSkipLabel ? ( + <p className="mt-1 text-[11px] text-amber-500/80"> + {exportNativeSkipLabel} + </p> + ) : null} + </div> + ) : exportError ? ( + <div className="rounded-2xl border border-foreground/10 bg-editor-surface p-4 text-foreground shadow-2xl"> + <p className="text-sm font-semibold text-foreground"> + {t("editor.exportStatus.issue", "Export issue")} + </p> + {exportRuntimeLabel ? ( + <p className="mt-1 text-[11px] text-muted-foreground/70"> + Path: {exportRuntimeLabel} + </p> + ) : null} + <p className="mt-1 whitespace-pre-line text-xs leading-relaxed text-muted-foreground"> + {exportError} + </p> + <div className="mt-4 flex gap-2"> + {hasPendingExportSave ? ( + <Button + type="button" + onClick={handleRetrySaveExport} + className="h-8 flex-1 rounded-[5px] bg-[#2563EB] text-xs font-semibold text-white hover:bg-[#2563EB]/92" + > + {t("editor.actions.saveAgain", "Save Again")} + </Button> + ) : null} + <Button + type="button" + variant="outline" + onClick={handleExportDropdownClose} + className="h-8 flex-1 border-foreground/10 bg-foreground/5 text-xs text-muted-foreground hover:bg-foreground/10" + > + {t("common.actions.close", "Close")} + </Button> + </div> + </div> + ) : exportedFilePath ? ( + <div className="rounded-2xl border border-foreground/10 bg-editor-surface p-4 text-foreground shadow-2xl"> + <p className="text-sm font-semibold text-foreground"> + {t("editor.exportStatus.complete", "Export complete")} + </p> + <p className="mt-1 text-xs text-muted-foreground"> + {t( + "editor.exportStatus.savedSuccessfully", + "Your file was saved successfully.", + )} + </p> + {exportRuntimeLabel ? ( + <p className="mt-1 text-[11px] text-muted-foreground/70"> + Path: {exportRuntimeLabel} + </p> + ) : null} + <p className="mt-3 truncate text-xs text-muted-foreground/70"> + {exportedFilePath.split(/[\\/]/).pop()} + </p> + <div className="mt-4 flex gap-2"> + <Button + type="button" + onClick={revealExportedFile} + className="h-8 flex-1 rounded-[5px] bg-[#2563EB] text-xs font-semibold text-white hover:bg-[#2563EB]/92" + > + {t("editor.actions.showInFolder", "Show In Folder")} + </Button> + <Button + type="button" + variant="outline" + onClick={handleExportDropdownClose} + className="h-8 flex-1 border-foreground/10 bg-foreground/5 text-xs text-muted-foreground hover:bg-foreground/10" + > + Done + </Button> + </div> + </div> + ) : ( + <ExportSettingsMenu + exportFormat={exportFormat} + onExportFormatChange={setExportFormat} + exportEncodingMode={exportEncodingMode} + onExportEncodingModeChange={setExportEncodingMode} + mp4FrameRate={mp4FrameRate} + onMp4FrameRateChange={setMp4FrameRate} + exportPipelineModel={exportPipelineModel} + experimentalNvidiaCudaExport={ + experimentalNvidiaCudaExport && nvidiaCudaExportAvailable + } + onExperimentalNvidiaCudaExportChange={setExperimentalNvidiaCudaExport} + nvidiaCudaExportAvailable={nvidiaCudaExportAvailable} + exportQuality={exportQuality} + onExportQualityChange={setExportQuality} + gifFrameRate={gifFrameRate} + onGifFrameRateChange={setGifFrameRate} + gifLoop={gifLoop} + onGifLoopChange={setGifLoop} + gifSizePreset={gifSizePreset} + onGifSizePresetChange={setGifSizePreset} + showCaptionSidecarOption={hasCaptionsForSidecar && exportFormat === "mp4"} + includeCaptionSidecar={includeCaptionSidecar} + onIncludeCaptionSidecarChange={setIncludeCaptionSidecar} + mp4OutputDimensions={mp4OutputDimensions} + gifOutputDimensions={gifOutputDimensions} + onExport={handleStartExportFromDropdown} + className="shadow-2xl" + /> + )} + </DropdownMenuContent> + </DropdownMenu> + ); +} diff --git a/src/components/video-editor/layout/EditorHeader.tsx b/src/components/video-editor/layout/EditorHeader.tsx new file mode 100644 index 000000000..927830d8d --- /dev/null +++ b/src/components/video-editor/layout/EditorHeader.tsx @@ -0,0 +1,227 @@ +import { + FolderOpen, + ArrowClockwise as Redo2, + ArrowCounterClockwise as Undo2, +} from "@phosphor-icons/react"; +import type { CSSProperties, FormEvent, RefObject } from "react"; +import { Button } from "@/components/ui/button"; +import type { useI18n } from "@/contexts/I18nContext"; +import type { useExportDimensions } from "../export/useExportDimensions"; +import type { useExportSession } from "../export/useExportSession"; +import type { useExportSettings } from "../export/useExportSettings"; +import type { useExportStatusViewModel } from "../export/useExportStatusViewModel"; +import type { useVideoEditorPresets } from "../presets/useVideoEditorPresets"; +import type { useProjectState } from "../state/useProjectState"; +import { APP_HEADER_ICON_BUTTON_CLASS, DiscordLinkButton, FeedbackDialog } from "../TutorialHelp"; +import { EditorExportMenu } from "./EditorExportMenu"; +import { EditorPresetMenu } from "./EditorPresetMenu"; + +type Props = { + t: ReturnType<typeof useI18n>["t"]; + headerLeftControlsPaddingClass: string; + project: ReturnType<typeof useProjectState>; + projectBrowserTriggerRef: RefObject<HTMLButtonElement>; + projectNameInputRef: RefObject<HTMLInputElement>; + projectDisplayName: string; + hasUnsavedChanges: boolean; + canUndo: boolean; + canRedo: boolean; + handleOpenProjectBrowser: () => void; + handleUndo: () => void; + handleRedo: () => void; + handleProjectNameSubmit: (event?: FormEvent<HTMLFormElement>) => void; + closeProjectNameEditor: () => void; + presets: ReturnType<typeof useVideoEditorPresets>; + exportSettings: ReturnType<typeof useExportSettings>; + exportSession: ReturnType<typeof useExportSession>; + exportDimensions: ReturnType<typeof useExportDimensions>; + exportStatus: ReturnType<typeof useExportStatusViewModel>; + hasCaptionsForSidecar: boolean; + nvidiaCudaExportAvailable: boolean; + experimentalNvidiaCudaExport: boolean; + setExperimentalNvidiaCudaExport: (enabled: boolean) => void; + handleOpenExportDropdown: () => void; + handleExportDropdownClose: () => void; + handleCancelExport: () => void; + handleRetrySaveExport: () => void; + handleStartExportFromDropdown: () => void; + revealExportedFile: () => void; + exportMessage: string | null; +}; + +export function EditorHeader(props: Props) { + const { + t, + headerLeftControlsPaddingClass, + project, + projectBrowserTriggerRef, + projectNameInputRef, + projectDisplayName, + hasUnsavedChanges, + canUndo, + canRedo, + handleOpenProjectBrowser, + handleUndo, + handleRedo, + handleProjectNameSubmit, + closeProjectNameEditor, + presets, + exportSettings, + exportSession, + exportDimensions, + exportStatus, + hasCaptionsForSidecar, + nvidiaCudaExportAvailable, + experimentalNvidiaCudaExport, + setExperimentalNvidiaCudaExport, + handleOpenExportDropdown, + handleExportDropdownClose, + handleCancelExport, + handleRetrySaveExport, + handleStartExportFromDropdown, + revealExportedFile, + exportMessage, + } = props; + const { + isEditingProjectName, + setIsEditingProjectName, + projectNameDraft, + setProjectNameDraft, + isSavingProjectName, + } = project; + + return ( + <div + className="relative z-50 flex h-11 flex-shrink-0 items-center justify-between border-b border-foreground/10 bg-editor-header/88 px-5 backdrop-blur-md" + style={{ WebkitAppRegion: "drag" } as CSSProperties} + > + <div + className={`flex items-center justify-self-start gap-1.5 ${headerLeftControlsPaddingClass}`} + style={{ WebkitAppRegion: "no-drag" } as CSSProperties} + > + <Button + ref={projectBrowserTriggerRef} + type="button" + variant="ghost" + size="sm" + onClick={handleOpenProjectBrowser} + className={APP_HEADER_ICON_BUTTON_CLASS} + title={t("editor.project.projects", "Open projects")} + aria-label={t("editor.project.projects", "Open projects")} + > + <FolderOpen className="h-4 w-4" /> + </Button> + <DiscordLinkButton /> + <FeedbackDialog /> + <div className="ml-1 h-5 w-px bg-foreground/10" /> + <Button + type="button" + variant="ghost" + onClick={handleUndo} + disabled={!canUndo} + className="inline-flex h-8 w-8 items-center justify-center rounded-[5px] border border-foreground/10 bg-foreground/5 p-0 text-foreground transition-colors hover:bg-foreground/10 hover:text-foreground disabled:cursor-not-allowed disabled:opacity-40" + title={t("common.actions.undo", "Undo")} + aria-label={t("common.actions.undo", "Undo")} + > + <Undo2 className="h-4 w-4" /> + </Button> + <Button + type="button" + variant="ghost" + onClick={handleRedo} + disabled={!canRedo} + className="inline-flex h-8 w-8 items-center justify-center rounded-[5px] border border-foreground/10 bg-foreground/5 p-0 text-foreground transition-colors hover:bg-foreground/10 hover:text-foreground disabled:cursor-not-allowed disabled:opacity-40" + title={t("common.actions.redo", "Redo")} + aria-label={t("common.actions.redo", "Redo")} + > + <Redo2 className="h-4 w-4" /> + </Button> + </div> + + <div + className="absolute left-1/2 flex min-w-0 -translate-x-1/2 items-center justify-center" + style={{ WebkitAppRegion: "no-drag" } as CSSProperties} + > + {isEditingProjectName ? ( + <form + onSubmit={(event) => void handleProjectNameSubmit(event)} + className="flex max-w-[min(52vw,460px)] items-baseline gap-1 rounded-[7px] border border-foreground/10 bg-editor-panel/[0.88] px-2.5 py-1 shadow-[0_10px_28px_rgba(0,0,0,0.18)]" + > + {hasUnsavedChanges ? ( + <span className="mt-[1px] size-2 shrink-0 rounded-full bg-[#2563EB]" /> + ) : null} + <input + ref={projectNameInputRef} + type="text" + value={projectNameDraft} + onChange={(event) => setProjectNameDraft(event.target.value)} + onBlur={() => { + if (!isSavingProjectName) closeProjectNameEditor(); + }} + onKeyDown={(event) => { + if (event.key === "Escape") { + event.preventDefault(); + closeProjectNameEditor(); + } + }} + disabled={isSavingProjectName} + className="min-w-[10ch] max-w-[min(40vw,360px)] bg-transparent text-sm font-semibold tracking-tight text-foreground/95 outline-none placeholder:text-muted-foreground/60 disabled:cursor-wait" + style={{ width: `${Math.max(projectNameDraft.length, 10)}ch` }} + aria-label={t("editor.project.renameInput", "Project name")} + /> + <span className="shrink-0 text-xs font-medium tracking-tight text-muted-foreground/70"> + .recordly + </span> + </form> + ) : ( + <button + type="button" + onClick={() => setIsEditingProjectName(true)} + className="inline-flex max-w-[min(52vw,460px)] items-baseline gap-1 rounded-[7px] px-2.5 py-1 transition-colors hover:bg-foreground/5" + title={t("editor.project.renameTitle", "Rename project")} + aria-label={t("editor.project.renameTitle", "Rename project")} + > + {hasUnsavedChanges ? ( + <span className="mt-[1px] size-2 shrink-0 rounded-full bg-[#2563EB]" /> + ) : null} + <span className="truncate text-sm font-semibold tracking-tight text-foreground/90"> + {projectDisplayName} + </span> + <span className="shrink-0 text-xs font-medium tracking-tight text-muted-foreground/70"> + .recordly + </span> + </button> + )} + </div> + + <div + className="flex items-center justify-self-end" + style={{ WebkitAppRegion: "no-drag" } as CSSProperties} + > + <EditorPresetMenu t={t} presets={presets} /> + <div + aria-hidden="true" + className="mx-2 h-4 w-px shrink-0 bg-foreground/10 opacity-0" + /> + <EditorExportMenu + t={t} + exportSettings={exportSettings} + exportSession={exportSession} + exportDimensions={exportDimensions} + exportStatus={exportStatus} + hasCaptionsForSidecar={hasCaptionsForSidecar} + nvidiaCudaExportAvailable={nvidiaCudaExportAvailable} + experimentalNvidiaCudaExport={experimentalNvidiaCudaExport} + setExperimentalNvidiaCudaExport={setExperimentalNvidiaCudaExport} + handleOpenExportDropdown={handleOpenExportDropdown} + handleExportDropdownClose={handleExportDropdownClose} + handleCancelExport={handleCancelExport} + handleRetrySaveExport={handleRetrySaveExport} + handleStartExportFromDropdown={handleStartExportFromDropdown} + revealExportedFile={revealExportedFile} + exportMessage={exportMessage} + /> + </div> + </div> + ); +} diff --git a/src/components/video-editor/layout/EditorPresetMenu.tsx b/src/components/video-editor/layout/EditorPresetMenu.tsx new file mode 100644 index 000000000..1c360a553 --- /dev/null +++ b/src/components/video-editor/layout/EditorPresetMenu.tsx @@ -0,0 +1,137 @@ +import { BookmarkSimple, CaretDown, Check, X } from "@phosphor-icons/react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import type { useI18n } from "@/contexts/I18nContext"; +import { cn } from "@/lib/utils"; +import type { useVideoEditorPresets } from "../presets/useVideoEditorPresets"; + +type Props = { + t: ReturnType<typeof useI18n>["t"]; + presets: ReturnType<typeof useVideoEditorPresets>; +}; + +export function EditorPresetMenu({ t, presets }: Props) { + const { + editorPresets, + presetPopoverOpen, + setPresetPopoverOpen, + presetNameDraft, + setPresetNameDraft, + currentEditorPreset, + handleApplyEditorPreset, + handleDeleteEditorPreset, + handleSavePresetSubmit, + } = presets; + + return ( + <Popover open={presetPopoverOpen} onOpenChange={setPresetPopoverOpen}> + <PopoverTrigger asChild> + <button + type="button" + title={t("editor.presets.open", "Open presets")} + aria-label={t("editor.presets.open", "Open presets")} + className="inline-flex items-center gap-1.5 bg-transparent p-0 text-sm font-medium tracking-tight text-foreground outline-none transition-opacity hover:opacity-80" + > + <span className="flex items-center gap-1.5"> + <BookmarkSimple weight="fill" className="h-4 w-4" /> + <span> + {currentEditorPreset?.name ?? t("editor.presets.label", "Presets")} + </span> + </span> + <CaretDown className="h-3.5 w-3.5 text-foreground" /> + </button> + </PopoverTrigger> + <PopoverContent + align="end" + sideOffset={10} + className="w-[300px] rounded-2xl border border-foreground/10 bg-editor-surface-alt p-3 shadow-xl" + > + <div className="space-y-3"> + <form + onSubmit={(event) => { + event.preventDefault(); + handleSavePresetSubmit(); + }} + className="space-y-2" + > + <p className="text-[11px] font-medium text-foreground"> + {t("editor.presets.saveCurrentAs", "Save current preset as")} + </p> + <div className="flex items-center gap-2"> + <Input + value={presetNameDraft} + onChange={(event) => setPresetNameDraft(event.target.value)} + className="h-9 rounded-xl border-foreground/10 bg-background/70 text-sm" + placeholder={t("editor.presets.namePlaceholder", "Preset name")} + aria-label={t("editor.presets.namePlaceholder", "Preset name")} + /> + <Button + type="submit" + size="sm" + className="h-9 rounded-xl bg-[#2563EB] px-3 text-white hover:bg-[#1d4ed8]" + > + {t("common.actions.save", "Save")} + </Button> + </div> + </form> + <div className="space-y-2"> + <p className="text-[11px] font-medium text-foreground"> + {t("editor.presets.savedList", "Saved presets")} + </p> + <div className="max-h-56 space-y-1 overflow-y-auto pr-1 custom-scrollbar"> + {editorPresets.length === 0 ? ( + <div className="rounded-xl border border-dashed border-foreground/10 px-3 py-4 text-center text-[11px] text-muted-foreground"> + {t("editor.presets.empty", "No presets yet.")} + </div> + ) : ( + editorPresets.map((preset) => { + const isActive = preset.id === currentEditorPreset?.id; + return ( + <div + key={preset.id} + className={cn( + "flex items-center gap-2 rounded-xl border px-2 py-2 text-sm transition-colors", + isActive + ? "border-[#2563EB]/20 bg-[#2563EB]/10 text-foreground" + : "border-foreground/8 bg-foreground/[0.03] text-muted-foreground hover:bg-foreground/[0.06] hover:text-foreground", + )} + > + <button + type="button" + onClick={() => handleApplyEditorPreset(preset.id)} + className="flex min-w-0 flex-1 items-center justify-between text-left" + > + <span className="truncate pr-3">{preset.name}</span> + {isActive ? ( + <Check className="h-3.5 w-3.5 shrink-0 text-[#2563EB]" /> + ) : null} + </button> + <button + type="button" + onClick={() => handleDeleteEditorPreset(preset.id)} + className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-foreground/8 hover:text-foreground" + aria-label={t( + "editor.presets.deleteAriaLabel", + "Delete preset {{name}}", + { name: preset.name }, + )} + title={t( + "editor.presets.deleteAriaLabel", + "Delete preset {{name}}", + { name: preset.name }, + )} + > + <X className="h-3.5 w-3.5" /> + </button> + </div> + ); + }) + )} + </div> + </div> + </div> + </PopoverContent> + </Popover> + ); +} diff --git a/src/components/video-editor/layout/EditorPreviewPanel.tsx b/src/components/video-editor/layout/EditorPreviewPanel.tsx new file mode 100644 index 000000000..27fabb64a --- /dev/null +++ b/src/components/video-editor/layout/EditorPreviewPanel.tsx @@ -0,0 +1,392 @@ +import { + CaretDown, + Check, + Crop, + MagicWand, + MagnifyingGlassPlus, + Pause, + Play, + Plus, + Scissors, + SkipBack, + SkipForward, + SpeakerHigh, + SpeakerLow, + SpeakerX, +} from "@phosphor-icons/react"; +import type { Dispatch, RefObject, SetStateAction } from "react"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import type { useI18n } from "@/contexts/I18nContext"; +import { ASPECT_RATIOS, type AspectRatio, getAspectRatioLabel } from "@/utils/aspectRatioUtils"; +import type { useVideoEditorAudio } from "../audio/useVideoEditorAudio"; +import type { CaptionEditTarget } from "../captionEditing"; +import type { useAnnotationRegionCommands } from "../hooks/useAnnotationRegionCommands"; +import type { useEditorPlaybackControls } from "../hooks/useEditorPlaybackControls"; +import type { useTimelineProjection } from "../hooks/useTimelineProjection"; +import type { useZoomRegionCommands } from "../hooks/useZoomRegionCommands"; +import type { useAppearanceState } from "../state/useAppearanceState"; +import type { useTimelineState } from "../state/useTimelineState"; +import type { TimelineEditorHandle } from "../timeline/TimelineEditor"; +import type { VideoPlaybackRef } from "../VideoPlayback"; +import { EditorVideoPreview } from "./EditorVideoPreview"; + +type Props = { + t: ReturnType<typeof useI18n>["t"]; + videoPath: string | null; + previewVersion: number; + aspectRatio: AspectRatio; + setAspectRatio: Dispatch<SetStateAction<AspectRatio>>; + previewAspectRatioValue: number; + videoPlaybackRef: RefObject<VideoPlaybackRef>; + timelineRef: RefObject<TimelineEditorHandle>; + currentTime: number; + isPlaying: boolean; + previewVolume: number; + setPreviewVolume: Dispatch<SetStateAction<number>>; + suspendRendering: boolean; + appearance: ReturnType<typeof useAppearanceState>; + timeline: ReturnType<typeof useTimelineState>; + audio: ReturnType<typeof useVideoEditorAudio>; + projection: ReturnType<typeof useTimelineProjection>; + playback: ReturnType<typeof useEditorPlaybackControls>; + zoomCommands: ReturnType<typeof useZoomRegionCommands>; + annotationCommands: ReturnType<typeof useAnnotationRegionCommands>; + effectiveCursorTelemetry: ReturnType<typeof useTimelineState>["cursorTelemetry"]; + effectiveShowCursor: boolean; + isCropped: boolean; + handleOpenCropEditor: () => void; + handleSaveAutoCaptionEdit: (target: CaptionEditTarget, text: string) => void; + handleSelectAnnotation: (id: string | null) => void; + setDuration: Dispatch<SetStateAction<number>>; + setIsPreviewReady: Dispatch<SetStateAction<boolean>>; + setCurrentTime: Dispatch<SetStateAction<number>>; + setIsPlaying: Dispatch<SetStateAction<boolean>>; + setError: Dispatch<SetStateAction<string | null>>; +}; + +function formatTime(seconds: number) { + if (!Number.isFinite(seconds) || seconds < 0) return "0:00"; + const mins = Math.floor(seconds / 60); + const secs = Math.floor(seconds % 60); + return `${mins}:${secs.toString().padStart(2, "0")}`; +} + +export function EditorPreviewPanel(props: Props) { + const { + t, + videoPath, + previewVersion, + aspectRatio, + setAspectRatio, + previewAspectRatioValue, + videoPlaybackRef, + timelineRef, + currentTime, + isPlaying, + previewVolume, + setPreviewVolume, + suspendRendering, + appearance, + timeline, + audio, + projection, + playback, + zoomCommands, + annotationCommands, + effectiveCursorTelemetry, + effectiveShowCursor, + isCropped, + handleOpenCropEditor, + handleSaveAutoCaptionEdit, + handleSelectAnnotation, + setDuration, + setIsPreviewReady, + setCurrentTime, + setIsPlaying, + setError, + } = props; + + return ( + <div className="flex min-h-0 flex-1 flex-col gap-3"> + <div className="flex min-h-0 flex-1 flex-col"> + <div className="relative flex min-h-0 flex-1 flex-col overflow-hidden"> + <div className="flex flex-shrink-0 items-center justify-center gap-2 py-1.5"> + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button + variant="ghost" + size="sm" + className="h-7 gap-1 px-2 text-xs text-muted-foreground transition-all hover:bg-foreground/10 hover:text-foreground" + > + <span className="font-medium"> + {getAspectRatioLabel(aspectRatio)} + </span> + <CaretDown className="h-3 w-3" /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent + align="center" + className="border-foreground/10 bg-editor-surface-alt" + > + {ASPECT_RATIOS.map((ratio) => ( + <DropdownMenuItem + key={ratio} + onClick={() => setAspectRatio(ratio)} + className="flex cursor-pointer items-center justify-between gap-3 text-muted-foreground hover:bg-foreground/10 hover:text-foreground" + > + <span>{getAspectRatioLabel(ratio)}</span> + {aspectRatio === ratio ? ( + <Check className="h-3 w-3 text-[#2563EB]" /> + ) : null} + </DropdownMenuItem> + ))} + </DropdownMenuContent> + </DropdownMenu> + <div className="h-4 w-px bg-foreground/20" /> + <Button + variant="ghost" + size="sm" + onClick={handleOpenCropEditor} + className="h-7 gap-1.5 px-2 text-xs text-muted-foreground transition-all hover:bg-foreground/10 hover:text-foreground" + > + <Crop className="h-3.5 w-3.5" /> + <span className="font-medium">{t("settings.crop.title")}</span> + {isCropped ? ( + <span className="h-1.5 w-1.5 rounded-full bg-[#2563EB]" /> + ) : null} + </Button> + </div> + <div + className="flex min-h-0 w-full flex-1 items-stretch" + style={{ flex: "1 1 auto", margin: "6px 0 0" }} + > + <div className="flex min-w-0 flex-1 items-center justify-center px-1"> + <div + className="relative" + style={{ + width: "auto", + height: "100%", + aspectRatio: previewAspectRatioValue, + maxWidth: "100%", + margin: "0 auto", + boxSizing: "border-box", + }} + > + <EditorVideoPreview + videoPath={videoPath} + previewVersion={previewVersion} + aspectRatio={aspectRatio} + playbackRef={videoPlaybackRef} + currentTime={currentTime} + isPlaying={isPlaying} + previewVolume={previewVolume} + suspendRendering={suspendRendering} + appearance={appearance} + timeline={timeline} + audio={audio} + effectiveZoomRegions={projection.effectiveZoomRegions} + effectiveSpeedRegions={projection.effectiveSpeedRegions} + effectiveCursorTelemetry={effectiveCursorTelemetry} + effectiveShowCursor={effectiveShowCursor} + setDuration={setDuration} + setIsPreviewReady={setIsPreviewReady} + setCurrentTime={setCurrentTime} + setIsPlaying={setIsPlaying} + setError={setError} + handlers={{ + onSelectZoom: zoomCommands.handleSelectZoom, + onZoomFocusChange: zoomCommands.handleZoomFocusChange, + onEditAutoCaption: handleSaveAutoCaptionEdit, + onSelectAnnotation: handleSelectAnnotation, + onAnnotationPositionChange: + annotationCommands.handleAnnotationPositionChange, + onAnnotationSizeChange: + annotationCommands.handleAnnotationSizeChange, + }} + /> + </div> + </div> + </div> + </div> + </div> + + <div className="relative flex flex-shrink-0 items-center px-1 py-1"> + <div className="z-10 flex min-w-0 flex-1 items-center gap-1.5"> + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button + variant="ghost" + size="sm" + className="h-7 gap-1 rounded-full border border-foreground/[0.08] bg-foreground/[0.04] px-2.5 text-[11px] text-foreground/65 shadow-[inset_0_1px_0_hsl(var(--foreground)/0.06)] transition-all hover:bg-foreground/[0.08] hover:text-foreground" + > + <Plus className="h-3.5 w-3.5" /> + <span className="font-medium">{t("editor.toolbar.addLayer")}</span> + <CaretDown className="h-3 w-3" /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent + align="start" + className="border-foreground/10 bg-editor-surface-alt" + > + <DropdownMenuItem + onClick={() => { + const nextTrack = + timeline.annotationRegions.length > 0 + ? Math.max( + ...timeline.annotationRegions.map( + (region) => region.trackIndex ?? 0, + ), + ) + 1 + : 0; + timelineRef.current?.addAnnotation(nextTrack); + }} + className="cursor-pointer text-muted-foreground hover:bg-foreground/10 hover:text-foreground" + > + {t("timeline.annotation.label")} + </DropdownMenuItem> + <DropdownMenuItem + onClick={() => { + const nextTrack = + timeline.audioRegions.length > 0 + ? Math.max( + ...timeline.audioRegions.map( + (region) => region.trackIndex ?? 0, + ), + ) + 1 + : 0; + timelineRef.current?.addAudio(nextTrack); + }} + className="cursor-pointer text-muted-foreground hover:bg-foreground/10 hover:text-foreground" + > + {t("timeline.audio.label")} + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + <div className="mx-1 h-4 w-px bg-foreground/10" /> + <Button + onClick={() => timelineRef.current?.addZoom()} + variant="ghost" + size="icon" + className="h-7 w-7 rounded-full text-muted-foreground transition-all hover:bg-[#2563EB]/10 hover:text-[#2563EB]" + title={t("timeline.zoom.addZoom")} + > + <MagnifyingGlassPlus className="h-4 w-4" /> + </Button> + <Button + onClick={() => timelineRef.current?.suggestZooms()} + variant="ghost" + size="icon" + className="h-7 w-7 rounded-full text-muted-foreground transition-all hover:bg-[#2563EB]/10 hover:text-[#2563EB]" + title={t("timeline.zoom.suggestZooms")} + > + <MagicWand className="h-4 w-4" /> + </Button> + <Button + onClick={() => timelineRef.current?.splitClip()} + variant="ghost" + size="icon" + className="h-7 w-7 rounded-full text-muted-foreground transition-all hover:bg-foreground/10 hover:text-foreground" + title={t("editor.toolbar.splitClip")} + > + <Scissors className="h-4 w-4" /> + </Button> + </div> + + <div className="pointer-events-none absolute inset-0 z-10 flex items-center justify-center"> + <div className="pointer-events-auto flex items-center gap-1.5"> + <span className="mr-1 text-[10px] font-medium tabular-nums text-muted-foreground"> + {formatTime(projection.timelinePlayheadTime)} + </span> + <Button + variant="ghost" + size="icon" + className="h-7 w-7 rounded-full text-muted-foreground transition-all hover:bg-foreground/10 hover:text-foreground" + title={t("editor.playback.skipBack")} + onClick={playback.handlePreviewSkipBack} + > + <SkipBack className="h-3.5 w-3.5" weight="fill" /> + </Button> + <Button + variant="ghost" + size="icon" + className={`h-7 w-7 rounded-full border border-foreground/10 shadow-[0_8px_18px_rgba(0,0,0,0.18)] transition-all ${isPlaying ? "bg-foreground/10 text-foreground hover:bg-foreground/20" : "bg-neutral-800 text-white hover:bg-neutral-700 dark:bg-white dark:text-black dark:hover:bg-white/90"}`} + onClick={playback.togglePlayPause} + title={isPlaying ? "Pause" : "Play"} + > + {isPlaying ? ( + <Pause className="h-3.5 w-3.5" weight="fill" /> + ) : ( + <Play className="h-3.5 w-3.5" weight="fill" /> + )} + </Button> + <Button + variant="ghost" + size="icon" + className="h-7 w-7 rounded-full text-muted-foreground transition-all hover:bg-foreground/10 hover:text-foreground" + title={t("editor.playback.skipForward")} + onClick={playback.handlePreviewSkipForward} + > + <SkipForward className="h-3.5 w-3.5" weight="fill" /> + </Button> + <span className="ml-1 text-[10px] font-medium tabular-nums text-muted-foreground/70"> + {formatTime(projection.timelineDuration)} + </span> + </div> + </div> + + <div className="z-10 ml-auto flex items-center gap-2"> + <div className="flex items-center gap-1.5"> + <button + type="button" + className="text-muted-foreground transition-colors hover:text-foreground" + title={t("editor.playback.muteUnmute")} + onClick={() => setPreviewVolume(previewVolume <= 0.001 ? 1 : 0)} + > + {previewVolume <= 0.001 ? ( + <SpeakerX className="h-3.5 w-3.5" /> + ) : previewVolume < 0.5 ? ( + <SpeakerLow className="h-3.5 w-3.5" /> + ) : ( + <SpeakerHigh className="h-3.5 w-3.5" /> + )} + </button> + <div className="relative flex h-7 w-24 select-none items-center overflow-hidden rounded-full border border-foreground/[0.06] bg-editor-bg/80 shadow-[inset_0_1px_0_hsl(var(--foreground)/0.06)]"> + <div + className="absolute inset-y-[3px] left-[3px] right-auto rounded-[10px] bg-foreground/[0.08]" + style={{ + width: + previewVolume > 0 + ? `max(calc(${previewVolume * 100}% - 6px), 1.2rem)` + : 0, + }} + /> + <div + className="pointer-events-none absolute bottom-[18%] top-[18%] z-10 w-0.5 rounded-full bg-foreground/95 shadow-[0_0_10px_rgba(37,99,235,0.28)]" + style={{ left: `calc(${previewVolume * 100}% - 8px)` }} + /> + <span className="pointer-events-none relative z-10 pl-2 text-[10px] font-medium text-muted-foreground"> + {Math.round(previewVolume * 100)}% + </span> + <input + type="range" + aria-label={t("editor.playback.volume", "Preview volume")} + min="0" + max="1" + step="0.01" + value={previewVolume} + onChange={(event) => setPreviewVolume(Number(event.target.value))} + className="absolute inset-0 h-full w-full cursor-ew-resize opacity-0" + /> + </div> + </div> + </div> + </div> + </div> + ); +} diff --git a/src/components/video-editor/layout/EditorShell.tsx b/src/components/video-editor/layout/EditorShell.tsx new file mode 100644 index 000000000..e85928280 --- /dev/null +++ b/src/components/video-editor/layout/EditorShell.tsx @@ -0,0 +1,260 @@ +import type { ComponentProps } from "react"; +import { EditorAnnouncementBanner } from "@/components/announcements/EditorAnnouncementBanner"; +import { Toaster } from "@/components/ui/sonner"; +import type { useI18n } from "@/contexts/I18nContext"; +import type { useEditorExportController } from "../export/useEditorExportController"; +import type { useExportDimensions } from "../export/useExportDimensions"; +import type { useExportSession } from "../export/useExportSession"; +import type { useExportSettings } from "../export/useExportSettings"; +import type { useTimelineEditingController } from "../hooks/useTimelineEditingController"; +import type { useVideoEditorPresets } from "../presets/useVideoEditorPresets"; +import type { useEditorProjectController } from "../project/useEditorProjectController"; +import { SettingsPanel } from "../SettingsPanel"; +import type { useAppearanceState } from "../state/useAppearanceState"; +import type { useEditorUiState } from "../state/useEditorUiState"; +import type { useProjectState } from "../state/useProjectState"; +import type { useTimelineState } from "../state/useTimelineState"; +import { CropEditorDialog } from "./CropEditorDialog"; +import { EditorDialogs } from "./EditorDialogs"; +import { EditorHeader } from "./EditorHeader"; +import { EditorPreviewPanel } from "./EditorPreviewPanel"; +import { EditorSidebar } from "./EditorSidebar"; +import { EditorTimelinePanel } from "./EditorTimelinePanel"; + +type Props = { + t: ReturnType<typeof useI18n>["t"]; + project: ReturnType<typeof useProjectState>; + appearance: ReturnType<typeof useAppearanceState>; + timeline: ReturnType<typeof useTimelineState>; + ui: ReturnType<typeof useEditorUiState>; + presets: ReturnType<typeof useVideoEditorPresets>; + projectController: ReturnType<typeof useEditorProjectController>; + editing: ReturnType<typeof useTimelineEditingController>; + exportController: ReturnType<typeof useEditorExportController>; + exportSettings: ReturnType<typeof useExportSettings>; + exportSession: ReturnType<typeof useExportSession>; + exportDimensions: ReturnType<typeof useExportDimensions>; + settingsPanelProps: ComponentProps<typeof SettingsPanel>; + headerLeftControlsPaddingClass: string; + hasCaptionsForSidecar: boolean; + nvidiaCudaExportAvailable: boolean; + experimentalNvidiaCudaExport: boolean; + setExperimentalNvidiaCudaExport: (enabled: boolean) => void; + effectiveShowCursor: boolean; + previewAspectRatioValue: number; +}; + +export function EditorShell(props: Props) { + const { + t, + project, + appearance, + timeline, + ui, + presets, + projectController, + editing, + exportController, + exportSettings, + exportSession, + exportDimensions, + settingsPanelProps, + headerLeftControlsPaddingClass, + hasCaptionsForSidecar, + nvidiaCudaExportAvailable, + experimentalNvidiaCudaExport, + setExperimentalNvidiaCudaExport, + effectiveShowCursor, + previewAspectRatioValue, + } = props; + const { + snapshot, + history, + lifecycle, + autoCaption, + saveActions, + openActions, + hasUnsavedChanges, + } = projectController; + const { + cursor, + projection, + audio, + playback, + captionCommands, + zoomCommands, + clipCommands, + audioCommands, + annotationCommands, + handleSelectAnnotation, + handleAutoSuggestZoomsConsumed, + } = editing; + const { dialogActions, status: exportStatus, exportMessage } = exportController; + const editorDialogs = ( + <EditorDialogs + t={t} + projectSaveDialogOpen={project.projectSaveDialogOpen} + setProjectSaveDialogOpen={project.setProjectSaveDialogOpen} + projectSaveDialogDraft={project.projectSaveDialogDraft} + setProjectSaveDialogDraft={project.setProjectSaveDialogDraft} + projectSaveDialogInputRef={ui.projectSaveDialogInputRef} + isSavingProjectDialog={project.isSavingProjectDialog} + resolveProjectSaveDialog={lifecycle.resolveProjectSaveDialog} + handleProjectSaveDialogSubmit={saveActions.handleProjectSaveDialogSubmit} + unsavedChangesDialogOpen={project.unsavedChangesDialogOpen} + setUnsavedChangesDialogOpen={project.setUnsavedChangesDialogOpen} + unsavedChangesDialogActionLabel={project.unsavedChangesDialogActionLabel} + resolveUnsavedChangesDialog={lifecycle.resolveUnsavedChangesDialog} + projectBrowserOpen={project.projectBrowserOpen} + setProjectBrowserOpen={project.setProjectBrowserOpen} + projectLibraryEntries={project.projectLibraryEntries} + projectBrowserAnchorRef={ + project.error ? ui.projectBrowserFallbackTriggerRef : ui.projectBrowserTriggerRef + } + handleImportMediaOrProject={openActions.handleImportMediaOrProject} + handleOpenProjectFromLibrary={openActions.handleOpenProjectFromLibrary} + nativeCaptureUnavailableModalOpen={ui.nativeCaptureUnavailableModalOpen} + setNativeCaptureUnavailableModalOpen={ui.setNativeCaptureUnavailableModalOpen} + /> + ); + if (project.loading) + return ( + <div className="flex h-screen items-center justify-center bg-background"> + <div className="text-foreground">Loading video...</div> + {editorDialogs} + <Toaster className="pointer-events-auto" /> + </div> + ); + if (project.error) + return ( + <div className="flex h-screen items-center justify-center bg-background"> + <div className="flex flex-col items-center gap-3"> + <div className="text-destructive">{project.error}</div> + <button + ref={ui.projectBrowserFallbackTriggerRef} + type="button" + onClick={openActions.handleOpenProjectBrowser} + className="rounded-[5px] bg-neutral-800 px-3 py-1.5 text-sm font-semibold text-white shadow-[0_14px_32px_rgba(0,0,0,0.18)] transition-colors hover:bg-neutral-700 dark:bg-white dark:text-black dark:hover:bg-white/90" + > + Open Projects + </button> + </div> + {editorDialogs} + <Toaster className="pointer-events-auto" /> + </div> + ); + + return ( + <div className="flex h-screen flex-col overflow-hidden bg-editor-bg text-foreground selection:bg-[#2563EB]/30"> + <EditorHeader + t={t} + headerLeftControlsPaddingClass={headerLeftControlsPaddingClass} + project={project} + projectBrowserTriggerRef={ui.projectBrowserTriggerRef} + projectNameInputRef={ui.projectNameInputRef} + projectDisplayName={snapshot.projectDisplayName} + hasUnsavedChanges={hasUnsavedChanges} + canUndo={history.canUndo} + canRedo={history.canRedo} + handleOpenProjectBrowser={openActions.handleOpenProjectBrowser} + handleUndo={history.handleUndo} + handleRedo={history.handleRedo} + handleProjectNameSubmit={saveActions.handleProjectNameSubmit} + closeProjectNameEditor={saveActions.closeProjectNameEditor} + presets={presets} + exportSettings={exportSettings} + exportSession={exportSession} + exportDimensions={exportDimensions} + exportStatus={exportStatus} + hasCaptionsForSidecar={hasCaptionsForSidecar} + nvidiaCudaExportAvailable={nvidiaCudaExportAvailable} + experimentalNvidiaCudaExport={experimentalNvidiaCudaExport} + setExperimentalNvidiaCudaExport={setExperimentalNvidiaCudaExport} + handleOpenExportDropdown={dialogActions.handleOpenExportDropdown} + handleExportDropdownClose={dialogActions.handleExportDropdownClose} + handleCancelExport={dialogActions.handleCancelExport} + handleRetrySaveExport={dialogActions.handleRetrySaveExport} + handleStartExportFromDropdown={dialogActions.handleStartExportFromDropdown} + revealExportedFile={dialogActions.revealExportedFile} + exportMessage={exportMessage} + /> + <EditorAnnouncementBanner /> + <div className="relative flex min-h-0 flex-1 flex-col gap-3 p-4"> + <div className="relative z-10 flex min-h-0 flex-1 gap-3"> + <EditorSidebar + t={t} + activeSection={ui.activeEffectSection} + setActiveSection={ui.setActiveEffectSection} + settingsPanelProps={settingsPanelProps} + /> + <EditorPreviewPanel + t={t} + videoPath={project.videoPath} + previewVersion={ui.previewVersion} + aspectRatio={ui.aspectRatio} + setAspectRatio={ui.setAspectRatio} + previewAspectRatioValue={previewAspectRatioValue} + videoPlaybackRef={ui.videoPlaybackRef} + timelineRef={ui.timelineRef} + currentTime={ui.currentTime} + isPlaying={ui.isPlaying} + previewVolume={ui.previewVolume} + setPreviewVolume={ui.setPreviewVolume} + suspendRendering={exportStatus.shouldSuspendPreviewRendering} + appearance={appearance} + timeline={timeline} + audio={audio} + projection={projection} + playback={playback} + zoomCommands={zoomCommands} + annotationCommands={annotationCommands} + effectiveCursorTelemetry={cursor.effectiveCursorTelemetry} + effectiveShowCursor={effectiveShowCursor} + isCropped={ui.isCropped} + handleOpenCropEditor={ui.handleOpenCropEditor} + handleSaveAutoCaptionEdit={autoCaption.handleSaveAutoCaptionEdit} + handleSelectAnnotation={handleSelectAnnotation} + setDuration={ui.setDuration} + setIsPreviewReady={ui.setIsPreviewReady} + setCurrentTime={ui.setCurrentTime} + setIsPlaying={ui.setIsPlaying} + setError={project.setError} + /> + </div> + <EditorTimelinePanel + timelineRef={ui.timelineRef} + timeline={timeline} + projection={projection} + playback={playback} + audio={audio} + zoomCommands={zoomCommands} + clipCommands={clipCommands} + audioCommands={audioCommands} + captionCommands={captionCommands} + annotationCommands={annotationCommands} + videoPath={project.videoPath} + videoSourcePath={project.videoSourcePath} + cursorTelemetrySourcePath={timeline.cursorTelemetrySourcePath} + normalizedCursorTelemetry={cursor.normalizedCursorTelemetry} + autoSuggestZoomsTrigger={ui.autoSuggestZoomsTrigger} + handleAutoSuggestZoomsConsumed={handleAutoSuggestZoomsConsumed} + disableSuggestedZooms={!appearance.autoApplyFreshRecordingAutoZooms} + currentTime={ui.currentTime} + handleSelectAnnotation={handleSelectAnnotation} + /> + </div> + {editorDialogs} + <CropEditorDialog + open={ui.showCropModal} + t={t} + videoElement={ui.videoPlaybackRef.current?.video ?? null} + cropRegion={appearance.cropRegion} + setCropRegion={appearance.setCropRegion} + aspectRatio={ui.aspectRatio} + onCancel={ui.handleCancelCropEditor} + onDone={ui.handleCloseCropEditor} + /> + <Toaster className="pointer-events-auto" /> + </div> + ); +} diff --git a/src/components/video-editor/layout/EditorSidebar.tsx b/src/components/video-editor/layout/EditorSidebar.tsx new file mode 100644 index 000000000..d73720b68 --- /dev/null +++ b/src/components/video-editor/layout/EditorSidebar.tsx @@ -0,0 +1,123 @@ +import { + Camera, + ClosedCaptioning, + Cursor, + Gear, + PuzzlePiece, + Sparkle, + UserCircle, +} from "@phosphor-icons/react"; +import { motion } from "motion/react"; +import type { ComponentProps, Dispatch, SetStateAction } from "react"; +import { useMemo } from "react"; +import { toast } from "sonner"; +import type { useI18n } from "@/contexts/I18nContext"; +import ExtensionManager from "../ExtensionManager"; +import { SettingsPanel } from "../SettingsPanel"; +import type { EditorEffectSection } from "../types"; + +type Props = { + t: ReturnType<typeof useI18n>["t"]; + activeSection: EditorEffectSection; + setActiveSection: Dispatch<SetStateAction<EditorEffectSection>>; + settingsPanelProps: ComponentProps<typeof SettingsPanel>; +}; + +export function EditorSidebar({ t, activeSection, setActiveSection, settingsPanelProps }: Props) { + const sections = useMemo( + () => [ + { id: "scene" as const, label: t("settings.sections.scene", "Scene"), icon: Sparkle }, + { id: "cursor" as const, label: t("settings.sections.cursor", "Cursor"), icon: Cursor }, + { id: "webcam" as const, label: t("settings.sections.webcam", "Webcam"), icon: Camera }, + { + id: "captions" as const, + label: t("settings.sections.captions", "Captions"), + icon: ClosedCaptioning, + }, + { + id: "settings" as const, + label: t("settings.sections.settings", "Settings"), + icon: Gear, + }, + { + id: "extensions" as const, + label: t("settings.sections.extensions", "Extensions"), + icon: PuzzlePiece, + }, + ], + [t], + ); + return ( + <div className="flex flex-shrink-0 gap-1.5"> + <div className="flex flex-shrink-0 flex-col items-center gap-0.5 px-2 py-2"> + {sections.map((section) => { + const isActive = activeSection === section.id; + return ( + <div key={section.id} className="flex items-center"> + <motion.button + type="button" + onClick={() => setActiveSection(section.id)} + title={section.label} + className="group relative flex h-9 w-9 items-center justify-center rounded-lg outline-none focus:outline-none focus-visible:outline-none" + animate={{ opacity: isActive ? 1 : 0.55 }} + transition={{ duration: 0.14 }} + > + {isActive ? ( + <motion.span + layoutId="rail-active-bg" + className="absolute inset-0 rounded-lg bg-foreground/[0.08]" + transition={{ type: "spring", stiffness: 450, damping: 35 }} + /> + ) : null} + <motion.span + className="relative z-10" + animate={{ + color: isActive ? "#2563EB" : "hsl(var(--foreground))", + }} + transition={{ duration: 0.14 }} + > + <section.icon + className="h-[27px] w-[27px]" + weight={isActive ? "fill" : "regular"} + /> + </motion.span> + </motion.button> + <div className="ml-1.5 h-1.5 w-1.5 flex-shrink-0"> + {isActive ? ( + <motion.span + layoutId="rail-active-dot" + className="block h-1.5 w-1.5 rounded-full bg-[#2563EB]" + initial={{ opacity: 0, scale: 0.5 }} + animate={{ opacity: 1, scale: 1 }} + exit={{ opacity: 0, scale: 0.5 }} + transition={{ type: "spring", stiffness: 500, damping: 32 }} + /> + ) : null} + </div> + </div> + ); + })} + <div className="mt-auto flex flex-col items-center gap-0.5 pt-3"> + <motion.button + type="button" + onClick={() => + toast.info(t("editor.account.comingSoon", "Account coming soon")) + } + title={t("editor.account.title", "Account")} + className="group relative flex h-9 w-9 items-center justify-center rounded-lg text-foreground/55 outline-none transition hover:text-foreground focus:outline-none focus-visible:outline-none" + whileHover={{ opacity: 1 }} + initial={{ opacity: 0.55 }} + > + <motion.span className="absolute inset-0 rounded-lg bg-foreground/[0.04] opacity-0 transition group-hover:opacity-100" /> + <UserCircle className="relative z-10 h-[22px] w-[22px]" /> + </motion.button> + </div> + </div> + {activeSection === "extensions" ? ( + <ExtensionManager /> + ) : ( + <SettingsPanel {...settingsPanelProps} /> + )} + </div> + ); +} diff --git a/src/components/video-editor/layout/EditorTimelinePanel.tsx b/src/components/video-editor/layout/EditorTimelinePanel.tsx new file mode 100644 index 000000000..78bfca53e --- /dev/null +++ b/src/components/video-editor/layout/EditorTimelinePanel.tsx @@ -0,0 +1,120 @@ +import type { RefObject } from "react"; +import type { useVideoEditorAudio } from "../audio/useVideoEditorAudio"; +import type { useAnnotationRegionCommands } from "../hooks/useAnnotationRegionCommands"; +import type { useAudioRegionCommands } from "../hooks/useAudioRegionCommands"; +import type { useCaptionCommands } from "../hooks/useCaptionCommands"; +import type { useClipRegionCommands } from "../hooks/useClipRegionCommands"; +import type { useEditorPlaybackControls } from "../hooks/useEditorPlaybackControls"; +import type { useTimelineProjection } from "../hooks/useTimelineProjection"; +import type { useZoomRegionCommands } from "../hooks/useZoomRegionCommands"; +import type { useTimelineState } from "../state/useTimelineState"; +import TimelineEditor, { type TimelineEditorHandle } from "../timeline/TimelineEditor"; + +type Props = { + timelineRef: RefObject<TimelineEditorHandle>; + timeline: ReturnType<typeof useTimelineState>; + projection: ReturnType<typeof useTimelineProjection>; + playback: ReturnType<typeof useEditorPlaybackControls>; + audio: ReturnType<typeof useVideoEditorAudio>; + zoomCommands: ReturnType<typeof useZoomRegionCommands>; + clipCommands: ReturnType<typeof useClipRegionCommands>; + audioCommands: ReturnType<typeof useAudioRegionCommands>; + captionCommands: ReturnType<typeof useCaptionCommands>; + annotationCommands: ReturnType<typeof useAnnotationRegionCommands>; + videoPath: string | null; + videoSourcePath: string | null; + cursorTelemetrySourcePath: string | null; + normalizedCursorTelemetry: ReturnType<typeof useTimelineState>["cursorTelemetry"]; + autoSuggestZoomsTrigger: number; + handleAutoSuggestZoomsConsumed: () => void; + disableSuggestedZooms: boolean; + currentTime: number; + handleSelectAnnotation: (id: string | null) => void; +}; + +export function EditorTimelinePanel(props: Props) { + const { + timelineRef, + timeline, + projection, + playback, + audio, + zoomCommands, + clipCommands, + audioCommands, + captionCommands, + annotationCommands, + videoPath, + videoSourcePath, + cursorTelemetrySourcePath, + normalizedCursorTelemetry, + autoSuggestZoomsTrigger, + handleAutoSuggestZoomsConsumed, + disableSuggestedZooms, + currentTime, + handleSelectAnnotation, + } = props; + + return ( + <div className="flex flex-shrink-0 flex-col" style={{ height: "15%", minHeight: 160 }}> + <TimelineEditor + ref={timelineRef} + videoDuration={projection.timelineDuration} + currentTime={currentTime} + playheadTime={projection.timelinePlayheadTime} + onSeek={playback.handleTimelineSeek} + videoPath={videoPath} + videoSourcePath={videoSourcePath} + cursorTelemetrySourcePath={cursorTelemetrySourcePath} + cursorTelemetry={normalizedCursorTelemetry} + autoSuggestZoomsTrigger={autoSuggestZoomsTrigger} + onAutoSuggestZoomsConsumed={handleAutoSuggestZoomsConsumed} + disableSuggestedZooms={disableSuggestedZooms} + zoomRegions={timeline.zoomRegions} + onZoomAdded={zoomCommands.handleZoomAdded} + onZoomSuggested={zoomCommands.handleZoomSuggested} + onZoomSpanChange={zoomCommands.handleZoomSpanChange} + onZoomDelete={zoomCommands.handleZoomDelete} + selectedZoomId={timeline.selectedZoomId} + onSelectZoom={zoomCommands.handleSelectZoom} + trimRegions={timeline.trimRegions} + clipRegions={timeline.clipRegions} + onClipSplit={clipCommands.handleClipSplit} + onClipSpanChange={clipCommands.handleClipSpanChange} + selectedClipId={timeline.selectedClipId} + onSelectClip={clipCommands.handleSelectClip} + audioRegions={timeline.audioRegions} + onAudioAdded={audioCommands.handleAudioAdded} + onAudioSpanChange={audioCommands.handleAudioSpanChange} + onAudioDelete={audioCommands.handleAudioDelete} + selectedAudioId={timeline.selectedAudioId} + onSelectAudio={audioCommands.handleSelectAudio} + captionRegions={projection.effectiveCaptionRegions} + onCaptionSpanChange={(id, span) => + captionCommands.handleCaptionRetime(id, { + startMs: projection.mapTimelineTimeToSourceTime(span.start), + endMs: projection.mapTimelineTimeToSourceTime(span.end), + }) + } + selectedCaptionId={timeline.selectedCaptionId} + onSelectCaption={captionCommands.handleSelectCaption} + onCaptionDelete={captionCommands.handleCaptionDelete} + onCaptionAdded={captionCommands.handleCaptionAdded} + captionsEnabled={timeline.autoCaptionSettings.enabled} + captionQuickAddEnabled={timeline.autoCaptionSettings.timelineQuickAdd} + annotationRegions={timeline.annotationRegions} + onAnnotationAdded={annotationCommands.handleAnnotationAdded} + onAnnotationSpanChange={annotationCommands.handleAnnotationSpanChange} + onAnnotationDelete={annotationCommands.handleAnnotationDelete} + selectedAnnotationId={timeline.selectedAnnotationId} + onSelectAnnotation={handleSelectAnnotation} + showSourceAudioTrack={timeline.clipRegions.some((clip) => clip.showSourceAudio)} + sourceAudioResourceVersion={timeline.sourceAudioFallbackRefreshKey} + sourceAudioTrackSettings={audio.activeSourceAudioTrackSettings} + getSourceAudioTrackSettingsForClip={audio.getSourceAudioTrackSettingsForClip} + onSourceAudioAvailabilityChange={timeline.setHasClipSourceAudio} + onSourceAudioTracksMetaChange={audio.onSourceAudioTracksMetaChange} + /> + </div> + ); +} diff --git a/src/components/video-editor/layout/EditorVideoPreview.tsx b/src/components/video-editor/layout/EditorVideoPreview.tsx new file mode 100644 index 000000000..caa665624 --- /dev/null +++ b/src/components/video-editor/layout/EditorVideoPreview.tsx @@ -0,0 +1,141 @@ +import type { ComponentProps, Dispatch, RefObject, SetStateAction } from "react"; +import type { AspectRatio } from "@/utils/aspectRatioUtils"; +import type { useVideoEditorAudio } from "../audio/useVideoEditorAudio"; +import type { useAppearanceState } from "../state/useAppearanceState"; +import type { useTimelineState } from "../state/useTimelineState"; +import type { CursorTelemetryPoint, SpeedRegion, ZoomRegion } from "../types"; +import VideoPlayback, { type VideoPlaybackRef } from "../VideoPlayback"; + +type PlaybackProps = ComponentProps<typeof VideoPlayback>; +type Handlers = Pick< + PlaybackProps, + | "onSelectZoom" + | "onZoomFocusChange" + | "onEditAutoCaption" + | "onSelectAnnotation" + | "onAnnotationPositionChange" + | "onAnnotationSizeChange" +>; + +type Props = { + videoPath: string | null; + previewVersion: number; + aspectRatio: AspectRatio; + playbackRef: RefObject<VideoPlaybackRef>; + currentTime: number; + isPlaying: boolean; + previewVolume: number; + suspendRendering: boolean; + appearance: ReturnType<typeof useAppearanceState>; + timeline: ReturnType<typeof useTimelineState>; + audio: ReturnType<typeof useVideoEditorAudio>; + effectiveZoomRegions: ZoomRegion[]; + effectiveSpeedRegions: SpeedRegion[]; + effectiveCursorTelemetry: CursorTelemetryPoint[]; + effectiveShowCursor: boolean; + setDuration: Dispatch<SetStateAction<number>>; + setIsPreviewReady: Dispatch<SetStateAction<boolean>>; + setCurrentTime: Dispatch<SetStateAction<number>>; + setIsPlaying: Dispatch<SetStateAction<boolean>>; + setError: Dispatch<SetStateAction<string | null>>; + handlers: Handlers; +}; + +export function EditorVideoPreview({ + videoPath, + previewVersion, + aspectRatio, + playbackRef, + currentTime, + isPlaying, + previewVolume, + suspendRendering, + appearance, + timeline, + audio, + effectiveZoomRegions, + effectiveSpeedRegions, + effectiveCursorTelemetry, + effectiveShowCursor, + setDuration, + setIsPreviewReady, + setCurrentTime, + setIsPlaying, + setError, + handlers, +}: Props) { + return ( + <VideoPlayback + key={`${videoPath || "no-video"}:${previewVersion}:inline`} + aspectRatio={aspectRatio} + ref={playbackRef} + videoPath={videoPath || ""} + onDurationChange={setDuration} + onPreviewReadyChange={setIsPreviewReady} + onTimeUpdate={setCurrentTime} + currentTime={currentTime} + onPlayStateChange={setIsPlaying} + onError={setError} + wallpaper={appearance.wallpaper} + zoomRegions={effectiveZoomRegions} + selectedZoomId={timeline.selectedZoomId} + isPlaying={isPlaying} + showShadow={appearance.shadowIntensity > 0} + shadowIntensity={appearance.shadowIntensity} + backgroundBlur={appearance.backgroundBlur} + connectZooms={appearance.connectZooms} + zoomInDurationMs={appearance.zoomInDurationMs} + zoomInOverlapMs={appearance.zoomInOverlapMs} + zoomOutDurationMs={appearance.zoomOutDurationMs} + connectedZoomGapMs={appearance.connectedZoomGapMs} + connectedZoomDurationMs={appearance.connectedZoomDurationMs} + zoomInEasing={appearance.zoomInEasing} + zoomOutEasing={appearance.zoomOutEasing} + connectedZoomEasing={appearance.connectedZoomEasing} + borderRadius={appearance.borderRadius} + padding={appearance.padding} + cropRegion={appearance.cropRegion} + webcam={appearance.webcam} + webcamVideoPath={ + appearance.webcam.sourcePath ? appearance.resolvedWebcamVideoUrl : null + } + trimRegions={timeline.trimRegions} + speedRegions={effectiveSpeedRegions} + annotationRegions={timeline.annotationRegions} + autoCaptions={timeline.autoCaptions} + autoCaptionSettings={timeline.autoCaptionSettings} + selectedAnnotationId={timeline.selectedAnnotationId} + cursorTelemetry={effectiveCursorTelemetry} + showCursor={effectiveShowCursor} + cursorStyle={appearance.cursorStyle} + cursorSize={appearance.cursorSize} + cursorSmoothing={appearance.cursorSmoothing} + cursorSpringStiffnessMultiplier={appearance.cursorSpringStiffnessMultiplier} + cursorSpringDampingMultiplier={appearance.cursorSpringDampingMultiplier} + cursorSpringMassMultiplier={appearance.cursorSpringMassMultiplier} + cameraSpringStiffnessMultiplier={appearance.cameraSpringStiffnessMultiplier} + cameraSpringDampingMultiplier={appearance.cameraSpringDampingMultiplier} + cameraSpringMassMultiplier={appearance.cameraSpringMassMultiplier} + zoomSmoothness={appearance.zoomSmoothness} + zoomClassicMode={appearance.zoomClassicMode} + zoomMotionBlur={appearance.zoomMotionBlur} + zoomMotionBlurTuning={appearance.zoomMotionBlurTuning} + cursorMotionBlur={appearance.cursorMotionBlur} + cursorClickEffect={appearance.cursorClickEffect} + cursorClickEffectColor={appearance.cursorClickEffectColor} + cursorClickEffectScale={appearance.cursorClickEffectScale} + cursorClickEffectOpacity={appearance.cursorClickEffectOpacity} + cursorClickEffectDurationMs={appearance.cursorClickEffectDurationMs} + cursorClickBounce={appearance.cursorClickBounce} + cursorClickBounceDuration={appearance.cursorClickBounceDuration} + cursorSway={appearance.cursorSway} + volume={ + audio.shouldMutePreviewVideo || audio.isCurrentClipMuted + ? 0 + : Math.max(0, Math.min(1, previewVolume * audio.embeddedSourcePreviewGain)) + } + suspendRendering={suspendRendering} + {...handlers} + /> + ); +} diff --git a/src/components/video-editor/layout/useEditorSettingsPanelProps.ts b/src/components/video-editor/layout/useEditorSettingsPanelProps.ts new file mode 100644 index 000000000..fc7427c26 --- /dev/null +++ b/src/components/video-editor/layout/useEditorSettingsPanelProps.ts @@ -0,0 +1,228 @@ +import type { ComponentProps, Dispatch, SetStateAction } from "react"; +import type { AspectRatio } from "@/utils/aspectRatioUtils"; +import type { useVideoEditorAudio } from "../audio/useVideoEditorAudio"; +import type { useAutoCaptionController } from "../captions/useAutoCaptionController"; +import type { useAnnotationRegionCommands } from "../hooks/useAnnotationRegionCommands"; +import type { useAudioRegionCommands } from "../hooks/useAudioRegionCommands"; +import type { useCaptionCommands } from "../hooks/useCaptionCommands"; +import type { useClipRegionCommands } from "../hooks/useClipRegionCommands"; +import type { useZoomRegionCommands } from "../hooks/useZoomRegionCommands"; +import { SettingsPanel } from "../SettingsPanel"; +import type { useAppearanceState } from "../state/useAppearanceState"; +import type { useTimelineState } from "../state/useTimelineState"; +import type { EditorEffectSection } from "../types"; + +type Input = { + activeEffectSection: EditorEffectSection; + appearance: ReturnType<typeof useAppearanceState>; + timeline: ReturnType<typeof useTimelineState>; + audio: ReturnType<typeof useVideoEditorAudio>; + zoomCommands: ReturnType<typeof useZoomRegionCommands>; + clipCommands: ReturnType<typeof useClipRegionCommands>; + audioCommands: ReturnType<typeof useAudioRegionCommands>; + captionCommands: ReturnType<typeof useCaptionCommands>; + annotationCommands: ReturnType<typeof useAnnotationRegionCommands>; + autoCaptionController: ReturnType<typeof useAutoCaptionController>; + effectiveShowCursor: boolean; + handleShowCursorChange: (show: boolean) => void; + currentTime: number; + isPlaying: boolean; + aspectRatio: AspectRatio; + setAspectRatio: Dispatch<SetStateAction<AspectRatio>>; + whisperExecutablePath: string | null; + whisperModelPath: string | null; + whisperModelDownloadStatus: "idle" | "downloading" | "downloaded" | "error"; + whisperModelDownloadProgress: number; + isGeneratingCaptions: boolean; + sessionNativeCaptureUnavailable: boolean; + setNativeCaptureUnavailableModalOpen: Dispatch<SetStateAction<boolean>>; + handleUploadWebcam: () => void; + handleClearWebcam: () => void; +}; + +export function useEditorSettingsPanelProps(input: Input): ComponentProps<typeof SettingsPanel> { + const { + activeEffectSection, + appearance, + timeline, + audio, + zoomCommands, + clipCommands, + audioCommands, + captionCommands, + annotationCommands, + autoCaptionController, + effectiveShowCursor, + handleShowCursorChange, + currentTime, + isPlaying, + aspectRatio, + setAspectRatio, + whisperExecutablePath, + whisperModelPath, + whisperModelDownloadStatus, + whisperModelDownloadProgress, + isGeneratingCaptions, + sessionNativeCaptureUnavailable, + setNativeCaptureUnavailableModalOpen, + handleUploadWebcam, + handleClearWebcam, + } = input; + const selectedZoom = timeline.zoomRegions.find( + (region) => region.id === timeline.selectedZoomId, + ); + const selectedClip = timeline.clipRegions.find( + (region) => region.id === timeline.selectedClipId, + ); + const selectedAudio = timeline.audioRegions.find( + (region) => region.id === timeline.selectedAudioId, + ); + + return { + panelMode: "editor", + activeEffectSection, + selected: appearance.wallpaper, + onWallpaperChange: appearance.setWallpaper, + selectedZoomDepth: selectedZoom?.depth ?? null, + onZoomDepthChange: (depth) => + timeline.selectedZoomId && zoomCommands.handleZoomDepthChange(depth), + selectedZoomId: timeline.selectedZoomId, + selectedZoomMode: selectedZoom?.mode ?? (timeline.selectedZoomId ? "auto" : null), + onZoomModeChange: (mode) => + timeline.selectedZoomId && zoomCommands.handleZoomModeChange(mode), + onZoomDelete: zoomCommands.handleZoomDelete, + selectedClipId: timeline.selectedClipId, + selectedClipSpeed: selectedClip?.speed ?? (timeline.selectedClipId ? 1 : null), + selectedClipMuted: selectedClip?.muted ?? (timeline.selectedClipId ? false : null), + selectedClipShowSourceAudio: + selectedClip?.showSourceAudio ?? (timeline.selectedClipId ? false : null), + onClipSpeedChange: clipCommands.handleClipSpeedChange, + onClipMutedChange: clipCommands.handleClipMutedChange, + onClipShowSourceAudioChange: clipCommands.handleClipShowSourceAudioChange, + onClipDelete: clipCommands.handleClipDelete, + hasClipSourceAudio: timeline.hasClipSourceAudio, + sourceAudioTrackMeta: audio.sourceAudioTrackMeta, + sourceAudioTrackSettings: audio.selectedClipSourceAudioTrackSettings, + onSourceAudioTrackVolumeChange: audio.onSelectedClipSourceAudioTrackVolumeChange, + onSourceAudioTrackNormalizeChange: audio.onSelectedClipSourceAudioTrackNormalizeChange, + selectedAudioId: timeline.selectedAudioId, + selectedAudioVolume: selectedAudio?.volume ?? null, + selectedAudioNormalize: + selectedAudio?.normalize ?? (timeline.selectedAudioId ? false : null), + onAudioVolumeChange: audioCommands.handleAudioVolumeChange, + onAudioNormalizeChange: audioCommands.handleAudioNormalizeChange, + onAudioDelete: audioCommands.handleAudioDelete, + shadowIntensity: appearance.shadowIntensity, + onShadowChange: appearance.setShadowIntensity, + backgroundBlur: appearance.backgroundBlur, + onBackgroundBlurChange: appearance.setBackgroundBlur, + autoApplyFreshRecordingAutoZooms: appearance.autoApplyFreshRecordingAutoZooms, + onAutoApplyFreshRecordingAutoZoomsChange: appearance.setAutoApplyFreshRecordingAutoZooms, + connectZooms: appearance.connectZooms, + onConnectZoomsChange: appearance.setConnectZooms, + zoomInDurationMs: appearance.zoomInDurationMs, + onZoomInDurationMsChange: appearance.setZoomInDurationMs, + zoomInOverlapMs: appearance.zoomInOverlapMs, + onZoomInOverlapMsChange: appearance.setZoomInOverlapMs, + zoomOutDurationMs: appearance.zoomOutDurationMs, + onZoomOutDurationMsChange: appearance.setZoomOutDurationMs, + connectedZoomGapMs: appearance.connectedZoomGapMs, + onConnectedZoomGapMsChange: appearance.setConnectedZoomGapMs, + connectedZoomDurationMs: appearance.connectedZoomDurationMs, + onConnectedZoomDurationMsChange: appearance.setConnectedZoomDurationMs, + zoomInEasing: appearance.zoomInEasing, + onZoomInEasingChange: appearance.setZoomInEasing, + zoomOutEasing: appearance.zoomOutEasing, + onZoomOutEasingChange: appearance.setZoomOutEasing, + connectedZoomEasing: appearance.connectedZoomEasing, + onConnectedZoomEasingChange: appearance.setConnectedZoomEasing, + showCursor: effectiveShowCursor, + onShowCursorChange: handleShowCursorChange, + loopCursor: appearance.loopCursor, + onLoopCursorChange: appearance.setLoopCursor, + cursorStyle: appearance.cursorStyle, + onCursorStyleChange: appearance.setCursorStyle, + cursorSize: appearance.cursorSize, + onCursorSizeChange: appearance.setCursorSize, + cursorSmoothing: appearance.cursorSmoothing, + onCursorSmoothingChange: appearance.setCursorSmoothing, + cursorSpringStiffnessMultiplier: appearance.cursorSpringStiffnessMultiplier, + onCursorSpringStiffnessMultiplierChange: appearance.setCursorSpringStiffnessMultiplier, + cursorSpringDampingMultiplier: appearance.cursorSpringDampingMultiplier, + onCursorSpringDampingMultiplierChange: appearance.setCursorSpringDampingMultiplier, + cursorSpringMassMultiplier: appearance.cursorSpringMassMultiplier, + onCursorSpringMassMultiplierChange: appearance.setCursorSpringMassMultiplier, + cameraSpringStiffnessMultiplier: appearance.cameraSpringStiffnessMultiplier, + onCameraSpringStiffnessMultiplierChange: appearance.setCameraSpringStiffnessMultiplier, + cameraSpringDampingMultiplier: appearance.cameraSpringDampingMultiplier, + onCameraSpringDampingMultiplierChange: appearance.setCameraSpringDampingMultiplier, + cameraSpringMassMultiplier: appearance.cameraSpringMassMultiplier, + onCameraSpringMassMultiplierChange: appearance.setCameraSpringMassMultiplier, + zoomClassicMode: appearance.zoomClassicMode, + onZoomClassicModeChange: appearance.setZoomClassicMode, + cursorClickEffect: appearance.cursorClickEffect, + cursorClickEffectColor: appearance.cursorClickEffectColor, + onCursorClickEffectChange: appearance.setCursorClickEffect, + onCursorClickEffectColorChange: appearance.setCursorClickEffectColor, + cursorClickEffectScale: appearance.cursorClickEffectScale, + onCursorClickEffectScaleChange: appearance.setCursorClickEffectScale, + cursorClickEffectOpacity: appearance.cursorClickEffectOpacity, + onCursorClickEffectOpacityChange: appearance.setCursorClickEffectOpacity, + cursorClickEffectDurationMs: appearance.cursorClickEffectDurationMs, + onCursorClickEffectDurationMsChange: appearance.setCursorClickEffectDurationMs, + cursorClickBounce: appearance.cursorClickBounce, + onCursorClickBounceChange: appearance.setCursorClickBounce, + cursorClickBounceDuration: appearance.cursorClickBounceDuration, + onCursorClickBounceDurationChange: appearance.setCursorClickBounceDuration, + cursorSway: appearance.cursorSway, + onCursorSwayChange: appearance.setCursorSway, + borderRadius: appearance.borderRadius, + onBorderRadiusChange: appearance.setBorderRadius, + webcam: appearance.webcam, + webcamPreviewSrc: appearance.webcam.sourcePath ? appearance.resolvedWebcamVideoUrl : null, + webcamPreviewCurrentTime: currentTime, + webcamPreviewPlaying: isPlaying, + onWebcamChange: appearance.setWebcam, + onUploadWebcam: handleUploadWebcam, + onClearWebcam: handleClearWebcam, + padding: appearance.padding, + onPaddingChange: appearance.setPadding, + cropRegion: appearance.cropRegion, + onCropChange: appearance.setCropRegion, + aspectRatio, + onAspectRatioChange: setAspectRatio, + selectedAnnotationId: timeline.selectedAnnotationId, + annotationRegions: timeline.annotationRegions, + autoCaptions: timeline.autoCaptions, + autoCaptionSettings: timeline.autoCaptionSettings, + whisperExecutablePath, + whisperModelPath, + whisperModelDownloadStatus, + whisperModelDownloadProgress, + isGeneratingCaptions, + onAutoCaptionSettingsChange: timeline.setAutoCaptionSettings, + onPickWhisperExecutable: autoCaptionController.handlePickWhisperExecutable, + onPickWhisperModel: autoCaptionController.handlePickWhisperModel, + onGenerateAutoCaptions: autoCaptionController.handleGenerateAutoCaptions, + onClearAutoCaptions: captionCommands.handleClearAutoCaptions, + captionCurrentTimeMs: Math.round(currentTime * 1000), + selectedCaptionId: timeline.selectedCaptionId, + onBeginCaptionEdit: captionCommands.handleBeginCaptionEdit, + onCaptionTextEdit: captionCommands.handleCaptionTextEdit, + onCaptionRetime: captionCommands.handleCaptionRetime, + onCaptionSplit: captionCommands.handleCaptionSplit, + onCaptionMerge: captionCommands.handleCaptionMerge, + onCaptionDelete: captionCommands.handleCaptionDelete, + onDownloadWhisperSmallModel: autoCaptionController.handleDownloadWhisperSmallModel, + onDeleteWhisperSmallModel: autoCaptionController.handleDeleteWhisperSmallModel, + nativeCaptureUnavailableSession: sessionNativeCaptureUnavailable, + onOpenNativeCaptureUnavailableModal: () => setNativeCaptureUnavailableModalOpen(true), + onAnnotationContentChange: annotationCommands.handleAnnotationContentChange, + onAnnotationTypeChange: annotationCommands.handleAnnotationTypeChange, + onAnnotationStyleChange: annotationCommands.handleAnnotationStyleChange, + onAnnotationFigureDataChange: annotationCommands.handleAnnotationFigureDataChange, + onAnnotationBlurIntensityChange: annotationCommands.handleAnnotationBlurIntensityChange, + onAnnotationBlurColorChange: annotationCommands.handleAnnotationBlurColorChange, + onAnnotationDelete: annotationCommands.handleAnnotationDelete, + }; +} diff --git a/src/components/video-editor/mp4ExportRouting.test.ts b/src/components/video-editor/mp4ExportRouting.test.ts new file mode 100644 index 000000000..72f943c9e --- /dev/null +++ b/src/components/video-editor/mp4ExportRouting.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from "vitest"; + +import { resolveMp4ExportRouting } from "./mp4ExportRouting"; + +const baseOptions = { + smokeExportConfig: { + enabled: false, + useNativeExport: false, + }, + settings: {}, + exportPipelineModel: "modern" as const, + exportBackendPreference: "breeze" as const, + experimentalNvidiaCudaExport: false, + nvidiaCudaExportAvailable: false, +}; + +describe("resolveMp4ExportRouting", () => { + it("uses the modern native auto route for normal MP4 exports by default", () => { + expect(resolveMp4ExportRouting(baseOptions)).toEqual({ + pipelineModel: "modern", + useExperimentalNativeExport: true, + useExperimentalNvidiaCudaExport: false, + backendPreference: "auto", + }); + }); + + it("forces WebCodecs and disables native export for the legacy pipeline", () => { + expect( + resolveMp4ExportRouting({ + ...baseOptions, + settings: { pipelineModel: "legacy", backendPreference: "breeze" }, + }), + ).toEqual({ + pipelineModel: "legacy", + useExperimentalNativeExport: false, + useExperimentalNvidiaCudaExport: false, + backendPreference: "webcodecs", + }); + }); + + it("keeps smoke exports on WebCodecs unless smoke native export is requested", () => { + expect( + resolveMp4ExportRouting({ + ...baseOptions, + smokeExportConfig: { + enabled: true, + useNativeExport: false, + }, + }), + ).toEqual({ + pipelineModel: "modern", + useExperimentalNativeExport: false, + useExperimentalNvidiaCudaExport: false, + backendPreference: "webcodecs", + }); + + expect( + resolveMp4ExportRouting({ + ...baseOptions, + smokeExportConfig: { + enabled: true, + useNativeExport: true, + }, + }), + ).toEqual({ + pipelineModel: "modern", + useExperimentalNativeExport: true, + useExperimentalNvidiaCudaExport: false, + backendPreference: "breeze", + }); + }); + + it("only enables NVIDIA CUDA when native export is active and the device is available", () => { + expect( + resolveMp4ExportRouting({ + ...baseOptions, + experimentalNvidiaCudaExport: true, + nvidiaCudaExportAvailable: true, + }).useExperimentalNvidiaCudaExport, + ).toBe(true); + + expect( + resolveMp4ExportRouting({ + ...baseOptions, + settings: { pipelineModel: "legacy" }, + experimentalNvidiaCudaExport: true, + nvidiaCudaExportAvailable: true, + }).useExperimentalNvidiaCudaExport, + ).toBe(false); + + expect( + resolveMp4ExportRouting({ + ...baseOptions, + experimentalNvidiaCudaExport: true, + nvidiaCudaExportAvailable: false, + }).useExperimentalNvidiaCudaExport, + ).toBe(false); + }); +}); diff --git a/src/components/video-editor/mp4ExportRouting.ts b/src/components/video-editor/mp4ExportRouting.ts new file mode 100644 index 000000000..506e2b030 --- /dev/null +++ b/src/components/video-editor/mp4ExportRouting.ts @@ -0,0 +1,53 @@ +import type { ExportBackendPreference, ExportPipelineModel, ExportSettings } from "@/lib/exporter"; +import type { SmokeExportConfig } from "./smokeExportConfig"; + +export type Mp4ExportRouting = { + pipelineModel: ExportPipelineModel; + useExperimentalNativeExport: boolean; + useExperimentalNvidiaCudaExport: boolean; + backendPreference: ExportBackendPreference; +}; + +export function resolveMp4ExportRouting({ + smokeExportConfig, + settings, + exportPipelineModel, + exportBackendPreference, + experimentalNvidiaCudaExport, + nvidiaCudaExportAvailable, +}: { + smokeExportConfig: Pick< + SmokeExportConfig, + "enabled" | "pipelineModel" | "useNativeExport" | "backendPreference" + >; + settings: Pick<ExportSettings, "pipelineModel" | "backendPreference">; + exportPipelineModel: ExportPipelineModel; + exportBackendPreference: ExportBackendPreference; + experimentalNvidiaCudaExport: boolean; + nvidiaCudaExportAvailable: boolean; +}): Mp4ExportRouting { + const pipelineModel = smokeExportConfig.enabled + ? (smokeExportConfig.pipelineModel ?? "modern") + : (settings.pipelineModel ?? exportPipelineModel); + const useExperimentalNativeExport = + pipelineModel === "modern" && + (smokeExportConfig.enabled ? smokeExportConfig.useNativeExport : true); + const useExperimentalNvidiaCudaExport = + useExperimentalNativeExport && experimentalNvidiaCudaExport && nvidiaCudaExportAvailable; + const backendPreference = + pipelineModel === "legacy" + ? "webcodecs" + : smokeExportConfig.enabled + ? (smokeExportConfig.backendPreference ?? + (smokeExportConfig.useNativeExport ? "breeze" : "webcodecs")) + : useExperimentalNativeExport + ? "auto" + : (settings.backendPreference ?? exportBackendPreference); + + return { + pipelineModel, + useExperimentalNativeExport, + useExperimentalNvidiaCudaExport, + backendPreference, + }; +} diff --git a/src/components/video-editor/mp4ExportSettings.test.ts b/src/components/video-editor/mp4ExportSettings.test.ts new file mode 100644 index 000000000..6d4c04084 --- /dev/null +++ b/src/components/video-editor/mp4ExportSettings.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "vitest"; + +import { resolveMp4ExportSettings } from "./mp4ExportSettings"; + +const baseOptions = { + smokeExportConfig: { + enabled: false, + }, + settings: {}, + exportQuality: "high" as const, + exportEncodingMode: "balanced" as const, + mp4FrameRate: 30 as const, +}; + +describe("resolveMp4ExportSettings", () => { + it("uses editor defaults when neither menu settings nor smoke settings override them", () => { + expect(resolveMp4ExportSettings(baseOptions)).toEqual({ + quality: "high", + encodingMode: "balanced", + selectedMp4FrameRate: 30, + }); + }); + + it("prefers explicit menu settings over editor defaults for normal exports", () => { + expect( + resolveMp4ExportSettings({ + ...baseOptions, + settings: { + quality: "source", + encodingMode: "quality", + mp4FrameRate: 60, + }, + }), + ).toEqual({ + quality: "source", + encodingMode: "quality", + selectedMp4FrameRate: 60, + }); + }); + + it("prefers smoke URL settings over menu settings when smoke export is enabled", () => { + expect( + resolveMp4ExportSettings({ + ...baseOptions, + smokeExportConfig: { + enabled: true, + quality: "medium", + encodingMode: "fast", + fps: 24, + }, + settings: { + quality: "source", + encodingMode: "quality", + mp4FrameRate: 60, + }, + }), + ).toEqual({ + quality: "medium", + encodingMode: "fast", + selectedMp4FrameRate: 24, + }); + }); + + it("falls back from incomplete smoke settings to menu settings and editor defaults", () => { + expect( + resolveMp4ExportSettings({ + ...baseOptions, + smokeExportConfig: { + enabled: true, + quality: "good", + }, + settings: { + encodingMode: "quality", + }, + }), + ).toEqual({ + quality: "good", + encodingMode: "quality", + selectedMp4FrameRate: 30, + }); + }); +}); diff --git a/src/components/video-editor/mp4ExportSettings.ts b/src/components/video-editor/mp4ExportSettings.ts new file mode 100644 index 000000000..61f6c3a34 --- /dev/null +++ b/src/components/video-editor/mp4ExportSettings.ts @@ -0,0 +1,39 @@ +import type { + ExportEncodingMode, + ExportMp4FrameRate, + ExportQuality, + ExportSettings, +} from "@/lib/exporter"; +import type { SmokeExportConfig } from "./smokeExportConfig"; + +export type ResolvedMp4ExportSettings = { + quality: ExportQuality; + encodingMode: ExportEncodingMode; + selectedMp4FrameRate: ExportMp4FrameRate; +}; + +export function resolveMp4ExportSettings({ + smokeExportConfig, + settings, + exportQuality, + exportEncodingMode, + mp4FrameRate, +}: { + smokeExportConfig: Pick<SmokeExportConfig, "enabled" | "quality" | "encodingMode" | "fps">; + settings: Pick<ExportSettings, "quality" | "encodingMode" | "mp4FrameRate">; + exportQuality: ExportQuality; + exportEncodingMode: ExportEncodingMode; + mp4FrameRate: ExportMp4FrameRate; +}): ResolvedMp4ExportSettings { + return { + quality: smokeExportConfig.enabled + ? (smokeExportConfig.quality ?? settings.quality ?? exportQuality) + : (settings.quality ?? exportQuality), + encodingMode: smokeExportConfig.enabled + ? (smokeExportConfig.encodingMode ?? settings.encodingMode ?? exportEncodingMode) + : (settings.encodingMode ?? exportEncodingMode), + selectedMp4FrameRate: smokeExportConfig.enabled + ? (smokeExportConfig.fps ?? settings.mp4FrameRate ?? mp4FrameRate) + : (settings.mp4FrameRate ?? mp4FrameRate), + }; +} diff --git a/src/components/video-editor/presets/useEditorPreferencesPersistence.ts b/src/components/video-editor/presets/useEditorPreferencesPersistence.ts new file mode 100644 index 000000000..2d44dfa07 --- /dev/null +++ b/src/components/video-editor/presets/useEditorPreferencesPersistence.ts @@ -0,0 +1,133 @@ +import { useEffect } from "react"; +import type { AspectRatio } from "@/utils/aspectRatioUtils"; +import { saveEditorPreferences } from "../editorPreferences"; +import type { useExportSettings } from "../export/useExportSettings"; +import type { useAppearanceState } from "../state/useAppearanceState"; + +type Input = { + appearance: ReturnType<typeof useAppearanceState>; + exportSettings: ReturnType<typeof useExportSettings>; + aspectRatio: AspectRatio; + whisperExecutablePath: string | null; + whisperModelPath: string | null; +}; + +export function useEditorPreferencesPersistence({ + appearance, + exportSettings, + aspectRatio, + whisperExecutablePath, + whisperModelPath, +}: Input) { + useEffect(() => { + saveEditorPreferences({ + wallpaper: appearance.wallpaper, + shadowIntensity: appearance.shadowIntensity, + backgroundBlur: appearance.backgroundBlur, + zoomMotionBlur: appearance.zoomMotionBlur, + zoomMotionBlurTuning: appearance.zoomMotionBlurTuning, + zoomTemporalMotionBlur: appearance.zoomTemporalMotionBlur, + zoomMotionBlurSampleCount: appearance.zoomMotionBlurSampleCount, + zoomMotionBlurShutterFraction: appearance.zoomMotionBlurShutterFraction, + autoApplyFreshRecordingAutoZooms: appearance.autoApplyFreshRecordingAutoZooms, + connectZooms: appearance.connectZooms, + zoomInDurationMs: appearance.zoomInDurationMs, + zoomInOverlapMs: appearance.zoomInOverlapMs, + zoomOutDurationMs: appearance.zoomOutDurationMs, + connectedZoomGapMs: appearance.connectedZoomGapMs, + connectedZoomDurationMs: appearance.connectedZoomDurationMs, + zoomInEasing: appearance.zoomInEasing, + zoomOutEasing: appearance.zoomOutEasing, + connectedZoomEasing: appearance.connectedZoomEasing, + showCursor: appearance.showCursor, + loopCursor: appearance.loopCursor, + cursorStyle: appearance.cursorStyle, + cursorSize: appearance.cursorSize, + cursorSmoothing: appearance.cursorSmoothing, + cursorSpringStiffnessMultiplier: appearance.cursorSpringStiffnessMultiplier, + cursorSpringDampingMultiplier: appearance.cursorSpringDampingMultiplier, + cursorSpringMassMultiplier: appearance.cursorSpringMassMultiplier, + cameraSpringStiffnessMultiplier: appearance.cameraSpringStiffnessMultiplier, + cameraSpringDampingMultiplier: appearance.cameraSpringDampingMultiplier, + cameraSpringMassMultiplier: appearance.cameraSpringMassMultiplier, + cursorMotionBlur: appearance.cursorMotionBlur, + cursorClickEffect: appearance.cursorClickEffect, + cursorClickEffectColor: appearance.cursorClickEffectColor, + cursorClickEffectScale: appearance.cursorClickEffectScale, + cursorClickEffectOpacity: appearance.cursorClickEffectOpacity, + cursorClickEffectDurationMs: appearance.cursorClickEffectDurationMs, + cursorClickBounce: appearance.cursorClickBounce, + cursorClickBounceDuration: appearance.cursorClickBounceDuration, + cursorSway: appearance.cursorSway, + borderRadius: appearance.borderRadius, + padding: appearance.padding, + webcam: appearance.webcam, + aspectRatio, + exportEncodingMode: exportSettings.exportEncodingMode, + exportBackendPreference: exportSettings.exportBackendPreference, + exportPipelineModel: exportSettings.exportPipelineModel, + exportQuality: exportSettings.exportQuality, + mp4FrameRate: exportSettings.mp4FrameRate, + exportFormat: exportSettings.exportFormat, + gifFrameRate: exportSettings.gifFrameRate, + gifLoop: exportSettings.gifLoop, + gifSizePreset: exportSettings.gifSizePreset, + whisperExecutablePath, + whisperModelPath, + }); + }, [ + appearance.wallpaper, + appearance.shadowIntensity, + appearance.backgroundBlur, + appearance.zoomMotionBlur, + appearance.zoomMotionBlurTuning, + appearance.zoomTemporalMotionBlur, + appearance.zoomMotionBlurSampleCount, + appearance.zoomMotionBlurShutterFraction, + appearance.autoApplyFreshRecordingAutoZooms, + appearance.connectZooms, + appearance.zoomInDurationMs, + appearance.zoomInOverlapMs, + appearance.zoomOutDurationMs, + appearance.connectedZoomGapMs, + appearance.connectedZoomDurationMs, + appearance.zoomInEasing, + appearance.zoomOutEasing, + appearance.connectedZoomEasing, + appearance.showCursor, + appearance.loopCursor, + appearance.cursorStyle, + appearance.cursorSize, + appearance.cursorSmoothing, + appearance.cursorSpringStiffnessMultiplier, + appearance.cursorSpringDampingMultiplier, + appearance.cursorSpringMassMultiplier, + appearance.cameraSpringStiffnessMultiplier, + appearance.cameraSpringDampingMultiplier, + appearance.cameraSpringMassMultiplier, + appearance.cursorMotionBlur, + appearance.cursorClickEffect, + appearance.cursorClickEffectColor, + appearance.cursorClickEffectScale, + appearance.cursorClickEffectOpacity, + appearance.cursorClickEffectDurationMs, + appearance.cursorClickBounce, + appearance.cursorClickBounceDuration, + appearance.cursorSway, + appearance.borderRadius, + appearance.padding, + appearance.webcam, + aspectRatio, + exportSettings.exportEncodingMode, + exportSettings.exportBackendPreference, + exportSettings.exportPipelineModel, + exportSettings.exportQuality, + exportSettings.mp4FrameRate, + exportSettings.exportFormat, + exportSettings.gifFrameRate, + exportSettings.gifLoop, + exportSettings.gifSizePreset, + whisperExecutablePath, + whisperModelPath, + ]); +} diff --git a/src/components/video-editor/presets/useEditorPresets.ts b/src/components/video-editor/presets/useEditorPresets.ts new file mode 100644 index 000000000..d092dbe9d --- /dev/null +++ b/src/components/video-editor/presets/useEditorPresets.ts @@ -0,0 +1,172 @@ +import { type Dispatch, type SetStateAction, useCallback, useEffect, useMemo } from "react"; +import { toast } from "sonner"; +import { + type EditorPreset, + type EditorPresetSnapshot, + saveEditorPresets, + serializeEditorPresetSnapshot, +} from "../editorPreferences"; + +type Translator = ( + key: string, + fallback?: string, + params?: Record<string, string | number>, +) => string; + +interface UseEditorPresetsParams { + t: Translator; + currentSnapshot: EditorPresetSnapshot; + applySnapshot: (snapshot: EditorPresetSnapshot) => void; + editorPresets: EditorPreset[]; + setEditorPresets: Dispatch<SetStateAction<EditorPreset[]>>; + activePresetId: string | null; + setActivePresetId: Dispatch<SetStateAction<string | null>>; + presetPopoverOpen: boolean; + presetNameDraft: string; + setPresetNameDraft: Dispatch<SetStateAction<string>>; +} + +export function useEditorPresets({ + t, + currentSnapshot, + applySnapshot, + editorPresets, + setEditorPresets, + activePresetId, + setActivePresetId, + presetPopoverOpen, + presetNameDraft, + setPresetNameDraft, +}: UseEditorPresetsParams) { + const currentSignature = useMemo( + () => serializeEditorPresetSnapshot(currentSnapshot), + [currentSnapshot], + ); + const currentEditorPreset = useMemo( + () => editorPresets.find((preset) => preset.id === activePresetId) ?? null, + [activePresetId, editorPresets], + ); + + useEffect(() => { + if ( + currentEditorPreset && + serializeEditorPresetSnapshot(currentEditorPreset.snapshot) === currentSignature + ) { + return; + } + + const matchingPreset = + editorPresets.find( + (preset) => serializeEditorPresetSnapshot(preset.snapshot) === currentSignature, + ) ?? null; + const nextActiveId = matchingPreset?.id ?? null; + if (nextActiveId !== activePresetId) setActivePresetId(nextActiveId); + }, [activePresetId, currentEditorPreset, currentSignature, editorPresets, setActivePresetId]); + + useEffect(() => { + if (!presetPopoverOpen) setPresetNameDraft(""); + }, [presetPopoverOpen, setPresetNameDraft]); + + const handleApplyEditorPreset = useCallback( + (presetId: string) => { + const preset = editorPresets.find((item) => item.id === presetId); + if (!preset) return; + setActivePresetId(preset.id); + applySnapshot(preset.snapshot); + toast.success( + t("editor.presets.toasts.applied", 'Applied preset "{{name}}"', { + name: preset.name, + }), + ); + }, + [applySnapshot, editorPresets, setActivePresetId, t], + ); + + const handleSaveEditorPreset = useCallback( + (name: string) => { + const normalizedName = name.trim().replace(/\s+/g, " "); + if (!normalizedName) { + toast.error(t("editor.presets.errors.nameRequired", "Enter a preset name.")); + return false; + } + if ( + editorPresets.some( + (preset) => + preset.name.toLocaleLowerCase() === normalizedName.toLocaleLowerCase(), + ) + ) { + toast.error( + t( + "editor.presets.errors.duplicateName", + "A preset with that name already exists.", + ), + ); + return false; + } + + const timestamp = new Date().toISOString(); + const nextPreset: EditorPreset = { + id: crypto.randomUUID(), + name: normalizedName, + createdAt: timestamp, + updatedAt: timestamp, + snapshot: currentSnapshot, + }; + const nextPresets = [nextPreset, ...editorPresets]; + if (!saveEditorPresets(nextPresets)) { + toast.error( + t( + "editor.presets.errors.saveFailed", + "Could not save that preset. Check your browser storage settings and try again.", + ), + ); + return false; + } + setEditorPresets(nextPresets); + setActivePresetId(nextPreset.id); + toast.success( + t("editor.presets.toasts.saved", 'Saved preset "{{name}}"', { + name: normalizedName, + }), + ); + return true; + }, + [currentSnapshot, editorPresets, setActivePresetId, setEditorPresets, t], + ); + + const handleDeleteEditorPreset = useCallback( + (presetId: string) => { + const preset = editorPresets.find((item) => item.id === presetId); + if (!preset) return; + const nextPresets = editorPresets.filter((item) => item.id !== presetId); + if (!saveEditorPresets(nextPresets)) { + toast.error( + t( + "editor.presets.errors.deleteFailed", + "Could not delete that preset. Check your browser storage settings and try again.", + ), + ); + return; + } + setEditorPresets(nextPresets); + if (preset.id === activePresetId) setActivePresetId(null); + toast.success( + t("editor.presets.toasts.deleted", 'Deleted preset "{{name}}"', { + name: preset.name, + }), + ); + }, + [activePresetId, editorPresets, setActivePresetId, setEditorPresets, t], + ); + + const handleSavePresetSubmit = useCallback(() => { + if (handleSaveEditorPreset(presetNameDraft)) setPresetNameDraft(""); + }, [handleSaveEditorPreset, presetNameDraft, setPresetNameDraft]); + + return { + currentEditorPreset, + handleApplyEditorPreset, + handleDeleteEditorPreset, + handleSavePresetSubmit, + }; +} diff --git a/src/components/video-editor/presets/useVideoEditorPresets.ts b/src/components/video-editor/presets/useVideoEditorPresets.ts new file mode 100644 index 000000000..fa6a971ab --- /dev/null +++ b/src/components/video-editor/presets/useVideoEditorPresets.ts @@ -0,0 +1,197 @@ +import { type Dispatch, type SetStateAction, useCallback, useMemo, useState } from "react"; +import type { AspectRatio } from "@/utils/aspectRatioUtils"; +import { type EditorPresetSnapshot, loadEditorPresets } from "../editorPreferences"; +import type { useExportSettings } from "../export/useExportSettings"; +import type { useAppearanceState } from "../state/useAppearanceState"; +import type { useTimelineState } from "../state/useTimelineState"; +import { useEditorPresets } from "./useEditorPresets"; + +type Input = { + t: Parameters<typeof useEditorPresets>[0]["t"]; + appearance: ReturnType<typeof useAppearanceState>; + timeline: ReturnType<typeof useTimelineState>; + exportSettings: ReturnType<typeof useExportSettings>; + aspectRatio: AspectRatio; + setAspectRatio: Dispatch<SetStateAction<AspectRatio>>; + whisperExecutablePath: string | null; + setWhisperExecutablePath: Dispatch<SetStateAction<string | null>>; + whisperModelPath: string | null; + setWhisperModelPath: Dispatch<SetStateAction<string | null>>; +}; + +export function useVideoEditorPresets({ + t, + appearance, + timeline, + exportSettings, + aspectRatio, + setAspectRatio, + whisperExecutablePath, + setWhisperExecutablePath, + whisperModelPath, + setWhisperModelPath, +}: Input) { + const [editorPresets, setEditorPresets] = useState(() => loadEditorPresets()); + const [activeEditorPresetId, setActiveEditorPresetId] = useState<string | null>(null); + const [presetPopoverOpen, setPresetPopoverOpen] = useState(false); + const [presetNameDraft, setPresetNameDraft] = useState(""); + + const currentSnapshot = useMemo<EditorPresetSnapshot>( + () => ({ + wallpaper: appearance.wallpaper, + shadowIntensity: appearance.shadowIntensity, + backgroundBlur: appearance.backgroundBlur, + zoomMotionBlur: appearance.zoomMotionBlur, + zoomMotionBlurTuning: { ...appearance.zoomMotionBlurTuning }, + zoomTemporalMotionBlur: appearance.zoomTemporalMotionBlur, + zoomMotionBlurSampleCount: appearance.zoomMotionBlurSampleCount, + zoomMotionBlurShutterFraction: appearance.zoomMotionBlurShutterFraction, + connectZooms: appearance.connectZooms, + zoomInDurationMs: appearance.zoomInDurationMs, + zoomInOverlapMs: appearance.zoomInOverlapMs, + zoomOutDurationMs: appearance.zoomOutDurationMs, + connectedZoomGapMs: appearance.connectedZoomGapMs, + connectedZoomDurationMs: appearance.connectedZoomDurationMs, + zoomInEasing: appearance.zoomInEasing, + zoomOutEasing: appearance.zoomOutEasing, + connectedZoomEasing: appearance.connectedZoomEasing, + showCursor: appearance.showCursor, + loopCursor: appearance.loopCursor, + cursorStyle: appearance.cursorStyle, + cursorSize: appearance.cursorSize, + cursorSmoothing: appearance.cursorSmoothing, + cursorSpringStiffnessMultiplier: appearance.cursorSpringStiffnessMultiplier, + cursorSpringDampingMultiplier: appearance.cursorSpringDampingMultiplier, + cursorSpringMassMultiplier: appearance.cursorSpringMassMultiplier, + cameraSpringStiffnessMultiplier: appearance.cameraSpringStiffnessMultiplier, + cameraSpringDampingMultiplier: appearance.cameraSpringDampingMultiplier, + cameraSpringMassMultiplier: appearance.cameraSpringMassMultiplier, + cursorMotionBlur: appearance.cursorMotionBlur, + cursorClickEffect: appearance.cursorClickEffect, + cursorClickEffectColor: appearance.cursorClickEffectColor, + cursorClickEffectScale: appearance.cursorClickEffectScale, + cursorClickEffectOpacity: appearance.cursorClickEffectOpacity, + cursorClickEffectDurationMs: appearance.cursorClickEffectDurationMs, + cursorClickBounce: appearance.cursorClickBounce, + cursorClickBounceDuration: appearance.cursorClickBounceDuration, + cursorSway: appearance.cursorSway, + borderRadius: appearance.borderRadius, + padding: { ...appearance.padding }, + cropRegion: { ...appearance.cropRegion }, + webcam: (({ sourcePath: _sourcePath, ...settings }) => settings)(appearance.webcam), + aspectRatio, + exportEncodingMode: exportSettings.exportEncodingMode, + exportBackendPreference: exportSettings.exportBackendPreference, + exportPipelineModel: exportSettings.exportPipelineModel, + exportQuality: exportSettings.exportQuality, + mp4FrameRate: exportSettings.mp4FrameRate, + exportFormat: exportSettings.exportFormat, + gifFrameRate: exportSettings.gifFrameRate, + gifLoop: exportSettings.gifLoop, + gifSizePreset: exportSettings.gifSizePreset, + autoCaptionSettings: { ...timeline.autoCaptionSettings }, + whisperExecutablePath, + whisperModelPath, + }), + [ + appearance, + timeline.autoCaptionSettings, + exportSettings, + aspectRatio, + whisperExecutablePath, + whisperModelPath, + ], + ); + + const applySnapshot = useCallback( + (snapshot: EditorPresetSnapshot) => { + appearance.setWallpaper(snapshot.wallpaper); + appearance.setShadowIntensity(snapshot.shadowIntensity); + appearance.setBackgroundBlur(snapshot.backgroundBlur); + appearance.setZoomMotionBlur(snapshot.zoomMotionBlur); + appearance.setZoomMotionBlurTuning({ ...snapshot.zoomMotionBlurTuning }); + appearance.setZoomTemporalMotionBlur(snapshot.zoomTemporalMotionBlur); + appearance.setZoomMotionBlurSampleCount(snapshot.zoomMotionBlurSampleCount); + appearance.setZoomMotionBlurShutterFraction(snapshot.zoomMotionBlurShutterFraction); + appearance.setConnectZooms(snapshot.connectZooms); + appearance.setZoomInDurationMs(snapshot.zoomInDurationMs); + appearance.setZoomInOverlapMs(snapshot.zoomInOverlapMs); + appearance.setZoomOutDurationMs(snapshot.zoomOutDurationMs); + appearance.setConnectedZoomGapMs(snapshot.connectedZoomGapMs); + appearance.setConnectedZoomDurationMs(snapshot.connectedZoomDurationMs); + appearance.setZoomInEasing(snapshot.zoomInEasing); + appearance.setZoomOutEasing(snapshot.zoomOutEasing); + appearance.setConnectedZoomEasing(snapshot.connectedZoomEasing); + appearance.setShowCursor(snapshot.showCursor); + appearance.setLoopCursor(snapshot.loopCursor); + appearance.setCursorStyle(snapshot.cursorStyle); + appearance.setCursorSize(snapshot.cursorSize); + appearance.setCursorSmoothing(snapshot.cursorSmoothing); + appearance.setCursorSpringStiffnessMultiplier(snapshot.cursorSpringStiffnessMultiplier); + appearance.setCursorSpringDampingMultiplier(snapshot.cursorSpringDampingMultiplier); + appearance.setCursorSpringMassMultiplier(snapshot.cursorSpringMassMultiplier); + appearance.setCameraSpringStiffnessMultiplier(snapshot.cameraSpringStiffnessMultiplier); + appearance.setCameraSpringDampingMultiplier(snapshot.cameraSpringDampingMultiplier); + appearance.setCameraSpringMassMultiplier(snapshot.cameraSpringMassMultiplier); + appearance.setCursorMotionBlur(snapshot.cursorMotionBlur); + appearance.setCursorClickEffect(snapshot.cursorClickEffect); + appearance.setCursorClickEffectColor(snapshot.cursorClickEffectColor); + appearance.setCursorClickEffectScale(snapshot.cursorClickEffectScale); + appearance.setCursorClickEffectOpacity(snapshot.cursorClickEffectOpacity); + appearance.setCursorClickEffectDurationMs(snapshot.cursorClickEffectDurationMs); + appearance.setCursorClickBounce(snapshot.cursorClickBounce); + appearance.setCursorClickBounceDuration(snapshot.cursorClickBounceDuration); + appearance.setCursorSway(snapshot.cursorSway); + appearance.setBorderRadius(snapshot.borderRadius); + appearance.setPadding({ ...snapshot.padding }); + appearance.setCropRegion({ ...snapshot.cropRegion }); + appearance.setWebcam((current) => ({ + ...snapshot.webcam, + sourcePath: current.sourcePath, + })); + setAspectRatio(snapshot.aspectRatio); + exportSettings.setExportEncodingMode(snapshot.exportEncodingMode); + exportSettings.setExportBackendPreference(snapshot.exportBackendPreference); + exportSettings.setExportPipelineModel(snapshot.exportPipelineModel); + exportSettings.setExportQuality(snapshot.exportQuality); + exportSettings.setMp4FrameRate(snapshot.mp4FrameRate); + exportSettings.setExportFormat(snapshot.exportFormat); + exportSettings.setGifFrameRate(snapshot.gifFrameRate); + exportSettings.setGifLoop(snapshot.gifLoop); + exportSettings.setGifSizePreset(snapshot.gifSizePreset); + timeline.setAutoCaptionSettings({ ...snapshot.autoCaptionSettings }); + setWhisperExecutablePath(snapshot.whisperExecutablePath); + setWhisperModelPath(snapshot.whisperModelPath); + }, + [ + appearance, + exportSettings, + timeline, + setAspectRatio, + setWhisperExecutablePath, + setWhisperModelPath, + ], + ); + + const actions = useEditorPresets({ + t, + currentSnapshot, + applySnapshot, + editorPresets, + setEditorPresets, + activePresetId: activeEditorPresetId, + setActivePresetId: setActiveEditorPresetId, + presetPopoverOpen, + presetNameDraft, + setPresetNameDraft, + }); + return { + editorPresets, + activeEditorPresetId, + presetPopoverOpen, + setPresetPopoverOpen, + presetNameDraft, + setPresetNameDraft, + ...actions, + }; +} diff --git a/src/components/video-editor/project/useEditorProjectController.ts b/src/components/video-editor/project/useEditorProjectController.ts new file mode 100644 index 000000000..a2bba3204 --- /dev/null +++ b/src/components/video-editor/project/useEditorProjectController.ts @@ -0,0 +1,228 @@ +import type { Dispatch, MutableRefObject, RefObject, SetStateAction } from "react"; +import { useMemo } from "react"; +import type { useI18n } from "@/contexts/I18nContext"; +import type { AspectRatio } from "@/utils/aspectRatioUtils"; +import { useAutoCaptionController } from "../captions/useAutoCaptionController"; +import { loadEditorPreferences } from "../editorPreferences"; +import type { useExportSettings } from "../export/useExportSettings"; +import { useEditorHistory } from "../hooks/useEditorHistory"; +import { useEditorPreferencesPersistence } from "../presets/useEditorPreferencesPersistence"; +import { hasUnsavedProjectChanges } from "../projectDirtyState"; +import { getDevOpenRecordingConfig, getSmokeExportConfig } from "../smokeExportConfig"; +import type { useAppearanceState } from "../state/useAppearanceState"; +import type { useProjectState } from "../state/useProjectState"; +import type { useTimelineState } from "../state/useTimelineState"; +import type { VideoPlaybackRef } from "../VideoPlayback"; +import { useInitialEditorSource } from "./useInitialEditorSource"; +import type { useProjectLibraryController } from "./useProjectLibraryController"; +import { useProjectLifecycle } from "./useProjectLifecycle"; +import { useProjectOpenActions } from "./useProjectOpenActions"; +import { useProjectSaveActions } from "./useProjectSaveActions"; +import { useProjectSnapshotModel } from "./useProjectSnapshotModel"; + +type Input = { + t: ReturnType<typeof useI18n>["t"]; + project: ReturnType<typeof useProjectState>; + appearance: ReturnType<typeof useAppearanceState>; + timeline: ReturnType<typeof useTimelineState>; + exportSettings: ReturnType<typeof useExportSettings>; + initialPreferences: ReturnType<typeof loadEditorPreferences>; + smokeConfig: ReturnType<typeof getSmokeExportConfig>; + devConfig: ReturnType<typeof getDevOpenRecordingConfig>; + aspectRatio: AspectRatio; + setAspectRatio: Dispatch<SetStateAction<AspectRatio>>; + videoPath: string | null; + setVideoPath: Dispatch<SetStateAction<string | null>>; + videoSourcePath: string | null; + setVideoSourcePath: Dispatch<SetStateAction<string | null>>; + currentTime: number; + setCurrentTime: Dispatch<SetStateAction<number>>; + setIsPlaying: Dispatch<SetStateAction<boolean>>; + setDuration: Dispatch<SetStateAction<number>>; + whisperExecutablePath: string | null; + setWhisperExecutablePath: Dispatch<SetStateAction<string | null>>; + whisperModelPath: string | null; + setWhisperModelPath: Dispatch<SetStateAction<string | null>>; + downloadedWhisperModelPath: string | null; + setDownloadedWhisperModelPath: Dispatch<SetStateAction<string | null>>; + whisperModelDownloadStatus: "idle" | "downloading" | "downloaded" | "error"; + setWhisperModelDownloadStatus: Dispatch< + SetStateAction<"idle" | "downloading" | "downloaded" | "error"> + >; + setWhisperModelDownloadProgress: Dispatch<SetStateAction<number>>; + isGeneratingCaptions: boolean; + setIsGeneratingCaptions: Dispatch<SetStateAction<boolean>>; + videoPlaybackRef: RefObject<VideoPlaybackRef>; + projectNameInputRef: RefObject<HTMLInputElement>; + projectSaveDialogInputRef: RefObject<HTMLInputElement>; + nextZoomIdRef: MutableRefObject<number>; + nextClipIdRef: MutableRefObject<number>; + nextAudioIdRef: MutableRefObject<number>; + nextAnnotationIdRef: MutableRefObject<number>; + nextAnnotationZIndexRef: MutableRefObject<number>; + clipInitializedRef: MutableRefObject<boolean>; + autoFullTrackClipIdRef: MutableRefObject<string | null>; + autoFullTrackClipEndMsRef: MutableRefObject<number | null>; + pendingFreshRecordingAutoZoomPathRef: MutableRefObject<string | null>; + pendingFreshRecordingAutoSuggestTelemetryCountRef: MutableRefObject<number>; + autoSuggestedVideoPathRef: MutableRefObject<string | null>; + applySessionPresentation: ( + session: + | { hideOverlayCursorByDefault?: boolean; nativeCaptureUnavailable?: boolean } + | null + | undefined, + ) => void; + refreshProjectLibrary: ReturnType<typeof useProjectLibraryController>["refreshProjectLibrary"]; + captureProjectThumbnail: ReturnType< + typeof useProjectLibraryController + >["captureProjectThumbnail"]; + remountPreview: () => void; +}; + +export function useEditorProjectController(input: Input) { + const snapshot = useProjectSnapshotModel({ + t: input.t, + project: input.project, + appearance: input.appearance, + timeline: input.timeline, + exportSettings: input.exportSettings, + aspectRatio: input.aspectRatio, + projectNameInputRef: input.projectNameInputRef, + projectSaveDialogInputRef: input.projectSaveDialogInputRef, + }); + const history = useEditorHistory({ + timeline: input.timeline, + nextZoomIdRef: input.nextZoomIdRef, + nextClipIdRef: input.nextClipIdRef, + nextAnnotationIdRef: input.nextAnnotationIdRef, + nextAudioIdRef: input.nextAudioIdRef, + nextAnnotationZIndexRef: input.nextAnnotationZIndexRef, + }); + const lifecycle = useProjectLifecycle({ + t: input.t, + project: input.project, + appearance: input.appearance, + timeline: input.timeline, + exportSettings: input.exportSettings, + aspectRatio: input.aspectRatio, + setAspectRatio: input.setAspectRatio, + currentSourcePath: snapshot.currentSourcePath, + currentPersistedEditorState: snapshot.currentPersistedEditorState, + videoPlaybackRef: input.videoPlaybackRef, + setIsPlaying: input.setIsPlaying, + setCurrentTime: input.setCurrentTime, + setDuration: input.setDuration, + applySessionPresentation: input.applySessionPresentation, + refreshProjectLibrary: input.refreshProjectLibrary, + buildPersistedEditorState: snapshot.buildPersistedEditorState, + resetHistory: history.resetHistory, + refs: { + nextZoomIdRef: input.nextZoomIdRef, + nextClipIdRef: input.nextClipIdRef, + nextAudioIdRef: input.nextAudioIdRef, + nextAnnotationIdRef: input.nextAnnotationIdRef, + nextAnnotationZIndexRef: input.nextAnnotationZIndexRef, + clipInitializedRef: input.clipInitializedRef, + autoFullTrackClipIdRef: input.autoFullTrackClipIdRef, + autoFullTrackClipEndMsRef: input.autoFullTrackClipEndMsRef, + pendingFreshRecordingAutoZoomPathRef: input.pendingFreshRecordingAutoZoomPathRef, + pendingFreshRecordingAutoSuggestTelemetryCountRef: + input.pendingFreshRecordingAutoSuggestTelemetryCountRef, + autoSuggestedVideoPathRef: input.autoSuggestedVideoPathRef, + }, + }); + const hasUnsavedChanges = useMemo( + () => + hasUnsavedProjectChanges( + lifecycle.currentProjectSnapshot, + input.project.lastSavedSnapshot, + ), + [lifecycle.currentProjectSnapshot, input.project.lastSavedSnapshot], + ); + + useInitialEditorSource({ + project: input.project, + appearance: input.appearance, + timeline: input.timeline, + smokeConfig: input.smokeConfig, + devConfig: input.devConfig, + videoSourcePath: input.videoSourcePath, + pendingFreshRecordingAutoZoomPathRef: input.pendingFreshRecordingAutoZoomPathRef, + applyLoadedProject: lifecycle.applyLoadedProject, + resetSourceScopedEditorState: lifecycle.resetSourceScopedEditorState, + applySessionPresentation: input.applySessionPresentation, + }); + useEditorPreferencesPersistence({ + appearance: input.appearance, + exportSettings: input.exportSettings, + aspectRatio: input.aspectRatio, + whisperExecutablePath: input.whisperExecutablePath, + whisperModelPath: input.whisperModelPath, + }); + const autoCaption = useAutoCaptionController({ + t: input.t, + videoPath: input.videoPath, + setVideoPath: input.setVideoPath, + videoSourcePath: input.videoSourcePath, + setVideoSourcePath: input.setVideoSourcePath, + webcamSourcePath: input.appearance.webcam.sourcePath ?? null, + whisperExecutablePath: input.whisperExecutablePath, + setWhisperExecutablePath: input.setWhisperExecutablePath, + whisperModelPath: input.whisperModelPath, + setWhisperModelPath: input.setWhisperModelPath, + downloadedWhisperModelPath: input.downloadedWhisperModelPath, + setDownloadedWhisperModelPath: input.setDownloadedWhisperModelPath, + whisperModelDownloadStatus: input.whisperModelDownloadStatus, + setWhisperModelDownloadStatus: input.setWhisperModelDownloadStatus, + setWhisperModelDownloadProgress: input.setWhisperModelDownloadProgress, + isGeneratingCaptions: input.isGeneratingCaptions, + setIsGeneratingCaptions: input.setIsGeneratingCaptions, + autoCaptionSettings: input.timeline.autoCaptionSettings, + setAutoCaptionSettings: input.timeline.setAutoCaptionSettings, + setAutoCaptions: input.timeline.setAutoCaptions, + syncActiveVideoSource: lifecycle.syncActiveVideoSource, + }); + const saveActions = useProjectSaveActions({ + project: input.project, + currentSourcePath: snapshot.currentSourcePath, + currentProjectSnapshot: lifecycle.currentProjectSnapshot, + currentPersistedEditorState: snapshot.currentPersistedEditorState, + projectDisplayName: snapshot.projectDisplayName, + hasUnsavedChanges, + projectSaveDialogInputRef: input.projectSaveDialogInputRef, + projectNameInputRef: input.projectNameInputRef, + openProjectSaveDialog: lifecycle.openProjectSaveDialog, + resolveProjectSaveDialog: lifecycle.resolveProjectSaveDialog, + captureProjectThumbnail: input.captureProjectThumbnail, + refreshProjectLibrary: input.refreshProjectLibrary, + remountPreview: input.remountPreview, + }); + const openActions = useProjectOpenActions({ + project: input.project, + appearance: input.appearance, + videoPlaybackRef: input.videoPlaybackRef, + pendingFreshRecordingAutoZoomPathRef: input.pendingFreshRecordingAutoZoomPathRef, + hasUnsavedChanges, + setIsPlaying: input.setIsPlaying, + setCurrentTime: input.setCurrentTime, + setDuration: input.setDuration, + applyLoadedProject: lifecycle.applyLoadedProject, + openUnsavedChangesDialog: lifecycle.openUnsavedChangesDialog, + saveProject: saveActions.saveProject, + refreshProjectLibrary: input.refreshProjectLibrary, + resetSourceScopedEditorState: lifecycle.resetSourceScopedEditorState, + applySessionPresentation: input.applySessionPresentation, + handleSaveProject: saveActions.handleSaveProject, + handleSaveProjectAs: saveActions.handleSaveProjectAs, + }); + + return { + snapshot, + history, + lifecycle, + autoCaption, + saveActions, + openActions, + hasUnsavedChanges, + }; +} diff --git a/src/components/video-editor/project/useInitialEditorSource.ts b/src/components/video-editor/project/useInitialEditorSource.ts new file mode 100644 index 000000000..4195bde40 --- /dev/null +++ b/src/components/video-editor/project/useInitialEditorSource.ts @@ -0,0 +1,250 @@ +/* biome-ignore-all lint/correctness/useExhaustiveDependencies: editor state setters are stable and initial source loading intentionally runs once per launch configuration. */ +import { type MutableRefObject, useEffect, useRef } from "react"; +import { fromFileUrl, resolveVideoUrl } from "../projectPersistence"; +import type { getDevOpenRecordingConfig, getSmokeExportConfig } from "../smokeExportConfig"; +import type { useAppearanceState } from "../state/useAppearanceState"; +import type { useProjectState } from "../state/useProjectState"; +import type { useTimelineState } from "../state/useTimelineState"; +import { DEFAULT_WEBCAM_TIME_OFFSET_MS } from "../types"; + +type SessionPresentation = { + hideOverlayCursorByDefault?: boolean; + nativeCaptureUnavailable?: boolean; +}; + +type Input = { + project: ReturnType<typeof useProjectState>; + appearance: ReturnType<typeof useAppearanceState>; + timeline: ReturnType<typeof useTimelineState>; + smokeConfig: ReturnType<typeof getSmokeExportConfig>; + devConfig: ReturnType<typeof getDevOpenRecordingConfig>; + videoSourcePath: string | null; + pendingFreshRecordingAutoZoomPathRef: MutableRefObject<string | null>; + applyLoadedProject: (candidate: unknown, path?: string | null) => Promise<boolean>; + resetSourceScopedEditorState: () => void; + applySessionPresentation: (session: SessionPresentation | null | undefined) => void; +}; + +export function useInitialEditorSource({ + project, + appearance, + timeline, + smokeConfig, + devConfig, + videoSourcePath, + pendingFreshRecordingAutoZoomPathRef, + applyLoadedProject, + resetSourceScopedEditorState, + applySessionPresentation, +}: Input) { + const initialLoadStartedRef = useRef(false); + + useEffect(() => { + // This effect owns launch-time hydration. Several of the callbacks it uses + // intentionally close over live editor state, so their identities may change + // after hydration updates that state. Never interpret that as a request to + // reload the source and reset the editor again. + if (initialLoadStartedRef.current) return; + initialLoadStartedRef.current = true; + + async function loadInitialData() { + try { + if (smokeConfig.enabled && smokeConfig.projectPath) { + const result = await window.electronAPI.openProjectFileAtPath( + smokeConfig.projectPath, + ); + if (!result.success || !result.project) { + project.setError( + `Smoke export failed to load project ${smokeConfig.projectPath}: ${result.error || result.message || "unknown error"}`, + ); + return; + } + if ( + !(await applyLoadedProject( + result.project, + result.path ?? smokeConfig.projectPath, + )) + ) { + project.setError( + `Smoke export could not apply project ${smokeConfig.projectPath}`, + ); + return; + } + project.setError(null); + return; + } + + if (!smokeConfig.enabled && devConfig.inputPath) { + const sourcePath = fromFileUrl(devConfig.inputPath); + const webcamPath = devConfig.webcamInputPath + ? fromFileUrl(devConfig.webcamInputPath) + : null; + if (webcamPath) { + await window.electronAPI.setCurrentRecordingSession?.({ + videoPath: sourcePath, + webcamPath, + timeOffsetMs: DEFAULT_WEBCAM_TIME_OFFSET_MS, + }); + } else { + await window.electronAPI.setCurrentVideoPath(sourcePath); + } + const sourceUrl = await resolveVideoUrl(sourcePath); + project.setVideoSourcePath(sourcePath); + project.setVideoPath(sourceUrl); + project.setCurrentProjectPath(null); + project.setLastSavedSnapshot(null); + resetSourceScopedEditorState(); + pendingFreshRecordingAutoZoomPathRef.current = + appearance.autoApplyFreshRecordingAutoZooms ? sourceUrl : null; + appearance.setWebcam((previous) => ({ + ...previous, + enabled: Boolean(webcamPath), + sourcePath: webcamPath, + timeOffsetMs: DEFAULT_WEBCAM_TIME_OFFSET_MS, + })); + project.setError(null); + return; + } + + if (smokeConfig.enabled) { + if (!smokeConfig.inputPath) { + project.setError("Smoke export input path is missing."); + return; + } + const sourcePath = fromFileUrl(smokeConfig.inputPath); + const webcamPath = smokeConfig.webcamInputPath + ? fromFileUrl(smokeConfig.webcamInputPath) + : null; + if (webcamPath) { + await window.electronAPI.setCurrentRecordingSession?.({ + videoPath: sourcePath, + webcamPath, + timeOffsetMs: DEFAULT_WEBCAM_TIME_OFFSET_MS, + }); + } else { + await window.electronAPI.setCurrentVideoPath(sourcePath); + } + const sourceUrl = await resolveVideoUrl(sourcePath); + project.setVideoSourcePath(sourcePath); + project.setVideoPath(sourceUrl); + project.setCurrentProjectPath(null); + project.setLastSavedSnapshot(null); + resetSourceScopedEditorState(); + pendingFreshRecordingAutoZoomPathRef.current = null; + appearance.setWebcam((previous) => ({ + ...previous, + enabled: Boolean(webcamPath), + sourcePath: webcamPath, + timeOffsetMs: DEFAULT_WEBCAM_TIME_OFFSET_MS, + shadow: smokeConfig.webcamShadow ?? previous.shadow, + size: smokeConfig.webcamSize ?? previous.size, + width: smokeConfig.webcamSize ?? previous.width ?? previous.size, + height: smokeConfig.webcamSize ?? previous.height ?? previous.size, + })); + project.setError(null); + return; + } + + const currentProject = await window.electronAPI.loadCurrentProjectFile(); + if ( + currentProject.success && + currentProject.project && + (await applyLoadedProject(currentProject.project, currentProject.path ?? null)) + ) { + return; + } + + const sessionResult = await window.electronAPI.getCurrentRecordingSession?.(); + if (sessionResult?.success && sessionResult.session?.videoPath) { + const sourcePath = fromFileUrl(sessionResult.session.videoPath); + const sourceUrl = await resolveVideoUrl(sourcePath); + project.setVideoSourcePath(sourcePath); + project.setVideoPath(sourceUrl); + project.setCurrentProjectPath(null); + project.setLastSavedSnapshot(null); + resetSourceScopedEditorState(); + pendingFreshRecordingAutoZoomPathRef.current = + appearance.autoApplyFreshRecordingAutoZooms ? sourceUrl : null; + applySessionPresentation(sessionResult.session); + appearance.setWebcam((previous) => ({ + ...previous, + enabled: Boolean(sessionResult.session?.webcamPath), + sourcePath: sessionResult.session?.webcamPath ?? null, + timeOffsetMs: + sessionResult.session?.timeOffsetMs ?? DEFAULT_WEBCAM_TIME_OFFSET_MS, + })); + return; + } + + const currentVideo = await window.electronAPI.getCurrentVideoPath(); + if (!currentVideo.success || !currentVideo.path) { + project.setError("No video to load. Please record or select a video."); + return; + } + const sourcePath = fromFileUrl(currentVideo.path); + project.setVideoSourcePath(sourcePath); + project.setVideoPath(await resolveVideoUrl(sourcePath)); + project.setCurrentProjectPath(null); + project.setLastSavedSnapshot(null); + resetSourceScopedEditorState(); + pendingFreshRecordingAutoZoomPathRef.current = null; + applySessionPresentation(null); + appearance.setWebcam((previous) => ({ + ...previous, + enabled: false, + sourcePath: null, + timeOffsetMs: DEFAULT_WEBCAM_TIME_OFFSET_MS, + })); + } catch (error) { + project.setError(`Error loading video: ${String(error)}`); + } finally { + project.setLoading(false); + } + } + void loadInitialData(); + }, [ + applyLoadedProject, + applySessionPresentation, + devConfig, + resetSourceScopedEditorState, + smokeConfig, + ]); + + useEffect(() => { + if (!window.electronAPI.onRecordingSessionChanged) return; + return window.electronAPI.onRecordingSessionChanged((session) => { + const sessionSourcePath = session?.videoPath ? fromFileUrl(session.videoPath) : null; + const webcamPath = session?.webcamPath ? fromFileUrl(session.webcamPath) : null; + if (!session || sessionSourcePath !== videoSourcePath) return; + appearance.setWebcam((previous) => ({ + ...previous, + enabled: Boolean(webcamPath), + sourcePath: webcamPath, + timeOffsetMs: webcamPath + ? (session.timeOffsetMs ?? previous.timeOffsetMs) + : DEFAULT_WEBCAM_TIME_OFFSET_MS, + })); + timeline.setSourceAudioFallbackRefreshKey((key) => key + 1); + }); + }, [videoSourcePath, appearance.setWebcam, timeline.setSourceAudioFallbackRefreshKey]); + + useEffect(() => { + let cancelled = false; + if (!appearance.webcam.sourcePath) { + appearance.setResolvedWebcamVideoUrl(null); + return; + } + void resolveVideoUrl(appearance.webcam.sourcePath).then((url) => { + if (!cancelled) appearance.setResolvedWebcamVideoUrl(url); + }); + return () => { + cancelled = true; + }; + }, [appearance.webcam.sourcePath, appearance.setResolvedWebcamVideoUrl]); + + useEffect(() => { + if (!appearance.autoApplyFreshRecordingAutoZooms) { + pendingFreshRecordingAutoZoomPathRef.current = null; + } + }, [appearance.autoApplyFreshRecordingAutoZooms, pendingFreshRecordingAutoZoomPathRef]); +} diff --git a/src/components/video-editor/project/useProjectLibraryController.ts b/src/components/video-editor/project/useProjectLibraryController.ts new file mode 100644 index 000000000..a2cdf5a53 --- /dev/null +++ b/src/components/video-editor/project/useProjectLibraryController.ts @@ -0,0 +1,329 @@ +/* biome-ignore-all lint/correctness/useExhaustiveDependencies: grouped editor domain objects contain the thumbnail renderer dependencies. */ +import { type RefObject, useCallback, useEffect, useRef } from "react"; +import { FrameRenderer } from "@/lib/exporter"; +import { toFileUrl } from "../projectPersistence"; +import type { useAppearanceState } from "../state/useAppearanceState"; +import type { useProjectState } from "../state/useProjectState"; +import type { useTimelineState } from "../state/useTimelineState"; +import { getClipSourceEndMs, type SpeedRegion } from "../types"; +import type { VideoPlaybackRef } from "../VideoPlayback"; + +type Input = { + project: ReturnType<typeof useProjectState>; + appearance: ReturnType<typeof useAppearanceState>; + timeline: ReturnType<typeof useTimelineState>; + videoPlaybackRef: RefObject<VideoPlaybackRef | null>; + currentTime: number; + effectiveShowCursor: boolean; +}; + +export function useProjectLibraryController({ + project, + appearance, + timeline, + videoPlaybackRef, + currentTime, + effectiveShowCursor, +}: Input) { + const currentTimeRef = useRef(currentTime); + useEffect(() => { + currentTimeRef.current = currentTime; + }, [currentTime]); + const { setProjectLibraryEntries } = project; + const { + backgroundBlur, + borderRadius, + connectZooms, + connectedZoomDurationMs, + connectedZoomEasing, + connectedZoomGapMs, + cropRegion, + cursorClickBounce, + cursorClickBounceDuration, + cursorClickEffect, + cursorClickEffectColor, + cursorClickEffectScale, + cursorClickEffectOpacity, + cursorClickEffectDurationMs, + cursorMotionBlur, + cursorSize, + cursorSmoothing, + cursorSpringDampingMultiplier, + cursorSpringMassMultiplier, + cursorSpringStiffnessMultiplier, + cameraSpringStiffnessMultiplier, + cameraSpringDampingMultiplier, + cameraSpringMassMultiplier, + zoomSmoothness, + cursorStyle, + cursorSway, + padding, + resolvedWebcamVideoUrl, + shadowIntensity, + wallpaper, + webcam, + zoomInDurationMs, + zoomInEasing, + zoomInOverlapMs, + zoomMotionBlur, + zoomMotionBlurTuning, + zoomTemporalMotionBlur, + zoomMotionBlurSampleCount, + zoomMotionBlurShutterFraction, + zoomOutDurationMs, + zoomOutEasing, + zoomClassicMode, + } = appearance; + const { + annotationRegions, + autoCaptionSettings, + autoCaptions, + cursorTelemetry, + clipRegions, + speedRegions, + zoomRegions, + } = timeline; + const refreshProjectLibrary = useCallback(async () => { + try { + const result = await window.electronAPI.listProjectFiles(); + if (!result.success) { + throw new Error(result.error || "Failed to load project library"); + } + + setProjectLibraryEntries(result.entries); + } catch (projectLibraryError) { + console.warn("Unable to refresh project library:", projectLibraryError); + } + }, []); + + const captureProjectThumbnail = useCallback(async () => { + const previewHandle = videoPlaybackRef.current; + const previewVideo = previewHandle?.video ?? null; + const previewCanvas = previewHandle?.app?.canvas ?? null; + + if (previewHandle && previewVideo && previewVideo.paused) { + try { + await previewHandle.refreshFrame(); + await new Promise((resolve) => requestAnimationFrame(() => resolve(undefined))); + } catch (thumbnailRefreshError) { + console.warn( + "Unable to refresh preview frame before thumbnail capture:", + thumbnailRefreshError, + ); + } + } + + const canvas = document.createElement("canvas"); + const targetWidth = 320; + const targetHeight = 180; + canvas.width = targetWidth; + canvas.height = targetHeight; + + const context = canvas.getContext("2d"); + if (!context) { + return null; + } + context.imageSmoothingEnabled = true; + context.imageSmoothingQuality = "high"; + const editorBgHsl = getComputedStyle(document.documentElement) + .getPropertyValue("--editor-bg") + .trim(); + context.fillStyle = editorBgHsl ? `hsl(${editorBgHsl})` : "#111113"; + context.fillRect(0, 0, targetWidth, targetHeight); + + const previewWidth = previewHandle?.containerRef.current?.clientWidth || 1920; + const previewHeight = previewHandle?.containerRef.current?.clientHeight || 1080; + const frameTimestampUs = Math.max(0, Math.round(currentTimeRef.current * 1_000_000)); + + if (previewVideo && previewVideo.videoWidth > 0 && previewVideo.videoHeight > 0) { + let videoFrame: VideoFrame | null = null; + let frameRenderer: FrameRenderer | null = null; + + try { + videoFrame = new VideoFrame(previewVideo, { timestamp: frameTimestampUs }); + frameRenderer = new FrameRenderer({ + width: targetWidth, + height: targetHeight, + wallpaper, + zoomRegions, + showShadow: shadowIntensity > 0, + shadowIntensity, + backgroundBlur, + zoomMotionBlur, + zoomMotionBlurTuning, + zoomTemporalMotionBlur, + zoomMotionBlurSampleCount, + zoomMotionBlurShutterFraction, + connectZooms, + zoomInDurationMs, + zoomInOverlapMs, + zoomOutDurationMs, + connectedZoomGapMs, + connectedZoomDurationMs, + zoomInEasing, + zoomOutEasing, + connectedZoomEasing, + borderRadius, + padding, + cropRegion, + webcam, + webcamUrl: + resolvedWebcamVideoUrl ?? + (webcam.sourcePath ? toFileUrl(webcam.sourcePath) : null), + videoWidth: previewVideo.videoWidth, + videoHeight: previewVideo.videoHeight, + annotationRegions, + autoCaptions, + autoCaptionSettings, + speedRegions: (() => { + const clipDerived: SpeedRegion[] = clipRegions + .filter((clip) => clip.speed !== 1) + .map((clip) => ({ + id: `clip-speed-${clip.id}`, + startMs: clip.startMs, + endMs: getClipSourceEndMs(clip), + speed: clip.speed as SpeedRegion["speed"], + })); + if (clipDerived.length === 0) return speedRegions; + const result = [...speedRegions]; + for (const cs of clipDerived) { + const overlaps = speedRegions.some( + (sr) => sr.endMs > cs.startMs && sr.startMs < cs.endMs, + ); + if (!overlaps) { + result.push(cs); + } + } + return result; + })(), + previewWidth, + previewHeight, + cursorTelemetry, + showCursor: effectiveShowCursor, + cursorStyle, + cursorSize, + cursorSmoothing, + cursorSpringStiffnessMultiplier, + cursorSpringDampingMultiplier, + cursorSpringMassMultiplier, + cameraSpringStiffnessMultiplier, + cameraSpringDampingMultiplier, + cameraSpringMassMultiplier, + zoomSmoothness, + zoomClassicMode, + cursorMotionBlur, + cursorClickEffect, + cursorClickEffectColor, + cursorClickEffectScale, + cursorClickEffectOpacity, + cursorClickEffectDurationMs, + cursorClickBounce, + cursorClickBounceDuration, + cursorSway, + }); + await frameRenderer.initialize(); + await frameRenderer.renderFrame(videoFrame, frameTimestampUs); + return frameRenderer.getCanvas().toDataURL("image/png"); + } catch (thumbnailRenderError) { + console.warn( + "Unable to render thumbnail from composed frame:", + thumbnailRenderError, + ); + } finally { + videoFrame?.close(); + frameRenderer?.destroy(); + } + } + + const drawableSource = + previewCanvas && previewCanvas.width > 0 && previewCanvas.height > 0 + ? previewCanvas + : previewVideo && previewVideo.videoWidth > 0 && previewVideo.videoHeight > 0 + ? previewVideo + : null; + + if (!drawableSource) { + return null; + } + + const sourceWidth = + drawableSource instanceof HTMLVideoElement + ? drawableSource.videoWidth + : drawableSource.width; + const sourceHeight = + drawableSource instanceof HTMLVideoElement + ? drawableSource.videoHeight + : drawableSource.height; + + if (sourceWidth <= 0 || sourceHeight <= 0) { + return null; + } + + const scale = Math.min(targetWidth / sourceWidth, targetHeight / sourceHeight); + const drawWidth = Math.round(sourceWidth * scale); + const drawHeight = Math.round(sourceHeight * scale); + const offsetX = Math.round((targetWidth - drawWidth) / 2); + const offsetY = Math.round((targetHeight - drawHeight) / 2); + + try { + context.drawImage(drawableSource, offsetX, offsetY, drawWidth, drawHeight); + return canvas.toDataURL("image/png"); + } catch (thumbnailError) { + console.warn("Unable to capture project thumbnail:", thumbnailError); + return null; + } + }, [ + annotationRegions, + autoCaptionSettings, + autoCaptions, + backgroundBlur, + borderRadius, + connectZooms, + connectedZoomDurationMs, + connectedZoomEasing, + connectedZoomGapMs, + cropRegion, + cursorClickBounce, + cursorClickBounceDuration, + cursorClickEffect, + cursorClickEffectColor, + cursorClickEffectScale, + cursorClickEffectOpacity, + cursorClickEffectDurationMs, + cursorMotionBlur, + cursorSize, + cursorSmoothing, + cursorSpringDampingMultiplier, + cursorSpringMassMultiplier, + cursorSpringStiffnessMultiplier, + cameraSpringStiffnessMultiplier, + cameraSpringDampingMultiplier, + cameraSpringMassMultiplier, + zoomSmoothness, + cursorStyle, + cursorSway, + cursorTelemetry, + clipRegions, + padding, + resolvedWebcamVideoUrl, + shadowIntensity, + effectiveShowCursor, + speedRegions, + wallpaper, + webcam, + zoomInDurationMs, + zoomInEasing, + zoomInOverlapMs, + zoomMotionBlur, + zoomMotionBlurTuning, + zoomTemporalMotionBlur, + zoomMotionBlurSampleCount, + zoomMotionBlurShutterFraction, + zoomOutDurationMs, + zoomOutEasing, + zoomRegions, + zoomClassicMode, + ]); + + return { refreshProjectLibrary, captureProjectThumbnail }; +} diff --git a/src/components/video-editor/project/useProjectLifecycle.ts b/src/components/video-editor/project/useProjectLifecycle.ts new file mode 100644 index 000000000..909f0cfa1 --- /dev/null +++ b/src/components/video-editor/project/useProjectLifecycle.ts @@ -0,0 +1,388 @@ +/* biome-ignore-all lint/correctness/useExhaustiveDependencies: editor domain setters and refs are stable. */ +import { + type Dispatch, + type MutableRefObject, + type RefObject, + type SetStateAction, + useCallback, + useMemo, + useRef, +} from "react"; +import { toast } from "sonner"; +import type { AspectRatio } from "@/utils/aspectRatioUtils"; +import type { useExportSettings } from "../export/useExportSettings"; +import type { UnsavedChangesDecision } from "../layout/EditorDialogs"; +import { + createProjectData, + deriveNextId, + fromFileUrl, + normalizeProjectEditor, + resolveVideoUrl, + stripPersistedDevMotionBlurSettings, + validateProjectData, +} from "../projectPersistence"; +import type { useAppearanceState } from "../state/useAppearanceState"; +import type { useProjectState } from "../state/useProjectState"; +import type { useTimelineState } from "../state/useTimelineState"; +import { DEFAULT_WEBCAM_TIME_OFFSET_MS } from "../types"; +import type { VideoPlaybackRef } from "../VideoPlayback"; +import { cloneStructured } from "../videoEditorUtils"; + +type Input = { + t: (key: string) => string; + project: ReturnType<typeof useProjectState>; + appearance: ReturnType<typeof useAppearanceState>; + timeline: ReturnType<typeof useTimelineState>; + exportSettings: ReturnType<typeof useExportSettings>; + aspectRatio: AspectRatio; + setAspectRatio: Dispatch<SetStateAction<AspectRatio>>; + currentSourcePath: string | null; + currentPersistedEditorState: Parameters<typeof createProjectData>[1]; + videoPlaybackRef: RefObject<VideoPlaybackRef | null>; + setIsPlaying: Dispatch<SetStateAction<boolean>>; + setCurrentTime: Dispatch<SetStateAction<number>>; + setDuration: Dispatch<SetStateAction<number>>; + applySessionPresentation: ( + session: + | { hideOverlayCursorByDefault?: boolean; nativeCaptureUnavailable?: boolean } + | null + | undefined, + ) => void; + refreshProjectLibrary: () => Promise<void>; + buildPersistedEditorState: ( + editor: Parameters<typeof createProjectData>[1], + ) => Parameters<typeof createProjectData>[1]; + resetHistory: () => void; + refs: { + nextZoomIdRef: MutableRefObject<number>; + nextClipIdRef: MutableRefObject<number>; + nextAudioIdRef: MutableRefObject<number>; + nextAnnotationIdRef: MutableRefObject<number>; + nextAnnotationZIndexRef: MutableRefObject<number>; + clipInitializedRef: MutableRefObject<boolean>; + autoFullTrackClipIdRef: MutableRefObject<string | null>; + autoFullTrackClipEndMsRef: MutableRefObject<number | null>; + pendingFreshRecordingAutoZoomPathRef: MutableRefObject<string | null>; + pendingFreshRecordingAutoSuggestTelemetryCountRef: MutableRefObject<number>; + autoSuggestedVideoPathRef: MutableRefObject<string | null>; + }; +}; + +export function useProjectLifecycle(input: Input) { + const { project, appearance } = input; + const inputRef = useRef(input); + inputRef.current = input; + const pendingSaveDialogRef = useRef<{ resolve(saved: boolean): void } | null>(null); + const pendingUnsavedDialogRef = useRef<{ + resolve(decision: UnsavedChangesDecision): void; + } | null>(null); + + const applyLoadedProject = useCallback(async (candidate: unknown, path?: string | null) => { + const current = inputRef.current; + const { project, appearance, timeline, exportSettings, refs } = current; + if (!validateProjectData(candidate)) return false; + const loadedProject = candidate; + const sourcePath = fromFileUrl(loadedProject.videoPath); + const editor = normalizeProjectEditor( + stripPersistedDevMotionBlurSettings(loadedProject.editor ?? {}), + ); + try { + current.videoPlaybackRef.current?.pause(); + } catch { + /* preview may be tearing down */ + } + current.setIsPlaying(false); + current.setCurrentTime(0); + current.setDuration(0); + project.setError(null); + project.setVideoSourcePath(sourcePath); + project.setCurrentProjectPath(path ?? null); + refs.pendingFreshRecordingAutoZoomPathRef.current = null; + if (editor.webcam.sourcePath) { + await window.electronAPI.setCurrentRecordingSession?.( + { + videoPath: sourcePath, + webcamPath: editor.webcam.sourcePath, + timeOffsetMs: editor.webcam.timeOffsetMs, + }, + { preserveProjectPath: Boolean(path) }, + ); + const session = await window.electronAPI.getCurrentRecordingSession?.(); + current.applySessionPresentation(session?.success ? session.session : null); + } else { + await window.electronAPI.setCurrentVideoPath(sourcePath, { + preserveProjectPath: Boolean(path), + }); + current.applySessionPresentation(null); + } + project.setVideoPath(await resolveVideoUrl(sourcePath)); + + appearance.setWallpaper(editor.wallpaper); + appearance.setShadowIntensity(editor.shadowIntensity); + appearance.setBackgroundBlur(editor.backgroundBlur); + appearance.setZoomMotionBlur(editor.zoomMotionBlur); + appearance.setZoomMotionBlurTuning({ ...editor.zoomMotionBlurTuning }); + appearance.setZoomTemporalMotionBlur(editor.zoomTemporalMotionBlur); + appearance.setZoomMotionBlurSampleCount(editor.zoomMotionBlurSampleCount); + appearance.setZoomMotionBlurShutterFraction(editor.zoomMotionBlurShutterFraction); + appearance.setConnectZooms(editor.connectZooms); + appearance.setZoomInDurationMs(editor.zoomInDurationMs); + appearance.setZoomInOverlapMs(editor.zoomInOverlapMs); + appearance.setZoomOutDurationMs(editor.zoomOutDurationMs); + appearance.setConnectedZoomGapMs(editor.connectedZoomGapMs); + appearance.setConnectedZoomDurationMs(editor.connectedZoomDurationMs); + appearance.setZoomInEasing(editor.zoomInEasing); + appearance.setZoomOutEasing(editor.zoomOutEasing); + appearance.setConnectedZoomEasing(editor.connectedZoomEasing); + appearance.setShowCursor(editor.showCursor); + appearance.setLoopCursor(editor.loopCursor); + appearance.setCursorStyle(editor.cursorStyle); + appearance.setCursorSize(editor.cursorSize); + appearance.setCursorSmoothing(editor.cursorSmoothing); + appearance.setCursorSpringStiffnessMultiplier(editor.cursorSpringStiffnessMultiplier); + appearance.setCursorSpringDampingMultiplier(editor.cursorSpringDampingMultiplier); + appearance.setCursorSpringMassMultiplier(editor.cursorSpringMassMultiplier); + appearance.setCameraSpringStiffnessMultiplier(editor.cameraSpringStiffnessMultiplier); + appearance.setCameraSpringDampingMultiplier(editor.cameraSpringDampingMultiplier); + appearance.setCameraSpringMassMultiplier(editor.cameraSpringMassMultiplier); + appearance.setCursorClickEffect(editor.cursorClickEffect); + appearance.setCursorClickEffectColor(editor.cursorClickEffectColor); + appearance.setCursorClickEffectScale(editor.cursorClickEffectScale); + appearance.setCursorClickEffectOpacity(editor.cursorClickEffectOpacity); + appearance.setCursorClickEffectDurationMs(editor.cursorClickEffectDurationMs); + appearance.setZoomSmoothness(editor.zoomSmoothness); + appearance.setZoomClassicMode(editor.zoomClassicMode); + appearance.setCursorMotionBlur(editor.cursorMotionBlur); + appearance.setCursorClickBounce(editor.cursorClickBounce); + appearance.setCursorClickBounceDuration(editor.cursorClickBounceDuration); + appearance.setCursorSway(editor.cursorSway); + appearance.setBorderRadius(editor.borderRadius); + appearance.setPadding(editor.padding); + appearance.setCropRegion(editor.cropRegion); + appearance.setWebcam(editor.webcam); + timeline.setZoomRegions(editor.zoomRegions); + timeline.setTrimRegions(editor.trimRegions); + timeline.setClipRegions(editor.clipRegions); + refs.clipInitializedRef.current = editor.clipRegions.length > 0; + refs.autoFullTrackClipIdRef.current = null; + refs.autoFullTrackClipEndMsRef.current = null; + timeline.setSpeedRegions(editor.speedRegions); + timeline.setAnnotationRegions(editor.annotationRegions); + timeline.setAudioRegions(editor.audioRegions); + timeline.setSourceAudioTrackSettingsByClip(editor.sourceAudioTrackSettingsByClip ?? {}); + timeline.setDefaultSourceAudioTrackSettings(editor.defaultSourceAudioTrackSettings ?? {}); + timeline.setAutoCaptions(editor.autoCaptions); + timeline.setAutoCaptionSettings(editor.autoCaptionSettings); + current.setAspectRatio(editor.aspectRatio); + exportSettings.setExportEncodingMode(editor.exportEncodingMode); + exportSettings.setExportBackendPreference(editor.exportBackendPreference); + exportSettings.setExportPipelineModel(editor.exportPipelineModel); + exportSettings.setExportQuality(editor.exportQuality); + exportSettings.setMp4FrameRate(editor.mp4FrameRate); + exportSettings.setExportFormat(editor.exportFormat); + exportSettings.setGifFrameRate(editor.gifFrameRate); + exportSettings.setGifLoop(editor.gifLoop); + exportSettings.setGifSizePreset(editor.gifSizePreset); + timeline.setSelectedZoomId(null); + timeline.setSelectedClipId(null); + timeline.setSelectedAnnotationId(null); + timeline.setSelectedAudioId(null); + refs.nextZoomIdRef.current = deriveNextId( + "zoom", + editor.zoomRegions.map(({ id }) => id), + ); + refs.nextClipIdRef.current = deriveNextId( + "clip", + editor.clipRegions.map(({ id }) => id), + ); + refs.nextAudioIdRef.current = deriveNextId( + "audio", + editor.audioRegions.map(({ id }) => id), + ); + refs.nextAnnotationIdRef.current = deriveNextId( + "annotation", + editor.annotationRegions.map(({ id }) => id), + ); + refs.nextAnnotationZIndexRef.current = + editor.annotationRegions.reduce((max, region) => Math.max(max, region.zIndex), 0) + 1; + current.resetHistory(); + project.setLastSavedSnapshot( + cloneStructured( + createProjectData( + sourcePath, + current.buildPersistedEditorState(editor), + loadedProject.projectId ?? null, + ), + ), + ); + await current.refreshProjectLibrary(); + return true; + }, []); + + const currentProjectSnapshot = useMemo( + () => + input.currentSourcePath + ? createProjectData( + input.currentSourcePath, + input.currentPersistedEditorState, + project.lastSavedSnapshot?.projectId ?? null, + ) + : null, + [ + input.currentSourcePath, + input.currentPersistedEditorState, + project.lastSavedSnapshot?.projectId, + ], + ); + const resolveProjectSaveDialog = useCallback( + (saved: boolean) => { + const pending = pendingSaveDialogRef.current; + pendingSaveDialogRef.current = null; + project.setProjectSaveDialogOpen(false); + project.setIsSavingProjectDialog(false); + pending?.resolve(saved); + }, + [project.setProjectSaveDialogOpen, project.setIsSavingProjectDialog], + ); + const openProjectSaveDialog = useCallback( + (initialName: string) => { + pendingSaveDialogRef.current?.resolve(false); + project.setProjectSaveDialogDraft(initialName); + project.setProjectSaveDialogOpen(true); + project.setIsSavingProjectDialog(false); + return new Promise<boolean>((resolve) => { + pendingSaveDialogRef.current = { resolve }; + }); + }, + [ + project.setProjectSaveDialogDraft, + project.setProjectSaveDialogOpen, + project.setIsSavingProjectDialog, + ], + ); + const resolveUnsavedChangesDialog = useCallback( + (decision: UnsavedChangesDecision) => { + const pending = pendingUnsavedDialogRef.current; + pendingUnsavedDialogRef.current = null; + project.setUnsavedChangesDialogOpen(false); + pending?.resolve(decision); + }, + [project.setUnsavedChangesDialogOpen], + ); + const openUnsavedChangesDialog = useCallback( + (actionLabel: string) => { + pendingUnsavedDialogRef.current?.resolve("cancel"); + project.setUnsavedChangesDialogActionLabel(actionLabel); + project.setUnsavedChangesDialogOpen(true); + return new Promise<UnsavedChangesDecision>((resolve) => { + pendingUnsavedDialogRef.current = { resolve }; + }); + }, + [project.setUnsavedChangesDialogActionLabel, project.setUnsavedChangesDialogOpen], + ); + + const syncRecordingSessionWebcam = useCallback( + async (webcamPath: string | null, offset?: number) => { + if (!input.currentSourcePath || !window.electronAPI.setCurrentRecordingSession) return; + await window.electronAPI.setCurrentRecordingSession( + { + videoPath: input.currentSourcePath, + webcamPath, + timeOffsetMs: + webcamPath && Number.isFinite(offset) + ? (offset ?? DEFAULT_WEBCAM_TIME_OFFSET_MS) + : webcamPath + ? appearance.webcam.timeOffsetMs + : DEFAULT_WEBCAM_TIME_OFFSET_MS, + }, + { preserveProjectPath: Boolean(project.currentProjectPath) }, + ); + }, + [input.currentSourcePath, project.currentProjectPath, appearance.webcam.timeOffsetMs], + ); + const syncActiveVideoSource = useCallback( + async (sourcePath: string, webcamPath?: string | null) => { + if (webcamPath) { + await window.electronAPI.setCurrentRecordingSession?.( + { + videoPath: sourcePath, + webcamPath, + timeOffsetMs: appearance.webcam.timeOffsetMs, + }, + { preserveProjectPath: Boolean(project.currentProjectPath) }, + ); + } else + await window.electronAPI.setCurrentVideoPath(sourcePath, { + preserveProjectPath: Boolean(project.currentProjectPath), + }); + }, + [appearance.webcam.timeOffsetMs, project.currentProjectPath], + ); + const resetSourceScopedEditorState = useCallback(() => { + const current = inputRef.current; + const { timeline, refs } = current; + timeline.setZoomRegions([]); + timeline.setTrimRegions([]); + timeline.setClipRegions([]); + refs.clipInitializedRef.current = false; + refs.autoFullTrackClipIdRef.current = null; + refs.autoFullTrackClipEndMsRef.current = null; + timeline.setSpeedRegions([]); + timeline.setAnnotationRegions([]); + timeline.setAudioRegions([]); + timeline.setCursorTelemetry([]); + timeline.setCursorTelemetrySourcePath(null); + timeline.setSourceAudioTrackSettingsByClip({}); + timeline.setDefaultSourceAudioTrackSettings({}); + timeline.setHasClipSourceAudio(false); + timeline.setAutoCaptions([]); + timeline.setAutoCaptionSettings((previous) => ({ ...previous, enabled: false })); + timeline.setSelectedZoomId(null); + timeline.setSelectedClipId(null); + timeline.setSelectedAnnotationId(null); + timeline.setSelectedAudioId(null); + refs.nextZoomIdRef.current = 1; + refs.nextClipIdRef.current = 1; + refs.nextAudioIdRef.current = 1; + refs.nextAnnotationIdRef.current = 1; + refs.nextAnnotationZIndexRef.current = 1; + refs.pendingFreshRecordingAutoSuggestTelemetryCountRef.current = 0; + refs.autoSuggestedVideoPathRef.current = null; + current.resetHistory(); + }, []); + const handleUploadWebcam = useCallback(async () => { + const result = await window.electronAPI.openVideoFilePicker(); + if (!result.success || !result.path) return; + appearance.setWebcam((previous) => ({ + ...previous, + enabled: true, + sourcePath: result.path ?? null, + timeOffsetMs: DEFAULT_WEBCAM_TIME_OFFSET_MS, + })); + await syncRecordingSessionWebcam(result.path, DEFAULT_WEBCAM_TIME_OFFSET_MS); + toast.success(input.t("settings.effects.webcamFootageAdded")); + }, [appearance.setWebcam, syncRecordingSessionWebcam, input.t]); + const handleClearWebcam = useCallback(async () => { + appearance.setWebcam((previous) => ({ + ...previous, + enabled: false, + sourcePath: null, + timeOffsetMs: DEFAULT_WEBCAM_TIME_OFFSET_MS, + })); + await syncRecordingSessionWebcam(null); + toast.success(input.t("settings.effects.webcamFootageRemoved")); + }, [appearance.setWebcam, syncRecordingSessionWebcam, input.t]); + + return { + applyLoadedProject, + currentProjectSnapshot, + resolveProjectSaveDialog, + openProjectSaveDialog, + resolveUnsavedChangesDialog, + openUnsavedChangesDialog, + syncActiveVideoSource, + resetSourceScopedEditorState, + handleUploadWebcam, + handleClearWebcam, + }; +} diff --git a/src/components/video-editor/project/useProjectOpenActions.ts b/src/components/video-editor/project/useProjectOpenActions.ts new file mode 100644 index 000000000..127e5ec54 --- /dev/null +++ b/src/components/video-editor/project/useProjectOpenActions.ts @@ -0,0 +1,181 @@ +import { + type Dispatch, + type MutableRefObject, + type RefObject, + type SetStateAction, + useCallback, + useEffect, +} from "react"; +import { toast } from "sonner"; +import { fromFileUrl, resolveVideoUrl } from "../projectPersistence"; +import type { useAppearanceState } from "../state/useAppearanceState"; +import type { useProjectState } from "../state/useProjectState"; +import { DEFAULT_WEBCAM_TIME_OFFSET_MS } from "../types"; +import type { VideoPlaybackRef } from "../VideoPlayback"; + +type Set<T> = Dispatch<SetStateAction<T>>; + +type UseProjectOpenActionsInput = { + project: ReturnType<typeof useProjectState>; + appearance: ReturnType<typeof useAppearanceState>; + videoPlaybackRef: RefObject<VideoPlaybackRef | null>; + pendingFreshRecordingAutoZoomPathRef: MutableRefObject<string | null>; + hasUnsavedChanges: boolean; + setIsPlaying: Set<boolean>; + setCurrentTime: Set<number>; + setDuration: Set<number>; + applyLoadedProject: (candidate: unknown, path?: string | null) => Promise<boolean>; + openUnsavedChangesDialog: (actionLabel: string) => Promise<"save" | "discard" | "cancel">; + saveProject: (forceSaveAs: boolean) => Promise<boolean>; + refreshProjectLibrary: () => Promise<void>; + resetSourceScopedEditorState: () => void; + applySessionPresentation: (session: null) => void; + handleSaveProject: () => Promise<unknown>; + handleSaveProjectAs: () => Promise<unknown>; +}; + +export function useProjectOpenActions({ + project, + appearance, + videoPlaybackRef, + pendingFreshRecordingAutoZoomPathRef, + hasUnsavedChanges, + setIsPlaying, + setCurrentTime, + setDuration, + applyLoadedProject, + openUnsavedChangesDialog, + saveProject, + refreshProjectLibrary, + resetSourceScopedEditorState, + applySessionPresentation, + handleSaveProject, + handleSaveProjectAs, +}: UseProjectOpenActionsInput) { + const confirmReplaceSourceWithUnsavedChanges = useCallback( + async (actionLabel: string) => { + if (!hasUnsavedChanges) return true; + const decision = await openUnsavedChangesDialog(actionLabel); + if (decision === "discard") return true; + if (decision === "save") return saveProject(false); + return false; + }, + [hasUnsavedChanges, openUnsavedChangesDialog, saveProject], + ); + + const handleOpenProjectFromLibrary = useCallback( + async (projectPath: string) => { + if (!(await confirmReplaceSourceWithUnsavedChanges("open another project"))) return; + const result = await window.electronAPI.openProjectFileAtPath(projectPath); + if (result.canceled) return; + if (!result.success) { + toast.error(result.message || "Failed to load project"); + return; + } + if (!(await applyLoadedProject(result.project, result.path ?? null))) { + toast.error("Invalid project file format"); + return; + } + project.setProjectBrowserOpen(false); + await refreshProjectLibrary(); + toast.success(`Project loaded from ${result.path}`); + }, + [ + applyLoadedProject, + confirmReplaceSourceWithUnsavedChanges, + project, + refreshProjectLibrary, + ], + ); + + const handleImportMediaOrProject = useCallback(async () => { + if (!(await confirmReplaceSourceWithUnsavedChanges("import a file"))) return; + const result = await window.electronAPI.openVideoFilePicker({ includeProjects: true }); + if (result.canceled) return; + if (!result.success) { + toast.error(result.message || "Failed to import file"); + return; + } + if (result.kind === "project" || result.project) { + if (!(await applyLoadedProject(result.project, result.path ?? null))) { + toast.error("Invalid project file format"); + return; + } + project.setProjectBrowserOpen(false); + await refreshProjectLibrary(); + toast.success(result.path ? `Project loaded from ${result.path}` : "Project loaded"); + return; + } + if (!result.path) { + toast.error("No media file selected"); + return; + } + + const sourcePath = fromFileUrl(result.path); + await window.electronAPI.setCurrentVideoPath(sourcePath, { preserveProjectPath: false }); + const sourceVideoUrl = await resolveVideoUrl(sourcePath); + try { + videoPlaybackRef.current?.pause(); + } catch { + // The preview may already be tearing down. + } + setIsPlaying(false); + setCurrentTime(0); + setDuration(0); + project.setVideoSourcePath(sourcePath); + project.setVideoPath(sourceVideoUrl); + project.setCurrentProjectPath(null); + project.setLastSavedSnapshot(null); + resetSourceScopedEditorState(); + pendingFreshRecordingAutoZoomPathRef.current = appearance.autoApplyFreshRecordingAutoZooms + ? sourceVideoUrl + : null; + appearance.setWebcam((previous) => ({ + ...previous, + enabled: false, + sourcePath: null, + timeOffsetMs: DEFAULT_WEBCAM_TIME_OFFSET_MS, + })); + applySessionPresentation(null); + project.setProjectBrowserOpen(false); + await refreshProjectLibrary(); + toast.success("Media imported"); + }, [ + confirmReplaceSourceWithUnsavedChanges, + applyLoadedProject, + project, + appearance, + videoPlaybackRef, + setIsPlaying, + setCurrentTime, + setDuration, + resetSourceScopedEditorState, + pendingFreshRecordingAutoZoomPathRef, + applySessionPresentation, + refreshProjectLibrary, + ]); + + const handleOpenProjectBrowser = useCallback(() => { + if (project.projectBrowserOpen) { + project.setProjectBrowserOpen(false); + return; + } + project.setProjectBrowserOpen(true); + void refreshProjectLibrary(); + }, [project.projectBrowserOpen, project.setProjectBrowserOpen, refreshProjectLibrary]); + + useEffect(() => { + const removeLoad = window.electronAPI.onMenuLoadProject( + () => void handleOpenProjectBrowser(), + ); + const removeSave = window.electronAPI.onMenuSaveProject(handleSaveProject); + const removeSaveAs = window.electronAPI.onMenuSaveProjectAs(handleSaveProjectAs); + return () => { + removeLoad?.(); + removeSave?.(); + removeSaveAs?.(); + }; + }, [handleOpenProjectBrowser, handleSaveProject, handleSaveProjectAs]); + + return { handleOpenProjectFromLibrary, handleImportMediaOrProject, handleOpenProjectBrowser }; +} diff --git a/src/components/video-editor/project/useProjectSaveActions.ts b/src/components/video-editor/project/useProjectSaveActions.ts new file mode 100644 index 000000000..bf1c51b27 --- /dev/null +++ b/src/components/video-editor/project/useProjectSaveActions.ts @@ -0,0 +1,337 @@ +import { type RefObject, useCallback, useEffect, useRef } from "react"; +import { toast } from "sonner"; +import { createProjectData, type EditorProjectData } from "../projectPersistence"; +import type { useProjectState } from "../state/useProjectState"; +import { cloneStructured, getErrorMessage } from "../videoEditorUtils"; + +const PROJECT_AUTOSAVE_DELAY_MS = 1_000; + +type SaveProjectOptions = { + silent?: boolean; + remountPreviewAfterSave?: boolean; + refreshLibraryAfterSave?: boolean; + captureThumbnail?: boolean; +}; + +type UseProjectSaveActionsInput = { + project: ReturnType<typeof useProjectState>; + currentSourcePath: string | null; + currentProjectSnapshot: EditorProjectData | null; + currentPersistedEditorState: Parameters<typeof createProjectData>[1]; + projectDisplayName: string; + hasUnsavedChanges: boolean; + projectSaveDialogInputRef: RefObject<HTMLInputElement | null>; + projectNameInputRef: RefObject<HTMLInputElement | null>; + openProjectSaveDialog: (initialName: string) => Promise<boolean>; + resolveProjectSaveDialog: (saved: boolean) => void; + captureProjectThumbnail: () => Promise<string | null>; + refreshProjectLibrary: () => Promise<void>; + remountPreview: () => void; +}; + +export function useProjectSaveActions({ + project, + currentSourcePath, + currentProjectSnapshot, + currentPersistedEditorState, + projectDisplayName, + hasUnsavedChanges, + projectSaveDialogInputRef, + projectNameInputRef, + openProjectSaveDialog, + resolveProjectSaveDialog, + captureProjectThumbnail, + refreshProjectLibrary, + remountPreview, +}: UseProjectSaveActionsInput) { + const { + currentProjectPath, + lastSavedSnapshot, + projectSaveDialogDraft, + projectNameDraft, + setCurrentProjectPath, + setLastSavedSnapshot, + setIsSavingProjectDialog, + setProjectNameDraft, + setIsEditingProjectName, + setIsSavingProjectName, + setProjectBrowserOpen, + } = project; + const autosaveTimeoutRef = useRef<number | null>(null); + const saveQueueRef = useRef<Promise<unknown>>(Promise.resolve()); + const clearPendingAutosave = useCallback(() => { + if (autosaveTimeoutRef.current !== null) { + window.clearTimeout(autosaveTimeoutRef.current); + autosaveTimeoutRef.current = null; + } + }, []); + const queueSave = useCallback((task: () => Promise<boolean>) => { + const run = saveQueueRef.current.catch(() => undefined).then(task); + saveQueueRef.current = run.catch(() => undefined); + return run; + }, []); + + const saveProject = useCallback( + async (forceSaveAs: boolean, options?: SaveProjectOptions) => { + clearPendingAutosave(); + return queueSave(async () => { + if (!currentSourcePath) { + if (!options?.silent) toast.error("No video loaded"); + return false; + } + + const captureThumbnail = options?.captureThumbnail ?? true; + const refreshLibrary = options?.refreshLibraryAfterSave ?? true; + const remount = options?.remountPreviewAfterSave ?? true; + try { + const projectData = + currentProjectSnapshot?.videoPath === currentSourcePath + ? currentProjectSnapshot + : createProjectData( + currentSourcePath, + currentPersistedEditorState, + lastSavedSnapshot?.projectId ?? null, + ); + const fileNameBase = + currentSourcePath + .split(/[\\/]/) + .pop() + ?.replace(/\.[^.]+$/, "") || `project-${Date.now()}`; + let targetPath = forceSaveAs ? undefined : (currentProjectPath ?? undefined); + + if (!forceSaveAs && !targetPath) { + const activeProject = await window.electronAPI.loadCurrentProjectFile(); + if (activeProject.success && activeProject.path) { + targetPath = activeProject.path; + setCurrentProjectPath(activeProject.path); + } + } + if (forceSaveAs || !targetPath) { + if (options?.silent) return false; + return await openProjectSaveDialog(projectDisplayName || fileNameBase); + } + + const thumbnail = captureThumbnail + ? await captureProjectThumbnail() + : undefined; + const result = await window.electronAPI.saveProjectFile( + projectData, + fileNameBase, + targetPath, + thumbnail, + ); + if (result.canceled) { + if (!options?.silent) toast.info("Project save canceled"); + return false; + } + if (!result.success) { + if (!options?.silent) + toast.error(result.message || "Failed to save project"); + return false; + } + + if (result.path) setCurrentProjectPath(result.path); + setLastSavedSnapshot( + cloneStructured( + createProjectData( + projectData.videoPath, + projectData.editor, + result.projectId ?? projectData.projectId ?? null, + ), + ), + ); + if (refreshLibrary) await refreshProjectLibrary(); + if (!options?.silent) toast.success(`Project saved to ${result.path}`); + return true; + } finally { + if (remount) remountPreview(); + } + }); + }, + [ + clearPendingAutosave, + queueSave, + currentSourcePath, + currentProjectSnapshot, + currentPersistedEditorState, + currentProjectPath, + lastSavedSnapshot, + setCurrentProjectPath, + setLastSavedSnapshot, + openProjectSaveDialog, + projectDisplayName, + captureProjectThumbnail, + refreshProjectLibrary, + remountPreview, + ], + ); + + useEffect(() => { + window.electronAPI.setHasUnsavedChanges(hasUnsavedChanges); + }, [hasUnsavedChanges]); + useEffect( + () => window.electronAPI.onRequestSaveBeforeClose(() => saveProject(false)), + [saveProject], + ); + useEffect(() => { + if (!currentProjectPath || !hasUnsavedChanges) { + clearPendingAutosave(); + return; + } + autosaveTimeoutRef.current = window.setTimeout(() => { + autosaveTimeoutRef.current = null; + void saveProject(false, { + silent: true, + remountPreviewAfterSave: false, + refreshLibraryAfterSave: false, + captureThumbnail: false, + }); + }, PROJECT_AUTOSAVE_DELAY_MS); + return clearPendingAutosave; + }, [clearPendingAutosave, currentProjectPath, hasUnsavedChanges, saveProject]); + useEffect(() => clearPendingAutosave, [clearPendingAutosave]); + + const saveProjectWithName = useCallback( + async (name: string, mode: "rename" | "copy" = "rename") => { + const trimmedName = name.trim(); + if (!trimmedName) { + toast.error("Project name is required"); + return false; + } + if (!currentSourcePath) { + toast.error("No video loaded"); + return false; + } + try { + const projectData = + currentProjectSnapshot?.videoPath === currentSourcePath + ? currentProjectSnapshot + : createProjectData( + currentSourcePath, + currentPersistedEditorState, + lastSavedSnapshot?.projectId ?? null, + ); + const result = await window.electronAPI.saveProjectFileNamed( + projectData, + trimmedName, + await captureProjectThumbnail(), + mode, + ); + if (result.canceled) { + toast.info("Project save canceled"); + return false; + } + if (!result.success) { + toast.error(result.message || "Failed to save project"); + return false; + } + if (result.path) setCurrentProjectPath(result.path); + setLastSavedSnapshot( + cloneStructured( + createProjectData( + projectData.videoPath, + projectData.editor, + result.projectId ?? projectData.projectId ?? null, + ), + ), + ); + await refreshProjectLibrary(); + toast.success(result.path ? `Project saved to ${result.path}` : "Project saved"); + return true; + } finally { + remountPreview(); + } + }, + [ + currentSourcePath, + currentProjectSnapshot, + currentPersistedEditorState, + lastSavedSnapshot, + setCurrentProjectPath, + setLastSavedSnapshot, + captureProjectThumbnail, + refreshProjectLibrary, + remountPreview, + ], + ); + + const handleProjectSaveDialogSubmit = useCallback( + async (event?: React.FormEvent<HTMLFormElement>) => { + event?.preventDefault(); + const name = projectSaveDialogDraft.trim(); + if (!name) { + toast.error("Project name is required"); + projectSaveDialogInputRef.current?.focus(); + return; + } + setIsSavingProjectDialog(true); + let saved = false; + try { + saved = await saveProjectWithName(name, "copy"); + } catch (error) { + toast.error(getErrorMessage(error)); + } finally { + setIsSavingProjectDialog(false); + } + if (saved) resolveProjectSaveDialog(true); + else { + projectSaveDialogInputRef.current?.focus(); + projectSaveDialogInputRef.current?.select(); + } + }, + [ + projectSaveDialogDraft, + setIsSavingProjectDialog, + projectSaveDialogInputRef, + resolveProjectSaveDialog, + saveProjectWithName, + ], + ); + + const closeProjectNameEditor = useCallback(() => { + setProjectNameDraft(projectDisplayName); + setIsEditingProjectName(false); + }, [setProjectNameDraft, setIsEditingProjectName, projectDisplayName]); + const handleProjectNameSubmit = useCallback( + async (event?: React.FormEvent<HTMLFormElement>) => { + event?.preventDefault(); + const name = projectNameDraft.trim(); + if (!name) return closeProjectNameEditor(); + setIsSavingProjectName(true); + let saved = false; + try { + saved = await saveProjectWithName(name, "rename"); + } catch (error) { + toast.error(getErrorMessage(error)); + } finally { + setIsSavingProjectName(false); + } + if (saved) setIsEditingProjectName(false); + else { + projectNameInputRef.current?.focus(); + projectNameInputRef.current?.select(); + } + }, + [ + projectNameDraft, + setIsSavingProjectName, + setIsEditingProjectName, + projectNameInputRef, + closeProjectNameEditor, + saveProjectWithName, + ], + ); + const handleSaveProject = useCallback(() => saveProject(false), [saveProject]); + const handleSaveProjectAs = useCallback(async () => { + if (await saveProject(true)) setProjectBrowserOpen(false); + }, [saveProject, setProjectBrowserOpen]); + + return { + saveProject, + handleSaveProject, + handleSaveProjectAs, + handleProjectSaveDialogSubmit, + closeProjectNameEditor, + handleProjectNameSubmit, + }; +} diff --git a/src/components/video-editor/project/useProjectSnapshotModel.ts b/src/components/video-editor/project/useProjectSnapshotModel.ts new file mode 100644 index 000000000..9031a54c0 --- /dev/null +++ b/src/components/video-editor/project/useProjectSnapshotModel.ts @@ -0,0 +1,215 @@ +import { type RefObject, useCallback, useEffect, useMemo } from "react"; +import type { AspectRatio } from "@/utils/aspectRatioUtils"; +import type { useExportSettings } from "../export/useExportSettings"; +import { + fromFileUrl, + type ProjectEditorState, + stripPersistedDevMotionBlurSettings, +} from "../projectPersistence"; +import type { useAppearanceState } from "../state/useAppearanceState"; +import type { useProjectState } from "../state/useProjectState"; +import type { useTimelineState } from "../state/useTimelineState"; + +type Input = { + t: (key: string, fallback?: string) => string; + project: ReturnType<typeof useProjectState>; + appearance: ReturnType<typeof useAppearanceState>; + timeline: ReturnType<typeof useTimelineState>; + exportSettings: ReturnType<typeof useExportSettings>; + aspectRatio: AspectRatio; + projectNameInputRef: RefObject<HTMLInputElement | null>; + projectSaveDialogInputRef: RefObject<HTMLInputElement | null>; +}; + +export function useProjectSnapshotModel({ + t, + project, + appearance, + timeline, + exportSettings, + aspectRatio, + projectNameInputRef, + projectSaveDialogInputRef, +}: Input) { + const buildPersistedEditorState = useCallback( + (editor: Partial<ProjectEditorState>) => stripPersistedDevMotionBlurSettings(editor), + [], + ); + const currentSourcePath = useMemo( + () => + project.videoSourcePath ?? (project.videoPath ? fromFileUrl(project.videoPath) : null), + [project.videoPath, project.videoSourcePath], + ); + const projectDisplayName = useMemo(() => { + const fileName = + project.currentProjectPath?.split(/[\\/]/).pop() ?? + currentSourcePath?.split(/[\\/]/).pop() ?? + ""; + return ( + fileName.replace(/\.recordly$/i, "").replace(/\.[^.]+$/, "") || + t("editor.project.untitled", "Untitled") + ); + }, [project.currentProjectPath, currentSourcePath, t]); + + useEffect(() => { + if (!project.isEditingProjectName) project.setProjectNameDraft(projectDisplayName); + }, [project.isEditingProjectName, project.setProjectNameDraft, projectDisplayName]); + useEffect(() => { + if (!project.isEditingProjectName) return; + const frameId = window.requestAnimationFrame(() => { + projectNameInputRef.current?.focus(); + projectNameInputRef.current?.select(); + }); + return () => window.cancelAnimationFrame(frameId); + }, [project.isEditingProjectName, projectNameInputRef]); + useEffect(() => { + if (!project.projectSaveDialogOpen) return; + const frameId = window.requestAnimationFrame(() => { + projectSaveDialogInputRef.current?.focus(); + projectSaveDialogInputRef.current?.select(); + }); + return () => window.cancelAnimationFrame(frameId); + }, [project.projectSaveDialogOpen, projectSaveDialogInputRef]); + + const currentPersistedEditorState = useMemo( + () => + buildPersistedEditorState({ + wallpaper: appearance.wallpaper, + shadowIntensity: appearance.shadowIntensity, + backgroundBlur: appearance.backgroundBlur, + zoomMotionBlur: appearance.zoomMotionBlur, + zoomMotionBlurTuning: appearance.zoomMotionBlurTuning, + zoomTemporalMotionBlur: appearance.zoomTemporalMotionBlur, + zoomMotionBlurSampleCount: appearance.zoomMotionBlurSampleCount, + zoomMotionBlurShutterFraction: appearance.zoomMotionBlurShutterFraction, + connectZooms: appearance.connectZooms, + zoomInDurationMs: appearance.zoomInDurationMs, + zoomInOverlapMs: appearance.zoomInOverlapMs, + zoomOutDurationMs: appearance.zoomOutDurationMs, + connectedZoomGapMs: appearance.connectedZoomGapMs, + connectedZoomDurationMs: appearance.connectedZoomDurationMs, + zoomInEasing: appearance.zoomInEasing, + zoomOutEasing: appearance.zoomOutEasing, + connectedZoomEasing: appearance.connectedZoomEasing, + showCursor: appearance.showCursor, + loopCursor: appearance.loopCursor, + cursorStyle: appearance.cursorStyle, + cursorSize: appearance.cursorSize, + cursorSmoothing: appearance.cursorSmoothing, + cursorSpringStiffnessMultiplier: appearance.cursorSpringStiffnessMultiplier, + cursorSpringDampingMultiplier: appearance.cursorSpringDampingMultiplier, + cursorSpringMassMultiplier: appearance.cursorSpringMassMultiplier, + cameraSpringStiffnessMultiplier: appearance.cameraSpringStiffnessMultiplier, + cameraSpringDampingMultiplier: appearance.cameraSpringDampingMultiplier, + cameraSpringMassMultiplier: appearance.cameraSpringMassMultiplier, + zoomSmoothness: appearance.zoomSmoothness, + zoomClassicMode: appearance.zoomClassicMode, + cursorMotionBlur: appearance.cursorMotionBlur, + cursorClickEffect: appearance.cursorClickEffect, + cursorClickEffectColor: appearance.cursorClickEffectColor, + cursorClickEffectScale: appearance.cursorClickEffectScale, + cursorClickEffectOpacity: appearance.cursorClickEffectOpacity, + cursorClickEffectDurationMs: appearance.cursorClickEffectDurationMs, + cursorClickBounce: appearance.cursorClickBounce, + cursorClickBounceDuration: appearance.cursorClickBounceDuration, + cursorSway: appearance.cursorSway, + borderRadius: appearance.borderRadius, + padding: appearance.padding, + cropRegion: appearance.cropRegion, + webcam: appearance.webcam, + zoomRegions: timeline.zoomRegions, + trimRegions: timeline.trimRegions, + clipRegions: timeline.clipRegions, + speedRegions: timeline.speedRegions, + annotationRegions: timeline.annotationRegions, + audioRegions: timeline.audioRegions, + autoCaptions: timeline.autoCaptions, + autoCaptionSettings: timeline.autoCaptionSettings, + aspectRatio, + exportEncodingMode: exportSettings.exportEncodingMode, + exportBackendPreference: exportSettings.exportBackendPreference, + exportPipelineModel: exportSettings.exportPipelineModel, + exportQuality: exportSettings.exportQuality, + mp4FrameRate: exportSettings.mp4FrameRate, + exportFormat: exportSettings.exportFormat, + gifFrameRate: exportSettings.gifFrameRate, + gifLoop: exportSettings.gifLoop, + gifSizePreset: exportSettings.gifSizePreset, + sourceAudioTrackSettingsByClip: timeline.sourceAudioTrackSettingsByClip, + defaultSourceAudioTrackSettings: timeline.defaultSourceAudioTrackSettings, + }), + [ + appearance.wallpaper, + appearance.shadowIntensity, + appearance.backgroundBlur, + appearance.zoomMotionBlur, + appearance.zoomMotionBlurTuning, + appearance.zoomTemporalMotionBlur, + appearance.zoomMotionBlurSampleCount, + appearance.zoomMotionBlurShutterFraction, + appearance.connectZooms, + appearance.zoomInDurationMs, + appearance.zoomInOverlapMs, + appearance.zoomOutDurationMs, + appearance.connectedZoomGapMs, + appearance.connectedZoomDurationMs, + appearance.zoomInEasing, + appearance.zoomOutEasing, + appearance.connectedZoomEasing, + appearance.showCursor, + appearance.loopCursor, + appearance.cursorStyle, + appearance.cursorSize, + appearance.cursorSmoothing, + appearance.cursorSpringStiffnessMultiplier, + appearance.cursorSpringDampingMultiplier, + appearance.cursorSpringMassMultiplier, + appearance.cameraSpringStiffnessMultiplier, + appearance.cameraSpringDampingMultiplier, + appearance.cameraSpringMassMultiplier, + appearance.zoomSmoothness, + appearance.zoomClassicMode, + appearance.cursorMotionBlur, + appearance.cursorClickEffect, + appearance.cursorClickEffectColor, + appearance.cursorClickEffectScale, + appearance.cursorClickEffectOpacity, + appearance.cursorClickEffectDurationMs, + appearance.cursorClickBounce, + appearance.cursorClickBounceDuration, + appearance.cursorSway, + appearance.borderRadius, + appearance.padding, + appearance.cropRegion, + appearance.webcam, + timeline.zoomRegions, + timeline.trimRegions, + timeline.clipRegions, + timeline.speedRegions, + timeline.annotationRegions, + timeline.audioRegions, + timeline.autoCaptions, + timeline.autoCaptionSettings, + aspectRatio, + exportSettings.exportEncodingMode, + exportSettings.exportBackendPreference, + exportSettings.exportPipelineModel, + exportSettings.exportQuality, + exportSettings.mp4FrameRate, + exportSettings.exportFormat, + exportSettings.gifFrameRate, + exportSettings.gifLoop, + exportSettings.gifSizePreset, + timeline.sourceAudioTrackSettingsByClip, + timeline.defaultSourceAudioTrackSettings, + buildPersistedEditorState, + ], + ); + + return { + buildPersistedEditorState, + currentSourcePath, + projectDisplayName, + currentPersistedEditorState, + }; +} diff --git a/src/components/video-editor/projectDirtyState.test.ts b/src/components/video-editor/projectDirtyState.test.ts new file mode 100644 index 000000000..68c5650f4 --- /dev/null +++ b/src/components/video-editor/projectDirtyState.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from "vitest"; + +import { hasUnsavedProjectChanges } from "./projectDirtyState"; +import type { EditorProjectData } from "./projectPersistence"; + +function createProjectData(overrides: Partial<EditorProjectData> = {}): EditorProjectData { + return { + version: 1, + projectId: "project-1", + videoPath: "file:///recording.mp4", + editor: { + wallpaper: "#ffffff", + zoomRegions: [], + trimRegions: [], + clipRegions: [], + speedRegions: [], + annotationRegions: [], + audioRegions: [], + autoCaptions: [], + }, + ...overrides, + }; +} + +describe("hasUnsavedProjectChanges", () => { + it("does not report changes before a project snapshot exists", () => { + expect(hasUnsavedProjectChanges(null, createProjectData())).toBe(false); + }); + + it("reports changes when there is no saved baseline", () => { + expect(hasUnsavedProjectChanges(createProjectData(), null)).toBe(true); + }); + + it("treats deeply equal snapshots as unchanged", () => { + expect(hasUnsavedProjectChanges(createProjectData(), createProjectData())).toBe(false); + }); + + it("detects nested editor changes", () => { + const current = createProjectData({ + editor: { + ...createProjectData().editor, + zoomRegions: [ + { + id: "zoom-1", + startMs: 100, + endMs: 500, + focus: { cx: 0.4, cy: 0.6 }, + depth: 2, + }, + ], + }, + }); + + expect(hasUnsavedProjectChanges(current, createProjectData())).toBe(true); + }); + + it("detects array length changes", () => { + const current = createProjectData({ + editor: { + ...createProjectData().editor, + autoCaptions: [{ id: "caption-1", startMs: 0, endMs: 1_000, text: "hello" }], + }, + }); + + expect(hasUnsavedProjectChanges(current, createProjectData())).toBe(true); + }); + + it("ignores transient webcam media attachment changes", () => { + const saved = createProjectData({ + editor: { + ...createProjectData().editor, + webcam: { + enabled: false, + sourcePath: null, + timeOffsetMs: 0, + size: 28, + }, + }, + }); + const current = createProjectData({ + editor: { + ...createProjectData().editor, + webcam: { + enabled: true, + sourcePath: "/Users/test/webcam.mp4", + timeOffsetMs: 125, + size: 28, + }, + }, + }); + + expect(hasUnsavedProjectChanges(current, saved)).toBe(false); + }); + + it("detects persistent webcam presentation changes", () => { + const saved = createProjectData({ + editor: { + ...createProjectData().editor, + webcam: { + enabled: true, + sourcePath: "/Users/test/webcam.mp4", + timeOffsetMs: 0, + size: 28, + }, + }, + }); + const current = createProjectData({ + editor: { + ...createProjectData().editor, + webcam: { + enabled: true, + sourcePath: "/Users/test/webcam.mp4", + timeOffsetMs: 0, + size: 36, + }, + }, + }); + + expect(hasUnsavedProjectChanges(current, saved)).toBe(true); + }); +}); diff --git a/src/components/video-editor/projectDirtyState.ts b/src/components/video-editor/projectDirtyState.ts new file mode 100644 index 000000000..16858f124 --- /dev/null +++ b/src/components/video-editor/projectDirtyState.ts @@ -0,0 +1,84 @@ +import type { EditorProjectData } from "./projectPersistence"; + +function isComparableObject(value: unknown): value is Record<string, unknown> { + return typeof value === "object" && value !== null; +} + +function areDeepEqual(left: unknown, right: unknown): boolean { + if (Object.is(left, right)) { + return true; + } + + if (Array.isArray(left) || Array.isArray(right)) { + if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) { + return false; + } + + for (let index = 0; index < left.length; index += 1) { + if (!areDeepEqual(left[index], right[index])) { + return false; + } + } + + return true; + } + + if (!isComparableObject(left) || !isComparableObject(right)) { + return false; + } + + const leftKeys = Object.keys(left); + const rightKeys = Object.keys(right); + if (leftKeys.length !== rightKeys.length) { + return false; + } + + for (const key of leftKeys) { + if (!(key in right) || !areDeepEqual(left[key], right[key])) { + return false; + } + } + + return true; +} + +function omitTransientWebcamMediaFields(project: EditorProjectData | null) { + if (!project?.editor || typeof project.editor !== "object") { + return project; + } + + const editor = project.editor as Record<string, unknown>; + const webcam = editor.webcam; + if (!isComparableObject(webcam)) { + return project; + } + + const { + enabled: _enabled, + sourcePath: _sourcePath, + timeOffsetMs: _timeOffsetMs, + ...persistentWebcamFields + } = webcam; + + return { + ...project, + editor: { + ...editor, + webcam: persistentWebcamFields, + }, + }; +} + +export function hasUnsavedProjectChanges( + currentProjectSnapshot: EditorProjectData | null, + lastSavedSnapshot: EditorProjectData | null, +): boolean { + const comparableCurrentSnapshot = omitTransientWebcamMediaFields(currentProjectSnapshot); + const comparableLastSavedSnapshot = omitTransientWebcamMediaFields(lastSavedSnapshot); + + return Boolean( + comparableCurrentSnapshot && + (!comparableLastSavedSnapshot || + !areDeepEqual(comparableCurrentSnapshot, comparableLastSavedSnapshot)), + ); +} diff --git a/src/components/video-editor/projectPersistence.test.ts b/src/components/video-editor/projectPersistence.test.ts new file mode 100644 index 000000000..0e7a3f048 --- /dev/null +++ b/src/components/video-editor/projectPersistence.test.ts @@ -0,0 +1,97 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { normalizeProjectEditor, resolveVideoUrl } from "./projectPersistence"; +import { ADVANCED_VERTICAL_PADDING_MAX } from "./types"; + +afterEach(() => vi.unstubAllGlobals()); + +describe("resolveVideoUrl", () => { + it("does not send an already renderable URL through the local-path IPC", async () => { + const getLocalMediaUrl = vi.fn(); + vi.stubGlobal("window", { electronAPI: { getLocalMediaUrl } }); + + await expect(resolveVideoUrl("http://127.0.0.1:1234/video?path=test")).resolves.toBe( + "http://127.0.0.1:1234/video?path=test", + ); + expect(getLocalMediaUrl).not.toHaveBeenCalled(); + }); + + it("normalizes a file URL before requesting a local media URL", async () => { + const getLocalMediaUrl = vi.fn().mockResolvedValue({ + success: true, + url: "http://127.0.0.1:1234/video?path=clip", + }); + vi.stubGlobal("window", { electronAPI: { getLocalMediaUrl } }); + + await resolveVideoUrl("file:///Users/demo/My%20Clip.mp4"); + expect(getLocalMediaUrl).toHaveBeenCalledWith("/Users/demo/My Clip.mp4"); + }); +}); + +describe("normalizeProjectEditor", () => { + it("preserves the extended advanced vertical padding range", () => { + const editor = normalizeProjectEditor({ + padding: { + top: 240, + bottom: ADVANCED_VERTICAL_PADDING_MAX, + left: 22, + right: 22, + linked: false, + }, + }); + + expect(editor.padding).toMatchObject({ + top: 240, + bottom: ADVANCED_VERTICAL_PADDING_MAX, + left: 22, + right: 22, + linked: false, + }); + }); + + it("keeps linked padding clamped to the original range", () => { + const editor = normalizeProjectEditor({ + padding: { + top: ADVANCED_VERTICAL_PADDING_MAX, + bottom: ADVANCED_VERTICAL_PADDING_MAX, + left: ADVANCED_VERTICAL_PADDING_MAX, + right: ADVANCED_VERTICAL_PADDING_MAX, + linked: true, + }, + }); + + expect(editor.padding).toMatchObject({ + top: 100, + bottom: 100, + left: 100, + right: 100, + linked: true, + }); + }); + + it("migrates legacy webcam radius pixels to percentage roundness", () => { + const editor = normalizeProjectEditor({ + webcam: { + cornerRadius: 90, + width: 40, + height: 40, + } as never, + }); + + expect(editor.webcam.roundness).toBeCloseTo(17.36, 1); + expect(editor.webcam.cornerRadius).toBeUndefined(); + }); + + it("uses the legacy webcam size when migrating radius pixels", () => { + const editor = normalizeProjectEditor({ + webcam: { + cornerRadius: 90, + size: 80, + } as never, + }); + + expect(editor.webcam.width).toBe(80); + expect(editor.webcam.height).toBe(80); + expect(editor.webcam.roundness).toBeCloseTo(4.34, 1); + }); +}); diff --git a/src/components/video-editor/projectPersistence.ts b/src/components/video-editor/projectPersistence.ts new file mode 100644 index 000000000..1a098f40f --- /dev/null +++ b/src/components/video-editor/projectPersistence.ts @@ -0,0 +1,1136 @@ +import type { SourceAudioTrackSettings } from "@/components/video-editor/audio/audioTypes"; +import type { + ExportBackendPreference, + ExportEncodingMode, + ExportFormat, + ExportMp4FrameRate, + ExportPipelineModel, + ExportQuality, + GifFrameRate, + GifSizePreset, +} from "@/lib/exporter"; +import { isValidMp4FrameRate } from "@/lib/exporter"; +import { + TEMPORAL_MOTION_BLUR_DEFAULT_SAMPLE_COUNT, + TEMPORAL_MOTION_BLUR_DEFAULT_SHUTTER_FRACTION, + TEMPORAL_MOTION_BLUR_MAX_SAMPLE_COUNT, + TEMPORAL_MOTION_BLUR_MAX_SHUTTER_FRACTION, + TEMPORAL_MOTION_BLUR_MIN_SAMPLE_COUNT, + TEMPORAL_MOTION_BLUR_MIN_SHUTTER_FRACTION, +} from "@/lib/exporter/temporalMotionBlur"; +import { DEFAULT_WALLPAPER_PATH } from "@/lib/wallpapers"; +import { ASPECT_RATIOS, type AspectRatio, isCustomAspectRatio } from "@/utils/aspectRatioUtils"; +import { CURSOR_MOTION_PRESETS, resolveCursorMotionPresetId } from "./cursorMotionPresets"; +import { + ADVANCED_VERTICAL_PADDING_MAX, + type AnnotationRegion, + type AudioRegion, + type AutoCaptionAnimation, + type AutoCaptionSettings, + type CaptionCue, + type CaptionCueWord, + type ClipRegion, + type CropRegion, + type CursorClickEffectStyle, + type CursorStyle, + DEFAULT_ANNOTATION_POSITION, + DEFAULT_ANNOTATION_SIZE, + DEFAULT_ANNOTATION_STYLE, + DEFAULT_AUTO_CAPTION_SETTINGS, + DEFAULT_CONNECTED_ZOOM_DURATION_MS, + DEFAULT_CONNECTED_ZOOM_EASING, + DEFAULT_CONNECTED_ZOOM_GAP_MS, + DEFAULT_CROP_REGION, + DEFAULT_CURSOR_CLICK_EFFECT, + DEFAULT_CURSOR_CLICK_EFFECT_COLOR, + DEFAULT_CURSOR_CLICK_EFFECT_DURATION_MS, + DEFAULT_CURSOR_CLICK_EFFECT_OPACITY, + DEFAULT_CURSOR_CLICK_EFFECT_SCALE, + DEFAULT_CURSOR_MOTION_BLUR, + DEFAULT_CURSOR_STYLE, + DEFAULT_CURSOR_SWAY, + DEFAULT_FIGURE_DATA, + DEFAULT_PADDING, + DEFAULT_PLAYBACK_SPEED, + DEFAULT_WEBCAM_MARGIN, + DEFAULT_WEBCAM_OVERLAY, + DEFAULT_WEBCAM_POSITION_PRESET, + DEFAULT_WEBCAM_POSITION_X, + DEFAULT_WEBCAM_POSITION_Y, + DEFAULT_WEBCAM_REACT_TO_ZOOM, + DEFAULT_WEBCAM_ROUNDNESS, + DEFAULT_WEBCAM_SHADOW, + DEFAULT_WEBCAM_SIZE, + DEFAULT_WEBCAM_TIME_OFFSET_MS, + DEFAULT_ZOOM_DEPTH, + DEFAULT_ZOOM_IN_EASING, + DEFAULT_ZOOM_IN_OVERLAP_MS, + DEFAULT_ZOOM_MOTION_BLUR, + DEFAULT_ZOOM_MOTION_BLUR_TUNING, + DEFAULT_ZOOM_OUT_EASING, + DEFAULT_ZOOM_SMOOTHNESS, + getDefaultCaptionFontFamily, + normalizeCursorClickEffectColor, + normalizeCursorClickEffectStyle, + type Padding, + type SpeedRegion, + type TrimRegion, + type WebcamOverlaySettings, + type ZoomMotionBlurTuning, + type ZoomRegion, + type ZoomTransitionEasing, +} from "./types"; +import { convertLegacyWebcamRadiusToRoundness, normalizeWebcamCropRegion } from "./webcamOverlay"; + +export const PROJECT_VERSION = 1; + +const DEFAULT_MOTION_PRESET = CURSOR_MOTION_PRESETS.focused; + +export interface ProjectEditorState { + wallpaper: string; + shadowIntensity: number; + backgroundBlur: number; + zoomMotionBlur: number; + zoomMotionBlurTuning: ZoomMotionBlurTuning; + zoomTemporalMotionBlur: number; + zoomMotionBlurSampleCount: number | null; + zoomMotionBlurShutterFraction: number | null; + connectZooms: boolean; + zoomInDurationMs: number; + zoomInOverlapMs: number; + zoomOutDurationMs: number; + connectedZoomGapMs: number; + connectedZoomDurationMs: number; + zoomInEasing: ZoomTransitionEasing; + zoomOutEasing: ZoomTransitionEasing; + connectedZoomEasing: ZoomTransitionEasing; + showCursor: boolean; + loopCursor: boolean; + cursorStyle: CursorStyle; + cursorClickEffect: CursorClickEffectStyle; + cursorClickEffectColor: string; + cursorClickEffectScale: number; + cursorClickEffectOpacity: number; + cursorClickEffectDurationMs: number; + cursorSize: number; + cursorSmoothing: number; + cursorSpringStiffnessMultiplier: number; + cursorSpringDampingMultiplier: number; + cursorSpringMassMultiplier: number; + cameraSpringStiffnessMultiplier: number; + cameraSpringDampingMultiplier: number; + cameraSpringMassMultiplier: number; + zoomSmoothness: number; + zoomClassicMode: boolean; + cursorMotionBlur: number; + cursorClickBounce: number; + cursorClickBounceDuration: number; + cursorSway: number; + borderRadius: number; + padding: Padding; + cropRegion: CropRegion; + zoomRegions: ZoomRegion[]; + trimRegions: TrimRegion[]; + clipRegions: ClipRegion[]; + autoFullTrackClipId?: string | null; + autoFullTrackClipEndMs?: number | null; + speedRegions: SpeedRegion[]; + annotationRegions: AnnotationRegion[]; + audioRegions: AudioRegion[]; + autoCaptions: CaptionCue[]; + autoCaptionSettings: AutoCaptionSettings; + webcam: WebcamOverlaySettings; + aspectRatio: AspectRatio; + sourceAudioTrackSettingsByClip?: Record<string, SourceAudioTrackSettings>; + defaultSourceAudioTrackSettings?: SourceAudioTrackSettings; + exportEncodingMode: ExportEncodingMode; + exportBackendPreference: ExportBackendPreference; + exportPipelineModel: ExportPipelineModel; + exportQuality: ExportQuality; + mp4FrameRate: ExportMp4FrameRate; + exportFormat: ExportFormat; + gifFrameRate: GifFrameRate; + gifLoop: boolean; + gifSizePreset: GifSizePreset; +} + +export interface EditorProjectData { + version: number; + projectId?: string; + videoPath: string; + editor: Partial<ProjectEditorState>; +} + +function isFiniteNumber(value: unknown): value is number { + return typeof value === "number" && Number.isFinite(value); +} + +function clamp(value: number, min: number, max: number) { + return Math.min(max, Math.max(min, value)); +} + +type PersistedDevMotionBlurSettings = { + zoomMotionBlurTuning?: unknown; +}; + +export function stripPersistedDevMotionBlurSettings<T extends PersistedDevMotionBlurSettings>( + editor: T, +): Omit<T, keyof PersistedDevMotionBlurSettings> { + const { zoomMotionBlurTuning: _zoomMotionBlurTuning, ...persistedEditor } = editor; + + return persistedEditor; +} + +export function normalizeExportEncodingMode(value: unknown): ExportEncodingMode { + if (value === "fast" || value === "balanced" || value === "quality") { + return value; + } + + return "balanced"; +} + +export function normalizeExportBackendPreference(value: unknown): ExportBackendPreference { + if (value === "auto" || value === "webcodecs" || value === "breeze") { + return value; + } + + return "auto"; +} + +export function normalizeExportPipelineModel(_value: unknown): ExportPipelineModel { + // Legacy remains available to internal smoke/export routing, but persisted + // user selections migrate to the only pipeline exposed by the editor UI. + return "modern"; +} + +export function normalizeExportMp4FrameRate(value: unknown): ExportMp4FrameRate { + return typeof value === "number" && isValidMp4FrameRate(value) ? value : 30; +} + +function normalizeZoomTransitionEasing( + value: unknown, + fallback: ZoomTransitionEasing, +): ZoomTransitionEasing { + return value === "recordly" || + value === "glide" || + value === "smooth" || + value === "snappy" || + value === "linear" + ? value + : fallback; +} + +function normalizeAutoCaptionAnimation( + value: unknown, + fallback: AutoCaptionAnimation, +): AutoCaptionAnimation { + return value === "none" || value === "fade" || value === "rise" || value === "pop" + ? value + : fallback; +} + +function isFileUrl(value: string): boolean { + return /^file:\/\//i.test(value); +} + +function encodePathSegments(pathname: string, keepWindowsDrive = false): string { + return pathname + .split("/") + .map((segment, index) => { + if (!segment) return ""; + if (keepWindowsDrive && index === 1 && /^[a-zA-Z]:$/.test(segment)) { + return segment; + } + return encodeURIComponent(segment); + }) + .join("/"); +} + +export function toFileUrl(filePath: string): string { + const normalized = filePath.replace(/\\/g, "/"); + + // Windows drive path: C:/Users/... + if (/^[a-zA-Z]:\//.test(normalized)) { + return `file://${encodePathSegments(`/${normalized}`, true)}`; + } + + // UNC path: //server/share/... + if (normalized.startsWith("//")) { + const [host, ...pathParts] = normalized.replace(/^\/+/, "").split("/"); + const encodedPath = pathParts.map((part) => encodeURIComponent(part)).join("/"); + return encodedPath ? `file://${host}/${encodedPath}` : `file://${host}/`; + } + + const absolutePath = normalized.startsWith("/") ? normalized : `/${normalized}`; + return `file://${encodePathSegments(absolutePath)}`; +} + +export function fromFileUrl(fileUrl: string): string { + const value = fileUrl.trim(); + if (!isFileUrl(value)) { + return fileUrl; + } + + try { + const url = new URL(value); + const pathname = decodeURIComponent(url.pathname); + + if (url.host && url.host !== "localhost") { + const uncPath = `//${url.host}${pathname.startsWith("/") ? pathname : `/${pathname}`}`; + return uncPath.replace(/\//g, "\\"); + } + + if (/^\/[A-Za-z]:/.test(pathname)) { + return pathname.slice(1); + } + + return pathname; + } catch { + const rawFallbackPath = value.replace(/^file:\/\//i, ""); + let fallbackPath = rawFallbackPath; + try { + fallbackPath = decodeURIComponent(rawFallbackPath); + } catch { + // Keep raw best-effort path if percent decoding fails. + } + return fallbackPath.replace(/^\/([a-zA-Z]:)/, "$1"); + } +} + +export function deriveNextId(prefix: string, ids: string[]): number { + const max = ids.reduce((acc, id) => { + const match = id.match(new RegExp(`^${prefix}-(\\d+)$`)); + if (!match) return acc; + const value = Number(match[1]); + return Number.isFinite(value) ? Math.max(acc, value) : acc; + }, 0); + return max + 1; +} + +/** + * Resolve a local file path to a URL the `<video>` element can load. + * + * Prefers the local media HTTP server (works on all platforms regardless of + * Chromium's `file://` restrictions). Falls back to a `file://` URL if the + * media server is unavailable. + */ +export async function resolveVideoUrl(sourcePath: string): Promise<string> { + const trimmedSourcePath = sourcePath.trim(); + if (/^(?:https?:|blob:|data:)/i.test(trimmedSourcePath)) { + return trimmedSourcePath; + } + const localPath = fromFileUrl(trimmedSourcePath); + try { + const result = await window.electronAPI.getLocalMediaUrl(localPath); + if (result.success) { + return result.url; + } + } catch { + // Media server unavailable — fall through to file:// URL. + } + return toFileUrl(localPath); +} + +export function validateProjectData(candidate: unknown): candidate is EditorProjectData { + if (!candidate || typeof candidate !== "object") return false; + const project = candidate as Partial<EditorProjectData>; + if (typeof project.version !== "number") return false; + if (project.projectId !== undefined && typeof project.projectId !== "string") return false; + if (typeof project.videoPath !== "string" || !project.videoPath) return false; + if (!project.editor || typeof project.editor !== "object") return false; + return true; +} + +export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): ProjectEditorState { + const normalizeTemporalBlurSampleCount = (value: unknown): number => { + if (!isFiniteNumber(value)) { + return TEMPORAL_MOTION_BLUR_DEFAULT_SAMPLE_COUNT; + } + + const roundedValue = Math.round(value); + const clampedValue = clamp( + roundedValue, + TEMPORAL_MOTION_BLUR_MIN_SAMPLE_COUNT, + TEMPORAL_MOTION_BLUR_MAX_SAMPLE_COUNT, + ); + + if (clampedValue % 2 === 1) { + return clampedValue; + } + + return clampedValue >= TEMPORAL_MOTION_BLUR_MAX_SAMPLE_COUNT + ? clampedValue - 1 + : clampedValue + 1; + }; + + const validAspectRatios = new Set<AspectRatio>(ASPECT_RATIOS); + const legacyMotionBlurEnabled = (editor as Partial<{ motionBlurEnabled: boolean }>) + .motionBlurEnabled; + const legacyShowBlur = (editor as Partial<{ showBlur: boolean }>).showBlur; + const normalizedZoomMotionBlur = isFiniteNumber( + (editor as Partial<ProjectEditorState>).zoomMotionBlur, + ) + ? clamp((editor as Partial<ProjectEditorState>).zoomMotionBlur as number, 0, 2) + : legacyMotionBlurEnabled + ? 0.35 + : DEFAULT_ZOOM_MOTION_BLUR; + const rawZoomMotionBlurTuning = + (editor as Partial<ProjectEditorState>).zoomMotionBlurTuning && + typeof (editor as Partial<ProjectEditorState>).zoomMotionBlurTuning === "object" + ? ((editor as Partial<ProjectEditorState>) + .zoomMotionBlurTuning as Partial<ZoomMotionBlurTuning>) + : {}; + const normalizedZoomMotionBlurTuning: ZoomMotionBlurTuning = { + panVelocityThreshold: isFiniteNumber(rawZoomMotionBlurTuning.panVelocityThreshold) + ? clamp(rawZoomMotionBlurTuning.panVelocityThreshold, 0, 240) + : DEFAULT_ZOOM_MOTION_BLUR_TUNING.panVelocityThreshold, + zoomVelocityThreshold: isFiniteNumber(rawZoomMotionBlurTuning.zoomVelocityThreshold) + ? clamp(rawZoomMotionBlurTuning.zoomVelocityThreshold, 0, 0.4) + : DEFAULT_ZOOM_MOTION_BLUR_TUNING.zoomVelocityThreshold, + maxDirectionalBlurPx: isFiniteNumber(rawZoomMotionBlurTuning.maxDirectionalBlurPx) + ? clamp(rawZoomMotionBlurTuning.maxDirectionalBlurPx, 0, 96) + : DEFAULT_ZOOM_MOTION_BLUR_TUNING.maxDirectionalBlurPx, + maxRadialBlurStrength: isFiniteNumber(rawZoomMotionBlurTuning.maxRadialBlurStrength) + ? clamp(rawZoomMotionBlurTuning.maxRadialBlurStrength, 0, 1.5) + : DEFAULT_ZOOM_MOTION_BLUR_TUNING.maxRadialBlurStrength, + panResponsePerSecond: isFiniteNumber(rawZoomMotionBlurTuning.panResponsePerSecond) + ? clamp(rawZoomMotionBlurTuning.panResponsePerSecond, 1, 30) + : DEFAULT_ZOOM_MOTION_BLUR_TUNING.panResponsePerSecond, + zoomResponsePerSecond: isFiniteNumber(rawZoomMotionBlurTuning.zoomResponsePerSecond) + ? clamp(rawZoomMotionBlurTuning.zoomResponsePerSecond, 1, 30) + : DEFAULT_ZOOM_MOTION_BLUR_TUNING.zoomResponsePerSecond, + zoomSafeZoneRadiusPx: isFiniteNumber(rawZoomMotionBlurTuning.zoomSafeZoneRadiusPx) + ? clamp(rawZoomMotionBlurTuning.zoomSafeZoneRadiusPx, 0, 80) + : DEFAULT_ZOOM_MOTION_BLUR_TUNING.zoomSafeZoneRadiusPx, + }; + const normalizedZoomTemporalMotionBlur = isFiniteNumber( + (editor as Partial<ProjectEditorState>).zoomTemporalMotionBlur, + ) + ? clamp((editor as Partial<ProjectEditorState>).zoomTemporalMotionBlur as number, 0, 2) + : normalizedZoomMotionBlur; + const normalizedBackgroundBlur = isFiniteNumber( + (editor as Partial<ProjectEditorState>).backgroundBlur, + ) + ? clamp((editor as Partial<ProjectEditorState>).backgroundBlur as number, 0, 8) + : legacyShowBlur + ? 2 + : 0; + const normalizedZoomMotionBlurSampleCount = normalizeTemporalBlurSampleCount( + (editor as Partial<ProjectEditorState>).zoomMotionBlurSampleCount, + ); + const normalizedZoomMotionBlurShutterFraction = isFiniteNumber( + (editor as Partial<ProjectEditorState>).zoomMotionBlurShutterFraction, + ) + ? clamp( + (editor as Partial<ProjectEditorState>).zoomMotionBlurShutterFraction as number, + TEMPORAL_MOTION_BLUR_MIN_SHUTTER_FRACTION, + TEMPORAL_MOTION_BLUR_MAX_SHUTTER_FRACTION, + ) + : TEMPORAL_MOTION_BLUR_DEFAULT_SHUTTER_FRACTION; + const normalizedZoomInDurationMs = isFiniteNumber(editor.zoomInDurationMs) + ? clamp(editor.zoomInDurationMs, 60, 4000) + : DEFAULT_MOTION_PRESET.zoomInDurationMs; + const normalizedZoomInOverlapMs = isFiniteNumber(editor.zoomInOverlapMs) + ? clamp(editor.zoomInOverlapMs, 0, normalizedZoomInDurationMs) + : DEFAULT_ZOOM_IN_OVERLAP_MS; + const normalizedZoomOutDurationMs = isFiniteNumber(editor.zoomOutDurationMs) + ? clamp(editor.zoomOutDurationMs, 60, 4000) + : DEFAULT_MOTION_PRESET.zoomOutDurationMs; + const normalizedConnectedZoomGapMs = isFiniteNumber(editor.connectedZoomGapMs) + ? clamp(editor.connectedZoomGapMs, 0, 5000) + : DEFAULT_CONNECTED_ZOOM_GAP_MS; + const normalizedConnectedZoomDurationMs = isFiniteNumber(editor.connectedZoomDurationMs) + ? clamp(editor.connectedZoomDurationMs, 60, 4000) + : DEFAULT_CONNECTED_ZOOM_DURATION_MS; + + const normalizedZoomRegions: ZoomRegion[] = Array.isArray(editor.zoomRegions) + ? editor.zoomRegions + .filter((region): region is ZoomRegion => + Boolean(region && typeof region.id === "string"), + ) + .map((region) => { + const rawStart = isFiniteNumber(region.startMs) + ? Math.round(region.startMs) + : 0; + const rawEnd = isFiniteNumber(region.endMs) + ? Math.round(region.endMs) + : rawStart + 1000; + const startMs = Math.max(0, Math.min(rawStart, rawEnd)); + const endMs = Math.max(startMs + 1, rawEnd); + + return { + id: region.id, + startMs, + endMs, + depth: [1, 2, 3, 4, 5, 6].includes(region.depth) + ? region.depth + : DEFAULT_ZOOM_DEPTH, + focus: { + cx: clamp( + isFiniteNumber(region.focus?.cx) ? region.focus.cx : 0.5, + 0, + 1, + ), + cy: clamp( + isFiniteNumber(region.focus?.cy) ? region.focus.cy : 0.5, + 0, + 1, + ), + }, + mode: + region.mode === "auto" || region.mode === "manual" + ? region.mode + : undefined, + }; + }) + : []; + + const normalizedTrimRegions: TrimRegion[] = Array.isArray(editor.trimRegions) + ? editor.trimRegions + .filter((region): region is TrimRegion => + Boolean(region && typeof region.id === "string"), + ) + .map((region) => { + const rawStart = isFiniteNumber(region.startMs) + ? Math.round(region.startMs) + : 0; + const rawEnd = isFiniteNumber(region.endMs) + ? Math.round(region.endMs) + : rawStart + 1000; + const startMs = Math.max(0, Math.min(rawStart, rawEnd)); + const endMs = Math.max(startMs + 1, rawEnd); + return { + id: region.id, + startMs, + endMs, + }; + }) + : []; + + const normalizedClipRegions: ClipRegion[] = Array.isArray(editor.clipRegions) + ? editor.clipRegions + .filter((region): region is ClipRegion => + Boolean(region && typeof region.id === "string"), + ) + .map((region) => { + const rawStart = isFiniteNumber(region.startMs) + ? Math.round(region.startMs) + : 0; + const rawEnd = isFiniteNumber(region.endMs) + ? Math.round(region.endMs) + : rawStart + 1000; + const startMs = Math.max(0, Math.min(rawStart, rawEnd)); + const endMs = Math.max(startMs + 1, rawEnd); + return { + id: region.id, + startMs, + endMs, + speed: isFiniteNumber(region.speed) ? region.speed : 1, + muted: typeof region.muted === "boolean" ? region.muted : false, + showSourceAudio: + typeof region.showSourceAudio === "boolean" + ? region.showSourceAudio + : false, + }; + }) + : []; + + const normalizedAutoFullTrackClipId = + typeof editor.autoFullTrackClipId === "string" ? editor.autoFullTrackClipId : null; + const normalizedAutoFullTrackClipEndMs = isFiniteNumber(editor.autoFullTrackClipEndMs) + ? Math.round(editor.autoFullTrackClipEndMs) + : null; + + const normalizedSpeedRegions: SpeedRegion[] = Array.isArray(editor.speedRegions) + ? editor.speedRegions + .filter((region): region is SpeedRegion => + Boolean(region && typeof region.id === "string"), + ) + .map((region) => { + const rawStart = isFiniteNumber(region.startMs) + ? Math.round(region.startMs) + : 0; + const rawEnd = isFiniteNumber(region.endMs) + ? Math.round(region.endMs) + : rawStart + 1000; + const startMs = Math.max(0, Math.min(rawStart, rawEnd)); + const endMs = Math.max(startMs + 1, rawEnd); + + const speed = + region.speed === 0.25 || + region.speed === 0.5 || + region.speed === 0.75 || + region.speed === 1.25 || + region.speed === 1.5 || + region.speed === 1.75 || + region.speed === 2 + ? region.speed + : DEFAULT_PLAYBACK_SPEED; + + return { + id: region.id, + startMs, + endMs, + speed, + }; + }) + : []; + + const normalizedAnnotationRegions: AnnotationRegion[] = Array.isArray(editor.annotationRegions) + ? editor.annotationRegions + .filter((region): region is AnnotationRegion => + Boolean(region && typeof region.id === "string"), + ) + .map((region, index) => { + const rawStart = isFiniteNumber(region.startMs) + ? Math.round(region.startMs) + : 0; + const rawEnd = isFiniteNumber(region.endMs) + ? Math.round(region.endMs) + : rawStart + 1000; + const startMs = Math.max(0, Math.min(rawStart, rawEnd)); + const endMs = Math.max(startMs + 1, rawEnd); + + return { + id: region.id, + startMs, + endMs, + type: + region.type === "image" || + region.type === "figure" || + region.type === "blur" + ? region.type + : "text", + content: typeof region.content === "string" ? region.content : "", + textContent: + typeof region.textContent === "string" ? region.textContent : undefined, + imageContent: + typeof region.imageContent === "string" + ? region.imageContent + : undefined, + position: { + x: clamp( + isFiniteNumber(region.position?.x) + ? region.position.x + : DEFAULT_ANNOTATION_POSITION.x, + 0, + 100, + ), + y: clamp( + isFiniteNumber(region.position?.y) + ? region.position.y + : DEFAULT_ANNOTATION_POSITION.y, + 0, + 100, + ), + }, + size: { + width: clamp( + isFiniteNumber(region.size?.width) + ? region.size.width + : DEFAULT_ANNOTATION_SIZE.width, + 1, + 200, + ), + height: clamp( + isFiniteNumber(region.size?.height) + ? region.size.height + : DEFAULT_ANNOTATION_SIZE.height, + 1, + 200, + ), + }, + style: { + ...DEFAULT_ANNOTATION_STYLE, + ...(region.style && typeof region.style === "object" + ? region.style + : {}), + }, + zIndex: isFiniteNumber(region.zIndex) ? region.zIndex : index + 1, + figureData: region.figureData + ? { + ...DEFAULT_FIGURE_DATA, + ...region.figureData, + } + : undefined, + blurIntensity: isFiniteNumber(region.blurIntensity) + ? clamp(region.blurIntensity, 1, 100) + : 20, + blurColor: + typeof region.blurColor === "string" ? region.blurColor : undefined, + trackIndex: isFiniteNumber(region.trackIndex) + ? Math.max(0, Math.floor(region.trackIndex)) + : 0, + }; + }) + : []; + + const normalizedAudioRegions: AudioRegion[] = Array.isArray( + (editor as Partial<ProjectEditorState>).audioRegions, + ) + ? ((editor as Partial<ProjectEditorState>).audioRegions as AudioRegion[]) + .filter((region): region is AudioRegion => + Boolean(region && typeof region.id === "string"), + ) + .map((region) => { + const rawStart = isFiniteNumber(region.startMs) + ? Math.round(region.startMs) + : 0; + const rawEnd = isFiniteNumber(region.endMs) + ? Math.round(region.endMs) + : rawStart + 1000; + const startMs = Math.max(0, Math.min(rawStart, rawEnd)); + const endMs = Math.max(startMs + 1, rawEnd); + + return { + id: region.id, + startMs, + endMs, + audioPath: typeof region.audioPath === "string" ? region.audioPath : "", + volume: isFiniteNumber(region.volume) ? clamp(region.volume, 0, 1) : 1, + normalize: Boolean(region.normalize), + trackIndex: isFiniteNumber(region.trackIndex) + ? Math.max(0, Math.floor(region.trackIndex)) + : 0, + }; + }) + : []; + + const normalizedAutoCaptions: CaptionCue[] = Array.isArray( + (editor as Partial<ProjectEditorState>).autoCaptions, + ) + ? ((editor as Partial<ProjectEditorState>).autoCaptions as CaptionCue[]) + .filter((cue): cue is CaptionCue => Boolean(cue && typeof cue.id === "string")) + .map((cue) => { + const rawStart = isFiniteNumber(cue.startMs) ? Math.round(cue.startMs) : 0; + const rawEnd = isFiniteNumber(cue.endMs) + ? Math.round(cue.endMs) + : rawStart + 1000; + const startMs = Math.max(0, Math.min(rawStart, rawEnd)); + const endMs = Math.max(startMs + 1, rawEnd); + const words: CaptionCueWord[] | undefined = Array.isArray(cue.words) + ? cue.words + .filter((word): word is CaptionCueWord => + Boolean(word && typeof word.text === "string"), + ) + .map((word) => { + const rawWordStart = isFiniteNumber(word.startMs) + ? Math.round(word.startMs) + : startMs; + const rawWordEnd = isFiniteNumber(word.endMs) + ? Math.round(word.endMs) + : rawWordStart + 1; + const normalizedWordStart = clamp( + rawWordStart, + startMs, + endMs - 1, + ); + const normalizedWordEnd = clamp( + rawWordEnd, + normalizedWordStart + 1, + endMs, + ); + + return { + text: word.text.trim(), + startMs: normalizedWordStart, + endMs: normalizedWordEnd, + ...(word.leadingSpace ? { leadingSpace: true } : {}), + }; + }) + .filter((word) => word.text.length > 0) + : undefined; + + return { + id: cue.id, + startMs, + endMs, + text: typeof cue.text === "string" ? cue.text.trim() : "", + ...(words && words.length > 0 ? { words } : {}), + }; + }) + .filter((cue) => cue.text.length > 0) + : []; + + const rawAutoCaptionSettings: Partial<AutoCaptionSettings> = + editor.autoCaptionSettings && typeof editor.autoCaptionSettings === "object" + ? (editor.autoCaptionSettings as Partial<AutoCaptionSettings>) + : {}; + const normalizedAutoCaptionSettings: AutoCaptionSettings = { + enabled: + typeof rawAutoCaptionSettings.enabled === "boolean" + ? rawAutoCaptionSettings.enabled + : DEFAULT_AUTO_CAPTION_SETTINGS.enabled, + timelineQuickAdd: + typeof rawAutoCaptionSettings.timelineQuickAdd === "boolean" + ? rawAutoCaptionSettings.timelineQuickAdd + : DEFAULT_AUTO_CAPTION_SETTINGS.timelineQuickAdd, + language: + typeof rawAutoCaptionSettings.language === "string" && + rawAutoCaptionSettings.language.trim() + ? rawAutoCaptionSettings.language.trim() + : DEFAULT_AUTO_CAPTION_SETTINGS.language, + fontFamily: getDefaultCaptionFontFamily(), + fontSize: isFiniteNumber(rawAutoCaptionSettings.fontSize) + ? clamp(rawAutoCaptionSettings.fontSize, 16, 72) + : DEFAULT_AUTO_CAPTION_SETTINGS.fontSize, + bottomOffset: isFiniteNumber(rawAutoCaptionSettings.bottomOffset) + ? clamp(rawAutoCaptionSettings.bottomOffset, 0, 30) + : DEFAULT_AUTO_CAPTION_SETTINGS.bottomOffset, + maxWidth: isFiniteNumber(rawAutoCaptionSettings.maxWidth) + ? clamp(rawAutoCaptionSettings.maxWidth, 40, 95) + : DEFAULT_AUTO_CAPTION_SETTINGS.maxWidth, + maxRows: isFiniteNumber(rawAutoCaptionSettings.maxRows) + ? clamp(Math.round(rawAutoCaptionSettings.maxRows), 1, 4) + : DEFAULT_AUTO_CAPTION_SETTINGS.maxRows, + animationStyle: normalizeAutoCaptionAnimation( + rawAutoCaptionSettings.animationStyle, + DEFAULT_AUTO_CAPTION_SETTINGS.animationStyle, + ), + boxRadius: isFiniteNumber(rawAutoCaptionSettings.boxRadius) + ? clamp(rawAutoCaptionSettings.boxRadius, 0, 40) + : DEFAULT_AUTO_CAPTION_SETTINGS.boxRadius, + textColor: + typeof rawAutoCaptionSettings.textColor === "string" && + rawAutoCaptionSettings.textColor.trim() + ? rawAutoCaptionSettings.textColor + : DEFAULT_AUTO_CAPTION_SETTINGS.textColor, + inactiveTextColor: + typeof rawAutoCaptionSettings.inactiveTextColor === "string" && + rawAutoCaptionSettings.inactiveTextColor.trim() + ? rawAutoCaptionSettings.inactiveTextColor + : DEFAULT_AUTO_CAPTION_SETTINGS.inactiveTextColor, + backgroundOpacity: isFiniteNumber(rawAutoCaptionSettings.backgroundOpacity) + ? clamp(rawAutoCaptionSettings.backgroundOpacity, 0, 1) + : DEFAULT_AUTO_CAPTION_SETTINGS.backgroundOpacity, + }; + + const rawCropX = isFiniteNumber(editor.cropRegion?.x) + ? editor.cropRegion.x + : DEFAULT_CROP_REGION.x; + const rawCropY = isFiniteNumber(editor.cropRegion?.y) + ? editor.cropRegion.y + : DEFAULT_CROP_REGION.y; + const rawCropWidth = isFiniteNumber(editor.cropRegion?.width) + ? editor.cropRegion.width + : DEFAULT_CROP_REGION.width; + const rawCropHeight = isFiniteNumber(editor.cropRegion?.height) + ? editor.cropRegion.height + : DEFAULT_CROP_REGION.height; + + const cropX = clamp(rawCropX, 0, 1); + const cropY = clamp(rawCropY, 0, 1); + const cropWidth = clamp(rawCropWidth, 0.01, 1 - cropX); + const cropHeight = clamp(rawCropHeight, 0.01, 1 - cropY); + + const webcam: Partial<WebcamOverlaySettings> = + editor.webcam && typeof editor.webcam === "object" ? editor.webcam : {}; + const webcamSourcePath = typeof webcam.sourcePath === "string" ? webcam.sourcePath : null; + const legacyZoomScaleEffect = isFiniteNumber( + (webcam as Partial<{ zoomScaleEffect: number }>).zoomScaleEffect, + ) + ? (webcam as Partial<{ zoomScaleEffect: number }>).zoomScaleEffect + : null; + const normalizedCursorStyle = + typeof editor.cursorStyle === "string" && editor.cursorStyle.trim().length > 0 + ? editor.cursorStyle === "mono" + ? "tahoe-inverted" + : editor.cursorStyle + : DEFAULT_CURSOR_STYLE; + const normalizedMotionValues = { + zoomInDurationMs: normalizedZoomInDurationMs, + zoomOutDurationMs: normalizedZoomOutDurationMs, + cursorSize: isFiniteNumber(editor.cursorSize) + ? clamp(editor.cursorSize, 0.5, 10) + : DEFAULT_MOTION_PRESET.cursorSize, + cursorSmoothing: isFiniteNumber(editor.cursorSmoothing) + ? clamp(editor.cursorSmoothing, 0, 2) + : DEFAULT_MOTION_PRESET.cursorSmoothing, + cursorSpringStiffnessMultiplier: isFiniteNumber(editor.cursorSpringStiffnessMultiplier) + ? clamp(editor.cursorSpringStiffnessMultiplier, 0.25, 3) + : DEFAULT_MOTION_PRESET.cursorSpringStiffnessMultiplier, + cursorSpringDampingMultiplier: isFiniteNumber(editor.cursorSpringDampingMultiplier) + ? clamp(editor.cursorSpringDampingMultiplier, 0.25, 3) + : DEFAULT_MOTION_PRESET.cursorSpringDampingMultiplier, + cursorSpringMassMultiplier: isFiniteNumber(editor.cursorSpringMassMultiplier) + ? clamp(editor.cursorSpringMassMultiplier, 0.25, 3) + : DEFAULT_MOTION_PRESET.cursorSpringMassMultiplier, + cursorClickBounce: isFiniteNumber((editor as Partial<ProjectEditorState>).cursorClickBounce) + ? clamp((editor as Partial<ProjectEditorState>).cursorClickBounce as number, 0, 5) + : DEFAULT_MOTION_PRESET.cursorClickBounce, + cursorClickBounceDuration: isFiniteNumber( + (editor as Partial<ProjectEditorState>).cursorClickBounceDuration, + ) + ? clamp( + (editor as Partial<ProjectEditorState>).cursorClickBounceDuration as number, + 60, + 500, + ) + : DEFAULT_MOTION_PRESET.cursorClickBounceDuration, + }; + const normalizedCursorClickEffectScale = isFiniteNumber( + (editor as Partial<ProjectEditorState>).cursorClickEffectScale, + ) + ? clamp((editor as Partial<ProjectEditorState>).cursorClickEffectScale as number, 0.5, 2) + : DEFAULT_CURSOR_CLICK_EFFECT_SCALE; + const normalizedCursorClickEffectOpacity = isFiniteNumber( + (editor as Partial<ProjectEditorState>).cursorClickEffectOpacity, + ) + ? clamp((editor as Partial<ProjectEditorState>).cursorClickEffectOpacity as number, 0, 1) + : DEFAULT_CURSOR_CLICK_EFFECT_OPACITY; + const normalizedCursorClickEffectDurationMs = isFiniteNumber( + (editor as Partial<ProjectEditorState>).cursorClickEffectDurationMs, + ) + ? clamp( + (editor as Partial<ProjectEditorState>).cursorClickEffectDurationMs as number, + 120, + 1200, + ) + : DEFAULT_CURSOR_CLICK_EFFECT_DURATION_MS; + const normalizedCursorClickEffectColor = normalizeCursorClickEffectColor( + (editor as Partial<ProjectEditorState>).cursorClickEffectColor, + DEFAULT_CURSOR_CLICK_EFFECT_COLOR, + ); + const normalizedMotionPreset = + CURSOR_MOTION_PRESETS[resolveCursorMotionPresetId(normalizedMotionValues)]; + + return { + wallpaper: typeof editor.wallpaper === "string" ? editor.wallpaper : DEFAULT_WALLPAPER_PATH, + shadowIntensity: typeof editor.shadowIntensity === "number" ? editor.shadowIntensity : 0.67, + backgroundBlur: normalizedBackgroundBlur, + zoomMotionBlur: normalizedZoomMotionBlur, + zoomMotionBlurTuning: normalizedZoomMotionBlurTuning, + zoomTemporalMotionBlur: normalizedZoomTemporalMotionBlur, + zoomMotionBlurSampleCount: normalizedZoomMotionBlurSampleCount, + zoomMotionBlurShutterFraction: normalizedZoomMotionBlurShutterFraction, + connectZooms: typeof editor.connectZooms === "boolean" ? editor.connectZooms : true, + zoomInDurationMs: normalizedMotionPreset.zoomInDurationMs, + zoomInOverlapMs: normalizedZoomInOverlapMs, + zoomOutDurationMs: normalizedMotionPreset.zoomOutDurationMs, + connectedZoomGapMs: normalizedConnectedZoomGapMs, + connectedZoomDurationMs: normalizedConnectedZoomDurationMs, + zoomInEasing: normalizeZoomTransitionEasing(editor.zoomInEasing, DEFAULT_ZOOM_IN_EASING), + zoomOutEasing: normalizeZoomTransitionEasing(editor.zoomOutEasing, DEFAULT_ZOOM_OUT_EASING), + connectedZoomEasing: normalizeZoomTransitionEasing( + editor.connectedZoomEasing, + DEFAULT_CONNECTED_ZOOM_EASING, + ), + showCursor: typeof editor.showCursor === "boolean" ? editor.showCursor : true, + loopCursor: typeof editor.loopCursor === "boolean" ? editor.loopCursor : false, + cursorStyle: normalizedCursorStyle, + cursorClickEffect: normalizeCursorClickEffectStyle( + (editor as Partial<ProjectEditorState>).cursorClickEffect, + DEFAULT_CURSOR_CLICK_EFFECT, + ), + cursorClickEffectColor: normalizedCursorClickEffectColor, + cursorClickEffectScale: normalizedCursorClickEffectScale, + cursorClickEffectOpacity: normalizedCursorClickEffectOpacity, + cursorClickEffectDurationMs: normalizedCursorClickEffectDurationMs, + cursorSize: normalizedMotionPreset.cursorSize, + cursorSmoothing: normalizedMotionPreset.cursorSmoothing, + cursorSpringStiffnessMultiplier: normalizedMotionPreset.cursorSpringStiffnessMultiplier, + cursorSpringDampingMultiplier: normalizedMotionPreset.cursorSpringDampingMultiplier, + cursorSpringMassMultiplier: normalizedMotionPreset.cursorSpringMassMultiplier, + cameraSpringStiffnessMultiplier: isFiniteNumber(editor.cameraSpringStiffnessMultiplier) + ? clamp(editor.cameraSpringStiffnessMultiplier, 0.25, 3) + : 1, + cameraSpringDampingMultiplier: isFiniteNumber(editor.cameraSpringDampingMultiplier) + ? clamp(editor.cameraSpringDampingMultiplier, 0.25, 3) + : 1.13, + cameraSpringMassMultiplier: isFiniteNumber(editor.cameraSpringMassMultiplier) + ? clamp(editor.cameraSpringMassMultiplier, 0.25, 3) + : 1.12, + zoomSmoothness: DEFAULT_ZOOM_SMOOTHNESS, + zoomClassicMode: + typeof editor.zoomClassicMode === "boolean" ? editor.zoomClassicMode : false, + cursorMotionBlur: DEFAULT_CURSOR_MOTION_BLUR, + cursorClickBounce: normalizedMotionPreset.cursorClickBounce, + cursorClickBounceDuration: normalizedMotionPreset.cursorClickBounceDuration, + cursorSway: isFiniteNumber((editor as Partial<ProjectEditorState>).cursorSway) + ? clamp((editor as Partial<ProjectEditorState>).cursorSway as number, 0, 2) + : DEFAULT_CURSOR_SWAY, + borderRadius: typeof editor.borderRadius === "number" ? editor.borderRadius : 12.5, + padding: (() => { + const p = editor.padding; + if (p && typeof p === "object") { + const linked = typeof p.linked === "boolean" ? p.linked : true; + const verticalMax = linked ? 100 : ADVANCED_VERTICAL_PADDING_MAX; + const top = isFiniteNumber(p.top) + ? clamp(p.top, 0, verticalMax) + : DEFAULT_PADDING.top; + if (linked) { + return { top, bottom: top, left: top, right: top, linked: true }; + } + return { + top, + bottom: isFiniteNumber(p.bottom) + ? clamp(p.bottom, 0, verticalMax) + : DEFAULT_PADDING.bottom, + left: isFiniteNumber(p.left) ? clamp(p.left, 0, 100) : DEFAULT_PADDING.left, + right: isFiniteNumber(p.right) ? clamp(p.right, 0, 100) : DEFAULT_PADDING.right, + linked: false, + }; + } + if (typeof p === "number" && isFiniteNumber(p)) { + const val = clamp(p, 0, 100); + return { top: val, bottom: val, left: val, right: val, linked: true }; + } + return { ...DEFAULT_PADDING }; + })(), + cropRegion: { + x: cropX, + y: cropY, + width: cropWidth, + height: cropHeight, + }, + zoomRegions: normalizedZoomRegions, + trimRegions: normalizedTrimRegions, + clipRegions: normalizedClipRegions, + autoFullTrackClipId: normalizedAutoFullTrackClipId, + autoFullTrackClipEndMs: normalizedAutoFullTrackClipEndMs, + speedRegions: normalizedSpeedRegions, + annotationRegions: normalizedAnnotationRegions, + audioRegions: normalizedAudioRegions, + autoCaptions: normalizedAutoCaptions, + autoCaptionSettings: normalizedAutoCaptionSettings, + webcam: { + enabled: + typeof webcam.enabled === "boolean" + ? webcam.enabled + : DEFAULT_WEBCAM_OVERLAY.enabled, + sourcePath: webcamSourcePath, + mirror: + typeof webcam.mirror === "boolean" ? webcam.mirror : DEFAULT_WEBCAM_OVERLAY.mirror, + cropRegion: normalizeWebcamCropRegion(webcam.cropRegion), + positionPreset: + webcam.positionPreset === "top-left" || + webcam.positionPreset === "top-center" || + webcam.positionPreset === "top-right" || + webcam.positionPreset === "center-left" || + webcam.positionPreset === "center" || + webcam.positionPreset === "center-right" || + webcam.positionPreset === "bottom-left" || + webcam.positionPreset === "bottom-center" || + webcam.positionPreset === "bottom-right" || + webcam.positionPreset === "custom" + ? webcam.positionPreset + : webcam.corner === "top-left" || + webcam.corner === "top-right" || + webcam.corner === "bottom-left" || + webcam.corner === "bottom-right" + ? webcam.corner + : DEFAULT_WEBCAM_POSITION_PRESET, + positionX: isFiniteNumber(webcam.positionX) + ? clamp(webcam.positionX, 0, 1) + : DEFAULT_WEBCAM_POSITION_X, + positionY: isFiniteNumber(webcam.positionY) + ? clamp(webcam.positionY, 0, 1) + : DEFAULT_WEBCAM_POSITION_Y, + corner: + webcam.corner === "top-left" || + webcam.corner === "top-right" || + webcam.corner === "bottom-left" || + webcam.corner === "bottom-right" + ? webcam.corner + : DEFAULT_WEBCAM_OVERLAY.corner, + size: isFiniteNumber(webcam.size) ? clamp(webcam.size, 10, 100) : DEFAULT_WEBCAM_SIZE, + width: isFiniteNumber(webcam.width) + ? clamp(webcam.width, 10, 100) + : isFiniteNumber(webcam.size) + ? clamp(webcam.size, 10, 100) + : DEFAULT_WEBCAM_SIZE, + height: isFiniteNumber(webcam.height) + ? clamp(webcam.height, 10, 100) + : isFiniteNumber(webcam.size) + ? clamp(webcam.size, 10, 100) + : DEFAULT_WEBCAM_SIZE, + reactToZoom: + typeof webcam.reactToZoom === "boolean" + ? webcam.reactToZoom + : legacyZoomScaleEffect != null + ? legacyZoomScaleEffect > 0 + : DEFAULT_WEBCAM_REACT_TO_ZOOM, + roundness: isFiniteNumber(webcam.roundness) + ? clamp(webcam.roundness, 0, 100) + : isFiniteNumber(webcam.cornerRadius) + ? convertLegacyWebcamRadiusToRoundness( + webcam.cornerRadius, + isFiniteNumber(webcam.width) + ? webcam.width + : isFiniteNumber(webcam.size) + ? webcam.size + : DEFAULT_WEBCAM_SIZE, + isFiniteNumber(webcam.height) + ? webcam.height + : isFiniteNumber(webcam.size) + ? webcam.size + : DEFAULT_WEBCAM_SIZE, + ) + : DEFAULT_WEBCAM_ROUNDNESS, + shadow: isFiniteNumber(webcam.shadow) + ? clamp(webcam.shadow, 0, 1) + : DEFAULT_WEBCAM_SHADOW, + timeOffsetMs: isFiniteNumber(webcam.timeOffsetMs) + ? Math.round(webcam.timeOffsetMs) + : DEFAULT_WEBCAM_TIME_OFFSET_MS, + margin: isFiniteNumber(webcam.margin) + ? clamp(webcam.margin, 0, 96) + : DEFAULT_WEBCAM_MARGIN, + }, + sourceAudioTrackSettingsByClip: + editor.sourceAudioTrackSettingsByClip && + typeof editor.sourceAudioTrackSettingsByClip === "object" + ? editor.sourceAudioTrackSettingsByClip + : {}, + defaultSourceAudioTrackSettings: + editor.defaultSourceAudioTrackSettings && + typeof editor.defaultSourceAudioTrackSettings === "object" + ? editor.defaultSourceAudioTrackSettings + : {}, + aspectRatio: + typeof editor.aspectRatio === "string" && + (validAspectRatios.has(editor.aspectRatio as AspectRatio) || + isCustomAspectRatio(editor.aspectRatio)) + ? (editor.aspectRatio as AspectRatio) + : "16:9", + exportEncodingMode: normalizeExportEncodingMode(editor.exportEncodingMode), + exportBackendPreference: normalizeExportBackendPreference(editor.exportBackendPreference), + exportPipelineModel: normalizeExportPipelineModel(editor.exportPipelineModel), + exportQuality: + editor.exportQuality === "medium" || + editor.exportQuality === "good" || + editor.exportQuality === "high" || + editor.exportQuality === "source" + ? editor.exportQuality + : "source", + mp4FrameRate: normalizeExportMp4FrameRate(editor.mp4FrameRate), + exportFormat: editor.exportFormat === "gif" ? "gif" : "mp4", + gifFrameRate: + editor.gifFrameRate === 15 || + editor.gifFrameRate === 20 || + editor.gifFrameRate === 25 || + editor.gifFrameRate === 30 + ? editor.gifFrameRate + : 15, + gifLoop: typeof editor.gifLoop === "boolean" ? editor.gifLoop : true, + gifSizePreset: + editor.gifSizePreset === "medium" || + editor.gifSizePreset === "large" || + editor.gifSizePreset === "original" + ? editor.gifSizePreset + : "medium", + }; +} + +export function createProjectData( + videoPath: string, + editor: Partial<ProjectEditorState>, + projectId?: string | null, +): EditorProjectData { + return { + version: PROJECT_VERSION, + ...(typeof projectId === "string" && projectId.trim().length > 0 ? { projectId } : {}), + videoPath, + editor, + }; +} diff --git a/src/components/video-editor/smokeExportConfig.test.ts b/src/components/video-editor/smokeExportConfig.test.ts new file mode 100644 index 000000000..3df06e4e6 --- /dev/null +++ b/src/components/video-editor/smokeExportConfig.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it } from "vitest"; + +import { getDevOpenRecordingConfig, getSmokeExportConfig } from "./smokeExportConfig"; + +describe("getSmokeExportConfig", () => { + it("keeps smoke export disabled when the flag is absent", () => { + expect(getSmokeExportConfig("?smokeInput=/tmp/input.mp4")).toEqual({ + enabled: false, + inputPath: null, + outputPath: null, + useNativeExport: false, + encodingMode: undefined, + shadowIntensity: undefined, + webcamInputPath: null, + webcamShadow: undefined, + webcamSize: undefined, + pipelineModel: undefined, + backendPreference: undefined, + renderBackend: undefined, + maxEncodeQueue: undefined, + maxDecodeQueue: undefined, + maxPendingFrames: undefined, + projectPath: null, + quality: undefined, + fps: undefined, + }); + }); + + it("parses the enabled smoke export query contract", () => { + const config = getSmokeExportConfig( + "?smokeExport=1" + + "&smokeInput=/tmp/input.mp4" + + "&smokeOutput=/tmp/output.mp4" + + "&smokeUseNativeExport=1" + + "&smokeEncodingMode=quality" + + "&smokeShadowIntensity=0" + + "&smokeWebcamInput=/tmp/webcam.mp4" + + "&smokeWebcamShadow=1.5" + + "&smokeWebcamSize=0.75" + + "&smokePipelineModel=legacy" + + "&smokeBackendPreference=breeze" + + "&smokeRenderBackend=webgpu" + + "&smokeMaxEncodeQueue=8" + + "&smokeMaxDecodeQueue=9" + + "&smokeMaxPendingFrames=10" + + "&smokeProject=/tmp/project.recordly" + + "&smokeQuality=source" + + "&smokeFps=60", + ); + + expect(config).toEqual({ + enabled: true, + inputPath: "/tmp/input.mp4", + outputPath: "/tmp/output.mp4", + useNativeExport: true, + encodingMode: "quality", + shadowIntensity: 0, + webcamInputPath: "/tmp/webcam.mp4", + webcamShadow: 1.5, + webcamSize: 0.75, + pipelineModel: "legacy", + backendPreference: "breeze", + renderBackend: "webgpu", + maxEncodeQueue: 8, + maxDecodeQueue: 9, + maxPendingFrames: 10, + projectPath: "/tmp/project.recordly", + quality: "source", + fps: 60, + }); + }); + + it("drops invalid optional smoke export values", () => { + const config = getSmokeExportConfig( + "?smokeExport=1" + + "&smokeEncodingMode=slow" + + "&smokeShadowIntensity=-1" + + "&smokeWebcamShadow=nan" + + "&smokeWebcamSize=-0.1" + + "&smokePipelineModel=classic" + + "&smokeBackendPreference=native" + + "&smokeRenderBackend=canvas" + + "&smokeMaxEncodeQueue=0" + + "&smokeMaxDecodeQueue=-4" + + "&smokeMaxPendingFrames=abc" + + "&smokeQuality=ultra" + + "&smokeFps=25", + ); + + expect(config).toMatchObject({ + enabled: true, + useNativeExport: false, + encodingMode: undefined, + shadowIntensity: undefined, + webcamShadow: undefined, + webcamSize: undefined, + pipelineModel: undefined, + backendPreference: undefined, + renderBackend: undefined, + maxEncodeQueue: undefined, + maxDecodeQueue: undefined, + maxPendingFrames: undefined, + quality: undefined, + fps: undefined, + }); + }); +}); + +describe("getDevOpenRecordingConfig", () => { + it("reads dev-open paths independently from smoke export", () => { + expect( + getDevOpenRecordingConfig("?devOpenInput=/tmp/input.mp4&devOpenWebcam=/tmp/webcam.mp4"), + ).toEqual({ + inputPath: "/tmp/input.mp4", + webcamInputPath: "/tmp/webcam.mp4", + }); + }); +}); diff --git a/src/components/video-editor/smokeExportConfig.ts b/src/components/video-editor/smokeExportConfig.ts new file mode 100644 index 000000000..8e30561bf --- /dev/null +++ b/src/components/video-editor/smokeExportConfig.ts @@ -0,0 +1,137 @@ +import { + isValidMp4FrameRate, + type ExportBackendPreference, + type ExportEncodingMode, + type ExportMp4FrameRate, + type ExportPipelineModel, + type ExportQuality, + type ExportRenderBackend, +} from "@/lib/exporter/types"; + +export type SmokeExportConfig = { + enabled: boolean; + inputPath: string | null; + outputPath: string | null; + useNativeExport: boolean; + encodingMode?: ExportEncodingMode; + shadowIntensity?: number; + webcamInputPath?: string | null; + webcamShadow?: number; + webcamSize?: number; + pipelineModel?: ExportPipelineModel; + backendPreference?: ExportBackendPreference; + renderBackend?: ExportRenderBackend; + maxEncodeQueue?: number; + maxDecodeQueue?: number; + maxPendingFrames?: number; + projectPath?: string | null; + quality?: ExportQuality; + fps?: ExportMp4FrameRate; +}; + +export type DevOpenRecordingConfig = { + inputPath: string | null; + webcamInputPath: string | null; +}; + +function parseSmokeExportNumber(value: string | null): number | undefined { + if (value === null) { + return undefined; + } + + const parsed = Number.parseInt(value, 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined; +} + +function parseSmokeExportNonNegativeNumber(value: string | null): number | undefined { + if (value === null) { + return undefined; + } + + const parsed = Number.parseFloat(value); + return Number.isFinite(parsed) && parsed >= 0 ? parsed : undefined; +} + +function parseSmokeExportQuality(value: string | null): ExportQuality | undefined { + if (value === "medium" || value === "good" || value === "high" || value === "source") { + return value; + } + return undefined; +} + +function parseSmokeExportFps(value: string | null): ExportMp4FrameRate | undefined { + if (value === null) return undefined; + const parsed = Number.parseInt(value, 10); + return isValidMp4FrameRate(parsed) ? parsed : undefined; +} + +function parseSmokeRenderBackend(value: string | null): ExportRenderBackend | undefined { + return value === "webgl" || value === "webgpu" ? value : undefined; +} + +export function getSmokeExportConfig(search: string): SmokeExportConfig { + const params = new URLSearchParams(search); + const enabled = params.get("smokeExport") === "1"; + + return { + enabled, + inputPath: enabled ? params.get("smokeInput") : null, + outputPath: enabled ? params.get("smokeOutput") : null, + useNativeExport: enabled ? params.get("smokeUseNativeExport") === "1" : false, + encodingMode: + enabled && params.get("smokeEncodingMode") === "fast" + ? "fast" + : enabled && params.get("smokeEncodingMode") === "balanced" + ? "balanced" + : enabled && params.get("smokeEncodingMode") === "quality" + ? "quality" + : undefined, + shadowIntensity: enabled + ? parseSmokeExportNonNegativeNumber(params.get("smokeShadowIntensity")) + : undefined, + webcamInputPath: enabled ? params.get("smokeWebcamInput") : null, + webcamShadow: enabled + ? parseSmokeExportNonNegativeNumber(params.get("smokeWebcamShadow")) + : undefined, + webcamSize: enabled + ? parseSmokeExportNonNegativeNumber(params.get("smokeWebcamSize")) + : undefined, + pipelineModel: + enabled && params.get("smokePipelineModel") === "modern" + ? "modern" + : enabled && params.get("smokePipelineModel") === "legacy" + ? "legacy" + : undefined, + backendPreference: + enabled && params.get("smokeBackendPreference") === "auto" + ? "auto" + : enabled && params.get("smokeBackendPreference") === "webcodecs" + ? "webcodecs" + : enabled && params.get("smokeBackendPreference") === "breeze" + ? "breeze" + : undefined, + renderBackend: enabled + ? parseSmokeRenderBackend(params.get("smokeRenderBackend")) + : undefined, + maxEncodeQueue: enabled + ? parseSmokeExportNumber(params.get("smokeMaxEncodeQueue")) + : undefined, + maxDecodeQueue: enabled + ? parseSmokeExportNumber(params.get("smokeMaxDecodeQueue")) + : undefined, + maxPendingFrames: enabled + ? parseSmokeExportNumber(params.get("smokeMaxPendingFrames")) + : undefined, + projectPath: enabled ? params.get("smokeProject") : null, + quality: enabled ? parseSmokeExportQuality(params.get("smokeQuality")) : undefined, + fps: enabled ? parseSmokeExportFps(params.get("smokeFps")) : undefined, + }; +} + +export function getDevOpenRecordingConfig(search: string): DevOpenRecordingConfig { + const params = new URLSearchParams(search); + return { + inputPath: params.get("devOpenInput"), + webcamInputPath: params.get("devOpenWebcam"), + }; +} diff --git a/src/components/video-editor/smokeExportProgress.test.ts b/src/components/video-editor/smokeExportProgress.test.ts new file mode 100644 index 000000000..3251a218a --- /dev/null +++ b/src/components/video-editor/smokeExportProgress.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from "vitest"; +import type { ExportProgress } from "@/lib/exporter/types"; +import { createSmokeExportProgressSampler } from "./smokeExportProgress"; + +function progress(overrides: Partial<ExportProgress> = {}): ExportProgress { + return { + currentFrame: 1, + totalFrames: 100, + percentage: 1, + estimatedTimeRemaining: 10, + ...overrides, + }; +} + +describe("createSmokeExportProgressSampler", () => { + it("does not sample when smoke export is disabled", () => { + const sampler = createSmokeExportProgressSampler({ + enabled: false, + startedAtMs: 100, + now: () => 200, + }); + + sampler.record(progress()); + + expect(sampler.samples).toEqual([]); + }); + + it("does not sample before the smoke export start time is known", () => { + const sampler = createSmokeExportProgressSampler({ + enabled: true, + startedAtMs: null, + now: () => 200, + }); + + sampler.record(progress()); + + expect(sampler.samples).toEqual([]); + }); + + it("records the first progress update with the default phase", () => { + const sampler = createSmokeExportProgressSampler({ + enabled: true, + startedAtMs: 100, + now: () => 245.4, + }); + + sampler.record( + progress({ + currentFrame: 4, + totalFrames: 200, + percentage: 2, + estimatedTimeRemaining: 12, + renderFps: 58, + renderBackend: "webgpu", + encodeBackend: "webcodecs", + encoderName: "VideoEncoder", + }), + ); + + expect(sampler.samples).toEqual([ + { + elapsedMs: 145, + phase: "extracting", + currentFrame: 4, + totalFrames: 200, + percentage: 2, + estimatedTimeRemaining: 12, + renderFps: 58, + renderBackend: "webgpu", + encodeBackend: "webcodecs", + encoderName: "VideoEncoder", + }, + ]); + }); + + it("skips same-phase updates inside the sampling interval", () => { + const timestamps = [100, 500]; + const sampler = createSmokeExportProgressSampler({ + enabled: true, + startedAtMs: 0, + now: () => timestamps.shift() ?? 500, + }); + + sampler.record(progress({ currentFrame: 1, percentage: 1 })); + sampler.record(progress({ currentFrame: 2, percentage: 2 })); + + expect(sampler.samples).toHaveLength(1); + expect(sampler.samples[0]?.currentFrame).toBe(1); + }); + + it("samples phase changes and interval updates", () => { + const timestamps = [100, 200, 1200]; + const sampler = createSmokeExportProgressSampler({ + enabled: true, + startedAtMs: 0, + now: () => timestamps.shift() ?? 1200, + }); + + sampler.record(progress({ currentFrame: 1, phase: "extracting" })); + sampler.record(progress({ currentFrame: 2, phase: "finalizing" })); + sampler.record(progress({ currentFrame: 3, phase: "finalizing" })); + + expect(sampler.samples.map((sample) => sample.currentFrame)).toEqual([1, 2, 3]); + }); + + it("samples final progress even inside the sampling interval", () => { + const timestamps = [100, 250]; + const sampler = createSmokeExportProgressSampler({ + enabled: true, + startedAtMs: 0, + now: () => timestamps.shift() ?? 250, + }); + + sampler.record(progress({ currentFrame: 1, totalFrames: 10 })); + sampler.record(progress({ currentFrame: 10, totalFrames: 10 })); + + expect(sampler.samples.map((sample) => sample.currentFrame)).toEqual([1, 10]); + }); +}); diff --git a/src/components/video-editor/smokeExportProgress.ts b/src/components/video-editor/smokeExportProgress.ts new file mode 100644 index 000000000..8fabc13c3 --- /dev/null +++ b/src/components/video-editor/smokeExportProgress.ts @@ -0,0 +1,72 @@ +import type { ExportProgress } from "@/lib/exporter/types"; + +const DEFAULT_SAMPLE_INTERVAL_MS = 1000; + +export type SmokeExportProgressSample = { + elapsedMs: number; + phase: NonNullable<ExportProgress["phase"]> | "extracting"; + currentFrame: number; + totalFrames: number; + percentage: number; + estimatedTimeRemaining: number; + renderFps?: number; + renderBackend?: ExportProgress["renderBackend"]; + encodeBackend?: ExportProgress["encodeBackend"]; + encoderName?: string; +}; + +type SmokeExportProgressSamplerOptions = { + enabled: boolean; + startedAtMs: number | null; + now?: () => number; + sampleIntervalMs?: number; +}; + +export function createSmokeExportProgressSampler({ + enabled, + startedAtMs, + now = () => performance.now(), + sampleIntervalMs = DEFAULT_SAMPLE_INTERVAL_MS, +}: SmokeExportProgressSamplerOptions) { + const samples: SmokeExportProgressSample[] = []; + let lastSampleAt = 0; + let lastSamplePhase: ExportProgress["phase"] | "extracting" | undefined; + + const record = (progress: ExportProgress) => { + if (!enabled || startedAtMs === null) { + return; + } + + const timestamp = now(); + const phase = progress.phase ?? "extracting"; + const shouldSample = + samples.length === 0 || + phase !== lastSamplePhase || + timestamp - lastSampleAt >= sampleIntervalMs || + progress.currentFrame >= progress.totalFrames; + + if (!shouldSample) { + return; + } + + samples.push({ + elapsedMs: Math.round(timestamp - startedAtMs), + phase, + currentFrame: progress.currentFrame, + totalFrames: progress.totalFrames, + percentage: progress.percentage, + estimatedTimeRemaining: progress.estimatedTimeRemaining, + renderFps: progress.renderFps, + renderBackend: progress.renderBackend, + encodeBackend: progress.encodeBackend, + encoderName: progress.encoderName, + }); + lastSampleAt = timestamp; + lastSamplePhase = phase; + }; + + return { + record, + samples, + }; +} diff --git a/src/components/video-editor/state/useAppearanceState.ts b/src/components/video-editor/state/useAppearanceState.ts new file mode 100644 index 000000000..7335fe7be --- /dev/null +++ b/src/components/video-editor/state/useAppearanceState.ts @@ -0,0 +1,219 @@ +import { useState } from "react"; +import type { EditorPreferences } from "../editorPreferences"; +import type { + CropRegion, + CursorClickEffectStyle, + CursorStyle, + WebcamOverlaySettings, + ZoomMotionBlurTuning, + ZoomTransitionEasing, +} from "../types"; +import { + DEFAULT_CONNECTED_ZOOM_DURATION_MS, + DEFAULT_CONNECTED_ZOOM_EASING, + DEFAULT_CONNECTED_ZOOM_GAP_MS, + DEFAULT_CROP_REGION, + DEFAULT_CURSOR_STYLE, + DEFAULT_WEBCAM_OVERLAY, + DEFAULT_ZOOM_IN_DURATION_MS, + DEFAULT_ZOOM_IN_EASING, + DEFAULT_ZOOM_IN_OVERLAP_MS, + DEFAULT_ZOOM_MOTION_BLUR_TUNING, + DEFAULT_ZOOM_OUT_DURATION_MS, + DEFAULT_ZOOM_OUT_EASING, +} from "../types"; + +export function useAppearanceState(preferences: EditorPreferences) { + const [wallpaper, setWallpaper] = useState(preferences.wallpaper); + const [shadowIntensity, setShadowIntensity] = useState(preferences.shadowIntensity); + const [backgroundBlur, setBackgroundBlur] = useState(preferences.backgroundBlur); + const [zoomMotionBlur, setZoomMotionBlur] = useState(preferences.zoomMotionBlur); + const [zoomMotionBlurTuning, setZoomMotionBlurTuning] = useState<ZoomMotionBlurTuning>( + preferences.zoomMotionBlurTuning ?? DEFAULT_ZOOM_MOTION_BLUR_TUNING, + ); + const [zoomTemporalMotionBlur, setZoomTemporalMotionBlur] = useState( + preferences.zoomTemporalMotionBlur, + ); + const [zoomMotionBlurSampleCount, setZoomMotionBlurSampleCount] = useState<number | null>( + preferences.zoomMotionBlurSampleCount, + ); + const [zoomMotionBlurShutterFraction, setZoomMotionBlurShutterFraction] = useState< + number | null + >(preferences.zoomMotionBlurShutterFraction); + const [autoApplyFreshRecordingAutoZooms, setAutoApplyFreshRecordingAutoZooms] = useState( + preferences.autoApplyFreshRecordingAutoZooms, + ); + const [connectZooms, setConnectZooms] = useState(preferences.connectZooms); + const [zoomInDurationMs, setZoomInDurationMs] = useState( + preferences.zoomInDurationMs ?? DEFAULT_ZOOM_IN_DURATION_MS, + ); + const [zoomInOverlapMs, setZoomInOverlapMs] = useState( + preferences.zoomInOverlapMs ?? DEFAULT_ZOOM_IN_OVERLAP_MS, + ); + const [zoomOutDurationMs, setZoomOutDurationMs] = useState( + preferences.zoomOutDurationMs ?? DEFAULT_ZOOM_OUT_DURATION_MS, + ); + const [connectedZoomGapMs, setConnectedZoomGapMs] = useState( + preferences.connectedZoomGapMs ?? DEFAULT_CONNECTED_ZOOM_GAP_MS, + ); + const [connectedZoomDurationMs, setConnectedZoomDurationMs] = useState( + preferences.connectedZoomDurationMs ?? DEFAULT_CONNECTED_ZOOM_DURATION_MS, + ); + const [zoomInEasing, setZoomInEasing] = useState<ZoomTransitionEasing>( + preferences.zoomInEasing ?? DEFAULT_ZOOM_IN_EASING, + ); + const [zoomOutEasing, setZoomOutEasing] = useState<ZoomTransitionEasing>( + preferences.zoomOutEasing ?? DEFAULT_ZOOM_OUT_EASING, + ); + const [connectedZoomEasing, setConnectedZoomEasing] = useState<ZoomTransitionEasing>( + preferences.connectedZoomEasing ?? DEFAULT_CONNECTED_ZOOM_EASING, + ); + const [showCursor, setShowCursor] = useState(preferences.showCursor); + const [loopCursor, setLoopCursor] = useState(preferences.loopCursor); + const [cursorStyle, setCursorStyle] = useState<CursorStyle>( + preferences.cursorStyle ?? DEFAULT_CURSOR_STYLE, + ); + const [cursorSize, setCursorSize] = useState(preferences.cursorSize); + const [cursorSmoothing, setCursorSmoothing] = useState(preferences.cursorSmoothing); + const [cursorSpringStiffnessMultiplier, setCursorSpringStiffnessMultiplier] = useState( + preferences.cursorSpringStiffnessMultiplier, + ); + const [cursorSpringDampingMultiplier, setCursorSpringDampingMultiplier] = useState( + preferences.cursorSpringDampingMultiplier, + ); + const [cursorSpringMassMultiplier, setCursorSpringMassMultiplier] = useState( + preferences.cursorSpringMassMultiplier, + ); + const [cameraSpringStiffnessMultiplier, setCameraSpringStiffnessMultiplier] = useState( + preferences.cameraSpringStiffnessMultiplier, + ); + const [cameraSpringDampingMultiplier, setCameraSpringDampingMultiplier] = useState( + preferences.cameraSpringDampingMultiplier, + ); + const [cameraSpringMassMultiplier, setCameraSpringMassMultiplier] = useState( + preferences.cameraSpringMassMultiplier, + ); + const [zoomSmoothness, setZoomSmoothness] = useState(0.5); + const [zoomClassicMode, setZoomClassicMode] = useState(false); + const [cursorMotionBlur, setCursorMotionBlur] = useState(preferences.cursorMotionBlur); + const [cursorClickEffect, setCursorClickEffect] = useState<CursorClickEffectStyle>( + preferences.cursorClickEffect, + ); + const [cursorClickEffectColor, setCursorClickEffectColor] = useState( + preferences.cursorClickEffectColor, + ); + const [cursorClickEffectScale, setCursorClickEffectScale] = useState( + preferences.cursorClickEffectScale, + ); + const [cursorClickEffectOpacity, setCursorClickEffectOpacity] = useState( + preferences.cursorClickEffectOpacity, + ); + const [cursorClickEffectDurationMs, setCursorClickEffectDurationMs] = useState( + preferences.cursorClickEffectDurationMs, + ); + const [cursorClickBounce, setCursorClickBounce] = useState(preferences.cursorClickBounce); + const [cursorClickBounceDuration, setCursorClickBounceDuration] = useState( + preferences.cursorClickBounceDuration, + ); + const [cursorSway, setCursorSway] = useState(preferences.cursorSway); + const [borderRadius, setBorderRadius] = useState(preferences.borderRadius); + const [padding, setPadding] = useState(preferences.padding); + const [cropRegion, setCropRegion] = useState<CropRegion>(DEFAULT_CROP_REGION); + const [webcam, setWebcam] = useState<WebcamOverlaySettings>( + preferences.webcam ?? DEFAULT_WEBCAM_OVERLAY, + ); + const [resolvedWebcamVideoUrl, setResolvedWebcamVideoUrl] = useState<string | null>(null); + + return { + wallpaper, + setWallpaper, + shadowIntensity, + setShadowIntensity, + backgroundBlur, + setBackgroundBlur, + zoomMotionBlur, + setZoomMotionBlur, + zoomMotionBlurTuning, + setZoomMotionBlurTuning, + zoomTemporalMotionBlur, + setZoomTemporalMotionBlur, + zoomMotionBlurSampleCount, + setZoomMotionBlurSampleCount, + zoomMotionBlurShutterFraction, + setZoomMotionBlurShutterFraction, + autoApplyFreshRecordingAutoZooms, + setAutoApplyFreshRecordingAutoZooms, + connectZooms, + setConnectZooms, + zoomInDurationMs, + setZoomInDurationMs, + zoomInOverlapMs, + setZoomInOverlapMs, + zoomOutDurationMs, + setZoomOutDurationMs, + connectedZoomGapMs, + setConnectedZoomGapMs, + connectedZoomDurationMs, + setConnectedZoomDurationMs, + zoomInEasing, + setZoomInEasing, + zoomOutEasing, + setZoomOutEasing, + connectedZoomEasing, + setConnectedZoomEasing, + showCursor, + setShowCursor, + loopCursor, + setLoopCursor, + cursorStyle, + setCursorStyle, + cursorSize, + setCursorSize, + cursorSmoothing, + setCursorSmoothing, + cursorSpringStiffnessMultiplier, + setCursorSpringStiffnessMultiplier, + cursorSpringDampingMultiplier, + setCursorSpringDampingMultiplier, + cursorSpringMassMultiplier, + setCursorSpringMassMultiplier, + cameraSpringStiffnessMultiplier, + setCameraSpringStiffnessMultiplier, + cameraSpringDampingMultiplier, + setCameraSpringDampingMultiplier, + cameraSpringMassMultiplier, + setCameraSpringMassMultiplier, + zoomSmoothness, + setZoomSmoothness, + zoomClassicMode, + setZoomClassicMode, + cursorMotionBlur, + setCursorMotionBlur, + cursorClickEffect, + setCursorClickEffect, + cursorClickEffectColor, + setCursorClickEffectColor, + cursorClickEffectScale, + setCursorClickEffectScale, + cursorClickEffectOpacity, + setCursorClickEffectOpacity, + cursorClickEffectDurationMs, + setCursorClickEffectDurationMs, + cursorClickBounce, + setCursorClickBounce, + cursorClickBounceDuration, + setCursorClickBounceDuration, + cursorSway, + setCursorSway, + borderRadius, + setBorderRadius, + padding, + setPadding, + cropRegion, + setCropRegion, + webcam, + setWebcam, + resolvedWebcamVideoUrl, + setResolvedWebcamVideoUrl, + }; +} diff --git a/src/components/video-editor/state/useEditorUiState.ts b/src/components/video-editor/state/useEditorUiState.ts new file mode 100644 index 000000000..4d6e34d58 --- /dev/null +++ b/src/components/video-editor/state/useEditorUiState.ts @@ -0,0 +1,177 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { OPEN_EDITOR_SECTION_EVENT } from "@/lib/announcementActions"; +import { type AnnouncementEditorSection, isAnnouncementEditorSection } from "@/lib/announcements"; +import type { AspectRatio } from "@/utils/aspectRatioUtils"; +import type { loadEditorPreferences } from "../editorPreferences"; +import type { TimelineEditorHandle } from "../timeline/TimelineEditor"; +import type { CropRegion, EditorEffectSection } from "../types"; +import type { VideoPlaybackRef } from "../VideoPlayback"; + +type SessionPresentation = { + hideOverlayCursorByDefault?: boolean; + nativeCaptureUnavailable?: boolean; +}; + +export function useEditorUiState( + initialPreferences: ReturnType<typeof loadEditorPreferences>, + cropRegion: CropRegion, + setCropRegion: (region: CropRegion) => void, +) { + const [appPlatform, setAppPlatform] = useState( + typeof navigator !== "undefined" && /Mac/i.test(navigator.platform) ? "darwin" : "", + ); + const [isPlaying, setIsPlaying] = useState(false); + const [currentTime, setCurrentTime] = useState(0); + const [duration, setDuration] = useState(0); + const [sessionShowCursorOverride, setSessionShowCursorOverride] = useState<boolean | null>( + null, + ); + const [sessionNativeCaptureUnavailable, setSessionNativeCaptureUnavailable] = useState(false); + const [nativeCaptureUnavailableModalOpen, setNativeCaptureUnavailableModalOpen] = + useState(false); + const [whisperExecutablePath, setWhisperExecutablePath] = useState<string | null>( + initialPreferences.whisperExecutablePath, + ); + const [whisperModelPath, setWhisperModelPath] = useState<string | null>( + initialPreferences.whisperModelPath, + ); + const [downloadedWhisperModelPath, setDownloadedWhisperModelPath] = useState<string | null>( + null, + ); + const [whisperModelDownloadStatus, setWhisperModelDownloadStatus] = useState< + "idle" | "downloading" | "downloaded" | "error" + >(initialPreferences.whisperModelPath ? "downloaded" : "idle"); + const [whisperModelDownloadProgress, setWhisperModelDownloadProgress] = useState(0); + const [isGeneratingCaptions, setIsGeneratingCaptions] = useState(false); + const [previewVolume, setPreviewVolume] = useState(1); + const [aspectRatio, setAspectRatio] = useState<AspectRatio>(initialPreferences.aspectRatio); + const [activeEffectSection, setActiveEffectSection] = useState<EditorEffectSection>("scene"); + const [showCropModal, setShowCropModal] = useState(false); + const [previewVersion, setPreviewVersion] = useState(0); + const [isPreviewReady, setIsPreviewReady] = useState(false); + const [autoSuggestZoomsTrigger, setAutoSuggestZoomsTrigger] = useState(0); + + const videoPlaybackRef = useRef<VideoPlaybackRef>(null); + const projectBrowserTriggerRef = useRef<HTMLButtonElement | null>(null); + const projectBrowserFallbackTriggerRef = useRef<HTMLButtonElement | null>(null); + const projectNameInputRef = useRef<HTMLInputElement | null>(null); + const projectSaveDialogInputRef = useRef<HTMLInputElement | null>(null); + const nextZoomIdRef = useRef(1); + const nextClipIdRef = useRef(1); + const clipInitializedRef = useRef(false); + const autoFullTrackClipIdRef = useRef<string | null>(null); + const autoFullTrackClipEndMsRef = useRef<number | null>(null); + const nextAudioIdRef = useRef(1); + const nextAnnotationIdRef = useRef(1); + const nextAnnotationZIndexRef = useRef(1); + const autoSuggestedVideoPathRef = useRef<string | null>(null); + const pendingFreshRecordingAutoZoomPathRef = useRef<string | null>(null); + const pendingFreshRecordingAutoSuggestTimeoutRef = useRef<number | null>(null); + const pendingFreshRecordingAutoSuggestTelemetryCountRef = useRef(0); + const cropSnapshotRef = useRef<CropRegion | null>(null); + const timelineRef = useRef<TimelineEditorHandle>(null); + + useEffect(() => { + void window.electronAPI?.getPlatform?.()?.then(setAppPlatform); + }, []); + useEffect(() => { + const handleOpenEditorSection = (event: Event) => { + const section = (event as CustomEvent<AnnouncementEditorSection>).detail; + if (isAnnouncementEditorSection(section)) setActiveEffectSection(section); + }; + window.addEventListener(OPEN_EDITOR_SECTION_EVENT, handleOpenEditorSection); + return () => window.removeEventListener(OPEN_EDITOR_SECTION_EVENT, handleOpenEditorSection); + }, []); + useEffect(() => { + if (activeEffectSection === "frame" || activeEffectSection === "crop") + setActiveEffectSection("scene"); + }, [activeEffectSection]); + + const applySessionPresentation = useCallback( + (session: SessionPresentation | null | undefined) => { + setSessionShowCursorOverride(session?.hideOverlayCursorByDefault ? false : null); + setSessionNativeCaptureUnavailable(Boolean(session?.nativeCaptureUnavailable)); + setNativeCaptureUnavailableModalOpen(Boolean(session?.nativeCaptureUnavailable)); + }, + [], + ); + const handleOpenCropEditor = useCallback(() => { + cropSnapshotRef.current = { ...cropRegion }; + setShowCropModal(true); + }, [cropRegion]); + const handleCloseCropEditor = useCallback(() => setShowCropModal(false), []); + const handleCancelCropEditor = useCallback(() => { + if (cropSnapshotRef.current) setCropRegion(cropSnapshotRef.current); + setShowCropModal(false); + }, [setCropRegion]); + const isCropped = useMemo(() => { + const top = Math.round(cropRegion.y * 100); + const left = Math.round(cropRegion.x * 100); + const bottom = Math.round((1 - cropRegion.y - cropRegion.height) * 100); + const right = Math.round((1 - cropRegion.x - cropRegion.width) * 100); + return top > 0 || left > 0 || bottom > 0 || right > 0; + }, [cropRegion]); + + return { + appPlatform, + isPlaying, + setIsPlaying, + currentTime, + setCurrentTime, + duration, + setDuration, + sessionShowCursorOverride, + setSessionShowCursorOverride, + sessionNativeCaptureUnavailable, + nativeCaptureUnavailableModalOpen, + setNativeCaptureUnavailableModalOpen, + whisperExecutablePath, + setWhisperExecutablePath, + whisperModelPath, + setWhisperModelPath, + downloadedWhisperModelPath, + setDownloadedWhisperModelPath, + whisperModelDownloadStatus, + setWhisperModelDownloadStatus, + whisperModelDownloadProgress, + setWhisperModelDownloadProgress, + isGeneratingCaptions, + setIsGeneratingCaptions, + previewVolume, + setPreviewVolume, + aspectRatio, + setAspectRatio, + activeEffectSection, + setActiveEffectSection, + showCropModal, + previewVersion, + setPreviewVersion, + isPreviewReady, + setIsPreviewReady, + autoSuggestZoomsTrigger, + setAutoSuggestZoomsTrigger, + videoPlaybackRef, + projectBrowserTriggerRef, + projectBrowserFallbackTriggerRef, + projectNameInputRef, + projectSaveDialogInputRef, + nextZoomIdRef, + nextClipIdRef, + clipInitializedRef, + autoFullTrackClipIdRef, + autoFullTrackClipEndMsRef, + nextAudioIdRef, + nextAnnotationIdRef, + nextAnnotationZIndexRef, + autoSuggestedVideoPathRef, + pendingFreshRecordingAutoZoomPathRef, + pendingFreshRecordingAutoSuggestTimeoutRef, + pendingFreshRecordingAutoSuggestTelemetryCountRef, + timelineRef, + applySessionPresentation, + handleOpenCropEditor, + handleCloseCropEditor, + handleCancelCropEditor, + isCropped, + }; +} diff --git a/src/components/video-editor/state/useProjectState.ts b/src/components/video-editor/state/useProjectState.ts new file mode 100644 index 000000000..1b5a3d7ac --- /dev/null +++ b/src/components/video-editor/state/useProjectState.ts @@ -0,0 +1,58 @@ +import { useState } from "react"; +import type { ProjectLibraryEntry } from "../ProjectBrowserDialog"; +import type { EditorProjectData } from "../projectPersistence"; + +export function useProjectState() { + const [videoPath, setVideoPath] = useState<string | null>(null); + const [videoSourcePath, setVideoSourcePath] = useState<string | null>(null); + const [currentProjectPath, setCurrentProjectPath] = useState<string | null>(null); + const [projectLibraryEntries, setProjectLibraryEntries] = useState<ProjectLibraryEntry[]>([]); + const [projectBrowserOpen, setProjectBrowserOpen] = useState(false); + const [isEditingProjectName, setIsEditingProjectName] = useState(false); + const [projectNameDraft, setProjectNameDraft] = useState(""); + const [isSavingProjectName, setIsSavingProjectName] = useState(false); + const [projectSaveDialogOpen, setProjectSaveDialogOpen] = useState(false); + const [projectSaveDialogDraft, setProjectSaveDialogDraft] = useState(""); + const [isSavingProjectDialog, setIsSavingProjectDialog] = useState(false); + const [unsavedChangesDialogOpen, setUnsavedChangesDialogOpen] = useState(false); + const [unsavedChangesDialogActionLabel, setUnsavedChangesDialogActionLabel] = + useState("continue"); + const [lastSavedSnapshot, setLastSavedSnapshot] = useState<EditorProjectData | null>(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState<string | null>(null); + + return { + videoPath, + setVideoPath, + videoSourcePath, + setVideoSourcePath, + currentProjectPath, + setCurrentProjectPath, + projectLibraryEntries, + setProjectLibraryEntries, + projectBrowserOpen, + setProjectBrowserOpen, + isEditingProjectName, + setIsEditingProjectName, + projectNameDraft, + setProjectNameDraft, + isSavingProjectName, + setIsSavingProjectName, + projectSaveDialogOpen, + setProjectSaveDialogOpen, + projectSaveDialogDraft, + setProjectSaveDialogDraft, + isSavingProjectDialog, + setIsSavingProjectDialog, + unsavedChangesDialogOpen, + setUnsavedChangesDialogOpen, + unsavedChangesDialogActionLabel, + setUnsavedChangesDialogActionLabel, + lastSavedSnapshot, + setLastSavedSnapshot, + loading, + setLoading, + error, + setError, + }; +} diff --git a/src/components/video-editor/state/useTimelineState.ts b/src/components/video-editor/state/useTimelineState.ts new file mode 100644 index 000000000..155d9a54f --- /dev/null +++ b/src/components/video-editor/state/useTimelineState.ts @@ -0,0 +1,82 @@ +import { useState } from "react"; +import type { SourceAudioTrackSettings } from "../audio/audioTypes"; +import type { + AnnotationRegion, + AudioRegion, + AutoCaptionSettings, + CaptionCue, + ClipRegion, + CursorTelemetryPoint, + SpeedRegion, + TrimRegion, + ZoomRegion, +} from "../types"; +import { DEFAULT_AUTO_CAPTION_SETTINGS } from "../types"; + +export function useTimelineState() { + const [zoomRegions, setZoomRegions] = useState<ZoomRegion[]>([]); + const [cursorTelemetry, setCursorTelemetry] = useState<CursorTelemetryPoint[]>([]); + const [cursorTelemetrySourcePath, setCursorTelemetrySourcePath] = useState<string | null>(null); + const [selectedZoomId, setSelectedZoomId] = useState<string | null>(null); + const [trimRegions, setTrimRegions] = useState<TrimRegion[]>([]); + const [clipRegions, setClipRegions] = useState<ClipRegion[]>([]); + const [selectedClipId, setSelectedClipId] = useState<string | null>(null); + const [speedRegions, setSpeedRegions] = useState<SpeedRegion[]>([]); + const [annotationRegions, setAnnotationRegions] = useState<AnnotationRegion[]>([]); + const [selectedAnnotationId, setSelectedAnnotationId] = useState<string | null>(null); + const [audioRegions, setAudioRegions] = useState<AudioRegion[]>([]); + const [selectedAudioId, setSelectedAudioId] = useState<string | null>(null); + const [selectedCaptionId, setSelectedCaptionId] = useState<string | null>(null); + const [sourceAudioTrackSettingsByClip, setSourceAudioTrackSettingsByClip] = useState< + Record<string, SourceAudioTrackSettings> + >({}); + const [defaultSourceAudioTrackSettings, setDefaultSourceAudioTrackSettings] = + useState<SourceAudioTrackSettings>({}); + const [sourceAudioFallbackRefreshKey, setSourceAudioFallbackRefreshKey] = useState(0); + const [hasClipSourceAudio, setHasClipSourceAudio] = useState(false); + const [autoCaptions, setAutoCaptions] = useState<CaptionCue[]>([]); + const [autoCaptionSettings, setAutoCaptionSettings] = useState<AutoCaptionSettings>( + DEFAULT_AUTO_CAPTION_SETTINGS, + ); + + return { + zoomRegions, + setZoomRegions, + cursorTelemetry, + setCursorTelemetry, + cursorTelemetrySourcePath, + setCursorTelemetrySourcePath, + selectedZoomId, + setSelectedZoomId, + trimRegions, + setTrimRegions, + clipRegions, + setClipRegions, + selectedClipId, + setSelectedClipId, + speedRegions, + setSpeedRegions, + annotationRegions, + setAnnotationRegions, + selectedAnnotationId, + setSelectedAnnotationId, + audioRegions, + setAudioRegions, + selectedAudioId, + setSelectedAudioId, + selectedCaptionId, + setSelectedCaptionId, + sourceAudioTrackSettingsByClip, + setSourceAudioTrackSettingsByClip, + defaultSourceAudioTrackSettings, + setDefaultSourceAudioTrackSettings, + sourceAudioFallbackRefreshKey, + setSourceAudioFallbackRefreshKey, + hasClipSourceAudio, + setHasClipSourceAudio, + autoCaptions, + setAutoCaptions, + autoCaptionSettings, + setAutoCaptionSettings, + }; +} diff --git a/src/components/video-editor/timeline/Item.module.css b/src/components/video-editor/timeline/Item.module.css deleted file mode 100644 index ac79e29e0..000000000 --- a/src/components/video-editor/timeline/Item.module.css +++ /dev/null @@ -1,12 +0,0 @@ -.itemDark { - background: #23232a; - border: 1px solid #34B27B; - border-radius: 12px; - box-shadow: 0 2px 12px rgba(0,0,0,0.18); - color: #e5e7eb; - transition: box-shadow 0.2s, border 0.2s, background 0.2s; -} -.squircle { - border-radius: 12px; - -corner-smoothing: antialiased; -} diff --git a/src/components/video-editor/timeline/Item.tsx b/src/components/video-editor/timeline/Item.tsx index 4c9c79fe3..7dff788be 100644 --- a/src/components/video-editor/timeline/Item.tsx +++ b/src/components/video-editor/timeline/Item.tsx @@ -1,49 +1,297 @@ -import { useItem } from "dnd-timeline"; +import { + FilmSlate as Film, + Gauge, + ChatCircle as MessageSquare, + MusicNotes as Music, + MouseLeftClickIcon as PhMouseLeftClick, + Scissors, + SpeakerX, + MagnifyingGlassPlus as ZoomIn, +} from "@phosphor-icons/react"; import type { Span } from "dnd-timeline"; +import { useItem } from "dnd-timeline"; +import { useMemo } from "react"; +import { Skeleton } from "@/components/ui/skeleton"; import { cn } from "@/lib/utils"; +import { formatClipSpeedLabel } from "../clipSpeedChange"; +import AudioWaveform from "./components/waveform/AudioWaveform"; +import type { AudioPeaksData } from "./core/timelineTypes"; import glassStyles from "./ItemGlass.module.css"; interface ItemProps { - id: string; - span: Span; - rowId: string; - children: React.ReactNode; - isSelected?: boolean; - onSelect?: () => void; + id: string; + span: Span; + rowId: string; + disabled?: boolean; + children: React.ReactNode; + isSelected?: boolean; + onSelect?: () => void; + onSelectId?: (id: string) => void; + zoomDepth?: number; + zoomMode?: "auto" | "manual"; + speedValue?: number; + waveformPeaks?: AudioPeaksData | null; + waveformSegmentSpan?: Span; + waveformGain?: number; + waveformNormalize?: boolean; + muted?: boolean; + variant?: "zoom" | "trim" | "clip" | "annotation" | "speed" | "audio" | "caption"; + isLoading?: boolean; + loadingLabel?: string; +} + +// Map zoom depth to multiplier labels +const ZOOM_LABELS: Record<number, string> = { + 1: "1.25×", + 2: "1.5×", + 3: "1.8×", + 4: "2.2×", + 5: "3.5×", + 6: "5×", +}; + +function formatMs(ms: number): string { + const totalSeconds = ms / 1000; + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + if (minutes > 0) { + return `${minutes}:${seconds.toFixed(1).padStart(4, "0")}`; + } + return `${seconds.toFixed(1)}s`; } -export default function Item({ id, span, rowId, isSelected: _isSelected = false, onSelect }: ItemProps) { - const { setNodeRef, attributes, listeners, itemStyle, itemContentStyle } = useItem({ - id, - span, - data: { rowId }, - }); - - return ( - <div - ref={setNodeRef} - style={itemStyle} - {...listeners} - {...attributes} - onPointerDownCapture={() => onSelect?.()} - className={cn(glassStyles.itemDark)} - > - <div style={itemContentStyle}> - <div - className={cn( - "w-full overflow-hidden flex items-center justify-center transition-all duration-150 cursor-grab active:cursor-grabbing group relative", - glassStyles.glassPurple - )} - style={{ height: 48 }} - onClick={(event) => { - event.stopPropagation(); - onSelect?.(); - }} - > - <div className={cn(glassStyles.zoomEndCap, glassStyles.left)} /> - <div className={cn(glassStyles.zoomEndCap, glassStyles.right)} /> - </div> - </div> - </div> - ); -} \ No newline at end of file +export default function Item({ + id, + span, + rowId, + disabled = false, + isSelected = false, + onSelect, + onSelectId, + zoomDepth = 1, + zoomMode = "auto", + speedValue, + waveformPeaks = null, + waveformSegmentSpan, + waveformGain = 1, + waveformNormalize = false, + muted = false, + variant = "zoom", + isLoading = false, + loadingLabel, + children, +}: ItemProps) { + const { setNodeRef, attributes, listeners, itemStyle, itemContentStyle } = useItem({ + id, + span, + disabled: disabled || isLoading, + data: { rowId }, + }); + + const timeLabel = useMemo( + () => `${formatMs(span.start)} – ${formatMs(span.end)}`, + [span.start, span.end], + ); + + if (isLoading) { + return ( + <div + ref={setNodeRef} + style={{ + ...itemStyle, + height: "100%", + display: "flex", + alignItems: "center", + }} + {...listeners} + {...attributes} + data-timeline-item="true" + onMouseDownCapture={(event) => event.stopPropagation()} + onClickCapture={(event) => event.stopPropagation()} + > + <Skeleton + variant="clip" + animation="shimmer-premium" + label={loadingLabel || "Loading..."} + className="w-full" + style={{ height: "85%", minHeight: 22 }} + /> + </div> + ); + } + + const isZoom = variant === "zoom"; + const isTrim = variant === "trim"; + const isClip = variant === "clip"; + const isSpeed = variant === "speed"; + const isAudio = variant === "audio"; + const isCaption = variant === "caption"; + const showAudioWaveform = isAudio && Boolean(waveformPeaks); + const clipSpeedLabel = isClip ? formatClipSpeedLabel(speedValue ?? 1) : null; + + const glassClass = isZoom + ? glassStyles.glassPurple + : isTrim + ? glassStyles.glassRed + : isClip + ? glassStyles.glassCyan + : isSpeed + ? glassStyles.glassAmber + : isAudio + ? glassStyles.glassDarkGreen + : isCaption + ? glassStyles.glassCaption + : glassStyles.glassYellow; + + const MIN_ITEM_PX = 6; + const handleSelect = () => { + onSelect?.(); + onSelectId?.(id); + }; + const safeItemStyle = { + ...itemStyle, + minWidth: MIN_ITEM_PX, + height: "100%", + overflow: "hidden", + }; + + return ( + <div + ref={setNodeRef} + style={safeItemStyle} + {...listeners} + {...attributes} + data-timeline-item="true" + onPointerDownCapture={handleSelect} + className="group h-full" + > + <div + className="h-full" + style={{ + ...itemContentStyle, + minWidth: MIN_ITEM_PX, + height: "100%", + display: "flex", + alignItems: "center", + }} + > + <div + className={cn( + glassClass, + "w-full overflow-hidden flex items-center justify-center gap-1.5 cursor-grab active:cursor-grabbing relative", + isSelected && glassStyles.selected, + )} + style={{ + height: "85%", + minHeight: 22, + minWidth: MIN_ITEM_PX, + }} + onClick={(event) => { + event.stopPropagation(); + }} + > + <div + className={cn(glassStyles.zoomEndCap, glassStyles.left)} + style={{ cursor: "col-resize", pointerEvents: "auto" }} + title="Resize left" + /> + <div + className={cn(glassStyles.zoomEndCap, glassStyles.right)} + style={{ cursor: "col-resize", pointerEvents: "auto" }} + title="Resize right" + /> + {showAudioWaveform && waveformPeaks && ( + <AudioWaveform + peaks={waveformPeaks} + segmentStartMs={waveformSegmentSpan?.start ?? span.start} + segmentEndMs={waveformSegmentSpan?.end ?? span.end} + gain={waveformGain} + normalize={waveformNormalize} + className="absolute inset-0 w-full h-full pointer-events-none opacity-45" + /> + )} + {/* Muted overlay for source audio track items */} + {isAudio && muted && ( + <div className="absolute inset-0 z-20 flex items-center justify-center gap-1 bg-red-900/40 pointer-events-none"> + <SpeakerX className="w-3 h-3 text-red-300/90 shrink-0" /> + </div> + )} + {/* Content */} + <div className="relative z-10 flex flex-col items-center justify-center text-black/70 dark:text-white/90 opacity-80 group-hover:opacity-100 transition-opacity select-none overflow-hidden"> + <div className="flex items-center gap-1.5"> + {isZoom ? ( + <> + <ZoomIn className="w-3.5 h-3.5 shrink-0" /> + <span className="text-[11px] font-semibold tracking-tight whitespace-nowrap"> + {ZOOM_LABELS[zoomDepth] || `${zoomDepth}×`} + </span> + </> + ) : isTrim ? ( + <> + <Scissors className="w-3.5 h-3.5 shrink-0" /> + <span className="text-[11px] font-semibold tracking-tight whitespace-nowrap"> + Trim + </span> + </> + ) : isClip ? ( + <> + <Film className="w-3.5 h-3.5 shrink-0" /> + <span className="text-[11px] font-semibold tracking-tight whitespace-nowrap"> + Clip + </span> + {clipSpeedLabel && ( + <span className="rounded-[4px] bg-black/10 px-1 text-[9px] font-bold tabular-nums text-black/65 dark:bg-white/15 dark:text-white/80"> + {clipSpeedLabel} + </span> + )} + </> + ) : isSpeed ? ( + <> + <Gauge className="w-3.5 h-3.5 shrink-0" /> + <span className="text-[11px] font-semibold tracking-tight whitespace-nowrap"> + {speedValue !== undefined ? `${speedValue}×` : "Speed"} + </span> + </> + ) : isAudio ? ( + <> + <Music className="w-3.5 h-3.5 shrink-0" /> + <span className="text-[11px] font-semibold tracking-tight truncate max-w-full"> + {children} + </span> + </> + ) : ( + <> + <MessageSquare className="w-3.5 h-3.5 shrink-0" /> + <span className="text-[11px] font-semibold tracking-tight whitespace-nowrap"> + {children} + </span> + </> + )} + </div> + {isZoom ? ( + <div + className={`flex items-center gap-0.5 transition-opacity ${isSelected ? "opacity-70" : "opacity-0 group-hover:opacity-50"}`} + > + <PhMouseLeftClick + className="w-2.5 h-2.5 shrink-0" + weight={zoomMode === "manual" ? "regular" : "fill"} + /> + <span className="text-[9px] font-medium tracking-tight whitespace-nowrap"> + {zoomMode === "manual" ? "Manual" : "Auto"} + </span> + </div> + ) : ( + <span + className={`text-[9px] tabular-nums tracking-tight whitespace-nowrap transition-opacity ${ + isSelected ? "opacity-60" : "opacity-0 group-hover:opacity-40" + }`} + > + {timeLabel} + </span> + )} + </div> + </div> + </div> + </div> + ); +} diff --git a/src/components/video-editor/timeline/ItemGlass.module.css b/src/components/video-editor/timeline/ItemGlass.module.css index e5aa9640a..1ad9fdd0c 100644 --- a/src/components/video-editor/timeline/ItemGlass.module.css +++ b/src/components/video-editor/timeline/ItemGlass.module.css @@ -1,33 +1,347 @@ +.glassGreen { + position: relative; + border-radius: 8px; + background: #183065; + border: 1px solid #1b3a7f; + transition: + background 0.15s ease, + border-color 0.15s ease; +} + +.glassGreen:hover { + background: #1a387a; + border-color: #1e469f; +} + +.glassGreen.selected { + background: #1c3e89; + border-color: #2563eb; + box-shadow: inset 0 0 0 1.5px #2563eb; + z-index: 10; +} + +.glassRed { + position: relative; + border-radius: 8px; + background: #652425; + border: 1px solid #802a2b; + transition: + background 0.15s ease, + border-color 0.15s ease; +} + +.glassRed:hover { + background: #7b292a; + border-color: #a13232; +} + +.glassRed.selected { + background: #8b2d2d; + border-color: #ef4444; + box-shadow: inset 0 0 0 1.5px #ef4444; + z-index: 10; +} + +.glassYellow { + position: relative; + border-radius: 8px; + background: #4e4726; + border: 1px solid #62582c; + transition: + background 0.15s ease, + border-color 0.15s ease; +} + +.glassYellow:hover { + background: #5f552b; + border-color: #7a6d34; +} + +.glassYellow.selected { + background: #6a5f2f; + border-color: #b4a046; + box-shadow: inset 0 0 0 1.5px #b4a046; + z-index: 10; +} + +.glassAmber { + position: relative; + border-radius: 8px; + background: #67460f; + border: 1px solid #83570f; + transition: + background 0.15s ease, + border-color 0.15s ease; +} + +.glassAmber:hover { + background: #7e540f; + border-color: #a56c0d; +} + +.glassAmber.selected { + background: #8e5e0e; + border-color: #f59e0b; + box-shadow: inset 0 0 0 1.5px #f59e0b; + z-index: 10; +} + .glassPurple { - position: relative; - border-radius: 12px; - -corner-smoothing: antialiased; - background: #34B27B; - border: none; - box-shadow: 0 2px 12px 0 rgba(52,178,123,0.14) inset, 0 2px 8px 0 rgba(0,0,0,0.10), 0 1px 6px 0 rgba(52,178,123,0.08); - margin: 0 4px; - backdrop-filter: blur(2px) saturate(120%); - -webkit-backdrop-filter: blur(2px) saturate(120%); + position: relative; + border-radius: 8px; + background: #4a2a69; + border: 1px solid #5c3385; + transition: + background 0.15s ease, + border-color 0.15s ease; +} + +.glassPurple:hover { + background: #593180; + border-color: #733da7; +} + +.glassPurple.selected { + background: #643690; + border-color: #a855f7; + box-shadow: inset 0 0 0 1.5px #a855f7; + z-index: 10; +} + +.glassCyan { + position: relative; + border-radius: 8px; + background: #183065; + border: 1px solid #1b3a7f; + transition: + background 0.15s ease, + border-color 0.15s ease; +} + +.glassCyan:hover { + background: #1a387a; + border-color: #1e469f; +} + +.glassCyan.selected { + background: #1c3e89; + border-color: #2563eb; + box-shadow: inset 0 0 0 1.5px #2563eb; + z-index: 10; +} + +.glassDarkGreen { + position: relative; + border-radius: 8px; + background: #164f2d; + border: 1px solid #196b38; + transition: + background 0.15s ease, + border-color 0.15s ease; +} + +.glassDarkGreen:hover { + background: #186234; + border-color: #1c8643; +} + +.glassDarkGreen.selected { + background: #196e3a; + border-color: #22c55e; + box-shadow: inset 0 0 0 1.5px #22c55e; + z-index: 10; +} + +.glassCaption { + position: relative; + border-radius: 8px; + background: #5b2746; + border: 1px solid #7a3157; + transition: + background 0.15s ease, + border-color 0.15s ease; +} + +.glassCaption:hover { + background: #6d2f54; + border-color: #933a68; } +.glassCaption.selected { + background: #7a3157; + border-color: #f472b6; + box-shadow: inset 0 0 0 1.5px #f472b6; + z-index: 10; +} + +/* Pill resize handle — visible on hover */ .zoomEndCap { - position: absolute; - top: 0; - background: #315d4a; + position: absolute; + top: 50%; + bottom: auto; + height: 70%; + width: 4px; + pointer-events: auto; + z-index: 2; + opacity: 0; + display: flex; + align-items: center; + justify-content: center; + transform: translateY(-50%); + border-radius: 2px; + background: rgba(255, 255, 255, 0.88); + transition: + opacity 0.15s ease, + background 0.15s ease; +} + +/* --- Light-mode overrides --- */ +:global(:root:not(.dark)) .glassGreen { + background: linear-gradient(180deg, #dbeafe 0%, #93c5fd 100%); + border-color: #60a5fa; +} +:global(:root:not(.dark)) .glassGreen:hover { + background: linear-gradient(180deg, #bfdbfe 0%, #60a5fa 100%); + border-color: #3b82f6; +} +:global(:root:not(.dark)) .glassGreen.selected { + background: linear-gradient(180deg, #93c5fd 0%, #3b82f6 100%); + border-color: #2563eb; + box-shadow: inset 0 0 0 1.5px #2563eb; +} + +:global(:root:not(.dark)) .glassRed { + background: linear-gradient(180deg, #fee2e2 0%, #fca5a5 100%); + border-color: #f87171; +} +:global(:root:not(.dark)) .glassRed:hover { + background: linear-gradient(180deg, #fecaca 0%, #f87171 100%); + border-color: #ef4444; +} +:global(:root:not(.dark)) .glassRed.selected { + background: linear-gradient(180deg, #fca5a5 0%, #ef4444 100%); + border-color: #ef4444; + box-shadow: inset 0 0 0 1.5px #ef4444; +} - width: 18px; - height: 100%; - pointer-events: none; - z-index: 2; - transition: background 0.2s, box-shadow 0.2s; +:global(:root:not(.dark)) .glassYellow { + background: linear-gradient(180deg, #fef9c3 0%, #fde047 100%); + border-color: #facc15; +} +:global(:root:not(.dark)) .glassYellow:hover { + background: linear-gradient(180deg, #fef08a 0%, #facc15 100%); + border-color: #eab308; +} +:global(:root:not(.dark)) .glassYellow.selected { + background: linear-gradient(180deg, #fde047 0%, #eab308 100%); + border-color: #b4a046; + box-shadow: inset 0 0 0 1.5px #b4a046; +} + +:global(:root:not(.dark)) .glassAmber { + background: linear-gradient(180deg, #fef3c7 0%, #fcd34d 100%); + border-color: #fbbf24; +} +:global(:root:not(.dark)) .glassAmber:hover { + background: linear-gradient(180deg, #fde68a 0%, #fbbf24 100%); + border-color: #f59e0b; +} +:global(:root:not(.dark)) .glassAmber.selected { + background: linear-gradient(180deg, #fcd34d 0%, #f59e0b 100%); + border-color: #f59e0b; + box-shadow: inset 0 0 0 1.5px #f59e0b; +} + +:global(:root:not(.dark)) .glassPurple { + background: linear-gradient(180deg, #f3e8ff 0%, #d8b4fe 100%); + border-color: #c084fc; +} +:global(:root:not(.dark)) .glassPurple:hover { + background: linear-gradient(180deg, #e9d5ff 0%, #c084fc 100%); + border-color: #a855f7; +} +:global(:root:not(.dark)) .glassPurple.selected { + background: linear-gradient(180deg, #d8b4fe 0%, #a855f7 100%); + border-color: #a855f7; + box-shadow: inset 0 0 0 1.5px #a855f7; +} + +:global(:root:not(.dark)) .glassCyan { + background: linear-gradient(180deg, #dbeafe 0%, #93c5fd 100%); + border-color: #60a5fa; +} +:global(:root:not(.dark)) .glassCyan:hover { + background: linear-gradient(180deg, #bfdbfe 0%, #60a5fa 100%); + border-color: #3b82f6; +} +:global(:root:not(.dark)) .glassCyan.selected { + background: linear-gradient(180deg, #93c5fd 0%, #3b82f6 100%); + border-color: #2563eb; + box-shadow: inset 0 0 0 1.5px #2563eb; +} + +:global(:root:not(.dark)) .glassDarkGreen { + background: linear-gradient(180deg, #dcfce7 0%, #86efac 100%); + border-color: #4ade80; +} +:global(:root:not(.dark)) .glassDarkGreen:hover { + background: linear-gradient(180deg, #bbf7d0 0%, #4ade80 100%); + border-color: #22c55e; +} +:global(:root:not(.dark)) .glassDarkGreen.selected { + background: linear-gradient(180deg, #86efac 0%, #22c55e 100%); + border-color: #22c55e; + box-shadow: inset 0 0 0 1.5px #22c55e; +} + +:global(:root:not(.dark)) .glassCaption { + background: linear-gradient(180deg, #fce7f3 0%, #f9a8d4 100%); + border-color: #f472b6; +} +:global(:root:not(.dark)) .glassCaption:hover { + background: linear-gradient(180deg, #fbcfe8 0%, #f472b6 100%); + border-color: #ec4899; +} +:global(:root:not(.dark)) .glassCaption.selected { + background: linear-gradient(180deg, #f9a8d4 0%, #ec4899 100%); + border-color: #ec4899; + box-shadow: inset 0 0 0 1.5px #ec4899; +} + +:global(:root:not(.dark)) .zoomEndCap { + background: rgba(0, 0, 0, 0.5); +} +:global(:root:not(.dark)) .zoomEndCap:hover { + background: rgba(0, 0, 0, 0.7); +} + +.zoomEndCap:hover { + background: rgba(255, 255, 255, 1); +} + +.glassGreen:hover .zoomEndCap, +.glassRed:hover .zoomEndCap, +.glassYellow:hover .zoomEndCap, +.glassAmber:hover .zoomEndCap, +.glassPurple:hover .zoomEndCap, +.glassCyan:hover .zoomEndCap, +.glassDarkGreen:hover .zoomEndCap, +.glassCaption:hover .zoomEndCap { + opacity: 1; } .zoomEndCap.left { - left: -9px; - cursor: ew-resize; + left: 6px; + cursor: col-resize; } + .zoomEndCap.right { - right: -9px; - transform: scaleX(-1); - cursor: ew-resize; + right: 6px; + cursor: col-resize; +} + +/* Grip lines removed */ +.zoomEndCap::before, +.zoomEndCap::after { + content: none; } diff --git a/src/components/video-editor/timeline/Row.tsx b/src/components/video-editor/timeline/Row.tsx index 3456b5a1f..d22257f84 100644 --- a/src/components/video-editor/timeline/Row.tsx +++ b/src/components/video-editor/timeline/Row.tsx @@ -1,21 +1,64 @@ -import { useRow } from "dnd-timeline"; import type { RowDefinition } from "dnd-timeline"; +import { useRow } from "dnd-timeline"; interface RowProps extends RowDefinition { - children: React.ReactNode; + children: React.ReactNode; + label?: string; + hint?: string; + isEmpty?: boolean; + labelColor?: string; + onMouseEnter?: React.MouseEventHandler<HTMLDivElement>; + onMouseMove?: React.MouseEventHandler<HTMLDivElement>; + onMouseLeave?: React.MouseEventHandler<HTMLDivElement>; + onMouseDown?: React.MouseEventHandler<HTMLDivElement>; + onClick?: React.MouseEventHandler<HTMLDivElement>; } -export default function Row({ id, children }: RowProps) { - const { setNodeRef, rowWrapperStyle, rowStyle } = useRow({ id }); +export default function Row({ + id, + children, + label, + hint, + isEmpty, + labelColor = "#666", + onMouseEnter, + onMouseMove, + onMouseLeave, + onMouseDown, + onClick, +}: RowProps) { + const { setNodeRef, rowWrapperStyle, rowStyle } = useRow({ id }); - return ( - <div - className="border-b border-[#18181b] bg-[#18181b]" - style={{ ...rowWrapperStyle, minHeight: 88 }} - > - <div ref={setNodeRef} style={rowStyle}> - {children} - </div> - </div> - ); -} \ No newline at end of file + return ( + <div + className="bg-transparent relative flex-1 min-h-[26px]" + style={{ ...rowWrapperStyle, marginBottom: 2 }} + > + {label && ( + <div + className="absolute left-1.5 top-1/2 -translate-y-1/2 text-[9px] font-semibold uppercase tracking-widest z-20 pointer-events-none select-none" + style={{ color: labelColor, writingMode: "horizontal-tb" }} + > + {label} + </div> + )} + {isEmpty && hint && ( + <div className="absolute inset-0 flex items-center justify-center pointer-events-none select-none z-10"> + <span className="text-[11px] text-foreground/15 font-medium">{hint}</span> + </div> + )} + <div + ref={setNodeRef} + className="relative h-full min-h-[26px] overflow-hidden" + style={rowStyle} + onMouseEnter={onMouseEnter} + onMouseMove={onMouseMove} + onMouseLeave={onMouseLeave} + onMouseDown={onMouseDown} + onClick={onClick} + > + {children} + </div> + </div> + ); +} diff --git a/src/components/video-editor/timeline/Subrow.tsx b/src/components/video-editor/timeline/Subrow.tsx deleted file mode 100644 index 1c7e8a1e7..000000000 --- a/src/components/video-editor/timeline/Subrow.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { cn } from "@/lib/utils"; -interface SubrowProps { - children: React.ReactNode; -} - -export default function Subrow({ children }: SubrowProps) { - return ( - <div className={cn("flex items-center min-h-[32px] gap-1 px-2 py-0.5 bg-[#23232a] rounded-md text-slate-300")}> - {children} - </div> - ); -} \ No newline at end of file diff --git a/src/components/video-editor/timeline/TimelineEditor.tsx b/src/components/video-editor/timeline/TimelineEditor.tsx index 150061db2..46a610f29 100644 --- a/src/components/video-editor/timeline/TimelineEditor.tsx +++ b/src/components/video-editor/timeline/TimelineEditor.tsx @@ -1,521 +1,524 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; -import { useTimelineContext } from "dnd-timeline"; -import { Button } from "@/components/ui/button"; -import { Plus } from "lucide-react"; -import { toast } from "sonner"; -import TimelineWrapper from "./TimelineWrapper"; -import Row from "./Row"; -import Item from "./Item"; -import type { Range, Span } from "dnd-timeline"; -import type { ZoomRegion } from "../types"; - -const ROW_ID = "row-1"; -const FALLBACK_RANGE_MS = 1000; -const TARGET_MARKER_COUNT = 12; - -interface TimelineEditorProps { - videoDuration: number; - currentTime: number; - onSeek?: (time: number) => void; - zoomRegions: ZoomRegion[]; - onZoomAdded: (span: Span) => void; - onZoomSpanChange: (id: string, span: Span) => void; - onZoomDelete: (id: string) => void; - selectedZoomId: string | null; - onSelectZoom: (id: string | null) => void; -} - -interface TimelineScaleConfig { - intervalMs: number; - gridMs: number; - minItemDurationMs: number; - defaultItemDurationMs: number; - minVisibleRangeMs: number; -} - -interface TimelineRenderItem { - id: string; - rowId: string; - span: Span; - label: string; -} - -const SCALE_CANDIDATES = [ - { intervalSeconds: 0.25, gridSeconds: 0.05 }, - { intervalSeconds: 0.5, gridSeconds: 0.1 }, - { intervalSeconds: 1, gridSeconds: 0.25 }, - { intervalSeconds: 2, gridSeconds: 0.5 }, - { intervalSeconds: 5, gridSeconds: 1 }, - { intervalSeconds: 10, gridSeconds: 2 }, - { intervalSeconds: 15, gridSeconds: 3 }, - { intervalSeconds: 30, gridSeconds: 5 }, - { intervalSeconds: 60, gridSeconds: 10 }, - { intervalSeconds: 120, gridSeconds: 20 }, - { intervalSeconds: 300, gridSeconds: 30 }, - { intervalSeconds: 600, gridSeconds: 60 }, - { intervalSeconds: 900, gridSeconds: 120 }, - { intervalSeconds: 1800, gridSeconds: 180 }, - { intervalSeconds: 3600, gridSeconds: 300 }, -]; - -function calculateTimelineScale(durationSeconds: number): TimelineScaleConfig { - const totalMs = Math.max(0, Math.round(durationSeconds * 1000)); - - const selectedCandidate = SCALE_CANDIDATES.find((candidate) => { - if (durationSeconds <= 0) { - return true; - } - const markers = durationSeconds / candidate.intervalSeconds; - return markers <= TARGET_MARKER_COUNT; - }) ?? SCALE_CANDIDATES[SCALE_CANDIDATES.length - 1]; - - const intervalMs = Math.round(selectedCandidate.intervalSeconds * 1000); - const gridMs = Math.round(selectedCandidate.gridSeconds * 1000); - - // Set minItemDurationMs to 1ms for maximum granularity - const minItemDurationMs = 1; - const defaultItemDurationMs = Math.min( - Math.max(minItemDurationMs, intervalMs * 2), - totalMs > 0 ? totalMs : intervalMs * 2, - ); - - const minVisibleRangeMs = totalMs > 0 - ? Math.min(Math.max(intervalMs * 3, minItemDurationMs * 6, 1000), totalMs) - : Math.max(intervalMs * 3, minItemDurationMs * 6, 1000); - - return { - intervalMs, - gridMs, - minItemDurationMs, - defaultItemDurationMs, - minVisibleRangeMs, - }; -} - -function createInitialRange(totalMs: number): Range { - if (totalMs > 0) { - return { start: 0, end: totalMs }; - } - - return { start: 0, end: FALLBACK_RANGE_MS }; -} - -function formatTimeLabel(milliseconds: number, intervalMs: number) { - const totalSeconds = milliseconds / 1000; - const hours = Math.floor(totalSeconds / 3600); - const minutes = Math.floor((totalSeconds % 3600) / 60); - const seconds = totalSeconds % 60; - - const fractionalDigits = intervalMs < 250 ? 2 : intervalMs < 1000 ? 1 : 0; - - if (hours > 0) { - const minutesString = minutes.toString().padStart(2, "0"); - const secondsString = Math.floor(seconds) - .toString() - .padStart(2, "0"); - return `${hours}:${minutesString}:${secondsString}`; - } - - if (fractionalDigits > 0) { - const secondsWithFraction = seconds.toFixed(fractionalDigits); - const [wholeSeconds, fraction] = secondsWithFraction.split("."); - return `${minutes}:${wholeSeconds.padStart(2, "0")}.${fraction}`; - } - - return `${minutes}:${Math.floor(seconds).toString().padStart(2, "0")}`; +import { Plus } from "@phosphor-icons/react"; +import type { Span } from "dnd-timeline"; +import { forwardRef, useEffect, useMemo, useRef, useState } from "react"; +import type { + SourceAudioTrackMeta, + SourceAudioTrackSettings, +} from "@/components/video-editor/audio/audioTypes"; +import { useScopedT } from "@/contexts/I18nContext"; +import { useShortcuts } from "@/contexts/ShortcutsContext"; +import { fromFileUrl } from "../projectPersistence"; +import type { + AnnotationRegion, + AudioRegion, + CaptionCue, + ClipRegion, + CursorTelemetryPoint, + SpeedRegion, + TrimRegion, + ZoomFocus, + ZoomRegion, +} from "../types"; +import KeyframeMarkers from "./components/markers/KeyframeMarkers"; +import TimelineCanvas from "./components/viewport/TimelineCanvas"; +import TimelineWrapper from "./components/wrapper/TimelineWrapper"; +import { calculateTimelineScale } from "./core/time"; +import { useTimelineAudioPeaks } from "./hooks/useTimelineAudioPeaks"; +import { useTimelineEditorRuntime } from "./hooks/useTimelineEditorRuntime"; +import { useTimelineRange } from "./hooks/useTimelineRange"; +import { + buildSourceSidecarPathCandidates, + buildTimelineSourceAudioTracks, +} from "./sourceAudioTracks"; + +export interface TimelineEditorProps { + videoDuration: number; + currentTime: number; + playheadTime?: number; + onSeek?: (time: number) => void; + cursorTelemetry?: CursorTelemetryPoint[]; + autoSuggestZoomsTrigger?: number; + onAutoSuggestZoomsConsumed?: () => void; + disableSuggestedZooms?: boolean; + zoomRegions: ZoomRegion[]; + onZoomAdded: (span: Span) => void; + onZoomSuggested?: (span: Span, focus: ZoomFocus) => void; + onZoomSpanChange: (id: string, span: Span) => void; + onZoomDelete: (id: string) => void; + selectedZoomId: string | null; + onSelectZoom: (id: string | null) => void; + trimRegions?: TrimRegion[]; + onTrimSpanChange?: (id: string, span: Span) => void; + clipRegions?: ClipRegion[]; + onClipSplit?: (splitMs: number) => void; + onClipSpanChange?: (id: string, span: Span) => void; + onClipDelete?: (id: string) => void; + selectedClipId?: string | null; + onSelectClip?: (id: string | null) => void; + annotationRegions?: AnnotationRegion[]; + onAnnotationAdded?: (span: Span, trackIndex?: number) => void; + onAnnotationSpanChange?: (id: string, span: Span, trackIndex?: number) => void; + onAnnotationDelete?: (id: string) => void; + selectedAnnotationId?: string | null; + onSelectAnnotation?: (id: string | null) => void; + speedRegions?: SpeedRegion[]; + onSpeedSpanChange?: (id: string, span: Span) => void; + audioRegions?: AudioRegion[]; + onAudioAdded?: (span: Span, audioPath: string, trackIndex?: number) => void; + onAudioSpanChange?: (id: string, span: Span, trackIndex?: number) => void; + onAudioDelete?: (id: string) => void; + selectedAudioId?: string | null; + onSelectAudio?: (id: string | null) => void; + captionRegions?: CaptionCue[]; + onCaptionSpanChange?: (id: string, span: Span) => void; + onCaptionDelete?: (id: string) => void; + onCaptionAdded?: (span: Span) => void; + captionsEnabled?: boolean; + captionQuickAddEnabled?: boolean; + selectedCaptionId?: string | null; + onSelectCaption?: (id: string | null) => void; + videoPath?: string | null; + videoSourcePath?: string | null; + cursorTelemetrySourcePath?: string | null; + showSourceAudioTrack?: boolean; + sourceAudioResourceVersion?: number; + onSourceAudioAvailabilityChange?: (available: boolean) => void; + sourceAudioTrackSettings?: SourceAudioTrackSettings; + getSourceAudioTrackSettingsForClip?: (clipId: string | null) => SourceAudioTrackSettings; + onSourceAudioTracksMetaChange?: (tracks: SourceAudioTrackMeta) => void; } -function PlaybackCursor({ - currentTimeMs, - videoDurationMs -}: { - currentTimeMs: number; - videoDurationMs: number; -}) { - const { sidebarWidth, direction, range, valueToPixels } = useTimelineContext(); - const sideProperty = direction === "rtl" ? "right" : "left"; - - if (videoDurationMs <= 0 || currentTimeMs < 0) { - return null; - } - - const clampedTime = Math.min(currentTimeMs, videoDurationMs); - - if (clampedTime < range.start || clampedTime > range.end) { - return null; - } - - const offset = valueToPixels(clampedTime - range.start); - - return ( - <div - className="absolute top-0 bottom-0 pointer-events-none z-50" - style={{ - [sideProperty === "right" ? "marginRight" : "marginLeft"]: `${sidebarWidth - 8}px`, - }} - > - <div - className="absolute top-3 bottom-3 w-[2px] bg-red-500/90 shadow-[0_0_8px_rgba(239,68,68,0.5)]" - style={{ - [sideProperty]: `${offset}px`, - }} - > - <div - className="absolute -top-2 left-1/2 -translate-x-1/2 flex flex-col items-center" - style={{ width: '32px' }} - > - <div - style={{ - width: '8px', - height: '8px', - background: '#ef4444', - borderRadius: '12px 12px 12px 12px/14px 14px 8px 8px', - boxShadow: '0 2px 8px rgba(0,0,0,0.10)', - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - fontSize: '11px', - fontWeight: 600, - color: '#ef4444', - letterSpacing: '-0.5px', - position: 'relative', - }} - > - - </div> - - </div> - <div className="absolute -top-1 left-1/2 -translate-x-1/2 w-2 h-2 bg-red-500/30 rounded-full blur-sm" /> - </div> - </div> - ); -} - -function TimelineAxis({ - intervalMs, - videoDurationMs, - currentTimeMs, -}: { - intervalMs: number; - videoDurationMs: number; - currentTimeMs: number; -}) { - const { sidebarWidth, direction, range, valueToPixels } = useTimelineContext(); - const sideProperty = direction === "rtl" ? "right" : "left"; - - const markers = useMemo(() => { - if (intervalMs <= 0) { - return [] as { time: number; label: string }[]; - } - - const maxTime = videoDurationMs > 0 ? videoDurationMs : range.end; - const visibleStart = Math.max(0, Math.min(range.start, maxTime)); - const visibleEnd = Math.min(range.end, maxTime); - const markerTimes = new Set<number>(); - - const firstMarker = Math.ceil(visibleStart / intervalMs) * intervalMs; - - for (let time = firstMarker; time <= maxTime; time += intervalMs) { - if (time >= visibleStart && time <= visibleEnd) { - markerTimes.add(Math.round(time)); - } - } - - if (visibleStart <= maxTime) { - markerTimes.add(Math.round(visibleStart)); - } - - if (videoDurationMs > 0) { - markerTimes.add(Math.round(videoDurationMs)); - } - - const sorted = Array.from(markerTimes) - .filter(time => time <= maxTime) - .sort((a, b) => a - b); - - return sorted.map((time) => ({ - time, - label: formatTimeLabel(time, intervalMs), - })); - }, [intervalMs, range.end, range.start, videoDurationMs]); - - return ( - <div - className="h-10 bg-black border-b border-[#18181b] relative overflow-hidden" - style={{ - [sideProperty === "right" ? "marginRight" : "marginLeft"]: `${sidebarWidth}px`, - }} - > - {markers.map((marker) => { - const offset = valueToPixels(marker.time - range.start); - const markerStyle: React.CSSProperties = { - position: "absolute", - bottom: 0, - height: "100%", - display: "flex", - flexDirection: "row", - alignItems: "flex-end", - [sideProperty]: `${offset}px`, - }; - - return ( - <div key={marker.time} style={markerStyle}> - <div style={{ display: 'flex', alignItems: 'center', height: '100%' }}> - <div - style={{ - width: '5px', - height: '5px', - borderRadius: '50%', - backgroundColor: marker.time === currentTimeMs ? '#34B27B' : '#94a3b8', - boxShadow: marker.time === currentTimeMs ? '0 0 4px #34B27B55' : 'none', - marginRight: '5px', - marginTop: '2px', - transition: 'background 0.2s, box-shadow 0.2s', - }} - /> - <span - style={{ - fontWeight: marker.time === currentTimeMs ? 700 : 500, - color: marker.time === currentTimeMs ? '#34B27B' : '#94a3b8', - fontSize: '11px', - letterSpacing: '-0.5px', - textShadow: marker.time === currentTimeMs ? '0 1px 6px #34B27B33' : 'none', - marginTop: '2px', - }} - className="select-none" - > - {marker.label} - </span> - </div> - </div> - ); - })} - </div> - ); +function extractLocalPathFromMediaServerUrl(input: string | null | undefined): string | null { + if (!input) return null; + try { + const url = new URL(input); + const isLocalMediaServer = + (url.protocol === "http:" || url.protocol === "https:") && + (url.hostname === "127.0.0.1" || url.hostname === "localhost") && + url.pathname === "/video"; + if (!isLocalMediaServer) return null; + return url.searchParams.get("path"); + } catch { + return null; + } } -function Timeline({ - items, - videoDurationMs, - intervalMs, - currentTimeMs, - onSeek, - onSelectZoom, - selectedZoomId, -}: { - items: TimelineRenderItem[]; - videoDurationMs: number; - intervalMs: number; - currentTimeMs: number; - onSeek?: (time: number) => void; - onSelectZoom?: (id: string | null) => void; - selectedZoomId: string | null; -}) { - const { setTimelineRef, style, sidebarWidth, range, pixelsToValue } = useTimelineContext(); - - const handleTimelineClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => { - if (!onSeek || videoDurationMs <= 0) return; - onSelectZoom?.(null); - - const rect = e.currentTarget.getBoundingClientRect(); - const clickX = e.clientX - rect.left - sidebarWidth; - - if (clickX < 0) return; - - const relativeMs = pixelsToValue(clickX); - const absoluteMs = Math.max(0, Math.min(range.start + relativeMs, videoDurationMs)); - const timeInSeconds = absoluteMs / 1000; - - onSeek(timeInSeconds); - }, [onSeek, onSelectZoom, videoDurationMs, sidebarWidth, range.start, pixelsToValue]); - - return ( - <div - ref={setTimelineRef} - style={style} - className="select-none bg-black min-h-[120px] relative cursor-pointer" - onClick={handleTimelineClick} - > - <TimelineAxis intervalMs={intervalMs} videoDurationMs={videoDurationMs} currentTimeMs={currentTimeMs} /> - <PlaybackCursor currentTimeMs={currentTimeMs} videoDurationMs={videoDurationMs} /> - <Row id={ROW_ID}> - {items.map((item) => ( - <Item - id={item.id} - key={item.id} - rowId={item.rowId} - span={item.span} - isSelected={item.id === selectedZoomId} - onSelect={() => onSelectZoom?.(item.id)} - > - {item.label} - </Item> - ))} - </Row> - </div> - ); +export interface TimelineEditorHandle { + addZoom: () => void; + suggestZooms: () => void; + splitClip: () => void; + addAnnotation: (trackIndex?: number) => void; + addAudio: (trackIndex?: number) => Promise<void>; + keyframes: { id: string; time: number }[]; } -export default function TimelineEditor({ - videoDuration, - currentTime, - onSeek, - zoomRegions, - onZoomAdded, - onZoomSpanChange, - selectedZoomId, - onSelectZoom, -}: TimelineEditorProps) { - const totalMs = useMemo(() => Math.max(0, Math.round(videoDuration * 1000)), [videoDuration]); - const currentTimeMs = useMemo(() => Math.round(currentTime * 1000), [currentTime]); - const timelineScale = useMemo(() => calculateTimelineScale(videoDuration), [videoDuration]); - const safeMinDurationMs = useMemo( - () => (totalMs > 0 ? Math.min(timelineScale.minItemDurationMs, totalMs) : timelineScale.minItemDurationMs), - [timelineScale.minItemDurationMs, totalMs], - ); - - const [range, setRange] = useState<Range>(() => createInitialRange(totalMs)); - - useEffect(() => { - setRange(createInitialRange(totalMs)); - }, [totalMs]); - - useEffect(() => { - if (totalMs === 0 || safeMinDurationMs <= 0) { - return; - } - - zoomRegions.forEach((region) => { - const clampedStart = Math.max(0, Math.min(region.startMs, totalMs)); - const minEnd = clampedStart + safeMinDurationMs; - const clampedEnd = Math.min(totalMs, Math.max(minEnd, region.endMs)); - const normalizedStart = Math.max(0, Math.min(clampedStart, totalMs - safeMinDurationMs)); - const normalizedEnd = Math.max(minEnd, Math.min(clampedEnd, totalMs)); - - if (normalizedStart !== region.startMs || normalizedEnd !== region.endMs) { - onZoomSpanChange(region.id, { start: normalizedStart, end: normalizedEnd }); - } - }); - }, [zoomRegions, totalMs, safeMinDurationMs, onZoomSpanChange]); - - const hasOverlap = useCallback((newSpan: Span, excludeId?: string): boolean => { - // Snap if gap is 2ms or less - return zoomRegions.some((region) => { - if (region.id === excludeId) return false; - const gapBefore = newSpan.start - region.endMs; - const gapAfter = region.startMs - newSpan.end; - if (gapBefore > 0 && gapBefore <= 2) return true; - if (gapAfter > 0 && gapAfter <= 2) return true; - return !(newSpan.end <= region.startMs || newSpan.start >= region.endMs); - }); - }, [zoomRegions]); - - const handleAddZoom = useCallback(() => { - if (!videoDuration || videoDuration === 0 || totalMs === 0) { - return; - } - - const defaultDuration = Math.min( - Math.max(3000, safeMinDurationMs), - totalMs, - ); - - if (defaultDuration <= 0) { - return; - } - - let startPos = 0; - const sorted = [...zoomRegions].sort((a, b) => a.startMs - b.startMs); - - for (const region of sorted) { - if (startPos + defaultDuration <= region.startMs) { - break; - } - startPos = Math.max(startPos, region.endMs); - } - - if (startPos + defaultDuration > totalMs) { - toast.error("No space available", { - description: "Remove or resize existing zoom regions to add more.", - }); - return; - } - - onZoomAdded({ start: startPos, end: startPos + defaultDuration }); - }, [videoDuration, totalMs, timelineScale.defaultItemDurationMs, safeMinDurationMs, zoomRegions, onZoomAdded]); - - const clampedRange = useMemo<Range>(() => { - if (totalMs === 0) { - return range; - } - - return { - start: Math.max(0, Math.min(range.start, totalMs)), - end: Math.min(range.end, totalMs), - }; - }, [range, totalMs]); - - const timelineItems = useMemo<TimelineRenderItem[]>(() => { - return [...zoomRegions] - .sort((a, b) => a.startMs - b.startMs) - .map((region, index) => ({ - id: region.id, - rowId: ROW_ID, - span: { start: region.startMs, end: region.endMs }, - label: `Zoom ${index + 1}`, - })); - }, [zoomRegions]); - - if (!videoDuration || videoDuration === 0) { - return ( - <div className="flex-1 flex items-center justify-center rounded-lg"> - <span className="text-slate-400 text-sm">Load a video to see timeline</span> - </div> - ); - } - - return ( - <div className="flex-1 flex flex-col bg-black border border-none rounded-xl shadow-lg overflow-hidden"> - <div className="flex items-center gap-3 px-4 py-2.5"> - <Button onClick={handleAddZoom} variant="outline" size="sm" className="gap-2 h-8 px-3 text-xs bg-[#23232a] border-none text-slate-200 hover:bg-white hover:text-black"> - <Plus className="w-3.5 h-3.5 text-slate-400" /> - Add Zoom - </Button> - <div className="flex-1" /> - <div className="flex items-center gap-3 text-[10px] text-slate-400 font-medium"> - <span className="flex items-center gap-1"> - <kbd className="px-1.5 py-0.5 bg-[#23232a] rounded text-slate-300">Command + Shift + Scroll</kbd> - <span>Pan</span> - </span> - <span className="text-slate-600">•</span> - <span className="flex items-center gap-1"> - <kbd className="px-1.5 py-0.5 bg-[#23232a] rounded text-slate-300">Command + Scroll</kbd> - <span>Zoom</span> - </span> - </div> - </div> - <div className="mt-4 flex-1 overflow-x-auto overflow-y-hidden bg-[#000]"> - <TimelineWrapper - range={clampedRange} - videoDuration={videoDuration} - hasOverlap={hasOverlap} - onRangeChange={setRange} - minItemDurationMs={timelineScale.minItemDurationMs} - minVisibleRangeMs={timelineScale.minVisibleRangeMs} - gridSizeMs={timelineScale.gridMs} - onItemSpanChange={onZoomSpanChange} - > - <Timeline - items={timelineItems} - videoDurationMs={totalMs} - intervalMs={timelineScale.intervalMs} - currentTimeMs={currentTimeMs} - onSeek={onSeek} - onSelectZoom={onSelectZoom} - selectedZoomId={selectedZoomId} - /> - </TimelineWrapper> - </div> - </div> - ); -} +const TimelineEditor = forwardRef<TimelineEditorHandle, TimelineEditorProps>( + function TimelineEditor( + { + videoDuration, + currentTime, + playheadTime, + onSeek, + cursorTelemetry = [], + autoSuggestZoomsTrigger = 0, + onAutoSuggestZoomsConsumed, + disableSuggestedZooms = false, + zoomRegions, + onZoomAdded, + onZoomSuggested, + onZoomSpanChange, + onZoomDelete, + selectedZoomId, + onSelectZoom, + trimRegions = [], + onTrimSpanChange, + clipRegions = [], + onClipSplit, + onClipSpanChange, + onClipDelete, + selectedClipId, + onSelectClip, + annotationRegions = [], + onAnnotationAdded, + onAnnotationSpanChange, + onAnnotationDelete, + selectedAnnotationId, + onSelectAnnotation, + speedRegions = [], + onSpeedSpanChange, + audioRegions = [], + onAudioAdded, + onAudioSpanChange, + onAudioDelete, + selectedAudioId, + onSelectAudio, + captionRegions = [], + onCaptionSpanChange, + onCaptionDelete, + onCaptionAdded, + captionsEnabled = false, + captionQuickAddEnabled = true, + selectedCaptionId, + onSelectCaption, + videoPath, + videoSourcePath, + cursorTelemetrySourcePath, + showSourceAudioTrack = false, + sourceAudioResourceVersion = 0, + onSourceAudioAvailabilityChange, + sourceAudioTrackSettings = {}, + getSourceAudioTrackSettingsForClip, + onSourceAudioTracksMetaChange, + }, + ref, + ) { + const t = useScopedT("settings"); + const totalMs = useMemo( + () => Math.max(0, Math.round(videoDuration * 1000)), + [videoDuration], + ); + const currentTimeMs = useMemo( + () => Math.round((playheadTime ?? currentTime) * 1000), + [currentTime, playheadTime], + ); + const timelineScale = useMemo(() => calculateTimelineScale(videoDuration), [videoDuration]); + const safeMinDurationMs = useMemo( + () => + totalMs > 0 + ? Math.min(timelineScale.minItemDurationMs, totalMs) + : timelineScale.minItemDurationMs, + [timelineScale.minItemDurationMs, totalMs], + ); + + const timelineContainerRef = useRef<HTMLDivElement>(null); + const isTimelineFocusedRef = useRef(false); + const { setRange, clampedRange, handleTimelineWheel } = useTimelineRange({ + totalMs, + timelineContainerRef, + }); + + const [liveSpanPreviewById, setLiveSpanPreviewById] = useState<Record<string, Span>>({}); + const [isDragging, setIsDragging] = useState(false); + const liveZoomPreview = useMemo(() => { + const previewSpans: Record<string, Span> = { ...liveSpanPreviewById }; + const hiddenZoomIds = new Set<string>(); + + for (const [previewId, previewSpan] of Object.entries(liveSpanPreviewById)) { + const oldClip = clipRegions.find((clip) => clip.id === previewId); + if (!oldClip) continue; + + const newStart = Math.round(previewSpan.start); + const newEnd = Math.round(previewSpan.end); + const removedSegments = [ + ...(newStart > oldClip.startMs + ? [{ startMs: oldClip.startMs, endMs: newStart }] + : []), + ...(newEnd < oldClip.endMs ? [{ startMs: newEnd, endMs: oldClip.endMs }] : []), + ]; + + const startDelta = newStart - oldClip.startMs; + const endDelta = newEnd - oldClip.endMs; + const isMove = Math.abs(startDelta - endDelta) < 1 && Math.abs(startDelta) > 0; + + if (isMove) { + const delta = startDelta; + for (const zoom of zoomRegions) { + const overlaps = + zoom.startMs < oldClip.endMs && zoom.endMs > oldClip.startMs; + if (!overlaps) continue; + previewSpans[zoom.id] = { + start: zoom.startMs + delta, + end: zoom.endMs + delta, + }; + } + } + + if (removedSegments.length > 0) { + for (const zoom of zoomRegions) { + const removed = removedSegments.some( + (segment) => + zoom.startMs < segment.endMs && zoom.endMs > segment.startMs, + ); + if (removed) hiddenZoomIds.add(zoom.id); + } + } + } + + return { previewSpans, hiddenZoomIds }; + }, [clipRegions, liveSpanPreviewById, zoomRegions]); + const { shortcuts: keyShortcuts, isMac } = useShortcuts(); + const { peaks: sourceAudioPeaks, loading: sourceAudioLoading } = useTimelineAudioPeaks( + videoPath, + { resourceVersion: sourceAudioResourceVersion }, + ); + const localSourcePath = useMemo(() => { + if (!videoPath) return null; + return ( + extractLocalPathFromMediaServerUrl(videoPath) || + (/^file:\/\//i.test(videoPath) ? fromFileUrl(videoPath) : videoPath) + ); + }, [videoPath]); + const micSidecarPaths = useMemo( + () => (localSourcePath ? buildSourceSidecarPathCandidates(localSourcePath, "mic") : []), + [localSourcePath], + ); + const micSidecarFallbackPaths = useMemo(() => micSidecarPaths.slice(1), [micSidecarPaths]); + const systemSidecarPaths = useMemo( + () => + localSourcePath ? buildSourceSidecarPathCandidates(localSourcePath, "system") : [], + [localSourcePath], + ); + const systemSidecarFallbackPaths = useMemo( + () => systemSidecarPaths.slice(1), + [systemSidecarPaths], + ); + const { peaks: micSidecarPeaks, loading: micSidecarLoading } = useTimelineAudioPeaks( + micSidecarPaths[0] ?? null, + { + fallbackResources: micSidecarFallbackPaths, + resourceVersion: sourceAudioResourceVersion, + }, + ); + const { peaks: systemSidecarPeaks, loading: systemSidecarLoading } = useTimelineAudioPeaks( + systemSidecarPaths[0] ?? null, + { + fallbackResources: systemSidecarFallbackPaths, + resourceVersion: sourceAudioResourceVersion, + }, + ); + const sourceAudioTracks = useMemo( + () => + buildTimelineSourceAudioTracks({ + sourceAudioPeaks, + micSidecarPeaks, + systemSidecarPeaks, + labels: { + system: t("audio.systemLabel", "Source System"), + mic: t("audio.micLabel", "Source Mic"), + mixed: t("audio.mixedLabel", "Source"), + }, + }), + [micSidecarPeaks, sourceAudioPeaks, systemSidecarPeaks, t], + ); + + const isLoading = useMemo(() => { + // If we are still actively trying to load audio peaks (main or sidecars) + if (videoPath && (sourceAudioLoading || micSidecarLoading || systemSidecarLoading)) + return true; + + // Robust telemetry loading detection: + // If a source path is set but telemetry hasn't arrived (or failed/retried) for it yet. + if (videoSourcePath && cursorTelemetrySourcePath !== videoSourcePath) return true; + + return false; + }, [ + videoPath, + videoSourcePath, + cursorTelemetrySourcePath, + sourceAudioLoading, + micSidecarLoading, + systemSidecarLoading, + ]); + useEffect(() => { + onSourceAudioTracksMetaChange?.( + sourceAudioTracks.map((t) => ({ id: t.id, label: t.label })), + ); + }, [onSourceAudioTracksMetaChange, sourceAudioTracks]); + void sourceAudioTrackSettings; + useEffect(() => { + onSourceAudioAvailabilityChange?.(sourceAudioTracks.length > 0); + }, [onSourceAudioAvailabilityChange, sourceAudioTracks.length]); + + const { + keyframes, + selectedKeyframeId, + setSelectedKeyframeId, + selectAllBlocksActive, + setSelectAllBlocksActive, + handleKeyframeMove, + clearSelectedBlocks, + handleSelectZoom, + handleSelectClip, + handleSelectAnnotation, + handleSelectAudio, + handleSelectCaption, + hasOverlap, + timelineItems, + allRegionSpans, + getResolvedDropRowId, + handleItemSpanChange, + canPlaceZoomAtMs, + addZoomAtMs, + canPlaceCaptionAtMs, + addCaptionAtMs, + resolveCaptionSpanAtMs, + } = useTimelineEditorRuntime({ + ref, + videoDuration, + totalMs, + currentTimeMs, + safeMinDurationMs, + cursorTelemetry, + autoSuggestZoomsTrigger, + onAutoSuggestZoomsConsumed, + disableSuggestedZooms, + zoomRegions, + onZoomAdded, + onZoomSuggested, + onZoomSpanChange, + onZoomDelete, + selectedZoomId, + onSelectZoom, + trimRegions, + onTrimSpanChange, + clipRegions, + onClipSplit, + onClipSpanChange, + onClipDelete, + selectedClipId, + onSelectClip, + annotationRegions, + onAnnotationAdded, + onAnnotationSpanChange, + onAnnotationDelete, + selectedAnnotationId, + onSelectAnnotation, + speedRegions, + onSpeedSpanChange, + audioRegions, + onAudioAdded, + onAudioSpanChange, + onAudioDelete, + selectedAudioId, + onSelectAudio, + captionCues: captionRegions, + onCaptionSpanChange, + onCaptionDelete, + onCaptionAdded, + selectedCaptionId, + onSelectCaption, + isMac, + keyShortcuts, + isTimelineFocusedRef, + }); + + if (!videoDuration || videoDuration === 0) { + return ( + <div className="flex-1 flex flex-col items-center justify-center rounded-lg bg-editor-surface gap-3"> + <div className="w-12 h-12 rounded-full bg-foreground/5 flex items-center justify-center"> + <Plus className="w-6 h-6 text-muted-foreground" /> + </div> + <div className="text-center"> + <p className="text-sm font-medium text-muted-foreground">No Video Loaded</p> + <p className="text-xs text-muted-foreground/70 mt-1"> + Drag and drop a video to start editing + </p> + </div> + </div> + ); + } + + return ( + <div className="flex-1 min-h-0 flex flex-col bg-editor-bg overflow-hidden"> + <div + ref={timelineContainerRef} + className="flex-1 min-h-0 overflow-auto bg-editor-bg relative" + tabIndex={0} + onFocus={() => { + isTimelineFocusedRef.current = true; + }} + onBlur={() => { + isTimelineFocusedRef.current = false; + }} + onMouseDown={() => { + timelineContainerRef.current?.focus(); + isTimelineFocusedRef.current = true; + }} + onClick={() => { + setSelectedKeyframeId(null); + setSelectAllBlocksActive(false); + }} + onWheel={handleTimelineWheel} + > + <TimelineWrapper + range={clampedRange} + videoDuration={videoDuration} + hasOverlap={hasOverlap} + onRangeChange={setRange} + minItemDurationMs={timelineScale.minItemDurationMs} + minVisibleRangeMs={timelineScale.minVisibleRangeMs} + onItemSpanChange={handleItemSpanChange} + resolveTargetRowId={getResolvedDropRowId} + allRegionSpans={allRegionSpans} + onDraggingChange={setIsDragging} + onLiveSpanPreviewChange={(id, span) => { + setLiveSpanPreviewById((prev) => { + if (!span) { + if (!(id in prev)) return prev; + const next = { ...prev }; + delete next[id]; + return next; + } + const current = prev[id]; + if ( + current && + current.start === span.start && + current.end === span.end + ) { + return prev; + } + return { ...prev, [id]: span }; + }); + }} + > + <KeyframeMarkers + keyframes={keyframes} + selectedKeyframeId={selectedKeyframeId} + setSelectedKeyframeId={setSelectedKeyframeId} + onKeyframeMove={handleKeyframeMove} + videoDurationMs={totalMs} + timelineRef={timelineContainerRef} + /> + <TimelineCanvas + items={timelineItems} + videoDurationMs={totalMs} + currentTimeMs={currentTimeMs} + onSeek={onSeek} + onAddZoomAtMs={addZoomAtMs} + canPlaceZoomAtMs={canPlaceZoomAtMs} + onAddCaptionAtMs={addCaptionAtMs} + canPlaceCaptionAtMs={canPlaceCaptionAtMs} + resolveCaptionSpanAtMs={resolveCaptionSpanAtMs} + captionsEnabled={captionsEnabled} + captionQuickAddEnabled={captionQuickAddEnabled} + onSelectZoom={handleSelectZoom} + onSelectClip={handleSelectClip} + onSelectAnnotation={handleSelectAnnotation} + onSelectAudio={handleSelectAudio} + onSelectCaption={handleSelectCaption} + selectedZoomId={selectedZoomId} + selectedClipId={selectedClipId} + selectedAnnotationId={selectedAnnotationId} + selectedAudioId={selectedAudioId} + selectedCaptionId={selectedCaptionId} + selectAllBlocksActive={selectAllBlocksActive} + onClearBlockSelection={clearSelectedBlocks} + keyframes={keyframes} + sourceAudioTracks={sourceAudioTracks} + getSourceAudioTrackSettingsForClip={getSourceAudioTrackSettingsForClip} + showSourceAudioTrack={showSourceAudioTrack} + liveSpanPreviewById={liveZoomPreview.previewSpans} + liveHiddenItemIds={Array.from(liveZoomPreview.hiddenZoomIds)} + isDragging={isDragging} + isLoading={isLoading} + /> + </TimelineWrapper> + </div> + </div> + ); + }, +); + +TimelineEditor.displayName = "TimelineEditor"; + +export default TimelineEditor; diff --git a/src/components/video-editor/timeline/TimelineWrapper.tsx b/src/components/video-editor/timeline/TimelineWrapper.tsx deleted file mode 100644 index cc33936bd..000000000 --- a/src/components/video-editor/timeline/TimelineWrapper.tsx +++ /dev/null @@ -1,163 +0,0 @@ -import { useCallback } from "react"; -import type { Dispatch, ReactNode, SetStateAction } from "react"; -import { TimelineContext } from "dnd-timeline"; -import type { DragEndEvent, Range, ResizeEndEvent, Span } from "dnd-timeline"; - -interface TimelineWrapperProps { - children: ReactNode; - range: Range; - videoDuration: number; - hasOverlap: (newSpan: Span, excludeId?: string) => boolean; - onRangeChange: Dispatch<SetStateAction<Range>>; - minItemDurationMs: number; - minVisibleRangeMs: number; - gridSizeMs: number; - onItemSpanChange: (id: string, span: Span) => void; -} - -export default function TimelineWrapper({ - children, - range, - videoDuration, - hasOverlap, - onRangeChange, - minItemDurationMs, - minVisibleRangeMs, - gridSizeMs: _gridSizeMs, - onItemSpanChange, -}: TimelineWrapperProps) { - const totalMs = Math.max(0, Math.round(videoDuration * 1000)); - - const clampSpanToBounds = useCallback( - (span: Span): Span => { - const rawDuration = Math.max(span.end - span.start, 0); - const normalizedStart = Number.isFinite(span.start) ? span.start : 0; - - if (totalMs === 0) { - const minDuration = Math.max(minItemDurationMs, 1); - const duration = Math.max(rawDuration, minDuration); - const start = Math.max(0, normalizedStart); - return { - start, - end: start + duration, - }; - } - - const minDuration = Math.min(Math.max(minItemDurationMs, 1), totalMs); - const duration = Math.min(Math.max(rawDuration, minDuration), totalMs); - - const start = Math.max(0, Math.min(normalizedStart, totalMs - duration)); - const end = start + duration; - - return { start, end }; - }, - [minItemDurationMs, totalMs], - ); - - const clampRange = useCallback( - (candidate: Range): Range => { - if (totalMs === 0) { - const minSpan = Math.max(minVisibleRangeMs, 1); - const span = Math.max(candidate.end - candidate.start, minSpan); - const start = Math.max(0, Math.min(candidate.start, candidate.end - span)); - return { start, end: start + span }; - } - - const rawStart = Math.max(0, candidate.start); - const rawEnd = candidate.end; - const clampedEnd = Math.min(rawEnd, totalMs); - - const minSpan = Math.min(Math.max(minVisibleRangeMs, 1), totalMs); - const desiredSpan = clampedEnd - rawStart; - const span = Math.min(Math.max(desiredSpan, minSpan), totalMs); - - let finalStart = rawStart; - let finalEnd = finalStart + span; - - if (finalEnd > totalMs) { - finalEnd = totalMs; - finalStart = Math.max(0, finalEnd - span); - } - - return { start: finalStart, end: finalEnd }; - }, - [minVisibleRangeMs, totalMs], - ); - - const onResizeEnd = useCallback( - (event: ResizeEndEvent) => { - const updatedSpan = event.active.data.current.getSpanFromResizeEvent?.(event); - if (!updatedSpan) return; - - const activeItemId = event.active.id as string; - const clampedSpan = clampSpanToBounds(updatedSpan); - - if (clampedSpan.end - clampedSpan.start < Math.min(minItemDurationMs, totalMs || minItemDurationMs)) { - return; - } - - if (hasOverlap(clampedSpan, activeItemId)) { - return; - } - - onItemSpanChange(activeItemId, clampedSpan); - }, - [clampSpanToBounds, hasOverlap, minItemDurationMs, onItemSpanChange, totalMs] - ); - - const onDragEnd = useCallback( - (event: DragEndEvent) => { - const activeRowId = event.over?.id as string; - const updatedSpan = event.active.data.current.getSpanFromDragEvent?.(event); - if (!updatedSpan || !activeRowId) return; - - const activeItemId = event.active.id as string; - const clampedSpan = clampSpanToBounds(updatedSpan); - - if (hasOverlap(clampedSpan, activeItemId)) { - return; - } - - onItemSpanChange(activeItemId, clampedSpan); - }, - [clampSpanToBounds, hasOverlap, onItemSpanChange] - ); - - const handleRangeChange = useCallback( - (updater: (previous: Range) => Range) => { - onRangeChange((prev) => { - const normalized = totalMs > 0 ? clampRange(prev) : prev; - const desired = updater(normalized); - - if (totalMs > 0) { - const clamped = clampRange(desired); - - if (clamped.end > totalMs) { - const span = Math.min(clamped.end - clamped.start, totalMs); - return { - start: Math.max(0, totalMs - span), - end: totalMs, - }; - } - - return clamped; - } - - return desired; - }); - }, - [clampRange, onRangeChange, totalMs], - ); - - return ( - <TimelineContext - range={range} - onRangeChanged={handleRangeChange} - onResizeEnd={onResizeEnd} - onDragEnd={onDragEnd} - autoScroll={{ enabled: false }} - > - {children} - </TimelineContext> - ); -} \ No newline at end of file diff --git a/src/components/video-editor/timeline/components/axis/TimelineAxis.tsx b/src/components/video-editor/timeline/components/axis/TimelineAxis.tsx new file mode 100644 index 000000000..a285515c3 --- /dev/null +++ b/src/components/video-editor/timeline/components/axis/TimelineAxis.tsx @@ -0,0 +1,106 @@ +import { useTimelineContext } from "dnd-timeline"; +import { useMemo, type CSSProperties } from "react"; +import { cn } from "@/lib/utils"; +import { calculateAxisScale, formatTimeLabel } from "../../core/time"; + +interface TimelineAxisProps { + videoDurationMs: number; + currentTimeMs: number; +} + +export default function TimelineAxis({ videoDurationMs, currentTimeMs }: TimelineAxisProps) { + const { sidebarWidth, direction, range, valueToPixels } = useTimelineContext(); + const sideProperty = direction === "rtl" ? "right" : "left"; + + const { intervalMs } = useMemo( + () => calculateAxisScale(range.end - range.start), + [range.end, range.start], + ); + + const markers = useMemo(() => { + if (intervalMs <= 0) { + return { markers: [], minorTicks: [] as number[] }; + } + + const maxTime = videoDurationMs > 0 ? videoDurationMs : range.end; + const visibleStart = Math.max(0, Math.min(range.start, maxTime)); + const visibleEnd = Math.min(range.end, maxTime); + const markerTimes = new Set<number>(); + const firstMarker = Math.ceil(visibleStart / intervalMs) * intervalMs; + + for (let time = firstMarker; time <= visibleEnd; time += intervalMs) { + markerTimes.add(Math.round(time)); + } + + if (visibleStart <= maxTime) markerTimes.add(Math.round(visibleStart)); + if (videoDurationMs > 0) markerTimes.add(Math.round(videoDurationMs)); + + const sorted = Array.from(markerTimes) + .filter((time) => time <= maxTime) + .sort((a, b) => a - b); + + const minorTicks: number[] = []; + const minorInterval = intervalMs / 5; + for (let time = firstMarker; time <= visibleEnd; time += minorInterval) { + const isMajor = Math.abs(time % intervalMs) < 1; + if (!isMajor) minorTicks.push(time); + } + + return { + markers: sorted.map((time) => ({ time, label: formatTimeLabel(time, intervalMs) })), + minorTicks, + }; + }, [intervalMs, range.end, range.start, videoDurationMs]); + + return ( + <div + className="h-8 bg-editor-bg border-b border-foreground/10 relative overflow-hidden select-none" + style={{ + [sideProperty === "right" ? "marginRight" : "marginLeft"]: `${sidebarWidth}px`, + }} + > + {markers.minorTicks.map((time) => { + const offset = valueToPixels(time - range.start); + return ( + <div + key={`minor-${time}`} + className="absolute bottom-1 h-1 w-[1px] bg-foreground/5" + style={{ [sideProperty]: `${offset}px` }} + /> + ); + })} + + {markers.markers.map((marker) => { + const offset = valueToPixels(marker.time - range.start); + const markerStyle: CSSProperties = { + position: "absolute", + bottom: 0, + height: "100%", + display: "flex", + flexDirection: "row", + alignItems: "flex-end", + [sideProperty]: `${offset}px`, + transform: direction === "rtl" ? "translateX(50%)" : "translateX(-50%)", + }; + + return ( + <div key={marker.time} style={markerStyle}> + <div className="flex flex-col items-center pb-1"> + <div className="mb-1.5 h-[5px] w-[5px] rounded-full bg-foreground/30" /> + <span + className={cn( + "text-[10px] font-medium tabular-nums tracking-tight", + Math.abs(marker.time - currentTimeMs) < 1 + ? "text-[#2563EB]" + : "text-foreground/40", + )} + > + {marker.label} + </span> + </div> + </div> + ); + })} + </div> + ); +} diff --git a/src/components/video-editor/timeline/components/markers/KeyframeMarkers.tsx b/src/components/video-editor/timeline/components/markers/KeyframeMarkers.tsx new file mode 100644 index 000000000..e35ff5122 --- /dev/null +++ b/src/components/video-editor/timeline/components/markers/KeyframeMarkers.tsx @@ -0,0 +1,117 @@ +import { useTimelineContext } from "dnd-timeline"; +import React, { useEffect, useState } from "react"; + +interface Keyframe { + id: string; + time: number; +} + +interface KeyframeMarkersProps { + keyframes: Keyframe[]; + selectedKeyframeId: string | null; + setSelectedKeyframeId: (id: string | null) => void; + onKeyframeMove: (id: string, newTime: number) => void; + videoDurationMs: number; + timelineRef: React.RefObject<HTMLDivElement>; +} + +const KeyframeMarkers: React.FC<KeyframeMarkersProps> = ({ + keyframes, + selectedKeyframeId, + setSelectedKeyframeId, + onKeyframeMove, + videoDurationMs, + timelineRef, +}) => { + const { sidebarWidth, range, valueToPixels, pixelsToValue } = useTimelineContext(); + const [draggingKeyframeId, setDraggingKeyframeId] = useState<string | null>(null); + + useEffect(() => { + if (!draggingKeyframeId) return; + + const handleMouseMove = (e: MouseEvent) => { + if (!timelineRef.current) return; + + const rect = timelineRef.current.getBoundingClientRect(); + const clickX = e.clientX - rect.left - sidebarWidth; + const relativeMs = pixelsToValue(clickX); + const absoluteMs = Math.max(0, Math.min(range.start + relativeMs, videoDurationMs)); + + // Update the keyframe position in real-time + onKeyframeMove(draggingKeyframeId, absoluteMs); + }; + + const handleMouseUp = () => { + setDraggingKeyframeId(null); + document.body.style.cursor = ""; + }; + + window.addEventListener("mousemove", handleMouseMove); + window.addEventListener("mouseup", handleMouseUp); + document.body.style.cursor = "ew-resize"; + + return () => { + window.removeEventListener("mousemove", handleMouseMove); + window.removeEventListener("mouseup", handleMouseUp); + document.body.style.cursor = ""; + }; + }, [ + draggingKeyframeId, + onKeyframeMove, + timelineRef, + sidebarWidth, + range.start, + videoDurationMs, + pixelsToValue, + ]); + + return ( + <> + {keyframes.map((kf) => { + const offset = valueToPixels(kf.time - range.start); + const isSelected = kf.id === selectedKeyframeId; + const isDragging = kf.id === draggingKeyframeId; + + return ( + <div + key={kf.id} + className={`absolute top-8 cursor-grab active:cursor-grabbing ${isSelected ? "ring-2 ring-[#2563EB]" : ""}`} + style={{ + left: `${sidebarWidth + offset - 8}px`, + zIndex: isDragging ? 50 : 40, + transition: isDragging ? "none" : "left 0.1s ease-out", + }} + onMouseDown={(e) => { + e.stopPropagation(); + setSelectedKeyframeId(kf.id); + if (e.button !== 0) { + return; + } + setDraggingKeyframeId(kf.id); + }} + onContextMenu={(e) => { + e.preventDefault(); + e.stopPropagation(); + setSelectedKeyframeId(kf.id); + }} + title={`Keyframe @ ${Math.round(kf.time)}ms (drag to move, Delete/Backspace to remove)`} + > + <div + style={{ + width: "10px", + height: "10px", + background: "#ffe100ff", + transform: "rotate(45deg)", + border: "none", + opacity: isSelected ? 1 : 0.6, + transition: "opacity 0.15s", + }} + /> + </div> + ); + })} + </> + ); +}; + +export default KeyframeMarkers; diff --git a/src/components/video-editor/timeline/components/overlays/ClipMarkerOverlay.tsx b/src/components/video-editor/timeline/components/overlays/ClipMarkerOverlay.tsx new file mode 100644 index 000000000..14427795d --- /dev/null +++ b/src/components/video-editor/timeline/components/overlays/ClipMarkerOverlay.tsx @@ -0,0 +1,55 @@ +import { useTimelineContext } from "dnd-timeline"; +import { memo, useMemo } from "react"; +import { calculateAxisScale } from "../../core/time"; + +interface ClipMarkerOverlayProps { + videoDurationMs: number; +} + +function ClipMarkerOverlayComponent({ videoDurationMs }: ClipMarkerOverlayProps) { + const { direction, range, valueToPixels } = useTimelineContext(); + const sideProperty = direction === "rtl" ? "right" : "left"; + + const { intervalMs } = useMemo( + () => calculateAxisScale(range.end - range.start), + [range.end, range.start], + ); + + const markers = useMemo(() => { + if (intervalMs <= 0) return [] as { time: number; offset: number }[]; + const maxTime = videoDurationMs > 0 ? videoDurationMs : range.end; + const visibleStart = Math.max(0, range.start); + const visibleEnd = Math.min(range.end, maxTime); + const firstMarker = Math.ceil(visibleStart / intervalMs) * intervalMs; + const result: { time: number; offset: number }[] = []; + for (let time = firstMarker; time <= maxTime; time += intervalMs) { + if (time > visibleStart && time < visibleEnd) { + result.push({ + time: Math.round(time), + offset: valueToPixels(Math.round(time) - range.start), + }); + } + } + return result; + }, [intervalMs, range.start, range.end, videoDurationMs, valueToPixels]); + + return ( + <div className="pointer-events-none absolute inset-0 z-[1]"> + {markers.map(({ time, offset }) => ( + <div + key={time} + className="absolute w-px" + style={{ + top: "7.5%", + bottom: "7.5%", + [sideProperty]: `${offset}px`, + background: + "linear-gradient(to bottom, transparent 0%, rgba(255,255,255,0.32) 35%, rgba(255,255,255,0.32) 65%, transparent 100%)", + }} + /> + ))} + </div> + ); +} + +export default memo(ClipMarkerOverlayComponent); diff --git a/src/components/video-editor/timeline/components/playhead/PlaybackCursor.tsx b/src/components/video-editor/timeline/components/playhead/PlaybackCursor.tsx new file mode 100644 index 000000000..80f729e0e --- /dev/null +++ b/src/components/video-editor/timeline/components/playhead/PlaybackCursor.tsx @@ -0,0 +1,137 @@ +import { useTimelineContext } from "dnd-timeline"; +import { useEffect, useState, type RefObject } from "react"; +import { cn } from "@/lib/utils"; +import { formatPlayheadTime } from "../../core/time"; + +interface PlaybackCursorProps { + currentTimeMs: number; + videoDurationMs: number; + onSeek?: (time: number) => void; + timelineRef: RefObject<HTMLDivElement>; + keyframes?: { id: string; time: number }[]; + isLoading?: boolean; +} + +export default function PlaybackCursor({ + currentTimeMs, + videoDurationMs, + onSeek, + timelineRef, + keyframes = [], + isLoading = false, +}: PlaybackCursorProps) { + const { sidebarWidth, direction, range, valueToPixels, pixelsToValue } = useTimelineContext(); + const sideProperty = direction === "rtl" ? "right" : "left"; + const [isDragging, setIsDragging] = useState(false); + + useEffect(() => { + if (!isDragging) return; + + const handleMouseMove = (e: MouseEvent) => { + if (!timelineRef.current || !onSeek) return; + const rect = timelineRef.current.getBoundingClientRect(); + const clickX = e.clientX - rect.left - sidebarWidth; + const relativeMs = pixelsToValue(clickX); + let absoluteMs = Math.max(0, Math.min(range.start + relativeMs, videoDurationMs)); + + const snapThresholdMs = 150; + const nearbyKeyframe = keyframes.find( + (kf) => + Math.abs(kf.time - absoluteMs) <= snapThresholdMs && + kf.time >= range.start && + kf.time <= range.end, + ); + if (nearbyKeyframe) absoluteMs = nearbyKeyframe.time; + + onSeek(absoluteMs / 1000); + }; + + const handleMouseUp = () => { + setIsDragging(false); + document.body.style.cursor = ""; + }; + + window.addEventListener("mousemove", handleMouseMove); + window.addEventListener("mouseup", handleMouseUp); + document.body.style.cursor = "ew-resize"; + + return () => { + window.removeEventListener("mousemove", handleMouseMove); + window.removeEventListener("mouseup", handleMouseUp); + document.body.style.cursor = ""; + }; + }, [ + isDragging, + onSeek, + timelineRef, + sidebarWidth, + range.start, + range.end, + videoDurationMs, + pixelsToValue, + keyframes, + ]); + + if (videoDurationMs <= 0 || currentTimeMs < 0) return null; + const clampedTime = Math.min(currentTimeMs, videoDurationMs); + if (clampedTime < range.start || clampedTime > range.end) return null; + + const offset = valueToPixels(clampedTime - range.start); + + return ( + <div + className="absolute top-0 bottom-0 z-50 group/cursor" + style={{ + [sideProperty === "right" ? "marginRight" : "marginLeft"]: `${sidebarWidth - 1}px`, + pointerEvents: "none", + }} + > + <div + className="absolute top-0 bottom-0 w-[2px] bg-[#2563EB] shadow-[0_0_10px_rgba(37,99,235,0.5)] cursor-ew-resize pointer-events-auto hover:shadow-[0_0_15px_rgba(37,99,235,0.7)] transition-shadow" + style={{ [sideProperty]: `${offset}px` }} + onMouseDown={(e) => { + e.stopPropagation(); + setIsDragging(true); + }} + > + <div + className="absolute -top-1 left-1/2 -translate-x-1/2 hover:scale-125 transition-transform" + style={{ width: "16px", height: "16px" }} + > + <div className="w-3 h-3 mx-auto mt-[2px] bg-[#2563EB] rotate-45 rounded-sm shadow-lg border border-foreground/20" /> + </div> + <div + className={cn( + "absolute -top-6 left-1/2 -translate-x-1/2 px-1.5 py-0.5 rounded bg-black/80 text-[10px] text-white/90 font-medium tabular-nums whitespace-nowrap border border-foreground/10 shadow-lg pointer-events-none transition-opacity", + isDragging || isLoading ? "opacity-100" : "opacity-0", + )} + > + <div className="flex items-center"> + {formatPlayheadTime(clampedTime) + .split("") + .map((char, i) => ( + <span + key={i} + className={cn( + "leading-5 whitespace-pre", + isLoading && + "bg-gradient-to-r from-white/40 via-white to-white/40 bg-clip-text text-transparent animate-text-shimmer", + )} + style={ + isLoading + ? { + animationDelay: `${i * 0.05}s`, + animationDuration: "2.5s", + } + : undefined + } + > + {char} + </span> + ))} + </div> + </div> + </div> + </div> + ); +} diff --git a/src/components/video-editor/timeline/components/toolbar/TimelineToolbar.tsx b/src/components/video-editor/timeline/components/toolbar/TimelineToolbar.tsx new file mode 100644 index 000000000..cec20aca8 --- /dev/null +++ b/src/components/video-editor/timeline/components/toolbar/TimelineToolbar.tsx @@ -0,0 +1,235 @@ +import { + Check, + CaretDown as ChevronDown, + Crop, + ChatText as MessageSquare, + MusicNote as Music, + Scissors, + MagicWand as WandSparkles, + MagnifyingGlassPlus as ZoomIn, +} from "@phosphor-icons/react"; +import type { KeyboardEvent as ReactKeyboardEvent } from "react"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + ASPECT_RATIOS, + type AspectRatio, + getAspectRatioLabel, + isCustomAspectRatio, +} from "@/utils/aspectRatioUtils"; + +interface TimelineToolbarProps { + aspectRatio: AspectRatio; + isCropped: boolean; + scrollLabels: { pan: string; zoom: string }; + customAspectWidth: string; + customAspectHeight: string; + onCustomAspectWidthChange: (value: string) => void; + onCustomAspectHeightChange: (value: string) => void; + onCustomAspectRatioKeyDown: (event: ReactKeyboardEvent<HTMLInputElement>) => void; + onApplyCustomAspectRatio: () => void; + onAspectRatioChange?: (aspectRatio: AspectRatio) => void; + onOpenCropEditor?: () => void; + onAddZoom: () => void; + onSuggestZooms: () => void; + onAddAnnotation: () => void; + onAddAudio: () => void; + onSplitClip: () => void; + cropLabel: string; + addZoomLabel: string; + suggestZoomsLabel: string; + addAnnotationLabel: string; + addAudioLabel: string; + splitClipLabel: string; +} + +export default function TimelineToolbar({ + aspectRatio, + isCropped, + scrollLabels, + customAspectWidth, + customAspectHeight, + onCustomAspectWidthChange, + onCustomAspectHeightChange, + onCustomAspectRatioKeyDown, + onApplyCustomAspectRatio, + onAspectRatioChange, + onOpenCropEditor, + onAddZoom, + onSuggestZooms, + onAddAnnotation, + onAddAudio, + onSplitClip, + cropLabel, + addZoomLabel, + suggestZoomsLabel, + addAnnotationLabel, + addAudioLabel, + splitClipLabel, +}: TimelineToolbarProps) { + return ( + <div className="flex items-center gap-2 px-4 py-2 border-b border-foreground/10 bg-editor-panel"> + <div className="flex items-center gap-1"> + <Button + onClick={onAddZoom} + variant="ghost" + size="icon" + className="h-7 w-7 text-muted-foreground hover:text-[#2563EB] hover:bg-[#2563EB]/10 transition-all" + title={addZoomLabel} + aria-label={addZoomLabel} + > + <ZoomIn className="w-4 h-4" /> + </Button> + <Button + onClick={onSuggestZooms} + variant="ghost" + size="icon" + className="h-7 w-7 text-muted-foreground hover:text-[#2563EB] hover:bg-[#2563EB]/10 transition-all" + title={suggestZoomsLabel} + aria-label={suggestZoomsLabel} + > + <WandSparkles className="w-4 h-4" /> + </Button> + <Button + onClick={onAddAnnotation} + variant="ghost" + size="icon" + className="h-7 w-7 text-muted-foreground hover:text-[#B4A046] hover:bg-[#B4A046]/10 transition-all" + title={addAnnotationLabel} + aria-label={addAnnotationLabel} + > + <MessageSquare className="w-4 h-4" /> + </Button> + <Button + onClick={onAddAudio} + variant="ghost" + size="icon" + className="h-7 w-7 text-muted-foreground hover:text-[#a855f7] hover:bg-[#a855f7]/10 transition-all" + title={addAudioLabel} + aria-label={addAudioLabel} + > + <Music className="w-4 h-4" /> + </Button> + <Button + onClick={onSplitClip} + variant="ghost" + size="icon" + className="h-7 w-7 text-muted-foreground hover:text-foreground hover:bg-foreground/10 transition-all" + title={splitClipLabel} + aria-label={splitClipLabel} + > + <Scissors className="w-4 h-4" /> + </Button> + </div> + <div className="flex items-center gap-2"> + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button + variant="ghost" + size="sm" + className="h-7 px-2 text-xs text-muted-foreground hover:text-foreground hover:bg-foreground/10 transition-all gap-1" + > + <span className="font-medium">{getAspectRatioLabel(aspectRatio)}</span> + <ChevronDown className="w-3 h-3" /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent + align="end" + className="bg-editor-surface-alt border-foreground/10" + > + {ASPECT_RATIOS.map((ratio) => ( + <DropdownMenuItem + key={ratio} + onClick={() => onAspectRatioChange?.(ratio)} + className="text-muted-foreground hover:text-foreground hover:bg-foreground/10 cursor-pointer flex items-center justify-between gap-3" + > + <span>{getAspectRatioLabel(ratio)}</span> + {aspectRatio === ratio && ( + <Check className="w-3 h-3 text-[#2563EB]" /> + )} + </DropdownMenuItem> + ))} + <div className="mx-1 my-1 h-px bg-foreground/10" /> + <div className="px-2 py-1.5 flex items-center gap-2 text-muted-foreground"> + <span className="text-sm">Custom</span> + <input + type="text" + inputMode="numeric" + value={customAspectWidth} + onChange={(event) => + onCustomAspectWidthChange(event.target.value.replace(/\D/g, "")) + } + onKeyDown={onCustomAspectRatioKeyDown} + className="w-12 h-7 rounded border border-foreground/10 bg-foreground/5 px-1.5 text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-[#2563EB]" + aria-label="Custom aspect width" + /> + <span className="text-muted-foreground/70">:</span> + <input + type="text" + inputMode="numeric" + value={customAspectHeight} + onChange={(event) => + onCustomAspectHeightChange( + event.target.value.replace(/\D/g, ""), + ) + } + onKeyDown={onCustomAspectRatioKeyDown} + className="w-12 h-7 rounded border border-foreground/10 bg-foreground/5 px-1.5 text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-[#2563EB]" + aria-label="Custom aspect height" + /> + <Button + variant="ghost" + size="sm" + onClick={onApplyCustomAspectRatio} + className="h-7 px-2 text-xs text-muted-foreground hover:text-foreground hover:bg-foreground/10" + > + Set + </Button> + {isCustomAspectRatio(aspectRatio) && ( + <Check className="w-3 h-3 text-[#2563EB] ml-auto" /> + )} + </div> + </DropdownMenuContent> + </DropdownMenu> + <div className="w-[1px] h-4 bg-foreground/10" /> + <Button + variant="ghost" + size="sm" + onClick={onOpenCropEditor} + disabled={!onOpenCropEditor} + className="h-7 px-2 text-xs text-muted-foreground hover:text-foreground hover:bg-foreground/10 transition-all gap-1.5" + > + <Crop className="w-3.5 h-3.5" /> + <span className="font-medium">{cropLabel}</span> + {isCropped ? <span className="h-1.5 w-1.5 rounded-full bg-[#2563EB]" /> : null} + </Button> + </div> + <div className="flex-1" /> + <div className="flex items-center gap-4 text-[10px] text-muted-foreground/70 font-medium"> + <span className="flex items-center gap-1.5"> + <kbd className="px-1.5 py-0.5 bg-foreground/5 border border-foreground/10 rounded text-[#2563EB] font-sans"> + Side Scroll + </kbd> + <span>Pan</span> + </span> + <span className="flex items-center gap-1.5"> + <kbd className="px-1.5 py-0.5 bg-foreground/5 border border-foreground/10 rounded text-[#2563EB] font-sans"> + {scrollLabels.pan} + </kbd> + <span>Pan</span> + </span> + <span className="flex items-center gap-1.5"> + <kbd className="px-1.5 py-0.5 bg-foreground/5 border border-foreground/10 rounded text-[#2563EB] font-sans"> + {scrollLabels.zoom} + </kbd> + <span>Zoom</span> + </span> + </div> + </div> + ); +} diff --git a/src/components/video-editor/timeline/components/viewport/TimelineCanvas.tsx b/src/components/video-editor/timeline/components/viewport/TimelineCanvas.tsx new file mode 100644 index 000000000..a2a20cc1c --- /dev/null +++ b/src/components/video-editor/timeline/components/viewport/TimelineCanvas.tsx @@ -0,0 +1,1077 @@ +import { Plus } from "@phosphor-icons/react"; +import { useTimelineContext } from "dnd-timeline"; +import { + type MouseEvent, + type MouseEventHandler, + memo, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import type { + SourceAudioTrackSettings, + SourceAudioTrackWithPeaks, +} from "@/components/video-editor/audio/audioTypes"; +import { cn } from "@/lib/utils"; +import { + CAPTION_ROW_ID, + CLIP_ROW_ID, + SOURCE_AUDIO_ROW_ID, + ZOOM_ROW_ID, +} from "../../core/constants"; +import { + getAnnotationTrackIndex, + getAnnotationTrackRowId, + getAudioTrackIndex, + getAudioTrackRowId, + isAnnotationTrackRowId, + isAudioTrackRowId, +} from "../../core/rows"; +import type { TimelineRenderItem } from "../../core/timelineTypes"; +import { DEFAULT_CAPTION_DURATION_MS } from "../../hooks/actions/useTimelineCaptionActions"; +import { useTimelineAudioPeaks } from "../../hooks/useTimelineAudioPeaks"; +import Item from "../../Item"; +import glassStyles from "../../ItemGlass.module.css"; +import Row from "../../Row"; +import { + getTimelineContentMinHeightPx, + getTimelineRowsMinHeightPx, + getTimelineViewportStretchFactor, + TIMELINE_AXIS_HEIGHT_PX, +} from "../../timelineLayout"; +import TimelineAxis from "../axis/TimelineAxis"; +import ClipMarkerOverlay from "../overlays/ClipMarkerOverlay"; +import PlaybackCursor from "../playhead/PlaybackCursor"; + +const HINT_CLIP = "Press C to split clip"; +const HINT_ANNOTATION = "Press A to add annotation"; +const HINT_AUDIO = "Click music icon to add audio"; + +interface TimelineCanvasProps { + items: TimelineRenderItem[]; + videoDurationMs: number; + currentTimeMs: number; + onSeek?: (time: number) => void; + canPlaceZoomAtMs?: (startMs: number) => boolean; + onSelectZoom?: (id: string | null) => void; + onSelectClip?: (id: string | null) => void; + onSelectAnnotation?: (id: string | null) => void; + onSelectAudio?: (id: string | null) => void; + onSelectCaption?: (id: string | null) => void; + onAddZoomAtMs?: (startMs: number) => void; + onAddCaptionAtMs?: (startMs: number) => void; + canPlaceCaptionAtMs?: (startMs: number) => boolean; + resolveCaptionSpanAtMs?: (startMs: number) => { start: number; end: number } | null; + captionsEnabled?: boolean; + captionQuickAddEnabled?: boolean; + selectedZoomId: string | null; + selectedClipId?: string | null; + selectedAnnotationId?: string | null; + selectedAudioId?: string | null; + selectedCaptionId?: string | null; + selectAllBlocksActive?: boolean; + onClearBlockSelection?: () => void; + keyframes?: { id: string; time: number }[]; + sourceAudioTracks?: SourceAudioTrackWithPeaks[]; + getSourceAudioTrackSettingsForClip?: (clipId: string | null) => SourceAudioTrackSettings; + showSourceAudioTrack?: boolean; + liveSpanPreviewById?: Record<string, { start: number; end: number }>; + liveHiddenItemIds?: string[]; + isDragging?: boolean; + isLoading?: boolean; +} + +interface LaneHoverParams { + direction: string; + rangeStart: number; + visibleDurationMs: number; + videoDurationMs: number; + valueToPixels: (value: number) => number; + // Ghost block length to preview under the cursor. + ghostDurationMs: number; + // Whether the lane currently accepts adds (e.g. captions only when shown). + enabled: boolean; + // Suppress the ghost while a drag/resize is in progress. + isDragging: boolean; + onAddAtMs?: (startMs: number) => void; + canPlaceAtMs?: (startMs: number) => boolean; + // When set, the ghost previews the exact span an add would produce (clamped to + // neighbors/end) instead of a fixed ghostDurationMs. Returns null when no add fits. + resolveGhostSpanMs?: (startMs: number) => { start: number; end: number } | null; +} + +/** + * Hover + click-to-add behaviour for a single timeline lane (zoom, captions, …). + * Tracks the pointer position over the row and derives the translucent "add" + * ghost geometry. Lanes differ only by their ghost duration, enabled flag and + * add/can-place callbacks. + */ +function useTimelineLaneHover({ + direction, + rangeStart, + visibleDurationMs, + videoDurationMs, + valueToPixels, + ghostDurationMs, + enabled, + isDragging, + onAddAtMs, + canPlaceAtMs, + resolveGhostSpanMs, +}: LaneHoverParams) { + const [isHovered, setIsHovered] = useState(false); + const [hoverMs, setHoverMs] = useState<number | null>(null); + + const updateHoverTime = useCallback( + (clientX: number, rect: DOMRect) => { + if (rect.width <= 0) return; + const position = + direction === "rtl" + ? Math.max(0, Math.min(rect.right - clientX, rect.width)) + : Math.max(0, Math.min(clientX - rect.left, rect.width)); + const ratio = position / rect.width; + const nextMs = rangeStart + ratio * visibleDurationMs; + setHoverMs(Math.max(0, Math.min(nextMs, videoDurationMs))); + }, + [direction, rangeStart, videoDurationMs, visibleDurationMs], + ); + + const onMouseEnter = useCallback( + (event: MouseEvent<HTMLDivElement>) => { + setIsHovered(true); + updateHoverTime(event.clientX, event.currentTarget.getBoundingClientRect()); + }, + [updateHoverTime], + ); + + const onMouseMove = useCallback( + (event: MouseEvent<HTMLDivElement>) => { + setIsHovered(true); + updateHoverTime(event.clientX, event.currentTarget.getBoundingClientRect()); + }, + [updateHoverTime], + ); + + const onMouseLeave = useCallback(() => { + setIsHovered(false); + setHoverMs(null); + }, []); + + const onMouseDown = useCallback((event: MouseEvent<HTMLDivElement>) => { + event.stopPropagation(); + }, []); + + const onClick = useCallback( + (event: MouseEvent<HTMLDivElement>) => { + event.stopPropagation(); + // Respect the lane's enabled flag so a hidden ghost can't still add on click. + if (!enabled || !onAddAtMs || hoverMs === null) return; + const startMs = Math.max(0, Math.min(hoverMs, videoDurationMs)); + if (canPlaceAtMs && !canPlaceAtMs(startMs)) return; + onAddAtMs(startMs); + }, + [enabled, canPlaceAtMs, onAddAtMs, videoDurationMs, hoverMs], + ); + + const reset = useCallback(() => { + setIsHovered(false); + setHoverMs(null); + }, []); + + const clampedHoverMs = + hoverMs === null ? null : Math.max(0, Math.min(hoverMs, videoDurationMs)); + // When a resolver is supplied, preview the exact span the add would create (clamped to + // the next item / end of timeline); otherwise fall back to a fixed-length ghost. + const resolvedSpan = + resolveGhostSpanMs && clampedHoverMs !== null ? resolveGhostSpanMs(clampedHoverMs) : null; + const ghostStartMs = + clampedHoverMs === null ? null : resolvedSpan ? resolvedSpan.start : clampedHoverMs; + const ghostEndMs = + ghostStartMs === null + ? null + : resolvedSpan + ? resolvedSpan.end + : Math.max(ghostStartMs, Math.min(videoDurationMs, ghostStartMs + ghostDurationMs)); + const ghostStartOffsetPx = + ghostStartMs === null ? 0 : valueToPixels(Math.max(0, ghostStartMs - rangeStart)); + const ghostEndOffsetPx = + ghostEndMs === null ? 0 : valueToPixels(Math.max(0, ghostEndMs - rangeStart)); + const ghostWidthPx = Math.max(18, ghostEndOffsetPx - ghostStartOffsetPx); + const canShowGhost = + !isDragging && + enabled && + isHovered && + ghostStartMs !== null && + (resolveGhostSpanMs + ? resolvedSpan !== null + : onAddAtMs + ? (canPlaceAtMs?.(ghostStartMs) ?? true) + : false); + + return { + reset, + ghostStartMs, + ghostStartOffsetPx, + ghostWidthPx, + canShowGhost, + onMouseEnter, + onMouseMove, + onMouseLeave, + onMouseDown, + onClick, + }; +} + +interface TimelineHoverParams { + direction: string; + sidebarWidth: number; + rangeStart: number; + rangeEnd: number; + videoDurationMs: number; + onAddZoomAtMs?: (startMs: number) => void; + canPlaceZoomAtMs?: (startMs: number) => boolean; + onAddCaptionAtMs?: (startMs: number) => void; + canPlaceCaptionAtMs?: (startMs: number) => boolean; + resolveCaptionSpanAtMs?: (startMs: number) => { start: number; end: number } | null; + captionsEnabled?: boolean; + captionQuickAddEnabled?: boolean; + isDragging: boolean; + valueToPixels: (value: number) => number; +} + +function useTimelineHover({ + direction, + sidebarWidth, + rangeStart, + rangeEnd, + videoDurationMs, + onAddZoomAtMs, + canPlaceZoomAtMs, + onAddCaptionAtMs, + canPlaceCaptionAtMs, + resolveCaptionSpanAtMs, + captionsEnabled, + captionQuickAddEnabled = true, + isDragging, + valueToPixels, +}: TimelineHoverParams) { + const [isTimelineHovered, setIsTimelineHovered] = useState(false); + const [timelineHoverMs, setTimelineHoverMs] = useState<number | null>(null); + + const visibleDurationMs = Math.max(1, rangeEnd - rangeStart); + + const updateTimelineHoverTime = useCallback( + (clientX: number, rect: DOMRect) => { + const contentWidth = Math.max(1, rect.width - sidebarWidth); + const contentX = + direction === "rtl" + ? rect.right - sidebarWidth - clientX + : clientX - rect.left - sidebarWidth; + const clampedX = Math.max(0, Math.min(contentX, contentWidth)); + const ratio = clampedX / contentWidth; + const nextMs = rangeStart + ratio * visibleDurationMs; + setTimelineHoverMs(Math.max(0, Math.min(nextMs, videoDurationMs))); + }, + [direction, rangeStart, sidebarWidth, videoDurationMs, visibleDurationMs], + ); + + const handleTimelineMouseEnter = useCallback( + (event: MouseEvent<HTMLDivElement>) => { + setIsTimelineHovered(true); + updateTimelineHoverTime(event.clientX, event.currentTarget.getBoundingClientRect()); + }, + [updateTimelineHoverTime], + ); + + const handleTimelineMouseMove = useCallback( + (event: MouseEvent<HTMLDivElement>) => { + if (!isTimelineHovered) setIsTimelineHovered(true); + updateTimelineHoverTime(event.clientX, event.currentTarget.getBoundingClientRect()); + }, + [isTimelineHovered, updateTimelineHoverTime], + ); + + const zoom = useTimelineLaneHover({ + direction, + rangeStart, + visibleDurationMs, + videoDurationMs, + valueToPixels, + ghostDurationMs: Math.min(1000, videoDurationMs), + enabled: true, + isDragging, + onAddAtMs: onAddZoomAtMs, + canPlaceAtMs: canPlaceZoomAtMs, + }); + + const caption = useTimelineLaneHover({ + direction, + rangeStart, + visibleDurationMs, + videoDurationMs, + valueToPixels, + ghostDurationMs: Math.min(DEFAULT_CAPTION_DURATION_MS, videoDurationMs), + enabled: Boolean(captionsEnabled) && captionQuickAddEnabled, + isDragging, + onAddAtMs: onAddCaptionAtMs, + canPlaceAtMs: canPlaceCaptionAtMs, + resolveGhostSpanMs: resolveCaptionSpanAtMs, + }); + + const handleTimelineMouseLeave = useCallback(() => { + setIsTimelineHovered(false); + setTimelineHoverMs(null); + zoom.reset(); + caption.reset(); + }, [zoom.reset, caption.reset]); + + const timelineGhostOffsetPx = + timelineHoverMs === null ? 0 : valueToPixels(Math.max(0, timelineHoverMs - rangeStart)); + const canShowGhostPlayhead = isTimelineHovered && timelineHoverMs !== null; + + return { + canShowGhostPlayhead, + timelineGhostOffsetPx, + handleTimelineMouseEnter, + handleTimelineMouseMove, + handleTimelineMouseLeave, + canShowGhostZoom: zoom.canShowGhost, + ghostStartMs: zoom.ghostStartMs, + ghostStartOffsetPx: zoom.ghostStartOffsetPx, + ghostWidthPx: zoom.ghostWidthPx, + handleZoomRowMouseEnter: zoom.onMouseEnter, + handleZoomRowMouseMove: zoom.onMouseMove, + handleZoomRowMouseLeave: zoom.onMouseLeave, + handleZoomRowMouseDown: zoom.onMouseDown, + handleZoomRowClick: zoom.onClick, + canShowGhostCaption: caption.canShowGhost, + captionGhostStartMs: caption.ghostStartMs, + captionGhostStartOffsetPx: caption.ghostStartOffsetPx, + captionGhostWidthPx: caption.ghostWidthPx, + handleCaptionRowMouseEnter: caption.onMouseEnter, + handleCaptionRowMouseMove: caption.onMouseMove, + handleCaptionRowMouseLeave: caption.onMouseLeave, + handleCaptionRowMouseDown: caption.onMouseDown, + handleCaptionRowClick: caption.onClick, + }; +} + +interface TimelineCanvasRowsProps { + items: TimelineRenderItem[]; + videoDurationMs: number; + selectAllBlocksActive: boolean; + selectedZoomId: string | null; + selectedClipId?: string | null; + selectedAnnotationId?: string | null; + selectedAudioId?: string | null; + selectedCaptionId?: string | null; + onSelectZoom?: (id: string | null) => void; + onSelectClip?: (id: string | null) => void; + onSelectAnnotation?: (id: string | null) => void; + onSelectAudio?: (id: string | null) => void; + onSelectCaption?: (id: string | null) => void; + sourceAudioTracks?: SourceAudioTrackWithPeaks[]; + getSourceAudioTrackSettingsForClip?: (clipId: string | null) => SourceAudioTrackSettings; + showSourceAudioTrack?: boolean; + liveSpanPreviewById?: Record<string, { start: number; end: number }>; + liveHiddenItemIds?: string[]; + direction: string; + canShowGhostZoom: boolean; + ghostStartMs: number | null; + ghostStartOffsetPx: number; + ghostWidthPx: number; + onZoomRowMouseEnter: MouseEventHandler<HTMLDivElement>; + onZoomRowMouseMove: MouseEventHandler<HTMLDivElement>; + onZoomRowMouseLeave: MouseEventHandler<HTMLDivElement>; + onZoomRowMouseDown: MouseEventHandler<HTMLDivElement>; + onZoomRowClick: MouseEventHandler<HTMLDivElement>; + captionsEnabled?: boolean; + canShowGhostCaption: boolean; + captionGhostStartMs: number | null; + captionGhostStartOffsetPx: number; + captionGhostWidthPx: number; + onCaptionRowMouseEnter: MouseEventHandler<HTMLDivElement>; + onCaptionRowMouseMove: MouseEventHandler<HTMLDivElement>; + onCaptionRowMouseLeave: MouseEventHandler<HTMLDivElement>; + onCaptionRowMouseDown: MouseEventHandler<HTMLDivElement>; + onCaptionRowClick: MouseEventHandler<HTMLDivElement>; +} + +interface AudioItemWithWaveformProps { + item: TimelineRenderItem; + span: { start: number; end: number }; + waveformSpan: { start: number; end: number }; + isSelected: boolean; + onSelectAudio?: (id: string | null) => void; +} + +function AudioItemWithWaveform({ + item, + span, + waveformSpan, + isSelected, + onSelectAudio, +}: AudioItemWithWaveformProps) { + const { peaks } = useTimelineAudioPeaks(item.audioPath ?? null); + const normalizedWaveformSpan = useMemo(() => { + const duration = Math.max(0, waveformSpan.end - waveformSpan.start); + return { start: 0, end: duration }; + }, [waveformSpan.end, waveformSpan.start]); + return ( + <Item + id={item.id} + rowId={item.rowId} + span={span} + isSelected={isSelected} + onSelectId={onSelectAudio} + variant="audio" + waveformPeaks={peaks} + waveformSegmentSpan={normalizedWaveformSpan} + waveformGain={Math.max(0, Math.min(1, item.audioGain ?? 1))} + waveformNormalize={Boolean(item.audioNormalize)} + > + {item.label} + </Item> + ); +} + +const TimelineCanvasRows = memo(function TimelineCanvasRows({ + items, + videoDurationMs, + selectAllBlocksActive, + selectedZoomId, + selectedClipId, + selectedAnnotationId, + selectedAudioId, + selectedCaptionId, + onSelectZoom, + onSelectClip, + onSelectAnnotation, + onSelectAudio, + onSelectCaption, + sourceAudioTracks = [], + getSourceAudioTrackSettingsForClip, + showSourceAudioTrack = false, + liveSpanPreviewById, + liveHiddenItemIds, + direction, + canShowGhostZoom, + ghostStartMs, + ghostStartOffsetPx, + ghostWidthPx, + onZoomRowMouseEnter, + onZoomRowMouseMove, + onZoomRowMouseLeave, + onZoomRowMouseDown, + onZoomRowClick, + captionsEnabled = false, + canShowGhostCaption, + captionGhostStartMs, + captionGhostStartOffsetPx, + captionGhostWidthPx, + onCaptionRowMouseEnter, + onCaptionRowMouseMove, + onCaptionRowMouseLeave, + onCaptionRowMouseDown, + onCaptionRowClick, +}: TimelineCanvasRowsProps) { + const hiddenIds = useMemo(() => new Set(liveHiddenItemIds ?? []), [liveHiddenItemIds]); + const { clipItems, zoomItems, captionItems, annotationRows, audioRows } = useMemo(() => { + const nextClipItems: TimelineRenderItem[] = []; + const nextZoomItems: TimelineRenderItem[] = []; + const nextCaptionItems: TimelineRenderItem[] = []; + const annotationBuckets = new Map<number, TimelineRenderItem[]>(); + const audioBuckets = new Map<number, TimelineRenderItem[]>(); + + for (const item of items) { + if (item.rowId === CLIP_ROW_ID) { + nextClipItems.push(item); + continue; + } + if (item.rowId === ZOOM_ROW_ID) { + nextZoomItems.push(item); + continue; + } + if (item.rowId === CAPTION_ROW_ID) { + nextCaptionItems.push(item); + continue; + } + if (isAnnotationTrackRowId(item.rowId)) { + const trackIndex = getAnnotationTrackIndex(item.rowId); + const bucket = annotationBuckets.get(trackIndex); + if (bucket) bucket.push(item); + else annotationBuckets.set(trackIndex, [item]); + continue; + } + if (isAudioTrackRowId(item.rowId)) { + const trackIndex = getAudioTrackIndex(item.rowId); + const bucket = audioBuckets.get(trackIndex); + if (bucket) bucket.push(item); + else audioBuckets.set(trackIndex, [item]); + } + } + + const annotationRowsSorted = Array.from(annotationBuckets.entries()) + .sort(([left], [right]) => left - right) + .map(([trackIndex, rowItems]) => ({ + rowId: getAnnotationTrackRowId(trackIndex), + items: rowItems, + })); + const audioRowsSorted = Array.from(audioBuckets.entries()) + .sort(([left], [right]) => left - right) + .map(([trackIndex, rowItems]) => ({ + rowId: getAudioTrackRowId(trackIndex), + items: rowItems, + })); + + return { + clipItems: nextClipItems, + zoomItems: nextZoomItems, + captionItems: nextCaptionItems, + annotationRows: annotationRowsSorted, + audioRows: audioRowsSorted, + }; + }, [items]); + + return ( + <> + <Row id={CLIP_ROW_ID} isEmpty={clipItems.length === 0} hint={HINT_CLIP}> + <ClipMarkerOverlay videoDurationMs={videoDurationMs} /> + {clipItems.map((item) => ( + <Item + id={item.id} + key={item.id} + rowId={item.rowId} + span={item.span} + isSelected={item.id === selectedClipId} + onSelectId={onSelectClip} + variant="clip" + speedValue={item.speedValue} + > + {item.label} + </Item> + ))} + </Row> + {showSourceAudioTrack && + sourceAudioTracks.map((track) => ( + <Row key={track.id} id={`${SOURCE_AUDIO_ROW_ID}-${track.id}`}> + {clipItems + .filter((item) => item.showSourceAudio) + .map((item) => { + const settings = getSourceAudioTrackSettingsForClip?.(item.id)?.[ + track.id + ] ?? { volume: 1, normalize: false }; + return ( + <Item + key={`source-audio-${track.id}-${item.id}`} + id={`source-audio-${track.id}-${item.id}`} + rowId={`${SOURCE_AUDIO_ROW_ID}-${track.id}`} + span={liveSpanPreviewById?.[item.id] ?? item.span} + disabled + isSelected={item.id === selectedClipId} + onSelect={() => onSelectClip?.(item.id)} + variant="audio" + waveformPeaks={track.peaks} + waveformSegmentSpan={item.sourceSpan ?? item.span} + waveformGain={Math.max(0, Math.min(1, settings.volume))} + waveformNormalize={Boolean(settings.normalize)} + muted={item.muted} + > + {track.label} + </Item> + ); + })} + </Row> + ))} + + <Row + id={ZOOM_ROW_ID} + isEmpty={zoomItems.length === 0} + onMouseEnter={onZoomRowMouseEnter} + onMouseMove={onZoomRowMouseMove} + onMouseLeave={onZoomRowMouseLeave} + onMouseDown={onZoomRowMouseDown} + onClick={onZoomRowClick} + > + {canShowGhostZoom && ghostStartMs !== null && ( + <div className="absolute inset-0 z-[3] pointer-events-none"> + <div + className="absolute top-1/2 -translate-y-1/2 h-[85%] min-h-[22px]" + style={ + direction === "rtl" + ? { + right: `${ghostStartOffsetPx}px`, + width: `${ghostWidthPx}px`, + } + : { + left: `${ghostStartOffsetPx}px`, + width: `${ghostWidthPx}px`, + } + } + > + <div + className={cn( + glassStyles.glassPurple, + "w-full h-full overflow-hidden flex items-center justify-center cursor-default relative opacity-80", + )} + > + <div className={cn(glassStyles.zoomEndCap, glassStyles.left)} /> + <div className={cn(glassStyles.zoomEndCap, glassStyles.right)} /> + <div className="relative z-10 inline-flex h-4 w-4 items-center justify-center rounded-full border border-white/45 bg-white/15 text-white"> + <Plus className="h-2.5 w-2.5" /> + </div> + </div> + </div> + </div> + )} + {zoomItems + .filter((item) => !hiddenIds.has(item.id)) + .map((item) => ( + <Item + id={item.id} + key={item.id} + rowId={item.rowId} + span={item.span} + isSelected={selectAllBlocksActive || item.id === selectedZoomId} + onSelectId={onSelectZoom} + zoomDepth={item.zoomDepth} + zoomMode={item.zoomMode} + variant="zoom" + > + {item.label} + </Item> + ))} + </Row> + + {(captionsEnabled || captionItems.length > 0) && ( + <Row + id={CAPTION_ROW_ID} + isEmpty={captionItems.length === 0} + onMouseEnter={onCaptionRowMouseEnter} + onMouseMove={onCaptionRowMouseMove} + onMouseLeave={onCaptionRowMouseLeave} + onMouseDown={onCaptionRowMouseDown} + onClick={onCaptionRowClick} + > + {canShowGhostCaption && captionGhostStartMs !== null && ( + <div className="absolute inset-0 z-[3] pointer-events-none"> + <div + className="absolute top-1/2 -translate-y-1/2 h-[85%] min-h-[22px]" + style={ + direction === "rtl" + ? { + right: `${captionGhostStartOffsetPx}px`, + width: `${captionGhostWidthPx}px`, + } + : { + left: `${captionGhostStartOffsetPx}px`, + width: `${captionGhostWidthPx}px`, + } + } + > + <div + className={cn( + glassStyles.glassCaption, + "w-full h-full overflow-hidden flex items-center justify-center cursor-default relative opacity-80", + )} + > + <div className="relative z-10 inline-flex h-4 w-4 items-center justify-center rounded-full border border-white/45 bg-white/15 text-white"> + <Plus className="h-2.5 w-2.5" /> + </div> + </div> + </div> + </div> + )} + {captionItems.map((item) => ( + <Item + id={item.id} + key={item.id} + rowId={item.rowId} + span={item.span} + isSelected={item.id === selectedCaptionId} + onSelectId={onSelectCaption} + variant="caption" + > + {item.label} + </Item> + ))} + </Row> + )} + + {annotationRows.map(({ rowId, items: rowItems }, index) => ( + <Row + key={rowId} + id={rowId} + isEmpty={rowItems.length === 0} + hint={index === 0 ? HINT_ANNOTATION : undefined} + > + {rowItems.map((item) => ( + <Item + id={item.id} + key={item.id} + rowId={item.rowId} + span={item.span} + isSelected={item.id === selectedAnnotationId} + onSelectId={onSelectAnnotation} + variant="annotation" + > + {item.label} + </Item> + ))} + </Row> + ))} + + {audioRows.map(({ rowId, items: rowItems }, index) => ( + <Row + key={rowId} + id={rowId} + isEmpty={rowItems.length === 0} + hint={index === 0 ? HINT_AUDIO : undefined} + > + {rowItems.map((item) => ( + <AudioItemWithWaveform + key={item.id} + item={item} + span={item.span} + waveformSpan={liveSpanPreviewById?.[item.id] ?? item.span} + isSelected={item.id === selectedAudioId} + onSelectAudio={onSelectAudio} + /> + ))} + </Row> + ))} + </> + ); +}); + +export default function TimelineCanvas({ + items, + videoDurationMs, + currentTimeMs, + onSeek, + onAddZoomAtMs, + canPlaceZoomAtMs, + onAddCaptionAtMs, + canPlaceCaptionAtMs, + resolveCaptionSpanAtMs, + captionsEnabled, + captionQuickAddEnabled, + onSelectZoom, + onSelectClip, + onSelectAnnotation, + onSelectAudio, + onSelectCaption, + selectedZoomId, + selectedClipId, + selectedAnnotationId, + selectedAudioId, + selectedCaptionId, + selectAllBlocksActive = false, + onClearBlockSelection, + keyframes = [], + sourceAudioTracks = [], + getSourceAudioTrackSettingsForClip, + showSourceAudioTrack = false, + liveSpanPreviewById, + liveHiddenItemIds, + isDragging = false, + isLoading = false, +}: TimelineCanvasProps) { + const { setTimelineRef, style, sidebarWidth, direction, range, valueToPixels, pixelsToValue } = + useTimelineContext(); + const localTimelineRef = useRef<HTMLDivElement | null>(null); + const [isSeeking, setIsSeeking] = useState(false); + const seekRafRef = useRef<number | null>(null); + const pendingSeekClientXRef = useRef<number | null>(null); + + const setRefs = useCallback( + (node: HTMLDivElement | null) => { + setTimelineRef(node); + localTimelineRef.current = node; + }, + [setTimelineRef], + ); + + const handleTimelineClick = useCallback( + (e: MouseEvent<HTMLDivElement>) => { + if (isSeeking) return; + if (!onSeek || videoDurationMs <= 0) return; + + if (onClearBlockSelection) { + onClearBlockSelection(); + } else { + onSelectZoom?.(null); + onSelectClip?.(null); + onSelectAnnotation?.(null); + onSelectAudio?.(null); + onSelectCaption?.(null); + } + + const rect = e.currentTarget.getBoundingClientRect(); + const clickX = + direction === "rtl" + ? rect.right - sidebarWidth - e.clientX + : e.clientX - rect.left - sidebarWidth; + if (clickX < 0) return; + const relativeMs = pixelsToValue(clickX); + const absoluteMs = Math.max(0, Math.min(range.start + relativeMs, videoDurationMs)); + onSeek(absoluteMs / 1000); + }, + [ + isSeeking, + onSeek, + onSelectZoom, + onSelectClip, + onSelectAnnotation, + onSelectAudio, + onSelectCaption, + onClearBlockSelection, + videoDurationMs, + sidebarWidth, + direction, + range.start, + pixelsToValue, + ], + ); + + const getAbsoluteMsFromClientX = useCallback( + (clientX: number, rect: DOMRect) => { + const clickX = + direction === "rtl" + ? rect.right - sidebarWidth - clientX + : clientX - rect.left - sidebarWidth; + const relativeMs = pixelsToValue(clickX); + return Math.max(0, Math.min(range.start + relativeMs, videoDurationMs)); + }, + [direction, pixelsToValue, range.start, sidebarWidth, videoDurationMs], + ); + + const handleTimelineMouseDown = useCallback( + (e: MouseEvent<HTMLDivElement>) => { + if (e.button !== 0 || !onSeek || videoDurationMs <= 0 || !localTimelineRef.current) + return; + if ((e.target as HTMLElement).closest("[data-timeline-item]")) { + return; + } + + if (onClearBlockSelection) { + onClearBlockSelection(); + } else { + onSelectZoom?.(null); + onSelectClip?.(null); + onSelectAnnotation?.(null); + onSelectAudio?.(null); + onSelectCaption?.(null); + } + + const rect = localTimelineRef.current.getBoundingClientRect(); + onSeek(getAbsoluteMsFromClientX(e.clientX, rect) / 1000); + setIsSeeking(true); + e.preventDefault(); + }, + [ + getAbsoluteMsFromClientX, + onClearBlockSelection, + onSeek, + onSelectAnnotation, + onSelectAudio, + onSelectCaption, + onSelectClip, + onSelectZoom, + videoDurationMs, + ], + ); + + useEffect(() => { + if (!isSeeking) return; + + const flushSeek = () => { + seekRafRef.current = null; + if (!onSeek || !localTimelineRef.current || pendingSeekClientXRef.current === null) + return; + const rect = localTimelineRef.current.getBoundingClientRect(); + onSeek(getAbsoluteMsFromClientX(pendingSeekClientXRef.current, rect) / 1000); + }; + + const handleMouseMove = (event: globalThis.MouseEvent) => { + pendingSeekClientXRef.current = event.clientX; + if (seekRafRef.current === null) { + seekRafRef.current = requestAnimationFrame(flushSeek); + } + }; + + const handleMouseUp = () => { + if (seekRafRef.current !== null) { + cancelAnimationFrame(seekRafRef.current); + seekRafRef.current = null; + } + if (pendingSeekClientXRef.current !== null) { + flushSeek(); + } + pendingSeekClientXRef.current = null; + setIsSeeking(false); + }; + + window.addEventListener("mousemove", handleMouseMove); + window.addEventListener("mouseup", handleMouseUp); + + return () => { + if (seekRafRef.current !== null) { + cancelAnimationFrame(seekRafRef.current); + seekRafRef.current = null; + } + pendingSeekClientXRef.current = null; + window.removeEventListener("mousemove", handleMouseMove); + window.removeEventListener("mouseup", handleMouseUp); + }; + }, [getAbsoluteMsFromClientX, isSeeking, onSeek]); + + const timelineRowCount = useMemo(() => { + const annotationRowIds = new Set<string>(); + const audioRowIds = new Set<string>(); + let hasCaptionRow = false; + for (const item of items) { + if (isAnnotationTrackRowId(item.rowId)) annotationRowIds.add(item.rowId); + if (isAudioTrackRowId(item.rowId)) audioRowIds.add(item.rowId); + if (item.rowId === CAPTION_ROW_ID) hasCaptionRow = true; + } + const sourceAudioRows = showSourceAudioTrack ? sourceAudioTracks.length : 0; + // The caption lane is always shown when captions are enabled (even before any cue + // exists), so count it whenever captionsEnabled — not only when a caption item is + // present — or the min-height/stretch math undersizes the empty lane. + const captionRows = hasCaptionRow || captionsEnabled ? 1 : 0; + return 2 + sourceAudioRows + annotationRowIds.size + audioRowIds.size + captionRows; + }, [items, showSourceAudioTrack, sourceAudioTracks.length, captionsEnabled]); + const timelineRowsMinHeightPx = getTimelineRowsMinHeightPx(timelineRowCount); + const timelineContentMinHeightPx = getTimelineContentMinHeightPx(timelineRowCount); + const timelineViewportStretchFactor = getTimelineViewportStretchFactor(timelineRowCount); + const sideProperty = direction === "rtl" ? "right" : "left"; + const { + canShowGhostPlayhead, + timelineGhostOffsetPx, + handleTimelineMouseEnter, + handleTimelineMouseMove, + handleTimelineMouseLeave, + canShowGhostZoom, + ghostStartMs, + ghostStartOffsetPx, + ghostWidthPx, + handleZoomRowMouseEnter, + handleZoomRowMouseMove, + handleZoomRowMouseLeave, + handleZoomRowMouseDown, + handleZoomRowClick, + canShowGhostCaption, + captionGhostStartMs, + captionGhostStartOffsetPx, + captionGhostWidthPx, + handleCaptionRowMouseEnter, + handleCaptionRowMouseMove, + handleCaptionRowMouseLeave, + handleCaptionRowMouseDown, + handleCaptionRowClick, + } = useTimelineHover({ + direction, + sidebarWidth, + rangeStart: range.start, + rangeEnd: range.end, + videoDurationMs, + onAddZoomAtMs, + canPlaceZoomAtMs, + onAddCaptionAtMs, + canPlaceCaptionAtMs, + resolveCaptionSpanAtMs, + captionsEnabled, + captionQuickAddEnabled, + isDragging, + valueToPixels, + }); + + return ( + <div + ref={setRefs} + style={{ + ...style, + height: `max(100%, ${timelineContentMinHeightPx}px, calc(${TIMELINE_AXIS_HEIGHT_PX}px + (100% - ${TIMELINE_AXIS_HEIGHT_PX}px) * ${timelineViewportStretchFactor}))`, + }} + className="select-none bg-editor-bg relative cursor-pointer group flex flex-col" + onMouseDown={handleTimelineMouseDown} + onClick={handleTimelineClick} + onMouseEnter={handleTimelineMouseEnter} + onMouseMove={handleTimelineMouseMove} + onMouseLeave={handleTimelineMouseLeave} + > + <TimelineAxis videoDurationMs={videoDurationMs} currentTimeMs={currentTimeMs} /> + <PlaybackCursor + currentTimeMs={currentTimeMs} + videoDurationMs={videoDurationMs} + onSeek={onSeek} + timelineRef={localTimelineRef} + keyframes={keyframes} + isLoading={isLoading} + /> + {canShowGhostPlayhead && ( + <div + className="absolute top-0 bottom-0 z-[45] pointer-events-none" + style={{ + [sideProperty === "right" ? "marginRight" : "marginLeft"]: + `${sidebarWidth - 1}px`, + }} + > + <div + className="absolute top-0 bottom-0 w-px bg-foreground/35" + style={{ [sideProperty]: `${timelineGhostOffsetPx}px` }} + /> + </div> + )} + + <div + className="relative z-10 flex flex-1 min-h-0 flex-col" + style={{ minHeight: timelineRowsMinHeightPx }} + > + <TimelineCanvasRows + items={items} + videoDurationMs={videoDurationMs} + selectAllBlocksActive={selectAllBlocksActive} + selectedZoomId={selectedZoomId} + selectedClipId={selectedClipId} + selectedAnnotationId={selectedAnnotationId} + selectedAudioId={selectedAudioId} + selectedCaptionId={selectedCaptionId} + onSelectZoom={onSelectZoom} + onSelectClip={onSelectClip} + onSelectAnnotation={onSelectAnnotation} + onSelectAudio={onSelectAudio} + onSelectCaption={onSelectCaption} + sourceAudioTracks={sourceAudioTracks} + getSourceAudioTrackSettingsForClip={getSourceAudioTrackSettingsForClip} + showSourceAudioTrack={showSourceAudioTrack} + liveSpanPreviewById={liveSpanPreviewById} + liveHiddenItemIds={liveHiddenItemIds} + direction={direction} + canShowGhostZoom={canShowGhostZoom} + ghostStartMs={ghostStartMs} + ghostStartOffsetPx={ghostStartOffsetPx} + ghostWidthPx={ghostWidthPx} + onZoomRowMouseEnter={handleZoomRowMouseEnter} + onZoomRowMouseMove={handleZoomRowMouseMove} + onZoomRowMouseLeave={handleZoomRowMouseLeave} + onZoomRowMouseDown={handleZoomRowMouseDown} + onZoomRowClick={handleZoomRowClick} + captionsEnabled={captionsEnabled} + canShowGhostCaption={canShowGhostCaption} + captionGhostStartMs={captionGhostStartMs} + captionGhostStartOffsetPx={captionGhostStartOffsetPx} + captionGhostWidthPx={captionGhostWidthPx} + onCaptionRowMouseEnter={handleCaptionRowMouseEnter} + onCaptionRowMouseMove={handleCaptionRowMouseMove} + onCaptionRowMouseLeave={handleCaptionRowMouseLeave} + onCaptionRowMouseDown={handleCaptionRowMouseDown} + onCaptionRowClick={handleCaptionRowClick} + /> + </div> + </div> + ); +} diff --git a/src/components/video-editor/timeline/components/waveform/AudioWaveform.tsx b/src/components/video-editor/timeline/components/waveform/AudioWaveform.tsx new file mode 100644 index 000000000..2a5300f1a --- /dev/null +++ b/src/components/video-editor/timeline/components/waveform/AudioWaveform.tsx @@ -0,0 +1,128 @@ +import { useTimelineContext } from "dnd-timeline"; +import { memo, useCallback, useEffect, useRef, useState } from "react"; +import type { AudioPeaksData } from "../../core/timelineTypes"; + +interface AudioWaveformProps { + peaks: AudioPeaksData; + segmentStartMs?: number; + segmentEndMs?: number; + gain?: number; + normalize?: boolean; + className?: string; +} + +/** + * Renders an audio waveform as a canvas that fills its parent container. + * Automatically syncs with the timeline's visible range so the waveform + * scrolls and zooms together with the clip items above it. + */ +function AudioWaveformComponent({ + peaks, + segmentStartMs, + segmentEndMs, + gain = 1, + normalize = false, + className, +}: AudioWaveformProps) { + const canvasRef = useRef<HTMLCanvasElement>(null); + const { range } = useTimelineContext(); + const [resizeKey, setResizeKey] = useState(0); + const lastDrawAtRef = useRef(0); + + // Bump resizeKey when the canvas element changes size. + const observerRef = useRef<ResizeObserver | null>(null); + const setCanvasRef = useCallback((node: HTMLCanvasElement | null) => { + if (observerRef.current) { + observerRef.current.disconnect(); + observerRef.current = null; + } + (canvasRef as React.MutableRefObject<HTMLCanvasElement | null>).current = node; + if (node) { + const ro = new ResizeObserver(() => setResizeKey((k) => k + 1)); + ro.observe(node); + observerRef.current = ro; + } + }, []); + + // biome-ignore lint/correctness/useExhaustiveDependencies: resizeKey intentionally redraws the canvas after ResizeObserver notifications. + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + let rafId = 0; + + const draw = () => { + const now = performance.now(); + if (now - lastDrawAtRef.current < 33) { + rafId = requestAnimationFrame(draw); + return; + } + lastDrawAtRef.current = now; + + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + const rect = canvas.getBoundingClientRect(); + const dpr = window.devicePixelRatio || 1; + const width = Math.round(rect.width * dpr); + const height = Math.round(rect.height * dpr); + + if (width === 0 || height === 0) return; + + canvas.width = width; + canvas.height = height; + + ctx.clearRect(0, 0, width, height); + + const { peaks: peakData, durationMs } = peaks; + if (durationMs <= 0 || peakData.length === 0) return; + + // Use raw values for smooth zooming/panning (no snapping) + const visibleStartMs = segmentStartMs ?? range.start; + const visibleEndMs = segmentEndMs ?? range.end; + const visibleDurationMs = visibleEndMs - visibleStartMs; + + if (visibleDurationMs <= 0) return; + + const midY = height / 2; + ctx.beginPath(); + + for (let px = 0; px < width; px++) { + const t = visibleStartMs + (px / width) * visibleDurationMs; + + // If the timeline time is beyond the actual audio duration, we draw nothing (flat line) + if (t < 0 || t > durationMs) continue; + + const exactIndex = (t / durationMs) * (peakData.length - 1); + const leftIndex = Math.floor(exactIndex); + const rightIndex = Math.min(peakData.length - 1, leftIndex + 1); + const mix = exactIndex - leftIndex; + + let amplitude = peakData[leftIndex] * (1 - mix) + peakData[rightIndex] * mix; + + if (normalize) amplitude = Math.sqrt(Math.max(0, amplitude)); + amplitude = Math.max(0, Math.min(1, amplitude * gain)); + + const barHeight = amplitude * midY * 0.85; + + ctx.moveTo(px, midY - barHeight); + ctx.lineTo(px, midY + barHeight); + } + + ctx.strokeStyle = "rgba(255, 255, 255, 0.55)"; + ctx.lineWidth = dpr; + ctx.stroke(); + }; + rafId = requestAnimationFrame(draw); + return () => cancelAnimationFrame(rafId); + }, [gain, normalize, peaks, range.start, range.end, resizeKey, segmentStartMs, segmentEndMs]); + + return ( + <canvas + ref={setCanvasRef} + className={className ?? "absolute inset-0 w-full h-full pointer-events-none"} + style={{ display: "block" }} + /> + ); +} + +export default memo(AudioWaveformComponent); diff --git a/src/components/video-editor/timeline/components/wrapper/TimelineWrapper.tsx b/src/components/video-editor/timeline/components/wrapper/TimelineWrapper.tsx new file mode 100644 index 000000000..fa3cc9231 --- /dev/null +++ b/src/components/video-editor/timeline/components/wrapper/TimelineWrapper.tsx @@ -0,0 +1,280 @@ +import type { + DragEndEvent, + DragMoveEvent, + DragStartEvent, + Range, + ResizeEndEvent, + ResizeMoveEvent, + Span, +} from "dnd-timeline"; +import { TimelineContext } from "dnd-timeline"; +import type { Dispatch, ReactNode, SetStateAction } from "react"; +import { useCallback, useRef } from "react"; +import type { TimelineRegionSpan } from "../../core/timelineTypes"; +import { clampRange, resolveDragEnd, resolveResizeEnd } from "../../dnd/engine"; + +interface TimelineWrapperProps { + children: ReactNode; + range: Range; + videoDuration: number; + hasOverlap: (newSpan: Span, excludeId?: string, rowId?: string) => boolean; + onRangeChange: Dispatch<SetStateAction<Range>>; + minItemDurationMs: number; + minVisibleRangeMs: number; + gridSizeMs?: number; + onItemSpanChange: (id: string, span: Span, rowId?: string) => void; + resolveTargetRowId?: (id: string, proposedRowId: string) => string; + allRegionSpans?: TimelineRegionSpan[]; + onLiveSpanPreviewChange?: (id: string, span: Span | null) => void; + onDraggingChange?: (dragging: boolean) => void; +} + +export default function TimelineWrapper({ + children, + range, + videoDuration, + hasOverlap, + onRangeChange, + minItemDurationMs, + minVisibleRangeMs, + gridSizeMs: _gridSizeMs, + onItemSpanChange, + resolveTargetRowId, + allRegionSpans = [], + onLiveSpanPreviewChange, + onDraggingChange, +}: TimelineWrapperProps) { + const totalMs = Math.max(0, Math.round(videoDuration * 1000)); + + const onResizeEnd = useCallback( + (event: ResizeEndEvent) => { + const updatedSpan = event.active.data.current.getSpanFromResizeEvent?.(event); + if (!updatedSpan) return; + + const activeItemId = event.active.id as string; + const resolvedSpan = resolveResizeEnd(activeItemId, updatedSpan, { + totalMs, + minItemDurationMs, + allRegionSpans, + hasOverlap, + }); + if (!resolvedSpan) return; + onItemSpanChange(activeItemId, resolvedSpan); + }, + [allRegionSpans, hasOverlap, minItemDurationMs, onItemSpanChange, totalMs], + ); + + const onDragEnd = useCallback( + (event: DragEndEvent) => { + const proposedRowId = event.over?.id as string; + const updatedSpan = event.active.data.current.getSpanFromDragEvent?.(event); + if (!updatedSpan || !proposedRowId) return; + + const activeItemId = event.active.id as string; + const resolved = resolveDragEnd( + activeItemId, + updatedSpan, + proposedRowId, + { + allRegionSpans, + totalMs, + minItemDurationMs, + hasOverlap, + }, + resolveTargetRowId, + ); + if (!resolved) return; + onItemSpanChange(activeItemId, resolved.span, resolved.rowId); + }, + [ + allRegionSpans, + hasOverlap, + minItemDurationMs, + onItemSpanChange, + resolveTargetRowId, + totalMs, + ], + ); + + // Drag/resize tooltip (direct DOM updates, no re-renders) + const tooltipRef = useRef<HTMLDivElement>(null); + + const formatTooltipMs = useCallback((ms: number) => { + const s = ms / 1000; + const min = Math.floor(s / 60); + const sec = s % 60; + return min > 0 ? `${min}:${sec.toFixed(1).padStart(4, "0")}` : `${sec.toFixed(1)}s`; + }, []); + + const showTooltip = useCallback( + (span: { start: number; end: number } | null, screenX?: number) => { + const el = tooltipRef.current; + if (!el) return; + if (!span) { + el.style.opacity = "0"; + return; + } + el.textContent = `${formatTooltipMs(span.start)} – ${formatTooltipMs(span.end)}`; + el.style.opacity = "1"; + if (screenX !== undefined) { + const parent = el.parentElement; + if (parent) { + const rect = parent.getBoundingClientRect(); + const x = Math.max(0, Math.min(screenX - rect.left, rect.width - 100)); + el.style.left = `${x}px`; + } + } + }, + [formatTooltipMs], + ); + + const resolveDragPreviewSpan = useCallback( + (event: DragMoveEvent, span: Span): Span | null => { + const proposedRowId = event.over?.id as string | undefined; + if (!proposedRowId) return span; + + const activeItemId = event.active.id as string; + const resolved = resolveDragEnd( + activeItemId, + span, + proposedRowId, + { + allRegionSpans, + totalMs, + minItemDurationMs, + hasOverlap, + }, + resolveTargetRowId, + ); + + return resolved?.span ?? null; + }, + [allRegionSpans, hasOverlap, minItemDurationMs, resolveTargetRowId, totalMs], + ); + + const onDragStart = useCallback( + (event: DragStartEvent) => { + onDraggingChange?.(true); + const span = event.active.data.current.getSpanFromDragEvent?.(event); + if (span) showTooltip(span); + }, + [onDraggingChange, showTooltip], + ); + + const onDragMove = useCallback( + (event: DragMoveEvent) => { + const span = event.active.data.current.getSpanFromDragEvent?.(event); + const previewSpan = span ? resolveDragPreviewSpan(event, span) : null; + const screenX = + event.activatorEvent && "clientX" in event.activatorEvent + ? (event.activatorEvent as PointerEvent).clientX + (event.delta?.x ?? 0) + : undefined; + if (previewSpan) { + showTooltip(previewSpan, screenX); + } else { + showTooltip(null); + } + const moved = Math.hypot(event.delta?.x ?? 0, event.delta?.y ?? 0) > 0.01; + if (moved) { + onLiveSpanPreviewChange?.(event.active.id as string, previewSpan); + } + }, + [onLiveSpanPreviewChange, resolveDragPreviewSpan, showTooltip], + ); + + const onResizeMove = useCallback( + (event: ResizeMoveEvent) => { + onDraggingChange?.(true); + const span = event.active.data.current.getSpanFromResizeEvent?.(event); + const screenX = + event.activatorEvent && "clientX" in event.activatorEvent + ? (event.activatorEvent as PointerEvent).clientX + (event.delta?.x ?? 0) + : undefined; + if (span) showTooltip(span, screenX); + // dnd-timeline mutates the active item's DOM during resize; React preview + // renders here can reset that inline width/edge position and make trims stutter. + }, + [onDraggingChange, showTooltip], + ); + + const hideTooltip = useCallback(() => showTooltip(null), [showTooltip]); + + const onResizeEndWithTooltip = useCallback( + (event: ResizeEndEvent) => { + hideTooltip(); + onResizeEnd(event); + onLiveSpanPreviewChange?.(event.active.id as string, null); + onDraggingChange?.(false); + }, + [hideTooltip, onDraggingChange, onLiveSpanPreviewChange, onResizeEnd], + ); + + const onDragEndWithTooltip = useCallback( + (event: DragEndEvent) => { + hideTooltip(); + onDragEnd(event); + onLiveSpanPreviewChange?.(event.active.id as string, null); + onDraggingChange?.(false); + }, + [hideTooltip, onDraggingChange, onDragEnd, onLiveSpanPreviewChange], + ); + + // dnd-kit fires onDragCancel (not onDragEnd) when a drag is aborted, e.g. via + // Escape. Without this the drag-active state and live preview would stick. + const onDragCancel = useCallback( + (event: { active: { id: string | number } }) => { + hideTooltip(); + onLiveSpanPreviewChange?.(String(event.active.id), null); + onDraggingChange?.(false); + }, + [hideTooltip, onDraggingChange, onLiveSpanPreviewChange], + ); + + const handleRangeChange = useCallback( + (updater: (previous: Range) => Range) => { + onRangeChange((prev) => { + const normalized = + totalMs > 0 ? clampRange(prev, { totalMs, minVisibleRangeMs }) : prev; + const desired = updater(normalized); + + if (totalMs > 0) { + return clampRange(desired, { totalMs, minVisibleRangeMs }); + } + + return desired; + }); + }, + [minVisibleRangeMs, onRangeChange, totalMs], + ); + + return ( + <TimelineContext + range={range} + onRangeChanged={handleRangeChange} + onResizeEnd={onResizeEndWithTooltip} + onResizeMove={onResizeMove} + onDragStart={onDragStart} + onDragMove={onDragMove} + onDragEnd={onDragEndWithTooltip} + onDragCancel={onDragCancel} + autoScroll={{ + enabled: true, + threshold: { x: 0.08, y: 0.08 }, + acceleration: 3, + interval: 16, + layoutShiftCompensation: { x: false, y: false }, + }} + resizeHandleWidth={28} + > + <div className="relative h-full min-h-0"> + {children} + {/* Floating tooltip shown during drag/resize */} + <div + ref={tooltipRef} + className="absolute top-1 pointer-events-none z-[60] px-1.5 py-0.5 rounded bg-editor-bg/90 text-[10px] text-foreground/90 font-medium tabular-nums whitespace-nowrap border border-foreground/10 shadow-lg" + style={{ opacity: 0, transition: "opacity 0.1s" }} + /> + </div> + </TimelineContext> + ); +} diff --git a/src/components/video-editor/timeline/core/constants.ts b/src/components/video-editor/timeline/core/constants.ts new file mode 100644 index 000000000..97508022c --- /dev/null +++ b/src/components/video-editor/timeline/core/constants.ts @@ -0,0 +1,12 @@ +export const ZOOM_ROW_ID = "row-zoom"; +export const CLIP_ROW_ID = "row-clip"; +export const ANNOTATION_ROW_ID = "row-annotation"; +export const AUDIO_ROW_ID = "row-audio"; +export const SOURCE_AUDIO_ROW_ID = "row-source-audio"; +export const CAPTION_ROW_ID = "row-captions"; +export const ANNOTATION_ROW_PREFIX = `${ANNOTATION_ROW_ID}-`; +export const AUDIO_ROW_PREFIX = `${AUDIO_ROW_ID}-`; + +export const FALLBACK_RANGE_MS = 1000; +export const TARGET_MARKER_COUNT = 12; +export const WAVEFORM_DEFAULT_PEAK_COUNT = 2048; diff --git a/src/components/video-editor/timeline/core/rows.test.ts b/src/components/video-editor/timeline/core/rows.test.ts new file mode 100644 index 000000000..1d825c8f7 --- /dev/null +++ b/src/components/video-editor/timeline/core/rows.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; +import { + getAnnotationTrackIndex, + getAnnotationTrackRowId, + getAudioTrackIndex, + getAudioTrackRowId, + isAnnotationTrackRowId, + isAudioTrackRowId, +} from "./rows"; + +describe("timeline core/rows", () => { + it("builds and parses annotation rows", () => { + expect(getAnnotationTrackRowId(2.9)).toBe("row-annotation-2"); + expect(getAnnotationTrackRowId(-5)).toBe("row-annotation-0"); + expect(getAnnotationTrackIndex("row-annotation-4")).toBe(4); + expect(getAnnotationTrackIndex("row-annotation")).toBe(0); + expect(isAnnotationTrackRowId("row-annotation")).toBe(true); + expect(isAnnotationTrackRowId("row-annotation-1")).toBe(true); + }); + + it("handles invalid annotation row IDs safely", () => { + expect(getAnnotationTrackIndex("row-annotation-foo")).toBe(0); + expect(getAnnotationTrackIndex("other")).toBe(0); + expect(isAnnotationTrackRowId("row-audio-1")).toBe(false); + }); + + it("builds and parses audio rows", () => { + expect(getAudioTrackRowId(1.2)).toBe("row-audio-1"); + expect(getAudioTrackRowId(-3)).toBe("row-audio-0"); + expect(getAudioTrackIndex("row-audio-3")).toBe(3); + expect(getAudioTrackIndex("row-audio")).toBe(0); + expect(isAudioTrackRowId("row-audio")).toBe(true); + expect(isAudioTrackRowId("row-audio-1")).toBe(true); + }); + + it("handles invalid audio row IDs safely", () => { + expect(getAudioTrackIndex("row-audio-foo")).toBe(0); + expect(getAudioTrackIndex("other")).toBe(0); + expect(isAudioTrackRowId("row-annotation-1")).toBe(false); + }); +}); diff --git a/src/components/video-editor/timeline/core/rows.ts b/src/components/video-editor/timeline/core/rows.ts new file mode 100644 index 000000000..7b6453460 --- /dev/null +++ b/src/components/video-editor/timeline/core/rows.ts @@ -0,0 +1,40 @@ +import { + ANNOTATION_ROW_ID, + ANNOTATION_ROW_PREFIX, + AUDIO_ROW_ID, + AUDIO_ROW_PREFIX, +} from "./constants"; + +export function getAnnotationTrackRowId(trackIndex: number) { + return `${ANNOTATION_ROW_ID}-${Math.max(0, Math.floor(trackIndex))}`; +} + +export function isAnnotationTrackRowId(rowId: string) { + return rowId === ANNOTATION_ROW_ID || rowId.startsWith(ANNOTATION_ROW_PREFIX); +} + +export function getAnnotationTrackIndex(rowId: string) { + if (rowId === ANNOTATION_ROW_ID) { + return 0; + } + + const parsed = Number.parseInt(rowId.slice(ANNOTATION_ROW_PREFIX.length), 10); + return Number.isFinite(parsed) ? Math.max(0, parsed) : 0; +} + +export function getAudioTrackRowId(trackIndex: number) { + return `${AUDIO_ROW_PREFIX}${Math.max(0, Math.floor(trackIndex))}`; +} + +export function isAudioTrackRowId(rowId: string) { + return rowId === AUDIO_ROW_ID || rowId.startsWith(AUDIO_ROW_PREFIX); +} + +export function getAudioTrackIndex(rowId: string) { + if (rowId === AUDIO_ROW_ID) { + return 0; + } + + const parsed = Number.parseInt(rowId.slice(AUDIO_ROW_PREFIX.length), 10); + return Number.isFinite(parsed) ? Math.max(0, parsed) : 0; +} diff --git a/src/components/video-editor/timeline/core/spans.test.ts b/src/components/video-editor/timeline/core/spans.test.ts new file mode 100644 index 000000000..29b02545f --- /dev/null +++ b/src/components/video-editor/timeline/core/spans.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import { normalizeRegionSpan, spansOverlap } from "./spans"; + +describe("timeline core/spans", () => { + it("treats adjacent spans as non-overlapping", () => { + expect(spansOverlap({ start: 0, end: 100 }, { start: 100, end: 200 })).toBe(false); + }); + + it("detects strict overlap and containment", () => { + expect(spansOverlap({ start: 0, end: 101 }, { start: 100, end: 200 })).toBe(true); + expect(spansOverlap({ start: 50, end: 150 }, { start: 75, end: 100 })).toBe(true); + }); + + it("normalizes region to bounds and enforces min duration", () => { + expect( + normalizeRegionSpan({ startMs: -50, endMs: 10, totalMs: 1000, minDurationMs: 100 }), + ).toEqual({ start: 0, end: 100 }); + }); + + it("clamps start when requested end exceeds total", () => { + expect( + normalizeRegionSpan({ startMs: 980, endMs: 1200, totalMs: 1000, minDurationMs: 100 }), + ).toEqual({ start: 900, end: 1000 }); + }); + + it("keeps already valid spans unchanged", () => { + expect( + normalizeRegionSpan({ startMs: 100, endMs: 300, totalMs: 1000, minDurationMs: 50 }), + ).toEqual({ start: 100, end: 300 }); + }); + + it("never exceeds total when min duration is larger than total", () => { + expect( + normalizeRegionSpan({ startMs: 100, endMs: 300, totalMs: 80, minDurationMs: 100 }), + ).toEqual({ start: 0, end: 80 }); + }); +}); diff --git a/src/components/video-editor/timeline/core/spans.ts b/src/components/video-editor/timeline/core/spans.ts new file mode 100644 index 000000000..6f8ac203c --- /dev/null +++ b/src/components/video-editor/timeline/core/spans.ts @@ -0,0 +1,24 @@ +import type { Span } from "dnd-timeline"; + +export function spansOverlap(left: Span, right: Span) { + return left.end > right.start && left.start < right.end; +} + +export function normalizeRegionSpan(params: { + startMs: number; + endMs: number; + totalMs: number; + minDurationMs: number; +}) { + const { startMs, endMs, totalMs, minDurationMs } = params; + const safeTotalMs = Math.max(0, totalMs); + const safeMinDurationMs = Math.max(0, Math.min(minDurationMs, safeTotalMs)); + const clampedStart = Math.max(0, Math.min(startMs, safeTotalMs)); + const normalizedStart = Math.max(0, Math.min(clampedStart, safeTotalMs - safeMinDurationMs)); + const normalizedEnd = Math.min( + safeTotalMs, + Math.max(endMs, normalizedStart + safeMinDurationMs), + ); + + return { start: normalizedStart, end: normalizedEnd }; +} diff --git a/src/components/video-editor/timeline/core/time.test.ts b/src/components/video-editor/timeline/core/time.test.ts new file mode 100644 index 000000000..a1a64bd36 --- /dev/null +++ b/src/components/video-editor/timeline/core/time.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import { + calculateAxisScale, + calculateTimelineScale, + createInitialRange, + formatPlayheadTime, + formatTimeLabel, + normalizeWheelDeltaToPixels, +} from "./time"; + +describe("timeline core/time", () => { + it("creates fallback range for empty or invalid duration", () => { + expect(createInitialRange(0)).toEqual({ start: 0, end: 1000 }); + expect(createInitialRange(-10)).toEqual({ start: 0, end: 1000 }); + expect(createInitialRange(2500)).toEqual({ start: 0, end: 2500 }); + }); + + it("computes scale defaults and caps", () => { + expect(calculateTimelineScale(0)).toEqual({ + minItemDurationMs: 100, + defaultItemDurationMs: 1000, + minVisibleRangeMs: 300, + }); + expect(calculateTimelineScale(1).defaultItemDurationMs).toBe(100); + expect(calculateTimelineScale(100).defaultItemDurationMs).toBe(5000); + expect(calculateTimelineScale(10_000).defaultItemDurationMs).toBe(30000); + }); + + it("formats timeline labels in fractional, whole-second, and hour modes", () => { + expect(formatTimeLabel(1234, 100)).toBe("0:01.23"); + expect(formatTimeLabel(1234, 500)).toBe("0:01.2"); + expect(formatTimeLabel(61_900, 1000)).toBe("1:01"); + expect(formatTimeLabel(3_661_999, 1000)).toBe("1:01:01"); + }); + + it("formats playhead labels for sub-minute and minute timelines", () => { + expect(formatPlayheadTime(1234)).toBe("1.2s"); + expect(formatPlayheadTime(61_400)).toBe("1:01.4"); + }); + + it("normalizes wheel delta by deltaMode", () => { + expect(normalizeWheelDeltaToPixels(2, 0)).toBe(2); + expect(normalizeWheelDeltaToPixels(2, 1)).toBe(32); + expect(normalizeWheelDeltaToPixels(2, 2)).toBe(480); + expect(normalizeWheelDeltaToPixels(-3, 1)).toBe(-48); + }); + + it("picks fine and coarse axis scales based on visible range", () => { + const tiny = calculateAxisScale(1); + const typical = calculateAxisScale(2000); + const huge = calculateAxisScale(24 * 60 * 60 * 1000); + + expect(tiny.intervalMs).toBeGreaterThan(0); + expect(tiny.gridMs).toBeGreaterThan(0); + expect(typical.intervalMs).toBeGreaterThanOrEqual(tiny.intervalMs); + expect(huge.intervalMs).toBeGreaterThanOrEqual(typical.intervalMs); + }); +}); diff --git a/src/components/video-editor/timeline/core/time.ts b/src/components/video-editor/timeline/core/time.ts new file mode 100644 index 000000000..7e02f8ed8 --- /dev/null +++ b/src/components/video-editor/timeline/core/time.ts @@ -0,0 +1,116 @@ +import type { Range } from "dnd-timeline"; +import { FALLBACK_RANGE_MS, TARGET_MARKER_COUNT } from "./constants"; + +export interface TimelineScaleConfig { + minItemDurationMs: number; + defaultItemDurationMs: number; + minVisibleRangeMs: number; +} + +const SCALE_CANDIDATES = [ + { intervalSeconds: 0.05, gridSeconds: 0.01 }, + { intervalSeconds: 0.1, gridSeconds: 0.02 }, + { intervalSeconds: 0.25, gridSeconds: 0.05 }, + { intervalSeconds: 0.5, gridSeconds: 0.1 }, + { intervalSeconds: 1, gridSeconds: 0.25 }, + { intervalSeconds: 2, gridSeconds: 0.5 }, + { intervalSeconds: 5, gridSeconds: 1 }, + { intervalSeconds: 10, gridSeconds: 2 }, + { intervalSeconds: 15, gridSeconds: 3 }, + { intervalSeconds: 30, gridSeconds: 5 }, + { intervalSeconds: 60, gridSeconds: 10 }, + { intervalSeconds: 120, gridSeconds: 20 }, + { intervalSeconds: 300, gridSeconds: 30 }, + { intervalSeconds: 600, gridSeconds: 60 }, + { intervalSeconds: 900, gridSeconds: 120 }, + { intervalSeconds: 1800, gridSeconds: 180 }, + { intervalSeconds: 3600, gridSeconds: 300 }, +]; + +export function calculateAxisScale(visibleRangeMs: number): { + intervalMs: number; + gridMs: number; +} { + const visibleSeconds = visibleRangeMs / 1000; + const candidate = + SCALE_CANDIDATES.find((scaleCandidate) => { + if (visibleSeconds <= 0) { + return true; + } + return visibleSeconds / scaleCandidate.intervalSeconds <= TARGET_MARKER_COUNT; + }) ?? SCALE_CANDIDATES[SCALE_CANDIDATES.length - 1]; + + return { + intervalMs: Math.round(candidate.intervalSeconds * 1000), + gridMs: Math.round(candidate.gridSeconds * 1000), + }; +} + +export function calculateTimelineScale(durationSeconds: number): TimelineScaleConfig { + const totalMs = Math.max(0, Math.round(durationSeconds * 1000)); + const minItemDurationMs = 100; + + const defaultItemDurationMs = + totalMs > 0 + ? Math.max(minItemDurationMs, Math.min(Math.round(totalMs * 0.05), 30000)) + : Math.max(minItemDurationMs, 1000); + + const minVisibleRangeMs = 300; + + return { + minItemDurationMs, + defaultItemDurationMs, + minVisibleRangeMs, + }; +} + +export function createInitialRange(totalMs: number): Range { + if (totalMs > 0) { + return { start: 0, end: totalMs }; + } + + return { start: 0, end: FALLBACK_RANGE_MS }; +} + +export function normalizeWheelDeltaToPixels(delta: number, deltaMode: number) { + if (deltaMode === 1) { + return delta * 16; + } + + if (deltaMode === 2) { + return delta * 240; + } + + return delta; +} + +export function formatTimeLabel(milliseconds: number, intervalMs: number) { + const totalSeconds = milliseconds / 1000; + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + + const fractionalDigits = intervalMs < 250 ? 2 : intervalMs < 1000 ? 1 : 0; + + if (hours > 0) { + const minutesString = minutes.toString().padStart(2, "0"); + const secondsString = Math.floor(seconds).toString().padStart(2, "0"); + return `${hours}:${minutesString}:${secondsString}`; + } + + if (fractionalDigits > 0) { + const secondsWithFraction = seconds.toFixed(fractionalDigits); + const [wholeSeconds, fraction] = secondsWithFraction.split("."); + return `${minutes}:${wholeSeconds.padStart(2, "0")}.${fraction}`; + } + + return `${minutes}:${Math.floor(seconds).toString().padStart(2, "0")}`; +} + +export function formatPlayheadTime(ms: number): string { + const s = ms / 1000; + const min = Math.floor(s / 60); + const sec = s % 60; + if (min > 0) return `${min}:${sec.toFixed(1).padStart(4, "0")}`; + return `${sec.toFixed(1)}s`; +} diff --git a/src/components/video-editor/timeline/core/timelineTypes.ts b/src/components/video-editor/timeline/core/timelineTypes.ts new file mode 100644 index 000000000..881ec39ca --- /dev/null +++ b/src/components/video-editor/timeline/core/timelineTypes.ts @@ -0,0 +1,50 @@ +import type { Span } from "dnd-timeline"; +import type { ShortcutBinding } from "@/lib/shortcuts"; +import type { ZoomMode } from "../../types"; + +export interface TimelineRegionSpan { + id: string; + start: number; + end: number; + rowId: string; +} + +export interface TimelineRegion { + id: string; + startMs: number; + endMs: number; +} + +export interface TimelineAudioRegion extends TimelineRegion { + trackIndex?: number; +} + +export interface TimelineShortcutBindings { + addKeyframe: ShortcutBinding; + addZoom: ShortcutBinding; + splitClip: ShortcutBinding; + addAnnotation: ShortcutBinding; + deleteSelected: ShortcutBinding; +} + +export interface TimelineRenderItem { + id: string; + rowId: string; + span: Span; + sourceSpan?: Span; + label: string; + audioPath?: string; + audioGain?: number; + audioNormalize?: boolean; + zoomDepth?: number; + zoomMode?: ZoomMode; + speedValue?: number; + showSourceAudio?: boolean; + muted?: boolean; + variant: "zoom" | "trim" | "clip" | "annotation" | "speed" | "audio" | "caption"; +} + +export interface AudioPeaksData { + durationMs: number; + peaks: Float32Array; +} diff --git a/src/components/video-editor/timeline/dnd/engine.test.ts b/src/components/video-editor/timeline/dnd/engine.test.ts new file mode 100644 index 000000000..c7879acbb --- /dev/null +++ b/src/components/video-editor/timeline/dnd/engine.test.ts @@ -0,0 +1,243 @@ +import { describe, expect, it } from "vitest"; +import { + clampDraggedSpanToNeighbours, + clampRange, + clampResizedSpanToNeighbours, + clampSpanToBounds, + getSiblingSpans, + resolveDragEnd, + resolveResizeEnd, +} from "./engine"; + +const BASE_SPANS = [ + { id: "a", start: 0, end: 1000, rowId: "row-clip" }, + { id: "b", start: 1500, end: 2500, rowId: "row-clip" }, + { id: "c", start: 3000, end: 3600, rowId: "row-clip" }, + { id: "aud-1", start: 100, end: 500, rowId: "row-audio-0" }, +]; + +const hasBaseOverlap = (span: { start: number; end: number }, excludeId?: string, rowId?: string) => + BASE_SPANS.some( + (region) => + region.id !== excludeId && + (!rowId || region.rowId === rowId) && + span.start < region.end && + span.end > region.start, + ); + +describe("timeline dnd engine", () => { + it("clamps item span to timeline bounds and min duration", () => { + expect( + clampSpanToBounds({ start: -100, end: 20 }, { totalMs: 5000, minItemDurationMs: 100 }), + ).toEqual({ start: 0, end: 120 }); + expect( + clampSpanToBounds( + { start: 4900, end: 7000 }, + { totalMs: 5000, minItemDurationMs: 100 }, + ), + ).toEqual({ start: 2900, end: 5000 }); + }); + + it("handles zero-duration timelines in span clamping", () => { + expect( + clampSpanToBounds({ start: -10, end: -5 }, { totalMs: 0, minItemDurationMs: 100 }), + ).toEqual({ start: 0, end: 100 }); + expect( + clampSpanToBounds({ start: 50, end: 60 }, { totalMs: 0, minItemDurationMs: 1 }), + ).toEqual({ start: 50, end: 60 }); + }); + + it("clamps visible range for bounded and unbounded timelines", () => { + expect( + clampRange({ start: 4900, end: 5200 }, { totalMs: 5000, minVisibleRangeMs: 300 }), + ).toEqual({ start: 4700, end: 5000 }); + expect(clampRange({ start: -20, end: 50 }, { totalMs: 0, minVisibleRangeMs: 300 })).toEqual( + { start: 0, end: 300 }, + ); + }); + + it("resolves siblings by row and active item", () => { + expect(getSiblingSpans("b", undefined, BASE_SPANS).map((s) => s.id)).toEqual(["a", "c"]); + expect(getSiblingSpans("missing", "row-clip", BASE_SPANS).map((s) => s.id)).toEqual([ + "a", + "b", + "c", + ]); + expect(getSiblingSpans("missing", undefined, BASE_SPANS)).toEqual([]); + }); + + it("clamps resize against nearest neighbours and min duration", () => { + const resizedRight = clampResizedSpanToNeighbours({ start: 900, end: 2000 }, "a", { + allRegionSpans: BASE_SPANS, + minItemDurationMs: 100, + totalMs: 5000, + }); + expect(resizedRight.end).toBe(1500); + + const resizedLeft = clampResizedSpanToNeighbours({ start: 900, end: 2500 }, "b", { + allRegionSpans: BASE_SPANS, + minItemDurationMs: 100, + totalMs: 5000, + }); + expect(resizedLeft.start).toBe(1000); + }); + + it("keeps drag unchanged when already inside valid neighbour gap", () => { + const dragged = clampDraggedSpanToNeighbours({ start: 1400, end: 2400 }, "b", "row-clip", { + allRegionSpans: BASE_SPANS, + minItemDurationMs: 100, + totalMs: 5000, + }); + expect(dragged).toEqual({ start: 1400, end: 2400 }); + }); + + it("clamps drag to previous or next neighbour bounds", () => { + const toLeftBoundary = clampDraggedSpanToNeighbours( + { start: -500, end: 500 }, + "b", + "row-clip", + { allRegionSpans: BASE_SPANS, minItemDurationMs: 100, totalMs: 5000 }, + ); + expect(toLeftBoundary).toEqual({ start: 1000, end: 2000 }); + + const toRightBoundary = clampDraggedSpanToNeighbours( + { start: 2200, end: 3200 }, + "b", + "row-clip", + { allRegionSpans: BASE_SPANS, minItemDurationMs: 100, totalMs: 5000 }, + ); + expect(toRightBoundary).toEqual({ start: 2000, end: 3000 }); + }); + + it("places a clip after the next neighbour once most of the dragged clip crosses its start", () => { + const dragged = clampDraggedSpanToNeighbours({ start: 2600, end: 3600 }, "b", "row-clip", { + allRegionSpans: BASE_SPANS, + minItemDurationMs: 100, + totalMs: 5000, + }); + expect(dragged).toEqual({ start: 3600, end: 4600 }); + }); + + it("allows the final clip drag to extend the timeline", () => { + const dragged = clampDraggedSpanToNeighbours({ start: 5200, end: 5800 }, "c", "row-clip", { + allRegionSpans: BASE_SPANS, + minItemDurationMs: 100, + totalMs: 5000, + }); + expect(dragged).toEqual({ start: 5200, end: 5800 }); + }); + + it("falls back to generic clamping when active drag item is unknown", () => { + const dragged = clampDraggedSpanToNeighbours( + { start: -10, end: 20 }, + "missing", + "row-clip", + { allRegionSpans: BASE_SPANS, minItemDurationMs: 100, totalMs: 5000 }, + ); + expect(dragged).toEqual({ start: 0, end: 100 }); + }); + + it("resolves resize end with overlap fallback semantics", () => { + const result = resolveResizeEnd( + "a", + { start: 900, end: 2200 }, + { + totalMs: 5000, + minItemDurationMs: 100, + allRegionSpans: BASE_SPANS, + hasOverlap: (span, id) => id === "a" && span.end > 1500, + }, + ); + expect(result).toEqual({ start: 900, end: 1500 }); + }); + + it("returns null when resize still overlaps after neighbour clamp", () => { + const result = resolveResizeEnd( + "a", + { start: 900, end: 2200 }, + { + totalMs: 5000, + minItemDurationMs: 100, + allRegionSpans: BASE_SPANS, + hasOverlap: () => true, + }, + ); + expect(result).toBeNull(); + }); + + it("resolves drag end with row resolver while preserving duration", () => { + const result = resolveDragEnd( + "b", + { start: 1200, end: 1800 }, + "row-clip", + { + allRegionSpans: BASE_SPANS, + totalMs: 5000, + minItemDurationMs: 100, + hasOverlap: () => false, + }, + (id, rowId) => (id === "b" ? rowId : rowId), + ); + expect(result).toEqual({ rowId: "row-clip", span: { start: 1200, end: 2200 } }); + }); + + it("resolves final clip drags beyond the current timeline duration", () => { + const result = resolveDragEnd("c", { start: 5200, end: 5800 }, "row-clip", { + allRegionSpans: BASE_SPANS, + totalMs: 5000, + minItemDurationMs: 100, + hasOverlap: () => false, + }); + expect(result).toEqual({ rowId: "row-clip", span: { start: 5200, end: 5800 } }); + }); + + it("resolves a middle clip drag after the final clip by extending the timeline", () => { + const result = resolveDragEnd("b", { start: 4200, end: 5200 }, "row-clip", { + allRegionSpans: BASE_SPANS, + totalMs: 5000, + minItemDurationMs: 100, + hasOverlap: () => false, + }); + expect(result).toEqual({ rowId: "row-clip", span: { start: 4200, end: 5200 } }); + }); + + it("resolves an overlapping clip drag as an after-neighbour reorder intent", () => { + const result = resolveDragEnd("b", { start: 3500, end: 4500 }, "row-clip", { + allRegionSpans: BASE_SPANS, + totalMs: 5000, + minItemDurationMs: 100, + hasOverlap: hasBaseOverlap, + }); + expect(result).toEqual({ rowId: "row-clip", span: { start: 3600, end: 4600 } }); + }); + + it("keeps non-clip drags bounded by the current timeline duration", () => { + const result = resolveDragEnd("aud-1", { start: 5200, end: 5600 }, "row-audio-0", { + allRegionSpans: BASE_SPANS, + totalMs: 5000, + minItemDurationMs: 100, + hasOverlap: () => false, + }); + expect(result).toEqual({ rowId: "row-audio-0", span: { start: 4600, end: 5000 } }); + }); + + it("returns null when drag still overlaps after neighbour clamp", () => { + const result = resolveDragEnd("b", { start: 1200, end: 1800 }, "row-clip", { + allRegionSpans: BASE_SPANS, + totalMs: 5000, + minItemDurationMs: 100, + hasOverlap: () => true, + }); + expect(result).toBeNull(); + }); + + it("keeps proposed row when no target row resolver is provided", () => { + const result = resolveDragEnd("aud-1", { start: 700, end: 1000 }, "row-audio-2", { + allRegionSpans: BASE_SPANS, + totalMs: 5000, + minItemDurationMs: 100, + hasOverlap: () => false, + }); + expect(result?.rowId).toBe("row-audio-2"); + }); +}); diff --git a/src/components/video-editor/timeline/dnd/engine.ts b/src/components/video-editor/timeline/dnd/engine.ts new file mode 100644 index 000000000..84c9d99c9 --- /dev/null +++ b/src/components/video-editor/timeline/dnd/engine.ts @@ -0,0 +1,334 @@ +import type { Range, Span } from "dnd-timeline"; +import { CLIP_ROW_ID } from "../core/constants"; +import type { TimelineRegionSpan } from "../core/timelineTypes"; + +export interface DndEngineConfig { + totalMs: number; + minItemDurationMs: number; + minVisibleRangeMs: number; + allRegionSpans: TimelineRegionSpan[]; + hasOverlap: (newSpan: Span, excludeId?: string, rowId?: string) => boolean; +} + +export function clampSpanToBounds( + span: Span, + config: Pick<DndEngineConfig, "totalMs" | "minItemDurationMs">, +): Span { + const { totalMs, minItemDurationMs } = config; + const rawDuration = Math.max(span.end - span.start, 0); + const normalizedStart = Number.isFinite(span.start) ? span.start : 0; + + if (totalMs === 0) { + const minDuration = Math.max(minItemDurationMs, 1); + const duration = Math.max(rawDuration, minDuration); + const start = Math.max(0, normalizedStart); + return { start, end: start + duration }; + } + + const minDuration = Math.min(Math.max(minItemDurationMs, 1), totalMs); + const duration = Math.min(Math.max(rawDuration, minDuration), totalMs); + const start = Math.max(0, Math.min(normalizedStart, totalMs - duration)); + return { start, end: start + duration }; +} + +export function clampRange( + candidate: Range, + config: Pick<DndEngineConfig, "totalMs" | "minVisibleRangeMs">, +): Range { + const { totalMs, minVisibleRangeMs } = config; + if (totalMs === 0) { + const minSpan = Math.max(minVisibleRangeMs, 1); + const span = Math.max(candidate.end - candidate.start, minSpan); + const start = Math.max(0, Math.min(candidate.start, candidate.end - span)); + return { start, end: start + span }; + } + + const rawStart = Math.max(0, candidate.start); + const rawEnd = candidate.end; + const clampedEnd = Math.min(rawEnd, totalMs); + const minSpan = Math.min(Math.max(minVisibleRangeMs, 1), totalMs); + const desiredSpan = clampedEnd - rawStart; + const span = Math.min(Math.max(desiredSpan, minSpan), totalMs); + + let finalStart = rawStart; + let finalEnd = finalStart + span; + if (finalEnd > totalMs) { + finalEnd = totalMs; + finalStart = Math.max(0, finalEnd - span); + } + + return { start: finalStart, end: finalEnd }; +} + +export function getSiblingSpans( + activeItemId: string, + rowId: string | undefined, + allRegionSpans: TimelineRegionSpan[], +) { + const activeItem = allRegionSpans.find((region) => region.id === activeItemId); + const resolvedRowId = rowId ?? activeItem?.rowId; + if (!resolvedRowId) { + return []; + } + + return allRegionSpans + .filter((region) => region.id !== activeItemId && region.rowId === resolvedRowId) + .sort((left, right) => left.start - right.start); +} + +export function clampResizedSpanToNeighbours( + span: Span, + activeItemId: string, + config: Pick<DndEngineConfig, "allRegionSpans" | "minItemDurationMs" | "totalMs">, +): Span { + const { allRegionSpans, minItemDurationMs, totalMs } = config; + const siblings = getSiblingSpans(activeItemId, undefined, allRegionSpans); + const activeItem = allRegionSpans.find((region) => region.id === activeItemId); + let { start, end } = span; + + for (const r of siblings) { + if (end > r.start && start < r.start) { + end = r.start; + } + if (start < r.end && end > r.end) { + start = r.end; + } + } + + const minDur = Math.min(minItemDurationMs, totalMs || minItemDurationMs); + if (end - start < minDur) { + const resizedLeft = Boolean( + activeItem && span.start !== activeItem.start && span.end === activeItem.end, + ); + if (resizedLeft) { + start = end - minDur; + } else { + end = start + minDur; + } + } + + return { start: Math.max(0, start), end: Math.min(end, totalMs || end) }; +} + +function getClipDragTotalMs( + activeItem: TimelineRegionSpan | undefined, + rowId: string | undefined, + span: Span, + totalMs: number, +) { + if (activeItem?.rowId !== CLIP_ROW_ID || rowId !== CLIP_ROW_ID) { + return totalMs; + } + + return Math.max(totalMs, Math.ceil(span.end)); +} + +function spansOverlap(left: Span, right: Span) { + return left.start < right.end && left.end > right.start; +} + +function placeSpanAfterSibling( + siblings: TimelineRegionSpan[], + siblingIndex: number, + duration: number, +): Span { + let start = siblings[siblingIndex].end; + + for (let index = siblingIndex + 1; index < siblings.length; index += 1) { + const sibling = siblings[index]; + if (start + duration <= sibling.start) { + break; + } + start = sibling.end; + } + + return { start, end: start + duration }; +} + +function placeSpanBeforeSibling( + siblings: TimelineRegionSpan[], + siblingIndex: number, + duration: number, +): Span | null { + let end = siblings[siblingIndex].start; + + for (let index = siblingIndex - 1; index >= 0; index -= 1) { + const sibling = siblings[index]; + if (end - duration >= sibling.end) { + break; + } + end = sibling.start; + } + + const start = end - duration; + if (start < 0) { + return null; + } + + return { start, end }; +} + +function resolveClipDragInsertionSpan(params: { + activeItem: TimelineRegionSpan; + siblings: TimelineRegionSpan[]; + proposedStart: number; + duration: number; +}): Span | null { + const { activeItem, siblings, proposedStart, duration } = params; + const proposedSpan = { start: proposedStart, end: proposedStart + duration }; + const proposedCenter = proposedStart + duration / 2; + const delta = proposedStart - activeItem.start; + + if (delta > 0) { + const nextIndex = siblings.findIndex( + (sibling) => + sibling.start >= activeItem.end && + spansOverlap(proposedSpan, sibling) && + proposedCenter >= sibling.start, + ); + if (nextIndex >= 0) { + return placeSpanAfterSibling(siblings, nextIndex, duration); + } + + return null; + } + + if (delta < 0) { + for (let index = siblings.length - 1; index >= 0; index -= 1) { + const sibling = siblings[index]; + if ( + sibling.end <= activeItem.start && + spansOverlap(proposedSpan, sibling) && + proposedCenter <= sibling.end + ) { + return placeSpanBeforeSibling(siblings, index, duration); + } + } + } + + return null; +} + +export function clampDraggedSpanToNeighbours( + span: Span, + activeItemId: string, + rowId: string | undefined, + config: Pick<DndEngineConfig, "allRegionSpans" | "minItemDurationMs" | "totalMs">, +): Span { + const { allRegionSpans, minItemDurationMs, totalMs } = config; + const activeItem = allRegionSpans.find((region) => region.id === activeItemId); + if (!activeItem) { + return clampSpanToBounds(span, { totalMs, minItemDurationMs }); + } + + const siblings = getSiblingSpans(activeItemId, rowId, allRegionSpans); + const duration = Math.max( + activeItem.end - activeItem.start, + Math.min(minItemDurationMs, totalMs || minItemDurationMs), + ); + const proposedStart = Number.isFinite(span.start) ? span.start : activeItem.start; + const proposedSpan = { start: proposedStart, end: proposedStart + duration }; + + if (activeItem.rowId === CLIP_ROW_ID && rowId === CLIP_ROW_ID) { + const insertionSpan = resolveClipDragInsertionSpan({ + activeItem, + siblings, + proposedStart, + duration, + }); + if (insertionSpan) { + const insertionTotalMs = getClipDragTotalMs(activeItem, rowId, insertionSpan, totalMs); + return clampSpanToBounds(insertionSpan, { + totalMs: insertionTotalMs, + minItemDurationMs, + }); + } + } + + const effectiveTotalMs = getClipDragTotalMs(activeItem, rowId, proposedSpan, totalMs); + + const previousSibling = [...siblings] + .reverse() + .find((region) => region.end <= activeItem.start); + const nextSibling = siblings.find((region) => region.start >= activeItem.end); + const minStart = previousSibling ? previousSibling.end : 0; + const maxStart = nextSibling + ? nextSibling.start - duration + : effectiveTotalMs > 0 + ? effectiveTotalMs - duration + : proposedStart; + + const start = Math.max(minStart, Math.min(proposedStart, maxStart)); + return clampSpanToBounds( + { start, end: start + duration }, + { totalMs: effectiveTotalMs, minItemDurationMs }, + ); +} + +export function resolveResizeEnd( + activeItemId: string, + updatedSpan: Span, + config: Pick< + DndEngineConfig, + "totalMs" | "minItemDurationMs" | "allRegionSpans" | "hasOverlap" + >, +): Span | null { + const { totalMs, minItemDurationMs, allRegionSpans, hasOverlap } = config; + let clamped = clampSpanToBounds(updatedSpan, { totalMs, minItemDurationMs }); + const effectiveMinDuration = + totalMs > 0 ? Math.min(minItemDurationMs, totalMs) : minItemDurationMs; + if (clamped.end - clamped.start < effectiveMinDuration) { + return null; + } + + if (hasOverlap(clamped, activeItemId)) { + clamped = clampSpanToBounds( + clampResizedSpanToNeighbours(clamped, activeItemId, { + allRegionSpans, + minItemDurationMs, + totalMs, + }), + { totalMs, minItemDurationMs }, + ); + if (hasOverlap(clamped, activeItemId)) { + return null; + } + } + + return clamped; +} + +export function resolveDragEnd( + activeItemId: string, + updatedSpan: Span, + proposedRowId: string, + config: Pick< + DndEngineConfig, + "allRegionSpans" | "totalMs" | "minItemDurationMs" | "hasOverlap" + >, + resolveTargetRowId?: (id: string, proposedRowId: string) => string, +): { span: Span; rowId: string } | null { + const { allRegionSpans, totalMs, minItemDurationMs, hasOverlap } = config; + const resolvedRowId = resolveTargetRowId?.(activeItemId, proposedRowId) ?? proposedRowId; + + const activeItem = allRegionSpans.find((r) => r.id === activeItemId); + const originalDuration = activeItem + ? activeItem.end - activeItem.start + : updatedSpan.end - updatedSpan.start; + const dragSpan: Span = { start: updatedSpan.start, end: updatedSpan.start + originalDuration }; + const effectiveTotalMs = getClipDragTotalMs(activeItem, resolvedRowId, dragSpan, totalMs); + + let clamped = clampSpanToBounds(dragSpan, { totalMs: effectiveTotalMs, minItemDurationMs }); + if (hasOverlap(clamped, activeItemId, resolvedRowId)) { + clamped = clampDraggedSpanToNeighbours(clamped, activeItemId, resolvedRowId, { + allRegionSpans, + minItemDurationMs, + totalMs: effectiveTotalMs, + }); + if (hasOverlap(clamped, activeItemId, resolvedRowId)) { + return null; + } + } + + return { span: clamped, rowId: resolvedRowId }; +} diff --git a/src/components/video-editor/timeline/hooks/actions/useTimelineAudioActions.ts b/src/components/video-editor/timeline/hooks/actions/useTimelineAudioActions.ts new file mode 100644 index 000000000..63dc36eb2 --- /dev/null +++ b/src/components/video-editor/timeline/hooks/actions/useTimelineAudioActions.ts @@ -0,0 +1,144 @@ +import { useCallback, useMemo } from "react"; +import { resolveMediaElementSource } from "@/lib/exporter/localMediaSource"; +import type { TimelineAudioRegion } from "../../core/timelineTypes"; +import { resolveAudioPlacement } from "../utils/timelineAudioPlacement"; +import { timelineNotifications } from "../utils/timelineNotifications"; + +interface AudioFilePickerResult { + success: boolean; + path?: string; +} + +interface TimelineAudioActionsDeps { + openFilePicker: () => Promise<AudioFilePickerResult | null | undefined>; + probeAudioDurationMs: (audioPath: string) => Promise<number>; + reportError: (title: string, description: string) => void; +} + +interface UseTimelineAudioActionsParams { + timeline: { + videoDuration: number; + totalMs: number; + currentTimeMs: number; + }; + regions: { + audio: TimelineAudioRegion[]; + }; + onAudioAdded?: ( + span: { start: number; end: number }, + audioPath: string, + trackIndex?: number, + ) => void; + deps?: Partial<TimelineAudioActionsDeps>; +} + +async function defaultOpenFilePicker(): Promise<AudioFilePickerResult | null | undefined> { + return window.electronAPI.openAudioFilePicker(); +} + +async function defaultProbeAudioDurationMs(audioPath: string): Promise<number> { + const resolved = await resolveMediaElementSource(audioPath); + return new Promise<number>((resolve) => { + const audio = new Audio(); + const cleanup = () => { + audio.removeAttribute("src"); + audio.load(); + resolved.revoke(); + }; + + audio.addEventListener( + "loadedmetadata", + () => { + resolve(Math.round(audio.duration * 1000)); + cleanup(); + }, + { once: true }, + ); + audio.addEventListener( + "error", + () => { + resolve(0); + cleanup(); + }, + { once: true }, + ); + audio.src = resolved.src; + }); +} + +function buildTimelineAudioActionsDeps( + overrides?: Partial<TimelineAudioActionsDeps>, +): TimelineAudioActionsDeps { + return { + openFilePicker: overrides?.openFilePicker ?? defaultOpenFilePicker, + probeAudioDurationMs: overrides?.probeAudioDurationMs ?? defaultProbeAudioDurationMs, + reportError: overrides?.reportError ?? timelineNotifications.error, + }; +} + +export function useTimelineAudioActions({ + timeline, + regions, + onAudioAdded, + deps: depsOverrides, +}: UseTimelineAudioActionsParams) { + const { videoDuration, totalMs, currentTimeMs } = timeline; + const { audio: audioRegions } = regions; + const deps = useMemo(() => buildTimelineAudioActionsDeps(depsOverrides), [depsOverrides]); + + const handleAddAudio = useCallback( + async (preferredTrackIndex?: number) => { + if (!videoDuration || videoDuration === 0 || totalMs === 0 || !onAudioAdded) { + return; + } + + const result = await deps.openFilePicker(); + if (!result?.success || !result.path) { + return; + } + + const audioPath = result.path; + const audioDurationMs = await deps.probeAudioDurationMs(audioPath); + if (audioDurationMs <= 0) { + deps.reportError( + "Could not read audio file", + "The selected file may be corrupted or in an unsupported format.", + ); + return; + } + + const startPos = Math.max(0, Math.min(currentTimeMs, totalMs)); + if (totalMs - startPos <= 0) { + deps.reportError( + "Cannot place audio here", + "There is no remaining space at the current playhead position.", + ); + return; + } + + const placement = resolveAudioPlacement({ + audioRegions, + startPos, + totalMs, + audioDurationMs, + preferredTrackIndex, + }); + if (!placement) { + deps.reportError( + "Cannot place audio here", + "Audio region already exists at this location or not enough space available.", + ); + return; + } + + onAudioAdded( + { start: startPos, end: startPos + placement.durationMs }, + audioPath, + placement.trackIndex, + ); + }, + [videoDuration, totalMs, onAudioAdded, deps, currentTimeMs, audioRegions], + ); + + return { handleAddAudio }; +} diff --git a/src/components/video-editor/timeline/hooks/actions/useTimelineCaptionActions.ts b/src/components/video-editor/timeline/hooks/actions/useTimelineCaptionActions.ts new file mode 100644 index 000000000..c0d19fe25 --- /dev/null +++ b/src/components/video-editor/timeline/hooks/actions/useTimelineCaptionActions.ts @@ -0,0 +1,90 @@ +import type { Span } from "dnd-timeline"; +import { useCallback } from "react"; +import type { CaptionCue } from "../../../types"; +import { timelineNotifications } from "../utils/timelineNotifications"; + +// Default length of a caption added manually from the timeline. Shared with the +// hover ghost so the preview width matches what actually gets created. +export const DEFAULT_CAPTION_DURATION_MS = 1500; + +interface UseTimelineCaptionActionsParams { + totalMs: number; + // Caption regions in timeline-ms (matches the hover position passed in). + captionRegions: CaptionCue[]; + onCaptionAdded?: (span: Span) => void; +} + +export function useTimelineCaptionActions({ + totalMs, + captionRegions, + onCaptionAdded, +}: UseTimelineCaptionActionsParams) { + const canPlaceCaptionAtMs = useCallback( + (startMs: number) => { + if (totalMs === 0) { + return false; + } + const startPos = Math.max(0, Math.min(startMs, totalMs)); + // Only block when the cursor sits inside an existing caption — tight + // gaps are fine because the new caption is clamped to fit (see below). + return !captionRegions.some((cue) => startPos >= cue.startMs && startPos < cue.endMs); + }, + [totalMs, captionRegions], + ); + + // Resolve the exact span an add would create at `startMs`, clamped to the next caption + // and the end of the timeline. Shared with the hover ghost so the preview matches the + // inserted caption exactly. Returns null when no caption can be placed there. + const resolveCaptionSpanAtMs = useCallback( + (startMs: number): Span | null => { + if (totalMs === 0) { + return null; + } + const startPos = Math.max(0, Math.min(startMs, totalMs)); + if (!canPlaceCaptionAtMs(startPos)) { + return null; + } + const nextCaptionStartMs = captionRegions + .filter((cue) => cue.startMs > startPos) + .reduce((min, cue) => Math.min(min, cue.startMs), totalMs); + const endPos = Math.min( + startPos + DEFAULT_CAPTION_DURATION_MS, + totalMs, + nextCaptionStartMs, + ); + if (endPos <= startPos) { + return null; + } + return { start: startPos, end: endPos }; + }, + [totalMs, canPlaceCaptionAtMs, captionRegions], + ); + + const addCaptionAtMs = useCallback( + (startMs: number) => { + if (!onCaptionAdded || totalMs === 0) { + return; + } + const startPos = Math.max(0, Math.min(startMs, totalMs)); + if (!canPlaceCaptionAtMs(startPos)) { + timelineNotifications.error( + "Cannot place caption here", + "A caption already exists at this position.", + ); + return; + } + const span = resolveCaptionSpanAtMs(startPos); + if (!span) { + return; + } + onCaptionAdded(span); + }, + [onCaptionAdded, totalMs, canPlaceCaptionAtMs, resolveCaptionSpanAtMs], + ); + + return { + canPlaceCaptionAtMs, + addCaptionAtMs, + resolveCaptionSpanAtMs, + }; +} diff --git a/src/components/video-editor/timeline/hooks/actions/useTimelineZoomActions.ts b/src/components/video-editor/timeline/hooks/actions/useTimelineZoomActions.ts new file mode 100644 index 000000000..4f85751e3 --- /dev/null +++ b/src/components/video-editor/timeline/hooks/actions/useTimelineZoomActions.ts @@ -0,0 +1,206 @@ +import type { Span } from "dnd-timeline"; +import { useCallback, useEffect, useMemo } from "react"; +import type { CursorTelemetryPoint, ZoomFocus, ZoomRegion } from "../../../types"; +import { buildInteractionZoomSuggestions } from "../../zoomSuggestionUtils"; +import { timelineNotifications } from "../utils/timelineNotifications"; + +interface UseTimelineZoomActionsParams { + timeline: { + videoDuration: number; + totalMs: number; + currentTimeMs: number; + }; + regions: { + zoom: ZoomRegion[]; + clip: { startMs: number; endMs: number }[]; + }; + cursorTelemetry: CursorTelemetryPoint[]; + options: { + disableSuggestedZooms: boolean; + }; + autoSuggestZoomsTrigger: number; + onAutoSuggestZoomsConsumed?: () => void; + onZoomAdded: (span: Span) => void; + onZoomSuggested?: (span: Span, focus: ZoomFocus) => void; +} + +export function useTimelineZoomActions({ + timeline, + regions, + cursorTelemetry, + options, + autoSuggestZoomsTrigger, + onAutoSuggestZoomsConsumed, + onZoomAdded, + onZoomSuggested, +}: UseTimelineZoomActionsParams) { + const { videoDuration, totalMs, currentTimeMs } = timeline; + const { zoom: zoomRegions, clip: clipRegions } = regions; + const { disableSuggestedZooms } = options; + const defaultRegionDurationMs = useMemo(() => Math.min(1000, totalMs), [totalMs]); + + const canPlaceZoomAtMs = useCallback( + (startMs: number) => { + if (!videoDuration || videoDuration === 0 || totalMs === 0) { + return false; + } + + const defaultDuration = Math.min(defaultRegionDurationMs, totalMs); + if (defaultDuration <= 0) { + return false; + } + + const startPos = Math.max(0, Math.min(startMs, totalMs)); + const activeClip = + clipRegions.length === 0 + ? { startMs: 0, endMs: totalMs } + : clipRegions.find((clip) => startPos >= clip.startMs && startPos < clip.endMs); + if (!activeClip) { + return false; + } + + const sorted = [...zoomRegions].sort((a, b) => a.startMs - b.startMs); + const nextRegion = sorted.find((region) => region.startMs > startPos); + const gapToNextClipEdge = activeClip.endMs - startPos; + const gapToNextRegion = nextRegion ? nextRegion.startMs - startPos : gapToNextClipEdge; + const availableDuration = Math.min(gapToNextClipEdge, gapToNextRegion); + + const isOverlapping = sorted.some( + (region) => startPos >= region.startMs && startPos < region.endMs, + ); + + return !isOverlapping && availableDuration >= defaultDuration; + }, + [videoDuration, totalMs, defaultRegionDurationMs, clipRegions, zoomRegions], + ); + + const addZoomAtMs = useCallback( + (startMs: number) => { + if (!videoDuration || videoDuration === 0 || totalMs === 0) { + return; + } + + const defaultDuration = Math.min(defaultRegionDurationMs, totalMs); + if (defaultDuration <= 0) { + return; + } + + const startPos = Math.max(0, Math.min(startMs, totalMs)); + if (!canPlaceZoomAtMs(startPos)) { + timelineNotifications.error( + "Cannot place zoom here", + "Zoom already exists here or there is not enough room before the next zoom or clip end.", + ); + return; + } + + onZoomAdded({ start: startPos, end: startPos + defaultDuration }); + }, + [videoDuration, totalMs, defaultRegionDurationMs, canPlaceZoomAtMs, onZoomAdded], + ); + + const handleAddZoom = useCallback(() => { + if (!videoDuration || videoDuration === 0 || totalMs === 0) { + return; + } + + addZoomAtMs(currentTimeMs); + }, [videoDuration, totalMs, currentTimeMs, addZoomAtMs]); + + const handleSuggestZooms = useCallback(() => { + if (!videoDuration || videoDuration === 0 || totalMs === 0) { + return; + } + + if (disableSuggestedZooms) { + timelineNotifications.info( + "Suggested zooms are unavailable while cursor looping is enabled.", + ); + return; + } + + if (!onZoomSuggested) { + timelineNotifications.error("Zoom suggestion handler unavailable"); + return; + } + + if (cursorTelemetry.length < 2) { + timelineNotifications.info( + "No cursor telemetry available", + "Record a screencast first to generate cursor-based suggestions.", + ); + return; + } + + const defaultDuration = Math.min(defaultRegionDurationMs, totalMs); + if (defaultDuration <= 0) { + return; + } + + const result = buildInteractionZoomSuggestions({ + cursorTelemetry, + totalMs, + defaultDurationMs: defaultDuration, + reservedSpans: zoomRegions + .map((region) => ({ start: region.startMs, end: region.endMs })) + .sort((a, b) => a.start - b.start), + }); + + if (result.status === "no-telemetry") { + timelineNotifications.info( + "No usable cursor telemetry", + "The recording does not include enough cursor movement data.", + ); + return; + } + + if (result.status === "no-interactions") { + timelineNotifications.info( + "No clear interaction moments found", + "Try a recording with pauses or clicks around important actions.", + ); + return; + } + + if (result.status === "no-slots" || result.suggestions.length === 0) { + timelineNotifications.info( + "No auto-zoom slots available", + "Detected dwell points overlap existing zoom regions.", + ); + return; + } + + for (const region of result.suggestions) { + onZoomSuggested({ start: region.start, end: region.end }, region.focus); + } + + timelineNotifications.success( + `Added ${result.suggestions.length} interaction-based zoom suggestion${result.suggestions.length === 1 ? "" : "s"}`, + ); + }, [ + videoDuration, + totalMs, + disableSuggestedZooms, + onZoomSuggested, + cursorTelemetry, + defaultRegionDurationMs, + zoomRegions, + ]); + + useEffect(() => { + if (autoSuggestZoomsTrigger <= 0) { + return; + } + + onAutoSuggestZoomsConsumed?.(); + handleSuggestZooms(); + }, [autoSuggestZoomsTrigger, handleSuggestZooms, onAutoSuggestZoomsConsumed]); + + return { + defaultRegionDurationMs, + canPlaceZoomAtMs, + addZoomAtMs, + handleAddZoom, + handleSuggestZooms, + }; +} diff --git a/src/components/video-editor/timeline/hooks/useTimelineAudioPeaks.ts b/src/components/video-editor/timeline/hooks/useTimelineAudioPeaks.ts new file mode 100644 index 000000000..4d56cf09f --- /dev/null +++ b/src/components/video-editor/timeline/hooks/useTimelineAudioPeaks.ts @@ -0,0 +1,145 @@ +import { useEffect, useRef, useState } from "react"; +import { getVersionedAudioResourceUrl } from "@/components/video-editor/audio/audioResourceVersion"; +import { resolveMediaResourceUrl } from "@/lib/exporter/localMediaSource"; +import { waveformGenerator } from "../../audio/waveform/WaveformGenerator"; +import { fromFileUrl } from "../../projectPersistence"; +import { WAVEFORM_DEFAULT_PEAK_COUNT } from "../core/constants"; +import type { AudioPeaksData } from "../core/timelineTypes"; + +const EMPTY_FALLBACK_RESOURCES: string[] = []; + +function buildSidecarAudioCandidates(sourcePath: string): string[] { + const normalized = sourcePath.replace(/\\/g, "/"); + const lastSlash = normalized.lastIndexOf("/"); + const dir = lastSlash >= 0 ? normalized.slice(0, lastSlash + 1) : ""; + const fileName = lastSlash >= 0 ? normalized.slice(lastSlash + 1) : normalized; + const dotIndex = fileName.lastIndexOf("."); + const baseName = dotIndex > 0 ? fileName.slice(0, dotIndex) : fileName; + + return [ + `${dir}${baseName}.system.wav`, + `${dir}${baseName}.mic.wav`, + `${dir}${baseName}.system.m4a`, + `${dir}${baseName}.mic.m4a`, + `${dir}${baseName}.system.webm`, + `${dir}${baseName}.mic.webm`, + ]; +} + +function extractLocalPathFromMediaServerUrl(input: string): string | null { + try { + const url = new URL(input); + const isLocalMediaServer = + (url.protocol === "http:" || url.protocol === "https:") && + (url.hostname === "127.0.0.1" || url.hostname === "localhost") && + url.pathname === "/video"; + if (!isLocalMediaServer) return null; + return url.searchParams.get("path"); + } catch { + return null; + } +} + +interface TimelineAudioPeaksOptions { + enableSourceSidecarFallback?: boolean; + fallbackResources?: string[]; + peakCount?: number; + resourceVersion?: number; +} + +export interface TimelineAudioPeaksResult { + peaks: AudioPeaksData | null; + loading: boolean; +} + +export function useTimelineAudioPeaks( + mediaResource: string | null | undefined, + options: TimelineAudioPeaksOptions = {}, +): TimelineAudioPeaksResult { + const [peaks, setPeaks] = useState<AudioPeaksData | null>(null); + const [loading, setLoading] = useState(false); + const sourceRef = useRef(mediaResource); + const enableSourceSidecarFallback = options.enableSourceSidecarFallback ?? false; + const fallbackResources = options.fallbackResources ?? EMPTY_FALLBACK_RESOURCES; + const peakCount = options.peakCount ?? WAVEFORM_DEFAULT_PEAK_COUNT; + const resourceVersion = options.resourceVersion ?? 0; + + useEffect(() => { + sourceRef.current = mediaResource; + setPeaks(null); + if (!mediaResource) { + setLoading(false); + return; + } + + setLoading(true); + let cancelled = false; + + const run = async () => { + const tryGenerate = async (resource: string): Promise<AudioPeaksData> => { + const resolvedUrl = await resolveMediaResourceUrl(resource); + const versionedUrl = getVersionedAudioResourceUrl(resolvedUrl, resourceVersion); + return waveformGenerator.generate(versionedUrl, peakCount, resourceVersion); + }; + + try { + const result = await tryGenerate(mediaResource); + if (!cancelled && sourceRef.current === mediaResource) { + setPeaks(result); + setLoading(false); + } + return; + } catch { + // fallthrough + } + + if (!enableSourceSidecarFallback && fallbackResources.length === 0) { + if (!cancelled && sourceRef.current === mediaResource) { + setLoading(false); + } + return; + } + + let sourceSidecarCandidates: string[] = []; + if (enableSourceSidecarFallback) { + const localPathFromServer = extractLocalPathFromMediaServerUrl(mediaResource); + const localSourcePath = + localPathFromServer || + (/^file:\/\//i.test(mediaResource) + ? fromFileUrl(mediaResource) + : mediaResource); + if (localSourcePath) { + sourceSidecarCandidates = buildSidecarAudioCandidates(localSourcePath); + } + } + + const candidates = Array.from( + new Set([...fallbackResources, ...sourceSidecarCandidates]), + ); + for (const candidate of candidates) { + try { + const result = await tryGenerate(candidate); + if (!cancelled && sourceRef.current === mediaResource) { + setPeaks(result); + setLoading(false); + } + return; + } catch { + // try next + } + } + + if (!cancelled && sourceRef.current === mediaResource) { + setLoading(false); + } + }; + + void run(); + + return () => { + cancelled = true; + }; + }, [mediaResource, enableSourceSidecarFallback, fallbackResources, peakCount, resourceVersion]); + + return { peaks, loading }; +} diff --git a/src/components/video-editor/timeline/hooks/useTimelineDndBindings.ts b/src/components/video-editor/timeline/hooks/useTimelineDndBindings.ts new file mode 100644 index 000000000..4e0af7692 --- /dev/null +++ b/src/components/video-editor/timeline/hooks/useTimelineDndBindings.ts @@ -0,0 +1,211 @@ +import type { Span } from "dnd-timeline"; +import { useCallback, useMemo } from "react"; +import type { + AnnotationRegion, + AudioRegion, + CaptionCue, + ClipRegion, + SpeedRegion, + TrimRegion, + ZoomRegion, +} from "../../types"; +import { + getAnnotationTrackIndex, + getAudioTrackIndex, + isAnnotationTrackRowId, + isAudioTrackRowId, +} from "../core/rows"; +import { spansOverlap } from "../core/spans"; +import type { TimelineRenderItem } from "../core/timelineTypes"; +import { buildAllRegionSpans, buildTimelineItems, resolveDropRowId } from "../model/timelineModel"; + +interface UseTimelineDndBindingsParams { + zoomRegions: ZoomRegion[]; + trimRegions: TrimRegion[]; + clipRegions: ClipRegion[]; + annotationRegions: AnnotationRegion[]; + speedRegions: SpeedRegion[]; + audioRegions: AudioRegion[]; + captionCues: CaptionCue[]; + onZoomSpanChange: (id: string, span: Span) => void; + onTrimSpanChange?: (id: string, span: Span) => void; + onClipSpanChange?: (id: string, span: Span) => void; + onAnnotationSpanChange?: (id: string, span: Span, trackIndex?: number) => void; + onSpeedSpanChange?: (id: string, span: Span) => void; + onAudioSpanChange?: (id: string, span: Span, trackIndex?: number) => void; + onCaptionSpanChange?: (id: string, span: Span) => void; +} + +type TimelineItemKind = + | "zoom" + | "trim" + | "clip" + | "annotation" + | "speed" + | "audio" + | "caption" + | null; + +export function useTimelineDndBindings({ + zoomRegions, + trimRegions, + clipRegions, + annotationRegions, + speedRegions, + audioRegions, + captionCues, + onZoomSpanChange, + onTrimSpanChange, + onClipSpanChange, + onAnnotationSpanChange, + onSpeedSpanChange, + onAudioSpanChange, + onCaptionSpanChange, +}: UseTimelineDndBindingsParams) { + const resolveItemKind = useCallback( + (id: string): TimelineItemKind => { + if (zoomRegions.some((r) => r.id === id)) return "zoom"; + if (trimRegions.some((r) => r.id === id)) return "trim"; + if (clipRegions.some((r) => r.id === id)) return "clip"; + if (annotationRegions.some((r) => r.id === id)) return "annotation"; + if (speedRegions.some((r) => r.id === id)) return "speed"; + if (audioRegions.some((r) => r.id === id)) return "audio"; + if (captionCues.some((c) => c.id === id)) return "caption"; + return null; + }, + [ + zoomRegions, + trimRegions, + clipRegions, + annotationRegions, + speedRegions, + audioRegions, + captionCues, + ], + ); + + const resolveTrackIndex = useCallback( + (kind: "annotation" | "audio", id: string, rowId?: string): number => { + if (kind === "annotation") { + return rowId && isAnnotationTrackRowId(rowId) + ? getAnnotationTrackIndex(rowId) + : (annotationRegions.find((region) => region.id === id)?.trackIndex ?? 0); + } + return rowId && isAudioTrackRowId(rowId) + ? getAudioTrackIndex(rowId) + : (audioRegions.find((region) => region.id === id)?.trackIndex ?? 0); + }, + [annotationRegions, audioRegions], + ); + + const hasOverlap = useCallback( + (newSpan: Span, excludeId?: string, rowId?: string): boolean => { + if (!excludeId) return false; + const itemKind = resolveItemKind(excludeId); + + if (itemKind === "annotation") return false; + + const checkOverlap = (regions: { id: string; startMs: number; endMs: number }[]) => + regions.some((region) => { + if (region.id === excludeId) return false; + return spansOverlap(newSpan, { start: region.startMs, end: region.endMs }); + }); + + if (itemKind === "zoom") return checkOverlap(zoomRegions); + if (itemKind === "trim") return checkOverlap(trimRegions); + if (itemKind === "clip") return checkOverlap(clipRegions); + if (itemKind === "speed") return checkOverlap(speedRegions); + // Captions share a single lane and must never overlap, so validate a dragged or + // resized caption against the other cues just like the other timeline items. + if (itemKind === "caption") return checkOverlap(captionCues); + + if (itemKind === "audio") { + const activeTrackIndex = resolveTrackIndex("audio", excludeId, rowId); + return checkOverlap( + audioRegions.filter((region) => (region.trackIndex ?? 0) === activeTrackIndex), + ); + } + + return false; + }, + [ + resolveItemKind, + resolveTrackIndex, + zoomRegions, + trimRegions, + clipRegions, + audioRegions, + speedRegions, + captionCues, + ], + ); + + const timelineItems = useMemo<TimelineRenderItem[]>( + () => + buildTimelineItems({ + zoomRegions, + clipRegions, + annotationRegions, + audioRegions, + captionCues, + }), + [zoomRegions, clipRegions, annotationRegions, audioRegions, captionCues], + ); + + const allRegionSpans = useMemo( + () => + buildAllRegionSpans({ + zoomRegions, + clipRegions, + audioRegions, + }), + [zoomRegions, clipRegions, audioRegions], + ); + + const getResolvedDropRowId = useCallback( + (id: string, proposedRowId: string) => resolveDropRowId(id, proposedRowId, timelineItems), + [timelineItems], + ); + + const handleItemSpanChange = useCallback( + (id: string, span: Span, rowId?: string) => { + const itemKind = resolveItemKind(id); + if (itemKind === "zoom") { + onZoomSpanChange(id, span); + } else if (itemKind === "trim") { + onTrimSpanChange?.(id, span); + } else if (itemKind === "clip") { + onClipSpanChange?.(id, span); + } else if (itemKind === "annotation") { + const nextTrackIndex = resolveTrackIndex("annotation", id, rowId); + onAnnotationSpanChange?.(id, span, nextTrackIndex); + } else if (itemKind === "speed") { + onSpeedSpanChange?.(id, span); + } else if (itemKind === "audio") { + const nextTrackIndex = resolveTrackIndex("audio", id, rowId); + onAudioSpanChange?.(id, span, nextTrackIndex); + } else if (itemKind === "caption") { + onCaptionSpanChange?.(id, span); + } + }, + [ + resolveItemKind, + resolveTrackIndex, + onZoomSpanChange, + onTrimSpanChange, + onClipSpanChange, + onAnnotationSpanChange, + onSpeedSpanChange, + onAudioSpanChange, + onCaptionSpanChange, + ], + ); + + return { + hasOverlap, + timelineItems, + allRegionSpans, + getResolvedDropRowId, + handleItemSpanChange, + }; +} diff --git a/src/components/video-editor/timeline/hooks/useTimelineEditorRuntime.ts b/src/components/video-editor/timeline/hooks/useTimelineEditorRuntime.ts new file mode 100644 index 000000000..9f7092db0 --- /dev/null +++ b/src/components/video-editor/timeline/hooks/useTimelineEditorRuntime.ts @@ -0,0 +1,339 @@ +import type { Span } from "dnd-timeline"; +import type { ForwardedRef, RefObject } from "react"; +import { useCallback, useImperativeHandle } from "react"; +import type { + AnnotationRegion, + AudioRegion, + CaptionCue, + ClipRegion, + CursorTelemetryPoint, + SpeedRegion, + TrimRegion, + ZoomFocus, + ZoomRegion, +} from "../../types"; +import type { TimelineShortcutBindings } from "../core/timelineTypes"; +import type { TimelineEditorHandle } from "../TimelineEditor"; +import { useTimelineAudioActions } from "./actions/useTimelineAudioActions"; +import { useTimelineCaptionActions } from "./actions/useTimelineCaptionActions"; +import { useTimelineZoomActions } from "./actions/useTimelineZoomActions"; +import { useTimelineDndBindings } from "./useTimelineDndBindings"; +import { useTimelineKeyboardShortcuts } from "./useTimelineKeyboardShortcuts"; +import { useTimelineNormalization } from "./useTimelineNormalization"; +import { useTimelineSelection } from "./useTimelineSelection"; + +interface UseTimelineEditorRuntimeParams { + ref: ForwardedRef<TimelineEditorHandle>; + videoDuration: number; + totalMs: number; + currentTimeMs: number; + safeMinDurationMs: number; + cursorTelemetry: CursorTelemetryPoint[]; + autoSuggestZoomsTrigger: number; + onAutoSuggestZoomsConsumed?: () => void; + disableSuggestedZooms: boolean; + zoomRegions: ZoomRegion[]; + onZoomAdded: (span: Span) => void; + onZoomSuggested?: (span: Span, focus: ZoomFocus) => void; + onZoomSpanChange: (id: string, span: Span) => void; + onZoomDelete: (id: string) => void; + selectedZoomId: string | null; + onSelectZoom: (id: string | null) => void; + trimRegions: TrimRegion[]; + onTrimSpanChange?: (id: string, span: Span) => void; + clipRegions: ClipRegion[]; + onClipSplit?: (splitMs: number) => void; + onClipSpanChange?: (id: string, span: Span) => void; + onClipDelete?: (id: string) => void; + selectedClipId?: string | null; + onSelectClip?: (id: string | null) => void; + annotationRegions: AnnotationRegion[]; + onAnnotationAdded?: (span: Span, trackIndex?: number) => void; + onAnnotationSpanChange?: (id: string, span: Span, trackIndex?: number) => void; + onAnnotationDelete?: (id: string) => void; + selectedAnnotationId?: string | null; + onSelectAnnotation?: (id: string | null) => void; + speedRegions: SpeedRegion[]; + onSpeedSpanChange?: (id: string, span: Span) => void; + audioRegions: AudioRegion[]; + onAudioAdded?: (span: Span, audioPath: string, trackIndex?: number) => void; + onAudioSpanChange?: (id: string, span: Span, trackIndex?: number) => void; + onAudioDelete?: (id: string) => void; + selectedAudioId?: string | null; + onSelectAudio?: (id: string | null) => void; + captionCues: CaptionCue[]; + onCaptionSpanChange?: (id: string, span: Span) => void; + onCaptionDelete?: (id: string) => void; + onCaptionAdded?: (span: Span) => void; + selectedCaptionId?: string | null; + onSelectCaption?: (id: string | null) => void; + isMac: boolean; + keyShortcuts: TimelineShortcutBindings; + isTimelineFocusedRef: RefObject<boolean>; +} + +export function useTimelineEditorRuntime({ + ref, + videoDuration, + totalMs, + currentTimeMs, + safeMinDurationMs, + cursorTelemetry, + autoSuggestZoomsTrigger, + onAutoSuggestZoomsConsumed, + disableSuggestedZooms, + zoomRegions, + onZoomAdded, + onZoomSuggested, + onZoomSpanChange, + onZoomDelete, + selectedZoomId, + onSelectZoom, + trimRegions, + onTrimSpanChange, + clipRegions, + onClipSplit, + onClipSpanChange, + onClipDelete, + selectedClipId, + onSelectClip, + annotationRegions, + onAnnotationAdded, + onAnnotationSpanChange, + onAnnotationDelete, + selectedAnnotationId, + onSelectAnnotation, + speedRegions, + onSpeedSpanChange, + audioRegions, + onAudioAdded, + onAudioSpanChange, + onAudioDelete, + selectedAudioId, + onSelectAudio, + captionCues, + onCaptionSpanChange, + onCaptionDelete, + onCaptionAdded, + selectedCaptionId, + onSelectCaption, + isMac, + keyShortcuts, + isTimelineFocusedRef, +}: UseTimelineEditorRuntimeParams) { + const { + keyframes, + selectedKeyframeId, + setSelectedKeyframeId, + selectAllBlocksActive, + setSelectAllBlocksActive, + hasAnyZoomBlocks, + activateSelectAllZooms, + addKeyframe, + deleteSelectedKeyframe, + handleKeyframeMove, + deleteSelectedZoom, + deleteSelectedClip, + deleteSelectedAnnotation, + deleteSelectedAudio, + deleteSelectedCaption, + clearSelectedBlocks, + handleSelectZoom, + handleSelectClip, + handleSelectAnnotation, + handleSelectAudio, + handleSelectCaption, + cycleAnnotationsAtCurrentTime, + } = useTimelineSelection({ + totalMs, + currentTimeMs, + zoomRegions, + clipRegions, + annotationRegions, + audioRegions, + selectedZoomId, + selectedClipId, + selectedAnnotationId, + selectedAudioId, + selectedCaptionId, + onZoomDelete, + onClipDelete, + onAnnotationDelete, + onAudioDelete, + onCaptionDelete, + onSelectZoom, + onSelectClip, + onSelectAnnotation, + onSelectAudio, + onSelectCaption, + }); + + useTimelineNormalization({ + totalMs, + safeMinDurationMs, + zoomRegions, + trimRegions, + speedRegions, + audioRegions, + onZoomSpanChange, + onTrimSpanChange, + onSpeedSpanChange, + onAudioSpanChange, + }); + + const { + hasOverlap, + timelineItems, + allRegionSpans, + getResolvedDropRowId, + handleItemSpanChange, + } = useTimelineDndBindings({ + zoomRegions, + trimRegions, + clipRegions, + annotationRegions, + speedRegions, + audioRegions, + captionCues, + onZoomSpanChange, + onTrimSpanChange, + onClipSpanChange, + onAnnotationSpanChange, + onSpeedSpanChange, + onAudioSpanChange, + onCaptionSpanChange, + }); + + const { + defaultRegionDurationMs, + canPlaceZoomAtMs, + addZoomAtMs, + handleAddZoom, + handleSuggestZooms, + } = useTimelineZoomActions({ + timeline: { videoDuration, totalMs, currentTimeMs }, + regions: { zoom: zoomRegions, clip: clipRegions }, + cursorTelemetry, + options: { disableSuggestedZooms }, + autoSuggestZoomsTrigger, + onAutoSuggestZoomsConsumed, + onZoomAdded, + onZoomSuggested, + }); + + const { canPlaceCaptionAtMs, addCaptionAtMs, resolveCaptionSpanAtMs } = + useTimelineCaptionActions({ + totalMs, + captionRegions: captionCues, + onCaptionAdded, + }); + + const handleSplitClip = useCallback(() => { + if (!videoDuration || videoDuration === 0 || totalMs === 0 || !onClipSplit) { + return; + } + onClipSplit(currentTimeMs); + }, [videoDuration, totalMs, currentTimeMs, onClipSplit]); + + const { handleAddAudio } = useTimelineAudioActions({ + timeline: { videoDuration, totalMs, currentTimeMs }, + regions: { audio: audioRegions }, + onAudioAdded, + }); + + const handleAddAnnotation = useCallback( + (trackIndex = 0) => { + if (!videoDuration || videoDuration === 0 || totalMs === 0 || !onAnnotationAdded) { + return; + } + + const defaultDuration = Math.min(defaultRegionDurationMs, totalMs); + if (defaultDuration <= 0) { + return; + } + + const latestStartPos = Math.max(0, totalMs - defaultDuration); + const startPos = Math.max(0, Math.min(currentTimeMs, latestStartPos)); + const endPos = Math.min(startPos + defaultDuration, totalMs); + onAnnotationAdded({ start: startPos, end: endPos }, trackIndex); + }, + [videoDuration, totalMs, currentTimeMs, defaultRegionDurationMs, onAnnotationAdded], + ); + + useTimelineKeyboardShortcuts({ + isMac, + keyShortcuts, + isTimelineFocusedRef, + hasAnyZoomBlocks, + activateSelectAllZooms, + annotationCount: annotationRegions.length, + selectedKeyframeId, + selectedZoomId, + selectedClipId, + selectedAnnotationId, + selectedAudioId, + selectedCaptionId, + selectAllBlocksActive, + addKeyframe, + handleAddZoom, + handleSplitClip, + handleAddAnnotation: () => handleAddAnnotation(), + deleteSelectedKeyframe, + deleteSelectedZoom, + deleteSelectedClip, + deleteSelectedAnnotation, + deleteSelectedAudio, + deleteSelectedCaption, + cycleAnnotationsAtCurrentTime, + }); + + useImperativeHandle( + ref, + () => ({ + addZoom: handleAddZoom, + suggestZooms: handleSuggestZooms, + splitClip: handleSplitClip, + addAnnotation: handleAddAnnotation, + addAudio: handleAddAudio, + keyframes, + }), + [ + handleAddAnnotation, + handleAddAudio, + handleAddZoom, + handleSuggestZooms, + handleSplitClip, + keyframes, + ], + ); + + return { + keyframes, + selectedKeyframeId, + setSelectedKeyframeId, + selectAllBlocksActive, + setSelectAllBlocksActive, + handleKeyframeMove, + clearSelectedBlocks, + handleSelectZoom, + handleSelectClip, + handleSelectAnnotation, + handleSelectAudio, + handleSelectCaption, + hasOverlap, + timelineItems, + allRegionSpans, + getResolvedDropRowId, + handleItemSpanChange, + canPlaceZoomAtMs, + addZoomAtMs, + canPlaceCaptionAtMs, + addCaptionAtMs, + resolveCaptionSpanAtMs, + handleAddZoom, + handleSuggestZooms, + handleSplitClip, + handleAddAudio, + handleAddAnnotation, + }; +} diff --git a/src/components/video-editor/timeline/hooks/useTimelineKeyboardShortcuts.ts b/src/components/video-editor/timeline/hooks/useTimelineKeyboardShortcuts.ts new file mode 100644 index 000000000..94cb39735 --- /dev/null +++ b/src/components/video-editor/timeline/hooks/useTimelineKeyboardShortcuts.ts @@ -0,0 +1,158 @@ +import { type RefObject, useEffect } from "react"; +import { matchesShortcut } from "@/lib/shortcuts"; +import type { TimelineShortcutBindings } from "../core/timelineTypes"; +import { resolveDeleteSelectionTarget } from "./utils/timelineSelectionUtils"; + +interface UseTimelineKeyboardShortcutsParams { + isMac: boolean; + keyShortcuts: TimelineShortcutBindings; + isTimelineFocusedRef: RefObject<boolean>; + hasAnyZoomBlocks: boolean; + activateSelectAllZooms: () => void; + annotationCount: number; + selectedKeyframeId: string | null; + selectedZoomId: string | null; + selectedClipId?: string | null; + selectedAnnotationId?: string | null; + selectedAudioId?: string | null; + selectedCaptionId?: string | null; + selectAllBlocksActive: boolean; + addKeyframe: () => void; + handleAddZoom: () => void; + handleSplitClip: () => void; + handleAddAnnotation: () => void; + deleteSelectedKeyframe: () => void; + deleteSelectedZoom: () => void; + deleteSelectedClip: () => void; + deleteSelectedAnnotation: () => void; + deleteSelectedAudio: () => void; + deleteSelectedCaption: () => void; + cycleAnnotationsAtCurrentTime: (backward?: boolean) => boolean; +} + +export function useTimelineKeyboardShortcuts({ + isMac, + keyShortcuts, + isTimelineFocusedRef, + hasAnyZoomBlocks, + activateSelectAllZooms, + annotationCount, + selectedKeyframeId, + selectedZoomId, + selectedClipId, + selectedAnnotationId, + selectedAudioId, + selectedCaptionId, + selectAllBlocksActive, + addKeyframe, + handleAddZoom, + handleSplitClip, + handleAddAnnotation, + deleteSelectedKeyframe, + deleteSelectedZoom, + deleteSelectedClip, + deleteSelectedAnnotation, + deleteSelectedAudio, + deleteSelectedCaption, + cycleAnnotationsAtCurrentTime, +}: UseTimelineKeyboardShortcutsParams) { + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + const eventTarget = e.target; + if ( + eventTarget instanceof HTMLInputElement || + eventTarget instanceof HTMLTextAreaElement || + eventTarget instanceof HTMLSelectElement || + (eventTarget instanceof HTMLElement && eventTarget.isContentEditable) + ) { + return; + } + + if (!isTimelineFocusedRef.current) { + return; + } + + if (matchesShortcut(e, { key: "a", ctrl: true }, isMac)) { + if (!hasAnyZoomBlocks) { + return; + } + e.preventDefault(); + activateSelectAllZooms(); + return; + } + + if (matchesShortcut(e, keyShortcuts.addKeyframe, isMac)) addKeyframe(); + if (matchesShortcut(e, keyShortcuts.addZoom, isMac)) handleAddZoom(); + if (matchesShortcut(e, keyShortcuts.splitClip, isMac)) handleSplitClip(); + if (matchesShortcut(e, keyShortcuts.addAnnotation, isMac)) { + handleAddAnnotation(); + } + + if (e.key === "Tab" && annotationCount > 0) { + if (cycleAnnotationsAtCurrentTime(e.shiftKey)) { + e.preventDefault(); + } + } + + if ( + e.key === "Delete" || + e.key === "Backspace" || + matchesShortcut(e, keyShortcuts.deleteSelected, isMac) + ) { + const target = resolveDeleteSelectionTarget({ + selectAllBlocksActive, + selectedKeyframeId, + selectedZoomId, + selectedClipId, + selectedAnnotationId, + selectedAudioId, + selectedCaptionId, + }); + if (target !== "none") { + e.preventDefault(); + } + if (target === "keyframe") { + deleteSelectedKeyframe(); + } else if (target === "zoom") { + deleteSelectedZoom(); + } else if (target === "clip") { + deleteSelectedClip(); + } else if (target === "annotation") { + deleteSelectedAnnotation(); + } else if (target === "audio") { + deleteSelectedAudio(); + } else if (target === "caption") { + deleteSelectedCaption(); + } + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [ + activateSelectAllZooms, + addKeyframe, + annotationCount, + cycleAnnotationsAtCurrentTime, + deleteSelectedAnnotation, + deleteSelectedAudio, + deleteSelectedCaption, + deleteSelectedClip, + deleteSelectedKeyframe, + deleteSelectedZoom, + handleAddAnnotation, + handleAddZoom, + handleSplitClip, + hasAnyZoomBlocks, + isMac, + isTimelineFocusedRef, + keyShortcuts, + selectAllBlocksActive, + selectedAnnotationId, + selectedAudioId, + selectedCaptionId, + selectedClipId, + selectedKeyframeId, + selectedZoomId, + ]); +} diff --git a/src/components/video-editor/timeline/hooks/useTimelineNormalization.ts b/src/components/video-editor/timeline/hooks/useTimelineNormalization.ts new file mode 100644 index 000000000..1b4fd4989 --- /dev/null +++ b/src/components/video-editor/timeline/hooks/useTimelineNormalization.ts @@ -0,0 +1,98 @@ +import { useEffect } from "react"; +import { normalizeRegionSpan } from "../core/spans"; +import type { AudioRegion, SpeedRegion, TrimRegion, ZoomRegion } from "../../types"; + +interface UseTimelineNormalizationParams { + totalMs: number; + safeMinDurationMs: number; + zoomRegions: ZoomRegion[]; + trimRegions: TrimRegion[]; + speedRegions: SpeedRegion[]; + audioRegions: AudioRegion[]; + onZoomSpanChange: (id: string, span: { start: number; end: number }) => void; + onTrimSpanChange?: (id: string, span: { start: number; end: number }) => void; + onSpeedSpanChange?: (id: string, span: { start: number; end: number }) => void; + onAudioSpanChange?: (id: string, span: { start: number; end: number }) => void; +} + +export function useTimelineNormalization({ + totalMs, + safeMinDurationMs, + zoomRegions, + trimRegions, + speedRegions, + audioRegions, + onZoomSpanChange, + onTrimSpanChange, + onSpeedSpanChange, + onAudioSpanChange, +}: UseTimelineNormalizationParams) { + useEffect(() => { + if (totalMs === 0 || safeMinDurationMs <= 0) { + return; + } + + zoomRegions.forEach((region) => { + const normalized = normalizeRegionSpan({ + startMs: region.startMs, + endMs: region.endMs, + totalMs, + minDurationMs: safeMinDurationMs, + }); + + if (normalized.start !== region.startMs || normalized.end !== region.endMs) { + onZoomSpanChange(region.id, normalized); + } + }); + + trimRegions.forEach((region) => { + const normalized = normalizeRegionSpan({ + startMs: region.startMs, + endMs: region.endMs, + totalMs, + minDurationMs: safeMinDurationMs, + }); + + if (normalized.start !== region.startMs || normalized.end !== region.endMs) { + onTrimSpanChange?.(region.id, normalized); + } + }); + + speedRegions.forEach((region) => { + const normalized = normalizeRegionSpan({ + startMs: region.startMs, + endMs: region.endMs, + totalMs, + minDurationMs: safeMinDurationMs, + }); + + if (normalized.start !== region.startMs || normalized.end !== region.endMs) { + onSpeedSpanChange?.(region.id, normalized); + } + }); + + audioRegions.forEach((region) => { + const normalized = normalizeRegionSpan({ + startMs: region.startMs, + endMs: region.endMs, + totalMs, + minDurationMs: safeMinDurationMs, + }); + + if (normalized.start !== region.startMs || normalized.end !== region.endMs) { + onAudioSpanChange?.(region.id, normalized); + } + }); + }, [ + totalMs, + safeMinDurationMs, + zoomRegions, + trimRegions, + speedRegions, + audioRegions, + onZoomSpanChange, + onTrimSpanChange, + onSpeedSpanChange, + onAudioSpanChange, + ]); +} diff --git a/src/components/video-editor/timeline/hooks/useTimelineRange.test.ts b/src/components/video-editor/timeline/hooks/useTimelineRange.test.ts new file mode 100644 index 000000000..bff6a651e --- /dev/null +++ b/src/components/video-editor/timeline/hooks/useTimelineRange.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vitest"; + +import { resolveTimelineWheelPanDeltaPx } from "./useTimelineRange"; + +describe("resolveTimelineWheelPanDeltaPx", () => { + it("uses trackpad horizontal wheel movement for timeline panning", () => { + expect( + resolveTimelineWheelPanDeltaPx({ + deltaX: 24, + deltaY: 0, + deltaMode: 0, + }), + ).toBe(24); + }); + + it("uses shifted vertical wheel movement for timeline panning", () => { + expect( + resolveTimelineWheelPanDeltaPx({ + deltaX: 0, + deltaY: 3, + deltaMode: 1, + shiftKey: true, + }), + ).toBe(48); + }); + + it("keeps ctrl wheel available for timeline zoom unless shift is also held", () => { + expect( + resolveTimelineWheelPanDeltaPx({ + deltaX: 0, + deltaY: 3, + deltaMode: 1, + ctrlKey: true, + }), + ).toBe(0); + expect( + resolveTimelineWheelPanDeltaPx({ + deltaX: 0, + deltaY: 3, + deltaMode: 1, + ctrlKey: true, + shiftKey: true, + }), + ).toBe(48); + }); + + it("uses regular wheel movement when the timeline has no vertical overflow", () => { + expect( + resolveTimelineWheelPanDeltaPx({ + deltaX: 0, + deltaY: 20, + deltaMode: 0, + canScrollVertically: false, + }), + ).toBe(20); + }); +}); diff --git a/src/components/video-editor/timeline/hooks/useTimelineRange.ts b/src/components/video-editor/timeline/hooks/useTimelineRange.ts new file mode 100644 index 000000000..c72b2fd97 --- /dev/null +++ b/src/components/video-editor/timeline/hooks/useTimelineRange.ts @@ -0,0 +1,119 @@ +import type { Range } from "dnd-timeline"; +import { useCallback, useEffect, useMemo, useState, type RefObject, type WheelEvent } from "react"; +import { createInitialRange, normalizeWheelDeltaToPixels } from "../core/time"; + +interface UseTimelineRangeParams { + totalMs: number; + timelineContainerRef: RefObject<HTMLDivElement>; +} + +export interface TimelineWheelPanDeltaInput { + deltaX: number; + deltaY: number; + deltaMode: number; + shiftKey?: boolean; + ctrlKey?: boolean; + metaKey?: boolean; + canScrollVertically?: boolean; +} + +export function resolveTimelineWheelPanDeltaPx({ + deltaX, + deltaY, + deltaMode, + shiftKey = false, + ctrlKey = false, + metaKey = false, + canScrollVertically = true, +}: TimelineWheelPanDeltaInput) { + if ((ctrlKey || metaKey) && !shiftKey) { + return 0; + } + + if (Math.abs(deltaX) > 0) { + return normalizeWheelDeltaToPixels(deltaX, deltaMode); + } + + if ((shiftKey || !canScrollVertically) && Math.abs(deltaY) > 0) { + return normalizeWheelDeltaToPixels(deltaY, deltaMode); + } + + return 0; +} + +export function useTimelineRange({ totalMs, timelineContainerRef }: UseTimelineRangeParams) { + const [range, setRange] = useState<Range>(() => createInitialRange(totalMs)); + + useEffect(() => { + setRange(createInitialRange(totalMs)); + }, [totalMs]); + + const clampedRange = useMemo<Range>(() => { + if (totalMs === 0) { + return range; + } + return { + start: Math.max(0, Math.min(range.start, totalMs)), + end: Math.min(range.end, totalMs), + }; + }, [range, totalMs]); + + const panTimelineRange = useCallback( + (deltaMs: number) => { + if (!Number.isFinite(deltaMs) || deltaMs === 0 || totalMs <= 0) { + return; + } + + setRange((previous) => { + const visibleSpan = Math.max(1, previous.end - previous.start); + const maxStart = Math.max(0, totalMs - visibleSpan); + const nextStart = Math.max(0, Math.min(previous.start + deltaMs, maxStart)); + return { start: nextStart, end: nextStart + visibleSpan }; + }); + }, + [totalMs], + ); + + const handleTimelineWheel = useCallback( + (event: WheelEvent<HTMLDivElement>) => { + if (((event.ctrlKey || event.metaKey) && !event.shiftKey) || totalMs <= 0) { + return; + } + + const container = timelineContainerRef.current; + const horizontalDeltaPx = resolveTimelineWheelPanDeltaPx({ + deltaX: event.deltaX, + deltaY: event.deltaY, + deltaMode: event.deltaMode, + shiftKey: event.shiftKey, + ctrlKey: event.ctrlKey, + metaKey: event.metaKey, + canScrollVertically: container + ? container.scrollHeight > container.clientHeight + 1 + : true, + }); + + if (horizontalDeltaPx === 0) { + return; + } + + const containerWidth = container?.clientWidth ?? 0; + const visibleRangeMs = clampedRange.end - clampedRange.start; + if (containerWidth <= 0 || visibleRangeMs <= 0) { + return; + } + + event.preventDefault(); + const deltaMs = (horizontalDeltaPx / containerWidth) * visibleRangeMs; + panTimelineRange(deltaMs); + }, + [clampedRange.end, clampedRange.start, panTimelineRange, timelineContainerRef, totalMs], + ); + + return { + range, + setRange, + clampedRange, + handleTimelineWheel, + }; +} diff --git a/src/components/video-editor/timeline/hooks/useTimelineSelection.ts b/src/components/video-editor/timeline/hooks/useTimelineSelection.ts new file mode 100644 index 000000000..41c06ade6 --- /dev/null +++ b/src/components/video-editor/timeline/hooks/useTimelineSelection.ts @@ -0,0 +1,236 @@ +import { useCallback, useMemo, useState } from "react"; +import type { TimelineRegion } from "../core/timelineTypes"; + +interface UseTimelineSelectionParams { + totalMs: number; + currentTimeMs: number; + zoomRegions: TimelineRegion[]; + clipRegions: TimelineRegion[]; + annotationRegions: (TimelineRegion & { zIndex: number })[]; + audioRegions: TimelineRegion[]; + selectedZoomId: string | null; + selectedClipId?: string | null; + selectedAnnotationId?: string | null; + selectedAudioId?: string | null; + selectedCaptionId?: string | null; + onZoomDelete: (id: string) => void; + onClipDelete?: (id: string) => void; + onAnnotationDelete?: (id: string) => void; + onAudioDelete?: (id: string) => void; + onCaptionDelete?: (id: string) => void; + onSelectZoom: (id: string | null) => void; + onSelectClip?: (id: string | null) => void; + onSelectAnnotation?: (id: string | null) => void; + onSelectAudio?: (id: string | null) => void; + onSelectCaption?: (id: string | null) => void; +} + +export function useTimelineSelection({ + totalMs, + currentTimeMs, + zoomRegions, + annotationRegions, + selectedZoomId, + selectedClipId, + selectedAnnotationId, + selectedAudioId, + selectedCaptionId, + onZoomDelete, + onClipDelete, + onAnnotationDelete, + onAudioDelete, + onCaptionDelete, + onSelectZoom, + onSelectClip, + onSelectAnnotation, + onSelectAudio, + onSelectCaption, +}: UseTimelineSelectionParams) { + const [keyframes, setKeyframes] = useState<{ id: string; time: number }[]>([]); + const [selectedKeyframeId, setSelectedKeyframeId] = useState<string | null>(null); + const [selectAllBlocksActive, setSelectAllBlocksActive] = useState(false); + const hasAnyZoomBlocks = useMemo(() => zoomRegions.length > 0, [zoomRegions.length]); + + const addKeyframe = useCallback(() => { + if (totalMs === 0) return; + const time = Math.max(0, Math.min(currentTimeMs, totalMs)); + if (keyframes.some((kf) => Math.abs(kf.time - time) < 1)) return; + setKeyframes((prev) => [...prev, { id: globalThis.crypto.randomUUID(), time }]); + }, [currentTimeMs, totalMs, keyframes]); + + const deleteSelectedKeyframe = useCallback(() => { + if (!selectedKeyframeId) return; + setKeyframes((prev) => prev.filter((kf) => kf.id !== selectedKeyframeId)); + setSelectedKeyframeId(null); + }, [selectedKeyframeId]); + + const handleKeyframeMove = useCallback( + (id: string, newTime: number) => { + setKeyframes((prev) => + prev.map((kf) => + kf.id === id ? { ...kf, time: Math.max(0, Math.min(newTime, totalMs)) } : kf, + ), + ); + }, + [totalMs], + ); + + const deleteSelectedZoom = useCallback(() => { + if (selectAllBlocksActive) { + zoomRegions.map((region) => region.id).forEach((id) => onZoomDelete(id)); + } else if (selectedZoomId) { + onZoomDelete(selectedZoomId); + } else { + return; + } + + onSelectZoom(null); + onSelectClip?.(null); + onSelectAnnotation?.(null); + onSelectAudio?.(null); + onSelectCaption?.(null); + setSelectAllBlocksActive(false); + }, [ + selectAllBlocksActive, + zoomRegions, + onZoomDelete, + selectedZoomId, + onSelectZoom, + onSelectClip, + onSelectAnnotation, + onSelectAudio, + onSelectCaption, + ]); + + const deleteSelectedClip = useCallback(() => { + if (!selectedClipId || !onClipDelete || !onSelectClip) return; + onClipDelete(selectedClipId); + onSelectClip(null); + }, [selectedClipId, onClipDelete, onSelectClip]); + + const deleteSelectedAnnotation = useCallback(() => { + if (!selectedAnnotationId || !onAnnotationDelete || !onSelectAnnotation) return; + onAnnotationDelete(selectedAnnotationId); + onSelectAnnotation(null); + }, [selectedAnnotationId, onAnnotationDelete, onSelectAnnotation]); + + const deleteSelectedAudio = useCallback(() => { + if (!selectedAudioId || !onAudioDelete || !onSelectAudio) return; + onAudioDelete(selectedAudioId); + onSelectAudio(null); + }, [selectedAudioId, onAudioDelete, onSelectAudio]); + + const deleteSelectedCaption = useCallback(() => { + if (!selectedCaptionId || !onCaptionDelete) return; + onCaptionDelete(selectedCaptionId); + onSelectCaption?.(null); + }, [selectedCaptionId, onCaptionDelete, onSelectCaption]); + + const clearSelectedBlocks = useCallback(() => { + onSelectZoom(null); + onSelectClip?.(null); + onSelectAnnotation?.(null); + onSelectAudio?.(null); + onSelectCaption?.(null); + setSelectAllBlocksActive(false); + }, [onSelectZoom, onSelectClip, onSelectAnnotation, onSelectAudio, onSelectCaption]); + + const activateSelectAllZooms = useCallback(() => { + onSelectZoom(null); + onSelectClip?.(null); + onSelectAnnotation?.(null); + onSelectAudio?.(null); + onSelectCaption?.(null); + setSelectedKeyframeId(null); + setSelectAllBlocksActive(true); + }, [onSelectZoom, onSelectClip, onSelectAnnotation, onSelectAudio, onSelectCaption]); + + const handleSelectZoom = useCallback( + (id: string | null) => { + setSelectAllBlocksActive(false); + onSelectZoom(id); + }, + [onSelectZoom], + ); + + const handleSelectClip = useCallback( + (id: string | null) => { + setSelectAllBlocksActive(false); + onSelectClip?.(id); + }, + [onSelectClip], + ); + + const handleSelectAnnotation = useCallback( + (id: string | null) => { + setSelectAllBlocksActive(false); + onSelectAnnotation?.(id); + }, + [onSelectAnnotation], + ); + + const handleSelectAudio = useCallback( + (id: string | null) => { + setSelectAllBlocksActive(false); + onSelectAudio?.(id); + }, + [onSelectAudio], + ); + + const handleSelectCaption = useCallback( + (id: string | null) => { + setSelectAllBlocksActive(false); + onSelectCaption?.(id); + }, + [onSelectCaption], + ); + + const cycleAnnotationsAtCurrentTime = useCallback( + (backward = false) => { + const overlapping = annotationRegions + .filter((a) => currentTimeMs >= a.startMs && currentTimeMs <= a.endMs) + .sort((a, b) => a.zIndex - b.zIndex); + if (overlapping.length === 0) { + return false; + } + + if (!selectedAnnotationId || !overlapping.some((a) => a.id === selectedAnnotationId)) { + onSelectAnnotation?.(overlapping[0].id); + return true; + } + + const currentIndex = overlapping.findIndex((a) => a.id === selectedAnnotationId); + const nextIndex = backward + ? (currentIndex - 1 + overlapping.length) % overlapping.length + : (currentIndex + 1) % overlapping.length; + onSelectAnnotation?.(overlapping[nextIndex].id); + return true; + }, + [annotationRegions, currentTimeMs, selectedAnnotationId, onSelectAnnotation], + ); + + return { + keyframes, + selectedKeyframeId, + setSelectedKeyframeId, + selectAllBlocksActive, + setSelectAllBlocksActive, + hasAnyZoomBlocks, + activateSelectAllZooms, + addKeyframe, + deleteSelectedKeyframe, + handleKeyframeMove, + deleteSelectedZoom, + deleteSelectedClip, + deleteSelectedAnnotation, + deleteSelectedAudio, + deleteSelectedCaption, + clearSelectedBlocks, + handleSelectZoom, + handleSelectClip, + handleSelectAnnotation, + handleSelectAudio, + handleSelectCaption, + cycleAnnotationsAtCurrentTime, + }; +} diff --git a/src/components/video-editor/timeline/hooks/utils/timelineAudioPlacement.test.ts b/src/components/video-editor/timeline/hooks/utils/timelineAudioPlacement.test.ts new file mode 100644 index 000000000..d549bb6c2 --- /dev/null +++ b/src/components/video-editor/timeline/hooks/utils/timelineAudioPlacement.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { resolveAudioPlacement } from "./timelineAudioPlacement"; + +describe("timelineAudioPlacement", () => { + it("uses first available track when no preferred track is provided", () => { + const placement = resolveAudioPlacement({ + audioRegions: [{ id: "a1", startMs: 0, endMs: 500, trackIndex: 0 }], + startPos: 500, + totalMs: 2000, + audioDurationMs: 500, + }); + expect(placement).toEqual({ trackIndex: 0, durationMs: 500 }); + }); + + it("falls back to next track when preferred track is blocked", () => { + const placement = resolveAudioPlacement({ + audioRegions: [{ id: "a1", startMs: 0, endMs: 1500, trackIndex: 0 }], + startPos: 1000, + totalMs: 3000, + audioDurationMs: 800, + }); + expect(placement).toEqual({ trackIndex: 1, durationMs: 800 }); + }); + + it("returns null when no slot is available", () => { + const placement = resolveAudioPlacement({ + audioRegions: [{ id: "a1", startMs: 0, endMs: 2000, trackIndex: 0 }], + startPos: 1500, + totalMs: 2000, + audioDurationMs: 800, + preferredTrackIndex: 0, + }); + expect(placement).toBeNull(); + }); +}); diff --git a/src/components/video-editor/timeline/hooks/utils/timelineAudioPlacement.ts b/src/components/video-editor/timeline/hooks/utils/timelineAudioPlacement.ts new file mode 100644 index 000000000..863344494 --- /dev/null +++ b/src/components/video-editor/timeline/hooks/utils/timelineAudioPlacement.ts @@ -0,0 +1,93 @@ +import { spansOverlap } from "../../core/spans"; +import type { TimelineAudioRegion } from "../../core/timelineTypes"; + +interface ResolveAudioPlacementParams { + audioRegions: TimelineAudioRegion[]; + startPos: number; + totalMs: number; + audioDurationMs: number; + preferredTrackIndex?: number; +} + +interface AudioPlacement { + trackIndex: number; + durationMs: number; +} + +export function resolveAudioPlacement({ + audioRegions, + startPos, + totalMs, + audioDurationMs, + preferredTrackIndex, +}: ResolveAudioPlacementParams): AudioPlacement | null { + const maxRemainingDuration = totalMs - startPos; + if (audioDurationMs <= 0 || maxRemainingDuration <= 0) { + return null; + } + + const desiredDuration = Math.min(audioDurationMs, maxRemainingDuration); + const normalizedPreferredTrackIndex = Number.isFinite(preferredTrackIndex) + ? Math.max(0, Math.floor(preferredTrackIndex ?? 0)) + : null; + const maxTrackIndex = audioRegions.reduce( + (max, region) => Math.max(max, region.trackIndex ?? 0), + -1, + ); + const candidateTrackIndexes = + normalizedPreferredTrackIndex === null + ? Array.from({ length: maxTrackIndex + 2 }, (_, index) => index) + : [normalizedPreferredTrackIndex]; + + const getGapForTrack = (trackIndex: number) => { + const trackRegions = audioRegions + .filter((region) => (region.trackIndex ?? 0) === trackIndex) + .sort((left, right) => left.startMs - right.startMs); + const desiredSpan = { + start: startPos, + end: startPos + desiredDuration, + }; + + const overlappingRegion = trackRegions.find((region) => + spansOverlap(desiredSpan, { start: region.startMs, end: region.endMs }), + ); + if (overlappingRegion) { + return 0; + } + + const nextRegion = trackRegions.find((region) => region.startMs > startPos); + return nextRegion ? nextRegion.startMs - startPos : totalMs - startPos; + }; + + let selectedTrackIndex: number | null = null; + let availableGap = 0; + + for (const trackIndex of candidateTrackIndexes) { + const gap = getGapForTrack(trackIndex); + if (gap >= desiredDuration) { + selectedTrackIndex = trackIndex; + availableGap = gap; + break; + } + } + + if (selectedTrackIndex === null && normalizedPreferredTrackIndex === null) { + for (const trackIndex of candidateTrackIndexes) { + const gap = getGapForTrack(trackIndex); + if (gap > 0) { + selectedTrackIndex = trackIndex; + availableGap = gap; + break; + } + } + } + + if (selectedTrackIndex === null || availableGap <= 0) { + return null; + } + + return { + trackIndex: selectedTrackIndex, + durationMs: Math.min(audioDurationMs, availableGap, totalMs - startPos), + }; +} diff --git a/src/components/video-editor/timeline/hooks/utils/timelineNotifications.ts b/src/components/video-editor/timeline/hooks/utils/timelineNotifications.ts new file mode 100644 index 000000000..7843b1d5b --- /dev/null +++ b/src/components/video-editor/timeline/hooks/utils/timelineNotifications.ts @@ -0,0 +1,14 @@ +import { toast } from "sonner"; + +export interface TimelineNotifications { + error: (title: string, description?: string) => void; + info: (title: string, description?: string) => void; + success: (title: string, description?: string) => void; +} + +export const timelineNotifications: TimelineNotifications = { + error: (title, description) => toast.error(title, description ? { description } : undefined), + info: (title, description) => toast.info(title, description ? { description } : undefined), + success: (title, description) => + toast.success(title, description ? { description } : undefined), +}; diff --git a/src/components/video-editor/timeline/hooks/utils/timelineSelectionUtils.test.ts b/src/components/video-editor/timeline/hooks/utils/timelineSelectionUtils.test.ts new file mode 100644 index 000000000..c1e3289a5 --- /dev/null +++ b/src/components/video-editor/timeline/hooks/utils/timelineSelectionUtils.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; +import { resolveDeleteSelectionTarget } from "./timelineSelectionUtils"; + +describe("timelineSelectionUtils", () => { + it("treats zoom select-all as a zoom deletion target", () => { + expect( + resolveDeleteSelectionTarget({ + selectAllBlocksActive: true, + selectedKeyframeId: "kf-1", + selectedZoomId: "z-1", + selectedClipId: "c-1", + selectedAnnotationId: "a-1", + selectedAudioId: "au-1", + }), + ).toBe("zoom"); + }); + + it("follows selection priority order", () => { + expect( + resolveDeleteSelectionTarget({ + selectAllBlocksActive: false, + selectedKeyframeId: "kf-1", + selectedZoomId: "z-1", + }), + ).toBe("keyframe"); + expect( + resolveDeleteSelectionTarget({ + selectAllBlocksActive: false, + selectedKeyframeId: null, + selectedZoomId: "z-1", + selectedClipId: "c-1", + }), + ).toBe("zoom"); + expect( + resolveDeleteSelectionTarget({ + selectAllBlocksActive: false, + selectedKeyframeId: null, + selectedZoomId: null, + selectedClipId: "c-1", + selectedAnnotationId: "a-1", + }), + ).toBe("clip"); + }); + + it("returns none when nothing is selected", () => { + expect( + resolveDeleteSelectionTarget({ + selectAllBlocksActive: false, + selectedKeyframeId: null, + selectedZoomId: null, + }), + ).toBe("none"); + }); +}); diff --git a/src/components/video-editor/timeline/hooks/utils/timelineSelectionUtils.ts b/src/components/video-editor/timeline/hooks/utils/timelineSelectionUtils.ts new file mode 100644 index 000000000..9e825635b --- /dev/null +++ b/src/components/video-editor/timeline/hooks/utils/timelineSelectionUtils.ts @@ -0,0 +1,37 @@ +export type DeleteSelectionTarget = + | "keyframe" + | "zoom" + | "clip" + | "annotation" + | "audio" + | "caption" + | "none"; + +interface ResolveDeleteSelectionTargetParams { + selectAllBlocksActive: boolean; + selectedKeyframeId: string | null; + selectedZoomId: string | null; + selectedClipId?: string | null; + selectedAnnotationId?: string | null; + selectedAudioId?: string | null; + selectedCaptionId?: string | null; +} + +export function resolveDeleteSelectionTarget({ + selectAllBlocksActive, + selectedKeyframeId, + selectedZoomId, + selectedClipId, + selectedAnnotationId, + selectedAudioId, + selectedCaptionId, +}: ResolveDeleteSelectionTargetParams): DeleteSelectionTarget { + if (selectAllBlocksActive) return "zoom"; + if (selectedKeyframeId) return "keyframe"; + if (selectedZoomId) return "zoom"; + if (selectedClipId) return "clip"; + if (selectedAnnotationId) return "annotation"; + if (selectedAudioId) return "audio"; + if (selectedCaptionId) return "caption"; + return "none"; +} diff --git a/src/components/video-editor/timeline/model/timelineModel.test.ts b/src/components/video-editor/timeline/model/timelineModel.test.ts new file mode 100644 index 000000000..0c470c776 --- /dev/null +++ b/src/components/video-editor/timeline/model/timelineModel.test.ts @@ -0,0 +1,160 @@ +import { describe, expect, it } from "vitest"; +import { + buildAllRegionSpans, + buildTimelineItems, + getAnnotationLabel, + getAudioLabel, + resolveDropRowId, +} from "./timelineModel"; + +const BASE_ANNOTATION = { + id: "a1", + startMs: 200, + endMs: 1200, + position: { x: 0, y: 0 }, + size: { width: 1, height: 1 }, + style: { + fontSize: 12, + color: "#fff", + backgroundColor: "transparent", + borderRadius: 0, + fontFamily: "Inter", + fontWeight: "normal" as const, + fontStyle: "normal" as const, + textDecoration: "none" as const, + textAlign: "left" as const, + }, + zIndex: 0, +}; + +describe("timeline model", () => { + it("maps regions to timeline items and labels", () => { + const items = buildTimelineItems({ + zoomRegions: [ + { id: "z1", startMs: 0, endMs: 1000, depth: 2, focus: { cx: 0.5, cy: 0.5 } }, + ], + clipRegions: [{ id: "c1", startMs: 0, endMs: 4000, speed: 1 }], + annotationRegions: [ + { + ...BASE_ANNOTATION, + type: "text" as const, + content: "Hello timeline", + trackIndex: 1, + }, + ], + audioRegions: [ + { + id: "au1", + startMs: 500, + endMs: 2000, + audioPath: "/tmp/foo.mp3", + volume: 1, + trackIndex: 0, + }, + ], + }); + + expect(items).toHaveLength(4); + expect(items.find((i) => i.id === "a1")?.rowId).toBe("row-annotation-1"); + expect(items.find((i) => i.id === "au1")?.label).toBe("foo"); + }); + + it("exposes clip speed for non-default speed labels", () => { + const items = buildTimelineItems({ + zoomRegions: [], + clipRegions: [{ id: "c1", startMs: 0, endMs: 8000, speed: 0.5 }], + annotationRegions: [], + audioRegions: [], + }); + + expect(items[0]).toMatchObject({ + id: "c1", + label: "Clip 1 0.5x", + speedValue: 0.5, + }); + }); + + it("builds all variant labels for annotation and audio", () => { + expect(getAnnotationLabel({ ...BASE_ANNOTATION, type: "text", content: " " })).toBe( + "Empty text", + ); + expect( + getAnnotationLabel({ + ...BASE_ANNOTATION, + type: "text", + content: "abcdefghijklmnopqrstuvwxyz", + }), + ).toBe("abcdefghijklmnopqrst..."); + expect(getAnnotationLabel({ ...BASE_ANNOTATION, type: "image", content: "x" })).toBe( + "Image", + ); + expect(getAnnotationLabel({ ...BASE_ANNOTATION, type: "figure", content: "x" })).toBe( + "Annotation", + ); + + expect( + getAudioLabel({ + id: "1", + startMs: 0, + endMs: 1, + audioPath: "C:\\x\\y\\z.wav", + volume: 1, + }), + ).toBe("z"); + expect(getAudioLabel({ id: "2", startMs: 0, endMs: 1, audioPath: "", volume: 1 })).toBe( + "Audio", + ); + }); + + it("builds row spans for dnd constraints", () => { + const spans = buildAllRegionSpans({ + zoomRegions: [ + { id: "z1", startMs: 0, endMs: 1000, depth: 2, focus: { cx: 0.5, cy: 0.5 } }, + ], + clipRegions: [{ id: "c1", startMs: 0, endMs: 4000, speed: 1 }], + audioRegions: [ + { + id: "au1", + startMs: 500, + endMs: 2000, + audioPath: "x.wav", + volume: 1, + trackIndex: 2, + }, + ], + }); + expect(spans.map((s) => s.rowId)).toEqual(["row-zoom", "row-clip", "row-audio-2"]); + }); + + it("keeps items in their domain rows during dnd", () => { + const items = [ + { + id: "a1", + rowId: "row-annotation-1", + span: { start: 0, end: 1 }, + label: "A", + variant: "annotation" as const, + }, + { + id: "au1", + rowId: "row-audio-2", + span: { start: 0, end: 1 }, + label: "X", + variant: "audio" as const, + }, + { + id: "z1", + rowId: "row-zoom", + span: { start: 0, end: 1 }, + label: "Z", + variant: "zoom" as const, + }, + ]; + expect(resolveDropRowId("a1", "row-audio-0", items)).toBe("row-annotation-1"); + expect(resolveDropRowId("a1", "row-annotation-3", items)).toBe("row-annotation-3"); + expect(resolveDropRowId("au1", "row-annotation-1", items)).toBe("row-audio-2"); + expect(resolveDropRowId("au1", "row-audio-7", items)).toBe("row-audio-7"); + expect(resolveDropRowId("z1", "row-audio-1", items)).toBe("row-zoom"); + expect(resolveDropRowId("unknown", "row-audio-1", items)).toBe("row-audio-1"); + }); +}); diff --git a/src/components/video-editor/timeline/model/timelineModel.ts b/src/components/video-editor/timeline/model/timelineModel.ts new file mode 100644 index 000000000..e1589fd60 --- /dev/null +++ b/src/components/video-editor/timeline/model/timelineModel.ts @@ -0,0 +1,162 @@ +import { formatClipSpeedLabel } from "../../clipSpeedChange"; +import type { + AnnotationRegion, + AudioRegion, + CaptionCue, + ClipRegion, + ZoomRegion, +} from "../../types"; +import { CAPTION_ROW_ID, CLIP_ROW_ID, ZOOM_ROW_ID } from "../core/constants"; +import { + getAnnotationTrackIndex, + getAnnotationTrackRowId, + getAudioTrackIndex, + getAudioTrackRowId, + isAnnotationTrackRowId, + isAudioTrackRowId, +} from "../core/rows"; +import type { TimelineRegionSpan, TimelineRenderItem } from "../core/timelineTypes"; + +export function getAnnotationLabel(region: AnnotationRegion): string { + if (region.type === "text") { + const preview = region.content.trim() || "Empty text"; + return preview.length > 20 ? `${preview.substring(0, 20)}...` : preview; + } + if (region.type === "image") { + return "Image"; + } + return "Annotation"; +} + +export function getAudioLabel(region: AudioRegion): string { + return ( + region.audioPath + .split(/[\\/]/) + .pop() + ?.replace(/\.[^.]+$/, "") || "Audio" + ); +} + +function getCaptionLabel(cue: CaptionCue): string { + const preview = cue.text.trim() || "Caption"; + return preview.length > 24 ? `${preview.substring(0, 24)}...` : preview; +} + +export function buildTimelineItems(params: { + zoomRegions: ZoomRegion[]; + clipRegions: ClipRegion[]; + annotationRegions: AnnotationRegion[]; + audioRegions: AudioRegion[]; + captionCues?: CaptionCue[]; +}): TimelineRenderItem[] { + const { zoomRegions, clipRegions, annotationRegions, audioRegions, captionCues = [] } = params; + const zooms: TimelineRenderItem[] = zoomRegions.map((region, index) => ({ + id: region.id, + rowId: ZOOM_ROW_ID, + span: { start: region.startMs, end: region.endMs }, + label: `Zoom ${index + 1}`, + zoomDepth: region.depth, + zoomMode: region.mode ?? "auto", + variant: "zoom", + })); + + const clips: TimelineRenderItem[] = clipRegions.map((region, index) => { + const displayDurationMs = Math.max(0, region.endMs - region.startMs); + const speed = Number.isFinite(region.speed) && region.speed > 0 ? region.speed : 1; + const sourceEndMs = region.startMs + displayDurationMs * speed; + const speedLabel = formatClipSpeedLabel(speed); + + return { + id: region.id, + rowId: CLIP_ROW_ID, + span: { start: region.startMs, end: region.endMs }, + sourceSpan: { start: region.startMs, end: sourceEndMs }, + label: speedLabel ? `Clip ${index + 1} ${speedLabel}` : `Clip ${index + 1}`, + speedValue: speedLabel ? speed : undefined, + showSourceAudio: region.showSourceAudio, + muted: Boolean(region.muted), + variant: "clip", + }; + }); + + const annotations: TimelineRenderItem[] = annotationRegions.map((region) => ({ + id: region.id, + rowId: getAnnotationTrackRowId(region.trackIndex ?? 0), + span: { start: region.startMs, end: region.endMs }, + label: getAnnotationLabel(region), + variant: "annotation", + })); + + const audios: TimelineRenderItem[] = audioRegions.map((region) => ({ + id: region.id, + rowId: getAudioTrackRowId(region.trackIndex ?? 0), + span: { start: region.startMs, end: region.endMs }, + label: getAudioLabel(region), + audioPath: region.audioPath, + audioGain: region.volume, + audioNormalize: Boolean(region.normalize), + variant: "audio", + })); + + const captions: TimelineRenderItem[] = captionCues.map((cue) => ({ + id: cue.id, + rowId: CAPTION_ROW_ID, + span: { start: cue.startMs, end: cue.endMs }, + label: getCaptionLabel(cue), + variant: "caption", + })); + + return [...zooms, ...clips, ...annotations, ...audios, ...captions]; +} + +export function buildAllRegionSpans(params: { + zoomRegions: ZoomRegion[]; + clipRegions: ClipRegion[]; + audioRegions: AudioRegion[]; +}): TimelineRegionSpan[] { + const { zoomRegions, clipRegions, audioRegions } = params; + const zooms = zoomRegions.map((r) => ({ + id: r.id, + start: r.startMs, + end: r.endMs, + rowId: ZOOM_ROW_ID, + })); + const clips = clipRegions.map((r) => ({ + id: r.id, + start: r.startMs, + end: r.endMs, + rowId: CLIP_ROW_ID, + })); + const audios = audioRegions.map((r) => ({ + id: r.id, + start: r.startMs, + end: r.endMs, + rowId: getAudioTrackRowId(r.trackIndex ?? 0), + })); + return [...zooms, ...clips, ...audios]; +} + +export function resolveDropRowId( + id: string, + proposedRowId: string, + timelineItems: TimelineRenderItem[], +) { + const currentRowId = timelineItems.find((item) => item.id === id)?.rowId; + if (!currentRowId) { + return proposedRowId; + } + + if (isAnnotationTrackRowId(currentRowId)) { + return isAnnotationTrackRowId(proposedRowId) + ? getAnnotationTrackRowId(getAnnotationTrackIndex(proposedRowId)) + : currentRowId; + } + + if (isAudioTrackRowId(currentRowId)) { + return isAudioTrackRowId(proposedRowId) + ? getAudioTrackRowId(getAudioTrackIndex(proposedRowId)) + : currentRowId; + } + + return currentRowId; +} diff --git a/src/components/video-editor/timeline/sourceAudioTracks.test.ts b/src/components/video-editor/timeline/sourceAudioTracks.test.ts new file mode 100644 index 000000000..f7c809e64 --- /dev/null +++ b/src/components/video-editor/timeline/sourceAudioTracks.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from "vitest"; +import type { AudioPeaksData } from "./core/timelineTypes"; +import { + buildSourceSidecarPathCandidates, + buildTimelineSourceAudioTracks, +} from "./sourceAudioTracks"; + +function peaks(id: number): AudioPeaksData { + return { + durationMs: 1000, + peaks: new Float32Array([id]), + }; +} + +const labels = { + system: "Source System", + mic: "Source Mic", + mixed: "Source", +}; + +describe("timeline source audio tracks", () => { + it("builds candidates for Windows and macOS sidecar containers", () => { + expect(buildSourceSidecarPathCandidates("C:\\Recordly\\recording-1.mp4", "mic")).toEqual([ + "C:/Recordly/recording-1.mic.wav", + "C:/Recordly/recording-1.mic.m4a", + "C:/Recordly/recording-1.mic.webm", + ]); + }); + + it("keeps embedded system audio controllable when mic is a sidecar", () => { + const source = peaks(1); + const mic = peaks(2); + + expect( + buildTimelineSourceAudioTracks({ + sourceAudioPeaks: source, + micSidecarPeaks: mic, + systemSidecarPeaks: null, + labels, + }), + ).toEqual([ + { id: "system", label: "Source System", peaks: source }, + { id: "mic", label: "Source Mic", peaks: mic }, + ]); + }); + + it("does not invent a system track when only the mic sidecar exists", () => { + const mic = peaks(2); + + expect( + buildTimelineSourceAudioTracks({ + sourceAudioPeaks: null, + micSidecarPeaks: mic, + systemSidecarPeaks: null, + labels, + }), + ).toEqual([{ id: "mic", label: "Source Mic", peaks: mic }]); + }); + + it("uses dedicated sidecars over the embedded track when both source tracks exist", () => { + const source = peaks(1); + const system = peaks(2); + const mic = peaks(3); + + expect( + buildTimelineSourceAudioTracks({ + sourceAudioPeaks: source, + micSidecarPeaks: mic, + systemSidecarPeaks: system, + labels, + }), + ).toEqual([ + { id: "system", label: "Source System", peaks: system }, + { id: "mic", label: "Source Mic", peaks: mic }, + ]); + }); + + it("falls back to one mixed source track when no dedicated sidecar exists", () => { + const source = peaks(1); + + expect( + buildTimelineSourceAudioTracks({ + sourceAudioPeaks: source, + micSidecarPeaks: null, + systemSidecarPeaks: null, + labels, + }), + ).toEqual([{ id: "mixed", label: "Source", peaks: source }]); + }); +}); diff --git a/src/components/video-editor/timeline/sourceAudioTracks.ts b/src/components/video-editor/timeline/sourceAudioTracks.ts new file mode 100644 index 000000000..46a089ee8 --- /dev/null +++ b/src/components/video-editor/timeline/sourceAudioTracks.ts @@ -0,0 +1,68 @@ +import type { SourceAudioTrackWithPeaks } from "@/components/video-editor/audio/audioTypes"; +import type { AudioPeaksData } from "./core/timelineTypes"; + +const SOURCE_SIDECAR_EXTENSIONS = [".wav", ".m4a", ".webm"] as const; + +export function buildSourceSidecarPathCandidates( + source: string, + suffix: "mic" | "system", +): string[] { + const normalized = source.replace(/\\/g, "/"); + const lastSlash = normalized.lastIndexOf("/"); + const dir = lastSlash >= 0 ? normalized.slice(0, lastSlash + 1) : ""; + const fileName = lastSlash >= 0 ? normalized.slice(lastSlash + 1) : normalized; + const dotIndex = fileName.lastIndexOf("."); + const baseName = dotIndex > 0 ? fileName.slice(0, dotIndex) : fileName; + return SOURCE_SIDECAR_EXTENSIONS.map((extension) => `${dir}${baseName}.${suffix}${extension}`); +} + +export function buildTimelineSourceAudioTracks({ + sourceAudioPeaks, + micSidecarPeaks, + systemSidecarPeaks, + labels, +}: { + sourceAudioPeaks: AudioPeaksData | null; + micSidecarPeaks: AudioPeaksData | null; + systemSidecarPeaks: AudioPeaksData | null; + labels: { + system: string; + mic: string; + mixed: string; + }; +}): SourceAudioTrackWithPeaks[] { + if (systemSidecarPeaks || micSidecarPeaks) { + const tracks: SourceAudioTrackWithPeaks[] = []; + if (systemSidecarPeaks) { + tracks.push({ + id: "system", + label: labels.system, + peaks: systemSidecarPeaks, + }); + } else if (micSidecarPeaks && sourceAudioPeaks) { + tracks.push({ + id: "system", + label: labels.system, + peaks: sourceAudioPeaks, + }); + } + if (micSidecarPeaks) { + tracks.push({ + id: "mic", + label: labels.mic, + peaks: micSidecarPeaks, + }); + } + return tracks; + } + + return sourceAudioPeaks + ? [ + { + id: "mixed", + label: labels.mixed, + peaks: sourceAudioPeaks, + }, + ] + : []; +} diff --git a/src/components/video-editor/timeline/timelineLayout.test.ts b/src/components/video-editor/timeline/timelineLayout.test.ts new file mode 100644 index 000000000..ba1756326 --- /dev/null +++ b/src/components/video-editor/timeline/timelineLayout.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; +import { + getTimelineContentMinHeightPx, + getTimelineRowsMinHeightPx, + getTimelineViewportStretchFactor, + TIMELINE_AXIS_HEIGHT_PX, + TIMELINE_ROW_MIN_HEIGHT_PX, + TIMELINE_VISIBLE_ROW_COUNT, +} from "./timelineLayout"; + +describe("timelineLayout", () => { + it("reserves vertical space for every rendered timeline row", () => { + expect(getTimelineRowsMinHeightPx(5)).toBe(5 * TIMELINE_ROW_MIN_HEIGHT_PX); + expect(getTimelineContentMinHeightPx(5)).toBe( + TIMELINE_AXIS_HEIGHT_PX + 5 * TIMELINE_ROW_MIN_HEIGHT_PX, + ); + }); + + it("ignores invalid row counts", () => { + expect(getTimelineRowsMinHeightPx(-1)).toBe(0); + expect(getTimelineRowsMinHeightPx(Number.NaN)).toBe(0); + expect(getTimelineContentMinHeightPx(Number.POSITIVE_INFINITY)).toBe( + TIMELINE_AXIS_HEIGHT_PX, + ); + }); + + it("floors fractional row counts", () => { + expect(getTimelineRowsMinHeightPx(2.9)).toBe(2 * TIMELINE_ROW_MIN_HEIGHT_PX); + expect(getTimelineContentMinHeightPx(2.9)).toBe( + TIMELINE_AXIS_HEIGHT_PX + 2 * TIMELINE_ROW_MIN_HEIGHT_PX, + ); + }); + + it("stretches content height to keep a two-row viewport", () => { + expect(TIMELINE_VISIBLE_ROW_COUNT).toBe(2); + expect(getTimelineViewportStretchFactor(2)).toBe(1); + expect(getTimelineViewportStretchFactor(4)).toBe(2); + expect(getTimelineViewportStretchFactor(5)).toBe(2.5); + expect(getTimelineViewportStretchFactor(0)).toBe(1); + }); +}); diff --git a/src/components/video-editor/timeline/timelineLayout.ts b/src/components/video-editor/timeline/timelineLayout.ts new file mode 100644 index 000000000..0f54c4b42 --- /dev/null +++ b/src/components/video-editor/timeline/timelineLayout.ts @@ -0,0 +1,29 @@ +export const TIMELINE_AXIS_HEIGHT_PX = 32; +export const TIMELINE_ROW_MIN_HEIGHT_PX = 28; +export const TIMELINE_VISIBLE_ROW_COUNT = 2; + +function normalizeRowCount(rowCount: number) { + if (!Number.isFinite(rowCount)) { + return 0; + } + + return Math.max(0, Math.floor(rowCount)); +} + +export function getTimelineRowsMinHeightPx(rowCount: number) { + return normalizeRowCount(rowCount) * TIMELINE_ROW_MIN_HEIGHT_PX; +} + +export function getTimelineContentMinHeightPx(rowCount: number) { + return TIMELINE_AXIS_HEIGHT_PX + getTimelineRowsMinHeightPx(rowCount); +} + +export function getTimelineViewportStretchFactor(rowCount: number) { + const normalizedRowCount = normalizeRowCount(rowCount); + + if (normalizedRowCount <= 0) { + return 1; + } + + return Math.max(1, normalizedRowCount / TIMELINE_VISIBLE_ROW_COUNT); +} diff --git a/src/components/video-editor/timeline/zoomSuggestionUtils.test.ts b/src/components/video-editor/timeline/zoomSuggestionUtils.test.ts new file mode 100644 index 000000000..f1a6dd212 --- /dev/null +++ b/src/components/video-editor/timeline/zoomSuggestionUtils.test.ts @@ -0,0 +1,217 @@ +import { describe, expect, it } from "vitest"; +import type { CursorTelemetryPoint } from "../types"; +import { + buildInteractionZoomSuggestions, + CLICK_CLUSTER_MERGE_GAP_MS, + CLICK_CLUSTER_PAD_MS, + shouldAutoApplyFreshRecordingZoomsForSource, +} from "./zoomSuggestionUtils"; + +function makeClick( + timeMs: number, + cx = 0.5, + cy = 0.5, + interactionType: CursorTelemetryPoint["interactionType"] = "click", +): CursorTelemetryPoint { + return { timeMs, cx, cy, interactionType }; +} + +function makeMove(timeMs: number, cx = 0.5, cy = 0.5): CursorTelemetryPoint { + return { timeMs, cx, cy, interactionType: "move" }; +} + +/** Wraps click samples with surrounding move events to mimic real mixed telemetry. */ +function withMoves(clicks: CursorTelemetryPoint[], totalMs: number): CursorTelemetryPoint[] { + return [makeMove(0), ...clicks, makeMove(totalMs)]; +} + +const TOTAL_MS = 30_000; + +describe("shouldAutoApplyFreshRecordingZoomsForSource", () => { + it("allows automatic fresh-recording zooms for landscape captures", () => { + expect(shouldAutoApplyFreshRecordingZoomsForSource(1920, 1080)).toBe(true); + expect(shouldAutoApplyFreshRecordingZoomsForSource(1280, 960)).toBe(true); + }); + + it("blocks automatic fresh-recording zooms for narrow or near-square captures", () => { + expect(shouldAutoApplyFreshRecordingZoomsForSource(960, 1020)).toBe(false); + expect(shouldAutoApplyFreshRecordingZoomsForSource(1080, 1080)).toBe(false); + }); + + it("does not block when source dimensions are not available yet", () => { + expect(shouldAutoApplyFreshRecordingZoomsForSource()).toBe(true); + }); +}); + +describe("buildInteractionZoomSuggestions (click-cluster logic)", () => { + it("creates one zoom track for a single isolated click with 500ms padding", () => { + const telemetry = withMoves([makeClick(5_000)], TOTAL_MS); + + const result = buildInteractionZoomSuggestions({ + cursorTelemetry: telemetry, + totalMs: TOTAL_MS, + defaultDurationMs: 3_000, + }); + + expect(result.status).toBe("ok"); + expect(result.suggestions).toHaveLength(1); + + const [s] = result.suggestions; + expect(s.start).toBe(5_000 - CLICK_CLUSTER_PAD_MS); + expect(s.end).toBe(5_000 + CLICK_CLUSTER_PAD_MS); + }); + + it("accepts a single explicit click sample without needing surrounding moves", () => { + const result = buildInteractionZoomSuggestions({ + cursorTelemetry: [makeClick(5_000)], + totalMs: TOTAL_MS, + defaultDurationMs: 3_000, + }); + + expect(result.status).toBe("ok"); + expect(result.suggestions).toHaveLength(1); + }); + + it.each([ + "right-click", + "middle-click", + ] as const)("accepts %s telemetry like a standard click", (interactionType) => { + const result = buildInteractionZoomSuggestions({ + cursorTelemetry: withMoves([makeClick(5_000, 0.5, 0.5, interactionType)], TOTAL_MS), + totalMs: TOTAL_MS, + defaultDurationMs: 3_000, + }); + + expect(result.status).toBe("ok"); + expect(result.suggestions).toHaveLength(1); + + const [suggestion] = result.suggestions; + expect(suggestion.start).toBe(5_000 - CLICK_CLUSTER_PAD_MS); + expect(suggestion.end).toBe(5_000 + CLICK_CLUSTER_PAD_MS); + }); + + it("merges two clicks within 2500ms into one zoom track", () => { + const telemetry = withMoves( + [makeClick(4_000), makeClick(4_000 + CLICK_CLUSTER_MERGE_GAP_MS - 1)], + TOTAL_MS, + ); + + const result = buildInteractionZoomSuggestions({ + cursorTelemetry: telemetry, + totalMs: TOTAL_MS, + defaultDurationMs: 3_000, + }); + + expect(result.status).toBe("ok"); + expect(result.suggestions).toHaveLength(1); + + const [s] = result.suggestions; + const lastClickMs = 4_000 + CLICK_CLUSTER_MERGE_GAP_MS - 1; + expect(s.start).toBe(4_000 - CLICK_CLUSTER_PAD_MS); + expect(s.end).toBe(lastClickMs + CLICK_CLUSTER_PAD_MS); + }); + + it("splits two clicks more than 2500ms apart into separate zoom tracks", () => { + const click1 = 3_000; + const click2 = 3_000 + CLICK_CLUSTER_MERGE_GAP_MS + 1; // just outside the merge gap + + const telemetry = withMoves([makeClick(click1), makeClick(click2)], TOTAL_MS); + + const result = buildInteractionZoomSuggestions({ + cursorTelemetry: telemetry, + totalMs: TOTAL_MS, + defaultDurationMs: 3_000, + }); + + expect(result.status).toBe("ok"); + expect(result.suggestions).toHaveLength(2); + + const [a, b] = result.suggestions; + expect(a.start).toBe(click1 - CLICK_CLUSTER_PAD_MS); + expect(a.end).toBe(click1 + CLICK_CLUSTER_PAD_MS); + expect(b.start).toBe(click2 - CLICK_CLUSTER_PAD_MS); + expect(b.end).toBe(click2 + CLICK_CLUSTER_PAD_MS); + }); + + it("chains multiple clicks: 3 in a row within 2500ms each become one track", () => { + // click at 0, 2000, 4000 — each gap is 2000ms < 2500ms + const telemetry = withMoves([makeClick(0), makeClick(2_000), makeClick(4_000)], TOTAL_MS); + + const result = buildInteractionZoomSuggestions({ + cursorTelemetry: telemetry, + totalMs: TOTAL_MS, + defaultDurationMs: 3_000, + }); + + expect(result.status).toBe("ok"); + expect(result.suggestions).toHaveLength(1); + + const [s] = result.suggestions; + expect(s.start).toBe(0); // clamped to 0 (would be -500) + expect(s.end).toBe(4_000 + CLICK_CLUSTER_PAD_MS); + }); + + it("returns no-interactions when there are no click telemetry points", () => { + // Move events only — no clicks + const telemetry: CursorTelemetryPoint[] = [ + { timeMs: 0, cx: 0.5, cy: 0.5, interactionType: "move" }, + { timeMs: 1_000, cx: 0.5, cy: 0.5, interactionType: "move" }, + { timeMs: 2_000, cx: 0.6, cy: 0.6, interactionType: "move" }, + { timeMs: TOTAL_MS, cx: 0.6, cy: 0.6, interactionType: "move" }, + ]; + + const result = buildInteractionZoomSuggestions({ + cursorTelemetry: telemetry, + totalMs: TOTAL_MS, + defaultDurationMs: 3_000, + }); + + expect(result.status).toBe("no-interactions"); + expect(result.suggestions).toHaveLength(0); + }); + + it("ignores dwell-derived click-like heuristics when there are no explicit clicks", () => { + const telemetry: CursorTelemetryPoint[] = [ + makeMove(0, 0.5, 0.5), + makeMove(200, 0.5005, 0.5005), + makeMove(400, 0.5008, 0.5008), + makeMove(600, 0.501, 0.501), + ]; + + const result = buildInteractionZoomSuggestions({ + cursorTelemetry: telemetry, + totalMs: TOTAL_MS, + defaultDurationMs: 3_000, + }); + + expect(result.status).toBe("no-interactions"); + expect(result.suggestions).toHaveLength(0); + }); + + it("skips clusters that overlap reserved spans", () => { + const click = 5_000; + + const result = buildInteractionZoomSuggestions({ + cursorTelemetry: withMoves([makeClick(click)], TOTAL_MS), + totalMs: TOTAL_MS, + defaultDurationMs: 3_000, + reservedSpans: [{ start: 4_000, end: 6_000 }], // overlaps the cluster window + }); + + expect(result.status).toBe("no-slots"); + expect(result.suggestions).toHaveLength(0); + }); + + it("clamps start to 0 and end to totalMs at video boundaries", () => { + const result = buildInteractionZoomSuggestions({ + cursorTelemetry: withMoves([makeClick(200)], 1_000), + totalMs: 1_000, + defaultDurationMs: 3_000, + }); + + expect(result.status).toBe("ok"); + const [s] = result.suggestions; + expect(s.start).toBeGreaterThanOrEqual(0); + expect(s.end).toBeLessThanOrEqual(1_000); + }); +}); diff --git a/src/components/video-editor/timeline/zoomSuggestionUtils.ts b/src/components/video-editor/timeline/zoomSuggestionUtils.ts new file mode 100644 index 000000000..bf38efa60 --- /dev/null +++ b/src/components/video-editor/timeline/zoomSuggestionUtils.ts @@ -0,0 +1,521 @@ +import type { CursorTelemetryPoint, ZoomFocus } from "../types"; + +export const MIN_DWELL_DURATION_MS = 450; +export const MAX_DWELL_DURATION_MS = 2600; +export const DWELL_MOVE_THRESHOLD = 0.02; +export const MIN_FRESH_RECORDING_AUTO_ZOOM_SOURCE_ASPECT_RATIO = 1.2; + +export interface ZoomDwellCandidate { + centerTimeMs: number; + focus: ZoomFocus; + strength: number; +} + +export interface CursorInteractionCandidate extends ZoomDwellCandidate { + kind: + | "dwell" + | "click-like" + | "double-click-like" + | "text-focus-like" + | "dropdown-open" + | "text-selection" + | "text-field-click"; + source: "explicit" | "heuristic"; +} + +export interface SuggestedZoomRegion { + start: number; + end: number; + focus: ZoomFocus; +} + +export type InteractionZoomSuggestionStatus = + | "ok" + | "no-telemetry" + | "no-interactions" + | "no-slots"; + +export interface InteractionZoomSuggestionResult { + status: InteractionZoomSuggestionStatus; + suggestions: SuggestedZoomRegion[]; +} + +export function shouldAutoApplyFreshRecordingZoomsForSource( + sourceWidth?: number, + sourceHeight?: number, +): boolean { + if ( + !Number.isFinite(sourceWidth) || + !Number.isFinite(sourceHeight) || + (sourceWidth ?? 0) <= 0 || + (sourceHeight ?? 0) <= 0 + ) { + return true; + } + + return ( + (sourceWidth as number) / (sourceHeight as number) >= + MIN_FRESH_RECORDING_AUTO_ZOOM_SOURCE_ASPECT_RATIO + ); +} + +/** Max gap between consecutive clicks before they are split into separate zoom clusters. */ +export const CLICK_CLUSTER_MERGE_GAP_MS = 2500; +/** Padding added before the first click and after the last click in a cluster. */ +export const CLICK_CLUSTER_PAD_MS = 500; +const EXPLICIT_CLICK_TYPES = new Set<NonNullable<CursorTelemetryPoint["interactionType"]>>([ + "click", + "double-click", + "right-click", + "middle-click", +]); + +function isExplicitClickType( + interactionType: CursorTelemetryPoint["interactionType"], +): interactionType is NonNullable<CursorTelemetryPoint["interactionType"]> { + return typeof interactionType === "string" && EXPLICIT_CLICK_TYPES.has(interactionType); +} + +function normalizeTelemetrySample( + sample: CursorTelemetryPoint, + totalMs: number, +): CursorTelemetryPoint { + return { + timeMs: Math.max(0, Math.min(sample.timeMs, totalMs)), + cx: Math.max(0, Math.min(sample.cx, 1)), + cy: Math.max(0, Math.min(sample.cy, 1)), + interactionType: sample.interactionType, + cursorType: sample.cursorType, + }; +} + +function applyCursorTypeInRange( + samples: CursorTelemetryPoint[], + startMs: number, + endMs: number, + cursorType: NonNullable<CursorTelemetryPoint["cursorType"]>, +) { + for (const sample of samples) { + if (sample.timeMs < startMs || sample.timeMs > endMs) continue; + if (!sample.cursorType) { + sample.cursorType = cursorType; + } + } +} + +export function normalizeCursorTelemetry( + telemetry: CursorTelemetryPoint[], + totalMs: number, +): CursorTelemetryPoint[] { + const normalized = [...telemetry] + .filter( + (sample) => + Number.isFinite(sample.timeMs) && + Number.isFinite(sample.cx) && + Number.isFinite(sample.cy), + ) + .sort((a, b) => a.timeMs - b.timeMs) + .map((sample) => normalizeTelemetrySample(sample, totalMs)); + + const interactions = detectInteractionCandidates(normalized); + for (const candidate of interactions) { + if (candidate.kind === "text-selection") { + applyCursorTypeInRange( + normalized, + candidate.centerTimeMs - 140, + candidate.centerTimeMs + 1200, + "text", + ); + continue; + } + + if (candidate.kind === "text-field-click" || candidate.kind === "text-focus-like") { + applyCursorTypeInRange( + normalized, + candidate.centerTimeMs - 100, + candidate.centerTimeMs + 900, + "text", + ); + continue; + } + } + + for (const sample of normalized) { + if (sample.interactionType !== "click" && sample.interactionType !== "double-click") { + continue; + } + + const mouseUp = normalized.find( + (candidate) => + candidate.timeMs > sample.timeMs && candidate.interactionType === "mouseup", + ); + if (!mouseUp) { + continue; + } + + const dragDuration = mouseUp.timeMs - sample.timeMs; + const dragDistance = Math.hypot(mouseUp.cx - sample.cx, mouseUp.cy - sample.cy); + if (dragDuration >= 160 && dragDistance > 0.015) { + const isTextDrag = + Math.abs(mouseUp.cx - sample.cx) > Math.abs(mouseUp.cy - sample.cy) * 1.8; + applyCursorTypeInRange( + normalized, + sample.timeMs, + mouseUp.timeMs, + isTextDrag ? "text" : "closed-hand", + ); + } + } + + return normalized; +} + +export function detectZoomDwellCandidates(samples: CursorTelemetryPoint[]): ZoomDwellCandidate[] { + if (samples.length < 2) { + return []; + } + + const dwellCandidates: ZoomDwellCandidate[] = []; + let runStart = 0; + + const pushRunIfDwell = (startIndex: number, endIndexExclusive: number) => { + if (endIndexExclusive - startIndex < 2) { + return; + } + + const start = samples[startIndex]; + const end = samples[endIndexExclusive - 1]; + const runDuration = end.timeMs - start.timeMs; + if (runDuration < MIN_DWELL_DURATION_MS || runDuration > MAX_DWELL_DURATION_MS) { + return; + } + + const runSamples = samples.slice(startIndex, endIndexExclusive); + const avgCx = runSamples.reduce((sum, sample) => sum + sample.cx, 0) / runSamples.length; + const avgCy = runSamples.reduce((sum, sample) => sum + sample.cy, 0) / runSamples.length; + + dwellCandidates.push({ + centerTimeMs: Math.round((start.timeMs + end.timeMs) / 2), + focus: { cx: avgCx, cy: avgCy }, + strength: runDuration, + }); + }; + + for (let index = 1; index < samples.length; index += 1) { + const prev = samples[index - 1]; + const curr = samples[index]; + const distance = Math.hypot(curr.cx - prev.cx, curr.cy - prev.cy); + + if (distance > DWELL_MOVE_THRESHOLD) { + pushRunIfDwell(runStart, index); + runStart = index; + } + } + pushRunIfDwell(runStart, samples.length); + + return dwellCandidates; +} + +export function detectInteractionCandidates( + samples: CursorTelemetryPoint[], +): CursorInteractionCandidate[] { + // --- Phase 1: Explicit interaction events (from uiohook telemetry) --- + const clickEvents = samples.filter((sample) => isExplicitClickType(sample.interactionType)); + + const explicitInteractionCandidates: CursorInteractionCandidate[] = []; + + for (const clickSample of clickEvents) { + // Classify what happened AFTER this click by analyzing cursor trajectory + const kind = classifyPostClickBehavior(samples, clickSample); + + const baseStrength = + kind === "double-click-like" + ? 1500 + : kind === "dropdown-open" + ? 1200 + : kind === "text-selection" + ? 1300 + : kind === "text-field-click" + ? 1100 + : 900; + + explicitInteractionCandidates.push({ + centerTimeMs: Math.round(clickSample.timeMs), + focus: { cx: clickSample.cx, cy: clickSample.cy }, + strength: baseStrength, + kind, + source: "explicit", + }); + } + + // --- Phase 2: Dwell-based heuristic candidates --- + const dwellCandidates = detectZoomDwellCandidates(samples).map<CursorInteractionCandidate>( + (candidate) => { + if (candidate.strength >= 1100) { + return { ...candidate, kind: "text-focus-like", source: "heuristic" }; + } + if (candidate.strength <= 800) { + return { ...candidate, kind: "click-like", source: "heuristic" }; + } + return { ...candidate, kind: "dwell", source: "heuristic" }; + }, + ); + + // --- Phase 3: Synthetic double-click detection from dwell pairs --- + const doubleClickCandidates: CursorInteractionCandidate[] = []; + const sortedByTime = [...dwellCandidates].sort((a, b) => a.centerTimeMs - b.centerTimeMs); + + for (let index = 1; index < sortedByTime.length; index += 1) { + const prev = sortedByTime[index - 1]; + const curr = sortedByTime[index]; + const timeGap = curr.centerTimeMs - prev.centerTimeMs; + const spatialGap = Math.hypot(curr.focus.cx - prev.focus.cx, curr.focus.cy - prev.focus.cy); + const bothShort = prev.strength <= 900 && curr.strength <= 900; + + if (bothShort && timeGap <= 450 && spatialGap <= 0.035) { + doubleClickCandidates.push({ + centerTimeMs: Math.round((prev.centerTimeMs + curr.centerTimeMs) / 2), + focus: { + cx: (prev.focus.cx + curr.focus.cx) / 2, + cy: (prev.focus.cy + curr.focus.cy) / 2, + }, + strength: prev.strength + curr.strength + 500, + kind: "double-click-like", + source: "heuristic", + }); + } + } + + return [...explicitInteractionCandidates, ...dwellCandidates, ...doubleClickCandidates]; +} + +/** + * Groups a sorted list of click timestamps into clusters where consecutive + * clicks are no more than `mergeGapMs` apart. Returns an array of + * `{ firstMs, lastMs, focus }` objects, one per cluster. The focus is taken + * from the click with the highest interaction strength, falling back to the + * centroid of all clicks in the cluster. + */ +function buildClickClusters( + clicks: CursorInteractionCandidate[], + mergeGapMs: number, +): Array<{ firstMs: number; lastMs: number; focus: ZoomFocus }> { + if (clicks.length === 0) { + return []; + } + + const sorted = [...clicks].sort((a, b) => a.centerTimeMs - b.centerTimeMs); + const clusters: Array<{ firstMs: number; lastMs: number; focus: ZoomFocus }> = []; + + let clusterStart = sorted[0].centerTimeMs; + let clusterEnd = sorted[0].centerTimeMs; + let bestStrength = sorted[0].strength; + let bestFocus = sorted[0].focus; + let sumCx = sorted[0].focus.cx; + let sumCy = sorted[0].focus.cy; + let count = 1; + + for (let i = 1; i < sorted.length; i++) { + const click = sorted[i]; + const gap = click.centerTimeMs - clusterEnd; + + if (gap <= mergeGapMs) { + // Extend current cluster + clusterEnd = Math.max(clusterEnd, click.centerTimeMs); + if (click.strength > bestStrength) { + bestStrength = click.strength; + bestFocus = click.focus; + } + sumCx += click.focus.cx; + sumCy += click.focus.cy; + count += 1; + } else { + // Flush current cluster and start a new one + clusters.push({ + firstMs: clusterStart, + lastMs: clusterEnd, + focus: bestFocus ?? { cx: sumCx / count, cy: sumCy / count }, + }); + clusterStart = click.centerTimeMs; + clusterEnd = click.centerTimeMs; + bestStrength = click.strength; + bestFocus = click.focus; + sumCx = click.focus.cx; + sumCy = click.focus.cy; + count = 1; + } + } + + // Flush last cluster + clusters.push({ + firstMs: clusterStart, + lastMs: clusterEnd, + focus: bestFocus ?? { cx: sumCx / count, cy: sumCy / count }, + }); + + return clusters; +} + +export function buildInteractionZoomSuggestions(params: { + cursorTelemetry: CursorTelemetryPoint[]; + totalMs: number; + defaultDurationMs: number; + reservedSpans?: Array<{ start: number; end: number }>; + spacingMs?: number; + mergeGapMs?: number; + padMs?: number; +}): InteractionZoomSuggestionResult { + const { + cursorTelemetry, + totalMs, + reservedSpans = [], + mergeGapMs = CLICK_CLUSTER_MERGE_GAP_MS, + padMs = CLICK_CLUSTER_PAD_MS, + } = params; + + if (totalMs <= 0) { + return { status: "no-slots", suggestions: [] }; + } + + const normalizedSamples = normalizeCursorTelemetry(cursorTelemetry, totalMs); + if (normalizedSamples.length === 0) { + return { status: "no-telemetry", suggestions: [] }; + } + + if ( + normalizedSamples.length === 1 && + !isExplicitClickType(normalizedSamples[0].interactionType) + ) { + return { status: "no-telemetry", suggestions: [] }; + } + + // Only use explicit click events (uiohook telemetry) – ignore dwell heuristics + const clickCandidates = detectInteractionCandidates(normalizedSamples).filter( + (candidate) => candidate.source === "explicit", + ); + + if (clickCandidates.length === 0) { + return { status: "no-interactions", suggestions: [] }; + } + + // Group nearby clicks into clusters, then derive zoom windows from those clusters + const clusters = buildClickClusters(clickCandidates, mergeGapMs); + + const reserved = [...reservedSpans].sort((a, b) => a.start - b.start); + const suggestions: SuggestedZoomRegion[] = []; + + for (const cluster of clusters) { + const regionStart = Math.max(0, cluster.firstMs - padMs); + const regionEnd = Math.min(totalMs, cluster.lastMs + padMs); + + if (regionEnd <= regionStart) { + continue; + } + + const hasOverlap = reserved.some( + (span) => regionEnd > span.start && regionStart < span.end, + ); + + if (hasOverlap) { + continue; + } + + reserved.push({ start: regionStart, end: regionEnd }); + suggestions.push({ + start: regionStart, + end: regionEnd, + focus: cluster.focus, + }); + } + + if (suggestions.length === 0) { + return { status: "no-slots", suggestions: [] }; + } + + // Sort chronologically + suggestions.sort((a, b) => a.start - b.start); + + return { status: "ok", suggestions }; +} + +/** + * Analyzes cursor movement after a click to classify the interaction pattern. + * + * - **dropdown-open**: click followed by slow downward cursor movement (browsing items) + * - **text-selection**: click followed by primarily horizontal drag movement + * - **text-field-click**: click followed by cursor staying mostly still (dwell) + * - **double-click-like**: explicit double-click interaction type + * - **click-like**: generic click with no recognizable post-click pattern + */ +function classifyPostClickBehavior( + samples: CursorTelemetryPoint[], + clickSample: CursorTelemetryPoint, +): CursorInteractionCandidate["kind"] { + // Explicit double-click from uiohook + if (clickSample.interactionType === "double-click") { + return "double-click-like"; + } + + const clickTime = clickSample.timeMs; + + // Check for mouseup shortly after (drag detection) + const mouseUpAfter = samples.find( + (s) => + s.interactionType === "mouseup" && s.timeMs > clickTime && s.timeMs - clickTime < 3000, + ); + + if (mouseUpAfter) { + const dragDx = Math.abs(mouseUpAfter.cx - clickSample.cx); + const dragDy = Math.abs(mouseUpAfter.cy - clickSample.cy); + const dragDuration = mouseUpAfter.timeMs - clickTime; + + // Text selection: horizontal drag > 3% of screen, mostly horizontal, duration 200ms+ + if (dragDuration >= 200 && dragDx > 0.03 && dragDx > dragDy * 1.8) { + return "text-selection"; + } + } + + // Analyze trajectory in the 400ms-2000ms window after click + const moveSamples = samples.filter( + (s) => + s.timeMs > clickTime + 100 && + s.timeMs <= clickTime + 2000 && + (s.interactionType === "move" || !s.interactionType), + ); + + if (moveSamples.length < 3) { + // Very few move samples after click = cursor stayed still = text field click + return "text-field-click"; + } + + // Compute displacement from click position + let maxDist = 0; + let totalAbsDy = 0; + let totalAbsDx = 0; + for (const s of moveSamples) { + const dist = Math.hypot(s.cx - clickSample.cx, s.cy - clickSample.cy); + maxDist = Math.max(maxDist, dist); + totalAbsDx += Math.abs(s.cx - clickSample.cx); + totalAbsDy += Math.abs(s.cy - clickSample.cy); + } + + // Cursor barely moved after click: text field click (dwell) + if (maxDist < 0.02) { + return "text-field-click"; + } + + // Primarily downward movement after click: dropdown open + const lastMoveSample = moveSamples[moveSamples.length - 1]; + const netDy = lastMoveSample.cy - clickSample.cy; + + if (netDy > 0.03 && totalAbsDy > totalAbsDx * 1.5) { + return "dropdown-open"; + } + + // Primarily horizontal movement: text selection (fallback if no mouseup) + if (totalAbsDx > 0.03 && totalAbsDx > totalAbsDy * 1.8) { + return "text-selection"; + } + + return "click-like"; +} diff --git a/src/components/video-editor/types.test.ts b/src/components/video-editor/types.test.ts new file mode 100644 index 000000000..34b3502bf --- /dev/null +++ b/src/components/video-editor/types.test.ts @@ -0,0 +1,182 @@ +import { describe, expect, it } from "vitest"; +import { deriveNextId } from "./projectPersistence"; + +import { + extendAutoFullTrackClip, + findClipAtTimelineTime, + getTimelineDurationMs, + mapSourceTimeToTimelineTime, + mapTimelineTimeToSourceTime, + trimsToClips, +} from "./types"; + +describe("extendAutoFullTrackClip", () => { + it("extends the default full-track clip when metadata duration grows", () => { + expect( + extendAutoFullTrackClip( + [{ id: "clip-1", startMs: 0, endMs: 5_000, speed: 1 }], + "clip-1", + 5_000, + 8_000, + ), + ).toEqual([{ id: "clip-1", startMs: 0, endMs: 8_000, speed: 1 }]); + }); + + it("does not change a clip that no longer matches the auto-created shape", () => { + expect( + extendAutoFullTrackClip( + [{ id: "clip-1", startMs: 0, endMs: 4_000, speed: 1.5 }], + "clip-1", + 5_000, + 8_000, + ), + ).toBeNull(); + }); + + it("does not change multi-clip timelines", () => { + expect( + extendAutoFullTrackClip( + [ + { id: "clip-1", startMs: 0, endMs: 3_000, speed: 1 }, + { id: "clip-2", startMs: 4_000, endMs: 8_000, speed: 1 }, + ], + "clip-1", + 8_000, + 10_000, + ), + ).toBeNull(); + }); + + it("does not change clips when the duration does not grow", () => { + expect( + extendAutoFullTrackClip( + [{ id: "clip-1", startMs: 0, endMs: 8_000, speed: 1 }], + "clip-1", + 8_000, + 8_000, + ), + ).toBeNull(); + }); + + it("does not change clips when the auto-created clip id is missing", () => { + expect( + extendAutoFullTrackClip( + [{ id: "clip-1", startMs: 0, endMs: 5_000, speed: 1 }], + null, + 5_000, + 8_000, + ), + ).toBeNull(); + }); + + it("does not change clips when the previous auto-created end time is missing", () => { + expect( + extendAutoFullTrackClip( + [{ id: "clip-1", startMs: 0, endMs: 5_000, speed: 1 }], + "clip-1", + null, + 8_000, + ), + ).toBeNull(); + }); + + it("does not change clips when the reported duration shrinks", () => { + expect( + extendAutoFullTrackClip( + [{ id: "clip-1", startMs: 0, endMs: 8_000, speed: 1 }], + "clip-1", + 8_000, + 7_000, + ), + ).toBeNull(); + }); + + it("does not change clips when the tracked clip id no longer matches", () => { + expect( + extendAutoFullTrackClip( + [{ id: "clip-1", startMs: 0, endMs: 5_000, speed: 1 }], + "clip-2", + 5_000, + 8_000, + ), + ).toBeNull(); + }); + + it("does not change clips when the clip no longer starts at zero", () => { + expect( + extendAutoFullTrackClip( + [{ id: "clip-1", startMs: 250, endMs: 5_000, speed: 1 }], + "clip-1", + 5_000, + 8_000, + ), + ).toBeNull(); + }); +}); + +describe("clip timeline mapping", () => { + const clips = [ + { id: "clip-1", startMs: 0, endMs: 4_000, speed: 1 }, + { id: "clip-2", startMs: 6_000, endMs: 8_000, speed: 2 }, + ]; + + it("maps kept timeline time into source time", () => { + expect(mapTimelineTimeToSourceTime(1_500, clips)).toBe(1_500); + expect(mapTimelineTimeToSourceTime(7_000, clips)).toBe(8_000); + }); + + it("snaps timeline gaps to the nearest clip edge", () => { + expect(mapTimelineTimeToSourceTime(4_300, clips)).toBe(4_000); + expect(mapTimelineTimeToSourceTime(5_700, clips)).toBe(6_000); + }); + + it("maps kept source time back into timeline time", () => { + expect(mapSourceTimeToTimelineTime(1_500, clips)).toBe(1_500); + expect(mapSourceTimeToTimelineTime(8_000, clips)).toBe(7_000); + }); + + it("snaps removed source gaps to the nearest kept boundary", () => { + expect(mapSourceTimeToTimelineTime(4_200, clips)).toBe(4_000); + expect(mapSourceTimeToTimelineTime(5_900, clips)).toBe(6_000); + }); + + it("finds clips only inside visible kept spans", () => { + expect(findClipAtTimelineTime(500, clips)?.id).toBe("clip-1"); + expect(findClipAtTimelineTime(5_000, clips)).toBeNull(); + }); + + it("derives the next clip id after converting trim gaps into clip ids", () => { + const clipsFromTrims = trimsToClips( + [ + { id: "trim-gap-1", startMs: 1_000, endMs: 2_000 }, + { id: "trim-gap-2", startMs: 4_000, endMs: 5_000 }, + ], + 6_000, + ); + + expect(clipsFromTrims.map((clip) => clip.id)).toEqual(["clip-1", "clip-2", "clip-3"]); + expect( + deriveNextId( + "clip", + clipsFromTrims.map((clip) => clip.id), + ), + ).toBe(4); + }); +}); + +describe("getTimelineDurationMs", () => { + it("extends the timeline when a slow clip becomes longer than the source duration", () => { + expect( + getTimelineDurationMs( + [{ id: "clip-1", startMs: 0, endMs: 20_000, speed: 0.5 }], + 10_000, + ), + ).toBe(20_000); + }); + + it("keeps the source duration when speed edits make clips shorter", () => { + expect( + getTimelineDurationMs([{ id: "clip-1", startMs: 0, endMs: 5_000, speed: 2 }], 10_000), + ).toBe(10_000); + }); +}); diff --git a/src/components/video-editor/types.ts b/src/components/video-editor/types.ts index e6d831154..7ffff4a2c 100644 --- a/src/components/video-editor/types.ts +++ b/src/components/video-editor/types.ts @@ -1,50 +1,639 @@ -export type ZoomDepth = 1 | 2 | 3 | 4 | 5; +export type ZoomDepth = 1 | 2 | 3 | 4 | 5 | 6; export interface ZoomFocus { - cx: number; // normalized horizontal center (0-1) - cy: number; // normalized vertical center (0-1) + cx: number; // normalized horizontal center (0-1) + cy: number; // normalized vertical center (0-1) } +export type ZoomMode = "auto" | "manual"; + export interface ZoomRegion { - id: string; - startMs: number; - endMs: number; - depth: ZoomDepth; - focus: ZoomFocus; + id: string; + startMs: number; + endMs: number; + depth: ZoomDepth; + focus: ZoomFocus; + mode?: ZoomMode; +} + +export 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"; +} + +export interface CursorVisualSettings { + size: number; + smoothing: number; + motionBlur: number; + clickBounce: number; + clickBounceDuration: number; + clickEffect: CursorClickEffectStyle; + clickEffectColor: string; + clickEffectScale: number; + clickEffectOpacity: number; + clickEffectDurationMs: number; + sway: number; + style: CursorStyle; +} + +export type CursorStyle = + | "macos" + | "tahoe" + | "tahoe-inverted" + | "windows11" + | "dot" + | "figma" + | (string & {}); +export const DEFAULT_CURSOR_STYLE: CursorStyle = "tahoe"; + +export type CursorClickEffectStyle = "none" | "spotlight" | "ripple" | "echo"; +export const DEFAULT_CURSOR_CLICK_EFFECT: CursorClickEffectStyle = "none"; +export const DEFAULT_CURSOR_CLICK_EFFECT_COLOR = "#2563EB"; +export const DEFAULT_CURSOR_CLICK_EFFECT_SCALE = 1; +export const DEFAULT_CURSOR_CLICK_EFFECT_OPACITY = 1; +export const DEFAULT_CURSOR_CLICK_EFFECT_DURATION_MS = 600; + +export function normalizeCursorClickEffectStyle( + value: unknown, + fallback: CursorClickEffectStyle = DEFAULT_CURSOR_CLICK_EFFECT, +): CursorClickEffectStyle { + if (value === "burst") { + return "echo"; + } + + return value === "none" || value === "spotlight" || value === "ripple" || value === "echo" + ? value + : fallback; +} + +export function normalizeCursorClickEffectColor( + value: unknown, + fallback: string = DEFAULT_CURSOR_CLICK_EFFECT_COLOR, +): string { + if (typeof value !== "string") { + return fallback; + } + + const trimmed = value.trim(); + if (!/^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(trimmed)) { + return fallback; + } + + if (trimmed.length === 4) { + const [red, green, blue] = trimmed.slice(1).split(""); + return `#${red}${red}${green}${green}${blue}${blue}`.toUpperCase(); + } + + return trimmed.toUpperCase(); +} + +export type EditorEffectSection = + | "scene" + | "cursor" + | "captions" + | "caption" + | "webcam" + | "settings" + | "zoom" + | "frame" + | "crop" + | "extensions" + | "clip" + | "audio" + | `ext:${string}`; + +export type ZoomTransitionEasing = "recordly" | "glide" | "smooth" | "snappy" | "linear"; + +export type WebcamCorner = "top-left" | "top-right" | "bottom-left" | "bottom-right"; +export type WebcamPositionPreset = + | WebcamCorner + | "top-center" + | "center-left" + | "center" + | "center-right" + | "bottom-center" + | "custom"; + +export interface WebcamOverlaySettings { + enabled: boolean; + sourcePath: string | null; + timeOffsetMs: number; + mirror: boolean; + cropRegion: CropRegion; + corner: WebcamCorner; + positionPreset: WebcamPositionPreset; + positionX: number; + positionY: number; + size: number; + width: number; + height: number; + reactToZoom: boolean; + roundness: number; + /** Legacy pixel value retained only while loading older projects. */ + cornerRadius?: number; + shadow: number; + margin: number; +} + +export const DEFAULT_CURSOR_SIZE = 3.0; +export const DEFAULT_CURSOR_SMOOTHING = 0.67; +export const DEFAULT_CURSOR_MOTION_BLUR = 0.6; +export const DEFAULT_CURSOR_CLICK_BOUNCE = 2; +export const DEFAULT_CURSOR_CLICK_BOUNCE_DURATION = 350; +export const DEFAULT_CURSOR_SWAY = 0.4; +export const DEFAULT_ZOOM_SMOOTHNESS = 0.5; +export const DEFAULT_ZOOM_MOTION_BLUR = 0.35; +export interface ZoomMotionBlurTuning { + panVelocityThreshold: number; + zoomVelocityThreshold: number; + maxDirectionalBlurPx: number; + maxRadialBlurStrength: number; + panResponsePerSecond: number; + zoomResponsePerSecond: number; + zoomSafeZoneRadiusPx: number; +} + +export const DEFAULT_ZOOM_MOTION_BLUR_TUNING: ZoomMotionBlurTuning = { + panVelocityThreshold: 0, + zoomVelocityThreshold: 0, + maxDirectionalBlurPx: 41.8, + maxRadialBlurStrength: 1, + panResponsePerSecond: 11, + zoomResponsePerSecond: 9, + zoomSafeZoneRadiusPx: 6, +}; +export const DEFAULT_ZOOM_IN_DURATION_MS = 1522.575; +export const DEFAULT_ZOOM_IN_OVERLAP_MS = 500; +export const DEFAULT_ZOOM_OUT_DURATION_MS = 1015.05; +export const DEFAULT_CONNECTED_ZOOM_GAP_MS = 1500; +export const DEFAULT_CONNECTED_ZOOM_DURATION_MS = 1000; +export const DEFAULT_ZOOM_IN_EASING: ZoomTransitionEasing = "recordly"; +export const DEFAULT_ZOOM_OUT_EASING: ZoomTransitionEasing = "recordly"; +export const DEFAULT_CONNECTED_ZOOM_EASING: ZoomTransitionEasing = "glide"; +export const DEFAULT_WEBCAM_SIZE = 40; +export const DEFAULT_WEBCAM_REACT_TO_ZOOM = true; +export const DEFAULT_WEBCAM_ROUNDNESS = 42; +export const DEFAULT_WEBCAM_SHADOW = 0.67; +export const DEFAULT_WEBCAM_MARGIN = 24; +export const DEFAULT_WEBCAM_POSITION_PRESET: WebcamPositionPreset = "bottom-right"; +export const DEFAULT_WEBCAM_POSITION_X = 1; +export const DEFAULT_WEBCAM_POSITION_Y = 1; +export const DEFAULT_WEBCAM_TIME_OFFSET_MS = 0; + +export const DEFAULT_WEBCAM_OVERLAY: WebcamOverlaySettings = { + enabled: false, + sourcePath: null, + timeOffsetMs: DEFAULT_WEBCAM_TIME_OFFSET_MS, + mirror: true, + cropRegion: { x: 0, y: 0, width: 1, height: 1 }, + corner: "bottom-right", + positionPreset: DEFAULT_WEBCAM_POSITION_PRESET, + positionX: DEFAULT_WEBCAM_POSITION_X, + positionY: DEFAULT_WEBCAM_POSITION_Y, + size: DEFAULT_WEBCAM_SIZE, + width: DEFAULT_WEBCAM_SIZE, + height: DEFAULT_WEBCAM_SIZE, + reactToZoom: DEFAULT_WEBCAM_REACT_TO_ZOOM, + roundness: DEFAULT_WEBCAM_ROUNDNESS, + shadow: DEFAULT_WEBCAM_SHADOW, + margin: DEFAULT_WEBCAM_MARGIN, +}; + +export interface TrimRegion { + id: string; + startMs: number; + endMs: number; +} + +export interface ClipRegion { + id: string; + startMs: number; + endMs: number; + speed: number; + muted?: boolean; + showSourceAudio?: boolean; +} + +export function getClipSourceEndMs(clip: ClipRegion): number { + const displayDurationMs = Math.max(0, clip.endMs - clip.startMs); + const speed = Number.isFinite(clip.speed) && clip.speed > 0 ? clip.speed : 1; + return Math.round(clip.startMs + displayDurationMs * speed); +} + +export function getTimelineDurationMs(clips: ClipRegion[], sourceDurationMs: number): number { + const baseDurationMs = Math.max(0, Math.round(sourceDurationMs)); + if (clips.length === 0) { + return baseDurationMs; + } + + return clips.reduce( + (durationMs, clip) => Math.max(durationMs, Math.max(0, Math.round(clip.endMs))), + baseDurationMs, + ); +} + +export function sortClipRegions(clips: ClipRegion[]): ClipRegion[] { + return [...clips].sort((left, right) => left.startMs - right.startMs); +} + +function getSafeClipSpeed(clip: ClipRegion) { + return Number.isFinite(clip.speed) && clip.speed > 0 ? clip.speed : 1; +} + +function clampToNearestClipBoundary( + timeMs: number, + clips: ClipRegion[], + kind: "timeline" | "source", +) { + let nearestTimeMs = Math.round(timeMs); + let nearestDistance = Number.POSITIVE_INFINITY; + + for (const clip of clips) { + const boundaries = + kind === "timeline" + ? [clip.startMs, clip.endMs] + : [clip.startMs, getClipSourceEndMs(clip)]; + + for (const boundary of boundaries) { + const distance = Math.abs(timeMs - boundary); + if (distance < nearestDistance) { + nearestDistance = distance; + nearestTimeMs = Math.round(boundary); + } + } + } + + return nearestTimeMs; +} + +export function mapTimelineTimeToSourceTime(timeMs: number, clips: ClipRegion[]): number { + const roundedTimeMs = Math.round(timeMs); + const sortedClips = sortClipRegions(clips); + + for (const clip of sortedClips) { + if (roundedTimeMs < clip.startMs || roundedTimeMs > clip.endMs) { + continue; + } + + return Math.round(clip.startMs + (roundedTimeMs - clip.startMs) * getSafeClipSpeed(clip)); + } + + if (sortedClips.length === 0) { + return roundedTimeMs; + } + + return clampToNearestClipBoundary(roundedTimeMs, sortedClips, "timeline"); +} + +export function mapSourceTimeToTimelineTime(timeMs: number, clips: ClipRegion[]): number { + const roundedTimeMs = Math.round(timeMs); + const sortedClips = sortClipRegions(clips); + + for (const clip of sortedClips) { + const sourceEndMs = getClipSourceEndMs(clip); + if (roundedTimeMs < clip.startMs || roundedTimeMs > sourceEndMs) { + continue; + } + + return Math.round(clip.startMs + (roundedTimeMs - clip.startMs) / getSafeClipSpeed(clip)); + } + + if (sortedClips.length === 0) { + return roundedTimeMs; + } + + return clampToNearestClipBoundary(roundedTimeMs, sortedClips, "source"); } +export function findClipAtTimelineTime(timeMs: number, clips: ClipRegion[]): ClipRegion | null { + const roundedTimeMs = Math.round(timeMs); + return ( + sortClipRegions(clips).find( + (clip) => roundedTimeMs >= clip.startMs && roundedTimeMs < clip.endMs, + ) ?? null + ); +} + +export function extendAutoFullTrackClip( + clips: ClipRegion[], + autoClipId: string | null, + previousAutoEndMs: number | null, + nextTotalDurationMs: number, +): ClipRegion[] | null { + if ( + !autoClipId || + !Number.isFinite(previousAutoEndMs) || + !Number.isFinite(nextTotalDurationMs) || + nextTotalDurationMs <= (previousAutoEndMs ?? 0) || + clips.length !== 1 + ) { + return null; + } + + const [clip] = clips; + if ( + clip.id !== autoClipId || + clip.startMs !== 0 || + clip.speed !== 1 || + clip.endMs !== previousAutoEndMs + ) { + return null; + } + + return [{ ...clip, endMs: nextTotalDurationMs }]; +} + +/** Convert clip regions (kept segments) to trim regions (gaps to remove). */ +export function clipsToTrims(clips: ClipRegion[], totalDurationMs: number): TrimRegion[] { + if (clips.length === 0) return []; + const sorted = [...clips].sort((a, b) => a.startMs - b.startMs); + const trims: TrimRegion[] = []; + let cursor = 0; + let trimId = 1; + for (const clip of sorted) { + if (clip.startMs > cursor) { + trims.push({ id: `trim-gap-${trimId++}`, startMs: cursor, endMs: clip.startMs }); + } + cursor = getClipSourceEndMs(clip); + } + if (cursor < totalDurationMs) { + trims.push({ id: `trim-gap-${trimId++}`, startMs: cursor, endMs: totalDurationMs }); + } + return trims; +} + +/** Convert legacy trim regions to clip regions (complement). */ +export function trimsToClips(trims: TrimRegion[], totalDurationMs: number): ClipRegion[] { + if (trims.length === 0) return [{ id: "clip-1", startMs: 0, endMs: totalDurationMs, speed: 1 }]; + const sorted = [...trims].sort((a, b) => a.startMs - b.startMs); + const clips: ClipRegion[] = []; + let cursor = 0; + let clipId = 1; + for (const trim of sorted) { + if (trim.startMs > cursor) { + clips.push({ id: `clip-${clipId++}`, startMs: cursor, endMs: trim.startMs, speed: 1 }); + } + cursor = trim.endMs; + } + if (cursor < totalDurationMs) { + clips.push({ id: `clip-${clipId++}`, startMs: cursor, endMs: totalDurationMs, speed: 1 }); + } + return clips; +} + +export type AnnotationType = "text" | "image" | "figure" | "blur"; +export const BLUR_ANNOTATION_STRENGTH = 20; +export const BASE_PREVIEW_WIDTH = 1920; +export const BASE_PREVIEW_HEIGHT = 1080; + +export type ArrowDirection = + | "up" + | "down" + | "left" + | "right" + | "up-right" + | "up-left" + | "down-right" + | "down-left"; + +export interface FigureData { + arrowDirection: ArrowDirection; + color: string; + strokeWidth: number; +} + +export interface AnnotationPosition { + x: number; + y: number; +} + +export interface AnnotationSize { + width: number; + height: number; +} + +export interface AnnotationTextStyle { + color: string; + backgroundColor: string; + fontSize: number; // pixels + fontFamily: string; + fontWeight: "normal" | "bold"; + fontStyle: "normal" | "italic"; + textDecoration: "none" | "underline"; + textAlign: "left" | "center" | "right"; + borderRadius: number; +} + +function getDefaultAnnotationFontFamily() { + return '"SF Pro Display", "SF Pro Text", "Helvetica Neue", sans-serif'; +} + +export function getDefaultCaptionFontFamily() { + return '"SF Pro Text", "SF Pro Display", "Helvetica Neue", sans-serif'; +} + +export interface AnnotationRegion { + id: string; + startMs: number; + endMs: number; + type: AnnotationType; + content: string; // Legacy - still used for current type + textContent?: string; // Separate storage for text + imageContent?: string; // Separate storage for image data URL + position: AnnotationPosition; + size: AnnotationSize; + style: AnnotationTextStyle; + zIndex: number; + trackIndex?: number; + figureData?: FigureData; + blurIntensity?: number; + blurColor?: string; +} + +export const DEFAULT_ANNOTATION_POSITION: AnnotationPosition = { + x: 50, + y: 50, +}; + +export const DEFAULT_ANNOTATION_SIZE: AnnotationSize = { + width: 30, + height: 20, +}; + +export const DEFAULT_ANNOTATION_STYLE: AnnotationTextStyle = { + color: "#ffffff", + backgroundColor: "transparent", + fontSize: 32, + fontFamily: getDefaultAnnotationFontFamily(), + fontWeight: "bold", + fontStyle: "normal", + textDecoration: "none", + textAlign: "center", + borderRadius: 8, +}; + +export const DEFAULT_FIGURE_DATA: FigureData = { + arrowDirection: "right", + color: "#2563EB", + strokeWidth: 4, +}; + export interface CropRegion { - x: number; // 0-1 normalized - y: number; // 0-1 normalized - width: number; // 0-1 normalized - height: number; // 0-1 normalized + x: number; + y: number; + width: number; + height: number; } export const DEFAULT_CROP_REGION: CropRegion = { - x: 0, - y: 0, - width: 1, - height: 1, + x: 0, + y: 0, + width: 1, + height: 1, +}; + +export const ADVANCED_VERTICAL_PADDING_MAX = 250; + +export interface Padding { + top: number; + bottom: number; + left: number; + right: number; + linked?: boolean; +} + +export const DEFAULT_PADDING: Padding = { + top: 20, + bottom: 20, + left: 20, + right: 20, + linked: true, }; +export type { + SourceAudioTrackSetting, + SourceAudioTrackSettings, +} from "@/components/video-editor/audio/audioTypes"; + +export interface AudioRegion { + id: string; + startMs: number; + endMs: number; + audioPath: string; + volume: number; + normalize?: boolean; + trackIndex?: number; +} + +export interface CaptionCue { + id: string; + startMs: number; + endMs: number; + text: string; + words?: CaptionCueWord[]; +} + +export interface CaptionCueWord { + text: string; + startMs: number; + endMs: number; + leadingSpace?: boolean; +} + +export type AutoCaptionAnimation = "none" | "fade" | "rise" | "pop"; + +export interface AutoCaptionSettings { + enabled: boolean; + /** Show the hover ghost on the timeline caption track for click-to-add. */ + timelineQuickAdd: boolean; + language: string; + fontFamily: string; + fontSize: number; + bottomOffset: number; + maxWidth: number; + maxRows: number; + animationStyle: AutoCaptionAnimation; + boxRadius: number; + textColor: string; + inactiveTextColor: string; + backgroundOpacity: number; +} + +export const DEFAULT_AUTO_CAPTION_SETTINGS: AutoCaptionSettings = { + enabled: false, + timelineQuickAdd: true, + language: "auto", + fontFamily: getDefaultCaptionFontFamily(), + fontSize: 30, + bottomOffset: 3, + maxWidth: 62, + maxRows: 1, + animationStyle: "fade", + boxRadius: 17.5, + textColor: "#FFFFFF", + inactiveTextColor: "#A3A3A3", + backgroundOpacity: 0.9, +}; + +export type PlaybackSpeed = 0.25 | 0.5 | 0.75 | 1.25 | 1.5 | 1.75 | 2; + +export interface SpeedRegion { + id: string; + startMs: number; + endMs: number; + speed: PlaybackSpeed; +} + +export const SPEED_OPTIONS: Array<{ speed: PlaybackSpeed; label: string }> = [ + { speed: 0.25, label: "0.25×" }, + { speed: 0.5, label: "0.5×" }, + { speed: 0.75, label: "0.75×" }, + { speed: 1.25, label: "1.25×" }, + { speed: 1.5, label: "1.5×" }, + { speed: 1.75, label: "1.75×" }, + { speed: 2, label: "2×" }, +]; + +export const DEFAULT_PLAYBACK_SPEED: PlaybackSpeed = 1.5; export const ZOOM_DEPTH_SCALES: Record<ZoomDepth, number> = { - 1: 1.25, - 2: 1.5, - 3: 1.8, - 4: 2.2, - 5: 3.5, + 1: 1.25, + 2: 1.5, + 3: 1.8, + 4: 2.2, + 5: 3.5, + 6: 5.0, }; export const DEFAULT_ZOOM_DEPTH: ZoomDepth = 3; +export const DEFAULT_AUTO_ZOOM_DEPTH: ZoomDepth = 2; export function clampFocusToDepth(focus: ZoomFocus, _depth: ZoomDepth): ZoomFocus { - return { - cx: clamp(focus.cx, 0, 1), - cy: clamp(focus.cy, 0, 1), - }; + return { + cx: clamp(focus.cx, 0, 1), + cy: clamp(focus.cy, 0, 1), + }; } function clamp(value: number, min: number, max: number) { - if (Number.isNaN(value)) return (min + max) / 2; - return Math.min(max, Math.max(min, value)); + if (Number.isNaN(value)) return (min + max) / 2; + return Math.min(max, Math.max(min, value)); } diff --git a/src/components/video-editor/useNvidiaCudaExportOptIn.test.ts b/src/components/video-editor/useNvidiaCudaExportOptIn.test.ts new file mode 100644 index 000000000..d63ba97cd --- /dev/null +++ b/src/components/video-editor/useNvidiaCudaExportOptIn.test.ts @@ -0,0 +1,68 @@ +import { afterEach, describe, expect, it } from "vitest"; + +import { + isNvidiaCudaExportAvailable, + loadInitialNvidiaCudaExportOptIn, + NVIDIA_CUDA_EXPORT_OPT_IN_SETTING_KEY, + resolveNvidiaCudaExportOptIn, + saveNvidiaCudaExportOptIn, +} from "./useNvidiaCudaExportOptIn"; + +function stubElectronSettings(initialValues: Record<string, unknown> = {}) { + const store = new Map(Object.entries(initialValues)); + + Object.defineProperty(globalThis, "electronAPI", { + configurable: true, + value: { + getAppSetting: (key: string) => (store.has(key) ? store.get(key) : null), + setAppSetting: (key: string, value: unknown) => { + store.set(key, value); + return true; + }, + } as Pick<Window["electronAPI"], "getAppSetting" | "setAppSetting">, + }); + + return store; +} + +describe("nvidiaCudaExportOptIn", () => { + afterEach(() => { + Reflect.deleteProperty(globalThis, "electronAPI"); + }); + + it("only treats explicit native CUDA availability as available", () => { + expect( + isNvidiaCudaExportAvailable({ + capabilities: { nvidiaCuda: { available: true } }, + }), + ).toBe(true); + expect( + isNvidiaCudaExportAvailable({ + capabilities: { nvidiaCuda: { available: false } }, + }), + ).toBe(false); + expect(isNvidiaCudaExportAvailable({ capabilities: {} })).toBe(false); + expect(isNvidiaCudaExportAvailable(null)).toBe(false); + }); + + it("requires both user request and runtime availability before enabling", () => { + expect(resolveNvidiaCudaExportOptIn(true, true)).toBe(true); + expect(resolveNvidiaCudaExportOptIn(true, false)).toBe(false); + expect(resolveNvidiaCudaExportOptIn(false, true)).toBe(false); + expect(resolveNvidiaCudaExportOptIn(false, false)).toBe(false); + }); + + it("loads and saves the local opt-in flag through app settings", () => { + const store = stubElectronSettings({ + [NVIDIA_CUDA_EXPORT_OPT_IN_SETTING_KEY]: true, + }); + + expect(loadInitialNvidiaCudaExportOptIn()).toBe(true); + expect(saveNvidiaCudaExportOptIn(false)).toBe(true); + expect(store.get(NVIDIA_CUDA_EXPORT_OPT_IN_SETTING_KEY)).toBe(false); + }); + + it("defaults to disabled when the app setting is unavailable", () => { + expect(loadInitialNvidiaCudaExportOptIn()).toBe(false); + }); +}); diff --git a/src/components/video-editor/useNvidiaCudaExportOptIn.ts b/src/components/video-editor/useNvidiaCudaExportOptIn.ts new file mode 100644 index 000000000..75f2ecc3f --- /dev/null +++ b/src/components/video-editor/useNvidiaCudaExportOptIn.ts @@ -0,0 +1,86 @@ +import { useCallback, useEffect, useState } from "react"; +import { loadAppSetting, saveAppSetting } from "@/lib/appSettings"; + +export const NVIDIA_CUDA_EXPORT_OPT_IN_SETTING_KEY = "recordly.export.experimentalNvidiaCuda"; + +type NativeExportCapabilitiesResult = { + capabilities?: { + nvidiaCuda?: { + available?: boolean; + }; + }; +} | null; + +export function isNvidiaCudaExportAvailable(result: NativeExportCapabilitiesResult | undefined) { + return result?.capabilities?.nvidiaCuda?.available === true; +} + +export function resolveNvidiaCudaExportOptIn( + requested: boolean, + nvidiaCudaExportAvailable: boolean, +) { + return Boolean(requested && nvidiaCudaExportAvailable); +} + +export function loadInitialNvidiaCudaExportOptIn() { + return loadAppSetting<boolean>(NVIDIA_CUDA_EXPORT_OPT_IN_SETTING_KEY) === true; +} + +export function saveNvidiaCudaExportOptIn(enabled: boolean) { + return saveAppSetting(NVIDIA_CUDA_EXPORT_OPT_IN_SETTING_KEY, enabled); +} + +export function useNvidiaCudaExportOptIn({ onEnabled }: { onEnabled?: () => void } = {}) { + const [nvidiaCudaExportAvailable, setNvidiaCudaExportAvailable] = useState(false); + const [experimentalNvidiaCudaExport, setExperimentalNvidiaCudaExportState] = useState( + loadInitialNvidiaCudaExportOptIn, + ); + + useEffect(() => { + let cancelled = false; + + void (async () => { + try { + const result = await window.electronAPI?.getNativeExportCapabilities?.(); + if (cancelled) { + return; + } + + const available = isNvidiaCudaExportAvailable(result); + setNvidiaCudaExportAvailable(available); + if (!available) { + setExperimentalNvidiaCudaExportState(false); + } + } catch (error) { + if (cancelled) { + return; + } + console.warn("[export] Failed to load native export capabilities", error); + setNvidiaCudaExportAvailable(false); + setExperimentalNvidiaCudaExportState(false); + } + })(); + + return () => { + cancelled = true; + }; + }, []); + + const setExperimentalNvidiaCudaExport = useCallback( + (enabled: boolean) => { + const nextEnabled = resolveNvidiaCudaExportOptIn(enabled, nvidiaCudaExportAvailable); + setExperimentalNvidiaCudaExportState(nextEnabled); + saveNvidiaCudaExportOptIn(nextEnabled); + if (nextEnabled) { + onEnabled?.(); + } + }, + [nvidiaCudaExportAvailable, onEnabled], + ); + + return { + nvidiaCudaExportAvailable, + experimentalNvidiaCudaExport, + setExperimentalNvidiaCudaExport, + }; +} diff --git a/src/components/video-editor/videoEditorUtils.ts b/src/components/video-editor/videoEditorUtils.ts new file mode 100644 index 000000000..f5bc1e07f --- /dev/null +++ b/src/components/video-editor/videoEditorUtils.ts @@ -0,0 +1,18 @@ +export function summarizeErrorMessage(message: string): string { + const firstLine = message + .split(/\r?\n/) + .map((line) => line.trim()) + .find((line) => line.length > 0); + + return firstLine ?? message; +} + +export function cloneStructured<T>(value: T): T { + return globalThis.structuredClone(value); +} + +export function getErrorMessage(error: unknown): string { + if (error instanceof Error) return error.message; + if (typeof error === "string") return error.replace(/^Error:\s*/i, ""); + return "Something went wrong"; +} diff --git a/src/components/video-editor/videoPlayback/constants.ts b/src/components/video-editor/videoPlayback/constants.ts index e0dbf6baa..64a2f4e4f 100644 --- a/src/components/video-editor/videoPlayback/constants.ts +++ b/src/components/video-editor/videoPlayback/constants.ts @@ -1,7 +1,10 @@ import type { ZoomFocus } from "../types"; export const DEFAULT_FOCUS: ZoomFocus = { cx: 0.5, cy: 0.5 }; -export const TRANSITION_WINDOW_MS = 320; -export const SMOOTHING_FACTOR = 0.12; +export const TRANSITION_WINDOW_MS = 1015.05; +export const ZOOM_IN_TRANSITION_WINDOW_MS = TRANSITION_WINDOW_MS * 1.5; +export const ZOOM_OUT_EARLY_START_MS = 500; export const MIN_DELTA = 0.0001; export const VIEWPORT_SCALE = 0.8; +export const ZOOM_TRANSLATION_DEADZONE_PX = 1.25; +export const ZOOM_SCALE_DEADZONE = 0.002; diff --git a/src/components/video-editor/videoPlayback/cursorFollowCamera.test.ts b/src/components/video-editor/videoPlayback/cursorFollowCamera.test.ts new file mode 100644 index 000000000..34b571828 --- /dev/null +++ b/src/components/video-editor/videoPlayback/cursorFollowCamera.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it } from "vitest"; +import { computeCursorFollowFocus, createCursorFollowCameraState } from "./cursorFollowCamera"; + +describe("computeCursorFollowFocus", () => { + it("holds the camera while the cursor stays inside the safe zone", () => { + const state = createCursorFollowCameraState(); + const cursorSamples = [ + { timeMs: 0, cx: 0.5, cy: 0.5, interactionType: "move" as const }, + { timeMs: 100, cx: 0.6, cy: 0.58, interactionType: "move" as const }, + ]; + + const initialFocus = computeCursorFollowFocus( + state, + cursorSamples, + 0, + 2, + 1, + { cx: 0.5, cy: 0.5 }, + { snapToEdgesRatio: 0.25 }, + ); + const heldFocus = computeCursorFollowFocus( + state, + cursorSamples, + 100, + 2, + 1, + { cx: 0.5, cy: 0.5 }, + { snapToEdgesRatio: 0.25 }, + ); + + expect(initialFocus).toEqual({ cx: 0.5, cy: 0.5 }); + expect(heldFocus).toEqual(initialFocus); + }); + + it("recenters the cursor after it leaves the safe zone", () => { + const state = createCursorFollowCameraState(); + const cursorSamples = [ + { timeMs: 0, cx: 0.5, cy: 0.5, interactionType: "move" as const }, + { timeMs: 100, cx: 0.7, cy: 0.5, interactionType: "move" as const }, + { timeMs: 200, cx: 0.72, cy: 0.5, interactionType: "move" as const }, + ]; + + computeCursorFollowFocus( + state, + cursorSamples, + 0, + 2, + 1, + { cx: 0.5, cy: 0.5 }, + { snapToEdgesRatio: 0.25 }, + ); + + const firstShift = computeCursorFollowFocus( + state, + cursorSamples, + 100, + 2, + 1, + { cx: 0.5, cy: 0.5 }, + { snapToEdgesRatio: 0.25 }, + ); + const secondShift = computeCursorFollowFocus( + state, + cursorSamples, + 200, + 2, + 1, + { cx: 0.5, cy: 0.5 }, + { snapToEdgesRatio: 0.25 }, + ); + + expect(firstShift.cx).toBeCloseTo(0.7, 6); + expect(firstShift.cy).toBeCloseTo(0.5, 6); + expect(secondShift.cx).toBeCloseTo(0.7, 6); + expect(secondShift.cy).toBeCloseTo(0.5, 6); + }); + + it("clamps the camera when the cursor pushes past the stage edge", () => { + const state = createCursorFollowCameraState(); + const cursorSamples = [ + { timeMs: 0, cx: 0.5, cy: 0.5, interactionType: "move" as const }, + { timeMs: 100, cx: 1, cy: 1, interactionType: "move" as const }, + ]; + + computeCursorFollowFocus( + state, + cursorSamples, + 0, + 2, + 1, + { cx: 0.5, cy: 0.5 }, + { snapToEdgesRatio: 0.25 }, + ); + + const clampedFocus = computeCursorFollowFocus( + state, + cursorSamples, + 100, + 2, + 1, + { cx: 0.5, cy: 0.5 }, + { snapToEdgesRatio: 0.25 }, + ); + + expect(clampedFocus).toEqual({ cx: 0.75, cy: 0.75 }); + }); + + it("starts auto zooms from the saved region focus before following the cursor", () => { + const state = createCursorFollowCameraState(); + const cursorSamples = [ + { timeMs: 0, cx: 0.8, cy: 0.2, interactionType: "move" as const }, + { timeMs: 100, cx: 0.82, cy: 0.22, interactionType: "move" as const }, + ]; + + const initialFocus = computeCursorFollowFocus( + state, + cursorSamples, + 0, + 2, + 1, + { cx: 0.3, cy: 0.7 }, + { snapToEdgesRatio: 0.25 }, + ); + + expect(initialFocus).toEqual({ cx: 0.3, cy: 0.7 }); + }); +}); diff --git a/src/components/video-editor/videoPlayback/cursorFollowCamera.ts b/src/components/video-editor/videoPlayback/cursorFollowCamera.ts new file mode 100644 index 000000000..006352342 --- /dev/null +++ b/src/components/video-editor/videoPlayback/cursorFollowCamera.ts @@ -0,0 +1,201 @@ +import type { CursorTelemetryPoint, ZoomFocus } from "../types"; +import { interpolateCursorPosition } from "./cursorRenderer"; +import { clampFocusToScale } from "./focusUtils"; + +/** + * Cursor-follow camera. + * + * Keeps a persistent camera center while zoomed in. The camera only recenters + * after the cursor leaves an inner safe zone within the current zoomed view, + * shifting just enough to bring the cursor back inside that zone. + */ + +/** Default snap ratio for manual zoom regions */ +export const SNAP_TO_EDGES_RATIO_MANUAL = 0.25; +/** Snap ratio for system/auto zoom regions */ +export const SNAP_TO_EDGES_RATIO_AUTO = 0.25; + +export interface CursorFollowCameraState { + /** Whether the state has been initialized with a starting position */ + initialized: boolean; + /** Time of last update in ms (video time, not wall clock) */ + lastTimeMs: number; + /** Current camera focus while zoomed */ + focusX: number; + /** Current camera focus while zoomed */ + focusY: number; + /** Whether the camera was active (zoomed) on the previous frame */ + wasZoomed: boolean; + /** Whether the zoom reached full strength (≈1) — used to detect zoom-out */ + reachedFullZoom: boolean; + /** Frozen focus when zooming out (camera holds position) */ + frozenFocusX: number; + frozenFocusY: number; +} + +export interface CursorFollowConfig { + /** + * snapToEdgesRatio — how much of the screen edge pins the camera. + * 0.25 for manual zooms, 0.25 for auto/system zooms. + */ + snapToEdgesRatio: number; +} + +export const DEFAULT_CURSOR_FOLLOW_CONFIG: CursorFollowConfig = { + snapToEdgesRatio: SNAP_TO_EDGES_RATIO_AUTO, +}; + +export function createCursorFollowCameraState(): CursorFollowCameraState { + return { + initialized: false, + lastTimeMs: 0, + focusX: 0.5, + focusY: 0.5, + wasZoomed: false, + reachedFullZoom: false, + frozenFocusX: 0.5, + frozenFocusY: 0.5, + }; +} + +export function resetCursorFollowCamera(state: CursorFollowCameraState): void { + state.initialized = false; + state.lastTimeMs = 0; + state.focusX = 0.5; + state.focusY = 0.5; + state.wasZoomed = false; + state.reachedFullZoom = false; + state.frozenFocusX = 0.5; + state.frozenFocusY = 0.5; +} + +function clampSafeZoneRatio(ratio: number) { + if (!Number.isFinite(ratio)) { + return SNAP_TO_EDGES_RATIO_AUTO; + } + + return Math.max(0, Math.min(0.49, ratio)); +} + +function getVisibleHalfSpan(zoomScale: number) { + return 1 / (2 * Math.max(1, zoomScale)); +} + +function recenterFocusWhenCursorLeavesSafeZone( + currentFocus: ZoomFocus, + cursorFocus: ZoomFocus, + zoomScale: number, + safeZoneRatio: number, +): ZoomFocus { + const halfSpan = getVisibleHalfSpan(zoomScale); + const visibleSpan = halfSpan * 2; + const safeZoneInset = visibleSpan * clampSafeZoneRatio(safeZoneRatio); + + const safeLeft = currentFocus.cx - halfSpan + safeZoneInset; + const safeRight = currentFocus.cx + halfSpan - safeZoneInset; + const safeTop = currentFocus.cy - halfSpan + safeZoneInset; + const safeBottom = currentFocus.cy + halfSpan - safeZoneInset; + + let nextFocusX = currentFocus.cx; + let nextFocusY = currentFocus.cy; + + if (cursorFocus.cx < safeLeft) { + nextFocusX = cursorFocus.cx; + } else if (cursorFocus.cx > safeRight) { + nextFocusX = cursorFocus.cx; + } + + if (cursorFocus.cy < safeTop) { + nextFocusY = cursorFocus.cy; + } else if (cursorFocus.cy > safeBottom) { + nextFocusY = cursorFocus.cy; + } + + return clampFocusToScale( + { + cx: nextFocusX, + cy: nextFocusY, + }, + zoomScale, + ); +} + +/** + * Cursor follow: target focus computation. + * + * Computes the desired camera focus point based on a persistent camera center + * and an inner safe zone. The zoom transition layer handles smooth + * interpolation toward the returned focus. + * + * @returns The target focus point for this frame (normalized 0-1). + */ +export function computeCursorFollowFocus( + state: CursorFollowCameraState, + cursorSamples: CursorTelemetryPoint[], + timeMs: number, + zoomScale: number, + zoomStrength: number, + regionFocus: ZoomFocus, + config: CursorFollowConfig = DEFAULT_CURSOR_FOLLOW_CONFIG, +): ZoomFocus { + const clampedRegionFocus = clampFocusToScale(regionFocus, zoomScale); + + // If not zoomed (strength ≈ 0), reset state and return region focus + if (zoomStrength < 0.01) { + if (state.wasZoomed) { + state.wasZoomed = false; + state.initialized = false; + state.reachedFullZoom = false; + } + return clampedRegionFocus; + } + + const cursorPos = interpolateCursorPosition(cursorSamples, timeMs); + if (!cursorPos) { + if (state.initialized) { + return { cx: state.focusX, cy: state.focusY }; + } + + return clampedRegionFocus; + } + + // Track when zoom reaches full strength + if (zoomStrength >= 0.99) { + state.reachedFullZoom = true; + } + + // Zooming out: was fully zoomed but strength is now dropping — freeze camera + if (state.reachedFullZoom && zoomStrength < 0.99) { + return { cx: state.frozenFocusX, cy: state.frozenFocusY }; + } + + const timeWentBackwards = state.initialized && timeMs + 0.5 < state.lastTimeMs; + + if (!state.initialized || !state.wasZoomed || timeWentBackwards) { + const initialFocus = clampedRegionFocus; + state.lastTimeMs = timeMs; + state.initialized = true; + state.wasZoomed = true; + state.focusX = initialFocus.cx; + state.focusY = initialFocus.cy; + state.frozenFocusX = initialFocus.cx; + state.frozenFocusY = initialFocus.cy; + return initialFocus; + } + + state.lastTimeMs = timeMs; + + const targetFocus = recenterFocusWhenCursorLeavesSafeZone( + { cx: state.focusX, cy: state.focusY }, + { cx: cursorPos.cx, cy: cursorPos.cy }, + zoomScale, + config.snapToEdgesRatio, + ); + + state.focusX = targetFocus.cx; + state.focusY = targetFocus.cy; + state.frozenFocusX = targetFocus.cx; + state.frozenFocusY = targetFocus.cy; + + return targetFocus; +} diff --git a/src/components/video-editor/videoPlayback/cursorLoopTelemetry.ts b/src/components/video-editor/videoPlayback/cursorLoopTelemetry.ts new file mode 100644 index 000000000..87ed503c7 --- /dev/null +++ b/src/components/video-editor/videoPlayback/cursorLoopTelemetry.ts @@ -0,0 +1,317 @@ +import type { CursorTelemetryPoint, TrimRegion } from "../types"; + +const LOOP_CURSOR_FREEZE_DURATION_MS = 670; +const LOOP_CURSOR_RETURN_STEPS = 20; +const LOOP_CURSOR_SETTLE_DURATION_MS = 120; +const CURSOR_TRAILING_MOVEMENT_EPSILON = 0.0015; + +function clamp(value: number, min: number, max: number) { + return Math.min(max, Math.max(min, value)); +} + +function interpolateCursorPosition( + samples: CursorTelemetryPoint[], + timeMs: number, +): { cx: number; cy: number } | null { + if (!samples || samples.length === 0) return null; + + if (timeMs <= samples[0].timeMs) { + return { cx: samples[0].cx, cy: samples[0].cy }; + } + + if (timeMs >= samples[samples.length - 1].timeMs) { + return { cx: samples[samples.length - 1].cx, cy: samples[samples.length - 1].cy }; + } + + let lo = 0; + let hi = samples.length - 1; + while (lo < hi - 1) { + const mid = (lo + hi) >> 1; + if (samples[mid].timeMs <= timeMs) { + lo = mid; + } else { + hi = mid; + } + } + + const a = samples[lo]; + const b = samples[hi]; + const span = b.timeMs - a.timeMs; + if (span <= 0) return { cx: a.cx, cy: a.cy }; + + const t = (timeMs - a.timeMs) / span; + return { + cx: a.cx + (b.cx - a.cx) * t, + cy: a.cy + (b.cy - a.cy) * t, + }; +} + +function findFirstStableCursorType(samples: CursorTelemetryPoint[]) { + for (const sample of samples) { + if (!sample.cursorType) { + continue; + } + + if ( + sample.interactionType === "click" || + sample.interactionType === "double-click" || + sample.interactionType === "right-click" || + sample.interactionType === "middle-click" + ) { + continue; + } + + return sample.cursorType; + } + + return samples[0]?.cursorType ?? "arrow"; +} + +function easeOutQuint(progress: number) { + return 1 - (1 - progress) ** 5; +} + +function findLastMovingSampleTime(samples: CursorTelemetryPoint[]) { + if (samples.length <= 1) { + return samples[0]?.timeMs ?? 0; + } + + for (let index = samples.length - 1; index > 0; index -= 1) { + const curr = samples[index]; + const prev = samples[index - 1]; + const distance = Math.hypot(curr.cx - prev.cx, curr.cy - prev.cy); + if (distance > CURSOR_TRAILING_MOVEMENT_EPSILON) { + return curr.timeMs; + } + } + + return samples[samples.length - 1].timeMs; +} + +function findLatestStableCursorType(samples: CursorTelemetryPoint[], timeMs: number) { + let lo = 0; + let hi = samples.length - 1; + while (lo < hi) { + const mid = Math.ceil((lo + hi) / 2); + if (samples[mid].timeMs <= timeMs) { + lo = mid; + } else { + hi = mid - 1; + } + } + + for (let index = lo; index >= 0; index -= 1) { + const sample = samples[index]; + if (sample.timeMs > timeMs) { + continue; + } + + if (!sample.cursorType) { + continue; + } + + if ( + sample.interactionType === "click" || + sample.interactionType === "double-click" || + sample.interactionType === "right-click" || + sample.interactionType === "middle-click" + ) { + continue; + } + + return sample.cursorType; + } + + return findLatestSample(samples, timeMs)?.cursorType ?? "arrow"; +} + +function findLatestSample(samples: CursorTelemetryPoint[], timeMs: number) { + if (samples.length === 0) return null; + + let lo = 0; + let hi = samples.length - 1; + while (lo < hi) { + const mid = Math.ceil((lo + hi) / 2); + if (samples[mid].timeMs <= timeMs) { + lo = mid; + } else { + hi = mid - 1; + } + } + + return samples[lo]?.timeMs <= timeMs ? samples[lo] : null; +} + +export function buildLoopedCursorTelemetry( + samples: CursorTelemetryPoint[], + totalDurationMs: number, + timelineStartMs = 0, +): CursorTelemetryPoint[] { + if (!samples || samples.length === 0) { + return []; + } + + const timelineEndMs = Math.max(0, Math.round(totalDurationMs)); + const timelineStartClampedMs = clamp(Math.round(timelineStartMs), 0, timelineEndMs); + if (timelineEndMs <= timelineStartClampedMs) { + return samples; + } + + const boundedSamples = samples.filter( + (sample) => sample.timeMs >= timelineStartClampedMs && sample.timeMs <= timelineEndMs, + ); + if (boundedSamples.length === 0) { + return samples; + } + + const firstSample = boundedSamples[0]; + const lastSample = boundedSamples[boundedSamples.length - 1]; + const maxFreezeWindowMs = timelineEndMs - firstSample.timeMs; + if (maxFreezeWindowMs <= 1) { + return boundedSamples; + } + + const freezeDurationMs = Math.min(LOOP_CURSOR_FREEZE_DURATION_MS, maxFreezeWindowMs); + const motionEndMs = timelineEndMs - freezeDurationMs; + const clampedSettleDurationMs = Math.min( + LOOP_CURSOR_SETTLE_DURATION_MS, + Math.max(0, freezeDurationMs - 1), + ); + const returnMotionDurationMs = Math.max(1, freezeDurationMs - clampedSettleDurationMs); + const sourceStartMs = firstSample.timeMs; + const sourceEndMs = Math.max(sourceStartMs, findLastMovingSampleTime(boundedSamples)); + const sourceDurationMs = Math.max(1, sourceEndMs - sourceStartMs); + // Keep remapped times in source-time coordinates so cursor lookups + // (which always use video.currentTime, i.e. source time) stay correct + // even when the visible timeline window doesn't start at 0. + const motionDurationMs = Math.max(1, motionEndMs - timelineStartClampedMs); + const startingCursorType = findFirstStableCursorType(boundedSamples); + const loopedSamples: CursorTelemetryPoint[] = [ + { + ...firstSample, + timeMs: timelineStartClampedMs, + interactionType: undefined, + cursorType: startingCursorType, + }, + ]; + + for (const sample of boundedSamples) { + const progress = clamp((sample.timeMs - sourceStartMs) / sourceDurationMs, 0, 1); + const mappedTimeMs = Math.round(timelineStartClampedMs + motionDurationMs * progress); + + if (mappedTimeMs <= loopedSamples[loopedSamples.length - 1].timeMs) { + loopedSamples[loopedSamples.length - 1] = { + ...sample, + timeMs: loopedSamples[loopedSamples.length - 1].timeMs, + }; + continue; + } + + loopedSamples.push({ + ...sample, + timeMs: mappedTimeMs, + }); + } + + const returnStartPoint = interpolateCursorPosition(loopedSamples, motionEndMs) ?? { + cx: lastSample.cx, + cy: lastSample.cy, + }; + const returnStartCursorType = findLatestStableCursorType(loopedSamples, motionEndMs); + const returnMotionStartMs = motionEndMs; + + for (let step = 0; step <= LOOP_CURSOR_RETURN_STEPS; step += 1) { + const progress = step / LOOP_CURSOR_RETURN_STEPS; + const easedProgress = easeOutQuint(progress); + const timeMs = Math.round(returnMotionStartMs + returnMotionDurationMs * progress); + loopedSamples.push({ + timeMs, + cx: returnStartPoint.cx + (firstSample.cx - returnStartPoint.cx) * easedProgress, + cy: returnStartPoint.cy + (firstSample.cy - returnStartPoint.cy) * easedProgress, + interactionType: progress > 0 ? "move" : undefined, + cursorType: + progress >= 1 + ? startingCursorType + : progress <= 0 + ? returnStartCursorType + : undefined, + }); + } + + if (clampedSettleDurationMs > 0) { + const settleSteps = Math.max( + 2, + Math.round( + (clampedSettleDurationMs / LOOP_CURSOR_FREEZE_DURATION_MS) * + LOOP_CURSOR_RETURN_STEPS, + ), + ); + const settleStartMs = returnMotionStartMs + returnMotionDurationMs; + + for (let step = 1; step <= settleSteps; step += 1) { + const progress = step / settleSteps; + const timeMs = Math.round(settleStartMs + clampedSettleDurationMs * progress); + loopedSamples.push({ + timeMs, + cx: firstSample.cx, + cy: firstSample.cy, + interactionType: "move", + cursorType: startingCursorType, + }); + } + } + + return loopedSamples; +} + +export function getDisplayedTimelineEndMs(totalDurationMs: number, trimRegions: TrimRegion[]) { + return getDisplayedTimelineWindowMs(totalDurationMs, trimRegions).endMs; +} + +export function getDisplayedTimelineWindowMs(totalDurationMs: number, trimRegions: TrimRegion[]) { + const durationMs = Math.max(0, Math.round(totalDurationMs)); + if (durationMs <= 0) { + return { startMs: 0, endMs: 0 }; + } + + if (!trimRegions || trimRegions.length === 0) { + return { startMs: 0, endMs: durationMs }; + } + + const sortedTrimRegions = trimRegions + .map((region) => ({ + startMs: clamp(Math.round(region.startMs), 0, durationMs), + endMs: clamp(Math.round(region.endMs), 0, durationMs), + })) + .filter((region) => region.endMs > region.startMs) + .sort((a, b) => a.startMs - b.startMs); + + if (sortedTrimRegions.length === 0) { + return { startMs: 0, endMs: durationMs }; + } + + let cursorMs = 0; + let firstVisibleStartMs: number | null = null; + let lastVisibleEndMs = 0; + + for (const trimRegion of sortedTrimRegions) { + if (trimRegion.startMs > cursorMs) { + if (firstVisibleStartMs === null) { + firstVisibleStartMs = cursorMs; + } + lastVisibleEndMs = trimRegion.startMs; + } + cursorMs = Math.max(cursorMs, trimRegion.endMs); + } + + if (cursorMs < durationMs) { + if (firstVisibleStartMs === null) { + firstVisibleStartMs = cursorMs; + } + lastVisibleEndMs = durationMs; + } + + const clampedEndMs = clamp(lastVisibleEndMs, 0, durationMs); + const clampedStartMs = clamp(firstVisibleStartMs ?? 0, 0, clampedEndMs); + return { startMs: clampedStartMs, endMs: clampedEndMs }; +} diff --git a/src/components/video-editor/videoPlayback/cursorRenderer.ts b/src/components/video-editor/videoPlayback/cursorRenderer.ts new file mode 100644 index 000000000..7988c857f --- /dev/null +++ b/src/components/video-editor/videoPlayback/cursorRenderer.ts @@ -0,0 +1,1714 @@ +import { Assets, BlurFilter, Container, Graphics, Sprite, Texture } from "pixi.js"; +import { MotionBlurFilter } from "pixi-filters/motion-blur"; +import minimalCursorUrl from "@/assets/cursors/custom/minimal-cursor.svg"; +import { getRenderableAssetUrl } from "@/lib/assetPath"; +import { + type CursorClickEffectStyle, + type CursorStyle, + type CursorTelemetryPoint, + DEFAULT_CURSOR_CLICK_BOUNCE_DURATION, + DEFAULT_CURSOR_CLICK_EFFECT, + DEFAULT_CURSOR_CLICK_EFFECT_COLOR, + DEFAULT_CURSOR_CLICK_EFFECT_DURATION_MS, + DEFAULT_CURSOR_CLICK_EFFECT_OPACITY, + DEFAULT_CURSOR_CLICK_EFFECT_SCALE, + DEFAULT_CURSOR_STYLE, + normalizeCursorClickEffectColor, +} from "../types"; +import { getCursorViewportScale } from "./cursorScale"; +import { computeCursorSwayRotation } from "./cursorSway"; +import { type CursorViewportRect, projectCursorPositionToViewport } from "./cursorViewport"; +import { + type CursorSpringTuning, + createSpringState, + getCursorSpringConfig, + resetSpringState, + stepSpringValue, +} from "./motionSmoothing"; +import { cursorSetAssets, getCursorStyleSizeMultiplier } from "./uploadedCursorAssets"; +import { computeDirectionalMotionBlur } from "./zoomTransform"; + +type CursorAssetKey = NonNullable<CursorTelemetryPoint["cursorType"]>; +type StatefulCursorStyle = Extract<CursorStyle, "macos" | "tahoe" | "tahoe-inverted" | "windows11">; +type SingleCursorStyle = Extract<CursorStyle, "dot" | "figma">; +type CursorPackStyle = Exclude<CursorStyle, StatefulCursorStyle | SingleCursorStyle>; +type CursorPackVariant = "default" | "pointer"; + +type LoadedCursorAsset = { + texture: Texture; + image: HTMLImageElement; + aspectRatio: number; + anchorX: number; + anchorY: number; +}; + +type LoadedCursorPackAssets = Record<CursorPackVariant, LoadedCursorAsset>; + +type CursorPackSource = { + defaultUrl: string; + pointerUrl: string; + defaultAnchor: { x: number; y: number }; + pointerAnchor: { x: number; y: number }; +}; + +export type NativeCursorAtlasEntry = { + cursorType: CursorAssetKey; + index: number; + x: number; + y: number; + width: number; + height: number; + anchorX: number; + anchorY: number; + aspectRatio: number; +}; + +export type NativeCursorAtlas = { + style: CursorStyle; + width: number; + height: number; + dataUrl: string; + entries: NativeCursorAtlasEntry[]; +}; + +/** + * Configuration for cursor rendering. + */ +export interface CursorRenderConfig { + /** Base cursor height in pixels (at reference width of 1920px) */ + dotRadius: number; + /** Minimum viewport scale applied to cursor sizing. */ + minViewportScale: number; + /** Cursor fill color (hex number for PixiJS) */ + dotColor: number; + /** Cursor opacity (0–1) */ + dotAlpha: number; + /** Unused, kept for interface compatibility */ + trailLength: number; + /** Smoothing factor for cursor interpolation (0–1, lower = smoother/slower) */ + smoothingFactor: number; + /** Optional multipliers applied on top of the derived cursor spring config. */ + springTuning: CursorSpringTuning; + /** Directional cursor motion blur amount. */ + motionBlur: number; + /** Click bounce multiplier. */ + clickBounce: number; + /** Click bounce duration in milliseconds. */ + clickBounceDuration: number; + /** Click effect graphics rendered around the pointer. */ + clickEffect: CursorClickEffectStyle; + /** Click effect base color. */ + clickEffectColor: string; + /** Click effect size multiplier. */ + clickEffectScale: number; + /** Click effect opacity multiplier. */ + clickEffectOpacity: number; + /** Click effect duration in milliseconds. */ + clickEffectDurationMs: number; + /** Cursor sway multiplier. */ + sway: number; + /** Cursor visual style. */ + style: CursorStyle; +} + +const MIN_CURSOR_VIEWPORT_SCALE = 0; +// Keep the cursor visually responsive while sharing the camera's frame-rate +// normalization, kernel, and directional offset formula. +const CURSOR_DIRECTIONAL_BLUR_STRENGTH = 2; +const CURSOR_TIME_DISCONTINUITY_MS = 100; +const CURSOR_SWAY_SMOOTHING_MULTIPLIER = 0.7; +const CURSOR_SWAY_SMOOTHING_OFFSET = 0.18; +const CURSOR_SVG_DROP_SHADOW_FILTER = "drop-shadow(0px 2px 3px rgba(0, 0, 0, 0.35))"; +const CURSOR_SHADOW_COLOR = 0x000000; +const CURSOR_SHADOW_ALPHA = 0.35; +const CURSOR_SHADOW_OFFSET_X = 0; +const CURSOR_SHADOW_OFFSET_Y = 2; +const CURSOR_SHADOW_BLUR = 3; +const CURSOR_SHADOW_PADDING = 12; +const MIN_RASTERIZED_CURSOR_HEIGHT = 512; +const NATIVE_CURSOR_ATLAS_DRAW_HEIGHT = 256; +const NATIVE_CURSOR_ATLAS_PADDING = 2; + +export const DEFAULT_CURSOR_CONFIG: CursorRenderConfig = { + dotRadius: 28, + minViewportScale: MIN_CURSOR_VIEWPORT_SCALE, + dotColor: 0xffffff, + dotAlpha: 0.95, + trailLength: 0, + smoothingFactor: 0.18, + springTuning: { + stiffnessMultiplier: 1, + dampingMultiplier: 1, + massMultiplier: 1, + }, + motionBlur: 0, + clickBounce: 1, + clickBounceDuration: DEFAULT_CURSOR_CLICK_BOUNCE_DURATION, + clickEffect: DEFAULT_CURSOR_CLICK_EFFECT, + clickEffectColor: DEFAULT_CURSOR_CLICK_EFFECT_COLOR, + clickEffectScale: DEFAULT_CURSOR_CLICK_EFFECT_SCALE, + clickEffectOpacity: DEFAULT_CURSOR_CLICK_EFFECT_OPACITY, + clickEffectDurationMs: DEFAULT_CURSOR_CLICK_EFFECT_DURATION_MS, + sway: 0, + style: DEFAULT_CURSOR_STYLE, +}; +let cursorAssetsPromise: Promise<void> | null = null; +let cursorPackAssetsPromise: Promise<void> | null = null; +let loadedCursorPackSourcesSignature = ""; +let loadedCursorAssets: Partial<Record<CursorAssetKey, LoadedCursorAsset>> = {}; +let loadedCursorSetAssets: Partial< + Record<StatefulCursorStyle, Partial<Record<CursorAssetKey, LoadedCursorAsset>>> +> = {}; +let loadedCursorStyleAssets: Partial<Record<SingleCursorStyle, LoadedCursorAsset>> = {}; +let loadedCursorPackAssets: Partial<Record<string, LoadedCursorPackAssets>> = {}; +const warnedMissingCursorPackStyles = new Set<string>(); +const SUPPORTED_CURSOR_KEYS: CursorAssetKey[] = [ + "arrow", + "text", + "pointer", + "crosshair", + "open-hand", + "closed-hand", + "resize-ew", + "resize-ns", + "not-allowed", +]; + +const CURSOR_PACK_POINTER_TYPES = new Set<CursorAssetKey>(["pointer", "open-hand", "closed-hand"]); +const BUILTIN_CURSOR_PACK_SOURCES: Record<string, CursorPackSource> = {}; + +function getCursorPackSources(): Record<string, CursorPackSource> { + return { ...BUILTIN_CURSOR_PACK_SOURCES }; +} + +function buildCursorPackSourcesSignature(sources: Record<string, CursorPackSource>): string { + return Object.entries(sources) + .sort(([left], [right]) => left.localeCompare(right)) + .map( + ([style, source]) => + `${style}:${source.defaultUrl}:${source.pointerUrl}:${source.defaultAnchor.x}:${source.defaultAnchor.y}:${source.pointerAnchor.x}:${source.pointerAnchor.y}`, + ) + .join("|"); +} + +function isStatefulCursorStyle(style: CursorStyle): style is StatefulCursorStyle { + return ( + style === "macos" || + style === "tahoe" || + style === "tahoe-inverted" || + style === "windows11" + ); +} + +function isSingleCursorStyle(style: CursorStyle): style is SingleCursorStyle { + return style === "dot" || style === "figma"; +} + +function resolveCursorPackVariant(cursorType: CursorAssetKey): CursorPackVariant { + return CURSOR_PACK_POINTER_TYPES.has(cursorType) ? "pointer" : "default"; +} + +async function createCursorStyleAsset(style: SingleCursorStyle): Promise<LoadedCursorAsset> { + if (style === "figma") { + const image = await loadImage(minimalCursorUrl); + const sourceCanvas = document.createElement("canvas"); + sourceCanvas.width = image.naturalWidth; + sourceCanvas.height = image.naturalHeight; + const sourceCtx = sourceCanvas.getContext("2d")!; + sourceCtx.drawImage(image, 0, 0); + const trimmed = trimCanvasToAlpha(sourceCanvas, { x: 40, y: 22 }); + await Assets.load(trimmed.dataUrl); + const trimmedImage = await loadImage(trimmed.dataUrl); + const texture = configureCursorTexture(Texture.from(trimmed.dataUrl)); + + return { + texture, + image: trimmedImage, + aspectRatio: trimmed.height > 0 ? trimmed.width / trimmed.height : 1, + anchorX: trimmed.hotspot && trimmed.width > 0 ? trimmed.hotspot.x / trimmed.width : 0, + anchorY: trimmed.hotspot && trimmed.height > 0 ? trimmed.hotspot.y / trimmed.height : 0, + }; + } + + const canvas = document.createElement("canvas"); + canvas.width = 112; + canvas.height = 112; + const ctx = canvas.getContext("2d")!; + const cx = canvas.width / 2; + const cy = canvas.height / 2; + const radius = 26; + ctx.fillStyle = "#ffffff"; + ctx.strokeStyle = "rgba(15, 23, 42, 0.88)"; + ctx.lineWidth = 10; + ctx.beginPath(); + ctx.arc(cx, cy, radius, 0, Math.PI * 2); + ctx.fill(); + ctx.stroke(); + + const dataUrl = canvas.toDataURL("image/png"); + await Assets.load(dataUrl); + const image = await loadImage(dataUrl); + const texture = configureCursorTexture(Texture.from(dataUrl)); + + return { + texture, + image, + aspectRatio: canvas.height > 0 ? canvas.width / canvas.height : 1, + anchorX: 0.5, + anchorY: 0.5, + }; +} + +async function createCursorPackAsset( + url: string, + anchor: { x: number; y: number }, +): Promise<LoadedCursorAsset> { + const renderableUrl = await getRenderableAssetUrl(url); + await Assets.load(renderableUrl); + const image = await loadImage(renderableUrl); + const texture = configureCursorTexture(Texture.from(renderableUrl)); + + return { + texture, + image, + aspectRatio: image.naturalHeight > 0 ? image.naturalWidth / image.naturalHeight : 1, + anchorX: clamp(anchor.x, 0, 1), + anchorY: clamp(anchor.y, 0, 1), + }; +} + +async function createRasterizedCursorAsset( + url: string, + anchor: { x: number; y: number }, + options: { preserveCanvas?: boolean } = {}, +): Promise<LoadedCursorAsset> { + const image = await loadImage(url); + const sourceCanvas = document.createElement("canvas"); + const rasterScale = + image.naturalHeight > 0 + ? Math.max(1, MIN_RASTERIZED_CURSOR_HEIGHT / image.naturalHeight) + : 1; + sourceCanvas.width = Math.max(1, Math.round(image.naturalWidth * rasterScale)); + sourceCanvas.height = Math.max(1, Math.round(image.naturalHeight * rasterScale)); + const sourceCtx = sourceCanvas.getContext("2d"); + if (!sourceCtx) { + await Assets.load(url); + const texture = configureCursorTexture(Texture.from(url)); + return { + texture, + image, + aspectRatio: image.naturalHeight > 0 ? image.naturalWidth / image.naturalHeight : 1, + anchorX: clamp(anchor.x, 0, 1), + anchorY: clamp(anchor.y, 0, 1), + }; + } + + sourceCtx.clearRect(0, 0, sourceCanvas.width, sourceCanvas.height); + sourceCtx.drawImage(image, 0, 0, sourceCanvas.width, sourceCanvas.height); + + if (options.preserveCanvas) { + const dataUrl = sourceCanvas.toDataURL("image/png"); + await Assets.load(dataUrl); + const rasterizedImage = await loadImage(dataUrl); + const texture = configureCursorTexture(Texture.from(dataUrl)); + return { + texture, + image: rasterizedImage, + aspectRatio: sourceCanvas.height > 0 ? sourceCanvas.width / sourceCanvas.height : 1, + anchorX: clamp(anchor.x, 0, 1), + anchorY: clamp(anchor.y, 0, 1), + }; + } + + const trimmed = trimCanvasToAlpha(sourceCanvas, { + x: sourceCanvas.width * clamp(anchor.x, 0, 1), + y: sourceCanvas.height * clamp(anchor.y, 0, 1), + }); + await Assets.load(trimmed.dataUrl); + const trimmedImage = await loadImage(trimmed.dataUrl); + const texture = configureCursorTexture(Texture.from(trimmed.dataUrl)); + + return { + texture, + image: trimmedImage, + aspectRatio: trimmed.height > 0 ? trimmed.width / trimmed.height : 1, + anchorX: + trimmed.hotspot && trimmed.width > 0 + ? clamp(trimmed.hotspot.x / trimmed.width, 0, 1) + : clamp(anchor.x, 0, 1), + anchorY: + trimmed.hotspot && trimmed.height > 0 + ? clamp(trimmed.hotspot.y / trimmed.height, 0, 1) + : clamp(anchor.y, 0, 1), + }; +} + +function loadImage(dataUrl: string) { + return new Promise<HTMLImageElement>((resolve, reject) => { + const image = new Image(); + image.onload = () => resolve(image); + image.onerror = () => + reject(new Error(`Failed to load cursor image: ${dataUrl.slice(0, 128)}`)); + image.src = dataUrl; + }); +} + +function clamp(value: number, min: number, max: number) { + return Math.min(max, Math.max(min, value)); +} + +function configureCursorTexture(texture: Texture) { + texture.source.scaleMode = "linear"; + texture.source.autoGenerateMipmaps = true; + return texture; +} + +function trimCanvasToAlpha(canvas: HTMLCanvasElement, hotspot?: { x: number; y: number }) { + const ctx = canvas.getContext("2d"); + if (!ctx) { + return { + dataUrl: canvas.toDataURL("image/png"), + width: canvas.width, + height: canvas.height, + hotspot, + }; + } + + const { width, height } = canvas; + const imageData = ctx.getImageData(0, 0, width, height); + const { data } = imageData; + let minX = width; + let minY = height; + let maxX = -1; + let maxY = -1; + + for (let y = 0; y < height; y += 1) { + for (let x = 0; x < width; x += 1) { + const alpha = data[(y * width + x) * 4 + 3]; + if (alpha === 0) { + continue; + } + + minX = Math.min(minX, x); + minY = Math.min(minY, y); + maxX = Math.max(maxX, x); + maxY = Math.max(maxY, y); + } + } + + if (maxX < minX || maxY < minY) { + return { + dataUrl: canvas.toDataURL("image/png"), + width, + height, + hotspot, + }; + } + + const croppedWidth = maxX - minX + 1; + const croppedHeight = maxY - minY + 1; + const croppedCanvas = document.createElement("canvas"); + croppedCanvas.width = croppedWidth; + croppedCanvas.height = croppedHeight; + const croppedCtx = croppedCanvas.getContext("2d")!; + croppedCtx.drawImage( + canvas, + minX, + minY, + croppedWidth, + croppedHeight, + 0, + 0, + croppedWidth, + croppedHeight, + ); + + return { + dataUrl: croppedCanvas.toDataURL("image/png"), + width: croppedWidth, + height: croppedHeight, + hotspot: hotspot + ? { + x: hotspot.x - minX, + y: hotspot.y - minY, + } + : undefined, + }; +} + +async function createInvertedCursorAsset(asset: LoadedCursorAsset): Promise<LoadedCursorAsset> { + const canvas = document.createElement("canvas"); + canvas.width = asset.image.naturalWidth; + canvas.height = asset.image.naturalHeight; + const ctx = canvas.getContext("2d")!; + ctx.drawImage(asset.image, 0, 0); + const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); + const { data } = imageData; + for (let index = 0; index < data.length; index += 4) { + if (data[index + 3] === 0) { + continue; + } + + data[index] = 255 - data[index]; + data[index + 1] = 255 - data[index + 1]; + data[index + 2] = 255 - data[index + 2]; + } + ctx.putImageData(imageData, 0, 0); + + const dataUrl = canvas.toDataURL("image/png"); + await Assets.load(dataUrl); + const image = await loadImage(dataUrl); + const texture = configureCursorTexture(Texture.from(dataUrl)); + + return { + texture, + image, + aspectRatio: asset.aspectRatio, + anchorX: asset.anchorX, + anchorY: asset.anchorY, + }; +} + +function getCursorAsset(key: CursorAssetKey): LoadedCursorAsset { + const asset = loadedCursorAssets[key]; + if (!asset) { + throw new Error(`Missing cursor asset for ${key}`); + } + + return asset; +} + +function getAvailableCursorKeys(): CursorAssetKey[] { + const loadedKeys = Object.keys(loadedCursorAssets) as CursorAssetKey[]; + return loadedKeys.length > 0 ? loadedKeys : ["arrow"]; +} + +function getCursorStyleAsset(style: SingleCursorStyle) { + const asset = loadedCursorStyleAssets[style]; + if (!asset) { + throw new Error(`Missing cursor style asset for ${style}`); + } + + return asset; +} + +function getCursorPackStyleAsset(style: CursorPackStyle, key: CursorAssetKey) { + const styleAssets = loadedCursorPackAssets[style]; + if (!styleAssets) { + if (!warnedMissingCursorPackStyles.has(style)) { + warnedMissingCursorPackStyles.add(style); + console.warn( + `[CursorRenderer] Missing cursor pack assets for ${style}; falling back to Tahoe cursors.`, + ); + } + return getStatefulCursorAsset("tahoe", key); + } + + const variant = resolveCursorPackVariant(key); + return styleAssets[variant] ?? styleAssets.default; +} + +function getStatefulCursorAsset(style: StatefulCursorStyle, key: CursorAssetKey) { + const assetMap = loadedCursorSetAssets[style] ?? loadedCursorAssets; + const asset = assetMap[key] ?? assetMap.arrow; + if (!asset) { + throw new Error(`Missing ${style} cursor asset for ${key}`); + } + + return asset; +} + +async function ensureCursorPackAssetsLoaded() { + const sources = getCursorPackSources(); + const signature = buildCursorPackSourcesSignature(sources); + + if (!cursorPackAssetsPromise || loadedCursorPackSourcesSignature !== signature) { + loadedCursorPackSourcesSignature = signature; + warnedMissingCursorPackStyles.clear(); + cursorPackAssetsPromise = (async () => { + const cursorPackEntries = await Promise.all( + Object.entries(sources).map(async ([style, source]) => { + try { + const [defaultAsset, pointerAsset] = await Promise.all([ + createCursorPackAsset(source.defaultUrl, source.defaultAnchor), + createCursorPackAsset(source.pointerUrl, source.pointerAnchor), + ]); + return [style, { default: defaultAsset, pointer: pointerAsset }] as const; + } catch (error) { + console.warn( + `[CursorRenderer] Failed to load cursor pack style for: ${style}`, + error, + ); + return null; + } + }), + ); + + loadedCursorPackAssets = Object.fromEntries( + cursorPackEntries.filter(Boolean).map((entry) => entry!), + ) as Partial<Record<string, LoadedCursorPackAssets>>; + })(); + } + + await cursorPackAssetsPromise; +} + +export async function preloadCursorAssets() { + if (!cursorAssetsPromise) { + cursorAssetsPromise = (async () => { + async function loadCursorSet( + style: keyof typeof cursorSetAssets, + ): Promise<Partial<Record<CursorAssetKey, LoadedCursorAsset>>> { + const entries = await Promise.all( + SUPPORTED_CURSOR_KEYS.map(async (key) => { + const sourceAsset = cursorSetAssets[style][key]; + if (!sourceAsset?.url) { + console.warn(`[CursorRenderer] No cursor image for: ${style}/${key}`); + return null; + } + + try { + const asset = await createRasterizedCursorAsset( + sourceAsset.url, + sourceAsset.fallbackAnchor, + { preserveCanvas: sourceAsset.preserveCanvas }, + ); + + return [key, asset] as const; + } catch (error) { + console.warn( + `[CursorRenderer] Failed to load cursor image for: ${style}/${key}`, + error, + ); + return null; + } + }), + ); + + return Object.fromEntries( + entries.filter(Boolean).map((entry) => entry!), + ) as Partial<Record<CursorAssetKey, LoadedCursorAsset>>; + } + + const [macosAssets, tahoeAssets, windows11Assets] = await Promise.all([ + loadCursorSet("macos"), + loadCursorSet("tahoe"), + loadCursorSet("windows11"), + ]); + + const invertedEntries = await Promise.all( + (Object.entries(tahoeAssets) as Array<[CursorAssetKey, LoadedCursorAsset]>).map( + async ([key, asset]) => [key, await createInvertedCursorAsset(asset)] as const, + ), + ); + + loadedCursorSetAssets = { + macos: macosAssets, + tahoe: tahoeAssets, + windows11: windows11Assets, + "tahoe-inverted": Object.fromEntries(invertedEntries) as Partial< + Record<CursorAssetKey, LoadedCursorAsset> + >, + }; + loadedCursorAssets = tahoeAssets; + + const customStyleEntries = await Promise.all( + (["dot", "figma"] as const).map( + async (style) => [style, await createCursorStyleAsset(style)] as const, + ), + ); + + loadedCursorStyleAssets = Object.fromEntries(customStyleEntries) as Partial< + Record<SingleCursorStyle, LoadedCursorAsset> + >; + + if (!loadedCursorAssets.arrow) { + throw new Error("Failed to initialize the fallback arrow cursor asset"); + } + })(); + } + + await cursorAssetsPromise; + await ensureCursorPackAssetsLoaded(); +} + +function getNativeCursorAtlasAsset(style: CursorStyle, key: CursorAssetKey) { + if (isStatefulCursorStyle(style)) { + return getStatefulCursorAsset(style, key); + } + + if (isSingleCursorStyle(style)) { + return getCursorStyleAsset(style); + } + + return getCursorPackStyleAsset(style, key); +} + +export async function buildNativeCursorAtlas( + style: CursorStyle = DEFAULT_CURSOR_STYLE, +): Promise<NativeCursorAtlas | null> { + if (typeof document === "undefined") { + return null; + } + + await preloadCursorAssets(); + + const entries: NativeCursorAtlasEntry[] = []; + const packedAssets = SUPPORTED_CURSOR_KEYS.map((key, index) => { + const asset = getNativeCursorAtlasAsset(style, key); + const height = NATIVE_CURSOR_ATLAS_DRAW_HEIGHT; + const width = Math.max(1, Math.round(height * asset.aspectRatio)); + return { key, index, asset, width, height }; + }); + + const atlasWidth = packedAssets.reduce( + (total, item) => total + item.width + NATIVE_CURSOR_ATLAS_PADDING, + NATIVE_CURSOR_ATLAS_PADDING, + ); + const atlasHeight = NATIVE_CURSOR_ATLAS_DRAW_HEIGHT + NATIVE_CURSOR_ATLAS_PADDING * 2; + const canvas = document.createElement("canvas"); + canvas.width = atlasWidth; + canvas.height = atlasHeight; + + const ctx = canvas.getContext("2d"); + if (!ctx) { + return null; + } + + ctx.clearRect(0, 0, atlasWidth, atlasHeight); + let x = NATIVE_CURSOR_ATLAS_PADDING; + for (const { key, index, asset, width, height } of packedAssets) { + const y = NATIVE_CURSOR_ATLAS_PADDING; + ctx.drawImage(asset.image, x, y, width, height); + entries.push({ + cursorType: key, + index, + x, + y, + width, + height, + anchorX: asset.anchorX, + anchorY: asset.anchorY, + aspectRatio: asset.aspectRatio, + }); + x += width + NATIVE_CURSOR_ATLAS_PADDING; + } + + return { + style, + width: atlasWidth, + height: atlasHeight, + dataUrl: canvas.toDataURL("image/png"), + entries, + }; +} + +/** + * Interpolates cursor position from telemetry samples at a given time. + * Uses linear interpolation between the two nearest samples. + */ +export function interpolateCursorPosition( + samples: CursorTelemetryPoint[], + timeMs: number, +): { cx: number; cy: number } | null { + if (!samples || samples.length === 0) return null; + + if (timeMs <= samples[0].timeMs) { + return { cx: samples[0].cx, cy: samples[0].cy }; + } + + if (timeMs >= samples[samples.length - 1].timeMs) { + return { + cx: samples[samples.length - 1].cx, + cy: samples[samples.length - 1].cy, + }; + } + + let lo = 0; + let hi = samples.length - 1; + while (lo < hi - 1) { + const mid = (lo + hi) >> 1; + if (samples[mid].timeMs <= timeMs) { + lo = mid; + } else { + hi = mid; + } + } + + const a = samples[lo]; + const b = samples[hi]; + const span = b.timeMs - a.timeMs; + if (span <= 0) return { cx: a.cx, cy: a.cy }; + + const t = (timeMs - a.timeMs) / span; + return { + cx: a.cx + (b.cx - a.cx) * t, + cy: a.cy + (b.cy - a.cy) * t, + }; +} + +function findLatestSample(samples: CursorTelemetryPoint[], timeMs: number) { + if (samples.length === 0) return null; + + let lo = 0; + let hi = samples.length - 1; + while (lo < hi) { + const mid = Math.ceil((lo + hi) / 2); + if (samples[mid].timeMs <= timeMs) { + lo = mid; + } else { + hi = mid - 1; + } + } + + return samples[lo]?.timeMs <= timeMs ? samples[lo] : null; +} + +function findLatestInteractionSample(samples: CursorTelemetryPoint[], timeMs: number) { + for (let index = samples.length - 1; index >= 0; index -= 1) { + const sample = samples[index]; + if (sample.timeMs > timeMs) { + continue; + } + + if ( + sample.interactionType === "click" || + sample.interactionType === "double-click" || + sample.interactionType === "right-click" || + sample.interactionType === "middle-click" + ) { + return sample; + } + } + + return null; +} + +function findLatestStableCursorType(samples: CursorTelemetryPoint[], timeMs: number) { + // Binary search to find position at timeMs, then scan backwards + let lo = 0; + let hi = samples.length - 1; + while (lo < hi) { + const mid = Math.ceil((lo + hi) / 2); + if (samples[mid].timeMs <= timeMs) { + lo = mid; + } else { + hi = mid - 1; + } + } + + // Scan backwards from the position to find a sample with cursorType + // Skip click events only (not mouseup) to avoid transient re-type during clicks + for (let index = lo; index >= 0; index -= 1) { + const sample = samples[index]; + if (sample.timeMs > timeMs) { + continue; + } + + if (!sample.cursorType) { + continue; + } + + if ( + sample.interactionType === "click" || + sample.interactionType === "double-click" || + sample.interactionType === "right-click" || + sample.interactionType === "middle-click" + ) { + continue; + } + + return sample.cursorType; + } + + return findLatestSample(samples, timeMs)?.cursorType ?? "arrow"; +} + +function getCursorSwaySpringConfig(smoothingFactor: number, springTuning: CursorSpringTuning) { + const baseConfig = getCursorSpringConfig( + Math.min( + 2, + Math.max( + 0.15, + smoothingFactor * CURSOR_SWAY_SMOOTHING_MULTIPLIER + CURSOR_SWAY_SMOOTHING_OFFSET, + ), + ), + springTuning, + ); + + return { + ...baseConfig, + damping: baseConfig.damping * 0.9, + mass: Math.max(0.55, baseConfig.mass * 0.8), + restDelta: 0.0005, + restSpeed: 0.02, + }; +} + +function getClickEffectColor(clickEffectColor: string) { + const normalized = normalizeCursorClickEffectColor(clickEffectColor); + return Number.parseInt(normalized.slice(1), 16); +} + +function getExtensionStyleRippleMetrics( + cursorSize: number, + clickProgress: number, + effectScale: number, + effectOpacity: number, +) { + const eased = 1 - Math.pow(clickProgress, 3); + const fade = Math.pow(clickProgress, 3); + + return { + radius: Math.max(0.5, eased * cursorSize * 1.95 * effectScale), + alpha: Math.max(0, Math.min(1, fade * 0.6 * effectOpacity)), + strokeWidth: Math.max(1, 2 * fade), + }; +} + +function drawClickEffectGraphics( + graphics: Graphics, + effect: CursorClickEffectStyle, + px: number, + py: number, + cursorSize: number, + clickProgress: number, + effectScale: number, + effectOpacity: number, + effectColor: string = DEFAULT_CURSOR_CLICK_EFFECT_COLOR, +) { + graphics.clear(); + if (effect === "none" || clickProgress <= 0) { + return; + } + + const reveal = 1 - clickProgress; + const alpha = clickProgress * effectOpacity; + const color = getClickEffectColor(effectColor); + const baseRadius = Math.max(12, cursorSize * 0.55 * effectScale); + const strokeWidth = Math.max(2, cursorSize * 0.08); + + if (effect === "ripple") { + const ripple = getExtensionStyleRippleMetrics( + cursorSize, + clickProgress, + effectScale, + effectOpacity, + ); + graphics.circle(px, py, ripple.radius); + graphics.stroke({ width: ripple.strokeWidth, color, alpha: ripple.alpha }); + return; + } + + if (effect === "spotlight") { + const glowRadius = baseRadius + reveal * cursorSize * effectScale; + const innerRadius = Math.max(baseRadius * 0.72, glowRadius * 0.76); + graphics.circle(px, py, glowRadius); + graphics.stroke({ width: Math.max(1.25, strokeWidth * 0.68), color, alpha: alpha * 0.28 }); + graphics.circle(px, py, innerRadius); + graphics.stroke({ width: Math.max(1.5, strokeWidth * 0.75), color, alpha: alpha * 0.5 }); + return; + } + + const echoOuterRadius = baseRadius + reveal * cursorSize * 1.22 * effectScale; + const echoInnerRadius = Math.max(baseRadius * 0.58, echoOuterRadius * 0.62); + graphics.circle(px, py, echoOuterRadius); + graphics.stroke({ width: strokeWidth, color, alpha: alpha * 0.72 }); + graphics.circle(px, py, echoInnerRadius); + graphics.stroke({ width: Math.max(1.4, strokeWidth * 0.72), color, alpha: alpha * 0.42 }); + graphics.circle(px, py, Math.max(3, baseRadius * 0.18)); + graphics.fill({ color, alpha: alpha * 0.14 }); +} + +function drawClickEffectOnCanvas( + ctx: CanvasRenderingContext2D, + effect: CursorClickEffectStyle, + px: number, + py: number, + cursorSize: number, + clickProgress: number, + effectScale: number, + effectOpacity: number, + effectColor: string = DEFAULT_CURSOR_CLICK_EFFECT_COLOR, +) { + if (effect === "none" || clickProgress <= 0) { + return; + } + + const reveal = 1 - clickProgress; + const alpha = clickProgress * effectOpacity; + const color = getClickEffectColor(effectColor); + const strokeColor = `rgba(${(color >> 16) & 255}, ${(color >> 8) & 255}, ${color & 255}, `; + const baseRadius = Math.max(12, cursorSize * 0.55 * effectScale); + const strokeWidth = Math.max(2, cursorSize * 0.08); + + ctx.save(); + + if (effect === "ripple") { + const ripple = getExtensionStyleRippleMetrics( + cursorSize, + clickProgress, + effectScale, + effectOpacity, + ); + ctx.lineWidth = ripple.strokeWidth; + ctx.strokeStyle = `${strokeColor}${ripple.alpha.toFixed(3)})`; + ctx.beginPath(); + ctx.arc(px, py, ripple.radius, 0, Math.PI * 2); + ctx.stroke(); + ctx.restore(); + return; + } + + if (effect === "spotlight") { + const glowRadius = baseRadius + reveal * cursorSize * effectScale; + const innerRadius = Math.max(baseRadius * 0.72, glowRadius * 0.76); + ctx.lineWidth = Math.max(1.25, strokeWidth * 0.68); + ctx.strokeStyle = `${strokeColor}${(alpha * 0.28).toFixed(3)})`; + ctx.beginPath(); + ctx.arc(px, py, glowRadius, 0, Math.PI * 2); + ctx.stroke(); + ctx.lineWidth = Math.max(1.5, strokeWidth * 0.75); + ctx.strokeStyle = `${strokeColor}${(alpha * 0.5).toFixed(3)})`; + ctx.beginPath(); + ctx.arc(px, py, innerRadius, 0, Math.PI * 2); + ctx.stroke(); + ctx.restore(); + return; + } + + const echoOuterRadius = baseRadius + reveal * cursorSize * 1.22 * effectScale; + const echoInnerRadius = Math.max(baseRadius * 0.58, echoOuterRadius * 0.62); + ctx.lineWidth = strokeWidth; + ctx.strokeStyle = `${strokeColor}${(alpha * 0.72).toFixed(3)})`; + ctx.beginPath(); + ctx.arc(px, py, echoOuterRadius, 0, Math.PI * 2); + ctx.stroke(); + ctx.lineWidth = Math.max(1.4, strokeWidth * 0.72); + ctx.strokeStyle = `${strokeColor}${(alpha * 0.42).toFixed(3)})`; + ctx.beginPath(); + ctx.arc(px, py, echoInnerRadius, 0, Math.PI * 2); + ctx.stroke(); + ctx.fillStyle = `${strokeColor}${(alpha * 0.14).toFixed(3)})`; + ctx.beginPath(); + ctx.arc(px, py, Math.max(3, baseRadius * 0.18), 0, Math.PI * 2); + ctx.fill(); + ctx.restore(); +} + +function getCursorVisualState( + samples: CursorTelemetryPoint[], + timeMs: number, + clickBounceDuration: number, + clickEffectDurationMs: number, +) { + const latestClick = findLatestInteractionSample(samples, timeMs); + const interactionType = latestClick?.interactionType; + const ageMs = latestClick ? Math.max(0, timeMs - latestClick.timeMs) : Number.POSITIVE_INFINITY; + const clickEffectDelayMs = clickBounceDuration * 0.5; + const clickEffectAgeMs = ageMs - clickEffectDelayMs; + const isClickEvent = + interactionType === "click" || + interactionType === "double-click" || + interactionType === "right-click" || + interactionType === "middle-click"; + const clickBounceProgress = + latestClick && isClickEvent && ageMs <= clickBounceDuration + ? 1 - ageMs / clickBounceDuration + : 0; + + return { + cursorType: findLatestStableCursorType(samples, timeMs), + interactionType, + clickSample: latestClick && isClickEvent ? latestClick : null, + clickBounceProgress, + clickProgress: + latestClick && + isClickEvent && + clickEffectAgeMs >= 0 && + clickEffectAgeMs <= clickEffectDurationMs + ? 1 - clickEffectAgeMs / clickEffectDurationMs + : 0, + }; +} + +/** + * Manages a smoothed cursor state that chases the interpolated target. + */ +export class SmoothedCursorState { + public x = 0.5; + public y = 0.5; + public trail: Array<{ x: number; y: number }> = []; + private smoothingFactor: number; + private springTuning: CursorSpringTuning; + private trailLength: number; + private initialized = false; + private lastTimeMs: number | null = null; + private xSpring = createSpringState(0.5); + private ySpring = createSpringState(0.5); + + constructor( + config: Pick<CursorRenderConfig, "smoothingFactor" | "trailLength" | "springTuning">, + ) { + this.smoothingFactor = config.smoothingFactor; + this.springTuning = config.springTuning; + this.trailLength = config.trailLength; + } + + update(targetX: number, targetY: number, timeMs: number): void { + if (!this.initialized) { + this.x = targetX; + this.y = targetY; + this.initialized = true; + this.lastTimeMs = timeMs; + this.xSpring.value = targetX; + this.ySpring.value = targetY; + this.xSpring.velocity = 0; + this.ySpring.velocity = 0; + this.xSpring.initialized = true; + this.ySpring.initialized = true; + this.trail = []; + return; + } + + if (this.smoothingFactor <= 0 || (this.lastTimeMs !== null && timeMs < this.lastTimeMs)) { + this.snapTo(targetX, targetY, timeMs); + return; + } + + this.trail.unshift({ x: this.x, y: this.y }); + if (this.trail.length > this.trailLength) { + this.trail.length = this.trailLength; + } + + const deltaMs = + this.lastTimeMs === null ? 1000 / 60 : Math.max(1, timeMs - this.lastTimeMs); + this.lastTimeMs = timeMs; + + const springConfig = getCursorSpringConfig(this.smoothingFactor, this.springTuning); + this.x = stepSpringValue(this.xSpring, targetX, deltaMs, springConfig); + this.y = stepSpringValue(this.ySpring, targetY, deltaMs, springConfig); + } + + setSmoothingFactor(smoothingFactor: number): void { + this.smoothingFactor = smoothingFactor; + } + + setSpringTuning(springTuning: CursorSpringTuning): void { + this.springTuning = springTuning; + } + + snapTo(targetX: number, targetY: number, timeMs: number): void { + this.x = targetX; + this.y = targetY; + this.initialized = true; + this.lastTimeMs = timeMs; + this.xSpring.value = targetX; + this.ySpring.value = targetY; + this.xSpring.velocity = 0; + this.ySpring.velocity = 0; + this.xSpring.initialized = true; + this.ySpring.initialized = true; + this.trail = []; + } + + reset(): void { + this.initialized = false; + this.lastTimeMs = null; + this.trail = []; + resetSpringState(this.xSpring, this.x); + resetSpringState(this.ySpring, this.y); + } +} + +export class PixiCursorOverlay { + public readonly container: Container; + private clickRingGraphics: Graphics; + private customCursorShadowSprite: Sprite; + private customCursorShadowFilter: BlurFilter; + private customCursorSprite: Sprite; + private cursorShadowSprites: Partial<Record<CursorAssetKey, Sprite>>; + private cursorShadowFilters: Partial<Record<CursorAssetKey, BlurFilter>>; + private cursorSprites: Partial<Record<CursorAssetKey, Sprite>>; + private cursorMotionBlurFilter: MotionBlurFilter; + private state: SmoothedCursorState; + private config: CursorRenderConfig; + private lastRenderedPoint: { px: number; py: number } | null = null; + private lastRenderedTimeMs: number | null = null; + private cursorVisible = false; + private swayRotation = 0; + private swaySpring = createSpringState(0); + + constructor(config: Partial<CursorRenderConfig> = {}) { + this.config = { + ...DEFAULT_CURSOR_CONFIG, + ...config, + springTuning: { + ...DEFAULT_CURSOR_CONFIG.springTuning, + ...config.springTuning, + }, + }; + this.state = new SmoothedCursorState(this.config); + + this.container = new Container(); + this.container.label = "cursor-overlay"; + + this.clickRingGraphics = new Graphics(); + const initialCustomAsset = getCursorStyleAsset("figma"); + this.customCursorShadowSprite = new Sprite(initialCustomAsset.texture); + this.customCursorShadowSprite.anchor.set( + initialCustomAsset.anchorX, + initialCustomAsset.anchorY, + ); + this.customCursorShadowSprite.visible = false; + this.customCursorShadowSprite.roundPixels = true; + this.customCursorShadowSprite.tint = CURSOR_SHADOW_COLOR; + this.customCursorShadowSprite.alpha = CURSOR_SHADOW_ALPHA; + this.customCursorShadowFilter = new BlurFilter(); + this.customCursorShadowFilter.blur = CURSOR_SHADOW_BLUR; + this.customCursorShadowFilter.quality = 4; + this.customCursorShadowFilter.padding = CURSOR_SHADOW_PADDING; + this.customCursorShadowSprite.filters = [this.customCursorShadowFilter]; + + this.customCursorSprite = new Sprite(initialCustomAsset.texture); + this.customCursorSprite.anchor.set(initialCustomAsset.anchorX, initialCustomAsset.anchorY); + this.customCursorSprite.visible = false; + this.customCursorSprite.roundPixels = true; + this.cursorShadowSprites = {}; + this.cursorShadowFilters = {}; + this.cursorSprites = {}; + for (const key of getAvailableCursorKeys()) { + const asset = getCursorAsset(key); + const shadowSprite = new Sprite(asset.texture); + shadowSprite.anchor.set(asset.anchorX, asset.anchorY); + shadowSprite.visible = false; + shadowSprite.roundPixels = true; + shadowSprite.tint = CURSOR_SHADOW_COLOR; + shadowSprite.alpha = CURSOR_SHADOW_ALPHA; + const shadowFilter = new BlurFilter(); + shadowFilter.blur = CURSOR_SHADOW_BLUR; + shadowFilter.quality = 4; + shadowFilter.padding = CURSOR_SHADOW_PADDING; + shadowSprite.filters = [shadowFilter]; + this.cursorShadowSprites[key] = shadowSprite; + this.cursorShadowFilters[key] = shadowFilter; + + const sprite = new Sprite(asset.texture); + sprite.anchor.set(asset.anchorX, asset.anchorY); + sprite.visible = false; + sprite.roundPixels = true; + this.cursorSprites[key] = sprite; + } + + this.cursorMotionBlurFilter = new MotionBlurFilter([0, 0], 5, 0); + this.container.filters = null; + + this.container.addChild( + this.clickRingGraphics, + this.customCursorShadowSprite, + ...Object.values(this.cursorShadowSprites), + this.customCursorSprite, + ...Object.values(this.cursorSprites), + ); + this.setMotionBlur(this.config.motionBlur); + this.setStyle(this.config.style); + } + + setDotRadius(dotRadius: number) { + this.config.dotRadius = dotRadius; + } + + setSmoothingFactor(smoothingFactor: number) { + this.config.smoothingFactor = smoothingFactor; + this.state.setSmoothingFactor(smoothingFactor); + } + + setSpringTuning(springTuning: CursorSpringTuning) { + this.config.springTuning = { + ...DEFAULT_CURSOR_CONFIG.springTuning, + ...springTuning, + }; + this.state.setSpringTuning(this.config.springTuning); + } + + setMotionBlur(motionBlur: number) { + this.config.motionBlur = Math.max(0, motionBlur); + this.container.filters = this.config.motionBlur > 0 ? [this.cursorMotionBlurFilter] : null; + if (this.config.motionBlur <= 0) { + this.cursorMotionBlurFilter.velocity = { x: 0, y: 0 }; + this.cursorMotionBlurFilter.kernelSize = 5; + this.cursorMotionBlurFilter.offset = 0; + } + } + + setFilterResolution(resolution: number) { + this.cursorMotionBlurFilter.resolution = Math.max(1, resolution); + } + + setClickBounce(clickBounce: number) { + this.config.clickBounce = Math.max(0, clickBounce); + } + + setClickEffect(clickEffect: CursorClickEffectStyle) { + this.config.clickEffect = clickEffect; + } + + setClickEffectColor(clickEffectColor: string) { + this.config.clickEffectColor = normalizeCursorClickEffectColor(clickEffectColor); + } + + setClickEffectScale(clickEffectScale: number) { + this.config.clickEffectScale = clamp(clickEffectScale, 0.5, 2); + } + + setClickEffectOpacity(clickEffectOpacity: number) { + this.config.clickEffectOpacity = clamp(clickEffectOpacity, 0, 1); + } + + setClickEffectDurationMs(clickEffectDurationMs: number) { + this.config.clickEffectDurationMs = clamp(clickEffectDurationMs, 120, 1200); + } + + setClickBounceDuration(clickBounceDuration: number) { + this.config.clickBounceDuration = clamp(clickBounceDuration, 60, 500); + } + + setSway(sway: number) { + this.config.sway = clamp(sway, 0, 2); + } + + setStyle(style: CursorStyle) { + this.config.style = style; + if (isStatefulCursorStyle(style)) { + for (const key of getAvailableCursorKeys()) { + const asset = getStatefulCursorAsset(style, key); + const shadowSprite = this.cursorShadowSprites[key]; + const sprite = this.cursorSprites[key]; + shadowSprite?.anchor.set(asset.anchorX, asset.anchorY); + if (shadowSprite) { + shadowSprite.texture = asset.texture; + } + sprite?.anchor.set(asset.anchorX, asset.anchorY); + if (sprite) { + sprite.texture = asset.texture; + } + } + return; + } + + const asset = isSingleCursorStyle(style) + ? getCursorStyleAsset(style) + : getCursorPackStyleAsset(style, "arrow"); + this.customCursorShadowSprite.texture = asset.texture; + this.customCursorShadowSprite.anchor.set(asset.anchorX, asset.anchorY); + this.customCursorSprite.texture = asset.texture; + this.customCursorSprite.anchor.set(asset.anchorX, asset.anchorY); + } + + getSmoothedCursorSnapshot(): { + cx: number; + cy: number; + trail: Array<{ cx: number; cy: number }>; + } | null { + if (!this.container.visible || !this.cursorVisible) { + return null; + } + + return { + cx: this.state.x, + cy: this.state.y, + trail: this.state.trail.map((point) => ({ cx: point.x, cy: point.y })), + }; + } + + update( + samples: CursorTelemetryPoint[], + timeMs: number, + viewport: CursorViewportRect, + visible: boolean, + freeze = false, + ): void { + if (samples.length === 0 || viewport.width <= 0 || viewport.height <= 0) { + this.container.visible = false; + this.cursorVisible = false; + this.clickRingGraphics.clear(); + this.lastRenderedPoint = null; + this.lastRenderedTimeMs = null; + this.swayRotation = 0; + resetSpringState(this.swaySpring, 0); + this.cursorMotionBlurFilter.velocity = { x: 0, y: 0 }; + this.customCursorShadowSprite.visible = false; + this.customCursorSprite.visible = false; + for (const shadowSprite of Object.values(this.cursorShadowSprites)) { + shadowSprite.visible = false; + } + for (const sprite of Object.values(this.cursorSprites)) { + sprite.visible = false; + } + return; + } + + const target = interpolateCursorPosition(samples, timeMs); + if (!target) { + this.container.visible = false; + this.cursorVisible = false; + this.clickRingGraphics.clear(); + return; + } + + const projectedTarget = projectCursorPositionToViewport(target, viewport.sourceCrop); + + const h = + this.config.dotRadius * + getCursorViewportScale(viewport.width, this.config.minViewportScale); + const { cursorType, clickSample, clickBounceProgress, clickProgress } = + getCursorVisualState( + samples, + timeMs, + this.config.clickBounceDuration, + this.config.clickEffectDurationMs, + ); + const projectedClickSample = clickSample + ? projectCursorPositionToViewport(clickSample, viewport.sourceCrop) + : null; + const clickEffectPx = + projectedClickSample && projectedClickSample.visible + ? viewport.x + projectedClickSample.cx * viewport.width + : viewport.x + projectedTarget.cx * viewport.width; + const clickEffectPy = + projectedClickSample && projectedClickSample.visible + ? viewport.y + projectedClickSample.cy * viewport.height + : viewport.y + projectedTarget.cy * viewport.height; + const shouldShowCursorSprite = visible && projectedTarget.visible; + const shouldDrawClickEffect = + this.config.clickEffect !== "none" && + clickProgress > 0 && + Boolean(projectedClickSample?.visible); + + if (!shouldShowCursorSprite && !shouldDrawClickEffect) { + this.container.visible = false; + this.cursorVisible = false; + this.clickRingGraphics.clear(); + this.customCursorShadowSprite.visible = false; + this.customCursorSprite.visible = false; + for (const shadowSprite of Object.values(this.cursorShadowSprites)) { + shadowSprite.visible = false; + } + for (const sprite of Object.values(this.cursorSprites)) { + sprite.visible = false; + } + this.lastRenderedPoint = null; + this.lastRenderedTimeMs = null; + this.swayRotation = 0; + resetSpringState(this.swaySpring, 0); + this.cursorMotionBlurFilter.velocity = { x: 0, y: 0 }; + return; + } + + this.container.visible = true; + this.cursorVisible = shouldShowCursorSprite; + + let px = viewport.x + projectedTarget.cx * viewport.width; + let py = viewport.y + projectedTarget.cy * viewport.height; + let shouldFreezeCursorMotion = true; + + if (shouldShowCursorSprite) { + const sameFrameTime = + this.lastRenderedTimeMs !== null && + Math.abs(this.lastRenderedTimeMs - timeMs) < 0.0001; + const hasTimeDiscontinuity = + this.lastRenderedTimeMs !== null && + Math.abs(timeMs - this.lastRenderedTimeMs) > CURSOR_TIME_DISCONTINUITY_MS; + shouldFreezeCursorMotion = freeze || hasTimeDiscontinuity; + + if (shouldFreezeCursorMotion) { + if (!sameFrameTime || !this.lastRenderedPoint) { + this.state.snapTo(projectedTarget.cx, projectedTarget.cy, timeMs); + } + } else { + this.state.update(projectedTarget.cx, projectedTarget.cy, timeMs); + } + + px = viewport.x + this.state.x * viewport.width; + py = viewport.y + this.state.y * viewport.height; + } else { + this.customCursorShadowSprite.visible = false; + this.customCursorSprite.visible = false; + for (const shadowSprite of Object.values(this.cursorShadowSprites)) { + shadowSprite.visible = false; + } + for (const sprite of Object.values(this.cursorSprites)) { + sprite.visible = false; + } + } + + const bounceScale = Math.max( + 0.72, + 1 - Math.sin(clickBounceProgress * Math.PI) * (0.08 * this.config.clickBounce), + ); + const scaledH = h * getCursorStyleSizeMultiplier(this.config.style); + const swayRotation = this.updateCursorSway(px, py, timeMs, shouldFreezeCursorMotion); + + drawClickEffectGraphics( + this.clickRingGraphics, + this.config.clickEffect, + clickEffectPx, + clickEffectPy, + scaledH, + clickProgress, + this.config.clickEffectScale, + this.config.clickEffectOpacity, + this.config.clickEffectColor, + ); + + const spriteKey = ( + cursorType in this.cursorSprites ? cursorType : "arrow" + ) as CursorAssetKey; + + if (shouldShowCursorSprite && isStatefulCursorStyle(this.config.style)) { + this.customCursorShadowSprite.visible = false; + this.customCursorSprite.visible = false; + + const asset = getStatefulCursorAsset(this.config.style, spriteKey); + const shadowSprite = + this.cursorShadowSprites[spriteKey] ?? this.cursorShadowSprites.arrow!; + const sprite = this.cursorSprites[spriteKey] ?? this.cursorSprites.arrow!; + + for (const [key, currentShadowSprite] of Object.entries( + this.cursorShadowSprites, + ) as Array<[CursorAssetKey, Sprite]>) { + currentShadowSprite.visible = key === spriteKey; + } + + for (const [key, currentSprite] of Object.entries(this.cursorSprites) as Array< + [CursorAssetKey, Sprite] + >) { + currentSprite.visible = key === spriteKey; + } + + if (shadowSprite) { + shadowSprite.height = scaledH * bounceScale; + shadowSprite.width = scaledH * bounceScale * asset.aspectRatio; + shadowSprite.position.set(px + CURSOR_SHADOW_OFFSET_X, py + CURSOR_SHADOW_OFFSET_Y); + shadowSprite.rotation = swayRotation; + } + + if (sprite) { + sprite.alpha = this.config.dotAlpha; + sprite.height = scaledH * bounceScale; + sprite.width = scaledH * bounceScale * asset.aspectRatio; + sprite.position.set(px, py); + sprite.rotation = swayRotation; + } + } else if (shouldShowCursorSprite) { + for (const currentShadowSprite of Object.values(this.cursorShadowSprites)) { + currentShadowSprite.visible = false; + } + + for (const currentSprite of Object.values(this.cursorSprites)) { + currentSprite.visible = false; + } + + const asset = isSingleCursorStyle(this.config.style) + ? getCursorStyleAsset(this.config.style) + : getCursorPackStyleAsset(this.config.style, spriteKey); + const showSeparateShadow = this.config.style !== "figma"; + this.customCursorShadowSprite.texture = asset.texture; + this.customCursorShadowSprite.anchor.set(asset.anchorX, asset.anchorY); + this.customCursorShadowSprite.visible = showSeparateShadow; + if (showSeparateShadow) { + this.customCursorShadowSprite.height = scaledH * bounceScale; + this.customCursorShadowSprite.width = scaledH * bounceScale * asset.aspectRatio; + this.customCursorShadowSprite.position.set( + px + CURSOR_SHADOW_OFFSET_X, + py + CURSOR_SHADOW_OFFSET_Y, + ); + this.customCursorShadowSprite.rotation = swayRotation; + } + + this.customCursorSprite.texture = asset.texture; + this.customCursorSprite.anchor.set(asset.anchorX, asset.anchorY); + this.customCursorSprite.visible = true; + this.customCursorSprite.alpha = this.config.dotAlpha; + this.customCursorSprite.height = scaledH * bounceScale; + this.customCursorSprite.width = scaledH * bounceScale * asset.aspectRatio; + this.customCursorSprite.position.set(px, py); + this.customCursorSprite.rotation = swayRotation; + } + + this.applyCursorMotionBlur(px, py, timeMs, shouldFreezeCursorMotion); + this.lastRenderedPoint = shouldShowCursorSprite ? { px, py } : null; + this.lastRenderedTimeMs = timeMs; + } + + private updateCursorSway(px: number, py: number, timeMs: number, freeze: boolean) { + const deltaMs = + this.lastRenderedTimeMs === null || freeze + ? 1000 / 60 + : Math.max(1, timeMs - this.lastRenderedTimeMs); + const targetRotation = + !freeze && this.lastRenderedPoint && this.lastRenderedTimeMs !== null + ? computeCursorSwayRotation( + px - this.lastRenderedPoint.px, + py - this.lastRenderedPoint.py, + timeMs - this.lastRenderedTimeMs, + this.config.sway, + ) + : 0; + + this.swayRotation = stepSpringValue( + this.swaySpring, + targetRotation, + deltaMs, + getCursorSwaySpringConfig(this.config.smoothingFactor, this.config.springTuning), + ); + + if (Math.abs(this.swayRotation) < 0.0001 && targetRotation === 0) { + this.swayRotation = 0; + } + + return this.swayRotation; + } + + private applyCursorMotionBlur(px: number, py: number, timeMs: number, freeze: boolean) { + if ( + freeze || + this.config.motionBlur <= 0 || + !this.lastRenderedPoint || + this.lastRenderedTimeMs === null + ) { + this.cursorMotionBlurFilter.velocity = { x: 0, y: 0 }; + this.cursorMotionBlurFilter.kernelSize = 5; + this.cursorMotionBlurFilter.offset = 0; + return; + } + + const deltaMs = Math.max(1, timeMs - this.lastRenderedTimeMs); + const dx = px - this.lastRenderedPoint.px; + const dy = py - this.lastRenderedPoint.py; + const blur = computeDirectionalMotionBlur( + { x: dx, y: dy }, + this.config.motionBlur, + deltaMs / 1000, + CURSOR_DIRECTIONAL_BLUR_STRENGTH, + ); + + this.cursorMotionBlurFilter.velocity = blur.velocity; + this.cursorMotionBlurFilter.kernelSize = blur.kernelSize; + this.cursorMotionBlurFilter.offset = blur.offset; + } + + reset(): void { + this.state.reset(); + this.clickRingGraphics.clear(); + this.cursorVisible = false; + for (const shadowSprite of Object.values(this.cursorShadowSprites)) { + shadowSprite.visible = false; + shadowSprite.scale.set(1); + } + this.customCursorShadowSprite.visible = false; + this.customCursorShadowSprite.scale.set(1); + for (const sprite of Object.values(this.cursorSprites)) { + sprite.visible = false; + sprite.scale.set(1); + } + this.customCursorSprite.visible = false; + this.customCursorSprite.scale.set(1); + this.container.visible = false; + this.lastRenderedPoint = null; + this.lastRenderedTimeMs = null; + this.swayRotation = 0; + resetSpringState(this.swaySpring, 0); + this.cursorMotionBlurFilter.velocity = { x: 0, y: 0 }; + this.cursorMotionBlurFilter.kernelSize = 5; + this.cursorMotionBlurFilter.offset = 0; + } + + destroy(): void { + this.clickRingGraphics.destroy(); + this.customCursorShadowFilter.destroy(); + for (const shadowFilter of Object.values(this.cursorShadowFilters)) { + shadowFilter.destroy(); + } + this.cursorMotionBlurFilter.destroy(); + this.container.destroy({ children: true }); + } +} + +export function drawCursorOnCanvas( + ctx: CanvasRenderingContext2D, + samples: CursorTelemetryPoint[], + timeMs: number, + viewport: CursorViewportRect, + smoothedState: SmoothedCursorState, + config: CursorRenderConfig = DEFAULT_CURSOR_CONFIG, +): void { + if (samples.length === 0 || viewport.width <= 0 || viewport.height <= 0) return; + + const target = interpolateCursorPosition(samples, timeMs); + if (!target) return; + + const projectedTarget = projectCursorPositionToViewport(target, viewport.sourceCrop); + if (!projectedTarget.visible) return; + + smoothedState.update(projectedTarget.cx, projectedTarget.cy, timeMs); + + const px = viewport.x + smoothedState.x * viewport.width; + const py = viewport.y + smoothedState.y * viewport.height; + const h = config.dotRadius * getCursorViewportScale(viewport.width, config.minViewportScale); + const { cursorType, clickSample, clickBounceProgress, clickProgress } = getCursorVisualState( + samples, + timeMs, + config.clickBounceDuration, + config.clickEffectDurationMs, + ); + const projectedClickSample = clickSample + ? projectCursorPositionToViewport(clickSample, viewport.sourceCrop) + : null; + const clickEffectPx = + projectedClickSample && projectedClickSample.visible + ? viewport.x + projectedClickSample.cx * viewport.width + : px; + const clickEffectPy = + projectedClickSample && projectedClickSample.visible + ? viewport.y + projectedClickSample.cy * viewport.height + : py; + const spriteKey = ( + cursorType && loadedCursorAssets[cursorType] ? cursorType : "arrow" + ) as CursorAssetKey; + const asset = isStatefulCursorStyle(config.style) + ? getStatefulCursorAsset(config.style, spriteKey) + : isSingleCursorStyle(config.style) + ? getCursorStyleAsset(config.style) + : getCursorPackStyleAsset(config.style, spriteKey); + const bounceScale = Math.max( + 0.72, + 1 - Math.sin(clickBounceProgress * Math.PI) * (0.08 * config.clickBounce), + ); + const effectHeight = h * getCursorStyleSizeMultiplier(config.style); + const drawHeight = effectHeight * bounceScale; + drawClickEffectOnCanvas( + ctx, + config.clickEffect, + clickEffectPx, + clickEffectPy, + effectHeight, + clickProgress, + config.clickEffectScale, + config.clickEffectOpacity, + config.clickEffectColor, + ); + + ctx.save(); + if (config.style !== "figma") { + ctx.filter = CURSOR_SVG_DROP_SHADOW_FILTER; + } + + const drawWidth = drawHeight * asset.aspectRatio; + const hotspotX = asset.anchorX * drawWidth; + const hotspotY = asset.anchorY * drawHeight; + ctx.globalAlpha = config.dotAlpha; + ctx.drawImage(asset.image, px - hotspotX, py - hotspotY, drawWidth, drawHeight); + + ctx.restore(); +} diff --git a/src/components/video-editor/videoPlayback/cursorScale.test.ts b/src/components/video-editor/videoPlayback/cursorScale.test.ts new file mode 100644 index 000000000..ccd2c3add --- /dev/null +++ b/src/components/video-editor/videoPlayback/cursorScale.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from "vitest"; +import { getCursorViewportScale } from "./cursorScale"; + +describe("cursor preview/export scale", () => { + it("preserves the same cursor-to-video ratio at preview and export sizes", () => { + const baseCursorHeight = 28 * 2.5; + const previewWidth = 720; + const exportWidth = 2940; + const previewCursorHeight = baseCursorHeight * getCursorViewportScale(previewWidth); + const exportCursorHeight = baseCursorHeight * getCursorViewportScale(exportWidth); + + expect(previewCursorHeight / previewWidth).toBeCloseTo(exportCursorHeight / exportWidth, 8); + }); +}); diff --git a/src/components/video-editor/videoPlayback/cursorScale.ts b/src/components/video-editor/videoPlayback/cursorScale.ts new file mode 100644 index 000000000..878901d3e --- /dev/null +++ b/src/components/video-editor/videoPlayback/cursorScale.ts @@ -0,0 +1,5 @@ +export const CURSOR_REFERENCE_VIEWPORT_WIDTH = 1920; + +export function getCursorViewportScale(viewportWidth: number, minimumScale = 0): number { + return Math.max(minimumScale, Math.max(0, viewportWidth) / CURSOR_REFERENCE_VIEWPORT_WIDTH); +} diff --git a/src/components/video-editor/videoPlayback/cursorSway.test.ts b/src/components/video-editor/videoPlayback/cursorSway.test.ts new file mode 100644 index 000000000..1f10a51a8 --- /dev/null +++ b/src/components/video-editor/videoPlayback/cursorSway.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; + +import { + computeCursorSwayRotation, + fromCursorSwaySliderValue, + toCursorSwaySliderValue, +} from "./cursorSway"; + +describe("computeCursorSwayRotation", () => { + it("returns zero when sway is disabled or there is no movement", () => { + expect(computeCursorSwayRotation(120, 0, 16, 0)).toBe(0); + expect(computeCursorSwayRotation(0, 0, 16, 1)).toBe(0); + }); + + it("leans with the motion direction", () => { + expect(computeCursorSwayRotation(120, 0, 16, 1)).toBeGreaterThan(0); + expect(computeCursorSwayRotation(-120, 0, 16, 1)).toBeLessThan(0); + expect(computeCursorSwayRotation(0, 120, 16, 1)).toBeGreaterThan(0); + expect(computeCursorSwayRotation(0, -120, 16, 1)).toBeLessThan(0); + }); + + it("increases with faster movement for the same direction", () => { + const slow = Math.abs(computeCursorSwayRotation(24, 0, 48, 1)); + const fast = Math.abs(computeCursorSwayRotation(120, 0, 16, 1)); + + expect(fast).toBeGreaterThan(slow); + }); + + it("maps a 2x slider value to a 6x sway intensity", () => { + expect(computeCursorSwayRotation(-140, 0, 100, 2)).toBeCloseTo(-(Math.PI / 3), 6); + }); + + it("maps a 1x slider value to the previous 2x sway strength", () => { + expect(fromCursorSwaySliderValue(1)).toBe(2); + expect(toCursorSwaySliderValue(2)).toBe(1); + }); +}); diff --git a/src/components/video-editor/videoPlayback/cursorSway.ts b/src/components/video-editor/videoPlayback/cursorSway.ts new file mode 100644 index 000000000..4b3094d9c --- /dev/null +++ b/src/components/video-editor/videoPlayback/cursorSway.ts @@ -0,0 +1,45 @@ +import { clampDeltaMs } from "./motionSmoothing"; + +const CURSOR_SWAY_MAX_ROTATION = Math.PI / 18; +const CURSOR_SWAY_SPEED_REFERENCE = 1400; +const CURSOR_SWAY_VERTICAL_WEIGHT = 0.65; +const CURSOR_SWAY_INTENSITY_SCALE = 3; +export const CURSOR_SWAY_SLIDER_SCALE = 2; + +function clamp(value: number, min: number, max: number) { + return Math.min(max, Math.max(min, value)); +} + +export function computeCursorSwayRotation(dx: number, dy: number, deltaMs: number, sway: number) { + if (sway <= 0) { + return 0; + } + + const distance = Math.hypot(dx, dy); + if (!Number.isFinite(distance) || distance < 0.01) { + return 0; + } + + const speedPxPerSecond = distance / (clampDeltaMs(deltaMs) / 1000); + const speedFactor = clamp(speedPxPerSecond / CURSOR_SWAY_SPEED_REFERENCE, 0, 1); + if (speedFactor <= 0) { + return 0; + } + + const directionalBias = clamp((dx + dy * CURSOR_SWAY_VERTICAL_WEIGHT) / distance, -1, 1); + return ( + directionalBias * + speedFactor * + CURSOR_SWAY_MAX_ROTATION * + sway * + CURSOR_SWAY_INTENSITY_SCALE + ); +} + +export function toCursorSwaySliderValue(sway: number) { + return sway / CURSOR_SWAY_SLIDER_SCALE; +} + +export function fromCursorSwaySliderValue(sliderValue: number) { + return sliderValue * CURSOR_SWAY_SLIDER_SCALE; +} diff --git a/src/components/video-editor/videoPlayback/cursorViewport.test.ts b/src/components/video-editor/videoPlayback/cursorViewport.test.ts new file mode 100644 index 000000000..899062721 --- /dev/null +++ b/src/components/video-editor/videoPlayback/cursorViewport.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; + +import { projectCursorPositionToViewport } from "./cursorViewport"; + +describe("projectCursorPositionToViewport", () => { + it("leaves coordinates unchanged when no crop is active", () => { + expect(projectCursorPositionToViewport({ cx: 0.25, cy: 0.75 })).toEqual({ + cx: 0.25, + cy: 0.75, + visible: true, + }); + }); + + it("remaps source-space coordinates into crop-relative viewport space", () => { + const projected = projectCursorPositionToViewport( + { cx: 0.3, cy: 0.55 }, + { x: 0.2, y: 0.4, width: 0.5, height: 0.4 }, + ); + + expect(projected.visible).toBe(true); + expect(projected.cx).toBeCloseTo(0.2, 6); + expect(projected.cy).toBeCloseTo(0.375, 6); + }); + + it("marks cursor invisible when it falls outside the cropped source region", () => { + expect( + projectCursorPositionToViewport( + { cx: 0.1, cy: 0.5 }, + { x: 0.2, y: 0.25, width: 0.5, height: 0.5 }, + ), + ).toEqual({ + cx: -0.2, + cy: 0.5, + visible: false, + }); + }); +}); diff --git a/src/components/video-editor/videoPlayback/cursorViewport.ts b/src/components/video-editor/videoPlayback/cursorViewport.ts new file mode 100644 index 000000000..8ed811c5e --- /dev/null +++ b/src/components/video-editor/videoPlayback/cursorViewport.ts @@ -0,0 +1,57 @@ +import type { CropRegion } from "../types"; + +const CURSOR_VIEWPORT_EPSILON = 0.000001; + +export interface CursorViewportRect { + x: number; + y: number; + width: number; + height: number; + renderWidth?: number; + renderHeight?: number; + sourceCrop?: CropRegion; +} + +export interface ProjectedCursorPosition { + cx: number; + cy: number; + visible: boolean; +} + +export function projectCursorPositionToViewport( + position: { cx: number; cy: number }, + sourceCrop?: CropRegion, +): ProjectedCursorPosition { + if (!sourceCrop) { + return { + cx: position.cx, + cy: position.cy, + visible: true, + }; + } + + const cropWidth = sourceCrop.width; + const cropHeight = sourceCrop.height; + + if (cropWidth <= 0 || cropHeight <= 0) { + return { + cx: position.cx, + cy: position.cy, + visible: false, + }; + } + + const projectedX = (position.cx - sourceCrop.x) / cropWidth; + const projectedY = (position.cy - sourceCrop.y) / cropHeight; + const visible = + projectedX >= -CURSOR_VIEWPORT_EPSILON && + projectedX <= 1 + CURSOR_VIEWPORT_EPSILON && + projectedY >= -CURSOR_VIEWPORT_EPSILON && + projectedY <= 1 + CURSOR_VIEWPORT_EPSILON; + + return { + cx: projectedX, + cy: projectedY, + visible, + }; +} diff --git a/src/components/video-editor/videoPlayback/focusUtils.ts b/src/components/video-editor/videoPlayback/focusUtils.ts index e8e9f4199..27f408c54 100644 --- a/src/components/video-editor/videoPlayback/focusUtils.ts +++ b/src/components/video-editor/videoPlayback/focusUtils.ts @@ -1,54 +1,175 @@ -import { ZOOM_DEPTH_SCALES, clampFocusToDepth, type ZoomFocus, type ZoomDepth } from "../types"; +import { clampFocusToDepth, ZOOM_DEPTH_SCALES, type ZoomDepth, type ZoomFocus } from "../types"; interface StageSize { - width: number; - height: number; + width: number; + height: number; +} + +function clamp(value: number, min: number, max: number) { + return Math.max(min, Math.min(max, value)); +} + +function easeIntoBoundary(normalized: number) { + const t = clamp(normalized, 0, 1); + return -t * t * t + 2 * t * t; +} + +function softClampToRange(value: number, min: number, max: number, softness: number) { + const clamped = clamp(value, min, max); + + if (softness <= 0 || max <= min) { + return clamped; + } + + if (clamped < min + softness) { + const normalized = (clamped - min) / softness; + return min + softness * easeIntoBoundary(normalized); + } + + if (clamped > max - softness) { + const normalized = (max - clamped) / softness; + return max - softness * easeIntoBoundary(normalized); + } + + return clamped; +} + +function getFocusBounds(depth: ZoomDepth) { + const zoomScale = ZOOM_DEPTH_SCALES[depth]; + return getFocusBoundsForScale(zoomScale); +} + +function getFocusBoundsForScale(zoomScale: number) { + const marginX = 1 / (2 * zoomScale); + const marginY = 1 / (2 * zoomScale); + + return { + minX: marginX, + maxX: 1 - marginX, + minY: marginY, + maxY: 1 - marginY, + }; } export function clampFocusToStage( - focus: ZoomFocus, - depth: ZoomDepth, - stageSize: StageSize + focus: ZoomFocus, + depth: ZoomDepth, + _stageSize: StageSize, ): ZoomFocus { - if (!stageSize.width || !stageSize.height) { - return clampFocusToDepth(focus, depth); - } + const baseFocus = clampFocusToDepth(focus, depth); + const bounds = getFocusBounds(depth); + + return { + cx: clamp(baseFocus.cx, bounds.minX, bounds.maxX), + cy: clamp(baseFocus.cy, bounds.minY, bounds.maxY), + }; +} + +export function clampFocusToScale(focus: ZoomFocus, zoomScale: number): ZoomFocus { + const baseFocus = { + cx: clamp(focus.cx, 0, 1), + cy: clamp(focus.cy, 0, 1), + }; + const bounds = getFocusBoundsForScale(zoomScale); + + return { + cx: clamp(baseFocus.cx, bounds.minX, bounds.maxX), + cy: clamp(baseFocus.cy, bounds.minY, bounds.maxY), + }; +} - const zoomScale = ZOOM_DEPTH_SCALES[depth]; - - const windowWidth = stageSize.width / zoomScale; - const windowHeight = stageSize.height / zoomScale; - - const marginX = windowWidth / (2 * stageSize.width); - const marginY = windowHeight / (2 * stageSize.height); +export function softenFocusToScale(focus: ZoomFocus, zoomScale: number): ZoomFocus { + const baseFocus = { + cx: clamp(focus.cx, 0, 1), + cy: clamp(focus.cy, 0, 1), + }; + const bounds = getFocusBoundsForScale(zoomScale); + const horizontalRange = bounds.maxX - bounds.minX; + const verticalRange = bounds.maxY - bounds.minY; + const horizontalSoftness = Math.min(0.12, horizontalRange * 0.35); + const verticalSoftness = Math.min(0.12, verticalRange * 0.35); - const baseFocus = clampFocusToDepth(focus, depth); + return { + cx: softClampToRange(baseFocus.cx, bounds.minX, bounds.maxX, horizontalSoftness), + cy: softClampToRange(baseFocus.cy, bounds.minY, bounds.maxY, verticalSoftness), + }; +} + +/** + * Edge snap algorithm for cursor-follow camera. + * + * Maps cursor position through a clamped linear remap so the camera + * pins to the edge when the cursor is within `snapToEdgesRatio` of + * the viewport boundary. + * + * With snapToEdgesRatio = 0.25 (default for manual zooms): + * cursor ∈ [0, 0.25] → output 0 (camera pinned to left/top) + * cursor ∈ [0.25, 0.75] → linearly maps 0→1 (camera follows) + * cursor ∈ [0.75, 1.0] → output 1 (camera pinned to right/bottom) + * + * The result is then mapped back to focus-space bounds for the given + * zoom scale, so the camera stays within the valid viewport. + * + * @param snapToEdgesRatio 0.25 for manual zooms, 0.5 for system/auto zooms + */ +export function edgeSnapFocus( + cursorFocus: ZoomFocus, + zoomScale: number, + snapToEdgesRatio: number, +): ZoomFocus { + const bounds = getFocusBoundsForScale(zoomScale); + + const snappedX = clampedInterpolate( + cursorFocus.cx, + snapToEdgesRatio, + 1 - snapToEdgesRatio + 0.0001, + ); + const snappedY = clampedInterpolate( + cursorFocus.cy, + snapToEdgesRatio, + 1 - snapToEdgesRatio + 0.0001, + ); + + return { + cx: bounds.minX + snappedX * (bounds.maxX - bounds.minX), + cy: bounds.minY + snappedY * (bounds.maxY - bounds.minY), + }; +} - return { - cx: Math.max(marginX, Math.min(1 - marginX, baseFocus.cx)), - cy: Math.max(marginY, Math.min(1 - marginY, baseFocus.cy)), - }; +/** + * Clamped linear interpolation: maps `value` from [inMin, inMax] → [0, 1]. + */ +function clampedInterpolate(value: number, inMin: number, inMax: number): number { + if (inMax <= inMin) return 0; + const t = (value - inMin) / (inMax - inMin); + return clamp(t, 0, 1); } export function stageFocusToVideoSpace( - focus: ZoomFocus, - stageSize: StageSize, - videoSize: { width: number; height: number }, - baseScale: number, - baseOffset: { x: number; y: number } + focus: ZoomFocus, + stageSize: StageSize, + videoSize: { width: number; height: number }, + baseScale: number, + baseOffset: { x: number; y: number }, ): ZoomFocus { - if (!stageSize.width || !stageSize.height || !videoSize.width || !videoSize.height || baseScale <= 0) { - return focus; - } + if ( + !stageSize.width || + !stageSize.height || + !videoSize.width || + !videoSize.height || + baseScale <= 0 + ) { + return focus; + } - const stageX = focus.cx * stageSize.width; - const stageY = focus.cy * stageSize.height; + const stageX = focus.cx * stageSize.width; + const stageY = focus.cy * stageSize.height; - const videoNormX = (stageX - baseOffset.x) / (videoSize.width * baseScale); - const videoNormY = (stageY - baseOffset.y) / (videoSize.height * baseScale); + const videoNormX = (stageX - baseOffset.x) / (videoSize.width * baseScale); + const videoNormY = (stageY - baseOffset.y) / (videoSize.height * baseScale); - return { - cx: videoNormX, - cy: videoNormY, - }; + return { + cx: videoNormX, + cy: videoNormY, + }; } diff --git a/src/components/video-editor/videoPlayback/index.ts b/src/components/video-editor/videoPlayback/index.ts index e5be6aaac..06cf42edf 100644 --- a/src/components/video-editor/videoPlayback/index.ts +++ b/src/components/video-editor/videoPlayback/index.ts @@ -1,8 +1,8 @@ -export * from './constants'; -export * from './mathUtils'; -export * from './zoomRegionUtils'; -export * from './focusUtils'; -export * from './overlayUtils'; -export * from './layoutUtils'; -export * from './zoomTransform'; -export * from './videoEventHandlers'; +export * from "./constants"; +export * from "./focusUtils"; +export * from "./layoutUtils"; +export * from "./mathUtils"; +export * from "./overlayUtils"; +export * from "./videoEventHandlers"; +export * from "./zoomRegionUtils"; +export * from "./zoomTransform"; diff --git a/src/components/video-editor/videoPlayback/layoutUtils.test.ts b/src/components/video-editor/videoPlayback/layoutUtils.test.ts new file mode 100644 index 000000000..a93db03f3 --- /dev/null +++ b/src/components/video-editor/videoPlayback/layoutUtils.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vitest"; + +import { ADVANCED_VERTICAL_PADDING_MAX } from "../types"; +import { computePaddedLayout, scalePreviewBorderRadius } from "./layoutUtils"; + +const BASE_LAYOUT_PARAMS = { + width: 1000, + height: 1000, + cropRegion: { x: 0, y: 0, width: 1, height: 1 }, + videoWidth: 1000, + videoHeight: 1000, +}; + +describe("computePaddedLayout", () => { + it("allows advanced bottom padding to pin the video to the top edge", () => { + const layout = computePaddedLayout({ + ...BASE_LAYOUT_PARAMS, + padding: { + top: 0, + bottom: ADVANCED_VERTICAL_PADDING_MAX, + left: 0, + right: 0, + linked: false, + }, + }); + + expect(layout.centerOffsetY).toBeCloseTo(0); + }); + + it("allows advanced top padding to pin the video to the bottom edge", () => { + const layout = computePaddedLayout({ + ...BASE_LAYOUT_PARAMS, + padding: { + top: ADVANCED_VERTICAL_PADDING_MAX, + bottom: 0, + left: 0, + right: 0, + linked: false, + }, + }); + + expect(layout.centerOffsetY + layout.croppedDisplayHeight).toBeCloseTo( + BASE_LAYOUT_PARAMS.height, + ); + }); + + it("preserves linked padding centering behavior", () => { + const layout = computePaddedLayout({ + ...BASE_LAYOUT_PARAMS, + padding: { top: 20, bottom: 20, left: 20, right: 20, linked: true }, + }); + + expect(layout.centerOffsetY).toBeCloseTo(40); + expect(layout.centerOffsetY + layout.croppedDisplayHeight).toBeCloseTo(960); + }); +}); + +describe("scalePreviewBorderRadius", () => { + it("matches export scaling against the logical preview size", () => { + expect(scalePreviewBorderRadius(1920, 1080, 16)).toBeCloseTo(16, 6); + expect(scalePreviewBorderRadius(960, 540, 16)).toBeCloseTo(8, 6); + expect(scalePreviewBorderRadius(1440, 810, 16)).toBeCloseTo(12, 6); + }); + + it("clamps invalid or empty preview sizes to zero", () => { + expect(scalePreviewBorderRadius(0, 540, 16)).toBe(0); + expect(scalePreviewBorderRadius(960, 0, 16)).toBe(0); + expect(scalePreviewBorderRadius(960, 540, -8)).toBe(0); + }); +}); diff --git a/src/components/video-editor/videoPlayback/layoutUtils.ts b/src/components/video-editor/videoPlayback/layoutUtils.ts index a83c9821c..b93ef19d4 100644 --- a/src/components/video-editor/videoPlayback/layoutUtils.ts +++ b/src/components/video-editor/videoPlayback/layoutUtils.ts @@ -1,108 +1,248 @@ -import * as PIXI from 'pixi.js'; -import { VIEWPORT_SCALE } from "./constants"; -import type { CropRegion } from '../types'; +import { Application, Graphics, Sprite } from "pixi.js"; +import { drawSquircleOnGraphics } from "@/lib/geometry/squircle"; +import { ADVANCED_VERTICAL_PADDING_MAX, type CropRegion, type Padding } from "../types"; + +export const PADDING_SCALE_FACTOR = 0.2; +export const BASE_PREVIEW_WIDTH = 1920; +export const BASE_PREVIEW_HEIGHT = 1080; + +export function scalePreviewBorderRadius(width: number, height: number, borderRadius = 0): number { + if (width <= 0 || height <= 0) { + return 0; + } + + const canvasScaleFactor = Math.min(width / BASE_PREVIEW_WIDTH, height / BASE_PREVIEW_HEIGHT); + return Math.max(0, borderRadius * canvasScaleFactor); +} + +export function isZeroPadding(padding: Padding | number): boolean { + if (typeof padding === "number") { + return padding === 0; + } + return padding.top === 0 && padding.bottom === 0 && padding.left === 0 && padding.right === 0; +} + +export interface PaddedLayoutResult { + scale: number; + centerOffsetX: number; + centerOffsetY: number; + spriteX: number; + spriteY: number; + fullFrameDisplayW: number; + fullFrameDisplayH: number; + fullVideoDisplayWidth: number; + fullVideoDisplayHeight: number; + croppedDisplayWidth: number; + croppedDisplayHeight: number; + cropStartX: number; + cropStartY: number; +} + +export function computePaddedLayout(params: { + width: number; + height: number; + padding: Padding | number; + frameInsets?: { top: number; right: number; bottom: number; left: number } | null; + cropRegion: CropRegion; + videoWidth: number; + videoHeight: number; +}): PaddedLayoutResult { + const { width, height, padding, frameInsets, cropRegion, videoWidth, videoHeight } = params; + + // Apply asymmetrical padding + const p = + typeof padding === "number" + ? { top: padding, bottom: padding, left: padding, right: padding } + : padding; + + // Padding is a percentage. Linked padding keeps the original 0-100 scaling + // behavior; advanced vertical padding gets extra range for positioning. + const isAdvancedPadding = typeof padding !== "number" && padding.linked === false; + const clampPercent = (v: number, max = 100) => Math.min(max, Math.max(0, v)); + const leftPercent = clampPercent(p.left); + const rightPercent = clampPercent(p.right); + const topPercent = clampPercent(p.top, isAdvancedPadding ? ADVANCED_VERTICAL_PADDING_MAX : 100); + const bottomPercent = clampPercent( + p.bottom, + isAdvancedPadding ? ADVANCED_VERTICAL_PADDING_MAX : 100, + ); + const leftPadFrac = (leftPercent / 100) * PADDING_SCALE_FACTOR; + const rightPadFrac = (rightPercent / 100) * PADDING_SCALE_FACTOR; + const topPadFrac = (Math.min(topPercent, 100) / 100) * PADDING_SCALE_FACTOR; + const bottomPadFrac = (Math.min(bottomPercent, 100) / 100) * PADDING_SCALE_FACTOR; + + const availableFracW = Math.max(0, 1.0 - leftPadFrac - rightPadFrac); + const availableFracH = Math.max(0, 1.0 - topPadFrac - bottomPadFrac); + + const maxDisplayWidth = width * availableFracW; + const maxDisplayHeight = height * availableFracH; + + const crop = cropRegion; + const croppedVideoWidth = videoWidth * crop.width; + const croppedVideoHeight = videoHeight * crop.height; + + const insets = frameInsets; + const screenFracW = insets ? 1 - insets.left - insets.right : 1; + const screenFracH = insets ? 1 - insets.top - insets.bottom : 1; + + const fullFrameVideoW = croppedVideoWidth / screenFracW; + const fullFrameVideoH = croppedVideoHeight / screenFracH; + + const scale = Math.min( + fullFrameVideoW > 0 ? maxDisplayWidth / fullFrameVideoW : 0, + fullFrameVideoH > 0 ? maxDisplayHeight / fullFrameVideoH : 0, + ); + + const fullVideoDisplayWidth = videoWidth * scale; + const fullVideoDisplayHeight = videoHeight * scale; + const croppedDisplayWidth = croppedVideoWidth * scale; + const croppedDisplayHeight = croppedVideoHeight * scale; + + const fullFrameDisplayW = fullFrameVideoW * scale; + const fullFrameDisplayH = fullFrameVideoH * scale; + + const availableCenterX = leftPadFrac * width + maxDisplayWidth / 2; + const availableCenterY = isAdvancedPadding + ? (() => { + const verticalTravel = Math.max(0, height - fullFrameDisplayH); + const centeredOffsetY = verticalTravel / 2; + const directionalOffsetY = + centeredOffsetY + + ((topPercent - bottomPercent) / ADVANCED_VERTICAL_PADDING_MAX) * + centeredOffsetY; + const frameOffsetY = Math.min(verticalTravel, Math.max(0, directionalOffsetY)); + return frameOffsetY + fullFrameDisplayH / 2; + })() + : topPadFrac * height + maxDisplayHeight / 2; + + const frameCenterX = availableCenterX - fullFrameDisplayW / 2; + const frameCenterY = availableCenterY - fullFrameDisplayH / 2; + + const centerOffsetX = insets ? frameCenterX + insets.left * fullFrameDisplayW : frameCenterX; + const centerOffsetY = insets ? frameCenterY + insets.top * fullFrameDisplayH : frameCenterY; + + const spriteX = centerOffsetX - crop.x * fullVideoDisplayWidth; + const spriteY = centerOffsetY - crop.y * fullVideoDisplayHeight; + + return { + scale, + centerOffsetX, + centerOffsetY, + spriteX, + spriteY, + fullFrameDisplayW, + fullFrameDisplayH, + fullVideoDisplayWidth, + fullVideoDisplayHeight, + croppedDisplayWidth, + croppedDisplayHeight, + cropStartX: crop.x * videoWidth, + cropStartY: crop.y * videoHeight, + }; +} interface LayoutParams { - container: HTMLDivElement; - app: PIXI.Application; - videoSprite: PIXI.Sprite; - maskGraphics: PIXI.Graphics; - videoElement: HTMLVideoElement; - cropRegion?: CropRegion; - lockedVideoDimensions?: { width: number; height: number } | null; + container: HTMLDivElement; + app: Application; + videoSprite: Sprite; + maskGraphics: Graphics; + videoElement: HTMLVideoElement; + cropRegion?: CropRegion; + lockedVideoDimensions?: { width: number; height: number } | null; + borderRadius?: number; + padding?: Padding | number; + /** Screen insets from the active device frame, used to scale/center the full frame */ + frameInsets?: { top: number; right: number; bottom: number; left: number } | null; } interface LayoutResult { - stageSize: { width: number; height: number }; - videoSize: { width: number; height: number }; - baseScale: number; - baseOffset: { x: number; y: number }; - maskRect: { x: number; y: number; width: number; height: number }; - cropBounds: { startX: number; endX: number; startY: number; endY: number }; + stageSize: { width: number; height: number }; + videoSize: { width: number; height: number }; + baseScale: number; + baseOffset: { x: number; y: number }; + maskRect: { + x: number; + y: number; + width: number; + height: number; + sourceCrop?: CropRegion; + }; + cropBounds: { startX: number; endX: number; startY: number; endY: number }; } export function layoutVideoContent(params: LayoutParams): LayoutResult | null { - const { container, app, videoSprite, maskGraphics, videoElement, cropRegion, lockedVideoDimensions } = params; - - - const videoWidth = lockedVideoDimensions?.width || videoElement.videoWidth; - const videoHeight = lockedVideoDimensions?.height || videoElement.videoHeight; - - if (!videoWidth || !videoHeight) { - return null; - } - - const width = container.clientWidth; - const height = container.clientHeight; - - if (!width || !height) { - return null; - } - - app.renderer.resize(width, height); - app.canvas.style.width = '100%'; - app.canvas.style.height = '100%'; - - // Apply crop region - const crop = cropRegion || { x: 0, y: 0, width: 1, height: 1 }; - - // Calculate the cropped dimensions - const croppedVideoWidth = videoWidth * crop.width; - const croppedVideoHeight = videoHeight * crop.height; - - const cropStartX = crop.x * videoWidth; - const cropStartY = crop.y * videoHeight; - const cropEndX = cropStartX + croppedVideoWidth; - const cropEndY = cropStartY + croppedVideoHeight; - - // Calculate scale to fit the cropped area in the viewport - const maxDisplayWidth = width * VIEWPORT_SCALE; - const maxDisplayHeight = height * VIEWPORT_SCALE; - - const scale = Math.min( - maxDisplayWidth / croppedVideoWidth, - maxDisplayHeight / croppedVideoHeight, - 1 - ); - - videoSprite.scale.set(scale); - - // Calculate display size of the full video at this scale - const fullVideoDisplayWidth = videoWidth * scale; - const fullVideoDisplayHeight = videoHeight * scale; - - // Calculate display size of just the cropped region - const croppedDisplayWidth = croppedVideoWidth * scale; - const croppedDisplayHeight = croppedVideoHeight * scale; - - // Center the cropped region in the container - const centerOffsetX = (width - croppedDisplayWidth) / 2; - const centerOffsetY = (height - croppedDisplayHeight) / 2; - - // Position the full video sprite so that when we apply the mask, - // the cropped region appears centered - // The crop starts at (crop.x * videoWidth, crop.y * videoHeight) in video coordinates - // In display coordinates, that's (crop.x * fullVideoDisplayWidth, crop.y * fullVideoDisplayHeight) - // We want that point to be at centerOffsetX, centerOffsetY - const spriteX = centerOffsetX - (crop.x * fullVideoDisplayWidth); - const spriteY = centerOffsetY - (crop.y * fullVideoDisplayHeight); - - videoSprite.position.set(spriteX, spriteY); - - // Create a mask that only shows the cropped region (centered in container) - const maskX = centerOffsetX; - const maskY = centerOffsetY; - const radius = Math.min(croppedDisplayWidth, croppedDisplayHeight) * 0.02; - maskGraphics.clear(); - maskGraphics.roundRect(maskX, maskY, croppedDisplayWidth, croppedDisplayHeight, radius); - maskGraphics.fill({ color: 0xffffff }); - - return { - stageSize: { width, height }, - videoSize: { width: croppedVideoWidth, height: croppedVideoHeight }, - baseScale: scale, - baseOffset: { x: spriteX, y: spriteY }, - maskRect: { x: maskX, y: maskY, width: croppedDisplayWidth, height: croppedDisplayHeight }, - cropBounds: { startX: cropStartX, endX: cropEndX, startY: cropStartY, endY: cropEndY }, - }; + const { + container, + app, + videoSprite, + maskGraphics, + videoElement, + cropRegion, + lockedVideoDimensions, + borderRadius = 0, + padding = 0, + frameInsets, + } = params; + + const videoWidth = lockedVideoDimensions?.width || videoElement.videoWidth; + const videoHeight = lockedVideoDimensions?.height || videoElement.videoHeight; + + if (!videoWidth || !videoHeight) { + return null; + } + + const width = container.clientWidth; + const height = container.clientHeight; + + if (!width || !height) { + return null; + } + + app.renderer.resize(width, height); + app.canvas.style.width = "100%"; + app.canvas.style.height = "100%"; + + const crop = cropRegion || { x: 0, y: 0, width: 1, height: 1 }; + const layout = computePaddedLayout({ + width, + height, + padding, + frameInsets, + cropRegion: crop, + videoWidth, + videoHeight, + }); + + videoSprite.scale.set(layout.scale); + videoSprite.position.set(layout.spriteX, layout.spriteY); + + maskGraphics.clear(); + drawSquircleOnGraphics(maskGraphics, { + x: layout.centerOffsetX, + y: layout.centerOffsetY, + width: layout.croppedDisplayWidth, + height: layout.croppedDisplayHeight, + radius: scalePreviewBorderRadius(width, height, borderRadius), + }); + maskGraphics.fill({ color: 0xffffff }); + + return { + stageSize: { width, height }, + videoSize: { width: videoWidth * crop.width, height: videoHeight * crop.height }, + baseScale: layout.scale, + baseOffset: { x: layout.spriteX, y: layout.spriteY }, + maskRect: { + x: layout.centerOffsetX, + y: layout.centerOffsetY, + width: layout.croppedDisplayWidth, + height: layout.croppedDisplayHeight, + sourceCrop: crop, + }, + cropBounds: { + startX: layout.cropStartX, + endX: layout.cropStartX + videoWidth * crop.width, + startY: layout.cropStartY, + endY: layout.cropStartY + videoHeight * crop.height, + }, + }; } diff --git a/src/components/video-editor/videoPlayback/mathUtils.ts b/src/components/video-editor/videoPlayback/mathUtils.ts index c629d59fa..dca5629dc 100644 --- a/src/components/video-editor/videoPlayback/mathUtils.ts +++ b/src/components/video-editor/videoPlayback/mathUtils.ts @@ -1,8 +1,86 @@ export function clamp01(value: number) { - return Math.max(0, Math.min(1, value)); + return Math.max(0, Math.min(1, value)); +} + +function sampleCubicBezier(a1: number, a2: number, t: number) { + const oneMinusT = 1 - t; + return 3 * a1 * oneMinusT * oneMinusT * t + 3 * a2 * oneMinusT * t * t + t * t * t; +} + +function sampleCubicBezierDerivative(a1: number, a2: number, t: number) { + const oneMinusT = 1 - t; + return 3 * a1 * oneMinusT * oneMinusT + 6 * (a2 - a1) * oneMinusT * t + 3 * (1 - a2) * t * t; +} + +export function cubicBezier(x1: number, y1: number, x2: number, y2: number, t: number) { + const targetX = clamp01(t); + let solvedT = targetX; + + for (let i = 0; i < 8; i += 1) { + const currentX = sampleCubicBezier(x1, x2, solvedT) - targetX; + const currentDerivative = sampleCubicBezierDerivative(x1, x2, solvedT); + + if (Math.abs(currentX) < 1e-6 || Math.abs(currentDerivative) < 1e-6) { + break; + } + + solvedT -= currentX / currentDerivative; + } + + let lower = 0; + let upper = 1; + solvedT = clamp01(solvedT); + + for (let i = 0; i < 10; i += 1) { + const currentX = sampleCubicBezier(x1, x2, solvedT); + if (Math.abs(currentX - targetX) < 1e-6) { + break; + } + + if (currentX < targetX) { + lower = solvedT; + } else { + upper = solvedT; + } + + solvedT = (lower + upper) / 2; + } + + return sampleCubicBezier(y1, y2, solvedT); +} + +export function easeOutExpo(t: number) { + const clamped = clamp01(t); + if (clamped === 1) { + return 1; + } + + return 1 - Math.pow(2, -7 * clamped); +} + +export function easeOutZoom(t: number) { + return cubicBezier(0.16, 1, 0.3, 1, t); } export function smoothStep(t: number) { - const clamped = clamp01(t); - return clamped * clamped * (3 - 2 * clamped); + const clamped = clamp01(t); + return clamped * clamped * (3 - 2 * clamped); +} + +/** + * Gentle ease-in-out cubic — slow start, smooth middle, gentle landing. + * Used for zoom-in transitions. + */ +export function easeInOutCubic(t: number) { + const x = clamp01(t); + return x < 0.5 ? 4 * x * x * x : 1 - Math.pow(-2 * x + 2, 3) / 2; +} + +/** + * Ease-out cubic — starts at speed, then decelerates to a gentle stop. + * Used for zoom-out transitions so strength eases smoothly to zero. + */ +export function easeOutCubic(t: number) { + const x = clamp01(t); + return 1 - Math.pow(1 - x, 3); } diff --git a/src/components/video-editor/videoPlayback/motionSmoothing.ts b/src/components/video-editor/videoPlayback/motionSmoothing.ts new file mode 100644 index 000000000..c4ec3852b --- /dev/null +++ b/src/components/video-editor/videoPlayback/motionSmoothing.ts @@ -0,0 +1,319 @@ +// Friendly reminder: Recordly is licensed under AGPL-3.0, author @webadderall, repo-> https://github.com/webadderall/Recordly +// Please use this code with the right attribution. + +export interface SpringState { + value: number; + velocity: number; + initialized: boolean; +} + +export interface SpringConfig { + stiffness: number; + damping: number; + mass: number; + restDelta?: number; + restSpeed?: number; +} + +export interface CursorSpringTuning { + stiffnessMultiplier?: number; + dampingMultiplier?: number; + massMultiplier?: number; +} + +const CURSOR_SMOOTHING_MIN = 0; +const CURSOR_SMOOTHING_MAX = 2; +const CURSOR_SMOOTHING_LEGACY_MAX = 0.5; +const DEFAULT_CURSOR_STIFFNESS_BOOST = 1.12; + +function clampSpringMultiplier(value: number | undefined) { + if (typeof value !== "number" || !Number.isFinite(value)) { + return 1; + } + + const numericValue = value; + return Math.min(3, Math.max(0.25, numericValue)); +} + +function applyCursorSpringTuning(config: SpringConfig, tuning?: CursorSpringTuning): SpringConfig { + return { + ...config, + stiffness: config.stiffness * clampSpringMultiplier(tuning?.stiffnessMultiplier), + damping: config.damping * clampSpringMultiplier(tuning?.dampingMultiplier), + mass: config.mass * clampSpringMultiplier(tuning?.massMultiplier), + }; +} + +export function createSpringState(initialValue = 0): SpringState { + return { + value: initialValue, + velocity: 0, + initialized: false, + }; +} + +export function resetSpringState(state: SpringState, initialValue?: number) { + if (typeof initialValue === "number") { + state.value = initialValue; + } + + state.velocity = 0; + state.initialized = false; +} + +export function clampDeltaMs(deltaMs: number, fallbackMs = 1000 / 60) { + if (!Number.isFinite(deltaMs) || deltaMs <= 0) { + return fallbackMs; + } + + return Math.min(80, Math.max(1, deltaMs)); +} + +/** + * Damped harmonic oscillator spring solver. + * + * Implements Hooke's law F = −kx − cv (stiffness k, damping c, mass m) + * with the closed-form analytic solution for the three damping regimes: + * ζ < 1 → underdamped (oscillates then settles) + * ζ = 1 → critically damped (fastest non-oscillating convergence) + * ζ > 1 → overdamped (exponential decay, no oscillation) + * + * where ζ = c / (2√(km)) and ω₀ = √(k/m). + * + * All time inputs are in seconds internally. + */ + +function msToSec(ms: number) { + return ms / 1000; +} + +function resolveSpringPosition( + t: number, + target: number, + initialDelta: number, + initialVelocity: number, + dampingRatio: number, + undampedAngularFreq: number, +): number { + if (dampingRatio < 1) { + // Underdamped — oscillatory envelope + const dampedFreq = undampedAngularFreq * Math.sqrt(1 - dampingRatio * dampingRatio); + const envelope = Math.exp(-dampingRatio * undampedAngularFreq * t); + return ( + target - + envelope * + (((initialVelocity + dampingRatio * undampedAngularFreq * initialDelta) / + dampedFreq) * + Math.sin(dampedFreq * t) + + initialDelta * Math.cos(dampedFreq * t)) + ); + } + + if (dampingRatio === 1) { + // Critically damped — no oscillation, fastest convergence + return ( + target - + Math.exp(-undampedAngularFreq * t) * + (initialDelta + (initialVelocity + undampedAngularFreq * initialDelta) * t) + ); + } + + // Overdamped — exponential decay, no oscillation + const dampedFreq = undampedAngularFreq * Math.sqrt(dampingRatio * dampingRatio - 1); + const envelope = Math.exp(-dampingRatio * undampedAngularFreq * t); + const freqT = Math.min(dampedFreq * t, 300); // cap to avoid Infinity in sinh/cosh + return ( + target - + (envelope * + ((initialVelocity + dampingRatio * undampedAngularFreq * initialDelta) * + Math.sinh(freqT) + + dampedFreq * initialDelta * Math.cosh(freqT))) / + dampedFreq + ); +} + +export function stepSpringValue( + state: SpringState, + target: number, + deltaMs: number, + config: SpringConfig, +) { + const safeDeltaMs = clampDeltaMs(deltaMs); + + if (!state.initialized || !Number.isFinite(state.value)) { + state.value = target; + state.velocity = 0; + state.initialized = true; + return state.value; + } + + const restDelta = config.restDelta ?? 0.0005; + const restSpeed = config.restSpeed ?? 0.02; + + if (Math.abs(target - state.value) <= restDelta && Math.abs(state.velocity) <= restSpeed) { + state.value = target; + state.velocity = 0; + return state.value; + } + + const { stiffness, damping, mass } = config; + const undampedAngularFreq = Math.sqrt(stiffness / mass); + const dampingRatio = damping / (2 * Math.sqrt(stiffness * mass)); + const initialDelta = target - state.value; + const initialVelocity = -state.velocity; + const tSec = msToSec(safeDeltaMs); + + const current = resolveSpringPosition( + tSec, + target, + initialDelta, + initialVelocity, + dampingRatio, + undampedAngularFreq, + ); + + // Overshoot guard for overdamped / critically-damped springs (ζ ≥ 1). + // With a fixed target an overdamped spring never overshoots, but when + // the target moves every frame (zoom easing curve) carried-over velocity + // can push the value past the new target → wobble on reversal. + // Clamping to target keeps the original animation speed while preventing + // the jelly-like counter-oscillation. + if (dampingRatio >= 1) { + const crossed = + (state.value <= target && current > target) || + (state.value >= target && current < target); + if (crossed) { + state.value = target; + state.velocity = 0; + return state.value; + } + } + + // Analytical velocity via forward-difference on the closed-form solution. + const epsilon = 0.0001; + const ahead = resolveSpringPosition( + tSec + epsilon, + target, + initialDelta, + initialVelocity, + dampingRatio, + undampedAngularFreq, + ); + const analyticalVelocity = (ahead - current) / epsilon; // value per second + + const isBelowVelocityThreshold = Math.abs(analyticalVelocity) <= restSpeed; + const isBelowDisplacementThreshold = Math.abs(target - current) <= restDelta; + const isDone = isBelowVelocityThreshold && isBelowDisplacementThreshold; + + if (isDone) { + state.value = target; + state.velocity = 0; + } else { + state.value = current; + state.velocity = analyticalVelocity; + } + + return state.value; +} + +export function getCursorSpringConfig( + smoothingFactor: number, + tuning?: CursorSpringTuning, +): SpringConfig { + const clamped = Math.min(CURSOR_SMOOTHING_MAX, Math.max(CURSOR_SMOOTHING_MIN, smoothingFactor)); + + if (clamped <= 0) { + return applyCursorSpringTuning( + { + stiffness: 1000, + damping: 100, + mass: 1, + restDelta: 0.0001, + restSpeed: 0.001, + }, + tuning, + ); + } + + if (clamped <= CURSOR_SMOOTHING_LEGACY_MAX) { + const legacyNormalized = Math.min( + 1, + Math.max( + 0, + (clamped - CURSOR_SMOOTHING_MIN) / + (CURSOR_SMOOTHING_LEGACY_MAX - CURSOR_SMOOTHING_MIN), + ), + ); + + return applyCursorSpringTuning( + { + stiffness: (760 - legacyNormalized * 420) * DEFAULT_CURSOR_STIFFNESS_BOOST, + damping: 34 + legacyNormalized * 24, + mass: 0.85 + legacyNormalized * 0.55, + restDelta: 0.0002, + restSpeed: 0.01, + }, + tuning, + ); + } + + const extendedNormalized = Math.min( + 1, + Math.max( + 0, + (clamped - CURSOR_SMOOTHING_LEGACY_MAX) / + (CURSOR_SMOOTHING_MAX - CURSOR_SMOOTHING_LEGACY_MAX), + ), + ); + + return applyCursorSpringTuning( + { + stiffness: (340 - extendedNormalized * 180) * DEFAULT_CURSOR_STIFFNESS_BOOST, + damping: 58 + extendedNormalized * 22, + mass: 1.35 + extendedNormalized * 0.45, + restDelta: 0.0002, + restSpeed: 0.01, + }, + tuning, + ); +} + +export function getZoomSpringConfig( + smoothnessFactor = 0.5, + tuning?: CursorSpringTuning, +): SpringConfig { + const clamped = Math.max(0, Math.min(1, smoothnessFactor)); + + if (clamped <= 0) { + return applyCursorSpringTuning( + { + stiffness: 1000, + damping: 100, + mass: 1, + restDelta: 0.0001, + restSpeed: 0.001, + }, + tuning, + ); + } + + // Map 0-1 slider to the internal 0-2 spring range so that + // smoothness=1 gives the same feel as the old smoothness=2. + const scaled = clamped * 2; + + // Hooke's law spring: F = -kx - cv + // Damping ratio ζ = c / (2√(km)) ≈ 1.05 — barely overdamped. + // The overshoot clamp in stepSpringValue prevents wobble even at + // this low damping, so animations stay fast and responsive. + // Higher scaled → lower stiffness + higher mass → slower, floatier settle. + return applyCursorSpringTuning( + { + stiffness: 100 / scaled, + damping: 21, + mass: 1.0 * scaled, + restDelta: 0.0005, + restSpeed: 0.015, + }, + tuning, + ); +} diff --git a/src/components/video-editor/videoPlayback/overlayUtils.ts b/src/components/video-editor/videoPlayback/overlayUtils.ts index 9acdca838..222757824 100644 --- a/src/components/video-editor/videoPlayback/overlayUtils.ts +++ b/src/components/video-editor/videoPlayback/overlayUtils.ts @@ -1,66 +1,68 @@ -import { ZOOM_DEPTH_SCALES, type ZoomRegion, type ZoomFocus } from "../types"; +import { ZOOM_DEPTH_SCALES, type ZoomFocus, type ZoomRegion } from "../types"; import { clampFocusToStage } from "./focusUtils"; interface OverlayUpdateParams { - overlayEl: HTMLDivElement; - indicatorEl: HTMLDivElement; - region: ZoomRegion | null; - focusOverride?: ZoomFocus; - videoSize: { width: number; height: number }; - baseScale: number; - isPlaying: boolean; + overlayEl: HTMLDivElement; + indicatorEl: HTMLDivElement; + region: ZoomRegion | null; + focusOverride?: ZoomFocus; + baseMask: { x: number; y: number; width: number; height: number }; + isPlaying: boolean; } export function updateOverlayIndicator(params: OverlayUpdateParams) { - const { overlayEl, indicatorEl, region, focusOverride, videoSize, baseScale, isPlaying } = params; + const { overlayEl, indicatorEl, region, focusOverride, baseMask, isPlaying } = params; - if (!region) { - indicatorEl.style.display = 'none'; - overlayEl.style.pointerEvents = 'none'; - return; - } + if (!region || region.mode === "auto") { + indicatorEl.style.display = "none"; + overlayEl.style.pointerEvents = "none"; + return; + } - const stageWidth = overlayEl.clientWidth; - const stageHeight = overlayEl.clientHeight; - - if (!stageWidth || !stageHeight) { - indicatorEl.style.display = 'none'; - overlayEl.style.pointerEvents = 'none'; - return; - } + const stageWidth = overlayEl.clientWidth; + const stageHeight = overlayEl.clientHeight; - if (!videoSize.width || !videoSize.height || baseScale <= 0) { - indicatorEl.style.display = 'none'; - overlayEl.style.pointerEvents = isPlaying ? 'none' : 'auto'; - return; - } + if (!stageWidth || !stageHeight) { + indicatorEl.style.display = "none"; + overlayEl.style.pointerEvents = "none"; + return; + } - const zoomScale = ZOOM_DEPTH_SCALES[region.depth]; - const focus = clampFocusToStage( - focusOverride ?? region.focus, - region.depth, - { width: stageWidth, height: stageHeight } - ); + if (!baseMask.width || !baseMask.height) { + indicatorEl.style.display = "none"; + overlayEl.style.pointerEvents = isPlaying ? "none" : "auto"; + return; + } - // Zoom window shows the stage area that will be visible after zooming (1/zoomScale of stage dimensions) - const indicatorWidth = stageWidth / zoomScale; - const indicatorHeight = stageHeight / zoomScale; + const zoomScale = ZOOM_DEPTH_SCALES[region.depth]; + const focus = clampFocusToStage(focusOverride ?? region.focus, region.depth, { + width: stageWidth, + height: stageHeight, + }); - const rawLeft = focus.cx * stageWidth - indicatorWidth / 2; - const rawTop = focus.cy * stageHeight - indicatorHeight / 2; + const indicatorWidth = baseMask.width / zoomScale; + const indicatorHeight = baseMask.height / zoomScale; - const adjustedLeft = indicatorWidth >= stageWidth - ? (stageWidth - indicatorWidth) / 2 - : Math.max(0, Math.min(stageWidth - indicatorWidth, rawLeft)); + const rawLeft = baseMask.x + focus.cx * baseMask.width - indicatorWidth / 2; + const rawTop = baseMask.y + focus.cy * baseMask.height - indicatorHeight / 2; - const adjustedTop = indicatorHeight >= stageHeight - ? (stageHeight - indicatorHeight) / 2 - : Math.max(0, Math.min(stageHeight - indicatorHeight, rawTop)); + const adjustedLeft = + indicatorWidth >= baseMask.width + ? baseMask.x + (baseMask.width - indicatorWidth) / 2 + : Math.max(baseMask.x, Math.min(baseMask.x + baseMask.width - indicatorWidth, rawLeft)); - indicatorEl.style.display = 'block'; - indicatorEl.style.width = `${indicatorWidth}px`; - indicatorEl.style.height = `${indicatorHeight}px`; - indicatorEl.style.left = `${adjustedLeft}px`; - indicatorEl.style.top = `${adjustedTop}px`; - overlayEl.style.pointerEvents = isPlaying ? 'none' : 'auto'; + const adjustedTop = + indicatorHeight >= baseMask.height + ? baseMask.y + (baseMask.height - indicatorHeight) / 2 + : Math.max( + baseMask.y, + Math.min(baseMask.y + baseMask.height - indicatorHeight, rawTop), + ); + + indicatorEl.style.display = "block"; + indicatorEl.style.width = `${indicatorWidth}px`; + indicatorEl.style.height = `${indicatorHeight}px`; + indicatorEl.style.left = `${adjustedLeft}px`; + indicatorEl.style.top = `${adjustedTop}px`; + overlayEl.style.pointerEvents = isPlaying ? "none" : "auto"; } diff --git a/src/components/video-editor/videoPlayback/uploadedCursorAssets.ts b/src/components/video-editor/videoPlayback/uploadedCursorAssets.ts new file mode 100644 index 000000000..b726301a6 --- /dev/null +++ b/src/components/video-editor/videoPlayback/uploadedCursorAssets.ts @@ -0,0 +1,123 @@ +import macosClosedHandUrl from "../../../assets/cursors/macos/closedhand-1__50-50.svg"; +import macosCrosshairUrl from "../../../assets/cursors/macos/crosshair-1__50-50.svg"; +import macosTextUrl from "../../../assets/cursors/macos/ibeamstroke-1__50-50.svg"; +import macosNotAllowedUrl from "../../../assets/cursors/macos/notallowed-1__23-0.svg"; +import macosOpenHandUrl from "../../../assets/cursors/macos/openhand-1__50-50.svg"; +import macosArrowUrl from "../../../assets/cursors/macos/pointer-1__34-24.svg"; +import macosPointerUrl from "../../../assets/cursors/macos/pointinghand-1__39-26.svg"; +import macosResizeEwUrl from "../../../assets/cursors/macos/resizeeastwest-1__50-50.svg"; +import macosResizeNsUrl from "../../../assets/cursors/macos/resizenorthsouth-1__50-50.svg"; +import tahoeClosedHandUrl from "../../../assets/cursors/tahoe/closedhand-1__50-46.svg"; +import tahoeCrosshairUrl from "../../../assets/cursors/tahoe/crosshair-1__50-50.svg"; +import tahoeTextUrl from "../../../assets/cursors/tahoe/ibeam-1__50-44.svg"; +import tahoeNotAllowedUrl from "../../../assets/cursors/tahoe/notallowed-1__23-0.svg"; +import tahoeOpenHandUrl from "../../../assets/cursors/tahoe/openhand-1__55-57.svg"; +import tahoeArrowUrl from "../../../assets/cursors/tahoe/pointer-1__14-6.svg"; +import tahoePointerUrl from "../../../assets/cursors/tahoe/pointinghand-1__40-10.svg"; +import tahoeResizeEwUrl from "../../../assets/cursors/tahoe/resizeeastwest-1__50-50.svg"; +import tahoeResizeNsUrl from "../../../assets/cursors/tahoe/resizenorthsouth-1__50-49.svg"; +import windows11ArrowUrl from "../../../assets/cursors/windows11/arrow__31-22.svg"; +import windows11ClosedHandUrl from "../../../assets/cursors/windows11/grabbing__50-50.svg"; +import windows11CrosshairUrl from "../../../assets/cursors/windows11/cross__50-50.svg"; +import windows11NotAllowedUrl from "../../../assets/cursors/windows11/block__50-50.svg"; +import windows11OpenHandUrl from "../../../assets/cursors/windows11/grab__50-50.svg"; +import windows11PointerUrl from "../../../assets/cursors/windows11/pointer__54-35.svg"; +import windows11ResizeEwUrl from "../../../assets/cursors/windows11/resize-horizontal__51-49.svg"; +import windows11ResizeNsUrl from "../../../assets/cursors/windows11/resize-vertical__48-50.svg"; +import windows11TextUrl from "../../../assets/cursors/windows11/text__50-50.svg"; +import type { CursorStyle, CursorTelemetryPoint } from "../types"; + +type CursorAssetKey = NonNullable<CursorTelemetryPoint["cursorType"]>; +type CursorSetStyle = Extract<CursorStyle, "macos" | "tahoe" | "tahoe-inverted" | "windows11">; + +const MACOS_POINTER_ASSET_HEIGHT = 746; +const MACOS_POINTER_CONTENT_HEIGHT = 386; +const TAHOE_POINTER_ASSET_HEIGHT = 958; +const TAHOE_POINTER_CONTENT_HEIGHT = 851; +const WINDOWS11_CURSOR_VIEWBOX_HEIGHT = 32; +const WINDOWS11_ARROW_CONTENT_HEIGHT = 18; + +// Measured from the raw pointer assets using the non-shadow pixel bounds. +const MACOS_CURSOR_STYLE_SIZE_MULTIPLIER = + TAHOE_POINTER_CONTENT_HEIGHT / + TAHOE_POINTER_ASSET_HEIGHT / + (MACOS_POINTER_CONTENT_HEIGHT / MACOS_POINTER_ASSET_HEIGHT); + +export type UploadedCursorAsset = { + url: string; + fallbackAnchor: { + x: number; + y: number; + }; + preserveCanvas?: boolean; +}; + +function asset( + url: string, + hotspotX: number, + hotspotY: number, + options: Pick<UploadedCursorAsset, "preserveCanvas"> = {}, +): UploadedCursorAsset { + return { + url, + fallbackAnchor: { + x: hotspotX / 100, + y: hotspotY / 100, + }, + ...options, + }; +} + +export const cursorSetAssets: Record< + Exclude<CursorSetStyle, "tahoe-inverted">, + Record<CursorAssetKey, UploadedCursorAsset> +> = { + macos: { + arrow: asset(macosArrowUrl, 34, 24), + text: asset(macosTextUrl, 50, 50), + pointer: asset(macosPointerUrl, 39, 26), + crosshair: asset(macosCrosshairUrl, 50, 50), + "open-hand": asset(macosOpenHandUrl, 50, 50), + "closed-hand": asset(macosClosedHandUrl, 50, 50), + "resize-ew": asset(macosResizeEwUrl, 50, 50), + "resize-ns": asset(macosResizeNsUrl, 50, 50), + "not-allowed": asset(macosNotAllowedUrl, 23, 0), + }, + tahoe: { + arrow: asset(tahoeArrowUrl, 14, 6), + text: asset(tahoeTextUrl, 50, 44), + pointer: asset(tahoePointerUrl, 40, 10), + crosshair: asset(tahoeCrosshairUrl, 50, 50), + "open-hand": asset(tahoeOpenHandUrl, 55, 57), + "closed-hand": asset(tahoeClosedHandUrl, 50, 46), + "resize-ew": asset(tahoeResizeEwUrl, 50, 50), + "resize-ns": asset(tahoeResizeNsUrl, 50, 49), + "not-allowed": asset(tahoeNotAllowedUrl, 23, 0), + }, + windows11: { + arrow: asset(windows11ArrowUrl, 31, 22, { preserveCanvas: true }), + text: asset(windows11TextUrl, 50, 50, { preserveCanvas: true }), + pointer: asset(windows11PointerUrl, 54, 35, { preserveCanvas: true }), + crosshair: asset(windows11CrosshairUrl, 50, 50, { preserveCanvas: true }), + "open-hand": asset(windows11OpenHandUrl, 50, 50, { preserveCanvas: true }), + "closed-hand": asset(windows11ClosedHandUrl, 50, 50, { preserveCanvas: true }), + "resize-ew": asset(windows11ResizeEwUrl, 51, 49, { preserveCanvas: true }), + "resize-ns": asset(windows11ResizeNsUrl, 48, 50, { preserveCanvas: true }), + "not-allowed": asset(windows11NotAllowedUrl, 50, 50, { preserveCanvas: true }), + }, +}; + +export const cursorStyleSizeMultipliers: Record<CursorSetStyle, number> = { + macos: MACOS_CURSOR_STYLE_SIZE_MULTIPLIER, + tahoe: 1, + "tahoe-inverted": 1, + windows11: WINDOWS11_CURSOR_VIEWBOX_HEIGHT / WINDOWS11_ARROW_CONTENT_HEIGHT, +}; + +export function getCursorStyleSizeMultiplier(style: CursorStyle) { + return style in cursorStyleSizeMultipliers + ? cursorStyleSizeMultipliers[style as CursorSetStyle] + : 1; +} + +export const uploadedCursorAssets = cursorSetAssets.tahoe; diff --git a/src/components/video-editor/videoPlayback/videoEventHandlers.test.ts b/src/components/video-editor/videoPlayback/videoEventHandlers.test.ts new file mode 100644 index 000000000..52237aafa --- /dev/null +++ b/src/components/video-editor/videoPlayback/videoEventHandlers.test.ts @@ -0,0 +1,195 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { createVideoEventHandlers } from "./videoEventHandlers"; + +type PresentedFrameCallback = (now: DOMHighResTimeStamp, metadata: { mediaTime?: number }) => void; + +type MockVideo = HTMLVideoElement & { + requestVideoFrameCallback?: (callback: PresentedFrameCallback) => number; + cancelVideoFrameCallback?: (handle: number) => void; +}; + +function createMutableRef<T>(value: T) { + return { current: value }; +} + +function createMockVideo(overrides: Partial<MockVideo> = {}): MockVideo { + const video = { + currentTime: 0.5, + duration: 10, + paused: false, + ended: false, + playbackRate: 1, + pause: vi.fn(), + } as unknown as MockVideo; + + return Object.assign(video, overrides); +} + +describe("createVideoEventHandlers", () => { + let requestAnimationFrameMock: ReturnType<typeof vi.fn>; + let cancelAnimationFrameMock: ReturnType<typeof vi.fn>; + + beforeEach(() => { + requestAnimationFrameMock = vi.fn(() => 11); + cancelAnimationFrameMock = vi.fn(); + vi.stubGlobal("requestAnimationFrame", requestAnimationFrameMock); + vi.stubGlobal("cancelAnimationFrame", cancelAnimationFrameMock); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("prefers requestVideoFrameCallback mediaTime when available", () => { + let presentedFrameCallback: PresentedFrameCallback | null = null; + const video = createMockVideo({ + requestVideoFrameCallback: vi.fn((callback) => { + presentedFrameCallback = callback; + return 7; + }), + cancelVideoFrameCallback: vi.fn(), + }); + const onPlayStateChange = vi.fn(); + const onTimeUpdate = vi.fn(); + const currentTimeRef = createMutableRef(0); + const timeUpdateAnimationRef = createMutableRef<number | null>(null); + + const handlers = createVideoEventHandlers({ + video, + isSeekingRef: createMutableRef(false), + isPlayingRef: createMutableRef(false), + allowPlaybackRef: createMutableRef(true), + currentTimeRef, + timeUpdateAnimationRef, + onPlayStateChange, + onTimeUpdate, + trimRegionsRef: createMutableRef([]), + speedRegionsRef: createMutableRef([]), + }); + + handlers.handlePlay(); + expect(onPlayStateChange).toHaveBeenCalledWith(true); + expect(video.requestVideoFrameCallback).toHaveBeenCalledTimes(1); + expect(requestAnimationFrameMock).not.toHaveBeenCalled(); + + presentedFrameCallback?.(0, { mediaTime: 1.25 }); + + expect(onTimeUpdate).toHaveBeenCalledWith(1.25); + expect(currentTimeRef.current).toBe(1250); + }); + + it("falls back to requestAnimationFrame when requestVideoFrameCallback is unavailable", () => { + let animationFrameCallback: FrameRequestCallback | null = null; + requestAnimationFrameMock.mockImplementation((callback: FrameRequestCallback) => { + animationFrameCallback = callback; + return 19; + }); + const video = createMockVideo({ currentTime: 0.75 }); + const onTimeUpdate = vi.fn(); + + const handlers = createVideoEventHandlers({ + video, + isSeekingRef: createMutableRef(false), + isPlayingRef: createMutableRef(false), + allowPlaybackRef: createMutableRef(true), + currentTimeRef: createMutableRef(0), + timeUpdateAnimationRef: createMutableRef<number | null>(null), + onPlayStateChange: vi.fn(), + onTimeUpdate, + trimRegionsRef: createMutableRef([]), + speedRegionsRef: createMutableRef([]), + }); + + handlers.handlePlay(); + expect(requestAnimationFrameMock).toHaveBeenCalledTimes(1); + + video.paused = true; + animationFrameCallback?.(0); + + expect(onTimeUpdate).toHaveBeenCalledWith(0.75); + }); + + it("skips removed footage when playback reaches a cut region", () => { + let animationFrameCallback: FrameRequestCallback | null = null; + requestAnimationFrameMock.mockImplementation((callback: FrameRequestCallback) => { + animationFrameCallback = callback; + return 29; + }); + const video = createMockVideo({ currentTime: 1.25, duration: 10 }); + const onTimeUpdate = vi.fn(); + const handlers = createVideoEventHandlers({ + video, + isSeekingRef: createMutableRef(false), + isPlayingRef: createMutableRef(false), + allowPlaybackRef: createMutableRef(true), + currentTimeRef: createMutableRef(0), + timeUpdateAnimationRef: createMutableRef<number | null>(null), + onPlayStateChange: vi.fn(), + onTimeUpdate, + trimRegionsRef: createMutableRef([{ id: "trim-1", startMs: 1000, endMs: 2000 }]), + speedRegionsRef: createMutableRef([]), + }); + + handlers.handlePlay(); + animationFrameCallback?.(0); + + expect(video.currentTime).toBe(2); + expect(video.pause).not.toHaveBeenCalled(); + expect(onTimeUpdate).toHaveBeenLastCalledWith(2); + }); + + it("cancels a pending requestVideoFrameCallback on pause and dispose", () => { + const cancelVideoFrameCallback = vi.fn(); + const video = createMockVideo({ + requestVideoFrameCallback: vi.fn(() => 23), + cancelVideoFrameCallback, + }); + const handlers = createVideoEventHandlers({ + video, + isSeekingRef: createMutableRef(false), + isPlayingRef: createMutableRef(false), + allowPlaybackRef: createMutableRef(true), + currentTimeRef: createMutableRef(0), + timeUpdateAnimationRef: createMutableRef<number | null>(null), + onPlayStateChange: vi.fn(), + onTimeUpdate: vi.fn(), + trimRegionsRef: createMutableRef([]), + speedRegionsRef: createMutableRef([]), + }); + + handlers.handlePlay(); + handlers.handlePause(); + expect(cancelVideoFrameCallback).toHaveBeenCalledWith(23); + + cancelVideoFrameCallback.mockClear(); + handlers.handlePlay(); + handlers.dispose(); + expect(cancelVideoFrameCallback).toHaveBeenCalledWith(23); + }); + + it("skips removed footage after a paused seek", () => { + const video = createMockVideo({ + currentTime: 1.25, + paused: true, + }); + const onTimeUpdate = vi.fn(); + const handlers = createVideoEventHandlers({ + video, + isSeekingRef: createMutableRef(true), + isPlayingRef: createMutableRef(false), + allowPlaybackRef: createMutableRef(true), + currentTimeRef: createMutableRef(0), + timeUpdateAnimationRef: createMutableRef<number | null>(null), + onPlayStateChange: vi.fn(), + onTimeUpdate, + trimRegionsRef: createMutableRef([{ id: "trim-1", startMs: 1000, endMs: 2000 }]), + speedRegionsRef: createMutableRef([]), + }); + + handlers.handleSeeked(); + + expect(video.currentTime).toBe(2); + expect(onTimeUpdate).toHaveBeenLastCalledWith(2); + }); +}); diff --git a/src/components/video-editor/videoPlayback/videoEventHandlers.ts b/src/components/video-editor/videoPlayback/videoEventHandlers.ts index 86b8201ff..d3ac2db59 100644 --- a/src/components/video-editor/videoPlayback/videoEventHandlers.ts +++ b/src/components/video-editor/videoPlayback/videoEventHandlers.ts @@ -1,92 +1,191 @@ -import type React from 'react'; +import type React from "react"; +import { enablePitchPreservingPlayback } from "@/lib/mediaTiming"; +import type { SpeedRegion, TrimRegion } from "../types"; + +interface PresentedFrameMetadata { + mediaTime?: number; +} + +type PresentedFrameVideoElement = HTMLVideoElement & { + requestVideoFrameCallback?: ( + callback: (now: DOMHighResTimeStamp, metadata: PresentedFrameMetadata) => void, + ) => number; + cancelVideoFrameCallback?: (handle: number) => void; +}; interface VideoEventHandlersParams { - video: HTMLVideoElement; - isSeekingRef: React.MutableRefObject<boolean>; - isPlayingRef: React.MutableRefObject<boolean>; - allowPlaybackRef: React.MutableRefObject<boolean>; - currentTimeRef: React.MutableRefObject<number>; - timeUpdateAnimationRef: React.MutableRefObject<number | null>; - onPlayStateChange: (playing: boolean) => void; - onTimeUpdate: (time: number) => void; + video: HTMLVideoElement; + isSeekingRef: React.MutableRefObject<boolean>; + isPlayingRef: React.MutableRefObject<boolean>; + allowPlaybackRef: React.MutableRefObject<boolean>; + currentTimeRef: React.MutableRefObject<number>; + timeUpdateAnimationRef: React.MutableRefObject<number | null>; + onPlayStateChange: (playing: boolean) => void; + onTimeUpdate: (time: number) => void; + trimRegionsRef: React.MutableRefObject<TrimRegion[]>; + speedRegionsRef: React.MutableRefObject<SpeedRegion[]>; } export function createVideoEventHandlers(params: VideoEventHandlersParams) { - const { - video, - isSeekingRef, - isPlayingRef, - allowPlaybackRef, - currentTimeRef, - timeUpdateAnimationRef, - onPlayStateChange, - onTimeUpdate, - } = params; - - const emitTime = (timeValue: number) => { - currentTimeRef.current = timeValue * 1000; - onTimeUpdate(timeValue); - }; - - function updateTime() { - if (!video) return; - emitTime(video.currentTime); - if (!video.paused && !video.ended) { - timeUpdateAnimationRef.current = requestAnimationFrame(updateTime); - } - } - - const handlePlay = () => { - if (isSeekingRef.current) { - video.pause(); - return; - } - - if (!allowPlaybackRef.current) { - video.pause(); - return; - } - - isPlayingRef.current = true; - onPlayStateChange(true); - if (timeUpdateAnimationRef.current) { - cancelAnimationFrame(timeUpdateAnimationRef.current); - } - timeUpdateAnimationRef.current = requestAnimationFrame(updateTime); - }; - - const handlePause = () => { - isPlayingRef.current = false; - onPlayStateChange(false); - if (timeUpdateAnimationRef.current) { - cancelAnimationFrame(timeUpdateAnimationRef.current); - timeUpdateAnimationRef.current = null; - } - emitTime(video.currentTime); - }; - - const handleSeeked = () => { - isSeekingRef.current = false; - - if (!isPlayingRef.current && !video.paused) { - video.pause(); - } - emitTime(video.currentTime); - }; - - const handleSeeking = () => { - isSeekingRef.current = true; - - if (!isPlayingRef.current && !video.paused) { - video.pause(); - } - emitTime(video.currentTime); - }; - - return { - handlePlay, - handlePause, - handleSeeked, - handleSeeking, - }; + const { + video, + isSeekingRef, + isPlayingRef, + allowPlaybackRef, + currentTimeRef, + timeUpdateAnimationRef, + onPlayStateChange, + onTimeUpdate, + trimRegionsRef, + speedRegionsRef, + } = params; + const presentedFrameVideo = video as PresentedFrameVideoElement; + let videoFrameRequestId: number | null = null; + enablePitchPreservingPlayback(video); + + const emitTime = (timeValue: number) => { + currentTimeRef.current = timeValue * 1000; + onTimeUpdate(timeValue); + }; + + // Helper function to check if current time is within a trim region + const findActiveTrimRegion = (currentTimeMs: number): TrimRegion | null => { + const trimRegions = trimRegionsRef.current; + return ( + trimRegions.find( + (region) => currentTimeMs >= region.startMs && currentTimeMs < region.endMs, + ) || null + ); + }; + + // Helper function to find the active speed region at the current time + const findActiveSpeedRegion = (currentTimeMs: number): SpeedRegion | null => { + return ( + speedRegionsRef.current.find( + (region) => currentTimeMs >= region.startMs && currentTimeMs < region.endMs, + ) || null + ); + }; + + const skipPastTrimRegion = (trimRegion: TrimRegion) => { + const skipToTime = trimRegion.endMs / 1000; + const clampedSkipToTime = Math.min(skipToTime, video.duration); + + video.currentTime = clampedSkipToTime; + emitTime(clampedSkipToTime); + + if (clampedSkipToTime >= video.duration) { + video.pause(); + } + }; + + const cancelScheduledUpdate = () => { + if (timeUpdateAnimationRef.current !== null) { + cancelAnimationFrame(timeUpdateAnimationRef.current); + timeUpdateAnimationRef.current = null; + } + + if ( + videoFrameRequestId !== null && + typeof presentedFrameVideo.cancelVideoFrameCallback === "function" + ) { + presentedFrameVideo.cancelVideoFrameCallback(videoFrameRequestId); + videoFrameRequestId = null; + } + }; + + const scheduleNextUpdate = () => { + if (video.paused || video.ended) { + return; + } + + // Align editor state with the frame Chromium actually presented instead of + // polling `currentTime` on a generic animation frame. + if (typeof presentedFrameVideo.requestVideoFrameCallback === "function") { + videoFrameRequestId = presentedFrameVideo.requestVideoFrameCallback( + (_now, metadata) => { + videoFrameRequestId = null; + updateTime(metadata); + }, + ); + return; + } + + timeUpdateAnimationRef.current = requestAnimationFrame(() => { + timeUpdateAnimationRef.current = null; + updateTime(); + }); + }; + + function getPresentedTime(metadata?: PresentedFrameMetadata): number { + const mediaTime = metadata?.mediaTime; + return Number.isFinite(mediaTime) ? (mediaTime ?? 0) : video.currentTime; + } + + function updateTime(metadata?: PresentedFrameMetadata) { + if (!video) return; + + const presentedTime = getPresentedTime(metadata); + const currentTimeMs = presentedTime * 1000; + const activeTrimRegion = findActiveTrimRegion(currentTimeMs); + + // If we're in a trim region during playback, skip to the end of it + if (activeTrimRegion && !video.paused && !video.ended) { + skipPastTrimRegion(activeTrimRegion); + } else { + // Apply playback speed from active speed region + const activeSpeedRegion = findActiveSpeedRegion(currentTimeMs); + enablePitchPreservingPlayback(video); + video.playbackRate = activeSpeedRegion ? activeSpeedRegion.speed : 1; + emitTime(presentedTime); + } + + scheduleNextUpdate(); + } + + const handlePlay = () => { + if (!allowPlaybackRef.current) { + video.pause(); + return; + } + + isPlayingRef.current = true; + onPlayStateChange(true); + cancelScheduledUpdate(); + scheduleNextUpdate(); + }; + + const handlePause = () => { + isPlayingRef.current = false; + onPlayStateChange(false); + cancelScheduledUpdate(); + emitTime(video.currentTime); + }; + + const handleSeeked = () => { + isSeekingRef.current = false; + + const currentTimeMs = video.currentTime * 1000; + const activeTrimRegion = findActiveTrimRegion(currentTimeMs); + + // Never leave the preview parked on removed footage after a seek. + if (activeTrimRegion) { + skipPastTrimRegion(activeTrimRegion); + } else { + emitTime(video.currentTime); + } + }; + + const handleSeeking = () => { + isSeekingRef.current = true; + emitTime(video.currentTime); + }; + + return { + dispose: cancelScheduledUpdate, + handlePlay, + handlePause, + handleSeeked, + handleSeeking, + }; } diff --git a/src/components/video-editor/videoPlayback/webcamSync.test.ts b/src/components/video-editor/videoPlayback/webcamSync.test.ts new file mode 100644 index 000000000..492b47646 --- /dev/null +++ b/src/components/video-editor/videoPlayback/webcamSync.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, it } from "vitest"; +import { + getWebcamMediaTargetTimeSeconds, + getWebcamPreviewTargetTimeSeconds, + isWebcamMediaSynchronized, + shouldSeekWebcamMedia, +} from "./webcamSync"; + +describe("getWebcamPreviewTargetTimeSeconds", () => { + it("subtracts positive webcam offsets when the webcam started after the main capture", () => { + expect( + getWebcamPreviewTargetTimeSeconds({ + currentTime: 10, + webcamDuration: 20, + timeOffsetMs: 250, + }), + ).toBe(9.75); + }); + + it("adds negative webcam offsets when the webcam started before the main capture", () => { + expect( + getWebcamPreviewTargetTimeSeconds({ + currentTime: 0.1, + webcamDuration: 20, + timeOffsetMs: -250, + }), + ).toBe(0.35); + }); + + it("falls back to the unshifted time when the offset is invalid", () => { + expect( + getWebcamPreviewTargetTimeSeconds({ + currentTime: 3.5, + webcamDuration: 20, + timeOffsetMs: Number.NaN, + }), + ).toBe(3.5); + }); + + it("clamps to the webcam duration", () => { + expect( + getWebcamPreviewTargetTimeSeconds({ + currentTime: 8.9, + webcamDuration: 9, + timeOffsetMs: -500, + }), + ).toBe(9); + }); +}); + +describe("getWebcamMediaTargetTimeSeconds", () => { + it("clamps positive offsets at zero when the main timeline is earlier than the webcam start", () => { + expect( + getWebcamMediaTargetTimeSeconds({ + currentTime: 0.1, + webcamDuration: 20, + timeOffsetMs: 250, + }), + ).toBe(0); + }); +}); + +describe("shouldSeekWebcamMedia", () => { + it("does not issue another corrective seek while the webcam element is already seeking", () => { + expect( + shouldSeekWebcamMedia({ + desiredTime: 10.5, + isPlaying: true, + isSeeking: true, + previousTimelineTime: 10, + timelineTime: 10.5, + webcamCurrentTime: 9.8, + }), + ).toBe(false); + }); + + it("seeks when playback drift grows beyond the active threshold", () => { + expect( + shouldSeekWebcamMedia({ + desiredTime: 10.5, + isPlaying: true, + isSeeking: false, + previousTimelineTime: 10, + timelineTime: 10.5, + webcamCurrentTime: 9.8, + }), + ).toBe(true); + }); + + it("does not seek when the clamped media target is already correct", () => { + expect( + shouldSeekWebcamMedia({ + desiredTime: 1 / 60, + isPlaying: false, + isSeeking: false, + previousTimelineTime: 0, + timelineTime: 0, + webcamCurrentTime: 1 / 60, + }), + ).toBe(false); + }); +}); + +describe("isWebcamMediaSynchronized", () => { + it("waits for current frame data and a completed seek", () => { + expect( + isWebcamMediaSynchronized({ + currentTime: 10, + targetTime: 10, + readyState: 1, + isSeeking: false, + }), + ).toBe(false); + expect( + isWebcamMediaSynchronized({ + currentTime: 10, + targetTime: 10, + readyState: 2, + isSeeking: true, + }), + ).toBe(false); + }); + + it("accepts a loaded webcam only when it is anchored to the timeline", () => { + expect( + isWebcamMediaSynchronized({ + currentTime: 9.95, + targetTime: 10, + readyState: 2, + isSeeking: false, + }), + ).toBe(true); + expect( + isWebcamMediaSynchronized({ + currentTime: 9.5, + targetTime: 10, + readyState: 2, + isSeeking: false, + }), + ).toBe(false); + }); +}); diff --git a/src/components/video-editor/videoPlayback/webcamSync.ts b/src/components/video-editor/videoPlayback/webcamSync.ts new file mode 100644 index 000000000..660b882b5 --- /dev/null +++ b/src/components/video-editor/videoPlayback/webcamSync.ts @@ -0,0 +1,73 @@ +import { clampMediaTimeToDuration } from "@/lib/mediaTiming"; + +const HAVE_CURRENT_DATA_READY_STATE = 2; + +/** + * Maps the editor timeline time to the corresponding webcam media timestamp, + * accounting for any recorded webcam start offset and media duration clamps. + */ +export function getWebcamMediaTargetTimeSeconds({ + currentTime, + webcamDuration, + timeOffsetMs, +}: { + currentTime: number; + webcamDuration?: number | null; + timeOffsetMs?: number | null; +}): number { + const safeOffsetMs = Number.isFinite(timeOffsetMs) ? (timeOffsetMs ?? 0) : 0; + const shiftedTime = currentTime - safeOffsetMs / 1000; + return clampMediaTimeToDuration(shiftedTime, webcamDuration); +} + +export const getWebcamPreviewTargetTimeSeconds = getWebcamMediaTargetTimeSeconds; + +export function isWebcamMediaSynchronized({ + currentTime, + targetTime, + readyState, + isSeeking, + toleranceSeconds = 0.12, +}: { + currentTime: number; + targetTime: number; + readyState: number; + isSeeking: boolean; + toleranceSeconds?: number; +}): boolean { + return ( + readyState >= HAVE_CURRENT_DATA_READY_STATE && + !isSeeking && + Math.abs(currentTime - targetTime) <= toleranceSeconds + ); +} + +/** + * Decides whether the webcam media element needs a corrective seek for the + * current preview frame, while avoiding repeated seeks during active media seeks. + */ +export function shouldSeekWebcamMedia({ + desiredTime, + isPlaying, + isSeeking, + previousTimelineTime, + timelineTime, + webcamCurrentTime, +}: { + desiredTime: number; + isPlaying: boolean; + isSeeking: boolean; + previousTimelineTime: number | null; + timelineTime: number; + webcamCurrentTime: number; +}): boolean { + if (isSeeking) { + return false; + } + + const timelineJumped = + previousTimelineTime === null || Math.abs(timelineTime - previousTimelineTime) > 0.25; + const driftThreshold = isPlaying ? 0.35 : 0.01; + + return timelineJumped || Math.abs(webcamCurrentTime - desiredTime) > driftThreshold; +} diff --git a/src/components/video-editor/videoPlayback/zoomAnimation.test.ts b/src/components/video-editor/videoPlayback/zoomAnimation.test.ts new file mode 100644 index 000000000..b98988591 --- /dev/null +++ b/src/components/video-editor/videoPlayback/zoomAnimation.test.ts @@ -0,0 +1,554 @@ +import { describe, expect, it } from "vitest"; +import type { ZoomRegion } from "../types"; +import { clamp01, cubicBezier, easeOutExpo, easeOutZoom } from "./mathUtils"; +import { + clampDeltaMs, + createSpringState, + getCursorSpringConfig, + getZoomSpringConfig, + resetSpringState, + type SpringConfig, + type SpringState, + stepSpringValue, +} from "./motionSmoothing"; +import { computeRegionStrength, findDominantRegion } from "./zoomRegionUtils"; + +// --------------------------------------------------------------------------- +// motionSmoothing — spring state helpers +// --------------------------------------------------------------------------- + +describe("createSpringState", () => { + it("returns uninitialized state at 0", () => { + const s = createSpringState(); + expect(s).toEqual({ value: 0, velocity: 0, initialized: false }); + }); + + it("accepts a custom initial value", () => { + const s = createSpringState(5); + expect(s.value).toBe(5); + expect(s.initialized).toBe(false); + }); +}); + +describe("resetSpringState", () => { + it("clears velocity and initialized flag", () => { + const s: SpringState = { value: 3, velocity: 10, initialized: true }; + resetSpringState(s); + expect(s.velocity).toBe(0); + expect(s.initialized).toBe(false); + expect(s.value).toBe(3); // kept + }); + + it("resets value when provided", () => { + const s: SpringState = { value: 3, velocity: 10, initialized: true }; + resetSpringState(s, 7); + expect(s.value).toBe(7); + expect(s.velocity).toBe(0); + }); +}); + +describe("clampDeltaMs", () => { + it("passes through a normal 16ms frame", () => { + expect(clampDeltaMs(16.67)).toBeCloseTo(16.67, 2); + }); + + it("clamps negative delta to fallback", () => { + expect(clampDeltaMs(-1)).toBeCloseTo(1000 / 60, 2); + }); + + it("clamps zero delta to fallback", () => { + expect(clampDeltaMs(0)).toBeCloseTo(1000 / 60, 2); + }); + + it("clamps NaN to fallback", () => { + expect(clampDeltaMs(Number.NaN)).toBeCloseTo(1000 / 60, 2); + }); + + it("clamps excessively large delta to 80ms", () => { + expect(clampDeltaMs(500)).toBe(80); + }); +}); + +// --------------------------------------------------------------------------- +// motionSmoothing — stepSpringValue +// --------------------------------------------------------------------------- + +describe("stepSpringValue", () => { + const config: SpringConfig = { + stiffness: 320, + damping: 40, + mass: 0.92, + restDelta: 0.0005, + restSpeed: 0.015, + }; + + it("snaps to target on first tick (initialization)", () => { + const s = createSpringState(); + const result = stepSpringValue(s, 1, 16, config); + expect(result).toBe(1); + expect(s.initialized).toBe(true); + }); + + it("converges toward the target over many ticks", () => { + const s = createSpringState(); + stepSpringValue(s, 0, 16, config); // init at 0 + + // Drive toward 1 over many frames + for (let i = 0; i < 200; i++) { + stepSpringValue(s, 1, 16, config); + } + + expect(s.value).toBeCloseTo(1, 3); + }); + + it("converges within reasonable time for default zoom config", () => { + const zoomConfig = getZoomSpringConfig(1.0); + const s = createSpringState(); + stepSpringValue(s, 0, 16, zoomConfig); // init at 0 + + let frames = 0; + while (Math.abs(s.value - 1) > 0.001 && frames < 500) { + stepSpringValue(s, 1, 16, zoomConfig); + frames++; + } + + // Should converge well within 500 frames (~8 seconds) + // High damping (ζ ≈ 2) settles without overshoot but takes longer + expect(frames).toBeLessThan(400); // ~6.5s at 60fps + expect(s.value).toBeCloseTo(1, 2); + }); + + it("handles target change mid-animation", () => { + const s = createSpringState(); + stepSpringValue(s, 0.5, 16, config); + + // Animate toward 1 + for (let i = 0; i < 30; i++) { + stepSpringValue(s, 1, 16, config); + } + const midway = s.value; + expect(midway).toBeGreaterThan(0.5); + + // Reverse direction toward 0 + for (let i = 0; i < 200; i++) { + stepSpringValue(s, 0, 16, config); + } + expect(s.value).toBeCloseTo(0, 2); + }); + + it("settles exactly at target when within rest thresholds", () => { + const s = createSpringState(); + stepSpringValue(s, 0, 16, config); + + // Manually place near target + s.value = 0.9999; + s.velocity = 0; + stepSpringValue(s, 1, 16, config); + + expect(s.value).toBe(1); + expect(s.velocity).toBe(0); + }); + + it("does not produce NaN or Infinity values", () => { + const s = createSpringState(); + stepSpringValue(s, 0, 16, config); + + for (let i = 0; i < 300; i++) { + stepSpringValue(s, Math.sin(i * 0.1), 16, config); + expect(Number.isFinite(s.value)).toBe(true); + expect(Number.isFinite(s.velocity)).toBe(true); + } + }); +}); + +// --------------------------------------------------------------------------- +// motionSmoothing — spring configs +// --------------------------------------------------------------------------- + +describe("getZoomSpringConfig", () => { + it("returns snap-like config at smoothness 0", () => { + const c = getZoomSpringConfig(0); + expect(c.stiffness).toBe(1000); + expect(c.damping).toBe(100); + }); + + it("returns the default mapped config at smoothness 0.5", () => { + const c = getZoomSpringConfig(0.5); + expect(c.stiffness).toBe(100); + expect(c.mass).toBeCloseTo(1, 2); + }); + + it("returns the max mapped config at smoothness 1.0", () => { + const c = getZoomSpringConfig(1.0); + expect(c.stiffness).toBe(50); + expect(c.mass).toBeCloseTo(2, 2); + }); + + it("clamps values above 1.0 to the max config", () => { + expect(getZoomSpringConfig(2.0)).toEqual(getZoomSpringConfig(1.0)); + }); + + it("clamps values far above 1.0 to the max config", () => { + const c = getZoomSpringConfig(5); + expect(c).toEqual(getZoomSpringConfig(1.0)); + }); + + it("defaults to 0.5 when called without arguments", () => { + expect(getZoomSpringConfig()).toEqual(getZoomSpringConfig(0.5)); + }); +}); + +describe("getCursorSpringConfig", () => { + it("returns stiff config at 0 (no smoothing)", () => { + const c = getCursorSpringConfig(0); + expect(c.stiffness).toBe(1000); + }); + + it("returns config in legacy range at 0.25", () => { + const c = getCursorSpringConfig(0.25); + expect(c.stiffness).toBeLessThan(760); + expect(c.stiffness).toBeGreaterThan(300); + expect(c.mass).toBeGreaterThan(1); + }); + + it("returns config in extended range at 1.5", () => { + const c = getCursorSpringConfig(1.5); + expect(c.stiffness).toBeLessThan(340); + expect(c.mass).toBeGreaterThan(1.6); + }); + + it("clamps at max smoothing", () => { + expect(getCursorSpringConfig(999)).toEqual(getCursorSpringConfig(2)); + }); + + it("applies cursor spring tuning multipliers", () => { + const untuned = getCursorSpringConfig(0.5); + const tuned = getCursorSpringConfig(0.5, { + stiffnessMultiplier: 1.5, + dampingMultiplier: 0.75, + massMultiplier: 1.25, + }); + + expect(tuned.stiffness).toBeCloseTo(untuned.stiffness * 1.5, 6); + expect(tuned.damping).toBeCloseTo(untuned.damping * 0.75, 6); + expect(tuned.mass).toBeCloseTo(untuned.mass * 1.25, 6); + }); +}); + +// --------------------------------------------------------------------------- +// mathUtils +// --------------------------------------------------------------------------- + +describe("clamp01", () => { + it("passes values in [0,1]", () => { + expect(clamp01(0.5)).toBe(0.5); + }); + it("clamps below 0", () => { + expect(clamp01(-0.1)).toBe(0); + }); + it("clamps above 1", () => { + expect(clamp01(1.5)).toBe(1); + }); +}); + +describe("easeOutZoom", () => { + it("starts at 0", () => { + expect(easeOutZoom(0)).toBeCloseTo(0, 4); + }); + + it("ends at 1", () => { + expect(easeOutZoom(1)).toBeCloseTo(1, 4); + }); + + it("is monotonically increasing", () => { + let previous = 0; + for (let t = 0.05; t <= 1; t += 0.05) { + const current = easeOutZoom(t); + expect(current).toBeGreaterThanOrEqual(previous); + previous = current; + } + }); + + it("has steep initial rise (ease-out character)", () => { + // At t=0.25 the curve should already be well above linear (0.25) + expect(easeOutZoom(0.25)).toBeGreaterThan(0.5); + }); +}); + +describe("easeOutExpo", () => { + it("starts at 0 and ends at 1", () => { + expect(easeOutExpo(0)).toBeCloseTo(0, 4); + expect(easeOutExpo(1)).toBe(1); + }); +}); + +describe("cubicBezier", () => { + it("linear bezier returns identity", () => { + for (let t = 0; t <= 1; t += 0.1) { + expect(cubicBezier(0.333, 0.333, 0.666, 0.666, t)).toBeCloseTo(t, 1); + } + }); +}); + +// --------------------------------------------------------------------------- +// zoomRegionUtils — computeRegionStrength +// --------------------------------------------------------------------------- + +describe("computeRegionStrength", () => { + const region: ZoomRegion = { + id: "z1", + startMs: 2000, + endMs: 5000, + depth: 2, + focus: { cx: 0.5, cy: 0.5 }, + }; + + it("returns 0 well before the region", () => { + expect(computeRegionStrength(region, 0)).toBe(0); + }); + + it("returns 0 well after the region", () => { + expect(computeRegionStrength(region, 10000)).toBe(0); + }); + + it("reaches full strength during the hold phase", () => { + // Mid-region: after zoom-in completes, before zoom-out starts + expect(computeRegionStrength(region, 3500)).toBe(1); + }); + + it("rises smoothly during zoom-in", () => { + // Zoom-in transitions from leadInStart .. zoomInEnd + // zoomInEnd = startMs + 500, leadInStart = zoomInEnd - 1500 = startMs - 1000 + // So at startMs the transition is partially done + const s = computeRegionStrength(region, region.startMs); + expect(s).toBeGreaterThan(0); + expect(s).toBeLessThan(1); + }); + + it("falls smoothly during zoom-out", () => { + // Zoom-out now starts 200ms later than the original timing. + const zoomOutStart = region.endMs - 150; + const s = computeRegionStrength(region, zoomOutStart + 700); + expect(s).toBeGreaterThan(0); + expect(s).toBeLessThan(1); + }); + + it("shifts zoom timing when custom durations are provided", () => { + const defaultStrength = computeRegionStrength(region, region.startMs); + const fasterStrength = computeRegionStrength(region, region.startMs, { + zoomInDurationMs: 300, + zoomOutDurationMs: 300, + }); + + expect(fasterStrength).not.toBe(defaultStrength); + expect(fasterStrength).toBeGreaterThan(defaultStrength); + }); +}); + +// --------------------------------------------------------------------------- +// zoomRegionUtils — findDominantRegion +// --------------------------------------------------------------------------- + +describe("findDominantRegion", () => { + it("returns null region when no regions exist", () => { + const result = findDominantRegion([], 1000); + expect(result.region).toBeNull(); + expect(result.strength).toBe(0); + }); + + it("returns the active region at its hold phase", () => { + const regions: ZoomRegion[] = [ + { id: "a", startMs: 1000, endMs: 4000, depth: 2, focus: { cx: 0.3, cy: 0.3 } }, + ]; + const result = findDominantRegion(regions, 2500); + expect(result.region).not.toBeNull(); + expect(result.region!.id).toBe("a"); + expect(result.strength).toBe(1); + }); + + it("returns null region outside all regions", () => { + const regions: ZoomRegion[] = [ + { id: "a", startMs: 1000, endMs: 2000, depth: 2, focus: { cx: 0.5, cy: 0.5 } }, + ]; + const result = findDominantRegion(regions, 10000); + expect(result.region).toBeNull(); + }); + + it("connects chained zooms when connectZooms is true", () => { + const regions: ZoomRegion[] = [ + { id: "a", startMs: 1000, endMs: 3000, depth: 2, focus: { cx: 0.2, cy: 0.2 } }, + { id: "b", startMs: 3500, endMs: 6000, depth: 3, focus: { cx: 0.8, cy: 0.8 } }, + ]; + + // During the connected handoff, the next region becomes the spring target. + const result = findDominantRegion(regions, 3200, { connectZooms: true }); + expect(result.strength).toBe(1); + expect(result.transition).toBeNull(); + expect(result.region?.id).toBe("b"); + }); + + it("keeps the outgoing region active until the connected transition begins", () => { + const regions: ZoomRegion[] = [ + { id: "a", startMs: 1000, endMs: 3000, depth: 2, focus: { cx: 0.2, cy: 0.2 } }, + { id: "b", startMs: 3500, endMs: 6000, depth: 3, focus: { cx: 0.8, cy: 0.8 } }, + ]; + + const result = findDominantRegion(regions, 3100, { connectZooms: true }); + expect(result.transition).toBeNull(); + expect(result.region?.id).toBe("a"); + expect(result.strength).toBeGreaterThan(0); + }); + + it("keeps the incoming region at full strength after a connected handoff", () => { + const regions: ZoomRegion[] = [ + { id: "a", startMs: 1000, endMs: 3000, depth: 2, focus: { cx: 0.2, cy: 0.2 } }, + { id: "b", startMs: 3500, endMs: 6000, depth: 3, focus: { cx: 0.8, cy: 0.8 } }, + ]; + + const result = findDominantRegion(regions, 4300, { connectZooms: true }); + expect(result.transition).toBeNull(); + expect(result.region?.id).toBe("b"); + expect(result.strength).toBe(1); + }); + + it("does NOT connect zooms with a large gap", () => { + const regions: ZoomRegion[] = [ + { id: "a", startMs: 1000, endMs: 3000, depth: 2, focus: { cx: 0.2, cy: 0.2 } }, + { id: "b", startMs: 8000, endMs: 10000, depth: 3, focus: { cx: 0.8, cy: 0.8 } }, + ]; + + // In the gap — should be no active region + const result = findDominantRegion(regions, 5000, { connectZooms: true }); + expect(result.region).toBeNull(); + }); + + it("holds the next region's focus between connected-transition end and next start", () => { + const regions: ZoomRegion[] = [ + { id: "a", startMs: 1000, endMs: 3000, depth: 2, focus: { cx: 0.2, cy: 0.2 } }, + { id: "b", startMs: 4300, endMs: 7000, depth: 3, focus: { cx: 0.7, cy: 0.7 } }, + ]; + + // After transition end (3000+200+1000=4200) but before b starts (4300) + const result = findDominantRegion(regions, 4250, { connectZooms: true }); + expect(result.strength).toBe(1); + expect(result.region).not.toBeNull(); + expect(result.region!.id).toBe("b"); + }); +}); + +// --------------------------------------------------------------------------- +// ZoomRegion mode field +// --------------------------------------------------------------------------- + +describe("ZoomRegion mode field", () => { + it("accepts manual mode", () => { + const r: ZoomRegion = { + id: "m1", + startMs: 0, + endMs: 1000, + depth: 2, + focus: { cx: 0.5, cy: 0.5 }, + mode: "manual", + }; + expect(r.mode).toBe("manual"); + }); + + it("accepts auto mode", () => { + const r: ZoomRegion = { + id: "a1", + startMs: 0, + endMs: 1000, + depth: 2, + focus: { cx: 0.5, cy: 0.5 }, + mode: "auto", + }; + expect(r.mode).toBe("auto"); + }); + + it("mode is optional", () => { + const r: ZoomRegion = { + id: "x1", + startMs: 0, + endMs: 1000, + depth: 2, + focus: { cx: 0.5, cy: 0.5 }, + }; + expect(r.mode).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// Spring physics — damping regimes +// --------------------------------------------------------------------------- + +describe("spring damping regimes", () => { + it("underdamped spring overshoots then converges", () => { + // Low damping → expect overshoot + const config: SpringConfig = { + stiffness: 300, + damping: 10, + mass: 1, + restDelta: 0.0005, + restSpeed: 0.015, + }; + const s = createSpringState(); + stepSpringValue(s, 0, 16, config); + + let maxValue = 0; + for (let i = 0; i < 500; i++) { + stepSpringValue(s, 1, 16, config); + if (s.value > maxValue) maxValue = s.value; + } + + // Should have overshot past 1 at some point + expect(maxValue).toBeGreaterThan(1.01); + // But should converge + expect(s.value).toBeCloseTo(1, 1); + }); + + it("overdamped spring converges without overshoot", () => { + const config: SpringConfig = { + stiffness: 100, + damping: 200, + mass: 1, + restDelta: 0.0005, + restSpeed: 0.015, + }; + const s = createSpringState(); + stepSpringValue(s, 0, 16, config); + + let maxValue = 0; + for (let i = 0; i < 1000; i++) { + stepSpringValue(s, 1, 16, config); + if (s.value > maxValue) maxValue = s.value; + } + + // Overdamped should not overshoot + expect(maxValue).toBeLessThanOrEqual(1.001); + // Should still converge (overdamped converges slowly) + expect(s.value).toBeCloseTo(1, 2); + }); + + it("critically damped spring converges without oscillation", () => { + // ζ = c / (2√(km)) = 1 → c = 2√(km) + const stiffness = 200; + const mass = 1; + const criticalDamping = 2 * Math.sqrt(stiffness * mass); + + const config: SpringConfig = { + stiffness, + damping: criticalDamping, + mass, + restDelta: 0.0005, + restSpeed: 0.015, + }; + const s = createSpringState(); + stepSpringValue(s, 0, 16, config); + + for (let i = 0; i < 300; i++) { + stepSpringValue(s, 1, 16, config); + } + + expect(s.value).toBeCloseTo(1, 2); + }); +}); diff --git a/src/components/video-editor/videoPlayback/zoomRegionUtils.ts b/src/components/video-editor/videoPlayback/zoomRegionUtils.ts index 26a3817c8..ab1af945e 100644 --- a/src/components/video-editor/videoPlayback/zoomRegionUtils.ts +++ b/src/components/video-editor/videoPlayback/zoomRegionUtils.ts @@ -1,31 +1,210 @@ -import type { ZoomRegion } from "../types"; -import { smoothStep } from "./mathUtils"; -import { TRANSITION_WINDOW_MS } from "./constants"; +import type { ZoomFocus, ZoomRegion } from "../types"; +import { ZOOM_DEPTH_SCALES } from "../types"; +import { + TRANSITION_WINDOW_MS, + ZOOM_IN_TRANSITION_WINDOW_MS, + ZOOM_OUT_EARLY_START_MS, +} from "./constants"; +import { clampFocusToScale } from "./focusUtils"; +import { clamp01, easeOutZoom } from "./mathUtils"; -export function computeRegionStrength(region: ZoomRegion, timeMs: number) { - const leadInStart = region.startMs - TRANSITION_WINDOW_MS; - const leadOutEnd = region.endMs + TRANSITION_WINDOW_MS; +const CHAINED_ZOOM_PAN_GAP_MS = 1350; +const CONNECTED_ZOOM_PAN_DURATION_MS = 1000; +const ZOOM_IN_OVERLAP_MS = 1000; +const ZOOM_ANIMATION_LEAD_MS = 200; - if (timeMs < leadInStart || timeMs > leadOutEnd) { - return 0; - } +type DominantRegionOptions = { + connectZooms?: boolean; + zoomInDurationMs?: number; + zoomOutDurationMs?: number; +}; - const fadeIn = smoothStep((timeMs - leadInStart) / TRANSITION_WINDOW_MS); - const fadeOut = smoothStep((leadOutEnd - timeMs) / TRANSITION_WINDOW_MS); - return Math.min(fadeIn, fadeOut); +type ConnectedRegionPair = { + currentRegion: ZoomRegion; + nextRegion: ZoomRegion; + transitionStart: number; + transitionEnd: number; +}; + +type ConnectedPanTransition = { + progress: number; + startFocus: ZoomFocus; + endFocus: ZoomFocus; + startScale: number; + endScale: number; +}; + +export function computeRegionStrength( + region: ZoomRegion, + timeMs: number, + options: Pick<DominantRegionOptions, "zoomInDurationMs" | "zoomOutDurationMs"> = {}, +) { + const zoomInDurationMs = Math.max(1, options.zoomInDurationMs ?? ZOOM_IN_TRANSITION_WINDOW_MS); + const zoomOutDurationMs = Math.max(1, options.zoomOutDurationMs ?? TRANSITION_WINDOW_MS); + const adjustedTimeMs = timeMs - ZOOM_ANIMATION_LEAD_MS; + const leadInStart = region.startMs + ZOOM_IN_OVERLAP_MS - ZOOM_IN_TRANSITION_WINDOW_MS; + let zoomOutStart = region.endMs - ZOOM_OUT_EARLY_START_MS; + let zoomInEnd = leadInStart + zoomInDurationMs; + + if (zoomInEnd > zoomOutStart) { + const midpoint = (zoomInEnd + zoomOutStart) / 2; + zoomInEnd = midpoint; + zoomOutStart = midpoint; + } + + const leadOutEnd = zoomOutStart + zoomOutDurationMs; + + if (adjustedTimeMs < leadInStart || adjustedTimeMs > leadOutEnd) { + return 0; + } + + if (adjustedTimeMs < zoomInEnd) { + const progress = (adjustedTimeMs - leadInStart) / zoomInDurationMs; + return easeOutZoom(progress); + } + + if (adjustedTimeMs <= zoomOutStart) { + return 1; + } + + const progress = clamp01((adjustedTimeMs - zoomOutStart) / zoomOutDurationMs); + return 1 - easeOutZoom(progress); +} + +function getResolvedFocus(region: ZoomRegion, zoomScale: number): ZoomFocus { + return clampFocusToScale(region.focus, zoomScale); +} + +function getConnectedRegionPairs(regions: ZoomRegion[]) { + const sortedRegions = [...regions].sort((a, b) => a.startMs - b.startMs); + const pairs: ConnectedRegionPair[] = []; + + for (let index = 0; index < sortedRegions.length - 1; index += 1) { + const currentRegion = sortedRegions[index]; + const nextRegion = sortedRegions[index + 1]; + const gapMs = nextRegion.startMs - currentRegion.endMs; + + if (gapMs > CHAINED_ZOOM_PAN_GAP_MS) { + continue; + } + + pairs.push({ + currentRegion, + nextRegion, + transitionStart: currentRegion.endMs + ZOOM_ANIMATION_LEAD_MS, + transitionEnd: + currentRegion.endMs + ZOOM_ANIMATION_LEAD_MS + CONNECTED_ZOOM_PAN_DURATION_MS, + }); + } + + return pairs; +} + +function getActiveRegion( + regions: ZoomRegion[], + timeMs: number, + connectedPairs: ConnectedRegionPair[], + options: DominantRegionOptions, +) { + const activeRegions = regions + .map((region) => { + const outgoingPair = connectedPairs.find((pair) => pair.currentRegion.id === region.id); + if (outgoingPair) { + if (timeMs >= outgoingPair.transitionStart) { + return { region, strength: 0 }; + } + + const zoomOutStart = + outgoingPair.currentRegion.endMs - + ZOOM_OUT_EARLY_START_MS + + ZOOM_ANIMATION_LEAD_MS; + if (timeMs >= zoomOutStart) { + return { region, strength: 1 }; + } + } + + const incomingPair = connectedPairs.find((pair) => pair.nextRegion.id === region.id); + if (incomingPair) { + if (timeMs < incomingPair.transitionStart) { + return { region, strength: 0 }; + } + + const nextRegionZoomOutStart = + incomingPair.nextRegion.endMs - + ZOOM_OUT_EARLY_START_MS + + ZOOM_ANIMATION_LEAD_MS; + if (timeMs < nextRegionZoomOutStart) { + return { region, strength: 1 }; + } + } + + return { region, strength: computeRegionStrength(region, timeMs, options) }; + }) + .filter((entry) => entry.strength > 0) + .sort((left, right) => { + if (right.strength !== left.strength) { + return right.strength - left.strength; + } + + return right.region.startMs - left.region.startMs; + }); + + if (activeRegions.length === 0) { + return null; + } + + const activeRegion = activeRegions[0].region; + const activeScale = ZOOM_DEPTH_SCALES[activeRegion.depth]; + + return { + region: { + ...activeRegion, + focus: getResolvedFocus(activeRegion, activeScale), + }, + strength: activeRegions[0].strength, + blendedScale: null, + }; +} + +function getConnectedRegionHold(timeMs: number, connectedPairs: ConnectedRegionPair[]) { + for (const pair of connectedPairs) { + if (timeMs >= pair.transitionEnd && timeMs < pair.nextRegion.startMs) { + const nextScale = ZOOM_DEPTH_SCALES[pair.nextRegion.depth]; + return { + region: { + ...pair.nextRegion, + focus: getResolvedFocus(pair.nextRegion, nextScale), + }, + strength: 1, + blendedScale: null, + }; + } + } + + return null; } -export function findDominantRegion(regions: ZoomRegion[], timeMs: number) { - let bestRegion: ZoomRegion | null = null; - let bestStrength = 0; +export function findDominantRegion( + regions: ZoomRegion[], + timeMs: number, + options: DominantRegionOptions = {}, +): { + region: ZoomRegion | null; + strength: number; + blendedScale: number | null; + transition: ConnectedPanTransition | null; +} { + const connectedPairs = options.connectZooms ? getConnectedRegionPairs(regions) : []; - for (const region of regions) { - const strength = computeRegionStrength(region, timeMs); - if (strength > bestStrength) { - bestStrength = strength; - bestRegion = region; - } - } + if (options.connectZooms) { + const connectedHold = getConnectedRegionHold(timeMs, connectedPairs); + if (connectedHold) { + return { ...connectedHold, transition: null }; + } + } - return { region: bestRegion, strength: bestStrength }; + const activeRegion = getActiveRegion(regions, timeMs, connectedPairs, options); + return activeRegion + ? { ...activeRegion, transition: null } + : { region: null, strength: 0, blendedScale: null, transition: null }; } diff --git a/src/components/video-editor/videoPlayback/zoomTransform.test.ts b/src/components/video-editor/videoPlayback/zoomTransform.test.ts new file mode 100644 index 000000000..79333ec9b --- /dev/null +++ b/src/components/video-editor/videoPlayback/zoomTransform.test.ts @@ -0,0 +1,184 @@ +import { describe, expect, it } from "vitest"; +import { + applyZoomTransform, + computeDirectionalMotionBlur, + computeZoomTransform, + createMotionBlurState, +} from "./zoomTransform"; + +function createStubContainer() { + return { + scale: { set: () => undefined }, + position: { set: () => undefined }, + }; +} + +describe("computeDirectionalMotionBlur", () => { + it("normalizes cursor and camera motion to the same 60fps baseline", () => { + const at60Fps = computeDirectionalMotionBlur({ x: 12, y: 0 }, 0.5, 1 / 60); + const at30Fps = computeDirectionalMotionBlur({ x: 12, y: 0 }, 0.5, 1 / 30); + + expect(at60Fps.velocity).toEqual({ x: 6, y: 0 }); + expect(at30Fps.velocity).toEqual({ x: 3, y: 0 }); + expect(at60Fps.kernelSize).toBe(13); + expect(at60Fps.offset).toBeCloseTo(-6 / 32, 6); + }); +}); + +describe("applyZoomTransform motion blur routing", () => { + it("keeps pure translation on the directional blur path", () => { + const motionBlurState = createMotionBlurState(); + const motionBlurFilter = { + velocity: { x: 0, y: 0 }, + kernelSize: 5, + offset: 0, + }; + const zoomBlurFilter = { + strength: 0, + center: { x: 0, y: 0 }, + innerRadius: 0, + radius: -1, + }; + + const sharedParams = { + cameraContainer: createStubContainer() as never, + zoomBlurFilter: zoomBlurFilter as never, + motionBlurFilter: motionBlurFilter as never, + stageSize: { width: 1280, height: 720 }, + baseMask: { x: 80, y: 60, width: 1120, height: 600 }, + zoomScale: 1, + focusX: 0.5, + focusY: 0.5, + isPlaying: true, + motionBlurAmount: 1, + motionBlurState, + }; + + applyZoomTransform({ + ...sharedParams, + transformOverride: { scale: 1, x: 0, y: 0 }, + frameTimeMs: 1000, + }); + applyZoomTransform({ + ...sharedParams, + transformOverride: { scale: 1, x: 96, y: 24 }, + frameTimeMs: 1016, + }); + + expect( + Math.hypot(motionBlurFilter.velocity.x, motionBlurFilter.velocity.y), + ).toBeGreaterThan(0); + expect(zoomBlurFilter.strength).toBe(0); + }); + + it("infers an off-center zoom origin for radial blur", () => { + const motionBlurState = createMotionBlurState(); + const motionBlurFilter = { + velocity: { x: 0, y: 0 }, + kernelSize: 5, + offset: 0, + }; + const zoomBlurFilter = { + strength: 0, + center: { x: 0, y: 0 }, + innerRadius: 0, + radius: -1, + }; + const stageSize = { width: 1000, height: 800 }; + const baseMask = { x: 100, y: 100, width: 800, height: 600 }; + const focusX = 0.2; + const focusY = 0.7; + + applyZoomTransform({ + cameraContainer: createStubContainer() as never, + zoomBlurFilter: zoomBlurFilter as never, + motionBlurFilter: motionBlurFilter as never, + stageSize, + baseMask, + zoomScale: 1, + focusX, + focusY, + isPlaying: true, + motionBlurAmount: 1, + motionBlurState, + transformOverride: computeZoomTransform({ + stageSize, + baseMask, + zoomScale: 1, + focusX, + focusY, + }), + frameTimeMs: 1000, + }); + applyZoomTransform({ + cameraContainer: createStubContainer() as never, + zoomBlurFilter: zoomBlurFilter as never, + motionBlurFilter: motionBlurFilter as never, + stageSize, + baseMask, + zoomScale: 1.7, + focusX, + focusY, + isPlaying: true, + motionBlurAmount: 1, + motionBlurState, + transformOverride: computeZoomTransform({ + stageSize, + baseMask, + zoomScale: 1.7, + focusX, + focusY, + }), + frameTimeMs: 1016, + }); + + expect(zoomBlurFilter.strength).toBeGreaterThan(0); + expect(Math.hypot(motionBlurFilter.velocity.x, motionBlurFilter.velocity.y)).toBe(0); + expect(zoomBlurFilter.center.x).toBeCloseTo(stageSize.width / 2, 0); + expect(zoomBlurFilter.center.y).toBeCloseTo(stageSize.height / 2, 0); + expect(zoomBlurFilter.radius).toBe(-1); + }); + + it("keeps a zoom on the radial path even when its center moves farther than its size", () => { + const motionBlurState = createMotionBlurState(); + const motionBlurFilter = { + velocity: { x: 0, y: 0 }, + kernelSize: 5, + offset: 0, + }; + const zoomBlurFilter = { + strength: 0, + center: { x: 0, y: 0 }, + innerRadius: 0, + radius: -1, + }; + const sharedParams = { + cameraContainer: createStubContainer() as never, + zoomBlurFilter: zoomBlurFilter as never, + motionBlurFilter: motionBlurFilter as never, + stageSize: { width: 1280, height: 720 }, + baseMask: { x: 80, y: 60, width: 1120, height: 600 }, + zoomScale: 1, + focusX: 0.5, + focusY: 0.5, + isPlaying: true, + motionBlurAmount: 1, + motionBlurState, + }; + + applyZoomTransform({ + ...sharedParams, + transformOverride: { scale: 1, x: 0, y: 0 }, + frameTimeMs: 1000, + }); + applyZoomTransform({ + ...sharedParams, + transformOverride: { scale: 1.01, x: 120, y: 0 }, + frameTimeMs: 1016, + }); + + expect(zoomBlurFilter.strength).toBeGreaterThan(0); + expect(Math.hypot(motionBlurFilter.velocity.x, motionBlurFilter.velocity.y)).toBe(0); + expect(zoomBlurFilter.innerRadius).toBe(0); + }); +}); diff --git a/src/components/video-editor/videoPlayback/zoomTransform.ts b/src/components/video-editor/videoPlayback/zoomTransform.ts index a3f864632..55ace06ae 100644 --- a/src/components/video-editor/videoPlayback/zoomTransform.ts +++ b/src/components/video-editor/videoPlayback/zoomTransform.ts @@ -1,59 +1,577 @@ -import * as PIXI from 'pixi.js'; +import { Container } from "pixi.js"; +import { MotionBlurFilter } from "pixi-filters/motion-blur"; +import { ZoomBlurFilter } from "pixi-filters/zoom-blur"; +import { DEFAULT_ZOOM_MOTION_BLUR_TUNING, type ZoomMotionBlurTuning } from "../types"; + +const MIN_DIRECTIONAL_BLUR_MAGNITUDE = 0.01; +const DIRECTIONAL_BLUR_KERNEL_SIZE = 13; +const DIRECTIONAL_BLUR_OFFSET_DIVISOR = 32; +const SPATIAL_PAN_SHUTTER_MULTIPLIER = 4; +// Spatial equivalent of a longer exposure. This extends the one-pass zoom +// trail without multiplying scene renders like temporal accumulation does. +const SPATIAL_ZOOM_SHUTTER_MULTIPLIER = 4; + +export interface DirectionalMotionBlur { + velocity: { x: number; y: number }; + magnitude: number; + kernelSize: number; + offset: number; +} + +export interface MotionBlurState { + lastFrameTimeMs: number; + prevCamX: number; + prevCamY: number; + prevCamScale: number; + initialized: boolean; +} + +export function createMotionBlurState(): MotionBlurState { + return { + lastFrameTimeMs: 0, + prevCamX: 0, + prevCamY: 0, + prevCamScale: 1, + initialized: false, + }; +} interface TransformParams { - cameraContainer: PIXI.Container; - blurFilter: PIXI.BlurFilter | null; - stageSize: { width: number; height: number }; - baseMask: { x: number; y: number; width: number; height: number }; - zoomScale: number; - focusX: number; - focusY: number; - motionIntensity: number; - isPlaying: boolean; + cameraContainer: Container; + zoomBlurFilter?: ZoomBlurFilter | null; + motionBlurFilter?: MotionBlurFilter | null; + stageSize: { width: number; height: number }; + baseMask: { x: number; y: number; width: number; height: number }; + zoomScale: number; + zoomProgress?: number; + focusX: number; + focusY: number; + isPlaying: boolean; + motionBlurAmount?: number; + motionBlurTuning?: ZoomMotionBlurTuning; + transformOverride?: AppliedTransform; + motionBlurState?: MotionBlurState; + frameTimeMs?: number; +} + +interface AppliedTransform { + scale: number; + x: number; + y: number; +} + +interface Point2D { + x: number; + y: number; +} + +interface TransformQuad { + center: Point2D; + corners: [Point2D, Point2D, Point2D, Point2D]; + diagonal: number; + size: Point2D; +} + +interface CameraStepAnalysis { + mode: "move" | "zoom" | null; + moveVelocity: Point2D; + moveBlurVelocity: Point2D; + moveBlurOffset: number; + zoomCenter: Point2D; + zoomStrength: number; +} + +interface FocusFromTransformGeometry { + stageSize: { width: number; height: number }; + baseMask: { x: number; y: number; width: number; height: number }; + zoomScale: number; + x: number; + y: number; +} + +interface ZoomTransformGeometry { + stageSize: { width: number; height: number }; + baseMask: { x: number; y: number; width: number; height: number }; + zoomScale: number; + zoomProgress?: number; + focusX: number; + focusY: number; +} + +function resetMotionEffects( + zoomBlurFilter?: ZoomBlurFilter | null, + motionBlurFilter?: MotionBlurFilter | null, + motionBlurState?: MotionBlurState, +) { + if (motionBlurFilter) { + motionBlurFilter.velocity = { x: 0, y: 0 }; + motionBlurFilter.kernelSize = 5; + motionBlurFilter.offset = 0; + } + + if (zoomBlurFilter) { + zoomBlurFilter.strength = 0; + zoomBlurFilter.innerRadius = 0; + zoomBlurFilter.radius = -1; + } + + if (motionBlurState) { + motionBlurState.lastFrameTimeMs = 0; + motionBlurState.prevCamX = 0; + motionBlurState.prevCamY = 0; + motionBlurState.prevCamScale = 1; + motionBlurState.initialized = false; + } +} + +function resolveMotionBlurTuning(motionBlurTuning?: ZoomMotionBlurTuning): ZoomMotionBlurTuning { + if (!motionBlurTuning) { + return DEFAULT_ZOOM_MOTION_BLUR_TUNING; + } + + return { + ...DEFAULT_ZOOM_MOTION_BLUR_TUNING, + ...motionBlurTuning, + }; +} + +function computeTransformQuad( + baseMask: { x: number; y: number; width: number; height: number }, + transform: AppliedTransform, +): TransformQuad { + const topLeft = { + x: transform.x + baseMask.x * transform.scale, + y: transform.y + baseMask.y * transform.scale, + }; + const topRight = { + x: transform.x + (baseMask.x + baseMask.width) * transform.scale, + y: transform.y + baseMask.y * transform.scale, + }; + const bottomRight = { + x: transform.x + (baseMask.x + baseMask.width) * transform.scale, + y: transform.y + (baseMask.y + baseMask.height) * transform.scale, + }; + const bottomLeft = { + x: transform.x + baseMask.x * transform.scale, + y: transform.y + (baseMask.y + baseMask.height) * transform.scale, + }; + const center = { + x: (topLeft.x + bottomRight.x) * 0.5, + y: (topLeft.y + bottomRight.y) * 0.5, + }; + + return { + center, + corners: [topLeft, topRight, bottomRight, bottomLeft], + diagonal: Math.hypot(bottomRight.x - topLeft.x, bottomRight.y - topLeft.y), + size: { + x: Math.hypot(topRight.x - topLeft.x, topRight.y - topLeft.y), + y: Math.hypot(bottomLeft.x - topLeft.x, bottomLeft.y - topLeft.y), + }, + }; +} + +function crossProduct(a: Point2D, b: Point2D) { + return a.x * b.y - a.y * b.x; +} + +function clamp(value: number, min: number, max: number) { + return Math.min(max, Math.max(min, value)); +} + +function intersectInfiniteLines( + aStart: Point2D, + aEnd: Point2D, + bStart: Point2D, + bEnd: Point2D, +): Point2D | null { + const aDelta = { x: aEnd.x - aStart.x, y: aEnd.y - aStart.y }; + const bDelta = { x: bEnd.x - bStart.x, y: bEnd.y - bStart.y }; + const denominator = crossProduct(aDelta, bDelta); + + if (Math.abs(denominator) < 0.0001) { + return null; + } + + const originDelta = { x: bStart.x - aStart.x, y: bStart.y - aStart.y }; + const t = crossProduct(originDelta, bDelta) / denominator; + + return { + x: aStart.x + aDelta.x * t, + y: aStart.y + aDelta.y * t, + }; +} + +function inferZoomCenterFromQuads( + previousQuad: TransformQuad, + currentQuad: TransformQuad, + stageSize: { width: number; height: number }, +) { + const hasMeaningfulChange = + Math.abs(currentQuad.center.x - previousQuad.center.x) > 0.001 || + Math.abs(currentQuad.center.y - previousQuad.center.y) > 0.001 || + Math.abs(currentQuad.diagonal - previousQuad.diagonal) > 0.001; + + if (!hasMeaningfulChange) { + return previousQuad.center; + } + + const motionSegments: Array<{ start: Point2D; end: Point2D }> = []; + + for (let i = 0; i < previousQuad.corners.length; i += 1) { + const start = previousQuad.corners[i]; + const end = currentQuad.corners[i]; + if (Math.hypot(end.x - start.x, end.y - start.y) < 0.5) { + continue; + } + motionSegments.push({ start, end }); + } + + for (let i = 0; i < motionSegments.length; i += 1) { + for (let j = i + 1; j < motionSegments.length; j += 1) { + const intersection = intersectInfiniteLines( + motionSegments[i].start, + motionSegments[i].end, + motionSegments[j].start, + motionSegments[j].end, + ); + + if (!intersection) { + continue; + } + + const marginX = stageSize.width; + const marginY = stageSize.height; + + return { + x: clamp(intersection.x, -marginX, stageSize.width + marginX), + y: clamp(intersection.y, -marginY, stageSize.height + marginY), + }; + } + } + + return currentQuad.center; +} + +function resolveFpsScale(deltaSeconds: number) { + const fps = 1 / Math.max(1 / 240, deltaSeconds); + return fps / 60; +} + +export function computeDirectionalMotionBlur( + delta: { x: number; y: number }, + motionBlurAmount: number, + deltaSeconds: number, + strengthMultiplier = 1, +): DirectionalMotionBlur { + const scale = motionBlurAmount * resolveFpsScale(deltaSeconds) * strengthMultiplier; + const velocity = { + x: delta.x * scale, + y: delta.y * scale, + }; + const magnitude = Math.hypot(velocity.x, velocity.y); + const isActive = magnitude >= MIN_DIRECTIONAL_BLUR_MAGNITUDE; + + return { + velocity: isActive ? velocity : createZeroPoint(), + magnitude: isActive ? magnitude : 0, + kernelSize: DIRECTIONAL_BLUR_KERNEL_SIZE, + offset: isActive ? -magnitude / DIRECTIONAL_BLUR_OFFSET_DIVISOR : 0, + }; +} + +function resolveZoomBlurStrength( + motionBlurAmount: number, + motionBlurTuning: ZoomMotionBlurTuning, + deltaSeconds: number, +) { + const fpsScale = resolveFpsScale(deltaSeconds); + return ( + motionBlurAmount * + fpsScale * + SPATIAL_ZOOM_SHUTTER_MULTIPLIER * + (motionBlurTuning.maxRadialBlurStrength / + DEFAULT_ZOOM_MOTION_BLUR_TUNING.maxRadialBlurStrength) + ); +} + +function computeMoveDelta(previousQuad: TransformQuad, currentQuad: TransformQuad) { + return { + x: currentQuad.center.x - previousQuad.center.x, + y: currentQuad.center.y - previousQuad.center.y, + }; +} + +function computeSizeDelta(previousQuad: TransformQuad, currentQuad: TransformQuad) { + return { + x: Math.abs(currentQuad.size.x - previousQuad.size.x), + y: Math.abs(currentQuad.size.y - previousQuad.size.y), + }; +} + +function computeZoomStrength(previousQuad: TransformQuad, currentQuad: TransformQuad) { + // Keep the spatial approximation sampling inward. Sampling outward during + // expansion can leave the filter's captured texture and produce transparent + // or black frames; true directionality requires temporal/overscanned input. + return Math.abs(1 - currentQuad.diagonal / Math.max(0.0001, previousQuad.diagonal)); +} + +function classifyMotionMode( + previousQuad: TransformQuad, + currentQuad: TransformQuad, + motionBlurTuning: ZoomMotionBlurTuning, + deltaSeconds: number, +) { + const moveDelta = computeMoveDelta(previousQuad, currentQuad); + const sizeDelta = computeSizeDelta(previousQuad, currentQuad); + const moveDistance = Math.hypot(moveDelta.x, moveDelta.y); + const zoomDistance = Math.hypot(sizeDelta.x, sizeDelta.y); + const moveVelocity = Math.hypot(moveDelta.x, moveDelta.y) / Math.max(0.0001, deltaSeconds); + const zoomVelocity = + Math.abs( + Math.log( + Math.max(0.0001, currentQuad.diagonal) / Math.max(0.0001, previousQuad.diagonal), + ), + ) / Math.max(0.0001, deltaSeconds); + const moveActive = moveDistance > 0.001 && moveVelocity > motionBlurTuning.panVelocityThreshold; + const zoomActive = + zoomDistance > 0.001 && zoomVelocity > motionBlurTuning.zoomVelocityThreshold; + + if (!moveActive && !zoomActive) { + return null; + } + + // Any meaningful scale change is a zoom. The inferred zoom center already + // accounts for the translation caused by an off-center zoom, so routing it + // through pan blur would turn a straight radial zoom into a directional smear. + if (zoomActive) { + return "zoom"; + } + + return "move"; +} + +function createZeroPoint(): Point2D { + return { x: 0, y: 0 }; +} + +function analyzeCameraStep({ + previousQuad, + currentQuad, + stageSize, + motionBlurAmount, + motionBlurTuning, + deltaSeconds, +}: { + previousQuad: TransformQuad; + currentQuad: TransformQuad; + stageSize: { width: number; height: number }; + motionBlurAmount: number; + motionBlurTuning: ZoomMotionBlurTuning; + deltaSeconds: number; +}): CameraStepAnalysis { + const mode = classifyMotionMode(previousQuad, currentQuad, motionBlurTuning, deltaSeconds); + const moveDelta = computeMoveDelta(previousQuad, currentQuad); + const zoomBlurStrength = resolveZoomBlurStrength( + motionBlurAmount, + motionBlurTuning, + deltaSeconds, + ); + const directionalBlur = computeDirectionalMotionBlur( + moveDelta, + motionBlurAmount, + deltaSeconds, + SPATIAL_PAN_SHUTTER_MULTIPLIER * + (motionBlurTuning.maxDirectionalBlurPx / + DEFAULT_ZOOM_MOTION_BLUR_TUNING.maxDirectionalBlurPx), + ); + + return { + mode, + moveVelocity: moveDelta, + moveBlurVelocity: mode === "move" ? directionalBlur.velocity : createZeroPoint(), + moveBlurOffset: mode === "move" ? directionalBlur.offset : 0, + zoomCenter: inferZoomCenterFromQuads(previousQuad, currentQuad, stageSize), + zoomStrength: + mode === "zoom" ? computeZoomStrength(previousQuad, currentQuad) * zoomBlurStrength : 0, + }; +} + +function applyCameraStepBlur({ + analysis, + motionBlurFilter, + zoomBlurFilter, +}: { + analysis: CameraStepAnalysis; + motionBlurFilter: MotionBlurFilter; + zoomBlurFilter?: ZoomBlurFilter | null; +}) { + motionBlurFilter.velocity = analysis.moveBlurVelocity; + motionBlurFilter.kernelSize = DIRECTIONAL_BLUR_KERNEL_SIZE; + motionBlurFilter.offset = analysis.moveBlurOffset; + + if (zoomBlurFilter) { + zoomBlurFilter.center = analysis.zoomCenter; + zoomBlurFilter.strength = analysis.zoomStrength; + zoomBlurFilter.innerRadius = 0; + zoomBlurFilter.radius = -1; + } +} + +export function computeZoomTransform({ + stageSize, + baseMask, + zoomScale, + zoomProgress = 1, + focusX, + focusY, +}: ZoomTransformGeometry): AppliedTransform { + if ( + stageSize.width <= 0 || + stageSize.height <= 0 || + baseMask.width <= 0 || + baseMask.height <= 0 + ) { + return { scale: 1, x: 0, y: 0 }; + } + + const progress = Math.min(1, Math.max(0, zoomProgress)); + const focusStagePxX = baseMask.x + focusX * baseMask.width; + const focusStagePxY = baseMask.y + focusY * baseMask.height; + const stageCenterX = stageSize.width / 2; + const stageCenterY = stageSize.height / 2; + const scale = 1 + (zoomScale - 1) * progress; + const finalX = stageCenterX - focusStagePxX * zoomScale; + const finalY = stageCenterY - focusStagePxY * zoomScale; + + return { + scale, + x: finalX * progress, + y: finalY * progress, + }; +} + +export function computeFocusFromTransform({ + stageSize, + baseMask, + zoomScale, + x, + y, +}: FocusFromTransformGeometry) { + if ( + stageSize.width <= 0 || + stageSize.height <= 0 || + baseMask.width <= 0 || + baseMask.height <= 0 || + zoomScale <= 0 + ) { + return { cx: 0.5, cy: 0.5 }; + } + + const stageCenterX = stageSize.width / 2; + const stageCenterY = stageSize.height / 2; + const focusStagePxX = (stageCenterX - x) / zoomScale; + const focusStagePxY = (stageCenterY - y) / zoomScale; + + return { + cx: (focusStagePxX - baseMask.x) / baseMask.width, + cy: (focusStagePxY - baseMask.y) / baseMask.height, + }; } export function applyZoomTransform({ - cameraContainer, - blurFilter, - stageSize, - baseMask, - zoomScale, - focusX, - focusY, - motionIntensity, - isPlaying, -}: TransformParams) { - if ( - stageSize.width <= 0 || - stageSize.height <= 0 || - baseMask.width <= 0 || - baseMask.height <= 0 - ) { - return; - } - - // The focus point in stage coordinates (where the user clicked/selected) - const focusStagePxX = focusX * stageSize.width; - const focusStagePxY = focusY * stageSize.height; - - // Stage center (where we want the focus to end up after zoom) - const stageCenterX = stageSize.width / 2; - const stageCenterY = stageSize.height / 2; - - // Apply zoom scale to camera container - cameraContainer.scale.set(zoomScale); - - // Calculate camera position to keep focus point centered - // After scaling, the focus point moves to (focusX * zoomScale, focusY * zoomScale) - // We want it at stage center, so offset = center - (focus * scale) - const cameraX = stageCenterX - focusStagePxX * zoomScale; - const cameraY = stageCenterY - focusStagePxY * zoomScale; - - cameraContainer.position.set(cameraX, cameraY); - - if (blurFilter) { - const shouldBlur = isPlaying && motionIntensity > 0.0005; - const motionBlur = shouldBlur ? Math.min(6, motionIntensity * 120) : 0; - blurFilter.blur = motionBlur; - } + cameraContainer, + zoomBlurFilter, + motionBlurFilter, + stageSize, + baseMask, + zoomScale, + zoomProgress = 1, + focusX, + focusY, + isPlaying, + motionBlurAmount = 0, + motionBlurTuning, + transformOverride, + motionBlurState, + frameTimeMs, +}: TransformParams): AppliedTransform { + if ( + stageSize.width <= 0 || + stageSize.height <= 0 || + baseMask.width <= 0 || + baseMask.height <= 0 + ) { + cameraContainer.scale.set(1); + cameraContainer.position.set(0, 0); + resetMotionEffects(zoomBlurFilter, motionBlurFilter, motionBlurState); + return { scale: 1, x: 0, y: 0 }; + } + + const transform = + transformOverride ?? + computeZoomTransform({ + stageSize, + baseMask, + zoomScale, + zoomProgress, + focusX, + focusY, + }); + + // Apply position & scale to camera container + cameraContainer.scale.set(transform.scale); + cameraContainer.position.set(transform.x, transform.y); + const resolvedTuning = resolveMotionBlurTuning(motionBlurTuning); + + if (motionBlurState && motionBlurFilter && motionBlurAmount > 0 && isPlaying) { + const now = frameTimeMs ?? performance.now(); + + if (!motionBlurState.initialized) { + motionBlurState.prevCamX = transform.x; + motionBlurState.prevCamY = transform.y; + motionBlurState.prevCamScale = transform.scale; + motionBlurState.lastFrameTimeMs = now; + motionBlurState.initialized = true; + motionBlurFilter.velocity = { x: 0, y: 0 }; + motionBlurFilter.kernelSize = DIRECTIONAL_BLUR_KERNEL_SIZE; + motionBlurFilter.offset = 0; + if (zoomBlurFilter) { + zoomBlurFilter.strength = 0; + } + } else { + const dtMs = Math.min(80, Math.max(1, now - motionBlurState.lastFrameTimeMs)); + const dtSeconds = dtMs / 1000; + motionBlurState.lastFrameTimeMs = now; + + const previousTransform = { + scale: motionBlurState.prevCamScale, + x: motionBlurState.prevCamX, + y: motionBlurState.prevCamY, + }; + const previousQuad = computeTransformQuad(baseMask, previousTransform); + const currentQuad = computeTransformQuad(baseMask, transform); + const analysis = analyzeCameraStep({ + previousQuad, + currentQuad, + stageSize, + motionBlurAmount, + motionBlurTuning: resolvedTuning, + deltaSeconds: dtSeconds, + }); + + motionBlurState.prevCamX = transform.x; + motionBlurState.prevCamY = transform.y; + motionBlurState.prevCamScale = transform.scale; + applyCameraStepBlur({ analysis, motionBlurFilter, zoomBlurFilter }); + } + } else { + resetMotionEffects(zoomBlurFilter, motionBlurFilter, motionBlurState); + } + + return { + scale: transform.scale, + x: transform.x, + y: transform.y, + }; } diff --git a/src/components/video-editor/webcamOverlay.test.ts b/src/components/video-editor/webcamOverlay.test.ts new file mode 100644 index 000000000..d5b6381b2 --- /dev/null +++ b/src/components/video-editor/webcamOverlay.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, it } from "vitest"; +import { + convertLegacyWebcamRadiusToRoundness, + getCropMatchedWebcamHeightPercent, + getWebcamCornerRadiusPx, + getWebcamCropSourceRect, + getWebcamOverlayDimensionsPx, + getWebcamOverlayPosition, + isWebcamCropRegionDefault, + normalizeWebcamCropRegion, + scaleWebcamOverlayPixels, +} from "./webcamOverlay"; + +describe("webcam roundness", () => { + it("uses a stronger response curve while preserving the endpoints", () => { + expect(getWebcamCornerRadiusPx(0, 640, 360)).toBe(0); + expect(getWebcamCornerRadiusPx(25, 640, 360)).toBe(90); + expect(getWebcamCornerRadiusPx(50, 640, 360)).toBeCloseTo(127.28, 1); + expect(getWebcamCornerRadiusPx(100, 640, 360)).toBe(180); + }); + + it("converts legacy reference pixels without changing the approximate shape", () => { + expect(convertLegacyWebcamRadiusToRoundness(90, 40, 40)).toBeCloseTo(17.36, 1); + }); +}); + +describe("scaleWebcamOverlayPixels", () => { + it("keeps webcam pixel controls proportional between preview and export", () => { + expect(scaleWebcamOverlayPixels(24, 960)).toBe(12); + expect(scaleWebcamOverlayPixels(24, 1920)).toBe(24); + expect(scaleWebcamOverlayPixels(24, 3840)).toBe(48); + }); +}); + +describe("normalizeWebcamCropRegion", () => { + it("defaults to the full webcam frame", () => { + expect(normalizeWebcamCropRegion()).toEqual({ x: 0, y: 0, width: 1, height: 1 }); + expect(isWebcamCropRegionDefault()).toBe(true); + }); + + it("clamps crop dimensions inside the source frame", () => { + const crop = normalizeWebcamCropRegion({ x: 0.8, y: -1, width: 0.5, height: 2 }); + expect(crop.x).toBe(0.8); + expect(crop.y).toBe(0); + expect(crop.width).toBeCloseTo(0.2); + expect(crop.height).toBe(1); + }); +}); + +describe("getWebcamOverlayDimensionsPx", () => { + it("resolves independent width and height percentages", () => { + expect( + getWebcamOverlayDimensionsPx({ + containerWidth: 1000, + containerHeight: 800, + widthPercent: 50, + heightPercent: 25, + margin: 0, + zoomScale: 1, + reactToZoom: false, + }), + ).toEqual({ + width: 400, + height: 200, + }); + }); +}); + +describe("getWebcamOverlayPosition", () => { + it("uses rectangular dimensions when anchoring to a preset", () => { + expect( + getWebcamOverlayPosition({ + containerWidth: 1000, + containerHeight: 800, + width: 400, + height: 200, + margin: 20, + positionPreset: "bottom-right", + positionX: 1, + positionY: 1, + legacyCorner: "bottom-right", + }), + ).toEqual({ x: 580, y: 580 }); + }); +}); + +describe("getCropMatchedWebcamHeightPercent", () => { + it("matches height to a non-default crop aspect when width and height are linked", () => { + expect( + getCropMatchedWebcamHeightPercent(60, 60, 1920, 1080, { + x: 0.1, + y: 0.2, + width: 0.6, + height: 0.4, + }), + ).toBeCloseTo(22.5); + }); + + it("preserves manually separated width and height controls", () => { + expect( + getCropMatchedWebcamHeightPercent(60, 45, 1920, 1080, { + x: 0.1, + y: 0.2, + width: 0.6, + height: 0.4, + }), + ).toBe(45); + }); + + it("keeps the default crop square-compatible", () => { + expect(getCropMatchedWebcamHeightPercent(60, 60, 1920, 1080, undefined)).toBe(60); + }); +}); + +describe("getWebcamCropSourceRect", () => { + it("converts normalized crop settings to source pixels", () => { + expect( + getWebcamCropSourceRect({ x: 0.25, y: 0.1, width: 0.5, height: 0.75 }, 1920, 1080), + ).toEqual({ + sx: 480, + sy: 108, + sw: 960, + sh: 810, + }); + }); +}); diff --git a/src/components/video-editor/webcamOverlay.ts b/src/components/video-editor/webcamOverlay.ts new file mode 100644 index 000000000..d84d99e1f --- /dev/null +++ b/src/components/video-editor/webcamOverlay.ts @@ -0,0 +1,261 @@ +import type { CropRegion, WebcamCorner, WebcamPositionPreset } from "./types"; + +const MIN_WEBCAM_OVERLAY_SIZE_PX = 56; +export const WEBCAM_REFERENCE_VIEWPORT_WIDTH = 1920; +const WEBCAM_REFERENCE_VIEWPORT_HEIGHT = 1080; + +function clamp(value: number, min: number, max: number) { + return Math.min(max, Math.max(min, value)); +} + +export function scaleWebcamOverlayPixels(value: number, containerWidth: number): number { + const safeValue = Number.isFinite(value) ? Math.max(0, value) : 0; + const safeWidth = Number.isFinite(containerWidth) ? Math.max(0, containerWidth) : 0; + return safeValue * (safeWidth / WEBCAM_REFERENCE_VIEWPORT_WIDTH); +} + +export function getWebcamCornerRadiusPx( + roundnessPercent: number, + width: number, + height: number, +): number { + const safeRoundness = Number.isFinite(roundnessPercent) ? clamp(roundnessPercent, 0, 100) : 0; + const strengthenedRoundness = Math.sqrt(safeRoundness / 100); + return (Math.max(0, Math.min(width, height)) / 2) * strengthenedRoundness; +} + +export function convertLegacyWebcamRadiusToRoundness( + radiusPixels: number, + widthPercent: number, + heightPercent: number, +): number { + const referenceShortSide = + WEBCAM_REFERENCE_VIEWPORT_HEIGHT * + (clamp(Math.min(widthPercent, heightPercent), 10, 100) / 100); + const normalizedRadius = clamp(Math.max(0, radiusPixels) / (referenceShortSide / 2), 0, 1); + return normalizedRadius ** 2 * 100; +} + +export function getWebcamPositionForPreset(preset: WebcamPositionPreset): { x: number; y: number } { + switch (preset) { + case "top-left": + return { x: 0, y: 0 }; + case "top-center": + return { x: 0.5, y: 0 }; + case "top-right": + return { x: 1, y: 0 }; + case "center-left": + return { x: 0, y: 0.5 }; + case "center": + return { x: 0.5, y: 0.5 }; + case "center-right": + return { x: 1, y: 0.5 }; + case "bottom-left": + return { x: 0, y: 1 }; + case "bottom-center": + return { x: 0.5, y: 1 }; + case "custom": + return { x: 1, y: 1 }; + case "bottom-right": + default: + return { x: 1, y: 1 }; + } +} + +function isCornerPreset(preset: WebcamPositionPreset): preset is WebcamCorner { + return ( + preset === "top-left" || + preset === "top-right" || + preset === "bottom-left" || + preset === "bottom-right" + ); +} + +export function resolveWebcamCorner( + preset: WebcamPositionPreset, + legacyCorner: WebcamCorner, +): WebcamCorner { + return isCornerPreset(preset) ? preset : legacyCorner; +} + +export function getWebcamOverlayScale(zoomScale: number, reactToZoom: boolean): number { + const safeZoomScale = Number.isFinite(zoomScale) && zoomScale > 0 ? zoomScale : 1; + return reactToZoom ? 1 / safeZoomScale : 1; +} + +export function getWebcamOverlaySizePx({ + containerWidth, + containerHeight, + sizePercent, + margin, + zoomScale, + reactToZoom, +}: { + containerWidth: number; + containerHeight: number; + sizePercent: number; + margin: number; + zoomScale: number; + reactToZoom: boolean; +}): number { + const minDimension = Math.min(containerWidth, containerHeight); + const clampedSizePercent = clamp(sizePercent, 10, 100); + const safeMargin = Math.max(0, margin); + const maxSize = Math.max(MIN_WEBCAM_OVERLAY_SIZE_PX, minDimension - safeMargin * 2); + const scaledSize = + minDimension * (clampedSizePercent / 100) * getWebcamOverlayScale(zoomScale, reactToZoom); + + return Math.min(maxSize, Math.max(MIN_WEBCAM_OVERLAY_SIZE_PX, scaledSize)); +} + +export function getWebcamOverlayDimensionsPx({ + containerWidth, + containerHeight, + widthPercent, + heightPercent, + margin, + zoomScale, + reactToZoom, +}: { + containerWidth: number; + containerHeight: number; + widthPercent: number; + heightPercent: number; + margin: number; + zoomScale: number; + reactToZoom: boolean; +}): { width: number; height: number } { + return { + width: getWebcamOverlaySizePx({ + containerWidth, + containerHeight, + sizePercent: widthPercent, + margin, + zoomScale, + reactToZoom, + }), + height: getWebcamOverlaySizePx({ + containerWidth, + containerHeight, + sizePercent: heightPercent, + margin, + zoomScale, + reactToZoom, + }), + }; +} + +export function getWebcamOverlayPosition({ + containerWidth, + containerHeight, + size, + width, + height, + margin, + positionPreset, + positionX, + positionY, + legacyCorner, +}: { + containerWidth: number; + containerHeight: number; + size?: number; + width?: number; + height?: number; + margin: number; + positionPreset: WebcamPositionPreset; + positionX: number; + positionY: number; + legacyCorner: WebcamCorner; +}): { x: number; y: number } { + const safeMargin = Math.max(0, margin); + const overlayWidth = Math.max(0, width ?? size ?? 0); + const overlayHeight = Math.max(0, height ?? size ?? overlayWidth); + const availableWidth = Math.max(0, containerWidth - overlayWidth - safeMargin * 2); + const availableHeight = Math.max(0, containerHeight - overlayHeight - safeMargin * 2); + const presetPosition = + positionPreset === "custom" + ? { x: clamp(positionX, 0, 1), y: clamp(positionY, 0, 1) } + : getWebcamPositionForPreset(positionPreset || legacyCorner); + + return { + x: safeMargin + availableWidth * presetPosition.x, + y: safeMargin + availableHeight * presetPosition.y, + }; +} + +export function normalizeWebcamCropRegion(cropRegion?: Partial<CropRegion> | null): CropRegion { + const candidate = cropRegion ?? {}; + const rawX = Number.isFinite(candidate.x) ? (candidate.x as number) : 0; + const rawY = Number.isFinite(candidate.y) ? (candidate.y as number) : 0; + const x = clamp(rawX, 0, 0.99); + const y = clamp(rawY, 0, 0.99); + const width = clamp( + Number.isFinite(candidate.width) ? (candidate.width as number) : 1, + 0.01, + 1 - x, + ); + const height = clamp( + Number.isFinite(candidate.height) ? (candidate.height as number) : 1, + 0.01, + 1 - y, + ); + + return { x, y, width, height }; +} + +export function isWebcamCropRegionDefault(cropRegion?: Partial<CropRegion> | null): boolean { + const crop = normalizeWebcamCropRegion(cropRegion); + return crop.x <= 0 && crop.y <= 0 && crop.width >= 1 && crop.height >= 1; +} + +export function getWebcamCropSourceRect( + cropRegion: Partial<CropRegion> | null | undefined, + sourceWidth: number, + sourceHeight: number, +): { sx: number; sy: number; sw: number; sh: number } { + const crop = normalizeWebcamCropRegion(cropRegion); + const safeWidth = Math.max(1, sourceWidth); + const safeHeight = Math.max(1, sourceHeight); + const sx = clamp(crop.x * safeWidth, 0, safeWidth - 1); + const sy = clamp(crop.y * safeHeight, 0, safeHeight - 1); + const sw = clamp(crop.width * safeWidth, 1, safeWidth - sx); + const sh = clamp(crop.height * safeHeight, 1, safeHeight - sy); + + return { sx, sy, sw, sh }; +} + +export function getCropMatchedWebcamHeightPercent( + widthPercent: number, + heightPercent: number, + sourceWidth: number | null | undefined, + sourceHeight: number | null | undefined, + cropRegion: Partial<CropRegion> | null | undefined, +): number { + const safeWidthPercent = Number.isFinite(widthPercent) ? widthPercent : 40; + const safeHeightPercent = Number.isFinite(heightPercent) ? heightPercent : safeWidthPercent; + if (Math.abs(safeWidthPercent - safeHeightPercent) > 0.001) { + return clamp(safeHeightPercent, 10, 100); + } + + const crop = normalizeWebcamCropRegion(cropRegion); + if (crop.x <= 0 && crop.y <= 0 && crop.width >= 1 && crop.height >= 1) { + return clamp(safeHeightPercent, 10, 100); + } + + const sourceAspect = + Number.isFinite(sourceWidth) && + Number.isFinite(sourceHeight) && + sourceWidth != null && + sourceHeight != null && + sourceWidth > 0 && + sourceHeight > 0 + ? sourceWidth / sourceHeight + : 1; + const cropAspect = (crop.width * sourceAspect) / Math.max(0.001, crop.height); + if (!Number.isFinite(cropAspect) || cropAspect <= 0) { + return clamp(safeHeightPercent, 10, 100); + } + + return clamp(safeWidthPercent / cropAspect, 10, 100); +} diff --git a/src/content/announcements.ts b/src/content/announcements.ts new file mode 100644 index 000000000..80281a12a --- /dev/null +++ b/src/content/announcements.ts @@ -0,0 +1,9 @@ +import type { AnnouncementFeed } from "@/lib/announcements"; + +/** Announcements bundled with a release. Use a new ID when an item should be shown again. */ +export const BUNDLED_ANNOUNCEMENT_FEED: AnnouncementFeed = { + settings: { + aspectRatio: "4:3", + }, + announcements: [], +}; diff --git a/src/contexts/I18nContext.tsx b/src/contexts/I18nContext.tsx new file mode 100644 index 000000000..da831a168 --- /dev/null +++ b/src/contexts/I18nContext.tsx @@ -0,0 +1,359 @@ +import { + createContext, + type ReactNode, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from "react"; +import { + type AppLocale, + DEFAULT_LOCALE, + I18N_NAMESPACES, + type I18nNamespace, + SUPPORTED_LOCALES, +} from "@/i18n/config"; +import enCommon from "@/i18n/locales/en/common.json"; +import enDialogs from "@/i18n/locales/en/dialogs.json"; +import enEditor from "@/i18n/locales/en/editor.json"; +import enLaunch from "@/i18n/locales/en/launch.json"; +import enSettings from "@/i18n/locales/en/settings.json"; +import enShortcuts from "@/i18n/locales/en/shortcuts.json"; +import enTimeline from "@/i18n/locales/en/timeline.json"; +import esCommon from "@/i18n/locales/es/common.json"; +import esDialogs from "@/i18n/locales/es/dialogs.json"; +import esEditor from "@/i18n/locales/es/editor.json"; +import esLaunch from "@/i18n/locales/es/launch.json"; +import esSettings from "@/i18n/locales/es/settings.json"; +import esShortcuts from "@/i18n/locales/es/shortcuts.json"; +import esTimeline from "@/i18n/locales/es/timeline.json"; +import frCommon from "@/i18n/locales/fr/common.json"; +import frDialogs from "@/i18n/locales/fr/dialogs.json"; +import frEditor from "@/i18n/locales/fr/editor.json"; +import frLaunch from "@/i18n/locales/fr/launch.json"; +import frSettings from "@/i18n/locales/fr/settings.json"; +import frShortcuts from "@/i18n/locales/fr/shortcuts.json"; +import frTimeline from "@/i18n/locales/fr/timeline.json"; +import deCommon from "@/i18n/locales/de/common.json"; +import deDialogs from "@/i18n/locales/de/dialogs.json"; +import deEditor from "@/i18n/locales/de/editor.json"; +import deLaunch from "@/i18n/locales/de/launch.json"; +import deSettings from "@/i18n/locales/de/settings.json"; +import deShortcuts from "@/i18n/locales/de/shortcuts.json"; +import deTimeline from "@/i18n/locales/de/timeline.json"; +import itCommon from "@/i18n/locales/it/common.json"; +import itDialogs from "@/i18n/locales/it/dialogs.json"; +import itEditor from "@/i18n/locales/it/editor.json"; +import itLaunch from "@/i18n/locales/it/launch.json"; +import itSettings from "@/i18n/locales/it/settings.json"; +import itShortcuts from "@/i18n/locales/it/shortcuts.json"; +import itTimeline from "@/i18n/locales/it/timeline.json"; +import koCommon from "@/i18n/locales/ko/common.json"; +import koDialogs from "@/i18n/locales/ko/dialogs.json"; +import koEditor from "@/i18n/locales/ko/editor.json"; +import koLaunch from "@/i18n/locales/ko/launch.json"; +import koSettings from "@/i18n/locales/ko/settings.json"; +import koShortcuts from "@/i18n/locales/ko/shortcuts.json"; +import koTimeline from "@/i18n/locales/ko/timeline.json"; +import nlCommon from "@/i18n/locales/nl/common.json"; +import nlDialogs from "@/i18n/locales/nl/dialogs.json"; +import nlEditor from "@/i18n/locales/nl/editor.json"; +import nlLaunch from "@/i18n/locales/nl/launch.json"; +import nlSettings from "@/i18n/locales/nl/settings.json"; +import nlShortcuts from "@/i18n/locales/nl/shortcuts.json"; +import nlTimeline from "@/i18n/locales/nl/timeline.json"; +import ptBRCommon from "@/i18n/locales/pt-BR/common.json"; +import ptBRDialogs from "@/i18n/locales/pt-BR/dialogs.json"; +import ptBREditor from "@/i18n/locales/pt-BR/editor.json"; +import ptBRLaunch from "@/i18n/locales/pt-BR/launch.json"; +import ptBRSettings from "@/i18n/locales/pt-BR/settings.json"; +import ptBRShortcuts from "@/i18n/locales/pt-BR/shortcuts.json"; +import ptBRTimeline from "@/i18n/locales/pt-BR/timeline.json"; +import zhCNCommon from "@/i18n/locales/zh-CN/common.json"; +import zhCNDialogs from "@/i18n/locales/zh-CN/dialogs.json"; +import zhCNEditor from "@/i18n/locales/zh-CN/editor.json"; +import zhCNLaunch from "@/i18n/locales/zh-CN/launch.json"; +import zhCNSettings from "@/i18n/locales/zh-CN/settings.json"; +import zhCNShortcuts from "@/i18n/locales/zh-CN/shortcuts.json"; +import zhCNTimeline from "@/i18n/locales/zh-CN/timeline.json"; +import zhTWCommon from "@/i18n/locales/zh-TW/common.json"; +import zhTWDialogs from "@/i18n/locales/zh-TW/dialogs.json"; +import zhTWEditor from "@/i18n/locales/zh-TW/editor.json"; +import zhTWLaunch from "@/i18n/locales/zh-TW/launch.json"; +import zhTWSettings from "@/i18n/locales/zh-TW/settings.json"; +import zhTWShortcuts from "@/i18n/locales/zh-TW/shortcuts.json"; +import zhTWTimeline from "@/i18n/locales/zh-TW/timeline.json"; + +const LOCALE_STORAGE_KEY = "recordly.locale"; + +type LocaleBundle = Record<I18nNamespace, Record<string, unknown>>; + +const messages: Record<AppLocale, LocaleBundle> = { + en: { + common: enCommon, + launch: enLaunch, + editor: enEditor, + timeline: enTimeline, + settings: enSettings, + dialogs: enDialogs, + shortcuts: enShortcuts, + }, + es: { + common: esCommon, + launch: esLaunch, + editor: esEditor, + timeline: esTimeline, + settings: esSettings, + dialogs: esDialogs, + shortcuts: esShortcuts, + }, + fr: { + common: frCommon, + launch: frLaunch, + editor: frEditor, + timeline: frTimeline, + settings: frSettings, + dialogs: frDialogs, + shortcuts: frShortcuts, + }, + de: { + common: deCommon, + launch: deLaunch, + editor: deEditor, + timeline: deTimeline, + settings: deSettings, + dialogs: deDialogs, + shortcuts: deShortcuts, + }, + it: { + common: itCommon, + launch: itLaunch, + editor: itEditor, + timeline: itTimeline, + settings: itSettings, + dialogs: itDialogs, + shortcuts: itShortcuts, + }, + nl: { + common: nlCommon, + launch: nlLaunch, + editor: nlEditor, + timeline: nlTimeline, + settings: nlSettings, + dialogs: nlDialogs, + shortcuts: nlShortcuts, + }, + ko: { + common: koCommon, + launch: koLaunch, + editor: koEditor, + timeline: koTimeline, + settings: koSettings, + dialogs: koDialogs, + shortcuts: koShortcuts, + }, + "pt-BR": { + common: ptBRCommon, + launch: ptBRLaunch, + editor: ptBREditor, + timeline: ptBRTimeline, + settings: ptBRSettings, + dialogs: ptBRDialogs, + shortcuts: ptBRShortcuts, + }, + "zh-CN": { + common: zhCNCommon, + launch: zhCNLaunch, + editor: zhCNEditor, + timeline: zhCNTimeline, + settings: zhCNSettings, + dialogs: zhCNDialogs, + shortcuts: zhCNShortcuts, + }, + "zh-TW": { + common: zhTWCommon, + launch: zhTWLaunch, + editor: zhTWEditor, + timeline: zhTWTimeline, + settings: zhTWSettings, + dialogs: zhTWDialogs, + shortcuts: zhTWShortcuts, + }, +} as const; + +interface I18nContextValue { + locale: AppLocale; + setLocale: (locale: AppLocale) => void; + t: (key: string, fallback?: string, vars?: Record<string, string | number>) => string; +} + +const I18nContext = createContext<I18nContextValue | null>(null); + +function isSupportedLocale(locale: string): locale is AppLocale { + return SUPPORTED_LOCALES.includes(locale as AppLocale); +} + +function normalizeLocale(locale: string | null | undefined): AppLocale { + if (!locale) { + return DEFAULT_LOCALE; + } + + // Exact match first (e.g. "zh-CN") + if (isSupportedLocale(locale)) return locale; + + // Canonicalize case (e.g. "zh-cn" → "zh-CN") + const canonical = SUPPORTED_LOCALES.find((l) => l.toLowerCase() === locale.toLowerCase()); + if (canonical) return canonical; + + // Handle extended subtags like "zh-Hans-CN" → try "zh-CN" + const parts = locale.split("-"); + if (parts.length >= 3) { + const langRegion = `${parts[0]}-${parts[parts.length - 1]}`; + if (isSupportedLocale(langRegion)) { + return langRegion; + } + } + + // Language-only fallback (e.g. "zh" matches "zh-CN") + const lang = parts[0].toLowerCase(); + const byLang = SUPPORTED_LOCALES.find((l) => l.split("-")[0].toLowerCase() === lang); + if (byLang) return byLang; + + return DEFAULT_LOCALE; +} + +function getSystemLocale(): AppLocale { + if (typeof navigator === "undefined") { + return DEFAULT_LOCALE; + } + + const preferredLocales = Array.isArray(navigator.languages) + ? navigator.languages + : [navigator.language]; + + for (const locale of preferredLocales) { + if (typeof locale !== "string" || locale.trim().length === 0) { + continue; + } + + const normalized = normalizeLocale(locale); + if (normalized !== DEFAULT_LOCALE || locale.toLowerCase().startsWith(DEFAULT_LOCALE)) { + return normalized; + } + } + + return DEFAULT_LOCALE; +} + +function getInitialLocale(): AppLocale { + if (typeof window === "undefined") { + return DEFAULT_LOCALE; + } + + const storedLocale = window.localStorage.getItem(LOCALE_STORAGE_KEY); + if (storedLocale) { + return normalizeLocale(storedLocale); + } + + return getSystemLocale(); +} + +function getMessageValue(source: unknown, key: string): string | undefined { + const parts = key.split("."); + let current: unknown = source; + + for (const part of parts) { + if (!current || typeof current !== "object" || !(part in current)) { + return undefined; + } + + current = (current as Record<string, unknown>)[part]; + } + + return typeof current === "string" ? current : undefined; +} + +function interpolate(template: string, vars?: Record<string, string | number>) { + if (!vars) return template; + return template.replace(/\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/g, (_match, key) => { + const value = vars[key]; + return value === undefined ? "" : String(value); + }); +} + +function parseKey(key: string): { namespace: I18nNamespace; path: string } { + const [first, ...rest] = key.split("."); + if (I18N_NAMESPACES.includes(first as I18nNamespace) && rest.length > 0) { + return { namespace: first as I18nNamespace, path: rest.join(".") }; + } + return { namespace: "common", path: key }; +} + +function translateForLocale( + locale: AppLocale, + key: string, + fallback?: string, + vars?: Record<string, string | number>, +) { + const { namespace, path } = parseKey(key); + + const rawValue = + getMessageValue(messages[locale][namespace], path) ?? + getMessageValue(messages[DEFAULT_LOCALE][namespace], path) ?? + fallback ?? + key; + + return interpolate(rawValue, vars); +} + +export function I18nProvider({ children }: { children: ReactNode }) { + const [locale, setLocaleState] = useState<AppLocale>(getInitialLocale); + + const setLocale = useCallback((nextLocale: AppLocale) => { + setLocaleState(nextLocale); + if (typeof window !== "undefined") { + window.localStorage.setItem(LOCALE_STORAGE_KEY, nextLocale); + } + }, []); + + useEffect(() => { + document.documentElement.lang = locale; + }, [locale]); + + const t = useCallback( + (key: string, fallback?: string, vars?: Record<string, string | number>) => { + return translateForLocale(locale, key, fallback, vars); + }, + [locale], + ); + + const value = useMemo<I18nContextValue>( + () => ({ + locale, + setLocale, + t, + }), + [locale, setLocale, t], + ); + + return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>; +} + +export function useI18n() { + const context = useContext(I18nContext); + if (!context) { + throw new Error("useI18n must be used within <I18nProvider>"); + } + return context; +} + +export function useScopedT(namespace: I18nNamespace) { + const { t } = useI18n(); + return useCallback( + (key: string, fallback?: string, vars?: Record<string, string | number>) => { + return t(`${namespace}.${key}`, fallback, vars); + }, + [namespace, t], + ); +} diff --git a/src/contexts/ShortcutsContext.tsx b/src/contexts/ShortcutsContext.tsx new file mode 100644 index 000000000..8b80818af --- /dev/null +++ b/src/contexts/ShortcutsContext.tsx @@ -0,0 +1,77 @@ +import { + createContext, + type ReactNode, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from "react"; +import { DEFAULT_SHORTCUTS, mergeWithDefaults, type ShortcutsConfig } from "@/lib/shortcuts"; +import { isMac as getIsMac } from "@/utils/platformUtils"; + +interface ShortcutsContextValue { + shortcuts: ShortcutsConfig; + isMac: boolean; + setShortcuts: (config: ShortcutsConfig) => void; + persistShortcuts: (config?: ShortcutsConfig) => Promise<void>; + isConfigOpen: boolean; + openConfig: () => void; + closeConfig: () => void; +} + +const ShortcutsContext = createContext<ShortcutsContextValue | null>(null); + +export function useShortcuts(): ShortcutsContextValue { + const ctx = useContext(ShortcutsContext); + if (!ctx) throw new Error("useShortcuts must be used within <ShortcutsProvider>"); + return ctx; +} + +export function ShortcutsProvider({ children }: { children: ReactNode }) { + const [shortcuts, setShortcuts] = useState<ShortcutsConfig>(DEFAULT_SHORTCUTS); + const [isMac, setIsMac] = useState(false); + const [isConfigOpen, setIsConfigOpen] = useState(false); + + useEffect(() => { + getIsMac() + .then(setIsMac) + .catch(() => undefined); + + void (async () => { + try { + const saved = await window.electronAPI?.getShortcuts?.(); + if (saved) { + setShortcuts(mergeWithDefaults(saved as Partial<ShortcutsConfig>)); + } + } catch { + return undefined; + } + })(); + }, []); + + const persistShortcuts = useCallback( + async (config?: ShortcutsConfig) => { + await window.electronAPI?.saveShortcuts?.(config ?? shortcuts); + }, + [shortcuts], + ); + + const openConfig = useCallback(() => setIsConfigOpen(true), []); + const closeConfig = useCallback(() => setIsConfigOpen(false), []); + + const value = useMemo<ShortcutsContextValue>( + () => ({ + shortcuts, + isMac, + setShortcuts, + persistShortcuts, + isConfigOpen, + openConfig, + closeConfig, + }), + [shortcuts, isMac, persistShortcuts, isConfigOpen, openConfig, closeConfig], + ); + + return <ShortcutsContext.Provider value={value}>{children}</ShortcutsContext.Provider>; +} diff --git a/src/contexts/ThemeContext.test.ts b/src/contexts/ThemeContext.test.ts new file mode 100644 index 000000000..f49e7ef49 --- /dev/null +++ b/src/contexts/ThemeContext.test.ts @@ -0,0 +1,71 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { loadThemePreference, persistThemePreference } from "./ThemeContext"; + +function createStorageMock(initialValues: Record<string, string> = {}): Storage { + const store = new Map(Object.entries(initialValues)); + + return { + get length() { + return store.size; + }, + clear() { + store.clear(); + }, + getItem(key) { + return store.get(key) ?? null; + }, + key(index) { + return Array.from(store.keys())[index] ?? null; + }, + removeItem(key) { + store.delete(key); + }, + setItem(key, value) { + store.set(key, value); + }, + }; +} + +function stubElectronSettings(initialValues: Record<string, unknown> = {}) { + const store = new Map(Object.entries(initialValues)); + + Object.defineProperty(globalThis, "electronAPI", { + configurable: true, + value: { + getAppSetting: (key: string) => (store.has(key) ? store.get(key) : null), + setAppSetting: (key: string, value: unknown) => { + store.set(key, value); + return true; + }, + } as Pick<Window["electronAPI"], "getAppSetting" | "setAppSetting">, + }); + + return store; +} + +describe("ThemeContext persistence", () => { + afterEach(() => { + vi.unstubAllGlobals(); + Reflect.deleteProperty(globalThis, "electronAPI"); + }); + + it("loads the persisted theme preference from Electron app settings", () => { + stubElectronSettings({ "recordly.theme": "dark" }); + + expect(loadThemePreference()).toBe("dark"); + }); + + it("saves the theme preference to Electron app settings", () => { + const settingsStore = stubElectronSettings(); + + persistThemePreference("dark"); + + expect(settingsStore.get("recordly.theme")).toBe("dark"); + }); + + it("falls back to localStorage when Electron settings are unavailable", () => { + vi.stubGlobal("localStorage", createStorageMock({ "recordly.theme": "light" })); + + expect(loadThemePreference()).toBe("light"); + }); +}); diff --git a/src/contexts/ThemeContext.tsx b/src/contexts/ThemeContext.tsx new file mode 100644 index 000000000..e43d8a311 --- /dev/null +++ b/src/contexts/ThemeContext.tsx @@ -0,0 +1,116 @@ +import { createContext, useCallback, useContext, useEffect, useState } from "react"; +import { loadAppSetting, saveAppSetting } from "../lib/appSettings"; + +export type ThemePreference = "light" | "dark" | "system"; +export type ResolvedTheme = "light" | "dark"; + +interface ThemeContextValue { + /** The user's preference: light, dark, or system */ + preference: ThemePreference; + /** The resolved theme applied to the DOM */ + theme: ResolvedTheme; + setPreference: (pref: ThemePreference) => void; + toggleTheme: () => void; +} + +const THEME_STORAGE_KEY = "recordly.theme"; + +const ThemeContext = createContext<ThemeContextValue | null>(null); + +export function loadThemePreference(): ThemePreference { + const persisted = loadAppSetting<unknown>(THEME_STORAGE_KEY); + if (persisted === "light" || persisted === "dark" || persisted === "system") { + return persisted; + } + + try { + const stored = globalThis.localStorage?.getItem(THEME_STORAGE_KEY); + if (stored === "light" || stored === "dark" || stored === "system") { + return stored; + } + } catch { + // Ignore storage errors + } + return "system"; +} + +export function persistThemePreference(pref: ThemePreference): void { + saveAppSetting(THEME_STORAGE_KEY, pref); + + try { + globalThis.localStorage?.setItem(THEME_STORAGE_KEY, pref); + } catch { + // Ignore storage errors + } +} + +function resolveTheme(pref: ThemePreference): ResolvedTheme { + if (pref === "system") { + return globalThis.matchMedia?.("(prefers-color-scheme: dark)").matches ? "dark" : "light"; + } + return pref; +} + +function applyThemeToDOM(theme: ResolvedTheme) { + const root = document.documentElement; + root.dataset.theme = theme; + if (theme === "dark") { + root.classList.add("dark"); + } else { + root.classList.remove("dark"); + } +} + +export function ThemeProvider({ children }: { children: React.ReactNode }) { + const [preference, setPreferenceState] = useState<ThemePreference>(() => { + const stored = loadThemePreference(); + applyThemeToDOM(resolveTheme(stored)); + return stored; + }); + + const [resolved, setResolved] = useState<ResolvedTheme>(() => resolveTheme(preference)); + + const setPreference = useCallback((pref: ThemePreference) => { + setPreferenceState(pref); + const r = resolveTheme(pref); + setResolved(r); + applyThemeToDOM(r); + persistThemePreference(pref); + }, []); + + const toggleTheme = useCallback(() => { + setPreference(resolved === "dark" ? "light" : "dark"); + }, [resolved, setPreference]); + + // Listen for system theme changes when preference is "system" + useEffect(() => { + if (preference !== "system") return; + const mq = globalThis.matchMedia?.("(prefers-color-scheme: dark)"); + if (!mq) return; + const handler = () => { + const r = resolveTheme("system"); + setResolved(r); + applyThemeToDOM(r); + }; + mq.addEventListener("change", handler); + return () => mq.removeEventListener("change", handler); + }, [preference]); + + useEffect(() => { + applyThemeToDOM(resolved); + }, [resolved]); + + return ( + <ThemeContext.Provider value={{ preference, theme: resolved, setPreference, toggleTheme }}> + {children} + </ThemeContext.Provider> + ); +} + +export function useTheme(): ThemeContextValue { + const context = useContext(ThemeContext); + if (!context) { + throw new Error("useTheme must be used within a ThemeProvider"); + } + return context; +} diff --git a/src/hooks/recordingMimeType.test.ts b/src/hooks/recordingMimeType.test.ts new file mode 100644 index 000000000..79dd6c2d2 --- /dev/null +++ b/src/hooks/recordingMimeType.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from "vitest"; +import { + getVideoExtensionForMimeType, + isWebmMimeType, + selectRecordingMimeType, + selectWebcamRecordingMimeType, +} from "./recordingMimeType"; + +describe("selectRecordingMimeType", () => { + it("keeps browser screen captures in WebM/H.264 when supported", () => { + const mimeType = selectRecordingMimeType({ + isTypeSupported: () => true, + canPlayType: (type) => { + if (type === "video/webm;codecs=h264") { + return "probably"; + } + + if (type === "video/webm;codecs=vp9") { + return "maybe"; + } + + return ""; + }, + }); + + expect(mimeType).toBe("video/webm;codecs=h264"); + }); + + it("skips recorder-only codecs when playback support is missing", () => { + const mimeType = selectRecordingMimeType({ + isTypeSupported: (type) => + ["video/webm;codecs=vp9", "video/webm;codecs=vp8"].includes(type), + canPlayType: (type) => (type === "video/webm;codecs=vp8" ? "probably" : ""), + }); + + expect(mimeType).toBe("video/webm;codecs=vp8"); + }); + + it("falls back to the first supported codec when playback probing is unavailable", () => { + const mimeType = selectRecordingMimeType({ + isTypeSupported: (type) => + ["video/webm;codecs=av1", "video/webm;codecs=h264"].includes(type), + canPlayType: () => "", + }); + + expect(mimeType).toBe("video/webm;codecs=h264"); + }); + + it("returns undefined when no preferred mime type is supported", () => { + const mimeType = selectRecordingMimeType({ + isTypeSupported: () => false, + canPlayType: () => "", + }); + + expect(mimeType).toBeUndefined(); + }); + + it("prefers MP4/H.264 for webcam captures when supported", () => { + const mimeType = selectWebcamRecordingMimeType({ + isTypeSupported: (type) => + ["video/mp4;codecs=avc1.42E01E", "video/webm;codecs=vp9"].includes(type), + canPlayType: () => "probably", + }); + + expect(mimeType).toBe("video/mp4;codecs=avc1.42E01E"); + }); + + it("falls back to WebM webcam capture when MP4 is unavailable", () => { + const mimeType = selectWebcamRecordingMimeType({ + isTypeSupported: (type) => ["video/webm;codecs=vp9", "video/webm"].includes(type), + canPlayType: () => "probably", + }); + + expect(mimeType).toBe("video/webm;codecs=vp9"); + }); + + it("maps recording MIME types to the saved file extension", () => { + expect(getVideoExtensionForMimeType("video/mp4;codecs=avc1")).toBe(".mp4"); + expect(getVideoExtensionForMimeType("video/webm;codecs=vp9")).toBe(".webm"); + expect(getVideoExtensionForMimeType(undefined)).toBe(".webm"); + }); + + it("detects WebM MIME types for duration repair", () => { + expect(isWebmMimeType("video/webm;codecs=vp9")).toBe(true); + expect(isWebmMimeType("video/mp4;codecs=avc1")).toBe(false); + expect(isWebmMimeType(undefined)).toBe(false); + }); +}); diff --git a/src/hooks/recordingMimeType.ts b/src/hooks/recordingMimeType.ts new file mode 100644 index 000000000..56930b841 --- /dev/null +++ b/src/hooks/recordingMimeType.ts @@ -0,0 +1,59 @@ +const RECORDING_MIME_TYPE_PREFERENCES = [ + "video/webm;codecs=h264", + "video/webm;codecs=vp9", + "video/webm", + "video/webm;codecs=vp8", + "video/webm;codecs=av1", +] as const; + +const WEBCAM_RECORDING_MIME_TYPE_PREFERENCES = [ + "video/mp4;codecs=avc1.42E01E", + "video/mp4;codecs=avc1", + "video/mp4;codecs=h264", + "video/mp4", + "video/webm;codecs=h264", + "video/webm;codecs=vp9", + "video/webm", + "video/webm;codecs=vp8", +] as const; + +type MimeTypeSelectorOptions = { + isTypeSupported?: (type: string) => boolean; + canPlayType?: (type: string) => string; +}; + +function selectMimeTypeFromPreferences( + preferences: readonly string[], + options: MimeTypeSelectorOptions = {}, +): string | undefined { + const isTypeSupported = + options.isTypeSupported ?? ((type: string) => MediaRecorder.isTypeSupported(type)); + const canPlayType = + options.canPlayType ?? + ((type: string) => document.createElement("video").canPlayType(type)); + + const supportedTypes = preferences.filter((type) => isTypeSupported(type)); + const playableType = supportedTypes.find((type) => canPlayType(type) !== ""); + + return playableType ?? supportedTypes[0]; +} + +export function selectRecordingMimeType(options: MimeTypeSelectorOptions = {}): string | undefined { + return selectMimeTypeFromPreferences(RECORDING_MIME_TYPE_PREFERENCES, options); +} + +export function selectWebcamRecordingMimeType( + options: MimeTypeSelectorOptions = {}, +): string | undefined { + return selectMimeTypeFromPreferences(WEBCAM_RECORDING_MIME_TYPE_PREFERENCES, options); +} + +export function isWebmMimeType(mimeType: string | undefined | null): boolean { + return /^video\/webm(?:[;\s]|$)/i.test(mimeType ?? ""); +} + +export function getVideoExtensionForMimeType( + mimeType: string | undefined | null, +): ".mp4" | ".webm" { + return /^video\/mp4(?:[;\s]|$)/i.test(mimeType ?? "") ? ".mp4" : ".webm"; +} diff --git a/src/hooks/useAudioLevelMeter.ts b/src/hooks/useAudioLevelMeter.ts new file mode 100644 index 000000000..9ad26995a --- /dev/null +++ b/src/hooks/useAudioLevelMeter.ts @@ -0,0 +1,106 @@ +import { useEffect, useRef, useState } from "react"; + +export interface AudioLevelMeterOptions { + enabled: boolean; + deviceId?: string; + smoothingFactor?: number; +} + +export function useAudioLevelMeter(options: AudioLevelMeterOptions) { + const [level, setLevel] = useState(0); + const audioContextRef = useRef<AudioContext | null>(null); + const analyserRef = useRef<AnalyserNode | null>(null); + const streamRef = useRef<MediaStream | null>(null); + const animationFrameRef = useRef<number | null>(null); + + useEffect(() => { + const cleanup = () => { + if (animationFrameRef.current) { + cancelAnimationFrame(animationFrameRef.current); + animationFrameRef.current = null; + } + if (streamRef.current) { + streamRef.current.getTracks().forEach((track) => track.stop()); + streamRef.current = null; + } + if (audioContextRef.current) { + audioContextRef.current.close().catch(() => undefined); + audioContextRef.current = null; + } + analyserRef.current = null; + }; + + if (!options.enabled) { + cleanup(); + setLevel(0); + return cleanup; + } + + let mounted = true; + + const startMonitoring = async () => { + try { + const constraints: MediaStreamConstraints = { + audio: options.deviceId ? { deviceId: { exact: options.deviceId } } : true, + video: false, + }; + + const stream = await navigator.mediaDevices.getUserMedia(constraints); + if (!mounted) { + stream.getTracks().forEach((track) => track.stop()); + return; + } + + streamRef.current = stream; + + const audioContext = new AudioContext(); + if (audioContext.state === "suspended") { + await audioContext.resume(); + } + audioContextRef.current = audioContext; + + const analyser = audioContext.createAnalyser(); + analyser.fftSize = 256; + analyser.smoothingTimeConstant = options.smoothingFactor ?? 0.8; + analyserRef.current = analyser; + + const source = audioContext.createMediaStreamSource(stream); + source.connect(analyser); + + const dataArray = new Uint8Array(analyser.frequencyBinCount); + + const updateLevel = () => { + if (!mounted || !analyserRef.current) return; + + analyser.getByteFrequencyData(dataArray); + + let sum = 0; + for (let index = 0; index < dataArray.length; index++) { + sum += dataArray[index] * dataArray[index]; + } + + const rms = Math.sqrt(sum / dataArray.length); + const normalizedLevel = Math.min(100, (rms / 255) * 100 * 2); + setLevel(normalizedLevel); + animationFrameRef.current = requestAnimationFrame(updateLevel); + }; + + updateLevel(); + } catch (error) { + console.error("Error starting audio level monitoring:", error); + if (mounted) { + setLevel(0); + } + } + }; + + void startMonitoring(); + + return () => { + mounted = false; + cleanup(); + }; + }, [options.deviceId, options.enabled, options.smoothingFactor]); + + return { level }; +} diff --git a/src/hooks/useMicrophoneDevices.ts b/src/hooks/useMicrophoneDevices.ts new file mode 100644 index 000000000..0d251b6c6 --- /dev/null +++ b/src/hooks/useMicrophoneDevices.ts @@ -0,0 +1,119 @@ +import { useEffect, useState } from "react"; + +export interface MicrophoneDevice { + deviceId: string; + label: string; + groupId: string; +} + +let hasRequestedMicrophoneLabels = false; + +export function useMicrophoneDevices(enabled: boolean = true, preferredDeviceId?: string) { + const [devices, setDevices] = useState<MicrophoneDevice[]>([]); + const [selectedDeviceId, setSelectedDeviceId] = useState<string>("default"); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState<string | null>(null); + + useEffect(() => { + if (!enabled) { + return; + } + + let mounted = true; + + const loadDevices = async () => { + let permissionStream: MediaStream | null = null; + + try { + setIsLoading(true); + setError(null); + + let allDevices = await navigator.mediaDevices.enumerateDevices(); + let audioInputs = allDevices + .filter((device) => device.kind === "audioinput") + .map((device) => ({ + deviceId: device.deviceId, + label: device.label || `Microphone ${device.deviceId.slice(0, 8)}`, + groupId: device.groupId, + })); + + const needsLabelPermission = + audioInputs.length > 0 && audioInputs.every((device) => !device.label.trim()); + + if (needsLabelPermission && !hasRequestedMicrophoneLabels) { + hasRequestedMicrophoneLabels = true; + permissionStream = await navigator.mediaDevices.getUserMedia({ audio: true }); + allDevices = await navigator.mediaDevices.enumerateDevices(); + audioInputs = allDevices + .filter((device) => device.kind === "audioinput") + .map((device) => ({ + deviceId: device.deviceId, + label: device.label || `Microphone ${device.deviceId.slice(0, 8)}`, + groupId: device.groupId, + })); + } + + if (mounted) { + setDevices(audioInputs); + setSelectedDeviceId((currentDeviceId) => { + const normalizedPreferredDeviceId = preferredDeviceId ?? "default"; + if ( + audioInputs.some( + (device) => device.deviceId === normalizedPreferredDeviceId, + ) + ) { + return normalizedPreferredDeviceId; + } + + if ( + currentDeviceId !== "default" && + audioInputs.some((device) => device.deviceId === currentDeviceId) + ) { + return currentDeviceId; + } + + return ( + audioInputs.find((device) => device.deviceId !== "default")?.deviceId ?? + audioInputs[0]?.deviceId ?? + "default" + ); + }); + setIsLoading(false); + } + } catch (error) { + if (mounted) { + const message = + error instanceof Error + ? error.message + : "Failed to enumerate audio devices"; + setError(message); + setIsLoading(false); + console.error("Error loading microphone devices:", error); + } + } finally { + permissionStream?.getTracks().forEach((track) => track.stop()); + } + }; + + void loadDevices(); + + const handleDeviceChange = () => { + void loadDevices(); + }; + + navigator.mediaDevices.addEventListener("devicechange", handleDeviceChange); + + return () => { + mounted = false; + navigator.mediaDevices.removeEventListener("devicechange", handleDeviceChange); + }; + }, [enabled, preferredDeviceId]); + + return { + devices, + selectedDeviceId, + setSelectedDeviceId, + isLoading, + error, + }; +} diff --git a/src/hooks/useScreenRecorder.test.ts b/src/hooks/useScreenRecorder.test.ts new file mode 100644 index 000000000..1ddceb4e1 --- /dev/null +++ b/src/hooks/useScreenRecorder.test.ts @@ -0,0 +1,909 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { + createBrowserRecordingOptions, + createProcessedMicrophoneConstraints, + normalizeBrowserMicrophoneProfile, + resolveBrowserCaptureCursorPolicy, + shouldUseNativeWindowsCaptureForSource, + stopAndDiscardNativeCapture, +} from "./useScreenRecorder"; + +type RecordingState = "inactive" | "recording" | "paused"; + +function createMockMediaRecorder(initialState: RecordingState = "inactive") { + let _state: RecordingState = initialState; + return { + get state() { + return _state; + }, + pause: vi.fn(() => { + if (_state === "recording") _state = "paused"; + }), + resume: vi.fn(() => { + if (_state === "paused") _state = "recording"; + }), + requestData: vi.fn(), + stop: vi.fn(() => { + _state = "inactive"; + }), + start: vi.fn(() => { + _state = "recording"; + }), + }; +} + +describe("createProcessedMicrophoneConstraints", () => { + it("requests browser voice processing with AGC for the default microphone", () => { + expect(createProcessedMicrophoneConstraints()).toEqual({ + audio: { + echoCancellation: true, + noiseSuppression: true, + autoGainControl: true, + channelCount: { ideal: 1 }, + sampleRate: { ideal: 48000 }, + }, + video: false, + }); + }); + + it("keeps default voice processing when a specific microphone is selected", () => { + expect(createProcessedMicrophoneConstraints("device-123")).toMatchObject({ + audio: { + deviceId: { exact: "device-123" }, + echoCancellation: true, + noiseSuppression: true, + autoGainControl: true, + channelCount: { ideal: 1 }, + sampleRate: { ideal: 48000 }, + }, + video: false, + }); + }); + + it("can request the legacy browser processed profile for lab comparisons", () => { + expect(createProcessedMicrophoneConstraints(undefined, "processed")).toMatchObject({ + audio: { + echoCancellation: true, + noiseSuppression: true, + autoGainControl: true, + }, + video: false, + }); + }); + + it("can disable AGC for lab comparisons", () => { + expect(createProcessedMicrophoneConstraints(undefined, "no-agc")).toMatchObject({ + audio: { + echoCancellation: true, + noiseSuppression: true, + autoGainControl: false, + }, + video: false, + }); + }); + + it("can disable echo cancellation for lab comparisons", () => { + expect(createProcessedMicrophoneConstraints(undefined, "no-echo")).toMatchObject({ + audio: { + echoCancellation: false, + noiseSuppression: true, + autoGainControl: true, + }, + video: false, + }); + }); + + it("can request a raw browser microphone stream for lab comparisons", () => { + expect(createProcessedMicrophoneConstraints(undefined, "raw")).toMatchObject({ + audio: { + echoCancellation: false, + noiseSuppression: false, + autoGainControl: false, + }, + video: false, + }); + }); + + it("normalizes invalid lab microphone profiles to production voice processing", () => { + expect(normalizeBrowserMicrophoneProfile("RAW")).toBe("raw"); + expect(normalizeBrowserMicrophoneProfile("unknown")).toBe("processed"); + expect(normalizeBrowserMicrophoneProfile(null)).toBe("processed"); + }); +}); + +describe("createBrowserRecordingOptions", () => { + it("sets an aggregate bitrate target for browser screen recordings", () => { + expect( + createBrowserRecordingOptions({ + audioBitsPerSecond: 128_000, + mimeType: "video/webm;codecs=vp9", + videoBitsPerSecond: 30_600_000, + }), + ).toEqual({ + audioBitsPerSecond: 128_000, + bitsPerSecond: 30_728_000, + mimeType: "video/webm;codecs=vp9", + videoBitsPerSecond: 30_600_000, + }); + }); + + it("keeps video-only recordings on the requested video budget", () => { + expect( + createBrowserRecordingOptions({ + videoBitsPerSecond: 30_600_000, + }), + ).toEqual({ + bitsPerSecond: 30_600_000, + videoBitsPerSecond: 30_600_000, + }); + }); +}); + +describe("resolveBrowserCaptureCursorPolicy", () => { + it("preserves the existing hidden-cursor browser policy by default", () => { + expect(resolveBrowserCaptureCursorPolicy()).toEqual({ + streamCursor: "never", + hideOsCursorBeforeRecording: true, + hideEditorOverlayCursorByDefault: true, + }); + }); + + it("uses the browser captured cursor after native Windows capture fails to start", () => { + expect( + resolveBrowserCaptureCursorPolicy({ nativeWindowsCaptureStartFailed: true }), + ).toEqual({ + streamCursor: "always", + hideOsCursorBeforeRecording: false, + hideEditorOverlayCursorByDefault: true, + }); + }); +}); + +describe("shouldUseNativeWindowsCaptureForSource", () => { + it("keeps native Windows capture on screen sources", () => { + expect(shouldUseNativeWindowsCaptureForSource({ id: "screen:101:0" })).toBe(true); + }); + + it("keeps native Windows capture on window sources", () => { + expect(shouldUseNativeWindowsCaptureForSource({ id: "window:123456:0" })).toBe(true); + }); + + it("keeps browser capture for non-desktop sources", () => { + expect(shouldUseNativeWindowsCaptureForSource({ id: "browser-tab:abc" })).toBe(false); + }); +}); + +describe("stopAndDiscardNativeCapture", () => { + it("deletes the partial recording after a successful warm-start stop", async () => { + const deleteRecordingFile = vi.fn().mockResolvedValue(undefined); + + await expect( + stopAndDiscardNativeCapture({ + stopNativeScreenRecording: vi.fn().mockResolvedValue({ + success: true, + path: "C:\\Recordly\\warm-start.mp4", + }), + deleteRecordingFile, + }), + ).resolves.toEqual({ + stopSucceeded: true, + deleteSucceeded: true, + path: "C:\\Recordly\\warm-start.mp4", + }); + expect(deleteRecordingFile).toHaveBeenCalledWith("C:\\Recordly\\warm-start.mp4"); + }); + + it("reports an unsuccessful stop without deleting or confirming cleanup", async () => { + const deleteRecordingFile = vi.fn(); + + await expect( + stopAndDiscardNativeCapture({ + stopNativeScreenRecording: vi.fn().mockResolvedValue({ + success: false, + error: "helper still running", + }), + deleteRecordingFile, + }), + ).resolves.toEqual({ + stopSucceeded: false, + deleteSucceeded: false, + error: "helper still running", + }); + expect(deleteRecordingFile).not.toHaveBeenCalled(); + }); + + it("keeps the stopped path available when deletion fails so cleanup can retry", async () => { + const deleteError = new Error("file locked"); + + await expect( + stopAndDiscardNativeCapture({ + stopNativeScreenRecording: vi.fn().mockResolvedValue({ + success: true, + path: "C:\\Recordly\\warm-start.mp4", + }), + deleteRecordingFile: vi.fn().mockRejectedValue(deleteError), + }), + ).resolves.toEqual({ + stopSucceeded: true, + deleteSucceeded: false, + path: "C:\\Recordly\\warm-start.mp4", + error: deleteError, + }); + }); +}); + +function stopRecording( + recorder: ReturnType<typeof createMockMediaRecorder>, + isNativeRecording: boolean, + webcamRecorder?: ReturnType<typeof createMockMediaRecorder> | null, +) { + if (isNativeRecording) { + if (webcamRecorder && webcamRecorder.state !== "inactive") { + webcamRecorder.stop(); + } + return { stopped: true, wasNative: true }; + } + + const recorderState = recorder.state; + if (recorderState === "recording" || recorderState === "paused") { + if (recorderState === "paused") { + try { + recorder.resume(); + } catch { + // Stopping a paused recorder is still valid; mirror the hook's fallback path. + } + } + if (webcamRecorder && webcamRecorder.state !== "inactive") { + webcamRecorder.stop(); + } + try { + recorder.requestData(); + } catch { + // Stopping should continue even if the browser refuses an explicit flush. + } + recorder.stop(); + return { stopped: true, wasNative: false }; + } + return { stopped: false, wasNative: false }; +} + +function pauseRecording( + recorder: ReturnType<typeof createMockMediaRecorder>, + recording: boolean, + paused: boolean, + isNativeRecording: boolean, + webcamRecorder?: ReturnType<typeof createMockMediaRecorder> | null, + micFallbackRecorder?: ReturnType<typeof createMockMediaRecorder> | null, +): boolean { + if (!recording || paused) return false; + if (isNativeRecording) { + if (webcamRecorder?.state === "recording") { + webcamRecorder.pause(); + } + if (micFallbackRecorder?.state === "recording") { + micFallbackRecorder.requestData(); + micFallbackRecorder.pause(); + } + return true; + } + if (recorder.state === "recording") { + recorder.pause(); + if (webcamRecorder?.state === "recording") { + webcamRecorder.pause(); + } + return true; + } + return false; +} + +function resumeRecording( + recorder: ReturnType<typeof createMockMediaRecorder>, + recording: boolean, + paused: boolean, + isNativeRecording: boolean, + webcamRecorder?: ReturnType<typeof createMockMediaRecorder> | null, + micFallbackRecorder?: ReturnType<typeof createMockMediaRecorder> | null, +): boolean { + if (!recording || !paused) return false; + if (isNativeRecording) { + if (webcamRecorder?.state === "paused") { + webcamRecorder.resume(); + } + if (micFallbackRecorder?.state === "paused") { + micFallbackRecorder.resume(); + } + return true; + } + if (recorder.state === "paused") { + recorder.resume(); + if (webcamRecorder?.state === "paused") { + webcamRecorder.resume(); + } + return true; + } + return false; +} + +async function pauseNativeRecording( + webcamRecorder?: ReturnType<typeof createMockMediaRecorder> | null, + result: { success: boolean } = { success: true }, + micFallbackRecorder?: ReturnType<typeof createMockMediaRecorder> | null, +): Promise<boolean> { + if (!result.success) { + return false; + } + + if (webcamRecorder?.state === "recording") { + webcamRecorder.pause(); + } + if (micFallbackRecorder?.state === "recording") { + micFallbackRecorder.requestData(); + micFallbackRecorder.pause(); + } + + return true; +} + +async function resumeNativeRecording( + webcamRecorder?: ReturnType<typeof createMockMediaRecorder> | null, + result: { success: boolean } = { success: true }, + micFallbackRecorder?: ReturnType<typeof createMockMediaRecorder> | null, +): Promise<boolean> { + if (!result.success) { + return false; + } + + if (webcamRecorder?.state === "paused") { + webcamRecorder.resume(); + } + if (micFallbackRecorder?.state === "paused") { + micFallbackRecorder.resume(); + } + + return true; +} + +async function stopNativeRecordingWithCompanions({ + getRecordingDurationMs, + markRecordingResumed, + now, + stopMicFallbackRecorder, + stopNativeScreenRecording, + stopWebcamRecorder, +}: { + getRecordingDurationMs: (timestampMs: number) => number; + markRecordingResumed: (timestampMs: number) => void; + now: () => number; + stopMicFallbackRecorder: () => Promise<Blob | null>; + stopNativeScreenRecording: () => Promise<{ success: boolean; path?: string }>; + stopWebcamRecorder: () => Promise<string | null>; +}) { + const stoppedAtMs = now(); + markRecordingResumed(stoppedAtMs); + const expectedDurationMs = getRecordingDurationMs(stoppedAtMs); + const micFallbackBlobPromise = stopMicFallbackRecorder(); + const webcamPathPromise = stopWebcamRecorder(); + const result = await stopNativeScreenRecording(); + const webcamPath = await webcamPathPromise; + const micFallbackBlob = await micFallbackBlobPromise; + + return { expectedDurationMs, micFallbackBlob, result, webcamPath }; +} + +function cancelRecording( + recorder: ReturnType<typeof createMockMediaRecorder>, + isNativeRecording: boolean, + chunks: { current: Blob[] }, + webcamRecorder?: ReturnType<typeof createMockMediaRecorder> | null, + webcamChunks?: { current: Blob[] }, +) { + if (webcamChunks) webcamChunks.current = []; + if (webcamRecorder && webcamRecorder.state !== "inactive") { + webcamRecorder.stop(); + } + + if (isNativeRecording) { + return { cancelled: true, wasNative: true }; + } + + chunks.current = []; + if (recorder.state !== "inactive") { + recorder.stop(); + } + return { cancelled: true, wasNative: false }; +} + +describe("useScreenRecorder state machine", () => { + let recorder: ReturnType<typeof createMockMediaRecorder>; + + beforeEach(() => { + recorder = createMockMediaRecorder("recording"); + }); + + describe("stopRecording", () => { + it("stops from recording state", () => { + const result = stopRecording(recorder, false); + + expect(result.stopped).toBe(true); + expect(recorder.stop).toHaveBeenCalled(); + expect(recorder.resume).not.toHaveBeenCalled(); + expect(recorder.state).toBe("inactive"); + }); + + it("resumes then stops from paused state", () => { + recorder.pause(); + expect(recorder.state).toBe("paused"); + + const result = stopRecording(recorder, false); + + expect(result.stopped).toBe(true); + expect(recorder.resume).toHaveBeenCalled(); + expect(recorder.stop).toHaveBeenCalled(); + expect(recorder.state).toBe("inactive"); + }); + + it("resume is called before stop when paused", () => { + recorder.pause(); + const callOrder: string[] = []; + recorder.resume.mockImplementation(() => { + callOrder.push("resume"); + }); + recorder.stop.mockImplementation(() => { + callOrder.push("stop"); + }); + + stopRecording(recorder, false); + + expect(callOrder).toEqual(["resume", "stop"]); + }); + + it("flushes the current recorder data before stopping", () => { + const callOrder: string[] = []; + recorder.requestData.mockImplementation(() => { + callOrder.push("requestData"); + }); + recorder.stop.mockImplementation(() => { + callOrder.push("stop"); + }); + + stopRecording(recorder, false); + + expect(callOrder).toEqual(["requestData", "stop"]); + }); + + it("resumes, flushes, then stops from paused state", () => { + recorder.pause(); + const callOrder: string[] = []; + recorder.resume.mockImplementation(() => { + callOrder.push("resume"); + }); + recorder.requestData.mockImplementation(() => { + callOrder.push("requestData"); + }); + recorder.stop.mockImplementation(() => { + callOrder.push("stop"); + }); + + stopRecording(recorder, false); + + expect(callOrder).toEqual(["resume", "requestData", "stop"]); + }); + + it("still stops when the explicit data flush fails", () => { + recorder.requestData.mockImplementation(() => { + throw new Error("flush failed"); + }); + + const result = stopRecording(recorder, false); + + expect(result.stopped).toBe(true); + expect(recorder.stop).toHaveBeenCalled(); + }); + + it("still stops from paused state when the explicit data flush fails", () => { + recorder.pause(); + const callOrder: string[] = []; + recorder.resume.mockImplementation(() => { + callOrder.push("resume"); + }); + recorder.requestData.mockImplementation(() => { + callOrder.push("requestData"); + throw new Error("flush failed"); + }); + recorder.stop.mockImplementation(() => { + callOrder.push("stop"); + }); + + const result = stopRecording(recorder, false); + + expect(result.stopped).toBe(true); + expect(callOrder).toEqual(["resume", "requestData", "stop"]); + }); + + it("still stops when resume throws from paused state", () => { + recorder.pause(); + recorder.resume.mockImplementation(() => { + throw new Error("resume failed"); + }); + + const result = stopRecording(recorder, false); + + expect(result.stopped).toBe(true); + expect(recorder.stop).toHaveBeenCalled(); + expect(recorder.state).toBe("inactive"); + }); + + it("does nothing when already inactive", () => { + const inactiveRecorder = createMockMediaRecorder("inactive"); + + const result = stopRecording(inactiveRecorder, false); + + expect(result.stopped).toBe(false); + expect(inactiveRecorder.stop).not.toHaveBeenCalled(); + }); + + it("delegates to native path for native recordings", () => { + const result = stopRecording(recorder, true); + + expect(result.stopped).toBe(true); + expect(result.wasNative).toBe(true); + expect(recorder.stop).not.toHaveBeenCalled(); + }); + + it("stops webcam when stopping browser recording", () => { + const webcam = createMockMediaRecorder("recording"); + + stopRecording(recorder, false, webcam); + + expect(webcam.stop).toHaveBeenCalled(); + expect(webcam.state).toBe("inactive"); + }); + + it("stops webcam when stopping native recording", () => { + const webcam = createMockMediaRecorder("recording"); + + stopRecording(recorder, true, webcam); + + expect(webcam.stop).toHaveBeenCalled(); + expect(webcam.state).toBe("inactive"); + }); + }); + + describe("pauseRecording", () => { + it("pauses an active recording", () => { + const result = pauseRecording(recorder, true, false, false); + + expect(result).toBe(true); + expect(recorder.pause).toHaveBeenCalled(); + expect(recorder.state).toBe("paused"); + }); + + it("does nothing when already paused", () => { + recorder.pause(); + recorder.pause.mockClear(); + + const result = pauseRecording(recorder, true, true, false); + + expect(result).toBe(false); + expect(recorder.pause).not.toHaveBeenCalled(); + }); + + it("does nothing when not recording", () => { + const result = pauseRecording(recorder, false, false, false); + + expect(result).toBe(false); + expect(recorder.pause).not.toHaveBeenCalled(); + }); + + it("allows pause for native recordings", () => { + const result = pauseRecording(recorder, true, false, true); + + expect(result).toBe(true); + }); + + it("pauses webcam alongside browser recording", () => { + const webcam = createMockMediaRecorder("recording"); + + pauseRecording(recorder, true, false, false, webcam); + + expect(recorder.state).toBe("paused"); + expect(webcam.state).toBe("paused"); + }); + + it("pauses webcam during native recording pause", () => { + const webcam = createMockMediaRecorder("recording"); + + const result = pauseRecording(recorder, true, false, true, webcam); + + expect(result).toBe(true); + expect(webcam.state).toBe("paused"); + }); + + it("pauses browser mic fallback during native recording pause", () => { + const micFallback = createMockMediaRecorder("recording"); + + const result = pauseRecording(recorder, true, false, true, null, micFallback); + + expect(result).toBe(true); + expect(micFallback.requestData).toHaveBeenCalled(); + expect(micFallback.state).toBe("paused"); + }); + + it("skips webcam pause when webcam is not recording", () => { + const webcam = createMockMediaRecorder("inactive"); + + pauseRecording(recorder, true, false, false, webcam); + + expect(webcam.pause).not.toHaveBeenCalled(); + }); + }); + + describe("resumeRecording", () => { + it("resumes a paused recording", () => { + recorder.pause(); + + const result = resumeRecording(recorder, true, true, false); + + expect(result).toBe(true); + expect(recorder.resume).toHaveBeenCalled(); + expect(recorder.state).toBe("recording"); + }); + + it("does nothing when not paused", () => { + const result = resumeRecording(recorder, true, false, false); + + expect(result).toBe(false); + expect(recorder.resume).not.toHaveBeenCalled(); + }); + + it("does nothing when not recording", () => { + const result = resumeRecording(recorder, false, true, false); + + expect(result).toBe(false); + }); + + it("resumes webcam alongside browser recording", () => { + const webcam = createMockMediaRecorder("recording"); + recorder.pause(); + webcam.pause(); + + resumeRecording(recorder, true, true, false, webcam); + + expect(recorder.state).toBe("recording"); + expect(webcam.state).toBe("recording"); + }); + + it("resumes webcam during native recording resume", () => { + const webcam = createMockMediaRecorder("recording"); + webcam.pause(); + + const result = resumeRecording(recorder, true, true, true, webcam); + + expect(result).toBe(true); + expect(webcam.state).toBe("recording"); + }); + + it("resumes browser mic fallback during native recording resume", () => { + const micFallback = createMockMediaRecorder("recording"); + micFallback.pause(); + + const result = resumeRecording(recorder, true, true, true, null, micFallback); + + expect(result).toBe(true); + expect(micFallback.state).toBe("recording"); + }); + + it("skips webcam resume when webcam is not paused", () => { + recorder.pause(); + const webcam = createMockMediaRecorder("inactive"); + + resumeRecording(recorder, true, true, false, webcam); + + expect(webcam.resume).not.toHaveBeenCalled(); + }); + }); + + describe("cancelRecording", () => { + it("clears chunks and stops browser recording", () => { + const chunks = { current: [new Blob(["data"])] }; + + const result = cancelRecording(recorder, false, chunks); + + expect(result.cancelled).toBe(true); + expect(result.wasNative).toBe(false); + expect(chunks.current).toEqual([]); + expect(recorder.stop).toHaveBeenCalled(); + expect(recorder.state).toBe("inactive"); + }); + + it("clears webcam chunks and stops webcam on cancel", () => { + const chunks = { current: [new Blob(["data"])] }; + const webcamChunks = { current: [new Blob(["cam"])] }; + const webcam = createMockMediaRecorder("recording"); + + cancelRecording(recorder, false, chunks, webcam, webcamChunks); + + expect(webcamChunks.current).toEqual([]); + expect(webcam.stop).toHaveBeenCalled(); + expect(webcam.state).toBe("inactive"); + }); + + it("stops webcam when cancelling native recording", () => { + const chunks = { current: [] as Blob[] }; + const webcam = createMockMediaRecorder("recording"); + + const result = cancelRecording(recorder, true, chunks, webcam); + + expect(result.wasNative).toBe(true); + expect(webcam.stop).toHaveBeenCalled(); + expect(recorder.stop).not.toHaveBeenCalled(); + }); + + it("handles cancel when recorder is already inactive", () => { + const inactiveRecorder = createMockMediaRecorder("inactive"); + const chunks = { current: [new Blob(["data"])] }; + + const result = cancelRecording(inactiveRecorder, false, chunks); + + expect(result.cancelled).toBe(true); + expect(chunks.current).toEqual([]); + expect(inactiveRecorder.stop).not.toHaveBeenCalled(); + }); + + it("handles cancel when webcam is already inactive", () => { + const chunks = { current: [] as Blob[] }; + const webcam = createMockMediaRecorder("inactive"); + + cancelRecording(recorder, false, chunks, webcam); + + expect(webcam.stop).not.toHaveBeenCalled(); + }); + }); + + describe("pause → stop → editor flow", () => { + it("record → pause → stop completes cleanly", () => { + expect(recorder.state).toBe("recording"); + + pauseRecording(recorder, true, false, false); + expect(recorder.state).toBe("paused"); + + const result = stopRecording(recorder, false); + expect(result.stopped).toBe(true); + expect(recorder.state).toBe("inactive"); + }); + + it("record → pause → resume → stop completes cleanly", () => { + expect(recorder.state).toBe("recording"); + + pauseRecording(recorder, true, false, false); + expect(recorder.state).toBe("paused"); + + resumeRecording(recorder, true, true, false); + expect(recorder.state).toBe("recording"); + + const result = stopRecording(recorder, false); + expect(result.stopped).toBe(true); + expect(recorder.state).toBe("inactive"); + }); + + it("webcam stays in sync through full pause/resume/stop cycle", () => { + const webcam = createMockMediaRecorder("recording"); + + pauseRecording(recorder, true, false, false, webcam); + expect(recorder.state).toBe("paused"); + expect(webcam.state).toBe("paused"); + + resumeRecording(recorder, true, true, false, webcam); + expect(recorder.state).toBe("recording"); + expect(webcam.state).toBe("recording"); + + stopRecording(recorder, false, webcam); + expect(recorder.state).toBe("inactive"); + expect(webcam.state).toBe("inactive"); + }); + + it("native recording pauses webcam only after native pause succeeds", async () => { + const webcam = createMockMediaRecorder("recording"); + const micFallback = createMockMediaRecorder("recording"); + + const pausedResult = await pauseNativeRecording(webcam, { success: true }, micFallback); + expect(pausedResult).toBe(true); + expect(webcam.state).toBe("paused"); + expect(micFallback.requestData).toHaveBeenCalled(); + expect(micFallback.state).toBe("paused"); + expect(recorder.pause).not.toHaveBeenCalled(); + + const resumedResult = await resumeNativeRecording( + webcam, + { success: true }, + micFallback, + ); + expect(resumedResult).toBe(true); + expect(webcam.state).toBe("recording"); + expect(micFallback.state).toBe("recording"); + expect(recorder.resume).not.toHaveBeenCalled(); + }); + + it("native recording leaves webcam state alone when native pause fails", async () => { + const webcam = createMockMediaRecorder("recording"); + const micFallback = createMockMediaRecorder("recording"); + + const pausedResult = await pauseNativeRecording( + webcam, + { success: false }, + micFallback, + ); + + expect(pausedResult).toBe(false); + expect(webcam.state).toBe("recording"); + expect(webcam.pause).not.toHaveBeenCalled(); + expect(micFallback.state).toBe("recording"); + expect(micFallback.pause).not.toHaveBeenCalled(); + }); + + it("stops native capture before awaiting webcam finalization", async () => { + const callOrder: string[] = []; + let resolveWebcam: (path: string | null) => void = () => {}; + const webcamPathPromise = new Promise<string | null>((resolve) => { + resolveWebcam = resolve; + }); + const stopWebcamRecorder = vi.fn(() => { + callOrder.push("stop-webcam-started"); + return webcamPathPromise; + }); + const stopNativeScreenRecording = vi.fn(async () => { + callOrder.push("stop-native"); + return { success: true, path: "screen.mp4" }; + }); + const markRecordingResumed = vi.fn((timestampMs: number) => { + callOrder.push(`mark-resumed-${timestampMs}`); + }); + const getRecordingDurationMs = vi.fn((timestampMs: number) => { + callOrder.push(`duration-${timestampMs}`); + return 35000; + }); + + let finalized = false; + const stopped = stopNativeRecordingWithCompanions({ + getRecordingDurationMs, + markRecordingResumed, + now: () => 123456, + stopMicFallbackRecorder: vi.fn(async () => null), + stopNativeScreenRecording, + stopWebcamRecorder, + }).then((result) => { + finalized = true; + return result; + }); + + await Promise.resolve(); + expect(callOrder).toEqual([ + "mark-resumed-123456", + "duration-123456", + "stop-webcam-started", + "stop-native", + ]); + expect(finalized).toBe(false); + + resolveWebcam("webcam.webm"); + await expect(stopped).resolves.toMatchObject({ + expectedDurationMs: 35000, + webcamPath: "webcam.webm", + }); + }); + + it("cancel discards both screen and webcam recordings", () => { + const webcam = createMockMediaRecorder("recording"); + const chunks = { current: [new Blob(["screen"])] }; + const webcamChunks = { current: [new Blob(["cam"])] }; + + cancelRecording(recorder, false, chunks, webcam, webcamChunks); + + expect(chunks.current).toEqual([]); + expect(webcamChunks.current).toEqual([]); + expect(recorder.state).toBe("inactive"); + expect(webcam.state).toBe("inactive"); + }); + }); +}); diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index 636f31dd8..459652415 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -1,132 +1,2450 @@ -import { useState, useRef, useEffect } from "react"; import { fixWebmDuration } from "@fix-webm-duration/fix"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { toast } from "sonner"; +import { getEffectiveRecordingDurationMs } from "@/lib/mediaTiming"; +import { + getVideoExtensionForMimeType, + isWebmMimeType, + selectRecordingMimeType, + selectWebcamRecordingMimeType, +} from "./recordingMimeType"; + +const TARGET_FRAME_RATE = 60; +const TARGET_WIDTH = 3840; +const TARGET_HEIGHT = 2160; +const FOUR_K_PIXELS = TARGET_WIDTH * TARGET_HEIGHT; +const QHD_WIDTH = 2560; +const QHD_HEIGHT = 1440; +const QHD_PIXELS = QHD_WIDTH * QHD_HEIGHT; +const BITRATE_4K = 45_000_000; +const BITRATE_QHD = 28_000_000; +const BITRATE_BASE = 18_000_000; +const HIGH_FRAME_RATE_THRESHOLD = 60; +const HIGH_FRAME_RATE_BOOST = 1.7; +const DEFAULT_WIDTH = 1920; +const DEFAULT_HEIGHT = 1080; +const CODEC_ALIGNMENT = 2; +const RECORDER_TIMESLICE_MS = 250; +const BITS_PER_MEGABIT = 1_000_000; +const MIN_FRAME_RATE = 30; +const CHROME_MEDIA_SOURCE = "desktop"; +const RECORDING_FILE_PREFIX = "recording-"; +const AUDIO_BITRATE_VOICE = 128_000; +const AUDIO_BITRATE_SYSTEM = 192_000; +const MIC_GAIN_BOOST = 1.4; +const WEBCAM_BITRATE = 8_000_000; +const WEBCAM_WIDTH = 1280; +const WEBCAM_HEIGHT = 720; +const WEBCAM_FRAME_RATE = 30; +const WEBCAM_SUFFIX = "-webcam"; +const MICROPHONE_FALLBACK_ERROR_TOAST_ID = "recording-microphone-fallback-error"; +const MICROPHONE_SIDECAR_ERROR_TOAST_ID = "recording-microphone-sidecar-error"; +export type BrowserMicrophoneProfile = + | "processed" + | "no-agc" + | "no-echo" + | "no-noise-suppression" + | "raw"; +type BrowserCaptureCursorMode = "always" | "never"; +export type BrowserCaptureCursorPolicy = { + streamCursor: BrowserCaptureCursorMode; + hideOsCursorBeforeRecording: boolean; + hideEditorOverlayCursorByDefault: boolean; +}; +const DEFAULT_BROWSER_MICROPHONE_PROFILE: BrowserMicrophoneProfile = "processed"; +const BROWSER_MICROPHONE_PROFILES = new Set<BrowserMicrophoneProfile>([ + "processed", + "no-agc", + "no-echo", + "no-noise-suppression", + "raw", +]); +type MicrophoneTrackSettingsSnapshot = Partial< + Pick< + MediaTrackSettings, + | "autoGainControl" + | "channelCount" + | "deviceId" + | "echoCancellation" + | "groupId" + | "noiseSuppression" + | "sampleRate" + | "sampleSize" + > +> & { + trackId?: string; + trackLabel?: string; + trackEnabled?: boolean; + trackMuted?: boolean; + trackReadyState?: MediaStreamTrackState; +}; +type MicrophoneAudioInputDeviceSnapshot = { + deviceId: string; + groupId?: string; + label: string; +}; +type MicrophoneFallbackChunkEvent = { + index: number; + size: number; + elapsedMs: number; + deltaMs: number | null; + recordedElapsedMs: number; + recordedDeltaMs: number | null; +}; +type MicrophoneFallbackPauseInterval = { + startElapsedMs: number; + endElapsedMs?: number; + durationMs?: number; +}; +type MicrophoneFallbackRecorderMetadata = { + mimeType: string; + audioBitsPerSecond: number; + timesliceMs: number; +}; +type MicrophoneSidecarOptions = { + startDelayMs?: number; + browserMicrophoneProfile?: BrowserMicrophoneProfile; + requestedBrowserMicrophoneProfile?: string | null; + requestedConstraints?: MediaStreamConstraints; + mediaTrackSettings?: MicrophoneTrackSettingsSnapshot; + audioInputDevices?: MicrophoneAudioInputDeviceSnapshot[]; + mediaRecorder?: MicrophoneFallbackRecorderMetadata; + chunkEvents?: MicrophoneFallbackChunkEvent[]; + pauseIntervals?: MicrophoneFallbackPauseInterval[]; +}; +const LINUX_PORTAL_SOURCE: ProcessedDesktopSource = { + id: "screen:linux-portal", + name: "Linux Portal", + display_id: "", + thumbnail: null, + appIcon: null, + sourceType: "screen", +}; + +type DesktopCaptureMediaDevices = { + getUserMedia: (constraints: unknown) => Promise<MediaStream>; + getDisplayMedia: (constraints: unknown) => Promise<MediaStream>; +}; type UseScreenRecorderReturn = { - recording: boolean; - toggleRecording: () => void; + recording: boolean; + paused: boolean; + finalizing: boolean; + countdownActive: boolean; + toggleRecording: () => void; + pauseRecording: () => void; + resumeRecording: () => void; + cancelRecording: () => void; + preparePermissions: (options?: { startup?: boolean }) => Promise<boolean>; + isMacOS: boolean; + microphoneEnabled: boolean; + setMicrophoneEnabled: (enabled: boolean) => void; + microphoneDeviceId: string | undefined; + setMicrophoneDeviceId: (deviceId: string | undefined) => void; + systemAudioEnabled: boolean; + setSystemAudioEnabled: (enabled: boolean) => void; + webcamEnabled: boolean; + setWebcamEnabled: (enabled: boolean) => void; + webcamDeviceId: string | undefined; + setWebcamDeviceId: (deviceId: string | undefined) => void; + countdownDelay: number; + setCountdownDelay: (delay: number) => void; }; +function getErrorMessage(error: unknown) { + if (error instanceof Error && error.message) { + return error.message; + } + + if (typeof error === "string" && error.trim().length > 0) { + return error; + } + + if (typeof error === "object" && error !== null) { + try { + const serialized = JSON.stringify(error); + if (serialized && serialized !== "{}") { + return serialized; + } + } catch { + // Ignore stringify failures and fall through to a generic message. + } + + if (typeof (error as { toString?: () => string }).toString === "function") { + const stringified = (error as { toString: () => string }).toString(); + if (stringified && stringified !== "[object Object]") { + return stringified; + } + } + } + + return "An unexpected error occurred"; +} + +export function normalizeBrowserMicrophoneProfile(value?: string | null): BrowserMicrophoneProfile { + const normalized = value?.trim().toLowerCase(); + return normalized && BROWSER_MICROPHONE_PROFILES.has(normalized as BrowserMicrophoneProfile) + ? (normalized as BrowserMicrophoneProfile) + : DEFAULT_BROWSER_MICROPHONE_PROFILE; +} + +export function resolveBrowserCaptureCursorPolicy({ + nativeWindowsCaptureStartFailed = false, +}: { + nativeWindowsCaptureStartFailed?: boolean; +} = {}): BrowserCaptureCursorPolicy { + if (nativeWindowsCaptureStartFailed) { + // If WGC already failed, avoid the telemetry overlay path that can lag on + // constrained Windows systems; keep the browser-captured cursor instead. + return { + streamCursor: "always", + hideOsCursorBeforeRecording: false, + hideEditorOverlayCursorByDefault: true, + }; + } + + return { + streamCursor: "never", + hideOsCursorBeforeRecording: true, + hideEditorOverlayCursorByDefault: true, + }; +} + +export function shouldUseNativeWindowsCaptureForSource( + source: Pick<ProcessedDesktopSource, "id"> | null | undefined, +): boolean { + return source?.id?.startsWith("screen:") === true || source?.id?.startsWith("window:") === true; +} + +export function createProcessedMicrophoneConstraints( + microphoneDeviceId?: string, + profile: BrowserMicrophoneProfile = DEFAULT_BROWSER_MICROPHONE_PROFILE, +): MediaStreamConstraints { + const normalizedProfile = normalizeBrowserMicrophoneProfile(profile); + const audio: MediaTrackConstraints = { + echoCancellation: normalizedProfile !== "no-echo" && normalizedProfile !== "raw", + noiseSuppression: + normalizedProfile !== "no-noise-suppression" && normalizedProfile !== "raw", + autoGainControl: normalizedProfile !== "no-agc" && normalizedProfile !== "raw", + channelCount: { ideal: 1 }, + sampleRate: { ideal: 48000 }, + }; + + if (microphoneDeviceId) { + audio.deviceId = { exact: microphoneDeviceId }; + } + + return { audio, video: false }; +} + +export function createBrowserRecordingOptions({ + audioBitsPerSecond, + mimeType, + videoBitsPerSecond, +}: { + audioBitsPerSecond?: number; + mimeType?: string; + videoBitsPerSecond: number; +}): MediaRecorderOptions { + const options: MediaRecorderOptions = { + videoBitsPerSecond, + bitsPerSecond: videoBitsPerSecond + (audioBitsPerSecond ?? 0), + }; + + if (audioBitsPerSecond !== undefined) { + options.audioBitsPerSecond = audioBitsPerSecond; + } + + if (mimeType) { + options.mimeType = mimeType; + } + + return options; +} + +type NativeCaptureStopResult = { + success: boolean; + path?: string; + error?: string; + message?: string; +}; + +export type DiscardNativeCaptureResult = { + stopSucceeded: boolean; + deleteSucceeded: boolean; + path?: string; + error?: unknown; +}; + +export async function stopAndDiscardNativeCapture({ + stopNativeScreenRecording, + deleteRecordingFile, +}: { + stopNativeScreenRecording: () => Promise<NativeCaptureStopResult>; + deleteRecordingFile: (path: string) => Promise<unknown>; +}): Promise<DiscardNativeCaptureResult> { + let stoppedResult: NativeCaptureStopResult; + try { + stoppedResult = await stopNativeScreenRecording(); + } catch (error) { + return { stopSucceeded: false, deleteSucceeded: false, error }; + } + + if (!stoppedResult.success) { + return { + stopSucceeded: false, + deleteSucceeded: false, + error: stoppedResult.error ?? stoppedResult.message, + }; + } + + if (!stoppedResult.path) { + return { stopSucceeded: true, deleteSucceeded: true }; + } + + try { + await deleteRecordingFile(stoppedResult.path); + return { + stopSucceeded: true, + deleteSucceeded: true, + path: stoppedResult.path, + }; + } catch (error) { + return { + stopSucceeded: true, + deleteSucceeded: false, + path: stoppedResult.path, + error, + }; + } +} + +function createMicrophoneTrackSettingsSnapshot( + stream: MediaStream, +): MicrophoneTrackSettingsSnapshot | null { + const track = stream.getAudioTracks()[0]; + const settings = track?.getSettings?.(); + if (!track || !settings) { + return null; + } + + const snapshot: MicrophoneTrackSettingsSnapshot = { + trackId: track.id, + trackLabel: track.label, + trackEnabled: track.enabled, + trackMuted: track.muted, + trackReadyState: track.readyState, + }; + for (const key of [ + "autoGainControl", + "channelCount", + "deviceId", + "echoCancellation", + "groupId", + "noiseSuppression", + "sampleRate", + "sampleSize", + ] as const) { + const value = settings[key]; + if (value !== undefined) { + snapshot[key] = value as never; + } + } + + return Object.keys(snapshot).length > 0 ? snapshot : null; +} + +async function createAudioInputDeviceSnapshot(): Promise< + MicrophoneAudioInputDeviceSnapshot[] | null +> { + if (typeof navigator.mediaDevices?.enumerateDevices !== "function") { + return null; + } + + const devices = await navigator.mediaDevices.enumerateDevices(); + const audioInputs = devices + .filter((device) => device.kind === "audioinput") + .map((device) => ({ + deviceId: device.deviceId, + ...(device.groupId ? { groupId: device.groupId } : {}), + label: device.label, + })); + + return audioInputs.length > 0 ? audioInputs : null; +} + export function useScreenRecorder(): UseScreenRecorderReturn { - const [recording, setRecording] = useState(false); - const mediaRecorder = useRef<MediaRecorder | null>(null); - const stream = useRef<MediaStream | null>(null); - const chunks = useRef<Blob[]>([]); - const startTime = useRef<number>(0); - - const stopRecording = useRef(() => { - if (mediaRecorder.current?.state === "recording") { - if (stream.current) { - stream.current.getTracks().forEach(track => track.stop()); - } - mediaRecorder.current.stop(); - setRecording(false); - window.electronAPI.stopMouseTracking(); - window.electronAPI?.setRecordingState(false); - } - }); - - useEffect(() => { - let cleanup: (() => void) | undefined; - - if (window.electronAPI?.onStopRecordingFromTray) { - cleanup = window.electronAPI.onStopRecordingFromTray(() => { - stopRecording.current(); - }); - } - - return () => { - if (cleanup) cleanup(); - - if (mediaRecorder.current?.state === "recording") { - mediaRecorder.current.stop(); - } - if (stream.current) { - stream.current.getTracks().forEach(track => track.stop()); - stream.current = null; - } - }; - }, []); - - const startRecording = async () => { - try { - const selectedSource = await window.electronAPI.getSelectedSource(); - if (!selectedSource) { - alert("Please select a source to record"); - return; - } - await window.electronAPI.startMouseTracking(); - const mediaStream = await (navigator.mediaDevices as any).getUserMedia({ - audio: false, - video: { - mandatory: { - chromeMediaSource: "desktop", - chromeMediaSourceId: selectedSource.id, - }, - }, - }); - stream.current = mediaStream; - if (!stream.current) { - throw new Error("Media stream is not available."); - } - const videoTrack = stream.current.getVideoTracks()[0]; - const { width = 1920, height = 1080 } = videoTrack.getSettings(); - const totalPixels = width * height; - let bitrate = 150_000_000; - if (totalPixels > 1920 * 1080 && totalPixels <= 2560 * 1440) { - bitrate = 250_000_000; - } else if (totalPixels > 2560 * 1440) { - bitrate = 400_000_000; - } - chunks.current = []; - const mimeType = "video/webm;codecs=vp9"; - const recorder = new MediaRecorder(stream.current, { mimeType, videoBitsPerSecond: bitrate }); - mediaRecorder.current = recorder; - recorder.ondataavailable = e => { - if (e.data && e.data.size > 0) chunks.current.push(e.data); - }; - recorder.onstop = async () => { - stream.current = null; - if (chunks.current.length === 0) return; - const duration = Date.now() - startTime.current; - const buggyBlob = new Blob(chunks.current, { type: mimeType }); - const timestamp = Date.now(); - const videoFileName = `recording-${timestamp}.webm`; - const trackingFileName = `recording-${timestamp}_tracking.json`; - try { - const videoBlob = await fixWebmDuration(buggyBlob, duration); - const arrayBuffer = await videoBlob.arrayBuffer(); - const videoResult = await window.electronAPI.storeRecordedVideo(arrayBuffer, videoFileName); - if (!videoResult.success) { - console.error('Failed to store video:', videoResult.message); - return; - } - const trackingResult = await window.electronAPI.storeMouseTrackingData(trackingFileName); - if (!trackingResult.success) { - console.warn('Failed to store mouse tracking:', trackingResult.message); - } - await window.electronAPI.switchToEditor(); - } catch (error) { - console.error('Error saving recording:', error); - } - }; - recorder.onerror = () => setRecording(false); - recorder.start(1000); - startTime.current = Date.now(); - setRecording(true); - window.electronAPI?.setRecordingState(true); - } catch (error) { - console.error('Failed to start recording:', error); - setRecording(false); - if (stream.current) { - stream.current.getTracks().forEach(track => track.stop()); - stream.current = null; - } - } - }; - - const toggleRecording = () => { - recording ? stopRecording.current() : startRecording(); - }; - - return { recording, toggleRecording }; + const [recording, setRecording] = useState(false); + const [paused, setPaused] = useState(false); + const [starting, setStarting] = useState(false); + const [finalizing, setFinalizing] = useState(false); + const [countdownActive, setCountdownActive] = useState(false); + const [isMacOS, setIsMacOS] = useState(false); + const [microphoneEnabled, setMicrophoneEnabled] = useState(false); + const [microphoneDeviceId, setMicrophoneDeviceId] = useState<string | undefined>(undefined); + const [systemAudioEnabled, setSystemAudioEnabled] = useState(false); + const [webcamEnabled, setWebcamEnabled] = useState(false); + const [webcamDeviceId, setWebcamDeviceId] = useState<string | undefined>(undefined); + const [countdownDelay, setCountdownDelayState] = useState(3); + const mediaRecorder = useRef<MediaRecorder | null>(null); + const webcamRecorder = useRef<MediaRecorder | null>(null); + const stream = useRef<MediaStream | null>(null); + const screenStream = useRef<MediaStream | null>(null); + const microphoneStream = useRef<MediaStream | null>(null); + const webcamStream = useRef<MediaStream | null>(null); + const mixingContext = useRef<AudioContext | null>(null); + const chunks = useRef<Blob[]>([]); + const webcamChunks = useRef<Blob[]>([]); + const startTime = useRef<number>(0); + const webcamStartTime = useRef<number | null>(null); + const webcamTimeOffsetMs = useRef(0); + const recordingSessionTimestamp = useRef<number | null>(null); + const nativeScreenRecording = useRef(false); + const nativeWindowsRecording = useRef(false); + const nativeWarmStartActive = useRef(false); + const pendingNativeCleanupPath = useRef<string | null>(null); + const recordingStartGeneration = useRef(0); + const nativeStopRequestInFlight = useRef(false); + const startInFlight = useRef(false); + const hasPromptedForReselect = useRef(false); + const hasShownNativeWindowsFallbackToast = useRef(false); + const countdownDelayLoaded = useRef(false); + const recordingPrefsLoaded = useRef(false); + const pendingWebcamPathPromise = useRef<Promise<string | null> | null>(null); + const webcamStopPromise = useRef<Promise<string | null> | null>(null); + const webcamStopResolver = useRef<((path: string | null) => void) | null>(null); + const resolvedWebcamPath = useRef<string | null>(null); + const accumulatedPausedDurationMs = useRef(0); + const pauseStartedAtMs = useRef<number | null>(null); + const micFallbackRecorder = useRef<MediaRecorder | null>(null); + const micFallbackChunks = useRef<Blob[]>([]); + const micFallbackStartDelayMs = useRef<number | null>(null); + const micFallbackTrackSettings = useRef<MicrophoneTrackSettingsSnapshot | null>(null); + const micFallbackRequestedConstraints = useRef<MediaStreamConstraints | null>(null); + const micFallbackAudioInputDevices = useRef<MicrophoneAudioInputDeviceSnapshot[] | null>(null); + const micFallbackRecorderMetadata = useRef<MicrophoneFallbackRecorderMetadata | null>(null); + const micFallbackChunkEvents = useRef<MicrophoneFallbackChunkEvent[]>([]); + const micFallbackRecorderStartedAt = useRef<number | null>(null); + const micFallbackPauseStartedAt = useRef<number | null>(null); + const micFallbackPausedDurationMs = useRef(0); + const micFallbackPauseIntervals = useRef<MicrophoneFallbackPauseInterval[]>([]); + const browserMicrophoneProfile = useRef<BrowserMicrophoneProfile>( + DEFAULT_BROWSER_MICROPHONE_PROFILE, + ); + const requestedBrowserMicrophoneProfile = useRef<string | null>(null); + const hideEditorOverlayCursorByDefault = useRef(false); + + const notifyRecordingFinalizationFailure = useCallback(async (message: string) => { + setFinalizing(false); + toast.error(message, { duration: 10000 }); + }, []); + + const logNativeCaptureDiagnostics = useCallback(async (context: string) => { + if (typeof window.electronAPI?.getLastNativeCaptureDiagnostics !== "function") { + return; + } + + try { + const result = await window.electronAPI.getLastNativeCaptureDiagnostics(); + if (result.success && result.diagnostics) { + console.warn(`[NativeCaptureDiagnostics:${context}]`, result.diagnostics); + } + } catch (error) { + console.warn("Failed to load native capture diagnostics:", error); + } + }, []); + + const buildNativeCaptureFailureMessage = useCallback( + async (context: string, fallbackMessage: string) => { + if (typeof window.electronAPI?.getLastNativeCaptureDiagnostics !== "function") { + return fallbackMessage; + } + + try { + const result = await window.electronAPI.getLastNativeCaptureDiagnostics(); + const diagnostics = result.success ? (result.diagnostics ?? null) : null; + if (!diagnostics) { + return fallbackMessage; + } + + console.warn(`[NativeCaptureDiagnostics:${context}]`, diagnostics); + + const details: string[] = []; + if (diagnostics.error) { + details.push(diagnostics.error); + } + if (diagnostics.outputPath && (diagnostics.fileSizeBytes ?? 0) > 0) { + details.push(`Saved file: ${diagnostics.outputPath}`); + } + + return details.length > 0 + ? `${fallbackMessage} ${details.join(". ")}` + : fallbackMessage; + } catch (error) { + console.warn("Failed to load native capture diagnostics:", error); + return fallbackMessage; + } + }, + [], + ); + + const resetRecordingClock = useCallback((startedAt: number) => { + startTime.current = startedAt; + accumulatedPausedDurationMs.current = 0; + pauseStartedAtMs.current = null; + }, []); + + const markRecordingPaused = useCallback((pausedAt: number) => { + if (pauseStartedAtMs.current === null) { + pauseStartedAtMs.current = pausedAt; + } + }, []); + + const markRecordingResumed = useCallback((resumedAt: number) => { + if (pauseStartedAtMs.current === null) { + return; + } + + const pauseStart = pauseStartedAtMs.current; + const pauseDurationMs = Math.max(0, resumedAt - pauseStart); + accumulatedPausedDurationMs.current += pauseDurationMs; + pauseStartedAtMs.current = null; + }, []); + + const getRecordingDurationMs = useCallback((endedAt: number) => { + return getEffectiveRecordingDurationMs({ + startTimeMs: startTime.current, + endTimeMs: endedAt, + accumulatedPausedDurationMs: accumulatedPausedDurationMs.current, + pauseStartedAtMs: pauseStartedAtMs.current, + }); + }, []); + + const getMicFallbackRecordedElapsedMs = useCallback((now = performance.now()) => { + const startedAt = micFallbackRecorderStartedAt.current; + if (startedAt === null) { + return 0; + } + + const currentPauseDurationMs = + micFallbackPauseStartedAt.current === null + ? 0 + : Math.max(0, now - micFallbackPauseStartedAt.current); + return Math.max( + 0, + Math.round( + now - startedAt - micFallbackPausedDurationMs.current - currentPauseDurationMs, + ), + ); + }, []); + + const resetMicFallbackTimingDiagnostics = useCallback(() => { + micFallbackChunkEvents.current = []; + micFallbackRecorderStartedAt.current = null; + micFallbackPauseStartedAt.current = null; + micFallbackPausedDurationMs.current = 0; + micFallbackPauseIntervals.current = []; + }, []); + + const preparePermissions = useCallback(async (options: { startup?: boolean } = {}) => { + const platform = await window.electronAPI.getPlatform(); + if (platform !== "darwin") { + return true; + } + + const screenPermission = await window.electronAPI.getScreenRecordingPermissionStatus(); + if (!screenPermission.success || screenPermission.status !== "granted") { + await window.electronAPI.openScreenRecordingPreferences(); + alert( + options.startup + ? "Recordly needs Screen Recording permission before you start. System Settings has been opened. After enabling it, quit and reopen Recordly." + : "Screen Recording permission is still missing. System Settings has been opened again. Enable it, then quit and reopen Recordly before recording.", + ); + return false; + } + + const accessibilityPermission = await window.electronAPI.getAccessibilityPermissionStatus(); + if (!accessibilityPermission.success) { + return false; + } + + if (accessibilityPermission.trusted) { + return true; + } + + const requestedAccessibility = await window.electronAPI.requestAccessibilityPermission(); + if (requestedAccessibility.success && requestedAccessibility.trusted) { + return true; + } + + await window.electronAPI.openAccessibilityPreferences(); + alert( + options.startup + ? "Recordly also needs Accessibility permission for cursor tracking. System Settings has been opened. After enabling it, quit and reopen Recordly." + : "Accessibility permission is still missing. System Settings has been opened again. Enable it, then quit and reopen Recordly before recording.", + ); + + return false; + }, []); + + const selectMimeType = useCallback(() => { + return selectRecordingMimeType(); + }, []); + + const selectWebcamMimeType = useCallback(() => { + return selectWebcamRecordingMimeType(); + }, []); + + const computeBitrate = (width: number, height: number) => { + const pixels = width * height; + const highFrameRateBoost = + TARGET_FRAME_RATE >= HIGH_FRAME_RATE_THRESHOLD ? HIGH_FRAME_RATE_BOOST : 1; + + if (pixels >= FOUR_K_PIXELS) { + return Math.round(BITRATE_4K * highFrameRateBoost); + } + + if (pixels >= QHD_PIXELS) { + return Math.round(BITRATE_QHD * highFrameRateBoost); + } + + return Math.round(BITRATE_BASE * highFrameRateBoost); + }; + + const cleanupCapturedMedia = useCallback(() => { + if (stream.current) { + stream.current.getTracks().forEach((track) => track.stop()); + stream.current = null; + } + + if (screenStream.current) { + screenStream.current.getTracks().forEach((track) => track.stop()); + screenStream.current = null; + } + + if (microphoneStream.current) { + microphoneStream.current.getTracks().forEach((track) => track.stop()); + microphoneStream.current = null; + } + + if (webcamStream.current) { + webcamStream.current.getTracks().forEach((track) => track.stop()); + webcamStream.current = null; + } + + if (mixingContext.current) { + mixingContext.current.close().catch(() => undefined); + mixingContext.current = null; + } + + if (micFallbackRecorder.current) { + try { + if (micFallbackRecorder.current.state !== "inactive") { + micFallbackRecorder.current.stop(); + } + micFallbackRecorder.current.stream?.getTracks().forEach((track) => track.stop()); + } catch { + /* ignore */ + } + micFallbackRecorder.current = null; + micFallbackChunks.current = []; + micFallbackTrackSettings.current = null; + micFallbackRequestedConstraints.current = null; + micFallbackAudioInputDevices.current = null; + micFallbackRecorderMetadata.current = null; + resetMicFallbackTimingDiagnostics(); + } + }, [resetMicFallbackTimingDiagnostics]); + + const appendMicFallbackChunk = useCallback( + (event: BlobEvent) => { + if (event.data.size <= 0) { + return; + } + + micFallbackChunks.current.push(event.data); + const startedAt = micFallbackRecorderStartedAt.current; + if (startedAt === null) { + return; + } + + const now = performance.now(); + const elapsedMs = Math.max(0, Math.round(now - startedAt)); + const recordedElapsedMs = getMicFallbackRecordedElapsedMs(now); + const previous = + micFallbackChunkEvents.current[micFallbackChunkEvents.current.length - 1]; + micFallbackChunkEvents.current.push({ + index: micFallbackChunkEvents.current.length, + size: event.data.size, + elapsedMs, + deltaMs: previous ? Math.max(0, elapsedMs - previous.elapsedMs) : null, + recordedElapsedMs, + recordedDeltaMs: previous + ? Math.max(0, recordedElapsedMs - previous.recordedElapsedMs) + : null, + }); + }, + [getMicFallbackRecordedElapsedMs], + ); + + const resolveBrowserCaptureSource = useCallback(async (source: ProcessedDesktopSource) => { + if (!source?.id?.startsWith("screen:")) { + return source; + } + + // Linux/Wayland portal sentinel: do NOT call getSources here, because + // on Wayland that triggers an additional xdg-desktop-portal dialog. + // The sentinel is handled later by routing through getDisplayMedia, + // which lets the portal pick the source in a single dialog. + if (source.id === "screen:linux-portal") { + return source; + } + + try { + const liveSources = await window.electronAPI.getSources({ + types: ["screen"], + thumbnailSize: { width: 1, height: 1 }, + fetchWindowIcons: false, + }); + + const exactMatch = liveSources.find((candidate) => candidate.id === source.id); + if (exactMatch) { + return { + ...source, + id: exactMatch.id, + name: exactMatch.name ?? source.name, + display_id: exactMatch.display_id ?? source.display_id, + }; + } + + const displayMatch = liveSources.find( + (candidate) => + String(candidate.display_id ?? "") === String(source.display_id ?? ""), + ); + if (displayMatch) { + return { + ...source, + id: displayMatch.id, + name: displayMatch.name ?? source.name, + display_id: displayMatch.display_id ?? source.display_id, + }; + } + } catch (error) { + console.warn("Failed to resolve browser capture source:", error); + } + + return source; + }, []); + + const finalizeRecordingSession = useCallback( + async (videoPath: string, webcamPath: string | null) => { + const start = performance.now(); + console.log("[PERF:RENDERER] Finalize Session & Switch to Editor: STARTED"); + const shouldHideOverlayCursor = hideEditorOverlayCursorByDefault.current; + try { + if (webcamPath) { + await window.electronAPI.setCurrentRecordingSession({ + videoPath, + webcamPath, + timeOffsetMs: webcamTimeOffsetMs.current, + hideOverlayCursorByDefault: shouldHideOverlayCursor, + }); + } else { + await window.electronAPI.setCurrentVideoPath(videoPath, { + hideOverlayCursorByDefault: shouldHideOverlayCursor, + }); + } + } catch (error) { + console.error("Failed to persist recording session metadata:", error); + + try { + await window.electronAPI.setCurrentVideoPath(videoPath, { + hideOverlayCursorByDefault: shouldHideOverlayCursor, + }); + } catch (fallbackError) { + console.error("Failed to persist fallback video path:", fallbackError); + } + } + + setFinalizing(false); + await window.electronAPI.switchToEditor(); + console.log( + `[PERF:RENDERER] Finalize Session & Switch to Editor: COMPLETED in ${(performance.now() - start).toFixed(2)}ms`, + ); + }, + [], + ); + + const closeMicFallbackPauseInterval = useCallback((now = performance.now()) => { + const pauseStartedAt = micFallbackPauseStartedAt.current; + if (pauseStartedAt === null) { + return; + } + + const durationMs = Math.max(0, Math.round(now - pauseStartedAt)); + micFallbackPausedDurationMs.current += durationMs; + const startedAt = micFallbackRecorderStartedAt.current ?? now; + const lastInterval = + micFallbackPauseIntervals.current[micFallbackPauseIntervals.current.length - 1]; + if (lastInterval && lastInterval.endElapsedMs === undefined) { + lastInterval.endElapsedMs = Math.max( + lastInterval.startElapsedMs, + Math.round(now - startedAt), + ); + lastInterval.durationMs = durationMs; + } + micFallbackPauseStartedAt.current = null; + }, []); + + const stopMicFallbackRecorder = useCallback((): Promise<Blob | null> => { + return new Promise((resolve) => { + const recorder = micFallbackRecorder.current; + if (!recorder || recorder.state === "inactive") { + micFallbackRecorder.current = null; + resolve(null); + return; + } + closeMicFallbackPauseInterval(); + recorder.ondataavailable = appendMicFallbackChunk; + recorder.onstop = () => { + const blob = + micFallbackChunks.current.length > 0 + ? new Blob(micFallbackChunks.current, { type: recorder.mimeType }) + : null; + micFallbackChunks.current = []; + recorder.stream.getTracks().forEach((track) => track.stop()); + micFallbackRecorder.current = null; + micFallbackRecorderStartedAt.current = null; + resolve(blob); + }; + recorder.stop(); + }); + }, [appendMicFallbackChunk, closeMicFallbackPauseInterval]); + + const pauseMicFallbackRecorder = useCallback(() => { + const recorder = micFallbackRecorder.current; + if (recorder?.state !== "recording") { + return; + } + + try { + recorder.requestData(); + } catch (error) { + console.warn("Failed to flush microphone fallback chunk before pause:", error); + } + + recorder.pause(); + const now = performance.now(); + const startedAt = micFallbackRecorderStartedAt.current ?? now; + micFallbackPauseStartedAt.current = now; + micFallbackPauseIntervals.current.push({ + startElapsedMs: Math.max(0, Math.round(now - startedAt)), + }); + }, []); + + const resumeMicFallbackRecorder = useCallback(() => { + const recorder = micFallbackRecorder.current; + if (recorder?.state !== "paused") { + return; + } + + closeMicFallbackPauseInterval(); + recorder.resume(); + }, [closeMicFallbackPauseInterval]); + + const storeMicrophoneSidecar = useCallback( + async ( + micFallbackBlobPromise: Promise<Blob | null> | null | undefined, + finalPath: string, + startDelayMs?: number | null, + mediaTrackSettings?: MicrophoneTrackSettingsSnapshot | null, + ) => { + const micFallbackBlob = await micFallbackBlobPromise; + if (!micFallbackBlob) { + micFallbackStartDelayMs.current = null; + micFallbackTrackSettings.current = null; + micFallbackRequestedConstraints.current = null; + micFallbackAudioInputDevices.current = null; + micFallbackRecorderMetadata.current = null; + resetMicFallbackTimingDiagnostics(); + return; + } + + try { + const arrayBuffer = await micFallbackBlob.arrayBuffer(); + const effectiveStartDelayMs = startDelayMs ?? micFallbackStartDelayMs.current; + const effectiveTrackSettings = + mediaTrackSettings ?? micFallbackTrackSettings.current; + const sidecarOptions: MicrophoneSidecarOptions = { + ...(Number.isFinite(effectiveStartDelayMs) && (effectiveStartDelayMs ?? 0) >= 0 + ? { startDelayMs: effectiveStartDelayMs ?? 0 } + : {}), + browserMicrophoneProfile: browserMicrophoneProfile.current, + ...(requestedBrowserMicrophoneProfile.current + ? { + requestedBrowserMicrophoneProfile: + requestedBrowserMicrophoneProfile.current, + } + : {}), + ...(micFallbackRequestedConstraints.current + ? { requestedConstraints: micFallbackRequestedConstraints.current } + : {}), + ...(effectiveTrackSettings + ? { mediaTrackSettings: effectiveTrackSettings } + : {}), + ...(micFallbackAudioInputDevices.current + ? { audioInputDevices: micFallbackAudioInputDevices.current } + : {}), + ...(micFallbackRecorderMetadata.current + ? { mediaRecorder: micFallbackRecorderMetadata.current } + : {}), + ...(micFallbackChunkEvents.current.length > 0 + ? { chunkEvents: [...micFallbackChunkEvents.current] } + : {}), + ...(micFallbackPauseIntervals.current.length > 0 + ? { + pauseIntervals: micFallbackPauseIntervals.current.map( + (interval) => ({ ...interval }), + ), + } + : {}), + }; + const result = await window.electronAPI.storeMicrophoneSidecar( + arrayBuffer, + finalPath, + sidecarOptions, + ); + if (!result.success) { + const errorMessage = + result.error || "Failed to save the fallback microphone audio track"; + console.warn("Failed to store microphone sidecar:", errorMessage); + toast.error( + `${errorMessage}. Recording was saved without the fallback microphone track.`, + { id: MICROPHONE_SIDECAR_ERROR_TOAST_ID, duration: 10000 }, + ); + } + } catch (error) { + console.warn("Failed to store microphone sidecar:", error); + toast.error( + `${getErrorMessage(error)}. Recording was saved without the fallback microphone track.`, + { id: MICROPHONE_SIDECAR_ERROR_TOAST_ID, duration: 10000 }, + ); + } finally { + micFallbackStartDelayMs.current = null; + micFallbackTrackSettings.current = null; + micFallbackRequestedConstraints.current = null; + micFallbackAudioInputDevices.current = null; + micFallbackRecorderMetadata.current = null; + resetMicFallbackTimingDiagnostics(); + } + }, + [resetMicFallbackTimingDiagnostics], + ); + + const stopWebcamRecorder = useCallback(async () => { + const recorder = webcamRecorder.current; + const pending = webcamStopPromise.current; + + if (!recorder) { + const result = pending ? await pending : resolvedWebcamPath.current; + webcamStopPromise.current = null; + pendingWebcamPathPromise.current = null; + resolvedWebcamPath.current = result ?? null; + return result ?? null; + } + + if (recorder.state !== "inactive") { + recorder.stop(); + } else if (pending && webcamStopResolver.current) { + webcamStopResolver.current(resolvedWebcamPath.current); + webcamStopResolver.current = null; + } + + const result = pending ? await pending : resolvedWebcamPath.current; + webcamStopPromise.current = null; + pendingWebcamPathPromise.current = null; + resolvedWebcamPath.current = result ?? null; + webcamRecorder.current = null; + return result ?? null; + }, []); + + const recoverNativeRecordingSession = useCallback( + async ( + micFallbackBlobPromise?: Promise<Blob | null> | null, + startDelayMs?: number | null, + ) => { + if (typeof window.electronAPI?.recoverNativeScreenRecording !== "function") { + return null; + } + + const result = await window.electronAPI.recoverNativeScreenRecording(); + if (!result.success || !result.path) { + return null; + } + + const resolvedMicFallbackBlobPromise = + micFallbackBlobPromise ?? stopMicFallbackRecorder(); + const webcamPath = await stopWebcamRecorder(); + await storeMicrophoneSidecar(resolvedMicFallbackBlobPromise, result.path, startDelayMs); + await finalizeRecordingSession(result.path, webcamPath); + + if (typeof window.electronAPI?.hudOverlayClose === "function") { + window.electronAPI.hudOverlayClose(); + } + + return result.path; + }, + [ + finalizeRecordingSession, + stopMicFallbackRecorder, + stopWebcamRecorder, + storeMicrophoneSidecar, + ], + ); + + /** + * Acquire the webcam stream and prepare the MediaRecorder, but do NOT start + * recording yet. Call {@link beginWebcamCapture} after the main recording + * has started so both begin at approximately the same time. + */ + const prepareWebcamRecorder = useCallback(async () => { + if (!webcamEnabled) { + resolvedWebcamPath.current = null; + pendingWebcamPathPromise.current = Promise.resolve(null); + webcamStartTime.current = null; + webcamTimeOffsetMs.current = 0; + return; + } + + try { + webcamStream.current = await navigator.mediaDevices.getUserMedia({ + video: webcamDeviceId + ? { + deviceId: { exact: webcamDeviceId }, + width: { ideal: WEBCAM_WIDTH }, + height: { ideal: WEBCAM_HEIGHT }, + frameRate: { ideal: WEBCAM_FRAME_RATE, max: WEBCAM_FRAME_RATE }, + } + : { + width: { ideal: WEBCAM_WIDTH }, + height: { ideal: WEBCAM_HEIGHT }, + frameRate: { ideal: WEBCAM_FRAME_RATE, max: WEBCAM_FRAME_RATE }, + }, + audio: false, + }); + + const mimeType = selectWebcamMimeType(); + webcamChunks.current = []; + resolvedWebcamPath.current = null; + webcamStopPromise.current = new Promise((resolve) => { + webcamStopResolver.current = resolve; + }); + pendingWebcamPathPromise.current = webcamStopPromise.current; + + const recorder = new MediaRecorder(webcamStream.current, { + videoBitsPerSecond: WEBCAM_BITRATE, + ...(mimeType ? { mimeType } : {}), + }); + + webcamRecorder.current = recorder; + recorder.ondataavailable = (event) => { + if (event.data && event.data.size > 0) { + webcamChunks.current.push(event.data); + } + }; + recorder.onerror = () => { + webcamStopResolver.current?.(null); + webcamStopResolver.current = null; + }; + recorder.onstop = async () => { + const sessionTimestamp = recordingSessionTimestamp.current ?? Date.now(); + const webcamMimeType = recorder.mimeType || mimeType; + const webcamFileName = `${RECORDING_FILE_PREFIX}${sessionTimestamp}${WEBCAM_SUFFIX}${getVideoExtensionForMimeType(webcamMimeType)}`; + + try { + if (webcamChunks.current.length === 0) { + webcamStopResolver.current?.(null); + return; + } + + const duration = Math.max( + 0, + getRecordingDurationMs(Date.now()) - webcamTimeOffsetMs.current, + ); + const webcamBlob = new Blob( + webcamChunks.current, + webcamMimeType ? { type: webcamMimeType } : undefined, + ); + webcamChunks.current = []; + const finalBlob = isWebmMimeType(webcamMimeType) + ? await fixWebmDuration(webcamBlob, duration) + : webcamBlob; + const arrayBuffer = await finalBlob.arrayBuffer(); + const result = await window.electronAPI.storeRecordedVideo( + arrayBuffer, + webcamFileName, + ); + webcamStopResolver.current?.(result.success ? (result.path ?? null) : null); + } catch (error) { + console.error("Error saving webcam recording:", error); + webcamStopResolver.current?.(null); + } finally { + webcamStopResolver.current = null; + webcamRecorder.current = null; + webcamStartTime.current = null; + if (webcamStream.current) { + webcamStream.current.getTracks().forEach((track) => track.stop()); + webcamStream.current = null; + } + } + }; + } catch (error) { + console.warn( + "Failed to start webcam recording; continuing without webcam layer:", + error, + ); + resolvedWebcamPath.current = null; + pendingWebcamPathPromise.current = Promise.resolve(null); + webcamStopPromise.current = Promise.resolve(null); + webcamRecorder.current = null; + webcamStartTime.current = null; + webcamTimeOffsetMs.current = 0; + if (webcamStream.current) { + webcamStream.current.getTracks().forEach((track) => track.stop()); + webcamStream.current = null; + } + } + }, [getRecordingDurationMs, selectWebcamMimeType, webcamDeviceId, webcamEnabled]); + + /** Start the prepared webcam MediaRecorder. Call after main recording begins. */ + const beginWebcamCapture = useCallback(() => { + const recorder = webcamRecorder.current; + if (recorder && recorder.state === "inactive") { + webcamStartTime.current = Date.now(); + recorder.start(RECORDER_TIMESLICE_MS); + } + }, []); + + const prepareRecordingStart = useCallback(async () => { + const platform = await window.electronAPI.getPlatform(); + hideEditorOverlayCursorByDefault.current = false; + const existingSource = await window.electronAPI.getSelectedSource(); + const selectedSource = + existingSource ?? (platform === "linux" ? LINUX_PORTAL_SOURCE : null); + if (!selectedSource) { + alert("Please select a source to record"); + return null; + } + + if (!existingSource && selectedSource.id === "screen:linux-portal") { + try { + await window.electronAPI.selectSource(selectedSource); + } catch (err) { + console.warn("Failed to persist Linux portal sentinel source:", err); + } + } + + const permissionsReady = await preparePermissions(); + if (!permissionsReady) { + return null; + } + + recordingSessionTimestamp.current = Date.now(); + resetRecordingClock(recordingSessionTimestamp.current); + await prepareWebcamRecorder(); + + const useNativeMacScreenCapture = + platform === "darwin" && + (selectedSource.id?.startsWith("screen:") || + selectedSource.id?.startsWith("window:")) && + typeof window.electronAPI.startNativeScreenRecording === "function"; + + let useNativeWindowsCapture = false; + if ( + platform === "win32" && + shouldUseNativeWindowsCaptureForSource(selectedSource) && + typeof window.electronAPI.isNativeWindowsCaptureAvailable === "function" + ) { + try { + const nativeWindowsResult = + await window.electronAPI.isNativeWindowsCaptureAvailable(); + useNativeWindowsCapture = nativeWindowsResult.available; + if (!useNativeWindowsCapture && !hasShownNativeWindowsFallbackToast.current) { + void logNativeCaptureDiagnostics("is-native-windows-capture-available"); + hasShownNativeWindowsFallbackToast.current = true; + toast.info( + "Native Windows capture is unavailable. Falling back to browser capture.", + ); + } + } catch { + useNativeWindowsCapture = false; + if (!hasShownNativeWindowsFallbackToast.current) { + hasShownNativeWindowsFallbackToast.current = true; + toast.info( + "Unable to check native Windows capture. Falling back to browser capture.", + ); + } + } + } + + let micLabel: string | undefined; + if ((useNativeMacScreenCapture || useNativeWindowsCapture) && microphoneEnabled) { + try { + const devices = await navigator.mediaDevices.enumerateDevices(); + const mic = devices.find( + (d) => d.deviceId === microphoneDeviceId && d.kind === "audioinput", + ); + micLabel = mic?.label || undefined; + } catch { + // Fall through - native process will use the default mic. + } + } + + return { + platform, + selectedSource, + useNativeMacScreenCapture, + useNativeWindowsCapture, + micLabel, + }; + }, [ + logNativeCaptureDiagnostics, + microphoneDeviceId, + microphoneEnabled, + preparePermissions, + prepareWebcamRecorder, + resetRecordingClock, + ]); + + const discardActiveNativeCapture = useCallback(async () => { + const pendingPath = pendingNativeCleanupPath.current; + if (pendingPath) { + try { + await window.electronAPI.deleteRecordingFile(pendingPath); + pendingNativeCleanupPath.current = null; + } catch (error) { + console.warn("Failed to delete pending native capture file:", error); + } + } + + if (!nativeScreenRecording.current) { + return pendingNativeCleanupPath.current === null; + } + if (nativeStopRequestInFlight.current) { + return false; + } + + nativeStopRequestInFlight.current = true; + let result: DiscardNativeCaptureResult; + try { + result = await stopAndDiscardNativeCapture({ + stopNativeScreenRecording: () => window.electronAPI.stopNativeScreenRecording(), + deleteRecordingFile: (path) => window.electronAPI.deleteRecordingFile(path), + }); + } finally { + nativeStopRequestInFlight.current = false; + } + + if (result.stopSucceeded) { + nativeScreenRecording.current = false; + nativeWindowsRecording.current = false; + nativeWarmStartActive.current = false; + } + + if (!result.deleteSucceeded && result.path) { + pendingNativeCleanupPath.current = result.path; + } + + if (!result.stopSucceeded || !result.deleteSucceeded) { + console.warn("Failed to fully discard native capture:", result.error); + return false; + } + + return true; + }, []); + + const stopRecording = useRef(() => { + recordingStartGeneration.current += 1; + setPaused(false); + if (nativeScreenRecording.current && nativeWarmStartActive.current) { + setRecording(false); + void (async () => { + await discardActiveNativeCapture(); + cleanupCapturedMedia(); + await Promise.allSettled([ + stopMicFallbackRecorder(), + stopWebcamRecorder(), + window.electronAPI?.setRecordingState(false), + ]); + })(); + return; + } + if (nativeScreenRecording.current) { + if (nativeStopRequestInFlight.current) { + return; + } + nativeStopRequestInFlight.current = true; + setRecording(false); + setFinalizing(true); + + void (async () => { + const stopStart = performance.now(); + console.log("[PERF:RENDERER] Total Stop Sequence: STARTED"); + + const fallbackStartDelayMs = micFallbackStartDelayMs.current; + const fallbackTrackSettings = micFallbackTrackSettings.current; + const stoppedAtMs = Date.now(); + markRecordingResumed(stoppedAtMs); + const expectedDurationMs = getRecordingDurationMs(stoppedAtMs); + const micFallbackBlobPromise = stopMicFallbackRecorder(); + const webcamPathPromise = stopWebcamRecorder(); + const isNativeWindows = nativeWindowsRecording.current; + + const ipcStopStart = performance.now(); + console.log("[PERF:RENDERER] IPC: stopNativeScreenRecording: STARTED"); + let result: NativeCaptureStopResult; + try { + result = await window.electronAPI.stopNativeScreenRecording(); + } catch (error) { + result = { success: false, error: getErrorMessage(error) }; + } + nativeStopRequestInFlight.current = false; + console.log( + `[PERF:RENDERER] IPC: stopNativeScreenRecording: COMPLETED in ${(performance.now() - ipcStopStart).toFixed(2)}ms`, + ); + if (result.success) { + nativeScreenRecording.current = false; + nativeWindowsRecording.current = false; + nativeWarmStartActive.current = false; + } + + try { + await window.electronAPI?.setRecordingState(false); + } catch (stateError) { + console.warn("Failed to reset main-process recording state:", stateError); + } + + if (!result.success || !result.path) { + console.error( + "Failed to stop native screen recording:", + result.error ?? result.message, + ); + void logNativeCaptureDiagnostics("stop-native-screen-recording"); + try { + const recoveredPath = await recoverNativeRecordingSession( + micFallbackBlobPromise, + fallbackStartDelayMs, + ); + if (recoveredPath) { + console.log( + `[PERF:RENDERER] Total Stop Sequence (RECOVERED) in ${(performance.now() - stopStart).toFixed(2)}ms`, + ); + return; + } + } catch (recoveryError) { + console.error("Failed to recover native screen recording:", recoveryError); + } + + const failureMessage = await buildNativeCaptureFailureMessage( + "stop-native-screen-recording", + isMacOS + ? "Failed to finish the macOS recording, so the editor was not opened." + : "Failed to finish the recording, so the editor was not opened.", + ); + await notifyRecordingFinalizationFailure(failureMessage); + return; + } + + const finalPath = result.path; + + // 1. Finalize the session and switch to editor immediately (Optimistic UI) + // We pass null for webcamPath initially to avoid blocking on webcam disk writes/muxing. + await finalizeRecordingSession(finalPath, null); + + // 2. Perform background finalization (webcam, muxing, sidecars) + // We don't await this to keep the UI responsive + void (async () => { + try { + // Webcam persistence, microphone sidecars, and Windows muxing are + // independent. Run them concurrently so the editor can keep playing + // the main video and reveal the webcam as soon as only that file is ready. + const webcamReadyPromise = webcamPathPromise.then(async (webcamPath) => { + console.log( + "[useScreenRecorder] Background native processing: webcamPath is", + webcamPath, + ); + + if (webcamPath) { + try { + await window.electronAPI.setCurrentRecordingSession({ + videoPath: finalPath, + webcamPath, + timeOffsetMs: webcamTimeOffsetMs.current, + hideOverlayCursorByDefault: + hideEditorOverlayCursorByDefault.current, + }); + } catch (sessionError) { + console.error( + "Failed to publish the asynchronously finalized webcam:", + sessionError, + ); + } + } + + return webcamPath; + }); + const microphoneReadyPromise = storeMicrophoneSidecar( + micFallbackBlobPromise, + finalPath, + fallbackStartDelayMs, + fallbackTrackSettings, + ); + const muxReadyPromise = isNativeWindows + ? window.electronAPI.muxNativeWindowsRecording(expectedDurationMs) + : Promise.resolve(null); + const [webcamResult, microphoneResult, muxResult] = + await Promise.allSettled([ + webcamReadyPromise, + microphoneReadyPromise, + muxReadyPromise, + ]); + const webcamPath = + webcamResult.status === "fulfilled" ? webcamResult.value : null; + for (const [taskName, result] of [ + ["webcam", webcamResult], + ["microphone sidecar", microphoneResult], + ["Windows mux", muxResult], + ] as const) { + if (result.status === "rejected") { + console.error( + `[useScreenRecorder] ${taskName} finalization failed:`, + result.reason, + ); + } + } + + console.log( + "[useScreenRecorder] Emitting setCurrentRecordingSession with:", + { finalPath, webcamPath }, + ); + + // Update the session state to notify the editor that all background assets (webcam, mic, etc.) are now ready. + // This broadcasts a 'recording-session-changed' event that the open editor listens to for re-scanning assets. + await window.electronAPI.setCurrentRecordingSession({ + videoPath: finalPath, + webcamPath, + timeOffsetMs: webcamTimeOffsetMs.current, + hideOverlayCursorByDefault: hideEditorOverlayCursorByDefault.current, + }); + + console.log( + `[PERF:RENDERER] Background Stop Sequence: COMPLETED in ${(performance.now() - stopStart).toFixed(2)}ms`, + ); + } catch (bgError) { + console.error("Error in background finalization:", bgError); + } finally { + // After all background tasks are done (webcam, mic sidecars, muxing), + // we can safely close the HUD window to release hardware and resources. + if (typeof window.electronAPI?.hudOverlayClose === "function") { + console.log( + "[useScreenRecorder] All background tasks finished, closing HUD", + ); + window.electronAPI.hudOverlayClose(); + } + } + })(); + })(); + return; + } + + const recorder = mediaRecorder.current; + const recorderState = recorder?.state; + if (recorder && (recorderState === "recording" || recorderState === "paused")) { + if (recorderState === "paused") { + try { + recorder.resume(); + markRecordingResumed(Date.now()); + } catch (error) { + console.warn("Failed to resume recorder before stopping:", error); + } + } + pendingWebcamPathPromise.current = stopWebcamRecorder(); + try { + recorder.requestData(); + } catch (error) { + console.warn("Failed to flush recorder before stopping:", error); + } + recorder.stop(); + setRecording(false); + setFinalizing(true); + window.electronAPI?.setRecordingState(false); + } + }); + + useEffect(() => { + void (async () => { + const platform = await window.electronAPI.getPlatform(); + setIsMacOS(platform === "darwin"); + })(); + }, []); + + useEffect(() => { + if (typeof window.electronAPI?.getRecordingAudioLabConfig !== "function") { + return; + } + + void (async () => { + const result = await window.electronAPI.getRecordingAudioLabConfig(); + browserMicrophoneProfile.current = normalizeBrowserMicrophoneProfile( + result.browserMicrophoneProfile, + ); + requestedBrowserMicrophoneProfile.current = + result.requestedBrowserMicrophoneProfile ?? null; + console.info("Browser microphone profile:", browserMicrophoneProfile.current); + })(); + }, []); + + useEffect(() => { + if (countdownDelayLoaded.current) return; + countdownDelayLoaded.current = true; + + void (async () => { + const result = await window.electronAPI.getCountdownDelay(); + if (result.success && typeof result.delay === "number") { + setCountdownDelayState(result.delay); + } + })(); + }, []); + + const setCountdownDelay = useCallback((delay: number) => { + setCountdownDelayState(delay); + void window.electronAPI.setCountdownDelay(delay); + }, []); + + useEffect(() => { + if (recordingPrefsLoaded.current) return; + recordingPrefsLoaded.current = true; + + void (async () => { + const result = await window.electronAPI.getRecordingPreferences(); + if (result.success) { + setMicrophoneEnabled(result.microphoneEnabled); + if (result.microphoneDeviceId) { + setMicrophoneDeviceId(result.microphoneDeviceId); + } + setSystemAudioEnabled(result.systemAudioEnabled); + setWebcamEnabled(result.webcamEnabled); + if (result.webcamDeviceId) { + setWebcamDeviceId(result.webcamDeviceId); + } + } + })(); + }, []); + + const persistMicrophoneEnabled = useCallback((enabled: boolean) => { + setMicrophoneEnabled(enabled); + void window.electronAPI.setRecordingPreferences({ microphoneEnabled: enabled }); + }, []); + + const persistMicrophoneDeviceId = useCallback((deviceId: string | undefined) => { + setMicrophoneDeviceId(deviceId); + void window.electronAPI.setRecordingPreferences({ microphoneDeviceId: deviceId }); + }, []); + + const persistSystemAudioEnabled = useCallback((enabled: boolean) => { + setSystemAudioEnabled(enabled); + void window.electronAPI.setRecordingPreferences({ systemAudioEnabled: enabled }); + }, []); + + const persistWebcamEnabled = useCallback((enabled: boolean) => { + setWebcamEnabled(enabled); + void window.electronAPI.setRecordingPreferences({ webcamEnabled: enabled }); + }, []); + + const persistWebcamDeviceId = useCallback((deviceId: string | undefined) => { + setWebcamDeviceId(deviceId); + void window.electronAPI.setRecordingPreferences({ webcamDeviceId: deviceId }); + }, []); + + useEffect(() => { + let cleanup: (() => void) | undefined; + + if (window.electronAPI?.onStopRecordingFromTray) { + cleanup = window.electronAPI.onStopRecordingFromTray(() => { + stopRecording.current(); + }); + } + + const removeRecordingStateListener = window.electronAPI?.onRecordingStateChanged?.( + (state) => { + setRecording(state.recording); + }, + ); + + const removeRecordingInterruptedListener = window.electronAPI?.onRecordingInterrupted?.( + (state) => { + void (async () => { + recordingStartGeneration.current += 1; + setRecording(false); + nativeScreenRecording.current = false; + nativeWindowsRecording.current = false; + nativeWarmStartActive.current = false; + cleanupCapturedMedia(); + await window.electronAPI.setRecordingState(false); + + if (state.reason !== "window-unavailable") { + try { + const recoveredPath = await recoverNativeRecordingSession(); + if (recoveredPath) { + return; + } + } catch (recoveryError) { + console.error( + "Failed to recover interrupted native screen recording:", + recoveryError, + ); + } + } + + if (state.reason === "window-unavailable" && !hasPromptedForReselect.current) { + hasPromptedForReselect.current = true; + alert(state.message); + await window.electronAPI.openSourceSelector(); + } else { + console.error(state.message); + toast.error(state.message); + } + })(); + }, + ); + + return () => { + recordingStartGeneration.current += 1; + cleanup?.(); + removeRecordingStateListener?.(); + removeRecordingInterruptedListener?.(); + + if (nativeScreenRecording.current) { + if (nativeWarmStartActive.current) { + void discardActiveNativeCapture(); + } else if (!nativeStopRequestInFlight.current) { + nativeStopRequestInFlight.current = true; + void window.electronAPI + .stopNativeScreenRecording() + .then((result) => { + if (result.success) { + nativeScreenRecording.current = false; + nativeWindowsRecording.current = false; + } + }) + .catch((error) => { + console.warn("Failed to stop native capture during cleanup:", error); + }) + .finally(() => { + nativeStopRequestInFlight.current = false; + }); + } + } else if (pendingNativeCleanupPath.current) { + void discardActiveNativeCapture(); + } + + const recorder = mediaRecorder.current; + const recorderState = recorder?.state; + if (recorder && (recorderState === "recording" || recorderState === "paused")) { + recorder.stop(); + } + + cleanupCapturedMedia(); + }; + }, [cleanupCapturedMedia, discardActiveNativeCapture, recoverNativeRecordingSession]); + + const startRecording = async () => { + if (startInFlight.current) { + return; + } + const startGeneration = recordingStartGeneration.current + 1; + recordingStartGeneration.current = startGeneration; + const startWasCancelled = () => recordingStartGeneration.current !== startGeneration; + + let hudSourceSelectionActive = false; + const setHudSourceSelectionActive = (active: boolean) => { + if (hudSourceSelectionActive === active) { + return; + } + + hudSourceSelectionActive = active; + window.electronAPI?.hudOverlaySetSourceSelectionActive?.(active); + }; + + hasPromptedForReselect.current = false; + startInFlight.current = true; + setStarting(true); + + try { + const preparedStart = await prepareRecordingStart(); + if (!preparedStart || startWasCancelled()) { + cleanupCapturedMedia(); + await stopWebcamRecorder(); + return; + } + + const { selectedSource, useNativeMacScreenCapture, useNativeWindowsCapture, micLabel } = + preparedStart; + const useNativeCapture = useNativeMacScreenCapture || useNativeWindowsCapture; + const shouldWarmStartNativeCapture = useNativeCapture && countdownDelay > 0; + if (countdownDelay > 0 && !shouldWarmStartNativeCapture) { + setCountdownActive(true); + try { + const result = await window.electronAPI.startCountdown(countdownDelay); + if (!result.success || result.cancelled || startWasCancelled()) { + cleanupCapturedMedia(); + await stopWebcamRecorder(); + return; + } + } finally { + setCountdownActive(false); + } + recordingSessionTimestamp.current = Date.now(); + resetRecordingClock(recordingSessionTimestamp.current); + } + + let nativeWindowsCaptureStartFailed = false; + + if (useNativeCapture) { + const nativeResult = await window.electronAPI.startNativeScreenRecording( + selectedSource, + { + capturesSystemAudio: systemAudioEnabled, + capturesMicrophone: microphoneEnabled, + microphoneDeviceId, + microphoneLabel: micLabel, + }, + ); + if (nativeResult.success && startWasCancelled()) { + nativeScreenRecording.current = true; + nativeWindowsRecording.current = useNativeWindowsCapture; + nativeWarmStartActive.current = shouldWarmStartNativeCapture; + await discardActiveNativeCapture(); + cleanupCapturedMedia(); + await stopWebcamRecorder(); + return; + } + if (!nativeResult.success) { + if (useNativeWindowsCapture) { + nativeWindowsCaptureStartFailed = true; + console.warn( + "Native Windows capture failed, falling back to browser capture:", + nativeResult.error ?? nativeResult.message, + ); + void logNativeCaptureDiagnostics("start-native-screen-recording"); + if (!hasShownNativeWindowsFallbackToast.current) { + hasShownNativeWindowsFallbackToast.current = true; + toast.warning( + "Native Windows capture failed to start. Falling back to browser capture.", + ); + } + } else if (!nativeResult.userNotified) { + throw new Error( + nativeResult.error ?? + nativeResult.message ?? + "Failed to start native screen recording", + ); + } else { + setRecording(false); + cleanupCapturedMedia(); + await stopWebcamRecorder(); + return; + } + } + + if (nativeResult.success) { + nativeScreenRecording.current = true; + nativeWindowsRecording.current = useNativeWindowsCapture; + if (shouldWarmStartNativeCapture) { + nativeWarmStartActive.current = true; + const pauseResult = await window.electronAPI.pauseNativeScreenRecording(); + if (startWasCancelled()) { + return; + } + if (!pauseResult.success) { + throw new Error( + pauseResult.error ?? + pauseResult.message ?? + "Failed to pause native capture before countdown", + ); + } + + setCountdownActive(true); + try { + const countdownResult = + await window.electronAPI.startCountdown(countdownDelay); + if ( + !countdownResult.success || + countdownResult.cancelled || + startWasCancelled() + ) { + if (!startWasCancelled()) { + await discardActiveNativeCapture(); + } + cleanupCapturedMedia(); + await stopWebcamRecorder(); + return; + } + } finally { + setCountdownActive(false); + } + + const resumeResult = await window.electronAPI.resumeNativeScreenRecording(); + if (startWasCancelled()) { + return; + } + if (!resumeResult.success) { + throw new Error( + resumeResult.error ?? + resumeResult.message ?? + "Failed to resume native capture after countdown", + ); + } + nativeWarmStartActive.current = false; + } + if (startWasCancelled()) { + return; + } + + const mainStartedAt = Date.now(); + micFallbackStartDelayMs.current = null; + beginWebcamCapture(); + resetRecordingClock(mainStartedAt); + webcamTimeOffsetMs.current = + webcamStartTime.current === null + ? 0 + : webcamStartTime.current - mainStartedAt; + + // When native mic capture is unavailable or explicitly bypassed, + // record mic via browser getUserMedia as a sidecar file. + if (nativeResult.microphoneFallbackRequired && microphoneEnabled) { + void logNativeCaptureDiagnostics("start-browser-microphone-fallback"); + console.info("Using browser microphone processing for this recording."); + try { + const microphoneConstraints = createProcessedMicrophoneConstraints( + microphoneDeviceId, + browserMicrophoneProfile.current, + ); + micFallbackRequestedConstraints.current = microphoneConstraints; + const micStream = + await navigator.mediaDevices.getUserMedia(microphoneConstraints); + micFallbackTrackSettings.current = + createMicrophoneTrackSettingsSnapshot(micStream); + micFallbackAudioInputDevices.current = + await createAudioInputDeviceSnapshot().catch(() => null); + console.info( + "Browser microphone track settings:", + micFallbackTrackSettings.current, + ); + console.info( + "Browser microphone audio input devices:", + micFallbackAudioInputDevices.current, + ); + micFallbackChunks.current = []; + const recorder = new MediaRecorder(micStream, { + mimeType: "audio/webm;codecs=opus", + audioBitsPerSecond: AUDIO_BITRATE_VOICE, + }); + micFallbackRecorderMetadata.current = { + mimeType: recorder.mimeType, + audioBitsPerSecond: AUDIO_BITRATE_VOICE, + timesliceMs: RECORDER_TIMESLICE_MS, + }; + resetMicFallbackTimingDiagnostics(); + micFallbackRecorderStartedAt.current = performance.now(); + recorder.ondataavailable = appendMicFallbackChunk; + micFallbackStartDelayMs.current = Math.max( + 0, + Date.now() - mainStartedAt, + ); + recorder.start(RECORDER_TIMESLICE_MS); + micFallbackRecorder.current = recorder; + } catch (micError) { + micFallbackStartDelayMs.current = null; + micFallbackTrackSettings.current = null; + micFallbackRequestedConstraints.current = null; + micFallbackAudioInputDevices.current = null; + micFallbackRecorderMetadata.current = null; + resetMicFallbackTimingDiagnostics(); + console.warn("Browser microphone fallback failed:", micError); + const permissionDenied = + micError instanceof DOMException && + (micError.name === "NotAllowedError" || + micError.name === "SecurityError"); + toast.error( + permissionDenied + ? "Microphone permission denied. Recording will continue without microphone audio." + : `${getErrorMessage(micError)}. Recording will continue without microphone audio.`, + { id: MICROPHONE_FALLBACK_ERROR_TOAST_ID, duration: 10000 }, + ); + } + } + if (startWasCancelled()) { + await stopMicFallbackRecorder(); + await stopWebcamRecorder(); + cleanupCapturedMedia(); + return; + } + + setRecording(true); + try { + await window.electronAPI?.setRecordingState(true); + } catch (stateError) { + console.warn( + "Failed to notify main process that native recording started:", + stateError, + ); + } + + return; + } + } + + if (nativeWindowsCaptureStartFailed && countdownDelay > 0) { + setCountdownActive(true); + try { + const result = await window.electronAPI.startCountdown(countdownDelay); + if (!result.success || result.cancelled) { + cleanupCapturedMedia(); + await stopWebcamRecorder(); + return; + } + } finally { + setCountdownActive(false); + } + recordingSessionTimestamp.current = Date.now(); + resetRecordingClock(recordingSessionTimestamp.current); + } + + const browserCursorPolicy = resolveBrowserCaptureCursorPolicy({ + nativeWindowsCaptureStartFailed, + }); + hideEditorOverlayCursorByDefault.current = + browserCursorPolicy.hideEditorOverlayCursorByDefault; + + const wantsAudioCapture = microphoneEnabled || systemAudioEnabled; + const browserCaptureSource = await resolveBrowserCaptureSource(selectedSource); + + if ( + browserCaptureSource?.id?.startsWith("screen:fallback:") || + browserCaptureSource?.id?.startsWith("window:fallback:") + ) { + throw new Error( + "Selected display is not available for browser capture on this system.", + ); + } + + if (browserCursorPolicy.hideOsCursorBeforeRecording) { + try { + const hideCursorResult = await window.electronAPI.hideOsCursor?.(); + if (hideCursorResult && !hideCursorResult.success) { + console.warn( + "Could not hide OS cursor before recording.", + hideCursorResult, + ); + } + } catch { + console.warn("Could not hide OS cursor before recording."); + } + } + + let videoTrack: MediaStreamTrack | undefined; + let systemAudioIncluded = false; + const mediaDevices = navigator.mediaDevices as DesktopCaptureMediaDevices; + const useLinuxPortal = selectedSource.id === "screen:linux-portal"; + const browserScreenVideoConstraints = { + mandatory: { + chromeMediaSource: CHROME_MEDIA_SOURCE, + chromeMediaSourceId: browserCaptureSource.id, + maxWidth: TARGET_WIDTH, + maxHeight: TARGET_HEIGHT, + maxFrameRate: TARGET_FRAME_RATE, + minFrameRate: MIN_FRAME_RATE, + googCaptureCursor: browserCursorPolicy.streamCursor === "always", + }, + cursor: browserCursorPolicy.streamCursor, + }; + + if (wantsAudioCapture) { + let screenMediaStream: MediaStream; + const acquireLinuxPortalStream = (withAudio: boolean) => + mediaDevices.getDisplayMedia({ + audio: withAudio, + video: { + displaySurface: "monitor", + width: { ideal: TARGET_WIDTH, max: TARGET_WIDTH }, + height: { ideal: TARGET_HEIGHT, max: TARGET_HEIGHT }, + frameRate: { ideal: TARGET_FRAME_RATE, max: TARGET_FRAME_RATE }, + cursor: browserCursorPolicy.streamCursor, + }, + selfBrowserSurface: "exclude", + surfaceSwitching: "exclude", + }); + + if (systemAudioEnabled) { + try { + screenMediaStream = useLinuxPortal + ? await acquireLinuxPortalStream(true) + : await mediaDevices.getUserMedia({ + audio: { + mandatory: { + chromeMediaSource: CHROME_MEDIA_SOURCE, + chromeMediaSourceId: browserCaptureSource.id, + }, + }, + video: browserScreenVideoConstraints, + }); + } catch (audioError) { + console.warn( + "System audio capture failed, falling back to video-only:", + audioError, + ); + alert( + "System audio is not available for this source. Recording will continue without system audio.", + ); + screenMediaStream = useLinuxPortal + ? await acquireLinuxPortalStream(false) + : await mediaDevices.getUserMedia({ + audio: false, + video: browserScreenVideoConstraints, + }); + } + } else { + screenMediaStream = useLinuxPortal + ? await acquireLinuxPortalStream(false) + : await mediaDevices.getUserMedia({ + audio: false, + video: browserScreenVideoConstraints, + }); + } + + screenStream.current = screenMediaStream; + stream.current = new MediaStream(); + + videoTrack = screenMediaStream.getVideoTracks()[0]; + if (!videoTrack) { + throw new Error("Video track is not available."); + } + + stream.current.addTrack(videoTrack); + + if (microphoneEnabled) { + try { + microphoneStream.current = await navigator.mediaDevices.getUserMedia( + createProcessedMicrophoneConstraints( + microphoneDeviceId, + browserMicrophoneProfile.current, + ), + ); + } catch (audioError) { + console.warn("Failed to get microphone access:", audioError); + alert( + "Microphone access was denied. Recording will continue without microphone audio.", + ); + setMicrophoneEnabled(false); + } + } + + const systemAudioTrack = screenMediaStream.getAudioTracks()[0]; + const micAudioTrack = microphoneStream.current?.getAudioTracks()[0]; + + if (systemAudioTrack && micAudioTrack) { + const context = new AudioContext({ sampleRate: 48000 }); + mixingContext.current = context; + const systemSource = context.createMediaStreamSource( + new MediaStream([systemAudioTrack]), + ); + const micSource = context.createMediaStreamSource( + new MediaStream([micAudioTrack]), + ); + const micGain = context.createGain(); + micGain.gain.value = MIC_GAIN_BOOST; + const destination = context.createMediaStreamDestination(); + + systemSource.connect(destination); + micSource.connect(micGain).connect(destination); + + const mixedTrack = destination.stream.getAudioTracks()[0]; + if (mixedTrack) { + stream.current.addTrack(mixedTrack); + systemAudioIncluded = true; + } + } else if (systemAudioTrack) { + stream.current.addTrack(systemAudioTrack); + systemAudioIncluded = true; + } else if (micAudioTrack) { + stream.current.addTrack(micAudioTrack); + } + } else { + const mediaStream = useLinuxPortal + ? await mediaDevices.getDisplayMedia({ + audio: false, + video: { + displaySurface: selectedSource.id?.startsWith("window:") + ? "window" + : "monitor", + width: { ideal: TARGET_WIDTH, max: TARGET_WIDTH }, + height: { ideal: TARGET_HEIGHT, max: TARGET_HEIGHT }, + frameRate: { ideal: TARGET_FRAME_RATE, max: TARGET_FRAME_RATE }, + cursor: browserCursorPolicy.streamCursor, + }, + selfBrowserSurface: "exclude", + surfaceSwitching: "exclude", + }) + : await mediaDevices.getUserMedia({ + audio: false, + video: browserScreenVideoConstraints, + }); + + stream.current = mediaStream; + videoTrack = mediaStream.getVideoTracks()[0]; + } + + if (!stream.current || !videoTrack) { + throw new Error("Media stream is not available."); + } + + try { + await videoTrack.applyConstraints({ + frameRate: { ideal: TARGET_FRAME_RATE, max: TARGET_FRAME_RATE }, + width: { ideal: TARGET_WIDTH, max: TARGET_WIDTH }, + height: { ideal: TARGET_HEIGHT, max: TARGET_HEIGHT }, + } as MediaTrackConstraints); + } catch (error) { + console.warn( + "Unable to lock 4K/60fps constraints, using best available track settings.", + error, + ); + } + + let { + width = DEFAULT_WIDTH, + height = DEFAULT_HEIGHT, + frameRate = TARGET_FRAME_RATE, + } = videoTrack.getSettings(); + + width = Math.floor(width / CODEC_ALIGNMENT) * CODEC_ALIGNMENT; + height = Math.floor(height / CODEC_ALIGNMENT) * CODEC_ALIGNMENT; + + const videoBitsPerSecond = computeBitrate(width, height); + const mimeType = selectMimeType(); + + console.log( + `Recording at ${width}x${height} @ ${frameRate ?? TARGET_FRAME_RATE}fps using ${mimeType ?? "browser default"} / ${Math.round( + videoBitsPerSecond / BITS_PER_MEGABIT, + )} Mbps`, + ); + + chunks.current = []; + const hasAudio = stream.current.getAudioTracks().length > 0; + const audioBitsPerSecond = hasAudio + ? systemAudioIncluded + ? AUDIO_BITRATE_SYSTEM + : AUDIO_BITRATE_VOICE + : undefined; + const recorder = new MediaRecorder( + stream.current, + createBrowserRecordingOptions({ + audioBitsPerSecond, + mimeType, + videoBitsPerSecond, + }), + ); + + mediaRecorder.current = recorder; + recorder.ondataavailable = (event) => { + if (event.data && event.data.size > 0) chunks.current.push(event.data); + }; + recorder.onstop = async () => { + cleanupCapturedMedia(); + if (chunks.current.length === 0) { + setFinalizing(false); + return; + } + + const duration = getRecordingDurationMs(Date.now()); + const recordedChunks = chunks.current; + const recordingBlobType = recorder.mimeType || mimeType; + const buggyBlob = new Blob( + recordedChunks, + recordingBlobType ? { type: recordingBlobType } : undefined, + ); + chunks.current = []; + const timestamp = recordingSessionTimestamp.current ?? Date.now(); + const videoFileName = `${RECORDING_FILE_PREFIX}${timestamp}${getVideoExtensionForMimeType(recordingBlobType)}`; + + try { + const videoBlob = isWebmMimeType(recordingBlobType) + ? await fixWebmDuration(buggyBlob, duration) + : buggyBlob; + const arrayBuffer = await videoBlob.arrayBuffer(); + const videoResult = await window.electronAPI.storeRecordedVideo( + arrayBuffer, + videoFileName, + ); + if (!videoResult.success) { + console.error("Failed to store video:", videoResult.message); + await notifyRecordingFinalizationFailure( + videoResult.message || "Failed to store the recording.", + ); + return; + } + + if (videoResult.path) { + const finalVideoPath = videoResult.path; + // 1. Launch editor immediately (Optimistic UI) + await finalizeRecordingSession(finalVideoPath, null); + + // 2. Background webcam processing + void (async () => { + const webcamPath = pendingWebcamPathPromise.current + ? await pendingWebcamPathPromise.current + : resolvedWebcamPath.current; + + try { + if (webcamPath) { + await window.electronAPI.setCurrentRecordingSession({ + videoPath: finalVideoPath, + webcamPath, + timeOffsetMs: webcamTimeOffsetMs.current, + hideOverlayCursorByDefault: + hideEditorOverlayCursorByDefault.current, + }); + } + } finally { + // After all background tasks are done (webcam), + // we can safely close the HUD window to release hardware and resources. + if (typeof window.electronAPI?.hudOverlayClose === "function") { + console.log( + "[useScreenRecorder:browser] All background tasks finished, closing HUD", + ); + window.electronAPI.hudOverlayClose(); + } + } + })(); + } else { + await notifyRecordingFinalizationFailure("Failed to save the recording."); + } + } catch (error) { + console.error("Error saving recording:", error); + const message = error instanceof Error ? error.message : String(error); + await notifyRecordingFinalizationFailure( + `Failed to finalize the recording. ${message}`, + ); + } + }; + recorder.onerror = () => { + setRecording(false); + }; + const mainStartedAt = Date.now(); + beginWebcamCapture(); + resetRecordingClock(mainStartedAt); + webcamTimeOffsetMs.current = + webcamStartTime.current === null ? 0 : webcamStartTime.current - mainStartedAt; + recorder.start(RECORDER_TIMESLICE_MS); + setRecording(true); + try { + await window.electronAPI?.setRecordingState(true); + } catch (stateError) { + console.warn("Failed to notify main process that recording started:", stateError); + } + } catch (error) { + console.error("Failed to start recording:", error); + alert( + error instanceof Error + ? `Failed to start recording: ${error.message}` + : "Failed to start recording", + ); + setRecording(false); + if (nativeScreenRecording.current) { + await discardActiveNativeCapture(); + } + try { + await window.electronAPI?.setRecordingState(false); + } catch (stateError) { + console.warn("Failed to reset main-process recording state:", stateError); + } finally { + cleanupCapturedMedia(); + await stopWebcamRecorder(); + } + } finally { + setHudSourceSelectionActive(false); + startInFlight.current = false; + setStarting(false); + } + }; + + const pauseRecording = useCallback(() => { + if (!recording || paused) return; + if (nativeScreenRecording.current) { + void (async () => { + const result = await window.electronAPI.pauseNativeScreenRecording(); + if (!result.success) { + console.error( + "Failed to pause native screen recording:", + result.error ?? result.message, + ); + return; + } + + if (webcamRecorder.current?.state === "recording") { + webcamRecorder.current.pause(); + } + pauseMicFallbackRecorder(); + const boundaryMs = Date.now(); + markRecordingPaused(boundaryMs); + setPaused(true); + try { + await window.electronAPI.pauseCursorCapture(boundaryMs); + } catch (error) { + console.warn("Failed to pause cursor capture:", error); + } + })(); + return; + } + if (mediaRecorder.current?.state === "recording") { + mediaRecorder.current.pause(); + if (webcamRecorder.current?.state === "recording") { + webcamRecorder.current.pause(); + } + void (async () => { + const boundaryMs = Date.now(); + markRecordingPaused(boundaryMs); + setPaused(true); + try { + await window.electronAPI.pauseCursorCapture(boundaryMs); + } catch (error) { + console.warn("Failed to pause cursor capture:", error); + } + })(); + } + }, [markRecordingPaused, pauseMicFallbackRecorder, paused, recording]); + + const resumeRecording = useCallback(() => { + if (!recording || !paused) return; + if (nativeScreenRecording.current) { + void (async () => { + const result = await window.electronAPI.resumeNativeScreenRecording(); + if (!result.success) { + console.error( + "Failed to resume native screen recording:", + result.error ?? result.message, + ); + return; + } + + if (webcamRecorder.current?.state === "paused") { + webcamRecorder.current.resume(); + } + resumeMicFallbackRecorder(); + const boundaryMs = Date.now(); + markRecordingResumed(boundaryMs); + setPaused(false); + try { + await window.electronAPI.resumeCursorCapture(boundaryMs); + } catch (error) { + console.warn("Failed to resume cursor capture:", error); + } + })(); + return; + } + if (mediaRecorder.current?.state === "paused") { + mediaRecorder.current.resume(); + if (webcamRecorder.current?.state === "paused") { + webcamRecorder.current.resume(); + } + void (async () => { + const boundaryMs = Date.now(); + markRecordingResumed(boundaryMs); + setPaused(false); + try { + await window.electronAPI.resumeCursorCapture(boundaryMs); + } catch (error) { + console.warn("Failed to resume cursor capture:", error); + } + })(); + } + }, [markRecordingResumed, paused, recording, resumeMicFallbackRecorder]); + + const cancelRecording = useCallback(() => { + recordingStartGeneration.current += 1; + if (!recording) return; + setPaused(false); + markRecordingResumed(Date.now()); + + // Discard webcam recording regardless of recording mode + webcamChunks.current = []; + if (webcamRecorder.current && webcamRecorder.current.state !== "inactive") { + webcamRecorder.current.stop(); + } + webcamRecorder.current = null; + webcamStartTime.current = null; + webcamTimeOffsetMs.current = 0; + webcamStream.current?.getTracks().forEach((t) => t.stop()); + webcamStream.current = null; + pendingWebcamPathPromise.current = null; + resolvedWebcamPath.current = null; + + if (nativeScreenRecording.current) { + setRecording(false); + window.electronAPI?.setRecordingState(false); + void (async () => { + await discardActiveNativeCapture(); + })(); + return; + } + + if (mediaRecorder.current) { + chunks.current = []; + cleanupCapturedMedia(); + if (mediaRecorder.current.state !== "inactive") { + mediaRecorder.current.stop(); + } + setRecording(false); + window.electronAPI?.setRecordingState(false); + } + }, [cleanupCapturedMedia, discardActiveNativeCapture, markRecordingResumed, recording]); + + const toggleRecording = async () => { + if (starting || countdownActive || finalizing) { + return; + } + + if (recording) { + stopRecording.current(); + return; + } + + startRecording(); + }; + + return { + recording, + paused, + finalizing, + countdownActive, + toggleRecording, + pauseRecording, + resumeRecording, + cancelRecording, + preparePermissions, + isMacOS, + microphoneEnabled, + setMicrophoneEnabled: persistMicrophoneEnabled, + microphoneDeviceId, + setMicrophoneDeviceId: persistMicrophoneDeviceId, + systemAudioEnabled, + setSystemAudioEnabled: persistSystemAudioEnabled, + webcamEnabled, + setWebcamEnabled: persistWebcamEnabled, + webcamDeviceId, + setWebcamDeviceId: persistWebcamDeviceId, + countdownDelay, + setCountdownDelay, + }; } diff --git a/src/hooks/useVideoDevices.ts b/src/hooks/useVideoDevices.ts new file mode 100644 index 000000000..6f7862ffb --- /dev/null +++ b/src/hooks/useVideoDevices.ts @@ -0,0 +1,118 @@ +import { useEffect, useState } from "react"; + +export interface VideoDevice { + deviceId: string; + label: string; + groupId: string; +} + +let hasRequestedVideoLabels = false; + +export function useVideoDevices(enabled: boolean = true) { + const [devices, setDevices] = useState<VideoDevice[]>([]); + const [selectedDeviceId, setSelectedDeviceId] = useState<string>("default"); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState<string | null>(null); + + useEffect(() => { + if (!enabled) { + return; + } + + let mounted = true; + let activeLoadId = 0; + + const loadDevices = async () => { + const loadId = ++activeLoadId; + let permissionStream: MediaStream | null = null; + + try { + if (mounted && loadId === activeLoadId) { + setIsLoading(true); + setError(null); + } + + let allDevices = await navigator.mediaDevices.enumerateDevices(); + let videoInputs = allDevices + .filter((device) => device.kind === "videoinput") + .map((device, index) => ({ + deviceId: device.deviceId, + label: device.label || `Camera ${index + 1}`, + groupId: device.groupId, + })); + + const needsLabelPermission = + videoInputs.length > 0 && videoInputs.every((device) => !device.label.trim()); + + if (needsLabelPermission && !hasRequestedVideoLabels) { + permissionStream = await navigator.mediaDevices.getUserMedia({ + video: true, + audio: false, + }); + allDevices = await navigator.mediaDevices.enumerateDevices(); + videoInputs = allDevices + .filter((device) => device.kind === "videoinput") + .map((device, index) => ({ + deviceId: device.deviceId, + label: device.label || `Camera ${index + 1}`, + groupId: device.groupId, + })); + hasRequestedVideoLabels = true; + } + + if (mounted && loadId === activeLoadId) { + setDevices(videoInputs); + setSelectedDeviceId((currentDeviceId) => { + if (currentDeviceId === "default" && videoInputs.length > 0) { + return videoInputs[0].deviceId; + } + + if ( + currentDeviceId !== "default" && + videoInputs.some((device) => device.deviceId === currentDeviceId) + ) { + return currentDeviceId; + } + + return videoInputs[0]?.deviceId ?? "default"; + }); + } + } catch (error) { + if (mounted && loadId === activeLoadId) { + const message = + error instanceof Error + ? error.message + : "Failed to enumerate video devices"; + setError(message); + console.error("Error loading video devices:", error); + } + } finally { + permissionStream?.getTracks().forEach((track) => track.stop()); + if (mounted && loadId === activeLoadId) { + setIsLoading(false); + } + } + }; + + void loadDevices(); + + const handleDeviceChange = () => { + void loadDevices(); + }; + + navigator.mediaDevices.addEventListener("devicechange", handleDeviceChange); + + return () => { + mounted = false; + navigator.mediaDevices.removeEventListener("devicechange", handleDeviceChange); + }; + }, [enabled]); + + return { + devices, + selectedDeviceId, + setSelectedDeviceId, + isLoading, + error, + }; +} diff --git a/src/i18n/config.ts b/src/i18n/config.ts new file mode 100644 index 000000000..567d72efe --- /dev/null +++ b/src/i18n/config.ts @@ -0,0 +1,27 @@ +export const DEFAULT_LOCALE = "en" as const; + +export const SUPPORTED_LOCALES = [ + "en", + "es", + "fr", + "de", + "it", + "nl", + "ko", + "pt-BR", + "zh-CN", + "zh-TW", +] as const; + +export const I18N_NAMESPACES = [ + "common", + "launch", + "editor", + "timeline", + "settings", + "dialogs", + "shortcuts", +] as const; + +export type AppLocale = (typeof SUPPORTED_LOCALES)[number]; +export type I18nNamespace = (typeof I18N_NAMESPACES)[number]; diff --git a/src/i18n/locales/de/common.json b/src/i18n/locales/de/common.json new file mode 100644 index 000000000..e322c3d0a --- /dev/null +++ b/src/i18n/locales/de/common.json @@ -0,0 +1,26 @@ +{ + "app": { + "name": "Recordly", + "editorTitle": "Recordly Editor", + "subtitle": "Bildschirmaufzeichnung und -bearbeitung", + "language": "Sprache", + "manageRecordings": "Aufzeichnungsordner öffnen" + }, + "actions": { + "cancel": "Abbrechen", + "close": "Schließen", + "export": "Exportieren", + "load": "Laden", + "redo": "Wiederherstellen", + "reset": "Zurücksetzen", + "save": "Speichern", + "undo": "Rückgängig", + "delete": "Löschen", + "done": "Fertig" + }, + "errors": { + "invalidFileType": "Ungültiger Dateityp", + "failedToUploadImage": "Bild konnte nicht hochgeladen werden", + "fileReadError": "Beim Lesen der Datei ist ein Fehler aufgetreten." + } +} diff --git a/src/i18n/locales/de/dialogs.json b/src/i18n/locales/de/dialogs.json new file mode 100644 index 000000000..f99c5ab3b --- /dev/null +++ b/src/i18n/locales/de/dialogs.json @@ -0,0 +1,62 @@ +{ + "export": { + "pleaseTryAgain": "Bitte versuche es erneut", + "compilingGifProgress": "GIF wird kompiliert... {{progress}}%", + "compilingGifWait": "GIF wird kompiliert... Dies kann eine Weile dauern", + "takeMoment": "Dies kann einen Moment dauern...", + "exportFailed": "Export fehlgeschlagen", + "compilingGifTitle": "GIF wird kompiliert", + "exportingFormat": "Exportiere {{format}}", + "exportComplete": "Export abgeschlossen", + "formatReady": "Dein {{format}} ist bereit", + "showInFolder": "Im Ordner anzeigen", + "compiling": "Kompilieren", + "renderingFrames": "Frames rendern", + "processing": "Verarbeiten...", + "status": "Status", + "format": "Format", + "compilingStatus": "Kompilieren...", + "frames": "Frames", + "cancelExport": "Export abbrechen", + "reopenSaveDialog": "Speicherdialog erneut öffnen", + "savedSuccess": "{{format}} erfolgreich gespeichert!" + }, + "addFont": { + "title": "Google-Schriftart hinzufügen", + "heading": "Google-Schriftart hinzufügen", + "description": "Fügen Sie eine benutzerdefinierte Schriftart aus Google Fonts hinzu, um sie in Ihren Anmerkungen zu verwenden.", + "urlLabel": "Google Fonts-Import-URL", + "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap", + "urlHelp": "So erhalten Sie diese von Google Fonts: Wählen Sie eine Schriftart aus → Klicken Sie auf \"Schriftart abrufen\" → Kopieren Sie die @import-URL", + "nameLabel": "Anzeigename", + "namePlaceholder": "Meine benutzerdefinierte Schriftart", + "nameHelp": "So wird die Schriftart in der Schriftartauswahl angezeigt", + "adding": "Wird hinzugefügt...", + "addFont": "Schriftart hinzufügen", + "enterUrl": "Bitte gib eine Google Fonts-Import-URL ein", + "invalidUrl": "Bitte gib eine gültige Google Fonts-URL ein", + "enterName": "Bitte gib einen Schriftartnamen ein", + "extractFailed": "Schriftfamilie konnte nicht aus der URL extrahiert werden", + "addSuccess": "Schriftart \"{{name}}\" erfolgreich hinzugefügt", + "addFailed": "Schriftart konnte nicht hinzugefügt werden", + "loadTimeout": "Das Laden der Schriftart hat zu lange gedauert. Bitte überprüfen Sie die URL und versuchen Sie es erneut.", + "loadFailed": "Die Schriftart konnte nicht geladen werden. Bitte überprüfen Sie, ob die Google Fonts-URL korrekt ist." + }, + "shortcutsConfig": { + "title": "Tastaturkürzel", + "configurable": "Konfigurierbar", + "fixed": "Fest", + "pressEscToCancel": "Zum Abbrechen Esc drücken", + "clickToChange": "Zum Ändern anklicken", + "pressAKey": "Drücke eine Taste…", + "alreadyUsedBy": "Wird bereits von <strong>{{action}}</strong> verwendet", + "swap": "Tauschen", + "reserved": "Dieser Tastenbefehl ist für \"{{label}}\" reserviert und kann nicht neu zugewiesen werden.", + "saved": "Tastaturkürzel gespeichert", + "resetNotice": "Auf Standard-Tastaturkürzel zurücksetzen – zum Übernehmen auf Speichern klicken", + "instructions": "Klicken Sie auf ein Tastaturkürzel und drücken Sie dann die neue Tastenkombination. Drücken Sie Esc, um abzubrechen.", + "resetToDefaults": "Auf Standardwerte zurücksetzen", + "cancel": "Abbrechen", + "save": "Speichern" + } +} diff --git a/src/i18n/locales/de/editor.json b/src/i18n/locales/de/editor.json new file mode 100644 index 000000000..294c2ce5c --- /dev/null +++ b/src/i18n/locales/de/editor.json @@ -0,0 +1,152 @@ +{ + "playback": { + "play": "Wiedergabe", + "pause": "Pause", + "skipBack": "Zurück springen", + "skipForward": "Vorwärts springen", + "muteUnmute": "Stummschalten/Stummschaltung aufheben", + "volume": "Vorschaulautstärke" + }, + "annotations": { + "settings": "Einstellungen für Anmerkungen", + "active": "Aktiv", + "text": "Text", + "image": "Bild", + "arrow": "Pfeil", + "blur": "Weichzeichnen", + "textContent": "Textinhalt", + "textPlaceholder": "Geben Sie Ihren Text ein...", + "fontStyle": "Schriftart", + "selectStyle": "Auswahlstil", + "size": "Größe", + "toggleBold": "Fett umschalten", + "toggleItalic": "Kursiv umschalten", + "toggleUnderline": "Unterstreichung umschalten", + "alignLeft": "Linksbündig", + "alignCenter": "Zentriert", + "alignRight": "Rechtsbündig", + "textColor": "Textfarbe", + "background": "Hintergrund", + "none": "Keine", + "clearBackground": "Hintergrund löschen", + "uploadImage": "Bild hochladen", + "supportedFormats": "Unterstützte Formate: JPG, PNG, GIF, WebP", + "arrowDirection": "Pfeilrichtung", + "strokeWidth": "Strichbreite: {{width}}px", + "arrowColor": "Pfeilfarbe", + "deleteAnnotation": "Anmerkung löschen", + "shortcutsAndTips": "Tastenkombinationen & Tipps", + "tipSelectAnnotation": "Bewegen Sie den Abspielkopf auf den überlappenden Anmerkungsbereich und wählen Sie ein Element aus.", + "tipCycleForward": "Verwenden Sie die Tabulatortaste, um durch die überlappenden Elemente zu blättern.", + "tipCycleBackward": "Verwenden Sie Umschalt+Tab, um rückwärts zu blättern.", + "imageUploadSuccess": "Bild erfolgreich hochgeladen!", + "imageUploadError": "Bitte lade eine JPG-, PNG-, GIF- oder WebP-Bilddatei hoch.", + "blurStrength": "Unschärfestärke: {{strength}}", + "solidColor": "Einfarbig (Zensur)", + "borderRadius": "Randradius" + }, + "fontStyles": { + "classic": "Klassisch", + "editor": "Editor", + "strong": "Fett", + "typewriter": "Schreibmaschine", + "deco": "Dekorativ", + "simple": "Einfach", + "modern": "Modern", + "clean": "Clean" + }, + "format": { + "mp4Video": "MP4-Video", + "mp4Description": "Hochwertige Videodatei", + "gifAnimation": "GIF-Animation", + "gifDescription": "Animiertes Bild zum Teilen" + }, + "gifOptions": { + "frameRate": "Bildfrequenz", + "outputSize": "Ausgabegröße", + "outputDimensions": "Ausgabe: {{width}} × {{height}}px", + "loopAnimation": "Animation wiederholen", + "loopDescription": "GIF wird kontinuierlich abgespielt" + }, + "tutorial": { + "howTrimmingWorks": "So funktioniert das Zuschneiden", + "title": "So funktioniert das Zuschneiden", + "understanding": "So schneiden Sie unerwünschte Teile aus Ihrem Video heraus.", + "descriptionP1": "Das Trimm-Werkzeug funktioniert, indem Sie die Segmente definieren, die Sie", + "descriptionRemove": "entfernen", + "descriptionP2": "aus Ihrem Video", + "descriptionP3": "Jeder Teil der Zeitleiste, der von einem roten Trimm-Segment abgedeckt ist, wird beim Exportieren herausgeschnitten.", + "visualExample": "Visuelles Beispiel", + "removed": "ENTFERNT", + "kept": "Beibehalten", + "finalVideo": "Endgültiges Video", + "part": "Teil {{number}}", + "addTrimStep": "1. Trimmen hinzufügen", + "addTrimDesc": "Drücken Sie T oder klicken Sie auf das Scheren-Symbol, um einen Abschnitt zum Entfernen zu markieren.", + "adjustStep": "2. Anpassen", + "adjustDesc": "Ziehen Sie die Ränder des roten Bereichs, um genau den Bereich abzudecken, den Sie herausschneiden möchten." + }, + "feedback": { + "trigger": "Feedback", + "title": "Feedback & Kontakt", + "description": "Wenden Sie sich direkt an uns oder erstellen Sie ein Ticket, wenn etwas nicht funktioniert oder fehlt.", + "emailLabel": "E-Mail", + "xLabel": "X", + "reportIssue": "Problem melden / Feedback senden", + "openFailed": "Link konnte nicht geöffnet werden." + }, + "keyboardShortcuts": { + "trigger": "Tastenkombinationen", + "title": "Tastenkombinationen", + "description": "Kurzanleitung für die Steuerelemente der Zeitleiste und des Editors.", + "customizeTooltip": "Tastenkombinationen anpassen", + "customize": "Anpassen", + "panTimeline": "Zeitleiste verschieben", + "zoomTimeline": "Zeitleiste zoomen", + "cycleAnnotations": "Anmerkungen durchlaufen", + "tab": "Registerkarte" + }, + "actions": { + "saveAgain": "Erneut speichern", + "showInFolder": "In Ordner anzeigen" + }, + "project": { + "untitled": "Unbenannt", + "unsavedChangesTitle": "Ungespeicherte Änderungen", + "unsavedChangesDescription": "Möchtest du dein aktuelles Projekt speichern, bevor du {{action}}?", + "discardChanges": "Änderungen verwerfen", + "saveProject": "Projekt speichern" + }, + "exportTips": { + "autoZooms": "Tipp: Automatisch angewendete Zooms können in den Einstellungen deaktiviert werden", + "experimentalBuilds": "Tipp: Aktiviere den Zugriff auf experimentelle Builds in den Einstellungen", + "cursorAppearance": "Tipp: Du kannst das Aussehen deines Cursors anpassen" + }, + "account": { "title": "Konto", "comingSoon": "Konto demnächst verfügbar" }, + "nativeCaptureUnavailable": { + "title": "Es ist nichts kaputt, aber wir können kein animiertes Cursor-Overlay rendern.", + "description": "Ihr Gerät unterstützt keine native Erfassung. Dies kann verschiedene Gründe haben, die wir noch nicht ermittelt haben. Recordly funktioniert weiterhin, aber eine Cursor-Glättung ist nicht möglich.", + "confirm": "Okay" + }, + "exportStatus": { + "exporting": "Exportieren", + "renderingFile": "Datei wird gerendert.", + "preparing": "Export wird vorbereitet...", + "completePercent": "{{percent}} % abgeschlossen", + "issue": "Problem beim Export", + "complete": "Export abgeschlossen", + "savedSuccessfully": "Deine Datei wurde erfolgreich gespeichert." + }, + "export": { + "processingAudioEdits": "Audio mit Geschwindigkeits-/Overlay-Bearbeitungen wird verarbeitet" + }, + "toolbar": { + "addLayer": "Ebene hinzufügen", + "splitClip": "Clip teilen (C)" + }, + "timeline": { + "expand": "Zeitleiste erweitern", + "collapse": "Zeitleiste reduzieren" + }, + "openRecordingsFolder": "Aufnahmeordner öffnen" +} diff --git a/src/i18n/locales/de/launch.json b/src/i18n/locales/de/launch.json new file mode 100644 index 000000000..bae512379 --- /dev/null +++ b/src/i18n/locales/de/launch.json @@ -0,0 +1,95 @@ +{ + "recording": { + "disableSystemAudio": "System-Audio deaktivieren", + "enableSystemAudio": "System-Audio aktivieren", + "disableMicrophone": "Mikrofon deaktivieren", + "enableMicrophone": "Mikrofon aktivieren", + "micToggleDisabledTip": "Mikrofon kann während der Aufnahme nicht umgeschaltet werden", + "disableWebcam": "Webcam-Overlay deaktivieren", + "enableWebcam": "Webcam-Overlay aktivieren", + "countdownDelay": "Countdown-Verzögerung", + "noDelay": "Keine Verzögerung", + "record": "Aufnehmen", + "recordingFolder": "Pfad zu den Aufzeichnungen: {{path}}", + "chooseRecordingsFolder": "Pfad zu den Aufzeichnungen auswählen", + "folderPath": "Pfad: /{{name}}/", + "openVideoFile": "Videodatei öffnen", + "openProject": "Projekt öffnen", + "hideHudFromVideo": "HUD in der Aufnahme ausblenden", + "showHudInVideo": "HUD in der Aufnahme anzeigen", + "hideHud": "HUD ausblenden", + "closeApp": "App schließen", + "screens": "Bildschirme", + "windows": "Fenster", + "screen": "Bildschirm", + "window": "Fenster", + "noSourcesFound": "Keine Quellen gefunden", + "microphone": "Mikrofon", + "turnOffMicrophone": "Mikrofon ausschalten", + "selectMicToEnable": "Mikrofon zum Aktivieren auswählen", + "noMicrophonesFound": "Keine Mikrofone gefunden", + "webcam": "Webcam", + "turnOffWebcam": "Webcam ausschalten", + "hideFloatingWebcamPreview": "Schwebende Vorschau ausblenden", + "showFloatingWebcamPreview": "Schwebende Vorschau anzeigen", + "selectWebcamToEnable": "Webcam zum Aktivieren auswählen", + "noWebcamsFound": "Keine Webcams gefunden", + "recordingsFolder": "Pfad zu den Aufzeichnungen", + "language": "Sprache", + "paused": "PAUSIERT", + "rec": "AUFNAHME", + "resume": "Fortsetzen", + "pause": "Pause", + "stop": "Stopp", + "cancel": "Abbrechen", + "more": "Mehr", + "update": { + "update": "Aktualisieren", + "updated": "Aktualisiert", + "idleTitle": "Nach Updates suchen.", + "checkingTitle": "Nach Updates suchen...", + "downloadingTitle": "Update wird heruntergeladen...", + "errorTitle": "Update-Prüfung fehlgeschlagen. Klicken Sie hier, um es erneut zu versuchen.", + "upToDateTitle": "Recordly {{version}} ist auf dem neuesten Stand.", + "availableTitle": "Recordly {{version}} ist verfügbar.", + "availableGenericTitle": "Ein Update ist verfügbar." + } + }, + "sourceSelector": { + "loadingSources": "Quellen werden geladen...", + "screens": "Bildschirme", + "windows": "Fenster", + "noScreensAvailable": "Keine Bildschirme verfügbar", + "noWindowsAvailable": "Keine Fenster verfügbar", + "windowsNote": "Es können nur sichtbare (nicht minimierte) Fenster aufgezeichnet werden.", + "windowPlaceholder": "Fenster", + "cancel": "Abbrechen", + "share": "Teilen" + }, + "permissions": { + "screenRecordingNeeded": "Recordly benötigt die Berechtigung zur Bildschirmaufzeichnung, bevor Sie beginnen können. Die Systemeinstellungen wurden geöffnet. Schließen Sie Recordly nach der Aktivierung und starten Sie es erneut.", + "screenRecordingMissing": "Die Berechtigung für die Bildschirmaufzeichnung fehlt noch. Die Systemeinstellungen wurden erneut geöffnet. Aktivieren Sie die Berechtigung, schließen Sie Recordly anschließend und starten Sie die Anwendung vor der Aufzeichnung erneut.", + "accessibilityNeeded": "Recordly benötigt außerdem die Berechtigung für Barrierefreiheit, um die Cursorbewegungen zu verfolgen. Die Systemeinstellungen wurden geöffnet. Schließen Sie Recordly nach der Aktivierung und starten Sie es erneut.", + "accessibilityMissing": "Die Berechtigung für die Barrierefreiheit fehlt noch. Die Systemeinstellungen wurden erneut geöffnet. Aktivieren Sie diese, schließen Sie Recordly anschließend und öffnen Sie die App vor der Aufnahme erneut.", + "selectSource": "Bitte wählen Sie eine Quelle für die Aufnahme aus", + "systemAudioUnavailable": "Für diese Quelle ist kein Systemton verfügbar. Die Aufnahme wird ohne Systemton fortgesetzt.", + "microphoneDenied": "Der Zugriff auf das Mikrofon wurde verweigert. Die Aufzeichnung wird ohne Mikrofonton fortgesetzt.", + "failedToStart": "Die Aufzeichnung konnte nicht gestartet werden: {{error}}", + "failedToStartGeneric": "Die Aufnahme konnte nicht gestartet werden" + }, + "updateToast": { + "availableTitle": "Update verfügbar", + "experimentalAvailableTitle": "Experimentelles Update verfügbar", + "experimentalDescription": "Sie haben experimentelle Updates aktiviert und können daher das neueste Update von Recordly testen, bevor es allgemein verfügbar ist.", + "downloadingTitle": "Update wird heruntergeladen", + "readyTitle": "Bereit zum Neustart", + "checkErrorTitle": "Updates konnten nicht geprüft werden", + "downloadErrorTitle": "Das Update konnte nicht heruntergeladen werden", + "experimentalBadge": "Experimentell", + "previewBadge": "Vorschau", + "notNow": "Nicht jetzt", + "updateNow": "Jetzt aktualisieren", + "restartToUpdate": "Zum Aktualisieren neu starten", + "tryAgain": "Erneut versuchen" + } +} diff --git a/src/i18n/locales/de/settings.json b/src/i18n/locales/de/settings.json new file mode 100644 index 000000000..6d5bac29f --- /dev/null +++ b/src/i18n/locales/de/settings.json @@ -0,0 +1,265 @@ +{ + "zoom": { + "level": "Zoomstufe", + "selectRegion": "Zoombereich zum Anpassen auswählen", + "deleteZoom": "Zoom löschen", + "modeAuto": "Automatisch", + "modeManual": "Manuell", + "modeManualDescription": "Legen Sie einen festen Fokuspunkt für diesen Zoom fest", + "modeAutoDescription": "Die Kamera zentriert sich neu, wenn sich der Cursor dem Rand der gezoomten Ansicht nähert" + }, + "trim": { + "deleteRegion": "Trimmbereich löschen" + }, + "speed": { + "playbackSpeed": "Wiedergabegeschwindigkeit", + "selectRegion": "Wähle einen Geschwindigkeitsbereich zum Anpassen aus", + "deleteRegion": "Geschwindigkeitsbereich löschen", + "label": "Geschwindigkeit" + }, + "clip": { + "title": "Clip", + "mute": "Stummschalten", + "mutedState": "Audio ist stummgeschaltet", + "unmutedState": "Audio wird wiedergegeben", + "separateClipFromAudio": "Clip vom Audio trennen", + "delete": "Clip löschen" + }, + "effects": { + "title": "Videoeffekte", + "show": "Anzeigen", + "showCursor": "Cursor anzeigen", + "loopCursor": "Cursor wiederholen", + "cursorStyle": "Cursorstil", + "cursorStyleOptions": { + "macos": "macOS", + "tahoe": "Tahoe", + "tahoe-inverted": "Tahoe invertiert", + "windows11": "Windows 11", + "dot": "Punkt", + "figma": "Minimal", + "lavender": "Lavender", + "parched": "Parched", + "chooper": "Chooper", + "amongus": "Among Us", + "turtle": "Turtle" + }, + "backgroundBlur": "Unschärfe", + "zoomMotionBlur": "Zoom-Bewegungsunschärfe", + "temporalZoomMotionBlur": "Zeitliche Zoom-Bewegungsunschärfe", + "temporalZoomMotionBlurDescription": "Steuert das Verschlussfenster und die Bildausschnitte, die vom neueren Zoom-Bewegungsunschärfe-Durchlauf verwendet werden.", + "zoomMotionBlurSamples": "Unschärfe-Samples", + "zoomMotionBlurShutter": "Verschluss", + "auto": "Auto", + "connectZooms": "Zooms verbinden", + "connectZoomsDescription": "Füge aufeinanderfolgende Zoom-Bereiche zu einer kontinuierlichen Kamerabewegung zusammen.", + "autoApplyFreshRecordingZooms": "Neue Aufzeichnungszooms automatisch anwenden", + "autoApplyFreshRecordingZoomsDescription": "Schlägt beim Öffnen einer neuen Aufzeichnung automatisch Zooms zur Randzentrierung vor.", + "zoomGeneralTitle": "Allgemein", + "zoomGeneralDescription": "Globale Bewegungseinstellungen für jeden Zoomübergang.", + "zoomInTitle": "Vergrößern", + "zoomInDescription": "Steuern Sie, wie die Kamera in einen Zoom-Bereich einzoomt.", + "zoomOutTitle": "Verkleinern", + "zoomOutDescription": "Steuern Sie, wie die Kamera aus einem Zoom-Bereich herauszoomt.", + "connectedZoomTitle": "Zwischen Zooms", + "connectedZoomDescription": "Passen Sie den Übergang zwischen aufeinanderfolgenden Zoom-Bereichen an, wenn die Verbindung aktiviert ist.", + "motionPresetsTitle": "Bewegungsvoreinstellungen", + "motionPresetsZoomHint": "Zoom-Bewegungsvoreinstellungen sind in den Einstellungen verfügbar.", + "animationPresets": "Animationsvoreinstellungen", + "cursorMotionPresets": "Cursor-Bewegungsvoreinstellungen", + "motionPresets": { + "focused": { + "label": "Fokussiert", + "description": "Schnellere Bewegung für Demos, Walkthroughs und alltägliche Aufnahmen." + }, + "smooth": { + "label": "Sanft", + "description": "Sanftere Bewegung für Präsentationen, Keynote-artige Videos und ausgefeilte Enthüllungen." + } + }, + "zoomInDuration": "Dauer des Heranzoomens", + "zoomInOverlap": "Überlappung beim Heranzoomen", + "zoomOutDuration": "Zoom-Out-Dauer", + "zoomInEasing": "Zoom-In-Kurve", + "zoomOutEasing": "Zoom-Out-Kurve", + "connectedZoomGap": "Lücke bei verbundenem Zoom", + "connectedZoomDuration": "Dauer bei verbundenem Zoom", + "connectedZoomEasing": "Kurve für verbundenen Schwenk", + "zoomEasingOptions": { + "recordly": "Recordly", + "glide": "Glide", + "smooth": "Smooth", + "snappy": "Snappy", + "linear": "Linear" + }, + "cursorSize": "Cursorgröße", + "cursorSmoothing": "Cursor-Glättung", + "cursorSpringStiffness": "Cursor-Federkonstante", + "cursorSpringDamping": "Cursor-Federdämpfung", + "cursorSpringMass": "Cursor-Federmasse", + "off": "Aus", + "cursorClickEffects": { + "title": "Klick-Effekte", + "advanced": "Erweitert", + "advancedShow": "Erweiterte Steuerelemente für Klick-Effekte anzeigen", + "advancedHide": "Erweiterte Steuerelemente für Klick-Effekte ausblenden", + "color": "Effekt-Farbe", + "size": "Effekt-Größe", + "opacity": "Effekt-Deckkraft", + "duration": "Effekt-Dauer", + "none": { + "label": "Aus", + "description": "Keine Klickgrafik. Beim Klicken ändert sich lediglich die Cursorbewegung." + }, + "ripple": { + "label": "Ripple", + "description": "Von jedem Klick gehen kreisförmige Ringe aus, sodass die Töne auch in Bewegung deutlich zu hören sind." + }, + "spotlight": { + "label": "Im Fokus", + "description": "Um den Zeiger blitzt ein sanfter Lichtschein auf, um den angeklickten Bereich hervorzuheben." + }, + "echo": { + "label": "Echo", + "description": "Ein Paar sanfter Ringe, die sich mit einem klareren Impuls nach außen ausbreiten." + } + }, + "cursorMotionBlur": "Cursor-Bewegungsunschärfe", + "cursorClickBounce": "Cursor-Klick-Rückprall", + "cursorClickBounceDuration": "Sprunggeschwindigkeit", + "cursorSway": "Cursor-Schwankung", + "webcam": "Webcam-Overlay", + "webcamFootage": "Webcam-Aufnahmen", + "webcamFootageDescription": "Zu diesem Video sind keine Webcam-Aufnahmen verknüpft", + "uploadWebcamFootage": "Aufnahmen hochladen", + "replaceWebcamFootage": "Aufnahmen ersetzen", + "removeWebcamFootage": "Aufnahmen entfernen", + "webcamFootageAdded": "Webcam-Aufnahmen verknüpft", + "webcamFootageRemoved": "Webcam-Aufnahmen entfernt", + "webcamSize": "Webcam-Größe", + "webcamWidth": "Webcam-Breite", + "webcamHeight": "Webcam-Höhe", + "webcamCrop": "Webcam zuschneiden", + "webcamReactToZoom": "Webcam reagiert auf Zoom", + "webcamMirror": "Webcam spiegeln", + "webcamRoundness": "Webcam-Rundung", + "webcamShadow": "Webcam-Schatten", + "shadow": "Schatten", + "radius": "Radius", + "roundness": "Rundung", + "padding": "Abstand", + "paddingLinked": "Verknüpft (gleichmäßig)", + "paddingUnlinked": "Unverknüpft (asymmetrisch)", + "paddingTop": "Oben", + "paddingBottom": "Unten", + "paddingLeft": "Links", + "paddingRight": "Rechts", + "removeBackground": "Hintergrund entfernen" + }, + "sections": { + "scene": "Szene", + "captions": "Untertitel", + "caption": "Untertitel", + "zoom": "Zoom", + "cursor": "Cursor", + "webcam": "Webcam", + "frame": "Bild", + "crop": "Zuschneiden" + }, + "captions": { + "selectOnTimeline": "Wählen Sie einen Untertitel auf der Zeitleiste aus, um ihn zu bearbeiten.", + "enabled": "Anzeigen", + "timelineQuickAdd": "Mit der Maus darüberfahren, um zur Zeitleiste hinzuzufügen", + "language": "Sprache", + "downloading": "Wird heruntergeladen...", + "deleteModel": "Modell löschen", + "clearModel": "Modell entfernen", + "downloadModel": "Modell herunterladen", + "generating": "Wird generiert...", + "generateFull": "Untertitel generieren", + "regenerateFull": "Untertitel neu generieren", + "clearFull": "Untertitel entfernen", + "fontSettings": "Schriftart-Einstellungen", + "defaultFont": "Standard", + "fontFamily": "Schriftart", + "fontSize": "Schriftgröße", + "rowCount": "Zeilen", + "animation": "Animation", + "animationOff": "Aus", + "animationFade": "Einblenden", + "animationRise": "Aufsteigen", + "animationPop": "Aufspringen", + "bottomOffset": "Versatz unten", + "maxWidth": "Maximale Breite", + "boxRadius": "Radius der Box", + "backgroundOpacity": "Hintergrundtransparenz", + "textColor": "Textfarbe", + "editor": { + "text": "Text", + "start": "Start", + "end": "Ende", + "split": "Teilen", + "merge": "Zusammenführen", + "delete": "Löschen" + } + }, + "crop": { + "title": "Video zuschneiden", + "instruction": "Ziehe an jeder Seite, um den Zuschneidebereich anzupassen", + "top": "Oben", + "bottom": "Unten", + "left": "Links", + "right": "Rechts", + "openEditor": "Zuschneide-Editor öffnen" + }, + "background": { + "title": "Hintergrund", + "image": "Bild", + "color": "Farbe", + "gradient": "Farbverlauf", + "wallpaperPreview": "Vorschau des Hintergrundbildes", + "uploadCustom": "Benutzerdefiniertes Bild hochladen", + "uploadSuccess": "Benutzerdefiniertes Bild erfolgreich hochgeladen!", + "uploadError": "Bitte lade eine JPG- oder JPEG-Bilddatei hoch.", + "uploadErrorDescription": "Es werden nur JPG- und JPEG-Bilder unterstützt." + }, + "export": { + "title": "Exportieren", + "mp4": "MP4", + "gif": "GIF", + "quality": { + "low": "Niedrig", + "medium": "Mittel", + "high": "Hoch", + "original": "Original" + }, + "fpsTitle": "FPS", + "loop": "Wiederholen", + "outputDimensions": "Ausgabe: {{dimensions}}px", + "sizePresetOriginalShort": "Orig", + "sizePresetMediumShort": "Med", + "sizePresetLargeShort": "Lar", + "loadProject": "Projekt laden", + "saveProject": "Projekt speichern", + "exportVideo": "{{format}} exportieren", + "reportBug": "Fehler melden", + "starOnGithub": "Auf GitHub markieren" + }, + "audio": { + "title": "Audio", + "volumeTitle": "Audio", + "volume": "Lautstärke", + "normalize": "Normalisieren", + "sourceTracksTitle": "Clip-Quellaudio", + "systemLabel": "Quellsystem", + "micLabel": "Quellmikrofon", + "mixedLabel": "Quelle", + "deleteRegion": "Audio löschen" + }, + "updates": { + "experimentalDescription": "Sie haben experimentelle Updates aktiviert und können daher das neueste Update von Recordly testen, bevor es allgemein verfügbar ist.", + "title": "Updates", + "experimental": "Experimentelle Updates", + "saveFailed": "Update-Kanal konnte nicht geändert werden." + } +} diff --git a/src/i18n/locales/de/shortcuts.json b/src/i18n/locales/de/shortcuts.json new file mode 100644 index 000000000..fc00e5a5b --- /dev/null +++ b/src/i18n/locales/de/shortcuts.json @@ -0,0 +1,16 @@ +{ + "actions": { + "addZoom": "Zoom hinzufügen", + "addTrim": "Trimmen hinzufügen", + "addSpeed": "Geschwindigkeit hinzufügen", + "addAnnotation": "Anmerkung hinzufügen", + "addKeyframe": "Keyframe hinzufügen", + "deleteSelected": "Ausgewählte löschen", + "playPause": "Wiedergabe / Pause", + "cycleForward": "Anmerkungen vorwärts durchlaufen", + "cycleBackward": "Anmerkungen rückwärts durchlaufen", + "deleteSelectedAlt": "Ausgewählte löschen (Alt)", + "panTimeline": "Zeitleiste verschieben", + "zoomTimeline": "Zeitleiste zoomen" + } +} diff --git a/src/i18n/locales/de/timeline.json b/src/i18n/locales/de/timeline.json new file mode 100644 index 000000000..6e916dfca --- /dev/null +++ b/src/i18n/locales/de/timeline.json @@ -0,0 +1,41 @@ +{ + "zoom": { + "cannotPlace": "Zoom kann hier nicht platziert werden", + "existsOrNoSpace": "Zoom existiert bereits an dieser Stelle oder es ist nicht genügend Platz verfügbar.", + "suggestHandlerUnavailable": "Zoom-Vorschlags-Handler nicht verfügbar", + "noTelemetry": "Keine Cursor-Telemetrie verfügbar", + "recordFirst": "Nehmen Sie zuerst einen Screencast auf, um cursorbasierte Vorschläge zu generieren.", + "noUsableTelemetry": "Keine verwertbare Cursor-Telemetrie", + "notEnoughMovement": "Die Aufnahme enthält nicht genügend Daten zur Cursorbewegung.", + "noInteractionMoments": "Keine eindeutigen Interaktionsmomente gefunden", + "tryRecording": "Versuchen Sie eine Aufnahme mit Pausen oder Klicks an wichtigen Stellen.", + "noAutoZoomSlots": "Keine Auto-Zoom-Slots verfügbar", + "dwellPointsOverlap": "Erkannte Verweildauerpunkte überschneiden sich mit bestehenden Zoom-Bereichen.", + "addedSuggestions": "{{count}} interaktionsbasierte Zoomvorschläge hinzugefügt", + "label": "Zoom {{index}}", + "addZoom": "Zoom hinzufügen (Z)", + "suggestZooms": "Zooms vom Cursor vorschlagen" + }, + "trim": { + "cannotPlace": "Trim kann hier nicht platziert werden", + "existsOrNoSpace": "Trim existiert bereits an dieser Stelle oder es ist nicht genügend Platz verfügbar.", + "label": "Trim {{index}}", + "addTrim": "Trim hinzufügen (T)" + }, + "speed": { + "cannotPlace": "Geschwindigkeit kann hier nicht platziert werden", + "existsOrNoSpace": "Geschwindigkeitsbereich existiert bereits an dieser Stelle oder es ist nicht genügend Platz verfügbar.", + "label": "Geschwindigkeit" + }, + "annotation": { + "label": "Anmerkung", + "image": "Bild", + "addAnnotation": "Anmerkung hinzufügen (A)" + }, + "audio": { + "label": "Audio" + }, + "addSpeed": "Geschwindigkeit hinzufügen (S)", + "resizeLeft": "Größe links anpassen", + "resizeRight": "Größe rechts anpassen" +} diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json new file mode 100644 index 000000000..98bb2c7aa --- /dev/null +++ b/src/i18n/locales/en/common.json @@ -0,0 +1,26 @@ +{ + "app": { + "name": "Recordly", + "editorTitle": "Recordly Editor", + "subtitle": "Screen recording and editing", + "language": "Language", + "manageRecordings": "Open recordings folder" + }, + "actions": { + "cancel": "Cancel", + "close": "Close", + "export": "Export", + "load": "Load", + "redo": "Redo", + "reset": "Reset", + "save": "Save", + "undo": "Undo", + "delete": "Delete", + "done": "Done" + }, + "errors": { + "invalidFileType": "Invalid file type", + "failedToUploadImage": "Failed to upload image", + "fileReadError": "There was an error reading the file." + } +} diff --git a/src/i18n/locales/en/dialogs.json b/src/i18n/locales/en/dialogs.json new file mode 100644 index 000000000..97d931a20 --- /dev/null +++ b/src/i18n/locales/en/dialogs.json @@ -0,0 +1,62 @@ +{ + "export": { + "pleaseTryAgain": "Please try again", + "compilingGifProgress": "Compiling GIF... {{progress}}%", + "compilingGifWait": "Compiling GIF... This may take a while", + "takeMoment": "This may take a moment...", + "exportFailed": "Export Failed", + "compilingGifTitle": "Compiling GIF", + "exportingFormat": "Exporting {{format}}", + "exportComplete": "Export Complete", + "formatReady": "Your {{format}} is ready", + "showInFolder": "Show in Folder", + "compiling": "Compiling", + "renderingFrames": "Rendering Frames", + "processing": "Processing...", + "status": "Status", + "format": "Format", + "compilingStatus": "Compiling...", + "frames": "Frames", + "cancelExport": "Cancel Export", + "reopenSaveDialog": "Open Save Dialog Again", + "savedSuccess": "{{format}} saved successfully!" + }, + "addFont": { + "title": "Add Google Font", + "heading": "Add Google Font", + "description": "Add a custom font from Google Fonts to use in your annotations.", + "urlLabel": "Google Fonts Import URL", + "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap", + "urlHelp": "Get this from Google Fonts: Select a font → Click \"Get font\" → Copy the @import URL", + "nameLabel": "Display Name", + "namePlaceholder": "My Custom Font", + "nameHelp": "This is how the font will appear in the font selector", + "adding": "Adding...", + "addFont": "Add Font", + "enterUrl": "Please enter a Google Fonts import URL", + "invalidUrl": "Please enter a valid Google Fonts URL", + "enterName": "Please enter a font name", + "extractFailed": "Could not extract font family from URL", + "addSuccess": "Font \"{{name}}\" added successfully", + "addFailed": "Failed to add font", + "loadTimeout": "Font took too long to load. Please check the URL and try again.", + "loadFailed": "The font could not be loaded. Please verify the Google Fonts URL is correct." + }, + "shortcutsConfig": { + "title": "Keyboard Shortcuts", + "configurable": "Configurable", + "fixed": "Fixed", + "pressEscToCancel": "Press Esc to cancel", + "clickToChange": "Click to change", + "pressAKey": "Press a key…", + "alreadyUsedBy": "Already used by <strong>{{action}}</strong>", + "swap": "Swap", + "reserved": "This shortcut is reserved for \"{{label}}\" and cannot be reassigned.", + "saved": "Keyboard shortcuts saved", + "resetNotice": "Reset to default shortcuts — click Save to apply", + "instructions": "Click a shortcut then press the new key combination. Press Esc to cancel.", + "resetToDefaults": "Reset to defaults", + "cancel": "Cancel", + "save": "Save" + } +} diff --git a/src/i18n/locales/en/editor.json b/src/i18n/locales/en/editor.json new file mode 100644 index 000000000..099393ba0 --- /dev/null +++ b/src/i18n/locales/en/editor.json @@ -0,0 +1,153 @@ +{ + "playback": { + "play": "Play", + "pause": "Pause", + "skipBack": "Skip Back", + "skipForward": "Skip Forward", + "muteUnmute": "Mute/Unmute", + "volume": "Preview volume" + }, + "annotations": { + "settings": "Annotation Settings", + "active": "Active", + "text": "Text", + "image": "Image", + "arrow": "Arrow", + "blur": "Blur", + "textContent": "Text Content", + "textPlaceholder": "Enter your text...", + "fontStyle": "Font Style", + "selectStyle": "Select style", + "size": "Size", + "toggleBold": "Toggle bold", + "toggleItalic": "Toggle italic", + "toggleUnderline": "Toggle underline", + "alignLeft": "Align left", + "alignCenter": "Align center", + "alignRight": "Align right", + "textColor": "Text Color", + "background": "Background", + "none": "None", + "clearBackground": "Clear Background", + "uploadImage": "Upload Image", + "supportedFormats": "Supported formats: JPG, PNG, GIF, WebP", + "arrowDirection": "Arrow Direction", + "strokeWidth": "Stroke Width: {{width}}px", + "arrowColor": "Arrow Color", + "deleteAnnotation": "Delete Annotation", + "shortcutsAndTips": "Shortcuts & Tips", + "tipSelectAnnotation": "Move playhead to overlapping annotation section and select an item.", + "tipCycleForward": "Use Tab to cycle through overlapping items.", + "tipCycleBackward": "Use Shift+Tab to cycle backwards.", + "imageUploadSuccess": "Image uploaded successfully!", + "imageUploadError": "Please upload a JPG, PNG, GIF, or WebP image file.", + "blurStrength": "Blur Strength: {{strength}}", + "solidColor": "Solid Color (Censorship)", + "borderRadius": "Border Radius" + }, + + "fontStyles": { + "classic": "Classic", + "editor": "Editor", + "strong": "Strong", + "typewriter": "Typewriter", + "deco": "Deco", + "simple": "Simple", + "modern": "Modern", + "clean": "Clean" + }, + "format": { + "mp4Video": "MP4 Video", + "mp4Description": "High quality video file", + "gifAnimation": "GIF Animation", + "gifDescription": "Animated image for sharing" + }, + "gifOptions": { + "frameRate": "Frame Rate", + "outputSize": "Output Size", + "outputDimensions": "Output: {{width}} × {{height}}px", + "loopAnimation": "Loop Animation", + "loopDescription": "GIF will play continuously" + }, + "tutorial": { + "howTrimmingWorks": "How trimming works", + "title": "How Trimming Works", + "understanding": "Understanding how to cut out unwanted parts of your video.", + "descriptionP1": "The Trim tool works by defining the segments you want to", + "descriptionRemove": "remove", + "descriptionP2": "from your video.", + "descriptionP3": "Any part of the timeline that is covered by a red trim segment will be cut out when you export.", + "visualExample": "Visual Example", + "removed": "REMOVED", + "kept": "Kept", + "finalVideo": "Final Video", + "part": "Part {{number}}", + "addTrimStep": "1. Add Trim", + "addTrimDesc": "Press T or click the scissors icon to mark a section for removal.", + "adjustStep": "2. Adjust", + "adjustDesc": "Drag the edges of the red region to cover exactly what you want to cut out." + }, + "feedback": { + "trigger": "Feedback", + "title": "Feedback & contact", + "description": "Reach out directly or open an issue if something is broken or missing.", + "emailLabel": "Email", + "xLabel": "X", + "reportIssue": "Report issue / send feedback", + "openFailed": "Failed to open link." + }, + "keyboardShortcuts": { + "trigger": "Shortcuts", + "title": "Keyboard Shortcuts", + "description": "Quick reference for the timeline and editor controls.", + "customizeTooltip": "Customize shortcuts", + "customize": "Customize", + "panTimeline": "Pan Timeline", + "zoomTimeline": "Zoom Timeline", + "cycleAnnotations": "Cycle Annotations", + "tab": "Tab" + }, + "actions": { + "saveAgain": "Save Again", + "showInFolder": "Show In Folder" + }, + "project": { + "untitled": "Untitled", + "unsavedChangesTitle": "Unsaved changes", + "unsavedChangesDescription": "Save your current project before you {{action}}?", + "discardChanges": "Discard changes", + "saveProject": "Save project" + }, + "exportTips": { + "autoZooms": "Tip: Turn off auto-applied zooms in settings", + "experimentalBuilds": "Tip: Try experimental builds by turning on access in settings", + "cursorAppearance": "Tip: You can customise your cursor appearance" + }, + "account": { "title": "Account", "comingSoon": "Account coming soon" }, + "nativeCaptureUnavailable": { + "title": "Nothing’s broken, but we won’t be able to render an animated cursor overlay.", + "description": "Your device does not support native capture. This could be for a variety of reasons we haven’t figured out yet. This doesn’t break Recordly, but it does make cursor smoothing impossible.", + "confirm": "Okay" + }, + "exportStatus": { + "exporting": "Exporting", + "renderingFile": "Rendering your file.", + "preparing": "Preparing export...", + "completePercent": "{{percent}}% complete", + "issue": "Export issue", + "complete": "Export complete", + "savedSuccessfully": "Your file was saved successfully." + }, + "export": { + "processingAudioEdits": "Processing audio with speed/overlay edits" + }, + "toolbar": { + "addLayer": "Add Layer", + "splitClip": "Split Clip (C)" + }, + "timeline": { + "expand": "Expand Timeline", + "collapse": "Collapse Timeline" + }, + "openRecordingsFolder": "Open recordings folder" +} diff --git a/src/i18n/locales/en/launch.json b/src/i18n/locales/en/launch.json new file mode 100644 index 000000000..d4f7aba94 --- /dev/null +++ b/src/i18n/locales/en/launch.json @@ -0,0 +1,95 @@ +{ + "recording": { + "disableSystemAudio": "Disable system audio", + "enableSystemAudio": "Enable system audio", + "disableMicrophone": "Disable microphone", + "enableMicrophone": "Enable microphone", + "micToggleDisabledTip": "Microphone cannot be toggled during recording", + "disableWebcam": "Disable webcam overlay", + "enableWebcam": "Enable webcam overlay", + "countdownDelay": "Countdown delay", + "noDelay": "No delay", + "record": "Record", + "recordingFolder": "Recordings Path: {{path}}", + "chooseRecordingsFolder": "Choose recordings path", + "folderPath": "Path: /{{name}}/", + "openVideoFile": "Open video file", + "openProject": "Open project", + "hideHudFromVideo": "Hide HUD from recording", + "showHudInVideo": "Show HUD in recording", + "hideHud": "Hide HUD", + "closeApp": "Close App", + "screens": "Screens", + "windows": "Windows", + "screen": "Screen", + "window": "Window", + "noSourcesFound": "No sources found", + "microphone": "Microphone", + "turnOffMicrophone": "Turn Off Microphone", + "selectMicToEnable": "Select a microphone to enable", + "noMicrophonesFound": "No microphones found", + "webcam": "Webcam", + "turnOffWebcam": "Turn Off Webcam", + "hideFloatingWebcamPreview": "Hide Floating Preview", + "showFloatingWebcamPreview": "Show Floating Preview", + "selectWebcamToEnable": "Select a webcam to enable", + "noWebcamsFound": "No webcams found", + "recordingsFolder": "Recordings Path", + "language": "Language", + "paused": "PAUSED", + "rec": "REC", + "resume": "Resume", + "pause": "Pause", + "stop": "Stop", + "cancel": "Cancel", + "more": "More", + "update": { + "update": "Update", + "updated": "Updated", + "idleTitle": "Check for updates.", + "checkingTitle": "Checking for updates...", + "downloadingTitle": "Downloading update...", + "errorTitle": "Update check failed. Click to retry.", + "upToDateTitle": "Recordly {{version}} is up to date.", + "availableTitle": "Recordly {{version}} is available.", + "availableGenericTitle": "An update is available." + } + }, + "sourceSelector": { + "loadingSources": "Loading sources...", + "screens": "Screens", + "windows": "Windows", + "noScreensAvailable": "No screens available", + "noWindowsAvailable": "No windows available", + "windowsNote": "Only visible (non-minimized) windows can be recorded.", + "windowPlaceholder": "Window", + "cancel": "Cancel", + "share": "Share" + }, + "permissions": { + "screenRecordingNeeded": "Recordly needs Screen Recording permission before you start. System Settings has been opened. After enabling it, quit and reopen Recordly.", + "screenRecordingMissing": "Screen Recording permission is still missing. System Settings has been opened again. Enable it, then quit and reopen Recordly before recording.", + "accessibilityNeeded": "Recordly also needs Accessibility permission for cursor tracking. System Settings has been opened. After enabling it, quit and reopen Recordly.", + "accessibilityMissing": "Accessibility permission is still missing. System Settings has been opened again. Enable it, then quit and reopen Recordly before recording.", + "selectSource": "Please select a source to record", + "systemAudioUnavailable": "System audio is not available for this source. Recording will continue without system audio.", + "microphoneDenied": "Microphone access was denied. Recording will continue without microphone audio.", + "failedToStart": "Failed to start recording: {{error}}", + "failedToStartGeneric": "Failed to start recording" + }, + "updateToast": { + "availableTitle": "Update available", + "experimentalAvailableTitle": "Experimental update available", + "experimentalDescription": "You've opted into experimental updates so you have the choice to test the latest update of Recordly before it's widely available.", + "downloadingTitle": "Downloading your update", + "readyTitle": "Ready to restart", + "checkErrorTitle": "Couldn't check for updates", + "downloadErrorTitle": "Couldn't download the update", + "experimentalBadge": "Experimental", + "previewBadge": "Preview", + "notNow": "Not now", + "updateNow": "Update now", + "restartToUpdate": "Restart to update", + "tryAgain": "Try again" + } +} diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json new file mode 100644 index 000000000..31953838b --- /dev/null +++ b/src/i18n/locales/en/settings.json @@ -0,0 +1,265 @@ +{ + "updates": { + "title": "Updates", + "experimental": "Experimental updates", + "experimentalDescription": "You've opted into experimental updates so you have the choice to test the latest update of Recordly before it's widely available.", + "saveFailed": "Failed to change the update channel." + }, + "zoom": { + "level": "Zoom Level", + "selectRegion": "Select a zoom region to adjust", + "deleteZoom": "Delete Zoom", + "modeAuto": "Auto", + "modeManual": "Manual", + "modeManualDescription": "Set a fixed focus point for this zoom", + "modeAutoDescription": "Camera recenters when the cursor nears the edge of the zoomed view" + }, + "trim": { + "deleteRegion": "Delete Trim Region" + }, + "speed": { + "playbackSpeed": "Playback Speed", + "selectRegion": "Select a speed region to adjust", + "deleteRegion": "Delete Speed Region", + "label": "Speed" + }, + "clip": { + "title": "Clip", + "mute": "Mute", + "mutedState": "Audio is muted", + "unmutedState": "Audio is playing", + "separateClipFromAudio": "Separate clip from audio", + "delete": "Delete Clip" + }, + "effects": { + "title": "Video Effects", + "show": "Show", + "showCursor": "Show Cursor", + "loopCursor": "Loop cursor", + "cursorStyle": "Cursor Style", + "cursorStyleOptions": { + "macos": "macOS", + "tahoe": "Tahoe", + "tahoe-inverted": "Tahoe Inverted", + "windows11": "Windows 11", + "dot": "Dot", + "figma": "Minimal", + "lavender": "Lavender", + "parched": "Parched", + "chooper": "Chooper", + "amongus": "Among Us", + "turtle": "Turtle" + }, + "backgroundBlur": "Blur", + "zoomMotionBlur": "Zoom Motion Blur", + "temporalZoomMotionBlur": "Temporal Zoom Blur", + "temporalZoomMotionBlurDescription": "Control the shutter window and frame samples used by the newer zoom blur pass.", + "zoomMotionBlurSamples": "Blur Samples", + "zoomMotionBlurShutter": "Shutter", + "auto": "Auto", + "connectZooms": "Connect Zooms", + "connectZoomsDescription": "Smooth consecutive zoom regions into a continuous camera move.", + "autoApplyFreshRecordingZooms": "Auto-apply fresh recording zooms", + "autoApplyFreshRecordingZoomsDescription": "Suggest edge-recentering zooms automatically when you open a new recording.", + "zoomGeneralTitle": "General", + "zoomGeneralDescription": "Global motion settings for every zoom transition.", + "zoomInTitle": "Zoom In", + "zoomInDescription": "Control how the camera enters a zoom region.", + "zoomOutTitle": "Zoom Out", + "zoomOutDescription": "Control how the camera exits a zoom region.", + "connectedZoomTitle": "Between Zooms", + "connectedZoomDescription": "Tune the glide between consecutive zoom regions when connection is enabled.", + "motionPresetsTitle": "Motion Presets", + "motionPresetsZoomHint": "Zoom motion presets are available in Settings.", + "animationPresets": "Animation Presets", + "cursorMotionPresets": "Cursor Motion Presets", + "motionPresets": { + "focused": { + "label": "Focused", + "description": "Snappier motion for demos, walkthroughs, and everyday recordings." + }, + "smooth": { + "label": "Smooth", + "description": "Gentler motion for presentations, keynote-style videos, and polished reveals." + } + }, + "zoomInDuration": "Zoom In Duration", + "zoomInOverlap": "Zoom In Overlap", + "zoomOutDuration": "Zoom Out Duration", + "zoomInEasing": "Zoom In Curve", + "zoomOutEasing": "Zoom Out Curve", + "connectedZoomGap": "Connected Zoom Gap", + "connectedZoomDuration": "Connected Zoom Duration", + "connectedZoomEasing": "Connected Pan Curve", + "zoomEasingOptions": { + "recordly": "Recordly", + "glide": "Glide", + "smooth": "Smooth", + "snappy": "Snappy", + "linear": "Linear" + }, + "cursorSize": "Cursor Size", + "cursorSmoothing": "Cursor Smoothing", + "cursorSpringStiffness": "Cursor Spring Stiffness", + "cursorSpringDamping": "Cursor Spring Damping", + "cursorSpringMass": "Cursor Spring Mass", + "off": "Off", + "cursorClickEffects": { + "title": "Click Effects", + "advanced": "Advanced", + "advancedShow": "Show advanced click effect controls", + "advancedHide": "Hide advanced click effect controls", + "color": "Effect Color", + "size": "Effect Size", + "opacity": "Effect Opacity", + "duration": "Effect Duration", + "none": { + "label": "Off", + "description": "No click graphic. Only the cursor motion changes when you click." + }, + "ripple": { + "label": "Ripple", + "description": "Expanding rings radiate from each click so taps read clearly in motion." + }, + "spotlight": { + "label": "Spotlight", + "description": "A soft halo flashes around the pointer to emphasize the clicked area." + }, + "echo": { + "label": "Echo", + "description": "A pair of soft rings that spread outward with a cleaner pulse." + } + }, + "cursorMotionBlur": "Cursor Motion Blur", + "cursorClickBounce": "Cursor Click Bounce", + "cursorClickBounceDuration": "Bounce Speed", + "cursorSway": "Cursor Sway", + "webcam": "Webcam Overlay", + "webcamFootage": "Webcam Footage", + "webcamFootageDescription": "No webcam footage linked to this video", + "uploadWebcamFootage": "Upload footage", + "replaceWebcamFootage": "Replace footage", + "removeWebcamFootage": "Remove footage", + "webcamFootageAdded": "Webcam footage linked", + "webcamFootageRemoved": "Webcam footage removed", + "webcamSize": "Webcam Size", + "webcamWidth": "Webcam Width", + "webcamHeight": "Webcam Height", + "webcamCrop": "Webcam Crop", + "webcamReactToZoom": "Webcam Reacts To Zoom", + "webcamMirror": "Mirror webcam", + "webcamRoundness": "Webcam Roundness", + "webcamShadow": "Webcam Shadow", + "shadow": "Shadow", + "radius": "Radius", + "roundness": "Roundness", + "padding": "Padding", + "paddingLinked": "Linked (Uniform)", + "paddingUnlinked": "Unlinked (Asymmetrical)", + "paddingTop": "Top", + "paddingBottom": "Bottom", + "paddingLeft": "Left", + "paddingRight": "Right", + "removeBackground": "Remove background" + }, + "sections": { + "scene": "Scene", + "captions": "Captions", + "caption": "Caption", + "zoom": "Zoom", + "cursor": "Cursor", + "webcam": "Webcam", + "frame": "Frame", + "crop": "Crop" + }, + "captions": { + "selectOnTimeline": "Select a caption on the timeline to edit it.", + "enabled": "Show", + "timelineQuickAdd": "Hover to add on timeline", + "language": "Language", + "downloading": "Downloading...", + "deleteModel": "Delete Model", + "clearModel": "Clear Model", + "downloadModel": "Download Model", + "generating": "Generating...", + "generateFull": "Generate Captions", + "regenerateFull": "Regenerate Captions", + "clearFull": "Clear Captions", + "fontSettings": "Font Settings", + "defaultFont": "Default", + "fontFamily": "Font", + "fontSize": "Font Size", + "rowCount": "Rows", + "animation": "Animation", + "animationOff": "Off", + "animationFade": "Fade", + "animationRise": "Rise", + "animationPop": "Pop", + "bottomOffset": "Bottom Offset", + "maxWidth": "Max Width", + "boxRadius": "Box Radius", + "backgroundOpacity": "Background Opacity", + "textColor": "Text Color", + "editor": { + "text": "Text", + "start": "Start", + "end": "End", + "split": "Split", + "merge": "Merge", + "delete": "Delete" + } + }, + "crop": { + "title": "Crop Video", + "instruction": "Drag on each side to adjust the crop area", + "top": "Top", + "bottom": "Bottom", + "left": "Left", + "right": "Right", + "openEditor": "Open Crop Editor" + }, + "background": { + "title": "Background", + "image": "Image", + "color": "Color", + "gradient": "Gradient", + "wallpaperPreview": "Wallpaper preview", + "uploadCustom": "Upload Custom", + "uploadSuccess": "Custom image uploaded successfully!", + "uploadError": "Please upload a JPG or JPEG image file.", + "uploadErrorDescription": "Only JPG and JPEG images are supported." + }, + "export": { + "title": "Export", + "mp4": "MP4", + "gif": "GIF", + "quality": { + "low": "Low", + "medium": "Medium", + "high": "High", + "original": "Original" + }, + "fpsTitle": "FPS", + "loop": "Loop", + "outputDimensions": "Output: {{dimensions}}px", + "sizePresetOriginalShort": "Orig", + "sizePresetMediumShort": "Med", + "sizePresetLargeShort": "Lar", + "loadProject": "Load Project", + "saveProject": "Save Project", + "exportVideo": "Export {{format}}", + "reportBug": "Report Bug", + "starOnGithub": "Star on GitHub" + }, + "audio": { + "title": "Audio", + "volumeTitle": "Audio", + "volume": "Volume", + "normalize": "Normalize", + "sourceTracksTitle": "Clip Source Audio", + "systemLabel": "Source System", + "micLabel": "Source Mic", + "mixedLabel": "Source", + "deleteRegion": "Delete Audio" + } +} diff --git a/src/i18n/locales/en/shortcuts.json b/src/i18n/locales/en/shortcuts.json new file mode 100644 index 000000000..dd789d984 --- /dev/null +++ b/src/i18n/locales/en/shortcuts.json @@ -0,0 +1,16 @@ +{ + "actions": { + "addZoom": "Add Zoom", + "addTrim": "Add Trim", + "addSpeed": "Add Speed", + "addAnnotation": "Add Annotation", + "addKeyframe": "Add Keyframe", + "deleteSelected": "Delete Selected", + "playPause": "Play / Pause", + "cycleForward": "Cycle Annotations Forward", + "cycleBackward": "Cycle Annotations Backward", + "deleteSelectedAlt": "Delete Selected (alt)", + "panTimeline": "Pan Timeline", + "zoomTimeline": "Zoom Timeline" + } +} diff --git a/src/i18n/locales/en/timeline.json b/src/i18n/locales/en/timeline.json new file mode 100644 index 000000000..2fcda722d --- /dev/null +++ b/src/i18n/locales/en/timeline.json @@ -0,0 +1,41 @@ +{ + "zoom": { + "cannotPlace": "Cannot place zoom here", + "existsOrNoSpace": "Zoom already exists at this location or not enough space available.", + "suggestHandlerUnavailable": "Zoom suggestion handler unavailable", + "noTelemetry": "No cursor telemetry available", + "recordFirst": "Record a screencast first to generate cursor-based suggestions.", + "noUsableTelemetry": "No usable cursor telemetry", + "notEnoughMovement": "The recording does not include enough cursor movement data.", + "noInteractionMoments": "No clear interaction moments found", + "tryRecording": "Try a recording with pauses or clicks around important actions.", + "noAutoZoomSlots": "No auto-zoom slots available", + "dwellPointsOverlap": "Detected dwell points overlap existing zoom regions.", + "addedSuggestions": "Added {{count}} interaction-based zoom suggestion(s)", + "label": "Zoom {{index}}", + "addZoom": "Add Zoom (Z)", + "suggestZooms": "Suggest Zooms from Cursor" + }, + "trim": { + "cannotPlace": "Cannot place trim here", + "existsOrNoSpace": "Trim already exists at this location or not enough space available.", + "label": "Trim {{index}}", + "addTrim": "Add Trim (T)" + }, + "speed": { + "cannotPlace": "Cannot place speed here", + "existsOrNoSpace": "Speed region already exists at this location or not enough space available.", + "label": "Speed" + }, + "annotation": { + "label": "Annotation", + "image": "Image", + "addAnnotation": "Add Annotation (A)" + }, + "audio": { + "label": "Audio" + }, + "addSpeed": "Add Speed (S)", + "resizeLeft": "Resize left", + "resizeRight": "Resize right" +} diff --git a/src/i18n/locales/es/common.json b/src/i18n/locales/es/common.json new file mode 100644 index 000000000..f74c23773 --- /dev/null +++ b/src/i18n/locales/es/common.json @@ -0,0 +1,26 @@ +{ + "app": { + "name": "Recordly", + "editorTitle": "Editor de Recordly", + "subtitle": "Grabación de pantalla y edición", + "language": "Idioma", + "manageRecordings": "Abrir carpeta de grabaciones" + }, + "actions": { + "cancel": "Cancelar", + "close": "Cerrar", + "export": "Exportar", + "load": "Cargar", + "redo": "Rehacer", + "reset": "Restablecer", + "save": "Guardar", + "undo": "Deshacer", + "delete": "Eliminar", + "done": "Hecho" + }, + "errors": { + "invalidFileType": "Tipo de archivo inválido", + "failedToUploadImage": "Error al subir la imagen", + "fileReadError": "Hubo un error al leer el archivo." + } +} diff --git a/src/i18n/locales/es/dialogs.json b/src/i18n/locales/es/dialogs.json new file mode 100644 index 000000000..e6203e667 --- /dev/null +++ b/src/i18n/locales/es/dialogs.json @@ -0,0 +1,62 @@ +{ + "export": { + "pleaseTryAgain": "Por favor inténtalo de nuevo", + "compilingGifProgress": "Compilando GIF... {{progress}}%", + "compilingGifWait": "Compilando GIF... Esto puede tardar un momento", + "takeMoment": "Esto puede tardar un momento...", + "exportFailed": "Error en la exportación", + "compilingGifTitle": "Compilando GIF", + "exportingFormat": "Exportando {{format}}", + "exportComplete": "Exportación completada", + "formatReady": "Tu {{format}} está listo", + "showInFolder": "Mostrar en carpeta", + "compiling": "Compilando", + "renderingFrames": "Renderizando cuadros", + "processing": "Procesando...", + "status": "Estado", + "format": "Formato", + "compilingStatus": "Compilando...", + "frames": "Cuadros", + "cancelExport": "Cancelar exportación", + "reopenSaveDialog": "Abrir de nuevo el diálogo de guardado", + "savedSuccess": "¡{{format}} guardado exitosamente!" + }, + "addFont": { + "title": "Agregar fuente de Google", + "heading": "Agregar fuente de Google", + "description": "Agrega una fuente personalizada de Google Fonts para usar en tus anotaciones.", + "urlLabel": "URL de importación de Google Fonts", + "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap", + "urlHelp": "Obtén esto de Google Fonts: Selecciona una fuente → Haz clic en \"Obtener fuente\" → Copia la URL de @import", + "nameLabel": "Nombre para mostrar", + "namePlaceholder": "Mi fuente personalizada", + "nameHelp": "Así aparecerá la fuente en el selector de fuentes", + "adding": "Agregando...", + "addFont": "Agregar fuente", + "enterUrl": "Por favor ingresa una URL de importación de Google Fonts", + "invalidUrl": "Por favor ingresa una URL válida de Google Fonts", + "enterName": "Por favor ingresa un nombre de fuente", + "extractFailed": "No se pudo extraer el nombre de la familia de fuentes de la URL", + "addSuccess": "Fuente \"{{name}}\" agregada exitosamente", + "addFailed": "Error al agregar la fuente", + "loadTimeout": "La fuente tardó demasiado en cargar. Por favor verifica la URL e inténtalo de nuevo.", + "loadFailed": "No se pudo cargar la fuente. Por favor verifica que la URL de Google Fonts sea correcta." + }, + "shortcutsConfig": { + "title": "Atajos de teclado", + "configurable": "Configurable", + "fixed": "Fijo", + "pressEscToCancel": "Presiona Esc para cancelar", + "clickToChange": "Haz clic para cambiar", + "pressAKey": "Presiona una tecla…", + "alreadyUsedBy": "Ya está en uso por <strong>{{action}}</strong>", + "swap": "Intercambiar", + "reserved": "Este atajo está reservado para \"{{label}}\" y no se puede reasignar.", + "saved": "Atajos de teclado guardados", + "resetNotice": "Restablecer a atajos predeterminados — haz clic en Guardar para aplicar", + "instructions": "Haz clic en un atajo y luego presiona la nueva combinación de teclas. Presiona Esc para cancelar.", + "resetToDefaults": "Restablecer valores predeterminados", + "cancel": "Cancelar", + "save": "Guardar" + } +} diff --git a/src/i18n/locales/es/editor.json b/src/i18n/locales/es/editor.json new file mode 100644 index 000000000..c5da834b6 --- /dev/null +++ b/src/i18n/locales/es/editor.json @@ -0,0 +1,153 @@ +{ + "playback": { + "play": "Reproducir", + "pause": "Pausar", + "skipBack": "Retroceder", + "skipForward": "Avanzar", + "muteUnmute": "Silenciar/activar sonido", + "volume": "Volumen de vista previa" + }, + "annotations": { + "settings": "Configuración de anotaciones", + "active": "Activo", + "text": "Texto", + "image": "Imagen", + "arrow": "Flecha", + "blur": "Desenfoque", + "textContent": "Contenido de texto", + "textPlaceholder": "Ingresa tu texto...", + "fontStyle": "Estilo de fuente", + "selectStyle": "Seleccionar estilo", + "size": "Tamaño", + "toggleBold": "Alternar negrita", + "toggleItalic": "Alternar cursiva", + "toggleUnderline": "Alternar subrayado", + "alignLeft": "Alinear a la izquierda", + "alignCenter": "Alinear al centro", + "alignRight": "Alinear a la derecha", + "textColor": "Color de texto", + "background": "Fondo", + "none": "Ninguno", + "clearBackground": "Borrar fondo", + "uploadImage": "Subir imagen", + "supportedFormats": "Formatos compatibles: JPG, PNG, GIF, WebP", + "arrowDirection": "Dirección de flecha", + "strokeWidth": "Ancho de trazo: {{width}}px", + "arrowColor": "Color de flecha", + "deleteAnnotation": "Eliminar anotación", + "shortcutsAndTips": "Atajos y consejos", + "tipSelectAnnotation": "Mueve el cabezal de reproducción a la sección de anotación superpuesta y selecciona un elemento.", + "tipCycleForward": "Usa Tab para recorrer los elementos superpuestos.", + "tipCycleBackward": "Usa Shift+Tab para recorrer hacia atrás.", + "imageUploadSuccess": "¡Imagen subida exitosamente!", + "imageUploadError": "Por favor sube un archivo de imagen JPG, PNG, GIF o WebP.", + "blurStrength": "Fuerza del Desenfoque: {{strength}}", + "solidColor": "Color Sólido (Censura)", + "borderRadius": "Radio del Borde" + }, + + "fontStyles": { + "classic": "Clásico", + "editor": "Editor", + "strong": "Fuerte", + "typewriter": "Máquina de escribir", + "deco": "Deco", + "simple": "Simple", + "modern": "Moderno", + "clean": "Limpio" + }, + "format": { + "mp4Video": "Video MP4", + "mp4Description": "Archivo de video de alta calidad", + "gifAnimation": "Animación GIF", + "gifDescription": "Imagen animada para compartir" + }, + "gifOptions": { + "frameRate": "Velocidad de cuadros", + "outputSize": "Tamaño de salida", + "outputDimensions": "Salida: {{width}} × {{height}}px", + "loopAnimation": "Animación en bucle", + "loopDescription": "El GIF se reproducirá continuamente" + }, + "tutorial": { + "howTrimmingWorks": "Cómo funciona el recorte", + "title": "Cómo Funciona el Recorte", + "understanding": "Comprende cómo eliminar las partes no deseadas de tu video.", + "descriptionP1": "La herramienta de recorte funciona definiendo los segmentos que deseas", + "descriptionRemove": "eliminar", + "descriptionP2": "de tu video.", + "descriptionP3": "Cualquier parte de la línea de tiempo cubierta por un segmento de recorte rojo será eliminada al exportar.", + "visualExample": "Ejemplo visual", + "removed": "ELIMINADO", + "kept": "Conservado", + "finalVideo": "Video final", + "part": "Parte {{number}}", + "addTrimStep": "1. Agregar recorte", + "addTrimDesc": "Presiona T o haz clic en el ícono de tijeras para marcar una sección para eliminar.", + "adjustStep": "2. Ajustar", + "adjustDesc": "Arrastra los bordes de la región roja para cubrir exactamente lo que deseas eliminar." + }, + "feedback": { + "trigger": "Comentarios", + "title": "Comentarios y contacto", + "description": "Contáctanos directamente o abre un issue si algo falla o falta.", + "emailLabel": "Correo", + "xLabel": "X", + "reportIssue": "Reportar problema / enviar comentarios", + "openFailed": "No se pudo abrir el enlace." + }, + "keyboardShortcuts": { + "trigger": "Atajos", + "title": "Atajos de teclado", + "description": "Referencia rápida para los controles de la línea de tiempo y del editor.", + "customizeTooltip": "Personalizar atajos", + "customize": "Personalizar", + "panTimeline": "Desplazar línea de tiempo", + "zoomTimeline": "Zoom en línea de tiempo", + "cycleAnnotations": "Recorrer anotaciones", + "tab": "Tab" + }, + "actions": { + "saveAgain": "Guardar de nuevo", + "showInFolder": "Mostrar en carpeta" + }, + "project": { + "untitled": "Sin título", + "unsavedChangesTitle": "Cambios sin guardar", + "unsavedChangesDescription": "¿Quieres guardar el proyecto actual antes de {{action}}?", + "discardChanges": "Descartar cambios", + "saveProject": "Guardar proyecto" + }, + "exportTips": { + "autoZooms": "Consejo: Desactiva los zooms automáticos en la configuración", + "experimentalBuilds": "Consejo: Activa el acceso a versiones experimentales en la configuración", + "cursorAppearance": "Consejo: Puedes personalizar la apariencia del cursor" + }, + "account": { "title": "Cuenta", "comingSoon": "Cuenta próximamente" }, + "nativeCaptureUnavailable": { + "title": "Nada está roto, pero no podremos renderizar una superposición de cursor animada.", + "description": "Tu dispositivo no es compatible con la captura nativa. Esto puede deberse a varias razones que todavía no hemos identificado. Recordly seguirá funcionando, pero hará imposible el suavizado del cursor.", + "confirm": "Entendido" + }, + "exportStatus": { + "exporting": "Exportando", + "renderingFile": "Renderizando tu archivo.", + "preparing": "Preparando exportación...", + "completePercent": "{{percent}}% completado", + "issue": "Problema de exportación", + "complete": "Exportación completada", + "savedSuccessfully": "Tu archivo se guardó correctamente." + }, + "export": { + "processingAudioEdits": "Procesando audio con ediciones de velocidad/superposición" + }, + "toolbar": { + "addLayer": "Agregar capa", + "splitClip": "Dividir clip (C)" + }, + "timeline": { + "expand": "Expandir línea de tiempo", + "collapse": "Contraer línea de tiempo" + }, + "openRecordingsFolder": "Abrir carpeta de grabaciones" +} diff --git a/src/i18n/locales/es/launch.json b/src/i18n/locales/es/launch.json new file mode 100644 index 000000000..4edc2d9c6 --- /dev/null +++ b/src/i18n/locales/es/launch.json @@ -0,0 +1,95 @@ +{ + "recording": { + "disableSystemAudio": "Desactivar audio del sistema", + "enableSystemAudio": "Activar audio del sistema", + "disableMicrophone": "Desactivar micrófono", + "enableMicrophone": "Activar micrófono", + "micToggleDisabledTip": "El micrófono no se puede alternar durante la grabación", + "disableWebcam": "Desactivar superposición de cámara", + "enableWebcam": "Activar superposición de cámara", + "countdownDelay": "Retraso de cuenta regresiva", + "noDelay": "Sin retraso", + "record": "Grabar", + "recordingFolder": "Carpeta de grabaciones: {{path}}", + "chooseRecordingsFolder": "Elegir carpeta de grabaciones", + "folderPath": "Ruta: /{{name}}/", + "openVideoFile": "Abrir archivo de video", + "openProject": "Abrir proyecto", + "hideHudFromVideo": "Ocultar HUD de la grabación", + "showHudInVideo": "Mostrar HUD en la grabación", + "hideHud": "Ocultar HUD", + "closeApp": "Cerrar aplicación", + "screens": "Pantallas", + "windows": "Ventanas", + "screen": "Pantalla", + "window": "Ventana", + "noSourcesFound": "No se encontraron fuentes", + "microphone": "Micrófono", + "turnOffMicrophone": "Desactivar micrófono", + "selectMicToEnable": "Selecciona un micrófono para activar", + "noMicrophonesFound": "No se encontraron micrófonos", + "webcam": "Cámara", + "turnOffWebcam": "Desactivar cámara", + "hideFloatingWebcamPreview": "Ocultar vista flotante", + "showFloatingWebcamPreview": "Mostrar vista flotante", + "selectWebcamToEnable": "Selecciona una cámara para activar", + "noWebcamsFound": "No se encontraron cámaras", + "recordingsFolder": "Carpeta de grabaciones", + "language": "Idioma", + "paused": "PAUSADO", + "rec": "REC", + "resume": "Reanudar", + "pause": "Pausa", + "stop": "Detener", + "cancel": "Cancelar", + "more": "Más", + "update": { + "update": "Actualizar", + "updated": "Actualizado", + "idleTitle": "Buscar actualizaciones.", + "checkingTitle": "Buscando actualizaciones...", + "downloadingTitle": "Descargando actualización...", + "errorTitle": "La comprobación de actualizaciones falló. Haz clic para reintentar.", + "upToDateTitle": "Recordly {{version}} está actualizado.", + "availableTitle": "Recordly {{version}} está disponible.", + "availableGenericTitle": "Hay una actualización disponible." + } + }, + "sourceSelector": { + "loadingSources": "Cargando fuentes...", + "screens": "Pantallas", + "windows": "Ventanas", + "noScreensAvailable": "No hay pantallas disponibles", + "noWindowsAvailable": "No hay ventanas disponibles", + "windowsNote": "Solo se pueden grabar las ventanas visibles (no minimizadas).", + "windowPlaceholder": "Ventana", + "cancel": "Cancelar", + "share": "Compartir" + }, + "permissions": { + "screenRecordingNeeded": "Recordly necesita permiso de grabación de pantalla antes de comenzar. Se ha abierto Configuración del sistema. Después de habilitarlo, cierra y vuelve a abrir Recordly.", + "screenRecordingMissing": "El permiso de grabación de pantalla aún falta. Se ha abierto Configuración del sistema nuevamente. Habilítalo, luego cierra y vuelve a abrir Recordly antes de grabar.", + "accessibilityNeeded": "Recordly también necesita permiso de accesibilidad para el seguimiento del cursor. Se ha abierto Configuración del sistema. Después de habilitarlo, cierra y vuelve a abrir Recordly.", + "accessibilityMissing": "El permiso de accesibilidad aún falta. Se ha abierto Configuración del sistema nuevamente. Habilítalo, luego cierra y vuelve a abrir Recordly antes de grabar.", + "selectSource": "Por favor selecciona una fuente para grabar", + "systemAudioUnavailable": "El audio del sistema no está disponible para esta fuente. La grabación continuará sin audio del sistema.", + "microphoneDenied": "Se denegó el acceso al micrófono. La grabación continuará sin audio del micrófono.", + "failedToStart": "Error al iniciar la grabación: {{error}}", + "failedToStartGeneric": "Error al iniciar la grabación" + }, + "updateToast": { + "availableTitle": "Actualización disponible", + "experimentalAvailableTitle": "Actualización experimental disponible", + "experimentalDescription": "Has activado las actualizaciones experimentales, así que puedes probar la actualización más reciente de Recordly antes de que esté disponible para todos.", + "downloadingTitle": "Descargando la actualización", + "readyTitle": "Listo para reiniciar", + "checkErrorTitle": "No se pudieron buscar actualizaciones", + "downloadErrorTitle": "No se pudo descargar la actualización", + "experimentalBadge": "Experimental", + "previewBadge": "Vista previa", + "notNow": "Ahora no", + "updateNow": "Actualizar ahora", + "restartToUpdate": "Reiniciar para actualizar", + "tryAgain": "Intentar de nuevo" + } +} diff --git a/src/i18n/locales/es/settings.json b/src/i18n/locales/es/settings.json new file mode 100644 index 000000000..3690c13c9 --- /dev/null +++ b/src/i18n/locales/es/settings.json @@ -0,0 +1,265 @@ +{ + "zoom": { + "level": "Nivel de zoom", + "selectRegion": "Selecciona una región de zoom para ajustar", + "deleteZoom": "Eliminar zoom", + "modeAuto": "Auto", + "modeManual": "Manual", + "modeManualDescription": "Establece un punto de enfoque fijo para este zoom", + "modeAutoDescription": "La cámara sigue el cursor automáticamente" + }, + "trim": { + "deleteRegion": "Eliminar región de recorte" + }, + "speed": { + "playbackSpeed": "Velocidad de reproducción", + "selectRegion": "Selecciona una región de velocidad para ajustar", + "deleteRegion": "Eliminar región de velocidad", + "label": "Velocidad" + }, + "clip": { + "title": "Clip", + "mute": "Silenciar", + "mutedState": "El audio está silenciado", + "unmutedState": "El audio se está reproduciendo", + "separateClipFromAudio": "Separar clip del audio", + "delete": "Eliminar clip" + }, + "effects": { + "title": "Efectos de video", + "show": "Mostrar", + "showCursor": "Mostrar cursor", + "loopCursor": "Cursor en bucle", + "cursorStyle": "Estilo del cursor", + "cursorStyleOptions": { + "macos": "macOS", + "tahoe": "Tahoe", + "tahoe-inverted": "Tahoe invertido", + "windows11": "Windows 11", + "dot": "Punto", + "figma": "Minimal", + "lavender": "Lavender", + "parched": "Parched", + "chooper": "Chooper", + "amongus": "Among Us", + "turtle": "Turtle" + }, + "backgroundBlur": "Desenfoque", + "zoomMotionBlur": "Desenfoque de movimiento del zoom", + "temporalZoomMotionBlur": "Temporal Zoom Blur", + "temporalZoomMotionBlurDescription": "Control the shutter window and frame samples used by the newer zoom blur pass.", + "zoomMotionBlurSamples": "Blur Samples", + "zoomMotionBlurShutter": "Shutter", + "auto": "Auto", + "connectZooms": "Conectar zooms", + "connectZoomsDescription": "Suaviza regiones de zoom consecutivas convirtiéndolas en un movimiento continuo de cámara.", + "autoApplyFreshRecordingZooms": "Aplicar automáticamente zooms a nuevas grabaciones", + "autoApplyFreshRecordingZoomsDescription": "Sugiere automáticamente zooms de seguimiento del cursor al abrir una nueva grabación.", + "zoomGeneralTitle": "General", + "zoomGeneralDescription": "Ajustes globales de movimiento para todas las transiciones de zoom.", + "zoomInTitle": "Entrada", + "zoomInDescription": "Controla cómo entra la cámara en una región de zoom.", + "zoomOutTitle": "Salida", + "zoomOutDescription": "Controla cómo sale la cámara de una región de zoom.", + "connectedZoomTitle": "Entre zooms", + "connectedZoomDescription": "Ajusta el deslizamiento entre regiones de zoom consecutivas cuando la conexión está activada.", + "motionPresetsTitle": "Motion Presets", + "motionPresetsZoomHint": "Zoom motion presets are available in Settings.", + "animationPresets": "Animation Presets", + "cursorMotionPresets": "Cursor Motion Presets", + "motionPresets": { + "focused": { + "label": "Focused", + "description": "Snappier motion for demos, walkthroughs, and everyday recordings." + }, + "smooth": { + "label": "Smooth", + "description": "Gentler motion for presentations, keynote-style videos, and polished reveals." + } + }, + "zoomInDuration": "Duración de entrada", + "zoomInOverlap": "Solapamiento de entrada", + "zoomOutDuration": "Duración de salida", + "zoomInEasing": "Curva de entrada", + "zoomOutEasing": "Curva de salida", + "connectedZoomGap": "Espacio entre zooms conectados", + "connectedZoomDuration": "Duración entre zooms conectados", + "connectedZoomEasing": "Curva del paneo conectado", + "zoomEasingOptions": { + "recordly": "Recordly", + "glide": "Glide", + "smooth": "Suave", + "snappy": "Rápida", + "linear": "Lineal" + }, + "cursorSize": "Tamaño del cursor", + "cursorSmoothing": "Suavizado del cursor", + "cursorSpringStiffness": "Cursor Spring Stiffness", + "cursorSpringDamping": "Cursor Spring Damping", + "cursorSpringMass": "Cursor Spring Mass", + "off": "Desactivado", + "cursorClickEffects": { + "title": "Click Effects", + "advanced": "Advanced", + "advancedShow": "Show advanced click effect controls", + "advancedHide": "Hide advanced click effect controls", + "color": "Effect Color", + "size": "Effect Size", + "opacity": "Effect Opacity", + "duration": "Effect Duration", + "none": { + "label": "Off", + "description": "No click graphic. Only the cursor motion changes when you click." + }, + "ripple": { + "label": "Ripple", + "description": "Expanding rings radiate from each click so taps read clearly in motion." + }, + "spotlight": { + "label": "Spotlight", + "description": "A soft halo flashes around the pointer to emphasize the clicked area." + }, + "echo": { + "label": "Echo", + "description": "A pair of soft rings that spread outward with a cleaner pulse." + } + }, + "cursorMotionBlur": "Desenfoque de movimiento del cursor", + "cursorClickBounce": "Rebote de clic del cursor", + "cursorClickBounceDuration": "Velocidad del rebote", + "cursorSway": "Balanceo del cursor", + "webcam": "Superposición de cámara", + "webcamFootage": "Metraje de cámara", + "webcamFootageDescription": "No hay metraje de cámara vinculado a este video", + "uploadWebcamFootage": "Subir metraje", + "replaceWebcamFootage": "Reemplazar metraje", + "removeWebcamFootage": "Quitar metraje", + "webcamFootageAdded": "Metraje de cámara vinculado", + "webcamFootageRemoved": "Metraje de cámara eliminado", + "webcamSize": "Tamaño de cámara", + "webcamWidth": "Ancho de cámara", + "webcamHeight": "Alto de cámara", + "webcamCrop": "Recorte de cámara", + "webcamReactToZoom": "La cámara reacciona al zoom", + "webcamMirror": "Reflejar cámara", + "webcamRoundness": "Redondez de cámara", + "webcamShadow": "Sombra de cámara", + "shadow": "Sombra", + "radius": "Radio", + "roundness": "Redondez", + "padding": "Relleno", + "paddingLinked": "Vinculado (uniforme)", + "paddingUnlinked": "No vinculado (asimétrico)", + "paddingTop": "Superior", + "paddingBottom": "Inferior", + "paddingLeft": "Izquierdo", + "paddingRight": "Derecho", + "removeBackground": "Quitar fondo" + }, + "sections": { + "scene": "Escena", + "captions": "Subtítulos", + "caption": "Subtítulo", + "zoom": "Zoom", + "cursor": "Cursor", + "webcam": "Cámara", + "frame": "Marco", + "crop": "Recorte" + }, + "captions": { + "selectOnTimeline": "Selecciona un subtítulo en la línea de tiempo para editarlo.", + "enabled": "Mostrar", + "timelineQuickAdd": "Pasar el cursor para añadir en la línea de tiempo", + "language": "Idioma", + "downloading": "Descargando...", + "deleteModel": "Eliminar modelo", + "clearModel": "Borrar modelo", + "downloadModel": "Descargar modelo", + "generating": "Generando...", + "generateFull": "Generar subtítulos", + "regenerateFull": "Regenerar subtítulos", + "clearFull": "Borrar subtítulos", + "fontSettings": "Tipografía", + "defaultFont": "Predeterminado", + "fontFamily": "Fuente", + "fontSize": "Tamaño", + "rowCount": "Filas", + "animation": "Animación", + "animationOff": "Desactivada", + "animationFade": "Desvanecer", + "animationRise": "Elevar", + "animationPop": "Resaltar", + "bottomOffset": "Desplazamiento inferior", + "maxWidth": "Ancho máximo", + "boxRadius": "Radio del contenedor", + "backgroundOpacity": "Opacidad del fondo", + "textColor": "Color del texto", + "editor": { + "text": "Text", + "start": "Start", + "end": "End", + "split": "Split", + "merge": "Merge", + "delete": "Delete" + } + }, + "crop": { + "title": "Recortar video", + "instruction": "Arrastra cada lado para ajustar el área de recorte", + "top": "Superior", + "bottom": "Inferior", + "left": "Izquierda", + "right": "Derecha", + "openEditor": "Abrir editor de recorte" + }, + "background": { + "title": "Fondo", + "image": "Imagen", + "color": "Color", + "gradient": "Degradado", + "wallpaperPreview": "Vista previa del fondo", + "uploadCustom": "Subir personalizado", + "uploadSuccess": "¡Imagen personalizada subida exitosamente!", + "uploadError": "Por favor sube un archivo de imagen JPG o JPEG.", + "uploadErrorDescription": "Solo se admiten imágenes JPG y JPEG." + }, + "export": { + "title": "Exportar", + "mp4": "MP4", + "gif": "GIF", + "quality": { + "low": "Baja", + "medium": "Media", + "high": "Alta", + "original": "Original" + }, + "fpsTitle": "FPS", + "loop": "Bucle", + "outputDimensions": "Salida: {{dimensions}}px", + "sizePresetOriginalShort": "Orig", + "sizePresetMediumShort": "Med", + "sizePresetLargeShort": "Gra", + "loadProject": "Cargar proyecto", + "saveProject": "Guardar proyecto", + "exportVideo": "Exportar {{format}}", + "reportBug": "Reportar error", + "starOnGithub": "Estrella en GitHub" + }, + "audio": { + "title": "Audio", + "volumeTitle": "Audio", + "volume": "Volumen", + "normalize": "Normalizar", + "sourceTracksTitle": "Audio fuente del clip", + "systemLabel": "Sonido del sistema", + "micLabel": "Micrófono", + "mixedLabel": "Fuente", + "deleteRegion": "Eliminar audio" + }, + "updates": { + "experimentalDescription": "Has activado las actualizaciones experimentales, así que puedes probar la actualización más reciente de Recordly antes de que esté disponible para todos.", + "title": "Actualizaciones", + "experimental": "Actualizaciones experimentales", + "saveFailed": "No se pudo cambiar el canal de actualización." + } +} diff --git a/src/i18n/locales/es/shortcuts.json b/src/i18n/locales/es/shortcuts.json new file mode 100644 index 000000000..28b42cbc6 --- /dev/null +++ b/src/i18n/locales/es/shortcuts.json @@ -0,0 +1,16 @@ +{ + "actions": { + "addZoom": "Agregar Zoom", + "addTrim": "Agregar Recorte", + "addSpeed": "Agregar Velocidad", + "addAnnotation": "Agregar Anotación", + "addKeyframe": "Agregar Fotograma clave", + "deleteSelected": "Eliminar seleccionado", + "playPause": "Reproducir / Pausar", + "cycleForward": "Recorrer anotaciones hacia adelante", + "cycleBackward": "Recorrer anotaciones hacia atrás", + "deleteSelectedAlt": "Eliminar seleccionado (alt)", + "panTimeline": "Desplazar línea de tiempo", + "zoomTimeline": "Zoom en línea de tiempo" + } +} diff --git a/src/i18n/locales/es/timeline.json b/src/i18n/locales/es/timeline.json new file mode 100644 index 000000000..69d213b27 --- /dev/null +++ b/src/i18n/locales/es/timeline.json @@ -0,0 +1,41 @@ +{ + "zoom": { + "cannotPlace": "No se puede colocar zoom aquí", + "existsOrNoSpace": "Ya existe un zoom en esta ubicación o no hay suficiente espacio.", + "suggestHandlerUnavailable": "Manejador de sugerencias de zoom no disponible", + "noTelemetry": "No hay telemetría de cursor disponible", + "recordFirst": "Graba una captura de pantalla primero para generar sugerencias basadas en el cursor.", + "noUsableTelemetry": "No hay telemetría de cursor utilizable", + "notEnoughMovement": "La grabación no incluye suficientes datos de movimiento del cursor.", + "noInteractionMoments": "No se encontraron momentos de interacción claros", + "tryRecording": "Intenta una grabación con pausas o clics alrededor de acciones importantes.", + "noAutoZoomSlots": "No hay espacios de auto-zoom disponibles", + "dwellPointsOverlap": "Los puntos de permanencia detectados se superponen con regiones de zoom existentes.", + "addedSuggestions": "Se agregaron {{count}} sugerencia(s) de zoom basadas en interacción", + "label": "Zoom {{index}}", + "addZoom": "Agregar Zoom (Z)", + "suggestZooms": "Sugerir zooms desde cursor" + }, + "trim": { + "cannotPlace": "No se puede colocar recorte aquí", + "existsOrNoSpace": "Ya existe un recorte en esta ubicación o no hay suficiente espacio.", + "label": "Recorte {{index}}", + "addTrim": "Agregar Recorte (T)" + }, + "speed": { + "cannotPlace": "No se puede colocar velocidad aquí", + "existsOrNoSpace": "Ya existe una región de velocidad en esta ubicación o no hay suficiente espacio.", + "label": "Velocidad" + }, + "annotation": { + "label": "Anotación", + "image": "Imagen", + "addAnnotation": "Agregar Anotación (A)" + }, + "audio": { + "label": "Audio" + }, + "addSpeed": "Agregar Velocidad (S)", + "resizeLeft": "Redimensionar izquierda", + "resizeRight": "Redimensionar derecha" +} diff --git a/src/i18n/locales/fr/common.json b/src/i18n/locales/fr/common.json new file mode 100644 index 000000000..38daaa826 --- /dev/null +++ b/src/i18n/locales/fr/common.json @@ -0,0 +1,26 @@ +{ + "app": { + "name": "Recordly", + "editorTitle": "Recordly Editor", + "subtitle": "Enregistrement et édition d’écran", + "language": "Langue", + "manageRecordings": "Ouvrir le dossier des enregistrements" + }, + "actions": { + "cancel": "Annuler", + "close": "Fermer", + "export": "Exporter", + "load": "Charger", + "redo": "Rétablir", + "reset": "Réinitialiser", + "save": "Enregistrer", + "undo": "Annuler", + "delete": "Supprimer", + "done": "Terminé" + }, + "errors": { + "invalidFileType": "Type de fichier invalide", + "failedToUploadImage": "Échec du téléchargement de l’image", + "fileReadError": "Une erreur s’est produite lors de la lecture du fichier." + } +} diff --git a/src/i18n/locales/fr/dialogs.json b/src/i18n/locales/fr/dialogs.json new file mode 100644 index 000000000..d13fe5eba --- /dev/null +++ b/src/i18n/locales/fr/dialogs.json @@ -0,0 +1,62 @@ +{ + "export": { + "pleaseTryAgain": "Veuillez réessayer", + "compilingGifProgress": "Compilation du GIF... {{progress}} %", + "compilingGifWait": "Compilation du GIF... Cela peut prendre un moment", + "takeMoment": "Cela peut prendre un moment...", + "exportFailed": "Échec de l’exportation", + "compilingGifTitle": "Compilation du GIF", + "exportingFormat": "Exportation en {{format}}", + "exportComplete": "Exportation terminée", + "formatReady": "Votre {{format}} est prêt", + "showInFolder": "Afficher dans le dossier", + "compiling": "Compilation", + "renderingFrames": "Rendu des images", + "processing": "Traitement...", + "status": "État", + "format": "Format", + "compilingStatus": "Compilation...", + "frames": "Images", + "cancelExport": "Annuler l’exportation", + "reopenSaveDialog": "Rouvrir la boîte de dialogue d’enregistrement", + "savedSuccess": "{{format}} enregistré avec succès !" + }, + "addFont": { + "title": "Ajouter une police Google", + "heading": "Ajouter une police Google", + "description": "Ajoutez une police personnalisée depuis Google Fonts pour l’utiliser dans vos annotations.", + "urlLabel": "URL d’importation Google Fonts", + "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap", + "urlHelp": "Récupérez-la sur Google Fonts : sélectionnez une police → cliquez sur « Obtenir la police » → copiez l’URL @import", + "nameLabel": "Nom affiché", + "namePlaceholder": "Ma police personnalisée", + "nameHelp": "C’est ainsi que la police apparaîtra dans le sélecteur de polices", + "adding": "Ajout...", + "addFont": "Ajouter la police", + "enterUrl": "Veuillez entrer une URL d’importation Google Fonts", + "invalidUrl": "Veuillez entrer une URL Google Fonts valide", + "enterName": "Veuillez entrer un nom de police", + "extractFailed": "Impossible d’extraire la famille de police depuis l’URL", + "addSuccess": "Police « {{name}} » ajoutée avec succès", + "addFailed": "Échec de l’ajout de la police", + "loadTimeout": "Le chargement de la police a pris trop de temps. Veuillez vérifier l’URL et réessayer.", + "loadFailed": "La police n’a pas pu être chargée. Veuillez vérifier que l’URL Google Fonts est correcte." + }, + "shortcutsConfig": { + "title": "Raccourcis clavier", + "configurable": "Configurable", + "fixed": "Fixe", + "pressEscToCancel": "Appuyez sur Échap pour annuler", + "clickToChange": "Cliquez pour modifier", + "pressAKey": "Appuyez sur une touche…", + "alreadyUsedBy": "Déjà utilisé par <strong>{{action}}</strong>", + "swap": "Échanger", + "reserved": "Ce raccourci est réservé à « {{label}} » et ne peut pas être réassigné.", + "saved": "Raccourcis clavier enregistrés", + "resetNotice": "Réinitialisation aux raccourcis par défaut — cliquez sur Enregistrer pour appliquer", + "instructions": "Cliquez sur un raccourci puis appuyez sur la nouvelle combinaison de touches. Appuyez sur Échap pour annuler.", + "resetToDefaults": "Réinitialiser par défaut", + "cancel": "Annuler", + "save": "Enregistrer" + } +} diff --git a/src/i18n/locales/fr/editor.json b/src/i18n/locales/fr/editor.json new file mode 100644 index 000000000..08704aaaa --- /dev/null +++ b/src/i18n/locales/fr/editor.json @@ -0,0 +1,153 @@ +{ + "playback": { + "play": "Lecture", + "pause": "Pause", + "skipBack": "Reculer", + "skipForward": "Avancer", + "muteUnmute": "Activer/Désactiver le son", + "volume": "Volume de l’aperçu" + }, + "annotations": { + "settings": "Paramètres des annotations", + "active": "Actif", + "text": "Texte", + "image": "Image", + "arrow": "Flèche", + "blur": "Flou", + "textContent": "Contenu du texte", + "textPlaceholder": "Entrez votre texte...", + "fontStyle": "Style de police", + "selectStyle": "Sélectionner un style", + "size": "Taille", + "toggleBold": "Activer/désactiver le gras", + "toggleItalic": "Activer/désactiver l’italique", + "toggleUnderline": "Activer/désactiver le soulignement", + "alignLeft": "Aligner à gauche", + "alignCenter": "Centrer", + "alignRight": "Aligner à droite", + "textColor": "Couleur du texte", + "background": "Arrière-plan", + "none": "Aucun", + "clearBackground": "Supprimer l’arrière-plan", + "uploadImage": "Importer une image", + "supportedFormats": "Formats pris en charge : JPG, PNG, GIF, WebP", + "arrowDirection": "Direction de la flèche", + "strokeWidth": "Épaisseur du trait : {{width}} px", + "arrowColor": "Couleur de la flèche", + "deleteAnnotation": "Supprimer l’annotation", + "shortcutsAndTips": "Raccourcis et conseils", + "tipSelectAnnotation": "Déplacez la tête de lecture vers une zone d’annotations superposées et sélectionnez un élément.", + "tipCycleForward": "Utilisez Tab pour parcourir les éléments superposés.", + "tipCycleBackward": "Utilisez Maj+Tab pour parcourir en arrière.", + "imageUploadSuccess": "Image importée avec succès !", + "imageUploadError": "Veuillez importer une image JPG, PNG, GIF ou WebP.", + "blurStrength": "Intensité du flou : {{strength}}", + "solidColor": "Couleur unie (censure)", + "borderRadius": "Rayon de bordure" + }, + + "fontStyles": { + "classic": "Classique", + "editor": "Éditeur", + "strong": "Fort", + "typewriter": "Machine à écrire", + "deco": "Décoratif", + "simple": "Simple", + "modern": "Moderne", + "clean": "Épuré" + }, + "format": { + "mp4Video": "Vidéo MP4", + "mp4Description": "Fichier vidéo de haute qualité", + "gifAnimation": "Animation GIF", + "gifDescription": "Image animée à partager" + }, + "gifOptions": { + "frameRate": "Fréquence d’images", + "outputSize": "Taille de sortie", + "outputDimensions": "Sortie : {{width}} × {{height}} px", + "loopAnimation": "Boucler l’animation", + "loopDescription": "Le GIF sera lu en continu" + }, + "tutorial": { + "howTrimmingWorks": "Fonctionnement du découpage", + "title": "Comment fonctionne le découpage", + "understanding": "Comprendre comment supprimer les parties indésirables de votre vidéo.", + "descriptionP1": "L’outil de découpage fonctionne en définissant les segments que vous souhaitez", + "descriptionRemove": "supprimer", + "descriptionP2": "de votre vidéo.", + "descriptionP3": "Toute partie de la timeline couverte par un segment rouge sera supprimée lors de l’exportation.", + "visualExample": "Exemple visuel", + "removed": "SUPPRIMÉ", + "kept": "Conservé", + "finalVideo": "Vidéo finale", + "part": "Partie {{number}}", + "addTrimStep": "1. Ajouter un découpage", + "addTrimDesc": "Appuyez sur T ou cliquez sur l’icône des ciseaux pour marquer une section à supprimer.", + "adjustStep": "2. Ajuster", + "adjustDesc": "Faites glisser les bords de la zone rouge pour couvrir exactement ce que vous souhaitez retirer." + }, + "feedback": { + "trigger": "Retour", + "title": "Retour et contact", + "description": "Contactez-nous directement ou ouvrez un ticket si quelque chose ne fonctionne pas ou manque.", + "emailLabel": "Email", + "xLabel": "X", + "reportIssue": "Signaler un problème / envoyer un retour", + "openFailed": "Échec de l’ouverture du lien." + }, + "keyboardShortcuts": { + "trigger": "Raccourcis", + "title": "Raccourcis clavier", + "description": "Référence rapide pour la timeline et les contrôles de l’éditeur.", + "customizeTooltip": "Personnaliser les raccourcis", + "customize": "Personnaliser", + "panTimeline": "Déplacer la timeline", + "zoomTimeline": "Zoomer la timeline", + "cycleAnnotations": "Parcourir les annotations", + "tab": "Tab" + }, + "actions": { + "saveAgain": "Enregistrer à nouveau", + "showInFolder": "Afficher dans le dossier" + }, + "project": { + "untitled": "Sans titre", + "unsavedChangesTitle": "Modifications non enregistrées", + "unsavedChangesDescription": "Enregistrer le projet actuel avant de {{action}} ?", + "discardChanges": "Ignorer les modifications", + "saveProject": "Enregistrer le projet" + }, + "exportTips": { + "autoZooms": "Astuce : Désactivez les zooms automatiques dans les paramètres", + "experimentalBuilds": "Astuce : Activez l’accès aux versions expérimentales dans les paramètres", + "cursorAppearance": "Astuce : Vous pouvez personnaliser l’apparence du curseur" + }, + "account": { "title": "Compte", "comingSoon": "Compte bientôt disponible" }, + "nativeCaptureUnavailable": { + "title": "Rien n'est cassé, mais nous ne pourrons pas afficher une superposition animée du curseur.", + "description": "Votre appareil ne prend pas en charge la capture native. Cela peut arriver pour plusieurs raisons que nous n'avons pas encore identifiées. Recordly continuera de fonctionner, mais le lissage du curseur sera impossible.", + "confirm": "D'accord" + }, + "exportStatus": { + "exporting": "Exportation", + "renderingFile": "Rendu de votre fichier.", + "preparing": "Préparation de l’export...", + "completePercent": "{{percent}} % terminé", + "issue": "Problème d’exportation", + "complete": "Exportation terminée", + "savedSuccessfully": "Votre fichier a été enregistré avec succès." + }, + "export": { + "processingAudioEdits": "Traitement de l’audio avec modifications de vitesse/superposition" + }, + "toolbar": { + "addLayer": "Ajouter une couche", + "splitClip": "Diviser le clip (C)" + }, + "timeline": { + "expand": "Développer la timeline", + "collapse": "Réduire la timeline" + }, + "openRecordingsFolder": "Ouvrir le dossier des enregistrements" +} diff --git a/src/i18n/locales/fr/launch.json b/src/i18n/locales/fr/launch.json new file mode 100644 index 000000000..2969355fa --- /dev/null +++ b/src/i18n/locales/fr/launch.json @@ -0,0 +1,95 @@ +{ + "recording": { + "disableSystemAudio": "Désactiver l’audio système", + "enableSystemAudio": "Activer l’audio système", + "disableMicrophone": "Désactiver le microphone", + "enableMicrophone": "Activer le microphone", + "micToggleDisabledTip": "Le microphone ne peut pas être basculé pendant l'enregistrement", + "disableWebcam": "Désactiver l’incrustation webcam", + "enableWebcam": "Activer l’incrustation webcam", + "countdownDelay": "Délai du compte à rebours", + "noDelay": "Aucun délai", + "record": "Enregistrer", + "recordingFolder": "Chemin des enregistrements : {{path}}", + "chooseRecordingsFolder": "Choisir le chemin des enregistrements", + "folderPath": "Chemin : /{{name}}/", + "openVideoFile": "Ouvrir le fichier vidéo", + "openProject": "Ouvrir le projet", + "hideHudFromVideo": "Masquer le HUD dans l’enregistrement", + "showHudInVideo": "Afficher le HUD dans l’enregistrement", + "hideHud": "Masquer le HUD", + "closeApp": "Fermer l’application", + "screens": "Écrans", + "windows": "Fenêtres", + "screen": "Écran", + "window": "Fenêtre", + "noSourcesFound": "Aucune source trouvée", + "microphone": "Microphone", + "turnOffMicrophone": "Désactiver le microphone", + "selectMicToEnable": "Sélectionnez un microphone à activer", + "noMicrophonesFound": "Aucun microphone trouvé", + "webcam": "Webcam", + "turnOffWebcam": "Désactiver la webcam", + "hideFloatingWebcamPreview": "Masquer l’aperçu flottant", + "showFloatingWebcamPreview": "Afficher l’aperçu flottant", + "selectWebcamToEnable": "Sélectionnez une webcam à activer", + "noWebcamsFound": "Aucune webcam trouvée", + "recordingsFolder": "Chemin des enregistrements", + "language": "Langue", + "paused": "EN PAUSE", + "rec": "REC", + "resume": "Reprendre", + "pause": "Pause", + "stop": "Arrêter", + "cancel": "Annuler", + "more": "Plus", + "update": { + "update": "Mettre à jour", + "updated": "Mis à jour", + "idleTitle": "Vérifier les mises à jour.", + "checkingTitle": "Vérification des mises à jour...", + "downloadingTitle": "Téléchargement de la mise à jour...", + "errorTitle": "Échec de la vérification des mises à jour. Cliquez pour réessayer.", + "upToDateTitle": "Recordly {{version}} est à jour.", + "availableTitle": "Recordly {{version}} est disponible.", + "availableGenericTitle": "Une mise à jour est disponible." + } + }, + "sourceSelector": { + "loadingSources": "Chargement des sources...", + "screens": "Écrans", + "windows": "Fenêtres", + "noScreensAvailable": "Aucun écran disponible", + "noWindowsAvailable": "Aucune fenêtre disponible", + "windowsNote": "Seules les fenêtres visibles (non minimisées) peuvent être enregistrées.", + "windowPlaceholder": "Fenêtre", + "cancel": "Annuler", + "share": "Partager" + }, + "permissions": { + "screenRecordingNeeded": "Recordly nécessite l’autorisation d’enregistrement d’écran avant de commencer. Les paramètres système ont été ouverts. Après l’avoir activée, quittez et relancez Recordly.", + "screenRecordingMissing": "L’autorisation d’enregistrement d’écran est toujours manquante. Les paramètres système ont été ouverts à nouveau. Activez-la, puis quittez et relancez Recordly avant d’enregistrer.", + "accessibilityNeeded": "Recordly nécessite également l’autorisation d’accessibilité pour le suivi du curseur. Les paramètres système ont été ouverts. Après l’avoir activée, quittez et relancez Recordly.", + "accessibilityMissing": "L’autorisation d’accessibilité est toujours manquante. Les paramètres système ont été ouverts à nouveau. Activez-la, puis quittez et relancez Recordly avant d’enregistrer.", + "selectSource": "Veuillez sélectionner une source à enregistrer", + "systemAudioUnavailable": "L’audio système n’est pas disponible pour cette source. L’enregistrement continuera sans audio système.", + "microphoneDenied": "L’accès au microphone a été refusé. L’enregistrement continuera sans audio du microphone.", + "failedToStart": "Échec du démarrage de l’enregistrement : {{error}}", + "failedToStartGeneric": "Échec du démarrage de l’enregistrement" + }, + "updateToast": { + "availableTitle": "Mise à jour disponible", + "experimentalAvailableTitle": "Mise à jour expérimentale disponible", + "experimentalDescription": "Vous avez activé les mises à jour expérimentales, vous pouvez donc tester la dernière mise à jour de Recordly avant sa disponibilité générale.", + "downloadingTitle": "Téléchargement de la mise à jour", + "readyTitle": "Prêt à redémarrer", + "checkErrorTitle": "Impossible de vérifier les mises à jour", + "downloadErrorTitle": "Impossible de télécharger la mise à jour", + "experimentalBadge": "Expérimental", + "previewBadge": "Aperçu", + "notNow": "Plus tard", + "updateNow": "Mettre à jour", + "restartToUpdate": "Redémarrer pour mettre à jour", + "tryAgain": "Réessayer" + } +} diff --git a/src/i18n/locales/fr/settings.json b/src/i18n/locales/fr/settings.json new file mode 100644 index 000000000..59b1e7701 --- /dev/null +++ b/src/i18n/locales/fr/settings.json @@ -0,0 +1,265 @@ +{ + "zoom": { + "level": "Niveau de zoom", + "selectRegion": "Sélectionnez une zone de zoom à ajuster", + "deleteZoom": "Supprimer le zoom", + "modeAuto": "Automatique", + "modeManual": "Manuel", + "modeManualDescription": "Définir un point de focus fixe pour ce zoom", + "modeAutoDescription": "La caméra suit automatiquement le curseur" + }, + "trim": { + "deleteRegion": "Supprimer la zone de découpe" + }, + "speed": { + "playbackSpeed": "Vitesse de lecture", + "selectRegion": "Sélectionnez une zone de vitesse à ajuster", + "deleteRegion": "Supprimer la zone de vitesse", + "label": "Vitesse" + }, + "clip": { + "title": "Clip", + "mute": "Sourdine", + "mutedState": "Le son est coupé", + "unmutedState": "Le son est activé", + "separateClipFromAudio": "Séparer le clip de l'audio", + "delete": "Supprimer le clip" + }, + "effects": { + "title": "Effets vidéo", + "show": "Afficher", + "showCursor": "Afficher le curseur", + "loopCursor": "Boucler le curseur", + "cursorStyle": "Style du curseur", + "cursorStyleOptions": { + "macos": "macOS", + "tahoe": "Tahoe", + "tahoe-inverted": "Tahoe inversé", + "windows11": "Windows 11", + "dot": "Point", + "figma": "Minimal", + "lavender": "Lavande", + "parched": "Parched", + "chooper": "Chooper", + "amongus": "Among Us", + "turtle": "Tortue" + }, + "backgroundBlur": "Flou", + "zoomMotionBlur": "Flou de mouvement du zoom", + "temporalZoomMotionBlur": "Temporal Zoom Blur", + "temporalZoomMotionBlurDescription": "Control the shutter window and frame samples used by the newer zoom blur pass.", + "zoomMotionBlurSamples": "Blur Samples", + "zoomMotionBlurShutter": "Shutter", + "auto": "Auto", + "connectZooms": "Relier les zooms", + "connectZoomsDescription": "Lisse les zones de zoom consécutives pour créer un mouvement de caméra continu.", + "autoApplyFreshRecordingZooms": "Appliquer automatiquement les zooms des nouveaux enregistrements", + "autoApplyFreshRecordingZoomsDescription": "Suggérer automatiquement des zooms suivant le curseur lorsque vous ouvrez un nouvel enregistrement.", + "zoomGeneralTitle": "Général", + "zoomGeneralDescription": "Paramètres globaux de mouvement pour chaque transition de zoom.", + "zoomInTitle": "Zoom avant", + "zoomInDescription": "Contrôle la manière dont la caméra entre dans une zone de zoom.", + "zoomOutTitle": "Zoom arrière", + "zoomOutDescription": "Contrôle la manière dont la caméra sort d’une zone de zoom.", + "connectedZoomTitle": "Entre les zooms", + "connectedZoomDescription": "Ajuste la transition entre des zones de zoom consécutives lorsque la connexion est activée.", + "motionPresetsTitle": "Motion Presets", + "motionPresetsZoomHint": "Zoom motion presets are available in Settings.", + "animationPresets": "Animation Presets", + "cursorMotionPresets": "Cursor Motion Presets", + "motionPresets": { + "focused": { + "label": "Focused", + "description": "Snappier motion for demos, walkthroughs, and everyday recordings." + }, + "smooth": { + "label": "Smooth", + "description": "Gentler motion for presentations, keynote-style videos, and polished reveals." + } + }, + "zoomInDuration": "Durée du zoom avant", + "zoomInOverlap": "Chevauchement du zoom avant", + "zoomOutDuration": "Durée du zoom arrière", + "zoomInEasing": "Courbe du zoom avant", + "zoomOutEasing": "Courbe du zoom arrière", + "connectedZoomGap": "Intervalle entre zooms connectés", + "connectedZoomDuration": "Durée du zoom connecté", + "connectedZoomEasing": "Courbe du pan connecté", + "zoomEasingOptions": { + "recordly": "Recordly", + "glide": "Glide", + "smooth": "Lisse", + "snappy": "Rapide", + "linear": "Linéaire" + }, + "cursorSize": "Taille du curseur", + "cursorSmoothing": "Lissage du curseur", + "cursorSpringStiffness": "Cursor Spring Stiffness", + "cursorSpringDamping": "Cursor Spring Damping", + "cursorSpringMass": "Cursor Spring Mass", + "off": "Désactivé", + "cursorClickEffects": { + "title": "Click Effects", + "advanced": "Advanced", + "advancedShow": "Show advanced click effect controls", + "advancedHide": "Hide advanced click effect controls", + "color": "Effect Color", + "size": "Effect Size", + "opacity": "Effect Opacity", + "duration": "Effect Duration", + "none": { + "label": "Off", + "description": "No click graphic. Only the cursor motion changes when you click." + }, + "ripple": { + "label": "Ripple", + "description": "Expanding rings radiate from each click so taps read clearly in motion." + }, + "spotlight": { + "label": "Spotlight", + "description": "A soft halo flashes around the pointer to emphasize the clicked area." + }, + "echo": { + "label": "Echo", + "description": "A pair of soft rings that spread outward with a cleaner pulse." + } + }, + "cursorMotionBlur": "Flou de mouvement du curseur", + "cursorClickBounce": "Rebond au clic du curseur", + "cursorClickBounceDuration": "Vitesse du rebond", + "cursorSway": "Oscillation du curseur", + "webcam": "Incrustation webcam", + "webcamFootage": "Vidéo de la webcam", + "webcamFootageDescription": "Aucune vidéo de webcam liée à cette vidéo", + "uploadWebcamFootage": "Importer une vidéo", + "replaceWebcamFootage": "Remplacer la vidéo", + "removeWebcamFootage": "Supprimer la vidéo", + "webcamFootageAdded": "Vidéo de webcam liée", + "webcamFootageRemoved": "Vidéo de webcam supprimée", + "webcamSize": "Taille de la webcam", + "webcamWidth": "Largeur de la webcam", + "webcamHeight": "Hauteur de la webcam", + "webcamCrop": "Recadrage de la webcam", + "webcamReactToZoom": "La webcam réagit au zoom", + "webcamMirror": "Miroir webcam", + "webcamRoundness": "Arrondi de la webcam", + "webcamShadow": "Ombre de la webcam", + "shadow": "Ombre", + "radius": "Rayon", + "roundness": "Arrondi", + "padding": "Marge intérieure", + "paddingLinked": "Lié (uniforme)", + "paddingUnlinked": "Dissocié (asymétrique)", + "paddingTop": "Haut", + "paddingBottom": "Bas", + "paddingLeft": "Gauche", + "paddingRight": "Droite", + "removeBackground": "Supprimer l’arrière-plan" + }, + "sections": { + "scene": "Scène", + "captions": "Sous-titres", + "caption": "Sous-titre", + "zoom": "Zoom", + "cursor": "Curseur", + "webcam": "Webcam", + "frame": "Cadre", + "crop": "Recadrage" + }, + "captions": { + "selectOnTimeline": "Sélectionnez un sous-titre sur la timeline pour le modifier.", + "enabled": "Afficher", + "timelineQuickAdd": "Survoler pour ajouter sur la timeline", + "language": "Langue", + "downloading": "Téléchargement...", + "deleteModel": "Supprimer le modèle", + "clearModel": "Effacer le modèle", + "downloadModel": "Télécharger le modèle", + "generating": "Génération...", + "generateFull": "Générer les sous-titres", + "regenerateFull": "Régénérer les sous-titres", + "clearFull": "Effacer les sous-titres", + "fontSettings": "Paramètres de police", + "defaultFont": "Par défaut", + "fontFamily": "Police", + "fontSize": "Taille de police", + "rowCount": "Lignes", + "animation": "Animation", + "animationOff": "Désactivée", + "animationFade": "Fondu", + "animationRise": "Remontée", + "animationPop": "Apparition", + "bottomOffset": "Décalage inférieur", + "maxWidth": "Largeur max", + "boxRadius": "Rayon de la boîte", + "backgroundOpacity": "Opacité de l’arrière-plan", + "textColor": "Couleur du texte", + "editor": { + "text": "Text", + "start": "Start", + "end": "End", + "split": "Split", + "merge": "Merge", + "delete": "Delete" + } + }, + "crop": { + "title": "Recadrer la vidéo", + "instruction": "Faites glisser chaque côté pour ajuster la zone de recadrage", + "top": "Haut", + "bottom": "Bas", + "left": "Gauche", + "right": "Droite", + "openEditor": "Ouvrir l’éditeur de recadrage" + }, + "background": { + "title": "Arrière-plan", + "image": "Image", + "color": "Couleur", + "gradient": "Dégradé", + "wallpaperPreview": "Aperçu du fond d’écran", + "uploadCustom": "Importer un fond personnalisé", + "uploadSuccess": "Image personnalisée importée avec succès !", + "uploadError": "Veuillez importer un fichier image JPG ou JPEG.", + "uploadErrorDescription": "Seules les images JPG et JPEG sont prises en charge." + }, + "export": { + "title": "Exportation", + "mp4": "MP4", + "gif": "GIF", + "quality": { + "low": "Faible", + "medium": "Moyenne", + "high": "Élevée", + "original": "Originale" + }, + "fpsTitle": "IPS", + "loop": "Boucle", + "outputDimensions": "Sortie : {{dimensions}} px", + "sizePresetOriginalShort": "Orig", + "sizePresetMediumShort": "Moy", + "sizePresetLargeShort": "Grd", + "loadProject": "Charger le projet", + "saveProject": "Enregistrer le projet", + "exportVideo": "Exporter en {{format}}", + "reportBug": "Signaler un bug", + "starOnGithub": "Mettre une étoile sur GitHub" + }, + "audio": { + "title": "Audio", + "volumeTitle": "Audio", + "volume": "Volume", + "normalize": "Normaliser", + "sourceTracksTitle": "Source audio du clip", + "systemLabel": "Son système", + "micLabel": "Microphone", + "mixedLabel": "Source", + "deleteRegion": "Supprimer la zone audio" + }, + "updates": { + "experimentalDescription": "Vous avez activé les mises à jour expérimentales, vous pouvez donc tester la dernière mise à jour de Recordly avant sa disponibilité générale.", + "title": "Mises à jour", + "experimental": "Mises à jour expérimentales", + "saveFailed": "Impossible de changer le canal de mise à jour." + } +} diff --git a/src/i18n/locales/fr/shortcuts.json b/src/i18n/locales/fr/shortcuts.json new file mode 100644 index 000000000..ed9f03088 --- /dev/null +++ b/src/i18n/locales/fr/shortcuts.json @@ -0,0 +1,16 @@ +{ + "actions": { + "addZoom": "Ajouter un zoom", + "addTrim": "Ajouter un découpage", + "addSpeed": "Ajouter une vitesse", + "addAnnotation": "Ajouter une annotation", + "addKeyframe": "Ajouter une image clé", + "deleteSelected": "Supprimer la sélection", + "playPause": "Lecture / Pause", + "cycleForward": "Parcourir les annotations vers l’avant", + "cycleBackward": "Parcourir les annotations vers l’arrière", + "deleteSelectedAlt": "Supprimer la sélection (alt)", + "panTimeline": "Déplacer la timeline", + "zoomTimeline": "Zoomer la timeline" + } +} diff --git a/src/i18n/locales/fr/timeline.json b/src/i18n/locales/fr/timeline.json new file mode 100644 index 000000000..68c59633b --- /dev/null +++ b/src/i18n/locales/fr/timeline.json @@ -0,0 +1,41 @@ +{ + "zoom": { + "cannotPlace": "Impossible de placer le zoom ici", + "existsOrNoSpace": "Un zoom existe déjà à cet emplacement ou il n’y a pas assez d’espace disponible.", + "suggestHandlerUnavailable": "Gestionnaire de suggestion de zoom indisponible", + "noTelemetry": "Aucune télémétrie du curseur disponible", + "recordFirst": "Enregistrez d’abord un screencast pour générer des suggestions basées sur le curseur.", + "noUsableTelemetry": "Aucune télémétrie du curseur exploitable", + "notEnoughMovement": "L’enregistrement ne contient pas assez de données de mouvement du curseur.", + "noInteractionMoments": "Aucun moment d’interaction clair trouvé", + "tryRecording": "Essayez un enregistrement avec des pauses ou des clics autour des actions importantes.", + "noAutoZoomSlots": "Aucun emplacement de zoom automatique disponible", + "dwellPointsOverlap": "Des points d’arrêt détectés se chevauchent avec des zones de zoom existantes.", + "addedSuggestions": "Suggestions de zoom basées sur l'interaction ajoutées : {{count}}", + "label": "Zoom {{index}}", + "addZoom": "Ajouter un zoom (Z)", + "suggestZooms": "Suggérer des zooms à partir du curseur" + }, + "trim": { + "cannotPlace": "Impossible de placer le découpage ici", + "existsOrNoSpace": "Un découpage existe déjà à cet emplacement ou il n’y a pas assez d’espace disponible.", + "label": "Découpage {{index}}", + "addTrim": "Ajouter un découpage (T)" + }, + "speed": { + "cannotPlace": "Impossible de placer la vitesse ici", + "existsOrNoSpace": "Une zone de vitesse existe déjà à cet emplacement ou il n’y a pas assez d’espace disponible.", + "label": "Vitesse" + }, + "annotation": { + "label": "Annotation", + "image": "Image", + "addAnnotation": "Ajouter une annotation (A)" + }, + "audio": { + "label": "Audio" + }, + "addSpeed": "Ajouter une vitesse (S)", + "resizeLeft": "Redimensionner vers la gauche", + "resizeRight": "Redimensionner vers la droite" +} diff --git a/src/i18n/locales/it/common.json b/src/i18n/locales/it/common.json new file mode 100644 index 000000000..9c270d7e8 --- /dev/null +++ b/src/i18n/locales/it/common.json @@ -0,0 +1,26 @@ +{ + "app": { + "name": "Recordly", + "editorTitle": "Editor Recordly", + "subtitle": "Registrazione e modifica dello schermo", + "language": "Lingua", + "manageRecordings": "Apri cartella registrazioni" + }, + "actions": { + "cancel": "Annulla", + "close": "Chiudi", + "export": "Esporta", + "load": "Carica", + "redo": "Ripeti", + "reset": "Ripristina", + "save": "Salva", + "undo": "Annulla", + "delete": "Elimina", + "done": "Fatto" + }, + "errors": { + "invalidFileType": "Tipo di file non valido", + "failedToUploadImage": "Caricamento immagine non riuscito", + "fileReadError": "Si è verificato un errore durante la lettura del file." + } +} diff --git a/src/i18n/locales/it/dialogs.json b/src/i18n/locales/it/dialogs.json new file mode 100644 index 000000000..4681c0bc9 --- /dev/null +++ b/src/i18n/locales/it/dialogs.json @@ -0,0 +1,62 @@ +{ + "export": { + "pleaseTryAgain": "Riprova", + "compilingGifProgress": "Compilazione GIF... {{progress}}%", + "compilingGifWait": "Compilazione GIF... Potrebbe richiedere del tempo", + "takeMoment": "Potrebbe richiedere un momento...", + "exportFailed": "Esportazione non riuscita", + "compilingGifTitle": "Compilazione GIF", + "exportingFormat": "Esportazione {{format}}", + "exportComplete": "Esportazione completata", + "formatReady": "Il tuo {{format}} è pronto", + "showInFolder": "Mostra nella cartella", + "compiling": "Compilazione", + "renderingFrames": "Rendering frame", + "processing": "Elaborazione...", + "status": "Stato", + "format": "Formato", + "compilingStatus": "Compilazione in corso...", + "frames": "Frame", + "cancelExport": "Annulla esportazione", + "reopenSaveDialog": "Riapri finestra di salvataggio", + "savedSuccess": "{{format}} salvato con successo!" + }, + "addFont": { + "title": "Aggiungi Google Font", + "heading": "Aggiungi Google Font", + "description": "Aggiungi un font personalizzato da Google Fonts da usare nelle annotazioni.", + "urlLabel": "URL di importazione Google Fonts", + "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap", + "urlHelp": "Recupera l'URL da Google Fonts: seleziona un font → clicca \"Get font\" → copia l'URL @import", + "nameLabel": "Nome visualizzato", + "namePlaceholder": "Il mio font personalizzato", + "nameHelp": "Questo è il nome con cui il font apparirà nel selettore", + "adding": "Aggiunta in corso...", + "addFont": "Aggiungi font", + "enterUrl": "Inserisci un URL di importazione Google Fonts", + "invalidUrl": "Inserisci un URL Google Fonts valido", + "enterName": "Inserisci un nome per il font", + "extractFailed": "Impossibile estrarre la famiglia di font dall'URL", + "addSuccess": "Font \"{{name}}\" aggiunto con successo", + "addFailed": "Aggiunta del font non riuscita", + "loadTimeout": "Il caricamento del font ha richiesto troppo tempo. Verifica l'URL e riprova.", + "loadFailed": "Impossibile caricare il font. Verifica che l'URL Google Fonts sia corretto." + }, + "shortcutsConfig": { + "title": "Scorciatoie da tastiera", + "configurable": "Configurabile", + "fixed": "Fisso", + "pressEscToCancel": "Premi Esc per annullare", + "clickToChange": "Clicca per modificare", + "pressAKey": "Premi un tasto…", + "alreadyUsedBy": "Già utilizzato da <strong>{{action}}</strong>", + "swap": "Scambia", + "reserved": "Questa scorciatoia è riservata a \"{{label}}\" e non può essere riassegnata.", + "saved": "Scorciatoie da tastiera salvate", + "resetNotice": "Ripristinate le scorciatoie predefinite — clicca Salva per applicare", + "instructions": "Clicca su una scorciatoia, poi premi la nuova combinazione di tasti. Premi Esc per annullare.", + "resetToDefaults": "Ripristina predefinite", + "cancel": "Annulla", + "save": "Salva" + } +} diff --git a/src/i18n/locales/it/editor.json b/src/i18n/locales/it/editor.json new file mode 100644 index 000000000..0576b03c3 --- /dev/null +++ b/src/i18n/locales/it/editor.json @@ -0,0 +1,153 @@ +{ + "playback": { + "play": "Riproduci", + "pause": "Pausa", + "skipBack": "Indietro", + "skipForward": "Avanti", + "muteUnmute": "Disattiva/Attiva audio", + "volume": "Volume anteprima" + }, + "annotations": { + "settings": "Impostazioni annotazione", + "active": "Attiva", + "text": "Testo", + "image": "Immagine", + "arrow": "Freccia", + "blur": "Sfocatura", + "textContent": "Contenuto del testo", + "textPlaceholder": "Inserisci il tuo testo...", + "fontStyle": "Stile font", + "selectStyle": "Seleziona stile", + "size": "Dimensione", + "toggleBold": "Attiva grassetto", + "toggleItalic": "Attiva corsivo", + "toggleUnderline": "Attiva sottolineato", + "alignLeft": "Allinea a sinistra", + "alignCenter": "Allinea al centro", + "alignRight": "Allinea a destra", + "textColor": "Colore testo", + "background": "Sfondo", + "none": "Nessuno", + "clearBackground": "Rimuovi sfondo", + "uploadImage": "Carica immagine", + "supportedFormats": "Formati supportati: JPG, PNG, GIF, WebP", + "arrowDirection": "Direzione freccia", + "strokeWidth": "Spessore tratto: {{width}}px", + "arrowColor": "Colore freccia", + "deleteAnnotation": "Elimina annotazione", + "shortcutsAndTips": "Scorciatoie e consigli", + "tipSelectAnnotation": "Sposta il cursore sulla sezione dell'annotazione sovrapposta e seleziona un elemento.", + "tipCycleForward": "Usa Tab per scorrere tra gli elementi sovrapposti.", + "tipCycleBackward": "Usa Shift+Tab per scorrere indietro.", + "imageUploadSuccess": "Immagine caricata con successo!", + "imageUploadError": "Carica un file immagine JPG, PNG, GIF o WebP.", + "blurStrength": "Intensità sfocatura: {{strength}}", + "solidColor": "Colore pieno (Censura)", + "borderRadius": "Raggio bordo" + }, + + "fontStyles": { + "classic": "Classico", + "editor": "Editor", + "strong": "Grassetto", + "typewriter": "Macchina da scrivere", + "deco": "Deco", + "simple": "Semplice", + "modern": "Moderno", + "clean": "Pulito" + }, + "format": { + "mp4Video": "Video MP4", + "mp4Description": "File video di alta qualità", + "gifAnimation": "Animazione GIF", + "gifDescription": "Immagine animata da condividere" + }, + "gifOptions": { + "frameRate": "Frequenza fotogrammi", + "outputSize": "Dimensione output", + "outputDimensions": "Output: {{width}} × {{height}}px", + "loopAnimation": "Riproduci in loop", + "loopDescription": "La GIF verrà riprodotta continuamente" + }, + "tutorial": { + "howTrimmingWorks": "Come funziona il taglio", + "title": "Come funziona il taglio", + "understanding": "Capire come tagliare le parti indesiderate del tuo video.", + "descriptionP1": "Lo strumento Taglia funziona definendo i segmenti che vuoi", + "descriptionRemove": "rimuovere", + "descriptionP2": "dal tuo video.", + "descriptionP3": "Qualsiasi parte della timeline coperta da un segmento di taglio rosso verrà rimossa durante l'esportazione.", + "visualExample": "Esempio visivo", + "removed": "RIMOSSO", + "kept": "Mantenuto", + "finalVideo": "Video finale", + "part": "Parte {{number}}", + "addTrimStep": "1. Aggiungi taglio", + "addTrimDesc": "Premi T o clicca sull'icona delle forbici per contrassegnare una sezione da rimuovere.", + "adjustStep": "2. Regola", + "adjustDesc": "Trascina i bordi della regione rossa per coprire esattamente ciò che vuoi tagliare." + }, + "feedback": { + "trigger": "Feedback", + "title": "Feedback e contatti", + "description": "Contattaci direttamente o apri una segnalazione se qualcosa non funziona o manca.", + "emailLabel": "Email", + "xLabel": "X", + "reportIssue": "Segnala problema / invia feedback", + "openFailed": "Apertura del link non riuscita." + }, + "keyboardShortcuts": { + "trigger": "Scorciatoie", + "title": "Scorciatoie da tastiera", + "description": "Riferimento rapido per i controlli della timeline e dell'editor.", + "customizeTooltip": "Personalizza scorciatoie", + "customize": "Personalizza", + "panTimeline": "Sposta timeline", + "zoomTimeline": "Zoom timeline", + "cycleAnnotations": "Scorri annotazioni", + "tab": "Tab" + }, + "actions": { + "saveAgain": "Salva di nuovo", + "showInFolder": "Mostra nella cartella" + }, + "project": { + "untitled": "Senza titolo", + "unsavedChangesTitle": "Modifiche non salvate", + "unsavedChangesDescription": "Salvare il progetto corrente prima di {{action}}?", + "discardChanges": "Ignora modifiche", + "saveProject": "Salva progetto" + }, + "exportTips": { + "autoZooms": "Suggerimento: Disattiva gli zoom applicati automaticamente nelle impostazioni", + "experimentalBuilds": "Suggerimento: Attiva l’accesso alle build sperimentali nelle impostazioni", + "cursorAppearance": "Suggerimento: Puoi personalizzare l’aspetto del cursore" + }, + "account": { "title": "Account", "comingSoon": "Account in arrivo" }, + "nativeCaptureUnavailable": { + "title": "Niente è rotto, ma non sarà possibile renderizzare un overlay del cursore animato.", + "description": "Il tuo dispositivo non supporta la cattura nativa. Le cause possono essere varie e non ancora identificate. Recordly funziona comunque, ma non è possibile applicare lo smoothing del cursore.", + "confirm": "Ok" + }, + "exportStatus": { + "exporting": "Esportazione in corso", + "renderingFile": "Rendering del file in corso.", + "preparing": "Preparazione esportazione...", + "completePercent": "{{percent}}% completato", + "issue": "Problema di esportazione", + "complete": "Esportazione completata", + "savedSuccessfully": "Il tuo file è stato salvato con successo." + }, + "export": { + "processingAudioEdits": "Elaborazione audio con modifiche di velocità/overlay" + }, + "toolbar": { + "addLayer": "Aggiungi livello", + "splitClip": "Dividi clip (C)" + }, + "timeline": { + "expand": "Espandi timeline", + "collapse": "Comprimi timeline" + }, + "openRecordingsFolder": "Apri cartella registrazioni" +} diff --git a/src/i18n/locales/it/launch.json b/src/i18n/locales/it/launch.json new file mode 100644 index 000000000..0dbdbea34 --- /dev/null +++ b/src/i18n/locales/it/launch.json @@ -0,0 +1,95 @@ +{ + "recording": { + "disableSystemAudio": "Disabilita audio di sistema", + "enableSystemAudio": "Abilita audio di sistema", + "disableMicrophone": "Disabilita microfono", + "enableMicrophone": "Abilita microfono", + "micToggleDisabledTip": "Il microfono non può essere attivato/disattivato durante la registrazione", + "disableWebcam": "Disabilita overlay webcam", + "enableWebcam": "Abilita overlay webcam", + "countdownDelay": "Ritardo conto alla rovescia", + "noDelay": "Nessun ritardo", + "record": "Registra", + "recordingFolder": "Percorso registrazioni: {{path}}", + "chooseRecordingsFolder": "Scegli percorso registrazioni", + "folderPath": "Percorso: /{{name}}/", + "openVideoFile": "Apri file video", + "openProject": "Apri progetto", + "hideHudFromVideo": "Nascondi HUD dalla registrazione", + "showHudInVideo": "Mostra HUD nella registrazione", + "hideHud": "Nascondi HUD", + "closeApp": "Chiudi app", + "screens": "Schermi", + "windows": "Finestre", + "screen": "Schermo", + "window": "Finestra", + "noSourcesFound": "Nessuna sorgente trovata", + "microphone": "Microfono", + "turnOffMicrophone": "Spegni microfono", + "selectMicToEnable": "Seleziona un microfono da abilitare", + "noMicrophonesFound": "Nessun microfono trovato", + "webcam": "Webcam", + "turnOffWebcam": "Spegni webcam", + "hideFloatingWebcamPreview": "Nascondi anteprima fluttuante", + "showFloatingWebcamPreview": "Mostra anteprima fluttuante", + "selectWebcamToEnable": "Seleziona una webcam da abilitare", + "noWebcamsFound": "Nessuna webcam trovata", + "recordingsFolder": "Percorso registrazioni", + "language": "Lingua", + "paused": "IN PAUSA", + "rec": "REC", + "resume": "Riprendi", + "pause": "Pausa", + "stop": "Stop", + "cancel": "Annulla", + "more": "Altro", + "update": { + "update": "Aggiorna", + "updated": "Aggiornato", + "idleTitle": "Verifica aggiornamenti.", + "checkingTitle": "Verifica aggiornamenti in corso...", + "downloadingTitle": "Download dell'aggiornamento in corso...", + "errorTitle": "Verifica aggiornamenti non riuscita. Clicca per riprovare.", + "upToDateTitle": "Recordly {{version}} è aggiornato.", + "availableTitle": "Recordly {{version}} è disponibile.", + "availableGenericTitle": "È disponibile un aggiornamento." + } + }, + "sourceSelector": { + "loadingSources": "Caricamento sorgenti...", + "screens": "Schermi", + "windows": "Finestre", + "noScreensAvailable": "Nessuno schermo disponibile", + "noWindowsAvailable": "Nessuna finestra disponibile", + "windowsNote": "Solo le finestre visibili (non ridotte a icona) possono essere registrate.", + "windowPlaceholder": "Finestra", + "cancel": "Annulla", + "share": "Condividi" + }, + "permissions": { + "screenRecordingNeeded": "Recordly necessita del permesso di Registrazione schermo prima di iniziare. Sono state aperte le Impostazioni di sistema. Dopo averlo abilitato, esci e riapri Recordly.", + "screenRecordingMissing": "Manca ancora il permesso di Registrazione schermo. Le Impostazioni di sistema sono state riaperte. Abilitalo, poi esci e riapri Recordly prima di registrare.", + "accessibilityNeeded": "Recordly necessita anche del permesso di Accessibilità per il tracciamento del cursore. Sono state aperte le Impostazioni di sistema. Dopo averlo abilitato, esci e riapri Recordly.", + "accessibilityMissing": "Manca ancora il permesso di Accessibilità. Le Impostazioni di sistema sono state riaperte. Abilitalo, poi esci e riapri Recordly prima di registrare.", + "selectSource": "Seleziona una sorgente da registrare", + "systemAudioUnavailable": "L'audio di sistema non è disponibile per questa sorgente. La registrazione continuerà senza audio di sistema.", + "microphoneDenied": "L'accesso al microfono è stato negato. La registrazione continuerà senza audio del microfono.", + "failedToStart": "Avvio della registrazione non riuscito: {{error}}", + "failedToStartGeneric": "Avvio della registrazione non riuscito" + }, + "updateToast": { + "availableTitle": "Aggiornamento disponibile", + "experimentalAvailableTitle": "Aggiornamento sperimentale disponibile", + "experimentalDescription": "Hai attivato gli aggiornamenti sperimentali, quindi puoi scegliere di provare l'ultimo aggiornamento di Recordly prima che sia disponibile per tutti.", + "downloadingTitle": "Download dell'aggiornamento", + "readyTitle": "Pronto per il riavvio", + "checkErrorTitle": "Impossibile verificare gli aggiornamenti", + "downloadErrorTitle": "Impossibile scaricare l'aggiornamento", + "experimentalBadge": "Sperimentale", + "previewBadge": "Anteprima", + "notNow": "Non ora", + "updateNow": "Aggiorna ora", + "restartToUpdate": "Riavvia per aggiornare", + "tryAgain": "Riprova" + } +} diff --git a/src/i18n/locales/it/settings.json b/src/i18n/locales/it/settings.json new file mode 100644 index 000000000..4a00e1db7 --- /dev/null +++ b/src/i18n/locales/it/settings.json @@ -0,0 +1,265 @@ +{ + "zoom": { + "level": "Livello di zoom", + "selectRegion": "Seleziona una regione di zoom da modificare", + "deleteZoom": "Elimina zoom", + "modeAuto": "Auto", + "modeManual": "Manuale", + "modeManualDescription": "Imposta un punto focale fisso per questo zoom", + "modeAutoDescription": "La camera si ricentra quando il cursore si avvicina al bordo della vista zoomata" + }, + "trim": { + "deleteRegion": "Elimina regione di taglio" + }, + "speed": { + "playbackSpeed": "Velocità di riproduzione", + "selectRegion": "Seleziona una regione di velocità da modificare", + "deleteRegion": "Elimina regione di velocità", + "label": "Velocità" + }, + "clip": { + "title": "Clip", + "mute": "Disattiva audio", + "mutedState": "Audio disattivato", + "unmutedState": "Audio in riproduzione", + "separateClipFromAudio": "Separa clip dall'audio", + "delete": "Elimina clip" + }, + "effects": { + "title": "Effetti video", + "show": "Mostra", + "showCursor": "Mostra cursore", + "loopCursor": "Cursore in loop", + "cursorStyle": "Stile cursore", + "cursorStyleOptions": { + "macos": "macOS", + "tahoe": "Tahoe", + "tahoe-inverted": "Tahoe invertito", + "windows11": "Windows 11", + "dot": "Punto", + "figma": "Minimal", + "lavender": "Lavanda", + "parched": "Parched", + "chooper": "Chooper", + "amongus": "Among Us", + "turtle": "Tartaruga" + }, + "backgroundBlur": "Sfocatura", + "zoomMotionBlur": "Motion blur dello zoom", + "temporalZoomMotionBlur": "Sfocatura zoom temporale", + "temporalZoomMotionBlurDescription": "Controlla la finestra dell'otturatore e i campioni di frame usati dal nuovo passaggio di sfocatura zoom.", + "zoomMotionBlurSamples": "Campioni di sfocatura", + "zoomMotionBlurShutter": "Otturatore", + "auto": "Auto", + "connectZooms": "Connetti zoom", + "connectZoomsDescription": "Unisce regioni di zoom consecutive in un movimento di camera continuo.", + "autoApplyFreshRecordingZooms": "Applica automaticamente zoom alle nuove registrazioni", + "autoApplyFreshRecordingZoomsDescription": "Suggerisci automaticamente zoom con ricentratura ai bordi quando apri una nuova registrazione.", + "zoomGeneralTitle": "Generale", + "zoomGeneralDescription": "Impostazioni di movimento globali per ogni transizione di zoom.", + "zoomInTitle": "Zoom in", + "zoomInDescription": "Controlla come la camera entra in una regione di zoom.", + "zoomOutTitle": "Zoom out", + "zoomOutDescription": "Controlla come la camera esce da una regione di zoom.", + "connectedZoomTitle": "Tra zoom", + "connectedZoomDescription": "Regola lo scorrimento tra regioni di zoom consecutive quando la connessione è abilitata.", + "motionPresetsTitle": "Preset di movimento", + "motionPresetsZoomHint": "I preset di movimento dello zoom sono disponibili nelle Impostazioni.", + "animationPresets": "Preset di animazione", + "cursorMotionPresets": "Preset di movimento del cursore", + "motionPresets": { + "focused": { + "label": "Focalizzato", + "description": "Movimento più rapido per demo, walkthrough e registrazioni quotidiane." + }, + "smooth": { + "label": "Fluido", + "description": "Movimento più delicato per presentazioni, video in stile keynote e reveal raffinati." + } + }, + "zoomInDuration": "Durata zoom in", + "zoomInOverlap": "Sovrapposizione zoom in", + "zoomOutDuration": "Durata zoom out", + "zoomInEasing": "Curva zoom in", + "zoomOutEasing": "Curva zoom out", + "connectedZoomGap": "Distanza zoom connessi", + "connectedZoomDuration": "Durata zoom connessi", + "connectedZoomEasing": "Curva pan connesso", + "zoomEasingOptions": { + "recordly": "Recordly", + "glide": "Glide", + "smooth": "Fluido", + "snappy": "Rapido", + "linear": "Lineare" + }, + "cursorSize": "Dimensione cursore", + "cursorSmoothing": "Smoothing cursore", + "cursorSpringStiffness": "Rigidezza molla cursore", + "cursorSpringDamping": "Smorzamento molla cursore", + "cursorSpringMass": "Massa molla cursore", + "off": "Off", + "cursorClickEffects": { + "title": "Click Effects", + "advanced": "Advanced", + "advancedShow": "Show advanced click effect controls", + "advancedHide": "Hide advanced click effect controls", + "color": "Effect Color", + "size": "Effect Size", + "opacity": "Effect Opacity", + "duration": "Effect Duration", + "none": { + "label": "Off", + "description": "No click graphic. Only the cursor motion changes when you click." + }, + "ripple": { + "label": "Ripple", + "description": "Expanding rings radiate from each click so taps read clearly in motion." + }, + "spotlight": { + "label": "Spotlight", + "description": "A soft halo flashes around the pointer to emphasize the clicked area." + }, + "echo": { + "label": "Echo", + "description": "A pair of soft rings that spread outward with a cleaner pulse." + } + }, + "cursorMotionBlur": "Motion blur del cursore", + "cursorClickBounce": "Rimbalzo al clic del cursore", + "cursorClickBounceDuration": "Velocità rimbalzo", + "cursorSway": "Oscillazione cursore", + "webcam": "Overlay webcam", + "webcamFootage": "Filmato webcam", + "webcamFootageDescription": "Nessun filmato webcam collegato a questo video", + "uploadWebcamFootage": "Carica filmato", + "replaceWebcamFootage": "Sostituisci filmato", + "removeWebcamFootage": "Rimuovi filmato", + "webcamFootageAdded": "Filmato webcam collegato", + "webcamFootageRemoved": "Filmato webcam rimosso", + "webcamSize": "Dimensione webcam", + "webcamWidth": "Larghezza webcam", + "webcamHeight": "Altezza webcam", + "webcamCrop": "Ritaglio webcam", + "webcamReactToZoom": "La webcam reagisce allo zoom", + "webcamMirror": "Specchia webcam", + "webcamRoundness": "Arrotondamento webcam", + "webcamShadow": "Ombra webcam", + "shadow": "Ombra", + "radius": "Raggio", + "roundness": "Arrotondamento", + "padding": "Padding", + "paddingLinked": "Collegato (uniforme)", + "paddingUnlinked": "Scollegato (asimmetrico)", + "paddingTop": "Alto", + "paddingBottom": "Basso", + "paddingLeft": "Sinistra", + "paddingRight": "Destra", + "removeBackground": "Rimuovi sfondo" + }, + "sections": { + "scene": "Scena", + "captions": "Sottotitoli", + "caption": "Sottotitolo", + "zoom": "Zoom", + "cursor": "Cursore", + "webcam": "Webcam", + "frame": "Cornice", + "crop": "Ritaglio" + }, + "captions": { + "selectOnTimeline": "Seleziona un sottotitolo sulla timeline per modificarlo.", + "enabled": "Mostra", + "timelineQuickAdd": "Passa il mouse per aggiungere sulla timeline", + "language": "Lingua", + "downloading": "Download in corso...", + "deleteModel": "Elimina modello", + "clearModel": "Cancella modello", + "downloadModel": "Scarica modello", + "generating": "Generazione in corso...", + "generateFull": "Genera sottotitoli", + "regenerateFull": "Rigenera sottotitoli", + "clearFull": "Cancella sottotitoli", + "fontSettings": "Impostazioni font", + "defaultFont": "Predefinito", + "fontFamily": "Font", + "fontSize": "Dimensione font", + "rowCount": "Righe", + "animation": "Animazione", + "animationOff": "Off", + "animationFade": "Dissolvenza", + "animationRise": "Salita", + "animationPop": "Pop", + "bottomOffset": "Margine inferiore", + "maxWidth": "Larghezza massima", + "boxRadius": "Raggio riquadro", + "backgroundOpacity": "Opacità sfondo", + "textColor": "Colore testo", + "editor": { + "text": "Text", + "start": "Start", + "end": "End", + "split": "Split", + "merge": "Merge", + "delete": "Delete" + } + }, + "crop": { + "title": "Ritaglia video", + "instruction": "Trascina su ogni lato per regolare l'area di ritaglio", + "top": "Alto", + "bottom": "Basso", + "left": "Sinistra", + "right": "Destra", + "openEditor": "Apri editor di ritaglio" + }, + "background": { + "title": "Sfondo", + "image": "Immagine", + "color": "Colore", + "gradient": "Gradiente", + "wallpaperPreview": "Anteprima sfondo", + "uploadCustom": "Carica personalizzato", + "uploadSuccess": "Immagine personalizzata caricata con successo!", + "uploadError": "Carica un file immagine JPG o JPEG.", + "uploadErrorDescription": "Sono supportate solo immagini JPG e JPEG." + }, + "export": { + "title": "Esporta", + "mp4": "MP4", + "gif": "GIF", + "quality": { + "low": "Bassa", + "medium": "Media", + "high": "Alta", + "original": "Originale" + }, + "fpsTitle": "FPS", + "loop": "Loop", + "outputDimensions": "Output: {{dimensions}}px", + "sizePresetOriginalShort": "Orig", + "sizePresetMediumShort": "Med", + "sizePresetLargeShort": "Gr", + "loadProject": "Carica progetto", + "saveProject": "Salva progetto", + "exportVideo": "Esporta {{format}}", + "reportBug": "Segnala bug", + "starOnGithub": "Metti una stella su GitHub" + }, + "audio": { + "title": "Audio", + "volumeTitle": "Audio", + "volume": "Volume", + "normalize": "Normalizza", + "sourceTracksTitle": "Audio sorgente clip", + "systemLabel": "Sorgente sistema", + "micLabel": "Sorgente microfono", + "mixedLabel": "Sorgente", + "deleteRegion": "Elimina audio" + }, + "updates": { + "experimentalDescription": "Hai attivato gli aggiornamenti sperimentali, quindi puoi scegliere di provare l'ultimo aggiornamento di Recordly prima che sia disponibile per tutti.", + "title": "Aggiornamenti", + "experimental": "Aggiornamenti sperimentali", + "saveFailed": "Impossibile cambiare il canale di aggiornamento." + } +} diff --git a/src/i18n/locales/it/shortcuts.json b/src/i18n/locales/it/shortcuts.json new file mode 100644 index 000000000..692a30a40 --- /dev/null +++ b/src/i18n/locales/it/shortcuts.json @@ -0,0 +1,16 @@ +{ + "actions": { + "addZoom": "Aggiungi zoom", + "addTrim": "Aggiungi taglio", + "addSpeed": "Aggiungi velocità", + "addAnnotation": "Aggiungi annotazione", + "addKeyframe": "Aggiungi keyframe", + "deleteSelected": "Elimina selezionato", + "playPause": "Riproduci / Pausa", + "cycleForward": "Scorri annotazioni avanti", + "cycleBackward": "Scorri annotazioni indietro", + "deleteSelectedAlt": "Elimina selezionato (alt)", + "panTimeline": "Sposta timeline", + "zoomTimeline": "Zoom timeline" + } +} diff --git a/src/i18n/locales/it/timeline.json b/src/i18n/locales/it/timeline.json new file mode 100644 index 000000000..2f93eef0c --- /dev/null +++ b/src/i18n/locales/it/timeline.json @@ -0,0 +1,41 @@ +{ + "zoom": { + "cannotPlace": "Impossibile posizionare lo zoom qui", + "existsOrNoSpace": "Uno zoom esiste già in questa posizione o non c'è abbastanza spazio disponibile.", + "suggestHandlerUnavailable": "Gestore dei suggerimenti zoom non disponibile", + "noTelemetry": "Telemetria del cursore non disponibile", + "recordFirst": "Registra prima uno screencast per generare suggerimenti basati sul cursore.", + "noUsableTelemetry": "Telemetria del cursore non utilizzabile", + "notEnoughMovement": "La registrazione non include abbastanza dati di movimento del cursore.", + "noInteractionMoments": "Nessun momento di interazione chiaro trovato", + "tryRecording": "Prova una registrazione con pause o clic attorno alle azioni importanti.", + "noAutoZoomSlots": "Nessuno slot di auto-zoom disponibile", + "dwellPointsOverlap": "I punti di permanenza rilevati si sovrappongono a regioni di zoom esistenti.", + "addedSuggestions": "Aggiunti {{count}} suggerimenti di zoom basati sull'interazione", + "label": "Zoom {{index}}", + "addZoom": "Aggiungi zoom (Z)", + "suggestZooms": "Suggerisci zoom dal cursore" + }, + "trim": { + "cannotPlace": "Impossibile posizionare il taglio qui", + "existsOrNoSpace": "Un taglio esiste già in questa posizione o non c'è abbastanza spazio disponibile.", + "label": "Taglio {{index}}", + "addTrim": "Aggiungi taglio (T)" + }, + "speed": { + "cannotPlace": "Impossibile posizionare la velocità qui", + "existsOrNoSpace": "Una regione di velocità esiste già in questa posizione o non c'è abbastanza spazio disponibile.", + "label": "Velocità" + }, + "annotation": { + "label": "Annotazione", + "image": "Immagine", + "addAnnotation": "Aggiungi annotazione (A)" + }, + "audio": { + "label": "Audio" + }, + "addSpeed": "Aggiungi velocità (S)", + "resizeLeft": "Ridimensiona a sinistra", + "resizeRight": "Ridimensiona a destra" +} diff --git a/src/i18n/locales/ko/common.json b/src/i18n/locales/ko/common.json new file mode 100644 index 000000000..f036318e0 --- /dev/null +++ b/src/i18n/locales/ko/common.json @@ -0,0 +1,26 @@ +{ + "app": { + "name": "Recordly", + "editorTitle": "Recordly 편집기", + "subtitle": "화면 녹화 및 편집", + "language": "언어", + "manageRecordings": "녹화 폴더 열기" + }, + "actions": { + "cancel": "취소", + "close": "닫기", + "export": "내보내기", + "load": "불러오기", + "redo": "다시 실행", + "reset": "초기화", + "save": "저장", + "undo": "실행 취소", + "delete": "삭제", + "done": "완료" + }, + "errors": { + "invalidFileType": "잘못된 파일 형식입니다", + "failedToUploadImage": "이미지 업로드에 실패했습니다", + "fileReadError": "파일을 읽는 중 오류가 발생했습니다." + } +} diff --git a/src/i18n/locales/ko/dialogs.json b/src/i18n/locales/ko/dialogs.json new file mode 100644 index 000000000..e63fa57fa --- /dev/null +++ b/src/i18n/locales/ko/dialogs.json @@ -0,0 +1,62 @@ +{ + "export": { + "pleaseTryAgain": "다시 시도해 주세요", + "compilingGifProgress": "GIF를 만드는 중... {{progress}}%", + "compilingGifWait": "GIF를 만드는 중입니다... 잠시 시간이 걸릴 수 있습니다", + "takeMoment": "잠시만 기다려 주세요...", + "exportFailed": "내보내기 실패", + "compilingGifTitle": "GIF 생성 중", + "exportingFormat": "{{format}} 내보내는 중", + "exportComplete": "내보내기 완료", + "formatReady": "{{format}} 파일이 준비되었습니다", + "showInFolder": "폴더에서 보기", + "compiling": "생성 중", + "renderingFrames": "프레임 렌더링 중", + "processing": "처리 중...", + "status": "상태", + "format": "형식", + "compilingStatus": "생성 중...", + "frames": "프레임", + "cancelExport": "내보내기 취소", + "reopenSaveDialog": "저장 대화상자 다시 열기", + "savedSuccess": "{{format}} 파일을 저장했습니다!" + }, + "addFont": { + "title": "Google 폰트 추가", + "heading": "Google 폰트 추가", + "description": "주석에 사용할 Google Fonts의 사용자 지정 글꼴을 추가합니다.", + "urlLabel": "Google Fonts 가져오기 URL", + "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap", + "urlHelp": "Google Fonts에서 가져오세요: 글꼴 선택 → \"Get font\" 클릭 → @import URL 복사", + "nameLabel": "표시 이름", + "namePlaceholder": "내 사용자 지정 글꼴", + "nameHelp": "글꼴 선택기에서 표시될 이름입니다", + "adding": "추가 중...", + "addFont": "글꼴 추가", + "enterUrl": "Google Fonts 가져오기 URL을 입력해 주세요", + "invalidUrl": "올바른 Google Fonts URL을 입력해 주세요", + "enterName": "글꼴 이름을 입력해 주세요", + "extractFailed": "URL에서 글꼴 패밀리를 추출할 수 없습니다", + "addSuccess": "글꼴 \"{{name}}\"을(를) 추가했습니다", + "addFailed": "글꼴 추가에 실패했습니다", + "loadTimeout": "글꼴을 불러오는 데 너무 오래 걸립니다. URL을 확인한 뒤 다시 시도해 주세요.", + "loadFailed": "글꼴을 불러올 수 없습니다. Google Fonts URL이 올바른지 확인해 주세요." + }, + "shortcutsConfig": { + "title": "키보드 단축키", + "configurable": "변경 가능", + "fixed": "고정", + "pressEscToCancel": "Esc를 눌러 취소", + "clickToChange": "클릭해서 변경", + "pressAKey": "키를 누르세요…", + "alreadyUsedBy": "이미 <strong>{{action}}</strong>에서 사용 중입니다", + "swap": "바꾸기", + "reserved": "이 단축키는 \"{{label}}\"에 예약되어 있어 다시 지정할 수 없습니다.", + "saved": "키보드 단축키를 저장했습니다", + "resetNotice": "기본 단축키로 초기화했습니다 — 적용하려면 저장을 클릭하세요", + "instructions": "단축키를 클릭한 다음 새 키 조합을 누르세요. 취소하려면 Esc를 누르세요.", + "resetToDefaults": "기본값으로 초기화", + "cancel": "취소", + "save": "저장" + } +} diff --git a/src/i18n/locales/ko/editor.json b/src/i18n/locales/ko/editor.json new file mode 100644 index 000000000..ddc411b9e --- /dev/null +++ b/src/i18n/locales/ko/editor.json @@ -0,0 +1,154 @@ +{ + "playback": { + "play": "재생", + "pause": "일시 정지", + "skipBack": "뒤로 건너뛰기", + "skipForward": "앞으로 건너뛰기", + "muteUnmute": "음소거/해제", + "volume": "미리보기 볼륨" + }, + "annotations": { + "settings": "주석 설정", + "active": "활성", + "text": "텍스트", + "image": "이미지", + "arrow": "화살표", + "blur": "흐리게", + "textContent": "텍스트 내용", + + "textPlaceholder": "텍스트를 입력하세요...", + "fontStyle": "글꼴 스타일", + "selectStyle": "스타일 선택", + "size": "크기", + "toggleBold": "굵게 전환", + "toggleItalic": "기울임꼴 전환", + "toggleUnderline": "밑줄 전환", + "alignLeft": "왼쪽 정렬", + "alignCenter": "가운데 정렬", + "alignRight": "오른쪽 정렬", + "textColor": "텍스트 색상", + "background": "배경", + "none": "없음", + "clearBackground": "배경 지우기", + "uploadImage": "이미지 업로드", + "supportedFormats": "지원 형식: JPG, PNG, GIF, WebP", + "arrowDirection": "화살표 방향", + "strokeWidth": "선 두께: {{width}}px", + "arrowColor": "화살표 색상", + "deleteAnnotation": "주석 삭제", + "shortcutsAndTips": "단축키 및 팁", + "tipSelectAnnotation": "재생 헤드를 겹치는 주석 구간으로 이동한 뒤 항목을 선택하세요.", + "tipCycleForward": "Tab 키로 겹치는 항목을 순환할 수 있습니다.", + "tipCycleBackward": "Shift+Tab으로 이전 항목으로 이동할 수 있습니다.", + "imageUploadSuccess": "이미지를 업로드했습니다!", + "imageUploadError": "JPG, PNG, GIF 또는 WebP 이미지 파일을 업로드해 주세요.", + "blurStrength": "블러 강도: {{strength}}", + "solidColor": "단색 (검열)", + "borderRadius": "테두리 반경" + }, + + "fontStyles": { + "classic": "클래식", + "editor": "에디터", + "strong": "강조", + "typewriter": "타자기", + "deco": "장식", + "simple": "심플", + "modern": "모던", + "clean": "클린" + }, + "format": { + "mp4Video": "MP4 비디오", + "mp4Description": "고화질 비디오 파일", + "gifAnimation": "GIF 애니메이션", + "gifDescription": "공유용 애니메이션 이미지" + }, + "gifOptions": { + "frameRate": "프레임 속도", + "outputSize": "출력 크기", + "outputDimensions": "출력: {{width}} × {{height}}px", + "loopAnimation": "반복 재생", + "loopDescription": "GIF가 계속 반복 재생됩니다" + }, + "tutorial": { + "howTrimmingWorks": "트림 사용법", + "title": "트림 작동 방식", + "understanding": "비디오에서 원하지 않는 부분을 잘라내는 방법을 알아보세요.", + "descriptionP1": "트림 도구는 제거하고 싶은 구간을", + "descriptionRemove": "삭제", + "descriptionP2": "하도록 설정합니다.", + "descriptionP3": "타임라인에서 빨간 트림 구간으로 덮인 부분은 내보낼 때 잘려 나갑니다.", + "visualExample": "예시 보기", + "removed": "제거됨", + "kept": "유지됨", + "finalVideo": "최종 비디오", + "part": "파트 {{number}}", + "addTrimStep": "1. 트림 추가", + "addTrimDesc": "T 키를 누르거나 가위 아이콘을 클릭해 제거할 구간을 표시하세요.", + "adjustStep": "2. 조정", + "adjustDesc": "빨간 구간의 가장자리를 드래그해 정확히 잘라낼 범위를 지정하세요." + }, + "feedback": { + "trigger": "피드백", + "title": "피드백 및 문의", + "description": "문제가 있거나 빠진 기능이 있으면 직접 연락하거나 이슈를 등록해 주세요.", + "emailLabel": "이메일", + "xLabel": "X", + "reportIssue": "이슈 등록 / 피드백 보내기", + "openFailed": "링크를 열지 못했습니다." + }, + "keyboardShortcuts": { + "trigger": "단축키", + "title": "키보드 단축키", + "description": "타임라인과 편집기 컨트롤에 대한 빠른 참고입니다.", + "customizeTooltip": "단축키 사용자 지정", + "customize": "사용자 지정", + "panTimeline": "타임라인 이동", + "zoomTimeline": "타임라인 확대/축소", + "cycleAnnotations": "주석 순환", + "tab": "Tab" + }, + "actions": { + "saveAgain": "다시 저장", + "showInFolder": "폴더에서 보기" + }, + "project": { + "untitled": "제목 없음", + "unsavedChangesTitle": "저장되지 않은 변경 사항", + "unsavedChangesDescription": "{{action}} 전에 현재 프로젝트를 저장하시겠습니까?", + "discardChanges": "변경 사항 버리기", + "saveProject": "프로젝트 저장" + }, + "exportTips": { + "autoZooms": "팁: 설정에서 자동 적용 확대를 끌 수 있습니다", + "experimentalBuilds": "팁: 설정에서 실험적 빌드 액세스를 켜 보세요", + "cursorAppearance": "팁: 커서 모양을 사용자 지정할 수 있습니다" + }, + "account": { "title": "계정", "comingSoon": "계정 기능 준비 중" }, + "nativeCaptureUnavailable": { + "title": "문제가 생긴 것은 아니지만, 애니메이션 커서 오버레이를 렌더링할 수 없습니다.", + "description": "이 장치는 네이티브 캡처를 지원하지 않습니다. 아직 확인하지 못한 여러 이유가 있을 수 있습니다. Recordly는 계속 작동하지만 커서 스무딩은 사용할 수 없습니다.", + "confirm": "확인" + }, + "exportStatus": { + "exporting": "내보내는 중", + "renderingFile": "파일을 렌더링하고 있습니다.", + "preparing": "내보내기를 준비하는 중...", + "completePercent": "{{percent}}% 완료", + "issue": "내보내기 오류", + "complete": "내보내기 완료", + "savedSuccessfully": "파일이 성공적으로 저장되었습니다." + }, + "export": { + "processingAudioEdits": "속도/오버레이 편집으로 오디오 처리 중" + }, + "toolbar": { + "addLayer": "레이어 추가", + "splitClip": "클립 분할 (C)" + }, + "timeline": { + "expand": "타임라인 펼치기", + "collapse": "타임라인 접기" + }, + "openRecordingsFolder": "녹화 폴더 열기" +} diff --git a/src/i18n/locales/ko/launch.json b/src/i18n/locales/ko/launch.json new file mode 100644 index 000000000..2e4683a56 --- /dev/null +++ b/src/i18n/locales/ko/launch.json @@ -0,0 +1,95 @@ +{ + "recording": { + "disableSystemAudio": "시스템 오디오 끄기", + "enableSystemAudio": "시스템 오디오 켜기", + "disableMicrophone": "마이크 끄기", + "enableMicrophone": "마이크 켜기", + "micToggleDisabledTip": "녹음 중에는 마이크를 전환할 수 없습니다", + "disableWebcam": "웹캠 오버레이 끄기", + "enableWebcam": "웹캠 오버레이 켜기", + "countdownDelay": "카운트다운 지연", + "noDelay": "지연 없음", + "record": "녹화", + "recordingFolder": "녹화 폴더: {{path}}", + "chooseRecordingsFolder": "녹화 폴더 선택", + "folderPath": "경로: /{{name}}/", + "openVideoFile": "비디오 파일 열기", + "openProject": "프로젝트 열기", + "hideHudFromVideo": "녹화에서 HUD 숨기기", + "showHudInVideo": "녹화에 HUD 표시", + "hideHud": "HUD 숨기기", + "closeApp": "앱 닫기", + "screens": "화면", + "windows": "창", + "screen": "화면", + "window": "창", + "noSourcesFound": "사용 가능한 소스를 찾을 수 없습니다", + "microphone": "마이크", + "turnOffMicrophone": "마이크 끄기", + "selectMicToEnable": "사용할 마이크를 선택하세요", + "noMicrophonesFound": "마이크를 찾을 수 없습니다", + "webcam": "웹캠", + "turnOffWebcam": "웹캠 끄기", + "hideFloatingWebcamPreview": "플로팅 미리보기 숨기기", + "showFloatingWebcamPreview": "플로팅 미리보기 표시", + "selectWebcamToEnable": "사용할 웹캠을 선택하세요", + "noWebcamsFound": "웹캠을 찾을 수 없습니다", + "recordingsFolder": "녹화 폴더", + "language": "언어", + "paused": "일시 정지", + "rec": "녹화 중", + "resume": "계속", + "pause": "일시 정지", + "stop": "중지", + "cancel": "취소", + "more": "더 보기", + "update": { + "update": "업데이트", + "updated": "업데이트 완료", + "idleTitle": "업데이트를 확인합니다.", + "checkingTitle": "업데이트를 확인하는 중...", + "downloadingTitle": "업데이트를 다운로드하는 중...", + "errorTitle": "업데이트 확인에 실패했습니다. 클릭하여 다시 시도하세요.", + "upToDateTitle": "Recordly {{version}}은(는) 최신 버전입니다.", + "availableTitle": "Recordly {{version}}을(를) 사용할 수 있습니다.", + "availableGenericTitle": "업데이트를 사용할 수 있습니다." + } + }, + "sourceSelector": { + "loadingSources": "소스를 불러오는 중...", + "screens": "화면", + "windows": "창", + "noScreensAvailable": "사용 가능한 화면이 없습니다", + "noWindowsAvailable": "사용 가능한 창이 없습니다", + "windowsNote": "보이는 창(최소화되지 않은 창)만 녹화할 수 있습니다.", + "windowPlaceholder": "창", + "cancel": "취소", + "share": "공유" + }, + "permissions": { + "screenRecordingNeeded": "녹화를 시작하기 전에 Recordly에 화면 녹화 권한이 필요합니다. 시스템 설정을 열었습니다. 권한을 허용한 뒤 Recordly를 종료하고 다시 실행해 주세요.", + "screenRecordingMissing": "화면 녹화 권한이 아직 없습니다. 시스템 설정을 다시 열었습니다. 권한을 허용한 뒤 Recordly를 종료하고 다시 실행한 후 녹화를 시작해 주세요.", + "accessibilityNeeded": "커서 추적을 위해 Recordly에 손쉬운 사용 권한도 필요합니다. 시스템 설정을 열었습니다. 권한을 허용한 뒤 Recordly를 종료하고 다시 실행해 주세요.", + "accessibilityMissing": "손쉬운 사용 권한이 아직 없습니다. 시스템 설정을 다시 열었습니다. 권한을 허용한 뒤 Recordly를 종료하고 다시 실행한 후 녹화를 시작해 주세요.", + "selectSource": "녹화할 소스를 선택해 주세요", + "systemAudioUnavailable": "이 소스에서는 시스템 오디오를 사용할 수 없습니다. 시스템 오디오 없이 녹화를 계속합니다.", + "microphoneDenied": "마이크 접근이 거부되었습니다. 마이크 오디오 없이 녹화를 계속합니다.", + "failedToStart": "녹화 시작에 실패했습니다: {{error}}", + "failedToStartGeneric": "녹화 시작에 실패했습니다" + }, + "updateToast": { + "availableTitle": "업데이트 사용 가능", + "experimentalAvailableTitle": "실험적 업데이트 사용 가능", + "experimentalDescription": "실험적 업데이트를 선택했으므로 Recordly의 최신 업데이트가 널리 제공되기 전에 먼저 테스트해 볼 수 있습니다.", + "downloadingTitle": "업데이트 다운로드 중", + "readyTitle": "다시 시작할 준비 완료", + "checkErrorTitle": "업데이트를 확인할 수 없습니다", + "downloadErrorTitle": "업데이트를 다운로드할 수 없습니다", + "experimentalBadge": "실험적", + "previewBadge": "미리보기", + "notNow": "나중에", + "updateNow": "지금 업데이트", + "restartToUpdate": "다시 시작하여 업데이트", + "tryAgain": "다시 시도" + } +} diff --git a/src/i18n/locales/ko/settings.json b/src/i18n/locales/ko/settings.json new file mode 100644 index 000000000..1c95bf2a6 --- /dev/null +++ b/src/i18n/locales/ko/settings.json @@ -0,0 +1,265 @@ +{ + "zoom": { + "level": "확대 레벨", + "selectRegion": "조정할 확대 구간을 선택하세요", + "deleteZoom": "확대 삭제", + "modeAuto": "자동", + "modeManual": "수동", + "modeManualDescription": "이 확대에 고정 초점을 설정합니다", + "modeAutoDescription": "카메라가 커서를 자동으로 따라갑니다" + }, + "trim": { + "deleteRegion": "트림 구간 삭제" + }, + "speed": { + "playbackSpeed": "재생 속도", + "selectRegion": "조정할 속도 구간을 선택하세요", + "deleteRegion": "속도 구간 삭제", + "label": "속도" + }, + "clip": { + "title": "클립", + "mute": "음소거", + "mutedState": "오디오가 음소거됨", + "unmutedState": "오디오가 재생 중", + "separateClipFromAudio": "클립에서 오디오 분리", + "delete": "클립 삭제" + }, + "effects": { + "title": "비디오 효과", + "show": "표시", + "showCursor": "커서 표시", + "loopCursor": "커서 반복", + "cursorStyle": "커서 스타일", + "cursorStyleOptions": { + "macos": "macOS", + "tahoe": "Tahoe", + "tahoe-inverted": "Tahoe Inverted", + "windows11": "Windows 11", + "dot": "Dot", + "figma": "Minimal", + "lavender": "Lavender", + "parched": "Parched", + "chooper": "Chooper", + "amongus": "Among Us", + "turtle": "Turtle" + }, + "backgroundBlur": "블러", + "zoomMotionBlur": "확대 모션 블러", + "temporalZoomMotionBlur": "Temporal Zoom Blur", + "temporalZoomMotionBlurDescription": "Control the shutter window and frame samples used by the newer zoom blur pass.", + "zoomMotionBlurSamples": "Blur Samples", + "zoomMotionBlurShutter": "Shutter", + "auto": "Auto", + "connectZooms": "확대 구간 연결", + "connectZoomsDescription": "연속된 확대 구간을 하나의 부드러운 카메라 이동으로 연결합니다.", + "autoApplyFreshRecordingZooms": "새 녹화에 확대 자동 적용", + "autoApplyFreshRecordingZoomsDescription": "새 녹화를 열면 커서를 따라가는 확대를 자동으로 제안합니다.", + "zoomGeneralTitle": "일반", + "zoomGeneralDescription": "모든 확대 전환에 적용되는 전체 모션 설정입니다.", + "zoomInTitle": "확대 시작", + "zoomInDescription": "카메라가 확대 구간에 진입하는 방식을 제어합니다.", + "zoomOutTitle": "확대 종료", + "zoomOutDescription": "카메라가 확대 구간에서 빠져나오는 방식을 제어합니다.", + "connectedZoomTitle": "확대 구간 사이", + "connectedZoomDescription": "연결이 활성화된 경우, 연속된 확대 구간 사이의 이동을 조정합니다.", + "motionPresetsTitle": "Motion Presets", + "motionPresetsZoomHint": "Zoom motion presets are available in Settings.", + "animationPresets": "Animation Presets", + "cursorMotionPresets": "Cursor Motion Presets", + "motionPresets": { + "focused": { + "label": "Focused", + "description": "Snappier motion for demos, walkthroughs, and everyday recordings." + }, + "smooth": { + "label": "Smooth", + "description": "Gentler motion for presentations, keynote-style videos, and polished reveals." + } + }, + "zoomInDuration": "확대 시작 시간", + "zoomInOverlap": "확대 시작 겹침", + "zoomOutDuration": "확대 종료 시간", + "zoomInEasing": "확대 시작 커브", + "zoomOutEasing": "확대 종료 커브", + "connectedZoomGap": "연결 확대 간격", + "connectedZoomDuration": "연결 확대 시간", + "connectedZoomEasing": "연결 이동 커브", + "zoomEasingOptions": { + "recordly": "Recordly", + "glide": "Glide", + "smooth": "Smooth", + "snappy": "Snappy", + "linear": "Linear" + }, + "cursorSize": "커서 크기", + "cursorSmoothing": "커서 보정", + "cursorSpringStiffness": "Cursor Spring Stiffness", + "cursorSpringDamping": "Cursor Spring Damping", + "cursorSpringMass": "Cursor Spring Mass", + "off": "끔", + "cursorClickEffects": { + "title": "Click Effects", + "advanced": "Advanced", + "advancedShow": "Show advanced click effect controls", + "advancedHide": "Hide advanced click effect controls", + "color": "Effect Color", + "size": "Effect Size", + "opacity": "Effect Opacity", + "duration": "Effect Duration", + "none": { + "label": "Off", + "description": "No click graphic. Only the cursor motion changes when you click." + }, + "ripple": { + "label": "Ripple", + "description": "Expanding rings radiate from each click so taps read clearly in motion." + }, + "spotlight": { + "label": "Spotlight", + "description": "A soft halo flashes around the pointer to emphasize the clicked area." + }, + "echo": { + "label": "Echo", + "description": "A pair of soft rings that spread outward with a cleaner pulse." + } + }, + "cursorMotionBlur": "커서 모션 블러", + "cursorClickBounce": "커서 클릭 바운스", + "cursorClickBounceDuration": "바운스 속도", + "cursorSway": "커서 흔들림", + "webcam": "웹캠 오버레이", + "webcamFootage": "웹캠 영상", + "webcamFootageDescription": "이 비디오에 연결된 웹캠 영상이 없습니다", + "uploadWebcamFootage": "영상 업로드", + "replaceWebcamFootage": "영상 교체", + "removeWebcamFootage": "영상 제거", + "webcamFootageAdded": "웹캠 영상을 연결했습니다", + "webcamFootageRemoved": "웹캠 영상을 제거했습니다", + "webcamSize": "웹캠 크기", + "webcamWidth": "웹캠 너비", + "webcamHeight": "웹캠 높이", + "webcamCrop": "웹캠 자르기", + "webcamReactToZoom": "확대 시 웹캠 반응", + "webcamMirror": "웹캠 미러링", + "webcamRoundness": "웹캠 둥글기", + "webcamShadow": "웹캠 그림자", + "shadow": "그림자", + "radius": "반경", + "roundness": "둥글기", + "padding": "여백", + "paddingLinked": "연결됨 (균일)", + "paddingUnlinked": "연결 안 됨 (비대칭)", + "paddingTop": "위", + "paddingBottom": "아래", + "paddingLeft": "왼쪽", + "paddingRight": "오른쪽", + "removeBackground": "배경 제거" + }, + "sections": { + "scene": "장면", + "captions": "자막", + "caption": "자막", + "zoom": "확대", + "cursor": "커서", + "webcam": "웹캠", + "frame": "프레임", + "crop": "자르기" + }, + "captions": { + "selectOnTimeline": "타임라인에서 자막을 선택하여 편집하세요.", + "enabled": "표시", + "timelineQuickAdd": "타임라인에 마우스를 올려 추가", + "language": "언어", + "downloading": "다운로드 중...", + "deleteModel": "모델 삭제", + "clearModel": "모델 초기화", + "downloadModel": "모델 다운로드", + "generating": "생성 중...", + "generateFull": "자막 생성", + "regenerateFull": "자막 다시 생성", + "clearFull": "자막 지우기", + "fontSettings": "글꼴 설정", + "defaultFont": "기본값", + "fontFamily": "글꼴", + "fontSize": "글꼴 크기", + "rowCount": "행 수", + "animation": "애니메이션", + "animationOff": "없음", + "animationFade": "페이드", + "animationRise": "올라오기", + "animationPop": "팝", + "bottomOffset": "하단 오프셋", + "maxWidth": "최대 너비", + "boxRadius": "박스 반경", + "backgroundOpacity": "배경 투명도", + "textColor": "텍스트 색상", + "editor": { + "text": "Text", + "start": "Start", + "end": "End", + "split": "Split", + "merge": "Merge", + "delete": "Delete" + } + }, + "crop": { + "title": "비디오 자르기", + "instruction": "각 가장자리를 드래그해 자르기 영역을 조정하세요", + "top": "상단", + "bottom": "하단", + "left": "왼쪽", + "right": "오른쪽", + "openEditor": "자르기 편집기 열기" + }, + "background": { + "title": "배경", + "image": "이미지", + "color": "색상", + "gradient": "그라데이션", + "wallpaperPreview": "배경화면 미리보기", + "uploadCustom": "사용자 이미지 업로드", + "uploadSuccess": "사용자 이미지를 업로드했습니다!", + "uploadError": "JPG 또는 JPEG 이미지 파일을 업로드해 주세요.", + "uploadErrorDescription": "JPG 또는 JPEG 이미지 파일을 업로드해 주세요." + }, + "export": { + "title": "내보내기", + "mp4": "MP4", + "gif": "GIF", + "quality": { + "low": "낮음", + "medium": "보통", + "high": "높음", + "original": "원본" + }, + "fpsTitle": "FPS", + "loop": "반복", + "outputDimensions": "출력: {{dimensions}}px", + "sizePresetOriginalShort": "원본", + "sizePresetMediumShort": "중간", + "sizePresetLargeShort": "큰", + "loadProject": "프로젝트 불러오기", + "saveProject": "프로젝트 저장", + "exportVideo": "{{format}} 내보내기", + "reportBug": "버그 신고", + "starOnGithub": "GitHub에서 별표 주기" + }, + "audio": { + "title": "오디오", + "volumeTitle": "오디오", + "volume": "볼륨", + "normalize": "정규화", + "sourceTracksTitle": "클립 소스 오디오", + "systemLabel": "시스템 소스", + "micLabel": "마이크 소스", + "mixedLabel": "소스", + "deleteRegion": "오디오 삭제" + }, + "updates": { + "experimentalDescription": "실험적 업데이트를 선택했으므로 Recordly의 최신 업데이트가 널리 제공되기 전에 먼저 테스트해 볼 수 있습니다.", + "title": "업데이트", + "experimental": "실험적 업데이트", + "saveFailed": "업데이트 채널을 변경하지 못했습니다." + } +} diff --git a/src/i18n/locales/ko/shortcuts.json b/src/i18n/locales/ko/shortcuts.json new file mode 100644 index 000000000..dc0303d20 --- /dev/null +++ b/src/i18n/locales/ko/shortcuts.json @@ -0,0 +1,16 @@ +{ + "actions": { + "addZoom": "확대 추가", + "addTrim": "트림 추가", + "addSpeed": "속도 추가", + "addAnnotation": "주석 추가", + "addKeyframe": "키프레임 추가", + "deleteSelected": "선택 항목 삭제", + "playPause": "재생 / 일시 정지", + "cycleForward": "주석 다음으로 순환", + "cycleBackward": "주석 이전으로 순환", + "deleteSelectedAlt": "선택 항목 삭제 (보조)", + "panTimeline": "타임라인 이동", + "zoomTimeline": "타임라인 확대/축소" + } +} diff --git a/src/i18n/locales/ko/timeline.json b/src/i18n/locales/ko/timeline.json new file mode 100644 index 000000000..810c775c0 --- /dev/null +++ b/src/i18n/locales/ko/timeline.json @@ -0,0 +1,41 @@ +{ + "zoom": { + "cannotPlace": "여기에는 확대를 배치할 수 없습니다", + "existsOrNoSpace": "이 위치에는 이미 확대가 있거나 사용할 수 있는 공간이 부족합니다.", + "suggestHandlerUnavailable": "확대 추천 기능을 사용할 수 없습니다", + "noTelemetry": "사용 가능한 커서 텔레메트리가 없습니다", + "recordFirst": "커서 기반 추천을 만들려면 먼저 화면 녹화를 진행해 주세요.", + "noUsableTelemetry": "사용 가능한 커서 텔레메트리가 없습니다", + "notEnoughMovement": "이 녹화에는 충분한 커서 이동 데이터가 포함되어 있지 않습니다.", + "noInteractionMoments": "명확한 상호작용 지점을 찾지 못했습니다", + "tryRecording": "중요한 동작 주변에서 잠시 멈추거나 클릭하는 녹화를 시도해 보세요.", + "noAutoZoomSlots": "자동 확대를 배치할 수 있는 구간이 없습니다", + "dwellPointsOverlap": "감지된 머무름 지점이 기존 확대 구간과 겹칩니다.", + "addedSuggestions": "상호작용 기반 확대 추천 {{count}}개를 추가했습니다", + "label": "확대 {{index}}", + "addZoom": "확대 추가 (Z)", + "suggestZooms": "커서 기반 확대 추천" + }, + "trim": { + "cannotPlace": "여기에는 트림을 배치할 수 없습니다", + "existsOrNoSpace": "이 위치에는 이미 트림이 있거나 사용할 수 있는 공간이 부족합니다.", + "label": "트림 {{index}}", + "addTrim": "트림 추가 (T)" + }, + "speed": { + "cannotPlace": "여기에는 속도 구간을 배치할 수 없습니다", + "existsOrNoSpace": "이 위치에는 이미 속도 구간이 있거나 사용할 수 있는 공간이 부족합니다.", + "label": "속도" + }, + "annotation": { + "label": "주석", + "image": "이미지", + "addAnnotation": "주석 추가 (A)" + }, + "audio": { + "label": "오디오" + }, + "addSpeed": "속도 추가 (S)", + "resizeLeft": "왼쪽 크기 조절", + "resizeRight": "오른쪽 크기 조절" +} diff --git a/src/i18n/locales/nl/common.json b/src/i18n/locales/nl/common.json new file mode 100644 index 000000000..0e7c2f33f --- /dev/null +++ b/src/i18n/locales/nl/common.json @@ -0,0 +1,26 @@ +{ + "app": { + "name": "Recordly", + "editorTitle": "Recordly Editor", + "subtitle": "Schermopname en bewerking", + "language": "Taal", + "manageRecordings": "Opnamemap openen" + }, + "actions": { + "cancel": "Annuleren", + "close": "Sluiten", + "export": "Exporteren", + "load": "Laden", + "redo": "Opnieuw", + "reset": "Herstellen", + "save": "Opslaan", + "undo": "Ongedaan maken", + "delete": "Verwijderen", + "done": "Klaar" + }, + "errors": { + "invalidFileType": "Ongeldig bestandstype", + "failedToUploadImage": "Afbeelding uploaden mislukt", + "fileReadError": "Er is een fout opgetreden bij het lezen van het bestand." + } +} diff --git a/src/i18n/locales/nl/dialogs.json b/src/i18n/locales/nl/dialogs.json new file mode 100644 index 000000000..32ca08e0a --- /dev/null +++ b/src/i18n/locales/nl/dialogs.json @@ -0,0 +1,62 @@ +{ + "export": { + "pleaseTryAgain": "Probeer het opnieuw", + "compilingGifProgress": "GIF samenstellen... {{progress}}%", + "compilingGifWait": "GIF samenstellen... Dit kan even duren", + "takeMoment": "Dit kan even duren...", + "exportFailed": "Export mislukt", + "compilingGifTitle": "GIF samenstellen", + "exportingFormat": "{{format}} exporteren", + "exportComplete": "Export voltooid", + "formatReady": "Je {{format}} is klaar", + "showInFolder": "Tonen in map", + "compiling": "Samenstellen", + "renderingFrames": "Frames renderen", + "processing": "Verwerken...", + "status": "Status", + "format": "Formaat", + "compilingStatus": "Samenstellen...", + "frames": "Frames", + "cancelExport": "Export annuleren", + "reopenSaveDialog": "Opslagvenster opnieuw openen", + "savedSuccess": "{{format}} succesvol opgeslagen!" + }, + "addFont": { + "title": "Google-lettertype toevoegen", + "heading": "Google-lettertype toevoegen", + "description": "Voeg een aangepast lettertype van Google Fonts toe om te gebruiken in je annotaties.", + "urlLabel": "Google Fonts import-URL", + "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap", + "urlHelp": "Haal dit op via Google Fonts: selecteer een lettertype → klik op \"Get font\" → kopieer de @import-URL", + "nameLabel": "Weergavenaam", + "namePlaceholder": "Mijn aangepast lettertype", + "nameHelp": "Zo verschijnt het lettertype in de lettertypekiezer", + "adding": "Toevoegen...", + "addFont": "Lettertype toevoegen", + "enterUrl": "Voer een Google Fonts import-URL in", + "invalidUrl": "Voer een geldige Google Fonts-URL in", + "enterName": "Voer een lettertypenaam in", + "extractFailed": "Kan lettertypefamilie niet uit URL extraheren", + "addSuccess": "Lettertype \"{{name}}\" succesvol toegevoegd", + "addFailed": "Lettertype toevoegen mislukt", + "loadTimeout": "Lettertype duurde te lang om te laden. Controleer de URL en probeer het opnieuw.", + "loadFailed": "Het lettertype kon niet worden geladen. Controleer of de Google Fonts-URL correct is." + }, + "shortcutsConfig": { + "title": "Sneltoetsen", + "configurable": "Aanpasbaar", + "fixed": "Vast", + "pressEscToCancel": "Druk op Esc om te annuleren", + "clickToChange": "Klik om te wijzigen", + "pressAKey": "Druk op een toets...", + "alreadyUsedBy": "Al in gebruik door <strong>{{action}}</strong>", + "swap": "Wisselen", + "reserved": "Deze sneltoets is gereserveerd voor \"{{label}}\" en kan niet opnieuw worden toegewezen.", + "saved": "Sneltoetsen opgeslagen", + "resetNotice": "Teruggezet naar standaardsneltoetsen — klik op Opslaan om toe te passen", + "instructions": "Klik op een sneltoets en druk vervolgens op de nieuwe toetscombinatie. Druk op Esc om te annuleren.", + "resetToDefaults": "Standaardwaarden herstellen", + "cancel": "Annuleren", + "save": "Opslaan" + } +} diff --git a/src/i18n/locales/nl/editor.json b/src/i18n/locales/nl/editor.json new file mode 100644 index 000000000..9d9376c7b --- /dev/null +++ b/src/i18n/locales/nl/editor.json @@ -0,0 +1,154 @@ +{ + "playback": { + "play": "Afspelen", + "pause": "Pauzeren", + "skipBack": "Terug springen", + "skipForward": "Vooruit springen", + "muteUnmute": "Dempen/dempen opheffen", + "volume": "Voorbeeldvolume" + }, + "annotations": { + "settings": "Annotatie-instellingen", + "active": "Actief", + "text": "Tekst", + "image": "Afbeelding", + "arrow": "Pijl", + "blur": "Vervagen", + "textContent": "Tekstinhoud", + + "textPlaceholder": "Voer je tekst in...", + "fontStyle": "Lettertype", + "selectStyle": "Selecteer stijl", + "size": "Grootte", + "toggleBold": "Vet aan/uit", + "toggleItalic": "Cursief aan/uit", + "toggleUnderline": "Onderstrepen aan/uit", + "alignLeft": "Links uitlijnen", + "alignCenter": "Centreren", + "alignRight": "Rechts uitlijnen", + "textColor": "Tekstkleur", + "background": "Achtergrond", + "none": "Geen", + "clearBackground": "Achtergrond wissen", + "uploadImage": "Afbeelding uploaden", + "supportedFormats": "Ondersteunde formaten: JPG, PNG, GIF, WebP", + "arrowDirection": "Pijlrichting", + "strokeWidth": "Lijndikte: {{width}}px", + "arrowColor": "Pijlkleur", + "deleteAnnotation": "Annotatie verwijderen", + "shortcutsAndTips": "Sneltoetsen & tips", + "tipSelectAnnotation": "Verplaats de afspeelkop naar een overlappend annotatiegedeelte en selecteer een item.", + "tipCycleForward": "Gebruik Tab om door overlappende items te bladeren.", + "tipCycleBackward": "Gebruik Shift+Tab om terug te bladeren.", + "imageUploadSuccess": "Afbeelding succesvol geüpload!", + "imageUploadError": "Upload een JPG-, PNG-, GIF- of WebP-afbeelding.", + "blurStrength": "Vervagingssterkte: {{strength}}", + "solidColor": "Effen Kleur (Censuur)", + "borderRadius": "Hoekradius" + }, + + "fontStyles": { + "classic": "Klassiek", + "editor": "Editor", + "strong": "Sterk", + "typewriter": "Typemachine", + "deco": "Deco", + "simple": "Simpel", + "modern": "Modern", + "clean": "Schoon" + }, + "format": { + "mp4Video": "MP4-video", + "mp4Description": "Videobestand van hoge kwaliteit", + "gifAnimation": "GIF-animatie", + "gifDescription": "Geanimeerde afbeelding om te delen" + }, + "gifOptions": { + "frameRate": "Framesnelheid", + "outputSize": "Uitvoergrootte", + "outputDimensions": "Uitvoer: {{width}} × {{height}}px", + "loopAnimation": "Animatie herhalen", + "loopDescription": "GIF wordt continu afgespeeld" + }, + "tutorial": { + "howTrimmingWorks": "Hoe trimmen werkt", + "title": "Hoe trimmen werkt", + "understanding": "Leer hoe je ongewenste delen uit je video knipt.", + "descriptionP1": "Het trimgereedschap werkt door de segmenten te definiëren die je wilt", + "descriptionRemove": "verwijderen", + "descriptionP2": "uit je video.", + "descriptionP3": "Elk deel van de tijdlijn dat bedekt is door een rood trimsegment wordt weggeknipt bij het exporteren.", + "visualExample": "Visueel voorbeeld", + "removed": "VERWIJDERD", + "kept": "Behouden", + "finalVideo": "Eindvideo", + "part": "Deel {{number}}", + "addTrimStep": "1. Trim toevoegen", + "addTrimDesc": "Druk op T of klik op het schaarpictogram om een sectie te markeren voor verwijdering.", + "adjustStep": "2. Aanpassen", + "adjustDesc": "Sleep de randen van het rode gebied om precies te bepalen wat je wilt wegknippen." + }, + "feedback": { + "trigger": "Feedback", + "title": "Feedback & contact", + "description": "Neem rechtstreeks contact op of maak een issue aan als er iets kapot of ontbrekend is.", + "emailLabel": "E-mail", + "xLabel": "X", + "reportIssue": "Issue melden / feedback sturen", + "openFailed": "Kan link niet openen." + }, + "keyboardShortcuts": { + "trigger": "Sneltoetsen", + "title": "Sneltoetsen", + "description": "Snelreferentie voor de tijdlijn- en editorbesturing.", + "customizeTooltip": "Sneltoetsen aanpassen", + "customize": "Aanpassen", + "panTimeline": "Tijdlijn verschuiven", + "zoomTimeline": "Tijdlijn zoomen", + "cycleAnnotations": "Annotaties doorlopen", + "tab": "Tab" + }, + "actions": { + "saveAgain": "Opnieuw opslaan", + "showInFolder": "Tonen in map" + }, + "project": { + "untitled": "Naamloos", + "unsavedChangesTitle": "Niet-opgeslagen wijzigingen", + "unsavedChangesDescription": "Wil je het huidige project opslaan voordat je {{action}}?", + "discardChanges": "Wijzigingen negeren", + "saveProject": "Project opslaan" + }, + "exportTips": { + "autoZooms": "Tip: Schakel automatisch toegepaste zooms uit in de instellingen", + "experimentalBuilds": "Tip: Schakel toegang tot experimentele builds in via de instellingen", + "cursorAppearance": "Tip: Je kunt het uiterlijk van je cursor aanpassen" + }, + "account": { "title": "Account", "comingSoon": "Account binnenkort beschikbaar" }, + "nativeCaptureUnavailable": { + "title": "Er is niets kapot, maar we kunnen geen geanimeerde cursor-overlay renderen.", + "description": "Je apparaat ondersteunt geen native capture. Dit kan verschillende oorzaken hebben die we nog niet hebben achterhaald. Recordly blijft werken, maar cursor smoothing is dan niet mogelijk.", + "confirm": "Oké" + }, + "exportStatus": { + "exporting": "Exporteren", + "renderingFile": "Je bestand wordt gerenderd.", + "preparing": "Export voorbereiden...", + "completePercent": "{{percent}}% voltooid", + "issue": "Exportprobleem", + "complete": "Export voltooid", + "savedSuccessfully": "Je bestand is succesvol opgeslagen." + }, + "export": { + "processingAudioEdits": "Audio verwerken met snelheids-/overlay-bewerkingen" + }, + "toolbar": { + "addLayer": "Laag toevoegen", + "splitClip": "Clip splitsen (C)" + }, + "timeline": { + "expand": "Tijdlijn uitvouwen", + "collapse": "Tijdlijn samenvouwen" + }, + "openRecordingsFolder": "Opnamemap openen" +} diff --git a/src/i18n/locales/nl/launch.json b/src/i18n/locales/nl/launch.json new file mode 100644 index 000000000..774c7335a --- /dev/null +++ b/src/i18n/locales/nl/launch.json @@ -0,0 +1,95 @@ +{ + "recording": { + "disableSystemAudio": "Systeemaudio uitschakelen", + "enableSystemAudio": "Systeemaudio inschakelen", + "disableMicrophone": "Microfoon uitschakelen", + "enableMicrophone": "Microfoon inschakelen", + "micToggleDisabledTip": "Microfoon kan niet worden gewijzigd tijdens de opname", + "disableWebcam": "Webcam-overlay uitschakelen", + "enableWebcam": "Webcam-overlay inschakelen", + "countdownDelay": "Aftelvertraging", + "noDelay": "Geen vertraging", + "record": "Opnemen", + "recordingFolder": "Opnamepad: {{path}}", + "chooseRecordingsFolder": "Kies opnamepad", + "folderPath": "Pad: /{{name}}/", + "openVideoFile": "Videobestand openen", + "openProject": "Project openen", + "hideHudFromVideo": "HUD verbergen in opname", + "showHudInVideo": "HUD tonen in opname", + "hideHud": "HUD verbergen", + "closeApp": "App sluiten", + "screens": "Schermen", + "windows": "Vensters", + "screen": "Scherm", + "window": "Venster", + "noSourcesFound": "Geen bronnen gevonden", + "microphone": "Microfoon", + "turnOffMicrophone": "Microfoon uitschakelen", + "selectMicToEnable": "Selecteer een microfoon om in te schakelen", + "noMicrophonesFound": "Geen microfoons gevonden", + "webcam": "Webcam", + "turnOffWebcam": "Webcam uitschakelen", + "hideFloatingWebcamPreview": "Zwevende voorbeeldweergave verbergen", + "showFloatingWebcamPreview": "Zwevende voorbeeldweergave tonen", + "selectWebcamToEnable": "Selecteer een webcam om in te schakelen", + "noWebcamsFound": "Geen webcams gevonden", + "recordingsFolder": "Opnamepad", + "language": "Taal", + "paused": "GEPAUZEERD", + "rec": "OPN", + "resume": "Hervatten", + "pause": "Pauzeren", + "stop": "Stoppen", + "cancel": "Annuleren", + "more": "Meer", + "update": { + "update": "Bijwerken", + "updated": "Bijgewerkt", + "idleTitle": "Controleer op updates.", + "checkingTitle": "Controleren op updates...", + "downloadingTitle": "Update downloaden...", + "errorTitle": "Updatecontrole mislukt. Klik om opnieuw te proberen.", + "upToDateTitle": "Recordly {{version}} is up-to-date.", + "availableTitle": "Recordly {{version}} is beschikbaar.", + "availableGenericTitle": "Er is een update beschikbaar." + } + }, + "sourceSelector": { + "loadingSources": "Bronnen laden...", + "screens": "Schermen", + "windows": "Vensters", + "noScreensAvailable": "Geen schermen beschikbaar", + "noWindowsAvailable": "Geen vensters beschikbaar", + "windowsNote": "Alleen zichtbare (niet-geminimaliseerde) vensters kunnen worden opgenomen.", + "windowPlaceholder": "Venster", + "cancel": "Annuleren", + "share": "Delen" + }, + "permissions": { + "screenRecordingNeeded": "Recordly heeft toestemming voor schermopname nodig voordat je begint. Systeeminstellingen zijn geopend. Schakel het in, sluit Recordly en open het opnieuw.", + "screenRecordingMissing": "Toestemming voor schermopname ontbreekt nog. Systeeminstellingen zijn opnieuw geopend. Schakel het in, sluit Recordly en open het opnieuw voordat je opneemt.", + "accessibilityNeeded": "Recordly heeft ook toegankelijkheidstoestemming nodig voor cursortracking. Systeeminstellingen zijn geopend. Schakel het in, sluit Recordly en open het opnieuw.", + "accessibilityMissing": "Toegankelijkheidstoestemming ontbreekt nog. Systeeminstellingen zijn opnieuw geopend. Schakel het in, sluit Recordly en open het opnieuw voordat je opneemt.", + "selectSource": "Selecteer een bron om op te nemen", + "systemAudioUnavailable": "Systeemaudio is niet beschikbaar voor deze bron. De opname gaat verder zonder systeemaudio.", + "microphoneDenied": "Microfoontoegang is geweigerd. De opname gaat verder zonder microfoonaudio.", + "failedToStart": "Opname starten mislukt: {{error}}", + "failedToStartGeneric": "Opname starten mislukt" + }, + "updateToast": { + "availableTitle": "Update beschikbaar", + "experimentalAvailableTitle": "Experimentele update beschikbaar", + "experimentalDescription": "Je hebt experimentele updates ingeschakeld, dus je kunt de nieuwste update van Recordly testen voordat die breed beschikbaar is.", + "downloadingTitle": "Update downloaden", + "readyTitle": "Klaar om opnieuw te starten", + "checkErrorTitle": "Kan niet controleren op updates", + "downloadErrorTitle": "Kan de update niet downloaden", + "experimentalBadge": "Experimenteel", + "previewBadge": "Preview", + "notNow": "Niet nu", + "updateNow": "Nu updaten", + "restartToUpdate": "Opnieuw starten om te updaten", + "tryAgain": "Opnieuw proberen" + } +} diff --git a/src/i18n/locales/nl/settings.json b/src/i18n/locales/nl/settings.json new file mode 100644 index 000000000..31eeec7ae --- /dev/null +++ b/src/i18n/locales/nl/settings.json @@ -0,0 +1,265 @@ +{ + "zoom": { + "level": "Zoomniveau", + "selectRegion": "Selecteer een zoomgebied om aan te passen", + "deleteZoom": "Zoom verwijderen", + "modeAuto": "Auto", + "modeManual": "Handmatig", + "modeManualDescription": "Stel een vast focuspunt in voor deze zoom", + "modeAutoDescription": "De camera volgt de cursor automatisch" + }, + "trim": { + "deleteRegion": "Trimgebied verwijderen" + }, + "speed": { + "playbackSpeed": "Afspeelsnelheid", + "selectRegion": "Selecteer een snelheidsgebied om aan te passen", + "deleteRegion": "Snelheidsgebied verwijderen", + "label": "Snelheid" + }, + "clip": { + "title": "Clip", + "mute": "Dempen", + "mutedState": "Audio is gedempt", + "unmutedState": "Audio wordt afgespeeld", + "separateClipFromAudio": "Clip van audio scheiden", + "delete": "Clip verwijderen" + }, + "effects": { + "title": "Video-effecten", + "show": "Tonen", + "showCursor": "Cursor tonen", + "loopCursor": "Cursor herhalen", + "cursorStyle": "Cursorstijl", + "cursorStyleOptions": { + "macos": "macOS", + "tahoe": "Tahoe", + "tahoe-inverted": "Tahoe omgekeerd", + "windows11": "Windows 11", + "dot": "Punt", + "figma": "Minimaal", + "lavender": "Lavendel", + "parched": "Parched", + "chooper": "Chooper", + "amongus": "Among Us", + "turtle": "Schildpad" + }, + "backgroundBlur": "Vervaging", + "zoomMotionBlur": "Zoom-bewegingsonscherpte", + "temporalZoomMotionBlur": "Temporal Zoom Blur", + "temporalZoomMotionBlurDescription": "Control the shutter window and frame samples used by the newer zoom blur pass.", + "zoomMotionBlurSamples": "Blur Samples", + "zoomMotionBlurShutter": "Shutter", + "auto": "Auto", + "connectZooms": "Zooms verbinden", + "connectZoomsDescription": "Maak opeenvolgende zoomgebieden vloeiend tot een doorlopende camerabeweging.", + "autoApplyFreshRecordingZooms": "Zooms voor nieuwe opnames automatisch toepassen", + "autoApplyFreshRecordingZoomsDescription": "Stel automatisch cursor-volgende zooms voor wanneer je een nieuwe opname opent.", + "zoomGeneralTitle": "Algemeen", + "zoomGeneralDescription": "Globale bewegingsinstellingen voor elke zoomovergang.", + "zoomInTitle": "Inzoomen", + "zoomInDescription": "Bepaal hoe de camera een zoomgebied binnengaat.", + "zoomOutTitle": "Uitzoomen", + "zoomOutDescription": "Bepaal hoe de camera een zoomgebied verlaat.", + "connectedZoomTitle": "Tussen zooms", + "connectedZoomDescription": "Stel de overgang af tussen opeenvolgende zoomgebieden wanneer verbinding is ingeschakeld.", + "motionPresetsTitle": "Motion Presets", + "motionPresetsZoomHint": "Zoom motion presets are available in Settings.", + "animationPresets": "Animation Presets", + "cursorMotionPresets": "Cursor Motion Presets", + "motionPresets": { + "focused": { + "label": "Focused", + "description": "Snappier motion for demos, walkthroughs, and everyday recordings." + }, + "smooth": { + "label": "Smooth", + "description": "Gentler motion for presentations, keynote-style videos, and polished reveals." + } + }, + "zoomInDuration": "Inzoomduur", + "zoomInOverlap": "Inzoomoverlap", + "zoomOutDuration": "Uitzoomduur", + "zoomInEasing": "Inzoomcurve", + "zoomOutEasing": "Uitzoomcurve", + "connectedZoomGap": "Verbonden zoomtussenpauze", + "connectedZoomDuration": "Verbonden zoomduur", + "connectedZoomEasing": "Verbonden pancurve", + "zoomEasingOptions": { + "recordly": "Recordly", + "glide": "Glijden", + "smooth": "Vloeiend", + "snappy": "Pittig", + "linear": "Lineair" + }, + "cursorSize": "Cursorgrootte", + "cursorSmoothing": "Cursorverzachting", + "cursorSpringStiffness": "Cursor Spring Stiffness", + "cursorSpringDamping": "Cursor Spring Damping", + "cursorSpringMass": "Cursor Spring Mass", + "off": "Uit", + "cursorClickEffects": { + "title": "Click Effects", + "advanced": "Advanced", + "advancedShow": "Show advanced click effect controls", + "advancedHide": "Hide advanced click effect controls", + "color": "Effect Color", + "size": "Effect Size", + "opacity": "Effect Opacity", + "duration": "Effect Duration", + "none": { + "label": "Off", + "description": "No click graphic. Only the cursor motion changes when you click." + }, + "ripple": { + "label": "Ripple", + "description": "Expanding rings radiate from each click so taps read clearly in motion." + }, + "spotlight": { + "label": "Spotlight", + "description": "A soft halo flashes around the pointer to emphasize the clicked area." + }, + "echo": { + "label": "Echo", + "description": "A pair of soft rings that spread outward with a cleaner pulse." + } + }, + "cursorMotionBlur": "Cursor-bewegingsonscherpte", + "cursorClickBounce": "Cursorklikstuit", + "cursorClickBounceDuration": "Stuitsnelheid", + "cursorSway": "Cursorbeweging", + "webcam": "Webcam-overlay", + "webcamFootage": "Webcambeelden", + "webcamFootageDescription": "Geen webcambeelden gekoppeld aan deze video", + "uploadWebcamFootage": "Beelden uploaden", + "replaceWebcamFootage": "Beelden vervangen", + "removeWebcamFootage": "Beelden verwijderen", + "webcamFootageAdded": "Webcambeelden gekoppeld", + "webcamFootageRemoved": "Webcambeelden verwijderd", + "webcamSize": "Webcamgrootte", + "webcamWidth": "Webcambreedte", + "webcamHeight": "Webcamhoogte", + "webcamCrop": "Webcam bijsnijden", + "webcamReactToZoom": "Webcam reageert op zoom", + "webcamMirror": "Webcam spiegelen", + "webcamRoundness": "Webcam-afronding", + "webcamShadow": "Webcamschaduw", + "shadow": "Schaduw", + "radius": "Straal", + "roundness": "Afronding", + "padding": "Opvulling", + "paddingLinked": "Gekoppeld (uniform)", + "paddingUnlinked": "Ontkoppeld (asymmetrisch)", + "paddingTop": "Boven", + "paddingBottom": "Onder", + "paddingLeft": "Links", + "paddingRight": "Rechts", + "removeBackground": "Achtergrond verwijderen" + }, + "sections": { + "scene": "Scène", + "captions": "Ondertiteling", + "caption": "Ondertitel", + "zoom": "Zoom", + "cursor": "Cursor", + "webcam": "Webcam", + "frame": "Kader", + "crop": "Bijsnijden" + }, + "captions": { + "selectOnTimeline": "Selecteer een ondertitel op de tijdlijn om deze te bewerken.", + "enabled": "Tonen", + "timelineQuickAdd": "Aanwijzen om toe te voegen op tijdlijn", + "language": "Taal", + "downloading": "Downloaden...", + "deleteModel": "Model verwijderen", + "clearModel": "Model wissen", + "downloadModel": "Model downloaden", + "generating": "Genereren...", + "generateFull": "Ondertiteling genereren", + "regenerateFull": "Ondertiteling opnieuw genereren", + "clearFull": "Ondertiteling wissen", + "fontSettings": "Lettertype-instellingen", + "defaultFont": "Standaard", + "fontFamily": "Lettertype", + "fontSize": "Lettergrootte", + "rowCount": "Rijen", + "animation": "Animatie", + "animationOff": "Uit", + "animationFade": "Vervagen", + "animationRise": "Opstijgen", + "animationPop": "Pop", + "bottomOffset": "Onderste afstand", + "maxWidth": "Maximale breedte", + "boxRadius": "Vakstraal", + "backgroundOpacity": "Achtergrond-dekking", + "textColor": "Tekstkleur", + "editor": { + "text": "Text", + "start": "Start", + "end": "End", + "split": "Split", + "merge": "Merge", + "delete": "Delete" + } + }, + "crop": { + "title": "Video bijsnijden", + "instruction": "Sleep aan elke zijde om het bijsnijgebied aan te passen", + "top": "Boven", + "bottom": "Onder", + "left": "Links", + "right": "Rechts", + "openEditor": "Bijsnijdeditor openen" + }, + "background": { + "title": "Achtergrond", + "image": "Afbeelding", + "color": "Kleur", + "gradient": "Verloop", + "wallpaperPreview": "Achtergrondvoorbeeld", + "uploadCustom": "Afbeelding uploaden", + "uploadSuccess": "Eigen afbeelding succesvol geüpload!", + "uploadError": "Upload een JPG- of JPEG-afbeelding.", + "uploadErrorDescription": "Alleen JPG- en JPEG-afbeeldingen worden ondersteund." + }, + "export": { + "title": "Exporteren", + "mp4": "MP4", + "gif": "GIF", + "quality": { + "low": "Laag", + "medium": "Gemiddeld", + "high": "Hoog", + "original": "Origineel" + }, + "fpsTitle": "FPS", + "loop": "Herhalen", + "outputDimensions": "Uitvoer: {{dimensions}}px", + "sizePresetOriginalShort": "Orig", + "sizePresetMediumShort": "Gem", + "sizePresetLargeShort": "Grt", + "loadProject": "Project laden", + "saveProject": "Project opslaan", + "exportVideo": "{{format}} exporteren", + "reportBug": "Bug melden", + "starOnGithub": "Ster op GitHub" + }, + "audio": { + "title": "Audio", + "volumeTitle": "Audio", + "volume": "Volume", + "normalize": "Normaliseren", + "sourceTracksTitle": "Bronaudio clip", + "systemLabel": "Systeemaudio", + "micLabel": "Microfoon", + "mixedLabel": "Bron", + "deleteRegion": "Audio verwijderen" + }, + "updates": { + "experimentalDescription": "Je hebt experimentele updates ingeschakeld, dus je kunt de nieuwste update van Recordly testen voordat die breed beschikbaar is.", + "title": "Updates", + "experimental": "Experimentele updates", + "saveFailed": "Kan het updatekanaal niet wijzigen." + } +} diff --git a/src/i18n/locales/nl/shortcuts.json b/src/i18n/locales/nl/shortcuts.json new file mode 100644 index 000000000..d11e3d8dd --- /dev/null +++ b/src/i18n/locales/nl/shortcuts.json @@ -0,0 +1,16 @@ +{ + "actions": { + "addZoom": "Zoom toevoegen", + "addTrim": "Trim toevoegen", + "addSpeed": "Snelheid toevoegen", + "addAnnotation": "Annotatie toevoegen", + "addKeyframe": "Keyframe toevoegen", + "deleteSelected": "Selectie verwijderen", + "playPause": "Afspelen / Pauzeren", + "cycleForward": "Annotaties vooruit doorlopen", + "cycleBackward": "Annotaties achteruit doorlopen", + "deleteSelectedAlt": "Selectie verwijderen (alt)", + "panTimeline": "Tijdlijn verschuiven", + "zoomTimeline": "Tijdlijn zoomen" + } +} diff --git a/src/i18n/locales/nl/timeline.json b/src/i18n/locales/nl/timeline.json new file mode 100644 index 000000000..aa5377424 --- /dev/null +++ b/src/i18n/locales/nl/timeline.json @@ -0,0 +1,41 @@ +{ + "zoom": { + "cannotPlace": "Kan zoom hier niet plaatsen", + "existsOrNoSpace": "Er bestaat al een zoom op deze locatie of er is niet genoeg ruimte beschikbaar.", + "suggestHandlerUnavailable": "Zoomsuggestie-handler niet beschikbaar", + "noTelemetry": "Geen cursortelemetrie beschikbaar", + "recordFirst": "Neem eerst een screencast op om cursorsuggesties te genereren.", + "noUsableTelemetry": "Geen bruikbare cursortelemetrie", + "notEnoughMovement": "De opname bevat niet genoeg cursorbewegingsdata.", + "noInteractionMoments": "Geen duidelijke interactiemomenten gevonden", + "tryRecording": "Probeer een opname met pauzes of klikken rond belangrijke acties.", + "noAutoZoomSlots": "Geen automatische zoomposities beschikbaar", + "dwellPointsOverlap": "Gedetecteerde rustpunten overlappen bestaande zoomgebieden.", + "addedSuggestions": "{{count}} interactiegebaseerde zoomsuggestie(s) toegevoegd", + "label": "Zoom {{index}}", + "addZoom": "Zoom toevoegen (Z)", + "suggestZooms": "Zooms voorstellen op basis van cursor" + }, + "trim": { + "cannotPlace": "Kan trim hier niet plaatsen", + "existsOrNoSpace": "Er bestaat al een trim op deze locatie of er is niet genoeg ruimte beschikbaar.", + "label": "Trim {{index}}", + "addTrim": "Trim toevoegen (T)" + }, + "speed": { + "cannotPlace": "Kan snelheid hier niet plaatsen", + "existsOrNoSpace": "Er bestaat al een snelheidsregio op deze locatie of er is niet genoeg ruimte beschikbaar.", + "label": "Snelheid" + }, + "annotation": { + "label": "Annotatie", + "image": "Afbeelding", + "addAnnotation": "Annotatie toevoegen (A)" + }, + "audio": { + "label": "Audio" + }, + "addSpeed": "Snelheid toevoegen (S)", + "resizeLeft": "Links verkleinen", + "resizeRight": "Rechts verkleinen" +} diff --git a/src/i18n/locales/pt-BR/common.json b/src/i18n/locales/pt-BR/common.json new file mode 100644 index 000000000..97a2ea1d9 --- /dev/null +++ b/src/i18n/locales/pt-BR/common.json @@ -0,0 +1,26 @@ +{ + "app": { + "name": "Recordly", + "editorTitle": "Editor do Recordly", + "subtitle": "Gravação e edição de tela", + "language": "Idioma", + "manageRecordings": "Abrir pasta de gravações" + }, + "actions": { + "cancel": "Cancelar", + "close": "Fechar", + "export": "Exportar", + "load": "Carregar", + "redo": "Refazer", + "reset": "Redefinir", + "save": "Salvar", + "undo": "Desfazer", + "delete": "Excluir", + "done": "Concluído" + }, + "errors": { + "invalidFileType": "Tipo de arquivo inválido", + "failedToUploadImage": "Falha ao enviar imagem", + "fileReadError": "Ocorreu um erro ao ler o arquivo." + } +} diff --git a/src/i18n/locales/pt-BR/dialogs.json b/src/i18n/locales/pt-BR/dialogs.json new file mode 100644 index 000000000..f5f772063 --- /dev/null +++ b/src/i18n/locales/pt-BR/dialogs.json @@ -0,0 +1,62 @@ +{ + "export": { + "pleaseTryAgain": "Tente novamente", + "compilingGifProgress": "Compilando GIF... {{progress}}%", + "compilingGifWait": "Compilando GIF... Isso pode levar um tempo", + "takeMoment": "Isso pode levar um momento...", + "exportFailed": "Falha na exportação", + "compilingGifTitle": "Compilando GIF", + "exportingFormat": "Exportando {{format}}", + "exportComplete": "Exportação concluída", + "formatReady": "Seu {{format}} está pronto", + "showInFolder": "Mostrar na pasta", + "compiling": "Compilando", + "renderingFrames": "Renderizando quadros", + "processing": "Processando...", + "status": "Status", + "format": "Formato", + "compilingStatus": "Compilando...", + "frames": "Quadros", + "cancelExport": "Cancelar exportação", + "reopenSaveDialog": "Abrir diálogo de salvar novamente", + "savedSuccess": "{{format}} salvo com sucesso!" + }, + "addFont": { + "title": "Adicionar fonte do Google", + "heading": "Adicionar fonte do Google", + "description": "Adicione uma fonte personalizada do Google Fonts para usar nas suas anotações.", + "urlLabel": "URL de importação do Google Fonts", + "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap", + "urlHelp": "Obtenha isso no Google Fonts: selecione uma fonte -> clique em \"Get font\" -> copie a URL de @import", + "nameLabel": "Nome de exibição", + "namePlaceholder": "Minha fonte personalizada", + "nameHelp": "Assim a fonte aparecerá no seletor de fontes", + "adding": "Adicionando...", + "addFont": "Adicionar fonte", + "enterUrl": "Informe uma URL de importação do Google Fonts", + "invalidUrl": "Informe uma URL válida do Google Fonts", + "enterName": "Informe um nome para a fonte", + "extractFailed": "Não foi possível extrair a família da fonte da URL", + "addSuccess": "Fonte \"{{name}}\" adicionada com sucesso", + "addFailed": "Falha ao adicionar fonte", + "loadTimeout": "A fonte demorou muito para carregar. Verifique a URL e tente novamente.", + "loadFailed": "A fonte não pode ser carregada. Verifique se a URL do Google Fonts está correta." + }, + "shortcutsConfig": { + "title": "Atalhos de teclado", + "configurable": "Configurável", + "fixed": "Fixo", + "pressEscToCancel": "Pressione Esc para cancelar", + "clickToChange": "Clique para alterar", + "pressAKey": "Pressione uma tecla...", + "alreadyUsedBy": "Já usado por <strong>{{action}}</strong>", + "swap": "Trocar", + "reserved": "Este atalho é reservado para \"{{label}}\" e não pode ser reatribuído.", + "saved": "Atalhos de teclado salvos", + "resetNotice": "Redefinir para atalhos padrão - clique em Salvar para aplicar", + "instructions": "Clique em um atalho e pressione a nova combinação de teclas. Pressione Esc para cancelar.", + "resetToDefaults": "Redefinir para padrão", + "cancel": "Cancelar", + "save": "Salvar" + } +} diff --git a/src/i18n/locales/pt-BR/editor.json b/src/i18n/locales/pt-BR/editor.json new file mode 100644 index 000000000..1a3766315 --- /dev/null +++ b/src/i18n/locales/pt-BR/editor.json @@ -0,0 +1,153 @@ +{ + "playback": { + "play": "Reproduzir", + "pause": "Pausar", + "skipBack": "Voltar", + "skipForward": "Avançar", + "muteUnmute": "Silenciar/ativar som", + "volume": "Volume da prévia" + }, + "annotations": { + "settings": "Configurações de anotação", + "active": "Ativo", + "text": "Texto", + "image": "Imagem", + "arrow": "Seta", + "blur": "Blur", + "textContent": "Conteúdo do texto", + "textPlaceholder": "Digite seu texto...", + "fontStyle": "Estilo da fonte", + "selectStyle": "Selecionar estilo", + "size": "Tamanho", + "toggleBold": "Alternar negrito", + "toggleItalic": "Alternar itálico", + "toggleUnderline": "Alternar sublinhado", + "alignLeft": "Alinhar à esquerda", + "alignCenter": "Alinhar ao centro", + "alignRight": "Alinhar à direita", + "textColor": "Cor do texto", + "background": "Fundo", + "none": "Nenhum", + "clearBackground": "Limpar fundo", + "uploadImage": "Enviar imagem", + "supportedFormats": "Formatos suportados: JPG, PNG, GIF, WebP", + "arrowDirection": "Direção da seta", + "strokeWidth": "Espessura do traço: {{width}}px", + "arrowColor": "Cor da seta", + "deleteAnnotation": "Excluir anotação", + "shortcutsAndTips": "Atalhos e dicas", + "tipSelectAnnotation": "Mova o indicador para uma seção com anotações sobrepostas e selecione um item.", + "tipCycleForward": "Use Tab para alternar entre itens sobrepostos.", + "tipCycleBackward": "Use Shift+Tab para voltar.", + "imageUploadSuccess": "Imagem enviada com sucesso!", + "imageUploadError": "Envie um arquivo de imagem JPG, PNG, GIF ou WebP.", + "blurStrength": "Intensidade do blur: {{strength}}", + "solidColor": "Cor sólida (censura)", + "borderRadius": "Raio da borda" + }, + + "fontStyles": { + "classic": "Classic", + "editor": "Editor", + "strong": "Strong", + "typewriter": "Typewriter", + "deco": "Deco", + "simple": "Simple", + "modern": "Modern", + "clean": "Clean" + }, + "format": { + "mp4Video": "Vídeo MP4", + "mp4Description": "Arquivo de vídeo em alta qualidade", + "gifAnimation": "Animação GIF", + "gifDescription": "Imagem animada para compartilhar" + }, + "gifOptions": { + "frameRate": "Taxa de quadros", + "outputSize": "Tamanho de saída", + "outputDimensions": "Saída: {{width}} × {{height}}px", + "loopAnimation": "Animação em loop", + "loopDescription": "O GIF será reproduzido continuamente" + }, + "tutorial": { + "howTrimmingWorks": "Como o corte funciona", + "title": "Como o corte funciona", + "understanding": "Entenda como remover partes indesejadas do seu vídeo.", + "descriptionP1": "A ferramenta de corte funciona definindo os trechos que você quer", + "descriptionRemove": "remover", + "descriptionP2": "do seu vídeo.", + "descriptionP3": "Qualquer parte da linha do tempo coberta por uma região de corte vermelha será removida na exportação.", + "visualExample": "Exemplo visual", + "removed": "REMOVIDO", + "kept": "Mantido", + "finalVideo": "Vídeo final", + "part": "Parte {{number}}", + "addTrimStep": "1. Adicionar corte", + "addTrimDesc": "Pressione T ou clique no ícone de tesoura para marcar um trecho para remoção.", + "adjustStep": "2. Ajustar", + "adjustDesc": "Arraste as bordas da região vermelha para cobrir exatamente o que você quer remover." + }, + "feedback": { + "trigger": "Feedback", + "title": "Feedback e contato", + "description": "Fale diretamente conosco ou abra uma issue se algo estiver com problema ou faltando.", + "emailLabel": "E-mail", + "xLabel": "X", + "reportIssue": "Reportar problema / enviar feedback", + "openFailed": "Falha ao abrir link." + }, + "keyboardShortcuts": { + "trigger": "Atalhos", + "title": "Atalhos de teclado", + "description": "Referência rápida para os controles da linha do tempo e do editor.", + "customizeTooltip": "Personalizar atalhos", + "customize": "Personalizar", + "panTimeline": "Mover linha do tempo", + "zoomTimeline": "Zoom da linha do tempo", + "cycleAnnotations": "Alternar anotações", + "tab": "Tab" + }, + "actions": { + "saveAgain": "Salvar novamente", + "showInFolder": "Mostrar na pasta" + }, + "project": { + "untitled": "Sem título", + "unsavedChangesTitle": "Alterações não salvas", + "unsavedChangesDescription": "Salvar o projeto atual antes de {{action}}?", + "discardChanges": "Descartar alterações", + "saveProject": "Salvar projeto" + }, + "exportTips": { + "autoZooms": "Dica: Desative os zooms aplicados automaticamente nas configurações", + "experimentalBuilds": "Dica: Ative o acesso a versões experimentais nas configurações", + "cursorAppearance": "Dica: Você pode personalizar a aparência do cursor" + }, + "account": { "title": "Conta", "comingSoon": "Conta em breve" }, + "nativeCaptureUnavailable": { + "title": "Nada está quebrado, mas não poderemos renderizar uma sobreposição animada do cursor.", + "description": "Seu dispositivo não oferece suporte à captura nativa. Isso pode acontecer por vários motivos que ainda não identificamos. O Recordly continuará funcionando, mas a suavização do cursor ficará indisponível.", + "confirm": "Entendi" + }, + "exportStatus": { + "exporting": "Exportando", + "renderingFile": "Renderizando seu arquivo.", + "preparing": "Preparando exportação...", + "completePercent": "{{percent}}% concluído", + "issue": "Problema na exportação", + "complete": "Exportação concluída", + "savedSuccessfully": "Seu arquivo foi salvo com sucesso." + }, + "export": { + "processingAudioEdits": "Processando áudio com edições de velocidade/sobreposição" + }, + "toolbar": { + "addLayer": "Adicionar camada", + "splitClip": "Dividir clipe (C)" + }, + "timeline": { + "expand": "Expandir linha do tempo", + "collapse": "Recolher linha do tempo" + }, + "openRecordingsFolder": "Abrir pasta de gravações" +} diff --git a/src/i18n/locales/pt-BR/launch.json b/src/i18n/locales/pt-BR/launch.json new file mode 100644 index 000000000..cc8c35c7e --- /dev/null +++ b/src/i18n/locales/pt-BR/launch.json @@ -0,0 +1,95 @@ +{ + "recording": { + "disableSystemAudio": "Desativar áudio do sistema", + "enableSystemAudio": "Ativar áudio do sistema", + "disableMicrophone": "Desativar microfone", + "enableMicrophone": "Ativar microfone", + "micToggleDisabledTip": "O microfone não pode ser alternado durante a gravação", + "disableWebcam": "Desativar sobreposição da webcam", + "enableWebcam": "Ativar sobreposição da webcam", + "countdownDelay": "Atraso da contagem regressiva", + "noDelay": "Sem atraso", + "record": "Gravar", + "recordingFolder": "Caminho das gravações: {{path}}", + "chooseRecordingsFolder": "Escolher caminho das gravações", + "folderPath": "Caminho: /{{name}}/", + "openVideoFile": "Abrir arquivo de vídeo", + "openProject": "Abrir projeto", + "hideHudFromVideo": "Ocultar HUD na gravação", + "showHudInVideo": "Mostrar HUD na gravação", + "hideHud": "Ocultar HUD", + "closeApp": "Fechar app", + "screens": "Telas", + "windows": "Janelas", + "screen": "Tela", + "window": "Janela", + "noSourcesFound": "Nenhuma fonte encontrada", + "microphone": "Microfone", + "turnOffMicrophone": "Desligar microfone", + "selectMicToEnable": "Selecione um microfone para ativar", + "noMicrophonesFound": "Nenhum microfone encontrado", + "webcam": "Webcam", + "turnOffWebcam": "Desligar webcam", + "hideFloatingWebcamPreview": "Ocultar prévia flutuante", + "showFloatingWebcamPreview": "Mostrar prévia flutuante", + "selectWebcamToEnable": "Selecione uma webcam para ativar", + "noWebcamsFound": "Nenhuma webcam encontrada", + "recordingsFolder": "Caminho das gravações", + "language": "Idioma", + "paused": "PAUSADO", + "rec": "GRAV", + "resume": "Retomar", + "pause": "Pausar", + "stop": "Parar", + "cancel": "Cancelar", + "more": "Mais", + "update": { + "update": "Atualizar", + "updated": "Atualizado", + "idleTitle": "Verificar atualizações.", + "checkingTitle": "Verificando atualizações...", + "downloadingTitle": "Baixando atualização...", + "errorTitle": "Falha ao verificar atualizações. Clique para tentar novamente.", + "upToDateTitle": "Recordly {{version}} está atualizado.", + "availableTitle": "Recordly {{version}} está disponível.", + "availableGenericTitle": "Há uma atualização disponível." + } + }, + "sourceSelector": { + "loadingSources": "Carregando fontes...", + "screens": "Telas", + "windows": "Janelas", + "noScreensAvailable": "Nenhuma tela disponível", + "noWindowsAvailable": "Nenhuma janela disponível", + "windowsNote": "Apenas janelas visíveis (não minimizadas) podem ser gravadas.", + "windowPlaceholder": "Janela", + "cancel": "Cancelar", + "share": "Compartilhar" + }, + "permissions": { + "screenRecordingNeeded": "O Recordly precisa da permissão de Gravação de Tela antes de iniciar. Ajustes do Sistema foram abertos. Depois de ativar, feche e reabra o Recordly.", + "screenRecordingMissing": "A permissão de Gravação de Tela ainda está ausente. Ajustes do Sistema foram abertos novamente. Ative e depois feche e reabra o Recordly antes de gravar.", + "accessibilityNeeded": "O Recordly também precisa da permissão de Acessibilidade para rastrear o cursor. Ajustes do Sistema foram abertos. Depois de ativar, feche e reabra o Recordly.", + "accessibilityMissing": "A permissão de Acessibilidade ainda está ausente. Ajustes do Sistema foram abertos novamente. Ative e depois feche e reabra o Recordly antes de gravar.", + "selectSource": "Selecione uma fonte para gravar", + "systemAudioUnavailable": "O áudio do sistema não está disponível para esta fonte. A gravação continuará sem áudio do sistema.", + "microphoneDenied": "O acesso ao microfone foi negado. A gravação continuará sem áudio do microfone.", + "failedToStart": "Falha ao iniciar gravação: {{error}}", + "failedToStartGeneric": "Falha ao iniciar gravação" + }, + "updateToast": { + "availableTitle": "Atualização disponível", + "experimentalAvailableTitle": "Atualização experimental disponível", + "experimentalDescription": "Você ativou as atualizações experimentais, então pode escolher testar a atualização mais recente do Recordly antes que ela fique amplamente disponível.", + "downloadingTitle": "Baixando sua atualização", + "readyTitle": "Pronto para reiniciar", + "checkErrorTitle": "Não foi possível verificar atualizações", + "downloadErrorTitle": "Não foi possível baixar a atualização", + "experimentalBadge": "Experimental", + "previewBadge": "Prévia", + "notNow": "Agora não", + "updateNow": "Atualizar agora", + "restartToUpdate": "Reiniciar para atualizar", + "tryAgain": "Tentar novamente" + } +} diff --git a/src/i18n/locales/pt-BR/settings.json b/src/i18n/locales/pt-BR/settings.json new file mode 100644 index 000000000..2e4bcf622 --- /dev/null +++ b/src/i18n/locales/pt-BR/settings.json @@ -0,0 +1,265 @@ +{ + "zoom": { + "level": "Nível de zoom", + "selectRegion": "Selecione uma região de zoom para ajustar", + "deleteZoom": "Excluir zoom", + "modeAuto": "Auto", + "modeManual": "Manual", + "modeManualDescription": "Defina um ponto de foco fixo para este zoom", + "modeAutoDescription": "A câmera segue o cursor automaticamente" + }, + "trim": { + "deleteRegion": "Excluir região de corte" + }, + "speed": { + "playbackSpeed": "Velocidade de reprodução", + "selectRegion": "Selecione uma região de velocidade para ajustar", + "deleteRegion": "Excluir região de velocidade", + "label": "Velocidade" + }, + "clip": { + "title": "Clipe", + "mute": "Mudo", + "mutedState": "O áudio está mudo", + "unmutedState": "O áudio está tocando", + "separateClipFromAudio": "Separar áudio do clipe", + "delete": "Excluir clipe" + }, + "effects": { + "title": "Efeitos de vídeo", + "show": "Mostrar", + "showCursor": "Mostrar cursor", + "loopCursor": "Loop do cursor", + "cursorStyle": "Estilo do cursor", + "cursorStyleOptions": { + "macos": "macOS", + "tahoe": "Tahoe", + "tahoe-inverted": "Tahoe invertido", + "windows11": "Windows 11", + "dot": "Dot", + "figma": "Minimal", + "lavender": "Lavender", + "parched": "Parched", + "chooper": "Chooper", + "amongus": "Among Us", + "turtle": "Turtle" + }, + "backgroundBlur": "Desfoque", + "zoomMotionBlur": "Desfoque de movimento do zoom", + "temporalZoomMotionBlur": "Temporal Zoom Blur", + "temporalZoomMotionBlurDescription": "Control the shutter window and frame samples used by the newer zoom blur pass.", + "zoomMotionBlurSamples": "Blur Samples", + "zoomMotionBlurShutter": "Shutter", + "auto": "Auto", + "connectZooms": "Conectar zooms", + "connectZoomsDescription": "Suaviza regiões de zoom consecutivas em um movimento contínuo de câmera.", + "autoApplyFreshRecordingZooms": "Aplicar zooms automaticamente em gravações novas", + "autoApplyFreshRecordingZoomsDescription": "Sugere zooms seguindo o cursor automaticamente ao abrir uma nova gravação.", + "zoomGeneralTitle": "Geral", + "zoomGeneralDescription": "Configurações globais de movimento para cada transição de zoom.", + "zoomInTitle": "Aproximar", + "zoomInDescription": "Controla como a câmera entra em uma região de zoom.", + "zoomOutTitle": "Afastar", + "zoomOutDescription": "Controla como a câmera sai de uma região de zoom.", + "connectedZoomTitle": "Entre zooms", + "connectedZoomDescription": "Ajuste o deslizamento entre regiões de zoom consecutivas quando a conexão estiver ativada.", + "motionPresetsTitle": "Motion Presets", + "motionPresetsZoomHint": "Zoom motion presets are available in Settings.", + "animationPresets": "Animation Presets", + "cursorMotionPresets": "Cursor Motion Presets", + "motionPresets": { + "focused": { + "label": "Focused", + "description": "Snappier motion for demos, walkthroughs, and everyday recordings." + }, + "smooth": { + "label": "Smooth", + "description": "Gentler motion for presentations, keynote-style videos, and polished reveals." + } + }, + "zoomInDuration": "Duração do zoom in", + "zoomInOverlap": "Sobreposição do zoom in", + "zoomOutDuration": "Duração do zoom out", + "zoomInEasing": "Curva do zoom in", + "zoomOutEasing": "Curva do zoom out", + "connectedZoomGap": "Intervalo entre zooms conectados", + "connectedZoomDuration": "Duração do zoom conectado", + "connectedZoomEasing": "Curva de pan conectado", + "zoomEasingOptions": { + "recordly": "Recordly", + "glide": "Glide", + "smooth": "Smooth", + "snappy": "Snappy", + "linear": "Linear" + }, + "cursorSize": "Tamanho do cursor", + "cursorSmoothing": "Suavização do cursor", + "cursorSpringStiffness": "Cursor Spring Stiffness", + "cursorSpringDamping": "Cursor Spring Damping", + "cursorSpringMass": "Cursor Spring Mass", + "off": "Desligado", + "cursorClickEffects": { + "title": "Click Effects", + "advanced": "Advanced", + "advancedShow": "Show advanced click effect controls", + "advancedHide": "Hide advanced click effect controls", + "color": "Effect Color", + "size": "Effect Size", + "opacity": "Effect Opacity", + "duration": "Effect Duration", + "none": { + "label": "Off", + "description": "No click graphic. Only the cursor motion changes when you click." + }, + "ripple": { + "label": "Ripple", + "description": "Expanding rings radiate from each click so taps read clearly in motion." + }, + "spotlight": { + "label": "Spotlight", + "description": "A soft halo flashes around the pointer to emphasize the clicked area." + }, + "echo": { + "label": "Echo", + "description": "A pair of soft rings that spread outward with a cleaner pulse." + } + }, + "cursorMotionBlur": "Desfoque de movimento do cursor", + "cursorClickBounce": "Salto de clique do cursor", + "cursorClickBounceDuration": "Velocidade do salto", + "cursorSway": "Balanço do cursor", + "webcam": "Sobreposição da webcam", + "webcamFootage": "Filmagem da webcam", + "webcamFootageDescription": "Nenhuma filmagem de webcam vinculada a este vídeo", + "uploadWebcamFootage": "Enviar filmagem", + "replaceWebcamFootage": "Substituir filmagem", + "removeWebcamFootage": "Remover filmagem", + "webcamFootageAdded": "Filmagem da webcam vinculada", + "webcamFootageRemoved": "Filmagem da webcam removida", + "webcamSize": "Tamanho da webcam", + "webcamWidth": "Largura da webcam", + "webcamHeight": "Altura da webcam", + "webcamCrop": "Corte da webcam", + "webcamReactToZoom": "Webcam reage ao zoom", + "webcamMirror": "Espelhar webcam", + "webcamRoundness": "Arredondamento da webcam", + "webcamShadow": "Sombra da webcam", + "shadow": "Sombra", + "radius": "Raio", + "roundness": "Arredondamento", + "padding": "Espaçamento", + "paddingLinked": "Vinculado (uniforme)", + "paddingUnlinked": "Não vinculado (assimétrico)", + "paddingTop": "Superior", + "paddingBottom": "Inferior", + "paddingLeft": "Esquerdo", + "paddingRight": "Direito", + "removeBackground": "Remover fundo" + }, + "sections": { + "scene": "Cena", + "captions": "Legendas", + "caption": "Legenda", + "zoom": "Zoom", + "cursor": "Cursor", + "webcam": "Webcam", + "frame": "Quadro", + "crop": "Corte" + }, + "captions": { + "selectOnTimeline": "Selecione uma legenda na linha do tempo para editá-la.", + "enabled": "Mostrar", + "timelineQuickAdd": "Passe o cursor para adicionar na linha do tempo", + "language": "Idioma", + "downloading": "Baixando...", + "deleteModel": "Excluir modelo", + "clearModel": "Limpar modelo", + "downloadModel": "Baixar modelo", + "generating": "Gerando...", + "generateFull": "Gerar legendas", + "regenerateFull": "Gerar legendas novamente", + "clearFull": "Limpar legendas", + "fontSettings": "Configurações da fonte", + "defaultFont": "Padrão", + "fontFamily": "Fonte", + "fontSize": "Tamanho da fonte", + "rowCount": "Linhas", + "animation": "Animação", + "animationOff": "Desligado", + "animationFade": "Fade", + "animationRise": "Rise", + "animationPop": "Pop", + "bottomOffset": "Deslocamento inferior", + "maxWidth": "Largura máxima", + "boxRadius": "Raio da caixa", + "backgroundOpacity": "Opacidade do fundo", + "textColor": "Cor do texto", + "editor": { + "text": "Text", + "start": "Start", + "end": "End", + "split": "Split", + "merge": "Merge", + "delete": "Delete" + } + }, + "crop": { + "title": "Cortar vídeo", + "instruction": "Arraste cada lado para ajustar a área de corte", + "top": "Topo", + "bottom": "Base", + "left": "Esquerda", + "right": "Direita", + "openEditor": "Abrir editor de corte" + }, + "background": { + "title": "Fundo", + "image": "Imagem", + "color": "Cor", + "gradient": "Gradiente", + "wallpaperPreview": "Prévia do papel de parede", + "uploadCustom": "Enviar personalizado", + "uploadSuccess": "Imagem personalizada enviada com sucesso!", + "uploadError": "Envie um arquivo de imagem JPG ou JPEG.", + "uploadErrorDescription": "Somente imagens JPG e JPEG são suportadas." + }, + "export": { + "title": "Exportar", + "mp4": "MP4", + "gif": "GIF", + "quality": { + "low": "Baixa", + "medium": "Média", + "high": "Alta", + "original": "Original" + }, + "fpsTitle": "FPS", + "loop": "Repetir", + "outputDimensions": "Saída: {{dimensions}}px", + "sizePresetOriginalShort": "Orig", + "sizePresetMediumShort": "Med", + "sizePresetLargeShort": "Gra", + "loadProject": "Carregar projeto", + "saveProject": "Salvar projeto", + "exportVideo": "Exportar {{format}}", + "reportBug": "Reportar bug", + "starOnGithub": "Dar estrela no GitHub" + }, + "audio": { + "title": "Áudio", + "volumeTitle": "Áudio", + "volume": "Volume", + "normalize": "Normalizar", + "sourceTracksTitle": "Áudio original do clipe", + "systemLabel": "Sistema", + "micLabel": "Microfone", + "mixedLabel": "Fonte", + "deleteRegion": "Excluir áudio" + }, + "updates": { + "experimentalDescription": "Você ativou as atualizações experimentais, então pode escolher testar a atualização mais recente do Recordly antes que ela fique amplamente disponível.", + "title": "Atualizações", + "experimental": "Atualizações experimentais", + "saveFailed": "Falha ao alterar o canal de atualização." + } +} diff --git a/src/i18n/locales/pt-BR/shortcuts.json b/src/i18n/locales/pt-BR/shortcuts.json new file mode 100644 index 000000000..cf9d42b25 --- /dev/null +++ b/src/i18n/locales/pt-BR/shortcuts.json @@ -0,0 +1,16 @@ +{ + "actions": { + "addZoom": "Adicionar zoom", + "addTrim": "Adicionar corte", + "addSpeed": "Adicionar velocidade", + "addAnnotation": "Adicionar anotação", + "addKeyframe": "Adicionar quadro-chave", + "deleteSelected": "Excluir selecionado", + "playPause": "Reproduzir / Pausar", + "cycleForward": "Alternar anotações para frente", + "cycleBackward": "Alternar anotações para trás", + "deleteSelectedAlt": "Excluir selecionado (alt)", + "panTimeline": "Mover linha do tempo", + "zoomTimeline": "Zoom da linha do tempo" + } +} diff --git a/src/i18n/locales/pt-BR/timeline.json b/src/i18n/locales/pt-BR/timeline.json new file mode 100644 index 000000000..7bd64162f --- /dev/null +++ b/src/i18n/locales/pt-BR/timeline.json @@ -0,0 +1,41 @@ +{ + "zoom": { + "cannotPlace": "Não é possível inserir zoom aqui", + "existsOrNoSpace": "Já existe zoom nesse local ou não há espaço suficiente.", + "suggestHandlerUnavailable": "Manipulador de sugestão de zoom indisponível", + "noTelemetry": "Nenhuma telemetria de cursor disponível", + "recordFirst": "Grave uma captura de tela primeiro para gerar sugestões baseadas no cursor.", + "noUsableTelemetry": "Nenhuma telemetria de cursor utilizável", + "notEnoughMovement": "A gravação não inclui dados suficientes de movimento do cursor.", + "noInteractionMoments": "Nenhum momento claro de interação encontrado", + "tryRecording": "Tente uma gravação com pausas ou cliques em torno de ações importantes.", + "noAutoZoomSlots": "Nenhum espaço de auto-zoom disponível", + "dwellPointsOverlap": "Pontos de permanência detectados se sobrepõem a regiões de zoom existentes.", + "addedSuggestions": "{{count}} sugestão(ões) de zoom por interação adicionada(s)", + "label": "Zoom {{index}}", + "addZoom": "Adicionar zoom (Z)", + "suggestZooms": "Sugerir zooms pelo cursor" + }, + "trim": { + "cannotPlace": "Não é possível inserir corte aqui", + "existsOrNoSpace": "Já existe corte nesse local ou não há espaço suficiente.", + "label": "Corte {{index}}", + "addTrim": "Adicionar corte (T)" + }, + "speed": { + "cannotPlace": "Não é possível inserir velocidade aqui", + "existsOrNoSpace": "Já existe uma região de velocidade nesse local ou não há espaço suficiente.", + "label": "Velocidade" + }, + "annotation": { + "label": "Anotação", + "image": "Imagem", + "addAnnotation": "Adicionar anotação (A)" + }, + "audio": { + "label": "Áudio" + }, + "addSpeed": "Adicionar velocidade (S)", + "resizeLeft": "Redimensionar para a esquerda", + "resizeRight": "Redimensionar para a direita" +} diff --git a/src/i18n/locales/ru/common.json b/src/i18n/locales/ru/common.json new file mode 100644 index 000000000..8ea45bfbd --- /dev/null +++ b/src/i18n/locales/ru/common.json @@ -0,0 +1,26 @@ +{ + "app": { + "name": "Recordly", + "editorTitle": "Recordly Editor", + "subtitle": "Запись экрана и редактирование видео", + "language": "Язык", + "manageRecordings": "Открыть папку с записями" + }, + "actions": { + "cancel": "Отмена", + "close": "Закрыть", + "export": "Экспорт", + "load": "Загрузить", + "redo": "Повторить", + "reset": "Сброс", + "save": "Сохранить", + "undo": "Отменить", + "delete": "Удалить", + "done": "Готово" + }, + "errors": { + "invalidFileType": "Неверный формат файла", + "failedToUploadImage": "Не удалось загрузить изображение", + "fileReadError": "Произошла ошибка при чтении файла." + } +} diff --git a/src/i18n/locales/ru/dialogs.json b/src/i18n/locales/ru/dialogs.json new file mode 100644 index 000000000..bb5c7512a --- /dev/null +++ b/src/i18n/locales/ru/dialogs.json @@ -0,0 +1,62 @@ +{ + "export": { + "pleaseTryAgain": "Пожалуйста, попробуйте еще раз", + "compilingGifProgress": "Создание GIF... {{progress}}%", + "compilingGifWait": "Создание GIF... Это займет некоторое время", + "takeMoment": "Это займет некоторое время...", + "exportFailed": "Экспорт не удался", + "compilingGifTitle": "Создание GIF", + "exportingFormat": "Экспорт в {{format}}", + "exportComplete": "Экспорт завершён", + "formatReady": "Ваш {{format}} готов", + "showInFolder": "Показать в папке", + "compiling": "Создание", + "renderingFrames": "Обработка кадров", + "processing": "Обработка...", + "status": "Статус", + "format": "Формат", + "compilingStatus": "Создание...", + "frames": "Кадров", + "cancelExport": "Отменить экспорт", + "reopenSaveDialog": "Снова открыть окно сохранения", + "savedSuccess": "{{format}} успешно сохранён!" + }, + "addFont": { + "title": "Google Fonts", + "heading": "Добавить Google Font", + "description": "Добавьте шрифт из Google Fonts для использования в аннотациях.", + "urlLabel": "Ссылка на импорт из Google Fonts", + "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap", + "urlHelp": "Выберите шрифт на Google Fonts → нажмите \"Get font\" → скопируйте ссылку из блока @import", + "nameLabel": "Название шрифта", + "namePlaceholder": "Мой шрифт", + "nameHelp": "Так он будет отображаться в списке", + "adding": "Добавление...", + "addFont": "Добавить шрифт", + "enterUrl": "Введите ссылку для импорта из Google Fonts", + "invalidUrl": "Введите корректную ссылку на Google Fonts", + "enterName": "Введите название шрифта", + "extractFailed": "Не удалось определить семейство шрифтов по ссылке", + "addSuccess": "Шрифт \"{{name}}\" успешно добавлен", + "addFailed": "Не удалось добавить шрифт", + "loadTimeout": "Шрифт загружается слишком долго. Проверьте ссылку и попробуйте снова.", + "loadFailed": "Не удалось загрузить шрифт. Проверьте ссылку на Google Fonts." + }, + "shortcutsConfig": { + "title": "Сочетания клавиш", + "configurable": "Настраиваемые", + "fixed": "Фиксированные", + "pressEscToCancel": "Нажмите Esc для отмены", + "clickToChange": "Нажмите, чтобы изменить", + "pressAKey": "Нажмите клавишу…", + "alreadyUsedBy": "Уже используется в <strong>{{action}}</strong>", + "swap": "Заменить", + "reserved": "Это сочетание клавиш зарезервировано для \"{{label}}\" – его нельзя изменить.", + "saved": "Сочетания клавиш сохранены", + "resetNotice": "Сброс к настройкам по умолчанию — нажмите «Сохранить»", + "instructions": "Выберите сочетание и нажмите новую комбинацию клавиш. Нажмите Esc для отмены.", + "resetToDefaults": "Сбросить по умолчанию", + "cancel": "Отмена", + "save": "Сохранить" + } +} diff --git a/src/i18n/locales/ru/editor.json b/src/i18n/locales/ru/editor.json new file mode 100644 index 000000000..56c62e1c7 --- /dev/null +++ b/src/i18n/locales/ru/editor.json @@ -0,0 +1,153 @@ +{ + "playback": { + "play": "Воспроизвести", + "pause": "Пауза", + "skipBack": "Перемотать назад", + "skipForward": "Перемотать вперёд", + "muteUnmute": "Вкл./выкл. звук", + "volume": "Громкость предпросмотра" + }, + "annotations": { + "settings": "Настройки аннотации", + "active": "Активно", + "text": "Текст", + "image": "Изображение", + "arrow": "Стрелки", + "blur": "Размытие", + "textContent": "Текст", + "textPlaceholder": "Введите текст...", + "fontStyle": "Стиль шрифта", + "selectStyle": "Выберите стиль", + "size": "Размер", + "toggleBold": "Жирный", + "toggleItalic": "Курсив", + "toggleUnderline": "Подчёркивание", + "alignLeft": "По левому краю", + "alignCenter": "По центру", + "alignRight": "По правому краю", + "textColor": "Цвет текста", + "background": "Фон", + "none": "Нет", + "clearBackground": "Убрать фон", + "uploadImage": "Загрузить изображение", + "supportedFormats": "Форматы: JPG, PNG, GIF, WebP", + "arrowDirection": "Направление стрелки", + "strokeWidth": "Толщина: {{width}}px", + "arrowColor": "Цвет стрелки", + "deleteAnnotation": "Удалить аннотацию", + "shortcutsAndTips": "Горячие клавиши и советы", + "tipSelectAnnotation": "Переместите ползунок в область пересечения аннотаций и выберите нужную.", + "tipCycleForward": "Нажмите Tab для переключения между элементами.", + "tipCycleBackward": "Shift+Tab — для переключения в обратном направлении.", + "imageUploadSuccess": "Изображение загружено.", + "imageUploadError": "Загрузите файл JPG, PNG, GIF, или WebP.", + "blurStrength": "Сила размытия: {{strength}}", + "solidColor": "Сплошной цвет (цензура)", + "borderRadius": "Скругление углов" + }, + + "fontStyles": { + "classic": "Классический", + "editor": "Базовый", + "strong": "Акцентный", + "typewriter": "Моноширинный", + "deco": "Декоративный", + "simple": "Простой", + "modern": "Современный", + "clean": "Минималистичный" + }, + "format": { + "mp4Video": "Видео MP4", + "mp4Description": "Высококачественный видеофайл", + "gifAnimation": "GIF-анимация", + "gifDescription": "Анимированное изображение для соцсетей и мессенджеров" + }, + "gifOptions": { + "frameRate": "Частота кадров", + "outputSize": "Размер", + "outputDimensions": "Разрешение: {{width}} × {{height}}px", + "loopAnimation": "Зациклить анимацию", + "loopDescription": "GIF будет воспроизводиться непрерывно" + }, + "tutorial": { + "howTrimmingWorks": "Как работает обрезка", + "title": "Как работает обрезка", + "understanding": "Как вырезать ненужные фрагменты из видео.", + "descriptionP1": "Инструмент обрезки позволяет задать участки, которые нужно", + "descriptionRemove": "удалить", + "descriptionP2": "из видео.", + "descriptionP3": "Все красные части таймлайна будут вырезаны при экспорте.", + "visualExample": "Пример", + "removed": "УДАЛЕНО", + "kept": "Оставлено", + "finalVideo": "Итоговое видео", + "part": "Часть {{number}}", + "addTrimStep": "1. Добавьте обрезку", + "addTrimDesc": "Нажмите T или иконку ножниц, чтобы отметить участок для удаления.", + "adjustStep": "2. Настройте", + "adjustDesc": "Перетащите края красной области, чтобы точно выделить нужный фрагмент." + }, + "feedback": { + "trigger": "Обратная связь", + "title": "Обратная связь и контакты", + "description": "Свяжитесь с нами или сообщите о проблеме, если что-то не работает или отсутствует.", + "emailLabel": "Email", + "xLabel": "X", + "reportIssue": "Сообщить о проблеме / отправить отзыв", + "openFailed": "Не удалось открыть ссылку." + }, + "keyboardShortcuts": { + "trigger": "Горячие клавиши", + "title": "Горячие клавиши", + "description": "Памятка для работы с таймлайном.", + "customizeTooltip": "Настроить клавиши", + "customize": "Настроить", + "panTimeline": "Перемещение по таймлайну", + "zoomTimeline": "Увеличение таймлайна", + "cycleAnnotations": "Переключение аннотаций", + "tab": "Tab" + }, + "actions": { + "saveAgain": "Сохранить ещё раз", + "showInFolder": "Показать в папке" + }, + "project": { + "untitled": "Без названия", + "unsavedChangesTitle": "Несохранённые изменения", + "unsavedChangesDescription": "Сохранить текущий проект перед действием «{{action}}»?", + "discardChanges": "Отменить изменения", + "saveProject": "Сохранить проект" + }, + "exportTips": { + "autoZooms": "Совет: Автоматические приближения можно отключить в настройках", + "experimentalBuilds": "Совет: Включите доступ к экспериментальным сборкам в настройках", + "cursorAppearance": "Совет: Внешний вид курсора можно настроить" + }, + "account": { "title": "Учётная запись", "comingSoon": "Учётная запись скоро появится" }, + "nativeCaptureUnavailable": { + "title": "Всё в порядке, но мы не можем отобразить анимированное наложение курсора.", + "description": "Устройство не поддерживает нативный захват изображения. Запись продолжится, но без сглаживания курсора.", + "confirm": "Хорошо" + }, + "exportStatus": { + "exporting": "Экспорт", + "renderingFile": "Обработка файла.", + "preparing": "Подготовка к экспорту...", + "completePercent": "Готово: {{percent}}%", + "issue": "Ошибка экспорта", + "complete": "Экспорт завершён", + "savedSuccessfully": "Файл сохранён" + }, + "export": { + "processingAudioEdits": "Обработка звука (скорость, наложения)" + }, + "toolbar": { + "addLayer": "Добавить слой", + "splitClip": "Разрезать клип (C)" + }, + "timeline": { + "expand": "Развернуть таймлайн", + "collapse": "Свернуть таймлайн" + }, + "openRecordingsFolder": "Открыть папку с записями" +} diff --git a/src/i18n/locales/ru/launch.json b/src/i18n/locales/ru/launch.json new file mode 100644 index 000000000..47531cfa1 --- /dev/null +++ b/src/i18n/locales/ru/launch.json @@ -0,0 +1,95 @@ +{ + "recording": { + "disableSystemAudio": "Отключить системные звуки", + "enableSystemAudio": "Включить системные звуки", + "disableMicrophone": "Отключить микрофон", + "enableMicrophone": "Включить микрофон", + "micToggleDisabledTip": "Нельзя переключать микрофон во время записи", + "disableWebcam": "Отключить наложение веб-камеры", + "enableWebcam": "Включить наложение веб-камеры", + "countdownDelay": "Обратный отсчёт", + "noDelay": "Без задержки", + "record": "Запись", + "recordingFolder": "Место сохранения: {{path}}", + "chooseRecordingsFolder": "Изменить место сохранения", + "folderPath": "Папка: /{{name}}/", + "openVideoFile": "Открыть записанное видео", + "openProject": "Открыть проект", + "hideHudFromVideo": "Скрыть интерфейс во время записи", + "showHudInVideo": "Показать интерфейс во время записи", + "hideHud": "Скрыть интерфейс", + "closeApp": "Закрыть приложение", + "screens": "Экраны", + "windows": "Окна", + "screen": "Экран", + "window": "Окно", + "noSourcesFound": "Источники изображения не найдены", + "microphone": "Микрофон", + "turnOffMicrophone": "Выключить микрофон", + "selectMicToEnable": "Выберите микрофон для записи звука", + "noMicrophonesFound": "Нет доступных микрофонов", + "webcam": "Веб-камера", + "turnOffWebcam": "Выключить веб-камеру", + "hideFloatingWebcamPreview": "Скрыть предпросмотр", + "showFloatingWebcamPreview": "Включить предпросмотр", + "selectWebcamToEnable": "Выберите веб-камеру", + "noWebcamsFound": "Нет доступных веб-камер", + "recordingsFolder": "Место сохранения", + "language": "Язык", + "paused": "ПАУЗА", + "rec": "ЗАПИСЬ", + "resume": "Возобновить", + "pause": "Пауза", + "stop": "Стоп", + "cancel": "Отмена", + "more": "Ещё", + "update": { + "update": "Обновить", + "updated": "Обновлено", + "idleTitle": "Проверить обновления.", + "checkingTitle": "Поиск обновлений...", + "downloadingTitle": "Загрузка обновления...", + "errorTitle": "Ошибка при проверке обновления. Нажмите, чтобы повторить", + "upToDateTitle": "У вас актуальная версия Recordly {{version}}.", + "availableTitle": "Доступна новая версия Recordly {{version}}.", + "availableGenericTitle": "Доступно обновление." + } + }, + "sourceSelector": { + "loadingSources": "Загрузка источников...", + "screens": "Экраны", + "windows": "Окна", + "noScreensAvailable": "Нет экранов для захвата", + "noWindowsAvailable": "Нет окон для захвата", + "windowsNote": "Записать можно только видимые (не свёрнутые) окна.", + "windowPlaceholder": "Окно", + "cancel": "Отменить", + "share": "Поделиться" + }, + "permissions": { + "screenRecordingNeeded": "Recordly нужно разрешение на запись экрана. Мы открыли настройки – включите доступ и перезапустите приложение.", + "screenRecordingMissing": "Доступа к записи экрана всё ещё нет. Мы снова открыли настройки. Разрешите доступ и перезапустите Recordly.", + "accessibilityNeeded": "Разрешите Recordly использовать универсальный доступ, чтобы отслеживать курсор. Мы открыли настройки – включите его и перезапустите приложение.", + "accessibilityMissing": "Разрешения для универсального доступа всё ещё нет. Включите его в настройках и перезапустите Recordly.", + "selectSource": "Выберите источник записи", + "systemAudioUnavailable": "Системные звуки недоступны для этого источника. Запись продолжится без их захвата.", + "microphoneDenied": "Нет доступа к микрофону. Запись продолжится без вашего голоса.", + "failedToStart": "Не удалось начать запись: {{error}}", + "failedToStartGeneric": "Не удалось начать запись" + }, + "updateToast": { + "availableTitle": "Доступно обновление", + "experimentalAvailableTitle": "Доступно экспериментальное обновление", + "experimentalDescription": "Вы включили экспериментальные обновления, поэтому можете протестировать последнее обновление Recordly до его широкого выпуска.", + "downloadingTitle": "Загрузка обновления", + "readyTitle": "Готово к перезапуску", + "checkErrorTitle": "Не удалось проверить обновления", + "downloadErrorTitle": "Не удалось загрузить обновление", + "experimentalBadge": "Экспериментальное", + "previewBadge": "Предпросмотр", + "notNow": "Не сейчас", + "updateNow": "Обновить сейчас", + "restartToUpdate": "Перезапустить для обновления", + "tryAgain": "Повторить" + } +} diff --git a/src/i18n/locales/ru/settings.json b/src/i18n/locales/ru/settings.json new file mode 100644 index 000000000..0d65dd89d --- /dev/null +++ b/src/i18n/locales/ru/settings.json @@ -0,0 +1,265 @@ +{ + "zoom": { + "level": "Масштабирование", + "selectRegion": "Выберите область для масштабирования", + "deleteZoom": "Удалить", + "modeAuto": "Автоматически", + "modeManual": "Вручную", + "modeManualDescription": "Установить фиксированную точку фокусировки", + "modeAutoDescription": "Камера выравнивается по центру, когда курсор приближается к краю увеличенного изображения" + }, + "trim": { + "deleteRegion": "Удалить" + }, + "speed": { + "playbackSpeed": "Скорость воспроизведения", + "selectRegion": "Диапазон скорости", + "deleteRegion": "Удалить", + "label": "Скорость" + }, + "clip": { + "title": "Клип", + "mute": "Без звука", + "mutedState": "Звук выключен", + "unmutedState": "Звук включен", + "separateClipFromAudio": "Отделить аудио от клипа", + "delete": "Удалить клип" + }, + "effects": { + "title": "Эффекты", + "show": "Показать", + "showCursor": "Показать курсор", + "loopCursor": "Зациклить курсор", + "cursorStyle": "Стиль курсора", + "cursorStyleOptions": { + "macos": "macOS", + "tahoe": "Tahoe", + "tahoe-inverted": "Tahoe (инверсия)", + "windows11": "Windows 11", + "dot": "Точка", + "figma": "Минималистичный", + "lavender": "Лаванда", + "parched": "Опаленный", + "chooper": "Чоппер", + "amongus": "Among Us", + "turtle": "Черепаха" + }, + "backgroundBlur": "Размытие", + "zoomMotionBlur": "Размытие при зуме", + "temporalZoomMotionBlur": "Временное размытие зума", + "temporalZoomMotionBlurDescription": "Настройка плавности зума по времени и кадрам.", + "zoomMotionBlurSamples": "Образцы размытия", + "zoomMotionBlurShutter": "Выдержка", + "auto": "Авто", + "connectZooms": "Связать зумы", + "connectZoomsDescription": "Плавный переход между зумами.", + "autoApplyFreshRecordingZooms": "Автозум для новых записей", + "autoApplyFreshRecordingZoomsDescription": "Автоматически предлагает зум при открытии записи.", + "zoomGeneralTitle": "Общее", + "zoomGeneralDescription": "Настройки движения зума.", + "zoomInTitle": "Приближение", + "zoomInDescription": "Настройка входа камеры в область зума.", + "zoomOutTitle": "Отдаление", + "zoomOutDescription": "Настройка выхода камеры из области зума.", + "connectedZoomTitle": "Между зумами", + "connectedZoomDescription": "Настройка плавного перехода между зумами.", + "motionPresetsTitle": "Пресеты движения", + "motionPresetsZoomHint": "Предварительные параметры доступны в настройках.", + "animationPresets": "Пресеты анимации", + "cursorMotionPresets": "Предустановки движения курсора", + "motionPresets": { + "focused": { + "label": "Фокус", + "description": "Быстрая анимация для демо-роликов, пошаговых инструкций и повседневных записей." + }, + "smooth": { + "label": "Спокойный", + "description": "Мягкая анимация для видеороликов в стиле Keynote и презентаций." + } + }, + "zoomInDuration": "Длительность приближения", + "zoomInOverlap": "Перекрытие приближения", + "zoomOutDuration": "Длительность отдаления", + "zoomInEasing": "Кривая приближения", + "zoomOutEasing": "Кривая отдаления", + "connectedZoomGap": "Интервал между зумами", + "connectedZoomDuration": "Длительность интервала", + "connectedZoomEasing": "Кривая панорамирования", + "zoomEasingOptions": { + "recordly": "Recordly", + "glide": "Плавно", + "smooth": "Мягко", + "snappy": "Резко", + "linear": "Линейно" + }, + "cursorSize": "Размер курсора", + "cursorSmoothing": "Сглаживание курсора", + "cursorSpringStiffness": "Жёсткость пружины", + "cursorSpringDamping": "Затухание", + "cursorSpringMass": "Масса (инерция)", + "off": "Выкл.", + "cursorClickEffects": { + "title": "Click Effects", + "advanced": "Advanced", + "advancedShow": "Show advanced click effect controls", + "advancedHide": "Hide advanced click effect controls", + "color": "Effect Color", + "size": "Effect Size", + "opacity": "Effect Opacity", + "duration": "Effect Duration", + "none": { + "label": "Off", + "description": "No click graphic. Only the cursor motion changes when you click." + }, + "ripple": { + "label": "Ripple", + "description": "Expanding rings radiate from each click so taps read clearly in motion." + }, + "spotlight": { + "label": "Spotlight", + "description": "A soft halo flashes around the pointer to emphasize the clicked area." + }, + "echo": { + "label": "Echo", + "description": "A pair of soft rings that spread outward with a cleaner pulse." + } + }, + "cursorMotionBlur": "Размытие в движении", + "cursorClickBounce": "Отскок при клике", + "cursorClickBounceDuration": "Скорость отскока", + "cursorSway": "Покачивание курсора", + "webcam": "Наложение веб-камеры", + "webcamFootage": "Запись с веб-камеры", + "webcamFootageDescription": "Видео не добавлено", + "uploadWebcamFootage": "Загрузить видео", + "replaceWebcamFootage": "Заменить видео", + "removeWebcamFootage": "Удалить видео", + "webcamFootageAdded": "Видео добавлено", + "webcamFootageRemoved": "Видео удалено", + "webcamSize": "Размер веб-камеры", + "webcamWidth": "Ширина веб-камеры", + "webcamHeight": "Высота веб-камеры", + "webcamCrop": "Обрезка веб-камеры", + "webcamReactToZoom": "Веб-камера реагирует на зум", + "webcamMirror": "Отразить веб-камеру", + "webcamRoundness": "Скругление веб-камеры", + "webcamShadow": "Тень веб-камеры", + "shadow": "Тень", + "radius": "Радиус", + "roundness": "Скругление", + "padding": "Отступы", + "paddingLinked": "Связанные (одинаковые)", + "paddingUnlinked": "Раздельные", + "paddingTop": "Сверху", + "paddingBottom": "Снизу", + "paddingLeft": "Слева", + "paddingRight": "Справа", + "removeBackground": "Удалить фон" + }, + "sections": { + "scene": "Сцена", + "captions": "Субтитры", + "caption": "Субтитр", + "zoom": "Зум", + "cursor": "Курсор", + "webcam": "Веб-камера", + "frame": "Рамка", + "crop": "Обрезать видео" + }, + "captions": { + "selectOnTimeline": "Выберите субтитры на таймлайне, чтобы отредактировать их.", + "enabled": "Показать", + "timelineQuickAdd": "Наведите курсор, чтобы добавить на таймлайн", + "language": "Язык", + "downloading": "Загрузка...", + "deleteModel": "Удалить модель", + "clearModel": "Очистить модель", + "downloadModel": "Загрузить модель", + "generating": "Создание...", + "generateFull": "Создать субтитры", + "regenerateFull": "Пересоздать субтитры", + "clearFull": "Удалить субтитры", + "fontSettings": "Настройка шрифта", + "defaultFont": "По умолчанию", + "fontFamily": "Шрифт", + "fontSize": "Размер", + "rowCount": "Строки", + "animation": "Анимация", + "animationOff": "Выкл", + "animationFade": "Появление", + "animationRise": "Подъем", + "animationPop": "Всплытие", + "bottomOffset": "Отступ снизу", + "maxWidth": "Максимальная ширина", + "boxRadius": "Скругление", + "backgroundOpacity": "Прозрачность фона", + "textColor": "Цвет текста", + "editor": { + "text": "Text", + "start": "Start", + "end": "End", + "split": "Split", + "merge": "Merge", + "delete": "Delete" + } + }, + "crop": { + "title": "Обрезка видео", + "instruction": "Перетащите края для обрезки", + "top": "Верх", + "bottom": "Низ", + "left": "Левый край", + "right": "Правый край", + "openEditor": "Открыть редактор" + }, + "background": { + "title": "Фон", + "image": "Изображение", + "color": "Цвет", + "gradient": "Градиент", + "wallpaperPreview": "Предпросмотр", + "uploadCustom": "Загрузить", + "uploadSuccess": "Изображение загружено.", + "uploadError": "Загрузите JPG или JPEG.", + "uploadErrorDescription": "Поддерживаются только JPG и JPEG." + }, + "export": { + "title": "Экспорт", + "mp4": "MP4", + "gif": "GIF", + "quality": { + "low": "Низкое", + "medium": "Среднее", + "high": "Высокое", + "original": "Исходное" + }, + "fpsTitle": "FPS", + "loop": "Зациклить", + "outputDimensions": "Размер: {{dimensions}}px", + "sizePresetOriginalShort": "Ориг", + "sizePresetMediumShort": "Сред", + "sizePresetLargeShort": "Бол", + "loadProject": "Загрузить проект", + "saveProject": "Сохранить проект", + "exportVideo": "Экспортировать {{format}}", + "reportBug": "Сообщить об ошибке", + "starOnGithub": "Оценить на GitHub" + }, + "audio": { + "title": "Аудио", + "volumeTitle": "Аудио", + "volume": "Громкость", + "normalize": "Нормализовать", + "sourceTracksTitle": "Исходное аудио клипа", + "systemLabel": "Системный звук", + "micLabel": "Микрофон", + "mixedLabel": "Источник", + "deleteRegion": "Удалить аудио" + }, + "updates": { + "title": "Обновления", + "experimental": "Экспериментальные обновления", + "saveFailed": "Не удалось изменить канал обновлений.", + "experimentalDescription": "Вы включили экспериментальные обновления, поэтому можете протестировать последнее обновление Recordly до его широкого выпуска." + } +} diff --git a/src/i18n/locales/ru/shortcuts.json b/src/i18n/locales/ru/shortcuts.json new file mode 100644 index 000000000..3e54412bc --- /dev/null +++ b/src/i18n/locales/ru/shortcuts.json @@ -0,0 +1,16 @@ +{ + "actions": { + "addZoom": "Добавить зум", + "addTrim": "Обрезать", + "addSpeed": "Увеличить скорость", + "addAnnotation": "Добавить аннотацию", + "addKeyframe": "Добавить ключ", + "deleteSelected": "Удалить выбранное", + "playPause": "Воспроизведение / Пауза", + "cycleForward": "Следующая аннотация", + "cycleBackward": "Предыдущая аннотация", + "deleteSelectedAlt": "Удалить выбранное (alt)", + "panTimeline": "Прокрутка таймлайна", + "zoomTimeline": "Масштабирование таймлайна" + } +} diff --git a/src/i18n/locales/ru/timeline.json b/src/i18n/locales/ru/timeline.json new file mode 100644 index 000000000..5b89d4667 --- /dev/null +++ b/src/i18n/locales/ru/timeline.json @@ -0,0 +1,41 @@ +{ + "zoom": { + "cannotPlace": "Нельзя добавить зум здесь", + "existsOrNoSpace": "Зум уже есть или недостаточно места для его добавления.", + "suggestHandlerUnavailable": "Подсказки недоступны", + "noTelemetry": "Нет данных о курсоре", + "recordFirst": "Запишите видео для генерации подсказок на основе курсора.", + "noUsableTelemetry": "Недостаточно данных о курсоре", + "notEnoughMovement": "В видео мало движения курсора.", + "noInteractionMoments": "Нет точек взаимодействия", + "tryRecording": "Попробуйте записать видео с паузами или щелчками мыши во время важных действий.", + "noAutoZoomSlots": "Нет слотов с авто-зумом.", + "dwellPointsOverlap": "Конфликт с существующими зумами.", + "addedSuggestions": "Добавлено зумов: {{count}}", + "label": "Зум {{index}}", + "addZoom": "Добавить зум (Z)", + "suggestZooms": "Предложить зумы на основе курсора" + }, + "trim": { + "cannotPlace": "Нельзя обрезать здесь", + "existsOrNoSpace": "Уже обрезано или недостаточно места.", + "label": "Обрезка {{index}}", + "addTrim": "Обрезать (T)" + }, + "speed": { + "cannotPlace": "Нельзя изменить скорость здесь", + "existsOrNoSpace": "Скорость уже задана или недостаточно места.", + "label": "Скорость" + }, + "annotation": { + "label": "Аннотация", + "image": "Изображение", + "addAnnotation": "Добавить аннотацию (A)" + }, + "audio": { + "label": "Аудио" + }, + "addSpeed": "Скорость (S)", + "resizeLeft": "Изменить размер слева", + "resizeRight": "Изменить размер справа" +} diff --git a/src/i18n/locales/zh-CN/common.json b/src/i18n/locales/zh-CN/common.json new file mode 100644 index 000000000..b35ca3d8c --- /dev/null +++ b/src/i18n/locales/zh-CN/common.json @@ -0,0 +1,26 @@ +{ + "app": { + "name": "Recordly", + "editorTitle": "Recordly 编辑器", + "subtitle": "屏幕录制与编辑", + "language": "语言", + "manageRecordings": "打开录制文件夹" + }, + "actions": { + "cancel": "取消", + "close": "关闭", + "export": "导出", + "load": "加载", + "redo": "重做", + "reset": "重置", + "save": "保存", + "undo": "撤销", + "delete": "删除", + "done": "完成" + }, + "errors": { + "invalidFileType": "无效的文件类型", + "failedToUploadImage": "上传图片失败", + "fileReadError": "读取文件时出错。" + } +} diff --git a/src/i18n/locales/zh-CN/dialogs.json b/src/i18n/locales/zh-CN/dialogs.json new file mode 100644 index 000000000..9642d2e96 --- /dev/null +++ b/src/i18n/locales/zh-CN/dialogs.json @@ -0,0 +1,62 @@ +{ + "export": { + "pleaseTryAgain": "请重试", + "compilingGifProgress": "正在编译 GIF... {{progress}}%", + "compilingGifWait": "正在编译 GIF... 这可能需要一些时间", + "takeMoment": "这可能需要一点时间...", + "exportFailed": "导出失败", + "compilingGifTitle": "正在编译 GIF", + "exportingFormat": "正在导出 {{format}}", + "exportComplete": "导出完成", + "formatReady": "您的 {{format}} 已准备就绪", + "showInFolder": "在文件夹中显示", + "compiling": "正在编译", + "renderingFrames": "正在渲染帧", + "processing": "正在处理...", + "status": "状态", + "format": "格式", + "compilingStatus": "正在编译...", + "frames": "帧", + "cancelExport": "取消导出", + "reopenSaveDialog": "再次打开保存对话框", + "savedSuccess": "{{format}} 保存成功!" + }, + "addFont": { + "title": "添加 Google 字体", + "heading": "添加 Google 字体", + "description": "从 Google Fonts 添加自定义字体用于注释。", + "urlLabel": "Google Fonts 导入 URL", + "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap", + "urlHelp": "从 Google Fonts 获取:选择字体 → 点击「获取字体」→ 复制 @import URL", + "nameLabel": "显示名称", + "namePlaceholder": "我的自定义字体", + "nameHelp": "此名称将显示在字体选择器中", + "adding": "正在添加...", + "addFont": "添加字体", + "enterUrl": "请输入 Google Fonts 导入 URL", + "invalidUrl": "请输入有效的 Google Fonts URL", + "enterName": "请输入字体名称", + "extractFailed": "无法从 URL 提取字体系列名称", + "addSuccess": "字体 \"{{name}}\" 添加成功", + "addFailed": "添加字体失败", + "loadTimeout": "字体加载超时。请检查 URL 后重试。", + "loadFailed": "无法加载字体。请确认 Google Fonts URL 是否正确。" + }, + "shortcutsConfig": { + "title": "键盘快捷键", + "configurable": "可配置", + "fixed": "固定", + "pressEscToCancel": "按 Esc 取消", + "clickToChange": "点击以更改", + "pressAKey": "按下按键…", + "alreadyUsedBy": "已被 <strong>{{action}}</strong> 使用", + "swap": "交换", + "reserved": "此快捷键已保留给 \"{{label}}\",无法重新分配。", + "saved": "键盘快捷键已保存", + "resetNotice": "已重置为默认快捷键 - 点击保存以应用", + "instructions": "点击快捷键后按下新的组合键。按 Esc 取消。", + "resetToDefaults": "重置为默认", + "cancel": "取消", + "save": "保存" + } +} diff --git a/src/i18n/locales/zh-CN/editor.json b/src/i18n/locales/zh-CN/editor.json new file mode 100644 index 000000000..bb799ed11 --- /dev/null +++ b/src/i18n/locales/zh-CN/editor.json @@ -0,0 +1,153 @@ +{ + "playback": { + "play": "播放", + "pause": "暂停", + "skipBack": "后退", + "skipForward": "前进", + "muteUnmute": "静音/取消静音", + "volume": "预览音量" + }, + "annotations": { + "settings": "注释设置", + "active": "活动", + "text": "文本", + "image": "图片", + "arrow": "箭头", + "blur": "模糊", + "textContent": "文本内容", + "textPlaceholder": "输入文本...", + "fontStyle": "字体样式", + "selectStyle": "选择样式", + "size": "大小", + "toggleBold": "切换粗体", + "toggleItalic": "切换斜体", + "toggleUnderline": "切换下划线", + "alignLeft": "左对齐", + "alignCenter": "居中对齐", + "alignRight": "右对齐", + "textColor": "文本颜色", + "background": "背景", + "none": "无", + "clearBackground": "清除背景", + "uploadImage": "上传图片", + "supportedFormats": "支持格式:JPG、PNG、GIF、WebP", + "arrowDirection": "箭头方向", + "strokeWidth": "描边宽度:{{width}}px", + "arrowColor": "箭头颜色", + "deleteAnnotation": "删除注释", + "shortcutsAndTips": "快捷键与提示", + "tipSelectAnnotation": "将播放头移动到注释重叠区域并选择项目。", + "tipCycleForward": "使用 Tab 循环切换重叠项目。", + "tipCycleBackward": "使用 Shift+Tab 反向循环切换。", + "imageUploadSuccess": "图片上传成功!", + "imageUploadError": "请上传 JPG、PNG、GIF 或 WebP 图片文件。", + "blurStrength": "模糊强度: {{strength}}", + "solidColor": "纯色 (审查)", + "borderRadius": "边框半径" + }, + + "fontStyles": { + "classic": "经典", + "editor": "编辑器", + "strong": "加粗", + "typewriter": "打字机", + "deco": "装饰", + "simple": "简约", + "modern": "现代", + "clean": "清爽" + }, + "format": { + "mp4Video": "MP4 视频", + "mp4Description": "高质量视频文件", + "gifAnimation": "GIF 动画", + "gifDescription": "可分享的动态图片" + }, + "gifOptions": { + "frameRate": "帧率", + "outputSize": "输出尺寸", + "outputDimensions": "输出:{{width}} × {{height}}px", + "loopAnimation": "循环动画", + "loopDescription": "GIF 将持续播放" + }, + "tutorial": { + "howTrimmingWorks": "修剪的工作方式", + "title": "修剪的工作方式", + "understanding": "了解如何裁剪视频中不需要的部分。", + "descriptionP1": "修剪工具通过定义要", + "descriptionRemove": "移除", + "descriptionP2": "的片段来工作。", + "descriptionP3": "时间线上被红色修剪区域覆盖的部分将在导出时被剪掉。", + "visualExample": "视觉示例", + "removed": "已移除", + "kept": "保留", + "finalVideo": "最终视频", + "part": "第 {{number}} 部分", + "addTrimStep": "1. 添加修剪", + "addTrimDesc": "按 T 或点击剪刀图标标记要移除的部分。", + "adjustStep": "2. 调整", + "adjustDesc": "拖动红色区域的边缘,精确覆盖要剪掉的部分。" + }, + "feedback": { + "trigger": "反馈", + "title": "反馈与联系", + "description": "如果有问题或缺失功能,可以直接联系或提交 issue。", + "emailLabel": "邮箱", + "xLabel": "X", + "reportIssue": "报告问题 / 提交反馈", + "openFailed": "打开链接失败。" + }, + "keyboardShortcuts": { + "trigger": "快捷键", + "title": "键盘快捷键", + "description": "时间线和编辑器控制的快速参考。", + "customizeTooltip": "自定义快捷键", + "customize": "自定义", + "panTimeline": "平移时间线", + "zoomTimeline": "缩放时间线", + "cycleAnnotations": "循环切换注释", + "tab": "Tab" + }, + "actions": { + "saveAgain": "再次保存", + "showInFolder": "在文件夹中显示" + }, + "project": { + "untitled": "未命名", + "unsavedChangesTitle": "未保存的更改", + "unsavedChangesDescription": "在{{action}}之前保存当前项目吗?", + "discardChanges": "放弃更改", + "saveProject": "保存项目" + }, + "exportTips": { + "autoZooms": "提示:可在设置中关闭自动应用的缩放", + "experimentalBuilds": "提示:可在设置中开启实验版本访问权限", + "cursorAppearance": "提示:你可以自定义光标外观" + }, + "account": { "title": "账户", "comingSoon": "账户功能即将推出" }, + "nativeCaptureUnavailable": { + "title": "没有出错,但我们无法渲染动画光标叠加层。", + "description": "你的设备不支持原生捕获。这可能是由我们尚未确定的多种原因造成的。Recordly 仍可继续运行,但无法进行光标平滑处理。", + "confirm": "好的" + }, + "exportStatus": { + "exporting": "正在导出", + "renderingFile": "正在渲染你的文件。", + "preparing": "正在准备导出...", + "completePercent": "已完成 {{percent}}%", + "issue": "导出问题", + "complete": "导出完成", + "savedSuccessfully": "文件已成功保存。" + }, + "export": { + "processingAudioEdits": "正在处理带有速度/叠加编辑的音频" + }, + "toolbar": { + "addLayer": "添加图层", + "splitClip": "拆分片段 (C)" + }, + "timeline": { + "expand": "展开时间轴", + "collapse": "折叠时间轴" + }, + "openRecordingsFolder": "打开录制文件夹" +} diff --git a/src/i18n/locales/zh-CN/launch.json b/src/i18n/locales/zh-CN/launch.json new file mode 100644 index 000000000..2b88c6d70 --- /dev/null +++ b/src/i18n/locales/zh-CN/launch.json @@ -0,0 +1,95 @@ +{ + "recording": { + "disableSystemAudio": "禁用系统音频", + "enableSystemAudio": "启用系统音频", + "disableMicrophone": "禁用麦克风", + "enableMicrophone": "启用麦克风", + "micToggleDisabledTip": "录制过程中无法切换麦克风", + "disableWebcam": "禁用摄像头叠加", + "enableWebcam": "启用摄像头叠加", + "countdownDelay": "倒计时延迟", + "noDelay": "无延迟", + "record": "录制", + "recordingFolder": "录制文件夹:{{path}}", + "chooseRecordingsFolder": "选择录制文件夹", + "folderPath": "路径:/{{name}}/", + "openVideoFile": "打开视频文件", + "openProject": "打开项目", + "hideHudFromVideo": "从录制中隐藏 HUD", + "showHudInVideo": "在录制中显示 HUD", + "hideHud": "隐藏 HUD", + "closeApp": "关闭应用", + "screens": "屏幕", + "windows": "窗口", + "screen": "屏幕", + "window": "窗口", + "noSourcesFound": "未找到源", + "microphone": "麦克风", + "turnOffMicrophone": "关闭麦克风", + "selectMicToEnable": "选择一个麦克风以启用", + "noMicrophonesFound": "未找到麦克风", + "webcam": "摄像头", + "turnOffWebcam": "关闭摄像头", + "hideFloatingWebcamPreview": "隐藏悬浮预览", + "showFloatingWebcamPreview": "显示悬浮预览", + "selectWebcamToEnable": "选择一个摄像头以启用", + "noWebcamsFound": "未找到摄像头", + "recordingsFolder": "录制文件夹", + "language": "语言", + "paused": "已暂停", + "rec": "录制中", + "resume": "恢复", + "pause": "暂停", + "stop": "停止", + "cancel": "取消", + "more": "更多", + "update": { + "update": "更新", + "updated": "已是最新", + "idleTitle": "检查更新。", + "checkingTitle": "正在检查更新...", + "downloadingTitle": "正在下载更新...", + "errorTitle": "检查更新失败。点击重试。", + "upToDateTitle": "Recordly {{version}} 已是最新版本。", + "availableTitle": "Recordly {{version}} 可更新。", + "availableGenericTitle": "有可用更新。" + } + }, + "sourceSelector": { + "loadingSources": "正在加载源...", + "screens": "屏幕", + "windows": "窗口", + "noScreensAvailable": "没有可用的屏幕", + "noWindowsAvailable": "没有可用的窗口", + "windowsNote": "仅可录制可见(非最小化)窗口。", + "windowPlaceholder": "窗口", + "cancel": "取消", + "share": "共享" + }, + "permissions": { + "screenRecordingNeeded": "Recordly 需要屏幕录制权限才能开始。系统设置已打开。启用后请退出并重新打开 Recordly。", + "screenRecordingMissing": "屏幕录制权限仍然缺失。系统设置已再次打开。请启用权限,然后退出并重新打开 Recordly。", + "accessibilityNeeded": "Recordly 还需要辅助功能权限以跟踪光标。系统设置已打开。启用后请退出并重新打开 Recordly。", + "accessibilityMissing": "辅助功能权限仍然缺失。系统设置已再次打开。请启用权限,然后退出并重新打开 Recordly。", + "selectSource": "请选择要录制的源", + "systemAudioUnavailable": "此源不支持系统音频。将继续录制但不包含系统音频。", + "microphoneDenied": "麦克风访问被拒绝。将继续录制但不包含麦克风音频。", + "failedToStart": "录制启动失败:{{error}}", + "failedToStartGeneric": "录制启动失败" + }, + "updateToast": { + "availableTitle": "有可用更新", + "experimentalAvailableTitle": "有可用的实验性更新", + "experimentalDescription": "你已选择接收实验性更新,因此可以在 Recordly 最新更新广泛发布之前选择先行测试。", + "downloadingTitle": "正在下载更新", + "readyTitle": "已准备好重新启动", + "checkErrorTitle": "无法检查更新", + "downloadErrorTitle": "无法下载更新", + "experimentalBadge": "实验性", + "previewBadge": "预览", + "notNow": "暂不", + "updateNow": "立即更新", + "restartToUpdate": "重新启动以更新", + "tryAgain": "重试" + } +} diff --git a/src/i18n/locales/zh-CN/settings.json b/src/i18n/locales/zh-CN/settings.json new file mode 100644 index 000000000..e07f4cf91 --- /dev/null +++ b/src/i18n/locales/zh-CN/settings.json @@ -0,0 +1,265 @@ +{ + "zoom": { + "level": "缩放级别", + "selectRegion": "选择缩放区域以调整", + "deleteZoom": "删除缩放", + "modeAuto": "自动", + "modeManual": "手动", + "modeManualDescription": "为此缩放设置固定焦点", + "modeAutoDescription": "镜头会自动跟随光标" + }, + "trim": { + "deleteRegion": "删除修剪区域" + }, + "speed": { + "playbackSpeed": "播放速度", + "selectRegion": "选择变速区域以调整", + "deleteRegion": "删除变速区域", + "label": "速度" + }, + "clip": { + "title": "片段", + "mute": "静音", + "mutedState": "音频已静音", + "unmutedState": "音频正在播放", + "separateClipFromAudio": "将剪辑与音频分离", + "delete": "删除片段" + }, + "effects": { + "title": "视频效果", + "show": "显示", + "showCursor": "显示光标", + "loopCursor": "循环光标", + "cursorStyle": "光标样式", + "cursorStyleOptions": { + "macos": "macOS", + "tahoe": "Tahoe", + "tahoe-inverted": "Tahoe 反相", + "windows11": "Windows 11", + "dot": "圆点", + "figma": "Minimal", + "lavender": "Lavender", + "parched": "Parched", + "chooper": "Chooper", + "amongus": "Among Us", + "turtle": "Turtle" + }, + "backgroundBlur": "模糊", + "zoomMotionBlur": "缩放运动模糊", + "temporalZoomMotionBlur": "Temporal Zoom Blur", + "temporalZoomMotionBlurDescription": "Control the shutter window and frame samples used by the newer zoom blur pass.", + "zoomMotionBlurSamples": "Blur Samples", + "zoomMotionBlurShutter": "Shutter", + "auto": "Auto", + "connectZooms": "连接缩放", + "connectZoomsDescription": "将连续的缩放区域平滑连接为一次连续的镜头移动。", + "autoApplyFreshRecordingZooms": "自动为新录制应用缩放", + "autoApplyFreshRecordingZoomsDescription": "打开新录制时自动建议跟随光标的缩放。", + "zoomGeneralTitle": "全局", + "zoomGeneralDescription": "控制所有缩放过渡的整体运动设置。", + "zoomInTitle": "进入缩放", + "zoomInDescription": "控制镜头进入缩放区域的方式。", + "zoomOutTitle": "退出缩放", + "zoomOutDescription": "控制镜头离开缩放区域的方式。", + "connectedZoomTitle": "缩放之间", + "connectedZoomDescription": "在启用连接缩放时,调整连续缩放区域之间的滑移方式。", + "motionPresetsTitle": "运动预设", + "motionPresetsZoomHint": "缩放运动预设已移到“设置”中。", + "animationPresets": "动画预设", + "cursorMotionPresets": "光标运动预设", + "motionPresets": { + "focused": { + "label": "专注", + "description": "更利落,适合演示、讲解和日常录屏。" + }, + "smooth": { + "label": "平滑", + "description": "更柔和,适合展示、发布会风格视频和更从容的镜头。" + } + }, + "zoomInDuration": "进入时长", + "zoomInOverlap": "进入重叠", + "zoomOutDuration": "退出时长", + "zoomInEasing": "进入曲线", + "zoomOutEasing": "退出曲线", + "connectedZoomGap": "连接缩放间隔", + "connectedZoomDuration": "连接缩放时长", + "connectedZoomEasing": "连接平移曲线", + "zoomEasingOptions": { + "recordly": "Recordly", + "glide": "滑行", + "smooth": "平滑", + "snappy": "利落", + "linear": "线性" + }, + "cursorSize": "光标大小", + "cursorSmoothing": "光标平滑", + "cursorSpringStiffness": "光标弹簧刚度", + "cursorSpringDamping": "光标弹簧阻尼", + "cursorSpringMass": "光标弹簧质量", + "off": "关", + "cursorClickEffects": { + "title": "Click Effects", + "advanced": "Advanced", + "advancedShow": "Show advanced click effect controls", + "advancedHide": "Hide advanced click effect controls", + "color": "Effect Color", + "size": "Effect Size", + "opacity": "Effect Opacity", + "duration": "Effect Duration", + "none": { + "label": "Off", + "description": "No click graphic. Only the cursor motion changes when you click." + }, + "ripple": { + "label": "Ripple", + "description": "Expanding rings radiate from each click so taps read clearly in motion." + }, + "spotlight": { + "label": "Spotlight", + "description": "A soft halo flashes around the pointer to emphasize the clicked area." + }, + "echo": { + "label": "Echo", + "description": "A pair of soft rings that spread outward with a cleaner pulse." + } + }, + "cursorMotionBlur": "光标运动模糊", + "cursorClickBounce": "光标点击弹跳", + "cursorClickBounceDuration": "弹跳速度", + "cursorSway": "光标摆动", + "webcam": "摄像头叠加", + "webcamFootage": "摄像头素材", + "webcamFootageDescription": "此视频尚未关联摄像头素材", + "uploadWebcamFootage": "上传素材", + "replaceWebcamFootage": "替换素材", + "removeWebcamFootage": "移除素材", + "webcamFootageAdded": "已关联摄像头素材", + "webcamFootageRemoved": "已移除摄像头素材", + "webcamSize": "摄像头大小", + "webcamWidth": "摄像头宽度", + "webcamHeight": "摄像头高度", + "webcamCrop": "摄像头裁剪", + "webcamReactToZoom": "摄像头随缩放变化", + "webcamMirror": "镜像摄像头", + "webcamRoundness": "摄像头圆角", + "webcamShadow": "摄像头阴影", + "shadow": "阴影", + "radius": "圆角半径", + "roundness": "圆角", + "padding": "内边距", + "paddingLinked": "联动(统一)", + "paddingUnlinked": "独立(非对称)", + "paddingTop": "上", + "paddingBottom": "下", + "paddingLeft": "左", + "paddingRight": "右", + "removeBackground": "移除背景" + }, + "sections": { + "scene": "场景", + "captions": "字幕", + "caption": "字幕编辑", + "zoom": "缩放", + "cursor": "光标", + "webcam": "摄像头", + "frame": "边框", + "crop": "裁剪" + }, + "captions": { + "selectOnTimeline": "在时间轴上选择字幕进行编辑。", + "enabled": "显示", + "timelineQuickAdd": "悬停以在时间轴上添加", + "language": "语言", + "downloading": "下载中...", + "deleteModel": "删除模型", + "clearModel": "清除模型", + "downloadModel": "下载模型", + "generating": "生成中...", + "generateFull": "生成字幕", + "regenerateFull": "重新生成字幕", + "clearFull": "清除字幕", + "fontSettings": "字体设置", + "defaultFont": "默认", + "fontFamily": "字体", + "fontSize": "字号", + "rowCount": "行数", + "animation": "动画", + "animationOff": "关闭", + "animationFade": "淡入淡出", + "animationRise": "上移", + "animationPop": "弹出", + "bottomOffset": "底部偏移", + "maxWidth": "最大宽度", + "boxRadius": "字幕框圆角", + "backgroundOpacity": "背景透明度", + "textColor": "文字颜色", + "editor": { + "text": "Text", + "start": "Start", + "end": "End", + "split": "Split", + "merge": "Merge", + "delete": "Delete" + } + }, + "crop": { + "title": "裁剪视频", + "instruction": "拖动各边以调整裁剪区域", + "top": "上", + "bottom": "下", + "left": "左", + "right": "右", + "openEditor": "打开裁剪编辑器" + }, + "background": { + "title": "背景", + "image": "图片", + "color": "颜色", + "gradient": "渐变", + "wallpaperPreview": "壁纸预览", + "uploadCustom": "上传自定义", + "uploadSuccess": "自定义图片上传成功!", + "uploadError": "请上传 JPG 或 JPEG 图片文件。", + "uploadErrorDescription": "仅支持 JPG 和 JPEG 图片。" + }, + "export": { + "title": "导出", + "mp4": "MP4", + "gif": "GIF", + "quality": { + "low": "低", + "medium": "中", + "high": "高", + "original": "原始" + }, + "fpsTitle": "帧率", + "loop": "循环", + "outputDimensions": "输出:{{dimensions}}px", + "sizePresetOriginalShort": "原", + "sizePresetMediumShort": "中", + "sizePresetLargeShort": "大", + "loadProject": "加载项目", + "saveProject": "保存项目", + "exportVideo": "导出{{format}}", + "reportBug": "报告问题", + "starOnGithub": "在 GitHub 上点赞" + }, + "audio": { + "title": "音频", + "volumeTitle": "音频", + "volume": "音量", + "normalize": "标准化", + "sourceTracksTitle": "剪辑源音频", + "systemLabel": "系统声音", + "micLabel": "麦克风", + "mixedLabel": "来源", + "deleteRegion": "删除音频" + }, + "updates": { + "experimentalDescription": "你已选择接收实验性更新,因此可以在 Recordly 最新更新广泛发布之前选择先行测试。", + "title": "更新", + "experimental": "实验性更新", + "saveFailed": "无法更改更新频道。" + } +} diff --git a/src/i18n/locales/zh-CN/shortcuts.json b/src/i18n/locales/zh-CN/shortcuts.json new file mode 100644 index 000000000..9e3f599bf --- /dev/null +++ b/src/i18n/locales/zh-CN/shortcuts.json @@ -0,0 +1,16 @@ +{ + "actions": { + "addZoom": "添加缩放", + "addTrim": "添加修剪", + "addSpeed": "添加变速", + "addAnnotation": "添加注释", + "addKeyframe": "添加关键帧", + "deleteSelected": "删除选中", + "playPause": "播放 / 暂停", + "cycleForward": "向前循环切换注释", + "cycleBackward": "向后循环切换注释", + "deleteSelectedAlt": "删除选中(替代)", + "panTimeline": "平移时间线", + "zoomTimeline": "缩放时间线" + } +} diff --git a/src/i18n/locales/zh-CN/timeline.json b/src/i18n/locales/zh-CN/timeline.json new file mode 100644 index 000000000..85113ae33 --- /dev/null +++ b/src/i18n/locales/zh-CN/timeline.json @@ -0,0 +1,41 @@ +{ + "zoom": { + "cannotPlace": "无法在此处放置缩放", + "existsOrNoSpace": "此位置已存在缩放或空间不足。", + "suggestHandlerUnavailable": "缩放建议处理不可用", + "noTelemetry": "无光标遥测数据", + "recordFirst": "请先录制屏幕以生成基于光标的建议。", + "noUsableTelemetry": "无可用的光标遥测数据", + "notEnoughMovement": "录制中未包含足够的光标移动数据。", + "noInteractionMoments": "未找到明显的交互时刻", + "tryRecording": "尝试在关键操作附近有停顿或点击的录制。", + "noAutoZoomSlots": "没有可用的自动缩放位置", + "dwellPointsOverlap": "检测到的停留点与现有缩放区域重叠。", + "addedSuggestions": "已添加 {{count}} 个基于交互的缩放建议", + "label": "缩放 {{index}}", + "addZoom": "添加缩放 (Z)", + "suggestZooms": "从光标建议缩放" + }, + "trim": { + "cannotPlace": "无法在此处放置修剪", + "existsOrNoSpace": "此位置已存在修剪或空间不足。", + "label": "修剪 {{index}}", + "addTrim": "添加修剪 (T)" + }, + "speed": { + "cannotPlace": "无法在此处放置变速", + "existsOrNoSpace": "此位置已存在变速区域或空间不足。", + "label": "变速" + }, + "annotation": { + "label": "注释", + "image": "图片", + "addAnnotation": "添加注释 (A)" + }, + "audio": { + "label": "音频" + }, + "addSpeed": "添加变速 (S)", + "resizeLeft": "向左调整", + "resizeRight": "向右调整" +} diff --git a/src/i18n/locales/zh-TW/common.json b/src/i18n/locales/zh-TW/common.json new file mode 100644 index 000000000..f65398523 --- /dev/null +++ b/src/i18n/locales/zh-TW/common.json @@ -0,0 +1,26 @@ +{ + "app": { + "name": "Recordly", + "editorTitle": "Recordly 編輯器", + "subtitle": "螢幕錄製與編輯", + "language": "語言", + "manageRecordings": "打開錄製影像文件夾" + }, + "actions": { + "cancel": "取消", + "close": "關閉", + "export": "導出", + "load": "讀取", + "redo": "重做", + "reset": "重置", + "save": "保存", + "undo": "撤銷", + "delete": "刪除", + "done": "完成" + }, + "errors": { + "invalidFileType": "無效的檔案類型", + "failedToUploadImage": "上傳圖片失敗", + "fileReadError": "讀取檔案時出錯。" + } +} diff --git a/src/i18n/locales/zh-TW/dialogs.json b/src/i18n/locales/zh-TW/dialogs.json new file mode 100644 index 000000000..921299dff --- /dev/null +++ b/src/i18n/locales/zh-TW/dialogs.json @@ -0,0 +1,62 @@ +{ + "export": { + "pleaseTryAgain": "請再試一次", + "compilingGifProgress": "正在編譯 GIF... {{progress}}%", + "compilingGifWait": "正在編譯 GIF... 可能需要一些時間", + "takeMoment": "可能需要一些時間...", + "exportFailed": "匯出失敗", + "compilingGifTitle": "正在編譯 GIF", + "exportingFormat": "正在匯出 {{format}}", + "exportComplete": "匯出完成", + "formatReady": "{{format}} 已準備完成", + "showInFolder": "在資料夾中顯示", + "compiling": "編譯中", + "renderingFrames": "正在渲染畫格", + "processing": "處理中...", + "status": "狀態", + "format": "格式", + "compilingStatus": "編譯中...", + "frames": "畫格", + "cancelExport": "取消匯出", + "reopenSaveDialog": "再次開啟儲存對話框", + "savedSuccess": "{{format}} 已成功儲存!" + }, + "addFont": { + "title": "新增 Google 字型", + "heading": "新增 Google 字型", + "description": "從 Google Fonts 新增自訂字型,用於註解文字。", + "urlLabel": "Google Fonts 匯入 URL", + "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap", + "urlHelp": "可在 Google Fonts 取得:選擇字型 → 點選「Get font」→ 複製 @import URL", + "nameLabel": "顯示名稱", + "namePlaceholder": "我的自訂字型", + "nameHelp": "此名稱會顯示在字型選擇器中", + "adding": "新增中...", + "addFont": "新增字型", + "enterUrl": "請輸入 Google Fonts 的匯入 URL", + "invalidUrl": "請輸入有效的 Google Fonts URL", + "enterName": "請輸入字型名稱", + "extractFailed": "無法從 URL 擷取字型系列名稱", + "addSuccess": "已成功新增字型「{{name}}」", + "addFailed": "新增字型失敗", + "loadTimeout": "字型載入時間過久。請檢查 URL 後再試一次。", + "loadFailed": "無法載入字型。請確認 Google Fonts URL 是否正確。" + }, + "shortcutsConfig": { + "title": "鍵盤快捷鍵", + "configurable": "可自訂", + "fixed": "固定", + "pressEscToCancel": "按下 Esc 以取消", + "clickToChange": "點一下以更改", + "pressAKey": "按下任意按鍵…", + "alreadyUsedBy": "已被 <strong>{{action}}</strong> 使用", + "swap": "交換", + "reserved": "此快捷鍵保留給「{{label}}」,無法重新指派。", + "saved": "已儲存鍵盤快捷鍵", + "resetNotice": "已重設為預設快捷鍵,點選「儲存」以套用", + "instructions": "點選一組快捷鍵後按下新的組合鍵。按下 Esc 可取消。", + "resetToDefaults": "重設為預設值", + "cancel": "取消", + "save": "儲存" + } +} diff --git a/src/i18n/locales/zh-TW/editor.json b/src/i18n/locales/zh-TW/editor.json new file mode 100644 index 000000000..0332aed16 --- /dev/null +++ b/src/i18n/locales/zh-TW/editor.json @@ -0,0 +1,153 @@ +{ + "playback": { + "play": "播放", + "pause": "暫停", + "skipBack": "後退", + "skipForward": "前進", + "muteUnmute": "靜音/取消靜音", + "volume": "預覽音量" + }, + "annotations": { + "settings": "註釋設定", + "active": "活動", + "text": "文字", + "image": "圖片", + "arrow": "箭頭", + "blur": "模糊", + "textContent": "文字內容", + "textPlaceholder": "輸入文字...", + "fontStyle": "字型樣式", + "selectStyle": "選擇樣式", + "size": "大小", + "toggleBold": "切換粗體", + "toggleItalic": "切換斜體", + "toggleUnderline": "切換下劃線", + "alignLeft": "左對齊", + "alignCenter": "居中對齊", + "alignRight": "右對齊", + "textColor": "文字顏色", + "background": "背景", + "none": "無", + "clearBackground": "清除背景", + "uploadImage": "上傳圖片", + "supportedFormats": "支援格式:JPG、PNG、GIF、WebP", + "arrowDirection": "箭頭方向", + "strokeWidth": "描邊寬度:{{width}}px", + "arrowColor": "箭頭顏色", + "deleteAnnotation": "刪除註釋", + "shortcutsAndTips": "快速鍵與提示", + "tipSelectAnnotation": "將播放頭移動到註釋重疊區域並選擇項目。", + "tipCycleForward": "使用 Tab 循環切換重疊項目。", + "tipCycleBackward": "使用 Shift+Tab 反向循環切換。", + "imageUploadSuccess": "圖片上傳成功!", + "imageUploadError": "請上傳 JPG、PNG、GIF 或 WebP 圖片檔案。", + "blurStrength": "模糊強度: {{strength}}", + "solidColor": "純色 (審查)", + "borderRadius": "邊框半徑" + }, + + "fontStyles": { + "classic": "經典", + "editor": "編輯器", + "strong": "加粗", + "typewriter": "打字機", + "deco": "裝飾", + "simple": "簡約", + "modern": "現代", + "clean": "清爽" + }, + "format": { + "mp4Video": "MP4 視訊", + "mp4Description": "高品質視訊檔案", + "gifAnimation": "GIF 動畫", + "gifDescription": "可分享的動態圖片" + }, + "gifOptions": { + "frameRate": "影格率", + "outputSize": "輸出尺寸", + "outputDimensions": "輸出:{{width}} × {{height}}px", + "loopAnimation": "循環動畫", + "loopDescription": "GIF 將持續播放" + }, + "tutorial": { + "howTrimmingWorks": "修剪的工作方式", + "title": "修剪的工作方式", + "understanding": "瞭解如何裁剪視訊中不需要的部分。", + "descriptionP1": "修剪工具的運作方式是:先定義你想要的區段,然後將其", + "descriptionRemove": "移除", + "descriptionP2": "的片段來工作。", + "descriptionP3": "只要時間軸上有被紅色修剪區段蓋到的地方,匯出時就不會包含那段內容。", + "visualExample": "視覺範例", + "removed": "已移除", + "kept": "保留", + "finalVideo": "最終視訊", + "part": "第 {{number}} 部分", + "addTrimStep": "1. 新增修剪", + "addTrimDesc": "按 T 或點選剪刀圖示標記要移除的部分。", + "adjustStep": "2. 調整", + "adjustDesc": "拖動紅色區域的邊緣,精確覆蓋要剪掉的部分。" + }, + "feedback": { + "trigger": "反饋", + "title": "反饋與聯絡", + "description": "如果有問題或缺失功能,可以直接聯絡或提交 issue。", + "emailLabel": "信箱", + "xLabel": "X", + "reportIssue": "報告問題 / 提交反饋", + "openFailed": "打開連結失敗。" + }, + "keyboardShortcuts": { + "trigger": "快速鍵", + "title": "鍵盤快速鍵", + "description": "時間線和編輯器控制的快速參考。", + "customizeTooltip": "自訂快速鍵", + "customize": "自訂", + "panTimeline": "平移時間線", + "zoomTimeline": "縮放時間線", + "cycleAnnotations": "循環切換註釋", + "tab": "Tab" + }, + "actions": { + "saveAgain": "再次保存", + "showInFolder": "在資料夾中顯示" + }, + "project": { + "untitled": "未命名", + "unsavedChangesTitle": "未儲存的變更", + "unsavedChangesDescription": "要在{{action}}之前儲存目前的專案嗎?", + "discardChanges": "捨棄變更", + "saveProject": "儲存專案" + }, + "exportTips": { + "autoZooms": "提示:可在設定中關閉自動套用的縮放", + "experimentalBuilds": "提示:可在設定中開啟實驗版本存取權", + "cursorAppearance": "提示:你可以自訂游標外觀" + }, + "account": { "title": "帳號", "comingSoon": "帳號功能即將推出" }, + "nativeCaptureUnavailable": { + "title": "沒有出錯,但我們無法轉譯動畫游標覆蓋層。", + "description": "你的裝置不支援原生擷取。這可能是由我們尚未釐清的多種原因造成的。Recordly 仍可繼續運作,但無法進行游標平滑處理。", + "confirm": "好的" + }, + "exportStatus": { + "exporting": "正在匯出", + "renderingFile": "正在渲染你的檔案。", + "preparing": "正在準備匯出...", + "completePercent": "已完成 {{percent}}%", + "issue": "匯出問題", + "complete": "匯出完成", + "savedSuccessfully": "檔案已成功保存。" + }, + "export": { + "processingAudioEdits": "正在處理帶有速度/疊加編輯的音訊" + }, + "toolbar": { + "addLayer": "新增圖層", + "splitClip": "拆分片段 (C)" + }, + "timeline": { + "expand": "展開時間軸", + "collapse": "摺疊時間軸" + }, + "openRecordingsFolder": "打開錄製資料夾" +} diff --git a/src/i18n/locales/zh-TW/launch.json b/src/i18n/locales/zh-TW/launch.json new file mode 100644 index 000000000..9d44d3218 --- /dev/null +++ b/src/i18n/locales/zh-TW/launch.json @@ -0,0 +1,95 @@ +{ + "recording": { + "disableSystemAudio": "停用系統音訊", + "enableSystemAudio": "啟用系統音訊", + "disableMicrophone": "停用麥克風", + "enableMicrophone": "啟用麥克風", + "micToggleDisabledTip": "錄製過程中無法切換麥克風", + "disableWebcam": "停用網路攝影機疊加", + "enableWebcam": "啟用網路攝影機疊加", + "countdownDelay": "倒數延遲", + "noDelay": "無延遲", + "record": "錄製", + "recordingFolder": "錄影儲存路徑:{{path}}", + "chooseRecordingsFolder": "選擇錄影儲存路徑", + "folderPath": "路徑:/{{name}}/", + "openVideoFile": "開啟影片檔案", + "openProject": "開啟專案", + "hideHudFromVideo": "錄製時隱藏 HUD", + "showHudInVideo": "錄製時顯示 HUD", + "hideHud": "隱藏 HUD", + "closeApp": "關閉應用程式", + "screens": "螢幕", + "windows": "視窗", + "screen": "螢幕", + "window": "視窗", + "noSourcesFound": "找不到可錄製的來源", + "microphone": "麥克風", + "turnOffMicrophone": "關閉麥克風", + "selectMicToEnable": "選擇要啟用的麥克風", + "noMicrophonesFound": "找不到麥克風", + "webcam": "網路攝影機", + "turnOffWebcam": "關閉網路攝影機", + "hideFloatingWebcamPreview": "隱藏浮動預覽", + "showFloatingWebcamPreview": "顯示浮動預覽", + "selectWebcamToEnable": "選擇要啟用的網路攝影機", + "noWebcamsFound": "找不到網路攝影機", + "recordingsFolder": "錄影儲存路徑", + "language": "語言", + "paused": "已暫停", + "rec": "錄製中", + "resume": "繼續", + "pause": "暫停", + "stop": "停止", + "cancel": "取消", + "more": "更多", + "update": { + "update": "更新", + "updated": "已更新", + "idleTitle": "檢查更新。", + "checkingTitle": "正在檢查更新...", + "downloadingTitle": "正在下載更新...", + "errorTitle": "檢查更新失敗,點一下重試。", + "upToDateTitle": "Recordly {{version}} 已是最新版本。", + "availableTitle": "Recordly {{version}} 已推出新版本。", + "availableGenericTitle": "有可用更新。" + } + }, + "sourceSelector": { + "loadingSources": "正在載入來源...", + "screens": "螢幕", + "windows": "視窗", + "noScreensAvailable": "沒有可用的螢幕", + "noWindowsAvailable": "沒有可用的視窗", + "windowsNote": "僅能錄製可見的視窗(非最小化)。", + "windowPlaceholder": "視窗", + "cancel": "取消", + "share": "分享" + }, + "permissions": { + "screenRecordingNeeded": "Recordly 需要「螢幕錄製」權限才能開始。已開啟系統設定。啟用後,請結束並重新開啟 Recordly。", + "screenRecordingMissing": "仍未取得「螢幕錄製」權限。已再次開啟系統設定。請先啟用,再結束並重新開啟 Recordly 後再錄製。", + "accessibilityNeeded": "Recordly 也需要「輔助使用」權限以追蹤游標。已開啟系統設定。啟用後,請結束並重新開啟 Recordly。", + "accessibilityMissing": "仍未取得「輔助使用」權限。已再次開啟系統設定。請先啟用,再結束並重新開啟 Recordly 後再錄製。", + "selectSource": "請選擇要錄製的來源", + "systemAudioUnavailable": "此來源不支援系統音訊,將在沒有系統音訊的情況下繼續錄製。", + "microphoneDenied": "麥克風存取遭拒,將在沒有麥克風音訊的情況下繼續錄製。", + "failedToStart": "無法開始錄製:{{error}}", + "failedToStartGeneric": "無法開始錄製" + }, + "updateToast": { + "availableTitle": "有可用更新", + "experimentalAvailableTitle": "有可用的實驗性更新", + "experimentalDescription": "你已選擇接收實驗性更新,因此可以在 Recordly 最新更新廣泛推出之前選擇先行測試。", + "downloadingTitle": "正在下載更新", + "readyTitle": "已準備好重新啟動", + "checkErrorTitle": "無法檢查更新", + "downloadErrorTitle": "無法下載更新", + "experimentalBadge": "實驗性", + "previewBadge": "預覽", + "notNow": "暫不", + "updateNow": "立即更新", + "restartToUpdate": "重新啟動以更新", + "tryAgain": "重試" + } +} diff --git a/src/i18n/locales/zh-TW/settings.json b/src/i18n/locales/zh-TW/settings.json new file mode 100644 index 000000000..1e4d6e6de --- /dev/null +++ b/src/i18n/locales/zh-TW/settings.json @@ -0,0 +1,265 @@ +{ + "zoom": { + "level": "縮放倍率", + "selectRegion": "選取要調整的縮放區域", + "deleteZoom": "刪除縮放", + "modeAuto": "自動", + "modeManual": "手動", + "modeManualDescription": "為此縮放設定固定的對焦點", + "modeAutoDescription": "相機會自動跟隨游標" + }, + "trim": { + "deleteRegion": "刪除修剪區域" + }, + "speed": { + "playbackSpeed": "播放速度", + "selectRegion": "選取要調整的速度區域", + "deleteRegion": "刪除速度區域", + "label": "速度" + }, + "clip": { + "title": "片段", + "mute": "靜音", + "mutedState": "音訊已靜音", + "unmutedState": "音訊正在播放", + "separateClipFromAudio": "將片段與音訊分離", + "delete": "刪除片段" + }, + "effects": { + "title": "影片效果", + "show": "顯示", + "showCursor": "顯示游標", + "loopCursor": "游標循環", + "cursorStyle": "游標樣式", + "cursorStyleOptions": { + "macos": "macOS", + "tahoe": "Tahoe", + "tahoe-inverted": "Tahoe 反相", + "windows11": "Windows 11", + "dot": "圓點", + "figma": "極簡", + "lavender": "Lavender", + "parched": "Parched", + "chooper": "Chooper", + "amongus": "Among Us", + "turtle": "Turtle" + }, + "backgroundBlur": "模糊", + "zoomMotionBlur": "縮放動態模糊", + "temporalZoomMotionBlur": "Temporal Zoom Blur", + "temporalZoomMotionBlurDescription": "Control the shutter window and frame samples used by the newer zoom blur pass.", + "zoomMotionBlurSamples": "Blur Samples", + "zoomMotionBlurShutter": "Shutter", + "auto": "Auto", + "connectZooms": "連接縮放", + "connectZoomsDescription": "將連續的縮放區域平滑串接成一段連續的鏡頭移動。", + "autoApplyFreshRecordingZooms": "自動套用新錄影的縮放", + "autoApplyFreshRecordingZoomsDescription": "開啟新錄影時,自動建議游標跟隨縮放。", + "zoomGeneralTitle": "一般", + "zoomGeneralDescription": "套用於每次縮放轉場的全域動作設定。", + "zoomInTitle": "拉近", + "zoomInDescription": "控制相機如何進入縮放區域。", + "zoomOutTitle": "拉遠", + "zoomOutDescription": "控制相機如何離開縮放區域。", + "connectedZoomTitle": "縮放之間", + "connectedZoomDescription": "啟用連接後,調整連續縮放區域之間的滑行效果。", + "motionPresetsTitle": "Motion Presets", + "motionPresetsZoomHint": "Zoom motion presets are available in Settings.", + "animationPresets": "Animation Presets", + "cursorMotionPresets": "Cursor Motion Presets", + "motionPresets": { + "focused": { + "label": "Focused", + "description": "Snappier motion for demos, walkthroughs, and everyday recordings." + }, + "smooth": { + "label": "Smooth", + "description": "Gentler motion for presentations, keynote-style videos, and polished reveals." + } + }, + "zoomInDuration": "拉近時間", + "zoomInOverlap": "拉近重疊", + "zoomOutDuration": "拉遠時間", + "zoomInEasing": "拉近曲線", + "zoomOutEasing": "拉遠曲線", + "connectedZoomGap": "連接縮放間距", + "connectedZoomDuration": "連接縮放時間", + "connectedZoomEasing": "連接平移曲線", + "zoomEasingOptions": { + "recordly": "Recordly", + "glide": "滑行", + "smooth": "平滑", + "snappy": "俐落", + "linear": "線性" + }, + "cursorSize": "游標大小", + "cursorSmoothing": "游標平滑", + "cursorSpringStiffness": "Cursor Spring Stiffness", + "cursorSpringDamping": "Cursor Spring Damping", + "cursorSpringMass": "Cursor Spring Mass", + "off": "關閉", + "cursorClickEffects": { + "title": "Click Effects", + "advanced": "Advanced", + "advancedShow": "Show advanced click effect controls", + "advancedHide": "Hide advanced click effect controls", + "color": "Effect Color", + "size": "Effect Size", + "opacity": "Effect Opacity", + "duration": "Effect Duration", + "none": { + "label": "Off", + "description": "No click graphic. Only the cursor motion changes when you click." + }, + "ripple": { + "label": "Ripple", + "description": "Expanding rings radiate from each click so taps read clearly in motion." + }, + "spotlight": { + "label": "Spotlight", + "description": "A soft halo flashes around the pointer to emphasize the clicked area." + }, + "echo": { + "label": "Echo", + "description": "A pair of soft rings that spread outward with a cleaner pulse." + } + }, + "cursorMotionBlur": "游標動態模糊", + "cursorClickBounce": "點擊彈跳", + "cursorClickBounceDuration": "彈跳速度", + "cursorSway": "游標擺動", + "webcam": "網路攝影機疊加", + "webcamFootage": "網路攝影機素材", + "webcamFootageDescription": "此影片尚未連結網路攝影機素材", + "uploadWebcamFootage": "上傳素材", + "replaceWebcamFootage": "替換素材", + "removeWebcamFootage": "移除素材", + "webcamFootageAdded": "已連結網路攝影機素材", + "webcamFootageRemoved": "已移除網路攝影機素材", + "webcamSize": "網路攝影機大小", + "webcamWidth": "網路攝影機寬度", + "webcamHeight": "網路攝影機高度", + "webcamCrop": "網路攝影機裁剪", + "webcamReactToZoom": "網路攝影機跟隨縮放", + "webcamMirror": "鏡像網路攝影機", + "webcamRoundness": "網路攝影機圓角", + "webcamShadow": "網路攝影機陰影", + "shadow": "陰影", + "radius": "半徑", + "roundness": "圓角", + "padding": "內距", + "paddingLinked": "連動(等距)", + "paddingUnlinked": "不連動(不對稱)", + "paddingTop": "上", + "paddingBottom": "下", + "paddingLeft": "左", + "paddingRight": "右", + "removeBackground": "移除背景" + }, + "sections": { + "scene": "場景", + "captions": "字幕", + "caption": "字幕編輯", + "zoom": "縮放", + "cursor": "游標", + "webcam": "網路攝影機", + "frame": "外框", + "crop": "裁切" + }, + "captions": { + "selectOnTimeline": "在時間軸上選擇字幕進行編輯。", + "enabled": "顯示", + "timelineQuickAdd": "懸停以在時間軸上新增", + "language": "語言", + "downloading": "下載中...", + "deleteModel": "刪除模型", + "clearModel": "清除模型", + "downloadModel": "下載模型", + "generating": "產生中...", + "generateFull": "產生字幕", + "regenerateFull": "重新產生字幕", + "clearFull": "清除字幕", + "fontSettings": "字型設定", + "defaultFont": "預設", + "fontFamily": "字型", + "fontSize": "字體大小", + "rowCount": "行數", + "animation": "動畫", + "animationOff": "關閉", + "animationFade": "淡入淡出", + "animationRise": "上浮", + "animationPop": "彈出", + "bottomOffset": "底部偏移", + "maxWidth": "最大寬度", + "boxRadius": "方框圓角", + "backgroundOpacity": "背景不透明度", + "textColor": "文字顏色", + "editor": { + "text": "Text", + "start": "Start", + "end": "End", + "split": "Split", + "merge": "Merge", + "delete": "Delete" + } + }, + "crop": { + "title": "裁切影片", + "instruction": "拖曳四邊以調整裁切範圍", + "top": "上", + "bottom": "下", + "left": "左", + "right": "右", + "openEditor": "開啟裁切編輯器" + }, + "background": { + "title": "背景", + "image": "圖片", + "color": "顏色", + "gradient": "漸層", + "wallpaperPreview": "桌布預覽", + "uploadCustom": "上傳自訂圖片", + "uploadSuccess": "自訂圖片已成功上傳!", + "uploadError": "請上傳 JPG 或 JPEG 圖片檔。", + "uploadErrorDescription": "僅支援 JPG 與 JPEG 圖片。" + }, + "export": { + "title": "匯出", + "mp4": "MP4", + "gif": "GIF", + "quality": { + "low": "低", + "medium": "中", + "high": "高", + "original": "原始" + }, + "fpsTitle": "FPS", + "loop": "循環", + "outputDimensions": "輸出:{{dimensions}}px", + "sizePresetOriginalShort": "原", + "sizePresetMediumShort": "中", + "sizePresetLargeShort": "大", + "loadProject": "載入專案", + "saveProject": "儲存專案", + "exportVideo": "匯出 {{format}}", + "reportBug": "回報錯誤", + "starOnGithub": "在 GitHub 按讚" + }, + "audio": { + "title": "音訊", + "volumeTitle": "音訊", + "volume": "音量", + "normalize": "正規化", + "sourceTracksTitle": "剪輯源音訊", + "systemLabel": "系統源", + "micLabel": "麥克風源", + "mixedLabel": "源", + "deleteRegion": "刪除音訊" + }, + "updates": { + "experimentalDescription": "你已選擇接收實驗性更新,因此可以在 Recordly 最新更新廣泛推出之前選擇先行測試。", + "title": "更新", + "experimental": "實驗性更新", + "saveFailed": "無法變更更新頻道。" + } +} diff --git a/src/i18n/locales/zh-TW/shortcuts.json b/src/i18n/locales/zh-TW/shortcuts.json new file mode 100644 index 000000000..ea0b8a388 --- /dev/null +++ b/src/i18n/locales/zh-TW/shortcuts.json @@ -0,0 +1,16 @@ +{ + "actions": { + "addZoom": "新增縮放", + "addTrim": "新增修剪", + "addSpeed": "新增速度", + "addAnnotation": "新增註解", + "addKeyframe": "新增關鍵影格", + "deleteSelected": "刪除所選項目", + "playPause": "播放或暫停", + "cycleForward": "往前切換註解", + "cycleBackward": "往後切換註解", + "deleteSelectedAlt": "刪除所選項目(Alt)", + "panTimeline": "平移時間軸", + "zoomTimeline": "縮放時間軸" + } +} diff --git a/src/i18n/locales/zh-TW/timeline.json b/src/i18n/locales/zh-TW/timeline.json new file mode 100644 index 000000000..0ddf65de6 --- /dev/null +++ b/src/i18n/locales/zh-TW/timeline.json @@ -0,0 +1,41 @@ +{ + "zoom": { + "cannotPlace": "無法在此處新增縮放", + "existsOrNoSpace": "此位置已存在縮放,或可用空間不足。", + "suggestHandlerUnavailable": "無法使用縮放建議功能", + "noTelemetry": "沒有可用的游標追蹤資料", + "recordFirst": "請先錄製螢幕影片,以產生基於游標的建議。", + "noUsableTelemetry": "沒有可用的游標追蹤資料", + "notEnoughMovement": "此錄影缺少足夠的游標移動資料。", + "noInteractionMoments": "找不到明確的互動時刻", + "tryRecording": "請嘗試在重要操作前後加入停頓或點擊再錄製。", + "noAutoZoomSlots": "沒有可用的自動縮放插槽", + "dwellPointsOverlap": "偵測到的停留點與既有縮放區域重疊。", + "addedSuggestions": "已新增 {{count}} 個依互動產生的縮放建議", + "label": "縮放 {{index}}", + "addZoom": "新增縮放(Z)", + "suggestZooms": "依游標建議縮放" + }, + "trim": { + "cannotPlace": "無法在此處新增修剪", + "existsOrNoSpace": "此位置已存在修剪,或可用空間不足。", + "label": "修剪 {{index}}", + "addTrim": "新增修剪(T)" + }, + "speed": { + "cannotPlace": "無法在此處新增速度區域", + "existsOrNoSpace": "此位置已存在速度區域,或可用空間不足。", + "label": "速度" + }, + "annotation": { + "label": "註解", + "image": "圖片", + "addAnnotation": "新增註解(A)" + }, + "audio": { + "label": "音訊" + }, + "addSpeed": "新增速度(S)", + "resizeLeft": "向左調整大小", + "resizeRight": "向右調整大小" +} diff --git a/src/index.css b/src/index.css index a452ccb26..afbb98aa0 100644 --- a/src/index.css +++ b/src/index.css @@ -1,129 +1,270 @@ -@tailwind base; -@tailwind components; -@tailwind utilities; - -@layer base { - :root { - --background: 0 0% 100%; - --foreground: 20 14.3% 4.1%; - --card: 0 0% 100%; - --card-foreground: 20 14.3% 4.1%; - --popover: 0 0% 100%; - --popover-foreground: 20 14.3% 4.1%; - --primary: 24 9.8% 10%; - --primary-foreground: 60 9.1% 97.8%; - --secondary: 60 4.8% 95.9%; - --secondary-foreground: 24 9.8% 10%; - --muted: 60 4.8% 95.9%; - --muted-foreground: 25 5.3% 44.7%; - --accent: 60 4.8% 95.9%; - --accent-foreground: 24 9.8% 10%; - --destructive: 0 84.2% 60.2%; - --destructive-foreground: 60 9.1% 97.8%; - --border: 20 5.9% 90%; - --input: 20 5.9% 90%; - --ring: 20 14.3% 4.1%; - --chart-1: 12 76% 61%; - --chart-2: 173 58% 39%; - --chart-3: 197 37% 24%; - --chart-4: 43 74% 66%; - --chart-5: 27 87% 67%; - --radius: 0.5rem; - } - .dark { - --background: 20 14.3% 4.1%; - --foreground: 60 9.1% 97.8%; - --card: 20 14.3% 4.1%; - --card-foreground: 60 9.1% 97.8%; - --popover: 20 14.3% 4.1%; - --popover-foreground: 60 9.1% 97.8%; - --primary: 60 9.1% 97.8%; - --primary-foreground: 24 9.8% 10%; - --secondary: 12 6.5% 15.1%; - --secondary-foreground: 60 9.1% 97.8%; - --muted: 12 6.5% 15.1%; - --muted-foreground: 24 5.4% 63.9%; - --accent: 12 6.5% 15.1%; - --accent-foreground: 60 9.1% 97.8%; - --destructive: 0 62.8% 30.6%; - --destructive-foreground: 60 9.1% 97.8%; - --border: 12 6.5% 15.1%; - --input: 12 6.5% 15.1%; - --ring: 24 5.7% 82.9%; - --chart-1: 220 70% 50%; - --chart-2: 160 60% 45%; - --chart-3: 30 80% 55%; - --chart-4: 280 65% 60%; - --chart-5: 340 75% 55%; - } -} - - -@layer base { - * { - @apply border-border; - } - body { - @apply bg-background text-foreground; - } -} - -/* Smooth timeline cursor animations */ -@layer utilities { - .timeline-cursor-smooth { - will-change: transform; - transition: left 33ms linear, right 33ms linear; - } - - /* Smooth playback scrubber */ - input[type="range"] { - -webkit-appearance: none; - appearance: none; - } - - input[type="range"]::-webkit-slider-thumb { - -webkit-appearance: none; - appearance: none; - width: 16px; - height: 16px; - border-radius: 50%; - background: #fff; - cursor: pointer; - box-shadow: 0 2px 8px rgba(255,255,255,0.32); - border: 2px solid #fff; - transition: all 0.08s ease-out; - } - - input[type="range"]::-webkit-slider-thumb:hover { - background: #fff; - transform: scale(1.15); - box-shadow: 0 3px 10px rgba(255,255,255,0.5); - border-color: #34B27B; - } - - input[type="range"]::-webkit-slider-thumb:active { - transform: scale(1.25); - } - - input[type="range"]::-moz-range-thumb { - width: 16px; - height: 16px; - border-radius: 50%; - background: #fff; - cursor: pointer; - border: 2px solid #fff; - box-shadow: 0 2px 8px rgba(255,255,255,0.32); - transition: all 0.08s ease-out; - } - - input[type="range"]::-moz-range-thumb:hover { - background: #fff; - transform: scale(1.15); - box-shadow: 0 3px 10px rgba(255,255,255,0.5); - border-color: #34B27B; - } - - input[type="range"]::-moz-range-thumb:active { - transform: scale(1.25); - } -} +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + /* Disable Electron/Chromium focus highlight (yellow/orange ring) */ + *:focus:not(:focus-visible) { + outline: none !important; + } + + *:focus-visible { + outline: 2px solid rgb(var(--brand-accent-rgb)); + outline-offset: 2px; + -webkit-tap-highlight-color: transparent; + } + + html, + body, + #root { + width: 100%; + height: 100%; + margin: 0; + } + + html.hud-overlay-window, + body.hud-overlay-window, + #root.hud-overlay-window { + overflow: hidden; + } + + :root { + --app-font-sans: "SF Pro Display", "SF Pro Text", "Helvetica Neue", sans-serif; + --brand-accent: #2563eb; + --brand-accent-rgb: 37, 99, 235; + --background: 0 0% 100%; + --foreground: 20 14.3% 4.1%; + --card: 0 0% 100%; + --card-foreground: 20 14.3% 4.1%; + --popover: 0 0% 100%; + --popover-foreground: 20 14.3% 4.1%; + --primary: 221 83% 53%; + --primary-foreground: 210 40% 98%; + --secondary: 60 4.8% 95.9%; + --secondary-foreground: 24 9.8% 10%; + --muted: 60 4.8% 95.9%; + --muted-foreground: 25 5.3% 44.7%; + --accent: 214 100% 97%; + --accent-foreground: 221 83% 40%; + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 60 9.1% 97.8%; + --border: 20 5.9% 90%; + --input: 20 5.9% 90%; + --ring: 221 83% 53%; + --chart-1: 12 76% 61%; + --chart-2: 173 58% 39%; + --chart-3: 197 37% 24%; + --chart-4: 43 74% 66%; + --chart-5: 27 87% 67%; + --radius: 0.5rem; + + /* Editor surface colors — light mode */ + --editor-bg: 0 0% 100%; + --editor-header: 0 0% 97%; + --editor-panel: 0 0% 98%; + --editor-surface: 0 0% 97%; + --editor-surface-alt: 0 0% 95%; + --editor-dialog: 0 0% 100%; + --editor-dialog-alt: 0 0% 98%; + --editor-timeline: 220 13% 91%; + --editor-row: 220 13% 88%; + --editor-subrow: 220 13% 85%; + + /* Slider track colors — light mode */ + --slider-track: 0 0% 0% / 0.12; + --slider-thumb: 0 0% 10%; + --slider-thumb-shadow: 0 0% 0% / 0.22; + --slider-glow: 0 0% 0% / 0.35; + } + .dark { + --background: 20 14.3% 4.1%; + --foreground: 60 9.1% 97.8%; + --card: 20 14.3% 4.1%; + --card-foreground: 60 9.1% 97.8%; + --popover: 20 14.3% 4.1%; + --popover-foreground: 60 9.1% 97.8%; + --primary: 221 83% 53%; + --primary-foreground: 210 40% 98%; + --secondary: 12 6.5% 15.1%; + --secondary-foreground: 60 9.1% 97.8%; + --muted: 12 6.5% 15.1%; + --muted-foreground: 24 5.4% 63.9%; + --accent: 217 33% 17%; + --accent-foreground: 210 40% 98%; + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 60 9.1% 97.8%; + --border: 12 6.5% 15.1%; + --input: 12 6.5% 15.1%; + --ring: 221 83% 53%; + --chart-1: 220 70% 50%; + --chart-2: 160 60% 45%; + --chart-3: 30 80% 55%; + --chart-4: 280 65% 60%; + --chart-5: 340 75% 55%; + + /* Editor surface colors — dark mode */ + --editor-bg: 240 6% 7%; + --editor-header: 240 5% 8.5%; + --editor-panel: 240 5% 9%; + --editor-surface: 240 4% 10%; + --editor-surface-alt: 240 5% 11%; + --editor-dialog: 240 10% 3%; + --editor-dialog-alt: 237 9% 5%; + --editor-timeline: 240 6% 7%; + --editor-row: 240 4% 10%; + --editor-subrow: 240 7% 15%; + + /* Slider track colors — dark mode */ + --slider-track: 0 0% 100% / 0.12; + --slider-thumb: 0 0% 100%; + --slider-thumb-shadow: 0 0% 100% / 0.32; + --slider-glow: 0 0% 100% / 0.5; + } +} + +@layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + + -webkit-user-select: none; + user-select: none; + font-family: var(--app-font-sans); + } + + button, + input, + textarea, + select { + font: inherit; + } + + body .font-mono, + body kbd { + font-family: var(--app-font-sans); + } +} + +/* Smooth timeline cursor animations */ +@layer utilities { + .timeline-cursor-smooth { + will-change: transform; + transition: + left 33ms linear, + right 33ms linear; + } + + .indeterminate-progress { + position: relative; + overflow: hidden; + isolation: isolate; + } + + .indeterminate-progress::before { + content: ""; + position: absolute; + inset: 0; + width: 38%; + min-width: 3.5rem; + border-radius: inherit; + background: linear-gradient( + 90deg, + rgba(37, 99, 235, 0) 0%, + rgba(37, 99, 235, 0.35) 18%, + rgba(96, 165, 250, 0.92) 52%, + rgba(37, 99, 235, 0.3) 82%, + rgba(37, 99, 235, 0) 100% + ); + animation: indeterminate-progress 1.15s cubic-bezier(0.4, 0, 0.2, 1) infinite; + } + + /* Hidden scrollbar - still scrollable but invisible */ + .custom-scrollbar { + scrollbar-width: none; /* Firefox */ + -ms-overflow-style: none; /* IE/Edge */ + } + + .custom-scrollbar::-webkit-scrollbar { + display: none; /* Chrome, Safari, Opera */ + } + + /* Smooth playback scrubber */ + input[type="range"] { + -webkit-appearance: none; + appearance: none; + } + + input[type="range"]::-webkit-slider-runnable-track { + border-radius: 100px; + height: 4px; + background: hsl(var(--slider-track)); + } + + input[type="range"]::-moz-range-track { + border-radius: 100px; + height: 4px; + background: hsl(var(--slider-track)); + } + + input[type="range"]::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 16px; + height: 16px; + border-radius: 50%; + background: hsl(var(--slider-thumb)); + cursor: pointer; + box-shadow: 0 2px 8px hsl(var(--slider-thumb-shadow)); + border: 2px solid hsl(var(--slider-thumb)); + transition: all 0.08s ease-out; + } + + input[type="range"]::-webkit-slider-thumb:hover { + background: hsl(var(--slider-thumb)); + transform: scale(1.15); + box-shadow: 0 3px 10px hsl(var(--slider-glow)); + border-color: var(--brand-accent); + } + + input[type="range"]::-webkit-slider-thumb:active { + transform: scale(1.25); + } + + input[type="range"]::-moz-range-thumb { + width: 16px; + height: 16px; + border-radius: 50%; + background: hsl(var(--slider-thumb)); + cursor: pointer; + border: 2px solid hsl(var(--slider-thumb)); + box-shadow: 0 2px 8px hsl(var(--slider-thumb-shadow)); + transition: all 0.08s ease-out; + } + + input[type="range"]::-moz-range-thumb:hover { + background: hsl(var(--slider-thumb)); + transform: scale(1.15); + box-shadow: 0 3px 10px hsl(var(--slider-glow)); + border-color: var(--brand-accent); + } + + input[type="range"]::-moz-range-thumb:active { + transform: scale(1.25); + } +} + +@keyframes indeterminate-progress { + 0% { + transform: translateX(-150%); + } + + 60% { + transform: translateX(270%); + } + + 100% { + transform: translateX(270%); + } +} diff --git a/src/lib/announcementActions.ts b/src/lib/announcementActions.ts new file mode 100644 index 000000000..2e05ab678 --- /dev/null +++ b/src/lib/announcementActions.ts @@ -0,0 +1,22 @@ +import type { AnnouncementAction, AnnouncementEditorSection } from "./announcements"; + +export const OPEN_EDITOR_SECTION_EVENT = "recordly:open-editor-section"; + +export function openEditorSection(section: AnnouncementEditorSection): void { + window.dispatchEvent( + new CustomEvent<AnnouncementEditorSection>(OPEN_EDITOR_SECTION_EVENT, { + detail: section, + }), + ); +} + +export async function runAnnouncementAction( + action: AnnouncementAction, +): Promise<{ success: boolean; error?: string }> { + if (action.section) { + openEditorSection(action.section); + return { success: true }; + } + + return window.electronAPI.openExternalUrl(action.url); +} diff --git a/src/lib/announcementState.ts b/src/lib/announcementState.ts new file mode 100644 index 000000000..13ab2719e --- /dev/null +++ b/src/lib/announcementState.ts @@ -0,0 +1,46 @@ +const DISMISSED_ANNOUNCEMENTS_KEY = "dismissedAnnouncementIds"; +const ANNOUNCEMENT_IMPRESSIONS_KEY = "announcementImpressionCounts"; +const MAX_DISMISSED_ANNOUNCEMENTS = 200; + +export function readDismissedAnnouncementIds(): string[] { + const value = window.electronAPI.getAppSetting(DISMISSED_ANNOUNCEMENTS_KEY); + if (!Array.isArray(value)) { + return []; + } + + return value + .filter((item): item is string => typeof item === "string") + .slice(-MAX_DISMISSED_ANNOUNCEMENTS); +} + +export function dismissAnnouncements(ids: Iterable<string>) { + const dismissedIds = new Set(readDismissedAnnouncementIds()); + for (const id of ids) { + dismissedIds.add(id); + } + window.electronAPI.setAppSetting( + DISMISSED_ANNOUNCEMENTS_KEY, + [...dismissedIds].slice(-MAX_DISMISSED_ANNOUNCEMENTS), + ); +} + +export function readAnnouncementImpressionCounts(): Record<string, number> { + const value = window.electronAPI.getAppSetting(ANNOUNCEMENT_IMPRESSIONS_KEY); + if (!value || typeof value !== "object" || Array.isArray(value)) { + return {}; + } + + const counts: Record<string, number> = {}; + for (const [id, count] of Object.entries(value)) { + if (typeof count === "number" && Number.isInteger(count) && count >= 0) { + counts[id] = count; + } + } + return counts; +} + +export function recordAnnouncementImpression(id: string) { + const counts = readAnnouncementImpressionCounts(); + counts[id] = (counts[id] ?? 0) + 1; + window.electronAPI.setAppSetting(ANNOUNCEMENT_IMPRESSIONS_KEY, counts); +} diff --git a/src/lib/announcements.test.ts b/src/lib/announcements.test.ts new file mode 100644 index 000000000..573158dd4 --- /dev/null +++ b/src/lib/announcements.test.ts @@ -0,0 +1,284 @@ +import { describe, expect, it } from "vitest"; +import { compareVersions, parseAnnouncementFeed, selectAnnouncements } from "./announcements"; + +describe("parseAnnouncementFeed", () => { + it("accepts safe text, media, and actions", () => { + const feed = parseAnnouncementFeed({ + settings: { aspectRatio: "16:9" }, + announcements: [ + { + id: "release-1", + title: "New release", + body: "See what changed.", + audience: "editor", + priority: 200, + mediaMode: "cover", + displayDurationSeconds: 15, + maxImpressions: 3, + controls: { + close: false, + dismiss: false, + action: true, + navigation: false, + indicators: true, + }, + media: { + type: "video", + url: "https://example.com/demo.mp4", + posterUrl: "/announcement-poster.jpg", + }, + action: { label: "Learn more", url: "https://example.com/release" }, + }, + ], + }); + + expect(feed.settings).toEqual({ aspectRatio: "16:9" }); + expect(feed.announcements).toEqual([ + { + id: "release-1", + title: "New release", + body: "See what changed.", + audience: "editor", + priority: 100, + mediaMode: "cover", + displayDurationSeconds: 15, + maxImpressions: 3, + controls: { + close: false, + dismiss: false, + action: true, + navigation: false, + indicators: true, + }, + media: { + type: "video", + url: "https://example.com/demo.mp4", + posterUrl: "/announcement-poster.jpg", + }, + action: { label: "Learn more", url: "https://example.com/release" }, + }, + ]); + }); + + it("keeps notifications as text-only toasts", () => { + const feed = parseAnnouncementFeed({ + announcements: [ + { + id: "notice-1", + title: "Service notice", + body: "A short notification.", + presentation: "notification", + mediaMode: "cover", + media: { type: "image", url: "https://example.com/banner.jpg" }, + action: { label: "Details", url: "https://example.com/details" }, + }, + ], + }); + + expect(feed.announcements).toEqual([ + { + id: "notice-1", + title: "Service notice", + body: "A short notification.", + presentation: "notification", + audience: "all", + priority: 0, + action: { label: "Details", url: "https://example.com/details" }, + }, + ]); + }); + + it("keeps editor banners text-only", () => { + const feed = parseAnnouncementFeed({ + announcements: [ + { + id: "banner-1", + title: "Editor notice", + body: "A short banner.", + presentation: "banner", + mediaMode: "cover", + media: { type: "video", url: "https://example.com/banner.mp4" }, + action: { label: "Details", url: "https://example.com/details" }, + }, + ], + }); + + expect(feed.announcements).toEqual([ + { + id: "banner-1", + title: "Editor notice", + body: "A short banner.", + presentation: "banner", + audience: "all", + priority: 0, + action: { label: "Details", url: "https://example.com/details" }, + }, + ]); + }); + + it("accepts text-only messages for the export status stream", () => { + const feed = parseAnnouncementFeed({ + announcements: [ + { + id: "export-tip-1", + title: "Did you know?", + body: "Cursor styles can be changed in the editor.", + presentation: "export", + media: { type: "image", url: "https://example.com/ignored.jpg" }, + displayDurationSeconds: 8, + }, + ], + }); + + expect(feed.announcements).toEqual([ + { + id: "export-tip-1", + title: "Did you know?", + body: "Cursor styles can be changed in the editor.", + presentation: "export", + audience: "all", + priority: 0, + displayDurationSeconds: 8, + }, + ]); + }); + + it("accepts an action that opens a safe editor section", () => { + const feed = parseAnnouncementFeed({ + announcements: [ + { + id: "settings-link", + title: "Try experimental updates", + body: "Open settings to opt in.", + action: { label: "Open settings", section: "settings" }, + }, + ], + }); + + expect(feed.announcements[0]?.action).toEqual({ + label: "Open settings", + section: "settings", + }); + }); + + it("ignores ambiguous or unknown in-app actions", () => { + const announcements = parseAnnouncementFeed({ + announcements: [ + { + id: "ambiguous", + title: "Ambiguous", + body: "Two destinations are not allowed.", + action: { + label: "Open", + url: "https://example.com", + section: "settings", + }, + }, + { + id: "unknown", + title: "Unknown", + body: "Unknown destinations are ignored.", + action: { label: "Open", section: "billing" }, + }, + ], + }).announcements; + + expect(announcements.map((announcement) => announcement.action)).toEqual([ + undefined, + undefined, + ]); + }); + + it("drops malformed items and unsafe URLs", () => { + const feed = parseAnnouncementFeed({ + announcements: [ + { id: "missing-body", title: "Incomplete" }, + { + id: "safe-text", + title: "Still valid", + body: "Unsafe optional fields are ignored.", + media: { type: "image", url: "javascript:alert(1)" }, + action: { label: "Bad", url: "http://example.com" }, + }, + ], + }); + + expect(feed.announcements).toEqual([ + { + id: "safe-text", + title: "Still valid", + body: "Unsafe optional fields are ignored.", + audience: "all", + priority: 0, + }, + ]); + }); +}); + +describe("selectAnnouncements", () => { + it("filters by audience, date, version, and dismissal", () => { + const parsed = parseAnnouncementFeed({ + announcements: [ + { id: "shown", title: "Shown", body: "Visible", priority: 2, minVersion: "1.2.0" }, + { id: "dismissed", title: "Dismissed", body: "Hidden" }, + { id: "future", title: "Future", body: "Hidden", startsAt: "2027-01-01T00:00:00Z" }, + { id: "expired", title: "Expired", body: "Hidden", endsAt: "2025-01-01T00:00:00Z" }, + { id: "newer", title: "Newer", body: "Hidden", minVersion: "2.0.0" }, + ], + }).announcements; + + const selected = selectAnnouncements({ + bundled: [], + remote: parsed, + dismissedIds: new Set(["dismissed"]), + appVersion: "1.3.5-beta.2", + audience: "editor", + now: new Date("2026-09-01T00:00:00Z"), + }); + + expect(selected.map((announcement) => announcement.id)).toEqual(["shown"]); + }); + + it("lets remote announcements replace bundled items with the same ID", () => { + const common = { audience: "all" as const, priority: 0 }; + const selected = selectAnnouncements({ + bundled: [{ id: "same", title: "Bundled", body: "Old", ...common }], + remote: [{ id: "same", title: "Remote", body: "New", ...common }], + dismissedIds: new Set(), + appVersion: "1.0.0", + audience: "editor", + }); + + expect(selected[0]?.title).toBe("Remote"); + }); + + it("stops selecting an announcement after its impression limit", () => { + const common = { audience: "all" as const, priority: 0 }; + const selected = selectAnnouncements({ + bundled: [ + { + id: "limited", + title: "Limited", + body: "Shown twice", + maxImpressions: 2, + ...common, + }, + ], + remote: [], + dismissedIds: new Set(), + impressionCounts: { limited: 2 }, + appVersion: "1.0.0", + audience: "editor", + }); + + expect(selected).toEqual([]); + }); +}); + +describe("compareVersions", () => { + it("compares numeric release segments", () => { + expect(compareVersions("1.3.5-beta.2", "1.3.4")).toBe(1); + expect(compareVersions("1.3", "1.3.0")).toBe(0); + expect(compareVersions("v1.2.9", "1.3.0")).toBe(-1); + }); +}); diff --git a/src/lib/announcements.ts b/src/lib/announcements.ts new file mode 100644 index 000000000..4509ce148 --- /dev/null +++ b/src/lib/announcements.ts @@ -0,0 +1,358 @@ +export type AnnouncementAudience = "all" | "editor"; + +export interface AnnouncementMedia { + type: "image" | "video"; + url: string; + alt?: string; + posterUrl?: string; +} + +export type AnnouncementEditorSection = + | "scene" + | "cursor" + | "webcam" + | "captions" + | "settings" + | "extensions"; + +export type AnnouncementAction = + | { label: string; url: string; section?: never } + | { label: string; section: AnnouncementEditorSection; url?: never }; + +export interface AnnouncementControls { + close?: boolean; + dismiss?: boolean; + action?: boolean; + navigation?: boolean; + indicators?: boolean; +} + +export interface Announcement { + id: string; + title: string; + body: string; + presentation?: "popup" | "notification" | "banner" | "export"; + audience: AnnouncementAudience; + priority: number; + mediaMode?: "banner" | "cover"; + displayDurationSeconds?: number; + maxImpressions?: number; + controls?: AnnouncementControls; + media?: AnnouncementMedia; + action?: AnnouncementAction; + startsAt?: string; + endsAt?: string; + minVersion?: string; + maxVersion?: string; +} + +export interface AnnouncementFeed { + settings: AnnouncementFeedSettings; + announcements: Announcement[]; +} + +export interface AnnouncementFeedSettings { + aspectRatio?: string; +} + +const MAX_ANNOUNCEMENTS = 50; +const MAX_ID_LENGTH = 100; +const MAX_TITLE_LENGTH = 160; +const MAX_BODY_LENGTH = 2_000; +const MAX_LABEL_LENGTH = 80; +const MAX_URL_LENGTH = 2_048; + +function isRecord(value: unknown): value is Record<string, unknown> { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function readTrimmedString(value: unknown, maxLength: number): string | undefined { + if (typeof value !== "string") { + return undefined; + } + + const trimmed = value.trim(); + return trimmed && trimmed.length <= maxLength ? trimmed : undefined; +} + +function readDate(value: unknown): string | undefined { + const date = readTrimmedString(value, 64); + return date && Number.isFinite(Date.parse(date)) ? date : undefined; +} + +function readAspectRatio(value: unknown): string | undefined { + const ratio = readTrimmedString(value, 16); + const match = ratio?.match(/^(\d{1,3}):(\d{1,3})$/); + if (!match) { + return undefined; + } + + const width = Number(match[1]); + const height = Number(match[2]); + const numericRatio = width / height; + return width > 0 && height > 0 && numericRatio >= 0.4 && numericRatio <= 3 + ? `${width}:${height}` + : undefined; +} + +function readBoundedInteger(value: unknown, minimum: number, maximum: number): number | undefined { + if (typeof value !== "number" || !Number.isFinite(value)) { + return undefined; + } + + const rounded = Math.round(value); + return rounded >= minimum && rounded <= maximum ? rounded : undefined; +} + +function readSafeUrl(value: unknown, allowRelative: boolean): string | undefined { + const url = readTrimmedString(value, MAX_URL_LENGTH); + if (!url) { + return undefined; + } + + if (allowRelative && url.startsWith("/") && !url.startsWith("//")) { + return url; + } + + try { + const parsed = new URL(url); + return parsed.protocol === "https:" && !parsed.username && !parsed.password + ? url + : undefined; + } catch { + return undefined; + } +} + +function parseMedia(value: unknown): AnnouncementMedia | undefined { + if (!isRecord(value) || (value.type !== "image" && value.type !== "video")) { + return undefined; + } + + const url = readSafeUrl(value.url, true); + if (!url) { + return undefined; + } + + const alt = readTrimmedString(value.alt, 300); + const posterUrl = value.type === "video" ? readSafeUrl(value.posterUrl, true) : undefined; + return { + type: value.type, + url, + ...(alt ? { alt } : {}), + ...(posterUrl ? { posterUrl } : {}), + }; +} + +function parseAction(value: unknown): AnnouncementAction | undefined { + if (!isRecord(value)) { + return undefined; + } + + const label = readTrimmedString(value.label, MAX_LABEL_LENGTH); + const url = readSafeUrl(value.url, false); + const section = isAnnouncementEditorSection(value.section) ? value.section : undefined; + if (!label || Boolean(url) === Boolean(section)) { + return undefined; + } + + if (url) { + return { label, url }; + } + + return section ? { label, section } : undefined; +} + +export function isAnnouncementEditorSection(value: unknown): value is AnnouncementEditorSection { + return ( + value === "scene" || + value === "cursor" || + value === "webcam" || + value === "captions" || + value === "settings" || + value === "extensions" + ); +} + +function parseControls(value: unknown): AnnouncementControls | undefined { + if (!isRecord(value)) { + return undefined; + } + + const controls: AnnouncementControls = {}; + for (const key of ["close", "dismiss", "action", "navigation", "indicators"] as const) { + if (typeof value[key] === "boolean") { + controls[key] = value[key]; + } + } + + return Object.keys(controls).length > 0 ? controls : undefined; +} + +function parseAnnouncement(value: unknown): Announcement | undefined { + if (!isRecord(value)) { + return undefined; + } + + const id = readTrimmedString(value.id, MAX_ID_LENGTH); + const title = readTrimmedString(value.title, MAX_TITLE_LENGTH); + const body = readTrimmedString(value.body, MAX_BODY_LENGTH); + if (!id || !title || !body) { + return undefined; + } + + const audience = value.audience === "editor" ? "editor" : "all"; + const presentation = + value.presentation === "notification" || + value.presentation === "banner" || + value.presentation === "export" + ? value.presentation + : undefined; + const isTextOnlyPresentation = + presentation === "notification" || presentation === "banner" || presentation === "export"; + const priority = + typeof value.priority === "number" && Number.isFinite(value.priority) + ? Math.max(-100, Math.min(100, value.priority)) + : 0; + const media = isTextOnlyPresentation ? undefined : parseMedia(value.media); + const action = parseAction(value.action); + const controls = parseControls(value.controls); + const mediaMode = + !isTextOnlyPresentation && (value.mediaMode === "cover" || value.mediaMode === "banner") + ? value.mediaMode + : undefined; + const displayDurationSeconds = readBoundedInteger(value.displayDurationSeconds, 3, 300); + const maxImpressions = readBoundedInteger(value.maxImpressions, 1, 100); + const startsAt = readDate(value.startsAt); + const endsAt = readDate(value.endsAt); + const minVersion = readTrimmedString(value.minVersion, 64); + const maxVersion = readTrimmedString(value.maxVersion, 64); + + return { + id, + title, + body, + ...(presentation ? { presentation } : {}), + audience, + priority, + ...(mediaMode ? { mediaMode } : {}), + ...(displayDurationSeconds ? { displayDurationSeconds } : {}), + ...(maxImpressions ? { maxImpressions } : {}), + ...(controls ? { controls } : {}), + ...(media ? { media } : {}), + ...(action ? { action } : {}), + ...(startsAt ? { startsAt } : {}), + ...(endsAt ? { endsAt } : {}), + ...(minVersion ? { minVersion } : {}), + ...(maxVersion ? { maxVersion } : {}), + }; +} + +export function parseAnnouncementFeed(value: unknown): AnnouncementFeed { + if (!isRecord(value) || !Array.isArray(value.announcements)) { + return { settings: {}, announcements: [] }; + } + + const settings = isRecord(value.settings) + ? { aspectRatio: readAspectRatio(value.settings.aspectRatio) } + : {}; + + const announcements: Announcement[] = []; + for (const item of value.announcements.slice(0, MAX_ANNOUNCEMENTS)) { + const announcement = parseAnnouncement(item); + if (announcement) { + announcements.push(announcement); + } + } + + return { + settings: settings.aspectRatio ? { aspectRatio: settings.aspectRatio } : {}, + announcements, + }; +} + +function versionParts(version: string): number[] { + const core = version.trim().replace(/^v/i, "").split("-")[0]; + return core.split(".").map((part) => { + const parsed = Number.parseInt(part, 10); + return Number.isFinite(parsed) ? parsed : 0; + }); +} + +export function compareVersions(left: string, right: string): number { + const leftParts = versionParts(left); + const rightParts = versionParts(right); + const length = Math.max(leftParts.length, rightParts.length); + + for (let index = 0; index < length; index += 1) { + const difference = (leftParts[index] ?? 0) - (rightParts[index] ?? 0); + if (difference !== 0) { + return difference > 0 ? 1 : -1; + } + } + + return 0; +} + +export function selectAnnouncements({ + bundled, + remote, + dismissedIds, + impressionCounts = {}, + appVersion, + audience, + now = new Date(), +}: { + bundled: Announcement[]; + remote: Announcement[]; + dismissedIds: ReadonlySet<string>; + impressionCounts?: Readonly<Record<string, number>>; + appVersion: string; + audience: AnnouncementAudience; + now?: Date; +}): Announcement[] { + const byId = new Map<string, Announcement>(); + for (const announcement of bundled) { + byId.set(announcement.id, announcement); + } + for (const announcement of remote) { + byId.set(announcement.id, announcement); + } + + const nowMs = now.getTime(); + return [...byId.values()] + .filter((announcement) => { + if (dismissedIds.has(announcement.id)) { + return false; + } + if ( + announcement.maxImpressions && + (impressionCounts[announcement.id] ?? 0) >= announcement.maxImpressions + ) { + return false; + } + if (announcement.audience !== "all" && announcement.audience !== audience) { + return false; + } + if (announcement.startsAt && Date.parse(announcement.startsAt) > nowMs) { + return false; + } + if (announcement.endsAt && Date.parse(announcement.endsAt) < nowMs) { + return false; + } + if ( + announcement.minVersion && + compareVersions(appVersion, announcement.minVersion) < 0 + ) { + return false; + } + if ( + announcement.maxVersion && + compareVersions(appVersion, announcement.maxVersion) > 0 + ) { + return false; + } + return true; + }) + .sort((left, right) => right.priority - left.priority); +} diff --git a/src/lib/appSettings.ts b/src/lib/appSettings.ts new file mode 100644 index 000000000..4aa3cd95e --- /dev/null +++ b/src/lib/appSettings.ts @@ -0,0 +1,42 @@ +type ElectronSettingsApi = Pick<Window["electronAPI"], "getAppSetting" | "setAppSetting">; + +function getElectronSettingsApi(): ElectronSettingsApi | null { + const api = (globalThis as typeof globalThis & { electronAPI?: ElectronSettingsApi }) + .electronAPI; + if ( + !api || + typeof api.getAppSetting !== "function" || + typeof api.setAppSetting !== "function" + ) { + return null; + } + + return api; +} + +export function loadAppSetting<T>(key: string): T | null { + const api = getElectronSettingsApi(); + if (!api) { + return null; + } + + try { + const value = api.getAppSetting(key); + return value === undefined ? null : (value as T | null); + } catch { + return null; + } +} + +export function saveAppSetting(key: string, value: unknown): boolean { + const api = getElectronSettingsApi(); + if (!api) { + return false; + } + + try { + return api.setAppSetting(key, value); + } catch { + return false; + } +} diff --git a/src/lib/assetPath.test.ts b/src/lib/assetPath.test.ts new file mode 100644 index 000000000..0cf6cd065 --- /dev/null +++ b/src/lib/assetPath.test.ts @@ -0,0 +1,87 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { getExportableVideoUrl, getRenderableAssetUrl, getRenderableVideoUrl } from "./assetPath"; + +describe("getRenderableAssetUrl", () => { + beforeEach(() => { + vi.unstubAllGlobals(); + }); + + it("keeps absolute POSIX image paths on the local-file path", async () => { + const readLocalFile = vi.fn(async () => ({ + success: false, + })); + vi.stubGlobal("window", { + electronAPI: { + readLocalFile, + }, + }); + + await expect(getRenderableAssetUrl("/Users/egg/Desktop/bg.jpg")).resolves.toBe( + "file:///Users/egg/Desktop/bg.jpg", + ); + expect(readLocalFile).toHaveBeenCalledWith("/Users/egg/Desktop/bg.jpg"); + }); +}); + +describe("getRenderableVideoUrl", () => { + beforeEach(() => { + vi.unstubAllGlobals(); + }); + + it("uses the local media server for absolute local video paths when available", async () => { + vi.stubGlobal("window", { + electronAPI: { + getLocalMediaUrl: vi.fn(async (filePath: string) => ({ + success: true, + url: `http://127.0.0.1:4321/video?path=${encodeURIComponent(filePath)}`, + })), + }, + }); + + await expect(getRenderableVideoUrl("/Users/egg/Desktop/bg.mp4")).resolves.toBe( + "http://127.0.0.1:4321/video?path=%2FUsers%2Fegg%2FDesktop%2Fbg.mp4", + ); + }); + + it("falls back to a file URL for absolute local video paths", async () => { + await expect(getRenderableVideoUrl("/Users/egg/Desktop/bg.mp4")).resolves.toBe( + "file:///Users/egg/Desktop/bg.mp4", + ); + }); + + it("keeps bundled wallpaper paths routed through the app asset directory", async () => { + vi.stubGlobal("window", { + location: { + protocol: "http:", + }, + }); + + await expect(getRenderableVideoUrl("/wallpapers/wispysky.mp4")).resolves.toBe( + "/wallpapers/wispysky.mp4", + ); + }); +}); + +describe("getExportableVideoUrl", () => { + beforeEach(() => { + vi.unstubAllGlobals(); + }); + + it("uses a direct file URL for absolute local video paths", async () => { + await expect(getExportableVideoUrl("/Users/egg/Desktop/bg.mp4")).resolves.toBe( + "file:///Users/egg/Desktop/bg.mp4", + ); + }); + + it("keeps bundled wallpaper paths routed through the app asset directory", async () => { + vi.stubGlobal("window", { + location: { + protocol: "http:", + }, + }); + + await expect(getExportableVideoUrl("/wallpapers/wispysky.mp4")).resolves.toBe( + "/wallpapers/wispysky.mp4", + ); + }); +}); diff --git a/src/lib/assetPath.ts b/src/lib/assetPath.ts index ccc58bf08..d6acbfbc6 100644 --- a/src/lib/assetPath.ts +++ b/src/lib/assetPath.ts @@ -1,25 +1,307 @@ +import { resolveAvailableWallpaperPath } from "./wallpapers"; + +function encodeRelativeAssetPath(relativePath: string): string { + return relativePath + .replace(/^\/+/, "") + .split("/") + .filter(Boolean) + .map((part) => encodeURIComponent(part)) + .join("/"); +} + +function ensureTrailingSlash(value: string): string { + return value.endsWith("/") ? value : `${value}/`; +} + +const WINDOWS_ABSOLUTE_PATH_PATTERN = /^[A-Za-z]:[\\/]/; +const WINDOWS_UNC_PATH_PATTERN = /^\\\\[^\\]+\\[^\\]+/; + +function encodeFilePathSegments(pathname: string, keepWindowsDrive = false): string { + return pathname + .split("/") + .map((segment, index) => { + if (!segment) return ""; + if (keepWindowsDrive && index === 1 && /^[A-Za-z]:$/.test(segment)) { + return segment; + } + return encodeURIComponent(segment); + }) + .join("/"); +} + +function toFileUrl(filePath: string): string { + const normalized = filePath.replace(/\\/g, "/"); + + if (WINDOWS_ABSOLUTE_PATH_PATTERN.test(normalized)) { + return `file://${encodeFilePathSegments(`/${normalized}`, true)}`; + } + + if (normalized.startsWith("//")) { + const [host, ...pathParts] = normalized.replace(/^\/+/, "").split("/"); + const encodedPath = pathParts.map((part) => encodeURIComponent(part)).join("/"); + return encodedPath ? `file://${host}/${encodedPath}` : `file://${host}/`; + } + + const absolutePath = normalized.startsWith("/") ? normalized : `/${normalized}`; + return `file://${encodeFilePathSegments(absolutePath)}`; +} + +export function isAbsoluteLocalAssetPath(asset: string): boolean { + return ( + asset.startsWith("/") || + WINDOWS_ABSOLUTE_PATH_PATTERN.test(asset) || + WINDOWS_UNC_PATH_PATTERN.test(asset) + ); +} + export async function getAssetPath(relativePath: string): Promise<string> { - try { - if (typeof window !== 'undefined') { - // If running in a dev server (http/https), prefer the web-served path - if (window.location && window.location.protocol && window.location.protocol.startsWith('http')) { - return `/${relativePath.replace(/^\//, '')}` - } - - if ((window as any).electronAPI && typeof (window as any).electronAPI.getAssetBasePath === 'function') { - const base = await (window as any).electronAPI.getAssetBasePath() - if (base) { - const normalized = base.replace(/\\/g, '/') - return `file://${normalized}/${relativePath}` - } - } - } - } catch (err) { - // ignore and use fallback - } - - // Fallback for web/dev server: public/wallpapers are served at '/wallpapers/...' - return `/${relativePath.replace(/^\//, '')}` + const encodedRelativePath = encodeRelativeAssetPath(relativePath); + const isWebContext = + typeof window !== "undefined" && Boolean(window.location?.protocol?.startsWith("http")); + + if (isWebContext) { + return `/${encodedRelativePath}`; + } + + try { + if (typeof window !== "undefined") { + if (typeof window.electronAPI?.getAssetBasePath === "function") { + const base = await window.electronAPI.getAssetBasePath(); + if (!base) { + throw new Error(`Failed to resolve asset base path for ${relativePath}`); + } + + return new URL(encodedRelativePath, ensureTrailingSlash(base)).toString(); + } + } + } catch (error) { + if (!isWebContext) { + throw error; + } + } + + // Fallback for web/dev server: public/wallpapers are served at '/wallpapers/...' + return `/${encodedRelativePath}`; +} + +const BASE64_CHUNK_SIZE = 0x8000; +const localFileDataUrlCache = new Map<string, string>(); + +function toLocalFilePath(resourceUrl: string) { + if (!resourceUrl.startsWith("file://")) { + return null; + } + + const decodedPath = decodeURIComponent(resourceUrl.replace(/^file:\/\//, "")); + if (/^\/[A-Za-z]:/.test(decodedPath)) { + return decodedPath.slice(1); + } + + return decodedPath; +} + +function getMimeTypeForAsset(resourceUrl: string) { + const normalized = resourceUrl.split("?")[0].toLowerCase(); + + if (normalized.endsWith(".png")) return "image/png"; + if (normalized.endsWith(".webp")) return "image/webp"; + if (normalized.endsWith(".gif")) return "image/gif"; + if (normalized.endsWith(".svg")) return "image/svg+xml"; + if (normalized.endsWith(".avif")) return "image/avif"; + return "image/jpeg"; +} + +function toBase64(bytes: Uint8Array) { + let binary = ""; + + for (let index = 0; index < bytes.length; index += BASE64_CHUNK_SIZE) { + const chunk = bytes.subarray(index, index + BASE64_CHUNK_SIZE); + binary += String.fromCharCode(...chunk); + } + + return btoa(binary); +} + +export async function getRenderableAssetUrl(asset: string): Promise<string> { + if ( + !asset || + asset.startsWith("data:") || + asset.startsWith("http") || + asset.startsWith("#") || + asset.startsWith("linear-gradient") || + asset.startsWith("radial-gradient") + ) { + return asset; + } + + const availableAsset = await resolveAvailableWallpaperPath(asset); + const resolvedAsset = + isAbsoluteLocalAssetPath(availableAsset) && !isBundledAssetPath(availableAsset) + ? toFileUrl(availableAsset) + : isBundledAssetPath(availableAsset) + ? await getAssetPath(availableAsset.replace(/^\//, "")) + : availableAsset; + + const localFilePath = toLocalFilePath(resolvedAsset); + if (!localFilePath || typeof window === "undefined" || !window.electronAPI?.readLocalFile) { + return resolvedAsset; + } + + const cached = localFileDataUrlCache.get(resolvedAsset); + if (cached) { + return cached; + } + + try { + const result = await window.electronAPI.readLocalFile(localFilePath); + if (!result.success || !result.data) { + return resolvedAsset; + } + + const bytes = result.data instanceof Uint8Array ? result.data : new Uint8Array(result.data); + const dataUrl = `data:${getMimeTypeForAsset(localFilePath)};base64,${toBase64(bytes)}`; + localFileDataUrlCache.set(resolvedAsset, dataUrl); + return dataUrl; + } catch { + return resolvedAsset; + } +} + +async function resolveLocalMediaUrl(filePath: string): Promise<string> { + if (typeof window !== "undefined" && window.electronAPI?.getLocalMediaUrl) { + try { + const result = await window.electronAPI.getLocalMediaUrl(filePath); + if (result.success && result.url) { + return result.url; + } + } catch { + // Fall through to a file URL when the media server is unavailable. + } + } + + return toFileUrl(filePath); +} + +function isBundledAssetPath(asset: string): boolean { + return asset.startsWith("/wallpapers/") || asset.startsWith("/app-icons/"); +} + +export async function getRenderableVideoUrl(asset: string): Promise<string> { + if ( + !asset || + asset.startsWith("blob:") || + asset.startsWith("data:") || + asset.startsWith("file://") || + asset.startsWith("http") + ) { + return asset; + } + + if (isAbsoluteLocalAssetPath(asset) && !isBundledAssetPath(asset)) { + return resolveLocalMediaUrl(asset); + } + + if (asset.startsWith("/") && !asset.startsWith("//")) { + return getAssetPath(asset.replace(/^\/+/, "")); + } + + return asset; +} + +export async function getExportableVideoUrl(asset: string): Promise<string> { + if ( + !asset || + asset.startsWith("blob:") || + asset.startsWith("data:") || + asset.startsWith("file://") || + asset.startsWith("http") + ) { + return asset; + } + + if (isAbsoluteLocalAssetPath(asset) && !isBundledAssetPath(asset)) { + return toFileUrl(asset); + } + + if (asset.startsWith("/") && !asset.startsWith("//")) { + return getAssetPath(asset.replace(/^\/+/, "")); + } + + return asset; +} + +// --------------------------------------------------------------------------- +// Wallpaper thumbnail helper — generates a tiny JPEG thumbnail via the main +// process (nativeImage resize) and returns a data URL for fast grid rendering. +// Concurrency is capped to avoid OOM from loading many full-res images at once. +// --------------------------------------------------------------------------- + +const thumbnailCache = new Map<string, string>(); + +const THUMB_CONCURRENCY = 3; +let thumbActive = 0; +const thumbQueue: Array<() => void> = []; + +function acquireThumbSlot(): Promise<void> { + if (thumbActive < THUMB_CONCURRENCY) { + thumbActive++; + return Promise.resolve(); + } + return new Promise<void>((resolve) => thumbQueue.push(resolve)); +} + +function releaseThumbSlot(): void { + const next = thumbQueue.shift(); + if (next) { + next(); + } else { + thumbActive--; + } +} + +export async function getWallpaperThumbnailUrl(asset: string): Promise<string> { + if ( + !asset || + asset.startsWith("data:") || + asset.startsWith("http") || + asset.startsWith("#") || + asset.startsWith("linear-gradient") || + asset.startsWith("radial-gradient") + ) { + return asset; + } + + const cached = thumbnailCache.get(asset); + if (cached) return cached; + + const localFilePath = toLocalFilePath( + asset.startsWith("/") && !asset.startsWith("//") + ? await getAssetPath(asset.replace(/^\//, "")) + : asset, + ); + if ( + !localFilePath || + typeof window === "undefined" || + !window.electronAPI?.generateWallpaperThumbnail + ) { + return getRenderableAssetUrl(asset); + } + + await acquireThumbSlot(); + try { + const result = await window.electronAPI.generateWallpaperThumbnail(localFilePath); + if (!result.success || !result.data) { + return getRenderableAssetUrl(asset); + } + const bytes = result.data instanceof Uint8Array ? result.data : new Uint8Array(result.data); + const dataUrl = `data:image/jpeg;base64,${toBase64(bytes)}`; + thumbnailCache.set(asset, dataUrl); + return dataUrl; + } catch { + return getRenderableAssetUrl(asset); + } finally { + releaseThumbSlot(); + } } export default getAssetPath; diff --git a/src/lib/customFonts.ts b/src/lib/customFonts.ts new file mode 100644 index 000000000..4c60b1b51 --- /dev/null +++ b/src/lib/customFonts.ts @@ -0,0 +1,206 @@ +// Google Fonts loading and management utility + +export interface CustomFont { + id: string; + name: string; // Display name + fontFamily: string; // CSS font-family value + importUrl: string; // Google Fonts @import URL +} + +const STORAGE_KEY = "recordly_custom_fonts"; +const LEGACY_STORAGE_KEY = "openscreen_custom_fonts"; +const loadedFonts = new Set<string>(); + +export class DuplicateFontError extends Error { + constructor(fontName: string) { + super(`Font \"${fontName}\" has already been added`); + this.name = "DuplicateFontError"; + } +} + +// Load custom fonts from localStorage +export function getCustomFonts(): CustomFont[] { + try { + const stored = + localStorage.getItem(STORAGE_KEY) ?? localStorage.getItem(LEGACY_STORAGE_KEY); + return stored ? JSON.parse(stored) : []; + } catch (error) { + console.error("Failed to load custom fonts from storage:", error); + return []; + } +} + +// Save custom fonts to localStorage +export function saveCustomFonts(fonts: CustomFont[]): void { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(fonts)); + localStorage.removeItem(LEGACY_STORAGE_KEY); + } catch (error) { + console.error("Failed to save custom fonts to storage:", error); + } +} + +// Add a new custom font (throws error if font fails to load) +export async function addCustomFont(font: CustomFont): Promise<CustomFont[]> { + const fonts = getCustomFonts(); + const normalizedFontFamily = font.fontFamily.trim().toLowerCase(); + const exists = fonts.some( + (existingFont) => + existingFont.id === font.id || + existingFont.fontFamily.trim().toLowerCase() === normalizedFontFamily, + ); + + if (exists) { + throw new DuplicateFontError(font.name); + } + + // Try to load the font first - this will throw if it fails + await loadFont(font); + + // Only add to storage if font loaded successfully + fonts.push(font); + saveCustomFonts(fonts); + + return fonts; +} + +// Remove a custom font +export function removeCustomFont(fontId: string): CustomFont[] { + const fonts = getCustomFonts(); + const filtered = fonts.filter((f) => f.id !== fontId); + saveCustomFonts(filtered); + + // Remove the style element + const styleEl = document.getElementById(`custom-font-${fontId}`); + if (styleEl) { + styleEl.remove(); + } + + loadedFonts.delete(fontId); + return filtered; +} + +// Load a Google Font into the document +export function loadFont(font: CustomFont): Promise<void> { + return new Promise((resolve, reject) => { + // Skip if already loaded + if (loadedFonts.has(font.id)) { + resolve(); + return; + } + + try { + const styleId = `custom-font-${font.id}`; + + // Remove existing style if present + const existing = document.getElementById(styleId); + if (existing) { + existing.remove(); + } + + // Create style element with @import + const style = document.createElement("style"); + style.id = styleId; + style.textContent = `@import url('${font.importUrl}');`; + document.head.appendChild(style); + + // Wait for font to load + waitForFont(font.fontFamily) + .then(() => { + loadedFonts.add(font.id); + resolve(); + }) + .catch((error) => { + style.remove(); + reject(error); + }); + } catch (error) { + console.error("Failed to load font:", font, error); + reject(error); + } + }); +} + +// Wait for a font to be available and verify it loaded +function waitForFont(fontFamily: string, timeout = 5000): Promise<void> { + return new Promise((resolve, reject) => { + // Use CSS Font Loading API if available + if ("fonts" in document) { + Promise.race([ + document.fonts.load(`16px "${fontFamily}"`), + new Promise((_, rej) => + setTimeout(() => rej(new Error("Font load timeout")), timeout), + ), + ]) + .then(() => { + // Verify the font actually loaded by checking if it's available + const isAvailable = document.fonts.check(`16px "${fontFamily}"`); + if (isAvailable) { + resolve(); + } else { + reject(new Error(`Font "${fontFamily}" failed to load`)); + } + }) + .catch((error) => { + reject(error); + }); + } else { + // Fallback for browsers without Font Loading API + // Wait a bit and hope for the best + setTimeout(() => resolve(), 1000); + } + }); +} + +// Load all stored custom fonts on app initialization +export function loadAllCustomFonts(): Promise<void[]> { + const fonts = getCustomFonts(); + return Promise.all( + fonts.map((font) => + loadFont(font).catch((err) => { + console.error("Failed to load custom font:", font.name, err); + }), + ), + ); +} + +// Generate a unique ID for a font +export function generateFontId(name: string): string { + return `${name.toLowerCase().replace(/\s+/g, "-")}-${Date.now()}`; +} + +// Parse Google Fonts @import URL to extract font family name +export function parseFontFamilyFromImport(importUrl: string): string | null { + try { + // Extract from URL like: https://fonts.googleapis.com/css2?family=Roboto:wght@400;700&display=swap + const url = new URL(importUrl); + const familyParam = url.searchParams.get("family"); + + if (familyParam) { + // Remove weight/style info: "Roboto:wght@400;700" -> "Roboto" + const fontName = familyParam.split(":")[0]; + // Replace + with spaces: "Open+Sans" -> "Open Sans" + return fontName.replace(/\+/g, " "); + } + + return null; + } catch (error) { + console.error("Failed to parse font family from import URL:", error); + return null; + } +} + +// Validate if a string looks like a Google Fonts import URL +export function isValidGoogleFontsUrl(url: string): boolean { + try { + const urlObj = new URL(url); + return ( + urlObj.protocol === "https:" && + urlObj.hostname === "fonts.googleapis.com" && + /^\/css2?$/.test(urlObj.pathname) && + urlObj.searchParams.has("family") + ); + } catch { + return false; + } +} diff --git a/src/lib/exporter/annotationRenderer.ts b/src/lib/exporter/annotationRenderer.ts new file mode 100644 index 000000000..d6c6ce991 --- /dev/null +++ b/src/lib/exporter/annotationRenderer.ts @@ -0,0 +1,518 @@ +import { + type AnnotationRegion, + type ArrowDirection, + BLUR_ANNOTATION_STRENGTH, +} from "@/components/video-editor/types"; + +export interface AnnotationRenderAssets { + imageCache: Map<string, HTMLImageElement>; +} + +interface AnnotationSceneTransform { + scale: number; + x: number; + y: number; +} + +interface AnnotationCoordinateRect { + x: number; + y: number; + width: number; + height: number; +} + +function transformAnnotationRect( + rect: { x: number; y: number; width: number; height: number }, + sceneTransform?: AnnotationSceneTransform, +) { + if (!sceneTransform) { + return rect; + } + + return { + x: rect.x * sceneTransform.scale + sceneTransform.x, + y: rect.y * sceneTransform.scale + sceneTransform.y, + width: rect.width * sceneTransform.scale, + height: rect.height * sceneTransform.scale, + }; +} + +const annotationImagePromiseCache = new Map<string, Promise<HTMLImageElement | null>>(); + +let blurBufferCanvas: HTMLCanvasElement | null = null; +function getBlurBufferCanvas(): HTMLCanvasElement | null { + if (typeof document === "undefined") return null; + if (!blurBufferCanvas) { + blurBufferCanvas = document.createElement("canvas"); + } + return blurBufferCanvas; +} + +function getAnnotationImageContent(annotation: AnnotationRegion): string | null { + const source = annotation.imageContent || annotation.content; + if (!source || !source.startsWith("data:image")) { + return null; + } + + return source; +} + +function loadAnnotationImage(source: string): Promise<HTMLImageElement | null> { + const cachedPromise = annotationImagePromiseCache.get(source); + if (cachedPromise) { + return cachedPromise; + } + + const loadPromise = new Promise<HTMLImageElement | null>((resolve) => { + const img = new Image(); + img.onload = () => resolve(img); + img.onerror = () => { + console.error("[AnnotationRenderer] Failed to load image annotation"); + resolve(null); + }; + img.src = source; + }); + + annotationImagePromiseCache.set(source, loadPromise); + return loadPromise; +} + +export async function preloadAnnotationAssets( + annotations: AnnotationRegion[] = [], +): Promise<AnnotationRenderAssets> { + const uniqueSources = [ + ...new Set( + annotations + .filter((annotation) => annotation.type === "image") + .map((annotation) => getAnnotationImageContent(annotation)) + .filter((source): source is string => !!source), + ), + ]; + + if (uniqueSources.length === 0) { + return { imageCache: new Map() }; + } + + const loadedSources = await Promise.all( + uniqueSources.map(async (source) => { + const image = await loadAnnotationImage(source); + return image ? ([source, image] as const) : null; + }), + ); + + return { + imageCache: new Map( + loadedSources.filter((entry): entry is readonly [string, HTMLImageElement] => !!entry), + ), + }; +} + +const ARROW_PATHS: Record<ArrowDirection, string[]> = { + up: ["M 50 20 L 50 80", "M 50 20 L 35 35", "M 50 20 L 65 35"], + down: ["M 50 20 L 50 80", "M 50 80 L 35 65", "M 50 80 L 65 65"], + left: ["M 80 50 L 20 50", "M 20 50 L 35 35", "M 20 50 L 35 65"], + right: ["M 20 50 L 80 50", "M 80 50 L 65 35", "M 80 50 L 65 65"], + "up-right": ["M 25 75 L 75 25", "M 75 25 L 60 30", "M 75 25 L 70 40"], + "up-left": ["M 75 75 L 25 25", "M 25 25 L 40 30", "M 25 25 L 30 40"], + "down-right": ["M 25 25 L 75 75", "M 75 75 L 70 60", "M 75 75 L 60 70"], + "down-left": ["M 75 25 L 25 75", "M 25 75 L 30 60", "M 25 75 L 40 70"], +}; + +function parseSvgPath( + pathString: string, + scaleX: number, + scaleY: number, +): Array<{ cmd: string; args: number[] }> { + const commands: Array<{ cmd: string; args: number[] }> = []; + const parts = pathString.trim().split(/\s+/); + + let i = 0; + while (i < parts.length) { + const cmd = parts[i]; + if (cmd === "M" || cmd === "L") { + const x = parseFloat(parts[i + 1]) * scaleX; + const y = parseFloat(parts[i + 2]) * scaleY; + commands.push({ cmd, args: [x, y] }); + i += 3; + } else { + i++; + } + } + + return commands; +} + +function renderArrow( + ctx: CanvasRenderingContext2D, + direction: ArrowDirection, + color: string, + strokeWidth: number, + x: number, + y: number, + width: number, + height: number, + _scaleFactor: number, +) { + const paths = ARROW_PATHS[direction]; + if (!paths) return; + + ctx.save(); + ctx.translate(x, y); + + const padding = 8 * _scaleFactor; + const availableWidth = Math.max(0, width - padding * 2); + const availableHeight = Math.max(0, height - padding * 2); + + const scale = Math.min(availableWidth / 100, availableHeight / 100); + + const offsetX = padding + (availableWidth - 100 * scale) / 2; + const offsetY = padding + (availableHeight - 100 * scale) / 2; + + ctx.translate(offsetX, offsetY); + + ctx.shadowColor = "rgba(0, 0, 0, 0.3)"; + ctx.shadowBlur = 8 * scale; + ctx.shadowOffsetX = 0; + ctx.shadowOffsetY = 4 * scale; + + ctx.strokeStyle = color; + ctx.lineWidth = strokeWidth * scale; + ctx.lineCap = "round"; + ctx.lineJoin = "round"; + + ctx.beginPath(); + + for (const pathString of paths) { + const commands = parseSvgPath(pathString, scale, scale); + + for (const { cmd, args } of commands) { + if (cmd === "M") { + ctx.moveTo(args[0], args[1]); + } else if (cmd === "L") { + ctx.lineTo(args[0], args[1]); + } + } + } + + ctx.stroke(); + + ctx.restore(); +} + +function renderText( + ctx: CanvasRenderingContext2D, + annotation: AnnotationRegion, + x: number, + y: number, + width: number, + height: number, + scaleFactor: number, +) { + const style = annotation.style; + + ctx.save(); + + ctx.beginPath(); + ctx.rect(x, y, width, height); + ctx.clip(); + + const fontWeight = style.fontWeight === "bold" ? "bold" : "normal"; + const fontStyle = style.fontStyle === "italic" ? "italic" : "normal"; + const scaledFontSize = style.fontSize * scaleFactor; + ctx.font = `${fontStyle} ${fontWeight} ${scaledFontSize}px ${style.fontFamily}`; + ctx.textBaseline = "middle"; + + const containerPadding = 8 * scaleFactor; + + let textX = x; + const textY = y + height / 2; + + if (style.textAlign === "center") { + textX = x + width / 2; + ctx.textAlign = "center"; + } else if (style.textAlign === "right") { + textX = x + width - containerPadding; + ctx.textAlign = "right"; + } else { + textX = x + containerPadding; + ctx.textAlign = "left"; + } + + const availableWidth = width - containerPadding * 2; + const rawLines = annotation.content.split("\n"); + const lines: string[] = []; + for (const rawLine of rawLines) { + if (!rawLine) { + lines.push(""); + continue; + } + const words = rawLine.split(/(\s+)/); + let current = ""; + for (const word of words) { + const test = current + word; + if (current && ctx.measureText(test).width > availableWidth) { + lines.push(current); + current = word.trimStart(); + } else { + current = test; + } + } + if (current) lines.push(current); + } + const lineHeight = scaledFontSize * 1.4; + + const startY = textY - ((lines.length - 1) * lineHeight) / 2; + + lines.forEach((line, index) => { + const currentY = startY + index * lineHeight; + + if (style.backgroundColor && style.backgroundColor !== "transparent") { + const metrics = ctx.measureText(line); + const verticalPadding = scaledFontSize * 0.1; + const horizontalPadding = scaledFontSize * 0.2; + const borderRadius = 4 * scaleFactor; + + let bgX = textX - horizontalPadding; + const bgWidth = metrics.width + horizontalPadding * 2; + + const contentHeight = scaledFontSize * 1.4; + const bgHeight = contentHeight + verticalPadding * 2; + const bgY = currentY - bgHeight / 2; + + if (style.textAlign === "center") { + bgX = textX - bgWidth / 2; + } else if (style.textAlign === "right") { + bgX = textX - bgWidth; + } + + ctx.fillStyle = style.backgroundColor; + ctx.beginPath(); + ctx.roundRect(bgX, bgY, bgWidth, bgHeight, borderRadius); + ctx.fill(); + } + + ctx.fillStyle = style.color; + ctx.fillText(line, textX, currentY); + + if (style.textDecoration === "underline") { + const metrics = ctx.measureText(line); + let underlineX = textX; + const underlineY = currentY + scaledFontSize * 0.15; + + if (style.textAlign === "center") { + underlineX = textX - metrics.width / 2; + } else if (style.textAlign === "right") { + underlineX = textX - metrics.width; + } + + ctx.strokeStyle = style.color; + ctx.lineWidth = Math.max(1, scaledFontSize / 16); + ctx.beginPath(); + ctx.moveTo(underlineX, underlineY); + ctx.lineTo(underlineX + metrics.width, underlineY); + ctx.stroke(); + } + }); + + ctx.restore(); +} + +async function renderImage( + ctx: CanvasRenderingContext2D, + annotation: AnnotationRegion, + x: number, + y: number, + width: number, + height: number, + assets?: AnnotationRenderAssets, +): Promise<void> { + const source = getAnnotationImageContent(annotation); + if (!source) { + return; + } + + const img = assets?.imageCache.get(source) ?? (await loadAnnotationImage(source)); + if (!img) { + return; + } + + const imgAspect = img.width / img.height; + const boxAspect = width / height; + + let drawWidth = width; + let drawHeight = height; + let drawX = x; + let drawY = y; + + if (imgAspect > boxAspect) { + drawHeight = width / imgAspect; + drawY = y + (height - drawHeight) / 2; + } else { + drawWidth = height * imgAspect; + drawX = x + (width - drawWidth) / 2; + } + + ctx.drawImage(img, drawX, drawY, drawWidth, drawHeight); +} + +export async function renderAnnotations( + ctx: CanvasRenderingContext2D, + annotations: AnnotationRegion[], + canvasWidth: number, + canvasHeight: number, + currentTimeMs: number, + scaleFactor: number = 1.0, + assets?: AnnotationRenderAssets, + sceneTransform?: AnnotationSceneTransform, + coordinateRect?: AnnotationCoordinateRect, +): Promise<void> { + const activeAnnotations = annotations.filter( + (ann) => currentTimeMs >= ann.startMs && currentTimeMs <= ann.endMs, + ); + + const sortedAnnotations = [...activeAnnotations].sort((a, b) => a.zIndex - b.zIndex); + const annotationRect = coordinateRect ?? { + x: 0, + y: 0, + width: canvasWidth, + height: canvasHeight, + }; + + for (const annotation of sortedAnnotations) { + const rect = transformAnnotationRect( + { + x: annotationRect.x + (annotation.position.x / 100) * annotationRect.width, + y: annotationRect.y + (annotation.position.y / 100) * annotationRect.height, + width: (annotation.size.width / 100) * annotationRect.width, + height: (annotation.size.height / 100) * annotationRect.height, + }, + sceneTransform, + ); + const { x, y, width, height } = rect; + const effectiveScaleFactor = scaleFactor * (sceneTransform?.scale ?? 1); + + switch (annotation.type) { + case "text": + renderText(ctx, annotation, x, y, width, height, effectiveScaleFactor); + break; + + case "image": + await renderImage(ctx, annotation, x, y, width, height, assets); + break; + + case "figure": + if (annotation.figureData) { + renderArrow( + ctx, + annotation.figureData.arrowDirection, + annotation.figureData.color, + annotation.figureData.strokeWidth, + x, + y, + width, + height, + effectiveScaleFactor, + ); + } + break; + + case "blur": { + const blurStrength = + (annotation.blurIntensity ?? BLUR_ANNOTATION_STRENGTH) * effectiveScaleFactor; + const padding = Math.ceil(blurStrength * 2); + + ctx.save(); + + ctx.beginPath(); + const borderRadius = (annotation.style.borderRadius ?? 0) * effectiveScaleFactor; + ctx.roundRect(x, y, width, height, borderRadius); + ctx.clip(); + + const sx = Math.max(0, x - padding); + const sy = Math.max(0, y - padding); + const sw = Math.min(canvasWidth - sx, width + padding * 2); + const sh = Math.min(canvasHeight - sy, height + padding * 2); + + if (sw > 0 && sh > 0) { + const buffer = getBlurBufferCanvas(); + if (buffer) { + buffer.width = sw; + buffer.height = sh; + const bCtx = buffer.getContext("2d"); + if (bCtx) { + bCtx.drawImage(ctx.canvas, sx, sy, sw, sh, 0, 0, sw, sh); + + ctx.filter = `blur(${blurStrength}px)`; + ctx.drawImage(buffer, sx, sy); + + if (annotation.blurColor && annotation.blurColor !== "transparent") { + ctx.filter = "none"; + ctx.fillStyle = annotation.blurColor; + ctx.fillRect(x, y, width, height); + } + } + } + } + + ctx.restore(); + break; + } + } + } +} + +export async function renderAnnotationToCanvas( + annotation: AnnotationRegion, + width: number, + height: number, + scaleFactor: number = 1.0, + assets?: AnnotationRenderAssets, +): Promise<HTMLCanvasElement | null> { + const canvasWidth = Math.max(1, Math.ceil(width)); + const canvasHeight = Math.max(1, Math.ceil(height)); + const canvas = document.createElement("canvas"); + canvas.width = canvasWidth; + canvas.height = canvasHeight; + + const ctx = canvas.getContext("2d"); + if (!ctx) { + return null; + } + + ctx.imageSmoothingEnabled = true; + ctx.imageSmoothingQuality = "high"; + + switch (annotation.type) { + case "text": + renderText(ctx, annotation, 0, 0, canvasWidth, canvasHeight, scaleFactor); + break; + + case "image": + await renderImage(ctx, annotation, 0, 0, canvasWidth, canvasHeight, assets); + break; + + case "figure": + if (!annotation.figureData) { + return null; + } + + renderArrow( + ctx, + annotation.figureData.arrowDirection, + annotation.figureData.color, + annotation.figureData.strokeWidth, + 0, + 0, + canvasWidth, + canvasHeight, + scaleFactor, + ); + break; + case "blur": + // Blur annotations must sample already-rendered scene pixels, + // so they cannot be rasterized as standalone sprites. + return null; + } + + return canvas; +} diff --git a/src/lib/exporter/audioEncoder.test.ts b/src/lib/exporter/audioEncoder.test.ts new file mode 100644 index 000000000..92a1a8872 --- /dev/null +++ b/src/lib/exporter/audioEncoder.test.ts @@ -0,0 +1,252 @@ +import { describe, expect, it, vi } from "vitest"; + +import { AudioProcessor, softLimitOfflineMixPeaksInPlace } from "./audioEncoder"; + +type OfflineRenderTestHarness = AudioProcessor & { + decodeAudioFromUrl(url: string): Promise<AudioBuffer | null>; + getMediaDurationSec(url: string): Promise<number>; + loadAudioFileDemuxer(audioPath: string): Promise<unknown>; + prepareOfflineRender( + videoUrl: string, + trimRegions: never[], + speedRegions: never[], + audioRegions: never[], + sourceAudioFallbackPaths: string[], + sourceAudioFallbackStartDelayMsByPath?: Record<string, number>, + ): Promise<{ + mainBufferEntry: { buffer: AudioBuffer; gain: number } | null; + companionEntries: Array<{ buffer: AudioBuffer; startDelaySec: number; gain: number }>; + }>; + renderAndMuxOfflineAudio( + videoUrl: string, + trimRegions: never[], + speedRegions: never[], + audioRegions: never[], + sourceAudioFallbackPaths: string[], + sourceAudioFallbackStartDelayMsByPath: Record<string, number> | undefined, + muxer: unknown, + ): Promise<void>; + renderChunked( + prepared: { + mainBufferEntry: null; + companionEntries: []; + regionEntries: []; + mutedSourceOutputRangesSec: []; + slices: []; + outputDurationMs: number; + numChannels: number; + }, + totalOutputSec: number, + onChunk: ( + rendered: AudioBuffer, + outputOffsetSec: number, + chunkIndex: number, + ) => Promise<void>, + ): Promise<void>; +}; + +function fakeAudioBuffer(channels: Float32Array[]): AudioBuffer { + return { + numberOfChannels: channels.length, + getChannelData: (channel: number) => channels[channel], + } as AudioBuffer; +} + +describe("AudioProcessor offline render preparation", () => { + it("keeps embedded source audio separate from external companion sidecars", async () => { + const processor = new AudioProcessor() as unknown as OfflineRenderTestHarness; + const mainBuffer = { duration: 10, numberOfChannels: 2 } as AudioBuffer; + const micBuffer = { duration: 9.5, numberOfChannels: 1 } as AudioBuffer; + + const decodeAudioFromUrl = vi + .spyOn(processor, "decodeAudioFromUrl") + .mockImplementation(async (url: string) => { + if (url === "file:///tmp/recording.mp4") { + return mainBuffer; + } + if (url === "/tmp/recording.mic.wav") { + return micBuffer; + } + return null; + }); + vi.spyOn(processor, "getMediaDurationSec").mockResolvedValue(10); + + const prepared = await processor.prepareOfflineRender( + "file:///tmp/recording.mp4", + [], + [], + [], + ["/tmp/recording.mp4", "/tmp/recording.mic.wav"], + ); + + expect(prepared.mainBufferEntry?.buffer).toBe(mainBuffer); + expect(prepared.mainBufferEntry?.gain).toBe(1); + expect(prepared.companionEntries).toHaveLength(1); + expect(prepared.companionEntries[0]?.buffer).toBe(micBuffer); + expect(prepared.companionEntries[0]?.gain).toBe(1); + expect(decodeAudioFromUrl).toHaveBeenCalledWith("file:///tmp/recording.mp4"); + expect(decodeAudioFromUrl).toHaveBeenCalledWith("/tmp/recording.mic.wav"); + expect(decodeAudioFromUrl).not.toHaveBeenCalledWith("/tmp/recording.mp4"); + }); + + it("does not treat a single embedded fallback path as an external sidecar", async () => { + const processor = new AudioProcessor() as unknown as OfflineRenderTestHarness; + const loadAudioFileDemuxer = vi.spyOn(processor, "loadAudioFileDemuxer"); + const renderAndMuxOfflineAudio = vi + .spyOn(processor, "renderAndMuxOfflineAudio") + .mockResolvedValue(); + + await processor.process( + null, + {} as never, + "file:///tmp/recording.mp4", + [], + [], + undefined, + [], + ["/tmp/recording.mp4"], + ); + + expect(loadAudioFileDemuxer).not.toHaveBeenCalled(); + expect(renderAndMuxOfflineAudio).not.toHaveBeenCalled(); + }); + + it("uses recorded companion start-delay metadata instead of inferring from duration gap", async () => { + const processor = new AudioProcessor() as unknown as OfflineRenderTestHarness; + const mainBuffer = { duration: 600, numberOfChannels: 2 } as AudioBuffer; + const micBuffer = { duration: 565, numberOfChannels: 1 } as AudioBuffer; + + vi.spyOn(processor, "decodeAudioFromUrl").mockImplementation(async (url: string) => { + if (url === "file:///tmp/recording.mp4") { + return mainBuffer; + } + if (url === "/tmp/recording.mic.webm") { + return micBuffer; + } + return null; + }); + + const prepared = await processor.prepareOfflineRender( + "file:///tmp/recording.mp4", + [], + [], + [], + ["/tmp/recording.mic.webm"], + { "/tmp/recording.mic.webm": 3_500 }, + ); + + expect(prepared.companionEntries[0]?.startDelaySec).toBeCloseTo(3.5); + }); + + it("avoids the single-sidecar fast path when companion timing metadata is present", async () => { + const processor = new AudioProcessor() as unknown as OfflineRenderTestHarness; + const loadAudioFileDemuxer = vi.spyOn(processor, "loadAudioFileDemuxer"); + const renderAndMuxOfflineAudio = vi + .spyOn(processor, "renderAndMuxOfflineAudio") + .mockResolvedValue(); + + await processor.process( + null, + {} as never, + "file:///tmp/recording.mp4", + [], + [], + undefined, + [], + ["/tmp/recording.mic.webm"], + { "/tmp/recording.mic.webm": 2_000 }, + ); + + expect(loadAudioFileDemuxer).not.toHaveBeenCalled(); + expect(renderAndMuxOfflineAudio).toHaveBeenCalled(); + }); + + it("avoids the single-sidecar fast path for legacy mac mic sidecars that still need embedded audio", async () => { + const processor = new AudioProcessor() as unknown as OfflineRenderTestHarness; + const loadAudioFileDemuxer = vi.spyOn(processor, "loadAudioFileDemuxer"); + const renderAndMuxOfflineAudio = vi + .spyOn(processor, "renderAndMuxOfflineAudio") + .mockResolvedValue(); + + await processor.process( + {} as never, + {} as never, + "file:///tmp/recording.mp4", + [], + [], + undefined, + [], + ["/tmp/recording.mic.m4a"], + ); + + expect(loadAudioFileDemuxer).not.toHaveBeenCalled(); + expect(renderAndMuxOfflineAudio).toHaveBeenCalled(); + }); + + it("soft-limits mixed peaks before encoding or WAV conversion", () => { + const samples = new Float32Array([ + -1.6, + -0.5, + Number.NEGATIVE_INFINITY, + Number.NaN, + 0, + 0.5, + 0.95, + Number.POSITIVE_INFINITY, + 1.6, + ]); + const changed = softLimitOfflineMixPeaksInPlace(fakeAudioBuffer([samples])); + + expect(changed).toBe(true); + expect(samples[0]).toBeGreaterThanOrEqual(-0.986); + expect(samples[1]).toBe(-0.5); + expect(samples[2]).toBe(0); + expect(samples[3]).toBe(0); + expect(samples[5]).toBe(0.5); + expect(samples[6]).toBeLessThan(0.95); + expect(samples[6]).toBeGreaterThan(0.9); + expect(samples[7]).toBe(0); + expect(samples[8]).toBeLessThanOrEqual(0.986); + }); + + it("runs the offline mix limiter for every rendered chunk", async () => { + const processor = new AudioProcessor() as unknown as OfflineRenderTestHarness; + const renderedSamples = new Float32Array([1.4]); + const renderedBuffer = fakeAudioBuffer([renderedSamples]); + const originalOfflineAudioContext = globalThis.OfflineAudioContext; + ( + globalThis as unknown as { OfflineAudioContext: typeof OfflineAudioContext } + ).OfflineAudioContext = class { + constructor() {} + + startRendering() { + return Promise.resolve(renderedBuffer); + } + } as unknown as typeof OfflineAudioContext; + + try { + let observedPeak = Number.POSITIVE_INFINITY; + await processor.renderChunked( + { + mainBufferEntry: null, + companionEntries: [], + regionEntries: [], + mutedSourceOutputRangesSec: [], + slices: [], + outputDurationMs: 100, + numChannels: 1, + }, + 0.1, + async (rendered) => { + observedPeak = rendered.getChannelData(0)[0] ?? 0; + }, + ); + + expect(observedPeak).toBeLessThanOrEqual(0.986); + } finally { + ( + globalThis as unknown as { OfflineAudioContext: typeof OfflineAudioContext } + ).OfflineAudioContext = originalOfflineAudioContext; + } + }); +}); diff --git a/src/lib/exporter/audioEncoder.ts b/src/lib/exporter/audioEncoder.ts new file mode 100644 index 000000000..6f1ca0757 --- /dev/null +++ b/src/lib/exporter/audioEncoder.ts @@ -0,0 +1,1856 @@ +import { WebDemuxer } from "web-demuxer"; +import { SOURCE_AUDIO_NORMALIZE_GAIN } from "@/components/video-editor/audio/audioTypes"; +import type { + AudioRegion, + ClipRegion, + SourceAudioTrackSettings, + SpeedRegion, + TrimRegion, +} from "@/components/video-editor/types"; +import { buildResolvedAudioPlan, SourceTrackId } from "@/lib/exporter/audioRoutingEngine"; +import { estimateCompanionAudioStartDelaySeconds } from "@/lib/mediaTiming"; +import { resolveMediaElementSource } from "./localMediaSource"; +import type { VideoMuxer } from "./muxer"; +import { resolveSourceTrackRoutingPolicy } from "./sourceTrackRoutingPolicy"; + +const AUDIO_BITRATE = 128_000; +const DECODE_BACKPRESSURE_LIMIT = 20; +const ENCODE_BACKPRESSURE_LIMIT = 20; +const MIN_SPEED_REGION_DELTA_MS = 0.0001; +const MP4_AUDIO_CODEC = "mp4a.40.2"; +const OFFLINE_AUDIO_SAMPLE_RATE = 48_000; +const OFFLINE_ENCODE_CHUNK_FRAMES = 1024; +const OFFLINE_CHUNK_DURATION_SEC = 30; +const OFFLINE_MIX_SOFT_LIMITER_THRESHOLD = 0.9; +const OFFLINE_MIX_SOFT_LIMITER_CEILING = 0.985; + +function softLimitSample(sample: number): number { + const magnitude = Math.abs(sample); + if (magnitude <= OFFLINE_MIX_SOFT_LIMITER_THRESHOLD) { + return sample; + } + + const sign = sample < 0 ? -1 : 1; + const kneeRange = 1 - OFFLINE_MIX_SOFT_LIMITER_THRESHOLD; + const limitedMagnitude = + OFFLINE_MIX_SOFT_LIMITER_THRESHOLD + + kneeRange * Math.tanh((magnitude - OFFLINE_MIX_SOFT_LIMITER_THRESHOLD) / kneeRange); + return sign * Math.min(OFFLINE_MIX_SOFT_LIMITER_CEILING, limitedMagnitude); +} + +export function softLimitOfflineMixPeaksInPlace(buffer: AudioBuffer): boolean { + let changed = false; + for (let channel = 0; channel < buffer.numberOfChannels; channel += 1) { + const data = buffer.getChannelData(channel); + for (let index = 0; index < data.length; index += 1) { + const sample = data[index]; + if (!Number.isFinite(sample)) { + data[index] = 0; + changed = true; + continue; + } + + const limited = softLimitSample(sample); + if (limited !== sample) { + data[index] = limited; + changed = true; + } + } + } + return changed; +} + +function resolveSourceTrackGain( + sourceAudioTrackSettings: SourceAudioTrackSettings | undefined, + trackId: "mic" | "system" | "mixed", +) { + const settings = sourceAudioTrackSettings?.[trackId]; + if (!settings) { + return 1; + } + const normalizeGain = settings.normalize ? SOURCE_AUDIO_NORMALIZE_GAIN : 1; + return Math.max(0, Math.min(2, settings.volume * normalizeGain)); +} + +export function getSourceTrackIdFromPath(audioPath: string): SourceTrackId { + const normalized = audioPath.toLowerCase(); + // Check for common patterns like .mic., -mic., mic.mp4, etc. + if ( + normalized.includes(".mic.") || + normalized.includes("-mic.") || + normalized.includes("_mic_") || + normalized.includes("/mic.") || + normalized.includes("\\mic.") || + normalized.endsWith("mic.mp4") || + normalized.endsWith("mic.m4a") || + normalized.endsWith("mic.wav") + ) { + return "mic"; + } + if ( + normalized.includes(".system.") || + normalized.includes("-system.") || + normalized.includes("_system_") || + normalized.includes("/system.") || + normalized.includes("\\system.") || + normalized.endsWith("system.mp4") || + normalized.endsWith("system.m4a") || + normalized.endsWith("system.wav") + ) { + return "system"; + } + return "mixed"; +} + +export function hasNonDefaultSourceTrackSettings( + sourceAudioTrackSettings?: SourceAudioTrackSettings, +) { + if (!sourceAudioTrackSettings) { + return false; + } + return Object.values(sourceAudioTrackSettings).some( + (settings) => + Math.abs((settings?.volume ?? 1) - 1) > 0.0005 || Boolean(settings?.normalize), + ); +} + +interface TimelineSlice { + sourceStartMs: number; + sourceEndMs: number; + speed: number; +} + +interface PreparedOfflineRender { + mainBufferEntry: { buffer: AudioBuffer; gain: number } | null; + companionEntries: Array<{ buffer: AudioBuffer; startDelaySec: number; gain: number }>; + regionEntries: Array<{ buffer: AudioBuffer; region: AudioRegion }>; + mutedSourceOutputRangesSec: Array<{ startSec: number; endSec: number }>; + slices: TimelineSlice[]; + outputDurationMs: number; + numChannels: number; +} + +export async function isAacAudioEncodingSupported( + sampleRate = 48_000, + numberOfChannels = 2, +): Promise<boolean> { + try { + const support = await AudioEncoder.isConfigSupported({ + codec: MP4_AUDIO_CODEC, + sampleRate, + numberOfChannels, + bitrate: AUDIO_BITRATE, + }); + return support.supported === true; + } catch { + return false; + } +} + +type TrimLikeRegion = TrimRegion | ClipRegion; + +export class AudioProcessor { + private cancelled = false; + private onProgress?: (progress: number) => void; + + private isPassthroughAudioCodec(codec: string | undefined): boolean { + if (!codec) { + return false; + } + + const normalizedCodec = codec.toLowerCase(); + return ( + normalizedCodec === MP4_AUDIO_CODEC || + normalizedCodec === "aac" || + normalizedCodec.startsWith("mp4a.40.2") + ); + } + + private async passthroughAudioStream( + audioStream: ReadableStream<EncodedAudioChunk>, + audioConfig: AudioDecoderConfig, + muxer: VideoMuxer, + ): Promise<boolean> { + if (!this.isPassthroughAudioCodec(audioConfig.codec)) { + return false; + } + + let reader: ReadableStreamDefaultReader<EncodedAudioChunk> | null = null; + let wroteAudio = false; + let passthroughTimestampOffsetUs: number | null = null; + + try { + reader = audioStream.getReader(); + while (!this.cancelled) { + const { done, value: chunk } = await reader.read(); + if (done || !chunk) break; + + if (passthroughTimestampOffsetUs === null) { + passthroughTimestampOffsetUs = chunk.timestamp; + } + + const normalizedTimestamp = Math.max( + 0, + chunk.timestamp - passthroughTimestampOffsetUs, + ); + const outputChunk = + passthroughTimestampOffsetUs === 0 + ? chunk + : this.cloneEncodedAudioChunkWithTimestamp(chunk, normalizedTimestamp); + + await muxer.addAudioChunk( + outputChunk, + wroteAudio + ? undefined + : { + decoderConfig: audioConfig, + }, + ); + wroteAudio = true; + } + } finally { + if (reader) { + try { + await reader.cancel(); + } catch { + // reader already closed + } + } + } + + return wroteAudio; + } + + /** + * Audio export has two modes: + * 1) no speed regions -> fast WebCodecs trim-only pipeline + * 2) speed regions present -> pitch-preserving rendered timeline pipeline + */ + setOnProgress(callback: (progress: number) => void) { + this.onProgress = callback; + } + + async process( + demuxer: WebDemuxer | null, + muxer: VideoMuxer, + videoUrl: string, + trimRegions?: TrimLikeRegion[], + speedRegions?: SpeedRegion[], + readEndSec?: number, + audioRegions?: AudioRegion[], + sourceAudioFallbackPaths?: string[], + sourceAudioFallbackStartDelayMsByPath?: Record<string, number>, + sourceAudioTrackSettings?: SourceAudioTrackSettings, + clipRegions?: ClipRegion[], + ): Promise<void> { + const sortedTrims = trimRegions + ? [...trimRegions].sort((a, b) => a.startMs - b.startMs) + : []; + const sortedSpeedRegions = speedRegions + ? [...speedRegions] + .filter((region) => region.endMs - region.startMs > MIN_SPEED_REGION_DELTA_MS) + .sort((a, b) => a.startMs - b.startMs) + : []; + const sortedAudioRegions = audioRegions + ? [...audioRegions].sort((a, b) => a.startMs - b.startMs) + : []; + const sortedSourceAudioFallbackPaths = sourceAudioFallbackPaths + ? sourceAudioFallbackPaths.filter( + (audioPath) => typeof audioPath === "string" && audioPath.trim().length > 0, + ) + : []; + const routingPolicy = resolveSourceTrackRoutingPolicy( + videoUrl, + sortedSourceAudioFallbackPaths, + ); + const requiresLegacyMacMicSidecarMix = + routingPolicy.includeEmbeddedInExport && + !routingPolicy.hasEmbeddedSourceAudio && + routingPolicy.playbackPaths.length === 1 && + routingPolicy.playbackPaths[0]?.toLowerCase().endsWith(".mic.m4a") === true; + const hasTimedCompanionAudio = routingPolicy.playbackPaths.some( + (audioPath) => (sourceAudioFallbackStartDelayMsByPath?.[audioPath] ?? 0) > 0, + ); + const needsSourceAudioMixing = + routingPolicy.playbackPaths.length > 1 || + (routingPolicy.hasEmbeddedSourceAudio && routingPolicy.playbackPaths.length > 0) || + requiresLegacyMacMicSidecarMix || + hasTimedCompanionAudio; + + // When speed edits, audio regions, or multiple audio sources need mixing, use offline AudioContext pipeline. + if ( + sortedSpeedRegions.length > 0 || + sortedAudioRegions.length > 0 || + needsSourceAudioMixing || + hasNonDefaultSourceTrackSettings(sourceAudioTrackSettings) || + (clipRegions ?? []).some((clip) => Boolean(clip.muted)) + ) { + await this.renderAndMuxOfflineAudio( + videoUrl, + sortedTrims, + sortedSpeedRegions, + sortedAudioRegions, + sortedSourceAudioFallbackPaths, + sourceAudioFallbackStartDelayMsByPath, + sourceAudioTrackSettings, + clipRegions, + muxer, + ); + return; + } + + // Single sidecar audio with no speed/audio edits: demux directly (skips slow real-time rendering). + if (!routingPolicy.hasEmbeddedSourceAudio && routingPolicy.playbackPaths.length === 1) { + const sidecarDemuxer = await this.loadAudioFileDemuxer(routingPolicy.playbackPaths[0]); + if (sidecarDemuxer) { + try { + await this.processTrimOnlyAudio(sidecarDemuxer, muxer, sortedTrims); + } finally { + try { + sidecarDemuxer.destroy(); + } catch { + /* cleanup */ + } + } + return; + } + // Fallback to offline rendering if demuxer creation failed + console.warn( + "[AudioProcessor] Fast sidecar demux failed, falling back to offline rendering", + ); + await this.renderAndMuxOfflineAudio( + videoUrl, + sortedTrims, + [], + [], + routingPolicy.playbackPaths, + sourceAudioFallbackStartDelayMsByPath, + sourceAudioTrackSettings, + clipRegions, + muxer, + ); + return; + } + + // No speed edits or audio regions: keep the original demux/decode/encode path with trim timestamp remap. + if (!demuxer) { + console.warn("[AudioProcessor] No demuxer available, skipping audio"); + return; + } + + if (sortedTrims.length === 0) { + let audioConfig: AudioDecoderConfig; + try { + audioConfig = (await demuxer.getDecoderConfig("audio")) as AudioDecoderConfig; + } catch { + console.warn("[AudioProcessor] No audio track found, skipping"); + return; + } + + const audioStream = + typeof readEndSec === "number" + ? demuxer.read("audio", 0, readEndSec) + : demuxer.read("audio"); + + const copiedSourceAudio = await this.passthroughAudioStream( + audioStream as ReadableStream<EncodedAudioChunk>, + audioConfig, + muxer, + ); + + if (copiedSourceAudio) { + return; + } + } + + await this.processTrimOnlyAudio(demuxer, muxer, sortedTrims, readEndSec); + } + + async renderEditedAudioTrack( + videoUrl: string, + trimRegions?: TrimLikeRegion[], + speedRegions?: SpeedRegion[], + audioRegions?: AudioRegion[], + sourceAudioFallbackPaths?: string[], + sourceAudioFallbackStartDelayMsByPath?: Record<string, number>, + sourceAudioTrackSettings?: SourceAudioTrackSettings, + clipRegions?: ClipRegion[], + ): Promise<Blob> { + const sortedTrims = trimRegions + ? [...trimRegions].sort((a, b) => a.startMs - b.startMs) + : []; + const sortedSpeedRegions = speedRegions + ? [...speedRegions] + .filter((region) => region.endMs - region.startMs > MIN_SPEED_REGION_DELTA_MS) + .sort((a, b) => a.startMs - b.startMs) + : []; + const sortedAudioRegions = audioRegions + ? [...audioRegions].sort((a, b) => a.startMs - b.startMs) + : []; + const sortedSourceAudioFallbackPaths = sourceAudioFallbackPaths + ? sourceAudioFallbackPaths.filter( + (audioPath) => typeof audioPath === "string" && audioPath.trim().length > 0, + ) + : []; + + const prepared = await this.prepareOfflineRender( + videoUrl, + sortedTrims, + sortedSpeedRegions, + sortedAudioRegions, + sortedSourceAudioFallbackPaths, + sourceAudioFallbackStartDelayMsByPath, + sourceAudioTrackSettings, + clipRegions, + ); + return this.renderToWavBlobChunked(prepared); + } + + // Legacy trim-only path used when no speed regions are configured. + private async processTrimOnlyAudio( + demuxer: WebDemuxer, + muxer: VideoMuxer, + sortedTrims: TrimLikeRegion[], + readEndSec?: number, + ): Promise<void> { + let audioConfig: AudioDecoderConfig; + try { + audioConfig = (await demuxer.getDecoderConfig("audio")) as AudioDecoderConfig; + } catch { + console.warn("[AudioProcessor] No audio track found, skipping"); + return; + } + + const codecCheck = await AudioDecoder.isConfigSupported(audioConfig); + if (!codecCheck.supported) { + console.warn("[AudioProcessor] Audio codec not supported:", audioConfig.codec); + return; + } + + const audioStream = + typeof readEndSec === "number" + ? demuxer.read("audio", 0, readEndSec) + : demuxer.read("audio"); + + let sourceTimestampOffsetUs: number | null = null; + + await this.transcodeAudioStream( + audioStream as ReadableStream<EncodedAudioChunk>, + audioConfig, + muxer, + { + observeChunkTimestampUs: (timestampUs) => { + if (sourceTimestampOffsetUs === null) { + sourceTimestampOffsetUs = timestampUs; + } + }, + shouldSkipChunk: (timestampMs) => this.isInTrimRegion(timestampMs, sortedTrims), + transformAudioData: (data) => { + const timestampMs = data.timestamp / 1000; + const trimOffsetMs = this.computeTrimOffset(timestampMs, sortedTrims); + const adjustedTimestampUs = + data.timestamp - (sourceTimestampOffsetUs ?? 0) - trimOffsetMs * 1000; + return this.cloneWithTimestamp(data, Math.max(0, adjustedTimestampUs)); + }, + }, + ); + } + + private async transcodeAudioStream( + audioStream: ReadableStream<EncodedAudioChunk>, + audioConfig: AudioDecoderConfig, + muxer: VideoMuxer, + options: { + observeChunkTimestampUs?: (timestampUs: number) => void; + shouldSkipChunk?: (timestampMs: number) => boolean; + transformAudioData?: (data: AudioData) => AudioData | null; + } = {}, + ): Promise<void> { + const pendingFrames: AudioData[] = []; + let decodeError: Error | null = null; + let encodeError: Error | null = null; + let muxError: Error | null = null; + let pendingMuxing = Promise.resolve(); + const capacityWaiters = new Set<() => void>(); + + const notifyCapacityAvailable = () => { + if (capacityWaiters.size === 0) { + return; + } + + const waiters = [...capacityWaiters]; + capacityWaiters.clear(); + for (const resolve of waiters) { + resolve(); + } + }; + + const waitForCapacity = () => + new Promise<void>((resolve) => { + capacityWaiters.add(resolve); + }); + + const failIfNeeded = () => { + if (decodeError) throw decodeError; + if (encodeError) throw encodeError; + if (muxError) throw muxError; + }; + + const pumpEncodedFrames = () => { + while (!this.cancelled && pendingFrames.length > 0) { + if (encodeError || muxError) { + break; + } + if (encoder.encodeQueueSize >= ENCODE_BACKPRESSURE_LIMIT) { + break; + } + + const frame = pendingFrames.shift(); + if (!frame) { + break; + } + + encoder.encode(frame); + frame.close(); + notifyCapacityAvailable(); + } + }; + + const cleanupPendingFrames = () => { + for (const frame of pendingFrames) { + frame.close(); + } + pendingFrames.length = 0; + }; + + const sampleRate = audioConfig.sampleRate || 48_000; + const channels = audioConfig.numberOfChannels || 2; + const encodeConfig: AudioEncoderConfig = { + codec: MP4_AUDIO_CODEC, + sampleRate, + numberOfChannels: channels, + bitrate: AUDIO_BITRATE, + }; + + const encodeSupport = await AudioEncoder.isConfigSupported(encodeConfig); + if (!encodeSupport.supported) { + console.warn("[AudioProcessor] AAC encoding not supported, skipping audio"); + return; + } + + const encoder = new AudioEncoder({ + output: (chunk: EncodedAudioChunk, meta?: EncodedAudioChunkMetadata) => { + pendingMuxing = pendingMuxing + .then(async () => { + if (this.cancelled) { + return; + } + await muxer.addAudioChunk(chunk, meta); + }) + .catch((error) => { + muxError = error instanceof Error ? error : new Error(String(error)); + notifyCapacityAvailable(); + }); + notifyCapacityAvailable(); + }, + error: (error: DOMException) => { + encodeError = new Error(`[AudioProcessor] Encode error: ${error.message}`); + notifyCapacityAvailable(); + }, + }); + + encoder.configure(encodeConfig); + + const decoder = new AudioDecoder({ + output: (data: AudioData) => { + if (this.cancelled || encodeError || muxError) { + data.close(); + return; + } + + const transformed = options.transformAudioData + ? options.transformAudioData(data) + : data; + + if (transformed !== data) { + data.close(); + } + + if (!transformed) { + return; + } + + pendingFrames.push(transformed); + notifyCapacityAvailable(); + }, + error: (error: DOMException) => { + decodeError = new Error(`[AudioProcessor] Decode error: ${error.message}`); + notifyCapacityAvailable(); + }, + }); + decoder.configure(audioConfig); + + let reader: ReadableStreamDefaultReader<EncodedAudioChunk> | null = null; + + try { + reader = audioStream.getReader(); + while (!this.cancelled) { + failIfNeeded(); + + const { done, value: chunk } = await reader.read(); + if (done || !chunk) break; + + options.observeChunkTimestampUs?.(chunk.timestamp); + const timestampMs = chunk.timestamp / 1000; + if (options.shouldSkipChunk?.(timestampMs)) continue; + + decoder.decode(chunk); + pumpEncodedFrames(); + + while ( + !this.cancelled && + (decoder.decodeQueueSize > DECODE_BACKPRESSURE_LIMIT || + pendingFrames.length > DECODE_BACKPRESSURE_LIMIT || + encoder.encodeQueueSize >= ENCODE_BACKPRESSURE_LIMIT) + ) { + failIfNeeded(); + pumpEncodedFrames(); + await waitForCapacity(); + } + } + + if (decoder.state === "configured") { + await decoder.flush(); + } + + while (!this.cancelled && (pendingFrames.length > 0 || encoder.encodeQueueSize > 0)) { + failIfNeeded(); + pumpEncodedFrames(); + if (pendingFrames.length > 0 || encoder.encodeQueueSize > 0) { + await waitForCapacity(); + } + } + + failIfNeeded(); + + if (encoder.state === "configured") { + await encoder.flush(); + } + + await pendingMuxing; + failIfNeeded(); + } finally { + notifyCapacityAvailable(); + if (reader) { + try { + await reader.cancel(); + } catch { + // reader already closed + } + } + + cleanupPendingFrames(); + + if (decoder.state === "configured") { + decoder.close(); + } + + if (encoder.state === "configured") { + encoder.close(); + } + } + + if (this.cancelled) { + return; + } + } + + // ---------- Offline audio rendering pipeline ---------- + // Replaces the old real-time MediaElement+MediaRecorder approach with + // OfflineAudioContext, which renders as fast as the CPU allows instead of + // waiting for 1× real-time playback. + + private async renderAndMuxOfflineAudio( + videoUrl: string, + trimRegions: TrimLikeRegion[], + speedRegions: SpeedRegion[], + audioRegions: AudioRegion[], + sourceAudioFallbackPaths: string[], + sourceAudioFallbackStartDelayMsByPath: Record<string, number> | undefined, + sourceAudioTrackSettings: SourceAudioTrackSettings | undefined, + clipRegions: ClipRegion[] | undefined, + muxer: VideoMuxer, + ): Promise<void> { + const prepared = await this.prepareOfflineRender( + videoUrl, + trimRegions, + speedRegions, + audioRegions, + sourceAudioFallbackPaths, + sourceAudioFallbackStartDelayMsByPath, + sourceAudioTrackSettings, + clipRegions, + ); + if (this.cancelled) return; + await this.renderAndEncodeChunked(prepared, muxer); + } + + private async prepareOfflineRender( + videoUrl: string, + trimRegions: TrimLikeRegion[], + speedRegions: SpeedRegion[], + audioRegions: AudioRegion[], + sourceAudioFallbackPaths: string[], + sourceAudioFallbackStartDelayMsByPath?: Record<string, number>, + sourceAudioTrackSettings?: SourceAudioTrackSettings, + clipRegions?: ClipRegion[], + ): Promise<PreparedOfflineRender> { + if (this.cancelled) throw new Error("Export cancelled"); + this.onProgress?.(0); + + const resolvedPlan = buildResolvedAudioPlan({ + videoResource: videoUrl, + sourceAudioFallbackPaths, + audioRegions, + sourceTrackGainById: { + mic: resolveSourceTrackGain(sourceAudioTrackSettings, "mic"), + system: resolveSourceTrackGain(sourceAudioTrackSettings, "system"), + mixed: resolveSourceTrackGain(sourceAudioTrackSettings, "mixed"), + }, + embeddedGain: Math.max( + 0, + Math.min( + 2, + sourceAudioTrackSettings?.mixed + ? resolveSourceTrackGain(sourceAudioTrackSettings, "mixed") + : sourceAudioTrackSettings?.system + ? resolveSourceTrackGain(sourceAudioTrackSettings, "system") + : 1, + ), + ), + }); + + // Decode embedded source audio separately from companion sidecars. + const mainBuffer = resolvedPlan.includeEmbeddedInExport + ? await this.decodeAudioFromUrl(videoUrl) + : null; + const mainBufferGain = resolveSourceTrackGain(sourceAudioTrackSettings, "mixed"); + const mainBufferEntry = mainBuffer ? { buffer: mainBuffer, gain: mainBufferGain } : null; + if (this.cancelled) throw new Error("Export cancelled"); + + // Decode companion / sidecar audio files + const companionEntries: Array<{ + buffer: AudioBuffer; + startDelaySec: number; + gain: number; + }> = []; + const refDuration = + mainBuffer?.duration ?? + (resolvedPlan.playbackPaths.length > 0 ? await this.getMediaDurationSec(videoUrl) : 0); + for (const audioPath of resolvedPlan.playbackPaths) { + if (this.cancelled) throw new Error("Export cancelled"); + const buffer = await this.decodeAudioFromUrl(audioPath); + if (!buffer) continue; + + companionEntries.push({ + buffer, + gain: resolveSourceTrackGain( + sourceAudioTrackSettings, + getSourceTrackIdFromPath(audioPath), + ), + startDelaySec: estimateCompanionAudioStartDelaySeconds( + refDuration, + buffer.duration, + sourceAudioFallbackStartDelayMsByPath?.[audioPath], + ), + }); + } + if (this.cancelled) throw new Error("Export cancelled"); + + // Decode audio region overlay files + const regionEntries: Array<{ buffer: AudioBuffer; region: AudioRegion }> = []; + for (const region of audioRegions) { + if (this.cancelled) throw new Error("Export cancelled"); + const buffer = await this.decodeAudioFromUrl(region.audioPath); + if (buffer) regionEntries.push({ buffer, region }); + } + + this.onProgress?.(0.2); + + // Determine source duration for timeline calculation + const primaryBuffer = mainBufferEntry?.buffer ?? companionEntries[0]?.buffer ?? null; + if (!primaryBuffer && regionEntries.length === 0) { + throw new Error("No decodable audio sources found"); + } + + let sourceDurationSec: number; + if (mainBufferEntry?.buffer) { + sourceDurationSec = mainBufferEntry.buffer.duration; + } else if (resolvedPlan.playbackPaths.length > 0 || regionEntries.length > 0) { + sourceDurationSec = await this.getMediaDurationSec(videoUrl); + } else { + sourceDurationSec = primaryBuffer?.duration ?? 0; + } + const sourceDurationMs = sourceDurationSec * 1000; + + // Build timeline slices (non-trimmed segments with speed info) + const slices = this.buildTimelineSlices(sourceDurationMs, trimRegions, speedRegions); + + let outputDurationMs = 0; + for (const slice of slices) { + outputDurationMs += (slice.sourceEndMs - slice.sourceStartMs) / slice.speed; + } + + // Extend for audio regions that might exceed the video timeline + for (const { region } of regionEntries) { + const regionEndOutput = this.sourceTimeToOutputTime(region.endMs, slices); + outputDurationMs = Math.max(outputDurationMs, regionEndOutput); + } + + const numChannels = Math.min(primaryBuffer?.numberOfChannels ?? 2, 2); + const mutedSourceOutputRangesSec = (clipRegions ?? []) + .filter( + (clip) => + Boolean(clip.muted) && + Number.isFinite(clip.startMs) && + Number.isFinite(clip.endMs) && + clip.endMs > clip.startMs, + ) + .map((clip) => ({ + startSec: Math.max(0, clip.startMs / 1000), + endSec: Math.max(0, clip.endMs / 1000), + })); + + return { + mainBufferEntry, + companionEntries, + regionEntries, + mutedSourceOutputRangesSec, + slices, + outputDurationMs, + numChannels, + }; + } + + // Render timeline in chunks and encode each chunk to the muxer immediately. + // Memory is bounded to ~OFFLINE_CHUNK_DURATION_SEC of PCM per chunk + // instead of holding the entire output buffer in memory. + private async renderAndEncodeChunked( + prepared: PreparedOfflineRender, + muxer: VideoMuxer, + ): Promise<void> { + const { numChannels } = prepared; + const totalOutputSec = Math.max(prepared.outputDurationMs / 1000, 0.01); + + let encodeError: Error | null = null; + let muxError: Error | null = null; + let pendingMuxing = Promise.resolve(); + let wroteFirstChunk = false; + + const encodeConfig: AudioEncoderConfig = { + codec: MP4_AUDIO_CODEC, + sampleRate: OFFLINE_AUDIO_SAMPLE_RATE, + numberOfChannels: numChannels, + bitrate: AUDIO_BITRATE, + }; + + const supported = await AudioEncoder.isConfigSupported(encodeConfig); + if (!supported.supported) { + console.warn("[AudioProcessor] AAC encoding not supported for offline audio"); + return; + } + + const encoder = new AudioEncoder({ + output: (chunk: EncodedAudioChunk, meta?: EncodedAudioChunkMetadata) => { + pendingMuxing = pendingMuxing + .then(async () => { + if (this.cancelled) return; + await muxer.addAudioChunk(chunk, !wroteFirstChunk ? meta : undefined); + wroteFirstChunk = true; + }) + .catch((error) => { + muxError = error instanceof Error ? error : new Error(String(error)); + }); + }, + error: (error: DOMException) => { + encodeError = new Error(`Audio encode error: ${error.message}`); + }, + }); + encoder.configure(encodeConfig); + + try { + await this.renderChunked( + prepared, + totalOutputSec, + async (rendered, outputOffsetSec) => { + if (encodeError) throw encodeError; + if (muxError) throw muxError; + await this.feedBufferToEncoder(encoder, rendered, outputOffsetSec); + }, + ); + + if (encodeError) throw encodeError; + if (muxError) throw muxError; + + if (encoder.state === "configured") { + await encoder.flush(); + } + + await pendingMuxing; + + if (encodeError) throw encodeError; + if (muxError) throw muxError; + } finally { + if (encoder.state === "configured") { + encoder.close(); + } + } + } + + // Render timeline to a WAV blob for the native/FFmpeg export path. + // Processes in chunks to avoid holding the entire output in memory. + private async renderToWavBlobChunked(prepared: PreparedOfflineRender): Promise<Blob> { + const totalOutputSec = Math.max(prepared.outputDurationMs / 1000, 0.01); + const totalFrames = Math.ceil(totalOutputSec * OFFLINE_AUDIO_SAMPLE_RATE); + const numChannels = prepared.numChannels; + + const header = this.createWavHeader(OFFLINE_AUDIO_SAMPLE_RATE, numChannels, totalFrames); + const pcmParts: ArrayBuffer[] = [header]; + + await this.renderChunked(prepared, totalOutputSec, async (rendered) => { + pcmParts.push(...this.audioBufferToPcmParts(rendered)); + }); + + return new Blob(pcmParts, { type: "audio/wav" }); + } + + // Shared chunked rendering loop. Processes the timeline in + // OFFLINE_CHUNK_DURATION_SEC segments, calling onChunk for each rendered buffer. + private async renderChunked( + prepared: PreparedOfflineRender, + totalOutputSec: number, + onChunk: ( + rendered: AudioBuffer, + outputOffsetSec: number, + chunkIndex: number, + ) => Promise<void>, + ): Promise<void> { + const { slices, numChannels } = prepared; + let outputOffsetSec = 0; + const chunkCount = Math.ceil(totalOutputSec / OFFLINE_CHUNK_DURATION_SEC); + + for (let i = 0; i < chunkCount && !this.cancelled; i++) { + const chunkSec = Math.min(OFFLINE_CHUNK_DURATION_SEC, totalOutputSec - outputOffsetSec); + const chunkFrames = Math.ceil(chunkSec * OFFLINE_AUDIO_SAMPLE_RATE); + + const offlineCtx = new OfflineAudioContext( + numChannels, + chunkFrames, + OFFLINE_AUDIO_SAMPLE_RATE, + ); + + // Schedule main audio + if (prepared.mainBufferEntry) { + this.scheduleBufferThroughTimeline( + offlineCtx, + prepared.mainBufferEntry.buffer, + slices, + 0, + prepared.mainBufferEntry.gain, + outputOffsetSec, + chunkSec, + prepared.mutedSourceOutputRangesSec, + ); + } + + // Schedule companion/sidecar audio + for (const entry of prepared.companionEntries) { + this.scheduleBufferThroughTimeline( + offlineCtx, + entry.buffer, + slices, + entry.startDelaySec, + entry.gain, + outputOffsetSec, + chunkSec, + prepared.mutedSourceOutputRangesSec, + ); + } + + // Schedule audio region overlays + for (const { buffer, region } of prepared.regionEntries) { + this.scheduleRegionForChunk( + offlineCtx, + buffer, + region, + slices, + outputOffsetSec, + chunkSec, + ); + } + + const rendered = await offlineCtx.startRendering(); + if (this.cancelled) break; + softLimitOfflineMixPeaksInPlace(rendered); + + await onChunk(rendered, outputOffsetSec, i); + + outputOffsetSec += chunkSec; + this.onProgress?.(0.3 + (outputOffsetSec / totalOutputSec) * 0.7); + } + } + + // Schedule an audio region overlay clipped to a specific chunk window. + private scheduleRegionForChunk( + ctx: OfflineAudioContext, + buffer: AudioBuffer, + region: AudioRegion, + slices: TimelineSlice[], + chunkOutputStartSec: number, + chunkDurationSec: number, + ): void { + const outputStartMs = this.sourceTimeToOutputTime(region.startMs, slices); + const outputEndMs = this.sourceTimeToOutputTime(region.endMs, slices); + + let localStartSec = outputStartMs / 1000 - chunkOutputStartSec; + let localEndSec = outputEndMs / 1000 - chunkOutputStartSec; + + // Skip if region doesn't overlap with this chunk + if (localEndSec <= 0 || localStartSec >= chunkDurationSec) return; + + // Clip to chunk bounds + let bufferOffsetSec = 0; + if (localStartSec < 0) { + bufferOffsetSec = -localStartSec; + localStartSec = 0; + } + if (localEndSec > chunkDurationSec) { + localEndSec = chunkDurationSec; + } + + const duration = Math.min(localEndSec - localStartSec, buffer.duration - bufferOffsetSec); + if (duration <= 0.001) return; + + const gainNode = ctx.createGain(); + const normalizeGain = region.normalize ? SOURCE_AUDIO_NORMALIZE_GAIN : 1; + gainNode.gain.value = Math.max(0, Math.min(1, region.volume * normalizeGain)); + gainNode.connect(ctx.destination); + + const source = ctx.createBufferSource(); + source.buffer = buffer; + source.connect(gainNode); + source.start(localStartSec, bufferOffsetSec, duration); + } + + // Feed a rendered AudioBuffer chunk to an AudioEncoder with a timestamp offset. + private async feedBufferToEncoder( + encoder: AudioEncoder, + buffer: AudioBuffer, + timestampOffsetSec: number, + ): Promise<void> { + const sampleRate = buffer.sampleRate; + const numChannels = buffer.numberOfChannels; + const totalFrames = buffer.length; + + for ( + let offset = 0; + offset < totalFrames && !this.cancelled; + offset += OFFLINE_ENCODE_CHUNK_FRAMES + ) { + const frameCount = Math.min(OFFLINE_ENCODE_CHUNK_FRAMES, totalFrames - offset); + + const planarData = new Float32Array(frameCount * numChannels); + for (let ch = 0; ch < numChannels; ch++) { + const channelData = buffer.getChannelData(ch); + planarData.set(channelData.subarray(offset, offset + frameCount), ch * frameCount); + } + + const audioData = new AudioData({ + format: "f32-planar", + sampleRate, + numberOfFrames: frameCount, + numberOfChannels: numChannels, + timestamp: Math.round((offset / sampleRate + timestampOffsetSec) * 1_000_000), + data: planarData, + }); + + encoder.encode(audioData); + audioData.close(); + + while (encoder.encodeQueueSize >= ENCODE_BACKPRESSURE_LIMIT && !this.cancelled) { + await new Promise((r) => setTimeout(r, 1)); + } + } + } + + // Decode audio from a URL using streaming WebCodecs decode with bulk fallback. + // Streaming decode avoids holding the full compressed file in memory alongside + // the decoded AudioBuffer, reducing peak memory for large recordings. + private async decodeAudioFromUrl(url: string): Promise<AudioBuffer | null> { + try { + const buffer = await this.streamDecodeFromUrl(url); + if (buffer) return buffer; + } catch (error) { + console.warn( + "[AudioProcessor] Streaming decode failed, falling back to bulk decode:", + url, + error, + ); + } + return this.bulkDecodeFromUrl(url, OFFLINE_AUDIO_SAMPLE_RATE); + } + + // Streaming decode via WebDemuxer + AudioDecoder. Decodes audio chunk-by-chunk + // without loading the entire compressed file into a contiguous ArrayBuffer. + private async streamDecodeFromUrl(url: string): Promise<AudioBuffer | null> { + const source = await resolveMediaElementSource(url); + let demuxer: WebDemuxer | null = null; + + try { + const wasmUrl = new URL("./wasm/web-demuxer.wasm", window.location.href).href; + demuxer = new WebDemuxer({ wasmFilePath: wasmUrl }); + await demuxer.load(source.src); + + let audioConfig: AudioDecoderConfig; + try { + audioConfig = (await demuxer.getDecoderConfig("audio")) as AudioDecoderConfig; + } catch { + return null; // No audio track + } + + const sampleRate = audioConfig.sampleRate || 48_000; + const numChannels = Math.min(audioConfig.numberOfChannels || 2, 2); + + // Accumulate decoded PCM per channel + const channelChunks: Float32Array[][] = Array.from({ length: numChannels }, () => []); + let totalFrames = 0; + let decodeError: Error | null = null; + const decodeCapacityWaiters = new Set<() => void>(); + + const notifyDecodeCapacityAvailable = () => { + if (decodeCapacityWaiters.size === 0) { + return; + } + + const waiters = [...decodeCapacityWaiters]; + decodeCapacityWaiters.clear(); + for (const resolve of waiters) { + resolve(); + } + }; + + const waitForDecodeCapacity = () => + new Promise<void>((resolve) => { + decodeCapacityWaiters.add(resolve); + }); + + const decoder = new AudioDecoder({ + output: (data: AudioData) => { + try { + const frames = data.numberOfFrames; + const dataChannels = Math.min(data.numberOfChannels, numChannels); + const format = data.format; + + if (format?.includes("planar")) { + for (let ch = 0; ch < dataChannels; ch++) { + const size = data.allocationSize({ + planeIndex: ch, + }); + const bytes = new ArrayBuffer(size); + data.copyTo(bytes, { planeIndex: ch }); + channelChunks[ch].push(this.rawToFloat32(bytes, format, frames)); + } + } else if (format) { + // Interleaved format — deinterleave into per-channel arrays. + // Use data.numberOfChannels as stride (not capped dataChannels) + // since the raw buffer contains all source channels. + const srcChannels = data.numberOfChannels; + const size = data.allocationSize({ planeIndex: 0 }); + const bytes = new ArrayBuffer(size); + data.copyTo(bytes, { planeIndex: 0 }); + const interleaved = this.rawToFloat32( + bytes, + format, + frames * srcChannels, + ); + for (let ch = 0; ch < dataChannels; ch++) { + const chData = new Float32Array(frames); + for (let i = 0; i < frames; i++) { + chData[i] = interleaved[i * srcChannels + ch]; + } + channelChunks[ch].push(chData); + } + } + + // Fill missing channels with silence + for (let ch = dataChannels; ch < numChannels; ch++) { + channelChunks[ch].push(new Float32Array(frames)); + } + + totalFrames += frames; + } finally { + data.close(); + notifyDecodeCapacityAvailable(); + } + }, + error: (err: DOMException) => { + decodeError = new Error(`Streaming audio decode error: ${err.message}`); + notifyDecodeCapacityAvailable(); + }, + }); + + decoder.configure(audioConfig); + + const audioStream = demuxer.read("audio"); + const reader = (audioStream as ReadableStream<EncodedAudioChunk>).getReader(); + + try { + while (!this.cancelled) { + if (decodeError) throw decodeError; + const { done, value: chunk } = await reader.read(); + if (done || !chunk) break; + + decoder.decode(chunk); + + while (decoder.decodeQueueSize > DECODE_BACKPRESSURE_LIMIT && !this.cancelled) { + if (decodeError) throw decodeError; + await waitForDecodeCapacity(); + } + } + + if (decoder.state === "configured") { + await decoder.flush(); + } + if (decodeError) throw decodeError; + } finally { + notifyDecodeCapacityAvailable(); + try { + await reader.cancel(); + } catch { + /* reader already closed */ + } + if (decoder.state === "configured") { + decoder.close(); + } + } + + if (totalFrames === 0) return null; + + // Build AudioBuffer from accumulated chunks + const audioBuffer = new AudioBuffer({ + length: totalFrames, + numberOfChannels: numChannels, + sampleRate, + }); + for (let ch = 0; ch < numChannels; ch++) { + const channelData = audioBuffer.getChannelData(ch); + let writeOffset = 0; + for (const chunk of channelChunks[ch]) { + channelData.set(chunk, writeOffset); + writeOffset += chunk.length; + } + } + + return audioBuffer; + } finally { + source.revoke(); + try { + demuxer?.destroy(); + } catch { + /* cleanup */ + } + } + } + + // Convert raw bytes from AudioData to Float32Array based on the sample format. + private rawToFloat32(bytes: ArrayBuffer, format: string, sampleCount: number): Float32Array { + if (format.startsWith("f32")) { + return new Float32Array(bytes); + } + if (format.startsWith("s16")) { + const int16 = new Int16Array(bytes); + const f32 = new Float32Array(sampleCount); + for (let i = 0; i < sampleCount; i++) { + f32[i] = int16[i] / 0x8000; + } + return f32; + } + if (format.startsWith("s32")) { + const int32 = new Int32Array(bytes); + const f32 = new Float32Array(sampleCount); + for (let i = 0; i < sampleCount; i++) { + f32[i] = int32[i] / 0x80000000; + } + return f32; + } + if (format.startsWith("u8")) { + const uint8 = new Uint8Array(bytes); + const f32 = new Float32Array(sampleCount); + for (let i = 0; i < sampleCount; i++) { + f32[i] = (uint8[i] - 128) / 128; + } + return f32; + } + // Unknown format — attempt float32 interpretation + return new Float32Array(bytes); + } + + // Bulk decode fallback: loads entire file into memory and uses decodeAudioData. + private async bulkDecodeFromUrl(url: string, sampleRate: number): Promise<AudioBuffer | null> { + try { + const source = await resolveMediaElementSource(url); + try { + const response = await fetch(source.src); + const arrayBuffer = await response.arrayBuffer(); + const tempCtx = new OfflineAudioContext(2, 1, sampleRate); + return await tempCtx.decodeAudioData(arrayBuffer); + } finally { + source.revoke(); + } + } catch (error) { + console.warn("[AudioProcessor] Failed to decode audio from URL:", url, error); + return null; + } + } + + // Get the duration of a media file by loading only its metadata. + private async getMediaDurationSec(url: string): Promise<number> { + const source = await resolveMediaElementSource(url); + try { + const media = document.createElement("video"); + media.preload = "metadata"; + media.src = source.src; + + return await new Promise<number>((resolve, reject) => { + const timeout = setTimeout(() => { + cleanup(); + media.src = ""; + media.load(); + reject(new Error("Timed out getting media duration (30s)")); + }, 30_000); + + const onLoaded = () => { + cleanup(); + const duration = media.duration; + media.src = ""; + media.load(); + resolve(Number.isFinite(duration) ? duration : 0); + }; + const onError = () => { + cleanup(); + media.src = ""; + media.load(); + reject(new Error("Failed to get media duration")); + }; + const cleanup = () => { + clearTimeout(timeout); + media.removeEventListener("loadedmetadata", onLoaded); + media.removeEventListener("error", onError); + }; + + media.addEventListener("loadedmetadata", onLoaded); + media.addEventListener("error", onError, { once: true }); + }); + } finally { + source.revoke(); + } + } + + // Build non-overlapping timeline slices from the source timeline, excluding + // trimmed regions and tagging each slice with its playback speed. + private buildTimelineSlices( + sourceDurationMs: number, + trimRegions: TrimLikeRegion[], + speedRegions: SpeedRegion[], + ): TimelineSlice[] { + const boundaries = new Set<number>(); + boundaries.add(0); + boundaries.add(sourceDurationMs); + + for (const trim of trimRegions) { + if (trim.startMs >= 0 && trim.startMs <= sourceDurationMs) boundaries.add(trim.startMs); + if (trim.endMs >= 0 && trim.endMs <= sourceDurationMs) boundaries.add(trim.endMs); + } + for (const speed of speedRegions) { + if (speed.startMs >= 0 && speed.startMs <= sourceDurationMs) + boundaries.add(speed.startMs); + if (speed.endMs >= 0 && speed.endMs <= sourceDurationMs) boundaries.add(speed.endMs); + } + + const sorted = [...boundaries].sort((a, b) => a - b); + const slices: TimelineSlice[] = []; + + for (let i = 0; i < sorted.length - 1; i++) { + const start = sorted[i]; + const end = sorted[i + 1]; + if (end - start < 0.001) continue; + + // Skip segments entirely inside a trim region + const midpoint = (start + end) / 2; + if (this.isInTrimRegion(midpoint, trimRegions)) continue; + + const speedRegion = speedRegions.find( + (s) => midpoint >= s.startMs && midpoint < s.endMs, + ); + + slices.push({ + sourceStartMs: start, + sourceEndMs: end, + speed: speedRegion?.speed ?? 1, + }); + } + + return slices; + } + + // Map a source-timeline timestamp to the corresponding output-timeline timestamp. + private sourceTimeToOutputTime(sourceMs: number, slices: TimelineSlice[]): number { + let outputMs = 0; + + for (const slice of slices) { + if (sourceMs <= slice.sourceStartMs) { + return outputMs; + } + const sliceDurationMs = slice.sourceEndMs - slice.sourceStartMs; + if (sourceMs >= slice.sourceEndMs) { + outputMs += sliceDurationMs / slice.speed; + continue; + } + // Source time falls within this slice + outputMs += (sourceMs - slice.sourceStartMs) / slice.speed; + return outputMs; + } + + return outputMs; + } + + // Schedule an AudioBuffer through the timeline slices in an OfflineAudioContext. + // Each non-trimmed segment creates an AudioBufferSourceNode with the appropriate + // playbackRate for speed regions. When chunkOutputStartSec/chunkDurationSec are + // provided, only sources overlapping the chunk window are scheduled. + private scheduleBufferThroughTimeline( + ctx: OfflineAudioContext, + buffer: AudioBuffer, + slices: TimelineSlice[], + sourceStartDelaySec: number, + gain = 1, + chunkOutputStartSec = 0, + chunkDurationSec = Number.POSITIVE_INFINITY, + mutedOutputRangesSec: Array<{ startSec: number; endSec: number }> = [], + ): void { + let outputOffsetSec = 0; + + for (const slice of slices) { + const sliceSourceDurationSec = (slice.sourceEndMs - slice.sourceStartMs) / 1000; + const sliceOutputDurationSec = sliceSourceDurationSec / slice.speed; + + // Where in the buffer does this slice read from? + const bufferOffsetSec = slice.sourceStartMs / 1000 - sourceStartDelaySec; + + // Skip if slice doesn't overlap with the buffer at all + if ( + bufferOffsetSec + sliceSourceDurationSec <= 0 || + bufferOffsetSec >= buffer.duration + ) { + outputOffsetSec += sliceOutputDurationSec; + continue; + } + + // Clamp to buffer bounds + let effectiveBufferStartSec = Math.max(0, bufferOffsetSec); + const trimmedFromStartSec = effectiveBufferStartSec - bufferOffsetSec; + let effectiveSourceDurationSec = Math.min( + sliceSourceDurationSec - trimmedFromStartSec, + buffer.duration - effectiveBufferStartSec, + ); + + if (effectiveSourceDurationSec <= 0.001) { + outputOffsetSec += sliceOutputDurationSec; + continue; + } + + // Calculate output position (global then chunk-local) + let localOutputStartSec = + outputOffsetSec + trimmedFromStartSec / slice.speed - chunkOutputStartSec; + let localOutputEndSec = localOutputStartSec + effectiveSourceDurationSec / slice.speed; + + // Skip if entirely outside chunk window + if (localOutputEndSec <= 0 || localOutputStartSec >= chunkDurationSec) { + outputOffsetSec += sliceOutputDurationSec; + continue; + } + + // Clip to chunk start + if (localOutputStartSec < 0) { + const skipOutputSec = -localOutputStartSec; + const skipSourceSec = skipOutputSec * slice.speed; + effectiveBufferStartSec += skipSourceSec; + effectiveSourceDurationSec -= skipSourceSec; + localOutputStartSec = 0; + } + + // Clip to chunk end + if (localOutputEndSec > chunkDurationSec) { + const excessOutputSec = localOutputEndSec - chunkDurationSec; + effectiveSourceDurationSec -= excessOutputSec * slice.speed; + } + + if (effectiveSourceDurationSec <= 0.001) { + outputOffsetSec += sliceOutputDurationSec; + continue; + } + + const audibleRanges: Array<{ startSec: number; endSec: number }> = [ + { + startSec: localOutputStartSec + chunkOutputStartSec, + endSec: + localOutputStartSec + + chunkOutputStartSec + + effectiveSourceDurationSec / slice.speed, + }, + ]; + for (const mutedRange of mutedOutputRangesSec) { + for (let rangeIndex = audibleRanges.length - 1; rangeIndex >= 0; rangeIndex -= 1) { + const current = audibleRanges[rangeIndex]; + const overlapStart = Math.max(current.startSec, mutedRange.startSec); + const overlapEnd = Math.min(current.endSec, mutedRange.endSec); + if (overlapEnd <= overlapStart) { + continue; + } + audibleRanges.splice(rangeIndex, 1); + if (current.startSec < overlapStart) { + audibleRanges.push({ startSec: current.startSec, endSec: overlapStart }); + } + if (overlapEnd < current.endSec) { + audibleRanges.push({ startSec: overlapEnd, endSec: current.endSec }); + } + } + } + + for (const audibleRange of audibleRanges) { + const audibleDurationSec = audibleRange.endSec - audibleRange.startSec; + if (audibleDurationSec <= 0.001) { + continue; + } + const source = ctx.createBufferSource(); + const gainNode = ctx.createGain(); + gainNode.gain.value = Math.max(0, Math.min(2, gain)); + + const sourceOffsetSec = + effectiveBufferStartSec + + (audibleRange.startSec - (localOutputStartSec + chunkOutputStartSec)) * + slice.speed; + const localStartSec = audibleRange.startSec - chunkOutputStartSec; + const sourceDurationSec = audibleDurationSec * slice.speed; + + const stretchedBuffer = this.stretchAudioBuffer( + buffer, + slice.speed, + sourceOffsetSec, + sourceDurationSec, + audibleDurationSec, + ctx, + ); + + source.buffer = stretchedBuffer; + source.playbackRate.value = 1; + source.connect(gainNode); + gainNode.connect(ctx.destination); + + source.start(localStartSec); + } + + outputOffsetSec += sliceOutputDurationSec; + } + } + + private stretchAudioBuffer( + originalBuffer: AudioBuffer, + speed: number, + sourceOffsetSec: number, + sourceDurationSec: number, + audibleDurationSec: number, + ctx: BaseAudioContext, + ): AudioBuffer { + const sampleRate = originalBuffer.sampleRate; + const channels = originalBuffer.numberOfChannels; + + const startSample = Math.max(0, Math.floor(sourceOffsetSec * sampleRate)); + const sourceSamples = Math.floor(sourceDurationSec * sampleRate); + const endSample = Math.min(originalBuffer.length, startSample + sourceSamples); + + const outSamples = Math.floor(audibleDurationSec * sampleRate); + if (outSamples <= 0 || startSample >= originalBuffer.length) { + return ctx.createBuffer(channels, 1, sampleRate); + } + + const outBuffer = ctx.createBuffer(channels, outSamples, sampleRate); + + if (Math.abs(speed - 1) < 0.001) { + const copyLength = Math.min(endSample - startSample, outSamples); + if (copyLength > 0) { + for (let c = 0; c < channels; c++) { + outBuffer.copyToChannel( + originalBuffer + .getChannelData(c) + .subarray(startSample, startSample + copyLength), + c, + ); + } + } + return outBuffer; + } + + // WSOLA uses windowing which causes fade-in at the start and fade-out at the end. + // To avoid clicks at chunk boundaries, we render with 100ms of padding and trim it. + const paddingSec = 0.1; + const paddingOutSamples = Math.floor(sampleRate * paddingSec); + const paddingInSamples = Math.floor(paddingOutSamples * speed); + + const workStartIn = Math.max(0, startSample - paddingInSamples); + const workEndIn = Math.min(originalBuffer.length, endSample + paddingInSamples); + + const actualPaddingInStart = startSample - workStartIn; + // We expect the output offset for the requested start to be roughly: + const actualPaddingOutStart = Math.floor(actualPaddingInStart / speed); + + const windowSize = Math.floor(sampleRate * 0.04); + const hopOut = Math.floor(windowSize * 0.5); + const hopIn = Math.floor(hopOut * speed); + const searchRange = Math.floor(sampleRate * 0.015); + + const workOutSamples = Math.floor((workEndIn - workStartIn) / speed) + windowSize * 2; + const workOutBuffer = ctx.createBuffer(channels, workOutSamples, sampleRate); + + const inDataByChannel = Array.from({ length: channels }, (_, c) => + originalBuffer.getChannelData(c), + ); + const workOutDataByChannel = Array.from({ length: channels }, (_, c) => + workOutBuffer.getChannelData(c), + ); + + const window = new Float32Array(windowSize); + for (let i = 0; i < windowSize; i++) { + window[i] = 0.5 * (1 - Math.cos((2 * Math.PI * i) / (windowSize - 1))); + } + + let inOffset = workStartIn; + let outOffset = 0; + + // Initial window + for (let i = 0; i < windowSize; i++) { + if (inOffset + i < workEndIn && outOffset + i < workOutSamples) { + for (let c = 0; c < channels; c++) { + workOutDataByChannel[c][outOffset + i] += + inDataByChannel[c][inOffset + i] * window[i]; + } + } + } + + outOffset += hopOut; + inOffset += hopIn; + + while (outOffset + windowSize < workOutSamples && inOffset + windowSize < workEndIn) { + let bestOffset = inOffset; + const minSearch = Math.max(workStartIn, inOffset - searchRange); + const maxSearch = Math.min(workEndIn - windowSize, inOffset + searchRange); + + if (maxSearch > minSearch) { + let maxCorr = -Infinity; + let bestDelta = 0; + + for (let testOffset = minSearch; testOffset <= maxSearch; testOffset += 4) { + let corr = 0; + for (let i = 0; i < hopOut; i += 4) { + if (outOffset + i < workOutSamples && testOffset + i < workEndIn) { + for (let c = 0; c < channels; c++) { + corr += + workOutDataByChannel[c][outOffset + i] * + inDataByChannel[c][testOffset + i]; + } + } + } + if (corr > maxCorr) { + maxCorr = corr; + bestDelta = testOffset - inOffset; + } + } + bestOffset = inOffset + bestDelta; + } + + for (let i = 0; i < windowSize; i++) { + if (bestOffset + i < workEndIn && outOffset + i < workOutSamples) { + for (let c = 0; c < channels; c++) { + workOutDataByChannel[c][outOffset + i] += + inDataByChannel[c][bestOffset + i] * window[i]; + } + } + } + + outOffset += hopOut; + inOffset += hopIn; + } + + // Transfer the stable middle portion to the final buffer + for (let c = 0; c < channels; c++) { + const finalData = outBuffer.getChannelData(c); + const tempData = workOutBuffer.getChannelData(c); + for (let i = 0; i < outSamples; i++) { + const srcIdx = actualPaddingOutStart + i; + if (srcIdx < workOutSamples) { + finalData[i] = tempData[srcIdx]; + } + } + } + + return outBuffer; + } + + // Create a WAV file header for the given audio parameters. + private createWavHeader( + sampleRate: number, + numChannels: number, + totalFrames: number, + ): ArrayBuffer { + const bytesPerSample = 2; // 16-bit PCM + const dataSize = totalFrames * numChannels * bytesPerSample; + const headerSize = 44; + const header = new ArrayBuffer(headerSize); + const view = new DataView(header); + + const writeString = (offset: number, str: string) => { + for (let i = 0; i < str.length; i++) { + view.setUint8(offset + i, str.charCodeAt(i)); + } + }; + + writeString(0, "RIFF"); + view.setUint32(4, headerSize - 8 + dataSize, true); + writeString(8, "WAVE"); + writeString(12, "fmt "); + view.setUint32(16, 16, true); + view.setUint16(20, 1, true); // PCM format + view.setUint16(22, numChannels, true); + view.setUint32(24, sampleRate, true); + view.setUint32(28, sampleRate * numChannels * bytesPerSample, true); + view.setUint16(32, numChannels * bytesPerSample, true); + view.setUint16(34, bytesPerSample * 8, true); + writeString(36, "data"); + view.setUint32(40, dataSize, true); + + return header; + } + + // Convert an AudioBuffer to chunked 16-bit PCM ArrayBuffers. + // Returns small (~256KB) pieces instead of one massive allocation. + private audioBufferToPcmParts(buffer: AudioBuffer): ArrayBuffer[] { + const PCM_CHUNK_FRAMES = 65536; + const numChannels = buffer.numberOfChannels; + const numFrames = buffer.length; + const bytesPerSample = 2; + const parts: ArrayBuffer[] = []; + + const channels: Float32Array[] = []; + for (let ch = 0; ch < numChannels; ch++) { + channels.push(buffer.getChannelData(ch)); + } + + for (let frameOffset = 0; frameOffset < numFrames; frameOffset += PCM_CHUNK_FRAMES) { + const chunkFrames = Math.min(PCM_CHUNK_FRAMES, numFrames - frameOffset); + const chunkBuffer = new ArrayBuffer(chunkFrames * numChannels * bytesPerSample); + const view = new DataView(chunkBuffer); + + let byteOffset = 0; + for (let i = 0; i < chunkFrames; i++) { + for (let ch = 0; ch < numChannels; ch++) { + const sample = Math.max(-1, Math.min(1, channels[ch][frameOffset + i])); + view.setInt16(byteOffset, sample < 0 ? sample * 0x8000 : sample * 0x7fff, true); + byteOffset += 2; + } + } + + parts.push(chunkBuffer); + } + + return parts; + } + + // Loads a sidecar audio file into a WebDemuxer for direct transcoding (avoiding real-time rendering). + private async loadAudioFileDemuxer(audioPath: string): Promise<WebDemuxer | null> { + try { + const source = await resolveMediaElementSource(audioPath); + try { + const wasmUrl = new URL("./wasm/web-demuxer.wasm", window.location.href).href; + const demuxer = new WebDemuxer({ wasmFilePath: wasmUrl }); + await demuxer.load(source.src); + return demuxer; + } finally { + source.revoke(); + } + } catch (error) { + console.warn("[AudioProcessor] Failed to create demuxer for sidecar audio:", error); + return null; + } + } + + private cloneWithTimestamp(src: AudioData, newTimestamp: number): AudioData { + const isPlanar = src.format?.includes("planar") ?? false; + const numPlanes = isPlanar ? src.numberOfChannels : 1; + + let totalSize = 0; + for (let planeIndex = 0; planeIndex < numPlanes; planeIndex++) { + totalSize += src.allocationSize({ planeIndex }); + } + + const buffer = new ArrayBuffer(totalSize); + let offset = 0; + + for (let planeIndex = 0; planeIndex < numPlanes; planeIndex++) { + const planeSize = src.allocationSize({ planeIndex }); + src.copyTo(new Uint8Array(buffer, offset, planeSize), { planeIndex }); + offset += planeSize; + } + + return new AudioData({ + format: src.format!, + sampleRate: src.sampleRate, + numberOfFrames: src.numberOfFrames, + numberOfChannels: src.numberOfChannels, + timestamp: newTimestamp, + data: buffer, + }); + } + + private cloneEncodedAudioChunkWithTimestamp( + src: EncodedAudioChunk, + newTimestamp: number, + ): EncodedAudioChunk { + const data = new Uint8Array(src.byteLength); + src.copyTo(data); + + return new EncodedAudioChunk({ + type: src.type, + timestamp: newTimestamp, + duration: src.duration ?? undefined, + data, + }); + } + + private isInTrimRegion(timestampMs: number, trims: TrimLikeRegion[]) { + return trims.some((trim) => timestampMs >= trim.startMs && timestampMs < trim.endMs); + } + + private computeTrimOffset(timestampMs: number, trims: TrimLikeRegion[]) { + let offset = 0; + for (const trim of trims) { + if (trim.endMs <= timestampMs) { + offset += trim.endMs - trim.startMs; + } + } + return offset; + } + + cancel() { + this.cancelled = true; + } +} diff --git a/src/lib/exporter/audioRoutingEngine.ts b/src/lib/exporter/audioRoutingEngine.ts new file mode 100644 index 000000000..8f9c7421d --- /dev/null +++ b/src/lib/exporter/audioRoutingEngine.ts @@ -0,0 +1,131 @@ +import type { AudioRegion } from "@/components/video-editor/types"; +import { SOURCE_AUDIO_NORMALIZE_GAIN } from "@/components/video-editor/audio/audioTypes"; +import { resolveSourceAudioFallbackPaths } from "./sourceAudioFallback"; + +export type SourceTrackId = "mic" | "system" | "mixed"; +export type ResolvedAudioTrackKind = "user" | "system" | "mic" | "mixed" | "embedded"; + +export interface ResolvedAudioTrack { + id: string; + kind: ResolvedAudioTrackKind; + sourceRef: { + path: string; + startDelayMs: number; + }; + gain: number; + timelineBinding: { + startMs: number; + endMs: number; + }; +} + +export interface ResolvedAudioPlan { + hasEmbeddedSourceAudio: boolean; + pathsByTrack: Partial<Record<SourceTrackId, string>>; + playbackPaths: string[]; + muteEmbeddedPreview: boolean; + includeEmbeddedInExport: boolean; + tracks: ResolvedAudioTrack[]; + masterGain: number; +} + +export function getSourceTrackIdFromPath(audioPath: string): SourceTrackId { + const normalized = audioPath.toLowerCase(); + if (normalized.includes(".mic.")) return "mic"; + if (normalized.includes(".system.")) return "system"; + return "mixed"; +} + +function clampGain(value: number, max: number) { + if (!Number.isFinite(value)) return 1; + return Math.max(0, Math.min(max, value)); +} + +export function buildResolvedAudioPlan(input: { + videoResource: string | null | undefined; + sourceAudioFallbackPaths: string[] | null | undefined; + audioRegions?: AudioRegion[]; + sourceTrackGainById?: Partial<Record<SourceTrackId, number>>; + embeddedGain?: number; + masterGain?: number; +}): ResolvedAudioPlan { + const { hasEmbeddedSourceAudio, externalAudioPaths } = resolveSourceAudioFallbackPaths( + input.videoResource, + input.sourceAudioFallbackPaths, + ); + + const pathsByTrack: Partial<Record<SourceTrackId, string>> = {}; + for (const path of externalAudioPaths) { + const trackId = getSourceTrackIdFromPath(path); + if (!pathsByTrack[trackId]) { + pathsByTrack[trackId] = path; + } + } + + const hasDedicatedTracks = Boolean(pathsByTrack.system || pathsByTrack.mic); + const playbackPaths: string[] = []; + if (pathsByTrack.system) playbackPaths.push(pathsByTrack.system); + if (pathsByTrack.mic) playbackPaths.push(pathsByTrack.mic); + if (!hasDedicatedTracks && pathsByTrack.mixed) playbackPaths.push(pathsByTrack.mixed); + + const includeEmbeddedInExport = !pathsByTrack.system && !pathsByTrack.mixed; + const resolvedRegions = (input.audioRegions ?? []) + .slice() + .sort((a, b) => a.startMs - b.startMs); + const tracks: ResolvedAudioTrack[] = resolvedRegions.map((region) => ({ + id: `user:${region.id}`, + kind: "user", + sourceRef: { + path: region.audioPath, + startDelayMs: 0, + }, + gain: clampGain(region.volume * (region.normalize ? SOURCE_AUDIO_NORMALIZE_GAIN : 1), 1), + timelineBinding: { + startMs: Math.max(0, region.startMs), + endMs: Math.max(0, region.endMs), + }, + })); + + for (const audioPath of playbackPaths) { + const trackId = getSourceTrackIdFromPath(audioPath); + tracks.push({ + id: `${trackId}:${audioPath}`, + kind: trackId, + sourceRef: { + path: audioPath, + startDelayMs: 0, + }, + gain: clampGain(input.sourceTrackGainById?.[trackId] ?? 1, 2), + timelineBinding: { + startMs: 0, + endMs: Number.POSITIVE_INFINITY, + }, + }); + } + + if (hasEmbeddedSourceAudio && input.videoResource) { + tracks.push({ + id: `embedded:${input.videoResource}`, + kind: "embedded", + sourceRef: { + path: input.videoResource, + startDelayMs: 0, + }, + gain: clampGain(input.embeddedGain ?? input.sourceTrackGainById?.mixed ?? 1, 2), + timelineBinding: { + startMs: 0, + endMs: Number.POSITIVE_INFINITY, + }, + }); + } + + return { + hasEmbeddedSourceAudio, + pathsByTrack, + playbackPaths, + muteEmbeddedPreview: hasDedicatedTracks && !includeEmbeddedInExport, + includeEmbeddedInExport, + tracks, + masterGain: clampGain(input.masterGain ?? 1, 1), + }; +} diff --git a/src/lib/exporter/backendPolicy.test.ts b/src/lib/exporter/backendPolicy.test.ts new file mode 100644 index 000000000..69ae5b8f5 --- /dev/null +++ b/src/lib/exporter/backendPolicy.test.ts @@ -0,0 +1,196 @@ +import { describe, expect, it } from "vitest"; + +import { + getDefaultLightningRenderBackend, + normalizeLightningRuntimePlatform, + planLightningExportRoutes, + shouldPreferNativeAutoBackend, + shouldPreferNativeStaticLayoutBeforeBreeze, +} from "./backendPolicy"; + +describe("backendPolicy", () => { + it("normalizes common platform hints", () => { + expect(normalizeLightningRuntimePlatform("Win32")).toBe("win32"); + expect(normalizeLightningRuntimePlatform("Linux x86_64")).toBe("linux"); + expect(normalizeLightningRuntimePlatform("MacIntel")).toBe("darwin"); + expect(normalizeLightningRuntimePlatform("unknown")).toBe("unknown"); + }); + + it("prefers native auto backend on desktop platforms with the fastest native path", () => { + expect(shouldPreferNativeAutoBackend("win32")).toBe(true); + expect(shouldPreferNativeAutoBackend("linux")).toBe(false); + expect(shouldPreferNativeAutoBackend("darwin")).toBe(true); + expect(shouldPreferNativeAutoBackend("unknown")).toBe(false); + }); + + it("keeps Lightning exports on the stable WebGL renderer by default", () => { + expect(getDefaultLightningRenderBackend()).toBe("webgl"); + }); + + it("keeps Windows auto exports on the streaming route by default", () => { + expect(shouldPreferNativeStaticLayoutBeforeBreeze("win32", "auto")).toBe(false); + expect(shouldPreferNativeStaticLayoutBeforeBreeze("darwin", "auto")).toBe(false); + + expect( + planLightningExportRoutes({ + backendPreference: "auto", + platform: "win32", + nativeStaticLayoutAvailable: true, + }), + ).toMatchObject({ + selectedRoute: "breeze-stream", + decisions: [ + { route: "native-static-layout", status: "rejected" }, + { route: "breeze-stream", status: "selected" }, + { route: "webcodecs", status: "fallback" }, + ], + }); + }); + + it("ignores native static skip reasons for Windows auto routing", () => { + expect( + planLightningExportRoutes({ + backendPreference: "auto", + platform: "win32", + nativeStaticLayoutAvailable: true, + nativeStaticLayoutSkipReasons: ["unsupported-caption-overlay"], + }), + ).toEqual({ + selectedRoute: "breeze-stream", + decisions: [ + { + route: "native-static-layout", + status: "rejected", + reasons: ["platform-does-not-use-native-static-layout"], + }, + { + route: "breeze-stream", + status: "selected", + reasons: ["platform-prefers-native-streaming"], + }, + { + route: "webcodecs", + status: "fallback", + reasons: ["breeze-unavailable-fallback"], + }, + ], + }); + }); + + it("documents the native static layout path when Breeze is selected explicitly", () => { + expect( + planLightningExportRoutes({ + backendPreference: "breeze", + platform: "win32", + nativeStaticLayoutAvailable: true, + }), + ).toEqual({ + selectedRoute: "native-static-layout", + decisions: [ + { + route: "native-static-layout", + status: "selected", + reasons: ["visually-compatible"], + }, + { + route: "breeze-stream", + status: "fallback", + reasons: ["user-selected-breeze"], + }, + { + route: "webcodecs", + status: "fallback", + reasons: ["breeze-unavailable-fallback"], + }, + ], + }); + }); + + it("keeps explicit Breeze selected when native static layout is unavailable", () => { + expect( + planLightningExportRoutes({ + backendPreference: "breeze", + platform: "win32", + nativeStaticLayoutAvailable: false, + }), + ).toEqual({ + selectedRoute: "breeze-stream", + decisions: [ + { + route: "native-static-layout", + status: "rejected", + reasons: ["native-static-unavailable"], + }, + { + route: "breeze-stream", + status: "selected", + reasons: ["user-selected-breeze"], + }, + { + route: "webcodecs", + status: "fallback", + reasons: ["breeze-unavailable-fallback"], + }, + ], + }); + }); + + it("records explicit Breeze native static layout skip reasons", () => { + expect( + planLightningExportRoutes({ + backendPreference: "breeze", + platform: "win32", + nativeStaticLayoutAvailable: true, + nativeStaticLayoutSkipReasons: ["unsupported-audio-mix"], + }), + ).toEqual({ + selectedRoute: "breeze-stream", + decisions: [ + { + route: "native-static-layout", + status: "rejected", + reasons: ["unsupported-audio-mix"], + }, + { + route: "breeze-stream", + status: "selected", + reasons: ["user-selected-breeze"], + }, + { + route: "webcodecs", + status: "fallback", + reasons: ["breeze-unavailable-fallback"], + }, + ], + }); + }); + + it("documents why macOS auto skips native static layout", () => { + expect( + planLightningExportRoutes({ + backendPreference: "auto", + platform: "darwin", + nativeStaticLayoutAvailable: true, + }), + ).toEqual({ + selectedRoute: "breeze-stream", + decisions: [ + { + route: "native-static-layout", + status: "rejected", + reasons: ["platform-does-not-use-native-static-layout"], + }, + { + route: "breeze-stream", + status: "selected", + reasons: ["platform-prefers-native-streaming"], + }, + { + route: "webcodecs", + status: "fallback", + reasons: ["breeze-unavailable-fallback"], + }, + ], + }); + }); +}); diff --git a/src/lib/exporter/backendPolicy.ts b/src/lib/exporter/backendPolicy.ts new file mode 100644 index 000000000..3c7f98479 --- /dev/null +++ b/src/lib/exporter/backendPolicy.ts @@ -0,0 +1,151 @@ +import type { ExportBackendPreference, ExportRenderBackend } from "./types"; + +export type LightningRuntimePlatform = "darwin" | "win32" | "linux" | "unknown"; + +export function normalizeLightningRuntimePlatform( + platformHint: string | null | undefined, +): LightningRuntimePlatform { + if (!platformHint) { + return "unknown"; + } + + if (/win/i.test(platformHint)) { + return "win32"; + } + + if (/linux/i.test(platformHint)) { + return "linux"; + } + + if (/mac|iphone|ipad|ipod/i.test(platformHint)) { + return "darwin"; + } + + return "unknown"; +} + +export function shouldPreferNativeAutoBackend(_platform: LightningRuntimePlatform): boolean { + return _platform === "darwin" || _platform === "win32"; +} + +export type LightningExportRoute = "native-static-layout" | "breeze-stream" | "webcodecs"; + +export interface LightningExportRouteDecision { + route: LightningExportRoute; + status: "selected" | "fallback" | "rejected"; + reasons: string[]; +} + +export interface LightningExportRoutePlan { + selectedRoute: LightningExportRoute; + decisions: LightningExportRouteDecision[]; +} + +// Disabled while stabilizing v1.3.0: the Windows auto static-layout probe can +// leave Lightning at "Preparing export..." before the stable streaming fallback. +const WINDOWS_AUTO_STATIC_LAYOUT_FIRST_ENABLED = false; + +export function shouldPreferNativeStaticLayoutBeforeBreeze( + platform: LightningRuntimePlatform, + backendPreference: ExportBackendPreference, +): boolean { + return ( + WINDOWS_AUTO_STATIC_LAYOUT_FIRST_ENABLED && + backendPreference === "auto" && + platform === "win32" + ); +} + +export function planLightningExportRoutes(options: { + backendPreference: ExportBackendPreference; + platform: LightningRuntimePlatform; + nativeStaticLayoutAvailable: boolean; + nativeStaticLayoutSkipReasons?: string[]; +}): LightningExportRoutePlan { + const decisions: LightningExportRouteDecision[] = []; + const nativeStaticLayoutSkipReasons = options.nativeStaticLayoutSkipReasons ?? []; + const canUseNativeStaticLayout = + options.nativeStaticLayoutAvailable && nativeStaticLayoutSkipReasons.length === 0; + + const addNativeStaticLayoutDecision = (status: LightningExportRouteDecision["status"]) => { + decisions.push({ + route: "native-static-layout", + status, + reasons: canUseNativeStaticLayout + ? ["visually-compatible"] + : nativeStaticLayoutSkipReasons.length > 0 + ? nativeStaticLayoutSkipReasons + : ["native-static-unavailable"], + }); + }; + + if (options.backendPreference === "webcodecs") { + decisions.push({ + route: "webcodecs", + status: "selected", + reasons: ["user-selected-webcodecs"], + }); + return { selectedRoute: "webcodecs", decisions }; + } + + const preferStaticFirst = + options.backendPreference === "breeze" || + shouldPreferNativeStaticLayoutBeforeBreeze(options.platform, options.backendPreference); + + if (preferStaticFirst) { + addNativeStaticLayoutDecision(canUseNativeStaticLayout ? "selected" : "rejected"); + decisions.push({ + route: "breeze-stream", + status: canUseNativeStaticLayout ? "fallback" : "selected", + reasons: [ + options.backendPreference === "breeze" + ? "user-selected-breeze" + : "native-static-layout-not-auto-default", + ], + }); + decisions.push({ + route: "webcodecs", + status: "fallback", + reasons: ["breeze-unavailable-fallback"], + }); + return { + selectedRoute: canUseNativeStaticLayout ? "native-static-layout" : "breeze-stream", + decisions, + }; + } + + if (options.backendPreference === "auto" && shouldPreferNativeAutoBackend(options.platform)) { + decisions.push({ + route: "native-static-layout", + status: "rejected", + reasons: ["platform-does-not-use-native-static-layout"], + }); + decisions.push({ + route: "breeze-stream", + status: "selected", + reasons: ["platform-prefers-native-streaming"], + }); + decisions.push({ + route: "webcodecs", + status: "fallback", + reasons: ["breeze-unavailable-fallback"], + }); + return { selectedRoute: "breeze-stream", decisions }; + } + + decisions.push({ + route: "webcodecs", + status: "selected", + reasons: ["default-webcodecs-first"], + }); + decisions.push({ + route: "breeze-stream", + status: "fallback", + reasons: ["webcodecs-software-or-unavailable-fallback"], + }); + return { selectedRoute: "webcodecs", decisions }; +} + +export function getDefaultLightningRenderBackend(): ExportRenderBackend { + return "webgl"; +} diff --git a/src/lib/exporter/captionRenderer.ts b/src/lib/exporter/captionRenderer.ts new file mode 100644 index 000000000..1459eb7cb --- /dev/null +++ b/src/lib/exporter/captionRenderer.ts @@ -0,0 +1,107 @@ +import { buildActiveCaptionLayout } from "@/components/video-editor/captionLayout"; +import { + CAPTION_FONT_WEIGHT, + CAPTION_LINE_HEIGHT, + getCaptionPadding, + getCaptionScaledFontSize, + getCaptionScaledRadius, + getCaptionTextMaxWidth, + getCaptionWordVisualState, +} from "@/components/video-editor/captionStyle"; +import { + type AutoCaptionSettings, + type CaptionCue, + getDefaultCaptionFontFamily, +} from "@/components/video-editor/types"; +import { drawSquircleOnCanvas } from "@/lib/geometry/squircle"; + +export function renderCaptions( + ctx: CanvasRenderingContext2D, + cues: CaptionCue[], + settings: AutoCaptionSettings, + width: number, + height: number, + timeMs: number, +) { + if (!settings.enabled || cues.length === 0) { + return; + } + + ctx.save(); + + const fontSize = getCaptionScaledFontSize(settings.fontSize, width, settings.maxWidth); + ctx.font = `${CAPTION_FONT_WEIGHT} ${fontSize}px ${getDefaultCaptionFontFamily()}`; + const padding = getCaptionPadding(fontSize); + + const activeCaptionLayout = buildActiveCaptionLayout({ + cues, + timeMs, + settings, + maxWidthPx: getCaptionTextMaxWidth(width, settings.maxWidth, fontSize), + measureText: (text) => ctx.measureText(text).width, + }); + if (!activeCaptionLayout) { + ctx.restore(); + return; + } + + const lineHeight = fontSize * CAPTION_LINE_HEIGHT; + const paddingX = padding.x; + const paddingY = padding.y; + const textBlockHeight = activeCaptionLayout.visibleLines.length * lineHeight; + const boxHeight = textBlockHeight + paddingY * 2; + const centerX = width / 2; + const centerY = height - (height * settings.bottomOffset) / 100 - boxHeight / 2; + const maxMeasuredWidth = activeCaptionLayout.visibleLines.reduce( + (largest, line) => Math.max(largest, line.width), + 0, + ); + const boxWidth = Math.min( + width * (settings.maxWidth / 100) + paddingX * 2, + maxMeasuredWidth + paddingX * 2, + ); + + ctx.translate(centerX, centerY + activeCaptionLayout.translateY); + ctx.scale(activeCaptionLayout.scale, activeCaptionLayout.scale); + ctx.globalAlpha = activeCaptionLayout.opacity; + + ctx.fillStyle = `rgba(0, 0, 0, ${settings.backgroundOpacity})`; + drawSquircleOnCanvas(ctx, { + x: -boxWidth / 2, + y: -boxHeight / 2, + width: boxWidth, + height: boxHeight, + radius: getCaptionScaledRadius(settings.boxRadius, fontSize), + }); + ctx.fill(); + + ctx.textAlign = "left"; + ctx.textBaseline = "middle"; + + activeCaptionLayout.visibleLines.forEach((line, lineIndex) => { + let cursorX = -line.width / 2; + const lineY = -boxHeight / 2 + paddingY + lineHeight * lineIndex + lineHeight / 2; + + line.words.forEach((word) => { + const segmentText = `${word.leadingSpace ? " " : ""}${word.text}`; + const segmentWidth = ctx.measureText(segmentText).width; + const visualState = getCaptionWordVisualState( + activeCaptionLayout.hasWordTimings, + word.state, + ); + + ctx.save(); + ctx.translate(cursorX, lineY); + ctx.fillStyle = visualState.isInactive + ? settings.inactiveTextColor + : settings.textColor; + ctx.globalAlpha = activeCaptionLayout.opacity * visualState.opacity; + ctx.fillText(segmentText, 0, 0); + ctx.restore(); + + cursorX += segmentWidth; + }); + }); + + ctx.restore(); +} diff --git a/src/lib/exporter/editedTrackStrategy.test.ts b/src/lib/exporter/editedTrackStrategy.test.ts new file mode 100644 index 000000000..3265661d4 --- /dev/null +++ b/src/lib/exporter/editedTrackStrategy.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, it } from "vitest"; +import type { AudioRegion, SpeedRegion, TrimRegion } from "@/components/video-editor/types"; +import { buildEditedTrackSourceSegments, classifyEditedTrackStrategy } from "./editedTrackStrategy"; + +const SOURCE_DURATION_MS = 20_000; +const EMPTY_TRIMS: TrimRegion[] = []; + +describe("editedTrackStrategy", () => { + it("marks single-source speed-only edits as filtergraph candidates", () => { + const speedRegions: SpeedRegion[] = [ + { id: "speed-1", startMs: 2_000, endMs: 10_000, speed: 1.5 }, + ]; + + expect( + classifyEditedTrackStrategy({ + primaryAudioSourcePath: "recording.mp4", + sourceDurationMs: SOURCE_DURATION_MS, + trimRegions: EMPTY_TRIMS, + speedRegions, + audioRegions: [], + sourceAudioFallbackPaths: [], + }), + ).toBe("filtergraph-fast-path"); + }); + + it("marks a single external source audio track as a filtergraph candidate", () => { + const speedRegions: SpeedRegion[] = [ + { id: "speed-1", startMs: 2_000, endMs: 10_000, speed: 1.25 }, + ]; + + expect( + classifyEditedTrackStrategy({ + primaryAudioSourcePath: "mic.m4a", + sourceDurationMs: SOURCE_DURATION_MS, + trimRegions: EMPTY_TRIMS, + speedRegions, + audioRegions: [], + sourceAudioFallbackPaths: ["mic.m4a"], + }), + ).toBe("filtergraph-fast-path"); + }); + + it("falls back for multi-source mixes", () => { + const speedRegions: SpeedRegion[] = [ + { id: "speed-1", startMs: 2_000, endMs: 10_000, speed: 1.5 }, + ]; + + expect( + classifyEditedTrackStrategy({ + primaryAudioSourcePath: "recording.mp4", + sourceDurationMs: SOURCE_DURATION_MS, + trimRegions: EMPTY_TRIMS, + speedRegions, + audioRegions: [], + sourceAudioFallbackPaths: ["mic.m4a"], + }), + ).toBe("offline-render-fallback"); + }); + + it("falls back when audio regions require overlay mixing", () => { + const speedRegions: SpeedRegion[] = [ + { id: "speed-1", startMs: 2_000, endMs: 10_000, speed: 1.5 }, + ]; + const audioRegions: AudioRegion[] = [ + { + id: "audio-1", + audioPath: "overlay.wav", + startMs: 1_000, + endMs: 4_000, + volume: 0.8, + }, + ]; + + expect( + classifyEditedTrackStrategy({ + primaryAudioSourcePath: "recording.mp4", + sourceDurationMs: SOURCE_DURATION_MS, + trimRegions: EMPTY_TRIMS, + speedRegions, + audioRegions, + sourceAudioFallbackPaths: [], + }), + ).toBe("offline-render-fallback"); + }); + + it("falls back for speeds outside the conservative filtergraph window", () => { + const speedRegions = [ + { id: "speed-1", startMs: 2_000, endMs: 10_000, speed: 2.5 }, + ] as SpeedRegion[]; + + expect( + classifyEditedTrackStrategy({ + primaryAudioSourcePath: "recording.mp4", + sourceDurationMs: SOURCE_DURATION_MS, + trimRegions: EMPTY_TRIMS, + speedRegions, + audioRegions: [], + sourceAudioFallbackPaths: [], + }), + ).toBe("offline-render-fallback"); + }); + + it("falls back when source duration or speed-region bounds are invalid", () => { + const invalidBounds: SpeedRegion[] = [ + { id: "speed-1", startMs: Number.NaN, endMs: 10_000, speed: 1.5 }, + ]; + + expect( + classifyEditedTrackStrategy({ + primaryAudioSourcePath: "recording.mp4", + sourceDurationMs: Number.POSITIVE_INFINITY, + trimRegions: EMPTY_TRIMS, + speedRegions: invalidBounds, + audioRegions: [], + sourceAudioFallbackPaths: [], + }), + ).toBe("offline-render-fallback"); + }); + + it("builds source segments that preserve trims and speed boundaries", () => { + const trimRegions: TrimRegion[] = [{ id: "trim-1", startMs: 10_000, endMs: 12_000 }]; + const speedRegions: SpeedRegion[] = [ + { id: "speed-1", startMs: 2_000, endMs: 6_000, speed: 1.5 }, + { id: "speed-2", startMs: 14_000, endMs: 18_000, speed: 0.75 }, + ]; + + expect( + buildEditedTrackSourceSegments(SOURCE_DURATION_MS, trimRegions, speedRegions), + ).toEqual([ + { startMs: 0, endMs: 2_000, speed: 1 }, + { startMs: 2_000, endMs: 6_000, speed: 1.5 }, + { startMs: 6_000, endMs: 10_000, speed: 1 }, + { startMs: 12_000, endMs: 14_000, speed: 1 }, + { startMs: 14_000, endMs: 18_000, speed: 0.75 }, + { startMs: 18_000, endMs: 20_000, speed: 1 }, + ]); + }); + + it("returns no source segments when speed-region bounds are invalid", () => { + expect( + buildEditedTrackSourceSegments(SOURCE_DURATION_MS, EMPTY_TRIMS, [ + { id: "speed-1", startMs: 15_000, endMs: 10_000, speed: 1.5 }, + ]), + ).toEqual([]); + }); +}); diff --git a/src/lib/exporter/editedTrackStrategy.ts b/src/lib/exporter/editedTrackStrategy.ts new file mode 100644 index 000000000..fa33a3b64 --- /dev/null +++ b/src/lib/exporter/editedTrackStrategy.ts @@ -0,0 +1,180 @@ +import type { AudioRegion, SpeedRegion, TrimRegion } from "@/components/video-editor/types"; + +const MIN_FILTERGRAPH_SPEED = 0.5; +const MAX_FILTERGRAPH_SPEED = 2; + +export type EditedTrackStrategy = "filtergraph-fast-path" | "offline-render-fallback"; + +export interface EditedTrackSourceSegment { + startMs: number; + endMs: number; + speed: number; +} + +export interface EditedTrackStrategyInput { + primaryAudioSourcePath: string | null; + sourceDurationMs: number; + trimRegions: TrimRegion[]; + speedRegions: SpeedRegion[]; + audioRegions: AudioRegion[]; + sourceAudioFallbackPaths: string[]; +} + +function isSafeFiltergraphSpeed(speed: number): boolean { + return ( + Number.isFinite(speed) && speed >= MIN_FILTERGRAPH_SPEED && speed <= MAX_FILTERGRAPH_SPEED + ); +} + +function hasFiniteTimelineRange(startMs: number, endMs: number, sourceDurationMs: number): boolean { + return ( + Number.isFinite(startMs) && + Number.isFinite(endMs) && + startMs >= 0 && + endMs > startMs && + endMs <= sourceDurationMs + ); +} + +function hasSafeFiltergraphSpeedRegions( + speedRegions: SpeedRegion[], + sourceDurationMs: number, +): boolean { + if (!Number.isFinite(sourceDurationMs) || sourceDurationMs <= 0) { + return false; + } + + return speedRegions.every( + (region) => + hasFiniteTimelineRange(region.startMs, region.endMs, sourceDurationMs) && + isSafeFiltergraphSpeed(region.speed), + ); +} + +function buildKeptRanges( + sourceDurationMs: number, + trimRegions: TrimRegion[], +): Array<{ startMs: number; endMs: number }> { + const sortedTrimRegions = [...trimRegions].sort((left, right) => left.startMs - right.startMs); + const keptRanges: Array<{ startMs: number; endMs: number }> = []; + let cursorMs = 0; + + for (const trimRegion of sortedTrimRegions) { + const startMs = Math.max(0, trimRegion.startMs); + const endMs = Math.min(sourceDurationMs, trimRegion.endMs); + if (endMs <= startMs) { + continue; + } + + if (startMs > cursorMs) { + keptRanges.push({ startMs: cursorMs, endMs: startMs }); + } + + cursorMs = Math.max(cursorMs, endMs); + } + + if (cursorMs < sourceDurationMs) { + keptRanges.push({ startMs: cursorMs, endMs: sourceDurationMs }); + } + + return keptRanges; +} + +export function buildEditedTrackSourceSegments( + sourceDurationMs: number, + trimRegions: TrimRegion[], + speedRegions: SpeedRegion[], +): EditedTrackSourceSegment[] { + if (!Number.isFinite(sourceDurationMs) || sourceDurationMs <= 0) { + return []; + } + + if ( + speedRegions.some( + (region) => + !hasFiniteTimelineRange(region.startMs, region.endMs, sourceDurationMs) || + !isSafeFiltergraphSpeed(region.speed), + ) + ) { + return []; + } + + const segments: EditedTrackSourceSegment[] = []; + const keptRanges = buildKeptRanges(sourceDurationMs, trimRegions); + + for (const keptRange of keptRanges) { + const boundaries = new Set<number>([keptRange.startMs, keptRange.endMs]); + for (const speedRegion of speedRegions) { + const startMs = Math.max(keptRange.startMs, speedRegion.startMs); + const endMs = Math.min(keptRange.endMs, speedRegion.endMs); + if (endMs > startMs) { + boundaries.add(startMs); + boundaries.add(endMs); + } + } + + const orderedBoundaries = [...boundaries].sort((left, right) => left - right); + for (let index = 0; index < orderedBoundaries.length - 1; index += 1) { + const startMs = orderedBoundaries[index] ?? 0; + const endMs = orderedBoundaries[index + 1] ?? 0; + if (endMs - startMs <= 0.5) { + continue; + } + + const midpointMs = startMs + (endMs - startMs) / 2; + const speedRegion = speedRegions.find( + (region) => midpointMs >= region.startMs && midpointMs < region.endMs, + ); + const speed = speedRegion?.speed ?? 1; + if (!isSafeFiltergraphSpeed(speed)) { + return []; + } + + segments.push({ + startMs, + endMs, + speed, + }); + } + } + + return segments; +} + +export function classifyEditedTrackStrategy(input: EditedTrackStrategyInput): EditedTrackStrategy { + if (!input.primaryAudioSourcePath) { + return "offline-render-fallback"; + } + + if (input.audioRegions.length > 0) { + return "offline-render-fallback"; + } + + if (input.speedRegions.length === 0) { + return "offline-render-fallback"; + } + + if (!hasSafeFiltergraphSpeedRegions(input.speedRegions, input.sourceDurationMs)) { + return "offline-render-fallback"; + } + + if ( + input.sourceAudioFallbackPaths.some( + (audioPath) => audioPath !== input.primaryAudioSourcePath, + ) + ) { + return "offline-render-fallback"; + } + + if ( + buildEditedTrackSourceSegments( + input.sourceDurationMs, + input.trimRegions, + input.speedRegions, + ).length === 0 + ) { + return "offline-render-fallback"; + } + + return "filtergraph-fast-path"; +} diff --git a/src/lib/exporter/exportBitrate.test.ts b/src/lib/exporter/exportBitrate.test.ts new file mode 100644 index 000000000..13b8a9be1 --- /dev/null +++ b/src/lib/exporter/exportBitrate.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from "vitest"; +import { getMp4ExportBitrate, getSourceQualityBitrate } from "./exportBitrate"; + +describe("export bitrate policy", () => { + it("uses the web-delivery source-quality caps", () => { + expect(getSourceQualityBitrate(1280, 720)).toBe(8_000_000); + expect(getSourceQualityBitrate(1920, 1080)).toBe(12_000_000); + expect(getSourceQualityBitrate(3840, 2160)).toBe(45_000_000); + expect( + getMp4ExportBitrate({ + width: 1920, + height: 1080, + frameRate: 30, + quality: "source", + encodingMode: "quality", + }), + ).toBe(12_000_000); + expect( + getMp4ExportBitrate({ + width: 1920, + height: 1080, + frameRate: 30, + quality: "source", + encodingMode: "balanced", + }), + ).toBe(9_600_000); + }); + + it("raises high-resolution 60fps source-quality exports above the 30fps budget", () => { + const sharedOptions = { + width: 2560, + height: 1440, + quality: "source" as const, + encodingMode: "quality" as const, + }; + + const thirtyFpsBitrate = getMp4ExportBitrate({ + ...sharedOptions, + frameRate: 30, + }); + const sixtyFpsBitrate = getMp4ExportBitrate({ + ...sharedOptions, + frameRate: 60, + }); + + expect(thirtyFpsBitrate).toBe(20_555_556); + expect(sixtyFpsBitrate).toBeGreaterThan(thirtyFpsBitrate); + expect(sixtyFpsBitrate).toBe(29_069_946); + }); + + it("keeps modern native static-layout source exports high enough for screen text", () => { + expect( + getMp4ExportBitrate({ + width: 1920, + height: 1080, + frameRate: 30, + quality: "source", + encodingMode: "balanced", + useModernNativeStaticLayout: true, + }), + ).toBe(9_600_000); + expect( + getMp4ExportBitrate({ + width: 1920, + height: 1080, + frameRate: 30, + quality: "source", + encodingMode: "quality", + useModernNativeStaticLayout: true, + }), + ).toBe(12_000_000); + }); + + it("scales modern native static-layout source exports at 60fps", () => { + const sharedOptions = { + width: 1920, + height: 1080, + quality: "source" as const, + encodingMode: "quality" as const, + useModernNativeStaticLayout: true, + }; + + const thirtyFpsBitrate = getMp4ExportBitrate({ + ...sharedOptions, + frameRate: 30, + }); + const sixtyFpsBitrate = getMp4ExportBitrate({ + ...sharedOptions, + frameRate: 60, + }); + + expect(thirtyFpsBitrate).toBe(12_000_000); + expect(sixtyFpsBitrate).toBeGreaterThan(thirtyFpsBitrate); + expect(sixtyFpsBitrate).toBe(16_970_563); + }); + + it("does not raise fast exports when the requested bitrate is already lower than the cap", () => { + expect( + getMp4ExportBitrate({ + width: 1920, + height: 1080, + frameRate: 30, + quality: "source", + encodingMode: "fast", + useModernNativeStaticLayout: true, + }), + ).toBe(6_000_000); + }); + + it("scales the modern native cap with output pixel rate", () => { + expect( + getMp4ExportBitrate({ + width: 3840, + height: 2160, + frameRate: 30, + quality: "source", + encodingMode: "quality", + useModernNativeStaticLayout: true, + }), + ).toBe(45_000_000); + }); +}); diff --git a/src/lib/exporter/exportBitrate.ts b/src/lib/exporter/exportBitrate.ts new file mode 100644 index 000000000..1b585509f --- /dev/null +++ b/src/lib/exporter/exportBitrate.ts @@ -0,0 +1,93 @@ +import type { ExportEncodingMode, ExportMp4FrameRate, ExportQuality } from "./types"; + +const MIN_MP4_BITRATE = 2_000_000; +const REFERENCE_FRAME_RATE = 30; +const HD_PIXELS = 1280 * 720; +const FULL_HD_PIXELS = 1920 * 1080; +const UHD_PIXELS = 3840 * 2160; + +function interpolateBitrate( + totalPixels: number, + startPixels: number, + endPixels: number, + startBitrate: number, + endBitrate: number, +): number { + const progress = Math.max( + 0, + Math.min(1, (totalPixels - startPixels) / (endPixels - startPixels)), + ); + return Math.round(startBitrate + (endBitrate - startBitrate) * progress); +} + +export function getEncodingModeBitrateMultiplier(encodingMode: ExportEncodingMode): number { + switch (encodingMode) { + case "fast": + return 0.5; + case "quality": + return 1; + case "balanced": + default: + return 0.8; + } +} + +export function getSourceQualityBitrate(width: number, height: number): number { + const totalPixels = width * height; + if (totalPixels <= HD_PIXELS) { + return 8_000_000; + } + if (totalPixels <= FULL_HD_PIXELS) { + return 12_000_000; + } + if (totalPixels >= UHD_PIXELS) { + return 45_000_000; + } + return interpolateBitrate(totalPixels, FULL_HD_PIXELS, UHD_PIXELS, 12_000_000, 45_000_000); +} + +function getBaseMp4ExportBitrate(width: number, height: number, quality: ExportQuality): number { + if (quality === "source") { + return getSourceQualityBitrate(width, height); + } + + const totalPixels = width * height; + if (totalPixels <= HD_PIXELS) { + return 5_000_000; + } + if (totalPixels <= FULL_HD_PIXELS) { + return 8_000_000; + } + if (totalPixels >= UHD_PIXELS) { + return 35_000_000; + } + return interpolateBitrate(totalPixels, FULL_HD_PIXELS, UHD_PIXELS, 8_000_000, 35_000_000); +} + +function getFrameRateBitrateMultiplier(frameRate: ExportMp4FrameRate): number { + // This only scales requestedBitrate above REFERENCE_FRAME_RATE, so 24fps + // and 30fps share the same multiplier. useModernNativeStaticLayout can + // still change the final bitrate because pixelRateScale uses frameRate + // against REFERENCE_PIXEL_RATE for the native layout floor/cap. + return Math.sqrt(Math.max(1, frameRate / REFERENCE_FRAME_RATE)); +} + +export function getMp4ExportBitrate(options: { + width: number; + height: number; + frameRate: ExportMp4FrameRate; + quality: ExportQuality; + encodingMode: ExportEncodingMode; + useModernNativeStaticLayout?: boolean; +}): number { + const requestedBitrate = Math.round( + getBaseMp4ExportBitrate(options.width, options.height, options.quality) * + getFrameRateBitrateMultiplier(options.frameRate) * + getEncodingModeBitrateMultiplier(options.encodingMode), + ); + + // Keep every backend on the same delivery bitrate policy. Native static-layout + // exports previously applied a second set of floors and caps that could more + // than double the requested web-delivery target. + return Math.max(MIN_MP4_BITRATE, requestedBitrate); +} diff --git a/src/lib/exporter/exportSavePolicy.test.ts b/src/lib/exporter/exportSavePolicy.test.ts new file mode 100644 index 000000000..fb60c2852 --- /dev/null +++ b/src/lib/exporter/exportSavePolicy.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; +import { + canUseInMemoryExportSaveFallback, + describeBlockedInMemoryExportSave, + isExportTooLargeForInMemorySave, + MAX_IN_MEMORY_EXPORT_BYTES, + normalizeExportExtension, +} from "./exportSavePolicy"; + +describe("exportSavePolicy", () => { + it("normalizes export extensions before policy checks", () => { + expect(normalizeExportExtension(" MP4 ")).toBe("mp4"); + }); + + it("blocks the legacy in-memory save path above Node's Buffer limit", () => { + expect(isExportTooLargeForInMemorySave(MAX_IN_MEMORY_EXPORT_BYTES + 1)).toBe(true); + expect( + canUseInMemoryExportSaveFallback({ + blobSize: MAX_IN_MEMORY_EXPORT_BYTES + 1, + extension: "gif", + hasExportStreamApi: false, + }), + ).toBe(false); + }); + + it("keeps Electron MP4 exports on the temp-file save path", () => { + expect( + canUseInMemoryExportSaveFallback({ + blobSize: 1024, + extension: "mp4", + hasExportStreamApi: true, + }), + ).toBe(false); + }); + + it("allows small non-MP4 exports to use the legacy save fallback", () => { + expect( + canUseInMemoryExportSaveFallback({ + blobSize: 1024, + extension: "gif", + hasExportStreamApi: false, + }), + ).toBe(true); + }); + + it("explains blocked large saves without mentioning implementation stack traces", () => { + expect( + describeBlockedInMemoryExportSave({ + blobSize: MAX_IN_MEMORY_EXPORT_BYTES + 1, + extension: "mp4", + }), + ).toContain("too large"); + }); +}); diff --git a/src/lib/exporter/exportSavePolicy.ts b/src/lib/exporter/exportSavePolicy.ts new file mode 100644 index 000000000..c48516ed3 --- /dev/null +++ b/src/lib/exporter/exportSavePolicy.ts @@ -0,0 +1,46 @@ +export const MAX_IN_MEMORY_EXPORT_BYTES = 0x7fffffff; + +export function normalizeExportExtension(extension: string): string { + return extension.trim().toLowerCase(); +} + +export function isExportTooLargeForInMemorySave(byteLength: number): boolean { + return byteLength > MAX_IN_MEMORY_EXPORT_BYTES; +} + +export function canUseInMemoryExportSaveFallback({ + blobSize, + extension, + hasExportStreamApi, +}: { + blobSize: number; + extension: string; + hasExportStreamApi: boolean; +}): boolean { + if (isExportTooLargeForInMemorySave(blobSize)) { + return false; + } + + // In Electron, MP4 exports should stay on the temp-file path. If that path + // failed, silently falling back to ArrayBuffer reintroduces the >2 GiB crash. + if (hasExportStreamApi && normalizeExportExtension(extension) === "mp4") { + return false; + } + + return true; +} + +export function describeBlockedInMemoryExportSave({ + blobSize, + extension, +}: { + blobSize: number; + extension: string; +}): string { + const normalizedExtension = normalizeExportExtension(extension) || "export"; + if (isExportTooLargeForInMemorySave(blobSize)) { + return `The ${normalizedExtension.toUpperCase()} export is too large to save through the legacy in-memory path. Please retry the export so Recordly can save it through the temp-file streaming path.`; + } + + return `The ${normalizedExtension.toUpperCase()} export could not be saved through the temp-file streaming path, and Recordly will not fall back to the legacy in-memory path for MP4 exports. Please retry the export.`; +} diff --git a/src/lib/exporter/exportTuning.test.ts b/src/lib/exporter/exportTuning.test.ts new file mode 100644 index 000000000..e2733916f --- /dev/null +++ b/src/lib/exporter/exportTuning.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from "vitest"; + +import { + getExportBackpressureProfile, + getPreferredWebCodecsLatencyModes, + getWebCodecsEncodeQueueLimit, + getWebCodecsKeyFrameInterval, +} from "./exportTuning"; + +describe("exportTuning", () => { + it("prefers realtime latency for fast and balanced exports", () => { + expect(getPreferredWebCodecsLatencyModes("fast")).toEqual(["realtime", "quality"]); + expect(getPreferredWebCodecsLatencyModes("balanced")).toEqual(["realtime", "quality"]); + expect(getPreferredWebCodecsLatencyModes("quality")).toEqual(["quality", "realtime"]); + }); + + it("keeps queue depth bounded by encoding mode", () => { + expect(getWebCodecsEncodeQueueLimit(60, "fast")).toBe(75); + expect(getWebCodecsEncodeQueueLimit(60, "balanced")).toBe(120); + expect(getWebCodecsEncodeQueueLimit(60, "quality")).toBe(144); + expect(getWebCodecsEncodeQueueLimit(240, "fast")).toBe(96); + expect(getWebCodecsEncodeQueueLimit(12, "balanced")).toBe(72); + }); + + it("widens keyframe spacing for faster modes", () => { + expect(getWebCodecsKeyFrameInterval(60, "fast")).toBe(240); + expect(getWebCodecsKeyFrameInterval(60, "balanced")).toBe(180); + expect(getWebCodecsKeyFrameInterval(60, "quality")).toBe(150); + }); + + it("uses shallower decode buffers for Breeze than for WebCodecs", () => { + const webCodecsProfile = getExportBackpressureProfile({ + encodeBackend: "webcodecs", + width: 1280, + height: 720, + frameRate: 60, + encodingMode: "balanced", + hardwareConcurrency: 8, + }); + const breezeProfile = getExportBackpressureProfile({ + encodeBackend: "ffmpeg", + width: 1280, + height: 720, + frameRate: 60, + encodingMode: "balanced", + hardwareConcurrency: 8, + }); + + expect(webCodecsProfile.name).toBe("webcodecs-balanced-plus"); + expect(webCodecsProfile.maxDecodeQueue).toBe(12); + expect(webCodecsProfile.maxPendingFrames).toBe(32); + expect(breezeProfile.name).toBe("breeze-balanced-plus"); + expect(breezeProfile.maxDecodeQueue).toBe(14); + expect(breezeProfile.maxPendingFrames).toBe(40); + expect(breezeProfile.maxInFlightNativeWrites).toBe(8); + }); + + it("falls back to conservative native settings on low-core or very heavy workloads", () => { + const breezeLowCoreProfile = getExportBackpressureProfile({ + encodeBackend: "ffmpeg", + width: 1280, + height: 720, + frameRate: 60, + hardwareConcurrency: 4, + }); + const breezeHeavyProfile = getExportBackpressureProfile({ + encodeBackend: "ffmpeg", + width: 3840, + height: 2160, + frameRate: 60, + hardwareConcurrency: 12, + }); + + expect(breezeLowCoreProfile.name).toBe("breeze-conservative"); + expect(breezeLowCoreProfile.maxDecodeQueue).toBe(8); + expect(breezeLowCoreProfile.maxPendingFrames).toBe(16); + expect(breezeLowCoreProfile.maxInFlightNativeWrites).toBe(2); + + expect(breezeHeavyProfile.name).toBe("breeze-conservative"); + expect(breezeHeavyProfile.maxDecodeQueue).toBe(8); + expect(breezeHeavyProfile.maxPendingFrames).toBe(16); + }); +}); diff --git a/src/lib/exporter/exportTuning.ts b/src/lib/exporter/exportTuning.ts new file mode 100644 index 000000000..8e54343b0 --- /dev/null +++ b/src/lib/exporter/exportTuning.ts @@ -0,0 +1,185 @@ +import type { ExportEncodeBackend, ExportEncodingMode } from "./types"; + +const DEFAULT_ENCODING_MODE: ExportEncodingMode = "balanced"; +type WebCodecsLatencyMode = "quality" | "realtime"; +const BASELINE_PIXELS_PER_SECOND = 1280 * 720 * 60; + +const LATENCY_MODE_PREFERENCES: Record<ExportEncodingMode, readonly WebCodecsLatencyMode[]> = { + fast: ["realtime", "quality"], + balanced: ["realtime", "quality"], + quality: ["quality", "realtime"], +}; + +const TARGET_QUEUE_SECONDS: Record<ExportEncodingMode, number> = { + fast: 1.25, + balanced: 2, + quality: 2.4, +}; + +const MIN_QUEUE_LIMIT: Record<ExportEncodingMode, number> = { + fast: 36, + balanced: 72, + quality: 96, +}; + +const MAX_QUEUE_LIMIT: Record<ExportEncodingMode, number> = { + fast: 96, + balanced: 120, + quality: 180, +}; + +const KEYFRAME_INTERVAL_SECONDS: Record<ExportEncodingMode, number> = { + fast: 4, + balanced: 3, + quality: 2.5, +}; + +function normalizeEncodingMode(encodingMode?: ExportEncodingMode): ExportEncodingMode { + return encodingMode ?? DEFAULT_ENCODING_MODE; +} + +function clamp(value: number, min: number, max: number): number { + return Math.min(Math.max(value, min), max); +} + +function getEffectiveHardwareConcurrency(hardwareConcurrency?: number): number { + if (typeof hardwareConcurrency === "number" && Number.isFinite(hardwareConcurrency)) { + return Math.max(1, Math.floor(hardwareConcurrency)); + } + + if ( + typeof navigator !== "undefined" && + typeof navigator.hardwareConcurrency === "number" && + Number.isFinite(navigator.hardwareConcurrency) + ) { + return Math.max(1, Math.floor(navigator.hardwareConcurrency)); + } + + return 8; +} + +function getRelativePixelRate(width: number, height: number, frameRate: number): number { + return ( + (Math.max(1, width) * Math.max(1, height) * Math.max(1, frameRate)) / + BASELINE_PIXELS_PER_SECOND + ); +} + +export interface ExportBackpressureProfile { + name: string; + maxEncodeQueue: number; + maxDecodeQueue: number; + maxPendingFrames: number; + maxInFlightNativeWrites: number; +} + +interface ExportBackpressureProfileOptions { + encodeBackend: ExportEncodeBackend; + width: number; + height: number; + frameRate: number; + encodingMode?: ExportEncodingMode; + hardwareConcurrency?: number; +} + +export function getPreferredWebCodecsLatencyModes( + encodingMode?: ExportEncodingMode, +): readonly WebCodecsLatencyMode[] { + return LATENCY_MODE_PREFERENCES[normalizeEncodingMode(encodingMode)]; +} + +export function getWebCodecsEncodeQueueLimit( + frameRate: number, + encodingMode?: ExportEncodingMode, +): number { + const resolvedEncodingMode = normalizeEncodingMode(encodingMode); + const targetLimit = Math.round(frameRate * TARGET_QUEUE_SECONDS[resolvedEncodingMode]); + + return clamp( + targetLimit, + MIN_QUEUE_LIMIT[resolvedEncodingMode], + MAX_QUEUE_LIMIT[resolvedEncodingMode], + ); +} + +export function getWebCodecsKeyFrameInterval( + frameRate: number, + encodingMode?: ExportEncodingMode, +): number { + const resolvedEncodingMode = normalizeEncodingMode(encodingMode); + return Math.max(1, Math.round(frameRate * KEYFRAME_INTERVAL_SECONDS[resolvedEncodingMode])); +} + +export function getExportBackpressureProfile( + options: ExportBackpressureProfileOptions, +): ExportBackpressureProfile { + const hardwareConcurrency = getEffectiveHardwareConcurrency(options.hardwareConcurrency); + const relativePixelRate = getRelativePixelRate( + options.width, + options.height, + options.frameRate, + ); + const isLowCoreSystem = hardwareConcurrency <= 4; + const isHighCoreSystem = hardwareConcurrency >= 8; + const isHeavyWorkload = relativePixelRate >= 1.5; + const isExtremeWorkload = relativePixelRate >= 3; + const maxEncodeQueue = getWebCodecsEncodeQueueLimit(options.frameRate, options.encodingMode); + + if (options.encodeBackend === "ffmpeg") { + if (isLowCoreSystem || isExtremeWorkload) { + return { + name: "breeze-conservative", + maxEncodeQueue, + maxDecodeQueue: 8, + maxPendingFrames: 16, + maxInFlightNativeWrites: 2, + }; + } + + if (isHighCoreSystem && !isHeavyWorkload) { + return { + name: "breeze-balanced-plus", + maxEncodeQueue, + maxDecodeQueue: 14, + maxPendingFrames: 40, + maxInFlightNativeWrites: 8, + }; + } + + return { + name: "breeze-balanced", + maxEncodeQueue, + maxDecodeQueue: 12, + maxPendingFrames: 28, + maxInFlightNativeWrites: 4, + }; + } + + if (isLowCoreSystem || isExtremeWorkload) { + return { + name: "webcodecs-conservative", + maxEncodeQueue, + maxDecodeQueue: 8, + maxPendingFrames: 20, + maxInFlightNativeWrites: 1, + }; + } + + if (isHighCoreSystem && !isHeavyWorkload) { + return { + name: "webcodecs-balanced-plus", + maxEncodeQueue, + maxDecodeQueue: 12, + maxPendingFrames: 32, + maxInFlightNativeWrites: 1, + }; + } + + return { + name: "webcodecs-balanced", + maxEncodeQueue, + maxDecodeQueue: 10, + maxPendingFrames: 24, + maxInFlightNativeWrites: 1, + }; +} diff --git a/src/lib/exporter/finalizationTimeout.test.ts b/src/lib/exporter/finalizationTimeout.test.ts new file mode 100644 index 000000000..7193470a9 --- /dev/null +++ b/src/lib/exporter/finalizationTimeout.test.ts @@ -0,0 +1,212 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + advanceFinalizationProgress, + type FinalizationProgressWatchdog, + getExportFinalizationIdleTimeoutMs, + getExportFinalizationTimeoutMs, + INITIAL_FINALIZATION_PROGRESS_STATE, + withFinalizationTimeout, +} from "./finalizationTimeout"; + +describe("finalizationTimeout", () => { + it("keeps non-audio finalization on the existing 10 minute timeout", () => { + expect(getExportFinalizationTimeoutMs({ workload: "default" })).toBe(600_000); + expect( + getExportFinalizationTimeoutMs({ + workload: "default", + effectiveDurationSec: 7_200, + }), + ).toBe(600_000); + }); + + it("gives audio finalization more headroom on longer exports", () => { + expect( + getExportFinalizationTimeoutMs({ + workload: "audio", + effectiveDurationSec: 1_200, + }), + ).toBe(1_200_000); + expect( + getExportFinalizationTimeoutMs({ + workload: "audio", + effectiveDurationSec: 2_700, + }), + ).toBe(1_950_000); + }); + + it("caps adaptive audio timeout growth", () => { + expect( + getExportFinalizationTimeoutMs({ + workload: "audio", + effectiveDurationSec: 10_800, + }), + ).toBe(2_700_000); + }); + + it("falls back to the base timeout for invalid audio durations", () => { + expect( + getExportFinalizationTimeoutMs({ + workload: "audio", + effectiveDurationSec: 0, + }), + ).toBe(600_000); + expect( + getExportFinalizationTimeoutMs({ + workload: "audio", + effectiveDurationSec: Number.NaN, + }), + ).toBe(600_000); + }); + + it("derives a bounded idle watchdog window from the total timeout", () => { + expect( + getExportFinalizationIdleTimeoutMs({ + workload: "default", + }), + ).toBe(150_000); + expect( + getExportFinalizationIdleTimeoutMs({ + workload: "audio", + effectiveDurationSec: 1_200, + }), + ).toBe(300_000); + expect( + getExportFinalizationIdleTimeoutMs({ + workload: "audio", + effectiveDurationSec: 2_700, + }), + ).toBe(300_000); + expect( + getExportFinalizationIdleTimeoutMs({ + workload: "audio", + effectiveDurationSec: 0, + }), + ).toBe(150_000); + expect( + getExportFinalizationIdleTimeoutMs({ + workload: "audio", + effectiveDurationSec: Number.NaN, + }), + ).toBe(150_000); + }); + + it("rejects when a progress-aware finalization stage stops reporting progress", async () => { + vi.useFakeTimers(); + + try { + const idleTimeoutMs = getExportFinalizationIdleTimeoutMs({ + workload: "audio", + effectiveDurationSec: 1_200, + }); + + const pendingStage = withFinalizationTimeout({ + promise: new Promise<never>(() => {}), + stage: "audio processing", + workload: "audio", + effectiveDurationSec: 1_200, + progressAware: true, + }); + + const rejection = pendingStage.then( + () => null, + (error) => (error instanceof Error ? error.message : String(error)), + ); + + await vi.advanceTimersByTimeAsync(idleTimeoutMs + 1); + + await expect(rejection).resolves.toContain("without observable progress"); + } finally { + vi.useRealTimers(); + } + }); + + it("resets the idle watchdog when finalization progress continues", async () => { + vi.useFakeTimers(); + + try { + const idleTimeoutMs = getExportFinalizationIdleTimeoutMs({ + workload: "audio", + effectiveDurationSec: 1_200, + }); + let watchdog: FinalizationProgressWatchdog | null = null; + const pendingStage = withFinalizationTimeout({ + promise: new Promise<never>(() => {}), + stage: "audio processing", + workload: "audio", + effectiveDurationSec: 1_200, + progressAware: true, + onWatchdogChanged: (nextWatchdog) => { + watchdog = nextWatchdog; + }, + }); + const rejection = pendingStage.then( + () => null, + (error) => (error instanceof Error ? error.message : String(error)), + ); + + await vi.advanceTimersByTimeAsync(idleTimeoutMs - 1_000); + expect(watchdog).not.toBeNull(); + + watchdog?.refreshProgress(); + await vi.advanceTimersByTimeAsync(idleTimeoutMs - 1_000); + + const pendingSentinel = Symbol("pending"); + await expect(Promise.race([rejection, Promise.resolve(pendingSentinel)])).resolves.toBe( + pendingSentinel, + ); + + await vi.advanceTimersByTimeAsync(1_001); + await expect(rejection).resolves.toContain("without observable progress"); + } finally { + vi.useRealTimers(); + } + }); + + it("only marks finalization as progressed when normalized progress increases", () => { + const initial = advanceFinalizationProgress({ + renderProgress: 99, + audioProgress: 0.5, + state: INITIAL_FINALIZATION_PROGRESS_STATE, + }); + expect(initial.progressed).toBe(true); + expect(initial.lastRenderProgress).toBe(99); + expect(initial.lastAudioProgress).toBe(0.5); + + const repeated = advanceFinalizationProgress({ + renderProgress: 99, + audioProgress: 0.5, + state: initial, + }); + expect(repeated.progressed).toBe(false); + + const advanced = advanceFinalizationProgress({ + renderProgress: 100, + audioProgress: 0.5, + state: repeated, + }); + expect(advanced.progressed).toBe(true); + expect(advanced.lastRenderProgress).toBe(100); + expect(advanced.lastAudioProgress).toBe(0.5); + }); + + it("ignores non-finite render progress without poisoning later updates", () => { + const invalid = advanceFinalizationProgress({ + renderProgress: Number.NaN, + audioProgress: 0.25, + state: INITIAL_FINALIZATION_PROGRESS_STATE, + }); + expect(invalid.progressed).toBe(true); + expect(invalid.lastRenderProgress).toBe(-1); + expect(invalid.lastAudioProgress).toBe(0.25); + + const recovered = advanceFinalizationProgress({ + renderProgress: 99, + audioProgress: 0.25, + state: invalid, + }); + expect(recovered.progressed).toBe(true); + expect(recovered.lastRenderProgress).toBe(99); + expect(recovered.lastAudioProgress).toBe(0.25); + }); +}); diff --git a/src/lib/exporter/finalizationTimeout.ts b/src/lib/exporter/finalizationTimeout.ts new file mode 100644 index 000000000..040409fde --- /dev/null +++ b/src/lib/exporter/finalizationTimeout.ts @@ -0,0 +1,181 @@ +export type FinalizationTimeoutWorkload = "default" | "audio"; +export type FinalizationProgressWatchdog = { + refreshProgress: () => void; +}; +export type FinalizationProgressState = { + lastRenderProgress: number; + lastAudioProgress: number; +}; + +const BASE_FINALIZATION_TIMEOUT_MS = 10 * 60_000; +const AUDIO_TIMEOUT_HEADROOM_PER_OUTPUT_SECOND_MS = 500; +const MAX_AUDIO_FINALIZATION_TIMEOUT_MS = 45 * 60_000; +const MIN_PROGRESS_IDLE_TIMEOUT_MS = 90_000; +const MAX_PROGRESS_IDLE_TIMEOUT_MS = 5 * 60_000; +const PROGRESS_IDLE_TIMEOUT_FRACTION = 0.25; + +export const INITIAL_FINALIZATION_PROGRESS_STATE: FinalizationProgressState = { + lastRenderProgress: -1, + lastAudioProgress: -1, +}; + +export function getExportFinalizationTimeoutMs({ + effectiveDurationSec, + workload = "default", +}: { + effectiveDurationSec?: number | null; + workload?: FinalizationTimeoutWorkload; +}): number { + if (workload !== "audio") { + return BASE_FINALIZATION_TIMEOUT_MS; + } + + const safeEffectiveDurationSec = + typeof effectiveDurationSec === "number" ? effectiveDurationSec : Number.NaN; + if (!Number.isFinite(safeEffectiveDurationSec) || safeEffectiveDurationSec <= 0) { + return BASE_FINALIZATION_TIMEOUT_MS; + } + + // Audio finalization work scales with the output timeline, so long exports need + // more headroom without making unrelated finalization hangs wait longer. + const adaptiveTimeoutMs = + BASE_FINALIZATION_TIMEOUT_MS + + safeEffectiveDurationSec * AUDIO_TIMEOUT_HEADROOM_PER_OUTPUT_SECOND_MS; + + return Math.min(adaptiveTimeoutMs, MAX_AUDIO_FINALIZATION_TIMEOUT_MS); +} + +export function getExportFinalizationIdleTimeoutMs({ + effectiveDurationSec, + workload = "default", +}: { + effectiveDurationSec?: number | null; + workload?: FinalizationTimeoutWorkload; +}): number { + const totalTimeoutMs = getExportFinalizationTimeoutMs({ + effectiveDurationSec, + workload, + }); + + return Math.min( + Math.max( + Math.floor(totalTimeoutMs * PROGRESS_IDLE_TIMEOUT_FRACTION), + MIN_PROGRESS_IDLE_TIMEOUT_MS, + ), + MAX_PROGRESS_IDLE_TIMEOUT_MS, + ); +} + +export function advanceFinalizationProgress({ + renderProgress, + audioProgress, + state, +}: { + renderProgress: number; + audioProgress?: number; + state: FinalizationProgressState; +}): FinalizationProgressState & { progressed: boolean } { + const normalizedRenderProgress = + typeof renderProgress === "number" && Number.isFinite(renderProgress) + ? Math.max(0, Math.min(renderProgress, 100)) + : null; + const normalizedAudioProgress = + typeof audioProgress === "number" && Number.isFinite(audioProgress) + ? Math.max(0, Math.min(audioProgress, 1)) + : null; + const nextRenderProgress = + normalizedRenderProgress === null + ? state.lastRenderProgress + : Math.max(state.lastRenderProgress, normalizedRenderProgress); + const nextAudioProgress = + normalizedAudioProgress === null + ? state.lastAudioProgress + : Math.max(state.lastAudioProgress, normalizedAudioProgress); + + return { + progressed: + nextRenderProgress > state.lastRenderProgress || + nextAudioProgress > state.lastAudioProgress, + lastRenderProgress: nextRenderProgress, + lastAudioProgress: nextAudioProgress, + }; +} + +export async function withFinalizationTimeout<T>({ + promise, + stage, + effectiveDurationSec, + workload = "default", + progressAware = false, + onWatchdogChanged, +}: { + promise: Promise<T>; + stage: string; + effectiveDurationSec?: number | null; + workload?: FinalizationTimeoutWorkload; + progressAware?: boolean; + onWatchdogChanged?: (watchdog: FinalizationProgressWatchdog | null) => void; +}): Promise<T> { + let timeoutId: ReturnType<typeof setTimeout> | null = null; + let idleTimeoutId: ReturnType<typeof setTimeout> | null = null; + const timeoutMs = getExportFinalizationTimeoutMs({ + effectiveDurationSec, + workload, + }); + const idleTimeoutMs = progressAware + ? getExportFinalizationIdleTimeoutMs({ + effectiveDurationSec, + workload, + }) + : null; + const hasIdleWatchdog = + progressAware && + typeof idleTimeoutMs === "number" && + Number.isFinite(idleTimeoutMs) && + idleTimeoutMs >= 0; + const resolvedIdleTimeoutMs = hasIdleWatchdog ? idleTimeoutMs : null; + const watchdog: FinalizationProgressWatchdog | null = hasIdleWatchdog + ? { + refreshProgress: () => undefined, + } + : null; + + try { + return await Promise.race([ + promise, + new Promise<T>((_, reject) => { + const rejectWithMessage = (message: string) => { + reject(new Error(message)); + }; + if (watchdog && resolvedIdleTimeoutMs !== null) { + const refreshProgress = () => { + if (idleTimeoutId) { + clearTimeout(idleTimeoutId); + } + idleTimeoutId = setTimeout(() => { + rejectWithMessage( + `Export timed out during ${stage} after ${Math.ceil(resolvedIdleTimeoutMs / 1000)} seconds without observable progress`, + ); + }, resolvedIdleTimeoutMs); + }; + watchdog.refreshProgress = refreshProgress; + onWatchdogChanged?.(watchdog); + refreshProgress(); + } + timeoutId = setTimeout(() => { + rejectWithMessage( + `Export timed out during ${stage} after ${Math.ceil(timeoutMs / 60_000)} minutes`, + ); + }, timeoutMs); + }), + ]); + } finally { + if (timeoutId) { + clearTimeout(timeoutId); + } + if (idleTimeoutId) { + clearTimeout(idleTimeoutId); + } + onWatchdogChanged?.(null); + } +} diff --git a/src/lib/exporter/forwardFrameSource.ts b/src/lib/exporter/forwardFrameSource.ts new file mode 100644 index 000000000..bd1afde7a --- /dev/null +++ b/src/lib/exporter/forwardFrameSource.ts @@ -0,0 +1,396 @@ +import { WebDemuxer } from "web-demuxer"; +import { getEffectiveVideoStreamDurationSeconds } from "@/lib/mediaTiming"; +import { createFallbackDemuxerSource, resolveMediaResourceUrl } from "./localMediaSource"; +import { getDecodedFrameTimelineOffsetUs } from "./streamingDecoder"; + +const DEFAULT_MAX_DECODE_QUEUE = 12; +const DEFAULT_MAX_PENDING_FRAMES = 32; + +export interface ForwardFrameSourceMetadata { + width: number; + height: number; + duration: number; + mediaStartTime?: number; + streamStartTime?: number; + streamDuration?: number; + codec: string; +} + +/** + * Forward-only decoded frame source for monotonic timestamp access. + * + * This avoids per-frame HTMLVideoElement seeking during export and returns + * the nearest decoded frame for increasing target timestamps. + */ +export class ForwardFrameSource { + private demuxer: WebDemuxer | null = null; + private decoder: VideoDecoder | null = null; + private cancelled = false; + private metadata: ForwardFrameSourceMetadata | null = null; + private pendingFrames: VideoFrame[] = []; + private frameResolve: ((frame: VideoFrame | null) => void) | null = null; + private decodeError: Error | null = null; + private decodeDone = false; + private feedPromise: Promise<void> | null = null; + private reader: ReadableStreamDefaultReader<EncodedVideoChunk> | null = null; + private heldFrame: VideoFrame | null = null; + private heldFrameSec = 0; + private lastTargetTimeSec = 0; + private lastFrameIntervalSec = 0; + private resolvedDecodedDurationSec: number | null = null; + private firstFrameTimestampUs: number | null = null; + private frameTimelineOffsetUs = 0; + private decodeCapacityWaiters = new Set<() => void>(); + + async initialize(videoUrl: string): Promise<ForwardFrameSourceMetadata> { + const resourceUrl = await resolveMediaResourceUrl(videoUrl); + const wasmUrl = new URL("./wasm/web-demuxer.wasm", window.location.href).href; + const loadMediaInfo = async (source: string | File) => { + this.demuxer = new WebDemuxer({ wasmFilePath: wasmUrl }); + await this.demuxer.load(source); + return this.demuxer.getMediaInfo(); + }; + + let mediaInfo; + try { + mediaInfo = await loadMediaInfo(resourceUrl); + } catch (error) { + console.warn( + "[ForwardFrameSource] Direct source load failed, retrying with a fresh media source:", + error, + ); + const currentDemuxer = this.demuxer; + if (currentDemuxer) { + try { + (currentDemuxer as unknown as { destroy: () => void }).destroy(); + } catch { + // Ignore cleanup errors before fallback re-init. + } + } + mediaInfo = await loadMediaInfo(await createFallbackDemuxerSource(videoUrl)); + } + + const videoStream = mediaInfo.streams.find( + (stream) => stream.codec_type_string === "video", + ); + const mediaStartTime = + typeof mediaInfo.start_time === "number" && Number.isFinite(mediaInfo.start_time) + ? mediaInfo.start_time + : 0; + const streamStartTime = + typeof videoStream?.start_time === "number" && Number.isFinite(videoStream.start_time) + ? videoStream.start_time + : mediaStartTime; + + this.metadata = { + width: videoStream?.width || 0, + height: videoStream?.height || 0, + duration: mediaInfo.duration, + mediaStartTime, + streamStartTime, + streamDuration: + typeof videoStream?.duration === "number" && Number.isFinite(videoStream.duration) + ? videoStream.duration + : undefined, + codec: videoStream?.codec_string || "unknown", + }; + + await this.startDecoder(); + return this.metadata; + } + + private async startDecoder(): Promise<void> { + if (!this.demuxer || !this.metadata) { + throw new Error("Must call initialize() before starting decoder"); + } + + const decoderConfig = await this.demuxer.getDecoderConfig("video"); + const codec = this.metadata.codec.toLowerCase(); + const shouldPreferSoftwareDecode = codec.includes("av01") || codec.includes("av1"); + + this.decoder = new VideoDecoder({ + output: (frame: VideoFrame) => { + if (this.frameResolve) { + const resolve = this.frameResolve; + this.frameResolve = null; + resolve(frame); + } else { + this.pendingFrames.push(frame); + } + this.notifyDecoderCapacityAvailable(); + }, + error: (error: DOMException) => { + this.decodeError = new Error(`VideoDecoder error: ${error.message}`); + if (this.frameResolve) { + const resolve = this.frameResolve; + this.frameResolve = null; + resolve(null); + } + this.notifyDecoderCapacityAvailable(); + }, + }); + + const preferredDecoderConfig = shouldPreferSoftwareDecode + ? { + ...decoderConfig, + hardwareAcceleration: "prefer-software" as const, + } + : decoderConfig; + + try { + this.decoder.configure(preferredDecoderConfig); + } catch (error) { + if (!shouldPreferSoftwareDecode) { + throw error; + } + this.decoder.configure(decoderConfig); + } + + const readEndSec = + Math.max( + this.metadata.duration + (this.metadata.mediaStartTime ?? 0), + (this.metadata.streamDuration ?? this.metadata.duration) + + (this.metadata.streamStartTime ?? this.metadata.mediaStartTime ?? 0), + ) + 0.5; + this.reader = this.demuxer.read("video", 0, readEndSec).getReader(); + + this.feedPromise = (async () => { + try { + while (!this.cancelled) { + const { done, value: chunk } = await this.reader!.read(); + if (done || !chunk) { + break; + } + + while ( + (this.decoder!.decodeQueueSize > DEFAULT_MAX_DECODE_QUEUE || + this.pendingFrames.length > DEFAULT_MAX_PENDING_FRAMES) && + !this.cancelled + ) { + await this.waitForDecoderCapacity(); + } + + if (this.cancelled) { + break; + } + + this.decoder!.decode(chunk); + } + + if (!this.cancelled && this.decoder?.state === "configured") { + await this.decoder.flush(); + } + } catch (error) { + this.decodeError = error instanceof Error ? error : new Error(String(error)); + } finally { + this.decodeDone = true; + this.notifyDecoderCapacityAvailable(); + if (this.frameResolve) { + const resolve = this.frameResolve; + this.frameResolve = null; + resolve(null); + } + } + })(); + } + + private getNextFrame(): Promise<VideoFrame | null> { + if (this.decodeError) { + throw this.decodeError; + } + + if (this.pendingFrames.length > 0) { + const frame = this.pendingFrames.shift()!; + this.notifyDecoderCapacityAvailable(); + return Promise.resolve(frame); + } + + if (this.decodeDone) { + return Promise.resolve(null); + } + + if (this.frameResolve) { + throw new Error("Concurrent getFrameAtTime() calls are not supported"); + } + + return new Promise((resolve) => { + this.frameResolve = resolve; + }); + } + + private waitForDecoderCapacity(): Promise<void> { + return new Promise((resolve) => { + this.decodeCapacityWaiters.add(resolve); + }); + } + + private notifyDecoderCapacityAvailable(): void { + if (this.decodeCapacityWaiters.size === 0) { + return; + } + + const waiters = [...this.decodeCapacityWaiters]; + this.decodeCapacityWaiters.clear(); + for (const resolve of waiters) { + resolve(); + } + } + + async getFrameAtTime(targetTimeSec: number): Promise<VideoFrame | null> { + if (!this.metadata) { + throw new Error("Frame source not initialized"); + } + + const clampedTargetTime = Math.max( + 0, + Math.min( + targetTimeSec, + getEffectiveVideoStreamDurationSeconds({ + duration: this.metadata.duration, + streamDuration: this.metadata.streamDuration, + }) || targetTimeSec, + ), + ); + if (clampedTargetTime + 0.001 < this.lastTargetTimeSec) { + throw new Error("ForwardFrameSource only supports increasing timestamps"); + } + this.lastTargetTimeSec = clampedTargetTime; + + if (!this.heldFrame) { + const firstFrame = await this.getNextFrame(); + if (!firstFrame) { + return null; + } + this.firstFrameTimestampUs = firstFrame.timestamp; + this.frameTimelineOffsetUs = getDecodedFrameTimelineOffsetUs( + firstFrame.timestamp, + this.metadata, + ); + this.heldFrame = firstFrame; + this.heldFrameSec = Math.max(0, this.frameTimelineOffsetUs / 1_000_000); + } + + while (!this.cancelled) { + const nextFrame = await this.getNextFrame(); + if (!nextFrame) { + this.resolvedDecodedDurationSec = Math.max( + this.heldFrameSec, + this.heldFrameSec + Math.max(0, this.lastFrameIntervalSec), + ); + return new VideoFrame(this.heldFrame, { + timestamp: this.heldFrame.timestamp, + }); + } + + const nextFrameSec = Math.max( + this.heldFrameSec, + Math.max( + 0, + (nextFrame.timestamp - + (this.firstFrameTimestampUs ?? nextFrame.timestamp) + + this.frameTimelineOffsetUs) / + 1_000_000, + ), + ); + const handoffBoundarySec = (this.heldFrameSec + nextFrameSec) / 2; + if (clampedTargetTime <= handoffBoundarySec) { + this.pendingFrames.unshift(nextFrame); + return new VideoFrame(this.heldFrame, { + timestamp: this.heldFrame.timestamp, + }); + } + + this.lastFrameIntervalSec = Math.max(0, nextFrameSec - this.heldFrameSec); + this.heldFrame.close(); + this.heldFrame = nextFrame; + this.heldFrameSec = nextFrameSec; + } + + return null; + } + + getResolvedDurationSec(): number | null { + return this.resolvedDecodedDurationSec; + } + + hasReachedEndOfStream(): boolean { + return this.decodeDone && this.pendingFrames.length === 0; + } + + cancel(): void { + this.cancelled = true; + this.notifyDecoderCapacityAvailable(); + if (this.frameResolve) { + const resolve = this.frameResolve; + this.frameResolve = null; + resolve(null); + } + if (this.reader) { + void this.reader.cancel().catch(() => { + // Ignore cancellation errors during shutdown. + }); + } + } + + async destroy(): Promise<void> { + this.cancelled = true; + + if (this.reader) { + try { + await this.reader.cancel(); + } catch { + // Ignore cancellation errors during shutdown. + } + this.reader = null; + } + + if (this.feedPromise) { + try { + await this.feedPromise; + } catch { + // Decoder errors are already surfaced through getFrameAtTime. + } + this.feedPromise = null; + } + + if (this.heldFrame) { + this.heldFrame.close(); + this.heldFrame = null; + } + + for (const frame of this.pendingFrames) { + frame.close(); + } + this.pendingFrames = []; + + if (this.decoder) { + try { + if (this.decoder.state === "configured") { + this.decoder.close(); + } + } catch { + // Ignore decoder shutdown errors. + } + this.decoder = null; + } + + if (this.demuxer) { + try { + this.demuxer.destroy(); + } catch { + // Ignore demuxer shutdown errors. + } + this.demuxer = null; + } + + this.metadata = null; + this.decodeDone = false; + this.decodeError = null; + this.lastTargetTimeSec = 0; + this.lastFrameIntervalSec = 0; + this.resolvedDecodedDurationSec = null; + this.firstFrameTimestampUs = null; + this.frameTimelineOffsetUs = 0; + this.decodeCapacityWaiters.clear(); + } +} diff --git a/src/lib/exporter/frameRenderer.test.ts b/src/lib/exporter/frameRenderer.test.ts new file mode 100644 index 000000000..32af67a9d --- /dev/null +++ b/src/lib/exporter/frameRenderer.test.ts @@ -0,0 +1,569 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { DEFAULT_WEBCAM_OVERLAY } from "../../components/video-editor/types"; + +const { + cancelForwardFrameSourceMock, + destroyForwardFrameSourceMock, + getForwardFrameAtTimeMock, + initializeForwardFrameSourceMock, + resolveMediaElementSourceMock, +} = vi.hoisted(() => ({ + cancelForwardFrameSourceMock: vi.fn(), + destroyForwardFrameSourceMock: vi.fn(async () => undefined), + getForwardFrameAtTimeMock: vi.fn(async () => null), + initializeForwardFrameSourceMock: vi.fn(async () => undefined), + resolveMediaElementSourceMock: vi.fn(async () => ({ + src: "blob:background", + revoke: vi.fn(), + })), +})); + +vi.mock("pixi.js", () => ({ + Application: vi.fn(), + Container: vi.fn(), + Sprite: vi.fn(), + Graphics: vi.fn(), + BlurFilter: vi.fn(), + Texture: { + from: vi.fn(() => ({ destroy: vi.fn() })), + }, +})); + +vi.mock("pixi-filters/motion-blur", () => ({ + MotionBlurFilter: vi.fn(), +})); + +vi.mock("@/lib/assetPath", () => ({ + getAssetPath: vi.fn(async (value: string) => value), + getExportableVideoUrl: vi.fn(async (value: string) => value), + getRenderableAssetUrl: vi.fn((value: string) => value), +})); + +vi.mock("@/components/video-editor/videoPlayback/zoomRegionUtils", () => ({ + findDominantRegion: vi.fn(() => ({ + region: null, + strength: 0, + blendedScale: 1, + transition: null, + })), +})); + +vi.mock("@/components/video-editor/videoPlayback/zoomTransform", () => ({ + applyZoomTransform: vi.fn(), + computeFocusFromTransform: vi.fn(() => ({ cx: 0.5, cy: 0.5 })), + computeZoomTransform: vi.fn(() => ({ scale: 1, x: 0, y: 0 })), + createMotionBlurState: vi.fn(() => ({})), +})); + +vi.mock("./annotationRenderer", () => ({ + renderAnnotations: vi.fn(), +})); + +vi.mock("@/components/video-editor/videoPlayback/cursorRenderer", () => ({ + PixiCursorOverlay: class { + container = {}; + update = vi.fn(); + destroy = vi.fn(); + }, + DEFAULT_CURSOR_CONFIG: { + dotRadius: 28, + smoothingFactor: 0.18, + motionBlur: 0, + clickBounce: 1, + sway: 0, + }, + preloadCursorAssets: vi.fn(async () => undefined), +})); + +vi.mock("./forwardFrameSource", () => ({ + ForwardFrameSource: class { + cancel = cancelForwardFrameSourceMock; + destroy = destroyForwardFrameSourceMock; + getFrameAtTime = getForwardFrameAtTimeMock; + initialize = initializeForwardFrameSourceMock; + }, +})); + +vi.mock("./localMediaSource", () => ({ + resolveMediaElementSource: resolveMediaElementSourceMock, +})); + +import { FrameRenderer } from "./frameRenderer"; + +type MockFunction = ReturnType<typeof vi.fn>; +type MockContext = { + beginPath: MockFunction; + moveTo: MockFunction; + lineTo: MockFunction; + closePath: MockFunction; + clip: MockFunction; + drawImage: MockFunction; + fillRect: MockFunction; + save: MockFunction; + restore: MockFunction; + translate: MockFunction; + scale: MockFunction; + clearRect: MockFunction; + filter: string; +}; +type MockCanvas = ReturnType<typeof createMockCanvas>; +type FrameRendererTestAccess = { + webcamVideoElement: FakeVideoElement | null; + webcamSeekPromise: Promise<void> | null; + webcamFrameCacheCanvas: MockCanvas | null; + webcamFrameCacheCtx: CanvasRenderingContext2D | null; + lastSyncedWebcamTime: number | null; + currentVideoTime: number; + animationState: { appliedScale: number }; + syncWebcamFrame: (targetTimeSec: number) => Promise<void>; + drawWebcamOverlay: ( + outputCtx: CanvasRenderingContext2D, + outputWidth: number, + outputHeight: number, + ) => void; +}; + +type Listener = { + callback: () => void; + once: boolean; +}; + +class FakeVideoElement { + duration: number; + readyState: number; + seeking = false; + videoWidth: number; + videoHeight: number; + muted = true; + preload = "auto"; + playsInline = true; + src = ""; + + private currentTimeValue: number; + private listeners = new Map<string, Listener[]>(); + + constructor({ + duration = 5, + currentTime = 0, + readyState = 2, + videoWidth = 1280, + videoHeight = 720, + }: { + duration?: number; + currentTime?: number; + readyState?: number; + videoWidth?: number; + videoHeight?: number; + } = {}) { + this.duration = duration; + this.currentTimeValue = currentTime; + this.readyState = readyState; + this.videoWidth = videoWidth; + this.videoHeight = videoHeight; + } + + get currentTime() { + return this.currentTimeValue; + } + + set currentTime(next: number) { + this.currentTimeValue = next; + this.seeking = true; + queueMicrotask(() => { + this.seeking = false; + this.dispatch("seeked"); + }); + } + + addEventListener( + name: string, + callback: () => void, + options?: boolean | AddEventListenerOptions, + ) { + const listeners = this.listeners.get(name) ?? []; + listeners.push({ + callback, + once: !!(typeof options === "object" && options?.once), + }); + this.listeners.set(name, listeners); + } + + removeEventListener(name: string, callback: () => void) { + const listeners = this.listeners.get(name) ?? []; + this.listeners.set( + name, + listeners.filter((listener) => listener.callback !== callback), + ); + } + + load() { + // Intentional no-op for the mock video element. + } + + pause() { + // Intentional no-op for the mock video element. + } + + private dispatch(name: string) { + const listeners = [...(this.listeners.get(name) ?? [])]; + if (listeners.length === 0) { + return; + } + + for (const listener of listeners) { + listener.callback(); + if (listener.once) { + this.removeEventListener(name, listener.callback); + } + } + } +} + +function createMockContext() { + return { + beginPath: vi.fn(), + moveTo: vi.fn(), + lineTo: vi.fn(), + closePath: vi.fn(), + clip: vi.fn(), + drawImage: vi.fn(), + fillRect: vi.fn(), + save: vi.fn(), + restore: vi.fn(), + translate: vi.fn(), + scale: vi.fn(), + clearRect: vi.fn(), + filter: "", + }; +} + +function createMockCanvas() { + const context = createMockContext(); + return { + width: 0, + height: 0, + context, + getContext: vi.fn((_type?: string) => context as unknown as CanvasRenderingContext2D), + }; +} + +function createRenderer() { + return new FrameRenderer({ + width: 1920, + height: 1080, + wallpaper: "#000000", + zoomRegions: [], + showShadow: false, + shadowIntensity: 0, + backgroundBlur: 0, + cropRegion: { x: 0, y: 0, width: 1, height: 1 }, + webcam: { + ...DEFAULT_WEBCAM_OVERLAY, + enabled: true, + mirror: false, + shadow: 0, + }, + webcamUrl: "file:///tmp/webcam.webm", + videoWidth: 1920, + videoHeight: 1080, + }); +} + +describe("FrameRenderer webcam export path", () => { + const createdCanvases: ReturnType<typeof createMockCanvas>[] = []; + + beforeEach(() => { + createdCanvases.length = 0; + + Object.assign(globalThis, { + window: globalThis, + requestAnimationFrame: (callback: FrameRequestCallback) => { + callback(0); + return 1; + }, + cancelAnimationFrame: vi.fn(), + HTMLMediaElement: { + HAVE_CURRENT_DATA: 2, + }, + document: { + createElement: vi.fn((tag: string) => { + if (tag === "video") { + return new FakeVideoElement(); + } + if (tag !== "canvas") { + throw new Error(`Unexpected element requested in test: ${tag}`); + } + + const canvas = createMockCanvas(); + createdCanvases.push(canvas); + return canvas; + }), + }, + }); + }); + + it("clamps webcam sync seeks to the media duration", async () => { + const renderer = createRenderer() as unknown as FrameRendererTestAccess; + const webcamVideo = new FakeVideoElement({ duration: 4.5, currentTime: 0.25 }); + renderer.webcamVideoElement = webcamVideo; + + await renderer.syncWebcamFrame(12); + + expect(webcamVideo.currentTime).toBe(4.5); + expect(renderer.lastSyncedWebcamTime).toBe(4.5); + expect(renderer.webcamSeekPromise).toBeNull(); + }); + + it("subtracts stored webcam offsets during export sync", async () => { + const renderer = createRenderer() as unknown as FrameRendererTestAccess & { + config: { webcam?: { timeOffsetMs?: number } }; + }; + const webcamVideo = new FakeVideoElement({ duration: 10, currentTime: 0.25 }); + renderer.webcamVideoElement = webcamVideo; + renderer.config.webcam = { + ...(renderer.config.webcam ?? {}), + timeOffsetMs: 250, + }; + + await renderer.syncWebcamFrame(2); + + expect(webcamVideo.currentTime).toBe(1.75); + expect(renderer.lastSyncedWebcamTime).toBe(1.75); + expect(renderer.webcamSeekPromise).toBeNull(); + }); + + it("falls back to animation frame when requestVideoFrameCallback does not fire", async () => { + const renderer = createRenderer() as unknown as FrameRendererTestAccess; + const webcamVideo = new FakeVideoElement({ + duration: 4.5, + currentTime: 0.25, + }) as FakeVideoElement & { + requestVideoFrameCallback?: (callback: () => void) => number; + cancelVideoFrameCallback?: (handle: number) => void; + }; + webcamVideo.requestVideoFrameCallback = vi.fn(() => 7); + webcamVideo.cancelVideoFrameCallback = vi.fn(); + renderer.webcamVideoElement = webcamVideo; + + await renderer.syncWebcamFrame(1.5); + + expect(webcamVideo.currentTime).toBe(1.5); + expect(renderer.lastSyncedWebcamTime).toBe(1.5); + expect(webcamVideo.requestVideoFrameCallback).toHaveBeenCalledTimes(1); + expect(webcamVideo.cancelVideoFrameCallback).not.toHaveBeenCalled(); + expect(renderer.webcamSeekPromise).toBeNull(); + }); + + it("uses the cached webcam frame when the live video is out of sync", () => { + const renderer = createRenderer() as unknown as FrameRendererTestAccess; + const outputContext = createMockContext(); + const webcamVideo = new FakeVideoElement({ + currentTime: 2, + readyState: 2, + videoWidth: 640, + videoHeight: 360, + }); + const cachedFrameCanvas = createMockCanvas(); + cachedFrameCanvas.width = 640; + cachedFrameCanvas.height = 360; + + renderer.webcamVideoElement = webcamVideo; + renderer.webcamFrameCacheCanvas = cachedFrameCanvas; + renderer.webcamFrameCacheCtx = cachedFrameCanvas.getContext("2d"); + renderer.lastSyncedWebcamTime = 1.5; + renderer.currentVideoTime = 2; + renderer.animationState.appliedScale = 1; + + renderer.drawWebcamOverlay(outputContext as unknown as CanvasRenderingContext2D, 1280, 720); + + const bubbleCanvas = createdCanvases[0]; + expect(bubbleCanvas).toBeDefined(); + expect((bubbleCanvas.context as MockContext).drawImage.mock.calls[0][0]).toBe( + cachedFrameCanvas, + ); + expect((outputContext as MockContext).drawImage.mock.calls[0][0]).toBe(bubbleCanvas); + }); + + it("keeps drawing the cached webcam frame when the live element temporarily has no current data", () => { + const renderer = createRenderer() as unknown as FrameRendererTestAccess; + const outputContext = createMockContext(); + const webcamVideo = new FakeVideoElement({ + currentTime: 2, + readyState: 0, + videoWidth: 640, + videoHeight: 360, + }); + const cachedFrameCanvas = createMockCanvas(); + cachedFrameCanvas.width = 640; + cachedFrameCanvas.height = 360; + + renderer.webcamVideoElement = webcamVideo; + renderer.webcamFrameCacheCanvas = cachedFrameCanvas; + renderer.webcamFrameCacheCtx = cachedFrameCanvas.getContext("2d"); + renderer.lastSyncedWebcamTime = 2; + renderer.currentVideoTime = 2; + renderer.animationState.appliedScale = 1; + + renderer.drawWebcamOverlay(outputContext as unknown as CanvasRenderingContext2D, 1280, 720); + + const bubbleCanvas = createdCanvases[0]; + expect(bubbleCanvas).toBeDefined(); + expect((bubbleCanvas.context as MockContext).drawImage.mock.calls[0][0]).toBe( + cachedFrameCanvas, + ); + expect((outputContext as MockContext).drawImage.mock.calls[0][0]).toBe(bubbleCanvas); + }); + + it("uses the live webcam frame and refreshes the cache when the video is synchronized", () => { + const renderer = createRenderer() as unknown as FrameRendererTestAccess; + const outputContext = createMockContext(); + const webcamVideo = new FakeVideoElement({ + currentTime: 2, + readyState: 2, + videoWidth: 800, + videoHeight: 600, + }); + + renderer.webcamVideoElement = webcamVideo; + renderer.lastSyncedWebcamTime = 2; + renderer.currentVideoTime = 2; + renderer.animationState.appliedScale = 1; + + renderer.drawWebcamOverlay(outputContext as unknown as CanvasRenderingContext2D, 1280, 720); + + const bubbleCanvas = createdCanvases[0]; + const cacheCanvas = createdCanvases[1]; + expect(cacheCanvas).toBeDefined(); + expect((cacheCanvas.context as MockContext).drawImage.mock.calls[0][0]).toBe(webcamVideo); + expect((bubbleCanvas.context as MockContext).drawImage.mock.calls[0][0]).toBe(cacheCanvas); + expect((outputContext as MockContext).drawImage.mock.calls[0][0]).toBe(bubbleCanvas); + }); + + it("uses the live webcam frame when sync is correct on an offset webcam timeline", () => { + const renderer = createRenderer() as unknown as FrameRendererTestAccess & { + config: { webcam: { timeOffsetMs?: number } }; + }; + const outputContext = createMockContext(); + const webcamVideo = new FakeVideoElement({ + currentTime: 1.75, + readyState: 2, + videoWidth: 800, + videoHeight: 600, + duration: 10, + }); + + renderer.config.webcam = { + ...renderer.config.webcam, + timeOffsetMs: 250, + }; + renderer.webcamVideoElement = webcamVideo; + renderer.lastSyncedWebcamTime = 1.75; + renderer.currentVideoTime = 2; + renderer.animationState.appliedScale = 1; + + renderer.drawWebcamOverlay(outputContext as unknown as CanvasRenderingContext2D, 1280, 720); + + const bubbleCanvas = createdCanvases[0]; + const cacheCanvas = createdCanvases[1]; + expect(cacheCanvas).toBeDefined(); + expect((cacheCanvas.context as MockContext).drawImage.mock.calls[0][0]).toBe(webcamVideo); + expect((bubbleCanvas.context as MockContext).drawImage.mock.calls[0][0]).toBe(cacheCanvas); + expect((outputContext as MockContext).drawImage.mock.calls[0][0]).toBe(bubbleCanvas); + }); + + it("reuses the webcam bubble canvas across frames", () => { + const renderer = createRenderer() as unknown as FrameRendererTestAccess; + const outputContext = createMockContext(); + const webcamVideo = new FakeVideoElement({ + currentTime: 2, + readyState: 2, + videoWidth: 800, + videoHeight: 600, + }); + + renderer.webcamVideoElement = webcamVideo; + renderer.lastSyncedWebcamTime = 2; + renderer.currentVideoTime = 2; + renderer.animationState.appliedScale = 1; + + renderer.drawWebcamOverlay(outputContext as unknown as CanvasRenderingContext2D, 1280, 720); + renderer.drawWebcamOverlay(outputContext as unknown as CanvasRenderingContext2D, 1280, 720); + + expect(createdCanvases).toHaveLength(2); + }); + + it("prefers decoder-backed sync for video wallpapers during export", async () => { + vi.clearAllMocks(); + const renderer = new FrameRenderer({ + width: 1920, + height: 1080, + wallpaper: "/wallpapers/wispysky.mp4", + zoomRegions: [], + showShadow: false, + shadowIntensity: 0, + backgroundBlur: 0, + cropRegion: { x: 0, y: 0, width: 1, height: 1 }, + webcam: { + ...DEFAULT_WEBCAM_OVERLAY, + enabled: false, + }, + videoWidth: 1920, + videoHeight: 1080, + }) as unknown as { + setupBackground: () => Promise<void>; + backgroundForwardFrameSource: unknown; + backgroundVideoElement: FakeVideoElement | null; + backgroundSprite: MockCanvas | null; + }; + + await renderer.setupBackground(); + + expect(initializeForwardFrameSourceMock).toHaveBeenCalledWith("wallpapers/wispysky.mp4"); + expect(resolveMediaElementSourceMock).not.toHaveBeenCalled(); + expect(renderer.backgroundForwardFrameSource).toBeTruthy(); + expect(renderer.backgroundVideoElement).toBeNull(); + expect(renderer.backgroundSprite).toBeTruthy(); + }); + + it("falls back to media-element sync when video wallpaper packet streaming fails", async () => { + vi.clearAllMocks(); + initializeForwardFrameSourceMock.mockResolvedValue(undefined); + getForwardFrameAtTimeMock.mockRejectedValueOnce( + new Error("readAVPacket pipeline failed: Failed after 3 attempts"), + ); + resolveMediaElementSourceMock.mockResolvedValueOnce({ + src: "blob:background-video", + revoke: vi.fn(), + }); + const renderer = new FrameRenderer({ + width: 1920, + height: 1080, + wallpaper: "/wallpapers/wispysky.mp4", + zoomRegions: [], + showShadow: false, + shadowIntensity: 0, + backgroundBlur: 0, + cropRegion: { x: 0, y: 0, width: 1, height: 1 }, + webcam: { + ...DEFAULT_WEBCAM_OVERLAY, + enabled: false, + }, + videoWidth: 1920, + videoHeight: 1080, + }) as unknown as { + setupBackground: () => Promise<void>; + syncBackgroundFrame: (timeSeconds: number) => Promise<void>; + backgroundForwardFrameSource: unknown; + backgroundVideoElement: FakeVideoElement | null; + }; + + await renderer.setupBackground(); + await expect(renderer.syncBackgroundFrame(1)).resolves.toBeUndefined(); + + expect(cancelForwardFrameSourceMock).toHaveBeenCalled(); + expect(destroyForwardFrameSourceMock).toHaveBeenCalled(); + expect(resolveMediaElementSourceMock).toHaveBeenCalledWith("wallpapers/wispysky.mp4"); + expect(renderer.backgroundForwardFrameSource).toBeNull(); + expect(renderer.backgroundVideoElement).toBeTruthy(); + }); +}); diff --git a/src/lib/exporter/frameRenderer.ts b/src/lib/exporter/frameRenderer.ts index 6fc9c15d4..1d1fc19f4 100644 --- a/src/lib/exporter/frameRenderer.ts +++ b/src/lib/exporter/frameRenderer.ts @@ -1,477 +1,2260 @@ -import * as PIXI from 'pixi.js'; -import type { ZoomRegion, CropRegion } from '@/components/video-editor/types'; -import { ZOOM_DEPTH_SCALES } from '@/components/video-editor/types'; -import { findDominantRegion } from '@/components/video-editor/videoPlayback/zoomRegionUtils'; -import { applyZoomTransform } from '@/components/video-editor/videoPlayback/zoomTransform'; -import { DEFAULT_FOCUS, SMOOTHING_FACTOR, MIN_DELTA, VIEWPORT_SCALE } from '@/components/video-editor/videoPlayback/constants'; -import { clampFocusToStage as clampFocusToStageUtil } from '@/components/video-editor/videoPlayback/focusUtils'; +import { Application, Container, Graphics, Rectangle, Sprite, Texture } from "pixi.js"; +import { MotionBlurFilter } from "pixi-filters/motion-blur"; +import { ZoomBlurFilter } from "pixi-filters/zoom-blur"; +import type { + AnnotationRegion, + AutoCaptionSettings, + CaptionCue, + CropRegion, + CursorClickEffectStyle, + CursorStyle, + CursorTelemetryPoint, + Padding, + SpeedRegion, + WebcamOverlaySettings, + ZoomMotionBlurTuning, + ZoomRegion, + ZoomTransitionEasing, +} from "@/components/video-editor/types"; +import { + BASE_PREVIEW_HEIGHT, + BASE_PREVIEW_WIDTH, + DEFAULT_WEBCAM_ROUNDNESS, + ZOOM_DEPTH_SCALES, +} from "@/components/video-editor/types"; +import { DEFAULT_FOCUS } from "@/components/video-editor/videoPlayback/constants"; +import { + type CursorFollowCameraState, + computeCursorFollowFocus, + createCursorFollowCameraState, + SNAP_TO_EDGES_RATIO_AUTO, +} from "@/components/video-editor/videoPlayback/cursorFollowCamera"; +import { + DEFAULT_CURSOR_CONFIG, + PixiCursorOverlay, + preloadCursorAssets, +} from "@/components/video-editor/videoPlayback/cursorRenderer"; +import { computePaddedLayout } from "@/components/video-editor/videoPlayback/layoutUtils"; +import { + createSpringState, + getZoomSpringConfig, + resetSpringState, + type SpringState, + stepSpringValue, +} from "@/components/video-editor/videoPlayback/motionSmoothing"; +import { getWebcamMediaTargetTimeSeconds } from "@/components/video-editor/videoPlayback/webcamSync"; +import { findDominantRegion } from "@/components/video-editor/videoPlayback/zoomRegionUtils"; +import { + applyZoomTransform, + computeZoomTransform, + createMotionBlurState, + type MotionBlurState, +} from "@/components/video-editor/videoPlayback/zoomTransform"; +import { + getCropMatchedWebcamHeightPercent, + getWebcamCornerRadiusPx, + getWebcamCropSourceRect, + getWebcamOverlayDimensionsPx, + getWebcamOverlayPosition, + scaleWebcamOverlayPixels, +} from "@/components/video-editor/webcamOverlay"; +import { getAssetPath, getExportableVideoUrl, getRenderableAssetUrl } from "@/lib/assetPath"; +import { getWebcamShadowFilter } from "@/lib/exporter/shadowProfile"; +import { drawSquircleOnCanvas, drawSquircleOnGraphics } from "@/lib/geometry/squircle"; +import { + clampMediaTimeToDuration, + getEffectiveVideoStreamDurationSeconds, +} from "@/lib/mediaTiming"; +import { + destroyPixiApplication, + initializePixiApplicationWithTimeout, +} from "@/lib/pixiApplicationLifecycle"; +import { isVideoWallpaperSource } from "@/lib/wallpapers"; +import { renderAnnotations } from "./annotationRenderer"; +import { renderCaptions } from "./captionRenderer"; +import { ForwardFrameSource } from "./forwardFrameSource"; +import { resolveMediaElementSource } from "./localMediaSource"; +import { buildTemporalSamplePlanUs, getTemporalMotionBlurConfig } from "./temporalMotionBlur"; + +const TEMPORAL_ZOOM_MOTION_BLUR_ENABLED = false; interface FrameRenderConfig { - width: number; - height: number; - wallpaper: string; - zoomRegions: ZoomRegion[]; - showShadow: boolean; - showBlur: boolean; - cropRegion: CropRegion; - videoWidth: number; - videoHeight: number; + width: number; + height: number; + preferredRenderBackend?: "webgl" | "webgpu"; + wallpaper: string; + zoomRegions: ZoomRegion[]; + showShadow: boolean; + shadowIntensity: number; + backgroundBlur: number; + zoomMotionBlur?: number; + zoomMotionBlurTuning?: ZoomMotionBlurTuning; + zoomTemporalMotionBlur?: number; + zoomMotionBlurSampleCount?: number | null; + zoomMotionBlurShutterFraction?: number | null; + connectZooms?: boolean; + zoomInDurationMs?: number; + zoomInOverlapMs?: number; + zoomOutDurationMs?: number; + connectedZoomGapMs?: number; + connectedZoomDurationMs?: number; + zoomInEasing?: ZoomTransitionEasing; + zoomOutEasing?: ZoomTransitionEasing; + connectedZoomEasing?: ZoomTransitionEasing; + borderRadius?: number; + padding?: Padding | number; + cropRegion: CropRegion; + webcam?: WebcamOverlaySettings; + webcamUrl?: string | null; + videoWidth: number; + videoHeight: number; + annotationRegions?: AnnotationRegion[]; + autoCaptions?: CaptionCue[]; + autoCaptionSettings?: AutoCaptionSettings; + speedRegions?: SpeedRegion[]; + previewWidth?: number; + previewHeight?: number; + cursorTelemetry?: CursorTelemetryPoint[]; + showCursor?: boolean; + cursorStyle?: CursorStyle; + cursorSize?: number; + cursorSmoothing?: number; + cursorSpringStiffnessMultiplier?: number; + cursorSpringDampingMultiplier?: number; + cursorSpringMassMultiplier?: number; + cameraSpringStiffnessMultiplier?: number; + cameraSpringDampingMultiplier?: number; + cameraSpringMassMultiplier?: number; + zoomSmoothness?: number; + zoomClassicMode?: boolean; + cursorMotionBlur?: number; + cursorClickEffect?: CursorClickEffectStyle; + cursorClickEffectColor?: string; + cursorClickEffectScale?: number; + cursorClickEffectOpacity?: number; + cursorClickEffectDurationMs?: number; + cursorClickBounce?: number; + cursorClickBounceDuration?: number; + cursorSway?: number; } interface AnimationState { - scale: number; - focusX: number; - focusY: number; + scale: number; + appliedScale: number; + focusX: number; + focusY: number; + progress: number; + x: number; + y: number; +} + +type ExportRenderBackend = "webgl" | "webgpu"; +type PixiRendererAttempt = { + backend: ExportRenderBackend; + message: string; +}; + +const PIXI_RENDERER_INIT_TIMEOUT_MS = 8_000; +const BACKGROUND_MEDIA_ELEMENT_READY_TIMEOUT_MS = 5_000; + +function isCanvasRenderer(renderer: Application): boolean { + const rendererName = renderer?.renderer?.constructor?.name?.toLowerCase(); + return Boolean( + rendererName && + (rendererName.includes("canvasrenderer") || rendererName.includes("canvas")), + ); +} + +function toErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error ?? "Unknown renderer init error"); +} + +function summarizeRendererAttempts(attempts: readonly PixiRendererAttempt[]): string { + const details = attempts.map((attempt) => `${attempt.backend}: ${attempt.message}`).join(" | "); + return `No supported Pixi export backend was available. Attempted: ${details}`; +} + +interface VideoTextureSource { + resource: VideoFrame | CanvasImageSource; + update: () => void; +} + +type PixiTextureInput = Parameters<typeof Texture.from>[0]; + +interface LayoutCache { + stageSize: { width: number; height: number }; + videoSize: { width: number; height: number }; + baseScale: number; + baseOffset: { x: number; y: number }; + maskRect: { + x: number; + y: number; + width: number; + height: number; + sourceCrop: CropRegion; + }; +} + +interface RenderSnapshot { + timeMs: number; + sceneTransform: { scale: number; x: number; y: number }; +} + +function createAnimationState(): AnimationState { + return { + scale: 1, + appliedScale: 1, + focusX: DEFAULT_FOCUS.cx, + focusY: DEFAULT_FOCUS.cy, + progress: 0, + x: 0, + y: 0, + }; +} + +function configureHighQuality2DContext( + context: CanvasRenderingContext2D | null, +): CanvasRenderingContext2D | null { + if (!context) { + return null; + } + + context.imageSmoothingEnabled = true; + context.imageSmoothingQuality = "high"; + + return context; } // Renders video frames with all effects (background, zoom, crop, blur, shadow) to an offscreen canvas for export. export class FrameRenderer { - private app: PIXI.Application | null = null; - private cameraContainer: PIXI.Container | null = null; - private videoContainer: PIXI.Container | null = null; - private videoSprite: PIXI.Sprite | null = null; - private backgroundSprite: PIXI.Sprite | null = null; - private maskGraphics: PIXI.Graphics | null = null; - private blurFilter: PIXI.BlurFilter | null = null; - private shadowCanvas: HTMLCanvasElement | null = null; - private shadowCtx: CanvasRenderingContext2D | null = null; - private compositeCanvas: HTMLCanvasElement | null = null; - private compositeCtx: CanvasRenderingContext2D | null = null; - private config: FrameRenderConfig; - private animationState: AnimationState; - private layoutCache: any = null; - private currentVideoTime = 0; - - constructor(config: FrameRenderConfig) { - this.config = config; - this.animationState = { - scale: 1, - focusX: DEFAULT_FOCUS.cx, - focusY: DEFAULT_FOCUS.cy, - }; - } - - async initialize(): Promise<void> { - // Create offscreen canvas with sRGB color space for fidelity - const canvas = document.createElement('canvas'); - canvas.width = this.config.width; - canvas.height = this.config.height; - if ('colorSpace' in canvas) { - // @ts-ignore - canvas.colorSpace = 'srgb'; - } - - // Initialize PixiJS app with transparent background (background rendered separately) - this.app = new PIXI.Application(); - await this.app.init({ - canvas, - width: this.config.width, - height: this.config.height, - backgroundAlpha: 0, - antialias: true, - resolution: 2, - autoDensity: true, - }); - - // Setup containers - this.cameraContainer = new PIXI.Container(); - this.videoContainer = new PIXI.Container(); - this.app.stage.addChild(this.cameraContainer); - this.cameraContainer.addChild(this.videoContainer); - - // Setup background (render separately, not in PixiJS) - await this.setupBackground(); - - // Setup blur filter for video container - this.blurFilter = new PIXI.BlurFilter(); - this.blurFilter.quality = 3; - this.blurFilter.resolution = this.app.renderer.resolution; - this.blurFilter.blur = 0; - this.videoContainer.filters = [this.blurFilter]; - - // Setup composite canvas for final output with shadows - this.compositeCanvas = document.createElement('canvas'); - this.compositeCanvas.width = this.config.width; - this.compositeCanvas.height = this.config.height; - this.compositeCtx = this.compositeCanvas.getContext('2d', { willReadFrequently: false }); - - // Setup shadow canvas if needed - if (this.config.showShadow) { - this.shadowCanvas = document.createElement('canvas'); - this.shadowCanvas.width = this.config.width; - this.shadowCanvas.height = this.config.height; - this.shadowCtx = this.shadowCanvas.getContext('2d', { willReadFrequently: false }); - } - - // Setup mask - this.maskGraphics = new PIXI.Graphics(); - this.videoContainer.addChild(this.maskGraphics); - this.videoContainer.mask = this.maskGraphics; - } - - private async setupBackground(): Promise<void> { - const wallpaper = this.config.wallpaper; - - // Create background canvas for separate rendering (not affected by zoom) - const bgCanvas = document.createElement('canvas'); - bgCanvas.width = this.config.width; - bgCanvas.height = this.config.height; - const bgCtx = bgCanvas.getContext('2d')!; - - try { - // Render background based on type - if (wallpaper.startsWith('file://') || wallpaper.startsWith('data:') || wallpaper.startsWith('/') || wallpaper.startsWith('http')) { - // Image background - const img = new Image(); - // Don't set crossOrigin for same-origin images to avoid CORS taint - // Only set it for cross-origin URLs - let imageUrl: string; - if (wallpaper.startsWith('http')) { - imageUrl = wallpaper; - if (!imageUrl.startsWith(window.location.origin)) { - img.crossOrigin = 'anonymous'; - } - } else if (wallpaper.startsWith('file://') || wallpaper.startsWith('data:')) { - imageUrl = wallpaper; - } else { - imageUrl = window.location.origin + wallpaper; - } - - await new Promise<void>((resolve, reject) => { - img.onload = () => resolve(); - img.onerror = (err) => { - console.error('[FrameRenderer] Failed to load background image:', imageUrl, err); - reject(new Error(`Failed to load background image: ${imageUrl}`)); - }; - img.src = imageUrl; - }); - - // Draw the image using cover and center positioning - const imgAspect = img.width / img.height; - const canvasAspect = this.config.width / this.config.height; - - let drawWidth, drawHeight, drawX, drawY; - - if (imgAspect > canvasAspect) { - drawHeight = this.config.height; - drawWidth = drawHeight * imgAspect; - drawX = (this.config.width - drawWidth) / 2; - drawY = 0; - } else { - drawWidth = this.config.width; - drawHeight = drawWidth / imgAspect; - drawX = 0; - drawY = (this.config.height - drawHeight) / 2; - } - - bgCtx.drawImage(img, drawX, drawY, drawWidth, drawHeight); - } else if (wallpaper.startsWith('#')) { - bgCtx.fillStyle = wallpaper; - bgCtx.fillRect(0, 0, this.config.width, this.config.height); - } else if (wallpaper.startsWith('linear-gradient') || wallpaper.startsWith('radial-gradient')) { - - const gradientMatch = wallpaper.match(/(linear|radial)-gradient\((.+)\)/); - if (gradientMatch) { - const [, type, params] = gradientMatch; - const parts = params.split(',').map(s => s.trim()); - - let gradient: CanvasGradient; - - if (type === 'linear') { - gradient = bgCtx.createLinearGradient(0, 0, 0, this.config.height); - parts.forEach((part, index) => { - if (part.startsWith('to ') || part.includes('deg')) return; - - const colorMatch = part.match(/^(#[0-9a-fA-F]{3,8}|rgba?\([^)]+\)|[a-z]+)/); - if (colorMatch) { - const color = colorMatch[1]; - const position = index / (parts.length - 1); - gradient.addColorStop(position, color); - } - }); - } else { - const cx = this.config.width / 2; - const cy = this.config.height / 2; - const radius = Math.max(this.config.width, this.config.height) / 2; - gradient = bgCtx.createRadialGradient(cx, cy, 0, cx, cy, radius); - - parts.forEach((part, index) => { - const colorMatch = part.match(/^(#[0-9a-fA-F]{3,8}|rgba?\([^)]+\)|[a-z]+)/); - if (colorMatch) { - const color = colorMatch[1]; - const position = index / (parts.length - 1); - gradient.addColorStop(position, color); - } - }); - } - - bgCtx.fillStyle = gradient; - bgCtx.fillRect(0, 0, this.config.width, this.config.height); - } else { - console.warn('[FrameRenderer] Could not parse gradient, using black fallback'); - bgCtx.fillStyle = '#000000'; - bgCtx.fillRect(0, 0, this.config.width, this.config.height); - } - } else { - bgCtx.fillStyle = wallpaper; - bgCtx.fillRect(0, 0, this.config.width, this.config.height); - } - } catch (error) { - console.error('[FrameRenderer] Error setting up background, using fallback:', error); - bgCtx.fillStyle = '#000000'; - bgCtx.fillRect(0, 0, this.config.width, this.config.height); - } - - // Store the background canvas for compositing - this.backgroundSprite = bgCanvas as any; - } - - async renderFrame(videoFrame: VideoFrame, timestamp: number): Promise<void> { - if (!this.app || !this.videoContainer || !this.cameraContainer) { - throw new Error('Renderer not initialized'); - } - - this.currentVideoTime = timestamp / 1000000; - - // Create or update video sprite from VideoFrame - if (!this.videoSprite) { - const texture = PIXI.Texture.from(videoFrame as any); - this.videoSprite = new PIXI.Sprite(texture); - this.videoContainer.addChild(this.videoSprite); - } else { - // Update texture with new frame - const texture = PIXI.Texture.from(videoFrame as any); - this.videoSprite.texture = texture; - } - - // Apply layout - this.updateLayout(); - - const timeMs = this.currentVideoTime * 1000; - const TICKS_PER_FRAME = 1; - - let maxMotionIntensity = 0; - for (let i = 0; i < TICKS_PER_FRAME; i++) { - const motionIntensity = this.updateAnimationState(timeMs); - maxMotionIntensity = Math.max(maxMotionIntensity, motionIntensity); - } - - // Apply transform once with maximum motion intensity from all ticks - applyZoomTransform({ - cameraContainer: this.cameraContainer, - blurFilter: this.blurFilter, - stageSize: this.layoutCache.stageSize, - baseMask: this.layoutCache.maskRect, - zoomScale: this.animationState.scale, - focusX: this.animationState.focusX, - focusY: this.animationState.focusY, - motionIntensity: maxMotionIntensity, - isPlaying: true, - }); - - // Render the PixiJS stage to its canvas (video only, transparent background) - this.app.renderer.render(this.app.stage); - - // Composite with shadows to final output canvas - this.compositeWithShadows(); - } - - private updateLayout(): void { - if (!this.app || !this.videoSprite || !this.maskGraphics || !this.videoContainer) return; - - const { width, height } = this.config; - const { cropRegion } = this.config; - const videoWidth = this.config.videoWidth; - const videoHeight = this.config.videoHeight; - - // Calculate cropped video dimensions - const cropStartX = cropRegion.x; - const cropStartY = cropRegion.y; - const cropEndX = cropRegion.x + cropRegion.width; - const cropEndY = cropRegion.y + cropRegion.height; - - const croppedVideoWidth = videoWidth * (cropEndX - cropStartX); - const croppedVideoHeight = videoHeight * (cropEndY - cropStartY); - - // Calculate scale to fit in viewport - const viewportWidth = width * VIEWPORT_SCALE; - const viewportHeight = height * VIEWPORT_SCALE; - const scale = Math.min(viewportWidth / croppedVideoWidth, viewportHeight / croppedVideoHeight); - - // Position video sprite - this.videoSprite.width = videoWidth * scale; - this.videoSprite.height = videoHeight * scale; - - const cropPixelX = cropStartX * videoWidth * scale; - const cropPixelY = cropStartY * videoHeight * scale; - this.videoSprite.x = -cropPixelX; - this.videoSprite.y = -cropPixelY; - - // Position video container - const croppedDisplayWidth = croppedVideoWidth * scale; - const croppedDisplayHeight = croppedVideoHeight * scale; - const centerOffsetX = (width - croppedDisplayWidth) / 2; - const centerOffsetY = (height - croppedDisplayHeight) / 2; - this.videoContainer.x = centerOffsetX; - this.videoContainer.y = centerOffsetY; - - // Update mask - const radius = Math.min(croppedDisplayWidth, croppedDisplayHeight) * 0.02; - this.maskGraphics.clear(); - this.maskGraphics.roundRect(0, 0, croppedDisplayWidth, croppedDisplayHeight, radius); - this.maskGraphics.fill({ color: 0xffffff }); - - // Cache layout info - this.layoutCache = { - stageSize: { width, height }, - videoSize: { width: croppedVideoWidth, height: croppedVideoHeight }, - baseScale: scale, - baseOffset: { x: centerOffsetX, y: centerOffsetY }, - maskRect: { x: 0, y: 0, width: croppedDisplayWidth, height: croppedDisplayHeight }, - }; - } - - private clampFocusToStage(focus: { cx: number; cy: number }, depth: number): { cx: number; cy: number } { - if (!this.layoutCache) return focus; - return clampFocusToStageUtil(focus, depth as any, this.layoutCache); - } - - private updateAnimationState(timeMs: number): number { - if (!this.cameraContainer || !this.layoutCache) return 0; - - const { region, strength } = findDominantRegion(this.config.zoomRegions, timeMs); - - const defaultFocus = DEFAULT_FOCUS; - let targetScaleFactor = 1; - let targetFocus = { ...defaultFocus }; - - if (region && strength > 0) { - const zoomScale = ZOOM_DEPTH_SCALES[region.depth]; - const regionFocus = this.clampFocusToStage(region.focus, region.depth); - - targetScaleFactor = 1 + (zoomScale - 1) * strength; - targetFocus = { - cx: defaultFocus.cx + (regionFocus.cx - defaultFocus.cx) * strength, - cy: defaultFocus.cy + (regionFocus.cy - defaultFocus.cy) * strength, - }; - } - - const state = this.animationState; - - const prevScale = state.scale; - const prevFocusX = state.focusX; - const prevFocusY = state.focusY; - - const scaleDelta = targetScaleFactor - state.scale; - const focusXDelta = targetFocus.cx - state.focusX; - const focusYDelta = targetFocus.cy - state.focusY; - - let nextScale = prevScale; - let nextFocusX = prevFocusX; - let nextFocusY = prevFocusY; - - if (Math.abs(scaleDelta) > MIN_DELTA) { - nextScale = prevScale + scaleDelta * SMOOTHING_FACTOR; - } else { - nextScale = targetScaleFactor; - } - - if (Math.abs(focusXDelta) > MIN_DELTA) { - nextFocusX = prevFocusX + focusXDelta * SMOOTHING_FACTOR; - } else { - nextFocusX = targetFocus.cx; - } - - if (Math.abs(focusYDelta) > MIN_DELTA) { - nextFocusY = prevFocusY + focusYDelta * SMOOTHING_FACTOR; - } else { - nextFocusY = targetFocus.cy; - } - - state.scale = nextScale; - state.focusX = nextFocusX; - state.focusY = nextFocusY; - - return Math.max( - Math.abs(nextScale - prevScale), - Math.abs(nextFocusX - prevFocusX), - Math.abs(nextFocusY - prevFocusY) - ); - } - - private compositeWithShadows(): void { - if (!this.compositeCanvas || !this.compositeCtx || !this.app) return; - - const videoCanvas = this.app.canvas as HTMLCanvasElement; - const ctx = this.compositeCtx; - const w = this.compositeCanvas.width; - const h = this.compositeCanvas.height; - - // Clear composite canvas - ctx.clearRect(0, 0, w, h); - - // Step 1: Draw background layer (with optional blur, not affected by zoom) - if (this.backgroundSprite) { - const bgCanvas = this.backgroundSprite as any as HTMLCanvasElement; - - if (this.config.showBlur) { - ctx.save(); - ctx.filter = 'blur(2px)'; - ctx.drawImage(bgCanvas, 0, 0, w, h); - ctx.restore(); - } else { - ctx.drawImage(bgCanvas, 0, 0, w, h); - } - } else { - console.warn('[FrameRenderer] No background sprite found during compositing!'); - } - - // Step 2: Draw video layer with shadows on top of background - if (this.config.showShadow && this.shadowCanvas && this.shadowCtx) { - const shadowCtx = this.shadowCtx; - shadowCtx.clearRect(0, 0, w, h); - shadowCtx.save(); - shadowCtx.filter = 'drop-shadow(0 12px 48px rgba(0,0,0,0.7)) drop-shadow(0 4px 16px rgba(0,0,0,0.5)) drop-shadow(0 2px 8px rgba(0,0,0,0.3))'; - shadowCtx.drawImage(videoCanvas, 0, 0, w, h); - shadowCtx.restore(); - ctx.drawImage(this.shadowCanvas, 0, 0, w, h); - } else { - ctx.drawImage(videoCanvas, 0, 0, w, h); - } - } - - getCanvas(): HTMLCanvasElement { - if (!this.compositeCanvas) { - throw new Error('Renderer not initialized'); - } - return this.compositeCanvas; - } - - updateConfig(config: Partial<FrameRenderConfig>): void { - this.config = { ...this.config, ...config }; - if (config.wallpaper) { - this.setupBackground(); - } - } - - destroy(): void { - if (this.videoSprite) { - this.videoSprite.destroy(); - this.videoSprite = null; - } - this.backgroundSprite = null; - if (this.app) { - this.app.destroy(true, { children: true, texture: true, textureSource: true }); - this.app = null; - } - this.cameraContainer = null; - this.videoContainer = null; - this.maskGraphics = null; - this.blurFilter = null; - this.shadowCanvas = null; - this.shadowCtx = null; - this.compositeCanvas = null; - this.compositeCtx = null; - } + private app: Application | null = null; + private cameraContainer: Container | null = null; + private videoContainer: Container | null = null; + private cursorContainer: Container | null = null; + private videoSprite: Sprite | null = null; + private videoTextureSource: VideoTextureSource | null = null; + private backgroundSprite: HTMLCanvasElement | null = null; + private maskGraphics: Graphics | null = null; + private zoomBlurFilter: ZoomBlurFilter | null = null; + private motionBlurFilter: MotionBlurFilter | null = null; + private shadowCanvas: HTMLCanvasElement | null = null; + private shadowCtx: CanvasRenderingContext2D | null = null; + private compositeCanvas: HTMLCanvasElement | null = null; + private compositeCtx: CanvasRenderingContext2D | null = null; + private temporalAccumulationCanvas: HTMLCanvasElement | null = null; + private temporalAccumulationCtx: CanvasRenderingContext2D | null = null; + private backgroundForwardFrameSource: ForwardFrameSource | null = null; + private backgroundForwardFrameSourceUrl: string | null = null; + private backgroundForwardFrameDurationSec: number | null = null; + private backgroundDecodedFrame: VideoFrame | null = null; + private backgroundVideoElement: HTMLVideoElement | null = null; + private backgroundCtx: CanvasRenderingContext2D | null = null; + private backgroundSeekPromise: Promise<void> | null = null; + private lastSyncedBackgroundLoopTimeSec: number | null = null; + private cleanupBackgroundSource: (() => void) | null = null; + private config: FrameRenderConfig; + private animationState: AnimationState; + private motionBlurState: MotionBlurState; + private layoutCache: LayoutCache | null = null; + private currentVideoTime = 0; + private springScale: SpringState; + private springX: SpringState; + private springY: SpringState; + private cursorFollowCamera: CursorFollowCameraState; + private lastContentTimeMs: number | null = null; + private cursorOverlay: PixiCursorOverlay | null = null; + private webcamForwardFrameSource: ForwardFrameSource | null = null; + private webcamDecodedFrame: VideoFrame | null = null; + private webcamVideoElement: HTMLVideoElement | null = null; + private webcamSeekPromise: Promise<void> | null = null; + private webcamFrameCacheCanvas: HTMLCanvasElement | null = null; + private webcamFrameCacheCtx: CanvasRenderingContext2D | null = null; + private webcamBubbleCanvas: HTMLCanvasElement | null = null; + private webcamBubbleCtx: CanvasRenderingContext2D | null = null; + private lastSyncedWebcamTime: number | null = null; + private cleanupWebcamSource: (() => void) | null = null; + + constructor(config: FrameRenderConfig) { + this.config = config; + this.animationState = createAnimationState(); + this.motionBlurState = createMotionBlurState(); + this.springScale = createSpringState(1); + this.springX = createSpringState(0); + this.springY = createSpringState(0); + this.cursorFollowCamera = createCursorFollowCameraState(); + } + + private async createPixiApplication( + canvas: HTMLCanvasElement, + ): Promise<{ app: Application; backend: ExportRenderBackend }> { + const baseOptions = { + canvas, + width: this.config.width, + height: this.config.height, + backgroundAlpha: 0, + antialias: true, + failIfMajorPerformanceCaveat: false, + resolution: 1, + autoDensity: true, + autoStart: false, + sharedTicker: false, + powerPreference: "high-performance" as const, + }; + + const preferredRenderBackend = this.config.preferredRenderBackend; + const backendOrder = + preferredRenderBackend === "webgpu" + ? (["webgpu", "webgl"] as const) + : preferredRenderBackend === "webgl" + ? (["webgl", "webgpu"] as const) + : (["webgl", "webgpu"] as const); + const failures: PixiRendererAttempt[] = []; + + for (const backend of backendOrder) { + if (backend === "webgpu" && !(typeof navigator !== "undefined" && "gpu" in navigator)) { + failures.push({ + backend, + message: "WebGPU runtime is unavailable in this environment.", + }); + continue; + } + + const app = new Application(); + const initStarted = typeof performance === "undefined" ? Date.now() : performance.now(); + try { + await initializePixiApplicationWithTimeout( + app, + { + ...baseOptions, + preference: backend, + }, + PIXI_RENDERER_INIT_TIMEOUT_MS, + backend, + ); + const elapsed = Math.round( + (typeof performance === "undefined" ? Date.now() : performance.now()) - + initStarted, + ); + if (isCanvasRenderer(app)) { + throw new Error( + `Renderer initialized with unsupported fallback backend after ${elapsed}ms: ${app.renderer.constructor?.name ?? "unknown"}`, + ); + } + return { app, backend }; + } catch (error) { + const elapsed = Math.round( + (typeof performance === "undefined" ? Date.now() : performance.now()) - + initStarted, + ); + failures.push({ + backend, + message: `${toErrorMessage(error)} (after ${elapsed}ms)`, + }); + console.warn( + `[FrameRenderer] ${backend} renderer unavailable after ${elapsed}ms; trying next backend.`, + error, + ); + destroyPixiApplication(app, `${backend} export renderer initialization`); + } + } + + throw new Error(summarizeRendererAttempts(failures)); + } + + async initialize(): Promise<void> { + let cursorOverlayEnabled = true; + try { + await preloadCursorAssets(); + } catch (error) { + cursorOverlayEnabled = false; + console.warn( + "[FrameRenderer] Native cursor assets are unavailable; continuing export without cursor overlay.", + error, + ); + } + + // Create canvas for rendering + const canvas = document.createElement("canvas"); + canvas.width = this.config.width; + canvas.height = this.config.height; + + // Try to set colorSpace if supported (may not be available on all platforms) + try { + if (canvas && "colorSpace" in canvas) { + canvas.colorSpace = "srgb"; + } + } catch (error) { + // Silently ignore colorSpace errors on platforms that don't support it + console.warn("[FrameRenderer] colorSpace not supported on this platform:", error); + } + + // Initialize PixiJS with optimized settings for export performance + const { app, backend } = await this.createPixiApplication(canvas); + this.app = app; + console.log(`[FrameRenderer] Export renderer backend: ${backend}`); + + // Setup containers + this.cameraContainer = new Container(); + this.videoContainer = new Container(); + this.cursorContainer = new Container(); + this.app.stage.addChild(this.cameraContainer); + this.cameraContainer.addChild(this.videoContainer); + this.cameraContainer.addChild(this.cursorContainer); + + if (cursorOverlayEnabled) { + this.cursorOverlay = new PixiCursorOverlay({ + dotRadius: DEFAULT_CURSOR_CONFIG.dotRadius * (this.config.cursorSize ?? 1.4), + style: this.config.cursorStyle ?? "tahoe", + smoothingFactor: + this.config.cursorSmoothing ?? DEFAULT_CURSOR_CONFIG.smoothingFactor, + springTuning: { + stiffnessMultiplier: this.config.cursorSpringStiffnessMultiplier, + dampingMultiplier: this.config.cursorSpringDampingMultiplier, + massMultiplier: this.config.cursorSpringMassMultiplier, + }, + motionBlur: this.config.cursorMotionBlur ?? 0, + clickEffect: this.config.cursorClickEffect ?? DEFAULT_CURSOR_CONFIG.clickEffect, + clickEffectColor: + this.config.cursorClickEffectColor ?? DEFAULT_CURSOR_CONFIG.clickEffectColor, + clickEffectScale: + this.config.cursorClickEffectScale ?? DEFAULT_CURSOR_CONFIG.clickEffectScale, + clickEffectOpacity: + this.config.cursorClickEffectOpacity ?? + DEFAULT_CURSOR_CONFIG.clickEffectOpacity, + clickEffectDurationMs: + this.config.cursorClickEffectDurationMs ?? + DEFAULT_CURSOR_CONFIG.clickEffectDurationMs, + clickBounce: this.config.cursorClickBounce ?? DEFAULT_CURSOR_CONFIG.clickBounce, + clickBounceDuration: + this.config.cursorClickBounceDuration ?? + DEFAULT_CURSOR_CONFIG.clickBounceDuration, + sway: this.config.cursorSway ?? DEFAULT_CURSOR_CONFIG.sway, + }); + } + + // Setup background (render separately, not in PixiJS) + await this.setupBackground(); + await this.setupWebcamSource(); + + if ((this.config.zoomMotionBlur ?? 0) > 0) { + this.zoomBlurFilter = new ZoomBlurFilter({ strength: 0, maxKernelSize: 13 }); + this.motionBlurFilter = new MotionBlurFilter([0, 0], 5, 0); + this.videoContainer.filterArea = new Rectangle( + 0, + 0, + this.config.width, + this.config.height, + ); + this.videoContainer.filters = [this.motionBlurFilter, this.zoomBlurFilter]; + } else { + this.videoContainer.filters = null; + } + + // Setup composite canvas for final output with shadows + this.compositeCanvas = document.createElement("canvas"); + this.compositeCanvas.width = this.config.width; + this.compositeCanvas.height = this.config.height; + this.compositeCtx = configureHighQuality2DContext( + this.compositeCanvas.getContext("2d", { + willReadFrequently: false, + }), + ); + + if (!this.compositeCtx) { + throw new Error("Failed to get 2D context for composite canvas"); + } + + this.temporalAccumulationCanvas = document.createElement("canvas"); + this.temporalAccumulationCanvas.width = this.config.width; + this.temporalAccumulationCanvas.height = this.config.height; + this.temporalAccumulationCtx = configureHighQuality2DContext( + this.temporalAccumulationCanvas.getContext("2d", { + willReadFrequently: true, + }), + ); + + if (!this.temporalAccumulationCtx) { + throw new Error("Failed to get 2D context for temporal accumulation canvas"); + } + + // Setup shadow canvas if needed + if (this.config.showShadow) { + this.shadowCanvas = document.createElement("canvas"); + this.shadowCanvas.width = this.config.width; + this.shadowCanvas.height = this.config.height; + this.shadowCtx = configureHighQuality2DContext( + this.shadowCanvas.getContext("2d", { + willReadFrequently: false, + }), + ); + + if (!this.shadowCtx) { + throw new Error("Failed to get 2D context for shadow canvas"); + } + } + + // Setup mask + this.maskGraphics = new Graphics(); + this.videoContainer.addChild(this.maskGraphics); + this.videoContainer.mask = this.maskGraphics; + if (this.cursorOverlay) { + this.cursorContainer.addChild(this.cursorOverlay.container); + } + } + + private async setupBackground(): Promise<void> { + const wallpaper = await this.resolveWallpaperForExport(this.config.wallpaper); + + // Create background canvas for separate rendering (not affected by zoom) + const bgCanvas = document.createElement("canvas"); + bgCanvas.width = this.config.width; + bgCanvas.height = this.config.height; + const bgCtx = configureHighQuality2DContext(bgCanvas.getContext("2d")); + + if (!bgCtx) { + throw new Error("Failed to get 2D context for background canvas"); + } + + this.backgroundCtx = bgCtx; + this.backgroundForwardFrameSource?.cancel(); + void this.backgroundForwardFrameSource?.destroy(); + this.backgroundForwardFrameSource = null; + this.backgroundForwardFrameSourceUrl = null; + this.backgroundForwardFrameDurationSec = null; + this.closeBackgroundDecodedFrame(); + this.cleanupBackgroundSource?.(); + this.cleanupBackgroundSource = null; + this.lastSyncedBackgroundLoopTimeSec = null; + if (this.backgroundVideoElement) { + this.backgroundVideoElement.pause(); + this.backgroundVideoElement.src = ""; + this.backgroundVideoElement.load(); + this.backgroundVideoElement = null; + } + this.backgroundSeekPromise = null; + + try { + // Check for video wallpaper first + if (isVideoWallpaperSource(wallpaper)) { + let videoSrc = wallpaper; + if (wallpaper.startsWith("/") && !wallpaper.startsWith("//")) { + videoSrc = await getAssetPath(wallpaper.replace(/^\//, "")); + } + + try { + const frameSource = new ForwardFrameSource(); + const metadata = await frameSource.initialize(videoSrc); + this.backgroundForwardFrameSource = frameSource; + this.backgroundForwardFrameSourceUrl = videoSrc; + this.backgroundForwardFrameDurationSec = getEffectiveVideoStreamDurationSeconds( + { + duration: metadata?.duration, + streamDuration: metadata?.streamDuration, + }, + ); + this.backgroundVideoElement = null; + this.backgroundSeekPromise = null; + this.lastSyncedBackgroundLoopTimeSec = null; + this.backgroundSprite = bgCanvas; + return; + } catch (error) { + console.warn( + "[FrameRenderer] Decoder-backed video wallpaper unavailable during export; falling back to media element sync:", + error, + ); + } + + if (!(await this.loadBackgroundMediaElementSource(videoSrc, wallpaper))) { + throw new Error(`Failed to load video wallpaper: ${wallpaper}`); + } + this.backgroundSprite = bgCanvas; + return; + } + + // Render background based on type + if ( + wallpaper.startsWith("file://") || + wallpaper.startsWith("data:") || + wallpaper.startsWith("/") || + wallpaper.startsWith("http") + ) { + // Image background + const img = new Image(); + const imageUrl = await this.resolveWallpaperImageUrl(wallpaper); + // Don't set crossOrigin for same-origin images to avoid CORS taint. + if ( + imageUrl.startsWith("http") && + window.location.origin && + !imageUrl.startsWith(window.location.origin) + ) { + img.crossOrigin = "anonymous"; + } + + await new Promise<void>((resolve, reject) => { + img.onload = () => resolve(); + img.onerror = (err) => { + console.error( + "[FrameRenderer] Failed to load background image:", + imageUrl, + err, + ); + reject(new Error(`Failed to load background image: ${imageUrl}`)); + }; + img.src = imageUrl; + }); + + // Draw the image using cover and center positioning + const imgAspect = img.width / img.height; + const canvasAspect = this.config.width / this.config.height; + + let drawWidth, drawHeight, drawX, drawY; + + if (imgAspect > canvasAspect) { + drawHeight = this.config.height; + drawWidth = drawHeight * imgAspect; + drawX = (this.config.width - drawWidth) / 2; + drawY = 0; + } else { + drawWidth = this.config.width; + drawHeight = drawWidth / imgAspect; + drawX = 0; + drawY = (this.config.height - drawHeight) / 2; + } + + bgCtx.drawImage(img, drawX, drawY, drawWidth, drawHeight); + } else if (wallpaper.startsWith("#")) { + bgCtx.fillStyle = wallpaper; + bgCtx.fillRect(0, 0, this.config.width, this.config.height); + } else if ( + wallpaper.startsWith("linear-gradient") || + wallpaper.startsWith("radial-gradient") + ) { + const gradientMatch = wallpaper.match(/(linear|radial)-gradient\((.+)\)/); + if (gradientMatch) { + const [, type, params] = gradientMatch; + const parts = params.split(",").map((s) => s.trim()); + + let gradient: CanvasGradient; + + if (type === "linear") { + gradient = bgCtx.createLinearGradient(0, 0, 0, this.config.height); + parts.forEach((part, index) => { + if (part.startsWith("to ") || part.includes("deg")) return; + + const colorMatch = part.match( + /^(#[0-9a-fA-F]{3,8}|rgba?\([^)]+\)|[a-z]+)/, + ); + if (colorMatch) { + const color = colorMatch[1]; + const position = index / (parts.length - 1); + gradient.addColorStop(position, color); + } + }); + } else { + const cx = this.config.width / 2; + const cy = this.config.height / 2; + const radius = Math.max(this.config.width, this.config.height) / 2; + gradient = bgCtx.createRadialGradient(cx, cy, 0, cx, cy, radius); + + parts.forEach((part, index) => { + const colorMatch = part.match( + /^(#[0-9a-fA-F]{3,8}|rgba?\([^)]+\)|[a-z]+)/, + ); + if (colorMatch) { + const color = colorMatch[1]; + const position = index / (parts.length - 1); + gradient.addColorStop(position, color); + } + }); + } + + bgCtx.fillStyle = gradient; + bgCtx.fillRect(0, 0, this.config.width, this.config.height); + } else { + console.warn("[FrameRenderer] Could not parse gradient, using black fallback"); + bgCtx.fillStyle = "#000000"; + bgCtx.fillRect(0, 0, this.config.width, this.config.height); + } + } else { + bgCtx.fillStyle = wallpaper; + bgCtx.fillRect(0, 0, this.config.width, this.config.height); + } + } catch (error) { + console.error("[FrameRenderer] Error setting up background, using fallback:", error); + bgCtx.fillStyle = "#000000"; + bgCtx.fillRect(0, 0, this.config.width, this.config.height); + } + + // Store the background canvas for compositing + this.backgroundSprite = bgCanvas; + } + + private drawVideoFrameToBackground(): void { + const video = this.backgroundVideoElement; + if (!video) return; + + this.drawBackgroundSourceToCanvas(video, video.videoWidth, video.videoHeight); + } + + private drawBackgroundSourceToCanvas( + source: CanvasImageSource, + sourceWidth: number, + sourceHeight: number, + ): void { + const ctx = this.backgroundCtx; + if (!ctx) return; + + const w = this.config.width; + const h = this.config.height; + const safeSourceWidth = Math.max(1, sourceWidth); + const safeSourceHeight = Math.max(1, sourceHeight); + const videoAspect = safeSourceWidth / safeSourceHeight; + const canvasAspect = w / h; + + let drawWidth: number, drawHeight: number, drawX: number, drawY: number; + if (videoAspect > canvasAspect) { + drawHeight = h; + drawWidth = drawHeight * videoAspect; + drawX = (w - drawWidth) / 2; + drawY = 0; + } else { + drawWidth = w; + drawHeight = drawWidth / videoAspect; + drawX = 0; + drawY = (h - drawHeight) / 2; + } + + ctx.clearRect(0, 0, w, h); + ctx.drawImage(source, drawX, drawY, drawWidth, drawHeight); + } + + private closeBackgroundDecodedFrame(): void { + if (!this.backgroundDecodedFrame) { + return; + } + + this.backgroundDecodedFrame.close(); + this.backgroundDecodedFrame = null; + } + + private async restartBackgroundForwardFrameSource(): Promise<void> { + const sourceUrl = this.backgroundForwardFrameSourceUrl; + if (!sourceUrl) { + return; + } + + const nextSource = new ForwardFrameSource(); + const metadata = await nextSource.initialize(sourceUrl); + const previousSource = this.backgroundForwardFrameSource; + + this.backgroundForwardFrameSource = nextSource; + const effectiveDuration = getEffectiveVideoStreamDurationSeconds({ + duration: metadata?.duration, + streamDuration: metadata?.streamDuration, + }); + this.backgroundForwardFrameDurationSec = + Number.isFinite(effectiveDuration) && effectiveDuration > 0 ? effectiveDuration : null; + this.lastSyncedBackgroundLoopTimeSec = null; + + previousSource?.cancel(); + void previousSource?.destroy(); + } + + private async syncBackgroundFrame(timeSeconds: number): Promise<void> { + if (this.backgroundForwardFrameSource) { + const duration = this.backgroundForwardFrameDurationSec; + const shouldLoop = Number.isFinite(duration) && (duration ?? 0) > 0; + let normalizedTargetTime = shouldLoop + ? ((timeSeconds % duration!) + duration!) % duration! + : Math.max(0, timeSeconds); + + if ( + shouldLoop && + this.lastSyncedBackgroundLoopTimeSec !== null && + normalizedTargetTime + 0.001 < this.lastSyncedBackgroundLoopTimeSec + ) { + try { + await this.restartBackgroundForwardFrameSource(); + } catch (error) { + console.warn( + "[FrameRenderer] Unable to restart looping video wallpaper decoder during export:", + error, + ); + } + } + + let decodedFrame: VideoFrame | null = null; + try { + decodedFrame = + await this.backgroundForwardFrameSource.getFrameAtTime(normalizedTargetTime); + } catch (error) { + console.warn( + "[FrameRenderer] Decoder-backed video wallpaper failed during export; falling back to media element sync:", + error, + ); + if (await this.fallbackBackgroundForwardFrameSourceToMediaElement()) { + await this.syncBackgroundFrame(timeSeconds); + } + return; + } + const resolvedDecodedDuration = + this.backgroundForwardFrameSource.getResolvedDurationSec(); + if ( + shouldLoop && + this.backgroundForwardFrameSource.hasReachedEndOfStream() && + Number.isFinite(resolvedDecodedDuration) && + (resolvedDecodedDuration ?? 0) > 0 && + normalizedTargetTime > (resolvedDecodedDuration ?? 0) + 0.001 + ) { + this.backgroundForwardFrameDurationSec = resolvedDecodedDuration ?? null; + this.closeBackgroundDecodedFrame(); + try { + await this.restartBackgroundForwardFrameSource(); + normalizedTargetTime = + ((timeSeconds % resolvedDecodedDuration!) + resolvedDecodedDuration!) % + resolvedDecodedDuration!; + const restartedFrame = + await this.backgroundForwardFrameSource.getFrameAtTime( + normalizedTargetTime, + ); + this.backgroundDecodedFrame = restartedFrame; + if (restartedFrame) { + this.lastSyncedBackgroundLoopTimeSec = normalizedTargetTime; + this.drawBackgroundSourceToCanvas( + restartedFrame, + restartedFrame.displayWidth, + restartedFrame.displayHeight, + ); + } + return; + } catch (error) { + console.warn( + "[FrameRenderer] Unable to wrap looping video wallpaper at decoded EOF during export:", + error, + ); + if (await this.fallbackBackgroundForwardFrameSourceToMediaElement()) { + await this.syncBackgroundFrame(timeSeconds); + } + return; + } + } + this.closeBackgroundDecodedFrame(); + this.backgroundDecodedFrame = decodedFrame; + if (decodedFrame) { + this.lastSyncedBackgroundLoopTimeSec = normalizedTargetTime; + this.drawBackgroundSourceToCanvas( + decodedFrame, + decodedFrame.displayWidth, + decodedFrame.displayHeight, + ); + } + return; + } + + await this.syncBackgroundVideo(timeSeconds); + } + + private async fallbackBackgroundForwardFrameSourceToMediaElement(): Promise<boolean> { + const sourceUrl = this.backgroundForwardFrameSourceUrl; + this.backgroundForwardFrameSource?.cancel(); + void this.backgroundForwardFrameSource?.destroy(); + this.backgroundForwardFrameSource = null; + this.backgroundForwardFrameSourceUrl = null; + this.backgroundForwardFrameDurationSec = null; + this.closeBackgroundDecodedFrame(); + this.lastSyncedBackgroundLoopTimeSec = null; + + return sourceUrl ? this.loadBackgroundMediaElementSource(sourceUrl, sourceUrl) : false; + } + + private async loadBackgroundMediaElementSource( + videoSrc: string, + errorLabel: string, + ): Promise<boolean> { + if (this.backgroundVideoElement) { + try { + this.backgroundVideoElement.pause(); + this.backgroundVideoElement.src = ""; + this.backgroundVideoElement.load(); + } catch { + // Ignore media element teardown errors during export fallback. + } + this.backgroundVideoElement = null; + } + this.backgroundSeekPromise = null; + this.cleanupBackgroundSource?.(); + this.cleanupBackgroundSource = null; + + let backgroundSource: Awaited<ReturnType<typeof resolveMediaElementSource>>; + try { + backgroundSource = await resolveMediaElementSource(videoSrc); + } catch (error) { + console.warn( + "[FrameRenderer] Unable to resolve video wallpaper fallback source:", + error, + ); + return false; + } + this.cleanupBackgroundSource = backgroundSource.revoke; + + const video = document.createElement("video"); + video.muted = true; + video.loop = true; + video.playsInline = true; + video.preload = "auto"; + video.src = backgroundSource.src; + video.load(); + + const ready = await new Promise<boolean>((resolve) => { + let settled = false; + let timeoutId: ReturnType<typeof setTimeout> | null = null; + + function cleanup() { + if (timeoutId !== null) { + clearTimeout(timeoutId); + } + video.removeEventListener("loadeddata", onReady); + video.removeEventListener("canplay", onReady); + video.removeEventListener("error", onError); + } + + function settle(value: boolean) { + if (settled) { + return; + } + settled = true; + cleanup(); + resolve(value); + } + function onReady() { + if (video.readyState < HTMLMediaElement.HAVE_CURRENT_DATA) { + return; + } + settle(true); + } + function onError() { + settle(false); + } + + video.addEventListener("loadeddata", onReady); + video.addEventListener("canplay", onReady); + video.addEventListener("error", onError); + timeoutId = setTimeout(() => { + console.warn( + `[FrameRenderer] Video wallpaper media element fallback did not become ready within ${BACKGROUND_MEDIA_ELEMENT_READY_TIMEOUT_MS}ms`, + ); + settle(false); + }, BACKGROUND_MEDIA_ELEMENT_READY_TIMEOUT_MS); + onReady(); + }); + + if (!ready) { + console.warn(`[FrameRenderer] Failed to load video wallpaper: ${errorLabel}`); + try { + video.pause(); + video.src = ""; + video.load(); + } catch { + // Ignore media element teardown errors on failed fallback. + } + backgroundSource.revoke(); + if (this.cleanupBackgroundSource === backgroundSource.revoke) { + this.cleanupBackgroundSource = null; + } + return false; + } + + this.backgroundVideoElement = video; + this.lastSyncedBackgroundLoopTimeSec = null; + this.drawVideoFrameToBackground(); + return true; + } + + private async syncBackgroundVideo(timeSeconds: number): Promise<void> { + const video = this.backgroundVideoElement; + if (!video) return; + + if (video.duration && Number.isFinite(video.duration)) { + const targetTime = timeSeconds % video.duration; + if (Math.abs(video.currentTime - targetTime) > 0.01) { + if (this.backgroundSeekPromise) { + await this.backgroundSeekPromise; + } + + this.backgroundSeekPromise = new Promise<void>((resolve) => { + let settled = false; + let fallbackTimeout: number | null = null; + let animationFrameRequestId: number | null = null; + let videoFrameRequestId: number | null = null; + + const finish = () => { + if (settled) { + return; + } + settled = true; + cleanup(); + resolve(); + }; + + const waitForPresentedFrame = () => { + const requestVideoFrameCallback = ( + video as HTMLVideoElement & { + requestVideoFrameCallback?: ( + callback: ( + now: DOMHighResTimeStamp, + metadata: VideoFrameCallbackMetadata, + ) => void, + ) => number; + cancelVideoFrameCallback?: (handle: number) => void; + } + ).requestVideoFrameCallback; + + animationFrameRequestId = requestAnimationFrame(() => { + animationFrameRequestId = null; + finish(); + }); + + if (typeof requestVideoFrameCallback === "function") { + videoFrameRequestId = requestVideoFrameCallback.call(video, () => { + videoFrameRequestId = null; + finish(); + }); + } + }; + + const handleMediaReady = () => { + if ( + !video.seeking && + Math.abs(video.currentTime - targetTime) <= 0.01 && + video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA + ) { + waitForPresentedFrame(); + } + }; + + const cleanup = () => { + video.removeEventListener("seeked", waitForPresentedFrame); + video.removeEventListener("loadeddata", handleMediaReady); + video.removeEventListener("canplay", handleMediaReady); + video.removeEventListener("error", finish); + if (animationFrameRequestId !== null) { + cancelAnimationFrame(animationFrameRequestId); + animationFrameRequestId = null; + } + if ( + videoFrameRequestId !== null && + typeof ( + video as HTMLVideoElement & { + cancelVideoFrameCallback?: (handle: number) => void; + } + ).cancelVideoFrameCallback === "function" + ) { + ( + video as HTMLVideoElement & { + cancelVideoFrameCallback: (handle: number) => void; + } + ).cancelVideoFrameCallback(videoFrameRequestId); + videoFrameRequestId = null; + } + if (fallbackTimeout !== null) { + window.clearTimeout(fallbackTimeout); + } + }; + + video.addEventListener("seeked", waitForPresentedFrame, { once: true }); + video.addEventListener("loadeddata", handleMediaReady, { once: true }); + video.addEventListener("canplay", handleMediaReady, { once: true }); + video.addEventListener("error", finish, { once: true }); + + fallbackTimeout = window.setTimeout(() => { + finish(); + }, 50); + + try { + video.currentTime = targetTime; + } catch { + finish(); + return; + } + + if ( + !video.seeking && + Math.abs(video.currentTime - targetTime) <= 0.001 && + video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA + ) { + waitForPresentedFrame(); + } + }); + + try { + await this.backgroundSeekPromise; + } finally { + this.backgroundSeekPromise = null; + } + } + } + this.drawVideoFrameToBackground(); + } + + private async resolveWallpaperImageUrl(wallpaper: string): Promise<string> { + if ( + wallpaper.startsWith("file://") || + wallpaper.startsWith("data:") || + wallpaper.startsWith("http") + ) { + return wallpaper; + } + + const resolved = await getAssetPath(wallpaper.replace(/^\/+/, "")); + if (resolved.startsWith("/") && window.location.protocol.startsWith("http")) { + return `${window.location.origin}${resolved}`; + } + + return resolved; + } + + private async resolveWallpaperForExport(wallpaper: string): Promise<string> { + if (!wallpaper) { + return wallpaper; + } + + if (isVideoWallpaperSource(wallpaper)) { + return getExportableVideoUrl(wallpaper); + } + + if ( + wallpaper.startsWith("#") || + wallpaper.startsWith("linear-gradient") || + wallpaper.startsWith("radial-gradient") + ) { + return wallpaper; + } + + const looksLikeAbsoluteFilePath = + wallpaper.startsWith("/") && + !wallpaper.startsWith("//") && + !wallpaper.startsWith("/wallpapers/") && + !wallpaper.startsWith("/app-icons/"); + + const wallpaperAsset = looksLikeAbsoluteFilePath + ? `file://${encodeURI(wallpaper)}` + : wallpaper; + + return getRenderableAssetUrl(wallpaperAsset); + } + + private async setupWebcamSource(): Promise<void> { + const webcamUrl = this.config.webcamUrl; + if (!this.config.webcam?.enabled || !webcamUrl) { + this.webcamForwardFrameSource?.cancel(); + void this.webcamForwardFrameSource?.destroy(); + this.webcamForwardFrameSource = null; + this.closeWebcamDecodedFrame(); + this.cleanupWebcamSource?.(); + this.cleanupWebcamSource = null; + this.webcamVideoElement = null; + this.webcamFrameCacheCanvas = null; + this.webcamFrameCacheCtx = null; + this.lastSyncedWebcamTime = null; + return; + } + + this.webcamForwardFrameSource?.cancel(); + void this.webcamForwardFrameSource?.destroy(); + this.webcamForwardFrameSource = null; + this.closeWebcamDecodedFrame(); + this.cleanupWebcamSource?.(); + this.cleanupWebcamSource = null; + + try { + const frameSource = new ForwardFrameSource(); + await frameSource.initialize(webcamUrl); + this.webcamForwardFrameSource = frameSource; + this.webcamVideoElement = null; + this.webcamSeekPromise = null; + this.webcamFrameCacheCanvas = null; + this.webcamFrameCacheCtx = null; + this.lastSyncedWebcamTime = null; + return; + } catch (error) { + console.warn( + "[FrameRenderer] Decoder-backed webcam source unavailable during export; falling back to media element sync:", + error, + ); + } + + const webcamSource = await resolveMediaElementSource(webcamUrl); + this.cleanupWebcamSource = webcamSource.revoke; + + const video = document.createElement("video"); + video.src = webcamSource.src; + video.muted = true; + video.preload = "auto"; + video.playsInline = true; + video.load(); + + await new Promise<void>((resolve, reject) => { + const onReady = () => { + if (video.readyState < HTMLMediaElement.HAVE_CURRENT_DATA) { + return; + } + cleanup(); + resolve(); + }; + const onError = () => { + cleanup(); + reject(new Error("Failed to load webcam source for export")); + }; + const cleanup = () => { + video.removeEventListener("loadeddata", onReady); + video.removeEventListener("canplay", onReady); + video.removeEventListener("canplaythrough", onReady); + video.removeEventListener("error", onError); + }; + if (video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) { + resolve(); + return; + } + video.addEventListener("loadeddata", onReady, { once: true }); + video.addEventListener("canplay", onReady, { once: true }); + video.addEventListener("canplaythrough", onReady, { once: true }); + video.addEventListener("error", onError, { once: true }); + }).catch((error) => { + console.warn("[FrameRenderer] Webcam overlay unavailable during export:", error); + this.webcamVideoElement = null; + }); + + if (video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) { + this.webcamVideoElement = video; + return; + } + + this.webcamVideoElement = null; + this.webcamFrameCacheCanvas = null; + this.webcamFrameCacheCtx = null; + this.lastSyncedWebcamTime = null; + } + + private async syncWebcamFrame(targetTime: number): Promise<void> { + const webcamTargetTime = getWebcamMediaTargetTimeSeconds({ + currentTime: targetTime, + webcamDuration: Number.isFinite(this.webcamVideoElement?.duration) + ? this.webcamVideoElement?.duration + : null, + timeOffsetMs: this.config.webcam?.timeOffsetMs, + }); + + if (this.webcamForwardFrameSource) { + const clampedTime = clampMediaTimeToDuration(webcamTargetTime, null); + const decodedFrame = await this.webcamForwardFrameSource.getFrameAtTime(clampedTime); + this.closeWebcamDecodedFrame(); + this.webcamDecodedFrame = decodedFrame; + if (decodedFrame) { + this.lastSyncedWebcamTime = clampedTime; + } + return; + } + + const webcamVideo = this.webcamVideoElement; + if (!webcamVideo) { + return; + } + + const clampedTime = clampMediaTimeToDuration( + webcamTargetTime, + Number.isFinite(webcamVideo.duration) ? webcamVideo.duration : null, + ); + + if (Math.abs(webcamVideo.currentTime - clampedTime) <= 0.008) { + this.lastSyncedWebcamTime = clampedTime; + return; + } + + if (this.webcamSeekPromise) { + await this.webcamSeekPromise; + } + + this.webcamSeekPromise = new Promise<void>((resolve) => { + let settled = false; + let fallbackTimeout: number | null = null; + let animationFrameRequestId: number | null = null; + let videoFrameRequestId: number | null = null; + const waitForPresentedFrame = () => { + const requestVideoFrameCallback = ( + webcamVideo as HTMLVideoElement & { + requestVideoFrameCallback?: ( + callback: ( + now: DOMHighResTimeStamp, + metadata: VideoFrameCallbackMetadata, + ) => void, + ) => number; + cancelVideoFrameCallback?: (handle: number) => void; + } + ).requestVideoFrameCallback; + + const scheduleAnimationFrameFinish = () => { + animationFrameRequestId = requestAnimationFrame(() => { + animationFrameRequestId = null; + finish(); + }); + }; + + scheduleAnimationFrameFinish(); + + if (typeof requestVideoFrameCallback === "function") { + videoFrameRequestId = requestVideoFrameCallback.call(webcamVideo, () => { + videoFrameRequestId = null; + finish(); + }); + return; + } + }; + const finish = () => { + if (settled) { + return; + } + settled = true; + if (Math.abs(webcamVideo.currentTime - clampedTime) <= 0.02) { + this.lastSyncedWebcamTime = clampedTime; + } + cleanup(); + resolve(); + }; + const handleMediaReady = () => { + if ( + !webcamVideo.seeking && + Math.abs(webcamVideo.currentTime - clampedTime) <= 0.01 && + webcamVideo.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA + ) { + waitForPresentedFrame(); + } + }; + const cleanup = () => { + webcamVideo.removeEventListener("seeked", waitForPresentedFrame); + webcamVideo.removeEventListener("loadeddata", handleMediaReady); + webcamVideo.removeEventListener("canplay", handleMediaReady); + webcamVideo.removeEventListener("error", finish); + if (animationFrameRequestId !== null) { + cancelAnimationFrame(animationFrameRequestId); + animationFrameRequestId = null; + } + if ( + videoFrameRequestId !== null && + typeof ( + webcamVideo as HTMLVideoElement & { + cancelVideoFrameCallback?: (handle: number) => void; + } + ).cancelVideoFrameCallback === "function" + ) { + ( + webcamVideo as HTMLVideoElement & { + cancelVideoFrameCallback: (handle: number) => void; + } + ).cancelVideoFrameCallback(videoFrameRequestId); + videoFrameRequestId = null; + } + if (fallbackTimeout !== null) { + window.clearTimeout(fallbackTimeout); + } + }; + + webcamVideo.addEventListener("seeked", waitForPresentedFrame, { + once: true, + }); + webcamVideo.addEventListener("loadeddata", handleMediaReady, { + once: true, + }); + webcamVideo.addEventListener("canplay", handleMediaReady, { + once: true, + }); + webcamVideo.addEventListener("error", finish, { + once: true, + }); + fallbackTimeout = window.setTimeout(() => { + finish(); + }, 50); + + try { + webcamVideo.currentTime = clampedTime; + } catch { + finish(); + return; + } + + if ( + !webcamVideo.seeking && + Math.abs(webcamVideo.currentTime - clampedTime) <= 0.001 && + webcamVideo.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA + ) { + waitForPresentedFrame(); + } + }); + + try { + await this.webcamSeekPromise; + } finally { + this.webcamSeekPromise = null; + } + } + + async renderFrame( + videoFrame: VideoFrame, + timestamp: number, + cursorTimestamp = timestamp, + frameDurationUs?: number, + backgroundTimelineTimestamp = timestamp, + ): Promise<void> { + if (!this.app || !this.videoContainer || !this.cameraContainer) { + throw new Error("Renderer not initialized"); + } + + this.currentVideoTime = timestamp / 1000000; + + // Create or update video sprite from VideoFrame + if (!this.videoSprite) { + const texture = Texture.from(videoFrame as unknown as PixiTextureInput); + this.videoSprite = new Sprite(texture); + this.videoTextureSource = texture.source as unknown as VideoTextureSource; + this.videoContainer.addChild(this.videoSprite); + if (this.cursorOverlay && this.cursorContainer) { + this.cursorContainer.addChild(this.cursorOverlay.container); + } + if (this.maskGraphics) { + this.videoContainer.addChild(this.maskGraphics); + } + } else { + this.videoTextureSource ??= this.videoSprite.texture + .source as unknown as VideoTextureSource; + this.videoTextureSource.resource = videoFrame; + this.videoTextureSource.update(); + } + + // Apply layout + this.updateLayout(); + const layoutCache = this.layoutCache; + if (!layoutCache) { + return; + } + + const temporalSnapshot = + TEMPORAL_ZOOM_MOTION_BLUR_ENABLED && + (this.config.zoomTemporalMotionBlur ?? 0) > 0 && + typeof frameDurationUs === "number" && + frameDurationUs > 0 + ? await this.renderTemporalMotionBlurFrame( + timestamp, + cursorTimestamp, + backgroundTimelineTimestamp, + frameDurationUs, + layoutCache, + ) + : null; + + if (temporalSnapshot) { + if ( + this.config.annotationRegions && + this.config.annotationRegions.length > 0 && + this.compositeCtx + ) { + const scaleX = this.config.width / BASE_PREVIEW_WIDTH; + const scaleY = this.config.height / BASE_PREVIEW_HEIGHT; + const scaleFactor = (scaleX + scaleY) / 2; + + await renderAnnotations( + this.compositeCtx, + this.config.annotationRegions, + this.config.width, + this.config.height, + temporalSnapshot.timeMs, + scaleFactor, + undefined, + temporalSnapshot.sceneTransform, + this.layoutCache?.maskRect, + ); + } + + if ( + this.config.autoCaptions && + this.config.autoCaptions.length > 0 && + this.config.autoCaptionSettings && + this.compositeCtx + ) { + renderCaptions( + this.compositeCtx, + this.config.autoCaptions, + this.config.autoCaptionSettings, + this.config.width, + this.config.height, + temporalSnapshot.timeMs, + ); + } + + return; + } + + if (this.webcamForwardFrameSource || this.webcamVideoElement) { + const targetTime = Math.max(0, this.currentVideoTime); + await this.syncWebcamFrame(targetTime); + } + + // Sync video wallpaper frame + if (this.backgroundForwardFrameSource || this.backgroundVideoElement) { + await this.syncBackgroundFrame(Math.max(0, backgroundTimelineTimestamp / 1_000_000)); + } + + const timeMs = this.currentVideoTime * 1000; + const cursorTimeMs = cursorTimestamp / 1000; + + if (this.cursorOverlay) { + this.cursorOverlay.update( + this.config.cursorTelemetry ?? [], + cursorTimeMs, + layoutCache.maskRect, + this.config.showCursor ?? true, + false, + ); + } + + const TICKS_PER_FRAME = 1; + + for (let i = 0; i < TICKS_PER_FRAME; i++) { + this.updateAnimationState(timeMs); + } + + applyZoomTransform({ + cameraContainer: this.cameraContainer, + zoomBlurFilter: this.zoomBlurFilter, + motionBlurFilter: this.motionBlurFilter, + stageSize: layoutCache.stageSize, + baseMask: layoutCache.maskRect, + zoomScale: this.animationState.scale, + zoomProgress: this.animationState.progress, + focusX: this.animationState.focusX, + focusY: this.animationState.focusY, + isPlaying: true, + motionBlurAmount: this.config.zoomMotionBlur ?? 0, + motionBlurTuning: this.config.zoomMotionBlurTuning, + transformOverride: { + scale: this.animationState.appliedScale, + x: this.animationState.x, + y: this.animationState.y, + }, + motionBlurState: this.motionBlurState, + frameTimeMs: timeMs, + }); + + // Render the PixiJS stage to its canvas (video only, transparent background) + this.app.renderer.render(this.app.stage); + + // Composite with shadows to final output canvas + this.compositeWithShadows(); + + // Draw device frame overlay on top of video content + + // Render annotations on top if present + if ( + this.config.annotationRegions && + this.config.annotationRegions.length > 0 && + this.compositeCtx + ) { + // Calculate scale factor based on export vs preview dimensions + const scaleX = this.config.width / BASE_PREVIEW_WIDTH; + const scaleY = this.config.height / BASE_PREVIEW_HEIGHT; + const scaleFactor = (scaleX + scaleY) / 2; + + await renderAnnotations( + this.compositeCtx, + this.config.annotationRegions, + this.config.width, + this.config.height, + timeMs, + scaleFactor, + undefined, + { + scale: this.animationState.appliedScale, + x: this.animationState.x, + y: this.animationState.y, + }, + this.layoutCache?.maskRect, + ); + } + + if ( + this.config.autoCaptions && + this.config.autoCaptions.length > 0 && + this.config.autoCaptionSettings && + this.compositeCtx + ) { + renderCaptions( + this.compositeCtx, + this.config.autoCaptions, + this.config.autoCaptionSettings, + this.config.width, + this.config.height, + timeMs, + ); + } + } + + private updateLayout(): void { + if (!this.app || !this.videoSprite || !this.maskGraphics || !this.videoContainer) return; + + const { + width, + height, + cropRegion, + borderRadius = 0, + padding = 0, + videoWidth, + videoHeight, + } = this.config; + + const layout = computePaddedLayout({ + width, + height, + padding, + frameInsets: null, + cropRegion, + videoWidth, + videoHeight, + }); + + this.videoSprite.scale.set(layout.scale); + this.videoSprite.position.set(layout.spriteX, layout.spriteY); + + this.videoContainer.position.set(0, 0); + + const canvasScaleFactor = Math.min( + width / BASE_PREVIEW_WIDTH, + height / BASE_PREVIEW_HEIGHT, + ); + + const scaledBorderRadius = borderRadius * canvasScaleFactor; + + this.maskGraphics.clear(); + drawSquircleOnGraphics(this.maskGraphics, { + x: layout.centerOffsetX, + y: layout.centerOffsetY, + width: layout.croppedDisplayWidth, + height: layout.croppedDisplayHeight, + radius: scaledBorderRadius, + }); + this.maskGraphics.fill({ color: 0xffffff }); + + // Cache layout info + this.layoutCache = { + stageSize: { width, height }, + videoSize: { + width: videoWidth * cropRegion.width, + height: videoHeight * cropRegion.height, + }, + baseScale: layout.scale, + baseOffset: { x: layout.spriteX, y: layout.spriteY }, + maskRect: { + x: layout.centerOffsetX, + y: layout.centerOffsetY, + width: layout.croppedDisplayWidth, + height: layout.croppedDisplayHeight, + sourceCrop: cropRegion, + }, + }; + } + + private updateAnimationState(timeMs: number): number { + if (!this.cameraContainer || !this.layoutCache) return 0; + + const { region, strength, blendedScale } = findDominantRegion( + this.config.zoomRegions, + timeMs, + { + connectZooms: this.config.connectZooms, + zoomInDurationMs: this.config.zoomInDurationMs, + zoomOutDurationMs: this.config.zoomOutDurationMs, + }, + ); + + const defaultFocus = DEFAULT_FOCUS; + let targetScaleFactor = 1; + let targetFocus = { ...defaultFocus }; + let targetProgress = 0; + + if (region && strength > 0) { + const zoomScale = blendedScale ?? ZOOM_DEPTH_SCALES[region.depth]; + + // Cursor follow: use cursor-follow camera for non-manual zoom regions + let regionFocus = region.focus; + if ( + !this.config.zoomClassicMode && + region.mode !== "manual" && + this.config.cursorTelemetry && + this.config.cursorTelemetry.length > 0 + ) { + regionFocus = computeCursorFollowFocus( + this.cursorFollowCamera, + this.config.cursorTelemetry, + timeMs, + zoomScale, + strength, + region.focus, + { snapToEdgesRatio: SNAP_TO_EDGES_RATIO_AUTO }, + ); + } + + targetScaleFactor = zoomScale; + targetFocus = regionFocus; + targetProgress = strength; + } + + const state = this.animationState; + + const prevScale = state.appliedScale; + const prevX = state.x; + const prevY = state.y; + + state.scale = targetScaleFactor; + state.focusX = targetFocus.cx; + state.focusY = targetFocus.cy; + state.progress = targetProgress; + + const projectedTransform = computeZoomTransform({ + stageSize: this.layoutCache.stageSize, + baseMask: this.layoutCache.maskRect, + zoomScale: state.scale, + zoomProgress: state.progress, + focusX: state.focusX, + focusY: state.focusY, + }); + + // Spring-driven zoom animation for export — use content time, not wall-clock, + // so the spring advances at the same rate as the video regardless of render speed. + const deltaMs = + this.lastContentTimeMs !== null ? timeMs - this.lastContentTimeMs : 1000 / 60; + this.lastContentTimeMs = timeMs; + + const zoomSpringConfig = getZoomSpringConfig(this.config.zoomSmoothness, { + stiffnessMultiplier: this.config.cameraSpringStiffnessMultiplier, + dampingMultiplier: this.config.cameraSpringDampingMultiplier, + massMultiplier: this.config.cameraSpringMassMultiplier, + }); + + if (this.config.zoomClassicMode) { + state.appliedScale = projectedTransform.scale; + state.x = projectedTransform.x; + state.y = projectedTransform.y; + resetSpringState(this.springScale, state.appliedScale); + resetSpringState(this.springX, state.x); + resetSpringState(this.springY, state.y); + } else { + state.appliedScale = stepSpringValue( + this.springScale, + projectedTransform.scale, + deltaMs, + zoomSpringConfig, + ); + state.x = stepSpringValue( + this.springX, + projectedTransform.x, + deltaMs, + zoomSpringConfig, + ); + state.y = stepSpringValue( + this.springY, + projectedTransform.y, + deltaMs, + zoomSpringConfig, + ); + } + + return Math.max( + Math.abs(state.appliedScale - prevScale), + Math.abs(state.x - prevX) / Math.max(1, this.layoutCache.stageSize.width), + Math.abs(state.y - prevY) / Math.max(1, this.layoutCache.stageSize.height), + ); + } + + private async renderSceneSample( + timestamp: number, + cursorTimestamp: number, + backgroundTimelineTimestamp: number, + layoutCache: LayoutCache, + useVelocityMotionBlur: boolean, + ): Promise<RenderSnapshot> { + if (!this.app || !this.cameraContainer) { + throw new Error("Renderer not initialized"); + } + + this.currentVideoTime = timestamp / 1_000_000; + + if (this.webcamForwardFrameSource || this.webcamVideoElement) { + await this.syncWebcamFrame(Math.max(0, this.currentVideoTime)); + } + + if (this.backgroundForwardFrameSource || this.backgroundVideoElement) { + await this.syncBackgroundFrame(Math.max(0, backgroundTimelineTimestamp / 1_000_000)); + } + + const timeMs = this.currentVideoTime * 1000; + const cursorTimeMs = cursorTimestamp / 1000; + + if (this.cursorOverlay) { + this.cursorOverlay.update( + this.config.cursorTelemetry ?? [], + cursorTimeMs, + layoutCache.maskRect, + this.config.showCursor ?? true, + false, + ); + } + + this.updateAnimationState(timeMs); + + applyZoomTransform({ + cameraContainer: this.cameraContainer, + zoomBlurFilter: this.zoomBlurFilter, + motionBlurFilter: this.motionBlurFilter, + stageSize: layoutCache.stageSize, + baseMask: layoutCache.maskRect, + zoomScale: this.animationState.scale, + zoomProgress: this.animationState.progress, + focusX: this.animationState.focusX, + focusY: this.animationState.focusY, + isPlaying: true, + motionBlurAmount: useVelocityMotionBlur ? (this.config.zoomMotionBlur ?? 0) : 0, + motionBlurTuning: this.config.zoomMotionBlurTuning, + transformOverride: { + scale: this.animationState.appliedScale, + x: this.animationState.x, + y: this.animationState.y, + }, + motionBlurState: this.motionBlurState, + frameTimeMs: timeMs, + }); + + this.app.renderer.render(this.app.stage); + this.compositeWithShadows(); + + return { + timeMs, + sceneTransform: { + scale: this.animationState.appliedScale, + x: this.animationState.x, + y: this.animationState.y, + }, + }; + } + + private async renderTemporalMotionBlurFrame( + timestamp: number, + cursorTimestamp: number, + backgroundTimelineTimestamp: number, + frameDurationUs: number, + layoutCache: LayoutCache, + ): Promise<RenderSnapshot | null> { + if (!this.compositeCanvas || !this.compositeCtx || !this.temporalAccumulationCtx) { + return null; + } + + const blurConfig = getTemporalMotionBlurConfig(this.config.zoomTemporalMotionBlur, { + sampleCount: this.config.zoomMotionBlurSampleCount, + shutterFraction: this.config.zoomMotionBlurShutterFraction, + }); + if (!blurConfig) { + return null; + } + + const samplePlan = buildTemporalSamplePlanUs(frameDurationUs, blurConfig); + + this.temporalAccumulationCtx.clearRect(0, 0, this.config.width, this.config.height); + + let centerSnapshot: RenderSnapshot | null = null; + let lastSnapshot: RenderSnapshot | null = null; + + for (const { offsetUs: sampleOffsetUs, weight } of samplePlan) { + const sampleTimestamp = Math.max(0, timestamp + sampleOffsetUs); + const sampleCursorTimestamp = Math.max(0, cursorTimestamp + sampleOffsetUs); + const sampleBackgroundTimelineTimestamp = Math.max( + 0, + backgroundTimelineTimestamp + sampleOffsetUs, + ); + const snapshot = await this.renderSceneSample( + sampleTimestamp, + sampleCursorTimestamp, + sampleBackgroundTimelineTimestamp, + layoutCache, + false, + ); + lastSnapshot = snapshot; + if (Math.abs(sampleOffsetUs) < 0.0001) { + centerSnapshot = snapshot; + } + + this.temporalAccumulationCtx.save(); + this.temporalAccumulationCtx.globalCompositeOperation = "lighter"; + this.temporalAccumulationCtx.globalAlpha = weight; + this.temporalAccumulationCtx.drawImage(this.compositeCanvas, 0, 0); + this.temporalAccumulationCtx.restore(); + } + this.compositeCtx.clearRect(0, 0, this.config.width, this.config.height); + this.compositeCtx.drawImage(this.temporalAccumulationCanvas!, 0, 0); + + return centerSnapshot ?? lastSnapshot; + } + + private compositeWithShadows(): void { + if (!this.compositeCanvas || !this.compositeCtx || !this.app) return; + + const videoCanvas = this.app.canvas as HTMLCanvasElement; + const ctx = this.compositeCtx; + const w = this.compositeCanvas.width; + const h = this.compositeCanvas.height; + + // Clear composite canvas + ctx.clearRect(0, 0, w, h); + ctx.imageSmoothingEnabled = true; + ctx.imageSmoothingQuality = "high"; + + // Step 1: Draw background layer (with optional blur, not affected by zoom) + if (this.backgroundSprite) { + const bgCanvas = this.backgroundSprite; + + if (this.config.backgroundBlur > 0) { + ctx.save(); + const blurPx = this.config.backgroundBlur * 3; + const overscan = Math.ceil(blurPx * 2); + ctx.filter = `blur(${blurPx}px)`; + ctx.drawImage(bgCanvas, -overscan, -overscan, w + overscan * 2, h + overscan * 2); + ctx.restore(); + } else { + ctx.drawImage(bgCanvas, 0, 0, w, h); + } + } else { + console.warn("[FrameRenderer] No background sprite found during compositing!"); + } + + // Draw video layer with shadows on top of background + if ( + this.config.showShadow && + this.config.shadowIntensity > 0 && + this.shadowCanvas && + this.shadowCtx + ) { + const shadowCtx = this.shadowCtx; + shadowCtx.clearRect(0, 0, w, h); + shadowCtx.imageSmoothingEnabled = true; + shadowCtx.imageSmoothingQuality = "high"; + shadowCtx.save(); + + // Calculate shadow parameters based on intensity (0-1) + const intensity = this.config.shadowIntensity; + const baseBlur1 = 48 * intensity; + const baseBlur2 = 16 * intensity; + const baseBlur3 = 8 * intensity; + const baseAlpha1 = 0.7 * intensity; + const baseAlpha2 = 0.5 * intensity; + const baseAlpha3 = 0.3 * intensity; + const baseOffset = 12 * intensity; + + shadowCtx.filter = `drop-shadow(0 ${baseOffset}px ${baseBlur1}px rgba(0,0,0,${baseAlpha1})) drop-shadow(0 ${baseOffset / 3}px ${baseBlur2}px rgba(0,0,0,${baseAlpha2})) drop-shadow(0 ${baseOffset / 6}px ${baseBlur3}px rgba(0,0,0,${baseAlpha3}))`; + shadowCtx.drawImage(videoCanvas, 0, 0, w, h); + shadowCtx.restore(); + ctx.drawImage(this.shadowCanvas, 0, 0, w, h); + } else { + ctx.drawImage(videoCanvas, 0, 0, w, h); + } + + this.drawWebcamOverlay(ctx, w, h); + } + + private drawWebcamOverlay(ctx: CanvasRenderingContext2D, width: number, height: number): void { + const webcam = this.config.webcam; + const webcamDecodedFrame = this.webcamDecodedFrame; + const webcamVideo = this.webcamVideoElement; + if (!webcam?.enabled || (!webcamDecodedFrame && !webcamVideo)) { + return; + } + + const hasCachedWebcamFrame = Boolean( + this.webcamFrameCacheCanvas && + this.webcamFrameCacheCanvas.width > 0 && + this.webcamFrameCacheCanvas.height > 0, + ); + const hasLiveWebcamFrame = webcamDecodedFrame + ? webcamDecodedFrame.displayWidth > 0 && webcamDecodedFrame.displayHeight > 0 + : Boolean( + webcamVideo && + webcamVideo.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA && + webcamVideo.videoWidth > 0 && + webcamVideo.videoHeight > 0, + ); + if (!hasLiveWebcamFrame && !hasCachedWebcamFrame) { + return; + } + + const bubbleCanvas = this.webcamBubbleCanvas ?? document.createElement("canvas"); + this.webcamBubbleCanvas = bubbleCanvas; + const bubbleCtx = + this.webcamBubbleCtx ?? configureHighQuality2DContext(bubbleCanvas.getContext("2d")); + if (!bubbleCtx) { + return; + } + this.webcamBubbleCtx = bubbleCtx; + + const expectedWebcamTargetTime = getWebcamMediaTargetTimeSeconds({ + currentTime: this.currentVideoTime, + webcamDuration: Number.isFinite(webcamVideo?.duration) ? webcamVideo?.duration : null, + timeOffsetMs: webcam.timeOffsetMs, + }); + + const canRefreshCache = + hasLiveWebcamFrame && + this.lastSyncedWebcamTime !== null && + Math.abs(this.lastSyncedWebcamTime - expectedWebcamTargetTime) <= 0.02 && + (webcamDecodedFrame + ? true + : Boolean( + webcamVideo && + !webcamVideo.seeking && + Math.abs(webcamVideo.currentTime - expectedWebcamTargetTime) <= 0.02 && + webcamVideo.videoWidth > 0 && + webcamVideo.videoHeight > 0, + )); + + if (canRefreshCache) { + const liveFrameWidth = webcamDecodedFrame?.displayWidth ?? webcamVideo?.videoWidth ?? 0; + const liveFrameHeight = + webcamDecodedFrame?.displayHeight ?? webcamVideo?.videoHeight ?? 0; + if ( + !this.webcamFrameCacheCanvas || + this.webcamFrameCacheCanvas.width !== liveFrameWidth || + this.webcamFrameCacheCanvas.height !== liveFrameHeight + ) { + this.webcamFrameCacheCanvas = document.createElement("canvas"); + this.webcamFrameCacheCanvas.width = liveFrameWidth; + this.webcamFrameCacheCanvas.height = liveFrameHeight; + this.webcamFrameCacheCtx = configureHighQuality2DContext( + this.webcamFrameCacheCanvas.getContext("2d"), + ); + } + + this.webcamFrameCacheCtx?.clearRect( + 0, + 0, + this.webcamFrameCacheCanvas!.width, + this.webcamFrameCacheCanvas!.height, + ); + this.webcamFrameCacheCtx?.drawImage( + webcamDecodedFrame ?? webcamVideo!, + 0, + 0, + this.webcamFrameCacheCanvas!.width, + this.webcamFrameCacheCanvas!.height, + ); + } + + const webcamFrameSource = + this.webcamFrameCacheCanvas ?? + (hasLiveWebcamFrame ? (webcamDecodedFrame ?? webcamVideo) : null); + if (!webcamFrameSource) { + return; + } + + const sourceWidth = + ("displayWidth" in webcamFrameSource + ? webcamFrameSource.displayWidth + : "videoWidth" in webcamFrameSource + ? webcamFrameSource.videoWidth + : webcamFrameSource.width) || 1; + const sourceHeight = + ("displayHeight" in webcamFrameSource + ? webcamFrameSource.displayHeight + : "videoHeight" in webcamFrameSource + ? webcamFrameSource.videoHeight + : webcamFrameSource.height) || sourceWidth; + const margin = scaleWebcamOverlayPixels(webcam.margin ?? 24, width); + const widthPercent = webcam.width ?? webcam.size ?? 50; + const heightPercent = getCropMatchedWebcamHeightPercent( + widthPercent, + webcam.height ?? webcam.size ?? 50, + sourceWidth, + sourceHeight, + webcam.cropRegion, + ); + const dimensions = getWebcamOverlayDimensionsPx({ + containerWidth: width, + containerHeight: height, + widthPercent, + heightPercent, + margin, + zoomScale: this.animationState.appliedScale || 1, + reactToZoom: webcam.reactToZoom ?? true, + }); + const { x, y } = getWebcamOverlayPosition({ + containerWidth: width, + containerHeight: height, + width: dimensions.width, + height: dimensions.height, + margin, + positionPreset: webcam.positionPreset ?? webcam.corner, + positionX: webcam.positionX ?? 1, + positionY: webcam.positionY ?? 1, + legacyCorner: webcam.corner, + }); + const radius = getWebcamCornerRadiusPx( + webcam.roundness ?? DEFAULT_WEBCAM_ROUNDNESS, + dimensions.width, + dimensions.height, + ); + const bubbleWidth = Math.max(1, Math.ceil(dimensions.width)); + const bubbleHeight = Math.max(1, Math.ceil(dimensions.height)); + if (bubbleCanvas.width !== bubbleWidth || bubbleCanvas.height !== bubbleHeight) { + bubbleCanvas.width = bubbleWidth; + bubbleCanvas.height = bubbleHeight; + } + bubbleCtx.clearRect(0, 0, bubbleCanvas.width, bubbleCanvas.height); + bubbleCtx.imageSmoothingEnabled = true; + bubbleCtx.imageSmoothingQuality = "high"; + const { sx, sy, sw, sh } = getWebcamCropSourceRect( + webcam.cropRegion, + sourceWidth, + sourceHeight, + ); + const coverScale = Math.max(dimensions.width / sw, dimensions.height / sh); + const drawWidth = sw * coverScale; + const drawHeight = sh * coverScale; + const drawX = (dimensions.width - drawWidth) / 2; + const drawY = (dimensions.height - drawHeight) / 2; + + bubbleCtx.save(); + drawSquircleOnCanvas(bubbleCtx, { + x: 0, + y: 0, + width: dimensions.width, + height: dimensions.height, + radius, + }); + bubbleCtx.clip(); + if (webcam.mirror) { + bubbleCtx.save(); + bubbleCtx.translate(dimensions.width, 0); + bubbleCtx.scale(-1, 1); + bubbleCtx.drawImage( + webcamFrameSource, + sx, + sy, + sw, + sh, + drawX, + drawY, + drawWidth, + drawHeight, + ); + bubbleCtx.restore(); + } else { + bubbleCtx.drawImage( + webcamFrameSource, + sx, + sy, + sw, + sh, + drawX, + drawY, + drawWidth, + drawHeight, + ); + } + bubbleCtx.restore(); + + if ((webcam.shadow ?? 0) > 0) { + const shadowSize = Math.min(dimensions.width, dimensions.height); + ctx.save(); + ctx.filter = getWebcamShadowFilter(shadowSize, webcam.shadow); + ctx.drawImage(bubbleCanvas, x, y, dimensions.width, dimensions.height); + ctx.restore(); + return; + } + + ctx.drawImage(bubbleCanvas, x, y, dimensions.width, dimensions.height); + } + + private closeWebcamDecodedFrame(): void { + if (!this.webcamDecodedFrame) { + return; + } + + this.webcamDecodedFrame.close(); + this.webcamDecodedFrame = null; + } + + getCanvas(): HTMLCanvasElement { + if (!this.compositeCanvas) { + throw new Error("Renderer not initialized"); + } + return this.compositeCanvas; + } + + destroy(): void { + if (this.videoSprite) { + const videoTexture = this.videoSprite.texture; + this.videoSprite.destroy({ texture: false, textureSource: false }); + videoTexture?.destroy(true); + this.videoSprite = null; + this.videoTextureSource = null; + } + this.backgroundSprite = null; + if (this.app) { + destroyPixiApplication(this.app, "legacy export renderer"); + this.app = null; + } + this.zoomBlurFilter?.destroy(); + this.motionBlurFilter?.destroy(); + this.cameraContainer = null; + this.videoContainer = null; + this.maskGraphics = null; + this.zoomBlurFilter = null; + this.motionBlurFilter = null; + if (this.cursorOverlay) { + this.cursorOverlay.destroy(); + this.cursorOverlay = null; + } + this.shadowCanvas = null; + this.shadowCtx = null; + this.compositeCanvas = null; + this.compositeCtx = null; + this.temporalAccumulationCanvas = null; + this.temporalAccumulationCtx = null; + this.backgroundCtx = null; + this.closeBackgroundDecodedFrame(); + this.backgroundForwardFrameSource?.cancel(); + void this.backgroundForwardFrameSource?.destroy(); + this.backgroundForwardFrameSource = null; + this.backgroundForwardFrameSourceUrl = null; + this.backgroundForwardFrameDurationSec = null; + this.lastSyncedBackgroundLoopTimeSec = null; + if (this.backgroundVideoElement) { + this.backgroundVideoElement.pause(); + this.backgroundVideoElement.src = ""; + this.backgroundVideoElement.load(); + this.backgroundVideoElement = null; + } + this.cleanupBackgroundSource?.(); + this.cleanupBackgroundSource = null; + if (this.webcamVideoElement) { + this.webcamVideoElement.pause(); + this.webcamVideoElement.src = ""; + this.webcamVideoElement.load(); + this.webcamVideoElement = null; + } + this.webcamForwardFrameSource?.cancel(); + void this.webcamForwardFrameSource?.destroy(); + this.webcamForwardFrameSource = null; + this.closeWebcamDecodedFrame(); + this.cleanupWebcamSource?.(); + this.cleanupWebcamSource = null; + this.webcamFrameCacheCanvas = null; + this.webcamFrameCacheCtx = null; + this.webcamBubbleCanvas = null; + this.webcamBubbleCtx = null; + this.lastSyncedWebcamTime = null; + } } diff --git a/src/lib/exporter/gifExporter.test.ts b/src/lib/exporter/gifExporter.test.ts new file mode 100644 index 000000000..8b261d976 --- /dev/null +++ b/src/lib/exporter/gifExporter.test.ts @@ -0,0 +1,509 @@ +import * as fc from "fast-check"; +import { describe, expect, it } from "vitest"; +import { + buildGifFrameRendererConfig, + calculateOutputDimensions, + getGifRepeat, +} from "./gifExporter"; +import { GIF_SIZE_PRESETS, GifSizePreset } from "./types"; + +/** + * Property 2: Loop Encoding Correctness + * + * *For any* GIF export configuration, when loop is enabled the output GIF SHALL + * have a loop count of 0 (infinite), and when loop is disabled the output GIF + * SHALL have a loop count of 1 (play once). + * + * **Validates: Requirements 3.2, 3.3** + * + * Feature: gif-export, Property 2: Loop Encoding Correctness + */ +describe("GIF Exporter", () => { + describe("Property 2: Loop Encoding Correctness", () => { + /** + * Test the loop configuration mapping logic. + * In gif.js: repeat=0 means infinite loop, repeat=1 means play once (no loop) + */ + it("should map loop=true to repeat=0 (infinite) and loop=false to repeat=1 (once)", () => { + fc.assert( + fc.property(fc.boolean(), (loopEnabled: boolean) => { + const repeat = getGifRepeat(loopEnabled); + + if (loopEnabled) { + // When loop is enabled, repeat should be 0 (infinite loop) + expect(repeat).toBe(0); + } else { + // When loop is disabled, repeat should be 1 (play once) + expect(repeat).toBe(1); + } + }), + { numRuns: 100 }, + ); + }); + + it("should always produce valid repeat values (0 or 1)", () => { + fc.assert( + fc.property(fc.boolean(), (loopEnabled: boolean) => { + const repeat = getGifRepeat(loopEnabled); + expect([0, 1]).toContain(repeat); + }), + { numRuns: 100 }, + ); + }); + }); + + /** + * Property 4: Aspect Ratio Preservation + * + * *For any* source video with aspect ratio R and any size preset, the exported + * GIF SHALL have an aspect ratio within 0.01 of R. + * + * **Validates: Requirements 4.4** + * + * Feature: gif-export, Property 4: Aspect Ratio Preservation + */ + describe("Property 4: Aspect Ratio Preservation", () => { + const sizePresets: GifSizePreset[] = ["medium", "large", "original"]; + + it("should preserve aspect ratio within 0.01 tolerance for all size presets", () => { + fc.assert( + fc.property( + fc.integer({ min: 100, max: 4000 }), // sourceWidth + fc.integer({ min: 100, max: 4000 }), // sourceHeight + fc.constantFrom(...sizePresets), + (sourceWidth: number, sourceHeight: number, sizePreset: GifSizePreset) => { + const originalAspectRatio = sourceWidth / sourceHeight; + + const { width, height } = calculateOutputDimensions( + sourceWidth, + sourceHeight, + sizePreset, + GIF_SIZE_PRESETS, + ); + + const outputAspectRatio = width / height; + + // Aspect ratio should be preserved within 0.01 tolerance + // (small deviation allowed due to rounding to even numbers) + expect(Math.abs(originalAspectRatio - outputAspectRatio)).toBeLessThan( + 0.01, + ); + }, + ), + { numRuns: 100 }, + ); + }); + + it("should return original dimensions when source is smaller than preset max height", () => { + fc.assert( + fc.property( + fc.integer({ min: 100, max: 400 }), // sourceWidth (small) + fc.integer({ min: 100, max: 400 }), // sourceHeight (small, less than 720p) + (sourceWidth: number, sourceHeight: number) => { + // For 'medium' preset with maxHeight 720, if source is smaller, use original + const { width, height } = calculateOutputDimensions( + sourceWidth, + sourceHeight, + "medium", + GIF_SIZE_PRESETS, + ); + + expect(width).toBe(sourceWidth); + expect(height).toBe(sourceHeight); + }, + ), + { numRuns: 100 }, + ); + }); + + it('should return original dimensions for "original" preset regardless of size', () => { + fc.assert( + fc.property( + fc.integer({ min: 100, max: 4000 }), + fc.integer({ min: 100, max: 4000 }), + (sourceWidth: number, sourceHeight: number) => { + const { width, height } = calculateOutputDimensions( + sourceWidth, + sourceHeight, + "original", + GIF_SIZE_PRESETS, + ); + + expect(width).toBe(sourceWidth); + expect(height).toBe(sourceHeight); + }, + ), + { numRuns: 100 }, + ); + }); + + it("should scale down to preset max height when source is larger", () => { + fc.assert( + fc.property( + fc.integer({ min: 1000, max: 4000 }), // sourceWidth (large) + fc.integer({ min: 800, max: 2000 }), // sourceHeight (larger than 720p) + (sourceWidth: number, sourceHeight: number) => { + // For 'medium' preset with maxHeight 720 + const { width, height } = calculateOutputDimensions( + sourceWidth, + sourceHeight, + "medium", + GIF_SIZE_PRESETS, + ); + + // Height should be at most 720 (or 722 due to even rounding) + expect(height).toBeLessThanOrEqual(722); + // Width should be scaled proportionally + expect(width).toBeLessThan(sourceWidth); + }, + ), + { numRuns: 100 }, + ); + }); + }); +}); + +/** + * Property 3: Size Preset Resolution Mapping + * + * *For any* valid size preset and source video dimensions, the GIF_Exporter SHALL + * produce output with height matching the preset's max height (or source height if smaller), + * with width calculated to maintain aspect ratio. + * + * **Validates: Requirements 4.2** + * + * Feature: gif-export, Property 3: Size Preset Resolution Mapping + */ +describe("Property 3: Size Preset Resolution Mapping", () => { + it("should map size presets to correct max heights", () => { + fc.assert( + fc.property( + fc.integer({ min: 800, max: 4000 }), // sourceWidth (large enough to trigger scaling) + fc.integer({ min: 800, max: 2000 }), // sourceHeight (larger than all presets except original) + fc.constantFrom("medium", "large") as fc.Arbitrary<GifSizePreset>, + (sourceWidth: number, sourceHeight: number, sizePreset: GifSizePreset) => { + const { height } = calculateOutputDimensions( + sourceWidth, + sourceHeight, + sizePreset, + GIF_SIZE_PRESETS, + ); + + const expectedMaxHeight = GIF_SIZE_PRESETS[sizePreset].maxHeight; + + // Height should be at or below the preset's max height + // (allowing +2 for even number rounding) + expect(height).toBeLessThanOrEqual(expectedMaxHeight + 2); + }, + ), + { numRuns: 100 }, + ); + }); + + it("should use source dimensions when smaller than preset", () => { + fc.assert( + fc.property( + fc.integer({ min: 100, max: 400 }), // sourceWidth + fc.integer({ min: 100, max: 400 }), // sourceHeight (smaller than 720p 'medium' preset) + fc.constantFrom("medium", "large", "original") as fc.Arbitrary<GifSizePreset>, + (sourceWidth: number, sourceHeight: number, sizePreset: GifSizePreset) => { + const { width, height } = calculateOutputDimensions( + sourceWidth, + sourceHeight, + sizePreset, + GIF_SIZE_PRESETS, + ); + + // When source is smaller than preset, use original dimensions + expect(width).toBe(sourceWidth); + expect(height).toBe(sourceHeight); + }, + ), + { numRuns: 100 }, + ); + }); + + it("should produce even dimensions for encoder compatibility", () => { + fc.assert( + fc.property( + fc.integer({ min: 100, max: 4000 }), + fc.integer({ min: 100, max: 4000 }), + fc.constantFrom("medium", "large", "original") as fc.Arbitrary<GifSizePreset>, + (sourceWidth: number, sourceHeight: number, sizePreset: GifSizePreset) => { + const { width, height } = calculateOutputDimensions( + sourceWidth, + sourceHeight, + sizePreset, + GIF_SIZE_PRESETS, + ); + + // When scaling occurs, dimensions should be even + // (original dimensions are passed through as-is) + if ( + sourceHeight > GIF_SIZE_PRESETS[sizePreset].maxHeight && + sizePreset !== "original" + ) { + expect(width % 2).toBe(0); + expect(height % 2).toBe(0); + } + }, + ), + { numRuns: 100 }, + ); + }); +}); + +describe("GIF renderer config", () => { + it("forwards cursor click-effect settings into the frame renderer config", () => { + const config = buildGifFrameRendererConfig( + { + videoUrl: "file:///recording.mp4", + width: 1920, + height: 1080, + frameRate: 30, + loop: true, + sizePreset: "original", + wallpaper: "#101010", + zoomRegions: [], + showShadow: false, + shadowIntensity: 0, + backgroundBlur: 0, + cropRegion: { x: 0, y: 0, width: 1, height: 1 }, + cursorClickEffect: "echo", + cursorClickEffectColor: "#22C55E", + cursorClickEffectScale: 1.4, + cursorClickEffectOpacity: 0.65, + cursorClickEffectDurationMs: 720, + } as never, + { width: 1920, height: 1080 }, + ); + + expect(config).toMatchObject({ + cursorClickEffect: "echo", + cursorClickEffectColor: "#22C55E", + cursorClickEffectScale: 1.4, + cursorClickEffectOpacity: 0.65, + cursorClickEffectDurationMs: 720, + }); + }); +}); + +/** + * Property 6: Frame Count Consistency + * + * *For any* video with effective duration D (excluding trim regions) and frame rate F, + * the exported GIF SHALL contain approximately D × F frames (within ±1 frame tolerance). + * + * **Validates: Requirements 5.1** + * + * Feature: gif-export, Property 6: Frame Count Consistency + */ +describe("Property 6: Frame Count Consistency", () => { + // Helper function to calculate expected frame count + const calculateExpectedFrameCount = (durationSeconds: number, frameRate: number): number => { + return Math.ceil(durationSeconds * frameRate); + }; + + it("should calculate correct frame count for duration and frame rate", () => { + fc.assert( + fc.property( + fc.float({ min: 0.5, max: 60, noNaN: true }), // duration in seconds + fc.constantFrom(10, 15, 20, 25, 30), // valid frame rates + (duration: number, frameRate: number) => { + const expectedFrames = calculateExpectedFrameCount(duration, frameRate); + + // Frame count should be positive + expect(expectedFrames).toBeGreaterThan(0); + + // Frame count should be approximately duration * frameRate + const approximateFrames = duration * frameRate; + expect(Math.abs(expectedFrames - approximateFrames)).toBeLessThanOrEqual(1); + }, + ), + { numRuns: 100 }, + ); + }); + + it("should produce more frames with higher frame rates", () => { + fc.assert( + fc.property( + fc.float({ min: 1, max: 30, noNaN: true }), // duration in seconds + (duration: number) => { + const frames10fps = calculateExpectedFrameCount(duration, 10); + const frames30fps = calculateExpectedFrameCount(duration, 30); + + // 30fps should produce approximately 3x more frames than 10fps + expect(frames30fps).toBeGreaterThan(frames10fps); + expect(frames30fps / frames10fps).toBeCloseTo(3, 0); + }, + ), + { numRuns: 100 }, + ); + }); + + it("should handle trim regions by reducing effective duration", () => { + fc.assert( + fc.property( + fc.float({ min: 5, max: 60, noNaN: true }), // total duration + fc.float({ min: 0.5, max: 2, noNaN: true }), // trim duration (smaller than total) + fc.constantFrom(10, 15, 20, 25, 30), + (totalDuration: number, trimDuration: number, frameRate: number) => { + const effectiveDuration = totalDuration - trimDuration; + const framesWithTrim = calculateExpectedFrameCount( + effectiveDuration, + frameRate, + ); + const framesWithoutTrim = calculateExpectedFrameCount(totalDuration, frameRate); + + // Trimmed video should have fewer frames + expect(framesWithTrim).toBeLessThan(framesWithoutTrim); + }, + ), + { numRuns: 100 }, + ); + }); +}); + +/** + * Property 5: Valid GIF Output (Configuration Validation) + * + * *For any* successful GIF export, the output blob SHALL be a valid GIF file. + * This test validates the GIF configuration parameters are correctly set up. + * + * **Validates: Requirements 5.3** + * + * Feature: gif-export, Property 5: Valid GIF Output + * + * Note: Full GIF encoding validation requires browser environment with video. + * This test validates configuration correctness. + */ +describe("Property 5: Valid GIF Output (Configuration)", () => { + it("should generate valid GIF configuration for all frame rates", () => { + fc.assert( + fc.property( + fc.constantFrom(10, 15, 20, 25, 30), + fc.integer({ min: 100, max: 1920 }), + fc.integer({ min: 100, max: 1080 }), + fc.boolean(), + (frameRate: number, width: number, height: number, loop: boolean) => { + // Validate frame delay calculation (gif.js uses milliseconds) + const frameDelay = Math.round(1000 / frameRate); + + // Frame delay should be positive and reasonable + expect(frameDelay).toBeGreaterThan(0); + expect(frameDelay).toBeLessThanOrEqual(100); // 10fps = 100ms delay + + // Loop configuration + const repeat = loop ? 0 : 1; + expect([0, 1]).toContain(repeat); + + // Dimensions should be positive + expect(width).toBeGreaterThan(0); + expect(height).toBeGreaterThan(0); + }, + ), + { numRuns: 100 }, + ); + }); + + it("should calculate correct frame delays for each frame rate", () => { + const expectedDelays: Record<number, number> = { + 10: 100, // 1000ms / 10fps = 100ms + 15: 67, // 1000ms / 15fps ≈ 67ms + 20: 50, // 1000ms / 20fps = 50ms + 25: 40, // 1000ms / 25fps = 40ms + 30: 33, // 1000ms / 30fps ≈ 33ms + }; + + for (const [fps, expectedDelay] of Object.entries(expectedDelays)) { + const frameRate = Number(fps); + const actualDelay = Math.round(1000 / frameRate); + expect(actualDelay).toBe(expectedDelay); + } + }); +}); + +/** + * Property 7: MP4 Export Regression + * + * *For any* valid MP4 export configuration that worked before this feature, + * the Video_Exporter SHALL continue to produce valid MP4 output. + * + * **Validates: Requirements 7.2** + * + * Feature: gif-export, Property 7: MP4 Export Regression + * + * Note: This test validates that MP4 export configuration remains unchanged. + */ +describe("Property 7: MP4 Export Regression", () => { + it("should maintain valid MP4 quality presets", () => { + const qualityPresets = ["medium", "good", "source"]; + + fc.assert( + fc.property(fc.constantFrom(...qualityPresets), (quality: string) => { + // Quality presets should be valid + expect(["medium", "good", "source"]).toContain(quality); + }), + { numRuns: 100 }, + ); + }); + + it("should calculate valid MP4 export dimensions", () => { + fc.assert( + fc.property( + fc.integer({ min: 640, max: 3840 }), // sourceWidth + fc.integer({ min: 480, max: 2160 }), // sourceHeight + fc.constantFrom("medium", "good", "source"), + (sourceWidth: number, sourceHeight: number, quality: string) => { + let exportWidth: number; + let exportHeight: number; + const aspectRatio = sourceWidth / sourceHeight; + + if (quality === "source") { + // Source quality uses original dimensions (may be odd) + exportWidth = sourceWidth; + exportHeight = sourceHeight; + + // Dimensions should be positive + expect(exportWidth).toBeGreaterThan(0); + expect(exportHeight).toBeGreaterThan(0); + } else { + const targetHeight = quality === "medium" ? 720 : 1080; + exportHeight = Math.floor(targetHeight / 2) * 2; + exportWidth = Math.floor((exportHeight * aspectRatio) / 2) * 2; + + // Dimensions should be positive and even for non-source quality + expect(exportWidth).toBeGreaterThan(0); + expect(exportHeight).toBeGreaterThan(0); + expect(exportWidth % 2).toBe(0); + expect(exportHeight % 2).toBe(0); + } + }, + ), + { numRuns: 100 }, + ); + }); + + it("should maintain aspect ratio in MP4 export", () => { + fc.assert( + fc.property( + fc.integer({ min: 640, max: 3840 }), + fc.integer({ min: 480, max: 2160 }), + fc.constantFrom("medium", "good"), + (sourceWidth: number, sourceHeight: number, quality: string) => { + const originalAspectRatio = sourceWidth / sourceHeight; + const targetHeight = quality === "medium" ? 720 : 1080; + + const exportHeight = Math.floor(targetHeight / 2) * 2; + const exportWidth = Math.floor((exportHeight * originalAspectRatio) / 2) * 2; + + const exportAspectRatio = exportWidth / exportHeight; + + // Aspect ratio should be preserved within tolerance (due to even rounding) + expect(Math.abs(originalAspectRatio - exportAspectRatio)).toBeLessThan(0.05); + }, + ), + { numRuns: 100 }, + ); + }); +}); diff --git a/src/lib/exporter/gifExporter.ts b/src/lib/exporter/gifExporter.ts new file mode 100644 index 000000000..d9e1b85e8 --- /dev/null +++ b/src/lib/exporter/gifExporter.ts @@ -0,0 +1,422 @@ +import GIF from "gif.js"; +import type { + AnnotationRegion, + AutoCaptionSettings, + CaptionCue, + CursorClickEffectStyle, + CropRegion, + CursorStyle, + CursorTelemetryPoint, + Padding, + SpeedRegion, + TrimRegion, + WebcamOverlaySettings, + ZoomMotionBlurTuning, + ZoomRegion, + ZoomTransitionEasing, +} from "@/components/video-editor/types"; +import { FrameRenderer } from "./frameRenderer"; +import { StreamingVideoDecoder } from "./streamingDecoder"; +import type { + ExportProgress, + ExportResult, + GIF_SIZE_PRESETS, + GifFrameRate, + GifSizePreset, +} from "./types"; + +const GIF_WORKER_URL = new URL("gif.js/dist/gif.worker.js", import.meta.url).toString(); + +const PROGRESS_SAMPLE_WINDOW_MS = 1_000; + +interface GifExporterConfig { + videoUrl: string; + width: number; + height: number; + frameRate: GifFrameRate; + loop: boolean; + sizePreset: GifSizePreset; + wallpaper: string; + zoomRegions: ZoomRegion[]; + trimRegions?: TrimRegion[]; + speedRegions?: SpeedRegion[]; + showShadow: boolean; + shadowIntensity: number; + backgroundBlur: number; + zoomMotionBlur?: number; + zoomMotionBlurTuning?: ZoomMotionBlurTuning; + zoomTemporalMotionBlur?: number; + zoomMotionBlurSampleCount?: number | null; + zoomMotionBlurShutterFraction?: number | null; + connectZooms?: boolean; + zoomInDurationMs?: number; + zoomInOverlapMs?: number; + zoomOutDurationMs?: number; + connectedZoomGapMs?: number; + connectedZoomDurationMs?: number; + zoomInEasing?: ZoomTransitionEasing; + zoomOutEasing?: ZoomTransitionEasing; + connectedZoomEasing?: ZoomTransitionEasing; + borderRadius?: number; + padding?: Padding | number; + videoPadding?: Padding | number; + cropRegion: CropRegion; + webcam?: WebcamOverlaySettings; + webcamUrl?: string | null; + annotationRegions?: AnnotationRegion[]; + autoCaptions?: CaptionCue[]; + autoCaptionSettings?: AutoCaptionSettings; + cursorTelemetry?: CursorTelemetryPoint[]; + showCursor?: boolean; + cursorStyle?: CursorStyle; + cursorSize?: number; + cursorSmoothing?: number; + cursorSpringStiffnessMultiplier?: number; + cursorSpringDampingMultiplier?: number; + cursorSpringMassMultiplier?: number; + cameraSpringStiffnessMultiplier?: number; + cameraSpringDampingMultiplier?: number; + cameraSpringMassMultiplier?: number; + zoomSmoothness?: number; + zoomClassicMode?: boolean; + cursorMotionBlur?: number; + cursorClickEffect?: CursorClickEffectStyle; + cursorClickEffectColor?: string; + cursorClickEffectScale?: number; + cursorClickEffectOpacity?: number; + cursorClickEffectDurationMs?: number; + cursorClickBounce?: number; + cursorClickBounceDuration?: number; + cursorSway?: number; + previewWidth?: number; + previewHeight?: number; + maxDecodeQueue?: number; + maxPendingFrames?: number; + onProgress?: (progress: ExportProgress) => void; +} + +/** + * Calculate output dimensions based on size preset and source dimensions while preserving aspect ratio. + * @param sourceWidth - Original video width + * @param sourceHeight - Original video height + * @param sizePreset - The size preset to use + * @param sizePresets - The size presets configuration + * @returns The calculated output dimensions + */ +export function calculateOutputDimensions( + sourceWidth: number, + sourceHeight: number, + sizePreset: GifSizePreset, + sizePresets: typeof GIF_SIZE_PRESETS, +): { width: number; height: number } { + const preset = sizePresets[sizePreset]; + const maxHeight = preset.maxHeight; + + // If original is smaller than max height or preset is 'original', use source dimensions + if (sourceHeight <= maxHeight || sizePreset === "original") { + return { width: sourceWidth, height: sourceHeight }; + } + + // Calculate scaled dimensions preserving aspect ratio + const aspectRatio = sourceWidth / sourceHeight; + const newHeight = maxHeight; + const newWidth = Math.round(newHeight * aspectRatio); + + // Ensure dimensions are even (required for some encoders) + return { + width: newWidth % 2 === 0 ? newWidth : newWidth + 1, + height: newHeight % 2 === 0 ? newHeight : newHeight + 1, + }; +} + +export function getGifRepeat(loop: boolean): 0 | 1 { + return loop ? 0 : 1; +} + +export function buildGifFrameRendererConfig( + config: GifExporterConfig, + videoInfo: { width: number; height: number }, +) { + return { + width: config.width, + height: config.height, + wallpaper: config.wallpaper, + zoomRegions: config.zoomRegions, + showShadow: config.showShadow, + shadowIntensity: config.shadowIntensity, + backgroundBlur: config.backgroundBlur, + zoomMotionBlur: config.zoomMotionBlur, + zoomMotionBlurTuning: config.zoomMotionBlurTuning, + zoomTemporalMotionBlur: config.zoomTemporalMotionBlur, + zoomMotionBlurSampleCount: config.zoomMotionBlurSampleCount, + zoomMotionBlurShutterFraction: config.zoomMotionBlurShutterFraction, + connectZooms: config.connectZooms, + zoomInDurationMs: config.zoomInDurationMs, + zoomInOverlapMs: config.zoomInOverlapMs, + zoomOutDurationMs: config.zoomOutDurationMs, + connectedZoomGapMs: config.connectedZoomGapMs, + connectedZoomDurationMs: config.connectedZoomDurationMs, + zoomInEasing: config.zoomInEasing, + zoomOutEasing: config.zoomOutEasing, + connectedZoomEasing: config.connectedZoomEasing, + borderRadius: config.borderRadius, + padding: config.padding, + cropRegion: config.cropRegion, + webcam: config.webcam, + webcamUrl: config.webcamUrl, + videoWidth: videoInfo.width, + videoHeight: videoInfo.height, + annotationRegions: config.annotationRegions, + autoCaptions: config.autoCaptions, + autoCaptionSettings: config.autoCaptionSettings, + speedRegions: config.speedRegions, + previewWidth: config.previewWidth, + previewHeight: config.previewHeight, + cursorTelemetry: config.cursorTelemetry, + showCursor: config.showCursor, + cursorStyle: config.cursorStyle, + cursorSize: config.cursorSize, + cursorSmoothing: config.cursorSmoothing, + cursorSpringStiffnessMultiplier: config.cursorSpringStiffnessMultiplier, + cursorSpringDampingMultiplier: config.cursorSpringDampingMultiplier, + cursorSpringMassMultiplier: config.cursorSpringMassMultiplier, + cameraSpringStiffnessMultiplier: config.cameraSpringStiffnessMultiplier, + cameraSpringDampingMultiplier: config.cameraSpringDampingMultiplier, + cameraSpringMassMultiplier: config.cameraSpringMassMultiplier, + zoomSmoothness: config.zoomSmoothness, + zoomClassicMode: config.zoomClassicMode, + cursorMotionBlur: config.cursorMotionBlur, + cursorClickEffect: config.cursorClickEffect, + cursorClickEffectColor: config.cursorClickEffectColor, + cursorClickEffectScale: config.cursorClickEffectScale, + cursorClickEffectOpacity: config.cursorClickEffectOpacity, + cursorClickEffectDurationMs: config.cursorClickEffectDurationMs, + cursorClickBounce: config.cursorClickBounce, + cursorClickBounceDuration: config.cursorClickBounceDuration, + cursorSway: config.cursorSway, + }; +} + +export class GifExporter { + private config: GifExporterConfig; + private streamingDecoder: StreamingVideoDecoder | null = null; + private renderer: FrameRenderer | null = null; + private gif: GIF | null = null; + private cancelled = false; + private exportStartTimeMs = 0; + private progressSampleStartTimeMs = 0; + private progressSampleStartFrame = 0; + private lastRenderFps: number | undefined; + + constructor(config: GifExporterConfig) { + this.config = config; + } + + async export(): Promise<ExportResult> { + try { + this.cleanup(); + this.cancelled = false; + this.exportStartTimeMs = this.getNowMs(); + this.progressSampleStartTimeMs = this.exportStartTimeMs; + this.progressSampleStartFrame = 0; + this.lastRenderFps = undefined; + + // Initialize streaming decoder and load video metadata + this.streamingDecoder = new StreamingVideoDecoder({ + maxDecodeQueue: this.config.maxDecodeQueue, + maxPendingFrames: this.config.maxPendingFrames, + }); + const videoInfo = await this.streamingDecoder.loadMetadata(this.config.videoUrl); + + // Initialize frame renderer + this.renderer = new FrameRenderer(buildGifFrameRendererConfig(this.config, videoInfo)); + await this.renderer.initialize(); + + // Initialize GIF encoder + // Loop: 0 = infinite loop, 1 = play once (no loop) + const repeat = getGifRepeat(this.config.loop); + const cores = navigator.hardwareConcurrency || 4; + const WORKER_COUNT = Math.max(1, Math.min(8, cores - 1)); + + this.gif = new GIF({ + workers: WORKER_COUNT, + quality: 10, + width: this.config.width, + height: this.config.height, + workerScript: GIF_WORKER_URL, + repeat, + background: "#000000", + transparent: null, + dither: "FloydSteinberg", + }); + + // Calculate effective duration and frame count (excluding trim regions) + const effectiveDuration = this.streamingDecoder.getEffectiveDuration( + this.config.trimRegions, + this.config.speedRegions, + ); + const totalFrames = Math.ceil(effectiveDuration * this.config.frameRate); + + // Calculate frame delay in milliseconds (gif.js uses ms) + const frameDelay = Math.round(1000 / this.config.frameRate); + + console.log("[GifExporter] Original duration:", videoInfo.duration, "s"); + console.log("[GifExporter] Effective duration:", effectiveDuration, "s"); + console.log("[GifExporter] Total frames to export:", totalFrames); + console.log("[GifExporter] Frame rate:", this.config.frameRate, "FPS"); + console.log("[GifExporter] Frame delay:", frameDelay, "ms"); + console.log("[GifExporter] Loop:", this.config.loop ? "infinite" : "once"); + console.log("[GifExporter] Using streaming decode (web-demuxer + VideoDecoder)"); + + let frameIndex = 0; + const frameDurationUs = 1_000_000 / this.config.frameRate; + + // Stream decode and process frames — no seeking! + await this.streamingDecoder.decodeAll( + this.config.frameRate, + this.config.trimRegions, + this.config.speedRegions, + async (videoFrame, _exportTimestampUs, sourceTimestampMs, cursorTimestampMs) => { + if (this.cancelled) { + return; + } + + const sourceTimestampUs = sourceTimestampMs * 1000; + const cursorTimestampUs = cursorTimestampMs * 1000; + await this.renderer!.renderFrame( + videoFrame, + sourceTimestampUs, + cursorTimestampUs, + frameDurationUs, + frameIndex * frameDurationUs, + ); + + this.addRenderedGifFrame(frameDelay); + frameIndex++; + this.reportProgress(frameIndex, totalFrames); + }, + ); + + if (this.cancelled) { + return { success: false, error: "Export cancelled" }; + } + + // Update progress to show we're now in the finalizing phase + if (this.config.onProgress) { + this.config.onProgress({ + currentFrame: totalFrames, + totalFrames, + percentage: 100, + estimatedTimeRemaining: 0, + phase: "finalizing", + renderFps: this.lastRenderFps, + }); + } + + // Render the GIF + const blob = await new Promise<Blob>((resolve, _reject) => { + this.gif!.on("finished", (blob: Blob) => { + resolve(blob); + }); + + // Track rendering progress + this.gif!.on("progress", (progress: number) => { + if (this.config.onProgress) { + this.config.onProgress({ + currentFrame: totalFrames, + totalFrames, + percentage: 100, + estimatedTimeRemaining: 0, + phase: "finalizing", + renderFps: this.lastRenderFps, + renderProgress: Math.round(progress * 100), + }); + } + }); + + // gif.js doesn't have a typed 'error' event, but we can catch errors in the try/catch + this.gif!.render(); + }); + + return { success: true, blob }; + } catch (error) { + console.error("GIF Export error:", error); + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } finally { + this.cleanup(); + } + } + + private addRenderedGifFrame(frameDelay: number) { + const canvas = this.renderer!.getCanvas(); + this.gif!.addFrame(canvas, { delay: frameDelay, copy: true }); + } + + private reportProgress(currentFrame: number, totalFrames: number) { + const nowMs = this.getNowMs(); + const elapsedSeconds = Math.max((nowMs - this.exportStartTimeMs) / 1000, 0.001); + const averageRenderFps = currentFrame / elapsedSeconds; + const sampleElapsedMs = Math.max(nowMs - this.progressSampleStartTimeMs, 1); + const sampleFrameDelta = Math.max(currentFrame - this.progressSampleStartFrame, 0); + const renderFps = (sampleFrameDelta * 1000) / sampleElapsedMs; + const remainingFrames = Math.max(totalFrames - currentFrame, 0); + const estimatedTimeRemaining = + averageRenderFps > 0 ? remainingFrames / averageRenderFps : 0; + this.lastRenderFps = renderFps; + + if (sampleElapsedMs >= PROGRESS_SAMPLE_WINDOW_MS) { + this.progressSampleStartTimeMs = nowMs; + this.progressSampleStartFrame = currentFrame; + } + + if (this.config.onProgress) { + this.config.onProgress({ + currentFrame, + totalFrames, + percentage: totalFrames > 0 ? (currentFrame / totalFrames) * 100 : 100, + estimatedTimeRemaining, + renderFps, + }); + } + } + + private getNowMs(): number { + return typeof performance !== "undefined" ? performance.now() : Date.now(); + } + + cancel(): void { + this.cancelled = true; + if (this.streamingDecoder) { + this.streamingDecoder.cancel(); + } + if (this.gif) { + this.gif.abort(); + } + this.cleanup(); + } + + private cleanup(): void { + if (this.streamingDecoder) { + try { + this.streamingDecoder.destroy(); + } catch (e) { + console.warn("Error destroying streaming decoder:", e); + } + this.streamingDecoder = null; + } + + if (this.renderer) { + try { + this.renderer.destroy(); + } catch (e) { + console.warn("Error destroying renderer:", e); + } + this.renderer = null; + } + + this.gif = null; + } +} diff --git a/src/lib/exporter/index.ts b/src/lib/exporter/index.ts index bc2ac94df..3561cb3a1 100644 --- a/src/lib/exporter/index.ts +++ b/src/lib/exporter/index.ts @@ -1,8 +1,44 @@ -export { VideoExporter } from './videoExporter'; -export { VideoFileDecoder } from './videoDecoder'; -export { FrameRenderer } from './frameRenderer'; -export { VideoMuxer } from './muxer'; -export type { ExportConfig, ExportProgress, ExportResult, VideoFrameData } from './types'; - - -// Ref: https://pietrasiak.com/fast-video-rendering-and-encoding-using-web-apis \ No newline at end of file +export { FrameRenderer } from "./frameRenderer"; +export { calculateOutputDimensions, GifExporter } from "./gifExporter"; +export { ModernVideoExporter } from "./modernVideoExporter"; +export type { + SupportedMp4Dimensions, + SupportedMp4EncoderPath, +} from "./mp4Support"; +export { + DEFAULT_MP4_CODEC, + MP4_CODEC_FALLBACK_LIST, + probeSupportedMp4Dimensions, + resolveSupportedMp4EncoderPath, +} from "./mp4Support"; +export { VideoMuxer } from "./muxer"; +export { StreamingVideoDecoder } from "./streamingDecoder"; +export type { + ExportBackendPreference, + ExportConfig, + ExportEncodeBackend, + ExportEncodingMode, + ExportFormat, + ExportMetrics, + ExportMp4FrameRate, + ExportPipelineModel, + ExportProgress, + ExportQuality, + ExportRenderBackend, + ExportResult, + ExportSettings, + GifExportConfig, + GifFrameRate, + GifSizePreset, + VideoFrameData, +} from "./types"; +export { + GIF_FRAME_RATES, + GIF_SIZE_PRESETS, + isValidGifFrameRate, + isValidMp4FrameRate, + MP4_FRAME_RATES, + VALID_GIF_FRAME_RATES, +} from "./types"; +export { VideoFileDecoder } from "./videoDecoder"; +export { VideoExporter } from "./videoExporter"; diff --git a/src/lib/exporter/localMediaSource.test.ts b/src/lib/exporter/localMediaSource.test.ts new file mode 100644 index 000000000..73cbc9b8a --- /dev/null +++ b/src/lib/exporter/localMediaSource.test.ts @@ -0,0 +1,86 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { createFallbackDemuxerSource, resolveMediaElementSource } from "./localMediaSource"; + +const readLocalFile = vi.fn(); +const getLocalMediaUrl = vi.fn(async (filePath: string) => ({ + success: true, + url: `http://127.0.0.1:4321/video?path=${encodeURIComponent(filePath)}`, +})); + +describe("resolveMediaElementSource", () => { + beforeEach(() => { + vi.restoreAllMocks(); + readLocalFile.mockReset(); + getLocalMediaUrl.mockReset(); + getLocalMediaUrl.mockImplementation(async (filePath: string) => ({ + success: true, + url: `http://127.0.0.1:4321/video?path=${encodeURIComponent(filePath)}`, + })); + Object.assign(globalThis, { + window: { + electronAPI: { + readLocalFile, + getLocalMediaUrl, + }, + }, + }); + }); + + it("resolves file URLs through the local media server for media elements", async () => { + const result = await resolveMediaElementSource("file:///tmp/example.mp4"); + + expect(readLocalFile).not.toHaveBeenCalled(); + expect(getLocalMediaUrl).toHaveBeenCalledWith("/tmp/example.mp4"); + expect(result.src).toBe("http://127.0.0.1:4321/video?path=%2Ftmp%2Fexample.mp4"); + }); + + it("resolves absolute local paths through the local media server without copying them into blobs", async () => { + const result = await resolveMediaElementSource("/tmp/example.wav"); + + expect(readLocalFile).not.toHaveBeenCalled(); + expect(getLocalMediaUrl).toHaveBeenCalledWith("/tmp/example.wav"); + expect(result.src).toBe("http://127.0.0.1:4321/video?path=%2Ftmp%2Fexample.wav"); + }); + + it("preserves loopback media-server URLs instead of materializing them through IPC", async () => { + const result = await resolveMediaElementSource( + "http://127.0.0.1:43123/video?path=%2Ftmp%2Fexample%20clip.mp4", + ); + + expect(readLocalFile).not.toHaveBeenCalled(); + expect(getLocalMediaUrl).not.toHaveBeenCalled(); + expect(result.src).toBe("http://127.0.0.1:43123/video?path=%2Ftmp%2Fexample%20clip.mp4"); + }); + + it("leaves remote URLs untouched", async () => { + const result = await resolveMediaElementSource("https://example.com/video.mp4"); + + expect(result.src).toBe("https://example.com/video.mp4"); + expect(readLocalFile).not.toHaveBeenCalled(); + }); + + it("keeps local demuxer fallback on the range-streamed media URL", async () => { + const source = await createFallbackDemuxerSource("/tmp/large-recording.mp4"); + + expect(source).toBe("http://127.0.0.1:4321/video?path=%2Ftmp%2Flarge-recording.mp4"); + expect(readLocalFile).not.toHaveBeenCalled(); + }); + + it("retains the readable File fallback for remote media", async () => { + const fetchMock = vi.fn(async () => ({ + ok: true, + blob: async () => new Blob([new Uint8Array([1, 2, 3])], { type: "video/mp4" }), + })); + vi.stubGlobal("fetch", fetchMock); + + try { + const source = await createFallbackDemuxerSource("https://example.com/video.mp4"); + + expect(source).toBeInstanceOf(File); + expect(fetchMock).toHaveBeenCalledWith("https://example.com/video.mp4"); + } finally { + vi.unstubAllGlobals(); + } + }); +}); diff --git a/src/lib/exporter/localMediaSource.ts b/src/lib/exporter/localMediaSource.ts new file mode 100644 index 000000000..984d959c1 --- /dev/null +++ b/src/lib/exporter/localMediaSource.ts @@ -0,0 +1,154 @@ +import { fromFileUrl, toFileUrl } from "@/components/video-editor/projectPersistence"; + +const NOOP = () => undefined; +const REMOTE_MEDIA_URL_PATTERN = /^(https?:|blob:|data:)/i; +const LOOPBACK_MEDIA_HOSTS = new Set(["127.0.0.1", "localhost"]); +const BUNDLED_ASSET_PATH_PREFIXES = ["/wallpapers/", "/app-icons/"]; + +export function isAbsoluteLocalPath(resource: string) { + return ( + resource.startsWith("/") || + /^[A-Za-z]:[\\/]/.test(resource) || + /^\\\\[^\\]+\\[^\\]+/.test(resource) + ); +} + +function isBundledAssetPath(resource: string) { + return BUNDLED_ASSET_PATH_PREFIXES.some((prefix) => resource.startsWith(prefix)); +} + +function getLocalMediaServerPath(resource: string) { + if (!/^https?:\/\//i.test(resource)) { + return null; + } + + try { + const url = new URL(resource); + if (!LOOPBACK_MEDIA_HOSTS.has(url.hostname) || url.pathname !== "/video") { + return null; + } + + const mediaPath = url.searchParams.get("path"); + return mediaPath && mediaPath.trim().length > 0 ? mediaPath : null; + } catch { + return null; + } +} + +export function isLocalMediaServerUrl(resource: string) { + return getLocalMediaServerPath(resource) !== null; +} + +export function getLocalFilePath(resource: string) { + const localMediaServerPath = getLocalMediaServerPath(resource); + if (localMediaServerPath) { + return localMediaServerPath; + } + + if (/^file:\/\//i.test(resource)) { + return fromFileUrl(resource); + } + + if (isBundledAssetPath(resource)) { + return null; + } + + return isAbsoluteLocalPath(resource) ? resource : null; +} + +function isRemoteMediaResource(resource: string) { + return REMOTE_MEDIA_URL_PATTERN.test(resource) && !isLocalMediaServerUrl(resource); +} + +export function getNormalizedMediaResourceUrl(resource: string) { + const localFilePath = getLocalFilePath(resource); + if (!localFilePath) { + return resource; + } + + if (isLocalMediaServerUrl(resource)) { + return resource; + } + + return /^file:\/\//i.test(resource) ? resource : toFileUrl(localFilePath); +} + +function inferMimeType(filePath: string) { + const normalized = filePath.split("?")[0]?.toLowerCase() ?? filePath.toLowerCase(); + + if (normalized.endsWith(".mp4") || normalized.endsWith(".m4v")) return "video/mp4"; + if (normalized.endsWith(".mov")) return "video/quicktime"; + if (normalized.endsWith(".webm")) return "video/webm"; + if (normalized.endsWith(".mkv")) return "video/x-matroska"; + if (normalized.endsWith(".avi")) return "video/x-msvideo"; + if (normalized.endsWith(".mp3")) return "audio/mpeg"; + if (normalized.endsWith(".wav")) return "audio/wav"; + if (normalized.endsWith(".m4a")) return "audio/mp4"; + if (normalized.endsWith(".aac")) return "audio/aac"; + if (normalized.endsWith(".ogg")) return "audio/ogg"; + if (normalized.endsWith(".opus")) return "audio/ogg;codecs=opus"; + if (normalized.endsWith(".flac")) return "audio/flac"; + + return "application/octet-stream"; +} + +export async function resolveMediaResourceUrl(resource: string): Promise<string> { + const localFilePath = getLocalFilePath(resource); + if (!localFilePath) { + return resource; + } + + if (isLocalMediaServerUrl(resource)) { + return resource; + } + + if (typeof window !== "undefined" && window.electronAPI?.getLocalMediaUrl) { + try { + const result = await window.electronAPI.getLocalMediaUrl(localFilePath); + if (result.success) { + return result.url; + } + } catch { + // Fall through to a file URL when the local media server is unavailable. + } + } + + return /^file:\/\//i.test(resource) ? resource : toFileUrl(localFilePath); +} + +async function createReadableMediaResourceFile(resource: string): Promise<File> { + const filename = resource.split(/[\\/]/).pop()?.split("?")[0] || "media"; + const resourceUrl = await resolveMediaResourceUrl(resource); + const response = await fetch(resourceUrl); + if (!response.ok) { + throw new Error(`Failed to load media resource: ${response.status} ${response.statusText}`); + } + + const blob = await response.blob(); + return new File([blob], filename, { type: blob.type || inferMimeType(filename) }); +} + +export async function createFallbackDemuxerSource(resource: string): Promise<string | File> { + // Local media already has a random-access transport: WebDemuxer issues bounded + // byte-range requests against this URL. Converting it to a File would copy the + // complete recording through Electron IPC and make memory use scale with file size. + if (getLocalFilePath(resource)) { + return resolveMediaResourceUrl(resource); + } + + return createReadableMediaResourceFile(resource); +} + +export async function resolveMediaElementSource(resource: string): Promise<{ + src: string; + revoke: () => void; +}> { + if (!resource || isRemoteMediaResource(resource)) { + return { src: resource, revoke: NOOP }; + } + + return { + src: await resolveMediaResourceUrl(resource), + revoke: NOOP, + }; +} diff --git a/src/lib/exporter/mediaResource.test.ts b/src/lib/exporter/mediaResource.test.ts new file mode 100644 index 000000000..5d79c8a02 --- /dev/null +++ b/src/lib/exporter/mediaResource.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; + +import { getLocalFilePathFromResource, getResourceFileName } from "./mediaResource"; + +describe("getLocalFilePathFromResource", () => { + it("extracts the path from file URLs", () => { + expect(getLocalFilePathFromResource("file:///tmp/example%20video.mp4")).toBe( + "/tmp/example video.mp4", + ); + }); + + it("extracts the approved file path from loopback media server URLs", () => { + expect( + getLocalFilePathFromResource( + "http://127.0.0.1:4321/video?path=%2Ftmp%2Fexample%20video.mp4", + ), + ).toBe("/tmp/example video.mp4"); + }); + + it("does not treat arbitrary remote URLs as local files", () => { + expect(getLocalFilePathFromResource("https://example.com/video.mp4")).toBeNull(); + }); +}); + +describe("getResourceFileName", () => { + it("uses the source file name for loopback media server URLs", () => { + expect( + getResourceFileName( + "http://127.0.0.1:4321/video?path=%2Ftmp%2Fexample%20video.mp4", + "fallback.mp4", + ), + ).toBe("example video.mp4"); + }); +}); diff --git a/src/lib/exporter/mediaResource.ts b/src/lib/exporter/mediaResource.ts new file mode 100644 index 000000000..2f689dca1 --- /dev/null +++ b/src/lib/exporter/mediaResource.ts @@ -0,0 +1,111 @@ +const LOOPBACK_MEDIA_SERVER_HOSTS = new Set(["127.0.0.1", "localhost", "::1", "[::1]"]); + +export function isAbsoluteLocalPath(resource: string): boolean { + return ( + resource.startsWith("/") || + /^[A-Za-z]:[\\/]/.test(resource) || + /^\\\\[^\\]+\\[^\\]+/.test(resource) + ); +} + +function fromFileUrl(resource: string): string { + try { + const url = new URL(resource); + const pathname = decodeURIComponent(url.pathname); + + if (url.host && url.host !== "localhost") { + const uncPath = `//${url.host}${pathname.startsWith("/") ? pathname : `/${pathname}`}`; + return uncPath.replace(/\//g, "\\"); + } + + if (/^\/[A-Za-z]:/.test(pathname)) { + return pathname.slice(1); + } + + return pathname; + } catch { + const rawFallbackPath = resource.replace(/^file:\/\//i, ""); + let fallbackPath = rawFallbackPath; + try { + fallbackPath = decodeURIComponent(rawFallbackPath); + } catch { + // Keep raw best-effort path if percent decoding fails. + } + return fallbackPath.replace(/^\/([A-Za-z]:)/, "$1"); + } +} + +function getLoopbackMediaServerPath(resource: string): string | null { + try { + const url = new URL(resource); + if (url.pathname !== "/video") { + return null; + } + + if (!/^https?:$/i.test(url.protocol)) { + return null; + } + + if (!LOOPBACK_MEDIA_SERVER_HOSTS.has(url.hostname.toLowerCase())) { + return null; + } + + const pathParam = url.searchParams.get("path"); + if (!pathParam) { + return null; + } + + if (/^file:\/\//i.test(pathParam)) { + return fromFileUrl(pathParam); + } + + return isAbsoluteLocalPath(pathParam) ? pathParam : null; + } catch { + return null; + } +} + +function getPathBaseName(filePath: string): string { + const normalized = filePath.replace(/\\/g, "/"); + const segments = normalized.split("/").filter(Boolean); + return segments[segments.length - 1] ?? ""; +} + +export function getLocalFilePathFromResource(resource: string): string | null { + if (!resource) { + return null; + } + + if (/^file:\/\//i.test(resource)) { + return fromFileUrl(resource); + } + + const mediaServerPath = getLoopbackMediaServerPath(resource); + if (mediaServerPath) { + return mediaServerPath; + } + + return isAbsoluteLocalPath(resource) ? resource : null; +} + +export function getResourceFileName(resource: string, fallback: string): string { + const localFilePath = getLocalFilePathFromResource(resource); + if (localFilePath) { + const fileName = getPathBaseName(localFilePath); + if (fileName) { + return fileName; + } + } + + try { + const url = new URL(resource); + const fileName = getPathBaseName(decodeURIComponent(url.pathname)); + if (fileName) { + return fileName; + } + } catch { + // Ignore parse errors and fall back to the provided default. + } + + return fallback; +} diff --git a/src/lib/exporter/modernFrameRenderer.test.ts b/src/lib/exporter/modernFrameRenderer.test.ts new file mode 100644 index 000000000..dd820c81a --- /dev/null +++ b/src/lib/exporter/modernFrameRenderer.test.ts @@ -0,0 +1,869 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { DEFAULT_WEBCAM_OVERLAY } from "../../components/video-editor/types"; + +const { + cancelForwardFrameSourceMock, + destroyForwardFrameSourceMock, + getForwardFrameAtTimeMock, + initializeForwardFrameSourceMock, + pixiApplicationInstancesMock, + pixiInitializationErrorsMock, + resolveMediaElementSourceMock, +} = vi.hoisted(() => ({ + cancelForwardFrameSourceMock: vi.fn(), + destroyForwardFrameSourceMock: vi.fn(async () => undefined), + getForwardFrameAtTimeMock: vi.fn(async () => null), + initializeForwardFrameSourceMock: vi.fn(async () => undefined), + pixiApplicationInstancesMock: [] as Array<{ + destroy: ReturnType<typeof vi.fn>; + init: ReturnType<typeof vi.fn>; + renderer: { destroy: ReturnType<typeof vi.fn> }; + stage: { destroy: ReturnType<typeof vi.fn> }; + }>, + pixiInitializationErrorsMock: [] as Array<Error | undefined>, + resolveMediaElementSourceMock: vi.fn(async () => ({ + src: "blob:background", + revoke: vi.fn(), + })), +})); + +vi.mock("pixi.js", () => ({ + Application: class { + destroy = vi.fn(() => { + throw new TypeError("this._cancelResize is not a function"); + }); + init = vi.fn(async () => { + const error = pixiInitializationErrorsMock.shift(); + if (error) throw error; + }); + renderer = { destroy: vi.fn() }; + stage = { destroy: vi.fn() }; + + constructor() { + pixiApplicationInstancesMock.push(this); + } + }, + BlurFilter: class {}, + Container: class { + visible = true; + addChild = vi.fn(); + addChildAt = vi.fn(); + removeChildren = vi.fn(); + }, + Graphics: class {}, + Sprite: class { + visible = true; + x = 0; + y = 0; + alpha = 1; + scale = { x: 1, y: 1, set: vi.fn() }; + anchor = { x: 0.5, y: 0.5, set: vi.fn() }; + position = { set: vi.fn() }; + texture: { destroy: ReturnType<typeof vi.fn> }; + + constructor(texture = { destroy: vi.fn() }) { + this.texture = texture; + } + }, + Texture: { + from: vi.fn(() => ({ source: { update: vi.fn() }, destroy: vi.fn() })), + }, +})); + +vi.mock("pixi-filters/motion-blur", () => ({ + MotionBlurFilter: class {}, +})); + +vi.mock("@/lib/assetPath", () => ({ + getAssetPath: vi.fn(async (value: string) => value), + getExportableVideoUrl: vi.fn(async (value: string) => value), + getRenderableAssetUrl: vi.fn((value: string) => value), +})); + +vi.mock("@/components/video-editor/videoPlayback/zoomRegionUtils", () => ({ + findDominantRegion: vi.fn(() => ({ + region: null, + strength: 0, + blendedScale: 1, + transition: null, + })), +})); + +vi.mock("@/components/video-editor/videoPlayback/zoomTransform", () => ({ + applyZoomTransform: vi.fn(), + computeFocusFromTransform: vi.fn(() => ({ cx: 0.5, cy: 0.5 })), + computeZoomTransform: vi.fn(() => ({ scale: 1, x: 0, y: 0 })), + createMotionBlurState: vi.fn(() => ({})), +})); + +vi.mock("@/components/video-editor/videoPlayback/cursorRenderer", () => ({ + PixiCursorOverlay: class { + container = {}; + update = vi.fn(); + destroy = vi.fn(); + }, + DEFAULT_CURSOR_CONFIG: { + dotRadius: 28, + smoothingFactor: 0.18, + motionBlur: 0, + clickBounce: 1, + sway: 0, + }, + preloadCursorAssets: vi.fn(async () => undefined), +})); + +vi.mock("./forwardFrameSource", () => ({ + ForwardFrameSource: class { + cancel = cancelForwardFrameSourceMock; + destroy = destroyForwardFrameSourceMock; + getFrameAtTime = getForwardFrameAtTimeMock; + initialize = initializeForwardFrameSourceMock; + }, +})); + +vi.mock("./localMediaSource", () => ({ + resolveMediaElementSource: resolveMediaElementSourceMock, +})); + +vi.mock("./annotationRenderer", () => ({ + preloadAnnotationAssets: vi.fn(async () => ({ imageCache: new Map() })), + renderAnnotationToCanvas: vi.fn(async () => null), + renderAnnotations: vi.fn(async () => undefined), +})); + +import { renderAnnotations } from "./annotationRenderer"; +import { FrameRenderer } from "./modernFrameRenderer"; + +function createMockContext() { + return { + clearRect: vi.fn(), + drawImage: vi.fn(), + fillRect: vi.fn(), + save: vi.fn(), + restore: vi.fn(), + getImageData: vi.fn(() => ({ data: new Uint8ClampedArray(0) })), + globalAlpha: 1, + imageSmoothingEnabled: true, + imageSmoothingQuality: "high", + } as unknown as CanvasRenderingContext2D; +} + +function createMockCanvas() { + const context = createMockContext(); + return { + width: 0, + height: 0, + getContext: vi.fn(() => context), + context, + }; +} + +function createRenderer() { + return new FrameRenderer({ + width: 1920, + height: 1080, + nativeReadbackMode: "pixels", + wallpaper: "#000000", + zoomRegions: [], + showShadow: false, + shadowIntensity: 0, + backgroundBlur: 0, + cropRegion: { x: 0, y: 0, width: 1, height: 1 }, + webcam: { + ...DEFAULT_WEBCAM_OVERLAY, + enabled: false, + }, + videoWidth: 1920, + videoHeight: 1080, + annotationRegions: [ + { + id: "blur-1", + startMs: 0, + endMs: 1000, + type: "blur", + content: "", + position: { x: 10, y: 10 }, + size: { width: 20, height: 20 }, + style: { + color: "#ffffff", + backgroundColor: "transparent", + fontSize: 24, + fontFamily: "Inter", + fontWeight: "normal", + fontStyle: "normal", + textDecoration: "none", + textAlign: "center", + borderRadius: 0, + }, + zIndex: 1, + blurIntensity: 20, + }, + ], + }); +} + +describe("ModernFrameRenderer Pixi lifecycle", () => { + it("continues to the next backend when failed-init cleanup would throw", async () => { + pixiApplicationInstancesMock.length = 0; + pixiInitializationErrorsMock.length = 0; + pixiInitializationErrorsMock.push(new Error("WebGPU initialization failed"), undefined); + vi.stubGlobal("navigator", { gpu: {} }); + + try { + const renderer = createRenderer() as unknown as { + config: { preferredRenderBackend?: "webgl" | "webgpu" }; + createPixiApplication: ( + canvas: HTMLCanvasElement, + ) => Promise<{ backend: "webgl" | "webgpu" }>; + }; + renderer.config.preferredRenderBackend = "webgpu"; + + await expect( + renderer.createPixiApplication({} as HTMLCanvasElement), + ).resolves.toMatchObject({ + backend: "webgl", + }); + + expect(pixiApplicationInstancesMock).toHaveLength(2); + expect(pixiApplicationInstancesMock[0].destroy).not.toHaveBeenCalled(); + expect(pixiApplicationInstancesMock[0].stage.destroy).toHaveBeenCalledTimes(1); + expect(pixiApplicationInstancesMock[0].renderer.destroy).toHaveBeenCalledTimes(1); + } finally { + vi.unstubAllGlobals(); + } + }); +}); + +describe("ModernFrameRenderer blur export path", () => { + beforeEach(() => { + Object.assign(globalThis, { + window: globalThis, + requestAnimationFrame: (callback: FrameRequestCallback) => { + callback(0); + return 1; + }, + cancelAnimationFrame: vi.fn(), + HTMLMediaElement: { + HAVE_CURRENT_DATA: 2, + }, + document: { + createElement: vi.fn((tag: string) => { + if (tag === "video") { + return { + duration: 5, + readyState: 2, + videoWidth: 1280, + videoHeight: 720, + muted: true, + loop: true, + playsInline: true, + preload: "auto", + src: "", + currentTime: 0, + load: vi.fn(), + pause: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }; + } + if (tag !== "canvas") { + throw new Error(`Unexpected element requested in test: ${tag}`); + } + + return createMockCanvas(); + }), + }, + }); + }); + + it("uses a composited canvas and disables pixel readback when blur post-processing is active", async () => { + const renderer = createRenderer() as any; + const sourceCanvas = createMockCanvas(); + + renderer.app = { canvas: sourceCanvas }; + renderer.annotationScaleFactor = 1; + renderer.annotationAssets = { imageCache: new Map() }; + + await renderer.composeBlurAnnotationFrame(500); + + expect(renderAnnotations).toHaveBeenCalledTimes(1); + expect(renderer.getCanvas()).not.toBe(sourceCanvas); + expect(renderer.capturePixelsForNativeExport()).not.toBeNull(); + }); + + it("uses the sampled scene transform for blur annotations during temporal blur", async () => { + const renderer = createRenderer() as any; + renderer.config.zoomTemporalMotionBlur = 1; + renderer.config.zoomMotionBlurSampleCount = 3; + renderer.config.zoomMotionBlurShutterFraction = 0.5; + renderer.app = { canvas: createMockCanvas() }; + renderer.annotationScaleFactor = 1; + renderer.annotationAssets = { imageCache: new Map() }; + renderer.updateCaptionLayer = vi.fn(); + renderer.renderSceneSample = vi.fn(async (sampleTimestamp: number) => ({ + timeMs: sampleTimestamp / 1000, + cursorTimeMs: sampleTimestamp / 1000, + backgroundTimelineTimeMs: sampleTimestamp / 1000, + sceneTransform: { scale: 1.75, x: 120, y: -48 }, + zoom: { scale: 1, focusX: 0.5, focusY: 0.5, progress: 0 }, + })); + + await renderer.renderTemporalMotionBlurFrame(1_000_000, 1_000_000, 1_000_000, 33_333, { + stageSize: { width: 1920, height: 1080 }, + videoSize: { width: 1920, height: 1080 }, + baseScale: 1, + baseOffset: { x: 0, y: 0 }, + maskRect: { + x: 0, + y: 0, + width: 1920, + height: 1080, + sourceCrop: { x: 0, y: 0, width: 1, height: 1 }, + }, + }); + + expect(renderAnnotations).toHaveBeenCalled(); + expect(vi.mocked(renderAnnotations).mock.lastCall?.[7]).toEqual({ + scale: 1.75, + x: 120, + y: -48, + }); + }); + + it("prefers decoder-backed sync for video wallpapers during export", async () => { + vi.clearAllMocks(); + const renderer = new FrameRenderer({ + width: 1920, + height: 1080, + nativeReadbackMode: "pixels", + wallpaper: "/wallpapers/wispysky.mp4", + zoomRegions: [], + showShadow: false, + shadowIntensity: 0, + backgroundBlur: 0, + cropRegion: { x: 0, y: 0, width: 1, height: 1 }, + webcam: { + ...DEFAULT_WEBCAM_OVERLAY, + enabled: false, + }, + videoWidth: 1920, + videoHeight: 1080, + }) as any; + + await renderer.setupBackground(); + + expect(initializeForwardFrameSourceMock).toHaveBeenCalledWith("wallpapers/wispysky.mp4"); + expect(resolveMediaElementSourceMock).not.toHaveBeenCalled(); + expect(renderer.backgroundForwardFrameSource).toBeTruthy(); + expect(renderer.backgroundVideoElement).toBeNull(); + }); + + it("falls back to media-element sync when video wallpaper packet streaming fails", async () => { + vi.clearAllMocks(); + initializeForwardFrameSourceMock.mockResolvedValue(undefined); + getForwardFrameAtTimeMock.mockRejectedValueOnce( + new Error("readAVPacket pipeline failed: Failed after 3 attempts"), + ); + resolveMediaElementSourceMock.mockResolvedValueOnce({ + src: "blob:background-video", + revoke: vi.fn(), + }); + const renderer = new FrameRenderer({ + width: 1920, + height: 1080, + nativeReadbackMode: "pixels", + wallpaper: "/wallpapers/wispysky.mp4", + zoomRegions: [], + showShadow: false, + shadowIntensity: 0, + backgroundBlur: 0, + cropRegion: { x: 0, y: 0, width: 1, height: 1 }, + webcam: { + ...DEFAULT_WEBCAM_OVERLAY, + enabled: false, + }, + videoWidth: 1920, + videoHeight: 1080, + }) as any; + + await renderer.setupBackground(); + await expect(renderer.syncBackgroundFrame(1)).resolves.toBeUndefined(); + + expect(cancelForwardFrameSourceMock).toHaveBeenCalled(); + expect(destroyForwardFrameSourceMock).toHaveBeenCalled(); + expect(resolveMediaElementSourceMock).toHaveBeenCalledWith("wallpapers/wispysky.mp4"); + expect(renderer.backgroundForwardFrameSource).toBeNull(); + expect(renderer.backgroundVideoElement).toBeTruthy(); + }); +}); + +describe("ModernFrameRenderer webcam frame cache", () => { + it("stages webcam video frames on WebGPU instead of using retained frame uploads", () => { + const renderer = createRenderer() as any; + renderer.rendererBackend = "webgpu"; + + const frame = { + displayWidth: 320, + displayHeight: 180, + timestamp: 0, + } as VideoFrame; + + const result = renderer.stageVideoFrameForTexture(frame, "webcam", 640, 360); + + expect(result).toBe(renderer.webcamVideoFrameStagingCanvas); + expect(renderer.webcamVideoFrameStagingCtx.drawImage).toHaveBeenCalledWith( + frame, + 0, + 0, + 320, + 180, + ); + }); + + it("uses staging canvas instead of recursing when WebGPU frame retention fails", () => { + const renderer = createRenderer() as any; + const originalVideoFrame = (globalThis as any).VideoFrame; + + (globalThis as any).VideoFrame = class { + constructor() { + throw new Error("retain failed"); + } + }; + + try { + renderer.rendererBackend = "webgpu"; + const frame = { + displayWidth: 320, + displayHeight: 180, + timestamp: 0, + } as VideoFrame; + + const result = renderer.stageVideoFrameForTexture(frame, "webcam", 640, 360); + + expect(result).toBe(renderer.webcamVideoFrameStagingCanvas); + expect(renderer.webcamVideoFrameStagingCtx.drawImage).toHaveBeenCalledWith( + frame, + 0, + 0, + 320, + 180, + ); + } finally { + if (originalVideoFrame === undefined) { + delete (globalThis as any).VideoFrame; + } else { + (globalThis as any).VideoFrame = originalVideoFrame; + } + } + }); + + it("renders non-video webcam frames directly for default crop regions", () => { + const renderer = createRenderer() as any; + const liveSource = {}; + + renderer.config.webcam.cropRegion = { x: 0, y: 0, width: 1, height: 1 }; + const renderableSource = renderer.resolveRenderableWebcamSource( + liveSource, + 1280, + 720, + true, + ); + + expect(renderableSource).toMatchObject({ + source: liveSource, + width: 1280, + height: 720, + mode: "live", + }); + expect(renderer.webcamFrameCacheCanvas).toBeNull(); + }); + + it("uses a cache-backed source for cropped webcam regions", () => { + const renderer = createRenderer() as any; + const liveSource = {}; + + renderer.config.webcam.cropRegion = { + x: 0.25, + y: 0, + width: 0.5, + height: 1, + }; + const renderableSource = renderer.resolveRenderableWebcamSource( + liveSource, + 1280, + 720, + true, + ); + + expect(renderableSource?.source).toBe(renderer.webcamFrameCacheCanvas); + expect(renderableSource).toMatchObject({ + width: 640, + height: 720, + mode: "cached", + }); + expect(renderer.webcamFrameCacheCtx.drawImage).toHaveBeenCalledWith( + liveSource, + 320, + 0, + 640, + 720, + 0, + 0, + 640, + 720, + ); + }); +}); + +describe("ModernFrameRenderer webcam export fallback", () => { + beforeEach(() => { + vi.clearAllMocks(); + initializeForwardFrameSourceMock.mockResolvedValue(undefined); + getForwardFrameAtTimeMock.mockResolvedValue(null); + resolveMediaElementSourceMock.mockResolvedValue({ + src: "blob:webcam", + revoke: vi.fn(), + }); + + Object.assign(globalThis, { + window: { + clearTimeout, + setTimeout, + }, + HTMLMediaElement: { + HAVE_CURRENT_DATA: 2, + }, + cancelAnimationFrame: vi.fn(), + requestAnimationFrame: vi.fn((callback: FrameRequestCallback) => { + callback(0); + return 1; + }), + document: { + createElement: vi.fn((tag: string) => { + if (tag === "video") { + return { + duration: 5, + readyState: 2, + videoWidth: 640, + videoHeight: 360, + muted: true, + loop: true, + playsInline: true, + preload: "auto", + src: "", + currentTime: 0, + seeking: false, + load: vi.fn(), + pause: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }; + } + if (tag !== "canvas") { + throw new Error(`Unexpected element requested in test: ${tag}`); + } + + return createMockCanvas(); + }), + }, + }); + }); + + it("falls back to media-element webcam sync when packet streaming fails after initialize", async () => { + getForwardFrameAtTimeMock.mockRejectedValueOnce( + new Error("readAVPacket pipeline failed: Failed after 3 attempts"), + ); + const renderer = createRenderer() as any; + renderer.config.webcam = { + ...DEFAULT_WEBCAM_OVERLAY, + enabled: true, + }; + renderer.config.webcamUrl = "file:///tmp/webcam.webm"; + + await renderer.setupWebcamSource(); + await expect(renderer.syncWebcamFrame(1)).resolves.toBeUndefined(); + + expect(cancelForwardFrameSourceMock).toHaveBeenCalled(); + expect(destroyForwardFrameSourceMock).toHaveBeenCalled(); + expect(resolveMediaElementSourceMock).toHaveBeenCalledWith("file:///tmp/webcam.webm"); + expect(renderer.webcamForwardFrameSource).toBeNull(); + expect(renderer.webcamVideoElement).toBeTruthy(); + }); + + it("tears down the media-element fallback when readiness times out", async () => { + vi.useFakeTimers(); + const originalCreateElement = (globalThis as any).document.createElement; + const revoke = vi.fn(); + getForwardFrameAtTimeMock.mockRejectedValueOnce( + new Error("readAVPacket pipeline failed: Failed after 3 attempts"), + ); + resolveMediaElementSourceMock.mockResolvedValueOnce({ + src: "blob:webcam-timeout", + revoke, + }); + Object.assign((globalThis as any).window, { + clearTimeout, + setTimeout, + }); + + (globalThis as any).document.createElement = vi.fn((tag: string) => { + if (tag === "video") { + return { + duration: Number.NaN, + readyState: 0, + videoWidth: 0, + videoHeight: 0, + muted: true, + loop: true, + playsInline: true, + preload: "auto", + src: "", + currentTime: 0, + seeking: false, + load: vi.fn(), + pause: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }; + } + if (tag !== "canvas") { + throw new Error(`Unexpected element requested in test: ${tag}`); + } + + return createMockCanvas(); + }); + + try { + const renderer = createRenderer() as any; + renderer.config.webcam = { + ...DEFAULT_WEBCAM_OVERLAY, + enabled: true, + }; + renderer.config.webcamUrl = "file:///tmp/webcam.webm"; + + await renderer.setupWebcamSource(); + const syncPromise = renderer.syncWebcamFrame(1); + + await vi.advanceTimersByTimeAsync(5_001); + await expect(syncPromise).resolves.toBeUndefined(); + + expect(cancelForwardFrameSourceMock).toHaveBeenCalled(); + expect(destroyForwardFrameSourceMock).toHaveBeenCalled(); + expect(revoke).toHaveBeenCalled(); + expect(renderer.webcamForwardFrameSource).toBeNull(); + expect(renderer.webcamVideoElement).toBeNull(); + } finally { + (globalThis as any).document.createElement = originalCreateElement; + vi.useRealTimers(); + } + }); + + it("keeps the webcam live when sync uses an offset timeline", () => { + const renderer = createRenderer() as any; + renderer.config.webcam = { + ...DEFAULT_WEBCAM_OVERLAY, + enabled: true, + timeOffsetMs: 500, + }; + renderer.currentVideoTime = 10; + renderer.lastSyncedWebcamTime = 9.5; + renderer.webcamVideoElement = { + readyState: 2, + seeking: false, + videoWidth: 640, + videoHeight: 360, + duration: Number.NaN, + }; + renderer.webcamRootContainer = { + visible: false, + position: { set: vi.fn() }, + }; + renderer.webcamContainer = { + addChildAt: vi.fn(), + }; + renderer.webcamMaskGraphics = { + clear: vi.fn(), + moveTo: vi.fn(), + lineTo: vi.fn(), + closePath: vi.fn(), + fill: vi.fn(), + }; + renderer.webcamShadowLayers = []; + renderer.animationState = { + appliedScale: 1, + }; + + renderer.updateWebcamOverlay(); + + expect(renderer.webcamRootContainer.visible).toBe(true); + expect(renderer.webcamSprite).toBeTruthy(); + }); + + it("keeps the webcam live when the media element time is current but lastSyncedWebcamTime is stale", () => { + const renderer = createRenderer() as any; + renderer.config.webcam = { + ...DEFAULT_WEBCAM_OVERLAY, + enabled: true, + timeOffsetMs: 500, + }; + renderer.currentVideoTime = 10; + renderer.lastSyncedWebcamTime = 8; + renderer.webcamVideoElement = { + currentTime: 9.5, + readyState: 2, + seeking: false, + videoWidth: 640, + videoHeight: 360, + duration: Number.NaN, + }; + renderer.webcamRootContainer = { + visible: false, + position: { set: vi.fn() }, + }; + renderer.webcamContainer = { + addChildAt: vi.fn(), + }; + renderer.webcamMaskGraphics = { + clear: vi.fn(), + moveTo: vi.fn(), + lineTo: vi.fn(), + closePath: vi.fn(), + fill: vi.fn(), + }; + renderer.webcamShadowLayers = []; + renderer.animationState = { + appliedScale: 1, + }; + + renderer.updateWebcamOverlay(); + + expect(renderer.webcamRootContainer.visible).toBe(true); + expect(renderer.webcamSprite).toBeTruthy(); + }); + + it("snapshots media-element webcam frames into the cache before rendering", () => { + const renderer = createRenderer() as any; + renderer.config.webcam = { + ...DEFAULT_WEBCAM_OVERLAY, + enabled: true, + cropRegion: { x: 0, y: 0, width: 1, height: 1 }, + }; + renderer.currentVideoTime = 4; + const previousHtmlVideoElement = ( + globalThis as typeof globalThis & { HTMLVideoElement?: unknown } + ).HTMLVideoElement; + class MockHtmlVideoElement { + currentTime = 4; + readyState = 2; + seeking = false; + videoWidth = 640; + videoHeight = 360; + duration = Number.NaN; + } + Object.assign(globalThis, { + HTMLVideoElement: MockHtmlVideoElement, + }); + + const webcamVideoElement = new MockHtmlVideoElement(); + + try { + const renderableSource = renderer.resolveRenderableWebcamSource( + webcamVideoElement, + 640, + 360, + true, + ); + + expect(renderableSource?.source).toBe(renderer.webcamFrameCacheCanvas); + expect(renderer.webcamFrameCacheCtx.drawImage).toHaveBeenCalledWith( + webcamVideoElement, + 0, + 0, + 640, + 360, + 0, + 0, + 640, + 360, + ); + } finally { + Object.assign(globalThis, { + HTMLVideoElement: previousHtmlVideoElement, + }); + } + }); + + it("renders decoder-backed webcam frames directly for the default crop region", () => { + const renderer = createRenderer() as any; + renderer.config.webcam = { + ...DEFAULT_WEBCAM_OVERLAY, + enabled: true, + cropRegion: { x: 0, y: 0, width: 1, height: 1 }, + }; + + const previousVideoFrame = (globalThis as typeof globalThis & { VideoFrame?: unknown }) + .VideoFrame; + class MockVideoFrame {} + Object.assign(globalThis, { + VideoFrame: MockVideoFrame, + }); + + try { + const webcamFrame = new MockVideoFrame(); + const renderableSource = renderer.resolveRenderableWebcamSource( + webcamFrame, + 640, + 360, + true, + ); + + expect(renderableSource?.source).toBe(webcamFrame); + expect(renderer.webcamFrameCacheCanvas).toBeNull(); + } finally { + Object.assign(globalThis, { + VideoFrame: previousVideoFrame, + }); + } + }); +}); + +describe("ModernFrameRenderer temporal webcam sync", () => { + it("pins webcam sync to the frame center during temporal blur sampling", async () => { + const renderer = createRenderer() as any; + renderer.config.annotationRegions = []; + renderer.config.zoomTemporalMotionBlur = 1; + renderer.config.zoomMotionBlurSampleCount = 3; + renderer.config.zoomMotionBlurShutterFraction = 0.5; + renderer.app = { canvas: createMockCanvas() }; + renderer.updateCaptionLayer = vi.fn(); + renderer.renderSceneSample = vi.fn(async (sampleTimestamp: number) => ({ + timeMs: sampleTimestamp / 1000, + cursorTimeMs: sampleTimestamp / 1000, + backgroundTimelineTimeMs: sampleTimestamp / 1000, + sceneTransform: { scale: 1, x: 0, y: 0 }, + zoom: { scale: 1, focusX: 0.5, focusY: 0.5, progress: 0 }, + })); + + await renderer.renderTemporalMotionBlurFrame(1_000_000, 1_000_000, 1_000_000, 33_333, { + stageSize: { width: 1920, height: 1080 }, + videoSize: { width: 1920, height: 1080 }, + baseScale: 1, + baseOffset: { x: 0, y: 0 }, + maskRect: { + x: 0, + y: 0, + width: 1920, + height: 1080, + sourceCrop: { x: 0, y: 0, width: 1, height: 1 }, + }, + }); + + expect(renderer.renderSceneSample).toHaveBeenCalledTimes(3); + expect(renderer.renderSceneSample.mock.calls.map((call: unknown[]) => call[6])).toEqual([ + 1, 1, 1, + ]); + expect( + new Set(renderer.renderSceneSample.mock.calls.map((call: unknown[]) => call[0])).size, + ).toBeGreaterThan(1); + }); +}); diff --git a/src/lib/exporter/modernFrameRenderer.ts b/src/lib/exporter/modernFrameRenderer.ts new file mode 100644 index 000000000..fa0778aee --- /dev/null +++ b/src/lib/exporter/modernFrameRenderer.ts @@ -0,0 +1,3619 @@ +import { Application, BlurFilter, Container, Graphics, Rectangle, Sprite, Texture } from "pixi.js"; +import { MotionBlurFilter } from "pixi-filters/motion-blur"; +import { ZoomBlurFilter } from "pixi-filters/zoom-blur"; +import { buildActiveCaptionLayout } from "@/components/video-editor/captionLayout"; +import { + CAPTION_FONT_WEIGHT, + CAPTION_LINE_HEIGHT, + getCaptionPadding, + getCaptionScaledFontSize, + getCaptionScaledRadius, + getCaptionTextMaxWidth, + getCaptionWordVisualState, +} from "@/components/video-editor/captionStyle"; +import type { + AnnotationRegion, + AutoCaptionSettings, + CaptionCue, + CropRegion, + CursorClickEffectStyle, + CursorStyle, + CursorTelemetryPoint, + Padding, + SpeedRegion, + WebcamOverlaySettings, + ZoomMotionBlurTuning, + ZoomRegion, + ZoomTransitionEasing, +} from "@/components/video-editor/types"; +import { + DEFAULT_WEBCAM_ROUNDNESS, + getDefaultCaptionFontFamily, + ZOOM_DEPTH_SCALES, +} from "@/components/video-editor/types"; +import { DEFAULT_FOCUS } from "@/components/video-editor/videoPlayback/constants"; +import { + type CursorFollowCameraState, + computeCursorFollowFocus, + createCursorFollowCameraState, + SNAP_TO_EDGES_RATIO_AUTO, +} from "@/components/video-editor/videoPlayback/cursorFollowCamera"; +import { + DEFAULT_CURSOR_CONFIG, + PixiCursorOverlay, + preloadCursorAssets, +} from "@/components/video-editor/videoPlayback/cursorRenderer"; +import { + computePaddedLayout, + scalePreviewBorderRadius, +} from "@/components/video-editor/videoPlayback/layoutUtils"; +import { + createSpringState, + getZoomSpringConfig, + resetSpringState, + type SpringState, + stepSpringValue, +} from "@/components/video-editor/videoPlayback/motionSmoothing"; +import { getWebcamMediaTargetTimeSeconds } from "@/components/video-editor/videoPlayback/webcamSync"; +import { findDominantRegion } from "@/components/video-editor/videoPlayback/zoomRegionUtils"; +import { + applyZoomTransform, + computeZoomTransform, + createMotionBlurState, + type MotionBlurState, +} from "@/components/video-editor/videoPlayback/zoomTransform"; +import { + getCropMatchedWebcamHeightPercent, + getWebcamCornerRadiusPx, + getWebcamCropSourceRect, + getWebcamOverlayDimensionsPx, + getWebcamOverlayPosition, + isWebcamCropRegionDefault, + scaleWebcamOverlayPixels, +} from "@/components/video-editor/webcamOverlay"; +import { getAssetPath, getExportableVideoUrl, getRenderableAssetUrl } from "@/lib/assetPath"; +import { drawSquircleOnCanvas, drawSquircleOnGraphics } from "@/lib/geometry/squircle"; +import { + clampMediaTimeToDuration, + getEffectiveVideoStreamDurationSeconds, +} from "@/lib/mediaTiming"; +import { + destroyPixiApplication, + initializePixiApplicationWithTimeout, +} from "@/lib/pixiApplicationLifecycle"; +import { isVideoWallpaperSource } from "@/lib/wallpapers"; +import { + type AnnotationRenderAssets, + preloadAnnotationAssets, + renderAnnotations, + renderAnnotationToCanvas, +} from "./annotationRenderer"; +import { ForwardFrameSource } from "./forwardFrameSource"; +import { resolveMediaElementSource } from "./localMediaSource"; +import { + getShadowFilterPadding, + getWebcamShadowStrength, + VIDEO_SHADOW_LAYER_PROFILES, + WEBCAM_SHADOW_LAYER_PROFILES, +} from "./shadowProfile"; +import { buildTemporalSamplePlanUs, getTemporalMotionBlurConfig } from "./temporalMotionBlur"; + +const TEMPORAL_ZOOM_MOTION_BLUR_ENABLED = false; + +import type { ExportRenderBackend } from "./types"; + +interface FrameRenderConfig { + width: number; + height: number; + preferredRenderBackend?: ExportRenderBackend; + wallpaper: string; + zoomRegions: ZoomRegion[]; + showShadow: boolean; + shadowIntensity: number; + backgroundBlur: number; + zoomMotionBlur?: number; + zoomMotionBlurTuning?: ZoomMotionBlurTuning; + zoomTemporalMotionBlur?: number; + zoomMotionBlurSampleCount?: number | null; + zoomMotionBlurShutterFraction?: number | null; + connectZooms?: boolean; + zoomInDurationMs?: number; + zoomInOverlapMs?: number; + zoomOutDurationMs?: number; + connectedZoomGapMs?: number; + connectedZoomDurationMs?: number; + zoomInEasing?: ZoomTransitionEasing; + zoomOutEasing?: ZoomTransitionEasing; + connectedZoomEasing?: ZoomTransitionEasing; + borderRadius?: number; + padding?: Padding | number; + cropRegion: CropRegion; + webcam?: WebcamOverlaySettings; + webcamUrl?: string | null; + videoWidth: number; + videoHeight: number; + annotationRegions?: AnnotationRegion[]; + autoCaptions?: CaptionCue[]; + autoCaptionSettings?: AutoCaptionSettings; + speedRegions?: SpeedRegion[]; + previewWidth?: number; + previewHeight?: number; + cursorTelemetry?: CursorTelemetryPoint[]; + showCursor?: boolean; + cursorStyle?: CursorStyle; + cursorSize?: number; + cursorSmoothing?: number; + cursorSpringStiffnessMultiplier?: number; + cursorSpringDampingMultiplier?: number; + cursorSpringMassMultiplier?: number; + cameraSpringStiffnessMultiplier?: number; + cameraSpringDampingMultiplier?: number; + cameraSpringMassMultiplier?: number; + cursorMotionBlur?: number; + cursorClickEffect?: CursorClickEffectStyle; + cursorClickEffectColor?: string; + cursorClickEffectScale?: number; + cursorClickEffectOpacity?: number; + cursorClickEffectDurationMs?: number; + cursorClickBounce?: number; + cursorClickBounceDuration?: number; + cursorSway?: number; + zoomSmoothness?: number; + zoomClassicMode?: boolean; + nativeReadbackMode?: "pixels" | "canvas"; +} + +interface AnimationState { + scale: number; + appliedScale: number; + focusX: number; + focusY: number; + progress: number; + x: number; + y: number; +} + +interface LayoutCache { + stageSize: { width: number; height: number }; + videoSize: { width: number; height: number }; + baseScale: number; + baseOffset: { x: number; y: number }; + maskRect: { + x: number; + y: number; + width: number; + height: number; + sourceCrop: CropRegion; + }; +} + +interface MutableVideoTextureSource { + resource: CanvasImageSource | VideoFrame; + update: () => void; +} + +interface ShadowLayer { + container: Container; + sprite: Sprite | null; + canvas: HTMLCanvasElement | null; + context: CanvasRenderingContext2D | null; + textureSource: MutableVideoTextureSource | null; + offsetScale: number; + alphaScale: number; + blurScale: number; +} + +interface WebcamRenderSource { + source: CanvasImageSource | VideoFrame; + width: number; + height: number; + mode: "live" | "cached"; +} + +interface WebcamLayoutCache { + sourceWidth: number; + sourceHeight: number; + width: number; + height: number; + positionX: number; + positionY: number; + radius: number; + shadowStrength: number; + mirror: boolean; +} + +interface AnnotationSpriteEntry { + annotation: AnnotationRegion; + sprite: Sprite; + texture: Texture; +} + +interface ExportCompositeCanvasState { + canvas: HTMLCanvasElement; + context: CanvasRenderingContext2D; +} + +type ResolvedCaptionLayout = NonNullable<ReturnType<typeof buildActiveCaptionLayout>>; + +interface CaptionRenderState { + key: string; + layout: ResolvedCaptionLayout; + fontFamily: string; + fontSize: number; + lineHeight: number; + boxWidth: number; + boxHeight: number; + centerX: number; + centerY: number; +} + +type PixiRendererAttempt = { + backend: ExportRenderBackend; + message: string; +}; + +const CANVAS_RENDERER_NOT_IMPLEMENTED_HINT = "CanvasRenderer is not yet implemented"; +const NO_RENDERER_HINT = "no available renderer"; +const PIXI_RENDERER_INIT_TIMEOUT_MS = 8_000; +const BACKGROUND_MEDIA_ELEMENT_READY_TIMEOUT_MS = 5_000; +const WEBCAM_MEDIA_ELEMENT_READY_TIMEOUT_MS = 5_000; + +function isCanvasRenderer(application: Application): boolean { + const rendererName = application?.renderer?.constructor?.name?.toLowerCase(); + return Boolean( + rendererName && + (rendererName.includes("canvasrenderer") || rendererName.includes("canvas")), + ); +} + +function toErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error ?? "Unknown renderer init error"); +} + +function summarizeRendererAttempts(attempts: readonly PixiRendererAttempt[]): string { + const details = attempts.map((attempt) => `${attempt.backend}: ${attempt.message}`).join(" | "); + return `No supported Pixi modern renderer was available. Attempted: ${details}`; +} + +function isKnownRendererUnavailableError(error: unknown): boolean { + const message = toErrorMessage(error).toLowerCase(); + return ( + message.includes(CANVAS_RENDERER_NOT_IMPLEMENTED_HINT.toLowerCase()) || + message.includes(NO_RENDERER_HINT) + ); +} + +interface RenderSnapshot { + timeMs: number; + cursorTimeMs: number; + backgroundTimelineTimeMs: number; + sceneTransform: { scale: number; x: number; y: number }; + zoom: { scale: number; focusX: number; focusY: number; progress: number }; +} + +function createAnimationState(): AnimationState { + return { + scale: 1, + appliedScale: 1, + focusX: DEFAULT_FOCUS.cx, + focusY: DEFAULT_FOCUS.cy, + progress: 0, + x: 0, + y: 0, + }; +} + +function configureHighQuality2DContext( + context: CanvasRenderingContext2D | null, +): CanvasRenderingContext2D | null { + if (!context) { + return null; + } + + context.imageSmoothingEnabled = true; + context.imageSmoothingQuality = "high"; + + return context; +} + +function drawSourceCoverToCanvas( + ctx: CanvasRenderingContext2D, + source: CanvasImageSource, + sourceWidth: number, + sourceHeight: number, + targetWidth: number, + targetHeight: number, +): void { + const safeSourceWidth = Math.max(1, sourceWidth); + const safeSourceHeight = Math.max(1, sourceHeight); + const sourceAspect = safeSourceWidth / safeSourceHeight; + const targetAspect = targetWidth / targetHeight; + + let drawWidth = targetWidth; + let drawHeight = targetHeight; + let drawX = 0; + let drawY = 0; + + if (sourceAspect > targetAspect) { + drawHeight = targetHeight; + drawWidth = drawHeight * sourceAspect; + drawX = (targetWidth - drawWidth) / 2; + } else { + drawWidth = targetWidth; + drawHeight = drawWidth / sourceAspect; + drawY = (targetHeight - drawHeight) / 2; + } + + ctx.clearRect(0, 0, targetWidth, targetHeight); + ctx.drawImage(source, drawX, drawY, drawWidth, drawHeight); +} + +function applyCoverLayoutToSprite( + sprite: Sprite, + sourceWidth: number, + sourceHeight: number, + targetWidth: number, + targetHeight: number, + centerX: number, + centerY: number, + mirror = false, +): void { + const safeSourceWidth = Math.max(1, sourceWidth); + const safeSourceHeight = Math.max(1, sourceHeight); + const coverScale = Math.max(targetWidth / safeSourceWidth, targetHeight / safeSourceHeight); + + sprite.anchor.set(0.5); + sprite.position.set(centerX, centerY); + sprite.scale.set(coverScale * (mirror ? -1 : 1), coverScale); +} + +function clampUnitInterval(value: number): number { + return Math.min(1, Math.max(0, value)); +} + +function areNearlyEqual(first: number, second: number, epsilon = 0.01): boolean { + return Math.abs(first - second) <= epsilon; +} + +// Renders video frames with all effects directly into a GPU-backed Pixi scene for export. +export class FrameRenderer { + private app: Application | null = null; + private rendererBackend: ExportRenderBackend = "webgl"; + private backgroundContainer: Container | null = null; + private cameraContainer: Container | null = null; + private videoEffectsContainer: Container | null = null; + private videoContainer: Container | null = null; + private cursorContainer: Container | null = null; + private overlayContainer: Container | null = null; + private annotationContainer: Container | null = null; + private captionContainer: Container | null = null; + private webcamRootContainer: Container | null = null; + private webcamContainer: Container | null = null; + private videoSprite: Sprite | null = null; + private videoTextureSource: MutableVideoTextureSource | null = null; + private backgroundSprite: Sprite | null = null; + private backgroundTextureSource: MutableVideoTextureSource | null = null; + private videoMaskGraphics: Graphics | null = null; + private webcamMaskGraphics: Graphics | null = null; + private zoomBlurFilter: ZoomBlurFilter | null = null; + private motionBlurFilter: MotionBlurFilter | null = null; + private backgroundBlurFilter: BlurFilter | null = null; + private annotationAssets: AnnotationRenderAssets | null = null; + private annotationScaleFactor = 1; + private annotationSprites: AnnotationSpriteEntry[] = []; + private backgroundForwardFrameSource: ForwardFrameSource | null = null; + private backgroundForwardFrameSourceUrl: string | null = null; + private backgroundForwardFrameDurationSec: number | null = null; + private backgroundDecodedFrame: VideoFrame | null = null; + private backgroundVideoElement: HTMLVideoElement | null = null; + private backgroundSeekPromise: Promise<void> | null = null; + private cleanupBackgroundSource: (() => void) | null = null; + private lastSyncedBackgroundLoopTimeSec: number | null = null; + private videoShadowLayers: ShadowLayer[] = []; + private webcamShadowLayers: ShadowLayer[] = []; + private webcamSprite: Sprite | null = null; + private webcamTextureSource: MutableVideoTextureSource | null = null; + private webcamForwardFrameSource: ForwardFrameSource | null = null; + private webcamDecodedFrame: VideoFrame | null = null; + private webcamVideoElement: HTMLVideoElement | null = null; + private webcamSeekPromise: Promise<void> | null = null; + private webcamFrameCacheCanvas: HTMLCanvasElement | null = null; + private webcamFrameCacheCtx: CanvasRenderingContext2D | null = null; + private sceneVideoFrameStagingCanvas: HTMLCanvasElement | null = null; + private sceneVideoFrameStagingCtx: CanvasRenderingContext2D | null = null; + private backgroundVideoFrameStagingCanvas: HTMLCanvasElement | null = null; + private backgroundVideoFrameStagingCtx: CanvasRenderingContext2D | null = null; + private webcamVideoFrameStagingCanvas: HTMLCanvasElement | null = null; + private webcamVideoFrameStagingCtx: CanvasRenderingContext2D | null = null; + private captionMeasureCanvas: HTMLCanvasElement | null = null; + private captionMeasureCtx: CanvasRenderingContext2D | null = null; + private captionCanvas: HTMLCanvasElement | null = null; + private captionCtx: CanvasRenderingContext2D | null = null; + private captionSprite: Sprite | null = null; + private captionTextureSource: MutableVideoTextureSource | null = null; + private captionRenderKey: string | null = null; + private exportCompositeCanvas: ExportCompositeCanvasState | null = null; + private temporalCompositeCanvas: ExportCompositeCanvasState | null = null; + private outputCanvasOverride: HTMLCanvasElement | null = null; + private config: FrameRenderConfig; + private animationState: AnimationState; + private motionBlurState: MotionBlurState; + private springScale: SpringState; + private springX: SpringState; + private springY: SpringState; + private cursorFollowCamera: CursorFollowCameraState; + private lastContentTimeMs: number | null = null; + private layoutCache: LayoutCache | null = null; + private currentVideoTime = 0; + private cursorOverlay: PixiCursorOverlay | null = null; + private lastSyncedWebcamTime: number | null = null; + private webcamRenderMode: "hidden" | "live" | "cached" = "hidden"; + private webcamLayoutCache: WebcamLayoutCache | null = null; + private videoTextureUsesStartupStaging = false; + private webcamTextureUsesStartupStaging = false; + private retainedSceneSourceFrame: VideoFrame | null = null; + private retainedSceneTextureFrame: VideoFrame | null = null; + private retainedBackgroundSourceFrame: VideoFrame | null = null; + private retainedBackgroundTextureFrame: VideoFrame | null = null; + private retainedWebcamSourceFrame: VideoFrame | null = null; + private retainedWebcamTextureFrame: VideoFrame | null = null; + private retainedSceneBitmapTimestamp: number | null = null; + private retainedSceneBitmap: ImageBitmap | null = null; + private retainedBackgroundBitmapTimestamp: number | null = null; + private retainedBackgroundBitmap: ImageBitmap | null = null; + private cleanupWebcamSource: (() => void) | null = null; + + constructor(config: FrameRenderConfig) { + this.config = config; + this.animationState = createAnimationState(); + this.motionBlurState = createMotionBlurState(); + this.springScale = createSpringState(1); + this.springX = createSpringState(0); + this.springY = createSpringState(0); + this.cursorFollowCamera = createCursorFollowCameraState(); + } + + private shouldUseZoomMotionBlur(): boolean { + return (this.config.zoomMotionBlur ?? 0) > 0; + } + + private updateVideoEffectsFilterState(): void { + if (!this.videoEffectsContainer) { + return; + } + + const activeFilters = + this.shouldUseZoomMotionBlur() && this.motionBlurFilter && this.zoomBlurFilter + ? [this.motionBlurFilter, this.zoomBlurFilter] + : null; + this.videoEffectsContainer.filters = activeFilters; + } + + async initialize(): Promise<void> { + let cursorOverlayEnabled = true; + try { + await preloadCursorAssets(); + } catch (error) { + cursorOverlayEnabled = false; + console.warn( + "[FrameRenderer] Native cursor assets are unavailable; continuing export without cursor overlay.", + error, + ); + } + + const canvas = document.createElement("canvas"); + canvas.width = this.config.width; + canvas.height = this.config.height; + + try { + const exportCanvas = canvas as HTMLCanvasElement & { + colorSpace?: string; + }; + if ("colorSpace" in exportCanvas) { + exportCanvas.colorSpace = "srgb"; + } + } catch (error) { + console.warn("[FrameRenderer] colorSpace not supported on this platform:", error); + } + + const application = await this.createPixiApplication(canvas); + this.app = application.app; + this.rendererBackend = application.backend; + + this.backgroundContainer = new Container(); + this.cameraContainer = new Container(); + this.videoEffectsContainer = new Container(); + this.videoContainer = new Container(); + this.cursorContainer = new Container(); + this.overlayContainer = new Container(); + this.annotationContainer = new Container(); + this.captionContainer = new Container(); + this.webcamRootContainer = new Container(); + this.webcamContainer = new Container(); + + this.app.stage.addChild(this.backgroundContainer); + this.app.stage.addChild(this.cameraContainer); + this.app.stage.addChild(this.overlayContainer); + + this.videoShadowLayers = this.createShadowLayers( + this.cameraContainer, + VIDEO_SHADOW_LAYER_PROFILES, + ); + + this.cameraContainer.addChild(this.videoEffectsContainer); + this.cameraContainer.addChild(this.cursorContainer); + this.videoEffectsContainer.addChild(this.videoContainer); + this.videoEffectsContainer.filterArea = new Rectangle( + 0, + 0, + this.config.width, + this.config.height, + ); + + this.webcamShadowLayers = this.createShadowLayers( + this.webcamRootContainer, + WEBCAM_SHADOW_LAYER_PROFILES, + ); + this.webcamRootContainer.addChild(this.webcamContainer); + this.webcamRootContainer.visible = false; + + this.overlayContainer.addChild(this.webcamRootContainer); + this.cameraContainer.addChild(this.annotationContainer); + this.overlayContainer.addChild(this.captionContainer); + + this.videoMaskGraphics = new Graphics(); + this.videoContainer.addChild(this.videoMaskGraphics); + this.videoContainer.mask = this.videoMaskGraphics; + + this.webcamMaskGraphics = new Graphics(); + this.webcamContainer.addChild(this.webcamMaskGraphics); + this.webcamContainer.mask = this.webcamMaskGraphics; + + if (cursorOverlayEnabled) { + this.cursorOverlay = new PixiCursorOverlay({ + dotRadius: DEFAULT_CURSOR_CONFIG.dotRadius * (this.config.cursorSize ?? 1.4), + style: this.config.cursorStyle ?? "tahoe", + smoothingFactor: + this.config.cursorSmoothing ?? DEFAULT_CURSOR_CONFIG.smoothingFactor, + springTuning: { + stiffnessMultiplier: this.config.cursorSpringStiffnessMultiplier, + dampingMultiplier: this.config.cursorSpringDampingMultiplier, + massMultiplier: this.config.cursorSpringMassMultiplier, + }, + motionBlur: this.config.cursorMotionBlur ?? 0, + clickEffect: this.config.cursorClickEffect ?? DEFAULT_CURSOR_CONFIG.clickEffect, + clickEffectColor: + this.config.cursorClickEffectColor ?? DEFAULT_CURSOR_CONFIG.clickEffectColor, + clickEffectScale: + this.config.cursorClickEffectScale ?? DEFAULT_CURSOR_CONFIG.clickEffectScale, + clickEffectOpacity: + this.config.cursorClickEffectOpacity ?? + DEFAULT_CURSOR_CONFIG.clickEffectOpacity, + clickEffectDurationMs: + this.config.cursorClickEffectDurationMs ?? + DEFAULT_CURSOR_CONFIG.clickEffectDurationMs, + clickBounce: this.config.cursorClickBounce ?? DEFAULT_CURSOR_CONFIG.clickBounce, + clickBounceDuration: + this.config.cursorClickBounceDuration ?? + DEFAULT_CURSOR_CONFIG.clickBounceDuration, + sway: this.config.cursorSway ?? DEFAULT_CURSOR_CONFIG.sway, + }); + this.cursorContainer.addChild(this.cursorOverlay.container); + } + + await this.setupBackground(); + await this.setupWebcamSource(); + + this.annotationScaleFactor = this.calculateAnnotationScaleFactor(); + this.annotationAssets = await preloadAnnotationAssets(this.config.annotationRegions ?? []); + await this.setupAnnotationLayer(); + this.setupCaptionResources(); + + if (this.shouldUseZoomMotionBlur()) { + this.zoomBlurFilter = new ZoomBlurFilter({ + strength: 0, + maxKernelSize: 13, + }); + this.motionBlurFilter = new MotionBlurFilter([0, 0], 5, 0); + } + + this.updateVideoEffectsFilterState(); + + console.log(`[FrameRenderer] Export renderer backend: ${this.rendererBackend}`); + } + + private async createPixiApplication( + canvas: HTMLCanvasElement, + ): Promise<{ app: Application; backend: ExportRenderBackend }> { + const baseOptions = { + canvas, + width: this.config.width, + height: this.config.height, + backgroundAlpha: 0, + antialias: true, + failIfMajorPerformanceCaveat: false, + resolution: 1, + autoDensity: true, + autoStart: false, + sharedTicker: false, + powerPreference: "high-performance" as const, + }; + + const preferredRenderBackend = this.config.preferredRenderBackend; + const backendOrder: ExportRenderBackend[] = + preferredRenderBackend === "webgl" + ? ["webgl", "webgpu"] + : preferredRenderBackend === "webgpu" + ? ["webgpu", "webgl"] + : typeof navigator !== "undefined" && "gpu" in navigator + ? ["webgpu", "webgl"] + : ["webgl"]; + const failures: PixiRendererAttempt[] = []; + + for (const backend of backendOrder) { + if (backend === "webgpu" && !(typeof navigator !== "undefined" && "gpu" in navigator)) { + failures.push({ + backend, + message: "WebGPU runtime is unavailable in this environment.", + }); + continue; + } + + const app = new Application(); + const initStarted = typeof performance === "undefined" ? Date.now() : performance.now(); + try { + await initializePixiApplicationWithTimeout( + app, + { + ...baseOptions, + preference: backend, + }, + PIXI_RENDERER_INIT_TIMEOUT_MS, + backend, + ); + const elapsed = Math.round( + (typeof performance === "undefined" ? Date.now() : performance.now()) - + initStarted, + ); + if (isCanvasRenderer(app)) { + throw new Error( + `Renderer initialized with unsupported fallback backend after ${elapsed}ms: ${app.renderer.constructor?.name ?? "unknown"}`, + ); + } + return { app, backend }; + } catch (error) { + const elapsed = Math.round( + (typeof performance === "undefined" ? Date.now() : performance.now()) - + initStarted, + ); + failures.push({ + backend, + message: `${toErrorMessage(error)} (after ${elapsed}ms)`, + }); + const rendererMessage = isKnownRendererUnavailableError(error) + ? "renderer backend unavailable in this runtime" + : "renderer init failed"; + console.warn( + `[FrameRenderer] ${backend} export renderer unavailable (${rendererMessage}) after ${elapsed}ms; trying next backend:`, + error, + ); + destroyPixiApplication(app, `${backend} export renderer initialization`); + } + } + + throw new Error(summarizeRendererAttempts(failures)); + } + + private createShadowLayers( + parent: Container, + configs: ReadonlyArray<{ + offsetScale: number; + alphaScale: number; + blurScale: number; + }>, + ): ShadowLayer[] { + return configs.map((config) => { + const container = new Container(); + container.visible = false; + parent.addChild(container); + + return { + container, + sprite: null, + canvas: null, + context: null, + textureSource: null, + ...config, + }; + }); + } + + private ensureShadowLayerCanvas(layer: ShadowLayer, width: number, height: number): void { + const targetWidth = Math.max(1, Math.ceil(width)); + const targetHeight = Math.max(1, Math.ceil(height)); + + if ( + layer.canvas && + layer.canvas.width === targetWidth && + layer.canvas.height === targetHeight && + layer.context && + layer.sprite + ) { + return; + } + + layer.canvas = document.createElement("canvas"); + layer.canvas.width = targetWidth; + layer.canvas.height = targetHeight; + layer.context = configureHighQuality2DContext(layer.canvas.getContext("2d")); + + if (!layer.context) { + throw new Error("Failed to create shadow export canvas"); + } + + const nextTexture = Texture.from(layer.canvas); + if (layer.sprite) { + const previousTexture = layer.sprite.texture; + layer.sprite.texture = nextTexture; + layer.textureSource = nextTexture.source as unknown as MutableVideoTextureSource; + previousTexture.destroy(true); + } else { + layer.sprite = new Sprite(nextTexture); + layer.container.addChild(layer.sprite); + layer.textureSource = nextTexture.source as unknown as MutableVideoTextureSource; + } + } + + private rasterizeShadowLayer( + layer: ShadowLayer, + options: { + x: number; + y: number; + width: number; + height: number; + radius: number; + offsetY: number; + alpha: number; + blur: number; + }, + ): void { + if (options.alpha <= 0 || options.width <= 0 || options.height <= 0) { + layer.container.visible = false; + return; + } + + const padding = getShadowFilterPadding(options.blur, options.offsetY); + this.ensureShadowLayerCanvas( + layer, + options.width + padding * 2, + options.height + padding * 2, + ); + + if (!layer.context || !layer.canvas || !layer.sprite) { + layer.container.visible = false; + return; + } + + layer.context.clearRect(0, 0, layer.canvas.width, layer.canvas.height); + layer.context.save(); + layer.context.filter = options.blur > 0 ? `blur(${options.blur}px)` : "none"; + layer.context.fillStyle = `rgba(0, 0, 0, ${options.alpha})`; + drawSquircleOnCanvas(layer.context, { + x: padding, + y: padding + options.offsetY, + width: options.width, + height: options.height, + radius: options.radius, + }); + layer.context.fill(); + layer.context.restore(); + + layer.sprite.position.set(options.x - padding, options.y - padding); + layer.textureSource?.update(); + layer.container.alpha = 1; + layer.container.visible = true; + } + + private getRetainedVideoFrameState(kind: "scene" | "background" | "webcam") { + if (kind === "scene") { + return { + sourceFrame: this.retainedSceneSourceFrame, + textureFrame: this.retainedSceneTextureFrame, + }; + } + + if (kind === "background") { + return { + sourceFrame: this.retainedBackgroundSourceFrame, + textureFrame: this.retainedBackgroundTextureFrame, + }; + } + + return { + sourceFrame: this.retainedWebcamSourceFrame, + textureFrame: this.retainedWebcamTextureFrame, + }; + } + + private setRetainedVideoFrameState( + kind: "scene" | "background" | "webcam", + sourceFrame: VideoFrame | null, + textureFrame: VideoFrame | null, + ): void { + if (kind === "scene") { + this.retainedSceneSourceFrame = sourceFrame; + this.retainedSceneTextureFrame = textureFrame; + return; + } + + if (kind === "background") { + this.retainedBackgroundSourceFrame = sourceFrame; + this.retainedBackgroundTextureFrame = textureFrame; + return; + } + + this.retainedWebcamSourceFrame = sourceFrame; + this.retainedWebcamTextureFrame = textureFrame; + } + + private closeRetainedVideoFrame(kind: "scene" | "background" | "webcam"): void { + const state = this.getRetainedVideoFrameState(kind); + if (!state.textureFrame) { + this.setRetainedVideoFrameState(kind, null, null); + return; + } + + state.textureFrame.close(); + this.setRetainedVideoFrameState(kind, null, null); + } + + private closeRetainedBitmap(kind: "scene" | "background"): void { + if (kind === "scene") { + this.retainedSceneBitmap?.close(); + this.retainedSceneBitmap = null; + this.retainedSceneBitmapTimestamp = null; + return; + } + + this.retainedBackgroundBitmap?.close(); + this.retainedBackgroundBitmap = null; + this.retainedBackgroundBitmapTimestamp = null; + } + + private async resolveDetachedVideoFrameSource( + frame: VideoFrame, + kind: "scene" | "background", + fallbackWidth: number, + fallbackHeight: number, + ): Promise<CanvasImageSource | VideoFrame> { + if (this.rendererBackend !== "webgpu" || typeof createImageBitmap !== "function") { + return this.stageVideoFrameForTexture(frame, kind, fallbackWidth, fallbackHeight); + } + + const cachedTimestamp = + kind === "scene" + ? this.retainedSceneBitmapTimestamp + : this.retainedBackgroundBitmapTimestamp; + const cachedBitmap = + kind === "scene" ? this.retainedSceneBitmap : this.retainedBackgroundBitmap; + if (cachedTimestamp === frame.timestamp && cachedBitmap) { + return cachedBitmap; + } + + try { + const bitmap = await createImageBitmap(frame); + this.closeRetainedBitmap(kind); + if (kind === "scene") { + this.retainedSceneBitmap = bitmap; + this.retainedSceneBitmapTimestamp = frame.timestamp; + } else { + this.retainedBackgroundBitmap = bitmap; + this.retainedBackgroundBitmapTimestamp = frame.timestamp; + } + return bitmap; + } catch (error) { + console.warn( + `[ModernFrameRenderer] Failed to detach ${kind} VideoFrame to ImageBitmap, falling back to retained VideoFrame:`, + error, + ); + return this.stageVideoFrameForTexture(frame, kind, fallbackWidth, fallbackHeight); + } + } + + private resolveRetainedVideoFrameSource( + frame: VideoFrame, + kind: "scene" | "background" | "webcam", + fallbackWidth: number, + fallbackHeight: number, + ): CanvasImageSource | VideoFrame { + if (this.rendererBackend !== "webgpu") { + return frame; + } + + const state = this.getRetainedVideoFrameState(kind); + if (state.sourceFrame === frame && state.textureFrame) { + return state.textureFrame; + } + + try { + const retainedFrame = new VideoFrame(frame, { + timestamp: frame.timestamp, + }); + this.closeRetainedVideoFrame(kind); + this.setRetainedVideoFrameState(kind, frame, retainedFrame); + return retainedFrame; + } catch (error) { + console.warn( + `[ModernFrameRenderer] Failed to retain ${kind} VideoFrame, falling back to staging canvas:`, + error, + ); + return this.stageVideoFrameOnCanvas(frame, kind, fallbackWidth, fallbackHeight); + } + } + + private ensureVideoFrameStagingCanvas( + kind: "scene" | "background" | "webcam", + width: number, + height: number, + ): { canvas: HTMLCanvasElement; context: CanvasRenderingContext2D } | null { + const targetWidth = Math.max(1, Math.ceil(width)); + const targetHeight = Math.max(1, Math.ceil(height)); + const currentCanvas = + kind === "scene" + ? this.sceneVideoFrameStagingCanvas + : kind === "background" + ? this.backgroundVideoFrameStagingCanvas + : this.webcamVideoFrameStagingCanvas; + const currentContext = + kind === "scene" + ? this.sceneVideoFrameStagingCtx + : kind === "background" + ? this.backgroundVideoFrameStagingCtx + : this.webcamVideoFrameStagingCtx; + + if ( + currentCanvas && + currentCanvas.width === targetWidth && + currentCanvas.height === targetHeight && + currentContext + ) { + return { canvas: currentCanvas, context: currentContext }; + } + + const canvas = document.createElement("canvas"); + canvas.width = targetWidth; + canvas.height = targetHeight; + const context = configureHighQuality2DContext( + canvas.getContext("2d", { willReadFrequently: true }), + ); + if (!context) { + return null; + } + + if (kind === "scene") { + this.sceneVideoFrameStagingCanvas = canvas; + this.sceneVideoFrameStagingCtx = context; + } else if (kind === "background") { + this.backgroundVideoFrameStagingCanvas = canvas; + this.backgroundVideoFrameStagingCtx = context; + } else { + this.webcamVideoFrameStagingCanvas = canvas; + this.webcamVideoFrameStagingCtx = context; + } + + return { canvas, context }; + } + + private stageVideoFrameOnCanvas( + frame: VideoFrame, + kind: "scene" | "background" | "webcam", + fallbackWidth: number, + fallbackHeight: number, + ): CanvasImageSource | VideoFrame { + const width = Math.max(1, frame.displayWidth || fallbackWidth); + const height = Math.max(1, frame.displayHeight || fallbackHeight); + const staging = this.ensureVideoFrameStagingCanvas(kind, width, height); + if (!staging) { + return frame; + } + + staging.context.clearRect(0, 0, staging.canvas.width, staging.canvas.height); + staging.context.drawImage(frame, 0, 0, staging.canvas.width, staging.canvas.height); + return staging.canvas; + } + + private stageVideoFrameForTexture( + frame: VideoFrame, + kind: "scene" | "background" | "webcam", + fallbackWidth: number, + fallbackHeight: number, + ): CanvasImageSource | VideoFrame { + // Keep webcam uploads on the older canvas-staged path. The newer + // retained-VideoFrame upload path is fine for the main scene/background, + // but it has produced unstable webcam overlays in Lightning exports. + if (kind === "webcam") { + return this.stageVideoFrameOnCanvas(frame, kind, fallbackWidth, fallbackHeight); + } + + if (this.rendererBackend === "webgpu") { + return this.resolveRetainedVideoFrameSource(frame, kind, fallbackWidth, fallbackHeight); + } + + return this.stageVideoFrameOnCanvas(frame, kind, fallbackWidth, fallbackHeight); + } + + private replaceSpriteTexture( + sprite: Sprite, + source: CanvasImageSource | VideoFrame, + ): MutableVideoTextureSource { + const nextTexture = this.createTextureFromSource(source); + const previousTexture = sprite.texture; + sprite.texture = nextTexture; + previousTexture.destroy(true); + return nextTexture.source as unknown as MutableVideoTextureSource; + } + + private createTextureFromSource(source: CanvasImageSource | VideoFrame): Texture { + if (typeof VideoFrame !== "undefined" && source instanceof VideoFrame) { + return Texture.from(source as unknown as ImageBitmap); + } + + return Texture.from( + source as HTMLCanvasElement | HTMLVideoElement | HTMLImageElement | ImageBitmap, + ); + } + + private getWebcamSourceDimensions(source: HTMLVideoElement | VideoFrame): { + width: number; + height: number; + } { + if ("displayWidth" in source && "displayHeight" in source) { + return { + width: source.displayWidth, + height: source.displayHeight, + }; + } + + return { + width: source.videoWidth, + height: source.videoHeight, + }; + } + + private resetBackgroundLayer(): void { + this.backgroundForwardFrameSource?.cancel(); + void this.backgroundForwardFrameSource?.destroy(); + this.backgroundForwardFrameSource = null; + this.backgroundForwardFrameSourceUrl = null; + this.backgroundForwardFrameDurationSec = null; + this.closeBackgroundDecodedFrame(); + this.cleanupBackgroundSource?.(); + this.cleanupBackgroundSource = null; + this.lastSyncedBackgroundLoopTimeSec = null; + + if (this.backgroundVideoElement) { + this.backgroundVideoElement.pause(); + this.backgroundVideoElement.src = ""; + this.backgroundVideoElement.load(); + this.backgroundVideoElement = null; + } + + const backgroundTexture = this.backgroundSprite?.texture ?? null; + this.backgroundSprite?.destroy({ texture: false, textureSource: false }); + backgroundTexture?.destroy(true); + + this.backgroundSprite = null; + this.backgroundTextureSource = null; + this.backgroundBlurFilter = null; + this.backgroundContainer?.removeChildren(); + } + + private async setupBackground(): Promise<void> { + this.resetBackgroundLayer(); + + const wallpaper = await this.resolveWallpaperForExport(this.config.wallpaper); + + try { + if (isVideoWallpaperSource(wallpaper)) { + let videoSrc = wallpaper; + if (wallpaper.startsWith("/") && !wallpaper.startsWith("//")) { + videoSrc = await getAssetPath(wallpaper.replace(/^\//, "")); + } + + try { + const frameSource = new ForwardFrameSource(); + const metadata = await frameSource.initialize(videoSrc); + this.backgroundForwardFrameSource = frameSource; + this.backgroundForwardFrameSourceUrl = videoSrc; + this.backgroundForwardFrameDurationSec = getEffectiveVideoStreamDurationSeconds( + { + duration: metadata?.duration, + streamDuration: metadata?.streamDuration, + }, + ); + this.backgroundVideoElement = null; + this.backgroundSeekPromise = null; + this.lastSyncedBackgroundLoopTimeSec = null; + return; + } catch (error) { + console.warn( + "[FrameRenderer] Decoder-backed video wallpaper unavailable during export; falling back to media element sync:", + error, + ); + } + + if (!(await this.loadBackgroundMediaElementSource(videoSrc, wallpaper))) { + throw new Error(`Failed to load video wallpaper: ${wallpaper}`); + } + return; + } + + const bgCanvas = document.createElement("canvas"); + bgCanvas.width = this.config.width; + bgCanvas.height = this.config.height; + const bgCtx = configureHighQuality2DContext(bgCanvas.getContext("2d")); + + if (!bgCtx) { + throw new Error("Failed to get 2D context for background canvas"); + } + + if ( + wallpaper.startsWith("file://") || + wallpaper.startsWith("data:") || + wallpaper.startsWith("/") || + wallpaper.startsWith("http") + ) { + const img = new Image(); + const imageUrl = await this.resolveWallpaperImageUrl(wallpaper); + if ( + imageUrl.startsWith("http") && + window.location.origin && + !imageUrl.startsWith(window.location.origin) + ) { + img.crossOrigin = "anonymous"; + } + + await new Promise<void>((resolve, reject) => { + img.onload = () => resolve(); + img.onerror = (err) => { + console.error( + "[FrameRenderer] Failed to load background image:", + imageUrl, + err, + ); + reject(new Error(`Failed to load background image: ${imageUrl}`)); + }; + img.src = imageUrl; + }); + + drawSourceCoverToCanvas( + bgCtx, + img, + img.width, + img.height, + this.config.width, + this.config.height, + ); + } else if (wallpaper.startsWith("#")) { + bgCtx.fillStyle = wallpaper; + bgCtx.fillRect(0, 0, this.config.width, this.config.height); + } else if ( + wallpaper.startsWith("linear-gradient") || + wallpaper.startsWith("radial-gradient") + ) { + const gradientMatch = wallpaper.match(/(linear|radial)-gradient\((.+)\)/); + if (!gradientMatch) { + bgCtx.fillStyle = "#000000"; + bgCtx.fillRect(0, 0, this.config.width, this.config.height); + } else { + const [, type, params] = gradientMatch; + const parts = params.split(",").map((value) => value.trim()); + const gradient = + type === "linear" + ? bgCtx.createLinearGradient(0, 0, 0, this.config.height) + : bgCtx.createRadialGradient( + this.config.width / 2, + this.config.height / 2, + 0, + this.config.width / 2, + this.config.height / 2, + Math.max(this.config.width, this.config.height) / 2, + ); + + parts.forEach((part, index) => { + if (type === "linear" && (part.startsWith("to ") || part.includes("deg"))) { + return; + } + + const colorMatch = part.match(/^(#[0-9a-fA-F]{3,8}|rgba?\([^)]+\)|[a-z]+)/); + if (!colorMatch) { + return; + } + + const position = index / Math.max(parts.length - 1, 1); + gradient.addColorStop(position, colorMatch[1]); + }); + + bgCtx.fillStyle = gradient; + bgCtx.fillRect(0, 0, this.config.width, this.config.height); + } + } else { + bgCtx.fillStyle = wallpaper; + bgCtx.fillRect(0, 0, this.config.width, this.config.height); + } + + const blurredCanvas = + this.config.backgroundBlur > 0 + ? this.createPreblurredBackgroundCanvas(bgCanvas) + : null; + const backgroundSource = blurredCanvas ?? bgCanvas; + const backgroundTexture = Texture.from(backgroundSource); + this.backgroundSprite = new Sprite(backgroundTexture); + this.backgroundContainer?.addChild(this.backgroundSprite); + applyCoverLayoutToSprite( + this.backgroundSprite, + backgroundSource.width, + backgroundSource.height, + this.config.width, + this.config.height, + this.config.width / 2, + this.config.height / 2, + ); + } catch (error) { + console.error("[FrameRenderer] Error setting up background, using fallback:", error); + const fallback = document.createElement("canvas"); + fallback.width = this.config.width; + fallback.height = this.config.height; + const ctx = configureHighQuality2DContext(fallback.getContext("2d")); + if (!ctx) { + throw new Error("Failed to create fallback background context"); + } + ctx.fillStyle = "#000000"; + ctx.fillRect(0, 0, fallback.width, fallback.height); + this.backgroundSprite = new Sprite(Texture.from(fallback)); + this.backgroundContainer?.addChild(this.backgroundSprite); + applyCoverLayoutToSprite( + this.backgroundSprite, + fallback.width, + fallback.height, + this.config.width, + this.config.height, + this.config.width / 2, + this.config.height / 2, + ); + } + } + + private async ensureBackgroundSprite( + source: CanvasImageSource | VideoFrame, + sourceWidth: number, + sourceHeight: number, + ): Promise<void> { + if (!this.backgroundContainer) { + return; + } + + const resolvedSource = + typeof VideoFrame !== "undefined" && source instanceof VideoFrame + ? await this.resolveDetachedVideoFrameSource( + source, + "background", + sourceWidth, + sourceHeight, + ) + : typeof HTMLVideoElement !== "undefined" && + source instanceof HTMLVideoElement && + sourceWidth > 0 && + sourceHeight > 0 + ? (() => { + const staging = this.ensureVideoFrameStagingCanvas( + "background", + sourceWidth, + sourceHeight, + ); + if (!staging) { + return source; + } + staging.context.clearRect( + 0, + 0, + staging.canvas.width, + staging.canvas.height, + ); + staging.context.drawImage( + source, + 0, + 0, + staging.canvas.width, + staging.canvas.height, + ); + return staging.canvas; + })() + : source; + + if (!this.backgroundSprite) { + const texture = this.createTextureFromSource(resolvedSource); + this.backgroundSprite = new Sprite(texture); + this.backgroundTextureSource = texture.source as unknown as MutableVideoTextureSource; + this.backgroundContainer.addChild(this.backgroundSprite); + + if (this.config.backgroundBlur > 0) { + this.backgroundBlurFilter = new BlurFilter(); + this.backgroundBlurFilter.blur = this.config.backgroundBlur * 3; + this.backgroundBlurFilter.quality = 4; + this.backgroundBlurFilter.resolution = this.app?.renderer.resolution ?? 1; + this.backgroundBlurFilter.repeatEdgePixels = true; + this.backgroundSprite.filters = [this.backgroundBlurFilter]; + } + } else if (this.backgroundTextureSource) { + this.backgroundTextureSource.resource = resolvedSource; + this.backgroundTextureSource.update(); + } + + applyCoverLayoutToSprite( + this.backgroundSprite, + sourceWidth, + sourceHeight, + this.config.width, + this.config.height, + this.config.width / 2, + this.config.height / 2, + ); + } + + private createPreblurredBackgroundCanvas( + sourceCanvas: HTMLCanvasElement, + ): HTMLCanvasElement | null { + const blurredCanvas = document.createElement("canvas"); + blurredCanvas.width = sourceCanvas.width; + blurredCanvas.height = sourceCanvas.height; + const blurredCtx = configureHighQuality2DContext(blurredCanvas.getContext("2d")); + if (!blurredCtx) { + return null; + } + + blurredCtx.save(); + const blurPx = this.config.backgroundBlur * 3; + const overscan = Math.ceil(blurPx * 2); + blurredCtx.filter = `blur(${blurPx}px)`; + blurredCtx.drawImage( + sourceCanvas, + -overscan, + -overscan, + blurredCanvas.width + overscan * 2, + blurredCanvas.height + overscan * 2, + ); + blurredCtx.restore(); + + return blurredCanvas; + } + + private closeBackgroundDecodedFrame(): void { + if (!this.backgroundDecodedFrame) { + return; + } + + this.backgroundDecodedFrame.close(); + this.backgroundDecodedFrame = null; + } + + private async restartBackgroundForwardFrameSource(): Promise<void> { + const sourceUrl = this.backgroundForwardFrameSourceUrl; + if (!sourceUrl) { + return; + } + + const nextSource = new ForwardFrameSource(); + const metadata = await nextSource.initialize(sourceUrl); + const previousSource = this.backgroundForwardFrameSource; + + this.backgroundForwardFrameSource = nextSource; + const effectiveDuration = getEffectiveVideoStreamDurationSeconds({ + duration: metadata?.duration, + streamDuration: metadata?.streamDuration, + }); + this.backgroundForwardFrameDurationSec = + Number.isFinite(effectiveDuration) && effectiveDuration > 0 ? effectiveDuration : null; + this.lastSyncedBackgroundLoopTimeSec = null; + + previousSource?.cancel(); + void previousSource?.destroy(); + } + + private calculateAnnotationScaleFactor(): number { + const previewWidth = this.config.previewWidth || 1920; + const previewHeight = this.config.previewHeight || 1080; + return (this.config.width / previewWidth + this.config.height / previewHeight) / 2; + } + + private hasActiveBlurAnnotations(timeMs: number): boolean { + return (this.config.annotationRegions ?? []).some( + (annotation) => + annotation.type === "blur" && + timeMs >= annotation.startMs && + timeMs <= annotation.endMs, + ); + } + + private ensureExportCompositeCanvas(): ExportCompositeCanvasState | null { + const targetWidth = Math.max(1, Math.ceil(this.config.width)); + const targetHeight = Math.max(1, Math.ceil(this.config.height)); + + if ( + this.exportCompositeCanvas && + this.exportCompositeCanvas.canvas.width === targetWidth && + this.exportCompositeCanvas.canvas.height === targetHeight + ) { + return this.exportCompositeCanvas; + } + + const canvas = document.createElement("canvas"); + canvas.width = targetWidth; + canvas.height = targetHeight; + + const context = configureHighQuality2DContext(canvas.getContext("2d")); + if (!context) { + return null; + } + + this.exportCompositeCanvas = { + canvas, + context, + }; + + return this.exportCompositeCanvas; + } + + private ensureTemporalCompositeCanvas(): ExportCompositeCanvasState | null { + const targetWidth = Math.max(1, Math.ceil(this.config.width)); + const targetHeight = Math.max(1, Math.ceil(this.config.height)); + + if ( + this.temporalCompositeCanvas && + this.temporalCompositeCanvas.canvas.width === targetWidth && + this.temporalCompositeCanvas.canvas.height === targetHeight + ) { + return this.temporalCompositeCanvas; + } + + const canvas = document.createElement("canvas"); + canvas.width = targetWidth; + canvas.height = targetHeight; + + const context = configureHighQuality2DContext(canvas.getContext("2d")); + if (!context) { + return null; + } + + this.temporalCompositeCanvas = { + canvas, + context, + }; + + return this.temporalCompositeCanvas; + } + + private drawCaptionOverlay(context: CanvasRenderingContext2D): void { + if ( + !this.captionContainer?.visible || + !this.captionSprite?.visible || + !this.captionCanvas + ) { + return; + } + + const drawWidth = this.captionCanvas.width * this.captionSprite.scale.x; + const drawHeight = this.captionCanvas.height * this.captionSprite.scale.y; + const drawX = this.captionSprite.x - drawWidth * this.captionSprite.anchor.x; + const drawY = this.captionSprite.y - drawHeight * this.captionSprite.anchor.y; + + context.save(); + context.globalAlpha = this.captionSprite.alpha; + context.drawImage(this.captionCanvas, drawX, drawY, drawWidth, drawHeight); + context.restore(); + } + + private async composeBlurAnnotationFrame( + timeMs: number, + sceneTransform?: { scale: number; x: number; y: number }, + sourceCanvas?: CanvasImageSource, + ): Promise<void> { + if (!this.app) { + this.outputCanvasOverride = null; + return; + } + + const compositeState = this.ensureExportCompositeCanvas(); + if (!compositeState) { + this.outputCanvasOverride = null; + return; + } + + const { canvas, context } = compositeState; + context.clearRect(0, 0, canvas.width, canvas.height); + context.drawImage(sourceCanvas ?? (this.app.canvas as HTMLCanvasElement), 0, 0); + + await renderAnnotations( + context, + this.config.annotationRegions ?? [], + this.config.width, + this.config.height, + timeMs, + this.annotationScaleFactor, + this.annotationAssets ?? undefined, + sceneTransform ?? { + scale: this.animationState.appliedScale, + x: this.animationState.x, + y: this.animationState.y, + }, + this.layoutCache?.maskRect, + ); + + this.drawCaptionOverlay(context); + this.outputCanvasOverride = canvas; + } + + private async setupAnnotationLayer(): Promise<void> { + if (!this.annotationContainer) { + return; + } + + for (const entry of this.annotationSprites) { + entry.sprite.destroy({ texture: false, textureSource: false }); + entry.texture.destroy(true); + } + this.annotationSprites = []; + this.annotationContainer.removeChildren(); + + const annotations = [...(this.config.annotationRegions ?? [])].sort( + (first, second) => first.zIndex - second.zIndex, + ); + + for (const annotation of annotations) { + const annotationRect = this.layoutCache?.maskRect ?? { + x: 0, + y: 0, + width: this.config.width, + height: this.config.height, + }; + const x = annotationRect.x + (annotation.position.x / 100) * annotationRect.width; + const y = annotationRect.y + (annotation.position.y / 100) * annotationRect.height; + const width = (annotation.size.width / 100) * annotationRect.width; + const height = (annotation.size.height / 100) * annotationRect.height; + + if (width <= 0 || height <= 0) { + continue; + } + + const canvas = await renderAnnotationToCanvas( + annotation, + width, + height, + this.annotationScaleFactor, + this.annotationAssets ?? undefined, + ); + if (!canvas) { + continue; + } + + const texture = Texture.from(canvas); + const sprite = new Sprite(texture); + sprite.position.set(x, y); + sprite.visible = false; + this.annotationContainer.addChild(sprite); + this.annotationSprites.push({ annotation, sprite, texture }); + } + } + + private updateAnnotationLayer(currentTimeMs: number): void { + for (const entry of this.annotationSprites) { + entry.sprite.visible = + currentTimeMs >= entry.annotation.startMs && + currentTimeMs <= entry.annotation.endMs; + } + } + + private setupCaptionResources(): void { + if (!this.config.autoCaptions?.length || !this.config.autoCaptionSettings) { + return; + } + + this.captionMeasureCanvas = document.createElement("canvas"); + this.captionMeasureCanvas.width = 1; + this.captionMeasureCanvas.height = 1; + this.captionMeasureCtx = configureHighQuality2DContext( + this.captionMeasureCanvas.getContext("2d"), + ); + } + + private buildCaptionRenderState(timeMs: number): CaptionRenderState | null { + const settings = this.config.autoCaptionSettings; + const cues = this.config.autoCaptions; + const measureCtx = this.captionMeasureCtx; + + if (!settings || !cues?.length || !measureCtx) { + return null; + } + + const fontFamily = settings.fontFamily || getDefaultCaptionFontFamily(); + const fontSize = getCaptionScaledFontSize( + settings.fontSize, + this.config.width, + settings.maxWidth, + ); + measureCtx.font = `${CAPTION_FONT_WEIGHT} ${fontSize}px ${fontFamily}`; + + const layout = buildActiveCaptionLayout({ + cues, + timeMs, + settings, + maxWidthPx: getCaptionTextMaxWidth(this.config.width, settings.maxWidth, fontSize), + measureText: (text) => measureCtx.measureText(text).width, + }); + if (!layout) { + return null; + } + + const padding = getCaptionPadding(fontSize); + const lineHeight = fontSize * CAPTION_LINE_HEIGHT; + const textBlockHeight = layout.visibleLines.length * lineHeight; + const boxHeight = textBlockHeight + padding.y * 2; + const maxMeasuredWidth = layout.visibleLines.reduce( + (largest, line) => Math.max(largest, line.width), + 0, + ); + const boxWidth = Math.min( + this.config.width * (settings.maxWidth / 100) + padding.x * 2, + maxMeasuredWidth + padding.x * 2, + ); + const centerX = this.config.width / 2; + const centerY = + this.config.height - (this.config.height * settings.bottomOffset) / 100 - boxHeight / 2; + + return { + key: `${layout.blockKey}:${layout.visiblePageIndex}:${layout.activeWordIndex}`, + layout, + fontFamily, + fontSize, + lineHeight, + boxWidth, + boxHeight, + centerX, + centerY, + }; + } + + private ensureCaptionCanvas(width: number, height: number): void { + const targetWidth = Math.max(1, Math.ceil(width)); + const targetHeight = Math.max(1, Math.ceil(height)); + + if ( + this.captionCanvas && + this.captionCanvas.width === targetWidth && + this.captionCanvas.height === targetHeight && + this.captionCtx && + this.captionSprite + ) { + return; + } + + this.captionCanvas = document.createElement("canvas"); + this.captionCanvas.width = targetWidth; + this.captionCanvas.height = targetHeight; + this.captionCtx = configureHighQuality2DContext(this.captionCanvas.getContext("2d")); + + if (!this.captionCtx) { + throw new Error("Failed to create caption export canvas"); + } + + const nextTexture = Texture.from(this.captionCanvas); + if (this.captionSprite) { + const previousTexture = this.captionSprite.texture; + this.captionSprite.texture = nextTexture; + this.captionTextureSource = nextTexture.source as unknown as MutableVideoTextureSource; + previousTexture.destroy(true); + } else { + this.captionSprite = new Sprite(nextTexture); + this.captionSprite.anchor.set(0.5); + this.captionContainer?.addChild(this.captionSprite); + this.captionTextureSource = nextTexture.source as unknown as MutableVideoTextureSource; + } + } + + private rasterizeCaptionSprite(state: CaptionRenderState): void { + this.ensureCaptionCanvas(state.boxWidth, state.boxHeight); + + if (!this.captionCtx || !this.captionCanvas || !this.captionSprite) { + return; + } + + const ctx = this.captionCtx; + const settings = this.config.autoCaptionSettings; + if (!settings) { + return; + } + + ctx.clearRect(0, 0, this.captionCanvas.width, this.captionCanvas.height); + ctx.font = `${CAPTION_FONT_WEIGHT} ${state.fontSize}px ${state.fontFamily}`; + ctx.fillStyle = `rgba(0, 0, 0, ${settings.backgroundOpacity})`; + drawSquircleOnCanvas(ctx, { + x: 0, + y: 0, + width: state.boxWidth, + height: state.boxHeight, + radius: getCaptionScaledRadius(settings.boxRadius, state.fontSize), + }); + ctx.fill(); + + const padding = getCaptionPadding(state.fontSize); + ctx.textAlign = "left"; + ctx.textBaseline = "middle"; + + state.layout.visibleLines.forEach((line, lineIndex) => { + let cursorX = (state.boxWidth - line.width) / 2; + const lineY = padding.y + state.lineHeight * lineIndex + state.lineHeight / 2; + + line.words.forEach((word) => { + const segmentText = `${word.leadingSpace ? " " : ""}${word.text}`; + const segmentWidth = ctx.measureText(segmentText).width; + const visualState = getCaptionWordVisualState( + state.layout.hasWordTimings, + word.state, + ); + + ctx.save(); + ctx.translate(cursorX, lineY); + ctx.fillStyle = visualState.isInactive + ? settings.inactiveTextColor + : settings.textColor; + ctx.globalAlpha = visualState.opacity; + ctx.fillText(segmentText, 0, 0); + ctx.restore(); + + cursorX += segmentWidth; + }); + }); + + this.captionTextureSource?.update(); + this.captionRenderKey = state.key; + } + + private updateCaptionLayer(timeMs: number): void { + const state = this.buildCaptionRenderState(timeMs); + if (!state || !this.captionContainer) { + if (this.captionSprite) { + this.captionSprite.visible = false; + } + if (this.captionContainer) { + this.captionContainer.visible = false; + } + this.captionRenderKey = null; + return; + } + + const needsReraster = + !this.captionSprite || + !this.captionCanvas || + this.captionCanvas.width !== Math.max(1, Math.ceil(state.boxWidth)) || + this.captionCanvas.height !== Math.max(1, Math.ceil(state.boxHeight)) || + this.captionRenderKey !== state.key; + + if (needsReraster) { + this.rasterizeCaptionSprite(state); + } + + if (!this.captionSprite) { + return; + } + + this.captionContainer.visible = true; + this.captionSprite.visible = true; + this.captionSprite.position.set(state.centerX, state.centerY + state.layout.translateY); + this.captionSprite.scale.set(state.layout.scale); + this.captionSprite.alpha = state.layout.opacity; + } + + private async syncBackgroundFrame(timeSeconds: number): Promise<void> { + if (this.backgroundForwardFrameSource) { + const duration = this.backgroundForwardFrameDurationSec; + const shouldLoop = Number.isFinite(duration) && (duration ?? 0) > 0; + let normalizedTargetTime = shouldLoop + ? ((timeSeconds % duration!) + duration!) % duration! + : Math.max(0, timeSeconds); + + if ( + shouldLoop && + this.lastSyncedBackgroundLoopTimeSec !== null && + normalizedTargetTime + 0.001 < this.lastSyncedBackgroundLoopTimeSec + ) { + try { + await this.restartBackgroundForwardFrameSource(); + } catch (error) { + console.warn( + "[FrameRenderer] Unable to restart looping video wallpaper decoder during export:", + error, + ); + } + } + + let decodedFrame: VideoFrame | null = null; + try { + decodedFrame = + await this.backgroundForwardFrameSource.getFrameAtTime(normalizedTargetTime); + } catch (error) { + console.warn( + "[FrameRenderer] Decoder-backed video wallpaper failed during export; falling back to media element sync:", + error, + ); + if (await this.fallbackBackgroundForwardFrameSourceToMediaElement()) { + await this.syncBackgroundFrame(timeSeconds); + } + return; + } + const resolvedDecodedDuration = + this.backgroundForwardFrameSource.getResolvedDurationSec(); + if ( + shouldLoop && + this.backgroundForwardFrameSource.hasReachedEndOfStream() && + Number.isFinite(resolvedDecodedDuration) && + (resolvedDecodedDuration ?? 0) > 0 && + normalizedTargetTime > (resolvedDecodedDuration ?? 0) + 0.001 + ) { + this.backgroundForwardFrameDurationSec = resolvedDecodedDuration ?? null; + this.closeBackgroundDecodedFrame(); + decodedFrame?.close(); + try { + await this.restartBackgroundForwardFrameSource(); + normalizedTargetTime = + ((timeSeconds % resolvedDecodedDuration!) + resolvedDecodedDuration!) % + resolvedDecodedDuration!; + const restartedFrame = + await this.backgroundForwardFrameSource.getFrameAtTime( + normalizedTargetTime, + ); + this.backgroundDecodedFrame = restartedFrame; + if (restartedFrame) { + this.lastSyncedBackgroundLoopTimeSec = normalizedTargetTime; + const resolvedBackgroundSource = this.stageVideoFrameForTexture( + restartedFrame, + "background", + restartedFrame.displayWidth, + restartedFrame.displayHeight, + ); + await this.ensureBackgroundSprite( + resolvedBackgroundSource, + restartedFrame.displayWidth, + restartedFrame.displayHeight, + ); + } + return; + } catch (error) { + console.warn( + "[FrameRenderer] Unable to wrap looping video wallpaper at decoded EOF during export:", + error, + ); + if (await this.fallbackBackgroundForwardFrameSourceToMediaElement()) { + await this.syncBackgroundFrame(timeSeconds); + } + return; + } + } + this.closeBackgroundDecodedFrame(); + this.backgroundDecodedFrame = decodedFrame; + if (decodedFrame) { + this.lastSyncedBackgroundLoopTimeSec = normalizedTargetTime; + const resolvedBackgroundSource = this.stageVideoFrameForTexture( + decodedFrame, + "background", + decodedFrame.displayWidth, + decodedFrame.displayHeight, + ); + await this.ensureBackgroundSprite( + resolvedBackgroundSource, + decodedFrame.displayWidth, + decodedFrame.displayHeight, + ); + } + return; + } + + const video = this.backgroundVideoElement; + if (!video) { + return; + } + + if (video.duration && Number.isFinite(video.duration)) { + const targetTime = timeSeconds % video.duration; + if (Math.abs(video.currentTime - targetTime) > 0.008) { + if (this.backgroundSeekPromise) { + await this.backgroundSeekPromise; + } + + this.backgroundSeekPromise = new Promise<void>((resolve) => { + let settled = false; + let fallbackTimeout: number | null = null; + let animationFrameRequestId: number | null = null; + let videoFrameRequestId: number | null = null; + + const finish = () => { + if (settled) { + return; + } + settled = true; + cleanup(); + resolve(); + }; + + const waitForPresentedFrame = () => { + const requestVideoFrameCallback = ( + video as HTMLVideoElement & { + requestVideoFrameCallback?: ( + callback: ( + now: DOMHighResTimeStamp, + metadata: VideoFrameCallbackMetadata, + ) => void, + ) => number; + cancelVideoFrameCallback?: (handle: number) => void; + } + ).requestVideoFrameCallback; + + animationFrameRequestId = requestAnimationFrame(() => { + animationFrameRequestId = null; + finish(); + }); + + if (typeof requestVideoFrameCallback === "function") { + videoFrameRequestId = requestVideoFrameCallback.call(video, () => { + videoFrameRequestId = null; + finish(); + }); + } + }; + + const handleMediaReady = () => { + if ( + !video.seeking && + Math.abs(video.currentTime - targetTime) <= 0.01 && + video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA + ) { + waitForPresentedFrame(); + } + }; + + const cleanup = () => { + video.removeEventListener("seeked", waitForPresentedFrame); + video.removeEventListener("loadeddata", handleMediaReady); + video.removeEventListener("canplay", handleMediaReady); + video.removeEventListener("error", finish); + if (animationFrameRequestId !== null) { + cancelAnimationFrame(animationFrameRequestId); + animationFrameRequestId = null; + } + if ( + videoFrameRequestId !== null && + typeof ( + video as HTMLVideoElement & { + cancelVideoFrameCallback?: (handle: number) => void; + } + ).cancelVideoFrameCallback === "function" + ) { + ( + video as HTMLVideoElement & { + cancelVideoFrameCallback: (handle: number) => void; + } + ).cancelVideoFrameCallback(videoFrameRequestId); + videoFrameRequestId = null; + } + if (fallbackTimeout !== null) { + window.clearTimeout(fallbackTimeout); + } + }; + + video.addEventListener("seeked", waitForPresentedFrame, { + once: true, + }); + video.addEventListener("loadeddata", handleMediaReady, { + once: true, + }); + video.addEventListener("canplay", handleMediaReady, { once: true }); + video.addEventListener("error", finish, { once: true }); + + fallbackTimeout = window.setTimeout(() => { + finish(); + }, 50); + + try { + video.currentTime = targetTime; + } catch { + finish(); + return; + } + + if ( + !video.seeking && + Math.abs(video.currentTime - targetTime) <= 0.001 && + video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA + ) { + waitForPresentedFrame(); + } + }); + + try { + await this.backgroundSeekPromise; + } finally { + this.backgroundSeekPromise = null; + } + } + } + + this.ensureBackgroundSprite(video, video.videoWidth, video.videoHeight); + } + + private async resolveWallpaperImageUrl(wallpaper: string): Promise<string> { + if ( + wallpaper.startsWith("file://") || + wallpaper.startsWith("data:") || + wallpaper.startsWith("http") + ) { + return wallpaper; + } + + const resolved = await getAssetPath(wallpaper.replace(/^\/+/, "")); + if (resolved.startsWith("/") && window.location.protocol.startsWith("http")) { + return `${window.location.origin}${resolved}`; + } + + return resolved; + } + + private async resolveWallpaperForExport(wallpaper: string): Promise<string> { + if (!wallpaper) { + return wallpaper; + } + + if (isVideoWallpaperSource(wallpaper)) { + return getExportableVideoUrl(wallpaper); + } + + if ( + wallpaper.startsWith("#") || + wallpaper.startsWith("linear-gradient") || + wallpaper.startsWith("radial-gradient") + ) { + return wallpaper; + } + + const looksLikeAbsoluteFilePath = + wallpaper.startsWith("/") && + !wallpaper.startsWith("//") && + !wallpaper.startsWith("/wallpapers/") && + !wallpaper.startsWith("/app-icons/"); + + const wallpaperAsset = looksLikeAbsoluteFilePath + ? `file://${encodeURI(wallpaper)}` + : wallpaper; + return getRenderableAssetUrl(wallpaperAsset); + } + + private async fallbackBackgroundForwardFrameSourceToMediaElement(): Promise<boolean> { + const sourceUrl = this.backgroundForwardFrameSourceUrl; + this.backgroundForwardFrameSource?.cancel(); + void this.backgroundForwardFrameSource?.destroy(); + this.backgroundForwardFrameSource = null; + this.backgroundForwardFrameSourceUrl = null; + this.backgroundForwardFrameDurationSec = null; + this.closeBackgroundDecodedFrame(); + this.lastSyncedBackgroundLoopTimeSec = null; + + return sourceUrl ? this.loadBackgroundMediaElementSource(sourceUrl, sourceUrl) : false; + } + + private async loadBackgroundMediaElementSource( + videoSrc: string, + errorLabel: string, + ): Promise<boolean> { + if (this.backgroundVideoElement) { + try { + this.backgroundVideoElement.pause(); + this.backgroundVideoElement.src = ""; + this.backgroundVideoElement.load(); + } catch { + // Ignore media element teardown errors during export fallback. + } + this.backgroundVideoElement = null; + } + this.backgroundSeekPromise = null; + this.cleanupBackgroundSource?.(); + this.cleanupBackgroundSource = null; + + let backgroundSource: Awaited<ReturnType<typeof resolveMediaElementSource>>; + try { + backgroundSource = await resolveMediaElementSource(videoSrc); + } catch (error) { + console.warn( + "[FrameRenderer] Unable to resolve video wallpaper fallback source:", + error, + ); + return false; + } + this.cleanupBackgroundSource = backgroundSource.revoke; + + const video = document.createElement("video"); + video.muted = true; + video.loop = true; + video.playsInline = true; + video.preload = "auto"; + video.src = backgroundSource.src; + video.load(); + + const ready = await new Promise<boolean>((resolve) => { + let settled = false; + let timeoutId: ReturnType<typeof setTimeout> | null = null; + + function cleanup() { + if (timeoutId !== null) { + clearTimeout(timeoutId); + } + video.removeEventListener("loadeddata", onReady); + video.removeEventListener("canplay", onReady); + video.removeEventListener("error", onError); + } + + function settle(value: boolean) { + if (settled) { + return; + } + settled = true; + cleanup(); + resolve(value); + } + function onReady() { + if (video.readyState < HTMLMediaElement.HAVE_CURRENT_DATA) { + return; + } + settle(true); + } + function onError() { + settle(false); + } + + video.addEventListener("loadeddata", onReady); + video.addEventListener("canplay", onReady); + video.addEventListener("error", onError); + timeoutId = setTimeout(() => { + console.warn( + `[FrameRenderer] Video wallpaper media element fallback did not become ready within ${BACKGROUND_MEDIA_ELEMENT_READY_TIMEOUT_MS}ms`, + ); + settle(false); + }, BACKGROUND_MEDIA_ELEMENT_READY_TIMEOUT_MS); + onReady(); + }); + + if (!ready) { + console.warn(`[FrameRenderer] Failed to load video wallpaper: ${errorLabel}`); + try { + video.pause(); + video.src = ""; + video.load(); + } catch { + // Ignore media element teardown errors on failed fallback. + } + backgroundSource.revoke(); + if (this.cleanupBackgroundSource === backgroundSource.revoke) { + this.cleanupBackgroundSource = null; + } + return false; + } + + this.backgroundVideoElement = video; + this.lastSyncedBackgroundLoopTimeSec = null; + await this.ensureBackgroundSprite(video, video.videoWidth, video.videoHeight); + return true; + } + + private disposeWebcamMediaElement(video: HTMLVideoElement): void { + try { + video.pause(); + video.src = ""; + video.load(); + } catch { + // Ignore media element teardown errors during export fallback. + } + } + + private clearWebcamMediaElement(): void { + if (this.webcamVideoElement) { + this.disposeWebcamMediaElement(this.webcamVideoElement); + } + + this.webcamVideoElement = null; + this.webcamSeekPromise = null; + this.cleanupWebcamSource?.(); + this.cleanupWebcamSource = null; + } + + private async loadWebcamMediaElementSource(webcamUrl: string): Promise<boolean> { + this.clearWebcamMediaElement(); + + let webcamSource: Awaited<ReturnType<typeof resolveMediaElementSource>>; + try { + webcamSource = await resolveMediaElementSource(webcamUrl); + } catch (error) { + console.warn("[FrameRenderer] Unable to resolve webcam media element source:", error); + return false; + } + this.cleanupWebcamSource = webcamSource.revoke; + + const video = document.createElement("video"); + video.src = webcamSource.src; + video.muted = true; + video.preload = "auto"; + video.playsInline = true; + video.load(); + + const ready = await new Promise<boolean>((resolve) => { + let readyTimeout: number | null = null; + const onReady = () => { + if (video.readyState < HTMLMediaElement.HAVE_CURRENT_DATA) { + return; + } + cleanup(); + resolve(true); + }; + const onError = () => { + cleanup(); + resolve(false); + }; + const cleanup = () => { + video.removeEventListener("loadeddata", onReady); + video.removeEventListener("canplay", onReady); + video.removeEventListener("canplaythrough", onReady); + video.removeEventListener("error", onError); + if (readyTimeout !== null) { + window.clearTimeout(readyTimeout); + readyTimeout = null; + } + }; + + if (video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) { + resolve(true); + return; + } + + video.addEventListener("loadeddata", onReady, { once: true }); + video.addEventListener("canplay", onReady, { once: true }); + video.addEventListener("canplaythrough", onReady, { once: true }); + video.addEventListener("error", onError, { once: true }); + readyTimeout = window.setTimeout(() => { + console.warn( + `[FrameRenderer] Webcam media element fallback did not become ready within ${WEBCAM_MEDIA_ELEMENT_READY_TIMEOUT_MS}ms`, + ); + onError(); + }, WEBCAM_MEDIA_ELEMENT_READY_TIMEOUT_MS); + }); + + if (ready && video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) { + this.webcamVideoElement = video; + this.lastSyncedWebcamTime = null; + return true; + } + + console.warn("[FrameRenderer] Webcam overlay unavailable during export"); + this.disposeWebcamMediaElement(video); + this.clearWebcamMediaElement(); + return false; + } + + private async setupWebcamSource(): Promise<void> { + const webcamUrl = this.config.webcamUrl; + if (!this.config.webcam?.enabled || !webcamUrl) { + this.webcamForwardFrameSource?.cancel(); + void this.webcamForwardFrameSource?.destroy(); + this.webcamForwardFrameSource = null; + this.closeWebcamDecodedFrame(); + this.clearWebcamMediaElement(); + this.webcamFrameCacheCanvas = null; + this.webcamFrameCacheCtx = null; + this.lastSyncedWebcamTime = null; + this.webcamLayoutCache = null; + this.webcamRenderMode = "hidden"; + return; + } + + this.webcamForwardFrameSource?.cancel(); + void this.webcamForwardFrameSource?.destroy(); + this.webcamForwardFrameSource = null; + this.closeWebcamDecodedFrame(); + this.clearWebcamMediaElement(); + this.webcamFrameCacheCanvas = null; + this.webcamFrameCacheCtx = null; + this.webcamLayoutCache = null; + this.webcamRenderMode = "hidden"; + + try { + const frameSource = new ForwardFrameSource(); + await frameSource.initialize(webcamUrl); + this.webcamForwardFrameSource = frameSource; + this.webcamVideoElement = null; + this.webcamSeekPromise = null; + this.lastSyncedWebcamTime = null; + return; + } catch (error) { + console.warn( + "[FrameRenderer] Decoder-backed webcam source unavailable during export; falling back to media element sync:", + error, + ); + } + + await this.loadWebcamMediaElementSource(webcamUrl); + this.lastSyncedWebcamTime = null; + } + + private ensureWebcamSprite( + source: CanvasImageSource | VideoFrame, + sourceWidth: number, + sourceHeight: number, + ): void { + if (!this.webcamContainer) { + return; + } + + const resolvedSource = + typeof VideoFrame !== "undefined" && source instanceof VideoFrame + ? this.stageVideoFrameForTexture(source, "webcam", sourceWidth, sourceHeight) + : source; + const usesStartupStaging = resolvedSource !== source; + + if (!this.webcamSprite) { + const texture = this.createTextureFromSource(resolvedSource); + this.webcamSprite = new Sprite(texture); + this.webcamTextureSource = texture.source as unknown as MutableVideoTextureSource; + this.webcamContainer.addChildAt(this.webcamSprite, 0); + this.webcamTextureUsesStartupStaging = usesStartupStaging; + } else if (this.webcamTextureUsesStartupStaging !== usesStartupStaging) { + this.webcamTextureSource = this.replaceSpriteTexture(this.webcamSprite, resolvedSource); + this.webcamTextureUsesStartupStaging = usesStartupStaging; + } else if (this.webcamTextureSource) { + this.webcamTextureSource.resource = resolvedSource; + this.webcamTextureSource.update(); + } + + if (this.webcamRootContainer) { + this.webcamRootContainer.visible = sourceWidth > 0 && sourceHeight > 0; + } + } + + private setWebcamRenderMode(nextMode: "hidden" | "live" | "cached"): void { + if (this.webcamRenderMode === nextMode) { + return; + } + + if (nextMode === "cached") { + console.log("[FrameRenderer] Webcam export source fell back to the last synced frame"); + } else if (this.webcamRenderMode === "cached" && nextMode === "live") { + console.log("[FrameRenderer] Webcam export source resynchronized"); + } + + this.webcamRenderMode = nextMode; + } + + private ensureWebcamFrameCache(width: number, height: number): boolean { + const targetWidth = Math.max(1, Math.ceil(width)); + const targetHeight = Math.max(1, Math.ceil(height)); + + if ( + this.webcamFrameCacheCanvas && + this.webcamFrameCacheCanvas.width === targetWidth && + this.webcamFrameCacheCanvas.height === targetHeight && + this.webcamFrameCacheCtx + ) { + return true; + } + + this.webcamFrameCacheCanvas = document.createElement("canvas"); + this.webcamFrameCacheCanvas.width = targetWidth; + this.webcamFrameCacheCanvas.height = targetHeight; + this.webcamFrameCacheCtx = configureHighQuality2DContext( + this.webcamFrameCacheCanvas.getContext("2d"), + ); + + return !!this.webcamFrameCacheCtx; + } + + private refreshWebcamFrameCache( + source: CanvasImageSource | VideoFrame, + width: number, + height: number, + ): boolean { + const sourceRect = getWebcamCropSourceRect(this.config.webcam?.cropRegion, width, height); + if (!this.ensureWebcamFrameCache(sourceRect.sw, sourceRect.sh)) { + return false; + } + + if (!this.webcamFrameCacheCanvas || !this.webcamFrameCacheCtx) { + return false; + } + + this.webcamFrameCacheCtx.clearRect( + 0, + 0, + this.webcamFrameCacheCanvas.width, + this.webcamFrameCacheCanvas.height, + ); + this.webcamFrameCacheCtx.drawImage( + source, + sourceRect.sx, + sourceRect.sy, + sourceRect.sw, + sourceRect.sh, + 0, + 0, + this.webcamFrameCacheCanvas.width, + this.webcamFrameCacheCanvas.height, + ); + return true; + } + + private getCachedWebcamRenderSource(): WebcamRenderSource | null { + if ( + !this.webcamFrameCacheCanvas || + this.webcamFrameCacheCanvas.width <= 0 || + this.webcamFrameCacheCanvas.height <= 0 + ) { + return null; + } + + return { + source: this.webcamFrameCacheCanvas, + width: this.webcamFrameCacheCanvas.width, + height: this.webcamFrameCacheCanvas.height, + mode: "cached", + }; + } + + private resolveRenderableWebcamSource( + liveSource: CanvasImageSource | VideoFrame | null, + liveSourceWidth: number, + liveSourceHeight: number, + canUseLiveSource: boolean, + ): WebcamRenderSource | null { + if (canUseLiveSource && liveSource && liveSourceWidth > 0 && liveSourceHeight > 0) { + const usesDefaultCropRegion = isWebcamCropRegionDefault(this.config.webcam?.cropRegion); + const needsCacheBackedSource = + !usesDefaultCropRegion || + (typeof HTMLVideoElement !== "undefined" && liveSource instanceof HTMLVideoElement); + + if (needsCacheBackedSource) { + this.refreshWebcamFrameCache(liveSource, liveSourceWidth, liveSourceHeight); + const cachedSource = this.getCachedWebcamRenderSource(); + if (cachedSource) { + this.setWebcamRenderMode("live"); + return cachedSource; + } + } + + this.setWebcamRenderMode("live"); + return { + source: liveSource, + width: liveSourceWidth, + height: liveSourceHeight, + mode: "live", + }; + } + + const cachedSource = this.getCachedWebcamRenderSource(); + if (cachedSource) { + this.setWebcamRenderMode("cached"); + return cachedSource; + } + + if (canUseLiveSource && liveSource && liveSourceWidth > 0 && liveSourceHeight > 0) { + this.setWebcamRenderMode("live"); + return { + source: liveSource, + width: liveSourceWidth, + height: liveSourceHeight, + mode: "live", + }; + } + + this.setWebcamRenderMode("hidden"); + return null; + } + + private hasMatchingWebcamLayout(nextLayout: WebcamLayoutCache): boolean { + const previousLayout = this.webcamLayoutCache; + if (!previousLayout) { + return false; + } + + return ( + previousLayout.mirror === nextLayout.mirror && + areNearlyEqual(previousLayout.sourceWidth, nextLayout.sourceWidth) && + areNearlyEqual(previousLayout.sourceHeight, nextLayout.sourceHeight) && + areNearlyEqual(previousLayout.width, nextLayout.width) && + areNearlyEqual(previousLayout.height, nextLayout.height) && + areNearlyEqual(previousLayout.positionX, nextLayout.positionX) && + areNearlyEqual(previousLayout.positionY, nextLayout.positionY) && + areNearlyEqual(previousLayout.radius, nextLayout.radius) && + areNearlyEqual(previousLayout.shadowStrength, nextLayout.shadowStrength) + ); + } + + private applyWebcamLayout(nextLayout: WebcamLayoutCache): void { + if (!this.webcamRootContainer || !this.webcamSprite || !this.webcamMaskGraphics) { + return; + } + + this.webcamRootContainer.position.set(nextLayout.positionX, nextLayout.positionY); + + applyCoverLayoutToSprite( + this.webcamSprite, + nextLayout.sourceWidth, + nextLayout.sourceHeight, + nextLayout.width, + nextLayout.height, + nextLayout.width / 2, + nextLayout.height / 2, + nextLayout.mirror, + ); + + this.webcamMaskGraphics.clear(); + drawSquircleOnGraphics(this.webcamMaskGraphics, { + x: 0, + y: 0, + width: nextLayout.width, + height: nextLayout.height, + radius: nextLayout.radius, + }); + this.webcamMaskGraphics.fill({ color: 0xffffff }); + + for (const layer of this.webcamShadowLayers) { + if (nextLayout.shadowStrength <= 0) { + layer.container.visible = false; + continue; + } + + const shadowSize = Math.min(nextLayout.width, nextLayout.height); + const offsetY = shadowSize * layer.offsetScale * nextLayout.shadowStrength; + this.rasterizeShadowLayer(layer, { + x: 0, + y: 0, + width: nextLayout.width, + height: nextLayout.height, + radius: nextLayout.radius, + offsetY, + alpha: layer.alphaScale * nextLayout.shadowStrength, + blur: Math.max(0, shadowSize * layer.blurScale * nextLayout.shadowStrength), + }); + } + + this.webcamLayoutCache = { ...nextLayout }; + } + + private getExpectedWebcamTargetTimeSeconds(targetTime: number): number { + return getWebcamMediaTargetTimeSeconds({ + currentTime: targetTime, + webcamDuration: Number.isFinite(this.webcamVideoElement?.duration) + ? this.webcamVideoElement?.duration + : null, + timeOffsetMs: this.config.webcam?.timeOffsetMs, + }); + } + + private async syncWebcamFrame(targetTime: number): Promise<void> { + const webcamTargetTime = this.getExpectedWebcamTargetTimeSeconds(targetTime); + + if (this.webcamForwardFrameSource) { + const clampedTime = clampMediaTimeToDuration(webcamTargetTime, null); + try { + const decodedFrame = + await this.webcamForwardFrameSource.getFrameAtTime(clampedTime); + this.closeWebcamDecodedFrame(); + this.webcamDecodedFrame = decodedFrame; + if (decodedFrame) { + this.lastSyncedWebcamTime = clampedTime; + } + return; + } catch (error) { + console.warn( + "[FrameRenderer] Decoder-backed webcam source failed during export; falling back to media element sync:", + error, + ); + this.webcamForwardFrameSource.cancel(); + void this.webcamForwardFrameSource.destroy(); + this.webcamForwardFrameSource = null; + this.closeWebcamDecodedFrame(); + this.lastSyncedWebcamTime = null; + const webcamUrl = this.config.webcamUrl; + if (!webcamUrl || !(await this.loadWebcamMediaElementSource(webcamUrl))) { + return; + } + } + } + + const webcamVideo = this.webcamVideoElement; + if (!webcamVideo) { + return; + } + + const clampedTime = clampMediaTimeToDuration( + webcamTargetTime, + Number.isFinite(webcamVideo.duration) ? webcamVideo.duration : null, + ); + + if (Math.abs(webcamVideo.currentTime - clampedTime) <= 0.008) { + this.lastSyncedWebcamTime = clampedTime; + return; + } + + if (this.webcamSeekPromise) { + await this.webcamSeekPromise; + } + + this.webcamSeekPromise = new Promise<void>((resolve) => { + let settled = false; + let fallbackTimeout: number | null = null; + let animationFrameRequestId: number | null = null; + let videoFrameRequestId: number | null = null; + + const waitForPresentedFrame = () => { + const requestVideoFrameCallback = ( + webcamVideo as HTMLVideoElement & { + requestVideoFrameCallback?: ( + callback: ( + now: DOMHighResTimeStamp, + metadata: VideoFrameCallbackMetadata, + ) => void, + ) => number; + cancelVideoFrameCallback?: (handle: number) => void; + } + ).requestVideoFrameCallback; + + const scheduleAnimationFrameFinish = () => { + animationFrameRequestId = requestAnimationFrame(() => { + animationFrameRequestId = null; + finish(); + }); + }; + + scheduleAnimationFrameFinish(); + + if (typeof requestVideoFrameCallback === "function") { + videoFrameRequestId = requestVideoFrameCallback.call(webcamVideo, () => { + videoFrameRequestId = null; + finish(); + }); + } + }; + + const finish = () => { + if (settled) { + return; + } + settled = true; + if (Math.abs(webcamVideo.currentTime - clampedTime) <= 0.02) { + this.lastSyncedWebcamTime = clampedTime; + } + cleanup(); + resolve(); + }; + + const handleMediaReady = () => { + if ( + !webcamVideo.seeking && + Math.abs(webcamVideo.currentTime - clampedTime) <= 0.01 && + webcamVideo.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA + ) { + waitForPresentedFrame(); + } + }; + + const cleanup = () => { + webcamVideo.removeEventListener("seeked", waitForPresentedFrame); + webcamVideo.removeEventListener("loadeddata", handleMediaReady); + webcamVideo.removeEventListener("canplay", handleMediaReady); + webcamVideo.removeEventListener("error", finish); + if (animationFrameRequestId !== null) { + cancelAnimationFrame(animationFrameRequestId); + animationFrameRequestId = null; + } + if ( + videoFrameRequestId !== null && + typeof ( + webcamVideo as HTMLVideoElement & { + cancelVideoFrameCallback?: (handle: number) => void; + } + ).cancelVideoFrameCallback === "function" + ) { + ( + webcamVideo as HTMLVideoElement & { + cancelVideoFrameCallback: (handle: number) => void; + } + ).cancelVideoFrameCallback(videoFrameRequestId); + videoFrameRequestId = null; + } + if (fallbackTimeout !== null) { + window.clearTimeout(fallbackTimeout); + } + }; + + webcamVideo.addEventListener("seeked", waitForPresentedFrame, { + once: true, + }); + webcamVideo.addEventListener("loadeddata", handleMediaReady, { + once: true, + }); + webcamVideo.addEventListener("canplay", handleMediaReady, { once: true }); + webcamVideo.addEventListener("error", finish, { once: true }); + + fallbackTimeout = window.setTimeout(() => { + finish(); + }, 50); + + try { + webcamVideo.currentTime = clampedTime; + } catch { + finish(); + return; + } + + if ( + !webcamVideo.seeking && + Math.abs(webcamVideo.currentTime - clampedTime) <= 0.001 && + webcamVideo.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA + ) { + waitForPresentedFrame(); + } + }); + + try { + await this.webcamSeekPromise; + } finally { + this.webcamSeekPromise = null; + } + } + + private updateWebcamOverlay(referenceTimeSeconds = this.currentVideoTime): void { + const webcam = this.config.webcam; + if (!webcam?.enabled || !this.webcamRootContainer || !this.webcamMaskGraphics) { + if (this.webcamRootContainer) { + this.webcamRootContainer.visible = false; + } + this.webcamLayoutCache = null; + this.setWebcamRenderMode("hidden"); + return; + } + + const webcamSource = this.webcamDecodedFrame ?? this.webcamVideoElement; + const liveSourceDimensions = webcamSource + ? this.getWebcamSourceDimensions(webcamSource) + : { width: 0, height: 0 }; + const activeWebcamVideoElement = + webcamSource === this.webcamVideoElement ? this.webcamVideoElement : null; + const expectedWebcamTargetTime = + this.getExpectedWebcamTargetTimeSeconds(referenceTimeSeconds); + const measuredWebcamTime = + activeWebcamVideoElement && Number.isFinite(activeWebcamVideoElement.currentTime) + ? activeWebcamVideoElement.currentTime + : this.lastSyncedWebcamTime; + const webcamTimeDrift = + measuredWebcamTime === null + ? 0 + : Math.abs(measuredWebcamTime - expectedWebcamTargetTime); + const canUseLiveSource = + !!webcamSource && + liveSourceDimensions.width > 0 && + liveSourceDimensions.height > 0 && + webcamTimeDrift <= 0.08 && + (!activeWebcamVideoElement || + (activeWebcamVideoElement.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA && + !activeWebcamVideoElement.seeking)); + const renderableWebcamSource = this.resolveRenderableWebcamSource( + webcamSource, + liveSourceDimensions.width, + liveSourceDimensions.height, + canUseLiveSource, + ); + + if (!renderableWebcamSource) { + this.webcamRootContainer.visible = false; + this.webcamLayoutCache = null; + return; + } + + this.ensureWebcamSprite( + renderableWebcamSource.source, + renderableWebcamSource.width, + renderableWebcamSource.height, + ); + if (!this.webcamSprite) { + this.webcamRootContainer.visible = false; + return; + } + + const margin = scaleWebcamOverlayPixels(webcam.margin ?? 24, this.config.width); + const widthPercent = webcam.width ?? webcam.size ?? 50; + const aspectSourceWidth = + liveSourceDimensions.width > 0 + ? liveSourceDimensions.width + : renderableWebcamSource.width; + const aspectSourceHeight = + liveSourceDimensions.height > 0 + ? liveSourceDimensions.height + : renderableWebcamSource.height; + const heightPercent = getCropMatchedWebcamHeightPercent( + widthPercent, + webcam.height ?? webcam.size ?? 50, + aspectSourceWidth, + aspectSourceHeight, + webcam.cropRegion, + ); + const dimensions = getWebcamOverlayDimensionsPx({ + containerWidth: this.config.width, + containerHeight: this.config.height, + widthPercent, + heightPercent, + margin, + zoomScale: this.animationState.appliedScale || 1, + reactToZoom: webcam.reactToZoom ?? true, + }); + const position = getWebcamOverlayPosition({ + containerWidth: this.config.width, + containerHeight: this.config.height, + width: dimensions.width, + height: dimensions.height, + margin, + positionPreset: webcam.positionPreset ?? webcam.corner, + positionX: webcam.positionX ?? 1, + positionY: webcam.positionY ?? 1, + legacyCorner: webcam.corner, + }); + const radius = getWebcamCornerRadiusPx( + webcam.roundness ?? DEFAULT_WEBCAM_ROUNDNESS, + dimensions.width, + dimensions.height, + ); + const shadowStrength = getWebcamShadowStrength(webcam.shadow ?? 0); + + this.webcamRootContainer.visible = true; + + const nextLayout: WebcamLayoutCache = { + sourceWidth: renderableWebcamSource.width, + sourceHeight: renderableWebcamSource.height, + width: dimensions.width, + height: dimensions.height, + positionX: position.x, + positionY: position.y, + radius, + shadowStrength, + mirror: webcam.mirror, + }; + + if (!this.hasMatchingWebcamLayout(nextLayout)) { + this.applyWebcamLayout(nextLayout); + } + } + + private async renderSceneSample( + timestamp: number, + cursorTimestamp: number, + backgroundTimelineTimestamp: number, + layoutCache: LayoutCache, + useVelocityMotionBlur: boolean, + includeOverlayLayers = true, + webcamTimeSecondsOverride?: number, + ): Promise<RenderSnapshot> { + if (!this.app || !this.cameraContainer || !this.videoMaskGraphics) { + throw new Error("Renderer not initialized"); + } + + this.currentVideoTime = timestamp / 1_000_000; + const webcamRenderTimeSeconds = Math.max( + 0, + webcamTimeSecondsOverride ?? this.currentVideoTime, + ); + + if (this.webcamForwardFrameSource || this.webcamVideoElement) { + await this.syncWebcamFrame(webcamRenderTimeSeconds); + } + + if (this.backgroundForwardFrameSource || this.backgroundVideoElement) { + await this.syncBackgroundFrame(Math.max(0, backgroundTimelineTimestamp / 1_000_000)); + } + + const timeMs = this.currentVideoTime * 1000; + const cursorTimeMs = cursorTimestamp / 1000; + + if (this.cursorOverlay) { + this.cursorOverlay.update( + this.config.cursorTelemetry ?? [], + cursorTimeMs, + layoutCache.maskRect, + this.config.showCursor ?? true, + false, + ); + } + + this.updateAnimationState(timeMs); + + applyZoomTransform({ + cameraContainer: this.cameraContainer, + zoomBlurFilter: this.zoomBlurFilter, + motionBlurFilter: this.motionBlurFilter, + stageSize: layoutCache.stageSize, + baseMask: layoutCache.maskRect, + zoomScale: this.animationState.scale, + zoomProgress: this.animationState.progress, + focusX: this.animationState.focusX, + focusY: this.animationState.focusY, + isPlaying: true, + motionBlurAmount: useVelocityMotionBlur ? (this.config.zoomMotionBlur ?? 0) : 0, + motionBlurTuning: this.config.zoomMotionBlurTuning, + transformOverride: { + scale: this.animationState.appliedScale, + x: this.animationState.x, + y: this.animationState.y, + }, + motionBlurState: this.motionBlurState, + frameTimeMs: timeMs, + }); + + if (includeOverlayLayers) { + this.updateAnnotationLayer(timeMs); + this.updateCaptionLayer(timeMs); + } + this.updateWebcamOverlay(webcamRenderTimeSeconds); + + const annotationContainerVisible = this.annotationContainer?.visible ?? true; + const captionContainerVisible = this.captionContainer?.visible ?? true; + if (!includeOverlayLayers) { + if (this.annotationContainer) { + this.annotationContainer.visible = false; + } + if (this.captionContainer) { + this.captionContainer.visible = false; + } + } + + this.app.render(); + + if (!includeOverlayLayers) { + if (this.annotationContainer) { + this.annotationContainer.visible = annotationContainerVisible; + } + if (this.captionContainer) { + this.captionContainer.visible = captionContainerVisible; + } + } + + return { + timeMs, + cursorTimeMs, + backgroundTimelineTimeMs: backgroundTimelineTimestamp / 1000, + sceneTransform: { + scale: this.animationState.appliedScale, + x: this.animationState.x, + y: this.animationState.y, + }, + zoom: { + scale: this.animationState.scale, + focusX: this.animationState.focusX, + focusY: this.animationState.focusY, + progress: this.animationState.progress, + }, + }; + } + + private async renderTemporalMotionBlurFrame( + timestamp: number, + cursorTimestamp: number, + backgroundTimelineTimestamp: number, + frameDurationUs: number, + layoutCache: LayoutCache, + ): Promise<RenderSnapshot | null> { + if (!this.app) { + return null; + } + + const blurConfig = getTemporalMotionBlurConfig(this.config.zoomTemporalMotionBlur, { + sampleCount: this.config.zoomMotionBlurSampleCount, + shutterFraction: this.config.zoomMotionBlurShutterFraction, + }); + if (!blurConfig) { + return null; + } + + const compositeState = this.ensureTemporalCompositeCanvas(); + if (!compositeState) { + return null; + } + + const samplePlan = buildTemporalSamplePlanUs(frameDurationUs, blurConfig); + const webcamReferenceTimeSeconds = Math.max(0, timestamp / 1_000_000); + + compositeState.context.clearRect( + 0, + 0, + compositeState.canvas.width, + compositeState.canvas.height, + ); + + let centerSnapshot: RenderSnapshot | null = null; + let lastSnapshot: RenderSnapshot | null = null; + + for (const { offsetUs: sampleOffsetUs, weight } of samplePlan) { + const sampleTimestamp = Math.max(0, timestamp + sampleOffsetUs); + const sampleCursorTimestamp = Math.max(0, cursorTimestamp + sampleOffsetUs); + const sampleBackgroundTimelineTimestamp = Math.max( + 0, + backgroundTimelineTimestamp + sampleOffsetUs, + ); + const snapshot = await this.renderSceneSample( + sampleTimestamp, + sampleCursorTimestamp, + sampleBackgroundTimelineTimestamp, + layoutCache, + false, + false, + webcamReferenceTimeSeconds, + ); + lastSnapshot = snapshot; + if (Math.abs(sampleOffsetUs) < 0.0001) { + centerSnapshot = snapshot; + } + + compositeState.context.save(); + compositeState.context.globalCompositeOperation = "lighter"; + compositeState.context.globalAlpha = weight; + compositeState.context.drawImage(this.app.canvas as HTMLCanvasElement, 0, 0); + compositeState.context.restore(); + } + + const resolvedSnapshot = centerSnapshot ?? lastSnapshot; + if (!resolvedSnapshot) { + return null; + } + + this.updateCaptionLayer(resolvedSnapshot.timeMs); + + const hasOverlayCanvasWork = + (this.config.annotationRegions?.length ?? 0) > 0 || + Boolean(this.captionCanvas && this.captionSprite?.visible); + if (hasOverlayCanvasWork) { + await this.composeBlurAnnotationFrame( + resolvedSnapshot.timeMs, + resolvedSnapshot.sceneTransform, + compositeState.canvas, + ); + } else { + this.outputCanvasOverride = compositeState.canvas; + } + + return resolvedSnapshot; + } + + async renderFrame( + videoFrame: VideoFrame, + timestamp: number, + cursorTimestamp = timestamp, + frameDurationUs?: number, + backgroundTimelineTimestamp = timestamp, + ): Promise<void> { + if (!this.app || !this.videoContainer || !this.cameraContainer || !this.videoMaskGraphics) { + throw new Error("Renderer not initialized"); + } + + this.currentVideoTime = timestamp / 1_000_000; + + const resolvedVideoSource = await this.resolveDetachedVideoFrameSource( + videoFrame, + "scene", + this.config.videoWidth, + this.config.videoHeight, + ); + const usesStartupStaging = resolvedVideoSource !== videoFrame; + + if (!this.videoSprite) { + const texture = this.createTextureFromSource(resolvedVideoSource); + this.videoSprite = new Sprite(texture); + this.videoTextureSource = texture.source as unknown as MutableVideoTextureSource; + this.videoContainer.addChildAt(this.videoSprite, 0); + this.videoTextureUsesStartupStaging = usesStartupStaging; + } else if (this.videoTextureUsesStartupStaging !== usesStartupStaging) { + this.videoTextureSource = this.replaceSpriteTexture( + this.videoSprite, + resolvedVideoSource, + ); + this.videoTextureUsesStartupStaging = usesStartupStaging; + } else if (this.videoTextureSource) { + this.videoTextureSource.resource = resolvedVideoSource; + this.videoTextureSource.update(); + } + + if (!this.layoutCache) { + this.updateLayout(); + } + const layoutCache = this.layoutCache; + if (!layoutCache) { + throw new Error("Renderer layout cache is unavailable"); + } + + const temporalSnapshot = + TEMPORAL_ZOOM_MOTION_BLUR_ENABLED && + (this.config.zoomTemporalMotionBlur ?? 0) > 0 && + typeof frameDurationUs === "number" && + frameDurationUs > 0 + ? await this.renderTemporalMotionBlurFrame( + timestamp, + cursorTimestamp, + backgroundTimelineTimestamp, + frameDurationUs, + layoutCache, + ) + : null; + + if (temporalSnapshot) { + return; + } + + if (this.webcamForwardFrameSource || this.webcamVideoElement) { + await this.syncWebcamFrame(Math.max(0, this.currentVideoTime)); + } + + if (this.backgroundForwardFrameSource || this.backgroundVideoElement) { + await this.syncBackgroundFrame(Math.max(0, backgroundTimelineTimestamp / 1_000_000)); + } + + const timeMs = this.currentVideoTime * 1000; + const cursorTimeMs = cursorTimestamp / 1000; + + if (this.cursorOverlay) { + this.cursorOverlay.update( + this.config.cursorTelemetry ?? [], + cursorTimeMs, + layoutCache.maskRect, + this.config.showCursor ?? true, + false, + ); + } + + this.updateAnimationState(timeMs); + + applyZoomTransform({ + cameraContainer: this.cameraContainer, + zoomBlurFilter: this.zoomBlurFilter, + motionBlurFilter: this.motionBlurFilter, + stageSize: layoutCache.stageSize, + baseMask: layoutCache.maskRect, + zoomScale: this.animationState.scale, + zoomProgress: this.animationState.progress, + focusX: this.animationState.focusX, + focusY: this.animationState.focusY, + isPlaying: true, + motionBlurAmount: this.config.zoomMotionBlur ?? 0, + motionBlurTuning: this.config.zoomMotionBlurTuning, + transformOverride: { + scale: this.animationState.appliedScale, + x: this.animationState.x, + y: this.animationState.y, + }, + motionBlurState: this.motionBlurState, + frameTimeMs: timeMs, + }); + + this.updateAnnotationLayer(timeMs); + this.updateCaptionLayer(timeMs); + this.updateWebcamOverlay(); + + if (this.hasActiveBlurAnnotations(timeMs)) { + const annotationContainerVisible = this.annotationContainer?.visible ?? true; + const captionContainerVisible = this.captionContainer?.visible ?? true; + + if (this.annotationContainer) { + this.annotationContainer.visible = false; + } + if (this.captionContainer) { + this.captionContainer.visible = false; + } + + this.app.render(); + + if (this.annotationContainer) { + this.annotationContainer.visible = annotationContainerVisible; + } + if (this.captionContainer) { + this.captionContainer.visible = captionContainerVisible; + } + + await this.composeBlurAnnotationFrame(timeMs); + return; + } + + this.outputCanvasOverride = null; + this.app.render(); + } + + private updateLayout(): void { + if (!this.app || !this.videoSprite || !this.videoMaskGraphics) return; + + const { + width, + height, + cropRegion, + borderRadius = 0, + padding = 0, + videoWidth, + videoHeight, + } = this.config; + + const layout = computePaddedLayout({ + width, + height, + padding, + frameInsets: null, + cropRegion, + videoWidth, + videoHeight, + }); + + this.videoSprite.scale.set(layout.scale); + this.videoSprite.position.set(layout.spriteX, layout.spriteY); + + const scaledBorderRadius = scalePreviewBorderRadius(width, height, borderRadius); + + this.videoMaskGraphics.clear(); + drawSquircleOnGraphics(this.videoMaskGraphics, { + x: layout.centerOffsetX, + y: layout.centerOffsetY, + width: layout.croppedDisplayWidth, + height: layout.croppedDisplayHeight, + radius: scaledBorderRadius, + }); + this.videoMaskGraphics.fill({ color: 0xffffff }); + + this.updateVideoShadowLayout({ + maskX: layout.centerOffsetX, + maskY: layout.centerOffsetY, + maskWidth: layout.croppedDisplayWidth, + maskHeight: layout.croppedDisplayHeight, + maskRadius: scaledBorderRadius, + }); + + this.layoutCache = { + stageSize: { width, height }, + videoSize: { + width: videoWidth * cropRegion.width, + height: videoHeight * cropRegion.height, + }, + baseScale: layout.scale, + baseOffset: { x: layout.spriteX, y: layout.spriteY }, + maskRect: { + x: layout.centerOffsetX, + y: layout.centerOffsetY, + width: layout.croppedDisplayWidth, + height: layout.croppedDisplayHeight, + sourceCrop: cropRegion, + }, + }; + } + + private updateVideoShadowLayout(layout: { + maskX: number; + maskY: number; + maskWidth: number; + maskHeight: number; + maskRadius: number; + }): void { + const shadowStrength = clampUnitInterval(this.config.shadowIntensity); + for (const layer of this.videoShadowLayers) { + if (!this.config.showShadow || shadowStrength <= 0) { + layer.container.visible = false; + continue; + } + + const offsetY = layer.offsetScale * shadowStrength; + this.rasterizeShadowLayer(layer, { + x: layout.maskX, + y: layout.maskY, + width: layout.maskWidth, + height: layout.maskHeight, + radius: layout.maskRadius, + offsetY, + alpha: layer.alphaScale * shadowStrength, + blur: Math.max(0, layer.blurScale * shadowStrength), + }); + } + } + + private updateAnimationState(timeMs: number): number { + if (!this.cameraContainer || !this.layoutCache) { + return 0; + } + + const { region, strength, blendedScale } = findDominantRegion( + this.config.zoomRegions, + timeMs, + { + connectZooms: this.config.connectZooms, + zoomInDurationMs: this.config.zoomInDurationMs, + zoomOutDurationMs: this.config.zoomOutDurationMs, + }, + ); + + let targetScaleFactor = 1; + let targetFocus = { ...DEFAULT_FOCUS }; + let targetProgress = 0; + + if (region && strength > 0) { + const zoomScale = blendedScale ?? ZOOM_DEPTH_SCALES[region.depth]; + + // Cursor follow: use cursor-follow camera for non-manual zoom regions + let regionFocus = region.focus; + if ( + !this.config.zoomClassicMode && + region.mode !== "manual" && + this.config.cursorTelemetry && + this.config.cursorTelemetry.length > 0 + ) { + regionFocus = computeCursorFollowFocus( + this.cursorFollowCamera, + this.config.cursorTelemetry, + timeMs, + zoomScale, + strength, + region.focus, + { snapToEdgesRatio: SNAP_TO_EDGES_RATIO_AUTO }, + ); + } + + targetScaleFactor = zoomScale; + targetFocus = regionFocus; + targetProgress = strength; + } + + const state = this.animationState; + const previousScale = state.appliedScale; + const previousX = state.x; + const previousY = state.y; + + state.scale = targetScaleFactor; + state.focusX = targetFocus.cx; + state.focusY = targetFocus.cy; + state.progress = targetProgress; + + const projectedTransform = computeZoomTransform({ + stageSize: this.layoutCache.stageSize, + baseMask: this.layoutCache.maskRect, + zoomScale: state.scale, + zoomProgress: state.progress, + focusX: state.focusX, + focusY: state.focusY, + }); + + // Spring-driven zoom animation for export — use content time, not wall-clock, + // so the spring advances at the same rate as the video regardless of render speed. + const deltaMs = + this.lastContentTimeMs !== null ? timeMs - this.lastContentTimeMs : 1000 / 60; + this.lastContentTimeMs = timeMs; + + const zoomSpringConfig = getZoomSpringConfig(this.config.zoomSmoothness, { + stiffnessMultiplier: this.config.cameraSpringStiffnessMultiplier, + dampingMultiplier: this.config.cameraSpringDampingMultiplier, + massMultiplier: this.config.cameraSpringMassMultiplier, + }); + + if (this.config.zoomClassicMode) { + state.appliedScale = projectedTransform.scale; + state.x = projectedTransform.x; + state.y = projectedTransform.y; + resetSpringState(this.springScale, state.appliedScale); + resetSpringState(this.springX, state.x); + resetSpringState(this.springY, state.y); + } else { + state.appliedScale = stepSpringValue( + this.springScale, + projectedTransform.scale, + deltaMs, + zoomSpringConfig, + ); + state.x = stepSpringValue( + this.springX, + projectedTransform.x, + deltaMs, + zoomSpringConfig, + ); + state.y = stepSpringValue( + this.springY, + projectedTransform.y, + deltaMs, + zoomSpringConfig, + ); + } + + return Math.max( + Math.abs(state.appliedScale - previousScale), + Math.abs(state.x - previousX) / Math.max(1, this.layoutCache.stageSize.width), + Math.abs(state.y - previousY) / Math.max(1, this.layoutCache.stageSize.height), + ); + } + + private closeWebcamDecodedFrame(): void { + if (!this.webcamDecodedFrame) { + return; + } + + this.webcamDecodedFrame.close(); + this.webcamDecodedFrame = null; + } + + getCanvas(): HTMLCanvasElement { + if (!this.app) { + throw new Error("Renderer not initialized"); + } + + return this.outputCanvasOverride ?? (this.app.canvas as HTMLCanvasElement); + } + + capturePixelsForNativeExport(): Uint8ClampedArray | null { + if (!this.app) { + return null; + } + + const finalCanvas = this.outputCanvasOverride; + + if (finalCanvas) { + const context = finalCanvas.getContext("2d"); + return context + ? context.getImageData(0, 0, finalCanvas.width, finalCanvas.height).data + : null; + } + + const result = this.app.renderer.extract.pixels(this.app.stage); + const pixels = result.pixels; + + return pixels instanceof Uint8ClampedArray ? pixels : new Uint8ClampedArray(pixels); + } + + getRendererBackend(): ExportRenderBackend { + return this.rendererBackend; + } + + destroy(): void { + const texturesToDestroy = new Set<Texture>(); + if (this.videoSprite?.texture) { + texturesToDestroy.add(this.videoSprite.texture); + } + if (this.backgroundSprite?.texture) { + texturesToDestroy.add(this.backgroundSprite.texture); + } + if (this.webcamSprite?.texture) { + texturesToDestroy.add(this.webcamSprite.texture); + } + if (this.captionSprite?.texture) { + texturesToDestroy.add(this.captionSprite.texture); + } + for (const layer of this.videoShadowLayers) { + if (layer.sprite?.texture) { + texturesToDestroy.add(layer.sprite.texture); + } + } + for (const layer of this.webcamShadowLayers) { + if (layer.sprite?.texture) { + texturesToDestroy.add(layer.sprite.texture); + } + } + for (const entry of this.annotationSprites) { + texturesToDestroy.add(entry.texture); + } + + if (this.cursorOverlay) { + this.cursorOverlay.destroy(); + this.cursorOverlay = null; + } + + if (this.videoEffectsContainer) { + this.videoEffectsContainer.filters = null; + } + if (this.backgroundSprite) { + this.backgroundSprite.filters = null; + } + this.zoomBlurFilter?.destroy(); + this.motionBlurFilter?.destroy(); + this.backgroundBlurFilter?.destroy(); + + destroyPixiApplication(this.app, "Lightning export renderer"); + + for (const texture of texturesToDestroy) { + try { + texture.destroy(true); + } catch (error) { + console.warn("[FrameRenderer] Failed to destroy texture during cleanup:", error); + } + } + + this.app = null; + this.backgroundContainer = null; + this.cameraContainer = null; + this.videoEffectsContainer = null; + this.videoContainer = null; + this.cursorContainer = null; + this.overlayContainer = null; + this.annotationContainer = null; + this.captionContainer = null; + this.webcamRootContainer = null; + this.webcamContainer = null; + this.videoSprite = null; + this.videoTextureSource = null; + this.backgroundSprite = null; + this.backgroundTextureSource = null; + this.videoMaskGraphics = null; + this.webcamMaskGraphics = null; + this.zoomBlurFilter = null; + this.motionBlurFilter = null; + this.backgroundBlurFilter = null; + this.annotationAssets = null; + this.annotationSprites = []; + this.videoShadowLayers = []; + this.webcamShadowLayers = []; + this.webcamSprite = null; + this.webcamTextureSource = null; + + this.closeBackgroundDecodedFrame(); + this.backgroundForwardFrameSource?.cancel(); + void this.backgroundForwardFrameSource?.destroy(); + this.backgroundForwardFrameSource = null; + this.backgroundForwardFrameSourceUrl = null; + this.backgroundForwardFrameDurationSec = null; + this.lastSyncedBackgroundLoopTimeSec = null; + if (this.backgroundVideoElement) { + this.backgroundVideoElement.pause(); + this.backgroundVideoElement.src = ""; + this.backgroundVideoElement.load(); + this.backgroundVideoElement = null; + } + + this.webcamForwardFrameSource?.cancel(); + void this.webcamForwardFrameSource?.destroy(); + this.webcamForwardFrameSource = null; + this.closeWebcamDecodedFrame(); + if (this.webcamVideoElement) { + this.webcamVideoElement.pause(); + this.webcamVideoElement.src = ""; + this.webcamVideoElement.load(); + this.webcamVideoElement = null; + } + this.cleanupWebcamSource?.(); + this.cleanupWebcamSource = null; + this.webcamFrameCacheCanvas = null; + this.webcamFrameCacheCtx = null; + this.sceneVideoFrameStagingCanvas = null; + this.sceneVideoFrameStagingCtx = null; + this.webcamVideoFrameStagingCanvas = null; + this.webcamVideoFrameStagingCtx = null; + this.videoTextureUsesStartupStaging = false; + this.webcamTextureUsesStartupStaging = false; + this.closeRetainedVideoFrame("scene"); + this.closeRetainedVideoFrame("background"); + this.closeRetainedVideoFrame("webcam"); + this.closeRetainedBitmap("scene"); + this.closeRetainedBitmap("background"); + + this.captionCanvas = null; + this.captionCtx = null; + this.captionMeasureCanvas = null; + this.captionMeasureCtx = null; + this.captionSprite = null; + this.captionTextureSource = null; + this.captionRenderKey = null; + this.exportCompositeCanvas = null; + this.temporalCompositeCanvas = null; + this.outputCanvasOverride = null; + + this.annotationScaleFactor = 1; + this.lastSyncedWebcamTime = null; + this.webcamRenderMode = "hidden"; + this.webcamLayoutCache = null; + this.layoutCache = null; + } +} diff --git a/src/lib/exporter/modernVideoExporter.fallback.test.ts b/src/lib/exporter/modernVideoExporter.fallback.test.ts new file mode 100644 index 000000000..e1afc68c2 --- /dev/null +++ b/src/lib/exporter/modernVideoExporter.fallback.test.ts @@ -0,0 +1,340 @@ +import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import type { ModernVideoExporter as ModernVideoExporterClass } from "./modernVideoExporter"; + +const mocks = vi.hoisted(() => { + const videoInfo = { + width: 1920, + height: 1080, + duration: 1, + streamDuration: 1, + frameRate: 30, + codec: "h264", + hasAudio: false, + audioCodec: null, + audioSampleRate: null, + }; + + return { + videoInfo, + streamingDecoderDestroy: vi.fn(), + streamingDecoderCancel: vi.fn(), + streamingDecoderDecodeAll: vi.fn(async () => {}), + streamingDecoderGetDemuxer: vi.fn(() => null), + streamingDecoderGetEffectiveDuration: vi.fn(() => 0), + streamingDecoderLoadMetadata: vi.fn(async () => videoInfo), + frameRendererDestroy: vi.fn(), + frameRendererGetBackend: vi.fn(() => "webgl"), + frameRendererInitialize: vi.fn(async () => {}), + muxerDestroy: vi.fn(), + muxerFinalize: vi.fn(async () => ({ + mode: "buffer" as const, + blob: new Blob([], { type: "video/mp4" }), + })), + muxerInitialize: vi.fn(async () => {}), + }; +}); + +vi.mock("./streamingDecoder", () => ({ + StreamingVideoDecoder: vi.fn().mockImplementation(function () { + return { + cancel: mocks.streamingDecoderCancel, + decodeAll: mocks.streamingDecoderDecodeAll, + destroy: mocks.streamingDecoderDestroy, + getDemuxer: mocks.streamingDecoderGetDemuxer, + getEffectiveDuration: mocks.streamingDecoderGetEffectiveDuration, + loadMetadata: mocks.streamingDecoderLoadMetadata, + }; + }), +})); + +vi.mock("./modernFrameRenderer", () => ({ + FrameRenderer: vi.fn().mockImplementation(function () { + return { + destroy: mocks.frameRendererDestroy, + getRendererBackend: mocks.frameRendererGetBackend, + initialize: mocks.frameRendererInitialize, + }; + }), +})); + +vi.mock("./muxer", () => ({ + VideoMuxer: vi.fn().mockImplementation(function () { + return { + destroy: mocks.muxerDestroy, + finalize: mocks.muxerFinalize, + initialize: mocks.muxerInitialize, + }; + }), +})); + +describe("ModernVideoExporter native fallback routing", () => { + let ModernVideoExporter: typeof ModernVideoExporterClass; + + beforeAll(async () => { + ({ ModernVideoExporter } = await import("./modernVideoExporter")); + }, 30_000); + + afterEach(() => { + vi.clearAllMocks(); + vi.unstubAllGlobals(); + }); + + it("falls back to WebCodecs instead of surfacing a native error when Breeze is unavailable", async () => { + const exporter = new ModernVideoExporter({ + videoUrl: "file:///recording.mp4", + width: 1920, + height: 1080, + frameRate: 30, + bitrate: 8_000_000, + wallpaper: "#101010", + padding: 0, + borderRadius: 0, + backgroundBlur: 0, + shadowIntensity: 0, + showShadow: false, + cropRegion: { x: 0, y: 0, width: 1, height: 1 }, + experimentalNativeExport: true, + backendPreference: "breeze", + } as never) as unknown as { + export: () => Promise<{ success: boolean; blob?: Blob; error?: string }>; + initializeEncoder: () => Promise<unknown>; + loadNativeStaticLayoutVideoInfo: () => Promise<unknown>; + tryExportNativeStaticLayout: () => Promise<unknown>; + tryStartNativeVideoExport: () => Promise<boolean>; + lastNativeExportError: string | null; + }; + + vi.spyOn(exporter, "loadNativeStaticLayoutVideoInfo").mockResolvedValue(mocks.videoInfo); + vi.spyOn(exporter, "tryExportNativeStaticLayout").mockResolvedValue(null); + vi.spyOn(exporter, "tryStartNativeVideoExport").mockImplementation(async () => { + exporter.lastNativeExportError = "Breeze native encoder unavailable"; + return false; + }); + const initializeEncoder = vi.spyOn(exporter, "initializeEncoder").mockResolvedValue({ + codec: "avc1.640034", + hardwareAcceleration: "prefer-hardware", + }); + + const result = await exporter.export(); + + expect(result.success).toBe(true); + expect(result.error).toBeUndefined(); + expect(result.blob).toBeInstanceOf(Blob); + expect(initializeEncoder).toHaveBeenCalledTimes(1); + expect(mocks.muxerFinalize).toHaveBeenCalledTimes(1); + }, 15_000); + + it("keeps Windows auto exports on the streaming native route before static layout", async () => { + vi.stubGlobal("navigator", { + platform: "Win32", + userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64)", + }); + + const nativeResult = { + success: true, + blob: new Blob([], { type: "video/mp4" }), + }; + const exporter = new ModernVideoExporter({ + videoUrl: "file:///recording.mp4", + width: 1920, + height: 1080, + frameRate: 30, + bitrate: 8_000_000, + wallpaper: "#101010", + padding: 0, + borderRadius: 0, + backgroundBlur: 0, + shadowIntensity: 0, + showShadow: false, + cropRegion: { x: 0, y: 0, width: 1, height: 1 }, + experimentalNativeExport: true, + backendPreference: "auto", + } as never) as unknown as { + export: () => Promise<{ success: boolean; blob?: Blob; error?: string }>; + finishNativeVideoExport: () => Promise<unknown>; + loadNativeStaticLayoutVideoInfo: () => Promise<unknown>; + tryExportNativeStaticLayout: () => Promise<unknown>; + tryStartNativeVideoExport: () => Promise<boolean>; + }; + + const loadNativeStaticLayoutVideoInfo = vi.spyOn( + exporter, + "loadNativeStaticLayoutVideoInfo", + ); + const tryExportNativeStaticLayout = vi.spyOn(exporter, "tryExportNativeStaticLayout"); + const tryStartNativeVideoExport = vi + .spyOn(exporter, "tryStartNativeVideoExport") + .mockResolvedValue(true); + const finishNativeVideoExport = vi + .spyOn(exporter, "finishNativeVideoExport") + .mockResolvedValue(nativeResult); + + const result = await exporter.export(); + + expect(result.success).toBe(true); + expect(result.blob).toBe(nativeResult.blob); + expect(tryStartNativeVideoExport).toHaveBeenCalledTimes(1); + expect(loadNativeStaticLayoutVideoInfo).not.toHaveBeenCalled(); + expect(tryExportNativeStaticLayout).not.toHaveBeenCalled(); + expect(mocks.streamingDecoderLoadMetadata).toHaveBeenCalledTimes(1); + expect(finishNativeVideoExport).toHaveBeenCalledTimes(1); + }, 15_000); + + it("tries Windows auto static-layout first when NVIDIA CUDA is opted in", async () => { + vi.stubGlobal("navigator", { + platform: "Win32", + userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64)", + }); + + const staticLayoutResult = { + success: true, + blob: new Blob([], { type: "video/mp4" }), + }; + const exporter = new ModernVideoExporter({ + videoUrl: "file:///recording.mp4", + width: 1920, + height: 1080, + frameRate: 30, + bitrate: 8_000_000, + wallpaper: "#101010", + padding: 0, + borderRadius: 0, + backgroundBlur: 0, + shadowIntensity: 0, + showShadow: false, + cropRegion: { x: 0, y: 0, width: 1, height: 1 }, + experimentalNativeExport: true, + experimentalNvidiaCudaExport: true, + backendPreference: "auto", + } as never) as unknown as { + export: () => Promise<{ success: boolean; blob?: Blob; error?: string }>; + initializeEncoder: () => Promise<unknown>; + loadNativeStaticLayoutVideoInfo: () => Promise<unknown>; + tryExportNativeStaticLayout: () => Promise<unknown>; + tryStartNativeVideoExport: () => Promise<boolean>; + }; + + const initializeEncoder = vi.spyOn(exporter, "initializeEncoder").mockResolvedValue({ + codec: "avc1.640034", + hardwareAcceleration: "prefer-hardware", + }); + const loadNativeStaticLayoutVideoInfo = vi + .spyOn(exporter, "loadNativeStaticLayoutVideoInfo") + .mockResolvedValue(mocks.videoInfo); + const tryExportNativeStaticLayout = vi + .spyOn(exporter, "tryExportNativeStaticLayout") + .mockResolvedValue(staticLayoutResult); + const tryStartNativeVideoExport = vi + .spyOn(exporter, "tryStartNativeVideoExport") + .mockResolvedValue(true); + + const result = await exporter.export(); + + expect(result).toBe(staticLayoutResult); + expect(loadNativeStaticLayoutVideoInfo).toHaveBeenCalledTimes(1); + expect(tryExportNativeStaticLayout).toHaveBeenCalledTimes(1); + expect(tryStartNativeVideoExport).not.toHaveBeenCalled(); + expect(initializeEncoder).not.toHaveBeenCalled(); + expect(mocks.streamingDecoderLoadMetadata).not.toHaveBeenCalled(); + }, 15_000); + + it("retries the main decode path once with a fresh media source", async () => { + mocks.streamingDecoderGetEffectiveDuration.mockReturnValue(1); + mocks.streamingDecoderDecodeAll + .mockRejectedValueOnce( + new Error("readAVPacket pipeline failed: Failed after 3 attempts"), + ) + .mockResolvedValueOnce(undefined); + + const exporter = new ModernVideoExporter({ + videoUrl: "file:///recording.mp4", + width: 1920, + height: 1080, + frameRate: 30, + bitrate: 8_000_000, + wallpaper: "#101010", + padding: 0, + borderRadius: 0, + backgroundBlur: 0, + shadowIntensity: 0, + showShadow: false, + cropRegion: { x: 0, y: 0, width: 1, height: 1 }, + backendPreference: "webcodecs", + } as never) as unknown as { + export: () => Promise<{ success: boolean; blob?: Blob; error?: string }>; + initializeEncoder: () => Promise<unknown>; + }; + + vi.spyOn(exporter, "initializeEncoder").mockResolvedValue({ + codec: "avc1.640034", + hardwareAcceleration: "prefer-hardware", + }); + + const result = await exporter.export(); + + expect(result.success).toBe(true); + expect(mocks.streamingDecoderLoadMetadata).toHaveBeenCalledTimes(2); + expect(mocks.streamingDecoderLoadMetadata.mock.calls[0]).toEqual([ + "file:///recording.mp4", + { + useFallbackMediaSource: false, + }, + ]); + expect(mocks.streamingDecoderLoadMetadata.mock.calls[1]).toEqual([ + "file:///recording.mp4", + { + useFallbackMediaSource: true, + }, + ]); + expect(mocks.streamingDecoderDecodeAll).toHaveBeenCalledTimes(2); + expect(mocks.muxerFinalize).toHaveBeenCalledTimes(1); + }); + + it("forwards cursor click-effect settings into the modern frame renderer", async () => { + const { ModernVideoExporter } = await import("./modernVideoExporter"); + const { FrameRenderer } = await import("./modernFrameRenderer"); + mocks.streamingDecoderGetEffectiveDuration.mockReturnValue(1); + + const exporter = new ModernVideoExporter({ + videoUrl: "file:///recording.mp4", + width: 1920, + height: 1080, + frameRate: 30, + bitrate: 8_000_000, + wallpaper: "#101010", + padding: 0, + borderRadius: 24, + backgroundBlur: 0, + shadowIntensity: 0, + showShadow: false, + cropRegion: { x: 0, y: 0, width: 1, height: 1 }, + backendPreference: "webcodecs", + cursorClickEffect: "echo", + cursorClickEffectColor: "#22C55E", + cursorClickEffectScale: 1.4, + cursorClickEffectOpacity: 0.65, + cursorClickEffectDurationMs: 720, + } as never) as unknown as { + export: () => Promise<{ success: boolean; blob?: Blob; error?: string }>; + initializeEncoder: () => Promise<unknown>; + }; + + vi.spyOn(exporter, "initializeEncoder").mockResolvedValue({ + codec: "avc1.640034", + hardwareAcceleration: "prefer-hardware", + }); + + const result = await exporter.export(); + + expect(result.success).toBe(true); + expect(FrameRenderer).toHaveBeenCalledWith( + expect.objectContaining({ + cursorClickEffect: "echo", + cursorClickEffectColor: "#22C55E", + cursorClickEffectScale: 1.4, + cursorClickEffectOpacity: 0.65, + cursorClickEffectDurationMs: 720, + }), + ); + }); +}); diff --git a/src/lib/exporter/modernVideoExporter.nativeStaticLayout.test.ts b/src/lib/exporter/modernVideoExporter.nativeStaticLayout.test.ts new file mode 100644 index 000000000..50f0e3bab --- /dev/null +++ b/src/lib/exporter/modernVideoExporter.nativeStaticLayout.test.ts @@ -0,0 +1,756 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { AudioRegion, SpeedRegion } from "@/components/video-editor/types"; +import { ModernVideoExporter } from "./modernVideoExporter"; +import type { DecodedVideoInfo } from "./streamingDecoder"; + +const videoInfo: DecodedVideoInfo = { + width: 1920, + height: 1080, + duration: 60, + streamDuration: 60, + frameRate: 30, + codec: "h264", + hasAudio: true, + audioCodec: "aac", + audioSampleRate: 48_000, +}; + +function createExporter(overrides: Record<string, unknown> = {}) { + vi.stubGlobal("window", { + electronAPI: { + nativeStaticLayoutExport: vi.fn(), + nativeStaticLayoutExportCancel: vi.fn(), + }, + }); + + return new ModernVideoExporter({ + videoUrl: "file:///recording.mp4", + width: 1920, + height: 1080, + frameRate: 30, + bitrate: 8_000_000, + wallpaper: "#101010", + padding: 0, + borderRadius: 0, + backgroundBlur: 0, + shadowIntensity: 0, + showShadow: false, + cropRegion: { x: 0, y: 0, width: 1, height: 1 }, + experimentalNativeExport: true, + ...overrides, + } as never) as unknown as { + buildNativeAudioPlan: (videoInfo: DecodedVideoInfo) => unknown; + buildNativeStaticLayoutVideoTimelineSegments: (videoInfo: DecodedVideoInfo) => Array<{ + sourceStartMs: number; + sourceEndMs: number; + outputStartMs: number; + outputEndMs: number; + speed: number; + }>; + getNativeStaticLayoutEffectiveDuration: (videoInfo: DecodedVideoInfo) => number; + getNativeStaticLayoutSkipReason: ( + audioPlan: unknown, + videoInfo: DecodedVideoInfo, + effectiveDurationSec: number, + ) => string | null; + getNativeStaticLayoutSkipReasons: ( + audioPlan: unknown, + videoInfo: DecodedVideoInfo, + effectiveDurationSec: number, + ) => string[]; + getNativeStaticLayoutSourceCrop: (videoInfo: DecodedVideoInfo) => { + x: number; + y: number; + width: number; + height: number; + }; + resolveNativeStaticLayoutBackground: () => Promise<unknown>; + createNativeStaticLayoutGradient: ( + ctx: CanvasRenderingContext2D, + wallpaper: string, + ) => CanvasGradient | null; + getNativeStaticLayoutCursorSize: (contentWidth: number) => number; + }; +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("ModernVideoExporter native static-layout eligibility", () => { + it("allows native static-layout eligibility for VP9/WebM sources so main can proxy them", () => { + const exporter = createExporter(); + + expect( + exporter.getNativeStaticLayoutSkipReason( + { audioMode: "none" }, + { + ...videoInfo, + codec: "vp9 (Profile 0)", + audioCodec: "opus", + }, + 60, + ), + ).toBeNull(); + }); + + it("carries embedded audio codec into native mux options", () => { + const exporter = createExporter(); + + expect( + exporter.buildNativeAudioPlan({ + ...videoInfo, + codec: "vp9 (Profile 0)", + audioCodec: "opus", + }), + ).toMatchObject({ + audioMode: "copy-source", + audioSourceCodec: "opus", + }); + }); + + it("allows native static-layout for H.264 source metadata", () => { + const exporter = createExporter(); + + expect( + exporter.getNativeStaticLayoutSkipReason( + { audioMode: "none" }, + { ...videoInfo, codec: "avc1.640034" }, + 60, + ), + ).toBeNull(); + }); + + it("uses FFmpeg filtergraph audio for speed edits with a single external source track", () => { + const speedRegions: SpeedRegion[] = [ + { id: "speed-1", startMs: 1_000, endMs: 4_000, speed: 1.5 }, + ]; + const exporter = createExporter({ + speedRegions, + sourceAudioFallbackPaths: ["C:\\recordly\\recording.system.wav"], + }); + + expect( + exporter.buildNativeAudioPlan({ + ...videoInfo, + hasAudio: false, + audioCodec: undefined, + audioSampleRate: undefined, + }), + ).toMatchObject({ + audioMode: "edited-track", + strategy: "filtergraph-fast-path", + audioSourcePath: "C:\\recordly\\recording.system.wav", + audioSourceSampleRate: 48_000, + editedTrackSegments: [ + { startMs: 0, endMs: 1_000, speed: 1 }, + { startMs: 1_000, endMs: 4_000, speed: 1.5 }, + { startMs: 4_000, endMs: 60_000, speed: 1 }, + ], + }); + }); + + it("mixes companion sidecar audio when the source MP4 also has an audio track", () => { + const videoPath = "C:\\recordly\\recording.mp4"; + const micPath = "C:\\recordly\\recording.mic.wav"; + const exporter = createExporter({ + videoUrl: `file:///${videoPath.replace(/\\/g, "/")}`, + sourceAudioFallbackPaths: [micPath], + }); + + expect(exporter.buildNativeAudioPlan(videoInfo)).toMatchObject({ + audioMode: "edited-track", + strategy: "offline-render-fallback", + sourceAudioFallbackPaths: [expect.stringMatching(/recording\.mp4$/), micPath], + }); + }); + + it("keeps timed companion audio on the offline render path", () => { + const audioPath = "C:\\recordly\\recording.system.wav"; + const speedRegions: SpeedRegion[] = [ + { id: "speed-1", startMs: 1_000, endMs: 4_000, speed: 1.5 }, + ]; + const exporter = createExporter({ + speedRegions, + sourceAudioFallbackPaths: [audioPath], + sourceAudioFallbackStartDelayMsByPath: { [audioPath]: 250 }, + }); + + expect( + exporter.buildNativeAudioPlan({ + ...videoInfo, + hasAudio: false, + audioCodec: undefined, + audioSampleRate: undefined, + }), + ).toMatchObject({ + audioMode: "edited-track", + strategy: "offline-render-fallback", + sourceAudioFallbackPaths: [audioPath], + }); + }); + + it("allows native video when only the audio track needs offline editing", () => { + const audioRegions: AudioRegion[] = [ + { + id: "audio-1", + audioPath: "file:///overlay.wav", + startMs: 1_000, + endMs: 4_000, + volume: 0.85, + }, + ]; + const exporter = createExporter({ audioRegions }); + + expect( + exporter.getNativeStaticLayoutSkipReason( + { + audioMode: "edited-track", + strategy: "offline-render-fallback", + }, + videoInfo, + 60, + ), + ).toBeNull(); + }); + + it("allows cursor overlay native static-layout without the experimental native flag", () => { + const exporter = createExporter({ + experimentalNativeExport: false, + showCursor: true, + cursorTelemetry: [ + { timeMs: 0, cx: 0.25, cy: 0.35 }, + { timeMs: 1_000, cx: 0.5, cy: 0.55 }, + ], + }); + + expect( + exporter.getNativeStaticLayoutSkipReason( + { + audioMode: "copy-source", + audioSourcePath: "recording.mp4", + }, + videoInfo, + 60, + ), + ).toBeNull(); + }); + + it("preserves the cursor-to-video ratio at every native static-layout size", () => { + const exporter = createExporter({ cursorSize: 3, cursorStyle: "tahoe" }); + + expect(exporter.getNativeStaticLayoutCursorSize(1920)).toBeCloseTo(84, 6); + expect(exporter.getNativeStaticLayoutCursorSize(960)).toBeCloseTo(42, 6); + expect(exporter.getNativeStaticLayoutCursorSize(480)).toBeCloseTo(21, 6); + }); + + it("skips native static-layout when cursor click effects are enabled", () => { + const exporter = createExporter({ + showCursor: true, + cursorClickEffect: "echo", + cursorTelemetry: [ + { timeMs: 0, cx: 0.25, cy: 0.35 }, + { timeMs: 1_000, cx: 0.5, cy: 0.55, interactionType: "click" }, + ], + }); + + expect( + exporter.getNativeStaticLayoutSkipReason( + { + audioMode: "copy-source", + audioSourcePath: "recording.mp4", + }, + videoInfo, + 60, + ), + ).toBe("unsupported-cursor-click-effect"); + }); + + it("skips native static-layout when click effects are enabled and cursor is hidden", () => { + const exporter = createExporter({ + showCursor: false, + cursorClickEffect: "echo", + cursorTelemetry: [ + { timeMs: 0, cx: 0.25, cy: 0.35 }, + { timeMs: 1_000, cx: 0.5, cy: 0.55, interactionType: "click" }, + ], + }); + + expect( + exporter.getNativeStaticLayoutSkipReason( + { + audioMode: "copy-source", + audioSourcePath: "recording.mp4", + }, + videoInfo, + 60, + ), + ).toBe("unsupported-cursor-click-effect"); + }); + + it("allows native static-layout with background blur", () => { + const exporter = createExporter({ backgroundBlur: 12 }); + + expect( + exporter.getNativeStaticLayoutSkipReason( + { + audioMode: "copy-source", + audioSourcePath: "recording.mp4", + }, + videoInfo, + 60, + ), + ).toBeNull(); + }); + + it("allows non-default crop when native source crop coordinates are valid", () => { + const exporter = createExporter({ + cropRegion: { x: 0.1, y: 0.1, width: 0.8, height: 0.8 }, + }); + + expect( + exporter.getNativeStaticLayoutSkipReason( + { + audioMode: "copy-source", + audioSourcePath: "recording.mp4", + }, + videoInfo, + 60, + ), + ).toBeNull(); + expect(exporter.getNativeStaticLayoutSourceCrop(videoInfo)).toEqual({ + x: 192, + y: 108, + width: 1536, + height: 864, + }); + }); + + it("uses the default wallpaper for native static-layout when the project has no wallpaper", async () => { + const exporter = createExporter({ wallpaper: "" }); + const electronAPI = window.electronAPI as typeof window.electronAPI & { + getAssetBasePath: () => Promise<string>; + listAssetDirectory: () => Promise<{ success: true; files: string[] }>; + }; + electronAPI.getAssetBasePath = vi.fn(async () => "file:///C:/Recordly/resources/"); + electronAPI.listAssetDirectory = vi.fn(async () => ({ + success: true, + files: ["tahoe-light.jpg"], + })); + + await expect(exporter.resolveNativeStaticLayoutBackground()).resolves.toEqual({ + backgroundColor: "#101010", + backgroundImagePath: "C:/Recordly/resources/wallpapers/tahoe-light.jpg", + }); + }); + + it("reports video backgrounds while speed can use native timeline maps", () => { + const exporter = createExporter({ + wallpaper: "file:///C:/Recordly/background.webm", + speedRegions: [{ id: "speed-1", startMs: 1_000, endMs: 4_000, speed: 1.5 }], + }); + + expect( + exporter.getNativeStaticLayoutSkipReason( + { + audioMode: "edited-track", + strategy: "offline-render-fallback", + }, + videoInfo, + 59, + ), + ).toBe("unsupported-background-video"); + }); + + it("collects every native static-layout blocker for beta diagnostics", () => { + const exporter = createExporter({ + width: 1921, + wallpaper: "file:///C:/Recordly/background.webm", + speedRegions: [{ id: "speed-1", startMs: 1_000, endMs: 4_000, speed: 1.5 }], + annotationRegions: [{ id: "annotation-1", startMs: 0, endMs: 1_000 }], + autoCaptions: [{ id: "caption-1", text: "hello", startMs: 0, endMs: 1_000 }], + webcam: { enabled: true }, + cropRegion: { x: 0.1, y: 0, width: 0.9, height: 1 }, + }); + + expect( + exporter.getNativeStaticLayoutSkipReasons( + { + audioMode: "edited-track", + strategy: "offline-render-fallback", + }, + videoInfo, + 59, + ), + ).toEqual([ + "odd-output-dimensions", + "unsupported-background-video", + "unsupported-annotation-overlay", + "unsupported-caption-overlay", + "native-webcam-style-mismatch", + "unsupported-webcam-source", + ]); + }); + + it("reports invalid crop geometry instead of passing native export bad coordinates", () => { + const exporter = createExporter({ + cropRegion: { x: 0, y: 0, width: 0, height: 1 }, + }); + + expect(exporter.getNativeStaticLayoutSkipReason({ audioMode: "none" }, videoInfo, 60)).toBe( + "invalid-crop-region", + ); + }); + + it("materializes uploaded data-url image backgrounds for native static-layout", async () => { + const jpegBytes = new Uint8Array([0xff, 0xd8, 0xff, 0xd9]); + const dataUrl = `data:image/jpeg;base64,${Buffer.from(jpegBytes).toString("base64")}`; + const exporter = createExporter({ wallpaper: dataUrl }); + const electronAPI = window.electronAPI as typeof window.electronAPI & { + openExportStream: ReturnType<typeof vi.fn>; + writeExportStreamChunk: ReturnType<typeof vi.fn>; + closeExportStream: ReturnType<typeof vi.fn>; + }; + electronAPI.openExportStream = vi.fn(async () => ({ + success: true, + streamId: "background-stream", + tempPath: "C:/Temp/unused.jpg", + })); + electronAPI.writeExportStreamChunk = vi.fn(async () => ({ success: true })); + electronAPI.closeExportStream = vi.fn(async () => ({ + success: true, + tempPath: "C:/Temp/recordly-background.jpg", + bytesWritten: jpegBytes.byteLength, + })); + + await expect(exporter.resolveNativeStaticLayoutBackground()).resolves.toEqual({ + backgroundColor: "#101010", + backgroundImagePath: "C:/Temp/recordly-background.jpg", + temporaryPath: "C:/Temp/recordly-background.jpg", + }); + expect(electronAPI.openExportStream).toHaveBeenCalledWith({ extension: "jpg" }); + expect(electronAPI.writeExportStreamChunk).toHaveBeenCalledTimes(1); + const [, position, chunk] = electronAPI.writeExportStreamChunk.mock.calls[0]; + expect(position).toBe(0); + expect(Array.from(chunk as Uint8Array)).toEqual(Array.from(jpegBytes)); + expect(electronAPI.closeExportStream).toHaveBeenCalledWith("background-stream"); + }); + + it("parses rgba color stops in native gradient backgrounds", () => { + const exporter = createExporter(); + const gradient = { addColorStop: vi.fn() }; + const ctx = { + createLinearGradient: vi.fn(() => gradient), + createRadialGradient: vi.fn(() => gradient), + } as unknown as CanvasRenderingContext2D; + + const result = exporter.createNativeStaticLayoutGradient( + ctx, + "linear-gradient( 111.6deg, rgba(114,167,232,1) 9.4%, rgba(253,129,82,1) 43.9%, rgba(249,202,86,1) 86.3% )", + ); + + expect(result).toBe(gradient); + expect(gradient.addColorStop).toHaveBeenCalledTimes(3); + expect(gradient.addColorStop).toHaveBeenNthCalledWith(1, 0, "rgba(114,167,232,1)"); + expect(gradient.addColorStop).toHaveBeenNthCalledWith(2, 0.5, "rgba(253,129,82,1)"); + expect(gradient.addColorStop).toHaveBeenNthCalledWith(3, 1, "rgba(249,202,86,1)"); + }); + + it("allows non-tail trim timelines with native static-layout", () => { + const exporter = createExporter({ + trimRegions: [{ id: "trim-1", startMs: 10_000, endMs: 12_000 }], + }); + const effectiveDuration = exporter.getNativeStaticLayoutEffectiveDuration(videoInfo); + + expect(effectiveDuration).toBeCloseTo(58, 3); + expect(exporter.buildNativeStaticLayoutVideoTimelineSegments(videoInfo)).toEqual([ + { + sourceStartMs: 0, + sourceEndMs: 10_000, + outputStartMs: 0, + outputEndMs: 10_000, + speed: 1, + }, + { + sourceStartMs: 12_000, + sourceEndMs: 60_000, + outputStartMs: 10_000, + outputEndMs: 58_000, + speed: 1, + }, + ]); + expect( + exporter.getNativeStaticLayoutSkipReason( + { + audioMode: "trim-source", + audioSourcePath: "recording.mp4", + trimSegments: [ + { startMs: 0, endMs: 10_000 }, + { startMs: 12_000, endMs: 60_000 }, + ], + }, + videoInfo, + effectiveDuration, + ), + ).toBeNull(); + }); + + it("requires the Windows GPU compositor for non-tail trim timelines", () => { + const exporter = createExporter({ + experimentalNativeExport: false, + trimRegions: [{ id: "trim-1", startMs: 10_000, endMs: 12_000 }], + }); + + expect( + exporter.getNativeStaticLayoutSkipReason( + { + audioMode: "trim-source", + audioSourcePath: "recording.mp4", + trimSegments: [ + { startMs: 0, endMs: 10_000 }, + { startMs: 12_000, endMs: 60_000 }, + ], + }, + videoInfo, + 58, + ), + ).toBe("native-timeline-requires-windows-gpu"); + }); + + it("requires the Windows GPU compositor for speed timelines", () => { + const speedRegions: SpeedRegion[] = [ + { id: "speed-1", startMs: 1_000, endMs: 4_000, speed: 1.5 }, + ]; + const exporter = createExporter({ experimentalNativeExport: false, speedRegions }); + + expect( + exporter.getNativeStaticLayoutSkipReason( + { + audioMode: "edited-track", + strategy: "offline-render-fallback", + }, + videoInfo, + 59, + ), + ).toBe("native-timeline-requires-windows-gpu"); + }); + + it("uses speed timeline duration during native static-layout preflight", () => { + const speedRegions: SpeedRegion[] = [ + { id: "speed-1", startMs: 1_000, endMs: 4_000, speed: 1.5 }, + ]; + const exporter = createExporter({ speedRegions }); + + expect(exporter.getNativeStaticLayoutEffectiveDuration(videoInfo)).toBeCloseTo(59, 3); + }); + + it("builds native timeline maps for the editor speed range endpoints", () => { + const speedRegions: SpeedRegion[] = [ + { id: "speed-1", startMs: 1_000, endMs: 2_000, speed: 0.25 }, + { id: "speed-2", startMs: 4_000, endMs: 5_000, speed: 30 }, + ]; + const exporter = createExporter({ speedRegions }); + + expect( + exporter + .buildNativeStaticLayoutVideoTimelineSegments(videoInfo) + .map((segment) => segment.speed), + ).toEqual([1, 0.25, 1, 30, 1]); + expect( + exporter.getNativeStaticLayoutSkipReason( + { + audioMode: "edited-track", + strategy: "offline-render-fallback", + }, + videoInfo, + 63.033, + ), + ).toBeNull(); + }); + + it("allows native speed timelines through the Windows GPU timeline map", () => { + const speedRegions: SpeedRegion[] = [ + { id: "speed-1", startMs: 1_000, endMs: 4_000, speed: 1.5 }, + ]; + const exporter = createExporter({ speedRegions }); + + expect(exporter.buildNativeStaticLayoutVideoTimelineSegments(videoInfo)).toEqual([ + { + sourceStartMs: 0, + sourceEndMs: 1_000, + outputStartMs: 0, + outputEndMs: 1_000, + speed: 1, + }, + { + sourceStartMs: 1_000, + sourceEndMs: 4_000, + outputStartMs: 1_000, + outputEndMs: 3_000, + speed: 1.5, + }, + { + sourceStartMs: 4_000, + sourceEndMs: 60_000, + outputStartMs: 3_000, + outputEndMs: 59_000, + speed: 1, + }, + ]); + expect( + exporter.getNativeStaticLayoutSkipReason( + { + audioMode: "edited-track", + strategy: "offline-render-fallback", + }, + videoInfo, + 60, + ), + ).toBeNull(); + }); + + it("rejects native static-layout when speed edits are outside the editor speed range", () => { + const speedRegions: SpeedRegion[] = [ + { id: "speed-1", startMs: 1_000, endMs: 4_000, speed: 31 }, + ]; + const exporter = createExporter({ speedRegions }); + + expect( + exporter.getNativeStaticLayoutSkipReason( + { + audioMode: "edited-track", + strategy: "offline-render-fallback", + }, + videoInfo, + 58, + ), + ).toBe("unsupported-native-speed-timeline"); + }); + + it("allows speed-only projects when audio and video share filtergraph segments", () => { + const speedRegions: SpeedRegion[] = [ + { id: "speed-1", startMs: 1_000, endMs: 4_000, speed: 1.5 }, + ]; + const exporter = createExporter({ speedRegions }); + + expect( + exporter.getNativeStaticLayoutSkipReason( + { + audioMode: "edited-track", + strategy: "filtergraph-fast-path", + audioSourcePath: "recording.mp4", + audioSourceSampleRate: 48_000, + editedTrackSegments: [ + { startMs: 0, endMs: 1_000, speed: 1 }, + { startMs: 1_000, endMs: 4_000, speed: 1.5 }, + { startMs: 4_000, endMs: 60_000, speed: 1 }, + ], + }, + videoInfo, + 59, + ), + ).toBeNull(); + }); + + it("allows slow-speed timelines through native frame duplication", () => { + const speedRegions: SpeedRegion[] = [ + { id: "speed-1", startMs: 1_000, endMs: 4_000, speed: 0.5 }, + ]; + const exporter = createExporter({ speedRegions }); + + expect( + exporter.getNativeStaticLayoutSkipReason( + { + audioMode: "edited-track", + strategy: "filtergraph-fast-path", + audioSourcePath: "recording.mp4", + audioSourceSampleRate: 48_000, + editedTrackSegments: [ + { startMs: 0, endMs: 1_000, speed: 1 }, + { startMs: 1_000, endMs: 4_000, speed: 0.5 }, + { startMs: 4_000, endMs: 60_000, speed: 1 }, + ], + }, + videoInfo, + 63, + ), + ).toBeNull(); + }); + + it("uses the shared renderer for slow-speed webcam timelines", () => { + const speedRegions: SpeedRegion[] = [ + { id: "speed-1", startMs: 1_000, endMs: 4_000, speed: 0.5 }, + ]; + const exporter = createExporter({ + speedRegions, + webcam: { + enabled: true, + sourcePath: "C:\\recordly\\webcam.mp4", + }, + }); + + expect( + exporter.getNativeStaticLayoutSkipReason( + { + audioMode: "edited-track", + strategy: "offline-render-fallback", + }, + videoInfo, + 63, + ), + ).toBe("native-webcam-style-mismatch"); + }); + + it("uses the shared renderer for rectangular webcam overlays", () => { + const exporter = createExporter({ + webcam: { + enabled: true, + sourcePath: "C:\\recordly\\webcam.mp4", + width: 60, + height: 35, + }, + }); + + expect( + exporter.getNativeStaticLayoutSkipReason( + { + audioMode: "edited-track", + strategy: "offline-render-fallback", + }, + videoInfo, + 60, + ), + ).toBe("native-webcam-style-mismatch"); + }); + + it("uses the shared renderer for native speed timelines with a webcam", () => { + const speedRegions: SpeedRegion[] = [ + { id: "speed-1", startMs: 1_000, endMs: 4_000, speed: 1.5 }, + ]; + const exporter = createExporter({ + speedRegions, + webcam: { + enabled: true, + sourcePath: "C:\\recordly\\webcam.mp4", + }, + }); + + expect( + exporter.getNativeStaticLayoutSkipReason( + { + audioMode: "edited-track", + strategy: "filtergraph-fast-path", + audioSourcePath: "recording.mp4", + audioSourceSampleRate: 48_000, + editedTrackSegments: [ + { startMs: 0, endMs: 1_000, speed: 1 }, + { startMs: 1_000, endMs: 4_000, speed: 1.5 }, + { startMs: 4_000, endMs: 60_000, speed: 1 }, + ], + }, + videoInfo, + 59, + ), + ).toBe("native-webcam-style-mismatch"); + }); +}); diff --git a/src/lib/exporter/modernVideoExporter.ts b/src/lib/exporter/modernVideoExporter.ts new file mode 100644 index 000000000..35f185de3 --- /dev/null +++ b/src/lib/exporter/modernVideoExporter.ts @@ -0,0 +1,3604 @@ +import type { + AnnotationRegion, + AudioRegion, + AutoCaptionSettings, + CaptionCue, + ClipRegion, + CropRegion, + CursorClickEffectStyle, + CursorStyle, + CursorTelemetryPoint, + Padding, + SourceAudioTrackSettings, + SpeedRegion, + TrimRegion, + WebcamOverlaySettings, + ZoomMotionBlurTuning, + ZoomRegion, + ZoomTransitionEasing, +} from "@/components/video-editor/types"; +import { DEFAULT_WEBCAM_ROUNDNESS, ZOOM_DEPTH_SCALES } from "@/components/video-editor/types"; +import { DEFAULT_FOCUS } from "@/components/video-editor/videoPlayback/constants"; +import { + computeCursorFollowFocus, + createCursorFollowCameraState, + SNAP_TO_EDGES_RATIO_AUTO, +} from "@/components/video-editor/videoPlayback/cursorFollowCamera"; +import { buildNativeCursorAtlas } from "@/components/video-editor/videoPlayback/cursorRenderer"; +import { getCursorViewportScale } from "@/components/video-editor/videoPlayback/cursorScale"; +import { + computePaddedLayout, + scalePreviewBorderRadius, +} from "@/components/video-editor/videoPlayback/layoutUtils"; +import { + createSpringState, + getZoomSpringConfig, + resetSpringState, + stepSpringValue, +} from "@/components/video-editor/videoPlayback/motionSmoothing"; +import { getCursorStyleSizeMultiplier } from "@/components/video-editor/videoPlayback/uploadedCursorAssets"; +import { findDominantRegion } from "@/components/video-editor/videoPlayback/zoomRegionUtils"; +import { computeZoomTransform } from "@/components/video-editor/videoPlayback/zoomTransform"; +import { + getWebcamCornerRadiusPx, + getWebcamOverlayPosition, + getWebcamOverlaySizePx, + isWebcamCropRegionDefault, +} from "@/components/video-editor/webcamOverlay"; +import { getEffectiveVideoStreamDurationSeconds } from "@/lib/mediaTiming"; +import { + DEFAULT_WALLPAPER_PATH, + DEFAULT_WALLPAPER_RELATIVE_PATH, + isVideoWallpaperSource, +} from "@/lib/wallpapers"; +import { AudioProcessor, isAacAudioEncodingSupported } from "./audioEncoder"; +import { + normalizeLightningRuntimePlatform, + shouldPreferNativeAutoBackend, + shouldPreferNativeStaticLayoutBeforeBreeze, +} from "./backendPolicy"; +import { buildEditedTrackSourceSegments, classifyEditedTrackStrategy } from "./editedTrackStrategy"; +import { + type ExportBackpressureProfile, + getExportBackpressureProfile, + getPreferredWebCodecsLatencyModes, + getWebCodecsEncodeQueueLimit, + getWebCodecsKeyFrameInterval, +} from "./exportTuning"; +import { + advanceFinalizationProgress, + type FinalizationProgressWatchdog, + type FinalizationTimeoutWorkload, + INITIAL_FINALIZATION_PROGRESS_STATE, + withFinalizationTimeout, +} from "./finalizationTimeout"; +import { getLocalFilePath } from "./localMediaSource"; +import { FrameRenderer as ModernFrameRenderer } from "./modernFrameRenderer"; +import { + getOrderedSupportedMp4EncoderCandidates, + type SupportedMp4EncoderPath, +} from "./mp4Support"; +import { VideoMuxer } from "./muxer"; +import { roundNativeStaticLayoutContentSize } from "./nativeStaticLayoutGeometry"; +import { buildNativeStaticLayoutCursorTelemetry } from "./nativeStaticLayoutTelemetry"; +import { getWebcamShadowStrength } from "./shadowProfile"; +import { resolveSourceAudioFallbackPaths } from "./sourceAudioFallback"; +import { type DecodedVideoInfo, StreamingVideoDecoder } from "./streamingDecoder"; +import type { + ExportConfig, + ExportEncodeBackend, + ExportFfmpegAudioMuxBreakdown, + ExportFinalizationStageMetrics, + ExportMetrics, + ExportProgress, + ExportRenderBackend, + ExportResult, +} from "./types"; +import { ENCODED_H264_COLOR_SPACE_FALLBACK, EXPORT_CANVAS_COLOR_SPACE } from "./videoColorSpace"; + +interface VideoExporterConfig extends ExportConfig { + videoUrl: string; + wallpaper: string; + zoomRegions: ZoomRegion[]; + trimRegions?: TrimRegion[]; + speedRegions?: SpeedRegion[]; + showShadow: boolean; + shadowIntensity: number; + backgroundBlur: number; + zoomMotionBlur?: number; + zoomMotionBlurTuning?: ZoomMotionBlurTuning; + zoomTemporalMotionBlur?: number; + zoomMotionBlurSampleCount?: number | null; + zoomMotionBlurShutterFraction?: number | null; + connectZooms?: boolean; + zoomInDurationMs?: number; + zoomInOverlapMs?: number; + zoomOutDurationMs?: number; + connectedZoomGapMs?: number; + connectedZoomDurationMs?: number; + zoomInEasing?: ZoomTransitionEasing; + zoomOutEasing?: ZoomTransitionEasing; + connectedZoomEasing?: ZoomTransitionEasing; + borderRadius?: number; + padding?: Padding | number; + videoPadding?: Padding | number; + cropRegion: CropRegion; + webcam?: WebcamOverlaySettings; + webcamUrl?: string | null; + annotationRegions?: AnnotationRegion[]; + autoCaptions?: CaptionCue[]; + autoCaptionSettings?: AutoCaptionSettings; + cursorTelemetry?: CursorTelemetryPoint[]; + showCursor?: boolean; + cursorStyle?: CursorStyle; + cursorSize?: number; + cursorSmoothing?: number; + cursorSpringStiffnessMultiplier?: number; + cursorSpringDampingMultiplier?: number; + cursorSpringMassMultiplier?: number; + cameraSpringStiffnessMultiplier?: number; + cameraSpringDampingMultiplier?: number; + cameraSpringMassMultiplier?: number; + cursorMotionBlur?: number; + cursorClickEffect?: CursorClickEffectStyle; + cursorClickEffectColor?: string; + cursorClickEffectScale?: number; + cursorClickEffectOpacity?: number; + cursorClickEffectDurationMs?: number; + cursorClickBounce?: number; + cursorClickBounceDuration?: number; + cursorSway?: number; + zoomSmoothness?: number; + zoomClassicMode?: boolean; + audioRegions?: AudioRegion[]; + clipRegions?: ClipRegion[]; + sourceAudioFallbackPaths?: string[]; + sourceAudioFallbackStartDelayMsByPath?: Record<string, number>; + sourceAudioTrackSettings?: SourceAudioTrackSettings; + previewWidth?: number; + previewHeight?: number; + onProgress?: (progress: ExportProgress) => void; + preferredEncoderPath?: SupportedMp4EncoderPath | null; +} + +type NativeAudioPlan = + | { + audioMode: "none"; + } + | { + audioMode: "copy-source" | "trim-source"; + audioSourcePath: string; + audioSourceCodec?: string; + trimSegments?: Array<{ startMs: number; endMs: number }>; + } + | { + audioMode: "edited-track"; + strategy: "offline-render-fallback"; + sourceAudioFallbackPaths?: string[]; + } + | { + audioMode: "edited-track"; + strategy: "filtergraph-fast-path"; + audioSourcePath: string; + audioSourceCodec?: string; + audioSourceSampleRate: number; + editedTrackSegments: Array<{ + startMs: number; + endMs: number; + speed: number; + }>; + }; + +const FILTERGRAPH_FALLBACK_AUDIO_SAMPLE_RATE = 48_000; + +function hasNonDefaultSourceTrackSettings(sourceAudioTrackSettings?: SourceAudioTrackSettings) { + if (!sourceAudioTrackSettings) { + return false; + } + return Object.values(sourceAudioTrackSettings).some( + (settings) => + Math.abs((settings?.volume ?? 1) - 1) > 0.0005 || Boolean(settings?.normalize), + ); +} +const MIN_NATIVE_STATIC_LAYOUT_SPEED = 0.25; +const MAX_NATIVE_STATIC_LAYOUT_SPEED = 30; + +type NativeStaticLayoutTimelineSegment = { + sourceStartMs: number; + sourceEndMs: number; + outputStartMs: number; + outputEndMs: number; + speed: number; +}; + +function canUseNativeStaticLayoutSpeed(speed: number): boolean { + return ( + Number.isFinite(speed) && + speed >= MIN_NATIVE_STATIC_LAYOUT_SPEED && + speed <= MAX_NATIVE_STATIC_LAYOUT_SPEED + ); +} + +function buildNativeStaticLayoutTimelineSegments( + segments: Array<{ startMs: number; endMs: number; speed: number }>, +): 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; +} + +type NativeStaticLayoutBackground = + | { + backgroundColor: string; + backgroundImagePath?: null; + temporaryPath?: string; + } + | { + backgroundColor: string; + backgroundImagePath: string; + temporaryPath?: string; + }; + +type NativeStaticLayoutWebcamOverlay = { + inputPath: string; + left: number; + top: number; + size: number; + radius: number; + shadowIntensity: number; + mirror: boolean; + timeOffsetMs: number; +}; + +type NativeStaticLayoutZoomSample = { + timeMs: number; + scale: number; + x: number; + y: number; +}; + +const NATIVE_EXPORT_ENGINE_NAME = "Breeze"; +const MEDIA_SOURCE_RETRY_ERROR_TOKENS = [ + "readavpacket", + "get_media_info", + "avfoundation", + "failed after 3 attempts", + "pipeline failed", +]; +const LIGHTNING_PIPELINE_NAME = "Lightning (Beta)"; +const STATIC_LAYOUT_CHUNK_DURATION_SEC = 120; +const MISSING_NATIVE_WALLPAPER_FALLBACK_COLOR = "#ffffff"; +const NATIVE_STATIC_LAYOUT_MAX_EXTRACTING_PROGRESS = 95; +const NATIVE_STATIC_LAYOUT_FRAME_COMPLETE_PROGRESS = 96; + +export class ModernVideoExporter { + private static readonly NATIVE_ENCODER_QUEUE_LIMIT = 64; + private static readonly NATIVE_WRITE_BATCH_MAX_CHUNKS = 12; + private static readonly NATIVE_WRITE_BATCH_MAX_BYTES = 2 * 1024 * 1024; + + private config: VideoExporterConfig; + private streamingDecoder: StreamingVideoDecoder | null = null; + private renderer: ModernFrameRenderer | null = null; + private encoder: VideoEncoder | null = null; + private muxer: VideoMuxer | null = null; + private audioProcessor: AudioProcessor | null = null; + private cancelled = false; + private encodeQueue = 0; + private webCodecsEncodeQueueLimit = 0; + private keyFrameInterval = 0; + private videoDescription: Uint8Array | undefined; + private videoColorSpace: VideoColorSpaceInit | undefined; + private pendingMuxing: Promise<void> = Promise.resolve(); + private chunkCount = 0; + private exportStartTimeMs = 0; + private lastThroughputLogTimeMs = 0; + private renderBackend: ExportRenderBackend | null = null; + private encodeBackend: ExportEncodeBackend | null = null; + private encoderName: string | null = null; + private backpressureProfile: ExportBackpressureProfile | null = null; + private nativeExportSessionId: string | null = null; + private nativeStaticLayoutSessionId: string | null = null; + private nativeStaticLayoutAverageFps: number | null = null; + private nativeWritePromises = new Set<Promise<void>>(); + private nativeWriteError: Error | null = null; + private pendingNativeWriteChunks: Uint8Array[] = []; + private pendingNativeWriteBytes = 0; + private maxNativeWriteInFlight = 1; + private lastNativeExportError: string | null = null; + private nativeStaticLayoutSkipReason: string | null = null; + private nativeStaticLayoutSkipReasons: string[] = []; + private nativeStaticLayoutBackgroundSkipReason: string | null = null; + private nativeH264Encoder: VideoEncoder | null = null; + private nativeEncoderError: Error | null = null; + private effectiveDurationSec = 0; + private totalExportStartTimeMs = 0; + private metadataLoadTimeMs = 0; + private rendererInitTimeMs = 0; + private nativeSessionStartTimeMs = 0; + private decodeLoopTimeMs = 0; + private frameCallbackTimeMs = 0; + private renderFrameTimeMs = 0; + private encodeWaitTimeMs = 0; + private encodeWaitEvents = 0; + private encoderError: Error | null = null; + private peakEncodeQueueSize = 0; + private peakNativeWriteInFlight = 0; + private nativeCaptureTimeMs = 0; + private nativeWriteTimeMs = 0; + private finalizationTimeMs = 0; + private finalizationStageMs: ExportFinalizationStageMetrics = {}; + private processedFrameCount = 0; + private encodeCapacityWaiters = new Set<() => void>(); + private activeFinalizationProgressWatchdog: FinalizationProgressWatchdog | null = null; + private lastFinalizationRenderProgress = INITIAL_FINALIZATION_PROGRESS_STATE.lastRenderProgress; + private lastFinalizationAudioProgress = INITIAL_FINALIZATION_PROGRESS_STATE.lastAudioProgress; + private lastProgressSampleTimeMs = 0; + private lastProgressSampleFrame = 0; + private displayedRenderFps = 0; + + constructor(config: VideoExporterConfig) { + this.config = config; + } + + async export(): Promise<ExportResult> { + let useFallbackMediaSource = false; + let retriedWithFallbackMediaSource = false; + + while (true) { + let shouldRetryWithFallbackMediaSource = false; + try { + this.cleanup(); + this.cancelled = false; + this.encoderError = null; + this.nativeEncoderError = null; + this.nativeStaticLayoutSkipReason = null; + this.nativeStaticLayoutSkipReasons = []; + this.nativeStaticLayoutBackgroundSkipReason = null; + this.totalExportStartTimeMs = this.getNowMs(); + const backendPreference = this.config.backendPreference ?? "auto"; + const runtimePlatform = this.getRuntimePlatform(); + let useNativeEncoder = false; + let triedNativeStaticLayoutWithProbe = false; + const prefersNativeStaticLayoutBeforeBreeze = + shouldPreferNativeStaticLayoutBeforeBreeze(runtimePlatform, backendPreference); + const shouldTryNativeStaticLayout = + backendPreference === "breeze" || + this.config.experimentalNvidiaCudaExport === true || + prefersNativeStaticLayoutBeforeBreeze; + let shouldDeferNativeEncoderStart = + backendPreference === "breeze" || + this.config.experimentalNvidiaCudaExport === true || + prefersNativeStaticLayoutBeforeBreeze; + this.lastNativeExportError = null; + + let stageStartedAt = this.getNowMs(); + if (shouldDeferNativeEncoderStart) { + // Defer the streaming native encoder until after metadata is known so + // static-layout exports can use the fastest compatible compositor first. + } else if ( + backendPreference === "auto" && + shouldPreferNativeAutoBackend(runtimePlatform) + ) { + stageStartedAt = this.getNowMs(); + useNativeEncoder = await this.tryStartNativeVideoExport(); + this.nativeSessionStartTimeMs = this.getNowMs() - stageStartedAt; + + if (!useNativeEncoder) { + console.warn( + `[VideoExporter] ${NATIVE_EXPORT_ENGINE_NAME} auto-preferred native export was unavailable; falling back to WebCodecs.`, + this.lastNativeExportError, + ); + stageStartedAt = this.getNowMs(); + await this.initializeEncoder(); + } + } else { + try { + const configuredWebCodecsPath = await this.initializeEncoder(); + if ( + backendPreference === "auto" && + configuredWebCodecsPath.hardwareAcceleration === "prefer-software" + ) { + console.warn( + "[VideoExporter] Auto backend resolved to a software WebCodecs encoder; trying Breeze native export instead.", + ); + stageStartedAt = this.getNowMs(); + useNativeEncoder = await this.tryStartNativeVideoExport(); + this.nativeSessionStartTimeMs = this.getNowMs() - stageStartedAt; + if (useNativeEncoder) { + this.disposeEncoder(); + } + } + } catch (error) { + const webCodecsError = + error instanceof Error ? error : new Error(String(error)); + if (backendPreference === "webcodecs") { + throw webCodecsError; + } + + console.warn( + `[VideoExporter] WebCodecs encoder unavailable, trying ${NATIVE_EXPORT_ENGINE_NAME} native export fallback`, + webCodecsError, + ); + this.disposeEncoder(); + + stageStartedAt = this.getNowMs(); + useNativeEncoder = await this.tryStartNativeVideoExport(); + this.nativeSessionStartTimeMs = this.getNowMs() - stageStartedAt; + + if (!useNativeEncoder) { + throw webCodecsError; + } + } + } + + this.backpressureProfile = getExportBackpressureProfile({ + encodeBackend: + shouldDeferNativeEncoderStart || useNativeEncoder ? "ffmpeg" : "webcodecs", + width: this.config.width, + height: this.config.height, + frameRate: this.config.frameRate, + encodingMode: this.config.encodingMode, + }); + this.maxNativeWriteInFlight = useNativeEncoder + ? Math.max( + 1, + Math.floor( + this.config.maxInFlightNativeWrites ?? + this.backpressureProfile.maxInFlightNativeWrites, + ), + ) + : 1; + + console.log("[VideoExporter] Backpressure profile", { + profile: this.backpressureProfile.name, + encodeBackend: + shouldDeferNativeEncoderStart || useNativeEncoder ? "ffmpeg" : "webcodecs", + maxEncodeQueue: + this.config.maxEncodeQueue ?? this.backpressureProfile.maxEncodeQueue, + maxDecodeQueue: + this.config.maxDecodeQueue ?? this.backpressureProfile.maxDecodeQueue, + maxPendingFrames: + this.config.maxPendingFrames ?? this.backpressureProfile.maxPendingFrames, + maxInFlightNativeWrites: this.maxNativeWriteInFlight, + }); + + if (shouldTryNativeStaticLayout && !useNativeEncoder) { + const nativeVideoInfo = await this.loadNativeStaticLayoutVideoInfo(); + if (nativeVideoInfo) { + triedNativeStaticLayoutWithProbe = true; + const nativeAudioPlan = this.buildNativeAudioPlan(nativeVideoInfo); + const effectiveDuration = + this.getNativeStaticLayoutEffectiveDuration(nativeVideoInfo); + this.effectiveDurationSec = effectiveDuration; + const totalFrames = Math.ceil(effectiveDuration * this.config.frameRate); + const staticLayoutResult = await this.tryExportNativeStaticLayout( + nativeVideoInfo, + nativeAudioPlan, + effectiveDuration, + totalFrames, + ); + if (staticLayoutResult) { + this.disposeEncoder(); + return staticLayoutResult; + } + } + } + + this.streamingDecoder = new StreamingVideoDecoder({ + maxDecodeQueue: + this.config.maxDecodeQueue ?? this.backpressureProfile.maxDecodeQueue, + maxPendingFrames: + this.config.maxPendingFrames ?? this.backpressureProfile.maxPendingFrames, + }); + stageStartedAt = this.getNowMs(); + const videoInfo = await this.streamingDecoder.loadMetadata(this.config.videoUrl, { + useFallbackMediaSource, + }); + this.metadataLoadTimeMs = this.getNowMs() - stageStartedAt; + const nativeAudioPlan = this.buildNativeAudioPlan(videoInfo); + const shouldUsePitchPreservingFfmpegAudio = + nativeAudioPlan.audioMode === "edited-track" && + nativeAudioPlan.strategy === "filtergraph-fast-path"; + const shouldUseFfmpegAudioFallback = + !useNativeEncoder && + nativeAudioPlan.audioMode !== "none" && + (shouldUsePitchPreservingFfmpegAudio || !(await isAacAudioEncodingSupported())); + const effectiveDuration = this.streamingDecoder.getEffectiveDuration( + this.config.trimRegions, + this.config.speedRegions, + ); + this.effectiveDurationSec = effectiveDuration; + const totalFrames = Math.ceil(effectiveDuration * this.config.frameRate); + + if ( + shouldTryNativeStaticLayout && + !useNativeEncoder && + !triedNativeStaticLayoutWithProbe + ) { + const staticLayoutResult = await this.tryExportNativeStaticLayout( + videoInfo, + nativeAudioPlan, + effectiveDuration, + totalFrames, + ); + if (staticLayoutResult) { + this.disposeEncoder(); + return staticLayoutResult; + } + } + + if (shouldDeferNativeEncoderStart && !useNativeEncoder) { + stageStartedAt = this.getNowMs(); + useNativeEncoder = await this.tryStartNativeVideoExport(); + this.nativeSessionStartTimeMs = this.getNowMs() - stageStartedAt; + if (!useNativeEncoder) { + const nativeFailure = + this.lastNativeExportError ?? + `${NATIVE_EXPORT_ENGINE_NAME} export is unavailable for this output profile on this system.`; + console.warn( + `[VideoExporter] ${NATIVE_EXPORT_ENGINE_NAME} native export unavailable after static-layout fallback; falling back to WebCodecs.`, + nativeFailure, + ); + shouldDeferNativeEncoderStart = false; + this.backpressureProfile = getExportBackpressureProfile({ + encodeBackend: "webcodecs", + width: this.config.width, + height: this.config.height, + frameRate: this.config.frameRate, + encodingMode: this.config.encodingMode, + }); + this.maxNativeWriteInFlight = 1; + await this.initializeEncoder(); + } + } + + stageStartedAt = this.getNowMs(); + this.renderer = new ModernFrameRenderer({ + width: this.config.width, + height: this.config.height, + preferredRenderBackend: undefined, + wallpaper: this.config.wallpaper, + zoomRegions: this.config.zoomRegions, + showShadow: this.config.showShadow, + shadowIntensity: this.config.shadowIntensity, + backgroundBlur: this.config.backgroundBlur, + zoomMotionBlur: this.config.zoomMotionBlur, + zoomMotionBlurTuning: this.config.zoomMotionBlurTuning, + zoomTemporalMotionBlur: this.config.zoomTemporalMotionBlur, + zoomMotionBlurSampleCount: this.config.zoomMotionBlurSampleCount, + zoomMotionBlurShutterFraction: this.config.zoomMotionBlurShutterFraction, + connectZooms: this.config.connectZooms, + zoomInDurationMs: this.config.zoomInDurationMs, + zoomInOverlapMs: this.config.zoomInOverlapMs, + zoomOutDurationMs: this.config.zoomOutDurationMs, + connectedZoomGapMs: this.config.connectedZoomGapMs, + connectedZoomDurationMs: this.config.connectedZoomDurationMs, + zoomInEasing: this.config.zoomInEasing, + zoomOutEasing: this.config.zoomOutEasing, + connectedZoomEasing: this.config.connectedZoomEasing, + borderRadius: this.config.borderRadius, + padding: this.config.padding, + cropRegion: this.config.cropRegion, + webcam: this.config.webcam, + webcamUrl: this.config.webcamUrl, + videoWidth: videoInfo.width, + videoHeight: videoInfo.height, + annotationRegions: this.config.annotationRegions, + autoCaptions: this.config.autoCaptions, + autoCaptionSettings: this.config.autoCaptionSettings, + speedRegions: this.config.speedRegions, + previewWidth: this.config.previewWidth, + previewHeight: this.config.previewHeight, + cursorTelemetry: this.config.cursorTelemetry, + showCursor: this.config.showCursor, + cursorStyle: this.config.cursorStyle, + cursorSize: this.config.cursorSize, + cursorSmoothing: this.config.cursorSmoothing, + cursorSpringStiffnessMultiplier: this.config.cursorSpringStiffnessMultiplier, + cursorSpringDampingMultiplier: this.config.cursorSpringDampingMultiplier, + cursorSpringMassMultiplier: this.config.cursorSpringMassMultiplier, + cameraSpringStiffnessMultiplier: this.config.cameraSpringStiffnessMultiplier, + cameraSpringDampingMultiplier: this.config.cameraSpringDampingMultiplier, + cameraSpringMassMultiplier: this.config.cameraSpringMassMultiplier, + cursorMotionBlur: this.config.cursorMotionBlur, + cursorClickEffect: this.config.cursorClickEffect, + cursorClickEffectColor: this.config.cursorClickEffectColor, + cursorClickEffectScale: this.config.cursorClickEffectScale, + cursorClickEffectOpacity: this.config.cursorClickEffectOpacity, + cursorClickEffectDurationMs: this.config.cursorClickEffectDurationMs, + cursorClickBounce: this.config.cursorClickBounce, + cursorClickBounceDuration: this.config.cursorClickBounceDuration, + cursorSway: this.config.cursorSway, + zoomSmoothness: this.config.zoomSmoothness, + zoomClassicMode: this.config.zoomClassicMode, + }); + await this.renderer.initialize(); + this.rendererInitTimeMs = this.getNowMs() - stageStartedAt; + this.renderBackend = this.renderer.getRendererBackend(); + console.log(`[VideoExporter] Using ${this.renderBackend} render backend`); + + if (!useNativeEncoder) { + const hasAudio = nativeAudioPlan.audioMode !== "none"; + this.muxer = new VideoMuxer( + this.config, + hasAudio && !shouldUseFfmpegAudioFallback, + ); + await this.muxer.initialize(); + } + + console.log("[VideoExporter] Original duration:", videoInfo.duration, "s"); + console.log("[VideoExporter] Effective duration:", effectiveDuration, "s"); + console.log("[VideoExporter] Total frames to export:", totalFrames); + console.log( + `[VideoExporter] Using ${useNativeEncoder ? `${NATIVE_EXPORT_ENGINE_NAME} native` : "WebCodecs"} encode path`, + ); + + const frameDuration = 1_000_000 / this.config.frameRate; // in microseconds + let frameIndex = 0; + this.exportStartTimeMs = this.getNowMs(); + this.lastThroughputLogTimeMs = this.exportStartTimeMs; + this.lastProgressSampleTimeMs = this.exportStartTimeMs; + this.lastProgressSampleFrame = 0; + this.displayedRenderFps = 0; + const decodeLoopStartedAt = this.getNowMs(); + + await this.streamingDecoder.decodeAll( + this.config.frameRate, + this.config.trimRegions, + this.config.speedRegions, + async ( + videoFrame, + _exportTimestampUs, + sourceTimestampMs, + cursorTimestampMs, + ) => { + const callbackStartedAt = this.getNowMs(); + if (this.cancelled) { + return; + } + + const timestamp = frameIndex * frameDuration; + const sourceTimestampUs = sourceTimestampMs * 1000; + const cursorTimestampUs = cursorTimestampMs * 1000; + const renderStartedAt = this.getNowMs(); + await this.renderer!.renderFrame( + videoFrame, + sourceTimestampUs, + cursorTimestampUs, + frameDuration, + timestamp, + ); + this.renderFrameTimeMs += this.getNowMs() - renderStartedAt; + + if (this.cancelled) { + return; + } + + if (useNativeEncoder) { + await this.encodeRenderedFrameNative( + timestamp, + frameDuration, + frameIndex, + ); + } else { + await this.encodeRenderedFrame(timestamp, frameDuration, frameIndex); + } + this.frameCallbackTimeMs += this.getNowMs() - callbackStartedAt; + frameIndex++; + this.processedFrameCount = frameIndex; + this.reportProgress(frameIndex, totalFrames, "extracting"); + }, + ); + this.decodeLoopTimeMs = this.getNowMs() - decodeLoopStartedAt; + + if (this.cancelled) { + if (this.encoderError) { + return { + success: false, + error: this.buildLightningExportError(this.encoderError), + metrics: this.buildExportMetrics(), + }; + } + + return { + success: false, + error: "Export cancelled", + metrics: this.buildExportMetrics(), + }; + } + + this.reportFinalizingProgress(totalFrames, 96); + + if (useNativeEncoder) { + stageStartedAt = this.getNowMs(); + this.reportFinalizingProgress(totalFrames, 99); + if (this.nativeH264Encoder) { + await this.measureFinalizationStage("nativeEncoderFlushMs", async () => { + await this.nativeH264Encoder!.flush(); + }); + } + const finishResult = await this.finishNativeVideoExport(nativeAudioPlan); + this.finalizationTimeMs = this.getNowMs() - stageStartedAt; + if ( + !finishResult.success || + (!finishResult.tempFilePath && !finishResult.blob) + ) { + return { + success: false, + error: + finishResult.error || `${NATIVE_EXPORT_ENGINE_NAME} export failed`, + metrics: this.buildExportMetrics(), + }; + } + + return { + success: true, + tempFilePath: finishResult.tempFilePath, + blob: finishResult.blob, + metrics: this.buildExportMetrics(), + }; + } + + stageStartedAt = this.getNowMs(); + if (this.encoder && this.encoder.state === "configured") { + this.reportFinalizingProgress(totalFrames, 97); + await this.measureFinalizationStage("encoderFlushMs", async () => { + await this.awaitWithFinalizationTimeout( + this.encoder!.flush(), + "encoder flush", + ); + }); + } + + this.reportFinalizingProgress(totalFrames, 98); + await this.measureFinalizationStage("queuedMuxingMs", async () => { + await this.awaitWithFinalizationTimeout( + this.pendingMuxing, + "muxing queued video chunks", + ); + }); + + // Surface muxing errors before proceeding with finalization + if (this.encoderError) { + throw this.encoderError; + } + + if ( + nativeAudioPlan.audioMode !== "none" && + !shouldUseFfmpegAudioFallback && + !this.cancelled + ) { + const demuxer = this.streamingDecoder.getDemuxer(); + if ( + demuxer || + (this.config.audioRegions ?? []).length > 0 || + (this.config.sourceAudioFallbackPaths ?? []).length > 0 + ) { + this.audioProcessor = new AudioProcessor(); + this.audioProcessor.setOnProgress((progress) => { + this.reportFinalizingProgress(totalFrames, 99, progress); + }); + this.reportFinalizingProgress(totalFrames, 99); + await this.measureFinalizationStage("audioProcessingMs", async () => { + await this.awaitWithFinalizationTimeout( + this.audioProcessor!.process( + demuxer, + this.muxer!, + this.config.videoUrl, + this.config.trimRegions, + this.config.speedRegions, + undefined, + this.config.audioRegions, + this.config.sourceAudioFallbackPaths, + this.config.sourceAudioFallbackStartDelayMsByPath, + this.config.sourceAudioTrackSettings, + this.config.clipRegions, + ), + "audio processing", + "audio", + true, + ); + }); + } + } + + this.reportFinalizingProgress(totalFrames, 99); + const muxerResult = await this.measureFinalizationStage( + "muxerFinalizeMs", + async () => + this.awaitWithFinalizationTimeout( + this.muxer!.finalize(), + "muxer finalization", + nativeAudioPlan.audioMode !== "none" && !shouldUseFfmpegAudioFallback + ? "audio" + : "default", + ), + ); + + if (shouldUseFfmpegAudioFallback) { + console.warn( + shouldUsePitchPreservingFfmpegAudio + ? "[VideoExporter] Using FFmpeg audio muxing for pitch-preserving speed edits." + : "[VideoExporter] Browser AAC encoding is unavailable; falling back to FFmpeg audio muxing.", + ); + const muxedResult = await this.finalizeExportWithFfmpegAudio( + muxerResult, + nativeAudioPlan, + ); + this.finalizationTimeMs = this.getNowMs() - stageStartedAt; + if (!muxedResult.success || (!muxedResult.blob && !muxedResult.tempFilePath)) { + return { + success: false, + error: muxedResult.error || "Failed to mux audio with FFmpeg", + metrics: this.buildExportMetrics(), + }; + } + + return { + success: true, + blob: muxedResult.blob, + tempFilePath: muxedResult.tempFilePath, + metrics: muxedResult.metrics ?? this.buildExportMetrics(), + }; + } + + this.finalizationTimeMs = this.getNowMs() - stageStartedAt; + if (muxerResult.mode === "stream") { + return { + success: true, + tempFilePath: muxerResult.tempFilePath, + metrics: this.buildExportMetrics(), + }; + } + return { + success: true, + blob: muxerResult.blob, + metrics: this.buildExportMetrics(), + }; + } catch (error) { + if ( + !useFallbackMediaSource && + !retriedWithFallbackMediaSource && + this.shouldRetryWithFallbackMediaSource(error) + ) { + retriedWithFallbackMediaSource = true; + useFallbackMediaSource = true; + shouldRetryWithFallbackMediaSource = true; + console.warn( + "[VideoExporter] Primary decode path failed; retrying export once with a fresh media source.", + error, + ); + } else { + if (this.cancelled && !this.encoderError) { + return { + success: false, + error: "Export cancelled", + metrics: this.buildExportMetrics(), + }; + } + + const resolvedError = this.encoderError ?? error; + console.error("Export error:", error); + return { + success: false, + error: this.buildLightningExportError(resolvedError), + metrics: this.buildExportMetrics(), + }; + } + } finally { + if (!shouldRetryWithFallbackMediaSource && this.totalExportStartTimeMs > 0) { + console.log( + `[VideoExporter] Final metrics ${JSON.stringify(this.buildExportMetrics())}`, + ); + } + this.cleanup(); + } + + if (shouldRetryWithFallbackMediaSource) { + continue; + } + } + } + + private shouldRetryWithFallbackMediaSource(error: unknown): boolean { + const resolvedError = this.encoderError ?? error; + const message = + resolvedError instanceof Error ? resolvedError.message : String(resolvedError); + const normalizedMessage = message.toLowerCase(); + return MEDIA_SOURCE_RETRY_ERROR_TOKENS.some((token) => normalizedMessage.includes(token)); + } + + private getPlatformLabel(): string { + switch (this.getRuntimePlatform()) { + case "win32": + return "Windows"; + case "linux": + return "Linux"; + case "darwin": + return "macOS"; + default: + if (typeof navigator === "undefined") { + return "Unknown"; + } + + return navigator.platform || navigator.userAgent || "Unknown"; + } + } + + private getRuntimePlatform() { + if (typeof navigator === "undefined") { + return "unknown"; + } + + return normalizeLightningRuntimePlatform(navigator.platform || navigator.userAgent || ""); + } + + private getLightningErrorGuidance(message: string): string[] { + const guidance = new Set<string>(); + const platform = this.getPlatformLabel(); + + guidance.add( + "Lightning is designed to work on macOS, Windows, and Linux, but the available encoder path depends on WebCodecs support, GPU drivers, and the bundled FFmpeg encoders.", + ); + + if (/even output dimensions/i.test(message)) { + guidance.add( + "Use an export size with even width and height. Switching quality presets usually fixes this automatically.", + ); + } + + if ( + /not supported on this system|H\.264 encoding|encoder path .* is not supported|Video encoding/i.test( + message, + ) + ) { + guidance.add("Try Good or Medium quality to reduce output resolution and bitrate."); + guidance.add( + "Update GPU and media drivers so system H.264 encoding paths are available.", + ); + } + + if (this.lastNativeExportError) { + guidance.add( + `Check that the packaged FFmpeg build includes a compatible ${NATIVE_EXPORT_ENGINE_NAME} encoder path for ${platform}, plus libx264 as a software fallback.`, + ); + } + + if (platform === "Windows") { + guidance.add( + "Windows Lightning exports can use WebCodecs or FFmpeg encoders such as h264_nvenc, h264_qsv, h264_amf, h264_mf, or libx264 depending on the machine.", + ); + } else if (platform === "Linux") { + guidance.add( + "Linux Lightning exports can use WebCodecs when supported, or FFmpeg encoders such as libx264 and optional GPU paths depending on the distro build.", + ); + } else if (platform === "macOS") { + guidance.add( + "macOS Lightning exports can use WebCodecs or VideoToolbox/libx264 through Breeze depending on the output profile.", + ); + } + + return [...guidance]; + } + + private buildLightningExportError(error: unknown): string { + const message = error instanceof Error ? error.message : String(error); + const resolvedEncodePath = + this.encodeBackend === "ffmpeg" + ? `${NATIVE_EXPORT_ENGINE_NAME} native` + : this.encodeBackend === "webcodecs" + ? "WebCodecs" + : null; + const lines = [ + `${LIGHTNING_PIPELINE_NAME} export failed.`, + `Reason: ${message}`, + `Platform: ${this.getPlatformLabel()}`, + `Requested backend mode: ${this.config.backendPreference ?? "auto"}`, + `Output: ${this.config.width}x${this.config.height} @ ${this.config.frameRate} FPS`, + ]; + + if (this.renderBackend) { + lines.push(`Renderer: ${this.renderBackend}`); + } + + if (resolvedEncodePath) { + lines.push( + `Encoder path: ${resolvedEncodePath}${this.encoderName ? ` (${this.encoderName})` : ""}`, + ); + } + + if (this.lastNativeExportError && !message.includes(this.lastNativeExportError)) { + lines.push(`${NATIVE_EXPORT_ENGINE_NAME} fallback: ${this.lastNativeExportError}`); + } + + const guidance = this.getLightningErrorGuidance(message); + if (guidance.length > 0) { + lines.push("Suggested actions:"); + for (const item of guidance) { + lines.push(`- ${item}`); + } + } + + return lines.join("\n"); + } + + private async awaitWithFinalizationTimeout<T>( + promise: Promise<T>, + stage: string, + workload: FinalizationTimeoutWorkload = "default", + progressAware = false, + ): Promise<T> { + return withFinalizationTimeout({ + promise, + stage, + effectiveDurationSec: this.effectiveDurationSec, + workload, + progressAware, + onWatchdogChanged: (watchdog) => { + this.activeFinalizationProgressWatchdog = watchdog; + }, + }); + } + + private getNativeVideoSourcePath(): string | null { + return this.config.videoUrl ? getLocalFilePath(this.config.videoUrl) : null; + } + + private getNativeWebcamSourcePath(): string | null { + const source = this.config.webcam?.sourcePath || this.config.webcamUrl || ""; + return source ? getLocalFilePath(source) : null; + } + + private async loadNativeStaticLayoutVideoInfo(): Promise<DecodedVideoInfo | null> { + if (typeof window === "undefined" || !window.electronAPI?.probeNativeVideoMetadata) { + return null; + } + + const sourcePath = this.getNativeVideoSourcePath(); + if (!sourcePath) { + return null; + } + + const startedAt = this.getNowMs(); + try { + const result = await window.electronAPI.probeNativeVideoMetadata(sourcePath); + this.metadataLoadTimeMs = this.getNowMs() - startedAt; + if (!result.success || !result.metadata) { + console.info("[VideoExporter] Native metadata probe unavailable", { + error: result.error, + }); + return null; + } + + return result.metadata; + } catch (error) { + this.metadataLoadTimeMs = this.getNowMs() - startedAt; + console.info("[VideoExporter] Native metadata probe failed", error); + return null; + } + } + + private buildNativeTrimSegments(durationMs: number): Array<{ startMs: number; endMs: number }> { + const trimRegions = [...(this.config.trimRegions ?? [])].sort( + (a, b) => a.startMs - b.startMs, + ); + if (trimRegions.length === 0) { + return [{ startMs: 0, endMs: Math.max(0, durationMs) }]; + } + + const segments: Array<{ startMs: number; endMs: number }> = []; + let cursorMs = 0; + + for (const region of trimRegions) { + const startMs = Math.max(0, Math.min(region.startMs, durationMs)); + const endMs = Math.max(startMs, Math.min(region.endMs, durationMs)); + if (startMs > cursorMs) { + segments.push({ startMs: cursorMs, endMs: startMs }); + } + cursorMs = Math.max(cursorMs, endMs); + } + + if (cursorMs < durationMs) { + segments.push({ startMs: cursorMs, endMs: durationMs }); + } + + return segments.filter((segment) => segment.endMs - segment.startMs > 0.5); + } + + private getNativeStaticLayoutEffectiveDuration(videoInfo: DecodedVideoInfo): number { + const sourceDurationSec = getEffectiveVideoStreamDurationSeconds({ + duration: videoInfo.duration, + streamDuration: videoInfo.streamDuration, + }); + const sourceDurationMs = sourceDurationSec * 1000; + const speedRegions = this.config.speedRegions ?? []; + if (speedRegions.length > 0) { + const timelineSegments = this.buildNativeStaticLayoutSourceSegments(sourceDurationMs); + if (timelineSegments.length > 0) { + return timelineSegments.reduce( + (totalSec, segment) => + totalSec + (segment.endMs - segment.startMs) / segment.speed / 1000, + 0, + ); + } + } + + const trimSegments = this.buildNativeTrimSegments(sourceDurationMs); + return trimSegments.reduce( + (totalSec, segment) => totalSec + (segment.endMs - segment.startMs) / 1000, + 0, + ); + } + + private buildNativeStaticLayoutSourceSegments(sourceDurationMs: number) { + if (!Number.isFinite(sourceDurationMs) || sourceDurationMs <= 0) { + return []; + } + + const speedRegions = this.config.speedRegions ?? []; + if ( + speedRegions.some( + (region) => + !Number.isFinite(region.startMs) || + !Number.isFinite(region.endMs) || + !canUseNativeStaticLayoutSpeed(region.speed), + ) + ) { + return []; + } + + const normalizedSpeedRegions = speedRegions + .map((region) => ({ + startMs: Math.max(0, Math.min(region.startMs, sourceDurationMs)), + endMs: Math.max(0, Math.min(region.endMs, sourceDurationMs)), + speed: region.speed, + })) + .filter((region) => region.endMs - region.startMs > 0.5); + const sourceSegments: Array<{ + startMs: number; + endMs: number; + speed: number; + }> = []; + + for (const keptRange of this.buildNativeTrimSegments(sourceDurationMs)) { + const boundaries = new Set<number>([keptRange.startMs, keptRange.endMs]); + for (const speedRegion of normalizedSpeedRegions) { + const startMs = Math.max(keptRange.startMs, speedRegion.startMs); + const endMs = Math.min(keptRange.endMs, speedRegion.endMs); + if (endMs - startMs > 0.5) { + boundaries.add(startMs); + boundaries.add(endMs); + } + } + + const orderedBoundaries = [...boundaries].sort((left, right) => left - right); + for (let index = 0; index < orderedBoundaries.length - 1; index += 1) { + const startMs = orderedBoundaries[index] ?? 0; + const endMs = orderedBoundaries[index + 1] ?? 0; + if (endMs - startMs <= 0.5) { + continue; + } + + const midpointMs = startMs + (endMs - startMs) / 2; + const speedRegion = normalizedSpeedRegions.find( + (region) => midpointMs >= region.startMs && midpointMs < region.endMs, + ); + sourceSegments.push({ + startMs, + endMs, + speed: speedRegion?.speed ?? 1, + }); + } + } + + return sourceSegments; + } + + private buildNativeStaticLayoutVideoTimelineSegments( + videoInfo: DecodedVideoInfo, + ): NativeStaticLayoutTimelineSegment[] { + const sourceDurationMs = Math.max( + 0, + Math.round((videoInfo.streamDuration ?? videoInfo.duration) * 1000), + ); + const sourceSegments = this.buildNativeStaticLayoutSourceSegments(sourceDurationMs); + return buildNativeStaticLayoutTimelineSegments(sourceSegments); + } + + private getNativeAudioFallbackPaths(videoInfo: DecodedVideoInfo): string[] { + const sourceAudioFallbackPaths = (this.config.sourceAudioFallbackPaths ?? []).filter( + (audioPath) => typeof audioPath === "string" && audioPath.trim().length > 0, + ); + const localVideoSourcePath = this.getNativeVideoSourcePath(); + if (!videoInfo.hasAudio || !localVideoSourcePath) { + return sourceAudioFallbackPaths; + } + + const { externalAudioPaths } = resolveSourceAudioFallbackPaths( + localVideoSourcePath, + sourceAudioFallbackPaths, + ); + if (externalAudioPaths.length === 0) { + return sourceAudioFallbackPaths; + } + + return [localVideoSourcePath, ...externalAudioPaths]; + } + + private shouldUseNativeStaticLayoutTimelineMap( + videoInfo: DecodedVideoInfo, + effectiveDurationSec: number, + ): boolean { + const speedRegions = this.config.speedRegions ?? []; + if (speedRegions.length > 0) { + return true; + } + + const trimRegions = this.config.trimRegions ?? []; + return ( + trimRegions.length > 0 && + !this.canUseNativeStaticTailTrim(videoInfo, effectiveDurationSec) + ); + } + + private buildNativeAudioPlan(videoInfo: DecodedVideoInfo): NativeAudioPlan { + const speedRegions = this.config.speedRegions ?? []; + const audioRegions = this.config.audioRegions ?? []; + const sourceAudioFallbackPaths = this.getNativeAudioFallbackPaths(videoInfo); + const hasTimedSourceAudioFallback = sourceAudioFallbackPaths.some( + (audioPath) => + (this.config.sourceAudioFallbackStartDelayMsByPath?.[audioPath] ?? 0) > 0, + ); + const localVideoSourcePath = this.getNativeVideoSourcePath(); + const primaryAudioSourcePath = + (videoInfo.hasAudio ? localVideoSourcePath : null) ?? + sourceAudioFallbackPaths[0] ?? + null; + const usesEmbeddedPrimaryAudio = + Boolean(videoInfo.hasAudio) && primaryAudioSourcePath === localVideoSourcePath; + const primaryAudioSourceSampleRate = usesEmbeddedPrimaryAudio + ? videoInfo.audioSampleRate + : FILTERGRAPH_FALLBACK_AUDIO_SAMPLE_RATE; + const primaryAudioSourceCodec = usesEmbeddedPrimaryAudio ? videoInfo.audioCodec : undefined; + + if ( + !videoInfo.hasAudio && + sourceAudioFallbackPaths.length === 0 && + audioRegions.length === 0 + ) { + return { audioMode: "none" }; + } + + if ( + speedRegions.length > 0 || + audioRegions.length > 0 || + sourceAudioFallbackPaths.length > 1 || + hasTimedSourceAudioFallback || + hasNonDefaultSourceTrackSettings(this.config.sourceAudioTrackSettings) || + (this.config.clipRegions ?? []).some((clip) => Boolean(clip.muted)) + ) { + const sourceDurationMs = Math.max( + 0, + Math.round( + getEffectiveVideoStreamDurationSeconds({ + duration: videoInfo.duration, + streamDuration: videoInfo.streamDuration, + }) * 1000, + ), + ); + const trimRegions = this.config.trimRegions ?? []; + const canUsePrimaryAudioFiltergraph = + Boolean(primaryAudioSourcePath) && + !hasTimedSourceAudioFallback && + (usesEmbeddedPrimaryAudio || + sourceAudioFallbackPaths.includes(primaryAudioSourcePath ?? "")) && + typeof primaryAudioSourceSampleRate === "number" && + Number.isFinite(primaryAudioSourceSampleRate) && + primaryAudioSourceSampleRate > 0; + const requiresRenderedEditedTrack = + hasNonDefaultSourceTrackSettings(this.config.sourceAudioTrackSettings) || + (this.config.clipRegions ?? []).some((clip) => Boolean(clip.muted)); + const strategy = + canUsePrimaryAudioFiltergraph && !requiresRenderedEditedTrack + ? classifyEditedTrackStrategy({ + primaryAudioSourcePath, + sourceDurationMs, + trimRegions, + speedRegions, + audioRegions, + sourceAudioFallbackPaths, + }) + : "offline-render-fallback"; + + if (strategy === "filtergraph-fast-path") { + const audioSourcePath = primaryAudioSourcePath; + const audioSourceSampleRate = primaryAudioSourceSampleRate; + const editedTrackSegments = buildEditedTrackSourceSegments( + sourceDurationMs, + trimRegions, + speedRegions, + ); + if ( + audioSourcePath && + typeof audioSourceSampleRate === "number" && + editedTrackSegments.length > 0 + ) { + return { + audioMode: "edited-track", + strategy, + audioSourcePath, + audioSourceCodec: primaryAudioSourceCodec, + audioSourceSampleRate, + editedTrackSegments, + }; + } + } + + return { + audioMode: "edited-track", + strategy: "offline-render-fallback", + sourceAudioFallbackPaths, + }; + } + + if (!primaryAudioSourcePath) { + return { + audioMode: "edited-track", + strategy: "offline-render-fallback", + sourceAudioFallbackPaths, + }; + } + + if ((this.config.trimRegions ?? []).length > 0) { + const sourceDurationMs = Math.max( + 0, + Math.round( + getEffectiveVideoStreamDurationSeconds({ + duration: videoInfo.duration, + streamDuration: videoInfo.streamDuration, + }) * 1000, + ), + ); + const trimSegments = this.buildNativeTrimSegments(sourceDurationMs); + if (trimSegments.length === 0) { + return { audioMode: "none" }; + } + + return { + audioMode: "trim-source", + audioSourcePath: primaryAudioSourcePath, + audioSourceCodec: primaryAudioSourceCodec, + trimSegments, + }; + } + + return { + audioMode: "copy-source", + audioSourcePath: primaryAudioSourcePath, + audioSourceCodec: primaryAudioSourceCodec, + }; + } + + private isDefaultCropRegion(): boolean { + const crop = this.config.cropRegion; + const epsilon = 0.0001; + return ( + Math.abs(crop.x) <= epsilon && + Math.abs(crop.y) <= epsilon && + Math.abs(crop.width - 1) <= epsilon && + Math.abs(crop.height - 1) <= epsilon + ); + } + + private getNativeStaticLayoutSourceCrop(videoInfo: DecodedVideoInfo) { + const crop = this.config.cropRegion; + const sourceWidth = Math.max(2, Math.round(videoInfo.width)); + const sourceHeight = Math.max(2, Math.round(videoInfo.height)); + const cropX = Math.min(1, Math.max(0, crop.x)); + const cropY = Math.min(1, Math.max(0, crop.y)); + const cropRight = Math.min(1, Math.max(cropX, crop.x + crop.width)); + const cropBottom = Math.min(1, Math.max(cropY, crop.y + crop.height)); + + const left = Math.min(sourceWidth - 2, Math.max(0, Math.floor(cropX * sourceWidth))) & ~1; + const top = Math.min(sourceHeight - 2, Math.max(0, Math.floor(cropY * sourceHeight))) & ~1; + const right = Math.min(sourceWidth, Math.max(left + 2, Math.ceil(cropRight * sourceWidth))); + const bottom = Math.min( + sourceHeight, + Math.max(top + 2, Math.ceil(cropBottom * sourceHeight)), + ); + const width = Math.max(2, right - left) & ~1; + const height = Math.max(2, bottom - top) & ~1; + + return { + x: left, + y: top, + width: Math.min(width, sourceWidth - left), + height: Math.min(height, sourceHeight - top), + }; + } + + private canUseNativeStaticTailTrim( + videoInfo: DecodedVideoInfo, + effectiveDurationSec: number, + ): boolean { + const trimRegions = this.config.trimRegions ?? []; + if (trimRegions.length === 0) { + return true; + } + + if (trimRegions.length !== 1) { + return false; + } + + const [trim] = trimRegions; + const sourceDurationMs = Math.max( + 0, + Math.round((videoInfo.streamDuration ?? videoInfo.duration) * 1000), + ); + const outputDurationMs = Math.max(0, Math.round(effectiveDurationSec * 1000)); + const toleranceMs = 250; + + return ( + Math.abs(trim.startMs - outputDurationMs) <= toleranceMs && + Math.abs(trim.endMs - sourceDurationMs) <= toleranceMs + ); + } + + private canUseNativeStaticLayoutAudioPlan(audioPlan: NativeAudioPlan): boolean { + switch (audioPlan.audioMode) { + case "none": + case "copy-source": + return true; + case "trim-source": + return true; + case "edited-track": + return ( + audioPlan.strategy === "offline-render-fallback" || + audioPlan.strategy === "filtergraph-fast-path" + ); + } + } + + private hasUnsupportedNativeStaticLayoutWebcamShape(): boolean { + const webcam = this.config.webcam; + if (!webcam?.enabled) { + return false; + } + + const width = webcam.width ?? webcam.size ?? 40; + const height = webcam.height ?? webcam.size ?? 40; + return Math.abs(width - height) > 0.001 || !isWebcamCropRegionDefault(webcam.cropRegion); + } + + private getNativeStaticLayoutSkipReasons( + audioPlan: NativeAudioPlan, + videoInfo: DecodedVideoInfo, + effectiveDurationSec: number, + ): string[] { + const reasons: string[] = []; + if ( + typeof window === "undefined" || + !window.electronAPI?.nativeStaticLayoutExport || + !window.electronAPI?.nativeStaticLayoutExportCancel + ) { + reasons.push("native-static-api-unavailable"); + } + + if (this.config.width % 2 !== 0 || this.config.height % 2 !== 0) { + reasons.push("odd-output-dimensions"); + } + + if (!this.canUseNativeStaticLayoutAudioPlan(audioPlan)) { + reasons.push(`unsupported-audio-mode:${audioPlan.audioMode}`); + } + + const speedRegions = this.config.speedRegions ?? []; + const hasCursorClickEffect = + (this.config.cursorTelemetry?.length ?? 0) > 0 && + (this.config.cursorClickEffect ?? "none") !== "none"; + const configuredWallpaper = this.config.wallpaper?.trim() ?? ""; + if (isVideoWallpaperSource(configuredWallpaper)) { + reasons.push("unsupported-background-video"); + } + if (hasCursorClickEffect) { + reasons.push("unsupported-cursor-click-effect"); + } + + const hasZoomRegions = (this.config.zoomRegions ?? []).length > 0; + const needsTimelineMap = this.shouldUseNativeStaticLayoutTimelineMap( + videoInfo, + effectiveDurationSec, + ); + if (needsTimelineMap && this.config.experimentalNativeExport !== true) { + reasons.push("native-timeline-requires-windows-gpu"); + } + if ( + needsTimelineMap && + this.buildNativeStaticLayoutVideoTimelineSegments(videoInfo).length === 0 + ) { + reasons.push( + speedRegions.length > 0 + ? "unsupported-native-speed-timeline" + : "unsupported-native-trim-timeline", + ); + } + if (hasZoomRegions && this.config.experimentalNativeExport !== true) { + reasons.push("native-zoom-requires-windows-gpu"); + } + if ((this.config.annotationRegions ?? []).length > 0) { + reasons.push("unsupported-annotation-overlay"); + } + if ((this.config.autoCaptions ?? []).length > 0) { + reasons.push("unsupported-caption-overlay"); + } + if (this.config.webcam?.enabled) { + // Native GPU compositors use a different corner and shadow model. + // Keep webcam exports on the shared renderer used by preview. + reasons.push("native-webcam-style-mismatch"); + } + + if (this.config.webcam?.enabled && !this.getNativeWebcamSourcePath()) { + reasons.push("unsupported-webcam-source"); + } + if (this.hasUnsupportedNativeStaticLayoutWebcamShape()) { + reasons.push("unsupported-rectangular-webcam-overlay"); + } + + const crop = this.config.cropRegion; + if ( + !Number.isFinite(crop.x) || + !Number.isFinite(crop.y) || + !Number.isFinite(crop.width) || + !Number.isFinite(crop.height) || + crop.width <= 0 || + crop.height <= 0 + ) { + reasons.push("invalid-crop-region"); + } + + return reasons; + } + + private getNativeStaticLayoutSkipReason( + audioPlan: NativeAudioPlan, + videoInfo: DecodedVideoInfo, + effectiveDurationSec: number, + ): string | null { + return ( + this.getNativeStaticLayoutSkipReasons(audioPlan, videoInfo, effectiveDurationSec)[0] ?? + null + ); + } + + private async resolveNativeStaticLayoutBackground(): Promise<NativeStaticLayoutBackground | null> { + this.nativeStaticLayoutBackgroundSkipReason = null; + const configuredWallpaper = this.config.wallpaper?.trim() ?? ""; + const wallpaper = configuredWallpaper || DEFAULT_WALLPAPER_PATH; + if (/^#?[0-9a-f]{6}$/i.test(wallpaper)) { + return { + backgroundColor: wallpaper.startsWith("#") ? wallpaper : `#${wallpaper}`, + backgroundImagePath: null, + }; + } + + if (wallpaper.startsWith("data:image/") || wallpaper.startsWith("blob:")) { + const materialized = await this.materializeNativeStaticLayoutImageSource(wallpaper); + if (materialized) { + return materialized; + } + this.nativeStaticLayoutBackgroundSkipReason = + "unsupported-background-image-materialize-failed"; + return null; + } + + if (wallpaper.startsWith("linear-gradient") || wallpaper.startsWith("radial-gradient")) { + const materialized = + await this.materializeNativeStaticLayoutGradientBackground(wallpaper); + if (materialized) { + return materialized; + } + this.nativeStaticLayoutBackgroundSkipReason = + "unsupported-background-gradient-materialize-failed"; + return null; + } + + if (isVideoWallpaperSource(wallpaper)) { + this.nativeStaticLayoutBackgroundSkipReason = "unsupported-background-video"; + return null; + } + + if (wallpaper.startsWith("data:") || wallpaper.startsWith("blob:")) { + this.nativeStaticLayoutBackgroundSkipReason = "unsupported-background-data-or-blob"; + return null; + } + + if (wallpaper.startsWith("http")) { + this.nativeStaticLayoutBackgroundSkipReason = "unsupported-background-remote"; + return null; + } + + if (wallpaper.startsWith("/wallpapers/") || wallpaper.startsWith("/app-icons/")) { + const assetPath = await this.resolveNativeBundledAssetPath(wallpaper); + if (assetPath) { + return { backgroundColor: "#101010", backgroundImagePath: assetPath }; + } + + const fallbackAssetPath = await this.resolveNativeBundledAssetPath( + `/${DEFAULT_WALLPAPER_RELATIVE_PATH}`, + ); + return fallbackAssetPath + ? { backgroundColor: "#101010", backgroundImagePath: fallbackAssetPath } + : { + backgroundColor: MISSING_NATIVE_WALLPAPER_FALLBACK_COLOR, + backgroundImagePath: null, + }; + } + + const localPath = getLocalFilePath(wallpaper); + if (localPath) { + return { backgroundColor: "#101010", backgroundImagePath: localPath }; + } + + this.nativeStaticLayoutBackgroundSkipReason = "unsupported-background-local-path"; + return null; + } + + private async materializeNativeStaticLayoutImageSource( + imageSource: string, + ): Promise<NativeStaticLayoutBackground | null> { + if (typeof fetch !== "function") { + return null; + } + + try { + const response = await fetch(imageSource); + if (!response.ok) { + return null; + } + + const blob = await response.blob(); + const mimeType = (blob.type || this.getDataUrlMimeType(imageSource)).toLowerCase(); + if (!mimeType.startsWith("image/")) { + return null; + } + + const extension = this.getNativeStaticLayoutImageExtension(mimeType); + if (!extension) { + return null; + } + + const tempPath = await this.writeNativeStaticLayoutTempAsset( + new Uint8Array(await blob.arrayBuffer()), + extension, + ); + return tempPath + ? { + backgroundColor: "#101010", + backgroundImagePath: tempPath, + temporaryPath: tempPath, + } + : null; + } catch (error) { + console.warn("[VideoExporter] Unable to materialize native background image", error); + return null; + } + } + + private async materializeNativeStaticLayoutGradientBackground( + wallpaper: string, + ): Promise<NativeStaticLayoutBackground | null> { + if (typeof document === "undefined") { + return null; + } + + try { + const canvas = document.createElement("canvas"); + canvas.width = Math.max(1, Math.round(this.config.width)); + canvas.height = Math.max(1, Math.round(this.config.height)); + const ctx = canvas.getContext("2d"); + if (!ctx) { + return null; + } + + const gradient = this.createNativeStaticLayoutGradient(ctx, wallpaper); + if (!gradient) { + return null; + } + + ctx.fillStyle = gradient; + ctx.fillRect(0, 0, canvas.width, canvas.height); + const blob = await new Promise<Blob | null>((resolve) => + canvas.toBlob(resolve, "image/png"), + ); + if (!blob) { + return null; + } + + const tempPath = await this.writeNativeStaticLayoutTempAsset( + new Uint8Array(await blob.arrayBuffer()), + "png", + ); + return tempPath + ? { + backgroundColor: "#101010", + backgroundImagePath: tempPath, + temporaryPath: tempPath, + } + : null; + } catch (error) { + console.warn("[VideoExporter] Unable to materialize native gradient background", error); + return null; + } + } + + private createNativeStaticLayoutGradient( + ctx: CanvasRenderingContext2D, + wallpaper: string, + ): CanvasGradient | null { + const gradientMatch = wallpaper.match(/(linear|radial)-gradient\((.+)\)/); + if (!gradientMatch) { + return null; + } + + const [, type, params] = gradientMatch; + const parts = this.splitCssGradientArguments(params).map((part) => part.trim()); + const colorStops = parts + .map( + (part) => + part.match(/^(#[0-9a-fA-F]{3,8}|rgba?\([^)]+\)|hsla?\([^)]+\)|[a-z]+)/i)?.[1], + ) + .filter((color): color is string => Boolean(color)); + if (colorStops.length === 0) { + return null; + } + + const gradient = + type === "linear" + ? ctx.createLinearGradient(0, 0, 0, this.config.height) + : ctx.createRadialGradient( + this.config.width / 2, + this.config.height / 2, + 0, + this.config.width / 2, + this.config.height / 2, + Math.max(this.config.width, this.config.height) / 2, + ); + + if (colorStops.length === 1) { + gradient.addColorStop(0, colorStops[0]); + gradient.addColorStop(1, colorStops[0]); + return gradient; + } + + colorStops.forEach((color, index) => { + gradient.addColorStop(index / (colorStops.length - 1), color); + }); + return gradient; + } + + private splitCssGradientArguments(params: string): string[] { + const parts: string[] = []; + let current = ""; + let depth = 0; + + for (const char of params) { + if (char === "(") { + depth++; + current += char; + continue; + } + if (char === ")") { + depth = Math.max(0, depth - 1); + current += char; + continue; + } + if (char === "," && depth === 0) { + if (current.trim()) { + parts.push(current.trim()); + } + current = ""; + continue; + } + + current += char; + } + + if (current.trim()) { + parts.push(current.trim()); + } + + return parts; + } + + private async writeNativeStaticLayoutTempAsset( + bytes: Uint8Array, + extension: string, + ): Promise<string | null> { + if (typeof window === "undefined") { + return null; + } + + const api = window.electronAPI; + if ( + !api?.openExportStream || + !api.writeExportStreamChunk || + !api.closeExportStream || + bytes.byteLength === 0 + ) { + return null; + } + + let streamId: string | undefined; + try { + const openResult = await api.openExportStream({ extension }); + if (!openResult.success || !openResult.streamId) { + return null; + } + + streamId = openResult.streamId; + const writeResult = await api.writeExportStreamChunk(streamId, 0, bytes); + if (!writeResult.success) { + throw new Error(writeResult.error || "Failed to write native background temp file"); + } + + const closeResult = await api.closeExportStream(streamId); + streamId = undefined; + return closeResult.success && closeResult.tempPath ? closeResult.tempPath : null; + } catch (error) { + console.warn("[VideoExporter] Unable to write native background temp file", error); + if (streamId) { + await api.closeExportStream(streamId, { abort: true }).catch(() => undefined); + } + return null; + } + } + + private async cleanupNativeStaticLayoutBackground( + background: NativeStaticLayoutBackground | null | undefined, + ) { + const temporaryPath = background?.temporaryPath; + if (!temporaryPath || typeof window === "undefined") { + return; + } + + try { + await window.electronAPI?.discardExportedTemp?.(temporaryPath); + } catch { + // Best-effort cleanup for temporary materialized background assets. + } + } + + private getDataUrlMimeType(dataUrl: string) { + return dataUrl.match(/^data:([^;,]+)/)?.[1] ?? ""; + } + + private getNativeStaticLayoutImageExtension(mimeType: string): string | null { + if (mimeType === "image/jpeg" || mimeType === "image/jpg") return "jpg"; + if (mimeType === "image/png") return "png"; + if (mimeType === "image/bmp") return "bmp"; + return null; + } + + private async resolveNativeBundledAssetPath(assetPath: string): Promise<string | null> { + const normalizedAssetPath = assetPath.replace(/^\/+/, ""); + const [assetDirectory, fileName] = normalizedAssetPath.split("/"); + if (!assetDirectory || !fileName) { + return null; + } + + try { + const result = await window.electronAPI?.listAssetDirectory?.(assetDirectory); + if ( + result?.success && + result.files && + !result.files.includes(decodeURIComponent(fileName)) + ) { + console.warn("[VideoExporter] Native static layout wallpaper asset is missing", { + assetPath, + }); + return null; + } + } catch { + // Keep native export opportunistic when directory probing is unavailable. + } + + const assetBasePath = await window.electronAPI?.getAssetBasePath?.(); + if (!assetBasePath) { + return null; + } + + const assetUrl = new URL(normalizedAssetPath, assetBasePath).toString(); + return getLocalFilePath(assetUrl); + } + + private async renderEditedAudioForNativeMux( + description: string, + onProgress: (progress: number) => void, + sourceAudioFallbackPaths = this.config.sourceAudioFallbackPaths, + ) { + this.audioProcessor = new AudioProcessor(); + this.audioProcessor.setOnProgress(onProgress); + const audioBlob = await this.measureFinalizationStage("editedAudioRenderMs", async () => + this.awaitWithFinalizationTimeout( + this.audioProcessor!.renderEditedAudioTrack( + this.config.videoUrl, + this.config.trimRegions, + this.config.speedRegions, + this.config.audioRegions, + sourceAudioFallbackPaths, + this.config.sourceAudioFallbackStartDelayMsByPath, + this.config.sourceAudioTrackSettings, + this.config.clipRegions, + ), + description, + "audio", + true, + ), + ); + + return { + editedAudioData: await audioBlob.arrayBuffer(), + editedAudioMimeType: audioBlob.type || null, + }; + } + + private async getNativeStaticLayoutAudioOptions( + audioPlan: NativeAudioPlan, + totalFrames: number, + ) { + switch (audioPlan.audioMode) { + case "none": + return { audioMode: "none" as const }; + case "copy-source": + case "trim-source": + return { + audioMode: audioPlan.audioMode, + audioSourcePath: audioPlan.audioSourcePath, + audioSourceCodec: audioPlan.audioSourceCodec, + trimSegments: audioPlan.trimSegments, + }; + case "edited-track": { + if (audioPlan.strategy === "filtergraph-fast-path") { + return { + audioMode: audioPlan.audioMode, + audioSourcePath: audioPlan.audioSourcePath, + audioSourceCodec: audioPlan.audioSourceCodec, + audioSourceSampleRate: audioPlan.audioSourceSampleRate, + editedTrackStrategy: audioPlan.strategy, + editedTrackSegments: audioPlan.editedTrackSegments, + }; + } + + const renderedAudio = await this.renderEditedAudioForNativeMux( + "Native static-layout edited audio rendering", + (progress) => + this.reportProgress(0, totalFrames, "preparing", undefined, progress), + audioPlan.sourceAudioFallbackPaths, + ); + + return { + audioMode: audioPlan.audioMode, + editedTrackStrategy: audioPlan.strategy, + ...renderedAudio, + }; + } + } + } + + private getNativeStaticLayoutWebcamOverlay(): NativeStaticLayoutWebcamOverlay | null { + const webcam = this.config.webcam; + if (!webcam?.enabled) { + return null; + } + + const inputPath = this.getNativeWebcamSourcePath(); + if (!inputPath) { + return null; + } + + const margin = webcam.margin ?? 24; + const rawSize = getWebcamOverlaySizePx({ + containerWidth: this.config.width, + containerHeight: this.config.height, + sizePercent: webcam.width ?? webcam.size ?? 40, + margin, + zoomScale: 1, + reactToZoom: webcam.reactToZoom ?? true, + }); + const size = Math.max(2, Math.round(rawSize / 2) * 2); + const position = getWebcamOverlayPosition({ + containerWidth: this.config.width, + containerHeight: this.config.height, + size, + margin, + positionPreset: webcam.positionPreset ?? webcam.corner, + positionX: webcam.positionX ?? 1, + positionY: webcam.positionY ?? 1, + legacyCorner: webcam.corner, + }); + + return { + inputPath, + left: Math.round(position.x), + top: Math.round(position.y), + size, + radius: getWebcamCornerRadiusPx( + webcam.roundness ?? DEFAULT_WEBCAM_ROUNDNESS, + size, + size, + ), + shadowIntensity: getWebcamShadowStrength(webcam.shadow ?? 0), + mirror: webcam.mirror !== false, + timeOffsetMs: Number.isFinite(webcam.timeOffsetMs) ? webcam.timeOffsetMs : 0, + }; + } + + private getNativeStaticLayoutCursorTelemetry(): + | Array<{ + timeMs: number; + cx: number; + cy: number; + cursorType?: CursorTelemetryPoint["cursorType"]; + cursorTypeIndex?: number; + bounceScale?: number; + }> + | undefined { + const telemetry = this.config.cursorTelemetry ?? []; + if (this.config.showCursor !== true || telemetry.length === 0) { + return undefined; + } + + return buildNativeStaticLayoutCursorTelemetry(telemetry, { + frameRate: this.config.frameRate, + durationSec: this.effectiveDurationSec || 0, + clickBounce: this.config.cursorClickBounce, + clickBounceDurationMs: this.config.cursorClickBounceDuration, + sourceCrop: this.config.cropRegion, + }); + } + + private getNativeStaticLayoutCursorSize(contentWidth: number) { + const cursorStyle = this.config.cursorStyle ?? "tahoe"; + const viewportScale = getCursorViewportScale(contentWidth); + return ( + 28 * + (this.config.cursorSize ?? 3) * + viewportScale * + getCursorStyleSizeMultiplier(cursorStyle) + ); + } + + private getNativeStaticLayoutZoomTelemetry( + layout: ReturnType<typeof computePaddedLayout>, + totalFrames: number, + cursorTelemetry: + | Array<{ + timeMs: number; + cx: number; + cy: number; + cursorType?: CursorTelemetryPoint["cursorType"]; + cursorTypeIndex?: number; + bounceScale?: number; + }> + | undefined, + ): NativeStaticLayoutZoomSample[] | undefined { + const zoomRegions = this.config.zoomRegions ?? []; + if (zoomRegions.length === 0 || totalFrames <= 0) { + return undefined; + } + + const stageSize = { width: this.config.width, height: this.config.height }; + const baseMask = { + x: layout.centerOffsetX, + y: layout.centerOffsetY, + width: layout.croppedDisplayWidth, + height: layout.croppedDisplayHeight, + sourceCrop: this.config.cropRegion, + }; + const cursorFollowCamera = createCursorFollowCameraState(); + const springScale = createSpringState(1); + const springX = createSpringState(0); + const springY = createSpringState(0); + const zoomSpringConfig = getZoomSpringConfig(this.config.zoomSmoothness); + const frameDurationMs = 1000 / Math.max(1, this.config.frameRate); + const samples: NativeStaticLayoutZoomSample[] = []; + let lastContentTimeMs: number | null = null; + let appliedScale = 1; + let appliedX = 0; + let appliedY = 0; + + for (let frameIndex = 0; frameIndex < totalFrames; frameIndex += 1) { + const timeMs = frameIndex * frameDurationMs; + const { region, strength, blendedScale } = findDominantRegion(zoomRegions, timeMs, { + connectZooms: this.config.connectZooms, + }); + + let targetScale = 1; + let targetFocus = DEFAULT_FOCUS; + let targetProgress = 0; + + if (region && strength > 0) { + const zoomScale = blendedScale ?? ZOOM_DEPTH_SCALES[region.depth]; + let regionFocus = region.focus; + if ( + !this.config.zoomClassicMode && + region.mode !== "manual" && + (cursorTelemetry?.length ?? 0) > 0 + ) { + regionFocus = computeCursorFollowFocus( + cursorFollowCamera, + cursorTelemetry ?? [], + timeMs, + zoomScale, + strength, + region.focus, + { snapToEdgesRatio: SNAP_TO_EDGES_RATIO_AUTO }, + ); + } + + targetScale = zoomScale; + targetFocus = regionFocus; + targetProgress = strength; + } + + const projectedTransform = computeZoomTransform({ + stageSize, + baseMask, + zoomScale: targetScale, + zoomProgress: targetProgress, + focusX: targetFocus.cx, + focusY: targetFocus.cy, + }); + const deltaMs = + lastContentTimeMs !== null ? timeMs - lastContentTimeMs : frameDurationMs; + lastContentTimeMs = timeMs; + + if (this.config.zoomClassicMode) { + appliedScale = projectedTransform.scale; + appliedX = projectedTransform.x; + appliedY = projectedTransform.y; + resetSpringState(springScale, appliedScale); + resetSpringState(springX, appliedX); + resetSpringState(springY, appliedY); + } else { + appliedScale = stepSpringValue( + springScale, + projectedTransform.scale, + deltaMs, + zoomSpringConfig, + ); + appliedX = stepSpringValue( + springX, + projectedTransform.x, + deltaMs, + zoomSpringConfig, + ); + appliedY = stepSpringValue( + springY, + projectedTransform.y, + deltaMs, + zoomSpringConfig, + ); + } + + samples.push({ + timeMs, + scale: appliedScale, + x: appliedX, + y: appliedY, + }); + } + + return samples; + } + + private async tryExportNativeStaticLayout( + videoInfo: DecodedVideoInfo, + audioPlan: NativeAudioPlan, + effectiveDuration: number, + totalFrames: number, + ): Promise<ExportResult | null> { + const skipReason = this.getNativeStaticLayoutSkipReason( + audioPlan, + videoInfo, + effectiveDuration, + ); + const skipReasons = skipReason + ? this.getNativeStaticLayoutSkipReasons(audioPlan, videoInfo, effectiveDuration) + : []; + if (skipReason) { + this.nativeStaticLayoutSkipReason = skipReason; + this.nativeStaticLayoutSkipReasons = skipReasons; + console.info("[VideoExporter] Native static layout skipped", { + reason: skipReason, + reasons: skipReasons, + audioMode: audioPlan.audioMode, + zoomRegions: this.config.zoomRegions?.length ?? 0, + speedRegions: this.config.speedRegions?.length ?? 0, + audioRegions: this.config.audioRegions?.length ?? 0, + annotationRegions: this.config.annotationRegions?.length ?? 0, + backgroundBlur: this.config.backgroundBlur, + hasCursorOverlay: + this.config.showCursor === true && + (this.config.cursorTelemetry?.length ?? 0) > 0, + experimentalNativeExport: this.config.experimentalNativeExport === true, + }); + return null; + } + + const sourcePath = this.getNativeVideoSourcePath(); + const audioOptions = await this.getNativeStaticLayoutAudioOptions(audioPlan, totalFrames); + if (!sourcePath || !audioOptions) { + this.nativeStaticLayoutSkipReason = !sourcePath + ? "missing-source-path" + : "missing-audio-options"; + this.nativeStaticLayoutSkipReasons = [this.nativeStaticLayoutSkipReason]; + return null; + } + const background = await this.resolveNativeStaticLayoutBackground(); + if (!background) { + this.nativeStaticLayoutSkipReason = + this.nativeStaticLayoutBackgroundSkipReason ?? "unsupported-background"; + this.nativeStaticLayoutSkipReasons = [this.nativeStaticLayoutSkipReason]; + return null; + } + + const layout = computePaddedLayout({ + width: this.config.width, + height: this.config.height, + padding: this.config.padding ?? 0, + cropRegion: this.config.cropRegion, + videoWidth: videoInfo.width, + videoHeight: videoInfo.height, + }); + const contentSize = roundNativeStaticLayoutContentSize({ + width: layout.croppedDisplayWidth, + height: layout.croppedDisplayHeight, + }); + const contentWidth = contentSize.width; + const contentHeight = contentSize.height; + if ( + contentWidth > this.config.width || + contentHeight > this.config.height || + !Number.isFinite(effectiveDuration) || + effectiveDuration <= 0 + ) { + this.nativeStaticLayoutSkipReason = "invalid-layout-or-duration"; + this.nativeStaticLayoutSkipReasons = [this.nativeStaticLayoutSkipReason]; + await this.cleanupNativeStaticLayoutBackground(background); + return null; + } + + const offsetX = Math.round(layout.centerOffsetX); + const offsetY = Math.round(layout.centerOffsetY); + const sourceCrop = this.isDefaultCropRegion() + ? null + : this.getNativeStaticLayoutSourceCrop(videoInfo); + const borderRadius = scalePreviewBorderRadius( + this.config.width, + this.config.height, + this.config.borderRadius ?? 0, + ); + const shadowIntensity = this.config.showShadow + ? Math.min(1, Math.max(0, this.config.shadowIntensity)) + : 0; + const webcamOverlay = this.getNativeStaticLayoutWebcamOverlay(); + const cursorTelemetry = this.getNativeStaticLayoutCursorTelemetry(); + const zoomTelemetry = this.getNativeStaticLayoutZoomTelemetry( + layout, + totalFrames, + cursorTelemetry, + ); + const needsTimelineMap = this.shouldUseNativeStaticLayoutTimelineMap( + videoInfo, + effectiveDuration, + ); + const timelineSegments = needsTimelineMap + ? this.buildNativeStaticLayoutVideoTimelineSegments(videoInfo) + : undefined; + if (needsTimelineMap && !timelineSegments?.length) { + this.nativeStaticLayoutSkipReason = + (this.config.speedRegions ?? []).length > 0 + ? "invalid-native-speed-timeline" + : "invalid-native-trim-timeline"; + this.nativeStaticLayoutSkipReasons = [this.nativeStaticLayoutSkipReason]; + await this.cleanupNativeStaticLayoutBackground(background); + return null; + } + const cursorAtlas = + cursorTelemetry && cursorTelemetry.length > 0 + ? await buildNativeCursorAtlas(this.config.cursorStyle ?? "tahoe").catch( + (error) => { + console.warn("[VideoExporter] Native cursor atlas unavailable", error); + return null; + }, + ) + : null; + if (cursorTelemetry && cursorTelemetry.length > 0 && !cursorAtlas) { + this.nativeStaticLayoutSkipReason = "cursor-atlas-unavailable"; + this.nativeStaticLayoutSkipReasons = [this.nativeStaticLayoutSkipReason]; + await this.cleanupNativeStaticLayoutBackground(background); + return null; + } + const startedAt = this.getNowMs(); + const sessionId = `recordly-static-layout-${Date.now()}-${Math.random() + .toString(36) + .slice(2, 8)}`; + const previousEncodeBackend = this.encodeBackend; + const previousEncoderName = this.encoderName; + const restoreEncoderState = () => { + this.encodeBackend = previousEncodeBackend; + this.encoderName = previousEncoderName; + }; + + this.exportStartTimeMs = startedAt; + this.lastThroughputLogTimeMs = startedAt; + this.lastProgressSampleTimeMs = startedAt; + this.lastProgressSampleFrame = 0; + this.nativeStaticLayoutSessionId = sessionId; + this.nativeStaticLayoutSkipReason = null; + this.nativeStaticLayoutSkipReasons = []; + this.nativeStaticLayoutAverageFps = null; + this.encodeBackend = "ffmpeg"; + const runtimePlatform = + typeof navigator !== "undefined" + ? normalizeLightningRuntimePlatform(navigator.userAgent) + : "unknown"; + this.encoderName = + this.config.experimentalNativeExport === true && runtimePlatform === "win32" + ? "windows-native-compositor" + : "static-layout-h264-nvenc"; + this.reportProgress(0, totalFrames, "preparing"); + let unsubscribeNativeProgress: (() => void) | undefined; + unsubscribeNativeProgress = window.electronAPI.onNativeStaticLayoutExportProgress?.( + (progress) => { + if (progress.sessionId && progress.sessionId !== sessionId) { + return; + } + if ( + !Number.isFinite(progress.currentFrame) || + !Number.isFinite(progress.totalFrames) + ) { + return; + } + + const nativeTotalFrames = Math.max(1, Math.floor(progress.totalFrames)); + const rawNativeCurrentFrame = Math.max(0, Math.floor(progress.currentFrame)); + const rawNativePercentage = Number.isFinite(progress.percentage) + ? Math.max(0, progress.percentage) + : 0; + if ( + progress.backend === "nvidia-cuda-compositor" || + progress.backend === "windows-d3d11-compositor" + ) { + this.encoderName = progress.backend; + } + if ( + progress.stage === "preparing" || + (progress.stage !== "finalizing" && + rawNativeCurrentFrame === 0 && + rawNativePercentage <= 3) + ) { + this.nativeStaticLayoutAverageFps = null; + this.processedFrameCount = 0; + this.reportProgress(0, totalFrames, "preparing"); + return; + } + const progressPercentFrame = Number.isFinite(progress.percentage) + ? Math.floor((nativeTotalFrames * progress.percentage) / 100) + : 0; + const nativeCurrentFrame = Math.max(rawNativeCurrentFrame, progressPercentFrame); + const nativeFramesComplete = nativeCurrentFrame >= nativeTotalFrames; + const nativeFinalizingProgress = + progress.stage === "finalizing" && Number.isFinite(progress.percentage) + ? Math.max( + NATIVE_STATIC_LAYOUT_FRAME_COMPLETE_PROGRESS, + Math.min(99, progress.percentage), + ) + : NATIVE_STATIC_LAYOUT_FRAME_COMPLETE_PROGRESS; + const maxExtractingFrame = Math.max( + 0, + Math.min( + totalFrames - 1, + Math.floor( + totalFrames * (NATIVE_STATIC_LAYOUT_MAX_EXTRACTING_PROGRESS / 100), + ), + ), + ); + const currentFrame = Math.min( + maxExtractingFrame, + Math.max(this.processedFrameCount, nativeCurrentFrame), + ); + this.nativeStaticLayoutAverageFps = + progress.stage === "finalizing" + ? null + : typeof progress.instantFps === "number" && + Number.isFinite(progress.instantFps) && + progress.instantFps > 0 + ? progress.instantFps + : typeof progress.averageFps === "number" && + Number.isFinite(progress.averageFps) && + progress.averageFps > 0 + ? progress.averageFps + : null; + this.processedFrameCount = currentFrame; + if (progress.stage === "finalizing" || nativeFramesComplete) { + this.reportFinalizingProgress(totalFrames, nativeFinalizingProgress); + } else { + this.reportProgress(currentFrame, totalFrames, "extracting"); + } + }, + ); + + try { + const result = await window.electronAPI.nativeStaticLayoutExport({ + sessionId, + inputPath: sourcePath, + width: this.config.width, + height: this.config.height, + frameRate: this.config.frameRate, + bitrate: this.config.bitrate, + encodingMode: this.config.encodingMode ?? "balanced", + durationSec: effectiveDuration, + contentWidth, + contentHeight, + offsetX, + offsetY, + sourceCropX: sourceCrop?.x, + sourceCropY: sourceCrop?.y, + sourceCropWidth: sourceCrop?.width, + sourceCropHeight: sourceCrop?.height, + backgroundColor: background.backgroundColor, + backgroundImagePath: background.backgroundImagePath ?? null, + backgroundBlurPx: Math.max(0, (this.config.backgroundBlur ?? 0) * 3), + borderRadius, + shadowIntensity, + webcamInputPath: webcamOverlay?.inputPath ?? null, + webcamLeft: webcamOverlay?.left, + webcamTop: webcamOverlay?.top, + webcamSize: webcamOverlay?.size, + webcamRadius: webcamOverlay?.radius, + webcamShadowIntensity: webcamOverlay?.shadowIntensity, + webcamMirror: webcamOverlay?.mirror, + webcamTimeOffsetMs: webcamOverlay?.timeOffsetMs, + cursorTelemetry, + cursorSize: this.getNativeStaticLayoutCursorSize(contentWidth), + cursorAtlasPngDataUrl: cursorAtlas?.dataUrl ?? null, + cursorAtlasEntries: cursorAtlas?.entries, + zoomTelemetry, + timelineSegments, + chunkDurationSec: STATIC_LAYOUT_CHUNK_DURATION_SEC, + experimentalWindowsGpuCompositor: this.config.experimentalNativeExport === true, + experimentalNvidiaCudaExport: this.config.experimentalNvidiaCudaExport === true, + audioOptions: { + ...audioOptions, + outputDurationSec: effectiveDuration, + }, + }); + + if (this.cancelled) { + return { + success: false, + error: "Export cancelled", + metrics: this.buildExportMetrics(), + }; + } + + if (!result.success || !result.tempPath) { + console.warn("[VideoExporter] Native static layout export unavailable", { + error: result.error, + }); + restoreEncoderState(); + return null; + } + + const elapsedMs = this.getNowMs() - startedAt; + this.encoderName = result.encoderName ?? "static-layout-h264-nvenc"; + this.nativeStaticLayoutAverageFps = null; + this.processedFrameCount = totalFrames; + this.decodeLoopTimeMs = result.metrics?.chunkExecMs ?? elapsedMs; + this.finalizationTimeMs = Math.max(0, elapsedMs - this.decodeLoopTimeMs); + this.finalizationStageMs.nativeExportFinalizeMs = elapsedMs; + if (result.metrics) { + const metrics: ExportFfmpegAudioMuxBreakdown = { + tempVideoWriteMs: result.metrics.tempVideoWriteMs, + tempEditedAudioWriteMs: result.metrics.tempEditedAudioWriteMs, + ffmpegExecMs: result.metrics.ffmpegExecMs, + muxedVideoReadMs: result.metrics.muxedVideoReadMs, + tempVideoBytes: result.metrics.tempVideoBytes, + tempEditedAudioBytes: result.metrics.tempEditedAudioBytes, + muxedVideoBytes: result.metrics.muxedVideoBytes, + chunkCount: result.metrics.chunkCount, + chunkDurationSec: result.metrics.chunkDurationSec, + chunkExecMs: result.metrics.chunkExecMs, + concatExecMs: result.metrics.concatExecMs, + staticAssetExecMs: result.metrics.staticAssetExecMs, + fallbackChunkCount: result.metrics.fallbackChunkCount, + videoOnlyBytes: result.metrics.videoOnlyBytes, + chunks: result.metrics.chunks, + }; + this.finalizationStageMs.ffmpegAudioMuxBreakdown = metrics; + } + this.reportFinalizingProgress(totalFrames, 99); + + return { + success: true, + tempFilePath: result.tempPath, + metrics: this.buildExportMetrics(), + }; + } catch (error) { + if (this.cancelled) { + return { + success: false, + error: "Export cancelled", + metrics: this.buildExportMetrics(), + }; + } + + console.warn("[VideoExporter] Native static layout export failed; falling back", error); + this.nativeStaticLayoutSkipReason = "native-static-runtime-failed"; + this.nativeStaticLayoutSkipReasons = [this.nativeStaticLayoutSkipReason]; + restoreEncoderState(); + return null; + } finally { + unsubscribeNativeProgress?.(); + await this.cleanupNativeStaticLayoutBackground(background); + if (this.nativeStaticLayoutSessionId === sessionId) { + this.nativeStaticLayoutSessionId = null; + } + } + } + + private async tryStartNativeVideoExport(): Promise<boolean> { + this.lastNativeExportError = null; + + if (typeof window === "undefined" || !window.electronAPI?.nativeVideoExportStart) { + this.lastNativeExportError = `${NATIVE_EXPORT_ENGINE_NAME} export is not available in this build.`; + return false; + } + + if (this.config.width % 2 !== 0 || this.config.height % 2 !== 0) { + this.lastNativeExportError = `${NATIVE_EXPORT_ENGINE_NAME} export requires even output dimensions (${this.config.width}x${this.config.height}).`; + console.warn( + `[VideoExporter] ${NATIVE_EXPORT_ENGINE_NAME} export requires even output dimensions, falling back to WebCodecs (${this.config.width}x${this.config.height})`, + ); + return false; + } + + if ( + typeof VideoEncoder === "undefined" || + typeof VideoEncoder.isConfigSupported !== "function" + ) { + this.lastNativeExportError = `${NATIVE_EXPORT_ENGINE_NAME} export requires WebCodecs VideoEncoder support.`; + return false; + } + + const encoderConfig: VideoEncoderConfig = { + codec: "avc1.640034", + width: this.config.width, + height: this.config.height, + bitrate: this.config.bitrate, + framerate: this.config.frameRate, + hardwareAcceleration: "prefer-hardware", + avc: { format: "annexb" }, + }; + + try { + const support = await VideoEncoder.isConfigSupported(encoderConfig); + if (!support.supported) { + this.lastNativeExportError = `H.264 Annex B encoding is not supported at ${this.config.width}x${this.config.height}.`; + return false; + } + } catch (error) { + this.lastNativeExportError = error instanceof Error ? error.message : String(error); + console.warn( + `[VideoExporter] ${NATIVE_EXPORT_ENGINE_NAME} encoder support check failed`, + error, + ); + return false; + } + + const result = await window.electronAPI.nativeVideoExportStart({ + width: this.config.width, + height: this.config.height, + frameRate: this.config.frameRate, + bitrate: this.config.bitrate, + encodingMode: this.config.encodingMode ?? "balanced", + inputMode: "h264-stream", + }); + + if (!result.success || !result.sessionId) { + this.lastNativeExportError = + result.error || + `${NATIVE_EXPORT_ENGINE_NAME} export could not be started on this system.`; + console.warn( + `[VideoExporter] ${NATIVE_EXPORT_ENGINE_NAME} export unavailable`, + result.error, + ); + return false; + } + + this.nativeExportSessionId = result.sessionId; + this.lastNativeExportError = null; + this.encodeBackend = "ffmpeg"; + this.encoderName = "h264-stream-copy"; + this.pendingNativeWriteChunks = []; + this.pendingNativeWriteBytes = 0; + + const sessionId = result.sessionId; + const encoder = new VideoEncoder({ + output: (chunk) => { + if (this.cancelled || !this.nativeExportSessionId) { + return; + } + + const buffer = new ArrayBuffer(chunk.byteLength); + chunk.copyTo(buffer); + this.queueNativeWriteChunk(sessionId, new Uint8Array(buffer)); + }, + error: (error) => { + this.nativeEncoderError = error; + this.notifyEncodeCapacityAvailable(); + }, + }); + + try { + encoder.configure(encoderConfig); + } catch (error) { + this.lastNativeExportError = error instanceof Error ? error.message : String(error); + try { + encoder.close(); + } catch (closeError) { + console.debug( + "[VideoExporter] Ignoring error closing native H.264 encoder after startup failure:", + closeError, + ); + } + this.nativeExportSessionId = null; + await window.electronAPI.nativeVideoExportCancel?.(sessionId); + console.warn( + `[VideoExporter] ${NATIVE_EXPORT_ENGINE_NAME} encoder configure failed`, + error, + ); + return false; + } + + this.nativeH264Encoder = encoder; + + console.log(`[VideoExporter] ${NATIVE_EXPORT_ENGINE_NAME} session ready (H264-stream)`, { + sessionId: result.sessionId, + }); + return true; + } + + private async encodeRenderedFrameNative( + timestamp: number, + frameDuration: number, + frameIndex: number, + ): Promise<void> { + if (!this.nativeH264Encoder || !this.nativeExportSessionId) { + if (this.cancelled) return; + throw new Error(`${NATIVE_EXPORT_ENGINE_NAME} export session is not active`); + } + if (this.nativeEncoderError) throw this.nativeEncoderError; + while (this.nativeWritePromises.size >= this.maxNativeWriteInFlight) { + await this.awaitOldestNativeWrite(); + if (this.cancelled) return; + if (this.nativeEncoderError) throw this.nativeEncoderError; + } + while ( + this.nativeH264Encoder.encodeQueueSize >= ModernVideoExporter.NATIVE_ENCODER_QUEUE_LIMIT + ) { + await this.waitForEncodeCapacity(); + if (this.cancelled) return; + if (this.nativeEncoderError) throw this.nativeEncoderError; + } + const canvas = this.renderer!.getCanvas(); + // @ts-expect-error - colorSpace is supported at runtime but missing from this DOM typing. + const frame = new VideoFrame(canvas, { + timestamp, + duration: frameDuration, + colorSpace: EXPORT_CANVAS_COLOR_SPACE, + }); + this.nativeH264Encoder.encode(frame, { keyFrame: frameIndex % 300 === 0 }); + frame.close(); + } + + private async finishNativeVideoExport(audioPlan: NativeAudioPlan): Promise<ExportResult> { + if (!this.nativeExportSessionId) { + return { + success: false, + error: `${NATIVE_EXPORT_ENGINE_NAME} export session is not active`, + }; + } + + let editedAudioBuffer: ArrayBuffer | undefined; + let editedAudioMimeType: string | null = null; + + if ( + audioPlan.audioMode === "edited-track" && + audioPlan.strategy === "offline-render-fallback" + ) { + const renderedAudio = await this.renderEditedAudioForNativeMux( + `${NATIVE_EXPORT_ENGINE_NAME} edited audio rendering`, + (progress) => this.reportFinalizingProgress(this.processedFrameCount, 99, progress), + audioPlan.sourceAudioFallbackPaths, + ); + editedAudioBuffer = renderedAudio.editedAudioData; + editedAudioMimeType = renderedAudio.editedAudioMimeType; + } + + const sessionId = this.nativeExportSessionId; + console.log(`[VideoExporter] Finalizing ${NATIVE_EXPORT_ENGINE_NAME} export`, { + sessionId, + audioMode: audioPlan.audioMode, + editedTrackStrategy: + audioPlan.audioMode === "edited-track" ? audioPlan.strategy : undefined, + encoderName: this.encoderName ?? "unknown", + }); + + this.flushPendingNativeWriteBatch(sessionId); + await this.awaitPendingNativeWrites(); + + const result = await this.measureFinalizationStage("nativeExportFinalizeMs", async () => + this.awaitWithFinalizationTimeout( + window.electronAPI.nativeVideoExportFinish(sessionId, { + audioMode: audioPlan.audioMode, + audioSourcePath: + audioPlan.audioMode === "copy-source" || + audioPlan.audioMode === "trim-source" || + (audioPlan.audioMode === "edited-track" && + audioPlan.strategy === "filtergraph-fast-path") + ? audioPlan.audioSourcePath + : null, + trimSegments: + audioPlan.audioMode === "trim-source" ? audioPlan.trimSegments : undefined, + editedTrackStrategy: + audioPlan.audioMode === "edited-track" ? audioPlan.strategy : undefined, + editedTrackSegments: + audioPlan.audioMode === "edited-track" && + audioPlan.strategy === "filtergraph-fast-path" + ? audioPlan.editedTrackSegments + : undefined, + outputDurationSec: this.effectiveDurationSec, + audioSourceSampleRate: + audioPlan.audioMode === "edited-track" && + audioPlan.strategy === "filtergraph-fast-path" + ? audioPlan.audioSourceSampleRate + : undefined, + editedAudioData: editedAudioBuffer, + editedAudioMimeType, + }), + `${NATIVE_EXPORT_ENGINE_NAME} export finalization`, + audioPlan.audioMode === "none" ? "default" : "audio", + ), + ); + if (result.metrics) { + this.finalizationStageMs.ffmpegAudioMuxBreakdown = result.metrics; + } + this.nativeExportSessionId = null; + + if (!result.success) { + return { + success: false, + error: result.error || `Failed to finalize ${NATIVE_EXPORT_ENGINE_NAME} export`, + }; + } + + this.encoderName = result.encoderName ?? this.encoderName; + if (!result.tempPath) { + return { + success: false, + error: `${NATIVE_EXPORT_ENGINE_NAME} export did not return a temp path`, + }; + } + + return { + success: true, + tempFilePath: result.tempPath, + }; + } + + private async finalizeExportWithFfmpegAudio( + videoSource: import("./muxer").MuxerFinalizeResult, + audioPlan: NativeAudioPlan, + ): Promise<ExportResult> { + if (typeof window === "undefined") { + return { + success: false, + error: "FFmpeg audio fallback is unavailable in this environment.", + }; + } + + let editedAudioBuffer: ArrayBuffer | undefined; + let editedAudioMimeType: string | null = null; + + if ( + audioPlan.audioMode === "edited-track" && + audioPlan.strategy === "offline-render-fallback" + ) { + const renderedAudio = await this.renderEditedAudioForNativeMux( + "FFmpeg edited audio rendering", + (progress) => this.reportFinalizingProgress(this.processedFrameCount, 99, progress), + audioPlan.sourceAudioFallbackPaths, + ); + editedAudioBuffer = renderedAudio.editedAudioData; + editedAudioMimeType = renderedAudio.editedAudioMimeType; + } + + const muxOptions = { + audioMode: audioPlan.audioMode, + audioSourcePath: + audioPlan.audioMode === "copy-source" || + audioPlan.audioMode === "trim-source" || + (audioPlan.audioMode === "edited-track" && + audioPlan.strategy === "filtergraph-fast-path") + ? audioPlan.audioSourcePath + : null, + trimSegments: + audioPlan.audioMode === "trim-source" ? audioPlan.trimSegments : undefined, + editedTrackStrategy: + audioPlan.audioMode === "edited-track" ? audioPlan.strategy : undefined, + editedTrackSegments: + audioPlan.audioMode === "edited-track" && + audioPlan.strategy === "filtergraph-fast-path" + ? audioPlan.editedTrackSegments + : undefined, + audioSourceSampleRate: + audioPlan.audioMode === "edited-track" && + audioPlan.strategy === "filtergraph-fast-path" + ? audioPlan.audioSourceSampleRate + : undefined, + outputDurationSec: this.effectiveDurationSec, + editedAudioData: editedAudioBuffer, + editedAudioMimeType, + }; + + if (videoSource.mode === "stream") { + if (!window.electronAPI?.muxExportedVideoAudioFromPath) { + return { + success: false, + error: "FFmpeg audio fallback via temp path is unavailable in this environment.", + }; + } + const result = await this.measureFinalizationStage("ffmpegAudioMuxMs", async () => + this.awaitWithFinalizationTimeout( + window.electronAPI.muxExportedVideoAudioFromPath( + videoSource.tempFilePath, + muxOptions, + ), + "FFmpeg audio muxing", + "audio", + ), + ); + if (result.metrics) { + this.finalizationStageMs.ffmpegAudioMuxBreakdown = result.metrics; + } + if (!result.success || !result.tempPath) { + return { + success: false, + error: result.error || "Failed to mux exported audio with FFmpeg", + }; + } + return { success: true, tempFilePath: result.tempPath }; + } + + if (!window.electronAPI?.muxExportedVideoAudio) { + return { + success: false, + error: "FFmpeg audio fallback is unavailable in this environment.", + }; + } + const videoBuffer = await videoSource.blob.arrayBuffer(); + const result = await this.measureFinalizationStage("ffmpegAudioMuxMs", async () => + this.awaitWithFinalizationTimeout( + window.electronAPI.muxExportedVideoAudio(videoBuffer, muxOptions), + "FFmpeg audio muxing", + "audio", + ), + ); + if (result.metrics) { + this.finalizationStageMs.ffmpegAudioMuxBreakdown = result.metrics; + } + + if (!result.success || !result.tempPath) { + return { + success: false, + error: result.error || "Failed to mux exported audio with FFmpeg", + }; + } + + // Returning a temp path (instead of buffering the muxed bytes back into + // the renderer) is what keeps >2 GiB exports off Node's fs.readFile cap. + return { + success: true, + tempFilePath: result.tempPath, + }; + } + + private async encodeRenderedFrame( + timestamp: number, + frameDuration: number, + frameIndex: number, + ) { + const canvas = this.renderer!.getCanvas(); + + // @ts-expect-error - colorSpace not in TypeScript definitions but works at runtime + const exportFrame = new VideoFrame(canvas, { + timestamp, + duration: frameDuration, + colorSpace: EXPORT_CANVAS_COLOR_SPACE, + }); + + while ( + this.encoder && + this.getCurrentEncodeBacklog() >= this.webCodecsEncodeQueueLimit && + !this.cancelled + ) { + const encodeWaitStartedAt = this.getNowMs(); + this.encodeWaitEvents++; + await this.waitForEncodeCapacity(); + this.encodeWaitTimeMs += this.getNowMs() - encodeWaitStartedAt; + } + + try { + if (this.encoder && this.encoder.state === "configured") { + this.peakEncodeQueueSize = Math.max( + this.peakEncodeQueueSize, + this.encoder.encodeQueueSize, + this.encodeQueue, + ); + this.encodeQueue++; + this.encoder.encode(exportFrame, { + keyFrame: frameIndex % Math.max(this.keyFrameInterval, 1) === 0, + }); + this.peakEncodeQueueSize = Math.max( + this.peakEncodeQueueSize, + this.encoder.encodeQueueSize, + this.encodeQueue, + ); + } else { + console.warn( + `[Frame ${frameIndex}] Encoder not ready! State: ${this.encoder?.state}`, + ); + } + } finally { + exportFrame.close(); + } + } + + private reportFinalizingProgress( + totalFrames: number, + renderProgress: number, + audioProgress?: number, + ) { + const nextProgress = advanceFinalizationProgress({ + renderProgress, + audioProgress, + state: { + lastRenderProgress: this.lastFinalizationRenderProgress, + lastAudioProgress: this.lastFinalizationAudioProgress, + }, + }); + if (nextProgress.progressed) { + this.activeFinalizationProgressWatchdog?.refreshProgress(); + } + this.lastFinalizationRenderProgress = nextProgress.lastRenderProgress; + this.lastFinalizationAudioProgress = nextProgress.lastAudioProgress; + this.reportProgress( + totalFrames, + totalFrames, + "finalizing", + nextProgress.lastRenderProgress, + typeof audioProgress === "number" && Number.isFinite(audioProgress) + ? nextProgress.lastAudioProgress + : undefined, + ); + } + + private queueNativeWriteChunk(sessionId: string, chunk: Uint8Array): void { + this.pendingNativeWriteChunks.push(chunk); + this.pendingNativeWriteBytes += chunk.byteLength; + + if ( + this.pendingNativeWriteChunks.length >= + ModernVideoExporter.NATIVE_WRITE_BATCH_MAX_CHUNKS || + this.pendingNativeWriteBytes >= ModernVideoExporter.NATIVE_WRITE_BATCH_MAX_BYTES + ) { + this.flushPendingNativeWriteBatch(sessionId); + } + } + + private flushPendingNativeWriteBatch(sessionId: string): void { + if (this.pendingNativeWriteChunks.length === 0) { + return; + } + + const chunks = this.pendingNativeWriteChunks; + this.pendingNativeWriteChunks = []; + this.pendingNativeWriteBytes = 0; + const writePromise = window.electronAPI + .nativeVideoExportWriteFrames(sessionId, chunks) + .then((writeResult) => { + if (!writeResult.success && !this.cancelled) { + throw new Error( + writeResult.error || "Failed to write H.264 chunks to native encoder", + ); + } + }) + .catch((error) => { + if (!this.cancelled) { + const resolvedError = error instanceof Error ? error : new Error(String(error)); + if (!this.nativeEncoderError) { + this.nativeEncoderError = resolvedError; + } + if (!this.nativeWriteError) { + this.nativeWriteError = resolvedError; + } + } + throw error; + }); + + this.trackNativeWritePromise(writePromise); + this.notifyEncodeCapacityAvailable(); + } + + private waitForEncodeCapacity(): Promise<void> { + return new Promise((resolve) => { + this.encodeCapacityWaiters.add(resolve); + }); + } + + private notifyEncodeCapacityAvailable(): void { + if (this.encodeCapacityWaiters.size === 0) { + return; + } + + const waiters = [...this.encodeCapacityWaiters]; + this.encodeCapacityWaiters.clear(); + for (const resolve of waiters) { + resolve(); + } + } + + private reportProgress( + currentFrame: number, + totalFrames: number, + phase: ExportProgress["phase"] = "extracting", + renderProgress?: number, + audioProgress?: number, + ) { + const nowMs = this.getNowMs(); + const elapsedSeconds = Math.max((nowMs - this.exportStartTimeMs) / 1000, 0.001); + const averageRenderFps = currentFrame / elapsedSeconds; + const sampleElapsedMs = Math.max(nowMs - this.lastProgressSampleTimeMs, 1); + const sampleFrameDelta = Math.max(currentFrame - this.lastProgressSampleFrame, 0); + const sampleRenderFps = (sampleFrameDelta * 1000) / sampleElapsedMs; + if (this.nativeStaticLayoutAverageFps !== null) { + this.displayedRenderFps = this.nativeStaticLayoutAverageFps; + } else if (sampleElapsedMs >= 500 || currentFrame === totalFrames) { + this.displayedRenderFps = + this.displayedRenderFps > 0 + ? this.displayedRenderFps * 0.35 + sampleRenderFps * 0.65 + : sampleRenderFps; + } else if (this.displayedRenderFps <= 0) { + this.displayedRenderFps = averageRenderFps; + } + const displayedRenderFps = + this.displayedRenderFps > 0 ? this.displayedRenderFps : sampleRenderFps; + const remainingFrames = Math.max(totalFrames - currentFrame, 0); + const estimatedTimeRemaining = + averageRenderFps > 0 ? remainingFrames / averageRenderFps : 0; + const safeRenderProgress = + phase === "finalizing" ? Math.max(0, Math.min(renderProgress ?? 100, 100)) : undefined; + const percentage = + phase === "preparing" + ? 0 + : phase === "finalizing" + ? (safeRenderProgress ?? 100) + : totalFrames > 0 + ? (currentFrame / totalFrames) * 100 + : 100; + + if (nowMs - this.lastThroughputLogTimeMs >= 1000 || currentFrame === totalFrames) { + const safeFrameCount = Math.max(this.processedFrameCount, 1); + this.peakEncodeQueueSize = Math.max( + this.peakEncodeQueueSize, + this.getCurrentEncodeBacklog(), + ); + console.log( + `[VideoExporter] Progress ${JSON.stringify({ + phase, + currentFrame, + totalFrames, + elapsedSec: Number(elapsedSeconds.toFixed(2)), + averageRenderFps: Number(averageRenderFps.toFixed(1)), + sampleRenderFps: Number(sampleRenderFps.toFixed(1)), + displayedRenderFps: Number(displayedRenderFps.toFixed(1)), + renderBackend: this.renderBackend ?? undefined, + encodeBackend: this.encodeBackend ?? undefined, + encoderName: this.encoderName ?? undefined, + encoderQueueSize: this.encoder?.encodeQueueSize ?? 0, + pendingEncodeQueue: this.encodeQueue, + encodeBacklog: this.getCurrentEncodeBacklog(), + peakEncodeQueueSize: this.peakEncodeQueueSize, + nativeWriteInFlight: this.nativeWritePromises.size, + peakNativeWriteInFlight: this.peakNativeWriteInFlight, + averageFrameCallbackMs: Number( + (this.frameCallbackTimeMs / safeFrameCount).toFixed(3), + ), + averageRenderFrameMs: Number( + (this.renderFrameTimeMs / safeFrameCount).toFixed(3), + ), + averageEncodeWaitMs: Number( + (this.encodeWaitTimeMs / safeFrameCount).toFixed(3), + ), + averageNativeCaptureMs: + this.nativeCaptureTimeMs > 0 + ? Number((this.nativeCaptureTimeMs / safeFrameCount).toFixed(3)) + : undefined, + averageNativeWriteMs: + this.nativeWriteTimeMs > 0 + ? Number((this.nativeWriteTimeMs / safeFrameCount).toFixed(3)) + : undefined, + })}`, + ); + this.lastThroughputLogTimeMs = nowMs; + this.lastProgressSampleTimeMs = nowMs; + this.lastProgressSampleFrame = currentFrame; + } + + if (this.config.onProgress) { + this.config.onProgress({ + currentFrame, + totalFrames, + percentage, + estimatedTimeRemaining, + renderFps: displayedRenderFps, + renderBackend: this.renderBackend ?? undefined, + encodeBackend: this.encodeBackend ?? undefined, + encoderName: this.encoderName ?? undefined, + nativeStaticLayoutSkipReason: this.nativeStaticLayoutSkipReason ?? undefined, + nativeStaticLayoutSkipReasons: + this.nativeStaticLayoutSkipReasons.length > 0 + ? this.nativeStaticLayoutSkipReasons + : undefined, + phase, + renderProgress: safeRenderProgress, + audioProgress, + }); + } + } + + private buildExportMetrics(): ExportMetrics { + const totalElapsedMs = + this.totalExportStartTimeMs > 0 ? this.getNowMs() - this.totalExportStartTimeMs : 0; + const safeFrameCount = Math.max(this.processedFrameCount, 1); + const hasFinalizationStageMetrics = Object.keys(this.finalizationStageMs).length > 0; + + return { + totalElapsedMs, + metadataLoadMs: this.metadataLoadTimeMs, + rendererInitMs: this.rendererInitTimeMs, + nativeSessionStartMs: this.nativeSessionStartTimeMs, + decodeLoopMs: this.decodeLoopTimeMs, + frameCallbackMs: this.frameCallbackTimeMs, + renderFrameMs: this.renderFrameTimeMs, + encodeWaitMs: this.encodeWaitTimeMs, + encodeWaitEvents: this.encodeWaitEvents, + peakEncodeQueueSize: this.peakEncodeQueueSize, + peakNativeWriteInFlight: this.peakNativeWriteInFlight, + nativeCaptureMs: this.nativeCaptureTimeMs, + nativeWriteMs: this.nativeWriteTimeMs, + finalizationMs: this.finalizationTimeMs, + frameCount: this.processedFrameCount, + renderBackend: this.renderBackend ?? undefined, + encodeBackend: this.encodeBackend ?? undefined, + encoderName: this.encoderName ?? undefined, + backpressureProfile: this.backpressureProfile?.name, + nativeStaticLayoutSkipReason: this.nativeStaticLayoutSkipReason ?? undefined, + nativeStaticLayoutSkipReasons: + this.nativeStaticLayoutSkipReasons.length > 0 + ? this.nativeStaticLayoutSkipReasons + : undefined, + effectiveDurationSec: this.effectiveDurationSec || undefined, + finalizationStageMs: hasFinalizationStageMetrics ? this.finalizationStageMs : undefined, + averageFrameCallbackMs: + this.processedFrameCount > 0 + ? this.frameCallbackTimeMs / safeFrameCount + : undefined, + averageRenderFrameMs: + this.processedFrameCount > 0 ? this.renderFrameTimeMs / safeFrameCount : undefined, + averageEncodeWaitMs: + this.processedFrameCount > 0 ? this.encodeWaitTimeMs / safeFrameCount : undefined, + averageNativeCaptureMs: + this.processedFrameCount > 0 + ? this.nativeCaptureTimeMs / safeFrameCount + : undefined, + averageNativeWriteMs: + this.processedFrameCount > 0 ? this.nativeWriteTimeMs / safeFrameCount : undefined, + }; + } + + private getCurrentEncodeBacklog(): number { + return Math.max(this.encoder?.encodeQueueSize ?? 0, this.encodeQueue); + } + + private trackNativeWritePromise(writePromise: Promise<void>): void { + this.nativeWritePromises.add(writePromise); + this.peakNativeWriteInFlight = Math.max( + this.peakNativeWriteInFlight, + this.nativeWritePromises.size, + ); + + void writePromise.finally(() => { + this.nativeWritePromises.delete(writePromise); + }); + } + + private async awaitOldestNativeWrite(): Promise<void> { + const oldestWritePromise = this.nativeWritePromises.values().next().value; + if (!oldestWritePromise) { + return; + } + + await oldestWritePromise; + + if (this.nativeWriteError) { + throw this.nativeWriteError; + } + } + + private async awaitPendingNativeWrites(): Promise<void> { + while (this.nativeWritePromises.size > 0) { + await this.awaitOldestNativeWrite(); + } + + if (this.nativeWriteError) { + throw this.nativeWriteError; + } + } + + private disposeNativeH264Encoder(): void { + if (!this.nativeH264Encoder) { + return; + } + + try { + this.nativeH264Encoder.close(); + } catch (error) { + console.debug("[VideoExporter] Ignoring error closing native H.264 encoder:", error); + } + + this.nativeH264Encoder = null; + } + + private getNowMs(): number { + if (typeof performance !== "undefined" && typeof performance.now === "function") { + return performance.now(); + } + + return Date.now(); + } + + private async measureFinalizationStage<T>( + stage: keyof ExportFinalizationStageMetrics, + task: () => Promise<T>, + ): Promise<T> { + const startedAt = this.getNowMs(); + try { + return await task(); + } finally { + this.finalizationStageMs[stage] = this.getNowMs() - startedAt; + } + } + + private async initializeEncoder(): Promise<SupportedMp4EncoderPath> { + this.encodeQueue = 0; + this.webCodecsEncodeQueueLimit = + this.config.maxEncodeQueue ?? + this.backpressureProfile?.maxEncodeQueue ?? + getWebCodecsEncodeQueueLimit(this.config.frameRate, this.config.encodingMode); + this.keyFrameInterval = getWebCodecsKeyFrameInterval( + this.config.frameRate, + this.config.encodingMode, + ); + this.pendingMuxing = Promise.resolve(); + this.chunkCount = 0; + let videoDescription: Uint8Array | undefined; + + const encoderCandidates = this.getEncoderCandidates(); + const latencyModePreferences = getPreferredWebCodecsLatencyModes(this.config.encodingMode); + + let resolvedCodec: string | null = null; + + console.log("[VideoExporter] WebCodecs tuning", { + encodingMode: this.config.encodingMode ?? "balanced", + keyFrameInterval: this.keyFrameInterval, + latencyModes: latencyModePreferences, + queueLimit: this.webCodecsEncodeQueueLimit, + }); + + this.encoder = new VideoEncoder({ + output: (chunk, meta) => { + // Capture decoder config metadata from encoder output + if (meta?.decoderConfig?.description && !videoDescription) { + const desc = meta.decoderConfig.description; + videoDescription = ArrayBuffer.isView(desc) + ? new Uint8Array(desc.buffer, desc.byteOffset, desc.byteLength) + : new Uint8Array(desc); + this.videoDescription = videoDescription; + } + // Capture colorSpace from encoder metadata if provided + if (meta?.decoderConfig?.colorSpace && !this.videoColorSpace) { + this.videoColorSpace = meta.decoderConfig.colorSpace; + } + + // Stream chunks to muxer in order without retaining an ever-growing promise array + const isFirstChunk = this.chunkCount === 0; + this.chunkCount++; + + this.pendingMuxing = this.pendingMuxing.then(async () => { + try { + if (isFirstChunk && this.videoDescription) { + // Add decoder config for the first chunk + const colorSpace = + this.videoColorSpace || ENCODED_H264_COLOR_SPACE_FALLBACK; + + const metadata: EncodedVideoChunkMetadata = { + decoderConfig: { + codec: resolvedCodec ?? (this.config.codec || "avc1.640033"), + codedWidth: this.config.width, + codedHeight: this.config.height, + description: this.videoDescription, + colorSpace, + }, + }; + + await this.muxer!.addVideoChunk(chunk, metadata); + } else { + await this.muxer!.addVideoChunk(chunk, meta); + } + } catch (error) { + console.error("Muxing error:", error); + const muxingError = + error instanceof Error ? error : new Error(String(error)); + if (!this.encoderError) { + this.encoderError = muxingError; + } + this.cancelled = true; + } + }); + this.encodeQueue--; + this.notifyEncodeCapacityAvailable(); + }, + error: (error) => { + console.error( + `[VideoExporter] Encoder error (codec: ${resolvedCodec}, ${this.config.width}x${this.config.height}):`, + error, + ); + this.encoderError = error instanceof Error ? error : new Error(String(error)); + this.cancelled = true; + this.notifyEncodeCapacityAvailable(); + }, + }); + + const baseConfig: Omit< + VideoEncoderConfig, + "codec" | "hardwareAcceleration" | "latencyMode" + > = { + width: this.config.width, + height: this.config.height, + bitrate: this.config.bitrate, + framerate: this.config.frameRate, + bitrateMode: "variable", + }; + + for (const candidate of encoderCandidates) { + for (const latencyMode of latencyModePreferences) { + const config: VideoEncoderConfig = { + ...baseConfig, + codec: candidate.codec, + hardwareAcceleration: candidate.hardwareAcceleration, + latencyMode, + }; + const support = await VideoEncoder.isConfigSupported(config); + if (support.supported) { + resolvedCodec = candidate.codec; + this.encodeBackend = "webcodecs"; + this.encoderName = `${candidate.codec}/${candidate.hardwareAcceleration}/${latencyMode}`; + console.log( + `[VideoExporter] Using ${candidate.hardwareAcceleration} ${latencyMode} encoder path with codec ${candidate.codec}`, + ); + this.encoder.configure(config); + return candidate; + } + + console.warn( + `[VideoExporter] Encoder path ${candidate.codec}/${candidate.hardwareAcceleration}/${latencyMode} is not supported (${this.config.width}x${this.config.height}), trying next...`, + ); + } + } + + throw new Error( + `Video encoding not supported on this system. ` + + `Tried encoder paths: ${encoderCandidates + .map((candidate) => `${candidate.codec}/${candidate.hardwareAcceleration}`) + .join(", ")} at ${this.config.width}x${this.config.height}. ` + + `Your browser or hardware may not support H.264 encoding at this resolution. ` + + `Try exporting at a lower quality setting.`, + ); + } + + private getEncoderCandidates(): SupportedMp4EncoderPath[] { + return getOrderedSupportedMp4EncoderCandidates({ + codec: this.config.codec, + preferredEncoderPath: this.config.preferredEncoderPath, + }); + } + + private disposeEncoder(): void { + if (!this.encoder) { + return; + } + + try { + if (this.encoder.state !== "closed") { + this.encoder.close(); + } + } catch (error) { + console.warn("Error closing encoder:", error); + } + + this.encoder = null; + this.encodeQueue = 0; + this.pendingMuxing = Promise.resolve(); + this.chunkCount = 0; + this.videoDescription = undefined; + this.videoColorSpace = undefined; + this.webCodecsEncodeQueueLimit = 0; + this.keyFrameInterval = 0; + this.encodeBackend = null; + this.encoderName = null; + } + + cancel(): void { + this.cancelled = true; + if (this.streamingDecoder) { + this.streamingDecoder.cancel(); + } + if (this.audioProcessor) { + this.audioProcessor.cancel(); + } + this.disposeNativeH264Encoder(); + + const nativeExportSessionId = this.nativeExportSessionId; + this.nativeExportSessionId = null; + if (nativeExportSessionId && typeof window !== "undefined") { + void window.electronAPI?.nativeVideoExportCancel?.(nativeExportSessionId); + } + + const nativeStaticLayoutSessionId = this.nativeStaticLayoutSessionId; + this.nativeStaticLayoutSessionId = null; + if (nativeStaticLayoutSessionId && typeof window !== "undefined") { + void window.electronAPI?.nativeStaticLayoutExportCancel?.(nativeStaticLayoutSessionId); + } + } + + private cleanup(): void { + this.disposeEncoder(); + + if (this.streamingDecoder) { + try { + this.streamingDecoder.destroy(); + } catch (e) { + console.warn("Error destroying streaming decoder:", e); + } + this.streamingDecoder = null; + } + + if (this.renderer) { + try { + this.renderer.destroy(); + } catch (e) { + console.warn("Error destroying renderer:", e); + } + this.renderer = null; + } + + if (this.muxer) { + try { + this.muxer.destroy(); + } catch (e) { + console.warn("Error destroying muxer:", e); + } + } + + this.muxer = null; + this.audioProcessor?.cancel(); + this.audioProcessor = null; + this.disposeNativeH264Encoder(); + const nativeExportSessionId = this.nativeExportSessionId; + this.nativeExportSessionId = null; + if (nativeExportSessionId && typeof window !== "undefined") { + void window.electronAPI?.nativeVideoExportCancel?.(nativeExportSessionId); + } + this.encodeQueue = 0; + this.pendingMuxing = Promise.resolve(); + this.chunkCount = 0; + this.exportStartTimeMs = 0; + this.lastThroughputLogTimeMs = 0; + this.totalExportStartTimeMs = 0; + this.metadataLoadTimeMs = 0; + this.rendererInitTimeMs = 0; + this.nativeSessionStartTimeMs = 0; + this.decodeLoopTimeMs = 0; + this.frameCallbackTimeMs = 0; + this.renderFrameTimeMs = 0; + this.encodeWaitTimeMs = 0; + this.encodeWaitEvents = 0; + this.encoderError = null; + this.peakEncodeQueueSize = 0; + this.peakNativeWriteInFlight = 0; + this.nativeCaptureTimeMs = 0; + this.nativeWriteTimeMs = 0; + this.finalizationTimeMs = 0; + this.finalizationStageMs = {}; + this.effectiveDurationSec = 0; + this.processedFrameCount = 0; + this.activeFinalizationProgressWatchdog = null; + this.lastFinalizationRenderProgress = + INITIAL_FINALIZATION_PROGRESS_STATE.lastRenderProgress; + this.lastFinalizationAudioProgress = INITIAL_FINALIZATION_PROGRESS_STATE.lastAudioProgress; + this.lastProgressSampleTimeMs = 0; + this.lastProgressSampleFrame = 0; + this.displayedRenderFps = 0; + this.nativeWritePromises = new Set(); + this.nativeWriteError = null; + this.pendingNativeWriteChunks = []; + this.pendingNativeWriteBytes = 0; + this.maxNativeWriteInFlight = 1; + this.notifyEncodeCapacityAvailable(); + this.encodeCapacityWaiters.clear(); + this.videoDescription = undefined; + this.videoColorSpace = undefined; + this.renderBackend = null; + this.encodeBackend = null; + this.encoderName = null; + this.nativeStaticLayoutAverageFps = null; + this.backpressureProfile = null; + this.lastNativeExportError = null; + } +} diff --git a/src/lib/exporter/mp4Support.test.ts b/src/lib/exporter/mp4Support.test.ts new file mode 100644 index 000000000..fa1c4a668 --- /dev/null +++ b/src/lib/exporter/mp4Support.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; + +import { getOrderedSupportedMp4EncoderCandidates } from "./mp4Support"; + +describe("getOrderedSupportedMp4EncoderCandidates", () => { + it("keeps hardware candidates ahead of a preferred software hint", () => { + const candidates = getOrderedSupportedMp4EncoderCandidates({ + codec: "avc1.640033", + preferredEncoderPath: { + codec: "avc1.640033", + hardwareAcceleration: "prefer-software", + }, + }); + + expect(candidates[0]).toEqual({ + codec: "avc1.640033", + hardwareAcceleration: "prefer-hardware", + }); + expect(candidates[1]).toEqual({ + codec: "avc1.4d4033", + hardwareAcceleration: "prefer-hardware", + }); + expect(candidates).toContainEqual({ + codec: "avc1.640033", + hardwareAcceleration: "prefer-software", + }); + expect( + candidates.findIndex((candidate) => { + return ( + candidate.codec === "avc1.640033" && + candidate.hardwareAcceleration === "prefer-software" + ); + }), + ).toBeGreaterThan( + candidates.findIndex((candidate) => { + return ( + candidate.codec === "avc1.42001f" && + candidate.hardwareAcceleration === "prefer-hardware" + ); + }), + ); + }); + + it("preserves a preferred hardware path at the front of the list", () => { + const candidates = getOrderedSupportedMp4EncoderCandidates({ + codec: "avc1.640033", + preferredEncoderPath: { + codec: "avc1.4d401f", + hardwareAcceleration: "prefer-hardware", + }, + }); + + expect(candidates[0]).toEqual({ + codec: "avc1.4d401f", + hardwareAcceleration: "prefer-hardware", + }); + }); +}); diff --git a/src/lib/exporter/mp4Support.ts b/src/lib/exporter/mp4Support.ts new file mode 100644 index 000000000..e1f9a384e --- /dev/null +++ b/src/lib/exporter/mp4Support.ts @@ -0,0 +1,245 @@ +export const DEFAULT_MP4_CODEC = "avc1.640033"; +export const MP4_CODEC_FALLBACK_LIST = [ + DEFAULT_MP4_CODEC, + "avc1.4d4033", + "avc1.420033", + "avc1.4d401f", + "avc1.42001f", +] as const; + +export type SupportedMp4EncoderPath = { + codec: string; + hardwareAcceleration: "prefer-hardware" | "prefer-software"; +}; + +export type SupportedMp4Dimensions = { + width: number; + height: number; + capped: boolean; + encoderPath: SupportedMp4EncoderPath | null; +}; + +type ResolveMp4EncoderPathOptions = { + width: number; + height: number; + frameRate: number; + bitrate: number; + codec?: string; +}; + +type ProbeSupportedMp4DimensionsOptions = { + width: number; + height: number; + frameRate: number; + codec?: string; + getBitrate: (width: number, height: number) => number; +}; + +const encoderSupportCache = new Map<string, SupportedMp4EncoderPath | null>(); +const supportedDimensionCache = new Map<string, SupportedMp4Dimensions>(); + +function normalizeEvenDimension(value: number): number { + return Math.max(2, Math.floor(value / 2) * 2); +} + +function buildEncoderSupportCacheKey(options: ResolveMp4EncoderPathOptions): string { + return [ + options.codec ?? DEFAULT_MP4_CODEC, + options.width, + options.height, + options.frameRate, + options.bitrate, + ].join(":"); +} + +function scaleDimensions( + width: number, + height: number, + scale: number, +): { width: number; height: number } { + return { + width: normalizeEvenDimension(width * scale), + height: normalizeEvenDimension(height * scale), + }; +} + +function appendEncoderCandidate( + candidates: SupportedMp4EncoderPath[], + candidate: SupportedMp4EncoderPath | null | undefined, +): void { + if (!candidate) { + return; + } + + const alreadyIncluded = candidates.some((value) => { + return ( + value.codec === candidate.codec && + value.hardwareAcceleration === candidate.hardwareAcceleration + ); + }); + + if (!alreadyIncluded) { + candidates.push(candidate); + } +} + +export function getOrderedSupportedMp4EncoderCandidates(options: { + codec?: string; + preferredEncoderPath?: SupportedMp4EncoderPath | null; +}): SupportedMp4EncoderPath[] { + const orderedCodecs = Array.from( + new Set( + [options.preferredEncoderPath?.codec, options.codec, ...MP4_CODEC_FALLBACK_LIST].filter( + (value): value is string => typeof value === "string" && value.length > 0, + ), + ), + ); + const candidates: SupportedMp4EncoderPath[] = []; + + if (options.preferredEncoderPath?.hardwareAcceleration === "prefer-hardware") { + appendEncoderCandidate(candidates, options.preferredEncoderPath); + } + + for (const codec of orderedCodecs) { + appendEncoderCandidate(candidates, { + codec, + hardwareAcceleration: "prefer-hardware", + }); + } + + if (options.preferredEncoderPath?.hardwareAcceleration === "prefer-software") { + appendEncoderCandidate(candidates, options.preferredEncoderPath); + } + + for (const codec of orderedCodecs) { + appendEncoderCandidate(candidates, { + codec, + hardwareAcceleration: "prefer-software", + }); + } + + return candidates; +} + +export async function resolveSupportedMp4EncoderPath( + options: ResolveMp4EncoderPathOptions, +): Promise<SupportedMp4EncoderPath | null> { + const codec = options.codec ?? DEFAULT_MP4_CODEC; + const width = normalizeEvenDimension(options.width); + const height = normalizeEvenDimension(options.height); + const cacheKey = buildEncoderSupportCacheKey({ ...options, codec, width, height }); + const cachedResult = encoderSupportCache.get(cacheKey); + + if (cachedResult !== undefined) { + return cachedResult; + } + + const baseConfig: Omit<VideoEncoderConfig, "hardwareAcceleration"> = { + codec, + width, + height, + bitrate: options.bitrate, + framerate: options.frameRate, + latencyMode: "quality", + bitrateMode: "variable", + }; + + for (const hardwareAcceleration of ["prefer-hardware", "prefer-software"] as const) { + const support = await VideoEncoder.isConfigSupported({ + ...baseConfig, + hardwareAcceleration, + }); + + if (support.supported) { + const result = { codec, hardwareAcceleration } satisfies SupportedMp4EncoderPath; + encoderSupportCache.set(cacheKey, result); + return result; + } + } + + encoderSupportCache.set(cacheKey, null); + return null; +} + +export async function probeSupportedMp4Dimensions( + options: ProbeSupportedMp4DimensionsOptions, +): Promise<SupportedMp4Dimensions> { + const codec = options.codec ?? DEFAULT_MP4_CODEC; + const normalizedWidth = normalizeEvenDimension(options.width); + const normalizedHeight = normalizeEvenDimension(options.height); + const requestedBitrate = options.getBitrate(normalizedWidth, normalizedHeight); + const dimensionCacheKey = [ + codec, + normalizedWidth, + normalizedHeight, + options.frameRate, + requestedBitrate, + ].join(":"); + const cachedResult = supportedDimensionCache.get(dimensionCacheKey); + + if (cachedResult) { + return cachedResult; + } + + const directPath = await resolveSupportedMp4EncoderPath({ + width: normalizedWidth, + height: normalizedHeight, + frameRate: options.frameRate, + bitrate: requestedBitrate, + codec, + }); + + if (directPath) { + const result = { + width: normalizedWidth, + height: normalizedHeight, + capped: false, + encoderPath: directPath, + } satisfies SupportedMp4Dimensions; + supportedDimensionCache.set(dimensionCacheKey, result); + return result; + } + + let bestResult: SupportedMp4Dimensions | null = null; + let low = 1; + let high = 1000; + + while (low <= high) { + const mid = Math.floor((low + high) / 2); + const scale = mid / 1000; + const candidateDimensions = scaleDimensions(normalizedWidth, normalizedHeight, scale); + const candidatePath = await resolveSupportedMp4EncoderPath({ + width: candidateDimensions.width, + height: candidateDimensions.height, + frameRate: options.frameRate, + bitrate: options.getBitrate(candidateDimensions.width, candidateDimensions.height), + codec, + }); + + if (candidatePath) { + bestResult = { + width: candidateDimensions.width, + height: candidateDimensions.height, + capped: + candidateDimensions.width !== normalizedWidth || + candidateDimensions.height !== normalizedHeight, + encoderPath: candidatePath, + }; + low = mid + 1; + } else { + high = mid - 1; + } + } + + const result = + bestResult ?? + ({ + width: normalizedWidth, + height: normalizedHeight, + capped: false, + encoderPath: null, + } satisfies SupportedMp4Dimensions); + + supportedDimensionCache.set(dimensionCacheKey, result); + return result; +} diff --git a/src/lib/exporter/muxer.test.ts b/src/lib/exporter/muxer.test.ts new file mode 100644 index 000000000..01c66f000 --- /dev/null +++ b/src/lib/exporter/muxer.test.ts @@ -0,0 +1,186 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { type MuxerTargetMode, VideoMuxer } from "./muxer"; +import type { ExportConfig } from "./types"; + +vi.mock("mediabunny", () => { + class FakeBufferTarget { + onwrite: ((start: number, end: number) => unknown) | null = null; + buffer: ArrayBuffer | null = null; + } + class FakeStreamTarget { + onwrite: ((start: number, end: number) => unknown) | null = null; + readonly writable: WritableStream<{ + type: "write"; + data: Uint8Array; + position: number; + }>; + constructor( + writable: WritableStream<{ type: "write"; data: Uint8Array; position: number }>, + ) { + this.writable = writable; + } + } + + const addedVideoTracks: unknown[] = []; + const addedAudioTracks: unknown[] = []; + const startedOutputs: unknown[] = []; + + class FakeOutput { + readonly target: FakeBufferTarget | FakeStreamTarget; + constructor(options: { + format: unknown; + target: FakeBufferTarget | FakeStreamTarget; + }) { + this.target = options.target; + } + addVideoTrack(source: unknown, opts: unknown) { + addedVideoTracks.push({ source, opts }); + } + addAudioTrack(source: unknown) { + addedAudioTracks.push(source); + } + async start() { + startedOutputs.push(this); + } + async finalize() { + if (this.target instanceof FakeBufferTarget) { + this.target.buffer = new ArrayBuffer(4); + return; + } + const writer = (this.target as FakeStreamTarget).writable.getWriter(); + await writer.write({ + type: "write", + data: new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]), + position: 0, + }); + await writer.close(); + } + } + + class FakeMp4OutputFormat { + constructor(public options: unknown) {} + } + + class FakeEncodedVideoPacketSource { + constructor(public codec: string) {} + async add() {} + } + + class FakeEncodedAudioPacketSource { + constructor(public codec: string) {} + async add() {} + } + + const EncodedPacket = { + fromEncodedChunk: (chunk: unknown) => ({ chunk }), + }; + + return { + BufferTarget: FakeBufferTarget, + StreamTarget: FakeStreamTarget, + Output: FakeOutput, + Mp4OutputFormat: FakeMp4OutputFormat, + EncodedVideoPacketSource: FakeEncodedVideoPacketSource, + EncodedAudioPacketSource: FakeEncodedAudioPacketSource, + EncodedPacket, + }; +}); + +const baseConfig: ExportConfig = { + width: 1920, + height: 1080, + frameRate: 30, + bitrate: 10_000_000, +}; + +describe("VideoMuxer target selection", () => { + const originalWindow = globalThis.window; + + afterEach(() => { + // biome-ignore lint/suspicious/noExplicitAny: restoring the test globals. + (globalThis as any).window = originalWindow; + }); + + it("defaults to BufferTarget when no electronAPI stream IPC is available", async () => { + // biome-ignore lint/suspicious/noExplicitAny: isolating the muxer from Electron. + (globalThis as any).window = undefined; + const muxer = new VideoMuxer(baseConfig); + expect(muxer.getTargetMode()).toBe<MuxerTargetMode>("buffer"); + await muxer.initialize(); + const result = await muxer.finalize(); + expect(result.mode).toBe("buffer"); + if (result.mode === "buffer") { + expect(result.blob.type).toBe("video/mp4"); + } + }); + + it("uses StreamTarget and routes chunks through the renderer IPC when available", async () => { + const chunkCalls: Array<{ + streamId: string; + position: number; + bytes: number; + }> = []; + const fakeApi = { + openExportStream: vi.fn(async () => ({ + success: true, + streamId: "stream-1", + tempPath: "/tmp/muxer-stream.mp4", + })), + writeExportStreamChunk: vi.fn( + async (streamId: string, position: number, chunk: Uint8Array) => { + chunkCalls.push({ streamId, position, bytes: chunk.byteLength }); + return { success: true }; + }, + ), + closeExportStream: vi.fn(async (streamId: string) => ({ + success: true, + tempPath: "/tmp/muxer-stream.mp4", + bytesWritten: 8, + })), + }; + + // biome-ignore lint/suspicious/noExplicitAny: mocking the Electron bridge. + (globalThis as any).window = { electronAPI: fakeApi } as any; + + const muxer = new VideoMuxer(baseConfig); + expect(muxer.getTargetMode()).toBe<MuxerTargetMode>("stream"); + + await muxer.initialize(); + const result = await muxer.finalize(); + + expect(fakeApi.openExportStream).toHaveBeenCalledTimes(1); + expect(fakeApi.writeExportStreamChunk).toHaveBeenCalledTimes(1); + expect(fakeApi.closeExportStream).toHaveBeenCalledWith("stream-1", undefined); + expect(chunkCalls).toEqual([{ streamId: "stream-1", position: 0, bytes: 8 }]); + expect(result.mode).toBe("stream"); + if (result.mode === "stream") { + expect(result.tempFilePath).toBe("/tmp/muxer-stream.mp4"); + expect(result.bytesWritten).toBe(8); + } + }); + + it("aborts the stream session when the muxer is destroyed mid-flight", async () => { + const closeSpy = vi.fn(async () => ({ + success: true, + tempPath: "/tmp/abort.mp4", + bytesWritten: 0, + })); + const fakeApi = { + openExportStream: vi.fn(async () => ({ + success: true, + streamId: "stream-abort", + tempPath: "/tmp/abort.mp4", + })), + writeExportStreamChunk: vi.fn(async () => ({ success: true })), + closeExportStream: closeSpy, + }; + // biome-ignore lint/suspicious/noExplicitAny: mocking the Electron bridge. + (globalThis as any).window = { electronAPI: fakeApi } as any; + + const muxer = new VideoMuxer(baseConfig); + await muxer.initialize(); + await muxer.abortStream(); + + expect(closeSpy).toHaveBeenCalledWith("stream-abort", { abort: true }); + }); +}); diff --git a/src/lib/exporter/muxer.ts b/src/lib/exporter/muxer.ts index b787db39a..54e551c9b 100644 --- a/src/lib/exporter/muxer.ts +++ b/src/lib/exporter/muxer.ts @@ -1,89 +1,217 @@ -import type { ExportConfig } from './types'; - -interface MP4MuxerOptions { - target: any; // ArrayBufferTarget instance - video: { - codec: string; - width: number; - height: number; - }; - audio?: { - codec: string; - numberOfChannels: number; - sampleRate: number; - }; - fastStart?: 'in-memory' | 'fragmented'; +import { + BufferTarget, + EncodedAudioPacketSource, + EncodedPacket, + EncodedVideoPacketSource, + type Target as MediabunnyTarget, + Mp4OutputFormat, + Output, + StreamTarget, +} from "mediabunny"; +import type { ExportConfig } from "./types"; + +/** + * Chunk boundary used by both the mediabunny StreamTarget and the IPC writer. + * 16 MiB is well below Electron's IPC size limits and keeps per-chunk overhead + * low — large enough that a 35-minute 1080p export only produces a few hundred + * writes across the renderer/main boundary. + */ +const EXPORT_STREAM_CHUNK_BYTES = 16 * 1024 * 1024; + +type IpcStreamSink = { + readonly streamId: string; + readonly tempPath: string; +}; + +async function openIpcExportStream(): Promise<IpcStreamSink> { + if (typeof window === "undefined" || !window.electronAPI?.openExportStream) { + throw new Error("openExportStream IPC is unavailable in this environment"); + } + const result = await window.electronAPI.openExportStream({ extension: "mp4" }); + if (!result.success || !result.streamId || !result.tempPath) { + throw new Error(result.error || "Failed to open export stream"); + } + return { streamId: result.streamId, tempPath: result.tempPath }; } -interface Muxer { - addVideoChunk(chunk: EncodedVideoChunk, meta?: EncodedVideoChunkMetadata): void; - addAudioChunk(chunk: EncodedAudioChunk, meta?: EncodedAudioChunkMetadata): void; - finalize(): void; - target?: any; +async function writeIpcExportStream( + streamId: string, + position: number, + chunk: Uint8Array, +): Promise<void> { + // Mediabunny owns the underlying buffer for the lifetime of the call, so we + // copy the bytes before crossing the IPC boundary — ipcRenderer.invoke uses + // a structured clone under the hood and the original buffer can be reused + // by the muxer immediately after await returns. + const copy = new Uint8Array(chunk.byteLength); + copy.set(chunk); + const result = await window.electronAPI!.writeExportStreamChunk(streamId, position, copy); + if (!result.success) { + throw new Error(result.error || "Failed to write export chunk"); + } +} + +async function closeIpcExportStream( + streamId: string, + options?: { abort?: boolean }, +): Promise<{ tempPath: string; bytesWritten: number }> { + const result = await window.electronAPI!.closeExportStream(streamId, options); + if (!result.success || !result.tempPath) { + throw new Error(result.error || "Failed to close export stream"); + } + return { tempPath: result.tempPath, bytesWritten: result.bytesWritten ?? 0 }; +} + +export type MuxerTargetMode = "stream" | "buffer"; + +export type MuxerFinalizeResult = + | { mode: "stream"; tempFilePath: string; bytesWritten: number } + | { mode: "buffer"; blob: Blob }; + +function shouldUseStreamTarget(): boolean { + return ( + typeof window !== "undefined" && + typeof window.electronAPI?.openExportStream === "function" && + typeof window.electronAPI?.writeExportStreamChunk === "function" && + typeof window.electronAPI?.closeExportStream === "function" + ); } export class VideoMuxer { - private muxer: Muxer | null = null; - private config: ExportConfig; - private hasAudio: boolean; - - constructor(config: ExportConfig, hasAudio = false) { - this.config = config; - this.hasAudio = hasAudio; - } - - async initialize(): Promise<void> { - const MP4MuxerModule = await import('mp4-muxer'); - const MP4MuxerClass = (MP4MuxerModule as any).Muxer || MP4MuxerModule.default; - const ArrayBufferTarget = (MP4MuxerModule as any).ArrayBufferTarget; - - const target = new ArrayBufferTarget(); - - const options: MP4MuxerOptions = { - target, - video: { - codec: 'avc', - width: this.config.width, - height: this.config.height, - }, - fastStart: 'in-memory', - }; - - if (this.hasAudio) { - options.audio = { - codec: 'opus', - numberOfChannels: 2, - sampleRate: 48000, - }; - } - - this.muxer = new MP4MuxerClass(options) as Muxer; - } - - addVideoChunk(chunk: EncodedVideoChunk, meta?: EncodedVideoChunkMetadata): void { - if (!this.muxer) { - throw new Error('Muxer not initialized'); - } - this.muxer.addVideoChunk(chunk, meta); - } - - addAudioChunk(chunk: EncodedAudioChunk, meta?: EncodedAudioChunkMetadata): void { - if (!this.muxer) { - throw new Error('Muxer not initialized'); - } - if (!this.hasAudio) { - throw new Error('Audio not configured for this muxer'); - } - this.muxer.addAudioChunk(chunk, meta); - } - - finalize(): Blob { - if (!this.muxer) { - throw new Error('Muxer not initialized'); - } - - this.muxer.finalize(); - const buffer = this.muxer.target.buffer; - return new Blob([buffer], { type: 'video/mp4' }); - } + private output: Output | null = null; + private videoSource: EncodedVideoPacketSource | null = null; + private audioSource: EncodedAudioPacketSource | null = null; + private hasAudio: boolean; + private target: MediabunnyTarget | null = null; + private config: ExportConfig; + private mode: MuxerTargetMode; + private streamSink: IpcStreamSink | null = null; + + constructor(config: ExportConfig, hasAudio = false, mode?: MuxerTargetMode) { + this.config = config; + this.hasAudio = hasAudio; + this.mode = mode ?? (shouldUseStreamTarget() ? "stream" : "buffer"); + } + + getTargetMode(): MuxerTargetMode { + return this.mode; + } + + async initialize(): Promise<void> { + if (this.mode === "stream") { + const sink = await openIpcExportStream(); + this.streamSink = sink; + const streamId = sink.streamId; + const writableStream = new WritableStream<{ + type: "write"; + data: Uint8Array; + position: number; + }>({ + async write(chunk) { + if (chunk.type !== "write") { + return; + } + await writeIpcExportStream(streamId, chunk.position, chunk.data); + }, + }); + this.target = new StreamTarget(writableStream, { + chunked: true, + chunkSize: EXPORT_STREAM_CHUNK_BYTES, + }); + } else { + this.target = new BufferTarget(); + } + + this.output = new Output({ + format: new Mp4OutputFormat({ + fastStart: false, + }), + target: this.target, + }); + + this.videoSource = new EncodedVideoPacketSource("avc"); + this.output.addVideoTrack(this.videoSource, { + frameRate: this.config.frameRate, + }); + + if (this.hasAudio) { + this.audioSource = new EncodedAudioPacketSource("aac"); + this.output.addAudioTrack(this.audioSource); + } + + await this.output.start(); + } + + async addVideoChunk(chunk: EncodedVideoChunk, meta?: EncodedVideoChunkMetadata): Promise<void> { + if (!this.videoSource) { + throw new Error("Muxer not initialized"); + } + + const packet = EncodedPacket.fromEncodedChunk(chunk); + await this.videoSource.add(packet, meta); + } + + async addAudioChunk(chunk: EncodedAudioChunk, meta?: EncodedAudioChunkMetadata): Promise<void> { + if (!this.audioSource) { + throw new Error("Audio not configured for this muxer"); + } + + const packet = EncodedPacket.fromEncodedChunk(chunk); + await this.audioSource.add(packet, meta); + } + + async finalize(): Promise<MuxerFinalizeResult> { + if (!this.output || !this.target) { + throw new Error("Muxer not initialized"); + } + + await this.output.finalize(); + + if (this.mode === "stream") { + const sink = this.streamSink; + if (!sink) { + throw new Error("Stream target closed before finalization"); + } + // Clear streamSink before awaiting close so a concurrent destroy()→ + // abortStream() short-circuits on `!this.streamSink` instead of racing + // us for the same streamId. + this.streamSink = null; + const closeResult = await closeIpcExportStream(sink.streamId); + return { + mode: "stream", + tempFilePath: closeResult.tempPath, + bytesWritten: closeResult.bytesWritten, + }; + } + + const buffer = (this.target as BufferTarget).buffer; + if (!buffer) { + throw new Error("Failed to finalize output"); + } + return { mode: "buffer", blob: new Blob([buffer], { type: "video/mp4" }) }; + } + + async abortStream(): Promise<void> { + if (this.mode !== "stream" || !this.streamSink) { + return; + } + try { + await closeIpcExportStream(this.streamSink.streamId, { abort: true }); + } catch { + // Best-effort cleanup on cancel — the main process also reaps stale + // streams on before-quit via cleanupAllExportStreams. + } finally { + this.streamSink = null; + } + } + + destroy(): void { + this.output = null; + this.videoSource = null; + this.audioSource = null; + this.target = null; + if (this.streamSink) { + void this.abortStream(); + } + } } diff --git a/src/lib/exporter/nativeFrameCapture.ts b/src/lib/exporter/nativeFrameCapture.ts new file mode 100644 index 000000000..5d69f6978 --- /dev/null +++ b/src/lib/exporter/nativeFrameCapture.ts @@ -0,0 +1,118 @@ +const RGBA_BYTES_PER_PIXEL = 4; + +let nativeFrameCaptureMode: "video-frame-rgba" | "canvas-readback" | null = null; +let nativeFrameCaptureFallbackWarned = false; +let fallbackCanvas: HTMLCanvasElement | OffscreenCanvas | null = null; +let fallbackContext: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D | null = null; + +function getRgbaLayout(width: number): PlaneLayout[] { + return [{ offset: 0, stride: width * RGBA_BYTES_PER_PIXEL }]; +} + +function getRgbaByteSize(width: number, height: number): number { + return width * height * RGBA_BYTES_PER_PIXEL; +} + +function getFallbackReadbackContext( + width: number, + height: number, +): CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D { + if (!fallbackCanvas || fallbackCanvas.width !== width || fallbackCanvas.height !== height) { + if (typeof OffscreenCanvas !== "undefined") { + fallbackCanvas = new OffscreenCanvas(width, height); + fallbackContext = fallbackCanvas.getContext("2d", { + willReadFrequently: true, + }); + } else if (typeof document !== "undefined") { + const canvas = document.createElement("canvas"); + canvas.width = width; + canvas.height = height; + fallbackCanvas = canvas; + fallbackContext = canvas.getContext("2d", { + willReadFrequently: true, + }); + } else { + throw new Error("No canvas readback path is available for native export"); + } + } + + if (!fallbackContext) { + throw new Error("Failed to initialize native export frame readback context"); + } + + return fallbackContext; +} + +function captureCanvasFrameWithReadback( + canvas: HTMLCanvasElement, + targetWidth?: number, + targetHeight?: number, +): Uint8Array { + const outWidth = targetWidth ?? canvas.width; + const outHeight = targetHeight ?? canvas.height; + const context = getFallbackReadbackContext(outWidth, outHeight); + context.clearRect(0, 0, outWidth, outHeight); + context.drawImage(canvas, 0, 0, outWidth, outHeight); + const imageData = context.getImageData(0, 0, outWidth, outHeight); + return new Uint8Array(imageData.data); +} + +function flipRgbaRowsInPlace(buffer: Uint8Array, width: number, height: number): void { + const rowByteLength = width * RGBA_BYTES_PER_PIXEL; + const scratchRow = new Uint8Array(rowByteLength); + const halfRows = Math.floor(height / 2); + + for (let rowIndex = 0; rowIndex < halfRows; rowIndex += 1) { + const topOffset = rowIndex * rowByteLength; + const bottomOffset = (height - rowIndex - 1) * rowByteLength; + + scratchRow.set(buffer.subarray(topOffset, topOffset + rowByteLength)); + buffer.copyWithin(topOffset, bottomOffset, bottomOffset + rowByteLength); + buffer.set(scratchRow, bottomOffset); + } +} + +export async function captureCanvasFrameForNativeExport( + canvas: HTMLCanvasElement, + timestamp: number, + flipVertical = false, + targetWidth?: number, + targetHeight?: number, +): Promise<Uint8Array> { + const outWidth = targetWidth ?? canvas.width; + const outHeight = targetHeight ?? canvas.height; + + if (nativeFrameCaptureMode !== "canvas-readback") { + const buffer = new Uint8Array(getRgbaByteSize(outWidth, outHeight)); + const frame = new VideoFrame(canvas, { timestamp }); + + try { + await frame.copyTo(buffer, { + format: "RGBA", + layout: getRgbaLayout(outWidth), + }); + if (flipVertical) { + flipRgbaRowsInPlace(buffer, outWidth, outHeight); + } + nativeFrameCaptureMode = "video-frame-rgba"; + return buffer; + } catch (error) { + nativeFrameCaptureMode = "canvas-readback"; + if (!nativeFrameCaptureFallbackWarned) { + nativeFrameCaptureFallbackWarned = true; + console.warn( + "[native-export] VideoFrame RGBA copyTo failed, falling back to canvas readback", + error, + ); + } + } finally { + frame.close(); + } + } + + const buffer = captureCanvasFrameWithReadback(canvas, outWidth, outHeight); + if (flipVertical) { + flipRgbaRowsInPlace(buffer, outWidth, outHeight); + } + return buffer; +} diff --git a/src/lib/exporter/nativeStaticLayoutGeometry.test.ts b/src/lib/exporter/nativeStaticLayoutGeometry.test.ts new file mode 100644 index 000000000..e8ad7a0e0 --- /dev/null +++ b/src/lib/exporter/nativeStaticLayoutGeometry.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { roundNativeStaticLayoutContentSize } from "./nativeStaticLayoutGeometry"; + +describe("roundNativeStaticLayoutContentSize", () => { + it("keeps even dimensions inside the floating layout bounds", () => { + expect( + roundNativeStaticLayoutContentSize({ + width: 1766.4, + height: 993.6, + }), + ).toEqual({ width: 1764, height: 992 }); + }); + + it("avoids independently rounding width and height into aspect drift", () => { + const rounded = roundNativeStaticLayoutContentSize({ + width: 1766.4, + height: 993.6, + }); + const independentAspect = 1766 / 994; + const roundedAspect = rounded.width / rounded.height; + const targetAspect = 1766.4 / 993.6; + + expect(Math.abs(roundedAspect - targetAspect)).toBeLessThan( + Math.abs(independentAspect - targetAspect), + ); + }); + + it("handles integer even layouts without changing them", () => { + expect( + roundNativeStaticLayoutContentSize({ + width: 1600, + height: 900, + }), + ).toEqual({ width: 1600, height: 900 }); + }); +}); diff --git a/src/lib/exporter/nativeStaticLayoutGeometry.ts b/src/lib/exporter/nativeStaticLayoutGeometry.ts new file mode 100644 index 000000000..84aa88b8e --- /dev/null +++ b/src/lib/exporter/nativeStaticLayoutGeometry.ts @@ -0,0 +1,61 @@ +type NativeStaticLayoutContentSize = { + width: number; + height: number; +}; + +function toEvenFloor(value: number) { + return Math.max(2, Math.floor(value / 2) * 2); +} + +function toEvenRound(value: number) { + return Math.max(2, Math.round(value / 2) * 2); +} + +function clampEven(value: number, max: number) { + const rounded = toEvenRound(value); + return rounded <= max ? rounded : toEvenFloor(max); +} + +function aspectError(size: NativeStaticLayoutContentSize, aspect: number) { + return Math.abs(size.width / size.height - aspect); +} + +export function roundNativeStaticLayoutContentSize(params: { + width: number; + height: number; +}): NativeStaticLayoutContentSize { + const maxWidth = toEvenFloor(params.width); + const maxHeight = toEvenFloor(params.height); + if ( + !Number.isFinite(params.width) || + !Number.isFinite(params.height) || + params.width <= 0 || + params.height <= 0 + ) { + return { width: maxWidth, height: maxHeight }; + } + + const aspect = params.width / params.height; + const fromWidth = { + width: maxWidth, + height: clampEven(maxWidth / aspect, maxHeight), + }; + const fromHeight = { + width: clampEven(maxHeight * aspect, maxWidth), + height: maxHeight, + }; + + const widthError = aspectError(fromWidth, aspect); + const heightError = aspectError(fromHeight, aspect); + if (heightError < widthError) { + return fromHeight; + } + + if (widthError < heightError) { + return fromWidth; + } + + return fromHeight.width * fromHeight.height >= fromWidth.width * fromWidth.height + ? fromHeight + : fromWidth; +} diff --git a/src/lib/exporter/nativeStaticLayoutTelemetry.test.ts b/src/lib/exporter/nativeStaticLayoutTelemetry.test.ts new file mode 100644 index 000000000..5157b5205 --- /dev/null +++ b/src/lib/exporter/nativeStaticLayoutTelemetry.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, it } from "vitest"; +import { buildNativeStaticLayoutCursorTelemetry } from "./nativeStaticLayoutTelemetry"; + +describe("buildNativeStaticLayoutCursorTelemetry", () => { + it("filters invalid samples, clamps coordinates, and sorts by time", () => { + expect( + buildNativeStaticLayoutCursorTelemetry( + [ + { timeMs: 30, cx: 2, cy: -1 }, + { timeMs: Number.NaN, cx: 0.5, cy: 0.5 }, + { timeMs: 10, cx: 0.25, cy: 0.75 }, + ], + { frameRate: 30, durationSec: 2 }, + ), + ).toEqual([ + { + timeMs: 0, + cx: 0.25, + cy: 0.75, + cursorType: "arrow", + cursorTypeIndex: 0, + bounceScale: 1, + }, + { + timeMs: 2000, + cx: 1, + cy: 0, + cursorType: "arrow", + cursorTypeIndex: 0, + bounceScale: 1, + }, + ]); + }); + + it("resamples high-frequency cursor telemetry to the export frame cadence", () => { + const telemetry = Array.from({ length: 101 }, (_, index) => ({ + timeMs: index * 10, + cx: index / 100, + cy: 1 - index / 100, + })); + + const resampled = buildNativeStaticLayoutCursorTelemetry(telemetry, { + frameRate: 10, + durationSec: 1, + }); + + expect(resampled).toBeDefined(); + expect(resampled).toHaveLength(11); + expect(resampled?.[0]).toEqual({ + timeMs: 0, + cx: 0, + cy: 1, + cursorType: "arrow", + cursorTypeIndex: 0, + bounceScale: 1, + }); + expect(resampled?.[5]).toEqual({ + timeMs: 500, + cx: 0.5, + cy: 0.5, + cursorType: "arrow", + cursorTypeIndex: 0, + bounceScale: 1, + }); + expect(resampled?.[10]).toEqual({ + timeMs: 1000, + cx: 1, + cy: 0, + cursorType: "arrow", + cursorTypeIndex: 0, + bounceScale: 1, + }); + }); + + it("collapses visually unchanged cursor positions", () => { + const telemetry = Array.from({ length: 101 }, (_, index) => ({ + timeMs: index * 10, + cx: 0.4, + cy: 0.6, + })); + + expect( + buildNativeStaticLayoutCursorTelemetry(telemetry, { + frameRate: 10, + durationSec: 1, + }), + ).toEqual([ + { + timeMs: 1000, + cx: 0.4, + cy: 0.6, + cursorType: "arrow", + cursorTypeIndex: 0, + bounceScale: 1, + }, + ]); + }); + + it("keeps cursor type transitions and click bounce samples for native parity", () => { + const resampled = buildNativeStaticLayoutCursorTelemetry( + [ + { timeMs: 0, cx: 0.1, cy: 0.2, cursorType: "arrow" }, + { timeMs: 100, cx: 0.1, cy: 0.2, interactionType: "click", cursorType: "pointer" }, + { timeMs: 200, cx: 0.1, cy: 0.2, interactionType: "mouseup", cursorType: "text" }, + ], + { frameRate: 10, durationSec: 0.4, clickBounce: 1, clickBounceDurationMs: 350 }, + ); + + expect(resampled?.map((sample) => sample.cursorTypeIndex)).toContain(1); + expect(resampled?.some((sample) => (sample.bounceScale ?? 1) < 1)).toBe(true); + }); + + it("projects cursor samples into the cropped viewport and marks out-of-crop samples hidden", () => { + const resampled = buildNativeStaticLayoutCursorTelemetry( + [ + { timeMs: 0, cx: 0.25, cy: 0.5 }, + { timeMs: 1000, cx: 0.75, cy: 0.5 }, + ], + { + frameRate: 1, + durationSec: 1, + sourceCrop: { x: 0.25, y: 0.25, width: 0.5, height: 0.5 }, + }, + ); + + expect(resampled?.[0]).toMatchObject({ timeMs: 0, cx: 0, cy: 0.5, visible: true }); + expect(resampled?.[1]).toMatchObject({ timeMs: 1000, cx: 1, cy: 0.5, visible: true }); + + const hidden = buildNativeStaticLayoutCursorTelemetry( + [ + { timeMs: 0, cx: 0.1, cy: 0.5 }, + { timeMs: 1000, cx: 0.1, cy: 0.5 }, + ], + { + frameRate: 1, + durationSec: 1, + sourceCrop: { x: 0.25, y: 0.25, width: 0.5, height: 0.5 }, + }, + ); + + expect(hidden).toEqual([ + expect.objectContaining({ + timeMs: 1000, + visible: false, + }), + ]); + }); +}); diff --git a/src/lib/exporter/nativeStaticLayoutTelemetry.ts b/src/lib/exporter/nativeStaticLayoutTelemetry.ts new file mode 100644 index 000000000..7a8cda118 --- /dev/null +++ b/src/lib/exporter/nativeStaticLayoutTelemetry.ts @@ -0,0 +1,208 @@ +import type { CropRegion, CursorTelemetryPoint } from "@/components/video-editor/types"; +import { projectCursorPositionToViewport } from "@/components/video-editor/videoPlayback/cursorViewport"; + +export type NativeStaticLayoutCursorTelemetrySample = { + timeMs: number; + cx: number; + cy: number; + cursorType?: CursorTelemetryPoint["cursorType"]; + interactionType?: string; + cursorTypeIndex?: number; + bounceScale?: number; + visible?: boolean; +}; + +export type NativeStaticLayoutCursorTelemetryOptions = { + frameRate: number; + durationSec: number; + clickBounce?: number; + clickBounceDurationMs?: number; + sourceCrop?: CropRegion; +}; + +const CURSOR_POSITION_EPSILON = 0.00001; +const CURSOR_BOUNCE_EPSILON = 0.0005; +const DEFAULT_CLICK_BOUNCE = 1; +const DEFAULT_CLICK_BOUNCE_DURATION_MS = 350; + +const CURSOR_TYPE_INDEX: Record<string, number> = { + arrow: 0, + text: 1, + pointer: 2, + crosshair: 3, + "open-hand": 4, + "closed-hand": 5, + "resize-ew": 6, + "resize-ns": 7, + "not-allowed": 8, +}; + +function clampUnit(value: number) { + return Math.min(1, Math.max(0, value)); +} + +function isCursorInteractionSample(sample: NativeStaticLayoutCursorTelemetrySample) { + return ( + sample.interactionType === "click" || + sample.interactionType === "double-click" || + sample.interactionType === "right-click" || + sample.interactionType === "middle-click" + ); +} + +function sanitizeCursorTelemetry(telemetry: NativeStaticLayoutCursorTelemetrySample[]) { + return 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: clampUnit(sample.cx), + cy: clampUnit(sample.cy), + cursorType: sample.cursorType, + interactionType: sample.interactionType, + })) + .sort((left, right) => left.timeMs - right.timeMs); +} + +function getCursorTypeIndex(cursorType: string | undefined) { + return CURSOR_TYPE_INDEX[cursorType ?? "arrow"] ?? CURSOR_TYPE_INDEX.arrow; +} + +function getCursorBounceScale( + latestClick: NativeStaticLayoutCursorTelemetrySample | null, + timeMs: number, + options: NativeStaticLayoutCursorTelemetryOptions, +) { + if (!latestClick) { + return 1; + } + + const clickBounceDurationMs = Math.max( + 1, + options.clickBounceDurationMs ?? DEFAULT_CLICK_BOUNCE_DURATION_MS, + ); + const ageMs = Math.max(0, timeMs - latestClick.timeMs); + if (ageMs > clickBounceDurationMs) { + return 1; + } + + const clickBounce = Math.max(0, options.clickBounce ?? DEFAULT_CLICK_BOUNCE); + const progress = 1 - ageMs / clickBounceDurationMs; + return Math.max(0.72, 1 - Math.sin(progress * Math.PI) * (0.08 * clickBounce)); +} + +function createMonotonicCursorRenderSampler( + samples: NativeStaticLayoutCursorTelemetrySample[], + options: NativeStaticLayoutCursorTelemetryOptions, +) { + let interpolationIndex = 0; + let metadataIndex = 0; + let latestClick: NativeStaticLayoutCursorTelemetrySample | null = null; + let latestStableCursorType: CursorTelemetryPoint["cursorType"] = "arrow"; + + return (timeMs: number): NativeStaticLayoutCursorTelemetrySample => { + while ( + interpolationIndex < samples.length - 2 && + samples[interpolationIndex + 1].timeMs <= timeMs + ) { + interpolationIndex += 1; + } + + while (metadataIndex < samples.length && samples[metadataIndex].timeMs <= timeMs) { + const sample = samples[metadataIndex]; + if (isCursorInteractionSample(sample)) { + latestClick = sample; + } else if (sample.cursorType) { + latestStableCursorType = sample.cursorType; + } + metadataIndex += 1; + } + + let position: Pick<NativeStaticLayoutCursorTelemetrySample, "cx" | "cy" | "timeMs">; + if (timeMs <= samples[0].timeMs) { + position = { timeMs, cx: samples[0].cx, cy: samples[0].cy }; + } else { + const last = samples[samples.length - 1]; + if (timeMs >= last.timeMs) { + position = { timeMs, cx: last.cx, cy: last.cy }; + } else { + const a = samples[interpolationIndex]; + const b = samples[Math.min(samples.length - 1, interpolationIndex + 1)]; + const span = b.timeMs - a.timeMs; + const t = span > 0 ? (timeMs - a.timeMs) / span : 0; + position = { + timeMs, + cx: a.cx + (b.cx - a.cx) * t, + cy: a.cy + (b.cy - a.cy) * t, + }; + } + } + + const projectedPosition = projectCursorPositionToViewport(position, options.sourceCrop); + return { + ...position, + cx: projectedPosition.cx, + cy: projectedPosition.cy, + cursorType: latestStableCursorType, + cursorTypeIndex: getCursorTypeIndex(latestStableCursorType), + bounceScale: getCursorBounceScale(latestClick, timeMs, options), + ...(options.sourceCrop ? { visible: projectedPosition.visible } : {}), + }; + }; +} + +function pushCursorSample( + samples: NativeStaticLayoutCursorTelemetrySample[], + sample: NativeStaticLayoutCursorTelemetrySample, +) { + const previous = samples[samples.length - 1]; + if ( + previous && + Math.abs(previous.cx - sample.cx) <= CURSOR_POSITION_EPSILON && + Math.abs(previous.cy - sample.cy) <= CURSOR_POSITION_EPSILON && + previous.cursorTypeIndex === sample.cursorTypeIndex && + Math.abs((previous.bounceScale ?? 1) - (sample.bounceScale ?? 1)) <= + CURSOR_BOUNCE_EPSILON && + (previous.visible ?? true) === (sample.visible ?? true) + ) { + previous.timeMs = sample.timeMs; + return; + } + + samples.push(sample); +} + +export function buildNativeStaticLayoutCursorTelemetry( + telemetry: NativeStaticLayoutCursorTelemetrySample[], + options: NativeStaticLayoutCursorTelemetryOptions, +) { + const sanitized = sanitizeCursorTelemetry(telemetry); + if (sanitized.length === 0) { + return undefined; + } + + const frameRate = Math.max(1, Math.round(options.frameRate)); + const durationMs = Number.isFinite(options.durationSec) + ? Math.max(0, options.durationSec * 1000) + : sanitized[sanitized.length - 1].timeMs; + const frameDurationMs = 1000 / frameRate; + const targetFrames = Math.max(1, Math.floor(durationMs / frameDurationMs) + 1); + const resampled: NativeStaticLayoutCursorTelemetrySample[] = []; + const sampleCursorAt = createMonotonicCursorRenderSampler(sanitized, options); + for (let frameIndex = 0; frameIndex < targetFrames; frameIndex += 1) { + const timeMs = Math.min(durationMs, frameIndex * frameDurationMs); + pushCursorSample(resampled, sampleCursorAt(timeMs)); + } + + const last = resampled[resampled.length - 1]; + if (!last || Math.abs(last.timeMs - durationMs) > 0.5) { + pushCursorSample(resampled, sampleCursorAt(durationMs)); + } + + return resampled; +} diff --git a/src/lib/exporter/shadowProfile.test.ts b/src/lib/exporter/shadowProfile.test.ts new file mode 100644 index 000000000..7c8ce8c8f --- /dev/null +++ b/src/lib/exporter/shadowProfile.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { + getWebcamShadowFilter, + getWebcamShadowStrength, + WEBCAM_SHADOW_LAYER_PROFILES, +} from "./shadowProfile"; + +describe("getWebcamShadowStrength", () => { + it("doubles the webcam shadow response and clamps it", () => { + expect(getWebcamShadowStrength(0)).toBe(0); + expect(getWebcamShadowStrength(0.25)).toBe(0.5); + expect(getWebcamShadowStrength(0.5)).toBe(1); + expect(getWebcamShadowStrength(1)).toBe(1); + }); + + it("adds a tighter contact shadow so strength also increases density", () => { + expect(WEBCAM_SHADOW_LAYER_PROFILES).toHaveLength(2); + expect(getWebcamShadowFilter(100, 0)).toBe("none"); + const filter = getWebcamShadowFilter(100, 0.5); + expect(filter.match(/drop-shadow/g)).toHaveLength(2); + expect(filter).toContain("rgba(0, 0, 0, 0.9)"); + expect(filter).toContain("rgba(0, 0, 0, 0.8)"); + }); +}); diff --git a/src/lib/exporter/shadowProfile.ts b/src/lib/exporter/shadowProfile.ts new file mode 100644 index 000000000..18bf0812d --- /dev/null +++ b/src/lib/exporter/shadowProfile.ts @@ -0,0 +1,38 @@ +export interface ShadowLayerProfile { + offsetScale: number; + alphaScale: number; + blurScale: number; +} + +export const VIDEO_SHADOW_LAYER_PROFILES: ReadonlyArray<ShadowLayerProfile> = Object.freeze([ + { offsetScale: 12, alphaScale: 0.7, blurScale: 48 }, + { offsetScale: 4, alphaScale: 0.5, blurScale: 16 }, + { offsetScale: 2, alphaScale: 0.3, blurScale: 8 }, +]); + +export const WEBCAM_SHADOW_LAYER_PROFILES: ReadonlyArray<ShadowLayerProfile> = Object.freeze([ + { offsetScale: 0.06, alphaScale: 0.9, blurScale: 0.22 }, + { offsetScale: 0.025, alphaScale: 0.8, blurScale: 0.07 }, +]); + +export function getWebcamShadowStrength(value: number): number { + return Math.min(1, Math.max(0, Number.isFinite(value) ? value * 2 : 0)); +} + +export function getWebcamShadowFilter(shadowSize: number, value: number): string { + const strength = getWebcamShadowStrength(value); + if (strength <= 0) { + return "none"; + } + + return WEBCAM_SHADOW_LAYER_PROFILES.map((profile) => { + const offset = Math.max(0, shadowSize) * profile.offsetScale * strength; + const blur = Math.max(0, shadowSize) * profile.blurScale * strength; + const alpha = Math.min(1, profile.alphaScale * strength); + return `drop-shadow(0 ${offset}px ${blur}px rgba(0, 0, 0, ${alpha}))`; + }).join(" "); +} + +export function getShadowFilterPadding(blur: number, offsetY: number): number { + return Math.ceil(Math.max(0, blur * 2 + Math.abs(offsetY))); +} diff --git a/src/lib/exporter/sourceAudioFallback.test.ts b/src/lib/exporter/sourceAudioFallback.test.ts new file mode 100644 index 000000000..aa28f1d6f --- /dev/null +++ b/src/lib/exporter/sourceAudioFallback.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest"; + +import { resolveSourceAudioFallbackPaths } from "./sourceAudioFallback"; + +describe("resolveSourceAudioFallbackPaths", () => { + it("treats the video file path as embedded source audio when present in the fallback list", () => { + const videoPath = "/tmp/recording.mp4"; + + expect( + resolveSourceAudioFallbackPaths(videoPath, [videoPath, "/tmp/recording.mic.wav"]), + ).toEqual({ + hasEmbeddedSourceAudio: true, + externalAudioPaths: ["/tmp/recording.mic.wav"], + }); + }); + + it("keeps all fallback paths external when the video has no embedded source audio", () => { + expect( + resolveSourceAudioFallbackPaths("/tmp/recording.mp4", [ + "/tmp/recording.system.wav", + "/tmp/recording.mic.wav", + ]), + ).toEqual({ + hasEmbeddedSourceAudio: false, + externalAudioPaths: ["/tmp/recording.system.wav", "/tmp/recording.mic.wav"], + }); + }); + + it("matches embedded source audio when the video resource is a file URL", () => { + expect( + resolveSourceAudioFallbackPaths("file:///tmp/recording.mp4", [ + "/tmp/recording.mp4", + "/tmp/recording.mic.wav", + ]), + ).toEqual({ + hasEmbeddedSourceAudio: true, + externalAudioPaths: ["/tmp/recording.mic.wav"], + }); + }); + + it("normalizes Windows file URLs and local paths when checking embedded audio", () => { + expect( + resolveSourceAudioFallbackPaths("file:///C:/Users/Egg/Videos/recording.mp4", [ + "C:\\Users\\Egg\\Videos\\recording.mp4", + "C:\\Users\\Egg\\Videos\\recording.mic.wav", + ]), + ).toEqual({ + hasEmbeddedSourceAudio: true, + externalAudioPaths: ["C:\\Users\\Egg\\Videos\\recording.mic.wav"], + }); + }); + + it("matches Windows paths case-insensitively for embedded audio detection", () => { + expect( + resolveSourceAudioFallbackPaths("file:///C:/Users/Egg/Videos/recording.mp4", [ + "c:\\users\\egg\\videos\\recording.mp4", + "c:\\users\\egg\\videos\\recording.mic.wav", + ]), + ).toEqual({ + hasEmbeddedSourceAudio: true, + externalAudioPaths: ["c:\\users\\egg\\videos\\recording.mic.wav"], + }); + }); +}); diff --git a/src/lib/exporter/sourceAudioFallback.ts b/src/lib/exporter/sourceAudioFallback.ts new file mode 100644 index 000000000..6e1fcca44 --- /dev/null +++ b/src/lib/exporter/sourceAudioFallback.ts @@ -0,0 +1,48 @@ +import { getLocalFilePathFromResource } from "./mediaResource"; + +function normalizeSourceAudioFallbackPath(resourceOrPath: string): string | null { + if (typeof resourceOrPath !== "string") { + return null; + } + + const resolvedPath = getLocalFilePathFromResource(resourceOrPath) ?? resourceOrPath; + const trimmedPath = resolvedPath.trim(); + if (!trimmedPath) { + return null; + } + + const isWindowsPath = + /^[A-Za-z]:[\\/]/.test(trimmedPath) || /^\\\\[^\\]+\\[^\\]+/.test(trimmedPath); + if (isWindowsPath) { + return trimmedPath.replace(/\//g, "\\").toLowerCase(); + } + + return trimmedPath.replace(/\\/g, "/"); +} + +export function resolveSourceAudioFallbackPaths( + videoResource: string | null | undefined, + sourceAudioFallbackPaths: string[] | null | undefined, +) { + const normalizedPaths = (sourceAudioFallbackPaths ?? []) + .filter((audioPath) => typeof audioPath === "string" && audioPath.trim().length > 0) + .map((audioPath) => ({ + audioPath, + normalizedPath: normalizeSourceAudioFallbackPath(audioPath), + })); + const localVideoSourcePath = videoResource + ? normalizeSourceAudioFallbackPath(videoResource) + : null; + const hasEmbeddedSourceAudio = + Boolean(localVideoSourcePath) && + normalizedPaths.some(({ normalizedPath }) => normalizedPath === localVideoSourcePath); + + return { + hasEmbeddedSourceAudio, + externalAudioPaths: hasEmbeddedSourceAudio + ? normalizedPaths + .filter(({ normalizedPath }) => normalizedPath !== localVideoSourcePath) + .map(({ audioPath }) => audioPath) + : normalizedPaths.map(({ audioPath }) => audioPath), + }; +} diff --git a/src/lib/exporter/sourceTrackRoutingPolicy.test.ts b/src/lib/exporter/sourceTrackRoutingPolicy.test.ts new file mode 100644 index 000000000..58743acca --- /dev/null +++ b/src/lib/exporter/sourceTrackRoutingPolicy.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; +import { resolveSourceTrackRoutingPolicy } from "./sourceTrackRoutingPolicy"; + +describe("resolveSourceTrackRoutingPolicy", () => { + it("prioritizes system+mic sidecars and mutes embedded preview", () => { + const policy = resolveSourceTrackRoutingPolicy("/tmp/recording.mp4", [ + "/tmp/recording.mp4", + "/tmp/recording.system.wav", + "/tmp/recording.mic.wav", + "/tmp/recording.mixed.wav", + ]); + + expect(policy.playbackPaths).toEqual([ + "/tmp/recording.system.wav", + "/tmp/recording.mic.wav", + ]); + expect(policy.muteEmbeddedPreview).toBe(true); + expect(policy.includeEmbeddedInExport).toBe(false); + }); + + it("falls back to mixed when dedicated tracks are absent", () => { + const policy = resolveSourceTrackRoutingPolicy("/tmp/recording.mp4", [ + "/tmp/recording.mixed.wav", + ]); + + expect(policy.playbackPaths).toEqual(["/tmp/recording.mixed.wav"]); + expect(policy.muteEmbeddedPreview).toBe(false); + expect(policy.includeEmbeddedInExport).toBe(false); + }); + + it("keeps embedded audio when only mic sidecar is present", () => { + const policy = resolveSourceTrackRoutingPolicy("/tmp/recording.mp4", [ + "/tmp/recording.mp4", + "/tmp/recording.mic.wav", + ]); + + expect(policy.playbackPaths).toEqual(["/tmp/recording.mic.wav"]); + expect(policy.muteEmbeddedPreview).toBe(false); + expect(policy.includeEmbeddedInExport).toBe(true); + }); +}); diff --git a/src/lib/exporter/sourceTrackRoutingPolicy.ts b/src/lib/exporter/sourceTrackRoutingPolicy.ts new file mode 100644 index 000000000..da1deb018 --- /dev/null +++ b/src/lib/exporter/sourceTrackRoutingPolicy.ts @@ -0,0 +1,27 @@ +import { buildResolvedAudioPlan, type SourceTrackId } from "./audioRoutingEngine"; + +export interface SourceTrackRoutingPolicy { + hasEmbeddedSourceAudio: boolean; + pathsByTrack: Partial<Record<SourceTrackId, string>>; + playbackPaths: string[]; + muteEmbeddedPreview: boolean; + includeEmbeddedInExport: boolean; +} + +export function resolveSourceTrackRoutingPolicy( + videoResource: string | null | undefined, + sourceAudioFallbackPaths: string[] | null | undefined, +): SourceTrackRoutingPolicy { + const plan = buildResolvedAudioPlan({ + videoResource, + sourceAudioFallbackPaths, + }); + + return { + hasEmbeddedSourceAudio: plan.hasEmbeddedSourceAudio, + pathsByTrack: plan.pathsByTrack, + playbackPaths: plan.playbackPaths, + muteEmbeddedPreview: plan.muteEmbeddedPreview, + includeEmbeddedInExport: plan.includeEmbeddedInExport, + }; +} diff --git a/src/lib/exporter/streamingDecoder.test.ts b/src/lib/exporter/streamingDecoder.test.ts new file mode 100644 index 000000000..394febec9 --- /dev/null +++ b/src/lib/exporter/streamingDecoder.test.ts @@ -0,0 +1,177 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + getDecodedFrameStartupOffsetUs, + getDecodedFrameTimelineOffsetUs, + StreamingVideoDecoder, +} from "./streamingDecoder"; + +const { + mockDemuxerLoad, + mockDemuxerGetMediaInfo, + mockDemuxerDestroy, + mockDemuxerGetDecoderConfig, +} = vi.hoisted(() => ({ + mockDemuxerLoad: vi.fn(), + mockDemuxerGetMediaInfo: vi.fn(async () => ({ + duration: 4, + start_time: 0, + streams: [ + { + codec_type_string: "video", + width: 1920, + height: 1080, + avg_frame_rate: "30/1", + codec_string: "avc1.640034", + start_time: 0, + duration: 4, + }, + ], + })), + mockDemuxerDestroy: vi.fn(), + mockDemuxerGetDecoderConfig: vi.fn(), +})); + +vi.mock("web-demuxer", () => ({ + WebDemuxer: class MockWebDemuxer { + load = mockDemuxerLoad; + getMediaInfo = mockDemuxerGetMediaInfo; + destroy = mockDemuxerDestroy; + getDecoderConfig = mockDemuxerGetDecoderConfig; + }, +})); + +const mockReadLocalFile = vi.fn(); +const mockGetLocalMediaUrl = vi.fn(async (filePath: string) => ({ + success: true, + url: `http://127.0.0.1:4321/video?path=${encodeURIComponent(filePath)}`, +})); + +describe("StreamingVideoDecoder local media loading", () => { + beforeEach(() => { + vi.restoreAllMocks(); + mockDemuxerLoad.mockReset(); + mockDemuxerGetMediaInfo.mockClear(); + mockDemuxerDestroy.mockClear(); + mockDemuxerGetDecoderConfig.mockClear(); + mockReadLocalFile.mockReset(); + mockGetLocalMediaUrl.mockReset(); + mockGetLocalMediaUrl.mockImplementation(async (filePath: string) => ({ + success: true, + url: `http://127.0.0.1:4321/video?path=${encodeURIComponent(filePath)}`, + })); + Object.assign(globalThis, { + window: { + location: { + href: "http://localhost:5173/", + }, + electronAPI: { + readLocalFile: mockReadLocalFile, + getLocalMediaUrl: mockGetLocalMediaUrl, + }, + }, + }); + }); + + it("loads loopback media-server URLs directly into WebDemuxer", async () => { + const decoder = new StreamingVideoDecoder(); + await decoder.loadMetadata("http://127.0.0.1:43123/video?path=%2Ftmp%2Fcapture.mp4"); + + expect(window.electronAPI.readLocalFile).not.toHaveBeenCalled(); + expect(mockDemuxerLoad).toHaveBeenCalledWith( + "http://127.0.0.1:43123/video?path=%2Ftmp%2Fcapture.mp4", + ); + }); + + it("resolves absolute local paths to range-streamed media URLs", async () => { + const decoder = new StreamingVideoDecoder(); + await decoder.loadMetadata("/tmp/capture.mp4"); + + expect(window.electronAPI.getLocalMediaUrl).toHaveBeenCalledWith("/tmp/capture.mp4"); + expect(mockDemuxerLoad).toHaveBeenCalledWith( + "http://127.0.0.1:4321/video?path=%2Ftmp%2Fcapture.mp4", + ); + }); + + it("retries the range-streamed URL when direct local loading fails", async () => { + mockDemuxerLoad.mockReset(); + mockDemuxerLoad + .mockRejectedValueOnce(new Error("get_media_info failed: Failed after 3 attempts")) + .mockResolvedValueOnce(undefined); + const decoder = new StreamingVideoDecoder(); + await decoder.loadMetadata("/tmp/fallback.mp4"); + + expect(mockDemuxerLoad).toHaveBeenNthCalledWith( + 2, + "http://127.0.0.1:4321/video?path=%2Ftmp%2Ffallback.mp4", + ); + expect(window.electronAPI.readLocalFile).not.toHaveBeenCalled(); + }); + + it("keeps an explicit local retry on the range-streamed URL", async () => { + const decoder = new StreamingVideoDecoder(); + await decoder.loadMetadata("/tmp/retry.mp4", { + useFallbackMediaSource: true, + }); + + expect(mockDemuxerLoad).toHaveBeenCalledWith( + "http://127.0.0.1:4321/video?path=%2Ftmp%2Fretry.mp4", + ); + expect(window.electronAPI.readLocalFile).not.toHaveBeenCalled(); + }); +}); + +describe("getDecodedFrameStartupOffsetUs", () => { + it("ignores positive stream start metadata when the first decoded frame matches it", () => { + expect( + getDecodedFrameStartupOffsetUs(4_978_000, { + streamStartTime: 4.978, + }), + ).toBe(0); + }); + + it("returns only the startup gap beyond the stream start timestamp", () => { + expect( + getDecodedFrameStartupOffsetUs(5_128_000, { + streamStartTime: 4.978, + }), + ).toBe(150_000); + }); + + it("falls back to media start time and then zero when stream metadata is missing", () => { + expect( + getDecodedFrameStartupOffsetUs(250_000, { + mediaStartTime: 0.1, + }), + ).toBe(150_000); + + expect(getDecodedFrameStartupOffsetUs(250_000, {})).toBe(250_000); + }); +}); + +describe("getDecodedFrameTimelineOffsetUs", () => { + it("preserves a non-zero stream start time when decoded timestamps match the stream start", () => { + expect( + getDecodedFrameTimelineOffsetUs(6_741_667, { + mediaStartTime: 0, + streamStartTime: 6.741667, + }), + ).toBe(6_741_667); + }); + + it("includes both the stream start offset and any startup gap beyond it", () => { + expect( + getDecodedFrameTimelineOffsetUs(5_128_000, { + mediaStartTime: 0, + streamStartTime: 4.978, + }), + ).toBe(5_128_000); + }); + + it("falls back to a media-relative startup gap when stream metadata is missing", () => { + expect( + getDecodedFrameTimelineOffsetUs(250_000, { + mediaStartTime: 0.1, + }), + ).toBe(150_000); + }); +}); diff --git a/src/lib/exporter/streamingDecoder.ts b/src/lib/exporter/streamingDecoder.ts new file mode 100644 index 000000000..004f1fec0 --- /dev/null +++ b/src/lib/exporter/streamingDecoder.ts @@ -0,0 +1,694 @@ +import { WebDemuxer } from "web-demuxer"; +import type { SpeedRegion, TrimRegion } from "@/components/video-editor/types"; +import { getEffectiveVideoStreamDurationSeconds } from "@/lib/mediaTiming"; +import { createFallbackDemuxerSource, resolveMediaResourceUrl } from "./localMediaSource"; + +const DEFAULT_MAX_DECODE_QUEUE = 12; +const DEFAULT_MAX_PENDING_FRAMES = 32; +const STARTUP_STABILIZATION_SECONDS = 1.25; +const STARTUP_MAX_DECODE_QUEUE = 12; +const STARTUP_MAX_PENDING_FRAMES = 28; + +export interface DecodedVideoInfo { + width: number; + height: number; + duration: number; // seconds + mediaStartTime?: number; // seconds + streamStartTime?: number; // seconds + streamDuration?: number; // seconds + frameRate: number; + codec: string; + hasAudio: boolean; + audioCodec?: string; + audioSampleRate?: number; +} + +interface StreamingVideoDecoderLoadOptions { + useFallbackMediaSource?: boolean; +} + +/** Decoder retains ownership of the VideoFrame and closes it after use. */ +type OnFrameCallback = ( + frame: VideoFrame, + exportTimestampUs: number, + sourceTimestampMs: number, + cursorTimestampMs: number, +) => Promise<void>; + +export function getDecodedFrameStartupOffsetUs( + firstDecodedFrameTimestampUs: number, + metadata: Pick<DecodedVideoInfo, "mediaStartTime" | "streamStartTime">, +): number { + const streamStartTimeUs = Math.round( + (metadata.streamStartTime ?? metadata.mediaStartTime ?? 0) * 1_000_000, + ); + + return Math.max(0, firstDecodedFrameTimestampUs - streamStartTimeUs); +} + +export function getDecodedFrameTimelineOffsetUs( + firstDecodedFrameTimestampUs: number, + metadata: Pick<DecodedVideoInfo, "mediaStartTime" | "streamStartTime">, +): number { + const mediaStartTimeUs = Math.round((metadata.mediaStartTime ?? 0) * 1_000_000); + const streamStartTimeUs = Math.round( + (metadata.streamStartTime ?? metadata.mediaStartTime ?? 0) * 1_000_000, + ); + + return ( + Math.max(0, streamStartTimeUs - mediaStartTimeUs) + + getDecodedFrameStartupOffsetUs(firstDecodedFrameTimestampUs, metadata) + ); +} + +/** + * Decodes video frames via web-demuxer + VideoDecoder in a single forward pass. + * Way faster than seeking an HTMLVideoElement per frame. + * + * Frames in trimmed regions are decoded (needed for P/B-frame state) but discarded. + * Kept frames are resampled to the target frame rate in a streaming pass. + */ +export class StreamingVideoDecoder { + private demuxer: WebDemuxer | null = null; + private decoder: VideoDecoder | null = null; + private cancelled = false; + private metadata: DecodedVideoInfo | null = null; + private pendingFrames: VideoFrame[] = []; + private readonly maxDecodeQueue: number; + private readonly maxPendingFrames: number; + + constructor(options?: { + maxDecodeQueue?: number; + maxPendingFrames?: number; + }) { + this.maxDecodeQueue = Math.max( + 1, + Math.floor(options?.maxDecodeQueue ?? DEFAULT_MAX_DECODE_QUEUE), + ); + this.maxPendingFrames = Math.max( + 1, + Math.floor(options?.maxPendingFrames ?? DEFAULT_MAX_PENDING_FRAMES), + ); + } + + async loadMetadata( + videoUrl: string, + options: StreamingVideoDecoderLoadOptions = {}, + ): Promise<DecodedVideoInfo> { + if (this.decoder) { + try { + if (this.decoder.state === "configured") { + this.decoder.close(); + } + } catch { + // Ignore cleanup errors while reloading metadata. + } + this.decoder = null; + } + + if (this.demuxer) { + try { + this.demuxer.destroy(); + } catch { + // Ignore cleanup errors while reloading metadata. + } + this.demuxer = null; + } + + const resourceUrl = await resolveMediaResourceUrl(videoUrl); + + // Relative URL so it resolves correctly in both dev (http) and packaged (file://) builds + const wasmUrl = new URL("./wasm/web-demuxer.wasm", window.location.href).href; + const loadMediaInfo = async (source: string | File) => { + this.demuxer = new WebDemuxer({ wasmFilePath: wasmUrl }); + await this.demuxer.load(source); + return this.demuxer.getMediaInfo(); + }; + + let mediaInfo; + if (options.useFallbackMediaSource) { + mediaInfo = await loadMediaInfo(await createFallbackDemuxerSource(videoUrl)); + } else { + try { + mediaInfo = await loadMediaInfo(resourceUrl); + } catch (error) { + console.warn( + "[StreamingVideoDecoder] Direct source load failed, retrying with a fresh media source:", + error, + ); + const currentDemuxer = this.demuxer; + if (currentDemuxer) { + try { + (currentDemuxer as unknown as { destroy: () => void }).destroy(); + } catch { + // Ignore cleanup errors before fallback re-init. + } + } + mediaInfo = await loadMediaInfo(await createFallbackDemuxerSource(videoUrl)); + } + } + + const videoStream = mediaInfo.streams.find((s) => s.codec_type_string === "video"); + const audioStream = mediaInfo.streams.find((s) => s.codec_type_string === "audio"); + const mediaStartTime = + typeof mediaInfo.start_time === "number" && Number.isFinite(mediaInfo.start_time) + ? mediaInfo.start_time + : 0; + const streamStartTime = + typeof videoStream?.start_time === "number" && Number.isFinite(videoStream.start_time) + ? videoStream.start_time + : mediaStartTime; + + let frameRate = 60; + if (videoStream?.avg_frame_rate) { + const parts = videoStream.avg_frame_rate.split("/"); + if (parts.length === 2) { + const num = parseInt(parts[0], 10); + const den = parseInt(parts[1], 10); + if (den > 0 && num > 0) frameRate = num / den; + } + } + + this.metadata = { + width: videoStream?.width || 1920, + height: videoStream?.height || 1080, + duration: mediaInfo.duration, + mediaStartTime, + streamStartTime, + streamDuration: + typeof videoStream?.duration === "number" && Number.isFinite(videoStream.duration) + ? videoStream.duration + : undefined, + frameRate, + codec: videoStream?.codec_string || "unknown", + hasAudio: !!audioStream, + audioCodec: audioStream?.codec_string, + audioSampleRate: + typeof audioStream?.sample_rate === "string" + ? Number.parseInt(audioStream.sample_rate, 10) + : undefined, + }; + + return this.metadata; + } + + async decodeAll( + targetFrameRate: number, + trimRegions: TrimRegion[] | undefined, + speedRegions: SpeedRegion[] | undefined, + onFrame: OnFrameCallback, + ): Promise<void> { + if (!this.demuxer || !this.metadata) { + throw new Error("Must call loadMetadata() before decodeAll()"); + } + + const decoderConfig = await this.demuxer.getDecoderConfig("video"); + const codec = this.metadata.codec.toLowerCase(); + const shouldPreferSoftwareDecode = codec.includes("av01") || codec.includes("av1"); + const effectiveVideoDuration = getEffectiveVideoStreamDurationSeconds({ + duration: this.metadata.duration, + streamDuration: this.metadata.streamDuration, + }); + const segments = this.splitBySpeed( + this.computeSegments(effectiveVideoDuration, trimRegions), + speedRegions, + ); + const segmentOutputFrameCounts = segments.map((segment) => + Math.ceil(((segment.endSec - segment.startSec) / segment.speed) * targetFrameRate), + ); + const expectedOutputFrames = segmentOutputFrameCounts.reduce( + (sum, count) => sum + count, + 0, + ); + const frameDurationUs = 1_000_000 / targetFrameRate; + const epsilonSec = 0.001; + const startupStabilizationSeconds = STARTUP_STABILIZATION_SECONDS; + const startupFrameBudget = Math.max( + 1, + Math.round(targetFrameRate * startupStabilizationSeconds), + ); + let exportFrameIndex = 0; + let loggedSteadyStateBackpressure = false; + const backpressureWaiters = new Set<() => void>(); + + const notifyBackpressureProgress = () => { + if (backpressureWaiters.size === 0) { + return; + } + + const waiters = [...backpressureWaiters]; + backpressureWaiters.clear(); + for (const resolve of waiters) { + resolve(); + } + }; + + const waitForBackpressureProgress = () => + new Promise<void>((resolve) => { + backpressureWaiters.add(resolve); + }); + + console.log( + `[StreamingVideoDecoder] Startup-safe decode backpressure active for first ${startupStabilizationSeconds}s (${startupFrameBudget} frames)`, + ); + + // Async frame queue — decoder pushes, consumer pulls + this.pendingFrames.length = 0; + const pendingFrames = this.pendingFrames; + let frameResolve: ((frame: VideoFrame | null) => void) | null = null; + let decodeError: Error | null = null; + let decodeDone = false; + let firstDecodedFrameTimestampUs: number | null = null; + let decodedFrameTimelineOffsetUs = 0; + + this.decoder = new VideoDecoder({ + output: (frame: VideoFrame) => { + if (frameResolve) { + const resolve = frameResolve; + frameResolve = null; + resolve(frame); + } else { + pendingFrames.push(frame); + } + notifyBackpressureProgress(); + }, + error: (e: DOMException) => { + decodeError = new Error(`VideoDecoder error: ${e.message}`); + if (frameResolve) { + const resolve = frameResolve; + frameResolve = null; + resolve(null); + } + notifyBackpressureProgress(); + }, + }); + const preferredDecoderConfig = shouldPreferSoftwareDecode + ? { + ...decoderConfig, + hardwareAcceleration: "prefer-software" as const, + } + : decoderConfig; + + try { + this.decoder.configure(preferredDecoderConfig); + } catch (error) { + if (!shouldPreferSoftwareDecode) { + throw error; + } + // Fall back to default decoder config if software preference is unsupported. + this.decoder.configure(decoderConfig); + } + + const getNextFrame = (): Promise<VideoFrame | null> => { + if (decodeError) throw decodeError; + if (pendingFrames.length > 0) { + const frame = pendingFrames.shift()!; + notifyBackpressureProgress(); + return Promise.resolve(frame); + } + if (decodeDone) return Promise.resolve(null); + return new Promise((resolve) => { + frameResolve = resolve; + }); + }; + + // One forward stream through the whole file. + // Pass explicit range because some containers are truncated when no end is provided. + const readEndSec = + Math.max( + this.metadata.duration + (this.metadata.mediaStartTime ?? 0), + (this.metadata.streamDuration ?? this.metadata.duration) + + (this.metadata.streamStartTime ?? this.metadata.mediaStartTime ?? 0), + ) + 0.5; + const reader = this.demuxer.read("video", 0, readEndSec).getReader(); + + // Feed chunks to decoder in background with backpressure + const feedPromise = (async () => { + try { + while (!this.cancelled) { + const { done, value: chunk } = await reader.read(); + if (done || !chunk) break; + + if (!loggedSteadyStateBackpressure && exportFrameIndex >= startupFrameBudget) { + loggedSteadyStateBackpressure = true; + console.log( + "[StreamingVideoDecoder] Switched to steady-state decode backpressure", + ); + } + + const decodeQueueLimit = + exportFrameIndex < startupFrameBudget + ? Math.min(this.maxDecodeQueue, STARTUP_MAX_DECODE_QUEUE) + : this.maxDecodeQueue; + const pendingFrameLimit = + exportFrameIndex < startupFrameBudget + ? Math.min(this.maxPendingFrames, STARTUP_MAX_PENDING_FRAMES) + : this.maxPendingFrames; + + // Backpressure on both decode queue and decoded frame backlog. + while ( + (this.decoder!.decodeQueueSize > decodeQueueLimit || + pendingFrames.length > pendingFrameLimit) && + !this.cancelled + ) { + await waitForBackpressureProgress(); + } + if (this.cancelled) break; + + this.decoder!.decode(chunk); + } + + if (!this.cancelled && this.decoder!.state === "configured") { + await this.decoder!.flush(); + } + } catch (e) { + decodeError = e instanceof Error ? e : new Error(String(e)); + } finally { + decodeDone = true; + if (frameResolve) { + const resolve = frameResolve; + frameResolve = null; + resolve(null); + } + notifyBackpressureProgress(); + } + })(); + + // Route decoded frames into segments by timestamp, then deliver with VFR→CFR resampling + let segmentIdx = 0; + let segmentFrameIndex = 0; + let lastDecodedFrameSec: number | null = null; + let heldFrame: VideoFrame | null = null; + let heldFrameSec = 0; + + const emitHeldFrameForTarget = async (segment: { + startSec: number; + endSec: number; + speed: number; + }) => { + if (!heldFrame) return false; + const segmentFrameCount = segmentOutputFrameCounts[segmentIdx]; + if (segmentFrameIndex >= segmentFrameCount) return false; + + const segmentDurationSec = segment.endSec - segment.startSec; + const sourceTimeSec = + segment.startSec + (segmentFrameIndex / segmentFrameCount) * segmentDurationSec; + if (sourceTimeSec >= segment.endSec - epsilonSec) return false; + + const sourceTimestampMs = sourceTimeSec * 1000; + await onFrame( + heldFrame, + exportFrameIndex * frameDurationUs, + sourceTimestampMs, + sourceTimestampMs, + ); + segmentFrameIndex++; + exportFrameIndex++; + return true; + }; + + while (!this.cancelled && segmentIdx < segments.length) { + const frame = await getNextFrame(); + if (!frame) break; + + if (firstDecodedFrameTimestampUs === null) { + firstDecodedFrameTimestampUs = frame.timestamp; + decodedFrameTimelineOffsetUs = getDecodedFrameTimelineOffsetUs( + firstDecodedFrameTimestampUs, + this.metadata, + ); + } + + const normalizedFrameTimeSec = Math.max( + 0, + (frame.timestamp - firstDecodedFrameTimestampUs + decodedFrameTimelineOffsetUs) / + 1_000_000, + ); + const frameTimeSec: number = + lastDecodedFrameSec === null + ? normalizedFrameTimeSec + : Math.max(lastDecodedFrameSec, normalizedFrameTimeSec); + lastDecodedFrameSec = frameTimeSec; + + // Finalize completed segments before handling this frame. + while ( + segmentIdx < segments.length && + frameTimeSec >= segments[segmentIdx].endSec - epsilonSec + ) { + const segment = segments[segmentIdx]; + while (!this.cancelled && (await emitHeldFrameForTarget(segment))) { + // Keep emitting remaining output frames for this segment from the last known frame. + } + + segmentIdx++; + segmentFrameIndex = 0; + if ( + heldFrame && + segmentIdx < segments.length && + heldFrameSec < segments[segmentIdx].startSec - epsilonSec + ) { + heldFrame.close(); + heldFrame = null; + } + } + + if (segmentIdx >= segments.length) { + frame.close(); + continue; + } + + const currentSegment = segments[segmentIdx]; + + // Before current segment (trimmed region or pre-roll). + if (frameTimeSec < currentSegment.startSec - epsilonSec) { + frame.close(); + continue; + } + + if (!heldFrame) { + heldFrame = frame; + heldFrameSec = frameTimeSec; + continue; + } + + // Any target timestamp before this midpoint is closer to heldFrame than current frame. + const handoffBoundarySec = (heldFrameSec + frameTimeSec) / 2; + while (!this.cancelled) { + const segmentFrameCount = segmentOutputFrameCounts[segmentIdx]; + if (segmentFrameIndex >= segmentFrameCount) { + break; + } + + const segmentDurationSec = currentSegment.endSec - currentSegment.startSec; + const sourceTimeSec = + currentSegment.startSec + + (segmentFrameIndex / segmentFrameCount) * segmentDurationSec; + if (sourceTimeSec >= currentSegment.endSec - epsilonSec) { + break; + } + if (sourceTimeSec > handoffBoundarySec) { + break; + } + + const sourceTimestampMs = sourceTimeSec * 1000; + await onFrame( + heldFrame, + exportFrameIndex * frameDurationUs, + sourceTimestampMs, + sourceTimestampMs, + ); + segmentFrameIndex++; + exportFrameIndex++; + } + + heldFrame.close(); + heldFrame = frame; + heldFrameSec = frameTimeSec; + } + + // Flush remaining output frames for the last decoded frame. + if (heldFrame && segmentIdx < segments.length) { + while (!this.cancelled && segmentIdx < segments.length) { + const segment = segments[segmentIdx]; + if (heldFrameSec < segment.startSec - epsilonSec) { + break; + } + + while (!this.cancelled && (await emitHeldFrameForTarget(segment))) { + // Keep emitting output frames for the active segment. + } + + segmentIdx++; + segmentFrameIndex = 0; + if ( + segmentIdx < segments.length && + heldFrameSec < segments[segmentIdx].startSec - epsilonSec + ) { + break; + } + } + heldFrame.close(); + heldFrame = null; + } else if (heldFrame) { + heldFrame.close(); + heldFrame = null; + } + + // Drain leftover decoded frames + while (!decodeDone) { + const frame = await getNextFrame(); + if (!frame) break; + frame.close(); + } + + try { + reader.cancel(); + } catch { + /* already closed */ + } + await feedPromise; + for (const f of pendingFrames) f.close(); + pendingFrames.length = 0; + + if (this.decoder?.state === "configured") { + this.decoder.close(); + } + this.decoder = null; + + const requiredEndSec = segments.length > 0 ? segments[segments.length - 1].endSec : 0; + if ( + !this.cancelled && + lastDecodedFrameSec !== null && + requiredEndSec - lastDecodedFrameSec > 1 && + exportFrameIndex < expectedOutputFrames + ) { + throw new Error( + `Video decode ended early at ${lastDecodedFrameSec.toFixed(3)}s (needed ${requiredEndSec.toFixed(3)}s; rendered ${exportFrameIndex}/${expectedOutputFrames} frames).`, + ); + } + } + + private computeSegments( + totalDuration: number, + trimRegions?: TrimRegion[], + ): Array<{ startSec: number; endSec: number }> { + if (!trimRegions || trimRegions.length === 0) { + return [{ startSec: 0, endSec: totalDuration }]; + } + + const sorted = [...trimRegions].sort((a, b) => a.startMs - b.startMs); + const segments: Array<{ startSec: number; endSec: number }> = []; + let cursor = 0; + + for (const trim of sorted) { + const trimStart = trim.startMs / 1000; + const trimEnd = trim.endMs / 1000; + if (cursor < trimStart) { + segments.push({ startSec: cursor, endSec: trimStart }); + } + cursor = Math.max(cursor, trimEnd); + } + + if (cursor < totalDuration) { + segments.push({ startSec: cursor, endSec: totalDuration }); + } + + return segments; + } + + getEffectiveDuration(trimRegions?: TrimRegion[], speedRegions?: SpeedRegion[]): number { + if (!this.metadata) throw new Error("Must call loadMetadata() first"); + const trimSegments = this.computeSegments( + getEffectiveVideoStreamDurationSeconds({ + duration: this.metadata.duration, + streamDuration: this.metadata.streamDuration, + }), + trimRegions, + ); + const speedSegments = this.splitBySpeed(trimSegments, speedRegions); + return speedSegments.reduce((sum, seg) => sum + (seg.endSec - seg.startSec) / seg.speed, 0); + } + + private splitBySpeed( + segments: Array<{ startSec: number; endSec: number }>, + speedRegions?: SpeedRegion[], + ): Array<{ startSec: number; endSec: number; speed: number }> { + if (!speedRegions || speedRegions.length === 0) + return segments.map((s) => ({ ...s, speed: 1 })); + + const result: Array<{ startSec: number; endSec: number; speed: number }> = []; + for (const segment of segments) { + const overlapping = speedRegions + .filter( + (sr) => + sr.startMs / 1000 < segment.endSec && sr.endMs / 1000 > segment.startSec, + ) + .sort((a, b) => a.startMs - b.startMs); + + if (overlapping.length === 0) { + result.push({ ...segment, speed: 1 }); + continue; + } + + let cursor = segment.startSec; + for (const sr of overlapping) { + const srStart = Math.max(sr.startMs / 1000, segment.startSec); + const srEnd = Math.min(sr.endMs / 1000, segment.endSec); + if (cursor < srStart) { + result.push({ startSec: cursor, endSec: srStart, speed: 1 }); + } + const effectiveStart = Math.max(cursor, srStart); + if (srEnd > effectiveStart) { + result.push({ + startSec: effectiveStart, + endSec: srEnd, + speed: sr.speed, + }); + } + cursor = Math.max(cursor, srEnd); + } + if (cursor < segment.endSec) + result.push({ startSec: cursor, endSec: segment.endSec, speed: 1 }); + } + return result.filter((s) => s.endSec - s.startSec > 0.0001); + } + + cancel(): void { + this.cancelled = true; + } + + getDemuxer() { + return this.demuxer; + } + + destroy(): void { + this.cancelled = true; + + if (this.decoder) { + try { + if (this.decoder.state === "configured") this.decoder.close(); + } catch { + /* ignore */ + } + this.decoder = null; + } + + if (this.demuxer) { + try { + this.demuxer.destroy(); + } catch { + /* ignore */ + } + this.demuxer = null; + } + + for (const frame of this.pendingFrames) { + try { + frame.close(); + } catch { + /* ignore */ + } + } + this.pendingFrames.length = 0; + } +} diff --git a/src/lib/exporter/temporalMotionBlur.test.ts b/src/lib/exporter/temporalMotionBlur.test.ts new file mode 100644 index 000000000..b2a37f31c --- /dev/null +++ b/src/lib/exporter/temporalMotionBlur.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vitest"; +import { + buildTemporalSampleOffsetsUs, + buildTemporalSamplePlanUs, + getTemporalMotionBlurConfig, +} from "./temporalMotionBlur"; + +describe("temporalMotionBlur", () => { + it("disables temporal blur for zero strength", () => { + expect(getTemporalMotionBlurConfig(0)).toBeNull(); + expect(getTemporalMotionBlurConfig(undefined)).toBeNull(); + }); + + it("uses an odd number of centered samples", () => { + const config = getTemporalMotionBlurConfig(1); + + expect(config).not.toBeNull(); + expect(config?.sampleCount).toBe(5); + expect(config?.sampleCount % 2).toBe(1); + expect(config?.shutterFraction).toBeGreaterThan(0.24); + }); + + it("caps the automatic sample budget to keep exports responsive", () => { + const config = getTemporalMotionBlurConfig(2); + + expect(config).not.toBeNull(); + expect(config?.sampleCount).toBe(5); + }); + + it("builds symmetric shutter offsets around the frame center", () => { + const offsets = buildTemporalSampleOffsetsUs(33_333.333, { + sampleCount: 5, + shutterFraction: 0.9, + }); + + expect(offsets).toHaveLength(5); + expect(offsets[2]).toBeCloseTo(0, 6); + expect(offsets[0]).toBeCloseTo(-offsets[4], 6); + expect(offsets[1]).toBeCloseTo(-offsets[3], 6); + }); + + it("accepts explicit shutter and odd sample overrides", () => { + const config = getTemporalMotionBlurConfig(0.35, { + sampleCount: 60, + shutterFraction: 3, + }); + + expect(config).not.toBeNull(); + expect(config?.sampleCount).toBe(61); + expect(config?.shutterFraction).toBeCloseTo(3, 6); + }); + + it("allows experimental multi-frame shutter windows", () => { + const offsets = buildTemporalSampleOffsetsUs(33_333.333, { + sampleCount: 61, + shutterFraction: 3, + }); + + expect(offsets).toHaveLength(61); + expect(offsets[30]).toBeCloseTo(0, 6); + expect(offsets[0]).toBeCloseTo(-50_000, 0); + expect(offsets[60]).toBeCloseTo(50_000, 0); + }); + + it("builds normalized sample weights with a center bias", () => { + const config = getTemporalMotionBlurConfig(1.2); + expect(config).not.toBeNull(); + + const plan = buildTemporalSamplePlanUs(33_333.333, config!); + const totalWeight = plan.reduce((sum, sample) => sum + sample.weight, 0); + const centerSample = plan[Math.floor(plan.length / 2)]; + const edgeSample = plan[0]; + + expect(totalWeight).toBeCloseTo(1, 6); + expect(centerSample?.weight ?? 0).toBeGreaterThan(edgeSample?.weight ?? 0); + expect(plan.map((sample) => sample.offsetUs)).toContain(0); + }); +}); diff --git a/src/lib/exporter/temporalMotionBlur.ts b/src/lib/exporter/temporalMotionBlur.ts new file mode 100644 index 000000000..69ec5e24b --- /dev/null +++ b/src/lib/exporter/temporalMotionBlur.ts @@ -0,0 +1,134 @@ +interface TemporalMotionBlurConfig { + sampleCount: number; + shutterFraction: number; + weightCurvePower: number; +} + +interface TemporalMotionBlurOverrides { + sampleCount?: number | null; + shutterFraction?: number | null; +} + +interface TemporalMotionBlurSample { + offsetUs: number; + weight: number; +} + +const MIN_BLUR_AMOUNT = 0.001; +export const TEMPORAL_MOTION_BLUR_MIN_SHUTTER_FRACTION = 0.18; +export const TEMPORAL_MOTION_BLUR_MAX_SHUTTER_FRACTION = 3; +export const TEMPORAL_MOTION_BLUR_MIN_SAMPLE_COUNT = 3; +export const TEMPORAL_MOTION_BLUR_MAX_SAMPLE_COUNT = 61; +export const TEMPORAL_MOTION_BLUR_DEFAULT_SAMPLE_COUNT = 13; +export const TEMPORAL_MOTION_BLUR_DEFAULT_SHUTTER_FRACTION = 0.94; +const TEMPORAL_MOTION_BLUR_AUTO_MIN_SHUTTER_FRACTION = 0.24; +const TEMPORAL_MOTION_BLUR_AUTO_MAX_SHUTTER_FRACTION = 0.62; +const TEMPORAL_MOTION_BLUR_AUTO_MAX_SAMPLE_COUNT = 5; +const TEMPORAL_MOTION_BLUR_WEIGHT_FLOOR = 0.22; +const MAX_BLUR_AMOUNT = 2; + +function normalizeTemporalMotionBlurSampleCount(value: number | null | undefined): number | null { + if (!Number.isFinite(value)) { + return null; + } + + const roundedValue = Math.round(value ?? 0); + const clampedValue = Math.min( + TEMPORAL_MOTION_BLUR_MAX_SAMPLE_COUNT, + Math.max(TEMPORAL_MOTION_BLUR_MIN_SAMPLE_COUNT, roundedValue), + ); + + if (clampedValue % 2 === 1) { + return clampedValue; + } + + return clampedValue >= TEMPORAL_MOTION_BLUR_MAX_SAMPLE_COUNT + ? clampedValue - 1 + : clampedValue + 1; +} + +export function getTemporalMotionBlurConfig( + amount: number | null | undefined, + overrides: TemporalMotionBlurOverrides = {}, +): TemporalMotionBlurConfig | null { + const resolvedAmount = Number.isFinite(amount) ? Math.max(0, amount ?? 0) : 0; + if (resolvedAmount < MIN_BLUR_AMOUNT) { + return null; + } + + const normalizedAmount = Math.min(1, resolvedAmount / MAX_BLUR_AMOUNT); + const sampleStepCount = Math.round( + normalizedAmount * + ((TEMPORAL_MOTION_BLUR_AUTO_MAX_SAMPLE_COUNT - TEMPORAL_MOTION_BLUR_MIN_SAMPLE_COUNT) / + 2), + ); + const defaultSampleCount = TEMPORAL_MOTION_BLUR_MIN_SAMPLE_COUNT + sampleStepCount * 2; + const sampleCount = + normalizeTemporalMotionBlurSampleCount(overrides.sampleCount) ?? defaultSampleCount; + const shutterFraction = Number.isFinite(overrides.shutterFraction) + ? Math.min( + TEMPORAL_MOTION_BLUR_MAX_SHUTTER_FRACTION, + Math.max( + TEMPORAL_MOTION_BLUR_MIN_SHUTTER_FRACTION, + overrides.shutterFraction ?? TEMPORAL_MOTION_BLUR_AUTO_MIN_SHUTTER_FRACTION, + ), + ) + : TEMPORAL_MOTION_BLUR_AUTO_MIN_SHUTTER_FRACTION + + normalizedAmount * + (TEMPORAL_MOTION_BLUR_AUTO_MAX_SHUTTER_FRACTION - + TEMPORAL_MOTION_BLUR_AUTO_MIN_SHUTTER_FRACTION); + + return { + sampleCount, + shutterFraction, + weightCurvePower: 1.2 + normalizedAmount * 0.9, + }; +} + +export function buildTemporalSampleOffsetsUs( + frameDurationUs: number, + config: TemporalMotionBlurConfig, +): number[] { + const safeFrameDurationUs = Math.max(1, frameDurationUs); + const safeSampleCount = Math.max(1, Math.floor(config.sampleCount)); + if (safeSampleCount === 1) { + return [0]; + } + + const shutterWindowUs = + safeFrameDurationUs * + Math.max(0, Math.min(TEMPORAL_MOTION_BLUR_MAX_SHUTTER_FRACTION, config.shutterFraction)); + const startOffsetUs = -shutterWindowUs / 2; + const stepUs = shutterWindowUs / (safeSampleCount - 1); + + return Array.from({ length: safeSampleCount }, (_, index) => startOffsetUs + stepUs * index); +} + +export function buildTemporalSamplePlanUs( + frameDurationUs: number, + config: TemporalMotionBlurConfig, +): TemporalMotionBlurSample[] { + const offsetsUs = buildTemporalSampleOffsetsUs(frameDurationUs, config); + if (offsetsUs.length === 1) { + return [{ offsetUs: 0, weight: 1 }]; + } + + const centerIndex = (offsetsUs.length - 1) / 2; + const rawWeights = offsetsUs.map((_offsetUs, index) => { + const normalizedDistance = Math.abs(index - centerIndex) / Math.max(1, centerIndex); + const taperedWeight = Math.cos(normalizedDistance * (Math.PI / 2)); + return ( + TEMPORAL_MOTION_BLUR_WEIGHT_FLOOR + + (1 - TEMPORAL_MOTION_BLUR_WEIGHT_FLOOR) * + Math.pow(Math.max(0, taperedWeight), config.weightCurvePower) + ); + }); + const totalWeight = rawWeights.reduce((sum, weight) => sum + weight, 0) || 1; + + return offsetsUs.map((offsetUs, index) => ({ + offsetUs, + weight: rawWeights[index]! / totalWeight, + })); +} + +export type { TemporalMotionBlurConfig, TemporalMotionBlurOverrides, TemporalMotionBlurSample }; diff --git a/src/lib/exporter/types.test.ts b/src/lib/exporter/types.test.ts new file mode 100644 index 000000000..81f9553c3 --- /dev/null +++ b/src/lib/exporter/types.test.ts @@ -0,0 +1,56 @@ +import * as fc from "fast-check"; +import { describe, expect, it } from "vitest"; +import { GifFrameRate, isValidGifFrameRate, VALID_GIF_FRAME_RATES } from "./types"; + +/** + * Property 1: Valid Frame Rate Acceptance + * + * *For any* frame rate value, the GIF_Exporter SHALL accept it if and only if + * it is one of the valid presets (15, 20, 25, 30 FPS). Invalid frame rates + * should be rejected with an error. + * + * **Validates: Requirements 2.2** + * + * Feature: gif-export, Property 1: Valid Frame Rate Acceptance + */ +describe("GIF Export Types", () => { + describe("Property 1: Valid Frame Rate Acceptance", () => { + // Property test: Valid frame rates should be accepted + it("should accept all valid frame rates (15, 20, 25, 30)", () => { + fc.assert( + fc.property( + fc.constantFrom(...VALID_GIF_FRAME_RATES), + (frameRate: GifFrameRate) => { + expect(isValidGifFrameRate(frameRate)).toBe(true); + }, + ), + { numRuns: 100 }, + ); + }); + + // Property test: Invalid frame rates should be rejected + it("should reject any frame rate not in the valid set", () => { + fc.assert( + fc.property( + fc.integer().filter((n) => !VALID_GIF_FRAME_RATES.includes(n as GifFrameRate)), + (invalidFrameRate: number) => { + expect(isValidGifFrameRate(invalidFrameRate)).toBe(false); + }, + ), + { numRuns: 100 }, + ); + }); + + // Property test: Frame rate validation is deterministic + it("should return consistent results for the same input", () => { + fc.assert( + fc.property(fc.integer({ min: 1, max: 60 }), (frameRate: number) => { + const result1 = isValidGifFrameRate(frameRate); + const result2 = isValidGifFrameRate(frameRate); + expect(result1).toBe(result2); + }), + { numRuns: 100 }, + ); + }); + }); +}); diff --git a/src/lib/exporter/types.ts b/src/lib/exporter/types.ts index 5bba76f83..f474f9988 100644 --- a/src/lib/exporter/types.ts +++ b/src/lib/exporter/types.ts @@ -1,26 +1,231 @@ export interface ExportConfig { - width: number; - height: number; - frameRate: number; - bitrate: number; - codec?: string; + width: number; + height: number; + frameRate: number; + bitrate: number; + codec?: string; + encodingMode?: ExportEncodingMode; + backendPreference?: ExportBackendPreference; + preferredRenderBackend?: ExportRenderBackend; + experimentalNativeExport?: boolean; + experimentalNvidiaCudaExport?: boolean; + maxEncodeQueue?: number; + maxDecodeQueue?: number; + maxPendingFrames?: number; + maxInFlightNativeWrites?: number; + sourceAudioFallbackStartDelayMsByPath?: Record<string, number>; } +export type ExportRenderBackend = "webgpu" | "webgl"; +export type ExportEncodeBackend = "ffmpeg" | "webcodecs"; +export type ExportBackendPreference = "auto" | "webcodecs" | "breeze"; +export type ExportPipelineModel = "modern" | "legacy"; + export interface ExportProgress { - currentFrame: number; - totalFrames: number; - percentage: number; - estimatedTimeRemaining: number; // in seconds + currentFrame: number; + totalFrames: number; + percentage: number; + estimatedTimeRemaining: number; // in seconds + renderFps?: number; + renderBackend?: ExportRenderBackend; + encodeBackend?: ExportEncodeBackend; + encoderName?: string; + nativeStaticLayoutSkipReason?: string; + nativeStaticLayoutSkipReasons?: string[]; + phase?: "preparing" | "extracting" | "finalizing" | "saving"; // Phase of export + renderProgress?: number; // 0-100, progress of GIF rendering phase + audioProgress?: number; // 0-1, progress of real-time audio rendering (speed/audio regions) +} + +export interface ExportFinalizationStageMetrics { + encoderFlushMs?: number; + queuedMuxingMs?: number; + audioProcessingMs?: number; + muxerFinalizeMs?: number; + editedAudioRenderMs?: number; + ffmpegAudioMuxMs?: number; + nativeExportFinalizeMs?: number; + nativeEncoderFlushMs?: number; + ffmpegAudioMuxBreakdown?: ExportFfmpegAudioMuxBreakdown; +} + +export interface ExportFfmpegAudioMuxBreakdown { + tempVideoWriteMs?: number; + tempEditedAudioWriteMs?: number; + ffmpegExecMs?: number; + muxedVideoReadMs?: number; + tempVideoBytes?: number; + tempEditedAudioBytes?: number; + muxedVideoBytes?: number; + chunkCount?: number; + chunkDurationSec?: number; + chunkExecMs?: number; + concatExecMs?: number; + staticAssetExecMs?: number; + fallbackChunkCount?: number; + videoOnlyBytes?: number; + chunks?: Array<{ + index: number; + startSec: number; + durationSec: number; + backend: string; + elapsedMs: number; + outputBytes: number; + fallbackReason?: string; + windowsGpuSummary?: { + 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; + }; + }>; +} + +export interface ExportMetrics { + totalElapsedMs: number; + metadataLoadMs?: number; + rendererInitMs?: number; + nativeSessionStartMs?: number; + decodeLoopMs?: number; + frameCallbackMs?: number; + renderFrameMs?: number; + encodeWaitMs?: number; + encodeWaitEvents?: number; + peakEncodeQueueSize?: number; + peakNativeWriteInFlight?: number; + nativeCaptureMs?: number; + nativeWriteMs?: number; + finalizationMs?: number; + frameCount?: number; + renderBackend?: ExportRenderBackend; + encodeBackend?: ExportEncodeBackend; + encoderName?: string; + backpressureProfile?: string; + nativeStaticLayoutSkipReason?: string; + nativeStaticLayoutSkipReasons?: string[]; + averageFrameCallbackMs?: number; + averageRenderFrameMs?: number; + averageEncodeWaitMs?: number; + averageNativeCaptureMs?: number; + averageNativeWriteMs?: number; + effectiveDurationSec?: number; + finalizationStageMs?: ExportFinalizationStageMetrics; } export interface ExportResult { - success: boolean; - blob?: Blob; - error?: string; + success: boolean; + /** + * Absolute path to a main-process temp file containing the finished export. + * Preferred for MP4 output because it avoids loading multi-gigabyte files + * into the renderer's ArrayBuffer heap. The renderer should move the temp + * file to its final destination via `finalize-exported-video`. + */ + tempFilePath?: string; + /** + * In-renderer Blob for exports that fit in memory (GIF, smoke tests, legacy + * fallback). Mutually exclusive with `tempFilePath` — consumers should + * prefer the temp path when both are set. + */ + blob?: Blob; + filePath?: string; + error?: string; + metrics?: ExportMetrics; } export interface VideoFrameData { - frame: VideoFrame; - timestamp: number; // in microseconds - duration: number; // in microseconds + frame: VideoFrame; + timestamp: number; // in microseconds + duration: number; // in microseconds +} + +export type ExportEncodingMode = "fast" | "balanced" | "quality"; + +export type ExportQuality = "medium" | "good" | "high" | "source"; + +export type ExportMp4FrameRate = 24 | 30 | 60; + +// GIF Export Types +export type ExportFormat = "mp4" | "gif"; + +export type GifFrameRate = 15 | 20 | 25 | 30; + +export type GifSizePreset = "medium" | "large" | "original"; + +export interface GifExportConfig { + frameRate: GifFrameRate; + loop: boolean; + sizePreset: GifSizePreset; + width: number; + height: number; +} + +export interface ExportSettings { + format: ExportFormat; + includeCaptionSidecar?: boolean; + // MP4 settings + quality?: ExportQuality; + encodingMode?: ExportEncodingMode; + mp4FrameRate?: ExportMp4FrameRate; + backendPreference?: ExportBackendPreference; + pipelineModel?: ExportPipelineModel; + // GIF settings + gifConfig?: GifExportConfig; +} + +export const MP4_FRAME_RATES: readonly ExportMp4FrameRate[] = [24, 30, 60] as const; + +export function isValidMp4FrameRate(rate: number): rate is ExportMp4FrameRate { + return MP4_FRAME_RATES.includes(rate as ExportMp4FrameRate); +} + +export const GIF_SIZE_PRESETS: Record<GifSizePreset, { maxHeight: number; label: string }> = { + medium: { maxHeight: 720, label: "Medium (720p)" }, + large: { maxHeight: 1080, label: "Large (1080p)" }, + original: { maxHeight: Infinity, label: "Original" }, +}; + +export const GIF_FRAME_RATES: { value: GifFrameRate; label: string }[] = [ + { value: 15, label: "15 FPS - Balanced" }, + { value: 20, label: "20 FPS - Smooth" }, + { value: 25, label: "25 FPS - Very smooth" }, + { value: 30, label: "30 FPS - Maximum" }, +]; + +// Valid frame rates for validation +export const VALID_GIF_FRAME_RATES: readonly GifFrameRate[] = [15, 20, 25, 30] as const; + +export function isValidGifFrameRate(rate: number): rate is GifFrameRate { + return VALID_GIF_FRAME_RATES.includes(rate as GifFrameRate); } diff --git a/src/lib/exporter/videoColorSpace.test.ts b/src/lib/exporter/videoColorSpace.test.ts new file mode 100644 index 000000000..a19af92b6 --- /dev/null +++ b/src/lib/exporter/videoColorSpace.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from "vitest"; +import { ENCODED_H264_COLOR_SPACE_FALLBACK, EXPORT_CANVAS_COLOR_SPACE } from "./videoColorSpace"; + +describe("export colour metadata", () => { + it("does not confuse full-range RGB input with encoded YUV output", () => { + expect(EXPORT_CANVAS_COLOR_SPACE).toMatchObject({ matrix: "rgb", fullRange: true }); + expect(ENCODED_H264_COLOR_SPACE_FALLBACK).toMatchObject({ + matrix: "bt709", + transfer: "bt709", + fullRange: false, + }); + }); +}); diff --git a/src/lib/exporter/videoColorSpace.ts b/src/lib/exporter/videoColorSpace.ts new file mode 100644 index 000000000..69df56bc4 --- /dev/null +++ b/src/lib/exporter/videoColorSpace.ts @@ -0,0 +1,18 @@ +/** The renderer composites into an sRGB canvas, whose pixels are full-range RGB. */ +export const EXPORT_CANVAS_COLOR_SPACE = { + primaries: "bt709", + transfer: "iec61966-2-1", + matrix: "rgb", + fullRange: true, +} as const satisfies VideoColorSpaceInit; + +/** + * H.264 encoders normally convert the canvas to video-range YUV. Use this only + * when the encoder does not report its own output colour metadata. + */ +export const ENCODED_H264_COLOR_SPACE_FALLBACK = { + primaries: "bt709", + transfer: "bt709", + matrix: "bt709", + fullRange: false, +} as const satisfies VideoColorSpaceInit; diff --git a/src/lib/exporter/videoDecoder.ts b/src/lib/exporter/videoDecoder.ts index 4f3c590a4..4ed1157ac 100644 --- a/src/lib/exporter/videoDecoder.ts +++ b/src/lib/exporter/videoDecoder.ts @@ -1,86 +1,57 @@ export interface DecodedVideoInfo { - width: number; - height: number; - duration: number; // in seconds - frameRate: number; - codec: string; + width: number; + height: number; + duration: number; // in seconds + frameRate: number; + codec: string; } export class VideoFileDecoder { - private decoder: VideoDecoder | null = null; - private info: DecodedVideoInfo | null = null; - private videoElement: HTMLVideoElement | null = null; - - async loadVideo(videoUrl: string): Promise<DecodedVideoInfo> { - this.videoElement = document.createElement('video'); - this.videoElement.src = videoUrl; - this.videoElement.preload = 'metadata'; - - return new Promise((resolve, reject) => { - this.videoElement!.addEventListener('loadedmetadata', () => { - const video = this.videoElement!; - - this.info = { - width: video.videoWidth, - height: video.videoHeight, - duration: video.duration, - frameRate: 60, - codec: 'avc1.640033', - }; - - resolve(this.info); - }); - - this.videoElement!.addEventListener('error', (e) => { - reject(new Error(`Failed to load video: ${e}`)); - }); - }); - } - - /** - * Get video element for seeking - */ - getVideoElement(): HTMLVideoElement | null { - return this.videoElement; - } - - /** - * Seek to a specific time and wait for the frame to be ready - */ - async seekToTime(timeInSeconds: number): Promise<void> { - if (!this.videoElement) { - throw new Error('Video not loaded'); - } - - return new Promise((resolve) => { - const video = this.videoElement!; - - const onSeeked = () => { - video.removeEventListener('seeked', onSeeked); - resolve(); - }; - - video.addEventListener('seeked', onSeeked); - video.currentTime = timeInSeconds; - }); - } - - getInfo(): DecodedVideoInfo | null { - return this.info; - } - - destroy(): void { - if (this.videoElement) { - this.videoElement.pause(); - this.videoElement.src = ''; - this.videoElement = null; - } - - if (this.decoder) { - if (this.decoder.state !== 'closed') { - this.decoder.close(); - } - this.decoder = null; - } - } + private info: DecodedVideoInfo | null = null; + private videoElement: HTMLVideoElement | null = null; + + async loadVideo(videoUrl: string): Promise<DecodedVideoInfo> { + this.videoElement = document.createElement("video"); + this.videoElement.src = videoUrl; + this.videoElement.preload = "metadata"; + + return new Promise((resolve, reject) => { + this.videoElement!.addEventListener("loadedmetadata", () => { + const video = this.videoElement!; + + this.info = { + width: video.videoWidth, + height: video.videoHeight, + duration: video.duration, + frameRate: 60, + codec: "avc1.640033", + }; + + resolve(this.info); + }); + + this.videoElement!.addEventListener("error", (e) => { + reject(new Error(`Failed to load video: ${e}`)); + }); + }); + } + + /** + * Get video element for seeking + */ + getVideoElement(): HTMLVideoElement | null { + return this.videoElement; + } + + getInfo(): DecodedVideoInfo | null { + return this.info; + } + + destroy(): void { + if (this.videoElement) { + this.videoElement.pause(); + this.videoElement.src = ""; + this.videoElement = null; + } + } } diff --git a/src/lib/exporter/videoExporter.ts b/src/lib/exporter/videoExporter.ts index c692fc330..9349627c5 100644 --- a/src/lib/exporter/videoExporter.ts +++ b/src/lib/exporter/videoExporter.ts @@ -1,260 +1,1439 @@ -import type { ExportConfig, ExportProgress, ExportResult } from './types'; -import { VideoFileDecoder } from './videoDecoder'; -import { FrameRenderer } from './frameRenderer'; -import { VideoMuxer } from './muxer'; -import type { ZoomRegion, CropRegion } from '@/components/video-editor/types'; +import type { + AnnotationRegion, + AudioRegion, + AutoCaptionSettings, + CaptionCue, + ClipRegion, + CropRegion, + CursorStyle, + CursorTelemetryPoint, + Padding, + SourceAudioTrackSettings, + SpeedRegion, + TrimRegion, + WebcamOverlaySettings, + ZoomMotionBlurTuning, + ZoomRegion, + ZoomTransitionEasing, +} from "@/components/video-editor/types"; +import { getEffectiveVideoStreamDurationSeconds } from "@/lib/mediaTiming"; +import { AudioProcessor, isAacAudioEncodingSupported } from "./audioEncoder"; +import { buildEditedTrackSourceSegments, classifyEditedTrackStrategy } from "./editedTrackStrategy"; +import { + advanceFinalizationProgress, + type FinalizationProgressWatchdog, + type FinalizationTimeoutWorkload, + INITIAL_FINALIZATION_PROGRESS_STATE, + withFinalizationTimeout, +} from "./finalizationTimeout"; +import { FrameRenderer } from "./frameRenderer"; +import { getLocalFilePath } from "./localMediaSource"; +import type { SupportedMp4EncoderPath } from "./mp4Support"; +import { VideoMuxer } from "./muxer"; +import { type DecodedVideoInfo, StreamingVideoDecoder } from "./streamingDecoder"; +import type { + ExportConfig, + ExportFinalizationStageMetrics, + ExportMetrics, + ExportProgress, + ExportResult, +} from "./types"; +import { ENCODED_H264_COLOR_SPACE_FALLBACK, EXPORT_CANVAS_COLOR_SPACE } from "./videoColorSpace"; + +const DEFAULT_MAX_ENCODE_QUEUE = 240; +const PROGRESS_SAMPLE_WINDOW_MS = 1_000; interface VideoExporterConfig extends ExportConfig { - videoUrl: string; - wallpaper: string; - zoomRegions: ZoomRegion[]; - showShadow: boolean; - showBlur: boolean; - cropRegion: CropRegion; - onProgress?: (progress: ExportProgress) => void; + videoUrl: string; + wallpaper: string; + zoomRegions: ZoomRegion[]; + trimRegions?: TrimRegion[]; + speedRegions?: SpeedRegion[]; + showShadow: boolean; + shadowIntensity: number; + backgroundBlur: number; + zoomMotionBlur?: number; + zoomMotionBlurTuning?: ZoomMotionBlurTuning; + zoomTemporalMotionBlur?: number; + zoomMotionBlurSampleCount?: number | null; + zoomMotionBlurShutterFraction?: number | null; + connectZooms?: boolean; + zoomInDurationMs?: number; + zoomInOverlapMs?: number; + zoomOutDurationMs?: number; + connectedZoomGapMs?: number; + connectedZoomDurationMs?: number; + zoomInEasing?: ZoomTransitionEasing; + zoomOutEasing?: ZoomTransitionEasing; + connectedZoomEasing?: ZoomTransitionEasing; + borderRadius?: number; + padding?: Padding | number; + videoPadding?: number; + cropRegion: CropRegion; + webcam?: WebcamOverlaySettings; + webcamUrl?: string | null; + annotationRegions?: AnnotationRegion[]; + autoCaptions?: CaptionCue[]; + autoCaptionSettings?: AutoCaptionSettings; + cursorTelemetry?: CursorTelemetryPoint[]; + showCursor?: boolean; + cursorStyle?: CursorStyle; + cursorSize?: number; + cursorSmoothing?: number; + cursorSpringStiffnessMultiplier?: number; + cursorSpringDampingMultiplier?: number; + cursorSpringMassMultiplier?: number; + cameraSpringStiffnessMultiplier?: number; + cameraSpringDampingMultiplier?: number; + cameraSpringMassMultiplier?: number; + cursorMotionBlur?: number; + cursorClickBounce?: number; + cursorClickBounceDuration?: number; + cursorSway?: number; + zoomSmoothness?: number; + audioRegions?: AudioRegion[]; + clipRegions?: ClipRegion[]; + sourceAudioFallbackPaths?: string[]; + sourceAudioFallbackStartDelayMsByPath?: Record<string, number>; + sourceAudioTrackSettings?: SourceAudioTrackSettings; + previewWidth?: number; + previewHeight?: number; + onProgress?: (progress: ExportProgress) => void; + preferredEncoderPath?: SupportedMp4EncoderPath | null; +} + +type NativeAudioPlan = + | { + audioMode: "none"; + } + | { + audioMode: "copy-source" | "trim-source"; + audioSourcePath: string; + trimSegments?: Array<{ startMs: number; endMs: number }>; + } + | { + audioMode: "edited-track"; + strategy: "offline-render-fallback"; + } + | { + audioMode: "edited-track"; + strategy: "filtergraph-fast-path"; + audioSourcePath: string; + audioSourceSampleRate: number; + editedTrackSegments: Array<{ startMs: number; endMs: number; speed: number }>; + }; + +const FILTERGRAPH_FALLBACK_AUDIO_SAMPLE_RATE = 48_000; + +function hasNonDefaultSourceTrackSettings(sourceAudioTrackSettings?: SourceAudioTrackSettings) { + if (!sourceAudioTrackSettings) { + return false; + } + return Object.values(sourceAudioTrackSettings).some( + (settings) => + Math.abs((settings?.volume ?? 1) - 1) > 0.0005 || Boolean(settings?.normalize), + ); } export class VideoExporter { - private config: VideoExporterConfig; - private decoder: VideoFileDecoder | null = null; - private renderer: FrameRenderer | null = null; - private encoder: VideoEncoder | null = null; - private muxer: VideoMuxer | null = null; - private cancelled = false; - private encodedChunks: EncodedVideoChunk[] = []; - private encodeQueue = 0; - private readonly MAX_ENCODE_QUEUE = 60; - private videoDescription: Uint8Array | undefined; - - constructor(config: VideoExporterConfig) { - this.config = config; - } - - async export(): Promise<ExportResult> { - try { - this.cleanup(); - this.cancelled = false; - - // Initialize decoder and load video - this.decoder = new VideoFileDecoder(); - const videoInfo = await this.decoder.loadVideo(this.config.videoUrl); - - // Initialize frame renderer - this.renderer = new FrameRenderer({ - width: this.config.width, - height: this.config.height, - wallpaper: this.config.wallpaper, - zoomRegions: this.config.zoomRegions, - showShadow: this.config.showShadow, - showBlur: this.config.showBlur, - cropRegion: this.config.cropRegion, - videoWidth: videoInfo.width, - videoHeight: videoInfo.height, - }); - await this.renderer.initialize(); - - // Initialize video encoder - const totalFrames = Math.ceil(videoInfo.duration * this.config.frameRate); - await this.initializeEncoder(); - - // Initialize muxer - this.muxer = new VideoMuxer(this.config, false); - await this.muxer.initialize(); - - // Get the video element for frame extraction - const videoElement = this.decoder.getVideoElement(); - if (!videoElement) { - throw new Error('Video element not available'); - } - - // Process frames with optimized seeking - const frameDuration = 1_000_000 / this.config.frameRate; // in microseconds - let frameIndex = 0; - const timeStep = 1 / this.config.frameRate; - - // Pre-load first frame - videoElement.currentTime = 0; - await new Promise(resolve => { - const onSeeked = () => { - videoElement.removeEventListener('seeked', onSeeked); - resolve(null); - }; - videoElement.addEventListener('seeked', onSeeked); - }); - - while (frameIndex < totalFrames && !this.cancelled) { - const timestamp = frameIndex * frameDuration; - const videoTime = frameIndex * timeStep; - // Seek to frame (only seek if not already there) - if (Math.abs(videoElement.currentTime - videoTime) > 0.001) { - videoElement.currentTime = videoTime; - await Promise.race([ - new Promise(resolve => { - const onSeeked = () => { - videoElement.removeEventListener('seeked', onSeeked); - // Wait for video to render the frame - videoElement.requestVideoFrameCallback(() => resolve(null)); - }; - videoElement.addEventListener('seeked', onSeeked, { once: true }); - }), - new Promise(resolve => setTimeout(resolve, 200)) // higher this number, slower the export, but better capture/ no frame drops - ]); - } - - // Create a VideoFrame from the video element (on GPU!) - const videoFrame = new VideoFrame(videoElement, { - timestamp, - }); - - // Render the frame with all effects - await this.renderer!.renderFrame(videoFrame, timestamp); - - videoFrame.close(); - - while (this.encodeQueue >= this.MAX_ENCODE_QUEUE && !this.cancelled) { - await new Promise(resolve => setTimeout(resolve, 1)); - } - - if (this.cancelled) break; - - const canvas = this.renderer!.getCanvas(); - const exportFrame = new VideoFrame(canvas, { - timestamp, - duration: frameDuration, - }); - - if (this.encoder && this.encoder.state === 'configured') { - this.encodeQueue++; - this.encoder.encode(exportFrame, { keyFrame: frameIndex % 150 === 0 }); - } - exportFrame.close(); - - frameIndex++; - - if (this.config.onProgress) { - this.config.onProgress({ - currentFrame: frameIndex, - totalFrames, - percentage: (frameIndex / totalFrames) * 100, - estimatedTimeRemaining: 0, - }); - } - } - - if (this.cancelled) { - return { success: false, error: 'Export cancelled' }; - } - - // Finalize encoding - if (this.encoder && this.encoder.state === 'configured') { - await this.encoder.flush(); - } - - // Add all chunks to muxer with metadata - for (let i = 0; i < this.encodedChunks.length; i++) { - const chunk = this.encodedChunks[i]; - const meta: EncodedVideoChunkMetadata = {}; - - // Add decoder config for the first chunk - if (i === 0 && this.videoDescription) { - meta.decoderConfig = { - codec: this.config.codec || 'avc1.640033', - codedWidth: this.config.width, - codedHeight: this.config.height, - description: this.videoDescription, - }; - } - - this.muxer!.addVideoChunk(chunk, meta); - } - - // Finalize muxer and get output blob - const blob = this.muxer!.finalize(); - - return { success: true, blob }; - } catch (error) { - console.error('Export error:', error); - return { - success: false, - error: error instanceof Error ? error.message : String(error), - }; - } finally { - this.cleanup(); - } - } - - private async initializeEncoder(): Promise<void> { - this.encodedChunks = []; - this.encodeQueue = 0; - let videoDescription: Uint8Array | undefined; - - this.encoder = new VideoEncoder({ - output: (chunk, meta) => { - if (meta?.decoderConfig?.description && !videoDescription) { - const desc = meta.decoderConfig.description; - videoDescription = new Uint8Array(desc instanceof ArrayBuffer ? desc : (desc as any)); - this.videoDescription = videoDescription; - } - this.encodedChunks.push(chunk); - this.encodeQueue--; - }, - error: (error) => { - console.error('VideoEncoder error:', error); - }, - }); - - const codec = this.config.codec || 'avc1.640033'; - - this.encoder.configure({ - codec, - width: this.config.width, - height: this.config.height, - bitrate: this.config.bitrate, - framerate: this.config.frameRate, - latencyMode: 'realtime', - bitrateMode: 'variable', - hardwareAcceleration: 'prefer-hardware', - } as VideoEncoderConfig); - } - - cancel(): void { - this.cancelled = true; - this.cleanup(); - } - - private cleanup(): void { - if (this.encoder) { - try { - if (this.encoder.state === 'configured') { - this.encoder.close(); - } - } catch (e) { - console.warn('Error closing encoder:', e); - } - this.encoder = null; - } - - if (this.decoder) { - try { - this.decoder.destroy(); - } catch (e) { - console.warn('Error destroying decoder:', e); - } - this.decoder = null; - } - - if (this.renderer) { - try { - this.renderer.destroy(); - } catch (e) { - console.warn('Error destroying renderer:', e); - } - this.renderer = null; - } - - this.muxer = null; - this.encodedChunks = []; - this.encodeQueue = 0; - this.videoDescription = undefined; - } + private config: VideoExporterConfig; + private streamingDecoder: StreamingVideoDecoder | null = null; + private renderer: FrameRenderer | null = null; + private encoder: VideoEncoder | null = null; + private muxer: VideoMuxer | null = null; + private audioProcessor: AudioProcessor | null = null; + private cancelled = false; + private encodeQueue = 0; + private videoDescription: Uint8Array | undefined; + private videoColorSpace: VideoColorSpaceInit | undefined; + private pendingMuxing: Promise<void> = Promise.resolve(); + private chunkCount = 0; + private effectiveDurationSec = 0; + private exportStartTimeMs = 0; + private progressSampleStartTimeMs = 0; + private progressSampleStartFrame = 0; + private encoderError: Error | null = null; + private nativeExportSessionId: string | null = null; + private nativeH264Encoder: VideoEncoder | null = null; + private nativePendingWrite: Promise<void> = Promise.resolve(); + private nativeWritePromises = new Set<Promise<void>>(); + private nativeWriteError: Error | null = null; + private maxNativeWriteInFlight = 1; + private nativeEncoderError: Error | null = null; + private activeFinalizationProgressWatchdog: FinalizationProgressWatchdog | null = null; + private lastFinalizationRenderProgress = INITIAL_FINALIZATION_PROGRESS_STATE.lastRenderProgress; + private lastFinalizationAudioProgress = INITIAL_FINALIZATION_PROGRESS_STATE.lastAudioProgress; + private finalizationTimeMs = 0; + private finalizationStageMs: ExportFinalizationStageMetrics = {}; + private processedFrameCount = 0; + + constructor(config: VideoExporterConfig) { + this.config = config; + } + + async export(): Promise<ExportResult> { + try { + this.cleanup(); + this.cancelled = false; + this.encoderError = null; + this.nativeEncoderError = null; + this.nativePendingWrite = Promise.resolve(); + this.nativeWritePromises = new Set(); + this.nativeWriteError = null; + this.maxNativeWriteInFlight = Math.max( + 1, + Math.floor(this.config.maxInFlightNativeWrites ?? 1), + ); + this.exportStartTimeMs = this.getNowMs(); + this.progressSampleStartTimeMs = this.exportStartTimeMs; + this.progressSampleStartFrame = 0; + + // Initialize streaming decoder and load video metadata + this.streamingDecoder = new StreamingVideoDecoder({ + maxDecodeQueue: this.config.maxDecodeQueue, + maxPendingFrames: this.config.maxPendingFrames, + }); + const videoInfo = await this.streamingDecoder.loadMetadata(this.config.videoUrl); + const shouldUseExperimentalNativeExport = this.shouldUseExperimentalNativeExport(); + const audioPlan = this.buildNativeAudioPlan(videoInfo); + const nativeAudioPlan = shouldUseExperimentalNativeExport ? audioPlan : null; + let useNativeEncoder = shouldUseExperimentalNativeExport + ? await this.tryStartNativeVideoExport() + : false; + const shouldUsePitchPreservingFfmpegAudio = + audioPlan.audioMode === "edited-track" && + audioPlan.strategy === "filtergraph-fast-path"; + const shouldUseFfmpegAudioFallback = + !useNativeEncoder && + audioPlan.audioMode !== "none" && + (shouldUsePitchPreservingFfmpegAudio || !(await isAacAudioEncodingSupported())); + + if (!useNativeEncoder) { + await this.initializeEncoder(); + } + + // Initialize frame renderer + this.renderer = new FrameRenderer({ + width: this.config.width, + height: this.config.height, + preferredRenderBackend: undefined, + wallpaper: this.config.wallpaper, + zoomRegions: this.config.zoomRegions, + showShadow: this.config.showShadow, + shadowIntensity: this.config.shadowIntensity, + backgroundBlur: this.config.backgroundBlur, + zoomMotionBlur: this.config.zoomMotionBlur, + zoomMotionBlurTuning: this.config.zoomMotionBlurTuning, + zoomTemporalMotionBlur: this.config.zoomTemporalMotionBlur, + zoomMotionBlurSampleCount: this.config.zoomMotionBlurSampleCount, + zoomMotionBlurShutterFraction: this.config.zoomMotionBlurShutterFraction, + connectZooms: this.config.connectZooms, + zoomInDurationMs: this.config.zoomInDurationMs, + zoomInOverlapMs: this.config.zoomInOverlapMs, + zoomOutDurationMs: this.config.zoomOutDurationMs, + connectedZoomGapMs: this.config.connectedZoomGapMs, + connectedZoomDurationMs: this.config.connectedZoomDurationMs, + zoomInEasing: this.config.zoomInEasing, + zoomOutEasing: this.config.zoomOutEasing, + connectedZoomEasing: this.config.connectedZoomEasing, + borderRadius: this.config.borderRadius, + padding: this.config.padding, + cropRegion: this.config.cropRegion, + webcam: this.config.webcam, + webcamUrl: this.config.webcamUrl, + videoWidth: videoInfo.width, + videoHeight: videoInfo.height, + annotationRegions: this.config.annotationRegions, + autoCaptions: this.config.autoCaptions, + autoCaptionSettings: this.config.autoCaptionSettings, + speedRegions: this.config.speedRegions, + previewWidth: this.config.previewWidth, + previewHeight: this.config.previewHeight, + cursorTelemetry: this.config.cursorTelemetry, + showCursor: this.config.showCursor, + cursorStyle: this.config.cursorStyle, + cursorSize: this.config.cursorSize, + cursorSmoothing: this.config.cursorSmoothing, + cursorSpringStiffnessMultiplier: this.config.cursorSpringStiffnessMultiplier, + cursorSpringDampingMultiplier: this.config.cursorSpringDampingMultiplier, + cursorSpringMassMultiplier: this.config.cursorSpringMassMultiplier, + cameraSpringStiffnessMultiplier: this.config.cameraSpringStiffnessMultiplier, + cameraSpringDampingMultiplier: this.config.cameraSpringDampingMultiplier, + cameraSpringMassMultiplier: this.config.cameraSpringMassMultiplier, + cursorMotionBlur: this.config.cursorMotionBlur, + cursorClickBounce: this.config.cursorClickBounce, + cursorClickBounceDuration: this.config.cursorClickBounceDuration, + cursorSway: this.config.cursorSway, + zoomSmoothness: this.config.zoomSmoothness, + }); + await this.renderer.initialize(); + + const hasAudioRegions = (this.config.audioRegions ?? []).length > 0; + const hasSourceAudioFallback = (this.config.sourceAudioFallbackPaths ?? []).length > 0; + const hasAudio = videoInfo.hasAudio || hasAudioRegions || hasSourceAudioFallback; + + if (!useNativeEncoder) { + this.muxer = new VideoMuxer(this.config, hasAudio && !shouldUseFfmpegAudioFallback); + await this.muxer.initialize(); + } + + // Calculate effective duration and frame count (excluding trim regions) + const effectiveDuration = this.streamingDecoder.getEffectiveDuration( + this.config.trimRegions, + this.config.speedRegions, + ); + this.effectiveDurationSec = effectiveDuration; + const totalFrames = Math.ceil(effectiveDuration * this.config.frameRate); + + console.log("[VideoExporter] Original duration:", videoInfo.duration, "s"); + console.log("[VideoExporter] Effective duration:", effectiveDuration, "s"); + console.log("[VideoExporter] Total frames to export:", totalFrames); + console.log("[VideoExporter] Using streaming decode (web-demuxer + VideoDecoder)"); + console.log( + `[VideoExporter] Using ${useNativeEncoder ? "native ffmpeg" : "WebCodecs"} encode path`, + ); + + const frameDuration = 1_000_000 / this.config.frameRate; // in microseconds + let frameIndex = 0; + + // Stream decode and process frames — no seeking! + await this.streamingDecoder.decodeAll( + this.config.frameRate, + this.config.trimRegions, + this.config.speedRegions, + async (videoFrame, _exportTimestampUs, sourceTimestampMs, cursorTimestampMs) => { + if (this.cancelled) { + return; + } + + const timestamp = frameIndex * frameDuration; + const sourceTimestampUs = sourceTimestampMs * 1000; + const cursorTimestampUs = cursorTimestampMs * 1000; + await this.renderer!.renderFrame( + videoFrame, + sourceTimestampUs, + cursorTimestampUs, + frameDuration, + timestamp, + ); + + if (useNativeEncoder) { + await this.encodeRenderedFrameNative(timestamp, frameDuration, frameIndex); + } else { + await this.encodeRenderedFrame(timestamp, frameDuration, frameIndex); + } + frameIndex++; + this.processedFrameCount = frameIndex; + this.reportProgress(frameIndex, totalFrames); + }, + ); + + if (this.cancelled) { + const encoderError = this.encoderError as Error | null; + if (encoderError) { + return { + success: false, + error: encoderError.message, + metrics: this.buildExportMetrics(), + }; + } + + return { + success: false, + error: "Export cancelled", + metrics: this.buildExportMetrics(), + }; + } + + this.reportFinalizingProgress(totalFrames, 96); + const finalizationStartedAt = this.getNowMs(); + + if (useNativeEncoder && nativeAudioPlan) { + if (this.nativeH264Encoder) { + await this.measureFinalizationStage("nativeEncoderFlushMs", async () => { + await this.nativeH264Encoder!.flush(); + await this.awaitPendingNativeWrites(); + if (this.nativeEncoderError) { + throw this.nativeEncoderError; + } + }); + this.nativeH264Encoder.close(); + this.nativeH264Encoder = null; + } + this.reportFinalizingProgress(totalFrames, 99, 0); + const result = await this.finishNativeVideoExport(nativeAudioPlan, totalFrames); + this.finalizationTimeMs = this.getNowMs() - finalizationStartedAt; + return { + ...result, + metrics: this.buildExportMetrics(), + }; + } + + // Finalize encoding + if (this.encoder && this.encoder.state === "configured") { + this.reportFinalizingProgress(totalFrames, 97); + await this.measureFinalizationStage("encoderFlushMs", async () => { + await this.awaitWithFinalizationTimeout(this.encoder!.flush(), "encoder flush"); + }); + } + + // Wait for queued muxing operations to complete + this.reportFinalizingProgress(totalFrames, 98); + await this.measureFinalizationStage("queuedMuxingMs", async () => { + await this.awaitWithFinalizationTimeout( + this.pendingMuxing, + "muxing queued video chunks", + ); + }); + + // Surface muxing errors before proceeding with finalization + if (this.encoderError) { + throw this.encoderError; + } + + if (hasAudio && !shouldUseFfmpegAudioFallback && !this.cancelled) { + const demuxer = this.streamingDecoder.getDemuxer(); + if (demuxer || hasAudioRegions || hasSourceAudioFallback) { + this.audioProcessor = new AudioProcessor(); + this.audioProcessor.setOnProgress((progress) => { + this.reportFinalizingProgress(totalFrames, 99, progress); + }); + this.reportFinalizingProgress(totalFrames, 99, 0); + await this.measureFinalizationStage("audioProcessingMs", async () => { + await this.awaitWithFinalizationTimeout( + this.audioProcessor!.process( + demuxer, + this.muxer!, + this.config.videoUrl, + this.config.trimRegions, + this.config.speedRegions, + undefined, + this.config.audioRegions, + this.config.sourceAudioFallbackPaths, + this.config.sourceAudioFallbackStartDelayMsByPath, + this.config.sourceAudioTrackSettings, + ), + "audio processing", + "audio", + true, + ); + }); + } + } + + // Finalize muxer and get output (temp path for streaming, blob for legacy) + this.reportFinalizingProgress(totalFrames, 99); + const muxerResult = await this.measureFinalizationStage("muxerFinalizeMs", async () => + this.awaitWithFinalizationTimeout( + this.muxer!.finalize(), + "muxer finalization", + hasAudio && !shouldUseFfmpegAudioFallback ? "audio" : "default", + ), + ); + + if (shouldUseFfmpegAudioFallback) { + console.warn( + shouldUsePitchPreservingFfmpegAudio + ? "[VideoExporter] Using FFmpeg audio muxing for pitch-preserving speed edits." + : "[VideoExporter] Browser AAC encoding is unavailable; falling back to FFmpeg audio muxing.", + ); + const result = await this.finalizeExportWithFfmpegAudio( + muxerResult, + audioPlan, + totalFrames, + ); + this.finalizationTimeMs = this.getNowMs() - finalizationStartedAt; + return { + ...result, + metrics: this.buildExportMetrics(), + }; + } + + this.finalizationTimeMs = this.getNowMs() - finalizationStartedAt; + if (muxerResult.mode === "stream") { + return { + success: true, + tempFilePath: muxerResult.tempFilePath, + metrics: this.buildExportMetrics(), + }; + } + return { success: true, blob: muxerResult.blob, metrics: this.buildExportMetrics() }; + } catch (error) { + if (this.cancelled && !this.encoderError) { + return { + success: false, + error: "Export cancelled", + metrics: this.buildExportMetrics(), + }; + } + + const resolvedError = this.encoderError ?? error; + console.error("Export error:", error); + return { + success: false, + error: + resolvedError instanceof Error ? resolvedError.message : String(resolvedError), + metrics: this.buildExportMetrics(), + }; + } finally { + this.cleanup(); + } + } + + private shouldUseExperimentalNativeExport(): boolean { + return ( + typeof window !== "undefined" && + typeof VideoEncoder !== "undefined" && + typeof VideoEncoder.isConfigSupported === "function" && + typeof window.electronAPI?.nativeVideoExportStart === "function" && + typeof window.electronAPI?.nativeVideoExportWriteFrame === "function" && + typeof window.electronAPI?.nativeVideoExportFinish === "function" && + typeof window.electronAPI?.nativeVideoExportCancel === "function" + ); + } + + private async awaitWithFinalizationTimeout<T>( + promise: Promise<T>, + stage: string, + workload: FinalizationTimeoutWorkload = "default", + progressAware = false, + ): Promise<T> { + return withFinalizationTimeout({ + promise, + stage, + effectiveDurationSec: this.effectiveDurationSec, + workload, + progressAware, + onWatchdogChanged: (watchdog) => { + this.activeFinalizationProgressWatchdog = watchdog; + }, + }); + } + + private getNativeVideoSourcePath(): string | null { + return this.config.videoUrl ? getLocalFilePath(this.config.videoUrl) : null; + } + + private buildNativeTrimSegments(durationMs: number): Array<{ startMs: number; endMs: number }> { + const trimRegions = [...(this.config.trimRegions ?? [])].sort( + (a, b) => a.startMs - b.startMs, + ); + if (trimRegions.length === 0) { + return [{ startMs: 0, endMs: Math.max(0, durationMs) }]; + } + + const segments: Array<{ startMs: number; endMs: number }> = []; + let cursorMs = 0; + + for (const region of trimRegions) { + const startMs = Math.max(0, Math.min(region.startMs, durationMs)); + const endMs = Math.max(startMs, Math.min(region.endMs, durationMs)); + if (startMs > cursorMs) { + segments.push({ startMs: cursorMs, endMs: startMs }); + } + cursorMs = Math.max(cursorMs, endMs); + } + + if (cursorMs < durationMs) { + segments.push({ startMs: cursorMs, endMs: durationMs }); + } + + return segments.filter((segment) => segment.endMs - segment.startMs > 0.5); + } + + private buildNativeAudioPlan(videoInfo: DecodedVideoInfo): NativeAudioPlan { + const speedRegions = this.config.speedRegions ?? []; + const audioRegions = this.config.audioRegions ?? []; + const sourceAudioFallbackPaths = (this.config.sourceAudioFallbackPaths ?? []).filter( + (audioPath) => typeof audioPath === "string" && audioPath.trim().length > 0, + ); + const hasTimedSourceAudioFallback = sourceAudioFallbackPaths.some( + (audioPath) => + (this.config.sourceAudioFallbackStartDelayMsByPath?.[audioPath] ?? 0) > 0, + ); + const localVideoSourcePath = this.getNativeVideoSourcePath(); + const primaryAudioSourcePath = + (videoInfo.hasAudio ? localVideoSourcePath : null) ?? + sourceAudioFallbackPaths[0] ?? + null; + const usesEmbeddedPrimaryAudio = + Boolean(videoInfo.hasAudio) && primaryAudioSourcePath === localVideoSourcePath; + const primaryAudioSourceSampleRate = usesEmbeddedPrimaryAudio + ? videoInfo.audioSampleRate + : FILTERGRAPH_FALLBACK_AUDIO_SAMPLE_RATE; + + if ( + !videoInfo.hasAudio && + sourceAudioFallbackPaths.length === 0 && + audioRegions.length === 0 + ) { + return { audioMode: "none" }; + } + + if ( + speedRegions.length > 0 || + audioRegions.length > 0 || + sourceAudioFallbackPaths.length > 1 || + hasTimedSourceAudioFallback || + hasNonDefaultSourceTrackSettings(this.config.sourceAudioTrackSettings) || + (this.config.clipRegions ?? []).some((clip) => Boolean(clip.muted)) + ) { + const sourceDurationMs = Math.max( + 0, + Math.round( + getEffectiveVideoStreamDurationSeconds({ + duration: videoInfo.duration, + streamDuration: videoInfo.streamDuration, + }) * 1000, + ), + ); + const trimRegions = this.config.trimRegions ?? []; + const canUsePrimaryAudioFiltergraph = + Boolean(primaryAudioSourcePath) && + !hasTimedSourceAudioFallback && + (usesEmbeddedPrimaryAudio || + sourceAudioFallbackPaths.includes(primaryAudioSourcePath ?? "")) && + typeof primaryAudioSourceSampleRate === "number" && + Number.isFinite(primaryAudioSourceSampleRate) && + primaryAudioSourceSampleRate > 0; + const strategy = canUsePrimaryAudioFiltergraph + ? classifyEditedTrackStrategy({ + primaryAudioSourcePath, + sourceDurationMs, + trimRegions, + speedRegions, + audioRegions, + sourceAudioFallbackPaths, + }) + : "offline-render-fallback"; + + if (strategy === "filtergraph-fast-path") { + const audioSourcePath = primaryAudioSourcePath; + const audioSourceSampleRate = primaryAudioSourceSampleRate; + const editedTrackSegments = buildEditedTrackSourceSegments( + sourceDurationMs, + trimRegions, + speedRegions, + ); + if ( + audioSourcePath && + typeof audioSourceSampleRate === "number" && + editedTrackSegments.length > 0 + ) { + return { + audioMode: "edited-track", + strategy, + audioSourcePath, + audioSourceSampleRate, + editedTrackSegments, + }; + } + } + + return { + audioMode: "edited-track", + strategy: "offline-render-fallback", + }; + } + + if (!primaryAudioSourcePath) { + return { + audioMode: "edited-track", + strategy: "offline-render-fallback", + }; + } + + if ((this.config.trimRegions ?? []).length > 0) { + const sourceDurationMs = Math.max( + 0, + Math.round( + getEffectiveVideoStreamDurationSeconds({ + duration: videoInfo.duration, + streamDuration: videoInfo.streamDuration, + }) * 1000, + ), + ); + const trimSegments = this.buildNativeTrimSegments(sourceDurationMs); + if (trimSegments.length === 0) { + return { audioMode: "none" }; + } + + return { + audioMode: "trim-source", + audioSourcePath: primaryAudioSourcePath, + trimSegments, + }; + } + + return { + audioMode: "copy-source", + audioSourcePath: primaryAudioSourcePath, + }; + } + + private async tryStartNativeVideoExport(): Promise<boolean> { + if (!this.shouldUseExperimentalNativeExport()) { + return false; + } + + if (this.config.width % 2 !== 0 || this.config.height % 2 !== 0) { + console.warn( + `[VideoExporter] Native export requires even output dimensions, falling back to WebCodecs (${this.config.width}x${this.config.height})`, + ); + return false; + } + + const encoderConfig: VideoEncoderConfig = { + codec: "avc1.640034", + width: this.config.width, + height: this.config.height, + bitrate: this.config.bitrate, + framerate: this.config.frameRate, + hardwareAcceleration: "prefer-hardware", + avc: { format: "annexb" }, + }; + + try { + const support = await VideoEncoder.isConfigSupported(encoderConfig); + if (!support.supported) { + console.warn( + `[VideoExporter] Native H.264 Annex B encoding is unsupported at ${this.config.width}x${this.config.height}`, + ); + return false; + } + } catch (error) { + console.warn("[VideoExporter] Native encoder support check failed:", error); + return false; + } + + const result = await window.electronAPI.nativeVideoExportStart({ + width: this.config.width, + height: this.config.height, + frameRate: this.config.frameRate, + bitrate: this.config.bitrate, + encodingMode: this.config.encodingMode ?? "balanced", + inputMode: "h264-stream", + }); + + if (!result.success || !result.sessionId) { + console.warn("[VideoExporter] Native export unavailable", result.error); + return false; + } + + this.nativeExportSessionId = result.sessionId; + this.nativePendingWrite = Promise.resolve(); + this.nativeWritePromises = new Set(); + this.nativeWriteError = null; + this.maxNativeWriteInFlight = Math.max( + 1, + Math.floor(this.config.maxInFlightNativeWrites ?? 1), + ); + + // Initialize the browser-side H.264 encoder (hardware-accelerated where available). + // Encoded Annex B chunks are sent over IPC and FFmpeg stream-copies them into MP4. + const sessionId = result.sessionId; + const encoder = new VideoEncoder({ + output: (chunk) => { + if (this.cancelled || !this.nativeExportSessionId) return; + const buffer = new ArrayBuffer(chunk.byteLength); + chunk.copyTo(buffer); + const writePromise = this.nativePendingWrite + .then(async () => { + const writeResult = await window.electronAPI.nativeVideoExportWriteFrame( + sessionId, + new Uint8Array(buffer), + ); + if (!writeResult.success && !this.cancelled) { + throw new Error( + writeResult.error || + "Failed to write H.264 chunk to native encoder", + ); + } + }) + .catch((error) => { + if (!this.cancelled && !this.nativeEncoderError) { + this.nativeEncoderError = + error instanceof Error ? error : new Error(String(error)); + } + if (!this.cancelled && !this.nativeWriteError) { + this.nativeWriteError = + error instanceof Error ? error : new Error(String(error)); + } + }); + this.nativePendingWrite = writePromise; + this.trackNativeWritePromise(writePromise); + }, + error: (e) => { + this.nativeEncoderError = e; + }, + }); + + try { + encoder.configure(encoderConfig); + } catch (error) { + this.nativeEncoderError = error instanceof Error ? error : new Error(String(error)); + try { + encoder.close(); + } catch (closeError) { + console.debug( + "[VideoExporter] Ignoring error closing native encoder after startup failure:", + closeError, + ); + } + this.nativeExportSessionId = null; + await window.electronAPI.nativeVideoExportCancel(sessionId); + return false; + } + + this.nativeH264Encoder = encoder; + return true; + } + + private async encodeRenderedFrameNative( + timestamp: number, + frameDuration: number, + frameIndex: number, + ): Promise<void> { + if (!this.nativeH264Encoder || !this.nativeExportSessionId) { + if (this.cancelled) return; + throw new Error("Native export session is not active"); + } + + if (this.nativeEncoderError) throw this.nativeEncoderError; + if (this.nativeWriteError) throw this.nativeWriteError; + + while (this.nativeWritePromises.size >= this.maxNativeWriteInFlight && !this.cancelled) { + await this.awaitOldestNativeWrite(); + if (this.nativeEncoderError) throw this.nativeEncoderError; + if (this.nativeWriteError) throw this.nativeWriteError; + } + + // Apply backpressure: don't queue too far ahead of FFmpeg's stdin pipe + while ( + this.nativeH264Encoder.encodeQueueSize >= + Math.max(1, Math.floor(this.config.maxEncodeQueue ?? DEFAULT_MAX_ENCODE_QUEUE)) + ) { + await new Promise<void>((r) => setTimeout(r, 2)); + if (this.cancelled) return; + if (this.nativeEncoderError) throw this.nativeEncoderError; + if (this.nativeWriteError) throw this.nativeWriteError; + } + + const canvas = this.renderer!.getCanvas(); + // @ts-expect-error - colorSpace not in TypeScript definitions but works at runtime + const frame = new VideoFrame(canvas, { + timestamp, + duration: frameDuration, + colorSpace: EXPORT_CANVAS_COLOR_SPACE, + }); + this.nativeH264Encoder.encode(frame, { keyFrame: frameIndex % 300 === 0 }); + frame.close(); + } + + private async finishNativeVideoExport( + audioPlan: NativeAudioPlan, + totalFrames: number, + ): Promise<ExportResult> { + if (!this.nativeExportSessionId) { + return { success: false, error: "Native export session is not active" }; + } + + let editedAudioBuffer: ArrayBuffer | undefined; + let editedAudioMimeType: string | null = null; + + if ( + audioPlan.audioMode === "edited-track" && + audioPlan.strategy === "offline-render-fallback" + ) { + this.audioProcessor = new AudioProcessor(); + this.audioProcessor.setOnProgress((progress) => { + this.reportFinalizingProgress(totalFrames, 99, progress); + }); + const audioBlob = await this.measureFinalizationStage("editedAudioRenderMs", async () => + this.awaitWithFinalizationTimeout( + this.audioProcessor!.renderEditedAudioTrack( + this.config.videoUrl, + this.config.trimRegions, + this.config.speedRegions, + this.config.audioRegions, + this.config.sourceAudioFallbackPaths, + this.config.sourceAudioFallbackStartDelayMsByPath, + this.config.sourceAudioTrackSettings, + this.config.clipRegions, + ), + "native edited audio rendering", + "audio", + true, + ), + ); + editedAudioBuffer = await audioBlob.arrayBuffer(); + editedAudioMimeType = audioBlob.type || null; + } + + const sessionId = this.nativeExportSessionId; + this.nativeExportSessionId = null; + + const result = await this.measureFinalizationStage("nativeExportFinalizeMs", async () => + this.awaitWithFinalizationTimeout( + window.electronAPI.nativeVideoExportFinish(sessionId, { + audioMode: audioPlan.audioMode, + audioSourcePath: + audioPlan.audioMode === "copy-source" || + audioPlan.audioMode === "trim-source" || + (audioPlan.audioMode === "edited-track" && + audioPlan.strategy === "filtergraph-fast-path") + ? audioPlan.audioSourcePath + : null, + trimSegments: + audioPlan.audioMode === "trim-source" ? audioPlan.trimSegments : undefined, + editedTrackStrategy: + audioPlan.audioMode === "edited-track" ? audioPlan.strategy : undefined, + editedTrackSegments: + audioPlan.audioMode === "edited-track" && + audioPlan.strategy === "filtergraph-fast-path" + ? audioPlan.editedTrackSegments + : undefined, + audioSourceSampleRate: + audioPlan.audioMode === "edited-track" && + audioPlan.strategy === "filtergraph-fast-path" + ? audioPlan.audioSourceSampleRate + : undefined, + editedAudioData: editedAudioBuffer, + editedAudioMimeType, + }), + "native export finalization", + audioPlan.audioMode === "none" ? "default" : "audio", + ), + ); + if (result.metrics) { + this.finalizationStageMs.ffmpegAudioMuxBreakdown = result.metrics; + } + + if (!result.success || !result.tempPath) { + return { + success: false, + error: result.error || "Failed to finalize native video export", + metrics: this.buildExportMetrics(), + }; + } + + return { + success: true, + tempFilePath: result.tempPath, + metrics: this.buildExportMetrics(), + }; + } + + private async finalizeExportWithFfmpegAudio( + videoSource: import("./muxer").MuxerFinalizeResult, + audioPlan: NativeAudioPlan, + totalFrames: number, + ): Promise<ExportResult> { + if (typeof window === "undefined") { + return { + success: false, + error: "FFmpeg audio fallback is unavailable in this environment.", + }; + } + + let editedAudioBuffer: ArrayBuffer | undefined; + let editedAudioMimeType: string | null = null; + + if ( + audioPlan.audioMode === "edited-track" && + audioPlan.strategy === "offline-render-fallback" + ) { + this.audioProcessor = new AudioProcessor(); + this.audioProcessor.setOnProgress((progress) => { + this.reportFinalizingProgress(totalFrames, 99, progress); + }); + const audioBlob = await this.measureFinalizationStage("editedAudioRenderMs", async () => + this.awaitWithFinalizationTimeout( + this.audioProcessor!.renderEditedAudioTrack( + this.config.videoUrl, + this.config.trimRegions, + this.config.speedRegions, + this.config.audioRegions, + this.config.sourceAudioFallbackPaths, + this.config.sourceAudioFallbackStartDelayMsByPath, + this.config.sourceAudioTrackSettings, + this.config.clipRegions, + ), + "ffmpeg edited audio rendering", + "audio", + true, + ), + ); + editedAudioBuffer = await audioBlob.arrayBuffer(); + editedAudioMimeType = audioBlob.type || null; + } + + const muxOptions = { + audioMode: audioPlan.audioMode, + audioSourcePath: + audioPlan.audioMode === "copy-source" || + audioPlan.audioMode === "trim-source" || + (audioPlan.audioMode === "edited-track" && + audioPlan.strategy === "filtergraph-fast-path") + ? audioPlan.audioSourcePath + : null, + trimSegments: + audioPlan.audioMode === "trim-source" ? audioPlan.trimSegments : undefined, + editedTrackStrategy: + audioPlan.audioMode === "edited-track" ? audioPlan.strategy : undefined, + editedTrackSegments: + audioPlan.audioMode === "edited-track" && + audioPlan.strategy === "filtergraph-fast-path" + ? audioPlan.editedTrackSegments + : undefined, + audioSourceSampleRate: + audioPlan.audioMode === "edited-track" && + audioPlan.strategy === "filtergraph-fast-path" + ? audioPlan.audioSourceSampleRate + : undefined, + outputDurationSec: this.effectiveDurationSec, + editedAudioData: editedAudioBuffer, + editedAudioMimeType, + }; + + if (videoSource.mode === "stream") { + if (!window.electronAPI?.muxExportedVideoAudioFromPath) { + return { + success: false, + error: "FFmpeg audio fallback via temp path is unavailable in this environment.", + }; + } + const result = await this.measureFinalizationStage("ffmpegAudioMuxMs", async () => + this.awaitWithFinalizationTimeout( + window.electronAPI.muxExportedVideoAudioFromPath( + videoSource.tempFilePath, + muxOptions, + ), + "ffmpeg audio muxing", + "audio", + ), + ); + if (result.metrics) { + this.finalizationStageMs.ffmpegAudioMuxBreakdown = result.metrics; + } + if (!result.success || !result.tempPath) { + return { + success: false, + error: result.error || "Failed to mux exported audio with FFmpeg", + }; + } + return { success: true, tempFilePath: result.tempPath }; + } + + if (!window.electronAPI?.muxExportedVideoAudio) { + return { + success: false, + error: "FFmpeg audio fallback is unavailable in this environment.", + }; + } + const videoBuffer = await videoSource.blob.arrayBuffer(); + const result = await this.measureFinalizationStage("ffmpegAudioMuxMs", async () => + this.awaitWithFinalizationTimeout( + window.electronAPI.muxExportedVideoAudio(videoBuffer, muxOptions), + "ffmpeg audio muxing", + "audio", + ), + ); + if (result.metrics) { + this.finalizationStageMs.ffmpegAudioMuxBreakdown = result.metrics; + } + + if (!result.success || !result.tempPath) { + return { + success: false, + error: result.error || "Failed to mux exported audio with FFmpeg", + metrics: this.buildExportMetrics(), + }; + } + + // Returning a temp path (instead of buffering the muxed bytes back into + // the renderer) is what keeps >2 GiB exports off Node's fs.readFile cap. + return { + success: true, + tempFilePath: result.tempPath, + metrics: this.buildExportMetrics(), + }; + } + + private async encodeRenderedFrame( + timestamp: number, + frameDuration: number, + frameIndex: number, + ) { + const canvas = this.renderer!.getCanvas(); + + // @ts-expect-error - colorSpace not in TypeScript definitions but works at runtime + const exportFrame = new VideoFrame(canvas, { + timestamp, + duration: frameDuration, + colorSpace: EXPORT_CANVAS_COLOR_SPACE, + }); + + while ( + this.encoder && + this.encoder.encodeQueueSize >= + Math.max(1, Math.floor(this.config.maxEncodeQueue ?? DEFAULT_MAX_ENCODE_QUEUE)) && + !this.cancelled + ) { + await new Promise((resolve) => setTimeout(resolve, 5)); + } + + if (this.encoder && this.encoder.state === "configured") { + this.encodeQueue++; + this.encoder.encode(exportFrame, { keyFrame: frameIndex % 150 === 0 }); + } else { + console.warn(`[Frame ${frameIndex}] Encoder not ready! State: ${this.encoder?.state}`); + } + + exportFrame.close(); + } + + private trackNativeWritePromise(writePromise: Promise<void>): void { + this.nativeWritePromises.add(writePromise); + + void writePromise.finally(() => { + this.nativeWritePromises.delete(writePromise); + }); + } + + private async awaitOldestNativeWrite(): Promise<void> { + const oldestWritePromise = this.nativeWritePromises.values().next().value; + if (!oldestWritePromise) { + return; + } + + await this.awaitWithFinalizationTimeout(oldestWritePromise, "native frame write"); + + if (this.nativeWriteError) { + throw this.nativeWriteError; + } + } + + private async awaitPendingNativeWrites(): Promise<void> { + while (this.nativeWritePromises.size > 0) { + await this.awaitOldestNativeWrite(); + } + + if (this.nativeWriteError) { + throw this.nativeWriteError; + } + } + + private reportFinalizingProgress( + totalFrames: number, + renderProgress: number, + audioProgress?: number, + ) { + const nextProgress = advanceFinalizationProgress({ + renderProgress, + audioProgress, + state: { + lastRenderProgress: this.lastFinalizationRenderProgress, + lastAudioProgress: this.lastFinalizationAudioProgress, + }, + }); + if (nextProgress.progressed) { + this.activeFinalizationProgressWatchdog?.refreshProgress(); + } + this.lastFinalizationRenderProgress = nextProgress.lastRenderProgress; + this.lastFinalizationAudioProgress = nextProgress.lastAudioProgress; + this.reportProgress(totalFrames, totalFrames, "finalizing", renderProgress, audioProgress); + } + + private reportProgress( + currentFrame: number, + totalFrames: number, + phase: ExportProgress["phase"] = "extracting", + renderProgress?: number, + audioProgress?: number, + ) { + const nowMs = this.getNowMs(); + const elapsedSeconds = Math.max((nowMs - this.exportStartTimeMs) / 1000, 0.001); + const averageRenderFps = currentFrame / elapsedSeconds; + const sampleElapsedMs = Math.max(nowMs - this.progressSampleStartTimeMs, 1); + const sampleFrameDelta = Math.max(currentFrame - this.progressSampleStartFrame, 0); + const renderFps = (sampleFrameDelta * 1000) / sampleElapsedMs; + const remainingFrames = Math.max(totalFrames - currentFrame, 0); + const estimatedTimeRemaining = + averageRenderFps > 0 ? remainingFrames / averageRenderFps : 0; + const safeRenderProgress = + phase === "finalizing" ? Math.max(0, Math.min(renderProgress ?? 100, 100)) : undefined; + const percentage = + phase === "finalizing" + ? (safeRenderProgress ?? 100) + : totalFrames > 0 + ? (currentFrame / totalFrames) * 100 + : 100; + + if (sampleElapsedMs >= PROGRESS_SAMPLE_WINDOW_MS) { + this.progressSampleStartTimeMs = nowMs; + this.progressSampleStartFrame = currentFrame; + } + + if (this.config.onProgress) { + this.config.onProgress({ + currentFrame, + totalFrames, + percentage, + estimatedTimeRemaining, + renderFps, + phase, + renderProgress: safeRenderProgress, + audioProgress: + typeof audioProgress === "number" + ? Math.max(0, Math.min(audioProgress, 1)) + : undefined, + }); + } + } + + private getNowMs(): number { + return typeof performance !== "undefined" ? performance.now() : Date.now(); + } + + private async measureFinalizationStage<T>( + stage: keyof ExportFinalizationStageMetrics, + task: () => Promise<T>, + ): Promise<T> { + const startedAt = this.getNowMs(); + try { + return await task(); + } finally { + this.finalizationStageMs[stage] = this.getNowMs() - startedAt; + } + } + + private buildExportMetrics(): ExportMetrics { + const totalElapsedMs = + this.exportStartTimeMs > 0 ? this.getNowMs() - this.exportStartTimeMs : 0; + const hasFinalizationStageMetrics = Object.keys(this.finalizationStageMs).length > 0; + + return { + totalElapsedMs, + finalizationMs: this.finalizationTimeMs || undefined, + frameCount: this.processedFrameCount || undefined, + effectiveDurationSec: this.effectiveDurationSec || undefined, + finalizationStageMs: hasFinalizationStageMetrics ? this.finalizationStageMs : undefined, + }; + } + + private async initializeEncoder(): Promise<void> { + this.encodeQueue = 0; + this.pendingMuxing = Promise.resolve(); + this.chunkCount = 0; + let videoDescription: Uint8Array | undefined; + + // Ordered from most capable to most compatible. avc1.PPCCLL where PP=profile, CC=constraints, LL=level. + // High 5.1 → Main 5.1 → Baseline 5.1 → Main 3.1 → Baseline 3.1 + const CODEC_FALLBACK_LIST = this.config.codec + ? [this.config.codec] + : ["avc1.640033", "avc1.4d4033", "avc1.420033", "avc1.4d401f", "avc1.42001f"]; + + let resolvedCodec: string | null = null; + + this.encoder = new VideoEncoder({ + output: (chunk, meta) => { + // Capture decoder config metadata from encoder output + if (meta?.decoderConfig?.description && !videoDescription) { + const desc = meta.decoderConfig.description; + videoDescription = ArrayBuffer.isView(desc) + ? new Uint8Array(desc.buffer, desc.byteOffset, desc.byteLength) + : new Uint8Array(desc); + this.videoDescription = videoDescription; + } + // Capture colorSpace from encoder metadata if provided + if (meta?.decoderConfig?.colorSpace && !this.videoColorSpace) { + this.videoColorSpace = meta.decoderConfig.colorSpace; + } + + // Stream chunks to muxer in order without retaining an ever-growing promise array + const isFirstChunk = this.chunkCount === 0; + this.chunkCount++; + + this.pendingMuxing = this.pendingMuxing.then(async () => { + try { + if (isFirstChunk && this.videoDescription) { + // Add decoder config for the first chunk + const colorSpace = + this.videoColorSpace || ENCODED_H264_COLOR_SPACE_FALLBACK; + + const metadata: EncodedVideoChunkMetadata = { + decoderConfig: { + codec: resolvedCodec ?? (this.config.codec || "avc1.640033"), + codedWidth: this.config.width, + codedHeight: this.config.height, + description: this.videoDescription, + colorSpace, + }, + }; + + await this.muxer!.addVideoChunk(chunk, metadata); + } else { + await this.muxer!.addVideoChunk(chunk, meta); + } + } catch (error) { + console.error("Muxing error:", error); + const muxingError = + error instanceof Error ? error : new Error(String(error)); + if (!this.encoderError) { + this.encoderError = muxingError; + } + this.cancelled = true; + } + }); + this.encodeQueue--; + }, + error: (error) => { + console.error( + `[VideoExporter] Encoder error (codec: ${resolvedCodec}, ${this.config.width}x${this.config.height}):`, + error, + ); + this.encoderError = error instanceof Error ? error : new Error(String(error)); + this.cancelled = true; + }, + }); + + const baseConfig: Omit<VideoEncoderConfig, "codec" | "hardwareAcceleration"> = { + width: this.config.width, + height: this.config.height, + bitrate: this.config.bitrate, + framerate: this.config.frameRate, + latencyMode: "quality", + bitrateMode: "variable", + }; + + for (const candidateCodec of CODEC_FALLBACK_LIST) { + const hwConfig: VideoEncoderConfig = { + ...baseConfig, + codec: candidateCodec, + hardwareAcceleration: "prefer-hardware", + }; + const hwSupport = await VideoEncoder.isConfigSupported(hwConfig); + if (hwSupport.supported) { + resolvedCodec = candidateCodec; + console.log( + `[VideoExporter] Using hardware acceleration with codec ${candidateCodec}`, + ); + this.encoder.configure(hwConfig); + return; + } + + const swConfig: VideoEncoderConfig = { + ...baseConfig, + codec: candidateCodec, + hardwareAcceleration: "prefer-software", + }; + const swSupport = await VideoEncoder.isConfigSupported(swConfig); + if (swSupport.supported) { + resolvedCodec = candidateCodec; + console.log(`[VideoExporter] Using software encoding with codec ${candidateCodec}`); + this.encoder.configure(swConfig); + return; + } + + console.warn( + `[VideoExporter] Codec ${candidateCodec} not supported (${this.config.width}x${this.config.height}), trying next...`, + ); + } + + throw new Error( + `Video encoding not supported on this system. ` + + `Tried codecs: ${CODEC_FALLBACK_LIST.join(", ")} at ${this.config.width}x${this.config.height}. ` + + `Your browser or hardware may not support H.264 encoding at this resolution. ` + + `Try exporting at a lower quality setting.`, + ); + } + + cancel(): void { + this.cancelled = true; + if (this.streamingDecoder) { + this.streamingDecoder.cancel(); + } + if (this.audioProcessor) { + this.audioProcessor.cancel(); + } + this.cleanup(); + } + + private cleanup(): void { + if (this.nativeH264Encoder) { + try { + if (this.nativeH264Encoder.state === "configured") { + this.nativeH264Encoder.close(); + } + } catch (e) { + console.warn("Error closing native H264 encoder:", e); + } + this.nativeH264Encoder = null; + } + + if (this.nativeExportSessionId) { + if (typeof window !== "undefined") { + void window.electronAPI?.nativeVideoExportCancel?.(this.nativeExportSessionId); + } + this.nativeExportSessionId = null; + } + + if (this.encoder) { + try { + if (this.encoder.state === "configured") { + this.encoder.close(); + } + } catch (e) { + console.warn("Error closing encoder:", e); + } + this.encoder = null; + } + + if (this.streamingDecoder) { + try { + this.streamingDecoder.destroy(); + } catch (e) { + console.warn("Error destroying streaming decoder:", e); + } + this.streamingDecoder = null; + } + + if (this.renderer) { + try { + this.renderer.destroy(); + } catch (e) { + console.warn("Error destroying renderer:", e); + } + this.renderer = null; + } + + if (this.muxer) { + try { + this.muxer.destroy(); + } catch (e) { + console.warn("Error destroying muxer:", e); + } + } + + this.muxer = null; + this.audioProcessor?.cancel(); + this.audioProcessor = null; + this.activeFinalizationProgressWatchdog = null; + this.lastFinalizationRenderProgress = + INITIAL_FINALIZATION_PROGRESS_STATE.lastRenderProgress; + this.lastFinalizationAudioProgress = INITIAL_FINALIZATION_PROGRESS_STATE.lastAudioProgress; + this.encodeQueue = 0; + this.pendingMuxing = Promise.resolve(); + this.nativePendingWrite = Promise.resolve(); + this.chunkCount = 0; + this.effectiveDurationSec = 0; + this.encoderError = null; + this.finalizationTimeMs = 0; + this.finalizationStageMs = {}; + this.effectiveDurationSec = 0; + this.processedFrameCount = 0; + this.videoDescription = undefined; + this.videoColorSpace = undefined; + } } diff --git a/src/lib/geometry/squircle.ts b/src/lib/geometry/squircle.ts new file mode 100644 index 000000000..86d8f939e --- /dev/null +++ b/src/lib/geometry/squircle.ts @@ -0,0 +1,135 @@ +import type { Graphics } from "pixi.js"; + +interface SquircleRect { + x: number; + y: number; + width: number; + height: number; + radius: number; +} + +interface SquirclePoint { + x: number; + y: number; +} + +const SQUIRCLE_EXPONENT = 4.5; +const SQUIRCLE_SEGMENTS_PER_CORNER = 10; + +function clamp(value: number, min: number, max: number) { + return Math.min(max, Math.max(min, value)); +} + +function getClampedRadius(width: number, height: number, radius: number) { + return clamp(radius, 0, Math.min(width, height) / 2); +} + +function getSuperellipsePoint(centerX: number, centerY: number, radius: number, angle: number) { + const cos = Math.cos(angle); + const sin = Math.sin(angle); + const exponent = 2 / SQUIRCLE_EXPONENT; + + return { + x: centerX + Math.sign(cos) * radius * Math.pow(Math.abs(cos), exponent), + y: centerY + Math.sign(sin) * radius * Math.pow(Math.abs(sin), exponent), + }; +} + +export function getSquirclePathPoints({ + x, + y, + width, + height, + radius, +}: SquircleRect): SquirclePoint[] { + if (width <= 0 || height <= 0) { + return []; + } + + const clampedRadius = getClampedRadius(width, height, radius); + if (clampedRadius <= 0.5) { + return [ + { x, y }, + { x: x + width, y }, + { x: x + width, y: y + height }, + { x, y: y + height }, + ]; + } + + const points: SquirclePoint[] = [{ x: x + clampedRadius, y }]; + const corners = [ + { + centerX: x + width - clampedRadius, + centerY: y + clampedRadius, + start: -Math.PI / 2, + end: 0, + }, + { + centerX: x + width - clampedRadius, + centerY: y + height - clampedRadius, + start: 0, + end: Math.PI / 2, + }, + { + centerX: x + clampedRadius, + centerY: y + height - clampedRadius, + start: Math.PI / 2, + end: Math.PI, + }, + { + centerX: x + clampedRadius, + centerY: y + clampedRadius, + start: Math.PI, + end: (Math.PI * 3) / 2, + }, + ]; + + for (const corner of corners) { + for (let index = 1; index <= SQUIRCLE_SEGMENTS_PER_CORNER; index += 1) { + const t = index / SQUIRCLE_SEGMENTS_PER_CORNER; + const angle = corner.start + (corner.end - corner.start) * t; + points.push(getSuperellipsePoint(corner.centerX, corner.centerY, clampedRadius, angle)); + } + } + + return points; +} + +export function getSquircleSvgPath(rect: SquircleRect) { + const points = getSquirclePathPoints(rect); + if (points.length === 0) { + return ""; + } + + const [firstPoint, ...remainingPoints] = points; + return `M ${firstPoint.x} ${firstPoint.y} ${remainingPoints + .map((point) => `L ${point.x} ${point.y}`) + .join(" ")} Z`; +} + +export function drawSquircleOnCanvas(ctx: CanvasRenderingContext2D, rect: SquircleRect) { + const points = getSquirclePathPoints(rect); + if (points.length === 0) { + return; + } + + ctx.beginPath(); + ctx.moveTo(points[0].x, points[0].y); + for (let index = 1; index < points.length; index += 1) { + ctx.lineTo(points[index].x, points[index].y); + } + ctx.closePath(); +} + +export function drawSquircleOnGraphics(graphics: Graphics, rect: SquircleRect) { + const points = getSquirclePathPoints(rect); + if (points.length === 0) { + return; + } + + graphics.moveTo(points[0].x, points[0].y); + for (let index = 1; index < points.length; index += 1) { + graphics.lineTo(points[index].x, points[index].y); + } + graphics.closePath(); +} diff --git a/src/lib/mediaTiming.test.ts b/src/lib/mediaTiming.test.ts new file mode 100644 index 000000000..167896e4b --- /dev/null +++ b/src/lib/mediaTiming.test.ts @@ -0,0 +1,182 @@ +import { describe, expect, it } from "vitest"; + +import { + clampMediaTimeToDuration, + enablePitchPreservingPlayback, + estimateCompanionAudioStartDelaySeconds, + getEffectiveRecordingDurationMs, + getEffectiveVideoStreamDurationSeconds, + getMediaSyncPlaybackRate, + resolvePreviewMediaDuration, +} from "./mediaTiming"; + +describe("clampMediaTimeToDuration", () => { + it("clamps playback time to known media duration", () => { + expect(clampMediaTimeToDuration(12, 4.5)).toBe(4.5); + expect(clampMediaTimeToDuration(-1, 4.5)).toBe(0); + }); + + it("leaves playback time unchanged when duration is unknown", () => { + expect(clampMediaTimeToDuration(12, null)).toBe(12); + expect(clampMediaTimeToDuration(12, Number.NaN)).toBe(12); + }); +}); + +describe("resolvePreviewMediaDuration", () => { + it("keeps reported durations that approximately match the timeline", () => { + expect(resolvePreviewMediaDuration(120, 120)).toBe(120); + expect(resolvePreviewMediaDuration(119.25, 120)).toBe(119.25); + }); + + it("treats non-finite or missing reported durations as unknown", () => { + expect(resolvePreviewMediaDuration(Number.POSITIVE_INFINITY, 120)).toBeNull(); + expect(resolvePreviewMediaDuration(Number.NaN, 120)).toBeNull(); + expect(resolvePreviewMediaDuration(null, 120)).toBeNull(); + }); + + it("treats implausibly short reported durations as unknown", () => { + expect(resolvePreviewMediaDuration(20, 120)).toBeNull(); + expect(resolvePreviewMediaDuration(0, 120)).toBeNull(); + }); + + it("falls back to the reported duration when the timeline duration is unknown", () => { + expect(resolvePreviewMediaDuration(20, Number.NaN)).toBe(20); + expect(resolvePreviewMediaDuration(20, null)).toBe(20); + expect(resolvePreviewMediaDuration(Number.NaN, Number.NaN)).toBeNull(); + }); +}); + +describe("estimateCompanionAudioStartDelaySeconds", () => { + it("keeps small inferred offsets when the companion audio is only slightly shorter", () => { + expect(estimateCompanionAudioStartDelaySeconds(10, 9.6)).toBeCloseTo(0.4); + expect(estimateCompanionAudioStartDelaySeconds(10, 9.97)).toBeCloseTo(0.03); + }); + + it("prefers an explicitly recorded start delay", () => { + expect(estimateCompanionAudioStartDelaySeconds(10, 2, 3_500)).toBeCloseTo(3.5); + expect(estimateCompanionAudioStartDelaySeconds(10, 2, 0)).toBe(0); + }); + + it("ignores tiny, negative, or suspiciously large inferred differences", () => { + expect(estimateCompanionAudioStartDelaySeconds(10, 9.99)).toBe(0); + expect(estimateCompanionAudioStartDelaySeconds(10, 10.5)).toBe(0); + expect(estimateCompanionAudioStartDelaySeconds(600, 565)).toBe(0); + }); +}); + +describe("getEffectiveRecordingDurationMs", () => { + it("subtracts accumulated paused time", () => { + expect( + getEffectiveRecordingDurationMs({ + startTimeMs: 1_000, + endTimeMs: 11_000, + accumulatedPausedDurationMs: 2_500, + }), + ).toBe(7_500); + }); + + it("subtracts an active pause interval", () => { + expect( + getEffectiveRecordingDurationMs({ + startTimeMs: 1_000, + endTimeMs: 11_000, + accumulatedPausedDurationMs: 2_000, + pauseStartedAtMs: 9_000, + }), + ).toBe(6_000); + }); +}); + +describe("getMediaSyncPlaybackRate", () => { + it("returns the base rate when drift is within tolerance", () => { + expect( + getMediaSyncPlaybackRate({ + basePlaybackRate: 1, + currentTime: 10, + targetTime: 10.01, + }), + ).toBe(1); + }); + + it("nudges playback rate toward the target time", () => { + expect( + getMediaSyncPlaybackRate({ + basePlaybackRate: 1, + currentTime: 10, + targetTime: 10.1, + }), + ).toBeCloseTo(1.05); + + expect( + getMediaSyncPlaybackRate({ + basePlaybackRate: 1, + currentTime: 10.1, + targetTime: 10, + }), + ).toBeCloseTo(0.95); + }); + + it("clamps oversized corrections", () => { + expect( + getMediaSyncPlaybackRate({ + basePlaybackRate: 1, + currentTime: 10, + targetTime: 10.5, + }), + ).toBeCloseTo(1.08); + }); +}); + +describe("enablePitchPreservingPlayback", () => { + it("enables standard and vendor pitch-preserve switches", () => { + const media = {} as HTMLMediaElement & { + preservesPitch?: boolean; + mozPreservesPitch?: boolean; + webkitPreservesPitch?: boolean; + }; + + enablePitchPreservingPlayback(media); + + expect(media.preservesPitch).toBe(true); + expect(media.mozPreservesPitch).toBe(true); + expect(media.webkitPreservesPitch).toBe(true); + }); +}); + +describe("getEffectiveVideoStreamDurationSeconds", () => { + it("prefers the video stream duration when present", () => { + expect( + getEffectiveVideoStreamDurationSeconds({ + duration: 12, + streamDuration: 11.2, + }), + ).toBe(11.2); + }); + + it("uses the container duration when the video stream is much shorter", () => { + expect( + getEffectiveVideoStreamDurationSeconds({ + duration: 60, + streamDuration: 40, + }), + ).toBe(60); + }); + + it("falls back to the container duration when stream duration is missing", () => { + expect( + getEffectiveVideoStreamDurationSeconds({ + duration: 12, + streamDuration: undefined, + }), + ).toBe(12); + }); + + it("returns zero when neither duration is usable", () => { + expect( + getEffectiveVideoStreamDurationSeconds({ + duration: Number.NaN, + streamDuration: 0, + }), + ).toBe(0); + }); +}); diff --git a/src/lib/mediaTiming.ts b/src/lib/mediaTiming.ts new file mode 100644 index 000000000..aeb96ef9b --- /dev/null +++ b/src/lib/mediaTiming.ts @@ -0,0 +1,195 @@ +export function clampMediaTimeToDuration(targetTime: number, duration?: number | null): number { + const safeTargetTime = Math.max(0, targetTime); + if (!Number.isFinite(duration) || duration === null || duration === undefined) { + return safeTargetTime; + } + + return Math.max(0, Math.min(safeTargetTime, Math.max(0, duration))); +} + +const MIN_PREVIEW_MEDIA_DURATION_SHORTFALL_TOLERANCE_SECONDS = 0.5; +const MAX_PREVIEW_MEDIA_DURATION_SHORTFALL_TOLERANCE_SECONDS = 1; +const PREVIEW_MEDIA_DURATION_SHORTFALL_TOLERANCE_RATIO = 0.01; + +export function resolvePreviewMediaDuration( + reportedDuration?: number | null, + timelineDuration?: number | null, +): number | null { + if (!Number.isFinite(reportedDuration)) { + return null; + } + + const safeReportedDuration = Math.max(0, reportedDuration ?? 0); + if ( + !Number.isFinite(timelineDuration) || + timelineDuration === null || + timelineDuration === undefined + ) { + return safeReportedDuration; + } + + const safeTimelineDuration = Math.max(0, timelineDuration); + if (safeTimelineDuration === 0) { + return safeReportedDuration; + } + + const shortfallToleranceSeconds = Math.min( + MAX_PREVIEW_MEDIA_DURATION_SHORTFALL_TOLERANCE_SECONDS, + Math.max( + MIN_PREVIEW_MEDIA_DURATION_SHORTFALL_TOLERANCE_SECONDS, + safeTimelineDuration * PREVIEW_MEDIA_DURATION_SHORTFALL_TOLERANCE_RATIO, + ), + ); + + if (safeTimelineDuration - safeReportedDuration > shortfallToleranceSeconds) { + return null; + } + + return safeReportedDuration; +} + +const MIN_COMPANION_AUDIO_DELAY_SECONDS = 0.025; +const MAX_INFERRED_COMPANION_AUDIO_DELAY_SECONDS = 0.5; + +export function estimateCompanionAudioStartDelaySeconds( + timelineDuration?: number | null, + audioDuration?: number | null, + recordedStartDelayMs?: number | null, +): number { + if (Number.isFinite(recordedStartDelayMs) && (recordedStartDelayMs ?? 0) >= 0) { + return Math.max(0, recordedStartDelayMs ?? 0) / 1000; + } + + if (!Number.isFinite(timelineDuration) || !Number.isFinite(audioDuration)) { + return 0; + } + + const safeTimelineDuration = Math.max(0, timelineDuration ?? 0); + const safeAudioDuration = Math.max(0, audioDuration ?? 0); + const estimatedDelaySeconds = safeTimelineDuration - safeAudioDuration; + + if ( + estimatedDelaySeconds <= MIN_COMPANION_AUDIO_DELAY_SECONDS || + estimatedDelaySeconds > MAX_INFERRED_COMPANION_AUDIO_DELAY_SECONDS + ) { + return 0; + } + + return estimatedDelaySeconds; +} + +export function getMediaSyncPlaybackRate({ + basePlaybackRate, + currentTime, + targetTime, + toleranceSeconds = 0.015, + correctionWindowSeconds = 2, + maxAdjustment = 0.08, +}: { + basePlaybackRate: number; + currentTime: number; + targetTime: number; + toleranceSeconds?: number; + correctionWindowSeconds?: number; + maxAdjustment?: number; +}): number { + const safeBasePlaybackRate = + Number.isFinite(basePlaybackRate) && basePlaybackRate > 0 ? basePlaybackRate : 1; + + if (!Number.isFinite(currentTime) || !Number.isFinite(targetTime)) { + return safeBasePlaybackRate; + } + + const driftSeconds = targetTime - currentTime; + if (Math.abs(driftSeconds) <= toleranceSeconds) { + return safeBasePlaybackRate; + } + + const safeCorrectionWindow = correctionWindowSeconds > 0 ? correctionWindowSeconds : 2; + const safeMaxAdjustment = Math.max(0, maxAdjustment); + const adjustment = Math.max( + -safeMaxAdjustment, + Math.min(safeMaxAdjustment, driftSeconds / safeCorrectionWindow), + ); + + return Math.max(0.1, safeBasePlaybackRate + adjustment); +} + +type PitchPreservingMediaElement = HTMLMediaElement & { + preservesPitch?: boolean; + mozPreservesPitch?: boolean; + webkitPreservesPitch?: boolean; +}; + +export function enablePitchPreservingPlayback(media: HTMLMediaElement) { + const pitchMedia = media as PitchPreservingMediaElement; + pitchMedia.preservesPitch = true; + pitchMedia.mozPreservesPitch = true; + pitchMedia.webkitPreservesPitch = true; +} + +export function getEffectiveVideoStreamDurationSeconds({ + duration, + streamDuration, +}: { + duration?: number | null; + streamDuration?: number | null; +}): number { + const safeDuration = + Number.isFinite(duration) && (duration ?? 0) > 0 ? Math.max(0, duration ?? 0) : 0; + const safeStreamDuration = + Number.isFinite(streamDuration) && (streamDuration ?? 0) > 0 + ? Math.max(0, streamDuration ?? 0) + : 0; + + if (safeDuration > 0 && safeStreamDuration > 0) { + const gapSeconds = safeDuration - safeStreamDuration; + const largeMismatchThresholdSeconds = Math.max(2, safeDuration * 0.1); + if (gapSeconds > largeMismatchThresholdSeconds) { + return safeDuration; + } + + return safeStreamDuration; + } + + if (safeStreamDuration > 0) { + return safeStreamDuration; + } + + if (safeDuration > 0) { + return safeDuration; + } + + return 0; +} + +export function getEffectiveRecordingDurationMs({ + startTimeMs, + endTimeMs, + accumulatedPausedDurationMs = 0, + pauseStartedAtMs = null, +}: { + startTimeMs: number; + endTimeMs: number; + accumulatedPausedDurationMs?: number; + pauseStartedAtMs?: number | null; +}): number { + if (!Number.isFinite(startTimeMs) || !Number.isFinite(endTimeMs)) { + return 0; + } + + const safeStartTime = Math.max(0, startTimeMs); + const safeEndTime = Math.max(safeStartTime, endTimeMs); + const activePauseDuration = + Number.isFinite(pauseStartedAtMs) && pauseStartedAtMs !== null + ? Math.max(0, safeEndTime - pauseStartedAtMs) + : 0; + + return Math.max( + 0, + safeEndTime - + safeStartTime - + Math.max(0, accumulatedPausedDurationMs) - + activePauseDuration, + ); +} diff --git a/src/lib/pixiApplicationLifecycle.test.ts b/src/lib/pixiApplicationLifecycle.test.ts new file mode 100644 index 000000000..6b5762427 --- /dev/null +++ b/src/lib/pixiApplicationLifecycle.test.ts @@ -0,0 +1,113 @@ +import type { Application } from "pixi.js"; +import { describe, expect, it, vi } from "vitest"; +import { + destroyPixiApplication, + initializePixiApplication, + initializePixiApplicationWithTimeout, +} from "./pixiApplicationLifecycle"; + +function createApplication(init: () => Promise<void> = async () => undefined) { + return { + init: vi.fn(init), + destroy: vi.fn(), + stage: { destroy: vi.fn() }, + renderer: { destroy: vi.fn() }, + } as unknown as Application; +} + +describe("Pixi application lifecycle", () => { + it("cleans a failed initialization without running uninitialized plugins", async () => { + const initializationError = new Error("No available renderer"); + const app = createApplication(async () => { + throw initializationError; + }); + const applicationDestroy = vi.mocked(app.destroy); + applicationDestroy.mockImplementation(() => { + throw new TypeError("this._cancelResize is not a function"); + }); + + await expect(initializePixiApplication(app, {})).rejects.toBe(initializationError); + expect(() => destroyPixiApplication(app, "test renderer init")).not.toThrow(); + + expect(applicationDestroy).not.toHaveBeenCalled(); + expect(app.stage.destroy).toHaveBeenCalledWith({ + children: true, + texture: false, + textureSource: false, + }); + expect(app.renderer.destroy).toHaveBeenCalledWith({ + removeView: true, + releaseGlobalResources: false, + }); + }); + + it("destroys a successfully initialized application at most once", async () => { + const app = createApplication(); + + await initializePixiApplication(app, {}); + destroyPixiApplication(app, "test renderer"); + destroyPixiApplication(app, "test renderer"); + + expect(app.destroy).toHaveBeenCalledTimes(1); + expect(app.destroy).toHaveBeenCalledWith( + { removeView: true, releaseGlobalResources: false }, + { children: true, texture: false, textureSource: false }, + ); + }); + + it("defers teardown until an in-flight initialization settles", async () => { + let finishInitialization: (() => void) | undefined; + const app = createApplication( + () => + new Promise<void>((resolve) => { + finishInitialization = resolve; + }), + ); + const initialization = initializePixiApplication(app, {}); + + destroyPixiApplication(app, "timed-out renderer init"); + expect(app.destroy).not.toHaveBeenCalled(); + + finishInitialization?.(); + await initialization; + + expect(app.destroy).toHaveBeenCalledTimes(1); + }); + + it("reports cleanup errors without throwing or retrying unsafe teardown", async () => { + const app = createApplication(); + const cleanupError = new Error("renderer cleanup failed"); + vi.mocked(app.destroy).mockImplementation(() => { + throw cleanupError; + }); + const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); + + await initializePixiApplication(app, {}); + expect(() => destroyPixiApplication(app, "test renderer")).not.toThrow(); + expect(() => destroyPixiApplication(app, "test renderer")).not.toThrow(); + + expect(app.destroy).toHaveBeenCalledTimes(1); + expect(warn).toHaveBeenCalledWith( + "[PixiApplication] Failed to clean up test renderer:", + cleanupError, + ); + warn.mockRestore(); + }); + + it("reports the backend when initialization times out", async () => { + vi.useFakeTimers(); + try { + const app = createApplication(() => new Promise<void>(() => undefined)); + const initialization = initializePixiApplicationWithTimeout(app, {}, 250, "webgpu"); + const rejection = initialization.catch((error: unknown) => error); + + await vi.advanceTimersByTimeAsync(250); + + await expect(rejection).resolves.toEqual( + new Error("Initialization timed out after 250ms for webgpu renderer"), + ); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/src/lib/pixiApplicationLifecycle.ts b/src/lib/pixiApplicationLifecycle.ts new file mode 100644 index 000000000..5abe48da4 --- /dev/null +++ b/src/lib/pixiApplicationLifecycle.ts @@ -0,0 +1,110 @@ +import type { Application } from "pixi.js"; + +type PixiInitializationState = "initializing" | "initialized" | "failed"; +type PixiInitOptions = Parameters<Application["init"]>[0]; + +const initializationStates = new WeakMap<Application, PixiInitializationState>(); +const destroyRequests = new WeakSet<Application>(); +const destroyContexts = new WeakMap<Application, string>(); +const completedCleanups = new WeakSet<Application>(); + +const RENDERER_DESTROY_OPTIONS = { + removeView: true, + releaseGlobalResources: false, +} as const; + +const STAGE_DESTROY_OPTIONS = { + children: true, + texture: false, + textureSource: false, +} as const; + +function reportCleanupError(app: Application, error: unknown): void { + const context = destroyContexts.get(app) ?? "Pixi application"; + console.warn(`[PixiApplication] Failed to clean up ${context}:`, error); +} + +function destroyFailedApplication(app: Application): void { + const partialApp = app as Partial<Application>; + + try { + partialApp.stage?.destroy(STAGE_DESTROY_OPTIONS); + } catch (error) { + reportCleanupError(app, error); + } + + try { + partialApp.renderer?.destroy(RENDERER_DESTROY_OPTIONS); + } catch (error) { + reportCleanupError(app, error); + } +} + +function completeDestroy(app: Application): void { + if (completedCleanups.has(app)) return; + completedCleanups.add(app); + + if (initializationStates.get(app) !== "initialized") { + destroyFailedApplication(app); + return; + } + + try { + app.destroy(RENDERER_DESTROY_OPTIONS, STAGE_DESTROY_OPTIONS); + } catch (error) { + reportCleanupError(app, error); + } +} + +export async function initializePixiApplication( + app: Application, + options: PixiInitOptions, +): Promise<void> { + if (initializationStates.has(app) || destroyRequests.has(app)) { + throw new Error("Pixi application lifecycle has already started"); + } + + initializationStates.set(app, "initializing"); + try { + await app.init(options); + initializationStates.set(app, "initialized"); + } catch (error) { + initializationStates.set(app, "failed"); + if (destroyRequests.has(app)) completeDestroy(app); + throw error; + } + + if (destroyRequests.has(app)) completeDestroy(app); +} + +export async function initializePixiApplicationWithTimeout( + app: Application, + options: PixiInitOptions, + timeoutMs: number, + backendLabel: string, +): Promise<void> { + let timeoutId: ReturnType<typeof setTimeout> | undefined; + const timeoutPromise = new Promise<never>((_, reject) => { + timeoutId = setTimeout(() => { + reject( + new Error( + `Initialization timed out after ${timeoutMs}ms for ${backendLabel} renderer`, + ), + ); + }, timeoutMs); + }); + + try { + await Promise.race([initializePixiApplication(app, options), timeoutPromise]); + } finally { + if (timeoutId !== undefined) clearTimeout(timeoutId); + } +} + +export function destroyPixiApplication(app: Application | null, context: string): void { + if (!app || destroyRequests.has(app) || completedCleanups.has(app)) return; + + destroyRequests.add(app); + destroyContexts.set(app, context); + if (initializationStates.get(app) !== "initializing") completeDestroy(app); +} diff --git a/src/lib/shortcuts.ts b/src/lib/shortcuts.ts new file mode 100644 index 000000000..d43846930 --- /dev/null +++ b/src/lib/shortcuts.ts @@ -0,0 +1,136 @@ +export const SHORTCUT_ACTIONS = [ + "addZoom", + "splitClip", + "addAnnotation", + "addKeyframe", + "deleteSelected", + "playPause", +] as const; + +export type ShortcutAction = (typeof SHORTCUT_ACTIONS)[number]; + +export interface ShortcutBinding { + key: string; + /** Maps to Cmd on macOS, Ctrl on Windows/Linux */ + ctrl?: boolean; + shift?: boolean; + alt?: boolean; +} + +export type ShortcutsConfig = Record<ShortcutAction, ShortcutBinding>; + +export interface FixedShortcut { + label: string; + display: string; + bindings: ShortcutBinding[]; +} + +export const FIXED_SHORTCUTS: FixedShortcut[] = [ + { label: "Cycle Annotations Forward", display: "Tab", bindings: [{ key: "tab" }] }, + { + label: "Cycle Annotations Backward", + display: "Shift + Tab", + bindings: [{ key: "tab", shift: true }], + }, + { + label: "Delete Selected (alt)", + display: "Del / ⌫", + bindings: [{ key: "delete" }, { key: "backspace" }], + }, + { label: "Pan Timeline", display: "Shift + Scroll", bindings: [] }, + { label: "Zoom Timeline", display: "Ctrl + Scroll", bindings: [] }, +]; + +export type ShortcutConflict = + | { type: "configurable"; action: ShortcutAction } + | { type: "fixed"; label: string }; + +export function bindingsEqual(a: ShortcutBinding, b: ShortcutBinding): boolean { + return ( + a.key.toLowerCase() === b.key.toLowerCase() && + !!a.ctrl === !!b.ctrl && + !!a.shift === !!b.shift && + !!a.alt === !!b.alt + ); +} + +export function findConflict( + binding: ShortcutBinding, + forAction: ShortcutAction, + config: ShortcutsConfig, +): ShortcutConflict | null { + for (const fixed of FIXED_SHORTCUTS) { + if (fixed.bindings.some((b) => bindingsEqual(b, binding))) { + return { type: "fixed", label: fixed.label }; + } + } + for (const action of SHORTCUT_ACTIONS) { + if (action !== forAction && bindingsEqual(config[action], binding)) { + return { type: "configurable", action }; + } + } + return null; +} + +export const DEFAULT_SHORTCUTS: ShortcutsConfig = { + addZoom: { key: "z" }, + splitClip: { key: "c" }, + addAnnotation: { key: "a" }, + addKeyframe: { key: "f" }, + deleteSelected: { key: "d", ctrl: true }, + playPause: { key: " " }, +}; + +export const SHORTCUT_LABELS: Record<ShortcutAction, string> = { + addZoom: "Add Zoom", + splitClip: "Split Clip", + addAnnotation: "Add Annotation", + addKeyframe: "Add Keyframe", + deleteSelected: "Delete Selected", + playPause: "Play / Pause", +}; + +export function matchesShortcut( + e: KeyboardEvent, + binding: ShortcutBinding, + isMacPlatform: boolean, +): boolean { + if (e.key.toLowerCase() !== binding.key.toLowerCase()) return false; + + const primaryMod = isMacPlatform ? e.metaKey : e.ctrlKey; + if (primaryMod !== !!binding.ctrl) return false; + if (e.shiftKey !== !!binding.shift) return false; + if (e.altKey !== !!binding.alt) return false; + + return true; +} + +const KEY_LABELS: Record<string, string> = { + " ": "Space", + delete: "Del", + backspace: "⌫", + escape: "Esc", + arrowup: "↑", + arrowdown: "↓", + arrowleft: "←", + arrowright: "→", +}; + +export function formatBinding(binding: ShortcutBinding, isMac: boolean): string { + const parts: string[] = []; + if (binding.ctrl) parts.push(isMac ? "⌘" : "Ctrl"); + if (binding.shift) parts.push(isMac ? "⇧" : "Shift"); + if (binding.alt) parts.push(isMac ? "⌥" : "Alt"); + parts.push(KEY_LABELS[binding.key] ?? binding.key.toUpperCase()); + return parts.join(" + "); +} + +export function mergeWithDefaults(partial: Partial<ShortcutsConfig>): ShortcutsConfig { + const merged = { ...DEFAULT_SHORTCUTS }; + for (const action of SHORTCUT_ACTIONS) { + if (partial[action]) { + merged[action] = partial[action] as ShortcutBinding; + } + } + return merged; +} diff --git a/src/lib/utils.ts b/src/lib/utils.ts index bd0c391dd..ac680b303 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -1,6 +1,6 @@ -import { clsx, type ClassValue } from "clsx" -import { twMerge } from "tailwind-merge" +import { type ClassValue, clsx } from "clsx"; +import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { - return twMerge(clsx(inputs)) + return twMerge(clsx(inputs)); } diff --git a/src/lib/wallpapers.test.ts b/src/lib/wallpapers.test.ts new file mode 100644 index 000000000..62d923e33 --- /dev/null +++ b/src/lib/wallpapers.test.ts @@ -0,0 +1,94 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + BUILT_IN_WALLPAPERS, + DEFAULT_WALLPAPER_PATH, + DEFAULT_WALLPAPER_RELATIVE_PATH, + getAvailableWallpapers, + resolveAvailableWallpaperPath, +} from "./wallpapers"; + +describe("wallpapers", () => { + beforeEach(() => { + vi.unstubAllGlobals(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("keeps the curated wallpaper list and default path aligned", () => { + expect(DEFAULT_WALLPAPER_PATH).toBe("/wallpapers/tahoe-light.jpg"); + expect(DEFAULT_WALLPAPER_RELATIVE_PATH).toBe("wallpapers/tahoe-light.jpg"); + expect(BUILT_IN_WALLPAPERS.at(0)?.publicPath).toBe(DEFAULT_WALLPAPER_PATH); + expect(BUILT_IN_WALLPAPERS.at(1)?.publicPath).toBe("/wallpapers/tahoe-dark.jpg"); + expect(BUILT_IN_WALLPAPERS).toHaveLength(25); + }); + + it("preserves the curated order when asset discovery returns extra files", async () => { + vi.stubGlobal("window", { + electronAPI: { + listAssetDirectory: vi.fn(async () => ({ + success: true, + files: [ + "wallpaper1.jpg", + "energy-17.jpg", + "midnight-8.jpg", + "wallpaper4.jpg", + "wispysky.mp4", + "cityscape.jpg", + "ipad-17-light.jpg", + ], + })), + }, + }); + + await expect(getAvailableWallpapers()).resolves.toEqual([ + BUILT_IN_WALLPAPERS[2], + BUILT_IN_WALLPAPERS[4], + BUILT_IN_WALLPAPERS[15], + BUILT_IN_WALLPAPERS[16], + BUILT_IN_WALLPAPERS[23], + BUILT_IN_WALLPAPERS[24], + ]); + }); + + it("falls back to the default wallpaper when a bundled wallpaper is missing", async () => { + vi.stubGlobal("window", { + electronAPI: { + listAssetDirectory: vi.fn().mockResolvedValue({ + success: true, + files: ["wallpaper2.jpg"], + }), + }, + }); + + await expect(resolveAvailableWallpaperPath("/wallpapers/midnight-8.jpg")).resolves.toBe( + DEFAULT_WALLPAPER_PATH, + ); + await expect(resolveAvailableWallpaperPath("/wallpapers/wallpaper2.jpg")).resolves.toBe( + "/wallpapers/wallpaper2.jpg", + ); + }); + + it("strips query strings and fragments before checking bundled wallpaper files", async () => { + vi.stubGlobal("window", { + electronAPI: { + listAssetDirectory: vi.fn().mockResolvedValue({ + success: true, + files: ["wispysky.mp4"], + }), + }, + }); + + await expect(resolveAvailableWallpaperPath("/wallpapers/wispysky.mp4#t=0.1")).resolves.toBe( + "/wallpapers/wispysky.mp4#t=0.1", + ); + }); + + it("preserves non-bundled wallpaper values", async () => { + await expect(resolveAvailableWallpaperPath("#123456")).resolves.toBe("#123456"); + await expect(resolveAvailableWallpaperPath("data:image/png;base64,abc")).resolves.toBe( + "data:image/png;base64,abc", + ); + }); +}); diff --git a/src/lib/wallpapers.ts b/src/lib/wallpapers.ts new file mode 100644 index 000000000..fc6e39e56 --- /dev/null +++ b/src/lib/wallpapers.ts @@ -0,0 +1,161 @@ +export interface BuiltInWallpaper { + id: string; + label: string; + relativePath: string; + publicPath: string; +} + +const IMAGE_FILE_PATTERN = /\.(avif|gif|jpe?g|png|svg|webp)$/i; +const VIDEO_FILE_PATTERN = /\.(avi|m4v|mkv|mov|mp4|webm)$/i; + +export const BUILT_IN_WALLPAPERS: BuiltInWallpaper[] = [ + createWallpaperEntry("tahoe-light.jpg", "Tahoe Light"), + createWallpaperEntry("tahoe-dark.jpg", "Tahoe Dark"), + createWallpaperEntry("midnight-8.jpg", "Midnight 8"), + createWallpaperEntry("ipad-17-dark.jpg", "iPad 17 Dark"), + createWallpaperEntry("ipad-17-light.jpg", "iPad 17 Light"), + createWallpaperEntry("sequoia-blue.jpg", "Sequoia Blue"), + createWallpaperEntry("sequoia-blue-orange.jpg", "Sequoia Blue Orange"), + createWallpaperEntry("ventura.jpg", "Ventura"), + createWallpaperEntry("sonoma-clouds.jpg", "Sonoma Clouds"), + createWallpaperEntry("sonoma-light.jpg", "Sonoma Light"), + createWallpaperEntry("sonoma-dark.jpg", "Sonoma Dark"), + createWallpaperEntry("glassmorphism-3.jpg", "Glassmorphism 3"), + createWallpaperEntry("glassmorphism-4.jpg", "Glassmorphism 4"), + createWallpaperEntry("energy-19.jpg", "Energy 19"), + createWallpaperEntry("wallpaper3.jpg", "Wallpaper 3"), + createWallpaperEntry("wallpaper4.jpg", "Wallpaper 4"), + createWallpaperEntry("cityscape.jpg", "Cityscape"), + createWallpaperEntry("levels.jpg", "Levels"), + createWallpaperEntry("wallpaper10.jpg", "Wallpaper 10"), + createWallpaperEntry("ventura-dark.jpg", "Ventura Dark"), + createWallpaperEntry("sonoma-evening.jpg", "Sonoma Evening"), + createWallpaperEntry("sonoma-horizon.jpg", "Sonoma Horizon"), + createWallpaperEntry("iridescent-9.jpg", "Iridescent 9"), + createWallpaperEntry("energy-17.jpg", "Energy 17"), + createWallpaperEntry("wispysky.mp4", "Wispy Sky"), +]; + +export const WALLPAPER_PATHS = BUILT_IN_WALLPAPERS.map((wallpaper) => wallpaper.publicPath); +export const WALLPAPER_RELATIVE_PATHS = BUILT_IN_WALLPAPERS.map( + (wallpaper) => wallpaper.relativePath, +); +export const DEFAULT_WALLPAPER_PATH = "/wallpapers/tahoe-light.jpg"; +export const DEFAULT_WALLPAPER_RELATIVE_PATH = "wallpapers/tahoe-light.jpg"; + +function safeDecodeFileName(fileName: string) { + try { + return decodeURIComponent(fileName); + } catch { + return fileName; + } +} + +function getBundledWallpaperFileName(value: string) { + if (!value.startsWith("/wallpapers/")) { + return null; + } + + const normalizedValue = value.split(/[?#]/)[0] ?? value; + const fileName = normalizedValue.split("/").filter(Boolean).pop(); + return fileName ? safeDecodeFileName(fileName) : null; +} + +export async function resolveAvailableWallpaperPath(wallpaper: string): Promise<string> { + const bundledFileName = getBundledWallpaperFileName(wallpaper); + if ( + !bundledFileName || + typeof window === "undefined" || + !window.electronAPI?.listAssetDirectory + ) { + return wallpaper; + } + + try { + const result = await window.electronAPI.listAssetDirectory("wallpapers"); + if (!result.success || !result.files?.length) { + return wallpaper; + } + + return result.files.includes(bundledFileName) ? wallpaper : DEFAULT_WALLPAPER_PATH; + } catch { + return wallpaper; + } +} + +export function isVideoWallpaperSource(value: string): boolean { + if (!value) { + return false; + } + + const normalizedValue = value.split("?")[0]?.toLowerCase() ?? value.toLowerCase(); + return VIDEO_FILE_PATTERN.test(normalizedValue); +} + +function toWallpaperId(fileName: string) { + return fileName + .replace(/\.[^.]+$/, "") + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); +} + +function toWallpaperLabel(fileName: string) { + const baseName = fileName.replace(/\.[^.]+$/, "").trim(); + if (!baseName) { + return "Wallpaper"; + } + + return baseName + .replace(/[_-]+/g, " ") + .replace(/\s+/g, " ") + .replace(/\b\w/g, (match) => match.toUpperCase()); +} + +function createWallpaperEntry( + fileName: string, + label = toWallpaperLabel(fileName), +): BuiltInWallpaper { + const encodedFileName = encodeURIComponent(fileName); + return { + id: toWallpaperId(fileName) || `wallpaper-${encodedFileName.toLowerCase()}`, + label, + relativePath: `wallpapers/${fileName}`, + publicPath: `/wallpapers/${encodedFileName}`, + }; +} + +export async function getAvailableWallpapers(): Promise<BuiltInWallpaper[]> { + const fallbackWallpapers = BUILT_IN_WALLPAPERS; + + if (typeof window === "undefined" || !window.electronAPI?.listAssetDirectory) { + return fallbackWallpapers; + } + + try { + const result = await window.electronAPI.listAssetDirectory("wallpapers"); + if (!result.success || !result.files?.length) { + return fallbackWallpapers; + } + + const discoveredFiles = new Set( + result.files.filter( + (fileName) => + IMAGE_FILE_PATTERN.test(fileName) || VIDEO_FILE_PATTERN.test(fileName), + ), + ); + + if (discoveredFiles.size === 0) { + return fallbackWallpapers; + } + + const curatedWallpapers = fallbackWallpapers.filter((wallpaper) => + discoveredFiles.has(wallpaper.relativePath.replace(/^wallpapers\//, "")), + ); + + return curatedWallpapers.length > 0 ? curatedWallpapers : fallbackWallpapers; + } catch { + return fallbackWallpapers; + } +} diff --git a/src/main.tsx b/src/main.tsx index 9ec1b0ed6..2e7b08a18 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,10 +1,18 @@ -import React from 'react' -import ReactDOM from 'react-dom/client' -import App from './App.tsx' -import './index.css' - -ReactDOM.createRoot(document.getElementById('root')!).render( - <React.StrictMode> - <App /> - </React.StrictMode>, -) +import React from "react"; +import ReactDOM from "react-dom/client"; +import App from "./App.tsx"; +import { I18nProvider } from "./contexts/I18nContext.tsx"; +import { ThemeProvider } from "./contexts/ThemeContext.tsx"; +import "./index.css"; + +document.documentElement.dataset.platform = /mac/i.test(navigator.platform) ? "macos" : "other"; + +ReactDOM.createRoot(document.getElementById("root")!).render( + <React.StrictMode> + <ThemeProvider> + <I18nProvider> + <App /> + </I18nProvider> + </ThemeProvider> + </React.StrictMode>, +); diff --git a/src/utils/aspectRatioUtils.ts b/src/utils/aspectRatioUtils.ts new file mode 100644 index 000000000..45c12f96a --- /dev/null +++ b/src/utils/aspectRatioUtils.ts @@ -0,0 +1,103 @@ +export const ASPECT_RATIOS = [ + "native", + "16:9", + "9:16", + "1:1", + "4:3", + "4:5", + "16:10", + "10:16", +] as const; + +type PresetAspectRatio = (typeof ASPECT_RATIOS)[number]; +type CustomAspectRatio = `${number}:${number}`; + +export type AspectRatio = PresetAspectRatio | CustomAspectRatio; + +const CUSTOM_ASPECT_RATIO_REGEX = /^(\d+):(\d+)$/; + +export function isCustomAspectRatio(aspectRatio: string): aspectRatio is CustomAspectRatio { + if ((ASPECT_RATIOS as readonly string[]).includes(aspectRatio)) { + return false; + } + const match = aspectRatio.match(CUSTOM_ASPECT_RATIO_REGEX); + if (!match) { + return false; + } + const width = Number(match[1]); + const height = Number(match[2]); + return Number.isFinite(width) && Number.isFinite(height) && width > 0 && height > 0; +} + +function parseCustomAspectRatioValue(aspectRatio: string): number | null { + if (!isCustomAspectRatio(aspectRatio)) { + return null; + } + const [widthText, heightText] = aspectRatio.split(":"); + const width = Number(widthText); + const height = Number(heightText); + if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) { + return null; + } + return width / height; +} + +/** + * Returns the numeric value of an aspect ratio. + * Uses exhaustive type checking to ensure all AspectRatio cases are handled. + * If TypeScript errors here, a new ratio was added to the type but not handled. + */ +export function getAspectRatioValue(aspectRatio: AspectRatio, nativeAspectRatio = 16 / 9): number { + switch (aspectRatio) { + case "native": + return nativeAspectRatio > 0 ? nativeAspectRatio : 16 / 9; + case "16:9": + return 16 / 9; + case "9:16": + return 9 / 16; + case "1:1": + return 1; + case "4:3": + return 4 / 3; + case "4:5": + return 4 / 5; + case "16:10": + return 16 / 10; + case "10:16": + return 10 / 16; + default: + return parseCustomAspectRatioValue(aspectRatio) ?? 16 / 9; + } +} + +export function getAspectRatioDimensions( + aspectRatio: AspectRatio, + baseWidth: number, + nativeAspectRatio?: number, +): { width: number; height: number } { + const ratio = getAspectRatioValue(aspectRatio, nativeAspectRatio); + return { + width: baseWidth, + height: baseWidth / ratio, + }; +} + +export function getAspectRatioLabel(aspectRatio: AspectRatio): string { + if (aspectRatio === "native") { + return "Native"; + } + if (isCustomAspectRatio(aspectRatio)) { + return `Custom ${aspectRatio}`; + } + return aspectRatio; +} + +export function formatAspectRatioForCSS( + aspectRatio: AspectRatio, + nativeAspectRatio = 16 / 9, +): string { + if (aspectRatio === "native") { + return `${nativeAspectRatio > 0 ? nativeAspectRatio : 16 / 9}`; + } + return aspectRatio.replace(":", "/"); +} diff --git a/src/utils/platformUtils.ts b/src/utils/platformUtils.ts new file mode 100644 index 000000000..4816b33dd --- /dev/null +++ b/src/utils/platformUtils.ts @@ -0,0 +1,77 @@ +let cachedPlatform: string | null = null; + +function getNavigatorFallbackPlatform(): string { + if (typeof navigator === "undefined") { + return "win32"; + } + + const navigatorWithUAData = navigator as Navigator & { + userAgentData?: { platform?: string }; + }; + const platformSource = navigatorWithUAData.userAgentData?.platform ?? navigator.userAgent ?? ""; + + if (/mac|iphone|ipad|ipod/i.test(platformSource)) { + return "darwin"; + } + + if (/linux/i.test(platformSource)) { + return "linux"; + } + + return "win32"; +} + +/** + * Gets the current platform from Electron + */ +const getPlatform = async (): Promise<string> => { + if (cachedPlatform) return cachedPlatform; + + try { + const platform = await window.electronAPI.getPlatform(); + cachedPlatform = platform; + return platform; + } catch (error) { + console.warn("Failed to get platform from Electron, falling back to navigator:", error); + const fallbackPlatform = getNavigatorFallbackPlatform(); + cachedPlatform = fallbackPlatform; + return fallbackPlatform; + } +}; + +/** + * Detects if the current platform is macOS + */ +export const isMac = async (): Promise<boolean> => { + const platform = await getPlatform(); + return platform === "darwin"; +}; + +/** + * Gets the modifier key symbol based on the platform + */ +export const getModifierKey = async (): Promise<string> => { + return (await isMac()) ? "⌘" : "Ctrl"; +}; + +/** + * Gets the shift key symbol based on the platform + */ +export const getShiftKey = async (): Promise<string> => { + return (await isMac()) ? "⇧" : "Shift"; +}; + +/** + * Formats a keyboard shortcut for display based on the platform + * @param keys Array of key combinations (e.g., ['mod', 'D'] or ['shift', 'mod', 'Scroll']) + */ +export const formatShortcut = async (keys: string[]): Promise<string> => { + const isMacPlatform = await isMac(); + return keys + .map((key) => { + if (key.toLowerCase() === "mod") return isMacPlatform ? "⌘" : "Ctrl"; + if (key.toLowerCase() === "shift") return isMacPlatform ? "⇧" : "Shift"; + return key; + }) + .join(" + "); +}; diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index c5ec8ecb4..b7a073568 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -1,50 +1,2 @@ -/// <reference types="vite/client" /> -/// <reference types="../electron/electron-env" /> - -interface ProcessedDesktopSource { - id: string; - name: string; - display_id: string; - thumbnail: string | null; - appIcon: string | null; -} - -interface Window { - electronAPI: { - getSources: (opts: Electron.SourcesOptions) => Promise<ProcessedDesktopSource[]> - switchToEditor: () => Promise<void> - openSourceSelector: () => Promise<void> - selectSource: (source: any) => Promise<any> - getSelectedSource: () => Promise<any> - startMouseTracking: () => Promise<{ success: boolean; startTime?: number }> - stopMouseTracking: () => Promise<{ success: boolean; data?: any }> - storeRecordedVideo: (videoData: ArrayBuffer, fileName: string) => Promise<{ - success: boolean - path?: string - message: string - error?: string - }> - storeMouseTrackingData: (fileName: string) => Promise<{ - success: boolean - path?: string - eventCount?: number - message: string - error?: string - }> - getRecordedVideoPath: () => Promise<{ - success: boolean - path?: string - message?: string - error?: string - }> - getAssetBasePath: () => Promise<string | null> - setRecordingState: (recording: boolean) => Promise<void> - onStopRecordingFromTray: (callback: () => void) => () => void - openExternalUrl: (url: string) => Promise<{ success: boolean; error?: string }> - saveExportedVideo: (videoData: ArrayBuffer, fileName: string) => Promise<{ - success: boolean - path?: string - message?: string - }> - } -} \ No newline at end of file +/// <reference types="vite/client" /> +/// <reference types="../electron/electron-env" /> diff --git a/still on this.md b/still on this.md new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/still on this.md @@ -0,0 +1 @@ + diff --git a/tailwind.config.cjs b/tailwind.config.cjs index 126e9e2d9..1a6e32804 100644 --- a/tailwind.config.cjs +++ b/tailwind.config.cjs @@ -1,60 +1,98 @@ /** @type {import('tailwindcss').Config} */ module.exports = { - darkMode: ['class'], - content: [ - './index.html', - './src/**/*.{js,ts,jsx,tsx}', - ], - theme: { - extend: { - borderRadius: { - lg: 'var(--radius)', - md: 'calc(var(--radius) - 2px)', - sm: 'calc(var(--radius) - 4px)' - }, - colors: { - background: 'hsl(var(--background))', - foreground: 'hsl(var(--foreground))', - card: { - DEFAULT: 'hsl(var(--card))', - foreground: 'hsl(var(--card-foreground))' - }, - popover: { - DEFAULT: 'hsl(var(--popover))', - foreground: 'hsl(var(--popover-foreground))' - }, - primary: { - DEFAULT: 'hsl(var(--primary))', - foreground: 'hsl(var(--primary-foreground))' - }, - secondary: { - DEFAULT: 'hsl(var(--secondary))', - foreground: 'hsl(var(--secondary-foreground))' - }, - muted: { - DEFAULT: 'hsl(var(--muted))', - foreground: 'hsl(var(--muted-foreground))' - }, - accent: { - DEFAULT: 'hsl(var(--accent))', - foreground: 'hsl(var(--accent-foreground))' - }, - destructive: { - DEFAULT: 'hsl(var(--destructive))', - foreground: 'hsl(var(--destructive-foreground))' - }, - border: 'hsl(var(--border))', - input: 'hsl(var(--input))', - ring: 'hsl(var(--ring))', - chart: { - '1': 'hsl(var(--chart-1))', - '2': 'hsl(var(--chart-2))', - '3': 'hsl(var(--chart-3))', - '4': 'hsl(var(--chart-4))', - '5': 'hsl(var(--chart-5))' - } - } - } - }, - plugins: [require("tailwindcss-animate")], -} + darkMode: ["class"], + content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"], + theme: { + extend: { + keyframes: { + "accordion-down": { + from: { height: "0" }, + to: { height: "var(--radix-accordion-content-height)" }, + }, + "accordion-up": { + from: { height: "var(--radix-accordion-content-height)" }, + to: { height: "0" }, + }, + shimmer: { + "100%": { + transform: "translateX(100%)", + }, + }, + "text-shimmer": { + "0%, 100%": { + "background-size": "200% 200%", + "background-position": "left center", + }, + "50%": { + "background-size": "200% 200%", + "background-position": "right center", + }, + }, + }, + animation: { + "accordion-down": "accordion-down 0.2s ease-out", + "accordion-up": "accordion-up 0.2s ease-out", + shimmer: "shimmer 2s infinite", + "text-shimmer": "text-shimmer 2.5s ease-out infinite", + }, + borderRadius: { + lg: "var(--radius)", + md: "calc(var(--radius) - 2px)", + sm: "calc(var(--radius) - 4px)", + }, + colors: { + background: "hsl(var(--background))", + foreground: "hsl(var(--foreground))", + card: { + DEFAULT: "hsl(var(--card))", + foreground: "hsl(var(--card-foreground))", + }, + popover: { + DEFAULT: "hsl(var(--popover))", + foreground: "hsl(var(--popover-foreground))", + }, + primary: { + DEFAULT: "hsl(var(--primary))", + foreground: "hsl(var(--primary-foreground))", + }, + secondary: { + DEFAULT: "hsl(var(--secondary))", + foreground: "hsl(var(--secondary-foreground))", + }, + muted: { + DEFAULT: "hsl(var(--muted))", + foreground: "hsl(var(--muted-foreground))", + }, + accent: { + DEFAULT: "hsl(var(--accent))", + foreground: "hsl(var(--accent-foreground))", + }, + destructive: { + DEFAULT: "hsl(var(--destructive))", + foreground: "hsl(var(--destructive-foreground))", + }, + border: "hsl(var(--border))", + input: "hsl(var(--input))", + ring: "hsl(var(--ring))", + chart: { + 1: "hsl(var(--chart-1))", + 2: "hsl(var(--chart-2))", + 3: "hsl(var(--chart-3))", + 4: "hsl(var(--chart-4))", + 5: "hsl(var(--chart-5))", + }, + "editor-bg": "hsl(var(--editor-bg))", + "editor-header": "hsl(var(--editor-header))", + "editor-panel": "hsl(var(--editor-panel))", + "editor-surface": "hsl(var(--editor-surface))", + "editor-surface-alt": "hsl(var(--editor-surface-alt))", + "editor-dialog": "hsl(var(--editor-dialog))", + "editor-dialog-alt": "hsl(var(--editor-dialog-alt))", + "editor-timeline": "hsl(var(--editor-timeline))", + "editor-row": "hsl(var(--editor-row))", + "editor-subrow": "hsl(var(--editor-subrow))", + }, + }, + }, + plugins: [require("tailwindcss-animate")], +}; diff --git a/tsconfig.json b/tsconfig.json index 8a8fb05ce..9383adf28 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,29 +1,29 @@ -{ - "compilerOptions": { - "target": "ES2020", - "useDefineForClassFields": true, - "lib": ["ES2020", "DOM", "DOM.Iterable"], - "module": "ESNext", - "skipLibCheck": true, - "baseUrl": ".", - "paths": { - "@/*": ["src/*"] - }, - - /* Bundler mode */ - "moduleResolution": "bundler", - "allowImportingTsExtensions": true, - "resolveJsonModule": true, - "isolatedModules": true, - "noEmit": true, - "jsx": "react-jsx", - - /* Linting */ - "strict": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "noFallthroughCasesInSwitch": true - }, - "include": ["src", "electron"], - "references": [{ "path": "./tsconfig.node.json" }] -} +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "paths": { + "@/*": ["./src/*"] + }, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src", "electron"], + "exclude": ["**/*.test.ts", "**/*.test.tsx"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/tsconfig.node.json b/tsconfig.node.json index b85058209..1caabefce 100644 --- a/tsconfig.node.json +++ b/tsconfig.node.json @@ -1,11 +1,11 @@ -{ - "compilerOptions": { - "composite": true, - "skipLibCheck": true, - "module": "ESNext", - "moduleResolution": "bundler", - "allowSyntheticDefaultImports": true, - "strict": true - }, - "include": ["vite.config.ts"] -} +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true, + "strict": true + }, + "include": ["vite.config.ts"] +} diff --git a/vite.config.ts b/vite.config.ts index 1bd814d38..3dd36633d 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,42 +1,137 @@ -import { defineConfig } from 'vite' -import path from 'node:path' -import electron from 'vite-plugin-electron/simple' -import react from '@vitejs/plugin-react' +import { spawnSync } from "node:child_process"; +import path from "node:path"; +import react from "@vitejs/plugin-react"; +import { defineConfig, type Plugin } from "vite"; +import electron from "vite-plugin-electron/simple"; +function electronMainCjsOutputPlugin(): Plugin { + return { + name: "recordly-electron-main-cjs-output", + enforce: "post", + config(config) { + // Vite mergeConfig concatenates lib.formats with the plugin's ESM default. + config.build ??= {}; + const build = config.build; + const lib = build.lib; + if (lib && typeof lib === "object") { + lib.formats = ["cjs"]; + lib.fileName = (_format, entryName) => `${entryName}.cjs`; + } + + build.rollupOptions ??= {}; + const rollupOptions = build.rollupOptions; + const cjsOutput = { + format: "cjs" as const, + inlineDynamicImports: true, + entryFileNames: "[name].cjs", + chunkFileNames: "[name]-[hash].cjs", + }; + + rollupOptions.output = Array.isArray(rollupOptions.output) + ? rollupOptions.output.map((output) => ({ ...output, ...cjsOutput })) + : { ...(rollupOptions.output ?? {}), ...cjsOutput }; + }, + }; +} + +function electronMainCjsGuardPlugin(): Plugin { + return { + name: "recordly-electron-main-cjs-guard", + closeBundle() { + const scriptPath = path.resolve(__dirname, "scripts/smoke-electron-main-cjs.mjs"); + const result = spawnSync(process.execPath, [scriptPath], { + cwd: __dirname, + encoding: "utf8", + }); + + if (result.status !== 0) { + const details = [result.stdout, result.stderr].filter(Boolean).join("\n"); + throw new Error( + `Electron main CJS smoke failed after Vite build.${details ? `\n${details}` : ""}`, + ); + } + }, + }; +} // https://vitejs.dev/config/ export default defineConfig({ - plugins: [ - react(), - electron({ - main: { - // Shortcut of `build.lib.entry`. - entry: 'electron/main.ts', - vite: { - build: { - rollupOptions: { - external: ['uiohook-napi'] - } - } - } - }, - preload: { - // Shortcut of `build.rollupOptions.input`. - // Preload scripts may contain Web assets, so use the `build.rollupOptions.input` instead `build.lib.entry`. - input: path.join(__dirname, 'electron/preload.ts'), - }, - // Ployfill the Electron and Node.js API for Renderer process. - // If you want use Node.js in Renderer process, the `nodeIntegration` needs to be enabled in the Main process. - // See https://github.com/electron-vite/vite-plugin-electron-renderer - renderer: process.env.NODE_ENV === 'test' - // https://github.com/electron-vite/vite-plugin-electron-renderer/issues/78#issuecomment-2053600808 - ? undefined - : {}, - }), - ], - resolve: { - alias: { - '@': path.resolve(__dirname, 'src'), - }, - }, -}) + plugins: [ + react(), + electron({ + main: { + // Shortcut of `build.lib.entry`. + entry: "electron/main.ts", + vite: { + build: { + lib: { + entry: "electron/main.ts", + formats: ["cjs"], + fileName: (_format, entryName) => `${entryName}.cjs`, + }, + rollupOptions: { + external: ["ffmpeg-static", "uiohook-napi"], + output: { + format: "cjs", + inlineDynamicImports: true, + entryFileNames: "[name].cjs", + chunkFileNames: "[name]-[hash].cjs", + }, + }, + }, + plugins: [electronMainCjsOutputPlugin(), electronMainCjsGuardPlugin()], + }, + }, + preload: { + // Shortcut of `build.rollupOptions.input`. + // Preload scripts may contain Web assets, so use the `build.rollupOptions.input` instead `build.lib.entry`. + input: path.join(__dirname, "electron/preload.ts"), + }, + // Polyfill the Electron and Node.js API for the renderer process. + // If you want to use Node.js in the renderer process, enable `nodeIntegration` in the main process. + // See https://github.com/electron-vite/vite-plugin-electron-renderer + renderer: + process.env.NODE_ENV === "test" + ? // https://github.com/electron-vite/vite-plugin-electron-renderer/issues/78#issuecomment-2053600808 + undefined + : {}, + }), + ], + resolve: { + alias: { + "@": path.resolve(__dirname, "src"), + }, + }, + optimizeDeps: { + entries: ["index.html"], + exclude: [ + "react-icons/bs", + "react-icons/fa", + "react-icons/fa6", + "react-icons/fi", + "react-icons/md", + "react-icons/rx", + ], + }, + build: { + target: "esnext", + minify: "terser", + terserOptions: { + compress: { + drop_console: true, + drop_debugger: true, + pure_funcs: ["console.log", "console.debug"], + }, + }, + rollupOptions: { + output: { + manualChunks: { + pixi: ["pixi.js"], + "react-vendor": ["react", "react-dom"], + "video-processing": ["mediabunny", "mp4box", "@fix-webm-duration/fix"], + }, + }, + }, + chunkSizeWarningLimit: 1000, + }, +}); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 000000000..0ff0492ff --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,18 @@ +import path from "node:path"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + globals: true, + environment: "node", + include: [ + "src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}", + "electron/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}", + ], + }, + resolve: { + alias: { + "@": path.resolve(__dirname, "src"), + }, + }, +});