Compare commits

...
Author SHA1 Message Date
github-actions[bot] d46d37f3f9 chore: bump nix package to v1.5.0 2026-06-06 19:31:09 +00:00
Siddharth ffc41a3c8c -- 2026-06-06 12:26:35 -07:00
Siddharth cc8307f4c8 final readme 2026-06-06 12:23:43 -07:00
Siddharth 21c637a384 fix 2 2026-06-06 09:42:52 -07:00
Siddharth 5f71979e76 helper fix 2026-06-06 09:17:24 -07:00
Siddharth c539e9ba26 cleanup 2026-06-05 22:51:40 -07:00
Siddharth 53f9851af9 fix auto focus export sync 2026-06-05 22:24:47 -07:00
Siddharth 3e3a81684f bundle tts 2026-06-05 20:22:02 -07:00
Siddharth c6331badb1 fix(build): use sharp prebuilt instead of compiling from source (fixes macOS CI) 2026-06-04 22:58:48 -07:00
Siddharth c03db08448 version bump 2026-06-04 22:18:39 -07:00
Siddharth 96e178c432 fix toggles 2026-06-04 21:52:54 -07:00
Siddharth 358d4af438 remove oversmooth 2026-06-04 21:18:28 -07:00
Siddharth 02b4e1b45b cleanup follow effect 2026-06-04 21:06:48 -07:00
Siddharth e49cc02d75 improved cursor smoothing 2026-06-04 20:27:35 -07:00
Siddharth a806e34ff0 reactive zoom 2026-06-04 19:32:45 -07:00
Siddharth b088a098a9 custom cursors 2026-06-04 18:44:38 -07:00
Siddharth 98a32372e7 fix export compoisted shadow 2026-06-03 21:33:22 -07:00
Siddharth 0e3bd17480 improve waveform 2026-06-03 21:01:16 -07:00
Siddharth 332b6b7d38 rm blur 2026-06-03 20:51:36 -07:00
Siddharth 62f4f455c8 fix variant class 2026-06-03 20:30:31 -07:00
Siddharth a3716581b0 global auto toggle 2026-06-03 20:20:18 -07:00
Siddharth 1d7b677f9c clip tooltio 2026-06-03 19:47:07 -07:00
Siddharth 43b01cfb60 record permissions prompt 2026-06-03 19:41:25 -07:00
Siddharth 4a18361589 fix ci check 2026-06-02 23:33:15 -07:00
Siddharth c35a899ab4 fix vertical layout 2026-06-02 23:29:08 -07:00
Sid 970406a457 Merge pull request #669 from paulohenriquesg/feat/remember-last-project-folder
feat: Add projectFolder to user preferences
2026-06-02 23:09:58 -07:00
Sid a19771e90d Merge pull request #681 from jodelcioluz/fix-cursor-telemetry
fix: resolve cursor-sampler telemetry not working
2026-06-02 22:02:57 -07:00
Jodélcio Luz b8eeef55ab fix: resolve cursor-sampler telemetry not working 2026-06-02 21:20:06 -03:00
Sid 4ce7af4f25 Merge pull request #589 from parse-nip/feature/video-transcriptions
Feature/video transcriptions
2026-06-01 20:51:06 -07:00
Siddharth e5462f1ac8 captions: run Whisper in a Web Worker, move auto-captions button to timeline
Move model load and transcription off the renderer main thread into a
dedicated worker (transcribe.worker.ts + transcribeCore.ts) so the editor
UI no longer freezes during captioning. Relocate the auto-captions action
from the editor header into the timeline toolbar, and add the missing
it/pt-BR/ru/vi autoCaptions translations.
2026-06-01 20:42:41 -07:00
Sid 94a00c0abf Merge pull request #673 from kaili-yang/docs/mac-native-cursor
docs: add macOS native cursor capture test pipeline
2026-06-01 19:13:21 -07:00
Sid 6873b808d5 Merge pull request #631 from AjTheSpidey/codex/source-selector-test-hooks
test: expose source selector hooks
2026-06-01 19:01:29 -07:00
Sid 9a83062302 Merge pull request #633 from AjTheSpidey/codex/webcam-mirror-toggle
Add webcam mirror toggle
2026-06-01 18:59:38 -07:00
Paulo Henrique Garcia f5ad566534 fix: pass getProjectFolder to loadProjectFile in EditorEmptyState
The Studio Dashboard's Load Project button was calling loadProjectFile()
without the remembered folder, always defaulting to RECORDINGS_DIR.
Also save projectFolder preference after a successful load from this path.
2026-06-01 08:21:01 +02:00
Paulo Henrique Garcia c01c88b1eb Merge upstream/main into feat/remember-last-project-folder
Resolved conflicts:
- LaunchWindow.tsx: kept upstream's removal of openVideoFile/openProjectFile (moved to Studio Dashboard)
- VideoEditor.tsx: used upstream's doLoadProject rename while preserving getProjectFolder() arg
2026-06-01 08:00:18 +02:00
Kelly Yang a280179988 docs: fix three inaccuracies flagged by bot reviews
- Unify sidecar key name to camelCase cursorRecordingData (was mixing
  cursor-recording-data and cursorRecordingData in the same checklist)
- Graceful-degradation test now removes both binary copies so the
  missing-helper fallback is actually exercised (build/ and bin/darwin-*)
- Healthy recording section points to <videoPath>.cursor.json sidecar
  instead of the .openscreen project file, which does not embed cursor data
2026-05-31 20:23:38 -07:00
AjTheSpidey a18dc39bab Merge upstream main into source selector test hooks 2026-06-01 09:42:36 +08:00
AjTheSpidey b56daaee3e Merge upstream main into webcam mirror toggle 2026-06-01 09:39:02 +08:00
Kelly Yang 53be8df129 docs: add macOS native cursor capture test pipeline
Documents the cursor helper binary, permissions, manual test checklist,
expected sidecar shape, and known limitations for the darwin native
cursor path.
2026-05-31 17:14:41 -07:00
Siddharth 0ab085961f Merge remote-tracking branch 'origin/main' into feature/video-transcriptions
# Conflicts:
#	src/i18n/locales/ar/editor.json
#	src/i18n/locales/en/editor.json
#	src/i18n/locales/es/editor.json
#	src/i18n/locales/fr/editor.json
#	src/i18n/locales/ja-JP/editor.json
#	src/i18n/locales/ko-KR/editor.json
#	src/i18n/locales/tr/editor.json
#	src/i18n/locales/zh-CN/editor.json
#	src/i18n/locales/zh-TW/editor.json
2026-05-31 11:09:25 -07:00
Sid d2dd44ad3b Merge pull request #577 from makaradam/feature/open-studio-and-import
Studio Dasboard with Separated UI for future scalability and Fixes
2026-05-31 09:18:26 -07:00
makaradam 0f554e72ae test: import jest-dom matchers in SourceSelector test
SourceSelector.test.tsx uses toBeInTheDocument but never imported
@testing-library/jest-dom, and vitest.config.ts has no setupFiles to
register the matchers globally. It only passed when test ordering
happened to leak the matcher from another file, making CI intermittently
red on main (e.g. the #652 merge). Importing jest-dom directly makes the
test self-sufficient and deterministic.
2026-05-31 09:48:21 +02:00
makaradam 00191c479e feat(editor): Studio Dashboard empty state + project lifecycle
- EditorEmptyState shown when no video is loaded: Import Video, Load
  Project, and drag-drop of .openscreen files.
- "Open Studio" button in the recording HUD (replaces the two separate
  open-video / open-project buttons).
- New Project flow that clears back to the dashboard, prompting to save
  on unsaved changes; Load Project now prompts too (variant-aware dialog).
- Rebuilt on current main, preserving main's export refactor, cursor-clip
  / zoom-preview / trim-waveform work and the vertical/horizontal HUD tray.
2026-05-31 09:40:21 +02:00
makaradam 34ef71b363 feat(electron): project import/new-project IPC, menu wiring, HUD/editor anti-flash
- preload + native bridge: getPathForFile, loadProjectFileFromPath, and
  onMenuNewProject / onMenuImportVideo listeners.
- main process: New Project File-menu item (Cmd/Ctrl+N) + menu-new-project.
- handlers: broaden import video formats (.m4v, .wmv, .flv, .ts).
- windows: keep main's HUD geometry + draggable handler; re-apply the
  show-on-ready-to-show anti-flash for the HUD and editor windows.
2026-05-31 09:40:21 +02:00
makaradam 1f43ef8004 i18n: add Studio Dashboard, New Project, and import strings across locales 2026-05-31 09:40:20 +02:00
Paulo Henrique Garcia 68b26c220b Merge upstream/main into feat/remember-last-project-folder
Resolves conflicts in userPreferences.ts and userPreferences.test.ts by
combining the projectFolder feature with upstream's trayLayout addition.
2026-05-31 08:11:49 +02:00
Sid c491b7ca68 Merge pull request #652 from kimsr96/main
Fix screen source selection recovery flow
2026-05-30 14:29:18 -07:00
Sid 2cacedf0a0 Merge pull request #664 from enstie/font-animation
Add text animation presets for annotations
2026-05-30 14:26:42 -07:00
Sid 180a1eafd5 Merge pull request #515 from AbhinRustagi/feature/timeline-handle-snap
Add Timeline Snap Guides
2026-05-30 14:06:41 -07:00
Sid 19be540053 Merge pull request #648 from Arunsiva003/feature/vertical-menu-tray-layout
Add vertical tray layout toggle
2026-05-30 14:04:23 -07:00
Sid 324f4e01d7 Merge pull request #671 from kaili-yang/mac-native-cursor-capture
feat: native macOS cursor capture using NSCursor
2026-05-30 14:04:10 -07:00
Siddharth ff767a5e86 Fix tutorial help translations test for pt-BR locale
Add pt-BR to the dialogsByLocale map so the test covers the
newly added locale instead of crashing on an undefined entry.
2026-05-30 13:43:07 -07:00
arunkumar.a 993f693b5c Refine vertical tray appearance 2026-05-30 20:48:03 +05:30
Justice Entsie fb49ee1f1c Add Portuguese (pt-BR) dialogs to tutorial help translations 2026-05-30 14:47:39 +01:00
Justice Entsie 82b41da2d8 Add Portuguese (pt-BR) dialogs to tutorial help translations 2026-05-30 14:31:48 +01:00
Justice Entsie 2aa6e90edb Refactor text animation options to use translation keys for localization 2026-05-30 13:23:15 +01:00
Justice Entsieandcoderabbitai[bot] c34a600138 Update src/i18n/locales/ko-KR/settings.json
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-05-30 12:59:11 +01:00
Justice Entsie 0b06900a9c Add textAnimation keys to ja-JP, ko-KR, and ru locales 2026-05-30 11:54:01 +00:00
Justice Entsie c0702b5e91 Add text animation i18n keys to all locale files 2026-05-30 11:48:29 +00:00
Justice Entsie 928e1c5e01 Merge branch 'siddharthvaddem:main' into font-animation 2026-05-30 11:21:34 +00:00
Justice Entsie 545c35d4cd chore: format codebase and update i18n locale files 2026-05-30 12:13:38 +01:00
Kelly Yang eac5cc63b4 fix: address review comments on PR #671
- Replace lastEmittedAssetId with a process-wide Set<String> so each unique
  cursor shape is serialised at most once even across non-adjacent repeats
  (e.g. arrow → text → arrow no longer resends the arrow bitmap)
- Wrap the sampling loop body in autoreleasepool{} to prevent Cocoa objects
  (NSBitmapImageRep, PNG Data, base64 String) from accumulating for the
  lifetime of the helper during long recordings
- Update stale Accessibility comments: missing Accessibility only disables
  text/pointer affordance detection; native bitmap capture is unaffected
2026-05-29 13:48:44 -07:00
Kelly Yang e82fc0d8cc feat: capture native cursor bitmaps on macOS
Capture the real system cursor image during macOS recording so custom and
default cursors render natively instead of being mapped to bundled SVGs,
bringing macOS in line with the Windows WGC capture path.

- macOS cursor helper grabs NSCursor.currentSystem as a PNG asset (SHA256 id,
  intrinsic scale factor, pixel hotspot); the bitmap payload is emitted once per
  shape and referenced by assetId thereafter
- helper returns nil cursorType instead of an arrow fallback so default/custom
  cursors fall through to the captured bitmap while text/pointer stay beautified
- MacNativeCursorRecordingSession collects deduped assets, tags samples with
  assetId, and reports provider "native" when bitmaps are captured
2026-05-29 11:33:01 -07:00
Paulo Henrique Garcia 7cf78fe53c feat: Add projectFolder to user preferences
Adds `projectFolder` as a key to user preferences, stores the location of
the most recently opened project, and prefills it in the next File → Open
action. Mirrors the pattern from #512 (`exportFolder`).

Closes #668
2026-05-29 10:37:27 +02:00
Sid cf74b76d39 Merge pull request #581 from nxc-7/feat/pt-BR-translation
feat: add Brazilian Portuguese (pt-BR) translation
2026-05-28 22:05:53 -07:00
Sid afc76c368f Merge branch 'main' into feat/pt-BR-translation 2026-05-28 22:05:36 -07:00
Sid 0abe134218 Merge pull request #647 from davideme/claude/trusting-mcnulty-1089a4
Add Waveform as Background of the Trim Track - Easier to size the Trim
2026-05-28 21:17:44 -07:00
Sid 391817d648 Merge pull request #663 from enstie/font-addition
Add more built-in annotation font options
2026-05-28 21:12:33 -07:00
Sid df6c821700 Merge pull request #665 from zhenya-band/feature/keyboard-shortcut-to-open-OpenScreen
feat: add configurable global shortcut to open app
2026-05-28 21:10:25 -07:00
Sid dd2513d958 Merge pull request #667 from zhenya-band/fix/shortcuts-reset-button-hover-text
fix: remove conflicting hover style from shortcuts reset button
2026-05-28 21:07:39 -07:00
Yauheni Bandziukevich ed5ef32280 fix: remove conflicting hover style from shortcuts reset button 2026-05-28 23:42:27 +02:00
Yauheni Bandziukevich ff40a73a1b fix: keep shortcuts dialog open on registration failure 2026-05-28 23:21:41 +02:00
Yauheni Bandziukevich 59c9a19269 fix: preserve working shortcut when new registration fails 2026-05-28 23:18:35 +02:00
Yauheni Bandziukevich 902d4e4d8a fix: show error feedback when global shortcut registration fails 2026-05-28 23:09:51 +02:00
Yauheni Bandziukevich d86c17407a fix: convert special keys to valid Electron accelerators
Maps KeyboardEvent.key values (e.g., "ArrowDown", " ") to their
Electron accelerator equivalents (e.g., "Down", "Space") to prevent
global shortcut registration failures.
2026-05-28 22:11:38 +02:00
Yauheni Bandziukevich f1a8552046 refactor: update ShortcutBinding import path in main and preload scripts 2026-05-28 21:50:55 +02:00
Yauheni Bandziukevich 480890bcce feat: add configurable global shortcut to open app 2026-05-28 21:50:53 +02:00
Justice Entsie 62000c6dcf Add text animation presets for annotations 2026-05-28 20:07:03 +01:00
Justice Entsie 202c670090 Add more built-in annotation font options 2026-05-28 19:14:47 +01:00
Davide Mendolia 3d572b272d Merge branch 'siddharthvaddem:main' into claude/trusting-mcnulty-1089a4 2026-05-28 09:51:14 +02:00
Davide MendoliaandClaude Sonnet 4.6 fca17f901e revert(timeline): restore polygon waveform, remove per-column bar approach
The per-column fillRect skipped columns with amplitude < 0.5 px, causing
the waveform to disappear entirely at certain zoom levels. Revert to the
closed polygon + top-edge stroke which always renders something.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 09:50:24 +02:00
Davide MendoliaandClaude Sonnet 4.6 abb4413932 fix(timeline): correct waveform vertical alignment and silent baseline
Two fixes:
1. topInset/bottomInset both set to 3 — items are center-aligned in the
   36 px row (3 px gap top and bottom), so the drawing zone must match.
   topInset=0 was drawing 3 px above the trim box top.
2. Replace closed polygon with per-column fillRect so silent sections
   (barH < 0.5 px) produce no pixels instead of a flat green baseline.
   The top-edge stroke lifts the pen across silent gaps.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 09:45:14 +02:00
Davide MendoliaandClaude Sonnet 4.6 d8ab206b80 fix(timeline): draw waveform within item bounds, not CSS canvas bounds
Replace the single `verticalInset` CSS prop (which tried to shrink the
canvas element) with `topInset`/`bottomInset` drawing-space offsets.
The canvas remains `absolute inset-0` (full row height); the polygon
baseline is drawn at `H - bottomInset` and the amplitude is scaled to
`drawHeight * 0.9` — so the waveform is guaranteed to stay within
[topInset, H - bottomInset] regardless of sub-pixel CSS rounding.

Trim row: topInset=0, bottomInset=6 (row minHeight 36 − item height 30)
eliminates the faint green line that was visible below the trim box.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 09:38:06 +02:00
Davide MendoliaandClaude Sonnet 4.6 489f157eef fix(timeline): align waveform bounds with trim box height
The trim item content is 30 px tall inside a 36 px row, leaving 3 px of
vertical padding top and bottom. Previously the canvas used `inset-0` so
the waveform filled the full row height. A new `verticalInset` prop
(defaulting to 0) offsets the canvas top/bottom edges; the trim row
passes `verticalInset={3}` so the waveform top and bottom align exactly
with the trim box bounds.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 09:33:59 +02:00
Davide MendoliaandClaude Sonnet 4.6 60cbb449e8 feat(timeline): rectified (half) waveform style
Replace the bipolar ±center polygon with a rectified half-waveform:
amplitude per column is max(|min|, |max|) and the filled shape rises
from the bottom of the row upward, matching the single-sided waveform
display common in audio/video editors.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 09:30:30 +02:00
Davide MendoliaandClaude Sonnet 4.6 0d39f65af1 feat(timeline): green filled polygon waveform style
Switch the trim-row waveform from faint white vertical strokes to a
green filled polygon (upper max edge left→right, lower min edge
right→left) with crisp edge strokes — matching the look of Final Cut
Pro / DaVinci Resolve waveform displays.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 09:22:56 +02:00
Sid 8117d4826f Merge pull request #657 from aaravshirpurkar/main
fix: increase rounding snap tolerance to 4px to prevent background line bleed when padding is 0
2026-05-26 22:35:42 -07:00
Sid b70994b079 Merge pull request #658 from neurot1cal/fix/streaming-recording-duration
Fix: stream long recordings to disk and patch WebM duration on save (#616)
2026-05-26 22:27:09 -07:00
neurot1calandClaude Opus 4.7 5c5cab6903 fix: don't stream when the append IPC is unavailable
Codex re-review: if openRecordingStream exists but appendRecordingChunk
does not (renderer/main version skew), the recorder would open the stream
and switch to streaming mode, but every append silently no-ops and the
save ends up empty. Require both IPC methods before streaming; otherwise
fall back to in-memory buffering. Adds a regression test.

Verified: tsc --noEmit clean; biome clean; vitest 183/183.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 16:39:53 -07:00
neurot1calandClaude Opus 4.7 36d7d2bdd0 fix: tighten streaming failure handling from re-review
Addresses the CodeRabbit + Codex re-review of the prior commit.

- Normalize a rejected append (channel/handler error, not just a
  { success: false } result) into appendError, so the write queue never
  rejects and isStreaming() stays consistent after a failure (CodeRabbit).
- Handle a rejected open-stream IPC the same as a failed open: fall back
  to in-memory buffering instead of leaving the recorder stuck "pending"
  with an unhandled rejection (CodeRabbit).
- Discard a streamed webcam whose write failed even when the screen save
  succeeds. The cleanup gate is now per-recorder, so a webcam omitted from
  a successful screen-only save no longer leaks its stream and partial
  file (Codex).

Adds tests for the rejected-append and rejected-open paths.

Verified: tsc --noEmit clean; biome clean; vitest 182/182.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 16:28:50 -07:00
neurot1calandClaude Opus 4.7 f3c5b8a65d fix: harden streaming lifecycle and lift it out of the IPC god-module
Addresses the review feedback on #658 (CodeRabbit + Codex) and the
structural notes from the quality pass.

Correctness:
- Compute the recorder's streaming state at finalize time, not at
  construction. A stream that fails to open is now reported as
  not-streamed, so its buffered chunks are saved as a complete in-memory
  fallback instead of being dropped (was total data loss on open failure).
- Await every in-flight chunk write before onstop resolves, so the main
  process never closes the write stream while a final chunk is still in
  flight (was truncating the tail of a recording under load).
- Open the disk write stream by awaiting its 'open' event, so a bad path
  or permission error rejects up front instead of being acknowledged as
  success and then silently dropping bytes.
- Close the stream and remove the partial file when a streamed recording
  is discarded or fails, so cancelled/failed runs don't leak descriptors
  or orphan partial recordings.
- Surface a mid-stream write failure as a rejected recording rather than
  saving a silently truncated file.

Structure:
- Extract the streaming concern into electron/ipc/recordingStream.ts
  (RecordingStreamRegistry) and src/hooks/recorderHandle.ts, out of the
  2.8k-line handlers.ts and the screen-recorder hook.
- Key write streams by output file name, removing the implicit
  recordingId/+1 contract that spanned the IPC boundary.
- Collapse the duplicated screen/webcam finalize blocks into one helper
  and the repeated duration-validity guard into one check; patch the
  screen and webcam durations in parallel.

Adds unit tests for the registry (real temp-dir fs) and the recorder
handle state machine (open-failure fallback, in-order writes awaited
before stop, mid-stream failure). Extends the vitest include glob to
collect electron-side tests.

Verified: tsc --noEmit clean; biome clean; vitest 180/180.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 16:09:39 -07:00
aaravshirpurkar 8c7ea939ab fix: increase rounding snap tolerance to 4px to prevent background line bleed when padding is 0 2026-05-26 13:43:57 +05:30
727e395fcf fix: stream long recordings to disk and patch WebM duration on save
Recordings longer than ~10 minutes silently fail to save (#616). The
renderer buffers the whole WebM as a Blob[], then on stop makes several
in-memory copies (fixWebmDuration -> arrayBuffer -> Buffer.from) before
writing. A long 1080p recording duplicates hundreds of MB several times
in the renderer, exceeds Electron's memory limit, and the renderer
crashes silently with no file saved.

Two changes:

1. Stream chunks to disk (originally @Amanuel2x's contribution in #617).
   Open an fs.WriteStream in the main process at recording start and send
   each ~1s ondataavailable chunk straight to disk over two new IPC calls
   (open-recording-stream, append-recording-chunk), so the renderer never
   holds more than a single chunk. A full in-memory fallback is preserved
   for environments where the IPC stream cannot open.

2. Patch the WebM Duration header on disk after the stream closes. Browser
   MediaRecorder writes WebM with no Duration element, so streamed files
   save with duration=N/A and the editor's seek bar, timeline, and any
   scrub/trim break. A new electron/recording/webm-duration.ts module
   rewrites the Duration element, writing to a temp file and renaming in
   place so a crash mid-write cannot corrupt the recording.

Streaming is opt-in: the screen recorder and the browser-only webcam
recorder stream to disk; native-capture webcam sidecars (Windows, macOS)
keep buffering in-memory, since their finalize path reads the recorder
blob directly to attach the webcam track.

Verified: tsc --noEmit clean; biome clean; vitest 166/166.

Closes #616
Supersedes #617

Co-Authored-By: Amanuel <amanuel@localboostnetworking.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 17:53:22 -07:00
AjTheSpidey 205ea55b0c fix: tidy webcam mirror settings 2026-05-26 02:30:52 +08:00
Sid 54677960d0 Merge pull request #645 from auberginewly/fix/export-panel-hidden-after-zoom-select
fix(export): clear timeline selection when opening export panel
2026-05-25 10:59:13 -07:00
Sid 7f73f9089f Merge pull request #646 from AjTheSpidey/codex/editor-settings-scroll
Fix stale speed controls in the editor
2026-05-25 10:56:14 -07:00
Sid f6b0bbd3ed Merge pull request #653 from nachobh/roundingIncreased
Rounding increased
2026-05-25 10:52:56 -07:00
Ignacio Benito Herrero d32bb00959 Increased max rounding to 64 2026-05-25 10:42:43 +02:00
Ignacio Benito Herrero 3d129f85cd Added .import files to .gitignore 2026-05-25 10:38:26 +02:00
seungryk 6ba9f1e948 Fix screen source selection recovery flow 2026-05-25 11:32:48 +09:00
Davide MendoliaandClaude Sonnet 4.6 3a2f90722c chore: restore original VideoFrame formatting in streamingDecoder.ts
Biome had reformatted two pre-existing VideoFrame constructor calls from
single-line to multi-line style when --write was run on the file. Restore
them to match main so the diff only reflects our intentional refactoring.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 12:08:55 +02:00
Davide Mendolia c0b929d4b2 refactor: simplify function signatures and reduce line breaks for readability 2026-05-24 11:53:44 +02:00
Davide MendoliaandClaude Sonnet 4.6 db1d6bfe96 refactor: rewrite loadFileAsArrayBuffer using static loadLocalSourceFile/loadRemoteSourceFile
Make loadLocalSourceFile and loadRemoteSourceFile static so they can be
called without an instance. loadSourceFile applies withTimeout around
each static call. loadFileAsArrayBuffer delegates to the same static
methods and extracts ArrayBuffer + contentType from the blob, eliminating
the duplicated IPC/fetch branching logic.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 11:47:51 +02:00
Davide MendoliaandClaude Sonnet 4.6 69d0aa4d30 refactor: split loadSourceFile into loadLocalSourceFile and loadRemoteSourceFile
Restore loadSourceFile as a thin router (local vs remote) and extract
the IPC and fetch paths into dedicated private methods, each containing
the original logic from main.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 11:43:22 +02:00
Davide MendoliaandClaude Sonnet 4.6 d06bf37471 fix: propagate Content-Type from fetch through loadFileAsArrayBuffer
Change return type to { data, contentType } so the fetched Content-Type
header is available to callers. loadSourceFile uses it to set the Blob
MIME type for https:/blob: URLs instead of always falling back to
application/octet-stream. useAudioPeaks destructures only { data }.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 11:30:06 +02:00
AjTheSpidey 2cf09a88dc fix: polish webcam mirror toggle 2026-05-24 08:59:54 +08:00
AjTheSpidey 2349272b21 feat: add webcam mirror toggle 2026-05-24 08:51:26 +08:00
arunkumar.a 9fc047f2cd Improve tray layout documentation 2026-05-23 23:52:21 +05:30
arunkumar.a c293786e9d Add vertical tray layout toggle 2026-05-23 23:29:29 +05:30
Davide MendoliaandClaude Sonnet 4.6 928cabc68d fix: add AbortSignal to worker, fix data: URL filename in loadSourceFile
- computePeaksInWorker now accepts an AbortSignal; terminates the worker
  immediately on abort and rejects with AbortError so orphaned workers
  cannot outlive the effect cleanup
- useAudioPeaks wires an AbortController into the effect; controller.abort()
  fires alongside the existing cancelled flag on URL change or unmount
- loadSourceFile handles data: URLs by parsing the MIME type, deriving a
  safe filename (e.g. video.mp4), and setting Blob type correctly

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 19:04:35 +02:00
Davide MendoliaandClaude Sonnet 4.6 9a72f31148 docs: add missing JSDoc to satisfy docstring coverage threshold
Add docstrings to all undocumented functions and methods across the
files touched in this branch: getAudioCtx, Row component, and all
StreamingVideoDecoder methods (loadSourceFile, computeSegments,
getExportMetrics, splitBySpeed, getDemuxer, cancel, destroy, withTimeout).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 18:33:27 +02:00
Davide MendoliaandClaude Sonnet 4.6 9f55611f72 fix: always clear canvas before bailing on empty peaks
Move the peaks null-check after clearRect so a previously drawn
waveform is erased when peaks transitions to null (e.g. on URL change
or decode failure) rather than lingering on screen.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 18:12:54 +02:00
Davide MendoliaandClaude Sonnet 4.6 87f7268de7 i18n(zh-TW): align waveform label with existing trim terminology
Use 剪輯 (editing/clip) instead of 修剪 (trimming) to match the term
already used in the trim section throughout zh-TW strings.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 18:11:44 +02:00
Davide MendoliaandClaude Sonnet 4.6 f19d1bc668 fix: clear stale peaks before decoding a new source
Reset peaks to null immediately when an uncached URL is set, so the
previous video's waveform is not shown during decode. Also clear on
decode failure so stale data does not persist indefinitely.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 18:10:36 +02:00
Davide MendoliaandClaude Sonnet 4.6 98f7441dfa refactor: rename RowWaveform to BackgroundWaveform, drop wrapper div
Rename to BackgroundWaveform to reflect its visual role rather than
structural placement. Remove the redundant wrapper div — Row already
provides relative overflow-hidden, so the canvas can sit absolute
inset-0 directly and observe itself via ResizeObserver.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 18:06:28 +02:00
Davide MendoliaandClaude Sonnet 4.6 b7d7c08906 i18n: add Timeline panel translations for all 12 locales
Add timeline.title and timeline.waveform keys to every settings.json
locale file. Replace all hardcoded "Timeline" and "Show Audio Waveform
on Trim Track" strings in SettingsPanel with t() calls.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 17:43:13 +02:00
Davide MendoliaandClaude Sonnet 4.6 f67e997688 refactor: offload peak computation to Web Worker, replace module cache with useRef
- Move computePeaks into audioPeaksWorker.ts — runs off the main thread
  with zero-copy channel buffer transfer
- Replace module-level peaksCache Map with a useRef scoped to the hook
  instance — no global mutable state, toggle off/on still instant

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 17:34:38 +02:00
Davide MendoliaandClaude Sonnet 4.6 514943c142 refactor: extract loadFileAsArrayBuffer to eliminate duplicated file loading
Add a single exported loadFileAsArrayBuffer in streamingDecoder.ts that
owns the IPC-vs-fetch branching for local and remote video URLs.
StreamingVideoDecoder.loadSourceFile and useAudioPeaks both delegate to
it, removing the duplicated readBinaryFile/fetch logic.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 17:26:54 +02:00
Davide MendoliaandClaude Sonnet 4.6 a72aa1a594 refactor: extract audio peak decoding into useAudioPeaks hook
Move fetch/decode/cache logic out of RowWaveform into a standalone
useAudioPeaks hook. RowWaveform is now a pure canvas renderer that
accepts pre-computed peaks as a prop. TimelineEditor calls the hook
and passes peaks down, skipping decoding entirely when waveform is off.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 17:14:20 +02:00
Davide MendoliaandClaude Sonnet 4.6 46732fc76b revert: restore hint text opacity to 0.12 in Row
The 0.25 bump was a workaround for hint visibility over the waveform
background, but is now stale — waveform defaults to off and hints only
appear on empty rows anyway. Restores consistency with all other row hints.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 17:08:38 +02:00
Davide MendoliaandClaude Sonnet 4.6 5b4f31f661 chore: swap AudioWaveform icon for Brackets, improve toggle label
- Replace AudioWaveform icon with Brackets on the Timeline panel tab and section header
- Rename toggle label from "Waveform" to "Show Audio Waveform on Trim Track"
- Make toggle full-width to accommodate the longer label

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 16:57:19 +02:00
Davide MendoliaandClaude Sonnet 4.6 70c7d20d3c fix: timeline waveform in dedicated Timeline panel, disabled by default
- Add "Timeline" as a dedicated SettingsPanelMode with AudioWaveform icon
- Waveform toggle now lives exclusively in the Timeline panel (not in every panel)
- Default showTrimWaveform to false so trim row starts with visible hint text
- Guard AccordionItem render with activePanelMode === "timeline" to prevent bleed

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 15:45:00 +02:00
Davide MendoliaandClaude Sonnet 4.5 8413095aa9 feat: audio waveform on trim track + Timeline Settings toggle
- Add RowWaveform component (refactored from TrimWaveform) that decodes
  audio via AudioContext, caches peaks per URL, and renders a faint
  waveform canvas behind any timeline row using useTimelineContext() for
  zoom/pan alignment
- Wire RowWaveform as background of the trim row in TimelineEditor
- Add showTrimWaveform boolean to EditorState / ProjectEditorState with
  default true; persists across saves/loads
- Add Timeline Settings accordion section in SettingsPanel (always
  visible) with a Waveform toggle to enable/disable the visualization
- Bump hint text opacity from 0.12 to 0.25 so "Press T to add trim"
  remains readable over the waveform background

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-05-23 12:53:56 +02:00
AjTheSpidey d856a52316 fix stale speed selection in editor 2026-05-23 18:32:52 +08:00
auberginewly 37eaacc07b fix(export): clear timeline selection when opening export panel
When a zoom/trim/speed region is selected, hasTimelineSelection is true
and the export panel is gated behind !hasTimelineSelection. Clicking the
Download button only switched activePanelMode locally in SettingsPanel
without clearing the selection in VideoEditor, so the export panel never
rendered.

Add onExportPanelOpen callback prop to SettingsPanel and call it on
Download button click to clear selectedZoomId, selectedTrimId, and
selectedSpeedId — making hasTimelineSelection false and unblocking the
export panel.

Complements PR #611 which fixed the bulk suggest-zooms path; this
covers the manual selection path.
2026-05-23 16:17:12 +08:00
Sid 34340c2b29 Merge pull request #526 from Sunwood-ai-labs/codex/allow-png-background-upload
[codex] Allow PNG custom background uploads
2026-05-22 20:37:25 -07:00
Siddharth 2dbdb27bb6 Merge remote-tracking branch 'origin/main' into codex/allow-png-background-upload
# Conflicts:
#	electron/ipc/handlers.ts
#	electron/main.ts
2026-05-22 20:33:19 -07:00
Sid fbd06fca48 Merge pull request #613 from auberginewly/feat/zoom-hold-preview
feat(zoom): hold-to-preview button for zoom focus editing (prototype for #612)
2026-05-22 20:16:28 -07:00
Siddharth 85d0dea9fc Merge remote-tracking branch 'origin/main' into feat/zoom-hold-preview
# Conflicts:
#	src/components/video-editor/VideoPlayback.tsx
2026-05-22 20:15:00 -07:00
Siddharth 259bfa9097 Merge remote-tracking branch 'origin/main' into feat/zoom-hold-preview 2026-05-22 20:12:51 -07:00
Sid b6b37e3718 Merge pull request #639 from siddharthvaddem/codex/fix-windows-paused-recording
[codex] Fix native Windows recording pause
2026-05-22 20:09:05 -07:00
Siddharth a50835e30f Merge remote-tracking branch 'origin/main' into codex/fix-windows-paused-recording
# Conflicts:
#	src/hooks/useScreenRecorder.ts
2026-05-22 20:08:26 -07:00
Sid dfd961393f Merge pull request #605 from EtienneLescot/codex/editor-defaults-ssot
Centralize editor defaults
2026-05-22 20:04:54 -07:00
SiddharthandClaude Opus 4.7 9eaae72af1 fix: drop removed WEBCAM_TARGET width/height refs after main merge
PR #600 (now on main) removed WEBCAM_TARGET_WIDTH/HEIGHT and switched
this call site to width/height: 0 so the native helper picks the
camera's native dimensions. Align this branch with that so CI's
fresh PR-merge stops erroring on the undeclared identifiers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 20:02:25 -07:00
Siddharth d658ec40f5 Merge remote-tracking branch 'origin/main' into codex/editor-defaults-ssot 2026-05-22 20:00:54 -07:00
SiddharthandClaude Opus 4.7 84b523df83 fix: drop unused imports and reorder in SettingsPanel
Removes MAX_PLAYBACK_SPEED and DEFAULT_WEBCAM_SIZE_PRESET (TS6133) and
runs biome's organize-imports to satisfy the Lint check.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 19:54:13 -07:00
Sid 086039bf67 Merge pull request #600 from sagar290/webcam-layout-constraints
Fix vertical webcam recording layout
2026-05-22 19:47:31 -07:00
Sid b8e78ccbf5 Merge branch 'main' into codex/editor-defaults-ssot 2026-05-22 19:44:37 -07:00
Sid 3b7e78a16b Merge pull request #642 from siddharthvaddem/codex/fix-windows-webcam-sidecar
Fix native Windows webcam sidecar capture
2026-05-22 19:43:29 -07:00
Sid 97fa74aa92 Merge pull request #643 from AjTheSpidey/codex/fix-decimal-playback-speed
Fix decimal custom playback speeds
2026-05-22 19:42:34 -07:00
AjTheSpidey 0daf2295a3 fix: accept decimal custom speeds 2026-05-23 03:47:42 +08:00
Etienne Lescot b36a32d44b refactor: centralize editor defaults 2026-05-22 21:10:44 +02:00
EtienneLescot ca826d9088 Fix native Windows recording pause 2026-05-22 21:02:33 +02:00
AjTheSpidey ea09fdc12c test: expose source selector hooks 2026-05-21 19:35:26 +08:00
auberginewly b25c2c8363 revert(zoom): drop selected-zoom preview override, preview the current frame
The a686fa0 override replaced findDominantRegion's resolved region with the
raw stored region (forcing strength=1 / transition=null). findDominantRegion
already resolves focus via getResolvedFocus — for focusMode:"auto" it
interpolates the cursor-followed focus from telemetry and applies clamp/blend/
transition. The override bypassed all of that, so previewing an auto-focus
zoom showed a stale static focus and an instant un-eased zoom that did not
match real playback/export.

Hold-to-preview now shows the natural zoom for the current playhead frame
(true before/after compare). The isPreviewingZoom flag is kept — it only
disables the un-zoomed editing guard so findDominantRegion's result is shown.
Previewing while the playhead is outside any zoom shows no zoom by design.
2026-05-19 11:08:54 +08:00
auberginewly a686fa012a fix(zoom): address PR review — preview selected zoom + keyboard a11y
- VideoPlayback: while holding Preview, render the SELECTED zoom at full
  strength regardless of the playhead, instead of whatever findDominantRegion
  returns at currentTime (which is none/another zoom when the playhead is
  outside the selection). Uses getZoomScale/getRotation3D for the region's
  configured scale and 3D preset.
- SettingsPanel: require both onZoomPreviewStart && onZoomPreviewEnd to render
  the button (full lifecycle), and add keyboard support — Space/Enter keydown
  (repeat-guarded) starts preview, keyup/blur ends it.
2026-05-19 11:02:28 +08:00
auberginewly 24ce67b5a7 i18n(zoom): clarify previewHold wording to "preview zoom effect"
Make the label explicit about what holding previews (the zoom effect),
across all 11 locales.
2026-05-19 10:51:22 +08:00
auberginewly 2993a57853 feat(zoom): add hold-to-preview button for zoom focus editing
When a zoom region is selected and paused, the editor shows the full
un-zoomed frame for focus-point placement. This adds a press-and-hold
"Preview" button so editors can momentarily see the zoomed result at the
current focus + depth — like a before/after compare — without entering
playback.

- VideoPlayback: new transient isPreviewingZoom prop; shouldShowUnzoomedView
  now also requires !isPreviewingZoom, so the zoom transform is applied at
  the playhead while previewing
- VideoEditor: isPreviewingZoom state wired to VideoPlayback and to
  onZoomPreviewStart/End handlers
- SettingsPanel: hold button in the zoom controls (pointer down/up/leave/
  cancel)
- i18n: zoom.previewHold added across all 11 locales

Prototype for #612 — placement (panel vs overlay) and hold-vs-toggle still
open for maintainer direction.
2026-05-19 10:48:44 +08:00
Sagar Dash b5c105b5d6 fix: respect native webcam orientation on Windows 2026-05-18 18:28:13 +06:00
parse-nipandCursor 2ee00a7cfa fix(captioning): narrow ORT node shim, trim retries, and abort checks
Scope withoutNodeVersion to Transformers import/pipeline only; reapply trim-region filtering after ignore-trims retries; check AbortSignal after each slice inference before chunk processing.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-15 12:38:10 +08:00
parse-nipandCursor a0bcd9fe90 feat(editor): polish auto-caption loading toasts
Use one Sonner id for the full caption flow, show a distinct transcribing step, yield before Whisper so updates paint, match editor dark chrome on toasts, and keep pointer-events only on toast bodies. Add transcribing strings across locales.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-15 12:28:18 +08:00
parse-nipandCursor 5417581f0e merge: upstream main into feature/video-transcriptions
Merge siddharthvaddem/openscreen main and resolve conflicts in VideoEditor.tsx (export diagnostics, save-diagnostic handler, auto captions, toolbar chrome) and package-lock.json. Apply Biome formatting after merge so hooks pass.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-14 11:34:11 +08:00
parse-nipandCursor 3a48e54db1 fix: time-slice single-group phrase captions into lines
Phrase mode with one merged span now splits wrapCaptionTextByWordBounds
lines into separate CaptionSegments with even time allocation across
the phrase span (fallback when duration is too short for min spans).
Update unit test accordingly.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-14 10:51:31 +08:00
parse-nipandCursor 2b7e6f1677 fix: address PR review for auto captions and locales
- Gate trimSec segment shift when transcription used full buffer retry
- Restore UTF-8 autoCaptions strings (ar, es, fr, ja-JP, ko-KR, tr, zh-CN, zh-TW)
- Dedupe caption segments after grouping only; stricter chunk dedupe in transcribe
- Post-truncate duration, explicit consume merge for web demuxer, trim region shift cleanup
- Tests for caption annotation pipeline

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-14 10:39:08 +08:00
parse-nip 1c431fe473 fix: improve auto-caption timing and pause handling 2026-05-14 09:41:39 +08:00
parse-nipandCursor 7216f255a0 feat: add video transcriptions (in-browser auto captions)
Decode mono 16k audio from the editor video, run Whisper via Transformers.js, and insert linked text annotations with timing and layout helpers.

Adds Vite resolve shims for Node-only imports used by the model stack, optional leading-silence trim for the caption buffer, timeline gap reconciliation for auto-caption regions, and editor i18n. Raises Select content z-index so caption controls stay usable over the video surface.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-13 22:33:10 +08:00
nxc-7 c13d3c71bc feat: add Brazilian Portuguese (pt-BR) translation 2026-05-12 18:59:48 -04:00
AbhinRustagi 33bb751140 fix: resolve comments 2026-05-11 20:55:43 +10:00
AbhinRustagi d802473d86 fix: update snap guide color 2026-05-11 20:43:46 +10:00
AbhinRustagi 872bd959da Merge remote-tracking branch 'origin/main' into feature/timeline-handle-snap 2026-05-11 20:43:05 +10:00
Sunwood-ai-labs d3b51e84f2 Merge remote-tracking branch 'upstream/main' into codex/allow-png-background-upload
# Conflicts:
#	src/components/video-editor/SettingsPanel.tsx
#	src/i18n/locales/ja-JP/settings.json
2026-05-10 14:30:22 +09:00
Sunwood-ai-labs 613ed008fc Allow PNG custom background uploads 2026-05-03 15:18:50 +09:00
AbhinRustagi 3aad05abd2 feat: add timeline snap guides 2026-05-02 02:38:25 +05:30
248 changed files with 11793 additions and 2133 deletions
+44
View File
@@ -29,6 +29,14 @@ jobs:
- name: Install dependencies
run: npm ci
# Cache the downloaded caption model so we don't re-fetch from HuggingFace every run
# (and to avoid 429s when the platform matrix builds hit it at once).
- name: Cache caption assets
uses: actions/cache@v4
with:
path: caption-assets
key: caption-assets-${{ hashFiles('scripts/fetch-caption-model.mjs') }}
- name: Build Windows app
run: npm run build:win
env:
@@ -69,6 +77,23 @@ jobs:
- name: Install dependencies
run: npm ci
# ─── Ensure sharp prebuilt binary (+ bundled libvips) ─────
# `npm ci` can leave sharp without its prebuilt binary/vendored libvips,
# which makes electron-builder's native rebuild fail ("vips-cpp.42 not
# found"). Re-fetch the prebuilt (never compile from source).
- name: Ensure sharp prebuilt
run: npm rebuild sharp
env:
npm_config_build_from_source: "false"
# ─── Cache caption assets ─────────────────────────────────
# Avoid re-fetching the Whisper model from HuggingFace every run (and 429s under the matrix).
- name: Cache caption assets
uses: actions/cache@v4
with:
path: caption-assets
key: caption-assets-${{ hashFiles('scripts/fetch-caption-model.mjs') }}
# ─── Import Code Signing Certificate ──────────────────────
# This is the KEY step that makes CI signing work.
# We create a temporary keychain, import the .p12 cert into it,
@@ -111,6 +136,17 @@ jobs:
- name: Build Vite + Electron
run: npx tsc && npx vite build
# ─── Build native macOS helpers ───────────────────────────
# The package step below calls electron-builder directly (not `npm run build:mac`),
# so the Swift screencapturekit + cursor helpers must be compiled here first or the
# packaged app ships without them (no recording, "cursor helper couldn't be found").
# Build the matrix arch into electron/native/bin/darwin-<arch> so each DMG gets its
# own native binary.
- name: Build native macOS helpers
run: npm run build:native:mac
env:
OPENSCREEN_MAC_HELPER_ARCHS: ${{ matrix.arch }}
# ─── Package with electron-builder ────────────────────────
# electron-builder handles deep codesigning the .app bundle
# "notarize: false" in electron-builder.json5 prevents it from
@@ -236,6 +272,14 @@ jobs:
- name: Install pacman build dependencies
run: sudo apt-get update && sudo apt-get install -y libarchive-tools
# Cache the downloaded caption model so we don't re-fetch from HuggingFace every run
# (and to avoid 429s when the platform matrix builds hit it at once).
- name: Cache caption assets
uses: actions/cache@v4
with:
path: caption-assets
key: caption-assets-${{ hashFiles('scripts/fetch-caption-model.mjs') }}
- name: Build Linux app
run: npm run build:linux
env:
+8
View File
@@ -46,6 +46,7 @@ release/**
test-results
playwright-report/
# Vitest browser mode screenshots
__screenshots__/
@@ -58,3 +59,10 @@ result-*
#kilocode
.kilo/
#others
**/*.import
# Auto-caption model + ORT wasm — regenerated at build by scripts/fetch-caption-model.mjs
/caption-assets/
+1 -1
View File
@@ -1,4 +1,4 @@
# Contribution Guidelines
# Contribution Guidelines
Thank you for considering contributing to this project! By contributing, you help make this project better for everyone. Please take a moment to review these guidelines to ensure a smooth contribution process.
+38 -60
View File
@@ -1,51 +1,51 @@
> [!WARNING]
> This started as a side project that took off — it's not production grade and you'll hit bugs, but hopefully it covers what you need.
> This started as a side project that blew up; not production grade and you'll hit bugs, but hopefully it covers what you need. **This project will soon be archived.**
<p align="center">
<img src="public/openscreen.png" alt="OpenScreen Logo" width="64" />
<br />
<br />
<a href="https://trendshift.io/repositories/17427" target="_blank"><img src="https://trendshift.io/api/badge/repositories/17427" alt="siddharthvaddem%2Fopenscreen | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
<br />
<br />
<a href="https://deepwiki.com/siddharthvaddem/openscreen">
<img src="https://deepwiki.com/badge.svg" alt="Ask DeepWiki" />
</a>
&nbsp;
<a href="https://discord.gg/yAQQhRaEeg">
<img src="https://dcbadge.limes.pink/api/server/https://discord.gg/yAQQhRaEeg?style=flat" alt="Join Discord" />
<a href="https://trendshift.io/repositories/17427" target="_blank"><img src="https://trendshift.io/api/badge/repositories/17427" alt="siddharthvaddem%2Fopenscreen | Trendshift" style="width: 256px; height: 64px;" width="256" height="64"/></a>
</a>
</p>
# <p align="center">OpenScreen</p>
<p align="center"><strong>OpenScreen is your free, open-source alternative to Screen Studio (sort of).</strong></p>
<p align="center"><strong>OpenScreen is your free, open-source alternative to Screen Studio.</strong></p>
If you don't want to pay $29/month for Screen Studio but want a much simpler version that does what most people seem to need - quick, polished product demos and walkthroughs you'd post on X, Reddit. OpenScreen does not offer all Screen Studio features, but covers the basics well!
If you don't want to pay $29/month for Screen Studio but want a version that does what most people seem to need - quick, polished product demos and walkthroughs you'd post on X, Reddit or Youtube. OpenScreen does not offer every Screen Studio feature, but covers a lot of the core functionality.
Screen Studio is an awesome product and this is definitely not a 1:1 clone. OpenScreen is a much simpler take, just the basics for folks who want control and don't want to pay. If you need all the fancy features, your best bet is to support Screen Studio (they really do a great job, haha). But if you just want something free (no gotchas) and open, this project does the job!
Screen Studio is an awesome product and this is definitely not a 1:1 clone. If you just want something fully free and open source, this project should cover most of your needs.
**100% free** for both **personal** and **commercial** use. Use it, modify it, distribute it — just be cool 😁 and shout out the project if you feel like it.
**100% free** for both **personal** and **commercial** use. Use it, modify it, distribute it. Please respect the License.
> [!NOTE]
>Software should be accessible. OpenScreen has no paid tiers, premium features, upsells, or functionality locked behind a paywall.
<p align="center">
<img src="public/preview3.png" alt="OpenScreen App Preview 3" style="height: 0.2467; margin-right: 12px;" />
<img src="public/preview4.png" alt="OpenScreen App Preview 4" style="height: 0.1678; margin-right: 12px;" />
<img src="public/demo.png" alt="" style="height: 0.2467; margin-right: 12px;" />
<img src="public/sample.png" alt="" style="height: 0.2467; margin-right: 12px;" />
</p>
## Core Features
- Record a specific window, region, or your whole screen.
- Record a specific window, or your whole screen.
- Record microphone and system audio.
- Webcam overlay with picture-in-picture, drag-to-position, and shape options.
- Auto or manual zooms with adjustable depth, duration, easing, and pixel-precise position.
- Wallpapers, solid colors, gradients, or a custom background.
- Motion blur for smoother pan and zoom transitions.
- Webcam overlay with picture-in-picture, drag-to-position, mirroring, and shape options.
- Auto or manual zooms with adjustable depth, duration, easing, and pixel-precise position; auto-zoom follows your cursor as you work.
- Custom cursor size, smoothing, and click effects, with cursor themes and post-recording path smoothing.
- Automatic captions for voiceovers, generated on-device with no upload (works offline).
- Wallpapers, solid colors, gradients, or your own background image.
- Motion blur.
- Crop, trim, and per-segment speed control on the timeline.
- Blur effects to hide sensitive parts of the screen.
- Cursor and click highlighting.
- Text, arrow, and image annotations.
- Save and reopen projects without re-recording.
- Text, arrow, and image annotations, with text animation presets.
- Timeline snapping guides and an audio waveform to make trimming easier.
- Customizable keyboard shortcuts.
- Export to MP4 or GIF in multiple aspect ratios and resolutions.
- Translated into Arabic, English, Spanish, French, Japanese, Korean, Russian, Turkish, Vietnamese, Simplified Chinese, and Traditional Chinese.
- Languages supported: Arabic, English, Spanish, French, Italian, Japanese, Korean, Portuguese (Brazil), Russian, Turkish, Vietnamese, Simplified Chinese, and Traditional Chinese.
## Installation
@@ -76,6 +76,9 @@ Note: Give your terminal Full Disk Access in **System Settings > Privacy & Secur
After running this command, proceed to **System Preferences > Security & Privacy** to grant the necessary permissions for "screen recording" and "accessibility". Once permissions are granted, you can launch the app.
> [!NOTE]
> **Upgrading from an older version and hitting permission issues?** If you already had OpenScreen installed and the new version won't record (Screen Recording or Accessibility keep failing even after you grant them), uninstall the old version, remove OpenScreen's existing entries under **System Settings > Privacy & Security** (both Screen Recording and Accessibility), then do a fresh install and grant the permissions again when prompted.
### Windows
Install via [winget](https://learn.microsoft.com/en-us/windows/package-manager/winget/):
@@ -146,45 +149,20 @@ You may need to grant screen recording permissions depending on your desktop env
./Openscreen-Linux-*.AppImage --no-sandbox
```
### Limitations
### Platform differences
System audio capture relies on Electron's [desktopCapturer](https://www.electronjs.org/docs/latest/api/desktop-capturer) and has some platform-specific quirks:
Everything in the editor and export is the same on macOS, Windows, and Linux: zooms, backgrounds, motion blur, crop/trim/speed, blur regions, annotations, auto-captions, projects, export, and all languages. The differences are in **capture**, where macOS and Windows use a native pipeline that Linux doesn't have:
- **macOS**: Requires macOS 13+. On macOS 14.2+ you'll be prompted to grant audio capture permission. macOS 12 and below does not support system audio (mic still works).
- **Windows**: Works out of the box.
- **Linux**: Needs PipeWire (default on Ubuntu 22.04+, Fedora 34+). Older PulseAudio-only setups may not support system audio (mic should still work).
## Built with
- Electron
- React
- TypeScript
- Vite
- PixiJS
- dnd-timeline
- **Native recording**: macOS (ScreenCaptureKit) and Windows (Windows Graphics Capture) record through a native pipeline for higher quality and clean window-level capture. Linux records through the browser pipeline instead.
- **Custom cursors**: on macOS and Windows the real cursor is captured (shape, type, and clicks), which powers the cursor themes, click effects, and editable cursor overlay. On Linux only the cursor position is captured (used for auto-zoom), so those cursor options aren't available.
- **Webcam**: captured natively on macOS and Windows; on Linux it's recorded through the browser, but still works as a picture-in-picture overlay.
- **System audio** support varies by OS:
- **macOS**: requires macOS 13+. On macOS 14.2+ you'll be prompted to grant audio capture permission. macOS 12 and below can't capture system audio (mic still works).
- **Windows**: works out of the box.
- **Linux**: needs PipeWire (default on Ubuntu 22.04+, Fedora 34+). Older PulseAudio-only setups may not capture system audio (mic should still work).
---
## Documentation
See the documentation here:
[OpenScreen Docs](https://deepwiki.com/siddharthvaddem/openscreen)
Refresh if outdated.
## Contributing
Contributions are welcome - please **include screenshots or a short video** for any UI change or new user-facing feature. If it touches what users see or do, show it. Skip only when it genuinely doesn't apply. PRs that don't follow this will be closed.
## Star History
<a href="https://www.star-history.com/?repos=siddharthvaddem%2Fopenscreen&type=date&legend=top-left">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=siddharthvaddem/openscreen&type=date&theme=dark&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=siddharthvaddem/openscreen&type=date&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=siddharthvaddem/openscreen&type=date&legend=top-left" />
</picture>
</a>
## License
This project is licensed under the [MIT License](./LICENSE). By using this software, you agree that the authors are not liable for any issues, damages, or claims arising from its use.
+183
View File
@@ -0,0 +1,183 @@
# macOS native cursor test pipeline
This document covers manual and diagnostic testing for macOS native cursor capture — the path that records real system cursor bitmaps via `NSCursor.currentSystem` and surfaces them through the OpenScreen editor and export pipeline.
## How the macOS cursor helper works
The helper binary (`openscreen-macos-cursor-helper`) runs as a child process of Electron during recording. It:
- polls `NSCursor.currentSystem` at the configured sample interval
- converts each cursor image to PNG and computes a SHA-256 content hash as a stable asset id
- emits the full base64 bitmap payload **once** per unique cursor shape per session; subsequent samples carry only the `assetId` so stdout stays small
- tracks left-button down/up events via `CGEventTap` and tags each sample with `interactionType`
- uses the Accessibility API to detect `text` and `pointer` affordances (link/button/input roles) when Accessibility is granted; these shapes use the bundled high-quality SVG replacements instead of the raw bitmap
Each sample line is newline-delimited JSON:
```json
{ "type": "ready", "timestampMs": 1234567890, "accessibilityTrusted": true, "mouseTapReady": true }
{ "type": "sample", "timestampMs": 1234567891, "assetId": "a7472...", "asset": { "id": "a7472...", "imageDataUrl": "data:image/png;base64,...", "width": 64, "height": 64, "hotspotX": 16, "hotspotY": 16, "scaleFactor": 2.0 }, "cursorType": null, "leftButtonDown": false, "leftButtonPressed": false, "leftButtonReleased": false }
{ "type": "sample", "timestampMs": 1234567924, "assetId": "a7472...", "cursorType": null, "leftButtonDown": false, "leftButtonPressed": false, "leftButtonReleased": false }
```
`asset` is present only the first time a given `assetId` appears. The TypeScript session (`MacNativeCursorRecordingSession`) collects unique assets into a map and sets `provider: "native"` in the final `CursorRecordingData` when at least one bitmap was captured.
## Build the helper
```bash
npm run build:native:mac
```
This builds both Swift helpers (`openscreen-screencapturekit-helper` and `openscreen-macos-cursor-helper`) and copies them to:
- `electron/native/screencapturekit/build/` — used by the local dev server
- `electron/native/bin/darwin-arm64/` or `darwin-x64/` — used by packaged builds
Requires Xcode (not just Command Line Tools). If you see a build error about missing SDK metadata, run:
```bash
sudo xcode-select --switch /Applications/Xcode.app/Contents/Developer
sudo xcodebuild -license accept
```
## Smoke-test the helper directly
You can run the cursor helper standalone to inspect its raw output before involving the full app:
```bash
BIN=electron/native/screencapturekit/build/openscreen-macos-cursor-helper
("$BIN" '{"sampleIntervalMs":100}' & PID=$!; sleep 2; kill $PID) | head -20
```
Expected first line:
```json
{"type":"ready","mouseTapReady":true,"accessibilityTrusted":false,"timestampMs":...}
```
`accessibilityTrusted: false` is normal in dev/unsigned builds. It means text/pointer affordance detection is disabled; native bitmap capture still works.
Expected sample lines:
```json
{"type":"sample","assetId":"a7472...","asset":{"id":"a7472...","imageDataUrl":"data:image/png;base64,...","width":64,"height":64,"hotspotX":26,"hotspotY":16,"scaleFactor":2.0},...}
{"type":"sample","assetId":"a7472...",...}
```
Move the cursor over a text input while the helper is running and check that a new `assetId` appears with a different bitmap (if Accessibility is granted — see below).
## Point the app at a custom helper binary
```bash
export OPENSCREEN_MAC_CURSOR_HELPER_EXE=/path/to/openscreen-macos-cursor-helper
npm run dev
```
## macOS permissions
Two separate permissions are needed:
| Permission | What it enables | Where to grant |
|---|---|---|
| Screen Recording | ScreenCaptureKit video capture | System Settings → Privacy & Security → Screen & System Audio Recording → Electron ✅ |
| Accessibility | `text` / `pointer` cursor type detection (affordance hints) | System Settings → Privacy & Security → Accessibility → Electron ✅ |
**Screen Recording** is required to record. Without it the recording never starts.
**Accessibility** is optional. Without it, `cursorType` will always be `null` and all cursors render from their captured bitmaps (no SVG substitution). This is the expected fallback and does not degrade cursor quality for non-text/pointer shapes.
After granting either permission in System Settings, **fully quit and relaunch** the dev server — `getMediaAccessStatus` caches the result per-process.
## Manual test checklist
### P0 — core bitmap capture
- [ ] Record a short clip. Open the editor. Confirm the default arrow cursor is the real system arrow (not the bundled SVG approximation).
- [ ] Record while hovering over a web browser. Confirm custom-CSS cursors (e.g. `cursor: grab`, `cursor: crosshair`) appear as their actual shapes.
- [ ] Export to MP4. Confirm the cursor renders correctly in the exported video.
- [ ] Export to GIF. Same check.
### P1 — affordance substitution (requires Accessibility)
- [ ] Grant Accessibility permission and restart the app.
- [ ] Record hovering over a text input field. Confirm the text I-beam uses the bundled SVG version (prettier than the system bitmap).
- [ ] Record hovering over a link/button. Confirm the pointer hand uses the bundled SVG.
### P1 — hotspot alignment (Retina)
- [ ] On a Retina display, record a precise click on a small button. In the editor, confirm the cursor tip aligns with the actual click point. The helper reports `scaleFactor: 2.0`; the renderer divides pixel dimensions and hotspot by this value to recover point sizes.
### P1 — click detection
- [ ] Record several left-clicks. In the editor, confirm the click-bounce animation fires on each click.
- [ ] Confirm `interactionType: "click"` and `"mouseup"` events are present in the recording session sidecar (`cursorRecordingData` inside `<videoPath>.cursor.json`).
### P2 — graceful degradation
- [ ] Remove **both** build-output copies of the helper binary and start a recording. The session should succeed with `provider: "none"` (position-only telemetry, default arrow rendered). Restore both binaries afterward.
```bash
ARCH=$([ "$(uname -m)" = "arm64" ] && echo darwin-arm64 || echo darwin-x64)
mv electron/native/screencapturekit/build/openscreen-macos-cursor-helper /tmp/cursor-helper-build
mv electron/native/bin/$ARCH/openscreen-macos-cursor-helper /tmp/cursor-helper-bin
# ... start recording, then restore:
mv /tmp/cursor-helper-bin electron/native/bin/$ARCH/openscreen-macos-cursor-helper
mv /tmp/cursor-helper-build electron/native/screencapturekit/build/openscreen-macos-cursor-helper
```
- [ ] Revoke Accessibility. Confirm recording still works and cursors render from bitmaps (no SVG substitution).
### P2 — multi-display
- [ ] Move the cursor to a secondary display during recording. Confirm the cursor clips to the canvas edge rather than snapping invisible on fast swipes. Confirm it hides after ≈100 ms of sustained out-of-bounds movement.
### P2 — long recording memory
- [ ] Record for 3–5 minutes while switching between many apps (browser, terminal, editor). The helper should not grow in memory because each iteration drains Cocoa objects via `autoreleasepool`. Check `Activity Monitor` → `openscreen-macos-cursor-helper` RSS stays flat after the first few seconds.
## What a healthy recording looks like
Inspect the cursor sidecar file written alongside the recorded video (`<videoPath>.cursor.json`). For a recording saved to `/tmp/rec.mp4`, the sidecar is `/tmp/rec.mp4.cursor.json`:
```json
{
"version": 2,
"provider": "native",
"assets": [
{ "id": "a7472...", "platform": "darwin", "imageDataUrl": "data:image/png;base64,...", "width": 64, "height": 64, "hotspotX": 26.0, "hotspotY": 16.0, "scaleFactor": 2.0 }
],
"samples": [
{ "timeMs": 0, "cx": 0.42, "cy": 0.38, "visible": true, "assetId": "a7472...", "interactionType": "move" },
...
]
}
```
`provider: "native"` and a non-empty `assets` array confirm bitmap capture is active. If you see `provider: "none"` and `assets: []`, the helper was not found or exited before `ready`.
## Native macOS capture backend
The app routes macOS recordings through the ScreenCaptureKit helper (`openscreen-screencapturekit-helper`) when it is available, so the real system cursor is excluded from the video frame. The cursor position and bitmap are captured separately by the cursor helper and composited in the editor and export pipeline.
Current native availability rules:
- macOS 13 (Ventura) or newer
- `openscreen-screencapturekit-helper` binary is present
- Screen Recording permission is granted
Build both helpers locally:
```bash
npm run build:native:mac
```
For local diagnostics with a custom helper binary, use the environment override:
```bash
export OPENSCREEN_MAC_CURSOR_HELPER_EXE=/path/to/openscreen-macos-cursor-helper
npm run dev
```
## Known limitations
- **Intel (x86\_64) Macs**: the distributed helper is built for `darwin-arm64`. On Intel Macs, you need to build from source with `npm run build:native:mac` on the target machine.
- **Accessibility permission in unsigned/dev builds**: `getMediaAccessStatus("accessibility")` may not reflect the toggle state for unsigned Electron in dev mode. The helper will always probe and report `accessibilityTrusted` in its `ready` event — use that as the authoritative signal.
- **App-defined custom cursors (CGS layer)**: `NSCursor.currentSystem` captures the active AppKit cursor. Cursors set at the CoreGraphics/CGS layer by some games or GPU-accelerated apps may not be visible here. This is a known macOS API limitation.
+16 -3
View File
@@ -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"
}
],
+33 -1
View File
@@ -81,6 +81,12 @@ interface Window {
message?: string;
error?: string;
}>;
openRecordingStream: (fileName: string) => Promise<{ success: boolean; error?: string }>;
appendRecordingChunk: (
fileName: string,
chunk: ArrayBuffer,
) => Promise<{ success: boolean; error?: string }>;
closeRecordingStream: (fileName: string) => Promise<{ success: boolean; error?: string }>;
getRecordedVideoPath: () => Promise<{
success: boolean;
path?: string;
@@ -117,6 +123,14 @@ interface Window {
discarded?: boolean;
error?: string;
}>;
pauseNativeWindowsRecording: () => Promise<{
success: boolean;
error?: string;
}>;
resumeNativeWindowsRecording: () => Promise<{
success: boolean;
error?: string;
}>;
startNativeMacRecording: (
request: import("../src/lib/nativeMacRecording").NativeMacRecordingRequest,
) => Promise<import("../src/lib/nativeMacRecording").NativeMacRecordingStartResult>;
@@ -215,7 +229,7 @@ interface Window {
canceled?: boolean;
error?: string;
}>;
loadProjectFile: () => Promise<{
loadProjectFile: (projectFolder?: string) => Promise<{
success: boolean;
path?: string;
project?: unknown;
@@ -231,6 +245,17 @@ interface Window {
canceled?: boolean;
error?: string;
}>;
getPathForFile: (file: File) => string;
loadProjectFileFromPath: (filePath: string) => Promise<{
success: boolean;
path?: string;
project?: unknown;
message?: string;
canceled?: boolean;
error?: string;
}>;
onMenuNewProject: (callback: () => void) => () => void;
onMenuImportVideo: (callback: () => void) => () => void;
onMenuLoadProject: (callback: () => void) => () => void;
onMenuSaveProject: (callback: () => void) => () => void;
onMenuSaveProjectAs: (callback: () => void) => () => void;
@@ -240,10 +265,17 @@ interface Window {
) => Promise<{ success: boolean; error?: string; message?: string }>;
getShortcuts: () => Promise<Record<string, unknown> | null>;
saveShortcuts: (shortcuts: unknown) => Promise<{ success: boolean; error?: string }>;
updateGlobalShortcut: (binding: {
key: string;
ctrl?: boolean;
shift?: boolean;
alt?: boolean;
}) => Promise<{ success: boolean }>;
hudOverlayHide: () => void;
hudOverlayClose: () => void;
setHudOverlayIgnoreMouseEvents: (ignore: boolean) => void;
moveHudOverlayBy: (deltaX: number, deltaY: number) => void;
setHudOverlaySize: (width: number, height: number) => void;
showCountdownOverlay: (value: number, runId: number) => Promise<void>;
setCountdownOverlayValue: (value: number, runId: number) => Promise<void>;
hideCountdownOverlay: (runId: number) => Promise<void>;
+77
View File
@@ -0,0 +1,77 @@
import fs from "node:fs/promises";
import { globalShortcut } from "electron";
import { type ShortcutBinding } from "../src/lib/shortcuts";
import { SHORTCUTS_FILE } from "./ipc/handlers";
const DEFAULT_OPEN_APP_BINDING: ShortcutBinding = { key: "o", ctrl: true, shift: true };
// Maps KeyboardEvent.key values to Electron accelerator key names
const KEY_TO_ACCELERATOR: Record<string, string> = {
" ": "Space",
"+": "Plus",
"-": "numsub",
"*": "nummult",
"/": "numdiv",
arrowup: "Up",
arrowdown: "Down",
arrowleft: "Left",
arrowright: "Right",
escape: "Escape",
enter: "Return",
backspace: "Backspace",
delete: "Delete",
tab: "Tab",
};
function bindingToAccelerator(binding: ShortcutBinding): string {
const parts: string[] = [];
if (binding.ctrl) parts.push("CommandOrControl");
if (binding.shift) parts.push("Shift");
if (binding.alt) parts.push("Alt");
const keyLower = binding.key.toLowerCase();
const acceleratorKey = KEY_TO_ACCELERATOR[keyLower] ?? binding.key.toUpperCase();
parts.push(acceleratorKey);
return parts.join("+");
}
let currentAccelerator: string | null = null;
export function registerOpenAppShortcut(binding: ShortcutBinding, onTrigger: () => void): boolean {
const accelerator = bindingToAccelerator(binding);
if (accelerator === currentAccelerator) {
return true;
}
// Register the new shortcut before unregistering the old, so a failure leaves the old binding intact
const success = globalShortcut.register(accelerator, onTrigger);
if (success) {
if (currentAccelerator) {
globalShortcut.unregister(currentAccelerator);
}
currentAccelerator = accelerator;
console.log(`Global shortcut registered: ${accelerator}`);
} else {
console.warn(`Failed to register global shortcut: ${accelerator}`);
}
return success;
}
export async function loadAndRegisterGlobalShortcut(onTrigger: () => void): Promise<void> {
try {
const data = await fs.readFile(SHORTCUTS_FILE, "utf-8");
const shortcuts = JSON.parse(data);
const binding = shortcuts.openApp || DEFAULT_OPEN_APP_BINDING;
registerOpenAppShortcut(binding, onTrigger);
} catch {
registerOpenAppShortcut(DEFAULT_OPEN_APP_BINDING, onTrigger);
}
}
export function unregisterAllGlobalShortcuts(): void {
globalShortcut.unregisterAll();
}
+268 -46
View File
@@ -40,20 +40,29 @@ import { RECORDINGS_DIR } from "../main";
import { createCursorRecordingSession } from "../native-bridge/cursor/recording/factory";
import { requestMacCursorAccessibilityAccess } from "../native-bridge/cursor/recording/macNativeCursorRecordingSession";
import type { CursorRecordingSession } from "../native-bridge/cursor/recording/session";
import { patchWebmDurationOnDisk } from "../recording/webm-duration";
import { registerNativeBridgeHandlers } from "./nativeBridge";
import { RecordingStreamRegistry, registerRecordingStreamHandlers } from "./recordingStream";
const PROJECT_FILE_EXTENSION = "openscreen";
const SHORTCUTS_FILE = path.join(app.getPath("userData"), "shortcuts.json");
export const SHORTCUTS_FILE = path.join(app.getPath("userData"), "shortcuts.json");
const RECORDING_FILE_PREFIX = "recording-";
const RECORDING_SESSION_SUFFIX = ".session.json";
const ALLOWED_IMPORT_VIDEO_EXTENSIONS = new Set([".webm", ".mp4", ".mov", ".avi", ".mkv"]);
const ALLOWED_IMPORT_VIDEO_EXTENSIONS = new Set([
".webm",
".mp4",
".mov",
".avi",
".mkv",
".m4v",
".wmv",
".flv",
".ts",
]);
const PREVIEW_AUDIO_DIR = path.join(app.getPath("userData"), "preview-audio");
const nativeMacCaptureEvents = new EventEmitter();
/**
* Paths explicitly approved by the user via file picker dialogs or project loads.
* These are added at runtime when the user selects files from outside the default directories.
*/
// Paths the user approved via file picker or project load (i.e. outside the default dirs).
const approvedPaths = new Set<string>();
function approveFilePath(filePath: string): void {
@@ -89,10 +98,7 @@ function resolveApprovedVideoPath(videoPath?: string | null): string | null {
return normalizedPath;
}
/**
* Helper function to build dialog options with a parent window only when it's valid.
* This prevents passing stale or destroyed BrowserWindow references to dialog calls.
*/
// Attach the parent window only when valid, to avoid passing a destroyed BrowserWindow to dialogs.
function buildDialogOptions<T extends Electron.OpenDialogOptions | Electron.SaveDialogOptions>(
baseOptions: T,
parentWindow: BrowserWindow | null,
@@ -221,9 +227,8 @@ async function approveReadableVideoPath(
return null;
}
// When called with trustedDirs (e.g. from project load), only auto-approve
// paths within those directories. This prevents malicious project files from
// approving reads to arbitrary filesystem locations.
// With trustedDirs (e.g. project load), only auto-approve paths inside them so a
// malicious project file can't approve reads to arbitrary locations.
if (trustedDirs) {
const resolved = path.resolve(normalizedPath);
const withinTrusted = trustedDirs.some((dir) => isPathWithinDir(resolved, dir));
@@ -265,6 +270,28 @@ function resolveRecordingOutputPath(fileName: string): string {
return path.join(RECORDINGS_DIR, parsedPath.base);
}
function isValidDurationMs(value: number | undefined): value is number {
return typeof value === "number" && Number.isFinite(value) && value > 0;
}
/**
* Finalize one recording file: flush/close the stream if it was streamed, else write
* the buffered bytes (short recording or stream failed to open). Returns whether it was
* streamed, so the caller knows if the WebM duration needs patching on disk.
*/
async function finalizeRecordingFile(
registry: RecordingStreamRegistry,
fileName: string,
filePath: string,
videoData?: ArrayBuffer,
): Promise<boolean> {
const streamed = await registry.finalize(fileName);
if (!streamed && videoData && videoData.byteLength > 0) {
await fs.writeFile(filePath, Buffer.from(videoData));
}
return streamed;
}
async function getApprovedProjectSession(
project: unknown,
projectFilePath?: string,
@@ -286,8 +313,8 @@ async function getApprovedProjectSession(
return null;
}
// Only auto-approve media paths within the project's directory or RECORDINGS_DIR.
// This prevents crafted project files from approving reads to arbitrary locations.
// Only auto-approve media within the project's dir or RECORDINGS_DIR, so a crafted
// project file can't approve reads to arbitrary locations.
const trustedDirs = [RECORDINGS_DIR];
if (projectFilePath) {
trustedDirs.push(path.dirname(path.resolve(projectFilePath)));
@@ -330,10 +357,7 @@ let lastEnumeratedSources = new Map<string, DesktopCapturerSource>();
let currentProjectPath: string | null = null;
let currentRecordingSession: RecordingSession | null = null;
/**
* Returns the cached DesktopCapturerSource set when the user picked a source.
* Used by setDisplayMediaRequestHandler in main.ts for cursor-free capture.
*/
// Cached source from the user's pick. Used by setDisplayMediaRequestHandler in main.ts for cursor-free capture.
export function getSelectedDesktopSource(): DesktopCapturerSource | null {
return selectedDesktopSource;
}
@@ -384,6 +408,10 @@ let nativeWindowsCaptureWebcamTargetPath: string | null = null;
let nativeWindowsCaptureRecordingId: number | null = null;
let nativeWindowsCursorOffsetMs = 0;
let nativeWindowsCursorCaptureMode: CursorCaptureMode = "editable-overlay";
let nativeWindowsCursorRecordingStartMs = 0;
let nativeWindowsPauseStartedAtMs: number | null = null;
let nativeWindowsPauseRanges: Array<{ startMs: number; endMs: number }> = [];
let nativeWindowsIsPaused = false;
const NATIVE_WINDOWS_CAPTURE_STOP_TIMEOUT_MS = 15_000;
let nativeMacCaptureProcess: ChildProcessWithoutNullStreams | null = null;
let nativeMacCaptureOutput = "";
@@ -873,6 +901,18 @@ function completeNativeMacCursorPauseRange(endMs = Date.now()) {
nativeMacPauseStartedAtMs = null;
}
function completeNativeWindowsCursorPauseRange(endMs = Date.now()) {
if (nativeWindowsPauseStartedAtMs === null || nativeWindowsCursorRecordingStartMs <= 0) {
return;
}
nativeWindowsPauseRanges.push({
startMs: Math.max(0, nativeWindowsPauseStartedAtMs - nativeWindowsCursorRecordingStartMs),
endMs: Math.max(0, endMs - nativeWindowsCursorRecordingStartMs),
});
nativeWindowsPauseStartedAtMs = null;
}
function waitForNativeWindowsCaptureStart(proc: ChildProcessWithoutNullStreams) {
return new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => {
@@ -1238,7 +1278,7 @@ export function registerIpcHandlers(
return { success: true, granted: true, status };
}
// Screen recording has no askForMediaAccess equivalent. Trigger the
// Screen recording has no askForMediaAccess equivalent, so trigger the
// TCC prompt without opening OpenScreen's source selector above it.
if (status === "not-determined") {
const mainWin = getMainWindow();
@@ -1344,7 +1384,36 @@ export function registerIpcHandlers(
});
ipcMain.handle("request-native-mac-cursor-access", async () => {
return requestMacCursorAccessibilityAccess();
const access = await requestMacCursorAccessibilityAccess();
// When the editable cursor can't get Accessibility trust, pop a native dialog
// that deep-links to the Accessibility pane (mirrors the Screen Recording flow).
if (process.platform === "darwin" && !access.granted) {
const mainWin = getMainWindow();
const detail =
access.status === "missing-helper"
? "The cursor helper couldn't be found in this build, so the editable cursor can't be enabled. Rebuild the native helper (npm run build:native:mac) or switch the HUD cursor mode to system."
: "Allow OpenScreen under System Settings → Privacy & Security → Accessibility, then press record again to start the countdown.";
const messageOptions = {
type: "warning",
buttons: ["Open Accessibility Settings", "Cancel"],
defaultId: 0,
cancelId: 1,
message: "Accessibility access is required for the editable cursor",
detail,
} satisfies Electron.MessageBoxOptions;
const result =
mainWin && !mainWin.isDestroyed()
? await dialog.showMessageBox(mainWin, messageOptions)
: await dialog.showMessageBox(messageOptions);
if (result.response === 0) {
await shell.openExternal(
"x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility",
);
}
}
return access;
});
ipcMain.handle("open-source-selector", async () => {
@@ -1388,10 +1457,9 @@ export function registerIpcHandlers(
});
ipcMain.handle("switch-to-editor", () => {
const mainWin = getMainWindow();
if (mainWin) {
mainWin.close();
}
// createEditorWindow already closes the current mainWindow (the HUD) before
// opening the editor. Closing it here too double-closes, leaving ghost
// transparent windows and compounding the HUD shadow each cycle.
createEditorWindow();
});
@@ -1411,16 +1479,18 @@ export function registerIpcHandlers(
return;
}
if (!overlayWindow.isVisible()) {
overlayWindow.showInactive();
}
// Wait for the first frame before showing, else Chromium flashes a black
// rectangle because it hasn't rendered any pixels yet.
if (overlayWindow.webContents.isLoading()) {
await new Promise<void>((resolve) => {
overlayWindow.webContents.once("did-finish-load", () => resolve());
overlayWindow.once("ready-to-show", resolve);
});
}
if (!overlayWindow.isVisible()) {
overlayWindow.showInactive();
}
overlayWindow.webContents.send("countdown-overlay-value", value, runId);
});
@@ -1583,9 +1653,14 @@ export function registerIpcHandlers(
nativeWindowsCaptureRecordingId = recordingId;
nativeWindowsCursorOffsetMs = 0;
nativeWindowsCursorCaptureMode = cursorCaptureMode;
nativeWindowsCursorRecordingStartMs = 0;
nativeWindowsPauseStartedAtMs = null;
nativeWindowsPauseRanges = [];
nativeWindowsIsPaused = false;
const cursorStartTimeMs = Date.now();
if (cursorCaptureMode === "editable-overlay") {
nativeWindowsCursorRecordingStartMs = cursorStartTimeMs;
await startCursorRecording(cursorStartTimeMs);
console.info("[native-wgc] cursor sampler ready", {
cursorStartTimeMs,
@@ -1635,6 +1710,10 @@ export function registerIpcHandlers(
nativeWindowsCaptureRecordingId = null;
nativeWindowsCursorOffsetMs = 0;
nativeWindowsCursorCaptureMode = "editable-overlay";
nativeWindowsCursorRecordingStartMs = 0;
nativeWindowsPauseStartedAtMs = null;
nativeWindowsPauseRanges = [];
nativeWindowsIsPaused = false;
await stopCursorRecording();
return { success: false, error: String(error) };
}
@@ -1836,6 +1915,50 @@ export function registerIpcHandlers(
}
});
ipcMain.handle("pause-native-windows-recording", async () => {
const proc = nativeWindowsCaptureProcess;
if (!proc) {
return { success: false, error: "Native Windows capture is not running." };
}
if (nativeWindowsIsPaused) {
return { success: true };
}
if (!proc.stdin.writable) {
return { success: false, error: "Native Windows capture command channel is closed." };
}
try {
proc.stdin.write("pause\n");
nativeWindowsIsPaused = true;
nativeWindowsPauseStartedAtMs = Date.now();
return { success: true };
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) };
}
});
ipcMain.handle("resume-native-windows-recording", async () => {
const proc = nativeWindowsCaptureProcess;
if (!proc) {
return { success: false, error: "Native Windows capture is not running." };
}
if (!nativeWindowsIsPaused) {
return { success: true };
}
if (!proc.stdin.writable) {
return { success: false, error: "Native Windows capture command channel is closed." };
}
try {
proc.stdin.write("resume\n");
completeNativeWindowsCursorPauseRange();
nativeWindowsIsPaused = false;
return { success: true };
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) };
}
});
ipcMain.handle("stop-native-windows-recording", async (_, discard?: boolean) => {
const proc = nativeWindowsCaptureProcess;
const preferredPath = nativeWindowsCaptureTargetPath;
@@ -1848,6 +1971,7 @@ export function registerIpcHandlers(
}
try {
completeNativeWindowsCursorPauseRange();
const stoppedPathPromise = waitForNativeWindowsCaptureStop(proc);
proc.stdin.write("stop\n");
const stoppedPath = await stoppedPathPromise;
@@ -1872,6 +1996,7 @@ export function registerIpcHandlers(
}
if (cursorCaptureMode === "editable-overlay") {
compactPendingCursorTelemetryPauseRanges(nativeWindowsPauseRanges);
shiftPendingCursorTelemetry(nativeWindowsCursorOffsetMs);
await writePendingCursorTelemetry(screenVideoPath);
}
@@ -1913,6 +2038,10 @@ export function registerIpcHandlers(
nativeWindowsCaptureRecordingId = null;
nativeWindowsCursorOffsetMs = 0;
nativeWindowsCursorCaptureMode = "editable-overlay";
nativeWindowsCursorRecordingStartMs = 0;
nativeWindowsPauseStartedAtMs = null;
nativeWindowsPauseRanges = [];
nativeWindowsIsPaused = false;
const source = selectedSource || { name: "Screen" };
if (onRecordingStateChange) {
onRecordingStateChange(false, source.name);
@@ -2066,6 +2195,11 @@ export function registerIpcHandlers(
},
);
// On-disk write streams for in-progress recordings, keyed by output file name.
// Chunks append as they arrive so the renderer never buffers the full video (#616).
const recordingStreams = new RecordingStreamRegistry();
registerRecordingStreamHandlers(ipcMain, recordingStreams, resolveRecordingOutputPath);
ipcMain.handle("store-recorded-session", async (_, payload: StoreRecordedSessionInput) => {
try {
return await storeRecordedSessionFiles(payload);
@@ -2086,12 +2220,37 @@ export function registerIpcHandlers(
: Date.now();
const cursorCaptureMode = normalizeCursorCaptureMode(payload.cursorCaptureMode);
const screenVideoPath = resolveRecordingOutputPath(payload.screen.fileName);
await fs.writeFile(screenVideoPath, Buffer.from(payload.screen.videoData));
const screenStreamed = await finalizeRecordingFile(
recordingStreams,
payload.screen.fileName,
screenVideoPath,
payload.screen.videoData,
);
let webcamVideoPath: string | undefined;
let webcamStreamed = false;
if (payload.webcam) {
webcamVideoPath = resolveRecordingOutputPath(payload.webcam.fileName);
await fs.writeFile(webcamVideoPath, Buffer.from(payload.webcam.videoData));
webcamStreamed = await finalizeRecordingFile(
recordingStreams,
payload.webcam.fileName,
webcamVideoPath,
payload.webcam.videoData,
);
}
// Streamed files lack the WebM Duration header (renderer no longer holds the
// blob), so patch on disk for the editor's seek bar and timeline. Best-effort,
// independent per file, so they run together.
if (isValidDurationMs(payload.durationMs)) {
const patches: Promise<unknown>[] = [];
if (screenStreamed) {
patches.push(patchWebmDurationOnDisk(screenVideoPath, payload.durationMs));
}
if (webcamStreamed && webcamVideoPath) {
patches.push(patchWebmDurationOnDisk(webcamVideoPath, payload.durationMs));
}
await Promise.all(patches);
}
const session: RecordingSession = webcamVideoPath
@@ -2213,9 +2372,8 @@ export function registerIpcHandlers(
? [{ name: mainT("dialogs", "fileDialogs.gifImage"), extensions: ["gif"] }]
: [{ name: mainT("dialogs", "fileDialogs.mp4Video"), extensions: ["mp4"] }];
// Prefer the user's last export folder if it still exists, otherwise fall
// back to ~/Downloads. Validation must happen here because the renderer
// can't stat the filesystem.
// Prefer the user's last export folder if it still exists, else ~/Downloads.
// Validate here because the renderer can't stat the filesystem.
let defaultDir = app.getPath("downloads");
if (exportFolder) {
try {
@@ -2260,8 +2418,8 @@ export function registerIpcHandlers(
ipcMain.handle("write-export-to-path", async (_, videoData: ArrayBuffer, filePath: string) => {
try {
// Sanity-check the path. The renderer is trusted (contextIsolation is on),
// but a stale state bug shouldn't be able to clobber arbitrary files.
// Sanity-check the path: the renderer is trusted (contextIsolation on), but a
// stale-state bug shouldn't be able to clobber arbitrary files.
if (typeof filePath !== "string" || !path.isAbsolute(filePath)) {
return { success: false, message: "Invalid path" };
}
@@ -2298,7 +2456,7 @@ export function registerIpcHandlers(
filters: [
{
name: mainT("dialogs", "fileDialogs.videoFiles"),
extensions: ["webm", "mp4", "mov", "avi", "mkv"],
extensions: ["webm", "mp4", "mov", "avi", "mkv", "m4v", "wmv", "flv", "ts"],
},
{ name: mainT("dialogs", "fileDialogs.allFiles"), extensions: ["*"] },
],
@@ -2337,14 +2495,13 @@ export function registerIpcHandlers(
ipcMain.handle("reveal-in-folder", async (_, filePath: string) => {
try {
// shell.showItemInFolder doesn't return a value, it throws on error
// showItemInFolder returns nothing, it throws on error
shell.showItemInFolder(filePath);
return { success: true };
} catch (error) {
console.error(`Error revealing item in folder: ${filePath}`, error);
// Fallback to open the directory if revealing the item fails
// This might happen if the file was moved or deleted after export,
// or if the path is somehow invalid for showItemInFolder
// Fall back to opening the directory if revealing fails (file moved/deleted
// after export, or a path showItemInFolder rejects).
try {
const openPathResult = await shell.openPath(path.dirname(filePath));
if (openPathResult) {
@@ -2477,16 +2634,34 @@ export function registerIpcHandlers(
}
}
ipcMain.handle("load-project-file", async () => {
return loadProjectFile();
ipcMain.handle("load-project-file", async (_, projectFolder?: string) => {
return loadProjectFile(projectFolder);
});
async function loadProjectFile(): Promise<ProjectFileResult> {
async function loadProjectFile(projectFolder?: string): Promise<ProjectFileResult> {
try {
// Prefer the user's last opened-project folder if it still exists, else
// RECORDINGS_DIR. Validate here because the renderer can't stat the filesystem.
let defaultDir = RECORDINGS_DIR;
if (projectFolder) {
try {
const stats = await fs.stat(projectFolder);
if (stats.isDirectory()) {
defaultDir = projectFolder;
}
} catch (err) {
// Stat can fail if the folder was moved/deleted (expected) or on a
// permission error (worth surfacing). We fall back either way, but log it.
console.warn(
`Could not access remembered project folder "${projectFolder}", falling back to RECORDINGS_DIR:`,
err,
);
}
}
const dialogOptions = buildDialogOptions(
{
title: mainT("dialogs", "fileDialogs.openProject"),
defaultPath: RECORDINGS_DIR,
defaultPath: defaultDir,
filters: [
{
name: mainT("dialogs", "fileDialogs.openscreenProject"),
@@ -2526,6 +2701,50 @@ export function registerIpcHandlers(
}
}
ipcMain.handle("load-project-file-from-path", async (_event, filePath: string) => {
return loadProjectFileFromPath(filePath);
});
async function loadProjectFileFromPath(filePath: string): Promise<ProjectFileResult> {
try {
if (!filePath || typeof filePath !== "string") {
return { success: false, message: "Invalid file path" };
}
// Validate extension and readability
if (path.extname(filePath).toLowerCase() !== `.${PROJECT_FILE_EXTENSION}`) {
return { success: false, message: "Not an Openscreen project file" };
}
const stats = await fs.stat(filePath).catch(() => null);
if (!stats?.isFile()) {
return { success: false, message: "File not found" };
}
const content = await fs.readFile(filePath, "utf-8");
const project = JSON.parse(content);
currentProjectPath = filePath;
// Approve session paths but tolerate failures (e.g. video moved outside trusted
// dirs) so the project still loads and the renderer can show "video not found".
let session: import("../../src/lib/recordingSession").RecordingSession | null = null;
try {
session = await getApprovedProjectSession(project, filePath);
} catch (sessionError) {
console.warn(
"[loadProjectFileFromPath] Could not approve session paths, proceeding without session:",
sessionError,
);
}
setCurrentRecordingSessionState(session);
return { success: true, path: filePath, project };
} catch (error) {
console.error("Failed to load project file from path:", error);
return {
success: false,
message: "Failed to load project file",
error: String(error),
};
}
}
ipcMain.handle("load-current-project-file", async () => {
return loadCurrentProjectFile();
});
@@ -2608,6 +2827,8 @@ export function registerIpcHandlers(
function clearCurrentVideoPath(): ProjectPathResult {
currentVideoPath = null;
currentProjectPath = null;
setCurrentRecordingSessionState(null);
return { success: true };
}
@@ -2682,6 +2903,7 @@ export function registerIpcHandlers(
saveProjectFile,
loadProjectFile,
loadCurrentProjectFile,
loadProjectFileFromPath,
setCurrentVideoPath,
getCurrentVideoPathResult,
clearCurrentVideoPath,
+12 -2
View File
@@ -25,8 +25,9 @@ export interface NativeBridgeContext {
suggestedName?: string,
existingProjectPath?: string,
) => Promise<ProjectFileResult>;
loadProjectFile: () => Promise<ProjectFileResult>;
loadProjectFile: (projectFolder?: string) => Promise<ProjectFileResult>;
loadCurrentProjectFile: () => Promise<ProjectFileResult>;
loadProjectFileFromPath: (path: string) => Promise<ProjectFileResult>;
setCurrentVideoPath: (path: string) => ProjectPathResult | Promise<ProjectPathResult>;
getCurrentVideoPathResult: () => ProjectPathResult;
clearCurrentVideoPath: () => ProjectPathResult;
@@ -100,6 +101,7 @@ export function registerNativeBridgeHandlers(context: NativeBridgeContext) {
saveProjectFile: context.saveProjectFile,
loadProjectFile: context.loadProjectFile,
loadCurrentProjectFile: context.loadCurrentProjectFile,
loadProjectFileFromPath: context.loadProjectFileFromPath,
setCurrentVideoPath: context.setCurrentVideoPath,
getCurrentVideoPathResult: context.getCurrentVideoPathResult,
clearCurrentVideoPath: context.clearCurrentVideoPath,
@@ -162,12 +164,20 @@ export function registerNativeBridgeHandlers(context: NativeBridgeContext) {
),
);
case "loadProjectFile":
return createSuccessResponse(requestId, await projectService.loadProjectFile());
return createSuccessResponse(
requestId,
await projectService.loadProjectFile(request.payload?.projectFolder),
);
case "loadCurrentProjectFile":
return createSuccessResponse(
requestId,
await projectService.loadCurrentProjectFile(),
);
case "loadProjectFileFromPath":
return createSuccessResponse(
requestId,
await projectService.loadProjectFileFromPath(request.payload.path),
);
case "setCurrentVideoPath":
return createSuccessResponse(
requestId,
+84
View File
@@ -0,0 +1,84 @@
import { mkdtemp, readFile, rm, stat } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { RecordingStreamRegistry } from "./recordingStream";
describe("RecordingStreamRegistry", () => {
let dir: string;
const pathFor = (name: string) => path.join(dir, name);
beforeEach(async () => {
dir = await mkdtemp(path.join(tmpdir(), "openscreen-stream-"));
});
afterEach(async () => {
await rm(dir, { recursive: true, force: true });
});
it("streams chunks to disk in order and reports streamed on finalize", async () => {
const registry = new RecordingStreamRegistry();
await registry.open("rec.webm", pathFor("rec.webm"));
await registry.append("rec.webm", Buffer.from("hello "));
await registry.append("rec.webm", Buffer.from("world"));
const streamed = await registry.finalize("rec.webm");
expect(streamed).toBe(true);
expect(await readFile(pathFor("rec.webm"), "utf8")).toBe("hello world");
// A second finalize has nothing to close.
expect(await registry.finalize("rec.webm")).toBe(false);
});
it("reports not-streamed when no stream was opened", async () => {
const registry = new RecordingStreamRegistry();
expect(await registry.finalize("missing.webm")).toBe(false);
expect(registry.has("missing.webm")).toBe(false);
});
it("rejects open when the target path is not writable (open is awaited, not assumed)", async () => {
const registry = new RecordingStreamRegistry();
// Parent directory does not exist, so createWriteStream emits 'error' on open.
await expect(
registry.open("rec.webm", path.join(dir, "does-not-exist", "rec.webm")),
).rejects.toThrow();
// A failed open must not register a stream the renderer would treat as live.
expect(registry.has("rec.webm")).toBe(false);
});
it("rejects append when no stream is open", async () => {
const registry = new RecordingStreamRegistry();
await expect(registry.append("rec.webm", Buffer.from("x"))).rejects.toThrow(
/No active recording stream/,
);
});
it("discard closes the stream and removes the partial file", async () => {
const registry = new RecordingStreamRegistry();
await registry.open("rec.webm", pathFor("rec.webm"));
await registry.append("rec.webm", Buffer.from("partial"));
await registry.discard("rec.webm", pathFor("rec.webm"));
expect(registry.has("rec.webm")).toBe(false);
await expect(stat(pathFor("rec.webm"))).rejects.toThrow();
// Nothing left to finalize after a discard.
expect(await registry.finalize("rec.webm")).toBe(false);
});
it("discard tolerates a missing file", async () => {
const registry = new RecordingStreamRegistry();
await expect(registry.discard("never.webm", pathFor("never.webm"))).resolves.toBeUndefined();
});
it("opening the same file twice replaces the prior stream", async () => {
const registry = new RecordingStreamRegistry();
await registry.open("rec.webm", pathFor("rec.webm"));
await registry.append("rec.webm", Buffer.from("first"));
await registry.open("rec.webm", pathFor("rec.webm"));
await registry.append("rec.webm", Buffer.from("second"));
await registry.finalize("rec.webm");
expect(await readFile(pathFor("rec.webm"), "utf8")).toBe("second");
});
});
+139
View File
@@ -0,0 +1,139 @@
import { createWriteStream, type WriteStream } from "node:fs";
import { unlink } from "node:fs/promises";
import type { IpcMain } from "electron";
/**
* Owns write streams for in-progress recordings, keyed by output file name.
* MediaRecorder chunks are appended as they arrive so a long recording never
* buffers the whole video in the renderer (#616 fix). File name is the key
* because it's already exchanged across IPC and is unique per recording.
*/
export class RecordingStreamRegistry {
private readonly streams = new Map<string, WriteStream>();
/**
* Open a write stream, resolving only on the `open` event so a bad path or
* permission error rejects here instead of becoming a silent chunk drop later,
* letting the renderer's fallback take over.
*/
async open(fileName: string, filePath: string): Promise<void> {
await this.endStream(fileName);
const ws = createWriteStream(filePath, { flags: "w" });
await new Promise<void>((resolve, reject) => {
const onError = (error: Error) => reject(error);
ws.once("error", onError);
ws.once("open", () => {
ws.removeListener("error", onError);
resolve();
});
});
// Keep a lifetime listener so a late error logs instead of crashing the main
// process with an unhandled 'error'. Per-write failures still surface in `append`.
ws.on("error", (error) => {
console.error(`[recording-stream] ${fileName}:`, error);
});
this.streams.set(fileName, ws);
}
has(fileName: string): boolean {
return this.streams.has(fileName);
}
/** Append a chunk; rejects if no stream is open or the write fails. */
async append(fileName: string, chunk: Buffer): Promise<void> {
const ws = this.streams.get(fileName);
if (!ws) {
throw new Error(`No active recording stream for ${fileName}`);
}
await new Promise<void>((resolve, reject) => {
ws.write(chunk, (error) => (error ? reject(error) : resolve()));
});
}
/**
* Flush and close the stream, keeping the file. Returns true if a stream was
* open (streamed to disk) or false if the caller still needs to write its buffer.
*/
async finalize(fileName: string): Promise<boolean> {
const ws = this.streams.get(fileName);
if (!ws) {
return false;
}
this.streams.delete(fileName);
await new Promise<void>((resolve, reject) => {
ws.end((error?: Error | null) => (error ? reject(error) : resolve()));
});
return true;
}
/**
* Close the stream (if any) and delete the partial file, so a discarded or
* failed recording doesn't leak descriptors or orphan partial files on disk.
*/
async discard(fileName: string, filePath: string): Promise<void> {
await this.endStream(fileName);
await unlink(filePath).catch(() => undefined);
}
private async endStream(fileName: string): Promise<void> {
const ws = this.streams.get(fileName);
if (!ws) {
return;
}
this.streams.delete(fileName);
await new Promise<void>((resolve) => ws.end(() => resolve()));
}
}
/**
* Register the streaming IPC handlers. Thin wrappers that translate the
* registry's throw-on-failure contract into the `{ success, error }` shape the
* renderer expects.
*/
export function registerRecordingStreamHandlers(
ipcMain: IpcMain,
registry: RecordingStreamRegistry,
resolveRecordingOutputPath: (fileName: string) => string,
): void {
ipcMain.handle(
"open-recording-stream",
async (_, fileName: string): Promise<{ success: boolean; error?: string }> => {
try {
await registry.open(fileName, resolveRecordingOutputPath(fileName));
return { success: true };
} catch (error) {
return { success: false, error: String(error) };
}
},
);
ipcMain.handle(
"append-recording-chunk",
async (
_,
fileName: string,
chunk: ArrayBuffer,
): Promise<{ success: boolean; error?: string }> => {
try {
await registry.append(fileName, Buffer.from(chunk));
return { success: true };
} catch (error) {
return { success: false, error: String(error) };
}
},
);
ipcMain.handle(
"close-recording-stream",
async (_, fileName: string): Promise<{ success: boolean; error?: string }> => {
try {
await registry.discard(fileName, resolveRecordingOutputPath(fileName));
return { success: true };
} catch (error) {
return { success: false, error: String(error) };
}
},
);
}
+39 -23
View File
@@ -11,6 +11,12 @@ import {
systemPreferences,
Tray,
} from "electron";
import { ShortcutBinding } from "../src/lib/shortcuts";
import {
loadAndRegisterGlobalShortcut,
registerOpenAppShortcut,
unregisterAllGlobalShortcuts,
} from "./globalShortcut";
import { mainT, setMainLocale } from "./i18n";
import { getSelectedDesktopSource, registerIpcHandlers } from "./ipc/handlers";
import {
@@ -22,15 +28,14 @@ import {
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// Use Screen & System Audio Recording permissions instead of CoreAudio Tap API on macOS.
// CoreAudio Tap requires NSAudioCaptureUsageDescription in the parent app's Info.plist,
// which doesn't work when running from a terminal/IDE during development, makes my life easier
// Use Screen & System Audio Recording permissions instead of the CoreAudio Tap API on macOS.
// Tap needs NSAudioCaptureUsageDescription in the parent app's Info.plist, which breaks when
// running from a terminal/IDE during dev.
if (process.platform === "darwin") {
app.commandLine.appendSwitch("disable-features", "MacCatapLoopbackAudioForScreenShare");
}
// Enable Wayland support for proper screen capture and window management
// on Wayland compositors (Hyprland, GNOME, KDE, etc.)
// Wayland support for screen capture and window management on Wayland compositors.
if (process.platform === "linux") {
const isWayland =
process.env.XDG_SESSION_TYPE === "wayland" || process.env.WAYLAND_DISPLAY !== undefined;
@@ -108,7 +113,7 @@ function isEditorWindow(window: BrowserWindow) {
}
function sendEditorMenuAction(
channel: "menu-load-project" | "menu-save-project" | "menu-save-project-as",
channel: "menu-load-project" | "menu-save-project" | "menu-save-project-as" | "menu-new-project",
) {
let targetWindow = BrowserWindow.getFocusedWindow() ?? mainWindow;
@@ -167,6 +172,12 @@ function setupApplicationMenu() {
{
label: mainT("common", "actions.file") || "File",
submenu: [
{
label: mainT("dialogs", "unsavedChanges.newProject") || "New Project",
accelerator: "CmdOrCtrl+N",
click: () => sendEditorMenuAction("menu-new-project"),
},
{ type: "separator" as const },
{
label: mainT("dialogs", "unsavedChanges.loadProject") || "Load Project…",
accelerator: "CmdOrCtrl+O",
@@ -372,7 +383,7 @@ function createEditorWindowWrapper() {
const windowToClose = mainWindow;
if (!windowToClose || windowToClose.isDestroyed()) return;
// Ask renderer to show the custom in-app dialog
// Ask renderer to show the in-app close dialog.
windowToClose.webContents.send("request-close-confirm");
ipcMain.once("close-confirm-response", (event, choice: "save" | "discard" | "cancel") => {
@@ -381,7 +392,7 @@ function createEditorWindowWrapper() {
if (!windowToClose || windowToClose.isDestroyed()) return;
if (choice === "save") {
// Tell renderer to save the project, then close when done
// Save first, then close when the renderer reports done.
windowToClose.webContents.send("request-save-before-close");
ipcMain.once("save-before-close-done", (event, shouldClose: boolean) => {
if (event.sender.id !== windowToClose?.webContents.id) return;
@@ -416,16 +427,14 @@ function createCountdownOverlayWindowWrapper() {
return countdownOverlayWindow;
}
// Closing every window quits the app entirely (tray icon goes too).
// The in-app "Return to Recorder" button covers the editor → HUD round-trip,
// so closing the last window is an explicit "I'm done" signal.
// Closing every window quits the app (tray goes too). The in-app "Return to Recorder"
// button covers the editor-to-HUD round-trip, so closing the last window means "I'm done".
app.on("window-all-closed", () => {
app.quit();
});
app.on("activate", () => {
// On OS X it's common to re-create a window in the app when the
// dock icon is clicked and there are no other windows open.
// On macOS, re-open a window when the dock icon is clicked and none are open.
const hasVisibleWindow = BrowserWindow.getAllWindows().some((window) => {
if (window.isDestroyed() || !window.isVisible()) {
return false;
@@ -440,16 +449,18 @@ app.on("activate", () => {
}
});
// Register all IPC handlers when app is ready
app.on("will-quit", () => {
unregisterAllGlobalShortcuts();
});
app.whenReady().then(async () => {
// Force the app into "regular" activation policy so the Dock icon appears.
// The HUD overlay (transparent + frameless + skipTaskbar) is the first
// window we open, and AppKit otherwise classifies us as an accessory app.
// Force "regular" activation policy so the Dock icon appears. The HUD overlay
// (transparent, frameless, skipTaskbar) is the first window, and AppKit would
// otherwise classify us as an accessory app.
if (process.platform === "darwin") {
app.dock?.show();
}
// Allow microphone/media/screen permission checks
session.defaultSession.setPermissionCheckHandler((_webContents, permission) => {
const allowed = [
"media",
@@ -492,9 +503,8 @@ app.whenReady().then(async () => {
{ useSystemPicker: false },
);
// Request microphone permission from macOS. Screen Recording is requested
// lazily from the source-picker action so the system prompt is not hidden
// behind OpenScreen's source selector window.
// Request mic permission now. Screen Recording is requested lazily from the
// source-picker action so its prompt isn't hidden behind the selector window.
if (process.platform === "darwin") {
const micStatus = systemPreferences.getMediaAccessStatus("microphone");
if (micStatus !== "granted") {
@@ -502,7 +512,6 @@ app.whenReady().then(async () => {
}
}
// Listen for HUD overlay quit event (macOS only)
ipcMain.on("hud-overlay-close", () => {
app.quit();
});
@@ -512,10 +521,14 @@ app.whenReady().then(async () => {
updateTrayMenu();
});
ipcMain.handle("update-global-shortcut", (_, binding: ShortcutBinding) => {
const success = registerOpenAppShortcut(binding, showMainWindow);
return { success };
});
createTray();
updateTrayMenu();
setupApplicationMenu();
// Ensure recordings directory exists
await ensureRecordingsDir();
function switchToHudWrapper() {
@@ -545,5 +558,8 @@ app.whenReady().then(async () => {
},
switchToHudWrapper,
);
await loadAndRegisterGlobalShortcut(showMainWindow);
createWindow();
});
@@ -36,7 +36,7 @@ export function createCursorRecordingSession(
}
// Linux: capture cursor positions via Electron's `screen` API on an interval.
// No cursor sprites/assets and no clicks — just position telemetry.
// No cursor sprites/assets and no clicks, just position telemetry.
return new TelemetryRecordingSession({
getDisplayBounds: options.getDisplayBounds,
maxSamples: options.maxSamples,
@@ -6,10 +6,21 @@ import { type Rectangle, screen, systemPreferences } from "electron";
import type {
CursorRecordingData,
CursorRecordingSample,
NativeCursorAsset,
NativeCursorType,
} from "../../../../src/native/contracts";
import type { CursorRecordingSession } from "./session";
interface MacCursorAssetPayload {
id: string;
imageDataUrl: string;
width: number;
height: number;
hotspotX: number;
hotspotY: number;
scaleFactor?: number;
}
interface MacNativeCursorRecordingSessionOptions {
getDisplayBounds: () => Rectangle | null;
maxSamples: number;
@@ -28,6 +39,8 @@ type MacCursorEvent =
type: "sample";
timestampMs: number;
cursorType?: NativeCursorType | null;
assetId?: string | null;
asset?: MacCursorAssetPayload | null;
leftButtonDown?: boolean;
leftButtonPressed?: boolean;
leftButtonReleased?: boolean;
@@ -170,6 +183,7 @@ function normalizeCursorType(value: unknown): NativeCursorType | null {
export class MacNativeCursorRecordingSession implements CursorRecordingSession {
private samples: CursorRecordingSample[] = [];
private assets = new Map<string, NativeCursorAsset>();
private process: ChildProcessByStdio<null, Readable, Readable> | null = null;
private lineBuffer = "";
private startTimeMs = 0;
@@ -179,7 +193,7 @@ export class MacNativeCursorRecordingSession implements CursorRecordingSession {
private readyTimer: NodeJS.Timeout | null = null;
private previousLeftButtonDown = false;
private consecutiveOutsideSamples = 0;
// Only hide after this many consecutive out-of-bounds samples (≈100ms at 33ms interval).
// Hide only after this many consecutive out-of-bounds samples (~100ms at 33ms interval).
// Fast swipes that briefly exit the display are clipped by clip-path instead of disappearing.
private static readonly OUTSIDE_HIDE_THRESHOLD = 3;
@@ -187,6 +201,7 @@ export class MacNativeCursorRecordingSession implements CursorRecordingSession {
async start(): Promise<void> {
this.samples = [];
this.assets.clear();
this.lineBuffer = "";
this.startTimeMs = this.options.startTimeMs ?? Date.now();
this.previousLeftButtonDown = false;
@@ -195,7 +210,8 @@ export class MacNativeCursorRecordingSession implements CursorRecordingSession {
try {
systemPreferences.isTrustedAccessibilityClient(true);
} catch {
// Link cursor detection degrades to arrow when Accessibility is unavailable.
// Without Accessibility, text/pointer affordance detection is unavailable;
// bitmaps are still captured natively via NSCursor.
}
const helperPath = findMacCursorHelperPath();
@@ -263,19 +279,38 @@ export class MacNativeCursorRecordingSession implements CursorRecordingSession {
return {
version: 2,
provider: "none",
provider: this.assets.size > 0 ? "native" : "none",
samples: this.samples,
assets: [],
assets: [...this.assets.values()],
};
}
private startPositionOnlyFallback() {
this.captureSample(Date.now(), null, false, false, false);
this.captureSample(Date.now(), null, null, false, false, false);
this.fallbackInterval = setInterval(() => {
this.captureSample(Date.now(), null, false, false, false);
this.captureSample(Date.now(), null, null, false, false, false);
}, this.options.sampleIntervalMs);
}
private rememberAsset(asset: MacCursorAssetPayload | null | undefined) {
if (!asset?.id || this.assets.has(asset.id)) {
return;
}
const cursor = screen.getCursorScreenPoint();
const displayScaleFactor = screen.getDisplayNearestPoint(cursor).scaleFactor;
this.assets.set(asset.id, {
id: asset.id,
platform: "darwin",
imageDataUrl: asset.imageDataUrl,
width: asset.width,
height: asset.height,
hotspotX: asset.hotspotX,
hotspotY: asset.hotspotY,
scaleFactor: asset.scaleFactor ?? displayScaleFactor,
});
}
private handleStdoutChunk(chunk: string) {
this.lineBuffer += chunk;
const lines = this.lineBuffer.split(/\r?\n/);
@@ -299,7 +334,7 @@ export class MacNativeCursorRecordingSession implements CursorRecordingSession {
if (payload.type === "ready") {
if (payload.accessibilityTrusted === false) {
console.warn(
"[cursor-macos] Accessibility is not trusted; cursor shape detection will be arrow-only.",
"[cursor-macos] Accessibility is not trusted; text/pointer affordance detection disabled (bitmap capture still active).",
);
}
this.resolveReady();
@@ -307,9 +342,11 @@ export class MacNativeCursorRecordingSession implements CursorRecordingSession {
}
if (payload.type === "sample") {
this.rememberAsset(payload.asset);
this.captureSample(
payload.timestampMs,
normalizeCursorType(payload.cursorType),
payload.assetId ?? null,
payload.leftButtonDown === true,
payload.leftButtonPressed === true,
payload.leftButtonReleased === true,
@@ -320,6 +357,7 @@ export class MacNativeCursorRecordingSession implements CursorRecordingSession {
private captureSample(
timestampMs: number,
cursorType: NativeCursorType | null,
assetId: string | null,
leftButtonDown: boolean,
leftButtonPressed: boolean,
leftButtonReleased: boolean,
@@ -332,10 +370,9 @@ export class MacNativeCursorRecordingSession implements CursorRecordingSession {
const normalizedY = (cursor.y - bounds.y) / height;
const isOutsideDisplay =
normalizedX < 0 || normalizedX > 1 || normalizedY < 0 || normalizedY > 1;
// Fast swipes that briefly exit the display (<THRESHOLD samples) are handled by
// clip-path — the cursor clips to the canvas edge instead of snapping invisible.
// Sustained exits (≥THRESHOLD samples, ≈100ms) mark visible=false to prevent
// ghost cursors and motion trails from multi-display movement.
// Brief exits (under THRESHOLD samples) clip to the canvas edge via clip-path instead
// of snapping invisible. Sustained exits (>=THRESHOLD, ~100ms) mark visible=false to
// avoid ghost cursors and motion trails from multi-display movement.
if (isOutsideDisplay) {
this.consecutiveOutsideSamples++;
} else {
@@ -357,6 +394,7 @@ export class MacNativeCursorRecordingSession implements CursorRecordingSession {
cy: clamp(normalizedY, 0, 1),
visible,
interactionType,
...(assetId ? { assetId } : {}),
...(cursorType ? { cursorType } : {}),
});
@@ -22,10 +22,14 @@ function getCursorSamplerCandidates(): string[] {
const p = join(app.getAppPath(), ...segs);
return app.isPackaged ? p.replace(/\.asar([/\\])/, ".asar.unpacked$1") : p;
};
const resolvePackaged = (...segs: string[]) => {
return app.isPackaged ? join(process.resourcesPath, ...segs) : null;
};
return [
envPath,
resolve("electron", "native", "wgc-capture", "build", "cursor-sampler.exe"),
resolve("electron", "native", "bin", archTag, "cursor-sampler.exe"),
resolvePackaged("electron", "native", "bin", archTag, "cursor-sampler.exe"),
].filter((c): c is string => Boolean(c));
}
@@ -14,8 +14,9 @@ interface ProjectServiceOptions {
suggestedName?: string,
existingProjectPath?: string,
) => Promise<ProjectFileResult>;
loadProjectFile: () => Promise<ProjectFileResult>;
loadProjectFile: (projectFolder?: string) => Promise<ProjectFileResult>;
loadCurrentProjectFile: () => Promise<ProjectFileResult>;
loadProjectFileFromPath: (path: string) => Promise<ProjectFileResult>;
setCurrentVideoPath: (path: string) => ProjectPathResult | Promise<ProjectPathResult>;
getCurrentVideoPathResult: () => ProjectPathResult;
clearCurrentVideoPath: () => ProjectPathResult;
@@ -48,8 +49,8 @@ export class ProjectService {
return result;
}
async loadProjectFile() {
const result = await this.options.loadProjectFile();
async loadProjectFile(projectFolder?: string) {
const result = await this.options.loadProjectFile(projectFolder);
this.getCurrentContext();
return result;
}
@@ -60,6 +61,12 @@ export class ProjectService {
return result;
}
async loadProjectFileFromPath(path: string) {
const result = await this.options.loadProjectFileFromPath(path);
this.getCurrentContext();
return result;
}
async setCurrentVideoPath(path: string) {
const result = await this.options.setCurrentVideoPath(path);
this.getCurrentContext();
@@ -1,11 +1,22 @@
import AppKit
import ApplicationServices
import CryptoKit
import Foundation
struct CursorHelperRequest: Decodable {
let sampleIntervalMs: Int?
}
struct CapturedCursorAsset {
let id: String
let imageDataUrl: String
let width: Int
let height: Int
let hotspotX: Double
let hotspotY: Double
let scaleFactor: Double
}
final class MouseButtonTracker {
private let lock = NSLock()
private var leftDownCount = 0
@@ -211,10 +222,60 @@ func currentCursorType() -> String? {
)
guard result == .success, let element else {
return "arrow"
return nil
}
return cursorTypeForElement(element) ?? "arrow"
// Returns nil for anything that is not a text/pointer affordance so the
// renderer falls through to the natively captured cursor bitmap (this is
// what makes default and custom cursors render as their real images).
return cursorTypeForElement(element)
}
func currentCursorAsset() -> CapturedCursorAsset? {
guard let cursor = NSCursor.currentSystem ?? NSCursor.current as NSCursor? else {
return nil
}
let image = cursor.image
let pointSize = image.size
guard pointSize.width > 0, pointSize.height > 0 else {
return nil
}
var proposedRect = NSRect(origin: .zero, size: pointSize)
guard let cgImage = image.cgImage(forProposedRect: &proposedRect, context: nil, hints: nil) else {
return nil
}
let bitmap = NSBitmapImageRep(cgImage: cgImage)
guard let png = bitmap.representation(using: .png, properties: [:]) else {
return nil
}
let pixelsWide = bitmap.pixelsWide
let pixelsHigh = bitmap.pixelsHigh
guard pixelsWide > 0, pixelsHigh > 0 else {
return nil
}
// Intrinsic backing scale of the cursor image (e.g. 2.0 on Retina). The
// renderer divides pixel dimensions/hotspot by this to recover point sizes.
let scaleFactor = Double(pixelsWide) / Double(pointSize.width)
let hotSpot = cursor.hotSpot
let digest = SHA256.hash(data: png)
let id = digest.map { String(format: "%02x", $0) }.joined()
let imageDataUrl = "data:image/png;base64,\(png.base64EncodedString())"
return CapturedCursorAsset(
id: id,
imageDataUrl: imageDataUrl,
width: pixelsWide,
height: pixelsHigh,
hotspotX: hotSpot.x * scaleFactor,
hotspotY: hotSpot.y * scaleFactor,
scaleFactor: scaleFactor
)
}
func timestampMs() -> Int {
@@ -253,16 +314,39 @@ emit([
"mouseTapReady": mouseTapReady,
])
// Process-wide set so each unique cursor shape is serialised at most once,
// even if the user alternates between shapes (e.g. arrow → text → arrow).
var emittedAssetIds = Set<String>()
while true {
mouseTracker.pump()
let mouseEvents = mouseTracker.consume()
emit([
"type": "sample",
"timestampMs": timestampMs(),
"cursorType": currentCursorType(),
"leftButtonDown": leftButtonDown(),
"leftButtonPressed": mouseEvents.leftDownCount > 0,
"leftButtonReleased": mouseEvents.leftUpCount > 0,
])
Thread.sleep(forTimeInterval: Double(intervalMs) / 1000.0)
autoreleasepool {
mouseTracker.pump()
let mouseEvents = mouseTracker.consume()
let asset = currentCursorAsset()
// Only ship the (large) base64 payload the first time a cursor shape is seen;
// subsequent samples reference it by assetId so stdout stays small.
var assetPayload: [String: Any]?
if let asset, emittedAssetIds.insert(asset.id).inserted {
assetPayload = [
"id": asset.id,
"imageDataUrl": asset.imageDataUrl,
"width": asset.width,
"height": asset.height,
"hotspotX": asset.hotspotX,
"hotspotY": asset.hotspotY,
"scaleFactor": asset.scaleFactor,
]
}
emit([
"type": "sample",
"timestampMs": timestampMs(),
"cursorType": currentCursorType(),
"assetId": asset?.id,
"asset": assetPayload,
"leftButtonDown": leftButtonDown(),
"leftButtonPressed": mouseEvents.leftDownCount > 0,
"leftButtonReleased": mouseEvents.leftUpCount > 0,
])
Thread.sleep(forTimeInterval: Double(intervalMs) / 1000.0)
}
}
+1 -1
View File
@@ -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
@@ -279,6 +279,7 @@ bool AudioMixer::start() {
stopRequested_ = false;
emittedFrames_ = 0;
timelineStarted_ = false;
paused_ = false;
thread_ = std::thread([this] {
mixLoop();
});
@@ -296,6 +297,18 @@ void AudioMixer::beginTimeline() {
cv_.notify_all();
}
void AudioMixer::setPaused(bool paused) {
{
std::scoped_lock lock(mutex_);
paused_ = paused;
if (paused_) {
systemQueue_.clear();
microphoneQueue_.clear();
}
}
cv_.notify_all();
}
void AudioMixer::stop() {
stopRequested_ = true;
cv_.notify_all();
@@ -311,6 +324,9 @@ void AudioMixer::pushSystem(const BYTE* data, DWORD byteCount) {
{
std::scoped_lock lock(mutex_);
if (paused_) {
return;
}
append(systemQueue_, data, byteCount, systemFormat_, 1.0);
}
cv_.notify_all();
@@ -323,6 +339,9 @@ void AudioMixer::pushMicrophone(const BYTE* data, DWORD byteCount) {
{
std::scoped_lock lock(mutex_);
if (paused_) {
return;
}
append(microphoneQueue_, data, byteCount, microphoneFormat_, microphoneGain_);
}
cv_.notify_all();
@@ -371,13 +390,13 @@ void AudioMixer::mixLoop() {
const bool hasMicrophone = !includeMicrophone_ || microphoneQueue_.size() >= chunkBytes;
const bool hasAnySource = !systemQueue_.empty() || !microphoneQueue_.empty();
return stopRequested_.load() ||
(timelineStarted_ && (hasSystem || hasMicrophone) && hasAnySource);
(timelineStarted_ && !paused_ && (hasSystem || hasMicrophone) && hasAnySource);
});
if (stopRequested_) {
break;
}
if (!timelineStarted_) {
if (!timelineStarted_ || paused_) {
continue;
}
@@ -52,6 +52,7 @@ public:
bool start();
void beginTimeline();
void setPaused(bool paused);
void stop();
void pushSystem(const BYTE* data, DWORD byteCount);
void pushMicrophone(const BYTE* data, DWORD byteCount);
@@ -81,5 +82,6 @@ private:
std::thread thread_;
std::atomic<bool> stopRequested_ = false;
bool timelineStarted_ = false;
bool paused_ = false;
uint64_t emittedFrames_ = 0;
};
+91 -34
View File
@@ -13,6 +13,7 @@
#include <condition_variable>
#include <cctype>
#include <cstdint>
#include <functional>
#include <iostream>
#include <memory>
#include <mutex>
@@ -50,6 +51,37 @@ struct CaptureConfig {
int webcamFps = 0;
};
struct CaptureControl {
std::atomic<bool> stopRequested = false;
std::atomic<bool> paused = false;
std::mutex mutex;
std::condition_variable cv;
std::chrono::steady_clock::time_point pauseStartedAt;
std::chrono::steady_clock::duration totalPausedDuration{};
int64_t pausedDurationHns() {
std::scoped_lock lock(mutex);
auto total = totalPausedDuration;
if (paused.load()) {
total += std::chrono::steady_clock::now() - pauseStartedAt;
}
return std::chrono::duration_cast<std::chrono::nanoseconds>(total).count() / 100;
}
void setPaused(bool nextPaused) {
std::scoped_lock lock(mutex);
if (nextPaused == paused.load()) {
return;
}
if (nextPaused) {
pauseStartedAt = std::chrono::steady_clock::now();
} else {
totalPausedDuration += std::chrono::steady_clock::now() - pauseStartedAt;
}
paused = nextPaused;
}
};
std::wstring utf8ToWide(const std::string& value) {
if (value.empty()) {
return {};
@@ -319,17 +351,31 @@ bool parseConfig(const std::string& json, CaptureConfig& config) {
return true;
}
void readStopCommands(std::atomic<bool>& stopRequested, std::condition_variable& cv) {
void readCaptureCommands(CaptureControl& control, const std::function<void(bool)>& onPauseChanged) {
std::string line;
while (std::getline(std::cin, line)) {
if (line == "stop" || line == "q" || line == "quit") {
stopRequested = true;
cv.notify_all();
control.stopRequested = true;
control.cv.notify_all();
return;
}
if (line == "pause") {
control.setPaused(true);
onPauseChanged(true);
std::cout << "{\"event\":\"recording-paused\",\"schemaVersion\":2}" << std::endl;
control.cv.notify_all();
continue;
}
if (line == "resume") {
control.setPaused(false);
onPauseChanged(false);
std::cout << "{\"event\":\"recording-resumed\",\"schemaVersion\":2}" << std::endl;
control.cv.notify_all();
continue;
}
}
stopRequested = true;
cv.notify_all();
control.stopRequested = true;
control.cv.notify_all();
}
} // namespace
@@ -489,8 +535,7 @@ int main(int argc, char* argv[]) {
}
std::mutex mutex;
std::condition_variable cv;
std::atomic<bool> stopRequested = false;
CaptureControl control;
std::atomic<bool> firstFrameWritten = false;
std::atomic<bool> encodeFailed = false;
Microsoft::WRL::ComPtr<ID3D11Texture2D> latestFrameTexture;
@@ -503,7 +548,7 @@ int main(int argc, char* argv[]) {
bool hasVisibleWebcamFrame = false;
session.setFrameCallback([&](ID3D11Texture2D* texture, int64_t timestampHns) {
if (stopRequested) {
if (control.stopRequested || control.paused) {
return;
}
@@ -516,8 +561,8 @@ int main(int argc, char* argv[]) {
desc.MiscFlags = 0;
if (FAILED(session.device()->CreateTexture2D(&desc, nullptr, &latestFrameTexture))) {
encodeFailed = true;
stopRequested = true;
cv.notify_all();
control.stopRequested = true;
control.cv.notify_all();
return;
}
}
@@ -525,20 +570,29 @@ int main(int argc, char* argv[]) {
session.context()->CopyResource(latestFrameTexture.Get(), texture);
latestFrameTimestampHns = timestampHns;
if (!firstFrameWritten.exchange(true)) {
cv.notify_all();
control.cv.notify_all();
}
});
auto writeVideoFrames = [&]() {
const auto startedAt = std::chrono::steady_clock::now();
const auto frameDuration = std::chrono::duration_cast<std::chrono::steady_clock::duration>(
std::chrono::duration<double>(1.0 / config.fps));
uint64_t frameIndex = 0;
uint64_t lastWrittenWebcamSequence = 0;
uint64_t webcamOutputFrameIndex = 0;
int64_t lastEncodedVideoTimestampHns = -1;
while (!stopRequested && !encodeFailed) {
while (!control.stopRequested && !encodeFailed) {
{
std::scoped_lock lock(mutex);
std::unique_lock lock(mutex);
control.cv.wait(lock, [&] {
return control.stopRequested.load() ||
encodeFailed.load() ||
(!control.paused.load() && latestFrameTexture);
});
if (control.stopRequested || encodeFailed) {
break;
}
if (webcamActive) {
WebcamFrameSnapshot candidateWebcamFrame;
if (webcamCapture.copyLatestFrame(candidateWebcamFrame) &&
@@ -564,7 +618,9 @@ int main(int argc, char* argv[]) {
firstFrameTimestampHns = sourceTimestampHns;
}
int64_t frameTimestampHns =
std::max<int64_t>(0, sourceTimestampHns - firstFrameTimestampHns);
std::max<int64_t>(
0,
sourceTimestampHns - firstFrameTimestampHns - control.pausedDurationHns());
if (lastEncodedVideoTimestampHns >= 0 &&
frameTimestampHns <= lastEncodedVideoTimestampHns) {
frameTimestampHns =
@@ -576,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;
@@ -588,8 +644,8 @@ int main(int argc, char* argv[]) {
frameTimestampHns,
!writeSeparateWebcam && webcamFrame.data ? &webcamFrame : nullptr)) {
encodeFailed = true;
stopRequested = true;
cv.notify_all();
control.stopRequested = true;
control.cv.notify_all();
return;
}
if (latestFrameTexture) {
@@ -598,10 +654,7 @@ int main(int argc, char* argv[]) {
}
frameIndex += 1;
const auto nextDeadline = startedAt +
std::chrono::duration_cast<std::chrono::steady_clock::duration>(
std::chrono::duration<double>(static_cast<double>(frameIndex) / config.fps));
std::this_thread::sleep_until(nextDeadline);
std::this_thread::sleep_for(frameDuration);
}
};
@@ -633,8 +686,8 @@ int main(int argc, char* argv[]) {
[&](const BYTE* data, DWORD byteCount, int64_t timestampHns, int64_t durationHns) {
if (!encoder.writeAudio(data, byteCount, timestampHns, durationHns)) {
encodeFailed = true;
stopRequested = true;
cv.notify_all();
control.stopRequested = true;
control.cv.notify_all();
return false;
}
return true;
@@ -649,7 +702,7 @@ int main(int argc, char* argv[]) {
if (!microphoneCapture.start([&](const BYTE* data, DWORD byteCount, int64_t timestampHns, int64_t durationHns) {
(void)timestampHns;
(void)durationHns;
if (stopRequested || !audioMixer) {
if (control.stopRequested || !audioMixer) {
return;
}
@@ -665,7 +718,7 @@ int main(int argc, char* argv[]) {
if (!loopbackCapture.start([&](const BYTE* data, DWORD byteCount, int64_t timestampHns, int64_t durationHns) {
(void)timestampHns;
(void)durationHns;
if (stopRequested || !audioMixer) {
if (control.stopRequested || !audioMixer) {
return;
}
@@ -726,16 +779,20 @@ int main(int argc, char* argv[]) {
return 1;
}
std::thread stdinThread(readStopCommands, std::ref(stopRequested), std::ref(cv));
std::thread stdinThread(readCaptureCommands, std::ref(control), [&](bool isPaused) {
if (audioMixer) {
audioMixer->setPaused(isPaused);
}
});
{
std::unique_lock lock(mutex);
const bool started = cv.wait_for(lock, std::chrono::seconds(10), [&] {
return firstFrameWritten.load() || stopRequested.load();
const bool started = control.cv.wait_for(lock, std::chrono::seconds(10), [&] {
return firstFrameWritten.load() || control.stopRequested.load();
});
if (!started || !firstFrameWritten) {
stopRequested = true;
cv.notify_all();
control.stopRequested = true;
control.cv.notify_all();
if (stdinThread.joinable()) {
stdinThread.detach();
}
@@ -761,8 +818,8 @@ int main(int argc, char* argv[]) {
{
std::unique_lock lock(mutex);
cv.wait(lock, [&] {
return stopRequested.load();
control.cv.wait(lock, [&] {
return control.stopRequested.load();
});
}
+45 -3
View File
@@ -1,7 +1,8 @@
import { contextBridge, ipcRenderer } from "electron";
import { contextBridge, ipcRenderer, webUtils } from "electron";
import type { NativeMacRecordingRequest } from "../src/lib/nativeMacRecording";
import type { NativeWindowsRecordingRequest } from "../src/lib/nativeWindowsRecording";
import type { RecordingSession, StoreRecordedSessionInput } from "../src/lib/recordingSession";
import type { ShortcutBinding } from "../src/lib/shortcuts";
import { NATIVE_BRIDGE_CHANNEL, type NativeBridgeRequest } from "../src/native/contracts";
// Asset base URL is passed from the main process via webPreferences.additionalArguments
@@ -28,6 +29,9 @@ contextBridge.exposeInMainWorld("electronAPI", {
moveHudOverlayBy: (deltaX: number, deltaY: number) => {
ipcRenderer.send("hud-overlay-move-by", deltaX, deltaY);
},
setHudOverlaySize: (width: number, height: number) => {
ipcRenderer.send("hud-overlay-set-size", width, height);
},
getSources: async (opts: Electron.SourcesOptions) => {
return await ipcRenderer.invoke("get-sources", opts);
},
@@ -64,6 +68,15 @@ contextBridge.exposeInMainWorld("electronAPI", {
storeRecordedSession: (payload: StoreRecordedSessionInput) => {
return ipcRenderer.invoke("store-recorded-session", payload);
},
openRecordingStream: (fileName: string) => {
return ipcRenderer.invoke("open-recording-stream", fileName);
},
appendRecordingChunk: (fileName: string, chunk: ArrayBuffer) => {
return ipcRenderer.invoke("append-recording-chunk", fileName, chunk);
},
closeRecordingStream: (fileName: string) => {
return ipcRenderer.invoke("close-recording-stream", fileName);
},
getRecordedVideoPath: () => {
return ipcRenderer.invoke("get-recorded-video-path");
@@ -87,6 +100,12 @@ contextBridge.exposeInMainWorld("electronAPI", {
stopNativeWindowsRecording: (discard?: boolean) => {
return ipcRenderer.invoke("stop-native-windows-recording", discard);
},
pauseNativeWindowsRecording: () => {
return ipcRenderer.invoke("pause-native-windows-recording");
},
resumeNativeWindowsRecording: () => {
return ipcRenderer.invoke("resume-native-windows-recording");
},
startNativeMacRecording: (request: NativeMacRecordingRequest) => {
return ipcRenderer.invoke("start-native-mac-recording", request);
},
@@ -154,12 +173,32 @@ contextBridge.exposeInMainWorld("electronAPI", {
saveProjectFile: (projectData: unknown, suggestedName?: string, existingProjectPath?: string) => {
return ipcRenderer.invoke("save-project-file", projectData, suggestedName, existingProjectPath);
},
loadProjectFile: () => {
return ipcRenderer.invoke("load-project-file");
loadProjectFile: (projectFolder?: string) => {
return ipcRenderer.invoke("load-project-file", projectFolder);
},
loadProjectFileFromPath: (filePath: string) => {
return ipcRenderer.invoke("load-project-file-from-path", filePath);
},
getPathForFile: (file: File) => {
try {
return webUtils.getPathForFile(file);
} catch {
return "";
}
},
loadCurrentProjectFile: () => {
return ipcRenderer.invoke("load-current-project-file");
},
onMenuNewProject: (callback: () => void) => {
const listener = () => callback();
ipcRenderer.on("menu-new-project", listener);
return () => ipcRenderer.removeListener("menu-new-project", listener);
},
onMenuImportVideo: (callback: () => void) => {
const listener = () => callback();
ipcRenderer.on("menu-import-video", listener);
return () => ipcRenderer.removeListener("menu-import-video", listener);
},
onMenuLoadProject: (callback: () => void) => {
const listener = () => callback();
ipcRenderer.on("menu-load-project", listener);
@@ -187,6 +226,9 @@ contextBridge.exposeInMainWorld("electronAPI", {
saveShortcuts: (shortcuts: unknown) => {
return ipcRenderer.invoke("save-shortcuts", shortcuts);
},
updateGlobalShortcut: (binding: ShortcutBinding) => {
return ipcRenderer.invoke("update-global-shortcut", binding);
},
setLocale: (locale: string) => {
return ipcRenderer.invoke("set-locale", locale);
},
+86
View File
@@ -0,0 +1,86 @@
import fs from "node:fs/promises";
import { fixParsedWebmDuration } from "@fix-webm-duration/fix";
import { WebmFile } from "@fix-webm-duration/parser";
export type DurationPatchResult =
| { patched: true }
| { patched: false; reason: "no-section" | "already-valid" | "io-error" | "internal" };
/**
* Patch the WebM Duration header on a finalized recording file.
*
* MediaRecorder writes WebM with no Duration EBML element, and the streaming-to-disk
* path never holds the blob so the old `fixWebmDuration(blob, durationMs)` can't run.
* Patching on disk after `WriteStream.end()` gives the editor a real duration instead of `N/A`.
*
* Atomic: writes to `<filePath>.duration-patch.tmp` and renames in place, so a mid-rewrite
* crash leaves the original intact. Best-effort: any read/parse/write failure logs and returns
* a non-`patched` result rather than throwing; the file still plays without the patch (decoders
* walk frames sequentially), only the seek bar and timeline break.
*
* Reads the whole file into a main-process Buffer, off the renderer so it dodges V8's heap cap.
*/
export async function patchWebmDurationOnDisk(
filePath: string,
durationMs: number,
): Promise<DurationPatchResult> {
try {
const fileBytes = await fs.readFile(filePath);
const webm = new WebmFile(new Uint8Array(fileBytes));
const patched = fixParsedWebmDuration(webm, durationMs, { logger: false });
if (!patched) {
// false means missing Segment, missing Info, or an already-valid Duration.
// The first two mean a malformed (likely truncated) file; the third is a no-op.
const reason = inferUnpatchedReason(webm);
if (reason === "no-section") {
console.warn(
`[webm-duration] no Segment/Info section in ${filePath}; file may be truncated`,
);
}
return { patched: false, reason };
}
if (!webm.source) {
console.error(`[webm-duration] patched but source missing for ${filePath}`);
return { patched: false, reason: "internal" };
}
const tmpPath = `${filePath}.duration-patch.tmp`;
const patchedBytes = Buffer.from(
webm.source.buffer,
webm.source.byteOffset,
webm.source.byteLength,
);
try {
await fs.writeFile(tmpPath, patchedBytes);
await fs.rename(tmpPath, filePath);
return { patched: true };
} catch (writeError) {
console.error(`[webm-duration] failed to write patched ${filePath}:`, writeError);
// Clean up the temp file; the original is untouched since the rename never ran.
await fs.unlink(tmpPath).catch(() => undefined);
return { patched: false, reason: "io-error" };
}
} catch (error) {
console.error(`[webm-duration] failed to patch ${filePath}:`, error);
return { patched: false, reason: "io-error" };
}
}
/**
* Distinguish "no Segment/Info section" (malformed/truncated file) from "Info present
* but Duration already valid" (patch unnecessary).
*
* The IDs are the length-descriptor-stripped form @fix-webm-duration/parser uses as lookup
* keys (Segment `0x8538067`, Info `0x549a966`), per the parser's `src/lib/sections.js`, not
* the canonical 4-byte EBML IDs (`0x18538067` / `0x1549A966`) that `getSectionById` never matches.
*/
function inferUnpatchedReason(webm: WebmFile): "no-section" | "already-valid" {
const segment = webm.getSectionById?.(0x8538067);
if (!segment) return "no-section";
const info = (
segment as unknown as { getSectionById?: (id: number) => unknown }
).getSectionById?.(0x549a966);
return info ? "already-valid" : "no-section";
}
+81 -22
View File
@@ -10,7 +10,7 @@ const RENDERER_DIST = path.join(APP_ROOT, "dist");
const HEADLESS = process.env["HEADLESS"] === "true";
// Asset base URL for renderer (wallpapers, etc.). Packaged: extraResources copies
// public/wallpapers -> resources/wallpapers. Unpackaged: <appRoot>/public/.
// public/wallpapers to resources/wallpapers. Unpackaged: <appRoot>/public/.
const ASSET_BASE_DIR = process.defaultApp
? path.join(__dirname, "..", "public")
: process.resourcesPath;
@@ -44,10 +44,45 @@ ipcMain.on("hud-overlay-move-by", (_event, deltaX: number, deltaY: number) => {
hudOverlayWindow.setPosition(Math.round(x + deltaX), Math.round(y + deltaY), false);
});
// Resize the HUD to fit its rendered content. Anchored by its bottom-centre so it
// stays where the user dragged it while only growing/shrinking, which lets the
// vertical tray layout grow tall instead of scrolling inside a fixed window.
ipcMain.on("hud-overlay-set-size", (_event, width: number, height: number) => {
if (
!hudOverlayWindow ||
hudOverlayWindow.isDestroyed() ||
!Number.isFinite(width) ||
!Number.isFinite(height)
) {
return;
}
const bounds = hudOverlayWindow.getBounds();
// Clamp to the work area of the display the HUD sits on; on a short screen the
// vertical layout can exceed the display, where the bar's own overflow scroll takes over.
const { workArea } = screen.getDisplayMatching(bounds);
const nextWidth = Math.min(workArea.width, Math.max(1, Math.round(width)));
const nextHeight = Math.min(workArea.height, Math.max(1, Math.round(height)));
if (bounds.width === nextWidth && bounds.height === nextHeight) {
return;
}
const centerX = bounds.x + bounds.width / 2;
const bottomY = bounds.y + bounds.height;
hudOverlayWindow.setBounds({
x: Math.round(centerX - nextWidth / 2),
y: Math.round(bottomY - nextHeight),
width: nextWidth,
height: nextHeight,
});
});
/**
* Creates the always-on-top HUD overlay window centred at the bottom of the
* primary display. The window is frameless, transparent, and follows the user
* across macOS Spaces so it is never lost when switching virtual desktops.
* Frameless transparent HUD overlay, always-on-top, centred at the bottom of the
* primary display. Follows the user across macOS Spaces so it isn't lost on switch.
*/
export function createHudOverlayWindow(): BrowserWindow {
const primaryDisplay = screen.getPrimaryDisplay();
@@ -62,19 +97,25 @@ export function createHudOverlayWindow(): BrowserWindow {
const win = new BrowserWindow({
width: windowWidth,
height: windowHeight,
minWidth: 600,
maxWidth: 600,
minHeight: 160,
maxHeight: 160,
// Min/max are intentionally loose: the renderer resizes to fit content via
// "hud-overlay-set-size" (above), needed for the vertical tray to grow taller.
minWidth: 120,
minHeight: 80,
x: x,
y: y,
frame: false,
transparent: true,
// Fully-transparent ARGB backing. Without this macOS draws the window as a
// rounded glass panel with a border around the HUD content.
backgroundColor: "#00000000",
// Don't let macOS mask the window into a rounded rect; the HUD bar provides
// its own rounding and the window itself must be invisible.
roundedCorners: false,
resizable: false,
alwaysOnTop: true,
skipTaskbar: true,
hasShadow: false,
show: !HEADLESS,
show: false, // shown via ready-to-show to avoid black rectangle flash
webPreferences: {
preload: path.join(__dirname, "preload.mjs"),
additionalArguments: [ASSET_BASE_URL_ARG],
@@ -85,12 +126,18 @@ export function createHudOverlayWindow(): BrowserWindow {
});
win.setIgnoreMouseEvents(true, { forward: true });
// Follow the user across macOS Spaces (virtual desktops).
// Without this the HUD stays pinned to the Space it was first opened on.
// Follow the user across macOS Spaces, else the HUD stays pinned to the Space
// it was first opened on.
if (process.platform === "darwin") {
win.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });
}
// Show only once painted to avoid the black rectangle flash when a transparent
// window is shown before its first paint.
win.once("ready-to-show", () => {
if (!HEADLESS) win.show();
});
win.webContents.on("did-finish-load", () => {
win?.webContents.send("main-process-message", new Date().toLocaleString());
});
@@ -115,8 +162,8 @@ export function createHudOverlayWindow(): BrowserWindow {
}
/**
* Creates the main editor window. Starts maximised with a hidden title bar on
* macOS. This window is not always-on-top and appears in the taskbar/dock.
* Main editor window. Starts maximised with a hidden title bar on macOS; not
* always-on-top and appears in the taskbar/dock.
*/
export function createEditorWindow(): BrowserWindow {
const isMac = process.platform === "darwin";
@@ -135,8 +182,8 @@ export function createEditorWindow(): BrowserWindow {
alwaysOnTop: false,
skipTaskbar: false,
title: "OpenScreen",
backgroundColor: "#000000",
show: !HEADLESS,
backgroundColor: "#09090b",
show: false, // shown via ready-to-show to avoid white flash on first load
webPreferences: {
preload: path.join(__dirname, "preload.mjs"),
additionalArguments: [ASSET_BASE_URL_ARG],
@@ -147,9 +194,21 @@ export function createEditorWindow(): BrowserWindow {
},
});
// Maximize the window by default
win.maximize();
// Show only once painted to avoid a white flash on cold Vite start.
win.once("ready-to-show", () => {
if (!HEADLESS) win.show();
});
// Inject dark background before any React paint so the sub-titlebar area never
// flashes white on a cold Vite load.
win.webContents.on("dom-ready", () => {
win.webContents.insertCSS("html, body, #root { background: #09090b !important; }").catch(() => {
// Best-effort cosmetic; ignore if the page is mid-teardown.
});
});
win.webContents.on("did-finish-load", () => {
win?.webContents.send("main-process-message", new Date().toLocaleString());
});
@@ -166,8 +225,8 @@ export function createEditorWindow(): BrowserWindow {
}
/**
* Creates the floating source-selector window used to pick a screen or window
* to record. Frameless, transparent, and follows the user across macOS Spaces.
* Floating source-selector window for picking a screen or window to record.
* Frameless, transparent, and follows the user across macOS Spaces.
*/
export function createSourceSelectorWindow(): BrowserWindow {
const { width, height } = screen.getPrimaryDisplay().workAreaSize;
@@ -192,8 +251,8 @@ export function createSourceSelectorWindow(): BrowserWindow {
},
});
// Follow the user across macOS Spaces so the selector appears on the
// active desktop regardless of where the HUD was originally opened.
// Follow the user across macOS Spaces so the selector appears on the active
// desktop regardless of where the HUD was opened.
if (process.platform === "darwin") {
win.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });
}
@@ -210,8 +269,8 @@ export function createSourceSelectorWindow(): BrowserWindow {
}
/**
* Creates a centered transparent countdown overlay window that sits above the
* HUD while recording pre-roll is running.
* Centered transparent countdown overlay that sits above the HUD during
* recording pre-roll.
*/
export function createCountdownOverlayWindow(): BrowserWindow {
const { workArea } = screen.getPrimaryDisplay();
+2 -2
View File
@@ -1,12 +1,12 @@
<!doctype html>
<html lang="en">
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title></title>
</head>
<body>
<body style="background:#09090b;margin:0">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
+2 -2
View File
@@ -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";
+736 -20
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -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",
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 484 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 490 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 MiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 283 KiB

After

Width:  |  Height:  |  Size: 264 KiB

+14
View File
@@ -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, ".."),
});
};
+91 -38
View File
@@ -18,14 +18,24 @@ const cursorHelperName = "openscreen-macos-cursor-helper";
const packageDir = path.join(root, "electron", "native", "screencapturekit");
const buildDir = path.join(packageDir, "build");
const swiftBuildDir = path.join(buildDir, "swiftpm");
const builtHelperPath = path.join(swiftBuildDir, "release", helperName);
const localHelperPath = path.join(buildDir, helperName);
const builtCursorHelperPath = path.join(swiftBuildDir, "release", cursorHelperName);
const localCursorHelperPath = path.join(buildDir, cursorHelperName);
const archTag = process.arch === "arm64" ? "darwin-arm64" : "darwin-x64";
const distributableDir = path.join(root, "electron", "native", "bin", archTag);
const distributablePath = path.join(distributableDir, helperName);
const distributableCursorHelperPath = path.join(distributableDir, cursorHelperName);
// Build a separate single-arch binary per requested arch and place each in its own
// electron/native/bin/darwin-<arch> folder (the runtime resolves that folder by the running app's
// arch). No universal/fat binary. Defaults to the host arch for local builds; CI sets
// OPENSCREEN_MAC_HELPER_ARCHS per matrix entry (accepts arm64, x64, or x86_64).
function normalizeArch(value) {
return value === "x64" || value === "x86_64"
? { swift: "x86_64", tag: "darwin-x64" }
: { swift: "arm64", tag: "darwin-arm64" };
}
const hostArch = process.arch === "arm64" ? "arm64" : "x86_64";
const archs = (process.env.OPENSCREEN_MAC_HELPER_ARCHS ?? hostArch)
.split(",")
.map((a) => a.trim())
.filter(Boolean)
.map(normalizeArch);
const xcodebuildVersion = spawnSync("xcodebuild", ["-version"], {
cwd: root,
@@ -50,42 +60,85 @@ if (xcodebuildVersion.status !== 0) {
process.exit(1);
}
const result = spawnSync(
"swift",
["build", "-c", "release", "--package-path", packageDir, "--build-path", swiftBuildDir],
{
cwd: root,
stdio: "inherit",
},
);
// SwiftPM writes a single-arch release build to <buildPath>/<swiftArch>-apple-macosx/release/<name>.
// Fall back to a search that skips the identically-named file inside the .dSYM debug bundle (matching
// that file and feeding it forward is what produced an unrunnable "exec format error" binary before).
function findExecutable(dir, swiftArch, name) {
const expected = path.join(dir, `${swiftArch}-apple-macosx`, "release", name);
if (fs.existsSync(expected)) return expected;
if (result.error) {
console.error(`Failed to start Swift build: ${result.error.message}`);
process.exit(1);
}
if (result.status !== 0) {
process.exit(result.status ?? 1);
const stack = [dir];
const matches = [];
while (stack.length > 0) {
const current = stack.pop();
let entries;
try {
entries = fs.readdirSync(current, { withFileTypes: true });
} catch {
continue;
}
for (const entry of entries) {
if (entry.name.endsWith(".dSYM")) continue;
const full = path.join(current, entry.name);
if (entry.isDirectory()) stack.push(full);
else if (entry.isFile() && entry.name === name && /[/\\]release[/\\]/i.test(full)) {
matches.push(full);
}
}
}
return matches[0] ?? null;
}
fs.mkdirSync(buildDir, { recursive: true });
fs.mkdirSync(distributableDir, { recursive: true });
for (const artifactPath of [builtHelperPath, builtCursorHelperPath]) {
if (!fs.existsSync(artifactPath)) {
console.error(`Swift build completed but expected artifact was not found: ${artifactPath}`);
for (const { swift, tag } of archs) {
const archBuildDir = path.join(swiftBuildDir, swift);
const result = spawnSync(
"swift",
[
"build",
"-c",
"release",
"--arch",
swift,
"--package-path",
packageDir,
"--build-path",
archBuildDir,
],
{
cwd: root,
stdio: "inherit",
},
);
if (result.error) {
console.error(`Failed to start Swift build (${swift}): ${result.error.message}`);
process.exit(1);
}
}
fs.copyFileSync(builtHelperPath, localHelperPath);
fs.copyFileSync(builtHelperPath, distributablePath);
fs.copyFileSync(builtCursorHelperPath, localCursorHelperPath);
fs.copyFileSync(builtCursorHelperPath, distributableCursorHelperPath);
fs.chmodSync(localHelperPath, 0o755);
fs.chmodSync(distributablePath, 0o755);
fs.chmodSync(localCursorHelperPath, 0o755);
fs.chmodSync(distributableCursorHelperPath, 0o755);
if (result.status !== 0) {
process.exit(result.status ?? 1);
}
console.log(`Built macOS ScreenCaptureKit helper: ${localHelperPath}`);
console.log(`Copied redistributable helper: ${distributablePath}`);
console.log(`Built macOS cursor helper: ${localCursorHelperPath}`);
console.log(`Copied redistributable cursor helper: ${distributableCursorHelperPath}`);
const targetDir = path.join(root, "electron", "native", "bin", tag);
fs.mkdirSync(targetDir, { recursive: true });
for (const [name, localPath] of [
[helperName, localHelperPath],
[cursorHelperName, localCursorHelperPath],
]) {
const exe = findExecutable(archBuildDir, swift, name);
if (!exe) {
console.error(`Swift build (${swift}) completed but executable was not found: ${name}`);
process.exit(1);
}
// Always place it in the arch's bin folder; mirror the host-arch build into the dev build
// dir so `npm run dev` (candidate path #2) can spawn it.
const dests = [path.join(targetDir, name)];
if (swift === hostArch) dests.push(localPath);
for (const dest of dests) {
fs.copyFileSync(exe, dest);
fs.chmodSync(dest, 0o755);
}
}
console.log(`Built ${tag} helpers (${swift})`);
}
+19 -2
View File
@@ -37,6 +37,21 @@ function findVcVarsAll() {
return null;
}
function findWindowsSdkUmLibDir() {
const sdkLibRoot = "C:\\Program Files (x86)\\Windows Kits\\10\\Lib";
if (!fs.existsSync(sdkLibRoot)) {
return null;
}
return fs
.readdirSync(sdkLibRoot, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => path.join(sdkLibRoot, entry.name, "um", "x64"))
.filter((candidate) => fs.existsSync(path.join(candidate, "kernel32.lib")))
.sort()
.at(-1);
}
function run(command, args, options = {}) {
return new Promise((resolve, reject) => {
const child = spawn(command, args, {
@@ -64,6 +79,8 @@ async function runInVsEnv(command) {
);
}
const sdkUmLibDir = findWindowsSdkUmLibDir();
const cmdPath = path.join(os.tmpdir(), `openscreen-build-wgc-${process.pid}-${Date.now()}.cmd`);
fs.writeFileSync(
cmdPath,
@@ -72,9 +89,9 @@ async function runInVsEnv(command) {
`call "${vcvarsAll}" x64`,
"if errorlevel 1 exit /b %errorlevel%",
`if not exist "${COMPAT_LIB_DIR}" mkdir "${COMPAT_LIB_DIR}"`,
`for %%L in (gdi32.lib winspool.lib shell32.lib oleaut32.lib uuid.lib comdlg32.lib advapi32.lib) do if not exist "%WindowsSdkDir%Lib\\%WindowsSDKLibVersion%um\\x64\\%%L" copy /Y "%WindowsSdkDir%Lib\\%WindowsSDKLibVersion%um\\x64\\kernel32.Lib" "${COMPAT_LIB_DIR}\\%%L" >nul`,
`for %%L in (gdi32.lib gdiplus.lib winspool.lib shell32.lib oleaut32.lib uuid.lib comdlg32.lib advapi32.lib) do if not exist "%WindowsSdkDir%Lib\\%WindowsSDKLibVersion%um\\x64\\%%L" copy /Y "%WindowsSdkDir%Lib\\%WindowsSDKLibVersion%um\\x64\\kernel32.Lib" "${COMPAT_LIB_DIR}\\%%L" >nul`,
"if errorlevel 1 exit /b %errorlevel%",
`set "LIB=${COMPAT_LIB_DIR};%LIB%"`,
`set "LIB=${sdkUmLibDir ? `${sdkUmLibDir};` : ""}%LIB%;${COMPAT_LIB_DIR}"`,
command,
"exit /b %errorlevel%",
"",
+154
View File
@@ -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);
});
+32 -2
View File
@@ -4,6 +4,7 @@ import { LaunchWindow } from "./components/launch/LaunchWindow";
import { SourceSelector } from "./components/launch/SourceSelector";
import { Toaster } from "./components/ui/sonner";
import { TooltipProvider } from "./components/ui/tooltip";
import { useScopedT } from "./contexts/I18nContext";
import { ShortcutsProvider } from "./contexts/ShortcutsContext";
import { loadAllCustomFonts } from "./lib/customFonts";
@@ -18,6 +19,7 @@ export default function App() {
const [windowType, setWindowType] = useState(
() => new URLSearchParams(window.location.search).get("windowType") || "",
);
const tEditor = useScopedT("editor");
useEffect(() => {
const type = new URLSearchParams(window.location.search).get("windowType") || "";
@@ -64,7 +66,35 @@ export default function App() {
case "editor":
return (
<ShortcutsProvider>
<Suspense fallback={<div className="h-screen bg-background" />}>
<Suspense
fallback={
<div className="flex flex-col items-center justify-center gap-3 h-screen bg-[#09090b]">
<svg
className="animate-spin text-[#34B27B]"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
width={28}
height={28}
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
<span className="text-white/50 text-sm">{tEditor("loadingEditor")}</span>
</div>
}
>
<VideoEditor />
<ShortcutsConfigDialog />
</Suspense>
@@ -82,7 +112,7 @@ export default function App() {
return (
<TooltipProvider>
{content}
<Toaster theme="dark" className="pointer-events-auto" />
<Toaster theme="dark" />
</TooltipProvider>
);
}
+214 -68
View File
@@ -1,4 +1,4 @@
import { Check, ChevronDown, Languages } from "lucide-react";
import { Check, ChevronDown, Clapperboard, Columns3, Languages, Rows3 } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { BsPauseCircle, BsPlayCircle, BsRecordCircle } from "react-icons/bs";
@@ -21,6 +21,7 @@ import {
import { RxDragHandleDots2 } from "react-icons/rx";
import { useI18n, useScopedT } from "@/contexts/I18nContext";
import { getAvailableLocales, getLocaleName } from "@/i18n/loader";
import { loadUserPreferences, saveUserPreferences } from "@/lib/userPreferences";
import { nativeBridgeClient } from "@/native";
import { useAudioLevelMeter } from "../../hooks/useAudioLevelMeter";
import { useCameraDevices } from "../../hooks/useCameraDevices";
@@ -32,9 +33,15 @@ import { AudioLevelMeter } from "../ui/audio-level-meter";
import { Button } from "../ui/button";
import { Tooltip } from "../ui/tooltip";
import styles from "./LaunchWindow.module.css";
import { openSourceSelectorWithPermissionRetry } from "./openSourceSelectorFlow";
const ICON_SIZE = 20;
// Vertical tray gap (px): bar's `bottom-5` (20px) plus an 8px gap.
const HUD_DEVICE_POPUP_GAP = 28;
// Horizontal layout: mirrors the `bottom-[68px]` class on the popup element.
const HUD_DEVICE_POPUP_HORIZONTAL_BOTTOM = 68;
const ICON_CONFIG = {
drag: { icon: RxDragHandleDots2, size: ICON_SIZE },
monitor: { icon: MdMonitor, size: ICON_SIZE },
@@ -59,6 +66,7 @@ const ICON_CONFIG = {
type IconName = keyof typeof ICON_CONFIG;
/** Renders the configured icon for a HUD control. */
function getIcon(name: IconName, className?: string) {
const { icon: Icon, size } = ICON_CONFIG[name];
return <Icon size={size} className={className} />;
@@ -77,7 +85,10 @@ const windowBtnClasses =
"flex h-8 w-8 items-center justify-center rounded-lg transition-all duration-150 cursor-pointer opacity-50 hover:opacity-90 hover:bg-white/[0.08]";
const hudSidebarClasses = "ml-0.5 pl-1.5 border-l border-white/10 flex items-center gap-0.5";
const hudSidebarVerticalClasses =
"mt-0.5 pt-1.5 border-t border-white/10 flex flex-col items-center gap-0.5";
/** Launches the floating recording HUD and its recorder controls. */
export function LaunchWindow() {
const t = useScopedT("launch");
const availableLocales = getAvailableLocales();
@@ -128,9 +139,16 @@ export function LaunchWindow() {
const [isWebcamFocused, setIsWebcamFocused] = useState(false);
const webcamExpanded = isWebcamHovered || isWebcamFocused;
const [isLanguageMenuOpen, setIsLanguageMenuOpen] = useState(false);
const [trayLayout, setTrayLayout] = useState<"horizontal" | "vertical">(
() => loadUserPreferences().trayLayout,
);
const [supportsCursorModeToggle, setSupportsCursorModeToggle] = useState(false);
const languageTriggerRef = useRef<HTMLButtonElement | null>(null);
const languageMenuPanelRef = useRef<HTMLDivElement | null>(null);
const hudBarRef = useRef<HTMLDivElement | null>(null);
const deviceSelectorRef = useRef<HTMLDivElement | null>(null);
// Measured bar height, anchors the popups above the tall vertical tray so they don't overlap it.
const [hudBarHeight, setHudBarHeight] = useState(0);
const [languageMenuStyle, setLanguageMenuStyle] = useState<{
right: number;
top: number;
@@ -282,6 +300,106 @@ export function LaunchWindow() {
return () => cancelAnimationFrame(id);
}, [isLanguageMenuOpen]);
// Resize the overlay window to fit content, else the taller vertical tray gets clipped
// and scrolls. Measure from the window's bottom-centre (the anchor the main process
// preserves) so fixed bottom/centre offsets keep this stable and it doesn't oscillate.
const lastHudSizeRef = useRef({ width: 0, height: 0 });
const measureHudSize = useCallback(() => {
const barEl = hudBarRef.current;
if (!barEl || !window.electronAPI?.setHudOverlaySize) return;
// Breathing room so the drop shadow isn't clipped. TOP_MARGIN must also exceed the
// slack in the bar's `max-h: calc(100vh - 2.5rem)` cap (40px reserved - 20px bottom
// gap = 20px) so the window stays tall enough that the cap never engages and adds a scrollbar.
const SIDE_MARGIN = 24;
const TOP_MARGIN = 24;
// Wide enough that the language menu (11rem) never clips, even when the bar is narrow.
const MIN_WIDTH = 220;
const viewportHeight = window.innerHeight;
const centerX = window.innerWidth / 2;
// Use natural (scroll) size, not the clipped box: vertical mode's max-h cap is a
// small-screen fallback, and reading clipped height would pin the window to it.
// scrollHeight gives full content height; the cap only engages when the main process clamps to screen.
let topFromBottom = viewportHeight - barEl.getBoundingClientRect().bottom + barEl.scrollHeight;
let halfWidth = barEl.scrollWidth / 2;
// Popups drive both dimensions too. Their vertical anchor depends on bar height,
// which is fed back through React state and lags by a frame, so derive their top
// edge from the bar's natural height instead of the stale rendered position. Keeps
// one measurement pass authoritative and avoids a feedback re-measure.
if (deviceSelectorRef.current) {
const rect = deviceSelectorRef.current.getBoundingClientRect();
if (rect.width !== 0 || rect.height !== 0) {
const popupBottomOffset =
trayLayout === "vertical"
? barEl.scrollHeight + HUD_DEVICE_POPUP_GAP
: HUD_DEVICE_POPUP_HORIZONTAL_BOTTOM;
topFromBottom = Math.max(topFromBottom, popupBottomOffset + rect.height);
halfWidth = Math.max(halfWidth, rect.width / 2);
}
}
// The language menu scrolls within available height, so it only influences width.
// Its presence in the DOM means it's open.
if (languageMenuPanelRef.current) {
const rect = languageMenuPanelRef.current.getBoundingClientRect();
halfWidth = Math.max(halfWidth, centerX - rect.left, rect.right - centerX);
}
setHudBarHeight((prev) => {
const next = Math.round(barEl.scrollHeight);
return Math.abs(prev - next) > 1 ? next : prev;
});
const width = Math.max(MIN_WIDTH, Math.ceil(halfWidth * 2) + SIDE_MARGIN);
const height = Math.ceil(topFromBottom) + TOP_MARGIN;
if (width === lastHudSizeRef.current.width && height === lastHudSizeRef.current.height) {
return;
}
lastHudSizeRef.current = { width, height };
window.electronAPI.setHudOverlaySize(width, height);
}, [trayLayout]);
// One persistent observer; elements wire themselves up via callback refs as they
// mount/unmount so measurement re-runs without recreating it or threading mount state through deps.
const hudResizeObserverRef = useRef<ResizeObserver | null>(null);
useEffect(() => {
const observer = new ResizeObserver(() => measureHudSize());
hudResizeObserverRef.current = observer;
if (hudBarRef.current) observer.observe(hudBarRef.current);
if (deviceSelectorRef.current) observer.observe(deviceSelectorRef.current);
measureHudSize();
return () => {
observer.disconnect();
hudResizeObserverRef.current = null;
};
}, [measureHudSize]);
const observeHudElement = useCallback(
<T extends HTMLElement>(el: T | null, ref: React.MutableRefObject<T | null>) => {
const observer = hudResizeObserverRef.current;
if (ref.current && observer) observer.unobserve(ref.current);
ref.current = el;
if (el && observer) observer.observe(el);
measureHudSize();
},
[measureHudSize],
);
const setHudBarEl = useCallback(
(el: HTMLDivElement | null) => observeHudElement(el, hudBarRef),
[observeHudElement],
);
const setDeviceSelectorEl = useCallback(
(el: HTMLDivElement | null) => observeHudElement(el, deviceSelectorRef),
[observeHudElement],
);
const setLanguageMenuPanelEl = useCallback(
(el: HTMLDivElement | null) => observeHudElement(el, languageMenuPanelRef),
[observeHudElement],
);
const hudMouseEventsEnabledRef = useRef<boolean | undefined>(undefined);
const setHudMouseEventsEnabled = useCallback((enabled: boolean) => {
if (hudMouseEventsEnabledRef.current === enabled) {
@@ -326,35 +444,15 @@ export function LaunchWindow() {
return () => clearInterval(interval);
}, []);
const openSourceSelector = () => {
const openSourceSelector = async () => {
if (window.electronAPI) {
window.electronAPI.openSourceSelector();
await openSourceSelectorWithPermissionRetry({
openSourceSelector: () => window.electronAPI.openSourceSelector(),
requestScreenAccess: () => window.electronAPI.requestScreenAccess(),
});
}
};
const openVideoFile = async () => {
const result = await window.electronAPI.openVideoFilePicker();
if (result.canceled) {
return;
}
if (result.success && result.path) {
const setVideoPathResult = await nativeBridgeClient.project.setCurrentVideoPath(result.path);
if (!setVideoPathResult.success) {
console.error("Failed to set current video path:", setVideoPathResult);
return;
}
await window.electronAPI.switchToEditor();
}
};
const openProjectFile = async () => {
const result = await nativeBridgeClient.project.loadProjectFile();
if (result.canceled || !result.success) return;
await window.electronAPI.switchToEditor();
};
const sendHudOverlayHide = () => {
if (window.electronAPI && window.electronAPI.hudOverlayHide) {
window.electronAPI.hudOverlayHide();
@@ -365,6 +463,12 @@ export function LaunchWindow() {
window.electronAPI.hudOverlayClose();
}
};
/** Switches the HUD between horizontal and vertical tray layouts. */
const toggleTrayLayout = () => {
const nextLayout = trayLayout === "horizontal" ? "vertical" : "horizontal";
setTrayLayout(nextLayout);
saveUserPreferences({ trayLayout: nextLayout });
};
const toggleMicrophone = () => {
if (!recording) {
@@ -396,10 +500,8 @@ export function LaunchWindow() {
};
return (
// Root fills the HUD window only. Avoid w-screen/h-screen (100vw/100vh):
// 100vw can exceed the inner layout width when scrollbars affect the
// viewport (notably on Windows), causing a horizontal scrollbar once the
// recording toolbar widened (issue #305).
// Avoid w-screen/h-screen: 100vw can exceed the inner layout width when scrollbars
// affect the viewport (Windows), causing a horizontal scrollbar (issue #305).
<div
className={`h-full w-full min-w-0 max-w-full overflow-x-hidden overflow-y-hidden bg-transparent ${styles.electronDrag}`}
onPointerMove={(event) => {
@@ -451,11 +553,19 @@ export function LaunchWindow() {
</div>
)}
{/* Device selectors — fixed above HUD bar, viewport-relative, never clipped */}
{/* Device selectors, fixed above HUD bar, viewport-relative, never clipped */}
{(showMicControls || showWebcamControls) && (
<div
ref={setDeviceSelectorEl}
data-hud-interactive="true"
className={`fixed bottom-[68px] left-1/2 -translate-x-1/2 flex items-center gap-2 animate-mic-panel-in ${styles.electronNoDrag}`}
className={`fixed left-1/2 -translate-x-1/2 flex items-center gap-2 animate-mic-panel-in ${trayLayout === "vertical" ? "" : "bottom-[68px]"} ${styles.electronNoDrag}`}
style={
trayLayout === "vertical"
? // Sit above the tall vertical tray, anchored to the measured bar
// height. Matches the offset in measureHudSize.
{ bottom: hudBarHeight + HUD_DEVICE_POPUP_GAP }
: undefined
}
>
{/* Mic selector */}
{showMicControls && (
@@ -586,10 +696,16 @@ export function LaunchWindow() {
</div>
)}
{/* HUD bar — fixed at bottom center, viewport-relative, never moves */}
{/* HUD bar, fixed at bottom center, viewport-relative, never moves */}
<div
ref={setHudBarEl}
data-hud-interactive="true"
className={`fixed bottom-5 left-1/2 -translate-x-1/2 flex items-center gap-1.5 rounded-2xl border border-white/[0.10] bg-[#07080a]/90 px-2 py-1.5 shadow-[0_20px_60px_rgba(0,0,0,0.42),inset_0_1px_0_rgba(255,255,255,0.06)] backdrop-blur-2xl backdrop-saturate-[140%]`}
data-tray-layout={trayLayout}
className={`fixed bottom-5 left-1/2 -translate-x-1/2 flex rounded-2xl border border-white/[0.10] bg-[#07080a]/90 shadow-[0_20px_60px_rgba(0,0,0,0.42),inset_0_1px_0_rgba(255,255,255,0.06)] backdrop-blur-2xl backdrop-saturate-[140%] ${
trayLayout === "vertical"
? "max-h-[calc(100vh-2.5rem)] flex-col items-center gap-1 overflow-y-auto px-1 py-1.5"
: "items-center gap-1.5 px-2 py-1.5"
}`}
onPointerEnter={() => setHudMouseEventsEnabled(true)}
onPointerDown={() => setHudMouseEventsEnabled(true)}
onMouseEnter={() => setHudMouseEventsEnabled(true)}
@@ -601,7 +717,7 @@ export function LaunchWindow() {
>
{/* Drag handle */}
<div
className={`flex h-8 w-7 cursor-grab items-center justify-center active:cursor-grabbing ${styles.electronNoDrag}`}
className={`flex ${trayLayout === "vertical" ? "h-6 w-8" : "h-8 w-7"} cursor-grab items-center justify-center active:cursor-grabbing ${styles.electronNoDrag}`}
onPointerDown={handleHudDragPointerDown}
onPointerMove={handleHudDragPointerMove}
onPointerUp={handleHudDragPointerEnd}
@@ -610,21 +726,54 @@ export function LaunchWindow() {
{getIcon("drag", "text-white/30")}
</div>
<Tooltip
content={
trayLayout === "horizontal"
? t("tooltips.useVerticalTray")
: t("tooltips.useHorizontalTray")
}
>
<button
data-testid="launch-tray-layout-button"
type="button"
aria-label={
trayLayout === "horizontal"
? t("tooltips.useVerticalTray")
: t("tooltips.useHorizontalTray")
}
aria-pressed={trayLayout === "vertical"}
className={`${hudIconBtnClasses} ${styles.electronNoDrag}`}
onClick={toggleTrayLayout}
>
{trayLayout === "horizontal" ? (
<Columns3 size={ICON_SIZE} className="text-white/60" />
) : (
<Rows3 size={ICON_SIZE} className="text-white/60" />
)}
</button>
</Tooltip>
{/* Source selector */}
<button
className={`${hudGroupClasses} h-8 px-2.5 ${styles.electronNoDrag}`}
data-testid="launch-source-selector-button"
className={`${hudGroupClasses} h-8 ${trayLayout === "vertical" ? "w-8 justify-center px-0" : "px-2.5"} ${styles.electronNoDrag}`}
onClick={openSourceSelector}
disabled={recording}
title={selectedSource}
aria-label={selectedSource}
>
{getIcon("monitor", "text-white/80")}
<span className="max-w-[86px] truncate text-[11px] font-medium text-white/75">
<span
className={`${trayLayout === "vertical" ? "sr-only" : "max-w-[86px]"} truncate text-[11px] font-medium text-white/75`}
>
{selectedSource}
</span>
</button>
{/* Audio controls group */}
<div className={`${hudGroupClasses} ${styles.electronNoDrag}`}>
<div
className={`${hudGroupClasses} ${trayLayout === "vertical" ? "flex-col py-1" : ""} ${styles.electronNoDrag}`}
>
<button
data-testid="launch-system-audio-button"
className={`${hudIconBtnClasses} ${systemAudioEnabled ? "drop-shadow-[0_0_4px_rgba(74,222,128,0.4)]" : ""}`}
@@ -697,7 +846,7 @@ export function LaunchWindow() {
{/* Record/Stop group */}
<button
data-testid="launch-record-button"
className={`flex items-center justify-center rounded-full p-2 transition-[min-width,background-color] duration-150 ${recording ? "min-w-[78px]" : "min-w-[36px]"} ${styles.electronNoDrag} ${
className={`flex items-center justify-center rounded-full p-2 transition-[min-width,background-color] duration-150 ${recording ? "min-w-[78px]" : "min-w-[36px]"} ${trayLayout === "vertical" ? "min-h-9" : ""} ${styles.electronNoDrag} ${
recording
? paused
? "bg-amber-500/10 hover:bg-amber-500/15"
@@ -723,7 +872,9 @@ export function LaunchWindow() {
</button>
{recording && (
<div className={`flex items-center gap-0.5 ${styles.electronNoDrag}`}>
<div
className={`flex items-center gap-0.5 ${trayLayout === "vertical" ? "flex-col" : ""} ${styles.electronNoDrag}`}
>
{canPauseRecording && (
<Tooltip
content={paused ? t("tooltips.resumeRecording") : t("tooltips.pauseRecording")}
@@ -750,33 +901,21 @@ export function LaunchWindow() {
)}
{!recording && (
<>
{/* Open video file */}
<Tooltip content={t("tooltips.openVideoFile")}>
<button
data-testid="launch-open-video-button"
className={`${hudIconBtnClasses} ${styles.electronNoDrag}`}
onClick={openVideoFile}
>
{getIcon("videoFile", "text-white/60")}
</button>
</Tooltip>
{/* Open project */}
<Tooltip content={t("tooltips.openProject")}>
<button
data-testid="launch-open-project-button"
className={`${hudIconBtnClasses} ${styles.electronNoDrag}`}
onClick={openProjectFile}
>
{getIcon("folder", "text-white/60")}
</button>
</Tooltip>
</>
<Tooltip content={t("tooltips.openStudio")}>
<button
data-testid="launch-open-studio-button"
className={`${hudIconBtnClasses} ${styles.electronNoDrag}`}
onClick={() => window.electronAPI.switchToEditor()}
>
<Clapperboard size={ICON_SIZE} className="text-white/60" />
</button>
</Tooltip>
)}
{/* Right sidebar controls */}
<div className={`${hudSidebarClasses} ${styles.electronNoDrag}`}>
<div
className={`${trayLayout === "vertical" ? hudSidebarVerticalClasses : hudSidebarClasses} ${styles.electronNoDrag}`}
>
<div className={`${styles.languageMenuContainer} ${styles.electronNoDrag}`}>
<button
ref={languageTriggerRef}
@@ -785,10 +924,15 @@ export function LaunchWindow() {
aria-expanded={isLanguageMenuOpen}
aria-haspopup="menu"
onClick={() => setIsLanguageMenuOpen((open) => !open)}
className={`flex h-8 items-center gap-1.5 rounded-lg border border-white/10 bg-white/[0.045] px-2 text-white/85 shadow-none transition-colors hover:bg-white/10 ${styles.electronNoDrag}`}
title={activeLanguageLabel}
className={`flex h-8 items-center rounded-lg border border-white/10 bg-white/[0.045] text-white/85 shadow-none transition-colors hover:bg-white/10 ${
trayLayout === "vertical" ? "w-8 justify-center px-0" : "gap-1.5 px-2"
} ${styles.electronNoDrag}`}
>
<Languages size={13} className="text-white/70" />
<span className="max-w-[54px] truncate text-[10px] font-semibold text-white/75">
<span
className={`${trayLayout === "vertical" ? "sr-only" : "max-w-[54px]"} truncate text-[10px] font-semibold text-white/75`}
>
{activeLanguageLabel}
</span>
</button>
@@ -797,7 +941,7 @@ export function LaunchWindow() {
{isLanguageMenuOpen
? createPortal(
<div
ref={languageMenuPanelRef}
ref={setLanguageMenuPanelEl}
data-hud-interactive="true"
role="menu"
className={`${styles.languageMenuPanel} ${styles.languageMenuScroll} ${styles.electronNoDrag}`}
@@ -841,7 +985,9 @@ export function LaunchWindow() {
: null}
{/* Window controls */}
<div className="flex items-center gap-0.5">
<div
className={`flex items-center gap-0.5 ${trayLayout === "vertical" ? "flex-col" : ""}`}
>
<button
className={windowBtnClasses}
title={t("tooltips.hideHUD")}
@@ -0,0 +1,78 @@
import "@testing-library/jest-dom";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { SourceSelector } from "./SourceSelector";
vi.mock("@/contexts/I18nContext", () => ({
useScopedT: (namespace: string) => {
if (namespace === "common") {
return (key: string) => {
if (key === "actions.cancel") return "Cancel";
if (key === "actions.share") return "Share";
if (key === "actions.reload") return "Reload";
return key;
};
}
return (key: string, vars?: Record<string, string>) => {
if (key === "sourceSelector.loading") return "Loading sources...";
if (key === "sourceSelector.emptyTitle") return "No screens or windows found";
if (key === "sourceSelector.emptyDescription") {
return "If you just granted screen recording permission, reload this picker. On macOS you may need to reopen OpenScreen.";
}
if (key === "sourceSelector.loadFailedDescription") {
return "OpenScreen could not load capture sources. Reload this picker and try again.";
}
if (key === "sourceSelector.screens") return `Screens (${vars?.count ?? "0"})`;
if (key === "sourceSelector.windows") return `Windows (${vars?.count ?? "0"})`;
return key;
};
},
}));
describe("SourceSelector", () => {
beforeEach(() => {
window.electronAPI = {
...window.electronAPI,
getSources: vi.fn().mockResolvedValue([]),
selectSource: vi.fn(),
} as typeof window.electronAPI;
});
it("shows a retry state when no capture sources are available", async () => {
render(<SourceSelector />);
await screen.findByText("No screens or windows found");
expect(screen.getByRole("button", { name: "Reload" })).toBeInTheDocument();
});
it("reloads capture sources from the empty state", async () => {
const getSources = vi
.fn()
.mockResolvedValueOnce([])
.mockResolvedValueOnce([
{
id: "screen:1:0",
name: "Display 1",
thumbnail: "data:image/png;base64,abc",
display_id: "1",
appIcon: null,
},
]);
window.electronAPI = {
...window.electronAPI,
getSources,
selectSource: vi.fn(),
} as typeof window.electronAPI;
render(<SourceSelector />);
await screen.findByText("No screens or windows found");
fireEvent.click(screen.getByRole("button", { name: "Reload" }));
await waitFor(() => {
expect(screen.getByText("Display 1")).toBeInTheDocument();
});
expect(getSources).toHaveBeenCalledTimes(2);
});
});
+66 -29
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { MdCheck } from "react-icons/md";
import { useScopedT } from "@/contexts/I18nContext";
import { Button } from "../ui/button";
@@ -19,39 +19,49 @@ export function SourceSelector() {
const [sources, setSources] = useState<DesktopSource[]>([]);
const [selectedSource, setSelectedSource] = useState<DesktopSource | null>(null);
const [loading, setLoading] = useState(true);
const [loadFailed, setLoadFailed] = useState(false);
const fetchSources = useCallback(async () => {
setLoading(true);
setLoadFailed(false);
try {
const rawSources = await window.electronAPI.getSources({
types: ["screen", "window"],
thumbnailSize: { width: 320, height: 180 },
fetchWindowIcons: true,
});
setSources(
rawSources.map((source) => ({
id: source.id,
name:
source.id.startsWith("window:") && source.name.includes(" — ")
? source.name.split(" — ")[1] || source.name
: source.name,
thumbnail: source.thumbnail,
display_id: source.display_id,
appIcon: source.appIcon,
})),
);
setSelectedSource((current) =>
current && rawSources.some((source) => source.id === current.id) ? current : null,
);
} catch (error) {
console.error("Error loading sources:", error);
setSources([]);
setSelectedSource(null);
setLoadFailed(true);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
async function fetchSources() {
setLoading(true);
try {
const rawSources = await window.electronAPI.getSources({
types: ["screen", "window"],
thumbnailSize: { width: 320, height: 180 },
fetchWindowIcons: true,
});
setSources(
rawSources.map((source) => ({
id: source.id,
name:
source.id.startsWith("window:") && source.name.includes(" — ")
? source.name.split(" — ")[1] || source.name
: source.name,
thumbnail: source.thumbnail,
display_id: source.display_id,
appIcon: source.appIcon,
})),
);
} catch (error) {
console.error("Error loading sources:", error);
} finally {
setLoading(false);
}
}
fetchSources();
}, []);
void fetchSources();
}, [fetchSources]);
const screenSources = sources.filter((s) => s.id.startsWith("screen:"));
const windowSources = sources.filter((s) => s.id.startsWith("window:"));
const hasNoSources = !loading && sources.length === 0;
const handleSourceSelect = (source: DesktopSource) => setSelectedSource(source);
const handleShare = async () => {
@@ -72,11 +82,38 @@ export function SourceSelector() {
);
}
if (hasNoSources) {
return (
<div
className={`h-full flex items-center justify-center ${styles.glassContainer}`}
style={{ minHeight: "100vh" }}
>
<div className="max-w-[320px] px-6 text-center">
<h2 className="text-sm font-semibold text-white">{t("sourceSelector.emptyTitle")}</h2>
<p className="mt-2 text-xs leading-5 text-zinc-400">
{loadFailed
? t("sourceSelector.loadFailedDescription")
: t("sourceSelector.emptyDescription")}
</p>
<Button
onClick={() => void fetchSources()}
className="mt-4 h-8 rounded-lg bg-[#34B27B] px-5 text-[11px] font-semibold text-white transition-transform duration-150 hover:bg-[#34B27B]/85 active:scale-95"
>
{tc("actions.reload")}
</Button>
</div>
</div>
);
}
const renderSourceCard = (source: DesktopSource) => {
const isSelected = selectedSource?.id === source.id;
const sourceKind = source.id.startsWith("screen:") ? "screen" : "window";
return (
<div
key={source.id}
data-testid="source-selector-card"
data-source-kind={sourceKind}
className={`${styles.sourceCard} ${isSelected ? styles.selected : ""} p-1.5`}
onClick={() => handleSourceSelect(source)}
>
@@ -0,0 +1,73 @@
import { describe, expect, it, vi } from "vitest";
import { openSourceSelectorWithPermissionRetry } from "./openSourceSelectorFlow";
describe("openSourceSelectorWithPermissionRetry", () => {
it("returns immediately when the source selector opens on the first attempt", async () => {
const openSourceSelector = vi.fn().mockResolvedValue({ opened: true });
const requestScreenAccess = vi.fn();
const result = await openSourceSelectorWithPermissionRetry({
openSourceSelector,
requestScreenAccess,
wait: vi.fn(),
});
expect(result).toEqual({ opened: true });
expect(openSourceSelector).toHaveBeenCalledTimes(1);
expect(requestScreenAccess).not.toHaveBeenCalled();
});
it("retries opening after macOS screen permission becomes granted", async () => {
const openSourceSelector = vi
.fn()
.mockResolvedValueOnce({
opened: false,
reason: "screen-access-required",
access: { success: true, granted: false, status: "not-determined" },
})
.mockResolvedValueOnce({ opened: true });
const requestScreenAccess = vi
.fn()
.mockResolvedValueOnce({ success: true, granted: false, status: "not-determined" })
.mockResolvedValueOnce({ success: true, granted: true, status: "granted" });
const wait = vi.fn().mockResolvedValue(undefined);
const result = await openSourceSelectorWithPermissionRetry({
openSourceSelector,
requestScreenAccess,
wait,
maxAttempts: 4,
});
expect(result).toEqual({ opened: true });
expect(wait).toHaveBeenCalledTimes(2);
expect(requestScreenAccess).toHaveBeenCalledTimes(2);
expect(openSourceSelector).toHaveBeenCalledTimes(2);
});
it("stops retrying once macOS permission is explicitly denied", async () => {
const openSourceSelector = vi.fn().mockResolvedValue({
opened: false,
reason: "screen-access-required",
access: { success: true, granted: false, status: "not-determined" },
});
const requestScreenAccess = vi
.fn()
.mockResolvedValueOnce({ success: true, granted: false, status: "denied" });
const result = await openSourceSelectorWithPermissionRetry({
openSourceSelector,
requestScreenAccess,
wait: vi.fn(),
maxAttempts: 4,
});
expect(result).toEqual({
opened: false,
reason: "screen-access-required",
access: { success: true, granted: false, status: "denied" },
});
expect(requestScreenAccess).toHaveBeenCalledTimes(1);
expect(openSourceSelector).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,62 @@
export type ScreenAccessResult = {
success: boolean;
granted: boolean;
status: string;
error?: string;
};
export type OpenSourceSelectorResult = {
opened: boolean;
reason?: string;
access?: ScreenAccessResult;
};
type OpenSourceSelectorFlowOptions = {
openSourceSelector: () => Promise<OpenSourceSelectorResult>;
requestScreenAccess: () => Promise<ScreenAccessResult>;
wait?: (ms: number) => Promise<void>;
retryDelayMs?: number;
maxAttempts?: number;
};
const defaultWait = (ms: number) => new Promise<void>((resolve) => window.setTimeout(resolve, ms));
function shouldRetryAfterPermissionPrompt(result: OpenSourceSelectorResult): boolean {
return (
result.opened === false &&
result.reason === "screen-access-required" &&
result.access?.status === "not-determined"
);
}
export async function openSourceSelectorWithPermissionRetry({
openSourceSelector,
requestScreenAccess,
wait = defaultWait,
retryDelayMs = 750,
maxAttempts = 8,
}: OpenSourceSelectorFlowOptions): Promise<OpenSourceSelectorResult> {
const initialResult = await openSourceSelector();
if (!shouldRetryAfterPermissionPrompt(initialResult)) {
return initialResult;
}
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
await wait(retryDelayMs);
const access = await requestScreenAccess();
if (access.granted) {
return openSourceSelector();
}
if (access.status !== "not-determined") {
return {
opened: false,
reason: "screen-access-required",
access,
};
}
}
return initialResult;
}
+2 -4
View File
@@ -46,8 +46,7 @@ export default function ColorPicker(props: ColorPickerProps) {
return "#ffffff";
};
// Normalize the hex input.
// Adds a # at the beginning of the input if it's not there.
// Prefix a # when the user typed a bare hex value.
const normalizeHexDraft = (raw: string) => {
const trimmed = raw.trim();
if (trimmed === "") return "";
@@ -58,8 +57,7 @@ export default function ColorPicker(props: ColorPickerProps) {
const handleColorInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const normalized = normalizeHexDraft(e.target.value);
setHexInput(normalized);
// Check if the normalized hex is a valid hex color.
// It should follow the format #RRGGBB or #RGB.
// Only push when it's a complete #RGB or #RRGGBB value.
const isValidHexColor =
/^#[0-9A-Fa-f]{3}$/.test(normalized) || /^#[0-9A-Fa-f]{6}$/.test(normalized);
if (isValidHexColor) {
+2 -1
View File
@@ -82,7 +82,8 @@ const SelectContent = React.forwardRef<
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
/* Above Dialog (z-[10000]) and fullscreen overlays (e.g. z-[99999]) */
"relative z-[100000] max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1",
className,
+9 -5
View File
@@ -1,18 +1,22 @@
import { Toaster as Sonner } from "sonner";
import { cn } from "@/lib/utils";
type ToasterProps = React.ComponentProps<typeof Sonner>;
const Toaster = ({ ...props }: ToasterProps) => {
const Toaster = ({ className, ...props }: ToasterProps) => {
return (
<Sonner
theme="light"
className="toaster group"
theme="dark"
className={cn(
"dark toaster group pointer-events-none [&_[data-sonner-toast]]:pointer-events-auto",
className,
)}
duration={3000}
toastOptions={{
classNames: {
toast:
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
description: "group-[.toast]:text-muted-foreground",
"group toast border border-white/10 bg-[#09090b] text-slate-200 shadow-lg backdrop-blur-xl",
description: "group-[.toast]:text-slate-400",
actionButton: "group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
cancelButton: "group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
},
+2 -2
View File
@@ -10,7 +10,7 @@ const Switch = React.forwardRef<
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50",
"data-[state=checked]:bg-[#34B27B] data-[state=unchecked]:bg-[#23232a]",
"data-[state=checked]:bg-[#34B27B] data-[state=unchecked]:bg-[#52525b]",
className,
)}
{...props}
@@ -19,7 +19,7 @@ const Switch = React.forwardRef<
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-4 w-4 rounded-full shadow-lg ring-0 transition-transform",
"bg-[#f5f5f7] dark:bg-[#23232a]",
"bg-white",
"data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0",
)}
/>
@@ -36,7 +36,6 @@ export function AddCustomFontDialog({ onFontAdded }: AddCustomFontDialogProps) {
const handleImportUrlChange = (url: string) => {
setImportUrl(url);
// Auto-extract font name if valid Google Fonts URL
if (isValidGoogleFontsUrl(url)) {
const extracted = parseFontFamilyFromImport(url);
if (extracted && !fontName) {
@@ -46,7 +45,6 @@ export function AddCustomFontDialog({ onFontAdded }: AddCustomFontDialogProps) {
};
const handleAdd = async () => {
// Validate inputs
if (!importUrl.trim()) {
toast.error(t("customFont.errorEmptyUrl"));
return;
@@ -65,7 +63,6 @@ export function AddCustomFontDialog({ onFontAdded }: AddCustomFontDialogProps) {
setLoading(true);
try {
// Extract font family from URL
const fontFamily = parseFontFamilyFromImport(importUrl);
if (!fontFamily) {
toast.error(t("customFont.errorExtractFailed"));
@@ -73,7 +70,6 @@ export function AddCustomFontDialog({ onFontAdded }: AddCustomFontDialogProps) {
return;
}
// Create custom font object
const newFont: CustomFont = {
id: generateFontId(fontName),
name: fontName.trim(),
@@ -81,17 +77,15 @@ export function AddCustomFontDialog({ onFontAdded }: AddCustomFontDialogProps) {
importUrl: importUrl.trim(),
};
// Add font (this will load and verify it) - throws if it fails
// Loads and verifies the font; throws on failure
await addCustomFont(newFont);
// Notify parent
if (onFontAdded) {
onFontAdded(newFont);
}
toast.success(t("customFont.successMessage", { fontName }));
// Reset and close
setImportUrl("");
setFontName("");
setOpen(false);
@@ -1,5 +1,6 @@
import { type CSSProperties, type PointerEvent, useEffect, useRef, useState } from "react";
import { Rnd } from "react-rnd";
import { getTextAnimationState } from "@/lib/annotationTextAnimation";
import {
getBlurOverlayColor,
getMosaicGridOverlayColor,
@@ -47,9 +48,10 @@ interface AnnotationOverlayProps {
onBlurDataCommit?: () => void;
onClick: (id: string) => void;
zIndex: number;
isSelectedBoost: boolean; // Boost z-index when selected for easy editing
isSelectedBoost: boolean; // raise z-index when selected, for easier editing
previewSourceCanvas?: PreviewCanvasSource | null;
previewFrameVersion?: number;
currentTimeMs: number;
}
export function AnnotationOverlay({
@@ -66,6 +68,7 @@ export function AnnotationOverlay({
isSelectedBoost,
previewSourceCanvas,
previewFrameVersion,
currentTimeMs,
}: AnnotationOverlayProps) {
const committedX = (annotation.position.x / 100) * containerWidth;
const committedY = (annotation.position.y / 100) * containerHeight;
@@ -283,7 +286,12 @@ export function AnnotationOverlay({
const renderContent = () => {
switch (annotation.type) {
case "text":
case "text": {
const animationState = getTextAnimationState(annotation, currentTimeMs);
const typewriterClip =
animationState.revealProgress < 1
? `inset(0 ${100 - animationState.revealProgress * 100}% 0 0)`
: undefined;
return (
<div
className="w-full h-full flex items-center p-2 overflow-hidden"
@@ -307,6 +315,11 @@ export function AnnotationOverlay({
fontStyle: annotation.style.fontStyle,
textDecoration: annotation.style.textDecoration,
textAlign: annotation.style.textAlign,
opacity: animationState.opacity,
transform: `translate(${animationState.translateX}px, ${animationState.translateY}px) scale(${animationState.scale})`,
transformOrigin: "center",
clipPath: typewriterClip,
WebkitClipPath: typewriterClip,
wordBreak: "break-word",
whiteSpace: "pre-wrap",
boxDecorationBreak: "clone",
@@ -320,6 +333,7 @@ export function AnnotationOverlay({
</span>
</div>
);
}
case "image":
if (annotation.content && annotation.content.startsWith("data:image")) {
@@ -523,7 +537,7 @@ export function AnnotationOverlay({
const yPercent = (d.y / containerHeight) * 100;
onPositionChange(annotation.id, { x: xPercent, y: yPercent });
// Reset dragging flag after a short delay to prevent click event
// Delay clearing so the trailing click doesn't fire onClick
setTimeout(() => {
isDraggingRef.current = false;
}, 100);
@@ -562,7 +576,7 @@ export function AnnotationOverlay({
"ring-2 ring-[#34B27B] ring-offset-2 ring-offset-transparent",
)}
style={{
zIndex: isSelectedBoost ? zIndex + 1000 : zIndex, // Boost selected annotation to ensure it's on top
zIndex: isSelectedBoost ? zIndex + 1000 : zIndex, // keep the selected annotation on top
pointerEvents: isSelected ? "auto" : "none",
border:
isSelected && annotation.type !== "blur" ? "2px solid rgba(52, 178, 123, 0.8)" : "none",
@@ -28,6 +28,7 @@ import { Slider } from "@/components/ui/slider";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { useScopedT } from "@/contexts/I18nContext";
import { normalizeTextAnimation, TEXT_ANIMATION_OPTIONS } from "@/lib/annotationTextAnimation";
import { type CustomFont, getCustomFonts } from "@/lib/customFonts";
import { cn } from "@/lib/utils";
import ColorPicker from "../ui/color-picker";
@@ -50,7 +51,11 @@ interface AnnotationSettingsPanelProps {
onDelete: () => void;
}
const FONT_FAMILIES = [
const FONT_FAMILIES: Array<
| { value: string; labelKey: string; name?: never }
| { value: string; labelKey?: never; name: string }
> = [
{ value: "Inter", name: "Inter" },
{ value: "system-ui, -apple-system, sans-serif", labelKey: "classic" },
{ value: "Georgia, serif", labelKey: "editor" },
{ value: "Impact, Arial Black, sans-serif", labelKey: "strong" },
@@ -59,6 +64,21 @@ const FONT_FAMILIES = [
{ value: "Arial, sans-serif", labelKey: "simple" },
{ value: "Verdana, sans-serif", labelKey: "modern" },
{ value: "Trebuchet MS, sans-serif", labelKey: "clean" },
{ value: '"Plus Jakarta Sans", sans-serif', name: "Plus Jakarta Sans" },
{ value: '"Space Grotesk", sans-serif', name: "Space Grotesk" },
{ value: '"DM Sans", sans-serif', name: "DM Sans" },
{ value: "Sora, sans-serif", name: "Sora" },
{ value: "Manrope, sans-serif", name: "Manrope" },
{ value: '"IBM Plex Sans", sans-serif', name: "IBM Plex Sans" },
{ value: '"Playfair Display", Georgia, serif', name: "Playfair Display" },
{ value: "Merriweather, Georgia, serif", name: "Merriweather" },
{ value: "Lora, Georgia, serif", name: "Lora" },
{ value: '"IBM Plex Mono", monospace', name: "IBM Plex Mono" },
{ value: '"Fira Code", monospace', name: "Fira Code" },
{ value: '"Bebas Neue", sans-serif', name: "Bebas Neue" },
{ value: "Oswald, sans-serif", name: "Oswald" },
{ value: "Caveat, cursive", name: "Caveat" },
{ value: '"Permanent Marker", cursive', name: "Permanent Marker" },
];
const FONT_SIZES = [12, 14, 16, 18, 20, 24, 28, 32, 36, 40, 48, 56, 64, 72, 80, 96, 128];
@@ -85,8 +105,9 @@ export function AnnotationSettingsPanel({
modern: t("fontStyles.modern"),
clean: t("fontStyles.clean"),
};
const getFontLabel = (font: (typeof FONT_FAMILIES)[number]) =>
font.labelKey ? fontStyleLabels[font.labelKey] : font.name;
// Load custom fonts on mount
useEffect(() => {
setCustomFonts(getCustomFonts());
}, []);
@@ -116,7 +137,6 @@ export function AnnotationSettingsPanel({
const file = files[0];
// Validate file type
const validTypes = ["image/jpeg", "image/jpg", "image/png", "image/gif", "image/webp"];
if (!validTypes.includes(file.type)) {
toast.error(t("annotation.invalidImageType"), {
@@ -231,7 +251,7 @@ export function AnnotationSettingsPanel({
value={font.value}
style={{ fontFamily: font.value }}
>
{fontStyleLabels[font.labelKey]}
{getFontLabel(font)}
</SelectItem>
))}
{customFonts.length > 0 && (
@@ -285,6 +305,29 @@ export function AnnotationSettingsPanel({
/>
</div>
<div>
<label className="mb-2 block text-xs font-medium text-slate-200">
{t("annotation.textAnimation")}
</label>
<Select
value={normalizeTextAnimation(annotation.style.textAnimation)}
onValueChange={(value) =>
onStyleChange({ textAnimation: normalizeTextAnimation(value) })
}
>
<SelectTrigger className="h-9 w-full border-white/10 bg-white/5 text-xs text-slate-200">
<SelectValue placeholder={t("annotation.selectAnimation")} />
</SelectTrigger>
<SelectContent className="max-h-[240px] border-white/10 bg-[#1a1a1c] text-slate-200">
{TEXT_ANIMATION_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{t(option.translationKey)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Formatting Toggles */}
<div className="flex items-center justify-between gap-2">
<ToggleGroup
+1 -3
View File
@@ -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) {
+3 -2
View File
@@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from "react";
import { cn } from "@/lib/utils";
import { type AspectRatio } from "@/utils/aspectRatioUtils";
import { DEFAULT_SOURCE_DIMENSIONS } from "./editorDefaults";
interface CropRegion {
x: number; // 0-1 normalized
@@ -32,8 +33,8 @@ export function CropControl({ videoElement, cropRegion, onCropChange }: CropCont
const ctx = canvas.getContext("2d", { alpha: false });
if (!ctx) return;
canvas.width = videoElement.videoWidth || 1920;
canvas.height = videoElement.videoHeight || 1080;
canvas.width = videoElement.videoWidth || DEFAULT_SOURCE_DIMENSIONS.width;
canvas.height = videoElement.videoHeight || DEFAULT_SOURCE_DIMENSIONS.height;
const draw = () => {
if (videoElement.readyState >= 2) {
@@ -0,0 +1,209 @@
import { AlertCircle, Film, FolderOpen, Upload, X } from "lucide-react";
import { useCallback, useRef, useState } from "react";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { useScopedT } from "@/contexts/I18nContext";
import { getProjectFolder, parentDirectoryOf, saveUserPreferences } from "@/lib/userPreferences";
import { nativeBridgeClient } from "@/native";
interface EditorEmptyStateProps {
onVideoImported: (videoPath: string) => void;
/** Called with the loaded project data; handles both button click and drag-drop */
onProjectOpened: (project: unknown, path: string | null) => void;
}
type DropError = "unsupported-format" | "load-failed" | null;
export function EditorEmptyState({ onVideoImported, onProjectOpened }: EditorEmptyStateProps) {
const te = useScopedT("editor");
const tc = useScopedT("common");
const [isDraggingOver, setIsDraggingOver] = useState(false);
const [dropError, setDropError] = useState<DropError>(null);
// Freeze the last non-null error type so dialog content doesn't snap to the else-branch
// during the closing animation (same pattern as UnsavedChangesDialog).
const lastDropErrorRef = useRef<Exclude<DropError, null>>("unsupported-format");
if (dropError !== null) {
lastDropErrorRef.current = dropError;
}
const handleImportVideo = useCallback(async () => {
const result = await window.electronAPI.openVideoFilePicker();
if (result.canceled || !result.success || !result.path) return;
const setResult = await nativeBridgeClient.project.setCurrentVideoPath(result.path);
if (!setResult.success) return;
onVideoImported(result.path);
}, [onVideoImported]);
const handleLoadProject = useCallback(async () => {
const result = await nativeBridgeClient.project.loadProjectFile(getProjectFolder());
if (result.canceled || !result.success || !result.project) return;
if (result.path) {
const folder = parentDirectoryOf(result.path);
if (folder) {
saveUserPreferences({ projectFolder: folder });
}
}
onProjectOpened(result.project, result.path ?? null);
}, [onProjectOpened]);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
if (e.dataTransfer.items.length > 0) {
setIsDraggingOver(true);
}
}, []);
const handleDragLeave = useCallback((e: React.DragEvent) => {
if (!e.currentTarget.contains(e.relatedTarget as Node)) {
setIsDraggingOver(false);
}
}, []);
const handleDrop = useCallback(
async (e: React.DragEvent) => {
e.preventDefault();
setIsDraggingOver(false);
const files = Array.from(e.dataTransfer.files);
if (files.length === 0) return;
const projectFile = files.find((f) => f.name.endsWith(".openscreen"));
if (!projectFile) {
setDropError("unsupported-format");
return;
}
// Use Electron's webUtils.getPathForFile; File.path was removed in Electron 32+
let filePath: string;
try {
filePath = window.electronAPI.getPathForFile(projectFile);
} catch {
setDropError("load-failed");
return;
}
if (!filePath) {
setDropError("load-failed");
return;
}
let result: Awaited<ReturnType<typeof window.electronAPI.loadProjectFileFromPath>>;
try {
result = await window.electronAPI.loadProjectFileFromPath(filePath);
} catch {
setDropError("load-failed");
return;
}
if (!result.success || !result.project) {
setDropError("load-failed");
return;
}
onProjectOpened(result.project, result.path ?? null);
},
[onProjectOpened],
);
return (
<div
className="flex h-full w-full flex-col items-center justify-center bg-[#09090b]"
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
{/* Drop overlay */}
{isDraggingOver && (
<div className="pointer-events-none absolute inset-0 z-50 flex flex-col items-center justify-center rounded-xl border-2 border-dashed border-[#34B27B] bg-[#34B27B]/10">
<Upload className="mb-3 h-10 w-10 text-[#34B27B]" />
<p className="text-base font-semibold text-[#34B27B]">{te("emptyState.dropOverlay")}</p>
</div>
)}
{/* Drop error dialog */}
<Dialog open={dropError !== null} onOpenChange={(open) => !open && setDropError(null)}>
<DialogContent className="bg-[#09090b] border-white/10 rounded-2xl max-w-sm p-6 gap-0">
<DialogHeader className="mb-4">
<div className="flex items-center gap-3">
<img
src="./openscreen.png"
alt=""
aria-hidden="true"
className="w-9 h-9 rounded-xl flex-shrink-0"
/>
<DialogTitle className="text-base font-semibold text-slate-200 leading-tight">
{lastDropErrorRef.current === "unsupported-format"
? te("emptyState.dropErrors.unsupportedFormatTitle")
: te("emptyState.dropErrors.couldNotOpenTitle")}
</DialogTitle>
</div>
</DialogHeader>
<div className="flex flex-col items-center gap-3 mb-6 text-center">
<div className="flex items-center justify-center w-10 h-10 rounded-full bg-white/5 ring-1 ring-white/10">
<AlertCircle className="w-5 h-5 text-slate-400 flex-shrink-0" />
</div>
<p className="text-sm text-slate-400 leading-relaxed">
{lastDropErrorRef.current === "unsupported-format"
? te("emptyState.dropErrors.unsupportedFormatMessage")
: te("emptyState.dropErrors.couldNotOpenMessage")}
</p>
</div>
<button
type="button"
onClick={() => setDropError(null)}
className="flex items-center justify-center gap-2 w-full px-4 py-2.5 rounded-lg bg-white/5 hover:bg-white/10 border border-white/10 text-slate-300 font-medium text-sm transition-colors outline-none focus-visible:ring-2 focus-visible:ring-white/30 focus-visible:ring-offset-2 focus-visible:ring-offset-[#09090b]"
>
<X className="w-4 h-4" />
{tc("actions.close")}
</button>
</DialogContent>
</Dialog>
<div className="relative flex flex-col items-center gap-8 px-6 text-center">
{/* Logo */}
<img
src="./openscreen.png"
alt=""
aria-hidden="true"
className="h-16 w-16 rounded-2xl opacity-90"
/>
<div className="flex flex-col gap-2">
<h2 className="text-xl font-semibold text-slate-200">{te("emptyState.title")}</h2>
<p className="max-w-sm text-sm leading-relaxed text-slate-500">
{te("emptyState.description")}
</p>
</div>
{/* Actions */}
<div className="flex flex-col gap-3 w-full max-w-xs">
<button
type="button"
onClick={handleImportVideo}
className="flex items-center justify-center gap-2.5 w-full px-4 py-3 rounded-xl bg-[#34B27B] hover:bg-[#2d9e6c] active:bg-[#27885c] text-white font-medium text-sm transition-colors outline-none focus-visible:ring-2 focus-visible:ring-[#34B27B] focus-visible:ring-offset-2 focus-visible:ring-offset-[#09090b]"
>
<Film className="h-4 w-4" />
{te("emptyState.importVideoButton")}
</button>
<button
type="button"
onClick={handleLoadProject}
className="flex items-center justify-center gap-2.5 w-full px-4 py-3 rounded-xl bg-white/5 hover:bg-white/10 border border-white/10 text-slate-300 font-medium text-sm transition-colors outline-none focus-visible:ring-2 focus-visible:ring-white/30 focus-visible:ring-offset-2 focus-visible:ring-offset-[#09090b]"
>
<FolderOpen className="h-4 w-4" />
{te("emptyState.loadProjectButton")}
</button>
</div>
<div className="flex flex-col items-center gap-2">
<p className="text-xs text-slate-600">{te("emptyState.supportedFormats")}</p>
<div className="flex items-center gap-1.5 text-xs text-slate-700 mt-4">
<Upload className="h-3 w-3" />
<span>{te("emptyState.dragDropHint")}</span>
</div>
</div>
</div>
</div>
);
}
+3 -6
View File
@@ -30,14 +30,13 @@ export function ExportDialog({
const t = useScopedT("dialogs");
const [showSuccess, setShowSuccess] = useState(false);
// Reset showSuccess when a new export starts or dialog reopens
useEffect(() => {
if (isExporting) {
setShowSuccess(false);
}
}, [isExporting]);
// Reset showSuccess when dialog opens fresh
// Reset when the dialog opens fresh (not mid-export).
useEffect(() => {
if (isOpen && !isExporting && !progress) {
setShowSuccess(false);
@@ -59,13 +58,12 @@ export function ExportDialog({
const formatLabel = exportFormat === "gif" ? "GIF" : "Video";
// Determine if we're in the compiling phase (frames done but still exporting)
// Compiling phase: frames are done but the export is still finishing.
const isCompiling =
isExporting && progress && progress.percentage >= 100 && exportFormat === "gif";
const isFinalizing = progress?.phase === "finalizing";
const renderProgress = progress?.renderProgress;
// Get status message based on phase
const getStatusMessage = () => {
if (error) return t("export.tryAgain");
if (isCompiling || isFinalizing) {
@@ -80,7 +78,6 @@ export function ExportDialog({
return t("export.takeMoment");
};
// Get title based on phase
const getTitle = () => {
if (error) return t("export.failed");
if (isFinalizing && exportFormat === "mp4") return t("export.finalizingVideoTitle");
@@ -194,7 +191,7 @@ export function ExportDialog({
</div>
<div className="h-2 bg-white/5 rounded-full overflow-hidden border border-white/5">
{isCompiling || isFinalizing ? (
// Show render progress if available, otherwise animated indeterminate bar
// Real progress if we have it, otherwise an indeterminate bar.
renderProgress !== undefined && renderProgress > 0 ? (
<div
className="h-full bg-[#34B27B] shadow-[0_0_10px_rgba(52,178,123,0.3)] transition-all duration-300 ease-out"
@@ -2,6 +2,7 @@ import { HelpCircle, Settings2 } from "lucide-react";
import { useScopedT } from "@/contexts/I18nContext";
import { useShortcuts } from "@/contexts/ShortcutsContext";
import { FIXED_SHORTCUTS, formatBinding, SHORTCUT_ACTIONS } from "@/lib/shortcuts";
import { BLUR_REGIONS_ENABLED } from "./featureFlags";
export function KeyboardShortcutsHelp() {
const { shortcuts, isMac, openConfig } = useShortcuts();
@@ -26,14 +27,16 @@ export function KeyboardShortcutsHelp() {
</div>
<div className="space-y-1.5 text-[10px]">
{SHORTCUT_ACTIONS.map((action) => (
<div key={action} className="flex items-center justify-between">
<span className="text-slate-400">{t(`actions.${action}`)}</span>
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">
{formatBinding(shortcuts[action], isMac)}
</kbd>
</div>
))}
{SHORTCUT_ACTIONS.filter((action) => BLUR_REGIONS_ENABLED || action !== "addBlur").map(
(action) => (
<div key={action} className="flex items-center justify-between">
<span className="text-slate-400">{t(`actions.${action}`)}</span>
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">
{formatBinding(shortcuts[action], isMac)}
</kbd>
</div>
),
)}
<div className="pt-1 border-t border-white/5 mt-1 space-y-1.5">
{FIXED_SHORTCUTS.map((fixed) => (
@@ -96,7 +96,8 @@ export default function PlaybackControls({
<Button
onClick={onToggleFullscreen}
size="icon"
className="w-7 h-7 rounded-full transition-all duration-200 border border-transparent hover:bg-white/10 text-white hover:border-white/10 shrink-0 shadow-none ml-0.5"
variant="ghost"
className="w-7 h-7 rounded-full transition-all duration-200 border border-transparent bg-transparent hover:bg-white/10 text-white hover:text-white hover:border-white/10 shrink-0 shadow-none ml-0.5"
aria-label={isFullscreen ? t("playback.exitFullscreen") : t("playback.fullscreen")}
>
{isFullscreen ? (
+287 -82
View File
@@ -1,11 +1,13 @@
import * as SliderPrimitive from "@radix-ui/react-slider";
import {
Brackets,
Bug,
Crop,
Download,
FileDown,
Film,
Image,
Info,
LayoutPanelTop,
Lock,
MousePointerClick,
@@ -20,6 +22,7 @@ import {
} from "lucide-react";
import { type ComponentType, useCallback, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import defaultCursorPreviewUrl from "@/assets/cursors/Cursor=Default.svg";
import {
Accordion,
AccordionContent,
@@ -37,8 +40,11 @@ import {
import { Slider } from "@/components/ui/slider";
import { Switch } from "@/components/ui/switch";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Tooltip } from "@/components/ui/tooltip";
import { useScopedT } from "@/contexts/I18nContext";
import { getAssetPath } from "@/lib/assetPath";
import { WEBCAM_LAYOUT_PRESETS } from "@/lib/compositeLayout";
import { CURSOR_THEMES, DEFAULT_CURSOR_THEME_ID } from "@/lib/cursor/cursorThemes";
import type { ExportFormat, ExportQuality, GifFrameRate, GifSizePreset } from "@/lib/exporter";
import {
calculateEffectiveSourceDimensions,
@@ -52,7 +58,18 @@ import { getTestId } from "@/utils/getTestId";
import ColorPicker from "../ui/color-picker";
import { AnnotationSettingsPanel } from "./AnnotationSettingsPanel";
import { BlurSettingsPanel } from "./BlurSettingsPanel";
import { BACKGROUND_IMAGE_ACCEPT, isSupportedBackgroundImageType } from "./backgroundImageUpload";
import { CropControl } from "./CropControl";
import { parseCustomPlaybackSpeedInput } from "./customPlaybackSpeed";
import {
DEFAULT_CURSOR_SETTINGS,
DEFAULT_EDITOR_LAYOUT_SETTINGS,
DEFAULT_EXPORT_SETTINGS,
DEFAULT_GIF_SETTINGS,
DEFAULT_SOURCE_DIMENSIONS,
DEFAULT_WEBCAM_SETTINGS,
} from "./editorDefaults";
import { BLUR_REGIONS_ENABLED } from "./featureFlags";
import { KeyboardShortcutsHelp } from "./KeyboardShortcutsHelp";
import type {
AnnotationRegion,
@@ -70,8 +87,8 @@ import type {
ZoomFocusMode,
} from "./types";
import {
DEFAULT_WEBCAM_SIZE_PRESET,
MAX_PLAYBACK_SPEED,
DEFAULT_WEBCAM_MIRRORED,
DEFAULT_WEBCAM_REACTIVE_ZOOM,
MAX_ZOOM_SCALE,
MIN_ZOOM_SCALE,
ROTATION_3D_PRESET_ORDER,
@@ -90,37 +107,38 @@ function CustomSpeedInput({
onError: () => void;
}) {
const isPreset = SPEED_OPTIONS.some((o) => o.speed === value);
const [draft, setDraft] = useState(isPreset ? "" : String(Math.round(value)));
const [draft, setDraft] = useState(isPreset ? "" : String(value));
const [isFocused, setIsFocused] = useState(false);
const prevValue = useRef(value);
if (!isFocused && prevValue.current !== value) {
prevValue.current = value;
setDraft(isPreset ? "" : String(Math.round(value)));
setDraft(isPreset ? "" : String(value));
}
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const digits = e.target.value.replace(/\D/g, "");
if (digits === "") {
setDraft("");
return;
}
const num = Number(digits);
if (num > MAX_PLAYBACK_SPEED) {
const result = parseCustomPlaybackSpeedInput(e.target.value);
if (result.status === "too-fast") {
onError();
return;
}
setDraft(digits);
if (num >= 1) onChange(num);
setDraft(result.draft);
if (result.status === "valid") {
onChange(result.speed);
}
},
[onChange, onError],
);
const handleBlur = useCallback(() => {
setIsFocused(false);
if (!draft || Number(draft) < 1) {
setDraft(isPreset ? "" : String(Math.round(value)));
const result = parseCustomPlaybackSpeedInput(draft);
if (result.status === "valid") {
setDraft(String(result.speed));
} else {
setDraft(isPreset ? "" : String(value));
}
}, [draft, isPreset, value]);
@@ -128,8 +146,8 @@ function CustomSpeedInput({
<div className="flex items-center gap-1">
<input
type="text"
inputMode="numeric"
pattern="[0-9]*"
inputMode="decimal"
pattern="[0-9]*[.]?[0-9]*"
placeholder="--"
value={draft}
onFocus={() => setIsFocused(true)}
@@ -156,10 +174,8 @@ function ZoomFocusCoordInput({
disabled?: boolean;
ariaLabel: string;
}) {
// While the input is focused (user is editing), show their draft text
// so partial entries like "5" or "" don't get overwritten by re-renders.
// When not focused, mirror the live prop value so external changes
// (dragging the overlay on the preview) update the displayed number in real time.
// While focused, show the draft so partial entries like "5" or "" survive re-renders.
// While not focused, mirror the live prop so overlay drags update the number live.
const [draft, setDraft] = useState<string | null>(null);
const display = percent.toFixed(1);
@@ -230,8 +246,12 @@ interface SettingsPanelProps {
selectedZoomCustomScale?: number | null;
onZoomCustomScaleChange?: (scale: number) => void;
onZoomCustomScaleCommit?: () => void;
onZoomPreviewStart?: () => void;
onZoomPreviewEnd?: () => void;
selectedZoomFocusMode?: ZoomFocusMode | null;
onZoomFocusModeChange?: (mode: ZoomFocusMode) => void;
/** When the global Auto-Focus toggle is on, the per-zoom selector is locked. */
focusModeLocked?: boolean;
selectedZoomFocus?: ZoomFocus | null;
onZoomFocusCoordinateChange?: (focus: ZoomFocus) => void;
onZoomFocusCoordinateCommit?: () => void;
@@ -247,6 +267,8 @@ interface SettingsPanelProps {
onShadowCommit?: () => void;
showBlur?: boolean;
onBlurChange?: (showBlur: boolean) => void;
showTrimWaveform?: boolean;
onTrimWaveformChange?: (show: boolean) => void;
motionBlurAmount?: number;
onMotionBlurChange?: (amount: number) => void;
onMotionBlurCommit?: () => void;
@@ -273,6 +295,7 @@ interface SettingsPanelProps {
onGifSizePresetChange?: (preset: GifSizePreset) => void;
gifOutputDimensions?: { width: number; height: number };
onExport?: () => void;
onExportPanelOpen?: () => void;
unsavedExport?: {
arrayBuffer: ArrayBuffer;
fileName: string;
@@ -301,6 +324,10 @@ interface SettingsPanelProps {
onWebcamLayoutPresetChange?: (preset: WebcamLayoutPreset) => void;
webcamMaskShape?: import("./types").WebcamMaskShape;
onWebcamMaskShapeChange?: (shape: import("./types").WebcamMaskShape) => void;
webcamMirrored?: boolean;
onWebcamMirroredChange?: (mirrored: boolean) => void;
webcamReactiveZoom?: boolean;
onWebcamReactiveZoomChange?: (reactive: boolean) => void;
webcamSizePreset?: WebcamSizePreset;
onWebcamSizePresetChange?: (size: WebcamSizePreset) => void;
onWebcamSizePresetCommit?: () => void;
@@ -317,6 +344,8 @@ interface SettingsPanelProps {
onCursorClickBounceChange?: (bounce: number) => void;
cursorClipToBounds?: boolean;
onCursorClipToBoundsChange?: (clip: boolean) => void;
cursorTheme?: string;
onCursorThemeChange?: (theme: string) => void;
hasCursorData?: boolean;
showCursorSettings?: boolean;
}
@@ -332,7 +361,7 @@ const ZOOM_DEPTH_OPTIONS: Array<{ depth: ZoomDepth; label: string }> = [
{ depth: 6, label: "5×" },
];
type SettingsPanelMode = "background" | "effects" | "layout" | "cursor" | "export";
type SettingsPanelMode = "background" | "effects" | "layout" | "cursor" | "export" | "timeline";
const MP4_EXPORT_SHORT_SIDES = {
medium: 720,
@@ -359,8 +388,11 @@ export function SettingsPanel({
selectedZoomCustomScale,
onZoomCustomScaleChange,
onZoomCustomScaleCommit,
onZoomPreviewStart,
onZoomPreviewEnd,
selectedZoomFocusMode,
onZoomFocusModeChange,
focusModeLocked = false,
selectedZoomFocus,
onZoomFocusCoordinateChange,
onZoomFocusCoordinateCommit,
@@ -376,31 +408,34 @@ export function SettingsPanel({
onShadowCommit,
showBlur,
onBlurChange,
showTrimWaveform = false,
onTrimWaveformChange,
motionBlurAmount = 0,
onMotionBlurChange,
onMotionBlurCommit,
borderRadius = 0,
onBorderRadiusChange,
onBorderRadiusCommit,
padding = 50,
padding = DEFAULT_EDITOR_LAYOUT_SETTINGS.padding,
onPaddingChange,
onPaddingCommit,
cropRegion,
onCropChange,
aspectRatio,
videoElement,
exportQuality = "good",
exportQuality = DEFAULT_EXPORT_SETTINGS.quality,
onExportQualityChange,
exportFormat = "mp4",
exportFormat = DEFAULT_EXPORT_SETTINGS.format,
onExportFormatChange,
gifFrameRate = 15,
gifFrameRate = DEFAULT_GIF_SETTINGS.frameRate,
onGifFrameRateChange,
gifLoop = true,
gifLoop = DEFAULT_GIF_SETTINGS.loop,
onGifLoopChange,
gifSizePreset = "medium",
gifSizePreset = DEFAULT_GIF_SETTINGS.sizePreset,
onGifSizePresetChange,
gifOutputDimensions = { width: 1280, height: 720 },
gifOutputDimensions = DEFAULT_GIF_SETTINGS.outputDimensions,
onExport,
onExportPanelOpen,
unsavedExport,
onSaveUnsavedExport,
selectedAnnotationId,
@@ -421,36 +456,61 @@ export function SettingsPanel({
onSpeedChange,
onSpeedDelete,
hasWebcam = false,
webcamLayoutPreset = "picture-in-picture",
webcamLayoutPreset = DEFAULT_WEBCAM_SETTINGS.layoutPreset,
onWebcamLayoutPresetChange,
webcamMaskShape = "rectangle",
webcamMaskShape = DEFAULT_WEBCAM_SETTINGS.maskShape,
onWebcamMaskShapeChange,
webcamSizePreset = DEFAULT_WEBCAM_SIZE_PRESET,
webcamMirrored = DEFAULT_WEBCAM_MIRRORED,
onWebcamMirroredChange,
webcamReactiveZoom = DEFAULT_WEBCAM_REACTIVE_ZOOM,
onWebcamReactiveZoomChange,
webcamSizePreset = DEFAULT_WEBCAM_SETTINGS.sizePreset,
onWebcamSizePresetChange,
onWebcamSizePresetCommit,
onSaveDiagnostic,
showCursor = true,
showCursor = DEFAULT_CURSOR_SETTINGS.show,
onShowCursorChange,
cursorSize = 3.0,
cursorSize = DEFAULT_CURSOR_SETTINGS.size,
onCursorSizeChange,
cursorSmoothing = 0.67,
cursorSmoothing = DEFAULT_CURSOR_SETTINGS.smoothing,
onCursorSmoothingChange,
cursorMotionBlur = 0.35,
cursorMotionBlur = DEFAULT_CURSOR_SETTINGS.motionBlur,
onCursorMotionBlurChange,
cursorClickBounce = 2.5,
cursorClickBounce = DEFAULT_CURSOR_SETTINGS.clickBounce,
onCursorClickBounceChange,
cursorClipToBounds = false,
cursorClipToBounds = DEFAULT_CURSOR_SETTINGS.clipToBounds,
onCursorClipToBoundsChange,
cursorTheme = DEFAULT_CURSOR_SETTINGS.theme,
onCursorThemeChange,
hasCursorData = false,
showCursorSettings = true,
}: SettingsPanelProps) {
const t = useScopedT("settings");
const [activePanelMode, setActivePanelMode] = useState<SettingsPanelMode>("background");
const sourceDimensions = formatSourceDimensions(videoElement, cropRegion);
// Resolved URLs are for DOM rendering only (backgroundImage). The canonical
// `/wallpapers/wallpaperN.jpg` form in WALLPAPER_PATHS is what gets persisted
// on click — never the machine-specific file:// URL.
// Resolved URLs are for DOM rendering only. We persist the canonical
// `/wallpapers/wallpaperN.jpg` form from WALLPAPER_PATHS, never the file:// URL.
const wallpaperPreviewUrls = useMemo(() => WALLPAPER_PATHS.map(resolveImageWallpaperUrl), []);
// Built-in "Default" plus each bundled theme. Thumbnails use the theme's arrow asset;
// the persisted value is the theme id.
const cursorThemeOptions = useMemo(
() => [
{
id: DEFAULT_CURSOR_THEME_ID,
name: t("cursor.themeDefault"),
previewUrl: defaultCursorPreviewUrl,
},
...CURSOR_THEMES.map((theme) => {
const previewPath = (theme.assets.arrow ?? theme.assets.pointer)?.assetPath;
return {
id: theme.id,
name: theme.name,
previewUrl: previewPath ? getAssetPath(previewPath) : defaultCursorPreviewUrl,
};
}),
],
[t],
);
const [customImages, setCustomImages] = useState<string[]>([]);
const fileInputRef = useRef<HTMLInputElement>(null);
const colorPalette = [
@@ -478,8 +538,8 @@ export function SettingsPanel({
const [cropAspectRatio, setCropAspectRatio] = useState("");
const isPortraitCanvas = isPortraitAspectRatio(aspectRatio);
const videoWidth = videoElement?.videoWidth || 1920;
const videoHeight = videoElement?.videoHeight || 1080;
const videoWidth = videoElement?.videoWidth || DEFAULT_SOURCE_DIMENSIONS.width;
const videoHeight = videoElement?.videoHeight || DEFAULT_SOURCE_DIMENSIONS.height;
const handleCropNumericChange = useCallback(
(field: "x" | "y" | "width" | "height", pixelValue: number) => {
@@ -590,6 +650,7 @@ export function SettingsPanel({
{ id: "background", label: t("background.title"), icon: Palette },
{ id: "effects", label: t("effects.title"), icon: SlidersHorizontal },
{ id: "layout", label: t("layout.title"), icon: LayoutPanelTop, disabled: !hasWebcam },
{ id: "timeline", label: t("timeline.title"), icon: Brackets },
...(hasCursorPanel
? [
{
@@ -611,8 +672,10 @@ export function SettingsPanel({
: selectedSpeedId
? t("speed.playbackSpeed")
: t("trim.deleteRegion")
: ([...panelModes, exportPanelMode].find((mode) => mode.id === activePanelMode)?.label ??
t("background.title"));
: activePanelMode === "timeline"
? t("timeline.title")
: ([...panelModes, exportPanelMode].find((mode) => mode.id === activePanelMode)?.label ??
t("background.title"));
const handleDeleteClick = () => {
if (selectedZoomId && onZoomDelete) {
@@ -632,9 +695,7 @@ export function SettingsPanel({
const file = files[0];
// Validate file type - only allow JPG/JPEG
const validTypes = ["image/jpeg", "image/jpg"];
if (!validTypes.includes(file.type)) {
if (!isSupportedBackgroundImageType(file.type, file.name)) {
toast.error(t("imageUpload.invalidFileType"), {
description: t("imageUpload.jpgOnly"),
});
@@ -667,13 +728,12 @@ export function SettingsPanel({
const handleRemoveCustomImage = (imageUrl: string, event: React.MouseEvent) => {
event.stopPropagation();
setCustomImages((prev) => prev.filter((img) => img !== imageUrl));
// If the removed image was selected, clear selection
// If the removed image was selected, reset to the first wallpaper.
if (selected === imageUrl) {
onWallpaperChange(WALLPAPER_PATHS[0]);
}
};
// Find selected annotation
const selectedAnnotation = selectedAnnotationId
? annotationRegions.find((a) => a.id === selectedAnnotationId)
: null;
@@ -717,7 +777,7 @@ export function SettingsPanel({
</div>
);
// If an annotation is selected, show annotation settings instead
// Annotation selected: show its settings panel instead.
if (
selectedAnnotation &&
onAnnotationContentChange &&
@@ -751,7 +811,7 @@ export function SettingsPanel({
);
}
if (selectedBlur && onBlurDataChange && onBlurDelete) {
if (BLUR_REGIONS_ENABLED && selectedBlur && onBlurDataChange && onBlurDelete) {
return (
<div className="editor-inspector-shell flex min-w-0 flex-col h-full overflow-hidden">
<div className="min-h-0 flex-1 overflow-hidden">
@@ -811,7 +871,10 @@ export function SettingsPanel({
data-testid={getTestId("export-panel-button")}
type="button"
title={exportPanelMode.label}
onClick={() => setActivePanelMode(exportPanelMode.id)}
onClick={() => {
setActivePanelMode(exportPanelMode.id);
onExportPanelOpen?.();
}}
className={cn(
"mt-auto flex h-8 w-8 items-center justify-center rounded-lg border transition-all",
activePanelMode === "export" && !hasTimelineSelection
@@ -913,34 +976,69 @@ export function SettingsPanel({
</div>
)}
{zoomEnabled && hasCursorTelemetry && (
<div className="flex items-center justify-between gap-3">
<span className="text-[11px] font-medium text-slate-400">
{t("zoom.focusMode.title")}
</span>
<div className="grid w-32 grid-cols-2 gap-0.5 rounded-lg border border-white/[0.06] bg-white/[0.035] p-0.5">
{(["manual", "auto"] as const).map((mode) => {
const isActive = selectedZoomFocusMode === mode;
return (
<Button
key={mode}
type="button"
onClick={() => onZoomFocusModeChange?.(mode)}
className={cn(
"h-6 w-full rounded-md border px-1 text-center transition-all duration-150 ease-out cursor-pointer",
isActive
? "border-[#34B27B]/50 bg-[#34B27B] text-white"
: "border-transparent bg-transparent text-slate-400 hover:bg-white/[0.06] hover:text-slate-200",
)}
>
<span className="text-[10px] font-semibold capitalize">
{t(`zoom.focusMode.${mode}`)}
</span>
</Button>
);
})}
<div className="space-y-1.5">
<div className="flex items-center justify-between gap-3">
<span className="text-[11px] font-medium text-slate-400">
{t("zoom.focusMode.title")}
</span>
<div className="grid w-32 grid-cols-2 gap-0.5 rounded-lg border border-white/[0.06] bg-white/[0.035] p-0.5">
{(["manual", "auto"] as const).map((mode) => {
const isActive = selectedZoomFocusMode === mode;
return (
<Button
key={mode}
type="button"
disabled={focusModeLocked}
onClick={() => !focusModeLocked && onZoomFocusModeChange?.(mode)}
className={cn(
"h-6 w-full rounded-md border px-1 text-center transition-all duration-150 ease-out",
isActive
? "border-[#34B27B]/50 bg-[#34B27B] text-white"
: "border-transparent bg-transparent text-slate-400 hover:bg-white/[0.06] hover:text-slate-200",
focusModeLocked ? "cursor-not-allowed opacity-50" : "cursor-pointer",
)}
>
<span className="text-[10px] font-semibold capitalize">
{t(`zoom.focusMode.${mode}`)}
</span>
</Button>
);
})}
</div>
</div>
{focusModeLocked && (
<div className="flex items-start gap-1 text-[10px] leading-snug text-slate-500">
<Info size={11} className="mt-px shrink-0" />
<span>{t("zoom.focusMode.lockedDisclaimer")}</span>
</div>
)}
</div>
)}
{zoomEnabled && onZoomPreviewStart && onZoomPreviewEnd && (
<Button
type="button"
onPointerDown={() => onZoomPreviewStart()}
onPointerUp={() => onZoomPreviewEnd()}
onPointerLeave={() => onZoomPreviewEnd()}
onPointerCancel={() => onZoomPreviewEnd()}
onKeyDown={(e) => {
if ((e.key === " " || e.key === "Enter") && !e.repeat) {
e.preventDefault();
onZoomPreviewStart();
}
}}
onKeyUp={(e) => {
if (e.key === " " || e.key === "Enter") {
e.preventDefault();
onZoomPreviewEnd();
}
}}
onBlur={() => onZoomPreviewEnd()}
className="h-7 w-full select-none rounded-md border border-white/[0.08] bg-white/[0.04] text-[10px] font-semibold text-slate-300 transition-all duration-150 ease-out hover:bg-white/[0.08] hover:text-slate-100 active:border-[#34B27B]/50 active:bg-[#34B27B] active:text-white cursor-pointer"
>
{t("zoom.previewHold")}
</Button>
)}
{zoomEnabled &&
selectedZoomFocusMode !== "auto" &&
selectedZoomFocus &&
@@ -1185,6 +1283,44 @@ export function SettingsPanel({
</SelectContent>
</Select>
</div>
{webcamLayoutPreset !== "no-webcam" && (
<div className="mt-2 flex items-center justify-between p-2 rounded-lg editor-control-surface">
<div className="text-[10px] font-medium text-slate-300">
{t("layout.mirrorWebcam")}
</div>
<Switch
checked={webcamMirrored}
onCheckedChange={onWebcamMirroredChange}
className="data-[state=checked]:bg-[#34B27B] scale-90"
aria-label={t("layout.mirrorWebcam")}
/>
</div>
)}
{webcamLayoutPreset === "picture-in-picture" && (
<div className="mt-2 flex items-center justify-between p-2 rounded-lg editor-control-surface">
<div className="flex items-center gap-1 text-[10px] font-medium text-slate-300">
<span>{t("layout.reactiveWebcam")}</span>
<Tooltip
content={t("layout.reactiveWebcamDescription")}
className="max-w-[220px] leading-snug whitespace-normal"
>
<button
type="button"
className="text-slate-400 transition-colors hover:text-slate-200"
aria-label={t("layout.reactiveWebcamDescription")}
>
<Info size={11} />
</button>
</Tooltip>
</div>
<Switch
checked={webcamReactiveZoom}
onCheckedChange={onWebcamReactiveZoomChange}
className="data-[state=checked]:bg-[#34B27B] scale-90"
aria-label={t("layout.reactiveWebcam")}
/>
</div>
)}
{webcamLayoutPreset === "picture-in-picture" && (
<div className="mt-2 p-2 rounded-lg editor-control-surface">
<div className="text-[10px] font-medium text-slate-300 mb-1.5">
@@ -1374,7 +1510,7 @@ export function SettingsPanel({
onValueChange={(values) => onBorderRadiusChange?.(values[0])}
onValueCommit={() => onBorderRadiusCommit?.()}
min={0}
max={16}
max={64}
step={0.5}
className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3"
/>
@@ -1420,8 +1556,20 @@ export function SettingsPanel({
{showCursor && (
<>
<div className="flex items-center justify-between">
<div className="text-[10px] font-medium text-slate-300">
{t("cursor.clipToBounds")}
<div className="flex items-center gap-1 text-[10px] font-medium text-slate-300">
<span>{t("cursor.clipToBounds")}</span>
<Tooltip
content={t("cursor.clipToBoundsDescription")}
className="max-w-[220px] leading-snug whitespace-normal"
>
<button
type="button"
className="text-slate-400 transition-colors hover:text-slate-200"
aria-label={t("cursor.clipToBoundsDescription")}
>
<Info size={11} />
</button>
</Tooltip>
</div>
<Switch
checked={cursorClipToBounds}
@@ -1430,6 +1578,41 @@ export function SettingsPanel({
aria-label={t("cursor.clipToBounds")}
/>
</div>
{cursorThemeOptions.length > 1 && (
<div className="space-y-1.5">
<div className="text-[10px] font-medium text-slate-300">
{t("cursor.theme")}
</div>
<div className="flex flex-wrap gap-1.5">
{cursorThemeOptions.map((option) => {
const isSelected = cursorTheme === option.id;
return (
<button
type="button"
key={option.id}
title={option.name}
aria-label={option.name}
aria-pressed={isSelected}
onClick={() => onCursorThemeChange?.(option.id)}
className={cn(
"flex items-center justify-center w-8 h-8 rounded-lg border overflow-hidden transition-all duration-150 shadow-sm bg-white/5",
isSelected
? "border-[#34B27B] ring-1 ring-[#34B27B]/30"
: "border-white/10 hover:border-[#34B27B]/40 opacity-80 hover:opacity-100",
)}
>
<img
src={option.previewUrl}
alt=""
className="w-5 h-5 object-contain"
draggable={false}
/>
</button>
);
})}
</div>
</div>
)}
<div className="grid grid-cols-2 gap-2">
<div className="p-2 rounded-lg bg-white/5 border border-white/5">
<div className="flex items-center justify-between mb-1">
@@ -1549,7 +1732,7 @@ export function SettingsPanel({
type="file"
ref={fileInputRef}
onChange={handleImageUpload}
accept=".jpg,.jpeg,image/jpeg"
accept={BACKGROUND_IMAGE_ACCEPT}
className="hidden"
/>
<Button
@@ -1660,6 +1843,28 @@ export function SettingsPanel({
</AccordionContent>
</AccordionItem>
)}
{activePanelMode === "timeline" && (
<AccordionItem value="timeline" className="editor-panel-section px-3">
<AccordionTrigger className="py-2.5 hover:no-underline">
<div className="flex items-center gap-2">
<Brackets className="w-4 h-4 text-[#34B27B]" />
<span className="text-xs font-medium">{t("timeline.title")}</span>
</div>
</AccordionTrigger>
<AccordionContent className="pb-3">
<div className="flex items-center justify-between p-2 rounded-lg editor-control-surface">
<div className="text-[10px] font-medium text-slate-300">
{t("timeline.waveform")}
</div>
<Switch
checked={showTrimWaveform}
onCheckedChange={onTrimWaveformChange}
className="data-[state=checked]:bg-[#34B27B] scale-90 ml-2 shrink-0"
/>
</div>
</AccordionContent>
</AccordionItem>
)}
</Accordion>
)}
</div>
@@ -22,6 +22,7 @@ import {
type ShortcutConflict,
type ShortcutsConfig,
} from "@/lib/shortcuts";
import { BLUR_REGIONS_ENABLED } from "./featureFlags";
const MODIFIER_KEYS = new Set(["Control", "Shift", "Alt", "Meta"]);
@@ -102,10 +103,14 @@ export function ShortcutsConfigDialog() {
const handleCancelConflict = useCallback(() => setConflict(null), []);
const handleSave = useCallback(async () => {
setShortcuts(draft);
await persistShortcuts(draft);
toast.success(t("savedToast"));
closeConfig();
const success = await persistShortcuts(draft);
if (success) {
setShortcuts(draft);
toast.success(t("savedToast"));
closeConfig();
} else {
toast.error(t("registrationFailed"));
}
}, [draft, setShortcuts, persistShortcuts, closeConfig, t]);
const handleReset = useCallback(() => {
@@ -139,61 +144,63 @@ export function ShortcutsConfigDialog() {
<p className="text-[10px] text-slate-500 mb-2 uppercase tracking-wide font-semibold">
{t("configurable")}
</p>
{SHORTCUT_ACTIONS.map((action) => {
const isCapturing = captureFor === action;
const hasConflict = conflict?.forAction === action;
return (
<div key={action}>
<div className="flex items-center justify-between py-1.5 px-1 border-b border-white/5">
<span className="text-sm text-slate-300">{t(`actions.${action}`)}</span>
<button
type="button"
onClick={() => {
setConflict(null);
setCaptureFor(isCapturing ? null : action);
}}
title={isCapturing ? t("pressEscToCancel") : t("clickToChange")}
className={[
"px-2 py-1 rounded text-xs font-mono border transition-all min-w-[90px] text-center select-none",
isCapturing
? "bg-[#34B27B]/20 border-[#34B27B] text-[#34B27B] animate-pulse"
: hasConflict
? "bg-amber-500/10 border-amber-500/50 text-amber-400"
: "bg-white/5 border-white/10 text-slate-200 hover:border-[#34B27B]/50 hover:text-[#34B27B] cursor-pointer",
].join(" ")}
>
{isCapturing ? t("pressKey") : formatBinding(draft[action], isMac)}
</button>
</div>
{hasConflict && conflict?.conflictWith.type === "configurable" && (
<div className="flex items-center justify-between px-1 py-1.5 mb-0.5 bg-amber-500/10 border border-amber-500/20 rounded text-xs">
<span className="text-amber-400">
⚠{" "}
{t("alreadyUsedBy", {
action: t(`actions.${conflict.conflictWith.action}`),
})}
</span>
<div className="flex gap-1.5">
<button
type="button"
onClick={handleSwap}
className="px-2 py-0.5 bg-amber-500/20 hover:bg-amber-500/30 border border-amber-500/40 rounded text-amber-300 font-medium transition-colors"
>
{t("swap")}
</button>
<button
type="button"
onClick={handleCancelConflict}
className="px-2 py-0.5 bg-white/5 hover:bg-white/10 border border-white/10 rounded text-slate-400 transition-colors"
>
{tc("actions.cancel")}
</button>
</div>
{SHORTCUT_ACTIONS.filter((action) => BLUR_REGIONS_ENABLED || action !== "addBlur").map(
(action) => {
const isCapturing = captureFor === action;
const hasConflict = conflict?.forAction === action;
return (
<div key={action}>
<div className="flex items-center justify-between py-1.5 px-1 border-b border-white/5">
<span className="text-sm text-slate-300">{t(`actions.${action}`)}</span>
<button
type="button"
onClick={() => {
setConflict(null);
setCaptureFor(isCapturing ? null : action);
}}
title={isCapturing ? t("pressEscToCancel") : t("clickToChange")}
className={[
"px-2 py-1 rounded text-xs font-mono border transition-all min-w-[90px] text-center select-none",
isCapturing
? "bg-[#34B27B]/20 border-[#34B27B] text-[#34B27B] animate-pulse"
: hasConflict
? "bg-amber-500/10 border-amber-500/50 text-amber-400"
: "bg-white/5 border-white/10 text-slate-200 hover:border-[#34B27B]/50 hover:text-[#34B27B] cursor-pointer",
].join(" ")}
>
{isCapturing ? t("pressKey") : formatBinding(draft[action], isMac)}
</button>
</div>
)}
</div>
);
})}
{hasConflict && conflict?.conflictWith.type === "configurable" && (
<div className="flex items-center justify-between px-1 py-1.5 mb-0.5 bg-amber-500/10 border border-amber-500/20 rounded text-xs">
<span className="text-amber-400">
⚠{" "}
{t("alreadyUsedBy", {
action: t(`actions.${conflict.conflictWith.action}`),
})}
</span>
<div className="flex gap-1.5">
<button
type="button"
onClick={handleSwap}
className="px-2 py-0.5 bg-amber-500/20 hover:bg-amber-500/30 border border-amber-500/40 rounded text-amber-300 font-medium transition-colors"
>
{t("swap")}
</button>
<button
type="button"
onClick={handleCancelConflict}
className="px-2 py-0.5 bg-white/5 hover:bg-white/10 border border-white/10 rounded text-slate-400 transition-colors"
>
{tc("actions.cancel")}
</button>
</div>
</div>
)}
</div>
);
},
)}
</div>
<div className="space-y-0.5 mt-2">
@@ -222,7 +229,7 @@ export function ShortcutsConfigDialog() {
<Button
variant="ghost"
size="sm"
className="text-slate-400 hover:text-white gap-1.5"
className="text-slate-400 gap-1.5"
onClick={handleReset}
>
<RotateCcw className="w-3 h-3" />
@@ -10,6 +10,7 @@ import { useScopedT } from "@/contexts/I18nContext";
interface UnsavedChangesDialogProps {
isOpen: boolean;
variant?: "close" | "newProject" | "loadProject";
onSaveAndClose: () => void;
onDiscardAndClose: () => void;
onCancel: () => void;
@@ -17,6 +18,7 @@ interface UnsavedChangesDialogProps {
export function UnsavedChangesDialog({
isOpen,
variant = "close",
onSaveAndClose,
onDiscardAndClose,
onCancel,
@@ -24,6 +26,25 @@ export function UnsavedChangesDialog({
const td = useScopedT("dialogs");
const tc = useScopedT("common");
const detail =
variant === "newProject"
? td("unsavedChanges.detailNewProject")
: variant === "loadProject"
? td("unsavedChanges.detailLoadProject")
: td("unsavedChanges.detail");
const saveLabel =
variant === "newProject"
? td("unsavedChanges.saveAndNewProject")
: variant === "loadProject"
? td("unsavedChanges.saveAndLoadProject")
: td("unsavedChanges.saveAndClose");
const discardLabel =
variant === "newProject"
? td("unsavedChanges.discardAndNewProject")
: variant === "loadProject"
? td("unsavedChanges.discardAndLoadProject")
: td("unsavedChanges.discardAndClose");
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onCancel()}>
<DialogContent className="bg-[#09090b] border-white/10 rounded-2xl max-w-sm p-6 gap-0">
@@ -42,9 +63,7 @@ export function UnsavedChangesDialog({
</DialogHeader>
<p className="text-sm text-slate-300 mb-1">{td("unsavedChanges.message")}</p>
<DialogDescription className="text-sm text-slate-500 mb-6">
{td("unsavedChanges.detail")}
</DialogDescription>
<DialogDescription className="text-sm text-slate-500 mb-6">{detail}</DialogDescription>
<div className="flex flex-col gap-2">
<button
@@ -53,7 +72,7 @@ export function UnsavedChangesDialog({
className="flex items-center justify-center gap-2 w-full px-4 py-2.5 rounded-lg bg-[#34B27B] hover:bg-[#2d9e6c] active:bg-[#27885c] text-white font-medium text-sm transition-colors outline-none focus-visible:ring-2 focus-visible:ring-[#34B27B] focus-visible:ring-offset-2 focus-visible:ring-offset-[#09090b]"
>
<Save className="w-4 h-4" />
{td("unsavedChanges.saveAndClose")}
{saveLabel}
</button>
<button
type="button"
@@ -61,7 +80,7 @@ export function UnsavedChangesDialog({
className="flex items-center justify-center gap-2 w-full px-4 py-2.5 rounded-lg bg-white/5 hover:bg-red-500/15 border border-white/10 hover:border-red-500/30 text-slate-300 hover:text-red-400 font-medium text-sm transition-colors outline-none focus-visible:ring-2 focus-visible:ring-white/30 focus-visible:ring-offset-2 focus-visible:ring-offset-[#09090b]"
>
<Trash2 className="w-4 h-4" />
{td("unsavedChanges.discardAndClose")}
{discardLabel}
</button>
<button
type="button"
File diff suppressed because it is too large Load Diff
+167 -100
View File
@@ -20,14 +20,15 @@ import {
} from "react";
import {
getWebcamLayoutCssBoxShadow,
reactiveWebcamScale,
type Size,
type StyledRenderRect,
type WebcamLayoutPreset,
type WebcamSizePreset,
} from "@/lib/compositeLayout";
import { getSmoothedCursorPath } from "@/lib/cursor/cursorPathSmoothing";
import {
createNativeCursorMotionBlurState,
createNativeCursorSmoothingState,
getNativeCursorClickBounceProgress,
getNativeCursorClickBounceScale,
getNativeCursorMotionBlurPx,
@@ -35,10 +36,8 @@ import {
projectNativeCursorToLocal,
projectNativeCursorToStage,
resetNativeCursorMotionBlurState,
resetNativeCursorSmoothingState,
resolveInterpolatedNativeCursorFrame,
resolveNativeCursorRenderAsset,
smoothNativeCursorSample,
} from "@/lib/cursor/nativeCursor";
import { classifyWallpaper, DEFAULT_WALLPAPER, resolveImageWallpaperUrl } from "@/lib/wallpaper";
import { getCssClipPath } from "@/lib/webcamMaskShapes";
@@ -49,46 +48,40 @@ import {
getNativeAspectRatioValue,
} from "@/utils/aspectRatioUtils";
import { AnnotationOverlay } from "./AnnotationOverlay";
import {
DEFAULT_CURSOR_SETTINGS,
DEFAULT_EDITOR_LAYOUT_SETTINGS,
DEFAULT_SOURCE_DIMENSIONS,
} from "./editorDefaults";
import {
type AnnotationRegion,
type BlurData,
type CursorTelemetryPoint,
computeRotation3DContainScale,
DEFAULT_CURSOR_CLICK_BOUNCE,
DEFAULT_CURSOR_MOTION_BLUR,
DEFAULT_CURSOR_SIZE,
DEFAULT_CURSOR_SMOOTHING,
DEFAULT_ROTATION_3D,
getZoomScale,
isRotation3DIdentity,
lerpRotation3D,
rotation3DPerspective,
type SpeedRegion,
type TrimRegion,
ZOOM_DEPTH_SCALES,
type ZoomDepth,
type ZoomFocus,
type ZoomRegion,
} from "./types";
import {
AUTO_FOLLOW_RAMP_DISTANCE,
AUTO_FOLLOW_SMOOTHING_FACTOR,
AUTO_FOLLOW_SMOOTHING_FACTOR_MAX,
DEFAULT_FOCUS,
ZOOM_SCALE_DEADZONE,
ZOOM_TRANSLATION_DEADZONE_PX,
} from "./videoPlayback/constants";
import { adaptiveSmoothFactor, smoothCursorFocus } from "./videoPlayback/cursorFollowUtils";
import { AUTO_FOLLOW_PARAMS, DEFAULT_FOCUS } from "./videoPlayback/constants";
import { advanceFollowFocus } from "./videoPlayback/cursorFollowUtils";
import {
DEFAULT_CURSOR_CONFIG,
PixiCursorOverlay,
preloadCursorAssets,
} from "./videoPlayback/cursorRenderer";
import { clampFocusToStage as clampFocusToStageUtil } from "./videoPlayback/focusUtils";
import { clampFocusToScale } from "./videoPlayback/focusUtils";
import { layoutVideoContent as layoutVideoContentUtil } from "./videoPlayback/layoutUtils";
import { clamp01 } from "./videoPlayback/mathUtils";
import { updateOverlayIndicator } from "./videoPlayback/overlayUtils";
import { createVideoEventHandlers } from "./videoPlayback/videoEventHandlers";
import { findDominantRegion } from "./videoPlayback/zoomRegionUtils";
import { createZoomSpringState, resetZoomSpring, stepZoomSpring } from "./videoPlayback/zoomSpring";
import {
applyZoomTransform,
computeFocusFromTransform,
@@ -102,6 +95,8 @@ interface VideoPlaybackProps {
webcamVideoPath?: string;
webcamLayoutPreset: WebcamLayoutPreset;
webcamMaskShape?: import("./types").WebcamMaskShape;
webcamMirrored?: boolean;
webcamReactiveZoom?: boolean;
webcamSizePreset?: WebcamSizePreset;
webcamPosition?: { cx: number; cy: number } | null;
onWebcamPositionChange?: (position: { cx: number; cy: number }) => void;
@@ -149,6 +144,10 @@ interface VideoPlaybackProps {
cursorMotionBlur?: number;
cursorClickBounce?: number;
cursorClipToBounds?: boolean;
cursorTheme?: string;
// Render the selected zoom at the playhead even while paused, so the editor can
// preview the effect without leaving the focus-edit view.
isPreviewingZoom?: boolean;
}
export interface VideoPlaybackRef {
@@ -223,6 +222,8 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
webcamVideoPath,
webcamLayoutPreset,
webcamMaskShape,
webcamMirrored = false,
webcamReactiveZoom = false,
webcamSizePreset,
webcamPosition,
onWebcamPositionChange,
@@ -244,7 +245,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
showBlur,
motionBlurAmount = 0,
borderRadius = 0,
padding = 50,
padding = DEFAULT_EDITOR_LAYOUT_SETTINGS.padding,
cropRegion,
trimRegions = [],
speedRegions = [],
@@ -265,17 +266,23 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
cursorTelemetry = [],
cursorClickTimestamps = [],
showCursor = false,
cursorSize = DEFAULT_CURSOR_SIZE,
cursorSmoothing = DEFAULT_CURSOR_SMOOTHING,
cursorMotionBlur = DEFAULT_CURSOR_MOTION_BLUR,
cursorClickBounce = DEFAULT_CURSOR_CLICK_BOUNCE,
cursorClipToBounds = false,
cursorSize = DEFAULT_CURSOR_SETTINGS.size,
cursorSmoothing = DEFAULT_CURSOR_SETTINGS.smoothing,
cursorMotionBlur = DEFAULT_CURSOR_SETTINGS.motionBlur,
cursorClickBounce = DEFAULT_CURSOR_SETTINGS.clickBounce,
cursorClipToBounds = DEFAULT_CURSOR_SETTINGS.clipToBounds,
cursorTheme = DEFAULT_CURSOR_SETTINGS.theme,
isPreviewingZoom = false,
},
ref,
) => {
const videoRef = useRef<HTMLVideoElement | null>(null);
const supplementalAudioRef = useRef<HTMLAudioElement | null>(null);
const webcamVideoRef = useRef<HTMLVideoElement | null>(null);
const webcamWrapperRef = useRef<HTMLDivElement | null>(null);
const webcamReactiveZoomRef = useRef(webcamReactiveZoom);
const webcamLayoutPresetRef = useRef(webcamLayoutPreset);
const webcamPositionRef = useRef(webcamPosition);
const containerRef = useRef<HTMLDivElement | null>(null);
const appRef = useRef<Application | null>(null);
const videoSpriteRef = useRef<Sprite | null>(null);
@@ -308,6 +315,9 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
y: 0,
appliedScale: 1,
});
// Spring that chases the eased zoom target so the camera glides instead of jerking.
const zoomSpringRef = useRef(createZoomSpringState());
const prevZoomTimeMsRef = useRef<number | null>(null);
const blurFilterRef = useRef<BlurFilter | null>(null);
const motionBlurFilterRef = useRef<MotionBlurFilter | null>(null);
const isDraggingFocusRef = useRef(false);
@@ -341,6 +351,8 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
const cursorMotionBlurRef = useRef(cursorMotionBlur);
const cursorClickBounceRef = useRef(cursorClickBounce);
const cursorClipToBoundsRef = useRef(cursorClipToBounds);
const cursorThemeRef = useRef(cursorTheme);
const isPreviewingZoomRef = useRef(isPreviewingZoom);
const motionBlurStateRef = useRef<MotionBlurState>(createMotionBlurState());
const onTimeUpdateRef = useRef(onTimeUpdate);
const onPlayStateChangeRef = useRef(onPlayStateChange);
@@ -357,7 +369,6 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
const nativeCursorTextureIdRef = useRef<string | null>(null);
const nativeCursorImageRef = useRef<HTMLImageElement | null>(null);
const nativeCursorImageIdRef = useRef<string | null>(null);
const nativeCursorSmoothingStateRef = useRef(createNativeCursorSmoothingState());
const nativeCursorMotionBlurStateRef = useRef(createNativeCursorMotionBlurState());
const nativeCursorClipRef = useRef<HTMLDivElement | null>(null);
const borderRadiusRef = useRef<number>(0);
@@ -471,8 +482,10 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
[onDurationChange, syncResolvedDuration],
);
const clampFocusToStage = useCallback((focus: ZoomFocus, depth: ZoomDepth) => {
return clampFocusToStageUtil(focus, depth, stageSizeRef.current);
// Clamp against getZoomScale(region), not region.depth: depth is just the preset
// slot (1x/2x/4x) and ignores customScale, which gives wrong drag bounds near the edges.
const clampFocusForRegion = useCallback((focus: ZoomFocus, region: ZoomRegion) => {
return clampFocusToScale(focus, getZoomScale(region));
}, []);
const updateOverlayForRegion = useCallback(
@@ -484,7 +497,6 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
return;
}
// Update stage size from overlay dimensions
const stageWidth = overlayEl.clientWidth;
const stageHeight = overlayEl.clientHeight;
if (stageWidth && stageHeight) {
@@ -666,7 +678,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
cx: clamp01(localX / stageWidth),
cy: clamp01(localY / stageHeight),
};
const clampedFocus = clampFocusToStage(unclampedFocus, region.depth);
const clampedFocus = clampFocusForRegion(unclampedFocus, region);
onZoomFocusChange(region.id, clampedFocus);
updateOverlayForRegion({ ...region, focus: clampedFocus }, clampedFocus);
@@ -804,7 +816,6 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
useEffect(() => {
cursorRecordingDataRef.current = cursorRecordingData;
resetNativeCursorSmoothingState(nativeCursorSmoothingStateRef.current);
resetNativeCursorMotionBlurState(nativeCursorMotionBlurStateRef.current);
}, [cursorRecordingData]);
@@ -832,6 +843,28 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
cursorClipToBoundsRef.current = cursorClipToBounds;
}, [cursorClipToBounds]);
useEffect(() => {
cursorThemeRef.current = cursorTheme;
}, [cursorTheme]);
useEffect(() => {
webcamReactiveZoomRef.current = webcamReactiveZoom;
webcamLayoutPresetRef.current = webcamLayoutPreset;
webcamPositionRef.current = webcamPosition;
// Clear any reactive transform when the effect is turned off or layout changes,
// so a stale shrink doesn't linger while the ticker isn't updating it.
if (
webcamWrapperRef.current &&
(!webcamReactiveZoom || webcamLayoutPreset !== "picture-in-picture")
) {
webcamWrapperRef.current.style.transform = "";
}
}, [webcamReactiveZoom, webcamLayoutPreset, webcamPosition]);
useEffect(() => {
isPreviewingZoomRef.current = isPreviewingZoom;
}, [isPreviewingZoom]);
// Sync cursor overlay config when settings change
useEffect(() => {
const overlay = cursorOverlayRef.current;
@@ -891,10 +924,9 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
};
}, [pixiReady, videoReady, layoutVideoContent]);
// Drop the PIXI canvas resolution to 1.0 while scrubbing (the user is
// navigating, not previewing) and restore native DPR on play/idle so the
// preview stays faithful. Mutating renderer.resolution per-frame would
// thrash texture uploads; we only do it on scrub-state transitions.
// Drop canvas resolution to 1.0 while scrubbing and restore native DPR on play/idle.
// Only on scrub-state transitions; mutating renderer.resolution per-frame thrashes
// texture uploads.
useEffect(() => {
if (!pixiReady) return;
const app = appRef.current;
@@ -1280,12 +1312,32 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
motionBlurAmount: motionBlurAmountRef.current,
transformOverride: transform,
motionBlurState: motionBlurStateRef.current,
frameTimeMs: performance.now(),
// Content time, not wall-clock, so motion-blur velocity matches export and stays
// correct under speed regions (frameRenderer passes the same content timeMs).
frameTimeMs: currentTimeRef.current,
});
state.x = appliedTransform.x;
state.y = appliedTransform.y;
state.appliedScale = appliedTransform.scale;
// Scale the PiP webcam inversely with the (eased) zoom, anchored to the docked
// corner (bottom-right by default) so it stays flush instead of drifting to center.
const webcamWrapper = webcamWrapperRef.current;
if (webcamWrapper) {
const reactive =
webcamReactiveZoomRef.current && webcamLayoutPresetRef.current === "picture-in-picture";
const factor = reactive ? reactiveWebcamScale(state.appliedScale) : 1;
if (factor < 1) {
const pos = webcamPositionRef.current;
const originX = (pos ? pos.cx >= 0.5 : true) ? "100%" : "0%";
const originY = (pos ? pos.cy >= 0.5 : true) ? "100%" : "0%";
webcamWrapper.style.transformOrigin = `${originX} ${originY}`;
webcamWrapper.style.transform = `scale(${factor})`;
} else {
webcamWrapper.style.transform = "";
}
}
};
let lastMotionBlurActive: boolean | null = null;
@@ -1306,52 +1358,55 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
let targetFocus = defaultFocus;
let targetProgress = 0;
// If a zoom is selected but video is not playing, show default unzoomed view
// If a zoom is selected but not playing, show the default unzoomed view.
const selectedId = selectedZoomIdRef.current;
const hasSelectedZoom = selectedId !== null;
const shouldShowUnzoomedView = hasSelectedZoom && !isPlayingRef.current;
const shouldShowUnzoomedView =
hasSelectedZoom && !isPlayingRef.current && !isPreviewingZoomRef.current;
if (region && strength > 0 && !shouldShowUnzoomedView) {
const zoomScale = blendedScale ?? ZOOM_DEPTH_SCALES[region.depth];
// Use getZoomScale (customScale-aware) to match export and the magnification
// findDominantRegion resolved focus at. Falling back to the depth preset would
// zoom/pan to a different level than export.
const zoomScale = blendedScale ?? getZoomScale(region);
const regionFocus = region.focus;
targetScaleFactor = zoomScale;
targetFocus = regionFocus;
targetProgress = strength;
// Apply adaptive smoothing for auto-follow mode
// Adaptive smoothing for auto-follow mode.
if (region.focusMode === "auto" && !transition) {
const raw = targetFocus;
const isZoomingIn =
targetProgress < 0.999 && targetProgress >= prevTargetProgressRef.current;
// Follow the cursor in content time (frame-rate independent) so the camera pans
// at the same speed in preview and export. Snap to target when not actively
// playing (paused/seek/scrub), matching the zoom spring's snap.
const focusAnimating =
isPlayingRef.current && !isSeekingRef.current && !isScrubbingRef.current;
const focusDtMs =
prevZoomTimeMsRef.current === null
? 0
: currentTimeRef.current - prevZoomTimeMsRef.current;
if (targetProgress >= 0.999) {
// Full zoom: adaptive smoothing — moves faster when far, decelerates when close
// Full zoom: adaptive smoothing, faster when far, decelerating when close.
const prev = smoothedAutoFocusRef.current ?? raw;
const factor = adaptiveSmoothFactor(
raw,
prev,
AUTO_FOLLOW_SMOOTHING_FACTOR,
AUTO_FOLLOW_SMOOTHING_FACTOR_MAX,
AUTO_FOLLOW_RAMP_DISTANCE,
);
const smoothed = smoothCursorFocus(raw, prev, factor);
const smoothed = focusAnimating
? advanceFollowFocus(prev, raw, focusDtMs, AUTO_FOLLOW_PARAMS)
: raw;
smoothedAutoFocusRef.current = smoothed;
targetFocus = smoothed;
} else if (isZoomingIn) {
// Zoom-in: track cursor directly so zoom always aims at current cursor
// position; keep ref in sync to avoid snap when full-zoom begins
// Zoom-in: track cursor directly so zoom always aims at the current position;
// keep ref in sync to avoid a snap when full-zoom begins.
smoothedAutoFocusRef.current = raw;
} else {
// Zoom-out: keep smoothing for continuity — avoids snap at zoom-out start
// Zoom-out: keep smoothing for continuity to avoid a snap at zoom-out start.
const prev = smoothedAutoFocusRef.current ?? raw;
const factor = adaptiveSmoothFactor(
raw,
prev,
AUTO_FOLLOW_SMOOTHING_FACTOR,
AUTO_FOLLOW_SMOOTHING_FACTOR_MAX,
AUTO_FOLLOW_RAMP_DISTANCE,
);
const smoothed = smoothCursorFocus(raw, prev, factor);
const smoothed = focusAnimating
? advanceFollowFocus(prev, raw, focusDtMs, AUTO_FOLLOW_PARAMS)
: raw;
smoothedAutoFocusRef.current = smoothed;
targetFocus = smoothed;
}
@@ -1360,7 +1415,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
}
prevTargetProgressRef.current = targetProgress;
// Handle connected zoom transitions (pan between adjacent zoom regions)
// Connected zoom transitions: pan between adjacent regions.
if (transition) {
const startTransform = computeZoomTransform({
stageSize: stageSizeRef.current,
@@ -1418,18 +1473,28 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
focusY: state.focusY,
});
const appliedScale =
Math.abs(projectedTransform.scale - prevScale) < ZOOM_SCALE_DEADZONE
? projectedTransform.scale
: projectedTransform.scale;
const appliedX =
Math.abs(projectedTransform.x - prevX) < ZOOM_TRANSLATION_DEADZONE_PX
? projectedTransform.x
: projectedTransform.x;
const appliedY =
Math.abs(projectedTransform.y - prevY) < ZOOM_TRANSLATION_DEADZONE_PX
? projectedTransform.y
: projectedTransform.y;
// Chase the eased target with a spring so the camera glides (no jerk at the steep
// start of the ease, no snap at close-region seams). Step by content time while
// playing; snap to the exact target when paused/seeking/scrubbing for crisp frames.
const nowMs = currentTimeRef.current;
const prevMs = prevZoomTimeMsRef.current;
const animating = isPlayingRef.current && !isSeekingRef.current && !isScrubbingRef.current;
const dtMs = prevMs === null ? 0 : nowMs - prevMs;
let appliedScale: number;
let appliedX: number;
let appliedY: number;
if (!animating || prevMs === null || dtMs <= 0 || dtMs > 80) {
resetZoomSpring(zoomSpringRef.current, projectedTransform);
appliedScale = projectedTransform.scale;
appliedX = projectedTransform.x;
appliedY = projectedTransform.y;
} else {
const sprung = stepZoomSpring(zoomSpringRef.current, projectedTransform, dtMs);
appliedScale = sprung.scale;
appliedX = sprung.x;
appliedY = sprung.y;
}
prevZoomTimeMsRef.current = nowMs;
const motionIntensity = Math.max(
Math.abs(appliedScale - prevScale),
@@ -1467,7 +1532,6 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
}
}
// Update cursor overlay
const cursorOverlay = cursorOverlayRef.current;
if (cursorOverlay) {
const timeMs = currentTimeRef.current; // already in ms
@@ -1494,7 +1558,6 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
if (nativeCursorClipRef.current) {
nativeCursorClipRef.current.style.clipPath = "";
}
resetNativeCursorSmoothingState(nativeCursorSmoothingStateRef.current);
resetNativeCursorMotionBlurState(nativeCursorMotionBlurStateRef.current);
};
if (nativeCursorImage) {
@@ -1505,13 +1568,15 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
timeMs,
);
if (frame) {
const displaySample = smoothNativeCursorSample({
forceSnap: !isPlayingRef.current || isSeekingRef.current,
sample: frame.sample,
smoothing: cursorSmoothingRef.current,
state: nativeCursorSmoothingStateRef.current,
timeMs,
});
// Position comes from the precomputed offline-smoothed path; the frame still
// supplies the cursor image, type, and click timing.
const smoothedPos = getSmoothedCursorPath(
cursorRecordingDataRef.current,
cursorSmoothingRef.current,
)?.sampleAt(timeMs);
const displaySample = smoothedPos
? { ...frame.sample, cx: smoothedPos.cx, cy: smoothedPos.cy }
: frame.sample;
const cameraContainer = cameraContainerRef.current;
const videoContainer = videoContainerRef.current;
const cropRegionValue = cropRegionRef.current ?? { x: 0, y: 0, width: 1, height: 1 };
@@ -1534,9 +1599,14 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
})
: null;
if (projectedLocalPoint && projectedStagePoint) {
// Pass deviceScaleFactor=1 — asset.scaleFactor already encodes DPR.
// Pass deviceScaleFactor=1 since asset.scaleFactor already encodes DPR.
// Size is normalized below so preview matches export proportionally.
const renderAsset = resolveNativeCursorRenderAsset(frame.asset, 1, displaySample);
const renderAsset = resolveNativeCursorRenderAsset(
frame.asset,
1,
displaySample,
cursorThemeRef.current,
);
const bounceProgress = getNativeCursorClickBounceProgress(
cursorRecordingDataRef.current,
timeMs,
@@ -1565,9 +1635,8 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
nativeCursorImageIdRef.current = renderAsset.id;
}
nativeCursorImage.style.display = "block";
// Update clip-path on nativeCursorClipRef to the camera-aware video boundary.
// clip-path works correctly here because nativeCursorClipRef is outside preserve-3d.
// When cursorClipToBounds is off, allow the cursor to overflow the canvas.
// Clip to the camera-aware video boundary. Works here because nativeCursorClipRef
// sits outside preserve-3d. When cursorClipToBounds is off, let the cursor overflow.
if (nativeCursorClipRef.current) {
if (!cursorClipToBoundsRef.current) {
nativeCursorClipRef.current.style.clipPath = "none";
@@ -1590,7 +1659,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
nativeCursorImage.style.filter =
blurPx > 0 ? `blur(${blurPx.toFixed(2)}px)` : "none";
// translate3d is relative to nativeCursorClipRef (absolute inset-0 = stage origin).
// projectedStagePoint.x is the stage-space cursor position — no offset needed.
// projectedStagePoint.x is the stage-space cursor position, so no offset is needed.
nativeCursorImage.style.transform = `translate3d(${
projectedStagePoint.x - renderAsset.hotspotX * transformedScale
}px, ${projectedStagePoint.y - renderAsset.hotspotY * transformedScale}px, 0)`;
@@ -1824,15 +1893,15 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
aspectRatio,
aspectRatio === "native"
? getNativeAspectRatioValue(
lockedVideoDimensionsRef.current?.width || 1920,
lockedVideoDimensionsRef.current?.height || 1080,
lockedVideoDimensionsRef.current?.width || DEFAULT_SOURCE_DIMENSIONS.width,
lockedVideoDimensionsRef.current?.height || DEFAULT_SOURCE_DIMENSIONS.height,
cropRegion,
)
: undefined,
),
}}
>
{/* Background layer - always render as DOM element with blur */}
{/* Background always renders as a DOM element so it can be blurred. */}
<div
className="absolute inset-0 bg-cover bg-center"
style={{
@@ -1864,6 +1933,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
const useClipPath = !!clipPath;
return (
<div
ref={webcamWrapperRef}
className="absolute"
style={{
left: webcamLayout?.x ?? 0,
@@ -1887,6 +1957,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
clipPath: clipPath ?? undefined,
boxShadow: useClipPath ? "none" : webcamCssBoxShadow,
backgroundColor: "#000",
transform: webcamMirrored ? "scaleX(-1)" : undefined,
}}
onPointerDown={handleWebcamPointerDown}
onPointerMove={handleWebcamPointerMove}
@@ -1899,7 +1970,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
</div>
);
})()}
{/* Only render overlay after PIXI and video are fully initialized */}
{/* Render the overlay only once PIXI and video are ready. */}
{pixiReady && videoReady && (
<div
ref={setOverlayRefs}
@@ -1965,18 +2036,15 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
})()
: null;
// Handle click-through cycling: when clicking same annotation, cycle to next
// Re-clicking a selected annotation cycles through any overlapping ones.
const handleAnnotationClick = (clickedId: string) => {
if (!onSelectAnnotation) return;
// If clicking on already selected annotation and there are multiple overlapping
if (clickedId === selectedAnnotationId && filteredAnnotations.length > 1) {
// Find current index and cycle to next
const currentIndex = filteredAnnotations.findIndex((a) => a.id === clickedId);
const nextIndex = (currentIndex + 1) % filteredAnnotations.length;
onSelectAnnotation(filteredAnnotations[nextIndex].id);
} else {
// First click or clicking different annotation
onSelectAnnotation(clickedId);
}
};
@@ -2033,16 +2101,15 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
}
previewSourceCanvas={previewSnapshotCanvas}
previewFrameVersion={Math.round(currentTime * 1000)}
currentTimeMs={Math.round(currentTime * 1000)}
/>
));
})()}
</div>
)}
</div>
{/* Clip the native cursor overlay to the exact video canvas boundary.
Placed OUTSIDE composite3DRef (preserve-3d) so clip-path works
correctly even during 3D zoom rotation regions.
clip-path is set dynamically to the camera-aware video bounds. */}
{/* Native cursor clip. Lives outside composite3DRef (preserve-3d) so clip-path
keeps working during 3D zoom rotations; bounds are set dynamically. */}
<div
ref={nativeCursorClipRef}
className="absolute inset-0"
@@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest";
import { isSupportedBackgroundImageType } from "./backgroundImageUpload";
describe("background image upload validation", () => {
it("accepts PNG images for custom backgrounds", () => {
expect(isSupportedBackgroundImageType("image/png", "生成画像1.png")).toBe(true);
});
it("accepts PNG images by extension when the browser does not provide a MIME type", () => {
expect(isSupportedBackgroundImageType("", "生成画像1.png")).toBe(true);
});
it("keeps rejecting non-image uploads", () => {
expect(isSupportedBackgroundImageType("text/plain", "notes.txt")).toBe(false);
});
it("does not allow extension fallback for explicit unsupported MIME types", () => {
expect(isSupportedBackgroundImageType("text/plain", "notes.png")).toBe(false);
});
});
@@ -0,0 +1,20 @@
const SUPPORTED_BACKGROUND_IMAGE_TYPES = new Set(["image/jpeg", "image/jpg", "image/png"]);
const SUPPORTED_BACKGROUND_IMAGE_EXTENSIONS = new Set([".jpg", ".jpeg", ".png"]);
export const BACKGROUND_IMAGE_ACCEPT = ".jpg,.jpeg,.png,image/jpeg,image/png";
export function isSupportedBackgroundImageType(type: string, fileName: string): boolean {
const normalizedType = type.trim().toLowerCase();
if (SUPPORTED_BACKGROUND_IMAGE_TYPES.has(normalizedType)) {
return true;
}
if (normalizedType) {
return false;
}
const lowerName = fileName.trim().toLowerCase();
return [...SUPPORTED_BACKGROUND_IMAGE_EXTENSIONS].some((extension) =>
lowerName.endsWith(extension),
);
}
@@ -0,0 +1,50 @@
import { describe, expect, it } from "vitest";
import { parseCustomPlaybackSpeedInput } from "./customPlaybackSpeed";
describe("parseCustomPlaybackSpeedInput", () => {
it("accepts decimal playback speeds", () => {
expect(parseCustomPlaybackSpeedInput("1.1")).toEqual({
status: "valid",
draft: "1.1",
speed: 1.1,
});
});
it("keeps a single decimal point while typing", () => {
expect(parseCustomPlaybackSpeedInput("1.2.3")).toEqual({
status: "valid",
draft: "1.23",
speed: 1.23,
});
});
it("allows sub-1 custom speeds down to the editor minimum", () => {
expect(parseCustomPlaybackSpeedInput("0.1")).toEqual({
status: "valid",
draft: "0.1",
speed: 0.1,
});
});
it("rejects speeds below the editor minimum", () => {
expect(parseCustomPlaybackSpeedInput("0.09")).toEqual({
status: "too-slow",
draft: "0.09",
});
});
it("accepts comma decimal input by normalizing to a dot", () => {
expect(parseCustomPlaybackSpeedInput("1,1")).toEqual({
status: "valid",
draft: "1.1",
speed: 1.1,
});
});
it("rejects speeds above the editor maximum", () => {
expect(parseCustomPlaybackSpeedInput("16.1")).toEqual({
status: "too-fast",
draft: "16.1",
});
});
});
@@ -0,0 +1,37 @@
import {
clampPlaybackSpeed,
MAX_PLAYBACK_SPEED,
MIN_PLAYBACK_SPEED,
type PlaybackSpeed,
} from "./types";
export type CustomPlaybackSpeedInputResult =
| { status: "empty"; draft: string }
| { status: "too-fast"; draft: string }
| { status: "too-slow"; draft: string }
| { status: "valid"; draft: string; speed: PlaybackSpeed };
export function parseCustomPlaybackSpeedInput(rawValue: string): CustomPlaybackSpeedInputResult {
const decimalDraft = rawValue.replace(/,/g, ".").replace(/[^\d.]/g, "");
const [whole = "", ...fractionParts] = decimalDraft.split(".");
const draft = fractionParts.length > 0 ? `${whole}.${fractionParts.join("")}` : whole;
if (draft === "" || draft === ".") {
return { status: "empty", draft };
}
const speed = Number(draft);
if (!Number.isFinite(speed)) {
return { status: "empty", draft };
}
if (speed > MAX_PLAYBACK_SPEED) {
return { status: "too-fast", draft };
}
if (speed < MIN_PLAYBACK_SPEED) {
return { status: "too-slow", draft };
}
return { status: "valid", draft, speed: clampPlaybackSpeed(speed) };
}
@@ -0,0 +1,55 @@
import { describe, expect, it } from "vitest";
import { INITIAL_EDITOR_STATE } from "@/hooks/useEditorHistory";
import { DEFAULT_PREFS } from "@/lib/userPreferences";
import {
DEFAULT_EDITOR_APPEARANCE_SETTINGS,
DEFAULT_EDITOR_LAYOUT_SETTINGS,
DEFAULT_EXPORT_SETTINGS,
DEFAULT_GIF_SETTINGS,
DEFAULT_WEBCAM_SETTINGS,
} from "./editorDefaults";
import { normalizeProjectEditor } from "./projectPersistence";
describe("editor defaults SSOT", () => {
it("keeps history defaults aligned with editor defaults", () => {
expect(INITIAL_EDITOR_STATE).toMatchObject({
...DEFAULT_EDITOR_APPEARANCE_SETTINGS,
padding: DEFAULT_EDITOR_LAYOUT_SETTINGS.padding,
aspectRatio: DEFAULT_EDITOR_LAYOUT_SETTINGS.aspectRatio,
cropRegion: DEFAULT_EDITOR_LAYOUT_SETTINGS.cropRegion,
wallpaper: DEFAULT_EDITOR_LAYOUT_SETTINGS.wallpaper,
webcamLayoutPreset: DEFAULT_WEBCAM_SETTINGS.layoutPreset,
webcamMaskShape: DEFAULT_WEBCAM_SETTINGS.maskShape,
webcamSizePreset: DEFAULT_WEBCAM_SETTINGS.sizePreset,
webcamPosition: DEFAULT_WEBCAM_SETTINGS.position,
});
});
it("keeps user preference defaults aligned with editor and export defaults", () => {
expect(DEFAULT_PREFS).toMatchObject({
padding: DEFAULT_EDITOR_LAYOUT_SETTINGS.padding,
aspectRatio: DEFAULT_EDITOR_LAYOUT_SETTINGS.aspectRatio,
exportQuality: DEFAULT_EXPORT_SETTINGS.quality,
exportFormat: DEFAULT_EXPORT_SETTINGS.format,
});
});
it("keeps project fallback normalization aligned with editor defaults", () => {
expect(normalizeProjectEditor({})).toMatchObject({
...DEFAULT_EDITOR_APPEARANCE_SETTINGS,
padding: DEFAULT_EDITOR_LAYOUT_SETTINGS.padding,
cropRegion: DEFAULT_EDITOR_LAYOUT_SETTINGS.cropRegion,
wallpaper: DEFAULT_EDITOR_LAYOUT_SETTINGS.wallpaper,
aspectRatio: DEFAULT_EDITOR_LAYOUT_SETTINGS.aspectRatio,
webcamLayoutPreset: DEFAULT_WEBCAM_SETTINGS.layoutPreset,
webcamMaskShape: DEFAULT_WEBCAM_SETTINGS.maskShape,
webcamSizePreset: DEFAULT_WEBCAM_SETTINGS.sizePreset,
webcamPosition: DEFAULT_WEBCAM_SETTINGS.position,
exportQuality: DEFAULT_EXPORT_SETTINGS.quality,
exportFormat: DEFAULT_EXPORT_SETTINGS.format,
gifFrameRate: DEFAULT_GIF_SETTINGS.frameRate,
gifLoop: DEFAULT_GIF_SETTINGS.loop,
gifSizePreset: DEFAULT_GIF_SETTINGS.sizePreset,
});
});
});
@@ -0,0 +1,99 @@
import { DEFAULT_CURSOR_THEME_ID } from "@/lib/cursor/cursorThemes";
import type { ExportFormat, ExportQuality, GifFrameRate, GifSizePreset } from "@/lib/exporter";
import { DEFAULT_WALLPAPER } from "@/lib/wallpaper";
import type { AspectRatio } from "@/utils/aspectRatioUtils";
import {
type CursorVisualSettings,
DEFAULT_CROP_REGION,
DEFAULT_CURSOR_CLICK_BOUNCE,
DEFAULT_CURSOR_CLIP_TO_BOUNDS,
DEFAULT_CURSOR_MOTION_BLUR,
DEFAULT_CURSOR_SIZE,
DEFAULT_CURSOR_SMOOTHING,
DEFAULT_WEBCAM_LAYOUT_PRESET,
DEFAULT_WEBCAM_MASK_SHAPE,
DEFAULT_WEBCAM_POSITION,
DEFAULT_WEBCAM_SIZE_PRESET,
type WebcamLayoutPreset,
type WebcamMaskShape,
type WebcamPosition,
type WebcamSizePreset,
} from "./types";
export const DEFAULT_SOURCE_DIMENSIONS = {
width: 1920,
height: 1080,
} as const;
export const DEFAULT_GIF_OUTPUT_DIMENSIONS = {
width: 1280,
height: 720,
} as const;
export const DEFAULT_EDITOR_APPEARANCE_SETTINGS: {
shadowIntensity: number;
showBlur: boolean;
motionBlurAmount: number;
borderRadius: number;
showTrimWaveform: boolean;
} = {
shadowIntensity: 0,
showBlur: false,
motionBlurAmount: 0,
borderRadius: 0,
showTrimWaveform: true,
};
export const DEFAULT_EDITOR_LAYOUT_SETTINGS: {
padding: number;
aspectRatio: AspectRatio;
cropRegion: typeof DEFAULT_CROP_REGION;
wallpaper: string;
} = {
padding: 50,
aspectRatio: "16:9",
cropRegion: DEFAULT_CROP_REGION,
wallpaper: DEFAULT_WALLPAPER,
};
export const DEFAULT_WEBCAM_SETTINGS = {
layoutPreset: DEFAULT_WEBCAM_LAYOUT_PRESET,
maskShape: DEFAULT_WEBCAM_MASK_SHAPE,
sizePreset: DEFAULT_WEBCAM_SIZE_PRESET,
position: DEFAULT_WEBCAM_POSITION,
} as const satisfies {
layoutPreset: WebcamLayoutPreset;
maskShape: WebcamMaskShape;
sizePreset: WebcamSizePreset;
position: WebcamPosition | null;
};
export const DEFAULT_CURSOR_SETTINGS: CursorVisualSettings & { show: boolean; theme: string } = {
show: true,
size: DEFAULT_CURSOR_SIZE,
smoothing: DEFAULT_CURSOR_SMOOTHING,
motionBlur: DEFAULT_CURSOR_MOTION_BLUR,
clickBounce: DEFAULT_CURSOR_CLICK_BOUNCE,
clipToBounds: DEFAULT_CURSOR_CLIP_TO_BOUNDS,
theme: DEFAULT_CURSOR_THEME_ID,
};
export const DEFAULT_EXPORT_SETTINGS: {
quality: ExportQuality;
format: ExportFormat;
} = {
quality: "good",
format: "mp4",
};
export const DEFAULT_GIF_SETTINGS: {
frameRate: GifFrameRate;
loop: boolean;
sizePreset: GifSizePreset;
outputDimensions: typeof DEFAULT_GIF_OUTPUT_DIMENSIONS;
} = {
frameRate: 15,
loop: true,
sizePreset: "medium",
outputDimensions: DEFAULT_GIF_OUTPUT_DIMENSIONS,
};

Some files were not shown because too many files have changed in this diff Show More