Compare commits

...
Author SHA1 Message Date
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
EtienneLescot 10614c2950 Address webcam sidecar review feedback 2026-05-22 21:20:51 +02: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
EtienneLescot ef5855f1f4 Fix native Windows webcam sidecar capture
Record browser webcam sidecar when native Windows capture is active.

Add native webcam sidecar output and DirectShow NV12/YUY2 fallback.

Sample exported webcam frames by source timestamp.
2026-05-22 20:56:09 +02:00
AjTheSpidey ea09fdc12c test: expose source selector hooks 2026-05-21 19:35:26 +08:00
Sid 9f7f498e22 Merge pull request #621 from LucaFontanot/refactor-cursor
refactor: Migrate the powershell cursor script into native cursor sampler
2026-05-20 21:23:09 -07:00
Sid a9df720554 Merge pull request #614 from creazyfrog/feature/rename-native-to-original
ux: rename 'Native' aspect ratio label to 'Original'
2026-05-20 21:06:36 -07:00
Sid 37ab35f5a8 Merge pull request #603 from AjTheSpidey/codex/multi-source-recording-editor
test: cover MP4 editor export
2026-05-20 21:05:40 -07:00
Sid 4a55dcdb4c Merge pull request #618 from LucaFontanot/i18n-ita
i18n: Add italian
2026-05-20 20:37:48 -07:00
Luca Fontanot cfe6b9e594 fix: Thread detach before teardown is race-prone. 2026-05-20 11:53:50 +02:00
Luca Fontanot 7826cc44e3 fix: Add <algorithm> include for std::max 2026-05-20 11:49:23 +02:00
AjTheSpidey 57eed2c829 test: tighten export e2e guards 2026-05-20 16:38:14 +08:00
Sid 12350520cc Merge pull request #622 from marcgabe15/fix-locale-save-diagnostics
Fix locale save diagnostics
2026-05-19 20:59:52 -07:00
Marc Diaz a9181e6782 fix: update translations 2026-05-19 23:22:50 -04:00
Marc Diaz 3be317b7f9 refactor: update support key 2026-05-19 22:41:38 -04:00
Luca Fontanot 49ee3ac0db refactor: Migrate the powershell cursor script into the native cursor-sampler.cpp 2026-05-20 00:45:33 +02:00
AjTheSpidey fd2bf21e7e test: wait for export bytes in e2e 2026-05-20 04:07:38 +08:00
AjTheSpidey 94e848452e test: cover MP4 editor exports 2026-05-20 00:14:04 +08:00
Luca Fontanotandcoderabbitai[bot] de3c2ef5bd i18n: CR update src/i18n/locales/it/shortcuts.json
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-05-19 15:14:29 +02:00
Luca Fontanot c4acb9d6e7 i18n: Fix application language selection with electronAPI setLocale 2026-05-19 14:34:45 +02:00
Luca Fontanot a1a75badde i18n: Add italian (and missing russian) language to window main locale 2026-05-19 14:33:16 +02:00
Luca Fontanot 7319ec2db6 i18n: Fixed language sorting and added italian to config and test 2026-05-19 14:19:41 +02:00
Luca Fontanot de2cc6546a i18n: Added italian 2026-05-19 14:15:02 +02:00
Rohit SharmaandClaude Sonnet 4.6 9348b9c7a0 ux: rename 'Native' aspect ratio label to 'Original'
The aspect ratio dropdown showed 'Native', which is video-industry jargon
that isn't self-explanatory for most users. Renaming it to 'Original'
makes it immediately clear that this option preserves the source video's
own dimensions.

The internal `"native"` value in the AspectRatio union type is unchanged;
only the display string returned by `getAspectRatioLabel()` is updated.

Closes #607

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 01:19:50 -07:00
Sid 00fbd95452 Merge pull request #609 from kwakseongjae/i18n/ko-kr-missing-keys
i18n(ko-KR): fill missing Korean translation keys
2026-05-18 21:00:31 -07:00
kwakseongjaeandCursor dd413785f3 fix(i18n): remove duplicate keys in ko-KR settings after main merge
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-19 12:30:07 +09: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
Sid 36ceca38f9 Merge pull request #611 from auberginewly/fix/suggest-zooms-export-hidden
fix(zoom): keep export panel visible after Suggest Zooms
2026-05-18 19:26:54 -07:00
Sid 7bae09fc88 Merge branch 'main' into i18n/ko-kr-missing-keys 2026-05-18 19:26:05 -07:00
auberginewly bc8655a4bb fix(zoom): 批量建议缩放后不再抢占选中态,避免导出面板被隐藏
handleZoomSuggested 每加一个建议缩放就调用 setSelectedZoomId,循环结束后
最后一个 auto-zoom 处于选中态。SettingsPanel 以 !hasTimelineSelection 作为
导出面板渲染条件,导致用户点完"自动添加缩放"后导出按钮消失,必须先点剪辑
轨道取消选中才能导出。

批量 suggest 路径移除选中副作用;单个手动添加 (handleZoomAdded) 保持自动
选中不变。

Closes #610
2026-05-19 10:23:37 +08:00
Sid e7ca9ecb5d Merge pull request #573 from EtienneLescot/feat/macos-native-capture-pipeline
feat: add macOS native capture and cursor pipeline
2026-05-18 08:03:21 -07:00
kwakseongjaeandClaude Opus 4.7 0e130b6d49 feat(i18n): fill missing Korean (ko-KR) translation keys
Adds Korean translations for keys that had accumulated in en/* but were
missing from ko-KR/* as other features landed after the initial Korean
localization.

common.json (22 keys, matching macOS Korean menu standards):
- actions.{undo, redo, cut, copy, paste, selectAll, minimize, reload,
  forceReload, toggleDevTools, actualSize, zoomIn, zoomOut,
  toggleFullScreen, recordingStatus, about, services, hide, hideOthers,
  unhide}

settings.json (7 keys):
- zoom.customScale, zoom.position.{title, x, y, hint}
- layout.noWebcam
- effects.on

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 22:37:25 +09:00
Sagar Dash b5c105b5d6 fix: respect native webcam orientation on Windows 2026-05-18 18:28:13 +06:00
Etienne Lescot 788b0a2e9f fix(cursor): default canvas clipping off 2026-05-18 12:38:40 +02:00
auberginewly 8516707880 fix(cursor): address review findings — aria-label, 3D transform sync, i18n
- Add aria-label to cursorClipToBounds Switch so screen readers announce the control
- Mirror composite3D 3D transform onto nativeCursorClipRef so the cursor clip layer
  rotates with the video during 3D zoom regions (cursor stays outside preserve-3d
  so clip-path continues to work; only the transform string is mirrored)
- Fix vi cursor.motionBlur: "Mờ chuyển động" → "Làm mờ chuyển động" to match
  effects.motionBlur phrasing
- Fix zh-TW cursor.motionBlur: "運動模糊" → "動態模糊" to match effects.motionBlur
2026-05-18 12:19:48 +02:00
auberginewly 58722a1d84 feat(cursor): add cursorClipToBounds setting with i18n translations
Add a cursor.clipToBounds toggle to the Settings panel (default on) that controls
whether the native cursor is clipped to the video canvas boundary in both preview
and export. Wire up 11 locale files (ar, en, es, fr, ja-JP, ko-KR, ru, tr, vi,
zh-CN, zh-TW) with the new cursor settings section.
2026-05-18 12:19:48 +02:00
auberginewly 65bb5bc8dd feat(cursor): clip native cursor to camera-aware video bounds in preview and export
- Add nativeCursorClipRef div (outside preserve-3d) with CSS inset() clip-path that
  tracks the camera-transformed video boundary, including border-radius
- Add cameraAwareMaskRect() in FrameRenderer that computes the same boundary for
  Canvas 2D clip in the export path; remove stage-clamping so rounded corners match
  the preview's inset() behavior when zoom/pan pushes the mask off-stage
- Cache maskBorderRadius in LayoutCache so both shadow and direct composite paths
  can apply camera-aware rounded clipping
- Fix double mask.x offset introduced by nativeCursorMaskRef; replace mask div with
  clip-path on the outer wrapper
- Normalize cursor size relative to maskRect.width so preview and export scale match
- Clip cursor to canvas boundary and hide on non-recorded display
- Wire cursorClipToBounds flag through FrameRenderConfig and VideoExporter
2026-05-18 12:19:47 +02:00
Etienne Lescot 31e394fe1c fix: address follow-up review comments 2026-05-18 12:19:47 +02:00
Etienne Lescot e708ae973e fix: address native mac review feedback 2026-05-18 12:19:47 +02:00
Etienne Lescot 179047b834 fix: isolate macOS native capture by platform 2026-05-18 12:19:47 +02:00
Etienne Lescot df6da28ad2 fix: improve macOS HUD interactions and audio preview 2026-05-18 12:19:47 +02:00
Etienne Lescot c1ba82fc71 chore: sync i18n locale keys 2026-05-18 12:19:47 +02:00
Etienne Lescot 73870c65ef feat: support pausing macOS native recordings 2026-05-18 12:19:47 +02:00
Etienne Lescot b2f9afab8c feat: add macOS editable cursor overlay support 2026-05-18 12:19:47 +02:00
Etienne 6a4ddc5dad feat: compose mac native capture with media 2026-05-18 12:19:05 +02:00
Etienne b9e2134749 feat: add macos screencapturekit helper 2026-05-18 12:19:05 +02:00
Etienne 7102110de5 chore: ignore macos native build outputs 2026-05-18 12:19:05 +02:00
EtienneLescot fbdc7d5697 feat: scaffold macOS native capture pipeline 2026-05-18 12:19:05 +02:00
Sid 6018ba0fe1 Merge pull request #594 from EtienneLescot/codex/timeline-empty-space-scrub
[codex] add empty timeline scrubbing
2026-05-16 12:35:30 -07:00
Sid e50f65e3b6 Merge pull request #597 from EtienneLescot/codex/fix-editable-cursor-native
Fix editable cursor mode for native Windows capture
2026-05-16 12:34:57 -07:00
Sid 80e2c3545d Merge pull request #598 from EtienneLescot/codex/fix-high-quality-export
Clarify export resolution presets
2026-05-16 12:34:33 -07:00
Sid 939de7081b Merge pull request #593 from EtienneLescot/codex/fix-native-aspect-ratio-fallback
[codex] fix native aspect ratio fallback
2026-05-16 12:34:05 -07:00
Sid 55dfca05aa Merge pull request #596 from EtienneLescot/codex/fix-language-prompt-clicks
[codex] fix language prompt HUD clicks
2026-05-16 12:33:31 -07:00
EtienneLescot 5e76170307 Clarify MP4 export resolution presets 2026-05-16 20:19:00 +02:00
EtienneLescot 0d3c4df453 fix: relax cursor capture helper validation 2026-05-16 13:54:51 +02:00
EtienneLescot 9d5be8beb4 fix: enforce cursor-free WGC editable mode 2026-05-16 13:44:08 +02:00
EtienneLescot c4eb3003be fix timeline scrub lint formatting 2026-05-16 13:20:20 +02:00
EtienneLescot 7bf07611c3 fix language prompt hud clicks 2026-05-16 12:53:18 +02:00
EtienneLescot c9985a08d4 add empty timeline scrubbing 2026-05-16 12:21:30 +02:00
EtienneLescot 55bc0c9836 fix native aspect ratio fallback 2026-05-16 12:20:54 +02: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
Sid b0293e7d93 Merge pull request #217 from EtienneLescot/feat/cursor-pipeline
feat: add Windows native capture and cursor pipeline
2026-05-10 14:21:01 -07:00
Siddharth 1d36ad239d Merge remote-tracking branch 'origin/main' into feat/cursor-pipeline
# Conflicts:
#	src/components/video-editor/VideoEditor.tsx
2026-05-10 14:17:42 -07:00
Siddharth b41c4f49fc remove macos cursor highlight; wire telemetry session for non-windows 2026-05-10 14:12:54 -07:00
Sid 201729e8ab Merge pull request #536 from yusufm/codex/export-diagnostics
Improve export failure diagnostics
2026-05-10 12:03:24 -07:00
EtienneLescot 0720a6d802 fix: restore native cursor wiring after upstream rebase 2026-05-10 15:19:19 +02:00
EtienneLescot 8137e816fd fix: normalize native Windows audio for AAC 2026-05-10 15:11:38 +02:00
EtienneLescot 4e5b7a4f5a test: log source copy fast path blockers 2026-05-10 15:11:38 +02:00
EtienneLescot afd5e35730 docs: remove README developer notes link 2026-05-10 15:11:37 +02:00
EtienneLescot ac2e34e58c fix: preserve Windows system audio on export 2026-05-10 15:11:37 +02:00
EtienneLescot 4d3bce0f20 feat: add Windows cursor capture mode 2026-05-10 15:11:36 +02:00
EtienneLescot b349c0a27c fix: downmix multichannel export audio 2026-05-10 15:11:35 +02:00
EtienneLescot 238fc97c6d fix: preserve cursor and audio in exports 2026-05-10 15:11:34 +02:00
EtienneLescot 0d9e821171 fix: guard source copy while native cursor data loads 2026-05-10 15:11:34 +02:00
EtienneLescot 34e22d001c fix: restore source copy export fast path 2026-05-10 15:11:33 +02:00
EtienneLescot 722f630117 fix: address maintainer platform regressions 2026-05-10 15:11:32 +02:00
EtienneLescot f4fc7fab9e fix: preserve native cursor click interactions 2026-05-10 15:11:31 +02:00
EtienneLescot f91300a1b7 fix: make native cursor click bounce visible 2026-05-10 15:11:31 +02:00
EtienneLescot 82bffefa54 fix: harden native recorder review paths 2026-05-10 15:11:30 +02:00
EtienneLescot 826790fe52 fix: address native cursor review findings 2026-05-10 15:11:29 +02:00
EtienneLescot 9b85cacec7 test: harden Windows cursor diagnostic 2026-05-10 15:11:28 +02:00
EtienneLescot f76fb423be docs: backlog native cursor click bounce 2026-05-10 15:11:28 +02:00
EtienneLescot e33d2205e6 fix: record native cursor click events 2026-05-10 15:11:28 +02:00
EtienneLescot 3a32a140cc fix: capture quick native cursor clicks 2026-05-10 15:11:27 +02:00
EtienneLescot d0341580d6 feat: apply native cursor visual effects 2026-05-10 15:11:27 +02:00
EtienneLescot ab3d38d90f fix: address native capture review feedback 2026-05-10 15:11:25 +02:00
EtienneLescot c7b43a50ef fix: resolve selected Windows microphone 2026-05-10 15:11:24 +02:00
EtienneLescot 0ebf5c143b test: add Windows native checklist smoke test 2026-05-10 15:11:23 +02:00
EtienneLescot c0deb03414 fix: gate Windows cursor settings 2026-05-10 15:11:22 +02:00
EtienneLescot 38d727eb8e fix: skip black webcam warmup frames 2026-05-10 15:11:21 +02:00
EtienneLescot 84484d6167 fix: support DirectShow virtual webcams 2026-05-10 15:11:21 +02:00
EtienneLescot fdcd882058 fix: honor selected native Windows webcam 2026-05-10 15:11:20 +02:00
EtienneLescot fb85f66875 feat: add native Windows webcam composition 2026-05-10 15:11:19 +02:00
EtienneLescot 048189da72 feat: add native Windows window capture 2026-05-10 15:11:18 +02:00
EtienneLescot 7929aea908 fix: align native mixed audio timeline 2026-05-10 15:11:17 +02:00
EtienneLescot 588a0a7be8 feat: add native Windows microphone capture 2026-05-10 15:11:17 +02:00
EtienneLescot 062cf2a87c feat: add native Windows recorder helper 2026-05-10 15:11:16 +02:00
EtienneLescot d21e5eb34c fix: restore native cursor preview and export 2026-05-10 15:11:15 +02:00
EtienneLescot 87240a919e fix: align native cursor preview and export 2026-05-10 15:11:12 +02:00
EtienneLescot 3d1d4a5ff0 fix: avoid unsupported display media min constraint 2026-05-10 15:11:10 +02:00
EtienneLescot ef36da4a4f feat: complete windows cursor assets 2026-05-10 15:11:09 +02:00
EtienneLescot bb0dec7344 feat: add windows cursor preview diagnostics 2026-05-10 15:11:07 +02:00
EtienneLescot 28ff0fb7bf fix: restore cursor pipeline build after rebase 2026-05-10 15:11:03 +02:00
Etienne Lescot e9650225ba feat: add cursor overlay pipeline for high-fidelity cursor recording and playback
- Implement native bridge for Windows cursor capture via PowerShell/C#
- Add cursor-free capture using getDisplayMedia with setDisplayMediaRequestHandler
- Update video player and exporters to support native cursor telemetry
- Enable system audio capture on Windows via WASAPI loopback
- Add interpolation for smoother cursor movement in playback and export
- Improve cursor scaling and visibility handling in editor and playback
2026-05-10 15:11:00 +02:00
Etienne Lescot 248ebabcf1 feat: add windows native cursor capture and rendering 2026-05-10 15:10:56 +02:00
Etienne Lescot 44f59bfa89 feat: add unified native bridge foundation 2026-05-10 15:10:54 +02:00
Etienne Lescot 6f099b3483 feat: add cursor overlay pipeline 2026-05-10 15:10:53 +02: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
Sid 162e734b76 Merge pull request #535 from yusufm/codex/lazy-load-editor
Lazy load the editor bundle
2026-05-09 22:22:33 -07:00
Yusuf Mohsinally 2b8ec9e3a5 Merge remote-tracking branch 'origin/main' into codex/export-diagnostics
# Conflicts:
#	src/components/video-editor/VideoEditor.tsx
2026-05-09 20:03:58 -07:00
Siddharth e3d4a330df ui revamp 2026-05-09 19:18:16 -07:00
Siddharth 7bbb855e8e update readme 2026-05-09 17:32:43 -07:00
Siddharth 52cb709a88 readme update 2026-05-09 17:12:27 -07:00
Sid 68a95c642a Merge pull request #567 from siddharthvaddem/chore/bump-nix-1.4.0
chore: bump nix package to v1.4.0
2026-05-09 17:05:05 -07:00
github-actions[bot] 2ae7aca185 chore: bump nix package to v1.4.0 2026-05-10 00:04:08 +00:00
Sid 8afca89520 Merge pull request #566 from siddharthvaddem/chore/nix-auto-bump-workflow
chore: add nix package auto-bump workflow
2026-05-09 16:59:34 -07:00
SiddharthandClaude Opus 4.7 7feb05cca7 add nix package auto-bump workflow
On every published GitHub Release, opens a PR bumping nix/package.nix:
- version => the new release version
- npmDepsHash => freshly computed via prefetch-npm-deps package-lock.json

Mirrors the brew + winget release-bump pattern, but lands the change in
this repo (not a separate tap), so it opens a PR instead of pushing
directly. Uses GITHUB_TOKEN — note that PRs created by GITHUB_TOKEN do
not auto-trigger CI; the diff is two lines, easy to review and merge.

Refs the long-standing manual-bump pain (e.g. PR #504 fixing a stale
hash). After this lands, Nix users get new releases without anyone
having to remember the manual edit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 16:58:51 -07:00
Sid 9ac4e778f8 Merge pull request #565 from siddharthvaddem/chore/winget-releaser-workflow
chore: add winget-releaser workflow
2026-05-09 16:42:27 -07:00
SiddharthandClaude Opus 4.7 ed825d8b37 add winget-releaser workflow
Auto-publishes new releases to winget via vedantmgoyal9/winget-releaser.
On every "released" event (not pre-release), the action opens a PR against
microsoft/winget-pkgs bumping SiddharthVaddem.OpenScreen.

Requires:
- WINGET_ACC_TOKEN secret: classic PAT with public_repo scope
  (fine-grained PATs are NOT supported by the action).
- A fork of microsoft/winget-pkgs under siddharthvaddem (or pass fork-user
  if forked elsewhere).

Closes #299

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 16:39:31 -07:00
Siddharth b48370e3d0 update readme w brew 2026-05-09 16:34:26 -07:00
Sid 8c0c555640 Merge pull request #564 from siddharthvaddem/chore/homebrew-cask-final-style
chore: final homebrew cask style cleanup
2026-05-09 16:28:49 -07:00
SiddharthandClaude Opus 4.7 24be97bae7 fix: final homebrew cask style + audit cleanup
- Drop unnecessary verified: stanza (URL host matches homepage host).
- Add blank line between sha256 and url inside on_arm/on_intel
  (rubocop treats them as separate stanza groups).
- Keep no blank line between on_arm and on_intel blocks
  (same outer stanza group).

After re-running the bump workflow, the cask passes both
brew audit --cask and brew style --cask cleanly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 16:28:15 -07:00
Sid c820b416f9 Merge pull request #563 from siddharthvaddem/chore/homebrew-cask-audit-fix
chore: fix homebrew cask audit warnings
2026-05-09 16:25:08 -07:00
SiddharthandClaude Opus 4.7 f42c478725 fix homebrew cask audit warnings
- Use #{version} interpolation in URLs so brew detects them as versioned
  (silences "Use sha256 :no_check when URL is unversioned").
- Drop blank line between on_arm and on_intel (same stanza group).
- Alphabetize zap trash array.
- Add verified: stanza for the GitHub release URL.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 16:22:58 -07:00
Sid 64c2a43006 Merge pull request #562 from siddharthvaddem/chore/homebrew-cask-workflow
chore: add homebrew cask bump workflow
2026-05-09 16:07:47 -07:00
SiddharthandClaude Opus 4.7 4a0878c3d0 add homebrew cask bump workflow
Auto-updates the openscreen Homebrew tap on each published release:
finds the macOS DMGs, computes sha256, and rewrites Casks/openscreen.rb
in siddharthvaddem/homebrew-openscreen.

Requires HOMEBREW_TAP_TOKEN secret with contents:write on the tap repo.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 16:03:50 -07:00
Sid d8da26a41a Merge pull request #561 from auberginewly/fix/electron-screen-capture-permissions
fix(macOS): fix three screen capture permission issues in Electron layer
2026-05-09 15:10:47 -07:00
Sid f0699cc8b0 Merge pull request #507 from hthienloc/add-vietnamese-i18n-1022783609047552672
Add Vietnamese i18n support (vi locale)
2026-05-09 14:48:31 -07:00
Siddharth 3ad3e22a16 test(i18n): add vi to tutorialHelpTranslations locale map 2026-05-09 14:43:56 -07:00
Siddharth 2381e48a46 Merge main into add-vietnamese-i18n-1022783609047552672
Resolve conflict in electron/i18n.ts by keeping both `ar` (from main) and `vi` (from this branch). Also add `vi` to SUPPORTED_LOCALES in src/i18n/config.ts so Vietnamese is selectable in the language picker.
2026-05-09 14:35:03 -07:00
Siddharth 68c35ff01c zoom precision position 2026-05-09 14:32:50 -07:00
auberginewly 3dd7b85ebb fix(build): remove misplaced entitlement key from mac.extendInfo
com.apple.security.device.audio-input is an entitlement key and should
only appear in macos.entitlements. Placing it in extendInfo writes it
into Info.plist where it has no effect and is misleading.

The correct entry already exists in macos.entitlements; this removes
the redundant, incorrectly-placed duplicate.
2026-05-10 05:30:48 +08:00
auberginewly be4e2d0c94 fix(electron/macOS): proactively check screen recording permission on startup
Microphone permission is checked at startup via getMediaAccessStatus, and
camera has a dedicated request-camera-access IPC handler, but screen
recording relied entirely on desktopCapturer.getSources() to implicitly
trigger the TCC prompt — causing the permission dialog to reappear on
every launch (issue #558).

Note: askForMediaAccess() only accepts "microphone" | "camera"; screen
recording TCC is triggered via desktopCapturer.getSources() instead.

Fix:
- Import desktopCapturer in main.ts
- Call getMediaAccessStatus("screen") in app.whenReady(); trigger the
  TCC prompt via getSources when status is "not-determined"
- Add request-screen-access IPC handler symmetric to request-camera-access
2026-05-10 05:30:42 +08:00
auberginewly c9b6074626 fix(electron): add screen and display-capture to Electron permission allowlists
setPermissionCheckHandler and setPermissionRequestHandler only allowed
["media", "audioCapture", "microphone", "videoCapture", "camera"], causing
any renderer-side getUserMedia/desktopCapturer request using a screen source
to be silently denied by Electron before macOS TCC is ever consulted.

Fix: add "screen" and "display-capture" to both handler allowlists.
2026-05-10 05:24:19 +08:00
Siddharth c1f6cf67b2 loc first and then export processing 2026-05-09 11:59:52 -07:00
Siddharth 5bd17f4346 fix layout 2026-05-09 11:46:09 -07:00
Sid d3e397e249 Merge pull request #399 from muratclk/fix/trim-handle-boundary-clamp
fix: clamp trim handle end position to timeline boundary
2026-05-09 10:19:12 -07:00
Murat Çelik c771bf8bb9 fix: clamp trim handle end position to timeline boundary
The right-side trim handle could be dragged past the end of the
timeline because clampSpanToBounds did not cap the computed end
value at totalMs. This adds Math.min(…, totalMs) so the handle
snaps to the timeline edge.

Fixes #393
2026-05-09 10:07:01 -07:00
Sid 38f2044967 Merge pull request #549 from Ayusman-Singhal/feat/no-webcam-layout-preset
feat: add 'No Webcam' layout preset to hide webcam in final recording
2026-05-09 10:03:45 -07:00
Sid b4f7b4c182 Merge pull request #518 from makaradam/feature/custom-zoom-slider-clean
feat: add custom zoom slider with continuous scale control (#513)
2026-05-09 09:14:37 -07:00
Sid e880f05866 Merge pull request #504 from 0david0mp/fix/package.nix
fix: bumped npmDepsHash on package.nix
2026-05-09 09:02:22 -07:00
Sid b7c85a9b4e Merge pull request #546 from psychosomat/feature/add-russian-localization
Add Russian localization
2026-05-09 08:57:12 -07:00
Sid bc7c51ecdf Merge branch 'main' into feature/add-russian-localization 2026-05-09 08:55:16 -07:00
makaradam 42127e647f fix: add NaN guard in handleZoomCustomScaleChange before state update 2026-05-09 11:23:37 +02:00
makaradam f3dcbf2867 fix: address code review feedback on custom zoom slider
- Clamp and NaN-guard customScale in getZoomScale (defensive sanitization)
- Set customScale on preset button click so slider stays green
- Set customScale on new zoom region creation so slider lights up immediately
2026-05-09 11:23:37 +02:00
makaradam f30090bf88 fix: sanitize customScale in getZoomScale and fix isCustomActive styling 2026-05-09 11:23:36 +02:00
makaradamandClaude Sonnet 4.6 37215531c2 feat: add custom zoom slider with continuous scale control (#513)
Adds a Radix UI slider below the zoom preset buttons allowing any scale
between 1.0x and 5.0x. When the slider value matches a preset exactly,
that preset button also shows as active.

- Add `customScale?: number` to `ZoomRegion` and `getZoomScale()` helper
  that returns customScale when set, falling back to ZOOM_DEPTH_SCALES[depth]
- Overlay indicator, playback renderer, and frame exporter all use
  getZoomScale() so preview, playback, and export are consistent
- Fix focus clamping in zoomRegionUtils and frameRenderer to use actual
  scale instead of depth-based preset scale, preventing zoom drift with
  custom values
- Fix drag boundary in VideoPlayback to use clampFocusToScale with the
  actual scale so the full canvas is clickable at high custom zoom levels
- Timeline item label shows custom scale value when set
- Slider styled dark with green thumb/fill when a custom (non-preset) value is active

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 11:23:36 +02:00
Sid 770a872861 Merge pull request #521 from makaradam/feature/save-dialog-redesign
feat: replace native OS close dialog with custom in-app dialog
2026-05-08 20:14:43 -07:00
Sid e170fe1a83 Merge pull request #554 from creazyfrog/fix/missing-NSScreenCaptureUsageDescription
fix(macOS): add NSScreenCaptureUsageDescription and screen-capture entitlement
2026-05-08 20:07:32 -07:00
Sid 9af318561f Merge pull request #512 from AbhinRustagi/feature/remember-last-export-folder
feat: Add exportFolder to user preferences
2026-05-08 19:30:56 -07:00
Sid b525571f71 Merge pull request #552 from marcgabe15/feature/diagnostics
add diagnostics report
2026-05-08 08:08:29 -07:00
Sid fe93050089 Merge pull request #551 from marcgabe15/fix/tests
fix: tests + how to write them
2026-05-08 08:04:54 -07:00
Trivenzaa-Admin f47fa6bdca fix(macos): add NSScreenCaptureUsageDescription and screen-capture entitlement
Without NSScreenCaptureUsageDescription in Info.plist, macOS silently
blocks desktopCapturer.getSources(), breaking window detection on macOS
10.15+. Also adds the com.apple.security.device.screen-capture entitlement
to macos.entitlements alongside the existing camera and audio-input entries.

Fixes #548
2026-05-08 01:48:52 -07:00
Marc Diaz a0c423de67 add diagnostics report 2026-05-08 00:00:30 -04:00
Marc Diaz c9980b0dca fix: tests + how to write them 2026-05-07 23:22:32 -04:00
AbhinRustagi 1aac6eddb0 Merge branch 'main' of github.com:siddharthvaddem/openscreen into feature/remember-last-export-folder 2026-05-08 05:29:14 +05:30
AbhinRustagi 25cfd2777f fix: resolve comments 2026-05-08 05:24:40 +05:30
Ayusman Singhal ada1f434f7 feat: add 'No Webcam' layout preset to hide webcam in final recording
Adds a new 'No Webcam' option to the webcam layout preset dropdown in the editor. When selected, the webcam feed is completely hidden from both the preview and the exported video, allowing users who recorded with a webcam to exclude it from the final output.

- Add 'no-webcam' to WebcamLayoutPreset type union and preset map

- Handle 'no-webcam' in computeCompositeLayout (returns webcamRect: null)

- Add 'no-webcam' case in project persistence normalization

- Add 'No Webcam' option to the layout preset dropdown in SettingsPanel

- Add 'noWebcam' i18n translation key (en)
2026-05-07 12:19:48 +05:30
psychosomat 9336e3d3c6 Fix Russian translation typo and reorder imports 2026-05-06 13:16:21 +03:00
psychosomat 6130c66be6 Add Russian localization 2026-05-06 12:55:01 +03:00
Siddharth 899504f8e2 fix export mouse overlay 2026-05-05 22:02:21 -07:00
Siddharth 6a6caf618b fix build 2026-05-05 20:29:53 -07:00
Sid b6af435e7f Merge pull request #529 from i1Zeus/arabic-support
feat: add Arabic localization support for editor, launch, settings, s…
2026-05-05 19:09:12 -07:00
Siddharth c13ec0df7d fix build to exclude uiohook 2026-05-04 19:48:30 -07:00
Sid 40f18a9bdf Merge pull request #542 from auberginewly/fix/i18n-add-missing-zoom-threeD-keys
fix(i18n): add missing zoom.threeD translation keys for 7 locales
2026-05-04 18:53:39 -07:00
auberginewly 81b1eb3e8a fix(i18n): 补充 7 个语言缺失的 zoom.threeD 翻译键
es/fr/ja-JP/ko-KR/tr/zh-CN/zh-TW 的 settings.json 均缺少
zoom.threeD.title 和 zoom.threeD.preset.{iso,left,right},
导致 npm run i18n:check 报告 MISSING。
2026-05-05 06:37:21 +08:00
Yusuf Mohsinally 559e97ddea Wrap export error diagnostics 2026-05-03 18:08:22 -07:00
Siddharth 190d5d8ecb 3d iso,tilt 2026-05-03 17:54:21 -07:00
Yusuf Mohsinally 156e9c1ec5 Improve export failure diagnostics 2026-05-03 14:24:42 -07:00
Yusuf Mohsinally 42c596da66 Lazy load the editor bundle 2026-05-03 14:20:43 -07:00
Siddharth 6fc19314dd fix dock macos lifecycle 2026-05-03 12:03:23 -07:00
Siddharth 7e00cdb1a9 preview intentional perf optimizations 2026-05-03 11:41:03 -07:00
i1Zeus a0d1cfe8c8 added ar to config and added fallback to the main.ts recordingStatus 2026-05-03 20:55:11 +03:00
Sid f7d1bc6f05 Merge pull request #484 from psychosomat/main
Improve Arch Linux support and fix video export on Hyprland
2026-05-03 10:23:27 -07:00
i1Zeus 59ecedb0ac implement i18n support and dynamic application menu in electron main process 2026-05-03 20:21:42 +03:00
i1Zeus bb30e20df7 implement lightweight i18n support for electron main process 2026-05-03 20:05:06 +03:00
i1Zeus b5d37c4270 feat: implement video editor SettingsPanel and add Arabic and English localization files 2026-05-03 20:03:01 +03:00
i1Zeus 679e306d31 feat: add Arabic localization support for editor, launch, settings, shortcuts, timeline, common, and dialogs modules 2026-05-03 19:49:35 +03:00
psychosomat b7d3563272 Upload pacman package in Linux CI artifacts 2026-05-03 12:10:00 +03:00
Siddharth 78f57970e9 fix ci checks 2026-05-02 23:27:38 -07:00
Sid bba5fd34cf Merge pull request #524 from hiroppelx/improve-ja-jp-localization
Improve Japanese localization
2026-05-02 23:23:44 -07:00
Sid 876378b622 Merge pull request #328 from AmirYunus/fix/305-hud-horizontal-scrollbar
fix(hud): avoid horizontal scrollbar when recording on Windows
2026-05-02 23:23:00 -07:00
Siddharth b7d1864a0b Merge main into fix/305-hud-horizontal-scrollbar
Resolved conflicts in src/App.tsx and src/components/launch/LaunchWindow.tsx:
- App.tsx: kept main's split useEffect for loadAllCustomFonts; placed PR's
  HUD-overlay style block inside the original [windowType] effect.
- LaunchWindow.tsx: kept main's systemLocaleSuggestion modal in place of the
  earlier inline language switcher; preserved PR's root-div className change
  that fixes the Windows horizontal-scrollbar bug.
2026-05-02 23:21:12 -07:00
Sunwood-ai-labs 613ed008fc Allow PNG custom background uploads 2026-05-03 15:18:50 +09:00
Siddharth 8d79a14e3b cursor highlighting and clicks 2026-05-02 23:03:14 -07:00
hiroppelx e4eeff0ea3 日本語訳を改善 2026-05-03 11:03:20 +09:00
Siddharth c8d4e867b2 fix recording inception error 2026-05-02 17:53:43 -07:00
Siddharth 279320d3ef fix save prompt despite being saved 2026-05-02 17:49:40 -07:00
Siddharth 0f28cc0f38 fix missing locales 2026-05-02 17:44:56 -07:00
Siddharth d59db3d839 fix missing spanish locale 2026-05-02 17:34:47 -07:00
Sid 716002f1a9 Merge pull request #370 from BaptisteAuscher/feature/color-wheel
feature/color-wheel
2026-05-02 14:32:44 -07:00
makaradam e2bdfee653 fix: scope IPC close-confirm responses to the originating window
Both ipcMain.once handlers now check event.sender.id against
windowToClose.webContents.id and ignore messages from any other
renderer, preventing cross-window response mix-ups if multiple editor
windows are ever open simultaneously.
2026-05-02 14:36:59 +02:00
makaradam e7e493294b fix: use relative path for logo so it resolves in packaged app
./openscreen.png resolves correctly both in dev (Vite serves public/)
and in production (loadFile sets base to dist/, where public assets land
inside the asar). getAssetPath points to extraResources, which is the
wrong location for bundled dist assets.
2026-05-02 14:33:14 +02:00
makaradam b2cc722613 fix: use getAssetPath for logo so it resolves correctly in packaged app 2026-05-02 13:43:20 +02:00
makaradam 36076aaf2a fix: address code review feedback on custom close dialog 2026-05-02 13:08:52 +02:00
makaradam b3469c469b feat: replace native OS close dialog with custom in-app dialog 2026-05-02 12:28:04 +02:00
AbhinRustagi 3aad05abd2 feat: add timeline snap guides 2026-05-02 02:38:25 +05:30
AbhinRustagi b801c1ccea fix: resolve comments 2026-05-02 01:19:44 +05:30
AbhinRustagi c40727672f feat: implement handlers to store last export location 2026-05-02 01:05:17 +05:30
AbhinRustagi a38454a7fb feat: update saveExportedVideo fn signature 2026-05-02 01:02:42 +05:30
BaptisteAuscher 8e8b194454 adds support for japanese and chineese (taiwan) 2026-04-30 22:22:46 +02:00
BaptisteAuscher 916d649037 Merge branch 'main' of github.com:siddharthvaddem/openscreen into feature/color-wheel 2026-04-30 22:07:31 +02:00
google-labs-jules[bot]andhthienloc 37c1ea5984 Add Vietnamese i18n support (vi locale)
Co-authored-by: hthienloc <148019203+hthienloc@users.noreply.github.com>
2026-04-30 16:27:57 +00:00
Sid 884021c7d6 Merge pull request #505 from marcgabe15/fix/decodeEarlyBug
Fix/decode early bug
2026-04-29 21:33:18 -07:00
Marc Diaz 93466fdda1 fix: add max duration 2026-04-29 22:52:15 -04:00
Marc Diaz 786165208f misc: remove misc changes 2026-04-29 22:45:41 -04:00
Marc Diaz 0768c449d7 feat: all changes 2026-04-29 22:36:49 -04:00
david dc7259ba09 fix: bumped npmDepsHash on package.nix 2026-04-29 10:31:08 +02:00
Sid a6fe33a0f6 Merge pull request #501 from FabLrc/fix/vp8-vp9-codec-normalization
Fix/vp8 vp9 codec normalization
2026-04-28 19:51:03 -07:00
Sid 608e0abe87 Merge pull request #457 from shaun0927/fix/cursor-telemetry-session-isolation
fix: isolate cursor telemetry samples per recording session
2026-04-28 08:08:08 -07:00
FabLrc f9401f051c fix(exporter): fall back to avc1.640033 for unsupported H.264 codec strings 2026-04-28 14:13:34 +02:00
FabLrc cae71ed49c fix(exporter): add codec normalization for bare avc1/h264 and logging 2026-04-28 14:08:01 +02:00
FabLrc 6577a54418 fix(exporter): normalize bare VP8/VP9 codec strings from web-demuxer 2026-04-28 13:59:10 +02:00
shaun0927 3b9b4192bf fix: key cursor telemetry batches by recordingId for safe discard
discardLatestPending() popped whichever batch happened to be at the
back of the queue. With a Stop → Record → Discard sequence, the
pending queue can have recording B's batch sitting in front of A's by
the time A's finalize callback resolves (because finalizeRecording
awaits fixWebmDuration), so the discard targets the wrong recording.

Tag each completed batch with the recording id supplied at
startSession() time and replace discardLatestPending() with
discardBatch(recordingId). takeNextBatch() now returns the full
{recordingId, samples} shape so prependBatch() can re-queue it on
write-failure without losing the id. The renderer already owns a
stable recordingId (Date.now() in useScreenRecorder) and the IPC
surface threads it through set-recording-state and
discard-cursor-telemetry.

Adds a regression test that mirrors FabLrc's scenario in PR #457:
two recordings finalize, A is discarded after B has already been
queued, and the buffer must drop A while keeping B intact.
2026-04-28 18:27:14 +09:00
Siddharth 1fefde8881 auto zoom marker 2026-04-26 17:25:20 -07:00
Siddharth 5e994d214e fix perf playback choppiness 2026-04-26 17:17:49 -07:00
Sid 49213960e2 Merge pull request #419 from rajtiwariee/fix/video-blur
fix: resolve blurry screen recordings and video editor previews
2026-04-25 16:52:48 -07:00
Siddharth 8458cbb40e fix: pass asset base URL to preload via additionalArguments
Sandboxed preloads (Electron's default with contextIsolation) cannot
require node modules. Commit 702b733 added node:path / node:url imports
to preload.ts which fail at load time:

  Unable to load preload script: dist-electron/preload.mjs
  Error: module not found: node:path

This left window.electronAPI undefined, breaking every IPC call.

Compute the asset base URL in main process (windows.ts) and pass it
to preload via webPreferences.additionalArguments. Preload reads it
from process.argv. Sync API for renderer is preserved.
2026-04-25 16:50:18 -07:00
Siddharth e1c67c4e92 Revert "Merge pull request #373 from Moncef-Mhz/adjust-zoom-speed"
This reverts commit a6ae0e6d98, reversing
changes made to db10f92c49.
2026-04-25 16:50:18 -07:00
Sid 92f0ed8efe Merge pull request #472 from ichi1007/feature/add-i18n-japanese-key
feat(i18n): add Japanese locale and update translations for existing locales
2026-04-25 16:21:24 -07:00
Sid 67e7048636 Merge pull request #480 from saiganesh47/patch-1
Remove unnecessary newline in i18n-check.mjs
2026-04-25 15:56:17 -07:00
Sid 13c982618a Merge pull request #491 from AmitwalaH/feature/video-playback-fix
Fix video playback initialization and zoom sync
2026-04-25 09:24:00 -07:00
AmitwalaH 657d55bd72 fix: rethrow play error so allowPlaybackRef resets on failure 2026-04-25 15:08:01 +05:30
Sid c53dd2df93 Merge pull request #496 from Enriquefft/fix/wallpaper-export-376
Fix wallpaper backgrounds exporting as black (#376)
2026-04-24 21:34:59 -07:00
Enriquefft e06e40dbc2 clean review nits: typed prefix sentinel, instanceof narrowing, drop dead re-export
- Replace anonymous Error in resolveImageWallpaperUrl with typed
  UnsafeImagePrefixError, mirroring UnsafeAssetPathError so cause
  chains stay discriminable.
- Replace `(err as BackgroundLoadError).cause` casts in wallpaper
  tests with instanceof narrowing (no `as` per project rules).
- Remove unused `WALLPAPER_PATHS` re-export from projectPersistence;
  consumers import directly from @/lib/wallpaper (SSOT).
2026-04-24 22:34:00 -05:00
Enriquefft 373319808e cover Windows drive-letter file URLs in legacy wallpaper normalizer test 2026-04-24 21:58:59 -05:00
Enriquefft af159e8a2b tighten legacy normalizer and guard against BackgroundLoadError double-wrap
Reviewer audit found two real risks in the prior amendment:

1. LEGACY_FILE_WALLPAPER_RE was too permissive. Any file:// URL
   containing /wallpapers/wallpaperN.jpg would match — including a user's
   own file at /home/me/wallpapers/wallpaper1.jpg that happened to share
   the name pattern. Silent data-loss potential: user's photo replaced
   with a bundled asset. In-app upload flow uses data: URIs today so it
   can't actually produce such a value, but the regex should be tight
   on intent. Now requires a known install-layout segment:
   resources/[assets/]wallpapers/ (packaged) or public/wallpapers/ (dev).

2. No upper bound on \d+. A corrupted or future-schema project with
   wallpaper99.jpg was silently rewritten to /wallpapers/wallpaper99.jpg
   which 404s. Now validates against WALLPAPER_PATHS; out-of-set
   bundled-looking values fall back to DEFAULT_WALLPAPER.

Also applied R2.2 defensive guard: resolveImageWallpaperUrl's catch
block now checks instanceof BackgroundLoadError and rethrows unchanged
instead of wrapping a second time. Current getAssetPath cannot throw
BackgroundLoadError so this is a future-proof against refactors.

Tests: 56 pass (up from 54). Added coverage for "user file outside
install dir stays untouched" and "bundled-looking but out-of-set falls
back to default".
2026-04-24 18:58:34 -05:00
Enriquefft f2ff7fb21c address review audit: persist canonical wallpaper, dedupe types, tighten edge cases
R1 — Persisted wallpaper is now always the canonical /wallpapers/wallpaperN.jpg
form, never the resolved file:// URL. Swatch clicks pass WALLPAPER_PATHS[i]
(the relative path) to onWallpaperChange; the resolved URL stays in
wallpaperPreviewUrls for rendering only. This prevents machine-specific paths
from being written into project JSON and avoids break-on-upgrade /
break-on-share regressions. Legacy projects carrying resolved file:// URLs are
rewritten by a new normalizer in normalizeProjectEditor:
file://…(/assets)?/wallpapers/wallpaperN.jpg → /wallpapers/wallpaperN.jpg.

R2 — resolveImageWallpaperUrl now catches anything getAssetPath throws
(UnsafeAssetPathError, AssetBaseUnavailableError) and rewraps as
BackgroundLoadError with the original as cause. Callers (videoExporter retry
loop, gifExporter catch, VideoEditor toast) only need one instanceof check and
users always see the translated errors.exportBackgroundLoadFailed toast.

R3 — src/vite-env.d.ts no longer duplicates Window.electronAPI. The interface
had drifted — renderer declaration was missing readBinaryFile, getPlatform,
revealInFolder, getShortcuts, saveShortcuts, hudOverlay*, countdown overlay
methods that electron-env.d.ts already declares. Removed the duplicate and
kept the triple-slash reference so the authoritative declaration is the one
in electron/electron-env.d.ts.

N1 — GRADIENT_RE accepts optional "repeating-" prefix so
repeating-linear/radial/conic-gradient values classify as gradients instead
of falling through to color.

N2 — displayBasename returns "(unknown)" sentinel for URLs without a
meaningful basename (file:///, bare /) instead of leaking the original string.

N3 — electron-builder.json5 extraResources block gets an inline comment
pointing at preload.ts:assetBaseDir so the bidirectional coupling is
discoverable from either file.

Tests: 54 unit tests pass (up from 35). New coverage for repeating
gradients, displayBasename sentinels, BackgroundLoadError cause wrapping,
legacy file:// wallpaper normalization (5 cases).
2026-04-24 18:55:04 -05:00
Enriquefft 702b733074 resolve asset base path synchronously from preload
Every consumer of /wallpapers/*.jpg — SettingsPanel, VideoPlayback,
frameRenderer — was doing async IPC round trips, useEffect dances, and
Promise.all for a value that is a build-time constant per process. Each
consumer showed briefly-empty or briefly-404ing state on first paint
until the handler's reply resolved.

The asset base URL depends only on process.defaultApp and
process.resourcesPath / __dirname — all available in preload at
context-bridge time. Compute once there, expose as a sync string.

- preload.ts resolves baseDir (process.resourcesPath packaged,
  <appRoot>/public unpackaged) and emits assetBaseUrl synchronously.
- get-asset-base-path IPC handler + main-process branching deleted.
- getAssetPath() is now sync. Returns string, not Promise<string>.
  Throws AssetBaseUnavailableError (new) when electronAPI.assetBaseUrl
  is missing — catastrophic preload failure, not silent 404.
- resolveImageWallpaperUrl() sync; same sync throw semantics.
- SettingsPanel: Promise.all + useState + useEffect collapse to one
  useMemo. First paint has real URLs, no 18× ERR_FILE_NOT_FOUND, no
  flicker.
- VideoPlayback: wallpaper-resolve useEffect collapses to useMemo.
- frameRenderer.setupBackground: drops the await.
- electronAPI type decls updated in both .d.ts files.
- 35 unit tests updated to reflect sync signature + new
  AssetBaseUnavailableError contract.

Silent-fallback behavior from getAssetPath (returning /relative when
electronAPI failed) is gone. Renderers now surface preload failures
instead of rendering 404s.
2026-04-24 18:33:03 -05:00
Enriquefft 86c1c483d4 avoid 404s on first swatch render
SettingsPanel fell back to rendering WALLPAPER_PATHS (raw
/wallpapers/*.jpg strings) during the brief window before the
resolveImageWallpaperUrl effect populated wallpaperPaths. In packaged
Electron the browser resolved those against a file:// origin, producing
18 ERR_FILE_NOT_FOUND requests per load / reload. The second render
replaced them with correct URLs, so swatches appeared — but the wasted
requests showed up in devtools and churned the network panel.

Drop the fallback; render nothing until the effect completes. The
resolution is effectively instant and avoids the empty-origin round
trip.
2026-04-24 18:22:27 -05:00
Enriquefft adf3855ac8 harden wallpaper resolver against traversal, PII, and SSOT drift
Adversarial review surfaced four defects and four drive-bys. All applied:

B1 (security, MEDIUM) — Path traversal via encodeRelativeAssetPath.
encodeURIComponent passed "." and ".." through unchanged; percent-encoded
"%2e%2e" got decoded by the URL constructor. Either form escaped the
asset root: new URL("../../etc/passwd", "file:///opt/Openscreen/resources/")
→ file:///opt/etc/passwd. Reject both at src/lib/assetPath.ts via a new
UnsafeAssetPathError thrown when a decoded segment equals "." or "..".

B2 (correctness) — classifyWallpaper returned { kind: "image" } for
conic-gradient(...), rgb(...), hsl(...), oklch(...), empty string,
and named colors like "red". Old frameRenderer's bare fillStyle = value
handled these; new code would throw BackgroundLoadError with misleading
message. Classification now anchors on regexes, accepts all CSS color
functions and all three gradient types, treats unknown strings as
fallthrough color (old behavior), and normalizes "" to "#000000".

B3 (SSOT) — DEFAULT_WALLPAPER, projectPersistence.WALLPAPER_PATHS, and
SettingsPanel.WALLPAPER_RELATIVE independently hardcoded the same
/wallpapers/wallpaperN.jpg pattern. Three drift sites collapse into one:
WALLPAPER_PATHS lives in src/lib/wallpaper.ts, DEFAULT_WALLPAPER derives
from WALLPAPER_PATHS[0], projectPersistence re-exports from the canonical
module, SettingsPanel imports it directly.

B4 (privacy) — BackgroundLoadError.message and the translated toast
surfaced full file paths like file:///home/<user>/…/wallpaper.jpg —
leaks the user's home directory in copy-pasted bug reports. Added a
displayUrl getter that returns just the basename (or "data:…" for data
URIs), wired into the toast. Full URL remains in console.error and
error.url for debugging.

N1 — resolveImageWallpaperUrl now rejects image paths that don't live
under /wallpapers/ (throws BackgroundLoadError). Narrows the blast
radius of the returned <resourcesPath>/ base so the renderer can only
request files within the wallpapers directory, regardless of what the
project JSON claims.

N2 — videoExporter retry loop no longer calls cleanup() twice in the
BackgroundLoadError branch; the finally handles it.

N3 — Browser tests assert BackgroundLoadError.url contains the failing
path. Guards the {{url}} i18n interpolation contract.

N4 — VideoPlayback wallpaper resolve effect now catches resolver
throws (UnsafeAssetPathError, BackgroundLoadError from /wallpapers/
prefix enforcement). Prevents the new strict-rejection logic from
silently leaving the preview without a background.

Tests: 35 unit tests pass (up from 20); new coverage for all color
functions, all gradient types, empty string, named color fallback,
whitespace trimming, /wallpapers/ prefix enforcement, traversal
rejection, percent-encoded traversal rejection, displayUrl basename
and data-URI abbreviation.
2026-04-24 18:16:57 -05:00
Enriquefft d145f80041 fix: wallpaper backgrounds black in exported video (#376)
Three independent defects plus one SSOT violation caused reported symptom
of image wallpapers rendering solid black in exported MP4/GIF while
appearing correctly in the editor preview.

Bug A — Dev-mode IPC handler returned <appPath>/public/assets/, but
wallpapers live at public/wallpapers/. No assets/ subdirectory exists in
source.

Bug B — FrameRenderer.setupBackground bypassed getAssetPath and did
window.location.origin + wallpaper, producing file:///wallpapers/*.jpg
404s in packaged Electron.

Bug C — setupBackground silently caught any background-load error and
filled black. Masked Bug B from the export pipeline; why the bug shipped.

Smell D — Asset layout asymmetric: public/wallpapers/ (dev) vs
resources/assets/wallpapers/ (packaged). assets/ subdirectory had no
other consumers.

Fixes:

- Unify asset layout. electron-builder extraResources now copies to
  resources/wallpapers/ (no assets/). Main handler returns
  <resourcesPath>/ packaged and <appPath>/public/ unpackaged. Same
  convention in both modes: /wallpapers/x.jpg maps to <base>/wallpapers/x.jpg.
  Nix package.nix mirror updated.

- New src/lib/wallpaper.ts module owns the wallpaper contract:
  DEFAULT_WALLPAPER, classifyWallpaper (color/gradient/image), and
  resolveImageWallpaperUrl (pure URL resolver, wraps getAssetPath).
  BackgroundLoadError typed error for short-circuit detection.

- FrameRenderer.setupBackground uses the new helpers. Silent black
  fallback removed; rethrows as BackgroundLoadError. Export pipeline
  (VideoExporter + GifExporter) short-circuits encoder-retry loop on
  BackgroundLoadError. VideoEditor catch site dispatches to translated
  exportBackgroundLoadFailed toast.

- VideoPlayback editor preview consolidated onto the same helpers.
  Three default-wallpaper path literals (useEditorHistory,
  projectPersistence, VideoPlayback) collapsed onto DEFAULT_WALLPAPER.

- i18n: new errors.exportBackgroundLoadFailed key added to all seven
  locales (en, zh-CN, zh-TW, es, fr, tr, ko-KR).

- Tests: 20 unit tests for wallpaper module (classifyWallpaper +
  resolveImageWallpaperUrl branches + BackgroundLoadError).
  videoExporter.browser.test.ts and gifExporter.browser.test.ts extended
  with image-wallpaper happy path and BackgroundLoadError failure path.

Migration note: packaged users upgrading in place may retain an empty
resources/assets/ directory from the prior layout. Unreferenced at
runtime; cosmetic only. DMG/AppImage fresh installs get the new layout
directly.
2026-04-24 17:59:21 -05:00
AmitwalaH 466fad399a fix: seed overlaySize on mount and guard ResizeObserver entries 2026-04-24 15:28:53 +05:30
AmitwalaH 1673daabe4 merge: resolve conflicts and update video playback system 2026-04-24 15:07:05 +05:30
Marc Diaz cffca5f2ff fix: just use one test 2026-04-23 17:37:08 -04:00
Marc Diaz d1087af63c fix: lint 2026-04-23 15:46:35 -04:00
Raj Tiwari 8e1c7e035a fix: correct motion blur state caching logic 2026-04-23 23:02:04 +05:30
Raj Tiwari a26eb3cbab perf: cache motion blur state in ticker 2026-04-23 22:55:40 +05:30
Raj Tiwari f4e10b28cc style: fix linting errors for biome check 2026-04-23 22:50:21 +05:30
AmitwalaH 9a361a9f2e fix(video-playback): resolve initialization timing issues and ensure smooth zoom & layout rendering 2026-04-23 15:10:59 +05:30
Sid 67ec57751f Merge pull request #390 from FabLrc/update-french-translation
fix(i18n): Update French translations for dialogs, editor, and settings
2026-04-22 20:56:05 -07:00
Sid 0264d8cb9e Merge pull request #482 from FabLrc/chore/update-dependencies-security-2026-04
Update dependencies and resolve vite compatibility issues
2026-04-22 20:54:45 -07:00
Sid fafe8ff82d Merge pull request #486 from FabLrc/update-readme
doc: Update README
2026-04-22 07:30:14 -07:00
FabLrc d59ef6a8dd Update README with additional badges for Trendshift (top repository of the day) and Discord badge update 2026-04-22 16:06:25 +02:00
FabLrc d823f3f011 Add Star History section to README 2026-04-22 12:27:15 +02:00
psychosomat d6d872e529 Fix CodeRabbit review comments
- Add buildDialogOptions helper function to safely attach parent window only when valid and not destroyed
- Update all dialog calls (save-exported-video, open-video-file-picker, save-project-file, load-project-file) to use the helper
- Fix supportsWindowOpacity logic by removing || isWayland so Linux always follows no-opacity codepath
- Change incorrect Chromium feature name 'PipeWire' to 'WebRTCPipeWireCapturer' in main.ts
- Remove unused isWayland variable in handlers.ts
2026-04-22 02:23:31 +03:00
psychosomat 31f0483c65 Improve Arch Linux support and fix video export on Hyprland
- Add pacman package build target for Arch Linux in electron-builder.json5
- Update build:linux script in package.json to include pacman target
- Fix dialog window issues on Wayland/Hyprland:
  * Pass mainWindow reference to dialog.showSaveDialog and dialog.showOpenDialog in electron/ipc/handlers.ts
  * Required for proper dialog functionality on Wayland compositors
  * Previously dialogs opened without parent window attachment causing issues on Hyprland

Changes ensure:
- Correct video export on Arch Linux + Hyprland systems
- Ability to install via pacman package manager
- Improved compatibility with Wayland compositors
2026-04-22 02:01:20 +03:00
FabLrc 9613e714e1 chore: align @types/node with engine and fix package-lock.json cross-platform resolution 2026-04-21 15:06:57 +02:00
FabLrc 7573d8822c fix: regenerate pack-lock.json 2026-04-21 15:00:16 +02:00
FabLrc 659affa88c fix: upgrade vite to 7.x to resolve lockfile/platform issues
vitest@4.1.4 requires vite ^6||^7||^8. With vite@6 at project level,
npm@10 installs a separate vite@8 for vitest, which pulls in rolldown
(native .node bindings) that npm ci cannot install cross-platform due
to npm bug #4828.

vite@7 avoids rolldown entirely (uses rollup) and npm@10 deduplicates
correctly with the project-level vite@7. Also adds esbuild@^0.27.0
explicitly (required by vite-plugin-electron-renderer) and aligns with
vite@7's own esbuild@^0.27.0 so no duplicate installs.

- vite: ^6.4.2 → ^7.3.2
- @vitejs/plugin-react: ^4.7.0 → ^5.2.0 (adds vite@7/8 support)
- esbuild: ^0.27.0 added explicitly
- vite.config.ts: manualChunks converted to function form (rollup compat)
2026-04-21 14:34:09 +02:00
Sai Ganesh Maganti f60a11820e Remove unnecessary newline in i18n-check.mjs 2026-04-21 18:01:59 +05:30
FabLrc b472c768ce style: migrate biome config to 2.4.12 and fix formatting (CRLF → LF) 2026-04-21 14:11:31 +02:00
FabLrc 018ba08eb9 fix(security): remove unused electron-icon-builder and electron-rebuild
Both packages were listed as devDependencies but not referenced in any
scripts or source files. Removing them eliminates all 22 npm audit
vulnerabilities (2 critical, 5 high, 13 moderate, 2 low) introduced by
their unmaintained transitive dependency chain (phantomjs-prebuilt,
request, tar, etc.).
2026-04-21 14:07:23 +02:00
FabLrc 41a26f3e66 fix: upgrade vite to 6.x to satisfy vitest 4.x peer dependency
vitest ^4.1.4 requires vite ^6+, which conflicted with the pinned
vite 5.4.21 and caused npm ci to fail with an inconsistent lockfile.
Also bumps vite-plugin-electron to 0.29.1.
2026-04-21 14:06:59 +02:00
FabLrc 9d365ca406 fix: Update French translations for editor, launch, and settings 2026-04-21 12:48:29 +02:00
FabLrc a1762b2691 Update French translations for dialogs, editor, and settings 2026-04-21 12:33:39 +02:00
FabLrc 9e345660e6 chore: update dependencies to latest versions 2026-04-21 12:27:13 +02:00
shaun0927 96765e483d docs: correct cx/cy units and sanitize buffer option limits
Two follow-up fixes for CodeRabbit feedback on the docs commit:

- CursorTelemetryPoint JSDoc previously described cx/cy as 'device-pixel
  positions'. The producer sampleCursorPoint() in electron/ipc/handlers.ts
  clamps them to the [0, 1] range after dividing by the source display's
  width/height, so they are normalised ratios, not pixel values. Correct
  the doc comment accordingly.
- createCursorTelemetryBuffer now sanitizes maxActiveSamples and
  maxPendingBatches: non-finite, zero, or negative values fall back to
  safe positive-integer defaults. Without this, a caller passing Infinity
  or NaN would hang the trim loops.

New test covers the sanitisation path for both options.
2026-04-21 18:12:28 +09:00
shaun0927 adc610544c docs: document cursor telemetry buffer API and surface drop events
Add JSDoc to every public export in cursorTelemetryBuffer so the module
meets the 80% docstring-coverage threshold, and make two silent-drop
paths observable:

- endSession() now returns the number of pending batches evicted by the
  maxPendingBatches cap and emits console.warn when any are dropped.
- prependBatch() defensively trims and warns if an unusual retry pattern
  would push the queue past the cap (normal retry after takeNextBatch()
  stays a no-op).

Tests cover both drop paths.
2026-04-21 17:07:19 +09:00
Marc Diaz 95c7b7fc2b fix: add webm inflated duration and fix 2026-04-20 23:11:58 -04:00
Sid cccb966fda Merge pull request #460 from Galactic99/feat/countdown-before-record-start
feat:add countdown before record start
2026-04-20 08:25:30 -07:00
Aaryash Khalkar c033984ccb Merge branch 'main' into feat/countdown-before-record-start 2026-04-20 20:52:29 +05:30
Sid ae6b6ca860 Merge pull request #357 from imAaryash/main
Update LaunchWindow.tsx
2026-04-20 08:12:14 -07:00
Sid 1f99fcb4ad Merge pull request #325 from dheerajmr01/fix/camera-bugs
fix: camera light flashes and turns off when clicking webcam button (…
2026-04-20 08:10:37 -07:00
Fabien LaurenceandCopilot 0bb14f3a33 Update src/components/launch/LaunchWindow.tsx
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-20 17:07:17 +02:00
Fabien Laurence 97fdefa433 Merge branch 'main' into main 2026-04-20 17:05:46 +02:00
Sid 2b1c93146d Merge pull request #471 from tmchow/fix/449-cjk-text-wrap
fix(annotations): wrap CJK text at character boundaries in export renderer
2026-04-19 11:53:50 -07:00
Trevin Chow dd622f83c1 fix(annotations): use Unicode script properties for CJK detection
Address review feedback on #471 from @coderabbitai. The BMP-only
codepoint ranges missed two classes of characters:

- Non-BMP Han extensions (CJK Unified Ideographs Extension B, C, D, E, F)
  such as 𠀀. A long string of Extension-B characters would still be
  tokenized as a single unbreakable unit and overflow the box.
- Halfwidth Katakana (U+FF65-U+FF9F) such as カ. Same failure mode.

Switch to Unicode script property escapes (\\p{Script=Han},
\\p{Script=Hiragana}, \\p{Script=Katakana}, \\p{Script=Hangul}) which
cover these cases without enumerating ranges. tsconfig target is ES2020;
property escapes require ES2018+ so this is safe.

Verified coverage: 漢 あ ア 가 𠀀 カ all match; A and digits do not.
2026-04-19 10:05:48 -07:00
ichi d6bf31cb3f feat(i18n): add Japanese locale and update translations for existing locales
- Added Japanese (ja-JP) translations for common, editor, dialogs, launch, settings, shortcuts, and timeline.
- Updated translations for existing locales (en, es, fr, ko-KR, tr, zh-CN, zh-TW) to include new keys for "showInFolder", "loadingVideo", "trim", and "speed".
- Refactored VideoEditor and timeline Item components to utilize localized strings for various user interface elements and notifications.
- Enhanced user experience by providing localized messages for project loading, exporting, and timeline actions.
2026-04-19 20:44:07 +09:00
Trevin Chow f04c2b7c14 fix(annotations): wrap CJK text at character boundaries in export renderer
renderText split each line on whitespace, which works for Latin text
but leaves CJK strings as a single unbreakable token because CJK
scripts have no word-separating whitespace. Result: CJK annotation
text overflows the clipped annotation box even though the editor's
HTML preview wraps it correctly via CSS word-break: break-word.

Replace the ad-hoc whitespace split with a tokenizeForWrap helper
that emits each CJK character (Hiragana, Katakana, Hangul Syllables,
CJK Unified Ideographs + Extension A, and CJK Compatibility
Ideographs) as its own token, while keeping Latin words + whitespace
intact. The existing width-measurement wrap loop then handles CJK
per-character, matching the editor's behavior.

Closes #449
2026-04-19 02:49:17 -07:00
Galactic99 4a65ab8171 chore:safewrapper consistency and hide countdown overlay before starting recording setup. 2026-04-19 12:57:17 +05:30
Galactic99 7e02856836 fix:hide handler actually hides window instead of just clearing value 2026-04-19 12:37:19 +05:30
Galactic99 65b9d189e8 fix:improve ui of the countdown by adding a low opacity circle background 2026-04-19 12:37:19 +05:30
Galactic99 3ba9e901c9 fix:Claim the countdown run before the first await. 2026-04-19 12:37:18 +05:30
Galactic99 331e126d3c fix:handle hideCountdownOverlay rejections in cleanup/cancel paths. 2026-04-19 12:37:18 +05:30
Galactic99 d04bab732b prioritize recording stop over countdown cancel 2026-04-19 12:37:18 +05:30
Galactic99 ea68e4cfc3 fix:prevent stale countdown IPC updates from repainting overlay 2026-04-19 12:37:18 +05:30
Galactic99 6b08a0a72a fix:flickering, stale runs, macOS bugs provided by coderabbit and thread countdown token 2026-04-19 12:37:17 +05:30
Galactic99 1670db41a8 feat:add countdown before record start 2026-04-19 12:37:17 +05:30
Sid fd6a0778fb Merge pull request #469 from imAaryash/feat/discord-actions
Improve Discord API error handling and webhook checks
2026-04-18 17:52:22 -07:00
Aaryash Khalkar cfc6579e37 Improve Discord API error handling and webhook checks
Refactor error handling for Discord API responses and improve webhook secret checks.
2026-04-19 06:19:05 +05:30
Siddharth 10463f882f rm 2026-04-18 17:46:46 -07:00
Sid 3e436087b7 Merge pull request #467 from imAaryash/feat/discord-actions
updated discord workflow
2026-04-18 17:43:06 -07:00
Aaryash Khalkar 63c850bc08 Change pull_request to pull_request_target in workflow 2026-04-19 05:47:52 +05:30
Siddharth dc74db13ad test 2026-04-18 11:36:59 -07:00
Siddharth 33eb245aea codeowner 2026-04-18 11:29:12 -07:00
Siddharth d22c4190cf fix 2026-04-18 11:05:33 -07:00
Sid 57c6a590a9 Merge pull request #423 from org-cyber/fix/windows-export-clean
fix(windows): Fixed windows Export Issue and early decode Crash
2026-04-18 10:54:13 -07:00
Sid 88ab1eabdd Merge pull request #401 from hobostay/fix/bug-fixes-security-and-reliability
Fix security and reliability issues
2026-04-18 10:50:18 -07:00
Sid a20a31f27d Merge branch 'main' into fix/bug-fixes-security-and-reliability 2026-04-18 10:50:05 -07:00
Sid 9ef1f756b4 Merge pull request #448 from theopfr/fix/cpu-readback-only-for-linux
fix: improve performance on windows and macos by passing canvas direclty to `VideoFrame()`
2026-04-18 10:49:09 -07:00
Sid b0529c87a6 Merge pull request #450 from michthemaker/feat/hud-overlay-ux-overhaul
Feat/hud overlay ux overhaul
2026-04-18 10:47:01 -07:00
Sid 974fde4f1d Merge pull request #344 from ekkoitac/fix/tutorial-help-missing-translations
Fix/tutorial help missing translations
2026-04-18 10:44:04 -07:00
Sid e7247d880d Merge pull request #434 from Enriquefft/fix/export-audio-duration-validation
fix: validate export duration and fix audio trim in speed-aware path
2026-04-18 10:41:38 -07:00
Sid 56d3d59598 Merge pull request #342 from kuishou68/cocoon/feature-duplicate-annotation
feat(editor): duplicate annotations
2026-04-18 10:39:32 -07:00
Sid 0ec18358d5 Merge branch 'main' into cocoon/feature-duplicate-annotation 2026-04-18 10:37:56 -07:00
Sid e85d07ba78 Merge pull request #461 from imAaryash/discord-actions
Refactor Discord webhook URL handling in workflow
2026-04-18 10:29:02 -07:00
Test UserandClaude Opus 4.7 721e8f4759 Fix lint, type check errors, and apply CodeRabbit review feedback
- Remove trailing comma in SUPPORTED_LOCALES that caused Locale type to
  include undefined, fixing all downstream type errors
- Remove unused webcamSizePreset from useMemo dependency array
- Use parsed.toString() instead of raw url in shell.openExternal per
  Electron security best practice

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-18 21:37:16 +08:00
Theodor Peifer 9e4ec790f3 chore: fix linting issue 2026-04-18 11:32:42 +02:00
Theodor Peifer 2f24038cb5 fix: use existing getPlatform() so the OS based CPU readback check also works in the browser 2026-04-18 11:31:09 +02:00
Theodor Peifer 934f05cc80 fix: pass platform from video/gifExporter to FrameRenderer, skip readback also for canvas composition for non-linux 2026-04-18 11:31:09 +02:00
Theodor Peifer d12f3980f9 fix: only read back frames from canvas if the OS is linux, work around not necessary for other OS' line win or darwin 2026-04-18 11:31:09 +02:00
ekkoitac 485c95b672 resolve conflicts: adopt main's tutorial i18n restructuring
The main branch has already applied the same tutorial.help key
restructuring with slightly different intermediate values.
Adopting main's version to resolve merge conflicts.
2026-04-18 08:52:26 +08:00
Enriquefft dd8c001f6d refactor: require validatedDurationSec in AudioProcessor, drop fallbacks
AudioProcessor.process and renderPitchPreservedTimelineAudio accepted
validatedDurationSec as optional, so the speed-aware path fell back to
media.duration when it was absent. HTMLMediaElement.duration can be
Infinity for the same MediaRecorder/Chromium Linux containers this PR
targets, which would make effectiveEnd and the playback stop checks
unreliable.

The only caller (VideoExporter.process) already threads
streamingDecoder's validatedDuration through, so make the parameter
required. Drop the media.duration fallback, the Number.isFinite guard
on readEndSec, and the two `!== undefined` checks in the tick loop.

While here:
- Document that +0.5 on readEndSec mirrors streamingDecoder.decodeAll's
  read window so trim-only and speed-aware paths stay in sync.
- Replace the unreachable silent-blob fallback at the end of
  renderPitchPreservedTimelineAudio with a loud invariant throw, so a
  broken recorder contract surfaces instead of yielding empty audio.
2026-04-16 14:49:27 -05:00
Enriquefft 0c01db7afa fix: fall back to unbounded packet scan when duration hints missing
The earlier NaN/Infinity guard collapsed both duration hints to 0 when
the container reported invalid values, which turned scanEndSec into
0.5s. The packet scan then read only the first half-second, scannedDuration
capped there, and validateDuration fell back to that wrong value for the
entire export — exactly the Chromium Linux case this PR is meant to fix.

Use a 24h sentinel as the read endpoint when no hint is usable. An
explicit end is still required (some containers are truncated without
one, per prior comment), but the sentinel is large enough to exceed any
realistic recording so the scan reaches real EOF.
2026-04-16 14:33:27 -05:00
Enriquefft 4d4b08db07 fix: skip chained initial trims before recording starts
Startup trim-skip only consulted the first active region at t=0, so
back-to-back or overlapping trims starting at zero (e.g. [0,500ms]
followed by [500ms,1000ms]) left the second region un-skipped. The
in-flight tick loop would catch it, but MediaRecorder was already
running by then, capturing up to one rAF frame of trimmed audio into
the blob and shifting the downstream timeline.

Loop findActiveTrimRegion from the advancing startPosition until no
region matches or startPosition >= effectiveEnd, bounded by
trimRegions.length for safety. Recompute initialSpeedRegion from the
final startPosition so playbackRate reflects the true start point.
2026-04-16 14:31:51 -05:00
Enriquefft 61e895a75a fix: sanitize packet-scan range against NaN/Infinity duration
mediaInfo.duration from web-demuxer can be NaN or Infinity on Chromium
Linux (same MediaRecorder bug this PR otherwise addresses). That value
flowed straight into Math.max + demuxer.read() as scanEndSec, producing
an invalid range argument and breaking the ground-truth packet scan.

Guard both mediaInfo.duration and videoStream.duration with
Number.isFinite before Math.max; validateDuration() already handled the
downstream use.

Drop redundant WebDemuxer.read() / getDecoderConfig() type casts while
here — the generics infer the chunk/config type from the media string
literal, so the `as ReadableStream<EncodedVideoChunk>` and
`as AudioDecoderConfig` are no-ops.
2026-04-16 14:18:40 -05:00
Enriquefft 83ea025ed8 fix: handle NaN in zero-scan fallback and symmetric divergence check
- validateDuration returns 0 instead of NaN when both container is
  NaN and scanned is zero
- Use Math.abs for divergence check so container under-reporting is
  also corrected (not just over-reporting)
2026-04-16 13:50:09 -05:00
Enriquefft 337838294d fix: pass explicit range to packet scan read
Some containers are truncated when read() has no end bound.
Use container/stream duration + buffer as scan range, matching
the same pattern used in decodeAll().
2026-04-16 13:50:09 -05:00
Enriquefft 5e62ad3215 fix: validate export duration and fix audio trim in speed-aware path
Two bugs in the export pipeline:

1. Container duration from WebM metadata can be unreliable (Chromium bug
   on Linux — reports Infinity, 0, or inflated values). The pipeline
   trusted this value, causing inflated exports, frozen video, and
   "decode ended early" errors.

   Fix: scan actual packet timestamps in loadMetadata() and compare
   against container duration. Use packet-based ground truth when they
   diverge.

2. The speed-aware audio path (renderPitchPreservedTimelineAudio)
   recorded in real-time via MediaRecorder but never paused recording
   during trim-region seeks. Seek dead time was captured as audio,
   inflating the audio track beyond the video duration.

   Fix: pause MediaRecorder during trim seeks, skip past initial trim
   before recording starts, wait for seek completion before resuming.

Fixes #276, #433. Partially addresses #428.
2026-04-16 13:50:09 -05:00
Aaryash Khalkar 7264b9989e Refactor Discord webhook URL handling in workflow
Updated Discord webhook handling to allow for a fallback to DISCORD_PR_FORUM_WEBHOOK if DISCORD_WEBHOOK_URL is not set. Added checks to ensure webhook URL is provided, especially for fork PR events.
2026-04-16 17:16:56 +05:30
Cocoon-Break 501c4f20a1 fix: remove unused COMPARE_LOCALES variable in i18n-check.mjs to pass Biome lint 2026-04-16 17:29:05 +08:00
Cocoon-Break 64e011f798 style: wrap long onDuplicate prop to fix Biome formatter 2026-04-16 17:01:02 +08:00
Cocoon-Break 8b7047365c style: sort lucide-react imports alphabetically to fix Biome lint 2026-04-16 17:00:48 +08:00
Azeru 5caee9bc2d chore(merge): resolve merge conflict in streamingDecoder.ts
Address merge conflict markers added during resolution of Windows export fixes, ensuring clean integration of decode termination logic updates.
2026-04-16 09:51:26 +01:00
Charles Ikechukwu 61b3182f87 Merge branch 'main' into feat/hud-overlay-ux-overhaul 2026-04-16 09:43:08 +01:00
themaker cb44dec81e Merge branch 'feat/hud-overlay-ux-overhaul' of https://github.com/michthemaker/openscreen into feat/hud-overlay-ux-overhaul 2026-04-16 09:41:01 +01:00
themaker 17bed0956d fix(package-lock.json): update package-lock.json to resolve dependencies mismatch 2026-04-16 09:40:31 +01:00
Sid 6d449a46c4 Merge pull request #362 from imAaryash/detect-system-lang
feat(launch): refine recording HUD and language switching UX
2026-04-15 23:10:47 -07:00
Sid e2c4f3f62a Merge pull request #414 from theopfr/fix/correct-frame-count
fix: export frame counter exceeding total frames
2026-04-15 23:06:37 -07:00
Sid ff52e55fa1 Merge branch 'main' into detect-system-lang 2026-04-15 23:02:34 -07:00
Sid 8aa85413f9 Merge pull request #444 from AmitwalaH/fix/read-binary-error
fix: prevent crash in read-binary-file handler and improve error debugging
2026-04-15 23:01:21 -07:00
Sid 4f05cf572e Merge pull request #432 from Enriquefft/feature/nix-support
feat: add Nix flake with dev shell, package, and NixOS/Home Manager modules
2026-04-15 22:57:12 -07:00
Sid cefcf443e4 Merge pull request #436 from Dopiz/feat/i18n-zh-TW
feat(i18n): add zh-TW locale
2026-04-15 22:55:42 -07:00
Sid 20aecc13ae Merge pull request #431 from LorenzoLancia/feat/blur-mosaic-and-black
feat: add mosaic blur and black shading option
2026-04-15 22:51:47 -07:00
Sid 89fce713e5 Merge pull request #430 from SimulAffect/fix/tutorial-help-i18n
fix(i18n): sync tutorial help translations
2026-04-15 22:50:28 -07:00
Sid 847647d310 Merge pull request #421 from pufferfish3e/main
Add documentation section to README
2026-04-15 22:46:21 -07:00
Sid 97d1957b78 Merge pull request #452 from imAaryash/discord-actions
added discord.yaml
2026-04-15 22:13:51 -07:00
JunghwanNAandClaude Opus 4.6 fac0b405d3 fix: handle recording discard and write-failure in cursor telemetry buffer
Address two issues raised during review:

P1 – When a recording is cancelled or restarted, setRecordingState(false)
enqueues its cursor batch but store-recorded-session is never called,
leaving a stale batch that contaminates the next recording's telemetry.
Add discardLatestPending() to the buffer and a discard-cursor-telemetry
IPC handler; the renderer now calls it on the discard path.

P2 – takeNextBatch() dequeued the batch before fs.writeFile, so a write
failure would permanently lose the telemetry. Wrap the write in
try/catch and re-insert the batch via prependBatch() on failure.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 11:58:16 +09:00
Cocoon-Break 12f3be02f2 fix: sort lucide-react imports alphabetically
Signed-off-by: Cocoon-Break <54054995+kuishou68@users.noreply.github.com>
2026-04-16 09:31:37 +08:00
shaun0927 84ec5a7e68 fix: isolate cursor telemetry samples per recording session
Previously, the main process kept two module-scope arrays —
activeCursorSamples and pendingCursorSamples — and set-recording-state
on a new recording wiped BOTH. When a user stopped recording and
immediately started a new one before store-recorded-session fired,
the previous recording's pending samples were discarded or later
overwritten with the new session's data, producing empty or mismatched
.cursor.json files.

Replace the two arrays with a small FIFO buffer
(createCursorTelemetryBuffer) that:
- Keeps pending batches per completed recording, never wiping them on
  a new session start.
- Yields batches in arrival order to storeRecordedSessionFiles.
- Caps pending batches (default 8) so a never-stored sequence cannot
  leak unbounded memory.

Unit-tested directly in src/lib/cursorTelemetryBuffer.test.ts, including
the rapid-restart race that motivated the change.
2026-04-16 10:27:20 +09:00
imAaryash ee395b7896 added discord.yaml 2026-04-15 22:01:28 +05:30
Charles Ikechukwuandcoderabbitai[bot] 9998b43acc Update src/components/launch/SourceSelector.module.css
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-04-15 14:57:26 +01:00
themaker 1cdb8ed1cd feat(ui): add squircle corner shape to SourceSelector and polish sources spinner ui
Added corner-shape: squircle; to SourceSelector.module.css for more visually appealing rounded corners.

Customized windows source selector scrollbar to be more subtle but carry the product colour.

Removed box-shadow on SourceSelector because electron doesn't round corners of the shadow, thereby leaving a square border shadow conflicting with the rounded corners of the SourceSelector.
2026-04-15 14:25:30 +01:00
themaker 566830a866 feat(): changed .gitignore 2026-04-15 09:39:03 +01:00
themaker 143cd1e772 additions 2026-04-14 23:17:06 +01:00
Theodor Peifer 14bbe8f183 fix: algin frame cap with epsilon boundary to prevent frame count mismatch 2026-04-14 20:26:21 +02:00
themaker 5bcdf4c558 me and you 2026-04-14 15:58:52 +01:00
themaker df06369b75 me and you 2026-04-14 15:58:39 +01:00
AmitwalaH 6441e96035 fix: prevent crash in read-binary-file handler and improve error debugging 2026-04-14 12:45:02 +05:30
Theodor Peifer 46c611bd3f fix: include epsilon subtration in totalFrame calculation 2026-04-13 17:30:16 +02:00
Enriquefft d20a062150 fix(nix): handle store path sources for path: flake inputs
gitTracked uses builtins.fetchGit which fails when the source is
already a store path (happens with path: flake inputs from consuming
flakes). Detect store paths at eval time and fall back to cleanSource.
2026-04-13 06:17:07 -05:00
Dopiz 515baf1d84 feat: add zh-TW locale 2026-04-13 17:19:45 +08:00
Enriquefft f106cc6835 fix(nix): restrict package source to git-tracked files
Replace denylist approach with gitTracked to exclude node_modules,
dist, .git, and any other untracked artifacts from the derivation.
Keeps the nix/flake/md exclusions as they are nix-only or non-source.
2026-04-12 18:14:44 -05:00
Enriquefft 456816ab2e fix(nix): correct Electron binary path to libexec/electron
Electron 41.x in nixpkgs places the binary at libexec/electron/,
not lib/electron/. Without this fix, npm run dev fails with ENOENT.
2026-04-12 17:55:43 -05:00
Enriquefft 64cdc0dd3c feat: add Nix flake with dev shell, package, and NixOS/Home Manager modules
Reproducible development environment for NixOS/Nix contributors:
- Dev shell with Node 22, system Electron, Playwright, LD_LIBRARY_PATH
  for X11/Wayland/audio libs, activated automatically via direnv
- buildNpmPackage derivation wrapping system Electron with desktop file
  and hicolor icons
- NixOS module (programs.openscreen.enable) with xdg-desktop-portal
- Home Manager module for per-user installation
- Overlay for composing with other flakes

Tested: nix flake show, nix develop, nix build, nixos-rebuild switch
2026-04-12 13:33:13 -05:00
LorenzoLancia 8bcce473d5 feat: add mosaic blur with black shading 2026-04-12 18:04:43 +02:00
SimulAffect 0efd2d64ed fix(i18n): sync tutorial help translations 2026-04-12 17:26:45 +08:00
Sid a6ae0e6d98 Merge pull request #373 from Moncef-Mhz/adjust-zoom-speed
feat: implement zoom speed
2026-04-11 20:23:10 -07:00
imAaryash d1c9555464 feat(i18n): auto-discover valid locales and harden language menu
- derive available locales from locale folders with required namespace validation

- exclude incomplete locales and report missing namespace files

- align system-language suggestion and selectors with discovered locales

- improve launch HUD language menu interaction, scrolling, and viewport clipping

- make i18n-check discover locale folders automatically
2026-04-12 05:13:31 +05:30
imAaryash 1ef30ff1c7 Merge branch 'detect-system-lang' of https://github.com/imAaryash/openscreen into detect-system-lang 2026-04-12 04:24:00 +05:30
imAaryash e96478e813 Revert "Merge pull request #365 from AmitwalaH/fix-tutorial-translations"
This reverts commit 5494acb5ba.
2026-04-12 04:23:41 +05:30
imAaryash 97fbb01801 fix(i18n): resolve prompt persistence and language menu behavior 2026-04-12 04:23:39 +05:30
imAaryash c9c2634db4 fix(launch): polish language menu behavior 2026-04-12 04:23:37 +05:30
imAaryash 0c627da22c feat(launch): refine recording HUD and language switching UX 2026-04-12 04:23:35 +05:30
moncef e8d6fe3d1b Merge branch 'main' into adjust-zoom-speed 2026-04-11 23:27:50 +01:00
Sid db10f92c49 Merge pull request #300 from samirpatil2000/main
feat: configure macOS hardened runtime, entitlements, and build envir…
2026-04-11 11:45:20 -07:00
Sid bbf75a27e7 Merge pull request #418 from Orchardxyz/fix/icon-size
fix: adjust icon size for macOS platform compatibility
2026-04-11 11:41:58 -07:00
Sid 5781be0ba1 Merge pull request #409 from Scottlexium/fix/hud-follows-spaces
fix: HUD overlay and source selector follow across macOS Spaces
2026-04-11 11:18:50 -07:00
Sid 26c243950a Merge pull request #407 from kwakseongjae/feat/i18n-ko-KR
feat(i18n): add Korean (ko-KR) localization
2026-04-11 11:15:23 -07:00
Sid 0e4fc249ce Merge pull request #392 from imAaryash/patch-1
Fix SUPPORTED_LOCALES array syntax
2026-04-11 10:59:55 -07:00
Sid 321c4983ca Merge pull request #316 from lueckpeter76-lgtm/revert-293-fix/restart-recording-windows
Revert "fix: prevent double-finalize race condition in restartRecording on Windos"
2026-04-11 10:45:38 -07:00
Sid d9114877ff Merge pull request #389 from richard950825-sys/fix/zh-CN-missing-newRecording-translation
fix(i18n): add missing zh-CN translation for newRecording dialog
2026-04-11 10:44:35 -07:00
Azeru e4d4ce284b fix(export): compute requiredEndSec for decode termination handling
Add requiredEndSec calculation to properly handle early decode termination by using the last segment's end time. This addresses issues with export processing on Windows platforms.
2026-04-11 18:35:00 +01:00
Siddharth b713b6a9e8 fix: zoom focus now matches indicator position including wallpaper edges 2026-04-11 10:26:26 -07:00
Siddharth 40028cfd55 feat: add dual frame webcam layout preset (#347) 2026-04-11 10:01:19 -07:00
Siddharth 7169e583c7 revert: undo local merge of PR #347 2026-04-11 09:58:15 -07:00
Azeru d40f40d69d fix(export): compute requiredEndSec for decode termination handling
Add requiredEndSec calculation to properly handle early decode termination by using the last segment's end time. This addresses issues with export processing on Windows platforms.
2026-04-11 17:55:05 +01:00
Siddharth de7518549c feat: add dual frame webcam layout preset (#347) 2026-04-11 09:54:30 -07:00
Azeru 05da56fdc8 fix(export): relax early decode termination on Windows
On Windows, tolerate small decode gaps (<=3 seconds) to work around driver quirks, allowing export to complete with available frames.
2026-04-11 17:45:23 +01:00
Shreyas b1a1f45e93 refactor: simplify dual frame preset normalization 2026-04-11 09:30:01 -07:00
Shreyas bce1957505 fix: clear webcam position for non-pip layouts 2026-04-11 09:30:00 -07:00
Azeru 08aff31351 fix(windows): normalize export save path and relax early decode end 2026-04-11 17:27:52 +01:00
Shreyas 24b4b4254a fix: normalize dual frame preset for portrait projects 2026-04-11 09:26:15 -07:00
Shreyas 16cba73cb2 fix: avoid double-scaling dual frame export radius 2026-04-11 09:26:15 -07:00
Shreyas c55f462f1c feat: add dual frame webcam layout preset 2026-04-11 09:20:34 -07:00
Orchard d526ab4cda fix(tray): standardize icon size to 16px on macOS 2026-04-11 22:21:22 +08:00
Kendrick 363683d288 Add documentation section to README
Added a documentation section with a link to OpenScreen Docs.
2026-04-11 21:57:46 +08:00
곽성재 71cdd5f0e0 Merge branch 'main' into feat/i18n-ko-KR 2026-04-11 20:55:36 +09:00
Raj Tiwari 90d04c734e fix(video): prioritize h264 codec and fix pixi render blur 2026-04-11 13:07:07 +05:30
Orchard 33a60fed8c fix(tray): adjust icon size for macOS platform compatibility 2026-04-11 10:39:55 +08:00
Theodor Peifer d21dd1cbf1 fix: export frame counter exceeding total frames 2026-04-10 22:24:37 +02:00
Sid 68295b21ec Merge pull request #394 from LorenzoLancia/feature/blur-selection
feat: add blur selection (rectangle, oval)
2026-04-10 07:10:29 -07:00
Scott Lexium 0bde359421 docs: add JSDoc comments to window factory functions 2026-04-10 12:28:47 +01:00
Scott Lexium e7d82e1478 fix: make HUD overlay and source selector follow across macOS Spaces
Both windows had alwaysOnTop but lacked setVisibleOnAllWorkspaces, so
they stayed pinned to the Space they were first opened on. Users moving
to a different virtual desktop would lose sight of the overlay.

Calls setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true })
on macOS only — no-op on Windows/Linux so cross-platform behaviour is
unchanged.
2026-04-10 12:13:54 +01:00
kwakseongjae d512f59826 feat(i18n): add Korean (ko-KR) localization
- Add complete Korean locale across all 7 i18n namespaces
- All translation keys match the English baseline 1:1
- Register ko-KR in SUPPORTED_LOCALES and i18n-check validation

Refs siddharthvaddem/openscreen#406
2026-04-10 16:11:23 +09:00
LorenzoLancia 3232918197 Add the Shortcut Blur 2026-04-09 21:51:27 +02:00
Test UserandClaude Opus 4.6 cf6dce552e Fix security and reliability issues
1. Validate URL scheme in open-external-url handler
   - Prevent opening file:// or other dangerous schemes via shell.openExternal
   - Only allow http:, https:, and mailto: protocols

2. Fix latest video detection using mtime instead of lexicographic sort
   - Lexicographic sort gives wrong results (e.g. recording-9 > recording-10)
   - Now sorts by file modification time for reliable latest-file detection

3. Add null guard for AudioData.format in cloneWithTimestamp
   - Replace non-null assertion (!) with proper validation
   - Throws descriptive error if format is unexpectedly null

4. Prevent encodeQueue counter underflow in VideoExporter
   - Use Math.max(0, ...) to prevent negative queue count

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-09 16:58:12 +08:00
moncef 8118a0cf89 Merge branch 'main' into adjust-zoom-speed 2026-04-08 22:10:55 +01:00
BaptisteAuscher 283fa406b2 langages : tr and fr 2026-04-08 23:00:33 +02:00
BaptisteAuscher 33c384a827 Merge branch 'main' of github.com:siddharthvaddem/openscreen into feature/color-wheel 2026-04-08 22:56:02 +02:00
BaptisteAuscher c3faca19fd small fix: color block handles transparent values 2026-04-08 22:45:27 +02:00
LorenzoLancia 38d72217c2 fix little things blur 2026-04-08 22:43:30 +02:00
BaptisteAuscher 765434b935 code rabbit 2026-04-08 22:23:52 +02:00
LorenzoLancia f6b7c463f0 Fix last issues 2026-04-08 22:21:19 +02:00
BaptisteAuscher 545c02b5bb handle transparent values for the color wheel 2026-04-08 22:04:19 +02:00
LorenzoLancia f8232d9c76 Fix some little issues 2026-04-08 21:36:53 +02:00
LorenzoLancia 5a9c85c345 Fix formatting and locale config 2026-04-08 20:26:16 +02:00
Lorenzo Lancia a4f1c6a2ee feat: add blur selection (rectangle, oval) 2026-04-08 16:42:12 +02:00
Aaryash Khalkar fdfeb51c00 Fix SUPPORTED_LOCALES array syntax 2026-04-08 19:19:08 +05:30
Sid 5494acb5ba Merge pull request #365 from AmitwalaH/fix-tutorial-translations
fix(i18n): add missing tutorial dialog translation keys
2026-04-08 18:55:21 +05:30
Richard 85bd215f1f fix(i18n): add missing zh-CN translation for newRecording dialog
The zh-CN locale was missing the 'newRecording' section in editor.json,
which is present in the en locale. This commit adds the translation for:
- title: 返回录屏
- description: 当前会话已保存。
- cancel: 取消
- confirm: 确认
2026-04-08 13:58:53 +08:00
Sid e7d5f51740 Merge pull request #345 from GarryLaly/feature/webcam-resize-slider
feat: Add webcam size with slider
2026-04-07 22:40:15 -07:00
Sid 9e6b05815f Merge pull request #375 from mehmetnadir/feat/turkish-locale
feat(i18n): add Turkish (tr) locale support
2026-04-07 22:30:30 -07:00
Sid 7bd993a97b Merge branch 'main' into feat/turkish-locale 2026-04-07 22:30:16 -07:00
Sid 558379702a Merge pull request #330 from maxbailey/main
fix: resolve green MP4 exports on CachyOS/Arch Linux (Wayland)
2026-04-07 22:28:00 -07:00
Sid 09b99563f5 Merge pull request #380 from FabLrc/french-traduction
feat(i18n): add French translations
2026-04-07 22:21:34 -07:00
Sid b34961f6af Merge pull request #365 from AmitwalaH/fix-tutorial-translations
fix(i18n): add missing tutorial dialog translation keys
2026-04-07 22:21:02 -07:00
Sid 5a36179454 Merge pull request #383 from marcgabe15/exportTesting
feat: Add unit tests for exporting videos
2026-04-07 22:02:17 -07:00
BaptisteAuscher 10a8feb71d changes after review, factor the color picker component and add validation for the input 2026-04-07 22:33:39 +02:00
Marc Diaz 3482be9864 refactor: remove extraneous comments 2026-04-07 13:50:26 -04:00
Marc Diaz b8fe1a1ec8 fix(playwright): use one version 2026-04-07 13:32:49 -04:00
Marc Diaz 33609432e1 fix: use npm for install 2026-04-07 13:05:27 -04:00
Marc Diaz b65c68d139 fix: use headless 2026-04-07 13:02:11 -04:00
Marc Diaz 6bff2a2a2c feat: use export testing 2026-04-07 12:58:33 -04:00
samirpatil2000 dfbaf3f176 ci: update build workflow configuration 2026-04-07 22:13:28 +05:30
samirpatil2000 3709342c6c ci: update build workflow configuration and dependencies 2026-04-07 22:00:42 +05:30
Samir Patil 0489d7b9f5 Merge branch 'siddharthvaddem:main' into main 2026-04-07 21:59:25 +05:30
moncef 0cb298d20b Fix Pr reviews 2026-04-07 11:58:45 +01:00
moncef 7409631207 Fix pr review SelecedSpeedId 2026-04-07 11:43:20 +01:00
moncef 8f35cf090c feat: add zoomRegionUtils to calculate dominant zoom regions and handle smooth transitions between connected regions 2026-04-07 11:40:39 +01:00
FabLrc 1f56bb42c3 fix(i18n): update French translations for cycle annotations shortcuts 2026-04-07 12:17:53 +02:00
FabLrc 7a8fb807e6 feat(i18n): add French translations for common and dialogs namespaces 2026-04-07 12:17:10 +02:00
Garry Priambudi 0e1a69a7b2 Merge branch 'main' into feature/webcam-resize-slider 2026-04-07 17:13:38 +07:00
FabLrc e739653b3f feat(i18n): add French translations for various application components 2026-04-07 12:05:36 +02:00
Sid 9024eaae61 Merge pull request #307 from Ayush765-spec/main
Added the new recording button so that user does not exit the entire application
2026-04-06 23:00:24 -07:00
Sid c5882b06b1 Merge pull request #334 from matthew-hre/matthew-hre/jj-przmrvurqkow
fix: handle av1 VideoDecoder errors
2026-04-06 22:47:50 -07:00
Sid 306b61a902 Merge pull request #291 from 1shanpanta/feat/extended-speed-options
feat: extend speed options with higher presets and custom speed input
2026-04-06 22:14:08 -07:00
Nadir A.andClaude Opus 4.6 c36349d950 feat(i18n): add Turkish (tr) locale support
Add complete Turkish translation across all 7 i18n namespaces:
- common: actions, playback controls, locale metadata
- launch: HUD tooltips, audio/webcam controls, source selector
- editor: error messages, export, project, recording permissions
- dialogs: export progress, trim tutorial, unsaved changes, file dialogs
- settings: all panels (zoom, speed, trim, layout, effects, background,
  crop, export, annotations, custom fonts, language, audio)
- shortcuts: keyboard shortcuts panel and all actions
- timeline: toolbar buttons, hints, labels, errors, success messages

Also adds "tr" to SUPPORTED_LOCALES config and i18n validation script.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 03:05:21 +03:00
moncef 112f02fe03 feat: implement video editor timeline components with interactive zoom, trim, and speed region controls. 2026-04-07 00:30:23 +01:00
BaptisteAuscher 2c10073d30 ai review changes 2026-04-06 21:02:50 +02:00
BaptisteAuscher 7e563166a3 add color wheel to background and annotations 2026-04-06 20:37:05 +02:00
AmitwalaH 4e2a53b200 fix: spacing issues in tutorial translations 2026-04-06 15:19:24 +05:30
AmitwalaH 90ba713323 fix(i18n): update tutorial dialog translation keys for all locales 2026-04-06 15:08:49 +05:30
Sid 24928164ca Merge pull request #355 from getSono/main
Adding automatic debian builds
2026-04-05 22:56:56 -07:00
imAaryash 3d20c67c63 fix(i18n): resolve prompt persistence and language menu behavior 2026-04-06 10:15:41 +05:30
imAaryash 4e43b59b42 fix(launch): polish language menu behavior 2026-04-06 10:11:07 +05:30
imAaryash 08b5580ca2 feat(launch): refine recording HUD and language switching UX 2026-04-06 09:41:42 +05:30
imAaryash 36453d740f Update LaunchWindow.tsx 2026-04-06 08:18:40 +05:30
Garry Priambudi 5320f76aae Merge branch 'main' into feature/webcam-resize-slider 2026-04-06 07:56:28 +07:00
Sid e571ecbf8d Merge pull request #352 from siddharthvaddem/sid/fix-read-handler-security
fix(security): prevent path traversal in IPC file read handlers
2026-04-05 17:04:27 -07:00
Siddharth d4c50c9a5e ci: remove flaky e2e test job from CI pipeline 2026-04-05 16:48:53 -07:00
Siddharth 3e6dff9c34 fix: wrap evaluate in try/catch for expected HUD window close
The HUD window now closes faster after switchToEditor, causing the
Playwright page context to terminate before evaluate returns.
2026-04-05 16:34:35 -07:00
Siddharth 1b6f4cce46 fix: restore original e2e test with minimal security fix additions
Revert to exact working version (7e65d52), only adding:
- recordings dir copy for path security check
- --enable-unsafe-swiftshader for CI WebGL
2026-04-05 16:29:54 -07:00
Julian Wolf ec946f0807 Merge branch 'siddharthvaddem:main' into main 2026-04-06 01:26:58 +02:00
Julian Wolf 37e1a82d05 Update ci.yml 2026-04-06 01:26:43 +02:00
Sid 2ce4dc49b3 Merge pull request #354 from notrudyyy/patch-1
Improve grammar and mobile images in README
2026-04-05 16:23:53 -07:00
Siddharth db815e362a ci: trigger checks 2026-04-05 16:22:22 -07:00
Siddharth ed9b8689f7 fix: catch expected page close error in e2e test evaluate call
switchToEditor closes the HUD window, which terminates the Playwright
page context before evaluate can return. Catch at the outer level.
2026-04-05 16:20:29 -07:00
Siddharth dc0856282f fix: add --enable-unsafe-swiftshader to e2e test for CI WebGL support
The headless CI environment fails to create valid WebGL framebuffers,
causing PixiJS pixel reads to fail silently and GIF export to hang.
SwiftShader provides a software WebGL implementation that works reliably.
2026-04-05 16:14:34 -07:00
Anirudh Vempati da79dab756 Update preview image sizes to be dynamic 2026-04-06 04:37:49 +05:30
Siddharth 1dc2c06ee4 fix: revert e2e test to fire-and-forget setCurrentVideoPath with reload
Restore the original test approach that was passing: fire-and-forget
setCurrentVideoPath, catch the switchToEditor context close, and reload
the editor window for WebCodecs initialization.
2026-04-05 16:04:01 -07:00
Anirudh Vempati b6803eb6e3 Update README.md 2026-04-06 04:28:15 +05:30
Siddharth 8013cc97bb fix: remove editor reload in e2e test that was clearing video state
The reload was intended to ensure WebCodecs registered, but it clears
the video path state set before the editor opened, causing the editor
to load blank and the export to never complete.
2026-04-05 15:56:28 -07:00
Siddharth e45611ade4 fix: e2e test — copy fixture into recordings dir for path security check
The test fixture path is outside RECORDINGS_DIR, so set-current-video-path
rejects it after the path traversal fix. Copy the fixture into the app
recordings directory before loading it.
2026-04-05 15:42:25 -07:00
Siddharth b986148d5d fix exporter test 2026-04-05 15:36:29 -07:00
Siddharth fe0c2829a7 fix 2026-04-05 15:33:39 -07:00
Siddharth e4672811de fix(security): prevent path traversal in IPC file read handlers 2026-04-05 14:58:28 -07:00
Julian Wolf 3e28e5860d Fix JSON formatting in package.json 2026-04-05 22:06:06 +02:00
Julian Wolf 925a7e532d Add author information to package.json 2026-04-05 22:04:16 +02:00
Sid f3d761b28d Merge pull request #324 from JasonOA888/fix/306-persist-user-settings
fix: persist user settings across sessions
2026-04-05 12:55:31 -07:00
Julian Wolf c9861dbef8 Refactor CI workflow for E2E tests
Updated CI workflow to include E2E tests conditionally.
2026-04-05 21:54:03 +02:00
Julian Wolf 1591edbeca Add .deb files to build artifacts 2026-04-05 21:35:33 +02:00
Julian Wolf a3c2ed8ed1 Update Linux build command to include AppImage and deb 2026-04-05 21:34:26 +02:00
Siddharth ae971bc480 fix: resolve type error, formatting, and import order from PR #321 2026-04-05 11:03:45 -07:00
Sid 213637967e fix(editor): track unsaved changes for new projects (#321)
fix(editor): track unsaved changes for new projects
2026-04-05 11:02:42 -07:00
JasonOA888 a8427b950e fix: resolve lint errors for CI
- Add updateState to useEffect dependency array
- Remove ineffective biome-ignore suppression comment
- Fix formatting in userPreferences.ts per biome rules
2026-04-06 02:01:01 +08:00
Ayush765-spec 013312be1f Refactor: update 'New Recording' dialog and atomize confirm workflow (plus lint fixes) 2026-04-05 22:27:32 +05:30
Ayush Mukherjee 735dd2a191 Merge branch 'siddharthvaddem:main' into main 2026-04-05 22:14:49 +05:30
Garry Laly 2ee7ccd89c fix: feedback coderabbit 2026-04-05 20:19:31 +07:00
Garry Laly 79201569c5 feat: Add webcam size presets with slider 2026-04-05 20:00:44 +07:00
Garry Laly ca962ff16b feat: Add webcam size presets (small/medium/large) 2026-04-05 19:45:50 +07:00
cocoon 5426b6284c feat(editor): duplicate annotations 2026-04-05 09:16:04 +00:00
Matthew Hrehirchuk 2712d8a41b fix: use view-aware byte extraction for BufferSource inputs 2026-04-04 21:00:16 -06:00
Matthew Hrehirchuk 21361d9bf8 fix: handle av1 VideoDecoder errors 2026-04-04 20:33:39 -06:00
Max Bailey 3b5ad5064e fix: resolve green MP4 exports on CachyOS/Arch Linux (Wayland)
On Linux/Wayland the implicit GPU-to-2D texture-sharing path used by
drawImage(webglCanvas) fails silently (EGL/Ozone), producing green
frames. Use explicit gl.readPixels to copy from GPU to CPU memory,
bypassing that path.
2026-04-04 19:12:15 -05:00
dheerajmr01 210baee0da added acquireId guard to prevent stale getUserMedia from repopulating webcamStream 2026-04-04 14:25:48 -05:00
Samir Patil 0e3106f7ec Merge branch 'main' into main 2026-04-05 00:42:10 +05:30
dheerajmr01 5ff613922f fix:addresses comments - clear track.onended before intentional stop to prevent disconnect toast 2026-04-04 14:03:26 -05:00
dheerajmr01 b270affb25 trigger re-review 2026-04-04 12:42:23 -05:00
Amir Yunus 1b980d6264 fix(hud): avoid horizontal scrollbar when recording on Windows
Use full-size layout and overflow clipping instead of 100vw/100vh on the HUD shell so the fixed 600×160 overlay does not gain a horizontal scrollbar when recording widens the toolbar.

Fixes #305
2026-04-05 01:33:25 +08:00
dheerajmr01 954b99e962 fix: addresses review - differentiate webcam error types and handle stream acquisition 2026-04-04 12:31:28 -05:00
JasonOA888 4f48ecd4bc fix: address code review feedback for settings persistence
- Replace useRef with useState for prefsHydrated to prevent race condition
- Wrap localStorage.getItem in try/catch in loadUserPreferences
- Validate aspectRatio against known valid values
- Include 'good' in exportQuality validation, 'mp4' in exportFormat validation
2026-04-04 23:58:25 +08:00
JasonOA888 7d746196d2 fix: persist user settings across sessions (closes #306)
Load saved preferences (padding, aspect ratio, export quality, export format)
on mount and auto-save whenever these settings change. Uses the existing
userPreferences.ts utility with a ref guard to prevent overwriting saved prefs
with defaults before the initial load completes.
2026-04-04 23:27:56 +08:00
JasonOA888 d5f59a7b8e fix: persist user settings across sessions
Add userPreferences module to save/load padding, aspect ratio,
export format and quality to localStorage. Applied on mount
in VideoEditor.

Closes #306
2026-04-04 23:16:39 +08:00
cocoon 478fe316dc fix(editor): track unsaved changes for new projects 2026-04-04 13:23:51 +00:00
dheerajmr01 20b0899c05 fix: camera light flashes and turns off when clicking webcam button (#308) 2026-04-04 01:43:54 -05:00
Ayush765-spec b451bdc03d Merge branch 'main' of https://github.com/Ayush765-spec/openscreen 2026-04-04 11:51:49 +05:30
Ayush765-spec 43ec6ee9cd fix(editor): localize new recording dialog and fix session clear behavior 2026-04-04 11:51:05 +05:30
Ayush Mukherjee 98da431da0 Merge branch 'siddharthvaddem:main' into main 2026-04-04 11:38:01 +05:30
lueckpeter76-lgtm f972556443 Revert "fix: prevent double-finalize race condition in restartRecording on Windos" 2026-04-03 18:33:54 -06:00
Ayush Mukherjee 5259ae5d87 Merge branch 'siddharthvaddem:main' into main 2026-04-03 18:58:00 +05:30
Ayush765-spec 14cd045e65 [Feature]: Ability to start a new recording from the editor 2026-04-03 18:57:05 +05:30
samirpatil2000 78901a8076 feat: configure macOS hardened runtime, entitlements, and build environment variables for notarization 2026-04-03 15:16:45 +05:30
Ishan Panta 3895ca985f [add] extend speed options with higher presets and custom speed input
add 3x, 4x, 5x speed presets and a custom playback speed input field
that accepts any integer value up to 16x. change PlaybackSpeed type
from a fixed union to number with min/max constants and clamp utility.
update project persistence to validate any speed in range instead of
exact value matching. add i18n keys for en, es, zh-CN.

closes #252
2026-04-03 08:37:16 +05:45
397 changed files with 47298 additions and 10472 deletions
+10
View File
@@ -0,0 +1,10 @@
APP_NAME=Openscreen
BUNDLE_ID=com.siddharthvaddem.openscreen
APPLE_ID=
TEAM_ID=
SIGN_IDENTITY="Developer ID Application: Samir Patil ()"
CSC_NAME="Samir Patil ()"
NOTARY_PROFILE=OpenScreen-notary
APPLE_APP_SPECIFIC_PASSWORD=
+1
View File
@@ -0,0 +1 @@
use flake
+1
View File
@@ -0,0 +1 @@
* @siddharthvaddem
+217 -20
View File
@@ -3,6 +3,16 @@ name: Build Electron App
on:
workflow_dispatch:
inputs:
arch:
description: 'Architecture to build'
required: true
default: 'both'
type: choice
options:
- arm64
- x64
- both
jobs:
build-windows:
@@ -10,18 +20,23 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '22'
- name: Install dependencies
run: npm ci
- name: Install app dependencies
run: npx electron-builder install-app-deps
# 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:
@@ -36,38 +51,208 @@ jobs:
build-macos:
runs-on: macos-latest
strategy:
matrix:
arch: ${{ github.event.inputs.arch == 'both' && fromJSON('["arm64", "x64"]') || fromJSON(format('["{0}"]', github.event.inputs.arch)) }}
steps:
# ─── Checkout ─────────────────────────────────────────────
- name: Checkout code
uses: actions/checkout@v3
uses: actions/checkout@v4
# ─── Setup Node.js ────────────────────────────────────────
- name: Setup Node.js
uses: actions/setup-node@v3
uses: actions/setup-node@v4
with:
node-version: '22'
node-version: 22
cache: npm
# ─── Setup Python (needed by some native deps) ────────────
- name: Setup Python
uses: actions/setup-python@v4
uses: actions/setup-python@v5
with:
python-version: '3.11'
# ─── Install Dependencies ─────────────────────────────────
- name: Install dependencies
run: npm ci
- name: Install app dependencies
run: npx electron-builder install-app-deps
- name: Build macOS app
run: npm run build:mac
# ─── 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,
# and set it as the default so codesign can find it.
- name: Import code signing certificate
env:
MAC_CERTIFICATE_P12: ${{ secrets.MAC_CERTIFICATE_P12 }}
MAC_CERTIFICATE_PASSWORD: ${{ secrets.MAC_CERTIFICATE_PASSWORD }}
run: |
# Create a temporary keychain
KEYCHAIN_PATH=$RUNNER_TEMP/build.keychain-db
KEYCHAIN_PASSWORD=$(openssl rand -base64 32)
# Create and configure keychain
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH"
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
# Decode and import certificate
echo "$MAC_CERTIFICATE_P12" | base64 --decode > $RUNNER_TEMP/certificate.p12
security import $RUNNER_TEMP/certificate.p12 \
-k "$KEYCHAIN_PATH" \
-P "$MAC_CERTIFICATE_PASSWORD" \
-T /usr/bin/codesign \
-T /usr/bin/security
# Allow codesign to access the keychain without UI prompt
security set-key-partition-list -S apple-tool:,apple: -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
# Add to keychain search path (makes it the default)
security list-keychains -d user -s "$KEYCHAIN_PATH" $(security list-keychains -d user | tr -d '"')
# Verify the identity is available
security find-identity -v -p codesigning "$KEYCHAIN_PATH"
# Clean up the .p12 file
rm -f $RUNNER_TEMP/certificate.p12
# ─── Build Vite + Electron ────────────────────────────────
- 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
# trying its own notarization flow
- name: Package .app bundle
run: npx electron-builder --mac --${{ matrix.arch }} --dir
env:
CSC_NAME: "Samir Patil (N26FZ4GW28)"
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload macOS build
# ─── Read version from package.json ───────────────────────
- name: Get version
id: version
run: echo "version=$(node -p 'require(\"./package.json\").version')" >> $GITHUB_OUTPUT
# ─── Locate the .app bundle ───────────────────────────────
- name: Find .app bundle
id: find_app
run: |
VERSION="${{ steps.version.outputs.version }}"
echo "=== Release directory contents ==="
ls -laR "release/${VERSION}/" || echo "release/${VERSION}/ not found"
echo "=== Searching for .app bundle ==="
APP_BUNDLE=$(find "release/${VERSION}" -maxdepth 4 -name "*.app" -type d | head -n1)
if [ -z "$APP_BUNDLE" ]; then
echo "::error::No .app bundle found in release/${VERSION}/"
exit 1
fi
echo "app_bundle=$APP_BUNDLE" >> $GITHUB_OUTPUT
echo "Found: $APP_BUNDLE"
# ─── Verify .app signature ────────────────────────────────
- name: Verify .app code signature
run: codesign --verify --deep --strict "${{ steps.find_app.outputs.app_bundle }}"
# ─── Create DMG ───────────────────────────────────────────
- name: Create DMG
id: dmg
run: |
VERSION="${{ steps.version.outputs.version }}"
ARCH="${{ matrix.arch }}"
DMG_NAME="Openscreen-Mac-${ARCH}-${VERSION}.dmg"
RELEASE_DIR="release/${VERSION}"
DMG_OUTPUT="${RELEASE_DIR}/${DMG_NAME}"
STAGING="${RELEASE_DIR}/dmg-staging"
mkdir -p "$STAGING"
cp -R "${{ steps.find_app.outputs.app_bundle }}" "$STAGING/"
ln -s /Applications "$STAGING/Applications"
hdiutil create \
-srcfolder "$STAGING" \
-volname "Openscreen" \
-fs HFS+ \
-fsargs "-c c=64,a=16,e=16" \
-format UDBZ \
"$DMG_OUTPUT"
rm -rf "$STAGING"
echo "dmg_path=$DMG_OUTPUT" >> $GITHUB_OUTPUT
echo "dmg_name=$DMG_NAME" >> $GITHUB_OUTPUT
# ─── Sign DMG ─────────────────────────────────────────────
- name: Sign DMG
run: |
codesign --force \
--sign "Developer ID Application: Samir Patil (N26FZ4GW28)" \
--timestamp \
"${{ steps.dmg.outputs.dmg_path }}"
# ─── Notarize DMG ────────────────────────────────────────
# On CI we can't use keychain profiles for notarytool, so we
# pass credentials directly via env vars / flags
- name: Notarize DMG
run: |
xcrun notarytool submit "${{ steps.dmg.outputs.dmg_path }}" \
--apple-id "${{ secrets.APPLE_ID }}" \
--team-id "${{ secrets.APPLE_TEAM_ID }}" \
--password "${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}" \
--wait
timeout-minutes: 15
# ─── Staple ───────────────────────────────────────────────
- name: Staple notarization ticket
run: xcrun stapler staple "${{ steps.dmg.outputs.dmg_path }}"
# ─── Validate ─────────────────────────────────────────────
- name: Validate stapled DMG
run: |
xcrun stapler validate "${{ steps.dmg.outputs.dmg_path }}"
spctl -a -vv -t install "${{ steps.dmg.outputs.dmg_path }}"
# ─── Upload Artifact ──────────────────────────────────────
- name: Upload notarized DMG
uses: actions/upload-artifact@v4
with:
name: macos-installer
path: release/**/*.dmg
name: openscreen-mac-${{ matrix.arch }}
path: ${{ steps.dmg.outputs.dmg_path }}
retention-days: 30
# ─── Cleanup Keychain ─────────────────────────────────────
- name: Cleanup keychain
if: always()
run: security delete-keychain $RUNNER_TEMP/build.keychain-db || true
build-linux:
runs-on: ubuntu-latest
steps:
@@ -82,8 +267,18 @@ jobs:
- name: Install dependencies
run: npm ci
- name: Install app dependencies
run: npx electron-builder install-app-deps
# bsdtar (from libarchive-tools) is required by fpm to build pacman
# packages. AppImage and deb don't need it; ubuntu-latest doesn't ship it.
- 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
@@ -97,4 +292,6 @@ jobs:
path: |
release/**/*.AppImage
release/**/*.zsync
release/**/*.deb
release/**/*.pacman
retention-days: 30
+118
View File
@@ -0,0 +1,118 @@
name: Bump Nix package on release
on:
release:
types: [published]
workflow_dispatch:
inputs:
tag:
description: "Release tag to bump (e.g. v1.5.0)"
required: true
type: string
permissions:
contents: write
pull-requests: write
jobs:
bump:
runs-on: ubuntu-latest
if: github.event_name == 'workflow_dispatch' || !github.event.release.prerelease
steps:
- name: Resolve tag and version
id: meta
env:
GH_EVENT_TAG: ${{ github.event.release.tag_name }}
INPUT_TAG: ${{ inputs.tag }}
run: |
set -euo pipefail
TAG="${GH_EVENT_TAG:-$INPUT_TAG}"
if [[ -z "$TAG" ]]; then
echo "::error::No tag resolved from release event or workflow input"
exit 1
fi
VERSION="${TAG#v}"
BRANCH="chore/bump-nix-${VERSION}"
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "branch=$BRANCH" >> "$GITHUB_OUTPUT"
- name: Checkout main
uses: actions/checkout@v4
with:
ref: main
fetch-depth: 0
- name: Install Nix
uses: cachix/install-nix-action@v27
with:
nix_path: nixpkgs=channel:nixos-unstable
extra_nix_config: |
experimental-features = nix-command flakes
- name: Compute npmDepsHash
id: hash
run: |
set -euo pipefail
HASH=$(nix run nixpkgs#prefetch-npm-deps -- package-lock.json)
if [[ -z "$HASH" ]]; then
echo "::error::prefetch-npm-deps returned an empty hash"
exit 1
fi
echo "hash=$HASH" >> "$GITHUB_OUTPUT"
echo "Computed npmDepsHash: $HASH"
- name: Update nix/package.nix
env:
VERSION: ${{ steps.meta.outputs.version }}
HASH: ${{ steps.hash.outputs.hash }}
run: |
set -euo pipefail
# Update version line: ` version = "<anything>";`
sed -i -E "s|^([[:space:]]*version[[:space:]]*=[[:space:]]*)\"[^\"]*\";|\1\"${VERSION}\";|" nix/package.nix
# Update npmDepsHash line: ` npmDepsHash = "<anything>";`
sed -i -E "s|^([[:space:]]*npmDepsHash[[:space:]]*=[[:space:]]*)\"[^\"]*\";|\1\"${HASH}\";|" nix/package.nix
echo "=== diff ==="
git --no-pager diff nix/package.nix || true
- name: Create PR
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ steps.meta.outputs.version }}
HASH: ${{ steps.hash.outputs.hash }}
BRANCH: ${{ steps.meta.outputs.branch }}
TAG: ${{ steps.meta.outputs.tag }}
run: |
set -euo pipefail
if git diff --quiet -- nix/package.nix; then
echo "nix/package.nix already at v${VERSION} with this hash — nothing to do."
exit 0
fi
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
# Replace any prior bump branch to keep the workflow idempotent.
git push origin --delete "$BRANCH" 2>/dev/null || true
git checkout -b "$BRANCH"
git add nix/package.nix
git commit -m "chore: bump nix package to v${VERSION}"
git push -u origin "$BRANCH"
gh pr create \
--title "chore: bump nix package to v${VERSION}" \
--base main \
--head "$BRANCH" \
--body "$(cat <<EOF
Automated bump triggered by release \`${TAG}\`.
- \`version\` → \`${VERSION}\`
- \`npmDepsHash\` → \`${HASH}\` (computed via \`prefetch-npm-deps package-lock.json\`)
Merge this so Nix users (NixOS, Home Manager, \`nix run github:siddharthvaddem/openscreen\`) pick up the new release.
> Note: PRs opened by \`GITHUB_TOKEN\` don't auto-trigger CI. The diff is two lines — review the change here, then merge. If you want CI to run, push an empty commit to this branch or close-and-reopen the PR.
EOF
)"
+14 -23
View File
@@ -31,6 +31,20 @@ jobs:
- run: npm ci
- run: npx tsc --noEmit
test:
name: Test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm run test
- run: npm run test:browser:install
- run: npm run test:browser
build:
name: Build
runs-on: ubuntu-latest
@@ -42,26 +56,3 @@ jobs:
cache: npm
- run: npm ci
- run: npx vite build
e2e:
name: E2E Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npx playwright install --with-deps chromium
# Install Electron system dependencies not covered by Playwright's chromium deps
- run: npx electron . --version || sudo apt-get install -y libgbm-dev
- run: npm run build-vite
# xvfb provides a virtual display; Electron needs one on Linux even with show:false
- run: xvfb-run --auto-servernum npm run test:e2e
- uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-report
path: playwright-report/
retention-days: 7
+519
View File
@@ -0,0 +1,519 @@
name: PR to Discord Forum
on:
pull_request_target:
types: [opened, reopened, ready_for_review, converted_to_draft, synchronize, edited, labeled, unlabeled, closed]
pull_request_review:
types: [submitted]
issue_comment:
types: [created]
schedule:
- cron: "0 12 * * 1"
workflow_dispatch:
permissions:
contents: read
pull-requests: write
issues: read
jobs:
notify:
if: github.event_name != 'schedule' && github.actor != 'github-actions[bot]'
concurrency:
group: discord-pr-sync-${{ github.repository }}-${{ github.event.pull_request.number || github.event.issue.number || github.run_id }}
cancel-in-progress: false
runs-on: ubuntu-latest
steps:
- name: Sync PR activity to Discord forum thread
id: sync
uses: actions/github-script@v7
env:
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
DISCORD_PR_FORUM_WEBHOOK: ${{ secrets.DISCORD_PR_FORUM_WEBHOOK }}
DISCORD_WEBHOOK_USERNAME: ${{ secrets.DISCORD_WEBHOOK_USERNAME }}
DISCORD_WEBHOOK_AVATAR_URL: ${{ secrets.DISCORD_WEBHOOK_AVATAR_URL }}
DISCORD_BOT_TOKEN: ${{ secrets.DISCORD_BOT_TOKEN }}
DISCORD_REVIEWER_ROLE_ID: ${{ secrets.DISCORD_REVIEWER_ROLE_ID }}
DISCORD_ALERT_WEBHOOK_URL: ${{ secrets.DISCORD_ALERT_WEBHOOK_URL }}
with:
script: |
const WEBHOOK_USERNAME = (process.env.DISCORD_WEBHOOK_USERNAME || "OpenScreen").trim();
const WEBHOOK_AVATAR = (process.env.DISCORD_WEBHOOK_AVATAR_URL || "").trim();
const THREAD_MARKER_REGEX = /<!--\s*discord-thread-id:(\d+)\s*-->/i;
const webhookUrl = (process.env.DISCORD_WEBHOOK_URL || process.env.DISCORD_PR_FORUM_WEBHOOK || "").trim();
const botToken = (process.env.DISCORD_BOT_TOKEN || "").trim();
const reviewerRoleId = (process.env.DISCORD_REVIEWER_ROLE_ID || "").trim();
const alertWebhookUrl = (process.env.DISCORD_ALERT_WEBHOOK_URL || "").trim();
const TAGS = {
open: "1493976692967080096",
draft: "1493976782028935279",
ready: "1493976833626996756",
changes: "1493976909875515564",
approved: "1493976951038152764",
merged: "1493977049709281320",
closed: "1493977108102516786",
};
const labelTagMap = {
bug: "1493977562773458975",
enhancement: "1493977619216207993",
documentation: "1493978565153394830",
};
function cleanDescription(text, maxLen = 3500) {
if (!text) return "No description provided.";
const normalized = text
.replace(/\r\n/g, "\n")
.replace(/\n{3,}/g, "\n\n")
.trim();
if (normalized.length <= maxLen) return normalized;
return `${normalized.slice(0, maxLen - 1)}…`;
}
function trimThreadName(name) {
return name.length > 95 ? name.slice(0, 95) : name;
}
function extractThreadId(body) {
if (!body) return null;
const match = body.match(THREAD_MARKER_REGEX);
return match ? match[1] : null;
}
function upsertThreadMarker(body, threadId) {
const cleaned = (body || "").replace(THREAD_MARKER_REGEX, "").trim();
return `${cleaned}\n\n<!-- discord-thread-id:${threadId} -->`.trim();
}
async function discordPost(payload, options = {}) {
const endpoint = new URL(webhookUrl);
endpoint.searchParams.set("wait", "true");
if (options.threadId) endpoint.searchParams.set("thread_id", String(options.threadId));
const response = await fetch(endpoint.toString(), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
username: WEBHOOK_USERNAME,
avatar_url: WEBHOOK_AVATAR,
allowed_mentions: { parse: [] },
...payload,
})
});
const contentType = (response.headers.get("content-type") || "").toLowerCase();
const text = await response.text();
if (!response.ok) {
throw new Error(`Discord API error ${response.status}: ${text}`);
}
if (!text) return {};
if (contentType.includes("application/json")) return JSON.parse(text);
// Some proxy/CDN edge responses may return HTML with 2xx; avoid crashing on JSON parse.
core.warning(`Discord webhook returned non-JSON response (content-type: ${contentType || "unknown"}).`);
return {};
}
async function patchDiscordThread(threadId, patchBody) {
if (!botToken || !threadId) return;
const response = await fetch(`https://discord.com/api/v10/channels/${threadId}`, {
method: "PATCH",
headers: {
"Authorization": `Bot ${botToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify(patchBody),
});
if (!response.ok) {
const text = await response.text();
core.warning(`Discord thread patch failed (${response.status}): ${text}`);
}
}
function desiredStatusTag(prState) {
if (prState.merged && TAGS.merged) return TAGS.merged;
if (prState.closed && !prState.merged && TAGS.closed) return TAGS.closed;
if (prState.reviewState === "CHANGES_REQUESTED" && TAGS.changes) return TAGS.changes;
if (prState.reviewState === "APPROVED" && TAGS.approved) return TAGS.approved;
if (prState.draft && TAGS.draft) return TAGS.draft;
if (!prState.draft && TAGS.ready) return TAGS.ready;
return TAGS.open || null;
}
function tagIdsFromLabels(labels) {
const out = [];
for (const label of labels) {
const mapped = labelTagMap[label.toLowerCase()] || labelTagMap[label];
if (mapped) out.push(String(mapped));
}
return out;
}
async function getPullRequest() {
if (context.eventName === "pull_request_target" || context.eventName === "pull_request_review") {
return context.payload.pull_request || null;
}
if (context.eventName === "issue_comment") {
const issue = context.payload.issue;
if (!issue?.pull_request) return null;
const { data } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: issue.number,
});
return data;
}
return null;
}
async function getReviewState(owner, repo, pullNumber) {
const { data } = await github.rest.pulls.listReviews({ owner, repo, pull_number: pullNumber, per_page: 100 });
let hasChanges = false;
let hasApproved = false;
for (const r of data) {
const s = (r.state || "").toUpperCase();
if (s === "CHANGES_REQUESTED") hasChanges = true;
if (s === "APPROVED") hasApproved = true;
}
if (hasChanges) return "CHANGES_REQUESTED";
if (hasApproved) return "APPROVED";
return "NONE";
}
async function sendFailureAlert(message) {
if (!alertWebhookUrl) return;
try {
await fetch(alertWebhookUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
username: "OpenScreen",
avatar_url: WEBHOOK_AVATAR,
content: `⚠️ PR Discord sync failed\n${message}\nRun: ${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`,
allowed_mentions: { parse: [] }
})
});
} catch {
core.warning("Failed to send failure alert webhook.");
}
}
try {
const pr = await getPullRequest();
if (!pr) {
core.info("No PR context found. Skipping.");
return;
}
if (!webhookUrl) {
const strictEvents = new Set(["pull_request_target", "workflow_dispatch"]);
const msg =
`Discord sync skipped: webhook secret unavailable for event '${context.eventName}'. ` +
"Set either DISCORD_WEBHOOK_URL or DISCORD_PR_FORUM_WEBHOOK in repository secrets.";
if (strictEvents.has(context.eventName)) {
core.setFailed(msg);
} else {
core.warning(msg);
}
return;
}
const action = context.payload.action || "";
const owner = context.repo.owner;
const repo = context.repo.repo;
const number = pr.number;
const title = pr.title;
const author = pr.user?.login || "unknown";
const url = pr.html_url;
const authorUrl = pr.user?.html_url || "";
const authorAvatar = pr.user?.avatar_url || "";
const base = pr.base?.ref || "";
const head = pr.head?.ref || "";
const repoFullName = pr.base?.repo?.full_name || `${owner}/${repo}`;
const labels = (pr.labels || []).map((l) => l.name);
const body = (pr.body || "").trim();
const reviewState = await getReviewState(owner, repo, number);
let threadId = extractThreadId(body);
const shouldCreateThread =
context.eventName === "pull_request_target" &&
["opened", "reopened", "ready_for_review"].includes(action) &&
!threadId;
if (shouldCreateThread) {
const fields = [
{ name: "PR", value: `[#${number}](${url})`, inline: true },
{ name: "Author", value: `[${author}](${authorUrl || url})`, inline: true },
{ name: "Status", value: pr.draft ? "Draft" : "Open", inline: true },
{ name: "Branches", value: `\`${head}\` -> \`${base}\``, inline: true },
{ name: "Changes", value: `+${pr.additions} / -${pr.deletions}`, inline: true },
{ name: "Files Changed", value: String(pr.changed_files), inline: true }
];
if (labels.length) {
fields.push({
name: "Labels",
value: labels.map((l) => `\`${l}\``).join(" "),
inline: false,
});
}
const statusTag = desiredStatusTag({ draft: pr.draft, reviewState, merged: false, closed: false });
const mappedLabelTags = tagIdsFromLabels(labels);
const appliedTags = [...new Set([statusTag, ...mappedLabelTags].filter(Boolean))];
const createPayload = {
content: action === "ready_for_review" ? "🔔 PR is now ready for review" : "🔔 New pull request opened",
thread_name: trimThreadName(`PR #${number} - ${title}`),
applied_tags: appliedTags,
embeds: [
{
title: `PR #${number}: ${title}`,
url,
description: cleanDescription(body),
color: pr.draft ? 15105570 : 1998671,
author: {
name: author,
url: authorUrl || undefined,
icon_url: authorAvatar || undefined,
},
fields,
footer: { text: repoFullName },
timestamp: new Date().toISOString(),
},
],
};
const result = await discordPost(createPayload);
const createdThreadId = result.channel_id || null;
if (createdThreadId) {
const updatedBody = upsertThreadMarker(body, createdThreadId);
await github.rest.pulls.update({ owner, repo, pull_number: number, body: updatedBody });
core.info(`Created Discord thread ${createdThreadId} and stored mapping.`);
} else {
core.warning("Discord thread created but channel_id missing in response.");
}
return;
}
if (!threadId) {
core.info("No mapped Discord thread ID found; skipping update event.");
return;
}
if (context.eventName === "pull_request_target" && ["edited", "labeled", "unlabeled", "ready_for_review", "converted_to_draft"].includes(action)) {
const statusTag = desiredStatusTag({
draft: action === "converted_to_draft" ? true : pr.draft,
reviewState,
merged: false,
closed: false,
});
const mappedLabelTags = tagIdsFromLabels(labels);
const appliedTags = [...new Set([statusTag, ...mappedLabelTags].filter(Boolean))];
await patchDiscordThread(threadId, {
name: trimThreadName(`PR #${number} - ${title}`),
...(appliedTags.length ? { applied_tags: appliedTags } : {}),
});
}
let updateMessage = null;
let updateEmbed = null;
if (context.eventName === "pull_request_target") {
if (action === "synchronize") {
const { data: commits } = await github.rest.pulls.listCommits({ owner, repo, pull_number: number, per_page: 5 });
const list = commits.map((c) => `- \`${c.sha.slice(0, 7)}\` ${c.commit.message.split("\n")[0]}`).join("\n") || "- No commit details";
updateMessage = `🧩 New commits pushed to PR #${number}`;
updateEmbed = {
title: `Commit Update • PR #${number}`,
url: `${url}/files`,
description: `${list}`,
color: 1998671,
footer: { text: repoFullName },
timestamp: new Date().toISOString(),
};
} else if (action === "edited") {
updateMessage = `✏️ PR #${number} details were edited`;
updateEmbed = {
title: `PR Updated • #${number}`,
url,
description: cleanDescription(body, 1200),
color: 1998671,
timestamp: new Date().toISOString(),
};
} else if (action === "closed") {
const isMerged = !!pr.merged;
const statusTag = desiredStatusTag({ draft: false, reviewState, merged: isMerged, closed: true });
const mappedLabelTags = tagIdsFromLabels(labels);
const appliedTags = [...new Set([statusTag, ...mappedLabelTags].filter(Boolean))];
await patchDiscordThread(threadId, {
...(appliedTags.length ? { applied_tags: appliedTags } : {}),
...(isMerged ? { archived: true, locked: true } : {}),
});
updateMessage = isMerged
? `✅ PR #${number} was merged`
: `🛑 PR #${number} was closed without merge`;
updateEmbed = {
title: isMerged ? `Merged • PR #${number}` : `Closed • PR #${number}`,
url,
description: isMerged ? "This PR has been merged into the base branch." : "This PR was closed before merge.",
color: isMerged ? 5763719 : 15158332,
timestamp: new Date().toISOString(),
};
} else if (action === "ready_for_review") {
updateMessage = `🚀 PR #${number} moved from draft to ready for review`;
if (reviewerRoleId) updateMessage += ` <@&${reviewerRoleId}>`;
} else if (action === "converted_to_draft") {
updateMessage = `📝 PR #${number} converted to draft`;
}
} else if (context.eventName === "pull_request_review") {
const review = context.payload.review;
if (review) {
const state = (review.state || "commented").toUpperCase();
const reviewer = review.user?.login || "reviewer";
updateMessage = `🧪 Review ${state} by **${reviewer}** on PR #${number}`;
if (state === "CHANGES_REQUESTED" && reviewerRoleId) updateMessage += ` <@&${reviewerRoleId}>`;
updateEmbed = {
title: `Review ${state} • PR #${number}`,
url: review.html_url || url,
description: cleanDescription(review.body || "No review note.", 1000),
color: state === "APPROVED" ? 5763719 : state === "CHANGES_REQUESTED" ? 15158332 : 1998671,
timestamp: new Date().toISOString(),
};
if (state === "CHANGES_REQUESTED" || state === "APPROVED") {
const statusTag = desiredStatusTag({ draft: pr.draft, reviewState: state, merged: false, closed: false });
const mappedLabelTags = tagIdsFromLabels(labels);
const appliedTags = [...new Set([statusTag, ...mappedLabelTags].filter(Boolean))];
await patchDiscordThread(threadId, {
...(appliedTags.length ? { applied_tags: appliedTags } : {}),
});
}
}
} else if (context.eventName === "issue_comment") {
const comment = context.payload.comment;
if (comment) {
const commenter = comment.user?.login || "user";
updateMessage = `💬 New comment by **${commenter}** on PR #${number}`;
updateEmbed = {
title: `New PR Comment • #${number}`,
url: comment.html_url || url,
description: cleanDescription(comment.body || "No comment body.", 1000),
color: 1998671,
timestamp: new Date().toISOString(),
};
}
}
if (!updateMessage && !updateEmbed) {
core.info("No Discord update message for this event/action. Skipping.");
return;
}
const payload = { content: updateMessage || "" };
if (updateEmbed) payload.embeds = [updateEmbed];
await discordPost(payload, { threadId });
core.info(`Posted update to Discord thread ${threadId}.`);
} catch (err) {
const msg = err && err.message ? err.message : String(err);
core.setFailed(msg);
const alertWebhook = process.env.DISCORD_ALERT_WEBHOOK_URL;
if (alertWebhook) {
try {
await fetch(alertWebhook, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
username: "OpenScreen",
avatar_url: WEBHOOK_AVATAR,
content: `⚠️ PR->Discord sync failed\n${msg}\nRun: ${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`,
allowed_mentions: { parse: [] }
})
});
} catch {
core.warning("Failed to send alert webhook.");
}
}
}
weekly-contributor-leaderboard:
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
steps:
- name: Post weekly contributor leaderboard
uses: actions/github-script@v7
env:
DISCORD_SPOTLIGHT_WEBHOOK_URL: ${{ secrets.DISCORD_SPOTLIGHT_WEBHOOK_URL }}
DISCORD_WEBHOOK_USERNAME: ${{ secrets.DISCORD_WEBHOOK_USERNAME }}
DISCORD_WEBHOOK_AVATAR_URL: ${{ secrets.DISCORD_WEBHOOK_AVATAR_URL }}
with:
script: |
const spotlightWebhook = (process.env.DISCORD_SPOTLIGHT_WEBHOOK_URL || "").trim();
const webhookUsername = (process.env.DISCORD_WEBHOOK_USERNAME || "OpenScreen").trim();
const webhookAvatar = (process.env.DISCORD_WEBHOOK_AVATAR_URL || "").trim();
if (!spotlightWebhook) {
core.info("DISCORD_SPOTLIGHT_WEBHOOK_URL missing. Skipping leaderboard post.");
return;
}
const since = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString();
const owner = context.repo.owner;
const repo = context.repo.repo;
const q = `repo:${owner}/${repo} is:pr is:merged merged:>=${since.substring(0, 10)}`;
const search = await github.rest.search.issuesAndPullRequests({
q,
per_page: 100,
});
const counter = new Map();
for (const item of search.data.items) {
const login = item.user?.login;
if (!login) continue;
counter.set(login, (counter.get(login) || 0) + 1);
}
const ranked = [...counter.entries()]
.sort((a, b) => b[1] - a[1])
.slice(0, 10);
const totalMerged = search.data.items.length;
const lines = ranked.length
? ranked.map(([user, count], idx) => `${idx + 1}. **${user}** - ${count} merged PR(s)`).join("\n")
: "No merged PRs this week.";
const payload = {
username: webhookUsername,
...(webhookAvatar ? { avatar_url: webhookAvatar } : {}),
embeds: [
{
title: "🌟 Weekly Contributor Leaderboard",
description: lines,
color: 1998671,
fields: [
{ name: "Merged PRs (7d)", value: String(totalMerged), inline: true },
{ name: "Repository", value: `${owner}/${repo}`, inline: true },
{ name: "Period", value: "Last 7 days", inline: true }
],
timestamp: new Date().toISOString()
}
],
allowed_mentions: { parse: [] }
};
const res = await fetch(`${spotlightWebhook}?wait=true`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
if (!res.ok) {
const txt = await res.text();
core.setFailed(`Leaderboard post failed ${res.status}: ${txt}`);
}
+26
View File
@@ -0,0 +1,26 @@
name: Publish release to WinGet
on:
release:
types: [released]
workflow_dispatch:
inputs:
tag:
description: "Release tag to publish to winget (e.g. v1.4.0)"
required: true
type: string
jobs:
publish:
runs-on: windows-latest
if: github.event_name == 'workflow_dispatch' || !github.event.release.prerelease
steps:
- uses: vedantmgoyal9/winget-releaser@v2
with:
identifier: SiddharthVaddem.OpenScreen
# Match the Windows installer asset attached to each release.
# Today: "Openscreen.Setup.latest.exe". Adjust this regex if you
# ever rename the installer to include a version (e.g. "Setup\.\d+\.\d+\.\d+\.exe").
installers-regex: 'Setup\..*\.exe$'
release-tag: ${{ inputs.tag || github.event.release.tag_name }}
token: ${{ secrets.WINGET_ACC_TOKEN }}
+168
View File
@@ -0,0 +1,168 @@
name: Update Homebrew Cask
on:
release:
types: [published]
workflow_dispatch:
inputs:
tag:
description: "Release tag to publish to the tap (e.g. v1.4.0)"
required: true
type: string
permissions:
contents: read
jobs:
update-cask:
runs-on: ubuntu-latest
if: github.event_name == 'workflow_dispatch' || !github.event.release.prerelease
env:
TAP_OWNER: siddharthvaddem
TAP_REPO: homebrew-openscreen
CASK_NAME: openscreen
steps:
- name: Resolve tag and version
id: meta
env:
GH_EVENT_TAG: ${{ github.event.release.tag_name }}
INPUT_TAG: ${{ inputs.tag }}
run: |
set -euo pipefail
TAG="${GH_EVENT_TAG:-$INPUT_TAG}"
if [[ -z "$TAG" ]]; then
echo "::error::No tag resolved from release event or workflow input"
exit 1
fi
VERSION="${TAG#v}"
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
- name: Find macOS DMG assets
id: assets
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ steps.meta.outputs.tag }}
REPO: ${{ github.repository }}
run: |
set -euo pipefail
NAMES=$(gh release view "$TAG" --repo "$REPO" --json assets --jq '.assets[].name')
# arm64 DMG: explicit "arm64" / "apple silicon" / fallback to any .dmg
# whose name does NOT contain "x64" or non-mac platform markers.
ARM_NAME=$(echo "$NAMES" | grep -iE '\.dmg$' \
| grep -iE '(arm64|apple[-_. ]?silicon)' | head -n1 || true)
if [[ -z "$ARM_NAME" ]]; then
ARM_NAME=$(echo "$NAMES" | grep -iE '\.dmg$' \
| grep -iv 'x64' | grep -iv 'linux' | grep -iv 'win' | head -n1 || true)
fi
# x64 DMG
X64_NAME=$(echo "$NAMES" | grep -iE '\.dmg$' \
| grep -iE '(x64|x86[-_]?64|intel)' | head -n1 || true)
if [[ -z "$ARM_NAME" || -z "$X64_NAME" ]]; then
echo "::error::Could not locate both arm64 and x64 DMGs in release assets"
echo "Available assets:"
echo "$NAMES"
exit 1
fi
echo "arm_name=$ARM_NAME" >> "$GITHUB_OUTPUT"
echo "x64_name=$X64_NAME" >> "$GITHUB_OUTPUT"
echo "Found arm64 asset: $ARM_NAME"
echo "Found x64 asset: $X64_NAME"
- name: Download DMGs and compute sha256
id: shas
env:
REPO: ${{ github.repository }}
TAG: ${{ steps.meta.outputs.tag }}
ARM_NAME: ${{ steps.assets.outputs.arm_name }}
X64_NAME: ${{ steps.assets.outputs.x64_name }}
run: |
set -euo pipefail
BASE="https://github.com/${REPO}/releases/download/${TAG}"
curl -fsSL --retry 3 -o /tmp/arm.dmg "${BASE}/${ARM_NAME}"
curl -fsSL --retry 3 -o /tmp/x64.dmg "${BASE}/${X64_NAME}"
ARM_SHA=$(sha256sum /tmp/arm.dmg | awk '{print $1}')
X64_SHA=$(sha256sum /tmp/x64.dmg | awk '{print $1}')
echo "arm_sha=$ARM_SHA" >> "$GITHUB_OUTPUT"
echo "x64_sha=$X64_SHA" >> "$GITHUB_OUTPUT"
- name: Checkout tap
uses: actions/checkout@v4
with:
repository: ${{ env.TAP_OWNER }}/${{ env.TAP_REPO }}
token: ${{ secrets.HOMEBREW_TAP_TOKEN }}
path: tap
- name: Write cask file
env:
REPO: ${{ github.repository }}
TAG: ${{ steps.meta.outputs.tag }}
VERSION: ${{ steps.meta.outputs.version }}
ARM_NAME: ${{ steps.assets.outputs.arm_name }}
X64_NAME: ${{ steps.assets.outputs.x64_name }}
ARM_SHA: ${{ steps.shas.outputs.arm_sha }}
X64_SHA: ${{ steps.shas.outputs.x64_sha }}
run: |
set -euo pipefail
mkdir -p tap/Casks
BASE="https://github.com/${REPO}/releases/download/${TAG}"
# #{version} is Ruby interpolation written literally to the cask
# file (bash heredoc leaves "#{...}" alone). \${VERSION}, \${ARM_SHA},
# etc. are bash variables expanded by the heredoc. The literal
# #{version} fixes Homebrew's "URL is unversioned" audit warning by
# making the version string statically detectable.
cat > "tap/Casks/${CASK_NAME}.rb" <<EOF
cask "${CASK_NAME}" do
version "${VERSION}"
on_arm do
sha256 "${ARM_SHA}"
url "https://github.com/${REPO}/releases/download/v#{version}/${ARM_NAME}"
end
on_intel do
sha256 "${X64_SHA}"
url "https://github.com/${REPO}/releases/download/v#{version}/${X64_NAME}"
end
name "Openscreen"
desc "Screen recorder and video editor"
homepage "https://github.com/${REPO}"
auto_updates false
depends_on macos: ">= :big_sur"
app "Openscreen.app"
zap trash: [
"~/Library/Application Support/Openscreen",
"~/Library/Caches/com.siddharthvaddem.openscreen",
"~/Library/Logs/Openscreen",
"~/Library/Preferences/com.siddharthvaddem.openscreen.plist",
"~/Library/Saved Application State/com.siddharthvaddem.openscreen.savedState",
]
end
EOF
- name: Commit and push to tap
working-directory: tap
env:
VERSION: ${{ steps.meta.outputs.version }}
run: |
set -euo pipefail
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add "Casks/${CASK_NAME}.rb"
if git diff --cached --quiet; then
echo "Cask already up to date for ${VERSION} — nothing to commit."
exit 0
fi
git commit -m "Bump ${CASK_NAME} to ${VERSION}"
git push
+68 -32
View File
@@ -1,32 +1,68 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-electron
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
release/**
*.kiro/
# npx electron-builder --mac --win
# Playwright
test-results
playwright-report/
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-electron
dist-ssr
*.local
.env
# Native helper build outputs
/electron/native/wgc-capture/build/
/electron/native/screencapturekit/build/
/electron/native/screencapturekit/.build/
/electron/native/screencapturekit/.swiftpm/
/electron/native/bin/
# Native macOS generated files
DerivedData/
*.xcuserstate
xcuserdata/
# Editor directories and files
.vscode/*
.zed/
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
release/**
*.kiro/
.claude/
# npx electron-builder --mac --win
# Playwright
test-results
playwright-report/
# Vitest browser mode screenshots
__screenshots__/
# shell files
/shell.sh
# Nix
result
result-*
.direnv/
#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.
+111 -46
View File
@@ -1,46 +1,51 @@
> [!WARNING]
> This is very much in beta and might be buggy here and there (but hope you have a good experience!).
> 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://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://img.shields.io/discord/pHAUbcqNd?logo=discord&label=Discord&color=5865F2" 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, making beautiful product demos and walkthroughs, here's a free-to-use app for you. OpenScreen does not offer all Screen Studio features, but covers the basics well!
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.
OpenScreen is 100% free for personal and commercial use. Use it, modify it, distribute it. (Just be cool 😁 and give a shoutout if you feel like it !)
**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: 320px; margin-right: 12px;" />
<img src="public/preview4.png" alt="OpenScreen App Preview 4" style="height: 320px; 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 your whole screen or specific windows.
- Add Automatic zooms or manual zooms (customizable depth levels).
- Record microphone audio and system audio capture.
- Customize the duration and position of zooms however you please.
- Crop video recordings to hide parts.
- Choose between wallpapers, solid colors, gradients or a custom background.
- Motion blur for smoother pan and zoom effects.
- Add annotations (text, arrows, images).
- Trim sections of the clip.
- Customize speed at different segments.
- Export in different aspect ratios and resolutions.
- Record a specific window, or your whole screen.
- Record microphone and system audio.
- 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.
- 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.
- Languages supported: Arabic, English, Spanish, French, Italian, Japanese, Korean, Portuguese (Brazil), Russian, Turkish, Vietnamese, Simplified Chinese, and Traditional Chinese.
## Installation
@@ -48,7 +53,20 @@ Download the latest installer for your platform from the [GitHub Releases](https
### macOS
If you encounter issues with macOS Gatekeeper blocking the app (since it does not come with a developer certificate), you can bypass this by running the following command in your terminal after installation:
The easiest way to install on macOS is via [Homebrew](https://brew.sh):
```bash
brew install --cask siddharthvaddem/openscreen/openscreen
```
Brew automatically picks the right build for Apple Silicon or Intel, and verifies the download against a notarized signature so Gatekeeper won't block it.
To update later: `brew upgrade --cask openscreen`
To uninstall: `brew uninstall --cask openscreen` (add `--zap` to also remove app data)
#### Manual install (if you prefer)
If you'd rather grab the `.dmg` directly from the [Releases page](https://github.com/siddharthvaddem/openscreen/releases) and encounter Gatekeeper blocking the app, you can bypass it by running the following command in your terminal after installation:
```bash
xattr -rd com.apple.quarantine /Applications/Openscreen.app
@@ -58,46 +76,93 @@ 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/):
```bash
winget install SiddharthVaddem.OpenScreen
```
To update later: `winget upgrade SiddharthVaddem.OpenScreen`
To uninstall: `winget uninstall SiddharthVaddem.OpenScreen`
If you'd rather grab the `.exe` installer directly, download it from the [Releases page](https://github.com/siddharthvaddem/openscreen/releases).
### Linux
Download the `.AppImage` file from the releases page. Make it executable and run:
Three packages are published to the [Releases page](https://github.com/siddharthvaddem/openscreen/releases) for each version. Pick the one that matches your distro:
**Debian / Ubuntu / Pop!_OS (`.deb`)**
```bash
sudo apt install ./Openscreen-Linux-latest.deb
```
**Arch / Manjaro (`.pacman`)**
```bash
sudo pacman -U Openscreen-Linux-latest.pacman
```
**Any distro (`.AppImage`)**
```bash
chmod +x Openscreen-Linux-*.AppImage
./Openscreen-Linux-*.AppImage
```
**NixOS / Nix (flake)**
Try without installing:
```bash
nix run github:siddharthvaddem/openscreen
```
Install into your user profile:
```bash
nix profile install github:siddharthvaddem/openscreen
```
For a NixOS system config (flake):
```nix
{
inputs.openscreen.url = "github:siddharthvaddem/openscreen";
outputs = { nixpkgs, openscreen, ... }: {
nixosConfigurations.<host> = nixpkgs.lib.nixosSystem {
modules = [
openscreen.nixosModules.default
{ programs.openscreen.enable = true; }
];
};
};
}
```
For Home Manager, use `openscreen.homeManagerModules.default` with the same `programs.openscreen.enable = true;`.
You may need to grant screen recording permissions depending on your desktop environment.
**Note:** If the app fails to launch due to a "sandbox" error, run it with --no-sandbox:
**Sandbox error:** If the AppImage fails to launch with a "sandbox" error, run it with `--no-sandbox`:
```bash
./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 work).
- **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 works).
## 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).
---
_I'm new to open source, idk what I'm doing lol. If something is wrong please raise an issue 🙏_
## Contributing
Contributions are welcome! If you’d like to help out or see what’s currently being worked on, take a look at the open issues and the [project roadmap](https://github.com/users/siddharthvaddem/projects/3) to understand the current direction of the project and find ways to contribute.
## 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.
+1 -1
View File
@@ -1,5 +1,5 @@
{
"$schema": "https://biomejs.dev/schemas/2.3.13/schema.json",
"$schema": "https://biomejs.dev/schemas/2.4.12/schema.json",
"vcs": { "enabled": true, "clientKind": "git", "useIgnoreFile": true },
"files": { "ignoreUnknown": false, "includes": ["**", "!**/*.css"] },
"formatter": {
+39
View File
@@ -0,0 +1,39 @@
# Native Bridge Architecture
## Goal
Provide a single, resilient source of truth for platform-native capabilities while keeping Electron transport thin and renderer APIs unified.
## Layers
1. Native adapters
Platform-specific providers implement stable domain interfaces such as cursor telemetry or system asset discovery.
2. Main-process services
Services orchestrate adapters, own runtime state, and expose domain-level operations.
3. Unified IPC transport
Renderer code talks to a single `native-bridge:invoke` channel using versioned contracts.
4. Renderer client
React code should consume `src/native/client.ts` rather than binding directly to ad hoc Electron APIs.
## Principles
- Single source of truth: runtime-native state lives in the Electron main process.
- Capability-first: renderer can query support before attempting native behavior.
- Versioned contracts: requests and responses are explicit and evolve predictably.
- Resilience: every response uses a consistent result envelope with stable error codes.
## Current rollout
This repository now contains the initial scaffold:
- shared contracts in `src/native/contracts.ts`
- renderer SDK in `src/native/client.ts`
- main-process state store in `electron/native-bridge/store.ts`
- cursor telemetry adapter in `electron/native-bridge/cursor/telemetryCursorAdapter.ts`
- domain services in `electron/native-bridge/services/*`
- unified handler registration in `electron/ipc/nativeBridge.ts`
The legacy `window.electronAPI` surface still exists for backward compatibility. New native-facing features should prefer the unified bridge client.
@@ -0,0 +1,210 @@
# macOS Native Recorder Roadmap
OpenScreen's macOS recorder should follow the same architecture boundaries as the Windows native recorder: Electron owns session orchestration and persistence, while a platform-native helper owns capture, timing, encoding, and platform-specific permissions.
This work is intentionally scoped as a macOS-only port. Windows native capture remains owned by the WGC helper, and Linux remains on the existing Electron path.
## Goals
- Capture displays and windows through ScreenCaptureKit.
- Exclude the real system cursor during capture when using the editable OpenScreen cursor overlay.
- Preserve the current high-quality cursor overlay path in preview and export.
- Capture macOS system audio through ScreenCaptureKit on supported macOS versions.
- Capture microphone audio through the same native timing domain where the OS supports it, or through an explicit companion path until it can be moved into the helper.
- Mix system audio and microphone audio into the primary MP4 without renderer-side track assembly.
- Capture webcam video natively and compose it into the helper-owned MP4 during the native-recording migration.
- Keep screen video, audio, webcam, and cursor aligned to one native timing origin.
- Package per-architecture helper binaries with macOS builds.
## Non-Goals
- Replacing the editor/export pipeline.
- Changing Windows native capture behavior.
- Adding Linux native capture.
- Shipping a silent fallback from native macOS capture to Electron capture when the user explicitly requested a native-only feature.
## Architecture
The renderer keeps the existing recording controls. On macOS, `useScreenRecorder` should eventually send a complete recording request to Electron instead of assembling display, audio, microphone, webcam, and cursor streams in the browser.
Electron owns the native recording session:
- resolves the selected display/window source;
- resolves output paths;
- starts cursor telemetry capture when editable cursor mode is selected;
- starts the ScreenCaptureKit helper process;
- sends pause/resume/stop/cancel commands;
- writes `RecordingSession` manifests;
- reports explicit errors when a macOS-native capability is unavailable.
The helper owns macOS media capture:
- ScreenCaptureKit display/window frames;
- ScreenCaptureKit system audio where supported;
- microphone capture or helper-owned companion audio capture;
- webcam capture and initial picture-in-picture composition;
- AVFoundation/VideoToolbox encoding and muxing;
- stream timestamp normalization.
## Helper Contract V1
The helper receives a single JSON argument:
```json
{
"schemaVersion": 1,
"recordingId": 1234567890,
"source": {
"type": "display",
"sourceId": "screen:0:0",
"displayId": 1,
"windowId": null,
"bounds": { "x": 0, "y": 0, "width": 1920, "height": 1080 }
},
"video": {
"fps": 60,
"width": 1920,
"height": 1080,
"bitrate": 18000000,
"hideSystemCursor": true
},
"audio": {
"system": { "enabled": true },
"microphone": {
"enabled": true,
"deviceId": "default",
"deviceName": "MacBook Pro Microphone",
"gain": 1.4
}
},
"webcam": {
"enabled": true,
"deviceId": "default",
"deviceName": "FaceTime HD Camera",
"width": 1280,
"height": 720,
"fps": 30
},
"cursor": {
"mode": "editable-overlay"
},
"outputs": {
"screenPath": "/Users/me/Library/Application Support/openscreen/recordings/recording-123.mp4",
"manifestPath": "/Users/me/Library/Application Support/openscreen/recordings/recording-123.session.json"
}
}
```
The helper emits newline-delimited JSON events to stdout:
```json
{ "event": "ready", "schemaVersion": 1 }
{ "event": "recording-started", "timestampMs": 1234567890 }
{ "event": "warning", "code": "microphone-unavailable", "message": "..." }
{ "event": "recording-stopped", "screenPath": "..." }
{ "event": "error", "code": "screen-permission-denied", "message": "..." }
```
## Implementation Phases
Current PR status: macOS screen/window capture routes through the ScreenCaptureKit helper when it is available so editable-cursor recordings can hide the system cursor. The helper now writes ScreenCaptureKit system audio into the primary MP4 and attempts runtime-gated native microphone capture on macOS versions that expose ScreenCaptureKit microphone output. Webcam capture is currently an Electron-recorded sidecar attached to the same recording session; native AVFoundation webcam composition remains the target end state.
### 1. Native Session Boundary
- Add a structured macOS native recording request type.
- Add a macOS helper resolver and build script placeholders.
- Keep the helper contract process-based, matching the Windows helper boundary.
- Do not route production macOS recording through this helper until the helper is available and validated.
Acceptance:
- TypeScript build passes.
- The macOS helper path and request contract are documented and testable without affecting Windows/Linux behavior.
### 2. ScreenCaptureKit Display Capture
- Implement a Swift helper using ScreenCaptureKit.
- Select display captures by `displayId`.
- Encode H.264 MP4 through AVFoundation/VideoToolbox.
- Set `showsCursor = false` when editable cursor overlay mode is selected.
Acceptance:
- Display-only recording produces a valid MP4.
- The real cursor is not baked into editable-cursor recordings.
### 3. ScreenCaptureKit Window Capture
- Resolve Electron `window:*` selections to ScreenCaptureKit window ids.
- Capture `SCContentFilter(desktopIndependentWindow:)`.
- Handle closed/minimized/protected windows with explicit errors.
- Keep window selection and capture source resolution in Electron/main, not the renderer.
Acceptance:
- Capturing a normal app window works with cursor/audio/webcam disabled.
- Unsupported windows return clear native errors.
### 4. System Audio
- Enable ScreenCaptureKit system audio on supported macOS versions.
- Keep audio format and timing owned by the helper.
- Encode or mux AAC audio into the primary MP4.
Acceptance:
- System-audio-only recordings produce a valid AAC track.
- Unsupported macOS versions return an explicit capability error.
### 5. Microphone
- Resolve the selected microphone device from the renderer-provided browser `deviceId` and user-visible label.
- Capture microphone audio in the helper timing domain.
- Apply OpenScreen microphone gain policy.
- Mix system and microphone audio before final AAC output.
Acceptance:
- Mic-only and mic-plus-system recordings produce a valid, balanced AAC track.
- Device selection honors the selected microphone, not only the default device.
### 6. Webcam Composition
- Capture the selected camera natively through AVFoundation.
- Match browser device id first where possible, then user-visible label.
- Compose an initial picture-in-picture overlay into the primary MP4.
- Hide webcam output until the first usable frame to avoid black startup flashes.
Acceptance:
- Native display/window recordings can include webcam without returning to Electron capture.
- Selected camera is honored.
### 7. Runtime Controls
- Add pause/resume commands to the helper.
- Add cancel command that removes partial outputs.
- Keep restart as stop-discard-start until the helper exposes a native restart operation.
Acceptance:
- Pause/resume keeps output duration coherent.
- Cancel leaves no stale media/session files.
### 8. Test Pipeline
- `npm run build:native:mac`: builds Swift helper binaries on macOS.
- `npm run test:sck-helper:mac`: display-only helper smoke test.
- `npm run test:sck-window:mac`: window capture smoke test.
- `npm run test:sck-audio:mac`: system audio smoke test when supported.
- `npm run test:sck-mic:mac`: microphone smoke test.
- `npm run test:sck-webcam:mac`: webcam smoke test when a webcam is available.
- Packaging check: confirms helpers are available under `electron/native/bin/darwin-${arch}` in packaged builds.
## SSOT Rules
- `src/lib/nativeMacRecording.ts` is the renderer/main TypeScript request contract.
- This document is the feature-level contract and phase checklist.
- The Swift helper owns ScreenCaptureKit/AVFoundation media timing.
- Electron owns output paths, session manifests, and selected source/device resolution.
- Renderer code must use existing hooks/client APIs and should not bind directly to helper process details.
@@ -0,0 +1,248 @@
# Windows Native Recorder Roadmap
OpenScreen's Windows recorder should be owned by one native backend. Electron capture can remain available for non-Windows platforms and temporary developer diagnostics, but Windows production recording should not silently fall back to `getDisplayMedia` / `MediaRecorder`.
## Goals
- Capture displays and windows through Windows Graphics Capture (WGC).
- Render the native Windows cursor as OpenScreen's high-quality scalable cursor overlay.
- Capture system audio through WASAPI loopback.
- Capture microphone audio through WASAPI.
- Mix system audio and microphone audio into the primary screen recording.
- Capture webcam video natively and compose it into the Windows helper MP4 during the native-recording migration.
- Keep preview/export aligned because screen video, audio, webcam, and cursor share one native timing origin.
- Keep exported MP4s Windows-friendly: H.264 video plus AAC audio. Opus-in-MP4 is not an acceptable Windows export target.
- Package the native helper with the Windows app.
## Non-Goals
- Replacing the editor/export pipeline.
- Replacing the editor/export pipeline. A later pass can reintroduce a separate editable native `webcamVideoPath`; the current Windows-native milestone prioritizes a helper-owned multi-flux MP4 with deterministic screen/audio/mic/webcam sync.
- Adding a native fallback for macOS or Linux in this branch.
## Target Architecture
The renderer keeps the existing recording controls. On Windows, `useScreenRecorder` sends a complete recording request to Electron and does not assemble Windows `MediaStream` tracks with `MediaRecorder`.
Electron owns the native recording session:
- resolves the selected source;
- resolves output paths;
- starts cursor sampling;
- starts the helper process;
- sends pause/resume/stop/cancel commands;
- writes `RecordingSession` manifests;
- reports explicit errors when a Windows-native capability is unavailable.
The helper owns Windows media capture:
- WGC screen/window frames;
- WASAPI system loopback;
- WASAPI microphone input;
- Media Foundation webcam capture;
- DirectShow webcam fallback for virtual cameras not visible to Media Foundation;
- Media Foundation encoding/muxing;
- stream timestamp normalization.
## Helper Contract V2
The helper receives a single JSON argument:
```json
{
"schemaVersion": 2,
"recordingId": 1234567890,
"source": {
"type": "display",
"sourceId": "screen:0:0",
"displayId": 123,
"windowHandle": null,
"bounds": { "x": 0, "y": 0, "width": 1920, "height": 1080 }
},
"video": {
"fps": 60,
"width": 1920,
"height": 1080,
"bitrate": 18000000
},
"audio": {
"system": { "enabled": true },
"microphone": { "enabled": true, "deviceId": "default", "gain": 1.4 }
},
"webcam": {
"enabled": true,
"deviceId": "default",
"deviceName": "Camera (NVIDIA Broadcast)",
"width": 1280,
"height": 720,
"fps": 30,
"bitrate": 18000000
},
"outputs": {
"screenPath": "C:\\Users\\me\\recording-123.mp4",
"manifestPath": "C:\\Users\\me\\recording-123.session.json"
}
}
```
The helper emits newline-delimited JSON events to stdout:
```json
{ "event": "ready", "schemaVersion": 2 }
{ "event": "recording-started", "timestampMs": 1234567890 }
{ "event": "warning", "code": "audio-device-unavailable", "message": "..." }
{ "event": "recording-stopped", "screenPath": "..." }
{ "event": "error", "code": "unsupported-window-source", "message": "..." }
```
During migration, Electron also accepts the current textual helper messages so existing display-only smoke tests keep working.
## Implementation Phases
### 1. Native Session Boundary
- Add a structured Windows native recording request type.
- Pass source kind, audio flags, microphone device, webcam flags, and output paths into the helper.
- On Windows, do not silently fall back to Electron capture. If the helper is unavailable or a native feature is missing, show a clear error.
- Keep Electron fallback only for non-Windows and optional developer diagnostics.
Acceptance:
- Display-only recording still works.
- Enabling an unsupported native feature returns an explicit native error instead of recording through Electron.
### 2. WASAPI System Audio
Status: initial implementation landed. The helper captures the default render endpoint with WASAPI loopback, passes the runtime mix format into `MFEncoder`, and muxes AAC audio into the primary MP4. Long-run drift correction and explicit silence insertion remain follow-up hardening work.
- Add `WasapiLoopbackCapture`.
- Capture the default render endpoint in shared loopback mode.
- Keep `WasapiLoopbackCapture` responsible only for device activation, packet capture, and packet timestamps.
- Keep `MFEncoder` responsible for all Media Foundation stream definitions and muxing.
- Feed the endpoint mix format into `MFEncoder` as the single source of truth for audio stream shape: sample rate, channel count, bits per sample, block alignment, average bytes/sec, and subtype (`PCM` or `Float`).
- Encode the primary screen MP4 with H.264 video and AAC audio through one `IMFSinkWriter`.
- Timestamp audio from the captured frame count in 100ns units. The first implementation uses the WASAPI packet timeline; later drift correction will add explicit silence or resampling if long recordings show measurable clock skew.
- Treat microphone mixing as a later phase. System loopback must land first without introducing renderer-side audio code.
Acceptance:
- Screen MP4 has an AAC audio track when system audio is enabled.
- A 5-minute recording has audio/video duration drift below one frame.
SSOT rules for this phase:
- `src/lib/nativeWindowsRecording.ts` is the renderer/main TypeScript request contract.
- `docs/engineering/windows-native-recorder-roadmap.md` is the feature-level contract and phase checklist.
- `WgcSession::captureWidth()/captureHeight()` is the encoded screen frame size until a dedicated native scaling stage exists.
- `WasapiLoopbackCapture::inputFormat()` is the runtime audio format source used by `MFEncoder`.
- The renderer passes both the browser webcam `deviceId` and selected display label as `deviceName`; `electron/native/wgc-capture/src/webcam_capture.*` is the only place that maps those values to Media Foundation devices.
- Electron resolves the selected label to a DirectShow filter CLSID once and passes it as `webcamDirectShowClsid`; the helper must not independently guess among DirectShow filters.
- No duplicated hard-coded audio format assumptions in `main.cpp`.
### 3. WASAPI Microphone
Status: initial implementation in progress. The helper can open the default WASAPI capture endpoint, apply the OpenScreen microphone gain, encode mic-only audio, and mix system loopback plus microphone through a single queued `AudioMixer` timeline when both endpoints expose the same runtime format. Audio endpoints are warmed before WGC starts, the mixer drops pre-roll and begins its paced timeline on the first encoded video frame, then cuts queued tail audio on stop so the MP4 does not drift past the video. Browser `deviceId` to MMDevice id mapping, resampling between mismatched endpoint formats, and drift correction remain follow-up hardening work.
- Add microphone device enumeration and stable device-id mapping.
- Capture selected/default microphone through WASAPI.
- Apply OpenScreen's current mic gain policy.
- Mix microphone and system audio before AAC encoding.
Acceptance:
- Mic-only, system-only, and mixed audio recordings produce a valid AAC track.
- Device unplug/permission failure produces an explicit error or warning.
### 4. Webcam Capture
- Add Media Foundation webcam source reader.
- Select requested dimensions/fps or the nearest format accepted by Media Foundation.
- Convert webcam samples to BGRA and compose them into the primary helper MP4 as an initial bottom-right picture-in-picture overlay.
- Ignore black webcam warmup frames and keep the overlay hidden until the first visible frame is available, so virtual cameras do not flash a black picture-in-picture rectangle at recording start.
- Keep the helper process as the SSOT for screen/window, WASAPI system audio, microphone, webcam, and mux timing.
- Match the requested webcam through Media Foundation friendly names first, then browser device ids/symbolic links, so UI selection remains stable across Chromium and Windows native device namespaces.
- Use the Electron-resolved DirectShow CLSID when the selected virtual camera, for example NVIDIA Broadcast, is registered for DirectShow but absent from Media Foundation enumeration.
- Later: promote the same webcam capture source to a separate editable native `webcamVideoPath` if product requirements need post-recording layout edits.
Acceptance:
- Native display/window recordings can include webcam without returning to Electron capture.
- `npm run test:wgc-webcam:win` validates the helper path when a webcam is available and skips explicitly when no webcam device exists.
- Combined webcam + system audio + microphone produces one MP4 with H.264 video and AAC audio.
### 5. Native Window Capture
Status: initial implementation in progress. Electron parses the `window:<HWND>:...` desktop source id through the shared native Windows recording contract and passes `windowHandle` to the helper. The helper resolves the `HWND`, validates it with `IsWindow`, and creates the WGC item with `CreateForWindow(HWND)`. Resize/minimize/move hardening and protected-window diagnostics remain follow-up work.
- Resolve Electron `window:*` selections to an `HWND`.
- Use WGC `CreateForWindow(HWND)`.
- Handle window close, minimize, resize, DPI scaling, and monitor moves.
- Return clear errors for unsupported protected windows.
Acceptance:
- Capturing a normal app window works with cursor/audio/mic/webcam.
- Window resize and movement do not corrupt the recording.
### 6. Runtime Controls
- Add pause/resume commands to the helper.
- Add cancel command that removes partial screen/webcam outputs.
- Keep restart as stop-discard-start from Electron until the helper supports a native restart event.
Acceptance:
- Pause/resume keeps preview duration coherent.
- Cancel leaves no stale media/session/cursor files.
### 7. Test Pipeline
- `npm run test:wgc-helper:win`: display-only helper smoke test.
- `npm run test:wgc-audio:win`: validates AAC track presence and duration.
- `npm run test:wgc-window:win`: captures a fixture window by HWND.
- `npm run test:wgc-webcam:win`: validates webcam output when a webcam is available, otherwise skips explicitly.
- Packaging check: confirms the helper is in `app.asar.unpacked`.
- Export check: exported MP4s generated from native recordings keep an AAC audio track when the source has audio.
- `npm run test:wgc-mic:win`: validates default-microphone capture writes an AAC track when an input endpoint is available.
- `npm run test:wgc-mixed-audio:win`: validates system loopback plus microphone writes one mixed AAC track when endpoint formats are compatible.
## Backlog
### Native Cursor Click Bounce Is Not Visibly Applied
Status: open. Do not treat Windows native cursor `Click Bounce` as shipped.
Problem:
- The cursor settings UI exposes `Size`, `Smoothing`, `Motion Blur`, and `Click Bounce`.
- On Windows native cursor recordings, `Size`, `Smoothing`, and `Motion Blur` are visibly applied in preview/export.
- `Click Bounce` still has no visible effect in manual packaged-app testing, even after adding click-related sample metadata.
What has already been tried:
- Added `interactionType: "click" | "mouseup" | "move"` to native cursor samples.
- Added polling-based left-button state through `GetAsyncKeyState`.
- Added the `GetAsyncKeyState` low-bit path to catch quick clicks between samples.
- Added a PowerShell/C# `WH_MOUSE_LL` mouse hook experiment and launched the sampler through a temporary `.ps1` file to avoid Windows command-line length limits.
- Updated `npm run test:cursor-native:win` so the diagnostic can observe a synthetic short click and emit `clickSampleCount`.
Current diagnosis:
- The diagnostic can observe synthetic click events, but this has not translated into a visible `Click Bounce` effect in the real packaged app.
- The test currently proves that some click metadata can be recorded, not that the full OpenScreen record -> preview -> export path displays a bounce at the expected time.
- The current native implementation may be animating from metadata that is not present in the real recording session, may be using the wrong timestamp origin, or may be applying a scale change too subtle to notice on the DOM/native cursor path.
Next investigation when resumed:
- Inspect the actual `.cursor.json`/session sidecar generated by a packaged-app manual recording and confirm whether real clicks produce `interactionType: "click"` at the right `timeMs`.
- Add a targeted end-to-end fixture that records a known click, loads the generated project, and asserts the preview/export cursor scale changes across adjacent frames.
- Compare the native DOM cursor path against the older `PixiCursorOverlay` click visual state and decide whether native cursor bounce should be a scale-only animation, an additional click ring, or a short explicit keyframe animation independent of sample cadence.
- If event capture remains unreliable in the PowerShell sampler, move click events into a small native cursor helper instead of PowerShell/C# script injection.
## Ship Criteria
- Windows display capture works with cursor, system audio, microphone, and webcam.
- Windows window capture works with cursor, system audio, microphone, and webcam.
- Preview and export show no cursor position drift.
- Preview and export show no measurable audio/video/webcam drift.
- Windows production builds do not depend on Electron capture fallback.
+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.
+130
View File
@@ -0,0 +1,130 @@
# Windows native cursor test pipeline
This branch includes two Windows-focused diagnostics for fast iteration on native cursor capture and rendering. They are intentionally local developer tools: they create short videos and JSON reports so cursor changes can be inspected without doing a full manual record/edit/export cycle.
## Native sampler diagnostic
```powershell
npm run test:cursor-native:win
```
This script does not launch OpenScreen. It:
- starts a Windows `GetCursorInfo` sampler
- moves the real OS pointer with `SetCursorPos`
- captures native cursor handles, hotspots, assets, and standard `IDC_*` cursor types
- writes normalized `CursorRecordingData`
- generates an abstract preview video
- generates a real-screen preview video using screenshots of the current desktop
The output directory is printed in the command result, for example:
```text
C:\Users\<user>\AppData\Local\Temp\openscreen-cursor-native-...
```
Useful files:
- `report.json`: sample counts, asset counts, cursor handles, and generated artifact paths
- `cursor-recording-data.json`: sidecar-compatible cursor data
- `preview.webm`: abstract path/asset/hotspot preview
- `real-capture-preview.webm`: real desktop screenshot background with reconstructed cursor overlay
- `assets/*.png`: raw cursor bitmaps captured from Windows
Environment overrides:
```powershell
$env:CURSOR_TEST_DURATION_MS = "3000"
$env:CURSOR_TEST_SAMPLE_INTERVAL_MS = "16"
$env:CURSOR_TEST_SCREEN_FRAME_INTERVAL_MS = "80"
$env:CURSOR_TEST_OUTPUT_DIR = "C:\temp\openscreen-cursor-test"
npm run test:cursor-native:win
```
## OpenScreen preview capture
```powershell
npm run capture:openscreen-preview
```
This script launches the real Electron app, injects a fixture video plus cursor sidecar data, opens the editor, captures frames from the actual OpenScreen preview UI, and encodes them into a WebM.
By default it uses the latest `cursor-recording-data.json` generated by `npm run test:cursor-native:win`. To force a specific sidecar:
```powershell
$env:CURSOR_RECORDING_DATA_PATH = "C:\path\to\cursor-recording-data.json"
npm run capture:openscreen-preview
```
Useful environment overrides:
```powershell
$env:OPENSCREEN_PREVIEW_SKIP_BUILD = "true"
$env:OPENSCREEN_PREVIEW_FRAME_COUNT = "120"
$env:OPENSCREEN_PREVIEW_FPS = "30"
$env:OPENSCREEN_PREVIEW_OUTPUT_DIR = "C:\temp\openscreen-preview"
npm run capture:openscreen-preview
```
Useful files:
- `openscreen-preview.webm`: video of the real OpenScreen editor preview
- `frames/*.png`: captured preview frames
- `report.json`: fixture paths, source sidecar, frame count, and output path
## What these tests validate
Together, the scripts make it quick to inspect:
- whether Windows cursor samples are visible and continuous
- whether native hotspots stay anchored when scaling to `3x`
- whether standard Windows cursors are recognized via `IDC_*`
- whether high-quality SVG cursor replacements follow the native hotspot
- whether the real OpenScreen preview renders the same cursor behavior as the diagnostic pipeline
They are not a full substitute for an end-to-end manual recording pass. Before shipping cursor changes, also test a real capture session and export from the packaged app.
## Known Gap
Windows native cursor `Click Bounce` is currently backlogged. `Size`, `Smoothing`, and `Motion Blur` can be validated through preview/export, but `Click Bounce` has not shown a visible effect in packaged-app manual testing. The current diagnostic can observe synthetic click metadata, but that is not enough to validate the real OpenScreen record -> preview -> export path.
Track the open item in `docs/engineering/windows-native-recorder-roadmap.md` under `Native Cursor Click Bounce Is Not Visibly Applied`.
## Native Windows capture backend
The app now routes Windows recordings through an external WGC helper instead of Electron `getDisplayMedia`. This is meant to remove the coordinate and clock split that made the reconstructed cursor drift in the preview/export path.
Current native availability rules:
- Windows 10 build 19041 or newer
- a helper executable is available
The helper currently implements display/window video capture, system audio loopback, default microphone capture, Media Foundation webcam capture, and DirectShow fallback for selected virtual cameras such as NVIDIA Broadcast. Webcam frames are composed into the primary MP4 as a bottom-right picture-in-picture overlay, and black webcam warmup frames are ignored until the first visible frame is available.
Build OpenScreen's helper locally:
```powershell
npm run build:native:win
```
Smoke-test the helper directly:
```powershell
npm run test:wgc-helper:win
npm run test:wgc-helper:win -- --capture-cursor
npm run test:wgc-window:win
npm run test:wgc-audio:win
npm run test:wgc-mic:win
npm run test:wgc-mixed-audio:win
npm run test:wgc-webcam:win
```
For local diagnostics with another compatible helper, point OpenScreen at that executable:
```powershell
$env:OPENSCREEN_WGC_CAPTURE_EXE = "C:\path\to\wgc-capture.exe"
npm run build-vite
npm run dev
```
The helper receives one JSON config argument, emits JSON lifecycle events, prints the legacy `Recording started` marker, accepts `stop` on stdin, and prints `Recording stopped. Output path: <path>`. See `electron/native/README.md` for the exact contract and build output paths.
+149
View File
@@ -0,0 +1,149 @@
# Writing Tests
This project uses [Vitest](https://vitest.dev/) for both unit/integration tests and browser tests. There are two separate configs — each targets a different set of files.
## Unit tests
**Config:** `vitest.config.ts`
**Runs in:** jsdom (simulated DOM, no real browser)
**File pattern:** `src/**/*.test.ts` — anything that does **not** end in `.browser.test.ts`
**CI command:** `npm run test`
Use unit tests for pure logic, utility functions, data transformations, and anything that doesn't need real browser APIs (Canvas, WebCodecs, MediaRecorder, etc.).
### File placement
Co-locate the test file next to the source file, or put it in a `__tests__/` folder in the same directory.
```
src/lib/compositeLayout.ts
src/lib/compositeLayout.test.ts # co-located
src/i18n/__tests__/tutorialHelpTranslations.test.ts # grouped
```
### Example
```ts
import { describe, expect, it } from "vitest";
import { computeCompositeLayout } from "./compositeLayout";
describe("computeCompositeLayout", () => {
it("anchors the overlay in the lower-right corner", () => {
const layout = computeCompositeLayout({
canvasSize: { width: 1920, height: 1080 },
screenSize: { width: 1920, height: 1080 },
webcamSize: { width: 1280, height: 720 },
});
expect(layout).not.toBeNull();
expect(layout!.webcamRect!.x).toBeGreaterThan(1920 / 2);
expect(layout!.webcamRect!.y).toBeGreaterThan(1080 / 2);
});
});
```
### Path aliases
The `@/` alias resolves to `src/`. Use it for imports that would otherwise need long relative paths.
```ts
import { SUPPORTED_LOCALES } from "@/i18n/config";
```
### Running locally
```bash
npm run test # run once
npm run test:watch # watch mode
```
---
## Browser tests
**Config:** `vitest.browser.config.ts`
**Runs in:** real Chromium via Playwright (headless)
**File pattern:** `src/**/*.browser.test.ts`
**CI commands:** `npm run test:browser:install` then `npm run test:browser`
Use browser tests when the code under test depends on real browser APIs that jsdom doesn't implement: `VideoDecoder`, `VideoEncoder`, `MediaRecorder`, `OffscreenCanvas`, `WebGL`, etc.
### File placement
Name the file `<subject>.browser.test.ts` and place it next to the source file.
```
src/lib/exporter/videoExporter.ts
src/lib/exporter/videoExporter.browser.test.ts
```
### Loading fixture assets
Static assets (video files, images) live in `tests/fixtures/`. Import them with Vite's `?url` suffix so Vite serves them through the dev server.
```ts
import sampleVideoUrl from "../../../tests/fixtures/sample.webm?url";
```
### Example
```ts
import { describe, expect, it } from "vitest";
import sampleVideoUrl from "../../../tests/fixtures/sample.webm?url";
import { VideoExporter } from "./videoExporter";
describe("VideoExporter (real browser)", () => {
it("exports a valid MP4 blob from a real video", async () => {
const exporter = new VideoExporter({
videoUrl: sampleVideoUrl,
width: 320,
height: 180,
frameRate: 15,
bitrate: 1_000_000,
wallpaper: "#1a1a2e",
zoomRegions: [],
showShadow: false,
shadowIntensity: 0,
showBlur: false,
cropRegion: { x: 0, y: 0, width: 1, height: 1 },
});
const result = await exporter.export();
expect(result.success, result.error).toBe(true);
expect(result.blob).toBeInstanceOf(Blob);
});
});
```
### Timeouts
Browser tests have a default timeout of 120 seconds per test and 30 seconds per hook (set in `vitest.browser.config.ts`). Export operations are slow — prefer small fixture dimensions (320×180) and low bitrates to keep tests fast.
### Running locally
First install the browser (one-time):
```bash
npm run test:browser:install
```
Then run the tests:
```bash
npm run test:browser
```
---
## Choosing the right type
| Situation | Use |
|---|---|
| Pure function / data transformation | Unit test |
| i18n key coverage | Unit test |
| React hook logic (no real browser APIs) | Unit test |
| `VideoDecoder` / `VideoEncoder` / `MediaRecorder` | Browser test |
| `OffscreenCanvas` / WebGL / Pixi.js rendering | Browser test |
| File export producing a real `Blob` | Browser test |
+67 -30
View File
@@ -3,9 +3,18 @@
"$schema": "https://raw.githubusercontent.com/electron-userland/electron-builder/master/packages/app-builder-lib/scheme.json",
"appId": "com.siddharthvaddem.openscreen",
"asar": true,
// .node binaries can't be dlopen'd from inside an asar — must live unpacked.
"asarUnpack": [
"**/*.node"
],
"productName": "Openscreen",
// Fetch the auto-caption model + ORT wasm into caption-assets/ before packaging (idempotent).
"beforePack": "scripts/before-pack.cjs",
"npmRebuild": true,
"buildDependenciesFromSource": true,
// sharp ships ABI-stable (napi) prebuilt binaries with bundled libvips. Building it from source
// needs a system libvips we don't provide and breaks on CI/local ("vips-cpp.42 not found"), so we
// let electron-builder use the prebuilt instead of recompiling.
"buildDependenciesFromSource": false,
"compression": "normal",
"directories": {
"output": "release/${version}"
@@ -20,16 +29,28 @@
"!CONTRIBUTING.md",
"!LICENSE"
],
"extraResources": [
{
"from": "public/wallpapers",
"to": "assets/wallpapers"
}
],
"publish": [{"provider": "github"}],
"mac": {
"hardenedRuntime": false,
// 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"
}
],
"mac": {
"notarize": false,
"hardenedRuntime": true,
"entitlements": "macos.entitlements",
"entitlementsInherit": "macos.entitlements",
"target": [
{
"target": "dmg",
@@ -38,30 +59,46 @@
],
"icon": "icons/icons/mac/icon.icns",
"artifactName": "${productName}-Mac-${arch}-${version}-Installer.${ext}",
"extendInfo": {
"NSAudioCaptureUsageDescription": "OpenScreen needs audio capture permission to record system audio.",
"NSMicrophoneUsageDescription": "OpenScreen needs microphone access to record voice audio.",
"NSCameraUsageDescription": "OpenScreen needs camera access to record webcam video.",
"NSCameraUseContinuityCameraDeviceType": true,
"com.apple.security.device.audio-input": true
}
"extraResources": [
{
"from": "electron/native/bin",
"to": "electron/native/bin",
"filter": ["darwin-*/*"]
}
],
"extendInfo": {
"NSAudioCaptureUsageDescription": "OpenScreen needs audio capture permission to record system audio.",
"NSMicrophoneUsageDescription": "OpenScreen needs microphone access to record voice audio.",
"NSCameraUsageDescription": "OpenScreen needs camera access to record webcam video.",
"NSScreenCaptureUsageDescription": "OpenScreen needs screen recording permission to detect and capture windows.",
"NSCameraUseContinuityCameraDeviceType": true
}
},
"linux": {
"target": [
"AppImage"
"AppImage",
"deb",
"pacman"
],
"icon": "icons/icons/png",
"artifactName": "${productName}-Linux-${version}.${ext}",
"category": "AudioVideo"
},
"win": {
"target": [
"nsis"
],
"icon": "icons/icons/win/icon.ico"
},
"nsis": {
"oneClick": false,
"allowToChangeInstallationDirectory": true
}
}
"win": {
"target": [
"nsis"
],
"icon": "icons/icons/win/icon.ico",
"extraResources": [
{
"from": "electron/native/bin",
"to": "electron/native/bin",
"filter": ["win32-*/*"]
}
]
},
"nsis": {
"oneClick": false,
"allowToChangeInstallationDirectory": true
}
}
+162 -7
View File
@@ -24,9 +24,23 @@ declare namespace NodeJS {
// Used in Renderer process, expose in `preload.ts`
interface Window {
electronAPI: {
invokeNativeBridge: <TData = unknown>(
request: import("../src/native/contracts").NativeBridgeRequest,
) => Promise<import("../src/native/contracts").NativeBridgeResponse<TData>>;
getSources: (opts: Electron.SourcesOptions) => Promise<ProcessedDesktopSource[]>;
switchToEditor: () => Promise<void>;
openSourceSelector: () => Promise<void>;
switchToHud: () => Promise<void>;
startNewRecording: () => Promise<{ success: boolean; error?: string }>;
openSourceSelector: () => Promise<{
opened: boolean;
reason?: string;
access?: {
success: boolean;
granted: boolean;
status: string;
error?: string;
};
}>;
selectSource: (source: ProcessedDesktopSource) => Promise<ProcessedDesktopSource | null>;
getSelectedSource: () => Promise<ProcessedDesktopSource | null>;
requestCameraAccess: () => Promise<{
@@ -35,7 +49,19 @@ interface Window {
status: string;
error?: string;
}>;
getAssetBasePath: () => Promise<string | null>;
requestScreenAccess: () => Promise<{
success: boolean;
granted: boolean;
status: string;
error?: string;
}>;
requestNativeMacCursorAccess: () => Promise<{
success: boolean;
granted: boolean;
status: string;
error?: string;
}>;
assetBaseUrl: string;
storeRecordedVideo: (
videoData: ArrayBuffer,
fileName: string,
@@ -55,25 +81,116 @@ 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;
message?: string;
error?: string;
}>;
setRecordingState: (recording: boolean) => Promise<void>;
setRecordingState: (
recording: boolean,
recordingId?: number,
cursorCaptureMode?: import("../src/lib/recordingSession").CursorCaptureMode,
) => Promise<void>;
isNativeWindowsCaptureAvailable: () => Promise<{
success: boolean;
available: boolean;
helperPath?: string;
reason?: string;
error?: string;
}>;
isNativeMacCaptureAvailable: () => Promise<{
success: boolean;
available: boolean;
helperPath?: string;
reason?: "unsupported-platform" | "missing-helper" | string;
error?: string;
}>;
startNativeWindowsRecording: (
request: import("../src/lib/nativeWindowsRecording").NativeWindowsRecordingRequest,
) => Promise<import("../src/lib/nativeWindowsRecording").NativeWindowsRecordingStartResult>;
stopNativeWindowsRecording: (discard?: boolean) => Promise<{
success: boolean;
path?: string;
session?: import("../src/lib/recordingSession").RecordingSession;
message?: string;
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>;
pauseNativeMacRecording: () => Promise<{
success: boolean;
error?: string;
}>;
resumeNativeMacRecording: () => Promise<{
success: boolean;
error?: string;
}>;
stopNativeMacRecording: (discard?: boolean) => Promise<{
success: boolean;
path?: string;
session?: import("../src/lib/recordingSession").RecordingSession;
message?: string;
discarded?: boolean;
error?: string;
}>;
attachNativeMacWebcamRecording: (payload: {
screenVideoPath: string;
recordingId: number;
webcam: import("../src/lib/recordingSession").RecordedVideoAssetInput;
cursorCaptureMode?: import("../src/lib/recordingSession").CursorCaptureMode;
}) => Promise<{
success: boolean;
path?: string;
session?: import("../src/lib/recordingSession").RecordingSession;
message?: string;
error?: string;
}>;
discardCursorTelemetry: (recordingId: number) => Promise<void>;
getCursorTelemetry: (videoPath?: string) => Promise<{
success: boolean;
samples: CursorTelemetryPoint[];
clicks: number[];
message?: string;
error?: string;
}>;
onStopRecordingFromTray: (callback: () => void) => () => void;
openExternalUrl: (url: string) => Promise<{ success: boolean; error?: string }>;
saveExportedVideo: (
videoData: ArrayBuffer,
pickExportSavePath: (
fileName: string,
) => Promise<{ success: boolean; path?: string; message?: string; canceled?: boolean }>;
exportFolder?: string,
) => Promise<{
success: boolean;
path?: string;
message?: string;
canceled?: boolean;
error?: string;
}>;
writeExportToPath: (
videoData: ArrayBuffer,
filePath: string,
) => Promise<{
success: boolean;
path?: string;
message?: string;
error?: string;
}>;
openVideoFilePicker: () => Promise<{ success: boolean; path?: string; canceled?: boolean }>;
setCurrentVideoPath: (path: string) => Promise<{ success: boolean }>;
setCurrentRecordingSession: (
@@ -94,6 +211,12 @@ interface Window {
message?: string;
error?: string;
}>;
preparePreviewAudioTrack: (filePath: string) => Promise<{
success: boolean;
path?: string | null;
message?: string;
error?: string;
}>;
clearCurrentVideoPath: () => Promise<{ success: boolean }>;
saveProjectFile: (
projectData: unknown,
@@ -106,7 +229,7 @@ interface Window {
canceled?: boolean;
error?: string;
}>;
loadProjectFile: () => Promise<{
loadProjectFile: (projectFolder?: string) => Promise<{
success: boolean;
path?: string;
project?: unknown;
@@ -122,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;
@@ -131,12 +265,33 @@ 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>;
onCountdownOverlayValue: (callback: (value: number | null) => void) => () => void;
setMicrophoneExpanded: (expanded: boolean) => void;
setHasUnsavedChanges: (hasChanges: boolean) => void;
onRequestSaveBeforeClose: (callback: () => Promise<boolean> | boolean) => () => void;
onRequestCloseConfirm: (callback: () => void) => () => void;
sendCloseConfirmResponse: (choice: "save" | "discard" | "cancel") => void;
setLocale: (locale: string) => Promise<void>;
saveDiagnostic: (payload: {
error: string;
stack?: string;
projectState: unknown;
logs: string[];
}) => Promise<{ success: boolean; path?: string; canceled?: boolean; error?: string }>;
};
}
+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();
}
+55 -3
View File
@@ -1,27 +1,79 @@
// Lightweight i18n for the Electron main process.
// Imports the same JSON translation files used by the renderer.
import commonAr from "../src/i18n/locales/ar/common.json";
import dialogsAr from "../src/i18n/locales/ar/dialogs.json";
import commonEn from "../src/i18n/locales/en/common.json";
import dialogsEn from "../src/i18n/locales/en/dialogs.json";
import commonEs from "../src/i18n/locales/es/common.json";
import dialogsEs from "../src/i18n/locales/es/dialogs.json";
import commonFr from "../src/i18n/locales/fr/common.json";
import dialogsFr from "../src/i18n/locales/fr/dialogs.json";
import commonIt from "../src/i18n/locales/it/common.json";
import dialogsIt from "../src/i18n/locales/it/dialogs.json";
import commonJa from "../src/i18n/locales/ja-JP/common.json";
import dialogsJa from "../src/i18n/locales/ja-JP/dialogs.json";
import commonKo from "../src/i18n/locales/ko-KR/common.json";
import dialogsKo from "../src/i18n/locales/ko-KR/dialogs.json";
import commonRu from "../src/i18n/locales/ru/common.json";
import dialogsRu from "../src/i18n/locales/ru/dialogs.json";
import commonTr from "../src/i18n/locales/tr/common.json";
import dialogsTr from "../src/i18n/locales/tr/dialogs.json";
import commonVi from "../src/i18n/locales/vi/common.json";
import dialogsVi from "../src/i18n/locales/vi/dialogs.json";
import commonZh from "../src/i18n/locales/zh-CN/common.json";
import dialogsZh from "../src/i18n/locales/zh-CN/dialogs.json";
import commonZhTw from "../src/i18n/locales/zh-TW/common.json";
import dialogsZhTw from "../src/i18n/locales/zh-TW/dialogs.json";
type Locale = "en" | "zh-CN" | "es";
type Locale =
| "en"
| "ar"
| "es"
| "fr"
| "it"
| "ja-JP"
| "ko-KR"
| "ru"
| "tr"
| "vi"
| "zh-CN"
| "zh-TW";
type Namespace = "common" | "dialogs";
type MessageMap = Record<string, unknown>;
const messages: Record<Locale, Record<Namespace, MessageMap>> = {
en: { common: commonEn, dialogs: dialogsEn },
"zh-CN": { common: commonZh, dialogs: dialogsZh },
ar: { common: commonAr, dialogs: dialogsAr },
es: { common: commonEs, dialogs: dialogsEs },
fr: { common: commonFr, dialogs: dialogsFr },
it: { common: commonIt, dialogs: dialogsIt },
"ja-JP": { common: commonJa, dialogs: dialogsJa },
"ko-KR": { common: commonKo, dialogs: dialogsKo },
ru: { common: commonRu, dialogs: dialogsRu },
tr: { common: commonTr, dialogs: dialogsTr },
vi: { common: commonVi, dialogs: dialogsVi },
"zh-CN": { common: commonZh, dialogs: dialogsZh },
"zh-TW": { common: commonZhTw, dialogs: dialogsZhTw },
};
let currentLocale: Locale = "en";
export function setMainLocale(locale: string) {
if (locale === "en" || locale === "zh-CN" || locale === "es") {
if (
locale === "en" ||
locale === "ar" ||
locale === "es" ||
locale === "fr" ||
locale === "it" ||
locale === "ja-JP" ||
locale === "ko-KR" ||
locale === "ru" ||
locale === "tr" ||
locale === "vi" ||
locale === "zh-CN" ||
locale === "zh-TW"
) {
currentLocale = locale;
}
}
+2552 -420
View File
File diff suppressed because it is too large Load Diff
+239
View File
@@ -0,0 +1,239 @@
import { ipcMain } from "electron";
import {
NATIVE_BRIDGE_CHANNEL,
NATIVE_BRIDGE_VERSION,
type NativeBridgeErrorCode,
type NativeBridgeRequest,
type NativeBridgeResponse,
type NativePlatform,
type ProjectFileResult,
type ProjectPathResult,
} from "../../src/native/contracts";
import type { CursorTelemetryLoadResult } from "../native-bridge/cursor/adapter";
import { TelemetryCursorAdapter } from "../native-bridge/cursor/telemetryCursorAdapter";
import { CursorService } from "../native-bridge/services/cursorService";
import { ProjectService } from "../native-bridge/services/projectService";
import { SystemService } from "../native-bridge/services/systemService";
import { NativeBridgeStateStore } from "../native-bridge/store";
export interface NativeBridgeContext {
getPlatform: () => NodeJS.Platform;
getCurrentProjectPath: () => string | null;
getCurrentVideoPath: () => string | null;
saveProjectFile: (
projectData: unknown,
suggestedName?: string,
existingProjectPath?: string,
) => 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;
resolveAssetBasePath: () => string | null;
resolveVideoPath: (videoPath?: string | null) => string | null;
loadCursorRecordingData: (
videoPath: string,
) => Promise<import("../../src/native/contracts").CursorRecordingData>;
loadCursorTelemetry: (videoPath: string) => Promise<CursorTelemetryLoadResult>;
}
function normalizePlatform(platform: NodeJS.Platform): NativePlatform {
if (platform === "darwin" || platform === "win32") {
return platform;
}
return "linux";
}
function createMeta(requestId?: string) {
return {
version: NATIVE_BRIDGE_VERSION,
requestId: requestId || `native-${Date.now()}`,
timestampMs: Date.now(),
} as const;
}
function createSuccessResponse<TData>(requestId: string | undefined, data: TData) {
return {
ok: true,
data,
meta: createMeta(requestId),
} satisfies NativeBridgeResponse<TData>;
}
function createErrorResponse(
requestId: string | undefined,
code: NativeBridgeErrorCode,
message: string,
retryable = false,
) {
return {
ok: false,
error: {
code,
message,
retryable,
},
meta: createMeta(requestId),
} satisfies NativeBridgeResponse;
}
function isBridgeRequest(value: unknown): value is NativeBridgeRequest {
if (!value || typeof value !== "object") {
return false;
}
const candidate = value as Partial<NativeBridgeRequest>;
return typeof candidate.domain === "string" && typeof candidate.action === "string";
}
export function registerNativeBridgeHandlers(context: NativeBridgeContext) {
ipcMain.removeHandler(NATIVE_BRIDGE_CHANNEL);
const platform = normalizePlatform(context.getPlatform());
const store = new NativeBridgeStateStore(platform);
const projectService = new ProjectService({
store,
getCurrentProjectPath: context.getCurrentProjectPath,
getCurrentVideoPath: context.getCurrentVideoPath,
saveProjectFile: context.saveProjectFile,
loadProjectFile: context.loadProjectFile,
loadCurrentProjectFile: context.loadCurrentProjectFile,
loadProjectFileFromPath: context.loadProjectFileFromPath,
setCurrentVideoPath: context.setCurrentVideoPath,
getCurrentVideoPathResult: context.getCurrentVideoPathResult,
clearCurrentVideoPath: context.clearCurrentVideoPath,
});
const cursorService = new CursorService({
store,
adapter: new TelemetryCursorAdapter({
loadRecordingData: context.loadCursorRecordingData,
resolveVideoPath: context.resolveVideoPath,
loadTelemetry: context.loadCursorTelemetry,
}),
});
const systemService = new SystemService({
store,
getPlatform: () => platform,
getAssetBasePath: context.resolveAssetBasePath,
getCursorCapabilities: () => cursorService.getCapabilities(),
});
ipcMain.handle(NATIVE_BRIDGE_CHANNEL, async (_, request: unknown) => {
if (!isBridgeRequest(request)) {
return createErrorResponse(undefined, "INVALID_REQUEST", "Invalid native bridge request.");
}
const requestId = request.requestId;
const domain = request.domain as string;
try {
switch (request.domain) {
case "system": {
const action = request.action as string;
switch (request.action) {
case "getPlatform":
return createSuccessResponse(requestId, systemService.getPlatform());
case "getAssetBasePath":
return createSuccessResponse(requestId, systemService.getAssetBasePath());
case "getCapabilities":
return createSuccessResponse(requestId, await systemService.getCapabilities());
default:
return createErrorResponse(
requestId,
"UNSUPPORTED_ACTION",
`Unsupported system action: ${action}`,
);
}
}
case "project": {
const action = request.action as string;
switch (request.action) {
case "getCurrentContext":
return createSuccessResponse(requestId, projectService.getCurrentContext());
case "saveProjectFile":
return createSuccessResponse(
requestId,
await projectService.saveProjectFile(
request.payload.projectData,
request.payload.suggestedName,
request.payload.existingProjectPath,
),
);
case "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,
await projectService.setCurrentVideoPath(request.payload.path),
);
case "getCurrentVideoPath":
return createSuccessResponse(requestId, projectService.getCurrentVideoPath());
case "clearCurrentVideoPath":
return createSuccessResponse(requestId, projectService.clearCurrentVideoPath());
default:
return createErrorResponse(
requestId,
"UNSUPPORTED_ACTION",
`Unsupported project action: ${action}`,
);
}
}
case "cursor": {
const action = request.action as string;
switch (request.action) {
case "getCapabilities":
return createSuccessResponse(requestId, await cursorService.getCapabilities());
case "getTelemetry":
return createSuccessResponse(
requestId,
await cursorService.getTelemetry(request.payload?.videoPath),
);
case "getRecordingData":
return createSuccessResponse(
requestId,
await cursorService.getRecordingData(request.payload?.videoPath),
);
default:
return createErrorResponse(
requestId,
"UNSUPPORTED_ACTION",
`Unsupported cursor action: ${action}`,
);
}
}
default:
return createErrorResponse(
requestId,
"UNSUPPORTED_ACTION",
`Unsupported bridge domain: ${domain}`,
);
}
} catch (error) {
return createErrorResponse(
requestId,
"INTERNAL_ERROR",
error instanceof Error ? error.message : "Unknown native bridge error.",
true,
);
}
});
}
+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) };
}
},
);
}
+251 -75
View File
@@ -4,7 +4,6 @@ import { fileURLToPath } from "node:url";
import {
app,
BrowserWindow,
dialog,
ipcMain,
Menu,
nativeImage,
@@ -12,19 +11,41 @@ import {
systemPreferences,
Tray,
} from "electron";
import { ShortcutBinding } from "../src/lib/shortcuts";
import {
loadAndRegisterGlobalShortcut,
registerOpenAppShortcut,
unregisterAllGlobalShortcuts,
} from "./globalShortcut";
import { mainT, setMainLocale } from "./i18n";
import { registerIpcHandlers } from "./ipc/handlers";
import { createEditorWindow, createHudOverlayWindow, createSourceSelectorWindow } from "./windows";
import { getSelectedDesktopSource, registerIpcHandlers } from "./ipc/handlers";
import {
createCountdownOverlayWindow,
createEditorWindow,
createHudOverlayWindow,
createSourceSelectorWindow,
} from "./windows";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// Use Screen & System Audio Recording permissions instead of CoreAudio Tap API on macOS.
// CoreAudio Tap requires NSAudioCaptureUsageDescription in the parent app's Info.plist,
// which doesn't work when running from a terminal/IDE during development, makes my life easier
// Use Screen & System Audio Recording permissions instead of the CoreAudio Tap API on macOS.
// Tap needs NSAudioCaptureUsageDescription in the parent app's Info.plist, which breaks when
// running from a terminal/IDE during dev.
if (process.platform === "darwin") {
app.commandLine.appendSwitch("disable-features", "MacCatapLoopbackAudioForScreenShare");
}
// 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;
if (isWayland) {
app.commandLine.appendSwitch("ozone-platform", "wayland");
// Enable WebRTCPipeWireCapturer for screen capture on Wayland
app.commandLine.appendSwitch("enable-features", "WaylandWindowDrag,WebRTCPipeWireCapturer");
}
}
export const RECORDINGS_DIR = path.join(app.getPath("userData"), "recordings");
async function ensureRecordingsDir() {
@@ -60,12 +81,15 @@ process.env.VITE_PUBLIC = VITE_DEV_SERVER_URL
// Window references
let mainWindow: BrowserWindow | null = null;
let sourceSelectorWindow: BrowserWindow | null = null;
let countdownOverlayWindow: BrowserWindow | null = null;
let tray: Tray | null = null;
let selectedSourceName = "";
const isMac = process.platform === "darwin";
const trayIconSize = isMac ? 16 : 24;
// Tray Icons
const defaultTrayIcon = getTrayIcon("openscreen.png");
const recordingTrayIcon = getTrayIcon("rec-button.png");
const defaultTrayIcon = getTrayIcon("openscreen.png", trayIconSize);
const recordingTrayIcon = getTrayIcon("rec-button.png", trayIconSize);
function createWindow() {
mainWindow = createHudOverlayWindow();
@@ -89,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;
@@ -116,15 +140,30 @@ function setupApplicationMenu() {
template.push({
label: app.name,
submenu: [
{ role: "about" },
{
role: "about",
label: mainT("common", "actions.about") || "About OpenScreen",
},
{ type: "separator" },
{ role: "services" },
{
role: "services",
label: mainT("common", "actions.services") || "Services",
},
{ type: "separator" },
{ role: "hide" },
{ role: "hideOthers" },
{ role: "unhide" },
{
role: "hide",
label: mainT("common", "actions.hide") || "Hide OpenScreen",
},
{
role: "hideOthers",
label: mainT("common", "actions.hideOthers") || "Hide Others",
},
{
role: "unhide",
label: mainT("common", "actions.unhide") || "Show All",
},
{ type: "separator" },
{ role: "quit" },
{ role: "quit", label: mainT("common", "actions.quit") || "Quit" },
],
});
}
@@ -133,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",
@@ -148,40 +193,89 @@ function setupApplicationMenu() {
accelerator: "CmdOrCtrl+Shift+S",
click: () => sendEditorMenuAction("menu-save-project-as"),
},
...(isMac ? [] : [{ type: "separator" as const }, { role: "quit" as const }]),
...(isMac
? []
: [
{ type: "separator" as const },
{
role: "quit" as const,
label: mainT("common", "actions.quit") || "Quit",
},
]),
],
},
{
label: mainT("common", "actions.edit") || "Edit",
submenu: [
{ role: "undo" },
{ role: "redo" },
{ role: "undo", label: mainT("common", "actions.undo") || "Undo" },
{ role: "redo", label: mainT("common", "actions.redo") || "Redo" },
{ type: "separator" },
{ role: "cut" },
{ role: "copy" },
{ role: "paste" },
{ role: "selectAll" },
{ role: "cut", label: mainT("common", "actions.cut") || "Cut" },
{ role: "copy", label: mainT("common", "actions.copy") || "Copy" },
{ role: "paste", label: mainT("common", "actions.paste") || "Paste" },
{
role: "selectAll",
label: mainT("common", "actions.selectAll") || "Select All",
},
],
},
{
label: mainT("common", "actions.view") || "View",
submenu: [
{ role: "reload" },
{ role: "forceReload" },
{ role: "toggleDevTools" },
{
role: "reload",
label: mainT("common", "actions.reload") || "Reload",
},
{
role: "forceReload",
label: mainT("common", "actions.forceReload") || "Force Reload",
},
{
role: "toggleDevTools",
label: mainT("common", "actions.toggleDevTools") || "Toggle Developer Tools",
},
{ type: "separator" },
{ role: "resetZoom" },
{ role: "zoomIn" },
{ role: "zoomOut" },
{
role: "resetZoom",
label: mainT("common", "actions.actualSize") || "Actual Size",
},
{
role: "zoomIn",
label: mainT("common", "actions.zoomIn") || "Zoom In",
},
{
role: "zoomOut",
label: mainT("common", "actions.zoomOut") || "Zoom Out",
},
{ type: "separator" },
{ role: "togglefullscreen" },
{
role: "togglefullscreen",
label: mainT("common", "actions.toggleFullScreen") || "Toggle Full Screen",
},
],
},
{
label: mainT("common", "actions.window") || "Window",
submenu: isMac
? [{ role: "minimize" }, { role: "zoom" }, { type: "separator" }, { role: "front" }]
: [{ role: "minimize" }, { role: "close" }],
? [
{
role: "minimize",
label: mainT("common", "actions.minimize") || "Minimize",
},
{ role: "zoom" },
{ type: "separator" },
{ role: "front" },
]
: [
{
role: "minimize",
label: mainT("common", "actions.minimize") || "Minimize",
},
{
role: "close",
label: mainT("common", "actions.close") || "Close",
},
],
},
);
@@ -199,12 +293,12 @@ function createTray() {
});
}
function getTrayIcon(filename: string) {
function getTrayIcon(filename: string, size: number) {
return nativeImage
.createFromPath(path.join(process.env.VITE_PUBLIC || RENDERER_DIST, filename))
.resize({
width: 24,
height: 24,
width: size,
height: size,
quality: "best",
});
}
@@ -212,7 +306,11 @@ function getTrayIcon(filename: string) {
function updateTrayMenu(recording: boolean = false) {
if (!tray) return;
const trayIcon = recording ? recordingTrayIcon : defaultTrayIcon;
const trayToolTip = recording ? `Recording: ${selectedSourceName}` : "OpenScreen";
const trayToolTip = recording
? mainT("common", "actions.recordingStatus", {
source: selectedSourceName,
}) || `Recording: ${selectedSourceName}`
: "OpenScreen";
const menuTemplate = recording
? [
{
@@ -245,6 +343,7 @@ function updateTrayMenu(recording: boolean = false) {
let editorHasUnsavedChanges = false;
let isForceClosing = false;
let isCloseConfirmInFlight = false;
ipcMain.on("set-has-unsaved-changes", (_, hasChanges: boolean) => {
editorHasUnsavedChanges = hasChanges;
@@ -276,39 +375,35 @@ function createEditorWindowWrapper() {
editorHasUnsavedChanges = false;
mainWindow.on("close", (event) => {
if (isForceClosing || !editorHasUnsavedChanges) return;
if (isForceClosing || !editorHasUnsavedChanges || isCloseConfirmInFlight) return;
event.preventDefault();
const choice = dialog.showMessageBoxSync(mainWindow!, {
type: "warning",
buttons: [
mainT("dialogs", "unsavedChanges.saveAndClose"),
mainT("dialogs", "unsavedChanges.discardAndClose"),
mainT("common", "actions.cancel"),
],
defaultId: 0,
cancelId: 2,
title: mainT("dialogs", "unsavedChanges.title"),
message: mainT("dialogs", "unsavedChanges.message"),
detail: mainT("dialogs", "unsavedChanges.detail"),
});
isCloseConfirmInFlight = true;
const windowToClose = mainWindow;
if (!windowToClose || windowToClose.isDestroyed()) return;
if (choice === 0) {
// Save & Close — tell renderer to save, then close
windowToClose.webContents.send("request-save-before-close");
ipcMain.once("save-before-close-done", (_, shouldClose: boolean) => {
if (!shouldClose) return;
// 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") => {
if (event.sender.id !== windowToClose?.webContents.id) return;
isCloseConfirmInFlight = false;
if (!windowToClose || windowToClose.isDestroyed()) return;
if (choice === "save") {
// 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;
if (!shouldClose) return;
forceCloseEditorWindow(windowToClose);
});
} else if (choice === "discard") {
forceCloseEditorWindow(windowToClose);
});
} else if (choice === 1) {
// Discard & Close
forceCloseEditorWindow(windowToClose);
}
// choice === 2: Cancel — do nothing, window stays open
}
// "cancel": flag reset, window stays open
});
});
}
@@ -320,34 +415,96 @@ function createSourceSelectorWindowWrapper() {
return sourceSelectorWindow;
}
// On macOS, applications and their menu bar stay active until the user quits
// explicitly with Cmd + Q.
function createCountdownOverlayWindowWrapper() {
if (countdownOverlayWindow && !countdownOverlayWindow.isDestroyed()) {
return countdownOverlayWindow;
}
countdownOverlayWindow = createCountdownOverlayWindow();
countdownOverlayWindow.on("closed", () => {
countdownOverlayWindow = null;
});
return countdownOverlayWindow;
}
// 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", () => {
// Keep app running (macOS behavior)
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.
if (BrowserWindow.getAllWindows().length === 0) {
createWindow();
// 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;
}
const url = window.webContents.getURL();
const isCountdownOverlayWindow = url.includes("windowType=countdown-overlay");
return !isCountdownOverlayWindow;
});
if (!hasVisibleWindow) {
showMainWindow();
}
});
// Register all IPC handlers when app is ready
app.on("will-quit", () => {
unregisterAllGlobalShortcuts();
});
app.whenReady().then(async () => {
// Allow microphone/media permission checks
// 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();
}
session.defaultSession.setPermissionCheckHandler((_webContents, permission) => {
const allowed = ["media", "audioCapture", "microphone", "videoCapture", "camera"];
const allowed = [
"media",
"audioCapture",
"microphone",
"videoCapture",
"camera",
"screen",
"display-capture",
];
return allowed.includes(permission);
});
session.defaultSession.setPermissionRequestHandler((_webContents, permission, callback) => {
const allowed = ["media", "audioCapture", "microphone", "videoCapture", "camera"];
const allowed = [
"media",
"audioCapture",
"microphone",
"videoCapture",
"camera",
"screen",
"display-capture",
];
callback(allowed.includes(permission));
});
// Request microphone permission from macOS
session.defaultSession.setDisplayMediaRequestHandler(
(request, callback) => {
const source = getSelectedDesktopSource();
if (!request.videoRequested || !source) {
callback({});
return;
}
callback({
video: source,
...(request.audioRequested && process.platform === "win32" ? { audio: "loopback" } : {}),
});
},
{ useSystemPicker: false },
);
// 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") {
@@ -355,7 +512,6 @@ app.whenReady().then(async () => {
}
}
// Listen for HUD overlay quit event (macOS only)
ipcMain.on("hud-overlay-close", () => {
app.quit();
});
@@ -365,17 +521,33 @@ 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() {
if (mainWindow) {
isForceClosing = true;
mainWindow.close();
isForceClosing = false;
mainWindow = null;
}
showMainWindow();
}
registerIpcHandlers(
createEditorWindowWrapper,
createSourceSelectorWindowWrapper,
createCountdownOverlayWindowWrapper,
() => mainWindow,
() => sourceSelectorWindow,
() => countdownOverlayWindow,
(recording: boolean, sourceName: string) => {
selectedSourceName = sourceName;
if (!tray) createTray();
@@ -384,6 +556,10 @@ app.whenReady().then(async () => {
showMainWindow();
}
},
switchToHudWrapper,
);
await loadAndRegisterGlobalShortcut(showMainWindow);
createWindow();
});
+20
View File
@@ -0,0 +1,20 @@
import type {
CursorCapabilities,
CursorProviderKind,
CursorRecordingData,
CursorTelemetryPoint,
} from "../../../src/native/contracts";
export interface CursorTelemetryLoadResult {
success: boolean;
samples: CursorTelemetryPoint[];
message?: string;
error?: string;
}
export interface CursorNativeAdapter {
readonly kind: CursorProviderKind;
getCapabilities(): Promise<CursorCapabilities>;
getRecordingData(videoPath?: string | null): Promise<CursorRecordingData>;
getTelemetry(videoPath?: string | null): Promise<CursorTelemetryLoadResult>;
}
@@ -0,0 +1,46 @@
import type { Rectangle } from "electron";
import { MacNativeCursorRecordingSession } from "./macNativeCursorRecordingSession";
import type { CursorRecordingSession } from "./session";
import { TelemetryRecordingSession } from "./telemetryRecordingSession";
import { WindowsNativeRecordingSession } from "./windowsNativeRecordingSession";
interface CreateCursorRecordingSessionOptions {
getDisplayBounds: () => Rectangle | null;
maxSamples: number;
platform: NodeJS.Platform;
sampleIntervalMs: number;
sourceId?: string | null;
startTimeMs?: number;
}
export function createCursorRecordingSession(
options: CreateCursorRecordingSessionOptions,
): CursorRecordingSession {
if (options.platform === "win32") {
return new WindowsNativeRecordingSession({
getDisplayBounds: options.getDisplayBounds,
maxSamples: options.maxSamples,
sampleIntervalMs: options.sampleIntervalMs,
sourceId: options.sourceId,
startTimeMs: options.startTimeMs,
});
}
if (options.platform === "darwin") {
return new MacNativeCursorRecordingSession({
getDisplayBounds: options.getDisplayBounds,
maxSamples: options.maxSamples,
sampleIntervalMs: options.sampleIntervalMs,
startTimeMs: options.startTimeMs,
});
}
// Linux: capture cursor positions via Electron's `screen` API on an interval.
// No cursor sprites/assets and no clicks, just position telemetry.
return new TelemetryRecordingSession({
getDisplayBounds: options.getDisplayBounds,
maxSamples: options.maxSamples,
sampleIntervalMs: options.sampleIntervalMs,
startTimeMs: options.startTimeMs,
});
}
@@ -0,0 +1,449 @@
import { type ChildProcessByStdio, spawn } from "node:child_process";
import { accessSync, constants as fsConstants } from "node:fs";
import path from "node:path";
import type { Readable } from "node:stream";
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;
sampleIntervalMs: number;
startTimeMs?: number;
}
type MacCursorEvent =
| {
type: "ready";
timestampMs: number;
accessibilityTrusted?: boolean;
mouseTapReady?: boolean;
}
| {
type: "sample";
timestampMs: number;
cursorType?: NativeCursorType | null;
assetId?: string | null;
asset?: MacCursorAssetPayload | null;
leftButtonDown?: boolean;
leftButtonPressed?: boolean;
leftButtonReleased?: boolean;
};
const HELPER_NAME = "openscreen-macos-cursor-helper";
const READY_TIMEOUT_MS = 5_000;
function helperCandidates() {
const envPath = process.env.OPENSCREEN_MAC_CURSOR_HELPER_EXE?.trim();
const appRoot = process.env.APP_ROOT ? path.resolve(process.env.APP_ROOT) : process.cwd();
const archTag = process.arch === "arm64" ? "darwin-arm64" : "darwin-x64";
const resourceRoot =
typeof process.resourcesPath === "string"
? process.resourcesPath
: path.join(appRoot, "resources");
return [
envPath,
path.join(appRoot, "electron", "native", "screencapturekit", "build", HELPER_NAME),
path.join(appRoot, "electron", "native", "bin", archTag, HELPER_NAME),
path.join(resourceRoot, "electron", "native", "bin", archTag, HELPER_NAME),
].filter((candidate): candidate is string => Boolean(candidate));
}
export function findMacCursorHelperPath() {
for (const candidate of helperCandidates()) {
try {
accessSync(candidate, fsConstants.X_OK);
return candidate;
} catch {
// Try the next helper location.
}
}
return null;
}
export async function requestMacCursorAccessibilityAccess() {
if (process.platform !== "darwin") {
return { success: true, granted: true, status: "granted" };
}
try {
systemPreferences.isTrustedAccessibilityClient(true);
} catch {
// Continue with helper probing; it can trigger the same macOS prompt.
}
const helperPath = findMacCursorHelperPath();
if (!helperPath) {
return { success: true, granted: false, status: "missing-helper" };
}
return new Promise<{ success: boolean; granted: boolean; status: string; error?: string }>(
(resolve) => {
const child = spawn(helperPath, [JSON.stringify({ sampleIntervalMs: 250 })], {
stdio: ["ignore", "pipe", "pipe"],
});
let settled = false;
let lineBuffer = "";
const finish = (result: {
success: boolean;
granted: boolean;
status: string;
error?: string;
}) => {
if (settled) {
return;
}
settled = true;
clearTimeout(timer);
if (!child.killed) {
child.kill("SIGTERM");
}
resolve(result);
};
const timer = setTimeout(() => {
finish({
success: false,
granted: false,
status: "timeout",
error: "Timed out waiting for macOS cursor helper",
});
}, READY_TIMEOUT_MS);
child.stdout.setEncoding("utf8");
child.stdout.on("data", (chunk: string) => {
lineBuffer += chunk;
const lines = lineBuffer.split(/\r?\n/);
lineBuffer = lines.pop() ?? "";
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) {
continue;
}
try {
const event = JSON.parse(trimmed) as MacCursorEvent;
if (event.type === "ready") {
finish({
success: true,
granted: event.accessibilityTrusted === true,
status: event.accessibilityTrusted === true ? "granted" : "not-determined",
});
return;
}
} catch {
// Ignore non-JSON helper output.
}
}
});
child.once("error", (error) => {
finish({
success: false,
granted: false,
status: "error",
error: error.message,
});
});
child.once("exit", (code, signal) => {
finish({
success: false,
granted: false,
status: "exited",
error: `macOS cursor helper exited before ready (code=${code}, signal=${signal})`,
});
});
},
);
}
function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value));
}
function normalizeCursorType(value: unknown): NativeCursorType | null {
return value === "arrow" || value === "pointer" || value === "text" ? value : 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;
private fallbackInterval: NodeJS.Timeout | null = null;
private readyResolve: (() => void) | null = null;
private readyReject: ((error: Error) => void) | null = null;
private readyTimer: NodeJS.Timeout | null = null;
private previousLeftButtonDown = false;
private consecutiveOutsideSamples = 0;
// 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;
constructor(private readonly options: MacNativeCursorRecordingSessionOptions) {}
async start(): Promise<void> {
this.samples = [];
this.assets.clear();
this.lineBuffer = "";
this.startTimeMs = this.options.startTimeMs ?? Date.now();
this.previousLeftButtonDown = false;
this.consecutiveOutsideSamples = 0;
try {
systemPreferences.isTrustedAccessibilityClient(true);
} catch {
// Without Accessibility, text/pointer affordance detection is unavailable;
// bitmaps are still captured natively via NSCursor.
}
const helperPath = findMacCursorHelperPath();
if (!helperPath) {
this.startPositionOnlyFallback();
return;
}
const child = spawn(
helperPath,
[
JSON.stringify({
sampleIntervalMs: this.options.sampleIntervalMs,
}),
],
{
stdio: ["ignore", "pipe", "pipe"],
},
);
this.process = child;
child.stdout.setEncoding("utf8");
child.stdout.on("data", (chunk: string) => this.handleStdoutChunk(chunk));
child.stderr.setEncoding("utf8");
child.stderr.on("data", (chunk: string) => {
const message = chunk.trim();
if (message) {
console.error("[cursor-macos]", message);
}
});
child.once("exit", (code, signal) => {
this.rejectReady(
new Error(`macOS cursor helper exited before ready (code=${code}, signal=${signal})`),
);
this.process = null;
});
child.once("error", (error) => {
this.rejectReady(error);
this.process = null;
});
try {
await this.waitUntilReady();
} catch (error) {
this.killHelperProcess(child);
this.process = null;
console.warn("[cursor-macos] falling back to position-only cursor telemetry:", error);
this.startPositionOnlyFallback();
}
}
async stop(): Promise<CursorRecordingData> {
const child = this.process;
this.process = null;
this.clearReadyState();
if (this.fallbackInterval) {
clearInterval(this.fallbackInterval);
this.fallbackInterval = null;
}
if (child) {
this.killHelperProcess(child);
}
return {
version: 2,
provider: this.assets.size > 0 ? "native" : "none",
samples: this.samples,
assets: [...this.assets.values()],
};
}
private startPositionOnlyFallback() {
this.captureSample(Date.now(), null, null, false, false, false);
this.fallbackInterval = setInterval(() => {
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/);
this.lineBuffer = lines.pop() ?? "";
for (const line of lines) {
const trimmedLine = line.trim();
if (!trimmedLine) {
continue;
}
try {
this.handleEvent(JSON.parse(trimmedLine) as MacCursorEvent);
} catch (error) {
console.error("Failed to parse macOS cursor helper output:", error, trimmedLine);
}
}
}
private handleEvent(payload: MacCursorEvent) {
if (payload.type === "ready") {
if (payload.accessibilityTrusted === false) {
console.warn(
"[cursor-macos] Accessibility is not trusted; text/pointer affordance detection disabled (bitmap capture still active).",
);
}
this.resolveReady();
return;
}
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,
);
}
}
private captureSample(
timestampMs: number,
cursorType: NativeCursorType | null,
assetId: string | null,
leftButtonDown: boolean,
leftButtonPressed: boolean,
leftButtonReleased: boolean,
) {
const cursor = screen.getCursorScreenPoint();
const bounds = this.options.getDisplayBounds() ?? screen.getDisplayNearestPoint(cursor).bounds;
const width = Math.max(1, bounds.width);
const height = Math.max(1, bounds.height);
const normalizedX = (cursor.x - bounds.x) / width;
const normalizedY = (cursor.y - bounds.y) / height;
const isOutsideDisplay =
normalizedX < 0 || normalizedX > 1 || normalizedY < 0 || normalizedY > 1;
// 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 {
this.consecutiveOutsideSamples = 0;
}
const visible =
this.consecutiveOutsideSamples < MacNativeCursorRecordingSession.OUTSIDE_HIDE_THRESHOLD;
const interactionType =
leftButtonPressed || (leftButtonDown && !this.previousLeftButtonDown)
? "click"
: leftButtonReleased || (!leftButtonDown && this.previousLeftButtonDown)
? "mouseup"
: "move";
this.previousLeftButtonDown = leftButtonDown;
this.samples.push({
timeMs: Math.max(0, timestampMs - this.startTimeMs),
cx: clamp(normalizedX, 0, 1),
cy: clamp(normalizedY, 0, 1),
visible,
interactionType,
...(assetId ? { assetId } : {}),
...(cursorType ? { cursorType } : {}),
});
if (this.samples.length > this.options.maxSamples) {
this.samples.shift();
}
}
private waitUntilReady() {
return new Promise<void>((resolve, reject) => {
this.readyResolve = resolve;
this.readyReject = reject;
this.readyTimer = setTimeout(() => {
this.rejectReady(new Error("Timed out waiting for macOS cursor helper"));
}, READY_TIMEOUT_MS);
});
}
private resolveReady() {
const resolve = this.readyResolve;
this.clearReadyState();
resolve?.();
}
private rejectReady(error: Error) {
const reject = this.readyReject;
this.clearReadyState();
reject?.(error);
}
private clearReadyState() {
if (this.readyTimer) {
clearTimeout(this.readyTimer);
this.readyTimer = null;
}
this.readyResolve = null;
this.readyReject = null;
}
private killHelperProcess(child: ChildProcessByStdio<null, Readable, Readable>) {
if (child.killed) {
return;
}
child.kill("SIGTERM");
setTimeout(() => {
if (!child.killed) {
child.kill("SIGKILL");
}
}, 500).unref();
}
}
@@ -0,0 +1,6 @@
import type { CursorRecordingData } from "../../../../src/native/contracts";
export interface CursorRecordingSession {
start(): Promise<void>;
stop(): Promise<CursorRecordingData>;
}
@@ -0,0 +1,63 @@
import { type Rectangle, screen } from "electron";
import type { CursorRecordingData, CursorRecordingSample } from "../../../../src/native/contracts";
import type { CursorRecordingSession } from "./session";
interface TelemetryRecordingSessionOptions {
getDisplayBounds: () => Rectangle | null;
maxSamples: number;
sampleIntervalMs: number;
startTimeMs?: number;
}
function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value));
}
export class TelemetryRecordingSession implements CursorRecordingSession {
private samples: CursorRecordingSample[] = [];
private interval: NodeJS.Timeout | null = null;
private startTimeMs = 0;
constructor(private readonly options: TelemetryRecordingSessionOptions) {}
async start(): Promise<void> {
this.samples = [];
this.startTimeMs = this.options.startTimeMs ?? Date.now();
this.captureSample();
this.interval = setInterval(() => {
this.captureSample();
}, this.options.sampleIntervalMs);
}
async stop(): Promise<CursorRecordingData> {
if (this.interval) {
clearInterval(this.interval);
this.interval = null;
}
return {
version: 2,
provider: "none",
samples: this.samples,
assets: [],
};
}
private captureSample() {
const cursor = screen.getCursorScreenPoint();
const display = this.options.getDisplayBounds() ?? screen.getDisplayNearestPoint(cursor).bounds;
const width = Math.max(1, display.width);
const height = Math.max(1, display.height);
this.samples.push({
timeMs: Math.max(0, Date.now() - this.startTimeMs),
cx: clamp((cursor.x - display.x) / width, 0, 1),
cy: clamp((cursor.y - display.y) / height, 0, 1),
visible: true,
});
if (this.samples.length > this.options.maxSamples) {
this.samples.shift();
}
}
}
@@ -0,0 +1,330 @@
import { type ChildProcessByStdio, spawn } from "node:child_process";
import { existsSync } from "node:fs";
import { join } from "node:path";
import type { Readable } from "node:stream";
import { app, screen } from "electron";
import { parseWindowHandleFromSourceId } from "../../../../src/lib/nativeWindowsRecording";
import type {
CursorRecordingData,
CursorRecordingSample,
NativeCursorAsset,
} from "../../../../src/native/contracts";
import type { CursorRecordingSession } from "./session";
import type {
WindowsCursorEvent,
WindowsNativeRecordingSessionOptions,
} from "./windowsNativeRecordingSession.types";
function getCursorSamplerCandidates(): string[] {
const envPath = process.env.OPENSCREEN_CURSOR_SAMPLER_EXE?.trim();
const archTag = process.arch === "arm64" ? "win32-arm64" : "win32-x64";
const resolve = (...segs: 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));
}
function findCursorSamplerPath(): string | null {
for (const candidate of getCursorSamplerCandidates()) {
if (existsSync(candidate)) return candidate;
}
return null;
}
const READY_TIMEOUT_MS = 5_000;
interface NormalizedSample {
sample: CursorRecordingSample;
withinBounds: boolean;
}
export class WindowsNativeRecordingSession implements CursorRecordingSession {
private assets = new Map<string, NativeCursorAsset>();
private samples: CursorRecordingSample[] = [];
private process: ChildProcessByStdio<null, Readable, Readable> | null = null;
private lineBuffer = "";
private startTimeMs = 0;
private readyResolve: (() => void) | null = null;
private readyReject: ((error: Error) => void) | null = null;
private readyTimer: NodeJS.Timeout | null = null;
private sampleCount = 0;
private outOfBoundsSampleCount = 0;
private previousLeftButtonDown = false;
constructor(private readonly options: WindowsNativeRecordingSessionOptions) {}
async start(): Promise<void> {
this.assets.clear();
this.samples = [];
this.lineBuffer = "";
this.startTimeMs = this.options.startTimeMs ?? Date.now();
this.sampleCount = 0;
this.outOfBoundsSampleCount = 0;
this.previousLeftButtonDown = false;
const helperPath = findCursorSamplerPath();
if (!helperPath) {
throw new Error("Windows cursor sampler helper is not available.");
}
const windowHandle = parseWindowHandleFromSourceId(this.options.sourceId);
const args = [String(this.options.sampleIntervalMs)];
if (windowHandle) args.push(windowHandle);
const child = spawn(helperPath, args, {
stdio: ["ignore", "pipe", "pipe"],
windowsHide: true,
});
this.process = child;
this.logDiagnostic("spawn", {
pid: child.pid ?? null,
sampleIntervalMs: this.options.sampleIntervalMs,
sourceId: this.options.sourceId ?? null,
windowHandle,
});
child.stdout.setEncoding("utf8");
child.stdout.on("data", (chunk: string) => {
this.handleStdoutChunk(chunk);
});
child.stderr.setEncoding("utf8");
child.stderr.on("data", (chunk: string) => {
const message = chunk.trim();
if (message) {
this.logDiagnostic("stderr", { message });
}
console.error("[cursor-native]", message);
});
child.once("exit", (code, signal) => {
this.logDiagnostic("exit", {
code,
signal,
sampleCount: this.sampleCount,
assetCount: this.assets.size,
outOfBoundsSampleCount: this.outOfBoundsSampleCount,
});
this.rejectReady(
new Error(`Windows cursor helper exited before ready (code=${code}, signal=${signal})`),
);
});
child.once("error", (error) => {
this.logDiagnostic("process-error", { message: error.message });
this.rejectReady(error);
});
try {
await this.waitUntilReady();
} catch (error) {
this.terminateHelperProcess();
throw error;
}
}
async stop(): Promise<CursorRecordingData> {
const child = this.process;
this.process = null;
this.clearReadyState();
this.killHelperProcess(child);
this.logDiagnostic("stop", {
sampleCount: this.sampleCount,
assetCount: this.assets.size,
outOfBoundsSampleCount: this.outOfBoundsSampleCount,
});
return {
version: 2,
provider: this.assets.size > 0 ? "native" : "none",
samples: this.samples,
assets: [...this.assets.values()],
};
}
private handleStdoutChunk(chunk: string) {
this.lineBuffer += chunk;
const lines = this.lineBuffer.split(/\r?\n/);
this.lineBuffer = lines.pop() ?? "";
for (const line of lines) {
const trimmedLine = line.trim();
if (!trimmedLine) {
continue;
}
try {
const payload = JSON.parse(trimmedLine) as WindowsCursorEvent;
this.handleEvent(payload);
} catch (error) {
console.error("Failed to parse Windows cursor helper output:", error, trimmedLine);
}
}
}
private handleEvent(payload: WindowsCursorEvent) {
if (payload.type === "error") {
this.logDiagnostic("helper-error", { message: payload.message });
console.error("Windows cursor helper error:", payload.message);
this.failHelper(new Error(payload.message));
return;
}
if (payload.type === "ready") {
this.logDiagnostic("ready", { timestampMs: payload.timestampMs });
this.resolveReady();
return;
}
if (payload.asset?.id && !this.assets.has(payload.asset.id)) {
const assetDisplay = screen.getDisplayNearestPoint({ x: payload.x, y: payload.y });
this.assets.set(payload.asset.id, {
id: payload.asset.id,
platform: "win32",
imageDataUrl: payload.asset.imageDataUrl,
width: payload.asset.width,
height: payload.asset.height,
hotspotX: payload.asset.hotspotX,
hotspotY: payload.asset.hotspotY,
scaleFactor: assetDisplay.scaleFactor,
cursorType: payload.asset.cursorType ?? payload.cursorType ?? null,
});
this.logDiagnostic("asset", {
id: payload.asset.id,
width: payload.asset.width,
height: payload.asset.height,
hotspotX: payload.asset.hotspotX,
hotspotY: payload.asset.hotspotY,
scaleFactor: assetDisplay.scaleFactor,
});
}
const normalized = this.normalizeSample(payload);
this.sampleCount += 1;
if (!normalized.withinBounds) {
this.outOfBoundsSampleCount += 1;
}
this.samples.push(normalized.sample);
if (this.samples.length > this.options.maxSamples) {
this.samples.shift();
}
}
private normalizeSample(
payload: Extract<WindowsCursorEvent, { type: "sample" }>,
): NormalizedSample {
const bounds =
payload.bounds ?? this.options.getDisplayBounds() ?? screen.getPrimaryDisplay().bounds;
const width = Math.max(1, bounds.width);
const height = Math.max(1, bounds.height);
const normalizedX = (payload.x - bounds.x) / width;
const normalizedY = (payload.y - bounds.y) / height;
const withinBounds =
normalizedX >= 0 && normalizedX <= 1 && normalizedY >= 0 && normalizedY <= 1;
const leftButtonDown = payload.leftButtonDown === true;
const leftButtonPressed = payload.leftButtonPressed === true;
const leftButtonReleased = payload.leftButtonReleased === true;
const interactionType =
leftButtonPressed || (leftButtonDown && !this.previousLeftButtonDown)
? "click"
: leftButtonReleased || (!leftButtonDown && this.previousLeftButtonDown)
? "mouseup"
: "move";
this.previousLeftButtonDown = leftButtonDown;
if (this.sampleCount === 0 || (!withinBounds && this.outOfBoundsSampleCount === 0)) {
this.logDiagnostic("sample", {
rawX: payload.x,
rawY: payload.y,
normalizedX,
normalizedY,
visible: payload.visible,
withinBounds,
bounds,
handle: payload.handle,
});
}
return {
withinBounds,
sample: {
timeMs: Math.max(0, payload.timestampMs - this.startTimeMs),
cx: normalizedX,
cy: normalizedY,
assetId: payload.handle,
visible: payload.visible && withinBounds,
cursorType: payload.cursorType ?? payload.asset?.cursorType ?? null,
interactionType,
},
};
}
private waitUntilReady() {
return new Promise<void>((resolve, reject) => {
this.readyResolve = resolve;
this.readyReject = reject;
this.readyTimer = setTimeout(() => {
this.rejectReady(new Error("Timed out waiting for Windows cursor helper readiness"));
}, READY_TIMEOUT_MS);
});
}
private resolveReady() {
const resolve = this.readyResolve;
this.clearReadyState();
resolve?.();
}
private rejectReady(error: Error) {
const reject = this.readyReject;
this.clearReadyState();
reject?.(error);
}
private failHelper(error: Error) {
this.rejectReady(error);
this.terminateHelperProcess();
}
private terminateHelperProcess() {
const child = this.process;
this.process = null;
this.killHelperProcess(child);
}
private killHelperProcess(child: ChildProcessByStdio<null, Readable, Readable> | null) {
if (child && !child.killed) {
child.kill();
}
}
private clearReadyState() {
if (this.readyTimer) {
clearTimeout(this.readyTimer);
this.readyTimer = null;
}
this.readyResolve = null;
this.readyReject = null;
}
private logDiagnostic(event: string, data: Record<string, unknown>) {
console.info(
"[cursor-native][win32]",
JSON.stringify({
event,
...data,
}),
);
}
}
@@ -0,0 +1,56 @@
import type { Rectangle } from "electron";
import type { NativeCursorType } from "../../../../src/native/contracts";
export interface WindowsCursorSampleEvent {
type: "sample";
timestampMs: number;
x: number;
y: number;
visible: boolean;
handle: string | null;
cursorType?: NativeCursorType | null;
leftButtonDown?: boolean;
leftButtonPressed?: boolean;
leftButtonReleased?: boolean;
bounds?: {
x: number;
y: number;
width: number;
height: number;
} | null;
asset: WindowsCursorAssetPayload | null;
}
export interface WindowsCursorReadyEvent {
type: "ready";
timestampMs: number;
}
export interface WindowsCursorErrorEvent {
type: "error";
timestampMs: number;
message: string;
}
export interface WindowsCursorAssetPayload {
id: string;
imageDataUrl: string;
width: number;
height: number;
hotspotX: number;
hotspotY: number;
cursorType?: NativeCursorType | null;
}
export type WindowsCursorEvent =
| WindowsCursorSampleEvent
| WindowsCursorReadyEvent
| WindowsCursorErrorEvent;
export interface WindowsNativeRecordingSessionOptions {
getDisplayBounds: () => Rectangle | null;
maxSamples: number;
sampleIntervalMs: number;
sourceId?: string | null;
startTimeMs?: number;
}
@@ -0,0 +1,49 @@
import type { CursorCapabilities, CursorRecordingData } from "../../../src/native/contracts";
import type { CursorNativeAdapter, CursorTelemetryLoadResult } from "./adapter";
interface TelemetryCursorAdapterOptions {
loadRecordingData: (videoPath: string) => Promise<CursorRecordingData>;
resolveVideoPath: (videoPath?: string | null) => string | null;
loadTelemetry: (videoPath: string) => Promise<CursorTelemetryLoadResult>;
}
export class TelemetryCursorAdapter implements CursorNativeAdapter {
readonly kind = "none" as const;
constructor(private readonly options: TelemetryCursorAdapterOptions) {}
async getCapabilities(): Promise<CursorCapabilities> {
return {
telemetry: true,
systemAssets: false,
provider: this.kind,
};
}
async getRecordingData(videoPath?: string | null): Promise<CursorRecordingData> {
const resolvedVideoPath = this.options.resolveVideoPath(videoPath);
if (!resolvedVideoPath) {
return {
version: 2,
provider: this.kind,
samples: [],
assets: [],
};
}
return this.options.loadRecordingData(resolvedVideoPath);
}
async getTelemetry(videoPath?: string | null) {
const resolvedVideoPath = this.options.resolveVideoPath(videoPath);
if (!resolvedVideoPath) {
return {
success: false,
message: "No video path is available for cursor telemetry",
samples: [],
} satisfies CursorTelemetryLoadResult;
}
return this.options.loadTelemetry(resolvedVideoPath);
}
}
@@ -0,0 +1,46 @@
import type {
CursorCapabilities,
CursorRecordingData,
CursorTelemetryPoint,
} from "../../../src/native/contracts";
import type { CursorNativeAdapter } from "../cursor/adapter";
import type { NativeBridgeStateStore } from "../store";
interface CursorServiceOptions {
store: NativeBridgeStateStore;
adapter: CursorNativeAdapter;
}
export class CursorService {
constructor(private readonly options: CursorServiceOptions) {}
async getCapabilities(): Promise<CursorCapabilities> {
const capabilities = await this.options.adapter.getCapabilities();
this.options.store.setCursorCapabilities(capabilities);
return capabilities;
}
async getTelemetry(videoPath?: string | null): Promise<CursorTelemetryPoint[]> {
const result = await this.options.adapter.getTelemetry(videoPath);
if (!result.success) {
throw new Error(result.message || result.error || "Failed to load cursor telemetry");
}
const resolvedVideoPath = videoPath ?? this.options.store.getState().project.currentVideoPath;
if (resolvedVideoPath) {
this.options.store.markCursorTelemetryLoaded(resolvedVideoPath, result.samples.length);
}
return result.samples;
}
async getRecordingData(videoPath?: string | null): Promise<CursorRecordingData> {
const data = await this.options.adapter.getRecordingData(videoPath);
const resolvedVideoPath = videoPath ?? this.options.store.getState().project.currentVideoPath;
if (resolvedVideoPath) {
this.options.store.markCursorTelemetryLoaded(resolvedVideoPath, data.samples.length);
}
return data;
}
}
@@ -0,0 +1,87 @@
import type {
ProjectContext,
ProjectFileResult,
ProjectPathResult,
} from "../../../src/native/contracts";
import type { NativeBridgeStateStore } from "../store";
interface ProjectServiceOptions {
store: NativeBridgeStateStore;
getCurrentProjectPath: () => string | null;
getCurrentVideoPath: () => string | null;
saveProjectFile: (
projectData: unknown,
suggestedName?: string,
existingProjectPath?: string,
) => 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;
}
export class ProjectService {
constructor(private readonly options: ProjectServiceOptions) {}
getCurrentContext(): ProjectContext {
const context = {
currentProjectPath: this.options.getCurrentProjectPath(),
currentVideoPath: this.options.getCurrentVideoPath(),
};
this.options.store.setProjectContext(context);
return context;
}
async saveProjectFile(
projectData: unknown,
suggestedName?: string,
existingProjectPath?: string,
) {
const result = await this.options.saveProjectFile(
projectData,
suggestedName,
existingProjectPath,
);
this.getCurrentContext();
return result;
}
async loadProjectFile(projectFolder?: string) {
const result = await this.options.loadProjectFile(projectFolder);
this.getCurrentContext();
return result;
}
async loadCurrentProjectFile() {
const result = await this.options.loadCurrentProjectFile();
this.getCurrentContext();
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();
return result;
}
getCurrentVideoPath() {
const result = this.options.getCurrentVideoPathResult();
this.getCurrentContext();
return result;
}
clearCurrentVideoPath() {
const result = this.options.clearCurrentVideoPath();
this.getCurrentContext();
return result;
}
}
@@ -0,0 +1,43 @@
import type {
CursorCapabilities,
NativePlatform,
SystemCapabilities,
} from "../../../src/native/contracts";
import { NATIVE_BRIDGE_VERSION } from "../../../src/native/contracts";
import type { NativeBridgeStateStore } from "../store";
interface SystemServiceOptions {
store: NativeBridgeStateStore;
getPlatform: () => NativePlatform;
getAssetBasePath: () => string | null;
getCursorCapabilities: () => Promise<CursorCapabilities>;
}
export class SystemService {
constructor(private readonly options: SystemServiceOptions) {}
getPlatform() {
return this.options.getPlatform();
}
getAssetBasePath() {
return this.options.getAssetBasePath();
}
async getCapabilities(): Promise<SystemCapabilities> {
const platform = this.getPlatform();
const cursorCapabilities = await this.options.getCursorCapabilities();
const capabilities: SystemCapabilities = {
bridgeVersion: NATIVE_BRIDGE_VERSION,
platform,
cursor: cursorCapabilities,
project: {
currentContext: true,
},
};
this.options.store.setSystemCapabilities(capabilities);
return capabilities;
}
}
+88
View File
@@ -0,0 +1,88 @@
import type {
CursorCapabilities,
NativePlatform,
ProjectContext,
SystemCapabilities,
} from "../../src/native/contracts";
export interface NativeBridgeState {
system: {
platform: NativePlatform;
capabilities: SystemCapabilities | null;
};
project: ProjectContext;
cursor: {
capabilities: CursorCapabilities | null;
lastTelemetryLoad: {
videoPath: string;
sampleCount: number;
loadedAt: number;
} | null;
};
}
export class NativeBridgeStateStore {
private state: NativeBridgeState;
constructor(platform: NativePlatform) {
this.state = {
system: {
platform,
capabilities: null,
},
project: {
currentProjectPath: null,
currentVideoPath: null,
},
cursor: {
capabilities: null,
lastTelemetryLoad: null,
},
};
}
getState() {
return this.state;
}
setProjectContext(project: ProjectContext) {
this.state = {
...this.state,
project,
};
}
setSystemCapabilities(capabilities: SystemCapabilities) {
this.state = {
...this.state,
system: {
...this.state.system,
capabilities,
},
};
}
setCursorCapabilities(capabilities: CursorCapabilities) {
this.state = {
...this.state,
cursor: {
...this.state.cursor,
capabilities,
},
};
}
markCursorTelemetryLoaded(videoPath: string, sampleCount: number) {
this.state = {
...this.state,
cursor: {
...this.state.cursor,
lastTelemetryLoad: {
videoPath,
sampleCount,
loadedAt: Date.now(),
},
},
};
}
}
+111
View File
@@ -0,0 +1,111 @@
# Native capture helpers
## macOS
macOS native recording will use a ScreenCaptureKit helper with the same process boundary as the Windows WGC helper:
1. Electron resolves the selected source, output paths, and user-selected devices.
2. The helper receives one structured JSON request.
3. The helper owns ScreenCaptureKit/AVFoundation capture, timing, encoding, and muxing.
4. Electron persists the resulting media/session manifest and reports helper errors explicitly.
Helper locations:
1. `OPENSCREEN_SCK_CAPTURE_EXE`, for local development and diagnostics.
2. `electron/native/screencapturekit/build/openscreen-screencapturekit-helper`, for locally built Swift output.
3. `electron/native/bin/darwin-arm64/openscreen-screencapturekit-helper` or `electron/native/bin/darwin-x64/openscreen-screencapturekit-helper`, for packaged prebuilt helpers.
The macOS cursor-shape helper is resolved from `OPENSCREEN_MAC_CURSOR_HELPER_EXE` first, then the matching `openscreen-macos-cursor-helper` binary in the same local build and packaged `electron/native/bin/darwin-${arch}` directories.
Build the macOS helper with:
```bash
npm run build:native:mac
```
On non-macOS hosts this command exits successfully and does not affect Windows/Linux development. On macOS it builds the Swift package at `electron/native/screencapturekit`, writes the development binaries to `electron/native/screencapturekit/build`, and copies redistributable binaries to `electron/native/bin/darwin-${arch}`.
The current helper implementation supports display/window ScreenCaptureKit video capture, cursor exclusion through `SCStreamConfiguration.showsCursor`, H.264 encoding, MP4 muxing, and ScreenCaptureKit system audio. It also attempts native ScreenCaptureKit microphone capture when the running macOS version exposes that capability. Webcam recording currently stays as an Electron sidecar and is attached to the same recording session after the native screen capture stops.
Electron exposes `is-native-mac-capture-available` for capability probing. It resolves the same helper locations listed above and reports `missing-helper` until a Swift helper binary is present. When available, macOS recording routes screen/window capture through the native helper so editable cursor recordings do not bake the system cursor into the video. Cursor positions are sampled in Electron; when the cursor helper is available and Accessibility is granted, samples are also tagged with link/text cursor hints such as `pointer`.
See `docs/engineering/macos-native-recorder-roadmap.md` for the contract, rollout phases, and SSOT rules.
## Windows
Windows native recording is resolved from one of these locations:
1. `OPENSCREEN_WGC_CAPTURE_EXE`, for local development and diagnostics.
2. `electron/native/wgc-capture/build/wgc-capture.exe`, for a locally built Ninja helper.
3. `electron/native/wgc-capture/build/Release/wgc-capture.exe`, for a locally built multi-config helper.
4. `electron/native/bin/win32-x64/wgc-capture.exe` or `electron/native/bin/win32-arm64/wgc-capture.exe`, for packaged prebuilt helpers.
Build the Windows helper with:
```powershell
npm run build:native:win
```
The build writes the CMake output to `electron/native/wgc-capture/build/wgc-capture.exe` and copies the redistributable binary to `electron/native/bin/win32-x64/wgc-capture.exe`.
The helper contract is process-based: the app starts the process with one JSON argument and sends commands on stdin. `stop\n` finalizes the recording. During migration the helper prints both newline-delimited JSON events and the legacy text messages `Recording started` / `Recording stopped. Output path: <path>`.
Current V2 JSON shape:
```json
{
"schemaVersion": 2,
"recordingId": 123,
"sourceType": "display",
"sourceId": "screen:0:0",
"displayId": 1,
"windowHandle": null,
"outputPath": "C:\\path\\recording-123.mp4",
"videoWidth": 1920,
"videoHeight": 1080,
"fps": 60,
"captureSystemAudio": false,
"captureMic": false,
"microphoneDeviceId": "default",
"microphoneDeviceName": "Microphone (NVIDIA Broadcast)",
"microphoneGain": 1.4,
"webcamEnabled": true,
"webcamDeviceId": "default",
"webcamDeviceName": "Camera (NVIDIA Broadcast)",
"webcamWidth": 1280,
"webcamHeight": 720,
"webcamFps": 30,
"outputs": {
"screenPath": "C:\\path\\recording-123.mp4"
}
}
```
The current helper implementation supports display/window video capture, system audio loopback, selected-microphone capture, Media Foundation webcam capture, and a DirectShow webcam fallback for virtual cameras that are not exposed through Media Foundation. Webcam frames are currently composed into the primary MP4 as a bottom-right picture-in-picture overlay. Browser `deviceId` values do not always map to Media Foundation symbolic links or WASAPI endpoint IDs, so the renderer passes both browser IDs and user-visible device names. For microphones, the helper tries the requested WASAPI endpoint ID first, then resolves an active capture endpoint by `microphoneDeviceName`, then falls back to the default endpoint. For webcams, Electron resolves a matching DirectShow filter CLSID for the selected label; the helper uses Media Foundation first, then that exact DirectShow filter when the requested camera is absent from Media Foundation.
Smoke-test the helper with:
```powershell
npm run test:wgc-helper:win
npm run test:wgc-window:win
npm run test:wgc-audio:win
npm run test:wgc-mic:win
npm run test:wgc-mixed-audio:win
npm run test:wgc-webcam:win
```
To validate a specific native webcam manually:
```powershell
$env:OPENSCREEN_WGC_TEST_WEBCAM_DEVICE_NAME = "NVIDIA Broadcast"
npm run test:wgc-webcam:win
Remove-Item Env:OPENSCREEN_WGC_TEST_WEBCAM_DEVICE_NAME
```
To validate a specific native microphone manually:
```powershell
$env:OPENSCREEN_WGC_TEST_MICROPHONE_DEVICE_NAME = "Microphone (NVIDIA Broadcast)"
npm run test:wgc-mic:win
Remove-Item Env:OPENSCREEN_WGC_TEST_MICROPHONE_DEVICE_NAME
```
@@ -0,0 +1,30 @@
// swift-tools-version: 5.9
import PackageDescription
let package = Package(
name: "OpenScreenScreenCaptureKitHelper",
platforms: [
.macOS(.v13)
],
products: [
.executable(
name: "openscreen-screencapturekit-helper",
targets: ["OpenScreenScreenCaptureKitHelper"]
),
.executable(
name: "openscreen-macos-cursor-helper",
targets: ["OpenScreenMacOSCursorHelper"]
)
],
targets: [
.executableTarget(
name: "OpenScreenScreenCaptureKitHelper",
path: "Sources/OpenScreenScreenCaptureKitHelper"
),
.executableTarget(
name: "OpenScreenMacOSCursorHelper",
path: "Sources/OpenScreenMacOSCursorHelper"
)
]
)
@@ -0,0 +1,352 @@
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
private var leftUpCount = 0
private var eventTap: CFMachPort?
private var runLoopSource: CFRunLoopSource?
struct Events {
let leftDownCount: Int
let leftUpCount: Int
}
func start() -> Bool {
let mask =
(1 << CGEventType.leftMouseDown.rawValue) |
(1 << CGEventType.leftMouseUp.rawValue)
guard let tap = CGEvent.tapCreate(
tap: .cgSessionEventTap,
place: .headInsertEventTap,
options: .listenOnly,
eventsOfInterest: CGEventMask(mask),
callback: { _, type, event, userInfo in
if let userInfo {
let tracker = Unmanaged<MouseButtonTracker>.fromOpaque(userInfo).takeUnretainedValue()
tracker.record(type)
}
return Unmanaged.passUnretained(event)
},
userInfo: UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque())
) else {
return false
}
guard let source = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, tap, 0) else {
return false
}
eventTap = tap
runLoopSource = source
CFRunLoopAddSource(CFRunLoopGetCurrent(), source, .commonModes)
CGEvent.tapEnable(tap: tap, enable: true)
return true
}
func pump() {
CFRunLoopRunInMode(.defaultMode, 0.001, false)
}
func consume() -> Events {
lock.lock()
defer { lock.unlock() }
let events = Events(leftDownCount: leftDownCount, leftUpCount: leftUpCount)
leftDownCount = 0
leftUpCount = 0
return events
}
private func record(_ type: CGEventType) {
lock.lock()
defer { lock.unlock() }
if type == .tapDisabledByTimeout || type == .tapDisabledByUserInput {
reenableTap()
return
}
if type == .leftMouseDown {
leftDownCount += 1
} else if type == .leftMouseUp {
leftUpCount += 1
}
}
private func reenableTap() {
if let eventTap {
CGEvent.tapEnable(tap: eventTap, enable: true)
}
}
}
func emit(_ fields: [String: Any?]) {
let compacted = fields.compactMapValues { $0 }
if let data = try? JSONSerialization.data(withJSONObject: compacted, options: []),
let line = String(data: data, encoding: .utf8)
{
print(line)
fflush(stdout)
}
}
func stringAttribute(_ element: AXUIElement, _ attribute: String) -> String? {
var value: CFTypeRef?
let result = AXUIElementCopyAttributeValue(element, attribute as CFString, &value)
guard result == .success else {
return nil
}
return value as? String
}
func parentElement(_ element: AXUIElement) -> AXUIElement? {
var value: CFTypeRef?
let result = AXUIElementCopyAttributeValue(element, kAXParentAttribute as CFString, &value)
guard result == .success else {
return nil
}
guard CFGetTypeID(value) == AXUIElementGetTypeID() else {
return nil
}
return (value as! AXUIElement)
}
func roleDescription(_ element: AXUIElement) -> String? {
var value: CFTypeRef?
let result = AXUIElementCopyAttributeValue(element, kAXRoleDescriptionAttribute as CFString, &value)
guard result == .success else {
return nil
}
return value as? String
}
func actionNames(_ element: AXUIElement) -> [String] {
var value: CFArray?
let result = AXUIElementCopyActionNames(element, &value)
guard result == .success, let value else {
return []
}
return (value as NSArray).compactMap { $0 as? String }
}
func isTextInputRole(_ role: String?) -> Bool {
role == "AXTextField" ||
role == "AXTextArea" ||
role == "AXTextView" ||
role == "AXComboBox"
}
func isPointerRole(_ role: String?, _ subrole: String?, _ description: String?) -> Bool {
if role == "AXLink" ||
subrole?.localizedCaseInsensitiveContains("link") == true ||
description?.contains("link") == true
{
return true
}
return role == "AXButton" ||
role == "AXMenuButton" ||
role == "AXPopUpButton" ||
role == "AXCheckBox" ||
role == "AXRadioButton" ||
role == "AXSwitch" ||
role == "AXDisclosureTriangle" ||
role == "AXTab" ||
role == "AXMenuItem"
}
func cursorTypeForElement(_ element: AXUIElement) -> String? {
var current: AXUIElement? = element
for _ in 0..<5 {
guard let element = current else {
break
}
let role = stringAttribute(element, kAXRoleAttribute)
let subrole = stringAttribute(element, kAXSubroleAttribute)
let description = roleDescription(element)?.lowercased()
if isTextInputRole(role) {
return "text"
}
if isPointerRole(role, subrole, description) {
return "pointer"
}
current = parentElement(element)
}
return nil
}
func accessibilityPointForMouse() -> CGPoint {
let mouse = NSEvent.mouseLocation
let primaryHeight = NSScreen.screens.first?.frame.height ?? NSScreen.main?.frame.height ?? 0
return CGPoint(x: mouse.x, y: primaryHeight - mouse.y)
}
func currentCursorType() -> String? {
guard AXIsProcessTrusted() else {
return nil
}
let point = accessibilityPointForMouse()
let systemWide = AXUIElementCreateSystemWide()
var element: AXUIElement?
let result = AXUIElementCopyElementAtPosition(
systemWide,
Float(point.x),
Float(point.y),
&element
)
guard result == .success, let element else {
return nil
}
// 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 {
Int(Date().timeIntervalSince1970 * 1000)
}
func leftButtonDown() -> Bool {
CGEventSource.buttonState(.hidSystemState, button: .left)
}
func requestAccessibilityTrust() -> Bool {
let options = [
kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String: true
] as CFDictionary
return AXIsProcessTrustedWithOptions(options)
}
let request: CursorHelperRequest
if CommandLine.arguments.count >= 2,
let data = CommandLine.arguments[1].data(using: .utf8),
let decoded = try? JSONDecoder().decode(CursorHelperRequest.self, from: data)
{
request = decoded
} else {
request = CursorHelperRequest(sampleIntervalMs: nil)
}
let intervalMs = max(8, request.sampleIntervalMs ?? 33)
let accessibilityTrusted = requestAccessibilityTrust()
let mouseTracker = MouseButtonTracker()
let mouseTapReady = mouseTracker.start()
emit([
"type": "ready",
"timestampMs": timestampMs(),
"accessibilityTrusted": accessibilityTrusted,
"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 {
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)
}
}
@@ -0,0 +1,673 @@
import AVFoundation
import CoreGraphics
import CoreMedia
import Foundation
import ScreenCaptureKit
struct Rectangle: Decodable {
let x: Double
let y: Double
let width: Double
let height: Double
}
struct RecordingRequest: Decodable {
struct Source: Decodable {
let type: String
let sourceId: String
let displayId: UInt32?
let windowId: UInt32?
let bounds: Rectangle?
}
struct Video: Decodable {
let fps: Int
let width: Int
let height: Int
let bitrate: Int?
let hideSystemCursor: Bool
}
struct Audio: Decodable {
struct SystemAudio: Decodable {
let enabled: Bool
}
struct Microphone: Decodable {
let enabled: Bool
let deviceId: String?
let deviceName: String?
let gain: Double
}
let system: SystemAudio
let microphone: Microphone
}
struct Webcam: Decodable {
let enabled: Bool
let deviceId: String?
let deviceName: String?
let width: Int
let height: Int
let fps: Int
}
struct Cursor: Decodable {
let mode: String
}
struct Outputs: Decodable {
let screenPath: String
let manifestPath: String?
}
let schemaVersion: Int?
let recordingId: Int?
let source: Source
let video: Video
let audio: Audio
let webcam: Webcam
let cursor: Cursor
let outputs: Outputs
}
enum HelperError: Error, CustomStringConvertible {
case invalidArguments
case unsupportedMacOS
case unsupportedFeature(String)
case sourceNotFound(String)
case invalidSourceType(String)
case permissionDenied(String)
case writerSetupFailed(String)
var description: String {
switch self {
case .invalidArguments:
return "Expected one JSON recording request argument."
case .unsupportedMacOS:
return "ScreenCaptureKit recording requires macOS 13 or newer."
case .unsupportedFeature(let message):
return message
case .sourceNotFound(let message):
return message
case .invalidSourceType(let sourceType):
return "Unsupported source type: \(sourceType)."
case .permissionDenied(let message):
return message
case .writerSetupFailed(let message):
return message
}
}
}
func emit(_ fields: [String: Any]) {
if let data = try? JSONSerialization.data(withJSONObject: fields, options: []),
let line = String(data: data, encoding: .utf8)
{
print(line)
fflush(stdout)
}
}
func emitError(code: String, message: String) {
emit([
"event": "error",
"code": code,
"message": message,
])
}
@available(macOS 13.0, *)
final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate {
private struct CaptureTarget {
let filter: SCContentFilter
let width: Int
let height: Int
}
private let request: RecordingRequest
private let sampleQueue = DispatchQueue(label: "app.openscreen.sck-helper.samples")
private let stateQueue = DispatchQueue(label: "app.openscreen.sck-helper.state")
private var stream: SCStream?
private var writer: AVAssetWriter?
private var videoInput: AVAssetWriterInput?
private var systemAudioInput: AVAssetWriterInput?
private var microphoneAudioInput: AVAssetWriterInput?
private var didStartWriting = false
private var didEmitRecordingStarted = false
private var isStopping = false
private var isPaused = false
private var pauseStartedAt: CMTime?
private var totalPausedDuration = CMTime.zero
private var nativeMicrophoneEnabled = false
private var outputWidth = 1920
private var outputHeight = 1080
private let microphoneOutputTypeRawValue = 2
private let hostClock = CMClockGetHostTimeClock()
init(request: RecordingRequest) {
self.request = request
}
func start() async throws {
try ensureRequestedPermissions()
let content = try await SCShareableContent.excludingDesktopWindows(
false,
onScreenWindowsOnly: true
)
let target = try makeCaptureTarget(from: content)
outputWidth = target.width
outputHeight = target.height
let configuration = makeStreamConfiguration()
let stream = SCStream(filter: target.filter, configuration: configuration, delegate: self)
try stream.addStreamOutput(self, type: .screen, sampleHandlerQueue: sampleQueue)
if request.audio.system.enabled {
try stream.addStreamOutput(self, type: .audio, sampleHandlerQueue: sampleQueue)
}
if nativeMicrophoneEnabled {
guard let microphoneOutputType = SCStreamOutputType(rawValue: microphoneOutputTypeRawValue) else {
throw HelperError.unsupportedFeature(
"Native microphone capture requires a macOS version with ScreenCaptureKit microphone output."
)
}
try stream.addStreamOutput(self, type: microphoneOutputType, sampleHandlerQueue: sampleQueue)
}
try setupWriter()
self.stream = stream
emit(["event": "ready", "schemaVersion": 1])
try await stream.startCapture()
}
func stop() async {
let shouldStop = stateQueue.sync {
if isStopping {
return false
}
isStopping = true
return true
}
if !shouldStop {
return
}
do {
try await stream?.stopCapture()
} catch {
emit([
"event": "warning",
"code": "stop-capture-failed",
"message": "\(error)",
])
}
await finishWriter()
}
func pause() {
let didPause = stateQueue.sync {
if isStopping || isPaused {
return false
}
isPaused = true
pauseStartedAt = CMClockGetTime(hostClock)
return true
}
if didPause {
emit([
"event": "recording-paused",
"timestampMs": Int(Date().timeIntervalSince1970 * 1000),
])
}
}
func resume() {
let didResume = stateQueue.sync {
if isStopping || !isPaused {
return false
}
if let pauseStartedAt {
let now = CMClockGetTime(hostClock)
totalPausedDuration = CMTimeAdd(
totalPausedDuration,
CMTimeSubtract(now, pauseStartedAt)
)
}
isPaused = false
pauseStartedAt = nil
return true
}
if didResume {
emit([
"event": "recording-resumed",
"timestampMs": Int(Date().timeIntervalSince1970 * 1000),
])
}
}
func stream(_ stream: SCStream, didStopWithError error: Error) {
emitError(code: "capture-stopped-with-error", message: "\(error)")
Task {
await stop()
}
}
func stream(_ stream: SCStream, didOutputSampleBuffer sampleBuffer: CMSampleBuffer, of type: SCStreamOutputType) {
guard CMSampleBufferDataIsReady(sampleBuffer) else {
return
}
let pauseState = currentPauseState()
if pauseState.paused {
return
}
guard let sampleBuffer = retimedSampleBuffer(sampleBuffer, subtracting: pauseState.offset) else {
return
}
if type == .audio {
appendAudioSampleBuffer(sampleBuffer, to: systemAudioInput)
return
}
if type.rawValue == microphoneOutputTypeRawValue {
appendAudioSampleBuffer(sampleBuffer, to: microphoneAudioInput)
return
}
guard type == .screen else {
return
}
guard isCompleteFrame(sampleBuffer) else {
return
}
guard let videoInput, let writer else {
return
}
let presentationTime = CMSampleBufferGetPresentationTimeStamp(sampleBuffer)
if !didStartWriting {
writer.startWriting()
writer.startSession(atSourceTime: presentationTime)
didStartWriting = true
}
if videoInput.isReadyForMoreMediaData {
if videoInput.append(sampleBuffer), !didEmitRecordingStarted {
didEmitRecordingStarted = true
emit([
"event": "recording-started",
"timestampMs": Int(Date().timeIntervalSince1970 * 1000),
"width": outputWidth,
"height": outputHeight,
])
}
}
}
private func ensureRequestedPermissions() throws {
if !CGPreflightScreenCaptureAccess() {
let granted = CGRequestScreenCaptureAccess()
if !granted {
throw HelperError.permissionDenied("Screen recording permission is required for ScreenCaptureKit capture.")
}
}
if request.audio.microphone.enabled {
switch AVCaptureDevice.authorizationStatus(for: .audio) {
case .authorized:
break
case .notDetermined:
let semaphore = DispatchSemaphore(value: 0)
AVCaptureDevice.requestAccess(for: .audio) { _ in
semaphore.signal()
}
let waitResult = semaphore.wait(timeout: .now() + 30)
if waitResult == .timedOut || AVCaptureDevice.authorizationStatus(for: .audio) != .authorized {
throw HelperError.permissionDenied("Microphone permission is required for native microphone capture.")
}
default:
throw HelperError.permissionDenied("Microphone permission is required for native microphone capture.")
}
}
}
private func makeCaptureTarget(from content: SCShareableContent) throws -> CaptureTarget {
switch request.source.type {
case "display":
guard let displayId = request.source.displayId else {
throw HelperError.sourceNotFound("Display capture requires source.displayId.")
}
guard let display = content.displays.first(where: { $0.displayID == displayId }) else {
throw HelperError.sourceNotFound("No ScreenCaptureKit display found for id \(displayId).")
}
let width = Int(CGDisplayPixelsWide(display.displayID))
let height = Int(CGDisplayPixelsHigh(display.displayID))
return CaptureTarget(
filter: SCContentFilter(display: display, excludingWindows: []),
width: clampCaptureDimension(width, fallback: request.video.width),
height: clampCaptureDimension(height, fallback: request.video.height)
)
case "window":
guard let windowId = request.source.windowId else {
throw HelperError.sourceNotFound("Window capture requires source.windowId.")
}
guard let window = content.windows.first(where: { $0.windowID == windowId }) else {
throw HelperError.sourceNotFound("No ScreenCaptureKit window found for id \(windowId).")
}
let candidateDisplay = content.displays.first {
$0.frame.intersects(window.frame) || $0.frame.contains(CGPoint(x: window.frame.midX, y: window.frame.midY))
}
let scaleFactor = Self.scaleFactor(for: candidateDisplay?.displayID ?? CGMainDisplayID())
let width = Int(window.frame.width) * scaleFactor
let height = Int(window.frame.height) * scaleFactor
return CaptureTarget(
filter: SCContentFilter(desktopIndependentWindow: window),
width: clampCaptureDimension(width, fallback: request.video.width),
height: clampCaptureDimension(height, fallback: request.video.height)
)
default:
throw HelperError.invalidSourceType(request.source.type)
}
}
private func makeStreamConfiguration() -> SCStreamConfiguration {
let configuration = SCStreamConfiguration()
configuration.width = outputWidth
configuration.height = outputHeight
configuration.minimumFrameInterval = CMTime(value: 1, timescale: CMTimeScale(max(1, request.video.fps)))
configuration.queueDepth = 6
configuration.showsCursor = !request.video.hideSystemCursor
configuration.pixelFormat = kCVPixelFormatType_32BGRA
configuration.sampleRate = 48_000
configuration.channelCount = 2
configuration.excludesCurrentProcessAudio = true
configuration.capturesAudio = request.audio.system.enabled
if request.audio.microphone.enabled {
guard supportsNativeMicrophoneCapture(streamConfig: configuration) else {
nativeMicrophoneEnabled = false
emit([
"event": "warning",
"code": "microphone-unavailable",
"message": "Native microphone capture requires ScreenCaptureKit microphone support on this macOS version.",
])
return configuration
}
nativeMicrophoneEnabled = true
configuration.capturesAudio = true
configuration.setValue(true, forKey: "captureMicrophone")
if let deviceId = resolveMicrophoneCaptureDeviceID() {
configuration.setValue(deviceId, forKey: "microphoneCaptureDeviceID")
}
} else {
nativeMicrophoneEnabled = false
}
return configuration
}
private func setupWriter() throws {
let outputUrl = URL(fileURLWithPath: request.outputs.screenPath)
try? FileManager.default.removeItem(at: outputUrl)
try FileManager.default.createDirectory(
at: outputUrl.deletingLastPathComponent(),
withIntermediateDirectories: true
)
let writer = try AVAssetWriter(outputURL: outputUrl, fileType: .mp4)
let settings: [String: Any] = [
AVVideoCodecKey: AVVideoCodecType.h264,
AVVideoWidthKey: outputWidth,
AVVideoHeightKey: outputHeight,
AVVideoCompressionPropertiesKey: [
AVVideoAverageBitRateKey: request.video.bitrate ?? 18_000_000,
AVVideoExpectedSourceFrameRateKey: request.video.fps,
],
]
let input = AVAssetWriterInput(mediaType: .video, outputSettings: settings)
input.expectsMediaDataInRealTime = true
guard writer.canAdd(input) else {
throw HelperError.writerSetupFailed("Unable to add H.264 video input to AVAssetWriter.")
}
writer.add(input)
self.writer = writer
self.videoInput = input
if request.audio.system.enabled {
systemAudioInput = try addAudioInput(to: writer, bitRate: 192_000)
}
if nativeMicrophoneEnabled {
microphoneAudioInput = try addAudioInput(to: writer, bitRate: 128_000)
}
}
private func finishWriter() async {
guard let writer else {
return
}
videoInput?.markAsFinished()
systemAudioInput?.markAsFinished()
microphoneAudioInput?.markAsFinished()
await withCheckedContinuation { continuation in
writer.finishWriting {
continuation.resume()
}
}
if writer.status == .completed {
emit([
"event": "recording-stopped",
"screenPath": request.outputs.screenPath,
])
} else {
emitError(
code: "writer-failed",
message: writer.error.map { "\($0)" } ?? "AVAssetWriter failed with status \(writer.status.rawValue)."
)
}
}
private func addAudioInput(to writer: AVAssetWriter, bitRate: Int) throws -> AVAssetWriterInput {
let settings: [String: Any] = [
AVFormatIDKey: kAudioFormatMPEG4AAC,
AVSampleRateKey: 48_000,
AVNumberOfChannelsKey: 2,
AVEncoderBitRateKey: bitRate,
]
let input = AVAssetWriterInput(mediaType: .audio, outputSettings: settings)
input.expectsMediaDataInRealTime = true
guard writer.canAdd(input) else {
throw HelperError.writerSetupFailed("Unable to add AAC audio input to AVAssetWriter.")
}
writer.add(input)
return input
}
private func appendAudioSampleBuffer(_ sampleBuffer: CMSampleBuffer, to input: AVAssetWriterInput?) {
guard didStartWriting else {
return
}
guard let input, input.isReadyForMoreMediaData else {
return
}
input.append(sampleBuffer)
}
private func currentPauseState() -> (paused: Bool, offset: CMTime) {
stateQueue.sync {
(isPaused, totalPausedDuration)
}
}
private func retimedSampleBuffer(_ sampleBuffer: CMSampleBuffer, subtracting offset: CMTime) -> CMSampleBuffer? {
if !offset.isValid || offset == .zero {
return sampleBuffer
}
let sampleCount = CMSampleBufferGetNumSamples(sampleBuffer)
if sampleCount <= 0 {
return sampleBuffer
}
var timing = Array(repeating: CMSampleTimingInfo(), count: sampleCount)
let timingStatus = CMSampleBufferGetSampleTimingInfoArray(
sampleBuffer,
entryCount: sampleCount,
arrayToFill: &timing,
entriesNeededOut: nil
)
if timingStatus != noErr {
emit([
"event": "warning",
"code": "sample-retime-failed",
"message": "Unable to read sample timing info: \(timingStatus).",
])
return sampleBuffer
}
for index in timing.indices {
if timing[index].presentationTimeStamp.isValid {
timing[index].presentationTimeStamp = CMTimeSubtract(
timing[index].presentationTimeStamp,
offset
)
}
if timing[index].decodeTimeStamp.isValid {
timing[index].decodeTimeStamp = CMTimeSubtract(timing[index].decodeTimeStamp, offset)
}
}
var retimedBuffer: CMSampleBuffer?
let copyStatus = CMSampleBufferCreateCopyWithNewTiming(
allocator: kCFAllocatorDefault,
sampleBuffer: sampleBuffer,
sampleTimingEntryCount: sampleCount,
sampleTimingArray: &timing,
sampleBufferOut: &retimedBuffer
)
if copyStatus != noErr {
emit([
"event": "warning",
"code": "sample-retime-failed",
"message": "Unable to copy sample timing info: \(copyStatus).",
])
return sampleBuffer
}
return retimedBuffer
}
private func isCompleteFrame(_ sampleBuffer: CMSampleBuffer) -> Bool {
guard let attachments = CMSampleBufferGetSampleAttachmentsArray(
sampleBuffer,
createIfNecessary: false
) as? [[SCStreamFrameInfo: Any]],
let attachment = attachments.first,
let statusRawValue = attachment[SCStreamFrameInfo.status] as? Int,
let status = SCFrameStatus(rawValue: statusRawValue)
else {
return true
}
return status == .complete
}
private func clampCaptureDimension(_ value: Int, fallback: Int) -> Int {
let requested = max(2, fallback)
let candidate = value > 0 ? value : requested
let clamped = min(candidate, requested)
return max(2, clamped - (clamped % 2))
}
private static func scaleFactor(for displayId: CGDirectDisplayID) -> Int {
guard let mode = CGDisplayCopyDisplayMode(displayId) else {
return 1
}
return max(1, mode.pixelWidth / max(1, mode.width))
}
private func supportsNativeMicrophoneCapture(streamConfig: SCStreamConfiguration) -> Bool {
streamConfig.responds(to: Selector(("setCaptureMicrophone:"))) &&
streamConfig.responds(to: Selector(("setMicrophoneCaptureDeviceID:"))) &&
SCStreamOutputType(rawValue: microphoneOutputTypeRawValue) != nil
}
private func resolveMicrophoneCaptureDeviceID() -> String? {
let devices = AVCaptureDevice.devices(for: .audio)
if let deviceName = request.audio.microphone.deviceName?.trimmingCharacters(in: .whitespacesAndNewlines),
!deviceName.isEmpty,
let device = devices.first(where: { $0.localizedName == deviceName })
{
return device.uniqueID
}
if let deviceId = request.audio.microphone.deviceId?.trimmingCharacters(in: .whitespacesAndNewlines),
!deviceId.isEmpty,
devices.contains(where: { $0.uniqueID == deviceId })
{
return deviceId
}
return nil
}
}
@main
struct OpenScreenScreenCaptureKitHelper {
static func main() async {
do {
guard CommandLine.arguments.count == 2 else {
throw HelperError.invalidArguments
}
guard #available(macOS 13.0, *) else {
throw HelperError.unsupportedMacOS
}
let requestData = Data(CommandLine.arguments[1].utf8)
let decoder = JSONDecoder()
let request = try decoder.decode(RecordingRequest.self, from: requestData)
let recorder = ScreenCaptureRecorder(request: request)
let stopTask = Task.detached {
while let line = readLine() {
let command = line.trimmingCharacters(in: .whitespacesAndNewlines)
switch command {
case "pause":
recorder.pause()
case "resume":
recorder.resume()
case "stop":
await recorder.stop()
exit(0)
default:
break
}
}
}
try await recorder.start()
await stopTask.value
} catch let error as HelperError {
emitError(code: "helper-error", message: error.description)
exit(1)
} catch {
emitError(code: "helper-error", message: "\(error)")
exit(1)
}
}
}
@@ -0,0 +1,67 @@
cmake_minimum_required(VERSION 3.20)
# The local Windows SDK image used by some contributors can miss gdi32.lib,
# while CMake's default MSVC console template links it unconditionally. This
# helper does not use GDI, so keep the standard library set minimal and explicit.
set(CMAKE_CXX_STANDARD_LIBRARIES
"kernel32.lib user32.lib shell32.lib ole32.lib oleaut32.lib uuid.lib comdlg32.lib advapi32.lib"
CACHE STRING "" FORCE)
project(openscreen-wgc-capture LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
add_executable(wgc-capture
src/audio_sample_utils.cpp
src/audio_sample_utils.h
src/dshow_webcam_capture.cpp
src/dshow_webcam_capture.h
src/main.cpp
src/mf_encoder.cpp
src/mf_encoder.h
src/monitor_utils.cpp
src/monitor_utils.h
src/wasapi_loopback_capture.cpp
src/wasapi_loopback_capture.h
src/webcam_capture.cpp
src/webcam_capture.h
src/wgc_session.cpp
src/wgc_session.h
)
target_compile_definitions(wgc-capture PRIVATE
NOMINMAX
WIN32_LEAN_AND_MEAN
_WIN32_WINNT=0x0A00
)
target_compile_options(wgc-capture PRIVATE /EHsc /W4 /utf-8 /await)
target_link_libraries(wgc-capture PRIVATE
d3d11
dxgi
mf
mfplat
mfreadwrite
mfuuid
runtimeobject
windowsapp
)
add_executable(cursor-sampler
src/cursor-sampler.cpp
)
target_compile_definitions(cursor-sampler PRIVATE
NOMINMAX
_WIN32_WINNT=0x0A00
)
target_compile_options(cursor-sampler PRIVATE /EHsc /W4 /utf-8)
target_link_libraries(cursor-sampler PRIVATE
gdi32
gdiplus
)
@@ -0,0 +1,439 @@
#include "audio_sample_utils.h"
#include <mfapi.h>
#include <algorithm>
#include <chrono>
#include <cmath>
#include <cstring>
#include <limits>
namespace {
bool isFloatFormat(const AudioInputFormat& format) {
return format.subtype == MFAudioFormat_Float && format.bitsPerSample == 32;
}
bool isPcmFormat(const AudioInputFormat& format, UINT32 bitsPerSample) {
return format.subtype == MFAudioFormat_PCM && format.bitsPerSample == bitsPerSample;
}
template <typename T>
T clampTo(double value) {
const double minValue = static_cast<double>(std::numeric_limits<T>::min());
const double maxValue = static_cast<double>(std::numeric_limits<T>::max());
return static_cast<T>(std::clamp(std::round(value), minValue, maxValue));
}
size_t bytesPerSample(const AudioInputFormat& format) {
return format.bitsPerSample / 8;
}
double readSampleAsDouble(const BYTE* source, const AudioInputFormat& format, size_t frameIndex, UINT32 channelIndex) {
if (!source || format.blockAlign == 0 || channelIndex >= format.channels) {
return 0.0;
}
const size_t offset = frameIndex * format.blockAlign + channelIndex * bytesPerSample(format);
if (isFloatFormat(format)) {
return static_cast<double>(*reinterpret_cast<const float*>(source + offset));
}
if (isPcmFormat(format, 16)) {
return static_cast<double>(*reinterpret_cast<const int16_t*>(source + offset)) / 32768.0;
}
if (isPcmFormat(format, 32)) {
return static_cast<double>(*reinterpret_cast<const int32_t*>(source + offset)) / 2147483648.0;
}
return 0.0;
}
void writeSampleFromDouble(BYTE* destination, const AudioInputFormat& format, size_t frameIndex, UINT32 channelIndex, double value) {
if (!destination || format.blockAlign == 0 || channelIndex >= format.channels) {
return;
}
const double clamped = std::clamp(value, -1.0, 1.0);
const size_t offset = frameIndex * format.blockAlign + channelIndex * bytesPerSample(format);
if (isFloatFormat(format)) {
*reinterpret_cast<float*>(destination + offset) = static_cast<float>(clamped);
return;
}
if (isPcmFormat(format, 16)) {
*reinterpret_cast<int16_t*>(destination + offset) = clampTo<int16_t>(clamped * 32767.0);
return;
}
if (isPcmFormat(format, 32)) {
*reinterpret_cast<int32_t*>(destination + offset) = clampTo<int32_t>(clamped * 2147483647.0);
}
}
double readMappedChannel(const BYTE* source, const AudioInputFormat& format, size_t frameIndex, UINT32 targetChannel, UINT32 targetChannels) {
if (format.channels == 0) {
return 0.0;
}
if (format.channels == targetChannels && targetChannel < format.channels) {
return readSampleAsDouble(source, format, frameIndex, targetChannel);
}
if (format.channels == 1) {
return readSampleAsDouble(source, format, frameIndex, 0);
}
if (targetChannels == 1) {
double sum = 0.0;
for (UINT32 channel = 0; channel < format.channels; ++channel) {
sum += readSampleAsDouble(source, format, frameIndex, channel);
}
return sum / static_cast<double>(format.channels);
}
return readSampleAsDouble(source, format, frameIndex, std::min(targetChannel, format.channels - 1));
}
} // namespace
constexpr int64_t HnsPerSecond = 10'000'000;
bool sameAudioFormatForMixing(const AudioInputFormat& left, const AudioInputFormat& right) {
return left.subtype == right.subtype &&
left.sampleRate == right.sampleRate &&
left.channels == right.channels &&
left.bitsPerSample == right.bitsPerSample &&
left.blockAlign == right.blockAlign &&
left.avgBytesPerSec == right.avgBytesPerSec;
}
AudioInputFormat makeAacCompatibleAudioFormat(const AudioInputFormat& source) {
AudioInputFormat format{};
format.subtype = MFAudioFormat_PCM;
format.sampleRate = source.sampleRate > 0 ? source.sampleRate : 48000;
format.channels = 2;
format.bitsPerSample = 16;
format.blockAlign = format.channels * (format.bitsPerSample / 8);
format.avgBytesPerSec = format.sampleRate * format.blockAlign;
return format;
}
void copyAudioWithGain(
const BYTE* source,
DWORD byteCount,
const AudioInputFormat& format,
double gain,
std::vector<BYTE>& destination) {
destination.resize(byteCount);
if (!source || byteCount == 0) {
std::fill(destination.begin(), destination.end(), static_cast<BYTE>(0));
return;
}
if (std::abs(gain - 1.0) < 0.0001) {
std::memcpy(destination.data(), source, byteCount);
return;
}
if (isFloatFormat(format)) {
const auto* input = reinterpret_cast<const float*>(source);
auto* output = reinterpret_cast<float*>(destination.data());
const size_t sampleCount = byteCount / sizeof(float);
for (size_t index = 0; index < sampleCount; index += 1) {
output[index] = static_cast<float>(std::clamp(input[index] * gain, -1.0, 1.0));
}
return;
}
if (isPcmFormat(format, 16)) {
const auto* input = reinterpret_cast<const int16_t*>(source);
auto* output = reinterpret_cast<int16_t*>(destination.data());
const size_t sampleCount = byteCount / sizeof(int16_t);
for (size_t index = 0; index < sampleCount; index += 1) {
output[index] = clampTo<int16_t>(static_cast<double>(input[index]) * gain);
}
return;
}
if (isPcmFormat(format, 32)) {
const auto* input = reinterpret_cast<const int32_t*>(source);
auto* output = reinterpret_cast<int32_t*>(destination.data());
const size_t sampleCount = byteCount / sizeof(int32_t);
for (size_t index = 0; index < sampleCount; index += 1) {
output[index] = clampTo<int32_t>(static_cast<double>(input[index]) * gain);
}
return;
}
std::memcpy(destination.data(), source, byteCount);
}
void convertAudioWithGain(
const BYTE* source,
DWORD byteCount,
const AudioInputFormat& sourceFormat,
const AudioInputFormat& targetFormat,
double gain,
std::vector<BYTE>& destination) {
if (!source || byteCount == 0 || sourceFormat.blockAlign == 0 || targetFormat.blockAlign == 0 ||
sourceFormat.sampleRate == 0 || targetFormat.sampleRate == 0 || sourceFormat.channels == 0 ||
targetFormat.channels == 0) {
destination.clear();
return;
}
if (sameAudioFormatForMixing(sourceFormat, targetFormat)) {
copyAudioWithGain(source, byteCount, targetFormat, gain, destination);
return;
}
const size_t sourceFrames = byteCount / sourceFormat.blockAlign;
if (sourceFrames == 0) {
destination.clear();
return;
}
const double rateRatio = static_cast<double>(targetFormat.sampleRate) /
static_cast<double>(sourceFormat.sampleRate);
const size_t targetFrames = std::max<size_t>(1, static_cast<size_t>(std::llround(sourceFrames * rateRatio)));
destination.assign(targetFrames * targetFormat.blockAlign, 0);
for (size_t targetFrame = 0; targetFrame < targetFrames; ++targetFrame) {
const double sourcePosition = static_cast<double>(targetFrame) / rateRatio;
const size_t sourceFrame = std::min(
sourceFrames - 1,
static_cast<size_t>(std::llround(sourcePosition)));
for (UINT32 channel = 0; channel < targetFormat.channels; ++channel) {
const double sample = readMappedChannel(
source,
sourceFormat,
sourceFrame,
channel,
targetFormat.channels);
writeSampleFromDouble(destination.data(), targetFormat, targetFrame, channel, sample * gain);
}
}
}
void mixAudioInPlace(
std::vector<BYTE>& destination,
const BYTE* source,
DWORD byteCount,
const AudioInputFormat& format) {
if (!source || byteCount == 0 || destination.empty()) {
return;
}
const size_t mixByteCount = std::min(destination.size(), static_cast<size_t>(byteCount));
if (isFloatFormat(format)) {
auto* output = reinterpret_cast<float*>(destination.data());
const auto* input = reinterpret_cast<const float*>(source);
const size_t sampleCount = mixByteCount / sizeof(float);
for (size_t index = 0; index < sampleCount; index += 1) {
output[index] = static_cast<float>(std::clamp(output[index] + input[index], -1.0f, 1.0f));
}
return;
}
if (isPcmFormat(format, 16)) {
auto* output = reinterpret_cast<int16_t*>(destination.data());
const auto* input = reinterpret_cast<const int16_t*>(source);
const size_t sampleCount = mixByteCount / sizeof(int16_t);
for (size_t index = 0; index < sampleCount; index += 1) {
output[index] = clampTo<int16_t>(
static_cast<double>(output[index]) + static_cast<double>(input[index]));
}
return;
}
if (isPcmFormat(format, 32)) {
auto* output = reinterpret_cast<int32_t*>(destination.data());
const auto* input = reinterpret_cast<const int32_t*>(source);
const size_t sampleCount = mixByteCount / sizeof(int32_t);
for (size_t index = 0; index < sampleCount; index += 1) {
output[index] = clampTo<int32_t>(
static_cast<double>(output[index]) + static_cast<double>(input[index]));
}
}
}
AudioMixer::AudioMixer(
const AudioInputFormat& format,
const AudioInputFormat& systemFormat,
const AudioInputFormat& microphoneFormat,
bool includeSystem,
bool includeMicrophone,
double microphoneGain,
OutputCallback output)
: format_(format),
systemFormat_(systemFormat),
microphoneFormat_(microphoneFormat),
includeSystem_(includeSystem),
includeMicrophone_(includeMicrophone),
microphoneGain_(microphoneGain),
output_(std::move(output)) {}
AudioMixer::~AudioMixer() {
stop();
}
bool AudioMixer::start() {
if (!output_ || format_.sampleRate == 0 || format_.blockAlign == 0) {
return false;
}
stopRequested_ = false;
emittedFrames_ = 0;
timelineStarted_ = false;
paused_ = false;
thread_ = std::thread([this] {
mixLoop();
});
return true;
}
void AudioMixer::beginTimeline() {
{
std::scoped_lock lock(mutex_);
systemQueue_.clear();
microphoneQueue_.clear();
emittedFrames_ = 0;
timelineStarted_ = true;
}
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();
if (thread_.joinable()) {
thread_.join();
}
}
void AudioMixer::pushSystem(const BYTE* data, DWORD byteCount) {
if (!includeSystem_ || stopRequested_) {
return;
}
{
std::scoped_lock lock(mutex_);
if (paused_) {
return;
}
append(systemQueue_, data, byteCount, systemFormat_, 1.0);
}
cv_.notify_all();
}
void AudioMixer::pushMicrophone(const BYTE* data, DWORD byteCount) {
if (!includeMicrophone_ || stopRequested_) {
return;
}
{
std::scoped_lock lock(mutex_);
if (paused_) {
return;
}
append(microphoneQueue_, data, byteCount, microphoneFormat_, microphoneGain_);
}
cv_.notify_all();
}
void AudioMixer::append(
std::vector<BYTE>& queue,
const BYTE* data,
DWORD byteCount,
const AudioInputFormat& sourceFormat,
double gain) {
if (!data || byteCount == 0) {
return;
}
convertAudioWithGain(data, byteCount, sourceFormat, format_, gain, gainBuffer_);
queue.insert(queue.end(), gainBuffer_.begin(), gainBuffer_.end());
}
bool AudioMixer::pop(std::vector<BYTE>& queue, std::vector<BYTE>& chunk, size_t byteCount) {
if (queue.empty()) {
chunk.assign(byteCount, 0);
return false;
}
chunk.assign(byteCount, 0);
const size_t copiedBytes = std::min(byteCount, queue.size());
std::memcpy(chunk.data(), queue.data(), copiedBytes);
queue.erase(queue.begin(), queue.begin() + static_cast<std::ptrdiff_t>(copiedBytes));
return copiedBytes > 0;
}
void AudioMixer::mixLoop() {
const uint32_t chunkFrames = std::max<uint32_t>(1, format_.sampleRate / 100);
const size_t chunkBytes = static_cast<size_t>(chunkFrames) * format_.blockAlign;
std::vector<BYTE> mixedChunk;
std::vector<BYTE> sourceChunk;
std::chrono::steady_clock::time_point audioClockStart;
bool audioClockStarted = false;
while (true) {
{
std::unique_lock lock(mutex_);
cv_.wait_for(lock, std::chrono::milliseconds(20), [&] {
const bool hasSystem = !includeSystem_ || systemQueue_.size() >= chunkBytes;
const bool hasMicrophone = !includeMicrophone_ || microphoneQueue_.size() >= chunkBytes;
const bool hasAnySource = !systemQueue_.empty() || !microphoneQueue_.empty();
return stopRequested_.load() ||
(timelineStarted_ && !paused_ && (hasSystem || hasMicrophone) && hasAnySource);
});
if (stopRequested_) {
break;
}
if (!timelineStarted_ || paused_) {
continue;
}
const bool hasAnyQueuedAudio = !systemQueue_.empty() || !microphoneQueue_.empty();
if (!hasAnyQueuedAudio) {
continue;
}
mixedChunk.assign(chunkBytes, 0);
if (includeSystem_) {
pop(systemQueue_, sourceChunk, chunkBytes);
mixAudioInPlace(mixedChunk, sourceChunk.data(), static_cast<DWORD>(sourceChunk.size()), format_);
}
if (includeMicrophone_) {
pop(microphoneQueue_, sourceChunk, chunkBytes);
mixAudioInPlace(mixedChunk, sourceChunk.data(), static_cast<DWORD>(sourceChunk.size()), format_);
}
}
if (!audioClockStarted) {
audioClockStart = std::chrono::steady_clock::now();
audioClockStarted = true;
}
const int64_t timestampHns =
static_cast<int64_t>((emittedFrames_ * HnsPerSecond) / format_.sampleRate);
const int64_t durationHns =
static_cast<int64_t>((static_cast<uint64_t>(chunkFrames) * HnsPerSecond) / format_.sampleRate);
if (!output_(mixedChunk.data(), static_cast<DWORD>(mixedChunk.size()), timestampHns, durationHns)) {
stopRequested_ = true;
break;
}
emittedFrames_ += chunkFrames;
const auto nextDeadline = audioClockStart +
std::chrono::duration_cast<std::chrono::steady_clock::duration>(
std::chrono::duration<double>(static_cast<double>(emittedFrames_) / format_.sampleRate));
std::this_thread::sleep_until(nextDeadline);
}
}
@@ -0,0 +1,87 @@
#pragma once
#include "mf_encoder.h"
#include <Windows.h>
#include <atomic>
#include <condition_variable>
#include <cstdint>
#include <functional>
#include <mutex>
#include <thread>
#include <vector>
bool sameAudioFormatForMixing(const AudioInputFormat& left, const AudioInputFormat& right);
AudioInputFormat makeAacCompatibleAudioFormat(const AudioInputFormat& source);
void copyAudioWithGain(
const BYTE* source,
DWORD byteCount,
const AudioInputFormat& format,
double gain,
std::vector<BYTE>& destination);
void convertAudioWithGain(
const BYTE* source,
DWORD byteCount,
const AudioInputFormat& sourceFormat,
const AudioInputFormat& targetFormat,
double gain,
std::vector<BYTE>& destination);
void mixAudioInPlace(
std::vector<BYTE>& destination,
const BYTE* source,
DWORD byteCount,
const AudioInputFormat& format);
class AudioMixer {
public:
using OutputCallback = std::function<bool(const BYTE* data, DWORD byteCount, int64_t timestampHns, int64_t durationHns)>;
AudioMixer(
const AudioInputFormat& format,
const AudioInputFormat& systemFormat,
const AudioInputFormat& microphoneFormat,
bool includeSystem,
bool includeMicrophone,
double microphoneGain,
OutputCallback output);
~AudioMixer();
AudioMixer(const AudioMixer&) = delete;
AudioMixer& operator=(const AudioMixer&) = delete;
bool start();
void beginTimeline();
void setPaused(bool paused);
void stop();
void pushSystem(const BYTE* data, DWORD byteCount);
void pushMicrophone(const BYTE* data, DWORD byteCount);
private:
void append(
std::vector<BYTE>& queue,
const BYTE* data,
DWORD byteCount,
const AudioInputFormat& sourceFormat,
double gain);
bool pop(std::vector<BYTE>& queue, std::vector<BYTE>& chunk, size_t byteCount);
void mixLoop();
AudioInputFormat format_{};
AudioInputFormat systemFormat_{};
AudioInputFormat microphoneFormat_{};
bool includeSystem_ = false;
bool includeMicrophone_ = false;
double microphoneGain_ = 1.0;
OutputCallback output_;
std::mutex mutex_;
std::condition_variable cv_;
std::vector<BYTE> systemQueue_;
std::vector<BYTE> microphoneQueue_;
std::vector<BYTE> gainBuffer_;
std::thread thread_;
std::atomic<bool> stopRequested_ = false;
bool timelineStarted_ = false;
bool paused_ = false;
uint64_t emittedFrames_ = 0;
};
@@ -0,0 +1,482 @@
#include <windows.h>
#include <gdiplus.h>
#include <objbase.h>
#include <atomic>
#include <algorithm>
#include <chrono>
#include <cinttypes>
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <iostream>
#include <mutex>
#include <string>
#include <thread>
#include <vector>
// ─────────────────────────────────────────────────────────────────────────────
// Global mouse-hook state
// ─────────────────────────────────────────────────────────────────────────────
static HHOOK g_mouseHook = nullptr;
static DWORD g_mainThreadId = 0;
static std::atomic<int> g_leftDownCount{0};
static std::atomic<int> g_leftUpCount{0};
static std::atomic<bool> g_stop{false};
static std::mutex g_stdoutMtx;
static LRESULT CALLBACK LowLevelMouseProc(int nCode, WPARAM wParam, LPARAM lParam) {
if (nCode >= 0) {
if (wParam == WM_LBUTTONDOWN) g_leftDownCount.fetch_add(1, std::memory_order_relaxed);
else if (wParam == WM_LBUTTONUP) g_leftUpCount.fetch_add(1, std::memory_order_relaxed);
}
return CallNextHookEx(g_mouseHook, nCode, wParam, lParam);
}
// ─────────────────────────────────────────────────────────────────────────────
// Utilities
// ─────────────────────────────────────────────────────────────────────────────
static int64_t nowMs() {
return static_cast<int64_t>(
std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::system_clock::now().time_since_epoch())
.count());
}
static void writeJsonLine(const std::string& json) {
std::lock_guard<std::mutex> lock(g_stdoutMtx);
std::cout << json << '\n';
std::cout.flush();
}
static std::string jsonEscape(const std::string& s) {
std::string r;
r.reserve(s.size());
for (unsigned char c : s) {
switch (c) {
case '"': r += "\\\""; break;
case '\\': r += "\\\\"; break;
case '\n': r += "\\n"; break;
case '\r': r += "\\r"; break;
case '\t': r += "\\t"; break;
default: r.push_back(static_cast<char>(c)); break;
}
}
return r;
}
static const char kBase64Chars[] =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
static std::string base64Encode(const uint8_t* data, size_t len) {
std::string out;
out.reserve(((len + 2) / 3) * 4);
for (size_t i = 0; i < len; i += 3) {
const uint32_t b =
(static_cast<uint32_t>(data[i]) << 16) |
(i + 1 < len ? static_cast<uint32_t>(data[i + 1]) << 8 : 0u) |
(i + 2 < len ? static_cast<uint32_t>(data[i + 2]) : 0u);
out.push_back(kBase64Chars[(b >> 18) & 0x3F]);
out.push_back(kBase64Chars[(b >> 12) & 0x3F]);
out.push_back(i + 1 < len ? kBase64Chars[(b >> 6) & 0x3F] : '=');
out.push_back(i + 2 < len ? kBase64Chars[(b ) & 0x3F] : '=');
}
return out;
}
// ─────────────────────────────────────────────────────────────────────────────
// GDI+ PNG encoder CLSID
// ─────────────────────────────────────────────────────────────────────────────
static bool getPngClsid(CLSID& out) {
UINT num = 0, sz = 0;
if (Gdiplus::GetImageEncodersSize(&num, &sz) != Gdiplus::Ok || sz == 0) return false;
std::vector<uint8_t> buf(sz);
auto* enc = reinterpret_cast<Gdiplus::ImageCodecInfo*>(buf.data());
if (Gdiplus::GetImageEncoders(num, sz, enc) != Gdiplus::Ok) return false;
for (UINT i = 0; i < num; ++i) {
if (std::wstring(enc[i].MimeType) == L"image/png") {
out = enc[i].Clsid;
return true;
}
}
return false;
}
// ─────────────────────────────────────────────────────────────────────────────
// Standard cursor-type lookup
// ─────────────────────────────────────────────────────────────────────────────
static const char* standardCursorType(HCURSOR hc) {
if (!hc) return nullptr;
static const struct { WORD id; const char* name; } kMap[] = {
{32512, "arrow"},
{32513, "text"},
{32514, "wait"},
{32515, "crosshair"},
{32516, "up-arrow"},
{32642, "resize-nwse"},
{32643, "resize-nesw"},
{32644, "resize-ew"},
{32645, "resize-ns"},
{32646, "move"},
{32648, "not-allowed"},
{32649, "pointer"},
{32650, "app-starting"},
{32651, "help"},
};
static constexpr int N = static_cast<int>(sizeof(kMap) / sizeof(kMap[0]));
static HCURSOR g_handles[N] = {};
static bool g_init = false;
if (!g_init) {
for (int i = 0; i < N; ++i)
g_handles[i] = LoadCursor(nullptr, MAKEINTRESOURCE(kMap[i].id));
g_init = true;
}
for (int i = 0; i < N; ++i)
if (g_handles[i] && g_handles[i] == hc) return kMap[i].name;
return nullptr;
}
// ─────────────────────────────────────────────────────────────────────────────
// Custom cursor-type detection (replicates the PowerShell heuristic)
// ─────────────────────────────────────────────────────────────────────────────
static const char* detectCustomCursorType(
const uint32_t* pixels, int w, int h, int hotX, int hotY)
{
if (w < 24 || h < 24 || w > 64 || h > 64) return nullptr;
if (hotX < w * 0.25 || hotX > w * 0.75) return nullptr;
if (hotY < h * 0.15 || hotY > h * 0.55) return nullptr;
int opaque = 0, topHalf = 0;
int left = w, top = h, right = -1, bottom = -1;
for (int y = 0; y < h; ++y) {
for (int x = 0; x < w; ++x) {
const uint8_t a = static_cast<uint8_t>(pixels[y * w + x] >> 24);
if (a <= 32) continue;
++opaque;
if (y < h / 2) ++topHalf;
if (x < left) left = x;
if (x > right) right = x;
if (y < top) top = y;
if (y > bottom) bottom = y;
}
}
if (opaque < 90 || right < left || bottom < top) return nullptr;
const int ow = right - left + 1;
const int oh = bottom - top + 1;
if (ow < w * 0.35 || ow > w * 0.9) return nullptr;
if (oh < h * 0.45 || oh > static_cast<double>(h)) return nullptr;
if (top > h * 0.45 || bottom < h * 0.65) return nullptr;
return topHalf > opaque * 0.55 ? "closed-hand" : "open-hand";
}
// ─────────────────────────────────────────────────────────────────────────────
// Build asset JSON for the given cursor (returns empty string on failure)
//
// Renders the cursor via GDI DrawIconEx onto a 32-bpp transparent DIB section
// and then encodes to PNG — matching the PowerShell approach of
// Graphics.Clear(Transparent) + Graphics.DrawIcon(). This correctly preserves
// per-pixel alpha for 32-bit cursors, unlike Gdiplus::Bitmap::FromHICON which
// can produce incorrect alpha for cursor handles.
// ─────────────────────────────────────────────────────────────────────────────
static std::string buildAssetJson(
HCURSOR hCursor,
const std::string& handleStr,
const CLSID& pngClsid,
const char** outCustomType)
{
*outCustomType = nullptr;
// Get hotspot and cursor dimensions from the icon info.
// For color cursors hbmColor gives the size; for monochrome cursors the
// mask bitmap is twice the cursor height (AND mask stacked on XOR mask).
ICONINFO ii{};
if (!GetIconInfo(hCursor, &ii)) return {};
const int hotX = static_cast<int>(ii.xHotspot);
const int hotY = static_cast<int>(ii.yHotspot);
int w = 0, h = 0;
if (ii.hbmColor) {
BITMAP bm{};
if (GetObject(ii.hbmColor, sizeof(bm), &bm)) { w = bm.bmWidth; h = bm.bmHeight; }
}
if (ii.hbmMask && (w == 0 || h == 0)) {
BITMAP bm{};
if (GetObject(ii.hbmMask, sizeof(bm), &bm)) {
w = bm.bmWidth;
h = ii.hbmColor ? bm.bmHeight : bm.bmHeight / 2;
}
}
if (ii.hbmMask) DeleteObject(ii.hbmMask);
if (ii.hbmColor) DeleteObject(ii.hbmColor);
if (w <= 0 || h <= 0) return {};
// Copy the cursor handle so DrawIconEx cannot affect the live system cursor.
const HICON hCopy = CopyIcon(hCursor);
if (!hCopy) return {};
// Allocate a 32-bpp top-down DIB section and clear it to transparent black,
// then draw the cursor with DI_NORMAL. For 32-bit alpha cursors Windows
// writes correct per-pixel alpha into the high byte of each BGRA pixel.
const int stride = w * 4;
BITMAPINFOHEADER bih{};
bih.biSize = sizeof(bih);
bih.biWidth = w;
bih.biHeight = -h; // negative = top-down scanline order
bih.biPlanes = 1;
bih.biBitCount = 32;
bih.biCompression = BI_RGB;
void* pBits = nullptr;
HDC hDC = CreateCompatibleDC(nullptr);
HBITMAP hBmp = hDC ? CreateDIBSection(hDC,
reinterpret_cast<const BITMAPINFO*>(&bih),
DIB_RGB_COLORS, &pBits, nullptr, 0)
: nullptr;
if (!hBmp || !pBits) {
if (hBmp) DeleteObject(hBmp);
if (hDC) DeleteDC(hDC);
DestroyIcon(hCopy);
return {};
}
HGDIOBJ hOld = SelectObject(hDC, hBmp);
std::memset(pBits, 0, static_cast<size_t>(stride * h)); // transparent black
DrawIconEx(hDC, 0, 0, hCopy, w, h, 0, nullptr, DI_NORMAL);
GdiFlush();
SelectObject(hDC, hOld);
DeleteDC(hDC);
DestroyIcon(hCopy);
// GDI's 32-bit DIB stores pixels as BGRA in memory. GDI+'s
// PixelFormat32bppARGB interprets each 32-bit word as 0xAARRGGBB which is
// identical to BGRA on little-endian, so the alpha byte is always >> 24.
{
const auto* px = static_cast<const uint32_t*>(pBits);
*outCustomType = detectCustomCursorType(px, w, h, hotX, hotY);
}
// Wrap the DIB pixels in a GDI+ Bitmap (zero-copy) and save to PNG.
// Keep hBmp alive until after gBmp is destroyed so pBits remains valid.
std::vector<uint8_t> pngData;
{
Gdiplus::Bitmap gBmp(w, h, stride, PixelFormat32bppARGB,
static_cast<BYTE*>(pBits));
if (gBmp.GetLastStatus() == Gdiplus::Ok) {
IStream* pStream = nullptr;
if (SUCCEEDED(CreateStreamOnHGlobal(nullptr, TRUE, &pStream))) {
if (gBmp.Save(pStream, &pngClsid) == Gdiplus::Ok) {
ULARGE_INTEGER sz{};
LARGE_INTEGER zero{};
pStream->Seek(zero, STREAM_SEEK_END, &sz);
pStream->Seek(zero, STREAM_SEEK_SET, nullptr);
pngData.resize(static_cast<size_t>(sz.QuadPart));
ULONG n = 0;
pStream->Read(pngData.data(), static_cast<ULONG>(pngData.size()), &n);
pngData.resize(n);
}
pStream->Release();
}
}
} // gBmp destroyed here; pBits (owned by hBmp) still valid
DeleteObject(hBmp);
if (pngData.empty()) return {};
const std::string dataUrl =
"data:image/png;base64," + base64Encode(pngData.data(), pngData.size());
std::string json;
json.reserve(dataUrl.size() + 128);
json = "{\"id\":\"" + handleStr + "\"";
json += ",\"imageDataUrl\":\"" + jsonEscape(dataUrl) + "\"";
json += ",\"width\":" + std::to_string(w);
json += ",\"height\":" + std::to_string(h);
json += ",\"hotspotX\":" + std::to_string(hotX);
json += ",\"hotspotY\":" + std::to_string(hotY);
if (*outCustomType) {
json += ",\"cursorType\":\"";
json += *outCustomType;
json += "\"";
} else {
json += ",\"cursorType\":null";
}
json += "}";
return json;
}
// ─────────────────────────────────────────────────────────────────────────────
// Sampling loop (background thread)
// ─────────────────────────────────────────────────────────────────────────────
static void runSamplingLoop(int intervalMs, HWND targetWindow, const CLSID& pngClsid) {
HCURSOR lastCursor = nullptr;
while (!g_stop.load(std::memory_order_relaxed)) {
const int downCount = g_leftDownCount.exchange(0, std::memory_order_relaxed);
const int upCount = g_leftUpCount.exchange(0, std::memory_order_relaxed);
CURSORINFO ci{};
ci.cbSize = sizeof(ci);
if (!GetCursorInfo(&ci)) {
char buf[160];
std::snprintf(buf, sizeof(buf),
"{\"type\":\"error\",\"timestampMs\":%" PRId64 ",\"message\":\"GetCursorInfo failed\"}",
nowMs());
writeJsonLine(buf);
std::this_thread::sleep_for(std::chrono::milliseconds(intervalMs));
continue;
}
const bool visible = (ci.flags & CURSOR_SHOWING) != 0;
const HCURSOR hc = ci.hCursor;
// Handle string ("0xHEX" or empty for null cursor)
char handleBuf[32] = {};
if (hc)
std::snprintf(handleBuf, sizeof(handleBuf),
"0x%" PRIX64, static_cast<uint64_t>(reinterpret_cast<uintptr_t>(hc)));
const std::string handleStr = hc ? handleBuf : "";
// Standard cursor type
const char* cursorType = standardCursorType(hc);
// Mouse button state
const SHORT ks = GetAsyncKeyState(VK_LBUTTON);
const bool leftDown = (ks & 0x8000) != 0;
const bool leftPressed = downCount > 0 || (ks & 0x0001) != 0;
const bool leftReleased = upCount > 0;
// Asset — only when the cursor handle changes
std::string assetJson;
if (visible && hc && hc != lastCursor) {
const char* customType = nullptr;
assetJson = buildAssetJson(hc, handleStr, pngClsid, &customType);
if (!assetJson.empty() && !cursorType && customType)
cursorType = customType;
lastCursor = hc;
}
// Window bounds
std::string boundsJson = "null";
if (targetWindow && IsWindow(targetWindow)) {
RECT r{};
if (GetWindowRect(targetWindow, &r)) {
const int bw = r.right - r.left;
const int bh = r.bottom - r.top;
if (bw > 0 && bh > 0) {
char buf[128];
std::snprintf(buf, sizeof(buf),
"{\"x\":%ld,\"y\":%ld,\"width\":%d,\"height\":%d}",
r.left, r.top, bw, bh);
boundsJson = buf;
}
}
}
// Emit sample JSON
std::string out;
out.reserve(256);
out += "{\"type\":\"sample\"";
out += ",\"timestampMs\":"; out += std::to_string(nowMs());
out += ",\"x\":"; out += std::to_string(ci.ptScreenPos.x);
out += ",\"y\":"; out += std::to_string(ci.ptScreenPos.y);
out += ",\"visible\":"; out += visible ? "true" : "false";
out += ",\"handle\":"; out += hc ? ("\"" + handleStr + "\"") : "null";
out += ",\"cursorType\":"; out += cursorType ? ("\"" + std::string(cursorType) + "\"") : "null";
out += ",\"leftButtonDown\":"; out += leftDown ? "true" : "false";
out += ",\"leftButtonPressed\":"; out += leftPressed ? "true" : "false";
out += ",\"leftButtonReleased\":"; out += leftReleased ? "true" : "false";
out += ",\"bounds\":"; out += boundsJson;
out += ",\"asset\":"; out += assetJson.empty() ? "null" : assetJson;
out += "}";
writeJsonLine(out);
// Exit if stdout pipe is broken (parent process died)
if (std::cout.fail()) {
PostThreadMessage(g_mainThreadId, WM_QUIT, 0, 0);
break;
}
std::this_thread::sleep_for(std::chrono::milliseconds(intervalMs));
}
}
// ─────────────────────────────────────────────────────────────────────────────
// main
// ─────────────────────────────────────────────────────────────────────────────
int main(int argc, char* argv[]) {
if (argc < 2) {
std::cerr << "Usage: cursor-sampler <intervalMs> [windowHandle]" << std::endl;
return 1;
}
const int intervalMs = std::max(1, std::atoi(argv[1]));
HWND targetWindow = nullptr;
if (argc >= 3) {
const std::string arg = argv[2];
if (!arg.empty() && arg != "null") {
try {
const int base = (arg.rfind("0x", 0) == 0 || arg.rfind("0X", 0) == 0) ? 16 : 10;
const uint64_t v = std::stoull(arg, nullptr, base);
if (v) targetWindow = reinterpret_cast<HWND>(static_cast<uintptr_t>(v));
} catch (...) {}
}
}
// Initialize GDI+
Gdiplus::GdiplusStartupInput gdipInput{};
ULONG_PTR gdipToken = 0;
if (Gdiplus::GdiplusStartup(&gdipToken, &gdipInput, nullptr) != Gdiplus::Ok) {
std::cerr << "GDI+ init failed" << std::endl;
return 1;
}
CLSID pngClsid{};
if (!getPngClsid(pngClsid)) {
std::cerr << "PNG encoder not found" << std::endl;
Gdiplus::GdiplusShutdown(gdipToken);
return 1;
}
// Install global low-level mouse hook on this thread
g_mouseHook = SetWindowsHookEx(WH_MOUSE_LL, LowLevelMouseProc, GetModuleHandle(nullptr), 0);
if (!g_mouseHook) {
std::cerr << "SetWindowsHookEx failed" << std::endl;
Gdiplus::GdiplusShutdown(gdipToken);
return 1;
}
// Prime GetAsyncKeyState so the first poll doesn't return stale "since-last-call" bits
GetAsyncKeyState(VK_LBUTTON);
// Signal readiness
g_mainThreadId = GetCurrentThreadId();
{
char buf[80];
std::snprintf(buf, sizeof(buf),
"{\"type\":\"ready\",\"timestampMs\":%" PRId64 "}", nowMs());
writeJsonLine(buf);
}
// Start sampling on a background thread
std::thread sampler(runSamplingLoop, intervalMs, targetWindow, std::cref(pngClsid));
// Run the message pump on the main thread — required for WH_MOUSE_LL callbacks
MSG msg;
while (GetMessage(&msg, nullptr, 0, 0) > 0) {
TranslateMessage(&msg);
DispatchMessage(&msg);
}
g_stop.store(true, std::memory_order_relaxed);
if (sampler.joinable()) sampler.join();
UnhookWindowsHookEx(g_mouseHook);
Gdiplus::GdiplusShutdown(gdipToken);
return 0;
}
@@ -0,0 +1,427 @@
#include "dshow_webcam_capture.h"
#include <initguid.h>
#include <dshow.h>
#include <wrl/client.h>
#include <algorithm>
#include <array>
#include <chrono>
#include <exception>
#include <iomanip>
#include <iostream>
#include <sstream>
namespace {
const CLSID CLSID_SampleGrabberLocal = {0xC1F400A0, 0x3F08, 0x11D3, {0x9F, 0x0B, 0x00, 0x60, 0x08, 0x03, 0x9E, 0x37}};
const CLSID CLSID_NullRendererLocal = {0xC1F400A4, 0x3F08, 0x11D3, {0x9F, 0x0B, 0x00, 0x60, 0x08, 0x03, 0x9E, 0x37}};
MIDL_INTERFACE("6B652FFF-11FE-4FCE-92AD-0266B5D7C78F")
ISampleGrabber : public IUnknown {
public:
virtual HRESULT STDMETHODCALLTYPE SetOneShot(BOOL oneShot) = 0;
virtual HRESULT STDMETHODCALLTYPE SetMediaType(const AM_MEDIA_TYPE* type) = 0;
virtual HRESULT STDMETHODCALLTYPE GetConnectedMediaType(AM_MEDIA_TYPE* type) = 0;
virtual HRESULT STDMETHODCALLTYPE SetBufferSamples(BOOL bufferThem) = 0;
virtual HRESULT STDMETHODCALLTYPE GetCurrentBuffer(long* bufferSize, long* buffer) = 0;
virtual HRESULT STDMETHODCALLTYPE GetCurrentSample(IMediaSample** sample) = 0;
virtual HRESULT STDMETHODCALLTYPE SetCallback(IUnknown* callback, long whichMethodToCallback) = 0;
};
bool succeeded(HRESULT hr, const char* label) {
if (SUCCEEDED(hr)) {
return true;
}
std::cerr << "ERROR: " << label << " failed (hr=0x" << std::hex << hr << std::dec << ")"
<< std::endl;
return false;
}
std::string guidToString(const GUID& guid) {
if (guid == MEDIASUBTYPE_RGB32) {
return "RGB32";
}
if (guid == MEDIASUBTYPE_YUY2) {
return "YUY2";
}
if (guid == MEDIASUBTYPE_NV12) {
return "NV12";
}
std::ostringstream stream;
stream << std::hex << std::setfill('0')
<< '{' << std::setw(8) << guid.Data1
<< '-' << std::setw(4) << guid.Data2
<< '-' << std::setw(4) << guid.Data3
<< '-';
for (int index = 0; index < 2; index += 1) {
stream << std::setw(2) << static_cast<int>(guid.Data4[index]);
}
stream << '-';
for (int index = 2; index < 8; index += 1) {
stream << std::setw(2) << static_cast<int>(guid.Data4[index]);
}
stream << '}';
return stream.str();
}
void freeMediaType(AM_MEDIA_TYPE& type) {
if (type.cbFormat != 0) {
CoTaskMemFree(type.pbFormat);
type.cbFormat = 0;
type.pbFormat = nullptr;
}
if (type.pUnk) {
type.pUnk->Release();
type.pUnk = nullptr;
}
}
BYTE clampToByte(int value) {
return static_cast<BYTE>(std::clamp(value, 0, 255));
}
std::array<BYTE, 3> yuvToBgr(int y, int u, int v) {
const int c = y - 16;
const int d = u - 128;
const int e = v - 128;
const int blue = (298 * c + 516 * d + 128) >> 8;
const int green = (298 * c - 100 * d - 208 * e + 128) >> 8;
const int red = (298 * c + 409 * e + 128) >> 8;
return {clampToByte(blue), clampToByte(green), clampToByte(red)};
}
} // namespace
struct DirectShowWebcamCapture::Impl {
Microsoft::WRL::ComPtr<IGraphBuilder> graph;
Microsoft::WRL::ComPtr<ICaptureGraphBuilder2> captureGraph;
Microsoft::WRL::ComPtr<IBaseFilter> captureFilter;
Microsoft::WRL::ComPtr<IBaseFilter> sampleGrabberFilter;
Microsoft::WRL::ComPtr<ISampleGrabber> sampleGrabber;
Microsoft::WRL::ComPtr<IBaseFilter> nullRenderer;
Microsoft::WRL::ComPtr<IMediaControl> mediaControl;
bool comInitialized = false;
bool running = false;
};
DirectShowWebcamCapture::~DirectShowWebcamCapture() {
stop();
delete impl_;
}
bool DirectShowWebcamCapture::initialize(
const std::wstring& deviceId,
const std::wstring& deviceName,
const std::wstring& directShowClsid,
int requestedWidth,
int requestedHeight,
int requestedFps) {
(void)deviceId;
stop();
delete impl_;
impl_ = nullptr;
impl_ = new Impl();
fps_ = std::clamp(requestedFps > 0 ? requestedFps : 30, 1, 60);
HRESULT hr = CoInitializeEx(nullptr, COINIT_MULTITHREADED);
if (SUCCEEDED(hr)) {
impl_->comInitialized = true;
} else if (hr != RPC_E_CHANGED_MODE) {
return succeeded(hr, "CoInitializeEx(DirectShow webcam)");
}
if (directShowClsid.empty()) {
std::cerr << "ERROR: DirectShow webcam fallback requires a resolved filter CLSID" << std::endl;
return false;
}
CLSID selectedClsid{};
if (FAILED(CLSIDFromString(directShowClsid.c_str(), &selectedClsid))) {
std::cerr << "ERROR: DirectShow webcam fallback received an invalid filter CLSID" << std::endl;
return false;
}
selectedDeviceName_ = deviceName.empty() ? directShowClsid : deviceName;
if (!succeeded(CoCreateInstance(selectedClsid, nullptr, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(&impl_->captureFilter)),
"CoCreateInstance(DirectShow webcam filter)")) {
return false;
}
if (!succeeded(CoCreateInstance(CLSID_FilterGraph, nullptr, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(&impl_->graph)),
"CoCreateInstance(FilterGraph)")) {
return false;
}
if (!succeeded(CoCreateInstance(CLSID_CaptureGraphBuilder2, nullptr, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(&impl_->captureGraph)),
"CoCreateInstance(CaptureGraphBuilder2)")) {
return false;
}
if (!succeeded(impl_->captureGraph->SetFiltergraph(impl_->graph.Get()), "SetFiltergraph(DirectShow webcam)")) {
return false;
}
if (!succeeded(impl_->graph->AddFilter(impl_->captureFilter.Get(), L"OpenScreen Webcam Source"),
"AddFilter(DirectShow webcam source)")) {
return false;
}
if (!succeeded(CoCreateInstance(CLSID_SampleGrabberLocal, nullptr, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(&impl_->sampleGrabberFilter)),
"CoCreateInstance(SampleGrabber)")) {
return false;
}
if (!succeeded(impl_->sampleGrabberFilter.As(&impl_->sampleGrabber), "QueryInterface(ISampleGrabber)")) {
return false;
}
AM_MEDIA_TYPE requestedType{};
requestedType.majortype = MEDIATYPE_Video;
requestedType.formattype = FORMAT_VideoInfo;
if (!succeeded(impl_->sampleGrabber->SetMediaType(&requestedType), "SetMediaType(DirectShow video)")) {
return false;
}
if (!succeeded(impl_->graph->AddFilter(impl_->sampleGrabberFilter.Get(), L"OpenScreen Webcam Sample Grabber"),
"AddFilter(SampleGrabber)")) {
return false;
}
if (!succeeded(CoCreateInstance(CLSID_NullRendererLocal, nullptr, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(&impl_->nullRenderer)),
"CoCreateInstance(NullRenderer)")) {
return false;
}
if (!succeeded(impl_->graph->AddFilter(impl_->nullRenderer.Get(), L"OpenScreen Webcam Null Renderer"),
"AddFilter(NullRenderer)")) {
return false;
}
if (!succeeded(impl_->captureGraph->RenderStream(
&PIN_CATEGORY_CAPTURE,
&MEDIATYPE_Video,
impl_->captureFilter.Get(),
impl_->sampleGrabberFilter.Get(),
impl_->nullRenderer.Get()),
"RenderStream(DirectShow webcam)")) {
return false;
}
AM_MEDIA_TYPE connectedType{};
if (!succeeded(impl_->sampleGrabber->GetConnectedMediaType(&connectedType), "GetConnectedMediaType(DirectShow webcam)")) {
return false;
}
if (connectedType.subtype == MEDIASUBTYPE_YUY2) {
pixelFormat_ = PixelFormat::Yuy2;
} else if (connectedType.subtype == MEDIASUBTYPE_NV12) {
pixelFormat_ = PixelFormat::Nv12;
} else if (connectedType.subtype == MEDIASUBTYPE_RGB32) {
pixelFormat_ = PixelFormat::Bgra;
} else {
std::cerr << "ERROR: Unsupported DirectShow webcam media subtype "
<< guidToString(connectedType.subtype) << std::endl;
freeMediaType(connectedType);
return false;
}
if (connectedType.formattype == FORMAT_VideoInfo && connectedType.pbFormat) {
const auto* videoInfo = reinterpret_cast<VIDEOINFOHEADER*>(connectedType.pbFormat);
width_ = std::abs(videoInfo->bmiHeader.biWidth);
height_ = std::abs(videoInfo->bmiHeader.biHeight);
const int bitsPerPixel = videoInfo->bmiHeader.biBitCount > 0 ? videoInfo->bmiHeader.biBitCount : 16;
if (pixelFormat_ == PixelFormat::Nv12) {
sourceStride_ = ((width_ + 3) / 4) * 4;
} else {
sourceStride_ = ((width_ * bitsPerPixel + 31) / 32) * 4;
}
sourceTopDown_ = pixelFormat_ != PixelFormat::Bgra || videoInfo->bmiHeader.biHeight < 0;
}
std::cerr << "INFO: DirectShow webcam connected subtype " << guidToString(connectedType.subtype)
<< " " << width_ << "x" << height_ << " stride=" << sourceStride_ << std::endl;
freeMediaType(connectedType);
if (width_ <= 0 || height_ <= 0) {
width_ = requestedWidth > 0 ? requestedWidth : 1280;
height_ = requestedHeight > 0 ? requestedHeight : 720;
}
if (sourceStride_ <= 0) {
sourceStride_ = pixelFormat_ == PixelFormat::Bgra ? width_ * 4 : ((width_ + 3) / 4) * 4;
}
impl_->sampleGrabber->SetBufferSamples(TRUE);
impl_->sampleGrabber->SetOneShot(FALSE);
if (!succeeded(impl_->graph.As(&impl_->mediaControl), "QueryInterface(IMediaControl)")) {
return false;
}
return true;
}
bool DirectShowWebcamCapture::start() {
if (!impl_ || !impl_->mediaControl || impl_->running) {
return false;
}
HRESULT hr = impl_->mediaControl->Run();
if (!succeeded(hr, "Run(DirectShow webcam)")) {
return false;
}
stopRequested_ = false;
try {
thread_ = std::thread(&DirectShowWebcamCapture::captureLoop, this);
} catch (const std::exception& error) {
stopRequested_ = true;
impl_->mediaControl->Stop();
std::cerr << "ERROR: Failed to start DirectShow webcam capture thread: " << error.what() << std::endl;
return false;
} catch (...) {
stopRequested_ = true;
impl_->mediaControl->Stop();
std::cerr << "ERROR: Failed to start DirectShow webcam capture thread" << std::endl;
return false;
}
impl_->running = true;
return true;
}
void DirectShowWebcamCapture::stop() {
stopRequested_ = true;
if (thread_.joinable()) {
thread_.join();
}
if (!impl_) {
return;
}
if (impl_->mediaControl && impl_->running) {
impl_->mediaControl->Stop();
}
impl_->running = false;
impl_->mediaControl.Reset();
impl_->nullRenderer.Reset();
impl_->sampleGrabber.Reset();
impl_->sampleGrabberFilter.Reset();
impl_->captureFilter.Reset();
impl_->captureGraph.Reset();
impl_->graph.Reset();
if (impl_->comInitialized) {
CoUninitialize();
impl_->comInitialized = false;
}
}
void DirectShowWebcamCapture::captureLoop() {
const HRESULT coinitHr = CoInitializeEx(nullptr, COINIT_MULTITHREADED);
while (!stopRequested_ && impl_ && impl_->sampleGrabber) {
long bufferSize = 0;
HRESULT hr = impl_->sampleGrabber->GetCurrentBuffer(&bufferSize, nullptr);
if (SUCCEEDED(hr) && bufferSize > 0) {
std::vector<BYTE> buffer(static_cast<size_t>(bufferSize));
hr = impl_->sampleGrabber->GetCurrentBuffer(&bufferSize, reinterpret_cast<long*>(buffer.data()));
if (SUCCEEDED(hr)) {
storeFrame(buffer.data(), bufferSize);
}
}
std::this_thread::sleep_for(std::chrono::milliseconds(1000 / std::max(1, fps_)));
}
if (SUCCEEDED(coinitHr)) {
CoUninitialize();
}
}
void DirectShowWebcamCapture::storeFrame(const BYTE* buffer, long length) {
const int destinationStride = width_ * 4;
const int sourceStride = sourceStride_ > 0 ? sourceStride_ : destinationStride;
const int expectedLength = pixelFormat_ == PixelFormat::Nv12
? sourceStride * height_ + sourceStride * ((height_ + 1) / 2)
: sourceStride * height_;
if (!buffer || length < expectedLength || width_ <= 0 || height_ <= 0) {
return;
}
std::vector<BYTE> frame(static_cast<size_t>(destinationStride * height_));
for (int y = 0; y < height_; y += 1) {
const int sourceY = sourceTopDown_ ? y : height_ - 1 - y;
const BYTE* source = buffer + sourceY * sourceStride;
BYTE* destination = frame.data() + y * destinationStride;
if (pixelFormat_ == PixelFormat::Bgra) {
std::copy(source, source + destinationStride, destination);
for (int x = 0; x < width_; x += 1) {
destination[x * 4 + 3] = 255;
}
continue;
}
if (pixelFormat_ == PixelFormat::Nv12) {
const BYTE* yPlane = buffer + sourceY * sourceStride;
const BYTE* uvPlane = buffer + sourceStride * height_ + (sourceY / 2) * sourceStride;
for (int x = 0; x < width_; x += 1) {
const int uvX = (x / 2) * 2;
const auto color = yuvToBgr(yPlane[x], uvPlane[uvX], uvPlane[uvX + 1]);
BYTE* pixel = destination + x * 4;
pixel[0] = color[0];
pixel[1] = color[1];
pixel[2] = color[2];
pixel[3] = 255;
}
continue;
}
for (int x = 0; x + 1 < width_; x += 2) {
const BYTE y0 = source[x * 2];
const BYTE u = source[x * 2 + 1];
const BYTE y1 = source[x * 2 + 2];
const BYTE v = source[x * 2 + 3];
const auto first = yuvToBgr(y0, u, v);
const auto second = yuvToBgr(y1, u, v);
BYTE* firstPixel = destination + x * 4;
BYTE* secondPixel = firstPixel + 4;
firstPixel[0] = first[0];
firstPixel[1] = first[1];
firstPixel[2] = first[2];
firstPixel[3] = 255;
secondPixel[0] = second[0];
secondPixel[1] = second[1];
secondPixel[2] = second[2];
secondPixel[3] = 255;
}
if (width_ % 2 == 1) {
const int x = width_ - 1;
const int previousPairStart = ((x - 1) / 2) * 4;
const BYTE y = source[x * 2];
const BYTE u = source[previousPairStart + 1];
const BYTE v = source[previousPairStart + 3];
const auto color = yuvToBgr(y, u, v);
BYTE* pixel = destination + x * 4;
pixel[0] = color[0];
pixel[1] = color[1];
pixel[2] = color[2];
pixel[3] = 255;
}
}
std::scoped_lock lock(frameMutex_);
latestFrame_ = std::move(frame);
latestFrameSequence_ += 1;
}
bool DirectShowWebcamCapture::copyLatestFrame(WebcamFrameSnapshot& destination) {
std::scoped_lock lock(frameMutex_);
if (latestFrame_.empty() || width_ <= 0 || height_ <= 0) {
return false;
}
destination.data = latestFrame_;
destination.width = width_;
destination.height = height_;
destination.sequence = latestFrameSequence_;
return true;
}
int DirectShowWebcamCapture::width() const {
return width_;
}
int DirectShowWebcamCapture::height() const {
return height_;
}
int DirectShowWebcamCapture::fps() const {
return fps_;
}
const std::wstring& DirectShowWebcamCapture::selectedDeviceName() const {
return selectedDeviceName_;
}
@@ -0,0 +1,67 @@
#pragma once
#include <Windows.h>
#include <atomic>
#include <cstdint>
#include <mutex>
#include <string>
#include <thread>
#include <vector>
struct WebcamFrameSnapshot {
std::vector<BYTE> data;
int width = 0;
int height = 0;
uint64_t sequence = 0;
};
class DirectShowWebcamCapture {
public:
DirectShowWebcamCapture() = default;
~DirectShowWebcamCapture();
DirectShowWebcamCapture(const DirectShowWebcamCapture&) = delete;
DirectShowWebcamCapture& operator=(const DirectShowWebcamCapture&) = delete;
bool initialize(
const std::wstring& deviceId,
const std::wstring& deviceName,
const std::wstring& directShowClsid,
int requestedWidth,
int requestedHeight,
int requestedFps);
bool start();
void stop();
bool copyLatestFrame(WebcamFrameSnapshot& destination);
int width() const;
int height() const;
int fps() const;
const std::wstring& selectedDeviceName() const;
void storeFrame(const BYTE* buffer, long length);
private:
enum class PixelFormat {
Bgra,
Nv12,
Yuy2,
};
struct Impl;
void captureLoop();
Impl* impl_ = nullptr;
std::thread thread_;
std::atomic<bool> stopRequested_ = false;
std::mutex frameMutex_;
std::vector<BYTE> latestFrame_;
uint64_t latestFrameSequence_ = 0;
int width_ = 0;
int height_ = 0;
int fps_ = 30;
int sourceStride_ = 0;
bool sourceTopDown_ = false;
PixelFormat pixelFormat_ = PixelFormat::Bgra;
std::wstring selectedDeviceName_;
};
+859
View File
@@ -0,0 +1,859 @@
#include "audio_sample_utils.h"
#include "mf_encoder.h"
#include "monitor_utils.h"
#include "wasapi_loopback_capture.h"
#include "webcam_capture.h"
#include "wgc_session.h"
#include <winrt/Windows.Foundation.h>
#include <algorithm>
#include <atomic>
#include <chrono>
#include <condition_variable>
#include <cctype>
#include <cstdint>
#include <functional>
#include <iostream>
#include <memory>
#include <mutex>
#include <string>
#include <thread>
namespace {
struct CaptureConfig {
int schemaVersion = 1;
int64_t displayId = 0;
int64_t recordingId = 0;
std::string sourceType = "display";
std::string sourceId;
std::string windowHandle;
std::string outputPath;
std::string webcamOutputPath;
int fps = 60;
int width = 0;
int height = 0;
MonitorBounds bounds{};
bool hasDisplayBounds = false;
bool captureSystemAudio = false;
bool captureMic = false;
bool captureCursor = false;
bool webcamEnabled = false;
std::string microphoneDeviceId;
std::string microphoneDeviceName;
double microphoneGain = 1.0;
std::string webcamDeviceId;
std::string webcamDeviceName;
std::string webcamDirectShowClsid;
int webcamWidth = 0;
int webcamHeight = 0;
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 {};
}
const int size = MultiByteToWideChar(CP_UTF8, 0, value.data(), static_cast<int>(value.size()), nullptr, 0);
std::wstring result(static_cast<size_t>(size), L'\0');
MultiByteToWideChar(CP_UTF8, 0, value.data(), static_cast<int>(value.size()), result.data(), size);
return result;
}
std::string wideToUtf8(const std::wstring& value) {
if (value.empty()) {
return {};
}
const int size = WideCharToMultiByte(CP_UTF8, 0, value.data(), static_cast<int>(value.size()), nullptr, 0, nullptr, nullptr);
std::string result(static_cast<size_t>(size), '\0');
WideCharToMultiByte(CP_UTF8, 0, value.data(), static_cast<int>(value.size()), result.data(), size, nullptr, nullptr);
return result;
}
std::string jsonEscape(const std::string& value) {
std::string result;
result.reserve(value.size());
for (const char c : value) {
switch (c) {
case '\\':
result += "\\\\";
break;
case '"':
result += "\\\"";
break;
case '\n':
result += "\\n";
break;
case '\r':
result += "\\r";
break;
case '\t':
result += "\\t";
break;
default:
result.push_back(c);
break;
}
}
return result;
}
bool hasVisibleBgraContent(const std::vector<BYTE>& frame) {
if (frame.size() < 4) {
return false;
}
uint64_t lumaTotal = 0;
BYTE maxLuma = 0;
const size_t pixelCount = frame.size() / 4;
const size_t step = std::max<size_t>(1, pixelCount / 4096);
size_t sampledPixels = 0;
for (size_t pixel = 0; pixel < pixelCount; pixel += step) {
const size_t offset = pixel * 4;
const BYTE b = frame[offset + 0];
const BYTE g = frame[offset + 1];
const BYTE r = frame[offset + 2];
const BYTE luma = static_cast<BYTE>((static_cast<uint16_t>(r) * 54 + static_cast<uint16_t>(g) * 183 + static_cast<uint16_t>(b) * 19) >> 8);
lumaTotal += luma;
maxLuma = std::max(maxLuma, luma);
sampledPixels += 1;
}
const uint64_t averageLuma = sampledPixels > 0 ? lumaTotal / sampledPixels : 0;
return maxLuma > 24 || averageLuma > 4;
}
bool findBool(const std::string& json, const std::string& key, bool fallback) {
auto pos = json.find("\"" + key + "\"");
if (pos == std::string::npos) {
return fallback;
}
pos = json.find(':', pos);
if (pos == std::string::npos) {
return fallback;
}
pos += 1;
while (pos < json.size() && std::isspace(static_cast<unsigned char>(json[pos]))) {
pos += 1;
}
if (json.compare(pos, 4, "true") == 0) {
return true;
}
if (json.compare(pos, 5, "false") == 0) {
return false;
}
return fallback;
}
int64_t findInt64(const std::string& json, const std::string& key, int64_t fallback) {
auto pos = json.find("\"" + key + "\"");
if (pos == std::string::npos) {
return fallback;
}
pos = json.find(':', pos);
if (pos == std::string::npos) {
return fallback;
}
pos += 1;
while (pos < json.size() && std::isspace(static_cast<unsigned char>(json[pos]))) {
pos += 1;
}
try {
return std::stoll(json.substr(pos));
} catch (...) {
return fallback;
}
}
int findInt(const std::string& json, const std::string& key, int fallback) {
return static_cast<int>(findInt64(json, key, fallback));
}
double findDouble(const std::string& json, const std::string& key, double fallback) {
auto pos = json.find("\"" + key + "\"");
if (pos == std::string::npos) {
return fallback;
}
pos = json.find(':', pos);
if (pos == std::string::npos) {
return fallback;
}
pos += 1;
while (pos < json.size() && std::isspace(static_cast<unsigned char>(json[pos]))) {
pos += 1;
}
try {
return std::stod(json.substr(pos));
} catch (...) {
return fallback;
}
}
std::string findString(const std::string& json, const std::string& key) {
auto pos = json.find("\"" + key + "\"");
if (pos == std::string::npos) {
return {};
}
pos = json.find(':', pos);
if (pos == std::string::npos) {
return {};
}
pos += 1;
while (pos < json.size() && std::isspace(static_cast<unsigned char>(json[pos]))) {
pos += 1;
}
if (pos >= json.size() || json[pos] != '"') {
return {};
}
pos += 1;
std::string result;
while (pos < json.size()) {
const char c = json[pos++];
if (c == '"') {
break;
}
if (c == '\\' && pos < json.size()) {
const char escaped = json[pos++];
switch (escaped) {
case '\\':
case '"':
case '/':
result.push_back(escaped);
break;
case 'n':
result.push_back('\n');
break;
case 'r':
result.push_back('\r');
break;
case 't':
result.push_back('\t');
break;
default:
result.push_back(escaped);
break;
}
continue;
}
result.push_back(c);
}
return result;
}
std::string parseWindowHandleFromSourceId(const std::string& sourceId) {
constexpr char prefix[] = "window:";
if (sourceId.rfind(prefix, 0) != 0) {
return {};
}
const size_t start = sizeof(prefix) - 1;
const size_t end = sourceId.find(':', start);
const std::string handle = sourceId.substr(start, end == std::string::npos ? std::string::npos : end - start);
return handle.empty() ? std::string{} : handle;
}
HWND parseWindowHandle(const std::string& value) {
if (value.empty()) {
return nullptr;
}
try {
size_t parsed = 0;
const int base = value.rfind("0x", 0) == 0 || value.rfind("0X", 0) == 0 ? 16 : 10;
const uint64_t handleValue = std::stoull(value, &parsed, base);
if (parsed != value.size() || handleValue == 0) {
return nullptr;
}
return reinterpret_cast<HWND>(static_cast<uintptr_t>(handleValue));
} catch (...) {
return nullptr;
}
}
bool parseConfig(const std::string& json, CaptureConfig& config) {
config.schemaVersion = findInt(json, "schemaVersion", 1);
config.outputPath = findString(json, "screenPath");
if (config.outputPath.empty()) {
config.outputPath = findString(json, "outputPath");
}
if (config.outputPath.empty()) {
return false;
}
config.recordingId = findInt64(json, "recordingId", 0);
config.sourceType = findString(json, "sourceType");
if (config.sourceType.empty()) {
config.sourceType = "display";
}
config.sourceId = findString(json, "sourceId");
config.windowHandle = findString(json, "windowHandle");
if (config.windowHandle.empty()) {
config.windowHandle = parseWindowHandleFromSourceId(config.sourceId);
}
config.displayId = findInt64(json, "displayId", 0);
config.fps = std::clamp(findInt(json, "fps", 60), 1, 120);
config.width = findInt(json, "videoWidth", findInt(json, "width", 0));
config.height = findInt(json, "videoHeight", findInt(json, "height", 0));
config.bounds.x = findInt(json, "displayX", 0);
config.bounds.y = findInt(json, "displayY", 0);
config.bounds.width = findInt(json, "displayW", 0);
config.bounds.height = findInt(json, "displayH", 0);
config.hasDisplayBounds = findBool(json, "hasDisplayBounds", false);
config.captureSystemAudio = findBool(json, "captureSystemAudio", false);
config.captureMic = findBool(json, "captureMic", false);
config.captureCursor = findBool(json, "captureCursor", false);
config.webcamEnabled = findBool(json, "webcamEnabled", false);
config.microphoneDeviceId = findString(json, "microphoneDeviceId");
config.microphoneDeviceName = findString(json, "microphoneDeviceName");
config.microphoneGain = findDouble(json, "microphoneGain", 1.0);
config.webcamDeviceId = findString(json, "webcamDeviceId");
config.webcamDeviceName = findString(json, "webcamDeviceName");
config.webcamDirectShowClsid = findString(json, "webcamDirectShowClsid");
config.webcamOutputPath = findString(json, "webcamPath");
config.webcamWidth = findInt(json, "webcamWidth", 0);
config.webcamHeight = findInt(json, "webcamHeight", 0);
config.webcamFps = findInt(json, "webcamFps", 0);
return true;
}
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") {
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;
}
}
control.stopRequested = true;
control.cv.notify_all();
}
} // namespace
int main(int argc, char* argv[]) {
if (argc < 2) {
std::cerr << "ERROR: Missing JSON config argument" << std::endl;
return 1;
}
winrt::init_apartment(winrt::apartment_type::multi_threaded);
CaptureConfig config;
if (!parseConfig(argv[1], config)) {
std::cerr << "ERROR: Failed to parse config JSON" << std::endl;
return 1;
}
std::cout << "{\"event\":\"ready\",\"schemaVersion\":2}" << std::endl;
WgcSession session;
if (config.sourceType == "display") {
HMONITOR monitor = findMonitorForCapture(
config.displayId,
config.hasDisplayBounds ? &config.bounds : nullptr);
if (!monitor) {
std::cerr << "ERROR: Could not resolve monitor" << std::endl;
return 1;
}
if (!session.initialize(monitor, config.fps, config.captureCursor)) {
std::cerr << "ERROR: Failed to initialize WGC display session" << std::endl;
return 1;
}
} else if (config.sourceType == "window") {
HWND window = parseWindowHandle(config.windowHandle);
if (!window || !IsWindow(window)) {
std::cerr << "ERROR: Native window capture requires a valid HWND" << std::endl;
return 1;
}
if (!session.initialize(window, config.fps, config.captureCursor)) {
std::cerr << "ERROR: Failed to initialize WGC window session" << std::endl;
return 1;
}
} else {
std::cerr << "ERROR: Unsupported native capture source type: " << config.sourceType << std::endl;
return 1;
}
// WGC owns the captured texture size. Encoding must use that exact size
// until a dedicated GPU scaling pass is introduced; CopyResource requires
// matching resource dimensions.
int width = session.captureWidth();
int height = session.captureHeight();
width = (std::max(2, width) / 2) * 2;
height = (std::max(2, height) / 2) * 2;
const int pixels = width * height;
const int bitrate = pixels >= 3840 * 2160 ? 45'000'000 : pixels >= 2560 * 1440 ? 28'000'000 : 18'000'000;
WebcamCapture webcamCapture;
bool webcamActive = false;
bool writeSeparateWebcam = false;
if (config.webcamEnabled) {
if (!webcamCapture.initialize(
utf8ToWide(config.webcamDeviceId),
utf8ToWide(config.webcamDeviceName),
utf8ToWide(config.webcamDirectShowClsid),
config.webcamWidth,
config.webcamHeight,
config.webcamFps > 0 ? config.webcamFps : config.fps)) {
std::cerr << "ERROR: Failed to initialize native webcam capture" << std::endl;
return 1;
}
std::cout << "{\"event\":\"webcam-format\",\"schemaVersion\":2,\"width\":" << webcamCapture.width()
<< ",\"height\":" << webcamCapture.height()
<< ",\"fps\":" << webcamCapture.fps()
<< ",\"deviceName\":\"" << jsonEscape(wideToUtf8(webcamCapture.selectedDeviceName()))
<< "\"}" << std::endl;
writeSeparateWebcam = !config.webcamOutputPath.empty();
}
WasapiLoopbackCapture loopbackCapture;
WasapiLoopbackCapture microphoneCapture;
const AudioInputFormat* audioFormat = nullptr;
AudioInputFormat encoderAudioFormat{};
AudioInputFormat systemAudioFormat{};
AudioInputFormat microphoneAudioFormat{};
if (config.captureSystemAudio) {
if (!loopbackCapture.initializeSystemLoopback()) {
std::cerr << "ERROR: Failed to initialize WASAPI loopback capture" << std::endl;
return 1;
}
systemAudioFormat = loopbackCapture.inputFormat();
audioFormat = &loopbackCapture.inputFormat();
}
if (config.captureMic) {
if (!microphoneCapture.initializeMicrophone(
utf8ToWide(config.microphoneDeviceId),
utf8ToWide(config.microphoneDeviceName))) {
std::cerr << "ERROR: Failed to initialize WASAPI microphone capture" << std::endl;
return 1;
}
microphoneAudioFormat = microphoneCapture.inputFormat();
if (!audioFormat) {
audioFormat = &microphoneCapture.inputFormat();
}
}
if (audioFormat) {
std::cout << "{\"event\":\"audio-format\",\"schemaVersion\":2,\"sampleRate\":" << audioFormat->sampleRate
<< ",\"channels\":" << audioFormat->channels
<< ",\"bitsPerSample\":" << audioFormat->bitsPerSample
<< ",\"system\":" << (config.captureSystemAudio ? "true" : "false")
<< ",\"microphone\":" << (config.captureMic ? "true" : "false");
if (config.captureMic) {
std::cout << ",\"microphoneDeviceName\":\""
<< jsonEscape(wideToUtf8(microphoneCapture.selectedDeviceName())) << "\"";
}
std::cout << "}" << std::endl;
encoderAudioFormat = makeAacCompatibleAudioFormat(*audioFormat);
std::cout << "{\"event\":\"encoder-audio-format\",\"schemaVersion\":2,\"sampleRate\":"
<< encoderAudioFormat.sampleRate
<< ",\"channels\":" << encoderAudioFormat.channels
<< ",\"bitsPerSample\":" << encoderAudioFormat.bitsPerSample
<< "}" << std::endl;
}
MFEncoder encoder;
if (!encoder.initialize(
utf8ToWide(config.outputPath),
width,
height,
config.fps,
bitrate,
session.device(),
session.context(),
audioFormat ? &encoderAudioFormat : nullptr)) {
std::cerr << "ERROR: Failed to initialize Media Foundation encoder" << std::endl;
return 1;
}
MFEncoder webcamEncoder;
if (writeSeparateWebcam) {
const int webcamPixels = std::max(1, webcamCapture.width()) * std::max(1, webcamCapture.height());
const int webcamBitrate = webcamPixels >= 1280 * 720 ? 8'000'000 : 4'000'000;
if (!webcamEncoder.initialize(
utf8ToWide(config.webcamOutputPath),
webcamCapture.width(),
webcamCapture.height(),
webcamCapture.fps(),
webcamBitrate,
session.device(),
session.context(),
nullptr)) {
std::cerr << "ERROR: Failed to initialize native webcam encoder" << std::endl;
return 1;
}
}
std::mutex mutex;
CaptureControl control;
std::atomic<bool> firstFrameWritten = false;
std::atomic<bool> encodeFailed = false;
Microsoft::WRL::ComPtr<ID3D11Texture2D> latestFrameTexture;
int64_t latestFrameTimestampHns = 0;
int64_t firstFrameTimestampHns = -1;
std::vector<BYTE> latestWebcamFrame;
int latestWebcamWidth = 0;
int latestWebcamHeight = 0;
uint64_t latestWebcamSequence = 0;
bool hasVisibleWebcamFrame = false;
session.setFrameCallback([&](ID3D11Texture2D* texture, int64_t timestampHns) {
if (control.stopRequested || control.paused) {
return;
}
std::scoped_lock lock(mutex);
if (!latestFrameTexture) {
D3D11_TEXTURE2D_DESC desc{};
texture->GetDesc(&desc);
desc.BindFlags = 0;
desc.CPUAccessFlags = 0;
desc.MiscFlags = 0;
if (FAILED(session.device()->CreateTexture2D(&desc, nullptr, &latestFrameTexture))) {
encodeFailed = true;
control.stopRequested = true;
control.cv.notify_all();
return;
}
}
session.context()->CopyResource(latestFrameTexture.Get(), texture);
latestFrameTimestampHns = timestampHns;
if (!firstFrameWritten.exchange(true)) {
control.cv.notify_all();
}
});
auto writeVideoFrames = [&]() {
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 (!control.stopRequested && !encodeFailed) {
{
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) &&
candidateWebcamFrame.sequence != latestWebcamSequence &&
hasVisibleBgraContent(candidateWebcamFrame.data)) {
latestWebcamFrame = std::move(candidateWebcamFrame.data);
latestWebcamWidth = candidateWebcamFrame.width;
latestWebcamHeight = candidateWebcamFrame.height;
latestWebcamSequence = candidateWebcamFrame.sequence;
hasVisibleWebcamFrame = true;
}
}
const BgraFrameView webcamFrame{
hasVisibleWebcamFrame && !latestWebcamFrame.empty() ? latestWebcamFrame.data() : nullptr,
latestWebcamWidth,
latestWebcamHeight,
};
const int64_t syntheticTimestampHns =
static_cast<int64_t>((frameIndex * 10'000'000ULL) / config.fps);
const int64_t sourceTimestampHns =
latestFrameTimestampHns > 0 ? latestFrameTimestampHns : syntheticTimestampHns;
if (firstFrameTimestampHns < 0) {
firstFrameTimestampHns = sourceTimestampHns;
}
int64_t frameTimestampHns =
std::max<int64_t>(
0,
sourceTimestampHns - firstFrameTimestampHns - control.pausedDurationHns());
if (lastEncodedVideoTimestampHns >= 0 &&
frameTimestampHns <= lastEncodedVideoTimestampHns) {
frameTimestampHns =
lastEncodedVideoTimestampHns + static_cast<int64_t>(10'000'000ULL / config.fps);
}
if (writeSeparateWebcam && webcamFrame.data &&
latestWebcamSequence != lastWrittenWebcamSequence) {
const int64_t webcamTimestampHns = static_cast<int64_t>(
(webcamOutputFrameIndex * 10'000'000ULL) / std::max(1, webcamCapture.fps()));
if (!webcamEncoder.writeBgraFrame(webcamFrame, webcamTimestampHns)) {
encodeFailed = true;
control.stopRequested = true;
control.cv.notify_all();
return;
}
lastWrittenWebcamSequence = latestWebcamSequence;
webcamOutputFrameIndex += 1;
}
if (latestFrameTexture && !encoder.writeFrame(
latestFrameTexture.Get(),
frameTimestampHns,
!writeSeparateWebcam && webcamFrame.data ? &webcamFrame : nullptr)) {
encodeFailed = true;
control.stopRequested = true;
control.cv.notify_all();
return;
}
if (latestFrameTexture) {
lastEncodedVideoTimestampHns = frameTimestampHns;
}
}
frameIndex += 1;
std::this_thread::sleep_for(frameDuration);
}
};
std::thread videoWriterThread;
auto stopVideoWriter = [&]() {
if (videoWriterThread.joinable()) {
videoWriterThread.join();
}
};
auto startVideoWriter = [&]() {
videoWriterThread = std::thread(writeVideoFrames);
};
std::unique_ptr<AudioMixer> audioMixer;
auto startAudioCaptures = [&]() -> bool {
if (!audioFormat) {
return true;
}
audioMixer = std::make_unique<AudioMixer>(
encoderAudioFormat,
config.captureSystemAudio ? systemAudioFormat : encoderAudioFormat,
config.captureMic ? microphoneAudioFormat : encoderAudioFormat,
config.captureSystemAudio,
config.captureMic,
config.microphoneGain,
[&](const BYTE* data, DWORD byteCount, int64_t timestampHns, int64_t durationHns) {
if (!encoder.writeAudio(data, byteCount, timestampHns, durationHns)) {
encodeFailed = true;
control.stopRequested = true;
control.cv.notify_all();
return false;
}
return true;
});
if (!audioMixer->start()) {
std::cerr << "ERROR: Failed to start native audio mixer" << std::endl;
return false;
}
if (config.captureMic) {
if (!microphoneCapture.start([&](const BYTE* data, DWORD byteCount, int64_t timestampHns, int64_t durationHns) {
(void)timestampHns;
(void)durationHns;
if (control.stopRequested || !audioMixer) {
return;
}
audioMixer->pushMicrophone(data, byteCount);
})) {
std::cerr << "ERROR: Failed to start WASAPI microphone capture" << std::endl;
audioMixer->stop();
return false;
}
}
if (config.captureSystemAudio) {
if (!loopbackCapture.start([&](const BYTE* data, DWORD byteCount, int64_t timestampHns, int64_t durationHns) {
(void)timestampHns;
(void)durationHns;
if (control.stopRequested || !audioMixer) {
return;
}
audioMixer->pushSystem(data, byteCount);
})) {
std::cerr << "ERROR: Failed to start WASAPI loopback capture" << std::endl;
microphoneCapture.stop();
audioMixer->stop();
return false;
}
}
return true;
};
if (!startAudioCaptures()) {
return 1;
}
if (config.webcamEnabled) {
if (!webcamCapture.start()) {
microphoneCapture.stop();
loopbackCapture.stop();
if (audioMixer) {
audioMixer->stop();
}
std::cerr << "ERROR: Failed to start native webcam capture" << std::endl;
return 1;
}
webcamActive = true;
const auto webcamDeadline = std::chrono::steady_clock::now() + std::chrono::seconds(3);
while (std::chrono::steady_clock::now() < webcamDeadline && !hasVisibleWebcamFrame) {
WebcamFrameSnapshot candidateWebcamFrame;
if (webcamCapture.copyLatestFrame(candidateWebcamFrame) &&
hasVisibleBgraContent(candidateWebcamFrame.data)) {
latestWebcamFrame = std::move(candidateWebcamFrame.data);
latestWebcamWidth = candidateWebcamFrame.width;
latestWebcamHeight = candidateWebcamFrame.height;
latestWebcamSequence = candidateWebcamFrame.sequence;
hasVisibleWebcamFrame = true;
break;
}
std::this_thread::sleep_for(std::chrono::milliseconds(20));
}
if (!hasVisibleWebcamFrame) {
std::cerr << "WARNING: Native webcam started but no visible frame was available before screen capture"
<< std::endl;
}
}
if (!session.start()) {
webcamCapture.stop();
microphoneCapture.stop();
loopbackCapture.stop();
if (audioMixer) {
audioMixer->stop();
}
std::cerr << "ERROR: Failed to start WGC session" << std::endl;
return 1;
}
std::thread stdinThread(readCaptureCommands, std::ref(control), [&](bool isPaused) {
if (audioMixer) {
audioMixer->setPaused(isPaused);
}
});
{
std::unique_lock lock(mutex);
const bool started = control.cv.wait_for(lock, std::chrono::seconds(10), [&] {
return firstFrameWritten.load() || control.stopRequested.load();
});
if (!started || !firstFrameWritten) {
control.stopRequested = true;
control.cv.notify_all();
if (stdinThread.joinable()) {
stdinThread.detach();
}
microphoneCapture.stop();
loopbackCapture.stop();
webcamCapture.stop();
if (audioMixer) {
audioMixer->stop();
}
session.stop();
std::cerr << "ERROR: Timed out waiting for first WGC frame" << std::endl;
return 1;
}
}
if (audioMixer) {
audioMixer->beginTimeline();
}
startVideoWriter();
std::cout << "{\"event\":\"recording-started\",\"schemaVersion\":2}" << std::endl;
std::cout << "Recording started" << std::endl;
{
std::unique_lock lock(mutex);
control.cv.wait(lock, [&] {
return control.stopRequested.load();
});
}
microphoneCapture.stop();
loopbackCapture.stop();
webcamCapture.stop();
if (audioMixer) {
audioMixer->stop();
}
stopVideoWriter();
session.stop();
{
std::scoped_lock lock(mutex);
encoder.finalize();
if (writeSeparateWebcam) {
webcamEncoder.finalize();
}
}
if (stdinThread.joinable()) {
stdinThread.detach();
}
if (encodeFailed) {
std::cerr << "ERROR: Failed to encode WGC frame" << std::endl;
return 1;
}
std::cout << "{\"event\":\"recording-stopped\",\"schemaVersion\":2,\"screenPath\":\""
<< jsonEscape(config.outputPath) << "\"";
if (writeSeparateWebcam) {
std::cout << ",\"webcamPath\":\"" << jsonEscape(config.webcamOutputPath) << "\"";
}
std::cout << "}" << std::endl;
std::cout << "Recording stopped. Output path: " << config.outputPath << std::endl;
return 0;
}
@@ -0,0 +1,450 @@
#include "mf_encoder.h"
#include "audio_sample_utils.h"
#include <mfapi.h>
#include <mferror.h>
#include <propvarutil.h>
#include <algorithm>
#include <cstring>
#include <iostream>
namespace {
bool succeeded(HRESULT hr, const char* label) {
if (SUCCEEDED(hr)) {
return true;
}
std::cerr << "ERROR: " << label << " failed (hr=0x" << std::hex << hr << std::dec << ")"
<< std::endl;
return false;
}
void setFrameSize(IMFMediaType* type, UINT32 width, UINT32 height) {
MFSetAttributeSize(type, MF_MT_FRAME_SIZE, width, height);
}
void setFrameRate(IMFMediaType* type, UINT32 fps) {
MFSetAttributeRatio(type, MF_MT_FRAME_RATE, fps, 1);
}
void setPixelAspectRatio(IMFMediaType* type) {
MFSetAttributeRatio(type, MF_MT_PIXEL_ASPECT_RATIO, 1, 1);
}
void setAudioFormat(IMFMediaType* type, UINT32 channels, UINT32 sampleRate, UINT32 bitsPerSample) {
type->SetUINT32(MF_MT_AUDIO_NUM_CHANNELS, channels);
type->SetUINT32(MF_MT_AUDIO_SAMPLES_PER_SECOND, sampleRate);
type->SetUINT32(MF_MT_AUDIO_BITS_PER_SAMPLE, bitsPerSample);
}
void compositeWebcam(BYTE* destination, int width, int height, const BgraFrameView& webcamFrame) {
if (!webcamFrame.data || webcamFrame.width <= 0 || webcamFrame.height <= 0 || width <= 0 || height <= 0) {
return;
}
const int margin = std::max(16, std::min(width, height) / 60);
const int maxOverlayWidth = std::max(2, width / 4);
int overlayWidth = maxOverlayWidth;
int overlayHeight = static_cast<int>(
(static_cast<int64_t>(overlayWidth) * webcamFrame.height) / std::max(1, webcamFrame.width));
const int maxOverlayHeight = std::max(2, height / 3);
if (overlayHeight > maxOverlayHeight) {
overlayHeight = maxOverlayHeight;
overlayWidth = static_cast<int>(
(static_cast<int64_t>(overlayHeight) * webcamFrame.width) / std::max(1, webcamFrame.height));
}
overlayWidth = std::max(2, std::min(overlayWidth, width - margin * 2));
overlayHeight = std::max(2, std::min(overlayHeight, height - margin * 2));
const int originX = std::max(0, width - overlayWidth - margin);
const int originY = std::max(0, height - overlayHeight - margin);
for (int y = 0; y < overlayHeight; y += 1) {
const int sourceY = static_cast<int>((static_cast<int64_t>(y) * webcamFrame.height) / overlayHeight);
BYTE* destinationRow = destination + ((originY + y) * width + originX) * 4;
for (int x = 0; x < overlayWidth; x += 1) {
const int sourceX = static_cast<int>((static_cast<int64_t>(x) * webcamFrame.width) / overlayWidth);
const BYTE* source = webcamFrame.data + (sourceY * webcamFrame.width + sourceX) * 4;
BYTE* target = destinationRow + x * 4;
target[0] = source[0];
target[1] = source[1];
target[2] = source[2];
target[3] = 255;
}
}
}
} // namespace
MFEncoder::~MFEncoder() {
finalize();
}
bool MFEncoder::initialize(
const std::wstring& outputPath,
int width,
int height,
int fps,
int bitrate,
ID3D11Device* device,
ID3D11DeviceContext* context,
const AudioInputFormat* audioFormat) {
width_ = (std::max(2, width) / 2) * 2;
height_ = (std::max(2, height) / 2) * 2;
fps_ = std::max(1, fps);
device_ = device;
context_ = context;
if (!succeeded(MFStartup(MF_VERSION), "MFStartup")) {
return false;
}
Microsoft::WRL::ComPtr<IMFMediaType> outputType;
if (!succeeded(MFCreateMediaType(&outputType), "MFCreateMediaType(output)")) {
return false;
}
outputType->SetGUID(MF_MT_MAJOR_TYPE, MFMediaType_Video);
outputType->SetGUID(MF_MT_SUBTYPE, MFVideoFormat_H264);
outputType->SetUINT32(MF_MT_AVG_BITRATE, static_cast<UINT32>(std::max(1, bitrate)));
outputType->SetUINT32(MF_MT_INTERLACE_MODE, MFVideoInterlace_Progressive);
setFrameSize(outputType.Get(), static_cast<UINT32>(width_), static_cast<UINT32>(height_));
setFrameRate(outputType.Get(), static_cast<UINT32>(fps_));
setPixelAspectRatio(outputType.Get());
if (!succeeded(MFCreateSinkWriterFromURL(outputPath.c_str(), nullptr, nullptr, &sinkWriter_),
"MFCreateSinkWriterFromURL")) {
return false;
}
if (!succeeded(sinkWriter_->AddStream(outputType.Get(), &videoStreamIndex_), "AddStream")) {
return false;
}
if (audioFormat && !configureAudioStream(*audioFormat)) {
return false;
}
Microsoft::WRL::ComPtr<IMFMediaType> inputType;
if (!succeeded(MFCreateMediaType(&inputType), "MFCreateMediaType(input)")) {
return false;
}
inputType->SetGUID(MF_MT_MAJOR_TYPE, MFMediaType_Video);
inputType->SetGUID(MF_MT_SUBTYPE, MFVideoFormat_RGB32);
inputType->SetUINT32(MF_MT_INTERLACE_MODE, MFVideoInterlace_Progressive);
inputType->SetUINT32(MF_MT_DEFAULT_STRIDE, static_cast<UINT32>(width_ * 4));
setFrameSize(inputType.Get(), static_cast<UINT32>(width_), static_cast<UINT32>(height_));
setFrameRate(inputType.Get(), static_cast<UINT32>(fps_));
setPixelAspectRatio(inputType.Get());
if (!succeeded(sinkWriter_->SetInputMediaType(videoStreamIndex_, inputType.Get(), nullptr),
"SetInputMediaType")) {
return false;
}
if (!succeeded(sinkWriter_->BeginWriting(), "BeginWriting")) {
return false;
}
return true;
}
bool MFEncoder::configureAudioStream(const AudioInputFormat& audioFormat) {
if (!sinkWriter_) {
return false;
}
if (audioFormat.sampleRate == 0 || audioFormat.channels == 0 || audioFormat.blockAlign == 0) {
std::cerr << "ERROR: Invalid audio input format" << std::endl;
return false;
}
const AudioInputFormat encoderFormat = makeAacCompatibleAudioFormat(audioFormat);
const UINT32 aacBytesPerSecond = 24'000;
Microsoft::WRL::ComPtr<IMFMediaType> outputType;
if (!succeeded(MFCreateMediaType(&outputType), "MFCreateMediaType(audio output)")) {
return false;
}
outputType->SetGUID(MF_MT_MAJOR_TYPE, MFMediaType_Audio);
outputType->SetGUID(MF_MT_SUBTYPE, MFAudioFormat_AAC);
setAudioFormat(outputType.Get(), encoderFormat.channels, encoderFormat.sampleRate, 16);
outputType->SetUINT32(MF_MT_AUDIO_AVG_BYTES_PER_SECOND, aacBytesPerSecond);
outputType->SetUINT32(MF_MT_AAC_PAYLOAD_TYPE, 0);
if (!succeeded(sinkWriter_->AddStream(outputType.Get(), &audioStreamIndex_), "AddStream(audio)")) {
return false;
}
Microsoft::WRL::ComPtr<IMFMediaType> inputType;
if (!succeeded(MFCreateMediaType(&inputType), "MFCreateMediaType(audio input)")) {
return false;
}
inputType->SetGUID(MF_MT_MAJOR_TYPE, MFMediaType_Audio);
inputType->SetGUID(MF_MT_SUBTYPE, encoderFormat.subtype);
setAudioFormat(inputType.Get(), encoderFormat.channels, encoderFormat.sampleRate, encoderFormat.bitsPerSample);
inputType->SetUINT32(MF_MT_AUDIO_BLOCK_ALIGNMENT, encoderFormat.blockAlign);
inputType->SetUINT32(MF_MT_AUDIO_AVG_BYTES_PER_SECOND, encoderFormat.avgBytesPerSec);
inputType->SetUINT32(MF_MT_ALL_SAMPLES_INDEPENDENT, TRUE);
if (!succeeded(sinkWriter_->SetInputMediaType(audioStreamIndex_, inputType.Get(), nullptr),
"SetInputMediaType(audio)")) {
return false;
}
hasAudioStream_ = true;
return true;
}
bool MFEncoder::ensureStagingTexture(ID3D11Texture2D* texture) {
if (stagingTexture_) {
return true;
}
D3D11_TEXTURE2D_DESC desc{};
texture->GetDesc(&desc);
desc.Width = static_cast<UINT>(width_);
desc.Height = static_cast<UINT>(height_);
desc.MipLevels = 1;
desc.ArraySize = 1;
desc.Format = DXGI_FORMAT_B8G8R8A8_UNORM;
desc.SampleDesc.Count = 1;
desc.SampleDesc.Quality = 0;
desc.Usage = D3D11_USAGE_STAGING;
desc.BindFlags = 0;
desc.CPUAccessFlags = D3D11_CPU_ACCESS_READ;
desc.MiscFlags = 0;
return succeeded(device_->CreateTexture2D(&desc, nullptr, &stagingTexture_),
"CreateTexture2D(staging)");
}
bool MFEncoder::copyFrameToBuffer(
ID3D11Texture2D* texture,
BYTE* destination,
DWORD destinationSize,
const BgraFrameView* webcamFrame) {
if (!ensureStagingTexture(texture)) {
return false;
}
context_->CopyResource(stagingTexture_.Get(), texture);
D3D11_MAPPED_SUBRESOURCE mapped{};
if (!succeeded(context_->Map(stagingTexture_.Get(), 0, D3D11_MAP_READ, 0, &mapped), "Map")) {
return false;
}
const DWORD rowBytes = static_cast<DWORD>(width_ * 4);
const DWORD requiredBytes = rowBytes * static_cast<DWORD>(height_);
if (destinationSize < requiredBytes) {
context_->Unmap(stagingTexture_.Get(), 0);
std::cerr << "ERROR: Media Foundation buffer is too small" << std::endl;
return false;
}
auto* source = static_cast<const BYTE*>(mapped.pData);
for (int y = 0; y < height_; y += 1) {
std::memcpy(destination + rowBytes * y, source + mapped.RowPitch * y, rowBytes);
}
if (webcamFrame) {
compositeWebcam(destination, width_, height_, *webcamFrame);
}
context_->Unmap(stagingTexture_.Get(), 0);
return true;
}
bool MFEncoder::copyBgraFrameToBuffer(const BgraFrameView& frame, BYTE* destination, DWORD destinationSize) {
if (!frame.data || frame.width <= 0 || frame.height <= 0) {
return false;
}
const DWORD rowBytes = static_cast<DWORD>(width_ * 4);
const DWORD requiredBytes = rowBytes * static_cast<DWORD>(height_);
if (destinationSize < requiredBytes) {
std::cerr << "ERROR: Media Foundation webcam buffer is too small" << std::endl;
return false;
}
if (frame.width == width_ && frame.height == height_) {
for (DWORD i = 0; i < requiredBytes; i += 4) {
destination[i] = frame.data[i];
destination[i + 1] = frame.data[i + 1];
destination[i + 2] = frame.data[i + 2];
destination[i + 3] = 255;
}
return true;
}
for (int y = 0; y < height_; y += 1) {
const int sourceY = static_cast<int>((static_cast<int64_t>(y) * frame.height) / height_);
BYTE* destinationRow = destination + rowBytes * y;
for (int x = 0; x < width_; x += 1) {
const int sourceX = static_cast<int>((static_cast<int64_t>(x) * frame.width) / width_);
const BYTE* source = frame.data + (sourceY * frame.width + sourceX) * 4;
BYTE* target = destinationRow + x * 4;
target[0] = source[0];
target[1] = source[1];
target[2] = source[2];
target[3] = 255;
}
}
return true;
}
bool MFEncoder::writeFrame(ID3D11Texture2D* texture, int64_t timestampHns, const BgraFrameView* webcamFrame) {
std::scoped_lock writerLock(writerMutex_);
if (!sinkWriter_ || finalized_) {
return false;
}
if (firstTimestampHns_ < 0) {
firstTimestampHns_ = timestampHns;
}
int64_t sampleTime = timestampHns - firstTimestampHns_;
if (sampleTime <= lastTimestampHns_) {
sampleTime = lastTimestampHns_ + (10'000'000LL / fps_);
}
const int64_t sampleDuration = 10'000'000LL / fps_;
lastTimestampHns_ = sampleTime;
Microsoft::WRL::ComPtr<IMFMediaBuffer> buffer;
const DWORD frameBytes = static_cast<DWORD>(width_ * height_ * 4);
if (!succeeded(MFCreateMemoryBuffer(frameBytes, &buffer), "MFCreateMemoryBuffer")) {
return false;
}
BYTE* data = nullptr;
DWORD maxLength = 0;
DWORD currentLength = 0;
if (!succeeded(buffer->Lock(&data, &maxLength, &currentLength), "IMFMediaBuffer::Lock")) {
return false;
}
const bool copied = copyFrameToBuffer(texture, data, maxLength, webcamFrame);
buffer->Unlock();
if (!copied) {
return false;
}
buffer->SetCurrentLength(frameBytes);
Microsoft::WRL::ComPtr<IMFSample> sample;
if (!succeeded(MFCreateSample(&sample), "MFCreateSample")) {
return false;
}
sample->AddBuffer(buffer.Get());
sample->SetSampleTime(sampleTime);
sample->SetSampleDuration(sampleDuration);
return succeeded(sinkWriter_->WriteSample(videoStreamIndex_, sample.Get()), "WriteSample");
}
bool MFEncoder::writeBgraFrame(const BgraFrameView& frame, int64_t timestampHns) {
std::scoped_lock writerLock(writerMutex_);
if (!sinkWriter_ || finalized_) {
return false;
}
if (firstTimestampHns_ < 0) {
firstTimestampHns_ = timestampHns;
}
int64_t sampleTime = timestampHns - firstTimestampHns_;
if (sampleTime <= lastTimestampHns_) {
sampleTime = lastTimestampHns_ + (10'000'000LL / fps_);
}
const int64_t sampleDuration = 10'000'000LL / fps_;
lastTimestampHns_ = sampleTime;
Microsoft::WRL::ComPtr<IMFMediaBuffer> buffer;
const DWORD frameBytes = static_cast<DWORD>(width_ * height_ * 4);
if (!succeeded(MFCreateMemoryBuffer(frameBytes, &buffer), "MFCreateMemoryBuffer(webcam)")) {
return false;
}
BYTE* data = nullptr;
DWORD maxLength = 0;
DWORD currentLength = 0;
if (!succeeded(buffer->Lock(&data, &maxLength, &currentLength), "IMFMediaBuffer::Lock(webcam)")) {
return false;
}
const bool copied = copyBgraFrameToBuffer(frame, data, maxLength);
buffer->Unlock();
if (!copied) {
return false;
}
buffer->SetCurrentLength(frameBytes);
Microsoft::WRL::ComPtr<IMFSample> sample;
if (!succeeded(MFCreateSample(&sample), "MFCreateSample(webcam)")) {
return false;
}
sample->AddBuffer(buffer.Get());
sample->SetSampleTime(sampleTime);
sample->SetSampleDuration(sampleDuration);
return succeeded(sinkWriter_->WriteSample(videoStreamIndex_, sample.Get()), "WriteSample(webcam)");
}
bool MFEncoder::writeAudio(const BYTE* data, DWORD byteCount, int64_t timestampHns, int64_t durationHns) {
std::scoped_lock writerLock(writerMutex_);
if (!sinkWriter_ || finalized_ || !hasAudioStream_) {
return false;
}
if (!data || byteCount == 0 || durationHns <= 0) {
return true;
}
Microsoft::WRL::ComPtr<IMFMediaBuffer> buffer;
if (!succeeded(MFCreateMemoryBuffer(byteCount, &buffer), "MFCreateMemoryBuffer(audio)")) {
return false;
}
BYTE* destination = nullptr;
DWORD maxLength = 0;
DWORD currentLength = 0;
if (!succeeded(buffer->Lock(&destination, &maxLength, &currentLength),
"IMFMediaBuffer::Lock(audio)")) {
return false;
}
if (maxLength < byteCount) {
buffer->Unlock();
std::cerr << "ERROR: Media Foundation audio buffer is too small" << std::endl;
return false;
}
std::memcpy(destination, data, byteCount);
buffer->Unlock();
buffer->SetCurrentLength(byteCount);
Microsoft::WRL::ComPtr<IMFSample> sample;
if (!succeeded(MFCreateSample(&sample), "MFCreateSample(audio)")) {
return false;
}
sample->AddBuffer(buffer.Get());
sample->SetSampleTime(std::max<int64_t>(0, timestampHns));
sample->SetSampleDuration(durationHns);
return succeeded(sinkWriter_->WriteSample(audioStreamIndex_, sample.Get()), "WriteSample(audio)");
}
bool MFEncoder::finalize() {
std::scoped_lock writerLock(writerMutex_);
if (finalized_) {
return true;
}
finalized_ = true;
bool ok = true;
if (sinkWriter_) {
ok = succeeded(sinkWriter_->Finalize(), "SinkWriter::Finalize");
sinkWriter_.Reset();
}
stagingTexture_.Reset();
context_.Reset();
device_.Reset();
MFShutdown();
return ok;
}
@@ -0,0 +1,75 @@
#pragma once
#include <Windows.h>
#include <d3d11.h>
#include <mfapi.h>
#include <mfidl.h>
#include <mfreadwrite.h>
#include <wrl/client.h>
#include <cstdint>
#include <mutex>
#include <string>
struct BgraFrameView {
const BYTE* data = nullptr;
int width = 0;
int height = 0;
};
struct AudioInputFormat {
GUID subtype = MFAudioFormat_PCM;
UINT32 sampleRate = 0;
UINT32 channels = 0;
UINT32 bitsPerSample = 0;
UINT32 blockAlign = 0;
UINT32 avgBytesPerSec = 0;
};
class MFEncoder {
public:
MFEncoder() = default;
~MFEncoder();
MFEncoder(const MFEncoder&) = delete;
MFEncoder& operator=(const MFEncoder&) = delete;
bool initialize(
const std::wstring& outputPath,
int width,
int height,
int fps,
int bitrate,
ID3D11Device* device,
ID3D11DeviceContext* context,
const AudioInputFormat* audioFormat = nullptr);
bool writeFrame(ID3D11Texture2D* texture, int64_t timestampHns, const BgraFrameView* webcamFrame = nullptr);
bool writeBgraFrame(const BgraFrameView& frame, int64_t timestampHns);
bool writeAudio(const BYTE* data, DWORD byteCount, int64_t timestampHns, int64_t durationHns);
bool finalize();
private:
bool ensureStagingTexture(ID3D11Texture2D* texture);
bool copyFrameToBuffer(
ID3D11Texture2D* texture,
BYTE* destination,
DWORD destinationSize,
const BgraFrameView* webcamFrame);
bool copyBgraFrameToBuffer(const BgraFrameView& frame, BYTE* destination, DWORD destinationSize);
bool configureAudioStream(const AudioInputFormat& audioFormat);
Microsoft::WRL::ComPtr<IMFSinkWriter> sinkWriter_;
Microsoft::WRL::ComPtr<ID3D11Device> device_;
Microsoft::WRL::ComPtr<ID3D11DeviceContext> context_;
Microsoft::WRL::ComPtr<ID3D11Texture2D> stagingTexture_;
std::mutex writerMutex_;
DWORD videoStreamIndex_ = 0;
DWORD audioStreamIndex_ = 0;
bool hasAudioStream_ = false;
int width_ = 0;
int height_ = 0;
int fps_ = 60;
int64_t firstTimestampHns_ = -1;
int64_t lastTimestampHns_ = -1;
bool finalized_ = false;
};
@@ -0,0 +1,88 @@
#include "monitor_utils.h"
#include <algorithm>
#include <cmath>
#include <vector>
namespace {
struct MonitorCandidate {
HMONITOR monitor = nullptr;
RECT rect{};
};
std::vector<MonitorCandidate> enumerateMonitors() {
std::vector<MonitorCandidate> monitors;
EnumDisplayMonitors(
nullptr,
nullptr,
[](HMONITOR monitor, HDC, LPRECT rect, LPARAM userData) -> BOOL {
auto* result = reinterpret_cast<std::vector<MonitorCandidate>*>(userData);
result->push_back({monitor, *rect});
return TRUE;
},
reinterpret_cast<LPARAM>(&monitors));
return monitors;
}
bool rectMatchesBounds(const RECT& rect, const MonitorBounds& bounds) {
return rect.left == bounds.x &&
rect.top == bounds.y &&
(rect.right - rect.left) == bounds.width &&
(rect.bottom - rect.top) == bounds.height;
}
int64_t overlapArea(const RECT& rect, const MonitorBounds& bounds) {
const LONG left = std::max<LONG>(rect.left, bounds.x);
const LONG top = std::max<LONG>(rect.top, bounds.y);
const LONG right = std::min<LONG>(rect.right, bounds.x + bounds.width);
const LONG bottom = std::min<LONG>(rect.bottom, bounds.y + bounds.height);
if (right <= left || bottom <= top) {
return 0;
}
return static_cast<int64_t>(right - left) * static_cast<int64_t>(bottom - top);
}
} // namespace
HMONITOR findMonitorForCapture(int64_t displayId, const MonitorBounds* bounds) {
const auto monitors = enumerateMonitors();
if (monitors.empty()) {
return MonitorFromPoint({0, 0}, MONITOR_DEFAULTTOPRIMARY);
}
// Electron's display_id is not stable across all Windows capture backends.
// Bounds are the most reliable contract because they come from Electron's
// selected display and match the WGC monitor coordinate space.
if (bounds && bounds->width > 0 && bounds->height > 0) {
for (const auto& candidate : monitors) {
if (rectMatchesBounds(candidate.rect, *bounds)) {
return candidate.monitor;
}
}
HMONITOR bestMonitor = nullptr;
int64_t bestArea = 0;
for (const auto& candidate : monitors) {
const int64_t area = overlapArea(candidate.rect, *bounds);
if (area > bestArea) {
bestArea = area;
bestMonitor = candidate.monitor;
}
}
if (bestMonitor) {
return bestMonitor;
}
}
// Best-effort fallback for helpers invoked without bounds. Some callers pass
// zero-based ids while Win32 monitor handles are pointer values, so only use
// this when it exactly matches the HMONITOR value.
for (const auto& candidate : monitors) {
if (reinterpret_cast<int64_t>(candidate.monitor) == displayId) {
return candidate.monitor;
}
}
return MonitorFromPoint({0, 0}, MONITOR_DEFAULTTOPRIMARY);
}
@@ -0,0 +1,14 @@
#pragma once
#include <Windows.h>
#include <cstdint>
struct MonitorBounds {
int x = 0;
int y = 0;
int width = 0;
int height = 0;
};
HMONITOR findMonitorForCapture(int64_t displayId, const MonitorBounds* bounds);
@@ -0,0 +1,411 @@
#include "wasapi_loopback_capture.h"
#include <Functiondiscoverykeys_devpkey.h>
#include <ksmedia.h>
#include <propvarutil.h>
#include <algorithm>
#include <chrono>
#include <cwctype>
#include <iostream>
namespace {
constexpr REFERENCE_TIME BufferDurationHns = 10'000'000;
constexpr int64_t HnsPerSecond = 10'000'000;
bool succeeded(HRESULT hr, const char* label) {
if (SUCCEEDED(hr)) {
return true;
}
std::cerr << "ERROR: " << label << " failed (hr=0x" << std::hex << hr << std::dec << ")"
<< std::endl;
return false;
}
GUID audioSubtypeFromFormat(WAVEFORMATEX* format) {
if (format->wFormatTag == WAVE_FORMAT_IEEE_FLOAT) {
return MFAudioFormat_Float;
}
if (format->wFormatTag == WAVE_FORMAT_PCM) {
return MFAudioFormat_PCM;
}
if (format->wFormatTag == WAVE_FORMAT_EXTENSIBLE &&
format->cbSize >= sizeof(WAVEFORMATEXTENSIBLE) - sizeof(WAVEFORMATEX)) {
auto* extensible = reinterpret_cast<WAVEFORMATEXTENSIBLE*>(format);
if (extensible->SubFormat == KSDATAFORMAT_SUBTYPE_IEEE_FLOAT) {
return MFAudioFormat_Float;
}
if (extensible->SubFormat == KSDATAFORMAT_SUBTYPE_PCM) {
return MFAudioFormat_PCM;
}
}
return GUID_NULL;
}
std::wstring normalizeDeviceName(const std::wstring& value) {
std::wstring result;
result.reserve(value.size());
bool lastWasSpace = true;
for (const wchar_t c : value) {
if (std::iswalnum(c)) {
result.push_back(static_cast<wchar_t>(std::towlower(c)));
lastWasSpace = false;
} else if (!lastWasSpace) {
result.push_back(L' ');
lastWasSpace = true;
}
}
if (!result.empty() && result.back() == L' ') {
result.pop_back();
}
return result;
}
int scoreDeviceName(const std::wstring& candidateName, const std::wstring& candidateId, const std::wstring& requestedName) {
const std::wstring candidate = normalizeDeviceName(candidateName);
const std::wstring id = normalizeDeviceName(candidateId);
const std::wstring requested = normalizeDeviceName(requestedName);
if (requested.empty()) {
return 0;
}
if (candidate == requested) {
return 1000;
}
if (!candidate.empty() && (candidate.find(requested) != std::wstring::npos || requested.find(candidate) != std::wstring::npos)) {
return 900;
}
if (!id.empty() && (id.find(requested) != std::wstring::npos || requested.find(id) != std::wstring::npos)) {
return 800;
}
int score = 0;
size_t pos = 0;
while (pos < requested.size()) {
const size_t end = requested.find(L' ', pos);
const std::wstring word = requested.substr(pos, end == std::wstring::npos ? std::wstring::npos : end - pos);
if (word.size() > 1 && word != L"microphone" && word != L"mic" && word != L"audio" && word != L"input") {
if (candidate.find(word) != std::wstring::npos) {
score += 100;
} else if (id.find(word) != std::wstring::npos) {
score += 50;
}
}
if (end == std::wstring::npos) {
break;
}
pos = end + 1;
}
return score;
}
std::wstring getDeviceFriendlyName(IMMDevice* device) {
if (!device) {
return {};
}
Microsoft::WRL::ComPtr<IPropertyStore> properties;
HRESULT hr = device->OpenPropertyStore(STGM_READ, &properties);
if (FAILED(hr) || !properties) {
return {};
}
PROPVARIANT value;
PropVariantInit(&value);
hr = properties->GetValue(PKEY_Device_FriendlyName, &value);
std::wstring name;
if (SUCCEEDED(hr) && value.vt == VT_LPWSTR && value.pwszVal) {
name = value.pwszVal;
}
PropVariantClear(&value);
return name;
}
} // namespace
WasapiLoopbackCapture::~WasapiLoopbackCapture() {
stop();
if (mixFormat_) {
CoTaskMemFree(mixFormat_);
mixFormat_ = nullptr;
}
}
bool WasapiLoopbackCapture::initializeSystemLoopback() {
return initialize(WasapiCaptureEndpoint::SystemLoopback, {}, {});
}
bool WasapiLoopbackCapture::initializeMicrophone(const std::wstring& deviceId, const std::wstring& deviceName) {
return initialize(WasapiCaptureEndpoint::Microphone, deviceId, deviceName);
}
bool WasapiLoopbackCapture::initialize(WasapiCaptureEndpoint endpoint, const std::wstring& deviceId, const std::wstring& deviceName) {
HRESULT hr = CoCreateInstance(
__uuidof(MMDeviceEnumerator),
nullptr,
CLSCTX_ALL,
IID_PPV_ARGS(&deviceEnumerator_));
if (!succeeded(hr, "CoCreateInstance(MMDeviceEnumerator)")) {
return false;
}
if (endpoint == WasapiCaptureEndpoint::Microphone && !deviceId.empty() && deviceId != L"default") {
hr = deviceEnumerator_->GetDevice(deviceId.c_str(), &device_);
if (FAILED(hr)) {
std::wcerr << L"WARNING: Could not resolve microphone device id directly"
<< std::endl;
device_.Reset();
}
}
if (endpoint == WasapiCaptureEndpoint::Microphone && !device_ && !deviceName.empty()) {
if (!resolveMicrophoneByName(deviceName)) {
std::wcerr << L"WARNING: Could not resolve microphone by name; using default capture endpoint"
<< std::endl;
}
}
if (!device_) {
const EDataFlow flow =
endpoint == WasapiCaptureEndpoint::SystemLoopback ? eRender : eCapture;
hr = deviceEnumerator_->GetDefaultAudioEndpoint(flow, eConsole, &device_);
if (!succeeded(hr, "GetDefaultAudioEndpoint")) {
return false;
}
}
selectedDeviceName_ = getDeviceFriendlyName(device_.Get());
hr = device_->Activate(__uuidof(IAudioClient), CLSCTX_ALL, nullptr, &audioClient_);
if (!succeeded(hr, "IMMDevice::Activate(IAudioClient)")) {
return false;
}
hr = audioClient_->GetMixFormat(&mixFormat_);
if (!succeeded(hr, "IAudioClient::GetMixFormat") || !mixFormat_) {
return false;
}
if (!resolveInputFormat(mixFormat_)) {
std::cerr << "ERROR: Unsupported WASAPI loopback mix format" << std::endl;
return false;
}
const DWORD streamFlags =
endpoint == WasapiCaptureEndpoint::SystemLoopback ? AUDCLNT_STREAMFLAGS_LOOPBACK : 0;
hr = audioClient_->Initialize(
AUDCLNT_SHAREMODE_SHARED,
streamFlags,
BufferDurationHns,
0,
mixFormat_,
nullptr);
if (!succeeded(hr, "IAudioClient::Initialize(loopback)")) {
return false;
}
hr = audioClient_->GetService(IID_PPV_ARGS(&captureClient_));
if (!succeeded(hr, "IAudioClient::GetService(IAudioCaptureClient)")) {
return false;
}
return true;
}
bool WasapiLoopbackCapture::resolveMicrophoneByName(const std::wstring& deviceName) {
if (!deviceEnumerator_ || deviceName.empty()) {
return false;
}
Microsoft::WRL::ComPtr<IMMDeviceCollection> devices;
HRESULT hr = deviceEnumerator_->EnumAudioEndpoints(eCapture, DEVICE_STATE_ACTIVE, &devices);
if (!succeeded(hr, "IMMDeviceEnumerator::EnumAudioEndpoints(eCapture)")) {
return false;
}
UINT count = 0;
hr = devices->GetCount(&count);
if (!succeeded(hr, "IMMDeviceCollection::GetCount")) {
return false;
}
Microsoft::WRL::ComPtr<IMMDevice> bestDevice;
std::wstring bestId;
std::wstring bestName;
int bestScore = 0;
for (UINT i = 0; i < count; ++i) {
Microsoft::WRL::ComPtr<IMMDevice> candidate;
hr = devices->Item(i, &candidate);
if (FAILED(hr) || !candidate) {
continue;
}
LPWSTR rawId = nullptr;
std::wstring candidateId;
if (SUCCEEDED(candidate->GetId(&rawId)) && rawId) {
candidateId = rawId;
CoTaskMemFree(rawId);
}
const std::wstring candidateName = getDeviceFriendlyName(candidate.Get());
const int score = scoreDeviceName(candidateName, candidateId, deviceName);
std::wcerr << L"Native microphone candidate: " << candidateName << L" score=" << score << std::endl;
if (score > bestScore) {
bestScore = score;
bestDevice = candidate;
bestId = candidateId;
bestName = candidateName;
}
}
if (!bestDevice || bestScore <= 0) {
return false;
}
device_ = bestDevice;
std::wcerr << L"Selected native microphone endpoint: " << bestName << L" id=" << bestId << std::endl;
return true;
}
bool WasapiLoopbackCapture::resolveInputFormat(WAVEFORMATEX* mixFormat) {
const GUID subtype = audioSubtypeFromFormat(mixFormat);
if (subtype == GUID_NULL) {
return false;
}
inputFormat_.subtype = subtype;
inputFormat_.sampleRate = mixFormat->nSamplesPerSec;
inputFormat_.channels = mixFormat->nChannels;
inputFormat_.bitsPerSample = mixFormat->wBitsPerSample;
inputFormat_.blockAlign = mixFormat->nBlockAlign;
inputFormat_.avgBytesPerSec = mixFormat->nAvgBytesPerSec;
return inputFormat_.sampleRate > 0 && inputFormat_.channels > 0 && inputFormat_.blockAlign > 0;
}
bool WasapiLoopbackCapture::start(AudioCallback callback) {
if (!audioClient_ || !captureClient_ || !callback) {
return false;
}
callback_ = std::move(callback);
stopRequested_ = false;
writtenFrames_ = 0;
lastDevicePositionEnd_ = 0;
hasLastDevicePosition_ = false;
HRESULT hr = audioClient_->Start();
if (!succeeded(hr, "IAudioClient::Start")) {
return false;
}
thread_ = std::thread([this] {
captureLoop();
});
return true;
}
void WasapiLoopbackCapture::stop() {
stopRequested_ = true;
if (thread_.joinable()) {
thread_.join();
}
if (audioClient_) {
audioClient_->Stop();
}
}
const AudioInputFormat& WasapiLoopbackCapture::inputFormat() const {
return inputFormat_;
}
const std::wstring& WasapiLoopbackCapture::selectedDeviceName() const {
return selectedDeviceName_;
}
void WasapiLoopbackCapture::captureLoop() {
auto emitSilenceFrames = [&](uint64_t frames, int64_t timestampHns) {
constexpr uint64_t MaxSilenceChunkFrames = 4800;
uint64_t remainingFrames = frames;
int64_t currentTimestampHns = timestampHns;
while (remainingFrames > 0 && !stopRequested_) {
const uint64_t chunkFrames = std::min<uint64_t>(remainingFrames, MaxSilenceChunkFrames);
const DWORD chunkBytes = static_cast<DWORD>(chunkFrames * inputFormat_.blockAlign);
const int64_t chunkDurationHns =
static_cast<int64_t>((chunkFrames * HnsPerSecond) / inputFormat_.sampleRate);
silenceBuffer_.assign(chunkBytes, 0);
callback_(silenceBuffer_.data(), chunkBytes, currentTimestampHns, chunkDurationHns);
remainingFrames -= chunkFrames;
currentTimestampHns += chunkDurationHns;
}
};
while (!stopRequested_) {
UINT32 packetFrames = 0;
HRESULT hr = captureClient_->GetNextPacketSize(&packetFrames);
if (FAILED(hr)) {
std::cerr << "ERROR: IAudioCaptureClient::GetNextPacketSize failed (hr=0x" << std::hex
<< hr << std::dec << ")" << std::endl;
break;
}
while (packetFrames > 0 && !stopRequested_) {
BYTE* data = nullptr;
UINT32 framesAvailable = 0;
DWORD flags = 0;
UINT64 devicePosition = 0;
UINT64 qpcPosition = 0;
hr = captureClient_->GetBuffer(&data, &framesAvailable, &flags, &devicePosition, &qpcPosition);
if (FAILED(hr)) {
std::cerr << "ERROR: IAudioCaptureClient::GetBuffer failed (hr=0x" << std::hex
<< hr << std::dec << ")" << std::endl;
break;
}
(void)qpcPosition;
if (hasLastDevicePosition_ && devicePosition > lastDevicePositionEnd_) {
const uint64_t gapFrames = devicePosition - lastDevicePositionEnd_;
if ((flags & AUDCLNT_BUFFERFLAGS_DATA_DISCONTINUITY) != 0 || gapFrames > framesAvailable) {
const int64_t gapTimestampHns =
static_cast<int64_t>((lastDevicePositionEnd_ * HnsPerSecond) / inputFormat_.sampleRate);
emitSilenceFrames(gapFrames, gapTimestampHns);
}
}
const DWORD byteCount = framesAvailable * inputFormat_.blockAlign;
const int64_t timestampHns =
static_cast<int64_t>((devicePosition * HnsPerSecond) / inputFormat_.sampleRate);
const int64_t durationHns =
static_cast<int64_t>((static_cast<uint64_t>(framesAvailable) * HnsPerSecond) /
inputFormat_.sampleRate);
if (byteCount > 0) {
if ((flags & AUDCLNT_BUFFERFLAGS_SILENT) != 0 || !data) {
silenceBuffer_.assign(byteCount, 0);
callback_(silenceBuffer_.data(), byteCount, timestampHns, durationHns);
} else {
callback_(data, byteCount, timestampHns, durationHns);
}
}
writtenFrames_ += framesAvailable;
lastDevicePositionEnd_ = devicePosition + framesAvailable;
hasLastDevicePosition_ = true;
captureClient_->ReleaseBuffer(framesAvailable);
hr = captureClient_->GetNextPacketSize(&packetFrames);
if (FAILED(hr)) {
std::cerr << "ERROR: IAudioCaptureClient::GetNextPacketSize failed (hr=0x"
<< std::hex << hr << std::dec << ")" << std::endl;
packetFrames = 0;
break;
}
}
std::this_thread::sleep_for(std::chrono::milliseconds(5));
}
}
@@ -0,0 +1,60 @@
#pragma once
#include "mf_encoder.h"
#include <Windows.h>
#include <audioclient.h>
#include <mmdeviceapi.h>
#include <wrl/client.h>
#include <atomic>
#include <cstdint>
#include <functional>
#include <string>
#include <thread>
#include <vector>
enum class WasapiCaptureEndpoint {
SystemLoopback,
Microphone,
};
class WasapiLoopbackCapture {
public:
using AudioCallback = std::function<void(const BYTE* data, DWORD byteCount, int64_t timestampHns, int64_t durationHns)>;
WasapiLoopbackCapture() = default;
~WasapiLoopbackCapture();
WasapiLoopbackCapture(const WasapiLoopbackCapture&) = delete;
WasapiLoopbackCapture& operator=(const WasapiLoopbackCapture&) = delete;
bool initializeSystemLoopback();
bool initializeMicrophone(const std::wstring& deviceId, const std::wstring& deviceName);
bool start(AudioCallback callback);
void stop();
const AudioInputFormat& inputFormat() const;
const std::wstring& selectedDeviceName() const;
private:
bool initialize(WasapiCaptureEndpoint endpoint, const std::wstring& deviceId, const std::wstring& deviceName);
bool resolveMicrophoneByName(const std::wstring& deviceName);
void captureLoop();
bool resolveInputFormat(WAVEFORMATEX* mixFormat);
Microsoft::WRL::ComPtr<IMMDeviceEnumerator> deviceEnumerator_;
Microsoft::WRL::ComPtr<IMMDevice> device_;
Microsoft::WRL::ComPtr<IAudioClient> audioClient_;
Microsoft::WRL::ComPtr<IAudioCaptureClient> captureClient_;
WAVEFORMATEX* mixFormat_ = nullptr;
AudioInputFormat inputFormat_{};
std::wstring selectedDeviceName_;
AudioCallback callback_;
std::thread thread_;
std::atomic<bool> stopRequested_ = false;
std::vector<BYTE> silenceBuffer_;
uint64_t writtenFrames_ = 0;
uint64_t lastDevicePositionEnd_ = 0;
bool hasLastDevicePosition_ = false;
};
@@ -0,0 +1,419 @@
#include "webcam_capture.h"
#include <mfapi.h>
#include <mferror.h>
#include <propvarutil.h>
#include <algorithm>
#include <chrono>
#include <cwctype>
#include <iostream>
namespace {
bool succeeded(HRESULT hr, const char* label) {
if (SUCCEEDED(hr)) {
return true;
}
std::cerr << "ERROR: " << label << " failed (hr=0x" << std::hex << hr << std::dec << ")"
<< std::endl;
return false;
}
std::wstring readAllocatedString(IMFActivate* activate, REFGUID key) {
WCHAR* value = nullptr;
UINT32 length = 0;
if (FAILED(activate->GetAllocatedString(key, &value, &length)) || !value) {
return {};
}
std::wstring result(value, value + length);
CoTaskMemFree(value);
return result;
}
bool containsInsensitive(const std::wstring& haystack, const std::wstring& needle) {
if (haystack.empty() || needle.empty()) {
return false;
}
std::wstring lowerHaystack = haystack;
std::wstring lowerNeedle = needle;
std::transform(lowerHaystack.begin(), lowerHaystack.end(), lowerHaystack.begin(), ::towlower);
std::transform(lowerNeedle.begin(), lowerNeedle.end(), lowerNeedle.begin(), ::towlower);
return lowerHaystack.find(lowerNeedle) != std::wstring::npos ||
lowerNeedle.find(lowerHaystack) != std::wstring::npos;
}
std::wstring normalizeDeviceName(const std::wstring& value) {
std::wstring normalized;
normalized.reserve(value.size());
bool lastWasSpace = true;
for (const wchar_t ch : value) {
if (std::iswalnum(ch)) {
normalized.push_back(static_cast<wchar_t>(std::towlower(ch)));
lastWasSpace = false;
continue;
}
if (!lastWasSpace) {
normalized.push_back(L' ');
lastWasSpace = true;
}
}
while (!normalized.empty() && normalized.back() == L' ') {
normalized.pop_back();
}
return normalized;
}
std::vector<std::wstring> splitWords(const std::wstring& value) {
std::vector<std::wstring> words;
size_t start = 0;
while (start < value.size()) {
const size_t end = value.find(L' ', start);
const auto word = value.substr(start, end == std::wstring::npos ? std::wstring::npos : end - start);
if (word.size() > 1 && word != L"camera" && word != L"webcam" && word != L"video" && word != L"input") {
words.push_back(word);
}
if (end == std::wstring::npos) {
break;
}
start = end + 1;
}
return words;
}
int deviceMatchScore(
const std::wstring& candidateName,
const std::wstring& candidateLink,
const std::wstring& requestedName,
const std::wstring& requestedId) {
int score = 0;
const auto normalizedName = normalizeDeviceName(candidateName);
const auto normalizedLink = normalizeDeviceName(candidateLink);
const auto normalizedRequestedName = normalizeDeviceName(requestedName);
const auto normalizedRequestedId = normalizeDeviceName(requestedId);
if (!normalizedRequestedName.empty()) {
if (normalizedName == normalizedRequestedName) {
score = std::max(score, 1000);
}
if (containsInsensitive(normalizedName, normalizedRequestedName)) {
score = std::max(score, 900);
}
if (containsInsensitive(normalizedLink, normalizedRequestedName)) {
score = std::max(score, 800);
}
int wordScore = 0;
for (const auto& word : splitWords(normalizedRequestedName)) {
if (normalizedName.find(word) != std::wstring::npos) {
wordScore += 100;
} else if (normalizedLink.find(word) != std::wstring::npos) {
wordScore += 50;
}
}
score = std::max(score, wordScore);
}
if (!normalizedRequestedId.empty()) {
if (containsInsensitive(normalizedLink, normalizedRequestedId)) {
score = std::max(score, 700);
}
if (containsInsensitive(normalizedName, normalizedRequestedId)) {
score = std::max(score, 600);
}
}
return score;
}
} // namespace
WebcamCapture::~WebcamCapture() {
stop();
}
bool WebcamCapture::initialize(
const std::wstring& deviceId,
const std::wstring& deviceName,
const std::wstring& directShowClsid,
int requestedWidth,
int requestedHeight,
int requestedFps) {
fps_ = std::clamp(requestedFps > 0 ? requestedFps : 30, 1, 60);
usingDirectShow_ = false;
selectedMatchScore_ = 0;
if (!succeeded(MFStartup(MF_VERSION), "MFStartup(webcam)")) {
if (directShowCapture_.initialize(deviceId, deviceName, directShowClsid, requestedWidth, requestedHeight, fps_)) {
usingDirectShow_ = true;
return true;
}
return false;
}
mfStarted_ = true;
if (!selectDevice(deviceId, deviceName)) {
if (mfStarted_) {
MFShutdown();
mfStarted_ = false;
}
if (directShowCapture_.initialize(deviceId, deviceName, directShowClsid, requestedWidth, requestedHeight, fps_)) {
usingDirectShow_ = true;
return true;
}
return false;
}
if ((!deviceId.empty() || !deviceName.empty()) && selectedMatchScore_ <= 0) {
if (mediaSource_) {
mediaSource_->Shutdown();
}
sourceReader_.Reset();
mediaSource_.Reset();
if (mfStarted_) {
MFShutdown();
mfStarted_ = false;
}
if (directShowCapture_.initialize(deviceId, deviceName, directShowClsid, requestedWidth, requestedHeight, fps_)) {
usingDirectShow_ = true;
return true;
}
std::cerr << "ERROR: Requested webcam device was not found by native Windows webcam providers"
<< std::endl;
return false;
}
return configureReader(requestedWidth, requestedHeight, fps_);
}
bool WebcamCapture::selectDevice(const std::wstring& deviceId, const std::wstring& deviceName) {
Microsoft::WRL::ComPtr<IMFAttributes> attributes;
if (!succeeded(MFCreateAttributes(&attributes, 1), "MFCreateAttributes(webcam enumeration)")) {
return false;
}
if (!succeeded(attributes->SetGUID(
MF_DEVSOURCE_ATTRIBUTE_SOURCE_TYPE,
MF_DEVSOURCE_ATTRIBUTE_SOURCE_TYPE_VIDCAP_GUID),
"SetGUID(webcam source type)")) {
return false;
}
IMFActivate** devices = nullptr;
UINT32 deviceCount = 0;
HRESULT hr = MFEnumDeviceSources(attributes.Get(), &devices, &deviceCount);
if (!succeeded(hr, "MFEnumDeviceSources") || deviceCount == 0) {
if (devices) {
CoTaskMemFree(devices);
}
std::cerr << "ERROR: No native Windows webcam devices were found" << std::endl;
return false;
}
UINT32 selectedIndex = 0;
int bestScore = 0;
for (UINT32 index = 0; index < deviceCount; index += 1) {
const std::wstring name = readAllocatedString(devices[index], MF_DEVSOURCE_ATTRIBUTE_FRIENDLY_NAME);
const std::wstring symbolicLink = readAllocatedString(devices[index], MF_DEVSOURCE_ATTRIBUTE_SOURCE_TYPE_VIDCAP_SYMBOLIC_LINK);
const int score = deviceMatchScore(name, symbolicLink, deviceName, deviceId);
std::wcerr << L"INFO: Native webcam candidate [" << index << L"] name=\"" << name << L"\" score=" << score << std::endl;
if (score > bestScore) {
selectedIndex = index;
bestScore = score;
}
}
if ((!deviceId.empty() || !deviceName.empty()) && bestScore <= 0) {
std::cerr << "WARNING: Requested webcam device was not found by Media Foundation; trying DirectShow"
<< std::endl;
}
selectedMatchScore_ = bestScore;
selectedDeviceName_ = readAllocatedString(devices[selectedIndex], MF_DEVSOURCE_ATTRIBUTE_FRIENDLY_NAME);
hr = devices[selectedIndex]->ActivateObject(IID_PPV_ARGS(&mediaSource_));
for (UINT32 index = 0; index < deviceCount; index += 1) {
devices[index]->Release();
}
CoTaskMemFree(devices);
return succeeded(hr, "ActivateObject(webcam)");
}
bool WebcamCapture::configureReader(int requestedWidth, int requestedHeight, int requestedFps) {
Microsoft::WRL::ComPtr<IMFAttributes> attributes;
if (!succeeded(MFCreateAttributes(&attributes, 2), "MFCreateAttributes(webcam reader)")) {
return false;
}
attributes->SetUINT32(MF_SOURCE_READER_ENABLE_VIDEO_PROCESSING, TRUE);
attributes->SetUINT32(MF_READWRITE_DISABLE_CONVERTERS, FALSE);
if (!succeeded(MFCreateSourceReaderFromMediaSource(mediaSource_.Get(), attributes.Get(), &sourceReader_),
"MFCreateSourceReaderFromMediaSource(webcam)")) {
return false;
}
Microsoft::WRL::ComPtr<IMFMediaType> mediaType;
if (!succeeded(MFCreateMediaType(&mediaType), "MFCreateMediaType(webcam output)")) {
return false;
}
mediaType->SetGUID(MF_MT_MAJOR_TYPE, MFMediaType_Video);
mediaType->SetGUID(MF_MT_SUBTYPE, MFVideoFormat_RGB32);
if (requestedWidth > 0 && requestedHeight > 0) {
MFSetAttributeSize(mediaType.Get(), MF_MT_FRAME_SIZE, static_cast<UINT32>(requestedWidth), static_cast<UINT32>(requestedHeight));
}
MFSetAttributeRatio(mediaType.Get(), MF_MT_FRAME_RATE, static_cast<UINT32>(std::max(1, requestedFps)), 1);
if (!succeeded(sourceReader_->SetCurrentMediaType(MF_SOURCE_READER_FIRST_VIDEO_STREAM, nullptr, mediaType.Get()),
"SetCurrentMediaType(webcam RGB32)")) {
return false;
}
sourceReader_->SetStreamSelection(MF_SOURCE_READER_ALL_STREAMS, FALSE);
sourceReader_->SetStreamSelection(MF_SOURCE_READER_FIRST_VIDEO_STREAM, TRUE);
Microsoft::WRL::ComPtr<IMFMediaType> currentType;
if (!succeeded(sourceReader_->GetCurrentMediaType(MF_SOURCE_READER_FIRST_VIDEO_STREAM, &currentType),
"GetCurrentMediaType(webcam)")) {
return false;
}
UINT32 width = 0;
UINT32 height = 0;
if (FAILED(MFGetAttributeSize(currentType.Get(), MF_MT_FRAME_SIZE, &width, &height)) || width == 0 || height == 0) {
width = static_cast<UINT32>(requestedWidth > 0 ? requestedWidth : 1280);
height = static_cast<UINT32>(requestedHeight > 0 ? requestedHeight : 720);
}
width_ = static_cast<int>(width);
height_ = static_cast<int>(height);
return true;
}
bool WebcamCapture::start() {
if (usingDirectShow_) {
return directShowCapture_.start();
}
if (!sourceReader_ || thread_.joinable()) {
return false;
}
stopRequested_ = false;
thread_ = std::thread(&WebcamCapture::captureLoop, this);
return true;
}
void WebcamCapture::stop() {
directShowCapture_.stop();
stopRequested_ = true;
if (thread_.joinable()) {
thread_.join();
}
if (mediaSource_) {
mediaSource_->Shutdown();
}
sourceReader_.Reset();
mediaSource_.Reset();
if (mfStarted_) {
MFShutdown();
mfStarted_ = false;
}
}
void WebcamCapture::captureLoop() {
CoInitializeEx(nullptr, COINIT_MULTITHREADED);
while (!stopRequested_) {
DWORD streamIndex = 0;
DWORD flags = 0;
LONGLONG timestamp = 0;
Microsoft::WRL::ComPtr<IMFSample> sample;
HRESULT hr = sourceReader_->ReadSample(
MF_SOURCE_READER_FIRST_VIDEO_STREAM,
0,
&streamIndex,
&flags,
&timestamp,
&sample);
(void)streamIndex;
(void)timestamp;
if (FAILED(hr)) {
std::cerr << "WARNING: Failed to read webcam sample (hr=0x" << std::hex << hr << std::dec << ")"
<< std::endl;
std::this_thread::sleep_for(std::chrono::milliseconds(20));
continue;
}
if ((flags & MF_SOURCE_READERF_ENDOFSTREAM) != 0) {
break;
}
if (!sample) {
continue;
}
Microsoft::WRL::ComPtr<IMFMediaBuffer> buffer;
if (FAILED(sample->ConvertToContiguousBuffer(&buffer)) || !buffer) {
continue;
}
BYTE* data = nullptr;
DWORD maxLength = 0;
DWORD currentLength = 0;
if (FAILED(buffer->Lock(&data, &maxLength, &currentLength)) || !data) {
continue;
}
const DWORD expectedLength = static_cast<DWORD>(std::max(0, width_) * std::max(0, height_) * 4);
if (currentLength >= expectedLength && expectedLength > 0) {
std::scoped_lock lock(frameMutex_);
latestFrame_.assign(data, data + expectedLength);
latestFrameSequence_ += 1;
}
buffer->Unlock();
}
CoUninitialize();
}
bool WebcamCapture::copyLatestFrame(WebcamFrameSnapshot& destination) {
if (usingDirectShow_) {
return directShowCapture_.copyLatestFrame(destination);
}
std::scoped_lock lock(frameMutex_);
if (latestFrame_.empty() || width_ <= 0 || height_ <= 0) {
return false;
}
destination.data = latestFrame_;
destination.width = width_;
destination.height = height_;
destination.sequence = latestFrameSequence_;
return true;
}
int WebcamCapture::width() const {
if (usingDirectShow_) {
return directShowCapture_.width();
}
return width_;
}
int WebcamCapture::height() const {
if (usingDirectShow_) {
return directShowCapture_.height();
}
return height_;
}
int WebcamCapture::fps() const {
if (usingDirectShow_) {
return directShowCapture_.fps();
}
return fps_;
}
const std::wstring& WebcamCapture::selectedDeviceName() const {
if (usingDirectShow_) {
return directShowCapture_.selectedDeviceName();
}
return selectedDeviceName_;
}
@@ -0,0 +1,61 @@
#pragma once
#include "dshow_webcam_capture.h"
#include <Windows.h>
#include <mfidl.h>
#include <mfreadwrite.h>
#include <wrl/client.h>
#include <atomic>
#include <cstdint>
#include <mutex>
#include <string>
#include <thread>
#include <vector>
class WebcamCapture {
public:
WebcamCapture() = default;
~WebcamCapture();
WebcamCapture(const WebcamCapture&) = delete;
WebcamCapture& operator=(const WebcamCapture&) = delete;
bool initialize(
const std::wstring& deviceId,
const std::wstring& deviceName,
const std::wstring& directShowClsid,
int requestedWidth,
int requestedHeight,
int requestedFps);
bool start();
void stop();
bool copyLatestFrame(WebcamFrameSnapshot& destination);
int width() const;
int height() const;
int fps() const;
const std::wstring& selectedDeviceName() const;
private:
bool selectDevice(const std::wstring& deviceId, const std::wstring& deviceName);
bool configureReader(int requestedWidth, int requestedHeight, int requestedFps);
void captureLoop();
Microsoft::WRL::ComPtr<IMFMediaSource> mediaSource_;
Microsoft::WRL::ComPtr<IMFSourceReader> sourceReader_;
DirectShowWebcamCapture directShowCapture_;
std::thread thread_;
std::atomic<bool> stopRequested_ = false;
std::mutex frameMutex_;
std::vector<BYTE> latestFrame_;
uint64_t latestFrameSequence_ = 0;
int width_ = 0;
int height_ = 0;
int fps_ = 30;
bool mfStarted_ = false;
bool usingDirectShow_ = false;
int selectedMatchScore_ = 0;
std::wstring selectedDeviceName_;
};
@@ -0,0 +1,315 @@
#include "wgc_session.h"
#include <Windows.Graphics.Capture.Interop.h>
#include <dxgi1_2.h>
#include <inspectable.h>
#include <winrt/base.h>
#include <iostream>
namespace wf = winrt::Windows::Foundation;
namespace wgcap = winrt::Windows::Graphics::Capture;
namespace wgdx = winrt::Windows::Graphics::DirectX;
namespace wgd3d = winrt::Windows::Graphics::DirectX::Direct3D11;
extern "C" HRESULT __stdcall CreateDirect3D11DeviceFromDXGIDevice(
::IDXGIDevice* dxgiDevice,
::IInspectable** graphicsDevice);
namespace {
bool succeeded(HRESULT hr, const char* label) {
if (SUCCEEDED(hr)) {
return true;
}
std::cerr << "ERROR: " << label << " failed (hr=0x" << std::hex << hr << std::dec << ")"
<< std::endl;
return false;
}
int64_t timeSpanToHns(wf::TimeSpan const& value) {
return value.count();
}
} // namespace
WgcSession::~WgcSession() {
stop();
}
bool WgcSession::createD3DDevice() {
UINT flags = D3D11_CREATE_DEVICE_BGRA_SUPPORT;
#if defined(_DEBUG)
flags |= D3D11_CREATE_DEVICE_DEBUG;
#endif
D3D_FEATURE_LEVEL featureLevels[] = {
D3D_FEATURE_LEVEL_11_1,
D3D_FEATURE_LEVEL_11_0,
D3D_FEATURE_LEVEL_10_1,
D3D_FEATURE_LEVEL_10_0,
};
D3D_FEATURE_LEVEL featureLevel{};
HRESULT hr = D3D11CreateDevice(
nullptr,
D3D_DRIVER_TYPE_HARDWARE,
nullptr,
flags,
featureLevels,
ARRAYSIZE(featureLevels),
D3D11_SDK_VERSION,
&d3dDevice_,
&featureLevel,
&d3dContext_);
#if defined(_DEBUG)
if (FAILED(hr)) {
flags &= ~D3D11_CREATE_DEVICE_DEBUG;
hr = D3D11CreateDevice(
nullptr,
D3D_DRIVER_TYPE_HARDWARE,
nullptr,
flags,
featureLevels,
ARRAYSIZE(featureLevels),
D3D11_SDK_VERSION,
&d3dDevice_,
&featureLevel,
&d3dContext_);
}
#endif
if (!succeeded(hr, "D3D11CreateDevice")) {
return false;
}
Microsoft::WRL::ComPtr<IDXGIDevice> dxgiDevice;
if (!succeeded(d3dDevice_.As(&dxgiDevice), "Query IDXGIDevice")) {
return false;
}
winrt::com_ptr<::IInspectable> inspectableDevice;
if (!succeeded(CreateDirect3D11DeviceFromDXGIDevice(dxgiDevice.Get(), inspectableDevice.put()),
"CreateDirect3D11DeviceFromDXGIDevice")) {
return false;
}
winrtDevice_ = inspectableDevice.as<wgd3d::IDirect3DDevice>();
return true;
}
bool WgcSession::createCaptureItem(HMONITOR monitor) {
auto factory = winrt::get_activation_factory<wgcap::GraphicsCaptureItem>();
auto interop = factory.as<IGraphicsCaptureItemInterop>();
wgcap::GraphicsCaptureItem item{nullptr};
HRESULT hr = interop->CreateForMonitor(
monitor,
winrt::guid_of<wgcap::GraphicsCaptureItem>(),
reinterpret_cast<void**>(winrt::put_abi(item)));
if (!succeeded(hr, "CreateForMonitor")) {
return false;
}
item_ = item;
const auto size = item_.Size();
width_ = static_cast<int>(size.Width);
height_ = static_cast<int>(size.Height);
return width_ > 0 && height_ > 0;
}
bool WgcSession::createCaptureItem(HWND window) {
auto factory = winrt::get_activation_factory<wgcap::GraphicsCaptureItem>();
auto interop = factory.as<IGraphicsCaptureItemInterop>();
wgcap::GraphicsCaptureItem item{nullptr};
HRESULT hr = interop->CreateForWindow(
window,
winrt::guid_of<wgcap::GraphicsCaptureItem>(),
reinterpret_cast<void**>(winrt::put_abi(item)));
if (!succeeded(hr, "CreateForWindow")) {
return false;
}
item_ = item;
const auto size = item_.Size();
width_ = static_cast<int>(size.Width);
height_ = static_cast<int>(size.Height);
return width_ > 0 && height_ > 0;
}
bool WgcSession::applySessionOptions(bool captureCursor) {
captureCursor_ = captureCursor;
try {
auto session2 = session_.try_as<wgcap::IGraphicsCaptureSession2>();
if (!session2) {
if (!captureCursor) {
std::cerr << "ERROR: WGC cursor suppression is not supported by this Windows runtime"
<< std::endl;
return false;
}
} else {
session2.IsCursorCaptureEnabled(captureCursor);
const bool appliedCursorCapture = session2.IsCursorCaptureEnabled();
std::cout << "{\"event\":\"cursor-capture\",\"schemaVersion\":2,\"requested\":"
<< (captureCursor ? "true" : "false")
<< ",\"applied\":" << (appliedCursorCapture ? "true" : "false") << "}"
<< std::endl;
if (appliedCursorCapture != captureCursor) {
std::cerr << "ERROR: WGC cursor capture setting did not apply" << std::endl;
return false;
}
}
} catch (winrt::hresult_error const& error) {
std::cerr << "ERROR: Failed to configure WGC cursor capture (hr=0x" << std::hex
<< static_cast<uint32_t>(error.code()) << std::dec << ")" << std::endl;
if (!captureCursor) {
return false;
}
} catch (...) {
std::cerr << "ERROR: Failed to configure WGC cursor capture" << std::endl;
if (!captureCursor) {
return false;
}
}
try {
session_.IsBorderRequired(false);
} catch (...) {
// IsBorderRequired is Windows 11-only. Ignore it on older builds.
}
return true;
}
bool WgcSession::initialize(HMONITOR monitor, int fps, bool captureCursor) {
fps_ = fps > 0 ? fps : 60;
if (!createD3DDevice()) {
return false;
}
if (!createCaptureItem(monitor)) {
return false;
}
framePool_ = wgcap::Direct3D11CaptureFramePool::CreateFreeThreaded(
winrtDevice_,
wgdx::DirectXPixelFormat::B8G8R8A8UIntNormalized,
2,
item_.Size());
session_ = framePool_.CreateCaptureSession(item_);
if (!applySessionOptions(captureCursor)) {
return false;
}
frameArrivedToken_ = framePool_.FrameArrived({this, &WgcSession::onFrameArrived});
return true;
}
bool WgcSession::initialize(HWND window, int fps, bool captureCursor) {
fps_ = fps > 0 ? fps : 60;
if (!createD3DDevice()) {
return false;
}
if (!createCaptureItem(window)) {
return false;
}
framePool_ = wgcap::Direct3D11CaptureFramePool::CreateFreeThreaded(
winrtDevice_,
wgdx::DirectXPixelFormat::B8G8R8A8UIntNormalized,
2,
item_.Size());
session_ = framePool_.CreateCaptureSession(item_);
if (!applySessionOptions(captureCursor)) {
return false;
}
frameArrivedToken_ = framePool_.FrameArrived({this, &WgcSession::onFrameArrived});
return true;
}
void WgcSession::setFrameCallback(FrameCallback callback) {
std::scoped_lock lock(callbackMutex_);
frameCallback_ = std::move(callback);
}
bool WgcSession::start() {
if (!session_) {
return false;
}
if (!applySessionOptions(captureCursor_)) {
return false;
}
session_.StartCapture();
started_ = true;
return true;
}
void WgcSession::stop() {
if (framePool_) {
framePool_.FrameArrived(frameArrivedToken_);
}
if (session_) {
session_.Close();
session_ = nullptr;
}
if (framePool_) {
framePool_.Close();
framePool_ = nullptr;
}
item_ = nullptr;
winrtDevice_ = nullptr;
d3dContext_.Reset();
d3dDevice_.Reset();
started_ = false;
}
void WgcSession::onFrameArrived(
wgcap::Direct3D11CaptureFramePool const& sender,
wf::IInspectable const&) {
auto frame = sender.TryGetNextFrame();
if (!frame) {
return;
}
auto surface = frame.Surface();
auto access = surface.as<::Windows::Graphics::DirectX::Direct3D11::IDirect3DDxgiInterfaceAccess>();
Microsoft::WRL::ComPtr<ID3D11Texture2D> texture;
HRESULT hr = access->GetInterface(__uuidof(ID3D11Texture2D), reinterpret_cast<void**>(texture.GetAddressOf()));
if (FAILED(hr) || !texture) {
return;
}
FrameCallback callback;
{
std::scoped_lock lock(callbackMutex_);
callback = frameCallback_;
}
if (callback) {
callback(texture.Get(), timeSpanToHns(frame.SystemRelativeTime()));
}
frame.Close();
}
int WgcSession::captureWidth() const {
return width_;
}
int WgcSession::captureHeight() const {
return height_;
}
ID3D11Device* WgcSession::device() const {
return d3dDevice_.Get();
}
ID3D11DeviceContext* WgcSession::context() const {
return d3dContext_.Get();
}
@@ -0,0 +1,59 @@
#pragma once
#include <Windows.h>
#include <d3d11.h>
#include <windows.graphics.capture.h>
#include <windows.graphics.directx.direct3d11.interop.h>
#include <winrt/Windows.Foundation.h>
#include <winrt/Windows.Graphics.Capture.h>
#include <winrt/Windows.Graphics.DirectX.Direct3D11.h>
#include <wrl/client.h>
#include <functional>
#include <mutex>
class WgcSession {
public:
using FrameCallback = std::function<void(ID3D11Texture2D*, int64_t)>;
WgcSession() = default;
~WgcSession();
WgcSession(const WgcSession&) = delete;
WgcSession& operator=(const WgcSession&) = delete;
bool initialize(HMONITOR monitor, int fps, bool captureCursor);
bool initialize(HWND window, int fps, bool captureCursor);
void setFrameCallback(FrameCallback callback);
bool start();
void stop();
int captureWidth() const;
int captureHeight() const;
ID3D11Device* device() const;
ID3D11DeviceContext* context() const;
private:
bool createD3DDevice();
bool createCaptureItem(HMONITOR monitor);
bool createCaptureItem(HWND window);
bool applySessionOptions(bool captureCursor);
void onFrameArrived(
winrt::Windows::Graphics::Capture::Direct3D11CaptureFramePool const& sender,
winrt::Windows::Foundation::IInspectable const&);
Microsoft::WRL::ComPtr<ID3D11Device> d3dDevice_;
Microsoft::WRL::ComPtr<ID3D11DeviceContext> d3dContext_;
winrt::Windows::Graphics::DirectX::Direct3D11::IDirect3DDevice winrtDevice_{nullptr};
winrt::Windows::Graphics::Capture::GraphicsCaptureItem item_{nullptr};
winrt::Windows::Graphics::Capture::Direct3D11CaptureFramePool framePool_{nullptr};
winrt::Windows::Graphics::Capture::GraphicsCaptureSession session_{nullptr};
winrt::event_token frameArrivedToken_{};
FrameCallback frameCallback_;
std::mutex callbackMutex_;
int width_ = 0;
int height_ = 0;
int fps_ = 60;
bool captureCursor_ = false;
bool started_ = false;
};
+155 -11
View File
@@ -1,16 +1,36 @@
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
// (see windows.ts). Sandboxed preloads cannot import node:path / node:url, so we
// can't compute it here.
const ASSET_BASE_URL_ARG_PREFIX = "--asset-base-url=";
const assetBaseUrlArg = process.argv.find((arg) => arg.startsWith(ASSET_BASE_URL_ARG_PREFIX));
const assetBaseUrl = assetBaseUrlArg ? assetBaseUrlArg.slice(ASSET_BASE_URL_ARG_PREFIX.length) : "";
contextBridge.exposeInMainWorld("electronAPI", {
assetBaseUrl,
invokeNativeBridge: <TData>(request: NativeBridgeRequest) => {
return ipcRenderer.invoke(NATIVE_BRIDGE_CHANNEL, request) as Promise<TData>;
},
hudOverlayHide: () => {
ipcRenderer.send("hud-overlay-hide");
},
hudOverlayClose: () => {
ipcRenderer.send("hud-overlay-close");
},
getAssetBasePath: async () => {
// ask main process for the correct base path (production vs dev)
return await ipcRenderer.invoke("get-asset-base-path");
setHudOverlayIgnoreMouseEvents: (ignore: boolean) => {
ipcRenderer.send("hud-overlay-ignore-mouse-events", ignore);
},
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);
@@ -18,6 +38,12 @@ contextBridge.exposeInMainWorld("electronAPI", {
switchToEditor: () => {
return ipcRenderer.invoke("switch-to-editor");
},
switchToHud: () => {
return ipcRenderer.invoke("switch-to-hud");
},
startNewRecording: () => {
return ipcRenderer.invoke("start-new-recording");
},
openSourceSelector: () => {
return ipcRenderer.invoke("open-source-selector");
},
@@ -30,23 +56,82 @@ contextBridge.exposeInMainWorld("electronAPI", {
requestCameraAccess: () => {
return ipcRenderer.invoke("request-camera-access");
},
requestScreenAccess: () => {
return ipcRenderer.invoke("request-screen-access");
},
requestNativeMacCursorAccess: () => {
return ipcRenderer.invoke("request-native-mac-cursor-access");
},
storeRecordedVideo: (videoData: ArrayBuffer, fileName: string) => {
return ipcRenderer.invoke("store-recorded-video", videoData, fileName);
},
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");
},
setRecordingState: (recording: boolean) => {
return ipcRenderer.invoke("set-recording-state", recording);
setRecordingState: (
recording: boolean,
recordingId?: number,
cursorCaptureMode?: import("../src/lib/recordingSession").CursorCaptureMode,
) => {
return ipcRenderer.invoke("set-recording-state", recording, recordingId, cursorCaptureMode);
},
isNativeWindowsCaptureAvailable: () => {
return ipcRenderer.invoke("is-native-windows-capture-available");
},
isNativeMacCaptureAvailable: () => {
return ipcRenderer.invoke("is-native-mac-capture-available");
},
startNativeWindowsRecording: (request: NativeWindowsRecordingRequest) => {
return ipcRenderer.invoke("start-native-windows-recording", request);
},
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);
},
pauseNativeMacRecording: () => {
return ipcRenderer.invoke("pause-native-mac-recording");
},
resumeNativeMacRecording: () => {
return ipcRenderer.invoke("resume-native-mac-recording");
},
stopNativeMacRecording: (discard?: boolean) => {
return ipcRenderer.invoke("stop-native-mac-recording", discard);
},
attachNativeMacWebcamRecording: (payload: {
screenVideoPath: string;
recordingId: number;
webcam: { fileName: string; videoData: ArrayBuffer };
cursorCaptureMode?: import("../src/lib/recordingSession").CursorCaptureMode;
}) => {
return ipcRenderer.invoke("attach-native-mac-webcam-recording", payload);
},
getCursorTelemetry: (videoPath?: string) => {
return ipcRenderer.invoke("get-cursor-telemetry", videoPath);
},
discardCursorTelemetry: (recordingId: number) => {
return ipcRenderer.invoke("discard-cursor-telemetry", recordingId);
},
onStopRecordingFromTray: (callback: () => void) => {
const listener = () => callback();
ipcRenderer.on("stop-recording-from-tray", listener);
@@ -55,8 +140,11 @@ contextBridge.exposeInMainWorld("electronAPI", {
openExternalUrl: (url: string) => {
return ipcRenderer.invoke("open-external-url", url);
},
saveExportedVideo: (videoData: ArrayBuffer, fileName: string) => {
return ipcRenderer.invoke("save-exported-video", videoData, fileName);
pickExportSavePath: (fileName: string, exportFolder?: string) => {
return ipcRenderer.invoke("pick-export-save-path", fileName, exportFolder);
},
writeExportToPath: (videoData: ArrayBuffer, filePath: string) => {
return ipcRenderer.invoke("write-export-to-path", videoData, filePath);
},
openVideoFilePicker: () => {
return ipcRenderer.invoke("open-video-file-picker");
@@ -76,18 +164,41 @@ contextBridge.exposeInMainWorld("electronAPI", {
readBinaryFile: (filePath: string) => {
return ipcRenderer.invoke("read-binary-file", filePath);
},
preparePreviewAudioTrack: (filePath: string) => {
return ipcRenderer.invoke("prepare-preview-audio-track", filePath);
},
clearCurrentVideoPath: () => {
return ipcRenderer.invoke("clear-current-video-path");
},
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);
@@ -115,15 +226,40 @@ 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);
},
saveDiagnostic: (payload: {
error: string;
stack?: string;
projectState: unknown;
logs: string[];
}) => {
return ipcRenderer.invoke("save-diagnostic", payload);
},
setMicrophoneExpanded: (expanded: boolean) => {
ipcRenderer.send("hud:setMicrophoneExpanded", expanded);
},
setHasUnsavedChanges: (hasChanges: boolean) => {
ipcRenderer.send("set-has-unsaved-changes", hasChanges);
},
showCountdownOverlay: (value: number, runId: number) => {
return ipcRenderer.invoke("countdown-overlay-show", value, runId);
},
setCountdownOverlayValue: (value: number, runId: number) => {
return ipcRenderer.invoke("countdown-overlay-set-value", value, runId);
},
hideCountdownOverlay: (runId: number) => {
return ipcRenderer.invoke("countdown-overlay-hide", runId);
},
onCountdownOverlayValue: (callback: (value: number | null) => void) => {
const listener = (_event: unknown, value: number | null) => callback(value);
ipcRenderer.on("countdown-overlay-value", listener);
return () => ipcRenderer.removeListener("countdown-overlay-value", listener);
},
onRequestSaveBeforeClose: (callback: () => Promise<boolean> | boolean) => {
const listener = async () => {
try {
@@ -136,4 +272,12 @@ contextBridge.exposeInMainWorld("electronAPI", {
ipcRenderer.on("request-save-before-close", listener);
return () => ipcRenderer.removeListener("request-save-before-close", listener);
},
onRequestCloseConfirm: (callback: () => void) => {
const listener = () => callback();
ipcRenderer.on("request-close-confirm", listener);
return () => ipcRenderer.removeListener("request-close-confirm", listener);
},
sendCloseConfirmResponse: (choice: "save" | "discard" | "cancel") => {
ipcRenderer.send("close-confirm-response", choice);
},
});
+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";
}
+177 -9
View File
@@ -1,5 +1,5 @@
import path from "node:path";
import { fileURLToPath } from "node:url";
import { fileURLToPath, pathToFileURL } from "node:url";
import { BrowserWindow, ipcMain, screen } from "electron";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -9,6 +9,13 @@ const VITE_DEV_SERVER_URL = process.env["VITE_DEV_SERVER_URL"];
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 to resources/wallpapers. Unpackaged: <appRoot>/public/.
const ASSET_BASE_DIR = process.defaultApp
? path.join(__dirname, "..", "public")
: process.resourcesPath;
const ASSET_BASE_URL_ARG = `--asset-base-url=${pathToFileURL(`${ASSET_BASE_DIR}${path.sep}`).toString()}`;
let hudOverlayWindow: BrowserWindow | null = null;
ipcMain.on("hud-overlay-hide", () => {
@@ -17,6 +24,66 @@ ipcMain.on("hud-overlay-hide", () => {
}
});
ipcMain.on("hud-overlay-ignore-mouse-events", (_event, ignore: boolean) => {
if (hudOverlayWindow && !hudOverlayWindow.isDestroyed()) {
hudOverlayWindow.setIgnoreMouseEvents(ignore, { forward: true });
}
});
ipcMain.on("hud-overlay-move-by", (_event, deltaX: number, deltaY: number) => {
if (
!hudOverlayWindow ||
hudOverlayWindow.isDestroyed() ||
!Number.isFinite(deltaX) ||
!Number.isFinite(deltaY)
) {
return;
}
const [x, y] = hudOverlayWindow.getPosition();
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,
});
});
/**
* 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();
const { workArea } = primaryDisplay;
@@ -30,26 +97,46 @@ 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],
nodeIntegration: false,
contextIsolation: true,
backgroundThrottling: false,
},
});
win.setIgnoreMouseEvents(true, { forward: true });
// 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());
@@ -74,6 +161,10 @@ export function createHudOverlayWindow(): BrowserWindow {
return win;
}
/**
* 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";
@@ -91,10 +182,11 @@ 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],
nodeIntegration: false,
contextIsolation: true,
webSecurity: false,
@@ -102,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());
});
@@ -120,6 +224,10 @@ export function createEditorWindow(): BrowserWindow {
return win;
}
/**
* 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;
@@ -137,11 +245,18 @@ export function createSourceSelectorWindow(): BrowserWindow {
backgroundColor: "#00000000",
webPreferences: {
preload: path.join(__dirname, "preload.mjs"),
additionalArguments: [ASSET_BASE_URL_ARG],
nodeIntegration: false,
contextIsolation: true,
},
});
// 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 });
}
if (VITE_DEV_SERVER_URL) {
win.loadURL(VITE_DEV_SERVER_URL + "?windowType=source-selector");
} else {
@@ -152,3 +267,56 @@ export function createSourceSelectorWindow(): BrowserWindow {
return win;
}
/**
* Centered transparent countdown overlay that sits above the HUD during
* recording pre-roll.
*/
export function createCountdownOverlayWindow(): BrowserWindow {
const { workArea } = screen.getPrimaryDisplay();
const overlayWidth = 420;
const overlayHeight = 260;
const win = new BrowserWindow({
width: overlayWidth,
height: overlayHeight,
minWidth: overlayWidth,
maxWidth: overlayWidth,
minHeight: overlayHeight,
maxHeight: overlayHeight,
x: Math.round(workArea.x + (workArea.width - overlayWidth) / 2),
y: Math.round(workArea.y + (workArea.height - overlayHeight) / 2),
frame: false,
resizable: false,
alwaysOnTop: true,
skipTaskbar: true,
focusable: false,
transparent: true,
backgroundColor: "#00000000",
hasShadow: false,
show: false,
webPreferences: {
preload: path.join(__dirname, "preload.mjs"),
additionalArguments: [ASSET_BASE_URL_ARG],
nodeIntegration: false,
contextIsolation: true,
backgroundThrottling: false,
},
});
win.setIgnoreMouseEvents(true);
if (process.platform === "darwin") {
win.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });
}
if (VITE_DEV_SERVER_URL) {
win.loadURL(VITE_DEV_SERVER_URL + "?windowType=countdown-overlay");
} else {
win.loadFile(path.join(RENDERER_DIST, "index.html"), {
query: { windowType: "countdown-overlay" },
});
}
return win;
}
Generated
+27
View File
@@ -0,0 +1,27 @@
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1775710090,
"narHash": "sha256-ar3rofg+awPB8QXDaFJhJ2jJhu+KqN/PRCXeyuXR76E=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "4c1018dae018162ec878d42fec712642d214fdfa",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}
+122
View File
@@ -0,0 +1,122 @@
{
description = "OpenScreen — desktop screen recorder with built-in editor";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
};
outputs =
{ self, nixpkgs }:
let
systems = [
"x86_64-linux"
"aarch64-linux"
];
forAllSystems = f: nixpkgs.lib.genAttrs systems (system: f nixpkgs.legacyPackages.${system});
in
{
# -- Per-system outputs (packages, dev shells) --
packages = forAllSystems (pkgs: {
openscreen = pkgs.callPackage ./nix/package.nix { };
default = self.packages.${pkgs.stdenv.hostPlatform.system}.openscreen;
});
devShells = forAllSystems (
pkgs:
let
electron = pkgs.electron;
# Libraries Electron needs at runtime on Linux
runtimeLibs = with pkgs; [
# X11
libx11
libxcomposite
libxdamage
libxext
libxfixes
libxrandr
libxtst
libxcb
libxshmfence
# Wayland
wayland
# GTK / UI toolkit
gtk3
glib
pango
cairo
gdk-pixbuf
atk
at-spi2-atk
at-spi2-core
# Graphics
mesa
libGL
libdrm
vulkan-loader
# Networking / crypto (NSS for Chromium)
nss
nspr
# Audio
alsa-lib
pipewire
pulseaudio
# System
dbus
cups
expat
libnotify
libsecret
util-linux # libuuid
];
in
{
default = pkgs.mkShell {
packages = with pkgs; [
nodejs_22
electron
# Native module compilation
python3
pkg-config
gcc
# Playwright browser tests
playwright-driver.browsers
];
# Electron's prebuilt binary needs these at runtime
LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath runtimeLibs;
# Tell the npm `electron` package to use the Nix-provided binary
# instead of downloading its own. vite-plugin-electron respects this.
ELECTRON_OVERRIDE_DIST_PATH = "${electron}/libexec/electron";
# Playwright browser path for test:browser / test:e2e
PLAYWRIGHT_BROWSERS_PATH = "${pkgs.playwright-driver.browsers}";
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD = "1";
shellHook = ''
echo "OpenScreen dev shell — node $(node --version), electron v$(electron --version 2>/dev/null | tr -d 'v')"
'';
};
}
);
# -- System-wide outputs (modules, overlay) --
overlays.default = final: _prev: {
openscreen = self.packages.${final.stdenv.hostPlatform.system}.openscreen;
};
nixosModules.default = import ./nix/module.nix self;
homeManagerModules.default = import ./nix/hm-module.nix self;
};
}
Binary file not shown.
+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>
+29
View File
@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- Required for Electron's V8 JIT compilation -->
<key>com.apple.security.cs.allow-jit</key>
<true/>
<!-- Required for Electron's native module loading -->
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<!-- Required for loading Electron's bundled frameworks/dylibs -->
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
<!-- Audio input (microphone / system audio capture) -->
<key>com.apple.security.device.audio-input</key>
<true/>
<!-- Camera (webcam capture) -->
<key>com.apple.security.device.camera</key>
<true/>
<!-- Screen recording (required for desktopCapturer.getSources() on macOS 10.15+) -->
<key>com.apple.security.device.screen-capture</key>
<true/>
</dict>
</plist>
+36
View File
@@ -0,0 +1,36 @@
# Home Manager module for OpenScreen
# Usage in flake-based Home Manager config:
#
# inputs.openscreen.url = "github:siddharthvaddem/openscreen";
#
# { inputs, ... }: {
# imports = [ inputs.openscreen.homeManagerModules.default ];
# programs.openscreen.enable = true;
# }
self:
{
config,
lib,
pkgs,
...
}:
let
cfg = config.programs.openscreen;
in
{
options.programs.openscreen = {
enable = lib.mkEnableOption "OpenScreen screen recorder";
package = lib.mkOption {
type = lib.types.package;
default = self.packages.${pkgs.stdenv.hostPlatform.system}.openscreen;
defaultText = lib.literalExpression "inputs.openscreen.packages.\${pkgs.stdenv.hostPlatform.system}.openscreen";
description = "The OpenScreen package to use.";
};
};
config = lib.mkIf cfg.enable {
home.packages = [ cfg.package ];
};
}
+42
View File
@@ -0,0 +1,42 @@
# NixOS module for OpenScreen
# Usage in flake-based NixOS config:
#
# inputs.openscreen.url = "github:siddharthvaddem/openscreen";
#
# { inputs, ... }: {
# imports = [ inputs.openscreen.nixosModules.default ];
# programs.openscreen.enable = true;
# }
self:
{
config,
lib,
pkgs,
...
}:
let
cfg = config.programs.openscreen;
in
{
options.programs.openscreen = {
enable = lib.mkEnableOption "OpenScreen screen recorder";
package = lib.mkOption {
type = lib.types.package;
default = self.packages.${pkgs.stdenv.hostPlatform.system}.openscreen;
defaultText = lib.literalExpression "inputs.openscreen.packages.\${pkgs.stdenv.hostPlatform.system}.openscreen";
description = "The OpenScreen package to use.";
};
};
config = lib.mkIf cfg.enable {
environment.systemPackages = [ cfg.package ];
# Screen capture on Wayland requires xdg-desktop-portal.
# We enable the base portal; users should also enable a
# desktop-specific portal (e.g. xdg-desktop-portal-gtk,
# xdg-desktop-portal-hyprland) in their DE config.
xdg.portal.enable = lib.mkDefault true;
};
}
+124
View File
@@ -0,0 +1,124 @@
{
lib,
buildNpmPackage,
nodejs_22,
electron,
makeWrapper,
makeDesktopItem,
copyDesktopItems,
}:
buildNpmPackage {
nodejs = nodejs_22;
pname = "openscreen";
version = "1.5.0";
src =
let
fs = lib.fileset;
# gitTracked fails when source is already a store path (path: flake inputs).
# Detect this and fall back to cleanSource which handles both cases.
isStorePath = builtins.storeDir == builtins.substring 0 (builtins.stringLength builtins.storeDir) (toString ../.);
baseFiles = if isStorePath then fs.fromSource (lib.cleanSource ../.) else fs.gitTracked ../.;
in
fs.toSource {
root = ../.;
fileset = fs.difference baseFiles (
fs.unions [
../nix
../flake.nix
../flake.lock
(fs.fileFilter (file: file.hasExt "md") ../.)
]
);
};
npmDepsHash = "sha256-tOpoJPzaZDK3HJijGHpZ0+jWsbrYyQUuw1pO0Uxcifg=";
env.ELECTRON_SKIP_BINARY_DOWNLOAD = "1";
# electron-builder is not needed — we wrap system electron directly
npmFlags = [ "--ignore-scripts" ];
makeCacheWritable = true;
# vite-plugin-electron compiles electron/ sources into dist-electron/
# tsconfig has noEmit — tsc is type-check only
buildPhase = ''
runHook preBuild
npx vite build
runHook postBuild
'';
installPhase = ''
runHook preInstall
mkdir -p "$out/lib/openscreen"
# Renderer build output (index.html, JS chunks, copied public/ assets)
cp -r dist "$out/lib/openscreen/"
# Main process + preload (compiled by vite-plugin-electron)
cp -r dist-electron "$out/lib/openscreen/"
# Package manifest (electron reads "main" field to find entry point)
cp package.json "$out/lib/openscreen/"
# Strip devDependencies (electron, vitest, biome, playwright, etc.)
npm prune --omit=dev --no-save
cp -r node_modules "$out/lib/openscreen/"
# Asset resolution: when app.isPackaged is false, the main process resolves
# assets at <appPath>/public/. Place wallpapers at that root to match the
# packaged layout (electron-builder extraResources -> resources/wallpapers).
mkdir -p "$out/lib/openscreen/public"
cp -r public/wallpapers "$out/lib/openscreen/public/wallpapers"
# Wrap system electron with the app directory
mkdir -p "$out/bin"
makeWrapper "${electron}/bin/electron" "$out/bin/openscreen" \
--add-flags "$out/lib/openscreen" \
--set ELECTRON_IS_DEV 0
# Install icons to hicolor theme
for size in 16 24 32 48 64 128 256 512 1024; do
icon="icons/icons/png/''${size}x''${size}.png"
if [ -f "$icon" ]; then
install -Dm644 "$icon" \
"$out/share/icons/hicolor/''${size}x''${size}/apps/openscreen.png"
fi
done
runHook postInstall
'';
nativeBuildInputs = [
makeWrapper
copyDesktopItems
];
desktopItems = [
(makeDesktopItem {
name = "openscreen";
desktopName = "OpenScreen";
genericName = "Screen Recorder";
exec = "openscreen %U";
icon = "openscreen";
comment = "Desktop screen recorder with built-in editor";
categories = [
"AudioVideo"
"Video"
"Recorder"
];
startupWMClass = "Openscreen";
terminal = false;
})
];
meta = {
description = "Desktop screen recorder with built-in editor";
homepage = "https://github.com/siddharthvaddem/openscreen";
license = lib.licenses.mit;
mainProgram = "openscreen";
platforms = lib.platforms.linux;
};
}
+2632 -6276
View File
File diff suppressed because it is too large Load Diff
+64 -42
View File
@@ -1,13 +1,17 @@
{
"name": "openscreen",
"private": true,
"version": "1.3.0",
"version": "1.5.0",
"type": "module",
"packageManager": "npm@10.9.4",
"engines": {
"node": "22.22.1",
"npm": "10.9.4"
},
"author": {
"name": "Sid",
"email": "svaddem@asu.edu"
},
"scripts": {
"dev": "vite",
"build": "tsc && vite build && electron-builder",
@@ -16,13 +20,28 @@
"format": "biome format --write .",
"i18n:check": "node scripts/i18n-check.mjs",
"preview": "vite preview",
"build:mac": "tsc && vite build && electron-builder --mac",
"build:win": "tsc && vite build && electron-builder --win",
"build:linux": "tsc && vite build && electron-builder --linux",
"build:native:mac": "node scripts/build-macos-screencapturekit-helper.mjs",
"build:mac": "npm run build:native:mac && tsc && vite build && electron-builder --mac",
"build:native:win": "node scripts/build-windows-wgc-helper.mjs",
"build:win": "npm run build:native:win && tsc && vite build && electron-builder --win --config.npmRebuild=false",
"build:linux": "tsc && vite build && electron-builder --linux AppImage deb pacman --config.npmRebuild=false",
"test": "vitest --run",
"test:watch": "vitest",
"test:cursor-native:win": "node scripts/test-windows-native-cursor.mjs",
"test:wgc-helper:win": "node scripts/test-windows-wgc-helper.mjs",
"test:wgc-window:win": "node scripts/test-windows-wgc-helper.mjs --window",
"test:wgc-audio:win": "node scripts/test-windows-wgc-helper.mjs --system-audio",
"test:wgc-mic:win": "node scripts/test-windows-wgc-helper.mjs --microphone",
"test:wgc-mixed-audio:win": "node scripts/test-windows-wgc-helper.mjs --system-audio --microphone",
"test:wgc-webcam:win": "node scripts/test-windows-wgc-helper.mjs --webcam",
"test:wgc-full:win": "node scripts/test-windows-wgc-helper.mjs --webcam --system-audio --microphone",
"capture:openscreen-preview": "node scripts/capture-openscreen-preview.mjs",
"inspect:cursor-click-bounce": "node scripts/inspect-native-cursor-click-bounce.mjs",
"build-vite": "tsc && vite build",
"test:browser": "vitest --config vitest.browser.config.ts --run",
"test:browser:install": "playwright install --with-deps chromium-headless-shell",
"test:e2e": "playwright test",
"test:e2e:windows-native-checklist": "playwright test tests/e2e/windows-native-checklist.spec.ts",
"prepare": "husky"
},
"dependencies": {
@@ -34,67 +53,70 @@
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8",
"@types/gif.js": "^0.2.5",
"@uiw/color-convert": "^2.9.2",
"@uiw/react-color-block": "^2.9.2",
"@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.2.0",
"emoji-picker-react": "^4.16.1",
"dnd-timeline": "^2.4.0",
"emoji-picker-react": "^4.18.0",
"fix-webm-duration": "^1.0.6",
"gif.js": "^0.2.0",
"gsap": "^3.13.0",
"gsap": "^3.15.0",
"lucide-react": "^0.545.0",
"mediabunny": "^1.25.1",
"motion": "^12.23.24",
"mp4box": "^2.2.0",
"mediabunny": "^1.40.1",
"motion": "^12.38.0",
"mp4box": "^2.3.0",
"pixi-filters": "^6.1.5",
"pixi.js": "^8.14.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-icons": "^5.5.0",
"pixi.js": "^8.18.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-icons": "^5.6.0",
"react-resizable-panels": "^3.0.6",
"react-rnd": "^10.5.2",
"react-rnd": "^10.5.3",
"sonner": "^2.0.7",
"tailwind-merge": "^3.3.1",
"tailwind-merge": "^3.5.0",
"tailwindcss-animate": "^1.0.7",
"uuid": "^13.0.0",
"web-demuxer": "^4.0.0"
},
"devDependencies": {
"@biomejs/biome": "^2.3.13",
"@playwright/test": "^1.58.2",
"@biomejs/biome": "^2.4.12",
"@electron/rebuild": "^4.0.4",
"@playwright/test": "^1.59.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^25.0.3",
"@types/react": "^18.2.64",
"@types/react-dom": "^18.2.21",
"@types/uuid": "^10.0.0",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.21",
"electron": "^39.2.7",
"electron-builder": "^26.7.0",
"electron-icon-builder": "^2.0.1",
"electron-rebuild": "^3.2.9",
"fast-check": "^4.5.2",
"@types/node": "^22.19.17",
"@types/react": "^18.3.28",
"@types/react-dom": "^18.3.7",
"@vitejs/plugin-react": "^5.2.0",
"@vitest/browser": "^4.1.4",
"@vitest/browser-playwright": "^4.1.4",
"autoprefixer": "^10.5.0",
"electron": "^41.2.1",
"electron-builder": "^26.8.1",
"esbuild": "^0.27.0",
"fast-check": "^4.7.0",
"husky": "^9.1.7",
"jsdom": "^29.0.1",
"lint-staged": "^16.3.2",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.18",
"terser": "^5.44.1",
"typescript": "^5.2.2",
"vite": "^5.1.6",
"vite-plugin-electron": "^0.28.6",
"vite-plugin-electron-renderer": "^0.14.5",
"vitest": "^4.0.16"
"jsdom": "^29.0.2",
"lint-staged": "^16.4.0",
"postcss": "^8.5.10",
"tailwindcss": "^3.4.19",
"terser": "^5.46.1",
"typescript": "^5.9.3",
"vite": "^7.3.2",
"vite-plugin-electron": "^0.29.1",
"vite-plugin-electron-renderer": "^0.14.6",
"vitest": "^4.1.4"
},
"main": "dist-electron/main.js",
"lint-staged": {
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

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