Compare commits

...
Author SHA1 Message Date
Siddharth 7169e583c7 revert: undo local merge of PR #347 2026-04-11 09:58:15 -07:00
Siddharth de7518549c feat: add dual frame webcam layout preset (#347) 2026-04-11 09:54:30 -07: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
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
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
LorenzoLancia 3232918197 Add the Shortcut Blur 2026-04-09 21:51:27 +02:00
LorenzoLancia 38d72217c2 fix little things blur 2026-04-08 22:43:30 +02:00
LorenzoLancia f6b7c463f0 Fix last issues 2026-04-08 22:21: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
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
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
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
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
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
Siddharth c868469be5 fix: auto-finalize duration bug, restore cancelRecording, and add i18n for pause tooltips 2026-04-05 10:17:35 -07:00
Sid e90bba82ef feat: add pause/resume recording (#314)
added a new Feature that allows user to pause/resume while screen rec…
2026-04-05 10:05:38 -07:00
Siddharth 475cbcd76c revert: undo manual merge of PR #314 2026-04-05 10:05:04 -07:00
Siddharth 08f66c7c25 feat: add pause/resume recording with duration fix 2026-04-05 09:57:35 -07: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
Siddharth 7072c05edd fix: duration bug in auto-finalize path and add i18n for pause tooltip 2026-04-05 09:39:28 -07:00
Manish 0bc3bbca6b Merge branch 'main' into feature/pause-button 2026-04-05 22:03:35 +05:30
Sid 5340272530 Merge pull request #313 from theaiagent/feature/frame-step-navigation
feat: add arrow key frame-by-frame playhead navigation
2026-04-05 08:49:43 -07:00
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
Sid da16872809 Merge pull request #295 from abres33/feature/cancel-recording
feat: add Cancel Recording button to HUD
2026-04-04 22:10:17 -07:00
Sid 11788ad703 Merge pull request #332 from marcgabe15/addDiscord
Add discord
2026-04-04 20:35:23 -07: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
Marc Diaz 66f9172a35 feat: add discord to readme 2026-04-04 20:52:14 -04:00
Marc Diaz bd604cb658 add discord to readme 2026-04-04 20:43:41 -04: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
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
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
Sid 21893f07af Merge pull request #288 from gulivan/feature/webcam-mask-shapes
Add webcam mask shape support
2026-04-03 22:56:01 -07:00
Sid 763c187f87 Merge pull request #281 from GuilhermeFaga/main
fix(#264): read raw pixels from canvas for VideoFrame to avoid silent failures on Linux
2026-04-03 22:50:15 -07:00
Sid 20567db245 Merge pull request #257 from xKeCo/feature/auto-follow-zoom
feat: add auto-follow zoom mode with cursor tracking
2026-04-03 22:42:02 -07:00
Sid 7a1113827c Merge pull request #318 from tmchow/feat/219-appimage-update-info
feat: embed AppImage update information for delta updates
2026-04-03 22:32:56 -07:00
Trevin Chow 7e298d3bbf feat: embed AppImage update information for delta updates
Add a top-level publish config in electron-builder.json5 pointing to
GitHub Releases. This embeds the update information URL in the AppImage
header, enabling tools like AppImageUpdate, AppImageLauncher, and
AppManager to perform delta updates instead of full re-downloads.

Also update the Linux build workflow to upload the generated .zsync file
alongside the .AppImage artifact.

Fixes #219
2026-04-03 20:14:20 -07:00
theaiagent 97c9a73578 fix: skip frame-step on ARIA widgets that own arrow keys
Expand the arrow key guard to also skip elements with
role="separator" (PanelResizeHandle), role="slider", and
role="spinbutton" so keyboard panel resizing is not intercepted.
2026-04-03 23:02:12 +03:00
theaiagent 3bfcd8576b fix: read live video.currentTime for rapid frame steps and add JSDoc
- Read currentTime directly from the video element instead of the React
  ref so rapid arrow key presses each advance by exactly one frame
- Add JSDoc docstrings to frameStep.ts exports
2026-04-03 22:44:25 +03:00
maniesh6900 b002f2a485 added a new Feature that allows user to pause/resume while screen recording, 2026-04-04 00:56:14 +05:30
theaiagent cd0f2ab318 fix: expand arrow key guard for form controls and wire i18n for fixed shortcuts
- Add HTMLSelectElement and contentEditable to the arrow key input guard
  to prevent intercepting native keyboard behavior on form controls
- Add i18nKey field to FixedShortcut interface and wire up i18n lookups
  in ShortcutsConfigDialog and KeyboardShortcutsHelp so fixed shortcut
  labels are properly localized
2026-04-03 22:06:45 +03:00
xKeCo 54df597160 ✨ feat: enhance adaptive smoothing for auto-follow zoom in video playback 2026-04-03 12:26:07 -05:00
theaiagent e5430eed39 feat: add arrow key frame-by-frame playhead navigation (#302) 2026-04-03 17:50:53 +03:00
theaiagent baa30a9d6a test: add unit tests for frame step time computation 2026-04-03 17:44:26 +03:00
theaiagent b709d0d240 feat: add frame step entries to FIXED_SHORTCUTS display list 2026-04-03 17:37:13 +03:00
theaiagent 11bad60eb2 feat: add i18n labels for frame step shortcuts (en, es, zh-CN) 2026-04-03 17:36:43 +03: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
Adam 27853cc2c3 fix: await setCurrentVideoPath and narrow catch in gif-export E2E test 2026-04-03 02:32:47 -05:00
Adam d6933813bd fix: move try/catch outside evaluate() in gif-export E2E test 2026-04-03 02:25:29 -05:00
Adam 2b471783c0 feat: add Cancel Recording button to HUD 2026-04-03 02:00:36 -05:00
Sid b101820ab8 Merge pull request #293 from abres33/fix/restart-recording-windows
fix: prevent double-finalize race condition in restartRecording on Windos
2026-04-02 23:35:46 -07:00
Sid 3061c141c6 Merge pull request #249 from EtienneLescot/feat/webcam-selector-optimization
feat: added webcam source selector and optimized horizontal UI
2026-04-02 23:30:30 -07:00
Adam 846cf71e09 fix: prevent double-finalize race condition in restartRecording on Windows 2026-04-03 01:12:26 -05:00
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
Ivan 9d0ccf3bde Add webcam mask shape support 2026-04-03 00:09:51 +03:00
Faga 914a3c7f7b fix: read raw pixels from canvas for VideoFrame to avoid silent failures on Linux 2026-04-02 11:55:21 -03:00
Siddharth 2f36160174 version bump 2026-04-01 22:08:43 -07:00
xKeCo 05a87a8ab1 Revert "demo: add example project file for auto-follow zoom"
This reverts commit 5c6621293a.
2026-04-01 02:53:03 -05:00
xKeCoandClaude Sonnet 4.6 5c6621293a demo: add example project file for auto-follow zoom
Contains the zoom region configuration used in the PR demo video:
two auto-follow zoom regions and one manual zoom region.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 02:51:25 -05:00
xKeCo 163b12d6fc ♻️ refactor: refactor zoom focus handling in video editor settings and playback 2026-04-01 02:38:42 -05:00
xKeCo 3be195cc15 ✨ feat: smooth auto-follow zoom with export parity 2026-04-01 01:41:20 -05:00
Etienne Lescot baec9a7585 fix: focusable element when webcam expanded with no devices, add error test
- LaunchWindow: render sr-only <select> when webcamExpanded but
  cameraDevices.length === 0 (loading/error/empty), so keyboard users
  always have a focusable element even in no-camera states
- useCameraDevices.test: add error-branch test asserting error message,
  empty devices array and isLoading=false when enumerateDevices rejects
2026-03-27 16:28:53 +01:00
Etienne Lescot 9817c85acf fix: address coderabbit review (concurrent stream, collapsed label, unified select, test quality)
- useCameraDevices: remove getUserMedia label probe to avoid conflict with
  useScreenRecorder acquiring the real stream; use enumerateDevices only and
  fall back to 'Camera <id>' for unlabeled devices; gate effect on enabled flag
- LaunchWindow: fix selectedCameraLabel to reflect loading/error/empty states
  in the collapsed view (was always showing 'Default Camera')
- LaunchWindow: unify webcam <select> to a single always-mounted element
  (sr-only when unavailable); mirrors the mic selector pattern
- useCameraDevices.test.ts: re-seed mockGetUserMedia in beforeEach after
  vi.resetAllMocks(); update permission test to assert fallback label behavior
2026-03-27 15:15:43 +01:00
Etienne Lescot 9762448929 fix: address coderabbit comments (loading state + keyboard access)
- LaunchWindow: expose isLoading/error from useCameraDevices; show
  'Searching...' only while enumeration is in flight, 'Camera unavailable'
  on error, 'No camera found' when list is empty (fixes perpetual loading state)
- LaunchWindow: keep <select> always mounted (sr-only when collapsed) and
  expand panel on focus as well as hover; fixes keyboard inaccessibility for
  both mic and webcam selectors
- i18n: add webcam.noneFound and webcam.unavailable to en/es/zh-CN locales
2026-03-27 14:53:41 +01:00
Etienne Lescot eade28079d fix: address PR review comments
- useCameraDevices: remove selectedDeviceId from useEffect deps (use ref instead)
- useCameraDevices: fall back to first available device when selected device is unplugged
- i18n: add missing keys (audio.defaultMicrophone, webcam.defaultCamera, webcam.searching) to en/es/zh-CN
- LaunchWindow: replace hardcoded strings with t() i18n calls
- tests: add afterEach(vi.resetAllMocks()), improve permission test assertions, add stale device fallback test
2026-03-27 14:39:19 +01:00
Etienne Lescot fed5a44b5a fix: enforce identical badge height with h-[36px] on both selectors 2026-03-27 14:25:28 +01:00
Etienne Lescot a9222c9484 fix: equalize badge heights and reduce window to 160px 2026-03-27 14:15:46 +01:00
Etienne Lescot e72851d4ef fix: use fixed positioning for HUD and device selectors to avoid h-full clipping 2026-03-27 14:09:54 +01:00
Etienne Lescot 317089d57f fix: restore flex layout to ensure HUD renders in transparent Electron window 2026-03-27 14:04:04 +01:00
Etienne Lescot 0a5e57ce76 feat: add webcam source selector with stable HUD layout
- Add useCameraDevices hook to enumerate video input devices
- Update useScreenRecorder to support webcamDeviceId selection
- Add device selector UI above HUD bar (mic + webcam, hover-to-expand)
- All selectors and HUD bar are absolute-positioned to prevent layout shifts
- Increase HUD window to 600x200px to accommodate device panels
- Add unit tests for useCameraDevices hook
2026-03-27 13:45:52 +01:00
Sid e35780bd85 Merge pull request #244 from ateendra24/fix-issue-226
feat: add fullscreen video player
2026-03-22 09:39:54 -07:00
AP Solanki eae3f119a4 feat: Implement PlaybackControls component and add i18n files for common terms in English, Spanish, and Chinese. 2026-03-22 15:33:43 +05:30
AP Solanki 5d561ff06f feat: add fullscreen video player 2026-03-22 14:17:44 +05:30
Siddharth a8bb0e88d5 improved vertical split gated behind 9:16 2026-03-21 23:15:46 -07:00
Siddharth cbbe2d7fbf movable camera pip 2026-03-21 22:04:10 -07:00
Siddharth 7aca8b8bc1 move project settings to top 2026-03-21 20:07:09 -07:00
Siddharth 4a299063c3 lang support 2026-03-21 18:18:43 -07:00
Siddharth 3d680e8521 Merge feat(export): allow re-saving exported video on dialog cancel (PR #181) 2026-03-21 17:06:36 -07:00
102 changed files with 9395 additions and 1293 deletions
+4 -2
View File
@@ -94,6 +94,8 @@ jobs:
uses: actions/upload-artifact@v4
with:
name: linux-installer
path: release/**/*.AppImage
path: |
release/**/*.AppImage
release/**/*.zsync
release/**/*.deb
retention-days: 30
+13 -23
View File
@@ -31,6 +31,19 @@ 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:browser:install
- run: npm run test:browser
build:
name: Build
runs-on: ubuntu-latest
@@ -42,26 +55,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
+4 -1
View File
@@ -29,4 +29,7 @@ release/**
# Playwright
test-results
playwright-report/
playwright-report/
# Vitest browser mode screenshots
__screenshots__/
+12 -9
View File
@@ -8,6 +8,10 @@
<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>
</p>
# <p align="center">OpenScreen</p>
@@ -21,21 +25,20 @@ Screen Studio is an awesome product and this is definitely not a 1:1 clone. Open
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 !)
<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/preview3.png" alt="OpenScreen App Preview 3" style="height: 0.2467; margin-right: 12px;" />
<img src="public/preview4.png" alt="OpenScreen App Preview 4" style="height: 0.1678; margin-right: 12px;" />
</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.
- Record specific windows or your whole screen.
- Add automatic or manual zooms (adjustable depth levels) and customize their durarion and position.
- Record microphone and system audio.
- 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.
- Customize the speed of different segments.
- Export in different aspect ratios and resolutions.
## Installation
@@ -74,9 +77,9 @@ You may need to grant screen recording permissions depending on your desktop env
System audio capture relies on Electron's [desktopCapturer](https://www.electronjs.org/docs/latest/api/desktop-capturer) and has some platform-specific quirks:
- **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).
- **macOS**: Requires macOS 13+. On macOS 14.2+ you'll be prompted to grant audio capture permission. macOS 12 and below does not support system audio (mic still works).
- **Windows**: Works out of the box.
- **Linux**: Needs PipeWire (default on Ubuntu 22.04+, Fedora 34+). Older PulseAudio-only setups may not support system audio (mic should still works).
- **Linux**: Needs PipeWire (default on Ubuntu 22.04+, Fedora 34+). Older PulseAudio-only setups may not support system audio (mic should still work).
## Built with
- Electron
+9 -8
View File
@@ -20,14 +20,15 @@
"!CONTRIBUTING.md",
"!LICENSE"
],
"extraResources": [
{
"from": "public/wallpapers",
"to": "assets/wallpapers"
}
],
"mac": {
"extraResources": [
{
"from": "public/wallpapers",
"to": "assets/wallpapers"
}
],
"publish": [{"provider": "github"}],
"mac": {
"hardenedRuntime": false,
"target": [
{
+3
View File
@@ -26,6 +26,8 @@ interface Window {
electronAPI: {
getSources: (opts: Electron.SourcesOptions) => Promise<ProcessedDesktopSource[]>;
switchToEditor: () => Promise<void>;
switchToHud: () => Promise<void>;
startNewRecording: () => Promise<{ success: boolean; error?: string }>;
openSourceSelector: () => Promise<void>;
selectSource: (source: ProcessedDesktopSource) => Promise<ProcessedDesktopSource | null>;
getSelectedSource: () => Promise<ProcessedDesktopSource | null>;
@@ -136,6 +138,7 @@ interface Window {
setMicrophoneExpanded: (expanded: boolean) => void;
setHasUnsavedChanges: (hasChanges: boolean) => void;
onRequestSaveBeforeClose: (callback: () => Promise<boolean> | boolean) => () => void;
setLocale: (locale: string) => Promise<void>;
};
}
+62
View File
@@ -0,0 +1,62 @@
// Lightweight i18n for the Electron main process.
// Imports the same JSON translation files used by the renderer.
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 commonZh from "../src/i18n/locales/zh-CN/common.json";
import dialogsZh from "../src/i18n/locales/zh-CN/dialogs.json";
type Locale = "en" | "zh-CN" | "es" | "fr";
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 },
es: { common: commonEs, dialogs: dialogsEs },
fr: { common: commonFr, dialogs: dialogsFr },
};
let currentLocale: Locale = "en";
export function setMainLocale(locale: string) {
if (locale === "en" || locale === "zh-CN" || locale === "es" || locale === "fr") {
currentLocale = locale;
}
}
export function getMainLocale(): Locale {
return currentLocale;
}
function getMessageValue(obj: unknown, dotPath: string): string | undefined {
const keys = dotPath.split(".");
let current: unknown = obj;
for (const key of keys) {
if (current == null || typeof current !== "object") return undefined;
current = (current as Record<string, unknown>)[key];
}
return typeof current === "string" ? current : undefined;
}
function interpolate(str: string, vars?: Record<string, string | number>): string {
if (!vars) return str;
return str.replace(/\{\{(\w+)\}\}/g, (_, key: string) => String(vars[key] ?? `{{${key}}}`));
}
export function mainT(
namespace: Namespace,
key: string,
vars?: Record<string, string | number>,
): string {
const value =
getMessageValue(messages[currentLocale]?.[namespace], key) ??
getMessageValue(messages.en?.[namespace], key);
if (value == null) return `${namespace}.${key}`;
return interpolate(value, vars);
}
+220 -40
View File
@@ -14,14 +14,153 @@ import {
import {
normalizeProjectMedia,
normalizeRecordingSession,
type ProjectMedia,
type RecordingSession,
type StoreRecordedSessionInput,
} from "../../src/lib/recordingSession";
import { mainT } from "../i18n";
import { RECORDINGS_DIR } from "../main";
const PROJECT_FILE_EXTENSION = "openscreen";
const SHORTCUTS_FILE = path.join(app.getPath("userData"), "shortcuts.json");
const RECORDING_SESSION_SUFFIX = ".session.json";
const ALLOWED_IMPORT_VIDEO_EXTENSIONS = new Set([".webm", ".mp4", ".mov", ".avi", ".mkv"]);
/**
* Paths explicitly approved by the user via file picker dialogs or project loads.
* These are added at runtime when the user selects files from outside the default directories.
*/
const approvedPaths = new Set<string>();
function approveFilePath(filePath: string): void {
approvedPaths.add(path.resolve(filePath));
}
function getAllowedReadDirs(): string[] {
return [RECORDINGS_DIR];
}
function isPathWithinDir(filePath: string, dirPath: string): boolean {
const resolved = path.resolve(filePath);
const resolvedDir = path.resolve(dirPath);
return resolved === resolvedDir || resolved.startsWith(resolvedDir + path.sep);
}
function isPathAllowed(filePath: string): boolean {
const resolved = path.resolve(filePath);
if (approvedPaths.has(resolved)) return true;
return getAllowedReadDirs().some((dir) => isPathWithinDir(resolved, dir));
}
function hasAllowedImportVideoExtension(filePath: string): boolean {
return ALLOWED_IMPORT_VIDEO_EXTENSIONS.has(path.extname(filePath).toLowerCase());
}
async function approveReadableVideoPath(
filePath?: string | null,
trustedDirs?: string[],
): Promise<string | null> {
const normalizedPath = normalizeVideoSourcePath(filePath);
if (!normalizedPath) {
return null;
}
if (isPathAllowed(normalizedPath)) {
return normalizedPath;
}
if (!hasAllowedImportVideoExtension(normalizedPath)) {
return null;
}
// When called with trustedDirs (e.g. from project load), only auto-approve
// paths within those directories. This prevents malicious project files from
// approving reads to arbitrary filesystem locations.
if (trustedDirs) {
const resolved = path.resolve(normalizedPath);
const withinTrusted = trustedDirs.some((dir) => isPathWithinDir(resolved, dir));
if (!withinTrusted) {
return null;
}
}
try {
const stats = await fs.stat(normalizedPath);
if (!stats.isFile()) {
return null;
}
} catch {
return null;
}
approveFilePath(normalizedPath);
return normalizedPath;
}
function resolveRecordingOutputPath(fileName: string): string {
const trimmed = fileName.trim();
if (!trimmed) {
throw new Error("Invalid recording file name");
}
const parsedPath = path.parse(trimmed);
const hasTraversalSegments = trimmed.split(/[\\/]+/).some((segment) => segment === "..");
const isNestedPath =
parsedPath.dir !== "" ||
path.isAbsolute(trimmed) ||
trimmed.includes("/") ||
trimmed.includes("\\");
if (hasTraversalSegments || isNestedPath || parsedPath.base !== trimmed) {
throw new Error("Recording file name must not contain path segments");
}
return path.join(RECORDINGS_DIR, parsedPath.base);
}
async function getApprovedProjectSession(
project: unknown,
projectFilePath?: string,
): Promise<RecordingSession | null> {
if (!project || typeof project !== "object") {
return null;
}
const rawProject = project as { media?: unknown; videoPath?: unknown };
const media: ProjectMedia | null =
normalizeProjectMedia(rawProject.media) ??
(typeof rawProject.videoPath === "string"
? {
screenVideoPath: normalizeVideoSourcePath(rawProject.videoPath) ?? rawProject.videoPath,
}
: null);
if (!media) {
return null;
}
// Only auto-approve media paths within the project's directory or RECORDINGS_DIR.
// This prevents crafted project files from approving reads to arbitrary locations.
const trustedDirs = [RECORDINGS_DIR];
if (projectFilePath) {
trustedDirs.push(path.dirname(path.resolve(projectFilePath)));
}
const screenVideoPath = await approveReadableVideoPath(media.screenVideoPath, trustedDirs);
if (!screenVideoPath) {
throw new Error("Project references an invalid or unsupported screen video path");
}
const webcamVideoPath = media.webcamVideoPath
? await approveReadableVideoPath(media.webcamVideoPath, trustedDirs)
: undefined;
if (media.webcamVideoPath && !webcamVideoPath) {
throw new Error("Project references an invalid or unsupported webcam video path");
}
return webcamVideoPath
? { screenVideoPath, webcamVideoPath, createdAt: Date.now() }
: { screenVideoPath, createdAt: Date.now() };
}
type SelectedSource = {
name: string;
@@ -120,12 +259,12 @@ async function storeRecordedSessionFiles(payload: StoreRecordedSessionInput) {
typeof payload.createdAt === "number" && Number.isFinite(payload.createdAt)
? payload.createdAt
: Date.now();
const screenVideoPath = path.join(RECORDINGS_DIR, payload.screen.fileName);
const screenVideoPath = resolveRecordingOutputPath(payload.screen.fileName);
await fs.writeFile(screenVideoPath, Buffer.from(payload.screen.videoData));
let webcamVideoPath: string | undefined;
if (payload.webcam) {
webcamVideoPath = path.join(RECORDINGS_DIR, payload.webcam.fileName);
webcamVideoPath = resolveRecordingOutputPath(payload.webcam.fileName);
await fs.writeFile(webcamVideoPath, Buffer.from(payload.webcam.videoData));
}
@@ -216,7 +355,24 @@ export function registerIpcHandlers(
getMainWindow: () => BrowserWindow | null,
getSourceSelectorWindow: () => BrowserWindow | null,
onRecordingStateChange?: (recording: boolean, sourceName: string) => void,
switchToHud?: () => void,
) {
ipcMain.handle("switch-to-hud", () => {
if (switchToHud) switchToHud();
});
ipcMain.handle("start-new-recording", async () => {
try {
setCurrentRecordingSessionState(null);
if (switchToHud) {
switchToHud();
}
return { success: true };
} catch (error) {
console.error("Failed to start new recording:", error);
return { success: false, error: String(error) };
}
});
ipcMain.handle("get-sources", async (_, opts) => {
const sources = await desktopCapturer.getSources(opts);
return sources.map((source) => ({
@@ -351,6 +507,14 @@ export function registerIpcHandlers(
return { success: false, message: "Invalid file path" };
}
if (!isPathAllowed(normalizedPath)) {
console.warn(
"[read-binary-file] Rejected path outside allowed directories:",
normalizedPath,
);
return { success: false, message: "Access denied: path outside allowed directories" };
}
const data = await fs.readFile(normalizedPath);
return {
success: true,
@@ -395,6 +559,14 @@ export function registerIpcHandlers(
return { success: true, samples: [] };
}
if (!isPathAllowed(targetVideoPath)) {
console.warn(
"[get-cursor-telemetry] Rejected path outside allowed directories:",
targetVideoPath,
);
return { success: true, samples: [] };
}
const telemetryPath = `${targetVideoPath}.cursor.json`;
try {
const content = await fs.readFile(telemetryPath, "utf-8");
@@ -472,11 +644,13 @@ export function registerIpcHandlers(
// Determine file type from extension
const isGif = fileName.toLowerCase().endsWith(".gif");
const filters = isGif
? [{ name: "GIF Image", extensions: ["gif"] }]
: [{ name: "MP4 Video", extensions: ["mp4"] }];
? [{ name: mainT("dialogs", "fileDialogs.gifImage"), extensions: ["gif"] }]
: [{ name: mainT("dialogs", "fileDialogs.mp4Video"), extensions: ["mp4"] }];
const result = await dialog.showSaveDialog({
title: isGif ? "Save Exported GIF" : "Save Exported Video",
title: isGif
? mainT("dialogs", "fileDialogs.saveGif")
: mainT("dialogs", "fileDialogs.saveVideo"),
defaultPath: path.join(app.getPath("downloads"), fileName),
filters,
properties: ["createDirectory", "showOverwriteConfirmation"],
@@ -510,11 +684,14 @@ export function registerIpcHandlers(
ipcMain.handle("open-video-file-picker", async () => {
try {
const result = await dialog.showOpenDialog({
title: "Select Video File",
title: mainT("dialogs", "fileDialogs.selectVideo"),
defaultPath: RECORDINGS_DIR,
filters: [
{ name: "Video Files", extensions: ["webm", "mp4", "mov", "avi", "mkv"] },
{ name: "All Files", extensions: ["*"] },
{
name: mainT("dialogs", "fileDialogs.videoFiles"),
extensions: ["webm", "mp4", "mov", "avi", "mkv"],
},
{ name: mainT("dialogs", "fileDialogs.allFiles"), extensions: ["*"] },
],
properties: ["openFile"],
});
@@ -523,10 +700,17 @@ export function registerIpcHandlers(
return { success: false, canceled: true };
}
const approvedPath = await approveReadableVideoPath(result.filePaths[0]);
if (!approvedPath) {
return {
success: false,
message: "Selected file is not a supported video",
};
}
currentProjectPath = null;
return {
success: true,
path: result.filePaths[0],
path: approvedPath,
};
} catch (error) {
console.error("Failed to open file picker:", error);
@@ -590,10 +774,13 @@ export function registerIpcHandlers(
: `${safeName}.${PROJECT_FILE_EXTENSION}`;
const result = await dialog.showSaveDialog({
title: "Save OpenScreen Project",
title: mainT("dialogs", "fileDialogs.saveProject"),
defaultPath: path.join(RECORDINGS_DIR, defaultName),
filters: [
{ name: "OpenScreen Project", extensions: [PROJECT_FILE_EXTENSION] },
{
name: mainT("dialogs", "fileDialogs.openscreenProject"),
extensions: [PROJECT_FILE_EXTENSION],
},
{ name: "JSON", extensions: ["json"] },
],
properties: ["createDirectory", "showOverwriteConfirmation"],
@@ -629,12 +816,15 @@ export function registerIpcHandlers(
ipcMain.handle("load-project-file", async () => {
try {
const result = await dialog.showOpenDialog({
title: "Open OpenScreen Project",
title: mainT("dialogs", "fileDialogs.openProject"),
defaultPath: RECORDINGS_DIR,
filters: [
{ name: "OpenScreen Project", extensions: [PROJECT_FILE_EXTENSION] },
{
name: mainT("dialogs", "fileDialogs.openscreenProject"),
extensions: [PROJECT_FILE_EXTENSION],
},
{ name: "JSON", extensions: ["json"] },
{ name: "All Files", extensions: ["*"] },
{ name: mainT("dialogs", "fileDialogs.allFiles"), extensions: ["*"] },
],
properties: ["openFile"],
});
@@ -646,19 +836,9 @@ export function registerIpcHandlers(
const filePath = result.filePaths[0];
const content = await fs.readFile(filePath, "utf-8");
const project = JSON.parse(content);
const session = await getApprovedProjectSession(project, filePath);
currentProjectPath = filePath;
if (project && typeof project === "object") {
const rawProject = project as { media?: unknown; videoPath?: unknown };
const media =
normalizeProjectMedia(rawProject.media) ??
(typeof rawProject.videoPath === "string"
? {
screenVideoPath:
normalizeVideoSourcePath(rawProject.videoPath) ?? rawProject.videoPath,
}
: null);
setCurrentRecordingSessionState(media ? { ...media, createdAt: Date.now() } : null);
}
setCurrentRecordingSessionState(session);
return {
success: true,
@@ -683,18 +863,8 @@ export function registerIpcHandlers(
const content = await fs.readFile(currentProjectPath, "utf-8");
const project = JSON.parse(content);
if (project && typeof project === "object") {
const rawProject = project as { media?: unknown; videoPath?: unknown };
const media =
normalizeProjectMedia(rawProject.media) ??
(typeof rawProject.videoPath === "string"
? {
screenVideoPath:
normalizeVideoSourcePath(rawProject.videoPath) ?? rawProject.videoPath,
}
: null);
setCurrentRecordingSessionState(media ? { ...media, createdAt: Date.now() } : null);
}
const session = await getApprovedProjectSession(project, currentProjectPath);
setCurrentRecordingSessionState(session);
return {
success: true,
path: currentProjectPath,
@@ -723,12 +893,22 @@ export function registerIpcHandlers(
});
ipcMain.handle("set-current-video-path", async (_, path: string) => {
const restoredSession = await loadRecordedSessionForVideoPath(path);
const normalizedPath = normalizeVideoSourcePath(path);
if (!normalizedPath || !isPathAllowed(normalizedPath)) {
return { success: false, message: "Video path has not been approved" };
}
const restoredSession = await loadRecordedSessionForVideoPath(normalizedPath);
if (restoredSession) {
// Approve all media paths from the restored session so they can be read later
approveFilePath(restoredSession.screenVideoPath);
if (restoredSession.webcamVideoPath) {
approveFilePath(restoredSession.webcamVideoPath);
}
setCurrentRecordingSessionState(restoredSession);
} else {
setCurrentRecordingSessionState({
screenVideoPath: normalizeVideoSourcePath(path) ?? path,
screenVideoPath: normalizedPath,
createdAt: Date.now(),
});
}
+36 -14
View File
@@ -12,6 +12,7 @@ import {
systemPreferences,
Tray,
} from "electron";
import { mainT, setMainLocale } from "./i18n";
import { registerIpcHandlers } from "./ipc/handlers";
import { createEditorWindow, createHudOverlayWindow, createSourceSelectorWindow } from "./windows";
@@ -130,20 +131,20 @@ function setupApplicationMenu() {
template.push(
{
label: "File",
label: mainT("common", "actions.file") || "File",
submenu: [
{
label: "Load Project…",
label: mainT("dialogs", "unsavedChanges.loadProject") || "Load Project…",
accelerator: "CmdOrCtrl+O",
click: () => sendEditorMenuAction("menu-load-project"),
},
{
label: "Save Project…",
label: mainT("dialogs", "unsavedChanges.saveProject") || "Save Project…",
accelerator: "CmdOrCtrl+S",
click: () => sendEditorMenuAction("menu-save-project"),
},
{
label: "Save Project As…",
label: mainT("dialogs", "unsavedChanges.saveProjectAs") || "Save Project As…",
accelerator: "CmdOrCtrl+Shift+S",
click: () => sendEditorMenuAction("menu-save-project-as"),
},
@@ -151,7 +152,7 @@ function setupApplicationMenu() {
],
},
{
label: "Edit",
label: mainT("common", "actions.edit") || "Edit",
submenu: [
{ role: "undo" },
{ role: "redo" },
@@ -163,7 +164,7 @@ function setupApplicationMenu() {
],
},
{
label: "View",
label: mainT("common", "actions.view") || "View",
submenu: [
{ role: "reload" },
{ role: "forceReload" },
@@ -177,7 +178,7 @@ function setupApplicationMenu() {
],
},
{
label: "Window",
label: mainT("common", "actions.window") || "Window",
submenu: isMac
? [{ role: "minimize" }, { role: "zoom" }, { type: "separator" }, { role: "front" }]
: [{ role: "minimize" }, { role: "close" }],
@@ -215,7 +216,7 @@ function updateTrayMenu(recording: boolean = false) {
const menuTemplate = recording
? [
{
label: "Stop Recording",
label: mainT("common", "actions.stopRecording") || "Stop Recording",
click: () => {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send("stop-recording-from-tray");
@@ -225,13 +226,13 @@ function updateTrayMenu(recording: boolean = false) {
]
: [
{
label: "Open",
label: mainT("common", "actions.open") || "Open",
click: () => {
showMainWindow();
},
},
{
label: "Quit",
label: mainT("common", "actions.quit") || "Quit",
click: () => {
app.quit();
},
@@ -281,12 +282,16 @@ function createEditorWindowWrapper() {
const choice = dialog.showMessageBoxSync(mainWindow!, {
type: "warning",
buttons: ["Save & Close", "Discard & Close", "Cancel"],
buttons: [
mainT("dialogs", "unsavedChanges.saveAndClose"),
mainT("dialogs", "unsavedChanges.discardAndClose"),
mainT("common", "actions.cancel"),
],
defaultId: 0,
cancelId: 2,
title: "Unsaved Changes",
message: "You have unsaved changes.",
detail: "Do you want to save your project before closing?",
title: mainT("dialogs", "unsavedChanges.title"),
message: mainT("dialogs", "unsavedChanges.message"),
detail: mainT("dialogs", "unsavedChanges.detail"),
});
const windowToClose = mainWindow;
@@ -354,12 +359,28 @@ app.whenReady().then(async () => {
ipcMain.on("hud-overlay-close", () => {
app.quit();
});
ipcMain.handle("set-locale", (_, locale: string) => {
setMainLocale(locale);
setupApplicationMenu();
updateTrayMenu();
});
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,
@@ -373,6 +394,7 @@ app.whenReady().then(async () => {
showMainWindow();
}
},
switchToHudWrapper,
);
createWindow();
});
+9
View File
@@ -18,6 +18,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");
},
@@ -115,6 +121,9 @@ contextBridge.exposeInMainWorld("electronAPI", {
saveShortcuts: (shortcuts: unknown) => {
return ipcRenderer.invoke("save-shortcuts", shortcuts);
},
setLocale: (locale: string) => {
return ipcRenderer.invoke("set-locale", locale);
},
setMicrophoneExpanded: (expanded: boolean) => {
ipcRenderer.send("hud:setMicrophoneExpanded", expanded);
},
+6 -6
View File
@@ -21,8 +21,8 @@ export function createHudOverlayWindow(): BrowserWindow {
const primaryDisplay = screen.getPrimaryDisplay();
const { workArea } = primaryDisplay;
const windowWidth = 500;
const windowHeight = 155;
const windowWidth = 600;
const windowHeight = 160;
const x = Math.floor(workArea.x + (workArea.width - windowWidth) / 2);
const y = Math.floor(workArea.y + workArea.height - windowHeight - 5);
@@ -30,10 +30,10 @@ export function createHudOverlayWindow(): BrowserWindow {
const win = new BrowserWindow({
width: windowWidth,
height: windowHeight,
minWidth: 500,
maxWidth: 500,
minHeight: 155,
maxHeight: 155,
minWidth: 600,
maxWidth: 600,
minHeight: 160,
maxHeight: 160,
x: x,
y: y,
frame: false,
+2343 -203
View File
File diff suppressed because it is too large Load Diff
+16 -3
View File
@@ -1,26 +1,33 @@
{
"name": "openscreen",
"private": true,
"version": "1.2.0",
"version": "1.3.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",
"lint": "biome check .",
"lint:fix": "biome check --write .",
"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:linux": "tsc && vite build && electron-builder --linux AppImage deb",
"test": "vitest --run",
"test:watch": "vitest",
"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",
"prepare": "husky"
},
@@ -68,12 +75,17 @@
},
"devDependencies": {
"@biomejs/biome": "^2.3.13",
"@playwright/test": "^1.58.2",
"@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",
"@vitest/browser": "^4.0.16",
"@vitest/browser-playwright": "^4.0.16",
"autoprefixer": "^10.4.21",
"electron": "^39.2.7",
"electron-builder": "^26.7.0",
@@ -81,6 +93,7 @@
"electron-rebuild": "^3.2.9",
"fast-check": "^4.5.2",
"husky": "^9.1.7",
"jsdom": "^29.0.1",
"lint-staged": "^16.3.2",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.18",
+82
View File
@@ -0,0 +1,82 @@
#!/usr/bin/env node
/**
* Validates that all locale translation files have identical key structures.
* Compares zh-CN and es against the en baseline for every namespace.
*
* Usage: node scripts/i18n-check.mjs
*/
import fs from "node:fs";
import path from "node:path";
const LOCALES_DIR = path.resolve("src/i18n/locales");
const BASE_LOCALE = "en";
const COMPARE_LOCALES = ["zh-CN", "es", "tr"];
function getKeys(obj, prefix = "") {
const keys = [];
for (const [key, value] of Object.entries(obj)) {
const fullKey = prefix ? `${prefix}.${key}` : key;
if (value && typeof value === "object" && !Array.isArray(value)) {
keys.push(...getKeys(value, fullKey));
} else {
keys.push(fullKey);
}
}
return keys.sort();
}
let hasErrors = false;
const baseDir = path.join(LOCALES_DIR, BASE_LOCALE);
const namespaces = fs
.readdirSync(baseDir)
.filter((f) => f.endsWith(".json"))
.map((f) => f.replace(".json", ""));
for (const namespace of namespaces) {
const basePath = path.join(baseDir, `${namespace}.json`);
const baseData = JSON.parse(fs.readFileSync(basePath, "utf-8"));
const baseKeys = getKeys(baseData);
for (const locale of COMPARE_LOCALES) {
const localePath = path.join(LOCALES_DIR, locale, `${namespace}.json`);
if (!fs.existsSync(localePath)) {
console.error(`MISSING: ${locale}/${namespace}.json does not exist`);
hasErrors = true;
continue;
}
const localeData = JSON.parse(fs.readFileSync(localePath, "utf-8"));
const localeKeys = getKeys(localeData);
const missing = baseKeys.filter((k) => !localeKeys.includes(k));
const extra = localeKeys.filter((k) => !baseKeys.includes(k));
if (missing.length > 0) {
console.error(`MISSING in ${locale}/${namespace}.json:`);
for (const key of missing) {
console.error(` - ${key}`);
}
hasErrors = true;
}
if (extra.length > 0) {
console.error(`EXTRA in ${locale}/${namespace}.json:`);
for (const key of extra) {
console.error(` + ${key}`);
}
hasErrors = true;
}
}
}
if (hasErrors) {
console.error("\ni18n check FAILED — translation files are out of sync.");
process.exit(1);
} else {
console.log(
`i18n check PASSED — all ${COMPARE_LOCALES.length} locales match ${BASE_LOCALE} across ${namespaces.length} namespaces.`,
);
}
+352 -158
View File
@@ -1,10 +1,11 @@
import { ChevronDown } from "lucide-react";
import { ChevronDown, Languages } from "lucide-react";
import { useEffect, useState } from "react";
import { BsRecordCircle } from "react-icons/bs";
import { BsPauseCircle, BsPlayCircle, BsRecordCircle } from "react-icons/bs";
import { FaRegStopCircle } from "react-icons/fa";
import { FaFolderOpen } from "react-icons/fa6";
import { FiMinus, FiX } from "react-icons/fi";
import {
MdCancel,
MdMic,
MdMicOff,
MdMonitor,
@@ -16,7 +17,12 @@ import {
MdVolumeUp,
} from "react-icons/md";
import { RxDragHandleDots2 } from "react-icons/rx";
import { useI18n, useScopedT } from "@/contexts/I18nContext";
import { type Locale, SUPPORTED_LOCALES } from "@/i18n/config";
import { getLocaleName } from "@/i18n/loader";
import { isMac as getIsMac } from "@/utils/platformUtils";
import { useAudioLevelMeter } from "../../hooks/useAudioLevelMeter";
import { useCameraDevices } from "../../hooks/useCameraDevices";
import { useMicrophoneDevices } from "../../hooks/useMicrophoneDevices";
import { useScreenRecorder } from "../../hooks/useScreenRecorder";
import { requestCameraAccess } from "../../lib/requestCameraAccess";
@@ -36,8 +42,11 @@ const ICON_CONFIG = {
micOff: { icon: MdMicOff, size: ICON_SIZE },
webcamOn: { icon: MdVideocam, size: ICON_SIZE },
webcamOff: { icon: MdVideocamOff, size: ICON_SIZE },
pause: { icon: BsPauseCircle, size: ICON_SIZE },
resume: { icon: BsPlayCircle, size: ICON_SIZE },
stop: { icon: FaRegStopCircle, size: ICON_SIZE },
restart: { icon: MdRestartAlt, size: ICON_SIZE },
cancel: { icon: MdCancel, size: ICON_SIZE },
record: { icon: BsRecordCircle, size: ICON_SIZE },
videoFile: { icon: MdVideoFile, size: ICON_SIZE },
folder: { icon: FaFolderOpen, size: ICON_SIZE },
@@ -62,10 +71,22 @@ const windowBtnClasses =
"flex items-center justify-center p-2 rounded-full transition-all duration-150 cursor-pointer opacity-50 hover:opacity-90 hover:bg-white/[0.08]";
export function LaunchWindow() {
const t = useScopedT("launch");
const { locale, setLocale } = useI18n();
const [isMac, setIsMac] = useState(false);
useEffect(() => {
getIsMac().then(setIsMac);
}, []);
const {
recording,
paused,
elapsedSeconds,
toggleRecording,
togglePaused,
restartRecording,
cancelRecording,
microphoneEnabled,
setMicrophoneEnabled,
microphoneDeviceId,
@@ -74,42 +95,62 @@ export function LaunchWindow() {
setSystemAudioEnabled,
webcamEnabled,
setWebcamEnabled,
webcamDeviceId,
setWebcamDeviceId,
} = useScreenRecorder();
const [recordingStart, setRecordingStart] = useState<number | null>(null);
const [elapsed, setElapsed] = useState(0);
const showMicControls = microphoneEnabled && !recording;
const { devices, selectedDeviceId, setSelectedDeviceId } =
useMicrophoneDevices(microphoneEnabled);
const showWebcamControls = webcamEnabled && !recording;
const [isMicHovered, setIsMicHovered] = useState(false);
const [isMicFocused, setIsMicFocused] = useState(false);
const micExpanded = isMicHovered || isMicFocused;
const [isWebcamHovered, setIsWebcamHovered] = useState(false);
const [isWebcamFocused, setIsWebcamFocused] = useState(false);
const webcamExpanded = isWebcamHovered || isWebcamFocused;
const {
devices: micDevices,
selectedDeviceId: selectedMicId,
setSelectedDeviceId: setSelectedMicId,
} = useMicrophoneDevices(microphoneEnabled);
const {
devices: cameraDevices,
selectedDeviceId: selectedCameraId,
setSelectedDeviceId: setSelectedCameraId,
isLoading: isCameraDevicesLoading,
error: cameraDevicesError,
} = useCameraDevices(webcamEnabled);
const selectedMicLabel =
micDevices.find((d) => d.deviceId === (microphoneDeviceId || selectedMicId))?.label ||
t("audio.defaultMicrophone");
const selectedCameraLabel = isCameraDevicesLoading
? t("webcam.searching")
: cameraDevicesError
? t("webcam.unavailable")
: cameraDevices.length === 0
? t("webcam.noneFound")
: cameraDevices.find((d) => d.deviceId === (webcamDeviceId || selectedCameraId))?.label ||
t("webcam.defaultCamera");
const { level } = useAudioLevelMeter({
enabled: showMicControls,
deviceId: microphoneDeviceId,
});
useEffect(() => {
if (selectedDeviceId && selectedDeviceId !== "default") {
setMicrophoneDeviceId(selectedDeviceId);
if (selectedMicId && selectedMicId !== "default") {
setMicrophoneDeviceId(selectedMicId);
}
}, [selectedDeviceId, setMicrophoneDeviceId]);
}, [selectedMicId, setMicrophoneDeviceId]);
useEffect(() => {
let timer: NodeJS.Timeout | null = null;
if (recording) {
if (!recordingStart) setRecordingStart(Date.now());
timer = setInterval(() => {
if (recordingStart) {
setElapsed(Math.floor((Date.now() - recordingStart) / 1000));
}
}, 1000);
} else {
setRecordingStart(null);
setElapsed(0);
if (timer) clearInterval(timer);
if (selectedCameraId) {
setWebcamDeviceId(selectedCameraId);
}
return () => {
if (timer) clearInterval(timer);
};
}, [recording, recordingStart]);
}, [selectedCameraId, setWebcamDeviceId]);
useEffect(() => {
if (!import.meta.env.DEV) {
@@ -187,156 +228,309 @@ export function LaunchWindow() {
};
return (
<div className="w-full h-full flex items-end justify-center bg-transparent">
<div className={`flex flex-col items-center gap-2 mx-auto ${styles.electronDrag}`}>
{/* Mic controls panel */}
{showMicControls && (
<div
className={`flex items-center gap-2 px-4 py-2 bg-gradient-to-br from-[rgba(28,28,36,0.97)] to-[rgba(18,18,26,0.96)] backdrop-blur-[16px] backdrop-saturate-[140%] border border-[rgba(80,80,120,0.25)] rounded-2xl shadow-mic-panel animate-mic-panel-in ${styles.electronNoDrag}`}
>
<div className="relative flex-1" style={{ maxWidth: "70%" }}>
<select
value={microphoneDeviceId || selectedDeviceId}
onChange={(e) => {
setSelectedDeviceId(e.target.value);
setMicrophoneDeviceId(e.target.value);
}}
className="w-full appearance-none bg-white/10 text-white text-xs rounded-full pl-3 pr-7 py-2 border border-white/20 outline-none truncate"
>
{devices.map((device) => (
<option key={device.deviceId} value={device.deviceId}>
{device.label}
</option>
))}
</select>
<ChevronDown
size={14}
className="absolute right-2 top-1/2 -translate-y-1/2 text-white/60 pointer-events-none"
<div className={`w-screen h-screen bg-transparent ${styles.electronDrag}`}>
{/* Language switcher — top-left, beside traffic lights */}
<div
className={`fixed top-2 flex items-center gap-1 px-2 py-1 rounded-md text-white/50 hover:text-white/90 hover:bg-white/10 transition-all duration-150 ${isMac ? "left-[72px]" : "left-2"} ${styles.electronNoDrag}`}
>
<Languages size={14} />
<select
value={locale}
onChange={(e) => setLocale(e.target.value as Locale)}
className="bg-transparent text-[11px] font-medium outline-none cursor-pointer appearance-none pr-1"
style={{ color: "inherit" }}
>
{SUPPORTED_LOCALES.map((loc) => (
<option key={loc} value={loc} className="bg-[#1c1c24] text-white">
{getLocaleName(loc)}
</option>
))}
</select>
</div>
{/* Device selectors — fixed above HUD bar, viewport-relative, never clipped */}
{(showMicControls || showWebcamControls) && (
<div
className={`fixed bottom-[60px] left-1/2 -translate-x-1/2 flex items-center gap-2 animate-mic-panel-in ${styles.electronNoDrag}`}
>
{/* Mic selector */}
{showMicControls && (
<div
className={`flex items-center gap-2 px-3 py-1.5 h-[36px] bg-gradient-to-br from-[rgba(28,28,36,0.97)] to-[rgba(18,18,26,0.96)] backdrop-blur-[24px] border border-white/10 rounded-xl shadow-2xl transition-all duration-300 overflow-hidden ${!micExpanded ? "opacity-60 grayscale-[0.5]" : "opacity-100"}`}
onMouseEnter={() => setIsMicHovered(true)}
onMouseLeave={() => setIsMicHovered(false)}
onFocus={() => setIsMicFocused(true)}
onBlur={() => setIsMicFocused(false)}
style={{ width: micExpanded ? "240px" : "140px", transition: "width 300ms ease" }}
>
<div className="relative flex-1 min-w-0">
{!micExpanded && (
<div className="text-white/60 text-[10px] font-medium truncate">
{selectedMicLabel}
</div>
)}
<select
value={microphoneDeviceId || selectedMicId}
onChange={(e) => {
setSelectedMicId(e.target.value);
setMicrophoneDeviceId(e.target.value);
}}
className={`w-full appearance-none bg-white/5 text-white text-[11px] rounded-lg pl-2 pr-6 py-1 border border-white/10 outline-none hover:bg-white/10 transition-colors cursor-pointer ${!micExpanded ? "sr-only" : ""}`}
>
{micDevices.map((device) => (
<option key={device.deviceId} value={device.deviceId} className="bg-[#1c1c24]">
{device.label}
</option>
))}
</select>
{micExpanded && (
<ChevronDown
size={12}
className="absolute right-1.5 top-1/2 -translate-y-1/2 text-white/40 pointer-events-none"
/>
)}
</div>
<AudioLevelMeter
level={level}
className={`${micExpanded ? "w-16" : "w-8"} h-2 transition-all duration-300`}
/>
</div>
<AudioLevelMeter level={level} className="w-24 h-4" />
</div>
)}
{/* Main pill bar */}
<div className="flex items-center gap-1.5 px-2 py-1.5 isolate rounded-full shadow-hud-bar bg-gradient-to-br from-[rgba(28,28,36,0.97)] to-[rgba(18,18,26,0.96)] backdrop-blur-[16px] backdrop-saturate-[140%] border border-[rgba(80,80,120,0.25)]">
{/* Drag handle */}
<div className={`flex items-center px-1 ${styles.electronDrag}`}>
{getIcon("drag", "text-white/30")}
</div>
{/* Source selector */}
<button
className={`${hudGroupClasses} p-2 ${styles.electronNoDrag}`}
onClick={openSourceSelector}
disabled={recording}
title={selectedSource}
>
{getIcon("monitor", "text-white/80")}
<span className="text-white/70 text-[11px] max-w-[72px] truncate">
{selectedSource}
</span>
</button>
{/* Audio controls group */}
<div className={`${hudGroupClasses} ${styles.electronNoDrag}`}>
<button
className={`${hudIconBtnClasses} ${systemAudioEnabled ? "drop-shadow-[0_0_4px_rgba(74,222,128,0.4)]" : ""}`}
onClick={() => !recording && setSystemAudioEnabled(!systemAudioEnabled)}
disabled={recording}
title={systemAudioEnabled ? "Disable system audio" : "Enable system audio"}
>
{systemAudioEnabled
? getIcon("volumeOn", "text-green-400")
: getIcon("volumeOff", "text-white/40")}
</button>
<button
className={`${hudIconBtnClasses} ${microphoneEnabled ? "drop-shadow-[0_0_4px_rgba(74,222,128,0.4)]" : ""}`}
onClick={toggleMicrophone}
disabled={recording}
title={microphoneEnabled ? "Disable microphone" : "Enable microphone"}
>
{microphoneEnabled
? getIcon("micOn", "text-green-400")
: getIcon("micOff", "text-white/40")}
</button>
<button
className={`${hudIconBtnClasses} ${webcamEnabled ? "drop-shadow-[0_0_4px_rgba(74,222,128,0.4)]" : ""}`}
onClick={async () => {
await setWebcamEnabled(!webcamEnabled);
}}
title={webcamEnabled ? "Disable webcam" : "Enable webcam"}
>
{webcamEnabled
? getIcon("webcamOn", "text-green-400")
: getIcon("webcamOff", "text-white/40")}
</button>
</div>
{/* Record/Stop group */}
<button
className={`flex items-center gap-0.5 rounded-full p-2 transition-colors duration-150 ${styles.electronNoDrag} ${
recording ? "animate-record-pulse bg-red-500/10" : "bg-white/5 hover:bg-white/[0.08]"
}`}
onClick={hasSelectedSource ? toggleRecording : openSourceSelector}
disabled={!hasSelectedSource && !recording}
style={{ flex: "0 0 auto" }}
>
{recording ? (
<>
{getIcon("stop", "text-red-400")}
<span className="text-red-400 text-xs font-semibold tabular-nums">
{formatTimePadded(elapsed)}
</span>
</>
) : (
getIcon("record", hasSelectedSource ? "text-white/80" : "text-white/30")
)}
</button>
{/* Restart recording */}
{recording && (
<Tooltip content="Restart recording">
<button
className={`${hudIconBtnClasses} ${styles.electronNoDrag}`}
onClick={restartRecording}
>
{getIcon("restart", "text-white/60")}
</button>
</Tooltip>
)}
{/* Open video file */}
<Tooltip content="Open video file">
{/* Webcam selector */}
{showWebcamControls && (
<div
className={`flex items-center gap-2 px-3 py-1.5 h-[36px] bg-gradient-to-br from-[rgba(28,28,36,0.97)] to-[rgba(18,18,26,0.96)] backdrop-blur-[24px] border border-white/10 rounded-xl shadow-2xl transition-all duration-300 overflow-hidden ${!webcamExpanded ? "opacity-60 grayscale-[0.5]" : "opacity-100"}`}
onMouseEnter={() => setIsWebcamHovered(true)}
onMouseLeave={() => setIsWebcamHovered(false)}
onFocus={() => setIsWebcamFocused(true)}
onBlur={() => setIsWebcamFocused(false)}
style={{ width: webcamExpanded ? "240px" : "140px", transition: "width 300ms ease" }}
>
<div className="relative flex-1 min-w-0">
{!webcamExpanded && (
<div className="text-white/60 text-[10px] font-medium truncate">
{selectedCameraLabel}
</div>
)}
{webcamExpanded &&
(isCameraDevicesLoading ? (
<span className="text-white/40 text-[10px] italic">
{t("webcam.searching")}
</span>
) : cameraDevicesError ? (
<span className="text-white/40 text-[10px] italic">
{t("webcam.unavailable")}
</span>
) : cameraDevices.length === 0 ? (
<span className="text-white/40 text-[10px] italic">
{t("webcam.noneFound")}
</span>
) : (
<>
<select
value={webcamDeviceId || selectedCameraId}
onChange={(e) => {
setSelectedCameraId(e.target.value);
setWebcamDeviceId(e.target.value);
}}
className="w-full appearance-none bg-white/5 text-white text-[11px] rounded-lg pl-2 pr-6 py-1 border border-white/10 outline-none hover:bg-white/10 transition-colors cursor-pointer"
>
{cameraDevices.map((device) => (
<option
key={device.deviceId}
value={device.deviceId}
className="bg-[#1c1c24]"
>
{device.label}
</option>
))}
</select>
<ChevronDown
size={12}
className="absolute right-1.5 top-1/2 -translate-y-1/2 text-white/40 pointer-events-none"
/>
</>
))}
{(!webcamExpanded || cameraDevices.length === 0) && (
<select
value={webcamDeviceId || selectedCameraId}
onChange={(e) => {
setSelectedCameraId(e.target.value);
setWebcamDeviceId(e.target.value);
}}
className="sr-only"
>
{cameraDevices.map((device) => (
<option key={device.deviceId} value={device.deviceId}>
{device.label}
</option>
))}
</select>
)}
</div>
</div>
)}
</div>
)}
{/* HUD bar — fixed at bottom center, viewport-relative, never moves */}
<div
className={`fixed bottom-4 left-1/2 -translate-x-1/2 flex items-center gap-1.5 px-2 py-1.5 rounded-full shadow-hud-bar bg-gradient-to-br from-[rgba(28,28,36,0.97)] to-[rgba(18,18,26,0.96)] backdrop-blur-[16px] backdrop-saturate-[140%] border border-[rgba(80,80,120,0.25)]`}
>
{/* Drag handle */}
<div className={`flex items-center px-1 ${styles.electronDrag}`}>
{getIcon("drag", "text-white/30")}
</div>
{/* Source selector */}
<button
className={`${hudGroupClasses} p-2 ${styles.electronNoDrag}`}
onClick={openSourceSelector}
disabled={recording}
title={selectedSource}
>
{getIcon("monitor", "text-white/80")}
<span className="text-white/70 text-[11px] max-w-[72px] truncate">{selectedSource}</span>
</button>
{/* Audio controls group */}
<div className={`${hudGroupClasses} ${styles.electronNoDrag}`}>
<button
className={`${hudIconBtnClasses} ${systemAudioEnabled ? "drop-shadow-[0_0_4px_rgba(74,222,128,0.4)]" : ""}`}
onClick={() => !recording && setSystemAudioEnabled(!systemAudioEnabled)}
disabled={recording}
title={
systemAudioEnabled ? t("audio.disableSystemAudio") : t("audio.enableSystemAudio")
}
>
{systemAudioEnabled
? getIcon("volumeOn", "text-green-400")
: getIcon("volumeOff", "text-white/40")}
</button>
<button
className={`${hudIconBtnClasses} ${microphoneEnabled ? "drop-shadow-[0_0_4px_rgba(74,222,128,0.4)]" : ""}`}
onClick={toggleMicrophone}
disabled={recording}
title={microphoneEnabled ? t("audio.disableMicrophone") : t("audio.enableMicrophone")}
>
{microphoneEnabled
? getIcon("micOn", "text-green-400")
: getIcon("micOff", "text-white/40")}
</button>
<button
className={`${hudIconBtnClasses} ${webcamEnabled ? "drop-shadow-[0_0_4px_rgba(74,222,128,0.4)]" : ""}`}
onClick={async () => {
await setWebcamEnabled(!webcamEnabled);
}}
title={webcamEnabled ? t("webcam.disableWebcam") : t("webcam.enableWebcam")}
>
{webcamEnabled
? getIcon("webcamOn", "text-green-400")
: getIcon("webcamOff", "text-white/40")}
</button>
</div>
{/* Record/Stop group */}
<button
className={`flex items-center gap-0.5 rounded-full p-2 transition-colors duration-150 ${styles.electronNoDrag} ${
recording
? paused
? "bg-amber-500/10 hover:bg-amber-500/15"
: "animate-record-pulse bg-red-500/10"
: "bg-white/5 hover:bg-white/[0.08]"
}`}
onClick={toggleRecording}
disabled={!hasSelectedSource && !recording}
style={{ flex: "0 0 auto" }}
>
{recording ? (
<>
{getIcon("stop", paused ? "text-amber-400" : "text-red-400")}
<span
className={`${paused ? "text-amber-400" : "text-red-400"} text-xs font-semibold tabular-nums`}
>
{formatTimePadded(elapsedSeconds)}
</span>
</>
) : (
getIcon("record", hasSelectedSource ? "text-white/80" : "text-white/30")
)}
</button>
{recording && (
<Tooltip content={paused ? t("tooltips.resumeRecording") : t("tooltips.pauseRecording")}>
<button
className={`${hudIconBtnClasses} ${styles.electronNoDrag}`}
onClick={openVideoFile}
disabled={recording}
onClick={togglePaused}
>
{getIcon("videoFile", "text-white/60")}
{getIcon(paused ? "resume" : "pause", paused ? "text-amber-400" : "text-white/60")}
</button>
</Tooltip>
)}
{/* Open project */}
<Tooltip content="Open project">
{/* Restart recording */}
{recording && (
<Tooltip content={t("tooltips.restartRecording")}>
<button
className={`${hudIconBtnClasses} ${styles.electronNoDrag}`}
onClick={openProjectFile}
disabled={recording}
onClick={restartRecording}
>
{getIcon("folder", "text-white/60")}
{getIcon("restart", "text-white/60")}
</button>
</Tooltip>
)}
{/* Window controls */}
<div className={`flex items-center gap-0.5 ${styles.electronNoDrag}`}>
<button className={windowBtnClasses} title="Hide HUD" onClick={sendHudOverlayHide}>
{getIcon("minimize", "text-white")}
{/* Cancel recording */}
{recording && (
<Tooltip content={t("tooltips.cancelRecording")}>
<button
className={`${hudIconBtnClasses} ${styles.electronNoDrag}`}
onClick={cancelRecording}
>
{getIcon("cancel", "text-white/60")}
</button>
<button className={windowBtnClasses} title="Close App" onClick={sendHudOverlayClose}>
{getIcon("close", "text-white")}
</button>
</div>
</Tooltip>
)}
{/* Open video file */}
<Tooltip content={t("tooltips.openVideoFile")}>
<button
className={`${hudIconBtnClasses} ${styles.electronNoDrag}`}
onClick={openVideoFile}
disabled={recording}
>
{getIcon("videoFile", "text-white/60")}
</button>
</Tooltip>
{/* Open project */}
<Tooltip content={t("tooltips.openProject")}>
<button
className={`${hudIconBtnClasses} ${styles.electronNoDrag}`}
onClick={openProjectFile}
disabled={recording}
>
{getIcon("folder", "text-white/60")}
</button>
</Tooltip>
{/* Window controls */}
<div className={`flex items-center gap-0.5 ${styles.electronNoDrag}`}>
<button
className={windowBtnClasses}
title={t("tooltips.hideHUD")}
onClick={sendHudOverlayHide}
>
{getIcon("minimize", "text-white")}
</button>
<button
className={windowBtnClasses}
title={t("tooltips.closeApp")}
onClick={sendHudOverlayClose}
>
{getIcon("close", "text-white")}
</button>
</div>
</div>
</div>
+8 -5
View File
@@ -1,5 +1,6 @@
import { useEffect, useState } from "react";
import { MdCheck } from "react-icons/md";
import { useScopedT } from "@/contexts/I18nContext";
import { Button } from "../ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs";
import styles from "./SourceSelector.module.css";
@@ -13,6 +14,8 @@ interface DesktopSource {
}
export function SourceSelector() {
const t = useScopedT("launch");
const tc = useScopedT("common");
const [sources, setSources] = useState<DesktopSource[]>([]);
const [selectedSource, setSelectedSource] = useState<DesktopSource | null>(null);
const [loading, setLoading] = useState(true);
@@ -63,7 +66,7 @@ export function SourceSelector() {
>
<div className="text-center">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-[#34B27B] mx-auto mb-2" />
<p className="text-xs text-zinc-400">Loading sources...</p>
<p className="text-xs text-zinc-400">{t("sourceSelector.loading")}</p>
</div>
</div>
);
@@ -113,13 +116,13 @@ export function SourceSelector() {
value="screens"
className="data-[state=active]:bg-white/15 data-[state=active]:text-white text-zinc-400 rounded-full text-xs py-1 transition-all"
>
Screens ({screenSources.length})
{t("sourceSelector.screens", { count: String(screenSources.length) })}
</TabsTrigger>
<TabsTrigger
value="windows"
className="data-[state=active]:bg-white/15 data-[state=active]:text-white text-zinc-400 rounded-full text-xs py-1 transition-all"
>
Windows ({windowSources.length})
{t("sourceSelector.windows", { count: String(windowSources.length) })}
</TabsTrigger>
</TabsList>
<div className="flex-1 min-h-0">
@@ -146,14 +149,14 @@ export function SourceSelector() {
onClick={() => window.close()}
className="px-5 py-1 text-xs text-zinc-400 hover:text-white hover:bg-white/5 rounded-full"
>
Cancel
{tc("actions.cancel")}
</Button>
<Button
onClick={handleShare}
disabled={!selectedSource}
className="px-5 py-1 text-xs bg-[#34B27B] text-white hover:bg-[#34B27B]/80 disabled:opacity-30 disabled:bg-zinc-700 rounded-full"
>
Share
{tc("actions.share")}
</Button>
</div>
</div>
@@ -12,6 +12,7 @@ import {
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useScopedT } from "@/contexts/I18nContext";
import {
addCustomFont,
type CustomFont,
@@ -25,6 +26,8 @@ interface AddCustomFontDialogProps {
}
export function AddCustomFontDialog({ onFontAdded }: AddCustomFontDialogProps) {
const t = useScopedT("settings");
const tc = useScopedT("common");
const [open, setOpen] = useState(false);
const [importUrl, setImportUrl] = useState("");
const [fontName, setFontName] = useState("");
@@ -45,17 +48,17 @@ export function AddCustomFontDialog({ onFontAdded }: AddCustomFontDialogProps) {
const handleAdd = async () => {
// Validate inputs
if (!importUrl.trim()) {
toast.error("Please enter a Google Fonts import URL");
toast.error(t("customFont.errorEmptyUrl"));
return;
}
if (!isValidGoogleFontsUrl(importUrl)) {
toast.error("Please enter a valid Google Fonts URL");
toast.error(t("customFont.errorInvalidUrl"));
return;
}
if (!fontName.trim()) {
toast.error("Please enter a font name");
toast.error(t("customFont.errorEmptyName"));
return;
}
@@ -65,7 +68,7 @@ export function AddCustomFontDialog({ onFontAdded }: AddCustomFontDialogProps) {
// Extract font family from URL
const fontFamily = parseFontFamilyFromImport(importUrl);
if (!fontFamily) {
toast.error("Could not extract font family from URL");
toast.error(t("customFont.errorExtractFailed"));
setLoading(false);
return;
}
@@ -86,7 +89,7 @@ export function AddCustomFontDialog({ onFontAdded }: AddCustomFontDialogProps) {
onFontAdded(newFont);
}
toast.success(`Font "${fontName}" added successfully`);
toast.success(t("customFont.successMessage", { fontName }));
// Reset and close
setImportUrl("");
@@ -95,10 +98,10 @@ export function AddCustomFontDialog({ onFontAdded }: AddCustomFontDialogProps) {
} catch (error) {
console.error("Failed to add custom font:", error);
const errorMessage = error instanceof Error ? error.message : "Failed to load font";
toast.error("Failed to add font", {
toast.error(t("customFont.failedToAdd"), {
description: errorMessage.includes("timeout")
? "Font took too long to load. Please check the URL and try again."
: "The font could not be loaded. Please verify the Google Fonts URL is correct.",
? t("customFont.errorTimeout")
: t("customFont.errorLoadFailed"),
});
} finally {
setLoading(false);
@@ -114,12 +117,12 @@ export function AddCustomFontDialog({ onFontAdded }: AddCustomFontDialogProps) {
className="w-full bg-white/5 border-white/10 text-slate-200 hover:bg-white/10 h-9 text-xs"
>
<Plus className="w-3 h-3 mr-1" />
Add Google Font
{t("customFont.dialogTitle")}
</Button>
</DialogTrigger>
<DialogContent className="bg-[#1a1a1c] border-white/10 text-slate-200">
<DialogHeader>
<DialogTitle>Add Google Font</DialogTitle>
<DialogTitle>{t("customFont.dialogTitle")}</DialogTitle>
<DialogDescription className="text-slate-400">
Add a custom font from Google Fonts to use in your annotations.
</DialogDescription>
@@ -128,34 +131,30 @@ export function AddCustomFontDialog({ onFontAdded }: AddCustomFontDialogProps) {
<div className="space-y-4 mt-4">
<div className="space-y-2">
<Label htmlFor="import-url" className="text-slate-200">
Google Fonts Import URL
{t("customFont.urlLabel")}
</Label>
<Input
id="import-url"
placeholder="https://fonts.googleapis.com/css2?family=Roboto&display=swap"
placeholder={t("customFont.urlPlaceholder")}
value={importUrl}
onChange={(e) => handleImportUrlChange(e.target.value)}
className="bg-white/5 border-white/10 text-slate-200"
/>
<p className="text-xs text-slate-400">
Get this from Google Fonts: Select a font → Click "Get font" → Copy the @import URL
</p>
<p className="text-xs text-slate-400">{t("customFont.urlHelp")}</p>
</div>
<div className="space-y-2">
<Label htmlFor="font-name" className="text-slate-200">
Display Name
{t("customFont.nameLabel")}
</Label>
<Input
id="font-name"
placeholder="My Custom Font"
placeholder={t("customFont.namePlaceholder")}
value={fontName}
onChange={(e) => setFontName(e.target.value)}
className="bg-white/5 border-white/10 text-slate-200"
/>
<p className="text-xs text-slate-400">
This is how the font will appear in the font selector
</p>
<p className="text-xs text-slate-400">{t("customFont.nameHelp")}</p>
</div>
<div className="flex justify-end gap-2 mt-6">
@@ -164,14 +163,14 @@ export function AddCustomFontDialog({ onFontAdded }: AddCustomFontDialogProps) {
onClick={() => setOpen(false)}
className="bg-white/5 border-white/10 text-slate-200 hover:bg-white/10"
>
Cancel
{tc("actions.cancel")}
</Button>
<Button
onClick={handleAdd}
disabled={loading}
className="bg-blue-600 hover:bg-blue-700 text-white"
>
{loading ? "Adding..." : "Add Font"}
{loading ? t("customFont.addingButton") : t("customFont.addButton")}
</Button>
</div>
</div>
+248 -12
View File
@@ -1,8 +1,28 @@
import { useRef } from "react";
import { type CSSProperties, type PointerEvent, useRef, useState } from "react";
import { Rnd } from "react-rnd";
import { cn } from "@/lib/utils";
import { getArrowComponent } from "./ArrowSvgs";
import type { AnnotationRegion } from "./types";
import {
type AnnotationRegion,
type BlurData,
DEFAULT_BLUR_DATA,
DEFAULT_BLUR_INTENSITY,
} from "./types";
const FREEHAND_POINT_THRESHOLD = 1;
function buildBlurPolygonClipPath(points: Array<{ x: number; y: number }>) {
if (points.length < 3) return undefined;
const polygon = points.map((point) => `${point.x}% ${point.y}%`).join(", ");
return `polygon(${polygon})`;
}
function buildBlurFreehandPath(points: Array<{ x: number; y: number }>, closed = true) {
if (closed ? points.length < 3 : points.length < 2) return null;
const [firstPoint, ...rest] = points;
const path = `M ${firstPoint.x} ${firstPoint.y} ${rest.map((point) => `L ${point.x} ${point.y}`).join(" ")}`;
return closed ? `${path} Z` : path;
}
interface AnnotationOverlayProps {
annotation: AnnotationRegion;
@@ -11,6 +31,8 @@ interface AnnotationOverlayProps {
containerHeight: number;
onPositionChange: (id: string, position: { x: number; y: number }) => void;
onSizeChange: (id: string, size: { width: number; height: number }) => void;
onBlurDataChange?: (id: string, blurData: BlurData) => void;
onBlurDataCommit?: () => void;
onClick: (id: string) => void;
zIndex: number;
isSelectedBoost: boolean; // Boost z-index when selected for easy editing
@@ -23,6 +45,8 @@ export function AnnotationOverlay({
containerHeight,
onPositionChange,
onSizeChange,
onBlurDataChange,
onBlurDataCommit,
onClick,
zIndex,
isSelectedBoost,
@@ -31,8 +55,16 @@ export function AnnotationOverlay({
const y = (annotation.position.y / 100) * containerHeight;
const width = (annotation.size.width / 100) * containerWidth;
const height = (annotation.size.height / 100) * containerHeight;
const blurShape = annotation.type === "blur" ? (annotation.blurData?.shape ?? "rectangle") : null;
const isSelectedFreehandBlur = isSelected && blurShape === "freehand";
const isDraggingRef = useRef(false);
const isDrawingFreehandRef = useRef(false);
const freehandPointsRef = useRef<Array<{ x: number; y: number }>>([]);
const [isFreehandDrawing, setIsFreehandDrawing] = useState(false);
const [draftFreehandPoints, setDraftFreehandPoints] = useState<Array<{ x: number; y: number }>>(
[],
);
const [livePointerPoint, setLivePointerPoint] = useState<{ x: number; y: number } | null>(null);
const renderArrow = () => {
const direction = annotation.figureData?.arrowDirection || "right";
@@ -43,6 +75,95 @@ export function AnnotationOverlay({
return <ArrowComponent color={color} strokeWidth={strokeWidth} />;
};
const normalizePoint = (event: PointerEvent<HTMLDivElement>) => {
const rect = event.currentTarget.getBoundingClientRect();
const x = ((event.clientX - rect.left) / rect.width) * 100;
const y = ((event.clientY - rect.top) / rect.height) * 100;
return {
x: Math.max(0, Math.min(100, x)),
y: Math.max(0, Math.min(100, y)),
};
};
const appendFreehandPoint = (point: { x: number; y: number }) => {
const points = freehandPointsRef.current;
const lastPoint = points[points.length - 1];
if (!lastPoint) {
points.push(point);
return;
}
const dx = point.x - lastPoint.x;
const dy = point.y - lastPoint.y;
// Sample freehand points in annotation-space percent units to avoid overly dense paths.
if (Math.hypot(dx, dy) >= FREEHAND_POINT_THRESHOLD) {
points.push(point);
}
};
const handleFreehandPointerDown = (event: PointerEvent<HTMLDivElement>) => {
if (
!isSelected ||
annotation.type !== "blur" ||
annotation.blurData?.shape !== "freehand" ||
!onBlurDataChange
) {
return;
}
event.preventDefault();
event.stopPropagation();
event.currentTarget.setPointerCapture(event.pointerId);
isDrawingFreehandRef.current = true;
setIsFreehandDrawing(true);
const point = normalizePoint(event);
freehandPointsRef.current = [point];
setDraftFreehandPoints([point]);
setLivePointerPoint(point);
};
const handleFreehandPointerMove = (event: PointerEvent<HTMLDivElement>) => {
if (!isDrawingFreehandRef.current) return;
event.preventDefault();
event.stopPropagation();
const point = normalizePoint(event);
setLivePointerPoint(point);
appendFreehandPoint(point);
setDraftFreehandPoints([...freehandPointsRef.current]);
};
const finishFreehandPointer = (event: PointerEvent<HTMLDivElement>) => {
if (!isDrawingFreehandRef.current || !onBlurDataChange) return;
isDrawingFreehandRef.current = false;
setIsFreehandDrawing(false);
try {
event.currentTarget.releasePointerCapture(event.pointerId);
} catch {
// no-op if already released
}
const points = [...freehandPointsRef.current];
if (livePointerPoint) {
const last = points[points.length - 1];
if (!last || Math.hypot(last.x - livePointerPoint.x, last.y - livePointerPoint.y) > 0.001) {
points.push(livePointerPoint);
}
}
if (points.length >= 3) {
const closedPoints = [...points];
const first = closedPoints[0];
const last = closedPoints[closedPoints.length - 1];
if (Math.hypot(last.x - first.x, last.y - first.y) > 0.001) {
closedPoints.push({ ...first });
}
onBlurDataChange(annotation.id, {
...(annotation.blurData || { ...DEFAULT_BLUR_DATA, shape: "freehand" }),
shape: "freehand",
freehandPoints: closedPoints,
});
setDraftFreehandPoints(closedPoints);
onBlurDataCommit?.();
}
setLivePointerPoint(null);
};
const renderContent = () => {
switch (annotation.type) {
case "text":
@@ -113,6 +234,114 @@ export function AnnotationOverlay({
<div className="w-full h-full flex items-center justify-center p-2">{renderArrow()}</div>
);
case "blur": {
const shape = annotation.blurData?.shape ?? "rectangle";
const blurIntensity = Math.max(
1,
Math.round(annotation.blurData?.intensity ?? DEFAULT_BLUR_INTENSITY),
);
const activeFreehandPoints =
shape === "freehand"
? isFreehandDrawing
? draftFreehandPoints
: (annotation.blurData?.freehandPoints ?? [])
: [];
const drawingPoints =
isFreehandDrawing && livePointerPoint
? (() => {
const last = activeFreehandPoints[activeFreehandPoints.length - 1];
if (!last) return [livePointerPoint];
const dx = livePointerPoint.x - last.x;
const dy = livePointerPoint.y - last.y;
return Math.hypot(dx, dy) > 0.01
? [...activeFreehandPoints, livePointerPoint]
: activeFreehandPoints;
})()
: activeFreehandPoints;
const clipPath =
shape === "freehand" ? buildBlurPolygonClipPath(activeFreehandPoints) : undefined;
const freehandPath =
shape === "freehand"
? buildBlurFreehandPath(
isFreehandDrawing ? drawingPoints : activeFreehandPoints,
!isFreehandDrawing,
)
: null;
const currentPointerPoint = isFreehandDrawing
? livePointerPoint || drawingPoints[drawingPoints.length - 1] || null
: null;
const shapeBorderRadius = shape === "oval" ? "50%" : shape === "rectangle" ? "8px" : "0";
const shouldShowFreehandBlurFill =
shape !== "freehand" || (!!clipPath && !isFreehandDrawing);
const shapeMaskStyle: CSSProperties = {
borderRadius: shapeBorderRadius,
clipPath: isFreehandDrawing ? undefined : clipPath,
WebkitClipPath: isFreehandDrawing ? undefined : clipPath,
};
const isFreehandSelected = isSelectedFreehandBlur;
return (
<div className="w-full h-full relative">
<div
className="absolute inset-0 overflow-hidden"
style={{
...shapeMaskStyle,
isolation: "isolate",
}}
>
<div
className="absolute inset-0"
style={{
...shapeMaskStyle,
backdropFilter: `blur(${blurIntensity}px)`,
WebkitBackdropFilter: `blur(${blurIntensity}px)`,
backgroundColor: "rgba(255, 255, 255, 0.02)",
opacity: shouldShowFreehandBlurFill ? 1 : 0,
}}
/>
{isSelected && shape !== "freehand" && (
<div
className="absolute inset-0 pointer-events-none border-2 border-[#34B27B]/80"
style={{ borderRadius: shapeBorderRadius }}
/>
)}
</div>
{isSelected && shape === "freehand" && freehandPath && (
<svg
viewBox="0 0 100 100"
preserveAspectRatio="none"
className="absolute inset-0 pointer-events-none"
>
<path
d={freehandPath}
fill="none"
stroke="#34B27B"
strokeWidth="0.55"
strokeLinecap="round"
strokeLinejoin="round"
/>
{currentPointerPoint && (
<circle
cx={currentPointerPoint.x}
cy={currentPointerPoint.y}
r="0.6"
fill="#34B27B"
/>
)}
</svg>
)}
{isFreehandSelected && (
<div
className="absolute inset-0 cursor-crosshair"
onPointerDown={handleFreehandPointerDown}
onPointerMove={handleFreehandPointerMove}
onPointerUp={finishFreehandPointer}
onPointerCancel={finishFreehandPointer}
/>
)}
</div>
);
}
default:
return null;
}
@@ -149,18 +378,23 @@ export function AnnotationOverlay({
}}
bounds="parent"
className={cn(
"cursor-move transition-all",
isSelected && "ring-2 ring-[#34B27B] ring-offset-2 ring-offset-transparent",
"cursor-move",
isSelected &&
annotation.type !== "blur" &&
"ring-2 ring-[#34B27B] ring-offset-2 ring-offset-transparent",
)}
style={{
zIndex: isSelectedBoost ? zIndex + 1000 : zIndex, // Boost selected annotation to ensure it's on top
pointerEvents: isSelected ? "auto" : "none",
border: isSelected ? "2px solid rgba(52, 178, 123, 0.8)" : "none",
backgroundColor: isSelected ? "rgba(52, 178, 123, 0.1)" : "transparent",
boxShadow: isSelected ? "0 0 0 1px rgba(52, 178, 123, 0.35)" : "none",
border:
isSelected && annotation.type !== "blur" ? "2px solid rgba(52, 178, 123, 0.8)" : "none",
backgroundColor:
isSelected && annotation.type !== "blur" ? "rgba(52, 178, 123, 0.1)" : "transparent",
boxShadow:
isSelected && annotation.type !== "blur" ? "0 0 0 1px rgba(52, 178, 123, 0.35)" : "none",
}}
enableResizing={isSelected}
disableDragging={!isSelected}
enableResizing={isSelected && !isSelectedFreehandBlur}
disableDragging={!isSelected || isSelectedFreehandBlur}
resizeHandleStyles={{
topLeft: {
width: "12px",
@@ -206,11 +440,13 @@ export function AnnotationOverlay({
>
<div
className={cn(
"w-full h-full rounded-lg",
"w-full h-full",
annotation.type !== "blur" && "rounded-lg",
annotation.type === "text" && "bg-transparent",
annotation.type === "image" && "bg-transparent",
annotation.type === "figure" && "bg-transparent",
isSelected && "shadow-lg",
annotation.type === "blur" && "bg-transparent",
isSelected && annotation.type !== "blur" && "shadow-lg",
)}
>
{renderContent()}
@@ -27,11 +27,17 @@ import {
import { Slider } from "@/components/ui/slider";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { useScopedT } from "@/contexts/I18nContext";
import { type CustomFont, getCustomFonts } from "@/lib/customFonts";
import { cn } from "@/lib/utils";
import { AddCustomFontDialog } from "./AddCustomFontDialog";
import { getArrowComponent } from "./ArrowSvgs";
import type { AnnotationRegion, AnnotationType, ArrowDirection, FigureData } from "./types";
import {
type AnnotationRegion,
type AnnotationType,
type ArrowDirection,
type FigureData,
} from "./types";
interface AnnotationSettingsPanelProps {
annotation: AnnotationRegion;
@@ -43,14 +49,14 @@ interface AnnotationSettingsPanelProps {
}
const FONT_FAMILIES = [
{ value: "system-ui, -apple-system, sans-serif", label: "Classic" },
{ value: "Georgia, serif", label: "Editor" },
{ value: "Impact, Arial Black, sans-serif", label: "Strong" },
{ value: "Courier New, monospace", label: "Typewriter" },
{ value: "Brush Script MT, cursive", label: "Deco" },
{ value: "Arial, sans-serif", label: "Simple" },
{ value: "Verdana, sans-serif", label: "Modern" },
{ value: "Trebuchet MS, sans-serif", label: "Clean" },
{ value: "system-ui, -apple-system, sans-serif", labelKey: "classic" },
{ value: "Georgia, serif", labelKey: "editor" },
{ value: "Impact, Arial Black, sans-serif", labelKey: "strong" },
{ value: "Courier New, monospace", labelKey: "typewriter" },
{ value: "Brush Script MT, cursive", labelKey: "deco" },
{ value: "Arial, sans-serif", labelKey: "simple" },
{ value: "Verdana, sans-serif", labelKey: "modern" },
{ value: "Trebuchet MS, sans-serif", labelKey: "clean" },
];
const FONT_SIZES = [12, 14, 16, 18, 20, 24, 28, 32, 36, 40, 48, 56, 64, 72, 80, 96, 128];
@@ -63,9 +69,21 @@ export function AnnotationSettingsPanel({
onFigureDataChange,
onDelete,
}: AnnotationSettingsPanelProps) {
const t = useScopedT("settings");
const fileInputRef = useRef<HTMLInputElement>(null);
const [customFonts, setCustomFonts] = useState<CustomFont[]>([]);
const fontStyleLabels: Record<string, string> = {
classic: t("fontStyles.classic"),
editor: t("fontStyles.editor"),
strong: t("fontStyles.strong"),
typewriter: t("fontStyles.typewriter"),
deco: t("fontStyles.deco"),
simple: t("fontStyles.simple"),
modern: t("fontStyles.modern"),
clean: t("fontStyles.clean"),
};
// Load custom fonts on mount
useEffect(() => {
setCustomFonts(getCustomFonts());
@@ -99,8 +117,8 @@ export function AnnotationSettingsPanel({
// Validate file type
const validTypes = ["image/jpeg", "image/jpg", "image/png", "image/gif", "image/webp"];
if (!validTypes.includes(file.type)) {
toast.error("Invalid file type", {
description: "Please upload a JPG, PNG, GIF, or WebP image file.",
toast.error(t("annotation.invalidImageType"), {
description: t("annotation.imageFormatsOnly"),
});
event.target.value = "";
return;
@@ -112,12 +130,12 @@ export function AnnotationSettingsPanel({
const dataUrl = e.target?.result as string;
if (dataUrl) {
onContentChange(dataUrl);
toast.success("Image uploaded successfully!");
toast.success(t("annotation.imageUploadSuccess"));
}
};
reader.onerror = () => {
toast.error("Failed to upload image", {
toast.error(t("annotation.failedImageUpload"), {
description: "There was an error reading the file.",
});
};
@@ -130,9 +148,9 @@ export function AnnotationSettingsPanel({
<div className="flex-[2] min-w-0 bg-[#09090b] border border-white/5 rounded-2xl p-4 flex flex-col shadow-xl h-full overflow-y-auto custom-scrollbar">
<div className="mb-6">
<div className="flex items-center justify-between mb-4">
<span className="text-sm font-medium text-slate-200">Annotation Settings</span>
<span className="text-sm font-medium text-slate-200">{t("annotation.title")}</span>
<span className="text-[10px] uppercase tracking-wider font-medium text-[#34B27B] bg-[#34B27B]/10 px-2 py-1 rounded-full">
Active
{t("annotation.active")}
</span>
</div>
@@ -148,14 +166,14 @@ export function AnnotationSettingsPanel({
className="data-[state=active]:bg-[#34B27B] data-[state=active]:text-white text-slate-400 py-2 rounded-lg transition-all gap-2"
>
<Type className="w-4 h-4" />
Text
{t("annotation.typeText")}
</TabsTrigger>
<TabsTrigger
value="image"
className="data-[state=active]:bg-[#34B27B] data-[state=active]:text-white text-slate-400 py-2 rounded-lg transition-all gap-2"
>
<ImageIcon className="w-4 h-4" />
Image
{t("annotation.typeImage")}
</TabsTrigger>
<TabsTrigger
value="figure"
@@ -170,18 +188,20 @@ export function AnnotationSettingsPanel({
>
<path d="M4 12h16m0 0l-6-6m6 6l-6 6" strokeLinecap="round" strokeLinejoin="round" />
</svg>
Arrow
{t("annotation.typeArrow")}
</TabsTrigger>
</TabsList>
{/* Text Content */}
<TabsContent value="text" className="mt-0 space-y-4">
<div>
<label className="text-xs font-medium text-slate-200 mb-2 block">Text Content</label>
<label className="text-xs font-medium text-slate-200 mb-2 block">
{t("annotation.textContent")}
</label>
<textarea
value={annotation.textContent || annotation.content}
onChange={(e) => onContentChange(e.target.value)}
placeholder="Enter your text..."
placeholder={t("annotation.textPlaceholder")}
rows={5}
className="w-full px-3 py-2 bg-white/5 border border-white/10 rounded-lg text-slate-200 text-sm placeholder:text-slate-500 focus:outline-none focus:ring-2 focus:ring-[#34B27B] focus:border-transparent resize-none"
/>
@@ -193,14 +213,14 @@ export function AnnotationSettingsPanel({
<div className="grid grid-cols-2 gap-2">
<div>
<label className="text-xs font-medium text-slate-200 mb-2 block">
Font Style
{t("annotation.fontStyle")}
</label>
<Select
value={annotation.style.fontFamily}
onValueChange={(value) => onStyleChange({ fontFamily: value })}
>
<SelectTrigger className="w-full bg-white/5 border-white/10 text-slate-200 h-9 text-xs">
<SelectValue placeholder="Select style" />
<SelectValue placeholder={t("annotation.selectStyle")} />
</SelectTrigger>
<SelectContent className="bg-[#1a1a1c] border-white/10 text-slate-200 max-h-[300px]">
{FONT_FAMILIES.map((font) => (
@@ -209,13 +229,13 @@ export function AnnotationSettingsPanel({
value={font.value}
style={{ fontFamily: font.value }}
>
{font.label}
{fontStyleLabels[font.labelKey]}
</SelectItem>
))}
{customFonts.length > 0 && (
<>
<div className="px-2 py-1.5 text-[10px] font-medium text-slate-400 uppercase tracking-wider">
Custom Fonts
{t("annotation.customFonts")}
</div>
{customFonts.map((font) => (
<SelectItem
@@ -232,13 +252,15 @@ export function AnnotationSettingsPanel({
</Select>
</div>
<div>
<label className="text-xs font-medium text-slate-200 mb-2 block">Size</label>
<label className="text-xs font-medium text-slate-200 mb-2 block">
{t("annotation.size")}
</label>
<Select
value={annotation.style.fontSize.toString()}
onValueChange={(value) => onStyleChange({ fontSize: parseInt(value) })}
>
<SelectTrigger className="w-full bg-white/5 border-white/10 text-slate-200 h-9 text-xs">
<SelectValue placeholder="Size" />
<SelectValue placeholder={t("annotation.size")} />
</SelectTrigger>
<SelectContent className="bg-[#1a1a1c] border-white/10 text-slate-200 max-h-[200px]">
{FONT_SIZES.map((size) => (
@@ -345,7 +367,7 @@ export function AnnotationSettingsPanel({
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-xs font-medium text-slate-200 mb-2 block">
Text Color
{t("annotation.textColor")}
</label>
<Popover>
<PopoverTrigger asChild>
@@ -379,7 +401,7 @@ export function AnnotationSettingsPanel({
</div>
<div>
<label className="text-xs font-medium text-slate-200 mb-2 block">
Background
{t("annotation.background")}
</label>
<Popover>
<PopoverTrigger asChild>
@@ -395,7 +417,9 @@ export function AnnotationSettingsPanel({
/>
</div>
<span className="text-xs text-slate-300 truncate flex-1 text-left">
{annotation.style.backgroundColor === "transparent" ? "None" : "Color"}
{annotation.style.backgroundColor === "transparent"
? t("annotation.none")
: t("annotation.color")}
</span>
<ChevronDown className="h-3 w-3 opacity-50" />
</Button>
@@ -423,7 +447,7 @@ export function AnnotationSettingsPanel({
onStyleChange({ backgroundColor: "transparent" });
}}
>
Clear Background
{t("annotation.clearBackground")}
</Button>
</PopoverContent>
</Popover>
@@ -447,7 +471,7 @@ export function AnnotationSettingsPanel({
className="w-full gap-2 bg-white/5 text-slate-200 border-white/10 hover:bg-[#34B27B] hover:text-white hover:border-[#34B27B] transition-all py-8"
>
<Upload className="w-5 h-5" />
Upload Image
{t("annotation.uploadImage")}
</Button>
{annotation.content && annotation.content.startsWith("data:image") && (
@@ -461,14 +485,14 @@ export function AnnotationSettingsPanel({
)}
<p className="text-xs text-slate-500 text-center leading-relaxed">
Supported formats: JPG, PNG, GIF, WebP
{t("annotation.supportedFormats")}
</p>
</TabsContent>
<TabsContent value="figure" className="mt-0 space-y-4">
<div>
<label className="text-xs font-medium text-slate-200 mb-3 block">
Arrow Direction
{t("annotation.arrowDirection")}
</label>
<div className="grid grid-cols-4 gap-2">
{(
@@ -517,7 +541,9 @@ export function AnnotationSettingsPanel({
<div>
<label className="text-xs font-medium text-slate-200 mb-2 block">
Stroke Width: {annotation.figureData?.strokeWidth || 4}px
{t("annotation.strokeWidth", {
width: String(annotation.figureData?.strokeWidth || 4),
})}
</label>
<Slider
value={[annotation.figureData?.strokeWidth || 4]}
@@ -536,7 +562,9 @@ export function AnnotationSettingsPanel({
</div>
<div>
<label className="text-xs font-medium text-slate-200 mb-2 block">Arrow Color</label>
<label className="text-xs font-medium text-slate-200 mb-2 block">
{t("annotation.arrowColor")}
</label>
<Popover>
<PopoverTrigger asChild>
<Button
@@ -581,28 +609,18 @@ export function AnnotationSettingsPanel({
className="w-full gap-2 bg-red-500/10 text-red-400 border border-red-500/20 hover:bg-red-500/20 hover:border-red-500/30 transition-all mt-4"
>
<Trash2 className="w-4 h-4" />
Delete Annotation
{t("annotation.deleteAnnotation")}
</Button>
<div className="mt-6 p-3 bg-white/5 rounded-lg border border-white/5">
<div className="flex items-center gap-2 mb-2 text-slate-300">
<Info className="w-3.5 h-3.5" />
<span className="text-xs font-medium">Shortcuts & Tips</span>
<span className="text-xs font-medium">{t("annotation.shortcutsAndTips")}</span>
</div>
<ul className="text-[10px] text-slate-400 space-y-1.5 list-disc pl-3 leading-relaxed">
<li>Move playhead to overlapping annotation section and select an item.</li>
<li>
Use{" "}
<kbd className="px-1 py-0.5 bg-white/10 rounded text-slate-300 font-mono">Tab</kbd> to
cycle through overlapping items.
</li>
<li>
Use{" "}
<kbd className="px-1 py-0.5 bg-white/10 rounded text-slate-300 font-mono">
Shift+Tab
</kbd>{" "}
to cycle backwards.
</li>
<li>{t("annotation.tipMovePlayhead")}</li>
<li>{t("annotation.tipTabCycle")}</li>
<li>{t("annotation.tipShiftTabCycle")}</li>
</ul>
</div>
</div>
@@ -0,0 +1,142 @@
import { Info, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Slider } from "@/components/ui/slider";
import { useScopedT } from "@/contexts/I18nContext";
import { cn } from "@/lib/utils";
import {
type AnnotationRegion,
type BlurData,
type BlurShape,
DEFAULT_BLUR_DATA,
MAX_BLUR_INTENSITY,
MIN_BLUR_INTENSITY,
} from "./types";
interface BlurSettingsPanelProps {
blurRegion: AnnotationRegion;
onBlurDataChange: (blurData: BlurData) => void;
onBlurDataCommit?: () => void;
onDelete: () => void;
}
export function BlurSettingsPanel({
blurRegion,
onBlurDataChange,
onBlurDataCommit,
onDelete,
}: BlurSettingsPanelProps) {
const t = useScopedT("settings");
const blurShapeOptions: Array<{ value: BlurShape; labelKey: string }> = [
{ value: "rectangle", labelKey: "blurShapeRectangle" },
{ value: "oval", labelKey: "blurShapeOval" },
];
return (
<div className="flex-[2] min-w-0 bg-[#09090b] border border-white/5 rounded-2xl p-4 flex flex-col shadow-xl h-full overflow-y-auto custom-scrollbar">
<div className="mb-6">
<div className="flex items-center justify-between mb-4">
<span className="text-sm font-medium text-slate-200">{t("annotation.blurShape")}</span>
<span className="text-[10px] uppercase tracking-wider font-medium text-[#34B27B] bg-[#34B27B]/10 px-2 py-1 rounded-full">
{t("annotation.active")}
</span>
</div>
<div className="grid grid-cols-2 gap-2">
{blurShapeOptions.map((shape) => {
const activeShape = blurRegion.blurData?.shape || DEFAULT_BLUR_DATA.shape;
const isActive = activeShape === shape.value;
return (
<button
key={shape.value}
onClick={() => {
const nextBlurData: BlurData = {
...DEFAULT_BLUR_DATA,
...blurRegion.blurData,
shape: shape.value,
};
onBlurDataChange(nextBlurData);
requestAnimationFrame(() => {
onBlurDataCommit?.();
});
}}
className={cn(
"h-16 rounded-lg border flex flex-col items-center justify-center transition-all p-2 gap-1",
isActive
? "bg-[#34B27B] border-[#34B27B]"
: "bg-white/5 border-white/10 hover:bg-white/10 hover:border-white/20",
)}
>
{shape.value === "rectangle" && (
<div
className={cn(
"w-8 h-5 border-2 rounded-sm",
isActive ? "border-white" : "border-slate-400",
)}
/>
)}
{shape.value === "oval" && (
<div
className={cn(
"w-8 h-5 border-2 rounded-full",
isActive ? "border-white" : "border-slate-400",
)}
/>
)}
<span className="text-[10px] leading-none">
{t(`annotation.${shape.labelKey}`)}
</span>
</button>
);
})}
</div>
<div className="mt-4 p-3 rounded-lg bg-white/5 border border-white/10">
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-medium text-slate-300">
{t("annotation.blurIntensity")}
</span>
<span className="text-[10px] text-slate-400 font-mono">
{Math.round(blurRegion.blurData?.intensity ?? DEFAULT_BLUR_DATA.intensity)}px
</span>
</div>
<Slider
value={[blurRegion.blurData?.intensity ?? DEFAULT_BLUR_DATA.intensity]}
onValueChange={(values) => {
onBlurDataChange({
...DEFAULT_BLUR_DATA,
...blurRegion.blurData,
intensity: values[0],
});
}}
onValueCommit={() => onBlurDataCommit?.()}
min={MIN_BLUR_INTENSITY}
max={MAX_BLUR_INTENSITY}
step={1}
className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3"
/>
</div>
<Button
onClick={onDelete}
variant="destructive"
size="sm"
className="w-full gap-2 bg-red-500/10 text-red-400 border border-red-500/20 hover:bg-red-500/20 hover:border-red-500/30 transition-all mt-4"
>
<Trash2 className="w-4 h-4" />
{t("annotation.deleteAnnotation")}
</Button>
<div className="mt-6 p-3 bg-white/5 rounded-lg border border-white/5">
<div className="flex items-center gap-2 mb-2 text-slate-300">
<Info className="w-3.5 h-3.5" />
<span className="text-xs font-medium">{t("annotation.shortcutsAndTips")}</span>
</div>
<ul className="text-[10px] text-slate-400 space-y-1.5 list-disc pl-3 leading-relaxed">
<li>{t("annotation.tipMovePlayhead")}</li>
</ul>
</div>
</div>
</div>
);
}
+30 -20
View File
@@ -1,6 +1,7 @@
import { Download, Loader2, X } from "lucide-react";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { useScopedT } from "@/contexts/I18nContext";
import type { ExportProgress } from "@/lib/exporter";
interface ExportDialogProps {
@@ -26,6 +27,7 @@ export function ExportDialog({
exportedFilePath,
onShowInFolder,
}: ExportDialogProps) {
const t = useScopedT("dialogs");
const [showSuccess, setShowSuccess] = useState(false);
// Reset showSuccess when a new export starts or dialog reopens
@@ -65,25 +67,25 @@ export function ExportDialog({
// Get status message based on phase
const getStatusMessage = () => {
if (error) return "Please try again";
if (error) return t("export.tryAgain");
if (isCompiling || isFinalizing) {
if (exportFormat === "mp4") {
return "Finalizing video export...";
return t("export.finalizingVideo");
}
if (renderProgress !== undefined && renderProgress > 0) {
return `Compiling GIF... ${renderProgress}%`;
return t("export.compilingGifProgress", { progress: String(renderProgress) });
}
return "Compiling GIF... This may take a while";
return t("export.compilingGifWait");
}
return "This may take a moment...";
return t("export.takeMoment");
};
// Get title based on phase
const getTitle = () => {
if (error) return "Export Failed";
if (isFinalizing && exportFormat === "mp4") return "Finalizing Video";
if (isCompiling || isFinalizing) return "Compiling GIF";
return `Exporting ${formatLabel}`;
if (error) return t("export.failed");
if (isFinalizing && exportFormat === "mp4") return t("export.finalizingVideoTitle");
if (isCompiling || isFinalizing) return t("export.compilingGif");
return t("export.exportingFormat", { format: formatLabel });
};
return (
@@ -101,9 +103,11 @@ export function ExportDialog({
<Download className="w-6 h-6 text-[#34B27B]" />
</div>
<div className="flex flex-col gap-2">
<span className="text-xl font-bold text-slate-200 block">Export Complete</span>
<span className="text-xl font-bold text-slate-200 block">
{t("export.complete")}
</span>
<span className="text-sm text-slate-400">
Your {formatLabel.toLowerCase()} is ready
{t("export.yourFormatReady", { format: formatLabel.toLowerCase() })}
</span>
{exportedFilePath && (
<Button
@@ -111,7 +115,7 @@ export function ExportDialog({
onClick={onShowInFolder}
className="mt-2 w-fit px-3 py-1 text-sm rounded-md bg-white/10 hover:bg-white/20 text-slate-200"
>
Show in Folder
{t("export.showInFolder")}
</Button>
)}
{exportedFilePath && (
@@ -166,7 +170,11 @@ export function ExportDialog({
<div className="space-y-6">
<div className="space-y-2">
<div className="flex justify-between text-xs font-medium text-slate-400 uppercase tracking-wider">
<span>{isCompiling || isFinalizing ? "Compiling" : "Rendering Frames"}</span>
<span>
{isCompiling || isFinalizing
? t("export.compiling")
: t("export.renderingFrames")}
</span>
<span className="font-mono text-slate-200">
{isCompiling || isFinalizing ? (
renderProgress !== undefined && renderProgress > 0 ? (
@@ -174,7 +182,7 @@ export function ExportDialog({
) : (
<span className="flex items-center gap-2">
<Loader2 className="w-3 h-3 animate-spin" />
Processing...
{t("export.processing")}
</span>
)
) : (
@@ -218,19 +226,19 @@ export function ExportDialog({
<div className="grid grid-cols-2 gap-4">
<div className="bg-white/5 rounded-xl p-3 border border-white/5">
<div className="text-[10px] text-slate-500 uppercase tracking-wider mb-1">
{isCompiling || isFinalizing ? "Status" : "Format"}
{isCompiling || isFinalizing ? t("export.status") : t("export.format")}
</div>
<div className="text-slate-200 font-medium text-sm">
{isFinalizing && exportFormat === "mp4"
? "Finalizing..."
? t("export.finalizing")
: isCompiling || isFinalizing
? "Compiling..."
? t("export.compilingStatus")
: formatLabel}
</div>
</div>
<div className="bg-white/5 rounded-xl p-3 border border-white/5">
<div className="text-[10px] text-slate-500 uppercase tracking-wider mb-1">
Frames
{t("export.frames")}
</div>
<div className="text-slate-200 font-medium text-sm">
{progress.currentFrame} / {progress.totalFrames}
@@ -245,7 +253,7 @@ export function ExportDialog({
variant="destructive"
className="w-full py-6 bg-red-500/10 text-red-400 border border-red-500/20 hover:bg-red-500/20 hover:border-red-500/30 transition-all rounded-xl"
>
Cancel Export
{t("export.cancelExport")}
</Button>
</div>
)}
@@ -254,7 +262,9 @@ export function ExportDialog({
{showSuccess && (
<div className="text-center py-4 animate-in zoom-in-95">
<p className="text-lg text-slate-200 font-medium">{formatLabel} saved successfully!</p>
<p className="text-lg text-slate-200 font-medium">
{t("export.savedSuccessfully", { format: formatLabel })}
</p>
</div>
)}
</div>
+14 -22
View File
@@ -1,4 +1,5 @@
import { Film, Image } from "lucide-react";
import { useScopedT } from "@/contexts/I18nContext";
import type { ExportFormat } from "@/lib/exporter/types";
import { cn } from "@/lib/utils";
@@ -8,26 +9,9 @@ interface FormatSelectorProps {
disabled?: boolean;
}
interface FormatOption {
value: ExportFormat;
label: string;
description: string;
icon: React.ReactNode;
}
const formatOptions: FormatOption[] = [
{
value: "mp4",
label: "MP4 Video",
description: "High quality video file",
icon: <Film className="w-5 h-5" />,
},
{
value: "gif",
label: "GIF Animation",
description: "Animated image for sharing",
icon: <Image className="w-5 h-5" />,
},
const formatOptions: Array<{ value: ExportFormat; icon: React.ReactNode }> = [
{ value: "mp4", icon: <Film className="w-5 h-5" /> },
{ value: "gif", icon: <Image className="w-5 h-5" /> },
];
export function FormatSelector({
@@ -35,10 +19,18 @@ export function FormatSelector({
onFormatChange,
disabled = false,
}: FormatSelectorProps) {
const t = useScopedT("settings");
const formatLabels: Record<ExportFormat, { label: string; description: string }> = {
mp4: { label: t("exportFormat.mp4Video"), description: t("exportFormat.mp4Description") },
gif: { label: t("exportFormat.gifAnimation"), description: t("exportFormat.gifDescription") },
};
return (
<div className="grid grid-cols-2 gap-3">
{formatOptions.map((option) => {
const isSelected = selectedFormat === option.value;
const labels = formatLabels[option.value];
return (
<button
key={option.value}
@@ -63,8 +55,8 @@ export function FormatSelector({
{option.icon}
</div>
<div className="text-center">
<div className="font-medium text-sm">{option.label}</div>
<div className="text-xs text-slate-500 mt-0.5">{option.description}</div>
<div className="font-medium text-sm">{labels.label}</div>
<div className="text-xs text-slate-500 mt-0.5">{labels.description}</div>
</div>
{isSelected && (
<div className="absolute top-2 right-2 w-2 h-2 rounded-full bg-[#34B27B]" />
@@ -1,9 +1,11 @@
import { HelpCircle, Settings2 } from "lucide-react";
import { useScopedT } from "@/contexts/I18nContext";
import { useShortcuts } from "@/contexts/ShortcutsContext";
import { FIXED_SHORTCUTS, formatBinding, SHORTCUT_ACTIONS, SHORTCUT_LABELS } from "@/lib/shortcuts";
import { FIXED_SHORTCUTS, formatBinding, SHORTCUT_ACTIONS } from "@/lib/shortcuts";
export function KeyboardShortcutsHelp() {
const { shortcuts, isMac, openConfig } = useShortcuts();
const t = useScopedT("shortcuts");
return (
<div className="relative group">
@@ -11,7 +13,7 @@ export function KeyboardShortcutsHelp() {
<div className="absolute right-0 top-full mt-2 w-64 bg-[#09090b] border border-white/10 rounded-lg p-3 opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200 shadow-xl z-50">
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-semibold text-slate-200">Keyboard Shortcuts</span>
<span className="text-xs font-semibold text-slate-200">{t("title")}</span>
<button
type="button"
onClick={openConfig}
@@ -19,14 +21,14 @@ export function KeyboardShortcutsHelp() {
className="flex items-center gap-1 text-[10px] text-slate-500 hover:text-[#34B27B] transition-colors"
>
<Settings2 className="w-3 h-3" />
Customize
{t("customize")}
</button>
</div>
<div className="space-y-1.5 text-[10px]">
{SHORTCUT_ACTIONS.map((action) => (
<div key={action} className="flex items-center justify-between">
<span className="text-slate-400">{SHORTCUT_LABELS[action]}</span>
<span className="text-slate-400">{t(`actions.${action}`)}</span>
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">
{formatBinding(shortcuts[action], isMac)}
</kbd>
@@ -35,8 +37,10 @@ export function KeyboardShortcutsHelp() {
<div className="pt-1 border-t border-white/5 mt-1 space-y-1.5">
{FIXED_SHORTCUTS.map((fixed) => (
<div key={fixed.label} className="flex items-center justify-between">
<span className="text-slate-400">{fixed.label}</span>
<div key={fixed.i18nKey} className="flex items-center justify-between">
<span className="text-slate-400">
{t(`fixedActions.${fixed.i18nKey}`, { defaultValue: fixed.label })}
</span>
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">
{isMac
? fixed.display
@@ -1,4 +1,5 @@
import { Pause, Play } from "lucide-react";
import { Maximize, Minimize, Pause, Play } from "lucide-react";
import { useScopedT } from "@/contexts/I18nContext";
import { cn } from "@/lib/utils";
import { Button } from "../ui/button";
@@ -6,6 +7,8 @@ interface PlaybackControlsProps {
isPlaying: boolean;
currentTime: number;
duration: number;
isFullscreen?: boolean;
onToggleFullscreen?: () => void;
onTogglePlayPause: () => void;
onSeek: (time: number) => void;
}
@@ -14,9 +17,13 @@ export default function PlaybackControls({
isPlaying,
currentTime,
duration,
isFullscreen = false,
onToggleFullscreen,
onTogglePlayPause,
onSeek,
}: PlaybackControlsProps) {
const t = useScopedT("common");
function formatTime(seconds: number) {
if (!isFinite(seconds) || isNaN(seconds) || seconds < 0) return "0:00";
const mins = Math.floor(seconds / 60);
@@ -41,7 +48,7 @@ export default function PlaybackControls({
? "bg-white/10 text-white hover:bg-white/20"
: "bg-white text-black hover:bg-white/90 hover:scale-105 shadow-[0_0_15px_rgba(255,255,255,0.3)]",
)}
aria-label={isPlaying ? "Pause" : "Play"}
aria-label={isPlaying ? t("playback.pause") : t("playback.play")}
>
{isPlaying ? (
<Pause className="w-3.5 h-3.5 fill-current" />
@@ -84,6 +91,21 @@ export default function PlaybackControls({
<span className="text-[9px] font-medium text-slate-500 tabular-nums w-[30px]">
{formatTime(duration)}
</span>
{onToggleFullscreen && (
<Button
onClick={onToggleFullscreen}
size="icon"
className="w-7 h-7 rounded-full transition-all duration-200 border border-transparent hover:bg-white/10 text-white hover:border-white/10 shrink-0 shadow-none ml-0.5"
aria-label={isFullscreen ? t("playback.exitFullscreen") : t("playback.fullscreen")}
>
{isFullscreen ? (
<Minimize className="w-3.5 h-3.5" />
) : (
<Maximize className="w-3.5 h-3.5" />
)}
</Button>
)}
</div>
);
}
+355 -87
View File
@@ -4,11 +4,9 @@ import {
Crop,
Download,
Film,
FolderOpen,
Image,
Lock,
Palette,
Save,
Sparkles,
Star,
Trash2,
@@ -35,26 +33,95 @@ import {
import { Slider } from "@/components/ui/slider";
import { Switch } from "@/components/ui/switch";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useScopedT } from "@/contexts/I18nContext";
import { getAssetPath } from "@/lib/assetPath";
import { WEBCAM_LAYOUT_PRESETS } from "@/lib/compositeLayout";
import type { ExportFormat, ExportQuality, GifFrameRate, GifSizePreset } from "@/lib/exporter";
import { GIF_FRAME_RATES, GIF_SIZE_PRESETS } from "@/lib/exporter";
import { cn } from "@/lib/utils";
import { type AspectRatio } from "@/utils/aspectRatioUtils";
import { type AspectRatio, isPortraitAspectRatio } from "@/utils/aspectRatioUtils";
import { getTestId } from "@/utils/getTestId";
import { AnnotationSettingsPanel } from "./AnnotationSettingsPanel";
import { BlurSettingsPanel } from "./BlurSettingsPanel";
import { CropControl } from "./CropControl";
import { KeyboardShortcutsHelp } from "./KeyboardShortcutsHelp";
import type {
AnnotationRegion,
AnnotationType,
BlurData,
CropRegion,
FigureData,
PlaybackSpeed,
WebcamLayoutPreset,
WebcamMaskShape,
WebcamSizePreset,
ZoomDepth,
ZoomFocusMode,
} from "./types";
import { SPEED_OPTIONS } from "./types";
import { DEFAULT_WEBCAM_SIZE_PRESET, MAX_PLAYBACK_SPEED, SPEED_OPTIONS } from "./types";
function CustomSpeedInput({
value,
onChange,
onError,
}: {
value: number;
onChange: (val: number) => void;
onError: () => void;
}) {
const isPreset = SPEED_OPTIONS.some((o) => o.speed === value);
const [draft, setDraft] = useState(isPreset ? "" : String(Math.round(value)));
const [isFocused, setIsFocused] = useState(false);
const prevValue = useRef(value);
if (!isFocused && prevValue.current !== value) {
prevValue.current = value;
setDraft(isPreset ? "" : String(Math.round(value)));
}
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const digits = e.target.value.replace(/\D/g, "");
if (digits === "") {
setDraft("");
return;
}
const num = Number(digits);
if (num > MAX_PLAYBACK_SPEED) {
onError();
return;
}
setDraft(digits);
if (num >= 1) onChange(num);
},
[onChange, onError],
);
const handleBlur = useCallback(() => {
setIsFocused(false);
if (!draft || Number(draft) < 1) {
setDraft(isPreset ? "" : String(Math.round(value)));
}
}, [draft, isPreset, value]);
return (
<div className="flex items-center gap-1">
<input
type="text"
inputMode="numeric"
pattern="[0-9]*"
placeholder="--"
value={draft}
onFocus={() => setIsFocused(true)}
onChange={handleChange}
onBlur={handleBlur}
onKeyDown={(e) => e.key === "Enter" && (e.target as HTMLInputElement).blur()}
className="w-12 bg-white/5 border border-white/10 rounded-md px-1 py-0.5 text-[11px] font-semibold text-[#d97706] text-center focus:outline-none focus:border-[#d97706]/40"
/>
<span className="text-[11px] font-semibold text-slate-500">×</span>
</div>
);
}
const WALLPAPER_COUNT = 18;
const WALLPAPER_RELATIVE = Array.from(
@@ -93,6 +160,9 @@ interface SettingsPanelProps {
onWallpaperChange: (path: string) => void;
selectedZoomDepth?: ZoomDepth | null;
onZoomDepthChange?: (depth: ZoomDepth) => void;
selectedZoomFocusMode?: ZoomFocusMode | null;
onZoomFocusModeChange?: (mode: ZoomFocusMode) => void;
hasCursorTelemetry?: boolean;
selectedZoomId?: string | null;
onZoomDelete?: (id: string) => void;
selectedTrimId?: string | null;
@@ -127,10 +197,12 @@ interface SettingsPanelProps {
gifSizePreset?: GifSizePreset;
onGifSizePresetChange?: (preset: GifSizePreset) => void;
gifOutputDimensions?: { width: number; height: number };
onSaveProject?: () => void;
onLoadProject?: () => void;
onExport?: () => void;
unsavedExport?: { arrayBuffer: ArrayBuffer; fileName: string; format: string } | null;
unsavedExport?: {
arrayBuffer: ArrayBuffer;
fileName: string;
format: string;
} | null;
onSaveUnsavedExport?: () => void;
selectedAnnotationId?: string | null;
annotationRegions?: AnnotationRegion[];
@@ -139,6 +211,11 @@ interface SettingsPanelProps {
onAnnotationStyleChange?: (id: string, style: Partial<AnnotationRegion["style"]>) => void;
onAnnotationFigureDataChange?: (id: string, figureData: FigureData) => void;
onAnnotationDelete?: (id: string) => void;
selectedBlurId?: string | null;
blurRegions?: AnnotationRegion[];
onBlurDataChange?: (id: string, blurData: BlurData) => void;
onBlurDataCommit?: () => void;
onBlurDelete?: (id: string) => void;
selectedSpeedId?: string | null;
selectedSpeedValue?: PlaybackSpeed | null;
onSpeedChange?: (speed: PlaybackSpeed) => void;
@@ -146,6 +223,11 @@ interface SettingsPanelProps {
hasWebcam?: boolean;
webcamLayoutPreset?: WebcamLayoutPreset;
onWebcamLayoutPresetChange?: (preset: WebcamLayoutPreset) => void;
webcamMaskShape?: import("./types").WebcamMaskShape;
onWebcamMaskShapeChange?: (shape: import("./types").WebcamMaskShape) => void;
webcamSizePreset?: WebcamSizePreset;
onWebcamSizePresetChange?: (size: WebcamSizePreset) => void;
onWebcamSizePresetCommit?: () => void;
}
export default SettingsPanel;
@@ -164,6 +246,9 @@ export function SettingsPanel({
onWallpaperChange,
selectedZoomDepth,
onZoomDepthChange,
selectedZoomFocusMode,
onZoomFocusModeChange,
hasCursorTelemetry = false,
selectedZoomId,
onZoomDelete,
selectedTrimId,
@@ -197,8 +282,6 @@ export function SettingsPanel({
gifSizePreset = "medium",
onGifSizePresetChange,
gifOutputDimensions = { width: 1280, height: 720 },
onSaveProject,
onLoadProject,
onExport,
unsavedExport,
onSaveUnsavedExport,
@@ -209,6 +292,11 @@ export function SettingsPanel({
onAnnotationStyleChange,
onAnnotationFigureDataChange,
onAnnotationDelete,
selectedBlurId,
blurRegions = [],
onBlurDataChange,
onBlurDataCommit,
onBlurDelete,
selectedSpeedId,
selectedSpeedValue,
onSpeedChange,
@@ -216,7 +304,13 @@ export function SettingsPanel({
hasWebcam = false,
webcamLayoutPreset = "picture-in-picture",
onWebcamLayoutPresetChange,
webcamMaskShape = "rectangle",
onWebcamMaskShapeChange,
webcamSizePreset = DEFAULT_WEBCAM_SIZE_PRESET,
onWebcamSizePresetChange,
onWebcamSizePresetCommit,
}: SettingsPanelProps) {
const t = useScopedT("settings");
const [wallpaperPaths, setWallpaperPaths] = useState<string[]>([]);
const [customImages, setCustomImages] = useState<string[]>([]);
const fileInputRef = useRef<HTMLInputElement>(null);
@@ -382,8 +476,8 @@ export function SettingsPanel({
// Validate file type - only allow JPG/JPEG
const validTypes = ["image/jpeg", "image/jpg"];
if (!validTypes.includes(file.type)) {
toast.error("Invalid file type", {
description: "Please upload a JPG or JPEG image file.",
toast.error(t("imageUpload.invalidFileType"), {
description: t("imageUpload.jpgOnly"),
});
event.target.value = "";
return;
@@ -396,13 +490,13 @@ export function SettingsPanel({
if (dataUrl) {
setCustomImages((prev) => [...prev, dataUrl]);
onWallpaperChange(dataUrl);
toast.success("Custom image uploaded successfully!");
toast.success(t("imageUpload.uploadSuccess"));
}
};
reader.onerror = () => {
toast.error("Failed to upload image", {
description: "There was an error reading the file.",
toast.error(t("imageUpload.failedToUpload"), {
description: t("imageUpload.errorReading"),
});
};
@@ -438,6 +532,9 @@ export function SettingsPanel({
const selectedAnnotation = selectedAnnotationId
? annotationRegions.find((a) => a.id === selectedAnnotationId)
: null;
const selectedBlur = selectedBlurId
? blurRegions.find((region) => region.id === selectedBlurId)
: null;
// If an annotation is selected, show annotation settings instead
if (
@@ -463,12 +560,23 @@ export function SettingsPanel({
);
}
if (selectedBlur && onBlurDataChange && onBlurDelete) {
return (
<BlurSettingsPanel
blurRegion={selectedBlur}
onBlurDataChange={(blurData) => onBlurDataChange(selectedBlur.id, blurData)}
onBlurDataCommit={onBlurDataCommit}
onDelete={() => onBlurDelete(selectedBlur.id)}
/>
);
}
return (
<div className="flex-[2] min-w-0 bg-[#09090b] border border-white/5 rounded-2xl flex flex-col shadow-xl h-full overflow-hidden">
<div className="flex-1 overflow-y-auto custom-scrollbar p-4 pb-0">
<div className="mb-4">
<div className="flex items-center justify-between mb-3">
<span className="text-sm font-medium text-slate-200">Zoom Level</span>
<span className="text-sm font-medium text-slate-200">{t("zoom.level")}</span>
<div className="flex items-center gap-2">
{zoomEnabled && selectedZoomDepth && (
<span className="text-[10px] uppercase tracking-wider font-medium text-[#34B27B] bg-[#34B27B]/10 px-2 py-0.5 rounded-full">
@@ -502,9 +610,42 @@ export function SettingsPanel({
})}
</div>
{!zoomEnabled && (
<p className="text-[10px] text-slate-500 mt-2 text-center">
Select a zoom region to adjust
</p>
<p className="text-[10px] text-slate-500 mt-2 text-center">{t("zoom.selectRegion")}</p>
)}
{zoomEnabled && hasCursorTelemetry && (
<div className="mt-3">
<span className="text-sm font-medium text-slate-200 mb-2 block">
{t("zoom.focusMode.title")}
</span>
<div className="grid grid-cols-2 gap-1.5">
{(["manual", "auto"] as const).map((mode) => {
const isActive = selectedZoomFocusMode === mode;
return (
<Button
key={mode}
type="button"
onClick={() => onZoomFocusModeChange?.(mode)}
className={cn(
"h-auto w-full rounded-lg border px-2 py-2 text-center shadow-sm transition-all",
"duration-200 ease-out cursor-pointer",
isActive
? "border-[#34B27B] bg-[#34B27B] text-white shadow-[#34B27B]/20"
: "border-white/5 bg-white/5 text-slate-400 hover:bg-white/10 hover:border-white/10 hover:text-slate-200",
)}
>
<span className="text-xs font-semibold capitalize">
{t(`zoom.focusMode.${mode}`)}
</span>
</Button>
);
})}
</div>
{selectedZoomFocusMode === "auto" && (
<p className="text-[10px] text-slate-500 mt-1.5">
{t("zoom.focusMode.autoDescription")}
</p>
)}
</div>
)}
{zoomEnabled && (
<Button
@@ -514,7 +655,7 @@ export function SettingsPanel({
className="mt-2 w-full gap-2 bg-red-500/10 text-red-400 border border-red-500/20 hover:bg-red-500/20 hover:border-red-500/30 transition-all h-8 text-xs"
>
<Trash2 className="w-3 h-3" />
Delete Zoom
{t("zoom.deleteZoom")}
</Button>
)}
</div>
@@ -528,14 +669,14 @@ export function SettingsPanel({
className="w-full gap-2 bg-red-500/10 text-red-400 border border-red-500/20 hover:bg-red-500/20 hover:border-red-500/30 transition-all h-8 text-xs"
>
<Trash2 className="w-3 h-3" />
Delete Trim Region
{t("trim.deleteRegion")}
</Button>
</div>
)}
<div className="mb-4">
<div className="flex items-center justify-between mb-3">
<span className="text-sm font-medium text-slate-200">Playback Speed</span>
<span className="text-sm font-medium text-slate-200">{t("speed.playbackSpeed")}</span>
{selectedSpeedId && selectedSpeedValue && (
<span className="text-[10px] uppercase tracking-wider font-medium text-[#d97706] bg-[#d97706]/10 px-2 py-0.5 rounded-full">
{SPEED_OPTIONS.find((o) => o.speed === selectedSpeedValue)?.label ??
@@ -543,7 +684,7 @@ export function SettingsPanel({
</span>
)}
</div>
<div className="grid grid-cols-7 gap-1.5">
<div className="grid grid-cols-5 gap-1.5">
{SPEED_OPTIONS.map((option) => {
const isActive = selectedSpeedValue === option.speed;
return (
@@ -568,10 +709,31 @@ export function SettingsPanel({
);
})}
</div>
<div className="mt-3">
<div className="flex items-center justify-between">
<span
className={cn("text-[11px]", selectedSpeedId ? "text-slate-500" : "text-slate-600")}
>
{t("speed.customPlaybackSpeed")}
</span>
{selectedSpeedId ? (
<CustomSpeedInput
value={selectedSpeedValue ?? 1}
onChange={(val) => onSpeedChange?.(val)}
onError={() => toast.error(t("speed.maxSpeedError"))}
/>
) : (
<div className="flex items-center gap-1 opacity-40">
<div className="w-12 bg-white/5 border border-white/10 rounded-md px-1 py-0.5 text-[11px] font-semibold text-slate-600 text-center">
--
</div>
<span className="text-[11px] font-semibold text-slate-600">×</span>
</div>
)}
</div>
</div>
{!selectedSpeedId && (
<p className="text-[10px] text-slate-500 mt-2 text-center">
Select a speed region to adjust
</p>
<p className="text-[10px] text-slate-500 mt-2 text-center">{t("speed.selectRegion")}</p>
)}
{selectedSpeedId && (
<Button
@@ -581,7 +743,7 @@ export function SettingsPanel({
className="mt-2 w-full gap-2 bg-red-500/10 text-red-400 border border-red-500/20 hover:bg-red-500/20 hover:border-red-500/30 transition-all h-8 text-xs"
>
<Trash2 className="w-3 h-3" />
Delete Speed Region
{t("speed.deleteRegion")}
</Button>
)}
</div>
@@ -599,12 +761,14 @@ export function SettingsPanel({
<AccordionTrigger className="py-2.5 hover:no-underline">
<div className="flex items-center gap-2">
<Sparkles className="w-4 h-4 text-[#34B27B]" />
<span className="text-xs font-medium">Layout</span>
<span className="text-xs font-medium">{t("layout.title")}</span>
</div>
</AccordionTrigger>
<AccordionContent className="pb-3">
<div className="p-2 rounded-lg bg-white/5 border border-white/5">
<div className="text-[10px] font-medium text-slate-300 mb-1.5">Preset</div>
<div className="text-[10px] font-medium text-slate-300 mb-1.5">
{t("layout.preset")}
</div>
<Select
value={webcamLayoutPreset}
onValueChange={(value: WebcamLayoutPreset) =>
@@ -612,17 +776,125 @@ export function SettingsPanel({
}
>
<SelectTrigger className="h-8 bg-black/20 border-white/10 text-xs">
<SelectValue placeholder="Select preset" />
<SelectValue placeholder={t("layout.selectPreset")} />
</SelectTrigger>
<SelectContent>
{WEBCAM_LAYOUT_PRESETS.map((preset) => (
{WEBCAM_LAYOUT_PRESETS.filter(
(preset) =>
preset.value === "picture-in-picture" ||
isPortraitAspectRatio(aspectRatio),
).map((preset) => (
<SelectItem key={preset.value} value={preset.value} className="text-xs">
{preset.label}
{preset.value === "picture-in-picture"
? t("layout.pictureInPicture")
: t("layout.verticalStack")}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{webcamLayoutPreset === "picture-in-picture" && (
<div className="mt-2 p-2 rounded-lg bg-white/5 border border-white/5">
<div className="text-[10px] font-medium text-slate-300 mb-1.5">
{t("layout.webcamShape")}
</div>
<div className="grid grid-cols-4 gap-1.5">
{(
[
{ value: "rectangle", label: "Rect" },
{ value: "circle", label: "Circle" },
{ value: "square", label: "Square" },
{ value: "rounded", label: "Rounded" },
] as Array<{ value: WebcamMaskShape; label: string }>
).map((shape) => (
<button
key={shape.value}
type="button"
onClick={() => onWebcamMaskShapeChange?.(shape.value)}
className={cn(
"h-10 rounded-lg border flex flex-col items-center justify-center gap-0.5 transition-all",
webcamMaskShape === shape.value
? "bg-[#34B27B] border-[#34B27B] text-white"
: "bg-white/5 border-white/10 hover:bg-white/10 hover:border-white/20 text-slate-400",
)}
>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
{shape.value === "rectangle" && (
<rect
x="1"
y="3"
width="14"
height="10"
rx="2"
stroke="currentColor"
strokeWidth="1.5"
/>
)}
{shape.value === "circle" && (
<circle
cx="8"
cy="8"
r="6.5"
stroke="currentColor"
strokeWidth="1.5"
/>
)}
{shape.value === "square" && (
<rect
x="2"
y="2"
width="12"
height="12"
rx="1"
stroke="currentColor"
strokeWidth="1.5"
/>
)}
{shape.value === "rounded" && (
<rect
x="1"
y="3"
width="14"
height="10"
rx="5"
stroke="currentColor"
strokeWidth="1.5"
/>
)}
</svg>
<span className="text-[8px] leading-none">{shape.label}</span>
</button>
))}
</div>
</div>
)}
{webcamLayoutPreset === "picture-in-picture" && (
<div className="p-2 rounded-lg bg-white/5 border border-white/5 mt-2">
<div className="flex items-center justify-between mb-1.5">
<div className="text-[10px] font-medium text-slate-300">
{t("layout.webcamSize")}
</div>
<div className="text-[10px] font-medium text-slate-400">
{webcamSizePreset}%
</div>
</div>
<Slider
value={[webcamSizePreset]}
onValueChange={(values) => onWebcamSizePresetChange?.(values[0])}
onValueCommit={() => onWebcamSizePresetCommit?.()}
min={10}
max={50}
step={1}
className="w-full"
/>
</div>
)}
</AccordionContent>
</AccordionItem>
)}
@@ -631,13 +903,15 @@ export function SettingsPanel({
<AccordionTrigger className="py-2.5 hover:no-underline">
<div className="flex items-center gap-2">
<Sparkles className="w-4 h-4 text-[#34B27B]" />
<span className="text-xs font-medium">Video Effects</span>
<span className="text-xs font-medium">{t("effects.title")}</span>
</div>
</AccordionTrigger>
<AccordionContent className="pb-3">
<div className="grid grid-cols-2 gap-2 mb-3">
<div className="flex items-center justify-between p-2 rounded-lg bg-white/5 border border-white/5">
<div className="text-[10px] font-medium text-slate-300">Blur BG</div>
<div className="text-[10px] font-medium text-slate-300">
{t("effects.blurBg")}
</div>
<Switch
checked={showBlur}
onCheckedChange={onBlurChange}
@@ -649,9 +923,11 @@ export function SettingsPanel({
<div className="grid grid-cols-2 gap-2">
<div className="p-2 rounded-lg bg-white/5 border border-white/5">
<div className="flex items-center justify-between mb-1">
<div className="text-[10px] font-medium text-slate-300">Motion Blur</div>
<div className="text-[10px] font-medium text-slate-300">
{t("effects.motionBlur")}
</div>
<span className="text-[10px] text-slate-500 font-mono">
{motionBlurAmount === 0 ? "off" : motionBlurAmount.toFixed(2)}
{motionBlurAmount === 0 ? t("effects.off") : motionBlurAmount.toFixed(2)}
</span>
</div>
<Slider
@@ -666,7 +942,9 @@ export function SettingsPanel({
</div>
<div className="p-2 rounded-lg bg-white/5 border border-white/5">
<div className="flex items-center justify-between mb-1">
<div className="text-[10px] font-medium text-slate-300">Shadow</div>
<div className="text-[10px] font-medium text-slate-300">
{t("effects.shadow")}
</div>
<span className="text-[10px] text-slate-500 font-mono">
{Math.round(shadowIntensity * 100)}%
</span>
@@ -683,7 +961,9 @@ export function SettingsPanel({
</div>
<div className="p-2 rounded-lg bg-white/5 border border-white/5">
<div className="flex items-center justify-between mb-1">
<div className="text-[10px] font-medium text-slate-300">Roundness</div>
<div className="text-[10px] font-medium text-slate-300">
{t("effects.roundness")}
</div>
<span className="text-[10px] text-slate-500 font-mono">{borderRadius}px</span>
</div>
<Slider
@@ -696,18 +976,25 @@ export function SettingsPanel({
className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3"
/>
</div>
<div className="p-2 rounded-lg bg-white/5 border border-white/5">
<div
className={`p-2 rounded-lg bg-white/5 border border-white/5 ${webcamLayoutPreset === "vertical-stack" ? "opacity-40 pointer-events-none" : ""}`}
>
<div className="flex items-center justify-between mb-1">
<div className="text-[10px] font-medium text-slate-300">Padding</div>
<span className="text-[10px] text-slate-500 font-mono">{padding}%</span>
<div className="text-[10px] font-medium text-slate-300">
{t("effects.padding")}
</div>
<span className="text-[10px] text-slate-500 font-mono">
{webcamLayoutPreset === "vertical-stack" ? "—" : `${padding}%`}
</span>
</div>
<Slider
value={[padding]}
value={[webcamLayoutPreset === "vertical-stack" ? 0 : padding]}
onValueChange={(values) => onPaddingChange?.(values[0])}
onValueCommit={() => onPaddingCommit?.()}
min={0}
max={100}
step={1}
disabled={webcamLayoutPreset === "vertical-stack"}
className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3"
/>
</div>
@@ -719,7 +1006,7 @@ export function SettingsPanel({
className="w-full mt-2 gap-1.5 bg-white/5 text-slate-200 border-white/10 hover:bg-white/10 hover:border-white/20 hover:text-white text-[10px] h-8 transition-all"
>
<Crop className="w-3 h-3" />
Crop Video
{t("crop.cropVideo")}
</Button>
</AccordionContent>
</AccordionItem>
@@ -731,7 +1018,7 @@ export function SettingsPanel({
<AccordionTrigger className="py-2.5 hover:no-underline">
<div className="flex items-center gap-2">
<Palette className="w-4 h-4 text-[#34B27B]" />
<span className="text-xs font-medium">Background</span>
<span className="text-xs font-medium">{t("background.title")}</span>
</div>
</AccordionTrigger>
<AccordionContent className="pb-3">
@@ -741,19 +1028,19 @@ export function SettingsPanel({
value="image"
className="data-[state=active]:bg-[#34B27B] data-[state=active]:text-white text-slate-400 text-[10px] py-1 rounded-md transition-all"
>
Image
{t("background.image")}
</TabsTrigger>
<TabsTrigger
value="color"
className="data-[state=active]:bg-[#34B27B] data-[state=active]:text-white text-slate-400 text-[10px] py-1 rounded-md transition-all"
>
Color
{t("background.color")}
</TabsTrigger>
<TabsTrigger
value="gradient"
className="data-[state=active]:bg-[#34B27B] data-[state=active]:text-white text-slate-400 text-[10px] py-1 rounded-md transition-all"
>
Gradient
{t("background.gradient")}
</TabsTrigger>
</TabsList>
@@ -772,7 +1059,7 @@ export function SettingsPanel({
className="w-full gap-2 bg-white/5 text-slate-200 border-white/10 hover:bg-[#34B27B] hover:text-white hover:border-[#34B27B] transition-all h-7 text-[10px]"
>
<Upload className="w-3 h-3" />
Upload Custom
{t("background.uploadCustom")}
</Button>
<div className="grid grid-cols-7 gap-1.5">
@@ -873,7 +1160,9 @@ export function SettingsPanel({
: "border-white/10 hover:border-[#34B27B]/40 opacity-80 hover:opacity-100 bg-white/5",
)}
style={{ background: g }}
aria-label={`Gradient ${idx + 1}`}
aria-label={t("background.gradientLabel", {
index: idx + 1,
})}
onClick={() => {
setGradient(g);
onWallpaperChange(g);
@@ -899,10 +1188,8 @@ export function SettingsPanel({
<div className="fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 z-[60] bg-[#09090b] rounded-2xl shadow-2xl border border-white/10 p-8 w-[90vw] max-w-5xl max-h-[90vh] overflow-auto animate-in zoom-in-95 duration-200">
<div className="flex items-center justify-between mb-6">
<div>
<span className="text-xl font-bold text-slate-200">Crop Video</span>
<p className="text-sm text-slate-400 mt-2">
Drag on each side to adjust the crop area
</p>
<span className="text-xl font-bold text-slate-200">{t("crop.cropVideo")}</span>
<p className="text-sm text-slate-400 mt-2">{t("crop.dragInstruction")}</p>
</div>
<Button
variant="ghost"
@@ -944,7 +1231,7 @@ export function SettingsPanel({
<div className="flex flex-col gap-1">
<label className="text-[10px] font-medium text-slate-400 uppercase tracking-wider">
Ratio
{t("crop.ratio")}
</label>
<div className="flex items-center gap-1.5">
<select
@@ -953,7 +1240,7 @@ export function SettingsPanel({
className="h-8 rounded-md border border-white/10 bg-[#1a1a1f] px-2 text-xs text-slate-200 outline-none focus:border-[#34B27B]/50 cursor-pointer"
>
<option value="" className="bg-[#1a1a1f] text-slate-200">
Free
{t("crop.free")}
</option>
<option value="16:9" className="bg-[#1a1a1f] text-slate-200">
16:9
@@ -983,7 +1270,9 @@ export function SettingsPanel({
? "border-[#34B27B]/50 bg-[#34B27B]/10 text-[#34B27B]"
: "border-white/10 bg-white/5 text-slate-400 hover:text-slate-200",
)}
title={cropAspectLocked ? "Unlock aspect ratio" : "Lock aspect ratio"}
title={
cropAspectLocked ? t("crop.unlockAspectRatio") : t("crop.lockAspectRatio")
}
>
{cropAspectLocked ? (
<Lock className="w-3.5 h-3.5" />
@@ -1005,7 +1294,7 @@ export function SettingsPanel({
size="lg"
className="bg-[#34B27B] hover:bg-[#34B27B]/90 text-white"
>
Done
{t("crop.done")}
</Button>
</div>
</div>
@@ -1025,7 +1314,7 @@ export function SettingsPanel({
)}
>
<Film className="w-3.5 h-3.5" />
MP4
{t("exportFormat.mp4")}
</button>
<button
data-testid={getTestId("gif-format-button")}
@@ -1038,7 +1327,7 @@ export function SettingsPanel({
)}
>
<Image className="w-3.5 h-3.5" />
GIF
{t("exportFormat.gif")}
</button>
</div>
@@ -1053,7 +1342,7 @@ export function SettingsPanel({
: "text-slate-400 hover:text-slate-200",
)}
>
Low
{t("exportQuality.low")}
</button>
<button
onClick={() => onExportQualityChange?.("good")}
@@ -1064,7 +1353,7 @@ export function SettingsPanel({
: "text-slate-400 hover:text-slate-200",
)}
>
Medium
{t("exportQuality.medium")}
</button>
<button
onClick={() => onExportQualityChange?.("source")}
@@ -1075,7 +1364,7 @@ export function SettingsPanel({
: "text-slate-400 hover:text-slate-200",
)}
>
High
{t("exportQuality.high")}
</button>
</div>
)}
@@ -1122,7 +1411,7 @@ export function SettingsPanel({
{gifOutputDimensions.width} × {gifOutputDimensions.height}px
</span>
<div className="flex items-center gap-2">
<span className="text-[10px] text-slate-400">Loop</span>
<span className="text-[10px] text-slate-400">{t("gifSettings.loop")}</span>
<Switch
checked={gifLoop}
onCheckedChange={onGifLoopChange}
@@ -1133,27 +1422,6 @@ export function SettingsPanel({
</div>
)}
<div className="grid grid-cols-2 gap-2 mb-2">
<Button
type="button"
variant="outline"
onClick={onLoadProject}
className="h-8 text-[10px] font-medium gap-1.5 bg-white/5 border-white/10 text-slate-300 hover:bg-white/10"
>
<FolderOpen className="w-3.5 h-3.5" />
Load Project
</Button>
<Button
type="button"
variant="outline"
onClick={onSaveProject}
className="h-8 text-[10px] font-medium gap-1.5 bg-white/5 border-white/10 text-slate-300 hover:bg-white/10"
>
<Save className="w-3.5 h-3.5" />
Save Project
</Button>
</div>
{unsavedExport && (
<Button
type="button"
@@ -1162,7 +1430,7 @@ export function SettingsPanel({
className="w-full mb-2 py-5 text-sm font-semibold flex items-center justify-center gap-2 bg-indigo-500 text-white rounded-xl shadow-lg shadow-indigo-500/20 hover:bg-indigo-500/90 hover:scale-[1.02] active:scale-[0.98] transition-all duration-200"
>
<Download className="w-4 h-4" />
Choose Save Location
{t("export.chooseSaveLocation")}
</Button>
)}
<Button
@@ -1173,7 +1441,7 @@ export function SettingsPanel({
className="w-full py-5 text-sm font-semibold flex items-center justify-center gap-2 bg-[#34B27B] text-white rounded-xl shadow-lg shadow-[#34B27B]/20 hover:bg-[#34B27B]/90 hover:scale-[1.02] active:scale-[0.98] transition-all duration-200"
>
<Download className="w-4 h-4" />
Export {exportFormat === "gif" ? "GIF" : "Video"}
{exportFormat === "gif" ? t("export.gifButton") : t("export.videoButton")}
</Button>
<div className="flex gap-2 mt-3">
@@ -1187,7 +1455,7 @@ export function SettingsPanel({
className="flex-1 flex items-center justify-center gap-1.5 text-[10px] text-slate-500 hover:text-slate-300 py-1.5 transition-colors"
>
<Bug className="w-3 h-3 text-[#34B27B]" />
Report Bug
{t("links.reportBug")}
</button>
<button
type="button"
@@ -1197,7 +1465,7 @@ export function SettingsPanel({
className="flex-1 flex items-center justify-center gap-1.5 text-[10px] text-slate-500 hover:text-slate-300 py-1.5 transition-colors"
>
<Star className="w-3 h-3 text-yellow-400" />
Star on GitHub
{t("links.starOnGithub")}
</button>
</div>
</div>
@@ -9,6 +9,7 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { useScopedT } from "@/contexts/I18nContext";
import { useShortcuts } from "@/contexts/ShortcutsContext";
import {
DEFAULT_SHORTCUTS,
@@ -16,7 +17,6 @@ import {
findConflict,
formatBinding,
SHORTCUT_ACTIONS,
SHORTCUT_LABELS,
type ShortcutAction,
type ShortcutBinding,
type ShortcutConflict,
@@ -28,6 +28,8 @@ const MODIFIER_KEYS = new Set(["Control", "Shift", "Alt", "Meta"]);
export function ShortcutsConfigDialog() {
const { shortcuts, isMac, isConfigOpen, closeConfig, setShortcuts, persistShortcuts } =
useShortcuts();
const t = useScopedT("shortcuts");
const tc = useScopedT("common");
const [draft, setDraft] = useState<ShortcutsConfig>(shortcuts);
const [captureFor, setCaptureFor] = useState<ShortcutAction | null>(null);
@@ -70,7 +72,7 @@ export function ShortcutsConfigDialog() {
setCaptureFor(null);
if (found?.type === "fixed") {
toast.error(`This shortcut is reserved for "${found.label}" and cannot be reassigned.`);
toast.error(t("reservedShortcut", { label: found.label }));
return;
}
@@ -84,7 +86,7 @@ export function ShortcutsConfigDialog() {
window.addEventListener("keydown", handleCapture, { capture: true });
return () => window.removeEventListener("keydown", handleCapture, { capture: true });
}, [captureFor, draft]);
}, [captureFor, draft, t]);
const handleSwap = useCallback(() => {
if (!conflict || conflict.conflictWith.type !== "configurable") return;
@@ -102,14 +104,14 @@ export function ShortcutsConfigDialog() {
const handleSave = useCallback(async () => {
setShortcuts(draft);
await persistShortcuts(draft);
toast.success("Keyboard shortcuts saved");
toast.success(t("savedToast"));
closeConfig();
}, [draft, setShortcuts, persistShortcuts, closeConfig]);
}, [draft, setShortcuts, persistShortcuts, closeConfig, t]);
const handleReset = useCallback(() => {
setDraft({ ...DEFAULT_SHORTCUTS });
toast.info("Reset to default shortcuts — click Save to apply");
}, []);
toast.info(t("resetToast"));
}, [t]);
const handleClose = useCallback(() => {
setCaptureFor(null);
@@ -128,13 +130,13 @@ export function ShortcutsConfigDialog() {
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-sm">
<Keyboard className="w-4 h-4 text-[#34B27B]" />
Keyboard Shortcuts
{t("title")}
</DialogTitle>
</DialogHeader>
<div className="space-y-0.5">
<p className="text-[10px] text-slate-500 mb-2 uppercase tracking-wide font-semibold">
Configurable
{t("configurable")}
</p>
{SHORTCUT_ACTIONS.map((action) => {
const isCapturing = captureFor === action;
@@ -142,14 +144,14 @@ export function ShortcutsConfigDialog() {
return (
<div key={action}>
<div className="flex items-center justify-between py-1.5 px-1 border-b border-white/5">
<span className="text-sm text-slate-300">{SHORTCUT_LABELS[action]}</span>
<span className="text-sm text-slate-300">{t(`actions.${action}`)}</span>
<button
type="button"
onClick={() => {
setConflict(null);
setCaptureFor(isCapturing ? null : action);
}}
title={isCapturing ? "Press Esc to cancel" : "Click to change"}
title={isCapturing ? t("pressEscToCancel") : t("clickToChange")}
className={[
"px-2 py-1 rounded text-xs font-mono border transition-all min-w-[90px] text-center select-none",
isCapturing
@@ -159,14 +161,14 @@ export function ShortcutsConfigDialog() {
: "bg-white/5 border-white/10 text-slate-200 hover:border-[#34B27B]/50 hover:text-[#34B27B] cursor-pointer",
].join(" ")}
>
{isCapturing ? "Press a key…" : formatBinding(draft[action], isMac)}
{isCapturing ? t("pressKey") : formatBinding(draft[action], isMac)}
</button>
</div>
{hasConflict && conflict?.conflictWith.type === "configurable" && (
<div className="flex items-center justify-between px-1 py-1.5 mb-0.5 bg-amber-500/10 border border-amber-500/20 rounded text-xs">
<span className="text-amber-400">
⚠ Already used by{" "}
<strong>{SHORTCUT_LABELS[conflict.conflictWith.action]}</strong>
⚠{" "}
{t("alreadyUsedBy", { action: t(`actions.${conflict.conflictWith.action}`) })}
</span>
<div className="flex gap-1.5">
<button
@@ -174,14 +176,14 @@ export function ShortcutsConfigDialog() {
onClick={handleSwap}
className="px-2 py-0.5 bg-amber-500/20 hover:bg-amber-500/30 border border-amber-500/40 rounded text-amber-300 font-medium transition-colors"
>
Swap
{t("swap")}
</button>
<button
type="button"
onClick={handleCancelConflict}
className="px-2 py-0.5 bg-white/5 hover:bg-white/10 border border-white/10 rounded text-slate-400 transition-colors"
>
Cancel
{tc("actions.cancel")}
</button>
</div>
</div>
@@ -193,14 +195,16 @@ export function ShortcutsConfigDialog() {
<div className="space-y-0.5 mt-2">
<p className="text-[10px] text-slate-500 mb-2 uppercase tracking-wide font-semibold">
Fixed
{t("fixed")}
</p>
{FIXED_SHORTCUTS.map(({ label, display }) => (
{FIXED_SHORTCUTS.map(({ i18nKey, label, display }) => (
<div
key={label}
key={i18nKey}
className="flex items-center justify-between py-1.5 px-1 border-b border-white/5 last:border-0"
>
<span className="text-sm text-slate-400">{label}</span>
<span className="text-sm text-slate-400">
{t(`fixedActions.${i18nKey}`, { defaultValue: label })}
</span>
<kbd className="px-2 py-1 bg-white/5 border border-white/10 rounded text-xs font-mono text-slate-400 min-w-[90px] text-center">
{display}
</kbd>
@@ -208,10 +212,7 @@ export function ShortcutsConfigDialog() {
))}
</div>
<p className="text-[10px] text-slate-500 mt-1">
Click a shortcut then press the new key combination. Press{" "}
<span className="font-mono border border-white/10 rounded px-1">Esc</span> to cancel.
</p>
<p className="text-[10px] text-slate-500 mt-1">{t("helpText")}</p>
<DialogFooter className="flex gap-2 sm:justify-between mt-2">
<Button
@@ -221,18 +222,18 @@ export function ShortcutsConfigDialog() {
onClick={handleReset}
>
<RotateCcw className="w-3 h-3" />
Reset to defaults
{t("resetToDefaults")}
</Button>
<div className="flex gap-2">
<Button variant="ghost" size="sm" onClick={handleClose}>
Cancel
{tc("actions.cancel")}
</Button>
<Button
size="sm"
className="bg-[#34B27B] hover:bg-[#2d9e6c] text-white"
onClick={handleSave}
>
Save
{tc("actions.save")}
</Button>
</div>
</DialogFooter>
+33 -25
View File
@@ -8,8 +8,10 @@ import {
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { useScopedT } from "@/contexts/I18nContext";
export function TutorialHelp() {
const t = useScopedT("dialogs");
return (
<Dialog>
<DialogTrigger asChild>
@@ -19,33 +21,33 @@ export function TutorialHelp() {
className="h-7 px-2 text-xs text-slate-400 hover:text-slate-200 hover:bg-white/10 transition-all gap-1.5"
>
<HelpCircle className="w-3.5 h-3.5" />
<span className="font-medium">How trimming works</span>
<span className="font-medium">{t("tutorial.triggerLabel")}</span>
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl bg-[#09090b] border-white/10 [&>button]:text-slate-400 [&>button:hover]:text-white">
<DialogHeader>
<DialogTitle className="text-xl font-semibold text-slate-200 flex items-center gap-2">
<Scissors className="w-5 h-5 text-[#ef4444]" /> How Trimming Works
<Scissors className="w-5 h-5 text-[#ef4444]" /> {t("tutorial.title")}
</DialogTitle>
<DialogDescription className="text-slate-400">
Understanding how to cut out unwanted parts of your video.
{t("tutorial.description")}
</DialogDescription>
</DialogHeader>
<div className="mt-4 space-y-8">
{/* Explanation */}
<div className="bg-white/5 rounded-lg p-4 border border-white/5">
<p className="text-slate-300 leading-relaxed">
The Trim tool works by defining the segments you want to
<span className="text-[#ef4444] font-bold"> remove</span>. Any part of the timeline
that is
<span className="text-[#ef4444] font-bold"> covered</span> by a red trim segment will
be cut out when you export.
{t("tutorial.explanationBefore")}
<span className="text-[#ef4444] font-bold"> {t("tutorial.remove")}</span>
{t("tutorial.explanationMiddle")}
<span className="text-[#ef4444] font-bold"> {t("tutorial.covered")}</span>
{t("tutorial.explanationAfter")}
</p>
</div>
{/* Visual Illustration */}
<div className="space-y-2">
<h3 className="text-sm font-medium text-slate-400 uppercase tracking-wider">
Visual Example
{t("tutorial.visualExample")}
</h3>
<div className="relative h-24 bg-[#000] rounded-lg border border-white/10 flex items-center px-4 overflow-hidden select-none">
{/* Background track (Kept parts) */}
@@ -58,7 +60,7 @@ export function TutorialHelp() {
style={{ width: "20%" }}
>
<span className="text-[10px] font-bold text-[#ef4444] bg-black/50 px-1 rounded">
REMOVED
{t("tutorial.removed")}
</span>
</div>
{/* Removed Segment 2 */}
@@ -67,13 +69,19 @@ export function TutorialHelp() {
style={{ width: "15%" }}
>
<span className="text-[10px] font-bold text-[#ef4444] bg-black/50 px-1 rounded">
REMOVED
{t("tutorial.removed")}
</span>
</div>
{/* Labels for kept parts */}
<div className="absolute left-[5%] text-[10px] text-slate-400 font-medium">Kept</div>
<div className="absolute left-[50%] text-[10px] text-slate-400 font-medium">Kept</div>
<div className="absolute left-[90%] text-[10px] text-slate-400 font-medium">Kept</div>
<div className="absolute left-[5%] text-[10px] text-slate-400 font-medium">
{t("tutorial.kept")}
</div>
<div className="absolute left-[50%] text-[10px] text-slate-400 font-medium">
{t("tutorial.kept")}
</div>
<div className="absolute left-[90%] text-[10px] text-slate-400 font-medium">
{t("tutorial.kept")}
</div>
</div>
<div className="flex justify-center mt-2">
<ArrowRight className="w-4 h-4 text-slate-600 rotate-90" />
@@ -84,38 +92,38 @@ export function TutorialHelp() {
className="h-8 bg-slate-700 rounded flex items-center justify-center opacity-80"
style={{ width: "30%" }}
>
<span className="text-[10px] text-white font-medium">Part 1</span>
<span className="text-[10px] text-white font-medium">{t("tutorial.part1")}</span>
</div>
<div
className="h-8 bg-slate-700 rounded flex items-center justify-center opacity-80"
style={{ width: "30%" }}
>
<span className="text-[10px] text-white font-medium">Part 2</span>
<span className="text-[10px] text-white font-medium">{t("tutorial.part2")}</span>
</div>
<div
className="h-8 bg-slate-700 rounded flex items-center justify-center opacity-80"
style={{ width: "30%" }}
>
<span className="text-[10px] text-white font-medium">Part 3</span>
<span className="text-[10px] text-white font-medium">{t("tutorial.part3")}</span>
</div>
<span className="absolute right-4 text-xs text-slate-400">Final Video</span>
<span className="absolute right-4 text-xs text-slate-400">
{t("tutorial.finalVideo")}
</span>
</div>
</div>
{/* Steps */}
<div className="grid grid-cols-2 gap-4">
<div className="p-3 rounded bg-white/5 border border-white/5">
<div className="text-[#ef4444] font-bold mb-1">1. Add Trim</div>
<div className="text-[#ef4444] font-bold mb-1">{t("tutorial.step1Title")}</div>
<p className="text-xs text-slate-400">
Press
{t("tutorial.step1DescriptionBefore")}
<kbd className="bg-white/10 px-1 rounded text-slate-300">T</kbd>
or click the scissors icon to mark a section for removal.
{t("tutorial.step1DescriptionAfter")}
</p>
</div>
<div className="p-3 rounded bg-white/5 border border-white/5">
<div className="text-[#ef4444] font-bold mb-1">2. Adjust</div>
<p className="text-xs text-slate-400">
Drag the edges of the red region to cover exactly what you want to cut out.
</p>
<div className="text-[#ef4444] font-bold mb-1">{t("tutorial.step2Title")}</div>
<p className="text-xs text-slate-400">{t("tutorial.step2Description")}</p>
</div>
</div>
</div>
File diff suppressed because it is too large Load Diff
+318 -46
View File
@@ -24,7 +24,9 @@ import {
type Size,
type StyledRenderRect,
type WebcamLayoutPreset,
type WebcamSizePreset,
} from "@/lib/compositeLayout";
import { getCssClipPath } from "@/lib/webcamMaskShapes";
import {
type AspectRatio,
formatAspectRatioForCSS,
@@ -33,6 +35,7 @@ import {
import { AnnotationOverlay } from "./AnnotationOverlay";
import {
type AnnotationRegion,
type BlurData,
type SpeedRegion,
type TrimRegion,
ZOOM_DEPTH_SCALES,
@@ -41,10 +44,14 @@ import {
type ZoomRegion,
} from "./types";
import {
AUTO_FOLLOW_RAMP_DISTANCE,
AUTO_FOLLOW_SMOOTHING_FACTOR,
AUTO_FOLLOW_SMOOTHING_FACTOR_MAX,
DEFAULT_FOCUS,
ZOOM_SCALE_DEADZONE,
ZOOM_TRANSLATION_DEADZONE_PX,
} from "./videoPlayback/constants";
import { adaptiveSmoothFactor, smoothCursorFocus } from "./videoPlayback/cursorFollowUtils";
import { clampFocusToStage as clampFocusToStageUtil } from "./videoPlayback/focusUtils";
import { layoutVideoContent as layoutVideoContentUtil } from "./videoPlayback/layoutUtils";
import { clamp01 } from "./videoPlayback/mathUtils";
@@ -63,6 +70,11 @@ interface VideoPlaybackProps {
videoPath: string;
webcamVideoPath?: string;
webcamLayoutPreset: WebcamLayoutPreset;
webcamMaskShape?: import("./types").WebcamMaskShape;
webcamSizePreset?: WebcamSizePreset;
webcamPosition?: { cx: number; cy: number } | null;
onWebcamPositionChange?: (position: { cx: number; cy: number }) => void;
onWebcamPositionDragEnd?: () => void;
onDurationChange: (duration: number) => void;
onTimeUpdate: (time: number) => void;
currentTime: number;
@@ -90,6 +102,14 @@ interface VideoPlaybackProps {
onSelectAnnotation?: (id: string | null) => void;
onAnnotationPositionChange?: (id: string, position: { x: number; y: number }) => void;
onAnnotationSizeChange?: (id: string, size: { width: number; height: number }) => void;
blurRegions?: AnnotationRegion[];
selectedBlurId?: string | null;
onSelectBlur?: (id: string | null) => void;
onBlurPositionChange?: (id: string, position: { x: number; y: number }) => void;
onBlurSizeChange?: (id: string, size: { width: number; height: number }) => void;
onBlurDataChange?: (id: string, blurData: BlurData) => void;
onBlurDataCommit?: () => void;
cursorTelemetry?: import("./types").CursorTelemetryPoint[];
}
export interface VideoPlaybackRef {
@@ -108,6 +128,11 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
videoPath,
webcamVideoPath,
webcamLayoutPreset,
webcamMaskShape,
webcamSizePreset,
webcamPosition,
onWebcamPositionChange,
onWebcamPositionDragEnd,
onDurationChange,
onTimeUpdate,
currentTime,
@@ -135,6 +160,14 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
onSelectAnnotation,
onAnnotationPositionChange,
onAnnotationSizeChange,
blurRegions = [],
selectedBlurId,
onSelectBlur,
onBlurPositionChange,
onBlurSizeChange,
onBlurDataChange,
onBlurDataCommit,
cursorTelemetry = [],
},
ref,
) => {
@@ -148,12 +181,15 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
const timeUpdateAnimationRef = useRef<number | null>(null);
const [pixiReady, setPixiReady] = useState(false);
const [videoReady, setVideoReady] = useState(false);
const [overlaySize, setOverlaySize] = useState({ width: 800, height: 600 });
const [overlayElement, setOverlayElement] = useState<HTMLDivElement | null>(null);
const overlayRef = useRef<HTMLDivElement | null>(null);
const focusIndicatorRef = useRef<HTMLDivElement | null>(null);
const [webcamLayout, setWebcamLayout] = useState<StyledRenderRect | null>(null);
const [webcamDimensions, setWebcamDimensions] = useState<Size | null>(null);
const currentTimeRef = useRef(0);
const zoomRegionsRef = useRef<ZoomRegion[]>([]);
const cursorTelemetryRef = useRef<import("./types").CursorTelemetryPoint[]>([]);
const selectedZoomIdRef = useRef<string | null>(null);
const animationStateRef = useRef({
scale: 1,
@@ -167,6 +203,8 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
const blurFilterRef = useRef<BlurFilter | null>(null);
const motionBlurFilterRef = useRef<MotionBlurFilter | null>(null);
const isDraggingFocusRef = useRef(false);
const isDraggingWebcamRef = useRef(false);
const webcamDragOffsetRef = useRef({ dx: 0, dy: 0 });
const stageSizeRef = useRef({ width: 0, height: 0 });
const videoSizeRef = useRef({ width: 0, height: 0 });
const baseScaleRef = useRef(1);
@@ -177,7 +215,10 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
const isPlayingRef = useRef(isPlaying);
const isSeekingRef = useRef(false);
const allowPlaybackRef = useRef(false);
const lockedVideoDimensionsRef = useRef<{ width: number; height: number } | null>(null);
const lockedVideoDimensionsRef = useRef<{
width: number;
height: number;
} | null>(null);
const layoutVideoContentRef = useRef<(() => void) | null>(null);
const trimRegionsRef = useRef<TrimRegion[]>([]);
const speedRegionsRef = useRef<SpeedRegion[]>([]);
@@ -186,6 +227,8 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
const onTimeUpdateRef = useRef(onTimeUpdate);
const onPlayStateChangeRef = useRef(onPlayStateChange);
const videoReadyRafRef = useRef<number | null>(null);
const smoothedAutoFocusRef = useRef<ZoomFocus | null>(null);
const prevTargetProgressRef = useRef(0);
const clampFocusToStage = useCallback((focus: ZoomFocus, depth: ZoomDepth) => {
return clampFocusToStageUtil(focus, depth, stageSizeRef.current);
@@ -263,6 +306,9 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
padding,
webcamDimensions,
webcamLayoutPreset,
webcamSizePreset,
webcamPosition,
webcamMaskShape,
});
if (result) {
@@ -292,12 +338,20 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
padding,
webcamDimensions,
webcamLayoutPreset,
webcamSizePreset,
webcamPosition,
webcamMaskShape,
]);
useEffect(() => {
layoutVideoContentRef.current = layoutVideoContent;
}, [layoutVideoContent]);
const setOverlayRefs = useCallback((node: HTMLDivElement | null) => {
overlayRef.current = node;
setOverlayElement(node);
}, []);
const selectedZoom = useMemo(() => {
if (!selectedZoomId) return null;
return zoomRegions.find((region) => region.id === selectedZoomId) ?? null;
@@ -369,6 +423,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
if (!regionId) return;
const region = zoomRegionsRef.current.find((r) => r.id === regionId);
if (!region) return;
if (region.focusMode === "auto") return;
onSelectZoom(region.id);
event.preventDefault();
isDraggingFocusRef.current = true;
@@ -401,10 +456,61 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
endFocusDrag(event);
};
// ── Webcam PiP drag handlers ──
const handleWebcamPointerDown = (event: React.PointerEvent<HTMLVideoElement>) => {
if (isPlayingRef.current) return;
if (webcamLayoutPreset !== "picture-in-picture") return;
event.preventDefault();
event.stopPropagation();
isDraggingWebcamRef.current = true;
event.currentTarget.setPointerCapture(event.pointerId);
const webcamEl = event.currentTarget;
const webcamRect = webcamEl.getBoundingClientRect();
webcamDragOffsetRef.current = {
dx: event.clientX - (webcamRect.left + webcamRect.width / 2),
dy: event.clientY - (webcamRect.top + webcamRect.height / 2),
};
};
const handleWebcamPointerMove = (event: React.PointerEvent<HTMLVideoElement>) => {
if (!isDraggingWebcamRef.current) return;
event.preventDefault();
event.stopPropagation();
const containerEl = containerRef.current;
if (!containerEl || !onWebcamPositionChange) return;
const containerRect = containerEl.getBoundingClientRect();
const cx = clamp01(
(event.clientX - webcamDragOffsetRef.current.dx - containerRect.left) / containerRect.width,
);
const cy = clamp01(
(event.clientY - webcamDragOffsetRef.current.dy - containerRect.top) / containerRect.height,
);
onWebcamPositionChange({ cx, cy });
};
const handleWebcamPointerUp = (event: React.PointerEvent<HTMLVideoElement>) => {
if (!isDraggingWebcamRef.current) return;
isDraggingWebcamRef.current = false;
try {
event.currentTarget.releasePointerCapture(event.pointerId);
} catch {
// Pointer may already be released.
}
onWebcamPositionDragEnd?.();
};
useEffect(() => {
zoomRegionsRef.current = zoomRegions;
}, [zoomRegions]);
useEffect(() => {
cursorTelemetryRef.current = cursorTelemetry;
}, [cursorTelemetry]);
useEffect(() => {
selectedZoomIdRef.current = selectedZoomId;
}, [selectedZoomId]);
@@ -539,7 +645,8 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
}, [selectedZoom, pixiReady, videoReady, updateOverlayForRegion]);
useEffect(() => {
const overlayEl = overlayRef.current;
if (!pixiReady || !videoReady) return;
const overlayEl = overlayElement;
if (!overlayEl) return;
if (!selectedZoom) {
overlayEl.style.cursor = "default";
@@ -548,7 +655,34 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
}
overlayEl.style.cursor = isPlaying ? "not-allowed" : "grab";
overlayEl.style.pointerEvents = isPlaying ? "none" : "auto";
}, [selectedZoom, isPlaying]);
}, [selectedZoom, isPlaying, pixiReady, videoReady, overlayElement]);
useEffect(() => {
const overlayEl = overlayElement;
if (!overlayEl) return;
const updateOverlaySize = () => {
const width = overlayEl.clientWidth || 800;
const height = overlayEl.clientHeight || 600;
setOverlaySize((prev) => {
if (prev.width === width && prev.height === height) return prev;
return { width, height };
});
};
updateOverlaySize();
if (typeof ResizeObserver !== "undefined") {
const observer = new ResizeObserver(() => {
updateOverlaySize();
});
observer.observe(overlayEl);
return () => observer.disconnect();
}
window.addEventListener("resize", updateOverlaySize);
return () => window.removeEventListener("resize", updateOverlaySize);
}, [overlayElement]);
useEffect(() => {
const container = containerRef.current;
@@ -572,7 +706,11 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
app.ticker.maxFPS = 60;
if (!mounted) {
app.destroy(true, { children: true, texture: true, textureSource: true });
app.destroy(true, {
children: true,
texture: true,
textureSource: true,
});
return;
}
@@ -596,7 +734,11 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
mounted = false;
setPixiReady(false);
if (app && app.renderer) {
app.destroy(true, { children: true, texture: true, textureSource: true });
app.destroy(true, {
children: true,
texture: true,
textureSource: true,
});
}
appRef.current = null;
cameraContainerRef.current = null;
@@ -773,10 +915,23 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
};
const ticker = () => {
const bm = baseMaskRef.current;
const ss = stageSizeRef.current;
const viewportRatio =
bm.width > 0 && bm.height > 0
? {
widthRatio: ss.width / bm.width,
heightRatio: ss.height / bm.height,
}
: undefined;
const { region, strength, blendedScale, transition } = findDominantRegion(
zoomRegionsRef.current,
currentTimeRef.current,
{ connectZooms: true },
{
connectZooms: true,
cursorTelemetry: cursorTelemetryRef.current,
viewportRatio,
},
);
const defaultFocus = DEFAULT_FOCUS;
@@ -797,6 +952,47 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
targetFocus = regionFocus;
targetProgress = strength;
// Apply adaptive smoothing for auto-follow mode
if (region.focusMode === "auto" && !transition) {
const raw = targetFocus;
const isZoomingIn =
targetProgress < 0.999 && targetProgress >= prevTargetProgressRef.current;
if (targetProgress >= 0.999) {
// Full zoom: adaptive smoothing — moves faster when far, decelerates when close
const prev = smoothedAutoFocusRef.current ?? raw;
const factor = adaptiveSmoothFactor(
raw,
prev,
AUTO_FOLLOW_SMOOTHING_FACTOR,
AUTO_FOLLOW_SMOOTHING_FACTOR_MAX,
AUTO_FOLLOW_RAMP_DISTANCE,
);
const smoothed = smoothCursorFocus(raw, prev, factor);
smoothedAutoFocusRef.current = smoothed;
targetFocus = smoothed;
} else if (isZoomingIn) {
// Zoom-in: track cursor directly so zoom always aims at current cursor
// position; keep ref in sync to avoid snap when full-zoom begins
smoothedAutoFocusRef.current = raw;
} else {
// Zoom-out: keep smoothing for continuity — avoids snap at zoom-out start
const prev = smoothedAutoFocusRef.current ?? raw;
const factor = adaptiveSmoothFactor(
raw,
prev,
AUTO_FOLLOW_SMOOTHING_FACTOR,
AUTO_FOLLOW_SMOOTHING_FACTOR_MAX,
AUTO_FOLLOW_RAMP_DISTANCE,
);
const smoothed = smoothCursorFocus(raw, prev, factor);
smoothedAutoFocusRef.current = smoothed;
targetFocus = smoothed;
}
} else if (region.focusMode !== "auto") {
smoothedAutoFocusRef.current = null;
}
prevTargetProgressRef.current = targetProgress;
// Handle connected zoom transitions (pan between adjacent zoom regions)
if (transition) {
const startTransform = computeZoomTransform({
@@ -1097,31 +1293,51 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
: "none",
}}
/>
{webcamVideoPath && (
<video
ref={webcamVideoRef}
src={webcamVideoPath}
className="absolute object-cover pointer-events-none"
style={{
left: webcamLayout?.x ?? 0,
top: webcamLayout?.y ?? 0,
width: webcamLayout?.width ?? 0,
height: webcamLayout?.height ?? 0,
borderRadius: webcamLayout?.borderRadius ?? 0,
boxShadow: webcamCssBoxShadow,
zIndex: 20,
opacity: webcamLayout ? 1 : 0,
backgroundColor: "#000",
}}
muted
preload="metadata"
playsInline
/>
)}
{webcamVideoPath &&
(() => {
const clipPath = getCssClipPath(webcamLayout?.maskShape ?? "rectangle");
const useClipPath = !!clipPath;
return (
<div
className="absolute"
style={{
left: webcamLayout?.x ?? 0,
top: webcamLayout?.y ?? 0,
width: webcamLayout?.width ?? 0,
height: webcamLayout?.height ?? 0,
zIndex: 20,
opacity: webcamLayout ? 1 : 0,
filter:
useClipPath && webcamCssBoxShadow !== "none"
? `drop-shadow(${webcamCssBoxShadow})`
: undefined,
}}
>
<video
ref={webcamVideoRef}
src={webcamVideoPath}
className={`w-full h-full object-cover ${webcamLayoutPreset === "picture-in-picture" ? "cursor-grab active:cursor-grabbing" : "pointer-events-none"}`}
style={{
borderRadius: useClipPath ? 0 : (webcamLayout?.borderRadius ?? 0),
clipPath: clipPath ?? undefined,
boxShadow: useClipPath ? "none" : webcamCssBoxShadow,
backgroundColor: "#000",
}}
onPointerDown={handleWebcamPointerDown}
onPointerMove={handleWebcamPointerMove}
onPointerUp={handleWebcamPointerUp}
onPointerLeave={handleWebcamPointerUp}
muted
preload="metadata"
playsInline
/>
</div>
);
})()}
{/* Only render overlay after PIXI and video are fully initialized */}
{pixiReady && videoReady && (
<div
ref={overlayRef}
ref={setOverlayRefs}
className="absolute inset-0 select-none"
style={{ pointerEvents: "none", zIndex: 30 }}
onPointerDown={handleOverlayPointerDown}
@@ -1135,7 +1351,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
style={{ display: "none", pointerEvents: "none" }}
/>
{(() => {
const filtered = (annotationRegions || []).filter((annotation) => {
const filteredAnnotations = (annotationRegions || []).filter((annotation) => {
if (typeof annotation.startMs !== "number" || typeof annotation.endMs !== "number")
return false;
@@ -1145,37 +1361,93 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
return timeMs >= annotation.startMs && timeMs <= annotation.endMs;
});
// Sort by z-index (lowest to highest) so higher z-index renders on top
const sorted = [...filtered].sort((a, b) => a.zIndex - b.zIndex);
const filteredBlurRegions = (blurRegions || []).filter((blurRegion) => {
if (typeof blurRegion.startMs !== "number" || typeof blurRegion.endMs !== "number")
return false;
if (blurRegion.id === selectedBlurId) return true;
const timeMs = Math.round(currentTime * 1000);
return timeMs >= blurRegion.startMs && timeMs <= blurRegion.endMs;
});
const sorted = [
...filteredAnnotations.map((annotation) => ({
kind: "annotation" as const,
region: annotation,
})),
...filteredBlurRegions.map((blurRegion) => ({
kind: "blur" as const,
region: blurRegion,
})),
].sort((a, b) => a.region.zIndex - b.region.zIndex);
// Handle click-through cycling: when clicking same annotation, cycle to next
const handleAnnotationClick = (clickedId: string) => {
if (!onSelectAnnotation) return;
// If clicking on already selected annotation and there are multiple overlapping
if (clickedId === selectedAnnotationId && sorted.length > 1) {
if (clickedId === selectedAnnotationId && filteredAnnotations.length > 1) {
// Find current index and cycle to next
const currentIndex = sorted.findIndex((a) => a.id === clickedId);
const nextIndex = (currentIndex + 1) % sorted.length;
onSelectAnnotation(sorted[nextIndex].id);
const currentIndex = filteredAnnotations.findIndex((a) => a.id === clickedId);
const nextIndex = (currentIndex + 1) % filteredAnnotations.length;
onSelectAnnotation(filteredAnnotations[nextIndex].id);
} else {
// First click or clicking different annotation
onSelectAnnotation(clickedId);
}
};
return sorted.map((annotation) => (
const handleBlurClick = (clickedId: string) => {
if (!onSelectBlur) return;
if (clickedId === selectedBlurId && filteredBlurRegions.length > 1) {
const currentIndex = filteredBlurRegions.findIndex((a) => a.id === clickedId);
const nextIndex = (currentIndex + 1) % filteredBlurRegions.length;
onSelectBlur(filteredBlurRegions[nextIndex].id);
} else {
onSelectBlur(clickedId);
}
};
return sorted.map((item) => (
<AnnotationOverlay
key={annotation.id}
annotation={annotation}
isSelected={annotation.id === selectedAnnotationId}
containerWidth={overlayRef.current?.clientWidth || 800}
containerHeight={overlayRef.current?.clientHeight || 600}
onPositionChange={(id, position) => onAnnotationPositionChange?.(id, position)}
onSizeChange={(id, size) => onAnnotationSizeChange?.(id, size)}
onClick={handleAnnotationClick}
zIndex={annotation.zIndex}
isSelectedBoost={annotation.id === selectedAnnotationId}
key={
item.kind === "blur"
? `${item.region.id}-${overlaySize.width}-${overlaySize.height}-${item.region.blurData?.shape ?? "rectangle"}-${(item.region.blurData?.freehandPoints ?? []).map((p) => `${Math.round(p.x)}_${Math.round(p.y)}`).join("-")}`
: `${item.region.id}-${overlaySize.width}-${overlaySize.height}`
}
annotation={item.region}
isSelected={
item.kind === "blur"
? item.region.id === selectedBlurId
: item.region.id === selectedAnnotationId
}
containerWidth={overlaySize.width}
containerHeight={overlaySize.height}
onPositionChange={(id, position) =>
item.kind === "blur"
? onBlurPositionChange?.(id, position)
: onAnnotationPositionChange?.(id, position)
}
onSizeChange={(id, size) =>
item.kind === "blur"
? onBlurSizeChange?.(id, size)
: onAnnotationSizeChange?.(id, size)
}
onBlurDataChange={
item.kind === "blur"
? (id, blurData) => onBlurDataChange?.(id, blurData)
: undefined
}
onBlurDataCommit={item.kind === "blur" ? onBlurDataCommit : undefined}
onClick={item.kind === "blur" ? handleBlurClick : handleAnnotationClick}
zIndex={item.region.zIndex}
isSelectedBoost={
item.kind === "blur"
? item.region.id === selectedBlurId
: item.region.id === selectedAnnotationId
}
/>
));
})()}
@@ -1,6 +1,9 @@
import { describe, expect, it } from "vitest";
import {
createProjectData,
createProjectSnapshot,
hasProjectUnsavedChanges,
normalizeProjectEditor,
PROJECT_VERSION,
resolveProjectMedia,
validateProjectData,
@@ -40,6 +43,7 @@ describe("projectPersistence media compatibility", () => {
annotationRegions: [],
aspectRatio: "16:9",
webcamLayoutPreset: "picture-in-picture",
webcamMaskShape: "circle",
exportQuality: "good",
exportFormat: "mp4",
gifFrameRate: 15,
@@ -55,4 +59,47 @@ describe("projectPersistence media compatibility", () => {
});
expect(validateProjectData(project)).toBe(true);
});
it("normalizes webcam mask shape values safely", () => {
expect(normalizeProjectEditor({ webcamMaskShape: "rounded" }).webcamMaskShape).toBe("rounded");
expect(
normalizeProjectEditor({ webcamMaskShape: "not-a-real-shape" as never }).webcamMaskShape,
).toBe("rectangle");
});
});
it("creates stable snapshots for identical project state", () => {
const media = {
screenVideoPath: "/tmp/screen.webm",
webcamVideoPath: "/tmp/webcam.webm",
};
const editor = normalizeProjectEditor({
wallpaper: "/wallpapers/wallpaper1.jpg",
shadowIntensity: 0,
showBlur: false,
motionBlurAmount: 0,
borderRadius: 0,
padding: 50,
cropRegion: { x: 0, y: 0, width: 1, height: 1 },
zoomRegions: [],
trimRegions: [],
speedRegions: [],
annotationRegions: [],
aspectRatio: "16:9",
webcamLayoutPreset: "picture-in-picture",
webcamMaskShape: "circle",
exportQuality: "good",
exportFormat: "mp4",
gifFrameRate: 15,
gifLoop: true,
gifSizePreset: "medium",
});
expect(createProjectSnapshot(media, editor)).toBe(createProjectSnapshot(media, editor));
});
it("detects unsaved changes from differing snapshots", () => {
expect(hasProjectUnsavedChanges(null, null)).toBe(false);
expect(hasProjectUnsavedChanges("same", "same")).toBe(false);
expect(hasProjectUnsavedChanges("current", "baseline")).toBe(true);
});
@@ -5,21 +5,36 @@ import { ASPECT_RATIOS, type AspectRatio } from "@/utils/aspectRatioUtils";
import {
type AnnotationRegion,
type CropRegion,
clampPlaybackSpeed,
DEFAULT_ANNOTATION_POSITION,
DEFAULT_ANNOTATION_SIZE,
DEFAULT_ANNOTATION_STYLE,
DEFAULT_BLUR_DATA,
DEFAULT_BLUR_FREEHAND_POINTS,
DEFAULT_BLUR_INTENSITY,
DEFAULT_CROP_REGION,
DEFAULT_FIGURE_DATA,
DEFAULT_PLAYBACK_SPEED,
DEFAULT_WEBCAM_LAYOUT_PRESET,
DEFAULT_WEBCAM_MASK_SHAPE,
DEFAULT_WEBCAM_POSITION,
DEFAULT_WEBCAM_SIZE_PRESET,
DEFAULT_ZOOM_DEPTH,
MAX_BLUR_INTENSITY,
MAX_PLAYBACK_SPEED,
MIN_BLUR_INTENSITY,
MIN_PLAYBACK_SPEED,
type SpeedRegion,
type TrimRegion,
type WebcamLayoutPreset,
type WebcamMaskShape,
type WebcamPosition,
type WebcamSizePreset,
type ZoomRegion,
} from "./types";
const WALLPAPER_COUNT = 18;
const VALID_BLUR_SHAPES = new Set(["rectangle", "oval", "freehand"] as const);
export const WALLPAPER_PATHS = Array.from(
{ length: WALLPAPER_COUNT },
@@ -42,6 +57,9 @@ export interface ProjectEditorState {
annotationRegions: AnnotationRegion[];
aspectRatio: AspectRatio;
webcamLayoutPreset: WebcamLayoutPreset;
webcamMaskShape: WebcamMaskShape;
webcamSizePreset: WebcamSizePreset;
webcamPosition: WebcamPosition | null;
exportQuality: ExportQuality;
exportFormat: ExportFormat;
gifFrameRate: GifFrameRate;
@@ -186,6 +204,7 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
cx: clamp(isFiniteNumber(region.focus?.cx) ? region.focus.cx : 0.5, 0, 1),
cy: clamp(isFiniteNumber(region.focus?.cy) ? region.focus.cy : 0.5, 0, 1),
},
focusMode: region.focusMode === "auto" ? "auto" : "manual",
};
})
: [];
@@ -216,14 +235,10 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
const endMs = Math.max(startMs + 1, rawEnd);
const speed =
region.speed === 0.25 ||
region.speed === 0.5 ||
region.speed === 0.75 ||
region.speed === 1.25 ||
region.speed === 1.5 ||
region.speed === 1.75 ||
region.speed === 2
? region.speed
isFiniteNumber(region.speed) &&
region.speed >= MIN_PLAYBACK_SPEED &&
region.speed <= MAX_PLAYBACK_SPEED
? clampPlaybackSpeed(region.speed)
: DEFAULT_PLAYBACK_SPEED;
return {
@@ -245,12 +260,20 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
const rawEnd = isFiniteNumber(region.endMs) ? Math.round(region.endMs) : rawStart + 1000;
const startMs = Math.max(0, Math.min(rawStart, rawEnd));
const endMs = Math.max(startMs + 1, rawEnd);
const blurShape =
typeof region.blurData?.shape === "string" &&
VALID_BLUR_SHAPES.has(region.blurData.shape)
? region.blurData.shape
: DEFAULT_BLUR_DATA.shape;
return {
id: region.id,
startMs,
endMs,
type: region.type === "image" || region.type === "figure" ? region.type : "text",
type:
region.type === "image" || region.type === "figure" || region.type === "blur"
? region.type
: "text",
content: typeof region.content === "string" ? region.content : "",
textContent: typeof region.textContent === "string" ? region.textContent : undefined,
imageContent: typeof region.imageContent === "string" ? region.imageContent : undefined,
@@ -297,6 +320,37 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
...region.figureData,
}
: undefined,
blurData:
region.blurData && typeof region.blurData === "object"
? {
...DEFAULT_BLUR_DATA,
...region.blurData,
shape: blurShape,
intensity: isFiniteNumber(region.blurData.intensity)
? clamp(region.blurData.intensity, MIN_BLUR_INTENSITY, MAX_BLUR_INTENSITY)
: DEFAULT_BLUR_INTENSITY,
freehandPoints: Array.isArray(region.blurData.freehandPoints)
? region.blurData.freehandPoints
.filter(
(
point,
): point is {
x: number;
y: number;
} =>
Boolean(
point &&
isFiniteNumber((point as { x?: unknown }).x) &&
isFiniteNumber((point as { y?: unknown }).y),
),
)
.map((point) => ({
x: clamp(point.x, 0, 100),
y: clamp(point.y, 0, 100),
}))
: DEFAULT_BLUR_FREEHAND_POINTS,
}
: undefined,
};
})
: [];
@@ -349,6 +403,27 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
editor.webcamLayoutPreset === "picture-in-picture"
? editor.webcamLayoutPreset
: DEFAULT_WEBCAM_LAYOUT_PRESET,
webcamMaskShape:
editor.webcamMaskShape === "rectangle" ||
editor.webcamMaskShape === "circle" ||
editor.webcamMaskShape === "square" ||
editor.webcamMaskShape === "rounded"
? editor.webcamMaskShape
: DEFAULT_WEBCAM_MASK_SHAPE,
webcamSizePreset:
typeof editor.webcamSizePreset === "number" && isFiniteNumber(editor.webcamSizePreset)
? Math.max(10, Math.min(50, editor.webcamSizePreset))
: DEFAULT_WEBCAM_SIZE_PRESET,
webcamPosition:
editor.webcamPosition &&
typeof editor.webcamPosition === "object" &&
isFiniteNumber((editor.webcamPosition as WebcamPosition).cx) &&
isFiniteNumber((editor.webcamPosition as WebcamPosition).cy)
? {
cx: clamp((editor.webcamPosition as WebcamPosition).cx, 0, 1),
cy: clamp((editor.webcamPosition as WebcamPosition).cy, 0, 1),
}
: DEFAULT_WEBCAM_POSITION,
exportQuality:
editor.exportQuality === "medium" || editor.exportQuality === "source"
? editor.exportQuality
@@ -381,3 +456,19 @@ export function createProjectData(
editor,
};
}
export function createProjectSnapshot(
media: ProjectMedia,
editor: Partial<ProjectEditorState>,
): string {
return JSON.stringify(createProjectData(media, normalizeProjectEditor(editor)));
}
export function hasProjectUnsavedChanges(
currentSnapshot: string | null,
baselineSnapshot: string | null,
): boolean {
return Boolean(
currentSnapshot !== null && baselineSnapshot !== null && currentSnapshot !== baselineSnapshot,
);
}
@@ -14,7 +14,7 @@ interface ItemProps {
onSelect?: () => void;
zoomDepth?: number;
speedValue?: number;
variant?: "zoom" | "trim" | "annotation" | "speed";
variant?: "zoom" | "trim" | "annotation" | "speed" | "blur";
}
// Map zoom depth to multiplier labels
@@ -20,6 +20,7 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useScopedT } from "@/contexts/I18nContext";
import { useShortcuts } from "@/contexts/ShortcutsContext";
import { matchesShortcut } from "@/lib/shortcuts";
import { cn } from "@/lib/utils";
@@ -43,6 +44,7 @@ import { detectZoomDwellCandidates, normalizeCursorTelemetry } from "./zoomSugge
const ZOOM_ROW_ID = "row-zoom";
const TRIM_ROW_ID = "row-trim";
const ANNOTATION_ROW_ID = "row-annotation";
const BLUR_ROW_ID = "row-blur";
const SPEED_ROW_ID = "row-speed";
const FALLBACK_RANGE_MS = 1000;
const TARGET_MARKER_COUNT = 12;
@@ -72,6 +74,12 @@ interface TimelineEditorProps {
onAnnotationDelete?: (id: string) => void;
selectedAnnotationId?: string | null;
onSelectAnnotation?: (id: string | null) => void;
blurRegions?: AnnotationRegion[];
onBlurAdded?: (span: Span) => void;
onBlurSpanChange?: (id: string, span: Span) => void;
onBlurDelete?: (id: string) => void;
selectedBlurId?: string | null;
onSelectBlur?: (id: string | null) => void;
speedRegions?: SpeedRegion[];
onSpeedAdded?: (span: Span) => void;
onSpeedSpanChange?: (id: string, span: Span) => void;
@@ -95,7 +103,7 @@ interface TimelineRenderItem {
label: string;
zoomDepth?: number;
speedValue?: number;
variant: "zoom" | "trim" | "annotation" | "speed";
variant: "zoom" | "trim" | "annotation" | "speed" | "blur";
}
const SCALE_CANDIDATES = [
@@ -524,10 +532,12 @@ function Timeline({
onSelectZoom,
onSelectTrim,
onSelectAnnotation,
onSelectBlur,
onSelectSpeed,
selectedZoomId,
selectedTrimId,
selectedAnnotationId,
selectedBlurId,
selectedSpeedId,
keyframes = [],
}: {
@@ -539,13 +549,16 @@ function Timeline({
onSelectZoom?: (id: string | null) => void;
onSelectTrim?: (id: string | null) => void;
onSelectAnnotation?: (id: string | null) => void;
onSelectBlur?: (id: string | null) => void;
onSelectSpeed?: (id: string | null) => void;
selectedZoomId: string | null;
selectedTrimId?: string | null;
selectedAnnotationId?: string | null;
selectedBlurId?: string | null;
selectedSpeedId?: string | null;
keyframes?: { id: string; time: number }[];
}) {
const t = useScopedT("timeline");
const { setTimelineRef, style, sidebarWidth, range, pixelsToValue } = useTimelineContext();
const localTimelineRef = useRef<HTMLDivElement | null>(null);
@@ -566,6 +579,7 @@ function Timeline({
onSelectZoom?.(null);
onSelectTrim?.(null);
onSelectAnnotation?.(null);
onSelectBlur?.(null);
onSelectSpeed?.(null);
const rect = e.currentTarget.getBoundingClientRect();
@@ -584,6 +598,7 @@ function Timeline({
onSelectZoom,
onSelectTrim,
onSelectAnnotation,
onSelectBlur,
onSelectSpeed,
videoDurationMs,
sidebarWidth,
@@ -635,6 +650,7 @@ function Timeline({
const zoomItems = items.filter((item) => item.rowId === ZOOM_ROW_ID);
const trimItems = items.filter((item) => item.rowId === TRIM_ROW_ID);
const annotationItems = items.filter((item) => item.rowId === ANNOTATION_ROW_ID);
const blurItems = items.filter((item) => item.rowId === BLUR_ROW_ID);
const speedItems = items.filter((item) => item.rowId === SPEED_ROW_ID);
return (
@@ -656,7 +672,7 @@ function Timeline({
keyframes={keyframes}
/>
<Row id={ZOOM_ROW_ID} isEmpty={zoomItems.length === 0} hint="Press Z to add zoom">
<Row id={ZOOM_ROW_ID} isEmpty={zoomItems.length === 0} hint={t("hints.pressZoom")}>
{zoomItems.map((item) => (
<Item
id={item.id}
@@ -673,7 +689,7 @@ function Timeline({
))}
</Row>
<Row id={TRIM_ROW_ID} isEmpty={trimItems.length === 0} hint="Press T to add trim">
<Row id={TRIM_ROW_ID} isEmpty={trimItems.length === 0} hint={t("hints.pressTrim")}>
{trimItems.map((item) => (
<Item
id={item.id}
@@ -692,7 +708,7 @@ function Timeline({
<Row
id={ANNOTATION_ROW_ID}
isEmpty={annotationItems.length === 0}
hint="Press A to add annotation"
hint={t("hints.pressAnnotation")}
>
{annotationItems.map((item) => (
<Item
@@ -709,7 +725,23 @@ function Timeline({
))}
</Row>
<Row id={SPEED_ROW_ID} isEmpty={speedItems.length === 0} hint="Press S to add speed">
<Row id={BLUR_ROW_ID} isEmpty={blurItems.length === 0} hint={t("hints.pressBlur")}>
{blurItems.map((item) => (
<Item
id={item.id}
key={item.id}
rowId={item.rowId}
span={item.span}
isSelected={item.id === selectedBlurId}
onSelect={() => onSelectBlur?.(item.id)}
variant={item.variant}
>
{item.label}
</Item>
))}
</Row>
<Row id={SPEED_ROW_ID} isEmpty={speedItems.length === 0} hint={t("hints.pressSpeed")}>
{speedItems.map((item) => (
<Item
id={item.id}
@@ -753,6 +785,12 @@ export default function TimelineEditor({
onAnnotationDelete,
selectedAnnotationId,
onSelectAnnotation,
blurRegions = [],
onBlurAdded,
onBlurSpanChange,
onBlurDelete,
selectedBlurId,
onSelectBlur,
speedRegions = [],
onSpeedAdded,
onSpeedSpanChange,
@@ -762,6 +800,7 @@ export default function TimelineEditor({
aspectRatio,
onAspectRatioChange,
}: TimelineEditorProps) {
const t = useScopedT("timeline");
const totalMs = useMemo(() => Math.max(0, Math.round(videoDuration * 1000)), [videoDuration]);
const currentTimeMs = useMemo(() => Math.round(currentTime * 1000), [currentTime]);
const timelineScale = useMemo(() => calculateTimelineScale(videoDuration), [videoDuration]);
@@ -836,6 +875,12 @@ export default function TimelineEditor({
onSelectAnnotation(null);
}, [selectedAnnotationId, onAnnotationDelete, onSelectAnnotation]);
const deleteSelectedBlur = useCallback(() => {
if (!selectedBlurId || !onBlurDelete || !onSelectBlur) return;
onBlurDelete(selectedBlurId);
onSelectBlur(null);
}, [selectedBlurId, onBlurDelete, onSelectBlur]);
const deleteSelectedSpeed = useCallback(() => {
if (!selectedSpeedId || !onSpeedDelete || !onSelectSpeed) return;
onSpeedDelete(selectedSpeedId);
@@ -905,9 +950,10 @@ export default function TimelineEditor({
const isZoomItem = zoomRegions.some((r) => r.id === excludeId);
const isTrimItem = trimRegions.some((r) => r.id === excludeId);
const isAnnotationItem = annotationRegions.some((r) => r.id === excludeId);
const isBlurItem = blurRegions.some((r) => r.id === excludeId);
const isSpeedItem = speedRegions.some((r) => r.id === excludeId);
if (isAnnotationItem) {
if (isAnnotationItem || isBlurItem) {
return false;
}
@@ -934,7 +980,7 @@ export default function TimelineEditor({
return false;
},
[zoomRegions, trimRegions, annotationRegions, speedRegions],
[zoomRegions, trimRegions, annotationRegions, blurRegions, speedRegions],
);
// At least 5% of the timeline or 1000ms, whichever is larger, so the region
@@ -966,15 +1012,15 @@ export default function TimelineEditor({
(region) => startPos >= region.startMs && startPos < region.endMs,
);
if (isOverlapping || gapToNext <= 0) {
toast.error("Cannot place zoom here", {
description: "Zoom already exists at this location or not enough space available.",
toast.error(t("errors.cannotPlaceZoom"), {
description: t("errors.zoomExistsAtLocation"),
});
return;
}
const actualDuration = Math.min(defaultRegionDurationMs, gapToNext);
onZoomAdded({ start: startPos, end: startPos + actualDuration });
}, [videoDuration, totalMs, currentTimeMs, zoomRegions, onZoomAdded, defaultRegionDurationMs]);
}, [videoDuration, totalMs, currentTimeMs, zoomRegions, onZoomAdded, defaultRegionDurationMs, t]);
const handleSuggestZooms = useCallback(() => {
if (!videoDuration || videoDuration === 0 || totalMs === 0) {
@@ -982,13 +1028,13 @@ export default function TimelineEditor({
}
if (!onZoomSuggested) {
toast.error("Zoom suggestion handler unavailable");
toast.error(t("errors.zoomSuggestionUnavailable"));
return;
}
if (cursorTelemetry.length < 2) {
toast.info("No cursor telemetry available", {
description: "Record a screencast first to generate cursor-based suggestions.",
toast.info(t("errors.noCursorTelemetry"), {
description: t("errors.noCursorTelemetryDescription"),
});
return;
}
@@ -1005,8 +1051,8 @@ export default function TimelineEditor({
const normalizedSamples = normalizeCursorTelemetry(cursorTelemetry, totalMs);
if (normalizedSamples.length < 2) {
toast.info("No usable cursor telemetry", {
description: "The recording does not include enough cursor movement data.",
toast.info(t("errors.noUsableTelemetry"), {
description: t("errors.noUsableTelemetryDescription"),
});
return;
}
@@ -1014,8 +1060,8 @@ export default function TimelineEditor({
const dwellCandidates = detectZoomDwellCandidates(normalizedSamples);
if (dwellCandidates.length === 0) {
toast.info("No clear cursor dwell moments found", {
description: "Try a recording with slower cursor pauses on important actions.",
toast.info(t("errors.noDwellMoments"), {
description: t("errors.noDwellMomentsDescription"),
});
return;
}
@@ -1052,13 +1098,17 @@ export default function TimelineEditor({
});
if (addedCount === 0) {
toast.info("No auto-zoom slots available", {
description: "Detected dwell points overlap existing zoom regions.",
toast.info(t("errors.noAutoZoomSlots"), {
description: t("errors.noAutoZoomSlotsDescription"),
});
return;
}
toast.success(`Added ${addedCount} cursor-based zoom suggestion${addedCount === 1 ? "" : "s"}`);
toast.success(
addedCount === 1
? t("success.addedZoomSuggestions", { count: String(addedCount) })
: t("success.addedZoomSuggestionsPlural", { count: String(addedCount) }),
);
}, [
videoDuration,
totalMs,
@@ -1066,6 +1116,7 @@ export default function TimelineEditor({
zoomRegions,
onZoomSuggested,
cursorTelemetry,
t,
]);
const handleAddTrim = useCallback(() => {
@@ -1090,15 +1141,15 @@ export default function TimelineEditor({
(region) => startPos >= region.startMs && startPos < region.endMs,
);
if (isOverlapping || gapToNext <= 0) {
toast.error("Cannot place trim here", {
description: "Trim already exists at this location or not enough space available.",
toast.error(t("errors.cannotPlaceTrim"), {
description: t("errors.trimExistsAtLocation"),
});
return;
}
const actualDuration = Math.min(defaultRegionDurationMs, gapToNext);
onTrimAdded({ start: startPos, end: startPos + actualDuration });
}, [videoDuration, totalMs, currentTimeMs, trimRegions, onTrimAdded, defaultRegionDurationMs]);
}, [videoDuration, totalMs, currentTimeMs, trimRegions, onTrimAdded, defaultRegionDurationMs, t]);
const handleAddSpeed = useCallback(() => {
if (!videoDuration || videoDuration === 0 || totalMs === 0 || !onSpeedAdded) {
@@ -1122,15 +1173,23 @@ export default function TimelineEditor({
(region) => startPos >= region.startMs && startPos < region.endMs,
);
if (isOverlapping || gapToNext <= 0) {
toast.error("Cannot place speed here", {
description: "Speed region already exists at this location or not enough space available.",
toast.error(t("errors.cannotPlaceSpeed"), {
description: t("errors.speedExistsAtLocation"),
});
return;
}
const actualDuration = Math.min(defaultRegionDurationMs, gapToNext);
onSpeedAdded({ start: startPos, end: startPos + actualDuration });
}, [videoDuration, totalMs, currentTimeMs, speedRegions, onSpeedAdded, defaultRegionDurationMs]);
}, [
videoDuration,
totalMs,
currentTimeMs,
speedRegions,
onSpeedAdded,
defaultRegionDurationMs,
t,
]);
const handleAddAnnotation = useCallback(() => {
if (!videoDuration || videoDuration === 0 || totalMs === 0 || !onAnnotationAdded) {
@@ -1149,6 +1208,21 @@ export default function TimelineEditor({
onAnnotationAdded({ start: startPos, end: endPos });
}, [videoDuration, totalMs, currentTimeMs, onAnnotationAdded, defaultRegionDurationMs]);
const handleAddBlur = useCallback(() => {
if (!videoDuration || videoDuration === 0 || totalMs === 0 || !onBlurAdded) {
return;
}
const defaultDuration = Math.min(defaultRegionDurationMs, totalMs);
if (defaultDuration <= 0) {
return;
}
const startPos = Math.max(0, Math.min(currentTimeMs, totalMs));
const endPos = Math.min(startPos + defaultDuration, totalMs);
onBlurAdded({ start: startPos, end: endPos });
}, [videoDuration, totalMs, currentTimeMs, onBlurAdded, defaultRegionDurationMs]);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
@@ -1167,6 +1241,9 @@ export default function TimelineEditor({
if (matchesShortcut(e, keyShortcuts.addAnnotation, isMac)) {
handleAddAnnotation();
}
if (matchesShortcut(e, keyShortcuts.addBlur, isMac)) {
handleAddBlur();
}
if (matchesShortcut(e, keyShortcuts.addSpeed, isMac)) {
handleAddSpeed();
}
@@ -1207,6 +1284,8 @@ export default function TimelineEditor({
deleteSelectedTrim();
} else if (selectedAnnotationId) {
deleteSelectedAnnotation();
} else if (selectedBlurId) {
deleteSelectedBlur();
} else if (selectedSpeedId) {
deleteSelectedSpeed();
}
@@ -1219,18 +1298,22 @@ export default function TimelineEditor({
handleAddZoom,
handleAddTrim,
handleAddAnnotation,
handleAddBlur,
handleAddSpeed,
deleteSelectedKeyframe,
deleteSelectedZoom,
deleteSelectedTrim,
deleteSelectedAnnotation,
deleteSelectedBlur,
deleteSelectedSpeed,
selectedKeyframeId,
selectedZoomId,
selectedTrimId,
selectedAnnotationId,
selectedBlurId,
selectedSpeedId,
annotationRegions,
blurRegions,
currentTime,
onSelectAnnotation,
keyShortcuts,
@@ -1253,7 +1336,7 @@ export default function TimelineEditor({
id: region.id,
rowId: ZOOM_ROW_ID,
span: { start: region.startMs, end: region.endMs },
label: `Zoom ${index + 1}`,
label: t("labels.zoomItem", { index: String(index + 1) }),
zoomDepth: region.depth,
variant: "zoom",
}));
@@ -1262,7 +1345,7 @@ export default function TimelineEditor({
id: region.id,
rowId: TRIM_ROW_ID,
span: { start: region.startMs, end: region.endMs },
label: `Trim ${index + 1}`,
label: t("labels.trimItem", { index: String(index + 1) }),
variant: "trim",
}));
@@ -1271,12 +1354,12 @@ export default function TimelineEditor({
if (region.type === "text") {
// Show text preview
const preview = region.content.trim() || "Empty text";
const preview = region.content.trim() || t("labels.emptyText");
label = preview.length > 20 ? `${preview.substring(0, 20)}...` : preview;
} else if (region.type === "image") {
label = "Image";
label = t("labels.imageItem");
} else {
label = "Annotation";
label = t("labels.annotationItem");
}
return {
@@ -1288,17 +1371,25 @@ export default function TimelineEditor({
};
});
const blurs: TimelineRenderItem[] = blurRegions.map((region, index) => ({
id: region.id,
rowId: BLUR_ROW_ID,
span: { start: region.startMs, end: region.endMs },
label: t("labels.blurItem", { index: String(index + 1) }),
variant: "blur",
}));
const speeds: TimelineRenderItem[] = speedRegions.map((region, index) => ({
id: region.id,
rowId: SPEED_ROW_ID,
span: { start: region.startMs, end: region.endMs },
label: `Speed ${index + 1}`,
label: t("labels.speedItem", { index: String(index + 1) }),
speedValue: region.speed,
variant: "speed",
}));
return [...zooms, ...trims, ...annotations, ...speeds];
}, [zoomRegions, trimRegions, annotationRegions, speedRegions]);
return [...zooms, ...trims, ...annotations, ...blurs, ...speeds];
}, [zoomRegions, trimRegions, annotationRegions, blurRegions, speedRegions, t]);
// Flat list of all non-annotation region spans for neighbour-clamping during drag/resize
const allRegionSpans = useMemo(() => {
@@ -1319,6 +1410,8 @@ export default function TimelineEditor({
onSpeedSpanChange?.(id, span);
} else if (annotationRegions.some((r) => r.id === id)) {
onAnnotationSpanChange?.(id, span);
} else if (blurRegions.some((r) => r.id === id)) {
onBlurSpanChange?.(id, span);
}
},
[
@@ -1326,10 +1419,12 @@ export default function TimelineEditor({
trimRegions,
speedRegions,
annotationRegions,
blurRegions,
onZoomSpanChange,
onTrimSpanChange,
onSpeedSpanChange,
onAnnotationSpanChange,
onBlurSpanChange,
],
);
@@ -1340,8 +1435,8 @@ export default function TimelineEditor({
<Plus className="w-6 h-6 text-slate-600" />
</div>
<div className="text-center">
<p className="text-sm font-medium text-slate-300">No Video Loaded</p>
<p className="text-xs text-slate-500 mt-1">Drag and drop a video to start editing</p>
<p className="text-sm font-medium text-slate-300">{t("emptyState.noVideo")}</p>
<p className="text-xs text-slate-500 mt-1">{t("emptyState.dragAndDrop")}</p>
</div>
</div>
);
@@ -1356,7 +1451,7 @@ export default function TimelineEditor({
variant="ghost"
size="icon"
className="h-7 w-7 text-slate-400 hover:text-[#34B27B] hover:bg-[#34B27B]/10 transition-all"
title="Add Zoom (Z)"
title={t("buttons.addZoom")}
>
<ZoomIn className="w-4 h-4" />
</Button>
@@ -1365,7 +1460,7 @@ export default function TimelineEditor({
variant="ghost"
size="icon"
className="h-7 w-7 text-slate-400 hover:text-[#34B27B] hover:bg-[#34B27B]/10 transition-all"
title="Suggest Zooms from Cursor"
title={t("buttons.suggestZooms")}
>
<WandSparkles className="w-4 h-4" />
</Button>
@@ -1374,7 +1469,7 @@ export default function TimelineEditor({
variant="ghost"
size="icon"
className="h-7 w-7 text-slate-400 hover:text-[#ef4444] hover:bg-[#ef4444]/10 transition-all"
title="Add Trim (T)"
title={t("buttons.addTrim")}
>
<Scissors className="w-4 h-4" />
</Button>
@@ -1383,16 +1478,35 @@ export default function TimelineEditor({
variant="ghost"
size="icon"
className="h-7 w-7 text-slate-400 hover:text-[#B4A046] hover:bg-[#B4A046]/10 transition-all"
title="Add Annotation (A)"
title={t("buttons.addAnnotation")}
>
<MessageSquare className="w-4 h-4" />
</Button>
<Button
onClick={handleAddBlur}
variant="ghost"
size="icon"
className="h-7 w-7 text-slate-400 hover:text-[#7dd3fc] hover:bg-[#7dd3fc]/10 transition-all"
title={t("buttons.addBlur")}
>
<svg
className="w-4 h-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<circle cx="8" cy="12" r="3" />
<circle cx="16" cy="12" r="3" />
<path d="M6 6h12M6 18h12" />
</svg>
</Button>
<Button
onClick={handleAddSpeed}
variant="ghost"
size="icon"
className="h-7 w-7 text-slate-400 hover:text-[#d97706] hover:bg-[#d97706]/10 transition-all"
title="Add Speed (S)"
title={t("buttons.addSpeed")}
>
<Gauge className="w-4 h-4" />
</Button>
@@ -1431,13 +1545,13 @@ export default function TimelineEditor({
<kbd className="px-1.5 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-sans">
{scrollLabels.pan}
</kbd>
<span>Pan</span>
<span>{t("labels.pan")}</span>
</span>
<span className="flex items-center gap-1.5">
<kbd className="px-1.5 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-sans">
{scrollLabels.zoom}
</kbd>
<span>Zoom</span>
<span>{t("labels.zoom")}</span>
</span>
</div>
</div>
@@ -1473,10 +1587,12 @@ export default function TimelineEditor({
onSelectZoom={onSelectZoom}
onSelectTrim={onSelectTrim}
onSelectAnnotation={onSelectAnnotation}
onSelectBlur={onSelectBlur}
onSelectSpeed={onSelectSpeed}
selectedZoomId={selectedZoomId}
selectedTrimId={selectedTrimId}
selectedAnnotationId={selectedAnnotationId}
selectedBlurId={selectedBlurId}
selectedSpeedId={selectedSpeedId}
keyframes={keyframes}
/>
+63 -2
View File
@@ -1,10 +1,26 @@
import type { WebcamLayoutPreset } from "@/lib/compositeLayout";
export type ZoomDepth = 1 | 2 | 3 | 4 | 5 | 6;
export type ZoomFocusMode = "manual" | "auto";
export type { WebcamLayoutPreset };
/** Webcam size as a percentage of the canvas reference dimension (10–50). */
export type WebcamSizePreset = number;
export const DEFAULT_WEBCAM_SIZE_PRESET: WebcamSizePreset = 25;
export const DEFAULT_WEBCAM_LAYOUT_PRESET: WebcamLayoutPreset = "picture-in-picture";
export type WebcamMaskShape = "rectangle" | "circle" | "square" | "rounded";
export const DEFAULT_WEBCAM_MASK_SHAPE: WebcamMaskShape = "rectangle";
export interface WebcamPosition {
cx: number; // normalized horizontal center (0-1)
cy: number; // normalized vertical center (0-1)
}
export const DEFAULT_WEBCAM_POSITION: WebcamPosition | null = null;
export interface ZoomFocus {
cx: number; // normalized horizontal center (0-1)
cy: number; // normalized vertical center (0-1)
@@ -16,6 +32,7 @@ export interface ZoomRegion {
endMs: number;
depth: ZoomDepth;
focus: ZoomFocus;
focusMode?: ZoomFocusMode;
}
export interface CursorTelemetryPoint {
@@ -30,7 +47,7 @@ export interface TrimRegion {
endMs: number;
}
export type AnnotationType = "text" | "image" | "figure";
export type AnnotationType = "text" | "image" | "figure" | "blur";
export type ArrowDirection =
| "up"
@@ -48,6 +65,19 @@ export interface FigureData {
strokeWidth: number;
}
export type BlurShape = "rectangle" | "oval" | "freehand";
export const MIN_BLUR_INTENSITY = 2;
export const MAX_BLUR_INTENSITY = 40;
export const DEFAULT_BLUR_INTENSITY = 12;
export interface BlurData {
shape: BlurShape;
intensity: number;
// Points are normalized (0-100) within the annotation bounds.
freehandPoints?: Array<{ x: number; y: number }>;
}
export interface AnnotationPosition {
x: number;
y: number;
@@ -82,6 +112,7 @@ export interface AnnotationRegion {
style: AnnotationTextStyle;
zIndex: number;
figureData?: FigureData;
blurData?: BlurData;
}
export const DEFAULT_ANNOTATION_POSITION: AnnotationPosition = {
@@ -111,6 +142,24 @@ export const DEFAULT_FIGURE_DATA: FigureData = {
strokeWidth: 4,
};
export const DEFAULT_BLUR_FREEHAND_POINTS: Array<{ x: number; y: number }> = [
{ x: 10, y: 30 },
{ x: 25, y: 10 },
{ x: 55, y: 8 },
{ x: 82, y: 20 },
{ x: 90, y: 45 },
{ x: 78, y: 72 },
{ x: 52, y: 90 },
{ x: 22, y: 84 },
{ x: 8, y: 58 },
];
export const DEFAULT_BLUR_DATA: BlurData = {
shape: "rectangle",
intensity: DEFAULT_BLUR_INTENSITY,
freehandPoints: DEFAULT_BLUR_FREEHAND_POINTS,
};
export interface CropRegion {
x: number;
y: number;
@@ -125,7 +174,16 @@ export const DEFAULT_CROP_REGION: CropRegion = {
height: 1,
};
export type PlaybackSpeed = 0.25 | 0.5 | 0.75 | 1.25 | 1.5 | 1.75 | 2;
export type PlaybackSpeed = number;
export const MIN_PLAYBACK_SPEED = 0.1;
// Anything above 16x causes the playhead to stall during preview
// due to the video decoder not being able to keep up.
export const MAX_PLAYBACK_SPEED = 16;
export function clampPlaybackSpeed(speed: number): PlaybackSpeed {
return Math.round(Math.min(MAX_PLAYBACK_SPEED, Math.max(MIN_PLAYBACK_SPEED, speed)) * 100) / 100;
}
export interface SpeedRegion {
id: string;
@@ -142,6 +200,9 @@ export const SPEED_OPTIONS: Array<{ speed: PlaybackSpeed; label: string }> = [
{ speed: 1.5, label: "1.5×" },
{ speed: 1.75, label: "1.75×" },
{ speed: 2, label: "2×" },
{ speed: 3, label: "3×" },
{ speed: 4, label: "4×" },
{ speed: 5, label: "5×" },
];
export const DEFAULT_PLAYBACK_SPEED: PlaybackSpeed = 1.5;
@@ -8,3 +8,6 @@ export const VIEWPORT_SCALE = 0.8;
export const SMOOTHING_FACTOR = 0.12;
export const ZOOM_TRANSLATION_DEADZONE_PX = 1.25;
export const ZOOM_SCALE_DEADZONE = 0.002;
export const AUTO_FOLLOW_SMOOTHING_FACTOR = 0.1;
export const AUTO_FOLLOW_SMOOTHING_FACTOR_MAX = 0.25;
export const AUTO_FOLLOW_RAMP_DISTANCE = 0.15;
@@ -0,0 +1,73 @@
import type { CursorTelemetryPoint, ZoomFocus } from "../types";
/**
* Binary-search the sorted telemetry array and linearly interpolate
* the cursor position at the given playback time.
*/
export function interpolateCursorAt(
telemetry: CursorTelemetryPoint[],
timeMs: number,
): ZoomFocus | null {
if (telemetry.length === 0) return null;
if (timeMs <= telemetry[0].timeMs) {
return { cx: telemetry[0].cx, cy: telemetry[0].cy };
}
const last = telemetry[telemetry.length - 1];
if (timeMs >= last.timeMs) {
return { cx: last.cx, cy: last.cy };
}
let lo = 0;
let hi = telemetry.length - 1;
while (lo < hi - 1) {
const mid = (lo + hi) >>> 1;
if (telemetry[mid].timeMs <= timeMs) {
lo = mid;
} else {
hi = mid;
}
}
const before = telemetry[lo];
const after = telemetry[hi];
const span = after.timeMs - before.timeMs;
const t = span > 0 ? (timeMs - before.timeMs) / span : 0;
return {
cx: before.cx + (after.cx - before.cx) * t,
cy: before.cy + (after.cy - before.cy) * t,
};
}
/**
* Exponential smoothing to reduce jitter from high-frequency cursor data.
* Lower factor = smoother / more lag, higher = more responsive.
*/
export function smoothCursorFocus(raw: ZoomFocus, prev: ZoomFocus, factor: number): ZoomFocus {
return {
cx: prev.cx + (raw.cx - prev.cx) * factor,
cy: prev.cy + (raw.cy - prev.cy) * factor,
};
}
/**
* Compute an adaptive smoothing factor that scales with distance:
* far from target → faster (maxFactor), close → slower (minFactor).
* This replaces the hard deadzone with a natural deceleration curve.
*/
export function adaptiveSmoothFactor(
raw: ZoomFocus,
prev: ZoomFocus,
minFactor: number,
maxFactor: number,
rampDistance: number,
): number {
const dx = raw.cx - prev.cx;
const dy = raw.cy - prev.cy;
const distance = Math.sqrt(dx * dx + dy * dy);
const t = Math.min(1, distance / rampDistance);
return minFactor + (maxFactor - minFactor) * t;
}
@@ -39,9 +39,16 @@ function getFocusBounds(depth: ZoomDepth) {
return getFocusBoundsForScale(zoomScale);
}
function getFocusBoundsForScale(zoomScale: number) {
const marginX = 1 / (2 * zoomScale);
const marginY = 1 / (2 * zoomScale);
interface ViewportRatio {
widthRatio: number;
heightRatio: number;
}
function getFocusBoundsForScale(zoomScale: number, viewportRatio?: ViewportRatio) {
const wr = viewportRatio?.widthRatio ?? 1;
const hr = viewportRatio?.heightRatio ?? 1;
const marginX = Math.min(0.5, wr / (2 * zoomScale));
const marginY = Math.min(0.5, hr / (2 * zoomScale));
return {
minX: marginX,
@@ -65,12 +72,16 @@ export function clampFocusToStage(
};
}
export function clampFocusToScale(focus: ZoomFocus, zoomScale: number): ZoomFocus {
export function clampFocusToScale(
focus: ZoomFocus,
zoomScale: number,
viewportRatio?: ViewportRatio,
): ZoomFocus {
const baseFocus = {
cx: clamp(focus.cx, 0, 1),
cy: clamp(focus.cy, 0, 1),
};
const bounds = getFocusBoundsForScale(zoomScale);
const bounds = getFocusBoundsForScale(zoomScale, viewportRatio);
return {
cx: clamp(baseFocus.cx, bounds.minX, bounds.maxX),
@@ -78,12 +89,16 @@ export function clampFocusToScale(focus: ZoomFocus, zoomScale: number): ZoomFocu
};
}
export function softenFocusToScale(focus: ZoomFocus, zoomScale: number): ZoomFocus {
export function softenFocusToScale(
focus: ZoomFocus,
zoomScale: number,
viewportRatio?: ViewportRatio,
): ZoomFocus {
const baseFocus = {
cx: clamp(focus.cx, 0, 1),
cy: clamp(focus.cy, 0, 1),
};
const bounds = getFocusBoundsForScale(zoomScale);
const bounds = getFocusBoundsForScale(zoomScale, viewportRatio);
const horizontalRange = bounds.maxX - bounds.minX;
const verticalRange = bounds.maxY - bounds.minY;
const horizontalSoftness = Math.min(0.12, horizontalRange * 0.35);
@@ -5,8 +5,9 @@ import {
type Size,
type StyledRenderRect,
type WebcamLayoutPreset,
type WebcamSizePreset,
} from "@/lib/compositeLayout";
import type { CropRegion } from "../types";
import type { CropRegion, WebcamMaskShape } from "../types";
interface LayoutParams {
container: HTMLDivElement;
@@ -20,6 +21,9 @@ interface LayoutParams {
padding?: number;
webcamDimensions?: Size | null;
webcamLayoutPreset?: WebcamLayoutPreset;
webcamSizePreset?: WebcamSizePreset;
webcamPosition?: { cx: number; cy: number } | null;
webcamMaskShape?: WebcamMaskShape;
}
interface LayoutResult {
@@ -45,6 +49,9 @@ export function layoutVideoContent(params: LayoutParams): LayoutResult | null {
padding = 0,
webcamDimensions,
webcamLayoutPreset,
webcamSizePreset,
webcamPosition,
webcamMaskShape,
} = params;
const videoWidth = lockedVideoDimensions?.width || videoElement.videoWidth;
@@ -79,7 +86,9 @@ export function layoutVideoContent(params: LayoutParams): LayoutResult | null {
// Calculate scale to fit the cropped area in the viewport
// Padding is a percentage (0-100), where 50 matches the original VIEWPORT_SCALE of 0.8
const paddingScale = 1.0 - (padding / 100) * 0.4;
// Vertical stack ignores padding — it's full-bleed
const effectivePadding = webcamLayoutPreset === "vertical-stack" ? 0 : padding;
const paddingScale = 1.0 - (effectivePadding / 100) * 0.4;
const maxDisplayWidth = width * paddingScale;
const maxDisplayHeight = height * paddingScale;
@@ -89,13 +98,24 @@ export function layoutVideoContent(params: LayoutParams): LayoutResult | null {
screenSize: { width: croppedVideoWidth, height: croppedVideoHeight },
webcamSize: webcamDimensions,
layoutPreset: webcamLayoutPreset,
webcamSizePreset,
webcamPosition,
webcamMaskShape,
});
if (!compositeLayout) {
return null;
}
const scale = compositeLayout.screenRect.width / croppedVideoWidth;
const screenRect = compositeLayout.screenRect;
// Cover mode: scale to fill the rect (may crop), otherwise fit-to-width
let scale: number;
if (compositeLayout.screenCover) {
scale = Math.max(screenRect.width / croppedVideoWidth, screenRect.height / croppedVideoHeight);
} else {
scale = screenRect.width / croppedVideoWidth;
}
videoSprite.scale.set(scale);
@@ -103,25 +123,24 @@ export function layoutVideoContent(params: LayoutParams): LayoutResult | null {
const fullVideoDisplayWidth = videoWidth * scale;
const fullVideoDisplayHeight = videoHeight * scale;
// Calculate display size of just the cropped region
// Position the full video sprite so that when we apply the mask,
// the cropped region appears centered
// The crop starts at (crop.x * videoWidth, crop.y * videoHeight) in video coordinates
// In display coordinates, that's (crop.x * fullVideoDisplayWidth, crop.y * fullVideoDisplayHeight)
// We want that point to be at screenRect.x, screenRect.y
const spriteX = compositeLayout.screenRect.x - crop.x * fullVideoDisplayWidth;
const spriteY = compositeLayout.screenRect.y - crop.y * fullVideoDisplayHeight;
// Position the video so the cropped region is centered within the screenRect
const croppedDisplayWidth = croppedVideoWidth * scale;
const croppedDisplayHeight = croppedVideoHeight * scale;
const offsetX = screenRect.x + (screenRect.width - croppedDisplayWidth) / 2;
const offsetY = screenRect.y + (screenRect.height - croppedDisplayHeight) / 2;
const spriteX = offsetX - crop.x * fullVideoDisplayWidth;
const spriteY = offsetY - crop.y * fullVideoDisplayHeight;
videoSprite.position.set(spriteX, spriteY);
// Apply border radius
// Apply border radius — mask clips the video to the screenRect
maskGraphics.clear();
maskGraphics.roundRect(
compositeLayout.screenRect.x,
compositeLayout.screenRect.y,
compositeLayout.screenRect.width,
compositeLayout.screenRect.height,
borderRadius,
screenRect.x,
screenRect.y,
screenRect.width,
screenRect.height,
compositeLayout.screenCover ? 0 : borderRadius,
);
maskGraphics.fill({ color: 0xffffff });
@@ -14,7 +14,7 @@ interface OverlayUpdateParams {
export function updateOverlayIndicator(params: OverlayUpdateParams) {
const { overlayEl, indicatorEl, region, focusOverride, videoSize, baseScale, isPlaying } = params;
if (!region) {
if (!region || region.focusMode === "auto") {
indicatorEl.style.display = "none";
overlayEl.style.pointerEvents = "none";
return;
@@ -1,6 +1,7 @@
import type { ZoomFocus, ZoomRegion } from "../types";
import type { CursorTelemetryPoint, ZoomFocus, ZoomRegion } from "../types";
import { ZOOM_DEPTH_SCALES } from "../types";
import { TRANSITION_WINDOW_MS, ZOOM_IN_TRANSITION_WINDOW_MS } from "./constants";
import { interpolateCursorAt } from "./cursorFollowUtils";
import { clampFocusToScale } from "./focusUtils";
import { clamp01, cubicBezier, easeOutScreenStudio } from "./mathUtils";
@@ -10,6 +11,8 @@ const ZOOM_IN_OVERLAP_MS = 500;
type DominantRegionOptions = {
connectZooms?: boolean;
cursorTelemetry?: CursorTelemetryPoint[];
viewportRatio?: ViewportRatio;
};
type ConnectedRegionPair = {
@@ -64,8 +67,33 @@ function getLinearFocus(start: ZoomFocus, end: ZoomFocus, amount: number): ZoomF
};
}
function getResolvedFocus(region: ZoomRegion, zoomScale: number): ZoomFocus {
return clampFocusToScale(region.focus, zoomScale);
interface ViewportRatio {
widthRatio: number;
heightRatio: number;
}
function getResolvedFocus(
region: ZoomRegion,
zoomScale: number,
timeMs?: number,
cursorTelemetry?: CursorTelemetryPoint[],
viewportRatio?: ViewportRatio,
): ZoomFocus {
let focus = region.focus;
if (
region.focusMode === "auto" &&
cursorTelemetry &&
cursorTelemetry.length > 0 &&
timeMs !== undefined
) {
const cursorFocus = interpolateCursorAt(cursorTelemetry, timeMs);
if (cursorFocus) {
focus = cursorFocus;
}
}
return clampFocusToScale(focus, zoomScale, viewportRatio);
}
function getConnectedRegionPairs(regions: ZoomRegion[]) {
@@ -96,6 +124,8 @@ function getActiveRegion(
regions: ZoomRegion[],
timeMs: number,
connectedPairs: ConnectedRegionPair[],
cursorTelemetry?: CursorTelemetryPoint[],
viewportRatio?: ViewportRatio,
) {
const activeRegions = regions
.map((region) => {
@@ -130,21 +160,32 @@ function getActiveRegion(
return {
region: {
...activeRegion,
focus: getResolvedFocus(activeRegion, activeScale),
focus: getResolvedFocus(activeRegion, activeScale, timeMs, cursorTelemetry, viewportRatio),
},
strength: activeRegions[0].strength,
blendedScale: null,
};
}
function getConnectedRegionHold(timeMs: number, connectedPairs: ConnectedRegionPair[]) {
function getConnectedRegionHold(
timeMs: number,
connectedPairs: ConnectedRegionPair[],
cursorTelemetry?: CursorTelemetryPoint[],
viewportRatio?: ViewportRatio,
) {
for (const pair of connectedPairs) {
if (timeMs > pair.transitionEnd && timeMs < pair.nextRegion.startMs) {
const nextScale = ZOOM_DEPTH_SCALES[pair.nextRegion.depth];
return {
region: {
...pair.nextRegion,
focus: getResolvedFocus(pair.nextRegion, nextScale),
focus: getResolvedFocus(
pair.nextRegion,
nextScale,
timeMs,
cursorTelemetry,
viewportRatio,
),
},
strength: 1,
blendedScale: null,
@@ -155,7 +196,12 @@ function getConnectedRegionHold(timeMs: number, connectedPairs: ConnectedRegionP
return null;
}
function getConnectedRegionTransition(connectedPairs: ConnectedRegionPair[], timeMs: number) {
function getConnectedRegionTransition(
connectedPairs: ConnectedRegionPair[],
timeMs: number,
cursorTelemetry?: CursorTelemetryPoint[],
viewportRatio?: ViewportRatio,
) {
for (const pair of connectedPairs) {
const { currentRegion, nextRegion, transitionStart, transitionEnd } = pair;
@@ -169,8 +215,23 @@ function getConnectedRegionTransition(connectedPairs: ConnectedRegionPair[], tim
const currentScale = ZOOM_DEPTH_SCALES[currentRegion.depth];
const nextScale = ZOOM_DEPTH_SCALES[nextRegion.depth];
const transitionScale = lerp(currentScale, nextScale, transitionProgress);
const currentFocus = getResolvedFocus(currentRegion, currentScale);
const nextFocus = getResolvedFocus(nextRegion, nextScale);
// Both regions share the same timeMs, so interpolate cursor once and reuse.
const sharedCursorFocus =
cursorTelemetry && cursorTelemetry.length > 0
? interpolateCursorAt(cursorTelemetry, timeMs)
: null;
const currentFocus = clampFocusToScale(
currentRegion.focusMode === "auto" && sharedCursorFocus
? sharedCursorFocus
: currentRegion.focus,
currentScale,
viewportRatio,
);
const nextFocus = clampFocusToScale(
nextRegion.focusMode === "auto" && sharedCursorFocus ? sharedCursorFocus : nextRegion.focus,
nextScale,
viewportRatio,
);
const transitionFocus = getLinearFocus(currentFocus, nextFocus, transitionProgress);
return {
@@ -204,20 +265,22 @@ export function findDominantRegion(
transition: ConnectedPanTransition | null;
} {
const connectedPairs = options.connectZooms ? getConnectedRegionPairs(regions) : [];
const telemetry = options.cursorTelemetry;
const vr = options.viewportRatio;
if (options.connectZooms) {
const connectedTransition = getConnectedRegionTransition(connectedPairs, timeMs);
const connectedTransition = getConnectedRegionTransition(connectedPairs, timeMs, telemetry, vr);
if (connectedTransition) {
return connectedTransition;
}
const connectedHold = getConnectedRegionHold(timeMs, connectedPairs);
const connectedHold = getConnectedRegionHold(timeMs, connectedPairs, telemetry, vr);
if (connectedHold) {
return { ...connectedHold, transition: null };
}
}
const activeRegion = getActiveRegion(regions, timeMs, connectedPairs);
const activeRegion = getActiveRegion(regions, timeMs, connectedPairs, telemetry, vr);
return activeRegion
? { ...activeRegion, transition: null }
: { region: null, strength: 0, blendedScale: null, transition: null };
+90
View File
@@ -0,0 +1,90 @@
import {
createContext,
type ReactNode,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from "react";
import {
DEFAULT_LOCALE,
type I18nNamespace,
LOCALE_STORAGE_KEY,
type Locale,
SUPPORTED_LOCALES,
} from "@/i18n/config";
import { translate } from "@/i18n/loader";
type TranslateVars = Record<string, string | number>;
interface I18nContextValue {
locale: Locale;
setLocale: (locale: Locale) => void;
t: (qualifiedKey: string, vars?: TranslateVars) => string;
}
const I18nContext = createContext<I18nContextValue | null>(null);
export function useI18n(): I18nContextValue {
const ctx = useContext(I18nContext);
if (!ctx) throw new Error("useI18n must be used within <I18nProvider>");
return ctx;
}
export function useScopedT(namespace: I18nNamespace) {
const { locale } = useI18n();
return useCallback(
(key: string, vars?: TranslateVars): string => translate(locale, namespace, key, vars),
[locale, namespace],
);
}
function isSupportedLocale(value: string): value is Locale {
return (SUPPORTED_LOCALES as readonly string[]).includes(value);
}
function getInitialLocale(): Locale {
try {
const stored = localStorage.getItem(LOCALE_STORAGE_KEY);
if (stored && isSupportedLocale(stored)) return stored;
} catch {
// localStorage may be unavailable
}
return DEFAULT_LOCALE;
}
export function I18nProvider({ children }: { children: ReactNode }) {
const [locale, setLocaleState] = useState<Locale>(getInitialLocale);
const setLocale = useCallback((newLocale: Locale) => {
setLocaleState(newLocale);
try {
localStorage.setItem(LOCALE_STORAGE_KEY, newLocale);
} catch {
// localStorage may be unavailable
}
document.documentElement.lang = newLocale;
// Notify Electron main process
window.electronAPI?.setLocale?.(newLocale);
}, []);
useEffect(() => {
document.documentElement.lang = locale;
}, [locale]);
const t = useCallback(
(qualifiedKey: string, vars?: TranslateVars): string => {
const dotIndex = qualifiedKey.indexOf(".");
if (dotIndex === -1) return qualifiedKey;
const namespace = qualifiedKey.slice(0, dotIndex) as I18nNamespace;
const key = qualifiedKey.slice(dotIndex + 1);
return translate(locale, namespace, key, vars);
},
[locale],
);
const value = useMemo<I18nContextValue>(() => ({ locale, setLocale, t }), [locale, setLocale, t]);
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
}
+113
View File
@@ -0,0 +1,113 @@
import { act, renderHook, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useCameraDevices } from "./useCameraDevices";
// Mock navigator.mediaDevices
const mockDevices = [
{ kind: "videoinput", deviceId: "cam1", label: "Camera 1", groupId: "group1" },
{ kind: "videoinput", deviceId: "cam2", label: "Camera 2", groupId: "group1" },
{ kind: "audioinput", deviceId: "mic1", label: "Mic 1", groupId: "group2" },
];
const mockGetUserMedia = vi.fn().mockResolvedValue({
getTracks: () => [{ stop: vi.fn() }],
});
const mockEnumerateDevices = vi.fn().mockResolvedValue(mockDevices);
Object.defineProperty(global.navigator, "mediaDevices", {
value: {
enumerateDevices: mockEnumerateDevices,
getUserMedia: mockGetUserMedia,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
},
configurable: true,
});
describe("useCameraDevices", () => {
beforeEach(() => {
vi.clearAllMocks();
mockEnumerateDevices.mockResolvedValue(mockDevices);
mockGetUserMedia.mockResolvedValue({
getTracks: () => [{ stop: vi.fn() }],
});
});
afterEach(() => {
vi.resetAllMocks();
});
it("should list video input devices", async () => {
const { result } = renderHook(() => useCameraDevices(true));
await waitFor(() => {
expect(result.current.devices).toHaveLength(2);
});
expect(result.current.devices[0].label).toBe("Camera 1");
expect(result.current.devices[1].deviceId).toBe("cam2");
});
it("should set first device as default", async () => {
const { result } = renderHook(() => useCameraDevices(true));
await waitFor(() => {
expect(result.current.selectedDeviceId).toBe("cam1");
});
});
it("should use device ID as fallback label when label is missing", async () => {
mockEnumerateDevices.mockResolvedValueOnce([
{ kind: "videoinput", deviceId: "cam1abc123456", label: "", groupId: "group1" },
]);
const { result } = renderHook(() => useCameraDevices(true));
await waitFor(() => {
expect(result.current.devices[0]?.label).toBe("Camera cam1abc1");
});
expect(mockGetUserMedia).not.toHaveBeenCalled();
});
it("should set error state when enumeration fails", async () => {
mockEnumerateDevices.mockRejectedValueOnce(new Error("Permission denied"));
const { result } = renderHook(() => useCameraDevices(true));
await waitFor(() => {
expect(result.current.error).toBe("Permission denied");
});
expect(result.current.devices).toHaveLength(0);
expect(result.current.isLoading).toBe(false);
});
it("should fall back to first available device when selected device is unplugged", async () => {
const { result } = renderHook(() => useCameraDevices(true));
await waitFor(() => {
expect(result.current.selectedDeviceId).toBe("cam1");
});
// Simulate cam1 being unplugged — only cam2 remains
const cam2Only = [
{ kind: "videoinput", deviceId: "cam2", label: "Camera 2", groupId: "group1" },
];
mockEnumerateDevices.mockResolvedValueOnce(cam2Only);
// Trigger devicechange event via the registered handler
const devicechangeHandler = (
navigator.mediaDevices.addEventListener as ReturnType<typeof vi.fn>
).mock.calls[0]?.[1] as (() => void) | undefined;
await act(async () => {
devicechangeHandler?.();
});
await waitFor(() => {
expect(result.current.selectedDeviceId).toBe("cam2");
});
});
});
+64
View File
@@ -0,0 +1,64 @@
import { useEffect, useRef, useState } from "react";
export interface CameraDevice {
deviceId: string;
label: string;
groupId: string;
}
export function useCameraDevices(enabled: boolean = false) {
const [devices, setDevices] = useState<CameraDevice[]>([]);
const [selectedDeviceId, setSelectedDeviceId] = useState<string>("");
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const selectedDeviceIdRef = useRef(selectedDeviceId);
selectedDeviceIdRef.current = selectedDeviceId;
useEffect(() => {
if (!enabled) return;
let mounted = true;
const loadDevices = async () => {
try {
setIsLoading(true);
setError(null);
// Enumerate without requesting a second stream — the recorder handles
// the real acquisition; unlabeled devices fall back to their device ID.
const allDevices = await navigator.mediaDevices.enumerateDevices();
const videoInputs = allDevices
.filter((device) => device.kind === "videoinput")
.map((device) => ({
deviceId: device.deviceId,
label: device.label || `Camera ${device.deviceId.slice(0, 8)}`,
groupId: device.groupId,
}));
if (mounted) {
setDevices(videoInputs);
const currentId = selectedDeviceIdRef.current;
const stillAvailable = videoInputs.some((d) => d.deviceId === currentId);
if (!currentId || !stillAvailable) {
setSelectedDeviceId(videoInputs[0]?.deviceId ?? "");
}
setIsLoading(false);
}
} catch (err) {
if (mounted) {
setError(err instanceof Error ? err.message : "Failed to load cameras");
setIsLoading(false);
}
}
};
loadDevices();
navigator.mediaDevices.addEventListener("devicechange", loadDevices);
return () => {
mounted = false;
navigator.mediaDevices.removeEventListener("devicechange", loadDevices);
};
}, [enabled]);
return { devices, selectedDeviceId, setSelectedDeviceId, isLoading, error };
}
+16 -1
View File
@@ -5,9 +5,18 @@ import type {
SpeedRegion,
TrimRegion,
WebcamLayoutPreset,
WebcamMaskShape,
WebcamPosition,
WebcamSizePreset,
ZoomRegion,
} from "@/components/video-editor/types";
import { DEFAULT_CROP_REGION, DEFAULT_WEBCAM_LAYOUT_PRESET } from "@/components/video-editor/types";
import {
DEFAULT_CROP_REGION,
DEFAULT_WEBCAM_LAYOUT_PRESET,
DEFAULT_WEBCAM_MASK_SHAPE,
DEFAULT_WEBCAM_POSITION,
DEFAULT_WEBCAM_SIZE_PRESET,
} from "@/components/video-editor/types";
import type { AspectRatio } from "@/utils/aspectRatioUtils";
// Undoable state — selection IDs are intentionally excluded (undoing a
@@ -26,6 +35,9 @@ export interface EditorState {
padding: number;
aspectRatio: AspectRatio;
webcamLayoutPreset: WebcamLayoutPreset;
webcamMaskShape: WebcamMaskShape;
webcamSizePreset: WebcamSizePreset;
webcamPosition: WebcamPosition | null;
}
export const INITIAL_EDITOR_STATE: EditorState = {
@@ -42,6 +54,9 @@ export const INITIAL_EDITOR_STATE: EditorState = {
padding: 50,
aspectRatio: "16:9",
webcamLayoutPreset: DEFAULT_WEBCAM_LAYOUT_PRESET,
webcamMaskShape: DEFAULT_WEBCAM_MASK_SHAPE,
webcamSizePreset: DEFAULT_WEBCAM_SIZE_PRESET,
webcamPosition: DEFAULT_WEBCAM_POSITION,
};
type StateUpdate = Partial<EditorState> | ((prev: EditorState) => Partial<EditorState>);
+169 -39
View File
@@ -1,6 +1,7 @@
import { fixWebmDuration } from "@fix-webm-duration/fix";
import { useCallback, useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { useScopedT } from "@/contexts/I18nContext";
import { requestCameraAccess } from "@/lib/requestCameraAccess";
const TARGET_FRAME_RATE = 60;
@@ -40,12 +41,18 @@ const WEBCAM_TARGET_FRAME_RATE = 30;
type UseScreenRecorderReturn = {
recording: boolean;
paused: boolean;
elapsedSeconds: number;
toggleRecording: () => void;
togglePaused: () => void;
restartRecording: () => void;
cancelRecording: () => void;
microphoneEnabled: boolean;
setMicrophoneEnabled: (enabled: boolean) => void;
microphoneDeviceId: string | undefined;
setMicrophoneDeviceId: (deviceId: string | undefined) => void;
webcamDeviceId: string | undefined;
setWebcamDeviceId: (deviceId: string | undefined) => void;
systemAudioEnabled: boolean;
setSystemAudioEnabled: (enabled: boolean) => void;
webcamEnabled: boolean;
@@ -80,9 +87,13 @@ function createRecorderHandle(stream: MediaStream, options: MediaRecorderOptions
}
export function useScreenRecorder(): UseScreenRecorderReturn {
const t = useScopedT("editor");
const [recording, setRecording] = useState(false);
const [paused, setPaused] = useState(false);
const [elapsedSeconds, setElapsedSeconds] = useState(0);
const [microphoneEnabled, setMicrophoneEnabled] = useState(false);
const [microphoneDeviceId, setMicrophoneDeviceId] = useState<string | undefined>(undefined);
const [webcamDeviceId, setWebcamDeviceId] = useState<string | undefined>(undefined);
const [systemAudioEnabled, setSystemAudioEnabled] = useState(false);
const [webcamEnabled, setWebcamEnabledState] = useState(false);
const screenRecorder = useRef<RecorderHandle | null>(null);
@@ -92,13 +103,20 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
const microphoneStream = useRef<MediaStream | null>(null);
const webcamStream = useRef<MediaStream | null>(null);
const mixingContext = useRef<AudioContext | null>(null);
const startTime = useRef<number>(0);
const recordingId = useRef<number>(0);
const accumulatedDurationMs = useRef(0);
const segmentStartedAt = useRef<number | null>(null);
const finalizingRecordingId = useRef<number | null>(null);
const allowAutoFinalize = useRef(false);
const discardRecordingId = useRef<number | null>(null);
const restarting = useRef(false);
const getRecordingDurationMs = useCallback(() => {
const segmentDuration =
segmentStartedAt.current === null ? 0 : Date.now() - segmentStartedAt.current;
return accumulatedDurationMs.current + segmentDuration;
}, []);
const selectMimeType = () => {
const preferred = [
"video/webm;codecs=av1",
@@ -152,26 +170,29 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
}
}, []);
const setWebcamEnabled = useCallback(async (enabled: boolean) => {
if (!enabled) {
setWebcamEnabledState(false);
const setWebcamEnabled = useCallback(
async (enabled: boolean) => {
if (!enabled) {
setWebcamEnabledState(false);
return true;
}
const accessResult = await requestCameraAccess();
if (!accessResult.success) {
toast.error(t("recording.failedCameraAccess"));
return false;
}
if (!accessResult.granted) {
toast.error(t("recording.cameraBlocked"));
return false;
}
setWebcamEnabledState(true);
return true;
}
const accessResult = await requestCameraAccess();
if (!accessResult.success) {
toast.error("Failed to request camera access.");
return false;
}
if (!accessResult.granted) {
toast.error("Camera access is blocked. Enable it in system settings to use the webcam.");
return false;
}
setWebcamEnabledState(true);
return true;
}, []);
},
[t],
);
const finalizeRecording = useCallback(
(
@@ -194,6 +215,10 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
teardownMedia();
setRecording(false);
setPaused(false);
setElapsedSeconds(0);
accumulatedDurationMs.current = 0;
segmentStartedAt.current = null;
window.electronAPI?.setRecordingState(false);
void (async () => {
@@ -265,7 +290,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
}
const activeWebcamRecorder = webcamRecorder.current;
const duration = Date.now() - startTime.current;
const duration = getRecordingDurationMs();
const activeRecordingId = recordingId.current;
finalizeRecording(
@@ -275,7 +300,10 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
activeRecordingId,
);
if (activeScreenRecorder.recorder.state === "recording") {
if (
activeScreenRecorder.recorder.state === "recording" ||
activeScreenRecorder.recorder.state === "paused"
) {
try {
activeScreenRecorder.recorder.stop();
} catch {
@@ -283,7 +311,10 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
}
}
if (activeWebcamRecorder) {
if (activeWebcamRecorder.recorder.state === "recording") {
if (
activeWebcamRecorder.recorder.state === "recording" ||
activeWebcamRecorder.recorder.state === "paused"
) {
try {
activeWebcamRecorder.recorder.stop();
} catch {
@@ -308,14 +339,20 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
restarting.current = false;
discardRecordingId.current = null;
if (screenRecorder.current?.recorder.state === "recording") {
if (
screenRecorder.current?.recorder.state === "recording" ||
screenRecorder.current?.recorder.state === "paused"
) {
try {
screenRecorder.current.recorder.stop();
} catch {
// Ignore recorder teardown errors during cleanup.
}
}
if (webcamRecorder.current?.recorder.state === "recording") {
if (
webcamRecorder.current?.recorder.state === "recording" ||
webcamRecorder.current?.recorder.state === "paused"
) {
try {
webcamRecorder.current.recorder.stop();
} catch {
@@ -332,7 +369,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
try {
const selectedSource = await window.electronAPI.getSelectedSource();
if (!selectedSource) {
alert("Please select a source to record");
alert(t("recording.selectSource"));
return;
}
@@ -362,7 +399,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
} as unknown as MediaStreamConstraints);
} catch (audioErr) {
console.warn("System audio capture failed, falling back to video-only:", audioErr);
toast.error("System audio not available. Recording without system audio.");
toast.error(t("recording.systemAudioUnavailable"));
screenMediaStream = await navigator.mediaDevices.getUserMedia({
audio: false,
video: videoConstraints,
@@ -395,7 +432,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
});
} catch (audioError) {
console.warn("Failed to get microphone access:", audioError);
toast.error("Microphone access denied. Recording will continue without audio.");
toast.error(t("recording.microphoneDenied"));
setMicrophoneEnabled(false);
}
}
@@ -404,11 +441,18 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
try {
webcamStream.current = await navigator.mediaDevices.getUserMedia({
audio: false,
video: {
width: { ideal: WEBCAM_TARGET_WIDTH },
height: { ideal: WEBCAM_TARGET_HEIGHT },
frameRate: { ideal: WEBCAM_TARGET_FRAME_RATE, max: WEBCAM_TARGET_FRAME_RATE },
},
video: webcamDeviceId
? {
deviceId: { exact: webcamDeviceId },
width: { ideal: WEBCAM_TARGET_WIDTH },
height: { ideal: WEBCAM_TARGET_HEIGHT },
frameRate: { ideal: WEBCAM_TARGET_FRAME_RATE, max: WEBCAM_TARGET_FRAME_RATE },
}
: {
width: { ideal: WEBCAM_TARGET_WIDTH },
height: { ideal: WEBCAM_TARGET_HEIGHT },
frameRate: { ideal: WEBCAM_TARGET_FRAME_RATE, max: WEBCAM_TARGET_FRAME_RATE },
},
});
} catch (cameraError) {
console.warn("Failed to get webcam access:", cameraError);
@@ -417,7 +461,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
webcamStream.current = null;
}
setWebcamEnabledState(false);
toast.error("Camera access denied. Recording will continue without webcam.");
toast.error(t("recording.cameraDenied"));
}
}
@@ -503,9 +547,12 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
}
recordingId.current = Date.now();
startTime.current = recordingId.current;
accumulatedDurationMs.current = 0;
segmentStartedAt.current = Date.now();
allowAutoFinalize.current = true;
setRecording(true);
setPaused(false);
setElapsedSeconds(0);
window.electronAPI?.setRecordingState(true);
const activeScreenRecorder = screenRecorder.current;
@@ -521,7 +568,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
finalizeRecording(
activeScreenRecorder,
activeWebcamRecorder ?? null,
Math.max(0, Date.now() - startTime.current),
Math.max(0, getRecordingDurationMs()),
activeRecordingId,
);
},
@@ -532,17 +579,61 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
console.error("Failed to start recording:", error);
const errorMsg = error instanceof Error ? error.message : "Failed to start recording";
if (errorMsg.includes("Permission denied") || errorMsg.includes("NotAllowedError")) {
toast.error("Recording permission denied. Please allow screen recording.");
toast.error(t("recording.permissionDenied"));
} else {
toast.error(errorMsg);
}
setRecording(false);
setPaused(false);
setElapsedSeconds(0);
accumulatedDurationMs.current = 0;
segmentStartedAt.current = null;
screenRecorder.current = null;
webcamRecorder.current = null;
teardownMedia();
}
};
const togglePaused = () => {
const activeScreenRecorder = screenRecorder.current?.recorder;
if (!activeScreenRecorder || activeScreenRecorder.state === "inactive") {
return;
}
const activeWebcamRecorder = webcamRecorder.current?.recorder;
if (activeScreenRecorder.state === "paused") {
try {
activeScreenRecorder.resume();
if (activeWebcamRecorder?.state === "paused") {
activeWebcamRecorder.resume();
}
segmentStartedAt.current = Date.now();
setPaused(false);
} catch (error) {
console.error("Failed to resume recording:", error);
}
return;
}
if (activeScreenRecorder.state !== "recording") {
return;
}
try {
accumulatedDurationMs.current = getRecordingDurationMs();
segmentStartedAt.current = null;
setElapsedSeconds(Math.floor(accumulatedDurationMs.current / 1000));
activeScreenRecorder.pause();
if (activeWebcamRecorder?.state === "recording") {
activeWebcamRecorder.pause();
}
setPaused(true);
} catch (error) {
console.error("Failed to pause recording:", error);
}
};
const toggleRecording = () => {
recording ? stopRecording.current() : startRecording();
};
@@ -551,13 +642,14 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
if (restarting.current) return;
const activeScreenRecorder = screenRecorder.current;
if (!activeScreenRecorder || activeScreenRecorder.recorder.state !== "recording") return;
if (!activeScreenRecorder || activeScreenRecorder.recorder.state === "inactive") return;
const activeWebcamRecorder = webcamRecorder.current;
const activeRecordingId = recordingId.current;
restarting.current = true;
discardRecordingId.current = activeRecordingId;
allowAutoFinalize.current = false;
const stopPromises = [
new Promise<void>((resolve) => {
@@ -565,7 +657,10 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
}),
];
if (activeWebcamRecorder?.recorder.state === "recording") {
if (
activeWebcamRecorder?.recorder.state === "recording" ||
activeWebcamRecorder?.recorder.state === "paused"
) {
stopPromises.push(
new Promise<void>((resolve) => {
activeWebcamRecorder.recorder.addEventListener("stop", () => resolve(), {
@@ -585,14 +680,49 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
}
};
useEffect(() => {
if (!recording) {
setElapsedSeconds(0);
return;
}
setElapsedSeconds(Math.floor(getRecordingDurationMs() / 1000));
if (paused) {
return;
}
const interval = window.setInterval(() => {
setElapsedSeconds(Math.floor(getRecordingDurationMs() / 1000));
}, 250);
return () => window.clearInterval(interval);
}, [getRecordingDurationMs, paused, recording]);
const cancelRecording = () => {
const activeScreenRecorder = screenRecorder.current;
if (!activeScreenRecorder || activeScreenRecorder.recorder.state !== "recording") return;
const activeRecordingId = recordingId.current;
discardRecordingId.current = activeRecordingId;
allowAutoFinalize.current = false;
stopRecording.current();
};
return {
recording,
paused,
elapsedSeconds,
toggleRecording,
togglePaused,
restartRecording,
cancelRecording,
microphoneEnabled,
setMicrophoneEnabled,
microphoneDeviceId,
setMicrophoneDeviceId,
webcamDeviceId,
setWebcamDeviceId,
systemAudioEnabled,
setSystemAudioEnabled,
webcamEnabled,
+16
View File
@@ -0,0 +1,16 @@
export const DEFAULT_LOCALE = "en" as const;
export const SUPPORTED_LOCALES = ["en", "zh-CN", "es", "fr", "tr"] as const;
export const I18N_NAMESPACES = [
"common",
"dialogs",
"editor",
"launch",
"settings",
"shortcuts",
"timeline",
] as const;
export type Locale = (typeof SUPPORTED_LOCALES)[number];
export type I18nNamespace = (typeof I18N_NAMESPACES)[number];
export const LOCALE_STORAGE_KEY = "openscreen-locale";
+60
View File
@@ -0,0 +1,60 @@
import { DEFAULT_LOCALE, type I18nNamespace, type Locale } from "./config";
type MessageMap = Record<string, unknown>;
const modules = import.meta.glob("./locales/**/*.json", { eager: true }) as Record<
string,
{ default: MessageMap }
>;
const messages: Record<string, Record<string, MessageMap>> = {};
for (const [path, mod] of Object.entries(modules)) {
// path looks like "./locales/en/common.json"
const parts = path.replace("./locales/", "").replace(".json", "").split("/");
const locale = parts[0];
const namespace = parts[1];
if (!messages[locale]) messages[locale] = {};
messages[locale][namespace] = mod.default;
}
function getMessageValue(obj: unknown, dotPath: string): string | undefined {
const keys = dotPath.split(".");
let current: unknown = obj;
for (const key of keys) {
if (current == null || typeof current !== "object") return undefined;
current = (current as Record<string, unknown>)[key];
}
return typeof current === "string" ? current : undefined;
}
function interpolate(str: string, vars?: Record<string, string | number>): string {
if (!vars) return str;
return str.replace(/\{\{(\w+)\}\}/g, (_, key: string) => String(vars[key] ?? `{{${key}}}`));
}
export function getMessages(locale: Locale, namespace: I18nNamespace): MessageMap {
return messages[locale]?.[namespace] ?? {};
}
export function getLocaleName(locale: Locale): string {
return getMessageValue(messages[locale]?.common, "locale.name") ?? locale;
}
export function getLocaleShort(locale: Locale): string {
return getMessageValue(messages[locale]?.common, "locale.short") ?? locale;
}
export function translate(
locale: Locale,
namespace: I18nNamespace,
key: string,
vars?: Record<string, string | number>,
): string {
const value =
getMessageValue(messages[locale]?.[namespace], key) ??
getMessageValue(messages[DEFAULT_LOCALE]?.[namespace], key);
if (value == null) return `${namespace}.${key}`;
return interpolate(value, vars);
}
+29
View File
@@ -0,0 +1,29 @@
{
"actions": {
"cancel": "Cancel",
"save": "Save",
"delete": "Delete",
"close": "Close",
"share": "Share",
"done": "Done",
"open": "Open",
"upload": "Upload",
"export": "Export",
"file": "File",
"edit": "Edit",
"view": "View",
"window": "Window",
"quit": "Quit",
"stopRecording": "Stop Recording"
},
"playback": {
"play": "Play",
"pause": "Pause",
"fullscreen": "Fullscreen",
"exitFullscreen": "Exit Fullscreen"
},
"locale": {
"name": "English",
"short": "EN"
}
}
+71
View File
@@ -0,0 +1,71 @@
{
"export": {
"complete": "Export Complete",
"yourFormatReady": "Your {{format}} is ready",
"showInFolder": "Show in Folder",
"finalizingVideo": "Finalizing video export...",
"compilingGifProgress": "Compiling GIF... {{progress}}%",
"compilingGifWait": "Compiling GIF... This may take a while",
"takeMoment": "This may take a moment...",
"failed": "Export Failed",
"tryAgain": "Please try again",
"finalizingVideoTitle": "Finalizing Video",
"compilingGif": "Compiling GIF",
"exportingFormat": "Exporting {{format}}",
"compiling": "Compiling",
"renderingFrames": "Rendering Frames",
"processing": "Processing...",
"finalizing": "Finalizing...",
"compilingStatus": "Compiling...",
"status": "Status",
"format": "Format",
"frames": "Frames",
"cancelExport": "Cancel Export",
"savedSuccessfully": "{{format}} saved successfully!"
},
"tutorial": {
"triggerLabel": "How trimming works",
"title": "How Trimming Works",
"description": "Understanding how to cut out unwanted parts of your video.",
"explanationBefore": "The Trim tool works by defining the segments you want to",
"remove": "remove",
"explanationMiddle": " — anything",
"covered": "covered",
"explanationAfter": "by a red trim segment will be cut out when you export.",
"visualExample": "Visual Example",
"removed": "REMOVED",
"kept": "Kept",
"part1": "Part 1",
"part2": "Part 2",
"part3": "Part 3",
"finalVideo": "Final Video",
"step1Title": "1. Add Trim",
"step1DescriptionBefore": "Press ",
"step1DescriptionAfter": " or click the scissors icon to mark a section for removal.",
"step2Title": "2. Adjust",
"step2Description": "Drag the edges of the red region to cover exactly what you want to cut out."
},
"unsavedChanges": {
"title": "Unsaved Changes",
"message": "You have unsaved changes.",
"detail": "Do you want to save your project before closing?",
"saveAndClose": "Save & Close",
"discardAndClose": "Discard & Close",
"loadProject": "Load Project…",
"saveProject": "Save Project…",
"saveProjectAs": "Save Project As…"
},
"fileDialogs": {
"saveGif": "Save Exported GIF",
"saveVideo": "Save Exported Video",
"selectVideo": "Select Video File",
"saveProject": "Save OpenScreen Project",
"openProject": "Open OpenScreen Project",
"gifImage": "GIF Image",
"mp4Video": "MP4 Video",
"videoFiles": "Video Files",
"openscreenProject": "OpenScreen Project",
"allFiles": "All Files"
}
}
+41
View File
@@ -0,0 +1,41 @@
{
"newRecording": {
"title": "Return to Recorder",
"description": "Your current session has been saved.",
"cancel": "Cancel",
"confirm": "Confirm"
},
"errors": {
"noVideoLoaded": "No video loaded",
"videoNotReady": "Video not ready",
"unableToDetermineSourcePath": "Unable to determine source video path",
"failedToSaveGif": "Failed to save GIF",
"gifExportFailed": "GIF export failed",
"failedToSaveVideo": "Failed to save video",
"exportFailed": "Export failed",
"exportFailedWithError": "Export failed: {{error}}",
"failedToSaveExport": "Failed to save export",
"failedToSaveExportedVideo": "Failed to save exported video",
"failedToRevealInFolder": "Error revealing in folder: {{error}}"
},
"export": {
"canceled": "Export canceled",
"exportedSuccessfully": "{{format}} exported successfully"
},
"project": {
"saveCanceled": "Project save canceled",
"failedToSave": "Failed to save project",
"savedTo": "Project saved to {{path}}",
"failedToLoad": "Failed to load project",
"invalidFormat": "Invalid project file format",
"loadedFrom": "Project loaded from {{path}}"
},
"recording": {
"failedCameraAccess": "Failed to request camera access.",
"cameraBlocked": "Camera access is blocked. Enable it in system settings to use the webcam.",
"systemAudioUnavailable": "System audio not available. Recording without system audio.",
"microphoneDenied": "Microphone access denied. Recording will continue without audio.",
"cameraDenied": "Camera access denied. Recording will continue without webcam.",
"permissionDenied": "Recording permission denied. Please allow screen recording."
}
}
+37
View File
@@ -0,0 +1,37 @@
{
"tooltips": {
"hideHUD": "Hide HUD",
"closeApp": "Close App",
"restartRecording": "Restart recording",
"cancelRecording": "Cancel recording",
"pauseRecording": "Pause recording",
"resumeRecording": "Resume recording",
"openVideoFile": "Open video file",
"openProject": "Open project"
},
"audio": {
"enableSystemAudio": "Enable system audio",
"disableSystemAudio": "Disable system audio",
"enableMicrophone": "Enable microphone",
"disableMicrophone": "Disable microphone",
"defaultMicrophone": "Default Microphone"
},
"webcam": {
"enableWebcam": "Enable webcam",
"disableWebcam": "Disable webcam",
"defaultCamera": "Default Camera",
"searching": "Searching...",
"noneFound": "No camera found",
"unavailable": "Camera unavailable"
},
"sourceSelector": {
"loading": "Loading sources...",
"screens": "Screens ({{count}})",
"windows": "Windows ({{count}})",
"defaultSourceName": "Screen"
},
"recording": {
"selectSource": "Please select a source to record"
},
"language": "Language"
}
+168
View File
@@ -0,0 +1,168 @@
{
"zoom": {
"level": "Zoom Level",
"selectRegion": "Select a zoom region to adjust",
"deleteZoom": "Delete Zoom",
"focusMode": {
"title": "Focus Mode",
"manual": "Manual",
"auto": "Auto",
"autoDescription": "Camera follows the recorded cursor position"
}
},
"speed": {
"playbackSpeed": "Playback Speed",
"selectRegion": "Select a speed region to adjust",
"deleteRegion": "Delete Speed Region",
"customPlaybackSpeed": "Custom Playback Speed",
"maxSpeedError": "Speed can't go higher than 16×"
},
"trim": {
"deleteRegion": "Delete Trim Region"
},
"layout": {
"title": "Layout",
"preset": "Preset",
"selectPreset": "Select preset",
"pictureInPicture": "Picture in Picture",
"verticalStack": "Vertical Stack",
"webcamShape": "Camera Shape",
"webcamSize": "Webcam Size"
},
"effects": {
"title": "Video Effects",
"blurBg": "Blur BG",
"motionBlur": "Motion Blur",
"off": "off",
"shadow": "Shadow",
"roundness": "Roundness",
"padding": "Padding"
},
"background": {
"title": "Background",
"image": "Image",
"color": "Color",
"gradient": "Gradient",
"uploadCustom": "Upload Custom",
"gradientLabel": "Gradient {{index}}"
},
"crop": {
"title": "Crop",
"cropVideo": "Crop Video",
"dragInstruction": "Drag on each side to adjust the crop area",
"ratio": "Ratio",
"free": "Free",
"done": "Done",
"lockAspectRatio": "Lock aspect ratio",
"unlockAspectRatio": "Unlock aspect ratio"
},
"exportFormat": {
"mp4": "MP4",
"gif": "GIF",
"mp4Video": "MP4 Video",
"mp4Description": "High quality video file",
"gifAnimation": "GIF Animation",
"gifDescription": "Animated image for sharing"
},
"exportQuality": {
"title": "Export Quality",
"low": "Low",
"medium": "Medium",
"high": "High"
},
"gifSettings": {
"frameRate": "GIF Frame Rate",
"size": "GIF Size",
"loop": "Loop GIF"
},
"project": {
"save": "Save Project",
"load": "Load Project"
},
"export": {
"videoButton": "Export Video",
"gifButton": "Export GIF",
"chooseSaveLocation": "Choose Save Location"
},
"links": {
"reportBug": "Report Bug",
"starOnGithub": "Star on GitHub"
},
"imageUpload": {
"invalidFileType": "Invalid file type",
"jpgOnly": "Please upload a JPG or JPEG image file.",
"uploadSuccess": "Custom image uploaded successfully!",
"failedToUpload": "Failed to upload image",
"errorReading": "There was an error reading the file."
},
"annotation": {
"title": "Annotation Settings",
"active": "Active",
"typeText": "Text",
"typeImage": "Image",
"typeArrow": "Arrow",
"typeBlur": "Blur",
"textContent": "Text Content",
"textPlaceholder": "Enter your text...",
"fontStyle": "Font Style",
"selectStyle": "Select style",
"size": "Size",
"customFonts": "Custom Fonts",
"textColor": "Text Color",
"background": "Background",
"none": "None",
"color": "Color",
"clearBackground": "Clear Background",
"uploadImage": "Upload Image",
"supportedFormats": "Supported formats: JPG, PNG, GIF, WebP",
"arrowDirection": "Arrow Direction",
"strokeWidth": "Stroke Width: {{width}}px",
"arrowColor": "Arrow Color",
"blurShape": "Blur Shape",
"blurIntensity": "Blur Intensity",
"blurShapeRectangle": "Rectangle",
"blurShapeOval": "Oval",
"blurShapeFreehand": "Freehand",
"deleteAnnotation": "Delete Annotation",
"shortcutsAndTips": "Shortcuts & Tips",
"tipMovePlayhead": "Move playhead to overlapping annotation section and select an item.",
"tipTabCycle": "Use Tab to cycle through overlapping items.",
"tipShiftTabCycle": "Use Shift+Tab to cycle backwards.",
"invalidImageType": "Invalid file type",
"imageFormatsOnly": "Please upload a JPG, PNG, GIF, or WebP image file.",
"imageUploadSuccess": "Image uploaded successfully!",
"failedImageUpload": "Failed to upload image"
},
"fontStyles": {
"classic": "Classic",
"editor": "Editor",
"strong": "Strong",
"typewriter": "Typewriter",
"deco": "Deco",
"simple": "Simple",
"modern": "Modern",
"clean": "Clean"
},
"customFont": {
"dialogTitle": "Add Google Font",
"urlLabel": "Google Fonts Import URL",
"urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
"urlHelp": "Get this from Google Fonts: Select a font → Click \"Get font\" → Copy the @import URL",
"nameLabel": "Display Name",
"namePlaceholder": "My Custom Font",
"nameHelp": "This is how the font will appear in the font selector",
"addButton": "Add Font",
"addingButton": "Adding...",
"errorEmptyUrl": "Please enter a Google Fonts import URL",
"errorInvalidUrl": "Please enter a valid Google Fonts URL",
"errorEmptyName": "Please enter a font name",
"errorExtractFailed": "Could not extract font family from URL",
"successMessage": "Font \"{{fontName}}\" added successfully",
"failedToAdd": "Failed to add font",
"errorTimeout": "Font took too long to load. Please check the URL and try again.",
"errorLoadFailed": "The font could not be loaded. Please verify the Google Fonts URL is correct."
},
"language": {
"title": "Language"
}
}
+37
View File
@@ -0,0 +1,37 @@
{
"title": "Keyboard Shortcuts",
"customize": "Customize",
"configurable": "Configurable",
"fixed": "Fixed",
"pressKey": "Press a key…",
"clickToChange": "Click to change",
"pressEscToCancel": "Press Esc to cancel",
"helpText": "Click a shortcut then press the new key combination. Press Esc to cancel.",
"resetToDefaults": "Reset to defaults",
"alreadyUsedBy": "Already used by {{action}}",
"swap": "Swap",
"reservedShortcut": "This shortcut is reserved for \"{{label}}\" and cannot be reassigned.",
"savedToast": "Keyboard shortcuts saved",
"resetToast": "Reset to default shortcuts — click Save to apply",
"actions": {
"addZoom": "Add Zoom",
"addTrim": "Add Trim",
"addSpeed": "Add Speed",
"addAnnotation": "Add Annotation",
"addBlur": "Add Blur",
"addKeyframe": "Add Keyframe",
"deleteSelected": "Delete Selected",
"playPause": "Play / Pause"
},
"fixedActions": {
"undo": "Undo",
"redo": "Redo",
"cycleAnnotationsForward": "Cycle Annotations Forward",
"cycleAnnotationsBackward": "Cycle Annotations Backward",
"deleteSelectedAlt": "Delete Selected (alt)",
"panTimeline": "Pan Timeline",
"zoomTimeline": "Zoom Timeline",
"frameBack": "Frame Back",
"frameForward": "Frame Forward"
}
}
+53
View File
@@ -0,0 +1,53 @@
{
"buttons": {
"addZoom": "Add Zoom (Z)",
"suggestZooms": "Suggest Zooms from Cursor",
"addTrim": "Add Trim (T)",
"addAnnotation": "Add Annotation (A)",
"addBlur": "Add Blur (B)",
"addSpeed": "Add Speed (S)"
},
"hints": {
"pressZoom": "Press Z to add zoom",
"pressTrim": "Press T to add trim",
"pressAnnotation": "Press A to add annotation",
"pressBlur": "Press B to add blur region",
"pressSpeed": "Press S to add speed"
},
"labels": {
"pan": "Pan",
"zoom": "Zoom",
"zoomItem": "Zoom {{index}}",
"trimItem": "Trim {{index}}",
"speedItem": "Speed {{index}}",
"annotationItem": "Annotation",
"blurItem": "Blur {{index}}",
"imageItem": "Image",
"emptyText": "Empty text"
},
"emptyState": {
"noVideo": "No Video Loaded",
"dragAndDrop": "Drag and drop a video to start editing"
},
"errors": {
"cannotPlaceZoom": "Cannot place zoom here",
"zoomExistsAtLocation": "Zoom already exists at this location or not enough space available.",
"zoomSuggestionUnavailable": "Zoom suggestion handler unavailable",
"noCursorTelemetry": "No cursor telemetry available",
"noCursorTelemetryDescription": "Record a screencast first to generate cursor-based suggestions.",
"noUsableTelemetry": "No usable cursor telemetry",
"noUsableTelemetryDescription": "The recording does not include enough cursor movement data.",
"noDwellMoments": "No clear cursor dwell moments found",
"noDwellMomentsDescription": "Try a recording with slower cursor pauses on important actions.",
"noAutoZoomSlots": "No auto-zoom slots available",
"noAutoZoomSlotsDescription": "Detected dwell points overlap existing zoom regions.",
"cannotPlaceTrim": "Cannot place trim here",
"trimExistsAtLocation": "Trim already exists at this location or not enough space available.",
"cannotPlaceSpeed": "Cannot place speed here",
"speedExistsAtLocation": "Speed region already exists at this location or not enough space available."
},
"success": {
"addedZoomSuggestions": "Added {{count}} cursor-based zoom suggestion",
"addedZoomSuggestionsPlural": "Added {{count}} cursor-based zoom suggestions"
}
}
+29
View File
@@ -0,0 +1,29 @@
{
"actions": {
"cancel": "Cancelar",
"save": "Guardar",
"delete": "Eliminar",
"close": "Cerrar",
"share": "Compartir",
"done": "Listo",
"open": "Abrir",
"upload": "Subir",
"export": "Exportar",
"file": "Archivo",
"edit": "Editar",
"view": "Vista",
"window": "Ventana",
"quit": "Salir",
"stopRecording": "Detener grabación"
},
"playback": {
"play": "Reproducir",
"pause": "Pausar",
"fullscreen": "Pantalla completa",
"exitFullscreen": "Salir de pantalla completa"
},
"locale": {
"name": "Español",
"short": "ES"
}
}
+70
View File
@@ -0,0 +1,70 @@
{
"export": {
"complete": "Exportación completada",
"yourFormatReady": "Tu {{format}} está listo",
"showInFolder": "Mostrar en carpeta",
"finalizingVideo": "Finalizando exportación de video...",
"compilingGifProgress": "Compilando GIF... {{progress}}%",
"compilingGifWait": "Compilando GIF... Esto puede tardar un rato",
"takeMoment": "Esto puede tardar un momento...",
"failed": "La exportación falló",
"tryAgain": "Por favor intenta de nuevo",
"finalizingVideoTitle": "Finalizando video",
"compilingGif": "Compilando GIF",
"exportingFormat": "Exportando {{format}}",
"compiling": "Compilando",
"renderingFrames": "Renderizando cuadros",
"processing": "Procesando...",
"finalizing": "Finalizando...",
"compilingStatus": "Compilando...",
"status": "Estado",
"format": "Formato",
"frames": "Cuadros",
"cancelExport": "Cancelar exportación",
"savedSuccessfully": "¡{{format}} guardado exitosamente!"
},
"tutorial": {
"triggerLabel": "Cómo funciona el recorte",
"title": "Cómo funciona el recorte",
"description": "Aprende a eliminar las partes no deseadas de tu video.",
"explanationBefore": "La herramienta de recorte funciona definiendo los segmentos que deseas",
"remove": "eliminar",
"explanationMiddle": " — cualquier parte",
"covered": "cubierta",
"explanationAfter": "por un segmento rojo será eliminada al exportar.",
"visualExample": "Ejemplo visual",
"removed": "ELIMINADO",
"kept": "Conservado",
"part1": "Parte 1",
"part2": "Parte 2",
"part3": "Parte 3",
"finalVideo": "Video final",
"step1Title": "1. Agregar recorte",
"step1DescriptionBefore": "Presiona ",
"step1DescriptionAfter": " o haz clic en el ícono de tijeras para marcar una sección a eliminar.",
"step2Title": "2. Ajustar",
"step2Description": "Arrastra los bordes de la región roja para cubrir exactamente lo que deseas eliminar."
},
"unsavedChanges": {
"title": "Cambios sin guardar",
"message": "Tienes cambios sin guardar.",
"detail": "¿Deseas guardar tu proyecto antes de cerrar?",
"saveAndClose": "Guardar y cerrar",
"discardAndClose": "Descartar y cerrar",
"loadProject": "Cargar proyecto…",
"saveProject": "Guardar proyecto…",
"saveProjectAs": "Guardar proyecto como…"
},
"fileDialogs": {
"saveGif": "Guardar GIF exportado",
"saveVideo": "Guardar video exportado",
"selectVideo": "Seleccionar archivo de video",
"saveProject": "Guardar proyecto OpenScreen",
"openProject": "Abrir proyecto OpenScreen",
"gifImage": "Imagen GIF",
"mp4Video": "Video MP4",
"videoFiles": "Archivos de video",
"openscreenProject": "Proyecto OpenScreen",
"allFiles": "Todos los archivos"
}
}
+35
View File
@@ -0,0 +1,35 @@
{
"errors": {
"noVideoLoaded": "No hay video cargado",
"videoNotReady": "El video no está listo",
"unableToDetermineSourcePath": "No se pudo determinar la ruta del video de origen",
"failedToSaveGif": "Error al guardar el GIF",
"gifExportFailed": "La exportación de GIF falló",
"failedToSaveVideo": "Error al guardar el video",
"exportFailed": "La exportación falló",
"exportFailedWithError": "La exportación falló: {{error}}",
"failedToSaveExport": "Error al guardar la exportación",
"failedToSaveExportedVideo": "Error al guardar el video exportado",
"failedToRevealInFolder": "Error al mostrar en la carpeta: {{error}}"
},
"export": {
"canceled": "Exportación cancelada",
"exportedSuccessfully": "{{format}} exportado exitosamente"
},
"project": {
"saveCanceled": "Guardado de proyecto cancelado",
"failedToSave": "Error al guardar el proyecto",
"savedTo": "Proyecto guardado en {{path}}",
"failedToLoad": "Error al cargar el proyecto",
"invalidFormat": "Formato de archivo de proyecto no válido",
"loadedFrom": "Proyecto cargado desde {{path}}"
},
"recording": {
"failedCameraAccess": "Error al solicitar acceso a la cámara.",
"cameraBlocked": "El acceso a la cámara está bloqueado. Actívalo en la configuración del sistema para usar la cámara web.",
"systemAudioUnavailable": "Audio del sistema no disponible. Grabando sin audio del sistema.",
"microphoneDenied": "Acceso al micrófono denegado. La grabación continuará sin audio.",
"cameraDenied": "Acceso a la cámara denegado. La grabación continuará sin cámara web.",
"permissionDenied": "Permiso de grabación denegado. Por favor permite la grabación de pantalla."
}
}
+37
View File
@@ -0,0 +1,37 @@
{
"tooltips": {
"hideHUD": "Ocultar HUD",
"closeApp": "Cerrar aplicación",
"restartRecording": "Reiniciar grabación",
"cancelRecording": "Cancelar grabación",
"pauseRecording": "Pausar grabación",
"resumeRecording": "Reanudar grabación",
"openVideoFile": "Abrir archivo de video",
"openProject": "Abrir proyecto"
},
"audio": {
"enableSystemAudio": "Activar audio del sistema",
"disableSystemAudio": "Desactivar audio del sistema",
"enableMicrophone": "Activar micrófono",
"disableMicrophone": "Desactivar micrófono",
"defaultMicrophone": "Micrófono predeterminado"
},
"webcam": {
"enableWebcam": "Activar cámara web",
"disableWebcam": "Desactivar cámara web",
"defaultCamera": "Cámara predeterminada",
"searching": "Buscando...",
"noneFound": "No se encontró cámara",
"unavailable": "Cámara no disponible"
},
"sourceSelector": {
"loading": "Cargando fuentes...",
"screens": "Pantallas ({{count}})",
"windows": "Ventanas ({{count}})",
"defaultSourceName": "Pantalla"
},
"recording": {
"selectSource": "Por favor selecciona una fuente para grabar"
},
"language": "Idioma"
}
+168
View File
@@ -0,0 +1,168 @@
{
"zoom": {
"level": "Nivel de zoom",
"selectRegion": "Selecciona una región de zoom para ajustar",
"deleteZoom": "Eliminar zoom",
"focusMode": {
"title": "Modo de enfoque",
"manual": "Manual",
"auto": "Auto",
"autoDescription": "La cámara sigue la posición del cursor grabado"
}
},
"speed": {
"playbackSpeed": "Velocidad de reproducción",
"selectRegion": "Selecciona una región de velocidad para ajustar",
"deleteRegion": "Eliminar región de velocidad",
"customPlaybackSpeed": "Velocidad personalizada",
"maxSpeedError": "La velocidad no puede superar 16×"
},
"trim": {
"deleteRegion": "Eliminar región de recorte"
},
"layout": {
"title": "Diseño",
"preset": "Predefinido",
"selectPreset": "Seleccionar predefinido",
"pictureInPicture": "Imagen en imagen",
"verticalStack": "Apilado vertical",
"webcamShape": "Forma de cámara",
"webcamSize": "Tamaño de cámara"
},
"effects": {
"title": "Efectos de video",
"blurBg": "Desenfocar fondo",
"motionBlur": "Desenfoque de movimiento",
"off": "desactivado",
"shadow": "Sombra",
"roundness": "Redondez",
"padding": "Relleno"
},
"background": {
"title": "Fondo",
"image": "Imagen",
"color": "Color",
"gradient": "Degradado",
"uploadCustom": "Subir personalizado",
"gradientLabel": "Degradado {{index}}"
},
"crop": {
"title": "Recortar",
"cropVideo": "Recortar video",
"dragInstruction": "Arrastra cada lado para ajustar el área de recorte",
"ratio": "Proporción",
"free": "Libre",
"done": "Listo",
"lockAspectRatio": "Bloquear relación de aspecto",
"unlockAspectRatio": "Desbloquear relación de aspecto"
},
"exportFormat": {
"mp4": "MP4",
"gif": "GIF",
"mp4Video": "Video MP4",
"mp4Description": "Archivo de video de alta calidad",
"gifAnimation": "Animación GIF",
"gifDescription": "Imagen animada para compartir"
},
"exportQuality": {
"title": "Calidad de exportación",
"low": "Baja",
"medium": "Media",
"high": "Alta"
},
"gifSettings": {
"frameRate": "Velocidad de cuadros del GIF",
"size": "Tamaño del GIF",
"loop": "Repetir GIF"
},
"project": {
"save": "Guardar proyecto",
"load": "Cargar proyecto"
},
"export": {
"videoButton": "Exportar video",
"gifButton": "Exportar GIF",
"chooseSaveLocation": "Elegir ubicación de guardado"
},
"links": {
"reportBug": "Reportar error",
"starOnGithub": "Dar estrella en GitHub"
},
"imageUpload": {
"invalidFileType": "Tipo de archivo no válido",
"jpgOnly": "Por favor sube un archivo de imagen JPG o JPEG.",
"uploadSuccess": "¡Imagen personalizada subida exitosamente!",
"failedToUpload": "Error al subir la imagen",
"errorReading": "Hubo un error al leer el archivo."
},
"annotation": {
"title": "Configuración de anotaciones",
"active": "Activo",
"typeText": "Texto",
"typeImage": "Imagen",
"typeArrow": "Flecha",
"typeBlur": "Desenfoque",
"textContent": "Contenido de texto",
"textPlaceholder": "Escribe tu texto...",
"fontStyle": "Estilo de fuente",
"selectStyle": "Seleccionar estilo",
"size": "Tamaño",
"customFonts": "Fuentes personalizadas",
"textColor": "Color de texto",
"background": "Fondo",
"none": "Ninguno",
"color": "Color",
"clearBackground": "Quitar fondo",
"uploadImage": "Subir imagen",
"supportedFormats": "Formatos compatibles: JPG, PNG, GIF, WebP",
"arrowDirection": "Dirección de la flecha",
"strokeWidth": "Grosor del trazo: {{width}}px",
"arrowColor": "Color de la flecha",
"blurShape": "Forma del desenfoque",
"blurIntensity": "Intensidad del desenfoque",
"blurShapeRectangle": "Rectángulo",
"blurShapeOval": "Óvalo",
"blurShapeFreehand": "Mano alzada",
"deleteAnnotation": "Eliminar anotación",
"shortcutsAndTips": "Atajos y consejos",
"tipMovePlayhead": "Mueve el cabezal de reproducción a la sección de anotación superpuesta y selecciona un elemento.",
"tipTabCycle": "Usa Tab para recorrer los elementos superpuestos.",
"tipShiftTabCycle": "Usa Shift+Tab para recorrer hacia atrás.",
"invalidImageType": "Tipo de archivo no válido",
"imageFormatsOnly": "Por favor sube un archivo de imagen JPG, PNG, GIF o WebP.",
"imageUploadSuccess": "¡Imagen subida exitosamente!",
"failedImageUpload": "Error al subir la imagen"
},
"fontStyles": {
"classic": "Clásico",
"editor": "Editor",
"strong": "Fuerte",
"typewriter": "Máquina de escribir",
"deco": "Deco",
"simple": "Simple",
"modern": "Moderno",
"clean": "Limpio"
},
"customFont": {
"dialogTitle": "Agregar fuente de Google",
"urlLabel": "URL de importación de Google Fonts",
"urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
"urlHelp": "Obtén esto de Google Fonts: Selecciona una fuente → Haz clic en \"Get font\" → Copia la URL de @import",
"nameLabel": "Nombre para mostrar",
"namePlaceholder": "Mi fuente personalizada",
"nameHelp": "Así aparecerá la fuente en el selector de fuentes",
"addButton": "Agregar fuente",
"addingButton": "Agregando...",
"errorEmptyUrl": "Por favor ingresa una URL de importación de Google Fonts",
"errorInvalidUrl": "Por favor ingresa una URL válida de Google Fonts",
"errorEmptyName": "Por favor ingresa un nombre de fuente",
"errorExtractFailed": "No se pudo extraer la familia de fuentes de la URL",
"successMessage": "Fuente \"{{fontName}}\" agregada exitosamente",
"failedToAdd": "Error al agregar la fuente",
"errorTimeout": "La fuente tardó demasiado en cargarse. Por favor verifica la URL e intenta de nuevo.",
"errorLoadFailed": "No se pudo cargar la fuente. Por favor verifica que la URL de Google Fonts sea correcta."
},
"language": {
"title": "Idioma"
}
}
+37
View File
@@ -0,0 +1,37 @@
{
"title": "Atajos de teclado",
"customize": "Personalizar",
"configurable": "Configurables",
"fixed": "Fijos",
"pressKey": "Presiona una tecla…",
"clickToChange": "Haz clic para cambiar",
"pressEscToCancel": "Presiona Esc para cancelar",
"helpText": "Haz clic en un atajo y luego presiona la nueva combinación de teclas. Presiona Esc para cancelar.",
"resetToDefaults": "Restablecer valores predeterminados",
"alreadyUsedBy": "Ya está en uso por {{action}}",
"swap": "Intercambiar",
"reservedShortcut": "Este atajo está reservado para \"{{label}}\" y no se puede reasignar.",
"savedToast": "Atajos de teclado guardados",
"resetToast": "Restablecido a los atajos predeterminados — haz clic en Guardar para aplicar",
"actions": {
"addZoom": "Agregar zoom",
"addTrim": "Agregar recorte",
"addSpeed": "Agregar velocidad",
"addAnnotation": "Agregar anotación",
"addBlur": "Agregar desenfoque",
"addKeyframe": "Agregar fotograma clave",
"deleteSelected": "Eliminar seleccionado",
"playPause": "Reproducir / Pausar"
},
"fixedActions": {
"undo": "Deshacer",
"redo": "Rehacer",
"cycleAnnotationsForward": "Recorrer anotaciones hacia adelante",
"cycleAnnotationsBackward": "Recorrer anotaciones hacia atrás",
"deleteSelectedAlt": "Eliminar seleccionado (alt)",
"panTimeline": "Desplazar línea de tiempo",
"zoomTimeline": "Zoom en línea de tiempo",
"frameBack": "Fotograma anterior",
"frameForward": "Fotograma siguiente"
}
}
+53
View File
@@ -0,0 +1,53 @@
{
"buttons": {
"addZoom": "Agregar zoom (Z)",
"suggestZooms": "Sugerir zooms desde el cursor",
"addTrim": "Agregar recorte (T)",
"addAnnotation": "Agregar anotación (A)",
"addSpeed": "Agregar velocidad (S)",
"addBlur": "Agregar desenfoque (B)"
},
"hints": {
"pressZoom": "Presiona Z para agregar zoom",
"pressTrim": "Presiona T para agregar recorte",
"pressAnnotation": "Presiona A para agregar anotación",
"pressSpeed": "Presiona S para agregar velocidad",
"pressBlur": "Presiona B para agregar una región de desenfoque"
},
"labels": {
"pan": "Desplazar",
"zoom": "Zoom",
"zoomItem": "Zoom {{index}}",
"trimItem": "Recorte {{index}}",
"speedItem": "Velocidad {{index}}",
"annotationItem": "Anotación",
"imageItem": "Imagen",
"emptyText": "Texto vacío",
"blurItem": "Desenfoque {{index}}"
},
"emptyState": {
"noVideo": "No hay video cargado",
"dragAndDrop": "Arrastra y suelta un video para comenzar a editar"
},
"errors": {
"cannotPlaceZoom": "No se puede colocar el zoom aquí",
"zoomExistsAtLocation": "Ya existe un zoom en esta ubicación o no hay suficiente espacio disponible.",
"zoomSuggestionUnavailable": "El controlador de sugerencias de zoom no está disponible",
"noCursorTelemetry": "No hay telemetría de cursor disponible",
"noCursorTelemetryDescription": "Graba una captura de pantalla primero para generar sugerencias basadas en el cursor.",
"noUsableTelemetry": "No hay telemetría de cursor utilizable",
"noUsableTelemetryDescription": "La grabación no incluye suficientes datos de movimiento del cursor.",
"noDwellMoments": "No se encontraron momentos claros de pausa del cursor",
"noDwellMomentsDescription": "Intenta una grabación con pausas más lentas del cursor en acciones importantes.",
"noAutoZoomSlots": "No hay espacios de auto-zoom disponibles",
"noAutoZoomSlotsDescription": "Los puntos de pausa detectados se superponen con regiones de zoom existentes.",
"cannotPlaceTrim": "No se puede colocar el recorte aquí",
"trimExistsAtLocation": "Ya existe un recorte en esta ubicación o no hay suficiente espacio disponible.",
"cannotPlaceSpeed": "No se puede colocar la velocidad aquí",
"speedExistsAtLocation": "Ya existe una región de velocidad en esta ubicación o no hay suficiente espacio disponible."
},
"success": {
"addedZoomSuggestions": "Se agregó {{count}} sugerencia de zoom basada en el cursor",
"addedZoomSuggestionsPlural": "Se agregaron {{count}} sugerencias de zoom basadas en el cursor"
}
}
+29
View File
@@ -0,0 +1,29 @@
{
"actions": {
"cancel": "Annuler",
"save": "Enregistrer",
"delete": "Supprimer",
"close": "Fermer",
"share": "Partager",
"done": "Terminer",
"open": "Ouvrir",
"upload": "Téléverser",
"export": "Exporter",
"file": "Fichier",
"edit": "Éditer",
"view": "Affichage",
"window": "Fenêtre",
"quit": "Quitter",
"stopRecording": "Arrêter l'enregistrement"
},
"playback": {
"play": "Lecture",
"pause": "Pause",
"fullscreen": "Plein écran",
"exitFullscreen": "Quitter le plein écran"
},
"locale": {
"name": "Français",
"short": "FR"
}
}
+68
View File
@@ -0,0 +1,68 @@
{
"export": {
"complete": "Export terminé",
"yourFormatReady": "Votre {{format}} est prêt",
"showInFolder": "Afficher dans le dossier",
"finalizingVideo": "Finalisation de l'export vidéo...",
"compilingGifProgress": "Compilation du GIF... {{progress}}%",
"compilingGifWait": "Compilation du GIF... Cela peut prendre un moment",
"takeMoment": "Cela peut prendre un moment...",
"failed": "Export échoué",
"tryAgain": "Veuillez réessayer",
"finalizingVideoTitle": "Finalisation de la vidéo",
"compilingGif": "Compilation du GIF",
"exportingFormat": "Export de {{format}}",
"compiling": "Compilation en cours",
"renderingFrames": "Rendu des images",
"processing": "Traitement en cours...",
"finalizing": "Finalisation...",
"compilingStatus": "Compilation...",
"status": "Statut",
"format": "Format",
"frames": "Images",
"cancelExport": "Annuler l'export",
"savedSuccessfully": "{{format}} enregistré avec succès !"
},
"tutorial": {
"triggerLabel": "Comment fonctionne la coupe",
"title": "Comment fonctionne la coupe",
"description": "Comprendre comment supprimer les parties indésirables de votre vidéo.",
"explanation": "L'outil Coupe fonctionne en définissant les segments que vous souhaitez",
"explanationRemove": "supprimer",
"explanationCovered": "couvert",
"explanationEnd": "par un segment de coupe rouge sera coupé lors de l'export.",
"visualExample": "Exemple visuel",
"removed": "SUPPRIMÉ",
"kept": "Conservé",
"part1": "Partie 1",
"part2": "Partie 2",
"part3": "Partie 3",
"finalVideo": "Vidéo finale",
"step1Title": "1. Ajouter une coupe",
"step1Description": "Appuyez sur T ou cliquez sur l'icône ciseaux pour marquer une section à supprimer.",
"step2Title": "2. Ajuster",
"step2Description": "Faites glisser les bords de la région rouge pour couvrir exactement ce que vous souhaitez couper."
},
"unsavedChanges": {
"title": "Modifications non enregistrées",
"message": "Vous avez des modifications non enregistrées.",
"detail": "Voulez-vous enregistrer votre projet avant de fermer ?",
"saveAndClose": "Enregistrer et fermer",
"discardAndClose": "Ignorer et fermer",
"loadProject": "Charger un projet…",
"saveProject": "Enregistrer le projet…",
"saveProjectAs": "Enregistrer le projet sous…"
},
"fileDialogs": {
"saveGif": "Enregistrer le GIF exporté",
"saveVideo": "Enregistrer la vidéo exportée",
"selectVideo": "Sélectionner un fichier vidéo",
"saveProject": "Enregistrer le projet OpenScreen",
"openProject": "Ouvrir un projet OpenScreen",
"gifImage": "Image GIF",
"mp4Video": "Vidéo MP4",
"videoFiles": "Fichiers vidéo",
"openscreenProject": "Projet OpenScreen",
"allFiles": "Tous les fichiers"
}
}
+35
View File
@@ -0,0 +1,35 @@
{
"errors": {
"noVideoLoaded": "Aucune vidéo chargée",
"videoNotReady": "Vidéo non prête",
"unableToDetermineSourcePath": "Impossible de déterminer le chemin de la vidéo source",
"failedToSaveGif": "Échec de l'enregistrement du GIF",
"gifExportFailed": "L'export du GIF a échoué",
"failedToSaveVideo": "Échec de l'enregistrement de la vidéo",
"exportFailed": "L'export a échoué",
"exportFailedWithError": "L'export a échoué : {{error}}",
"failedToSaveExport": "Échec de l'enregistrement de l'export",
"failedToSaveExportedVideo": "Échec de l'enregistrement de la vidéo exportée",
"failedToRevealInFolder": "Erreur lors de l'affichage dans le dossier : {{error}}"
},
"export": {
"canceled": "Export annulé",
"exportedSuccessfully": "{{format}} exporté avec succès"
},
"project": {
"saveCanceled": "Enregistrement du projet annulé",
"failedToSave": "Échec de l'enregistrement du projet",
"savedTo": "Projet enregistré dans {{path}}",
"failedToLoad": "Échec du chargement du projet",
"invalidFormat": "Format de fichier projet invalide",
"loadedFrom": "Projet chargé depuis {{path}}"
},
"recording": {
"failedCameraAccess": "Échec de la demande d'accès à la caméra.",
"cameraBlocked": "L'accès à la caméra est bloqué. Activez-le dans les paramètres système pour utiliser la webcam.",
"systemAudioUnavailable": "Audio système non disponible. Enregistrement sans audio système.",
"microphoneDenied": "Accès au microphone refusé. L'enregistrement continuera sans audio.",
"cameraDenied": "Accès à la caméra refusé. L'enregistrement continuera sans webcam.",
"permissionDenied": "Permission d'enregistrement refusée. Veuillez autoriser l'enregistrement d'écran."
}
}
+37
View File
@@ -0,0 +1,37 @@
{
"tooltips": {
"hideHUD": "Masquer le HUD",
"closeApp": "Fermer l'application",
"restartRecording": "Redémarrer l'enregistrement",
"cancelRecording": "Annuler l'enregistrement",
"pauseRecording": "Mettre en pause l'enregistrement",
"resumeRecording": "Reprendre l'enregistrement",
"openVideoFile": "Ouvrir un fichier vidéo",
"openProject": "Ouvrir un projet"
},
"audio": {
"enableSystemAudio": "Activer l'audio système",
"disableSystemAudio": "Désactiver l'audio système",
"enableMicrophone": "Activer le microphone",
"disableMicrophone": "Désactiver le microphone",
"defaultMicrophone": "Microphone par défaut"
},
"webcam": {
"enableWebcam": "Activer la webcam",
"disableWebcam": "Désactiver la webcam",
"defaultCamera": "Caméra par défaut",
"searching": "Recherche en cours...",
"noneFound": "Aucune caméra trouvée",
"unavailable": "Caméra non disponible"
},
"sourceSelector": {
"loading": "Chargement des sources...",
"screens": "Écrans ({{count}})",
"windows": "Fenêtres ({{count}})",
"defaultSourceName": "Écran"
},
"recording": {
"selectSource": "Veuillez sélectionner une source à enregistrer"
},
"language": "Langue"
}
+165
View File
@@ -0,0 +1,165 @@
{
"zoom": {
"level": "Niveau de zoom",
"selectRegion": "Sélectionnez une région de zoom à ajuster",
"deleteZoom": "Supprimer le zoom",
"focusMode": {
"title": "Mode focus",
"manual": "Manuel",
"auto": "Auto",
"autoDescription": "La caméra suit la position du curseur enregistré"
}
},
"speed": {
"playbackSpeed": "Vitesse de lecture",
"selectRegion": "Sélectionnez une région de vitesse à ajuster",
"deleteRegion": "Supprimer la région de vitesse"
},
"trim": {
"deleteRegion": "Supprimer la région de coupe"
},
"layout": {
"title": "Mise en page",
"preset": "Préréglage",
"selectPreset": "Choisir un préréglage",
"pictureInPicture": "Incrustation d'image",
"verticalStack": "Empilement vertical",
"webcamShape": "Forme de la caméra"
},
"effects": {
"title": "Effets vidéo",
"blurBg": "Flou arrière-plan",
"motionBlur": "Flou de mouvement",
"off": "désactivé",
"shadow": "Ombre",
"roundness": "Arrondi",
"padding": "Marge"
},
"background": {
"title": "Arrière-plan",
"image": "Image",
"color": "Couleur",
"gradient": "Dégradé",
"uploadCustom": "Téléverser une image",
"gradientLabel": "Dégradé {{index}}"
},
"crop": {
"title": "Recadrage",
"cropVideo": "Recadrer la vidéo",
"dragInstruction": "Faites glisser chaque côté pour ajuster la zone de recadrage",
"ratio": "Ratio",
"free": "Libre",
"done": "Terminer",
"lockAspectRatio": "Verrouiller le ratio",
"unlockAspectRatio": "Déverrouiller le ratio"
},
"exportFormat": {
"mp4": "MP4",
"gif": "GIF",
"mp4Video": "Vidéo MP4",
"mp4Description": "Fichier vidéo haute qualité",
"gifAnimation": "Animation GIF",
"gifDescription": "Image animée pour le partage"
},
"exportQuality": {
"title": "Qualité d'export",
"low": "Faible",
"medium": "Moyenne",
"high": "Haute"
},
"gifSettings": {
"frameRate": "Fréquence d'images GIF",
"size": "Taille du GIF",
"loop": "GIF en boucle"
},
"project": {
"save": "Enregistrer le projet",
"load": "Charger un projet"
},
"export": {
"videoButton": "Exporter la vidéo",
"gifButton": "Exporter le GIF",
"chooseSaveLocation": "Choisir l'emplacement d'enregistrement"
},
"links": {
"reportBug": "Signaler un bug",
"starOnGithub": "Étoile sur GitHub"
},
"imageUpload": {
"invalidFileType": "Type de fichier invalide",
"jpgOnly": "Veuillez téléverser un fichier image JPG ou JPEG.",
"uploadSuccess": "Image personnalisée téléversée avec succès !",
"failedToUpload": "Échec du téléversement de l'image",
"errorReading": "Une erreur s'est produite lors de la lecture du fichier."
},
"annotation": {
"title": "Paramètres d'annotation",
"active": "Actif",
"typeText": "Texte",
"typeImage": "Image",
"typeArrow": "Flèche",
"typeBlur": "Flou",
"textContent": "Contenu du texte",
"textPlaceholder": "Saisissez votre texte...",
"fontStyle": "Style de police",
"selectStyle": "Choisir un style",
"size": "Taille",
"customFonts": "Polices personnalisées",
"textColor": "Couleur du texte",
"background": "Arrière-plan",
"none": "Aucun",
"color": "Couleur",
"clearBackground": "Supprimer l'arrière-plan",
"uploadImage": "Téléverser une image",
"supportedFormats": "Formats supportés : JPG, PNG, GIF, WebP",
"arrowDirection": "Direction de la flèche",
"strokeWidth": "Épaisseur du trait : {{width}}px",
"arrowColor": "Couleur de la flèche",
"blurShape": "Forme du flou",
"blurIntensity": "Intensité du flou",
"blurShapeRectangle": "Rectangle",
"blurShapeOval": "Ovale",
"blurShapeFreehand": "Main levée",
"deleteAnnotation": "Supprimer l'annotation",
"shortcutsAndTips": "Raccourcis & Astuces",
"tipMovePlayhead": "Déplacez la tête de lecture sur la section d'annotation et sélectionnez un élément.",
"tipTabCycle": "Utilisez Tab pour cycler entre les éléments superposés.",
"tipShiftTabCycle": "Utilisez Shift+Tab pour cycler en sens inverse.",
"invalidImageType": "Type de fichier invalide",
"imageFormatsOnly": "Veuillez téléverser un fichier image JPG, PNG, GIF ou WebP.",
"imageUploadSuccess": "Image téléversée avec succès !",
"failedImageUpload": "Échec du téléversement de l'image"
},
"fontStyles": {
"classic": "Classique",
"editor": "Éditeur",
"strong": "Gras",
"typewriter": "Machine à écrire",
"deco": "Déco",
"simple": "Simple",
"modern": "Moderne",
"clean": "Épuré"
},
"customFont": {
"dialogTitle": "Ajouter une police Google",
"urlLabel": "URL d'import Google Fonts",
"urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
"urlHelp": "Obtenez-la depuis Google Fonts : Sélectionnez une police → Cliquez sur « Obtenir la police » → Copiez l'URL @import",
"nameLabel": "Nom d'affichage",
"namePlaceholder": "Ma police personnalisée",
"nameHelp": "C'est ainsi que la police apparaîtra dans le sélecteur de polices",
"addButton": "Ajouter la police",
"addingButton": "Ajout en cours...",
"errorEmptyUrl": "Veuillez saisir une URL d'import Google Fonts",
"errorInvalidUrl": "Veuillez saisir une URL Google Fonts valide",
"errorEmptyName": "Veuillez saisir un nom de police",
"errorExtractFailed": "Impossible d'extraire la famille de polices depuis l'URL",
"successMessage": "Police « {{fontName}} » ajoutée avec succès",
"failedToAdd": "Échec de l'ajout de la police",
"errorTimeout": "La police a mis trop de temps à charger. Vérifiez l'URL et réessayez.",
"errorLoadFailed": "La police n'a pas pu être chargée. Vérifiez que l'URL Google Fonts est correcte."
},
"language": {
"title": "Langue"
}
}
+37
View File
@@ -0,0 +1,37 @@
{
"title": "Raccourcis clavier",
"customize": "Personnaliser",
"configurable": "Configurable",
"fixed": "Fixe",
"pressKey": "Appuyez sur une touche…",
"clickToChange": "Cliquez pour modifier",
"pressEscToCancel": "Appuyez sur Échap pour annuler",
"helpText": "Cliquez sur un raccourci puis appuyez sur la nouvelle combinaison de touches. Appuyez sur Échap pour annuler.",
"resetToDefaults": "Réinitialiser les valeurs par défaut",
"alreadyUsedBy": "Déjà utilisé par {{action}}",
"swap": "Échanger",
"reservedShortcut": "Ce raccourci est réservé pour « {{label}} » et ne peut pas être réassigné.",
"savedToast": "Raccourcis clavier enregistrés",
"resetToast": "Réinitialisé aux raccourcis par défaut — cliquez sur Enregistrer pour appliquer",
"actions": {
"addZoom": "Ajouter un zoom",
"addTrim": "Ajouter une coupe",
"addSpeed": "Ajouter une vitesse",
"addAnnotation": "Ajouter une annotation",
"addBlur": "Ajouter un flou",
"addKeyframe": "Ajouter une image-clé",
"deleteSelected": "Supprimer la sélection",
"playPause": "Lecture / Pause"
},
"fixedActions": {
"undo": "Annuler",
"redo": "Rétablir",
"cycleAnnotationsForward": "Parcourir les annotations en avant",
"cycleAnnotationsBackward": "Parcourir les annotations en arrière",
"deleteSelectedAlt": "Supprimer la sélection (alt)",
"panTimeline": "Panoramique de la timeline",
"zoomTimeline": "Zoom de la timeline",
"frameBack": "Image précédente",
"frameForward": "Image suivante"
}
}
+53
View File
@@ -0,0 +1,53 @@
{
"buttons": {
"addZoom": "Ajouter un zoom (Z)",
"suggestZooms": "Suggérer des zooms depuis le curseur",
"addTrim": "Ajouter une coupe (T)",
"addAnnotation": "Ajouter une annotation (A)",
"addSpeed": "Ajouter une vitesse (S)",
"addBlur": "Ajouter un flou (B)"
},
"hints": {
"pressZoom": "Appuyez sur Z pour ajouter un zoom",
"pressTrim": "Appuyez sur T pour ajouter une coupe",
"pressAnnotation": "Appuyez sur A pour ajouter une annotation",
"pressSpeed": "Appuyez sur S pour ajouter une vitesse",
"pressBlur": "Appuyez sur B pour ajouter une zone de flou"
},
"labels": {
"pan": "Panoramique",
"zoom": "Zoom",
"zoomItem": "Zoom {{index}}",
"trimItem": "Coupe {{index}}",
"speedItem": "Vitesse {{index}}",
"annotationItem": "Annotation",
"imageItem": "Image",
"emptyText": "Texte vide",
"blurItem": "Flou {{index}}"
},
"emptyState": {
"noVideo": "Aucune vidéo chargée",
"dragAndDrop": "Glissez-déposez une vidéo pour commencer à éditer"
},
"errors": {
"cannotPlaceZoom": "Impossible de placer le zoom ici",
"zoomExistsAtLocation": "Un zoom existe déjà à cet emplacement ou l\u0027espace disponible est insuffisant.",
"zoomSuggestionUnavailable": "Gestionnaire de suggestions de zoom non disponible",
"noCursorTelemetry": "Aucune télémétrie de curseur disponible",
"noCursorTelemetryDescription": "Enregistrez d\u0027abord un screencast pour générer des suggestions basées sur le curseur.",
"noUsableTelemetry": "Aucune télémétrie de curseur utilisable",
"noUsableTelemetryDescription": "L\u0027enregistrement ne contient pas suffisamment de données de mouvement du curseur.",
"noDwellMoments": "Aucun moment de pause du curseur trouvé",
"noDwellMomentsDescription": "Essayez un enregistrement avec des pauses plus lentes du curseur sur les actions importantes.",
"noAutoZoomSlots": "Aucun emplacement de zoom automatique disponible",
"noAutoZoomSlotsDescription": "Les points de pause détectés chevauchent des régions de zoom existantes.",
"cannotPlaceTrim": "Impossible de placer la coupe ici",
"trimExistsAtLocation": "Une coupe existe déjà à cet emplacement ou l\u0027espace disponible est insuffisant.",
"cannotPlaceSpeed": "Impossible de placer la vitesse ici",
"speedExistsAtLocation": "Une région de vitesse existe déjà à cet emplacement ou l\u0027espace disponible est insuffisant."
},
"success": {
"addedZoomSuggestions": "{{count}} suggestion de zoom basée sur le curseur ajoutée",
"addedZoomSuggestionsPlural": "{{count}} suggestions de zoom basées sur le curseur ajoutées"
}
}
+29
View File
@@ -0,0 +1,29 @@
{
"actions": {
"cancel": "İptal",
"save": "Kaydet",
"delete": "Sil",
"close": "Kapat",
"share": "Paylaş",
"done": "Tamam",
"open": "Aç",
"upload": "Yükle",
"export": "Dışa Aktar",
"file": "Dosya",
"edit": "Düzenle",
"view": "Görünüm",
"window": "Pencere",
"quit": "Çıkış",
"stopRecording": "Kaydı Durdur"
},
"playback": {
"play": "Oynat",
"pause": "Duraklat",
"fullscreen": "Tam Ekran",
"exitFullscreen": "Tam Ekrandan Çık"
},
"locale": {
"name": "Türkçe",
"short": "TR"
}
}
+68
View File
@@ -0,0 +1,68 @@
{
"export": {
"complete": "Dışa Aktarım Tamamlandı",
"yourFormatReady": "{{format}} dosyanız hazır",
"showInFolder": "Klasörde Göster",
"finalizingVideo": "Video dışa aktarımı sonlandırılıyor...",
"compilingGifProgress": "GIF derleniyor... %{{progress}}",
"compilingGifWait": "GIF derleniyor... Bu biraz zaman alabilir",
"takeMoment": "Bu biraz zaman alabilir...",
"failed": "Dışa Aktarım Başarısız",
"tryAgain": "Lütfen tekrar deneyin",
"finalizingVideoTitle": "Video Sonlandırılıyor",
"compilingGif": "GIF Derleniyor",
"exportingFormat": "{{format}} Dışa Aktarılıyor",
"compiling": "Derleniyor",
"renderingFrames": "Kareler İşleniyor",
"processing": "İşleniyor...",
"finalizing": "Sonlandırılıyor...",
"compilingStatus": "Derleniyor...",
"status": "Durum",
"format": "Biçim",
"frames": "Kareler",
"cancelExport": "Dışa Aktarımı İptal Et",
"savedSuccessfully": "{{format}} başarıyla kaydedildi!"
},
"tutorial": {
"triggerLabel": "Kırpma nasıl çalışır",
"title": "Kırpma Nasıl Çalışır",
"description": "Videonuzun istenmeyen bölümlerini nasıl keseceğinizi anlayın.",
"explanation": "Kırpma aracı, kaldırmak istediğiniz bölümleri tanımlayarak çalışır.",
"explanationRemove": "kaldırmak",
"explanationCovered": "kaplanan",
"explanationEnd": "kırmızı kırpma bölgesi ile işaretlenen kısımlar dışa aktarımda kesilecektir.",
"visualExample": "Görsel Örnek",
"removed": "KALDIRILDI",
"kept": "Korundu",
"part1": "Bölüm 1",
"part2": "Bölüm 2",
"part3": "Bölüm 3",
"finalVideo": "Son Video",
"step1Title": "1. Kırpma Ekle",
"step1Description": "Kaldırılacak bölümü işaretlemek için T tuşuna basın veya makas simgesine tıklayın.",
"step2Title": "2. Ayarla",
"step2Description": "Kesmek istediğiniz kısmı tam olarak kaplamak için kırmızı bölgenin kenarlarını sürükleyin."
},
"unsavedChanges": {
"title": "Kaydedilmemiş Değişiklikler",
"message": "Kaydedilmemiş değişiklikleriniz var.",
"detail": "Kapatmadan önce projenizi kaydetmek ister misiniz?",
"saveAndClose": "Kaydet ve Kapat",
"discardAndClose": "Kaydetmeden Kapat",
"loadProject": "Proje Yükle…",
"saveProject": "Proje Kaydet…",
"saveProjectAs": "Farklı Kaydet…"
},
"fileDialogs": {
"saveGif": "Dışa Aktarılan GIF'i Kaydet",
"saveVideo": "Dışa Aktarılan Videoyu Kaydet",
"selectVideo": "Video Dosyası Seç",
"saveProject": "OpenScreen Projesini Kaydet",
"openProject": "OpenScreen Projesini Aç",
"gifImage": "GIF Görüntüsü",
"mp4Video": "MP4 Video",
"videoFiles": "Video Dosyaları",
"openscreenProject": "OpenScreen Projesi",
"allFiles": "Tüm Dosyalar"
}
}
+35
View File
@@ -0,0 +1,35 @@
{
"errors": {
"noVideoLoaded": "Video yüklenmedi",
"videoNotReady": "Video hazır değil",
"unableToDetermineSourcePath": "Kaynak video yolu belirlenemiyor",
"failedToSaveGif": "GIF kaydedilemedi",
"gifExportFailed": "GIF dışa aktarımı başarısız oldu",
"failedToSaveVideo": "Video kaydedilemedi",
"exportFailed": "Dışa aktarım başarısız oldu",
"exportFailedWithError": "Dışa aktarım başarısız: {{error}}",
"failedToSaveExport": "Dışa aktarım kaydedilemedi",
"failedToSaveExportedVideo": "Dışa aktarılan video kaydedilemedi",
"failedToRevealInFolder": "Klasörde gösterme hatası: {{error}}"
},
"export": {
"canceled": "Dışa aktarım iptal edildi",
"exportedSuccessfully": "{{format}} başarıyla dışa aktarıldı"
},
"project": {
"saveCanceled": "Proje kaydetme iptal edildi",
"failedToSave": "Proje kaydedilemedi",
"savedTo": "Proje şuraya kaydedildi: {{path}}",
"failedToLoad": "Proje yüklenemedi",
"invalidFormat": "Geçersiz proje dosyası biçimi",
"loadedFrom": "Proje şuradan yüklendi: {{path}}"
},
"recording": {
"failedCameraAccess": "Kamera erişimi istenemedi.",
"cameraBlocked": "Kamera erişimi engellendi. Kamerayı kullanmak için sistem ayarlarından izin verin.",
"systemAudioUnavailable": "Sistem sesi kullanılamıyor. Sistem sesi olmadan kaydediliyor.",
"microphoneDenied": "Mikrofon erişimi reddedildi. Kayıt ses olmadan devam edecek.",
"cameraDenied": "Kamera erişimi reddedildi. Kayıt kamera olmadan devam edecek.",
"permissionDenied": "Kayıt izni reddedildi. Lütfen ekran kaydına izin verin."
}
}
+48
View File
@@ -0,0 +1,48 @@
{
"tooltips": {
"hideHUD": "Kontrol panelini gizle",
"closeApp": "Uygulamayı kapat",
"restartRecording": "Kaydı yeniden başlat",
"cancelRecording": "Kaydı iptal et",
"pauseRecording": "Kaydı duraklat",
"resumeRecording": "Kayda devam et",
"openVideoFile": "Video dosyası aç",
"openProject": "Proje aç"
},
"audio": {
"enableSystemAudio": "Sistem sesini etkinleştir",
"disableSystemAudio": "Sistem sesini devre dışı bırak",
"enableMicrophone": "Mikrofonu etkinleştir",
"disableMicrophone": "Mikrofonu devre dışı bırak",
"defaultMicrophone": "Varsayılan Mikrofon",
"enableNoiseReduction": "Gürültü azaltmayı etkinleştir (yapay zeka destekli)",
"disableNoiseReduction": "Gürültü azaltmayı devre dışı bırak",
"noiseReduction": "Gürültü azaltma",
"clickToCycle": "Seviye değiştirmek için tıklayın",
"nrLevel": {
"light": "Hafif",
"moderate": "Orta",
"aggressive": "Güçlü"
},
"noiseReductionPrompt": "Daha net ses için yapay zeka destekli gürültü azaltmayı etkinleştirmek ister misiniz?",
"enableNoiseReductionShort": "Etkinleştir"
},
"webcam": {
"enableWebcam": "Kamerayı etkinleştir",
"disableWebcam": "Kamerayı devre dışı bırak",
"defaultCamera": "Varsayılan Kamera",
"searching": "Aranıyor...",
"noneFound": "Kamera bulunamadı",
"unavailable": "Kamera kullanılamıyor"
},
"sourceSelector": {
"loading": "Kaynaklar yükleniyor...",
"screens": "Ekranlar ({{count}})",
"windows": "Pencereler ({{count}})",
"defaultSourceName": "Ekran"
},
"recording": {
"selectSource": "Lütfen kayıt için bir kaynak seçin"
},
"language": "Dil"
}
+176
View File
@@ -0,0 +1,176 @@
{
"zoom": {
"level": "Yakınlaştırma Seviyesi",
"selectRegion": "Ayarlamak için bir yakınlaştırma bölgesi seçin",
"deleteZoom": "Yakınlaştırmayı Sil",
"focusMode": {
"title": "Odak Modu",
"manual": "Manuel",
"auto": "Otomatik",
"autoDescription": "Kamera kaydedilen imleç konumunu takip eder"
}
},
"speed": {
"playbackSpeed": "Oynatma Hızı",
"selectRegion": "Ayarlamak için bir hız bölgesi seçin",
"deleteRegion": "Hız Bölgesini Sil"
},
"trim": {
"deleteRegion": "Kırpma Bölgesini Sil"
},
"layout": {
"title": "Düzen",
"preset": "Ön Ayar",
"selectPreset": "Ön ayar seçin",
"pictureInPicture": "Resim İçinde Resim",
"verticalStack": "Dikey Yığın",
"webcamShape": "Kamera Şekli"
},
"effects": {
"title": "Video Efektleri",
"blurBg": "Arka Planı Bulanıklaştır",
"motionBlur": "Hareket Bulanıklığı",
"off": "kapalı",
"shadow": "Gölge",
"roundness": "Yuvarlaklık",
"padding": "Dolgu"
},
"background": {
"title": "Arka Plan",
"image": "Görüntü",
"color": "Renk",
"gradient": "Gradyan",
"uploadCustom": "Özel Yükle",
"gradientLabel": "Gradyan {{index}}"
},
"crop": {
"title": "Kırpma",
"cropVideo": "Videoyu Kırp",
"dragInstruction": "Kırpma alanını ayarlamak için her kenarı sürükleyin",
"ratio": "Oran",
"free": "Serbest",
"done": "Tamam",
"lockAspectRatio": "En boy oranını kilitle",
"unlockAspectRatio": "En boy oranının kilidini aç"
},
"exportFormat": {
"mp4": "MP4",
"gif": "GIF",
"mp4Video": "MP4 Video",
"mp4Description": "Yüksek kaliteli video dosyası",
"gifAnimation": "GIF Animasyon",
"gifDescription": "Paylaşım için hareketli görüntü"
},
"exportQuality": {
"title": "Dışa Aktarım Kalitesi",
"low": "Düşük",
"medium": "Orta",
"high": "Yüksek"
},
"gifSettings": {
"frameRate": "GIF Kare Hızı",
"size": "GIF Boyutu",
"loop": "GIF Döngüsü"
},
"project": {
"save": "Projeyi Kaydet",
"load": "Proje Yükle"
},
"export": {
"videoButton": "Videoyu Dışa Aktar",
"gifButton": "GIF Olarak Dışa Aktar",
"chooseSaveLocation": "Kayıt Konumu Seç"
},
"links": {
"reportBug": "Hata Bildir",
"starOnGithub": "GitHub'da Yıldızla"
},
"imageUpload": {
"invalidFileType": "Geçersiz dosya türü",
"jpgOnly": "Lütfen bir JPG veya JPEG görüntü dosyası yükleyin.",
"uploadSuccess": "Özel görüntü başarıyla yüklendi!",
"failedToUpload": "Görüntü yüklenemedi",
"errorReading": "Dosya okunurken bir hata oluştu."
},
"annotation": {
"title": "Açıklama Ayarları",
"active": "Aktif",
"typeText": "Metin",
"typeImage": "Görüntü",
"typeArrow": "Ok",
"typeBlur": "Bulanık",
"textContent": "Metin İçeriği",
"textPlaceholder": "Metninizi girin...",
"fontStyle": "Yazı Tipi Stili",
"selectStyle": "Stil seçin",
"size": "Boyut",
"customFonts": "Özel Yazı Tipleri",
"textColor": "Metin Rengi",
"background": "Arka Plan",
"none": "Yok",
"color": "Renk",
"clearBackground": "Arka Planı Temizle",
"uploadImage": "Görüntü Yükle",
"supportedFormats": "Desteklenen biçimler: JPG, PNG, GIF, WebP",
"arrowDirection": "Ok Yönü",
"strokeWidth": "Çizgi Kalınlığı: {{width}}px",
"arrowColor": "Ok Rengi",
"blurShape": "Bulanık Şekli",
"blurIntensity": "Bulanıklık Yoğunluğu",
"blurShapeRectangle": "Dikdörtgen",
"blurShapeOval": "Oval",
"blurShapeFreehand": "Serbest",
"deleteAnnotation": "Açıklamayı Sil",
"shortcutsAndTips": "Kısayollar ve İpuçları",
"tipMovePlayhead": "Oynatma imlecini çakışan açıklama bölümüne taşıyın ve bir öğe seçin.",
"tipTabCycle": "Çakışan öğeler arasında geçiş yapmak için Tab tuşunu kullanın.",
"tipShiftTabCycle": "Geriye doğru geçiş yapmak için Shift+Tab kullanın.",
"invalidImageType": "Geçersiz dosya türü",
"imageFormatsOnly": "Lütfen bir JPG, PNG, GIF veya WebP görüntü dosyası yükleyin.",
"imageUploadSuccess": "Görüntü başarıyla yüklendi!",
"failedImageUpload": "Görüntü yüklenemedi"
},
"fontStyles": {
"classic": "Klasik",
"editor": "Editör",
"strong": "Kalın",
"typewriter": "Daktilo",
"deco": "Dekoratif",
"simple": "Sade",
"modern": "Modern",
"clean": "Temiz"
},
"customFont": {
"dialogTitle": "Google Yazı Tipi Ekle",
"urlLabel": "Google Fonts İçe Aktarım URL'si",
"urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
"urlHelp": "Google Fonts'tan alabilirsiniz: Bir yazı tipi seçin → \"Get font\"a tıklayın → @import URL'sini kopyalayın",
"nameLabel": "Görünen Ad",
"namePlaceholder": "Özel Yazı Tipim",
"nameHelp": "Yazı tipinin seçicide nasıl görüneceğini belirler",
"addButton": "Yazı Tipi Ekle",
"addingButton": "Ekleniyor...",
"errorEmptyUrl": "Lütfen bir Google Fonts içe aktarım URL'si girin",
"errorInvalidUrl": "Lütfen geçerli bir Google Fonts URL'si girin",
"errorEmptyName": "Lütfen bir yazı tipi adı girin",
"errorExtractFailed": "URL'den yazı tipi ailesi çıkarılamadı",
"successMessage": "\"{{fontName}}\" yazı tipi başarıyla eklendi",
"failedToAdd": "Yazı tipi eklenemedi",
"errorTimeout": "Yazı tipinin yüklenmesi çok uzun sürdü. Lütfen URL'yi kontrol edip tekrar deneyin.",
"errorLoadFailed": "Yazı tipi yüklenemedi. Lütfen Google Fonts URL'sinin doğruluğunu kontrol edin."
},
"language": {
"title": "Dil"
},
"audio": {
"title": "Ses",
"noiseReduction": "Gürültü Azaltma",
"level": "Seviye",
"nrLevel": {
"light": "Hafif",
"moderate": "Orta",
"aggressive": "Güçlü"
},
"nrDescription": "Yapay zeka destekli gürültü azaltma arka plan gürültüsünü temizler. Daha yüksek seviyeler daha agresiftir ancak ses kalitesini etkileyebilir."
}
}
+37
View File
@@ -0,0 +1,37 @@
{
"title": "Klavye Kısayolları",
"customize": "Özelleştir",
"configurable": "Yapılandırılabilir",
"fixed": "Sabit",
"pressKey": "Bir tuşa basın…",
"clickToChange": "Değiştirmek için tıklayın",
"pressEscToCancel": "İptal etmek için Esc tuşuna basın",
"helpText": "Bir kısayola tıklayın, ardından yeni tuş kombinasyonuna basın. İptal etmek için Esc tuşuna basın.",
"resetToDefaults": "Varsayılanlara sıfırla",
"alreadyUsedBy": "\"{{action}}\" tarafından zaten kullanılıyor",
"swap": "Değiştir",
"reservedShortcut": "Bu kısayol \"{{label}}\" için ayrılmıştır ve yeniden atanamaz.",
"savedToast": "Klavye kısayolları kaydedildi",
"resetToast": "Varsayılan kısayollara sıfırlandı — uygulamak için Kaydet'e tıklayın",
"actions": {
"addZoom": "Yakınlaştırma Ekle",
"addTrim": "Kırpma Ekle",
"addSpeed": "Hız Ekle",
"addAnnotation": "Açıklama Ekle",
"addBlur": "Bulanik Ekle",
"addKeyframe": "Anahtar Kare Ekle",
"deleteSelected": "Seçileni Sil",
"playPause": "Oynat / Duraklat"
},
"fixedActions": {
"undo": "Geri Al",
"redo": "Yinele",
"cycleAnnotationsForward": "Açıklamalar Arasında İleri Geç",
"cycleAnnotationsBackward": "Açıklamalar Arasında Geri Geç",
"deleteSelectedAlt": "Seçileni Sil (alternatif)",
"panTimeline": "Zaman Çizelgesini Kaydır",
"zoomTimeline": "Zaman Çizelgesini Yakınlaştır",
"frameBack": "Önceki Kare",
"frameForward": "Sonraki Kare"
}
}
+53
View File
@@ -0,0 +1,53 @@
{
"buttons": {
"addZoom": "Yakınlaştırma Ekle (Z)",
"suggestZooms": "İmleçten Yakınlaştırma Öner",
"addTrim": "Kırpma Ekle (T)",
"addAnnotation": "Açıklama Ekle (A)",
"addSpeed": "Hız Ekle (S)",
"addBlur": "Bulanık ekle (B)"
},
"hints": {
"pressZoom": "Yakınlaştırma eklemek için Z tuşuna basın",
"pressTrim": "Kırpma eklemek için T tuşuna basın",
"pressAnnotation": "Açıklama eklemek için A tuşuna basın",
"pressSpeed": "Hız eklemek için S tuşuna basın",
"pressBlur": "Bulanık bölge eklemek için B tuşuna basın"
},
"labels": {
"pan": "Kaydır",
"zoom": "Yakınlaştır",
"zoomItem": "Yakınlaştırma {{index}}",
"trimItem": "Kırpma {{index}}",
"speedItem": "Hız {{index}}",
"annotationItem": "Açıklama",
"imageItem": "Görüntü",
"emptyText": "Boş metin",
"blurItem": "Bulanık {{index}}"
},
"emptyState": {
"noVideo": "Video Yüklenmedi",
"dragAndDrop": "Düzenlemeye başlamak için bir video sürükleyip bırakın"
},
"errors": {
"cannotPlaceZoom": "Buraya yakınlaştırma yerleştirilemiyor",
"zoomExistsAtLocation": "Bu konumda zaten bir yakınlaştırma var veya yeterli alan yok.",
"zoomSuggestionUnavailable": "Yakınlaştırma öneri işleyicisi kullanılamıyor",
"noCursorTelemetry": "İmleç telemetrisi mevcut değil",
"noCursorTelemetryDescription": "İmleç tabanlı öneriler oluşturmak için önce bir ekran kaydı yapın.",
"noUsableTelemetry": "Kullanılabilir imleç telemetrisi yok",
"noUsableTelemetryDescription": "Kayıt yeterli imleç hareketi verisi içermiyor.",
"noDwellMoments": "Belirgin imleç bekleme anları bulunamadı",
"noDwellMomentsDescription": "Önemli işlemlerde daha yavaş imleç duraklamaları olan bir kayıt deneyin.",
"noAutoZoomSlots": "Otomatik yakınlaştırma alanı yok",
"noAutoZoomSlotsDescription": "Algılanan bekleme noktaları mevcut yakınlaştırma bölgeleriyle çakışıyor.",
"cannotPlaceTrim": "Buraya kırpma yerleştirilemiyor",
"trimExistsAtLocation": "Bu konumda zaten bir kırpma var veya yeterli alan yok.",
"cannotPlaceSpeed": "Buraya hız yerleştirilemiyor",
"speedExistsAtLocation": "Bu konumda zaten bir hız bölgesi var veya yeterli alan yok."
},
"success": {
"addedZoomSuggestions": "{{count}} imleç tabanlı yakınlaştırma önerisi eklendi",
"addedZoomSuggestionsPlural": "{{count}} imleç tabanlı yakınlaştırma önerisi eklendi"
}
}
+29
View File
@@ -0,0 +1,29 @@
{
"actions": {
"cancel": "取消",
"save": "保存",
"delete": "删除",
"close": "关闭",
"share": "分享",
"done": "完成",
"open": "打开",
"upload": "上传",
"export": "导出",
"file": "文件",
"edit": "编辑",
"view": "视图",
"window": "窗口",
"quit": "退出",
"stopRecording": "停止录制"
},
"playback": {
"play": "播放",
"pause": "暂停",
"fullscreen": "全屏",
"exitFullscreen": "退出全屏"
},
"locale": {
"name": "中文",
"short": "中文"
}
}
+70
View File
@@ -0,0 +1,70 @@
{
"export": {
"complete": "导出完成",
"yourFormatReady": "您的 {{format}} 已准备就绪",
"showInFolder": "在文件夹中显示",
"finalizingVideo": "正在完成视频导出...",
"compilingGifProgress": "正在编译 GIF... {{progress}}%",
"compilingGifWait": "正在编译 GIF... 这可能需要一些时间",
"takeMoment": "这可能需要一点时间...",
"failed": "导出失败",
"tryAgain": "请重试",
"finalizingVideoTitle": "正在完成视频",
"compilingGif": "正在编译 GIF",
"exportingFormat": "正在导出 {{format}}",
"compiling": "编译中",
"renderingFrames": "渲染帧",
"processing": "处理中...",
"finalizing": "正在完成...",
"compilingStatus": "编译中...",
"status": "状态",
"format": "格式",
"frames": "帧",
"cancelExport": "取消导出",
"savedSuccessfully": "{{format}} 保存成功!"
},
"tutorial": {
"triggerLabel": "剪辑功能说明",
"title": "剪辑功能说明",
"description": "了解如何剪掉视频中不需要的部分。",
"explanationBefore": "剪辑工具通过定义您要",
"remove": "移除",
"explanationMiddle": "——任何被",
"covered": "覆盖",
"explanationAfter": "的红色剪辑区域部分将在导出时被剪掉。",
"visualExample": "示例演示",
"removed": "已移除",
"kept": "保留",
"part1": "第 1 部分",
"part2": "第 2 部分",
"part3": "第 3 部分",
"finalVideo": "最终视频",
"step1Title": "1. 添加剪辑",
"step1DescriptionBefore": "按",
"step1DescriptionAfter": "键或点击剪刀图标来标记要移除的片段。",
"step2Title": "2. 调整",
"step2Description": "拖动红色区域的边缘,精确覆盖您要剪掉的部分。"
},
"unsavedChanges": {
"title": "未保存的更改",
"message": "您有未保存的更改。",
"detail": "是否在关闭前保存项目?",
"saveAndClose": "保存并关闭",
"discardAndClose": "放弃并关闭",
"loadProject": "加载项目…",
"saveProject": "保存项目…",
"saveProjectAs": "项目另存为…"
},
"fileDialogs": {
"saveGif": "保存导出的 GIF",
"saveVideo": "保存导出的视频",
"selectVideo": "选择视频文件",
"saveProject": "保存 OpenScreen 项目",
"openProject": "打开 OpenScreen 项目",
"gifImage": "GIF 图片",
"mp4Video": "MP4 视频",
"videoFiles": "视频文件",
"openscreenProject": "OpenScreen 项目",
"allFiles": "所有文件"
}
}
+35
View File
@@ -0,0 +1,35 @@
{
"errors": {
"noVideoLoaded": "未加载视频",
"videoNotReady": "视频未就绪",
"unableToDetermineSourcePath": "无法确定源视频路径",
"failedToSaveGif": "保存 GIF 失败",
"gifExportFailed": "GIF 导出失败",
"failedToSaveVideo": "保存视频失败",
"exportFailed": "导出失败",
"exportFailedWithError": "导出失败:{{error}}",
"failedToSaveExport": "保存导出文件失败",
"failedToSaveExportedVideo": "保存导出的视频失败",
"failedToRevealInFolder": "在文件夹中显示时出错:{{error}}"
},
"export": {
"canceled": "导出已取消",
"exportedSuccessfully": "{{format}} 导出成功"
},
"project": {
"saveCanceled": "项目保存已取消",
"failedToSave": "保存项目失败",
"savedTo": "项目已保存至 {{path}}",
"failedToLoad": "加载项目失败",
"invalidFormat": "无效的项目文件格式",
"loadedFrom": "项目已从 {{path}} 加载"
},
"recording": {
"failedCameraAccess": "请求摄像头权限失败。",
"cameraBlocked": "摄像头权限已被阻止。请在系统设置中启用以使用摄像头。",
"systemAudioUnavailable": "系统音频不可用。将在无系统音频的情况下录制。",
"microphoneDenied": "麦克风权限被拒绝。录制将继续,但不包含音频。",
"cameraDenied": "摄像头权限被拒绝。录制将继续,但不包含摄像头画面。",
"permissionDenied": "录屏权限被拒绝。请允许屏幕录制。"
}
}
+37
View File
@@ -0,0 +1,37 @@
{
"tooltips": {
"hideHUD": "隐藏控制面板",
"closeApp": "关闭应用",
"restartRecording": "重新开始录制",
"cancelRecording": "取消录制",
"pauseRecording": "暂停录制",
"resumeRecording": "继续录制",
"openVideoFile": "打开视频文件",
"openProject": "打开项目"
},
"audio": {
"enableSystemAudio": "启用系统音频",
"disableSystemAudio": "禁用系统音频",
"enableMicrophone": "启用麦克风",
"disableMicrophone": "禁用麦克风",
"defaultMicrophone": "默认麦克风"
},
"webcam": {
"enableWebcam": "启用摄像头",
"disableWebcam": "禁用摄像头",
"defaultCamera": "默认摄像头",
"searching": "正在搜索...",
"noneFound": "未找到摄像头",
"unavailable": "摄像头不可用"
},
"sourceSelector": {
"loading": "正在加载源...",
"screens": "屏幕 ({{count}})",
"windows": "窗口 ({{count}})",
"defaultSourceName": "屏幕"
},
"recording": {
"selectSource": "请选择要录制的源"
},
"language": "语言"
}
+168
View File
@@ -0,0 +1,168 @@
{
"zoom": {
"level": "缩放级别",
"selectRegion": "选择要调整的缩放区域",
"deleteZoom": "删除缩放",
"focusMode": {
"title": "对焦模式",
"manual": "手动",
"auto": "自动",
"autoDescription": "摄像头跟随录制时的光标位置"
}
},
"speed": {
"playbackSpeed": "播放速度",
"selectRegion": "选择要调整的速度区域",
"deleteRegion": "删除速度区域",
"customPlaybackSpeed": "自定义播放速度",
"maxSpeedError": "速度不能超过 16×"
},
"trim": {
"deleteRegion": "删除剪辑区域"
},
"layout": {
"title": "布局",
"preset": "预设",
"selectPreset": "选择预设",
"pictureInPicture": "画中画",
"verticalStack": "垂直堆叠",
"webcamShape": "摄像头形状",
"webcamSize": "摄像头大小"
},
"effects": {
"title": "视频效果",
"blurBg": "模糊背景",
"motionBlur": "运动模糊",
"off": "关",
"shadow": "阴影",
"roundness": "圆角",
"padding": "内边距"
},
"background": {
"title": "背景",
"image": "图片",
"color": "颜色",
"gradient": "渐变",
"uploadCustom": "上传自定义",
"gradientLabel": "渐变 {{index}}"
},
"crop": {
"title": "裁剪",
"cropVideo": "裁剪视频",
"dragInstruction": "拖动每一侧来调整裁剪区域",
"ratio": "比例",
"free": "自由",
"done": "完成",
"lockAspectRatio": "锁定宽高比",
"unlockAspectRatio": "解锁宽高比"
},
"exportFormat": {
"mp4": "MP4",
"gif": "GIF",
"mp4Video": "MP4 视频",
"mp4Description": "高质量视频文件",
"gifAnimation": "GIF 动画",
"gifDescription": "可分享的动态图片"
},
"exportQuality": {
"title": "导出质量",
"low": "低",
"medium": "中",
"high": "高"
},
"gifSettings": {
"frameRate": "GIF 帧率",
"size": "GIF 尺寸",
"loop": "循环 GIF"
},
"project": {
"save": "保存项目",
"load": "加载项目"
},
"export": {
"videoButton": "导出视频",
"gifButton": "导出 GIF",
"chooseSaveLocation": "选择保存位置"
},
"links": {
"reportBug": "报告错误",
"starOnGithub": "在 GitHub 上加星"
},
"imageUpload": {
"invalidFileType": "无效的文件类型",
"jpgOnly": "请上传 JPG 或 JPEG 格式的图片文件。",
"uploadSuccess": "自定义图片上传成功!",
"failedToUpload": "上传图片失败",
"errorReading": "读取文件时出错。"
},
"annotation": {
"title": "标注设置",
"active": "活动",
"typeText": "文本",
"typeImage": "图片",
"typeArrow": "箭头",
"typeBlur": "模糊",
"textContent": "文本内容",
"textPlaceholder": "输入您的文本...",
"fontStyle": "字体样式",
"selectStyle": "选择样式",
"size": "大小",
"customFonts": "自定义字体",
"textColor": "文本颜色",
"background": "背景",
"none": "无",
"color": "颜色",
"clearBackground": "清除背景",
"uploadImage": "上传图片",
"supportedFormats": "支持的格式:JPG、PNG、GIF、WebP",
"arrowDirection": "箭头方向",
"strokeWidth": "描边宽度:{{width}}px",
"arrowColor": "箭头颜色",
"blurShape": "模糊形状",
"blurIntensity": "模糊强度",
"blurShapeRectangle": "矩形",
"blurShapeOval": "椭圆",
"blurShapeFreehand": "自由手绘",
"deleteAnnotation": "删除标注",
"shortcutsAndTips": "快捷键与提示",
"tipMovePlayhead": "将播放头移动到重叠的标注区域并选择一个项目。",
"tipTabCycle": "使用 Tab 键在重叠项目之间循环切换。",
"tipShiftTabCycle": "使用 Shift+Tab 反向循环切换。",
"invalidImageType": "无效的文件类型",
"imageFormatsOnly": "请上传 JPG、PNG、GIF 或 WebP 格式的图片文件。",
"imageUploadSuccess": "图片上传成功!",
"failedImageUpload": "上传图片失败"
},
"fontStyles": {
"classic": "经典",
"editor": "编辑器",
"strong": "粗体",
"typewriter": "打字机",
"deco": "装饰",
"simple": "简约",
"modern": "现代",
"clean": "简洁"
},
"customFont": {
"dialogTitle": "添加 Google 字体",
"urlLabel": "Google Fonts 导入 URL",
"urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
"urlHelp": "从 Google Fonts 获取:选择字体 → 点击 \"Get font\" → 复制 @import URL",
"nameLabel": "显示名称",
"namePlaceholder": "我的自定义字体",
"nameHelp": "这是字体在字体选择器中显示的名称",
"addButton": "添加字体",
"addingButton": "添加中...",
"errorEmptyUrl": "请输入 Google Fonts 导入 URL",
"errorInvalidUrl": "请输入有效的 Google Fonts URL",
"errorEmptyName": "请输入字体名称",
"errorExtractFailed": "无法从 URL 中提取字体系列",
"successMessage": "字体 \"{{fontName}}\" 添加成功",
"failedToAdd": "添加字体失败",
"errorTimeout": "字体加载时间过长。请检查 URL 并重试。",
"errorLoadFailed": "无法加载该字体。请确认 Google Fonts URL 是否正确。"
},
"language": {
"title": "语言"
}
}
+37
View File
@@ -0,0 +1,37 @@
{
"title": "键盘快捷键",
"customize": "自定义",
"configurable": "可配置",
"fixed": "固定",
"pressKey": "请按下按键…",
"clickToChange": "点击以更改",
"pressEscToCancel": "按 Esc 取消",
"helpText": "点击一个快捷键,然后按下新的组合键。按 Esc 取消。",
"resetToDefaults": "恢复默认设置",
"alreadyUsedBy": "已被 \"{{action}}\" 使用",
"swap": "交换",
"reservedShortcut": "此快捷键已保留给 \"{{label}}\",无法重新分配。",
"savedToast": "键盘快捷键已保存",
"resetToast": "已恢复默认快捷键 — 点击保存以应用",
"actions": {
"addZoom": "添加缩放",
"addTrim": "添加剪辑",
"addSpeed": "添加速度",
"addAnnotation": "添加标注",
"addBlur": "添加模糊",
"addKeyframe": "添加关键帧",
"deleteSelected": "删除所选",
"playPause": "播放 / 暂停"
},
"fixedActions": {
"undo": "撤销",
"redo": "重做",
"cycleAnnotationsForward": "向前切换标注",
"cycleAnnotationsBackward": "向后切换标注",
"deleteSelectedAlt": "删除所选(替代)",
"panTimeline": "平移时间轴",
"zoomTimeline": "缩放时间轴",
"frameBack": "上一帧",
"frameForward": "下一帧"
}
}
+53
View File
@@ -0,0 +1,53 @@
{
"buttons": {
"addZoom": "添加缩放 (Z)",
"suggestZooms": "根据光标建议缩放",
"addTrim": "添加剪辑 (T)",
"addAnnotation": "添加标注 (A)",
"addSpeed": "添加速度 (S)",
"addBlur": "添加模糊 (B)"
},
"hints": {
"pressZoom": "按 Z 添加缩放",
"pressTrim": "按 T 添加剪辑",
"pressAnnotation": "按 A 添加标注",
"pressSpeed": "按 S 添加速度",
"pressBlur": "按 B 添加模糊区域"
},
"labels": {
"pan": "平移",
"zoom": "缩放",
"zoomItem": "缩放 {{index}}",
"trimItem": "剪辑 {{index}}",
"speedItem": "速度 {{index}}",
"annotationItem": "标注",
"imageItem": "图片",
"emptyText": "空文本",
"blurItem": "模糊 {{index}}"
},
"emptyState": {
"noVideo": "未加载视频",
"dragAndDrop": "拖放视频以开始编辑"
},
"errors": {
"cannotPlaceZoom": "无法在此处放置缩放",
"zoomExistsAtLocation": "此位置已存在缩放或没有足够的空间。",
"zoomSuggestionUnavailable": "缩放建议处理器不可用",
"noCursorTelemetry": "无可用的光标遥测数据",
"noCursorTelemetryDescription": "请先录制一段屏幕录像以生成基于光标的建议。",
"noUsableTelemetry": "无可用的光标遥测数据",
"noUsableTelemetryDescription": "录制内容没有包含足够的光标移动数据。",
"noDwellMoments": "未找到明确的光标停留时刻",
"noDwellMomentsDescription": "请尝试在重要操作上进行较慢光标停留的录制。",
"noAutoZoomSlots": "无可用的自动缩放位置",
"noAutoZoomSlotsDescription": "检测到的停留点与现有缩放区域重叠。",
"cannotPlaceTrim": "无法在此处放置剪辑",
"trimExistsAtLocation": "此位置已存在剪辑或没有足够的空间。",
"cannotPlaceSpeed": "无法在此处放置速度",
"speedExistsAtLocation": "此位置已存在速度区域或没有足够的空间。"
},
"success": {
"addedZoomSuggestions": "已添加 {{count}} 个基于光标的缩放建议",
"addedZoomSuggestionsPlural": "已添加 {{count}} 个基于光标的缩放建议"
}
}
@@ -0,0 +1,42 @@
import { describe, expect, it } from "vitest";
import { computeFrameStepTime, FRAME_DURATION_SEC } from "@/lib/frameStep";
describe("computeFrameStepTime", () => {
const duration = 10;
it("moves forward by one frame from the middle", () => {
const result = computeFrameStepTime(5, duration, "forward");
expect(result).toBeCloseTo(5 + FRAME_DURATION_SEC, 10);
});
it("moves backward by one frame from the middle", () => {
const result = computeFrameStepTime(5, duration, "backward");
expect(result).toBeCloseTo(5 - FRAME_DURATION_SEC, 10);
});
it("clamps to 0 when stepping backward at the beginning", () => {
const result = computeFrameStepTime(0, duration, "backward");
expect(result).toBe(0);
});
it("clamps to 0 when stepping backward near the beginning", () => {
const result = computeFrameStepTime(FRAME_DURATION_SEC / 2, duration, "backward");
expect(result).toBe(0);
});
it("clamps to duration when stepping forward at the end", () => {
const result = computeFrameStepTime(duration, duration, "forward");
expect(result).toBe(duration);
});
it("clamps to duration when stepping forward near the end", () => {
const result = computeFrameStepTime(duration - FRAME_DURATION_SEC / 2, duration, "forward");
expect(result).toBe(duration);
});
it("handles duration of 0 gracefully", () => {
expect(computeFrameStepTime(0, 0, "forward")).toBe(0);
expect(computeFrameStepTime(0, 0, "backward")).toBe(0);
});
});
+157 -20
View File
@@ -24,15 +24,110 @@ describe("computeCompositeLayout", () => {
webcamSize: { width: 1920, height: 1080 },
});
const refDim = Math.sqrt(1280 * 720);
const defaultFraction = 25 / 100; // DEFAULT_WEBCAM_SIZE_PRESET = 25
expect(layout).not.toBeNull();
expect(layout!.webcamRect).not.toBeNull();
expect(layout!.webcamRect!.width).toBeLessThanOrEqual(Math.round(1280 * 0.18) + 1);
expect(layout!.webcamRect!.height).toBeLessThanOrEqual(Math.round(720 * 0.18) + 1);
expect(layout!.webcamRect!.width).toBeLessThanOrEqual(Math.round(refDim * defaultFraction) + 1);
expect(layout!.webcamRect!.height).toBeLessThanOrEqual(
Math.round(refDim * defaultFraction) + 1,
);
expect(
Math.abs(layout!.webcamRect!.width * 1080 - layout!.webcamRect!.height * 1920),
).toBeLessThanOrEqual(1920);
});
it("produces consistent webcam size across landscape and portrait aspect ratios", () => {
const webcamSize = { width: 1280, height: 720 };
const landscape = computeCompositeLayout({
canvasSize: { width: 1920, height: 1080 },
screenSize: { width: 1920, height: 1080 },
webcamSize,
webcamSizePreset: 50,
});
const portrait = computeCompositeLayout({
canvasSize: { width: 1080, height: 1920 },
screenSize: { width: 1080, height: 1920 },
webcamSize,
webcamSizePreset: 50,
});
expect(landscape).not.toBeNull();
expect(portrait).not.toBeNull();
// Same total pixel count — webcam area should be comparable
const landscapeArea = landscape!.webcamRect!.width * landscape!.webcamRect!.height;
const portraitArea = portrait!.webcamRect!.width * portrait!.webcamRect!.height;
expect(landscapeArea).toBe(portraitArea);
});
it("scales the webcam proportionally as webcamSizePreset increases", () => {
const canvasSize = { width: 1920, height: 1080 };
const screenSize = { width: 1920, height: 1080 };
const webcamSize = { width: 1280, height: 720 };
const small = computeCompositeLayout({
canvasSize,
screenSize,
webcamSize,
webcamSizePreset: 10,
});
const medium = computeCompositeLayout({
canvasSize,
screenSize,
webcamSize,
webcamSizePreset: 25,
});
const large = computeCompositeLayout({
canvasSize,
screenSize,
webcamSize,
webcamSizePreset: 50,
});
expect(small!.webcamRect!.width).toBeLessThan(medium!.webcamRect!.width);
expect(medium!.webcamRect!.width).toBeLessThan(large!.webcamRect!.width);
expect(small!.webcamRect!.height).toBeLessThan(medium!.webcamRect!.height);
expect(medium!.webcamRect!.height).toBeLessThan(large!.webcamRect!.height);
});
it("clamps webcamSizePreset to the valid range (10–50)", () => {
const canvasSize = { width: 1920, height: 1080 };
const screenSize = { width: 1920, height: 1080 };
const webcamSize = { width: 1280, height: 720 };
const atMin = computeCompositeLayout({
canvasSize,
screenSize,
webcamSize,
webcamSizePreset: 10,
});
const belowMin = computeCompositeLayout({
canvasSize,
screenSize,
webcamSize,
webcamSizePreset: 1,
});
const atMax = computeCompositeLayout({
canvasSize,
screenSize,
webcamSize,
webcamSizePreset: 50,
});
const aboveMax = computeCompositeLayout({
canvasSize,
screenSize,
webcamSize,
webcamSizePreset: 100,
});
// Values below 10 should clamp to 10
expect(belowMin!.webcamRect!.width).toBe(atMin!.webcamRect!.width);
expect(belowMin!.webcamRect!.height).toBe(atMin!.webcamRect!.height);
// Values above 50 should clamp to 50
expect(aboveMax!.webcamRect!.width).toBe(atMax!.webcamRect!.width);
expect(aboveMax!.webcamRect!.height).toBe(atMax!.webcamRect!.height);
});
it("centers the combined screen and webcam stack in vertical stack mode", () => {
const layout = computeCompositeLayout({
canvasSize: { width: 1920, height: 1080 },
@@ -43,22 +138,19 @@ describe("computeCompositeLayout", () => {
});
expect(layout).not.toBeNull();
expect(layout?.screenRect).toEqual({
x: 576,
y: 108,
width: 768,
height: 432,
});
expect(layout?.webcamRect).toEqual({
x: 576,
y: 540,
width: 768,
height: 432,
borderRadius: 0,
});
// Webcam is full-width at the bottom
expect(layout!.webcamRect).not.toBeNull();
expect(layout!.webcamRect!.x).toBe(0);
expect(layout!.webcamRect!.width).toBe(1920);
expect(layout!.webcamRect!.borderRadius).toBe(0);
// Screen fills remaining space at the top (cover mode)
expect(layout!.screenRect.x).toBe(0);
expect(layout!.screenRect.y).toBe(0);
expect(layout!.screenRect.width).toBe(1920);
expect(layout!.screenCover).toBe(true);
});
it("keeps the screen centered and omits the webcam when dimensions are unavailable", () => {
it("keeps the screen full-canvas and omits the webcam when dimensions are unavailable in stack mode", () => {
const layout = computeCompositeLayout({
canvasSize: { width: 1920, height: 1080 },
maxContentSize: { width: 1536, height: 864 },
@@ -68,11 +160,56 @@ describe("computeCompositeLayout", () => {
expect(layout).not.toBeNull();
expect(layout?.screenRect).toEqual({
x: 192,
y: 108,
width: 1536,
height: 864,
x: 0,
y: 0,
width: 1920,
height: 1080,
});
expect(layout?.webcamRect).toBeNull();
expect(layout?.screenCover).toBe(true);
});
it("forces circular and square masks to use square dimensions", () => {
const circularLayout = computeCompositeLayout({
canvasSize: { width: 1920, height: 1080 },
screenSize: { width: 1920, height: 1080 },
webcamSize: { width: 1280, height: 720 },
webcamMaskShape: "circle",
});
const squareLayout = computeCompositeLayout({
canvasSize: { width: 1920, height: 1080 },
screenSize: { width: 1920, height: 1080 },
webcamSize: { width: 1280, height: 720 },
webcamMaskShape: "square",
});
expect(circularLayout?.webcamRect).not.toBeNull();
expect(squareLayout?.webcamRect).not.toBeNull();
expect(circularLayout?.webcamRect?.width).toBe(circularLayout?.webcamRect?.height);
expect(squareLayout?.webcamRect?.width).toBe(squareLayout?.webcamRect?.height);
expect(circularLayout?.webcamRect?.maskShape).toBe("circle");
expect(squareLayout?.webcamRect?.maskShape).toBe("square");
});
it("applies larger rounding for the rounded webcam mask", () => {
const roundedLayout = computeCompositeLayout({
canvasSize: { width: 1920, height: 1080 },
screenSize: { width: 1920, height: 1080 },
webcamSize: { width: 1280, height: 720 },
webcamMaskShape: "rounded",
});
const rectangleLayout = computeCompositeLayout({
canvasSize: { width: 1920, height: 1080 },
screenSize: { width: 1920, height: 1080 },
webcamSize: { width: 1280, height: 720 },
webcamMaskShape: "rectangle",
});
expect(roundedLayout?.webcamRect).not.toBeNull();
expect(rectangleLayout?.webcamRect).not.toBeNull();
expect(roundedLayout?.webcamRect?.borderRadius).toBeGreaterThan(
rectangleLayout?.webcamRect?.borderRadius ?? 0,
);
expect(roundedLayout?.webcamRect?.maskShape).toBe("rounded");
});
});
+95 -58
View File
@@ -7,6 +7,7 @@ export interface RenderRect {
export interface StyledRenderRect extends RenderRect {
borderRadius: number;
maskShape?: import("@/components/video-editor/types").WebcamMaskShape;
}
export interface Size {
@@ -15,6 +16,8 @@ export interface Size {
}
export type WebcamLayoutPreset = "picture-in-picture" | "vertical-stack";
/** Webcam size as a percentage of the canvas reference dimension (10–50). */
export type WebcamSizePreset = number;
export interface WebcamLayoutShadow {
color: string;
@@ -31,7 +34,6 @@ interface BorderRadiusRule {
interface OverlayTransform {
type: "overlay";
maxStageFraction: number;
marginFraction: number;
minMargin: number;
minSize: number;
@@ -52,21 +54,27 @@ export interface WebcamLayoutPresetDefinition {
export interface WebcamCompositeLayout {
screenRect: RenderRect;
webcamRect: StyledRenderRect | null;
/** When true, the video should be scaled to cover screenRect (cropping overflow). */
screenCover?: boolean;
}
/** Convert a webcam size percentage (10–50) to a fraction of the reference dimension. */
function webcamSizeToFraction(percent: number): number {
const safe = Number.isFinite(percent) ? percent : 25;
const clamped = Math.max(10, Math.min(50, safe));
return clamped / 100;
}
const MAX_STAGE_FRACTION = 0.18;
const MARGIN_FRACTION = 0.02;
const MIN_SIZE = 96;
const MAX_BORDER_RADIUS = 24;
const WEBCAM_LAYOUT_PRESET_MAP: Record<WebcamLayoutPreset, WebcamLayoutPresetDefinition> = {
"picture-in-picture": {
label: "Picture in Picture",
transform: {
type: "overlay",
maxStageFraction: MAX_STAGE_FRACTION,
marginFraction: MARGIN_FRACTION,
minMargin: 12,
minSize: MIN_SIZE,
minMargin: 0,
minSize: 0,
},
borderRadius: {
max: MAX_BORDER_RADIUS,
@@ -123,6 +131,9 @@ export function computeCompositeLayout(params: {
screenSize: Size;
webcamSize?: Size | null;
layoutPreset?: WebcamLayoutPreset;
webcamSizePreset?: WebcamSizePreset;
webcamPosition?: { cx: number; cy: number } | null;
webcamMaskShape?: import("@/components/video-editor/types").WebcamMaskShape;
}): WebcamCompositeLayout | null {
const {
canvasSize,
@@ -130,66 +141,55 @@ export function computeCompositeLayout(params: {
screenSize,
webcamSize,
layoutPreset = "picture-in-picture",
webcamSizePreset = 25,
webcamPosition,
webcamMaskShape = "rectangle",
} = params;
const { width: canvasWidth, height: canvasHeight } = canvasSize;
const { width: maxContentWidth, height: maxContentHeight } = maxContentSize;
const { width: screenWidth, height: screenHeight } = screenSize;
const webcamWidth = webcamSize?.width;
const webcamHeight = webcamSize?.height;
const preset = getWebcamLayoutPresetDefinition(layoutPreset);
const MAX_STAGE_FRACTION = webcamSizeToFraction(webcamSizePreset);
if (canvasWidth <= 0 || canvasHeight <= 0 || screenWidth <= 0 || screenHeight <= 0) {
return null;
}
if (preset.transform.type === "stack") {
if (!webcamWidth || !webcamHeight || webcamWidth <= 0 || webcamHeight <= 0) {
// No webcam — screen fills the entire canvas (cover mode)
return {
screenRect: centerRect({
canvasSize,
size: screenSize,
maxSize: maxContentSize,
}),
screenRect: { x: 0, y: 0, width: canvasWidth, height: canvasHeight },
webcamRect: null,
screenCover: true,
};
}
const gap = preset.transform.gap;
const normalizedWebcamHeight = webcamHeight * (screenWidth / webcamWidth);
const combinedHeight = screenHeight + gap + normalizedWebcamHeight;
const scale = Math.min(maxContentWidth / screenWidth, maxContentHeight / combinedHeight, 1);
const clampedScale = Number.isFinite(scale) && scale > 0 ? scale : 1;
const resolvedScreenHeight = Math.round(screenHeight * clampedScale);
const resolvedScreenWidth = Math.round(screenWidth * clampedScale);
const resolvedWebcamHeight = Math.round(normalizedWebcamHeight * clampedScale);
const resolvedGap = Math.round(gap * clampedScale);
const totalHeight = resolvedScreenHeight + resolvedGap + resolvedWebcamHeight;
const top = Math.max(0, Math.floor((canvasHeight - totalHeight) / 2));
const left = Math.max(0, Math.floor((canvasWidth - resolvedScreenWidth) / 2));
const screenRect = {
x: left,
y: top,
width: resolvedScreenWidth,
height: resolvedScreenHeight,
};
// Webcam: full width at the bottom, maintaining its aspect ratio
const webcamAspect = webcamWidth / webcamHeight;
const resolvedWebcamWidth = canvasWidth;
const resolvedWebcamHeight = Math.round(canvasWidth / webcamAspect);
// Screen: fills remaining space at the top (cover mode — may crop sides)
const screenRectHeight = canvasHeight - resolvedWebcamHeight;
return {
screenRect,
webcamRect: {
x: left,
y: top + resolvedScreenHeight + resolvedGap,
width: resolvedScreenWidth,
height: resolvedWebcamHeight,
borderRadius: Math.min(
preset.borderRadius.max,
Math.max(
preset.borderRadius.min,
Math.round(
Math.min(resolvedScreenWidth, resolvedWebcamHeight) * preset.borderRadius.fraction,
),
),
),
screenRect: {
x: 0,
y: 0,
width: canvasWidth,
height: Math.max(0, screenRectHeight),
},
webcamRect: {
x: 0,
y: Math.max(0, screenRectHeight),
width: resolvedWebcamWidth,
height: resolvedWebcamHeight,
borderRadius: 0,
},
screenCover: true,
};
}
@@ -208,26 +208,63 @@ export function computeCompositeLayout(params: {
transform.minMargin,
Math.round(Math.min(canvasWidth, canvasHeight) * transform.marginFraction),
);
const maxWidth = Math.max(transform.minSize, canvasWidth * transform.maxStageFraction);
const maxHeight = Math.max(transform.minSize, canvasHeight * transform.maxStageFraction);
// Use geometric mean so the webcam occupies a consistent visual proportion
// regardless of whether the canvas is portrait or landscape.
const referenceDim = Math.sqrt(canvasWidth * canvasHeight);
const maxWidth = Math.max(transform.minSize, referenceDim * MAX_STAGE_FRACTION);
const maxHeight = Math.max(transform.minSize, referenceDim * MAX_STAGE_FRACTION);
const scale = Math.min(maxWidth / webcamWidth, maxHeight / webcamHeight);
const width = Math.round(webcamWidth * scale);
const height = Math.round(webcamHeight * scale);
let width = Math.round(webcamWidth * scale);
let height = Math.round(webcamHeight * scale);
// Shape-specific dimension adjustments
if (webcamMaskShape === "circle" || webcamMaskShape === "square") {
const side = Math.min(width, height);
width = side;
height = side;
}
let webcamX: number;
let webcamY: number;
if (webcamPosition) {
// Custom position: cx/cy represent the center of the webcam as a fraction of the canvas
webcamX = Math.round(webcamPosition.cx * canvasWidth - width / 2);
webcamY = Math.round(webcamPosition.cy * canvasHeight - height / 2);
// Clamp to stay within canvas bounds
webcamX = Math.max(0, Math.min(canvasWidth - width, webcamX));
webcamY = Math.max(0, Math.min(canvasHeight - height, webcamY));
} else {
// Default: bottom-right with margin
webcamX = Math.max(0, Math.round(canvasWidth - margin - width));
webcamY = Math.max(0, Math.round(canvasHeight - margin - height));
}
// Shape-specific border radius
let borderRadius: number;
if (webcamMaskShape === "rounded") {
borderRadius = Math.round(Math.min(width, height) * 0.3);
} else if (webcamMaskShape === "circle") {
borderRadius = Math.round(Math.min(width, height) / 2);
} else {
borderRadius = Math.min(
preset.borderRadius.max,
Math.max(
preset.borderRadius.min,
Math.round(Math.min(width, height) * preset.borderRadius.fraction),
),
);
}
return {
screenRect,
webcamRect: {
x: Math.max(0, Math.round(canvasWidth - margin - width)),
y: Math.max(0, Math.round(canvasHeight - margin - height)),
x: webcamX,
y: webcamY,
width,
height,
borderRadius: Math.min(
preset.borderRadius.max,
Math.max(
preset.borderRadius.min,
Math.round(Math.min(width, height) * preset.borderRadius.fraction),
),
),
borderRadius,
maskShape: webcamMaskShape,
},
};
}
+101 -1
View File
@@ -1,4 +1,13 @@
import type { AnnotationRegion, ArrowDirection } from "@/components/video-editor/types";
import {
type AnnotationRegion,
type ArrowDirection,
DEFAULT_BLUR_INTENSITY,
MAX_BLUR_INTENSITY,
MIN_BLUR_INTENSITY,
} from "@/components/video-editor/types";
let blurScratchCanvas: HTMLCanvasElement | null = null;
let blurScratchCtx: CanvasRenderingContext2D | null = null;
// SVG path data for each arrow direction
const ARROW_PATHS: Record<ArrowDirection, string[]> = {
@@ -96,6 +105,93 @@ function renderArrow(
ctx.restore();
}
function drawBlurPath(
ctx: CanvasRenderingContext2D,
annotation: AnnotationRegion,
x: number,
y: number,
width: number,
height: number,
) {
const shape = annotation.blurData?.shape || "rectangle";
if (shape === "rectangle") {
ctx.beginPath();
ctx.rect(x, y, width, height);
return;
}
if (shape === "oval") {
ctx.beginPath();
ctx.ellipse(x + width / 2, y + height / 2, width / 2, height / 2, 0, 0, Math.PI * 2);
return;
}
const points = annotation.blurData?.freehandPoints;
if (shape === "freehand" && points && points.length >= 3) {
ctx.beginPath();
ctx.moveTo(x + (points[0].x / 100) * width, y + (points[0].y / 100) * height);
for (let i = 1; i < points.length; i++) {
ctx.lineTo(x + (points[i].x / 100) * width, y + (points[i].y / 100) * height);
}
ctx.closePath();
return;
}
ctx.beginPath();
ctx.rect(x, y, width, height);
}
function renderBlur(
ctx: CanvasRenderingContext2D,
annotation: AnnotationRegion,
x: number,
y: number,
width: number,
height: number,
scaleFactor: number,
) {
const canvas = ctx.canvas;
const configuredIntensity = annotation.blurData?.intensity ?? DEFAULT_BLUR_INTENSITY;
const blurRadius = Math.max(
1,
Math.round(clamp(configuredIntensity, MIN_BLUR_INTENSITY, MAX_BLUR_INTENSITY) * scaleFactor),
);
// Sample pixels around the target shape too; without this padding, small blur regions
// lose intensity because the filter has no neighboring pixels to blend with.
const samplePadding = Math.max(2, Math.ceil(blurRadius * 2));
const sx = Math.max(0, Math.floor(x) - samplePadding);
const sy = Math.max(0, Math.floor(y) - samplePadding);
const ex = Math.min(canvas.width, Math.ceil(x + width) + samplePadding);
const ey = Math.min(canvas.height, Math.ceil(y + height) + samplePadding);
const sw = Math.max(0, ex - sx);
const sh = Math.max(0, ey - sy);
if (sw <= 0 || sh <= 0) return;
if (!blurScratchCanvas || !blurScratchCtx) {
blurScratchCanvas = document.createElement("canvas");
blurScratchCtx = blurScratchCanvas.getContext("2d");
}
if (!blurScratchCanvas || !blurScratchCtx) return;
blurScratchCanvas.width = sw;
blurScratchCanvas.height = sh;
blurScratchCtx.clearRect(0, 0, sw, sh);
blurScratchCtx.drawImage(canvas, sx, sy, sw, sh, 0, 0, sw, sh);
ctx.save();
drawBlurPath(ctx, annotation, x, y, width, height);
ctx.clip();
ctx.filter = `blur(${blurRadius}px)`;
ctx.drawImage(blurScratchCanvas, sx, sy);
ctx.filter = "none";
ctx.restore();
}
function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value));
}
function renderText(
ctx: CanvasRenderingContext2D,
annotation: AnnotationRegion,
@@ -304,6 +400,10 @@ export async function renderAnnotations(
);
}
break;
case "blur":
renderBlur(ctx, annotation, x, y, width, height, scaleFactor);
break;
}
}
}
+152 -21
View File
@@ -13,15 +13,23 @@ import type {
CropRegion,
SpeedRegion,
WebcamLayoutPreset,
WebcamSizePreset,
ZoomDepth,
ZoomRegion,
} from "@/components/video-editor/types";
import { ZOOM_DEPTH_SCALES } from "@/components/video-editor/types";
import {
AUTO_FOLLOW_RAMP_DISTANCE,
AUTO_FOLLOW_SMOOTHING_FACTOR,
AUTO_FOLLOW_SMOOTHING_FACTOR_MAX,
DEFAULT_FOCUS,
ZOOM_SCALE_DEADZONE,
ZOOM_TRANSLATION_DEADZONE_PX,
} from "@/components/video-editor/videoPlayback/constants";
import {
adaptiveSmoothFactor,
smoothCursorFocus,
} from "@/components/video-editor/videoPlayback/cursorFollowUtils";
import { clampFocusToStage as clampFocusToStageUtil } from "@/components/video-editor/videoPlayback/focusUtils";
import { findDominantRegion } from "@/components/video-editor/videoPlayback/zoomRegionUtils";
import {
@@ -37,6 +45,7 @@ import {
type Size,
type StyledRenderRect,
} from "@/lib/compositeLayout";
import { drawCanvasClipPath } from "@/lib/webcamMaskShapes";
import { renderAnnotations } from "./annotationRenderer";
import {
getLinearGradientPoints,
@@ -61,10 +70,14 @@ interface FrameRenderConfig {
videoHeight: number;
webcamSize?: Size | null;
webcamLayoutPreset?: WebcamLayoutPreset;
webcamMaskShape?: import("@/components/video-editor/types").WebcamMaskShape;
webcamSizePreset?: WebcamSizePreset;
webcamPosition?: { cx: number; cy: number } | null;
annotationRegions?: AnnotationRegion[];
speedRegions?: SpeedRegion[];
previewWidth?: number;
previewHeight?: number;
cursorTelemetry?: import("@/components/video-editor/types").CursorTelemetryPoint[];
}
interface AnimationState {
@@ -101,11 +114,16 @@ export class FrameRenderer {
private shadowCtx: CanvasRenderingContext2D | null = null;
private compositeCanvas: HTMLCanvasElement | null = null;
private compositeCtx: CanvasRenderingContext2D | null = null;
private rasterCanvas: HTMLCanvasElement | null = null;
private rasterCtx: CanvasRenderingContext2D | null = null;
private config: FrameRenderConfig;
private animationState: AnimationState;
private layoutCache: LayoutCache | null = null;
private currentVideoTime = 0;
private motionBlurState: MotionBlurState = createMotionBlurState();
private smoothedAutoFocus: { cx: number; cy: number } | null = null;
private prevAnimationTimeMs: number | null = null;
private prevTargetProgress = 0;
constructor(config: FrameRenderConfig) {
this.config = config;
@@ -177,6 +195,14 @@ export class FrameRenderer {
throw new Error("Failed to get 2D context for composite canvas");
}
this.rasterCanvas = document.createElement("canvas");
this.rasterCanvas.width = this.config.width;
this.rasterCanvas.height = this.config.height;
this.rasterCtx = this.rasterCanvas.getContext("2d");
if (!this.rasterCtx) {
throw new Error("Failed to get 2D context for raster canvas");
}
// Setup shadow canvas if needed
if (this.config.showShadow) {
this.shadowCanvas = document.createElement("canvas");
@@ -428,7 +454,9 @@ export class FrameRenderer {
// Calculate scale to fit in viewport
// Padding is a percentage (0-100), where 50% ~ 0.8 scale
const paddingScale = 1.0 - (padding / 100) * 0.4;
// Vertical stack ignores padding — it's full-bleed
const effectivePadding = this.config.webcamLayoutPreset === "vertical-stack" ? 0 : padding;
const paddingScale = 1.0 - (effectivePadding / 100) * 0.4;
const viewportWidth = width * paddingScale;
const viewportHeight = height * paddingScale;
const compositeLayout = computeCompositeLayout({
@@ -437,40 +465,52 @@ export class FrameRenderer {
screenSize: { width: croppedVideoWidth, height: croppedVideoHeight },
webcamSize: webcamFrame ? this.config.webcamSize : null,
layoutPreset: this.config.webcamLayoutPreset,
webcamSizePreset: this.config.webcamSizePreset,
webcamPosition: this.config.webcamPosition,
webcamMaskShape: this.config.webcamMaskShape,
});
if (!compositeLayout) return;
const scale = compositeLayout.screenRect.width / croppedVideoWidth;
const screenRect = compositeLayout.screenRect;
// Cover mode: scale to fill the rect (may crop), otherwise fit-to-width
let scale: number;
if (compositeLayout.screenCover) {
scale = Math.max(
screenRect.width / croppedVideoWidth,
screenRect.height / croppedVideoHeight,
);
} else {
scale = screenRect.width / croppedVideoWidth;
}
// Position video sprite
this.videoSprite.width = videoWidth * scale;
this.videoSprite.height = videoHeight * scale;
// Center the cropped region within the screenRect
const croppedDisplayWidth = croppedVideoWidth * scale;
const croppedDisplayHeight = croppedVideoHeight * scale;
const coverOffsetX = (screenRect.width - croppedDisplayWidth) / 2;
const coverOffsetY = (screenRect.height - croppedDisplayHeight) / 2;
const cropPixelX = cropStartX * videoWidth * scale;
const cropPixelY = cropStartY * videoHeight * scale;
this.videoSprite.x = -cropPixelX;
this.videoSprite.y = -cropPixelY;
this.videoSprite.x = -cropPixelX + coverOffsetX;
this.videoSprite.y = -cropPixelY + coverOffsetY;
// Position video container
const croppedDisplayWidth = compositeLayout.screenRect.width;
const croppedDisplayHeight = compositeLayout.screenRect.height;
this.videoContainer.x = compositeLayout.screenRect.x;
this.videoContainer.y = compositeLayout.screenRect.y;
this.videoContainer.x = screenRect.x;
this.videoContainer.y = screenRect.y;
// scale border radius by export/preview canvas ratio
const previewWidth = this.config.previewWidth || 1920;
const previewHeight = this.config.previewHeight || 1080;
const canvasScaleFactor = Math.min(width / previewWidth, height / previewHeight);
const scaledBorderRadius = borderRadius * canvasScaleFactor;
const scaledBorderRadius = compositeLayout.screenCover ? 0 : borderRadius * canvasScaleFactor;
this.maskGraphics.clear();
this.maskGraphics.roundRect(
0,
0,
croppedDisplayWidth,
croppedDisplayHeight,
scaledBorderRadius,
);
this.maskGraphics.roundRect(0, 0, screenRect.width, screenRect.height, scaledBorderRadius);
this.maskGraphics.fill({ color: 0xffffff });
// Cache layout info
@@ -495,10 +535,16 @@ export class FrameRenderer {
private updateAnimationState(timeMs: number): number {
if (!this.cameraContainer || !this.layoutCache) return 0;
const bmEx = this.layoutCache.maskRect;
const ssEx = this.layoutCache.stageSize;
const viewportRatio =
bmEx.width > 0 && bmEx.height > 0
? { widthRatio: ssEx.width / bmEx.width, heightRatio: ssEx.height / bmEx.height }
: undefined;
const { region, strength, blendedScale, transition } = findDominantRegion(
this.config.zoomRegions,
timeMs,
{ connectZooms: true },
{ connectZooms: true, cursorTelemetry: this.config.cursorTelemetry, viewportRatio },
);
const defaultFocus = DEFAULT_FOCUS;
@@ -514,6 +560,50 @@ export class FrameRenderer {
targetFocus = regionFocus;
targetProgress = strength;
// Apply adaptive smoothing for auto-follow mode
if (region.focusMode === "auto" && !transition) {
const raw = targetFocus;
const dtMs = this.prevAnimationTimeMs != null ? timeMs - this.prevAnimationTimeMs : 0;
const framesElapsed = dtMs > 0 ? dtMs / (1000 / 60) : 1;
const isZoomingIn = targetProgress < 0.999 && targetProgress >= this.prevTargetProgress;
if (targetProgress >= 0.999) {
// Full zoom: adaptive smoothing — moves faster when far, decelerates when close
const prev = this.smoothedAutoFocus ?? raw;
const baseFactor = adaptiveSmoothFactor(
raw,
prev,
AUTO_FOLLOW_SMOOTHING_FACTOR,
AUTO_FOLLOW_SMOOTHING_FACTOR_MAX,
AUTO_FOLLOW_RAMP_DISTANCE,
);
const factor = 1 - Math.pow(1 - baseFactor, Math.max(1, framesElapsed));
const smoothed = smoothCursorFocus(raw, prev, factor);
this.smoothedAutoFocus = smoothed;
targetFocus = smoothed;
} else if (isZoomingIn) {
// Zoom-in: track cursor directly so zoom always aims at current cursor
// position; keep ref in sync to avoid snap when full-zoom begins
this.smoothedAutoFocus = raw;
} else {
// Zoom-out: keep smoothing for continuity — avoids snap at zoom-out start
const prev = this.smoothedAutoFocus ?? raw;
const baseFactor = adaptiveSmoothFactor(
raw,
prev,
AUTO_FOLLOW_SMOOTHING_FACTOR,
AUTO_FOLLOW_SMOOTHING_FACTOR_MAX,
AUTO_FOLLOW_RAMP_DISTANCE,
);
const factor = 1 - Math.pow(1 - baseFactor, Math.max(1, framesElapsed));
const smoothed = smoothCursorFocus(raw, prev, factor);
this.smoothedAutoFocus = smoothed;
targetFocus = smoothed;
}
} else if (region.focusMode !== "auto") {
this.smoothedAutoFocus = null;
}
this.prevTargetProgress = targetProgress;
if (transition) {
const startTransform = computeZoomTransform({
stageSize: this.layoutCache.stageSize,
@@ -589,6 +679,8 @@ export class FrameRenderer {
state.y = appliedY;
state.appliedScale = appliedScale;
this.prevAnimationTimeMs = timeMs;
return Math.max(
Math.abs(appliedScale - prevScale),
Math.abs(appliedX - prevX) / Math.max(1, this.layoutCache.stageSize.width),
@@ -596,10 +688,46 @@ export class FrameRenderer {
);
}
// On Linux/Wayland the implicit GPU→2D texture-sharing path
// used by drawImage(webglCanvas) can fail silently (EGL/Ozone),
// producing green/empty frames. Explicit gl.readPixels always
// copies from GPU to CPU memory, bypassing that path.
private readbackVideoCanvas(): HTMLCanvasElement {
const glCanvas = this.app!.canvas as HTMLCanvasElement;
const gl =
(glCanvas.getContext("webgl2") as WebGL2RenderingContext | null) ??
(glCanvas.getContext("webgl") as WebGLRenderingContext | null);
if (!gl || !this.rasterCanvas || !this.rasterCtx) {
return glCanvas;
}
const w = glCanvas.width;
const h = glCanvas.height;
const buf = new Uint8Array(w * h * 4);
gl.readPixels(0, 0, w, h, gl.RGBA, gl.UNSIGNED_BYTE, buf);
// readPixels returns rows bottom-to-top; flip vertically
const rowSize = w * 4;
const temp = new Uint8Array(rowSize);
for (let top = 0, bot = h - 1; top < bot; top++, bot--) {
const tOff = top * rowSize;
const bOff = bot * rowSize;
temp.set(buf.subarray(tOff, tOff + rowSize));
buf.copyWithin(tOff, bOff, bOff + rowSize);
buf.set(temp, bOff);
}
const imageData = new ImageData(new Uint8ClampedArray(buf.buffer), w, h);
this.rasterCtx.putImageData(imageData, 0, 0);
return this.rasterCanvas;
}
private compositeWithShadows(webcamFrame?: VideoFrame | null): void {
if (!this.compositeCanvas || !this.compositeCtx || !this.app) return;
const videoCanvas = this.app.canvas as HTMLCanvasElement;
const videoCanvas = this.readbackVideoCanvas();
const ctx = this.compositeCtx;
const w = this.compositeCanvas.width;
const h = this.compositeCanvas.height;
@@ -655,16 +783,17 @@ export class FrameRenderer {
const webcamRect = this.layoutCache?.webcamRect ?? null;
if (webcamFrame && webcamRect) {
const preset = getWebcamLayoutPresetDefinition(this.config.webcamLayoutPreset);
const shape = webcamRect.maskShape ?? this.config.webcamMaskShape ?? "rectangle";
ctx.save();
ctx.beginPath();
ctx.roundRect(
drawCanvasClipPath(
ctx,
webcamRect.x,
webcamRect.y,
webcamRect.width,
webcamRect.height,
shape,
webcamRect.borderRadius,
);
ctx.closePath();
if (preset.shadow) {
ctx.shadowColor = preset.shadow.color;
ctx.shadowBlur = preset.shadow.blur;
@@ -715,5 +844,7 @@ export class FrameRenderer {
this.shadowCtx = null;
this.compositeCanvas = null;
this.compositeCtx = null;
this.rasterCanvas = null;
this.rasterCtx = null;
}
}
@@ -0,0 +1,43 @@
import { describe, expect, it } from "vitest";
import sampleVideoUrl from "../../../tests/fixtures/sample.webm?url";
import { GifExporter } from "./gifExporter";
import type { ExportProgress } from "./types";
describe("GifExporter (real browser)", () => {
it("exports a valid GIF blob from a real video", async () => {
const progressEvents: ExportProgress[] = [];
const exporter = new GifExporter({
videoUrl: sampleVideoUrl,
width: 320,
height: 180,
frameRate: 15,
loop: true,
sizePreset: "medium",
wallpaper: "#1a1a2e",
zoomRegions: [],
showShadow: false,
shadowIntensity: 0,
showBlur: false,
cropRegion: { x: 0, y: 0, width: 1, height: 1 },
onProgress: (p) => progressEvents.push(p),
});
const result = await exporter.export();
expect(result.success, result.error).toBe(true);
expect(result.blob).toBeInstanceOf(Blob);
const buf = await result.blob!.arrayBuffer();
const header = new TextDecoder().decode(new Uint8Array(buf, 0, 6));
expect(header).toMatch(/^GIF8[79]a/);
expect(result.blob!.size).toBeGreaterThan(1024);
expect(progressEvents.length).toBeGreaterThan(0);
const finalizing = progressEvents.filter((p) => p.phase === "finalizing");
expect(finalizing.length).toBeGreaterThan(0);
expect(finalizing.at(-1)!.percentage).toBe(100);
});
});
+9
View File
@@ -5,6 +5,7 @@ import type {
SpeedRegion,
TrimRegion,
WebcamLayoutPreset,
WebcamSizePreset,
ZoomRegion,
} from "@/components/video-editor/types";
import { AsyncVideoFrameQueue } from "./asyncVideoFrameQueue";
@@ -41,9 +42,13 @@ interface GifExporterConfig {
videoPadding?: number;
cropRegion: CropRegion;
webcamLayoutPreset?: WebcamLayoutPreset;
webcamMaskShape?: import("@/components/video-editor/types").WebcamMaskShape;
webcamSizePreset?: WebcamSizePreset;
webcamPosition?: { cx: number; cy: number } | null;
annotationRegions?: AnnotationRegion[];
previewWidth?: number;
previewHeight?: number;
cursorTelemetry?: import("@/components/video-editor/types").CursorTelemetryPoint[];
onProgress?: (progress: ExportProgress) => void;
}
@@ -140,10 +145,14 @@ export class GifExporter {
videoHeight: videoInfo.height,
webcamSize: webcamInfo ? { width: webcamInfo.width, height: webcamInfo.height } : null,
webcamLayoutPreset: this.config.webcamLayoutPreset,
webcamMaskShape: this.config.webcamMaskShape,
webcamSizePreset: this.config.webcamSizePreset,
webcamPosition: this.config.webcamPosition,
annotationRegions: this.config.annotationRegions,
speedRegions: this.config.speedRegions,
previewWidth: this.config.previewWidth,
previewHeight: this.config.previewHeight,
cursorTelemetry: this.config.cursorTelemetry,
});
await this.renderer.initialize();
+57 -1
View File
@@ -3,6 +3,52 @@ import type { SpeedRegion, TrimRegion } from "@/components/video-editor/types";
const SOURCE_LOAD_TIMEOUT_MS = 60_000;
/**
* Build a full WebCodecs-compatible AV1 codec string from the AV1CodecConfigurationRecord.
* web-demuxer may return a bare "av01" when the WASM-side parser fails to read
* the extradata (e.g. raw OBU sequence header from WebM instead of ISOBMFF av1C box).
* This function parses the record if present, otherwise returns a safe default.
*
* @see https://aomediacodec.github.io/av1-isobmff/#av1codecconfigurationbox-section
*/
function buildAV1CodecString(description?: BufferSource): string {
const fallback = "av01.0.01M.08";
if (!description) return fallback;
const bytes =
description instanceof ArrayBuffer
? new Uint8Array(description)
: new Uint8Array(description.buffer, description.byteOffset, description.byteLength);
// AV1CodecConfigurationRecord layout (4+ bytes):
// Byte 0: marker (1) | version (7)
// Byte 1: seq_profile (3) | seq_level_idx_0 (5)
// Byte 2: seq_tier_0 (1) | high_bitdepth (1) | twelve_bit (1) | ...
// The spec says version should be 1, but Chrome/Electron's MediaRecorder
// may write version 127 (0xFF first byte). We accept any version as long
// as the marker bit is set and the record is long enough.
if (bytes.length < 4) return fallback;
if (!(bytes[0] & 0x80)) return fallback; // marker bit must be 1
// Byte 1: seq_profile (3) | seq_level_idx_0 (5)
const profile = (bytes[1] >> 5) & 0x07;
const level = bytes[1] & 0x1f;
// Byte 2: seq_tier_0 (1) | high_bitdepth (1) | twelve_bit (1) | monochrome (1) | ...
const tier = (bytes[2] >> 7) & 0x01;
const highBitdepth = (bytes[2] >> 6) & 0x01;
const twelveBit = (bytes[2] >> 5) & 0x01;
let bitdepth = 8;
if (highBitdepth) bitdepth = twelveBit ? 12 : 10;
const tierChar = tier ? "H" : "M";
const levelStr = level.toString().padStart(2, "0");
const bitdepthStr = bitdepth.toString().padStart(2, "0");
return `av01.${profile}.${levelStr}${tierChar}.${bitdepthStr}`;
}
export interface DecodedVideoInfo {
width: number;
height: number;
@@ -183,7 +229,17 @@ export class StreamingVideoDecoder {
}
const decoderConfig = await this.demuxer.getDecoderConfig("video");
const codec = this.metadata.codec.toLowerCase();
// web-demuxer may return a bare "av01" for AV1 in WebM containers when the
// extradata isn't in the expected ISOBMFF format. WebCodecs requires the
// full parametrized form (e.g. "av01.0.05M.08").
if (/^av01$/i.test(decoderConfig.codec)) {
decoderConfig.codec = buildAV1CodecString(
decoderConfig.description as BufferSource | undefined,
);
}
const codec = decoderConfig.codec.toLowerCase();
const shouldPreferSoftwareDecode = codec.includes("av01") || codec.includes("av1");
const segments = this.splitBySpeed(
this.computeSegments(this.metadata.duration, trimRegions),
@@ -0,0 +1,43 @@
import { describe, expect, it } from "vitest";
import sampleVideoUrl from "../../../tests/fixtures/sample.webm?url";
import type { ExportProgress } from "./types";
import { VideoExporter } from "./videoExporter";
describe("VideoExporter (real browser)", () => {
it("exports a valid MP4 blob from a real video", async () => {
const progressEvents: ExportProgress[] = [];
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 },
onProgress: (p) => progressEvents.push(p),
});
const result = await exporter.export();
expect(result.success, result.error).toBe(true);
expect(result.blob).toBeInstanceOf(Blob);
const buf = await result.blob!.arrayBuffer();
const bytes = new Uint8Array(buf);
const ftyp = new TextDecoder().decode(bytes.slice(4, 8));
expect(ftyp).toBe("ftyp");
expect(result.blob!.size).toBeGreaterThan(1024);
expect(progressEvents.length).toBeGreaterThan(0);
const finalizing = progressEvents.filter((p) => p.phase === "finalizing");
expect(finalizing.length).toBeGreaterThan(0);
expect(finalizing.at(-1)!.percentage).toBe(100);
});
});
+19 -2
View File
@@ -4,6 +4,7 @@ import type {
SpeedRegion,
TrimRegion,
WebcamLayoutPreset,
WebcamSizePreset,
ZoomRegion,
} from "@/components/video-editor/types";
import { AsyncVideoFrameQueue } from "./asyncVideoFrameQueue";
@@ -32,9 +33,13 @@ interface VideoExporterConfig extends ExportConfig {
videoPadding?: number;
cropRegion: CropRegion;
webcamLayoutPreset?: WebcamLayoutPreset;
webcamMaskShape?: import("@/components/video-editor/types").WebcamMaskShape;
webcamSizePreset?: WebcamSizePreset;
webcamPosition?: { cx: number; cy: number } | null;
annotationRegions?: AnnotationRegion[];
previewWidth?: number;
previewHeight?: number;
cursorTelemetry?: import("@/components/video-editor/types").CursorTelemetryPoint[];
onProgress?: (progress: ExportProgress) => void;
}
@@ -133,10 +138,14 @@ export class VideoExporter {
videoHeight: videoInfo.height,
webcamSize: webcamInfo ? { width: webcamInfo.width, height: webcamInfo.height } : null,
webcamLayoutPreset: this.config.webcamLayoutPreset,
webcamMaskShape: this.config.webcamMaskShape,
webcamSizePreset: this.config.webcamSizePreset,
webcamPosition: this.config.webcamPosition,
annotationRegions: this.config.annotationRegions,
speedRegions: this.config.speedRegions,
previewWidth: this.config.previewWidth,
previewHeight: this.config.previewHeight,
cursorTelemetry: this.config.cursorTelemetry,
});
this.renderer = renderer;
await renderer.initialize();
@@ -228,8 +237,16 @@ export class VideoExporter {
const canvas = renderer.getCanvas();
// @ts-expect-error - colorSpace is available at runtime even if TS does not know it.
const exportFrame = new VideoFrame(canvas, {
// Read raw pixels from the canvas instead of passing
// the canvas directly to VideoFrame. On some Linux
// systems the GPU shared-image path (EGL/Ozone) fails
// silently, producing empty frames.
const canvasCtx = canvas.getContext("2d")!;
const imageData = canvasCtx.getImageData(0, 0, canvas.width, canvas.height);
const exportFrame = new VideoFrame(imageData.data.buffer, {
format: "RGBA",
codedWidth: canvas.width,
codedHeight: canvas.height,
timestamp,
duration: frameDuration,
colorSpace: {
+15
View File
@@ -0,0 +1,15 @@
/** Duration of a single frame in seconds at 60 FPS (~16.67ms). */
export const FRAME_DURATION_SEC = 1 / 60;
/**
* Compute the new playhead time after stepping one frame forward or backward.
* The result is clamped to the range [0, duration].
*/
export function computeFrameStepTime(
currentTime: number,
duration: number,
direction: "forward" | "backward",
): number {
const delta = direction === "forward" ? FRAME_DURATION_SEC : -FRAME_DURATION_SEC;
return Math.min(duration, Math.max(0, currentTime + delta));
}
+30 -5
View File
@@ -3,6 +3,7 @@ export const SHORTCUT_ACTIONS = [
"addTrim",
"addSpeed",
"addAnnotation",
"addBlur",
"addKeyframe",
"deleteSelected",
"playPause",
@@ -21,14 +22,16 @@ export interface ShortcutBinding {
export type ShortcutsConfig = Record<ShortcutAction, ShortcutBinding>;
export interface FixedShortcut {
i18nKey: string;
label: string;
display: string;
bindings: ShortcutBinding[];
}
export const FIXED_SHORTCUTS: FixedShortcut[] = [
{ label: "Undo", display: "Ctrl + Z", bindings: [{ key: "z", ctrl: true }] },
{ i18nKey: "undo", label: "Undo", display: "Ctrl + Z", bindings: [{ key: "z", ctrl: true }] },
{
i18nKey: "redo",
label: "Redo",
display: "Ctrl + Shift + Z / Ctrl + Y",
bindings: [
@@ -36,19 +39,38 @@ export const FIXED_SHORTCUTS: FixedShortcut[] = [
{ key: "y", ctrl: true },
],
},
{ label: "Cycle Annotations Forward", display: "Tab", bindings: [{ key: "tab" }] },
{
i18nKey: "cycleAnnotationsForward",
label: "Cycle Annotations Forward",
display: "Tab",
bindings: [{ key: "tab" }],
},
{
i18nKey: "cycleAnnotationsBackward",
label: "Cycle Annotations Backward",
display: "Shift + Tab",
bindings: [{ key: "tab", shift: true }],
},
{
i18nKey: "deleteSelectedAlt",
label: "Delete Selected (alt)",
display: "Del / ⌫",
bindings: [{ key: "delete" }, { key: "backspace" }],
},
{ label: "Pan Timeline", display: "Shift + Ctrl + Scroll", bindings: [] },
{ label: "Zoom Timeline", display: "Ctrl + Scroll", bindings: [] },
{
i18nKey: "panTimeline",
label: "Pan Timeline",
display: "Shift + Ctrl + Scroll",
bindings: [],
},
{ i18nKey: "zoomTimeline", label: "Zoom Timeline", display: "Ctrl + Scroll", bindings: [] },
{ i18nKey: "frameBack", label: "Frame Back", display: "←", bindings: [{ key: "arrowleft" }] },
{
i18nKey: "frameForward",
label: "Frame Forward",
display: "→",
bindings: [{ key: "arrowright" }],
},
];
export type ShortcutConflict =
@@ -87,6 +109,7 @@ export const DEFAULT_SHORTCUTS: ShortcutsConfig = {
addTrim: { key: "t" },
addSpeed: { key: "s" },
addAnnotation: { key: "a" },
addBlur: { key: "b" },
addKeyframe: { key: "f" },
deleteSelected: { key: "d", ctrl: true },
playPause: { key: " " },
@@ -97,6 +120,7 @@ export const SHORTCUT_LABELS: Record<ShortcutAction, string> = {
addTrim: "Add Trim",
addSpeed: "Add Speed",
addAnnotation: "Add Annotation",
addBlur: "Add Blur",
addKeyframe: "Add Keyframe",
deleteSelected: "Delete Selected",
playPause: "Play / Pause",
@@ -104,9 +128,10 @@ export const SHORTCUT_LABELS: Record<ShortcutAction, string> = {
export function matchesShortcut(
e: KeyboardEvent,
binding: ShortcutBinding,
binding: ShortcutBinding | undefined,
isMacPlatform: boolean,
): boolean {
if (!binding) return false;
if (e.key.toLowerCase() !== binding.key.toLowerCase()) return false;
const primaryMod = isMacPlatform ? e.metaKey : e.ctrlKey;
+94
View File
@@ -0,0 +1,94 @@
import type { ExportFormat, ExportQuality } from "@/lib/exporter";
import type { AspectRatio } from "@/utils/aspectRatioUtils";
const PREFS_KEY = "openscreen_user_preferences";
const VALID_ASPECT_RATIOS: readonly string[] = [
"16:9",
"9:16",
"1:1",
"4:3",
"4:5",
"16:10",
"10:16",
"native",
];
export interface UserPreferences {
/** Default padding % */
padding: number;
/** Default aspect ratio */
aspectRatio: AspectRatio;
/** Default export quality */
exportQuality: ExportQuality;
/** Default export format */
exportFormat: ExportFormat;
}
const DEFAULT_PREFS: UserPreferences = {
padding: 50,
aspectRatio: "16:9",
exportQuality: "good",
exportFormat: "mp4",
};
function safeJsonParse(text: string | null): Record<string, unknown> | null {
if (!text) return null;
try {
return JSON.parse(text);
} catch {
return null;
}
}
/**
* Load persisted user preferences from localStorage.
* Returns defaults for any missing or invalid fields.
*/
export function loadUserPreferences(): UserPreferences {
let raw: Record<string, unknown> | null = null;
try {
raw = safeJsonParse(localStorage.getItem(PREFS_KEY));
} catch {
return { ...DEFAULT_PREFS };
}
if (!raw || typeof raw !== "object") return { ...DEFAULT_PREFS };
return {
padding:
typeof raw.padding === "number" &&
Number.isFinite(raw.padding) &&
raw.padding >= 0 &&
raw.padding <= 100
? raw.padding
: DEFAULT_PREFS.padding,
aspectRatio:
typeof raw.aspectRatio === "string" && VALID_ASPECT_RATIOS.includes(raw.aspectRatio)
? (raw.aspectRatio as AspectRatio)
: DEFAULT_PREFS.aspectRatio,
exportQuality:
raw.exportQuality === "medium" ||
raw.exportQuality === "good" ||
raw.exportQuality === "source"
? (raw.exportQuality as ExportQuality)
: DEFAULT_PREFS.exportQuality,
exportFormat:
raw.exportFormat === "gif" || raw.exportFormat === "mp4"
? (raw.exportFormat as ExportFormat)
: DEFAULT_PREFS.exportFormat,
};
}
/**
* Persist user preferences to localStorage.
* Only the explicitly provided fields are updated.
*/
export function saveUserPreferences(partial: Partial<UserPreferences>): void {
const current = loadUserPreferences();
const merged = { ...current, ...partial };
try {
localStorage.setItem(PREFS_KEY, JSON.stringify(merged));
} catch {
// localStorage may be unavailable (e.g. private browsing quota exceeded)
}
}
+48
View File
@@ -0,0 +1,48 @@
import type { WebcamMaskShape } from "@/components/video-editor/types";
/**
* Returns a CSS clip-path value for the given shape, or null if borderRadius alone suffices.
*/
export function getCssClipPath(shape: WebcamMaskShape): string | null {
switch (shape) {
case "circle":
return "circle(50% at 50% 50%)";
case "rectangle":
case "rounded":
case "square":
default:
return null;
}
}
/**
* Draws a Canvas 2D clip path for the given webcam mask shape.
* Call ctx.beginPath() is handled internally; caller should call ctx.clip() after.
*/
export function drawCanvasClipPath(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
w: number,
h: number,
shape: WebcamMaskShape,
borderRadius: number,
): void {
ctx.beginPath();
switch (shape) {
case "circle": {
const cx = x + w / 2;
const cy = y + h / 2;
const r = Math.min(w, h) / 2;
ctx.arc(cx, cy, r, 0, Math.PI * 2);
break;
}
case "rectangle":
case "rounded":
case "square":
default:
ctx.roundRect(x, y, w, h, borderRadius);
break;
}
ctx.closePath();
}
+4 -1
View File
@@ -1,10 +1,13 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App.tsx";
import { I18nProvider } from "./contexts/I18nContext";
import "./index.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
<I18nProvider>
<App />
</I18nProvider>
</React.StrictMode>,
);
+4
View File
@@ -67,6 +67,10 @@ export function getAspectRatioLabel(aspectRatio: AspectRatio): string {
return aspectRatio;
}
export function isPortraitAspectRatio(aspectRatio: AspectRatio): boolean {
return getAspectRatioValue(aspectRatio) < 1;
}
export function formatAspectRatioForCSS(aspectRatio: AspectRatio, nativeRatio?: number): string {
if (aspectRatio === "native") return String(nativeRatio ?? 16 / 9);
return aspectRatio.replace(":", "/");
+3
View File
@@ -19,6 +19,8 @@ interface Window {
electronAPI: {
getSources: (opts: Electron.SourcesOptions) => Promise<ProcessedDesktopSource[]>;
switchToEditor: () => Promise<void>;
switchToHud: () => Promise<void>;
startNewRecording: () => Promise<{ success: boolean; error?: string }>;
openSourceSelector: () => Promise<void>;
selectSource: (source: ProcessedDesktopSource) => Promise<ProcessedDesktopSource | null>;
getSelectedSource: () => Promise<ProcessedDesktopSource | null>;
@@ -119,5 +121,6 @@ interface Window {
setMicrophoneExpanded: (expanded: boolean) => void;
setHasUnsavedChanges: (hasChanges: boolean) => void;
onRequestSaveBeforeClose: (callback: () => Promise<boolean> | boolean) => () => void;
setLocale: (locale: string) => Promise<void>;
};
}
+24 -7
View File
@@ -11,12 +11,15 @@ const TEST_VIDEO = path.join(__dirname, "../fixtures/sample.webm");
test("exports a GIF from a loaded video", async () => {
const outputPath = path.join(os.tmpdir(), `test-gif-export-${Date.now()}.gif`);
let testVideoInRecordings = "";
const app = await electron.launch({
args: [
MAIN_JS,
// Required in CI sandbox environments (GitHub Actions, Docker, etc.)
"--no-sandbox",
// Force software WebGL in headless CI to avoid GPU framebuffer errors.
"--enable-unsafe-swiftshader",
],
env: {
...process.env,
@@ -58,14 +61,25 @@ test("exports a GIF from a loaded video", async () => {
);
});
await hudWindow.evaluate((videoPath: string) => {
window.electronAPI.setCurrentVideoPath(videoPath);
try {
// Copy the test fixture into the app's recordings directory so it passes
// the path security check in set-current-video-path.
const userDataDir = await app.evaluate(({ app: electronApp }) => {
return electronApp.getPath("userData");
});
const recordingsDir = path.join(userDataDir, "recordings");
testVideoInRecordings = path.join(recordingsDir, "test-sample.webm");
fs.mkdirSync(recordingsDir, { recursive: true });
fs.copyFileSync(TEST_VIDEO, testVideoInRecordings);
try {
await hudWindow.evaluate((videoPath: string) => {
window.electronAPI.setCurrentVideoPath(videoPath);
window.electronAPI.switchToEditor();
} catch {
// Expected: HUD window closes during this call, killing the context.
}
}, TEST_VIDEO);
}, testVideoInRecordings);
} catch {
// Expected: switchToEditor() closes the HUD window, terminating
// the Playwright page context before evaluate() can resolve.
}
// ── 3. Switch to the editor window. This closes the HUD and opens
// a new BrowserWindow with ?windowType=editor.
@@ -116,5 +130,8 @@ test("exports a GIF from a loaded video", async () => {
if (fs.existsSync(outputPath)) {
fs.unlinkSync(outputPath);
}
if (testVideoInRecordings && fs.existsSync(testVideoInRecordings)) {
fs.unlinkSync(testVideoInRecordings);
}
}
});

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