diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1f85736c2..73029f6bc 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -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- 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: diff --git a/.gitignore b/.gitignore index 82fc468b7..61dc9d2fc 100644 --- a/.gitignore +++ b/.gitignore @@ -63,3 +63,6 @@ result-* #others **/*.import + +# Auto-caption model + ORT wasm — regenerated at build by scripts/fetch-caption-model.mjs +/caption-assets/ diff --git a/README.md b/README.md index a893ce7fd..867f21634 100644 --- a/README.md +++ b/README.md @@ -1,66 +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.** +

OpenScreen Logo

- siddharthvaddem%2Fopenscreen | Trendshift -
-
- - Ask DeepWiki - -   - - Join Discord + siddharthvaddem%2Fopenscreen | Trendshift + +

#

OpenScreen

-

OpenScreen is your free, open-source alternative to Screen Studio (sort of).

+

OpenScreen is your free, open-source alternative to Screen Studio.

+ +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. -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! +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. -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! +**100% free** for both **personal** and **commercial** use. Use it, modify it, distribute it. Please respect the License. -**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. +> [!NOTE] +>Software should be accessible. OpenScreen has no paid tiers, premium features, upsells, or functionality locked behind a paywall.

- OpenScreen App Preview 3 - OpenScreen App Preview 4 + +

## 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. -- **Webcam Focus** — draw attention to your face at key moments. -- Captions, markers, and image annotations. -- 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. -### Webcam Focus - -When you record with a webcam, you can mark specific time ranges on the timeline where the webcam should take center stage. During those segments the screen recording blurs and dims while the webcam expands to fill most of the frame. Outside the region everything returns to the normal layout. Both transitions animate smoothly. - -**How to use it:** -1. Make sure your recording includes a webcam feed. -2. In the editor, click the **camera icon** (🎥) in the timeline toolbar to place a Webcam Focus region at the current playhead position. -3. Drag the edges of the indigo region to set the start and end times. -4. Press **Play** to preview — the webcam enlarges to portrait near-full-screen and the screen recording fades behind it. -5. Delete a region by selecting it and pressing `Delete` / `Backspace`. - -The effect is saved with your project and included in the exported video. ## Installation @@ -91,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/): @@ -161,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 - - - - - - Star History Chart - - - ## 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. diff --git a/docs/export-optimize-native-cpp-plan.md b/docs/export-optimize-native-cpp-plan.md new file mode 100644 index 000000000..bb7df7f3b --- /dev/null +++ b/docs/export-optimize-native-cpp-plan.md @@ -0,0 +1,157 @@ +# Native C++ Export Engine — Goals & Architecture + +**Status**: Draft +**Date**: 2026-06-01 + +--- + +## Prior Art — CapCut Desktop + +The architecture in this document draws on the export design of [CapCut](https://www.capcut.com) (desktop), which achieves near-instant export on consumer hardware through three interlocking strategies: + +- **Full-stack hardware acceleration.** CapCut routes decode, composite, and encode through the platform's native multimedia APIs (Apple VideoToolbox on macOS, NVENC/AMF/Quick Sync on Windows) so the CPU is only coordinating, not processing pixels. The rendering pipeline uses Metal / Vulkan / OpenGL shaders rather than software compositing. +- **Background pre-render cache.** While the user scrubs the timeline and previews effects, CapCut silently renders affected segments into a low-bitrate segment cache. When export is triggered, cached segments are assembled directly without re-rendering — reducing the export to a mux-and-encode pass over pre-computed frames. +- **On-demand decode.** Only frames that survive trim boundaries are decoded. The demuxer seeks to the nearest keyframe before each active segment and skips the rest at the packet level, so a 10-minute source with a 30-second active region decodes approximately 30 seconds of video, not 10 minutes. + +The architecture proposed here applies the first and third strategies directly. The second (segment pre-render cache) is a longer-term addition that can layer on top once the core C++ pipeline is in place. + +--- + +## Goal + +Reduce MP4/GIF export time from minutes to seconds for typical recordings. The target is to reach near-real-time export speed (export faster than the recording duration) on modern consumer hardware with hardware acceleration available. + +Secondary goals: eliminate UI jank during export, remove the real-time audio bottleneck, and give the product full control over codec quality and bitrate tuning. + +--- + +## Why the Current Approach Has a Hard Ceiling + +The existing pipeline runs entirely inside Chromium's renderer process using the WebCodecs API. This creates structural limits that cannot be fixed with incremental JS-level changes: + +- **No hardware control.** The browser decides whether to use a hardware encoder. There is no way to explicitly target NVENC, VideoToolbox, or Intel Quick Sync, or to pass hardware-specific quality parameters. +- **Single-threaded serial loop.** Every frame is decoded, composited, and encoded one at a time inside a single `await` chain. The compositor blocks the encoder; the encoder blocks the next decode. There is no pipeline parallelism. +- **No GPU zero-copy.** Decoded video frames cannot stay on the GPU between decode and encode. The browser forces a CPU round-trip for compositing, which is especially costly on Linux. +- **Real-time audio constraint.** Audio with speed changes is processed through `MediaRecorder` with real-time playback, meaning a clip with 2× speed still takes the original duration to process its audio. +- **WebCodecs is designed for streaming.** Its API model (optimised for WebRTC latency) imposes constraints — fixed latency mode, limited GOP control, no B-frames — that are the wrong trade-offs for offline batch export. + +--- + +## Architecture: Native C++ Export Helper + +The solution follows the same multi-process pattern OpenScreen already uses for `openscreen-screencapturekit-helper` and `openscreen-wgc-capture-helper`. A new standalone C++ binary, `openscreen-export-helper`, takes over the entire encode pipeline. + +### Process boundary + +``` +Electron Renderer ──IPC──► Electron Main ──spawn──► openscreen-export-helper + (React / UI) (Node.js) (C++ encode engine) +``` + +The renderer and main process are unchanged from the user's perspective: they display progress, handle cancellation, and write the final file. All pixel work happens in the helper process. + +### Why a separate process + +- **Crash isolation.** A codec crash or driver fault does not take down the UI. +- **True multi-threading.** The helper can run a decode thread, a composite thread, and an encode thread in parallel — something the JS single-threaded model cannot do. +- **Direct OS API access.** The helper calls VideoToolbox, NVENC, DXGI, and VAAPI directly without browser sandboxing. +- **Consistent with existing codebase patterns.** No new integration model to learn or maintain. + +--- + +## Hardware Acceleration Stack + +The helper selects the best available backend at runtime, in priority order: + +| Platform | Decode | Composite | Encode | +|---|---|---|---| +| macOS (Apple Silicon) | VideoToolbox | Metal compute | VideoToolbox (H.264 / HEVC) | +| macOS (Intel) | VideoToolbox | Metal compute | VideoToolbox | +| Windows (NVIDIA) | NVDEC | D3D11 compute | NVENC | +| Windows (AMD) | AMF decoder | D3D11 compute | AMF encoder | +| Windows (Intel) | Quick Sync | D3D11 compute | Quick Sync | +| Linux | VAAPI / NVDEC | OpenGL compute | VAAPI / NVENC | +| All (fallback) | FFmpeg software | CPU | libx264 / libx265 | + +The critical optimisation at each stage is **GPU zero-copy**: the decoded frame lives on a GPU surface, the compositor reads and writes GPU textures, and the encoder consumes the GPU surface directly — no pixel data crosses the CPU bus until the final muxed file is written to disk. + +--- + +## What the Helper Does + +### Decode +The helper demuxes the source file and sends encoded packets directly to a hardware decoder context. Only frames that fall within the active trim regions are decoded; frames in trimmed gaps are skipped at the GOP level after seeking to the nearest keyframe. This mirrors CapCut's on-demand decode and is the single largest gain for heavily edited projects. + +### Composite +Each output frame is assembled as a sequence of GPU shader passes on a render texture: + +1. Wallpaper / background fill +2. Video frame (crop + resize) +3. Webcam picture-in-picture (if present) +4. Shadow (pre-baked once per export, not per frame) +5. Zoom / pan transform +6. Cursor overlay +7. Annotations + +The shadow pass deserves special mention: the current JS pipeline recomputes a full CSS `drop-shadow` filter on every frame. In the C++ compositor, the shadow mask is a static texture baked once before the first frame and reused for the entire export — making it effectively free. + +### Encode +The composited GPU texture is handed directly to the hardware encoder surface. The encoder runs concurrently with the compositor: while frame N is being encoded, frame N+1 is being composited. This pipeline overlap is not possible in the current serial JS loop. + +Quality mapping from the existing UI quality selector: + +| UI preset | Encoder target | +|---|---| +| Medium | Hardware VBR, CQ ~28, fast preset | +| Good | Hardware VBR, CQ ~23, medium preset | +| Source | Hardware VBR, CQ ~18, slow preset | + +### Audio +Audio is decoded, speed-adjusted (using a time-stretch filter rather than real-time playback), and re-encoded fully offline on a separate thread. For a clip with 2× speed applied, audio processing takes half the clip duration — not the full duration as today. + +### Mux +Audio and video packets are muxed by DTS into a standard MP4 container with `faststart` (moov atom at the front), ready for immediate playback without post-processing. + +--- + +## Communication Contract + +The helper follows the same stdin/stdout JSON contract as the existing native helpers: + +- **Input**: a single JSON object passed as a command-line argument describing the full export job (paths, effects, quality, trim, cursor data, etc.) +- **Output**: newline-delimited JSON progress events on stdout (`ready`, `progress`, `done`, `error`) +- **Cancellation**: SIGTERM + +The JS side (`NativeExporter`) wraps this in a thin TypeScript class that translates the existing `VideoExporterConfig` into the helper's JSON format and maps progress events back to the `onProgress` callback. The `VideoExporter` (WebCodecs) remains as a fallback for systems where the helper binary is unavailable. + +--- + +## Build & Distribution + +Follows the existing native helper conventions: + +- Built with CMake, one binary per platform/arch +- Distributed alongside the existing helpers under `electron/native/bin/` +- Statically linked against FFmpeg (stripped to required codecs only) to avoid runtime dependency conflicts +- Must be code-signed and notarised on macOS (same as existing helpers) + +--- + +## Phased Delivery + +| Milestone | Scope | Value delivered | +|---|---|---| +| 1 | Helper skeleton: decode → passthrough → encode, no effects | Validates IPC contract; unblocks source-quality exports with no effects | +| 2 | Static effects: shadow, wallpaper, padding, border radius | Covers the most common export configuration | +| 3 | Per-frame effects: zoom, crop, motion blur | Full visual parity for standard editing features | +| 4 | Cursor overlay | Native cursor compositing without JS frame rendering | +| 5 | Webcam PiP + audio | Full feature parity | +| 6 | GIF output | Replaces gif.js; uses FFmpeg palette quantisation | + +At each milestone the helper is the primary path for the effect combinations it supports; all others fall back to the existing WebCodecs pipeline. + +--- + +## Expected Outcome + +On a machine with hardware acceleration available, the export of a typical 2-minute 1080p recording should complete in under 15 seconds. On machines without hardware support, the software fallback via libx264 (multi-threaded) is still substantially faster than the current single-threaded WebCodecs software path. diff --git a/docs/testing/macos-native-cursor.md b/docs/testing/macos-native-cursor.md new file mode 100644 index 000000000..b79064f8c --- /dev/null +++ b/docs/testing/macos-native-cursor.md @@ -0,0 +1,183 @@ +# macOS native cursor test pipeline + +This document covers manual and diagnostic testing for macOS native cursor capture — the path that records real system cursor bitmaps via `NSCursor.currentSystem` and surfaces them through the OpenScreen editor and export pipeline. + +## How the macOS cursor helper works + +The helper binary (`openscreen-macos-cursor-helper`) runs as a child process of Electron during recording. It: + +- polls `NSCursor.currentSystem` at the configured sample interval +- converts each cursor image to PNG and computes a SHA-256 content hash as a stable asset id +- emits the full base64 bitmap payload **once** per unique cursor shape per session; subsequent samples carry only the `assetId` so stdout stays small +- tracks left-button down/up events via `CGEventTap` and tags each sample with `interactionType` +- uses the Accessibility API to detect `text` and `pointer` affordances (link/button/input roles) when Accessibility is granted; these shapes use the bundled high-quality SVG replacements instead of the raw bitmap + +Each sample line is newline-delimited JSON: + +```json +{ "type": "ready", "timestampMs": 1234567890, "accessibilityTrusted": true, "mouseTapReady": true } +{ "type": "sample", "timestampMs": 1234567891, "assetId": "a7472...", "asset": { "id": "a7472...", "imageDataUrl": "data:image/png;base64,...", "width": 64, "height": 64, "hotspotX": 16, "hotspotY": 16, "scaleFactor": 2.0 }, "cursorType": null, "leftButtonDown": false, "leftButtonPressed": false, "leftButtonReleased": false } +{ "type": "sample", "timestampMs": 1234567924, "assetId": "a7472...", "cursorType": null, "leftButtonDown": false, "leftButtonPressed": false, "leftButtonReleased": false } +``` + +`asset` is present only the first time a given `assetId` appears. The TypeScript session (`MacNativeCursorRecordingSession`) collects unique assets into a map and sets `provider: "native"` in the final `CursorRecordingData` when at least one bitmap was captured. + +## Build the helper + +```bash +npm run build:native:mac +``` + +This builds both Swift helpers (`openscreen-screencapturekit-helper` and `openscreen-macos-cursor-helper`) and copies them to: + +- `electron/native/screencapturekit/build/` — used by the local dev server +- `electron/native/bin/darwin-arm64/` or `darwin-x64/` — used by packaged builds + +Requires Xcode (not just Command Line Tools). If you see a build error about missing SDK metadata, run: + +```bash +sudo xcode-select --switch /Applications/Xcode.app/Contents/Developer +sudo xcodebuild -license accept +``` + +## Smoke-test the helper directly + +You can run the cursor helper standalone to inspect its raw output before involving the full app: + +```bash +BIN=electron/native/screencapturekit/build/openscreen-macos-cursor-helper +("$BIN" '{"sampleIntervalMs":100}' & PID=$!; sleep 2; kill $PID) | head -20 +``` + +Expected first line: + +```json +{"type":"ready","mouseTapReady":true,"accessibilityTrusted":false,"timestampMs":...} +``` + +`accessibilityTrusted: false` is normal in dev/unsigned builds. It means text/pointer affordance detection is disabled; native bitmap capture still works. + +Expected sample lines: + +```json +{"type":"sample","assetId":"a7472...","asset":{"id":"a7472...","imageDataUrl":"data:image/png;base64,...","width":64,"height":64,"hotspotX":26,"hotspotY":16,"scaleFactor":2.0},...} +{"type":"sample","assetId":"a7472...",...} +``` + +Move the cursor over a text input while the helper is running and check that a new `assetId` appears with a different bitmap (if Accessibility is granted — see below). + +## Point the app at a custom helper binary + +```bash +export OPENSCREEN_MAC_CURSOR_HELPER_EXE=/path/to/openscreen-macos-cursor-helper +npm run dev +``` + +## macOS permissions + +Two separate permissions are needed: + +| Permission | What it enables | Where to grant | +|---|---|---| +| Screen Recording | ScreenCaptureKit video capture | System Settings → Privacy & Security → Screen & System Audio Recording → Electron ✅ | +| Accessibility | `text` / `pointer` cursor type detection (affordance hints) | System Settings → Privacy & Security → Accessibility → Electron ✅ | + +**Screen Recording** is required to record. Without it the recording never starts. + +**Accessibility** is optional. Without it, `cursorType` will always be `null` and all cursors render from their captured bitmaps (no SVG substitution). This is the expected fallback and does not degrade cursor quality for non-text/pointer shapes. + +After granting either permission in System Settings, **fully quit and relaunch** the dev server — `getMediaAccessStatus` caches the result per-process. + +## Manual test checklist + +### P0 — core bitmap capture + +- [ ] Record a short clip. Open the editor. Confirm the default arrow cursor is the real system arrow (not the bundled SVG approximation). +- [ ] Record while hovering over a web browser. Confirm custom-CSS cursors (e.g. `cursor: grab`, `cursor: crosshair`) appear as their actual shapes. +- [ ] Export to MP4. Confirm the cursor renders correctly in the exported video. +- [ ] Export to GIF. Same check. + +### P1 — affordance substitution (requires Accessibility) + +- [ ] Grant Accessibility permission and restart the app. +- [ ] Record hovering over a text input field. Confirm the text I-beam uses the bundled SVG version (prettier than the system bitmap). +- [ ] Record hovering over a link/button. Confirm the pointer hand uses the bundled SVG. + +### P1 — hotspot alignment (Retina) + +- [ ] On a Retina display, record a precise click on a small button. In the editor, confirm the cursor tip aligns with the actual click point. The helper reports `scaleFactor: 2.0`; the renderer divides pixel dimensions and hotspot by this value to recover point sizes. + +### P1 — click detection + +- [ ] Record several left-clicks. In the editor, confirm the click-bounce animation fires on each click. +- [ ] Confirm `interactionType: "click"` and `"mouseup"` events are present in the recording session sidecar (`cursorRecordingData` inside `.cursor.json`). + +### P2 — graceful degradation + +- [ ] Remove **both** build-output copies of the helper binary and start a recording. The session should succeed with `provider: "none"` (position-only telemetry, default arrow rendered). Restore both binaries afterward. + ```bash + ARCH=$([ "$(uname -m)" = "arm64" ] && echo darwin-arm64 || echo darwin-x64) + mv electron/native/screencapturekit/build/openscreen-macos-cursor-helper /tmp/cursor-helper-build + mv electron/native/bin/$ARCH/openscreen-macos-cursor-helper /tmp/cursor-helper-bin + # ... start recording, then restore: + mv /tmp/cursor-helper-bin electron/native/bin/$ARCH/openscreen-macos-cursor-helper + mv /tmp/cursor-helper-build electron/native/screencapturekit/build/openscreen-macos-cursor-helper + ``` +- [ ] Revoke Accessibility. Confirm recording still works and cursors render from bitmaps (no SVG substitution). + +### P2 — multi-display + +- [ ] Move the cursor to a secondary display during recording. Confirm the cursor clips to the canvas edge rather than snapping invisible on fast swipes. Confirm it hides after ≈100 ms of sustained out-of-bounds movement. + +### P2 — long recording memory + +- [ ] Record for 3–5 minutes while switching between many apps (browser, terminal, editor). The helper should not grow in memory because each iteration drains Cocoa objects via `autoreleasepool`. Check `Activity Monitor` → `openscreen-macos-cursor-helper` RSS stays flat after the first few seconds. + +## What a healthy recording looks like + +Inspect the cursor sidecar file written alongside the recorded video (`.cursor.json`). For a recording saved to `/tmp/rec.mp4`, the sidecar is `/tmp/rec.mp4.cursor.json`: + +```json +{ + "version": 2, + "provider": "native", + "assets": [ + { "id": "a7472...", "platform": "darwin", "imageDataUrl": "data:image/png;base64,...", "width": 64, "height": 64, "hotspotX": 26.0, "hotspotY": 16.0, "scaleFactor": 2.0 } + ], + "samples": [ + { "timeMs": 0, "cx": 0.42, "cy": 0.38, "visible": true, "assetId": "a7472...", "interactionType": "move" }, + ... + ] +} +``` + +`provider: "native"` and a non-empty `assets` array confirm bitmap capture is active. If you see `provider: "none"` and `assets: []`, the helper was not found or exited before `ready`. + +## Native macOS capture backend + +The app routes macOS recordings through the ScreenCaptureKit helper (`openscreen-screencapturekit-helper`) when it is available, so the real system cursor is excluded from the video frame. The cursor position and bitmap are captured separately by the cursor helper and composited in the editor and export pipeline. + +Current native availability rules: + +- macOS 13 (Ventura) or newer +- `openscreen-screencapturekit-helper` binary is present +- Screen Recording permission is granted + +Build both helpers locally: + +```bash +npm run build:native:mac +``` + +For local diagnostics with a custom helper binary, use the environment override: + +```bash +export OPENSCREEN_MAC_CURSOR_HELPER_EXE=/path/to/openscreen-macos-cursor-helper +npm run dev +``` + +## Known limitations + +- **Intel (x86\_64) Macs**: the distributed helper is built for `darwin-arm64`. On Intel Macs, you need to build from source with `npm run build:native:mac` on the target machine. +- **Accessibility permission in unsigned/dev builds**: `getMediaAccessStatus("accessibility")` may not reflect the toggle state for unsigned Electron in dev mode. The helper will always probe and report `accessibilityTrusted` in its `ready` event — use that as the authoritative signal. +- **App-defined custom cursors (CGS layer)**: `NSCursor.currentSystem` captures the active AppKit cursor. Cursors set at the CoreGraphics/CGS layer by some games or GPU-accelerated apps may not be visible here. This is a known macOS API limitation. diff --git a/electron-builder.json5 b/electron-builder.json5 index 8ad4a80eb..16a7f2c60 100644 --- a/electron-builder.json5 +++ b/electron-builder.json5 @@ -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" } ], diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index 70f6008ed..07e39cf0d 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -155,6 +155,7 @@ interface Window { recordingId: number; webcam: import("../src/lib/recordingSession").RecordedVideoAssetInput; cursorCaptureMode?: import("../src/lib/recordingSession").CursorCaptureMode; + durationMs?: number; }) => Promise<{ success: boolean; path?: string; @@ -229,7 +230,7 @@ interface Window { canceled?: boolean; error?: string; }>; - loadProjectFile: () => Promise<{ + loadProjectFile: (projectFolder?: string) => Promise<{ success: boolean; path?: string; project?: unknown; @@ -275,6 +276,7 @@ interface Window { hudOverlayClose: () => void; setHudOverlayIgnoreMouseEvents: (ignore: boolean) => void; moveHudOverlayBy: (deltaX: number, deltaY: number) => void; + setHudOverlaySize: (width: number, height: number) => void; showCountdownOverlay: (value: number, runId: number) => Promise; setCountdownOverlayValue: (value: number, runId: number) => Promise; hideCountdownOverlay: (runId: number) => Promise; diff --git a/electron/globalShortcut.ts b/electron/globalShortcut.ts index f1b046a34..2765bad22 100644 --- a/electron/globalShortcut.ts +++ b/electron/globalShortcut.ts @@ -41,16 +41,14 @@ let currentAccelerator: string | null = null; export function registerOpenAppShortcut(binding: ShortcutBinding, onTrigger: () => void): boolean { const accelerator = bindingToAccelerator(binding); - // Same shortcut already registered, nothing to do if (accelerator === currentAccelerator) { return true; } - // Try to register new shortcut first (before unregistering old one) + // Register the new shortcut before unregistering the old, so a failure leaves the old binding intact const success = globalShortcut.register(accelerator, onTrigger); if (success) { - // Only unregister old shortcut after new one succeeds if (currentAccelerator) { globalShortcut.unregister(currentAccelerator); } diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 65f153605..3a87176d2 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -62,10 +62,7 @@ const ALLOWED_IMPORT_VIDEO_EXTENSIONS = new Set([ const PREVIEW_AUDIO_DIR = path.join(app.getPath("userData"), "preview-audio"); const nativeMacCaptureEvents = new EventEmitter(); -/** - * Paths explicitly approved by the user via file picker dialogs or project loads. - * These are added at runtime when the user selects files from outside the default directories. - */ +// Paths the user approved via file picker or project load (i.e. outside the default dirs). const approvedPaths = new Set(); function approveFilePath(filePath: string): void { @@ -101,10 +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( baseOptions: T, parentWindow: BrowserWindow | null, @@ -233,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)); @@ -282,11 +275,9 @@ function isValidDurationMs(value: number | undefined): value is number { } /** - * Finalize a single recording file: if it was streamed to disk, flush and close - * the stream; otherwise (a short recording, or the stream failed to open and the - * renderer fell back to in-memory buffering) write the buffered bytes. Returns - * whether the file was streamed, which the caller uses to decide whether the - * WebM duration needs patching on disk. + * Finalize one recording file: flush/close the stream if it was streamed, else write + * the buffered bytes (short recording or stream failed to open). Returns whether it was + * streamed, so the caller knows if the WebM duration needs patching on disk. */ async function finalizeRecordingFile( registry: RecordingStreamRegistry, @@ -322,8 +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))); @@ -358,6 +349,12 @@ type AttachNativeMacWebcamRecordingInput = { recordingId?: number; webcam?: RecordedVideoAssetInput; cursorCaptureMode?: CursorCaptureMode; + /** + * Recording duration in ms. Present when the webcam took the streaming path so + * its on-disk WebM (which lacks a Duration header) can be patched here, exactly + * as store-recorded-session patches streamed screen/webcam files. + */ + durationMs?: number; }; let selectedSource: SelectedSource | null = null; @@ -366,10 +363,7 @@ let lastEnumeratedSources = new Map(); let currentProjectPath: string | null = null; let currentRecordingSession: RecordingSession | null = null; -/** - * Returns the cached DesktopCapturerSource set when the user picked a source. - * Used by setDisplayMediaRequestHandler in main.ts for cursor-free capture. - */ +// Cached source from the user's pick. Used by setDisplayMediaRequestHandler in main.ts for cursor-free capture. export function getSelectedDesktopSource(): DesktopCapturerSource | null { return selectedDesktopSource; } @@ -1290,7 +1284,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(); @@ -1396,7 +1390,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 () => { @@ -1440,10 +1463,9 @@ export function registerIpcHandlers( }); ipcMain.handle("switch-to-editor", () => { - // createEditorWindow is createEditorWindowWrapper — it already closes - // the current mainWindow (the HUD) before opening the editor. Closing - // it here too causes a double-close which leaves ghost transparent - // windows and makes the HUD shadow compound on each cycle. + // createEditorWindow already closes the current mainWindow (the HUD) before + // opening the editor. Closing it here too double-closes, leaving ghost + // transparent windows and compounding the HUD shadow each cycle. createEditorWindow(); }); @@ -1463,9 +1485,8 @@ export function registerIpcHandlers( return; } - // Wait for the first frame to be painted before showing the window. - // Showing before ready-to-show produces a black rectangle flash because - // Chromium hasn't rendered any pixels yet. + // Wait for the first frame before showing, else Chromium flashes a black + // rectangle because it hasn't rendered any pixels yet. if (overlayWindow.webContents.isLoading()) { await new Promise((resolve) => { overlayWindow.once("ready-to-show", resolve); @@ -2119,9 +2140,20 @@ export function registerIpcHandlers( } }); + // On-disk write streams for in-progress recordings, keyed by output file name. + // Chunks are appended as they arrive from ondataavailable so the renderer + // never buffers the full video in memory (the #616 fix). Declared before the + // handlers that consume it (attach-native-mac-webcam-recording finalizes a + // streamed webcam through this registry). + const recordingStreams = new RecordingStreamRegistry(); + registerRecordingStreamHandlers(ipcMain, recordingStreams, resolveRecordingOutputPath); + ipcMain.handle( "attach-native-mac-webcam-recording", async (_, payload: AttachNativeMacWebcamRecordingInput) => { + // When a streamed webcam is finalized to disk but a later step throws, this + // holds its path so the catch can remove the orphaned file. + let streamedWebcamRollbackPath: string | undefined; try { if (process.platform !== "darwin") { return { success: false, error: "Native macOS webcam attachment requires macOS." }; @@ -2137,12 +2169,30 @@ export function registerIpcHandlers( await fs.access(screenVideoPath, fsConstants.R_OK); - if (!payload.webcam?.fileName || !payload.webcam.videoData) { + if (!payload.webcam?.fileName) { return { success: false, error: "Native macOS webcam attachment is missing video data." }; } const webcamVideoPath = resolveRecordingOutputPath(payload.webcam.fileName); - await fs.writeFile(webcamVideoPath, Buffer.from(payload.webcam.videoData)); + // Streamed webcam bytes are already on disk (appended chunk-by-chunk during + // recording, the #616 fix); finalize() closes the stream and keeps the file. + // Otherwise the renderer sent the whole clip in memory and we write it here. + const webcamStreamed = await recordingStreams.finalize(payload.webcam.fileName); + if (webcamStreamed) { + // The file is now kept on disk; mark it so a later failure rolls it back. + streamedWebcamRollbackPath = webcamVideoPath; + if (isValidDurationMs(payload.durationMs)) { + await patchWebmDurationOnDisk(webcamVideoPath, payload.durationMs); + } + } else { + if (!payload.webcam.videoData || payload.webcam.videoData.byteLength === 0) { + return { + success: false, + error: "Native macOS webcam attachment is missing video data.", + }; + } + await fs.writeFile(webcamVideoPath, Buffer.from(payload.webcam.videoData)); + } const createdAt = typeof payload.recordingId === "number" && Number.isFinite(payload.recordingId) @@ -2172,6 +2222,11 @@ export function registerIpcHandlers( }; } catch (error) { console.error("Failed to attach native macOS webcam recording:", error); + // A streamed webcam was already finalized to disk before this failure; + // remove the orphan so no stray *-webcam.webm lingers without a session. + if (streamedWebcamRollbackPath) { + await fs.unlink(streamedWebcamRollbackPath).catch(() => undefined); + } return { success: false, error: error instanceof Error ? error.message : String(error), @@ -2180,12 +2235,6 @@ export function registerIpcHandlers( }, ); - // On-disk write streams for in-progress recordings, keyed by output file name. - // Chunks are appended as they arrive from ondataavailable so the renderer - // never buffers the full video in memory (the #616 fix). - const recordingStreams = new RecordingStreamRegistry(); - registerRecordingStreamHandlers(ipcMain, recordingStreams, resolveRecordingOutputPath); - ipcMain.handle("store-recorded-session", async (_, payload: StoreRecordedSessionInput) => { try { return await storeRecordedSessionFiles(payload); @@ -2225,9 +2274,9 @@ export function registerIpcHandlers( ); } - // Streamed files lack the WebM Duration header (the renderer no longer holds - // the blob to patch). Patch on disk so the editor's seek bar and timeline - // work. Best-effort and independent per file, so the patches run together. + // Streamed files lack the WebM Duration header (renderer no longer holds the + // blob), so patch on disk for the editor's seek bar and timeline. Best-effort, + // independent per file, so they run together. if (isValidDurationMs(payload.durationMs)) { const patches: Promise[] = []; if (screenStreamed) { @@ -2358,9 +2407,8 @@ export function registerIpcHandlers( ? [{ name: mainT("dialogs", "fileDialogs.gifImage"), extensions: ["gif"] }] : [{ name: mainT("dialogs", "fileDialogs.mp4Video"), extensions: ["mp4"] }]; - // Prefer the user's last export folder if it still exists, otherwise fall - // back to ~/Downloads. Validation must happen here because the renderer - // can't stat the filesystem. + // Prefer the user's last export folder if it still exists, else ~/Downloads. + // Validate here because the renderer can't stat the filesystem. let defaultDir = app.getPath("downloads"); if (exportFolder) { try { @@ -2405,8 +2453,8 @@ export function registerIpcHandlers( ipcMain.handle("write-export-to-path", async (_, videoData: ArrayBuffer, filePath: string) => { try { - // Sanity-check the path. The renderer is trusted (contextIsolation is on), - // but a stale state bug shouldn't be able to clobber arbitrary files. + // Sanity-check the path: the renderer is trusted (contextIsolation on), but a + // stale-state bug shouldn't be able to clobber arbitrary files. if (typeof filePath !== "string" || !path.isAbsolute(filePath)) { return { success: false, message: "Invalid path" }; } @@ -2482,14 +2530,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) { @@ -2622,16 +2669,34 @@ export function registerIpcHandlers( } } - ipcMain.handle("load-project-file", async () => { - return loadProjectFile(); + ipcMain.handle("load-project-file", async (_, projectFolder?: string) => { + return loadProjectFile(projectFolder); }); - async function loadProjectFile(): Promise { + async function loadProjectFile(projectFolder?: string): Promise { try { + // Prefer the user's last opened-project folder if it still exists, else + // RECORDINGS_DIR. Validate here because the renderer can't stat the filesystem. + let defaultDir = RECORDINGS_DIR; + if (projectFolder) { + try { + const stats = await fs.stat(projectFolder); + if (stats.isDirectory()) { + defaultDir = projectFolder; + } + } catch (err) { + // Stat can fail if the folder was moved/deleted (expected) or on a + // permission error (worth surfacing). We fall back either way, but log it. + console.warn( + `Could not access remembered project folder "${projectFolder}", falling back to RECORDINGS_DIR:`, + err, + ); + } + } const dialogOptions = buildDialogOptions( { title: mainT("dialogs", "fileDialogs.openProject"), - defaultPath: RECORDINGS_DIR, + defaultPath: defaultDir, filters: [ { name: mainT("dialogs", "fileDialogs.openscreenProject"), @@ -2692,9 +2757,8 @@ export function registerIpcHandlers( const project = JSON.parse(content); currentProjectPath = filePath; - // Approve session paths; tolerate failures (e.g. video moved outside - // trusted dirs) so the project still loads and the renderer can surface - // a "video not found" error rather than a generic load failure. + // Approve session paths but tolerate failures (e.g. video moved outside trusted + // dirs) so the project still loads and the renderer can show "video not found". let session: import("../../src/lib/recordingSession").RecordingSession | null = null; try { session = await getApprovedProjectSession(project, filePath); @@ -2875,9 +2939,7 @@ export function registerIpcHandlers( return { success: false, error: "extract-subtitles.mjs script not found" }; } - const nodeBin = - process.env.NODE_BINARY || - (process.platform === "win32" ? "node.exe" : "node"); + const nodeBin = process.env.NODE_BINARY || (process.platform === "win32" ? "node.exe" : "node"); return new Promise<{ success: boolean; diff --git a/electron/ipc/nativeBridge.ts b/electron/ipc/nativeBridge.ts index 425f93e1a..2669300a9 100644 --- a/electron/ipc/nativeBridge.ts +++ b/electron/ipc/nativeBridge.ts @@ -25,7 +25,7 @@ export interface NativeBridgeContext { suggestedName?: string, existingProjectPath?: string, ) => Promise; - loadProjectFile: () => Promise; + loadProjectFile: (projectFolder?: string) => Promise; loadCurrentProjectFile: () => Promise; loadProjectFileFromPath: (path: string) => Promise; setCurrentVideoPath: (path: string) => ProjectPathResult | Promise; @@ -164,7 +164,10 @@ export function registerNativeBridgeHandlers(context: NativeBridgeContext) { ), ); case "loadProjectFile": - return createSuccessResponse(requestId, await projectService.loadProjectFile()); + return createSuccessResponse( + requestId, + await projectService.loadProjectFile(request.payload?.projectFolder), + ); case "loadCurrentProjectFile": return createSuccessResponse( requestId, diff --git a/electron/ipc/recordingStream.ts b/electron/ipc/recordingStream.ts index 3dce5b955..665ea4194 100644 --- a/electron/ipc/recordingStream.ts +++ b/electron/ipc/recordingStream.ts @@ -3,23 +3,18 @@ import { unlink } from "node:fs/promises"; import type { IpcMain } from "electron"; /** - * Owns the lifecycle of on-disk write streams for in-progress recordings, keyed - * by the recording's output file name. Browser MediaRecorder chunks are appended - * here as they arrive so a long recording never buffers the whole video in the - * renderer (the #616 fix). - * - * The file name is the key because it is the one value the renderer and main - * process already exchange and it is globally unique per recording, so there is - * no derived/offset key to keep in sync across the IPC boundary. + * Owns write streams for in-progress recordings, keyed by output file name. + * MediaRecorder chunks are appended as they arrive so a long recording never + * buffers the whole video in the renderer (#616 fix). File name is the key + * because it's already exchanged across IPC and is unique per recording. */ export class RecordingStreamRegistry { private readonly streams = new Map(); /** - * Open a write stream and resolve only once the OS confirms it is writable. - * Resolving on the `open` event (rather than on `createWriteStream` returning) - * means a bad path or permission error rejects here instead of surfacing as a - * silent chunk drop later, so the renderer's fallback can take over. + * Open a write stream, resolving only on the `open` event so a bad path or + * permission error rejects here instead of becoming a silent chunk drop later, + * letting the renderer's fallback take over. */ async open(fileName: string, filePath: string): Promise { await this.endStream(fileName); @@ -33,9 +28,8 @@ export class RecordingStreamRegistry { resolve(); }); }); - // Keep a listener for the stream's lifetime so a late error logs rather - // than crashing the main process with an unhandled 'error' event. Per-write - // failures still surface through the `append` callback below. + // Keep a lifetime listener so a late error logs instead of crashing the main + // process with an unhandled 'error'. Per-write failures still surface in `append`. ws.on("error", (error) => { console.error(`[recording-stream] ${fileName}:`, error); }); @@ -59,9 +53,8 @@ export class RecordingStreamRegistry { } /** - * Flush and close the stream, keeping the file. Returns whether a stream was - * open — i.e. whether the recording was streamed to disk (true) or needs its - * in-memory buffer written by the caller (false). + * Flush and close the stream, keeping the file. Returns true if a stream was + * open (streamed to disk) or false if the caller still needs to write its buffer. */ async finalize(fileName: string): Promise { const ws = this.streams.get(fileName); @@ -76,9 +69,8 @@ export class RecordingStreamRegistry { } /** - * Close the stream (if any) and delete the partial file. Used when a streamed - * recording is discarded or fails before a successful save, so cancelled runs - * don't leak file descriptors or orphan partial recordings on disk. + * Close the stream (if any) and delete the partial file, so a discarded or + * failed recording doesn't leak descriptors or orphan partial files on disk. */ async discard(fileName: string, filePath: string): Promise { await this.endStream(fileName); diff --git a/electron/main.ts b/electron/main.ts index 14255d5b3..bfb949ed7 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -28,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; @@ -384,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") => { @@ -393,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; @@ -428,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; @@ -456,16 +453,14 @@ app.on("will-quit", () => { unregisterAllGlobalShortcuts(); }); -// Register all IPC handlers when app is ready app.whenReady().then(async () => { - // Force the app into "regular" activation policy so the Dock icon appears. - // The HUD overlay (transparent + frameless + skipTaskbar) is the first - // window we open, and AppKit otherwise classifies us as an accessory app. + // Force "regular" activation policy so the Dock icon appears. The HUD overlay + // (transparent, frameless, skipTaskbar) is the first window, and AppKit would + // otherwise classify us as an accessory app. if (process.platform === "darwin") { app.dock?.show(); } - // Allow microphone/media/screen permission checks session.defaultSession.setPermissionCheckHandler((_webContents, permission) => { const allowed = [ "media", @@ -508,9 +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") { @@ -518,7 +512,6 @@ app.whenReady().then(async () => { } } - // Listen for HUD overlay quit event (macOS only) ipcMain.on("hud-overlay-close", () => { app.quit(); }); @@ -536,7 +529,6 @@ app.whenReady().then(async () => { createTray(); updateTrayMenu(); setupApplicationMenu(); - // Ensure recordings directory exists await ensureRecordingsDir(); function switchToHudWrapper() { diff --git a/electron/native-bridge/cursor/recording/factory.ts b/electron/native-bridge/cursor/recording/factory.ts index 0ba307788..9a82ffd55 100644 --- a/electron/native-bridge/cursor/recording/factory.ts +++ b/electron/native-bridge/cursor/recording/factory.ts @@ -36,7 +36,7 @@ export function createCursorRecordingSession( } // Linux: capture cursor positions via Electron's `screen` API on an interval. - // No cursor sprites/assets and no clicks — just position telemetry. + // No cursor sprites/assets and no clicks, just position telemetry. return new TelemetryRecordingSession({ getDisplayBounds: options.getDisplayBounds, maxSamples: options.maxSamples, diff --git a/electron/native-bridge/cursor/recording/macNativeCursorRecordingSession.ts b/electron/native-bridge/cursor/recording/macNativeCursorRecordingSession.ts index 5e09e9298..27a8a870c 100644 --- a/electron/native-bridge/cursor/recording/macNativeCursorRecordingSession.ts +++ b/electron/native-bridge/cursor/recording/macNativeCursorRecordingSession.ts @@ -193,7 +193,7 @@ export class MacNativeCursorRecordingSession implements CursorRecordingSession { private readyTimer: NodeJS.Timeout | null = null; private previousLeftButtonDown = false; private consecutiveOutsideSamples = 0; - // Only hide after this many consecutive out-of-bounds samples (≈100ms at 33ms interval). + // Hide only after this many consecutive out-of-bounds samples (~100ms at 33ms interval). // Fast swipes that briefly exit the display are clipped by clip-path instead of disappearing. private static readonly OUTSIDE_HIDE_THRESHOLD = 3; @@ -211,7 +211,7 @@ export class MacNativeCursorRecordingSession implements CursorRecordingSession { systemPreferences.isTrustedAccessibilityClient(true); } catch { // Without Accessibility, text/pointer affordance detection is unavailable; - // cursor bitmaps are still captured natively via NSCursor. + // bitmaps are still captured natively via NSCursor. } const helperPath = findMacCursorHelperPath(); @@ -370,10 +370,9 @@ export class MacNativeCursorRecordingSession implements CursorRecordingSession { const normalizedY = (cursor.y - bounds.y) / height; const isOutsideDisplay = normalizedX < 0 || normalizedX > 1 || normalizedY < 0 || normalizedY > 1; - // Fast swipes that briefly exit the display (=THRESHOLD, ~100ms) mark visible=false to + // avoid ghost cursors and motion trails from multi-display movement. if (isOutsideDisplay) { this.consecutiveOutsideSamples++; } else { diff --git a/electron/native-bridge/cursor/recording/windowsNativeRecordingSession.ts b/electron/native-bridge/cursor/recording/windowsNativeRecordingSession.ts index 5c318f0f2..ea037b3fa 100644 --- a/electron/native-bridge/cursor/recording/windowsNativeRecordingSession.ts +++ b/electron/native-bridge/cursor/recording/windowsNativeRecordingSession.ts @@ -22,10 +22,14 @@ function getCursorSamplerCandidates(): string[] { const p = join(app.getAppPath(), ...segs); return app.isPackaged ? p.replace(/\.asar([/\\])/, ".asar.unpacked$1") : p; }; + const resolvePackaged = (...segs: string[]) => { + return app.isPackaged ? join(process.resourcesPath, ...segs) : null; + }; return [ envPath, resolve("electron", "native", "wgc-capture", "build", "cursor-sampler.exe"), resolve("electron", "native", "bin", archTag, "cursor-sampler.exe"), + resolvePackaged("electron", "native", "bin", archTag, "cursor-sampler.exe"), ].filter((c): c is string => Boolean(c)); } diff --git a/electron/native-bridge/services/projectService.ts b/electron/native-bridge/services/projectService.ts index 9e96aa22d..0c363cc13 100644 --- a/electron/native-bridge/services/projectService.ts +++ b/electron/native-bridge/services/projectService.ts @@ -14,7 +14,7 @@ interface ProjectServiceOptions { suggestedName?: string, existingProjectPath?: string, ) => Promise; - loadProjectFile: () => Promise; + loadProjectFile: (projectFolder?: string) => Promise; loadCurrentProjectFile: () => Promise; loadProjectFileFromPath: (path: string) => Promise; setCurrentVideoPath: (path: string) => ProjectPathResult | Promise; @@ -49,8 +49,8 @@ export class ProjectService { return result; } - async loadProjectFile() { - const result = await this.options.loadProjectFile(); + async loadProjectFile(projectFolder?: string) { + const result = await this.options.loadProjectFile(projectFolder); this.getCurrentContext(); return result; } diff --git a/electron/native/wgc-capture/CMakeLists.txt b/electron/native/wgc-capture/CMakeLists.txt index 32c5d6ef5..4b9a24a4e 100644 --- a/electron/native/wgc-capture/CMakeLists.txt +++ b/electron/native/wgc-capture/CMakeLists.txt @@ -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 diff --git a/electron/native/wgc-capture/src/main.cpp b/electron/native/wgc-capture/src/main.cpp index bb741d33e..9ea4e59df 100644 --- a/electron/native/wgc-capture/src/main.cpp +++ b/electron/native/wgc-capture/src/main.cpp @@ -632,8 +632,8 @@ int main(int argc, char* argv[]) { (webcamOutputFrameIndex * 10'000'000ULL) / std::max(1, webcamCapture.fps())); if (!webcamEncoder.writeBgraFrame(webcamFrame, webcamTimestampHns)) { encodeFailed = true; - stopRequested = true; - cv.notify_all(); + control.stopRequested = true; + control.cv.notify_all(); return; } lastWrittenWebcamSequence = latestWebcamSequence; diff --git a/electron/preload.ts b/electron/preload.ts index 902fc8ab0..b8de562bf 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -29,6 +29,9 @@ contextBridge.exposeInMainWorld("electronAPI", { moveHudOverlayBy: (deltaX: number, deltaY: number) => { ipcRenderer.send("hud-overlay-move-by", deltaX, deltaY); }, + setHudOverlaySize: (width: number, height: number) => { + ipcRenderer.send("hud-overlay-set-size", width, height); + }, getSources: async (opts: Electron.SourcesOptions) => { return await ipcRenderer.invoke("get-sources", opts); }, @@ -120,6 +123,7 @@ contextBridge.exposeInMainWorld("electronAPI", { recordingId: number; webcam: { fileName: string; videoData: ArrayBuffer }; cursorCaptureMode?: import("../src/lib/recordingSession").CursorCaptureMode; + durationMs?: number; }) => { return ipcRenderer.invoke("attach-native-mac-webcam-recording", payload); }, @@ -170,8 +174,8 @@ contextBridge.exposeInMainWorld("electronAPI", { saveProjectFile: (projectData: unknown, suggestedName?: string, existingProjectPath?: string) => { return ipcRenderer.invoke("save-project-file", projectData, suggestedName, existingProjectPath); }, - loadProjectFile: () => { - return ipcRenderer.invoke("load-project-file"); + loadProjectFile: (projectFolder?: string) => { + return ipcRenderer.invoke("load-project-file", projectFolder); }, loadProjectFileFromPath: (filePath: string) => { return ipcRenderer.invoke("load-project-file-from-path", filePath); diff --git a/electron/recording/webm-duration.ts b/electron/recording/webm-duration.ts index 5b2c197c9..a93451f0a 100644 --- a/electron/recording/webm-duration.ts +++ b/electron/recording/webm-duration.ts @@ -9,23 +9,16 @@ export type DurationPatchResult = /** * Patch the WebM Duration header on a finalized recording file. * - * Browser MediaRecorder writes WebM with no Duration EBML element. With the - * streaming-to-disk path the renderer never holds the blob, so the historical - * `fixWebmDuration(blob, durationMs)` call can't run. Patching on disk after - * `WriteStream.end()` produces an equivalent result: the editor's seek bar and - * timeline read a real duration instead of `N/A`. + * 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 by design: writes the patched bytes to `.duration-patch.tmp` - * and renames in place. If the process crashes mid-rewrite, the original file - * survives intact, so the user never loses their recording to a partial write. + * Atomic: writes to `.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. * - * Best-effort by intent: any failure (read, parse, write) logs and returns a - * non-`patched` result rather than throwing. The file is still playable without - * the patch (decoders walk frames sequentially); the only cost is that the - * editor's seek bar and timeline break until it is patched. - * - * Memory: reads the whole file into a main-process Buffer, the same footprint - * as the pre-streaming renderer path, just on the side without V8's heap cap. + * 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, @@ -37,9 +30,8 @@ export async function patchWebmDurationOnDisk( const patched = fixParsedWebmDuration(webm, durationMs, { logger: false }); if (!patched) { - // fixParsedWebmDuration returns false for: missing Segment, missing - // Info, or a Duration that is already valid. The first two mean a - // malformed (most likely truncated) file; the third is a no-op. + // 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( @@ -66,8 +58,7 @@ export async function patchWebmDurationOnDisk( return { patched: true }; } catch (writeError) { console.error(`[webm-duration] failed to write patched ${filePath}:`, writeError); - // Best-effort cleanup of the temp file; if unlink also fails, leave it. - // The original recording is untouched because the rename never ran. + // 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" }; } @@ -78,14 +69,12 @@ export async function patchWebmDurationOnDisk( } /** - * Distinguish "no Segment/Info section" (malformed/truncated file) from "Info - * present but Duration already valid" (patch unnecessary). + * Distinguish "no Segment/Info section" (malformed/truncated file) from "Info present + * but Duration already valid" (patch unnecessary). * - * The IDs are the length-descriptor-stripped form that @fix-webm-duration/parser - * uses as its lookup keys (Segment `0x8538067`, Info `0x549a966`), verified - * against the parser's `src/lib/sections.js` — not the canonical 4-byte EBML - * IDs (`0x18538067` / `0x1549A966`), which this parser's `getSectionById` would - * never match. + * 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); diff --git a/electron/windows.ts b/electron/windows.ts index 5d34fe80c..c3830157f 100644 --- a/electron/windows.ts +++ b/electron/windows.ts @@ -10,7 +10,7 @@ const RENDERER_DIST = path.join(APP_ROOT, "dist"); const HEADLESS = process.env["HEADLESS"] === "true"; // Asset base URL for renderer (wallpapers, etc.). Packaged: extraResources copies -// public/wallpapers -> resources/wallpapers. Unpackaged: /public/. +// public/wallpapers to resources/wallpapers. Unpackaged: /public/. const ASSET_BASE_DIR = process.defaultApp ? path.join(__dirname, "..", "public") : process.resourcesPath; @@ -44,10 +44,45 @@ ipcMain.on("hud-overlay-move-by", (_event, deltaX: number, deltaY: number) => { hudOverlayWindow.setPosition(Math.round(x + deltaX), Math.round(y + deltaY), false); }); +// Resize the HUD to fit its rendered content. Anchored by its bottom-centre so it +// stays where the user dragged it while only growing/shrinking, which lets the +// vertical tray layout grow tall instead of scrolling inside a fixed window. +ipcMain.on("hud-overlay-set-size", (_event, width: number, height: number) => { + if ( + !hudOverlayWindow || + hudOverlayWindow.isDestroyed() || + !Number.isFinite(width) || + !Number.isFinite(height) + ) { + return; + } + + const bounds = hudOverlayWindow.getBounds(); + + // Clamp to the work area of the display the HUD sits on; on a short screen the + // vertical layout can exceed the display, where the bar's own overflow scroll takes over. + const { workArea } = screen.getDisplayMatching(bounds); + const nextWidth = Math.min(workArea.width, Math.max(1, Math.round(width))); + const nextHeight = Math.min(workArea.height, Math.max(1, Math.round(height))); + + if (bounds.width === nextWidth && bounds.height === nextHeight) { + return; + } + + const centerX = bounds.x + bounds.width / 2; + const bottomY = bounds.y + bounds.height; + + hudOverlayWindow.setBounds({ + x: Math.round(centerX - nextWidth / 2), + y: Math.round(bottomY - nextHeight), + width: nextWidth, + height: nextHeight, + }); +}); + /** - * Creates the always-on-top HUD overlay window centred at the bottom of the - * primary display. The window is frameless, transparent, and follows the user - * across macOS Spaces so it is never lost when switching virtual desktops. + * Frameless transparent HUD overlay, always-on-top, centred at the bottom of the + * primary display. Follows the user across macOS Spaces so it isn't lost on switch. */ export function createHudOverlayWindow(): BrowserWindow { const primaryDisplay = screen.getPrimaryDisplay(); @@ -62,14 +97,20 @@ export function createHudOverlayWindow(): BrowserWindow { const win = new BrowserWindow({ width: windowWidth, height: windowHeight, - minWidth: 600, - maxWidth: 600, - minHeight: 160, - maxHeight: 160, + // Min/max are intentionally loose: the renderer resizes to fit content via + // "hud-overlay-set-size" (above), needed for the vertical tray to grow taller. + minWidth: 120, + minHeight: 80, x: x, y: y, frame: false, transparent: true, + // Fully-transparent ARGB backing. Without this macOS draws the window as a + // rounded glass panel with a border around the HUD content. + backgroundColor: "#00000000", + // Don't let macOS mask the window into a rounded rect; the HUD bar provides + // its own rounding and the window itself must be invisible. + roundedCorners: false, resizable: false, alwaysOnTop: true, skipTaskbar: true, @@ -85,14 +126,14 @@ export function createHudOverlayWindow(): BrowserWindow { }); win.setIgnoreMouseEvents(true, { forward: true }); - // Follow the user across macOS Spaces (virtual desktops). - // Without this the HUD stays pinned to the Space it was first opened on. + // Follow the user across macOS Spaces, else the HUD stays pinned to the Space + // it was first opened on. if (process.platform === "darwin") { win.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true }); } - // Show only once content is painted — prevents the black rectangle flash - // that appears when a transparent window is shown before its first paint. + // Show only once painted to avoid the black rectangle flash when a transparent + // window is shown before its first paint. win.once("ready-to-show", () => { if (!HEADLESS) win.show(); }); @@ -121,8 +162,8 @@ export function createHudOverlayWindow(): BrowserWindow { } /** - * Creates the main editor window. Starts maximised with a hidden title bar on - * macOS. This window is not always-on-top and appears in the taskbar/dock. + * Main editor window. Starts maximised with a hidden title bar on macOS; not + * always-on-top and appears in the taskbar/dock. */ export function createEditorWindow(): BrowserWindow { const isMac = process.platform === "darwin"; @@ -153,16 +194,15 @@ export function createEditorWindow(): BrowserWindow { }, }); - // Maximize the window by default win.maximize(); - // Show only once content is painted — prevents white flash on cold Vite start. + // Show only once painted to avoid a white flash on cold Vite start. win.once("ready-to-show", () => { if (!HEADLESS) win.show(); }); - // Inject dark background before any React paint so the sub-titlebar area - // never flashes white even on the very first cold Vite load. + // Inject dark background before any React paint so the sub-titlebar area never + // flashes white on a cold Vite load. win.webContents.on("dom-ready", () => { win.webContents.insertCSS("html, body, #root { background: #09090b !important; }").catch(() => { // Best-effort cosmetic; ignore if the page is mid-teardown. @@ -185,8 +225,8 @@ export function createEditorWindow(): BrowserWindow { } /** - * Creates the floating source-selector window used to pick a screen or window - * to record. Frameless, transparent, and follows the user across macOS Spaces. + * Floating source-selector window for picking a screen or window to record. + * Frameless, transparent, and follows the user across macOS Spaces. */ export function createSourceSelectorWindow(): BrowserWindow { const { width, height } = screen.getPrimaryDisplay().workAreaSize; @@ -211,8 +251,8 @@ export function createSourceSelectorWindow(): BrowserWindow { }, }); - // Follow the user across macOS Spaces so the selector appears on the - // active desktop regardless of where the HUD was originally opened. + // Follow the user across macOS Spaces so the selector appears on the active + // desktop regardless of where the HUD was opened. if (process.platform === "darwin") { win.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true }); } @@ -229,8 +269,8 @@ export function createSourceSelectorWindow(): BrowserWindow { } /** - * Creates a centered transparent countdown overlay window that sits above the - * HUD while recording pre-roll is running. + * Centered transparent countdown overlay that sits above the HUD during + * recording pre-roll. */ export function createCountdownOverlayWindow(): BrowserWindow { const { workArea } = screen.getPrimaryDisplay(); diff --git a/nix/package.nix b/nix/package.nix index 33dc4f735..bca77c91b 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -11,7 +11,7 @@ buildNpmPackage { nodejs = nodejs_22; pname = "openscreen"; - version = "1.4.0"; + version = "1.5.0"; src = let @@ -33,7 +33,7 @@ buildNpmPackage { ); }; - npmDepsHash = "sha256-tOpoJPzaZDK3HJijGHpZ0+jWsbrYyQUuw1pO0Uxcifg="; + npmDepsHash = "sha256-lx38H0qG5IrjQRekLG2N+x90Zq/emPfbxOo/qDSn7iE="; env.ELECTRON_SKIP_BINARY_DOWNLOAD = "1"; diff --git a/package-lock.json b/package-lock.json index cd50a40ef..e5b97fcdc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "openscreen", - "version": "1.4.0", + "version": "1.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "openscreen", - "version": "1.4.0", + "version": "1.5.0", "dependencies": { "@fix-webm-duration/fix": "^1.0.1", "@pixi/filter-drop-shadow": "^5.2.0", @@ -26,6 +26,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", @@ -64,7 +65,6 @@ "@vitejs/plugin-react": "^5.2.0", "@vitest/browser": "^4.1.4", "@vitest/browser-playwright": "^4.1.4", - "@xenova/transformers": "^2.17.2", "autoprefixer": "^10.5.0", "electron": "^41.2.1", "electron-builder": "^26.8.1", @@ -1788,7 +1788,6 @@ "version": "0.2.2", "resolved": "https://registry.npmjs.org/@huggingface/jinja/-/jinja-0.2.2.tgz", "integrity": "sha512-/KPde26khDUIPkTGU82jdtTW9UAuvUTumCAbFs/7giR0SxsvZC4hru51PBvpijH6BVkHcROcvZM/lpy5h1jRRA==", - "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -2485,9 +2484,9 @@ } }, "node_modules/@npmcli/fs/node_modules/semver": { - "version": "7.8.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", - "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", + "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", "dev": true, "license": "ISC", "bin": { @@ -2695,73 +2694,64 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", - "dev": true, "license": "BSD-3-Clause" }, "node_modules/@protobufjs/base64": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", - "dev": true, "license": "BSD-3-Clause" }, "node_modules/@protobufjs/codegen": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", - "dev": true, "license": "BSD-3-Clause" }, "node_modules/@protobufjs/eventemitter": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", - "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", - "dev": true, + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", + "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", "license": "BSD-3-Clause" }, "node_modules/@protobufjs/fetch": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", - "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", - "dev": true, + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", + "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", "license": "BSD-3-Clause", "dependencies": { - "@protobufjs/aspromise": "^1.1.1" + "@protobufjs/aspromise": "^1.1.1", + "@protobufjs/inquire": "^1.1.0" } }, "node_modules/@protobufjs/float": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", - "dev": true, "license": "BSD-3-Clause" }, "node_modules/@protobufjs/inquire": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.2.tgz", - "integrity": "sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw==", - "dev": true, + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.1.tgz", + "integrity": "sha512-mnzgDV26ueAvk7rsbt9L7bE0SuAoqyuys/sMMrmVcN5x9VsxpcG3rqAUSgDyLp0UZlmNfIbQ4fHfCtreVBk8Ew==", "license": "BSD-3-Clause" }, "node_modules/@protobufjs/path": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", - "dev": true, "license": "BSD-3-Clause" }, "node_modules/@protobufjs/pool": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", - "dev": true, "license": "BSD-3-Clause" }, "node_modules/@protobufjs/utf8": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz", "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==", - "dev": true, "license": "BSD-3-Clause" }, "node_modules/@radix-ui/number": { @@ -4503,7 +4493,6 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.2.tgz", "integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==", - "dev": true, "license": "MIT" }, "node_modules/@types/ms": { @@ -4517,7 +4506,6 @@ "version": "22.19.17", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz", "integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -4988,7 +4976,6 @@ "version": "2.17.2", "resolved": "https://registry.npmjs.org/@xenova/transformers/-/transformers-2.17.2.tgz", "integrity": "sha512-lZmHqzrVIkSvZdKZEx7IYY51TK0WDrC8eR0c5IMnBsO8di8are1zzw8BlLhyO2TklZKLN5UffNGs1IJwT6oOqQ==", - "dev": true, "license": "Apache-2.0", "dependencies": { "@huggingface/jinja": "^0.2.2", @@ -5669,10 +5656,9 @@ } }, "node_modules/bare-events": { - "version": "2.8.3", - "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.3.tgz", - "integrity": "sha512-HdUm8EMQBLaJvGUdidNNbqpA1kYkwNcb+MYxkxCLAPJGQzlv9J0C24h8V65Z4c5GLd/JEALDvpFCQgpLJqc0zw==", - "dev": true, + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz", + "integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==", "license": "Apache-2.0", "peerDependencies": { "bare-abort-controller": "*" @@ -5687,7 +5673,6 @@ "version": "4.7.1", "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.7.1.tgz", "integrity": "sha512-WDRsyVN52eAx/lBamKD6uyw8H4228h/x0sGGGegOamM2cd7Pag88GfMQalobXI+HaEUxpCkbKQUDOQqt9wawRw==", - "dev": true, "license": "Apache-2.0", "dependencies": { "bare-events": "^2.5.4", @@ -5712,17 +5697,15 @@ "version": "3.9.1", "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.9.1.tgz", "integrity": "sha512-6M5XjcnsygQNPMCMPXSK379xrJFiZ/AEMNBmFEmQW8d/789VQATvriyi5r0HYTL9TkQ26rn3kgdTG3aisbrXkQ==", - "dev": true, "license": "Apache-2.0", "engines": { "bare": ">=1.14.0" } }, "node_modules/bare-path": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.1.tgz", - "integrity": "sha512-ghj2DSK/2e99a1anTVPCV4m4YIYtrbXhfM7V3D7XZLOTsybnYyaJloymGqssQc8l/or0UoDyRtNQkmkEF/ysgQ==", - "dev": true, + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz", + "integrity": "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==", "license": "Apache-2.0", "dependencies": { "bare-os": "^3.0.1" @@ -5732,7 +5715,6 @@ "version": "2.13.1", "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.13.1.tgz", "integrity": "sha512-Vp0cnjYyrEC4whYTymQ+YZi6pBpfiICZO3cfRG8sy67ZNWe951urv1x4eW1BKNngw3U+3fPYb5JQvHbCtxH7Ow==", - "dev": true, "license": "Apache-2.0", "dependencies": { "streamx": "^2.25.0", @@ -5759,7 +5741,6 @@ "version": "2.4.3", "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.4.3.tgz", "integrity": "sha512-Kccpc7ACfXaxfeInfqKcZtW4pT5YBn1mesc4sCsun6sRwtbJ4h+sNOaksUpYEJUKfN65YWC6Bw2OJEFiKxq8nQ==", - "dev": true, "license": "Apache-2.0", "dependencies": { "bare-path": "^3.0.0" @@ -5769,7 +5750,6 @@ "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "dev": true, "funding": [ { "type": "github", @@ -5835,7 +5815,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", - "dev": true, "license": "MIT", "dependencies": { "buffer": "^5.5.0", @@ -5922,7 +5901,6 @@ "version": "5.7.1", "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "dev": true, "funding": [ { "type": "github", @@ -6660,7 +6638,6 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", - "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1", @@ -6674,7 +6651,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -6687,14 +6663,12 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, "license": "MIT" }, "node_modules/color-string": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", - "dev": true, "license": "MIT", "dependencies": { "color-name": "^1.0.0", @@ -6992,7 +6966,6 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", - "dev": true, "license": "MIT", "dependencies": { "mimic-response": "^3.1.0" @@ -7008,7 +6981,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -7021,7 +6993,6 @@ "version": "0.6.0", "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", - "dev": true, "license": "MIT", "engines": { "node": ">=4.0.0" @@ -7159,7 +7130,6 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "dev": true, "license": "Apache-2.0", "engines": { "node": ">=8" @@ -7814,9 +7784,9 @@ } }, "node_modules/electron-rebuild/node_modules/semver": { - "version": "7.8.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", - "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", + "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", "dev": true, "license": "ISC", "bin": { @@ -7977,7 +7947,6 @@ "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", - "dev": true, "license": "MIT", "dependencies": { "once": "^1.4.0" @@ -8218,7 +8187,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", - "dev": true, "license": "Apache-2.0", "dependencies": { "bare-events": "^2.7.0" @@ -8234,7 +8202,6 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", - "dev": true, "license": "(MIT OR WTFPL)", "engines": { "node": ">=6" @@ -8330,7 +8297,6 @@ "version": "1.3.2", "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", - "dev": true, "license": "MIT" }, "node_modules/fast-glob": { @@ -8514,7 +8480,6 @@ "version": "1.12.0", "resolved": "https://registry.npmjs.org/flatbuffers/-/flatbuffers-1.12.0.tgz", "integrity": "sha512-c7CZADjRcl6j0PlvFy0ZqXQ67qSEZfrVPynmnL+2zPc+NtMvrF8Y0QceMo7QqnSPc7+uWjUIAbvCQ5WIKlMVdQ==", - "dev": true, "license": "SEE LICENSE IN LICENSE.txt" }, "node_modules/follow-redirects": { @@ -8610,7 +8575,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", - "dev": true, "license": "MIT" }, "node_modules/fs-extra": { @@ -8860,7 +8824,6 @@ "version": "0.0.0", "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", - "dev": true, "license": "MIT" }, "node_modules/glob": { @@ -9066,7 +9029,6 @@ "version": "1.0.9", "resolved": "https://registry.npmjs.org/guid-typescript/-/guid-typescript-1.0.9.tgz", "integrity": "sha512-Y8T4vYhEfwJOTbouREvG+3XDsjr8E3kIr7uf+JZ0BYloFsttiHU0WfvANVsR7TxNUJa/WpCnw/Ino/p+DeBhBQ==", - "dev": true, "license": "ISC" }, "node_modules/har-schema": { @@ -9416,7 +9378,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "dev": true, "funding": [ { "type": "github", @@ -9503,14 +9464,12 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true, "license": "ISC" }, "node_modules/ini": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", - "dev": true, "license": "ISC" }, "node_modules/invert-kv": { @@ -9537,7 +9496,6 @@ "version": "0.3.4", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz", "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==", - "dev": true, "license": "MIT" }, "node_modules/is-binary-path": { @@ -10344,7 +10302,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz", "integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==", - "dev": true, "license": "Apache-2.0" }, "node_modules/loose-envify": { @@ -10725,7 +10682,6 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -10955,7 +10911,6 @@ "version": "0.5.3", "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", - "dev": true, "license": "MIT" }, "node_modules/motion": { @@ -11068,7 +11023,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", - "dev": true, "license": "MIT" }, "node_modules/negotiator": { @@ -11400,7 +11354,6 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, "license": "ISC", "dependencies": { "wrappy": "1" @@ -11426,7 +11379,6 @@ "version": "4.0.4", "resolved": "https://registry.npmjs.org/onnx-proto/-/onnx-proto-4.0.4.tgz", "integrity": "sha512-aldMOB3HRoo6q/phyB6QRQxSt895HNNw82BNyZ2CMh4bjeKv7g/c+VpAFtJuEMVfYLMbRx61hbuqnKceLeDcDA==", - "dev": true, "license": "MIT", "dependencies": { "protobufjs": "^6.8.8" @@ -11436,14 +11388,12 @@ "version": "1.14.0", "resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.14.0.tgz", "integrity": "sha512-3LJpegM2iMNRX2wUmtYfeX/ytfOzNwAWKSq1HbRrKc9+uqG/FsEA0bbKZl1btQeZaXhC26l44NWpNUeXPII7Ew==", - "dev": true, "license": "MIT" }, "node_modules/onnxruntime-node": { "version": "1.14.0", "resolved": "https://registry.npmjs.org/onnxruntime-node/-/onnxruntime-node-1.14.0.tgz", "integrity": "sha512-5ba7TWomIV/9b6NH/1x/8QEeowsb+jBEvFzU6z0T4mNsFwdPqXeFUM7uxC6QeSRkEbWu3qEB0VMjrvzN/0S9+w==", - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -11459,7 +11409,6 @@ "version": "1.14.0", "resolved": "https://registry.npmjs.org/onnxruntime-web/-/onnxruntime-web-1.14.0.tgz", "integrity": "sha512-Kcqf43UMfW8mCydVGcX9OMXI2VN17c0p6XvR7IPSZzBf/6lteBzXHvcEVWDPmCKuGombl997HgLqj91F11DzXw==", - "dev": true, "license": "MIT", "dependencies": { "flatbuffers": "^1.12.0", @@ -12032,7 +11981,6 @@ "version": "1.3.6", "resolved": "https://registry.npmjs.org/platform/-/platform-1.3.6.tgz", "integrity": "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==", - "dev": true, "license": "MIT" }, "node_modules/playwright": { @@ -12290,7 +12238,6 @@ "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", - "dev": true, "license": "MIT", "dependencies": { "detect-libc": "^2.0.0", @@ -12317,14 +12264,12 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", - "dev": true, "license": "ISC" }, "node_modules/prebuild-install/node_modules/node-abi": { "version": "3.92.0", "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.92.0.tgz", "integrity": "sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==", - "dev": true, "license": "MIT", "dependencies": { "semver": "^7.3.5" @@ -12334,10 +12279,9 @@ } }, "node_modules/prebuild-install/node_modules/semver": { - "version": "7.8.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", - "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", - "dev": true, + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -12350,7 +12294,6 @@ "version": "2.1.4", "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", - "dev": true, "license": "MIT", "dependencies": { "chownr": "^1.1.1", @@ -12363,7 +12306,6 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", - "dev": true, "license": "MIT", "dependencies": { "bl": "^4.0.3", @@ -12497,7 +12439,6 @@ "version": "6.11.6", "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-6.11.6.tgz", "integrity": "sha512-k8BHqgPBOtrlougZZqF2uUk5Z7bN8f0wj+3e8M3hvtSv0NBAz4VBy5f6R5Nxq/l+i7mRFTgNZb2trxqTpHNY/A==", - "dev": true, "hasInstallScript": true, "license": "BSD-3-Clause", "dependencies": { @@ -12537,7 +12478,6 @@ "version": "3.0.4", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", - "dev": true, "license": "MIT", "dependencies": { "end-of-stream": "^1.1.0", @@ -12624,7 +12564,6 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", - "dev": true, "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", "dependencies": { "deep-extend": "^0.6.0", @@ -12882,7 +12821,6 @@ "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "dev": true, "license": "MIT", "dependencies": { "inherits": "^2.0.3", @@ -13345,7 +13283,6 @@ "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "dev": true, "funding": [ { "type": "github", @@ -13457,7 +13394,6 @@ "version": "0.32.6", "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.32.6.tgz", "integrity": "sha512-KyLTWwgcR9Oe4d9HwCwNM2l7+J0dUQwn/yf7S0EnTtb0eVS4RxO0eUSvxPtzT4F3SY+C4K6fqdv/DO27sJ/v/w==", - "dev": true, "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { @@ -13481,14 +13417,12 @@ "version": "6.1.0", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-6.1.0.tgz", "integrity": "sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA==", - "dev": true, "license": "MIT" }, "node_modules/sharp/node_modules/semver": { - "version": "7.8.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", - "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", - "dev": true, + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -13614,7 +13548,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", - "dev": true, "funding": [ { "type": "github", @@ -13635,7 +13568,6 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", - "dev": true, "funding": [ { "type": "github", @@ -13661,7 +13593,6 @@ "version": "0.2.4", "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz", "integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==", - "dev": true, "license": "MIT", "dependencies": { "is-arrayish": "^0.3.1" @@ -13956,10 +13887,9 @@ "license": "MIT" }, "node_modules/streamx": { - "version": "2.26.0", - "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.26.0.tgz", - "integrity": "sha512-VvNG1K72Po/xwJzxZFnZ++Tbrv4lwSptsbkFuzXCJAYZvCK5nnxsvXU6ajqkv7chyiI1Y0YXq2Jh8Iy8Y7NF/A==", - "dev": true, + "version": "2.25.0", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.25.0.tgz", + "integrity": "sha512-0nQuG6jf1w+wddNEEXCF4nTg3LtufWINB5eFEN+5TNZW7KWJp6x87+JFL43vaAUPyCfH1wID+mNVyW6OHtFamg==", "license": "MIT", "dependencies": { "events-universal": "^1.0.0", @@ -13971,7 +13901,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "dev": true, "license": "MIT", "dependencies": { "safe-buffer": "~5.2.0" @@ -14074,7 +14003,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -14409,7 +14337,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.2.tgz", "integrity": "sha512-QGxxTxxyleAdyM3kpFs14ymbYmNFrfY+pHj7Z8FgtbZ7w2//VAgLMac7sT6nRpIHjppXO2AwwEOg0bPFVRcmXw==", - "dev": true, "license": "MIT", "dependencies": { "pump": "^3.0.0", @@ -14424,7 +14351,6 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.2.0.tgz", "integrity": "sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==", - "dev": true, "license": "MIT", "dependencies": { "b4a": "^1.6.4", @@ -14437,7 +14363,6 @@ "version": "1.8.1", "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.1.tgz", "integrity": "sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==", - "dev": true, "license": "Apache-2.0", "peerDependencies": { "react-native-b4a": "*" @@ -14462,7 +14387,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz", "integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==", - "dev": true, "license": "MIT", "dependencies": { "streamx": "^2.12.5" @@ -14562,7 +14486,6 @@ "version": "1.2.7", "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz", "integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==", - "dev": true, "license": "Apache-2.0", "dependencies": { "b4a": "^1.6.4" @@ -14572,7 +14495,6 @@ "version": "1.8.1", "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.1.tgz", "integrity": "sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==", - "dev": true, "license": "Apache-2.0", "peerDependencies": { "react-native-b4a": "*" @@ -14832,7 +14754,6 @@ "version": "0.6.0", "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", - "dev": true, "license": "Apache-2.0", "dependencies": { "safe-buffer": "^5.0.1" @@ -14897,7 +14818,6 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, "license": "MIT" }, "node_modules/unique-filename": { @@ -15496,7 +15416,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true, "license": "ISC" }, "node_modules/ws": { diff --git a/package.json b/package.json index edbfc998d..784bcf305 100644 --- a/package.json +++ b/package.json @@ -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", @@ -94,14 +95,13 @@ "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", -"@types/node": "^22.19.17", + "@types/node": "^22.19.17", "@types/react": "^18.3.28", "@types/react-dom": "^18.3.7", "@types/uuid": "^10.0.0", "@vitejs/plugin-react": "^5.2.0", "@vitest/browser": "^4.1.4", "@vitest/browser-playwright": "^4.1.4", - "@xenova/transformers": "^2.17.2", "autoprefixer": "^10.5.0", "electron": "^41.2.1", "electron-builder": "^26.8.1", diff --git a/public/cursors/among-us-sus-knife-and-red-animated/arrow.png b/public/cursors/among-us-sus-knife-and-red-animated/arrow.png new file mode 100644 index 000000000..84e9a3b34 Binary files /dev/null and b/public/cursors/among-us-sus-knife-and-red-animated/arrow.png differ diff --git a/public/cursors/among-us-sus-knife-and-red-animated/pointer.png b/public/cursors/among-us-sus-knife-and-red-animated/pointer.png new file mode 100644 index 000000000..b4212f732 Binary files /dev/null and b/public/cursors/among-us-sus-knife-and-red-animated/pointer.png differ diff --git a/public/cursors/black-and-rainbow-stroke-gradient-animated/arrow.png b/public/cursors/black-and-rainbow-stroke-gradient-animated/arrow.png new file mode 100644 index 000000000..adfcec8dc Binary files /dev/null and b/public/cursors/black-and-rainbow-stroke-gradient-animated/arrow.png differ diff --git a/public/cursors/black-and-rainbow-stroke-gradient-animated/pointer.png b/public/cursors/black-and-rainbow-stroke-gradient-animated/pointer.png new file mode 100644 index 000000000..2a0ebf08f Binary files /dev/null and b/public/cursors/black-and-rainbow-stroke-gradient-animated/pointer.png differ diff --git a/public/cursors/black-pixel/arrow.png b/public/cursors/black-pixel/arrow.png new file mode 100644 index 000000000..ec2d6a315 Binary files /dev/null and b/public/cursors/black-pixel/arrow.png differ diff --git a/public/cursors/black-pixel/pointer.png b/public/cursors/black-pixel/pointer.png new file mode 100644 index 000000000..ffd561641 Binary files /dev/null and b/public/cursors/black-pixel/pointer.png differ diff --git a/public/cursors/christmas-miles-morales/arrow.png b/public/cursors/christmas-miles-morales/arrow.png new file mode 100644 index 000000000..6931394ec Binary files /dev/null and b/public/cursors/christmas-miles-morales/arrow.png differ diff --git a/public/cursors/christmas-miles-morales/pointer.png b/public/cursors/christmas-miles-morales/pointer.png new file mode 100644 index 000000000..049a68e2e Binary files /dev/null and b/public/cursors/christmas-miles-morales/pointer.png differ diff --git a/public/cursors/hello-kitty-watermelon/arrow.png b/public/cursors/hello-kitty-watermelon/arrow.png new file mode 100644 index 000000000..9e6a20319 Binary files /dev/null and b/public/cursors/hello-kitty-watermelon/arrow.png differ diff --git a/public/cursors/hello-kitty-watermelon/pointer.png b/public/cursors/hello-kitty-watermelon/pointer.png new file mode 100644 index 000000000..f45fdfd06 Binary files /dev/null and b/public/cursors/hello-kitty-watermelon/pointer.png differ diff --git a/public/cursors/hollow-knight-and-game-arrow/arrow.png b/public/cursors/hollow-knight-and-game-arrow/arrow.png new file mode 100644 index 000000000..efb4b7708 Binary files /dev/null and b/public/cursors/hollow-knight-and-game-arrow/arrow.png differ diff --git a/public/cursors/hollow-knight-and-game-arrow/pointer.png b/public/cursors/hollow-knight-and-game-arrow/pointer.png new file mode 100644 index 000000000..dcea8e91d Binary files /dev/null and b/public/cursors/hollow-knight-and-game-arrow/pointer.png differ diff --git a/public/cursors/hollow-knight-nail-sword-and-mask/arrow.png b/public/cursors/hollow-knight-nail-sword-and-mask/arrow.png new file mode 100644 index 000000000..dbd4f52a2 Binary files /dev/null and b/public/cursors/hollow-knight-nail-sword-and-mask/arrow.png differ diff --git a/public/cursors/hollow-knight-nail-sword-and-mask/pointer.png b/public/cursors/hollow-knight-nail-sword-and-mask/pointer.png new file mode 100644 index 000000000..ac77b710c Binary files /dev/null and b/public/cursors/hollow-knight-nail-sword-and-mask/pointer.png differ diff --git a/public/cursors/mickey-mouse-black-hand-inflated-glove/arrow.png b/public/cursors/mickey-mouse-black-hand-inflated-glove/arrow.png new file mode 100644 index 000000000..143076372 Binary files /dev/null and b/public/cursors/mickey-mouse-black-hand-inflated-glove/arrow.png differ diff --git a/public/cursors/mickey-mouse-black-hand-inflated-glove/pointer.png b/public/cursors/mickey-mouse-black-hand-inflated-glove/pointer.png new file mode 100644 index 000000000..f52a4e650 Binary files /dev/null and b/public/cursors/mickey-mouse-black-hand-inflated-glove/pointer.png differ diff --git a/public/cursors/naruto-akatsuki-cloud-arrow/arrow.png b/public/cursors/naruto-akatsuki-cloud-arrow/arrow.png new file mode 100644 index 000000000..d6361dff2 Binary files /dev/null and b/public/cursors/naruto-akatsuki-cloud-arrow/arrow.png differ diff --git a/public/cursors/naruto-akatsuki-cloud-arrow/pointer.png b/public/cursors/naruto-akatsuki-cloud-arrow/pointer.png new file mode 100644 index 000000000..fa10e9d2d Binary files /dev/null and b/public/cursors/naruto-akatsuki-cloud-arrow/pointer.png differ diff --git a/public/cursors/old-roblox/arrow.png b/public/cursors/old-roblox/arrow.png new file mode 100644 index 000000000..a55875df9 Binary files /dev/null and b/public/cursors/old-roblox/arrow.png differ diff --git a/public/cursors/old-roblox/pointer.png b/public/cursors/old-roblox/pointer.png new file mode 100644 index 000000000..d490a65fd Binary files /dev/null and b/public/cursors/old-roblox/pointer.png differ diff --git a/public/cursors/pink-glossy-arrow-and-hand-3d/arrow.png b/public/cursors/pink-glossy-arrow-and-hand-3d/arrow.png new file mode 100644 index 000000000..69ecd5b19 Binary files /dev/null and b/public/cursors/pink-glossy-arrow-and-hand-3d/arrow.png differ diff --git a/public/cursors/pink-glossy-arrow-and-hand-3d/pointer.png b/public/cursors/pink-glossy-arrow-and-hand-3d/pointer.png new file mode 100644 index 000000000..f8d5938a7 Binary files /dev/null and b/public/cursors/pink-glossy-arrow-and-hand-3d/pointer.png differ diff --git a/public/cursors/pinky-pixel/arrow.png b/public/cursors/pinky-pixel/arrow.png new file mode 100644 index 000000000..4bcd3dcaa Binary files /dev/null and b/public/cursors/pinky-pixel/arrow.png differ diff --git a/public/cursors/pinky-pixel/pointer.png b/public/cursors/pinky-pixel/pointer.png new file mode 100644 index 000000000..7eb897a74 Binary files /dev/null and b/public/cursors/pinky-pixel/pointer.png differ diff --git a/public/cursors/pokemon-neon-gengar/arrow.png b/public/cursors/pokemon-neon-gengar/arrow.png new file mode 100644 index 000000000..4dcccb360 Binary files /dev/null and b/public/cursors/pokemon-neon-gengar/arrow.png differ diff --git a/public/cursors/pokemon-neon-gengar/pointer.png b/public/cursors/pokemon-neon-gengar/pointer.png new file mode 100644 index 000000000..e7b4437f0 Binary files /dev/null and b/public/cursors/pokemon-neon-gengar/pointer.png differ diff --git a/public/cursors/sanrio-gudetama-and-arrow-kawaii/arrow.png b/public/cursors/sanrio-gudetama-and-arrow-kawaii/arrow.png new file mode 100644 index 000000000..123117b05 Binary files /dev/null and b/public/cursors/sanrio-gudetama-and-arrow-kawaii/arrow.png differ diff --git a/public/cursors/sanrio-gudetama-and-arrow-kawaii/pointer.png b/public/cursors/sanrio-gudetama-and-arrow-kawaii/pointer.png new file mode 100644 index 000000000..37afa7bd1 Binary files /dev/null and b/public/cursors/sanrio-gudetama-and-arrow-kawaii/pointer.png differ diff --git a/public/cursors/sanrio-kuromi-skull-arrow/arrow.png b/public/cursors/sanrio-kuromi-skull-arrow/arrow.png new file mode 100644 index 000000000..6fe7e89d6 Binary files /dev/null and b/public/cursors/sanrio-kuromi-skull-arrow/arrow.png differ diff --git a/public/cursors/sanrio-kuromi-skull-arrow/pointer.png b/public/cursors/sanrio-kuromi-skull-arrow/pointer.png new file mode 100644 index 000000000..2971a7f16 Binary files /dev/null and b/public/cursors/sanrio-kuromi-skull-arrow/pointer.png differ diff --git a/public/cursors/solo-leveling-sung-jinwoo-dark-flames/arrow.png b/public/cursors/solo-leveling-sung-jinwoo-dark-flames/arrow.png new file mode 100644 index 000000000..2155e0fd9 Binary files /dev/null and b/public/cursors/solo-leveling-sung-jinwoo-dark-flames/arrow.png differ diff --git a/public/cursors/solo-leveling-sung-jinwoo-dark-flames/pointer.png b/public/cursors/solo-leveling-sung-jinwoo-dark-flames/pointer.png new file mode 100644 index 000000000..0e0b1a0b7 Binary files /dev/null and b/public/cursors/solo-leveling-sung-jinwoo-dark-flames/pointer.png differ diff --git a/public/cursors/spring-gradient/arrow.png b/public/cursors/spring-gradient/arrow.png new file mode 100644 index 000000000..51889625d Binary files /dev/null and b/public/cursors/spring-gradient/arrow.png differ diff --git a/public/cursors/spring-gradient/pointer.png b/public/cursors/spring-gradient/pointer.png new file mode 100644 index 000000000..9ccdc2fb6 Binary files /dev/null and b/public/cursors/spring-gradient/pointer.png differ diff --git a/public/demo.png b/public/demo.png new file mode 100644 index 000000000..a0f008fe4 Binary files /dev/null and b/public/demo.png differ diff --git a/public/sample.png b/public/sample.png new file mode 100644 index 000000000..0138b39ac Binary files /dev/null and b/public/sample.png differ diff --git a/public/wallpapers/wallpaper1.jpg b/public/wallpapers/wallpaper1.jpg index 98c93cb14..dbd8afb8b 100644 Binary files a/public/wallpapers/wallpaper1.jpg and b/public/wallpapers/wallpaper1.jpg differ diff --git a/scripts/before-pack.cjs b/scripts/before-pack.cjs new file mode 100644 index 000000000..934756a4d --- /dev/null +++ b/scripts/before-pack.cjs @@ -0,0 +1,14 @@ +// electron-builder beforePack hook: ensure the auto-caption assets (Whisper model + ORT wasm) exist +// before packaging, so the `caption-assets` extraResources entry has something to copy. Runs on +// every package invocation (local `npm run build:*` and CI's bare `electron-builder`). The fetch +// script is idempotent, so it's a no-op once the assets are present. + +const { execFileSync } = require("node:child_process"); +const path = require("node:path"); + +exports.default = async function beforePack() { + execFileSync("node", [path.join(__dirname, "fetch-caption-model.mjs")], { + stdio: "inherit", + cwd: path.join(__dirname, ".."), + }); +}; diff --git a/scripts/build-macos-screencapturekit-helper.mjs b/scripts/build-macos-screencapturekit-helper.mjs index 8e7c97396..b94836cff 100644 --- a/scripts/build-macos-screencapturekit-helper.mjs +++ b/scripts/build-macos-screencapturekit-helper.mjs @@ -18,14 +18,24 @@ const cursorHelperName = "openscreen-macos-cursor-helper"; const packageDir = path.join(root, "electron", "native", "screencapturekit"); const buildDir = path.join(packageDir, "build"); const swiftBuildDir = path.join(buildDir, "swiftpm"); -const builtHelperPath = path.join(swiftBuildDir, "release", helperName); const localHelperPath = path.join(buildDir, helperName); -const builtCursorHelperPath = path.join(swiftBuildDir, "release", cursorHelperName); const localCursorHelperPath = path.join(buildDir, cursorHelperName); -const archTag = process.arch === "arm64" ? "darwin-arm64" : "darwin-x64"; -const distributableDir = path.join(root, "electron", "native", "bin", archTag); -const distributablePath = path.join(distributableDir, helperName); -const distributableCursorHelperPath = path.join(distributableDir, cursorHelperName); + +// Build a separate single-arch binary per requested arch and place each in its own +// electron/native/bin/darwin- folder (the runtime resolves that folder by the running app's +// arch). No universal/fat binary. Defaults to the host arch for local builds; CI sets +// OPENSCREEN_MAC_HELPER_ARCHS per matrix entry (accepts arm64, x64, or x86_64). +function normalizeArch(value) { + return value === "x64" || value === "x86_64" + ? { swift: "x86_64", tag: "darwin-x64" } + : { swift: "arm64", tag: "darwin-arm64" }; +} +const hostArch = process.arch === "arm64" ? "arm64" : "x86_64"; +const archs = (process.env.OPENSCREEN_MAC_HELPER_ARCHS ?? hostArch) + .split(",") + .map((a) => a.trim()) + .filter(Boolean) + .map(normalizeArch); const xcodebuildVersion = spawnSync("xcodebuild", ["-version"], { cwd: root, @@ -50,42 +60,85 @@ if (xcodebuildVersion.status !== 0) { process.exit(1); } -const result = spawnSync( - "swift", - ["build", "-c", "release", "--package-path", packageDir, "--build-path", swiftBuildDir], - { - cwd: root, - stdio: "inherit", - }, -); +// SwiftPM writes a single-arch release build to /-apple-macosx/release/. +// Fall back to a search that skips the identically-named file inside the .dSYM debug bundle (matching +// that file and feeding it forward is what produced an unrunnable "exec format error" binary before). +function findExecutable(dir, swiftArch, name) { + const expected = path.join(dir, `${swiftArch}-apple-macosx`, "release", name); + if (fs.existsSync(expected)) return expected; -if (result.error) { - console.error(`Failed to start Swift build: ${result.error.message}`); - process.exit(1); -} - -if (result.status !== 0) { - process.exit(result.status ?? 1); + const stack = [dir]; + const matches = []; + while (stack.length > 0) { + const current = stack.pop(); + let entries; + try { + entries = fs.readdirSync(current, { withFileTypes: true }); + } catch { + continue; + } + for (const entry of entries) { + if (entry.name.endsWith(".dSYM")) continue; + const full = path.join(current, entry.name); + if (entry.isDirectory()) stack.push(full); + else if (entry.isFile() && entry.name === name && /[/\\]release[/\\]/i.test(full)) { + matches.push(full); + } + } + } + return matches[0] ?? null; } fs.mkdirSync(buildDir, { recursive: true }); -fs.mkdirSync(distributableDir, { recursive: true }); -for (const artifactPath of [builtHelperPath, builtCursorHelperPath]) { - if (!fs.existsSync(artifactPath)) { - console.error(`Swift build completed but expected artifact was not found: ${artifactPath}`); + +for (const { swift, tag } of archs) { + const archBuildDir = path.join(swiftBuildDir, swift); + const result = spawnSync( + "swift", + [ + "build", + "-c", + "release", + "--arch", + swift, + "--package-path", + packageDir, + "--build-path", + archBuildDir, + ], + { + cwd: root, + stdio: "inherit", + }, + ); + if (result.error) { + console.error(`Failed to start Swift build (${swift}): ${result.error.message}`); process.exit(1); } -} -fs.copyFileSync(builtHelperPath, localHelperPath); -fs.copyFileSync(builtHelperPath, distributablePath); -fs.copyFileSync(builtCursorHelperPath, localCursorHelperPath); -fs.copyFileSync(builtCursorHelperPath, distributableCursorHelperPath); -fs.chmodSync(localHelperPath, 0o755); -fs.chmodSync(distributablePath, 0o755); -fs.chmodSync(localCursorHelperPath, 0o755); -fs.chmodSync(distributableCursorHelperPath, 0o755); + if (result.status !== 0) { + process.exit(result.status ?? 1); + } -console.log(`Built macOS ScreenCaptureKit helper: ${localHelperPath}`); -console.log(`Copied redistributable helper: ${distributablePath}`); -console.log(`Built macOS cursor helper: ${localCursorHelperPath}`); -console.log(`Copied redistributable cursor helper: ${distributableCursorHelperPath}`); + const targetDir = path.join(root, "electron", "native", "bin", tag); + fs.mkdirSync(targetDir, { recursive: true }); + + for (const [name, localPath] of [ + [helperName, localHelperPath], + [cursorHelperName, localCursorHelperPath], + ]) { + const exe = findExecutable(archBuildDir, swift, name); + if (!exe) { + console.error(`Swift build (${swift}) completed but executable was not found: ${name}`); + process.exit(1); + } + // Always place it in the arch's bin folder; mirror the host-arch build into the dev build + // dir so `npm run dev` (candidate path #2) can spawn it. + const dests = [path.join(targetDir, name)]; + if (swift === hostArch) dests.push(localPath); + for (const dest of dests) { + fs.copyFileSync(exe, dest); + fs.chmodSync(dest, 0o755); + } + } + console.log(`Built ${tag} helpers (${swift})`); +} diff --git a/scripts/extract-subtitles.mjs b/scripts/extract-subtitles.mjs index 0c0cc8e18..d1393927b 100644 --- a/scripts/extract-subtitles.mjs +++ b/scripts/extract-subtitles.mjs @@ -1,8 +1,8 @@ #!/usr/bin/env node import { execSync } from "child_process"; -import { existsSync, mkdirSync, unlinkSync, writeFileSync, readFileSync } from "fs"; -import { join, basename, dirname } from "path"; +import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "fs"; +import { basename, dirname, join } from "path"; import { fileURLToPath } from "url"; import wavefile from "wavefile"; @@ -12,281 +12,349 @@ const FPS = 30; // Words/patterns that suggest emphasis or impact (for zoom effects) // Portuguese (Brazilian) impact patterns const IMPACT_PATTERNS_PT = [ - // Exclamations - /!+$/, - /\?!$/, - // Numbers and statistics - /\d+%/, - /R\$\s?\d+/, - /\d+x/i, - // Portuguese action/emphasis words - /\b(incrível|inacreditável|insano|absurdo|bizarro|épico|lendário|surreal)\b/i, - /\b(urgente|importante|crítico|atenção|perigo|alerta|cuidado)\b/i, - /\b(segredo|revelado|exposto|verdade|finalmente|agora|hoje)\b/i, - /\b(melhor|pior|primeiro|último|único|nunca|sempre|jamais)\b/i, - /\b(ganhar|ganhei|ganhamos|perder|perdi|perdemos|vencer|sucesso|consegui|dominei)\b/i, - /\b(milhão|milhões|bilhão|bilhões|mil)\b/i, - /\b(grátis|gratuito|novo|nova|exclusivo|exclusiva|limitado|especial)\b/i, - /\b(olha|olhem|cara|mano|gente|galera|pessoal)\b/i, - /\b(demais|muito|super|mega|ultra|hiper)\b/i, - /\b(quebrou|explodiu|bombou|viralizou|estourou)\b/i, - // All caps words (emphasis) - /\b[A-Z]{3,}\b/, + // Exclamations + /!+$/, + /\?!$/, + // Numbers and statistics + /\d+%/, + /R\$\s?\d+/, + /\d+x/i, + // Portuguese action/emphasis words + /\b(incrível|inacreditável|insano|absurdo|bizarro|épico|lendário|surreal)\b/i, + /\b(urgente|importante|crítico|atenção|perigo|alerta|cuidado)\b/i, + /\b(segredo|revelado|exposto|verdade|finalmente|agora|hoje)\b/i, + /\b(melhor|pior|primeiro|último|único|nunca|sempre|jamais)\b/i, + /\b(ganhar|ganhei|ganhamos|perder|perdi|perdemos|vencer|sucesso|consegui|dominei)\b/i, + /\b(milhão|milhões|bilhão|bilhões|mil)\b/i, + /\b(grátis|gratuito|novo|nova|exclusivo|exclusiva|limitado|especial)\b/i, + /\b(olha|olhem|cara|mano|gente|galera|pessoal)\b/i, + /\b(demais|muito|super|mega|ultra|hiper)\b/i, + /\b(quebrou|explodiu|bombou|viralizou|estourou)\b/i, + // All caps words (emphasis) + /\b[A-Z]{3,}\b/, ]; // English impact patterns (fallback) const IMPACT_PATTERNS_EN = [ - /!+$/, - /\?!$/, - /\d+%/, - /\$\d+/, - /\d+x/i, - /\b(amazing|incredible|unbelievable|insane|crazy|huge|massive|epic|legendary)\b/i, - /\b(breaking|urgent|important|critical|warning|danger|alert)\b/i, - /\b(secret|revealed|exposed|truth|finally|now|today)\b/i, - /\b(best|worst|top|first|last|only|never|always|ever)\b/i, - /\b(win|won|lose|lost|fail|success|achieve|dominate)\b/i, - /\b(million|billion|thousand|hundred)\b/i, - /\b(free|new|exclusive|limited|special)\b/i, - /\b[A-Z]{3,}\b/, + /!+$/, + /\?!$/, + /\d+%/, + /\$\d+/, + /\d+x/i, + /\b(amazing|incredible|unbelievable|insane|crazy|huge|massive|epic|legendary)\b/i, + /\b(breaking|urgent|important|critical|warning|danger|alert)\b/i, + /\b(secret|revealed|exposed|truth|finally|now|today)\b/i, + /\b(best|worst|top|first|last|only|never|always|ever)\b/i, + /\b(win|won|lose|lost|fail|success|achieve|dominate)\b/i, + /\b(million|billion|thousand|hundred)\b/i, + /\b(free|new|exclusive|limited|special)\b/i, + /\b[A-Z]{3,}\b/, ]; // Detect if text is impactful and deserves a zoom function detectImpact(text, language = "pt") { - const patterns = language === "pt" ? IMPACT_PATTERNS_PT : IMPACT_PATTERNS_EN; - - const impactScore = patterns.reduce((score, pattern) => { - return score + (pattern.test(text) ? 1 : 0); - }, 0); - - if (impactScore >= 2) { - return { type: "zoomCut", intensity: 2 }; - } else if (impactScore === 1) { - return { type: "zoomHold", intensity: 1 }; - } - return null; + const patterns = language === "pt" ? IMPACT_PATTERNS_PT : IMPACT_PATTERNS_EN; + + const impactScore = patterns.reduce((score, pattern) => { + return score + (pattern.test(text) ? 1 : 0); + }, 0); + + if (impactScore >= 2) { + return { type: "zoomCut", intensity: 2 }; + } else if (impactScore === 1) { + return { type: "zoomHold", intensity: 1 }; + } + return null; } // Convert seconds to frame number function secondsToFrame(seconds, fps = FPS) { - return Math.round(seconds * fps); + return Math.round(seconds * fps); } // Extract audio from video using ffmpeg async function extractAudio(videoPath, outputPath) { - console.log("Extraindo áudio do vídeo..."); - - try { - execSync( - `ffmpeg -y -i "${videoPath}" -vn -acodec pcm_s16le -ar 16000 -ac 1 "${outputPath}"`, - { stdio: "pipe" } - ); - console.log("Áudio extraído com sucesso."); - return true; - } catch (error) { - console.error("Erro ao extrair áudio:", error.message); - return false; - } + console.log("Extraindo áudio do vídeo..."); + + try { + execSync(`ffmpeg -y -i "${videoPath}" -vn -acodec pcm_s16le -ar 16000 -ac 1 "${outputPath}"`, { + stdio: "pipe", + }); + console.log("Áudio extraído com sucesso."); + return true; + } catch (error) { + console.error("Erro ao extrair áudio:", error.message); + return false; + } } // Read WAV file and convert to Float32Array for Whisper function readWavAsFloat32(audioPath) { - console.log("Lendo arquivo de áudio..."); - const buffer = readFileSync(audioPath); - const wav = new wavefile.WaveFile(buffer); + console.log("Lendo arquivo de áudio..."); + const buffer = readFileSync(audioPath); + const wav = new wavefile.WaveFile(buffer); - // Ensure 16-bit PCM format - wav.toBitDepth("16"); + // Ensure 16-bit PCM format + wav.toBitDepth("16"); - // Get samples and convert to Float32Array normalized to [-1, 1] - const samples = wav.getSamples(false, Int16Array); - const float32 = new Float32Array(samples.length); + // Get samples and convert to Float32Array normalized to [-1, 1] + const samples = wav.getSamples(false, Int16Array); + const float32 = new Float32Array(samples.length); - for (let i = 0; i < samples.length; i++) { - float32[i] = samples[i] / 32768.0; - } + for (let i = 0; i < samples.length; i++) { + float32[i] = samples[i] / 32768.0; + } - return float32; + return float32; } // Transcribe audio using Whisper via transformers.js async function transcribeAudio(audioPath, language = "portuguese", modelSize = "small") { - console.log("Carregando modelo Whisper (pode demorar na primeira execução)..."); + console.log("Carregando modelo Whisper (pode demorar na primeira execução)..."); - const { pipeline } = await import("@xenova/transformers"); + const { pipeline } = await import("@xenova/transformers"); - const modelName = `Xenova/whisper-${modelSize}`; - console.log(`Usando modelo: ${modelName}`); + const modelName = `Xenova/whisper-${modelSize}`; + console.log(`Usando modelo: ${modelName}`); - const transcriber = await pipeline("automatic-speech-recognition", modelName, { - chunk_length_s: 30, - stride_length_s: 5, - }); + const transcriber = await pipeline("automatic-speech-recognition", modelName, { + chunk_length_s: 30, + stride_length_s: 5, + }); - // Read audio file as Float32Array (required for Node.js) - const audioData = readWavAsFloat32(audioPath); + // Read audio file as Float32Array (required for Node.js) + const audioData = readWavAsFloat32(audioPath); - console.log("Transcrevendo áudio em português..."); + console.log("Transcrevendo áudio em português..."); - const result = await transcriber(audioData, { - return_timestamps: "word", - chunk_length_s: 30, - stride_length_s: 5, - language: language, - task: "transcribe", - sampling_rate: 16000, - }); + const result = await transcriber(audioData, { + return_timestamps: "word", + chunk_length_s: 30, + stride_length_s: 5, + language: language, + task: "transcribe", + sampling_rate: 16000, + }); - return result; + return result; } // Group words into subtitle chunks (by sentence or time gaps) function groupIntoSubtitles(chunks, maxWordsPerSubtitle = 8, maxDuration = 3) { - const subtitles = []; - let currentSubtitle = { - words: [], - startTime: null, - endTime: null, - }; - - for (const chunk of chunks) { - if (!chunk.timestamp) continue; - - const [start, end] = chunk.timestamp; - const word = chunk.text.trim(); - - if (!word) continue; - - // Skip bracketed annotations like [Música], [Music], [Applause], etc. - if (/^\[.*\]$/.test(word)) continue; - - // Start new subtitle if: - // 1. Current is empty - // 2. Too many words - // 3. Gap > 0.5s between words - // 4. Duration would exceed max - // 5. Sentence ends (., !, ?) - const shouldStartNew = - currentSubtitle.words.length === 0 || - currentSubtitle.words.length >= maxWordsPerSubtitle || - (currentSubtitle.endTime && start - currentSubtitle.endTime > 0.5) || - (currentSubtitle.startTime && end - currentSubtitle.startTime > maxDuration) || - (currentSubtitle.words.length > 0 && - /[.!?]$/.test(currentSubtitle.words[currentSubtitle.words.length - 1])); - - if (shouldStartNew && currentSubtitle.words.length > 0) { - subtitles.push({ ...currentSubtitle }); - currentSubtitle = { words: [], startTime: null, endTime: null }; - } - - currentSubtitle.words.push(word); - if (currentSubtitle.startTime === null) { - currentSubtitle.startTime = start; - } - currentSubtitle.endTime = end; - } - - // Push the last subtitle - if (currentSubtitle.words.length > 0) { - subtitles.push(currentSubtitle); - } - - return subtitles; + const subtitles = []; + let currentSubtitle = { + words: [], + startTime: null, + endTime: null, + }; + + for (const chunk of chunks) { + if (!chunk.timestamp) continue; + + const [start, end] = chunk.timestamp; + const word = chunk.text.trim(); + + if (!word) continue; + + // Skip bracketed annotations like [Música], [Music], [Applause], etc. + if (/^\[.*\]$/.test(word)) continue; + + // Start new subtitle if: + // 1. Current is empty + // 2. Too many words + // 3. Gap > 0.5s between words + // 4. Duration would exceed max + // 5. Sentence ends (., !, ?) + const shouldStartNew = + currentSubtitle.words.length === 0 || + currentSubtitle.words.length >= maxWordsPerSubtitle || + (currentSubtitle.endTime && start - currentSubtitle.endTime > 0.5) || + (currentSubtitle.startTime && end - currentSubtitle.startTime > maxDuration) || + (currentSubtitle.words.length > 0 && + /[.!?]$/.test(currentSubtitle.words[currentSubtitle.words.length - 1])); + + if (shouldStartNew && currentSubtitle.words.length > 0) { + subtitles.push({ ...currentSubtitle }); + currentSubtitle = { words: [], startTime: null, endTime: null }; + } + + currentSubtitle.words.push(word); + if (currentSubtitle.startTime === null) { + currentSubtitle.startTime = start; + } + currentSubtitle.endTime = end; + } + + // Push the last subtitle + if (currentSubtitle.words.length > 0) { + subtitles.push(currentSubtitle); + } + + return subtitles; } // Words that should not end a subtitle (orphan words) - only applies to short words (< 4 letters) const ORPHAN_WORDS_PT = new Set([ - // Articles - "o", "a", "os", "as", "um", "uma", "uns", - // Prepositions - "de", "da", "do", "das", "dos", "em", "na", "no", "nas", "nos", - "por", "com", "sem", "sob", "ao", "aos", "à", "às", - // Conjunctions - "e", "ou", "mas", "que", "se", "nem", - // Pronouns - "eu", "tu", "ele", "ela", "nós", "vós", - "me", "te", "se", "nos", "vos", "lhe", - "meu", "teu", "seu", - // Other common short words - "não", "já", "só", + // Articles + "o", + "a", + "os", + "as", + "um", + "uma", + "uns", + // Prepositions + "de", + "da", + "do", + "das", + "dos", + "em", + "na", + "no", + "nas", + "nos", + "por", + "com", + "sem", + "sob", + "ao", + "aos", + "à", + "às", + // Conjunctions + "e", + "ou", + "mas", + "que", + "se", + "nem", + // Pronouns + "eu", + "tu", + "ele", + "ela", + "nós", + "vós", + "me", + "te", + "se", + "nos", + "vos", + "lhe", + "meu", + "teu", + "seu", + // Other common short words + "não", + "já", + "só", ]); const ORPHAN_WORDS_EN = new Set([ - // Articles - "a", "an", "the", - // Prepositions - "of", "to", "in", "on", "at", "by", "for", - // Conjunctions - "and", "or", "but", "if", "as", "so", "yet", "nor", - // Pronouns - "i", "we", "he", "she", "it", "my", "our", "his", "her", "its", - // Other - "is", "are", "was", "be", "has", + // Articles + "a", + "an", + "the", + // Prepositions + "of", + "to", + "in", + "on", + "at", + "by", + "for", + // Conjunctions + "and", + "or", + "but", + "if", + "as", + "so", + "yet", + "nor", + // Pronouns + "i", + "we", + "he", + "she", + "it", + "my", + "our", + "his", + "her", + "its", + // Other + "is", + "are", + "was", + "be", + "has", ]); // Check if text ends with punctuation function endsWithPunctuation(text) { - return /[.,!?;:"""'']$/.test(text.trim()); + return /[.,!?;:"""'']$/.test(text.trim()); } // Fix orphan words at the end of subtitles function fixOrphanWords(subtitles, language = "pt") { - const orphanWords = language === "pt" ? ORPHAN_WORDS_PT : ORPHAN_WORDS_EN; - const result = [...subtitles]; + const orphanWords = language === "pt" ? ORPHAN_WORDS_PT : ORPHAN_WORDS_EN; + const result = [...subtitles]; - for (let i = 0; i < result.length - 1; i++) { - const current = result[i]; - const next = result[i + 1]; + for (let i = 0; i < result.length - 1; i++) { + const current = result[i]; + const next = result[i + 1]; - if (current.words.length <= 1) continue; + if (current.words.length <= 1) continue; - // Keep moving words while the last word is an orphan - while (current.words.length > 1) { - const lastWord = current.words[current.words.length - 1]; - const lastWordClean = lastWord.replace(/[.,!?;:"""'']/g, ""); - const lastWordLower = lastWordClean.toLowerCase(); + // Keep moving words while the last word is an orphan + while (current.words.length > 1) { + const lastWord = current.words[current.words.length - 1]; + const lastWordClean = lastWord.replace(/[.,!?;:"""'']/g, ""); + const lastWordLower = lastWordClean.toLowerCase(); - // Stop if word ends with punctuation (good break point) - if (endsWithPunctuation(lastWord)) break; + // Stop if word ends with punctuation (good break point) + if (endsWithPunctuation(lastWord)) break; - // Words with 4+ letters are fine - if (lastWordClean.length >= 4) break; + // Words with 4+ letters are fine + if (lastWordClean.length >= 4) break; - // Check if it's an orphan word - const isOrphan = orphanWords.has(lastWordLower); + // Check if it's an orphan word + const isOrphan = orphanWords.has(lastWordLower); - if (isOrphan) { - // Move word to next subtitle - const wordToMove = current.words.pop(); - next.words.unshift(wordToMove); - } else { - break; - } - } - } + if (isOrphan) { + // Move word to next subtitle + const wordToMove = current.words.pop(); + next.words.unshift(wordToMove); + } else { + break; + } + } + } - return result; + return result; } // Format subtitles as TypeScript code function formatAsTypeScript(subtitles, fps = FPS, language = "pt") { - const items = subtitles.map((sub) => { - const text = sub.words.join(" "); - const startFrame = secondsToFrame(sub.startTime, fps); - const endFrame = secondsToFrame(sub.endTime, fps); - const zoom = detectImpact(text, language); + const items = subtitles.map((sub) => { + const text = sub.words.join(" "); + const startFrame = secondsToFrame(sub.startTime, fps); + const endFrame = secondsToFrame(sub.endTime, fps); + const zoom = detectImpact(text, language); - let item = ` { text: "${text}", startFrame: ${startFrame}, endFrame: ${endFrame}`; + let item = ` { text: "${text}", startFrame: ${startFrame}, endFrame: ${endFrame}`; - if (zoom) { - item += `, zoom: { type: "${zoom.type}" as ZoomType, intensity: ${zoom.intensity} }`; - } + if (zoom) { + item += `, zoom: { type: "${zoom.type}" as ZoomType, intensity: ${zoom.intensity} }`; + } - item += " }"; - return item; - }); + item += " }"; + return item; + }); - return ` subtitles: { + return ` subtitles: { transition: "slideUp" as TransitionType, items: [ ${items.join(",\n")}, @@ -296,149 +364,157 @@ ${items.join(",\n")}, // Group subtitles into sentences (for audio splitting) function groupIntoSentences(subtitles) { - const sentences = []; - let currentSentence = []; - - for (let i = 0; i < subtitles.length; i++) { - currentSentence.push(i); - const text = subtitles[i].words.join(" "); - - // End sentence if we hit punctuation or it's the last subtitle - if (text.match(/[.!?]$/) || i === subtitles.length - 1) { - sentences.push([...currentSentence]); - currentSentence = []; - } - } - - // Push any remaining subtitles as a sentence - if (currentSentence.length > 0) { - sentences.push(currentSentence); - } - - return sentences; + const sentences = []; + let currentSentence = []; + + for (let i = 0; i < subtitles.length; i++) { + currentSentence.push(i); + const text = subtitles[i].words.join(" "); + + // End sentence if we hit punctuation or it's the last subtitle + if (text.match(/[.!?]$/) || i === subtitles.length - 1) { + sentences.push([...currentSentence]); + currentSentence = []; + } + } + + // Push any remaining subtitles as a sentence + if (currentSentence.length > 0) { + sentences.push(currentSentence); + } + + return sentences; } // Split audio file into sentence chunks async function splitAudioIntoSentences(audioPath, subtitles, sentences, sessionDir, fps = FPS) { - const audioDir = join(sessionDir, 'audio'); - if (!existsSync(audioDir)) { - mkdirSync(audioDir, { recursive: true }); - } - - console.log(`\nDividindo áudio em ${sentences.length} sentenças...`); - - const sentenceAudioFiles = []; - - for (let sentenceIdx = 0; sentenceIdx < sentences.length; sentenceIdx++) { - const subtitleIndices = sentences[sentenceIdx]; - const firstSubIndex = subtitleIndices[0]; - const lastSubIndex = subtitleIndices[subtitleIndices.length - 1]; - - const startTime = subtitles[firstSubIndex].startTime; - const endTime = subtitles[lastSubIndex].endTime; - const duration = endTime - startTime; - - const outputPath = join(audioDir, `sentence_${sentenceIdx}.wav`); - - try { - // Extract audio chunk using ffmpeg - execSync( - `ffmpeg -y -i "${audioPath}" -ss ${startTime} -t ${duration} -acodec pcm_s16le -ar 44100 -ac 1 "${outputPath}"`, - { stdio: 'pipe' } - ); - - const durationInFrames = Math.round(duration * fps); - sentenceAudioFiles.push({ - sentenceId: sentenceIdx, - path: `/sessions/${basename(sessionDir)}/audio/sentence_${sentenceIdx}.wav`, - durationInFrames - }); - } catch (error) { - console.error(`Erro ao extrair áudio da sentença ${sentenceIdx}:`, error.message); - sentenceAudioFiles.push(null); - } - } - - return sentenceAudioFiles; + const audioDir = join(sessionDir, "audio"); + if (!existsSync(audioDir)) { + mkdirSync(audioDir, { recursive: true }); + } + + console.log(`\nDividindo áudio em ${sentences.length} sentenças...`); + + const sentenceAudioFiles = []; + + for (let sentenceIdx = 0; sentenceIdx < sentences.length; sentenceIdx++) { + const subtitleIndices = sentences[sentenceIdx]; + const firstSubIndex = subtitleIndices[0]; + const lastSubIndex = subtitleIndices[subtitleIndices.length - 1]; + + const startTime = subtitles[firstSubIndex].startTime; + const endTime = subtitles[lastSubIndex].endTime; + const duration = endTime - startTime; + + const outputPath = join(audioDir, `sentence_${sentenceIdx}.wav`); + + try { + // Extract audio chunk using ffmpeg + execSync( + `ffmpeg -y -i "${audioPath}" -ss ${startTime} -t ${duration} -acodec pcm_s16le -ar 44100 -ac 1 "${outputPath}"`, + { stdio: "pipe" }, + ); + + const durationInFrames = Math.round(duration * fps); + sentenceAudioFiles.push({ + sentenceId: sentenceIdx, + path: `/sessions/${basename(sessionDir)}/audio/sentence_${sentenceIdx}.wav`, + durationInFrames, + }); + } catch (error) { + console.error(`Erro ao extrair áudio da sentença ${sentenceIdx}:`, error.message); + sentenceAudioFiles.push(null); + } + } + + return sentenceAudioFiles; } // Format subtitles as JSON for video-subtitles.json with voice chunks -function formatAsJSON(subtitles, fps = FPS, language = "pt", backgroundVideo = null, titleText = null, template = "bold", sentenceAudioFiles = null) { - // Group subtitles into sentences - const sentences = groupIntoSentences(subtitles); - - const items = subtitles.map((sub, index) => { - const text = sub.words.join(" "); - const startFrame = secondsToFrame(sub.startTime, fps); - const endFrame = secondsToFrame(sub.endTime, fps); - const zoom = detectImpact(text, language); - - const item = { text, startFrame, endFrame }; - if (index === 0) { - item.zoom = { type: "zoomHold", intensity: 2 }; - } else if (zoom) { - item.zoom = zoom; - } - - // Find which sentence this subtitle belongs to - if (sentenceAudioFiles) { - for (let sentenceIdx = 0; sentenceIdx < sentences.length; sentenceIdx++) { - if (sentences[sentenceIdx].includes(index)) { - item.sentenceId = sentenceIdx; - - // Add voice reference to first chunk of sentence - const isFirstChunk = sentences[sentenceIdx][0] === index; - if (isFirstChunk && sentenceAudioFiles[sentenceIdx]) { - item.voice = { - src: sentenceAudioFiles[sentenceIdx].path, - volume: 1.0, - durationInFrames: sentenceAudioFiles[sentenceIdx].durationInFrames - }; - } - break; - } - } - } - - return item; - }); - - // Calculate last frame for title end - const lastFrame = items.length > 0 ? items[items.length - 1].endFrame : 90; - - const config = { - background: backgroundVideo - ? { type: "video", src: `/${backgroundVideo}` } - : { type: "color", color: "#1a1815" }, - colors: { text: "#ffffff" }, - title: { - show: !!titleText, - text: titleText || "", - startFrame: 0, - endFrame: 90, - transition: "slideDown", - template - }, - subtitles: { - transition: "slideUp", - template, - items - }, - style: { - position: "bottom", - bottomOffset: 80 - } - }; - - return config; +function formatAsJSON( + subtitles, + fps = FPS, + language = "pt", + backgroundVideo = null, + titleText = null, + template = "bold", + sentenceAudioFiles = null, +) { + // Group subtitles into sentences + const sentences = groupIntoSentences(subtitles); + + const items = subtitles.map((sub, index) => { + const text = sub.words.join(" "); + const startFrame = secondsToFrame(sub.startTime, fps); + const endFrame = secondsToFrame(sub.endTime, fps); + const zoom = detectImpact(text, language); + + const item = { text, startFrame, endFrame }; + if (index === 0) { + item.zoom = { type: "zoomHold", intensity: 2 }; + } else if (zoom) { + item.zoom = zoom; + } + + // Find which sentence this subtitle belongs to + if (sentenceAudioFiles) { + for (let sentenceIdx = 0; sentenceIdx < sentences.length; sentenceIdx++) { + if (sentences[sentenceIdx].includes(index)) { + item.sentenceId = sentenceIdx; + + // Add voice reference to first chunk of sentence + const isFirstChunk = sentences[sentenceIdx][0] === index; + if (isFirstChunk && sentenceAudioFiles[sentenceIdx]) { + item.voice = { + src: sentenceAudioFiles[sentenceIdx].path, + volume: 1.0, + durationInFrames: sentenceAudioFiles[sentenceIdx].durationInFrames, + }; + } + break; + } + } + } + + return item; + }); + + // Calculate last frame for title end + const _lastFrame = items.length > 0 ? items[items.length - 1].endFrame : 90; + + const config = { + background: backgroundVideo + ? { type: "video", src: `/${backgroundVideo}` } + : { type: "color", color: "#1a1815" }, + colors: { text: "#ffffff" }, + title: { + show: !!titleText, + text: titleText || "", + startFrame: 0, + endFrame: 90, + transition: "slideDown", + template, + }, + subtitles: { + transition: "slideUp", + template, + items, + }, + style: { + position: "bottom", + bottomOffset: 80, + }, + }; + + return config; } // Main function async function main() { - const args = process.argv.slice(2); + const args = process.argv.slice(2); - if (args.length === 0) { - console.log(` + if (args.length === 0) { + console.log(` Uso: node scripts/extract-subtitles.mjs [opções] Opções: @@ -461,143 +537,157 @@ Exemplos: node scripts/extract-subtitles.mjs public/video.mp4 --json --background uploads/bg.mp4 node scripts/extract-subtitles.mjs public/video.mp4 --model tiny --fps 60 `); - process.exit(1); - } - - const videoPath = args[0]; - - // Parse options - let outputPath = null; - let modelSize = "small"; - let maxWords = 8; - let fps = FPS; - let language = "pt"; - let jsonFormat = false; - let backgroundVideo = null; - let titleText = null; - let template = "bold"; - let splitAudio = false; - let sessionDir = null; - - for (let i = 1; i < args.length; i++) { - if (args[i] === "--output" || args[i] === "-o") { - outputPath = args[++i]; - } else if (args[i] === "--model" || args[i] === "-m") { - modelSize = args[++i]; - } else if (args[i] === "--max-words") { - maxWords = parseInt(args[++i]); - } else if (args[i] === "--fps") { - fps = parseInt(args[++i]); - } else if (args[i] === "--lang" || args[i] === "-l") { - language = args[++i]; - } else if (args[i] === "--json") { - jsonFormat = true; - } else if (args[i] === "--background") { - backgroundVideo = args[++i]; - } else if (args[i] === "--title" || args[i] === "-t") { - titleText = args[++i]; - } else if (args[i] === "--template") { - template = args[++i]; - } else if (args[i] === "--split-audio") { - splitAudio = true; - } else if (args[i] === "--session-dir") { - sessionDir = args[++i]; - } - } - - // Auto-detect session dir from output path if not provided - if (splitAudio && !sessionDir && outputPath) { - // Try to extract from path like /path/to/datalake/session-xxx/video-subtitles.json - const match = outputPath.match(/(.*\/session-[^/]+)/); - if (match) { - sessionDir = match[1]; - } - } - - const whisperLang = language === "pt" ? "portuguese" : "english"; - - if (!existsSync(videoPath)) { - console.error(`Erro: Arquivo de vídeo não encontrado: ${videoPath}`); - process.exit(1); - } - - // Create temp directory for audio - const tempDir = join(__dirname, "..", ".temp"); - if (!existsSync(tempDir)) { - mkdirSync(tempDir, { recursive: true }); - } - - const videoBasename = basename(videoPath).replace(/\.[^.]+$/, ""); - const audioPath = join(tempDir, `${videoBasename}.wav`); - - try { - // Step 1: Extract audio - const audioExtracted = await extractAudio(videoPath, audioPath); - if (!audioExtracted) { - process.exit(1); - } - - // Step 2: Transcribe - const transcription = await transcribeAudio(audioPath, whisperLang, modelSize); - - console.log("\nTranscrição:", transcription.text); - console.log(`\nEncontrados ${transcription.chunks?.length || 0} timestamps de palavras`); - - // Step 3: Group into subtitles - const rawSubtitles = groupIntoSubtitles(transcription.chunks || [], maxWords); - - // Step 3.5: Fix orphan words at the end of subtitles - const subtitles = fixOrphanWords(rawSubtitles, language); - - console.log(`\nAgrupados em ${subtitles.length} legendas`); - - // Step 4: Split audio into sentences if requested - let sentenceAudioFiles = null; - if (splitAudio && jsonFormat) { - if (!sessionDir) { - console.error('\nErro: --session-dir é necessário quando --split-audio é usado'); - process.exit(1); - } - - const sentences = groupIntoSentences(subtitles); - sentenceAudioFiles = await splitAudioIntoSentences(audioPath, subtitles, sentences, sessionDir, fps); - console.log(`\nCriados ${sentenceAudioFiles.filter(f => f).length} arquivos de áudio`); - } - - // Step 5: Format output - let output; - if (jsonFormat) { - const jsonConfig = formatAsJSON(subtitles, fps, language, backgroundVideo, titleText, template, sentenceAudioFiles); - output = JSON.stringify(jsonConfig, null, 2); - } else { - output = formatAsTypeScript(subtitles, fps, language); - } - - // Step 6: Output - if (outputPath) { - writeFileSync(outputPath, output); - console.log(`\nLegendas escritas em: ${outputPath}`); - } else { - if (jsonFormat) { - console.log("\n--- JSON Config ---\n"); - console.log(output); - console.log("\n--- Copie para video-subtitles.json ---\n"); - } else { - console.log("\n--- TypeScript Gerado ---\n"); - console.log(output); - console.log("\n--- Copie o código acima para seu constants.ts ---\n"); - } - } - - // Cleanup - if (existsSync(audioPath)) { - unlinkSync(audioPath); - } - } catch (error) { - console.error("Erro:", error.message); - console.error(error.stack); - process.exit(1); - } + process.exit(1); + } + + const videoPath = args[0]; + + // Parse options + let outputPath = null; + let modelSize = "small"; + let maxWords = 8; + let fps = FPS; + let language = "pt"; + let jsonFormat = false; + let backgroundVideo = null; + let titleText = null; + let template = "bold"; + let splitAudio = false; + let sessionDir = null; + + for (let i = 1; i < args.length; i++) { + if (args[i] === "--output" || args[i] === "-o") { + outputPath = args[++i]; + } else if (args[i] === "--model" || args[i] === "-m") { + modelSize = args[++i]; + } else if (args[i] === "--max-words") { + maxWords = parseInt(args[++i]); + } else if (args[i] === "--fps") { + fps = parseInt(args[++i]); + } else if (args[i] === "--lang" || args[i] === "-l") { + language = args[++i]; + } else if (args[i] === "--json") { + jsonFormat = true; + } else if (args[i] === "--background") { + backgroundVideo = args[++i]; + } else if (args[i] === "--title" || args[i] === "-t") { + titleText = args[++i]; + } else if (args[i] === "--template") { + template = args[++i]; + } else if (args[i] === "--split-audio") { + splitAudio = true; + } else if (args[i] === "--session-dir") { + sessionDir = args[++i]; + } + } + + // Auto-detect session dir from output path if not provided + if (splitAudio && !sessionDir && outputPath) { + // Try to extract from path like /path/to/datalake/session-xxx/video-subtitles.json + const match = outputPath.match(/(.*\/session-[^/]+)/); + if (match) { + sessionDir = match[1]; + } + } + + const whisperLang = language === "pt" ? "portuguese" : "english"; + + if (!existsSync(videoPath)) { + console.error(`Erro: Arquivo de vídeo não encontrado: ${videoPath}`); + process.exit(1); + } + + // Create temp directory for audio + const tempDir = join(__dirname, "..", ".temp"); + if (!existsSync(tempDir)) { + mkdirSync(tempDir, { recursive: true }); + } + + const videoBasename = basename(videoPath).replace(/\.[^.]+$/, ""); + const audioPath = join(tempDir, `${videoBasename}.wav`); + + try { + // Step 1: Extract audio + const audioExtracted = await extractAudio(videoPath, audioPath); + if (!audioExtracted) { + process.exit(1); + } + + // Step 2: Transcribe + const transcription = await transcribeAudio(audioPath, whisperLang, modelSize); + + console.log("\nTranscrição:", transcription.text); + console.log(`\nEncontrados ${transcription.chunks?.length || 0} timestamps de palavras`); + + // Step 3: Group into subtitles + const rawSubtitles = groupIntoSubtitles(transcription.chunks || [], maxWords); + + // Step 3.5: Fix orphan words at the end of subtitles + const subtitles = fixOrphanWords(rawSubtitles, language); + + console.log(`\nAgrupados em ${subtitles.length} legendas`); + + // Step 4: Split audio into sentences if requested + let sentenceAudioFiles = null; + if (splitAudio && jsonFormat) { + if (!sessionDir) { + console.error("\nErro: --session-dir é necessário quando --split-audio é usado"); + process.exit(1); + } + + const sentences = groupIntoSentences(subtitles); + sentenceAudioFiles = await splitAudioIntoSentences( + audioPath, + subtitles, + sentences, + sessionDir, + fps, + ); + console.log(`\nCriados ${sentenceAudioFiles.filter((f) => f).length} arquivos de áudio`); + } + + // Step 5: Format output + let output; + if (jsonFormat) { + const jsonConfig = formatAsJSON( + subtitles, + fps, + language, + backgroundVideo, + titleText, + template, + sentenceAudioFiles, + ); + output = JSON.stringify(jsonConfig, null, 2); + } else { + output = formatAsTypeScript(subtitles, fps, language); + } + + // Step 6: Output + if (outputPath) { + writeFileSync(outputPath, output); + console.log(`\nLegendas escritas em: ${outputPath}`); + } else { + if (jsonFormat) { + console.log("\n--- JSON Config ---\n"); + console.log(output); + console.log("\n--- Copie para video-subtitles.json ---\n"); + } else { + console.log("\n--- TypeScript Gerado ---\n"); + console.log(output); + console.log("\n--- Copie o código acima para seu constants.ts ---\n"); + } + } + + // Cleanup + if (existsSync(audioPath)) { + unlinkSync(audioPath); + } + } catch (error) { + console.error("Erro:", error.message); + console.error(error.stack); + process.exit(1); + } } main(); diff --git a/scripts/fetch-caption-model.mjs b/scripts/fetch-caption-model.mjs new file mode 100644 index 000000000..f1d0a1f50 --- /dev/null +++ b/scripts/fetch-caption-model.mjs @@ -0,0 +1,154 @@ +// Populates `caption-assets/` so the packaged app can transcribe offline (under file://) +// instead of fetching the Whisper model from HuggingFace and the onnxruntime wasm from a CDN. +// +// caption-assets/ +// models/Xenova/whisper-tiny/... ← downloaded from HuggingFace (config + quantized ONNX) +// ort/ort-wasm*.wasm ← copied from @xenova/transformers/dist +// +// Idempotent: existing non-empty files are left alone, so re-runs and CI cache hits are no-ops. +// `caption-assets/` is gitignored and shipped via electron-builder `extraResources`. + +import { createWriteStream } from "node:fs"; +import { copyFile, mkdir, stat } from "node:fs/promises"; +import path from "node:path"; +import { Readable } from "node:stream"; +import { pipeline } from "node:stream/promises"; +import { fileURLToPath } from "node:url"; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const OUT = path.join(ROOT, "caption-assets"); +const MODEL_ID = "Xenova/whisper-tiny"; +const HF_BASE = `https://huggingface.co/${MODEL_ID}/resolve/main`; + +// Small config/tokenizer/preprocessor files plus the quantized ONNX the ASR pipeline loads by +// default (encoder + merged decoder). Grab every metadata file so transformers never requests +// one we forgot to bundle. +const MODEL_FILES = [ + "config.json", + "generation_config.json", + "preprocessor_config.json", + "tokenizer.json", + "tokenizer_config.json", + "added_tokens.json", + "special_tokens_map.json", + "normalizer.json", + "merges.txt", + "vocab.json", + "quantize_config.json", + "onnx/encoder_model_quantized.onnx", + "onnx/decoder_model_merged_quantized.onnx", +]; + +async function exists(filePath) { + try { + const s = await stat(filePath); + return s.isFile() && s.size > 0; + } catch { + return false; + } +} + +const MAX_ATTEMPTS = 6; +// HuggingFace rate-limits (429) when the parallel CI matrix builds all hit it at once; also retry the +// usual transient server errors. +const RETRYABLE_STATUS = new Set([408, 425, 429, 500, 502, 503, 504]); + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function backoffMs(attempt, retryAfter) { + // Honor Retry-After when the server sends it (seconds or an HTTP date). + if (retryAfter) { + const secs = Number(retryAfter); + if (Number.isFinite(secs)) return Math.min(60_000, secs * 1000); + const at = Date.parse(retryAfter); + if (!Number.isNaN(at)) return Math.min(60_000, Math.max(0, at - Date.now())); + } + // Exponential backoff with jitter: ~2s, 4s, 8s, 16s, 32s, capped at 60s. + return Math.min(60_000, 2000 * 2 ** (attempt - 1)) + Math.floor(Math.random() * 1000); +} + +async function fetchWithRetry(url) { + let lastErr; + for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { + try { + const res = await fetch(url, { headers: { "user-agent": "openscreen-build" } }); + if (res.ok && res.body) return res; + if (RETRYABLE_STATUS.has(res.status) && attempt < MAX_ATTEMPTS) { + const wait = backoffMs(attempt, res.headers.get("retry-after")); + console.log( + ` … HTTP ${res.status}, retry ${attempt}/${MAX_ATTEMPTS - 1} in ${Math.round(wait / 1000)}s`, + ); + await sleep(wait); + continue; + } + throw new Error(`Failed to download ${url}: HTTP ${res.status} ${res.statusText}`); + } catch (err) { + lastErr = err; + const isHttp = err instanceof Error && err.message.startsWith("Failed to download"); + if (isHttp || attempt >= MAX_ATTEMPTS) throw err; + // Network/DNS error: back off and retry. + const wait = backoffMs(attempt, null); + console.log( + ` … ${err.message}, retry ${attempt}/${MAX_ATTEMPTS - 1} in ${Math.round(wait / 1000)}s`, + ); + await sleep(wait); + } + } + throw lastErr; +} + +async function download(url, dest) { + if (await exists(dest)) { + console.log(` ✓ cached ${path.relative(OUT, dest)}`); + return; + } + await mkdir(path.dirname(dest), { recursive: true }); + const res = await fetchWithRetry(url); + const tmp = `${dest}.partial`; + await pipeline(Readable.fromWeb(res.body), createWriteStream(tmp)); + const { rename } = await import("node:fs/promises"); + await rename(tmp, dest); + const mb = ((await stat(dest)).size / 1_000_000).toFixed(1); + console.log(` ↓ ${path.relative(OUT, dest)} (${mb} MB)`); +} + +async function copyOrtWasm() { + const distDir = path.join(ROOT, "node_modules", "@xenova", "transformers", "dist"); + // Non-threaded variants only: the worker runs ORT with numThreads=1 (no SharedArrayBuffer + // under file://), so the threaded wasm is never loaded. Saves ~20MB. + const wasm = ["ort-wasm.wasm", "ort-wasm-simd.wasm"]; + const ortOut = path.join(OUT, "ort"); + await mkdir(ortOut, { recursive: true }); + for (const name of wasm) { + const src = path.join(distDir, name); + const dest = path.join(ortOut, name); + if (!(await exists(src))) { + throw new Error(`Missing ${src} — is @xenova/transformers installed? Run npm ci first.`); + } + if (await exists(dest)) { + console.log(` ✓ cached ort/${name}`); + continue; + } + await copyFile(src, dest); + console.log(` + copied ort/${name}`); + } +} + +async function main() { + console.log(`Fetching caption assets → ${path.relative(ROOT, OUT)}/`); + console.log("ONNX Runtime wasm:"); + await copyOrtWasm(); + console.log(`Whisper model (${MODEL_ID}):`); + const modelDir = path.join(OUT, "models", ...MODEL_ID.split("/")); + for (const rel of MODEL_FILES) { + await download(`${HF_BASE}/${rel}`, path.join(modelDir, rel)); + } + console.log("Caption assets ready."); +} + +main().catch((err) => { + console.error(`\nfetch-caption-model failed: ${err.message}`); + process.exit(1); +}); diff --git a/src/App.tsx b/src/App.tsx index 6c36aa8c5..0c8875d04 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -112,7 +112,7 @@ export default function App() { return ( {content} - + ); } diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index bba5f494c..a93514c5a 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -21,13 +21,13 @@ 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"; import { useMicrophoneDevices } from "../../hooks/useMicrophoneDevices"; import { useScreenRecorder } from "../../hooks/useScreenRecorder"; import { requestCameraAccess } from "../../lib/requestCameraAccess"; -import { loadUserPreferences, saveUserPreferences } from "../../lib/userPreferences"; import { formatTimePadded } from "../../utils/timeUtils"; import { AudioLevelMeter } from "../ui/audio-level-meter"; import { Button } from "../ui/button"; @@ -37,6 +37,11 @@ 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 }, @@ -140,6 +145,10 @@ export function LaunchWindow() { const [supportsCursorModeToggle, setSupportsCursorModeToggle] = useState(false); const languageTriggerRef = useRef(null); const languageMenuPanelRef = useRef(null); + const hudBarRef = useRef(null); + const deviceSelectorRef = useRef(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; @@ -291,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(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( + (el: T | null, ref: React.MutableRefObject) => { + 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(undefined); const setHudMouseEventsEnabled = useCallback((enabled: boolean) => { if (hudMouseEventsEnabledRef.current === enabled) { @@ -391,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).
{ @@ -446,11 +553,19 @@ export function LaunchWindow() {
)} - {/* Device selectors — fixed above HUD bar, viewport-relative, never clipped */} + {/* Device selectors, fixed above HUD bar, viewport-relative, never clipped */} {(showMicControls || showWebcamControls) && (
{/* Mic selector */} {showMicControls && ( @@ -581,8 +696,9 @@ export function LaunchWindow() {
)} - {/* HUD bar — fixed at bottom center, viewport-relative, never moves */} + {/* HUD bar, fixed at bottom center, viewport-relative, never moves */}
{ const isSelected = selectedSource?.id === source.id; + const sourceKind = source.id.startsWith("screen:") ? "screen" : "window"; return (
handleSourceSelect(source)} > diff --git a/src/components/ui/color-picker.tsx b/src/components/ui/color-picker.tsx index d8ec2b33c..72f0135e2 100644 --- a/src/components/ui/color-picker.tsx +++ b/src/components/ui/color-picker.tsx @@ -46,8 +46,7 @@ export default function ColorPicker(props: ColorPickerProps) { return "#ffffff"; }; - // Normalize the hex input. - // Adds a # at the beginning of the input if it's not there. + // Prefix a # when the user typed a bare hex value. const normalizeHexDraft = (raw: string) => { const trimmed = raw.trim(); if (trimmed === "") return ""; @@ -58,8 +57,7 @@ export default function ColorPicker(props: ColorPickerProps) { const handleColorInputChange = (e: React.ChangeEvent) => { const normalized = normalizeHexDraft(e.target.value); setHexInput(normalized); - // Check if the normalized hex is a valid hex color. - // It should follow the format #RRGGBB or #RGB. + // Only push when it's a complete #RGB or #RRGGBB value. const isValidHexColor = /^#[0-9A-Fa-f]{3}$/.test(normalized) || /^#[0-9A-Fa-f]{6}$/.test(normalized); if (isValidHexColor) { diff --git a/src/components/ui/select.tsx b/src/components/ui/select.tsx index d151d164e..bdbf64e9a 100644 --- a/src/components/ui/select.tsx +++ b/src/components/ui/select.tsx @@ -82,7 +82,8 @@ const SelectContent = React.forwardRef< ; -const Toaster = ({ ...props }: ToasterProps) => { +const Toaster = ({ className, ...props }: ToasterProps) => { return ( diff --git a/src/components/video-editor/AddCustomFontDialog.tsx b/src/components/video-editor/AddCustomFontDialog.tsx index 9ab9ce3da..872559d28 100644 --- a/src/components/video-editor/AddCustomFontDialog.tsx +++ b/src/components/video-editor/AddCustomFontDialog.tsx @@ -36,7 +36,6 @@ export function AddCustomFontDialog({ onFontAdded }: AddCustomFontDialogProps) { const handleImportUrlChange = (url: string) => { setImportUrl(url); - // Auto-extract font name if valid Google Fonts URL if (isValidGoogleFontsUrl(url)) { const extracted = parseFontFamilyFromImport(url); if (extracted && !fontName) { @@ -46,7 +45,6 @@ export function AddCustomFontDialog({ onFontAdded }: AddCustomFontDialogProps) { }; const handleAdd = async () => { - // Validate inputs if (!importUrl.trim()) { toast.error(t("customFont.errorEmptyUrl")); return; @@ -65,7 +63,6 @@ export function AddCustomFontDialog({ onFontAdded }: AddCustomFontDialogProps) { setLoading(true); try { - // Extract font family from URL const fontFamily = parseFontFamilyFromImport(importUrl); if (!fontFamily) { toast.error(t("customFont.errorExtractFailed")); @@ -73,7 +70,6 @@ export function AddCustomFontDialog({ onFontAdded }: AddCustomFontDialogProps) { return; } - // Create custom font object const newFont: CustomFont = { id: generateFontId(fontName), name: fontName.trim(), @@ -81,17 +77,15 @@ export function AddCustomFontDialog({ onFontAdded }: AddCustomFontDialogProps) { importUrl: importUrl.trim(), }; - // Add font (this will load and verify it) - throws if it fails + // Loads and verifies the font; throws on failure await addCustomFont(newFont); - // Notify parent if (onFontAdded) { onFontAdded(newFont); } toast.success(t("customFont.successMessage", { fontName })); - // Reset and close setImportUrl(""); setFontName(""); setOpen(false); diff --git a/src/components/video-editor/AnnotationOverlay.tsx b/src/components/video-editor/AnnotationOverlay.tsx index ab6df00ba..85f7b8d06 100644 --- a/src/components/video-editor/AnnotationOverlay.tsx +++ b/src/components/video-editor/AnnotationOverlay.tsx @@ -48,7 +48,7 @@ interface AnnotationOverlayProps { onBlurDataCommit?: () => void; onClick: (id: string) => void; zIndex: number; - isSelectedBoost: boolean; // Boost z-index when selected for easy editing + isSelectedBoost: boolean; // raise z-index when selected, for easier editing previewSourceCanvas?: PreviewCanvasSource | null; previewFrameVersion?: number; currentTimeMs: number; @@ -461,7 +461,6 @@ export function AnnotationOverlay({ }} /> ); - }; const renderContent = () => { @@ -558,7 +557,10 @@ export function AnnotationOverlay({ className="w-full h-full overflow-hidden" style={{ borderRadius: radius > 0 ? `${radius}px` : undefined, opacity: animOpacity }} > -
+
Annotation { isDraggingRef.current = false; }, 100); @@ -800,7 +802,7 @@ export function AnnotationOverlay({ "ring-2 ring-[#34B27B] ring-offset-2 ring-offset-transparent", )} style={{ - zIndex: isSelectedBoost ? zIndex + 1000 : zIndex, // Boost selected annotation to ensure it's on top + zIndex: isSelectedBoost ? zIndex + 1000 : zIndex, // keep the selected annotation on top pointerEvents: isSelected ? "auto" : "none", border: isSelected && annotation.type !== "blur" ? "2px solid rgba(52, 178, 123, 0.8)" : "none", diff --git a/src/components/video-editor/AnnotationSettingsPanel.tsx b/src/components/video-editor/AnnotationSettingsPanel.tsx index 4fe3f505e..f98c4cc11 100644 --- a/src/components/video-editor/AnnotationSettingsPanel.tsx +++ b/src/components/video-editor/AnnotationSettingsPanel.tsx @@ -108,7 +108,6 @@ export function AnnotationSettingsPanel({ const getFontLabel = (font: (typeof FONT_FAMILIES)[number]) => font.labelKey ? fontStyleLabels[font.labelKey] : font.name; - // Load custom fonts on mount useEffect(() => { setCustomFonts(getCustomFonts()); }, []); @@ -138,7 +137,6 @@ export function AnnotationSettingsPanel({ const file = files[0]; - // Validate file type const validTypes = ["image/jpeg", "image/jpg", "image/png", "image/gif", "image/webp"]; if (!validTypes.includes(file.type)) { toast.error(t("annotation.invalidImageType"), { diff --git a/src/components/video-editor/ArrowSvgs.tsx b/src/components/video-editor/ArrowSvgs.tsx index 99c542ff7..5078b844e 100644 --- a/src/components/video-editor/ArrowSvgs.tsx +++ b/src/components/video-editor/ArrowSvgs.tsx @@ -7,9 +7,7 @@ interface ArrowSvgProps { } /** - * Inline SVG arrow components for 8 directions. - * These match the visual style of the previous icon-based arrows but use - * pure SVG paths for easy replication in export. + * Inline SVG arrows for 8 directions. Pure paths (not icon fonts) so export can replicate them. */ export function ArrowUp({ color, strokeWidth, className }: ArrowSvgProps) { diff --git a/src/components/video-editor/EditorEmptyState.tsx b/src/components/video-editor/EditorEmptyState.tsx index 511323abe..2a5c49547 100644 --- a/src/components/video-editor/EditorEmptyState.tsx +++ b/src/components/video-editor/EditorEmptyState.tsx @@ -2,11 +2,12 @@ import { AlertCircle, Film, FolderOpen, Upload, X } from "lucide-react"; import { useCallback, useRef, useState } from "react"; import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { useScopedT } from "@/contexts/I18nContext"; +import { getProjectFolder, parentDirectoryOf, saveUserPreferences } from "@/lib/userPreferences"; import { nativeBridgeClient } from "@/native"; interface EditorEmptyStateProps { onVideoImported: (videoPath: string) => void; - /** Called with the loaded project data — handles both button click and drag-drop */ + /** Called with the loaded project data; handles both button click and drag-drop */ onProjectOpened: (project: unknown, path: string | null) => void; } @@ -17,8 +18,8 @@ export function EditorEmptyState({ onVideoImported, onProjectOpened }: EditorEmp const tc = useScopedT("common"); const [isDraggingOver, setIsDraggingOver] = useState(false); const [dropError, setDropError] = useState(null); - // Freeze the last non-null error type so dialog content doesn't snap to the - // else-branch during the closing animation (same pattern as UnsavedChangesDialog). + // Freeze the last non-null error type so dialog content doesn't snap to the else-branch + // during the closing animation (same pattern as UnsavedChangesDialog). const lastDropErrorRef = useRef>("unsupported-format"); if (dropError !== null) { lastDropErrorRef.current = dropError; @@ -35,8 +36,14 @@ export function EditorEmptyState({ onVideoImported, onProjectOpened }: EditorEmp }, [onVideoImported]); const handleLoadProject = useCallback(async () => { - const result = await nativeBridgeClient.project.loadProjectFile(); + const result = await nativeBridgeClient.project.loadProjectFile(getProjectFolder()); if (result.canceled || !result.success || !result.project) return; + if (result.path) { + const folder = parentDirectoryOf(result.path); + if (folder) { + saveUserPreferences({ projectFolder: folder }); + } + } onProjectOpened(result.project, result.path ?? null); }, [onProjectOpened]); @@ -67,7 +74,7 @@ export function EditorEmptyState({ onVideoImported, onProjectOpened }: EditorEmp return; } - // Use Electron's webUtils.getPathForFile — File.path was removed in Electron 32+ + // Use Electron's webUtils.getPathForFile; File.path was removed in Electron 32+ let filePath: string; try { filePath = window.electronAPI.getPathForFile(projectFile); diff --git a/src/components/video-editor/EnterpriseAnnotationSettingsPanel.tsx b/src/components/video-editor/EnterpriseAnnotationSettingsPanel.tsx index 6dcb4d968..1e2543699 100644 --- a/src/components/video-editor/EnterpriseAnnotationSettingsPanel.tsx +++ b/src/components/video-editor/EnterpriseAnnotationSettingsPanel.tsx @@ -369,9 +369,7 @@ export function EnterpriseAnnotationSettingsPanel({ {/* Weight & Stretch */}
- +
- +
- + onSubtitleStyleChange?.({ template: v as import("./types").SubtitleTemplate })} + onValueChange={(v) => + onSubtitleStyleChange?.({ + template: v as import("./types").SubtitleTemplate, + }) + } > @@ -1414,7 +1465,9 @@ export function EnterpriseSettingsPanel({
Font size - {subtitleStyle?.fontSize ?? 32}px + + {subtitleStyle?.fontSize ?? 32}px +
Offset - {subtitleStyle?.bottomOffset ?? 8}% + + {subtitleStyle?.bottomOffset ?? 8}% +
+
{ts}