OpenScreen is your free, open-source alternative to Screen Studio (sort of).
+
OpenScreen is a free, open-source tool for creating polished screen recordings, product demos, and walkthroughs.
+
+
+
+
+
+
+
+
+
+
+OpenScreen was originally positioned as a free, open-source alternative to Screen Studio: something you can use to create quick, polished product demos and walkthroughs for X, Reddit, YouTube, documentation, landing pages, or internal demos.
-If you don't want to pay $29/month for Screen Studio but want a much simpler version that does what most people seem to need - quick, polished product demos and walkthroughs you'd post on X, Reddit. OpenScreen does not offer all Screen Studio features, but covers the basics well!
+It is not a 1:1 clone of Screen Studio. Screen Studio is an excellent commercial product. OpenScreen focuses on covering the core open-source workflow: recording, zooms, cursor effects, webcam overlay, captions, editing, annotations, and export.
-Screen Studio is an awesome product and this is definitely not a 1:1 clone. OpenScreen is a much simpler take, just the basics for folks who want control and don't want to pay. If you need all the fancy features, your best bet is to support Screen Studio (they really do a great job, haha). But if you just want something free (no gotchas) and open, this project does the job!
+The goal of this continuation is to keep OpenScreen alive as a fully open-source project and progressively evolve it toward a broader recording and editing workflow.
-**100% free** for both **personal** and **commercial** use. Use it, modify it, distribute it — just be cool 😁 and shout out the project if you feel like it.
+**100% free** for both **personal** and **commercial** use. Use it, modify it, distribute it. Please respect the license.
+
+> [!NOTE]
+> Software should be accessible. OpenScreen has no paid tiers, premium features, upsells, or functionality locked behind a paywall.
-
-
+
+
## Core Features
-- Record a specific window, region, or your whole screen.
+- Record a specific window, or your whole screen.
- Record microphone and system audio.
-- Webcam overlay with picture-in-picture, drag-to-position, and shape options.
-- Auto or manual zooms with adjustable depth, duration, easing, and pixel-precise position.
-- Wallpapers, solid colors, gradients, or a custom background.
-- Motion blur for smoother pan and zoom transitions.
+- Webcam overlay with picture-in-picture, drag-to-position, mirroring, and shape options.
+- Auto or manual zooms with adjustable depth, duration, easing, and pixel-precise position; auto-zoom follows your cursor as you work.
+- Custom cursor size, smoothing, and click effects, with cursor themes and post-recording path smoothing.
+- Automatic captions for voiceovers, generated on-device with no upload (works offline).
+- Wallpapers, solid colors, gradients, or your own background image.
+- Motion blur.
- Crop, trim, and per-segment speed control on the timeline.
-- Blur effects to hide sensitive parts of the screen.
-- Cursor and click highlighting.
-- Text, arrow, and image annotations.
-- Save and reopen projects without re-recording.
+- Text, arrow, and image annotations, with text animation presets.
+- Timeline snapping guides and an audio waveform to make trimming easier.
+- Customizable keyboard shortcuts.
- Export to MP4 or GIF in multiple aspect ratios and resolutions.
-- Translated into Arabic, English, Spanish, French, Japanese, Korean, Russian, Turkish, Vietnamese, Simplified Chinese, and Traditional Chinese.
+- Languages supported: Arabic, English, Spanish, French, Italian, Japanese, Korean, Portuguese (Brazil), Russian, Turkish, Vietnamese, Simplified Chinese, and Traditional Chinese.
+
## Installation
-Download the latest installer for your platform from the [GitHub Releases](https://github.com/siddharthvaddem/openscreen/releases) page.
+Download the latest installer for your platform from the [GitHub Releases](https://github.com/EtienneLescot/openscreen/releases) page.
### macOS
-The easiest way to install on macOS is via [Homebrew](https://brew.sh):
-
-```bash
-brew install --cask siddharthvaddem/openscreen/openscreen
-```
-
-Brew automatically picks the right build for Apple Silicon or Intel, and verifies the download against a notarized signature so Gatekeeper won't block it.
-
-To update later: `brew upgrade --cask openscreen`
-To uninstall: `brew uninstall --cask openscreen` (add `--zap` to also remove app data)
-
-#### Manual install (if you prefer)
-
-If you'd rather grab the `.dmg` directly from the [Releases page](https://github.com/siddharthvaddem/openscreen/releases) and encounter Gatekeeper blocking the app, you can bypass it by running the following command in your terminal after installation:
+Download the `.dmg` installer directly from the [Releases page](https://github.com/EtienneLescot/openscreen/releases). If Gatekeeper blocks the app, you can bypass it by running the following command in your terminal after installation:
```bash
xattr -rd com.apple.quarantine /Applications/Openscreen.app
@@ -74,24 +72,18 @@ xattr -rd com.apple.quarantine /Applications/Openscreen.app
Note: Give your terminal Full Disk Access in **System Settings > Privacy & Security** to grant you access and then run the above command.
-After running this command, proceed to **System Preferences > Security & Privacy** to grant the necessary permissions for "screen recording" and "accessibility". Once permissions are granted, you can launch the app.
+After running this command, proceed to **System Settings > Privacy & Security** to grant the necessary permissions for "screen recording" and "accessibility". Once permissions are granted, you can launch the app.
-### Windows
-
-Install via [winget](https://learn.microsoft.com/en-us/windows/package-manager/winget/):
+> [!NOTE]
+> **Upgrading from an older version and hitting permission issues?** If you already had OpenScreen installed and the new version won't record (Screen Recording or Accessibility keep failing even after you grant them), uninstall the old version, remove OpenScreen's existing entries under **System Settings > Privacy & Security** (both Screen Recording and Accessibility), then do a fresh install and grant the permissions again when prompted.
-```bash
-winget install SiddharthVaddem.OpenScreen
-```
-
-To update later: `winget upgrade SiddharthVaddem.OpenScreen`
-To uninstall: `winget uninstall SiddharthVaddem.OpenScreen`
+### Windows
-If you'd rather grab the `.exe` installer directly, download it from the [Releases page](https://github.com/siddharthvaddem/openscreen/releases).
+Download the `.exe` installer directly from the [Releases page](https://github.com/EtienneLescot/openscreen/releases).
### Linux
-Three packages are published to the [Releases page](https://github.com/siddharthvaddem/openscreen/releases) for each version. Pick the one that matches your distro:
+Three packages are published to the [Releases page](https://github.com/EtienneLescot/openscreen/releases) for each version. Pick the one that matches your distro:
**Debian / Ubuntu / Pop!_OS (`.deb`)**
```bash
@@ -113,18 +105,18 @@ chmod +x Openscreen-Linux-*.AppImage
Try without installing:
```bash
-nix run github:siddharthvaddem/openscreen
+nix run github:EtienneLescot/openscreen
```
Install into your user profile:
```bash
-nix profile install github:siddharthvaddem/openscreen
+nix profile install github:EtienneLescot/openscreen
```
For a NixOS system config (flake):
```nix
{
- inputs.openscreen.url = "github:siddharthvaddem/openscreen";
+ inputs.openscreen.url = "github:EtienneLescot/openscreen";
outputs = { nixpkgs, openscreen, ... }: {
nixosConfigurations. = nixpkgs.lib.nixosSystem {
@@ -146,44 +138,38 @@ You may need to grant screen recording permissions depending on your desktop env
./Openscreen-Linux-*.AppImage --no-sandbox
```
-### Limitations
+### Platform differences
-System audio capture relies on Electron's [desktopCapturer](https://www.electronjs.org/docs/latest/api/desktop-capturer) and has some platform-specific quirks:
+Everything in the editor and export is the same on macOS, Windows, and Linux: zooms, backgrounds, motion blur, crop/trim/speed, blur regions, annotations, auto-captions, projects, export, and all languages. The differences are in **capture**, where macOS and Windows use a native pipeline that Linux doesn't have:
-- **macOS**: Requires macOS 13+. On macOS 14.2+ you'll be prompted to grant audio capture permission. macOS 12 and below does not support system audio (mic still works).
-- **Windows**: Works out of the box.
-- **Linux**: Needs PipeWire (default on Ubuntu 22.04+, Fedora 34+). Older PulseAudio-only setups may not support system audio (mic should still work).
+- **Native recording**: macOS (ScreenCaptureKit) and Windows (Windows Graphics Capture) record through a native pipeline for higher quality and clean window-level capture. Linux records through the browser pipeline instead.
+- **Custom cursors**: on macOS and Windows the real cursor is captured (shape, type, and clicks), which powers the cursor themes, click effects, and editable cursor overlay. On Linux only the cursor position is captured (used for auto-zoom), so those cursor options aren't available.
+- **Webcam**: captured natively on macOS and Windows; on Linux it's recorded through the browser, but still works as a picture-in-picture overlay.
+- **System audio** support varies by OS:
+ - **macOS**: requires macOS 13+. On macOS 14.2+ you'll be prompted to grant audio capture permission. macOS 12 and below can't capture system audio (mic still works).
+ - **Windows**: works out of the box.
+ - **Linux**: needs PipeWire (default on Ubuntu 22.04+, Fedora 34+). Older PulseAudio-only setups may not capture system audio (mic should still work).
-## Built with
-- Electron
-- React
-- TypeScript
-- Vite
-- PixiJS
-- dnd-timeline
+## Official links
----
+This repository is the community-maintained continuation of OpenScreen.
+Official / trusted links:
-## Documentation
+* Original archived repository: https://github.com/siddharthvaddem/openscreen
+* Community continuation: https://github.com/EtienneLescot/openscreen
-See the documentation here:
-[OpenScreen Docs](https://deepwiki.com/siddharthvaddem/openscreen)
-Refresh if outdated.
+For safety, download OpenScreen only from the official GitHub Releases linked from this repository. Third-party websites using the OpenScreen name are not affiliated with this continuation unless explicitly listed here.
-## Contributing
+## Community
-Contributions are welcome - please **include screenshots or a short video** for any UI change or new user-facing feature. If it touches what users see or do, show it. Skip only when it genuinely doesn't apply. PRs that don't follow this will be closed.
+OpenScreen is community-driven. If you need help, want to report a bug, or just want to chat with other users and contributors:
-## Star History
+- 💬 **Discord** — [Join the OpenScreen Discord](https://discord.gg/VvT6Vtnyh) for real-time help, showcase, and discussion
+- 🐞 **[GitHub Issues](https://github.com/EtienneLescot/openscreen/issues)** — bug reports and feature requests
+- 🗺️ **[Roadmap](./ROADMAP.md)** — see what we're building next
-
-
-
-
-
-
-
+---
## License
diff --git a/ROADMAP.md b/ROADMAP.md
new file mode 100644
index 0000000000..85b827ed53
--- /dev/null
+++ b/ROADMAP.md
@@ -0,0 +1,64 @@
+# OpenScreen Roadmap
+The recorder you love, with an optional AI sidekick. Same sleek, low-friction recorder UX. An opt-in AI editing layer is on the way for users who want it — never required, never snuck in.
+
+This roadmap is the source of truth for what we're shipping next in OpenScreen. It is a living document — items move between tiers as work lands. Have an idea, a vote, or a dissenting opinion? Drop into the 🗺️・roadmap channel on our Discord or open a GitHub issue with the `roadmap` label.
+
+## 🧭 North Star
+**Record → Edit → Export.** (with an optional AI shortcut for users who want one)
+
+OpenScreen is, first and foremost, a polished screen recorder. Record, trim on the timeline, export. Most users will keep using exactly this workflow.
+
+We're also exploring an optional AI editing layer — for users who want to edit by talking or by editing a transcript. It's opt-in, off by default, and never required. If you don't enable it, the AI layer doesn't exist for your install: nothing downloads, nothing leaves your machine, no LLM is contacted.
+
+Three axes guide every decision on this roadmap:
+
+- **Stability first** — the recorder must work reliably on macOS, Windows, and Linux. Bugs found by real users ship before new features.
+- **Sleek UX stays** — every AI feature must keep the OpenScreen feel: minimal clicks, instant feedback, no clutter.
+- **100% free, forever** — no paywalls, no premium tier, no usage caps. Every feature on this page ships under MIT.
+
+## 🤖 Direction — the optional AI Edition
+A Screen Studio + Descript clone, open-source and free forever. The recorder-first UX stays intact, and the AI layer sits beside it, off by default.
+
+Capabilities we're exploring (each one opt-in, each one toggleable independently):
+
+- **Local Whisper transcription (opt-in, on-device)** — OpenScreen already ships on-device Whisper transcription for automatic captions. This extends that foundation: the same local transcript powers the editing features below, with no upload, no cloud, no extra setup required.
+- **Transcript-driven editing (opt-in, local)** — edit video like a doc (Descript-style: delete a word, cut the span). Works with the local transcript; no cloud needed.
+- **One-click cleanup (opt-in, local)** — filler-word removal, silence trimming, Studio Sound voice enhancement. All on-device.
+- **Edit by chat (opt-in, requires BYO LLM key)** — say "cut the part where I repeat myself between 0:42 and 1:10" and the agent applies a structured timeline operation. Off until you connect a provider.
+- **Non-destructive project document (always on)** — every edit, AI or manual, is undoable; the timeline is always recoverable.
+- **Bring-your-own LLM (opt-in)** — OpenAI, Anthropic, Google, Mistral, OpenRouter, GitHub Copilot, OpenAI-compatible endpoints, ChatGPT account auth. You choose; we never see your keys or your data.
+
+This section is a direction, not a sprint plan. Concrete items land here as RFCs once the recorder is stable enough to build on top of.
+
+## 🛠️ Stability & quality (what we're actually shipping)
+Pulled from real user bug reports on getopenscreen/openscreen. This is the queue for the next release window.
+
+- [ ] **Fix:** video disappears from editor after export — [#8](../../issues/8) (Linux, Manjaro). Renderer regression after export.
+- [ ] **Fix:** crash after stopping macOS recording — [#21](../../issues/21) (macOS 26.4.1, Apple Silicon). Crash is in the Electron / Node async fs shutdown path; recording artifacts are written correctly.
+- [ ] **Fix:** macOS cursor offset in single-window capture — [#22](../../issues/22).
+- [ ] **Fix:** recover preview from WebGL context loss on Linux / Wayland — [#19](../../issues/19).
+- [ ] **Feature:** software H.264 fallback when no GPU encoder MFT is available — [#18](../../issues/18). Critical for VMs, broken-driver machines, and headless environments.
+- [ ] **Feature:** copy / paste attributes & effects in the timeline — [#24](../../issues/24). Right-click menu + standard Ctrl/Cmd+C / Ctrl/Cmd+V shortcuts.
+- [ ] **Feature:** restore blur regions (rectangle / oval / freehand, mosaic + CSS) — [#76](../../issues/76). Upstream v1.5.0 dropped the feature in the final release before archiving. The renderer pipeline (`BlurSettingsPanel`, `blurEffects`, `annotationRenderer`) is already present in this fork; the export guard in `src/lib/exporter/videoExporter.ts:151-152` (refuses export while `showBlur` or `motionBlurAmount` is set) is what needs to be unblocked, plus a regression test in the timeline.
+
+## 📚 Site & documentation
+- [ ] **Feature:** Docusaurus site — landing + docs, deployed to GitHub Pages via CI.
+ - Monorepo at `website/`. Versioning off until v2.
+ - Landing (pitch, demo, quick start, downloads) + migrate `docs/` → `website/docs/`.
+ - Bespoke theme (TBD).
+ - CI: build on PR (artifact preview), deploy to Pages on `main`. Custom domain as follow-up.
+ - MIT, no tracking, no paywall — same posture as the app.
+
+## 📬 How to influence this roadmap
+- **Discord** — join the OpenScreen Discord and post in [#🗺️・roadmap](https://discord.com/channels/1489517664467681310/1493586210675884265). The fastest way to get a thumbs-up or thumbs-down on a feature.
+- **GitHub** — open an issue with the `enhancement` label, or react with 👍 / 👎 on existing items.
+- **PRs** — if you want to ship one of these, open a PR and link the relevant issue. We review fast and help with native-bridge / i18n questions.
+
+Anything not on this list yet? Open an issue and tag it `roadmap` — we'll triage it into a tier within a week.
+
+---
+
+## Changelog
+- **2026-06-24** — initial draft. Stability items pulled from open issues / PRs on getopenscreen/openscreen. AI section presented as opt-in / off by default. Whisper entry updated to reflect existing caption feature.
+- **2026-06-25** — added "Site & documentation" tier: Docusaurus + GitHub Pages. Cleaned smoke-test noise from the changelog (internal CI sync validation, not user-facing).
+- **2026-07-06** — added blur regions to the stability & quality tier. Confirmed upstream deprecated the feature in v1.5.0 without an explicit reason; the renderer code carried over to the fork, so the work is unblocking the export guard + adding coverage. Tracked via #76.
\ No newline at end of file
diff --git a/docs/github-actions-workflows.md b/docs/github-actions-workflows.md
new file mode 100644
index 0000000000..e572940f51
--- /dev/null
+++ b/docs/github-actions-workflows.md
@@ -0,0 +1,261 @@
+# GitHub Actions workflows
+
+## Overview
+
+The repository uses 14 workflow files across five functional tiers. This document describes the triggers, job dependencies, and artifact flow for each tier.
+
+## Workflow dependency graph
+
+```mermaid
+graph TD
+ subgraph Tier 1 - CI
+ ci[ci.yml push / PR → main]
+ ci_lint[lint]
+ ci_typecheck[typecheck]
+ ci_test[test]
+ ci_build[build]
+ ci_semantic[semantic-pr]
+ ci --> ci_lint
+ ci --> ci_typecheck
+ ci --> ci_test
+ ci --> ci_build
+ ci --> ci_semantic
+ end
+
+ subgraph Tier 2 - Release build
+ build[build.yml tag v* / dispatch]
+ build_win[build-windows]
+ build_mac[build-macos matrix arm64 x64]
+ build_linux[build-linux]
+ build_release[publish-release]
+ build --> build_win
+ build --> build_mac
+ build --> build_linux
+ build_win --> build_release
+ build_mac --> build_release
+ build_linux --> build_release
+ end
+
+ subgraph Tier 2.5 - Release management
+ prerelease[prerelease.yml dispatch]
+ promote[promote.yml dispatch]
+ prerelease -->|push tag vX.Y.Z-rc.N| build
+ promote -->|push tag vX.Y.Z| build
+ end
+
+ subgraph Tier 3 - Package registries
+ homebrew[update-homebrew-cask.yml release published]
+ winget[publish-winget.yml release published]
+ nix[bump-nix-package.yml release published]
+ aur[aur-publish.yml release published]
+ end
+
+ subgraph Tier 4 - Automation
+ discord_pr[discord-pr-notify.yml PR events, review, comment]
+ discord_roadmap[discord-roadmap-sync.yml push to main]
+ discord_leaderboard[discord-weekly-leaderboard.yml schedule mon 12:00 UTC]
+ bookkeeping[merged-pr-bookkeeping.yml PR closed merged]
+ diag[diagnostic-artifact.yml push / PR / dispatch]
+ diag_win[build-windows]
+ diag_mac[build-macos matrix arm64 x64]
+ diag --> diag_win
+ diag --> diag_mac
+ end
+
+ build_release -->|PAT: gh release create| homebrew
+ build_release -->|PAT: gh release create| winget
+ build_release -->|PAT: gh release create| nix
+ build_release -->|PAT: gh release create| aur
+ promote -->|if: success| discord_announce_rc[discord-release-announce.mjs #rc-testing]
+ prerelease -->|if: success| discord_announce_stable[discord-release-announce.mjs #announcements]
+```
+
+> Note: the announce-edge arrows above are inverted for readability — `prerelease.yml` posts to `#rc-testing`, and `promote.yml` posts to `#announcements`.
+
+## Tier 1: CI checks
+
+**File:** `ci.yml`
+
+Triggered on every push to `main` and every pull request targeting `main`. Four parallel, independent jobs with no interdependencies:
+
+| Job | Runner | Purpose |
+|---|---|---|
+| `lint` | ubuntu-latest | Biome check |
+| `typecheck` | ubuntu-latest | `tsc --noEmit` |
+| `test` | ubuntu-latest | Vitest unit tests + Playwright browser tests |
+| `build` | ubuntu-latest | `vite build` (renderer-only, no electron-builder) |
+
+All jobs use the shared composite action `.github/actions/setup` for Node.js installation and `npm ci`. Failure of one job does not cancel the others.
+
+## Tier 2: Release build and publish
+
+### build.yml
+
+Triggered by version tags (`v*`) or manual `workflow_dispatch` (with optional macOS architecture selection and release tag override).
+
+**Jobs:**
+
+1. **`build-windows`** (windows-latest): Compiles NSIS installer via `electron-builder --win`. Uploads artifact `openscreen-windows` (30-day retention).
+
+2. **`build-macos`** (macos-latest, matrix `arm64` / `x64`): Compiles native helpers, runs `tsc && vite build`, builds `.app` bundle, creates and signs a DMG. Uploads artifacts `openscreen-mac-arm64` and `openscreen-mac-x64` (30-day retention). Signing and notarization are conditional on the presence of Apple developer secrets (`MAC_CERTIFICATE_P12`, `APPLE_ID`, etc.). Without secrets, produces an unsigned DMG.
+
+3. **`build-linux`** (ubuntu-latest): Installs `libarchive-tools` for `.pacman` support, runs `electron-builder --linux AppImage deb pacman`. Uploads artifact `openscreen-linux` (30-day retention).
+
+4. **`publish-release`** (ubuntu-latest, needs all three build jobs): Downloads all four artifacts by explicit name, validates that `package.json` version matches the tag, and publishes them to a GitHub Release via `gh release create` or `gh release upload --clobber`. The download step uses explicit `name:` parameters to fail fast on missing artifacts rather than silently skipping them.
+
+All three build jobs use a shared caption-assets cache keyed by `runner.os` and the hash of `scripts/fetch-caption-model.mjs` to avoid cross-platform cache collisions.
+
+## Tier 2.5: Release management
+
+Two `workflow_dispatch` workflows manage the release cycle. Both run on `main` and require the `OPENSCREEN_RELEASE_TOKEN` secret.
+
+### prerelease.yml
+
+Triggered manually to cut a release candidate.
+
+**Inputs:** `bump` (`patch|minor|major`, default `minor`), `rc_number` (default `1`), `target_version` (optional override).
+
+**Steps:**
+1. Checkout, setup Node.
+2. Compute next SemVer from `package.json` + `bump`, derive `vX.Y.Z-rc.N` tag.
+3. **Migrate** all items from the rolling `Next Release` milestone into a fresh `vX.Y.Z` milestone (idempotent — each migrated item gets an HTML marker comment).
+4. Bump `package.json` to `X.Y.Z-rc.N` and commit on `main`.
+5. Push the `vX.Y.Z-rc.N` tag → triggers `build.yml`.
+6. Announce in `#rc-testing` on Discord via `discord-release-announce.mjs`.
+
+### promote.yml
+
+Triggered manually to promote an RC to a stable release.
+
+**Inputs:** `rc_tag` (e.g. `v1.5.0-rc.2`), `release_notes_extra` (optional).
+
+**Steps:**
+1. Validate the tag matches `^vX.Y.Z-(rc|beta|alpha)\.N$`; derive `X.Y.Z`.
+2. Close the `vX.Y.Z` milestone (snapshots the closed-issue list for release notes).
+3. Strip `-rc.N` from `package.json` and commit on `main`.
+4. Push the `vX.Y.Z` tag → triggers `build.yml` → publishes a stable release.
+5. Tier 3 fires automatically because the release was created with `OPENSCREEN_RELEASE_TOKEN` (which propagates the `release: published` event).
+6. Announce in `#announcements` on Discord with the release notes + closed-issue list.
+
+### Manual fallback
+
+```bash
+git tag v1.5.0-rc.1
+git push origin v1.5.0-rc.1
+
+# later
+git tag v1.5.0
+git push origin v1.5.0
+```
+
+This works because `build.yml` is triggered by any tag matching `v*`. It skips milestone migration and Discord announcements — useful for emergency cuts when the dispatch UI is unavailable.
+
+### Why a fine-grained PAT (`OPENSCREEN_RELEASE_TOKEN`)?
+
+`GITHUB_TOKEN` cannot trigger downstream workflows from the actions it performs. Specifically, `gh release create` using `GITHUB_TOKEN` does **not** fire the `release: published` event, so homebrew/winget/nix/aur would silently skip every release. The fine-grained PAT (scoped to `getopenscreen/openscreen` with `contents: write` + `issues: write`) is the standard fix. See `docs/secrets.md` for creation and rotation instructions.
+
+## Tier 3: Package registries
+
+These workflows react to `release: published` events and push the release to external package registries. Each also supports `workflow_dispatch` for manual re-runs.
+
+### update-homebrew-cask.yml
+
+Finds both `arm64` and `x64` DMG assets in the release, downloads them, computes SHA-256, generates a Ruby cask file, and pushes it to a separate Homebrew tap repository (`vars.HOMEBREW_TAP_OWNER` / `vars.HOMEBREW_TAP_REPO`).
+
+Before scanning for assets, a polling loop waits up to 12 minutes for DMGs to appear in the release, accounting for the Apple notarization delay.
+
+Conditional on `vars.HOMEBREW_TAP_OWNER`, `vars.HOMEBREW_TAP_REPO`, and `secrets.HOMEBREW_TAP_TOKEN`.
+
+### publish-winget.yml
+
+Delegates to `vedantmgoyal9/winget-releaser@v2`, which finds the Windows installer matching `Setup\..*\.exe$` and publishes a manifest to the WinGet Community Repository.
+
+Conditional on `vars.WINGET_IDENTIFIER` and `secrets.WINGET_ACC_TOKEN`.
+
+### bump-nix-package.yml
+
+Checks out `main`, installs Nix, runs `prefetch-npm-deps` on `package-lock.json` to compute the new `npmDepsHash`, patches `nix/package.nix` with `sed`, and opens a PR against `main` on branch `chore/bump-nix-{version}`.
+
+Conditional on non-prerelease releases.
+
+### aur-publish.yml
+
+Finds the `.pacman` asset in the release, computes SHA-256, clones the AUR repository via SSH, updates `PKGBUILD` and `.SRCINFO`, and pushes the updated package.
+
+Conditional on `vars.AUR_PACKAGE_NAME` and `secrets.AUR_SSH_PRIVATE_KEY`.
+
+## Tier 4: Automation and diagnostics
+
+### discord-pr-notify.yml
+
+Triggered by `pull_request_target` (opened, reopened, synchronize, edited, labeled, unlabeled, closed, converted_to_draft, ready_for_review), `pull_request_review` (submitted), and `issue_comment` (created).
+
+Runs `node .github/scripts/discord-pr-sync.mjs`, which creates or updates a Discord forum thread for each PR. Thread state is persisted via an HTML comment (``) in the PR body. Tag updates (draft, ready, changes requested, approved, merged, closed) are applied via the Discord API. The job is marked `continue-on-error: true` so that Discord failures never block the PR workflow.
+
+All Discord traffic goes through a single bot (`DISCORD_BOT_TOKEN`, secret). The script creates the forum thread via `POST /channels/{forumChannelId}/threads` and posts review/comment updates into the existing thread via `POST /channels/{threadId}/messages`. Required bot permissions on the PR forum channel: View Channel, Send Messages, Embed Links, **Create Public Threads** (initial thread), **Send Messages in Threads** (subsequent updates), Manage Threads (for tag/archive/lock). Optional failure alerts can be sent to a separate channel via `DISCORD_ALERT_CHANNEL_ID` (variable); unset to silence.
+
+### discord-roadmap-sync.yml
+
+Triggered on push to `main` and on merged PRs targeting `main`. Runs `node .github/scripts/discord-roadmap-sync.mjs`, which:
+
+- Detects whether `ROADMAP.md` changed in the event
+- Fetches the current `ROADMAP.md` from `main`
+- Updates (or creates and pins) a Discord message in the `#roadmap` channel
+- Uses the channel's pinned message as persistent state; self-heals if a moderator unpins it
+
+Requires `DISCORD_BOT_TOKEN` (secret) and `DISCORD_ROADMAP_CHANNEL_ID` (variable). `DISCORD_ROADMAP_MESSAGE_ID` (variable) is an optional escape hatch that bypasses the pin-based lookup.
+
+### discord-weekly-leaderboard.yml
+
+Triggered by schedule (Mondays at 12:00 UTC) and `workflow_dispatch`. Runs `node .github/scripts/discord-weekly-leaderboard.mjs`, which queries the GitHub Search API for merged PRs in the last 7 days, ranks contributors by PR count, and posts a top-10 leaderboard to the `#🌟・contributor-spotlight` channel via the same bot (`DISCORD_BOT_TOKEN`).
+
+Requires `DISCORD_SPOTLIGHT_CHANNEL_ID` (variable) and the bot to have View Channel + Send Messages + Embed Links on that channel.
+
+### merged-pr-bookkeeping.yml
+
+Triggered by `pull_request_target: closed` on merged PRs targeting `main`. Uses a GraphQL query (`closingIssuesReferences`) to find linked issues, then:
+
+- Adds labels `status: fixed in main` and `status: pending release`
+- Removes `status: in progress` and `status: needs triage`
+- Assigns the `Next Release` milestone (creates it if missing)
+- Closes the issue with `state_reason: completed`
+- Posts an idempotent comment with a marker comment
+
+### diagnostic-artifact.yml
+
+Triggered on push to `main`, PRs targeting `main`, and `workflow_dispatch`. Produces platform-specific diagnostic bundles for troubleshooting:
+
+- **`build-windows`** (windows-latest): Compiles the WGC capture helper via CMake, bundles it with diagnostic scripts into a ZIP, smoke-tests the bundle structure.
+- **`build-macos`** (macos-latest, matrix `arm64` / `x64`): Compiles the ScreenCaptureKit helper, bundles it with diagnostic scripts into a `.tar.gz`.
+
+Artifacts are retained for 14 days (shorter than release artifacts).
+
+## Shared infrastructure
+
+### Composite action: `.github/actions/setup`
+
+A single composite action used by all jobs that need Node.js:
+
+```yaml
+runs:
+ using: composite
+ steps:
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 22
+ cache: npm
+ - run: npm ci
+ shell: bash
+```
+
+When the Node.js version needs to change, only this one file is updated. The action does not include `actions/checkout`; callers manage their own checkout step to allow for custom `ref`, `repository`, or `fetch-depth` options.
+
+### Inline scripts: `.github/scripts/`
+
+Scripts previously embedded as `actions/github-script@v7` inline JavaScript blocks are now standalone `.mjs` files invoked via `node`. This allows:
+
+- Biome linting and formatting coverage in CI
+- TypeScript type-checking coverage in CI
+- Local execution and debugging outside of GitHub Actions
+
+The scripts import `@actions/core` and `@actions/github` (added to `devDependencies`) to access the same APIs (`core.info`, `core.warning`, `context`, `getOctokit`) that `actions/github-script@v7` provides as globals.
diff --git a/docs/secrets.md b/docs/secrets.md
new file mode 100644
index 0000000000..b10714d088
--- /dev/null
+++ b/docs/secrets.md
@@ -0,0 +1,153 @@
+# Secrets and tokens
+
+OpenScreen uses a small set of GitHub Actions secrets and repository variables. This file documents what each one does and how to create or rotate it.
+
+## Required for releases
+
+### `OPENSCREEN_RELEASE_TOKEN`
+
+A **fine-grained personal access token** used by the release pipeline (`build.yml#publish-release`, `prerelease.yml`, `promote.yml`) for the actions that `GITHUB_TOKEN` cannot perform reliably:
+
+- Creating a GitHub Release via `gh release create` such that the `release: published` event **does** fire downstream workflows (homebrew/winget/nix/aur). With `GITHUB_TOKEN`, the event is suppressed to prevent recursive workflow runs.
+- Pushing commits and tags to `main` from `prerelease.yml` and `promote.yml` in a way that can later trigger downstream CI.
+- Closing milestones and posting comments during the issue-migration step.
+
+**Why not just use `GITHUB_TOKEN` for everything else?**
+
+Most of the repo's workflows (CI, build, Tier 3 publishers, Discord sync) only need read access or scoped write access within a single repo. `GITHUB_TOKEN` is fine for those and is the safer default. The release pipeline needs cross-workflow event firing, which only a PAT can provide.
+
+**How to create it:**
+
+1. Go to (fine-grained PATs).
+2. **Resource owner**: `getopenscreen` (only this org — do not grant access to personal repos).
+3. **Repository access**: `getopenscreen/openscreen` only.
+4. **Permissions**:
+ - `Contents`: Read and write
+ - `Issues`: Read and write
+ - `Pull requests`: Read and write (the release pipeline opens a PR to bump `package.json` and rebase-merges it into `main` because the org-level workflow permissions block `GITHUB_TOKEN` from creating PRs)
+ - `Actions`: Read and write (the release pipeline triggers `build.yml` via `gh workflow run`; GITHUB_TOKEN tag pushes don't fire downstream workflows in this org)
+ - `Workflows`: Read and write (the release branch contains the workflow files; creating it requires writing to `.github/workflows/`)
+ - `Metadata`: Read-only (auto-selected)
+5. **Expiration**: 1 year. Set a calendar reminder to rotate.
+6. Generate the token, copy it once, then add it as a repository secret. The `gh` CLI does **not** accept the value as a positional argument — use `--body` or stdin:
+ ```bash
+ # Either:
+ gh secret set OPENSCREEN_RELEASE_TOKEN --body "ghp_xxxxxxxxxxxxxxxxxxxx" --repo getopenscreen/openscreen
+ # Or:
+ echo "ghp_xxxxxxxxxxxxxxxxxxxx" | gh secret set OPENSCREEN_RELEASE_TOKEN --repo getopenscreen/openscreen
+ ```
+7. Verify by triggering a test `workflow_dispatch` on `prerelease.yml` with `bump=patch`, `rc_number=99` against an empty milestone, then revert the resulting `package.json` bump PR/commit.
+
+**Rotation:**
+
+Old token and new token both work in parallel until the old one expires or is revoked. Rotate by:
+
+1. Generate the new token.
+2. Update the secret.
+3. Revoke the old token.
+
+There's no need to coordinate a rotation window — the release pipeline runs at most a few times per month.
+
+## Required repo ruleset bypass
+
+The `main` branch is protected by the repository ruleset `main-protection` (id `18060803` on this repo), which requires changes to be made through a pull request. The release pipeline (`prerelease.yml` and `promote.yml`) commits `package.json` directly to `main` because the version bump has to land before the tag is pushed and the build runs.
+
+To allow that direct push, the ruleset has two bypass actors:
+
+- **`EtienneLescot`** (id `215859519`) — so manual pushes from the maintainer's local checkout work.
+- **`github-actions[bot]`** (id `41898282`) — so the workflow's `GITHUB_TOKEN` push (the default `actions/checkout@v4` auth) is also accepted.
+
+## Required repo ruleset bypass and PR flow
+
+The `main` branch is protected by the repository ruleset `main-protection` (id `18060803` on this repo). It enforces:
+
+- `deletion` — branches can't be deleted
+- `non_fast_forward` — no force pushes
+- `required_linear_history` — fast-forward only
+- `pull_request` — 1 approving review + code owner review, only rebase merge allowed (`merge` and `squash` are disabled at the repo level)
+
+The release pipeline (`prerelease.yml` and `promote.yml`) cannot bypass this directly because:
+
+- The org policy disables `GITHUB_TOKEN` write permissions (`Allow GitHub Actions to create and approve pull requests` is OFF at the org level), so `GITHUB_TOKEN` cannot create the bump PR.
+- Fine-grained PATs do not satisfy ruleset bypass actors, so a PAT-driven direct push is rejected with `GH013`.
+
+So the workflow:
+
+1. Pushes the bump commit to a `release/vX.Y.Z` branch using the PAT (no rule check on non-main branches).
+2. Opens the PR using the PAT (`gh pr create` with `GH_TOKEN=$OPENSCREEN_RELEASE_TOKEN`).
+3. Rebase-merges the PR using the PAT. EtienneLescot is a ruleset bypass actor with `bypass_mode: "always"`, so the `pull_request` review requirement is skipped for this merge.
+
+The ruleset has two bypass actors:
+
+- **`EtienneLescot`** (id `215859519`) — so the PAT-driven PR merge satisfies the `pull_request` rule.
+- **`github-actions[bot]`** (id `41898282`) — added defensively, though `GITHUB_TOKEN`-driven operations are blocked by the org policy regardless.
+
+To confirm the bypass list:
+
+```bash
+gh api /repos/getopenscreen/openscreen/rulesets/18060803 --jq '.bypass_actors'
+# Expect both 215859519 and 41898282 with bypass_mode "always".
+```
+
+## Required for Discord announcements
+
+### `DISCORD_BOT_TOKEN`
+
+Bot token from a Discord application added to the OpenScreen Discord server with the `bot` scope and at minimum:
+
+- `Send Messages` in any text channels where the bot posts
+- `Create Public Threads` in the forum channels (for the release announce script)
+- `Send Messages in Threads` so the first message in a new thread goes through
+- `Manage Messages` if you want the roadmap-sync workflow to pin its message
+- `Read Message History` (usually default)
+
+Stored as a repository secret.
+
+### `DISCORD_RC_TESTING_CHANNEL_ID`
+
+Snowflake ID of the Discord channel where release candidates are announced. Can be a regular text channel or a forum channel — the `discord-release-announce.mjs` script auto-detects the type:
+
+- **Text channel** (`type=0`): posts the announcement as a regular message.
+- **Forum channel** (`type=15` or `16`): creates a new thread with the announcement as the first message. One thread per release, named like `v1.5.1-rc.1 RC — testing`.
+
+Set as a **repository variable** (not a secret — it's not sensitive):
+
+```bash
+gh variable set DISCORD_RC_TESTING_CHANNEL_ID --body "1521416826146263051" --repo getopenscreen/openscreen
+```
+
+### `DISCORD_RELEASE_CHANNEL_ID`
+
+Same pattern as above, for the stable release announcement channel.
+
+```bash
+gh variable set DISCORD_RELEASE_CHANNEL_ID --body "" --repo getopenscreen/openscreen
+```
+
+### `DISCORD_ROADMAP_CHANNEL_ID` and `DISCORD_ROADMAP_MESSAGE_ID`
+
+Used by `discord-roadmap-sync.yml` to keep the pinned roadmap message in sync. Repository variables.
+
+## Tier 3 package registries
+
+Each external registry has its own credential set. See the per-workflow README comments at the top of these files:
+
+- `.github/workflows/update-homebrew-cask.yml` — `HOMEBREW_TAP_TOKEN`, `HOMEBREW_TAP_OWNER`, `HOMEBREW_TAP_REPO`, `HOMEBREW_CASK_NAME`
+- `.github/workflows/publish-winget.yml` — `WINGET_ACC_TOKEN`, `WINGET_IDENTIFIER`
+- `.github/workflows/bump-nix-package.yml` — uses `GITHUB_TOKEN` (no extra secret required)
+- `.github/workflows/aur-publish.yml` — `AUR_SSH_PRIVATE_KEY`, `AUR_KNOWN_HOSTS`, `AUR_PACKAGE_NAME`
+
+All four already gate on `!prerelease`, so a `vX.Y.Z-rc.N` tag will not push to homebrew/winget/nix/aur.
+
+## Apple notarization
+
+`build.yml` skips notarization when the tag contains a `-` (i.e. any pre-release), so the macOS secrets below are only consulted for stable releases:
+
+- `MAC_CERTIFICATE_P12` (base64 of the Developer ID Application `.p12`)
+- `MAC_CERTIFICATE_PASSWORD`
+- `MAC_CSC_NAME`
+- `APPLE_ID`
+- `APPLE_TEAM_ID`
+- `APPLE_APP_SPECIFIC_PASSWORD`
+
+If any of these is missing, the build produces an **unsigned** DMG without notarization. This is the expected behavior for forks and CI debug runs. The release pipeline still works; the macOS DMG will trigger a Gatekeeper warning on first install.
\ No newline at end of file
diff --git a/docs/testing/macos-native-cursor.md b/docs/testing/macos-native-cursor.md
new file mode 100644
index 0000000000..b79064f8cb
--- /dev/null
+++ b/docs/testing/macos-native-cursor.md
@@ -0,0 +1,183 @@
+# macOS native cursor test pipeline
+
+This document covers manual and diagnostic testing for macOS native cursor capture — the path that records real system cursor bitmaps via `NSCursor.currentSystem` and surfaces them through the OpenScreen editor and export pipeline.
+
+## How the macOS cursor helper works
+
+The helper binary (`openscreen-macos-cursor-helper`) runs as a child process of Electron during recording. It:
+
+- polls `NSCursor.currentSystem` at the configured sample interval
+- converts each cursor image to PNG and computes a SHA-256 content hash as a stable asset id
+- emits the full base64 bitmap payload **once** per unique cursor shape per session; subsequent samples carry only the `assetId` so stdout stays small
+- tracks left-button down/up events via `CGEventTap` and tags each sample with `interactionType`
+- uses the Accessibility API to detect `text` and `pointer` affordances (link/button/input roles) when Accessibility is granted; these shapes use the bundled high-quality SVG replacements instead of the raw bitmap
+
+Each sample line is newline-delimited JSON:
+
+```json
+{ "type": "ready", "timestampMs": 1234567890, "accessibilityTrusted": true, "mouseTapReady": true }
+{ "type": "sample", "timestampMs": 1234567891, "assetId": "a7472...", "asset": { "id": "a7472...", "imageDataUrl": "data:image/png;base64,...", "width": 64, "height": 64, "hotspotX": 16, "hotspotY": 16, "scaleFactor": 2.0 }, "cursorType": null, "leftButtonDown": false, "leftButtonPressed": false, "leftButtonReleased": false }
+{ "type": "sample", "timestampMs": 1234567924, "assetId": "a7472...", "cursorType": null, "leftButtonDown": false, "leftButtonPressed": false, "leftButtonReleased": false }
+```
+
+`asset` is present only the first time a given `assetId` appears. The TypeScript session (`MacNativeCursorRecordingSession`) collects unique assets into a map and sets `provider: "native"` in the final `CursorRecordingData` when at least one bitmap was captured.
+
+## Build the helper
+
+```bash
+npm run build:native:mac
+```
+
+This builds both Swift helpers (`openscreen-screencapturekit-helper` and `openscreen-macos-cursor-helper`) and copies them to:
+
+- `electron/native/screencapturekit/build/` — used by the local dev server
+- `electron/native/bin/darwin-arm64/` or `darwin-x64/` — used by packaged builds
+
+Requires Xcode (not just Command Line Tools). If you see a build error about missing SDK metadata, run:
+
+```bash
+sudo xcode-select --switch /Applications/Xcode.app/Contents/Developer
+sudo xcodebuild -license accept
+```
+
+## Smoke-test the helper directly
+
+You can run the cursor helper standalone to inspect its raw output before involving the full app:
+
+```bash
+BIN=electron/native/screencapturekit/build/openscreen-macos-cursor-helper
+("$BIN" '{"sampleIntervalMs":100}' & PID=$!; sleep 2; kill $PID) | head -20
+```
+
+Expected first line:
+
+```json
+{"type":"ready","mouseTapReady":true,"accessibilityTrusted":false,"timestampMs":...}
+```
+
+`accessibilityTrusted: false` is normal in dev/unsigned builds. It means text/pointer affordance detection is disabled; native bitmap capture still works.
+
+Expected sample lines:
+
+```json
+{"type":"sample","assetId":"a7472...","asset":{"id":"a7472...","imageDataUrl":"data:image/png;base64,...","width":64,"height":64,"hotspotX":26,"hotspotY":16,"scaleFactor":2.0},...}
+{"type":"sample","assetId":"a7472...",...}
+```
+
+Move the cursor over a text input while the helper is running and check that a new `assetId` appears with a different bitmap (if Accessibility is granted — see below).
+
+## Point the app at a custom helper binary
+
+```bash
+export OPENSCREEN_MAC_CURSOR_HELPER_EXE=/path/to/openscreen-macos-cursor-helper
+npm run dev
+```
+
+## macOS permissions
+
+Two separate permissions are needed:
+
+| Permission | What it enables | Where to grant |
+|---|---|---|
+| Screen Recording | ScreenCaptureKit video capture | System Settings → Privacy & Security → Screen & System Audio Recording → Electron ✅ |
+| Accessibility | `text` / `pointer` cursor type detection (affordance hints) | System Settings → Privacy & Security → Accessibility → Electron ✅ |
+
+**Screen Recording** is required to record. Without it the recording never starts.
+
+**Accessibility** is optional. Without it, `cursorType` will always be `null` and all cursors render from their captured bitmaps (no SVG substitution). This is the expected fallback and does not degrade cursor quality for non-text/pointer shapes.
+
+After granting either permission in System Settings, **fully quit and relaunch** the dev server — `getMediaAccessStatus` caches the result per-process.
+
+## Manual test checklist
+
+### P0 — core bitmap capture
+
+- [ ] Record a short clip. Open the editor. Confirm the default arrow cursor is the real system arrow (not the bundled SVG approximation).
+- [ ] Record while hovering over a web browser. Confirm custom-CSS cursors (e.g. `cursor: grab`, `cursor: crosshair`) appear as their actual shapes.
+- [ ] Export to MP4. Confirm the cursor renders correctly in the exported video.
+- [ ] Export to GIF. Same check.
+
+### P1 — affordance substitution (requires Accessibility)
+
+- [ ] Grant Accessibility permission and restart the app.
+- [ ] Record hovering over a text input field. Confirm the text I-beam uses the bundled SVG version (prettier than the system bitmap).
+- [ ] Record hovering over a link/button. Confirm the pointer hand uses the bundled SVG.
+
+### P1 — hotspot alignment (Retina)
+
+- [ ] On a Retina display, record a precise click on a small button. In the editor, confirm the cursor tip aligns with the actual click point. The helper reports `scaleFactor: 2.0`; the renderer divides pixel dimensions and hotspot by this value to recover point sizes.
+
+### P1 — click detection
+
+- [ ] Record several left-clicks. In the editor, confirm the click-bounce animation fires on each click.
+- [ ] Confirm `interactionType: "click"` and `"mouseup"` events are present in the recording session sidecar (`cursorRecordingData` inside `.cursor.json`).
+
+### P2 — graceful degradation
+
+- [ ] Remove **both** build-output copies of the helper binary and start a recording. The session should succeed with `provider: "none"` (position-only telemetry, default arrow rendered). Restore both binaries afterward.
+ ```bash
+ ARCH=$([ "$(uname -m)" = "arm64" ] && echo darwin-arm64 || echo darwin-x64)
+ mv electron/native/screencapturekit/build/openscreen-macos-cursor-helper /tmp/cursor-helper-build
+ mv electron/native/bin/$ARCH/openscreen-macos-cursor-helper /tmp/cursor-helper-bin
+ # ... start recording, then restore:
+ mv /tmp/cursor-helper-bin electron/native/bin/$ARCH/openscreen-macos-cursor-helper
+ mv /tmp/cursor-helper-build electron/native/screencapturekit/build/openscreen-macos-cursor-helper
+ ```
+- [ ] Revoke Accessibility. Confirm recording still works and cursors render from bitmaps (no SVG substitution).
+
+### P2 — multi-display
+
+- [ ] Move the cursor to a secondary display during recording. Confirm the cursor clips to the canvas edge rather than snapping invisible on fast swipes. Confirm it hides after ≈100 ms of sustained out-of-bounds movement.
+
+### P2 — long recording memory
+
+- [ ] Record for 3–5 minutes while switching between many apps (browser, terminal, editor). The helper should not grow in memory because each iteration drains Cocoa objects via `autoreleasepool`. Check `Activity Monitor` → `openscreen-macos-cursor-helper` RSS stays flat after the first few seconds.
+
+## What a healthy recording looks like
+
+Inspect the cursor sidecar file written alongside the recorded video (`.cursor.json`). For a recording saved to `/tmp/rec.mp4`, the sidecar is `/tmp/rec.mp4.cursor.json`:
+
+```json
+{
+ "version": 2,
+ "provider": "native",
+ "assets": [
+ { "id": "a7472...", "platform": "darwin", "imageDataUrl": "data:image/png;base64,...", "width": 64, "height": 64, "hotspotX": 26.0, "hotspotY": 16.0, "scaleFactor": 2.0 }
+ ],
+ "samples": [
+ { "timeMs": 0, "cx": 0.42, "cy": 0.38, "visible": true, "assetId": "a7472...", "interactionType": "move" },
+ ...
+ ]
+}
+```
+
+`provider: "native"` and a non-empty `assets` array confirm bitmap capture is active. If you see `provider: "none"` and `assets: []`, the helper was not found or exited before `ready`.
+
+## Native macOS capture backend
+
+The app routes macOS recordings through the ScreenCaptureKit helper (`openscreen-screencapturekit-helper`) when it is available, so the real system cursor is excluded from the video frame. The cursor position and bitmap are captured separately by the cursor helper and composited in the editor and export pipeline.
+
+Current native availability rules:
+
+- macOS 13 (Ventura) or newer
+- `openscreen-screencapturekit-helper` binary is present
+- Screen Recording permission is granted
+
+Build both helpers locally:
+
+```bash
+npm run build:native:mac
+```
+
+For local diagnostics with a custom helper binary, use the environment override:
+
+```bash
+export OPENSCREEN_MAC_CURSOR_HELPER_EXE=/path/to/openscreen-macos-cursor-helper
+npm run dev
+```
+
+## Known limitations
+
+- **Intel (x86\_64) Macs**: the distributed helper is built for `darwin-arm64`. On Intel Macs, you need to build from source with `npm run build:native:mac` on the target machine.
+- **Accessibility permission in unsigned/dev builds**: `getMediaAccessStatus("accessibility")` may not reflect the toggle state for unsigned Electron in dev mode. The helper will always probe and report `accessibilityTrusted` in its `ready` event — use that as the authoritative signal.
+- **App-defined custom cursors (CGS layer)**: `NSCursor.currentSystem` captures the active AppKit cursor. Cursors set at the CoreGraphics/CGS layer by some games or GPU-accelerated apps may not be visible here. This is a known macOS API limitation.
diff --git a/electron-builder.json5 b/electron-builder.json5
index 8ad4a80eb9..0ff1453834 100644
--- a/electron-builder.json5
+++ b/electron-builder.json5
@@ -1,15 +1,20 @@
// @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",
+ "appId": "com.etiennelescot.openscreen",
"asar": true,
// .node binaries can't be dlopen'd from inside an asar — must live unpacked.
"asarUnpack": [
"**/*.node"
],
"productName": "Openscreen",
+ // Fetch the auto-caption model + ORT wasm into caption-assets/ before packaging (idempotent).
+ "beforePack": "scripts/before-pack.cjs",
"npmRebuild": true,
- "buildDependenciesFromSource": true,
+ // sharp ships ABI-stable (napi) prebuilt binaries with bundled libvips. Building it from source
+ // needs a system libvips we don't provide and breaks on CI/local ("vips-cpp.42 not found"), so we
+ // let electron-builder use the prebuilt instead of recompiling.
+ "buildDependenciesFromSource": false,
"compression": "normal",
"directories": {
"output": "release/${version}"
@@ -24,12 +29,20 @@
"!CONTRIBUTING.md",
"!LICENSE"
],
- // Asset layout contract: "wallpapers/" under resourcesPath must align with
- // assetBaseDir in electron/preload.ts (packaged branch).
+ // Asset layout contract: "wallpapers/" and "cursors/" under resourcesPath must
+ // align with assetBaseDir in electron/preload.ts (packaged branch).
"extraResources": [
{
"from": "public/wallpapers",
"to": "wallpapers"
+ },
+ {
+ "from": "public/cursors",
+ "to": "cursors"
+ },
+ {
+ "from": "caption-assets",
+ "to": "caption-assets"
}
],
@@ -61,24 +74,26 @@
"NSCameraUseContinuityCameraDeviceType": true
}
},
- "linux": {
- "target": [
- "AppImage",
- "deb",
- "pacman"
- ],
- "icon": "icons/icons/png",
- "artifactName": "${productName}-Linux-${version}.${ext}",
- "category": "AudioVideo"
- },
- "win": {
- "target": [
- "nsis"
- ],
- "icon": "icons/icons/win/icon.ico",
- "extraResources": [
- {
- "from": "electron/native/bin",
+ "linux": {
+ "target": [
+ "AppImage",
+ "deb",
+ "pacman"
+ ],
+ "icon": "icons/icons/png",
+ "artifactName": "${productName}-Linux-${version}.${ext}",
+ "maintainer": "Etienne Lescot ",
+ "category": "AudioVideo"
+ },
+ "win": {
+ "target": [
+ "nsis"
+ ],
+ "icon": "icons/icons/win/icon.ico",
+ "artifactName": "${productName}.Setup.${version}.${ext}",
+ "extraResources": [
+ {
+ "from": "electron/native/bin",
"to": "electron/native/bin",
"filter": ["win32-*/*"]
}
diff --git a/electron/diagnostics/main-log-buffer.test.ts b/electron/diagnostics/main-log-buffer.test.ts
new file mode 100644
index 0000000000..b835118267
--- /dev/null
+++ b/electron/diagnostics/main-log-buffer.test.ts
@@ -0,0 +1,82 @@
+import { afterEach, describe, expect, it } from "vitest";
+import { MainLogBuffer } from "./main-log-buffer";
+
+describe("MainLogBuffer", () => {
+ const buffers: MainLogBuffer[] = [];
+
+ function make(capacity: number) {
+ const b = new MainLogBuffer(capacity);
+ buffers.push(b);
+ return b;
+ }
+
+ afterEach(() => {
+ for (const b of buffers) b.uninstall();
+ buffers.length = 0;
+ });
+
+ it("captures every console level and routes to original", () => {
+ const buf = make(10);
+ const captured: string[] = [];
+ const original = {
+ log: (...args: unknown[]) => captured.push(`log:${args.join(",")}`),
+ info: (...args: unknown[]) => captured.push(`info:${args.join(",")}`),
+ warn: (...args: unknown[]) => captured.push(`warn:${args.join(",")}`),
+ error: (...args: unknown[]) => captured.push(`error:${args.join(",")}`),
+ };
+ Object.assign(console, original);
+ buf.install();
+ console.log("hello");
+ console.info("world");
+ console.warn("watch");
+ console.error("bad");
+ const snap = buf.snapshot();
+ expect(snap.map((e) => e.level)).toEqual(["log", "info", "warn", "error"]);
+ expect(snap.map((e) => e.text)).toEqual(["hello", "world", "watch", "bad"]);
+ expect(captured).toEqual(["log:hello", "info:world", "warn:watch", "error:bad"]);
+ });
+
+ it("stringifies non-string args", () => {
+ const buf = make(5);
+ buf.install();
+ console.info({ a: 1 });
+ const snap = buf.snapshot();
+ expect(snap[0].text).toBe('{"a":1}');
+ });
+
+ it("drops oldest entries past capacity", () => {
+ const buf = make(3);
+ buf.install();
+ for (let i = 0; i < 5; i += 1) console.info(`line ${i}`);
+ const snap = buf.snapshot();
+ expect(snap.map((e) => e.text)).toEqual(["line 2", "line 3", "line 4"]);
+ });
+
+ it("uninstall restores originals", () => {
+ const buf = make(5);
+ buf.install();
+ console.info("captured");
+ buf.uninstall();
+ console.info("after-uninstall");
+ const snap = buf.snapshot();
+ expect(snap.map((e) => e.text)).toEqual(["captured"]);
+ });
+
+ it("clear empties the buffer", () => {
+ const buf = make(5);
+ buf.install();
+ console.info("one");
+ console.info("two");
+ buf.clear();
+ expect(buf.snapshot()).toEqual([]);
+ });
+
+ it("install is idempotent", () => {
+ const buf = make(5);
+ buf.install();
+ buf.install();
+ console.info("once");
+ const snap = buf.snapshot();
+ expect(snap.map((e) => e.text)).toEqual(["once"]);
+ });
+});
diff --git a/electron/diagnostics/main-log-buffer.ts b/electron/diagnostics/main-log-buffer.ts
new file mode 100644
index 0000000000..ba12ffefec
--- /dev/null
+++ b/electron/diagnostics/main-log-buffer.ts
@@ -0,0 +1,102 @@
+/**
+ * Ring buffer for main-process console output.
+ *
+ * Captures the last `capacity` lines written via console.info / console.warn /
+ * console.error / console.log into a single in-memory buffer. Disabled by
+ * default — install only when verbose diagnostics are wanted, e.g. when
+ * OPENSCREEN_DIAGNOSTIC=1 is set or when a developer wants a more complete
+ * "Save Diagnostics" payload for an upstream bug report.
+ *
+ * Cost when enabled: one array.push + occasional shift per console call,
+ * negligible against the rest of the app. Cost when disabled: zero, the
+ * original console methods are kept untouched.
+ */
+
+const DEFAULT_CAPACITY = 500;
+
+export interface MainLogEntry {
+ timestampMs: number;
+ level: "info" | "warn" | "error" | "log";
+ text: string;
+}
+
+export class MainLogBuffer {
+ private readonly capacity: number;
+ private readonly entries: MainLogEntry[] = [];
+ private installed = false;
+ private readonly originals: Partial<
+ Record<"log" | "info" | "warn" | "error", (...args: unknown[]) => void>
+ > = {};
+
+ constructor(capacity = DEFAULT_CAPACITY) {
+ this.capacity = Math.max(1, capacity);
+ }
+
+ install(): void {
+ if (this.installed) return;
+ this.installed = true;
+ const console_ = console as unknown as Record<
+ "log" | "info" | "warn" | "error",
+ (...args: unknown[]) => void
+ >;
+ for (const level of ["log", "info", "warn", "error"] as const) {
+ this.originals[level] = console_[level].bind(console);
+ console_[level] = (...args: unknown[]) => {
+ this.push(level, args);
+ this.originals[level]?.(...args);
+ };
+ }
+ }
+
+ uninstall(): void {
+ if (!this.installed) return;
+ this.installed = false;
+ const console_ = console as unknown as Record<
+ "log" | "info" | "warn" | "error",
+ (...args: unknown[]) => void
+ >;
+ for (const level of ["log", "info", "warn", "error"] as const) {
+ if (this.originals[level]) {
+ console_[level] = this.originals[level] as (...args: unknown[]) => void;
+ }
+ }
+ this.originals.log = undefined;
+ this.originals.info = undefined;
+ this.originals.warn = undefined;
+ this.originals.error = undefined;
+ }
+
+ snapshot(): MainLogEntry[] {
+ return this.entries.slice();
+ }
+
+ clear(): void {
+ this.entries.length = 0;
+ }
+
+ private push(level: MainLogEntry["level"], args: unknown[]): void {
+ const text = args
+ .map((arg) => {
+ if (typeof arg === "string") return arg;
+ try {
+ return JSON.stringify(arg);
+ } catch {
+ return String(arg);
+ }
+ })
+ .join(" ");
+ this.entries.push({ timestampMs: Date.now(), level, text });
+ if (this.entries.length > this.capacity) {
+ this.entries.splice(0, this.entries.length - this.capacity);
+ }
+ }
+}
+
+export const mainLogBuffer = new MainLogBuffer();
+
+export function isDiagnosticModeEnabled(): boolean {
+ const raw = process.env.OPENSCREEN_DIAGNOSTIC;
+ if (!raw) return false;
+ const lowered = raw.trim().toLowerCase();
+ return lowered === "1" || lowered === "true" || lowered === "yes";
+}
diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts
index ac29d45af0..b7314ea7c2 100644
--- a/electron/electron-env.d.ts
+++ b/electron/electron-env.d.ts
@@ -41,8 +41,14 @@ interface Window {
error?: string;
};
}>;
+ openNotes: () => Promise<{
+ opened: boolean;
+ reason?: string;
+ }>;
selectSource: (source: ProcessedDesktopSource) => Promise;
getSelectedSource: () => Promise;
+ onSelectedSourceChanged: (callback: (source: ProcessedDesktopSource) => void) => () => void;
+ onSourceSelectorClosed: (callback: () => void) => () => void;
requestCameraAccess: () => Promise<{
success: boolean;
granted: boolean;
@@ -211,6 +217,25 @@ interface Window {
message?: string;
error?: string;
}>;
+ getReadableFileInfo: (filePath: string) => Promise<{
+ success: boolean;
+ size?: number;
+ mtimeMs?: number;
+ path?: string;
+ message?: string;
+ error?: string;
+ }>;
+ readFileChunk: (
+ filePath: string,
+ offset: number,
+ length: number,
+ ) => Promise<{
+ success: boolean;
+ data?: ArrayBuffer;
+ bytesRead?: number;
+ message?: string;
+ error?: string;
+ }>;
preparePreviewAudioTrack: (filePath: string) => Promise<{
success: boolean;
path?: string | null;
@@ -229,7 +254,7 @@ interface Window {
canceled?: boolean;
error?: string;
}>;
- loadProjectFile: () => Promise<{
+ loadProjectFile: (projectFolder?: string) => Promise<{
success: boolean;
path?: string;
project?: unknown;
@@ -275,6 +300,7 @@ interface Window {
hudOverlayClose: () => void;
setHudOverlayIgnoreMouseEvents: (ignore: boolean) => void;
moveHudOverlayBy: (deltaX: number, deltaY: number) => void;
+ setHudOverlaySize: (width: number, height: number) => void;
showCountdownOverlay: (value: number, runId: number) => Promise;
setCountdownOverlayValue: (value: number, runId: number) => Promise;
hideCountdownOverlay: (runId: number) => Promise;
diff --git a/electron/globalShortcut.ts b/electron/globalShortcut.ts
index f1b046a34b..2765bad220 100644
--- a/electron/globalShortcut.ts
+++ b/electron/globalShortcut.ts
@@ -41,16 +41,14 @@ let currentAccelerator: string | null = null;
export function registerOpenAppShortcut(binding: ShortcutBinding, onTrigger: () => void): boolean {
const accelerator = bindingToAccelerator(binding);
- // Same shortcut already registered, nothing to do
if (accelerator === currentAccelerator) {
return true;
}
- // Try to register new shortcut first (before unregistering old one)
+ // Register the new shortcut before unregistering the old, so a failure leaves the old binding intact
const success = globalShortcut.register(accelerator, onTrigger);
if (success) {
- // Only unregister old shortcut after new one succeeds
if (currentAccelerator) {
globalShortcut.unregister(currentAccelerator);
}
diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts
index 15a6539a70..ae8f5525d9 100644
--- a/electron/ipc/handlers.ts
+++ b/electron/ipc/handlers.ts
@@ -5,7 +5,7 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
-import type { DesktopCapturerSource } from "electron";
+import type { DesktopCapturerSource, Rectangle } from "electron";
import {
app,
BrowserWindow,
@@ -35,6 +35,7 @@ import type {
ProjectFileResult,
ProjectPathResult,
} from "../../src/native/contracts";
+import { mainLogBuffer } from "../diagnostics/main-log-buffer";
import { mainT } from "../i18n";
import { RECORDINGS_DIR } from "../main";
import { createCursorRecordingSession } from "../native-bridge/cursor/recording/factory";
@@ -62,10 +63,7 @@ const ALLOWED_IMPORT_VIDEO_EXTENSIONS = new Set([
const PREVIEW_AUDIO_DIR = path.join(app.getPath("userData"), "preview-audio");
const nativeMacCaptureEvents = new EventEmitter();
-/**
- * Paths explicitly approved by the user via file picker dialogs or project loads.
- * These are added at runtime when the user selects files from outside the default directories.
- */
+// Paths the user approved via file picker or project load (i.e. outside the default dirs).
const approvedPaths = new Set();
function approveFilePath(filePath: string): void {
@@ -101,10 +99,7 @@ function resolveApprovedVideoPath(videoPath?: string | null): string | null {
return normalizedPath;
}
-/**
- * Helper function to build dialog options with a parent window only when it's valid.
- * This prevents passing stale or destroyed BrowserWindow references to dialog calls.
- */
+// Attach the parent window only when valid, to avoid passing a destroyed BrowserWindow to dialogs.
function buildDialogOptions(
baseOptions: T,
parentWindow: BrowserWindow | null,
@@ -233,9 +228,8 @@ async function approveReadableVideoPath(
return null;
}
- // When called with trustedDirs (e.g. from project load), only auto-approve
- // paths within those directories. This prevents malicious project files from
- // approving reads to arbitrary filesystem locations.
+ // With trustedDirs (e.g. project load), only auto-approve paths inside them so a
+ // malicious project file can't approve reads to arbitrary locations.
if (trustedDirs) {
const resolved = path.resolve(normalizedPath);
const withinTrusted = trustedDirs.some((dir) => isPathWithinDir(resolved, dir));
@@ -282,11 +276,9 @@ function isValidDurationMs(value: number | undefined): value is number {
}
/**
- * Finalize a single recording file: if it was streamed to disk, flush and close
- * the stream; otherwise (a short recording, or the stream failed to open and the
- * renderer fell back to in-memory buffering) write the buffered bytes. Returns
- * whether the file was streamed, which the caller uses to decide whether the
- * WebM duration needs patching on disk.
+ * Finalize one recording file: flush/close the stream if it was streamed, else write
+ * the buffered bytes (short recording or stream failed to open). Returns whether it was
+ * streamed, so the caller knows if the WebM duration needs patching on disk.
*/
async function finalizeRecordingFile(
registry: RecordingStreamRegistry,
@@ -322,8 +314,8 @@ async function getApprovedProjectSession(
return null;
}
- // Only auto-approve media paths within the project's directory or RECORDINGS_DIR.
- // This prevents crafted project files from approving reads to arbitrary locations.
+ // Only auto-approve media within the project's dir or RECORDINGS_DIR, so a crafted
+ // project file can't approve reads to arbitrary locations.
const trustedDirs = [RECORDINGS_DIR];
if (projectFilePath) {
trustedDirs.push(path.dirname(path.resolve(projectFilePath)));
@@ -366,10 +358,7 @@ let lastEnumeratedSources = new Map();
let currentProjectPath: string | null = null;
let currentRecordingSession: RecordingSession | null = null;
-/**
- * Returns the cached DesktopCapturerSource set when the user picked a source.
- * Used by setDisplayMediaRequestHandler in main.ts for cursor-free capture.
- */
+// Cached source from the user's pick. Used by setDisplayMediaRequestHandler in main.ts for cursor-free capture.
export function getSelectedDesktopSource(): DesktopCapturerSource | null {
return selectedDesktopSource;
}
@@ -424,7 +413,7 @@ let nativeWindowsCursorRecordingStartMs = 0;
let nativeWindowsPauseStartedAtMs: number | null = null;
let nativeWindowsPauseRanges: Array<{ startMs: number; endMs: number }> = [];
let nativeWindowsIsPaused = false;
-const NATIVE_WINDOWS_CAPTURE_STOP_TIMEOUT_MS = 15_000;
+const NATIVE_WINDOWS_CAPTURE_STOP_TIMEOUT_MS = 60_000;
let nativeMacCaptureProcess: ChildProcessWithoutNullStreams | null = null;
let nativeMacCaptureOutput = "";
let nativeMacCaptureTargetPath: string | null = null;
@@ -435,6 +424,8 @@ let nativeMacCursorRecordingStartMs = 0;
let nativeMacPauseStartedAtMs: number | null = null;
let nativeMacPauseRanges: Array<{ startMs: number; endMs: number }> = [];
let nativeMacIsPaused = false;
+// Global frame of the region captured by the SCK helper (see getSelectedSourceBounds).
+let activeMacCaptureBounds: Rectangle | null = null;
function normalizeCursorSample(sample: unknown): CursorRecordingSample | null {
if (!sample || typeof sample !== "object") {
@@ -584,6 +575,14 @@ function resolveAssetBasePath() {
}
function getSelectedSourceBounds() {
+ // Single-window capture records only the window's region, not the whole display.
+ // Normalizing the cursor against display bounds leaves a fixed offset in the export,
+ // so prefer the helper-reported window frame when capturing a window.
+ const isWindowSource = selectedSource?.id?.startsWith("window:") === true;
+ if (isWindowSource && activeMacCaptureBounds) {
+ return activeMacCaptureBounds;
+ }
+
const cursor = screen.getCursorScreenPoint();
const sourceDisplayId = Number(selectedSource?.display_id);
const sourceDisplay = Number.isFinite(sourceDisplayId)
@@ -1050,11 +1049,19 @@ function tryParseNativeHelperEvent(line: string) {
}
}
+function dispatchNativeMacHelperEvent(event: Record) {
+ const bounds = event.captureBounds as Rectangle | undefined;
+ if (bounds && bounds.width > 0 && bounds.height > 0) {
+ activeMacCaptureBounds = bounds;
+ }
+ nativeMacCaptureEvents.emit("helper-event", event);
+}
+
function inspectNativeMacCaptureOutput() {
for (const line of nativeMacCaptureOutput.split(/\r?\n/)) {
const event = tryParseNativeHelperEvent(line.trim());
if (event) {
- nativeMacCaptureEvents.emit("helper-event", event);
+ dispatchNativeMacHelperEvent(event);
}
}
}
@@ -1070,7 +1077,7 @@ function attachNativeMacCaptureOutputDrain(proc: ChildProcessWithoutNullStreams)
for (const line of lines) {
const event = tryParseNativeHelperEvent(line.trim());
if (event) {
- nativeMacCaptureEvents.emit("helper-event", event);
+ dispatchNativeMacHelperEvent(event);
}
}
};
@@ -1273,8 +1280,10 @@ export function registerIpcHandlers(
createEditorWindow: () => void,
createSourceSelectorWindow: () => BrowserWindow,
createCountdownOverlayWindow: () => BrowserWindow,
+ createNotesWindowWrapper: () => BrowserWindow,
getMainWindow: () => BrowserWindow | null,
getSourceSelectorWindow: () => BrowserWindow | null,
+ getNotesWindow: () => BrowserWindow | null,
getCountdownOverlayWindow?: () => BrowserWindow | null,
onRecordingStateChange?: (recording: boolean, sourceName: string) => void,
_switchToHud?: () => void,
@@ -1290,7 +1299,7 @@ export function registerIpcHandlers(
return { success: true, granted: true, status };
}
- // Screen recording has no askForMediaAccess equivalent. Trigger the
+ // Screen recording has no askForMediaAccess equivalent, so trigger the
// TCC prompt without opening OpenScreen's source selector above it.
if (status === "not-determined") {
const mainWin = getMainWindow();
@@ -1348,6 +1357,10 @@ export function registerIpcHandlers(
selectedDesktopSource = null;
}
}
+ const mainWin = getMainWindow();
+ if (mainWin && !mainWin.isDestroyed()) {
+ mainWin.webContents.send("selected-source-changed", selectedSource);
+ }
const sourceSelectorWin = getSourceSelectorWindow();
if (sourceSelectorWin) {
sourceSelectorWin.close();
@@ -1396,7 +1409,36 @@ export function registerIpcHandlers(
});
ipcMain.handle("request-native-mac-cursor-access", async () => {
- return requestMacCursorAccessibilityAccess();
+ const access = await requestMacCursorAccessibilityAccess();
+
+ // When the editable cursor can't get Accessibility trust, pop a native dialog
+ // that deep-links to the Accessibility pane (mirrors the Screen Recording flow).
+ if (process.platform === "darwin" && !access.granted) {
+ const mainWin = getMainWindow();
+ const detail =
+ access.status === "missing-helper"
+ ? "The cursor helper couldn't be found in this build, so the editable cursor can't be enabled. Rebuild the native helper (npm run build:native:mac) or switch the HUD cursor mode to system."
+ : "Allow OpenScreen under System Settings → Privacy & Security → Accessibility, then press record again to start the countdown.";
+ const messageOptions = {
+ type: "warning",
+ buttons: ["Open Accessibility Settings", "Cancel"],
+ defaultId: 0,
+ cancelId: 1,
+ message: "Accessibility access is required for the editable cursor",
+ detail,
+ } satisfies Electron.MessageBoxOptions;
+ const result =
+ mainWin && !mainWin.isDestroyed()
+ ? await dialog.showMessageBox(mainWin, messageOptions)
+ : await dialog.showMessageBox(messageOptions);
+ if (result.response === 0) {
+ await shell.openExternal(
+ "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility",
+ );
+ }
+ }
+
+ return access;
});
ipcMain.handle("open-source-selector", async () => {
@@ -1439,11 +1481,21 @@ export function registerIpcHandlers(
return { opened: true };
});
+ ipcMain.handle("open-notes", async () => {
+ const notesSelectorWin = getNotesWindow();
+ if (notesSelectorWin) {
+ notesSelectorWin.focus();
+ return { opened: true };
+ }
+
+ createNotesWindowWrapper();
+ return { opened: true };
+ });
+
ipcMain.handle("switch-to-editor", () => {
- // createEditorWindow is createEditorWindowWrapper — it already closes
- // the current mainWindow (the HUD) before opening the editor. Closing
- // it here too causes a double-close which leaves ghost transparent
- // windows and makes the HUD shadow compound on each cycle.
+ // createEditorWindow already closes the current mainWindow (the HUD) before
+ // opening the editor. Closing it here too double-closes, leaving ghost
+ // transparent windows and compounding the HUD shadow each cycle.
createEditorWindow();
});
@@ -1463,9 +1515,8 @@ export function registerIpcHandlers(
return;
}
- // Wait for the first frame to be painted before showing the window.
- // Showing before ready-to-show produces a black rectangle flash because
- // Chromium hasn't rendered any pixels yet.
+ // Wait for the first frame before showing, else Chromium flashes a black
+ // rectangle because it hasn't rendered any pixels yet.
if (overlayWindow.webContents.isLoading()) {
await new Promise((resolve) => {
overlayWindow.once("ready-to-show", resolve);
@@ -1797,6 +1848,7 @@ export function registerIpcHandlers(
nativeMacPauseStartedAtMs = null;
nativeMacPauseRanges = [];
nativeMacIsPaused = false;
+ activeMacCaptureBounds = null;
const cursorStartTimeMs = Date.now();
if (cursorCaptureMode === "editable-overlay") {
@@ -2112,6 +2164,7 @@ export function registerIpcHandlers(
nativeMacPauseStartedAtMs = null;
nativeMacPauseRanges = [];
nativeMacIsPaused = false;
+ activeMacCaptureBounds = null;
const source = selectedSource || { name: "Screen" };
if (onRecordingStateChange) {
onRecordingStateChange(false, source.name);
@@ -2181,8 +2234,7 @@ export function registerIpcHandlers(
);
// On-disk write streams for in-progress recordings, keyed by output file name.
- // Chunks are appended as they arrive from ondataavailable so the renderer
- // never buffers the full video in memory (the #616 fix).
+ // Chunks append as they arrive so the renderer never buffers the full video (#616).
const recordingStreams = new RecordingStreamRegistry();
registerRecordingStreamHandlers(ipcMain, recordingStreams, resolveRecordingOutputPath);
@@ -2225,9 +2277,9 @@ export function registerIpcHandlers(
);
}
- // Streamed files lack the WebM Duration header (the renderer no longer holds
- // the blob to patch). Patch on disk so the editor's seek bar and timeline
- // work. Best-effort and independent per file, so the patches run together.
+ // Streamed files lack the WebM Duration header (renderer no longer holds the
+ // blob), so patch on disk for the editor's seek bar and timeline. Best-effort,
+ // independent per file, so they run together.
if (isValidDurationMs(payload.durationMs)) {
const patches: Promise[] = [];
if (screenStreamed) {
@@ -2358,9 +2410,8 @@ export function registerIpcHandlers(
? [{ name: mainT("dialogs", "fileDialogs.gifImage"), extensions: ["gif"] }]
: [{ name: mainT("dialogs", "fileDialogs.mp4Video"), extensions: ["mp4"] }];
- // Prefer the user's last export folder if it still exists, otherwise fall
- // back to ~/Downloads. Validation must happen here because the renderer
- // can't stat the filesystem.
+ // Prefer the user's last export folder if it still exists, else ~/Downloads.
+ // Validate here because the renderer can't stat the filesystem.
let defaultDir = app.getPath("downloads");
if (exportFolder) {
try {
@@ -2405,8 +2456,8 @@ export function registerIpcHandlers(
ipcMain.handle("write-export-to-path", async (_, videoData: ArrayBuffer, filePath: string) => {
try {
- // Sanity-check the path. The renderer is trusted (contextIsolation is on),
- // but a stale state bug shouldn't be able to clobber arbitrary files.
+ // Sanity-check the path: the renderer is trusted (contextIsolation on), but a
+ // stale-state bug shouldn't be able to clobber arbitrary files.
if (typeof filePath !== "string" || !path.isAbsolute(filePath)) {
return { success: false, message: "Invalid path" };
}
@@ -2482,14 +2533,13 @@ export function registerIpcHandlers(
ipcMain.handle("reveal-in-folder", async (_, filePath: string) => {
try {
- // shell.showItemInFolder doesn't return a value, it throws on error
+ // showItemInFolder returns nothing, 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
+ // Fall back to opening the directory if revealing fails (file moved/deleted
+ // after export, or a path showItemInFolder rejects).
try {
const openPathResult = await shell.openPath(path.dirname(filePath));
if (openPathResult) {
@@ -2530,6 +2580,83 @@ export function registerIpcHandlers(
}
});
+ // Stat an approved video file. Used to decide whether a recording is small
+ // enough to slurp via read-binary-file, or large enough that it must be
+ // streamed in chunks (Node's fs.readFile caps a single read at 2 GiB, so any
+ // recording above that can never be loaded whole — see read-file-chunk).
+ ipcMain.handle("get-readable-file-info", async (_, filePath: string) => {
+ try {
+ const normalizedPath = await approveReadableVideoPath(filePath);
+ if (!normalizedPath) {
+ return {
+ success: false,
+ message: "File path is not approved or is not a supported video file",
+ };
+ }
+
+ const stat = await fs.stat(normalizedPath);
+ return {
+ success: true,
+ size: stat.size,
+ mtimeMs: stat.mtimeMs,
+ path: normalizedPath,
+ };
+ } catch (error) {
+ console.error("Failed to stat file:", error);
+ return {
+ success: false,
+ message: "Failed to stat file",
+ error: String(error),
+ };
+ }
+ });
+
+ // Cap renderer-requested chunk sizes so a buggy or compromised renderer
+ // cannot make the main process allocate an arbitrarily large buffer.
+ const MAX_IPC_CHUNK_BYTES = 64 * 1024 * 1024;
+
+ // Read a byte range [offset, offset+length) from an approved video file.
+ // Lets the renderer stream a >2 GiB recording into OPFS one chunk at a time
+ // instead of materialising the whole file in memory, which fs.readFile cannot
+ // do (2 GiB cap) and a 16 GB machine cannot hold for multi-GB recordings.
+ ipcMain.handle("read-file-chunk", async (_, filePath: string, offset: number, length: number) => {
+ try {
+ const normalizedPath = await approveReadableVideoPath(filePath);
+ if (!normalizedPath) {
+ return {
+ success: false,
+ message: "File path is not approved or is not a supported video file",
+ };
+ }
+ if (!Number.isFinite(offset) || offset < 0 || !Number.isFinite(length) || length <= 0) {
+ return { success: false, message: "Invalid chunk range" };
+ }
+ if (length > MAX_IPC_CHUNK_BYTES) {
+ return { success: false, message: "Requested chunk size exceeds limit" };
+ }
+
+ const handle = await fs.open(normalizedPath, "r");
+ try {
+ const buffer = Buffer.allocUnsafe(length);
+ const { bytesRead } = await handle.read(buffer, 0, length, offset);
+ return {
+ success: true,
+ data: buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + bytesRead),
+ bytesRead,
+ };
+ } finally {
+ await handle.close();
+ }
+ } catch (error) {
+ console.error("Failed to read file chunk:", error);
+ return {
+ success: false,
+ message: "Failed to read file chunk",
+ error: String(error),
+ };
+ }
+ });
+
ipcMain.handle("prepare-preview-audio-track", async (_, filePath: string) => {
try {
return await prepareSupplementalPreviewAudioTrack(filePath);
@@ -2622,16 +2749,34 @@ export function registerIpcHandlers(
}
}
- ipcMain.handle("load-project-file", async () => {
- return loadProjectFile();
+ ipcMain.handle("load-project-file", async (_, projectFolder?: string) => {
+ return loadProjectFile(projectFolder);
});
- async function loadProjectFile(): Promise {
+ async function loadProjectFile(projectFolder?: string): Promise {
try {
+ // Prefer the user's last opened-project folder if it still exists, else
+ // RECORDINGS_DIR. Validate here because the renderer can't stat the filesystem.
+ let defaultDir = RECORDINGS_DIR;
+ if (projectFolder) {
+ try {
+ const stats = await fs.stat(projectFolder);
+ if (stats.isDirectory()) {
+ defaultDir = projectFolder;
+ }
+ } catch (err) {
+ // Stat can fail if the folder was moved/deleted (expected) or on a
+ // permission error (worth surfacing). We fall back either way, but log it.
+ console.warn(
+ `Could not access remembered project folder "${projectFolder}", falling back to RECORDINGS_DIR:`,
+ err,
+ );
+ }
+ }
const dialogOptions = buildDialogOptions(
{
title: mainT("dialogs", "fileDialogs.openProject"),
- defaultPath: RECORDINGS_DIR,
+ defaultPath: defaultDir,
filters: [
{
name: mainT("dialogs", "fileDialogs.openscreenProject"),
@@ -2692,9 +2837,8 @@ export function registerIpcHandlers(
const project = JSON.parse(content);
currentProjectPath = filePath;
- // Approve session paths; tolerate failures (e.g. video moved outside
- // trusted dirs) so the project still loads and the renderer can surface
- // a "video not found" error rather than a generic load failure.
+ // Approve session paths but tolerate failures (e.g. video moved outside trusted
+ // dirs) so the project still loads and the renderer can show "video not found".
let session: import("../../src/lib/recordingSession").RecordingSession | null = null;
try {
session = await getApprovedProjectSession(project, filePath);
@@ -2840,6 +2984,9 @@ export function registerIpcHandlers(
if (canceled || !filePath) return { success: false, canceled: true };
+ const HELPER_OUTPUT_MAX_BYTES = 64 * 1024;
+ const tail = (s: string, max: number) => (s.length <= max ? s : s.slice(s.length - max));
+
const diagnostic = {
timestamp: new Date().toISOString(),
appVersion: app.getVersion(),
@@ -2855,6 +3002,11 @@ export function registerIpcHandlers(
stack: payload.stack,
projectState: payload.projectState,
recentLogs: payload.logs,
+ helperOutput: {
+ windows: tail(nativeWindowsCaptureOutput, HELPER_OUTPUT_MAX_BYTES),
+ mac: tail(nativeMacCaptureOutput, HELPER_OUTPUT_MAX_BYTES),
+ },
+ mainProcessLogs: mainLogBuffer.snapshot(),
};
try {
diff --git a/electron/ipc/nativeBridge.ts b/electron/ipc/nativeBridge.ts
index 425f93e1a3..2669300a93 100644
--- a/electron/ipc/nativeBridge.ts
+++ b/electron/ipc/nativeBridge.ts
@@ -25,7 +25,7 @@ export interface NativeBridgeContext {
suggestedName?: string,
existingProjectPath?: string,
) => Promise;
- loadProjectFile: () => Promise;
+ loadProjectFile: (projectFolder?: string) => Promise;
loadCurrentProjectFile: () => Promise;
loadProjectFileFromPath: (path: string) => Promise;
setCurrentVideoPath: (path: string) => ProjectPathResult | Promise;
@@ -164,7 +164,10 @@ export function registerNativeBridgeHandlers(context: NativeBridgeContext) {
),
);
case "loadProjectFile":
- return createSuccessResponse(requestId, await projectService.loadProjectFile());
+ return createSuccessResponse(
+ requestId,
+ await projectService.loadProjectFile(request.payload?.projectFolder),
+ );
case "loadCurrentProjectFile":
return createSuccessResponse(
requestId,
diff --git a/electron/ipc/recordingStream.ts b/electron/ipc/recordingStream.ts
index 3dce5b955f..665ea41941 100644
--- a/electron/ipc/recordingStream.ts
+++ b/electron/ipc/recordingStream.ts
@@ -3,23 +3,18 @@ import { unlink } from "node:fs/promises";
import type { IpcMain } from "electron";
/**
- * Owns the lifecycle of on-disk write streams for in-progress recordings, keyed
- * by the recording's output file name. Browser MediaRecorder chunks are appended
- * here as they arrive so a long recording never buffers the whole video in the
- * renderer (the #616 fix).
- *
- * The file name is the key because it is the one value the renderer and main
- * process already exchange and it is globally unique per recording, so there is
- * no derived/offset key to keep in sync across the IPC boundary.
+ * Owns write streams for in-progress recordings, keyed by output file name.
+ * MediaRecorder chunks are appended as they arrive so a long recording never
+ * buffers the whole video in the renderer (#616 fix). File name is the key
+ * because it's already exchanged across IPC and is unique per recording.
*/
export class RecordingStreamRegistry {
private readonly streams = new Map();
/**
- * Open a write stream and resolve only once the OS confirms it is writable.
- * Resolving on the `open` event (rather than on `createWriteStream` returning)
- * means a bad path or permission error rejects here instead of surfacing as a
- * silent chunk drop later, so the renderer's fallback can take over.
+ * Open a write stream, resolving only on the `open` event so a bad path or
+ * permission error rejects here instead of becoming a silent chunk drop later,
+ * letting the renderer's fallback take over.
*/
async open(fileName: string, filePath: string): Promise {
await this.endStream(fileName);
@@ -33,9 +28,8 @@ export class RecordingStreamRegistry {
resolve();
});
});
- // Keep a listener for the stream's lifetime so a late error logs rather
- // than crashing the main process with an unhandled 'error' event. Per-write
- // failures still surface through the `append` callback below.
+ // Keep a lifetime listener so a late error logs instead of crashing the main
+ // process with an unhandled 'error'. Per-write failures still surface in `append`.
ws.on("error", (error) => {
console.error(`[recording-stream] ${fileName}:`, error);
});
@@ -59,9 +53,8 @@ export class RecordingStreamRegistry {
}
/**
- * Flush and close the stream, keeping the file. Returns whether a stream was
- * open — i.e. whether the recording was streamed to disk (true) or needs its
- * in-memory buffer written by the caller (false).
+ * Flush and close the stream, keeping the file. Returns true if a stream was
+ * open (streamed to disk) or false if the caller still needs to write its buffer.
*/
async finalize(fileName: string): Promise {
const ws = this.streams.get(fileName);
@@ -76,9 +69,8 @@ export class RecordingStreamRegistry {
}
/**
- * Close the stream (if any) and delete the partial file. Used when a streamed
- * recording is discarded or fails before a successful save, so cancelled runs
- * don't leak file descriptors or orphan partial recordings on disk.
+ * Close the stream (if any) and delete the partial file, so a discarded or
+ * failed recording doesn't leak descriptors or orphan partial files on disk.
*/
async discard(fileName: string, filePath: string): Promise {
await this.endStream(fileName);
diff --git a/electron/main.ts b/electron/main.ts
index 14255d5b34..fde69aa6f8 100644
--- a/electron/main.ts
+++ b/electron/main.ts
@@ -12,6 +12,7 @@ import {
Tray,
} from "electron";
import { ShortcutBinding } from "../src/lib/shortcuts";
+import { isDiagnosticModeEnabled, mainLogBuffer } from "./diagnostics/main-log-buffer";
import {
loadAndRegisterGlobalShortcut,
registerOpenAppShortcut,
@@ -19,24 +20,25 @@ import {
} from "./globalShortcut";
import { mainT, setMainLocale } from "./i18n";
import { getSelectedDesktopSource, registerIpcHandlers } from "./ipc/handlers";
+import { acquireStableInstanceLock } from "./singleInstanceLock";
import {
createCountdownOverlayWindow,
createEditorWindow,
createHudOverlayWindow,
+ createNotesWindow,
createSourceSelectorWindow,
} from "./windows";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
-// Use Screen & System Audio Recording permissions instead of CoreAudio Tap API on macOS.
-// CoreAudio Tap requires NSAudioCaptureUsageDescription in the parent app's Info.plist,
-// which doesn't work when running from a terminal/IDE during development, makes my life easier
+// Use Screen & System Audio Recording permissions instead of the CoreAudio Tap API on macOS.
+// Tap needs NSAudioCaptureUsageDescription in the parent app's Info.plist, which breaks when
+// running from a terminal/IDE during dev.
if (process.platform === "darwin") {
app.commandLine.appendSwitch("disable-features", "MacCatapLoopbackAudioForScreenShare");
}
-// Enable Wayland support for proper screen capture and window management
-// on Wayland compositors (Hyprland, GNOME, KDE, etc.)
+// Wayland support for screen capture and window management on Wayland compositors.
if (process.platform === "linux") {
const isWayland =
process.env.XDG_SESSION_TYPE === "wayland" || process.env.WAYLAND_DISPLAY !== undefined;
@@ -83,6 +85,7 @@ process.env.VITE_PUBLIC = VITE_DEV_SERVER_URL
let mainWindow: BrowserWindow | null = null;
let sourceSelectorWindow: BrowserWindow | null = null;
let countdownOverlayWindow: BrowserWindow | null = null;
+let notesWindow: BrowserWindow | null = null;
let tray: Tray | null = null;
let selectedSourceName = "";
const isMac = process.platform === "darwin";
@@ -93,6 +96,10 @@ const defaultTrayIcon = getTrayIcon("openscreen.png", trayIconSize);
const recordingTrayIcon = getTrayIcon("rec-button.png", trayIconSize);
function createWindow() {
+ if (mainWindow && !mainWindow.isDestroyed()) {
+ return;
+ }
+
mainWindow = createHudOverlayWindow();
}
@@ -109,6 +116,19 @@ function showMainWindow() {
createWindow();
}
+const stableInstanceLock = acquireStableInstanceLock();
+const hasElectronSingleInstanceLock = app.requestSingleInstanceLock();
+const hasSingleInstanceLock = Boolean(stableInstanceLock && hasElectronSingleInstanceLock);
+
+if (hasSingleInstanceLock) {
+ app.on("second-instance", () => {
+ showMainWindow();
+ });
+} else {
+ stableInstanceLock?.release();
+ app.quit();
+}
+
function isEditorWindow(window: BrowserWindow) {
return window.webContents.getURL().includes("windowType=editor");
}
@@ -384,7 +404,7 @@ function createEditorWindowWrapper() {
const windowToClose = mainWindow;
if (!windowToClose || windowToClose.isDestroyed()) return;
- // Ask renderer to show the custom in-app dialog
+ // Ask renderer to show the in-app close dialog.
windowToClose.webContents.send("request-close-confirm");
ipcMain.once("close-confirm-response", (event, choice: "save" | "discard" | "cancel") => {
@@ -393,7 +413,7 @@ function createEditorWindowWrapper() {
if (!windowToClose || windowToClose.isDestroyed()) return;
if (choice === "save") {
- // Tell renderer to save the project, then close when done
+ // Save first, then close when the renderer reports done.
windowToClose.webContents.send("request-save-before-close");
ipcMain.once("save-before-close-done", (event, shouldClose: boolean) => {
if (event.sender.id !== windowToClose?.webContents.id) return;
@@ -412,10 +432,26 @@ function createSourceSelectorWindowWrapper() {
sourceSelectorWindow = createSourceSelectorWindow();
sourceSelectorWindow.on("closed", () => {
sourceSelectorWindow = null;
+ if (mainWindow && !mainWindow.isDestroyed()) {
+ mainWindow.webContents.send("source-selector-closed");
+ }
});
return sourceSelectorWindow;
}
+function createNotesWindowWrapper() {
+ {
+ notesWindow = createNotesWindow();
+ notesWindow.on("closed", () => {
+ notesWindow = null;
+ if (mainWindow && !mainWindow.isDestroyed()) {
+ mainWindow.webContents.send("notes-window-closed");
+ }
+ });
+ return notesWindow;
+ }
+}
+
function createCountdownOverlayWindowWrapper() {
if (countdownOverlayWindow && !countdownOverlayWindow.isDestroyed()) {
return countdownOverlayWindow;
@@ -428,16 +464,14 @@ function createCountdownOverlayWindowWrapper() {
return countdownOverlayWindow;
}
-// Closing every window quits the app entirely (tray icon goes too).
-// The in-app "Return to Recorder" button covers the editor → HUD round-trip,
-// so closing the last window is an explicit "I'm done" signal.
+// Closing every window quits the app (tray goes too). The in-app "Return to Recorder"
+// button covers the editor-to-HUD round-trip, so closing the last window means "I'm done".
app.on("window-all-closed", () => {
app.quit();
});
app.on("activate", () => {
- // On OS X it's common to re-create a window in the app when the
- // dock icon is clicked and there are no other windows open.
+ // On macOS, re-open a window when the dock icon is clicked and none are open.
const hasVisibleWindow = BrowserWindow.getAllWindows().some((window) => {
if (window.isDestroyed() || !window.isVisible()) {
return false;
@@ -454,18 +488,24 @@ app.on("activate", () => {
app.on("will-quit", () => {
unregisterAllGlobalShortcuts();
+ stableInstanceLock?.release();
});
-// Register all IPC handlers when app is ready
-app.whenReady().then(async () => {
- // Force the app into "regular" activation policy so the Dock icon appears.
- // The HUD overlay (transparent + frameless + skipTaskbar) is the first
- // window we open, and AppKit otherwise classifies us as an accessory app.
+const appReady = hasSingleInstanceLock ? app.whenReady() : null;
+
+appReady?.then(async () => {
+ if (isDiagnosticModeEnabled()) {
+ mainLogBuffer.install();
+ console.info("[diagnostic] OPENSCREEN_DIAGNOSTIC=1, capturing console.* into ring buffer");
+ }
+
+ // Force "regular" activation policy so the Dock icon appears. The HUD overlay
+ // (transparent, frameless, skipTaskbar) is the first window, and AppKit would
+ // otherwise classify us as an accessory app.
if (process.platform === "darwin") {
app.dock?.show();
}
- // Allow microphone/media/screen permission checks
session.defaultSession.setPermissionCheckHandler((_webContents, permission) => {
const allowed = [
"media",
@@ -508,9 +548,8 @@ app.whenReady().then(async () => {
{ useSystemPicker: false },
);
- // Request microphone permission from macOS. Screen Recording is requested
- // lazily from the source-picker action so the system prompt is not hidden
- // behind OpenScreen's source selector window.
+ // Request mic permission now. Screen Recording is requested lazily from the
+ // source-picker action so its prompt isn't hidden behind the selector window.
if (process.platform === "darwin") {
const micStatus = systemPreferences.getMediaAccessStatus("microphone");
if (micStatus !== "granted") {
@@ -518,7 +557,6 @@ app.whenReady().then(async () => {
}
}
- // Listen for HUD overlay quit event (macOS only)
ipcMain.on("hud-overlay-close", () => {
app.quit();
});
@@ -536,7 +574,6 @@ app.whenReady().then(async () => {
createTray();
updateTrayMenu();
setupApplicationMenu();
- // Ensure recordings directory exists
await ensureRecordingsDir();
function switchToHudWrapper() {
@@ -553,8 +590,10 @@ app.whenReady().then(async () => {
createEditorWindowWrapper,
createSourceSelectorWindowWrapper,
createCountdownOverlayWindowWrapper,
+ createNotesWindowWrapper,
() => mainWindow,
() => sourceSelectorWindow,
+ () => notesWindow,
() => countdownOverlayWindow,
(recording: boolean, sourceName: string) => {
selectedSourceName = sourceName;
diff --git a/electron/native-bridge/cursor/recording/factory.ts b/electron/native-bridge/cursor/recording/factory.ts
index 0ba3077888..9a82ffd55c 100644
--- a/electron/native-bridge/cursor/recording/factory.ts
+++ b/electron/native-bridge/cursor/recording/factory.ts
@@ -36,7 +36,7 @@ export function createCursorRecordingSession(
}
// Linux: capture cursor positions via Electron's `screen` API on an interval.
- // No cursor sprites/assets and no clicks — just position telemetry.
+ // No cursor sprites/assets and no clicks, just position telemetry.
return new TelemetryRecordingSession({
getDisplayBounds: options.getDisplayBounds,
maxSamples: options.maxSamples,
diff --git a/electron/native-bridge/cursor/recording/macNativeCursorRecordingSession.ts b/electron/native-bridge/cursor/recording/macNativeCursorRecordingSession.ts
index 5e09e92981..27a8a870c2 100644
--- a/electron/native-bridge/cursor/recording/macNativeCursorRecordingSession.ts
+++ b/electron/native-bridge/cursor/recording/macNativeCursorRecordingSession.ts
@@ -193,7 +193,7 @@ export class MacNativeCursorRecordingSession implements CursorRecordingSession {
private readyTimer: NodeJS.Timeout | null = null;
private previousLeftButtonDown = false;
private consecutiveOutsideSamples = 0;
- // Only hide after this many consecutive out-of-bounds samples (≈100ms at 33ms interval).
+ // Hide only after this many consecutive out-of-bounds samples (~100ms at 33ms interval).
// Fast swipes that briefly exit the display are clipped by clip-path instead of disappearing.
private static readonly OUTSIDE_HIDE_THRESHOLD = 3;
@@ -211,7 +211,7 @@ export class MacNativeCursorRecordingSession implements CursorRecordingSession {
systemPreferences.isTrustedAccessibilityClient(true);
} catch {
// Without Accessibility, text/pointer affordance detection is unavailable;
- // cursor bitmaps are still captured natively via NSCursor.
+ // bitmaps are still captured natively via NSCursor.
}
const helperPath = findMacCursorHelperPath();
@@ -370,10 +370,9 @@ export class MacNativeCursorRecordingSession implements CursorRecordingSession {
const normalizedY = (cursor.y - bounds.y) / height;
const isOutsideDisplay =
normalizedX < 0 || normalizedX > 1 || normalizedY < 0 || normalizedY > 1;
- // Fast swipes that briefly exit the display (=THRESHOLD, ~100ms) mark visible=false to
+ // avoid ghost cursors and motion trails from multi-display movement.
if (isOutsideDisplay) {
this.consecutiveOutsideSamples++;
} else {
diff --git a/electron/native-bridge/cursor/recording/windowsNativeRecordingSession.ts b/electron/native-bridge/cursor/recording/windowsNativeRecordingSession.ts
index 5c318f0f29..c7a057fea3 100644
--- a/electron/native-bridge/cursor/recording/windowsNativeRecordingSession.ts
+++ b/electron/native-bridge/cursor/recording/windowsNativeRecordingSession.ts
@@ -22,10 +22,14 @@ function getCursorSamplerCandidates(): string[] {
const p = join(app.getAppPath(), ...segs);
return app.isPackaged ? p.replace(/\.asar([/\\])/, ".asar.unpacked$1") : p;
};
+ const resolvePackaged = (...segs: string[]) => {
+ return app.isPackaged ? join(process.resourcesPath, ...segs) : null;
+ };
return [
envPath,
resolve("electron", "native", "wgc-capture", "build", "cursor-sampler.exe"),
resolve("electron", "native", "bin", archTag, "cursor-sampler.exe"),
+ resolvePackaged("electron", "native", "bin", archTag, "cursor-sampler.exe"),
].filter((c): c is string => Boolean(c));
}
@@ -222,10 +226,21 @@ export class WindowsNativeRecordingSession implements CursorRecordingSession {
): NormalizedSample {
const bounds =
payload.bounds ?? this.options.getDisplayBounds() ?? screen.getPrimaryDisplay().bounds;
- const width = Math.max(1, bounds.width);
- const height = Math.max(1, bounds.height);
- const normalizedX = (payload.x - bounds.x) / width;
- const normalizedY = (payload.y - bounds.y) / height;
+ // The cursor-sampler reports raw x/y in physical screen pixels (Win32
+ // GetCursorInfo). `payload.bounds` from the sampler's GetWindowRect is also
+ // physical, so use it as-is. Bounds from Electron's `screen` API (or the
+ // fallback for display captures) are in DIPs — convert to physical screen
+ // coordinates via `dipToScreenRect`, which correctly handles the virtual-screen
+ // origin across multi-monitor and mixed-DPI setups (a naive
+ // `bounds.x * scaleFactor` would misplace the origin on non-primary
+ // displays).
+ const physicalBounds = payload.bounds != null ? bounds : screen.dipToScreenRect(null, bounds);
+ const physicalX = physicalBounds.x;
+ const physicalY = physicalBounds.y;
+ const width = Math.max(1, physicalBounds.width);
+ const height = Math.max(1, physicalBounds.height);
+ const normalizedX = (payload.x - physicalX) / width;
+ const normalizedY = (payload.y - physicalY) / height;
const withinBounds =
normalizedX >= 0 && normalizedX <= 1 && normalizedY >= 0 && normalizedY <= 1;
const leftButtonDown = payload.leftButtonDown === true;
diff --git a/electron/native-bridge/services/projectService.ts b/electron/native-bridge/services/projectService.ts
index 9e96aa22d9..0c363cc13a 100644
--- a/electron/native-bridge/services/projectService.ts
+++ b/electron/native-bridge/services/projectService.ts
@@ -14,7 +14,7 @@ interface ProjectServiceOptions {
suggestedName?: string,
existingProjectPath?: string,
) => Promise;
- loadProjectFile: () => Promise;
+ loadProjectFile: (projectFolder?: string) => Promise;
loadCurrentProjectFile: () => Promise;
loadProjectFileFromPath: (path: string) => Promise;
setCurrentVideoPath: (path: string) => ProjectPathResult | Promise;
@@ -49,8 +49,8 @@ export class ProjectService {
return result;
}
- async loadProjectFile() {
- const result = await this.options.loadProjectFile();
+ async loadProjectFile(projectFolder?: string) {
+ const result = await this.options.loadProjectFile(projectFolder);
this.getCurrentContext();
return result;
}
diff --git a/electron/native/README.md b/electron/native/README.md
index 59930ba36a..cda6c9f55d 100644
--- a/electron/native/README.md
+++ b/electron/native/README.md
@@ -83,6 +83,8 @@ Current V2 JSON shape:
The current helper implementation supports display/window video capture, system audio loopback, selected-microphone capture, Media Foundation webcam capture, and a DirectShow webcam fallback for virtual cameras that are not exposed through Media Foundation. Webcam frames are currently composed into the primary MP4 as a bottom-right picture-in-picture overlay. Browser `deviceId` values do not always map to Media Foundation symbolic links or WASAPI endpoint IDs, so the renderer passes both browser IDs and user-visible device names. For microphones, the helper tries the requested WASAPI endpoint ID first, then resolves an active capture endpoint by `microphoneDeviceName`, then falls back to the default endpoint. For webcams, Electron resolves a matching DirectShow filter CLSID for the selected label; the helper uses Media Foundation first, then that exact DirectShow filter when the requested camera is absent from Media Foundation.
+Encoder diagnostic on sink-writer failure: when `MFCreateSinkWriterFromURL` fails, the helper logs the registered H.264 video encoder MFT count (via `MFTEnumEx`), the registered AAC encoder count when audio was requested, and the hex HRESULT. If no H.264 encoder is registered, it additionally emits the four-bullet actionable error (missing Media Feature Pack / GPU driver registration / empty `HKLM:\SOFTWARE\Microsoft\Windows Media Foundation\Transforms` / reboot). If an H.264 encoder IS registered but the sink writer still failed, it logs a hint pointing at invalid output path, missing MP4 mux, or GPU driver incompatibility. The diagnostic runs only on the failure path — there is no pre-flight gating check, because `MFTEnumEx` and `MFCreateSinkWriterFromURL` can disagree about which H.264 encoders are "available" in non-interactive / Session 0 contexts, and a pre-flight zero-count would block a recording the sink writer can otherwise complete.
+
Smoke-test the helper with:
```powershell
diff --git a/electron/native/screencapturekit/Sources/OpenScreenScreenCaptureKitHelper/main.swift b/electron/native/screencapturekit/Sources/OpenScreenScreenCaptureKitHelper/main.swift
index 14860b03f9..9525b717bd 100644
--- a/electron/native/screencapturekit/Sources/OpenScreenScreenCaptureKitHelper/main.swift
+++ b/electron/native/screencapturekit/Sources/OpenScreenScreenCaptureKitHelper/main.swift
@@ -124,6 +124,9 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate {
let filter: SCContentFilter
let width: Int
let height: Int
+ // Global frame (points, top-left origin) of the captured region. Used by the
+ // renderer to normalize cursor positions into the captured window's space.
+ let captureFrame: CGRect
}
private let request: RecordingRequest
@@ -143,6 +146,7 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate {
private var nativeMicrophoneEnabled = false
private var outputWidth = 1920
private var outputHeight = 1080
+ private var captureFrame = CGRect.zero
private let microphoneOutputTypeRawValue = 2
private let hostClock = CMClockGetHostTimeClock()
@@ -160,6 +164,7 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate {
let target = try makeCaptureTarget(from: content)
outputWidth = target.width
outputHeight = target.height
+ captureFrame = target.captureFrame
let configuration = makeStreamConfiguration()
let stream = SCStream(filter: target.filter, configuration: configuration, delegate: self)
@@ -178,7 +183,10 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate {
try setupWriter()
self.stream = stream
- emit(["event": "ready", "schemaVersion": 1])
+ emit([
+ "event": "ready",
+ "schemaVersion": 1,
+ ])
try await stream.startCapture()
}
@@ -305,6 +313,7 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate {
"timestampMs": Int(Date().timeIntervalSince1970 * 1000),
"width": outputWidth,
"height": outputHeight,
+ "captureBounds": captureBoundsPayload(),
])
}
}
@@ -337,6 +346,15 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate {
}
}
+ private func captureBoundsPayload() -> [String: Double] {
+ return [
+ "x": captureFrame.origin.x,
+ "y": captureFrame.origin.y,
+ "width": captureFrame.size.width,
+ "height": captureFrame.size.height,
+ ]
+ }
+
private func makeCaptureTarget(from content: SCShareableContent) throws -> CaptureTarget {
switch request.source.type {
case "display":
@@ -351,7 +369,8 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate {
return CaptureTarget(
filter: SCContentFilter(display: display, excludingWindows: []),
width: clampCaptureDimension(width, fallback: request.video.width),
- height: clampCaptureDimension(height, fallback: request.video.height)
+ height: clampCaptureDimension(height, fallback: request.video.height),
+ captureFrame: display.frame
)
case "window":
guard let windowId = request.source.windowId else {
@@ -369,7 +388,8 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate {
return CaptureTarget(
filter: SCContentFilter(desktopIndependentWindow: window),
width: clampCaptureDimension(width, fallback: request.video.width),
- height: clampCaptureDimension(height, fallback: request.video.height)
+ height: clampCaptureDimension(height, fallback: request.video.height),
+ captureFrame: window.frame
)
default:
throw HelperError.invalidSourceType(request.source.type)
diff --git a/electron/native/wgc-capture/src/main.cpp b/electron/native/wgc-capture/src/main.cpp
index bb741d33e0..af73b26fce 100644
--- a/electron/native/wgc-capture/src/main.cpp
+++ b/electron/native/wgc-capture/src/main.cpp
@@ -632,8 +632,8 @@ int main(int argc, char* argv[]) {
(webcamOutputFrameIndex * 10'000'000ULL) / std::max(1, webcamCapture.fps()));
if (!webcamEncoder.writeBgraFrame(webcamFrame, webcamTimestampHns)) {
encodeFailed = true;
- stopRequested = true;
- cv.notify_all();
+ control.stopRequested = true;
+ control.cv.notify_all();
return;
}
lastWrittenWebcamSequence = latestWebcamSequence;
@@ -823,19 +823,34 @@ int main(int argc, char* argv[]) {
});
}
+ const auto stopStart = std::chrono::steady_clock::now();
+ auto logStopStep = [&](const char* step) {
+ const auto ms = std::chrono::duration_cast(
+ std::chrono::steady_clock::now() - stopStart).count();
+ std::cerr << "[stop-timing] step=" << step << " elapsed_ms=" << ms << std::endl;
+ };
+
microphoneCapture.stop();
+ logStopStep("microphone");
loopbackCapture.stop();
+ logStopStep("loopback");
webcamCapture.stop();
+ logStopStep("webcam");
if (audioMixer) {
audioMixer->stop();
}
+ logStopStep("audio-mixer");
stopVideoWriter();
+ logStopStep("video-writer-join");
session.stop();
+ logStopStep("wgc-session-close");
{
std::scoped_lock lock(mutex);
encoder.finalize();
+ logStopStep("encoder-finalize");
if (writeSeparateWebcam) {
webcamEncoder.finalize();
+ logStopStep("webcam-encoder-finalize");
}
}
diff --git a/electron/native/wgc-capture/src/mf_encoder.cpp b/electron/native/wgc-capture/src/mf_encoder.cpp
index 18bc4cca85..738a29b588 100644
--- a/electron/native/wgc-capture/src/mf_encoder.cpp
+++ b/electron/native/wgc-capture/src/mf_encoder.cpp
@@ -22,6 +22,107 @@ bool succeeded(HRESULT hr, const char* label) {
return false;
}
+// Count how many Media Foundation Transforms are registered for a given
+// (category, output subtype) pair. Caller does not need the activations
+// themselves; we just want to know whether at least one is registered so we
+// can diagnose "no encoder registered" vs "encoder registered but sink can't
+// wire it".
+//
+// `mediaType` is the encoder's major media type (e.g. video for video
+// encoders, audio for audio encoders). It is used as both the input and
+// output major type because an encoder's input and output streams share the
+// same major type by definition. `outputSubtype` is the encoder's output
+// media subtype (e.g. H264 for video encoders, AAC for audio encoders). We do
+// not constrain the input subtype because encoders typically accept many
+// input subtypes and constraining here would under-count.
+UINT32 countRegisteredMfts(
+ const GUID& category,
+ const GUID& mediaType,
+ const GUID& outputSubtype) {
+ MFT_REGISTER_TYPE_INFO inputType{};
+ inputType.guidMajorType = mediaType;
+ inputType.guidSubtype = GUID_NULL;
+
+ MFT_REGISTER_TYPE_INFO outputType{};
+ outputType.guidMajorType = mediaType;
+ outputType.guidSubtype = outputSubtype;
+
+ // MFT_ENUM_FLAG_ALL is the documented flag set for "synchronous, async,
+ // hardware, software" — there is no separate SOFTWARE flag. Using a
+ // narrower flag set can omit legitimate encoders (e.g. the AMD AMF H.264
+ // encoder is async hardware).
+ IMFActivate** activates = nullptr;
+ UINT32 count = 0;
+ HRESULT hr = MFTEnumEx(
+ category,
+ MFT_ENUM_FLAG_ALL,
+ &inputType,
+ &outputType,
+ &activates,
+ &count);
+ if (FAILED(hr)) {
+ // MFTEnumEx failed outright (e.g. COM not initialized, invalid
+ // category). Surface the HRESULT so future bug reports can
+ // distinguish this from "zero encoders registered" (which returns
+ // SUCCEEDED(hr) with count == 0).
+ std::cerr << "ERROR: MFTEnumEx failed (hr=0x" << std::hex << hr
+ << std::dec << ")" << std::endl;
+ return 0;
+ }
+ if (activates != nullptr) {
+ for (UINT32 i = 0; i < count; i += 1) {
+ if (activates[i] != nullptr) {
+ activates[i]->Release();
+ }
+ }
+ CoTaskMemFree(activates);
+ }
+ return count;
+}
+
+UINT32 countRegisteredH264VideoEncoders() {
+ return countRegisteredMfts(
+ MFT_CATEGORY_VIDEO_ENCODER, MFMediaType_Video, MFVideoFormat_H264);
+}
+
+UINT32 countRegisteredAacAudioEncoders() {
+ return countRegisteredMfts(
+ MFT_CATEGORY_AUDIO_ENCODER, MFMediaType_Audio, MFAudioFormat_AAC);
+}
+
+void logMissingH264EncoderError() {
+ std::cerr
+ << "ERROR: No H.264 video encoder MFT is registered on this system."
+ << std::endl;
+ std::cerr
+ << " Windows could not find any Media Foundation Transform that "
+ << "outputs MFVideoFormat_H264." << std::endl;
+ std::cerr
+ << " MP4 recording requires an H.264 encoder. Without one, "
+ << "MFCreateSinkWriterFromURL fails (hr=0x80070003)."
+ << std::endl;
+ std::cerr
+ << " Try the following fixes in order:" << std::endl;
+ std::cerr
+ << " 1. Install the Media Feature Pack via Optional Features "
+ << "(Settings > Apps > Optional features > Add > Media Feature Pack), "
+ << "or run: Dism /online /add-capability /capabilityname:Media.MediaFeaturePack~~~~0.0.1.0"
+ << std::endl;
+ std::cerr
+ << " 2. Update your GPU drivers so the hardware H.264 encoder MFT "
+ << "(AMD AMF, NVIDIA NVENC, Intel Quick Sync) re-registers itself."
+ << std::endl;
+ std::cerr
+ << " 3. Inspect the registered transforms under"
+ << " HKLM:\\SOFTWARE\\Microsoft\\Windows Media Foundation\\Transforms"
+ << " and HKLM:\\SOFTWARE\\Classes\\MediaFoundation\\Transforms."
+ << std::endl;
+ std::cerr
+ << " 4. Reboot after driver or Media Feature Pack changes; "
+ << "MFT registration is cached at boot."
+ << std::endl;
+}
+
void setFrameSize(IMFMediaType* type, UINT32 width, UINT32 height) {
MFSetAttributeSize(type, MF_MT_FRAME_SIZE, width, height);
}
@@ -98,6 +199,15 @@ bool MFEncoder::initialize(
device_ = device;
context_ = context;
+ // No H.264 encoder pre-flight check here. MFTEnumEx and
+ // MFCreateSinkWriterFromURL can disagree about which H.264 encoders are
+ // "available" in non-interactive / Session 0 contexts (NVENC et al. are
+ // registered but their COM server may not fully activate from a service
+ // session). A pre-flight that fails fast on a zero MFTEnumEx count would
+ // therefore break a recording that the sink writer can complete
+ // successfully. The diagnostic dump below runs only on the failure path,
+ // so a healthy MFTEnumEx result never blocks a working recording.
+
if (!succeeded(MFStartup(MF_VERSION), "MFStartup")) {
return false;
}
@@ -114,8 +224,37 @@ bool MFEncoder::initialize(
setFrameRate(outputType.Get(), static_cast(fps_));
setPixelAspectRatio(outputType.Get());
- if (!succeeded(MFCreateSinkWriterFromURL(outputPath.c_str(), nullptr, nullptr, &sinkWriter_),
- "MFCreateSinkWriterFromURL")) {
+ HRESULT sinkWriterHr = MFCreateSinkWriterFromURL(
+ outputPath.c_str(), nullptr, nullptr, &sinkWriter_);
+ if (FAILED(sinkWriterHr)) {
+ // The HRESULT alone is not actionable. Tell the user whether an H.264
+ // encoder is registered at all (no H.264 == the MFP missing-MFT path),
+ // whether AAC is registered (only when audio is requested), and the
+ // hex HRESULT so the exact failure is greppable. Encoder counts are
+ // computed lazily here, only on the failure path, so a healthy
+ // recording does not pay for an MFTEnumEx call.
+ const UINT32 h264EncoderCount = countRegisteredH264VideoEncoders();
+ const UINT32 aacEncoderCount = (audioFormat != nullptr)
+ ? countRegisteredAacAudioEncoders()
+ : 0;
+ std::cerr << "ERROR: MFCreateSinkWriterFromURL failed (hr=0x"
+ << std::hex << sinkWriterHr << std::dec << ")" << std::endl;
+ std::cerr << " Registered H.264 video encoder MFTs: " << h264EncoderCount
+ << std::endl;
+ if (audioFormat != nullptr) {
+ std::cerr << " Registered AAC audio encoder MFTs: " << aacEncoderCount
+ << std::endl;
+ }
+ if (h264EncoderCount == 0) {
+ logMissingH264EncoderError();
+ } else {
+ std::cerr
+ << " An H.264 encoder MFT is registered but the sink writer "
+ << "still failed. Possible causes: invalid output path or "
+ << "permissions, no MP4 mux configured, or GPU driver "
+ << "incompatibility with this Media Foundation build."
+ << std::endl;
+ }
return false;
}
if (!succeeded(sinkWriter_->AddStream(outputType.Get(), &videoStreamIndex_), "AddStream")) {
diff --git a/electron/preload.ts b/electron/preload.ts
index a89d296ee5..32e014953e 100644
--- a/electron/preload.ts
+++ b/electron/preload.ts
@@ -29,6 +29,9 @@ contextBridge.exposeInMainWorld("electronAPI", {
moveHudOverlayBy: (deltaX: number, deltaY: number) => {
ipcRenderer.send("hud-overlay-move-by", deltaX, deltaY);
},
+ setHudOverlaySize: (width: number, height: number) => {
+ ipcRenderer.send("hud-overlay-set-size", width, height);
+ },
getSources: async (opts: Electron.SourcesOptions) => {
return await ipcRenderer.invoke("get-sources", opts);
},
@@ -44,12 +47,25 @@ contextBridge.exposeInMainWorld("electronAPI", {
openSourceSelector: () => {
return ipcRenderer.invoke("open-source-selector");
},
+ openNotes: () => {
+ return ipcRenderer.invoke("open-notes");
+ },
selectSource: (source: ProcessedDesktopSource) => {
return ipcRenderer.invoke("select-source", source);
},
getSelectedSource: () => {
return ipcRenderer.invoke("get-selected-source");
},
+ onSelectedSourceChanged: (callback: (source: ProcessedDesktopSource) => void) => {
+ const listener = (_event: unknown, source: ProcessedDesktopSource) => callback(source);
+ ipcRenderer.on("selected-source-changed", listener);
+ return () => ipcRenderer.removeListener("selected-source-changed", listener);
+ },
+ onSourceSelectorClosed: (callback: () => void) => {
+ const listener = () => callback();
+ ipcRenderer.on("source-selector-closed", listener);
+ return () => ipcRenderer.removeListener("source-selector-closed", listener);
+ },
requestCameraAccess: () => {
return ipcRenderer.invoke("request-camera-access");
},
@@ -161,6 +177,12 @@ contextBridge.exposeInMainWorld("electronAPI", {
readBinaryFile: (filePath: string) => {
return ipcRenderer.invoke("read-binary-file", filePath);
},
+ getReadableFileInfo: (filePath: string) => {
+ return ipcRenderer.invoke("get-readable-file-info", filePath);
+ },
+ readFileChunk: (filePath: string, offset: number, length: number) => {
+ return ipcRenderer.invoke("read-file-chunk", filePath, offset, length);
+ },
preparePreviewAudioTrack: (filePath: string) => {
return ipcRenderer.invoke("prepare-preview-audio-track", filePath);
},
@@ -170,8 +192,8 @@ contextBridge.exposeInMainWorld("electronAPI", {
saveProjectFile: (projectData: unknown, suggestedName?: string, existingProjectPath?: string) => {
return ipcRenderer.invoke("save-project-file", projectData, suggestedName, existingProjectPath);
},
- loadProjectFile: () => {
- return ipcRenderer.invoke("load-project-file");
+ loadProjectFile: (projectFolder?: string) => {
+ return ipcRenderer.invoke("load-project-file", projectFolder);
},
loadProjectFileFromPath: (filePath: string) => {
return ipcRenderer.invoke("load-project-file-from-path", filePath);
diff --git a/electron/recording/webm-duration.test.ts b/electron/recording/webm-duration.test.ts
new file mode 100644
index 0000000000..d796bb36b2
--- /dev/null
+++ b/electron/recording/webm-duration.test.ts
@@ -0,0 +1,132 @@
+import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import path from "node:path";
+import { WebmBase, WebmContainer, WebmFile, WebmString, WebmUint } from "@fix-webm-duration/parser";
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+import { patchWebmDurationOnDisk } from "./webm-duration";
+
+interface WebmElementMock {
+ getSectionById: (id: number) => WebmElementMock;
+ getValue: () => number;
+}
+
+describe("webm-duration patching", () => {
+ let dir: string;
+ const pathFor = (name: string) => path.join(dir, name);
+
+ beforeEach(async () => {
+ dir = await mkdtemp(path.join(tmpdir(), "openscreen-duration-"));
+ });
+
+ afterEach(async () => {
+ await rm(dir, { recursive: true, force: true });
+ });
+
+ function createDummyWebm(includeCluster = true, clusterSize = 100): Uint8Array {
+ const ebml = new WebmContainer("EBML");
+ ebml.data = [];
+ const docType = new WebmString("DocType");
+ docType.setValue("webm");
+ ebml.data.push({ id: 0x282, idHex: "282", data: docType });
+ ebml.updateByData();
+
+ const segment = new WebmContainer("Segment");
+ segment.data = [];
+ segment.isInfinite = true;
+
+ const info = new WebmContainer("Info");
+ info.data = [];
+ const timecodeScale = new WebmUint("TimecodeScale");
+ timecodeScale.setValue(1000000);
+ info.data.push({ id: 0xad7b1, idHex: "ad7b1", data: timecodeScale });
+ info.updateByData();
+ segment.data.push({ id: 0x549a966, idHex: "549a966", data: info });
+
+ if (includeCluster) {
+ const cluster = new WebmBase("Cluster");
+ // A valid EBML element for Cluster: ID 0x1f43b675 (stripped as 0xf43b675)
+ // Followed by a VINT for length (e.g. clusterSize bytes)
+ const header = Buffer.from([0x1f, 0x43, 0xb6, 0x75, 0x01, 0x00]); // 6 bytes header (id + length)
+ const body = Buffer.alloc(clusterSize, 0x42); // cluster data filled with 0x42
+ const clusterBytes = Buffer.concat([header, body]);
+
+ cluster.setSource(new Uint8Array(clusterBytes));
+ segment.data.push({ id: 0xf43b675, idHex: "f43b675", data: cluster });
+ }
+
+ segment.updateByData();
+
+ const file = new WebmContainer("File");
+ file.data = [];
+ file.data.push({ id: 0xa45dfa3, idHex: "a45dfa3", data: ebml });
+ file.data.push({ id: 0x8538067, idHex: "8538067", data: segment });
+
+ file.updateByData();
+ return file.source;
+ }
+
+ it("patches small WebM files under 2MB in memory successfully", async () => {
+ const webmBytes = createDummyWebm(true, 100);
+ const filePath = pathFor("small.webm");
+ await writeFile(filePath, webmBytes);
+
+ const result = await patchWebmDurationOnDisk(filePath, 5000);
+ expect(result.patched).toBe(true);
+
+ const patchedBytes = await readFile(filePath);
+ const webm = new WebmFile(new Uint8Array(patchedBytes));
+
+ const segment = webm.getSectionById(0x8538067) as unknown as WebmElementMock;
+ const info = segment.getSectionById(0x549a966);
+ const duration = info.getSectionById(0x489);
+
+ expect(duration).toBeDefined();
+ expect(duration.getValue()).toBe(5000);
+ });
+
+ it("patches large WebM files over 2MB using the optimized streaming method successfully", async () => {
+ // Create a file larger than 2MB (e.g. 2.5MB cluster data) to trigger the optimized method
+ const webmBytes = createDummyWebm(true, 2.5 * 1024 * 1024);
+ const filePath = pathFor("large.webm");
+ await writeFile(filePath, webmBytes);
+
+ const result = await patchWebmDurationOnDisk(filePath, 12000);
+ expect(result.patched).toBe(true);
+
+ const patchedBytes = await readFile(filePath);
+ const webm = new WebmFile(new Uint8Array(patchedBytes));
+ const segment = webm.getSectionById(0x8538067) as unknown as WebmElementMock;
+ const info = segment.getSectionById(0x549a966);
+ const duration = info.getSectionById(0x489);
+
+ expect(duration).toBeDefined();
+ expect(duration.getValue()).toBe(12000);
+
+ // Verify the Cluster data (filled with 0x42) is intact at the end
+ const lastBytes = patchedBytes.subarray(patchedBytes.length - 100);
+ expect(lastBytes.every((b) => b === 0x42)).toBe(true);
+ });
+
+ it("falls back to in-memory patching if no Cluster section is found in the large file header chunk", async () => {
+ // File is over 2MB, but has no Cluster (very large header or weird file)
+ const webmBytes = createDummyWebm(false);
+ // Pad to 2.5MB without Cluster
+ const padding = Buffer.alloc(2.5 * 1024 * 1024, 0);
+ const largeNoClusterBytes = Buffer.concat([Buffer.from(webmBytes), padding]);
+
+ const filePath = pathFor("large_no_cluster.webm");
+ await writeFile(filePath, largeNoClusterBytes);
+
+ const result = await patchWebmDurationOnDisk(filePath, 8000);
+ expect(result.patched).toBe(true);
+
+ const patchedBytes = await readFile(filePath);
+ const webm = new WebmFile(new Uint8Array(patchedBytes));
+ const segment = webm.getSectionById(0x8538067) as unknown as WebmElementMock;
+ const info = segment.getSectionById(0x549a966);
+ const duration = info.getSectionById(0x489);
+
+ expect(duration).toBeDefined();
+ expect(duration.getValue()).toBe(8000);
+ });
+});
diff --git a/electron/recording/webm-duration.ts b/electron/recording/webm-duration.ts
index 5b2c197c98..d9b4b61979 100644
--- a/electron/recording/webm-duration.ts
+++ b/electron/recording/webm-duration.ts
@@ -1,4 +1,6 @@
+import { createReadStream, createWriteStream } from "node:fs";
import fs from "node:fs/promises";
+import { pipeline } from "node:stream/promises";
import { fixParsedWebmDuration } from "@fix-webm-duration/fix";
import { WebmFile } from "@fix-webm-duration/parser";
@@ -6,40 +8,164 @@ export type DurationPatchResult =
| { patched: true }
| { patched: false; reason: "no-section" | "already-valid" | "io-error" | "internal" };
+// Read 2MB from the start of the file. Headers for WebM recordings are typically well under 100KB.
+const HEADER_CHUNK_SIZE = 2 * 1024 * 1024;
+
/**
* Patch the WebM Duration header on a finalized recording file.
*
- * Browser MediaRecorder writes WebM with no Duration EBML element. With the
- * streaming-to-disk path the renderer never holds the blob, so the historical
- * `fixWebmDuration(blob, durationMs)` call can't run. Patching on disk after
- * `WriteStream.end()` produces an equivalent result: the editor's seek bar and
- * timeline read a real duration instead of `N/A`.
- *
- * Atomic by design: writes the patched bytes to `.duration-patch.tmp`
- * and renames in place. If the process crashes mid-rewrite, the original file
- * survives intact, so the user never loses their recording to a partial write.
+ * MediaRecorder writes WebM with no Duration EBML element.
*
- * Best-effort by intent: any failure (read, parse, write) logs and returns a
- * non-`patched` result rather than throwing. The file is still playable without
- * the patch (decoders walk frames sequentially); the only cost is that the
- * editor's seek bar and timeline break until it is patched.
+ * Rather than reading the entire multi-gigabyte file into memory (which crashes
+ * the main process on long recordings), we read only the first 2MB chunk containing
+ * the metadata headers, patch the Info section, and stream the remaining clusters
+ * from the original file using Node streams.
*
- * Memory: reads the whole file into a main-process Buffer, the same footprint
- * as the pre-streaming renderer path, just on the side without V8's heap cap.
+ * NOTE: This optimization handles the saving/finalization phase. For issues related
+ * to streaming large files during the editor/export phase, see also PR #74.
*/
export async function patchWebmDurationOnDisk(
filePath: string,
durationMs: number,
): Promise {
+ let fileHandle: fs.FileHandle | null = null;
+ const tmpPath = `${filePath}.duration-patch.tmp`;
+ try {
+ const stat = await fs.stat(filePath);
+ if (stat.size < HEADER_CHUNK_SIZE) {
+ // Fallback: If file is smaller than 2MB, just use the in-memory method.
+ return await patchWebmDurationInMemory(filePath, durationMs);
+ }
+
+ fileHandle = await fs.open(filePath, "r");
+ const buffer = Buffer.alloc(HEADER_CHUNK_SIZE);
+ const { bytesRead } = await fileHandle.read(buffer, 0, HEADER_CHUNK_SIZE, 0);
+ await fileHandle.close();
+ fileHandle = null;
+
+ const chunk = buffer.subarray(0, bytesRead);
+ const webm = new WebmFile(new Uint8Array(chunk));
+
+ if (!webm.data || !webm.source) {
+ console.warn(
+ `[webm-duration] Segment data or source is missing in chunk for ${filePath}; falling back to whole-file`,
+ );
+ return await patchWebmDurationInMemory(filePath, durationMs);
+ }
+
+ // Find Segment in webm.data
+ const segmentSec = webm.data.find((sec) => sec.id === 0x8538067);
+ if (!segmentSec || !segmentSec.data) {
+ console.warn(
+ `[webm-duration] Segment section is missing in chunk for ${filePath}; falling back to whole-file`,
+ );
+ return await patchWebmDurationInMemory(filePath, durationMs);
+ }
+
+ interface WebmContainerShape {
+ start: number;
+ isInfinite?: boolean;
+ data: { id: number; data: unknown }[];
+ }
+ const segment = segmentSec.data as WebmContainerShape;
+
+ const info = (
+ segment as unknown as { getSectionById?: (id: number) => unknown }
+ ).getSectionById?.(0x549a966);
+ if (!info) {
+ console.warn(
+ `[webm-duration] Info section is missing in chunk for ${filePath}; falling back to whole-file`,
+ );
+ return await patchWebmDurationInMemory(filePath, durationMs);
+ }
+
+ // Calculate the start of the Segment's payload (content) using EBML VINT length rules
+ const segmentStart = segment.start;
+ const idByte = webm.source[segmentStart];
+ const idLen = 9 - idByte.toString(2).length;
+ const lenByte = webm.source[segmentStart + idLen];
+ const lenLen = 9 - lenByte.toString(2).length;
+ const segmentPayloadStart = segmentStart + idLen + lenLen;
+
+ // Find the first Cluster section. ID is 0xf43b675.
+ const segmentData = segment.data;
+ const clusterIdx = segmentData.findIndex((sec) => sec.id === 0xf43b675);
+ if (clusterIdx === -1) {
+ console.warn(
+ `[webm-duration] No Cluster section found in header chunk for ${filePath}; falling back to whole-file`,
+ );
+ return await patchWebmDurationInMemory(filePath, durationMs);
+ }
+
+ const clusterSec = segmentData[clusterIdx];
+ const clusterData = clusterSec.data as { start: number };
+ const clusterOffset = segmentPayloadStart + clusterData.start;
+
+ // Truncate the segment children to remove the Cluster and everything after it.
+ // This forces updateByData() to only regenerate/write the metadata headers.
+ segment.data = segmentData.slice(0, clusterIdx);
+
+ // Segment length was likely infinite (-1) or matching the original file. Since we are patching
+ // headers and appending the original stream, setting Segment length to infinite (-1) is safe and standard.
+ segment.isInfinite = true;
+
+ const patched = fixParsedWebmDuration(webm, durationMs, { logger: false });
+ if (!patched) {
+ const reason = inferUnpatchedReason(webm);
+ return { patched: false, reason };
+ }
+
+ if (!webm.source) {
+ console.error(`[webm-duration] patched but source missing for ${filePath}`);
+ return { patched: false, reason: "internal" };
+ }
+
+ const patchedBytes = Buffer.from(
+ webm.source.buffer,
+ webm.source.byteOffset,
+ webm.source.byteLength,
+ );
+
+ // Now write the patched headers and stream append the rest of the original file
+ const ws = createWriteStream(tmpPath);
+ const rs = createReadStream(filePath, { start: clusterOffset });
+
+ try {
+ await new Promise((resolve, reject) => {
+ ws.write(patchedBytes, (err) => {
+ if (err) reject(err);
+ else resolve();
+ });
+ });
+ await pipeline(rs, ws);
+ } finally {
+ rs.destroy();
+ ws.destroy();
+ }
+
+ await fs.rename(tmpPath, filePath);
+ return { patched: true };
+ } catch (error) {
+ console.error(`[webm-duration] failed to patch ${filePath} using optimized method:`, error);
+ if (fileHandle) {
+ await fileHandle.close().catch(() => undefined);
+ }
+ await fs.unlink(tmpPath).catch(() => undefined);
+ return { patched: false, reason: "io-error" };
+ }
+}
+
+async function patchWebmDurationInMemory(
+ filePath: string,
+ durationMs: number,
+): Promise {
+ const tmpPath = `${filePath}.duration-patch.tmp`;
try {
const fileBytes = await fs.readFile(filePath);
const webm = new WebmFile(new Uint8Array(fileBytes));
const patched = fixParsedWebmDuration(webm, durationMs, { logger: false });
if (!patched) {
- // fixParsedWebmDuration returns false for: missing Segment, missing
- // Info, or a Duration that is already valid. The first two mean a
- // malformed (most likely truncated) file; the third is a no-op.
const reason = inferUnpatchedReason(webm);
if (reason === "no-section") {
console.warn(
@@ -54,7 +180,6 @@ export async function patchWebmDurationOnDisk(
return { patched: false, reason: "internal" };
}
- const tmpPath = `${filePath}.duration-patch.tmp`;
const patchedBytes = Buffer.from(
webm.source.buffer,
webm.source.byteOffset,
@@ -66,26 +191,18 @@ export async function patchWebmDurationOnDisk(
return { patched: true };
} catch (writeError) {
console.error(`[webm-duration] failed to write patched ${filePath}:`, writeError);
- // Best-effort cleanup of the temp file; if unlink also fails, leave it.
- // The original recording is untouched because the rename never ran.
await fs.unlink(tmpPath).catch(() => undefined);
return { patched: false, reason: "io-error" };
}
} catch (error) {
- console.error(`[webm-duration] failed to patch ${filePath}:`, error);
+ console.error(`[webm-duration] failed to patch ${filePath} in memory:`, error);
return { patched: false, reason: "io-error" };
}
}
/**
- * Distinguish "no Segment/Info section" (malformed/truncated file) from "Info
- * present but Duration already valid" (patch unnecessary).
- *
- * The IDs are the length-descriptor-stripped form that @fix-webm-duration/parser
- * uses as its lookup keys (Segment `0x8538067`, Info `0x549a966`), verified
- * against the parser's `src/lib/sections.js` — not the canonical 4-byte EBML
- * IDs (`0x18538067` / `0x1549A966`), which this parser's `getSectionById` would
- * never match.
+ * Distinguish "no Segment/Info section" (malformed/truncated file) from "Info present
+ * but Duration already valid" (patch unnecessary).
*/
function inferUnpatchedReason(webm: WebmFile): "no-section" | "already-valid" {
const segment = webm.getSectionById?.(0x8538067);
diff --git a/electron/singleInstanceLock.test.ts b/electron/singleInstanceLock.test.ts
new file mode 100644
index 0000000000..4df35c72b3
--- /dev/null
+++ b/electron/singleInstanceLock.test.ts
@@ -0,0 +1,52 @@
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import { afterEach, describe, expect, it } from "vitest";
+import { acquireStableInstanceLock } from "./singleInstanceLock";
+
+const testDirs: string[] = [];
+
+function createTestLockDir() {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openscreen-lock-test-"));
+ testDirs.push(dir);
+ return path.join(dir, "app.lock");
+}
+
+afterEach(() => {
+ for (const dir of testDirs.splice(0)) {
+ fs.rmSync(dir, { recursive: true, force: true });
+ }
+});
+
+describe("acquireStableInstanceLock", () => {
+ it("prevents a second lock while the owning process is still running", () => {
+ const lockDir = createTestLockDir();
+ const firstLock = acquireStableInstanceLock({ lockDir });
+
+ expect(firstLock).not.toBeNull();
+ expect(acquireStableInstanceLock({ lockDir })).toBeNull();
+
+ firstLock?.release();
+ });
+
+ it("reclaims a stale lock when its process is gone", () => {
+ const lockDir = createTestLockDir();
+ fs.mkdirSync(lockDir);
+ fs.writeFileSync(path.join(lockDir, "pid"), "99999999\n");
+
+ const lock = acquireStableInstanceLock({ lockDir });
+
+ expect(lock).not.toBeNull();
+ expect(fs.readFileSync(path.join(lockDir, "pid"), "utf8")).toBe(`${process.pid}\n`);
+
+ lock?.release();
+ });
+
+ it("does not remove a fresh empty lock directory", () => {
+ const lockDir = createTestLockDir();
+ fs.mkdirSync(lockDir);
+
+ expect(acquireStableInstanceLock({ lockDir })).toBeNull();
+ expect(fs.existsSync(lockDir)).toBe(true);
+ });
+});
diff --git a/electron/singleInstanceLock.ts b/electron/singleInstanceLock.ts
new file mode 100644
index 0000000000..d1a0a01afc
--- /dev/null
+++ b/electron/singleInstanceLock.ts
@@ -0,0 +1,104 @@
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+
+const LOCK_DIR_PREFIX = "openscreen-single-instance";
+const PID_FILE_NAME = "pid";
+const EMPTY_LOCK_STALE_MS = 30_000;
+
+export type StableInstanceLock = {
+ lockDir: string;
+ release: () => void;
+};
+
+type LockOptions = {
+ lockDir?: string;
+ pid?: number;
+ now?: () => number;
+};
+
+function isProcessRunning(pid: number): boolean {
+ try {
+ process.kill(pid, 0);
+ return true;
+ } catch (error) {
+ return (error as NodeJS.ErrnoException).code === "EPERM";
+ }
+}
+
+function readLockPid(lockDir: string): number | null {
+ try {
+ const rawPid = fs.readFileSync(path.join(lockDir, PID_FILE_NAME), "utf8").trim();
+ const pid = Number(rawPid);
+ return Number.isInteger(pid) && pid > 0 ? pid : null;
+ } catch {
+ return null;
+ }
+}
+
+function isEmptyLockStale(lockDir: string, now: () => number): boolean {
+ try {
+ const stat = fs.statSync(lockDir);
+ return now() - stat.mtimeMs > EMPTY_LOCK_STALE_MS;
+ } catch {
+ return false;
+ }
+}
+
+function releaseLock(lockDir: string, pid: number) {
+ if (readLockPid(lockDir) !== pid) {
+ return;
+ }
+ fs.rmSync(lockDir, { recursive: true, force: true });
+}
+
+function getCurrentUserLockKey() {
+ if (typeof process.getuid === "function") {
+ return `uid-${process.getuid()}`;
+ }
+
+ try {
+ const username = os.userInfo().username.replace(/[^a-zA-Z0-9._-]/g, "_");
+ return username || "default";
+ } catch {
+ return "default";
+ }
+}
+
+export function getStableInstanceLockDir() {
+ return path.join(os.tmpdir(), `${LOCK_DIR_PREFIX}-${getCurrentUserLockKey()}.lock`);
+}
+
+export function acquireStableInstanceLock(options: LockOptions = {}): StableInstanceLock | null {
+ const lockDir = options.lockDir ?? getStableInstanceLockDir();
+ const pid = options.pid ?? process.pid;
+ const now = options.now ?? Date.now;
+
+ for (let attempt = 0; attempt < 2; attempt += 1) {
+ try {
+ fs.mkdirSync(lockDir, { mode: 0o700 });
+ fs.writeFileSync(path.join(lockDir, PID_FILE_NAME), `${pid}\n`, { flag: "wx" });
+ return {
+ lockDir,
+ release: () => releaseLock(lockDir, pid),
+ };
+ } catch (error) {
+ const code = (error as NodeJS.ErrnoException).code;
+ if (code !== "EEXIST") {
+ throw error;
+ }
+
+ const existingPid = readLockPid(lockDir);
+ if (existingPid && isProcessRunning(existingPid)) {
+ return null;
+ }
+ if (!existingPid && !isEmptyLockStale(lockDir, now)) {
+ return null;
+ }
+
+ fs.rmSync(lockDir, { recursive: true, force: true });
+ }
+ }
+
+ return null;
+}
diff --git a/electron/windows.ts b/electron/windows.ts
index 5d34fe80ce..2f87e6c3d5 100644
--- a/electron/windows.ts
+++ b/electron/windows.ts
@@ -10,7 +10,7 @@ const RENDERER_DIST = path.join(APP_ROOT, "dist");
const HEADLESS = process.env["HEADLESS"] === "true";
// Asset base URL for renderer (wallpapers, etc.). Packaged: extraResources copies
-// public/wallpapers -> resources/wallpapers. Unpackaged: /public/.
+// public/wallpapers to resources/wallpapers. Unpackaged: /public/.
const ASSET_BASE_DIR = process.defaultApp
? path.join(__dirname, "..", "public")
: process.resourcesPath;
@@ -44,10 +44,45 @@ ipcMain.on("hud-overlay-move-by", (_event, deltaX: number, deltaY: number) => {
hudOverlayWindow.setPosition(Math.round(x + deltaX), Math.round(y + deltaY), false);
});
+// Resize the HUD to fit its rendered content. Anchored by its bottom-centre so it
+// stays where the user dragged it while only growing/shrinking, which lets the
+// vertical tray layout grow tall instead of scrolling inside a fixed window.
+ipcMain.on("hud-overlay-set-size", (_event, width: number, height: number) => {
+ if (
+ !hudOverlayWindow ||
+ hudOverlayWindow.isDestroyed() ||
+ !Number.isFinite(width) ||
+ !Number.isFinite(height)
+ ) {
+ return;
+ }
+
+ const bounds = hudOverlayWindow.getBounds();
+
+ // Clamp to the work area of the display the HUD sits on; on a short screen the
+ // vertical layout can exceed the display, where the bar's own overflow scroll takes over.
+ const { workArea } = screen.getDisplayMatching(bounds);
+ const nextWidth = Math.min(workArea.width, Math.max(1, Math.round(width)));
+ const nextHeight = Math.min(workArea.height, Math.max(1, Math.round(height)));
+
+ if (bounds.width === nextWidth && bounds.height === nextHeight) {
+ return;
+ }
+
+ const centerX = bounds.x + bounds.width / 2;
+ const bottomY = bounds.y + bounds.height;
+
+ hudOverlayWindow.setBounds({
+ x: Math.round(centerX - nextWidth / 2),
+ y: Math.round(bottomY - nextHeight),
+ width: nextWidth,
+ height: nextHeight,
+ });
+});
+
/**
- * Creates the always-on-top HUD overlay window centred at the bottom of the
- * primary display. The window is frameless, transparent, and follows the user
- * across macOS Spaces so it is never lost when switching virtual desktops.
+ * Frameless transparent HUD overlay, always-on-top, centred at the bottom of the
+ * primary display. Follows the user across macOS Spaces so it isn't lost on switch.
*/
export function createHudOverlayWindow(): BrowserWindow {
const primaryDisplay = screen.getPrimaryDisplay();
@@ -62,14 +97,20 @@ export function createHudOverlayWindow(): BrowserWindow {
const win = new BrowserWindow({
width: windowWidth,
height: windowHeight,
- minWidth: 600,
- maxWidth: 600,
- minHeight: 160,
- maxHeight: 160,
+ // Min/max are intentionally loose: the renderer resizes to fit content via
+ // "hud-overlay-set-size" (above), needed for the vertical tray to grow taller.
+ minWidth: 120,
+ minHeight: 80,
x: x,
y: y,
frame: false,
transparent: true,
+ // Fully-transparent ARGB backing. Without this macOS draws the window as a
+ // rounded glass panel with a border around the HUD content.
+ backgroundColor: "#00000000",
+ // Don't let macOS mask the window into a rounded rect; the HUD bar provides
+ // its own rounding and the window itself must be invisible.
+ roundedCorners: false,
resizable: false,
alwaysOnTop: true,
skipTaskbar: true,
@@ -85,14 +126,14 @@ export function createHudOverlayWindow(): BrowserWindow {
});
win.setIgnoreMouseEvents(true, { forward: true });
- // Follow the user across macOS Spaces (virtual desktops).
- // Without this the HUD stays pinned to the Space it was first opened on.
+ // Follow the user across macOS Spaces, else the HUD stays pinned to the Space
+ // it was first opened on.
if (process.platform === "darwin") {
win.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });
}
- // Show only once content is painted — prevents the black rectangle flash
- // that appears when a transparent window is shown before its first paint.
+ // Show only once painted to avoid the black rectangle flash when a transparent
+ // window is shown before its first paint.
win.once("ready-to-show", () => {
if (!HEADLESS) win.show();
});
@@ -121,8 +162,8 @@ export function createHudOverlayWindow(): BrowserWindow {
}
/**
- * Creates the main editor window. Starts maximised with a hidden title bar on
- * macOS. This window is not always-on-top and appears in the taskbar/dock.
+ * Main editor window. Starts maximised with a hidden title bar on macOS; not
+ * always-on-top and appears in the taskbar/dock.
*/
export function createEditorWindow(): BrowserWindow {
const isMac = process.platform === "darwin";
@@ -153,16 +194,15 @@ export function createEditorWindow(): BrowserWindow {
},
});
- // Maximize the window by default
win.maximize();
- // Show only once content is painted — prevents white flash on cold Vite start.
+ // Show only once painted to avoid a white flash on cold Vite start.
win.once("ready-to-show", () => {
if (!HEADLESS) win.show();
});
- // Inject dark background before any React paint so the sub-titlebar area
- // never flashes white even on the very first cold Vite load.
+ // Inject dark background before any React paint so the sub-titlebar area never
+ // flashes white on a cold Vite load.
win.webContents.on("dom-ready", () => {
win.webContents.insertCSS("html, body, #root { background: #09090b !important; }").catch(() => {
// Best-effort cosmetic; ignore if the page is mid-teardown.
@@ -185,8 +225,8 @@ export function createEditorWindow(): BrowserWindow {
}
/**
- * Creates the floating source-selector window used to pick a screen or window
- * to record. Frameless, transparent, and follows the user across macOS Spaces.
+ * Floating source-selector window for picking a screen or window to record.
+ * Frameless, transparent, and follows the user across macOS Spaces.
*/
export function createSourceSelectorWindow(): BrowserWindow {
const { width, height } = screen.getPrimaryDisplay().workAreaSize;
@@ -211,8 +251,8 @@ export function createSourceSelectorWindow(): BrowserWindow {
},
});
- // Follow the user across macOS Spaces so the selector appears on the
- // active desktop regardless of where the HUD was originally opened.
+ // Follow the user across macOS Spaces so the selector appears on the active
+ // desktop regardless of where the HUD was opened.
if (process.platform === "darwin") {
win.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });
}
@@ -229,8 +269,8 @@ export function createSourceSelectorWindow(): BrowserWindow {
}
/**
- * Creates a centered transparent countdown overlay window that sits above the
- * HUD while recording pre-roll is running.
+ * Centered transparent countdown overlay that sits above the HUD during
+ * recording pre-roll.
*/
export function createCountdownOverlayWindow(): BrowserWindow {
const { workArea } = screen.getPrimaryDisplay();
@@ -280,3 +320,44 @@ export function createCountdownOverlayWindow(): BrowserWindow {
return win;
}
+
+// Frameless Notes Window for taking notes during a recording.
+export function createNotesWindow(): BrowserWindow {
+ const win = new BrowserWindow({
+ width: 400,
+ height: 540,
+ minWidth: 360,
+ minHeight: 400,
+ maxWidth: 640,
+ maxHeight: 720,
+ title: "OpenScreen - Notes",
+ backgroundColor: "#09090b",
+ resizable: true,
+ alwaysOnTop: true,
+ skipTaskbar: false,
+ show: false,
+ webPreferences: {
+ preload: path.join(__dirname, "preload.mjs"),
+ additionalArguments: [ASSET_BASE_URL_ARG],
+ nodeIntegration: false,
+ contextIsolation: true,
+ backgroundThrottling: false,
+ },
+ });
+
+ win.setContentProtection(true);
+ win.once("ready-to-show", () => {
+ win.setContentProtection(true);
+ win.show();
+ });
+
+ if (VITE_DEV_SERVER_URL) {
+ win.loadURL(VITE_DEV_SERVER_URL + "?showNotes=true");
+ } else {
+ win.loadFile(path.join(RENDERER_DIST, "index.html"), {
+ query: { showNotes: "true" },
+ });
+ }
+
+ return win;
+}
diff --git a/nix/hm-module.nix b/nix/hm-module.nix
index b04f82793f..9ed0a7d4f5 100644
--- a/nix/hm-module.nix
+++ b/nix/hm-module.nix
@@ -1,7 +1,7 @@
# Home Manager module for OpenScreen
# Usage in flake-based Home Manager config:
#
-# inputs.openscreen.url = "github:siddharthvaddem/openscreen";
+# inputs.openscreen.url = "github:EtienneLescot/openscreen";
#
# { inputs, ... }: {
# imports = [ inputs.openscreen.homeManagerModules.default ];
diff --git a/nix/module.nix b/nix/module.nix
index 3282d2d4fc..0cb7242ca7 100644
--- a/nix/module.nix
+++ b/nix/module.nix
@@ -1,7 +1,7 @@
# NixOS module for OpenScreen
# Usage in flake-based NixOS config:
#
-# inputs.openscreen.url = "github:siddharthvaddem/openscreen";
+# inputs.openscreen.url = "github:EtienneLescot/openscreen";
#
# { inputs, ... }: {
# imports = [ inputs.openscreen.nixosModules.default ];
diff --git a/nix/package.nix b/nix/package.nix
index 33dc4f7356..f30c7c7e41 100644
--- a/nix/package.nix
+++ b/nix/package.nix
@@ -11,7 +11,7 @@
buildNpmPackage {
nodejs = nodejs_22;
pname = "openscreen";
- version = "1.4.0";
+ version = "1.6.0";
src =
let
@@ -33,7 +33,7 @@ buildNpmPackage {
);
};
- npmDepsHash = "sha256-tOpoJPzaZDK3HJijGHpZ0+jWsbrYyQUuw1pO0Uxcifg=";
+ npmDepsHash = "sha256-IZypOLWlDShIjCKWxlJcrdtIkMu0P/DuXaq4c0HW3FY=";
env.ELECTRON_SKIP_BINARY_DOWNLOAD = "1";
@@ -116,7 +116,7 @@ buildNpmPackage {
meta = {
description = "Desktop screen recorder with built-in editor";
- homepage = "https://github.com/siddharthvaddem/openscreen";
+ homepage = "https://github.com/EtienneLescot/openscreen";
license = lib.licenses.mit;
mainProgram = "openscreen";
platforms = lib.platforms.linux;
diff --git a/package-lock.json b/package-lock.json
index 50ecc9d88b..02f9cdb1c5 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "openscreen",
- "version": "1.4.0",
+ "version": "1.6.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openscreen",
- "version": "1.4.0",
+ "version": "1.6.0",
"dependencies": {
"@fix-webm-duration/fix": "^1.0.1",
"@pixi/filter-drop-shadow": "^5.2.0",
@@ -22,10 +22,14 @@
"@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8",
+ "@tiptap/extension-text-style": "^3.27.1",
+ "@tiptap/react": "^3.27.1",
+ "@tiptap/starter-kit": "^3.27.1",
"@types/gif.js": "^0.2.5",
"@uiw/color-convert": "^2.10.1",
"@uiw/react-color-block": "^2.10.1",
"@uiw/react-color-colorful": "^2.9.2",
+ "@xenova/transformers": "^2.17.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dnd-timeline": "^2.4.0",
@@ -51,6 +55,8 @@
"web-demuxer": "^4.0.0"
},
"devDependencies": {
+ "@actions/core": "^3.0.1",
+ "@actions/github": "^9.1.1",
"@biomejs/biome": "^2.4.12",
"@electron/rebuild": "^4.0.4",
"@playwright/test": "^1.59.1",
@@ -85,6 +91,92 @@
"npm": "10.9.4"
}
},
+ "node_modules/@actions/core": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/@actions/core/-/core-3.0.1.tgz",
+ "integrity": "sha512-a6d/Nwahm9fliVGRhdhofo40HjHQasUPusmc7vBfyky+7Z+P2A1J68zyFVaNcEclc/Se+eO595oAr5nwEIoIUA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@actions/exec": "^3.0.0",
+ "@actions/http-client": "^4.0.0"
+ }
+ },
+ "node_modules/@actions/exec": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/@actions/exec/-/exec-3.0.0.tgz",
+ "integrity": "sha512-6xH/puSoNBXb72VPlZVm7vQ+svQpFyA96qdDBvhB8eNZOE8LtPf9L4oAsfzK/crCL8YZ+19fKYVnM63Sl+Xzlw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@actions/io": "^3.0.2"
+ }
+ },
+ "node_modules/@actions/github": {
+ "version": "9.1.1",
+ "resolved": "https://registry.npmjs.org/@actions/github/-/github-9.1.1.tgz",
+ "integrity": "sha512-tL5JbYOBZHc0ngEnCsaDcryUizIUIlQyIMwy1Wkx93H5HzbBJ7TbiPx2PnFjBwZW0Vh05JmfFZhecE6gglYegA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@actions/http-client": "^3.0.2",
+ "@octokit/core": "^7.0.6",
+ "@octokit/plugin-paginate-rest": "^14.0.0",
+ "@octokit/plugin-rest-endpoint-methods": "^17.0.0",
+ "@octokit/request": "^10.0.7",
+ "@octokit/request-error": "^7.1.0",
+ "undici": "^6.23.0"
+ }
+ },
+ "node_modules/@actions/github/node_modules/@actions/http-client": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/@actions/http-client/-/http-client-3.0.2.tgz",
+ "integrity": "sha512-JP38FYYpyqvUsz+Igqlc/JG6YO9PaKuvqjM3iGvaLqFnJ7TFmcLyy2IDrY0bI0qCQug8E9K+elv5ZNfw62ZJzA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tunnel": "^0.0.6",
+ "undici": "^6.23.0"
+ }
+ },
+ "node_modules/@actions/github/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/@actions/http-client": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/@actions/http-client/-/http-client-4.0.1.tgz",
+ "integrity": "sha512-+Nvd1ImaOZBSoPbsUtEhv+1z99H12xzncCkz0a3RuehINE81FZSe2QTj3uvAPTcJX/SCzUQHQ0D1GrPMbrPitg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tunnel": "^0.0.6",
+ "undici": "^6.23.0"
+ }
+ },
+ "node_modules/@actions/http-client/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/@actions/io": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/@actions/io/-/io-3.0.2.tgz",
+ "integrity": "sha512-nRBchcMM+QK1pdjO7/idu86rbJI5YHUKCvKs0KxnSYbVe3F51UfGxuZX4Qy/fWlp6l7gWFwIkrOzN+oUK03kfw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@adobe/css-tools": {
"version": "4.4.4",
"resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz",
@@ -1772,6 +1864,15 @@
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
"license": "MIT"
},
+ "node_modules/@huggingface/jinja": {
+ "version": "0.2.2",
+ "resolved": "https://registry.npmjs.org/@huggingface/jinja/-/jinja-0.2.2.tgz",
+ "integrity": "sha512-/KPde26khDUIPkTGU82jdtTW9UAuvUTumCAbFs/7giR0SxsvZC4hru51PBvpijH6BVkHcROcvZM/lpy5h1jRRA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/@isaacs/fs-minipass": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz",
@@ -1955,6 +2056,144 @@
"node": ">= 8"
}
},
+ "node_modules/@octokit/auth-token": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-6.0.0.tgz",
+ "integrity": "sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@octokit/core": {
+ "version": "7.0.6",
+ "resolved": "https://registry.npmjs.org/@octokit/core/-/core-7.0.6.tgz",
+ "integrity": "sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@octokit/auth-token": "^6.0.0",
+ "@octokit/graphql": "^9.0.3",
+ "@octokit/request": "^10.0.6",
+ "@octokit/request-error": "^7.0.2",
+ "@octokit/types": "^16.0.0",
+ "before-after-hook": "^4.0.0",
+ "universal-user-agent": "^7.0.0"
+ },
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@octokit/endpoint": {
+ "version": "11.0.3",
+ "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-11.0.3.tgz",
+ "integrity": "sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@octokit/types": "^16.0.0",
+ "universal-user-agent": "^7.0.2"
+ },
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@octokit/graphql": {
+ "version": "9.0.3",
+ "resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-9.0.3.tgz",
+ "integrity": "sha512-grAEuupr/C1rALFnXTv6ZQhFuL1D8G5y8CN04RgrO4FIPMrtm+mcZzFG7dcBm+nq+1ppNixu+Jd78aeJOYxlGA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@octokit/request": "^10.0.6",
+ "@octokit/types": "^16.0.0",
+ "universal-user-agent": "^7.0.0"
+ },
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@octokit/openapi-types": {
+ "version": "27.0.0",
+ "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-27.0.0.tgz",
+ "integrity": "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@octokit/plugin-paginate-rest": {
+ "version": "14.0.0",
+ "resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-14.0.0.tgz",
+ "integrity": "sha512-fNVRE7ufJiAA3XUrha2omTA39M6IXIc6GIZLvlbsm8QOQCYvpq/LkMNGyFlB1d8hTDzsAXa3OKtybdMAYsV/fw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@octokit/types": "^16.0.0"
+ },
+ "engines": {
+ "node": ">= 20"
+ },
+ "peerDependencies": {
+ "@octokit/core": ">=6"
+ }
+ },
+ "node_modules/@octokit/plugin-rest-endpoint-methods": {
+ "version": "17.0.0",
+ "resolved": "https://registry.npmjs.org/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-17.0.0.tgz",
+ "integrity": "sha512-B5yCyIlOJFPqUUeiD0cnBJwWJO8lkJs5d8+ze9QDP6SvfiXSz1BF+91+0MeI1d2yxgOhU/O+CvtiZ9jSkHhFAw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@octokit/types": "^16.0.0"
+ },
+ "engines": {
+ "node": ">= 20"
+ },
+ "peerDependencies": {
+ "@octokit/core": ">=6"
+ }
+ },
+ "node_modules/@octokit/request": {
+ "version": "10.0.10",
+ "resolved": "https://registry.npmjs.org/@octokit/request/-/request-10.0.10.tgz",
+ "integrity": "sha512-KxNC2pTqqhszMNrf12ZRd4PonRgyJdsM4F/jySiddQK+DsRcfBtUvqn8t7UsyZhnRJHvX46OohDt5N3VqIWC2w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@octokit/endpoint": "^11.0.3",
+ "@octokit/request-error": "^7.0.2",
+ "@octokit/types": "^16.0.0",
+ "content-type": "^2.0.0",
+ "json-with-bigint": "^3.5.3",
+ "universal-user-agent": "^7.0.2"
+ },
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@octokit/request-error": {
+ "version": "7.1.0",
+ "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-7.1.0.tgz",
+ "integrity": "sha512-KMQIfq5sOPpkQYajXHwnhjCC0slzCNScLHs9JafXc4RAJI+9f+jNDlBNaIMTvazOPLgb4BnlhGJOTbnN0wIjPw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@octokit/types": "^16.0.0"
+ },
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@octokit/types": {
+ "version": "16.0.0",
+ "resolved": "https://registry.npmjs.org/@octokit/types/-/types-16.0.0.tgz",
+ "integrity": "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@octokit/openapi-types": "^27.0.0"
+ }
+ },
"node_modules/@pixi/color": {
"version": "7.4.3",
"resolved": "https://registry.npmjs.org/@pixi/color/-/color-7.4.3.tgz",
@@ -2104,6 +2343,70 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@protobufjs/aspromise": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
+ "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@protobufjs/base64": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz",
+ "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@protobufjs/codegen": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz",
+ "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@protobufjs/eventemitter": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz",
+ "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@protobufjs/fetch": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz",
+ "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "@protobufjs/aspromise": "^1.1.1",
+ "@protobufjs/inquire": "^1.1.0"
+ }
+ },
+ "node_modules/@protobufjs/float": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz",
+ "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@protobufjs/inquire": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.1.tgz",
+ "integrity": "sha512-mnzgDV26ueAvk7rsbt9L7bE0SuAoqyuys/sMMrmVcN5x9VsxpcG3rqAUSgDyLp0UZlmNfIbQ4fHfCtreVBk8Ew==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@protobufjs/path": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz",
+ "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@protobufjs/pool": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz",
+ "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@protobufjs/utf8": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz",
+ "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==",
+ "license": "BSD-3-Clause"
+ },
"node_modules/@radix-ui/number": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz",
@@ -3644,98 +3947,540 @@
"@testing-library/dom": ">=7.21.4"
}
},
- "node_modules/@types/aria-query": {
- "version": "5.0.4",
- "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
- "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
- "dev": true,
+ "node_modules/@tiptap/core": {
+ "version": "3.27.1",
+ "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.27.1.tgz",
+ "integrity": "sha512-rV6Qn4wmC6BxfF+4mu6bqGWj9vA4oXXhsrpXaJL2uhjxeHAGofjwcHof2X84VYzeyXgdlsGmqKie4TAppVXZUQ==",
"license": "MIT",
- "peer": true
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/pm": "3.27.1"
+ }
},
- "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,
+ "node_modules/@tiptap/extension-blockquote": {
+ "version": "3.27.1",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-3.27.1.tgz",
+ "integrity": "sha512-VMF7xJx6qEGiX6DTKNiL31NLqypOcd/4sNjFSe8rb41PwejBJh/nOqVIbBvWkiT6NMGFLxMhj7zJ8/zPo1hXeg==",
"license": "MIT",
- "dependencies": {
- "@babel/parser": "^7.20.7",
- "@babel/types": "^7.20.7",
- "@types/babel__generator": "*",
- "@types/babel__template": "*",
- "@types/babel__traverse": "*"
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "3.27.1"
}
},
- "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,
+ "node_modules/@tiptap/extension-bold": {
+ "version": "3.27.1",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-3.27.1.tgz",
+ "integrity": "sha512-TlC5bsS+pqETTrlz4CZz9RO/cKBYtELGIxwtKeivUn3eNfnOxQbbu4WDsiwIfzRFyd0OMnKl6BPM2KnYEehoEQ==",
"license": "MIT",
- "dependencies": {
- "@babel/types": "^7.0.0"
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "3.27.1"
}
},
- "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,
+ "node_modules/@tiptap/extension-bubble-menu": {
+ "version": "3.27.1",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-3.27.1.tgz",
+ "integrity": "sha512-j/j8Qp9Z5nViade2m7zjrO/CYH/Ca80Qj7aqo0eUaei6FZQ5izlF9o4XQU5EFMAutV6mwynsPUp8FVo5sCuYfw==",
"license": "MIT",
+ "optional": true,
"dependencies": {
- "@babel/parser": "^7.1.0",
- "@babel/types": "^7.0.0"
+ "@floating-ui/dom": "^1.0.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "3.27.1",
+ "@tiptap/pm": "3.27.1"
}
},
- "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,
+ "node_modules/@tiptap/extension-bullet-list": {
+ "version": "3.27.1",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-3.27.1.tgz",
+ "integrity": "sha512-faCUHnRP47o9Zh9VZZX6EX/569udw9Vopm2PgEKPWuKLE2qaS5WBuUVU0iItdJmKUqaWiOZkpoW4jvnDmj0dfg==",
"license": "MIT",
- "dependencies": {
- "@babel/types": "^7.28.2"
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/extension-list": "3.27.1"
}
},
- "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,
+ "node_modules/@tiptap/extension-code": {
+ "version": "3.27.1",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-3.27.1.tgz",
+ "integrity": "sha512-epOUpFfEmBzjvnqvjv2qHX7NAuLo5dlOGV690lWu+sAYMjibuJBeVvAiKPyFCfRCCTUxdbDB3jbaOA1yEcEJ7w==",
"license": "MIT",
- "dependencies": {
- "@types/http-cache-semantics": "*",
- "@types/keyv": "^3.1.4",
- "@types/node": "*",
- "@types/responselike": "^1.0.0"
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "3.27.1"
}
},
- "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,
+ "node_modules/@tiptap/extension-code-block": {
+ "version": "3.27.1",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.27.1.tgz",
+ "integrity": "sha512-pHlzmZx2OlHfyQ0yRlT5UL4mGokz947DthZuYefN1OleVqOkHpWBG+2JQwqoNq6bmzMne92zbH32rhcJUEYSjA==",
"license": "MIT",
- "dependencies": {
- "@types/deep-eql": "*",
- "assertion-error": "^2.0.1"
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "3.27.1",
+ "@tiptap/pm": "3.27.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==",
+ "node_modules/@tiptap/extension-document": {
+ "version": "3.27.1",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-3.27.1.tgz",
+ "integrity": "sha512-8FbBTkfnRP4iVaoj+2h3iWa+H0eGDD3yTyVCwrmue/sQTkqUNUoSuAZa3GDG4Sd41xdPwTJxl9nUWGgM1qDCnw==",
"license": "MIT",
- "peer": true
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "3.27.1"
+ }
},
- "node_modules/@types/debug": {
- "version": "4.1.13",
- "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz",
- "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==",
- "dev": true,
+ "node_modules/@tiptap/extension-dropcursor": {
+ "version": "3.27.1",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-3.27.1.tgz",
+ "integrity": "sha512-blFf9x9RG0Qr7P3FoAH/033ffa+mMLZn34trVs8Vi0Ppk6FmJAg5HpYFOtmYoeREdNDJ5rHJKV7SoACbOHgskQ==",
"license": "MIT",
- "dependencies": {
- "@types/ms": "*"
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/extensions": "3.27.1"
+ }
+ },
+ "node_modules/@tiptap/extension-floating-menu": {
+ "version": "3.27.1",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-3.27.1.tgz",
+ "integrity": "sha512-BmJF1VqB7dSJkgAalrpVFj88WLhxKjcWPuWHOqf2ITrUU2832BhKLXKmxjWUy1gqV8PfNNVWtGfIERy7I0y0+Q==",
+ "license": "MIT",
+ "optional": true,
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@floating-ui/dom": "^1.0.0",
+ "@tiptap/core": "3.27.1",
+ "@tiptap/pm": "3.27.1"
+ }
+ },
+ "node_modules/@tiptap/extension-gapcursor": {
+ "version": "3.27.1",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-3.27.1.tgz",
+ "integrity": "sha512-QoezN0wdvXIwLQ4ee2ccWDaX3RG0lzgQpIMpMz55oPDhpUVax1+19ApsS53LkcktpS4EbnPL4xO4DaJk0Vp7PQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/extensions": "3.27.1"
+ }
+ },
+ "node_modules/@tiptap/extension-hard-break": {
+ "version": "3.27.1",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-3.27.1.tgz",
+ "integrity": "sha512-iv/m9hzl6jfSj9Q8UEjAxONvCoUDaP7M9SRCPx3PaLNxA230TTD6RE0Ye4zFJ8ze7ZVoJJMAqg9Qpq1iYg2JOQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "3.27.1"
+ }
+ },
+ "node_modules/@tiptap/extension-heading": {
+ "version": "3.27.1",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-3.27.1.tgz",
+ "integrity": "sha512-SrC4l1kEIyv9ZXFaI/8LQqU2MyMmjczw7XXsWUQOTN4YXv0JyVgMNR3cI/wz0d2xsTfBdZ1N85Tdng+Ga1t0Sg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "3.27.1"
+ }
+ },
+ "node_modules/@tiptap/extension-horizontal-rule": {
+ "version": "3.27.1",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.27.1.tgz",
+ "integrity": "sha512-QlKE7qn5qMnIGVGhXQlvYedvLtNJ9z0dmit5w8vPb8tKzW4Spk6M7N2kruprrDA8GBwHfeR5wmF+njfUm34qxg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "3.27.1",
+ "@tiptap/pm": "3.27.1"
+ }
+ },
+ "node_modules/@tiptap/extension-italic": {
+ "version": "3.27.1",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.27.1.tgz",
+ "integrity": "sha512-jGGeyn9uRUnNjSTHpbqhiGsp6KaYTSbV09jDXPJI9cDwfV9hpugLvpaCZd0BMBbhU1B1W6kOfX0BE15qX/HQfA==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "3.27.1"
+ }
+ },
+ "node_modules/@tiptap/extension-link": {
+ "version": "3.27.1",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-3.27.1.tgz",
+ "integrity": "sha512-/2jBfsxBZUDGJmpZifqRQPz7f1E5qpS1BckTZ39TADzUJX+feKy7RJ3DtQ02+8y6SSMzvP9loGVjrk6zEMTk4g==",
+ "license": "MIT",
+ "dependencies": {
+ "linkifyjs": "^4.3.3"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "3.27.1",
+ "@tiptap/pm": "3.27.1"
+ }
+ },
+ "node_modules/@tiptap/extension-list": {
+ "version": "3.27.1",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.27.1.tgz",
+ "integrity": "sha512-c2Upru7lj0/ZV/Ibww6cNz6sUS8m6Dp/9uygFhYcZOd3X8M0xBIEk42c6m6SQehkPziVA8QOgNJz7sMqsbz1OQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "3.27.1",
+ "@tiptap/pm": "3.27.1"
+ }
+ },
+ "node_modules/@tiptap/extension-list-item": {
+ "version": "3.27.1",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-3.27.1.tgz",
+ "integrity": "sha512-zwRl01ETfCkWUvtvK5fw9bXtAajMPkvlkE3Cq6JvH3LF7XXJwDtNj5Tj7exacMpCaSZmlNc43vFb2rAYnrnwMA==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/extension-list": "3.27.1"
+ }
+ },
+ "node_modules/@tiptap/extension-list-keymap": {
+ "version": "3.27.1",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-list-keymap/-/extension-list-keymap-3.27.1.tgz",
+ "integrity": "sha512-OIMZNlzPSO8WRd4ic73Fxckzl4N1tesjjLL2XApaNA/uMpO0LoF6WSRPAWv+Z24Wp92ARRJAnRP7iZoI5+Jxig==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/extension-list": "3.27.1"
+ }
+ },
+ "node_modules/@tiptap/extension-ordered-list": {
+ "version": "3.27.1",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-3.27.1.tgz",
+ "integrity": "sha512-GYrKqD//9nHJ2r80uXqbDMzRnFpGzbaEQRTSGaO/SH7DvXWFMow8evkOdjQ7PCQO07jNjJo75+A85Jwu3Ov3AA==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/extension-list": "3.27.1"
+ }
+ },
+ "node_modules/@tiptap/extension-paragraph": {
+ "version": "3.27.1",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-3.27.1.tgz",
+ "integrity": "sha512-7K7eo1gruOgAsnbK+GCV23AUVUI0cL1bTig8HaPneoFMVbig7vddk8jNLKBWO8TXVbG7TuHdnDN4F98vdtwh5Q==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "3.27.1"
+ }
+ },
+ "node_modules/@tiptap/extension-strike": {
+ "version": "3.27.1",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.27.1.tgz",
+ "integrity": "sha512-Y3DW1jlSlCNCyMGHP3+3qBNNPS83wuFz4RTYGjZtvRRTCRh7apZme9XRWMq1rN5mJ2Cr7fKocA2/5Bs13KgN6Q==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "3.27.1"
+ }
+ },
+ "node_modules/@tiptap/extension-text": {
+ "version": "3.27.1",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.27.1.tgz",
+ "integrity": "sha512-6ZwaZwSrDh+KFFv6V1J79oO37yPs7y1bFxvk1/9Ih2rn3Xr5AWz+eMS+n8RpH3djBVVAQpdIAeYQgcn+VCSsTg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "3.27.1"
+ }
+ },
+ "node_modules/@tiptap/extension-text-style": {
+ "version": "3.27.1",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-text-style/-/extension-text-style-3.27.1.tgz",
+ "integrity": "sha512-J48WIl+6YDYTFPhWXUBQk+u7+AKVUqTdvrZOQyPYCGuQMgHrYzgWrI5+HeEifUgXJ5rMIWWP3qytp7KhVVqpDQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "3.27.1"
+ }
+ },
+ "node_modules/@tiptap/extension-underline": {
+ "version": "3.27.1",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-3.27.1.tgz",
+ "integrity": "sha512-N889J4nXN/TPfVt8uF9N1A0SY82E90zwc1y26lqOcw6KWNLmQrlhMh/9OD4ikLDbekmFpOBq/UicpHf/6S8hbQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "3.27.1"
+ }
+ },
+ "node_modules/@tiptap/extensions": {
+ "version": "3.27.1",
+ "resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.27.1.tgz",
+ "integrity": "sha512-1Tdx9faw8k0/83V6X+xCDVhV8yElGt95JxeW3YMkKQJI56QdlPz0xOdJPlMiSGJKinPyVier+x9LJD/YZUZIaw==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "3.27.1",
+ "@tiptap/pm": "3.27.1"
+ }
+ },
+ "node_modules/@tiptap/pm": {
+ "version": "3.27.1",
+ "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.27.1.tgz",
+ "integrity": "sha512-Ffjx+vimmBU7zH/KrpXzJid3+pziCe/VL2aexSTP63cyQwKQ65LkFkCKaIsSpFdQQuakVZBGWjCA5RoBV852pw==",
+ "license": "MIT",
+ "dependencies": {
+ "prosemirror-changeset": "^2.3.0",
+ "prosemirror-commands": "^1.6.2",
+ "prosemirror-dropcursor": "^1.8.1",
+ "prosemirror-gapcursor": "^1.3.2",
+ "prosemirror-history": "^1.4.1",
+ "prosemirror-inputrules": "^1.4.0",
+ "prosemirror-keymap": "^1.2.3",
+ "prosemirror-model": "^1.25.7",
+ "prosemirror-schema-list": "^1.5.0",
+ "prosemirror-state": "^1.4.4",
+ "prosemirror-tables": "^1.8.0",
+ "prosemirror-transform": "^1.12.0",
+ "prosemirror-view": "^1.41.8"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ }
+ },
+ "node_modules/@tiptap/react": {
+ "version": "3.27.1",
+ "resolved": "https://registry.npmjs.org/@tiptap/react/-/react-3.27.1.tgz",
+ "integrity": "sha512-/Wn2fc9zMtX08MXYScDFsm4wJ8lzfhfPEdbtls7WCDlbtrop48PWlkHDBBJrywARfAQTB2mFs9KiFy9yrQm5Lg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/use-sync-external-store": "^0.0.6",
+ "fast-equals": "^5.3.3",
+ "use-sync-external-store": "^1.4.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "optionalDependencies": {
+ "@tiptap/extension-bubble-menu": "^3.27.1",
+ "@tiptap/extension-floating-menu": "^3.27.1"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "3.27.1",
+ "@tiptap/pm": "3.27.1",
+ "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
+ "@types/react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react": "^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
+ "node_modules/@tiptap/starter-kit": {
+ "version": "3.27.1",
+ "resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-3.27.1.tgz",
+ "integrity": "sha512-vfxRsqW8rCc0k4pzo0ilU3wobVi2wqVj88VZI2SlgZlNnUAkrDGDIAph7CTa9k9fshV+O1ivpEgPC5yC046jow==",
+ "license": "MIT",
+ "dependencies": {
+ "@tiptap/core": "^3.27.1",
+ "@tiptap/extension-blockquote": "^3.27.1",
+ "@tiptap/extension-bold": "^3.27.1",
+ "@tiptap/extension-bullet-list": "^3.27.1",
+ "@tiptap/extension-code": "^3.27.1",
+ "@tiptap/extension-code-block": "^3.27.1",
+ "@tiptap/extension-document": "^3.27.1",
+ "@tiptap/extension-dropcursor": "^3.27.1",
+ "@tiptap/extension-gapcursor": "^3.27.1",
+ "@tiptap/extension-hard-break": "^3.27.1",
+ "@tiptap/extension-heading": "^3.27.1",
+ "@tiptap/extension-horizontal-rule": "^3.27.1",
+ "@tiptap/extension-italic": "^3.27.1",
+ "@tiptap/extension-link": "^3.27.1",
+ "@tiptap/extension-list": "^3.27.1",
+ "@tiptap/extension-list-item": "^3.27.1",
+ "@tiptap/extension-list-keymap": "^3.27.1",
+ "@tiptap/extension-ordered-list": "^3.27.1",
+ "@tiptap/extension-paragraph": "^3.27.1",
+ "@tiptap/extension-strike": "^3.27.1",
+ "@tiptap/extension-text": "^3.27.1",
+ "@tiptap/extension-underline": "^3.27.1",
+ "@tiptap/extensions": "^3.27.1",
+ "@tiptap/pm": "^3.27.1"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ }
+ },
+ "node_modules/@types/aria-query": {
+ "version": "5.0.4",
+ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
+ "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true
+ },
+ "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==",
+ "license": "MIT",
+ "peer": true
+ },
+ "node_modules/@types/debug": {
+ "version": "4.1.13",
+ "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz",
+ "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/ms": "*"
}
},
"node_modules/@types/deep-eql": {
@@ -3822,6 +4567,12 @@
"@types/node": "*"
}
},
+ "node_modules/@types/long": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.2.tgz",
+ "integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==",
+ "license": "MIT"
+ },
"node_modules/@types/ms": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
@@ -3833,7 +4584,6 @@
"version": "22.19.17",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz",
"integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==",
- "dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
@@ -3855,14 +4605,12 @@
"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.28",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz",
"integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==",
- "devOptional": true,
"license": "MIT",
"dependencies": {
"@types/prop-types": "*",
@@ -3873,7 +4621,6 @@
"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"
@@ -3889,6 +4636,12 @@
"@types/node": "*"
}
},
+ "node_modules/@types/use-sync-external-store": {
+ "version": "0.0.6",
+ "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
+ "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
+ "license": "MIT"
+ },
"node_modules/@types/verror": {
"version": "1.10.11",
"resolved": "https://registry.npmjs.org/@types/verror/-/verror-1.10.11.tgz",
@@ -4293,6 +5046,20 @@
"integrity": "sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ==",
"license": "BSD-3-Clause"
},
+ "node_modules/@xenova/transformers": {
+ "version": "2.17.2",
+ "resolved": "https://registry.npmjs.org/@xenova/transformers/-/transformers-2.17.2.tgz",
+ "integrity": "sha512-lZmHqzrVIkSvZdKZEx7IYY51TK0WDrC8eR0c5IMnBsO8di8are1zzw8BlLhyO2TklZKLN5UffNGs1IJwT6oOqQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@huggingface/jinja": "^0.2.2",
+ "onnxruntime-web": "1.14.0",
+ "sharp": "^0.32.0"
+ },
+ "optionalDependencies": {
+ "onnxruntime-node": "1.14.0"
+ }
+ },
"node_modules/@xmldom/xmldom": {
"version": "0.8.13",
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz",
@@ -4763,11 +5530,101 @@
"node": "18 || 20 || >=22"
}
},
+ "node_modules/bare-events": {
+ "version": "2.8.2",
+ "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz",
+ "integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==",
+ "license": "Apache-2.0",
+ "peerDependencies": {
+ "bare-abort-controller": "*"
+ },
+ "peerDependenciesMeta": {
+ "bare-abort-controller": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/bare-fs": {
+ "version": "4.7.1",
+ "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.7.1.tgz",
+ "integrity": "sha512-WDRsyVN52eAx/lBamKD6uyw8H4228h/x0sGGGegOamM2cd7Pag88GfMQalobXI+HaEUxpCkbKQUDOQqt9wawRw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "bare-events": "^2.5.4",
+ "bare-path": "^3.0.0",
+ "bare-stream": "^2.6.4",
+ "bare-url": "^2.2.2",
+ "fast-fifo": "^1.3.2"
+ },
+ "engines": {
+ "bare": ">=1.16.0"
+ },
+ "peerDependencies": {
+ "bare-buffer": "*"
+ },
+ "peerDependenciesMeta": {
+ "bare-buffer": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/bare-os": {
+ "version": "3.9.1",
+ "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.9.1.tgz",
+ "integrity": "sha512-6M5XjcnsygQNPMCMPXSK379xrJFiZ/AEMNBmFEmQW8d/789VQATvriyi5r0HYTL9TkQ26rn3kgdTG3aisbrXkQ==",
+ "license": "Apache-2.0",
+ "engines": {
+ "bare": ">=1.14.0"
+ }
+ },
+ "node_modules/bare-path": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz",
+ "integrity": "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "bare-os": "^3.0.1"
+ }
+ },
+ "node_modules/bare-stream": {
+ "version": "2.13.1",
+ "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.13.1.tgz",
+ "integrity": "sha512-Vp0cnjYyrEC4whYTymQ+YZi6pBpfiICZO3cfRG8sy67ZNWe951urv1x4eW1BKNngw3U+3fPYb5JQvHbCtxH7Ow==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "streamx": "^2.25.0",
+ "teex": "^1.0.1"
+ },
+ "peerDependencies": {
+ "bare-abort-controller": "*",
+ "bare-buffer": "*",
+ "bare-events": "*"
+ },
+ "peerDependenciesMeta": {
+ "bare-abort-controller": {
+ "optional": true
+ },
+ "bare-buffer": {
+ "optional": true
+ },
+ "bare-events": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/bare-url": {
+ "version": "2.4.3",
+ "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.4.3.tgz",
+ "integrity": "sha512-Kccpc7ACfXaxfeInfqKcZtW4pT5YBn1mesc4sCsun6sRwtbJ4h+sNOaksUpYEJUKfN65YWC6Bw2OJEFiKxq8nQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "bare-path": "^3.0.0"
+ }
+ },
"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",
@@ -4797,6 +5654,13 @@
"node": ">=6.0.0"
}
},
+ "node_modules/before-after-hook": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-4.0.0.tgz",
+ "integrity": "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==",
+ "dev": true,
+ "license": "Apache-2.0"
+ },
"node_modules/bidi-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
@@ -4819,6 +5683,17 @@
"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==",
+ "license": "MIT",
+ "dependencies": {
+ "buffer": "^5.5.0",
+ "inherits": "^2.0.4",
+ "readable-stream": "^3.4.0"
+ }
+ },
"node_modules/boolean": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz",
@@ -4891,7 +5766,6 @@
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
- "dev": true,
"funding": [
{
"type": "github",
@@ -4907,7 +5781,6 @@
}
],
"license": "MIT",
- "optional": true,
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.1.13"
@@ -5306,11 +6179,23 @@
"node": ">=6"
}
},
+ "node_modules/color": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
+ "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==",
+ "license": "MIT",
+ "dependencies": {
+ "color-convert": "^2.0.1",
+ "color-string": "^1.9.0"
+ },
+ "engines": {
+ "node": ">=12.5.0"
+ }
+ },
"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"
@@ -5323,9 +6208,18 @@
"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/color-string": {
+ "version": "1.9.1",
+ "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz",
+ "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==",
+ "license": "MIT",
+ "dependencies": {
+ "color-name": "^1.0.0",
+ "simple-swizzle": "^0.2.2"
+ }
+ },
"node_modules/colorette": {
"version": "2.0.20",
"resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz",
@@ -5373,6 +6267,20 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/content-type": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
+ "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
"node_modules/convert-source-map": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
@@ -5483,7 +6391,6 @@
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
- "devOptional": true,
"license": "MIT"
},
"node_modules/data-urls": {
@@ -5529,7 +6436,6 @@
"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"
@@ -5545,7 +6451,6 @@
"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"
@@ -5554,6 +6459,15 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/deep-extend": {
+ "version": "0.6.0",
+ "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
+ "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
"node_modules/defer-to-connect": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz",
@@ -5622,6 +6536,15 @@
"node": ">=6"
}
},
+ "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==",
+ "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",
@@ -6096,7 +7019,6 @@
"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"
@@ -6289,6 +7211,24 @@
"license": "MIT",
"peer": true
},
+ "node_modules/events-universal": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz",
+ "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "bare-events": "^2.7.0"
+ }
+ },
+ "node_modules/expand-template": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
+ "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
+ "license": "(MIT OR WTFPL)",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/expect-type": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
@@ -6368,6 +7308,21 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/fast-equals": {
+ "version": "5.4.0",
+ "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz",
+ "integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/fast-fifo": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz",
+ "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==",
+ "license": "MIT"
+ },
"node_modules/fast-glob": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
@@ -6503,6 +7458,12 @@
"integrity": "sha512-IKlE+pNvL2R+kVL1kEhUYqRxVqeFnjiIvHWDMLFXNaqyUdFXQM2wte44EfMYJNHkW16X991t2Zg8apKkhv7OBA==",
"license": "MIT"
},
+ "node_modules/flatbuffers": {
+ "version": "1.12.0",
+ "resolved": "https://registry.npmjs.org/flatbuffers/-/flatbuffers-1.12.0.tgz",
+ "integrity": "sha512-c7CZADjRcl6j0PlvFy0ZqXQ67qSEZfrVPynmnL+2zPc+NtMvrF8Y0QceMo7QqnSPc7+uWjUIAbvCQ5WIKlMVdQ==",
+ "license": "SEE LICENSE IN LICENSE.txt"
+ },
"node_modules/form-data": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
@@ -6561,6 +7522,12 @@
}
}
},
+ "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==",
+ "license": "MIT"
+ },
"node_modules/fs-extra": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz",
@@ -6716,6 +7683,12 @@
"js-binary-schema-parser": "^2.0.3"
}
},
+ "node_modules/github-from-package": {
+ "version": "0.0.0",
+ "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
+ "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
+ "license": "MIT"
+ },
"node_modules/glob": {
"version": "7.2.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
@@ -6883,6 +7856,12 @@
"integrity": "sha512-dMW4CWBTUK1AEEDeZc1g4xpPGIrSf9fJF960qbTZmN/QwZIWY5wgliS6JWl9/25fpTGJrMRtSjGtOmPnfjZB+A==",
"license": "Standard 'no charge' license: https://gsap.com/standard-license."
},
+ "node_modules/guid-typescript": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/guid-typescript/-/guid-typescript-1.0.9.tgz",
+ "integrity": "sha512-Y8T4vYhEfwJOTbouREvG+3XDsjr8E3kIr7uf+JZ0BYloFsttiHU0WfvANVsR7TxNUJa/WpCnw/Ino/p+DeBhBQ==",
+ "license": "ISC"
+ },
"node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
@@ -7093,7 +8072,6 @@
"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",
@@ -7108,8 +8086,7 @@
"url": "https://feross.org/support"
}
],
- "license": "BSD-3-Clause",
- "optional": true
+ "license": "BSD-3-Clause"
},
"node_modules/indent-string": {
"version": "4.0.0",
@@ -7137,9 +8114,20 @@
"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/ini": {
+ "version": "1.3.8",
+ "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
+ "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
+ "license": "ISC"
+ },
+ "node_modules/is-arrayish": {
+ "version": "0.3.4",
+ "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz",
+ "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==",
+ "license": "MIT"
+ },
"node_modules/is-binary-path": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
@@ -7382,6 +8370,13 @@
"license": "ISC",
"optional": true
},
+ "node_modules/json-with-bigint": {
+ "version": "3.5.8",
+ "resolved": "https://registry.npmjs.org/json-with-bigint/-/json-with-bigint-3.5.8.tgz",
+ "integrity": "sha512-eq/4KP6K34kwa7TcFdtvnftvHCD9KvHOGGICWwMFc4dOOKF5t4iYqnfLK8otCRCRv06FXOzGGyqE8h8ElMvvdw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/json5": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
@@ -7440,6 +8435,12 @@
"integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
"license": "MIT"
},
+ "node_modules/linkifyjs": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.3.tgz",
+ "integrity": "sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg==",
+ "license": "MIT"
+ },
"node_modules/lint-staged": {
"version": "16.4.0",
"resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-16.4.0.tgz",
@@ -7652,6 +8653,12 @@
"url": "https://github.com/chalk/slice-ansi?sponsor=1"
}
},
+ "node_modules/long": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz",
+ "integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==",
+ "license": "Apache-2.0"
+ },
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
@@ -7884,7 +8891,6 @@
"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"
@@ -7927,6 +8933,12 @@
"mkdirp": "bin/cmd.js"
}
},
+ "node_modules/mkdirp-classic": {
+ "version": "0.5.3",
+ "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
+ "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
+ "license": "MIT"
+ },
"node_modules/motion": {
"version": "12.38.0",
"resolved": "https://registry.npmjs.org/motion/-/motion-12.38.0.tgz",
@@ -8023,6 +9035,12 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
+ "node_modules/napi-build-utils": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
+ "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
+ "license": "MIT"
+ },
"node_modules/node-abi": {
"version": "4.28.0",
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-4.28.0.tgz",
@@ -8256,7 +9274,6 @@
"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"
@@ -8278,6 +9295,56 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/onnx-proto": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/onnx-proto/-/onnx-proto-4.0.4.tgz",
+ "integrity": "sha512-aldMOB3HRoo6q/phyB6QRQxSt895HNNw82BNyZ2CMh4bjeKv7g/c+VpAFtJuEMVfYLMbRx61hbuqnKceLeDcDA==",
+ "license": "MIT",
+ "dependencies": {
+ "protobufjs": "^6.8.8"
+ }
+ },
+ "node_modules/onnxruntime-common": {
+ "version": "1.14.0",
+ "resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.14.0.tgz",
+ "integrity": "sha512-3LJpegM2iMNRX2wUmtYfeX/ytfOzNwAWKSq1HbRrKc9+uqG/FsEA0bbKZl1btQeZaXhC26l44NWpNUeXPII7Ew==",
+ "license": "MIT"
+ },
+ "node_modules/onnxruntime-node": {
+ "version": "1.14.0",
+ "resolved": "https://registry.npmjs.org/onnxruntime-node/-/onnxruntime-node-1.14.0.tgz",
+ "integrity": "sha512-5ba7TWomIV/9b6NH/1x/8QEeowsb+jBEvFzU6z0T4mNsFwdPqXeFUM7uxC6QeSRkEbWu3qEB0VMjrvzN/0S9+w==",
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32",
+ "darwin",
+ "linux"
+ ],
+ "dependencies": {
+ "onnxruntime-common": "~1.14.0"
+ }
+ },
+ "node_modules/onnxruntime-web": {
+ "version": "1.14.0",
+ "resolved": "https://registry.npmjs.org/onnxruntime-web/-/onnxruntime-web-1.14.0.tgz",
+ "integrity": "sha512-Kcqf43UMfW8mCydVGcX9OMXI2VN17c0p6XvR7IPSZzBf/6lteBzXHvcEVWDPmCKuGombl997HgLqj91F11DzXw==",
+ "license": "MIT",
+ "dependencies": {
+ "flatbuffers": "^1.12.0",
+ "guid-typescript": "^1.0.9",
+ "long": "^4.0.0",
+ "onnx-proto": "^4.0.4",
+ "onnxruntime-common": "~1.14.0",
+ "platform": "^1.3.6"
+ }
+ },
+ "node_modules/orderedmap": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz",
+ "integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==",
+ "license": "MIT"
+ },
"node_modules/p-cancelable": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz",
@@ -8470,6 +9537,12 @@
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
"license": "MIT"
},
+ "node_modules/platform": {
+ "version": "1.3.6",
+ "resolved": "https://registry.npmjs.org/platform/-/platform-1.3.6.tgz",
+ "integrity": "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==",
+ "license": "MIT"
+ },
"node_modules/playwright": {
"version": "1.59.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz",
@@ -8695,22 +9768,107 @@
"commander": "^9.4.0"
},
"bin": {
- "postject": "dist/cli.js"
+ "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/prebuild-install": {
+ "version": "7.1.3",
+ "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
+ "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
+ "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.",
+ "license": "MIT",
+ "dependencies": {
+ "detect-libc": "^2.0.0",
+ "expand-template": "^2.0.3",
+ "github-from-package": "0.0.0",
+ "minimist": "^1.2.3",
+ "mkdirp-classic": "^0.5.3",
+ "napi-build-utils": "^2.0.0",
+ "node-abi": "^3.3.0",
+ "pump": "^3.0.0",
+ "rc": "^1.2.7",
+ "simple-get": "^4.0.0",
+ "tar-fs": "^2.0.0",
+ "tunnel-agent": "^0.6.0"
+ },
+ "bin": {
+ "prebuild-install": "bin.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/prebuild-install/node_modules/chownr": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
+ "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
+ "license": "ISC"
+ },
+ "node_modules/prebuild-install/node_modules/node-abi": {
+ "version": "3.92.0",
+ "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.92.0.tgz",
+ "integrity": "sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==",
+ "license": "MIT",
+ "dependencies": {
+ "semver": "^7.3.5"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/prebuild-install/node_modules/semver": {
+ "version": "7.8.0",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz",
+ "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==",
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
},
"engines": {
- "node": ">=14.0.0"
+ "node": ">=10"
}
},
- "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,
+ "node_modules/prebuild-install/node_modules/tar-fs": {
+ "version": "2.1.4",
+ "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
+ "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==",
"license": "MIT",
- "optional": true,
- "peer": true,
+ "dependencies": {
+ "chownr": "^1.1.1",
+ "mkdirp-classic": "^0.5.2",
+ "pump": "^3.0.0",
+ "tar-stream": "^2.1.4"
+ }
+ },
+ "node_modules/prebuild-install/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==",
+ "license": "MIT",
+ "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": "^12.20.0 || >=14"
+ "node": ">=6"
}
},
"node_modules/pretty-format": {
@@ -8806,11 +9964,175 @@
"signal-exit": "^3.0.2"
}
},
+ "node_modules/prosemirror-changeset": {
+ "version": "2.4.1",
+ "resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.1.tgz",
+ "integrity": "sha512-96WBLhOaYhJ+kPhLg3uW359Tz6I/MfcrQfL4EGv4SrcqKEMC1gmoGrXHecPE8eOwTVCJ4IwgfzM8fFad25wNfw==",
+ "license": "MIT",
+ "dependencies": {
+ "prosemirror-transform": "^1.0.0"
+ }
+ },
+ "node_modules/prosemirror-commands": {
+ "version": "1.7.1",
+ "resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.1.tgz",
+ "integrity": "sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==",
+ "license": "MIT",
+ "dependencies": {
+ "prosemirror-model": "^1.0.0",
+ "prosemirror-state": "^1.0.0",
+ "prosemirror-transform": "^1.10.2"
+ }
+ },
+ "node_modules/prosemirror-dropcursor": {
+ "version": "1.8.2",
+ "resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.2.tgz",
+ "integrity": "sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw==",
+ "license": "MIT",
+ "dependencies": {
+ "prosemirror-state": "^1.0.0",
+ "prosemirror-transform": "^1.1.0",
+ "prosemirror-view": "^1.1.0"
+ }
+ },
+ "node_modules/prosemirror-gapcursor": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.1.tgz",
+ "integrity": "sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==",
+ "license": "MIT",
+ "dependencies": {
+ "prosemirror-keymap": "^1.0.0",
+ "prosemirror-model": "^1.0.0",
+ "prosemirror-state": "^1.0.0",
+ "prosemirror-view": "^1.0.0"
+ }
+ },
+ "node_modules/prosemirror-history": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.0.tgz",
+ "integrity": "sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==",
+ "license": "MIT",
+ "dependencies": {
+ "prosemirror-state": "^1.2.2",
+ "prosemirror-transform": "^1.0.0",
+ "prosemirror-view": "^1.31.0",
+ "rope-sequence": "^1.3.0"
+ }
+ },
+ "node_modules/prosemirror-inputrules": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/prosemirror-inputrules/-/prosemirror-inputrules-1.5.1.tgz",
+ "integrity": "sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==",
+ "license": "MIT",
+ "dependencies": {
+ "prosemirror-state": "^1.0.0",
+ "prosemirror-transform": "^1.0.0"
+ }
+ },
+ "node_modules/prosemirror-keymap": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz",
+ "integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==",
+ "license": "MIT",
+ "dependencies": {
+ "prosemirror-state": "^1.0.0",
+ "w3c-keyname": "^2.2.0"
+ }
+ },
+ "node_modules/prosemirror-model": {
+ "version": "1.25.9",
+ "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.9.tgz",
+ "integrity": "sha512-pRTklkDDMMRopyoAcrr9wV/8g/RYgrLHBuJAb5hlEuYZRdm5yqmPjWId83fpBwPpSFqEdja0H7Dfd7z1X/npcA==",
+ "license": "MIT",
+ "dependencies": {
+ "orderedmap": "^2.0.0"
+ }
+ },
+ "node_modules/prosemirror-schema-list": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz",
+ "integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==",
+ "license": "MIT",
+ "dependencies": {
+ "prosemirror-model": "^1.0.0",
+ "prosemirror-state": "^1.0.0",
+ "prosemirror-transform": "^1.7.3"
+ }
+ },
+ "node_modules/prosemirror-state": {
+ "version": "1.4.4",
+ "resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz",
+ "integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==",
+ "license": "MIT",
+ "dependencies": {
+ "prosemirror-model": "^1.0.0",
+ "prosemirror-transform": "^1.0.0",
+ "prosemirror-view": "^1.27.0"
+ }
+ },
+ "node_modules/prosemirror-tables": {
+ "version": "1.8.5",
+ "resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.8.5.tgz",
+ "integrity": "sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==",
+ "license": "MIT",
+ "dependencies": {
+ "prosemirror-keymap": "^1.2.3",
+ "prosemirror-model": "^1.25.4",
+ "prosemirror-state": "^1.4.4",
+ "prosemirror-transform": "^1.10.5",
+ "prosemirror-view": "^1.41.4"
+ }
+ },
+ "node_modules/prosemirror-transform": {
+ "version": "1.12.0",
+ "resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.12.0.tgz",
+ "integrity": "sha512-GxboyN4AMIsoHNtz5uf2r2Ru551i5hWeCMD6E2Ib4Eogqoub0NflniaBPVQ4MrGE5yZ8JV9tUHg9qcZTTrcN4w==",
+ "license": "MIT",
+ "dependencies": {
+ "prosemirror-model": "^1.21.0"
+ }
+ },
+ "node_modules/prosemirror-view": {
+ "version": "1.42.0",
+ "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.42.0.tgz",
+ "integrity": "sha512-N54DF3OXNWDuP81G1kbfCys8ZzIjuL1VnvJ2mk5STSu/fNxWIcX/EutQLA3s9KR/2wVhgDi4hzBB/1fINVxk0A==",
+ "license": "MIT",
+ "dependencies": {
+ "prosemirror-model": "^1.25.8",
+ "prosemirror-state": "^1.0.0",
+ "prosemirror-transform": "^1.1.0"
+ }
+ },
+ "node_modules/protobufjs": {
+ "version": "6.11.6",
+ "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-6.11.6.tgz",
+ "integrity": "sha512-k8BHqgPBOtrlougZZqF2uUk5Z7bN8f0wj+3e8M3hvtSv0NBAz4VBy5f6R5Nxq/l+i7mRFTgNZb2trxqTpHNY/A==",
+ "hasInstallScript": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "@protobufjs/aspromise": "^1.1.2",
+ "@protobufjs/base64": "^1.1.2",
+ "@protobufjs/codegen": "^2.0.4",
+ "@protobufjs/eventemitter": "^1.1.0",
+ "@protobufjs/fetch": "^1.1.0",
+ "@protobufjs/float": "^1.0.2",
+ "@protobufjs/inquire": "^1.1.0",
+ "@protobufjs/path": "^1.1.2",
+ "@protobufjs/pool": "^1.1.0",
+ "@protobufjs/utf8": "^1.1.0",
+ "@types/long": "^4.0.1",
+ "@types/node": ">=13.7.0",
+ "long": "^4.0.0"
+ },
+ "bin": {
+ "pbjs": "bin/pbjs",
+ "pbts": "bin/pbts"
+ }
+ },
"node_modules/pump": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
"integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
- "dev": true,
"license": "MIT",
"dependencies": {
"end-of-stream": "^1.1.0",
@@ -8893,6 +10215,21 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/rc": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
+ "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
+ "license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
+ "dependencies": {
+ "deep-extend": "^0.6.0",
+ "ini": "~1.3.0",
+ "minimist": "^1.2.0",
+ "strip-json-comments": "~2.0.1"
+ },
+ "bin": {
+ "rc": "cli.js"
+ }
+ },
"node_modules/re-resizable": {
"version": "6.11.2",
"resolved": "https://registry.npmjs.org/re-resizable/-/re-resizable-6.11.2.tgz",
@@ -9091,6 +10428,20 @@
"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",
@@ -9350,6 +10701,12 @@
"fsevents": "~2.3.2"
}
},
+ "node_modules/rope-sequence": {
+ "version": "1.3.4",
+ "resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz",
+ "integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==",
+ "license": "MIT"
+ },
"node_modules/run-parallel": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
@@ -9373,6 +10730,26 @@
"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",
@@ -9457,6 +10834,47 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/sharp": {
+ "version": "0.32.6",
+ "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.32.6.tgz",
+ "integrity": "sha512-KyLTWwgcR9Oe4d9HwCwNM2l7+J0dUQwn/yf7S0EnTtb0eVS4RxO0eUSvxPtzT4F3SY+C4K6fqdv/DO27sJ/v/w==",
+ "hasInstallScript": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "color": "^4.2.3",
+ "detect-libc": "^2.0.2",
+ "node-addon-api": "^6.1.0",
+ "prebuild-install": "^7.1.1",
+ "semver": "^7.5.4",
+ "simple-get": "^4.0.1",
+ "tar-fs": "^3.0.4",
+ "tunnel-agent": "^0.6.0"
+ },
+ "engines": {
+ "node": ">=14.15.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/sharp/node_modules/node-addon-api": {
+ "version": "6.1.0",
+ "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-6.1.0.tgz",
+ "integrity": "sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA==",
+ "license": "MIT"
+ },
+ "node_modules/sharp/node_modules/semver": {
+ "version": "7.8.0",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz",
+ "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==",
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@@ -9570,6 +10988,60 @@
"dev": true,
"license": "ISC"
},
+ "node_modules/simple-concat": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
+ "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
+ "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/simple-get": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
+ "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
+ "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": {
+ "decompress-response": "^6.0.0",
+ "once": "^1.3.1",
+ "simple-concat": "^1.0.0"
+ }
+ },
+ "node_modules/simple-swizzle": {
+ "version": "0.2.4",
+ "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz",
+ "integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==",
+ "license": "MIT",
+ "dependencies": {
+ "is-arrayish": "^0.3.1"
+ }
+ },
"node_modules/simple-update-notifier": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz",
@@ -9711,6 +11183,26 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/streamx": {
+ "version": "2.25.0",
+ "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.25.0.tgz",
+ "integrity": "sha512-0nQuG6jf1w+wddNEEXCF4nTg3LtufWINB5eFEN+5TNZW7KWJp6x87+JFL43vaAUPyCfH1wID+mNVyW6OHtFamg==",
+ "license": "MIT",
+ "dependencies": {
+ "events-universal": "^1.0.0",
+ "fast-fifo": "^1.3.2",
+ "text-decoder": "^1.1.0"
+ }
+ },
+ "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-argv": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz",
@@ -9791,6 +11283,15 @@
"node": ">=8"
}
},
+ "node_modules/strip-json-comments": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
+ "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/sucrase": {
"version": "3.35.1",
"resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz",
@@ -9949,6 +11450,46 @@
"node": ">=18"
}
},
+ "node_modules/tar-fs": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.2.tgz",
+ "integrity": "sha512-QGxxTxxyleAdyM3kpFs14ymbYmNFrfY+pHj7Z8FgtbZ7w2//VAgLMac7sT6nRpIHjppXO2AwwEOg0bPFVRcmXw==",
+ "license": "MIT",
+ "dependencies": {
+ "pump": "^3.0.0",
+ "tar-stream": "^3.1.5"
+ },
+ "optionalDependencies": {
+ "bare-fs": "^4.0.1",
+ "bare-path": "^3.0.0"
+ }
+ },
+ "node_modules/tar-stream": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.2.0.tgz",
+ "integrity": "sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==",
+ "license": "MIT",
+ "dependencies": {
+ "b4a": "^1.6.4",
+ "bare-fs": "^4.5.5",
+ "fast-fifo": "^1.2.0",
+ "streamx": "^2.15.0"
+ }
+ },
+ "node_modules/tar-stream/node_modules/b4a": {
+ "version": "1.8.1",
+ "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.1.tgz",
+ "integrity": "sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==",
+ "license": "Apache-2.0",
+ "peerDependencies": {
+ "react-native-b4a": "*"
+ },
+ "peerDependenciesMeta": {
+ "react-native-b4a": {
+ "optional": true
+ }
+ }
+ },
"node_modules/tar/node_modules/yallist": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz",
@@ -9959,6 +11500,15 @@
"node": ">=18"
}
},
+ "node_modules/teex": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz",
+ "integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==",
+ "license": "MIT",
+ "dependencies": {
+ "streamx": "^2.12.5"
+ }
+ },
"node_modules/temp": {
"version": "0.9.4",
"resolved": "https://registry.npmjs.org/temp/-/temp-0.9.4.tgz",
@@ -10049,6 +11599,29 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/text-decoder": {
+ "version": "1.2.7",
+ "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz",
+ "integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "b4a": "^1.6.4"
+ }
+ },
+ "node_modules/text-decoder/node_modules/b4a": {
+ "version": "1.8.1",
+ "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.1.tgz",
+ "integrity": "sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==",
+ "license": "Apache-2.0",
+ "peerDependencies": {
+ "react-native-b4a": "*"
+ },
+ "peerDependenciesMeta": {
+ "react-native-b4a": {
+ "optional": true
+ }
+ }
+ },
"node_modules/thenify": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
@@ -10252,6 +11825,28 @@
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
+ "node_modules/tunnel": {
+ "version": "0.0.6",
+ "resolved": "https://registry.npmjs.org/tunnel/-/tunnel-0.0.6.tgz",
+ "integrity": "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.6.11 <=0.7.0 || >=0.7.3"
+ }
+ },
+ "node_modules/tunnel-agent": {
+ "version": "0.6.0",
+ "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
+ "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "safe-buffer": "^5.0.1"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
"node_modules/type-fest": {
"version": "0.13.1",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz",
@@ -10294,9 +11889,15 @@
"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/universal-user-agent": {
+ "version": "7.0.3",
+ "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-7.0.3.tgz",
+ "integrity": "sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==",
+ "dev": true,
+ "license": "ISC"
+ },
"node_modules/universalify": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz",
@@ -10412,6 +12013,15 @@
}
}
},
+ "node_modules/use-sync-external-store": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
+ "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
"node_modules/utf8-byte-length": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.5.tgz",
@@ -10656,6 +12266,12 @@
}
}
},
+ "node_modules/w3c-keyname": {
+ "version": "2.2.8",
+ "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
+ "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
+ "license": "MIT"
+ },
"node_modules/w3c-xmlserializer": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
@@ -10806,7 +12422,6 @@
"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/ws": {
diff --git a/package.json b/package.json
index fd0c4cf3d4..8d40c9d7e6 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "openscreen",
"private": true,
- "version": "1.4.0",
+ "version": "1.6.0",
"type": "module",
"packageManager": "npm@10.9.4",
"engines": {
@@ -9,9 +9,15 @@
"npm": "10.9.4"
},
"author": {
- "name": "Sid",
- "email": "svaddem@asu.edu"
+ "name": "Siddharth Vaddem",
+ "url": "https://github.com/siddharthvaddem"
},
+ "maintainers": [
+ {
+ "name": "Etienne Lescot",
+ "url": "https://github.com/EtienneLescot"
+ }
+ ],
"scripts": {
"dev": "vite",
"build": "tsc && vite build && electron-builder",
@@ -37,6 +43,8 @@
"test:wgc-full:win": "node scripts/test-windows-wgc-helper.mjs --webcam --system-audio --microphone",
"capture:openscreen-preview": "node scripts/capture-openscreen-preview.mjs",
"inspect:cursor-click-bounce": "node scripts/inspect-native-cursor-click-bounce.mjs",
+ "diagnostic:run": "node scripts/diagnostic-tool/diagnostic.mjs",
+ "diagnostic:smoke:win": "node scripts/diagnostic-tool/diagnostic.mjs --duration 3",
"build-vite": "tsc && vite build",
"test:browser": "vitest --config vitest.browser.config.ts --run",
"test:browser:install": "playwright install --with-deps chromium-headless-shell",
@@ -59,10 +67,14 @@
"@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8",
+ "@tiptap/extension-text-style": "^3.27.1",
+ "@tiptap/react": "^3.27.1",
+ "@tiptap/starter-kit": "^3.27.1",
"@types/gif.js": "^0.2.5",
"@uiw/color-convert": "^2.10.1",
"@uiw/react-color-block": "^2.10.1",
"@uiw/react-color-colorful": "^2.9.2",
+ "@xenova/transformers": "^2.17.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dnd-timeline": "^2.4.0",
@@ -88,6 +100,8 @@
"web-demuxer": "^4.0.0"
},
"devDependencies": {
+ "@actions/core": "^3.0.1",
+ "@actions/github": "^9.1.1",
"@biomejs/biome": "^2.4.12",
"@electron/rebuild": "^4.0.4",
"@playwright/test": "^1.59.1",
diff --git a/public/cursors/among-us-sus-knife-and-red-animated/arrow.png b/public/cursors/among-us-sus-knife-and-red-animated/arrow.png
new file mode 100644
index 0000000000..84e9a3b34d
Binary files /dev/null and b/public/cursors/among-us-sus-knife-and-red-animated/arrow.png differ
diff --git a/public/cursors/among-us-sus-knife-and-red-animated/pointer.png b/public/cursors/among-us-sus-knife-and-red-animated/pointer.png
new file mode 100644
index 0000000000..b4212f7326
Binary files /dev/null and b/public/cursors/among-us-sus-knife-and-red-animated/pointer.png differ
diff --git a/public/cursors/black-and-rainbow-stroke-gradient-animated/arrow.png b/public/cursors/black-and-rainbow-stroke-gradient-animated/arrow.png
new file mode 100644
index 0000000000..adfcec8dcb
Binary files /dev/null and b/public/cursors/black-and-rainbow-stroke-gradient-animated/arrow.png differ
diff --git a/public/cursors/black-and-rainbow-stroke-gradient-animated/pointer.png b/public/cursors/black-and-rainbow-stroke-gradient-animated/pointer.png
new file mode 100644
index 0000000000..2a0ebf08fb
Binary files /dev/null and b/public/cursors/black-and-rainbow-stroke-gradient-animated/pointer.png differ
diff --git a/public/cursors/black-pixel/arrow.png b/public/cursors/black-pixel/arrow.png
new file mode 100644
index 0000000000..ec2d6a3159
Binary files /dev/null and b/public/cursors/black-pixel/arrow.png differ
diff --git a/public/cursors/black-pixel/pointer.png b/public/cursors/black-pixel/pointer.png
new file mode 100644
index 0000000000..ffd5616410
Binary files /dev/null and b/public/cursors/black-pixel/pointer.png differ
diff --git a/public/cursors/christmas-miles-morales/arrow.png b/public/cursors/christmas-miles-morales/arrow.png
new file mode 100644
index 0000000000..6931394ecb
Binary files /dev/null and b/public/cursors/christmas-miles-morales/arrow.png differ
diff --git a/public/cursors/christmas-miles-morales/pointer.png b/public/cursors/christmas-miles-morales/pointer.png
new file mode 100644
index 0000000000..049a68e2ea
Binary files /dev/null and b/public/cursors/christmas-miles-morales/pointer.png differ
diff --git a/public/cursors/hello-kitty-watermelon/arrow.png b/public/cursors/hello-kitty-watermelon/arrow.png
new file mode 100644
index 0000000000..9e6a203199
Binary files /dev/null and b/public/cursors/hello-kitty-watermelon/arrow.png differ
diff --git a/public/cursors/hello-kitty-watermelon/pointer.png b/public/cursors/hello-kitty-watermelon/pointer.png
new file mode 100644
index 0000000000..f45fdfd061
Binary files /dev/null and b/public/cursors/hello-kitty-watermelon/pointer.png differ
diff --git a/public/cursors/hollow-knight-and-game-arrow/arrow.png b/public/cursors/hollow-knight-and-game-arrow/arrow.png
new file mode 100644
index 0000000000..efb4b77082
Binary files /dev/null and b/public/cursors/hollow-knight-and-game-arrow/arrow.png differ
diff --git a/public/cursors/hollow-knight-and-game-arrow/pointer.png b/public/cursors/hollow-knight-and-game-arrow/pointer.png
new file mode 100644
index 0000000000..dcea8e91d1
Binary files /dev/null and b/public/cursors/hollow-knight-and-game-arrow/pointer.png differ
diff --git a/public/cursors/hollow-knight-nail-sword-and-mask/arrow.png b/public/cursors/hollow-knight-nail-sword-and-mask/arrow.png
new file mode 100644
index 0000000000..dbd4f52a20
Binary files /dev/null and b/public/cursors/hollow-knight-nail-sword-and-mask/arrow.png differ
diff --git a/public/cursors/hollow-knight-nail-sword-and-mask/pointer.png b/public/cursors/hollow-knight-nail-sword-and-mask/pointer.png
new file mode 100644
index 0000000000..ac77b710c5
Binary files /dev/null and b/public/cursors/hollow-knight-nail-sword-and-mask/pointer.png differ
diff --git a/public/cursors/mickey-mouse-black-hand-inflated-glove/arrow.png b/public/cursors/mickey-mouse-black-hand-inflated-glove/arrow.png
new file mode 100644
index 0000000000..1430763725
Binary files /dev/null and b/public/cursors/mickey-mouse-black-hand-inflated-glove/arrow.png differ
diff --git a/public/cursors/mickey-mouse-black-hand-inflated-glove/pointer.png b/public/cursors/mickey-mouse-black-hand-inflated-glove/pointer.png
new file mode 100644
index 0000000000..f52a4e650c
Binary files /dev/null and b/public/cursors/mickey-mouse-black-hand-inflated-glove/pointer.png differ
diff --git a/public/cursors/naruto-akatsuki-cloud-arrow/arrow.png b/public/cursors/naruto-akatsuki-cloud-arrow/arrow.png
new file mode 100644
index 0000000000..d6361dff20
Binary files /dev/null and b/public/cursors/naruto-akatsuki-cloud-arrow/arrow.png differ
diff --git a/public/cursors/naruto-akatsuki-cloud-arrow/pointer.png b/public/cursors/naruto-akatsuki-cloud-arrow/pointer.png
new file mode 100644
index 0000000000..fa10e9d2d8
Binary files /dev/null and b/public/cursors/naruto-akatsuki-cloud-arrow/pointer.png differ
diff --git a/public/cursors/old-roblox/arrow.png b/public/cursors/old-roblox/arrow.png
new file mode 100644
index 0000000000..a55875df9e
Binary files /dev/null and b/public/cursors/old-roblox/arrow.png differ
diff --git a/public/cursors/old-roblox/pointer.png b/public/cursors/old-roblox/pointer.png
new file mode 100644
index 0000000000..d490a65fd6
Binary files /dev/null and b/public/cursors/old-roblox/pointer.png differ
diff --git a/public/cursors/pink-glossy-arrow-and-hand-3d/arrow.png b/public/cursors/pink-glossy-arrow-and-hand-3d/arrow.png
new file mode 100644
index 0000000000..69ecd5b199
Binary files /dev/null and b/public/cursors/pink-glossy-arrow-and-hand-3d/arrow.png differ
diff --git a/public/cursors/pink-glossy-arrow-and-hand-3d/pointer.png b/public/cursors/pink-glossy-arrow-and-hand-3d/pointer.png
new file mode 100644
index 0000000000..f8d5938a70
Binary files /dev/null and b/public/cursors/pink-glossy-arrow-and-hand-3d/pointer.png differ
diff --git a/public/cursors/pinky-pixel/arrow.png b/public/cursors/pinky-pixel/arrow.png
new file mode 100644
index 0000000000..4bcd3dcaa9
Binary files /dev/null and b/public/cursors/pinky-pixel/arrow.png differ
diff --git a/public/cursors/pinky-pixel/pointer.png b/public/cursors/pinky-pixel/pointer.png
new file mode 100644
index 0000000000..7eb897a743
Binary files /dev/null and b/public/cursors/pinky-pixel/pointer.png differ
diff --git a/public/cursors/pokemon-neon-gengar/arrow.png b/public/cursors/pokemon-neon-gengar/arrow.png
new file mode 100644
index 0000000000..4dcccb3609
Binary files /dev/null and b/public/cursors/pokemon-neon-gengar/arrow.png differ
diff --git a/public/cursors/pokemon-neon-gengar/pointer.png b/public/cursors/pokemon-neon-gengar/pointer.png
new file mode 100644
index 0000000000..e7b4437f0e
Binary files /dev/null and b/public/cursors/pokemon-neon-gengar/pointer.png differ
diff --git a/public/cursors/sanrio-gudetama-and-arrow-kawaii/arrow.png b/public/cursors/sanrio-gudetama-and-arrow-kawaii/arrow.png
new file mode 100644
index 0000000000..123117b05b
Binary files /dev/null and b/public/cursors/sanrio-gudetama-and-arrow-kawaii/arrow.png differ
diff --git a/public/cursors/sanrio-gudetama-and-arrow-kawaii/pointer.png b/public/cursors/sanrio-gudetama-and-arrow-kawaii/pointer.png
new file mode 100644
index 0000000000..37afa7bd17
Binary files /dev/null and b/public/cursors/sanrio-gudetama-and-arrow-kawaii/pointer.png differ
diff --git a/public/cursors/sanrio-kuromi-skull-arrow/arrow.png b/public/cursors/sanrio-kuromi-skull-arrow/arrow.png
new file mode 100644
index 0000000000..6fe7e89d6e
Binary files /dev/null and b/public/cursors/sanrio-kuromi-skull-arrow/arrow.png differ
diff --git a/public/cursors/sanrio-kuromi-skull-arrow/pointer.png b/public/cursors/sanrio-kuromi-skull-arrow/pointer.png
new file mode 100644
index 0000000000..2971a7f168
Binary files /dev/null and b/public/cursors/sanrio-kuromi-skull-arrow/pointer.png differ
diff --git a/public/cursors/solo-leveling-sung-jinwoo-dark-flames/arrow.png b/public/cursors/solo-leveling-sung-jinwoo-dark-flames/arrow.png
new file mode 100644
index 0000000000..2155e0fd95
Binary files /dev/null and b/public/cursors/solo-leveling-sung-jinwoo-dark-flames/arrow.png differ
diff --git a/public/cursors/solo-leveling-sung-jinwoo-dark-flames/pointer.png b/public/cursors/solo-leveling-sung-jinwoo-dark-flames/pointer.png
new file mode 100644
index 0000000000..0e0b1a0b7b
Binary files /dev/null and b/public/cursors/solo-leveling-sung-jinwoo-dark-flames/pointer.png differ
diff --git a/public/cursors/spring-gradient/arrow.png b/public/cursors/spring-gradient/arrow.png
new file mode 100644
index 0000000000..51889625d9
Binary files /dev/null and b/public/cursors/spring-gradient/arrow.png differ
diff --git a/public/cursors/spring-gradient/pointer.png b/public/cursors/spring-gradient/pointer.png
new file mode 100644
index 0000000000..9ccdc2fb64
Binary files /dev/null and b/public/cursors/spring-gradient/pointer.png differ
diff --git a/public/demo.png b/public/demo.png
new file mode 100644
index 0000000000..a0f008fe46
Binary files /dev/null and b/public/demo.png differ
diff --git a/public/sample.png b/public/sample.png
new file mode 100644
index 0000000000..0138b39ac9
Binary files /dev/null and b/public/sample.png differ
diff --git a/public/wallpapers/wallpaper1.jpg b/public/wallpapers/wallpaper1.jpg
index 98c93cb14e..dbd8afb8b0 100644
Binary files a/public/wallpapers/wallpaper1.jpg and b/public/wallpapers/wallpaper1.jpg differ
diff --git a/scripts/before-pack.cjs b/scripts/before-pack.cjs
new file mode 100644
index 0000000000..934756a4db
--- /dev/null
+++ b/scripts/before-pack.cjs
@@ -0,0 +1,14 @@
+// electron-builder beforePack hook: ensure the auto-caption assets (Whisper model + ORT wasm) exist
+// before packaging, so the `caption-assets` extraResources entry has something to copy. Runs on
+// every package invocation (local `npm run build:*` and CI's bare `electron-builder`). The fetch
+// script is idempotent, so it's a no-op once the assets are present.
+
+const { execFileSync } = require("node:child_process");
+const path = require("node:path");
+
+exports.default = async function beforePack() {
+ execFileSync("node", [path.join(__dirname, "fetch-caption-model.mjs")], {
+ stdio: "inherit",
+ cwd: path.join(__dirname, ".."),
+ });
+};
diff --git a/scripts/build-macos-screencapturekit-helper.mjs b/scripts/build-macos-screencapturekit-helper.mjs
index 8e7c973967..b94836cfff 100644
--- a/scripts/build-macos-screencapturekit-helper.mjs
+++ b/scripts/build-macos-screencapturekit-helper.mjs
@@ -18,14 +18,24 @@ const cursorHelperName = "openscreen-macos-cursor-helper";
const packageDir = path.join(root, "electron", "native", "screencapturekit");
const buildDir = path.join(packageDir, "build");
const swiftBuildDir = path.join(buildDir, "swiftpm");
-const builtHelperPath = path.join(swiftBuildDir, "release", helperName);
const localHelperPath = path.join(buildDir, helperName);
-const builtCursorHelperPath = path.join(swiftBuildDir, "release", cursorHelperName);
const localCursorHelperPath = path.join(buildDir, cursorHelperName);
-const archTag = process.arch === "arm64" ? "darwin-arm64" : "darwin-x64";
-const distributableDir = path.join(root, "electron", "native", "bin", archTag);
-const distributablePath = path.join(distributableDir, helperName);
-const distributableCursorHelperPath = path.join(distributableDir, cursorHelperName);
+
+// Build a separate single-arch binary per requested arch and place each in its own
+// electron/native/bin/darwin- folder (the runtime resolves that folder by the running app's
+// arch). No universal/fat binary. Defaults to the host arch for local builds; CI sets
+// OPENSCREEN_MAC_HELPER_ARCHS per matrix entry (accepts arm64, x64, or x86_64).
+function normalizeArch(value) {
+ return value === "x64" || value === "x86_64"
+ ? { swift: "x86_64", tag: "darwin-x64" }
+ : { swift: "arm64", tag: "darwin-arm64" };
+}
+const hostArch = process.arch === "arm64" ? "arm64" : "x86_64";
+const archs = (process.env.OPENSCREEN_MAC_HELPER_ARCHS ?? hostArch)
+ .split(",")
+ .map((a) => a.trim())
+ .filter(Boolean)
+ .map(normalizeArch);
const xcodebuildVersion = spawnSync("xcodebuild", ["-version"], {
cwd: root,
@@ -50,42 +60,85 @@ if (xcodebuildVersion.status !== 0) {
process.exit(1);
}
-const result = spawnSync(
- "swift",
- ["build", "-c", "release", "--package-path", packageDir, "--build-path", swiftBuildDir],
- {
- cwd: root,
- stdio: "inherit",
- },
-);
+// SwiftPM writes a single-arch release build to /-apple-macosx/release/.
+// Fall back to a search that skips the identically-named file inside the .dSYM debug bundle (matching
+// that file and feeding it forward is what produced an unrunnable "exec format error" binary before).
+function findExecutable(dir, swiftArch, name) {
+ const expected = path.join(dir, `${swiftArch}-apple-macosx`, "release", name);
+ if (fs.existsSync(expected)) return expected;
-if (result.error) {
- console.error(`Failed to start Swift build: ${result.error.message}`);
- process.exit(1);
-}
-
-if (result.status !== 0) {
- process.exit(result.status ?? 1);
+ const stack = [dir];
+ const matches = [];
+ while (stack.length > 0) {
+ const current = stack.pop();
+ let entries;
+ try {
+ entries = fs.readdirSync(current, { withFileTypes: true });
+ } catch {
+ continue;
+ }
+ for (const entry of entries) {
+ if (entry.name.endsWith(".dSYM")) continue;
+ const full = path.join(current, entry.name);
+ if (entry.isDirectory()) stack.push(full);
+ else if (entry.isFile() && entry.name === name && /[/\\]release[/\\]/i.test(full)) {
+ matches.push(full);
+ }
+ }
+ }
+ return matches[0] ?? null;
}
fs.mkdirSync(buildDir, { recursive: true });
-fs.mkdirSync(distributableDir, { recursive: true });
-for (const artifactPath of [builtHelperPath, builtCursorHelperPath]) {
- if (!fs.existsSync(artifactPath)) {
- console.error(`Swift build completed but expected artifact was not found: ${artifactPath}`);
+
+for (const { swift, tag } of archs) {
+ const archBuildDir = path.join(swiftBuildDir, swift);
+ const result = spawnSync(
+ "swift",
+ [
+ "build",
+ "-c",
+ "release",
+ "--arch",
+ swift,
+ "--package-path",
+ packageDir,
+ "--build-path",
+ archBuildDir,
+ ],
+ {
+ cwd: root,
+ stdio: "inherit",
+ },
+ );
+ if (result.error) {
+ console.error(`Failed to start Swift build (${swift}): ${result.error.message}`);
process.exit(1);
}
-}
-fs.copyFileSync(builtHelperPath, localHelperPath);
-fs.copyFileSync(builtHelperPath, distributablePath);
-fs.copyFileSync(builtCursorHelperPath, localCursorHelperPath);
-fs.copyFileSync(builtCursorHelperPath, distributableCursorHelperPath);
-fs.chmodSync(localHelperPath, 0o755);
-fs.chmodSync(distributablePath, 0o755);
-fs.chmodSync(localCursorHelperPath, 0o755);
-fs.chmodSync(distributableCursorHelperPath, 0o755);
+ if (result.status !== 0) {
+ process.exit(result.status ?? 1);
+ }
-console.log(`Built macOS ScreenCaptureKit helper: ${localHelperPath}`);
-console.log(`Copied redistributable helper: ${distributablePath}`);
-console.log(`Built macOS cursor helper: ${localCursorHelperPath}`);
-console.log(`Copied redistributable cursor helper: ${distributableCursorHelperPath}`);
+ const targetDir = path.join(root, "electron", "native", "bin", tag);
+ fs.mkdirSync(targetDir, { recursive: true });
+
+ for (const [name, localPath] of [
+ [helperName, localHelperPath],
+ [cursorHelperName, localCursorHelperPath],
+ ]) {
+ const exe = findExecutable(archBuildDir, swift, name);
+ if (!exe) {
+ console.error(`Swift build (${swift}) completed but executable was not found: ${name}`);
+ process.exit(1);
+ }
+ // Always place it in the arch's bin folder; mirror the host-arch build into the dev build
+ // dir so `npm run dev` (candidate path #2) can spawn it.
+ const dests = [path.join(targetDir, name)];
+ if (swift === hostArch) dests.push(localPath);
+ for (const dest of dests) {
+ fs.copyFileSync(exe, dest);
+ fs.chmodSync(dest, 0o755);
+ }
+ }
+ console.log(`Built ${tag} helpers (${swift})`);
+}
diff --git a/scripts/build-windows-wgc-helper.mjs b/scripts/build-windows-wgc-helper.mjs
index 29df4d842b..e0e3219bb8 100644
--- a/scripts/build-windows-wgc-helper.mjs
+++ b/scripts/build-windows-wgc-helper.mjs
@@ -1,4 +1,4 @@
-import { spawn } from "node:child_process";
+import { spawn, spawnSync } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
@@ -18,8 +18,37 @@ function findVcVarsAll() {
return explicit;
}
+ const vswhere = "C:\\Program Files (x86)\\Microsoft Visual Studio\\Installer\\vswhere.exe";
+ if (fs.existsSync(vswhere)) {
+ const result = spawnSync(
+ vswhere,
+ [
+ "-latest",
+ "-products",
+ "*",
+ "-requires",
+ "Microsoft.VisualStudio.Component.VC.Tools.x86.x64",
+ "-property",
+ "installationPath",
+ ],
+ { encoding: "utf8", windowsHide: true },
+ );
+ const installPath = result.stdout?.trim();
+ if (result.status === 0 && installPath) {
+ const candidate = path.join(installPath, "VC", "Auxiliary", "Build", "vcvarsall.bat");
+ if (fs.existsSync(candidate)) {
+ return candidate;
+ }
+ }
+ }
+
const roots = [
process.env.VSINSTALLDIR,
+ "C:\\Program Files\\Microsoft Visual Studio\\2026\\Community",
+ "C:\\Program Files\\Microsoft Visual Studio\\2026\\Professional",
+ "C:\\Program Files\\Microsoft Visual Studio\\2026\\Enterprise",
+ "C:\\Program Files (x86)\\Microsoft Visual Studio\\2026\\BuildTools",
+ "C:\\Program Files (x86)\\Microsoft Visual Studio\\2026\\Community",
"C:\\Program Files\\Microsoft Visual Studio\\2022\\Community",
"C:\\Program Files\\Microsoft Visual Studio\\2022\\Professional",
"C:\\Program Files\\Microsoft Visual Studio\\2022\\Enterprise",
diff --git a/scripts/diagnostic-tool/README.md b/scripts/diagnostic-tool/README.md
new file mode 100644
index 0000000000..046468e3e9
--- /dev/null
+++ b/scripts/diagnostic-tool/README.md
@@ -0,0 +1,65 @@
+# OpenScreen standalone diagnostic tool
+
+A small Node.js script that runs the native capture helper outside the
+Electron app, captures its stdout/stderr, and writes a JSON report.
+
+Used to capture `[stop-timing]` lines emitted by the WGC / ScreenCaptureKit
+helper when a recording stop hangs, so the issue reporter can attach the
+data without installing or rebuilding the full app.
+
+## Requirements
+
+- Node.js 22+ (OpenScreen's own engine pin)
+- The native capture helper for your platform in one of:
+ - the same directory as `diagnostic.mjs` (`wgc-capture.exe` on Windows,
+ `openscreen-screencapturekit-helper` on macOS)
+ - `helpers/-/` (CI artifact layout)
+ - `$OPENSCREEN_HELPER_EXE` env var
+
+Linux is not currently supported — OpenScreen has no Linux native helper.
+
+## Usage
+
+```text
+node diagnostic.mjs --duration 10 --output ./diag.json
+```
+
+Flags:
+- `-d, --duration ` recording length before sending stop (default 10)
+- `-o, --output ` output JSON path (default `./openscreen-diagnostic-.json`)
+- `--window` capture a window instead of the full display (default: display)
+- `-h, --help` show help
+
+Or use the bundled launcher:
+- Windows: `diagnostic.bat`
+- macOS / Linux: `./diagnostic.sh`
+
+## Output
+
+The JSON contains:
+- system info (platform, arch, OS, CPU, memory)
+- the helper's full stdout and stderr
+- parsed `[stop-timing]` entries as a structured array
+- the JSON config that was sent to the helper
+- exit code / signal
+
+Attach the JSON to a GitHub issue. Maintainers will read the
+`stopTiming` array and the helper stderr to pinpoint which step of the
+stop cleanup is slow.
+
+## Layout
+
+```text
+scripts/diagnostic-tool/
+ README.md
+ diagnostic.mjs # the tool
+ diagnostic.bat # Windows launcher
+ diagnostic.sh # macOS / Linux launcher
+```
+
+## CI artifacts
+
+`.github/workflows/diagnostic-artifact.yml` builds per-platform zips
+that bundle this directory with the prebuilt helper. The workflow runs on
+every push to main and on manual dispatch; artifacts are retained for 14
+days.
\ No newline at end of file
diff --git a/scripts/diagnostic-tool/diagnostic.bat b/scripts/diagnostic-tool/diagnostic.bat
new file mode 100644
index 0000000000..95c0f721dd
--- /dev/null
+++ b/scripts/diagnostic-tool/diagnostic.bat
@@ -0,0 +1,3 @@
+@echo off
+setlocal
+node "%~dp0diagnostic.mjs" %*
\ No newline at end of file
diff --git a/scripts/diagnostic-tool/diagnostic.mjs b/scripts/diagnostic-tool/diagnostic.mjs
new file mode 100644
index 0000000000..3b08d798cc
--- /dev/null
+++ b/scripts/diagnostic-tool/diagnostic.mjs
@@ -0,0 +1,311 @@
+#!/usr/bin/env node
+// OpenScreen standalone diagnostic tool.
+//
+// Runs the native capture helper outside the Electron app, captures its
+// stdout/stderr, and writes a JSON report you can attach to a bug report.
+// Used to capture [stop-timing] lines from the helper without requiring the
+// full app to install/reproduce.
+//
+// Usage:
+// node diagnostic.mjs # 10s recording, default output
+// node diagnostic.mjs --duration 30 # 30s recording
+// node diagnostic.mjs --output ./out.json # custom output path
+// node diagnostic.mjs --window # capture a window (default: display)
+//
+// Helper discovery:
+// 1. $OPENSCREEN_HELPER_EXE (any path)
+// 2. ./wgc-capture.exe (Windows)
+// ./openscreen-screencapturekit-helper (macOS)
+// 3. ./helpers/-/ (CI artifact layout)
+
+import { spawn } from "node:child_process";
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import process from "node:process";
+import { fileURLToPath } from "node:url";
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+
+const HELPER_CANDIDATES = {
+ win32: {
+ x64: { name: "wgc-capture.exe", kind: "windows" },
+ arm64: { name: "wgc-capture.exe", kind: "windows" },
+ },
+ darwin: {
+ x64: { name: "openscreen-screencapturekit-helper", kind: "mac" },
+ arm64: { name: "openscreen-screencapturekit-helper", kind: "mac" },
+ },
+};
+
+function parseArgs(argv) {
+ const opts = {
+ duration: 10_000,
+ output: null,
+ source: "display",
+ help: false,
+ };
+ const requireNumber = (raw, flag) => {
+ const n = Number(raw);
+ if (!Number.isFinite(n) || n <= 0) {
+ throw new Error(`${flag} requires a positive number, got: ${raw}`);
+ }
+ return n;
+ };
+ for (let i = 0; i < argv.length; i += 1) {
+ const arg = argv[i];
+ if (arg === "--duration" || arg === "-d") {
+ const value = argv[++i];
+ if (value === undefined) throw new Error(`${arg} requires a value`);
+ opts.duration = requireNumber(value, arg) * 1000;
+ } else if (arg === "--output" || arg === "-o") {
+ const value = argv[++i];
+ if (value === undefined) throw new Error(`${arg} requires a value`);
+ opts.output = value;
+ } else if (arg === "--source") {
+ const value = argv[++i];
+ if (value === undefined) throw new Error(`${arg} requires a value`);
+ opts.source = value;
+ } else if (arg === "--window") {
+ opts.source = "window";
+ } else if (arg === "--help" || arg === "-h") {
+ opts.help = true;
+ } else if (arg.startsWith("--")) {
+ throw new Error(`Unknown flag: ${arg}`);
+ }
+ }
+ return opts;
+}
+
+function printHelp() {
+ console.log(`OpenScreen standalone diagnostic tool
+
+Usage:
+ node diagnostic.mjs [flags]
+
+Flags:
+ -d, --duration Recording length before sending stop (default: 10)
+ -o, --output Output JSON path (default: ./openscreen-diagnostic-.json)
+ --source Capture source type (default: display)
+ --window Shortcut for --source window
+ -h, --help Show this help
+`);
+}
+
+function findHelper() {
+ const explicit = process.env.OPENSCREEN_HELPER_EXE?.trim();
+ if (explicit && fs.existsSync(explicit)) return { path: explicit, kind: null };
+
+ const platform = process.platform;
+ const arch = process.arch === "arm64" ? "arm64" : "x64";
+ const descriptor = HELPER_CANDIDATES[platform]?.[arch];
+ if (!descriptor) {
+ throw new Error(`Unsupported platform: ${platform}-${arch}`);
+ }
+
+ const inScriptDir = path.join(__dirname, descriptor.name);
+ if (fs.existsSync(inScriptDir)) return { path: inScriptDir, kind: descriptor.kind };
+
+ const inHelpersDir = path.join(__dirname, "helpers", `${platform}-${arch}`, descriptor.name);
+ if (fs.existsSync(inHelpersDir)) return { path: inHelpersDir, kind: descriptor.kind };
+
+ throw new Error(
+ `Native helper not found for ${platform}-${arch}. Looked for:\n` +
+ ` $OPENSCREEN_HELPER_EXE\n` +
+ ` ${inScriptDir}\n` +
+ ` ${inHelpersDir}\n` +
+ `Download the matching diagnostic bundle from the OpenScreen releases / CI artifacts.`,
+ );
+}
+
+function buildConfig(opts) {
+ const now = Date.now();
+ return {
+ schemaVersion: 2,
+ recordingId: now,
+ outputPath: path.join(os.tmpdir(), `openscreen-diag-${now}.mp4`),
+ sourceType: opts.source === "window" ? "window" : "display",
+ sourceId: opts.source === "window" ? "window:0:0" : "screen:0:0",
+ displayId: 0,
+ fps: 30,
+ videoWidth: 1280,
+ videoHeight: 720,
+ displayX: 0,
+ displayY: 0,
+ displayW: 1920,
+ displayH: 1080,
+ hasDisplayBounds: true,
+ captureSystemAudio: false,
+ captureMic: false,
+ captureCursor: false,
+ microphoneDeviceId: "default",
+ microphoneDeviceName: "",
+ microphoneGain: 1.0,
+ webcamEnabled: false,
+ outputs: { screenPath: "" },
+ };
+}
+
+function parseStopTiming(stderrText) {
+ const lines = [];
+ for (const line of stderrText.split(/\r?\n/)) {
+ const m = line.match(/\[stop-timing\]\s+step=(\S+)\s+elapsed_ms=(\d+)/);
+ if (m) lines.push({ step: m[1], elapsedMs: Number(m[2]) });
+ }
+ return lines;
+}
+
+function run(opts) {
+ const helper = findHelper();
+ console.log(`[diag] helper: ${helper.path}`);
+ console.log(`[diag] platform: ${process.platform}-${process.arch}`);
+ console.log(`[diag] duration: ${opts.duration}ms, source: ${opts.source}`);
+
+ const config = buildConfig(opts);
+ config.outputs.screenPath = config.outputPath;
+
+ const t0 = Date.now();
+ const proc = spawn(helper.path, [JSON.stringify(config)], {
+ stdio: ["pipe", "pipe", "pipe"],
+ windowsHide: true,
+ });
+
+ let stdout = "";
+ let stderr = "";
+ let stopSent = false;
+ let stopSentAt = 0;
+
+ proc.stdout.on("data", (chunk) => {
+ const text = chunk.toString();
+ stdout += text;
+ process.stdout.write(`[helper/stdout] ${text}`);
+ });
+ proc.stderr.on("data", (chunk) => {
+ const text = chunk.toString();
+ stderr += text;
+ process.stderr.write(`[helper/stderr] ${text}`);
+ });
+
+ const stopTimer = setTimeout(() => {
+ if (stopSent) return;
+ stopSent = true;
+ stopSentAt = Date.now();
+ proc.stdin.write("stop\n");
+ console.log(`[diag] sent stop after ${stopSentAt - t0}ms`);
+ }, opts.duration);
+
+ const fallbackTimer = setTimeout(() => {
+ if (!stopSent) {
+ stopSent = true;
+ stopSentAt = Date.now();
+ proc.stdin.write("stop\n");
+ console.log(`[diag] fallback stop fired after ${opts.duration}ms`);
+ }
+ }, opts.duration + 2_000);
+
+ const killTimer = setTimeout(() => {
+ console.error(`[diag] helper did not exit after stop, killing`);
+ proc.kill("SIGKILL");
+ }, opts.duration + 95_000);
+
+ return new Promise((resolve) => {
+ proc.once("exit", (code, signal) => {
+ clearTimeout(stopTimer);
+ clearTimeout(fallbackTimer);
+ clearTimeout(killTimer);
+ const tExit = Date.now();
+ resolve({
+ code,
+ signal,
+ t0,
+ stopSentAt,
+ tExit,
+ stdout,
+ stderr,
+ config,
+ });
+ });
+ proc.once("error", (error) => {
+ clearTimeout(stopTimer);
+ clearTimeout(fallbackTimer);
+ clearTimeout(killTimer);
+ resolve({
+ code: -1,
+ signal: null,
+ t0,
+ stopSentAt,
+ tExit: Date.now(),
+ stdout,
+ stderr: stderr + `\n[spawn-error] ${error.message}\n`,
+ config,
+ spawnError: error,
+ });
+ });
+ });
+}
+
+function buildReport(result) {
+ const stopTiming = parseStopTiming(result.stderr);
+ const stopElapsedMs = result.stopSentAt > 0 ? result.tExit - result.stopSentAt : null;
+ const helperPath = process.env.OPENSCREEN_HELPER_EXE?.trim() || "(auto-resolved)";
+
+ return {
+ timestamp: new Date(result.t0).toISOString(),
+ platform: process.platform,
+ arch: process.arch,
+ osRelease: os.release(),
+ osVersion: os.version(),
+ cpuModel: os.cpus()[0]?.model ?? null,
+ cpuCount: os.cpus().length,
+ totalMemoryMB: Math.round(os.totalmem() / 1024 / 1024),
+ nodeVersion: process.versions.node,
+ helperPath,
+ durationMs: result.stopSentAt > 0 ? result.stopSentAt - result.t0 : null,
+ stopElapsedMs,
+ exitCode: result.code,
+ exitSignal: result.signal,
+ spawnError: result.spawnError?.message ?? null,
+ config: result.config,
+ helperStdout: result.stdout,
+ helperStderr: result.stderr,
+ stopTiming,
+ };
+}
+
+async function main() {
+ let opts;
+ try {
+ opts = parseArgs(process.argv.slice(2));
+ } catch (error) {
+ console.error(`[diag] ${error.message}`);
+ process.exit(2);
+ }
+ if (opts.help) {
+ printHelp();
+ process.exit(0);
+ }
+
+ let result;
+ try {
+ result = await run(opts);
+ } catch (error) {
+ console.error(`[diag] ${error.message}`);
+ process.exit(1);
+ }
+
+ const report = buildReport(result);
+ const outputPath =
+ opts.output ?? path.join(process.cwd(), `openscreen-diagnostic-${Date.now()}.json`);
+ await fs.promises.writeFile(outputPath, JSON.stringify(report, null, 2), "utf-8");
+
+ console.log("");
+ console.log(`[diag] exit code: ${report.exitCode}`);
+ console.log(`[diag] stop elapsed: ${report.stopElapsedMs}ms`);
+ console.log(`[diag] stop timing steps:`);
+ for (const entry of report.stopTiming) {
+ console.log(`[diag] ${entry.step.padEnd(28)} ${entry.elapsedMs}ms`);
+ }
+ console.log(`[diag] report: ${outputPath}`);
+}
+
+main();
diff --git a/scripts/diagnostic-tool/diagnostic.sh b/scripts/diagnostic-tool/diagnostic.sh
new file mode 100644
index 0000000000..6dceac36b4
--- /dev/null
+++ b/scripts/diagnostic-tool/diagnostic.sh
@@ -0,0 +1,4 @@
+#!/usr/bin/env sh
+set -e
+DIR="$(cd "$(dirname "$0")" && pwd)"
+exec node "$DIR/diagnostic.mjs" "$@"
\ No newline at end of file
diff --git a/scripts/fetch-caption-model.mjs b/scripts/fetch-caption-model.mjs
new file mode 100644
index 0000000000..f1d0a1f50e
--- /dev/null
+++ b/scripts/fetch-caption-model.mjs
@@ -0,0 +1,154 @@
+// Populates `caption-assets/` so the packaged app can transcribe offline (under file://)
+// instead of fetching the Whisper model from HuggingFace and the onnxruntime wasm from a CDN.
+//
+// caption-assets/
+// models/Xenova/whisper-tiny/... ← downloaded from HuggingFace (config + quantized ONNX)
+// ort/ort-wasm*.wasm ← copied from @xenova/transformers/dist
+//
+// Idempotent: existing non-empty files are left alone, so re-runs and CI cache hits are no-ops.
+// `caption-assets/` is gitignored and shipped via electron-builder `extraResources`.
+
+import { createWriteStream } from "node:fs";
+import { copyFile, mkdir, stat } from "node:fs/promises";
+import path from "node:path";
+import { Readable } from "node:stream";
+import { pipeline } from "node:stream/promises";
+import { fileURLToPath } from "node:url";
+
+const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
+const OUT = path.join(ROOT, "caption-assets");
+const MODEL_ID = "Xenova/whisper-tiny";
+const HF_BASE = `https://huggingface.co/${MODEL_ID}/resolve/main`;
+
+// Small config/tokenizer/preprocessor files plus the quantized ONNX the ASR pipeline loads by
+// default (encoder + merged decoder). Grab every metadata file so transformers never requests
+// one we forgot to bundle.
+const MODEL_FILES = [
+ "config.json",
+ "generation_config.json",
+ "preprocessor_config.json",
+ "tokenizer.json",
+ "tokenizer_config.json",
+ "added_tokens.json",
+ "special_tokens_map.json",
+ "normalizer.json",
+ "merges.txt",
+ "vocab.json",
+ "quantize_config.json",
+ "onnx/encoder_model_quantized.onnx",
+ "onnx/decoder_model_merged_quantized.onnx",
+];
+
+async function exists(filePath) {
+ try {
+ const s = await stat(filePath);
+ return s.isFile() && s.size > 0;
+ } catch {
+ return false;
+ }
+}
+
+const MAX_ATTEMPTS = 6;
+// HuggingFace rate-limits (429) when the parallel CI matrix builds all hit it at once; also retry the
+// usual transient server errors.
+const RETRYABLE_STATUS = new Set([408, 425, 429, 500, 502, 503, 504]);
+
+function sleep(ms) {
+ return new Promise((resolve) => setTimeout(resolve, ms));
+}
+
+function backoffMs(attempt, retryAfter) {
+ // Honor Retry-After when the server sends it (seconds or an HTTP date).
+ if (retryAfter) {
+ const secs = Number(retryAfter);
+ if (Number.isFinite(secs)) return Math.min(60_000, secs * 1000);
+ const at = Date.parse(retryAfter);
+ if (!Number.isNaN(at)) return Math.min(60_000, Math.max(0, at - Date.now()));
+ }
+ // Exponential backoff with jitter: ~2s, 4s, 8s, 16s, 32s, capped at 60s.
+ return Math.min(60_000, 2000 * 2 ** (attempt - 1)) + Math.floor(Math.random() * 1000);
+}
+
+async function fetchWithRetry(url) {
+ let lastErr;
+ for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
+ try {
+ const res = await fetch(url, { headers: { "user-agent": "openscreen-build" } });
+ if (res.ok && res.body) return res;
+ if (RETRYABLE_STATUS.has(res.status) && attempt < MAX_ATTEMPTS) {
+ const wait = backoffMs(attempt, res.headers.get("retry-after"));
+ console.log(
+ ` … HTTP ${res.status}, retry ${attempt}/${MAX_ATTEMPTS - 1} in ${Math.round(wait / 1000)}s`,
+ );
+ await sleep(wait);
+ continue;
+ }
+ throw new Error(`Failed to download ${url}: HTTP ${res.status} ${res.statusText}`);
+ } catch (err) {
+ lastErr = err;
+ const isHttp = err instanceof Error && err.message.startsWith("Failed to download");
+ if (isHttp || attempt >= MAX_ATTEMPTS) throw err;
+ // Network/DNS error: back off and retry.
+ const wait = backoffMs(attempt, null);
+ console.log(
+ ` … ${err.message}, retry ${attempt}/${MAX_ATTEMPTS - 1} in ${Math.round(wait / 1000)}s`,
+ );
+ await sleep(wait);
+ }
+ }
+ throw lastErr;
+}
+
+async function download(url, dest) {
+ if (await exists(dest)) {
+ console.log(` ✓ cached ${path.relative(OUT, dest)}`);
+ return;
+ }
+ await mkdir(path.dirname(dest), { recursive: true });
+ const res = await fetchWithRetry(url);
+ const tmp = `${dest}.partial`;
+ await pipeline(Readable.fromWeb(res.body), createWriteStream(tmp));
+ const { rename } = await import("node:fs/promises");
+ await rename(tmp, dest);
+ const mb = ((await stat(dest)).size / 1_000_000).toFixed(1);
+ console.log(` ↓ ${path.relative(OUT, dest)} (${mb} MB)`);
+}
+
+async function copyOrtWasm() {
+ const distDir = path.join(ROOT, "node_modules", "@xenova", "transformers", "dist");
+ // Non-threaded variants only: the worker runs ORT with numThreads=1 (no SharedArrayBuffer
+ // under file://), so the threaded wasm is never loaded. Saves ~20MB.
+ const wasm = ["ort-wasm.wasm", "ort-wasm-simd.wasm"];
+ const ortOut = path.join(OUT, "ort");
+ await mkdir(ortOut, { recursive: true });
+ for (const name of wasm) {
+ const src = path.join(distDir, name);
+ const dest = path.join(ortOut, name);
+ if (!(await exists(src))) {
+ throw new Error(`Missing ${src} — is @xenova/transformers installed? Run npm ci first.`);
+ }
+ if (await exists(dest)) {
+ console.log(` ✓ cached ort/${name}`);
+ continue;
+ }
+ await copyFile(src, dest);
+ console.log(` + copied ort/${name}`);
+ }
+}
+
+async function main() {
+ console.log(`Fetching caption assets → ${path.relative(ROOT, OUT)}/`);
+ console.log("ONNX Runtime wasm:");
+ await copyOrtWasm();
+ console.log(`Whisper model (${MODEL_ID}):`);
+ const modelDir = path.join(OUT, "models", ...MODEL_ID.split("/"));
+ for (const rel of MODEL_FILES) {
+ await download(`${HF_BASE}/${rel}`, path.join(modelDir, rel));
+ }
+ console.log("Caption assets ready.");
+}
+
+main().catch((err) => {
+ console.error(`\nfetch-caption-model failed: ${err.message}`);
+ process.exit(1);
+});
diff --git a/src/App.tsx b/src/App.tsx
index 6c36aa8c5b..35749408c3 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,6 +1,7 @@
import { lazy, Suspense, useEffect, useState } from "react";
import { CountdownOverlay } from "./components/launch/CountdownOverlay.tsx";
import { LaunchWindow } from "./components/launch/LaunchWindow";
+import { NotesWindow } from "./components/launch/NotesWindow.tsx";
import { SourceSelector } from "./components/launch/SourceSelector";
import { Toaster } from "./components/ui/sonner";
import { TooltipProvider } from "./components/ui/tooltip";
@@ -19,6 +20,8 @@ export default function App() {
const [windowType, setWindowType] = useState(
() => new URLSearchParams(window.location.search).get("windowType") || "",
);
+ const showNotes = new URLSearchParams(window.location.search).get("showNotes") === "true";
+
const tEditor = useScopedT("editor");
useEffect(() => {
@@ -102,8 +105,10 @@ export default function App() {
);
default:
return (
-
)}
- {/* HUD bar — fixed at bottom center, viewport-relative, never moves */}
+ {/* HUD bar, fixed at bottom center, viewport-relative, never moves */}
@@ -660,8 +918,8 @@ export function LaunchWindow() {
handleSourceSelect(source)}
>
diff --git a/src/components/ui/color-picker.tsx b/src/components/ui/color-picker.tsx
index d8ec2b33c8..72f0135e25 100644
--- a/src/components/ui/color-picker.tsx
+++ b/src/components/ui/color-picker.tsx
@@ -46,8 +46,7 @@ export default function ColorPicker(props: ColorPickerProps) {
return "#ffffff";
};
- // Normalize the hex input.
- // Adds a # at the beginning of the input if it's not there.
+ // Prefix a # when the user typed a bare hex value.
const normalizeHexDraft = (raw: string) => {
const trimmed = raw.trim();
if (trimmed === "") return "";
@@ -58,8 +57,7 @@ export default function ColorPicker(props: ColorPickerProps) {
const handleColorInputChange = (e: React.ChangeEvent) => {
const normalized = normalizeHexDraft(e.target.value);
setHexInput(normalized);
- // Check if the normalized hex is a valid hex color.
- // It should follow the format #RRGGBB or #RGB.
+ // Only push when it's a complete #RGB or #RRGGBB value.
const isValidHexColor =
/^#[0-9A-Fa-f]{3}$/.test(normalized) || /^#[0-9A-Fa-f]{6}$/.test(normalized);
if (isValidHexColor) {
diff --git a/src/components/ui/select.tsx b/src/components/ui/select.tsx
index d151d164ea..bdbf64e9ae 100644
--- a/src/components/ui/select.tsx
+++ b/src/components/ui/select.tsx
@@ -82,7 +82,8 @@ const SelectContent = React.forwardRef<
;
-const Toaster = ({ ...props }: ToasterProps) => {
+const Toaster = ({ className, ...props }: ToasterProps) => {
return (
diff --git a/src/components/video-editor/AddCustomFontDialog.tsx b/src/components/video-editor/AddCustomFontDialog.tsx
index 9ab9ce3dad..872559d28a 100644
--- a/src/components/video-editor/AddCustomFontDialog.tsx
+++ b/src/components/video-editor/AddCustomFontDialog.tsx
@@ -36,7 +36,6 @@ export function AddCustomFontDialog({ onFontAdded }: AddCustomFontDialogProps) {
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) {
@@ -46,7 +45,6 @@ export function AddCustomFontDialog({ onFontAdded }: AddCustomFontDialogProps) {
};
const handleAdd = async () => {
- // Validate inputs
if (!importUrl.trim()) {
toast.error(t("customFont.errorEmptyUrl"));
return;
@@ -65,7 +63,6 @@ export function AddCustomFontDialog({ onFontAdded }: AddCustomFontDialogProps) {
setLoading(true);
try {
- // Extract font family from URL
const fontFamily = parseFontFamilyFromImport(importUrl);
if (!fontFamily) {
toast.error(t("customFont.errorExtractFailed"));
@@ -73,7 +70,6 @@ export function AddCustomFontDialog({ onFontAdded }: AddCustomFontDialogProps) {
return;
}
- // Create custom font object
const newFont: CustomFont = {
id: generateFontId(fontName),
name: fontName.trim(),
@@ -81,17 +77,15 @@ export function AddCustomFontDialog({ onFontAdded }: AddCustomFontDialogProps) {
importUrl: importUrl.trim(),
};
- // Add font (this will load and verify it) - throws if it fails
+ // Loads and verifies the font; throws on failure
await addCustomFont(newFont);
- // Notify parent
if (onFontAdded) {
onFontAdded(newFont);
}
toast.success(t("customFont.successMessage", { fontName }));
- // Reset and close
setImportUrl("");
setFontName("");
setOpen(false);
diff --git a/src/components/video-editor/AnnotationOverlay.tsx b/src/components/video-editor/AnnotationOverlay.tsx
index 13d245b8a8..09669d4e71 100644
--- a/src/components/video-editor/AnnotationOverlay.tsx
+++ b/src/components/video-editor/AnnotationOverlay.tsx
@@ -48,7 +48,7 @@ interface AnnotationOverlayProps {
onBlurDataCommit?: () => void;
onClick: (id: string) => void;
zIndex: number;
- isSelectedBoost: boolean; // Boost z-index when selected for easy editing
+ isSelectedBoost: boolean; // raise z-index when selected, for easier editing
previewSourceCanvas?: PreviewCanvasSource | null;
previewFrameVersion?: number;
currentTimeMs: number;
@@ -537,7 +537,7 @@ export function AnnotationOverlay({
const yPercent = (d.y / containerHeight) * 100;
onPositionChange(annotation.id, { x: xPercent, y: yPercent });
- // Reset dragging flag after a short delay to prevent click event
+ // Delay clearing so the trailing click doesn't fire onClick
setTimeout(() => {
isDraggingRef.current = false;
}, 100);
@@ -576,7 +576,7 @@ export function AnnotationOverlay({
"ring-2 ring-[#34B27B] ring-offset-2 ring-offset-transparent",
)}
style={{
- zIndex: isSelectedBoost ? zIndex + 1000 : zIndex, // Boost selected annotation to ensure it's on top
+ zIndex: isSelectedBoost ? zIndex + 1000 : zIndex, // keep the selected annotation on top
pointerEvents: isSelected ? "auto" : "none",
border:
isSelected && annotation.type !== "blur" ? "2px solid rgba(52, 178, 123, 0.8)" : "none",
diff --git a/src/components/video-editor/AnnotationSettingsPanel.tsx b/src/components/video-editor/AnnotationSettingsPanel.tsx
index 4fe3f505e4..91fee8ec84 100644
--- a/src/components/video-editor/AnnotationSettingsPanel.tsx
+++ b/src/components/video-editor/AnnotationSettingsPanel.tsx
@@ -108,7 +108,6 @@ export function AnnotationSettingsPanel({
const getFontLabel = (font: (typeof FONT_FAMILIES)[number]) =>
font.labelKey ? fontStyleLabels[font.labelKey] : font.name;
- // Load custom fonts on mount
useEffect(() => {
setCustomFonts(getCustomFonts());
}, []);
@@ -138,7 +137,6 @@ export function AnnotationSettingsPanel({
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("annotation.invalidImageType"), {
@@ -159,8 +157,8 @@ export function AnnotationSettingsPanel({
};
reader.onerror = () => {
- toast.error(t("annotation.failedImageUpload"), {
- description: "There was an error reading the file.",
+ toast.error(t("imageUpload.failedToUpload"), {
+ description: t("imageUpload.errorReading"),
});
};
diff --git a/src/components/video-editor/ArrowSvgs.tsx b/src/components/video-editor/ArrowSvgs.tsx
index 99c542ff77..5078b844ec 100644
--- a/src/components/video-editor/ArrowSvgs.tsx
+++ b/src/components/video-editor/ArrowSvgs.tsx
@@ -7,9 +7,7 @@ interface ArrowSvgProps {
}
/**
- * 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.
+ * Inline SVG arrows for 8 directions. Pure paths (not icon fonts) so export can replicate them.
*/
export function ArrowUp({ color, strokeWidth, className }: ArrowSvgProps) {
diff --git a/src/components/video-editor/EditorEmptyState.tsx b/src/components/video-editor/EditorEmptyState.tsx
index 511323abe2..2a5c495478 100644
--- a/src/components/video-editor/EditorEmptyState.tsx
+++ b/src/components/video-editor/EditorEmptyState.tsx
@@ -2,11 +2,12 @@ import { AlertCircle, Film, FolderOpen, Upload, X } from "lucide-react";
import { useCallback, useRef, useState } from "react";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { useScopedT } from "@/contexts/I18nContext";
+import { getProjectFolder, parentDirectoryOf, saveUserPreferences } from "@/lib/userPreferences";
import { nativeBridgeClient } from "@/native";
interface EditorEmptyStateProps {
onVideoImported: (videoPath: string) => void;
- /** Called with the loaded project data — handles both button click and drag-drop */
+ /** Called with the loaded project data; handles both button click and drag-drop */
onProjectOpened: (project: unknown, path: string | null) => void;
}
@@ -17,8 +18,8 @@ export function EditorEmptyState({ onVideoImported, onProjectOpened }: EditorEmp
const tc = useScopedT("common");
const [isDraggingOver, setIsDraggingOver] = useState(false);
const [dropError, setDropError] = useState(null);
- // Freeze the last non-null error type so dialog content doesn't snap to the
- // else-branch during the closing animation (same pattern as UnsavedChangesDialog).
+ // Freeze the last non-null error type so dialog content doesn't snap to the else-branch
+ // during the closing animation (same pattern as UnsavedChangesDialog).
const lastDropErrorRef = useRef>("unsupported-format");
if (dropError !== null) {
lastDropErrorRef.current = dropError;
@@ -35,8 +36,14 @@ export function EditorEmptyState({ onVideoImported, onProjectOpened }: EditorEmp
}, [onVideoImported]);
const handleLoadProject = useCallback(async () => {
- const result = await nativeBridgeClient.project.loadProjectFile();
+ const result = await nativeBridgeClient.project.loadProjectFile(getProjectFolder());
if (result.canceled || !result.success || !result.project) return;
+ if (result.path) {
+ const folder = parentDirectoryOf(result.path);
+ if (folder) {
+ saveUserPreferences({ projectFolder: folder });
+ }
+ }
onProjectOpened(result.project, result.path ?? null);
}, [onProjectOpened]);
@@ -67,7 +74,7 @@ export function EditorEmptyState({ onVideoImported, onProjectOpened }: EditorEmp
return;
}
- // Use Electron's webUtils.getPathForFile — File.path was removed in Electron 32+
+ // Use Electron's webUtils.getPathForFile; File.path was removed in Electron 32+
let filePath: string;
try {
filePath = window.electronAPI.getPathForFile(projectFile);
diff --git a/src/components/video-editor/ExportDialog.tsx b/src/components/video-editor/ExportDialog.tsx
index 2032289099..b012617c1a 100644
--- a/src/components/video-editor/ExportDialog.tsx
+++ b/src/components/video-editor/ExportDialog.tsx
@@ -30,14 +30,13 @@ export function ExportDialog({
const t = useScopedT("dialogs");
const [showSuccess, setShowSuccess] = useState(false);
- // Reset showSuccess when a new export starts or dialog reopens
useEffect(() => {
if (isExporting) {
setShowSuccess(false);
}
}, [isExporting]);
- // Reset showSuccess when dialog opens fresh
+ // Reset when the dialog opens fresh (not mid-export).
useEffect(() => {
if (isOpen && !isExporting && !progress) {
setShowSuccess(false);
@@ -59,13 +58,14 @@ export function ExportDialog({
const formatLabel = exportFormat === "gif" ? "GIF" : "Video";
- // Determine if we're in the compiling phase (frames done but still exporting)
+ // Compiling phase: frames are done but the export is still finishing.
const isCompiling =
isExporting && progress && progress.percentage >= 100 && exportFormat === "gif";
const isFinalizing = progress?.phase === "finalizing";
+ // Streaming a large recording into OPFS before frames start rendering.
+ const isPreparing = progress?.phase === "preparing";
const renderProgress = progress?.renderProgress;
- // Get status message based on phase
const getStatusMessage = () => {
if (error) return t("export.tryAgain");
if (isCompiling || isFinalizing) {
@@ -80,7 +80,6 @@ export function ExportDialog({
return t("export.takeMoment");
};
- // Get title based on phase
const getTitle = () => {
if (error) return t("export.failed");
if (isFinalizing && exportFormat === "mp4") return t("export.finalizingVideoTitle");
@@ -175,7 +174,9 @@ export function ExportDialog({
{isCompiling || isFinalizing
? t("export.compiling")
- : t("export.renderingFrames")}
+ : isPreparing
+ ? t("export.processing")
+ : t("export.renderingFrames")}
{isCompiling || isFinalizing ? (
@@ -194,7 +195,7 @@ export function ExportDialog({
{isCompiling || isFinalizing ? (
- // Show render progress if available, otherwise animated indeterminate bar
+ // Real progress if we have it, otherwise an indeterminate bar.
renderProgress !== undefined && renderProgress > 0 ? (
);
})()}
- {/* Only render overlay after PIXI and video are fully initialized */}
+ {/* Render the overlay only once PIXI and video are ready. */}
{pixiReady && videoReady && (
(
})()
: null;
- // Handle click-through cycling: when clicking same annotation, cycle to next
+ // Re-clicking a selected annotation cycles through any overlapping ones.
const handleAnnotationClick = (clickedId: string) => {
if (!onSelectAnnotation) return;
- // If clicking on already selected annotation and there are multiple overlapping
if (clickedId === selectedAnnotationId && filteredAnnotations.length > 1) {
- // Find current index and cycle to next
const currentIndex = filteredAnnotations.findIndex((a) => a.id === clickedId);
const nextIndex = (currentIndex + 1) % filteredAnnotations.length;
onSelectAnnotation(filteredAnnotations[nextIndex].id);
} else {
- // First click or clicking different annotation
onSelectAnnotation(clickedId);
}
};
@@ -2062,10 +2108,8 @@ const VideoPlayback = forwardRef(
)}
- {/* Clip the native cursor overlay to the exact video canvas boundary.
- Placed OUTSIDE composite3DRef (preserve-3d) so clip-path works
- correctly even during 3D zoom rotation regions.
- clip-path is set dynamically to the camera-aware video bounds. */}
+ {/* Native cursor clip. Lives outside composite3DRef (preserve-3d) so clip-path
+ keeps working during 3D zoom rotations; bounds are set dynamically. */}
{
aspectRatio: "16:9",
webcamLayoutPreset: "picture-in-picture",
webcamMaskShape: "circle",
+ webcamMirrored: true,
+ webcamSizePreset: 25,
webcamPosition: null,
exportQuality: "good",
exportFormat: "mp4",
@@ -68,6 +70,12 @@ describe("projectPersistence media compatibility", () => {
).toBe("rectangle");
});
+ it("normalizes webcam mirroring safely", () => {
+ expect(normalizeProjectEditor({ webcamMirrored: true }).webcamMirrored).toBe(true);
+ expect(normalizeProjectEditor({ webcamMirrored: false }).webcamMirrored).toBe(false);
+ expect(normalizeProjectEditor({ webcamMirrored: "yes" as never }).webcamMirrored).toBe(false);
+ });
+
it("normalizes blur region type and mosaic block size safely", () => {
const editor = normalizeProjectEditor({
annotationRegions: [
diff --git a/src/components/video-editor/projectPersistence.ts b/src/components/video-editor/projectPersistence.ts
index ff59427f2d..258efdf6a1 100644
--- a/src/components/video-editor/projectPersistence.ts
+++ b/src/components/video-editor/projectPersistence.ts
@@ -1,5 +1,6 @@
import { normalizeTextAnimation } from "@/lib/annotationTextAnimation";
import { normalizeBlurColor, normalizeBlurType } from "@/lib/blurEffects";
+import { normalizeCursorThemeId } from "@/lib/cursor/cursorThemes";
import type { ExportFormat, ExportQuality, GifFrameRate, GifSizePreset } from "@/lib/exporter";
import type { ProjectMedia } from "@/lib/recordingSession";
import { normalizeProjectMedia } from "@/lib/recordingSession";
@@ -25,6 +26,8 @@ import {
DEFAULT_BLUR_INTENSITY,
DEFAULT_FIGURE_DATA,
DEFAULT_PLAYBACK_SPEED,
+ DEFAULT_WEBCAM_MIRRORED,
+ DEFAULT_WEBCAM_REACTIVE_ZOOM,
DEFAULT_ZOOM_DEPTH,
DEFAULT_ZOOM_MOTION_BLUR,
MAX_BLUR_BLOCK_SIZE,
@@ -44,11 +47,10 @@ import {
const VALID_BLUR_SHAPES = new Set(["rectangle", "oval", "freehand"] as const);
-// Pre-fix projects could persist resolved file:// URLs (machine-specific) for
-// bundled wallpapers. Rewrite only paths that match a known install layout
-// (resources/[assets/]wallpapers for packaged, public/wallpapers for dev) so
-// a legitimate user file that happens to live in a folder named "wallpapers"
-// elsewhere is never silently replaced.
+// Old projects persisted machine-specific file:// URLs for bundled wallpapers.
+// Match only the known install layouts (packaged resources/[assets/]wallpapers,
+// dev public/wallpapers) so a user's own file under some "wallpapers" folder isn't
+// silently replaced.
const LEGACY_FILE_WALLPAPER_RE =
/^file:\/\/.*?\/(?:resources\/(?:assets\/)?|public\/)wallpapers\/(wallpaper\d+\.jpg)$/i;
const CANONICAL_WALLPAPERS = new Set(WALLPAPER_PATHS);
@@ -72,12 +74,16 @@ export interface ProjectEditorState {
padding: number;
cropRegion: CropRegion;
zoomRegions: ZoomRegion[];
+ autoZoomEnabled: boolean;
+ autoFocusAll: boolean;
trimRegions: TrimRegion[];
speedRegions: SpeedRegion[];
annotationRegions: AnnotationRegion[];
aspectRatio: AspectRatio;
webcamLayoutPreset: WebcamLayoutPreset;
webcamMaskShape: WebcamMaskShape;
+ webcamMirrored: boolean;
+ webcamReactiveZoom: boolean;
webcamSizePreset: WebcamSizePreset;
webcamPosition: WebcamPosition | null;
exportQuality: ExportQuality;
@@ -85,6 +91,7 @@ export interface ProjectEditorState {
gifFrameRate: GifFrameRate;
gifLoop: boolean;
gifSizePreset: GifSizePreset;
+ cursorTheme: string;
}
export interface EditorProjectData {
@@ -258,6 +265,7 @@ export function normalizeProjectEditor(editor: Partial): Pro
cy: clamp(isFiniteNumber(region.focus?.cy) ? region.focus.cy : 0.5, 0, 1),
},
focusMode: region.focusMode === "auto" ? "auto" : "manual",
+ source: region.source === "auto" ? "auto" : "manual",
...(validPreset ? { rotationPreset: validPreset } : {}),
};
})
@@ -333,6 +341,8 @@ export function normalizeProjectEditor(editor: Partial): Pro
content: typeof region.content === "string" ? region.content : "",
textContent: typeof region.textContent === "string" ? region.textContent : undefined,
imageContent: typeof region.imageContent === "string" ? region.imageContent : undefined,
+ annotationSource:
+ region.annotationSource === "auto-caption" ? ("auto-caption" as const) : undefined,
position: {
x: clamp(
isFiniteNumber(region.position?.x)
@@ -436,6 +446,7 @@ export function normalizeProjectEditor(editor: Partial): Pro
const cropHeight = clamp(rawCropHeight, 0.01, 1 - cropY);
return {
+ cursorTheme: normalizeCursorThemeId(editor.cursorTheme),
wallpaper:
typeof editor.wallpaper === "string"
? normalizeWallpaperValue(editor.wallpaper)
@@ -473,6 +484,10 @@ export function normalizeProjectEditor(editor: Partial): Pro
height: cropHeight,
},
zoomRegions: normalizedZoomRegions,
+ // Default on for legacy projects so re-opens match the new default. The
+ // on-load auto-suggest pass is gated separately, so this won't add zooms.
+ autoZoomEnabled: typeof editor.autoZoomEnabled === "boolean" ? editor.autoZoomEnabled : true,
+ autoFocusAll: typeof editor.autoFocusAll === "boolean" ? editor.autoFocusAll : false,
trimRegions: normalizedTrimRegions,
speedRegions: normalizedSpeedRegions,
annotationRegions: normalizedAnnotationRegions,
@@ -485,6 +500,12 @@ export function normalizeProjectEditor(editor: Partial): Pro
editor.webcamMaskShape === "rounded"
? editor.webcamMaskShape
: DEFAULT_WEBCAM_SETTINGS.maskShape,
+ webcamMirrored:
+ typeof editor.webcamMirrored === "boolean" ? editor.webcamMirrored : DEFAULT_WEBCAM_MIRRORED,
+ webcamReactiveZoom:
+ typeof editor.webcamReactiveZoom === "boolean"
+ ? editor.webcamReactiveZoom
+ : DEFAULT_WEBCAM_REACTIVE_ZOOM,
webcamSizePreset:
typeof editor.webcamSizePreset === "number" && isFiniteNumber(editor.webcamSizePreset)
? Math.max(10, Math.min(50, editor.webcamSizePreset))
diff --git a/src/components/video-editor/regionClipboard.test.ts b/src/components/video-editor/regionClipboard.test.ts
new file mode 100644
index 0000000000..f99f17eb27
--- /dev/null
+++ b/src/components/video-editor/regionClipboard.test.ts
@@ -0,0 +1,203 @@
+import { describe, expect, it } from "vitest";
+import {
+ buildPastedAnnotation,
+ buildSpeedRegion,
+ buildZoomRegion,
+ extractAnnotationAttributes,
+ extractSpeedAttributes,
+ extractZoomAttributes,
+ replaceAnnotationAttributes,
+} from "./regionClipboard";
+import {
+ type AnnotationRegion,
+ DEFAULT_ANNOTATION_POSITION,
+ DEFAULT_ANNOTATION_SIZE,
+ DEFAULT_ANNOTATION_STYLE,
+ DEFAULT_FIGURE_DATA,
+ type SpeedRegion,
+ type ZoomRegion,
+} from "./types";
+
+const zoom: ZoomRegion = {
+ id: "zoom-1",
+ startMs: 1000,
+ endMs: 3000,
+ depth: 4,
+ customScale: 2.75,
+ focus: { cx: 0.2, cy: 0.8 },
+ focusMode: "manual",
+ rotationPreset: "iso",
+ source: "manual",
+};
+
+const speed: SpeedRegion = { id: "speed-1", startMs: 0, endMs: 500, speed: 2 };
+
+const annotation: AnnotationRegion = {
+ id: "annotation-1",
+ startMs: 0,
+ endMs: 2000,
+ type: "figure",
+ content: "hello",
+ position: { x: 10, y: 90 },
+ size: { width: 40, height: 25 },
+ style: { ...DEFAULT_ANNOTATION_STYLE, color: "#ff0000", textAnimation: "pop" },
+ zIndex: 3,
+ figureData: { ...DEFAULT_FIGURE_DATA, color: "#123456" },
+};
+
+describe("zoom attribute copy/paste", () => {
+ it("round-trips the copyable attributes onto a different clip while keeping its identity/timing", () => {
+ const attrs = extractZoomAttributes(zoom);
+ const target: ZoomRegion = {
+ id: "zoom-2",
+ startMs: 9000,
+ endMs: 9500,
+ depth: 1,
+ focus: { cx: 0.5, cy: 0.5 },
+ source: "manual",
+ };
+ const result = buildZoomRegion(target, attrs);
+
+ expect(result.id).toBe("zoom-2");
+ expect(result.startMs).toBe(9000);
+ expect(result.endMs).toBe(9500);
+ expect(result.depth).toBe(4);
+ expect(result.customScale).toBe(2.75);
+ expect(result.focus).toEqual({ cx: 0.2, cy: 0.8 });
+ expect(result.focusMode).toBe("manual");
+ expect(result.rotationPreset).toBe("iso");
+ });
+
+ it("deep-copies focus so the source and target are decoupled", () => {
+ const attrs = extractZoomAttributes(zoom);
+ const result = buildZoomRegion({ ...zoom, id: "zoom-2" }, attrs);
+ result.focus.cx = 0.99;
+ expect(zoom.focus.cx).toBe(0.2);
+ });
+});
+
+describe("speed attribute copy/paste", () => {
+ it("copies only the speed value", () => {
+ const attrs = extractSpeedAttributes(speed);
+ const target: SpeedRegion = { id: "speed-2", startMs: 4000, endMs: 5000, speed: 1 };
+ const result = buildSpeedRegion(target, attrs);
+ expect(result).toEqual({ id: "speed-2", startMs: 4000, endMs: 5000, speed: 2 });
+ });
+});
+
+describe("annotation copy captures everything", () => {
+ it("captures styling plus content, type, and position", () => {
+ const attrs = extractAnnotationAttributes(annotation);
+ expect(attrs.type).toBe("figure");
+ expect(attrs.content).toBe("hello");
+ expect(attrs.position).toEqual({ x: 10, y: 90 });
+ expect(attrs.style.color).toBe("#ff0000");
+ expect(attrs.figureData?.color).toBe("#123456");
+ });
+});
+
+describe("paste onto an existing annotation applies styling only", () => {
+ it("overwrites the look/feel but keeps the target's content, position, timing, and zIndex", () => {
+ const attrs = extractAnnotationAttributes(annotation);
+ const target: AnnotationRegion = {
+ id: "annotation-2",
+ startMs: 7000,
+ endMs: 8000,
+ type: "text",
+ content: "world",
+ position: { ...DEFAULT_ANNOTATION_POSITION },
+ size: { ...DEFAULT_ANNOTATION_SIZE },
+ style: { ...DEFAULT_ANNOTATION_STYLE },
+ zIndex: 9,
+ };
+ const result = replaceAnnotationAttributes(target, attrs);
+
+ expect(result.content).toBe("world");
+ expect(result.position).toEqual(DEFAULT_ANNOTATION_POSITION);
+ expect(result.startMs).toBe(7000);
+ expect(result.zIndex).toBe(9);
+ expect(result.style.color).toBe("#ff0000");
+ expect(result.style.textAnimation).toBe("pop");
+ expect(result.size).toEqual({ width: 40, height: 25 });
+ // Target is text, so the copied figure's figureData must NOT attach (F5 guard).
+ expect(result.figureData).toBeUndefined();
+ });
+
+ it("keeps the target's own figure data when the copied region has none", () => {
+ const textAttrs = extractAnnotationAttributes({ ...annotation, figureData: undefined });
+ const figureTarget: AnnotationRegion = { ...annotation, id: "annotation-3" };
+ const result = replaceAnnotationAttributes(figureTarget, textAttrs);
+ expect(result.figureData?.color).toBe("#123456");
+ });
+});
+
+describe("paste as a new annotation clones the full copy", () => {
+ it("clones type, content, styling, and position; takes timing/identity from the base", () => {
+ const attrs = extractAnnotationAttributes(annotation);
+ const result = buildPastedAnnotation(
+ { id: "annotation-4", startMs: 12000, endMs: 14000, zIndex: 5 },
+ attrs,
+ );
+
+ expect(result.id).toBe("annotation-4");
+ expect(result.startMs).toBe(12000);
+ expect(result.endMs).toBe(14000);
+ expect(result.zIndex).toBe(5);
+ expect(result.type).toBe("figure");
+ expect(result.content).toBe("hello");
+ expect(result.position).toEqual({ x: 10, y: 90 });
+ expect(result.style.color).toBe("#ff0000");
+ expect(result.figureData?.color).toBe("#123456");
+ });
+
+ it("deep-copies position so source and clone are decoupled", () => {
+ const attrs = extractAnnotationAttributes(annotation);
+ const result = buildPastedAnnotation(
+ { id: "annotation-5", startMs: 0, endMs: 1000, zIndex: 1 },
+ attrs,
+ );
+ result.position.x = 99;
+ expect(annotation.position.x).toBe(10);
+ });
+});
+
+describe("zoom paste replaces customScale destructively (F4)", () => {
+ it("clears the target's customScale when the copied zoom is preset-only", () => {
+ const presetOnly = extractZoomAttributes({ ...zoom, customScale: undefined });
+ const target: ZoomRegion = { ...zoom, id: "zoom-3", customScale: 1.5 };
+ const result = buildZoomRegion(target, presetOnly);
+ expect(result.customScale).toBeUndefined();
+ });
+});
+
+describe("figureData guard on paste-onto-existing (F5)", () => {
+ it("does not attach figureData onto a non-figure target", () => {
+ const figureAttrs = extractAnnotationAttributes(annotation);
+ const textTarget: AnnotationRegion = {
+ id: "annotation-6",
+ startMs: 0,
+ endMs: 1000,
+ type: "text",
+ content: "hi",
+ position: { ...DEFAULT_ANNOTATION_POSITION },
+ size: { ...DEFAULT_ANNOTATION_SIZE },
+ style: { ...DEFAULT_ANNOTATION_STYLE },
+ zIndex: 1,
+ };
+ const result = replaceAnnotationAttributes(textTarget, figureAttrs);
+ expect(result.figureData).toBeUndefined();
+ // Styling still applies regardless of type.
+ expect(result.style.color).toBe("#ff0000");
+ });
+
+ it("applies figureData when the target is itself a figure", () => {
+ const figureAttrs = extractAnnotationAttributes(annotation);
+ const figureTarget: AnnotationRegion = {
+ ...annotation,
+ id: "annotation-7",
+ figureData: { ...DEFAULT_FIGURE_DATA, color: "#000000" },
+ };
+ const result = replaceAnnotationAttributes(figureTarget, figureAttrs);
+ expect(result.figureData?.color).toBe("#123456");
+ });
+});
diff --git a/src/components/video-editor/regionClipboard.ts b/src/components/video-editor/regionClipboard.ts
new file mode 100644
index 0000000000..876da43875
--- /dev/null
+++ b/src/components/video-editor/regionClipboard.ts
@@ -0,0 +1,158 @@
+import type {
+ AnnotationPosition,
+ AnnotationRegion,
+ AnnotationSize,
+ AnnotationTextStyle,
+ AnnotationType,
+ BlurData,
+ FigureData,
+ PlaybackSpeed,
+ Rotation3DPreset,
+ SpeedRegion,
+ ZoomDepth,
+ ZoomFocus,
+ ZoomFocusMode,
+ ZoomRegion,
+} from "./types";
+
+/** The copyable attributes of each region, tagged with its `kind` so paste can discriminate.
+ * Trim has no attributes, so it isn't copyable. */
+export type CopiedZoom = {
+ kind: "zoom";
+ depth: ZoomDepth;
+ customScale?: number;
+ focus: ZoomFocus;
+ focusMode?: ZoomFocusMode;
+ rotationPreset?: Rotation3DPreset;
+};
+
+export type CopiedSpeed = { kind: "speed"; speed: PlaybackSpeed };
+
+/** Annotation copy captures everything; paste then uses only the styling for an existing
+ * region, or the full set for a brand-new one. */
+export type CopiedAnnotation = {
+ kind: "annotation";
+ // Styling — applied both when pasting onto an existing region and onto a new one.
+ style: AnnotationTextStyle;
+ size: AnnotationSize;
+ figureData?: FigureData;
+ blurData?: BlurData;
+ // Content & placement — used only when pasting as a brand-new region.
+ type: AnnotationType;
+ content: string;
+ textContent?: string;
+ imageContent?: string;
+ position: AnnotationPosition;
+};
+
+export type CopiedRegion = CopiedZoom | CopiedSpeed | CopiedAnnotation;
+
+/** Session clipboard for "copy/paste region attributes" (not undoable, not persisted).
+ * Module-level so it's shared regardless of which editor instance copied. */
+let clipboard: CopiedRegion | null = null;
+
+export function getCopiedRegion(): CopiedRegion | null {
+ return clipboard;
+}
+
+export function setCopiedRegion(region: CopiedRegion): void {
+ clipboard = region;
+}
+
+export function extractZoomAttributes(region: ZoomRegion): CopiedZoom {
+ return {
+ kind: "zoom",
+ depth: region.depth,
+ customScale: region.customScale,
+ focus: { ...region.focus },
+ focusMode: region.focusMode,
+ rotationPreset: region.rotationPreset,
+ };
+}
+
+export function extractSpeedAttributes(region: SpeedRegion): CopiedSpeed {
+ return { kind: "speed", speed: region.speed };
+}
+
+/** Deep-clones blur data, including its nested freehand points array. */
+function cloneBlurData(blurData?: BlurData): BlurData | undefined {
+ if (!blurData) return undefined;
+ return {
+ ...blurData,
+ freehandPoints: blurData.freehandPoints ? [...blurData.freehandPoints] : undefined,
+ };
+}
+
+export function extractAnnotationAttributes(region: AnnotationRegion): CopiedAnnotation {
+ return {
+ kind: "annotation",
+ style: { ...region.style },
+ size: { ...region.size },
+ figureData: region.figureData ? { ...region.figureData } : undefined,
+ blurData: cloneBlurData(region.blurData),
+ type: region.type,
+ content: region.content,
+ textContent: region.textContent,
+ imageContent: region.imageContent,
+ position: { ...region.position },
+ };
+}
+
+/** Returns a region carrying the copied attributes. Identity, timing, and source come from
+ * `base` (so a full region keeps its own); every attribute comes from the copy, with nested
+ * objects deep-copied. Passing a stub `base` builds a brand-new region; passing an existing
+ * region overwrites ALL its attributes (e.g. a preset-only copy clears the target's customScale). */
+export function buildZoomRegion(
+ base: Pick,
+ attrs: CopiedZoom,
+): ZoomRegion {
+ const { kind: _kind, ...zoomAttrs } = attrs;
+ return { ...base, ...zoomAttrs, focus: { ...zoomAttrs.focus } };
+}
+
+export function buildSpeedRegion(
+ base: Pick,
+ attrs: CopiedSpeed,
+): SpeedRegion {
+ return { ...base, speed: attrs.speed };
+}
+
+/** Pastes onto an EXISTING annotation: only the styling is overwritten — the target keeps
+ * its own type, text/image content, position, timing, and stacking order. */
+export function replaceAnnotationAttributes(
+ region: AnnotationRegion,
+ attrs: CopiedAnnotation,
+): AnnotationRegion {
+ return {
+ ...region,
+ style: { ...attrs.style },
+ size: { ...attrs.size },
+ // Only carry figure data onto a figure target; never attach it to a non-figure
+ // (e.g. pasting a figure's attributes onto a text annotation keeps the text figure-less).
+ figureData:
+ region.type === "figure" && attrs.figureData ? { ...attrs.figureData } : region.figureData,
+ // Likewise, only carry blur settings onto a blur target.
+ blurData:
+ region.type === "blur" && attrs.blurData ? cloneBlurData(attrs.blurData) : region.blurData,
+ };
+}
+
+/** Builds a BRAND-NEW annotation from a full copy: clones type, content, styling, size,
+ * figure data, and position. Identity, timing, and stacking order come from `base`. */
+export function buildPastedAnnotation(
+ base: Pick,
+ attrs: CopiedAnnotation,
+): AnnotationRegion {
+ return {
+ ...base,
+ type: attrs.type,
+ content: attrs.content,
+ textContent: attrs.textContent,
+ imageContent: attrs.imageContent,
+ position: { ...attrs.position },
+ size: { ...attrs.size },
+ style: { ...attrs.style },
+ figureData: attrs.figureData ? { ...attrs.figureData } : undefined,
+ blurData: cloneBlurData(attrs.blurData),
+ };
+}
diff --git a/src/components/video-editor/regionPlacement.test.ts b/src/components/video-editor/regionPlacement.test.ts
new file mode 100644
index 0000000000..ecddf2ff61
--- /dev/null
+++ b/src/components/video-editor/regionPlacement.test.ts
@@ -0,0 +1,54 @@
+import { describe, expect, it } from "vitest";
+import { findFreeGapAt } from "./regionPlacement";
+
+const totalMs = 10000;
+
+describe("findFreeGapAt", () => {
+ it("returns the gap to the end when there are no regions", () => {
+ const { ok, gapMs } = findFreeGapAt([], 2000, totalMs);
+ expect(ok).toBe(true);
+ expect(gapMs).toBe(8000);
+ });
+
+ it("rejects a playhead that lands inside an existing region", () => {
+ const regions = [{ startMs: 1000, endMs: 4000 }];
+ const { ok } = findFreeGapAt(regions, 2000, totalMs);
+ expect(ok).toBe(false);
+ });
+
+ it("clamps the gap to the next region's start", () => {
+ const regions = [{ startMs: 5000, endMs: 7000 }];
+ const { ok, gapMs } = findFreeGapAt(regions, 2000, totalMs);
+ expect(ok).toBe(true);
+ expect(gapMs).toBe(3000);
+ });
+
+ it("rejects placement with no room before the end", () => {
+ const { ok, gapMs } = findFreeGapAt([], totalMs, totalMs);
+ expect(ok).toBe(false);
+ expect(gapMs).toBe(0);
+ });
+
+ it("rejects placement that lands exactly on a region's startMs", () => {
+ const regions = [{ startMs: 5000, endMs: 7000 }];
+ const { ok } = findFreeGapAt(regions, 5000, totalMs);
+ expect(ok).toBe(false);
+ });
+
+ it("allows placement adjacent to (exactly at the end of) an existing region", () => {
+ const regions = [{ startMs: 0, endMs: 2000 }];
+ const { ok, gapMs } = findFreeGapAt(regions, 2000, totalMs);
+ expect(ok).toBe(true);
+ expect(gapMs).toBe(8000);
+ });
+
+ it("sorts unordered regions before computing the next gap", () => {
+ const regions = [
+ { startMs: 8000, endMs: 9000 },
+ { startMs: 3000, endMs: 4000 },
+ ];
+ const { ok, gapMs } = findFreeGapAt(regions, 1000, totalMs);
+ expect(ok).toBe(true);
+ expect(gapMs).toBe(2000);
+ });
+});
diff --git a/src/components/video-editor/regionPlacement.ts b/src/components/video-editor/regionPlacement.ts
new file mode 100644
index 0000000000..673d3fe7f8
--- /dev/null
+++ b/src/components/video-editor/regionPlacement.ts
@@ -0,0 +1,24 @@
+/**
+ * Find the available gap at `startPos` in a list of regions.
+ *
+ * Looks at the span from `startPos` up to the start of the next region
+ * (or up to `totalMs` if there is no later region) and reports its size,
+ * along with whether placement at `startPos` is actually valid.
+ *
+ * Placement is valid as long as `startPos` does not fall inside an
+ * existing region and there is some room before the next one. Landing
+ * exactly on the end of an existing region is fine (adjacency is
+ * allowed); landing on a region's start or strictly between its start
+ * and end, or having zero space left before the next region, is not.
+ */
+export function findFreeGapAt(
+ regions: ReadonlyArray<{ startMs: number; endMs: number }>,
+ startPos: number,
+ totalMs: number,
+): { ok: boolean; gapMs: number } {
+ const sorted = [...regions].sort((a, b) => a.startMs - b.startMs);
+ const nextRegion = sorted.find((r) => r.startMs > startPos);
+ const gapMs = nextRegion ? nextRegion.startMs - startPos : totalMs - startPos;
+ const overlapping = sorted.some((r) => startPos >= r.startMs && startPos < r.endMs);
+ return { ok: !overlapping && gapMs > 0, gapMs };
+}
diff --git a/src/components/video-editor/timeline/BackgroundWaveform.tsx b/src/components/video-editor/timeline/BackgroundWaveform.tsx
index 815b472d13..d89a14c401 100644
--- a/src/components/video-editor/timeline/BackgroundWaveform.tsx
+++ b/src/components/video-editor/timeline/BackgroundWaveform.tsx
@@ -1,36 +1,28 @@
import { useTimelineContext } from "dnd-timeline";
-import { useEffect, useRef, useState } from "react";
+import { useEffect, useMemo, useRef, useState } from "react";
+
+// Perceptual curve on normalized amplitude; exponent < 1 lifts quiet passages so
+// one loud spike doesn't flatten the rest.
+const WAVEFORM_GAMMA = 0.6;
export interface BackgroundWaveformProps {
- /** Pre-computed peaks array: pairs of [min, max] per block (length = 2 * N). */
+ /** Pre-computed peaks: pairs of [min, max] per block (length = 2 * N). */
peaks: Float32Array | null;
videoDurationMs: number;
- /**
- * Pixels to inset the drawn waveform from the top of the canvas row,
- * so it aligns with the item content top edge. Defaults to 0.
- */
+ /** Inset from canvas top so the waveform aligns with item content top. Defaults to 0. */
topInset?: number;
- /**
- * Pixels to inset the drawn waveform from the bottom of the canvas row,
- * so it aligns with the item content bottom edge. Defaults to 0.
- */
+ /** Inset from canvas bottom so the waveform aligns with item content bottom. Defaults to 0. */
bottomInset?: number;
}
/**
- * Renders a rectified (half-wave) audio waveform on a `