Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ffc41a3c8c | ||
|
|
cc8307f4c8 | ||
|
|
21c637a384 | ||
|
|
5f71979e76 | ||
|
|
c539e9ba26 | ||
|
|
53f9851af9 | ||
|
|
3e3a81684f | ||
|
|
c6331badb1 | ||
|
|
c03db08448 | ||
|
|
96e178c432 | ||
|
|
358d4af438 | ||
|
|
02b4e1b45b | ||
|
|
e49cc02d75 | ||
|
|
a806e34ff0 | ||
|
|
b088a098a9 | ||
|
|
98a32372e7 | ||
|
|
0e3bd17480 | ||
|
|
332b6b7d38 | ||
|
|
62f4f455c8 | ||
|
|
a3716581b0 | ||
|
|
1d7b677f9c | ||
|
|
43b01cfb60 | ||
|
|
4a18361589 | ||
|
|
c35a899ab4 | ||
|
|
970406a457 | ||
|
|
a19771e90d | ||
|
|
b8eeef55ab | ||
|
|
4ce7af4f25 | ||
|
|
e5462f1ac8 | ||
|
|
94a00c0abf | ||
|
|
6873b808d5 | ||
|
|
9a83062302 | ||
|
|
f5ad566534 | ||
|
|
c01c88b1eb | ||
|
|
a280179988 | ||
|
|
a18dc39bab | ||
|
|
b56daaee3e | ||
|
|
53be8df129 | ||
|
|
0ab085961f | ||
|
|
d2dd44ad3b | ||
|
|
0f554e72ae | ||
|
|
00191c479e | ||
|
|
34ef71b363 | ||
|
|
1f43ef8004 | ||
|
|
68b26c220b | ||
|
|
c491b7ca68 | ||
|
|
2cacedf0a0 | ||
|
|
180a1eafd5 | ||
|
|
19be540053 | ||
|
|
324f4e01d7 | ||
|
|
ff767a5e86 | ||
|
|
993f693b5c | ||
|
|
fb49ee1f1c | ||
|
|
82b41da2d8 | ||
|
|
2aa6e90edb | ||
|
|
c34a600138 | ||
|
|
0b06900a9c | ||
|
|
c0702b5e91 | ||
|
|
928e1c5e01 | ||
|
|
545c35d4cd | ||
|
|
eac5cc63b4 | ||
|
|
e82fc0d8cc | ||
|
|
7cf78fe53c | ||
|
|
cf74b76d39 | ||
|
|
afc76c368f | ||
|
|
0abe134218 | ||
|
|
391817d648 | ||
|
|
df6c821700 | ||
|
|
dd2513d958 | ||
|
|
ed5ef32280 | ||
|
|
ff40a73a1b | ||
|
|
59c9a19269 | ||
|
|
902d4e4d8a | ||
|
|
d86c17407a | ||
|
|
f1a8552046 | ||
|
|
480890bcce | ||
|
|
62000c6dcf | ||
|
|
202c670090 | ||
|
|
3d572b272d | ||
|
|
fca17f901e | ||
|
|
abb4413932 | ||
|
|
d8ab206b80 | ||
|
|
489f157eef | ||
|
|
60cbb449e8 | ||
|
|
0d39f65af1 | ||
|
|
8117d4826f | ||
|
|
b70994b079 | ||
|
|
5c5cab6903 | ||
|
|
36d7d2bdd0 | ||
|
|
f3c5b8a65d | ||
|
|
8c7ea939ab | ||
|
|
727e395fcf | ||
|
|
205ea55b0c | ||
|
|
54677960d0 | ||
|
|
7f73f9089f | ||
|
|
f6b0bbd3ed | ||
|
|
d32bb00959 | ||
|
|
3d129f85cd | ||
|
|
6ba9f1e948 | ||
|
|
3a2f90722c | ||
|
|
c0b929d4b2 | ||
|
|
db1d6bfe96 | ||
|
|
69d0aa4d30 | ||
|
|
d06bf37471 | ||
|
|
2cf09a88dc | ||
|
|
2349272b21 | ||
|
|
9fc047f2cd | ||
|
|
c293786e9d | ||
|
|
928cabc68d | ||
|
|
9a72f31148 | ||
|
|
9f55611f72 | ||
|
|
87f7268de7 | ||
|
|
f19d1bc668 | ||
|
|
98f7441dfa | ||
|
|
b7d7c08906 | ||
|
|
f67e997688 | ||
|
|
514943c142 | ||
|
|
a72aa1a594 | ||
|
|
46732fc76b | ||
|
|
5b4f31f661 | ||
|
|
70c7d20d3c | ||
|
|
8413095aa9 | ||
|
|
d856a52316 | ||
|
|
37eaacc07b | ||
|
|
34340c2b29 | ||
|
|
2dbdb27bb6 | ||
|
|
fbd06fca48 | ||
|
|
85d0dea9fc | ||
|
|
259bfa9097 | ||
|
|
b6b37e3718 | ||
|
|
dfd961393f | ||
|
|
9eaae72af1 | ||
|
|
d658ec40f5 | ||
|
|
84b523df83 | ||
|
|
b8e78ccbf5 | ||
|
|
b36a32d44b | ||
|
|
ea09fdc12c | ||
|
|
b25c2c8363 | ||
|
|
a686fa012a | ||
|
|
24ce67b5a7 | ||
|
|
2993a57853 | ||
|
|
2ee00a7cfa | ||
|
|
a0bcd9fe90 | ||
|
|
5417581f0e | ||
|
|
3a48e54db1 | ||
|
|
2b7e6f1677 | ||
|
|
1c431fe473 | ||
|
|
7216f255a0 | ||
|
|
c13d3c71bc | ||
|
|
33bb751140 | ||
|
|
d802473d86 | ||
|
|
872bd959da | ||
|
|
d3b51e84f2 | ||
|
|
613ed008fc | ||
|
|
3aad05abd2 |
@@ -29,6 +29,14 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
# Cache the downloaded caption model so we don't re-fetch from HuggingFace every run
|
||||
# (and to avoid 429s when the platform matrix builds hit it at once).
|
||||
- name: Cache caption assets
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: caption-assets
|
||||
key: caption-assets-${{ hashFiles('scripts/fetch-caption-model.mjs') }}
|
||||
|
||||
- name: Build Windows app
|
||||
run: npm run build:win
|
||||
env:
|
||||
@@ -69,6 +77,23 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
# ─── Ensure sharp prebuilt binary (+ bundled libvips) ─────
|
||||
# `npm ci` can leave sharp without its prebuilt binary/vendored libvips,
|
||||
# which makes electron-builder's native rebuild fail ("vips-cpp.42 not
|
||||
# found"). Re-fetch the prebuilt (never compile from source).
|
||||
- name: Ensure sharp prebuilt
|
||||
run: npm rebuild sharp
|
||||
env:
|
||||
npm_config_build_from_source: "false"
|
||||
|
||||
# ─── Cache caption assets ─────────────────────────────────
|
||||
# Avoid re-fetching the Whisper model from HuggingFace every run (and 429s under the matrix).
|
||||
- name: Cache caption assets
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: caption-assets
|
||||
key: caption-assets-${{ hashFiles('scripts/fetch-caption-model.mjs') }}
|
||||
|
||||
# ─── Import Code Signing Certificate ──────────────────────
|
||||
# This is the KEY step that makes CI signing work.
|
||||
# We create a temporary keychain, import the .p12 cert into it,
|
||||
@@ -111,6 +136,17 @@ jobs:
|
||||
- name: Build Vite + Electron
|
||||
run: npx tsc && npx vite build
|
||||
|
||||
# ─── Build native macOS helpers ───────────────────────────
|
||||
# The package step below calls electron-builder directly (not `npm run build:mac`),
|
||||
# so the Swift screencapturekit + cursor helpers must be compiled here first or the
|
||||
# packaged app ships without them (no recording, "cursor helper couldn't be found").
|
||||
# Build the matrix arch into electron/native/bin/darwin-<arch> so each DMG gets its
|
||||
# own native binary.
|
||||
- name: Build native macOS helpers
|
||||
run: npm run build:native:mac
|
||||
env:
|
||||
OPENSCREEN_MAC_HELPER_ARCHS: ${{ matrix.arch }}
|
||||
|
||||
# ─── Package with electron-builder ────────────────────────
|
||||
# electron-builder handles deep codesigning the .app bundle
|
||||
# "notarize: false" in electron-builder.json5 prevents it from
|
||||
@@ -236,6 +272,14 @@ jobs:
|
||||
- name: Install pacman build dependencies
|
||||
run: sudo apt-get update && sudo apt-get install -y libarchive-tools
|
||||
|
||||
# Cache the downloaded caption model so we don't re-fetch from HuggingFace every run
|
||||
# (and to avoid 429s when the platform matrix builds hit it at once).
|
||||
- name: Cache caption assets
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: caption-assets
|
||||
key: caption-assets-${{ hashFiles('scripts/fetch-caption-model.mjs') }}
|
||||
|
||||
- name: Build Linux app
|
||||
run: npm run build:linux
|
||||
env:
|
||||
|
||||
@@ -46,6 +46,7 @@ release/**
|
||||
test-results
|
||||
playwright-report/
|
||||
|
||||
|
||||
# Vitest browser mode screenshots
|
||||
__screenshots__/
|
||||
|
||||
@@ -58,3 +59,10 @@ result-*
|
||||
|
||||
#kilocode
|
||||
.kilo/
|
||||
|
||||
#others
|
||||
|
||||
**/*.import
|
||||
|
||||
# Auto-caption model + ORT wasm — regenerated at build by scripts/fetch-caption-model.mjs
|
||||
/caption-assets/
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Contribution Guidelines
|
||||
# Contribution Guidelines
|
||||
|
||||
Thank you for considering contributing to this project! By contributing, you help make this project better for everyone. Please take a moment to review these guidelines to ensure a smooth contribution process.
|
||||
|
||||
|
||||
@@ -1,51 +1,51 @@
|
||||
> [!WARNING]
|
||||
> This started as a side project that took off — it's not production grade and you'll hit bugs, but hopefully it covers what you need.
|
||||
> This started as a side project that blew up; not production grade and you'll hit bugs, but hopefully it covers what you need. **This project will soon be archived.**
|
||||
|
||||
|
||||
<p align="center">
|
||||
<img src="public/openscreen.png" alt="OpenScreen Logo" width="64" />
|
||||
<br />
|
||||
<br />
|
||||
<a href="https://trendshift.io/repositories/17427" target="_blank"><img src="https://trendshift.io/api/badge/repositories/17427" alt="siddharthvaddem%2Fopenscreen | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
<br />
|
||||
<br />
|
||||
<a href="https://deepwiki.com/siddharthvaddem/openscreen">
|
||||
<img src="https://deepwiki.com/badge.svg" alt="Ask DeepWiki" />
|
||||
</a>
|
||||
|
||||
<a href="https://discord.gg/yAQQhRaEeg">
|
||||
<img src="https://dcbadge.limes.pink/api/server/https://discord.gg/yAQQhRaEeg?style=flat" alt="Join Discord" />
|
||||
<a href="https://trendshift.io/repositories/17427" target="_blank"><img src="https://trendshift.io/api/badge/repositories/17427" alt="siddharthvaddem%2Fopenscreen | Trendshift" style="width: 256px; height: 64px;" width="256" height="64"/></a>
|
||||
|
||||
|
||||
</a>
|
||||
</p>
|
||||
|
||||
# <p align="center">OpenScreen</p>
|
||||
|
||||
<p align="center"><strong>OpenScreen is your free, open-source alternative to Screen Studio (sort of).</strong></p>
|
||||
<p align="center"><strong>OpenScreen is your free, open-source alternative to Screen Studio.</strong></p>
|
||||
|
||||
If you don't want to pay $29/month for Screen Studio but want a much simpler version that does what most people seem to need - 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!
|
||||
If you don't want to pay $29/month for Screen Studio but want a version that does what most people seem to need - quick, polished product demos and walkthroughs you'd post on X, Reddit or Youtube. OpenScreen does not offer every Screen Studio feature, but covers a lot of the core functionality.
|
||||
|
||||
Screen Studio is an awesome product and this is definitely not a 1:1 clone. 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!
|
||||
Screen Studio is an awesome product and this is definitely not a 1:1 clone. If you just want something fully free and open source, this project should cover most of your needs.
|
||||
|
||||
**100% free** for both **personal** and **commercial** use. Use it, modify it, distribute it — 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.
|
||||
|
||||
<p align="center">
|
||||
<img src="public/preview3.png" alt="OpenScreen App Preview 3" style="height: 0.2467; margin-right: 12px;" />
|
||||
<img src="public/preview4.png" alt="OpenScreen App Preview 4" style="height: 0.1678; margin-right: 12px;" />
|
||||
<img src="public/demo.png" alt="" style="height: 0.2467; margin-right: 12px;" />
|
||||
<img src="public/sample.png" alt="" style="height: 0.2467; margin-right: 12px;" />
|
||||
</p>
|
||||
|
||||
## Core Features
|
||||
- Record 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
|
||||
|
||||
@@ -76,6 +76,9 @@ Note: Give your terminal Full Disk Access in **System Settings > Privacy & Secur
|
||||
|
||||
After running this command, proceed to **System Preferences > Security & Privacy** to grant the necessary permissions for "screen recording" and "accessibility". Once permissions are granted, you can launch the app.
|
||||
|
||||
> [!NOTE]
|
||||
> **Upgrading from an older version and hitting permission issues?** If you already had OpenScreen installed and the new version won't record (Screen Recording or Accessibility keep failing even after you grant them), uninstall the old version, remove OpenScreen's existing entries under **System Settings > Privacy & Security** (both Screen Recording and Accessibility), then do a fresh install and grant the permissions again when prompted.
|
||||
|
||||
### Windows
|
||||
|
||||
Install via [winget](https://learn.microsoft.com/en-us/windows/package-manager/winget/):
|
||||
@@ -146,45 +149,20 @@ 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).
|
||||
|
||||
## Built with
|
||||
- Electron
|
||||
- React
|
||||
- TypeScript
|
||||
- Vite
|
||||
- PixiJS
|
||||
- dnd-timeline
|
||||
- **Native recording**: macOS (ScreenCaptureKit) and Windows (Windows Graphics Capture) record through a native pipeline for higher quality and clean window-level capture. Linux records through the browser pipeline instead.
|
||||
- **Custom cursors**: on macOS and Windows the real cursor is captured (shape, type, and clicks), which powers the cursor themes, click effects, and editable cursor overlay. On Linux only the cursor position is captured (used for auto-zoom), so those cursor options aren't available.
|
||||
- **Webcam**: captured natively on macOS and Windows; on Linux it's recorded through the browser, but still works as a picture-in-picture overlay.
|
||||
- **System audio** support varies by OS:
|
||||
- **macOS**: requires macOS 13+. On macOS 14.2+ you'll be prompted to grant audio capture permission. macOS 12 and below can't capture system audio (mic still works).
|
||||
- **Windows**: works out of the box.
|
||||
- **Linux**: needs PipeWire (default on Ubuntu 22.04+, Fedora 34+). Older PulseAudio-only setups may not capture system audio (mic should still work).
|
||||
|
||||
---
|
||||
|
||||
|
||||
## Documentation
|
||||
|
||||
See the documentation here:
|
||||
[OpenScreen Docs](https://deepwiki.com/siddharthvaddem/openscreen)
|
||||
Refresh if outdated.
|
||||
|
||||
## Contributing
|
||||
|
||||
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.
|
||||
|
||||
## Star History
|
||||
|
||||
<a href="https://www.star-history.com/?repos=siddharthvaddem%2Fopenscreen&type=date&legend=top-left">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=siddharthvaddem/openscreen&type=date&theme=dark&legend=top-left" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=siddharthvaddem/openscreen&type=date&legend=top-left" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=siddharthvaddem/openscreen&type=date&legend=top-left" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the [MIT License](./LICENSE). By using this software, you agree that the authors are not liable for any issues, damages, or claims arising from its use.
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
# macOS native cursor test pipeline
|
||||
|
||||
This document covers manual and diagnostic testing for macOS native cursor capture — the path that records real system cursor bitmaps via `NSCursor.currentSystem` and surfaces them through the OpenScreen editor and export pipeline.
|
||||
|
||||
## How the macOS cursor helper works
|
||||
|
||||
The helper binary (`openscreen-macos-cursor-helper`) runs as a child process of Electron during recording. It:
|
||||
|
||||
- polls `NSCursor.currentSystem` at the configured sample interval
|
||||
- converts each cursor image to PNG and computes a SHA-256 content hash as a stable asset id
|
||||
- emits the full base64 bitmap payload **once** per unique cursor shape per session; subsequent samples carry only the `assetId` so stdout stays small
|
||||
- tracks left-button down/up events via `CGEventTap` and tags each sample with `interactionType`
|
||||
- uses the Accessibility API to detect `text` and `pointer` affordances (link/button/input roles) when Accessibility is granted; these shapes use the bundled high-quality SVG replacements instead of the raw bitmap
|
||||
|
||||
Each sample line is newline-delimited JSON:
|
||||
|
||||
```json
|
||||
{ "type": "ready", "timestampMs": 1234567890, "accessibilityTrusted": true, "mouseTapReady": true }
|
||||
{ "type": "sample", "timestampMs": 1234567891, "assetId": "a7472...", "asset": { "id": "a7472...", "imageDataUrl": "data:image/png;base64,...", "width": 64, "height": 64, "hotspotX": 16, "hotspotY": 16, "scaleFactor": 2.0 }, "cursorType": null, "leftButtonDown": false, "leftButtonPressed": false, "leftButtonReleased": false }
|
||||
{ "type": "sample", "timestampMs": 1234567924, "assetId": "a7472...", "cursorType": null, "leftButtonDown": false, "leftButtonPressed": false, "leftButtonReleased": false }
|
||||
```
|
||||
|
||||
`asset` is present only the first time a given `assetId` appears. The TypeScript session (`MacNativeCursorRecordingSession`) collects unique assets into a map and sets `provider: "native"` in the final `CursorRecordingData` when at least one bitmap was captured.
|
||||
|
||||
## Build the helper
|
||||
|
||||
```bash
|
||||
npm run build:native:mac
|
||||
```
|
||||
|
||||
This builds both Swift helpers (`openscreen-screencapturekit-helper` and `openscreen-macos-cursor-helper`) and copies them to:
|
||||
|
||||
- `electron/native/screencapturekit/build/` — used by the local dev server
|
||||
- `electron/native/bin/darwin-arm64/` or `darwin-x64/` — used by packaged builds
|
||||
|
||||
Requires Xcode (not just Command Line Tools). If you see a build error about missing SDK metadata, run:
|
||||
|
||||
```bash
|
||||
sudo xcode-select --switch /Applications/Xcode.app/Contents/Developer
|
||||
sudo xcodebuild -license accept
|
||||
```
|
||||
|
||||
## Smoke-test the helper directly
|
||||
|
||||
You can run the cursor helper standalone to inspect its raw output before involving the full app:
|
||||
|
||||
```bash
|
||||
BIN=electron/native/screencapturekit/build/openscreen-macos-cursor-helper
|
||||
("$BIN" '{"sampleIntervalMs":100}' & PID=$!; sleep 2; kill $PID) | head -20
|
||||
```
|
||||
|
||||
Expected first line:
|
||||
|
||||
```json
|
||||
{"type":"ready","mouseTapReady":true,"accessibilityTrusted":false,"timestampMs":...}
|
||||
```
|
||||
|
||||
`accessibilityTrusted: false` is normal in dev/unsigned builds. It means text/pointer affordance detection is disabled; native bitmap capture still works.
|
||||
|
||||
Expected sample lines:
|
||||
|
||||
```json
|
||||
{"type":"sample","assetId":"a7472...","asset":{"id":"a7472...","imageDataUrl":"data:image/png;base64,...","width":64,"height":64,"hotspotX":26,"hotspotY":16,"scaleFactor":2.0},...}
|
||||
{"type":"sample","assetId":"a7472...",...}
|
||||
```
|
||||
|
||||
Move the cursor over a text input while the helper is running and check that a new `assetId` appears with a different bitmap (if Accessibility is granted — see below).
|
||||
|
||||
## Point the app at a custom helper binary
|
||||
|
||||
```bash
|
||||
export OPENSCREEN_MAC_CURSOR_HELPER_EXE=/path/to/openscreen-macos-cursor-helper
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## macOS permissions
|
||||
|
||||
Two separate permissions are needed:
|
||||
|
||||
| Permission | What it enables | Where to grant |
|
||||
|---|---|---|
|
||||
| Screen Recording | ScreenCaptureKit video capture | System Settings → Privacy & Security → Screen & System Audio Recording → Electron ✅ |
|
||||
| Accessibility | `text` / `pointer` cursor type detection (affordance hints) | System Settings → Privacy & Security → Accessibility → Electron ✅ |
|
||||
|
||||
**Screen Recording** is required to record. Without it the recording never starts.
|
||||
|
||||
**Accessibility** is optional. Without it, `cursorType` will always be `null` and all cursors render from their captured bitmaps (no SVG substitution). This is the expected fallback and does not degrade cursor quality for non-text/pointer shapes.
|
||||
|
||||
After granting either permission in System Settings, **fully quit and relaunch** the dev server — `getMediaAccessStatus` caches the result per-process.
|
||||
|
||||
## Manual test checklist
|
||||
|
||||
### P0 — core bitmap capture
|
||||
|
||||
- [ ] Record a short clip. Open the editor. Confirm the default arrow cursor is the real system arrow (not the bundled SVG approximation).
|
||||
- [ ] Record while hovering over a web browser. Confirm custom-CSS cursors (e.g. `cursor: grab`, `cursor: crosshair`) appear as their actual shapes.
|
||||
- [ ] Export to MP4. Confirm the cursor renders correctly in the exported video.
|
||||
- [ ] Export to GIF. Same check.
|
||||
|
||||
### P1 — affordance substitution (requires Accessibility)
|
||||
|
||||
- [ ] Grant Accessibility permission and restart the app.
|
||||
- [ ] Record hovering over a text input field. Confirm the text I-beam uses the bundled SVG version (prettier than the system bitmap).
|
||||
- [ ] Record hovering over a link/button. Confirm the pointer hand uses the bundled SVG.
|
||||
|
||||
### P1 — hotspot alignment (Retina)
|
||||
|
||||
- [ ] On a Retina display, record a precise click on a small button. In the editor, confirm the cursor tip aligns with the actual click point. The helper reports `scaleFactor: 2.0`; the renderer divides pixel dimensions and hotspot by this value to recover point sizes.
|
||||
|
||||
### P1 — click detection
|
||||
|
||||
- [ ] Record several left-clicks. In the editor, confirm the click-bounce animation fires on each click.
|
||||
- [ ] Confirm `interactionType: "click"` and `"mouseup"` events are present in the recording session sidecar (`cursorRecordingData` inside `<videoPath>.cursor.json`).
|
||||
|
||||
### P2 — graceful degradation
|
||||
|
||||
- [ ] Remove **both** build-output copies of the helper binary and start a recording. The session should succeed with `provider: "none"` (position-only telemetry, default arrow rendered). Restore both binaries afterward.
|
||||
```bash
|
||||
ARCH=$([ "$(uname -m)" = "arm64" ] && echo darwin-arm64 || echo darwin-x64)
|
||||
mv electron/native/screencapturekit/build/openscreen-macos-cursor-helper /tmp/cursor-helper-build
|
||||
mv electron/native/bin/$ARCH/openscreen-macos-cursor-helper /tmp/cursor-helper-bin
|
||||
# ... start recording, then restore:
|
||||
mv /tmp/cursor-helper-bin electron/native/bin/$ARCH/openscreen-macos-cursor-helper
|
||||
mv /tmp/cursor-helper-build electron/native/screencapturekit/build/openscreen-macos-cursor-helper
|
||||
```
|
||||
- [ ] Revoke Accessibility. Confirm recording still works and cursors render from bitmaps (no SVG substitution).
|
||||
|
||||
### P2 — multi-display
|
||||
|
||||
- [ ] Move the cursor to a secondary display during recording. Confirm the cursor clips to the canvas edge rather than snapping invisible on fast swipes. Confirm it hides after ≈100 ms of sustained out-of-bounds movement.
|
||||
|
||||
### P2 — long recording memory
|
||||
|
||||
- [ ] Record for 3–5 minutes while switching between many apps (browser, terminal, editor). The helper should not grow in memory because each iteration drains Cocoa objects via `autoreleasepool`. Check `Activity Monitor` → `openscreen-macos-cursor-helper` RSS stays flat after the first few seconds.
|
||||
|
||||
## What a healthy recording looks like
|
||||
|
||||
Inspect the cursor sidecar file written alongside the recorded video (`<videoPath>.cursor.json`). For a recording saved to `/tmp/rec.mp4`, the sidecar is `/tmp/rec.mp4.cursor.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 2,
|
||||
"provider": "native",
|
||||
"assets": [
|
||||
{ "id": "a7472...", "platform": "darwin", "imageDataUrl": "data:image/png;base64,...", "width": 64, "height": 64, "hotspotX": 26.0, "hotspotY": 16.0, "scaleFactor": 2.0 }
|
||||
],
|
||||
"samples": [
|
||||
{ "timeMs": 0, "cx": 0.42, "cy": 0.38, "visible": true, "assetId": "a7472...", "interactionType": "move" },
|
||||
...
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`provider: "native"` and a non-empty `assets` array confirm bitmap capture is active. If you see `provider: "none"` and `assets: []`, the helper was not found or exited before `ready`.
|
||||
|
||||
## Native macOS capture backend
|
||||
|
||||
The app routes macOS recordings through the ScreenCaptureKit helper (`openscreen-screencapturekit-helper`) when it is available, so the real system cursor is excluded from the video frame. The cursor position and bitmap are captured separately by the cursor helper and composited in the editor and export pipeline.
|
||||
|
||||
Current native availability rules:
|
||||
|
||||
- macOS 13 (Ventura) or newer
|
||||
- `openscreen-screencapturekit-helper` binary is present
|
||||
- Screen Recording permission is granted
|
||||
|
||||
Build both helpers locally:
|
||||
|
||||
```bash
|
||||
npm run build:native:mac
|
||||
```
|
||||
|
||||
For local diagnostics with a custom helper binary, use the environment override:
|
||||
|
||||
```bash
|
||||
export OPENSCREEN_MAC_CURSOR_HELPER_EXE=/path/to/openscreen-macos-cursor-helper
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## Known limitations
|
||||
|
||||
- **Intel (x86\_64) Macs**: the distributed helper is built for `darwin-arm64`. On Intel Macs, you need to build from source with `npm run build:native:mac` on the target machine.
|
||||
- **Accessibility permission in unsigned/dev builds**: `getMediaAccessStatus("accessibility")` may not reflect the toggle state for unsigned Electron in dev mode. The helper will always probe and report `accessibilityTrusted` in its `ready` event — use that as the authoritative signal.
|
||||
- **App-defined custom cursors (CGS layer)**: `NSCursor.currentSystem` captures the active AppKit cursor. Cursors set at the CoreGraphics/CGS layer by some games or GPU-accelerated apps may not be visible here. This is a known macOS API limitation.
|
||||
@@ -8,8 +8,13 @@
|
||||
"**/*.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"
|
||||
}
|
||||
],
|
||||
|
||||
|
||||
@@ -81,6 +81,12 @@ interface Window {
|
||||
message?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
openRecordingStream: (fileName: string) => Promise<{ success: boolean; error?: string }>;
|
||||
appendRecordingChunk: (
|
||||
fileName: string,
|
||||
chunk: ArrayBuffer,
|
||||
) => Promise<{ success: boolean; error?: string }>;
|
||||
closeRecordingStream: (fileName: string) => Promise<{ success: boolean; error?: string }>;
|
||||
getRecordedVideoPath: () => Promise<{
|
||||
success: boolean;
|
||||
path?: string;
|
||||
@@ -223,7 +229,7 @@ interface Window {
|
||||
canceled?: boolean;
|
||||
error?: string;
|
||||
}>;
|
||||
loadProjectFile: () => Promise<{
|
||||
loadProjectFile: (projectFolder?: string) => Promise<{
|
||||
success: boolean;
|
||||
path?: string;
|
||||
project?: unknown;
|
||||
@@ -239,6 +245,17 @@ interface Window {
|
||||
canceled?: boolean;
|
||||
error?: string;
|
||||
}>;
|
||||
getPathForFile: (file: File) => string;
|
||||
loadProjectFileFromPath: (filePath: string) => Promise<{
|
||||
success: boolean;
|
||||
path?: string;
|
||||
project?: unknown;
|
||||
message?: string;
|
||||
canceled?: boolean;
|
||||
error?: string;
|
||||
}>;
|
||||
onMenuNewProject: (callback: () => void) => () => void;
|
||||
onMenuImportVideo: (callback: () => void) => () => void;
|
||||
onMenuLoadProject: (callback: () => void) => () => void;
|
||||
onMenuSaveProject: (callback: () => void) => () => void;
|
||||
onMenuSaveProjectAs: (callback: () => void) => () => void;
|
||||
@@ -248,10 +265,17 @@ interface Window {
|
||||
) => Promise<{ success: boolean; error?: string; message?: string }>;
|
||||
getShortcuts: () => Promise<Record<string, unknown> | null>;
|
||||
saveShortcuts: (shortcuts: unknown) => Promise<{ success: boolean; error?: string }>;
|
||||
updateGlobalShortcut: (binding: {
|
||||
key: string;
|
||||
ctrl?: boolean;
|
||||
shift?: boolean;
|
||||
alt?: boolean;
|
||||
}) => Promise<{ success: boolean }>;
|
||||
hudOverlayHide: () => void;
|
||||
hudOverlayClose: () => void;
|
||||
setHudOverlayIgnoreMouseEvents: (ignore: boolean) => void;
|
||||
moveHudOverlayBy: (deltaX: number, deltaY: number) => void;
|
||||
setHudOverlaySize: (width: number, height: number) => void;
|
||||
showCountdownOverlay: (value: number, runId: number) => Promise<void>;
|
||||
setCountdownOverlayValue: (value: number, runId: number) => Promise<void>;
|
||||
hideCountdownOverlay: (runId: number) => Promise<void>;
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import fs from "node:fs/promises";
|
||||
import { globalShortcut } from "electron";
|
||||
import { type ShortcutBinding } from "../src/lib/shortcuts";
|
||||
import { SHORTCUTS_FILE } from "./ipc/handlers";
|
||||
|
||||
const DEFAULT_OPEN_APP_BINDING: ShortcutBinding = { key: "o", ctrl: true, shift: true };
|
||||
|
||||
// Maps KeyboardEvent.key values to Electron accelerator key names
|
||||
const KEY_TO_ACCELERATOR: Record<string, string> = {
|
||||
" ": "Space",
|
||||
"+": "Plus",
|
||||
"-": "numsub",
|
||||
"*": "nummult",
|
||||
"/": "numdiv",
|
||||
arrowup: "Up",
|
||||
arrowdown: "Down",
|
||||
arrowleft: "Left",
|
||||
arrowright: "Right",
|
||||
escape: "Escape",
|
||||
enter: "Return",
|
||||
backspace: "Backspace",
|
||||
delete: "Delete",
|
||||
tab: "Tab",
|
||||
};
|
||||
|
||||
function bindingToAccelerator(binding: ShortcutBinding): string {
|
||||
const parts: string[] = [];
|
||||
if (binding.ctrl) parts.push("CommandOrControl");
|
||||
if (binding.shift) parts.push("Shift");
|
||||
if (binding.alt) parts.push("Alt");
|
||||
|
||||
const keyLower = binding.key.toLowerCase();
|
||||
const acceleratorKey = KEY_TO_ACCELERATOR[keyLower] ?? binding.key.toUpperCase();
|
||||
parts.push(acceleratorKey);
|
||||
|
||||
return parts.join("+");
|
||||
}
|
||||
|
||||
let currentAccelerator: string | null = null;
|
||||
|
||||
export function registerOpenAppShortcut(binding: ShortcutBinding, onTrigger: () => void): boolean {
|
||||
const accelerator = bindingToAccelerator(binding);
|
||||
|
||||
if (accelerator === currentAccelerator) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Register the new shortcut before unregistering the old, so a failure leaves the old binding intact
|
||||
const success = globalShortcut.register(accelerator, onTrigger);
|
||||
|
||||
if (success) {
|
||||
if (currentAccelerator) {
|
||||
globalShortcut.unregister(currentAccelerator);
|
||||
}
|
||||
currentAccelerator = accelerator;
|
||||
console.log(`Global shortcut registered: ${accelerator}`);
|
||||
} else {
|
||||
console.warn(`Failed to register global shortcut: ${accelerator}`);
|
||||
}
|
||||
|
||||
return success;
|
||||
}
|
||||
|
||||
export async function loadAndRegisterGlobalShortcut(onTrigger: () => void): Promise<void> {
|
||||
try {
|
||||
const data = await fs.readFile(SHORTCUTS_FILE, "utf-8");
|
||||
const shortcuts = JSON.parse(data);
|
||||
const binding = shortcuts.openApp || DEFAULT_OPEN_APP_BINDING;
|
||||
registerOpenAppShortcut(binding, onTrigger);
|
||||
} catch {
|
||||
registerOpenAppShortcut(DEFAULT_OPEN_APP_BINDING, onTrigger);
|
||||
}
|
||||
}
|
||||
|
||||
export function unregisterAllGlobalShortcuts(): void {
|
||||
globalShortcut.unregisterAll();
|
||||
}
|
||||
@@ -40,20 +40,29 @@ import { RECORDINGS_DIR } from "../main";
|
||||
import { createCursorRecordingSession } from "../native-bridge/cursor/recording/factory";
|
||||
import { requestMacCursorAccessibilityAccess } from "../native-bridge/cursor/recording/macNativeCursorRecordingSession";
|
||||
import type { CursorRecordingSession } from "../native-bridge/cursor/recording/session";
|
||||
import { patchWebmDurationOnDisk } from "../recording/webm-duration";
|
||||
import { registerNativeBridgeHandlers } from "./nativeBridge";
|
||||
import { RecordingStreamRegistry, registerRecordingStreamHandlers } from "./recordingStream";
|
||||
|
||||
const PROJECT_FILE_EXTENSION = "openscreen";
|
||||
const SHORTCUTS_FILE = path.join(app.getPath("userData"), "shortcuts.json");
|
||||
export const SHORTCUTS_FILE = path.join(app.getPath("userData"), "shortcuts.json");
|
||||
const RECORDING_FILE_PREFIX = "recording-";
|
||||
const RECORDING_SESSION_SUFFIX = ".session.json";
|
||||
const ALLOWED_IMPORT_VIDEO_EXTENSIONS = new Set([".webm", ".mp4", ".mov", ".avi", ".mkv"]);
|
||||
const ALLOWED_IMPORT_VIDEO_EXTENSIONS = new Set([
|
||||
".webm",
|
||||
".mp4",
|
||||
".mov",
|
||||
".avi",
|
||||
".mkv",
|
||||
".m4v",
|
||||
".wmv",
|
||||
".flv",
|
||||
".ts",
|
||||
]);
|
||||
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<string>();
|
||||
|
||||
function approveFilePath(filePath: string): void {
|
||||
@@ -89,10 +98,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<T extends Electron.OpenDialogOptions | Electron.SaveDialogOptions>(
|
||||
baseOptions: T,
|
||||
parentWindow: BrowserWindow | null,
|
||||
@@ -221,9 +227,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));
|
||||
@@ -265,6 +270,28 @@ function resolveRecordingOutputPath(fileName: string): string {
|
||||
return path.join(RECORDINGS_DIR, parsedPath.base);
|
||||
}
|
||||
|
||||
function isValidDurationMs(value: number | undefined): value is number {
|
||||
return typeof value === "number" && Number.isFinite(value) && value > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
fileName: string,
|
||||
filePath: string,
|
||||
videoData?: ArrayBuffer,
|
||||
): Promise<boolean> {
|
||||
const streamed = await registry.finalize(fileName);
|
||||
if (!streamed && videoData && videoData.byteLength > 0) {
|
||||
await fs.writeFile(filePath, Buffer.from(videoData));
|
||||
}
|
||||
return streamed;
|
||||
}
|
||||
|
||||
async function getApprovedProjectSession(
|
||||
project: unknown,
|
||||
projectFilePath?: string,
|
||||
@@ -286,8 +313,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)));
|
||||
@@ -330,10 +357,7 @@ let lastEnumeratedSources = new Map<string, DesktopCapturerSource>();
|
||||
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;
|
||||
}
|
||||
@@ -1254,7 +1278,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();
|
||||
@@ -1360,7 +1384,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 () => {
|
||||
@@ -1404,10 +1457,9 @@ export function registerIpcHandlers(
|
||||
});
|
||||
|
||||
ipcMain.handle("switch-to-editor", () => {
|
||||
const mainWin = getMainWindow();
|
||||
if (mainWin) {
|
||||
mainWin.close();
|
||||
}
|
||||
// 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();
|
||||
});
|
||||
|
||||
@@ -1427,16 +1479,18 @@ export function registerIpcHandlers(
|
||||
return;
|
||||
}
|
||||
|
||||
if (!overlayWindow.isVisible()) {
|
||||
overlayWindow.showInactive();
|
||||
}
|
||||
|
||||
// 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<void>((resolve) => {
|
||||
overlayWindow.webContents.once("did-finish-load", () => resolve());
|
||||
overlayWindow.once("ready-to-show", resolve);
|
||||
});
|
||||
}
|
||||
|
||||
if (!overlayWindow.isVisible()) {
|
||||
overlayWindow.showInactive();
|
||||
}
|
||||
|
||||
overlayWindow.webContents.send("countdown-overlay-value", value, runId);
|
||||
});
|
||||
|
||||
@@ -2141,6 +2195,11 @@ export function registerIpcHandlers(
|
||||
},
|
||||
);
|
||||
|
||||
// On-disk write streams for in-progress recordings, keyed by output file name.
|
||||
// Chunks append as they arrive so the renderer never buffers the full video (#616).
|
||||
const recordingStreams = new RecordingStreamRegistry();
|
||||
registerRecordingStreamHandlers(ipcMain, recordingStreams, resolveRecordingOutputPath);
|
||||
|
||||
ipcMain.handle("store-recorded-session", async (_, payload: StoreRecordedSessionInput) => {
|
||||
try {
|
||||
return await storeRecordedSessionFiles(payload);
|
||||
@@ -2161,12 +2220,37 @@ export function registerIpcHandlers(
|
||||
: Date.now();
|
||||
const cursorCaptureMode = normalizeCursorCaptureMode(payload.cursorCaptureMode);
|
||||
const screenVideoPath = resolveRecordingOutputPath(payload.screen.fileName);
|
||||
await fs.writeFile(screenVideoPath, Buffer.from(payload.screen.videoData));
|
||||
const screenStreamed = await finalizeRecordingFile(
|
||||
recordingStreams,
|
||||
payload.screen.fileName,
|
||||
screenVideoPath,
|
||||
payload.screen.videoData,
|
||||
);
|
||||
|
||||
let webcamVideoPath: string | undefined;
|
||||
let webcamStreamed = false;
|
||||
if (payload.webcam) {
|
||||
webcamVideoPath = resolveRecordingOutputPath(payload.webcam.fileName);
|
||||
await fs.writeFile(webcamVideoPath, Buffer.from(payload.webcam.videoData));
|
||||
webcamStreamed = await finalizeRecordingFile(
|
||||
recordingStreams,
|
||||
payload.webcam.fileName,
|
||||
webcamVideoPath,
|
||||
payload.webcam.videoData,
|
||||
);
|
||||
}
|
||||
|
||||
// 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<unknown>[] = [];
|
||||
if (screenStreamed) {
|
||||
patches.push(patchWebmDurationOnDisk(screenVideoPath, payload.durationMs));
|
||||
}
|
||||
if (webcamStreamed && webcamVideoPath) {
|
||||
patches.push(patchWebmDurationOnDisk(webcamVideoPath, payload.durationMs));
|
||||
}
|
||||
await Promise.all(patches);
|
||||
}
|
||||
|
||||
const session: RecordingSession = webcamVideoPath
|
||||
@@ -2288,9 +2372,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 {
|
||||
@@ -2335,8 +2418,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" };
|
||||
}
|
||||
@@ -2373,7 +2456,7 @@ export function registerIpcHandlers(
|
||||
filters: [
|
||||
{
|
||||
name: mainT("dialogs", "fileDialogs.videoFiles"),
|
||||
extensions: ["webm", "mp4", "mov", "avi", "mkv"],
|
||||
extensions: ["webm", "mp4", "mov", "avi", "mkv", "m4v", "wmv", "flv", "ts"],
|
||||
},
|
||||
{ name: mainT("dialogs", "fileDialogs.allFiles"), extensions: ["*"] },
|
||||
],
|
||||
@@ -2412,14 +2495,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) {
|
||||
@@ -2552,16 +2634,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<ProjectFileResult> {
|
||||
async function loadProjectFile(projectFolder?: string): Promise<ProjectFileResult> {
|
||||
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"),
|
||||
@@ -2601,6 +2701,50 @@ export function registerIpcHandlers(
|
||||
}
|
||||
}
|
||||
|
||||
ipcMain.handle("load-project-file-from-path", async (_event, filePath: string) => {
|
||||
return loadProjectFileFromPath(filePath);
|
||||
});
|
||||
|
||||
async function loadProjectFileFromPath(filePath: string): Promise<ProjectFileResult> {
|
||||
try {
|
||||
if (!filePath || typeof filePath !== "string") {
|
||||
return { success: false, message: "Invalid file path" };
|
||||
}
|
||||
// Validate extension and readability
|
||||
if (path.extname(filePath).toLowerCase() !== `.${PROJECT_FILE_EXTENSION}`) {
|
||||
return { success: false, message: "Not an Openscreen project file" };
|
||||
}
|
||||
const stats = await fs.stat(filePath).catch(() => null);
|
||||
if (!stats?.isFile()) {
|
||||
return { success: false, message: "File not found" };
|
||||
}
|
||||
const content = await fs.readFile(filePath, "utf-8");
|
||||
const project = JSON.parse(content);
|
||||
currentProjectPath = filePath;
|
||||
|
||||
// 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);
|
||||
} catch (sessionError) {
|
||||
console.warn(
|
||||
"[loadProjectFileFromPath] Could not approve session paths, proceeding without session:",
|
||||
sessionError,
|
||||
);
|
||||
}
|
||||
setCurrentRecordingSessionState(session);
|
||||
return { success: true, path: filePath, project };
|
||||
} catch (error) {
|
||||
console.error("Failed to load project file from path:", error);
|
||||
return {
|
||||
success: false,
|
||||
message: "Failed to load project file",
|
||||
error: String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
ipcMain.handle("load-current-project-file", async () => {
|
||||
return loadCurrentProjectFile();
|
||||
});
|
||||
@@ -2683,6 +2827,8 @@ export function registerIpcHandlers(
|
||||
|
||||
function clearCurrentVideoPath(): ProjectPathResult {
|
||||
currentVideoPath = null;
|
||||
currentProjectPath = null;
|
||||
setCurrentRecordingSessionState(null);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@@ -2757,6 +2903,7 @@ export function registerIpcHandlers(
|
||||
saveProjectFile,
|
||||
loadProjectFile,
|
||||
loadCurrentProjectFile,
|
||||
loadProjectFileFromPath,
|
||||
setCurrentVideoPath,
|
||||
getCurrentVideoPathResult,
|
||||
clearCurrentVideoPath,
|
||||
|
||||
@@ -25,8 +25,9 @@ export interface NativeBridgeContext {
|
||||
suggestedName?: string,
|
||||
existingProjectPath?: string,
|
||||
) => Promise<ProjectFileResult>;
|
||||
loadProjectFile: () => Promise<ProjectFileResult>;
|
||||
loadProjectFile: (projectFolder?: string) => Promise<ProjectFileResult>;
|
||||
loadCurrentProjectFile: () => Promise<ProjectFileResult>;
|
||||
loadProjectFileFromPath: (path: string) => Promise<ProjectFileResult>;
|
||||
setCurrentVideoPath: (path: string) => ProjectPathResult | Promise<ProjectPathResult>;
|
||||
getCurrentVideoPathResult: () => ProjectPathResult;
|
||||
clearCurrentVideoPath: () => ProjectPathResult;
|
||||
@@ -100,6 +101,7 @@ export function registerNativeBridgeHandlers(context: NativeBridgeContext) {
|
||||
saveProjectFile: context.saveProjectFile,
|
||||
loadProjectFile: context.loadProjectFile,
|
||||
loadCurrentProjectFile: context.loadCurrentProjectFile,
|
||||
loadProjectFileFromPath: context.loadProjectFileFromPath,
|
||||
setCurrentVideoPath: context.setCurrentVideoPath,
|
||||
getCurrentVideoPathResult: context.getCurrentVideoPathResult,
|
||||
clearCurrentVideoPath: context.clearCurrentVideoPath,
|
||||
@@ -162,12 +164,20 @@ 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,
|
||||
await projectService.loadCurrentProjectFile(),
|
||||
);
|
||||
case "loadProjectFileFromPath":
|
||||
return createSuccessResponse(
|
||||
requestId,
|
||||
await projectService.loadProjectFileFromPath(request.payload.path),
|
||||
);
|
||||
case "setCurrentVideoPath":
|
||||
return createSuccessResponse(
|
||||
requestId,
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { mkdtemp, readFile, rm, stat } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { RecordingStreamRegistry } from "./recordingStream";
|
||||
|
||||
describe("RecordingStreamRegistry", () => {
|
||||
let dir: string;
|
||||
const pathFor = (name: string) => path.join(dir, name);
|
||||
|
||||
beforeEach(async () => {
|
||||
dir = await mkdtemp(path.join(tmpdir(), "openscreen-stream-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("streams chunks to disk in order and reports streamed on finalize", async () => {
|
||||
const registry = new RecordingStreamRegistry();
|
||||
await registry.open("rec.webm", pathFor("rec.webm"));
|
||||
await registry.append("rec.webm", Buffer.from("hello "));
|
||||
await registry.append("rec.webm", Buffer.from("world"));
|
||||
|
||||
const streamed = await registry.finalize("rec.webm");
|
||||
|
||||
expect(streamed).toBe(true);
|
||||
expect(await readFile(pathFor("rec.webm"), "utf8")).toBe("hello world");
|
||||
// A second finalize has nothing to close.
|
||||
expect(await registry.finalize("rec.webm")).toBe(false);
|
||||
});
|
||||
|
||||
it("reports not-streamed when no stream was opened", async () => {
|
||||
const registry = new RecordingStreamRegistry();
|
||||
expect(await registry.finalize("missing.webm")).toBe(false);
|
||||
expect(registry.has("missing.webm")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects open when the target path is not writable (open is awaited, not assumed)", async () => {
|
||||
const registry = new RecordingStreamRegistry();
|
||||
// Parent directory does not exist, so createWriteStream emits 'error' on open.
|
||||
await expect(
|
||||
registry.open("rec.webm", path.join(dir, "does-not-exist", "rec.webm")),
|
||||
).rejects.toThrow();
|
||||
// A failed open must not register a stream the renderer would treat as live.
|
||||
expect(registry.has("rec.webm")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects append when no stream is open", async () => {
|
||||
const registry = new RecordingStreamRegistry();
|
||||
await expect(registry.append("rec.webm", Buffer.from("x"))).rejects.toThrow(
|
||||
/No active recording stream/,
|
||||
);
|
||||
});
|
||||
|
||||
it("discard closes the stream and removes the partial file", async () => {
|
||||
const registry = new RecordingStreamRegistry();
|
||||
await registry.open("rec.webm", pathFor("rec.webm"));
|
||||
await registry.append("rec.webm", Buffer.from("partial"));
|
||||
|
||||
await registry.discard("rec.webm", pathFor("rec.webm"));
|
||||
|
||||
expect(registry.has("rec.webm")).toBe(false);
|
||||
await expect(stat(pathFor("rec.webm"))).rejects.toThrow();
|
||||
// Nothing left to finalize after a discard.
|
||||
expect(await registry.finalize("rec.webm")).toBe(false);
|
||||
});
|
||||
|
||||
it("discard tolerates a missing file", async () => {
|
||||
const registry = new RecordingStreamRegistry();
|
||||
await expect(registry.discard("never.webm", pathFor("never.webm"))).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("opening the same file twice replaces the prior stream", async () => {
|
||||
const registry = new RecordingStreamRegistry();
|
||||
await registry.open("rec.webm", pathFor("rec.webm"));
|
||||
await registry.append("rec.webm", Buffer.from("first"));
|
||||
await registry.open("rec.webm", pathFor("rec.webm"));
|
||||
await registry.append("rec.webm", Buffer.from("second"));
|
||||
await registry.finalize("rec.webm");
|
||||
|
||||
expect(await readFile(pathFor("rec.webm"), "utf8")).toBe("second");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
import { createWriteStream, type WriteStream } from "node:fs";
|
||||
import { unlink } from "node:fs/promises";
|
||||
import type { IpcMain } from "electron";
|
||||
|
||||
/**
|
||||
* Owns write streams for in-progress recordings, keyed by output file name.
|
||||
* MediaRecorder chunks are appended as they arrive so a long recording never
|
||||
* buffers the whole video in the renderer (#616 fix). File name is the key
|
||||
* because it's already exchanged across IPC and is unique per recording.
|
||||
*/
|
||||
export class RecordingStreamRegistry {
|
||||
private readonly streams = new Map<string, WriteStream>();
|
||||
|
||||
/**
|
||||
* Open a write stream, resolving only on the `open` event so a bad path or
|
||||
* permission error rejects here instead of becoming a silent chunk drop later,
|
||||
* letting the renderer's fallback take over.
|
||||
*/
|
||||
async open(fileName: string, filePath: string): Promise<void> {
|
||||
await this.endStream(fileName);
|
||||
|
||||
const ws = createWriteStream(filePath, { flags: "w" });
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const onError = (error: Error) => reject(error);
|
||||
ws.once("error", onError);
|
||||
ws.once("open", () => {
|
||||
ws.removeListener("error", onError);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
// Keep a lifetime listener so a late error logs instead of crashing the main
|
||||
// process with an unhandled 'error'. Per-write failures still surface in `append`.
|
||||
ws.on("error", (error) => {
|
||||
console.error(`[recording-stream] ${fileName}:`, error);
|
||||
});
|
||||
|
||||
this.streams.set(fileName, ws);
|
||||
}
|
||||
|
||||
has(fileName: string): boolean {
|
||||
return this.streams.has(fileName);
|
||||
}
|
||||
|
||||
/** Append a chunk; rejects if no stream is open or the write fails. */
|
||||
async append(fileName: string, chunk: Buffer): Promise<void> {
|
||||
const ws = this.streams.get(fileName);
|
||||
if (!ws) {
|
||||
throw new Error(`No active recording stream for ${fileName}`);
|
||||
}
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
ws.write(chunk, (error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush and close the stream, keeping the file. Returns true if a stream was
|
||||
* open (streamed to disk) or false if the caller still needs to write its buffer.
|
||||
*/
|
||||
async finalize(fileName: string): Promise<boolean> {
|
||||
const ws = this.streams.get(fileName);
|
||||
if (!ws) {
|
||||
return false;
|
||||
}
|
||||
this.streams.delete(fileName);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
ws.end((error?: Error | null) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the stream (if any) and delete the partial file, so a discarded or
|
||||
* failed recording doesn't leak descriptors or orphan partial files on disk.
|
||||
*/
|
||||
async discard(fileName: string, filePath: string): Promise<void> {
|
||||
await this.endStream(fileName);
|
||||
await unlink(filePath).catch(() => undefined);
|
||||
}
|
||||
|
||||
private async endStream(fileName: string): Promise<void> {
|
||||
const ws = this.streams.get(fileName);
|
||||
if (!ws) {
|
||||
return;
|
||||
}
|
||||
this.streams.delete(fileName);
|
||||
await new Promise<void>((resolve) => ws.end(() => resolve()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the streaming IPC handlers. Thin wrappers that translate the
|
||||
* registry's throw-on-failure contract into the `{ success, error }` shape the
|
||||
* renderer expects.
|
||||
*/
|
||||
export function registerRecordingStreamHandlers(
|
||||
ipcMain: IpcMain,
|
||||
registry: RecordingStreamRegistry,
|
||||
resolveRecordingOutputPath: (fileName: string) => string,
|
||||
): void {
|
||||
ipcMain.handle(
|
||||
"open-recording-stream",
|
||||
async (_, fileName: string): Promise<{ success: boolean; error?: string }> => {
|
||||
try {
|
||||
await registry.open(fileName, resolveRecordingOutputPath(fileName));
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: String(error) };
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
"append-recording-chunk",
|
||||
async (
|
||||
_,
|
||||
fileName: string,
|
||||
chunk: ArrayBuffer,
|
||||
): Promise<{ success: boolean; error?: string }> => {
|
||||
try {
|
||||
await registry.append(fileName, Buffer.from(chunk));
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: String(error) };
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
"close-recording-stream",
|
||||
async (_, fileName: string): Promise<{ success: boolean; error?: string }> => {
|
||||
try {
|
||||
await registry.discard(fileName, resolveRecordingOutputPath(fileName));
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: String(error) };
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,12 @@ import {
|
||||
systemPreferences,
|
||||
Tray,
|
||||
} from "electron";
|
||||
import { ShortcutBinding } from "../src/lib/shortcuts";
|
||||
import {
|
||||
loadAndRegisterGlobalShortcut,
|
||||
registerOpenAppShortcut,
|
||||
unregisterAllGlobalShortcuts,
|
||||
} from "./globalShortcut";
|
||||
import { mainT, setMainLocale } from "./i18n";
|
||||
import { getSelectedDesktopSource, registerIpcHandlers } from "./ipc/handlers";
|
||||
import {
|
||||
@@ -22,15 +28,14 @@ import {
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
// Use Screen & System Audio Recording permissions instead of CoreAudio Tap API on macOS.
|
||||
// CoreAudio Tap requires NSAudioCaptureUsageDescription in the parent app's Info.plist,
|
||||
// which doesn't work when running from a terminal/IDE during development, makes my life easier
|
||||
// Use Screen & System Audio Recording permissions instead of the CoreAudio Tap API on macOS.
|
||||
// Tap needs NSAudioCaptureUsageDescription in the parent app's Info.plist, which breaks when
|
||||
// running from a terminal/IDE during dev.
|
||||
if (process.platform === "darwin") {
|
||||
app.commandLine.appendSwitch("disable-features", "MacCatapLoopbackAudioForScreenShare");
|
||||
}
|
||||
|
||||
// Enable Wayland support for proper screen capture and window management
|
||||
// on Wayland compositors (Hyprland, GNOME, KDE, etc.)
|
||||
// Wayland support for screen capture and window management on Wayland compositors.
|
||||
if (process.platform === "linux") {
|
||||
const isWayland =
|
||||
process.env.XDG_SESSION_TYPE === "wayland" || process.env.WAYLAND_DISPLAY !== undefined;
|
||||
@@ -108,7 +113,7 @@ function isEditorWindow(window: BrowserWindow) {
|
||||
}
|
||||
|
||||
function sendEditorMenuAction(
|
||||
channel: "menu-load-project" | "menu-save-project" | "menu-save-project-as",
|
||||
channel: "menu-load-project" | "menu-save-project" | "menu-save-project-as" | "menu-new-project",
|
||||
) {
|
||||
let targetWindow = BrowserWindow.getFocusedWindow() ?? mainWindow;
|
||||
|
||||
@@ -167,6 +172,12 @@ function setupApplicationMenu() {
|
||||
{
|
||||
label: mainT("common", "actions.file") || "File",
|
||||
submenu: [
|
||||
{
|
||||
label: mainT("dialogs", "unsavedChanges.newProject") || "New Project",
|
||||
accelerator: "CmdOrCtrl+N",
|
||||
click: () => sendEditorMenuAction("menu-new-project"),
|
||||
},
|
||||
{ type: "separator" as const },
|
||||
{
|
||||
label: mainT("dialogs", "unsavedChanges.loadProject") || "Load Project…",
|
||||
accelerator: "CmdOrCtrl+O",
|
||||
@@ -372,7 +383,7 @@ function createEditorWindowWrapper() {
|
||||
const windowToClose = mainWindow;
|
||||
if (!windowToClose || windowToClose.isDestroyed()) return;
|
||||
|
||||
// Ask renderer to show the custom in-app dialog
|
||||
// Ask renderer to show the in-app close dialog.
|
||||
windowToClose.webContents.send("request-close-confirm");
|
||||
|
||||
ipcMain.once("close-confirm-response", (event, choice: "save" | "discard" | "cancel") => {
|
||||
@@ -381,7 +392,7 @@ function createEditorWindowWrapper() {
|
||||
if (!windowToClose || windowToClose.isDestroyed()) return;
|
||||
|
||||
if (choice === "save") {
|
||||
// Tell renderer to save the project, then close when done
|
||||
// Save first, then close when the renderer reports done.
|
||||
windowToClose.webContents.send("request-save-before-close");
|
||||
ipcMain.once("save-before-close-done", (event, shouldClose: boolean) => {
|
||||
if (event.sender.id !== windowToClose?.webContents.id) return;
|
||||
@@ -416,16 +427,14 @@ function createCountdownOverlayWindowWrapper() {
|
||||
return countdownOverlayWindow;
|
||||
}
|
||||
|
||||
// Closing every window quits the app entirely (tray icon goes too).
|
||||
// The in-app "Return to Recorder" button covers the editor → HUD round-trip,
|
||||
// so closing the last window is an explicit "I'm done" signal.
|
||||
// Closing every window quits the app (tray goes too). The in-app "Return to Recorder"
|
||||
// button covers the editor-to-HUD round-trip, so closing the last window means "I'm done".
|
||||
app.on("window-all-closed", () => {
|
||||
app.quit();
|
||||
});
|
||||
|
||||
app.on("activate", () => {
|
||||
// On OS X it's common to re-create a window in the app when the
|
||||
// dock icon is clicked and there are no other windows open.
|
||||
// On macOS, re-open a window when the dock icon is clicked and none are open.
|
||||
const hasVisibleWindow = BrowserWindow.getAllWindows().some((window) => {
|
||||
if (window.isDestroyed() || !window.isVisible()) {
|
||||
return false;
|
||||
@@ -440,16 +449,18 @@ app.on("activate", () => {
|
||||
}
|
||||
});
|
||||
|
||||
// Register all IPC handlers when app is ready
|
||||
app.on("will-quit", () => {
|
||||
unregisterAllGlobalShortcuts();
|
||||
});
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
// Force the app into "regular" activation policy so the Dock icon appears.
|
||||
// The HUD overlay (transparent + frameless + skipTaskbar) is the first
|
||||
// window we open, and AppKit otherwise classifies us as an accessory app.
|
||||
// Force "regular" activation policy so the Dock icon appears. The HUD overlay
|
||||
// (transparent, frameless, skipTaskbar) is the first window, and AppKit would
|
||||
// otherwise classify us as an accessory app.
|
||||
if (process.platform === "darwin") {
|
||||
app.dock?.show();
|
||||
}
|
||||
|
||||
// Allow microphone/media/screen permission checks
|
||||
session.defaultSession.setPermissionCheckHandler((_webContents, permission) => {
|
||||
const allowed = [
|
||||
"media",
|
||||
@@ -492,9 +503,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") {
|
||||
@@ -502,7 +512,6 @@ app.whenReady().then(async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Listen for HUD overlay quit event (macOS only)
|
||||
ipcMain.on("hud-overlay-close", () => {
|
||||
app.quit();
|
||||
});
|
||||
@@ -512,10 +521,14 @@ app.whenReady().then(async () => {
|
||||
updateTrayMenu();
|
||||
});
|
||||
|
||||
ipcMain.handle("update-global-shortcut", (_, binding: ShortcutBinding) => {
|
||||
const success = registerOpenAppShortcut(binding, showMainWindow);
|
||||
return { success };
|
||||
});
|
||||
|
||||
createTray();
|
||||
updateTrayMenu();
|
||||
setupApplicationMenu();
|
||||
// Ensure recordings directory exists
|
||||
await ensureRecordingsDir();
|
||||
|
||||
function switchToHudWrapper() {
|
||||
@@ -545,5 +558,8 @@ app.whenReady().then(async () => {
|
||||
},
|
||||
switchToHudWrapper,
|
||||
);
|
||||
|
||||
await loadAndRegisterGlobalShortcut(showMainWindow);
|
||||
|
||||
createWindow();
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -6,10 +6,21 @@ import { type Rectangle, screen, systemPreferences } from "electron";
|
||||
import type {
|
||||
CursorRecordingData,
|
||||
CursorRecordingSample,
|
||||
NativeCursorAsset,
|
||||
NativeCursorType,
|
||||
} from "../../../../src/native/contracts";
|
||||
import type { CursorRecordingSession } from "./session";
|
||||
|
||||
interface MacCursorAssetPayload {
|
||||
id: string;
|
||||
imageDataUrl: string;
|
||||
width: number;
|
||||
height: number;
|
||||
hotspotX: number;
|
||||
hotspotY: number;
|
||||
scaleFactor?: number;
|
||||
}
|
||||
|
||||
interface MacNativeCursorRecordingSessionOptions {
|
||||
getDisplayBounds: () => Rectangle | null;
|
||||
maxSamples: number;
|
||||
@@ -28,6 +39,8 @@ type MacCursorEvent =
|
||||
type: "sample";
|
||||
timestampMs: number;
|
||||
cursorType?: NativeCursorType | null;
|
||||
assetId?: string | null;
|
||||
asset?: MacCursorAssetPayload | null;
|
||||
leftButtonDown?: boolean;
|
||||
leftButtonPressed?: boolean;
|
||||
leftButtonReleased?: boolean;
|
||||
@@ -170,6 +183,7 @@ function normalizeCursorType(value: unknown): NativeCursorType | null {
|
||||
|
||||
export class MacNativeCursorRecordingSession implements CursorRecordingSession {
|
||||
private samples: CursorRecordingSample[] = [];
|
||||
private assets = new Map<string, NativeCursorAsset>();
|
||||
private process: ChildProcessByStdio<null, Readable, Readable> | null = null;
|
||||
private lineBuffer = "";
|
||||
private startTimeMs = 0;
|
||||
@@ -179,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;
|
||||
|
||||
@@ -187,6 +201,7 @@ export class MacNativeCursorRecordingSession implements CursorRecordingSession {
|
||||
|
||||
async start(): Promise<void> {
|
||||
this.samples = [];
|
||||
this.assets.clear();
|
||||
this.lineBuffer = "";
|
||||
this.startTimeMs = this.options.startTimeMs ?? Date.now();
|
||||
this.previousLeftButtonDown = false;
|
||||
@@ -195,7 +210,8 @@ export class MacNativeCursorRecordingSession implements CursorRecordingSession {
|
||||
try {
|
||||
systemPreferences.isTrustedAccessibilityClient(true);
|
||||
} catch {
|
||||
// Link cursor detection degrades to arrow when Accessibility is unavailable.
|
||||
// Without Accessibility, text/pointer affordance detection is unavailable;
|
||||
// bitmaps are still captured natively via NSCursor.
|
||||
}
|
||||
|
||||
const helperPath = findMacCursorHelperPath();
|
||||
@@ -263,19 +279,38 @@ export class MacNativeCursorRecordingSession implements CursorRecordingSession {
|
||||
|
||||
return {
|
||||
version: 2,
|
||||
provider: "none",
|
||||
provider: this.assets.size > 0 ? "native" : "none",
|
||||
samples: this.samples,
|
||||
assets: [],
|
||||
assets: [...this.assets.values()],
|
||||
};
|
||||
}
|
||||
|
||||
private startPositionOnlyFallback() {
|
||||
this.captureSample(Date.now(), null, false, false, false);
|
||||
this.captureSample(Date.now(), null, null, false, false, false);
|
||||
this.fallbackInterval = setInterval(() => {
|
||||
this.captureSample(Date.now(), null, false, false, false);
|
||||
this.captureSample(Date.now(), null, null, false, false, false);
|
||||
}, this.options.sampleIntervalMs);
|
||||
}
|
||||
|
||||
private rememberAsset(asset: MacCursorAssetPayload | null | undefined) {
|
||||
if (!asset?.id || this.assets.has(asset.id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cursor = screen.getCursorScreenPoint();
|
||||
const displayScaleFactor = screen.getDisplayNearestPoint(cursor).scaleFactor;
|
||||
this.assets.set(asset.id, {
|
||||
id: asset.id,
|
||||
platform: "darwin",
|
||||
imageDataUrl: asset.imageDataUrl,
|
||||
width: asset.width,
|
||||
height: asset.height,
|
||||
hotspotX: asset.hotspotX,
|
||||
hotspotY: asset.hotspotY,
|
||||
scaleFactor: asset.scaleFactor ?? displayScaleFactor,
|
||||
});
|
||||
}
|
||||
|
||||
private handleStdoutChunk(chunk: string) {
|
||||
this.lineBuffer += chunk;
|
||||
const lines = this.lineBuffer.split(/\r?\n/);
|
||||
@@ -299,7 +334,7 @@ export class MacNativeCursorRecordingSession implements CursorRecordingSession {
|
||||
if (payload.type === "ready") {
|
||||
if (payload.accessibilityTrusted === false) {
|
||||
console.warn(
|
||||
"[cursor-macos] Accessibility is not trusted; cursor shape detection will be arrow-only.",
|
||||
"[cursor-macos] Accessibility is not trusted; text/pointer affordance detection disabled (bitmap capture still active).",
|
||||
);
|
||||
}
|
||||
this.resolveReady();
|
||||
@@ -307,9 +342,11 @@ export class MacNativeCursorRecordingSession implements CursorRecordingSession {
|
||||
}
|
||||
|
||||
if (payload.type === "sample") {
|
||||
this.rememberAsset(payload.asset);
|
||||
this.captureSample(
|
||||
payload.timestampMs,
|
||||
normalizeCursorType(payload.cursorType),
|
||||
payload.assetId ?? null,
|
||||
payload.leftButtonDown === true,
|
||||
payload.leftButtonPressed === true,
|
||||
payload.leftButtonReleased === true,
|
||||
@@ -320,6 +357,7 @@ export class MacNativeCursorRecordingSession implements CursorRecordingSession {
|
||||
private captureSample(
|
||||
timestampMs: number,
|
||||
cursorType: NativeCursorType | null,
|
||||
assetId: string | null,
|
||||
leftButtonDown: boolean,
|
||||
leftButtonPressed: boolean,
|
||||
leftButtonReleased: boolean,
|
||||
@@ -332,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 samples) are handled by
|
||||
// clip-path — the cursor clips to the canvas edge instead of snapping invisible.
|
||||
// Sustained exits (≥THRESHOLD samples, ≈100ms) mark visible=false to prevent
|
||||
// ghost cursors and motion trails from multi-display movement.
|
||||
// Brief exits (under THRESHOLD samples) clip to the canvas edge via clip-path instead
|
||||
// of snapping invisible. Sustained exits (>=THRESHOLD, ~100ms) mark visible=false to
|
||||
// avoid ghost cursors and motion trails from multi-display movement.
|
||||
if (isOutsideDisplay) {
|
||||
this.consecutiveOutsideSamples++;
|
||||
} else {
|
||||
@@ -357,6 +394,7 @@ export class MacNativeCursorRecordingSession implements CursorRecordingSession {
|
||||
cy: clamp(normalizedY, 0, 1),
|
||||
visible,
|
||||
interactionType,
|
||||
...(assetId ? { assetId } : {}),
|
||||
...(cursorType ? { cursorType } : {}),
|
||||
});
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -14,8 +14,9 @@ interface ProjectServiceOptions {
|
||||
suggestedName?: string,
|
||||
existingProjectPath?: string,
|
||||
) => Promise<ProjectFileResult>;
|
||||
loadProjectFile: () => Promise<ProjectFileResult>;
|
||||
loadProjectFile: (projectFolder?: string) => Promise<ProjectFileResult>;
|
||||
loadCurrentProjectFile: () => Promise<ProjectFileResult>;
|
||||
loadProjectFileFromPath: (path: string) => Promise<ProjectFileResult>;
|
||||
setCurrentVideoPath: (path: string) => ProjectPathResult | Promise<ProjectPathResult>;
|
||||
getCurrentVideoPathResult: () => ProjectPathResult;
|
||||
clearCurrentVideoPath: () => ProjectPathResult;
|
||||
@@ -48,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;
|
||||
}
|
||||
@@ -60,6 +61,12 @@ export class ProjectService {
|
||||
return result;
|
||||
}
|
||||
|
||||
async loadProjectFileFromPath(path: string) {
|
||||
const result = await this.options.loadProjectFileFromPath(path);
|
||||
this.getCurrentContext();
|
||||
return result;
|
||||
}
|
||||
|
||||
async setCurrentVideoPath(path: string) {
|
||||
const result = await this.options.setCurrentVideoPath(path);
|
||||
this.getCurrentContext();
|
||||
|
||||
@@ -1,11 +1,22 @@
|
||||
import AppKit
|
||||
import ApplicationServices
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
|
||||
struct CursorHelperRequest: Decodable {
|
||||
let sampleIntervalMs: Int?
|
||||
}
|
||||
|
||||
struct CapturedCursorAsset {
|
||||
let id: String
|
||||
let imageDataUrl: String
|
||||
let width: Int
|
||||
let height: Int
|
||||
let hotspotX: Double
|
||||
let hotspotY: Double
|
||||
let scaleFactor: Double
|
||||
}
|
||||
|
||||
final class MouseButtonTracker {
|
||||
private let lock = NSLock()
|
||||
private var leftDownCount = 0
|
||||
@@ -211,10 +222,60 @@ func currentCursorType() -> String? {
|
||||
)
|
||||
|
||||
guard result == .success, let element else {
|
||||
return "arrow"
|
||||
return nil
|
||||
}
|
||||
|
||||
return cursorTypeForElement(element) ?? "arrow"
|
||||
// Returns nil for anything that is not a text/pointer affordance so the
|
||||
// renderer falls through to the natively captured cursor bitmap (this is
|
||||
// what makes default and custom cursors render as their real images).
|
||||
return cursorTypeForElement(element)
|
||||
}
|
||||
|
||||
func currentCursorAsset() -> CapturedCursorAsset? {
|
||||
guard let cursor = NSCursor.currentSystem ?? NSCursor.current as NSCursor? else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let image = cursor.image
|
||||
let pointSize = image.size
|
||||
guard pointSize.width > 0, pointSize.height > 0 else {
|
||||
return nil
|
||||
}
|
||||
|
||||
var proposedRect = NSRect(origin: .zero, size: pointSize)
|
||||
guard let cgImage = image.cgImage(forProposedRect: &proposedRect, context: nil, hints: nil) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let bitmap = NSBitmapImageRep(cgImage: cgImage)
|
||||
guard let png = bitmap.representation(using: .png, properties: [:]) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let pixelsWide = bitmap.pixelsWide
|
||||
let pixelsHigh = bitmap.pixelsHigh
|
||||
guard pixelsWide > 0, pixelsHigh > 0 else {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Intrinsic backing scale of the cursor image (e.g. 2.0 on Retina). The
|
||||
// renderer divides pixel dimensions/hotspot by this to recover point sizes.
|
||||
let scaleFactor = Double(pixelsWide) / Double(pointSize.width)
|
||||
let hotSpot = cursor.hotSpot
|
||||
|
||||
let digest = SHA256.hash(data: png)
|
||||
let id = digest.map { String(format: "%02x", $0) }.joined()
|
||||
let imageDataUrl = "data:image/png;base64,\(png.base64EncodedString())"
|
||||
|
||||
return CapturedCursorAsset(
|
||||
id: id,
|
||||
imageDataUrl: imageDataUrl,
|
||||
width: pixelsWide,
|
||||
height: pixelsHigh,
|
||||
hotspotX: hotSpot.x * scaleFactor,
|
||||
hotspotY: hotSpot.y * scaleFactor,
|
||||
scaleFactor: scaleFactor
|
||||
)
|
||||
}
|
||||
|
||||
func timestampMs() -> Int {
|
||||
@@ -253,16 +314,39 @@ emit([
|
||||
"mouseTapReady": mouseTapReady,
|
||||
])
|
||||
|
||||
// Process-wide set so each unique cursor shape is serialised at most once,
|
||||
// even if the user alternates between shapes (e.g. arrow → text → arrow).
|
||||
var emittedAssetIds = Set<String>()
|
||||
|
||||
while true {
|
||||
mouseTracker.pump()
|
||||
let mouseEvents = mouseTracker.consume()
|
||||
emit([
|
||||
"type": "sample",
|
||||
"timestampMs": timestampMs(),
|
||||
"cursorType": currentCursorType(),
|
||||
"leftButtonDown": leftButtonDown(),
|
||||
"leftButtonPressed": mouseEvents.leftDownCount > 0,
|
||||
"leftButtonReleased": mouseEvents.leftUpCount > 0,
|
||||
])
|
||||
Thread.sleep(forTimeInterval: Double(intervalMs) / 1000.0)
|
||||
autoreleasepool {
|
||||
mouseTracker.pump()
|
||||
let mouseEvents = mouseTracker.consume()
|
||||
let asset = currentCursorAsset()
|
||||
// Only ship the (large) base64 payload the first time a cursor shape is seen;
|
||||
// subsequent samples reference it by assetId so stdout stays small.
|
||||
var assetPayload: [String: Any]?
|
||||
if let asset, emittedAssetIds.insert(asset.id).inserted {
|
||||
assetPayload = [
|
||||
"id": asset.id,
|
||||
"imageDataUrl": asset.imageDataUrl,
|
||||
"width": asset.width,
|
||||
"height": asset.height,
|
||||
"hotspotX": asset.hotspotX,
|
||||
"hotspotY": asset.hotspotY,
|
||||
"scaleFactor": asset.scaleFactor,
|
||||
]
|
||||
}
|
||||
emit([
|
||||
"type": "sample",
|
||||
"timestampMs": timestampMs(),
|
||||
"cursorType": currentCursorType(),
|
||||
"assetId": asset?.id,
|
||||
"asset": assetPayload,
|
||||
"leftButtonDown": leftButtonDown(),
|
||||
"leftButtonPressed": mouseEvents.leftDownCount > 0,
|
||||
"leftButtonReleased": mouseEvents.leftUpCount > 0,
|
||||
])
|
||||
Thread.sleep(forTimeInterval: Double(intervalMs) / 1000.0)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ target_compile_definitions(wgc-capture PRIVATE
|
||||
_WIN32_WINNT=0x0A00
|
||||
)
|
||||
|
||||
target_compile_options(wgc-capture PRIVATE /EHsc /W4 /utf-8)
|
||||
target_compile_options(wgc-capture PRIVATE /EHsc /W4 /utf-8 /await)
|
||||
|
||||
target_link_libraries(wgc-capture PRIVATE
|
||||
d3d11
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { contextBridge, ipcRenderer } from "electron";
|
||||
import { contextBridge, ipcRenderer, webUtils } from "electron";
|
||||
import type { NativeMacRecordingRequest } from "../src/lib/nativeMacRecording";
|
||||
import type { NativeWindowsRecordingRequest } from "../src/lib/nativeWindowsRecording";
|
||||
import type { RecordingSession, StoreRecordedSessionInput } from "../src/lib/recordingSession";
|
||||
import type { ShortcutBinding } from "../src/lib/shortcuts";
|
||||
import { NATIVE_BRIDGE_CHANNEL, type NativeBridgeRequest } from "../src/native/contracts";
|
||||
|
||||
// Asset base URL is passed from the main process via webPreferences.additionalArguments
|
||||
@@ -28,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);
|
||||
},
|
||||
@@ -64,6 +68,15 @@ contextBridge.exposeInMainWorld("electronAPI", {
|
||||
storeRecordedSession: (payload: StoreRecordedSessionInput) => {
|
||||
return ipcRenderer.invoke("store-recorded-session", payload);
|
||||
},
|
||||
openRecordingStream: (fileName: string) => {
|
||||
return ipcRenderer.invoke("open-recording-stream", fileName);
|
||||
},
|
||||
appendRecordingChunk: (fileName: string, chunk: ArrayBuffer) => {
|
||||
return ipcRenderer.invoke("append-recording-chunk", fileName, chunk);
|
||||
},
|
||||
closeRecordingStream: (fileName: string) => {
|
||||
return ipcRenderer.invoke("close-recording-stream", fileName);
|
||||
},
|
||||
|
||||
getRecordedVideoPath: () => {
|
||||
return ipcRenderer.invoke("get-recorded-video-path");
|
||||
@@ -160,12 +173,32 @@ 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);
|
||||
},
|
||||
getPathForFile: (file: File) => {
|
||||
try {
|
||||
return webUtils.getPathForFile(file);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
},
|
||||
loadCurrentProjectFile: () => {
|
||||
return ipcRenderer.invoke("load-current-project-file");
|
||||
},
|
||||
onMenuNewProject: (callback: () => void) => {
|
||||
const listener = () => callback();
|
||||
ipcRenderer.on("menu-new-project", listener);
|
||||
return () => ipcRenderer.removeListener("menu-new-project", listener);
|
||||
},
|
||||
onMenuImportVideo: (callback: () => void) => {
|
||||
const listener = () => callback();
|
||||
ipcRenderer.on("menu-import-video", listener);
|
||||
return () => ipcRenderer.removeListener("menu-import-video", listener);
|
||||
},
|
||||
onMenuLoadProject: (callback: () => void) => {
|
||||
const listener = () => callback();
|
||||
ipcRenderer.on("menu-load-project", listener);
|
||||
@@ -193,6 +226,9 @@ contextBridge.exposeInMainWorld("electronAPI", {
|
||||
saveShortcuts: (shortcuts: unknown) => {
|
||||
return ipcRenderer.invoke("save-shortcuts", shortcuts);
|
||||
},
|
||||
updateGlobalShortcut: (binding: ShortcutBinding) => {
|
||||
return ipcRenderer.invoke("update-global-shortcut", binding);
|
||||
},
|
||||
setLocale: (locale: string) => {
|
||||
return ipcRenderer.invoke("set-locale", locale);
|
||||
},
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import fs from "node:fs/promises";
|
||||
import { fixParsedWebmDuration } from "@fix-webm-duration/fix";
|
||||
import { WebmFile } from "@fix-webm-duration/parser";
|
||||
|
||||
export type DurationPatchResult =
|
||||
| { patched: true }
|
||||
| { patched: false; reason: "no-section" | "already-valid" | "io-error" | "internal" };
|
||||
|
||||
/**
|
||||
* Patch the WebM Duration header on a finalized recording file.
|
||||
*
|
||||
* MediaRecorder writes WebM with no Duration EBML element, and the streaming-to-disk
|
||||
* path never holds the blob so the old `fixWebmDuration(blob, durationMs)` can't run.
|
||||
* Patching on disk after `WriteStream.end()` gives the editor a real duration instead of `N/A`.
|
||||
*
|
||||
* Atomic: writes to `<filePath>.duration-patch.tmp` and renames in place, so a mid-rewrite
|
||||
* crash leaves the original intact. Best-effort: any read/parse/write failure logs and returns
|
||||
* a non-`patched` result rather than throwing; the file still plays without the patch (decoders
|
||||
* walk frames sequentially), only the seek bar and timeline break.
|
||||
*
|
||||
* Reads the whole file into a main-process Buffer, off the renderer so it dodges V8's heap cap.
|
||||
*/
|
||||
export async function patchWebmDurationOnDisk(
|
||||
filePath: string,
|
||||
durationMs: number,
|
||||
): Promise<DurationPatchResult> {
|
||||
try {
|
||||
const fileBytes = await fs.readFile(filePath);
|
||||
const webm = new WebmFile(new Uint8Array(fileBytes));
|
||||
|
||||
const patched = fixParsedWebmDuration(webm, durationMs, { logger: false });
|
||||
if (!patched) {
|
||||
// false means missing Segment, missing Info, or an already-valid Duration.
|
||||
// The first two mean a malformed (likely truncated) file; the third is a no-op.
|
||||
const reason = inferUnpatchedReason(webm);
|
||||
if (reason === "no-section") {
|
||||
console.warn(
|
||||
`[webm-duration] no Segment/Info section in ${filePath}; file may be truncated`,
|
||||
);
|
||||
}
|
||||
return { patched: false, reason };
|
||||
}
|
||||
|
||||
if (!webm.source) {
|
||||
console.error(`[webm-duration] patched but source missing for ${filePath}`);
|
||||
return { patched: false, reason: "internal" };
|
||||
}
|
||||
|
||||
const tmpPath = `${filePath}.duration-patch.tmp`;
|
||||
const patchedBytes = Buffer.from(
|
||||
webm.source.buffer,
|
||||
webm.source.byteOffset,
|
||||
webm.source.byteLength,
|
||||
);
|
||||
try {
|
||||
await fs.writeFile(tmpPath, patchedBytes);
|
||||
await fs.rename(tmpPath, filePath);
|
||||
return { patched: true };
|
||||
} catch (writeError) {
|
||||
console.error(`[webm-duration] failed to write patched ${filePath}:`, writeError);
|
||||
// Clean up the temp file; the original is untouched since the rename never ran.
|
||||
await fs.unlink(tmpPath).catch(() => undefined);
|
||||
return { patched: false, reason: "io-error" };
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[webm-duration] failed to patch ${filePath}:`, error);
|
||||
return { patched: false, reason: "io-error" };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Distinguish "no Segment/Info section" (malformed/truncated file) from "Info present
|
||||
* but Duration already valid" (patch unnecessary).
|
||||
*
|
||||
* The IDs are the length-descriptor-stripped form @fix-webm-duration/parser uses as lookup
|
||||
* keys (Segment `0x8538067`, Info `0x549a966`), per the parser's `src/lib/sections.js`, not
|
||||
* the canonical 4-byte EBML IDs (`0x18538067` / `0x1549A966`) that `getSectionById` never matches.
|
||||
*/
|
||||
function inferUnpatchedReason(webm: WebmFile): "no-section" | "already-valid" {
|
||||
const segment = webm.getSectionById?.(0x8538067);
|
||||
if (!segment) return "no-section";
|
||||
const info = (
|
||||
segment as unknown as { getSectionById?: (id: number) => unknown }
|
||||
).getSectionById?.(0x549a966);
|
||||
return info ? "already-valid" : "no-section";
|
||||
}
|
||||
@@ -10,7 +10,7 @@ const RENDERER_DIST = path.join(APP_ROOT, "dist");
|
||||
const HEADLESS = process.env["HEADLESS"] === "true";
|
||||
|
||||
// Asset base URL for renderer (wallpapers, etc.). Packaged: extraResources copies
|
||||
// public/wallpapers -> resources/wallpapers. Unpackaged: <appRoot>/public/.
|
||||
// public/wallpapers to resources/wallpapers. Unpackaged: <appRoot>/public/.
|
||||
const ASSET_BASE_DIR = process.defaultApp
|
||||
? path.join(__dirname, "..", "public")
|
||||
: process.resourcesPath;
|
||||
@@ -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,19 +97,25 @@ export function createHudOverlayWindow(): BrowserWindow {
|
||||
const win = new BrowserWindow({
|
||||
width: windowWidth,
|
||||
height: windowHeight,
|
||||
minWidth: 600,
|
||||
maxWidth: 600,
|
||||
minHeight: 160,
|
||||
maxHeight: 160,
|
||||
// Min/max are intentionally loose: the renderer resizes to fit content via
|
||||
// "hud-overlay-set-size" (above), needed for the vertical tray to grow taller.
|
||||
minWidth: 120,
|
||||
minHeight: 80,
|
||||
x: x,
|
||||
y: y,
|
||||
frame: false,
|
||||
transparent: true,
|
||||
// Fully-transparent ARGB backing. Without this macOS draws the window as a
|
||||
// rounded glass panel with a border around the HUD content.
|
||||
backgroundColor: "#00000000",
|
||||
// Don't let macOS mask the window into a rounded rect; the HUD bar provides
|
||||
// its own rounding and the window itself must be invisible.
|
||||
roundedCorners: false,
|
||||
resizable: false,
|
||||
alwaysOnTop: true,
|
||||
skipTaskbar: true,
|
||||
hasShadow: false,
|
||||
show: !HEADLESS,
|
||||
show: false, // shown via ready-to-show to avoid black rectangle flash
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, "preload.mjs"),
|
||||
additionalArguments: [ASSET_BASE_URL_ARG],
|
||||
@@ -85,12 +126,18 @@ 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 painted to avoid the black rectangle flash when a transparent
|
||||
// window is shown before its first paint.
|
||||
win.once("ready-to-show", () => {
|
||||
if (!HEADLESS) win.show();
|
||||
});
|
||||
|
||||
win.webContents.on("did-finish-load", () => {
|
||||
win?.webContents.send("main-process-message", new Date().toLocaleString());
|
||||
});
|
||||
@@ -115,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";
|
||||
@@ -135,8 +182,8 @@ export function createEditorWindow(): BrowserWindow {
|
||||
alwaysOnTop: false,
|
||||
skipTaskbar: false,
|
||||
title: "OpenScreen",
|
||||
backgroundColor: "#000000",
|
||||
show: !HEADLESS,
|
||||
backgroundColor: "#09090b",
|
||||
show: false, // shown via ready-to-show to avoid white flash on first load
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, "preload.mjs"),
|
||||
additionalArguments: [ASSET_BASE_URL_ARG],
|
||||
@@ -147,9 +194,21 @@ export function createEditorWindow(): BrowserWindow {
|
||||
},
|
||||
});
|
||||
|
||||
// Maximize the window by default
|
||||
win.maximize();
|
||||
|
||||
// Show only once painted to avoid a white flash on cold Vite start.
|
||||
win.once("ready-to-show", () => {
|
||||
if (!HEADLESS) win.show();
|
||||
});
|
||||
|
||||
// Inject dark background before any React paint so the sub-titlebar area never
|
||||
// flashes white on a cold Vite load.
|
||||
win.webContents.on("dom-ready", () => {
|
||||
win.webContents.insertCSS("html, body, #root { background: #09090b !important; }").catch(() => {
|
||||
// Best-effort cosmetic; ignore if the page is mid-teardown.
|
||||
});
|
||||
});
|
||||
|
||||
win.webContents.on("did-finish-load", () => {
|
||||
win?.webContents.send("main-process-message", new Date().toLocaleString());
|
||||
});
|
||||
@@ -166,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;
|
||||
@@ -192,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 });
|
||||
}
|
||||
@@ -210,8 +269,8 @@ export function createSourceSelectorWindow(): BrowserWindow {
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a centered transparent countdown overlay window that sits above the
|
||||
* HUD while recording pre-roll is running.
|
||||
* Centered transparent countdown overlay that sits above the HUD during
|
||||
* recording pre-roll.
|
||||
*/
|
||||
export function createCountdownOverlayWindow(): BrowserWindow {
|
||||
const { workArea } = screen.getPrimaryDisplay();
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<html lang="en" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title></title>
|
||||
</head>
|
||||
<body>
|
||||
<body style="background:#09090b;margin:0">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
buildNpmPackage {
|
||||
nodejs = nodejs_22;
|
||||
pname = "openscreen";
|
||||
version = "1.4.0";
|
||||
version = "1.5.0";
|
||||
|
||||
src =
|
||||
let
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "openscreen",
|
||||
"private": true,
|
||||
"version": "1.4.0",
|
||||
"version": "1.5.0",
|
||||
"type": "module",
|
||||
"packageManager": "npm@10.9.4",
|
||||
"engines": {
|
||||
@@ -63,6 +63,7 @@
|
||||
"@uiw/color-convert": "^2.10.1",
|
||||
"@uiw/react-color-block": "^2.10.1",
|
||||
"@uiw/react-color-colorful": "^2.9.2",
|
||||
"@xenova/transformers": "^2.17.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"dnd-timeline": "^2.4.0",
|
||||
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 484 B |
|
After Width: | Height: | Size: 490 B |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 7.9 KiB |
|
After Width: | Height: | Size: 8.7 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 7.1 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 6.7 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 7.9 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 6.0 KiB |
|
After Width: | Height: | Size: 8.6 KiB |
|
After Width: | Height: | Size: 6.1 KiB |
|
After Width: | Height: | Size: 8.1 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 2.7 MiB |
|
After Width: | Height: | Size: 2.5 MiB |
|
Before Width: | Height: | Size: 283 KiB After Width: | Height: | Size: 264 KiB |
@@ -0,0 +1,14 @@
|
||||
// electron-builder beforePack hook: ensure the auto-caption assets (Whisper model + ORT wasm) exist
|
||||
// before packaging, so the `caption-assets` extraResources entry has something to copy. Runs on
|
||||
// every package invocation (local `npm run build:*` and CI's bare `electron-builder`). The fetch
|
||||
// script is idempotent, so it's a no-op once the assets are present.
|
||||
|
||||
const { execFileSync } = require("node:child_process");
|
||||
const path = require("node:path");
|
||||
|
||||
exports.default = async function beforePack() {
|
||||
execFileSync("node", [path.join(__dirname, "fetch-caption-model.mjs")], {
|
||||
stdio: "inherit",
|
||||
cwd: path.join(__dirname, ".."),
|
||||
});
|
||||
};
|
||||
@@ -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-<arch> folder (the runtime resolves that folder by the running app's
|
||||
// arch). No universal/fat binary. Defaults to the host arch for local builds; CI sets
|
||||
// OPENSCREEN_MAC_HELPER_ARCHS per matrix entry (accepts arm64, x64, or x86_64).
|
||||
function normalizeArch(value) {
|
||||
return value === "x64" || value === "x86_64"
|
||||
? { swift: "x86_64", tag: "darwin-x64" }
|
||||
: { swift: "arm64", tag: "darwin-arm64" };
|
||||
}
|
||||
const hostArch = process.arch === "arm64" ? "arm64" : "x86_64";
|
||||
const archs = (process.env.OPENSCREEN_MAC_HELPER_ARCHS ?? hostArch)
|
||||
.split(",")
|
||||
.map((a) => a.trim())
|
||||
.filter(Boolean)
|
||||
.map(normalizeArch);
|
||||
|
||||
const xcodebuildVersion = spawnSync("xcodebuild", ["-version"], {
|
||||
cwd: root,
|
||||
@@ -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 <buildPath>/<swiftArch>-apple-macosx/release/<name>.
|
||||
// Fall back to a search that skips the identically-named file inside the .dSYM debug bundle (matching
|
||||
// that file and feeding it forward is what produced an unrunnable "exec format error" binary before).
|
||||
function findExecutable(dir, swiftArch, name) {
|
||||
const expected = path.join(dir, `${swiftArch}-apple-macosx`, "release", name);
|
||||
if (fs.existsSync(expected)) return expected;
|
||||
|
||||
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})`);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import { LaunchWindow } from "./components/launch/LaunchWindow";
|
||||
import { SourceSelector } from "./components/launch/SourceSelector";
|
||||
import { Toaster } from "./components/ui/sonner";
|
||||
import { TooltipProvider } from "./components/ui/tooltip";
|
||||
import { useScopedT } from "./contexts/I18nContext";
|
||||
import { ShortcutsProvider } from "./contexts/ShortcutsContext";
|
||||
import { loadAllCustomFonts } from "./lib/customFonts";
|
||||
|
||||
@@ -18,6 +19,7 @@ export default function App() {
|
||||
const [windowType, setWindowType] = useState(
|
||||
() => new URLSearchParams(window.location.search).get("windowType") || "",
|
||||
);
|
||||
const tEditor = useScopedT("editor");
|
||||
|
||||
useEffect(() => {
|
||||
const type = new URLSearchParams(window.location.search).get("windowType") || "";
|
||||
@@ -64,7 +66,35 @@ export default function App() {
|
||||
case "editor":
|
||||
return (
|
||||
<ShortcutsProvider>
|
||||
<Suspense fallback={<div className="h-screen bg-background" />}>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex flex-col items-center justify-center gap-3 h-screen bg-[#09090b]">
|
||||
<svg
|
||||
className="animate-spin text-[#34B27B]"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
width={28}
|
||||
height={28}
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
||||
/>
|
||||
</svg>
|
||||
<span className="text-white/50 text-sm">{tEditor("loadingEditor")}</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<VideoEditor />
|
||||
<ShortcutsConfigDialog />
|
||||
</Suspense>
|
||||
@@ -82,7 +112,7 @@ export default function App() {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
{content}
|
||||
<Toaster theme="dark" className="pointer-events-auto" />
|
||||
<Toaster theme="dark" />
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Check, ChevronDown, Languages } from "lucide-react";
|
||||
import { Check, ChevronDown, Clapperboard, Columns3, Languages, Rows3 } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { BsPauseCircle, BsPlayCircle, BsRecordCircle } from "react-icons/bs";
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
import { RxDragHandleDots2 } from "react-icons/rx";
|
||||
import { useI18n, useScopedT } from "@/contexts/I18nContext";
|
||||
import { getAvailableLocales, getLocaleName } from "@/i18n/loader";
|
||||
import { loadUserPreferences, saveUserPreferences } from "@/lib/userPreferences";
|
||||
import { nativeBridgeClient } from "@/native";
|
||||
import { useAudioLevelMeter } from "../../hooks/useAudioLevelMeter";
|
||||
import { useCameraDevices } from "../../hooks/useCameraDevices";
|
||||
@@ -32,9 +33,15 @@ import { AudioLevelMeter } from "../ui/audio-level-meter";
|
||||
import { Button } from "../ui/button";
|
||||
import { Tooltip } from "../ui/tooltip";
|
||||
import styles from "./LaunchWindow.module.css";
|
||||
import { openSourceSelectorWithPermissionRetry } from "./openSourceSelectorFlow";
|
||||
|
||||
const ICON_SIZE = 20;
|
||||
|
||||
// Vertical tray gap (px): bar's `bottom-5` (20px) plus an 8px gap.
|
||||
const HUD_DEVICE_POPUP_GAP = 28;
|
||||
// Horizontal layout: mirrors the `bottom-[68px]` class on the popup element.
|
||||
const HUD_DEVICE_POPUP_HORIZONTAL_BOTTOM = 68;
|
||||
|
||||
const ICON_CONFIG = {
|
||||
drag: { icon: RxDragHandleDots2, size: ICON_SIZE },
|
||||
monitor: { icon: MdMonitor, size: ICON_SIZE },
|
||||
@@ -59,6 +66,7 @@ const ICON_CONFIG = {
|
||||
|
||||
type IconName = keyof typeof ICON_CONFIG;
|
||||
|
||||
/** Renders the configured icon for a HUD control. */
|
||||
function getIcon(name: IconName, className?: string) {
|
||||
const { icon: Icon, size } = ICON_CONFIG[name];
|
||||
return <Icon size={size} className={className} />;
|
||||
@@ -77,7 +85,10 @@ const windowBtnClasses =
|
||||
"flex h-8 w-8 items-center justify-center rounded-lg transition-all duration-150 cursor-pointer opacity-50 hover:opacity-90 hover:bg-white/[0.08]";
|
||||
|
||||
const hudSidebarClasses = "ml-0.5 pl-1.5 border-l border-white/10 flex items-center gap-0.5";
|
||||
const hudSidebarVerticalClasses =
|
||||
"mt-0.5 pt-1.5 border-t border-white/10 flex flex-col items-center gap-0.5";
|
||||
|
||||
/** Launches the floating recording HUD and its recorder controls. */
|
||||
export function LaunchWindow() {
|
||||
const t = useScopedT("launch");
|
||||
const availableLocales = getAvailableLocales();
|
||||
@@ -128,9 +139,16 @@ export function LaunchWindow() {
|
||||
const [isWebcamFocused, setIsWebcamFocused] = useState(false);
|
||||
const webcamExpanded = isWebcamHovered || isWebcamFocused;
|
||||
const [isLanguageMenuOpen, setIsLanguageMenuOpen] = useState(false);
|
||||
const [trayLayout, setTrayLayout] = useState<"horizontal" | "vertical">(
|
||||
() => loadUserPreferences().trayLayout,
|
||||
);
|
||||
const [supportsCursorModeToggle, setSupportsCursorModeToggle] = useState(false);
|
||||
const languageTriggerRef = useRef<HTMLButtonElement | null>(null);
|
||||
const languageMenuPanelRef = useRef<HTMLDivElement | null>(null);
|
||||
const hudBarRef = useRef<HTMLDivElement | null>(null);
|
||||
const deviceSelectorRef = useRef<HTMLDivElement | null>(null);
|
||||
// Measured bar height, anchors the popups above the tall vertical tray so they don't overlap it.
|
||||
const [hudBarHeight, setHudBarHeight] = useState(0);
|
||||
const [languageMenuStyle, setLanguageMenuStyle] = useState<{
|
||||
right: number;
|
||||
top: number;
|
||||
@@ -282,6 +300,106 @@ export function LaunchWindow() {
|
||||
return () => cancelAnimationFrame(id);
|
||||
}, [isLanguageMenuOpen]);
|
||||
|
||||
// Resize the overlay window to fit content, else the taller vertical tray gets clipped
|
||||
// and scrolls. Measure from the window's bottom-centre (the anchor the main process
|
||||
// preserves) so fixed bottom/centre offsets keep this stable and it doesn't oscillate.
|
||||
const lastHudSizeRef = useRef({ width: 0, height: 0 });
|
||||
const measureHudSize = useCallback(() => {
|
||||
const barEl = hudBarRef.current;
|
||||
if (!barEl || !window.electronAPI?.setHudOverlaySize) return;
|
||||
|
||||
// Breathing room so the drop shadow isn't clipped. TOP_MARGIN must also exceed the
|
||||
// slack in the bar's `max-h: calc(100vh - 2.5rem)` cap (40px reserved - 20px bottom
|
||||
// gap = 20px) so the window stays tall enough that the cap never engages and adds a scrollbar.
|
||||
const SIDE_MARGIN = 24;
|
||||
const TOP_MARGIN = 24;
|
||||
// Wide enough that the language menu (11rem) never clips, even when the bar is narrow.
|
||||
const MIN_WIDTH = 220;
|
||||
|
||||
const viewportHeight = window.innerHeight;
|
||||
const centerX = window.innerWidth / 2;
|
||||
|
||||
// Use natural (scroll) size, not the clipped box: vertical mode's max-h cap is a
|
||||
// small-screen fallback, and reading clipped height would pin the window to it.
|
||||
// scrollHeight gives full content height; the cap only engages when the main process clamps to screen.
|
||||
let topFromBottom = viewportHeight - barEl.getBoundingClientRect().bottom + barEl.scrollHeight;
|
||||
let halfWidth = barEl.scrollWidth / 2;
|
||||
|
||||
// Popups drive both dimensions too. Their vertical anchor depends on bar height,
|
||||
// which is fed back through React state and lags by a frame, so derive their top
|
||||
// edge from the bar's natural height instead of the stale rendered position. Keeps
|
||||
// one measurement pass authoritative and avoids a feedback re-measure.
|
||||
if (deviceSelectorRef.current) {
|
||||
const rect = deviceSelectorRef.current.getBoundingClientRect();
|
||||
if (rect.width !== 0 || rect.height !== 0) {
|
||||
const popupBottomOffset =
|
||||
trayLayout === "vertical"
|
||||
? barEl.scrollHeight + HUD_DEVICE_POPUP_GAP
|
||||
: HUD_DEVICE_POPUP_HORIZONTAL_BOTTOM;
|
||||
topFromBottom = Math.max(topFromBottom, popupBottomOffset + rect.height);
|
||||
halfWidth = Math.max(halfWidth, rect.width / 2);
|
||||
}
|
||||
}
|
||||
|
||||
// The language menu scrolls within available height, so it only influences width.
|
||||
// Its presence in the DOM means it's open.
|
||||
if (languageMenuPanelRef.current) {
|
||||
const rect = languageMenuPanelRef.current.getBoundingClientRect();
|
||||
halfWidth = Math.max(halfWidth, centerX - rect.left, rect.right - centerX);
|
||||
}
|
||||
|
||||
setHudBarHeight((prev) => {
|
||||
const next = Math.round(barEl.scrollHeight);
|
||||
return Math.abs(prev - next) > 1 ? next : prev;
|
||||
});
|
||||
|
||||
const width = Math.max(MIN_WIDTH, Math.ceil(halfWidth * 2) + SIDE_MARGIN);
|
||||
const height = Math.ceil(topFromBottom) + TOP_MARGIN;
|
||||
if (width === lastHudSizeRef.current.width && height === lastHudSizeRef.current.height) {
|
||||
return;
|
||||
}
|
||||
lastHudSizeRef.current = { width, height };
|
||||
window.electronAPI.setHudOverlaySize(width, height);
|
||||
}, [trayLayout]);
|
||||
|
||||
// One persistent observer; elements wire themselves up via callback refs as they
|
||||
// mount/unmount so measurement re-runs without recreating it or threading mount state through deps.
|
||||
const hudResizeObserverRef = useRef<ResizeObserver | null>(null);
|
||||
useEffect(() => {
|
||||
const observer = new ResizeObserver(() => measureHudSize());
|
||||
hudResizeObserverRef.current = observer;
|
||||
if (hudBarRef.current) observer.observe(hudBarRef.current);
|
||||
if (deviceSelectorRef.current) observer.observe(deviceSelectorRef.current);
|
||||
measureHudSize();
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
hudResizeObserverRef.current = null;
|
||||
};
|
||||
}, [measureHudSize]);
|
||||
|
||||
const observeHudElement = useCallback(
|
||||
<T extends HTMLElement>(el: T | null, ref: React.MutableRefObject<T | null>) => {
|
||||
const observer = hudResizeObserverRef.current;
|
||||
if (ref.current && observer) observer.unobserve(ref.current);
|
||||
ref.current = el;
|
||||
if (el && observer) observer.observe(el);
|
||||
measureHudSize();
|
||||
},
|
||||
[measureHudSize],
|
||||
);
|
||||
const setHudBarEl = useCallback(
|
||||
(el: HTMLDivElement | null) => observeHudElement(el, hudBarRef),
|
||||
[observeHudElement],
|
||||
);
|
||||
const setDeviceSelectorEl = useCallback(
|
||||
(el: HTMLDivElement | null) => observeHudElement(el, deviceSelectorRef),
|
||||
[observeHudElement],
|
||||
);
|
||||
const setLanguageMenuPanelEl = useCallback(
|
||||
(el: HTMLDivElement | null) => observeHudElement(el, languageMenuPanelRef),
|
||||
[observeHudElement],
|
||||
);
|
||||
|
||||
const hudMouseEventsEnabledRef = useRef<boolean | undefined>(undefined);
|
||||
const setHudMouseEventsEnabled = useCallback((enabled: boolean) => {
|
||||
if (hudMouseEventsEnabledRef.current === enabled) {
|
||||
@@ -326,35 +444,15 @@ export function LaunchWindow() {
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const openSourceSelector = () => {
|
||||
const openSourceSelector = async () => {
|
||||
if (window.electronAPI) {
|
||||
window.electronAPI.openSourceSelector();
|
||||
await openSourceSelectorWithPermissionRetry({
|
||||
openSourceSelector: () => window.electronAPI.openSourceSelector(),
|
||||
requestScreenAccess: () => window.electronAPI.requestScreenAccess(),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const openVideoFile = async () => {
|
||||
const result = await window.electronAPI.openVideoFilePicker();
|
||||
|
||||
if (result.canceled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.success && result.path) {
|
||||
const setVideoPathResult = await nativeBridgeClient.project.setCurrentVideoPath(result.path);
|
||||
if (!setVideoPathResult.success) {
|
||||
console.error("Failed to set current video path:", setVideoPathResult);
|
||||
return;
|
||||
}
|
||||
await window.electronAPI.switchToEditor();
|
||||
}
|
||||
};
|
||||
|
||||
const openProjectFile = async () => {
|
||||
const result = await nativeBridgeClient.project.loadProjectFile();
|
||||
if (result.canceled || !result.success) return;
|
||||
await window.electronAPI.switchToEditor();
|
||||
};
|
||||
|
||||
const sendHudOverlayHide = () => {
|
||||
if (window.electronAPI && window.electronAPI.hudOverlayHide) {
|
||||
window.electronAPI.hudOverlayHide();
|
||||
@@ -365,6 +463,12 @@ export function LaunchWindow() {
|
||||
window.electronAPI.hudOverlayClose();
|
||||
}
|
||||
};
|
||||
/** Switches the HUD between horizontal and vertical tray layouts. */
|
||||
const toggleTrayLayout = () => {
|
||||
const nextLayout = trayLayout === "horizontal" ? "vertical" : "horizontal";
|
||||
setTrayLayout(nextLayout);
|
||||
saveUserPreferences({ trayLayout: nextLayout });
|
||||
};
|
||||
|
||||
const toggleMicrophone = () => {
|
||||
if (!recording) {
|
||||
@@ -396,10 +500,8 @@ export function LaunchWindow() {
|
||||
};
|
||||
|
||||
return (
|
||||
// Root fills the HUD window only. Avoid w-screen/h-screen (100vw/100vh):
|
||||
// 100vw can exceed the inner layout width when scrollbars affect the
|
||||
// viewport (notably on Windows), causing a horizontal scrollbar once the
|
||||
// recording toolbar widened (issue #305).
|
||||
// Avoid w-screen/h-screen: 100vw can exceed the inner layout width when scrollbars
|
||||
// affect the viewport (Windows), causing a horizontal scrollbar (issue #305).
|
||||
<div
|
||||
className={`h-full w-full min-w-0 max-w-full overflow-x-hidden overflow-y-hidden bg-transparent ${styles.electronDrag}`}
|
||||
onPointerMove={(event) => {
|
||||
@@ -451,11 +553,19 @@ export function LaunchWindow() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Device selectors — fixed above HUD bar, viewport-relative, never clipped */}
|
||||
{/* Device selectors, fixed above HUD bar, viewport-relative, never clipped */}
|
||||
{(showMicControls || showWebcamControls) && (
|
||||
<div
|
||||
ref={setDeviceSelectorEl}
|
||||
data-hud-interactive="true"
|
||||
className={`fixed bottom-[68px] left-1/2 -translate-x-1/2 flex items-center gap-2 animate-mic-panel-in ${styles.electronNoDrag}`}
|
||||
className={`fixed left-1/2 -translate-x-1/2 flex items-center gap-2 animate-mic-panel-in ${trayLayout === "vertical" ? "" : "bottom-[68px]"} ${styles.electronNoDrag}`}
|
||||
style={
|
||||
trayLayout === "vertical"
|
||||
? // Sit above the tall vertical tray, anchored to the measured bar
|
||||
// height. Matches the offset in measureHudSize.
|
||||
{ bottom: hudBarHeight + HUD_DEVICE_POPUP_GAP }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{/* Mic selector */}
|
||||
{showMicControls && (
|
||||
@@ -586,10 +696,16 @@ export function LaunchWindow() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* HUD bar — fixed at bottom center, viewport-relative, never moves */}
|
||||
{/* HUD bar, fixed at bottom center, viewport-relative, never moves */}
|
||||
<div
|
||||
ref={setHudBarEl}
|
||||
data-hud-interactive="true"
|
||||
className={`fixed bottom-5 left-1/2 -translate-x-1/2 flex items-center gap-1.5 rounded-2xl border border-white/[0.10] bg-[#07080a]/90 px-2 py-1.5 shadow-[0_20px_60px_rgba(0,0,0,0.42),inset_0_1px_0_rgba(255,255,255,0.06)] backdrop-blur-2xl backdrop-saturate-[140%]`}
|
||||
data-tray-layout={trayLayout}
|
||||
className={`fixed bottom-5 left-1/2 -translate-x-1/2 flex rounded-2xl border border-white/[0.10] bg-[#07080a]/90 shadow-[0_20px_60px_rgba(0,0,0,0.42),inset_0_1px_0_rgba(255,255,255,0.06)] backdrop-blur-2xl backdrop-saturate-[140%] ${
|
||||
trayLayout === "vertical"
|
||||
? "max-h-[calc(100vh-2.5rem)] flex-col items-center gap-1 overflow-y-auto px-1 py-1.5"
|
||||
: "items-center gap-1.5 px-2 py-1.5"
|
||||
}`}
|
||||
onPointerEnter={() => setHudMouseEventsEnabled(true)}
|
||||
onPointerDown={() => setHudMouseEventsEnabled(true)}
|
||||
onMouseEnter={() => setHudMouseEventsEnabled(true)}
|
||||
@@ -601,7 +717,7 @@ export function LaunchWindow() {
|
||||
>
|
||||
{/* Drag handle */}
|
||||
<div
|
||||
className={`flex h-8 w-7 cursor-grab items-center justify-center active:cursor-grabbing ${styles.electronNoDrag}`}
|
||||
className={`flex ${trayLayout === "vertical" ? "h-6 w-8" : "h-8 w-7"} cursor-grab items-center justify-center active:cursor-grabbing ${styles.electronNoDrag}`}
|
||||
onPointerDown={handleHudDragPointerDown}
|
||||
onPointerMove={handleHudDragPointerMove}
|
||||
onPointerUp={handleHudDragPointerEnd}
|
||||
@@ -610,21 +726,54 @@ export function LaunchWindow() {
|
||||
{getIcon("drag", "text-white/30")}
|
||||
</div>
|
||||
|
||||
<Tooltip
|
||||
content={
|
||||
trayLayout === "horizontal"
|
||||
? t("tooltips.useVerticalTray")
|
||||
: t("tooltips.useHorizontalTray")
|
||||
}
|
||||
>
|
||||
<button
|
||||
data-testid="launch-tray-layout-button"
|
||||
type="button"
|
||||
aria-label={
|
||||
trayLayout === "horizontal"
|
||||
? t("tooltips.useVerticalTray")
|
||||
: t("tooltips.useHorizontalTray")
|
||||
}
|
||||
aria-pressed={trayLayout === "vertical"}
|
||||
className={`${hudIconBtnClasses} ${styles.electronNoDrag}`}
|
||||
onClick={toggleTrayLayout}
|
||||
>
|
||||
{trayLayout === "horizontal" ? (
|
||||
<Columns3 size={ICON_SIZE} className="text-white/60" />
|
||||
) : (
|
||||
<Rows3 size={ICON_SIZE} className="text-white/60" />
|
||||
)}
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
{/* Source selector */}
|
||||
<button
|
||||
className={`${hudGroupClasses} h-8 px-2.5 ${styles.electronNoDrag}`}
|
||||
data-testid="launch-source-selector-button"
|
||||
className={`${hudGroupClasses} h-8 ${trayLayout === "vertical" ? "w-8 justify-center px-0" : "px-2.5"} ${styles.electronNoDrag}`}
|
||||
onClick={openSourceSelector}
|
||||
disabled={recording}
|
||||
title={selectedSource}
|
||||
aria-label={selectedSource}
|
||||
>
|
||||
{getIcon("monitor", "text-white/80")}
|
||||
<span className="max-w-[86px] truncate text-[11px] font-medium text-white/75">
|
||||
<span
|
||||
className={`${trayLayout === "vertical" ? "sr-only" : "max-w-[86px]"} truncate text-[11px] font-medium text-white/75`}
|
||||
>
|
||||
{selectedSource}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Audio controls group */}
|
||||
<div className={`${hudGroupClasses} ${styles.electronNoDrag}`}>
|
||||
<div
|
||||
className={`${hudGroupClasses} ${trayLayout === "vertical" ? "flex-col py-1" : ""} ${styles.electronNoDrag}`}
|
||||
>
|
||||
<button
|
||||
data-testid="launch-system-audio-button"
|
||||
className={`${hudIconBtnClasses} ${systemAudioEnabled ? "drop-shadow-[0_0_4px_rgba(74,222,128,0.4)]" : ""}`}
|
||||
@@ -697,7 +846,7 @@ export function LaunchWindow() {
|
||||
{/* Record/Stop group */}
|
||||
<button
|
||||
data-testid="launch-record-button"
|
||||
className={`flex items-center justify-center rounded-full p-2 transition-[min-width,background-color] duration-150 ${recording ? "min-w-[78px]" : "min-w-[36px]"} ${styles.electronNoDrag} ${
|
||||
className={`flex items-center justify-center rounded-full p-2 transition-[min-width,background-color] duration-150 ${recording ? "min-w-[78px]" : "min-w-[36px]"} ${trayLayout === "vertical" ? "min-h-9" : ""} ${styles.electronNoDrag} ${
|
||||
recording
|
||||
? paused
|
||||
? "bg-amber-500/10 hover:bg-amber-500/15"
|
||||
@@ -723,7 +872,9 @@ export function LaunchWindow() {
|
||||
</button>
|
||||
|
||||
{recording && (
|
||||
<div className={`flex items-center gap-0.5 ${styles.electronNoDrag}`}>
|
||||
<div
|
||||
className={`flex items-center gap-0.5 ${trayLayout === "vertical" ? "flex-col" : ""} ${styles.electronNoDrag}`}
|
||||
>
|
||||
{canPauseRecording && (
|
||||
<Tooltip
|
||||
content={paused ? t("tooltips.resumeRecording") : t("tooltips.pauseRecording")}
|
||||
@@ -750,33 +901,21 @@ export function LaunchWindow() {
|
||||
)}
|
||||
|
||||
{!recording && (
|
||||
<>
|
||||
{/* Open video file */}
|
||||
<Tooltip content={t("tooltips.openVideoFile")}>
|
||||
<button
|
||||
data-testid="launch-open-video-button"
|
||||
className={`${hudIconBtnClasses} ${styles.electronNoDrag}`}
|
||||
onClick={openVideoFile}
|
||||
>
|
||||
{getIcon("videoFile", "text-white/60")}
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
{/* Open project */}
|
||||
<Tooltip content={t("tooltips.openProject")}>
|
||||
<button
|
||||
data-testid="launch-open-project-button"
|
||||
className={`${hudIconBtnClasses} ${styles.electronNoDrag}`}
|
||||
onClick={openProjectFile}
|
||||
>
|
||||
{getIcon("folder", "text-white/60")}
|
||||
</button>
|
||||
</Tooltip>
|
||||
</>
|
||||
<Tooltip content={t("tooltips.openStudio")}>
|
||||
<button
|
||||
data-testid="launch-open-studio-button"
|
||||
className={`${hudIconBtnClasses} ${styles.electronNoDrag}`}
|
||||
onClick={() => window.electronAPI.switchToEditor()}
|
||||
>
|
||||
<Clapperboard size={ICON_SIZE} className="text-white/60" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* Right sidebar controls */}
|
||||
<div className={`${hudSidebarClasses} ${styles.electronNoDrag}`}>
|
||||
<div
|
||||
className={`${trayLayout === "vertical" ? hudSidebarVerticalClasses : hudSidebarClasses} ${styles.electronNoDrag}`}
|
||||
>
|
||||
<div className={`${styles.languageMenuContainer} ${styles.electronNoDrag}`}>
|
||||
<button
|
||||
ref={languageTriggerRef}
|
||||
@@ -785,10 +924,15 @@ export function LaunchWindow() {
|
||||
aria-expanded={isLanguageMenuOpen}
|
||||
aria-haspopup="menu"
|
||||
onClick={() => setIsLanguageMenuOpen((open) => !open)}
|
||||
className={`flex h-8 items-center gap-1.5 rounded-lg border border-white/10 bg-white/[0.045] px-2 text-white/85 shadow-none transition-colors hover:bg-white/10 ${styles.electronNoDrag}`}
|
||||
title={activeLanguageLabel}
|
||||
className={`flex h-8 items-center rounded-lg border border-white/10 bg-white/[0.045] text-white/85 shadow-none transition-colors hover:bg-white/10 ${
|
||||
trayLayout === "vertical" ? "w-8 justify-center px-0" : "gap-1.5 px-2"
|
||||
} ${styles.electronNoDrag}`}
|
||||
>
|
||||
<Languages size={13} className="text-white/70" />
|
||||
<span className="max-w-[54px] truncate text-[10px] font-semibold text-white/75">
|
||||
<span
|
||||
className={`${trayLayout === "vertical" ? "sr-only" : "max-w-[54px]"} truncate text-[10px] font-semibold text-white/75`}
|
||||
>
|
||||
{activeLanguageLabel}
|
||||
</span>
|
||||
</button>
|
||||
@@ -797,7 +941,7 @@ export function LaunchWindow() {
|
||||
{isLanguageMenuOpen
|
||||
? createPortal(
|
||||
<div
|
||||
ref={languageMenuPanelRef}
|
||||
ref={setLanguageMenuPanelEl}
|
||||
data-hud-interactive="true"
|
||||
role="menu"
|
||||
className={`${styles.languageMenuPanel} ${styles.languageMenuScroll} ${styles.electronNoDrag}`}
|
||||
@@ -841,7 +985,9 @@ export function LaunchWindow() {
|
||||
: null}
|
||||
|
||||
{/* Window controls */}
|
||||
<div className="flex items-center gap-0.5">
|
||||
<div
|
||||
className={`flex items-center gap-0.5 ${trayLayout === "vertical" ? "flex-col" : ""}`}
|
||||
>
|
||||
<button
|
||||
className={windowBtnClasses}
|
||||
title={t("tooltips.hideHUD")}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import "@testing-library/jest-dom";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { SourceSelector } from "./SourceSelector";
|
||||
|
||||
vi.mock("@/contexts/I18nContext", () => ({
|
||||
useScopedT: (namespace: string) => {
|
||||
if (namespace === "common") {
|
||||
return (key: string) => {
|
||||
if (key === "actions.cancel") return "Cancel";
|
||||
if (key === "actions.share") return "Share";
|
||||
if (key === "actions.reload") return "Reload";
|
||||
return key;
|
||||
};
|
||||
}
|
||||
|
||||
return (key: string, vars?: Record<string, string>) => {
|
||||
if (key === "sourceSelector.loading") return "Loading sources...";
|
||||
if (key === "sourceSelector.emptyTitle") return "No screens or windows found";
|
||||
if (key === "sourceSelector.emptyDescription") {
|
||||
return "If you just granted screen recording permission, reload this picker. On macOS you may need to reopen OpenScreen.";
|
||||
}
|
||||
if (key === "sourceSelector.loadFailedDescription") {
|
||||
return "OpenScreen could not load capture sources. Reload this picker and try again.";
|
||||
}
|
||||
if (key === "sourceSelector.screens") return `Screens (${vars?.count ?? "0"})`;
|
||||
if (key === "sourceSelector.windows") return `Windows (${vars?.count ?? "0"})`;
|
||||
return key;
|
||||
};
|
||||
},
|
||||
}));
|
||||
|
||||
describe("SourceSelector", () => {
|
||||
beforeEach(() => {
|
||||
window.electronAPI = {
|
||||
...window.electronAPI,
|
||||
getSources: vi.fn().mockResolvedValue([]),
|
||||
selectSource: vi.fn(),
|
||||
} as typeof window.electronAPI;
|
||||
});
|
||||
|
||||
it("shows a retry state when no capture sources are available", async () => {
|
||||
render(<SourceSelector />);
|
||||
|
||||
await screen.findByText("No screens or windows found");
|
||||
expect(screen.getByRole("button", { name: "Reload" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("reloads capture sources from the empty state", async () => {
|
||||
const getSources = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
id: "screen:1:0",
|
||||
name: "Display 1",
|
||||
thumbnail: "data:image/png;base64,abc",
|
||||
display_id: "1",
|
||||
appIcon: null,
|
||||
},
|
||||
]);
|
||||
window.electronAPI = {
|
||||
...window.electronAPI,
|
||||
getSources,
|
||||
selectSource: vi.fn(),
|
||||
} as typeof window.electronAPI;
|
||||
|
||||
render(<SourceSelector />);
|
||||
|
||||
await screen.findByText("No screens or windows found");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Reload" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Display 1")).toBeInTheDocument();
|
||||
});
|
||||
expect(getSources).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { MdCheck } from "react-icons/md";
|
||||
import { useScopedT } from "@/contexts/I18nContext";
|
||||
import { Button } from "../ui/button";
|
||||
@@ -19,39 +19,49 @@ export function SourceSelector() {
|
||||
const [sources, setSources] = useState<DesktopSource[]>([]);
|
||||
const [selectedSource, setSelectedSource] = useState<DesktopSource | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadFailed, setLoadFailed] = useState(false);
|
||||
|
||||
const fetchSources = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setLoadFailed(false);
|
||||
try {
|
||||
const rawSources = await window.electronAPI.getSources({
|
||||
types: ["screen", "window"],
|
||||
thumbnailSize: { width: 320, height: 180 },
|
||||
fetchWindowIcons: true,
|
||||
});
|
||||
setSources(
|
||||
rawSources.map((source) => ({
|
||||
id: source.id,
|
||||
name:
|
||||
source.id.startsWith("window:") && source.name.includes(" — ")
|
||||
? source.name.split(" — ")[1] || source.name
|
||||
: source.name,
|
||||
thumbnail: source.thumbnail,
|
||||
display_id: source.display_id,
|
||||
appIcon: source.appIcon,
|
||||
})),
|
||||
);
|
||||
setSelectedSource((current) =>
|
||||
current && rawSources.some((source) => source.id === current.id) ? current : null,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Error loading sources:", error);
|
||||
setSources([]);
|
||||
setSelectedSource(null);
|
||||
setLoadFailed(true);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchSources() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const rawSources = await window.electronAPI.getSources({
|
||||
types: ["screen", "window"],
|
||||
thumbnailSize: { width: 320, height: 180 },
|
||||
fetchWindowIcons: true,
|
||||
});
|
||||
setSources(
|
||||
rawSources.map((source) => ({
|
||||
id: source.id,
|
||||
name:
|
||||
source.id.startsWith("window:") && source.name.includes(" — ")
|
||||
? source.name.split(" — ")[1] || source.name
|
||||
: source.name,
|
||||
thumbnail: source.thumbnail,
|
||||
display_id: source.display_id,
|
||||
appIcon: source.appIcon,
|
||||
})),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Error loading sources:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
fetchSources();
|
||||
}, []);
|
||||
void fetchSources();
|
||||
}, [fetchSources]);
|
||||
|
||||
const screenSources = sources.filter((s) => s.id.startsWith("screen:"));
|
||||
const windowSources = sources.filter((s) => s.id.startsWith("window:"));
|
||||
const hasNoSources = !loading && sources.length === 0;
|
||||
|
||||
const handleSourceSelect = (source: DesktopSource) => setSelectedSource(source);
|
||||
const handleShare = async () => {
|
||||
@@ -72,11 +82,38 @@ export function SourceSelector() {
|
||||
);
|
||||
}
|
||||
|
||||
if (hasNoSources) {
|
||||
return (
|
||||
<div
|
||||
className={`h-full flex items-center justify-center ${styles.glassContainer}`}
|
||||
style={{ minHeight: "100vh" }}
|
||||
>
|
||||
<div className="max-w-[320px] px-6 text-center">
|
||||
<h2 className="text-sm font-semibold text-white">{t("sourceSelector.emptyTitle")}</h2>
|
||||
<p className="mt-2 text-xs leading-5 text-zinc-400">
|
||||
{loadFailed
|
||||
? t("sourceSelector.loadFailedDescription")
|
||||
: t("sourceSelector.emptyDescription")}
|
||||
</p>
|
||||
<Button
|
||||
onClick={() => void fetchSources()}
|
||||
className="mt-4 h-8 rounded-lg bg-[#34B27B] px-5 text-[11px] font-semibold text-white transition-transform duration-150 hover:bg-[#34B27B]/85 active:scale-95"
|
||||
>
|
||||
{tc("actions.reload")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const renderSourceCard = (source: DesktopSource) => {
|
||||
const isSelected = selectedSource?.id === source.id;
|
||||
const sourceKind = source.id.startsWith("screen:") ? "screen" : "window";
|
||||
return (
|
||||
<div
|
||||
key={source.id}
|
||||
data-testid="source-selector-card"
|
||||
data-source-kind={sourceKind}
|
||||
className={`${styles.sourceCard} ${isSelected ? styles.selected : ""} p-1.5`}
|
||||
onClick={() => handleSourceSelect(source)}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { openSourceSelectorWithPermissionRetry } from "./openSourceSelectorFlow";
|
||||
|
||||
describe("openSourceSelectorWithPermissionRetry", () => {
|
||||
it("returns immediately when the source selector opens on the first attempt", async () => {
|
||||
const openSourceSelector = vi.fn().mockResolvedValue({ opened: true });
|
||||
const requestScreenAccess = vi.fn();
|
||||
|
||||
const result = await openSourceSelectorWithPermissionRetry({
|
||||
openSourceSelector,
|
||||
requestScreenAccess,
|
||||
wait: vi.fn(),
|
||||
});
|
||||
|
||||
expect(result).toEqual({ opened: true });
|
||||
expect(openSourceSelector).toHaveBeenCalledTimes(1);
|
||||
expect(requestScreenAccess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("retries opening after macOS screen permission becomes granted", async () => {
|
||||
const openSourceSelector = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
opened: false,
|
||||
reason: "screen-access-required",
|
||||
access: { success: true, granted: false, status: "not-determined" },
|
||||
})
|
||||
.mockResolvedValueOnce({ opened: true });
|
||||
const requestScreenAccess = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ success: true, granted: false, status: "not-determined" })
|
||||
.mockResolvedValueOnce({ success: true, granted: true, status: "granted" });
|
||||
const wait = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
const result = await openSourceSelectorWithPermissionRetry({
|
||||
openSourceSelector,
|
||||
requestScreenAccess,
|
||||
wait,
|
||||
maxAttempts: 4,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ opened: true });
|
||||
expect(wait).toHaveBeenCalledTimes(2);
|
||||
expect(requestScreenAccess).toHaveBeenCalledTimes(2);
|
||||
expect(openSourceSelector).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("stops retrying once macOS permission is explicitly denied", async () => {
|
||||
const openSourceSelector = vi.fn().mockResolvedValue({
|
||||
opened: false,
|
||||
reason: "screen-access-required",
|
||||
access: { success: true, granted: false, status: "not-determined" },
|
||||
});
|
||||
const requestScreenAccess = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ success: true, granted: false, status: "denied" });
|
||||
|
||||
const result = await openSourceSelectorWithPermissionRetry({
|
||||
openSourceSelector,
|
||||
requestScreenAccess,
|
||||
wait: vi.fn(),
|
||||
maxAttempts: 4,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
opened: false,
|
||||
reason: "screen-access-required",
|
||||
access: { success: true, granted: false, status: "denied" },
|
||||
});
|
||||
expect(requestScreenAccess).toHaveBeenCalledTimes(1);
|
||||
expect(openSourceSelector).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
export type ScreenAccessResult = {
|
||||
success: boolean;
|
||||
granted: boolean;
|
||||
status: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export type OpenSourceSelectorResult = {
|
||||
opened: boolean;
|
||||
reason?: string;
|
||||
access?: ScreenAccessResult;
|
||||
};
|
||||
|
||||
type OpenSourceSelectorFlowOptions = {
|
||||
openSourceSelector: () => Promise<OpenSourceSelectorResult>;
|
||||
requestScreenAccess: () => Promise<ScreenAccessResult>;
|
||||
wait?: (ms: number) => Promise<void>;
|
||||
retryDelayMs?: number;
|
||||
maxAttempts?: number;
|
||||
};
|
||||
|
||||
const defaultWait = (ms: number) => new Promise<void>((resolve) => window.setTimeout(resolve, ms));
|
||||
|
||||
function shouldRetryAfterPermissionPrompt(result: OpenSourceSelectorResult): boolean {
|
||||
return (
|
||||
result.opened === false &&
|
||||
result.reason === "screen-access-required" &&
|
||||
result.access?.status === "not-determined"
|
||||
);
|
||||
}
|
||||
|
||||
export async function openSourceSelectorWithPermissionRetry({
|
||||
openSourceSelector,
|
||||
requestScreenAccess,
|
||||
wait = defaultWait,
|
||||
retryDelayMs = 750,
|
||||
maxAttempts = 8,
|
||||
}: OpenSourceSelectorFlowOptions): Promise<OpenSourceSelectorResult> {
|
||||
const initialResult = await openSourceSelector();
|
||||
if (!shouldRetryAfterPermissionPrompt(initialResult)) {
|
||||
return initialResult;
|
||||
}
|
||||
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
|
||||
await wait(retryDelayMs);
|
||||
const access = await requestScreenAccess();
|
||||
|
||||
if (access.granted) {
|
||||
return openSourceSelector();
|
||||
}
|
||||
|
||||
if (access.status !== "not-determined") {
|
||||
return {
|
||||
opened: false,
|
||||
reason: "screen-access-required",
|
||||
access,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return initialResult;
|
||||
}
|
||||
@@ -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<HTMLInputElement>) => {
|
||||
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) {
|
||||
|
||||
@@ -82,7 +82,8 @@ const SelectContent = React.forwardRef<
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
/* Above Dialog (z-[10000]) and fullscreen overlays (e.g. z-[99999]) */
|
||||
"relative z-[100000] max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1",
|
||||
className,
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
import { Toaster as Sonner } from "sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type ToasterProps = React.ComponentProps<typeof Sonner>;
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
const Toaster = ({ className, ...props }: ToasterProps) => {
|
||||
return (
|
||||
<Sonner
|
||||
theme="light"
|
||||
className="toaster group"
|
||||
theme="dark"
|
||||
className={cn(
|
||||
"dark toaster group pointer-events-none [&_[data-sonner-toast]]:pointer-events-auto",
|
||||
className,
|
||||
)}
|
||||
duration={3000}
|
||||
toastOptions={{
|
||||
classNames: {
|
||||
toast:
|
||||
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
|
||||
description: "group-[.toast]:text-muted-foreground",
|
||||
"group toast border border-white/10 bg-[#09090b] text-slate-200 shadow-lg backdrop-blur-xl",
|
||||
description: "group-[.toast]:text-slate-400",
|
||||
actionButton: "group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
|
||||
cancelButton: "group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
|
||||
},
|
||||
|
||||
@@ -10,7 +10,7 @@ const Switch = React.forwardRef<
|
||||
<SwitchPrimitives.Root
|
||||
className={cn(
|
||||
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"data-[state=checked]:bg-[#34B27B] data-[state=unchecked]:bg-[#23232a]",
|
||||
"data-[state=checked]:bg-[#34B27B] data-[state=unchecked]:bg-[#52525b]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -19,7 +19,7 @@ const Switch = React.forwardRef<
|
||||
<SwitchPrimitives.Thumb
|
||||
className={cn(
|
||||
"pointer-events-none block h-4 w-4 rounded-full shadow-lg ring-0 transition-transform",
|
||||
"bg-[#f5f5f7] dark:bg-[#23232a]",
|
||||
"bg-white",
|
||||
"data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0",
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { type CSSProperties, type PointerEvent, useEffect, useRef, useState } from "react";
|
||||
import { Rnd } from "react-rnd";
|
||||
import { getTextAnimationState } from "@/lib/annotationTextAnimation";
|
||||
import {
|
||||
getBlurOverlayColor,
|
||||
getMosaicGridOverlayColor,
|
||||
@@ -47,9 +48,10 @@ 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;
|
||||
}
|
||||
|
||||
export function AnnotationOverlay({
|
||||
@@ -66,6 +68,7 @@ export function AnnotationOverlay({
|
||||
isSelectedBoost,
|
||||
previewSourceCanvas,
|
||||
previewFrameVersion,
|
||||
currentTimeMs,
|
||||
}: AnnotationOverlayProps) {
|
||||
const committedX = (annotation.position.x / 100) * containerWidth;
|
||||
const committedY = (annotation.position.y / 100) * containerHeight;
|
||||
@@ -283,7 +286,12 @@ export function AnnotationOverlay({
|
||||
|
||||
const renderContent = () => {
|
||||
switch (annotation.type) {
|
||||
case "text":
|
||||
case "text": {
|
||||
const animationState = getTextAnimationState(annotation, currentTimeMs);
|
||||
const typewriterClip =
|
||||
animationState.revealProgress < 1
|
||||
? `inset(0 ${100 - animationState.revealProgress * 100}% 0 0)`
|
||||
: undefined;
|
||||
return (
|
||||
<div
|
||||
className="w-full h-full flex items-center p-2 overflow-hidden"
|
||||
@@ -307,6 +315,11 @@ export function AnnotationOverlay({
|
||||
fontStyle: annotation.style.fontStyle,
|
||||
textDecoration: annotation.style.textDecoration,
|
||||
textAlign: annotation.style.textAlign,
|
||||
opacity: animationState.opacity,
|
||||
transform: `translate(${animationState.translateX}px, ${animationState.translateY}px) scale(${animationState.scale})`,
|
||||
transformOrigin: "center",
|
||||
clipPath: typewriterClip,
|
||||
WebkitClipPath: typewriterClip,
|
||||
wordBreak: "break-word",
|
||||
whiteSpace: "pre-wrap",
|
||||
boxDecorationBreak: "clone",
|
||||
@@ -320,6 +333,7 @@ export function AnnotationOverlay({
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
case "image":
|
||||
if (annotation.content && annotation.content.startsWith("data:image")) {
|
||||
@@ -523,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);
|
||||
@@ -562,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",
|
||||
|
||||
@@ -28,6 +28,7 @@ import { Slider } from "@/components/ui/slider";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||
import { useScopedT } from "@/contexts/I18nContext";
|
||||
import { normalizeTextAnimation, TEXT_ANIMATION_OPTIONS } from "@/lib/annotationTextAnimation";
|
||||
import { type CustomFont, getCustomFonts } from "@/lib/customFonts";
|
||||
import { cn } from "@/lib/utils";
|
||||
import ColorPicker from "../ui/color-picker";
|
||||
@@ -50,7 +51,11 @@ interface AnnotationSettingsPanelProps {
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
const FONT_FAMILIES = [
|
||||
const FONT_FAMILIES: Array<
|
||||
| { value: string; labelKey: string; name?: never }
|
||||
| { value: string; labelKey?: never; name: string }
|
||||
> = [
|
||||
{ value: "Inter", name: "Inter" },
|
||||
{ value: "system-ui, -apple-system, sans-serif", labelKey: "classic" },
|
||||
{ value: "Georgia, serif", labelKey: "editor" },
|
||||
{ value: "Impact, Arial Black, sans-serif", labelKey: "strong" },
|
||||
@@ -59,6 +64,21 @@ const FONT_FAMILIES = [
|
||||
{ value: "Arial, sans-serif", labelKey: "simple" },
|
||||
{ value: "Verdana, sans-serif", labelKey: "modern" },
|
||||
{ value: "Trebuchet MS, sans-serif", labelKey: "clean" },
|
||||
{ value: '"Plus Jakarta Sans", sans-serif', name: "Plus Jakarta Sans" },
|
||||
{ value: '"Space Grotesk", sans-serif', name: "Space Grotesk" },
|
||||
{ value: '"DM Sans", sans-serif', name: "DM Sans" },
|
||||
{ value: "Sora, sans-serif", name: "Sora" },
|
||||
{ value: "Manrope, sans-serif", name: "Manrope" },
|
||||
{ value: '"IBM Plex Sans", sans-serif', name: "IBM Plex Sans" },
|
||||
{ value: '"Playfair Display", Georgia, serif', name: "Playfair Display" },
|
||||
{ value: "Merriweather, Georgia, serif", name: "Merriweather" },
|
||||
{ value: "Lora, Georgia, serif", name: "Lora" },
|
||||
{ value: '"IBM Plex Mono", monospace', name: "IBM Plex Mono" },
|
||||
{ value: '"Fira Code", monospace', name: "Fira Code" },
|
||||
{ value: '"Bebas Neue", sans-serif', name: "Bebas Neue" },
|
||||
{ value: "Oswald, sans-serif", name: "Oswald" },
|
||||
{ value: "Caveat, cursive", name: "Caveat" },
|
||||
{ value: '"Permanent Marker", cursive', name: "Permanent Marker" },
|
||||
];
|
||||
|
||||
const FONT_SIZES = [12, 14, 16, 18, 20, 24, 28, 32, 36, 40, 48, 56, 64, 72, 80, 96, 128];
|
||||
@@ -85,8 +105,9 @@ export function AnnotationSettingsPanel({
|
||||
modern: t("fontStyles.modern"),
|
||||
clean: t("fontStyles.clean"),
|
||||
};
|
||||
const getFontLabel = (font: (typeof FONT_FAMILIES)[number]) =>
|
||||
font.labelKey ? fontStyleLabels[font.labelKey] : font.name;
|
||||
|
||||
// Load custom fonts on mount
|
||||
useEffect(() => {
|
||||
setCustomFonts(getCustomFonts());
|
||||
}, []);
|
||||
@@ -116,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"), {
|
||||
@@ -231,7 +251,7 @@ export function AnnotationSettingsPanel({
|
||||
value={font.value}
|
||||
style={{ fontFamily: font.value }}
|
||||
>
|
||||
{fontStyleLabels[font.labelKey]}
|
||||
{getFontLabel(font)}
|
||||
</SelectItem>
|
||||
))}
|
||||
{customFonts.length > 0 && (
|
||||
@@ -285,6 +305,29 @@ export function AnnotationSettingsPanel({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-2 block text-xs font-medium text-slate-200">
|
||||
{t("annotation.textAnimation")}
|
||||
</label>
|
||||
<Select
|
||||
value={normalizeTextAnimation(annotation.style.textAnimation)}
|
||||
onValueChange={(value) =>
|
||||
onStyleChange({ textAnimation: normalizeTextAnimation(value) })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-9 w-full border-white/10 bg-white/5 text-xs text-slate-200">
|
||||
<SelectValue placeholder={t("annotation.selectAnimation")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-h-[240px] border-white/10 bg-[#1a1a1c] text-slate-200">
|
||||
{TEXT_ANIMATION_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{t(option.translationKey)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Formatting Toggles */}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<ToggleGroup
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { type AspectRatio } from "@/utils/aspectRatioUtils";
|
||||
import { DEFAULT_SOURCE_DIMENSIONS } from "./editorDefaults";
|
||||
|
||||
interface CropRegion {
|
||||
x: number; // 0-1 normalized
|
||||
@@ -32,8 +33,8 @@ export function CropControl({ videoElement, cropRegion, onCropChange }: CropCont
|
||||
const ctx = canvas.getContext("2d", { alpha: false });
|
||||
if (!ctx) return;
|
||||
|
||||
canvas.width = videoElement.videoWidth || 1920;
|
||||
canvas.height = videoElement.videoHeight || 1080;
|
||||
canvas.width = videoElement.videoWidth || DEFAULT_SOURCE_DIMENSIONS.width;
|
||||
canvas.height = videoElement.videoHeight || DEFAULT_SOURCE_DIMENSIONS.height;
|
||||
|
||||
const draw = () => {
|
||||
if (videoElement.readyState >= 2) {
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
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 */
|
||||
onProjectOpened: (project: unknown, path: string | null) => void;
|
||||
}
|
||||
|
||||
type DropError = "unsupported-format" | "load-failed" | null;
|
||||
|
||||
export function EditorEmptyState({ onVideoImported, onProjectOpened }: EditorEmptyStateProps) {
|
||||
const te = useScopedT("editor");
|
||||
const tc = useScopedT("common");
|
||||
const [isDraggingOver, setIsDraggingOver] = useState(false);
|
||||
const [dropError, setDropError] = useState<DropError>(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).
|
||||
const lastDropErrorRef = useRef<Exclude<DropError, null>>("unsupported-format");
|
||||
if (dropError !== null) {
|
||||
lastDropErrorRef.current = dropError;
|
||||
}
|
||||
|
||||
const handleImportVideo = useCallback(async () => {
|
||||
const result = await window.electronAPI.openVideoFilePicker();
|
||||
if (result.canceled || !result.success || !result.path) return;
|
||||
|
||||
const setResult = await nativeBridgeClient.project.setCurrentVideoPath(result.path);
|
||||
if (!setResult.success) return;
|
||||
|
||||
onVideoImported(result.path);
|
||||
}, [onVideoImported]);
|
||||
|
||||
const handleLoadProject = useCallback(async () => {
|
||||
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]);
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
if (e.dataTransfer.items.length > 0) {
|
||||
setIsDraggingOver(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||
if (!e.currentTarget.contains(e.relatedTarget as Node)) {
|
||||
setIsDraggingOver(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback(
|
||||
async (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDraggingOver(false);
|
||||
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
if (files.length === 0) return;
|
||||
|
||||
const projectFile = files.find((f) => f.name.endsWith(".openscreen"));
|
||||
if (!projectFile) {
|
||||
setDropError("unsupported-format");
|
||||
return;
|
||||
}
|
||||
|
||||
// Use Electron's webUtils.getPathForFile; File.path was removed in Electron 32+
|
||||
let filePath: string;
|
||||
try {
|
||||
filePath = window.electronAPI.getPathForFile(projectFile);
|
||||
} catch {
|
||||
setDropError("load-failed");
|
||||
return;
|
||||
}
|
||||
if (!filePath) {
|
||||
setDropError("load-failed");
|
||||
return;
|
||||
}
|
||||
|
||||
let result: Awaited<ReturnType<typeof window.electronAPI.loadProjectFileFromPath>>;
|
||||
try {
|
||||
result = await window.electronAPI.loadProjectFileFromPath(filePath);
|
||||
} catch {
|
||||
setDropError("load-failed");
|
||||
return;
|
||||
}
|
||||
if (!result.success || !result.project) {
|
||||
setDropError("load-failed");
|
||||
return;
|
||||
}
|
||||
|
||||
onProjectOpened(result.project, result.path ?? null);
|
||||
},
|
||||
[onProjectOpened],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex h-full w-full flex-col items-center justify-center bg-[#09090b]"
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
{/* Drop overlay */}
|
||||
{isDraggingOver && (
|
||||
<div className="pointer-events-none absolute inset-0 z-50 flex flex-col items-center justify-center rounded-xl border-2 border-dashed border-[#34B27B] bg-[#34B27B]/10">
|
||||
<Upload className="mb-3 h-10 w-10 text-[#34B27B]" />
|
||||
<p className="text-base font-semibold text-[#34B27B]">{te("emptyState.dropOverlay")}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Drop error dialog */}
|
||||
<Dialog open={dropError !== null} onOpenChange={(open) => !open && setDropError(null)}>
|
||||
<DialogContent className="bg-[#09090b] border-white/10 rounded-2xl max-w-sm p-6 gap-0">
|
||||
<DialogHeader className="mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<img
|
||||
src="./openscreen.png"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="w-9 h-9 rounded-xl flex-shrink-0"
|
||||
/>
|
||||
<DialogTitle className="text-base font-semibold text-slate-200 leading-tight">
|
||||
{lastDropErrorRef.current === "unsupported-format"
|
||||
? te("emptyState.dropErrors.unsupportedFormatTitle")
|
||||
: te("emptyState.dropErrors.couldNotOpenTitle")}
|
||||
</DialogTitle>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col items-center gap-3 mb-6 text-center">
|
||||
<div className="flex items-center justify-center w-10 h-10 rounded-full bg-white/5 ring-1 ring-white/10">
|
||||
<AlertCircle className="w-5 h-5 text-slate-400 flex-shrink-0" />
|
||||
</div>
|
||||
<p className="text-sm text-slate-400 leading-relaxed">
|
||||
{lastDropErrorRef.current === "unsupported-format"
|
||||
? te("emptyState.dropErrors.unsupportedFormatMessage")
|
||||
: te("emptyState.dropErrors.couldNotOpenMessage")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDropError(null)}
|
||||
className="flex items-center justify-center gap-2 w-full px-4 py-2.5 rounded-lg bg-white/5 hover:bg-white/10 border border-white/10 text-slate-300 font-medium text-sm transition-colors outline-none focus-visible:ring-2 focus-visible:ring-white/30 focus-visible:ring-offset-2 focus-visible:ring-offset-[#09090b]"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
{tc("actions.close")}
|
||||
</button>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<div className="relative flex flex-col items-center gap-8 px-6 text-center">
|
||||
{/* Logo */}
|
||||
<img
|
||||
src="./openscreen.png"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="h-16 w-16 rounded-2xl opacity-90"
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<h2 className="text-xl font-semibold text-slate-200">{te("emptyState.title")}</h2>
|
||||
<p className="max-w-sm text-sm leading-relaxed text-slate-500">
|
||||
{te("emptyState.description")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex flex-col gap-3 w-full max-w-xs">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleImportVideo}
|
||||
className="flex items-center justify-center gap-2.5 w-full px-4 py-3 rounded-xl bg-[#34B27B] hover:bg-[#2d9e6c] active:bg-[#27885c] text-white font-medium text-sm transition-colors outline-none focus-visible:ring-2 focus-visible:ring-[#34B27B] focus-visible:ring-offset-2 focus-visible:ring-offset-[#09090b]"
|
||||
>
|
||||
<Film className="h-4 w-4" />
|
||||
{te("emptyState.importVideoButton")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLoadProject}
|
||||
className="flex items-center justify-center gap-2.5 w-full px-4 py-3 rounded-xl bg-white/5 hover:bg-white/10 border border-white/10 text-slate-300 font-medium text-sm transition-colors outline-none focus-visible:ring-2 focus-visible:ring-white/30 focus-visible:ring-offset-2 focus-visible:ring-offset-[#09090b]"
|
||||
>
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
{te("emptyState.loadProjectButton")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<p className="text-xs text-slate-600">{te("emptyState.supportedFormats")}</p>
|
||||
<div className="flex items-center gap-1.5 text-xs text-slate-700 mt-4">
|
||||
<Upload className="h-3 w-3" />
|
||||
<span>{te("emptyState.dragDropHint")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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,12 @@ 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";
|
||||
const renderProgress = progress?.renderProgress;
|
||||
|
||||
// Get status message based on phase
|
||||
const getStatusMessage = () => {
|
||||
if (error) return t("export.tryAgain");
|
||||
if (isCompiling || isFinalizing) {
|
||||
@@ -80,7 +78,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");
|
||||
@@ -194,7 +191,7 @@ export function ExportDialog({
|
||||
</div>
|
||||
<div className="h-2 bg-white/5 rounded-full overflow-hidden border border-white/5">
|
||||
{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 ? (
|
||||
<div
|
||||
className="h-full bg-[#34B27B] shadow-[0_0_10px_rgba(52,178,123,0.3)] transition-all duration-300 ease-out"
|
||||
|
||||
@@ -2,6 +2,7 @@ import { HelpCircle, Settings2 } from "lucide-react";
|
||||
import { useScopedT } from "@/contexts/I18nContext";
|
||||
import { useShortcuts } from "@/contexts/ShortcutsContext";
|
||||
import { FIXED_SHORTCUTS, formatBinding, SHORTCUT_ACTIONS } from "@/lib/shortcuts";
|
||||
import { BLUR_REGIONS_ENABLED } from "./featureFlags";
|
||||
|
||||
export function KeyboardShortcutsHelp() {
|
||||
const { shortcuts, isMac, openConfig } = useShortcuts();
|
||||
@@ -26,14 +27,16 @@ export function KeyboardShortcutsHelp() {
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5 text-[10px]">
|
||||
{SHORTCUT_ACTIONS.map((action) => (
|
||||
<div key={action} className="flex items-center justify-between">
|
||||
<span className="text-slate-400">{t(`actions.${action}`)}</span>
|
||||
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">
|
||||
{formatBinding(shortcuts[action], isMac)}
|
||||
</kbd>
|
||||
</div>
|
||||
))}
|
||||
{SHORTCUT_ACTIONS.filter((action) => BLUR_REGIONS_ENABLED || action !== "addBlur").map(
|
||||
(action) => (
|
||||
<div key={action} className="flex items-center justify-between">
|
||||
<span className="text-slate-400">{t(`actions.${action}`)}</span>
|
||||
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">
|
||||
{formatBinding(shortcuts[action], isMac)}
|
||||
</kbd>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
|
||||
<div className="pt-1 border-t border-white/5 mt-1 space-y-1.5">
|
||||
{FIXED_SHORTCUTS.map((fixed) => (
|
||||
|
||||
@@ -96,7 +96,8 @@ export default function PlaybackControls({
|
||||
<Button
|
||||
onClick={onToggleFullscreen}
|
||||
size="icon"
|
||||
className="w-7 h-7 rounded-full transition-all duration-200 border border-transparent hover:bg-white/10 text-white hover:border-white/10 shrink-0 shadow-none ml-0.5"
|
||||
variant="ghost"
|
||||
className="w-7 h-7 rounded-full transition-all duration-200 border border-transparent bg-transparent hover:bg-white/10 text-white hover:text-white hover:border-white/10 shrink-0 shadow-none ml-0.5"
|
||||
aria-label={isFullscreen ? t("playback.exitFullscreen") : t("playback.fullscreen")}
|
||||
>
|
||||
{isFullscreen ? (
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import * as SliderPrimitive from "@radix-ui/react-slider";
|
||||
import {
|
||||
Brackets,
|
||||
Bug,
|
||||
Crop,
|
||||
Download,
|
||||
FileDown,
|
||||
Film,
|
||||
Image,
|
||||
Info,
|
||||
LayoutPanelTop,
|
||||
Lock,
|
||||
MousePointerClick,
|
||||
@@ -20,6 +22,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { type ComponentType, useCallback, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import defaultCursorPreviewUrl from "@/assets/cursors/Cursor=Default.svg";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
@@ -37,8 +40,11 @@ import {
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Tooltip } from "@/components/ui/tooltip";
|
||||
import { useScopedT } from "@/contexts/I18nContext";
|
||||
import { getAssetPath } from "@/lib/assetPath";
|
||||
import { WEBCAM_LAYOUT_PRESETS } from "@/lib/compositeLayout";
|
||||
import { CURSOR_THEMES, DEFAULT_CURSOR_THEME_ID } from "@/lib/cursor/cursorThemes";
|
||||
import type { ExportFormat, ExportQuality, GifFrameRate, GifSizePreset } from "@/lib/exporter";
|
||||
import {
|
||||
calculateEffectiveSourceDimensions,
|
||||
@@ -52,8 +58,18 @@ import { getTestId } from "@/utils/getTestId";
|
||||
import ColorPicker from "../ui/color-picker";
|
||||
import { AnnotationSettingsPanel } from "./AnnotationSettingsPanel";
|
||||
import { BlurSettingsPanel } from "./BlurSettingsPanel";
|
||||
import { BACKGROUND_IMAGE_ACCEPT, isSupportedBackgroundImageType } from "./backgroundImageUpload";
|
||||
import { CropControl } from "./CropControl";
|
||||
import { parseCustomPlaybackSpeedInput } from "./customPlaybackSpeed";
|
||||
import {
|
||||
DEFAULT_CURSOR_SETTINGS,
|
||||
DEFAULT_EDITOR_LAYOUT_SETTINGS,
|
||||
DEFAULT_EXPORT_SETTINGS,
|
||||
DEFAULT_GIF_SETTINGS,
|
||||
DEFAULT_SOURCE_DIMENSIONS,
|
||||
DEFAULT_WEBCAM_SETTINGS,
|
||||
} from "./editorDefaults";
|
||||
import { BLUR_REGIONS_ENABLED } from "./featureFlags";
|
||||
import { KeyboardShortcutsHelp } from "./KeyboardShortcutsHelp";
|
||||
import type {
|
||||
AnnotationRegion,
|
||||
@@ -71,7 +87,8 @@ import type {
|
||||
ZoomFocusMode,
|
||||
} from "./types";
|
||||
import {
|
||||
DEFAULT_WEBCAM_SIZE_PRESET,
|
||||
DEFAULT_WEBCAM_MIRRORED,
|
||||
DEFAULT_WEBCAM_REACTIVE_ZOOM,
|
||||
MAX_ZOOM_SCALE,
|
||||
MIN_ZOOM_SCALE,
|
||||
ROTATION_3D_PRESET_ORDER,
|
||||
@@ -157,10 +174,8 @@ function ZoomFocusCoordInput({
|
||||
disabled?: boolean;
|
||||
ariaLabel: string;
|
||||
}) {
|
||||
// While the input is focused (user is editing), show their draft text
|
||||
// so partial entries like "5" or "" don't get overwritten by re-renders.
|
||||
// When not focused, mirror the live prop value so external changes
|
||||
// (dragging the overlay on the preview) update the displayed number in real time.
|
||||
// While focused, show the draft so partial entries like "5" or "" survive re-renders.
|
||||
// While not focused, mirror the live prop so overlay drags update the number live.
|
||||
const [draft, setDraft] = useState<string | null>(null);
|
||||
const display = percent.toFixed(1);
|
||||
|
||||
@@ -231,8 +246,12 @@ interface SettingsPanelProps {
|
||||
selectedZoomCustomScale?: number | null;
|
||||
onZoomCustomScaleChange?: (scale: number) => void;
|
||||
onZoomCustomScaleCommit?: () => void;
|
||||
onZoomPreviewStart?: () => void;
|
||||
onZoomPreviewEnd?: () => void;
|
||||
selectedZoomFocusMode?: ZoomFocusMode | null;
|
||||
onZoomFocusModeChange?: (mode: ZoomFocusMode) => void;
|
||||
/** When the global Auto-Focus toggle is on, the per-zoom selector is locked. */
|
||||
focusModeLocked?: boolean;
|
||||
selectedZoomFocus?: ZoomFocus | null;
|
||||
onZoomFocusCoordinateChange?: (focus: ZoomFocus) => void;
|
||||
onZoomFocusCoordinateCommit?: () => void;
|
||||
@@ -248,6 +267,8 @@ interface SettingsPanelProps {
|
||||
onShadowCommit?: () => void;
|
||||
showBlur?: boolean;
|
||||
onBlurChange?: (showBlur: boolean) => void;
|
||||
showTrimWaveform?: boolean;
|
||||
onTrimWaveformChange?: (show: boolean) => void;
|
||||
motionBlurAmount?: number;
|
||||
onMotionBlurChange?: (amount: number) => void;
|
||||
onMotionBlurCommit?: () => void;
|
||||
@@ -274,6 +295,7 @@ interface SettingsPanelProps {
|
||||
onGifSizePresetChange?: (preset: GifSizePreset) => void;
|
||||
gifOutputDimensions?: { width: number; height: number };
|
||||
onExport?: () => void;
|
||||
onExportPanelOpen?: () => void;
|
||||
unsavedExport?: {
|
||||
arrayBuffer: ArrayBuffer;
|
||||
fileName: string;
|
||||
@@ -302,6 +324,10 @@ interface SettingsPanelProps {
|
||||
onWebcamLayoutPresetChange?: (preset: WebcamLayoutPreset) => void;
|
||||
webcamMaskShape?: import("./types").WebcamMaskShape;
|
||||
onWebcamMaskShapeChange?: (shape: import("./types").WebcamMaskShape) => void;
|
||||
webcamMirrored?: boolean;
|
||||
onWebcamMirroredChange?: (mirrored: boolean) => void;
|
||||
webcamReactiveZoom?: boolean;
|
||||
onWebcamReactiveZoomChange?: (reactive: boolean) => void;
|
||||
webcamSizePreset?: WebcamSizePreset;
|
||||
onWebcamSizePresetChange?: (size: WebcamSizePreset) => void;
|
||||
onWebcamSizePresetCommit?: () => void;
|
||||
@@ -318,6 +344,8 @@ interface SettingsPanelProps {
|
||||
onCursorClickBounceChange?: (bounce: number) => void;
|
||||
cursorClipToBounds?: boolean;
|
||||
onCursorClipToBoundsChange?: (clip: boolean) => void;
|
||||
cursorTheme?: string;
|
||||
onCursorThemeChange?: (theme: string) => void;
|
||||
hasCursorData?: boolean;
|
||||
showCursorSettings?: boolean;
|
||||
}
|
||||
@@ -333,7 +361,7 @@ const ZOOM_DEPTH_OPTIONS: Array<{ depth: ZoomDepth; label: string }> = [
|
||||
{ depth: 6, label: "5×" },
|
||||
];
|
||||
|
||||
type SettingsPanelMode = "background" | "effects" | "layout" | "cursor" | "export";
|
||||
type SettingsPanelMode = "background" | "effects" | "layout" | "cursor" | "export" | "timeline";
|
||||
|
||||
const MP4_EXPORT_SHORT_SIDES = {
|
||||
medium: 720,
|
||||
@@ -360,8 +388,11 @@ export function SettingsPanel({
|
||||
selectedZoomCustomScale,
|
||||
onZoomCustomScaleChange,
|
||||
onZoomCustomScaleCommit,
|
||||
onZoomPreviewStart,
|
||||
onZoomPreviewEnd,
|
||||
selectedZoomFocusMode,
|
||||
onZoomFocusModeChange,
|
||||
focusModeLocked = false,
|
||||
selectedZoomFocus,
|
||||
onZoomFocusCoordinateChange,
|
||||
onZoomFocusCoordinateCommit,
|
||||
@@ -377,31 +408,34 @@ export function SettingsPanel({
|
||||
onShadowCommit,
|
||||
showBlur,
|
||||
onBlurChange,
|
||||
showTrimWaveform = false,
|
||||
onTrimWaveformChange,
|
||||
motionBlurAmount = 0,
|
||||
onMotionBlurChange,
|
||||
onMotionBlurCommit,
|
||||
borderRadius = 0,
|
||||
onBorderRadiusChange,
|
||||
onBorderRadiusCommit,
|
||||
padding = 50,
|
||||
padding = DEFAULT_EDITOR_LAYOUT_SETTINGS.padding,
|
||||
onPaddingChange,
|
||||
onPaddingCommit,
|
||||
cropRegion,
|
||||
onCropChange,
|
||||
aspectRatio,
|
||||
videoElement,
|
||||
exportQuality = "good",
|
||||
exportQuality = DEFAULT_EXPORT_SETTINGS.quality,
|
||||
onExportQualityChange,
|
||||
exportFormat = "mp4",
|
||||
exportFormat = DEFAULT_EXPORT_SETTINGS.format,
|
||||
onExportFormatChange,
|
||||
gifFrameRate = 15,
|
||||
gifFrameRate = DEFAULT_GIF_SETTINGS.frameRate,
|
||||
onGifFrameRateChange,
|
||||
gifLoop = true,
|
||||
gifLoop = DEFAULT_GIF_SETTINGS.loop,
|
||||
onGifLoopChange,
|
||||
gifSizePreset = "medium",
|
||||
gifSizePreset = DEFAULT_GIF_SETTINGS.sizePreset,
|
||||
onGifSizePresetChange,
|
||||
gifOutputDimensions = { width: 1280, height: 720 },
|
||||
gifOutputDimensions = DEFAULT_GIF_SETTINGS.outputDimensions,
|
||||
onExport,
|
||||
onExportPanelOpen,
|
||||
unsavedExport,
|
||||
onSaveUnsavedExport,
|
||||
selectedAnnotationId,
|
||||
@@ -422,36 +456,61 @@ export function SettingsPanel({
|
||||
onSpeedChange,
|
||||
onSpeedDelete,
|
||||
hasWebcam = false,
|
||||
webcamLayoutPreset = "picture-in-picture",
|
||||
webcamLayoutPreset = DEFAULT_WEBCAM_SETTINGS.layoutPreset,
|
||||
onWebcamLayoutPresetChange,
|
||||
webcamMaskShape = "rectangle",
|
||||
webcamMaskShape = DEFAULT_WEBCAM_SETTINGS.maskShape,
|
||||
onWebcamMaskShapeChange,
|
||||
webcamSizePreset = DEFAULT_WEBCAM_SIZE_PRESET,
|
||||
webcamMirrored = DEFAULT_WEBCAM_MIRRORED,
|
||||
onWebcamMirroredChange,
|
||||
webcamReactiveZoom = DEFAULT_WEBCAM_REACTIVE_ZOOM,
|
||||
onWebcamReactiveZoomChange,
|
||||
webcamSizePreset = DEFAULT_WEBCAM_SETTINGS.sizePreset,
|
||||
onWebcamSizePresetChange,
|
||||
onWebcamSizePresetCommit,
|
||||
onSaveDiagnostic,
|
||||
showCursor = true,
|
||||
showCursor = DEFAULT_CURSOR_SETTINGS.show,
|
||||
onShowCursorChange,
|
||||
cursorSize = 3.0,
|
||||
cursorSize = DEFAULT_CURSOR_SETTINGS.size,
|
||||
onCursorSizeChange,
|
||||
cursorSmoothing = 0.67,
|
||||
cursorSmoothing = DEFAULT_CURSOR_SETTINGS.smoothing,
|
||||
onCursorSmoothingChange,
|
||||
cursorMotionBlur = 0.35,
|
||||
cursorMotionBlur = DEFAULT_CURSOR_SETTINGS.motionBlur,
|
||||
onCursorMotionBlurChange,
|
||||
cursorClickBounce = 2.5,
|
||||
cursorClickBounce = DEFAULT_CURSOR_SETTINGS.clickBounce,
|
||||
onCursorClickBounceChange,
|
||||
cursorClipToBounds = false,
|
||||
cursorClipToBounds = DEFAULT_CURSOR_SETTINGS.clipToBounds,
|
||||
onCursorClipToBoundsChange,
|
||||
cursorTheme = DEFAULT_CURSOR_SETTINGS.theme,
|
||||
onCursorThemeChange,
|
||||
hasCursorData = false,
|
||||
showCursorSettings = true,
|
||||
}: SettingsPanelProps) {
|
||||
const t = useScopedT("settings");
|
||||
const [activePanelMode, setActivePanelMode] = useState<SettingsPanelMode>("background");
|
||||
const sourceDimensions = formatSourceDimensions(videoElement, cropRegion);
|
||||
// Resolved URLs are for DOM rendering only (backgroundImage). The canonical
|
||||
// `/wallpapers/wallpaperN.jpg` form in WALLPAPER_PATHS is what gets persisted
|
||||
// on click — never the machine-specific file:// URL.
|
||||
// Resolved URLs are for DOM rendering only. We persist the canonical
|
||||
// `/wallpapers/wallpaperN.jpg` form from WALLPAPER_PATHS, never the file:// URL.
|
||||
const wallpaperPreviewUrls = useMemo(() => WALLPAPER_PATHS.map(resolveImageWallpaperUrl), []);
|
||||
// Built-in "Default" plus each bundled theme. Thumbnails use the theme's arrow asset;
|
||||
// the persisted value is the theme id.
|
||||
const cursorThemeOptions = useMemo(
|
||||
() => [
|
||||
{
|
||||
id: DEFAULT_CURSOR_THEME_ID,
|
||||
name: t("cursor.themeDefault"),
|
||||
previewUrl: defaultCursorPreviewUrl,
|
||||
},
|
||||
...CURSOR_THEMES.map((theme) => {
|
||||
const previewPath = (theme.assets.arrow ?? theme.assets.pointer)?.assetPath;
|
||||
return {
|
||||
id: theme.id,
|
||||
name: theme.name,
|
||||
previewUrl: previewPath ? getAssetPath(previewPath) : defaultCursorPreviewUrl,
|
||||
};
|
||||
}),
|
||||
],
|
||||
[t],
|
||||
);
|
||||
const [customImages, setCustomImages] = useState<string[]>([]);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const colorPalette = [
|
||||
@@ -479,8 +538,8 @@ export function SettingsPanel({
|
||||
const [cropAspectRatio, setCropAspectRatio] = useState("");
|
||||
const isPortraitCanvas = isPortraitAspectRatio(aspectRatio);
|
||||
|
||||
const videoWidth = videoElement?.videoWidth || 1920;
|
||||
const videoHeight = videoElement?.videoHeight || 1080;
|
||||
const videoWidth = videoElement?.videoWidth || DEFAULT_SOURCE_DIMENSIONS.width;
|
||||
const videoHeight = videoElement?.videoHeight || DEFAULT_SOURCE_DIMENSIONS.height;
|
||||
|
||||
const handleCropNumericChange = useCallback(
|
||||
(field: "x" | "y" | "width" | "height", pixelValue: number) => {
|
||||
@@ -591,6 +650,7 @@ export function SettingsPanel({
|
||||
{ id: "background", label: t("background.title"), icon: Palette },
|
||||
{ id: "effects", label: t("effects.title"), icon: SlidersHorizontal },
|
||||
{ id: "layout", label: t("layout.title"), icon: LayoutPanelTop, disabled: !hasWebcam },
|
||||
{ id: "timeline", label: t("timeline.title"), icon: Brackets },
|
||||
...(hasCursorPanel
|
||||
? [
|
||||
{
|
||||
@@ -612,8 +672,10 @@ export function SettingsPanel({
|
||||
: selectedSpeedId
|
||||
? t("speed.playbackSpeed")
|
||||
: t("trim.deleteRegion")
|
||||
: ([...panelModes, exportPanelMode].find((mode) => mode.id === activePanelMode)?.label ??
|
||||
t("background.title"));
|
||||
: activePanelMode === "timeline"
|
||||
? t("timeline.title")
|
||||
: ([...panelModes, exportPanelMode].find((mode) => mode.id === activePanelMode)?.label ??
|
||||
t("background.title"));
|
||||
|
||||
const handleDeleteClick = () => {
|
||||
if (selectedZoomId && onZoomDelete) {
|
||||
@@ -633,9 +695,7 @@ export function SettingsPanel({
|
||||
|
||||
const file = files[0];
|
||||
|
||||
// Validate file type - only allow JPG/JPEG
|
||||
const validTypes = ["image/jpeg", "image/jpg"];
|
||||
if (!validTypes.includes(file.type)) {
|
||||
if (!isSupportedBackgroundImageType(file.type, file.name)) {
|
||||
toast.error(t("imageUpload.invalidFileType"), {
|
||||
description: t("imageUpload.jpgOnly"),
|
||||
});
|
||||
@@ -668,13 +728,12 @@ export function SettingsPanel({
|
||||
const handleRemoveCustomImage = (imageUrl: string, event: React.MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
setCustomImages((prev) => prev.filter((img) => img !== imageUrl));
|
||||
// If the removed image was selected, clear selection
|
||||
// If the removed image was selected, reset to the first wallpaper.
|
||||
if (selected === imageUrl) {
|
||||
onWallpaperChange(WALLPAPER_PATHS[0]);
|
||||
}
|
||||
};
|
||||
|
||||
// Find selected annotation
|
||||
const selectedAnnotation = selectedAnnotationId
|
||||
? annotationRegions.find((a) => a.id === selectedAnnotationId)
|
||||
: null;
|
||||
@@ -718,7 +777,7 @@ export function SettingsPanel({
|
||||
</div>
|
||||
);
|
||||
|
||||
// If an annotation is selected, show annotation settings instead
|
||||
// Annotation selected: show its settings panel instead.
|
||||
if (
|
||||
selectedAnnotation &&
|
||||
onAnnotationContentChange &&
|
||||
@@ -752,7 +811,7 @@ export function SettingsPanel({
|
||||
);
|
||||
}
|
||||
|
||||
if (selectedBlur && onBlurDataChange && onBlurDelete) {
|
||||
if (BLUR_REGIONS_ENABLED && selectedBlur && onBlurDataChange && onBlurDelete) {
|
||||
return (
|
||||
<div className="editor-inspector-shell flex min-w-0 flex-col h-full overflow-hidden">
|
||||
<div className="min-h-0 flex-1 overflow-hidden">
|
||||
@@ -812,7 +871,10 @@ export function SettingsPanel({
|
||||
data-testid={getTestId("export-panel-button")}
|
||||
type="button"
|
||||
title={exportPanelMode.label}
|
||||
onClick={() => setActivePanelMode(exportPanelMode.id)}
|
||||
onClick={() => {
|
||||
setActivePanelMode(exportPanelMode.id);
|
||||
onExportPanelOpen?.();
|
||||
}}
|
||||
className={cn(
|
||||
"mt-auto flex h-8 w-8 items-center justify-center rounded-lg border transition-all",
|
||||
activePanelMode === "export" && !hasTimelineSelection
|
||||
@@ -914,34 +976,69 @@ export function SettingsPanel({
|
||||
</div>
|
||||
)}
|
||||
{zoomEnabled && hasCursorTelemetry && (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-[11px] font-medium text-slate-400">
|
||||
{t("zoom.focusMode.title")}
|
||||
</span>
|
||||
<div className="grid w-32 grid-cols-2 gap-0.5 rounded-lg border border-white/[0.06] bg-white/[0.035] p-0.5">
|
||||
{(["manual", "auto"] as const).map((mode) => {
|
||||
const isActive = selectedZoomFocusMode === mode;
|
||||
return (
|
||||
<Button
|
||||
key={mode}
|
||||
type="button"
|
||||
onClick={() => onZoomFocusModeChange?.(mode)}
|
||||
className={cn(
|
||||
"h-6 w-full rounded-md border px-1 text-center transition-all duration-150 ease-out cursor-pointer",
|
||||
isActive
|
||||
? "border-[#34B27B]/50 bg-[#34B27B] text-white"
|
||||
: "border-transparent bg-transparent text-slate-400 hover:bg-white/[0.06] hover:text-slate-200",
|
||||
)}
|
||||
>
|
||||
<span className="text-[10px] font-semibold capitalize">
|
||||
{t(`zoom.focusMode.${mode}`)}
|
||||
</span>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-[11px] font-medium text-slate-400">
|
||||
{t("zoom.focusMode.title")}
|
||||
</span>
|
||||
<div className="grid w-32 grid-cols-2 gap-0.5 rounded-lg border border-white/[0.06] bg-white/[0.035] p-0.5">
|
||||
{(["manual", "auto"] as const).map((mode) => {
|
||||
const isActive = selectedZoomFocusMode === mode;
|
||||
return (
|
||||
<Button
|
||||
key={mode}
|
||||
type="button"
|
||||
disabled={focusModeLocked}
|
||||
onClick={() => !focusModeLocked && onZoomFocusModeChange?.(mode)}
|
||||
className={cn(
|
||||
"h-6 w-full rounded-md border px-1 text-center transition-all duration-150 ease-out",
|
||||
isActive
|
||||
? "border-[#34B27B]/50 bg-[#34B27B] text-white"
|
||||
: "border-transparent bg-transparent text-slate-400 hover:bg-white/[0.06] hover:text-slate-200",
|
||||
focusModeLocked ? "cursor-not-allowed opacity-50" : "cursor-pointer",
|
||||
)}
|
||||
>
|
||||
<span className="text-[10px] font-semibold capitalize">
|
||||
{t(`zoom.focusMode.${mode}`)}
|
||||
</span>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{focusModeLocked && (
|
||||
<div className="flex items-start gap-1 text-[10px] leading-snug text-slate-500">
|
||||
<Info size={11} className="mt-px shrink-0" />
|
||||
<span>{t("zoom.focusMode.lockedDisclaimer")}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{zoomEnabled && onZoomPreviewStart && onZoomPreviewEnd && (
|
||||
<Button
|
||||
type="button"
|
||||
onPointerDown={() => onZoomPreviewStart()}
|
||||
onPointerUp={() => onZoomPreviewEnd()}
|
||||
onPointerLeave={() => onZoomPreviewEnd()}
|
||||
onPointerCancel={() => onZoomPreviewEnd()}
|
||||
onKeyDown={(e) => {
|
||||
if ((e.key === " " || e.key === "Enter") && !e.repeat) {
|
||||
e.preventDefault();
|
||||
onZoomPreviewStart();
|
||||
}
|
||||
}}
|
||||
onKeyUp={(e) => {
|
||||
if (e.key === " " || e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
onZoomPreviewEnd();
|
||||
}
|
||||
}}
|
||||
onBlur={() => onZoomPreviewEnd()}
|
||||
className="h-7 w-full select-none rounded-md border border-white/[0.08] bg-white/[0.04] text-[10px] font-semibold text-slate-300 transition-all duration-150 ease-out hover:bg-white/[0.08] hover:text-slate-100 active:border-[#34B27B]/50 active:bg-[#34B27B] active:text-white cursor-pointer"
|
||||
>
|
||||
{t("zoom.previewHold")}
|
||||
</Button>
|
||||
)}
|
||||
{zoomEnabled &&
|
||||
selectedZoomFocusMode !== "auto" &&
|
||||
selectedZoomFocus &&
|
||||
@@ -1186,6 +1283,44 @@ export function SettingsPanel({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{webcamLayoutPreset !== "no-webcam" && (
|
||||
<div className="mt-2 flex items-center justify-between p-2 rounded-lg editor-control-surface">
|
||||
<div className="text-[10px] font-medium text-slate-300">
|
||||
{t("layout.mirrorWebcam")}
|
||||
</div>
|
||||
<Switch
|
||||
checked={webcamMirrored}
|
||||
onCheckedChange={onWebcamMirroredChange}
|
||||
className="data-[state=checked]:bg-[#34B27B] scale-90"
|
||||
aria-label={t("layout.mirrorWebcam")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{webcamLayoutPreset === "picture-in-picture" && (
|
||||
<div className="mt-2 flex items-center justify-between p-2 rounded-lg editor-control-surface">
|
||||
<div className="flex items-center gap-1 text-[10px] font-medium text-slate-300">
|
||||
<span>{t("layout.reactiveWebcam")}</span>
|
||||
<Tooltip
|
||||
content={t("layout.reactiveWebcamDescription")}
|
||||
className="max-w-[220px] leading-snug whitespace-normal"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="text-slate-400 transition-colors hover:text-slate-200"
|
||||
aria-label={t("layout.reactiveWebcamDescription")}
|
||||
>
|
||||
<Info size={11} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Switch
|
||||
checked={webcamReactiveZoom}
|
||||
onCheckedChange={onWebcamReactiveZoomChange}
|
||||
className="data-[state=checked]:bg-[#34B27B] scale-90"
|
||||
aria-label={t("layout.reactiveWebcam")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{webcamLayoutPreset === "picture-in-picture" && (
|
||||
<div className="mt-2 p-2 rounded-lg editor-control-surface">
|
||||
<div className="text-[10px] font-medium text-slate-300 mb-1.5">
|
||||
@@ -1375,7 +1510,7 @@ export function SettingsPanel({
|
||||
onValueChange={(values) => onBorderRadiusChange?.(values[0])}
|
||||
onValueCommit={() => onBorderRadiusCommit?.()}
|
||||
min={0}
|
||||
max={16}
|
||||
max={64}
|
||||
step={0.5}
|
||||
className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3"
|
||||
/>
|
||||
@@ -1421,8 +1556,20 @@ export function SettingsPanel({
|
||||
{showCursor && (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-[10px] font-medium text-slate-300">
|
||||
{t("cursor.clipToBounds")}
|
||||
<div className="flex items-center gap-1 text-[10px] font-medium text-slate-300">
|
||||
<span>{t("cursor.clipToBounds")}</span>
|
||||
<Tooltip
|
||||
content={t("cursor.clipToBoundsDescription")}
|
||||
className="max-w-[220px] leading-snug whitespace-normal"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="text-slate-400 transition-colors hover:text-slate-200"
|
||||
aria-label={t("cursor.clipToBoundsDescription")}
|
||||
>
|
||||
<Info size={11} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Switch
|
||||
checked={cursorClipToBounds}
|
||||
@@ -1431,6 +1578,41 @@ export function SettingsPanel({
|
||||
aria-label={t("cursor.clipToBounds")}
|
||||
/>
|
||||
</div>
|
||||
{cursorThemeOptions.length > 1 && (
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-[10px] font-medium text-slate-300">
|
||||
{t("cursor.theme")}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{cursorThemeOptions.map((option) => {
|
||||
const isSelected = cursorTheme === option.id;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={option.id}
|
||||
title={option.name}
|
||||
aria-label={option.name}
|
||||
aria-pressed={isSelected}
|
||||
onClick={() => onCursorThemeChange?.(option.id)}
|
||||
className={cn(
|
||||
"flex items-center justify-center w-8 h-8 rounded-lg border overflow-hidden transition-all duration-150 shadow-sm bg-white/5",
|
||||
isSelected
|
||||
? "border-[#34B27B] ring-1 ring-[#34B27B]/30"
|
||||
: "border-white/10 hover:border-[#34B27B]/40 opacity-80 hover:opacity-100",
|
||||
)}
|
||||
>
|
||||
<img
|
||||
src={option.previewUrl}
|
||||
alt=""
|
||||
className="w-5 h-5 object-contain"
|
||||
draggable={false}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="p-2 rounded-lg bg-white/5 border border-white/5">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
@@ -1550,7 +1732,7 @@ export function SettingsPanel({
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
onChange={handleImageUpload}
|
||||
accept=".jpg,.jpeg,image/jpeg"
|
||||
accept={BACKGROUND_IMAGE_ACCEPT}
|
||||
className="hidden"
|
||||
/>
|
||||
<Button
|
||||
@@ -1661,6 +1843,28 @@ export function SettingsPanel({
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
)}
|
||||
{activePanelMode === "timeline" && (
|
||||
<AccordionItem value="timeline" className="editor-panel-section px-3">
|
||||
<AccordionTrigger className="py-2.5 hover:no-underline">
|
||||
<div className="flex items-center gap-2">
|
||||
<Brackets className="w-4 h-4 text-[#34B27B]" />
|
||||
<span className="text-xs font-medium">{t("timeline.title")}</span>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="pb-3">
|
||||
<div className="flex items-center justify-between p-2 rounded-lg editor-control-surface">
|
||||
<div className="text-[10px] font-medium text-slate-300">
|
||||
{t("timeline.waveform")}
|
||||
</div>
|
||||
<Switch
|
||||
checked={showTrimWaveform}
|
||||
onCheckedChange={onTrimWaveformChange}
|
||||
className="data-[state=checked]:bg-[#34B27B] scale-90 ml-2 shrink-0"
|
||||
/>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
)}
|
||||
</Accordion>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
type ShortcutConflict,
|
||||
type ShortcutsConfig,
|
||||
} from "@/lib/shortcuts";
|
||||
import { BLUR_REGIONS_ENABLED } from "./featureFlags";
|
||||
|
||||
const MODIFIER_KEYS = new Set(["Control", "Shift", "Alt", "Meta"]);
|
||||
|
||||
@@ -102,10 +103,14 @@ export function ShortcutsConfigDialog() {
|
||||
const handleCancelConflict = useCallback(() => setConflict(null), []);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
setShortcuts(draft);
|
||||
await persistShortcuts(draft);
|
||||
toast.success(t("savedToast"));
|
||||
closeConfig();
|
||||
const success = await persistShortcuts(draft);
|
||||
if (success) {
|
||||
setShortcuts(draft);
|
||||
toast.success(t("savedToast"));
|
||||
closeConfig();
|
||||
} else {
|
||||
toast.error(t("registrationFailed"));
|
||||
}
|
||||
}, [draft, setShortcuts, persistShortcuts, closeConfig, t]);
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
@@ -139,61 +144,63 @@ export function ShortcutsConfigDialog() {
|
||||
<p className="text-[10px] text-slate-500 mb-2 uppercase tracking-wide font-semibold">
|
||||
{t("configurable")}
|
||||
</p>
|
||||
{SHORTCUT_ACTIONS.map((action) => {
|
||||
const isCapturing = captureFor === action;
|
||||
const hasConflict = conflict?.forAction === action;
|
||||
return (
|
||||
<div key={action}>
|
||||
<div className="flex items-center justify-between py-1.5 px-1 border-b border-white/5">
|
||||
<span className="text-sm text-slate-300">{t(`actions.${action}`)}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setConflict(null);
|
||||
setCaptureFor(isCapturing ? null : action);
|
||||
}}
|
||||
title={isCapturing ? t("pressEscToCancel") : t("clickToChange")}
|
||||
className={[
|
||||
"px-2 py-1 rounded text-xs font-mono border transition-all min-w-[90px] text-center select-none",
|
||||
isCapturing
|
||||
? "bg-[#34B27B]/20 border-[#34B27B] text-[#34B27B] animate-pulse"
|
||||
: hasConflict
|
||||
? "bg-amber-500/10 border-amber-500/50 text-amber-400"
|
||||
: "bg-white/5 border-white/10 text-slate-200 hover:border-[#34B27B]/50 hover:text-[#34B27B] cursor-pointer",
|
||||
].join(" ")}
|
||||
>
|
||||
{isCapturing ? t("pressKey") : formatBinding(draft[action], isMac)}
|
||||
</button>
|
||||
</div>
|
||||
{hasConflict && conflict?.conflictWith.type === "configurable" && (
|
||||
<div className="flex items-center justify-between px-1 py-1.5 mb-0.5 bg-amber-500/10 border border-amber-500/20 rounded text-xs">
|
||||
<span className="text-amber-400">
|
||||
⚠{" "}
|
||||
{t("alreadyUsedBy", {
|
||||
action: t(`actions.${conflict.conflictWith.action}`),
|
||||
})}
|
||||
</span>
|
||||
<div className="flex gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSwap}
|
||||
className="px-2 py-0.5 bg-amber-500/20 hover:bg-amber-500/30 border border-amber-500/40 rounded text-amber-300 font-medium transition-colors"
|
||||
>
|
||||
{t("swap")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancelConflict}
|
||||
className="px-2 py-0.5 bg-white/5 hover:bg-white/10 border border-white/10 rounded text-slate-400 transition-colors"
|
||||
>
|
||||
{tc("actions.cancel")}
|
||||
</button>
|
||||
</div>
|
||||
{SHORTCUT_ACTIONS.filter((action) => BLUR_REGIONS_ENABLED || action !== "addBlur").map(
|
||||
(action) => {
|
||||
const isCapturing = captureFor === action;
|
||||
const hasConflict = conflict?.forAction === action;
|
||||
return (
|
||||
<div key={action}>
|
||||
<div className="flex items-center justify-between py-1.5 px-1 border-b border-white/5">
|
||||
<span className="text-sm text-slate-300">{t(`actions.${action}`)}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setConflict(null);
|
||||
setCaptureFor(isCapturing ? null : action);
|
||||
}}
|
||||
title={isCapturing ? t("pressEscToCancel") : t("clickToChange")}
|
||||
className={[
|
||||
"px-2 py-1 rounded text-xs font-mono border transition-all min-w-[90px] text-center select-none",
|
||||
isCapturing
|
||||
? "bg-[#34B27B]/20 border-[#34B27B] text-[#34B27B] animate-pulse"
|
||||
: hasConflict
|
||||
? "bg-amber-500/10 border-amber-500/50 text-amber-400"
|
||||
: "bg-white/5 border-white/10 text-slate-200 hover:border-[#34B27B]/50 hover:text-[#34B27B] cursor-pointer",
|
||||
].join(" ")}
|
||||
>
|
||||
{isCapturing ? t("pressKey") : formatBinding(draft[action], isMac)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{hasConflict && conflict?.conflictWith.type === "configurable" && (
|
||||
<div className="flex items-center justify-between px-1 py-1.5 mb-0.5 bg-amber-500/10 border border-amber-500/20 rounded text-xs">
|
||||
<span className="text-amber-400">
|
||||
⚠{" "}
|
||||
{t("alreadyUsedBy", {
|
||||
action: t(`actions.${conflict.conflictWith.action}`),
|
||||
})}
|
||||
</span>
|
||||
<div className="flex gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSwap}
|
||||
className="px-2 py-0.5 bg-amber-500/20 hover:bg-amber-500/30 border border-amber-500/40 rounded text-amber-300 font-medium transition-colors"
|
||||
>
|
||||
{t("swap")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancelConflict}
|
||||
className="px-2 py-0.5 bg-white/5 hover:bg-white/10 border border-white/10 rounded text-slate-400 transition-colors"
|
||||
>
|
||||
{tc("actions.cancel")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-0.5 mt-2">
|
||||
@@ -222,7 +229,7 @@ export function ShortcutsConfigDialog() {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-slate-400 hover:text-white gap-1.5"
|
||||
className="text-slate-400 gap-1.5"
|
||||
onClick={handleReset}
|
||||
>
|
||||
<RotateCcw className="w-3 h-3" />
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useScopedT } from "@/contexts/I18nContext";
|
||||
|
||||
interface UnsavedChangesDialogProps {
|
||||
isOpen: boolean;
|
||||
variant?: "close" | "newProject" | "loadProject";
|
||||
onSaveAndClose: () => void;
|
||||
onDiscardAndClose: () => void;
|
||||
onCancel: () => void;
|
||||
@@ -17,6 +18,7 @@ interface UnsavedChangesDialogProps {
|
||||
|
||||
export function UnsavedChangesDialog({
|
||||
isOpen,
|
||||
variant = "close",
|
||||
onSaveAndClose,
|
||||
onDiscardAndClose,
|
||||
onCancel,
|
||||
@@ -24,6 +26,25 @@ export function UnsavedChangesDialog({
|
||||
const td = useScopedT("dialogs");
|
||||
const tc = useScopedT("common");
|
||||
|
||||
const detail =
|
||||
variant === "newProject"
|
||||
? td("unsavedChanges.detailNewProject")
|
||||
: variant === "loadProject"
|
||||
? td("unsavedChanges.detailLoadProject")
|
||||
: td("unsavedChanges.detail");
|
||||
const saveLabel =
|
||||
variant === "newProject"
|
||||
? td("unsavedChanges.saveAndNewProject")
|
||||
: variant === "loadProject"
|
||||
? td("unsavedChanges.saveAndLoadProject")
|
||||
: td("unsavedChanges.saveAndClose");
|
||||
const discardLabel =
|
||||
variant === "newProject"
|
||||
? td("unsavedChanges.discardAndNewProject")
|
||||
: variant === "loadProject"
|
||||
? td("unsavedChanges.discardAndLoadProject")
|
||||
: td("unsavedChanges.discardAndClose");
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && onCancel()}>
|
||||
<DialogContent className="bg-[#09090b] border-white/10 rounded-2xl max-w-sm p-6 gap-0">
|
||||
@@ -42,9 +63,7 @@ export function UnsavedChangesDialog({
|
||||
</DialogHeader>
|
||||
|
||||
<p className="text-sm text-slate-300 mb-1">{td("unsavedChanges.message")}</p>
|
||||
<DialogDescription className="text-sm text-slate-500 mb-6">
|
||||
{td("unsavedChanges.detail")}
|
||||
</DialogDescription>
|
||||
<DialogDescription className="text-sm text-slate-500 mb-6">{detail}</DialogDescription>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<button
|
||||
@@ -53,7 +72,7 @@ export function UnsavedChangesDialog({
|
||||
className="flex items-center justify-center gap-2 w-full px-4 py-2.5 rounded-lg bg-[#34B27B] hover:bg-[#2d9e6c] active:bg-[#27885c] text-white font-medium text-sm transition-colors outline-none focus-visible:ring-2 focus-visible:ring-[#34B27B] focus-visible:ring-offset-2 focus-visible:ring-offset-[#09090b]"
|
||||
>
|
||||
<Save className="w-4 h-4" />
|
||||
{td("unsavedChanges.saveAndClose")}
|
||||
{saveLabel}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -61,7 +80,7 @@ export function UnsavedChangesDialog({
|
||||
className="flex items-center justify-center gap-2 w-full px-4 py-2.5 rounded-lg bg-white/5 hover:bg-red-500/15 border border-white/10 hover:border-red-500/30 text-slate-300 hover:text-red-400 font-medium text-sm transition-colors outline-none focus-visible:ring-2 focus-visible:ring-white/30 focus-visible:ring-offset-2 focus-visible:ring-offset-[#09090b]"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
{td("unsavedChanges.discardAndClose")}
|
||||
{discardLabel}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -20,14 +20,15 @@ import {
|
||||
} from "react";
|
||||
import {
|
||||
getWebcamLayoutCssBoxShadow,
|
||||
reactiveWebcamScale,
|
||||
type Size,
|
||||
type StyledRenderRect,
|
||||
type WebcamLayoutPreset,
|
||||
type WebcamSizePreset,
|
||||
} from "@/lib/compositeLayout";
|
||||
import { getSmoothedCursorPath } from "@/lib/cursor/cursorPathSmoothing";
|
||||
import {
|
||||
createNativeCursorMotionBlurState,
|
||||
createNativeCursorSmoothingState,
|
||||
getNativeCursorClickBounceProgress,
|
||||
getNativeCursorClickBounceScale,
|
||||
getNativeCursorMotionBlurPx,
|
||||
@@ -35,10 +36,8 @@ import {
|
||||
projectNativeCursorToLocal,
|
||||
projectNativeCursorToStage,
|
||||
resetNativeCursorMotionBlurState,
|
||||
resetNativeCursorSmoothingState,
|
||||
resolveInterpolatedNativeCursorFrame,
|
||||
resolveNativeCursorRenderAsset,
|
||||
smoothNativeCursorSample,
|
||||
} from "@/lib/cursor/nativeCursor";
|
||||
import { classifyWallpaper, DEFAULT_WALLPAPER, resolveImageWallpaperUrl } from "@/lib/wallpaper";
|
||||
import { getCssClipPath } from "@/lib/webcamMaskShapes";
|
||||
@@ -49,46 +48,40 @@ import {
|
||||
getNativeAspectRatioValue,
|
||||
} from "@/utils/aspectRatioUtils";
|
||||
import { AnnotationOverlay } from "./AnnotationOverlay";
|
||||
import {
|
||||
DEFAULT_CURSOR_SETTINGS,
|
||||
DEFAULT_EDITOR_LAYOUT_SETTINGS,
|
||||
DEFAULT_SOURCE_DIMENSIONS,
|
||||
} from "./editorDefaults";
|
||||
import {
|
||||
type AnnotationRegion,
|
||||
type BlurData,
|
||||
type CursorTelemetryPoint,
|
||||
computeRotation3DContainScale,
|
||||
DEFAULT_CURSOR_CLICK_BOUNCE,
|
||||
DEFAULT_CURSOR_MOTION_BLUR,
|
||||
DEFAULT_CURSOR_SIZE,
|
||||
DEFAULT_CURSOR_SMOOTHING,
|
||||
DEFAULT_ROTATION_3D,
|
||||
getZoomScale,
|
||||
isRotation3DIdentity,
|
||||
lerpRotation3D,
|
||||
rotation3DPerspective,
|
||||
type SpeedRegion,
|
||||
type TrimRegion,
|
||||
ZOOM_DEPTH_SCALES,
|
||||
type ZoomDepth,
|
||||
type ZoomFocus,
|
||||
type ZoomRegion,
|
||||
} from "./types";
|
||||
import {
|
||||
AUTO_FOLLOW_RAMP_DISTANCE,
|
||||
AUTO_FOLLOW_SMOOTHING_FACTOR,
|
||||
AUTO_FOLLOW_SMOOTHING_FACTOR_MAX,
|
||||
DEFAULT_FOCUS,
|
||||
ZOOM_SCALE_DEADZONE,
|
||||
ZOOM_TRANSLATION_DEADZONE_PX,
|
||||
} from "./videoPlayback/constants";
|
||||
import { adaptiveSmoothFactor, smoothCursorFocus } from "./videoPlayback/cursorFollowUtils";
|
||||
import { AUTO_FOLLOW_PARAMS, DEFAULT_FOCUS } from "./videoPlayback/constants";
|
||||
import { advanceFollowFocus } from "./videoPlayback/cursorFollowUtils";
|
||||
import {
|
||||
DEFAULT_CURSOR_CONFIG,
|
||||
PixiCursorOverlay,
|
||||
preloadCursorAssets,
|
||||
} from "./videoPlayback/cursorRenderer";
|
||||
import { clampFocusToStage as clampFocusToStageUtil } from "./videoPlayback/focusUtils";
|
||||
import { clampFocusToScale } from "./videoPlayback/focusUtils";
|
||||
import { layoutVideoContent as layoutVideoContentUtil } from "./videoPlayback/layoutUtils";
|
||||
import { clamp01 } from "./videoPlayback/mathUtils";
|
||||
import { updateOverlayIndicator } from "./videoPlayback/overlayUtils";
|
||||
import { createVideoEventHandlers } from "./videoPlayback/videoEventHandlers";
|
||||
import { findDominantRegion } from "./videoPlayback/zoomRegionUtils";
|
||||
import { createZoomSpringState, resetZoomSpring, stepZoomSpring } from "./videoPlayback/zoomSpring";
|
||||
import {
|
||||
applyZoomTransform,
|
||||
computeFocusFromTransform,
|
||||
@@ -102,6 +95,8 @@ interface VideoPlaybackProps {
|
||||
webcamVideoPath?: string;
|
||||
webcamLayoutPreset: WebcamLayoutPreset;
|
||||
webcamMaskShape?: import("./types").WebcamMaskShape;
|
||||
webcamMirrored?: boolean;
|
||||
webcamReactiveZoom?: boolean;
|
||||
webcamSizePreset?: WebcamSizePreset;
|
||||
webcamPosition?: { cx: number; cy: number } | null;
|
||||
onWebcamPositionChange?: (position: { cx: number; cy: number }) => void;
|
||||
@@ -149,6 +144,10 @@ interface VideoPlaybackProps {
|
||||
cursorMotionBlur?: number;
|
||||
cursorClickBounce?: number;
|
||||
cursorClipToBounds?: boolean;
|
||||
cursorTheme?: string;
|
||||
// Render the selected zoom at the playhead even while paused, so the editor can
|
||||
// preview the effect without leaving the focus-edit view.
|
||||
isPreviewingZoom?: boolean;
|
||||
}
|
||||
|
||||
export interface VideoPlaybackRef {
|
||||
@@ -223,6 +222,8 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
webcamVideoPath,
|
||||
webcamLayoutPreset,
|
||||
webcamMaskShape,
|
||||
webcamMirrored = false,
|
||||
webcamReactiveZoom = false,
|
||||
webcamSizePreset,
|
||||
webcamPosition,
|
||||
onWebcamPositionChange,
|
||||
@@ -244,7 +245,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
showBlur,
|
||||
motionBlurAmount = 0,
|
||||
borderRadius = 0,
|
||||
padding = 50,
|
||||
padding = DEFAULT_EDITOR_LAYOUT_SETTINGS.padding,
|
||||
cropRegion,
|
||||
trimRegions = [],
|
||||
speedRegions = [],
|
||||
@@ -265,17 +266,23 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
cursorTelemetry = [],
|
||||
cursorClickTimestamps = [],
|
||||
showCursor = false,
|
||||
cursorSize = DEFAULT_CURSOR_SIZE,
|
||||
cursorSmoothing = DEFAULT_CURSOR_SMOOTHING,
|
||||
cursorMotionBlur = DEFAULT_CURSOR_MOTION_BLUR,
|
||||
cursorClickBounce = DEFAULT_CURSOR_CLICK_BOUNCE,
|
||||
cursorClipToBounds = false,
|
||||
cursorSize = DEFAULT_CURSOR_SETTINGS.size,
|
||||
cursorSmoothing = DEFAULT_CURSOR_SETTINGS.smoothing,
|
||||
cursorMotionBlur = DEFAULT_CURSOR_SETTINGS.motionBlur,
|
||||
cursorClickBounce = DEFAULT_CURSOR_SETTINGS.clickBounce,
|
||||
cursorClipToBounds = DEFAULT_CURSOR_SETTINGS.clipToBounds,
|
||||
cursorTheme = DEFAULT_CURSOR_SETTINGS.theme,
|
||||
isPreviewingZoom = false,
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||
const supplementalAudioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const webcamVideoRef = useRef<HTMLVideoElement | null>(null);
|
||||
const webcamWrapperRef = useRef<HTMLDivElement | null>(null);
|
||||
const webcamReactiveZoomRef = useRef(webcamReactiveZoom);
|
||||
const webcamLayoutPresetRef = useRef(webcamLayoutPreset);
|
||||
const webcamPositionRef = useRef(webcamPosition);
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const appRef = useRef<Application | null>(null);
|
||||
const videoSpriteRef = useRef<Sprite | null>(null);
|
||||
@@ -308,6 +315,9 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
y: 0,
|
||||
appliedScale: 1,
|
||||
});
|
||||
// Spring that chases the eased zoom target so the camera glides instead of jerking.
|
||||
const zoomSpringRef = useRef(createZoomSpringState());
|
||||
const prevZoomTimeMsRef = useRef<number | null>(null);
|
||||
const blurFilterRef = useRef<BlurFilter | null>(null);
|
||||
const motionBlurFilterRef = useRef<MotionBlurFilter | null>(null);
|
||||
const isDraggingFocusRef = useRef(false);
|
||||
@@ -341,6 +351,8 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
const cursorMotionBlurRef = useRef(cursorMotionBlur);
|
||||
const cursorClickBounceRef = useRef(cursorClickBounce);
|
||||
const cursorClipToBoundsRef = useRef(cursorClipToBounds);
|
||||
const cursorThemeRef = useRef(cursorTheme);
|
||||
const isPreviewingZoomRef = useRef(isPreviewingZoom);
|
||||
const motionBlurStateRef = useRef<MotionBlurState>(createMotionBlurState());
|
||||
const onTimeUpdateRef = useRef(onTimeUpdate);
|
||||
const onPlayStateChangeRef = useRef(onPlayStateChange);
|
||||
@@ -357,7 +369,6 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
const nativeCursorTextureIdRef = useRef<string | null>(null);
|
||||
const nativeCursorImageRef = useRef<HTMLImageElement | null>(null);
|
||||
const nativeCursorImageIdRef = useRef<string | null>(null);
|
||||
const nativeCursorSmoothingStateRef = useRef(createNativeCursorSmoothingState());
|
||||
const nativeCursorMotionBlurStateRef = useRef(createNativeCursorMotionBlurState());
|
||||
const nativeCursorClipRef = useRef<HTMLDivElement | null>(null);
|
||||
const borderRadiusRef = useRef<number>(0);
|
||||
@@ -471,8 +482,10 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
[onDurationChange, syncResolvedDuration],
|
||||
);
|
||||
|
||||
const clampFocusToStage = useCallback((focus: ZoomFocus, depth: ZoomDepth) => {
|
||||
return clampFocusToStageUtil(focus, depth, stageSizeRef.current);
|
||||
// Clamp against getZoomScale(region), not region.depth: depth is just the preset
|
||||
// slot (1x/2x/4x) and ignores customScale, which gives wrong drag bounds near the edges.
|
||||
const clampFocusForRegion = useCallback((focus: ZoomFocus, region: ZoomRegion) => {
|
||||
return clampFocusToScale(focus, getZoomScale(region));
|
||||
}, []);
|
||||
|
||||
const updateOverlayForRegion = useCallback(
|
||||
@@ -484,7 +497,6 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
return;
|
||||
}
|
||||
|
||||
// Update stage size from overlay dimensions
|
||||
const stageWidth = overlayEl.clientWidth;
|
||||
const stageHeight = overlayEl.clientHeight;
|
||||
if (stageWidth && stageHeight) {
|
||||
@@ -666,7 +678,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
cx: clamp01(localX / stageWidth),
|
||||
cy: clamp01(localY / stageHeight),
|
||||
};
|
||||
const clampedFocus = clampFocusToStage(unclampedFocus, region.depth);
|
||||
const clampedFocus = clampFocusForRegion(unclampedFocus, region);
|
||||
|
||||
onZoomFocusChange(region.id, clampedFocus);
|
||||
updateOverlayForRegion({ ...region, focus: clampedFocus }, clampedFocus);
|
||||
@@ -804,7 +816,6 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
|
||||
useEffect(() => {
|
||||
cursorRecordingDataRef.current = cursorRecordingData;
|
||||
resetNativeCursorSmoothingState(nativeCursorSmoothingStateRef.current);
|
||||
resetNativeCursorMotionBlurState(nativeCursorMotionBlurStateRef.current);
|
||||
}, [cursorRecordingData]);
|
||||
|
||||
@@ -832,6 +843,28 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
cursorClipToBoundsRef.current = cursorClipToBounds;
|
||||
}, [cursorClipToBounds]);
|
||||
|
||||
useEffect(() => {
|
||||
cursorThemeRef.current = cursorTheme;
|
||||
}, [cursorTheme]);
|
||||
|
||||
useEffect(() => {
|
||||
webcamReactiveZoomRef.current = webcamReactiveZoom;
|
||||
webcamLayoutPresetRef.current = webcamLayoutPreset;
|
||||
webcamPositionRef.current = webcamPosition;
|
||||
// Clear any reactive transform when the effect is turned off or layout changes,
|
||||
// so a stale shrink doesn't linger while the ticker isn't updating it.
|
||||
if (
|
||||
webcamWrapperRef.current &&
|
||||
(!webcamReactiveZoom || webcamLayoutPreset !== "picture-in-picture")
|
||||
) {
|
||||
webcamWrapperRef.current.style.transform = "";
|
||||
}
|
||||
}, [webcamReactiveZoom, webcamLayoutPreset, webcamPosition]);
|
||||
|
||||
useEffect(() => {
|
||||
isPreviewingZoomRef.current = isPreviewingZoom;
|
||||
}, [isPreviewingZoom]);
|
||||
|
||||
// Sync cursor overlay config when settings change
|
||||
useEffect(() => {
|
||||
const overlay = cursorOverlayRef.current;
|
||||
@@ -891,10 +924,9 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
};
|
||||
}, [pixiReady, videoReady, layoutVideoContent]);
|
||||
|
||||
// Drop the PIXI canvas resolution to 1.0 while scrubbing (the user is
|
||||
// navigating, not previewing) and restore native DPR on play/idle so the
|
||||
// preview stays faithful. Mutating renderer.resolution per-frame would
|
||||
// thrash texture uploads; we only do it on scrub-state transitions.
|
||||
// Drop canvas resolution to 1.0 while scrubbing and restore native DPR on play/idle.
|
||||
// Only on scrub-state transitions; mutating renderer.resolution per-frame thrashes
|
||||
// texture uploads.
|
||||
useEffect(() => {
|
||||
if (!pixiReady) return;
|
||||
const app = appRef.current;
|
||||
@@ -1280,12 +1312,32 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
motionBlurAmount: motionBlurAmountRef.current,
|
||||
transformOverride: transform,
|
||||
motionBlurState: motionBlurStateRef.current,
|
||||
frameTimeMs: performance.now(),
|
||||
// Content time, not wall-clock, so motion-blur velocity matches export and stays
|
||||
// correct under speed regions (frameRenderer passes the same content timeMs).
|
||||
frameTimeMs: currentTimeRef.current,
|
||||
});
|
||||
|
||||
state.x = appliedTransform.x;
|
||||
state.y = appliedTransform.y;
|
||||
state.appliedScale = appliedTransform.scale;
|
||||
|
||||
// Scale the PiP webcam inversely with the (eased) zoom, anchored to the docked
|
||||
// corner (bottom-right by default) so it stays flush instead of drifting to center.
|
||||
const webcamWrapper = webcamWrapperRef.current;
|
||||
if (webcamWrapper) {
|
||||
const reactive =
|
||||
webcamReactiveZoomRef.current && webcamLayoutPresetRef.current === "picture-in-picture";
|
||||
const factor = reactive ? reactiveWebcamScale(state.appliedScale) : 1;
|
||||
if (factor < 1) {
|
||||
const pos = webcamPositionRef.current;
|
||||
const originX = (pos ? pos.cx >= 0.5 : true) ? "100%" : "0%";
|
||||
const originY = (pos ? pos.cy >= 0.5 : true) ? "100%" : "0%";
|
||||
webcamWrapper.style.transformOrigin = `${originX} ${originY}`;
|
||||
webcamWrapper.style.transform = `scale(${factor})`;
|
||||
} else {
|
||||
webcamWrapper.style.transform = "";
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let lastMotionBlurActive: boolean | null = null;
|
||||
@@ -1306,52 +1358,55 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
let targetFocus = defaultFocus;
|
||||
let targetProgress = 0;
|
||||
|
||||
// If a zoom is selected but video is not playing, show default unzoomed view
|
||||
// If a zoom is selected but not playing, show the default unzoomed view.
|
||||
const selectedId = selectedZoomIdRef.current;
|
||||
const hasSelectedZoom = selectedId !== null;
|
||||
const shouldShowUnzoomedView = hasSelectedZoom && !isPlayingRef.current;
|
||||
const shouldShowUnzoomedView =
|
||||
hasSelectedZoom && !isPlayingRef.current && !isPreviewingZoomRef.current;
|
||||
|
||||
if (region && strength > 0 && !shouldShowUnzoomedView) {
|
||||
const zoomScale = blendedScale ?? ZOOM_DEPTH_SCALES[region.depth];
|
||||
// Use getZoomScale (customScale-aware) to match export and the magnification
|
||||
// findDominantRegion resolved focus at. Falling back to the depth preset would
|
||||
// zoom/pan to a different level than export.
|
||||
const zoomScale = blendedScale ?? getZoomScale(region);
|
||||
const regionFocus = region.focus;
|
||||
|
||||
targetScaleFactor = zoomScale;
|
||||
targetFocus = regionFocus;
|
||||
targetProgress = strength;
|
||||
|
||||
// Apply adaptive smoothing for auto-follow mode
|
||||
// Adaptive smoothing for auto-follow mode.
|
||||
if (region.focusMode === "auto" && !transition) {
|
||||
const raw = targetFocus;
|
||||
const isZoomingIn =
|
||||
targetProgress < 0.999 && targetProgress >= prevTargetProgressRef.current;
|
||||
// Follow the cursor in content time (frame-rate independent) so the camera pans
|
||||
// at the same speed in preview and export. Snap to target when not actively
|
||||
// playing (paused/seek/scrub), matching the zoom spring's snap.
|
||||
const focusAnimating =
|
||||
isPlayingRef.current && !isSeekingRef.current && !isScrubbingRef.current;
|
||||
const focusDtMs =
|
||||
prevZoomTimeMsRef.current === null
|
||||
? 0
|
||||
: currentTimeRef.current - prevZoomTimeMsRef.current;
|
||||
if (targetProgress >= 0.999) {
|
||||
// Full zoom: adaptive smoothing — moves faster when far, decelerates when close
|
||||
// Full zoom: adaptive smoothing, faster when far, decelerating when close.
|
||||
const prev = smoothedAutoFocusRef.current ?? raw;
|
||||
const factor = adaptiveSmoothFactor(
|
||||
raw,
|
||||
prev,
|
||||
AUTO_FOLLOW_SMOOTHING_FACTOR,
|
||||
AUTO_FOLLOW_SMOOTHING_FACTOR_MAX,
|
||||
AUTO_FOLLOW_RAMP_DISTANCE,
|
||||
);
|
||||
const smoothed = smoothCursorFocus(raw, prev, factor);
|
||||
const smoothed = focusAnimating
|
||||
? advanceFollowFocus(prev, raw, focusDtMs, AUTO_FOLLOW_PARAMS)
|
||||
: raw;
|
||||
smoothedAutoFocusRef.current = smoothed;
|
||||
targetFocus = smoothed;
|
||||
} else if (isZoomingIn) {
|
||||
// Zoom-in: track cursor directly so zoom always aims at current cursor
|
||||
// position; keep ref in sync to avoid snap when full-zoom begins
|
||||
// Zoom-in: track cursor directly so zoom always aims at the current position;
|
||||
// keep ref in sync to avoid a snap when full-zoom begins.
|
||||
smoothedAutoFocusRef.current = raw;
|
||||
} else {
|
||||
// Zoom-out: keep smoothing for continuity — avoids snap at zoom-out start
|
||||
// Zoom-out: keep smoothing for continuity to avoid a snap at zoom-out start.
|
||||
const prev = smoothedAutoFocusRef.current ?? raw;
|
||||
const factor = adaptiveSmoothFactor(
|
||||
raw,
|
||||
prev,
|
||||
AUTO_FOLLOW_SMOOTHING_FACTOR,
|
||||
AUTO_FOLLOW_SMOOTHING_FACTOR_MAX,
|
||||
AUTO_FOLLOW_RAMP_DISTANCE,
|
||||
);
|
||||
const smoothed = smoothCursorFocus(raw, prev, factor);
|
||||
const smoothed = focusAnimating
|
||||
? advanceFollowFocus(prev, raw, focusDtMs, AUTO_FOLLOW_PARAMS)
|
||||
: raw;
|
||||
smoothedAutoFocusRef.current = smoothed;
|
||||
targetFocus = smoothed;
|
||||
}
|
||||
@@ -1360,7 +1415,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
}
|
||||
prevTargetProgressRef.current = targetProgress;
|
||||
|
||||
// Handle connected zoom transitions (pan between adjacent zoom regions)
|
||||
// Connected zoom transitions: pan between adjacent regions.
|
||||
if (transition) {
|
||||
const startTransform = computeZoomTransform({
|
||||
stageSize: stageSizeRef.current,
|
||||
@@ -1418,18 +1473,28 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
focusY: state.focusY,
|
||||
});
|
||||
|
||||
const appliedScale =
|
||||
Math.abs(projectedTransform.scale - prevScale) < ZOOM_SCALE_DEADZONE
|
||||
? projectedTransform.scale
|
||||
: projectedTransform.scale;
|
||||
const appliedX =
|
||||
Math.abs(projectedTransform.x - prevX) < ZOOM_TRANSLATION_DEADZONE_PX
|
||||
? projectedTransform.x
|
||||
: projectedTransform.x;
|
||||
const appliedY =
|
||||
Math.abs(projectedTransform.y - prevY) < ZOOM_TRANSLATION_DEADZONE_PX
|
||||
? projectedTransform.y
|
||||
: projectedTransform.y;
|
||||
// Chase the eased target with a spring so the camera glides (no jerk at the steep
|
||||
// start of the ease, no snap at close-region seams). Step by content time while
|
||||
// playing; snap to the exact target when paused/seeking/scrubbing for crisp frames.
|
||||
const nowMs = currentTimeRef.current;
|
||||
const prevMs = prevZoomTimeMsRef.current;
|
||||
const animating = isPlayingRef.current && !isSeekingRef.current && !isScrubbingRef.current;
|
||||
const dtMs = prevMs === null ? 0 : nowMs - prevMs;
|
||||
let appliedScale: number;
|
||||
let appliedX: number;
|
||||
let appliedY: number;
|
||||
if (!animating || prevMs === null || dtMs <= 0 || dtMs > 80) {
|
||||
resetZoomSpring(zoomSpringRef.current, projectedTransform);
|
||||
appliedScale = projectedTransform.scale;
|
||||
appliedX = projectedTransform.x;
|
||||
appliedY = projectedTransform.y;
|
||||
} else {
|
||||
const sprung = stepZoomSpring(zoomSpringRef.current, projectedTransform, dtMs);
|
||||
appliedScale = sprung.scale;
|
||||
appliedX = sprung.x;
|
||||
appliedY = sprung.y;
|
||||
}
|
||||
prevZoomTimeMsRef.current = nowMs;
|
||||
|
||||
const motionIntensity = Math.max(
|
||||
Math.abs(appliedScale - prevScale),
|
||||
@@ -1467,7 +1532,6 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
}
|
||||
}
|
||||
|
||||
// Update cursor overlay
|
||||
const cursorOverlay = cursorOverlayRef.current;
|
||||
if (cursorOverlay) {
|
||||
const timeMs = currentTimeRef.current; // already in ms
|
||||
@@ -1494,7 +1558,6 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
if (nativeCursorClipRef.current) {
|
||||
nativeCursorClipRef.current.style.clipPath = "";
|
||||
}
|
||||
resetNativeCursorSmoothingState(nativeCursorSmoothingStateRef.current);
|
||||
resetNativeCursorMotionBlurState(nativeCursorMotionBlurStateRef.current);
|
||||
};
|
||||
if (nativeCursorImage) {
|
||||
@@ -1505,13 +1568,15 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
timeMs,
|
||||
);
|
||||
if (frame) {
|
||||
const displaySample = smoothNativeCursorSample({
|
||||
forceSnap: !isPlayingRef.current || isSeekingRef.current,
|
||||
sample: frame.sample,
|
||||
smoothing: cursorSmoothingRef.current,
|
||||
state: nativeCursorSmoothingStateRef.current,
|
||||
timeMs,
|
||||
});
|
||||
// Position comes from the precomputed offline-smoothed path; the frame still
|
||||
// supplies the cursor image, type, and click timing.
|
||||
const smoothedPos = getSmoothedCursorPath(
|
||||
cursorRecordingDataRef.current,
|
||||
cursorSmoothingRef.current,
|
||||
)?.sampleAt(timeMs);
|
||||
const displaySample = smoothedPos
|
||||
? { ...frame.sample, cx: smoothedPos.cx, cy: smoothedPos.cy }
|
||||
: frame.sample;
|
||||
const cameraContainer = cameraContainerRef.current;
|
||||
const videoContainer = videoContainerRef.current;
|
||||
const cropRegionValue = cropRegionRef.current ?? { x: 0, y: 0, width: 1, height: 1 };
|
||||
@@ -1534,9 +1599,14 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
})
|
||||
: null;
|
||||
if (projectedLocalPoint && projectedStagePoint) {
|
||||
// Pass deviceScaleFactor=1 — asset.scaleFactor already encodes DPR.
|
||||
// Pass deviceScaleFactor=1 since asset.scaleFactor already encodes DPR.
|
||||
// Size is normalized below so preview matches export proportionally.
|
||||
const renderAsset = resolveNativeCursorRenderAsset(frame.asset, 1, displaySample);
|
||||
const renderAsset = resolveNativeCursorRenderAsset(
|
||||
frame.asset,
|
||||
1,
|
||||
displaySample,
|
||||
cursorThemeRef.current,
|
||||
);
|
||||
const bounceProgress = getNativeCursorClickBounceProgress(
|
||||
cursorRecordingDataRef.current,
|
||||
timeMs,
|
||||
@@ -1565,9 +1635,8 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
nativeCursorImageIdRef.current = renderAsset.id;
|
||||
}
|
||||
nativeCursorImage.style.display = "block";
|
||||
// Update clip-path on nativeCursorClipRef to the camera-aware video boundary.
|
||||
// clip-path works correctly here because nativeCursorClipRef is outside preserve-3d.
|
||||
// When cursorClipToBounds is off, allow the cursor to overflow the canvas.
|
||||
// Clip to the camera-aware video boundary. Works here because nativeCursorClipRef
|
||||
// sits outside preserve-3d. When cursorClipToBounds is off, let the cursor overflow.
|
||||
if (nativeCursorClipRef.current) {
|
||||
if (!cursorClipToBoundsRef.current) {
|
||||
nativeCursorClipRef.current.style.clipPath = "none";
|
||||
@@ -1590,7 +1659,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
nativeCursorImage.style.filter =
|
||||
blurPx > 0 ? `blur(${blurPx.toFixed(2)}px)` : "none";
|
||||
// translate3d is relative to nativeCursorClipRef (absolute inset-0 = stage origin).
|
||||
// projectedStagePoint.x is the stage-space cursor position — no offset needed.
|
||||
// projectedStagePoint.x is the stage-space cursor position, so no offset is needed.
|
||||
nativeCursorImage.style.transform = `translate3d(${
|
||||
projectedStagePoint.x - renderAsset.hotspotX * transformedScale
|
||||
}px, ${projectedStagePoint.y - renderAsset.hotspotY * transformedScale}px, 0)`;
|
||||
@@ -1824,15 +1893,15 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
aspectRatio,
|
||||
aspectRatio === "native"
|
||||
? getNativeAspectRatioValue(
|
||||
lockedVideoDimensionsRef.current?.width || 1920,
|
||||
lockedVideoDimensionsRef.current?.height || 1080,
|
||||
lockedVideoDimensionsRef.current?.width || DEFAULT_SOURCE_DIMENSIONS.width,
|
||||
lockedVideoDimensionsRef.current?.height || DEFAULT_SOURCE_DIMENSIONS.height,
|
||||
cropRegion,
|
||||
)
|
||||
: undefined,
|
||||
),
|
||||
}}
|
||||
>
|
||||
{/* Background layer - always render as DOM element with blur */}
|
||||
{/* Background always renders as a DOM element so it can be blurred. */}
|
||||
<div
|
||||
className="absolute inset-0 bg-cover bg-center"
|
||||
style={{
|
||||
@@ -1864,6 +1933,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
const useClipPath = !!clipPath;
|
||||
return (
|
||||
<div
|
||||
ref={webcamWrapperRef}
|
||||
className="absolute"
|
||||
style={{
|
||||
left: webcamLayout?.x ?? 0,
|
||||
@@ -1887,6 +1957,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
clipPath: clipPath ?? undefined,
|
||||
boxShadow: useClipPath ? "none" : webcamCssBoxShadow,
|
||||
backgroundColor: "#000",
|
||||
transform: webcamMirrored ? "scaleX(-1)" : undefined,
|
||||
}}
|
||||
onPointerDown={handleWebcamPointerDown}
|
||||
onPointerMove={handleWebcamPointerMove}
|
||||
@@ -1899,7 +1970,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
{/* Only render overlay after PIXI and video are fully initialized */}
|
||||
{/* Render the overlay only once PIXI and video are ready. */}
|
||||
{pixiReady && videoReady && (
|
||||
<div
|
||||
ref={setOverlayRefs}
|
||||
@@ -1965,18 +2036,15 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
})()
|
||||
: 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);
|
||||
}
|
||||
};
|
||||
@@ -2033,16 +2101,15 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
}
|
||||
previewSourceCanvas={previewSnapshotCanvas}
|
||||
previewFrameVersion={Math.round(currentTime * 1000)}
|
||||
currentTimeMs={Math.round(currentTime * 1000)}
|
||||
/>
|
||||
));
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* 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. */}
|
||||
<div
|
||||
ref={nativeCursorClipRef}
|
||||
className="absolute inset-0"
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isSupportedBackgroundImageType } from "./backgroundImageUpload";
|
||||
|
||||
describe("background image upload validation", () => {
|
||||
it("accepts PNG images for custom backgrounds", () => {
|
||||
expect(isSupportedBackgroundImageType("image/png", "生成画像1.png")).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts PNG images by extension when the browser does not provide a MIME type", () => {
|
||||
expect(isSupportedBackgroundImageType("", "生成画像1.png")).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps rejecting non-image uploads", () => {
|
||||
expect(isSupportedBackgroundImageType("text/plain", "notes.txt")).toBe(false);
|
||||
});
|
||||
|
||||
it("does not allow extension fallback for explicit unsupported MIME types", () => {
|
||||
expect(isSupportedBackgroundImageType("text/plain", "notes.png")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
const SUPPORTED_BACKGROUND_IMAGE_TYPES = new Set(["image/jpeg", "image/jpg", "image/png"]);
|
||||
const SUPPORTED_BACKGROUND_IMAGE_EXTENSIONS = new Set([".jpg", ".jpeg", ".png"]);
|
||||
|
||||
export const BACKGROUND_IMAGE_ACCEPT = ".jpg,.jpeg,.png,image/jpeg,image/png";
|
||||
|
||||
export function isSupportedBackgroundImageType(type: string, fileName: string): boolean {
|
||||
const normalizedType = type.trim().toLowerCase();
|
||||
if (SUPPORTED_BACKGROUND_IMAGE_TYPES.has(normalizedType)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (normalizedType) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const lowerName = fileName.trim().toLowerCase();
|
||||
return [...SUPPORTED_BACKGROUND_IMAGE_EXTENSIONS].some((extension) =>
|
||||
lowerName.endsWith(extension),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { INITIAL_EDITOR_STATE } from "@/hooks/useEditorHistory";
|
||||
import { DEFAULT_PREFS } from "@/lib/userPreferences";
|
||||
import {
|
||||
DEFAULT_EDITOR_APPEARANCE_SETTINGS,
|
||||
DEFAULT_EDITOR_LAYOUT_SETTINGS,
|
||||
DEFAULT_EXPORT_SETTINGS,
|
||||
DEFAULT_GIF_SETTINGS,
|
||||
DEFAULT_WEBCAM_SETTINGS,
|
||||
} from "./editorDefaults";
|
||||
import { normalizeProjectEditor } from "./projectPersistence";
|
||||
|
||||
describe("editor defaults SSOT", () => {
|
||||
it("keeps history defaults aligned with editor defaults", () => {
|
||||
expect(INITIAL_EDITOR_STATE).toMatchObject({
|
||||
...DEFAULT_EDITOR_APPEARANCE_SETTINGS,
|
||||
padding: DEFAULT_EDITOR_LAYOUT_SETTINGS.padding,
|
||||
aspectRatio: DEFAULT_EDITOR_LAYOUT_SETTINGS.aspectRatio,
|
||||
cropRegion: DEFAULT_EDITOR_LAYOUT_SETTINGS.cropRegion,
|
||||
wallpaper: DEFAULT_EDITOR_LAYOUT_SETTINGS.wallpaper,
|
||||
webcamLayoutPreset: DEFAULT_WEBCAM_SETTINGS.layoutPreset,
|
||||
webcamMaskShape: DEFAULT_WEBCAM_SETTINGS.maskShape,
|
||||
webcamSizePreset: DEFAULT_WEBCAM_SETTINGS.sizePreset,
|
||||
webcamPosition: DEFAULT_WEBCAM_SETTINGS.position,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps user preference defaults aligned with editor and export defaults", () => {
|
||||
expect(DEFAULT_PREFS).toMatchObject({
|
||||
padding: DEFAULT_EDITOR_LAYOUT_SETTINGS.padding,
|
||||
aspectRatio: DEFAULT_EDITOR_LAYOUT_SETTINGS.aspectRatio,
|
||||
exportQuality: DEFAULT_EXPORT_SETTINGS.quality,
|
||||
exportFormat: DEFAULT_EXPORT_SETTINGS.format,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps project fallback normalization aligned with editor defaults", () => {
|
||||
expect(normalizeProjectEditor({})).toMatchObject({
|
||||
...DEFAULT_EDITOR_APPEARANCE_SETTINGS,
|
||||
padding: DEFAULT_EDITOR_LAYOUT_SETTINGS.padding,
|
||||
cropRegion: DEFAULT_EDITOR_LAYOUT_SETTINGS.cropRegion,
|
||||
wallpaper: DEFAULT_EDITOR_LAYOUT_SETTINGS.wallpaper,
|
||||
aspectRatio: DEFAULT_EDITOR_LAYOUT_SETTINGS.aspectRatio,
|
||||
webcamLayoutPreset: DEFAULT_WEBCAM_SETTINGS.layoutPreset,
|
||||
webcamMaskShape: DEFAULT_WEBCAM_SETTINGS.maskShape,
|
||||
webcamSizePreset: DEFAULT_WEBCAM_SETTINGS.sizePreset,
|
||||
webcamPosition: DEFAULT_WEBCAM_SETTINGS.position,
|
||||
exportQuality: DEFAULT_EXPORT_SETTINGS.quality,
|
||||
exportFormat: DEFAULT_EXPORT_SETTINGS.format,
|
||||
gifFrameRate: DEFAULT_GIF_SETTINGS.frameRate,
|
||||
gifLoop: DEFAULT_GIF_SETTINGS.loop,
|
||||
gifSizePreset: DEFAULT_GIF_SETTINGS.sizePreset,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import { DEFAULT_CURSOR_THEME_ID } from "@/lib/cursor/cursorThemes";
|
||||
import type { ExportFormat, ExportQuality, GifFrameRate, GifSizePreset } from "@/lib/exporter";
|
||||
import { DEFAULT_WALLPAPER } from "@/lib/wallpaper";
|
||||
import type { AspectRatio } from "@/utils/aspectRatioUtils";
|
||||
import {
|
||||
type CursorVisualSettings,
|
||||
DEFAULT_CROP_REGION,
|
||||
DEFAULT_CURSOR_CLICK_BOUNCE,
|
||||
DEFAULT_CURSOR_CLIP_TO_BOUNDS,
|
||||
DEFAULT_CURSOR_MOTION_BLUR,
|
||||
DEFAULT_CURSOR_SIZE,
|
||||
DEFAULT_CURSOR_SMOOTHING,
|
||||
DEFAULT_WEBCAM_LAYOUT_PRESET,
|
||||
DEFAULT_WEBCAM_MASK_SHAPE,
|
||||
DEFAULT_WEBCAM_POSITION,
|
||||
DEFAULT_WEBCAM_SIZE_PRESET,
|
||||
type WebcamLayoutPreset,
|
||||
type WebcamMaskShape,
|
||||
type WebcamPosition,
|
||||
type WebcamSizePreset,
|
||||
} from "./types";
|
||||
|
||||
export const DEFAULT_SOURCE_DIMENSIONS = {
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
} as const;
|
||||
|
||||
export const DEFAULT_GIF_OUTPUT_DIMENSIONS = {
|
||||
width: 1280,
|
||||
height: 720,
|
||||
} as const;
|
||||
|
||||
export const DEFAULT_EDITOR_APPEARANCE_SETTINGS: {
|
||||
shadowIntensity: number;
|
||||
showBlur: boolean;
|
||||
motionBlurAmount: number;
|
||||
borderRadius: number;
|
||||
showTrimWaveform: boolean;
|
||||
} = {
|
||||
shadowIntensity: 0,
|
||||
showBlur: false,
|
||||
motionBlurAmount: 0,
|
||||
borderRadius: 0,
|
||||
showTrimWaveform: true,
|
||||
};
|
||||
|
||||
export const DEFAULT_EDITOR_LAYOUT_SETTINGS: {
|
||||
padding: number;
|
||||
aspectRatio: AspectRatio;
|
||||
cropRegion: typeof DEFAULT_CROP_REGION;
|
||||
wallpaper: string;
|
||||
} = {
|
||||
padding: 50,
|
||||
aspectRatio: "16:9",
|
||||
cropRegion: DEFAULT_CROP_REGION,
|
||||
wallpaper: DEFAULT_WALLPAPER,
|
||||
};
|
||||
|
||||
export const DEFAULT_WEBCAM_SETTINGS = {
|
||||
layoutPreset: DEFAULT_WEBCAM_LAYOUT_PRESET,
|
||||
maskShape: DEFAULT_WEBCAM_MASK_SHAPE,
|
||||
sizePreset: DEFAULT_WEBCAM_SIZE_PRESET,
|
||||
position: DEFAULT_WEBCAM_POSITION,
|
||||
} as const satisfies {
|
||||
layoutPreset: WebcamLayoutPreset;
|
||||
maskShape: WebcamMaskShape;
|
||||
sizePreset: WebcamSizePreset;
|
||||
position: WebcamPosition | null;
|
||||
};
|
||||
|
||||
export const DEFAULT_CURSOR_SETTINGS: CursorVisualSettings & { show: boolean; theme: string } = {
|
||||
show: true,
|
||||
size: DEFAULT_CURSOR_SIZE,
|
||||
smoothing: DEFAULT_CURSOR_SMOOTHING,
|
||||
motionBlur: DEFAULT_CURSOR_MOTION_BLUR,
|
||||
clickBounce: DEFAULT_CURSOR_CLICK_BOUNCE,
|
||||
clipToBounds: DEFAULT_CURSOR_CLIP_TO_BOUNDS,
|
||||
theme: DEFAULT_CURSOR_THEME_ID,
|
||||
};
|
||||
|
||||
export const DEFAULT_EXPORT_SETTINGS: {
|
||||
quality: ExportQuality;
|
||||
format: ExportFormat;
|
||||
} = {
|
||||
quality: "good",
|
||||
format: "mp4",
|
||||
};
|
||||
|
||||
export const DEFAULT_GIF_SETTINGS: {
|
||||
frameRate: GifFrameRate;
|
||||
loop: boolean;
|
||||
sizePreset: GifSizePreset;
|
||||
outputDimensions: typeof DEFAULT_GIF_OUTPUT_DIMENSIONS;
|
||||
} = {
|
||||
frameRate: 15,
|
||||
loop: true,
|
||||
sizePreset: "medium",
|
||||
outputDimensions: DEFAULT_GIF_OUTPUT_DIMENSIONS,
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export const BLUR_REGIONS_ENABLED = false;
|
||||
@@ -44,6 +44,8 @@ describe("projectPersistence media compatibility", () => {
|
||||
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: [
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
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";
|
||||
import { DEFAULT_WALLPAPER, WALLPAPER_PATHS } from "@/lib/wallpaper";
|
||||
import { ASPECT_RATIOS, type AspectRatio, isPortraitAspectRatio } from "@/utils/aspectRatioUtils";
|
||||
import {
|
||||
DEFAULT_EDITOR_APPEARANCE_SETTINGS,
|
||||
DEFAULT_EDITOR_LAYOUT_SETTINGS,
|
||||
DEFAULT_EXPORT_SETTINGS,
|
||||
DEFAULT_GIF_SETTINGS,
|
||||
DEFAULT_WEBCAM_SETTINGS,
|
||||
} from "./editorDefaults";
|
||||
import {
|
||||
type AnnotationRegion,
|
||||
type CropRegion,
|
||||
@@ -15,14 +24,12 @@ import {
|
||||
DEFAULT_BLUR_DATA,
|
||||
DEFAULT_BLUR_FREEHAND_POINTS,
|
||||
DEFAULT_BLUR_INTENSITY,
|
||||
DEFAULT_CROP_REGION,
|
||||
DEFAULT_FIGURE_DATA,
|
||||
DEFAULT_PLAYBACK_SPEED,
|
||||
DEFAULT_WEBCAM_LAYOUT_PRESET,
|
||||
DEFAULT_WEBCAM_MASK_SHAPE,
|
||||
DEFAULT_WEBCAM_POSITION,
|
||||
DEFAULT_WEBCAM_SIZE_PRESET,
|
||||
DEFAULT_WEBCAM_MIRRORED,
|
||||
DEFAULT_WEBCAM_REACTIVE_ZOOM,
|
||||
DEFAULT_ZOOM_DEPTH,
|
||||
DEFAULT_ZOOM_MOTION_BLUR,
|
||||
MAX_BLUR_BLOCK_SIZE,
|
||||
MAX_BLUR_INTENSITY,
|
||||
MAX_PLAYBACK_SPEED,
|
||||
@@ -40,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);
|
||||
@@ -62,17 +68,22 @@ export interface ProjectEditorState {
|
||||
wallpaper: string;
|
||||
shadowIntensity: number;
|
||||
showBlur: boolean;
|
||||
showTrimWaveform: boolean;
|
||||
motionBlurAmount: number;
|
||||
borderRadius: number;
|
||||
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;
|
||||
@@ -80,6 +91,7 @@ export interface ProjectEditorState {
|
||||
gifFrameRate: GifFrameRate;
|
||||
gifLoop: boolean;
|
||||
gifSizePreset: GifSizePreset;
|
||||
cursorTheme: string;
|
||||
}
|
||||
|
||||
export interface EditorProjectData {
|
||||
@@ -104,13 +116,13 @@ function computeNormalizedWebcamLayoutPreset(
|
||||
case "vertical-stack":
|
||||
return isPortraitAspectRatio(normalizedAspectRatio)
|
||||
? webcamLayoutPreset
|
||||
: DEFAULT_WEBCAM_LAYOUT_PRESET;
|
||||
: DEFAULT_WEBCAM_SETTINGS.layoutPreset;
|
||||
case "dual-frame":
|
||||
return isPortraitAspectRatio(normalizedAspectRatio)
|
||||
? DEFAULT_WEBCAM_LAYOUT_PRESET
|
||||
? DEFAULT_WEBCAM_SETTINGS.layoutPreset
|
||||
: webcamLayoutPreset;
|
||||
default:
|
||||
return DEFAULT_WEBCAM_LAYOUT_PRESET;
|
||||
return DEFAULT_WEBCAM_SETTINGS.layoutPreset;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -211,7 +223,7 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
|
||||
editor.aspectRatio as AspectRatio,
|
||||
)
|
||||
? (editor.aspectRatio as AspectRatio)
|
||||
: "16:9";
|
||||
: DEFAULT_EDITOR_LAYOUT_SETTINGS.aspectRatio;
|
||||
const normalizedWebcamLayoutPreset = computeNormalizedWebcamLayoutPreset(
|
||||
editor.webcamLayoutPreset,
|
||||
normalizedAspectRatio,
|
||||
@@ -226,7 +238,7 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
|
||||
cx: clamp((editor.webcamPosition as WebcamPosition).cx, 0, 1),
|
||||
cy: clamp((editor.webcamPosition as WebcamPosition).cy, 0, 1),
|
||||
}
|
||||
: DEFAULT_WEBCAM_POSITION;
|
||||
: DEFAULT_WEBCAM_SETTINGS.position;
|
||||
|
||||
const normalizedZoomRegions: ZoomRegion[] = Array.isArray(editor.zoomRegions)
|
||||
? editor.zoomRegions
|
||||
@@ -253,6 +265,7 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): 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 } : {}),
|
||||
};
|
||||
})
|
||||
@@ -328,6 +341,8 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): 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)
|
||||
@@ -363,6 +378,7 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
|
||||
style: {
|
||||
...DEFAULT_ANNOTATION_STYLE,
|
||||
...(region.style && typeof region.style === "object" ? region.style : {}),
|
||||
textAnimation: normalizeTextAnimation(region.style?.textAnimation),
|
||||
},
|
||||
zIndex: isFiniteNumber(region.zIndex) ? region.zIndex : index + 1,
|
||||
figureData: region.figureData
|
||||
@@ -413,16 +429,16 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
|
||||
|
||||
const rawCropX = isFiniteNumber(editor.cropRegion?.x)
|
||||
? editor.cropRegion.x
|
||||
: DEFAULT_CROP_REGION.x;
|
||||
: DEFAULT_EDITOR_LAYOUT_SETTINGS.cropRegion.x;
|
||||
const rawCropY = isFiniteNumber(editor.cropRegion?.y)
|
||||
? editor.cropRegion.y
|
||||
: DEFAULT_CROP_REGION.y;
|
||||
: DEFAULT_EDITOR_LAYOUT_SETTINGS.cropRegion.y;
|
||||
const rawCropWidth = isFiniteNumber(editor.cropRegion?.width)
|
||||
? editor.cropRegion.width
|
||||
: DEFAULT_CROP_REGION.width;
|
||||
: DEFAULT_EDITOR_LAYOUT_SETTINGS.cropRegion.width;
|
||||
const rawCropHeight = isFiniteNumber(editor.cropRegion?.height)
|
||||
? editor.cropRegion.height
|
||||
: DEFAULT_CROP_REGION.height;
|
||||
: DEFAULT_EDITOR_LAYOUT_SETTINGS.cropRegion.height;
|
||||
|
||||
const cropX = clamp(rawCropX, 0, 1);
|
||||
const cropY = clamp(rawCropY, 0, 1);
|
||||
@@ -430,21 +446,37 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
|
||||
const cropHeight = clamp(rawCropHeight, 0.01, 1 - cropY);
|
||||
|
||||
return {
|
||||
cursorTheme: normalizeCursorThemeId(editor.cursorTheme),
|
||||
wallpaper:
|
||||
typeof editor.wallpaper === "string"
|
||||
? normalizeWallpaperValue(editor.wallpaper)
|
||||
: DEFAULT_WALLPAPER,
|
||||
shadowIntensity: typeof editor.shadowIntensity === "number" ? editor.shadowIntensity : 0,
|
||||
showBlur: typeof editor.showBlur === "boolean" ? editor.showBlur : false,
|
||||
: DEFAULT_EDITOR_LAYOUT_SETTINGS.wallpaper,
|
||||
shadowIntensity:
|
||||
typeof editor.shadowIntensity === "number"
|
||||
? editor.shadowIntensity
|
||||
: DEFAULT_EDITOR_APPEARANCE_SETTINGS.shadowIntensity,
|
||||
showBlur:
|
||||
typeof editor.showBlur === "boolean"
|
||||
? editor.showBlur
|
||||
: DEFAULT_EDITOR_APPEARANCE_SETTINGS.showBlur,
|
||||
showTrimWaveform:
|
||||
typeof editor.showTrimWaveform === "boolean"
|
||||
? editor.showTrimWaveform
|
||||
: DEFAULT_EDITOR_APPEARANCE_SETTINGS.showTrimWaveform,
|
||||
motionBlurAmount: isFiniteNumber(editor.motionBlurAmount)
|
||||
? clamp(editor.motionBlurAmount, 0, 1)
|
||||
: typeof (editor as { motionBlurEnabled?: unknown }).motionBlurEnabled === "boolean"
|
||||
? (editor as { motionBlurEnabled?: boolean }).motionBlurEnabled
|
||||
? 0.35
|
||||
: 0
|
||||
: 0,
|
||||
borderRadius: typeof editor.borderRadius === "number" ? editor.borderRadius : 0,
|
||||
padding: isFiniteNumber(editor.padding) ? clamp(editor.padding, 0, 100) : 50,
|
||||
? DEFAULT_ZOOM_MOTION_BLUR
|
||||
: DEFAULT_EDITOR_APPEARANCE_SETTINGS.motionBlurAmount
|
||||
: DEFAULT_EDITOR_APPEARANCE_SETTINGS.motionBlurAmount,
|
||||
borderRadius:
|
||||
typeof editor.borderRadius === "number"
|
||||
? editor.borderRadius
|
||||
: DEFAULT_EDITOR_APPEARANCE_SETTINGS.borderRadius,
|
||||
padding: isFiniteNumber(editor.padding)
|
||||
? clamp(editor.padding, 0, 100)
|
||||
: DEFAULT_EDITOR_LAYOUT_SETTINGS.padding,
|
||||
cropRegion: {
|
||||
x: cropX,
|
||||
y: cropY,
|
||||
@@ -452,6 +484,10 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): 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,
|
||||
@@ -463,31 +499,37 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
|
||||
editor.webcamMaskShape === "square" ||
|
||||
editor.webcamMaskShape === "rounded"
|
||||
? editor.webcamMaskShape
|
||||
: DEFAULT_WEBCAM_MASK_SHAPE,
|
||||
: 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))
|
||||
: DEFAULT_WEBCAM_SIZE_PRESET,
|
||||
: DEFAULT_WEBCAM_SETTINGS.sizePreset,
|
||||
webcamPosition: normalizedWebcamPosition,
|
||||
exportQuality:
|
||||
editor.exportQuality === "medium" || editor.exportQuality === "source"
|
||||
? editor.exportQuality
|
||||
: "good",
|
||||
exportFormat: editor.exportFormat === "gif" ? "gif" : "mp4",
|
||||
: DEFAULT_EXPORT_SETTINGS.quality,
|
||||
exportFormat: editor.exportFormat === "gif" ? "gif" : DEFAULT_EXPORT_SETTINGS.format,
|
||||
gifFrameRate:
|
||||
editor.gifFrameRate === 15 ||
|
||||
editor.gifFrameRate === 20 ||
|
||||
editor.gifFrameRate === 25 ||
|
||||
editor.gifFrameRate === 30
|
||||
? editor.gifFrameRate
|
||||
: 15,
|
||||
gifLoop: typeof editor.gifLoop === "boolean" ? editor.gifLoop : true,
|
||||
: DEFAULT_GIF_SETTINGS.frameRate,
|
||||
gifLoop: typeof editor.gifLoop === "boolean" ? editor.gifLoop : DEFAULT_GIF_SETTINGS.loop,
|
||||
gifSizePreset:
|
||||
editor.gifSizePreset === "medium" ||
|
||||
editor.gifSizePreset === "large" ||
|
||||
editor.gifSizePreset === "original"
|
||||
? editor.gifSizePreset
|
||||
: "medium",
|
||||
: DEFAULT_GIF_SETTINGS.sizePreset,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import { useTimelineContext } from "dnd-timeline";
|
||||
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: pairs of [min, max] per block (length = 2 * N). */
|
||||
peaks: Float32Array | null;
|
||||
videoDurationMs: number;
|
||||
/** Inset from canvas top so the waveform aligns with item content top. Defaults to 0. */
|
||||
topInset?: number;
|
||||
/** 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 canvas filling its block.
|
||||
* Pass as the `background` prop of `<Row>`, which already provides
|
||||
* `relative overflow-hidden`.
|
||||
*
|
||||
* Canvas is always `inset-0` (full row height); vertical alignment comes from
|
||||
* `topInset`/`bottomInset` in the draw calls, not CSS, so it's immune to
|
||||
* sub-pixel layout rounding. `pointer-events: none` keeps drag-to-create working.
|
||||
*/
|
||||
export default function BackgroundWaveform({
|
||||
peaks,
|
||||
videoDurationMs,
|
||||
topInset = 0,
|
||||
bottomInset = 0,
|
||||
}: BackgroundWaveformProps) {
|
||||
const { range } = useTimelineContext();
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const [canvasSize, setCanvasSize] = useState({ w: 0, h: 0 });
|
||||
|
||||
// Normalize against the track's own loudest peak so quiet recordings (mic/system
|
||||
// audio rarely hit full scale) still fill the row. Recomputed only on peaks change,
|
||||
// not zoom/pan, so height stays stable while scrolling.
|
||||
const normFactor = useMemo(() => {
|
||||
if (!peaks || peaks.length === 0) return 0;
|
||||
let globalMax = 0;
|
||||
for (let i = 0; i < peaks.length; i++) {
|
||||
const a = Math.abs(peaks[i]);
|
||||
if (a > globalMax) globalMax = a;
|
||||
}
|
||||
return globalMax > 0 ? 1 / globalMax : 0;
|
||||
}, [peaks]);
|
||||
|
||||
// Observe the canvas directly; Row's `relative overflow-hidden` parent makes
|
||||
// it fill the row exactly, so no wrapper div is needed.
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const ro = new ResizeObserver((entries) => {
|
||||
const { width, height } = entries[0].contentRect;
|
||||
setCanvasSize({ w: width, h: height });
|
||||
});
|
||||
ro.observe(canvas);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas || canvasSize.w <= 0 || canvasSize.h <= 0) return;
|
||||
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
canvas.width = Math.round(canvasSize.w * dpr);
|
||||
canvas.height = Math.round(canvasSize.h * dpr);
|
||||
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
ctx.scale(dpr, dpr);
|
||||
ctx.clearRect(0, 0, canvasSize.w, canvasSize.h);
|
||||
|
||||
if (!peaks || peaks.length === 0 || normFactor === 0) return;
|
||||
|
||||
const W = canvasSize.w;
|
||||
const H = canvasSize.h;
|
||||
const rangeMs = range.end - range.start;
|
||||
if (rangeMs <= 0 || videoDurationMs <= 0) return;
|
||||
|
||||
// Draw within [topY, bottomY] so the waveform aligns with item bounds
|
||||
// regardless of sub-pixel rounding on the canvas element.
|
||||
const topY = topInset;
|
||||
const bottomY = H - bottomInset;
|
||||
const drawHeight = bottomY - topY;
|
||||
if (drawHeight <= 0) return;
|
||||
|
||||
const N = peaks.length / 2;
|
||||
const amp = drawHeight * 0.9;
|
||||
|
||||
// Rectified: amplitude = max(|min|, |max|), normalized to the loudest peak
|
||||
// and gamma-curved, drawn upward from bottomY.
|
||||
const colY = new Float32Array(W);
|
||||
for (let x = 0; x < W; x++) {
|
||||
const startMs = range.start + (x / W) * rangeMs;
|
||||
const endMs = range.start + ((x + 1) / W) * rangeMs;
|
||||
const lo = Math.max(0, Math.floor((startMs / videoDurationMs) * N));
|
||||
const hi = Math.min(N - 1, Math.ceil((endMs / videoDurationMs) * N));
|
||||
|
||||
let absMax = 0;
|
||||
for (let i = lo; i <= hi; i++) {
|
||||
const a = Math.abs(peaks[i * 2]);
|
||||
const b = Math.abs(peaks[i * 2 + 1]);
|
||||
if (a > absMax) absMax = a;
|
||||
if (b > absMax) absMax = b;
|
||||
}
|
||||
const normalized = Math.min(1, absMax * normFactor);
|
||||
const display = normalized > 0 ? normalized ** WAVEFORM_GAMMA : 0;
|
||||
colY[x] = bottomY - display * amp;
|
||||
}
|
||||
|
||||
// Filled polygon: bottom-left, up over the silhouette, down to bottom-right.
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, bottomY);
|
||||
for (let x = 0; x < W; x++) {
|
||||
ctx.lineTo(x, colY[x]);
|
||||
}
|
||||
ctx.lineTo(W, bottomY);
|
||||
ctx.closePath();
|
||||
ctx.fillStyle = "rgba(74, 222, 128, 0.55)";
|
||||
ctx.fill();
|
||||
|
||||
// Crisp top-edge stroke.
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, colY[0]);
|
||||
for (let x = 1; x < W; x++) {
|
||||
ctx.lineTo(x, colY[x]);
|
||||
}
|
||||
ctx.strokeStyle = "rgba(74, 222, 128, 0.85)";
|
||||
ctx.lineWidth = 1;
|
||||
ctx.stroke();
|
||||
}, [peaks, normFactor, range, canvasSize, videoDurationMs, topInset, bottomInset]);
|
||||
|
||||
return <canvas ref={canvasRef} className="absolute inset-0 pointer-events-none w-full h-full" />;
|
||||
}
|
||||
@@ -79,9 +79,8 @@ export default function Item({
|
||||
[span.start, span.end],
|
||||
);
|
||||
|
||||
// Minimum clickable width on the outer wrapper.
|
||||
// Kept small (6px) so items visually distinguish their real positions;
|
||||
// users should zoom in to interact with sub-second items precisely.
|
||||
// Minimum clickable width on the outer wrapper. Kept small so items keep their real
|
||||
// positions; zoom in to interact with sub-second items precisely.
|
||||
const MIN_ITEM_PX = 6;
|
||||
const safeItemStyle = { ...itemStyle, minWidth: MIN_ITEM_PX };
|
||||
|
||||
|
||||