From 1b980d626434bf117cf0a360bf304572ae59c2ba Mon Sep 17 00:00:00 2001 From: Amir Yunus <54809019+AmirYunus@users.noreply.github.com> Date: Sun, 5 Apr 2026 01:29:46 +0800 Subject: [PATCH 01/59] fix(hud): avoid horizontal scrollbar when recording on Windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use full-size layout and overflow clipping instead of 100vw/100vh on the HUD shell so the fixed 600×160 overlay does not gain a horizontal scrollbar when recording widens the toolbar. Fixes #305 --- src/App.tsx | 16 ++++++++++++++++ src/components/launch/LaunchWindow.tsx | 7 ++++++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/App.tsx b/src/App.tsx index 9772ef8..985ecc7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -21,6 +21,22 @@ export default function App() { document.getElementById("root")?.style.setProperty("background", "transparent"); } + // HUD window is a small fixed-size BrowserWindow (`electron/windows.ts`), not a full-screen + // surface. Pin the document shell to that viewport and hide overflow so the renderer cannot + // introduce scrollbars. Without this, `h-full` in `LaunchWindow` has no definite height chain + // from `html`/`body`, and stray overflow can still appear on some hosts (see issue #305). + if (type === "hud-overlay") { + document.documentElement.style.height = "100%"; + document.documentElement.style.overflow = "hidden"; + document.body.style.height = "100%"; + document.body.style.margin = "0"; + document.body.style.overflow = "hidden"; + const root = document.getElementById("root"); + root?.style.setProperty("height", "100%"); + root?.style.setProperty("min-height", "0"); + root?.style.setProperty("overflow", "hidden"); + } + // Load custom fonts on app initialization loadAllCustomFonts().catch((error) => { console.error("Failed to load custom fonts:", error); diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index f1b66b8..5c5ec92 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -241,7 +241,12 @@ export function LaunchWindow() { }; return ( -
+ // Root fills the HUD window only. Avoid `w-screen`/`h-screen` (`100vw`/`100vh`): `100vw` can + // exceed the inner layout width when scrollbars affect the viewport (notably on Windows), which + // showed up as a horizontal scrollbar once recording widened the toolbar (issue #305). +
{/* Language switcher — top-left, beside traffic lights */}
Date: Wed, 22 Apr 2026 02:01:20 +0300 Subject: [PATCH 02/59] Improve Arch Linux support and fix video export on Hyprland - Add pacman package build target for Arch Linux in electron-builder.json5 - Update build:linux script in package.json to include pacman target - Fix dialog window issues on Wayland/Hyprland: * Pass mainWindow reference to dialog.showSaveDialog and dialog.showOpenDialog in electron/ipc/handlers.ts * Required for proper dialog functionality on Wayland compositors * Previously dialogs opened without parent window attachment causing issues on Hyprland Changes ensure: - Correct video export on Arch Linux + Hyprland systems - Ability to install via pacman package manager - Improved compatibility with Wayland compositors --- electron-builder.json5 | 4 +- electron/ipc/handlers.ts | 147 +++++++++++++++++++++++++++------------ electron/main.ts | 12 ++++ package.json | 2 +- 4 files changed, 117 insertions(+), 48 deletions(-) diff --git a/electron-builder.json5 b/electron-builder.json5 index 18498df..b005c3f 100644 --- a/electron-builder.json5 +++ b/electron-builder.json5 @@ -50,7 +50,9 @@ }, "linux": { "target": [ - "AppImage" + "AppImage", + "deb", + "pacman" ], "icon": "icons/icons/png", "artifactName": "${productName}-Linux-${version}.${ext}", diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 261d93f..e5665a9 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -359,7 +359,9 @@ export function registerIpcHandlers( onRecordingStateChange?: (recording: boolean, sourceName: string) => void, switchToHud?: () => void, ) { - const supportsWindowOpacity = process.platform !== "linux"; + const isWayland = + process.env.XDG_SESSION_TYPE === "wayland" || process.env.WAYLAND_DISPLAY !== undefined; + const supportsWindowOpacity = process.platform !== "linux" || isWayland; const countdownOverlayState = { visible: false, value: null as number | null, @@ -834,14 +836,24 @@ export function registerIpcHandlers( ? [{ name: mainT("dialogs", "fileDialogs.gifImage"), extensions: ["gif"] }] : [{ name: mainT("dialogs", "fileDialogs.mp4Video"), extensions: ["mp4"] }]; - const result = await dialog.showSaveDialog({ - title: isGif - ? mainT("dialogs", "fileDialogs.saveGif") - : mainT("dialogs", "fileDialogs.saveVideo"), - defaultPath: path.join(app.getPath("downloads"), fileName), - filters, - properties: ["createDirectory", "showOverwriteConfirmation"], - }); + const mainWindow = getMainWindow(); + const result = mainWindow + ? await dialog.showSaveDialog(mainWindow, { + title: isGif + ? mainT("dialogs", "fileDialogs.saveGif") + : mainT("dialogs", "fileDialogs.saveVideo"), + defaultPath: path.join(app.getPath("downloads"), fileName), + filters, + properties: ["createDirectory", "showOverwriteConfirmation"], + }) + : await dialog.showSaveDialog({ + title: isGif + ? mainT("dialogs", "fileDialogs.saveGif") + : mainT("dialogs", "fileDialogs.saveVideo"), + defaultPath: path.join(app.getPath("downloads"), fileName), + filters, + properties: ["createDirectory", "showOverwriteConfirmation"], + }); if (result.canceled || !result.filePath) { return { @@ -876,18 +888,32 @@ export function registerIpcHandlers( }); ipcMain.handle("open-video-file-picker", async () => { try { - const result = await dialog.showOpenDialog({ - title: mainT("dialogs", "fileDialogs.selectVideo"), - defaultPath: RECORDINGS_DIR, - filters: [ - { - name: mainT("dialogs", "fileDialogs.videoFiles"), - extensions: ["webm", "mp4", "mov", "avi", "mkv"], - }, - { name: mainT("dialogs", "fileDialogs.allFiles"), extensions: ["*"] }, - ], - properties: ["openFile"], - }); + const mainWindow = getMainWindow(); + const result = mainWindow + ? await dialog.showOpenDialog(mainWindow, { + title: mainT("dialogs", "fileDialogs.selectVideo"), + defaultPath: RECORDINGS_DIR, + filters: [ + { + name: mainT("dialogs", "fileDialogs.videoFiles"), + extensions: ["webm", "mp4", "mov", "avi", "mkv"], + }, + { name: mainT("dialogs", "fileDialogs.allFiles"), extensions: ["*"] }, + ], + properties: ["openFile"], + }) + : await dialog.showOpenDialog({ + title: mainT("dialogs", "fileDialogs.selectVideo"), + defaultPath: RECORDINGS_DIR, + filters: [ + { + name: mainT("dialogs", "fileDialogs.videoFiles"), + extensions: ["webm", "mp4", "mov", "avi", "mkv"], + }, + { name: mainT("dialogs", "fileDialogs.allFiles"), extensions: ["*"] }, + ], + properties: ["openFile"], + }); if (result.canceled || result.filePaths.length === 0) { return { success: false, canceled: true }; @@ -966,18 +992,32 @@ export function registerIpcHandlers( ? safeName : `${safeName}.${PROJECT_FILE_EXTENSION}`; - const result = await dialog.showSaveDialog({ - title: mainT("dialogs", "fileDialogs.saveProject"), - defaultPath: path.join(RECORDINGS_DIR, defaultName), - filters: [ - { - name: mainT("dialogs", "fileDialogs.openscreenProject"), - extensions: [PROJECT_FILE_EXTENSION], - }, - { name: "JSON", extensions: ["json"] }, - ], - properties: ["createDirectory", "showOverwriteConfirmation"], - }); + const mainWindow = getMainWindow(); + const result = mainWindow + ? await dialog.showSaveDialog(mainWindow, { + title: mainT("dialogs", "fileDialogs.saveProject"), + defaultPath: path.join(RECORDINGS_DIR, defaultName), + filters: [ + { + name: mainT("dialogs", "fileDialogs.openscreenProject"), + extensions: [PROJECT_FILE_EXTENSION], + }, + { name: "JSON", extensions: ["json"] }, + ], + properties: ["createDirectory", "showOverwriteConfirmation"], + }) + : await dialog.showSaveDialog({ + title: mainT("dialogs", "fileDialogs.saveProject"), + defaultPath: path.join(RECORDINGS_DIR, defaultName), + filters: [ + { + name: mainT("dialogs", "fileDialogs.openscreenProject"), + extensions: [PROJECT_FILE_EXTENSION], + }, + { name: "JSON", extensions: ["json"] }, + ], + properties: ["createDirectory", "showOverwriteConfirmation"], + }); if (result.canceled || !result.filePath) { return { @@ -1008,19 +1048,34 @@ export function registerIpcHandlers( ipcMain.handle("load-project-file", async () => { try { - const result = await dialog.showOpenDialog({ - title: mainT("dialogs", "fileDialogs.openProject"), - defaultPath: RECORDINGS_DIR, - filters: [ - { - name: mainT("dialogs", "fileDialogs.openscreenProject"), - extensions: [PROJECT_FILE_EXTENSION], - }, - { name: "JSON", extensions: ["json"] }, - { name: mainT("dialogs", "fileDialogs.allFiles"), extensions: ["*"] }, - ], - properties: ["openFile"], - }); + const mainWindow = getMainWindow(); + const result = mainWindow + ? await dialog.showOpenDialog(mainWindow, { + title: mainT("dialogs", "fileDialogs.openProject"), + defaultPath: RECORDINGS_DIR, + filters: [ + { + name: mainT("dialogs", "fileDialogs.openscreenProject"), + extensions: [PROJECT_FILE_EXTENSION], + }, + { name: "JSON", extensions: ["json"] }, + { name: mainT("dialogs", "fileDialogs.allFiles"), extensions: ["*"] }, + ], + properties: ["openFile"], + }) + : await dialog.showOpenDialog({ + title: mainT("dialogs", "fileDialogs.openProject"), + defaultPath: RECORDINGS_DIR, + filters: [ + { + name: mainT("dialogs", "fileDialogs.openscreenProject"), + extensions: [PROJECT_FILE_EXTENSION], + }, + { name: "JSON", extensions: ["json"] }, + { name: mainT("dialogs", "fileDialogs.allFiles"), extensions: ["*"] }, + ], + properties: ["openFile"], + }); if (result.canceled || result.filePaths.length === 0) { return { success: false, canceled: true, message: "Open project canceled" }; diff --git a/electron/main.ts b/electron/main.ts index ad0a33f..3f77ead 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -30,6 +30,18 @@ if (process.platform === "darwin") { app.commandLine.appendSwitch("disable-features", "MacCatapLoopbackAudioForScreenShare"); } +// Enable Wayland support for proper screen capture and window management +// on Wayland compositors (Hyprland, GNOME, KDE, etc.) +if (process.platform === "linux") { + const isWayland = + process.env.XDG_SESSION_TYPE === "wayland" || process.env.WAYLAND_DISPLAY !== undefined; + if (isWayland) { + app.commandLine.appendSwitch("ozone-platform", "wayland"); + // Enable PipeWire for screen capture on Wayland + app.commandLine.appendSwitch("enable-features", "WaylandWindowDrag,PipeWire"); + } +} + export const RECORDINGS_DIR = path.join(app.getPath("userData"), "recordings"); async function ensureRecordingsDir() { diff --git a/package.json b/package.json index d41fd40..37b3762 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "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 AppImage deb", + "build:linux": "tsc && vite build && electron-builder --linux AppImage deb pacman", "test": "vitest --run", "test:watch": "vitest", "build-vite": "tsc && vite build", From d6d872e5298002c802d2e5249264b670c9d0a43e Mon Sep 17 00:00:00 2001 From: psychosomat Date: Wed, 22 Apr 2026 02:23:31 +0300 Subject: [PATCH 03/59] Fix CodeRabbit review comments - Add buildDialogOptions helper function to safely attach parent window only when valid and not destroyed - Update all dialog calls (save-exported-video, open-video-file-picker, save-project-file, load-project-file) to use the helper - Fix supportsWindowOpacity logic by removing || isWayland so Linux always follows no-opacity codepath - Change incorrect Chromium feature name 'PipeWire' to 'WebRTCPipeWireCapturer' in main.ts - Remove unused isWayland variable in handlers.ts --- electron/ipc/handlers.ts | 178 +++++++++++++++++---------------------- electron/main.ts | 4 +- 2 files changed, 79 insertions(+), 103 deletions(-) diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index e5665a9..eafca1e 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -52,6 +52,21 @@ function isPathAllowed(filePath: string): boolean { return getAllowedReadDirs().some((dir) => isPathWithinDir(resolved, dir)); } +/** + * Helper function to build dialog options with a parent window only when it's valid. + * This prevents passing stale or destroyed BrowserWindow references to dialog calls. + */ +function buildDialogOptions( + baseOptions: T, + parentWindow: BrowserWindow | null, +): T & { parent?: BrowserWindow } { + const mainWindow = parentWindow; + if (mainWindow && !mainWindow.isDestroyed()) { + return { ...baseOptions, parent: mainWindow }; + } + return baseOptions; +} + function hasAllowedImportVideoExtension(filePath: string): boolean { return ALLOWED_IMPORT_VIDEO_EXTENSIONS.has(path.extname(filePath).toLowerCase()); } @@ -359,9 +374,7 @@ export function registerIpcHandlers( onRecordingStateChange?: (recording: boolean, sourceName: string) => void, switchToHud?: () => void, ) { - const isWayland = - process.env.XDG_SESSION_TYPE === "wayland" || process.env.WAYLAND_DISPLAY !== undefined; - const supportsWindowOpacity = process.platform !== "linux" || isWayland; + const supportsWindowOpacity = process.platform !== "linux"; const countdownOverlayState = { visible: false, value: null as number | null, @@ -836,24 +849,18 @@ export function registerIpcHandlers( ? [{ name: mainT("dialogs", "fileDialogs.gifImage"), extensions: ["gif"] }] : [{ name: mainT("dialogs", "fileDialogs.mp4Video"), extensions: ["mp4"] }]; - const mainWindow = getMainWindow(); - const result = mainWindow - ? await dialog.showSaveDialog(mainWindow, { - title: isGif - ? mainT("dialogs", "fileDialogs.saveGif") - : mainT("dialogs", "fileDialogs.saveVideo"), - defaultPath: path.join(app.getPath("downloads"), fileName), - filters, - properties: ["createDirectory", "showOverwriteConfirmation"], - }) - : await dialog.showSaveDialog({ - title: isGif - ? mainT("dialogs", "fileDialogs.saveGif") - : mainT("dialogs", "fileDialogs.saveVideo"), - defaultPath: path.join(app.getPath("downloads"), fileName), - filters, - properties: ["createDirectory", "showOverwriteConfirmation"], - }); + const dialogOptions = buildDialogOptions( + { + title: isGif + ? mainT("dialogs", "fileDialogs.saveGif") + : mainT("dialogs", "fileDialogs.saveVideo"), + defaultPath: path.join(app.getPath("downloads"), fileName), + filters, + properties: ["createDirectory", "showOverwriteConfirmation"], + }, + getMainWindow(), + ); + const result = await dialog.showSaveDialog(dialogOptions); if (result.canceled || !result.filePath) { return { @@ -888,32 +895,22 @@ export function registerIpcHandlers( }); ipcMain.handle("open-video-file-picker", async () => { try { - const mainWindow = getMainWindow(); - const result = mainWindow - ? await dialog.showOpenDialog(mainWindow, { - title: mainT("dialogs", "fileDialogs.selectVideo"), - defaultPath: RECORDINGS_DIR, - filters: [ - { - name: mainT("dialogs", "fileDialogs.videoFiles"), - extensions: ["webm", "mp4", "mov", "avi", "mkv"], - }, - { name: mainT("dialogs", "fileDialogs.allFiles"), extensions: ["*"] }, - ], - properties: ["openFile"], - }) - : await dialog.showOpenDialog({ - title: mainT("dialogs", "fileDialogs.selectVideo"), - defaultPath: RECORDINGS_DIR, - filters: [ - { - name: mainT("dialogs", "fileDialogs.videoFiles"), - extensions: ["webm", "mp4", "mov", "avi", "mkv"], - }, - { name: mainT("dialogs", "fileDialogs.allFiles"), extensions: ["*"] }, - ], - properties: ["openFile"], - }); + const dialogOptions = buildDialogOptions( + { + title: mainT("dialogs", "fileDialogs.selectVideo"), + defaultPath: RECORDINGS_DIR, + filters: [ + { + name: mainT("dialogs", "fileDialogs.videoFiles"), + extensions: ["webm", "mp4", "mov", "avi", "mkv"], + }, + { name: mainT("dialogs", "fileDialogs.allFiles"), extensions: ["*"] }, + ], + properties: ["openFile"], + }, + getMainWindow(), + ); + const result = await dialog.showOpenDialog(dialogOptions); if (result.canceled || result.filePaths.length === 0) { return { success: false, canceled: true }; @@ -992,32 +989,22 @@ export function registerIpcHandlers( ? safeName : `${safeName}.${PROJECT_FILE_EXTENSION}`; - const mainWindow = getMainWindow(); - const result = mainWindow - ? await dialog.showSaveDialog(mainWindow, { - title: mainT("dialogs", "fileDialogs.saveProject"), - defaultPath: path.join(RECORDINGS_DIR, defaultName), - filters: [ - { - name: mainT("dialogs", "fileDialogs.openscreenProject"), - extensions: [PROJECT_FILE_EXTENSION], - }, - { name: "JSON", extensions: ["json"] }, - ], - properties: ["createDirectory", "showOverwriteConfirmation"], - }) - : await dialog.showSaveDialog({ - title: mainT("dialogs", "fileDialogs.saveProject"), - defaultPath: path.join(RECORDINGS_DIR, defaultName), - filters: [ - { - name: mainT("dialogs", "fileDialogs.openscreenProject"), - extensions: [PROJECT_FILE_EXTENSION], - }, - { name: "JSON", extensions: ["json"] }, - ], - properties: ["createDirectory", "showOverwriteConfirmation"], - }); + const dialogOptions = buildDialogOptions( + { + title: mainT("dialogs", "fileDialogs.saveProject"), + defaultPath: path.join(RECORDINGS_DIR, defaultName), + filters: [ + { + name: mainT("dialogs", "fileDialogs.openscreenProject"), + extensions: [PROJECT_FILE_EXTENSION], + }, + { name: "JSON", extensions: ["json"] }, + ], + properties: ["createDirectory", "showOverwriteConfirmation"], + }, + getMainWindow(), + ); + const result = await dialog.showSaveDialog(dialogOptions); if (result.canceled || !result.filePath) { return { @@ -1048,34 +1035,23 @@ export function registerIpcHandlers( ipcMain.handle("load-project-file", async () => { try { - const mainWindow = getMainWindow(); - const result = mainWindow - ? await dialog.showOpenDialog(mainWindow, { - title: mainT("dialogs", "fileDialogs.openProject"), - defaultPath: RECORDINGS_DIR, - filters: [ - { - name: mainT("dialogs", "fileDialogs.openscreenProject"), - extensions: [PROJECT_FILE_EXTENSION], - }, - { name: "JSON", extensions: ["json"] }, - { name: mainT("dialogs", "fileDialogs.allFiles"), extensions: ["*"] }, - ], - properties: ["openFile"], - }) - : await dialog.showOpenDialog({ - title: mainT("dialogs", "fileDialogs.openProject"), - defaultPath: RECORDINGS_DIR, - filters: [ - { - name: mainT("dialogs", "fileDialogs.openscreenProject"), - extensions: [PROJECT_FILE_EXTENSION], - }, - { name: "JSON", extensions: ["json"] }, - { name: mainT("dialogs", "fileDialogs.allFiles"), extensions: ["*"] }, - ], - properties: ["openFile"], - }); + const dialogOptions = buildDialogOptions( + { + title: mainT("dialogs", "fileDialogs.openProject"), + defaultPath: RECORDINGS_DIR, + filters: [ + { + name: mainT("dialogs", "fileDialogs.openscreenProject"), + extensions: [PROJECT_FILE_EXTENSION], + }, + { name: "JSON", extensions: ["json"] }, + { name: mainT("dialogs", "fileDialogs.allFiles"), extensions: ["*"] }, + ], + properties: ["openFile"], + }, + getMainWindow(), + ); + const result = await dialog.showOpenDialog(dialogOptions); if (result.canceled || result.filePaths.length === 0) { return { success: false, canceled: true, message: "Open project canceled" }; diff --git a/electron/main.ts b/electron/main.ts index 3f77ead..1da3603 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -37,8 +37,8 @@ if (process.platform === "linux") { process.env.XDG_SESSION_TYPE === "wayland" || process.env.WAYLAND_DISPLAY !== undefined; if (isWayland) { app.commandLine.appendSwitch("ozone-platform", "wayland"); - // Enable PipeWire for screen capture on Wayland - app.commandLine.appendSwitch("enable-features", "WaylandWindowDrag,PipeWire"); + // Enable WebRTCPipeWireCapturer for screen capture on Wayland + app.commandLine.appendSwitch("enable-features", "WaylandWindowDrag,WebRTCPipeWireCapturer"); } } From dc7259ba0944ed71ec7a263572d2c9e9e13faa84 Mon Sep 17 00:00:00 2001 From: david Date: Wed, 29 Apr 2026 10:31:08 +0200 Subject: [PATCH 04/59] fix: bumped npmDepsHash on package.nix --- nix/package.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nix/package.nix b/nix/package.nix index 13a8658..6ece133 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -33,7 +33,7 @@ buildNpmPackage { ); }; - npmDepsHash = "sha256-Pd6J9TuggA9vM4s/LjdoK4MoBEivSzAWc/G2+pFOM2U="; + npmDepsHash = "sha256-i8QMhvd/ydFPww7qTG3Bz2LOAIFyp65n1NXakr3MTk8="; env.ELECTRON_SKIP_BINARY_DOWNLOAD = "1"; From 37c1ea5984a33a5008c54ad35d8e839c3a83ed8d Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Thu, 30 Apr 2026 16:27:57 +0000 Subject: [PATCH 05/59] Add Vietnamese i18n support (vi locale) Co-authored-by: hthienloc <148019203+hthienloc@users.noreply.github.com> --- electron/i18n.ts | 8 +- src/i18n/locales/vi/common.json | 30 +++++ src/i18n/locales/vi/dialogs.json | 70 ++++++++++++ src/i18n/locales/vi/editor.json | 45 ++++++++ src/i18n/locales/vi/launch.json | 43 +++++++ src/i18n/locales/vi/settings.json | 176 +++++++++++++++++++++++++++++ src/i18n/locales/vi/shortcuts.json | 37 ++++++ src/i18n/locales/vi/timeline.json | 55 +++++++++ 8 files changed, 462 insertions(+), 2 deletions(-) create mode 100644 src/i18n/locales/vi/common.json create mode 100644 src/i18n/locales/vi/dialogs.json create mode 100644 src/i18n/locales/vi/editor.json create mode 100644 src/i18n/locales/vi/launch.json create mode 100644 src/i18n/locales/vi/settings.json create mode 100644 src/i18n/locales/vi/shortcuts.json create mode 100644 src/i18n/locales/vi/timeline.json diff --git a/electron/i18n.ts b/electron/i18n.ts index 4222741..f75bd25 100644 --- a/electron/i18n.ts +++ b/electron/i18n.ts @@ -13,12 +13,14 @@ import commonKo from "../src/i18n/locales/ko-KR/common.json"; import dialogsKo from "../src/i18n/locales/ko-KR/dialogs.json"; import commonTr from "../src/i18n/locales/tr/common.json"; import dialogsTr from "../src/i18n/locales/tr/dialogs.json"; +import commonVi from "../src/i18n/locales/vi/common.json"; +import dialogsVi from "../src/i18n/locales/vi/dialogs.json"; import commonZh from "../src/i18n/locales/zh-CN/common.json"; import dialogsZh from "../src/i18n/locales/zh-CN/dialogs.json"; import commonZhTw from "../src/i18n/locales/zh-TW/common.json"; import dialogsZhTw from "../src/i18n/locales/zh-TW/dialogs.json"; -type Locale = "en" | "zh-CN" | "zh-TW" | "es" | "fr" | "ja-JP" | "ko-KR" | "tr"; +type Locale = "en" | "zh-CN" | "zh-TW" | "es" | "fr" | "ja-JP" | "ko-KR" | "tr" | "vi"; type Namespace = "common" | "dialogs"; type MessageMap = Record; @@ -31,6 +33,7 @@ const messages: Record> = { "ja-JP": { common: commonJa, dialogs: dialogsJa }, "ko-KR": { common: commonKo, dialogs: dialogsKo }, tr: { common: commonTr, dialogs: dialogsTr }, + vi: { common: commonVi, dialogs: dialogsVi }, }; let currentLocale: Locale = "en"; @@ -44,7 +47,8 @@ export function setMainLocale(locale: string) { locale === "fr" || locale === "ja-JP" || locale === "ko-KR" || - locale === "tr" + locale === "tr" || + locale === "vi" ) { currentLocale = locale; } diff --git a/src/i18n/locales/vi/common.json b/src/i18n/locales/vi/common.json new file mode 100644 index 0000000..3a3ff5f --- /dev/null +++ b/src/i18n/locales/vi/common.json @@ -0,0 +1,30 @@ +{ + "actions": { + "cancel": "Hủy", + "save": "Lưu", + "delete": "Xóa", + "close": "Đóng", + "share": "Chia sẻ", + "done": "Hoàn tất", + "open": "Mở", + "upload": "Tải lên", + "export": "Xuất", + "showInFolder": "Hiển thị trong thư mục", + "file": "Tệp", + "edit": "Chỉnh sửa", + "view": "Xem", + "window": "Cửa sổ", + "quit": "Thoát", + "stopRecording": "Dừng ghi hình" + }, + "playback": { + "play": "Phát", + "pause": "Tạm dừng", + "fullscreen": "Toàn màn hình", + "exitFullscreen": "Thoát toàn màn hình" + }, + "locale": { + "name": "Tiếng Việt", + "short": "VI" + } +} diff --git a/src/i18n/locales/vi/dialogs.json b/src/i18n/locales/vi/dialogs.json new file mode 100644 index 0000000..c94dbaa --- /dev/null +++ b/src/i18n/locales/vi/dialogs.json @@ -0,0 +1,70 @@ +{ + "export": { + "complete": "Xuất hoàn tất", + "yourFormatReady": "{{format}} của bạn đã sẵn sàng", + "showInFolder": "Hiển thị trong thư mục", + "finalizingVideo": "Đang hoàn tất xuất video...", + "compilingGifProgress": "Đang biên dịch GIF... {{progress}}%", + "compilingGifWait": "Đang biên dịch GIF... Có thể mất một lúc", + "takeMoment": "Có thể mất một chút thời gian...", + "failed": "Xuất thất bại", + "tryAgain": "Vui lòng thử lại", + "finalizingVideoTitle": "Hoàn tất video", + "compilingGif": "Biên dịch GIF", + "exportingFormat": "Đang xuất {{format}}", + "compiling": "Đang biên dịch", + "renderingFrames": "Kết xuất khung hình", + "processing": "Đang xử lý...", + "finalizing": "Đang hoàn tất...", + "compilingStatus": "Đang biên dịch...", + "status": "Trạng thái", + "format": "Định dạng", + "frames": "Khung hình", + "cancelExport": "Hủy xuất", + "savedSuccessfully": "Đã lưu {{format}} thành công!" + }, + "tutorial": { + "triggerLabel": "Cách hoạt động của công cụ cắt", + "title": "Cách cắt video", + "description": "Hiểu cách cắt bỏ các phần không mong muốn trong video của bạn.", + "explanationBefore": "Công cụ Cắt hoạt động bằng cách xác định các đoạn bạn muốn", + "remove": "xóa", + "explanationMiddle": " — bất cứ thứ gì", + "covered": "được bao phủ", + "explanationAfter": "bởi một đoạn cắt màu đỏ sẽ bị loại bỏ khi bạn xuất.", + "visualExample": "Ví dụ trực quan", + "removed": "ĐÃ XÓA", + "kept": "Giữ lại", + "part1": "Phần 1", + "part2": "Phần 2", + "part3": "Phần 3", + "finalVideo": "Video cuối cùng", + "step1Title": "1. Thêm đoạn cắt", + "step1DescriptionBefore": "Nhấn ", + "step1DescriptionAfter": " hoặc nhấp vào biểu tượng cái kéo để đánh dấu một phần cần xóa.", + "step2Title": "2. Điều chỉnh", + "step2Description": "Kéo các cạnh của vùng màu đỏ để bao phủ chính xác những gì bạn muốn cắt bỏ." + }, + "unsavedChanges": { + "title": "Thay đổi chưa được lưu", + "message": "Bạn có các thay đổi chưa được lưu.", + "detail": "Bạn có muốn lưu dự án của mình trước khi đóng không?", + "saveAndClose": "Lưu & Đóng", + "discardAndClose": "Bỏ qua & Đóng", + "loadProject": "Tải dự án…", + "saveProject": "Lưu dự án…", + "saveProjectAs": "Lưu dự án thành…" + }, + "fileDialogs": { + "saveGif": "Lưu GIF đã xuất", + "saveVideo": "Lưu Video đã xuất", + "selectVideo": "Chọn tệp video", + "saveProject": "Lưu dự án OpenScreen", + "openProject": "Mở dự án OpenScreen", + "gifImage": "Hình ảnh GIF", + "mp4Video": "Video MP4", + "videoFiles": "Tệp Video", + "openscreenProject": "Dự án OpenScreen", + "allFiles": "Tất cả các tệp" + } +} diff --git a/src/i18n/locales/vi/editor.json b/src/i18n/locales/vi/editor.json new file mode 100644 index 0000000..a45bf3e --- /dev/null +++ b/src/i18n/locales/vi/editor.json @@ -0,0 +1,45 @@ +{ + "newRecording": { + "title": "Quay lại Trình ghi", + "description": "Phiên hiện tại của bạn đã được lưu.", + "cancel": "Hủy", + "confirm": "Xác nhận" + }, + "loadingVideo": "Đang tải video...", + "errors": { + "noVideoLoaded": "Chưa tải video nào", + "videoNotReady": "Video chưa sẵn sàng", + "unableToDetermineSourcePath": "Không thể xác định đường dẫn video gốc", + "failedToSaveGif": "Không thể lưu GIF", + "gifExportFailed": "Xuất GIF thất bại", + "failedToSaveVideo": "Không thể lưu video", + "exportFailed": "Xuất thất bại", + "exportFailedWithError": "Xuất thất bại: {{error}}", + "exportBackgroundLoadFailed": "Xuất thất bại: không thể tải hình nền ({{url}})", + "failedToSaveExport": "Không thể lưu bản xuất", + "failedToSaveExportedVideo": "Không thể lưu video đã xuất", + "failedToRevealInFolder": "Lỗi khi hiển thị trong thư mục: {{error}}" + }, + "export": { + "canceled": "Đã hủy xuất", + "exportedSuccessfully": "Đã xuất {{format}} thành công" + }, + "project": { + "saveCanceled": "Đã hủy lưu dự án", + "failedToSave": "Lưu dự án thất bại", + "savedTo": "Đã lưu dự án vào {{path}}", + "failedToLoad": "Tải dự án thất bại", + "invalidFormat": "Định dạng tệp dự án không hợp lệ", + "loadedFrom": "Đã tải dự án từ {{path}}" + }, + "recording": { + "failedCameraAccess": "Yêu cầu quyền truy cập máy ảnh thất bại.", + "cameraBlocked": "Quyền truy cập máy ảnh bị chặn. Hãy bật nó trong cài đặt hệ thống để sử dụng webcam.", + "systemAudioUnavailable": "Âm thanh hệ thống không khả dụng. Ghi hình không có âm thanh hệ thống.", + "microphoneDenied": "Quyền truy cập micro bị từ chối. Sẽ tiếp tục ghi hình không có âm thanh.", + "cameraDenied": "Quyền truy cập máy ảnh bị từ chối. Sẽ tiếp tục ghi hình không có webcam.", + "cameraDisconnected": "Webcam bị ngắt kết nối.", + "cameraNotFound": "Không tìm thấy máy ảnh.", + "permissionDenied": "Quyền ghi hình bị từ chối. Vui lòng cho phép ghi màn hình." + } +} diff --git a/src/i18n/locales/vi/launch.json b/src/i18n/locales/vi/launch.json new file mode 100644 index 0000000..132e822 --- /dev/null +++ b/src/i18n/locales/vi/launch.json @@ -0,0 +1,43 @@ +{ + "tooltips": { + "hideHUD": "Ẩn HUD", + "closeApp": "Đóng ứng dụng", + "restartRecording": "Khởi động lại ghi hình", + "cancelRecording": "Hủy ghi hình", + "pauseRecording": "Tạm dừng ghi hình", + "resumeRecording": "Tiếp tục ghi hình", + "openVideoFile": "Mở tệp video", + "openProject": "Mở dự án" + }, + "audio": { + "enableSystemAudio": "Bật âm thanh hệ thống", + "disableSystemAudio": "Tắt âm thanh hệ thống", + "enableMicrophone": "Bật micro", + "disableMicrophone": "Tắt micro", + "defaultMicrophone": "Micro Mặc định" + }, + "webcam": { + "enableWebcam": "Bật webcam", + "disableWebcam": "Tắt webcam", + "defaultCamera": "Máy ảnh Mặc định", + "searching": "Đang tìm kiếm...", + "noneFound": "Không tìm thấy máy ảnh", + "unavailable": "Máy ảnh không khả dụng" + }, + "sourceSelector": { + "loading": "Đang tải nguồn...", + "screens": "Màn hình ({{count}})", + "windows": "Cửa sổ ({{count}})", + "defaultSourceName": "Màn hình" + }, + "recording": { + "selectSource": "Vui lòng chọn một nguồn để ghi" + }, + "language": "Ngôn ngữ", + "systemLanguagePrompt": { + "title": "Sử dụng ngôn ngữ hệ thống của bạn?", + "description": "Chúng tôi phát hiện {{language}} là ngôn ngữ hệ thống của bạn. Bạn có muốn chuyển OpenScreen sang {{language}} không?", + "switch": "Chuyển sang {{language}}", + "keepDefault": "Giữ ngôn ngữ hiện tại" + } +} diff --git a/src/i18n/locales/vi/settings.json b/src/i18n/locales/vi/settings.json new file mode 100644 index 0000000..e6a897d --- /dev/null +++ b/src/i18n/locales/vi/settings.json @@ -0,0 +1,176 @@ +{ + "zoom": { + "level": "Mức độ thu phóng", + "selectRegion": "Chọn vùng thu phóng để điều chỉnh", + "deleteZoom": "Xóa thu phóng", + "focusMode": { + "title": "Chế độ lấy nét", + "manual": "Thủ công", + "auto": "Tự động", + "autoDescription": "Máy ảnh đi theo vị trí con trỏ đã ghi" + } + }, + "speed": { + "playbackSpeed": "Tốc độ phát", + "selectRegion": "Chọn vùng tốc độ để điều chỉnh", + "deleteRegion": "Xóa vùng tốc độ", + "customPlaybackSpeed": "Tốc độ phát tùy chỉnh", + "maxSpeedError": "Tốc độ không thể cao hơn 16×" + }, + "trim": { + "deleteRegion": "Xóa vùng cắt" + }, + "layout": { + "title": "Bố cục", + "preset": "Cài đặt sẵn", + "selectPreset": "Chọn cài đặt sẵn", + "pictureInPicture": "Hình trong hình", + "verticalStack": "Xếp chồng dọc", + "dualFrame": "Khung kép", + "webcamShape": "Hình dạng máy ảnh", + "webcamSize": "Kích thước Webcam" + }, + "effects": { + "title": "Hiệu ứng video", + "blurBg": "Làm mờ nền", + "motionBlur": "Làm mờ chuyển động", + "off": "tắt", + "shadow": "Bóng đổ", + "roundness": "Độ bo tròn", + "padding": "Phần đệm" + }, + "background": { + "title": "Nền", + "image": "Hình ảnh", + "color": "Màu sắc", + "gradient": "Dải màu", + "uploadCustom": "Tải lên tùy chỉnh", + "gradientLabel": "Dải màu {{index}}" + }, + "crop": { + "title": "Cắt xén", + "cropVideo": "Cắt xén video", + "dragInstruction": "Kéo ở mỗi cạnh để điều chỉnh vùng cắt xén", + "ratio": "Tỷ lệ", + "free": "Tự do", + "done": "Hoàn tất", + "lockAspectRatio": "Khóa tỷ lệ khung hình", + "unlockAspectRatio": "Mở khóa tỷ lệ khung hình" + }, + "exportFormat": { + "mp4": "MP4", + "gif": "GIF", + "mp4Video": "Video MP4", + "mp4Description": "Tệp video chất lượng cao", + "gifAnimation": "Ảnh động GIF", + "gifDescription": "Hình ảnh động để chia sẻ" + }, + "exportQuality": { + "title": "Chất lượng xuất", + "low": "Thấp", + "medium": "Trung bình", + "high": "Cao" + }, + "gifSettings": { + "frameRate": "Tốc độ khung hình GIF", + "size": "Kích thước GIF", + "loop": "Lặp lại GIF" + }, + "project": { + "save": "Lưu dự án", + "load": "Tải dự án" + }, + "export": { + "videoButton": "Xuất Video", + "gifButton": "Xuất GIF", + "chooseSaveLocation": "Chọn vị trí lưu" + }, + "links": { + "reportBug": "Báo cáo lỗi", + "starOnGithub": "Đánh giá sao trên GitHub" + }, + "imageUpload": { + "invalidFileType": "Loại tệp không hợp lệ", + "jpgOnly": "Vui lòng tải lên tệp hình ảnh JPG hoặc JPEG.", + "uploadSuccess": "Tải lên hình ảnh tùy chỉnh thành công!", + "failedToUpload": "Tải lên hình ảnh thất bại", + "errorReading": "Đã xảy ra lỗi khi đọc tệp." + }, + "annotation": { + "title": "Cài đặt chú thích", + "active": "Hoạt động", + "typeText": "Văn bản", + "typeImage": "Hình ảnh", + "typeArrow": "Mũi tên", + "typeBlur": "Làm mờ", + "textContent": "Nội dung văn bản", + "textPlaceholder": "Nhập văn bản của bạn...", + "fontStyle": "Kiểu phông chữ", + "selectStyle": "Chọn kiểu", + "size": "Kích thước", + "customFonts": "Phông chữ tùy chỉnh", + "textColor": "Màu văn bản", + "background": "Nền", + "none": "Không có", + "color": "Màu sắc", + "clearBackground": "Xóa nền", + "uploadImage": "Tải lên hình ảnh", + "supportedFormats": "Định dạng hỗ trợ: JPG, PNG, GIF, WebP", + "arrowDirection": "Hướng mũi tên", + "strokeWidth": "Độ dày nét: {{width}}px", + "arrowColor": "Màu mũi tên", + "blurType": "Loại làm mờ", + "blurTypeBlur": "Làm mờ", + "blurTypeMosaic": "Khảm", + "blurColor": "Màu làm mờ", + "blurColorWhite": "Trắng", + "blurColorBlack": "Đen", + "blurShape": "Hình dạng làm mờ", + "blurIntensity": "Cường độ làm mờ", + "mosaicBlockSize": "Kích thước khối khảm", + "blurShapeRectangle": "Chữ nhật", + "blurShapeOval": "Bầu dục", + "blurShapeFreehand": "Vẽ tự do", + "deleteAnnotation": "Xóa chú thích", + "shortcutsAndTips": "Phím tắt & Mẹo", + "tipMovePlayhead": "Di chuyển đầu phát đến phần chú thích chồng chéo và chọn một mục.", + "tipTabCycle": "Sử dụng Tab để chuyển qua các mục chồng chéo.", + "tipShiftTabCycle": "Sử dụng Shift+Tab để chuyển ngược lại.", + "invalidImageType": "Loại tệp không hợp lệ", + "imageFormatsOnly": "Vui lòng tải lên tệp hình ảnh JPG, PNG, GIF hoặc WebP.", + "imageUploadSuccess": "Tải lên hình ảnh thành công!", + "failedImageUpload": "Tải lên hình ảnh thất bại" + }, + "fontStyles": { + "classic": "Cổ điển", + "editor": "Trình chỉnh sửa", + "strong": "Đậm", + "typewriter": "Máy đánh chữ", + "deco": "Trang trí", + "simple": "Đơn giản", + "modern": "Hiện đại", + "clean": "Sạch sẽ" + }, + "customFont": { + "dialogTitle": "Thêm Google Font", + "urlLabel": "URL nhập Google Fonts", + "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap", + "urlHelp": "Lấy từ Google Fonts: Chọn một phông chữ → Nhấp \"Get font\" → Sao chép URL @import", + "nameLabel": "Tên hiển thị", + "namePlaceholder": "Phông chữ tùy chỉnh của tôi", + "nameHelp": "Đây là cách phông chữ sẽ xuất hiện trong bộ chọn phông chữ", + "addButton": "Thêm phông chữ", + "addingButton": "Đang thêm...", + "errorEmptyUrl": "Vui lòng nhập URL nhập Google Fonts", + "errorInvalidUrl": "Vui lòng nhập URL Google Fonts hợp lệ", + "errorEmptyName": "Vui lòng nhập tên phông chữ", + "errorExtractFailed": "Không thể trích xuất họ phông chữ từ URL", + "successMessage": "Thêm phông chữ \"{{fontName}}\" thành công", + "failedToAdd": "Thêm phông chữ thất bại", + "errorTimeout": "Tải phông chữ mất quá nhiều thời gian. Vui lòng kiểm tra URL và thử lại.", + "errorLoadFailed": "Không thể tải phông chữ. Vui lòng xác minh URL Google Fonts là chính xác." + }, + "language": { + "title": "Ngôn ngữ" + } +} diff --git a/src/i18n/locales/vi/shortcuts.json b/src/i18n/locales/vi/shortcuts.json new file mode 100644 index 0000000..918254f --- /dev/null +++ b/src/i18n/locales/vi/shortcuts.json @@ -0,0 +1,37 @@ +{ + "title": "Phím tắt", + "customize": "Tùy chỉnh", + "configurable": "Có thể định cấu hình", + "fixed": "Cố định", + "pressKey": "Nhấn một phím…", + "clickToChange": "Nhấp để thay đổi", + "pressEscToCancel": "Nhấn Esc để hủy", + "helpText": "Nhấp vào một phím tắt rồi nhấn tổ hợp phím mới. Nhấn Esc để hủy.", + "resetToDefaults": "Khôi phục mặc định", + "alreadyUsedBy": "Đã được sử dụng bởi {{action}}", + "swap": "Hoán đổi", + "reservedShortcut": "Phím tắt này được dành riêng cho \"{{label}}\" và không thể gán lại.", + "savedToast": "Đã lưu phím tắt", + "resetToast": "Đã đặt lại về phím tắt mặc định — nhấp Lưu để áp dụng", + "actions": { + "addZoom": "Thêm Thu phóng", + "addTrim": "Thêm Cắt", + "addSpeed": "Thêm Tốc độ", + "addAnnotation": "Thêm Chú thích", + "addBlur": "Thêm Làm mờ", + "addKeyframe": "Thêm Khung hình chính", + "deleteSelected": "Xóa mục đã chọn", + "playPause": "Phát / Tạm dừng" + }, + "fixedActions": { + "undo": "Hoàn tác", + "redo": "Làm lại", + "cycleAnnotationsForward": "Chuyển tiếp qua các chú thích", + "cycleAnnotationsBackward": "Chuyển lùi qua các chú thích", + "deleteSelectedAlt": "Xóa mục đã chọn (alt)", + "panTimeline": "Xoay Trục thời gian", + "zoomTimeline": "Thu phóng Trục thời gian", + "frameBack": "Lùi một khung hình", + "frameForward": "Tiến một khung hình" + } +} diff --git a/src/i18n/locales/vi/timeline.json b/src/i18n/locales/vi/timeline.json new file mode 100644 index 0000000..a01dfe4 --- /dev/null +++ b/src/i18n/locales/vi/timeline.json @@ -0,0 +1,55 @@ +{ + "buttons": { + "addZoom": "Thêm Thu phóng (Z)", + "suggestZooms": "Đề xuất Thu phóng từ Con trỏ", + "addTrim": "Thêm Cắt (T)", + "addAnnotation": "Thêm Chú thích (A)", + "addBlur": "Thêm Làm mờ (B)", + "addSpeed": "Thêm Tốc độ (S)" + }, + "hints": { + "pressZoom": "Nhấn Z để thêm thu phóng", + "pressTrim": "Nhấn T để thêm cắt", + "pressAnnotation": "Nhấn A để thêm chú thích", + "pressBlur": "Nhấn B để thêm vùng làm mờ", + "pressSpeed": "Nhấn S để thêm tốc độ" + }, + "labels": { + "pan": "Xoay", + "zoom": "Thu phóng", + "trim": "Cắt", + "speed": "Tốc độ", + "zoomItem": "Thu phóng {{index}}", + "trimItem": "Cắt {{index}}", + "speedItem": "Tốc độ {{index}}", + "annotationItem": "Chú thích", + "blurItem": "Làm mờ {{index}}", + "imageItem": "Hình ảnh", + "emptyText": "Văn bản trống" + }, + "emptyState": { + "noVideo": "Chưa tải video", + "dragAndDrop": "Kéo và thả video để bắt đầu chỉnh sửa" + }, + "errors": { + "cannotPlaceZoom": "Không thể đặt thu phóng ở đây", + "zoomExistsAtLocation": "Thu phóng đã tồn tại ở vị trí này hoặc không có đủ không gian.", + "zoomSuggestionUnavailable": "Trình xử lý đề xuất thu phóng không khả dụng", + "noCursorTelemetry": "Không có dữ liệu từ xa của con trỏ", + "noCursorTelemetryDescription": "Ghi hình màn hình trước để tạo các đề xuất dựa trên con trỏ.", + "noUsableTelemetry": "Không có dữ liệu từ xa của con trỏ có thể sử dụng", + "noUsableTelemetryDescription": "Bản ghi không chứa đủ dữ liệu chuyển động của con trỏ.", + "noDwellMoments": "Không tìm thấy khoảnh khắc dừng con trỏ rõ ràng", + "noDwellMomentsDescription": "Thử ghi hình với các lần tạm dừng con trỏ chậm hơn ở các thao tác quan trọng.", + "noAutoZoomSlots": "Không có khe thu phóng tự động nào", + "noAutoZoomSlotsDescription": "Các điểm dừng được phát hiện chồng chéo với các vùng thu phóng hiện có.", + "cannotPlaceTrim": "Không thể đặt cắt ở đây", + "trimExistsAtLocation": "Cắt đã tồn tại ở vị trí này hoặc không có đủ không gian.", + "cannotPlaceSpeed": "Không thể đặt tốc độ ở đây", + "speedExistsAtLocation": "Vùng tốc độ đã tồn tại ở vị trí này hoặc không có đủ không gian." + }, + "success": { + "addedZoomSuggestions": "Đã thêm {{count}} đề xuất thu phóng dựa trên con trỏ", + "addedZoomSuggestionsPlural": "Đã thêm {{count}} đề xuất thu phóng dựa trên con trỏ" + } +} From a38454a7fb03a5e736c52abf58a6ed5c280bb63a Mon Sep 17 00:00:00 2001 From: AbhinRustagi Date: Sat, 2 May 2026 01:02:42 +0530 Subject: [PATCH 06/59] feat: update saveExportedVideo fn signature --- electron/electron-env.d.ts | 1 + electron/preload.ts | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index 85d8294..f04b7c3 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -76,6 +76,7 @@ interface Window { saveExportedVideo: ( videoData: ArrayBuffer, fileName: string, + exportFolder?: string, ) => Promise<{ success: boolean; path?: string; message?: string; canceled?: boolean }>; openVideoFilePicker: () => Promise<{ success: boolean; path?: string; canceled?: boolean }>; setCurrentVideoPath: (path: string) => Promise<{ success: boolean }>; diff --git a/electron/preload.ts b/electron/preload.ts index 46e16f0..ec221b0 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -68,8 +68,8 @@ contextBridge.exposeInMainWorld("electronAPI", { openExternalUrl: (url: string) => { return ipcRenderer.invoke("open-external-url", url); }, - saveExportedVideo: (videoData: ArrayBuffer, fileName: string) => { - return ipcRenderer.invoke("save-exported-video", videoData, fileName); + saveExportedVideo: (videoData: ArrayBuffer, fileName: string, exportFolder?: string) => { + return ipcRenderer.invoke("save-exported-video", videoData, fileName, exportFolder); }, openVideoFilePicker: () => { return ipcRenderer.invoke("open-video-file-picker"); From c40727672ffa206d7f57f38a2913906f03dfcbe0 Mon Sep 17 00:00:00 2001 From: AbhinRustagi Date: Sat, 2 May 2026 01:05:17 +0530 Subject: [PATCH 07/59] feat: implement handlers to store last export location --- electron/ipc/handlers.ts | 102 ++++++++++++-------- src/components/video-editor/VideoEditor.tsx | 19 +++- src/lib/userPreferences.ts | 18 ++++ 3 files changed, 94 insertions(+), 45 deletions(-) diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 95ed797..9a8e9ca 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -822,54 +822,72 @@ export function registerIpcHandlers( * @returns Object with success status, optional file path, and error details. */ - ipcMain.handle("save-exported-video", async (_, videoData: ArrayBuffer, fileName: string) => { - try { - // Determine file type from extension - const isGif = fileName.toLowerCase().endsWith(".gif"); - const filters = isGif - ? [{ name: mainT("dialogs", "fileDialogs.gifImage"), extensions: ["gif"] }] - : [{ name: mainT("dialogs", "fileDialogs.mp4Video"), extensions: ["mp4"] }]; + ipcMain.handle( + "save-exported-video", + async (_, videoData: ArrayBuffer, fileName: string, exportFolder?: string) => { + try { + // Determine file type from extension + const isGif = fileName.toLowerCase().endsWith(".gif"); + const filters = isGif + ? [{ name: mainT("dialogs", "fileDialogs.gifImage"), extensions: ["gif"] }] + : [{ name: mainT("dialogs", "fileDialogs.mp4Video"), extensions: ["mp4"] }]; - const result = await dialog.showSaveDialog({ - title: isGif - ? mainT("dialogs", "fileDialogs.saveGif") - : mainT("dialogs", "fileDialogs.saveVideo"), - defaultPath: path.join(app.getPath("downloads"), fileName), - filters, - properties: ["createDirectory", "showOverwriteConfirmation"], - }); + // Prefer the user's last export folder if it still exists, otherwise fall + // back to ~/Downloads. Validation must happen here because the renderer + // can't stat the filesystem. + let defaultDir = app.getPath("downloads"); + if (exportFolder) { + try { + const stats = await fs.stat(exportFolder); + if (stats.isDirectory()) { + defaultDir = exportFolder; + } + } catch { + // Folder was moved or deleted since the last export; keep Downloads. + } + } - if (result.canceled || !result.filePath) { + const result = await dialog.showSaveDialog({ + title: isGif + ? mainT("dialogs", "fileDialogs.saveGif") + : mainT("dialogs", "fileDialogs.saveVideo"), + defaultPath: path.join(defaultDir, fileName), + filters, + properties: ["createDirectory", "showOverwriteConfirmation"], + }); + + if (result.canceled || !result.filePath) { + return { + success: false, + canceled: true, + message: "Export canceled", + }; + } + + // --- FIX: Normalize the path for Windows compatibility --- + const normalizedPath = path.normalize(result.filePath); + + // Ensure the parent directory exists (Windows may fail if the folder is missing) + await fs.mkdir(path.dirname(normalizedPath), { recursive: true }); + // --- END FIX --- + + await fs.writeFile(normalizedPath, Buffer.from(videoData)); + + return { + success: true, + path: normalizedPath, + message: "Video exported successfully", + }; + } catch (error) { + console.error("Failed to save exported video:", error); return { success: false, - canceled: true, - message: "Export canceled", + message: "Failed to save exported video", + error: String(error), }; } - - // --- FIX: Normalize the path for Windows compatibility --- - const normalizedPath = path.normalize(result.filePath); - - // Ensure the parent directory exists (Windows may fail if the folder is missing) - await fs.mkdir(path.dirname(normalizedPath), { recursive: true }); - // --- END FIX --- - - await fs.writeFile(normalizedPath, Buffer.from(videoData)); - - return { - success: true, - path: normalizedPath, - message: "Video exported successfully", - }; - } catch (error) { - console.error("Failed to save exported video:", error); - return { - success: false, - message: "Failed to save exported video", - error: String(error), - }; - } - }); + }, + ); ipcMain.handle("open-video-file-picker", async () => { try { const result = await dialog.showOpenDialog({ diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 7adc558..cf174fa 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -31,7 +31,7 @@ import { import { computeFrameStepTime } from "@/lib/frameStep"; import type { ProjectMedia } from "@/lib/recordingSession"; import { matchesShortcut } from "@/lib/shortcuts"; -import { loadUserPreferences, saveUserPreferences } from "@/lib/userPreferences"; +import { loadUserPreferences, parentDirectoryOf, saveUserPreferences } from "@/lib/userPreferences"; import { BackgroundLoadError } from "@/lib/wallpaper"; import { getAspectRatioValue, @@ -1285,6 +1285,10 @@ export default function VideoEditor() { const handleExportSaved = useCallback( (formatLabel: "GIF" | "Video", filePath: string) => { setExportedFilePath(filePath); + const folder = parentDirectoryOf(filePath); + if (folder) { + saveUserPreferences({ exportFolder: folder }); + } toast.success( t("export.exportedSuccessfully", { format: formatLabel, @@ -1309,6 +1313,7 @@ export default function VideoEditor() { const saveResult = await window.electronAPI.saveExportedVideo( unsavedExport.arrayBuffer, unsavedExport.fileName, + loadUserPreferences().exportFolder ?? undefined, ); if (saveResult.canceled) { toast.info("Export canceled"); @@ -1410,7 +1415,11 @@ export default function VideoEditor() { } } - const saveResult = await window.electronAPI.saveExportedVideo(arrayBuffer, fileName); + const saveResult = await window.electronAPI.saveExportedVideo( + arrayBuffer, + fileName, + loadUserPreferences().exportFolder ?? undefined, + ); if (saveResult.canceled) { setUnsavedExport({ arrayBuffer, fileName, format: "gif" }); @@ -1550,7 +1559,11 @@ export default function VideoEditor() { } } - const saveResult = await window.electronAPI.saveExportedVideo(arrayBuffer, fileName); + const saveResult = await window.electronAPI.saveExportedVideo( + arrayBuffer, + fileName, + loadUserPreferences().exportFolder ?? undefined, + ); if (saveResult.canceled) { setUnsavedExport({ arrayBuffer, fileName, format: "mp4" }); diff --git a/src/lib/userPreferences.ts b/src/lib/userPreferences.ts index e060788..6947da5 100644 --- a/src/lib/userPreferences.ts +++ b/src/lib/userPreferences.ts @@ -23,6 +23,8 @@ export interface UserPreferences { exportQuality: ExportQuality; /** Default export format */ exportFormat: ExportFormat; + /** Folder used for the most recent successful export, if any */ + exportFolder: string | null; } const DEFAULT_PREFS: UserPreferences = { @@ -30,6 +32,7 @@ const DEFAULT_PREFS: UserPreferences = { aspectRatio: "16:9", exportQuality: "good", exportFormat: "mp4", + exportFolder: null, }; function safeJsonParse(text: string | null): Record | null { @@ -76,9 +79,24 @@ export function loadUserPreferences(): UserPreferences { raw.exportFormat === "gif" || raw.exportFormat === "mp4" ? (raw.exportFormat as ExportFormat) : DEFAULT_PREFS.exportFormat, + exportFolder: + typeof raw.exportFolder === "string" && raw.exportFolder.length > 0 + ? raw.exportFolder + : DEFAULT_PREFS.exportFolder, }; } +/** + * Extracts the parent directory from a saved file path. Handles both POSIX + * and Windows separators since the path comes from the OS save dialog. + * Returns null if no separator is found. + */ +export function parentDirectoryOf(filePath: string): string | null { + const lastSep = Math.max(filePath.lastIndexOf("/"), filePath.lastIndexOf("\\")); + if (lastSep <= 0) return null; + return filePath.slice(0, lastSep); +} + /** * Persist user preferences to localStorage. * Only the explicitly provided fields are updated. From b801c1ccea42522e752fc9b72a733d492e262400 Mon Sep 17 00:00:00 2001 From: AbhinRustagi Date: Sat, 2 May 2026 01:19:44 +0530 Subject: [PATCH 08/59] fix: resolve comments --- src/lib/userPreferences.test.ts | 26 ++++++++++++++++++++++++++ src/lib/userPreferences.ts | 17 ++++++++++++++++- 2 files changed, 42 insertions(+), 1 deletion(-) create mode 100644 src/lib/userPreferences.test.ts diff --git a/src/lib/userPreferences.test.ts b/src/lib/userPreferences.test.ts new file mode 100644 index 0000000..5ba9fce --- /dev/null +++ b/src/lib/userPreferences.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import { parentDirectoryOf } from "./userPreferences"; + +describe("parentDirectoryOf", () => { + it("returns the directory for a POSIX path", () => { + expect(parentDirectoryOf("/Users/me/Movies/clip.mp4")).toBe("/Users/me/Movies"); + }); + + it("returns the directory for a Windows path", () => { + expect(parentDirectoryOf("C:\\Users\\me\\Movies\\clip.mp4")).toBe("C:\\Users\\me\\Movies"); + }); + + it("preserves the POSIX root when the file is at /", () => { + expect(parentDirectoryOf("/video.mp4")).toBe("/"); + }); + + it("preserves the Windows drive root with its trailing separator", () => { + expect(parentDirectoryOf("C:\\video.mp4")).toBe("C:\\"); + expect(parentDirectoryOf("D:/video.mp4")).toBe("D:/"); + }); + + it("returns null when no separator is present", () => { + expect(parentDirectoryOf("video.mp4")).toBeNull(); + expect(parentDirectoryOf("")).toBeNull(); + }); +}); diff --git a/src/lib/userPreferences.ts b/src/lib/userPreferences.ts index 6947da5..2c9db6f 100644 --- a/src/lib/userPreferences.ts +++ b/src/lib/userPreferences.ts @@ -89,11 +89,26 @@ export function loadUserPreferences(): UserPreferences { /** * Extracts the parent directory from a saved file path. Handles both POSIX * and Windows separators since the path comes from the OS save dialog. + * + * Root directories are preserved with their trailing separator so that the + * value is still a valid directory path: + * "/video.mp4" -> "/" + * "C:\\video.mp4" -> "C:\\" + * * Returns null if no separator is found. */ export function parentDirectoryOf(filePath: string): string | null { const lastSep = Math.max(filePath.lastIndexOf("/"), filePath.lastIndexOf("\\")); - if (lastSep <= 0) return null; + if (lastSep < 0) return null; + + // POSIX root, e.g. "/video.mp4" -> "/" + if (lastSep === 0) return filePath[0]; + + // Windows drive root, e.g. "C:\\video.mp4" -> "C:\\" + if (lastSep === 2 && /^[A-Za-z]:[/\\]/.test(filePath)) { + return filePath.slice(0, lastSep + 1); + } + return filePath.slice(0, lastSep); } From b3469c469b474a3a3aa32b909088b8611ee96f58 Mon Sep 17 00:00:00 2001 From: makaradam Date: Sat, 2 May 2026 12:28:04 +0200 Subject: [PATCH 09/59] feat: replace native OS close dialog with custom in-app dialog --- electron/electron-env.d.ts | 2 + electron/main.ts | 43 ++++------ electron/preload.ts | 8 ++ .../video-editor/UnsavedChangesDialog.tsx | 78 +++++++++++++++++++ src/components/video-editor/VideoEditor.tsx | 31 ++++++++ 5 files changed, 136 insertions(+), 26 deletions(-) create mode 100644 src/components/video-editor/UnsavedChangesDialog.tsx diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index 85d8294..f4b379f 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -143,6 +143,8 @@ interface Window { setMicrophoneExpanded: (expanded: boolean) => void; setHasUnsavedChanges: (hasChanges: boolean) => void; onRequestSaveBeforeClose: (callback: () => Promise | boolean) => () => void; + onRequestCloseConfirm: (callback: () => void) => () => void; + sendCloseConfirmResponse: (choice: "save" | "discard" | "cancel") => void; setLocale: (locale: string) => Promise; }; } diff --git a/electron/main.ts b/electron/main.ts index ad0a33f..5540419 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -4,7 +4,6 @@ import { fileURLToPath } from "node:url"; import { app, BrowserWindow, - dialog, ipcMain, Menu, nativeImage, @@ -288,35 +287,27 @@ function createEditorWindowWrapper() { event.preventDefault(); - const choice = dialog.showMessageBoxSync(mainWindow!, { - type: "warning", - buttons: [ - mainT("dialogs", "unsavedChanges.saveAndClose"), - mainT("dialogs", "unsavedChanges.discardAndClose"), - mainT("common", "actions.cancel"), - ], - defaultId: 0, - cancelId: 2, - title: mainT("dialogs", "unsavedChanges.title"), - message: mainT("dialogs", "unsavedChanges.message"), - detail: mainT("dialogs", "unsavedChanges.detail"), - }); - const windowToClose = mainWindow; if (!windowToClose || windowToClose.isDestroyed()) return; - if (choice === 0) { - // Save & Close — tell renderer to save, then close - windowToClose.webContents.send("request-save-before-close"); - ipcMain.once("save-before-close-done", (_, shouldClose: boolean) => { - if (!shouldClose) return; + // Ask renderer to show the custom in-app dialog + windowToClose.webContents.send("request-close-confirm"); + + ipcMain.once("close-confirm-response", (_, choice: "save" | "discard" | "cancel") => { + if (!windowToClose || windowToClose.isDestroyed()) return; + + if (choice === "save") { + // Tell renderer to save the project, then close when done + windowToClose.webContents.send("request-save-before-close"); + ipcMain.once("save-before-close-done", (_, shouldClose: boolean) => { + if (!shouldClose) return; + forceCloseEditorWindow(windowToClose); + }); + } else if (choice === "discard") { forceCloseEditorWindow(windowToClose); - }); - } else if (choice === 1) { - // Discard & Close - forceCloseEditorWindow(windowToClose); - } - // choice === 2: Cancel — do nothing, window stays open + } + // "cancel": do nothing, window stays open + }); }); } diff --git a/electron/preload.ts b/electron/preload.ts index 46e16f0..2e065bd 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -163,4 +163,12 @@ contextBridge.exposeInMainWorld("electronAPI", { ipcRenderer.on("request-save-before-close", listener); return () => ipcRenderer.removeListener("request-save-before-close", listener); }, + onRequestCloseConfirm: (callback: () => void) => { + const listener = () => callback(); + ipcRenderer.on("request-close-confirm", listener); + return () => ipcRenderer.removeListener("request-close-confirm", listener); + }, + sendCloseConfirmResponse: (choice: "save" | "discard" | "cancel") => { + ipcRenderer.send("close-confirm-response", choice); + }, }); diff --git a/src/components/video-editor/UnsavedChangesDialog.tsx b/src/components/video-editor/UnsavedChangesDialog.tsx new file mode 100644 index 0000000..9b8ee03 --- /dev/null +++ b/src/components/video-editor/UnsavedChangesDialog.tsx @@ -0,0 +1,78 @@ +import { Save, Trash2, X } from "lucide-react"; +import { useScopedT } from "@/contexts/I18nContext"; + +interface UnsavedChangesDialogProps { + isOpen: boolean; + onSaveAndClose: () => void; + onDiscardAndClose: () => void; + onCancel: () => void; +} + +export function UnsavedChangesDialog({ + isOpen, + onSaveAndClose, + onDiscardAndClose, + onCancel, +}: UnsavedChangesDialogProps) { + const td = useScopedT("dialogs"); + const tc = useScopedT("common"); + + if (!isOpen) return null; + + return ( + <> +
+
+
+ OpenScreen +

+ {td("unsavedChanges.title")} +

+ +
+ +

{td("unsavedChanges.message")}

+

{td("unsavedChanges.detail")}

+ +
+ + + +
+
+ + ); +} diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 7adc558..14c695a 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -74,6 +74,7 @@ import { type ZoomFocusMode, type ZoomRegion, } from "./types"; +import { UnsavedChangesDialog } from "./UnsavedChangesDialog"; import VideoPlayback, { VideoPlaybackRef } from "./VideoPlayback"; export default function VideoEditor() { @@ -144,6 +145,7 @@ export default function VideoEditor() { format: string; } | null>(null); const [isFullscreen, setIsFullscreen] = useState(false); + const [showCloseConfirmDialog, setShowCloseConfirmDialog] = useState(false); const playerContainerRef = useRef(null); const videoPlaybackRef = useRef(null); @@ -524,6 +526,28 @@ export default function VideoEditor() { return () => cleanup(); }, [saveProject]); + useEffect(() => { + const cleanup = window.electronAPI.onRequestCloseConfirm(() => { + setShowCloseConfirmDialog(true); + }); + return () => cleanup(); + }, []); + + const handleCloseConfirmSave = useCallback(() => { + setShowCloseConfirmDialog(false); + window.electronAPI.sendCloseConfirmResponse("save"); + }, []); + + const handleCloseConfirmDiscard = useCallback(() => { + setShowCloseConfirmDialog(false); + window.electronAPI.sendCloseConfirmResponse("discard"); + }, []); + + const handleCloseConfirmCancel = useCallback(() => { + setShowCloseConfirmDialog(false); + window.electronAPI.sendCloseConfirmResponse("cancel"); + }, []); + const handleSaveProject = useCallback(async () => { await saveProject(false); }, [saveProject]); @@ -2066,6 +2090,13 @@ export default function VideoEditor() { exportedFilePath ? () => void handleShowExportedFile(exportedFilePath) : undefined } /> + +
); } From 36076aaf2a3efd77213d11474c38d81177e1e7be Mon Sep 17 00:00:00 2001 From: makaradam Date: Sat, 2 May 2026 13:08:52 +0200 Subject: [PATCH 10/59] fix: address code review feedback on custom close dialog --- electron/main.ts | 7 ++- .../video-editor/UnsavedChangesDialog.tsx | 63 +++++++++---------- 2 files changed, 36 insertions(+), 34 deletions(-) diff --git a/electron/main.ts b/electron/main.ts index 5540419..94f0a42 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -252,6 +252,7 @@ function updateTrayMenu(recording: boolean = false) { let editorHasUnsavedChanges = false; let isForceClosing = false; +let isCloseConfirmInFlight = false; ipcMain.on("set-has-unsaved-changes", (_, hasChanges: boolean) => { editorHasUnsavedChanges = hasChanges; @@ -283,9 +284,10 @@ function createEditorWindowWrapper() { editorHasUnsavedChanges = false; mainWindow.on("close", (event) => { - if (isForceClosing || !editorHasUnsavedChanges) return; + if (isForceClosing || !editorHasUnsavedChanges || isCloseConfirmInFlight) return; event.preventDefault(); + isCloseConfirmInFlight = true; const windowToClose = mainWindow; if (!windowToClose || windowToClose.isDestroyed()) return; @@ -294,6 +296,7 @@ function createEditorWindowWrapper() { windowToClose.webContents.send("request-close-confirm"); ipcMain.once("close-confirm-response", (_, choice: "save" | "discard" | "cancel") => { + isCloseConfirmInFlight = false; if (!windowToClose || windowToClose.isDestroyed()) return; if (choice === "save") { @@ -306,7 +309,7 @@ function createEditorWindowWrapper() { } else if (choice === "discard") { forceCloseEditorWindow(windowToClose); } - // "cancel": do nothing, window stays open + // "cancel": flag reset, window stays open }); }); } diff --git a/src/components/video-editor/UnsavedChangesDialog.tsx b/src/components/video-editor/UnsavedChangesDialog.tsx index 9b8ee03..a0623ba 100644 --- a/src/components/video-editor/UnsavedChangesDialog.tsx +++ b/src/components/video-editor/UnsavedChangesDialog.tsx @@ -1,4 +1,11 @@ -import { Save, Trash2, X } from "lucide-react"; +import { Save, Trash2 } from "lucide-react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; import { useScopedT } from "@/contexts/I18nContext"; interface UnsavedChangesDialogProps { @@ -17,41 +24,33 @@ export function UnsavedChangesDialog({ const td = useScopedT("dialogs"); const tc = useScopedT("common"); - if (!isOpen) return null; - return ( - <> -
-
-
- OpenScreen -

- {td("unsavedChanges.title")} -

- -
+ !open && onCancel()}> + + +
+ + + {td("unsavedChanges.title")} + +
+

{td("unsavedChanges.message")}

-

{td("unsavedChanges.detail")}

+ + {td("unsavedChanges.detail")} +
-
- + + ); } From b2cc7226135117165e0b0fc539a913b5e4246d54 Mon Sep 17 00:00:00 2001 From: makaradam Date: Sat, 2 May 2026 13:43:20 +0200 Subject: [PATCH 11/59] fix: use getAssetPath for logo so it resolves correctly in packaged app --- src/components/video-editor/UnsavedChangesDialog.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/video-editor/UnsavedChangesDialog.tsx b/src/components/video-editor/UnsavedChangesDialog.tsx index a0623ba..f3f88dc 100644 --- a/src/components/video-editor/UnsavedChangesDialog.tsx +++ b/src/components/video-editor/UnsavedChangesDialog.tsx @@ -7,6 +7,7 @@ import { DialogTitle, } from "@/components/ui/dialog"; import { useScopedT } from "@/contexts/I18nContext"; +import getAssetPath from "@/lib/assetPath"; interface UnsavedChangesDialogProps { isOpen: boolean; @@ -30,7 +31,7 @@ export function UnsavedChangesDialog({
Date: Sat, 2 May 2026 14:33:14 +0200 Subject: [PATCH 12/59] fix: use relative path for logo so it resolves in packaged app ./openscreen.png resolves correctly both in dev (Vite serves public/) and in production (loadFile sets base to dist/, where public assets land inside the asar). getAssetPath points to extraResources, which is the wrong location for bundled dist assets. --- src/components/video-editor/UnsavedChangesDialog.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/components/video-editor/UnsavedChangesDialog.tsx b/src/components/video-editor/UnsavedChangesDialog.tsx index f3f88dc..902b142 100644 --- a/src/components/video-editor/UnsavedChangesDialog.tsx +++ b/src/components/video-editor/UnsavedChangesDialog.tsx @@ -7,7 +7,6 @@ import { DialogTitle, } from "@/components/ui/dialog"; import { useScopedT } from "@/contexts/I18nContext"; -import getAssetPath from "@/lib/assetPath"; interface UnsavedChangesDialogProps { isOpen: boolean; @@ -31,7 +30,7 @@ export function UnsavedChangesDialog({
Date: Sat, 2 May 2026 14:36:59 +0200 Subject: [PATCH 13/59] fix: scope IPC close-confirm responses to the originating window Both ipcMain.once handlers now check event.sender.id against windowToClose.webContents.id and ignore messages from any other renderer, preventing cross-window response mix-ups if multiple editor windows are ever open simultaneously. --- electron/main.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/electron/main.ts b/electron/main.ts index 94f0a42..3e0b232 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -295,14 +295,16 @@ function createEditorWindowWrapper() { // Ask renderer to show the custom in-app dialog windowToClose.webContents.send("request-close-confirm"); - ipcMain.once("close-confirm-response", (_, choice: "save" | "discard" | "cancel") => { + ipcMain.once("close-confirm-response", (event, choice: "save" | "discard" | "cancel") => { + if (event.sender.id !== windowToClose?.webContents.id) return; isCloseConfirmInFlight = false; if (!windowToClose || windowToClose.isDestroyed()) return; if (choice === "save") { // Tell renderer to save the project, then close when done windowToClose.webContents.send("request-save-before-close"); - ipcMain.once("save-before-close-done", (_, shouldClose: boolean) => { + ipcMain.once("save-before-close-done", (event, shouldClose: boolean) => { + if (event.sender.id !== windowToClose?.webContents.id) return; if (!shouldClose) return; forceCloseEditorWindow(windowToClose); }); From e4eeff0ea34ed2bcf14396e52f21802f8744a34b Mon Sep 17 00:00:00 2001 From: hiroppelx <66677513+hiroppelx@users.noreply.github.com> Date: Sun, 3 May 2026 11:03:20 +0900 Subject: [PATCH 14/59] =?UTF-8?q?=E6=97=A5=E6=9C=AC=E8=AA=9E=E8=A8=B3?= =?UTF-8?q?=E3=82=92=E6=94=B9=E5=96=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/i18n/locales/ja-JP/common.json | 4 +-- src/i18n/locales/ja-JP/dialogs.json | 24 ++++++++-------- src/i18n/locales/ja-JP/editor.json | 16 +++++------ src/i18n/locales/ja-JP/launch.json | 6 ++-- src/i18n/locales/ja-JP/settings.json | 43 ++++++++++++---------------- src/i18n/locales/ja-JP/timeline.json | 18 ++++++------ 6 files changed, 52 insertions(+), 59 deletions(-) diff --git a/src/i18n/locales/ja-JP/common.json b/src/i18n/locales/ja-JP/common.json index ee2205a..ee804f0 100644 --- a/src/i18n/locales/ja-JP/common.json +++ b/src/i18n/locales/ja-JP/common.json @@ -7,7 +7,7 @@ "share": "共有", "done": "完了", "open": "開く", - "upload": "アップロード", + "upload": "読み込む", "export": "エクスポート", "showInFolder": "フォルダに表示", "file": "ファイル", @@ -15,7 +15,7 @@ "view": "表示", "window": "ウィンドウ", "quit": "終了", - "stopRecording": "録画停止" + "stopRecording": "録画を停止" }, "playback": { "play": "再生", diff --git a/src/i18n/locales/ja-JP/dialogs.json b/src/i18n/locales/ja-JP/dialogs.json index 3c3fce5..a59cde7 100644 --- a/src/i18n/locales/ja-JP/dialogs.json +++ b/src/i18n/locales/ja-JP/dialogs.json @@ -1,22 +1,22 @@ { "export": { "complete": "エクスポート完了", - "yourFormatReady": "あなたの{{format}}が準備できました", + "yourFormatReady": "{{format}}の準備ができました", "showInFolder": "フォルダで表示", - "finalizingVideo": "ビデオのエクスポートを最終処理中...", - "compilingGifProgress": "GIFをコンパイル中... {{progress}}%", - "compilingGifWait": "GIFをコンパイル中... しばらくお待ちください", + "finalizingVideo": "動画のエクスポートを仕上げています...", + "compilingGifProgress": "GIFを生成中... {{progress}}%", + "compilingGifWait": "GIFを生成中... しばらくお待ちください", "takeMoment": "少々お待ちください...", "failed": "エクスポートに失敗しました", "tryAgain": "もう一度お試しください", - "finalizingVideoTitle": "ビデオの最終処理", - "compilingGif": "GIFをコンパイル中", + "finalizingVideoTitle": "動画の仕上げ", + "compilingGif": "GIFを生成中", "exportingFormat": "{{format}}をエクスポート中", - "compiling": "コンパイル中", + "compiling": "生成中", "renderingFrames": "フレームをレンダリング中", "processing": "処理中...", "finalizing": "最終処理中...", - "compilingStatus": "コンパイル中...", + "compilingStatus": "生成中...", "status": "ステータス", "format": "フォーマット", "frames": "フレーム", @@ -58,13 +58,13 @@ }, "fileDialogs": { "saveGif": "エクスポートしたGIFを保存", - "saveVideo": "エクスポートしたビデオを保存", - "selectVideo": "ビデオファイルを選択", + "saveVideo": "エクスポートした動画を保存", + "selectVideo": "動画ファイルを選択", "saveProject": "OpenScreen プロジェクトを保存", "openProject": "OpenScreen プロジェクトを開く", "gifImage": "GIF 画像", - "mp4Video": "MP4 ビデオ", - "videoFiles": "ビデオファイル", + "mp4Video": "MP4 動画", + "videoFiles": "動画ファイル", "openscreenProject": "OpenScreen プロジェクト", "allFiles": "すべてのファイル" } diff --git a/src/i18n/locales/ja-JP/editor.json b/src/i18n/locales/ja-JP/editor.json index 401dbc7..051335f 100644 --- a/src/i18n/locales/ja-JP/editor.json +++ b/src/i18n/locales/ja-JP/editor.json @@ -5,18 +5,18 @@ "cancel": "キャンセル", "confirm": "確認" }, - "loadingVideo": "ビデオを読み込み中...", + "loadingVideo": "動画を読み込み中...", "errors": { - "noVideoLoaded": "ビデオが読み込まれていません", - "videoNotReady": "ビデオが準備できていません", - "unableToDetermineSourcePath": "ソースビデオのパスを特定できません", + "noVideoLoaded": "動画が読み込まれていません", + "videoNotReady": "動画の準備ができていません", + "unableToDetermineSourcePath": "元動画のパスを特定できません", "failedToSaveGif": "GIFの保存に失敗しました", "gifExportFailed": "GIFのエクスポートに失敗しました", - "failedToSaveVideo": "ビデオの保存に失敗しました", + "failedToSaveVideo": "動画の保存に失敗しました", "exportFailed": "エクスポートに失敗しました", "exportFailedWithError": "エクスポートに失敗しました: {{error}}", "failedToSaveExport": "エクスポートの保存に失敗しました", - "failedToSaveExportedVideo": "エクスポートしたビデオの保存に失敗しました", + "failedToSaveExportedVideo": "エクスポートした動画の保存に失敗しました", "failedToRevealInFolder": "フォルダの表示に失敗しました: {{error}}", "exportBackgroundLoadFailed": "エクスポートに失敗しました: 背景画像を読み込めませんでした ({{url}})" }, @@ -35,8 +35,8 @@ "recording": { "failedCameraAccess": "カメラのアクセス要求に失敗しました。", "cameraBlocked": "カメラのアクセスがブロックされています。システム設定で有効にして、ウェブカメラを使用してください。", - "systemAudioUnavailable": "システムオーディオが利用できません。システムオーディオなしで録画します。", - "microphoneDenied": "マイクのアクセスが拒否されました。オーディオなしで録画を続行します。", + "systemAudioUnavailable": "システム音声を利用できません。システム音声なしで録画します。", + "microphoneDenied": "マイクへのアクセスが拒否されました。音声なしで録画を続行します。", "cameraDenied": "カメラのアクセスが拒否されました。ウェブカメラなしで録画を続行します。", "permissionDenied": "録画の権限が拒否されました。画面録画を許可してください。", "cameraDisconnected": "ウェブカメラが切断されました。", diff --git a/src/i18n/locales/ja-JP/launch.json b/src/i18n/locales/ja-JP/launch.json index 4504b00..51e3833 100644 --- a/src/i18n/locales/ja-JP/launch.json +++ b/src/i18n/locales/ja-JP/launch.json @@ -6,12 +6,12 @@ "cancelRecording": "録画をキャンセル", "pauseRecording": "録画を一時停止", "resumeRecording": "録画を再開", - "openVideoFile": "ビデオファイルを開く", + "openVideoFile": "動画ファイルを開く", "openProject": "プロジェクトを開く" }, "audio": { - "enableSystemAudio": "システムオーディオを有効にする", - "disableSystemAudio": "システムオーディオを無効にする", + "enableSystemAudio": "システム音声を有効にする", + "disableSystemAudio": "システム音声を無効にする", "enableMicrophone": "マイクを有効にする", "disableMicrophone": "マイクを無効にする", "defaultMicrophone": "デフォルトのマイク" diff --git a/src/i18n/locales/ja-JP/settings.json b/src/i18n/locales/ja-JP/settings.json index 129217c..800d078 100644 --- a/src/i18n/locales/ja-JP/settings.json +++ b/src/i18n/locales/ja-JP/settings.json @@ -7,20 +7,13 @@ "title": "フォーカスモード", "manual": "手動", "auto": "自動", - "autoDescription": "カメラが録画中のカーソル位置に追従します" - }, - "speed": { - "title": "ズーム速度", - "instant": "即時", - "fast": "高速", - "smooth": "滑らか", - "lazy": "遅延" + "autoDescription": "表示範囲が録画中のカーソル位置に追従します" } }, "speed": { "playbackSpeed": "再生速度", - "selectRegion": "速度範囲を選択して調整", - "deleteRegion": "速度範囲を削除", + "selectRegion": "再生速度の範囲を選択して調整", + "deleteRegion": "再生速度の範囲を削除", "customPlaybackSpeed": "カスタム再生速度", "maxSpeedError": "速度は16×を超えることはできません" }, @@ -31,14 +24,14 @@ "title": "レイアウト", "preset": "プリセット", "selectPreset": "プリセットを選択", - "pictureInPicture": "ピクチャーインピクチャー", - "verticalStack": "縦積み", + "pictureInPicture": "ピクチャーインピクチャ", + "verticalStack": "縦並び", "dualFrame": "デュアルフレーム", "webcamShape": "カメラの形状", "webcamSize": "カメラのサイズ" }, "effects": { - "title": "ビデオ効果", + "title": "動画効果", "blurBg": "背景をぼかす", "motionBlur": "モーションブラー", "off": "オフ", @@ -51,14 +44,14 @@ "image": "画像", "color": "色", "gradient": "グラデーション", - "uploadCustom": "カスタムをアップロード", + "uploadCustom": "カスタム画像を読み込む", "gradientLabel": "グラデーション {{index}}", "colorWheel": "カラーホイール", "colorPalette": "カラーパレット" }, "crop": { "title": "クロップ", - "cropVideo": "ビデオをクロップ", + "cropVideo": "動画をクロップ", "dragInstruction": "各辺をドラッグしてクロップ範囲を調整", "ratio": "比率", "free": "自由", @@ -69,8 +62,8 @@ "exportFormat": { "mp4": "MP4", "gif": "GIF", - "mp4Video": "MP4 ビデオ", - "mp4Description": "高品質のビデオファイル", + "mp4Video": "MP4 動画", + "mp4Description": "高品質の動画ファイル", "gifAnimation": "GIF アニメーション", "gifDescription": "共有用のアニメーション画像" }, @@ -90,7 +83,7 @@ "load": "プロジェクトを読み込む" }, "export": { - "videoButton": "ビデオをエクスポート", + "videoButton": "動画をエクスポート", "gifButton": "GIF をエクスポート", "chooseSaveLocation": "保存場所を選択" }, @@ -100,9 +93,9 @@ }, "imageUpload": { "invalidFileType": "無効なファイル形式", - "jpgOnly": "JPG または JPEG 画像ファイルをアップロードしてください。", - "uploadSuccess": "カスタム画像が正常にアップロードされました!", - "failedToUpload": "画像のアップロードに失敗しました", + "jpgOnly": "JPG または JPEG 画像ファイルを選択してください。", + "uploadSuccess": "カスタム画像を読み込みました。", + "failedToUpload": "画像の読み込みに失敗しました", "errorReading": "ファイルの読み取り中にエラーが発生しました。" }, "annotation": { @@ -125,7 +118,7 @@ "colorWheel": "カラーホイール", "colorPalette": "カラーパレット", "clearBackground": "背景をクリア", - "uploadImage": "画像をアップロード", + "uploadImage": "画像を読み込む", "supportedFormats": "サポートされている形式: JPG, PNG, GIF, WebP", "arrowDirection": "矢印の方向", "strokeWidth": "線の太さ: {{width}}px", @@ -148,9 +141,9 @@ "tipTabCycle": "Tabキーを使用して重なっている項目を順に切り替えます。", "tipShiftTabCycle": "Shift+Tabキーを使用して逆順に切り替えます。", "invalidImageType": "無効なファイル形式", - "imageFormatsOnly": "JPG、PNG、GIF、またはWebP画像ファイルをアップロードしてください。", - "imageUploadSuccess": "画像が正常にアップロードされました!", - "failedImageUpload": "画像のアップロードに失敗しました" + "imageFormatsOnly": "JPG、PNG、GIF、またはWebP画像ファイルを選択してください。", + "imageUploadSuccess": "画像を読み込みました。", + "failedImageUpload": "画像の読み込みに失敗しました" }, "fontStyles": { "classic": "クラシック", diff --git a/src/i18n/locales/ja-JP/timeline.json b/src/i18n/locales/ja-JP/timeline.json index e0507f6..ec9472a 100644 --- a/src/i18n/locales/ja-JP/timeline.json +++ b/src/i18n/locales/ja-JP/timeline.json @@ -5,23 +5,23 @@ "addTrim": "トリムを追加 (T)", "addAnnotation": "注釈を追加 (A)", "addBlur": "ぼかしを追加 (B)", - "addSpeed": "速度を追加 (S)" + "addSpeed": "再生速度を追加 (S)" }, "hints": { "pressZoom": "Zキーを押してズームを追加", "pressTrim": "Tキーを押してトリムを追加", "pressAnnotation": "Aキーを押して注釈を追加", "pressBlur": "Bキーを押してぼかしを追加", - "pressSpeed": "Sキーを押して速度を追加" + "pressSpeed": "Sキーを押して再生速度を追加" }, "labels": { "pan": "移動", "zoom": "ズーム", "trim": "トリム", - "speed": "速度", + "speed": "再生速度", "zoomItem": "ズーム {{index}}", "trimItem": "トリム {{index}}", - "speedItem": "速度 {{index}}", + "speedItem": "再生速度 {{index}}", "annotationItem": "注釈", "blurItem": "ぼかし {{index}}", "imageItem": "画像", @@ -36,17 +36,17 @@ "zoomExistsAtLocation": "この場所にはすでにズームが存在するか、十分なスペースがありません。", "zoomSuggestionUnavailable": "ズームの自動提案機能が利用できません", "noCursorTelemetry": "カーソルの動きが記録されていません", - "noCursorTelemetryDescription": "まず画面収録を行い、カーソルに基づく提案を生成してください。", + "noCursorTelemetryDescription": "まず画面録画を行い、カーソルに基づく提案を生成してください。", "noUsableTelemetry": "使用可能なカーソルの動きデータがありません", "noUsableTelemetryDescription": "録画には十分なカーソルの動きデータが含まれていません。", "noDwellMoments": "カーソルが静止したポイントが見つかりません", "noDwellMomentsDescription": "強調したい操作の際に、カーソルを一時停止させて録画してみてください。", "noAutoZoomSlots": "自動ズームを適用できる箇所がありません", "noAutoZoomSlotsDescription": "検出された滞留ポイントが既存のズーム領域と重なっています。", - "cannotPlaceTrim": "ここに切り取りを配置できません", - "trimExistsAtLocation": "この場所にはすでに切り取りが存在するか、十分なスペースがありません。", - "cannotPlaceSpeed": "ここに速度を配置できません", - "speedExistsAtLocation": "この場所にはすでに速度が存在するか、十分なスペースがありません。" + "cannotPlaceTrim": "ここにトリムを配置できません", + "trimExistsAtLocation": "この場所にはすでにトリムが存在するか、十分なスペースがありません。", + "cannotPlaceSpeed": "ここに再生速度を配置できません", + "speedExistsAtLocation": "この場所にはすでに再生速度の範囲が存在するか、十分なスペースがありません。" }, "success": { "addedZoomSuggestions": "カーソルに基づくズーム提案を {{count}} 件追加しました", From 8d79a14e3bba0a195df71ec57cd5c6540a06c04c Mon Sep 17 00:00:00 2001 From: Siddharth Date: Sat, 2 May 2026 23:03:14 -0700 Subject: [PATCH 15/59] cursor highlighting and clicks --- electron-builder.json5 | 5 + electron/electron-env.d.ts | 6 + electron/ipc/handlers.ts | 145 ++- electron/preload.ts | 3 + package-lock.json | 1054 ++--------------- package.json | 6 +- src/components/video-editor/SettingsPanel.tsx | 186 +++ src/components/video-editor/VideoEditor.tsx | 24 + src/components/video-editor/VideoPlayback.tsx | 74 +- .../video-editor/projectPersistence.ts | 47 + .../videoPlayback/cursorHighlight.ts | 125 ++ src/hooks/useEditorHistory.ts | 6 + src/lib/exporter/frameRenderer.ts | 48 + src/lib/exporter/gifExporter.ts | 4 + src/lib/exporter/videoExporter.ts | 4 + 15 files changed, 769 insertions(+), 968 deletions(-) create mode 100644 src/components/video-editor/videoPlayback/cursorHighlight.ts diff --git a/electron-builder.json5 b/electron-builder.json5 index ca053ef..1770238 100644 --- a/electron-builder.json5 +++ b/electron-builder.json5 @@ -3,6 +3,11 @@ "$schema": "https://raw.githubusercontent.com/electron-userland/electron-builder/master/packages/app-builder-lib/scheme.json", "appId": "com.siddharthvaddem.openscreen", "asar": true, + // .node binaries can't be dlopen'd from inside an asar — must live unpacked. + "asarUnpack": [ + "node_modules/uiohook-napi/**/*", + "**/*.node" + ], "productName": "Openscreen", "npmRebuild": true, "buildDependenciesFromSource": true, diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index 85d8294..d9ebab2 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -37,6 +37,11 @@ interface Window { status: string; error?: string; }>; + requestAccessibilityAccess: () => Promise<{ + success: boolean; + granted: boolean; + error?: string; + }>; assetBaseUrl: string; storeRecordedVideo: ( videoData: ArrayBuffer, @@ -68,6 +73,7 @@ interface Window { getCursorTelemetry: (videoPath?: string) => Promise<{ success: boolean; samples: CursorTelemetryPoint[]; + clicks: number[]; message?: string; error?: string; }>; diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index e067f59..30fbf23 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -1,6 +1,10 @@ import fs from "node:fs/promises"; +import { createRequire } from "node:module"; import path from "node:path"; import { fileURLToPath } from "node:url"; + +const nodeRequire = createRequire(import.meta.url); + import { app, BrowserWindow, @@ -280,19 +284,24 @@ async function storeRecordedSessionFiles(payload: StoreRecordedSessionInput) { const telemetryPath = `${screenVideoPath}.cursor.json`; const pendingBatch = cursorTelemetryBuffer.takeNextBatch(); - if (pendingBatch && pendingBatch.samples.length > 0) { + const pendingClicks = takeCursorClickTimestamps(); + if ((pendingBatch && pendingBatch.samples.length > 0) || pendingClicks.length > 0) { try { await fs.writeFile( telemetryPath, JSON.stringify( - { version: CURSOR_TELEMETRY_VERSION, samples: pendingBatch.samples }, + { + version: CURSOR_TELEMETRY_VERSION, + samples: pendingBatch?.samples ?? [], + clicks: pendingClicks, + }, null, 2, ), "utf-8", ); } catch (err) { - cursorTelemetryBuffer.prependBatch(pendingBatch); + if (pendingBatch) cursorTelemetryBuffer.prependBatch(pendingBatch); throw err; } } @@ -321,15 +330,114 @@ const cursorTelemetryBuffer = createCursorTelemetryBuffer({ maxActiveSamples: MAX_CURSOR_SAMPLES, }); +// Mouse click timestamps (macOS only — uiohook-napi behind Accessibility). +const MAX_CURSOR_CLICKS = 60 * 60 * 60; // ~1 click/sec for an hour +let cursorClickTimestampsMs: number[] = []; +let uioHookInstance: { + start: () => void; + stop: () => void; + on: (...a: unknown[]) => void; + off?: (...a: unknown[]) => void; + removeListener?: (...a: unknown[]) => void; +} | null = null; +let uioHookMouseDownHandler: ((event: { time?: number }) => void) | null = null; +let uioHookFailureLogged = false; + function clamp(value: number, min: number, max: number) { return Math.min(max, Math.max(min, value)); } +function loadUioHookForClicks(): typeof uioHookInstance { + try { + // Dynamic require + try/catch so a broken native binary doesn't crash startup. + const mod = nodeRequire("uiohook-napi"); + const candidate = mod.uIOhook ?? mod.default?.uIOhook ?? mod.uiohook ?? mod.default; + if (candidate && typeof candidate.start === "function" && typeof candidate.on === "function") { + return candidate; + } + return null; + } catch (error) { + if (!uioHookFailureLogged) { + uioHookFailureLogged = true; + console.warn("[clickCapture] uiohook-napi unavailable:", error); + } + return null; + } +} + +function startClickCapture() { + if (process.platform !== "darwin") return; + if (uioHookInstance) return; + + // Passive check — the prompt fires from the renderer when the user toggles + // "Only on clicks" so it doesn't stack with the screen-recording prompt. + try { + if (!systemPreferences.isTrustedAccessibilityClient(false)) { + if (!uioHookFailureLogged) { + uioHookFailureLogged = true; + console.warn( + "[clickCapture] Accessibility permission not granted — click capture disabled.", + ); + } + return; + } + } catch { + // fall through; uiohook will fail defensively below + } + + const hook = loadUioHookForClicks(); + if (!hook) return; + + uioHookMouseDownHandler = (event) => { + const elapsed = Math.max(0, Date.now() - cursorCaptureStartTimeMs); + void event; + if (cursorClickTimestampsMs.length >= MAX_CURSOR_CLICKS) return; + cursorClickTimestampsMs.push(elapsed); + }; + + try { + hook.on("mousedown", uioHookMouseDownHandler); + hook.start(); + uioHookInstance = hook; + } catch (error) { + if (!uioHookFailureLogged) { + uioHookFailureLogged = true; + console.warn("[clickCapture] failed to start uiohook:", error); + } + uioHookMouseDownHandler = null; + } +} + +function stopClickCapture() { + if (!uioHookInstance) return; + try { + if (uioHookMouseDownHandler) { + if (typeof uioHookInstance.off === "function") { + uioHookInstance.off("mousedown", uioHookMouseDownHandler); + } else if (typeof uioHookInstance.removeListener === "function") { + uioHookInstance.removeListener("mousedown", uioHookMouseDownHandler); + } + } + uioHookInstance.stop(); + } catch (error) { + console.warn("[clickCapture] failed to stop uiohook:", error); + } + uioHookInstance = null; + uioHookMouseDownHandler = null; +} + +function takeCursorClickTimestamps(): number[] { + const out = cursorClickTimestampsMs; + cursorClickTimestampsMs = []; + return out; +} + function stopCursorCapture() { if (cursorCaptureInterval) { clearInterval(cursorCaptureInterval); cursorCaptureInterval = null; } + stopClickCapture(); } function sampleCursorPoint() { @@ -594,6 +702,22 @@ export function registerIpcHandlers( } }); + // macOS Accessibility prompt for global click capture. First call shows the + // system dialog; the user has to toggle the app in System Settings (no + // programmatic grant exists for Accessibility). + ipcMain.handle("request-accessibility-access", () => { + if (process.platform !== "darwin") { + return { success: true, granted: true }; + } + try { + const granted = systemPreferences.isTrustedAccessibilityClient(true); + return { success: true, granted }; + } catch (error) { + console.error("Failed to request accessibility access:", error); + return { success: false, granted: false, error: String(error) }; + } + }); + ipcMain.handle("open-source-selector", () => { const sourceSelectorWin = getSourceSelectorWindow(); if (sourceSelectorWin) { @@ -723,6 +847,8 @@ export function registerIpcHandlers( const id = typeof recordingId === "number" ? recordingId : Date.now(); cursorTelemetryBuffer.startSession(id); cursorCaptureStartTimeMs = Date.now(); + cursorClickTimestampsMs = []; + startClickCapture(); sampleCursorPoint(); cursorCaptureInterval = setInterval(sampleCursorPoint, CURSOR_SAMPLE_INTERVAL_MS); } else { @@ -787,11 +913,19 @@ export function registerIpcHandlers( }) .sort((a: CursorTelemetryPoint, b: CursorTelemetryPoint) => a.timeMs - b.timeMs); - return { success: true, samples }; + const rawClicks = Array.isArray(parsed?.clicks) ? parsed.clicks : []; + const clicks: number[] = rawClicks + .map((value: unknown) => + typeof value === "number" && Number.isFinite(value) ? Math.max(0, value) : null, + ) + .filter((v: number | null): v is number => v !== null) + .sort((a: number, b: number) => a - b); + + return { success: true, samples, clicks }; } catch (error) { const nodeError = error as NodeJS.ErrnoException; if (nodeError.code === "ENOENT") { - return { success: true, samples: [] }; + return { success: true, samples: [], clicks: [] }; } console.error("Failed to load cursor telemetry:", error); return { @@ -799,6 +933,7 @@ export function registerIpcHandlers( message: "Failed to load cursor telemetry", error: String(error), samples: [], + clicks: [], }; } }); diff --git a/electron/preload.ts b/electron/preload.ts index 46e16f0..6c705d7 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -40,6 +40,9 @@ contextBridge.exposeInMainWorld("electronAPI", { requestCameraAccess: () => { return ipcRenderer.invoke("request-camera-access"); }, + requestAccessibilityAccess: () => { + return ipcRenderer.invoke("request-accessibility-access"); + }, storeRecordedVideo: (videoData: ArrayBuffer, fileName: string) => { return ipcRenderer.invoke("store-recorded-video", videoData, fileName); diff --git a/package-lock.json b/package-lock.json index a449101..e823ad1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -47,11 +47,13 @@ "sonner": "^2.0.7", "tailwind-merge": "^3.5.0", "tailwindcss-animate": "^1.0.7", + "uiohook-napi": "^1.5.5", "uuid": "^13.0.0", "web-demuxer": "^4.0.0" }, "devDependencies": { "@biomejs/biome": "^2.4.12", + "@electron/rebuild": "^4.0.4", "@playwright/test": "^1.59.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", @@ -1078,25 +1080,18 @@ } }, "node_modules/@electron/rebuild": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/@electron/rebuild/-/rebuild-4.0.3.tgz", - "integrity": "sha512-u9vpTHRMkOYCs/1FLiSVAFZ7FbjsXK+bQuzviJZa+lG7BHZl1nz52/IcGvwa3sk80/fc3llutBkbCq10Vh8WQA==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@electron/rebuild/-/rebuild-4.0.4.tgz", + "integrity": "sha512-Rzc39XPdk/+/wBG8MfwAHohXflep0ITUfulb6Rgz3R0NeSB1noE+E9/M/cb8ftCAiyDD9PPhLuuWgE1GaInbKg==", "dev": true, "license": "MIT", "dependencies": { "@malept/cross-spawn-promise": "^2.0.0", "debug": "^4.1.1", - "detect-libc": "^2.0.1", - "got": "^11.7.0", - "graceful-fs": "^4.2.11", "node-abi": "^4.2.0", "node-api-version": "^0.2.1", - "node-gyp": "^11.2.0", - "ora": "^5.1.0", - "read-binary-file-arch": "^1.0.6", - "semver": "^7.3.5", - "tar": "^7.5.6", - "yargs": "^17.0.1" + "node-gyp": "^12.2.0", + "read-binary-file-arch": "^1.0.6" }, "bin": { "electron-rebuild": "lib/cli.js" @@ -1105,19 +1100,6 @@ "node": ">=22.12.0" } }, - "node_modules/@electron/rebuild/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/@electron/universal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@electron/universal/-/universal-2.0.3.tgz", @@ -1791,80 +1773,6 @@ "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", "license": "MIT" }, - "node_modules/@isaacs/cliui": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", - "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^5.1.2", - "string-width-cjs": "npm:string-width@^4.2.0", - "strip-ansi": "^7.0.1", - "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", - "wrap-ansi": "^8.1.0", - "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@isaacs/cliui/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/@isaacs/cliui/node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@isaacs/cliui/node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", - "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, "node_modules/@isaacs/fs-minipass": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", @@ -2048,56 +1956,6 @@ "node": ">= 8" } }, - "node_modules/@npmcli/agent": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@npmcli/agent/-/agent-3.0.0.tgz", - "integrity": "sha512-S79NdEgDQd/NGCay6TCoVzXSj74skRZIKJcpJjC5lOq34SZzyI6MqtiiWoiVWoVrTcGjNeC4ipbh1VIHlpfF5Q==", - "dev": true, - "license": "ISC", - "dependencies": { - "agent-base": "^7.1.0", - "http-proxy-agent": "^7.0.0", - "https-proxy-agent": "^7.0.1", - "lru-cache": "^10.0.1", - "socks-proxy-agent": "^8.0.3" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/@npmcli/agent/node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/@npmcli/fs": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-4.0.0.tgz", - "integrity": "sha512-/xGlezI6xfGO9NwuJlnwz/K14qD1kCSAGtacBHnGzeAIuJGazcp45KP5NuyARXoKb7cwulAGWVsbeSxdG/cb0Q==", - "dev": true, - "license": "ISC", - "dependencies": { - "semver": "^7.3.5" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/@npmcli/fs/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/@pixi/color": { "version": "7.4.3", "resolved": "https://registry.npmjs.org/@pixi/color/-/color-7.4.3.tgz", @@ -2224,17 +2082,6 @@ "url": "^0.11.0" } }, - "node_modules/@pkgjs/parseargs": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", - "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">=14" - } - }, "node_modules/@playwright/test": { "version": "1.59.1", "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz", @@ -4464,13 +4311,13 @@ "license": "MIT" }, "node_modules/abbrev": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-3.0.1.tgz", - "integrity": "sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-4.0.0.tgz", + "integrity": "sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==", "dev": true, "license": "ISC", "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/acorn": { @@ -4973,18 +4820,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/bl": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", - "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "buffer": "^5.5.0", - "inherits": "^2.0.4", - "readable-stream": "^3.4.0" - } - }, "node_modules/boolean": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz", @@ -5073,6 +4908,7 @@ } ], "license": "MIT", + "optional": true, "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" @@ -5172,92 +5008,6 @@ "node": ">= 10.0.0" } }, - "node_modules/cacache": { - "version": "19.0.1", - "resolved": "https://registry.npmjs.org/cacache/-/cacache-19.0.1.tgz", - "integrity": "sha512-hdsUxulXCi5STId78vRVYEtDAjq99ICAUktLTeTYsLoTE6Z8dS0c8pWNCxwdrk9YfJeobDZc2Y186hD/5ZQgFQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "@npmcli/fs": "^4.0.0", - "fs-minipass": "^3.0.0", - "glob": "^10.2.2", - "lru-cache": "^10.0.1", - "minipass": "^7.0.3", - "minipass-collect": "^2.0.1", - "minipass-flush": "^1.0.5", - "minipass-pipeline": "^1.2.4", - "p-map": "^7.0.2", - "ssri": "^12.0.0", - "tar": "^7.4.3", - "unique-filename": "^4.0.0" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/cacache/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/cacache/node_modules/brace-expansion": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", - "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/cacache/node_modules/glob": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", - "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "dev": true, - "license": "ISC", - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/cacache/node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/cacache/node_modules/minimatch": { - "version": "9.0.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", - "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.2" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/cacheable-lookup": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz", @@ -5471,19 +5221,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/cli-spinners": { - "version": "2.9.2", - "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", - "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/cli-truncate": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-2.1.0.tgz", @@ -5548,16 +5285,6 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/clone": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", - "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8" - } - }, "node_modules/clone-response": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.3.tgz", @@ -5828,19 +5555,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/defaults": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", - "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "clone": "^1.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/defer-to-connect": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", @@ -5909,16 +5623,6 @@ "node": ">=6" } }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=8" - } - }, "node_modules/detect-node": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", @@ -6138,13 +5842,6 @@ "license": "ISC", "peer": true }, - "node_modules/eastasianwidth": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "dev": true, - "license": "MIT" - }, "node_modules/ejs": { "version": "3.1.10", "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", @@ -6396,17 +6093,6 @@ "dev": true, "license": "MIT" }, - "node_modules/encoding": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", - "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "iconv-lite": "^0.6.2" - } - }, "node_modules/end-of-stream": { "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", @@ -6818,36 +6504,6 @@ "integrity": "sha512-IKlE+pNvL2R+kVL1kEhUYqRxVqeFnjiIvHWDMLFXNaqyUdFXQM2wte44EfMYJNHkW16X991t2Zg8apKkhv7OBA==", "license": "MIT" }, - "node_modules/foreground-child": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", - "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", - "dev": true, - "license": "ISC", - "dependencies": { - "cross-spawn": "^7.0.6", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/foreground-child/node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/form-data": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", @@ -6921,19 +6577,6 @@ "node": ">=6 <7 || >=8" } }, - "node_modules/fs-minipass": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-3.0.3.tgz", - "integrity": "sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw==", - "dev": true, - "license": "ISC", - "dependencies": { - "minipass": "^7.0.3" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -7466,17 +7109,8 @@ "url": "https://feross.org/support" } ], - "license": "BSD-3-Clause" - }, - "node_modules/imurmurhash": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8.19" - } + "license": "BSD-3-Clause", + "optional": true }, "node_modules/indent-string": { "version": "4.0.0", @@ -7507,16 +7141,6 @@ "dev": true, "license": "ISC" }, - "node_modules/ip-address": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", - "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 12" - } - }, "node_modules/is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", @@ -7575,16 +7199,6 @@ "node": ">=0.10.0" } }, - "node_modules/is-interactive": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", - "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -7601,19 +7215,6 @@ "dev": true, "license": "MIT" }, - "node_modules/is-unicode-supported": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", - "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/isbinaryfile": { "version": "5.0.7", "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-5.0.7.tgz", @@ -7643,22 +7244,6 @@ "integrity": "sha512-VaFW53yt8QO61k2WJui0dHf4SlL8lxBofUuUmwBo0ljPk0Drz2TiuDW4jo3wDcv41qy/SxrJ+VAzJ/qYqsmzRw==", "license": "MIT" }, - "node_modules/jackspeak": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", - "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" - } - }, "node_modules/jake": { "version": "10.9.4", "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz", @@ -8002,23 +7587,6 @@ "dev": true, "license": "MIT" }, - "node_modules/log-symbols": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", - "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", - "dev": true, - "license": "MIT", - "dependencies": { - "chalk": "^4.1.0", - "is-unicode-supported": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/log-update": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz", @@ -8147,29 +7715,6 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, - "node_modules/make-fetch-happen": { - "version": "14.0.3", - "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-14.0.3.tgz", - "integrity": "sha512-QMjGbFTP0blj97EeidG5hk/QhKQ3T4ICckQGLgz38QF7Vgbk6e6FTARN8KhKxyBbWn8R0HU+bnw8aSoFPD4qtQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "@npmcli/agent": "^3.0.0", - "cacache": "^19.0.1", - "http-cache-semantics": "^4.1.1", - "minipass": "^7.0.2", - "minipass-fetch": "^4.0.0", - "minipass-flush": "^1.0.5", - "minipass-pipeline": "^1.2.4", - "negotiator": "^1.0.0", - "proc-log": "^5.0.0", - "promise-retry": "^2.0.1", - "ssri": "^12.0.0" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, "node_modules/matcher": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", @@ -8287,16 +7832,6 @@ "node": ">= 0.6" } }, - "node_modules/mimic-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", - "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/mimic-function": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", @@ -8366,136 +7901,6 @@ "node": ">=16 || 14 >=14.17" } }, - "node_modules/minipass-collect": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-2.0.1.tgz", - "integrity": "sha512-D7V8PO9oaz7PWGLbCACuI1qEOsq7UKfLotx/C0Aet43fCUB/wfQ7DYeq2oR/svFJGYDHPr38SHATeaj/ZoKHKw==", - "dev": true, - "license": "ISC", - "dependencies": { - "minipass": "^7.0.3" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/minipass-fetch": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-4.0.1.tgz", - "integrity": "sha512-j7U11C5HXigVuutxebFadoYBbd7VSdZWggSe64NVdvWNBqGAiXPL2QVCehjmw7lY1oF9gOllYbORh+hiNgfPgQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "minipass": "^7.0.3", - "minipass-sized": "^1.0.3", - "minizlib": "^3.0.1" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - }, - "optionalDependencies": { - "encoding": "^0.1.13" - } - }, - "node_modules/minipass-flush": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.7.tgz", - "integrity": "sha512-TbqTz9cUwWyHS2Dy89P3ocAGUGxKjjLuR9z8w4WUTGAVgEj17/4nhgo2Du56i0Fm3Pm30g4iA8Lcqctc76jCzA==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/minipass-flush/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/minipass-flush/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true, - "license": "ISC" - }, - "node_modules/minipass-pipeline": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz", - "integrity": "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==", - "dev": true, - "license": "ISC", - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/minipass-pipeline/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/minipass-pipeline/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true, - "license": "ISC" - }, - "node_modules/minipass-sized": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/minipass-sized/-/minipass-sized-1.0.3.tgz", - "integrity": "sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g==", - "dev": true, - "license": "ISC", - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/minipass-sized/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/minipass-sized/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true, - "license": "ISC" - }, "node_modules/minizlib": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", @@ -8619,16 +8024,6 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, - "node_modules/negotiator": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", - "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, "node_modules/node-abi": { "version": "4.28.0", "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-4.28.0.tgz", @@ -8687,28 +8082,49 @@ } }, "node_modules/node-gyp": { - "version": "11.5.0", - "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-11.5.0.tgz", - "integrity": "sha512-ra7Kvlhxn5V9Slyus0ygMa2h+UqExPqUIkfk7Pc8QTLT956JLSy51uWFwHtIYy0vI8cB4BDhc/S03+880My/LQ==", + "version": "12.3.0", + "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-12.3.0.tgz", + "integrity": "sha512-QNcUWM+HgJplcPzBvFBZ9VXacyGZ4+VTOb80PwWR+TlVzoHbRKULNEzpRsnaoxG3Wzr7Qh7BYxGDU3CbKib2Yg==", "dev": true, "license": "MIT", "dependencies": { "env-paths": "^2.2.0", "exponential-backoff": "^3.1.1", "graceful-fs": "^4.2.6", - "make-fetch-happen": "^14.0.3", - "nopt": "^8.0.0", - "proc-log": "^5.0.0", + "nopt": "^9.0.0", + "proc-log": "^6.0.0", "semver": "^7.3.5", - "tar": "^7.4.3", + "tar": "^7.5.4", "tinyglobby": "^0.2.12", - "which": "^5.0.0" + "undici": "^6.25.0", + "which": "^6.0.0" }, "bin": { "node-gyp": "bin/node-gyp.js" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, + "node_modules/node-gyp/node_modules/isexe": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz", + "integrity": "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=20" } }, "node_modules/node-gyp/node_modules/semver": { @@ -8724,6 +8140,32 @@ "node": ">=10" } }, + "node_modules/node-gyp/node_modules/undici": { + "version": "6.25.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.25.0.tgz", + "integrity": "sha512-ZgpWDC5gmNiuY9CnLVXEH8rl50xhRCuLNA97fAUnKi8RRuV4E6KG31pDTsLVUKnohJE0I3XDrTeEydAXRw47xg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, + "node_modules/node-gyp/node_modules/which": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/which/-/which-6.0.1.tgz", + "integrity": "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^4.0.0" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, "node_modules/node-releases": { "version": "2.0.37", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz", @@ -8732,19 +8174,19 @@ "license": "MIT" }, "node_modules/nopt": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/nopt/-/nopt-8.1.0.tgz", - "integrity": "sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A==", + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-9.0.0.tgz", + "integrity": "sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw==", "dev": true, "license": "ISC", "dependencies": { - "abbrev": "^3.0.0" + "abbrev": "^4.0.0" }, "bin": { "nopt": "bin/nopt.js" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/normalize-path": { @@ -8848,86 +8290,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/ora": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", - "integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "bl": "^4.1.0", - "chalk": "^4.1.0", - "cli-cursor": "^3.1.0", - "cli-spinners": "^2.5.0", - "is-interactive": "^1.0.0", - "is-unicode-supported": "^0.1.0", - "log-symbols": "^4.1.0", - "strip-ansi": "^6.0.0", - "wcwidth": "^1.0.1" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/ora/node_modules/cli-cursor": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", - "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", - "dev": true, - "license": "MIT", - "dependencies": { - "restore-cursor": "^3.1.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/ora/node_modules/onetime": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", - "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "mimic-fn": "^2.1.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/ora/node_modules/restore-cursor": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", - "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", - "dev": true, - "license": "MIT", - "dependencies": { - "onetime": "^5.1.0", - "signal-exit": "^3.0.2" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/ora/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/p-cancelable": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz", @@ -8954,26 +8316,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/p-map": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.4.tgz", - "integrity": "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/package-json-from-dist": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", - "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", - "dev": true, - "license": "BlueOak-1.0.0" - }, "node_modules/parse-svg-path": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/parse-svg-path/-/parse-svg-path-0.1.2.tgz", @@ -9019,30 +8361,6 @@ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", "license": "MIT" }, - "node_modules/path-scurry": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", - "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" - }, - "engines": { - "node": ">=16 || 14 >=14.18" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/path-scurry/node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true, - "license": "ISC" - }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -9438,13 +8756,13 @@ } }, "node_modules/proc-log": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-5.0.0.tgz", - "integrity": "sha512-Azwzvl90HaF0aCz1JrDdXQykFakSSNPaPoiZ9fm5qJIMHioDZEi7OAdRwSm6rSoPtY3Qutnm3L7ogmg3dc+wbQ==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz", + "integrity": "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==", "dev": true, "license": "ISC", "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/progress": { @@ -9785,21 +9103,6 @@ "pify": "^2.3.0" } }, - "node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "dev": true, - "license": "MIT", - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -10082,27 +9385,6 @@ "queue-microtask": "^1.2.2" } }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -10363,41 +9645,12 @@ "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", "dev": true, "license": "MIT", + "optional": true, "engines": { "node": ">= 6.0.0", "npm": ">= 3.0.0" } }, - "node_modules/socks": { - "version": "2.8.7", - "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", - "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ip-address": "^10.0.1", - "smart-buffer": "^4.2.0" - }, - "engines": { - "node": ">= 10.0.0", - "npm": ">= 3.0.0" - } - }, - "node_modules/socks-proxy-agent": { - "version": "8.0.5", - "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", - "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", - "dev": true, - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "^4.3.4", - "socks": "^2.8.3" - }, - "engines": { - "node": ">= 14" - } - }, "node_modules/sonner": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz", @@ -10446,19 +9699,6 @@ "license": "BSD-3-Clause", "optional": true }, - "node_modules/ssri": { - "version": "12.0.0", - "resolved": "https://registry.npmjs.org/ssri/-/ssri-12.0.0.tgz", - "integrity": "sha512-S7iGNosepx9RadX82oimUkvr0Ct7IjJbEbs4mJcTxst8um95J3sDYU1RBEOvdu6oL1Wek2ODI5i4MAw+dZ6cAQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "minipass": "^7.0.3" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", @@ -10483,16 +9723,6 @@ "dev": true, "license": "MIT" }, - "node_modules/string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "dev": true, - "license": "MIT", - "dependencies": { - "safe-buffer": "~5.2.0" - } - }, "node_modules/string-argv": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", @@ -10518,35 +9748,6 @@ "node": ">=8" } }, - "node_modules/string-width-cjs": { - "name": "string-width", - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width-cjs/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/string-width/node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", @@ -10576,20 +9777,6 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, - "node_modules/strip-ansi-cjs": { - "name": "strip-ansi", - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/strip-ansi/node_modules/ansi-regex": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", @@ -11105,6 +10292,19 @@ "node": ">=14.17" } }, + "node_modules/uiohook-napi": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/uiohook-napi/-/uiohook-napi-1.5.5.tgz", + "integrity": "sha512-oSlTdnECw2GBfsJPTbBQBeE4v/EXP0EZmX6BJq5nzH/JgFaBE8JpFwEA/kLhiEP7HxQw28FViWiYgdIZzWuuJQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-gyp-build": "^4.8.4" + }, + "engines": { + "node": ">= 16" + } + }, "node_modules/undici": { "version": "7.25.0", "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz", @@ -11122,32 +10322,6 @@ "dev": true, "license": "MIT" }, - "node_modules/unique-filename": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-4.0.0.tgz", - "integrity": "sha512-XSnEewXmQ+veP7xX2dS5Q4yZAvO40cBN2MWkJ7D/6sW4Dg6wYBNwM1Vrnz1FhH5AdeLIlUXRI9e28z1YZi71NQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "unique-slug": "^5.0.0" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/unique-slug": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-5.0.0.tgz", - "integrity": "sha512-9OdaqO5kwqR+1kVgHAhsp5vPNU0hnxRa26rBFNfNgM7M6pNtgzeBn3s/xbyCQL3dcjzOatcef6UUHpB/6MaETg==", - "dev": true, - "license": "ISC", - "dependencies": { - "imurmurhash": "^0.1.4" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, "node_modules/universalify": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", @@ -11520,16 +10694,6 @@ "node": ">=18" } }, - "node_modules/wcwidth": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", - "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==", - "dev": true, - "license": "MIT", - "dependencies": { - "defaults": "^1.0.3" - } - }, "node_modules/web-demuxer": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/web-demuxer/-/web-demuxer-4.0.0.tgz", @@ -11625,38 +10789,6 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/wrap-ansi-cjs": { - "name": "wrap-ansi", - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/wrap-ansi/node_modules/ansi-styles": { "version": "6.2.3", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", diff --git a/package.json b/package.json index 102e97c..2709d3e 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,9 @@ "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" + "prepare": "husky", + "rebuild:native": "node ./node_modules/@electron/rebuild/lib/cli.js --force --only uiohook-napi", + "postinstall": "npm run rebuild:native" }, "dependencies": { "@fix-webm-duration/fix": "^1.0.1", @@ -71,11 +73,13 @@ "sonner": "^2.0.7", "tailwind-merge": "^3.5.0", "tailwindcss-animate": "^1.0.7", + "uiohook-napi": "^1.5.5", "uuid": "^13.0.0", "web-demuxer": "^4.0.0" }, "devDependencies": { "@biomejs/biome": "^2.4.12", + "@electron/rebuild": "^4.0.4", "@playwright/test": "^1.59.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx index 82e106c..5cac573 100644 --- a/src/components/video-editor/SettingsPanel.tsx +++ b/src/components/video-editor/SettingsPanel.tsx @@ -1,5 +1,6 @@ import { Bug, + ChevronDown, Crop, Download, Film, @@ -22,6 +23,7 @@ import { AccordionTrigger, } from "@/components/ui/accordion"; import { Button } from "@/components/ui/button"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Select, SelectContent, @@ -151,6 +153,12 @@ const GRADIENTS = [ ]; interface SettingsPanelProps { + cursorHighlight?: import("./videoPlayback/cursorHighlight").CursorHighlightConfig; + onCursorHighlightChange?: ( + next: import("./videoPlayback/cursorHighlight").CursorHighlightConfig, + ) => void; + // macOS only — gates the "Only on clicks" toggle (needs uiohook). + cursorHighlightSupportsClicks?: boolean; selected: string; onWallpaperChange: (path: string) => void; selectedZoomDepth?: ZoomDepth | null; @@ -238,6 +246,9 @@ const ZOOM_DEPTH_OPTIONS: Array<{ depth: ZoomDepth; label: string }> = [ ]; export function SettingsPanel({ + cursorHighlight, + onCursorHighlightChange, + cursorHighlightSupportsClicks = false, selected, onWallpaperChange, selectedZoomDepth, @@ -991,6 +1002,181 @@ export function SettingsPanel({
+ {cursorHighlight && onCursorHighlightChange && ( +
+
+
Cursor highlight
+ +
+
+ {(["dot", "ring"] as const).map((style) => ( + + ))} +
+
+
+
Size
+ + {cursorHighlight.sizePx}px + +
+ + onCursorHighlightChange({ ...cursorHighlight, sizePx: values[0] }) + } + min={10} + max={36} + step={1} + className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3" + /> +
+ {cursorHighlightSupportsClicks && ( +
+
Only on clicks
+ +
+ )} +
+
Color
+ + + + + + + onCursorHighlightChange({ ...cursorHighlight, color }) + } + /> + + +
+
+
+
Offset X (window recordings)
+ + {(cursorHighlight.offsetXNorm * 100).toFixed(1)}% + +
+ + onCursorHighlightChange({ + ...cursorHighlight, + offsetXNorm: values[0], + }) + } + min={-0.25} + max={0.25} + step={0.005} + className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3" + /> +
+
+
+
Offset Y
+ + {(cursorHighlight.offsetYNorm * 100).toFixed(1)}% + +
+ + onCursorHighlightChange({ + ...cursorHighlight, + offsetYNorm: values[0], + }) + } + min={-0.25} + max={0.25} + step={0.005} + className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3" + /> +
+
+ )} +
@@ -1957,6 +1978,9 @@ export default function VideoEditor() { {/* Right section: settings panel */}
pushState({ cursorHighlight: next })} + cursorHighlightSupportsClicks={isMac} selected={wallpaper} onWallpaperChange={(w) => pushState({ wallpaper: w })} selectedZoomDepth={ diff --git a/src/components/video-editor/VideoPlayback.tsx b/src/components/video-editor/VideoPlayback.tsx index 35e0077..a69c8d7 100644 --- a/src/components/video-editor/VideoPlayback.tsx +++ b/src/components/video-editor/VideoPlayback.tsx @@ -51,7 +51,17 @@ import { ZOOM_SCALE_DEADZONE, ZOOM_TRANSLATION_DEADZONE_PX, } from "./videoPlayback/constants"; -import { adaptiveSmoothFactor, smoothCursorFocus } from "./videoPlayback/cursorFollowUtils"; +import { + adaptiveSmoothFactor, + interpolateCursorAt, + smoothCursorFocus, +} from "./videoPlayback/cursorFollowUtils"; +import { + type CursorHighlightConfig, + clickEmphasisAlpha, + DEFAULT_CURSOR_HIGHLIGHT, + drawCursorHighlightGraphics, +} from "./videoPlayback/cursorHighlight"; import { clampFocusToStage as clampFocusToStageUtil } from "./videoPlayback/focusUtils"; import { layoutVideoContent as layoutVideoContentUtil } from "./videoPlayback/layoutUtils"; import { clamp01 } from "./videoPlayback/mathUtils"; @@ -110,6 +120,8 @@ interface VideoPlaybackProps { onBlurDataChange?: (id: string, blurData: BlurData) => void; onBlurDataCommit?: () => void; cursorTelemetry?: import("./types").CursorTelemetryPoint[]; + cursorHighlight?: CursorHighlightConfig; + cursorClickTimestamps?: number[]; } export interface VideoPlaybackRef { @@ -168,6 +180,8 @@ const VideoPlayback = forwardRef( onBlurDataChange, onBlurDataCommit, cursorTelemetry = [], + cursorHighlight = DEFAULT_CURSOR_HIGHLIGHT, + cursorClickTimestamps = [], }, ref, ) => { @@ -191,6 +205,9 @@ const VideoPlayback = forwardRef( const currentTimeRef = useRef(0); const zoomRegionsRef = useRef([]); const cursorTelemetryRef = useRef([]); + const cursorHighlightRef = useRef(DEFAULT_CURSOR_HIGHLIGHT); + const cursorClickTimestampsRef = useRef([]); + const cursorHighlightGraphicsRef = useRef(null); const selectedZoomIdRef = useRef(null); const animationStateRef = useRef({ scale: 1, @@ -515,6 +532,17 @@ const VideoPlayback = forwardRef( cursorTelemetryRef.current = cursorTelemetry; }, [cursorTelemetry]); + useEffect(() => { + cursorHighlightRef.current = cursorHighlight; + if (cursorHighlightGraphicsRef.current) { + drawCursorHighlightGraphics(cursorHighlightGraphicsRef.current, cursorHighlight); + } + }, [cursorHighlight]); + + useEffect(() => { + cursorClickTimestampsRef.current = cursorClickTimestamps; + }, [cursorClickTimestamps]); + useEffect(() => { selectedZoomIdRef.current = selectedZoomId; }, [selectedZoomId]); @@ -738,6 +766,12 @@ const VideoPlayback = forwardRef( videoContainer.mask = maskGraphics; maskGraphicsRef.current = maskGraphics; + const cursorHighlightGraphics = new Graphics(); + cursorHighlightGraphics.visible = false; + videoContainer.addChild(cursorHighlightGraphics); + cursorHighlightGraphicsRef.current = cursorHighlightGraphics; + drawCursorHighlightGraphics(cursorHighlightGraphics, cursorHighlightRef.current); + animationStateRef.current = { scale: 1, focusX: DEFAULT_FOCUS.cx, @@ -797,6 +831,11 @@ const VideoPlayback = forwardRef( videoContainer.removeChild(maskGraphics); maskGraphics.destroy(); } + if (cursorHighlightGraphicsRef.current) { + videoContainer.removeChild(cursorHighlightGraphicsRef.current); + cursorHighlightGraphicsRef.current.destroy(); + cursorHighlightGraphicsRef.current = null; + } videoContainer.mask = null; maskGraphicsRef.current = null; if (blurFilterRef.current) { @@ -1016,6 +1055,39 @@ const VideoPlayback = forwardRef( motionVector, ); + const cursorGraphics = cursorHighlightGraphicsRef.current; + const cursorConfig = cursorHighlightRef.current; + const lockedDims = lockedVideoDimensionsRef.current; + if (cursorGraphics) { + if (cursorConfig.enabled && lockedDims && cursorTelemetryRef.current.length > 0) { + const emphasisAlpha = clickEmphasisAlpha( + currentTimeRef.current, + cursorClickTimestampsRef.current, + cursorConfig, + ); + const cursorPoint = + emphasisAlpha > 0 + ? interpolateCursorAt(cursorTelemetryRef.current, currentTimeRef.current) + : null; + if (cursorPoint) { + const baseScale = baseScaleRef.current; + const baseOffset = baseOffsetRef.current; + const cx = cursorPoint.cx + cursorConfig.offsetXNorm; + const cy = cursorPoint.cy + cursorConfig.offsetYNorm; + cursorGraphics.position.set( + baseOffset.x + cx * lockedDims.width * baseScale, + baseOffset.y + cy * lockedDims.height * baseScale, + ); + cursorGraphics.alpha = emphasisAlpha; + cursorGraphics.visible = true; + } else { + cursorGraphics.visible = false; + } + } else { + cursorGraphics.visible = false; + } + } + const isMotionBlurActive = (motionBlurAmountRef.current || 0) > 0 && isPlayingRef.current; if (isMotionBlurActive !== lastMotionBlurActive && videoContainerRef.current) { diff --git a/src/components/video-editor/projectPersistence.ts b/src/components/video-editor/projectPersistence.ts index 7259c1e..beabbe4 100644 --- a/src/components/video-editor/projectPersistence.ts +++ b/src/components/video-editor/projectPersistence.ts @@ -80,6 +80,7 @@ export interface ProjectEditorState { gifFrameRate: GifFrameRate; gifLoop: boolean; gifSizePreset: GifSizePreset; + cursorHighlight: import("./videoPlayback/cursorHighlight").CursorHighlightConfig; } export interface EditorProjectData { @@ -494,6 +495,52 @@ export function normalizeProjectEditor(editor: Partial): Pro editor.gifSizePreset === "original" ? editor.gifSizePreset : "medium", + cursorHighlight: normalizeCursorHighlight(editor.cursorHighlight), + }; +} + +function normalizeCursorHighlight( + value: unknown, +): import("./videoPlayback/cursorHighlight").CursorHighlightConfig { + const fallback: import("./videoPlayback/cursorHighlight").CursorHighlightConfig = { + enabled: false, + style: "ring", + sizePx: 24, + color: "#FFD700", + opacity: 0.9, + onlyOnClicks: false, + clickEmphasisDurationMs: 350, + offsetXNorm: 0, + offsetYNorm: 0, + }; + if (!value || typeof value !== "object") return fallback; + const v = value as Partial; + return { + enabled: typeof v.enabled === "boolean" ? v.enabled : fallback.enabled, + style: v.style === "dot" || v.style === "ring" ? v.style : fallback.style, + sizePx: + typeof v.sizePx === "number" && v.sizePx >= 10 && v.sizePx <= 36 ? v.sizePx : fallback.sizePx, + color: + typeof v.color === "string" && /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(v.color) + ? v.color + : fallback.color, + opacity: + typeof v.opacity === "number" && v.opacity >= 0 && v.opacity <= 1 + ? v.opacity + : fallback.opacity, + onlyOnClicks: typeof v.onlyOnClicks === "boolean" ? v.onlyOnClicks : fallback.onlyOnClicks, + clickEmphasisDurationMs: + typeof v.clickEmphasisDurationMs === "number" && v.clickEmphasisDurationMs > 0 + ? v.clickEmphasisDurationMs + : fallback.clickEmphasisDurationMs, + offsetXNorm: + typeof v.offsetXNorm === "number" && Number.isFinite(v.offsetXNorm) + ? Math.max(-1, Math.min(1, v.offsetXNorm)) + : fallback.offsetXNorm, + offsetYNorm: + typeof v.offsetYNorm === "number" && Number.isFinite(v.offsetYNorm) + ? Math.max(-1, Math.min(1, v.offsetYNorm)) + : fallback.offsetYNorm, }; } diff --git a/src/components/video-editor/videoPlayback/cursorHighlight.ts b/src/components/video-editor/videoPlayback/cursorHighlight.ts new file mode 100644 index 0000000..273e7b2 --- /dev/null +++ b/src/components/video-editor/videoPlayback/cursorHighlight.ts @@ -0,0 +1,125 @@ +import type { Graphics } from "pixi.js"; + +export type CursorHighlightStyle = "dot" | "ring"; + +export interface CursorHighlightConfig { + enabled: boolean; + style: CursorHighlightStyle; + sizePx: number; + color: string; + opacity: number; + // Show only on clicks (macOS — depends on click telemetry from uiohook). + onlyOnClicks: boolean; + clickEmphasisDurationMs: number; + // Per-recording manual nudge. Cursor telemetry is normalized to the display, + // but window recordings frame a subset of the display so the highlight + // lands offset. Users dial these in once to align with the actual cursor. + offsetXNorm: number; + offsetYNorm: number; +} + +export const CURSOR_HIGHLIGHT_MIN_SIZE_PX = 10; +export const CURSOR_HIGHLIGHT_MAX_SIZE_PX = 36; + +export const DEFAULT_CURSOR_HIGHLIGHT: CursorHighlightConfig = { + enabled: false, + style: "ring", + sizePx: 24, + color: "#FFD700", + opacity: 0.9, + onlyOnClicks: false, + clickEmphasisDurationMs: 350, + offsetXNorm: 0, + offsetYNorm: 0, +}; + +export const CURSOR_HIGHLIGHT_OFFSET_RANGE = 0.25; // ±25% of recorded surface + +// Alpha multiplier for the highlight at `timeMs`. Returns 1 when not in +// click-only mode; in click-only mode fades 1→0 across each click's window. +export function clickEmphasisAlpha( + timeMs: number, + clickTimestampsMs: number[] | undefined, + config: CursorHighlightConfig, +): number { + if (!config.onlyOnClicks) return 1; + if (!clickTimestampsMs || clickTimestampsMs.length === 0) return 0; + const window = Math.max(1, config.clickEmphasisDurationMs); + for (let i = 0; i < clickTimestampsMs.length; i++) { + const dt = timeMs - clickTimestampsMs[i]; + if (dt >= 0 && dt <= window) { + return 1 - dt / window; + } + } + return 0; +} + +function parseHexColor(hex: string): number { + const cleaned = hex.replace("#", ""); + if (cleaned.length === 3) { + const r = cleaned[0]; + const g = cleaned[1]; + const b = cleaned[2]; + return Number.parseInt(`${r}${r}${g}${g}${b}${b}`, 16); + } + return Number.parseInt(cleaned.slice(0, 6), 16); +} + +export function drawCursorHighlightGraphics(g: Graphics, config: CursorHighlightConfig): void { + g.clear(); + if (!config.enabled) return; + + const color = parseHexColor(config.color); + const radius = Math.max(1, config.sizePx / 2); + const alpha = Math.max(0, Math.min(1, config.opacity)); + + switch (config.style) { + case "dot": { + g.circle(0, 0, radius); + g.fill({ color, alpha }); + break; + } + case "ring": { + g.circle(0, 0, radius); + g.stroke({ color, alpha, width: Math.max(2, radius * 0.18) }); + break; + } + } +} + +export function drawCursorHighlightCanvas( + ctx: CanvasRenderingContext2D, + cx: number, + cy: number, + config: CursorHighlightConfig, + pixelScale = 1, +): void { + if (!config.enabled) return; + + const radius = Math.max(1, (config.sizePx / 2) * pixelScale); + const alpha = Math.max(0, Math.min(1, config.opacity)); + const color = config.color; + + ctx.save(); + ctx.globalAlpha = alpha; + + switch (config.style) { + case "dot": { + ctx.fillStyle = color; + ctx.beginPath(); + ctx.arc(cx, cy, radius, 0, Math.PI * 2); + ctx.fill(); + break; + } + case "ring": { + ctx.beginPath(); + ctx.arc(cx, cy, radius, 0, Math.PI * 2); + ctx.strokeStyle = color; + ctx.lineWidth = Math.max(2, radius * 0.18); + ctx.stroke(); + break; + } + } + + ctx.restore(); +} diff --git a/src/hooks/useEditorHistory.ts b/src/hooks/useEditorHistory.ts index bd410da..a655137 100644 --- a/src/hooks/useEditorHistory.ts +++ b/src/hooks/useEditorHistory.ts @@ -17,6 +17,10 @@ import { DEFAULT_WEBCAM_POSITION, DEFAULT_WEBCAM_SIZE_PRESET, } from "@/components/video-editor/types"; +import { + type CursorHighlightConfig, + DEFAULT_CURSOR_HIGHLIGHT, +} from "@/components/video-editor/videoPlayback/cursorHighlight"; import { DEFAULT_WALLPAPER } from "@/lib/wallpaper"; import type { AspectRatio } from "@/utils/aspectRatioUtils"; @@ -39,6 +43,7 @@ export interface EditorState { webcamMaskShape: WebcamMaskShape; webcamSizePreset: WebcamSizePreset; webcamPosition: WebcamPosition | null; + cursorHighlight: CursorHighlightConfig; } export const INITIAL_EDITOR_STATE: EditorState = { @@ -58,6 +63,7 @@ export const INITIAL_EDITOR_STATE: EditorState = { webcamMaskShape: DEFAULT_WEBCAM_MASK_SHAPE, webcamSizePreset: DEFAULT_WEBCAM_SIZE_PRESET, webcamPosition: DEFAULT_WEBCAM_POSITION, + cursorHighlight: DEFAULT_CURSOR_HIGHLIGHT, }; type StateUpdate = Partial | ((prev: EditorState) => Partial); diff --git a/src/lib/exporter/frameRenderer.ts b/src/lib/exporter/frameRenderer.ts index ad65a08..0a151b0 100644 --- a/src/lib/exporter/frameRenderer.ts +++ b/src/lib/exporter/frameRenderer.ts @@ -28,8 +28,14 @@ import { } from "@/components/video-editor/videoPlayback/constants"; import { adaptiveSmoothFactor, + interpolateCursorAt, smoothCursorFocus, } from "@/components/video-editor/videoPlayback/cursorFollowUtils"; +import { + type CursorHighlightConfig, + clickEmphasisAlpha, + drawCursorHighlightCanvas, +} from "@/components/video-editor/videoPlayback/cursorHighlight"; import { clampFocusToStage as clampFocusToStageUtil } from "@/components/video-editor/videoPlayback/focusUtils"; import { findDominantRegion } from "@/components/video-editor/videoPlayback/zoomRegionUtils"; import { @@ -79,6 +85,8 @@ interface FrameRenderConfig { previewWidth?: number; previewHeight?: number; cursorTelemetry?: import("@/components/video-editor/types").CursorTelemetryPoint[]; + cursorHighlight?: CursorHighlightConfig; + cursorClickTimestamps?: number[]; platform: string; } @@ -387,6 +395,46 @@ export class FrameRenderer { // Composite with shadows to final output canvas this.compositeWithShadows(webcamFrame); + // Cursor highlight overlay (rendered above video, below annotations) + if ( + this.config.cursorHighlight?.enabled && + this.config.cursorTelemetry && + this.config.cursorTelemetry.length > 0 && + this.compositeCtx + ) { + const emphasisAlpha = clickEmphasisAlpha( + timeMs, + this.config.cursorClickTimestamps, + this.config.cursorHighlight, + ); + const cursorPoint = + emphasisAlpha > 0 ? interpolateCursorAt(this.config.cursorTelemetry, timeMs) : null; + if (cursorPoint) { + const cx = cursorPoint.cx + this.config.cursorHighlight.offsetXNorm; + const cy = cursorPoint.cy + this.config.cursorHighlight.offsetYNorm; + const stageX = + layoutCache.baseOffset.x + cx * this.config.videoWidth * layoutCache.baseScale; + const stageY = + layoutCache.baseOffset.y + cy * this.config.videoHeight * layoutCache.baseScale; + const appliedScale = this.animationState.appliedScale; + const canvasX = stageX * appliedScale + this.animationState.x; + const canvasY = stageY * appliedScale + this.animationState.y; + const previewW = this.config.previewWidth ?? this.config.width; + const previewH = this.config.previewHeight ?? this.config.height; + const cursorScale = (this.config.width / previewW + this.config.height / previewH) / 2; + drawCursorHighlightCanvas( + this.compositeCtx, + canvasX, + canvasY, + { + ...this.config.cursorHighlight, + opacity: this.config.cursorHighlight.opacity * emphasisAlpha, + }, + appliedScale * cursorScale, + ); + } + } + // Render annotations on top if present if ( this.config.annotationRegions && diff --git a/src/lib/exporter/gifExporter.ts b/src/lib/exporter/gifExporter.ts index f41b58d..0d7a432 100644 --- a/src/lib/exporter/gifExporter.ts +++ b/src/lib/exporter/gifExporter.ts @@ -51,6 +51,8 @@ interface GifExporterConfig { previewWidth?: number; previewHeight?: number; cursorTelemetry?: import("@/components/video-editor/types").CursorTelemetryPoint[]; + cursorHighlight?: import("@/components/video-editor/videoPlayback/cursorHighlight").CursorHighlightConfig; + cursorClickTimestamps?: number[]; onProgress?: (progress: ExportProgress) => void; } @@ -161,6 +163,8 @@ export class GifExporter { previewWidth: this.config.previewWidth, previewHeight: this.config.previewHeight, cursorTelemetry: this.config.cursorTelemetry, + cursorClickTimestamps: this.config.cursorClickTimestamps, + cursorHighlight: this.config.cursorHighlight, platform, }); await this.renderer.initialize(); diff --git a/src/lib/exporter/videoExporter.ts b/src/lib/exporter/videoExporter.ts index d44bf40..e064ba7 100644 --- a/src/lib/exporter/videoExporter.ts +++ b/src/lib/exporter/videoExporter.ts @@ -42,6 +42,8 @@ interface VideoExporterConfig extends ExportConfig { previewWidth?: number; previewHeight?: number; cursorTelemetry?: import("@/components/video-editor/types").CursorTelemetryPoint[]; + cursorHighlight?: import("@/components/video-editor/videoPlayback/cursorHighlight").CursorHighlightConfig; + cursorClickTimestamps?: number[]; onProgress?: (progress: ExportProgress) => void; } @@ -156,6 +158,8 @@ export class VideoExporter { previewWidth: this.config.previewWidth, previewHeight: this.config.previewHeight, cursorTelemetry: this.config.cursorTelemetry, + cursorClickTimestamps: this.config.cursorClickTimestamps, + cursorHighlight: this.config.cursorHighlight, platform, }); this.renderer = renderer; From 78f57970e96c107c91448085a87c1e2b9c159a71 Mon Sep 17 00:00:00 2001 From: Siddharth Date: Sat, 2 May 2026 23:27:38 -0700 Subject: [PATCH 16/59] fix ci checks --- package.json | 2 +- scripts/rebuild-native.mjs | 21 +++++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) create mode 100644 scripts/rebuild-native.mjs diff --git a/package.json b/package.json index 2709d3e..855160f 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "test:browser:install": "playwright install --with-deps chromium-headless-shell", "test:e2e": "playwright test", "prepare": "husky", - "rebuild:native": "node ./node_modules/@electron/rebuild/lib/cli.js --force --only uiohook-napi", + "rebuild:native": "node ./scripts/rebuild-native.mjs", "postinstall": "npm run rebuild:native" }, "dependencies": { diff --git a/scripts/rebuild-native.mjs b/scripts/rebuild-native.mjs new file mode 100644 index 0000000..e028602 --- /dev/null +++ b/scripts/rebuild-native.mjs @@ -0,0 +1,21 @@ +import { spawnSync } from "node:child_process"; +import process from "node:process"; + +// uiohook-napi click capture is macOS-only at runtime (gated in +// electron/ipc/handlers.ts). Skip the rebuild on other platforms so CI runners +// without X11 dev headers don't fail npm install. The library's prebuilt +// .node binaries are still bundled and loadable; we just don't need a fresh +// build against Electron's ABI on platforms where we don't load it. +if (process.platform !== "darwin") { + console.log( + `[rebuild:native] Skipping uiohook-napi rebuild on ${process.platform} (macOS-only).`, + ); + process.exit(0); +} + +const result = spawnSync( + process.execPath, + ["./node_modules/@electron/rebuild/lib/cli.js", "--force", "--only", "uiohook-napi"], + { stdio: "inherit" }, +); +process.exit(result.status ?? 0); From b7d356327259c6befd698d5fa2132cb66f93185f Mon Sep 17 00:00:00 2001 From: psychosomat Date: Sun, 3 May 2026 12:10:00 +0300 Subject: [PATCH 17/59] Upload pacman package in Linux CI artifacts --- .github/workflows/build.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f42a92d..35177bc 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -250,4 +250,5 @@ jobs: release/**/*.AppImage release/**/*.zsync release/**/*.deb + release/**/*.pacman retention-days: 30 From 679e306d31415ebc370ccf1e2a83ef27bd79d290 Mon Sep 17 00:00:00 2001 From: i1Zeus Date: Sun, 3 May 2026 19:49:35 +0300 Subject: [PATCH 18/59] feat: add Arabic localization support for editor, launch, settings, shortcuts, timeline, common, and dialogs modules --- src/i18n/locales/ar/common.json | 30 +++++ src/i18n/locales/ar/dialogs.json | 70 +++++++++++ src/i18n/locales/ar/editor.json | 45 ++++++++ src/i18n/locales/ar/launch.json | 43 +++++++ src/i18n/locales/ar/settings.json | 180 +++++++++++++++++++++++++++++ src/i18n/locales/ar/shortcuts.json | 37 ++++++ src/i18n/locales/ar/timeline.json | 55 +++++++++ 7 files changed, 460 insertions(+) create mode 100644 src/i18n/locales/ar/common.json create mode 100644 src/i18n/locales/ar/dialogs.json create mode 100644 src/i18n/locales/ar/editor.json create mode 100644 src/i18n/locales/ar/launch.json create mode 100644 src/i18n/locales/ar/settings.json create mode 100644 src/i18n/locales/ar/shortcuts.json create mode 100644 src/i18n/locales/ar/timeline.json diff --git a/src/i18n/locales/ar/common.json b/src/i18n/locales/ar/common.json new file mode 100644 index 0000000..e4f17fe --- /dev/null +++ b/src/i18n/locales/ar/common.json @@ -0,0 +1,30 @@ +{ + "actions": { + "cancel": "الغاء", + "save": "حفظ", + "delete": "حذف", + "close": "اغلاق", + "share": "مشاركة", + "done": "تم", + "open": "فتح", + "upload": "رفع", + "export": "تصدير", + "showInFolder": "عرض في المجلد", + "file": "ملف", + "edit": "تعديل", + "view": "عرض", + "window": "نافذة", + "quit": "خروج", + "stopRecording": "ايقاف التسجيل" + }, + "playback": { + "play": "تشغيل", + "pause": "ايقاف مؤقت", + "fullscreen": "ملء الشاشة", + "exitFullscreen": "خروج من ملء الشاشة" + }, + "locale": { + "name": "عربي", + "short": "AR" + } +} diff --git a/src/i18n/locales/ar/dialogs.json b/src/i18n/locales/ar/dialogs.json new file mode 100644 index 0000000..2263f60 --- /dev/null +++ b/src/i18n/locales/ar/dialogs.json @@ -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": "جميع الملفات" + } +} diff --git a/src/i18n/locales/ar/editor.json b/src/i18n/locales/ar/editor.json new file mode 100644 index 0000000..0d293d7 --- /dev/null +++ b/src/i18n/locales/ar/editor.json @@ -0,0 +1,45 @@ +{ + "newRecording": { + "title": "العودة إلى المسجل", + "description": "تم حفظ جلستك الحالية.", + "cancel": "إلغاء", + "confirm": "تأكيد" + }, + "loadingVideo": "جاري تحميل الفيديو...", + "errors": { + "noVideoLoaded": "لم يتم تحميل أي فيديو", + "videoNotReady": "الفيديو غير جاهز", + "unableToDetermineSourcePath": "تعذر تحديد مسار الفيديو المصدر", + "failedToSaveGif": "فشل حفظ GIF", + "gifExportFailed": "فشل تصدير GIF", + "failedToSaveVideo": "فشل حفظ الفيديو", + "exportFailed": "فشل التصدير", + "exportFailedWithError": "فشل التصدير: {{error}}", + "exportBackgroundLoadFailed": "فشل التصدير: تعذر تحميل صورة الخلفية ({{url}})", + "failedToSaveExport": "فشل حفظ التصدير", + "failedToSaveExportedVideo": "فشل حفظ الفيديو المصدر", + "failedToRevealInFolder": "خطأ في الكشف في المجلد: {{error}}" + }, + "export": { + "canceled": "تم إلغاء التصدير", + "exportedSuccessfully": "تم تصدير {{format}} بنجاح" + }, + "project": { + "saveCanceled": "تم إلغاء حفظ المشروع", + "failedToSave": "فشل حفظ المشروع", + "savedTo": "تم حفظ المشروع في {{path}}", + "failedToLoad": "فشل تحميل المشروع", + "invalidFormat": "تنسيق ملف المشروع غير صالح", + "loadedFrom": "تم تحميل المشروع من {{path}}" + }, + "recording": { + "failedCameraAccess": "فشل طلب الوصول إلى الكاميرا.", + "cameraBlocked": "الوصول إلى الكاميرا محظور. قم بتمكينه في إعدادات النظام لاستخدام كاميرا الويب.", + "systemAudioUnavailable": "صوت النظام غير متوفر. يتم التسجيل بدون صوت النظام.", + "microphoneDenied": "تم رفض الوصول إلى الميكروفون. سيستمر التسجيل بدون صوت.", + "cameraDenied": "تم رفض الوصول إلى الكاميرا. سيستمر التسجيل بدون كاميرا الويب.", + "cameraDisconnected": "تم فصل كاميرا الويب.", + "cameraNotFound": "لم يتم العثور على كاميرا.", + "permissionDenied": "تم رفض إذن التسجيل. يرجى السماح بتسجيل الشاشة." + } +} diff --git a/src/i18n/locales/ar/launch.json b/src/i18n/locales/ar/launch.json new file mode 100644 index 0000000..19da8fb --- /dev/null +++ b/src/i18n/locales/ar/launch.json @@ -0,0 +1,43 @@ +{ + "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": "اللغة", + "systemLanguagePrompt": { + "title": "هل تريد استخدام لغة نظامك؟", + "description": "اكتشفنا أن {{language}} هي لغة نظامك. هل تريد تبديل OpenScreen إلى {{language}}؟", + "switch": "التبديل إلى {{language}}", + "keepDefault": "الاحتفاظ باللغة الحالية" + } +} diff --git a/src/i18n/locales/ar/settings.json b/src/i18n/locales/ar/settings.json new file mode 100644 index 0000000..e0510c9 --- /dev/null +++ b/src/i18n/locales/ar/settings.json @@ -0,0 +1,180 @@ +{ + "zoom": { + "level": "مستوى التكبير", + "selectRegion": "حدد منطقة التكبير للتعديل", + "deleteZoom": "حذف التكبير", + "focusMode": { + "title": "وضع التركيز", + "manual": "يدوي", + "auto": "تلقائي", + "autoDescription": "الكاميرا تتبع موضع المؤشر المسجل" + } + }, + "speed": { + "playbackSpeed": "سرعة التشغيل", + "selectRegion": "حدد منطقة السرعة للتعديل", + "deleteRegion": "حذف منطقة السرعة", + "customPlaybackSpeed": "سرعة تشغيل مخصصة", + "maxSpeedError": "لا يمكن للسرعة أن تتجاوز 16×" + }, + "trim": { + "deleteRegion": "حذف منطقة القص" + }, + "layout": { + "title": "التخطيط", + "preset": "الإعداد المسبق", + "selectPreset": "حدد إعدادًا مسبقًا", + "pictureInPicture": "صورة داخل صورة", + "verticalStack": "تكدس عمودي", + "dualFrame": "إطار مزدوج", + "webcamShape": "شكل الكاميرا", + "webcamSize": "حجم كاميرا الويب" + }, + "effects": { + "title": "تأثيرات الفيديو", + "blurBg": "تمويه الخلفية", + "motionBlur": "ضبابية الحركة", + "off": "إيقاف", + "shadow": "ظل", + "roundness": "الاستدارة", + "padding": "المسافة البادئة" + }, + "background": { + "title": "الخلفية", + "image": "صورة", + "color": "لون", + "gradient": "تدرج لوني", + "uploadCustom": "رفع صورة مخصصة", + "gradientLabel": "تدرج لوني {{index}}", + "colorWheel": "عجلة الألوان", + "colorPalette": "لوحة الألوان" + }, + "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": "لون", + "colorWheel": "عجلة الألوان", + "colorPalette": "لوحة الألوان", + "clearBackground": "مسح الخلفية", + "uploadImage": "رفع صورة", + "supportedFormats": "الصيغ المدعومة: JPG, PNG, GIF, WebP", + "arrowDirection": "اتجاه السهم", + "strokeWidth": "عرض الخط: {{width}}px", + "arrowColor": "لون السهم", + "blurType": "نوع التمويه", + "blurTypeBlur": "تمويه", + "blurTypeMosaic": "فسيفساء", + "blurColor": "لون التمويه", + "blurColorWhite": "أبيض", + "blurColorBlack": "أسود", + "blurShape": "شكل التمويه", + "blurIntensity": "كثافة التمويه", + "mosaicBlockSize": "حجم كتلة الفسيفساء", + "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", + "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap", + "urlHelp": "احصل على هذا من خطوط Google: حدد خطًا → انقر على \"Get font\" → انسخ رابط @import", + "nameLabel": "اسم العرض", + "namePlaceholder": "خطي المخصص", + "nameHelp": "هكذا سيظهر الخط في محدد الخطوط", + "addButton": "إضافة خط", + "addingButton": "جاري الإضافة...", + "errorEmptyUrl": "يرجى إدخال رابط استيراد لخطوط Google", + "errorInvalidUrl": "يرجى إدخال رابط صحيح لخطوط Google", + "errorEmptyName": "يرجى إدخال اسم الخط", + "errorExtractFailed": "تعذر استخراج عائلة الخط من الرابط", + "successMessage": "تم إضافة الخط \"{{fontName}}\" بنجاح", + "failedToAdd": "فشل في إضافة الخط", + "errorTimeout": "استغرق تحميل الخط وقتًا طويلاً. يرجى التحقق من الرابط والمحاولة مرة أخرى.", + "errorLoadFailed": "تعذر تحميل الخط. يرجى التحقق من صحة رابط خطوط Google." + }, + "language": { + "title": "اللغة" + } +} diff --git a/src/i18n/locales/ar/shortcuts.json b/src/i18n/locales/ar/shortcuts.json new file mode 100644 index 0000000..a560c06 --- /dev/null +++ b/src/i18n/locales/ar/shortcuts.json @@ -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": "حذف المحدد (alt)", + "panTimeline": "تحريك المخطط الزمني", + "zoomTimeline": "تكبير المخطط الزمني", + "frameBack": "إطار للخلف", + "frameForward": "إطار للأمام" + } +} diff --git a/src/i18n/locales/ar/timeline.json b/src/i18n/locales/ar/timeline.json new file mode 100644 index 0000000..09d55c4 --- /dev/null +++ b/src/i18n/locales/ar/timeline.json @@ -0,0 +1,55 @@ +{ + "buttons": { + "addZoom": "إضافة تكبير (Z)", + "suggestZooms": "اقتراح تكبير من المؤشر", + "addTrim": "إضافة قص (T)", + "addAnnotation": "إضافة شرح (A)", + "addBlur": "إضافة تمويه (B)", + "addSpeed": "إضافة سرعة (S)" + }, + "hints": { + "pressZoom": "اضغط Z لإضافة تكبير", + "pressTrim": "اضغط T لإضافة قص", + "pressAnnotation": "اضغط A لإضافة شرح", + "pressBlur": "اضغط B لإضافة منطقة تمويه", + "pressSpeed": "اضغط S لإضافة سرعة" + }, + "labels": { + "pan": "تحريك", + "zoom": "تكبير", + "trim": "قص", + "speed": "سرعة", + "zoomItem": "تكبير {{index}}", + "trimItem": "قص {{index}}", + "speedItem": "سرعة {{index}}", + "annotationItem": "شرح", + "blurItem": "تمويه {{index}}", + "imageItem": "صورة", + "emptyText": "نص فارغ" + }, + "emptyState": { + "noVideo": "لم يتم تحميل أي فيديو", + "dragAndDrop": "اسحب وأفلت مقطع فيديو لبدء التعديل" + }, + "errors": { + "cannotPlaceZoom": "لا يمكن وضع التكبير هنا", + "zoomExistsAtLocation": "يوجد تكبير بالفعل في هذا الموقع أو لا توجد مساحة كافية متاحة.", + "zoomSuggestionUnavailable": "معالج اقتراح التكبير غير متوفر", + "noCursorTelemetry": "لا تتوفر بيانات قياس المؤشر", + "noCursorTelemetryDescription": "قم بتسجيل الشاشة أولاً لإنشاء اقتراحات بناءً على المؤشر.", + "noUsableTelemetry": "لا توجد بيانات قياس مؤشر قابلة للاستخدام", + "noUsableTelemetryDescription": "التسجيل لا يتضمن بيانات حركة مؤشر كافية.", + "noDwellMoments": "لم يتم العثور على لحظات توقف واضحة للمؤشر", + "noDwellMomentsDescription": "جرب تسجيلاً مع توقفات مؤشر أبطأ عند الإجراءات المهمة.", + "noAutoZoomSlots": "لا تتوفر خانات تكبير تلقائي", + "noAutoZoomSlotsDescription": "نقاط التوقف المكتشفة تتداخل مع مناطق التكبير الحالية.", + "cannotPlaceTrim": "لا يمكن وضع القص هنا", + "trimExistsAtLocation": "يوجد قص بالفعل في هذا الموقع أو لا توجد مساحة كافية متاحة.", + "cannotPlaceSpeed": "لا يمكن وضع السرعة هنا", + "speedExistsAtLocation": "توجد منطقة سرعة بالفعل في هذا الموقع أو لا توجد مساحة كافية متاحة." + }, + "success": { + "addedZoomSuggestions": "تمت إضافة {{count}} اقتراح تكبير بناءً على المؤشر", + "addedZoomSuggestionsPlural": "تمت إضافة {{count}} اقتراحات تكبير بناءً على المؤشر" + } +} From b5d37c427098a38f5b23b9dba3efb27df69b75b2 Mon Sep 17 00:00:00 2001 From: i1Zeus Date: Sun, 3 May 2026 20:03:01 +0300 Subject: [PATCH 19/59] feat: implement video editor SettingsPanel and add Arabic and English localization files --- src/components/video-editor/SettingsPanel.tsx | 52 +++++++++++++------ src/i18n/locales/ar/editor.json | 2 +- src/i18n/locales/ar/settings.json | 16 +++++- src/i18n/locales/en/settings.json | 16 +++++- 4 files changed, 68 insertions(+), 18 deletions(-) diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx index 5cac573..343c4cf 100644 --- a/src/components/video-editor/SettingsPanel.tsx +++ b/src/components/video-editor/SettingsPanel.tsx @@ -1005,7 +1005,9 @@ export function SettingsPanel({ {cursorHighlight && onCursorHighlightChange && (
-
Cursor highlight
+
+ {t("effects.cursorHighlight.title")} +
- {style} + {t(`effects.cursorHighlight.${style}`)} ))}
-
Size
+
+ {t("effects.cursorHighlight.size")} +
{cursorHighlight.sizePx}px @@ -1051,7 +1055,10 @@ export function SettingsPanel({ - onCursorHighlightChange({ ...cursorHighlight, sizePx: values[0] }) + onCursorHighlightChange({ + ...cursorHighlight, + sizePx: values[0], + }) } min={10} max={36} @@ -1063,7 +1070,9 @@ export function SettingsPanel({
-
Only on clicks
+
+ {t("effects.cursorHighlight.onlyOnClicks")} +
)}
-
Color
+
+ {t("effects.cursorHighlight.color")} +
-
Offset X (window recordings)
+
+ {t("effects.cursorHighlight.offsetX")} +
{(cursorHighlight.offsetXNorm * 100).toFixed(1)}% @@ -1155,7 +1175,9 @@ export function SettingsPanel({
-
Offset Y
+
+ {t("effects.cursorHighlight.offsetY")} +
{(cursorHighlight.offsetYNorm * 100).toFixed(1)}% diff --git a/src/i18n/locales/ar/editor.json b/src/i18n/locales/ar/editor.json index 0d293d7..a246f01 100644 --- a/src/i18n/locales/ar/editor.json +++ b/src/i18n/locales/ar/editor.json @@ -17,7 +17,7 @@ "exportFailedWithError": "فشل التصدير: {{error}}", "exportBackgroundLoadFailed": "فشل التصدير: تعذر تحميل صورة الخلفية ({{url}})", "failedToSaveExport": "فشل حفظ التصدير", - "failedToSaveExportedVideo": "فشل حفظ الفيديو المصدر", + "failedToSaveExportedVideo": "فشل حفظ الفيديو المُصدَّر", "failedToRevealInFolder": "خطأ في الكشف في المجلد: {{error}}" }, "export": { diff --git a/src/i18n/locales/ar/settings.json b/src/i18n/locales/ar/settings.json index e0510c9..e21976d 100644 --- a/src/i18n/locales/ar/settings.json +++ b/src/i18n/locales/ar/settings.json @@ -35,9 +35,23 @@ "blurBg": "تمويه الخلفية", "motionBlur": "ضبابية الحركة", "off": "إيقاف", + "on": "تشغيل", "shadow": "ظل", "roundness": "الاستدارة", - "padding": "المسافة البادئة" + "padding": "المسافة البادئة", + "cursorHighlight": { + "title": "تمييز المؤشر", + "style": "النمط", + "dot": "نقطة", + "ring": "حلقة", + "size": "الحجم", + "onlyOnClicks": "عند النقر فقط", + "color": "اللون", + "offsetX": "إزاحة X (لتسجيلات النوافذ)", + "offsetY": "إزاحة Y", + "accessibilityPermissionTitle": "مطلوب إذن الوصول", + "accessibilityPermissionDescription": "افتح إعدادات النظام ← الخصوصية والأمان ← إمكانية الوصول، وقم بتفعيل Openscreen، ثم أعد تشغيل التطبيق." + } }, "background": { "title": "الخلفية", diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json index 9b85c2b..aaa5be4 100644 --- a/src/i18n/locales/en/settings.json +++ b/src/i18n/locales/en/settings.json @@ -35,9 +35,23 @@ "blurBg": "Blur BG", "motionBlur": "Motion Blur", "off": "off", + "on": "on", "shadow": "Shadow", "roundness": "Roundness", - "padding": "Padding" + "padding": "Padding", + "cursorHighlight": { + "title": "Cursor highlight", + "style": "Style", + "dot": "Dot", + "ring": "Ring", + "size": "Size", + "onlyOnClicks": "Only on clicks", + "color": "Color", + "offsetX": "Offset X (window recordings)", + "offsetY": "Offset Y", + "accessibilityPermissionTitle": "Accessibility permission needed", + "accessibilityPermissionDescription": "Open System Settings → Privacy & Security → Accessibility, enable Openscreen, then restart the app." + } }, "background": { "title": "Background", From bb30e20df7f70213937628b9fd4a3bcb9697d775 Mon Sep 17 00:00:00 2001 From: i1Zeus Date: Sun, 3 May 2026 20:05:06 +0300 Subject: [PATCH 20/59] implement lightweight i18n support for electron main process --- electron/i18n.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/electron/i18n.ts b/electron/i18n.ts index 4222741..7856357 100644 --- a/electron/i18n.ts +++ b/electron/i18n.ts @@ -1,6 +1,8 @@ // Lightweight i18n for the Electron main process. // Imports the same JSON translation files used by the renderer. +import commonAr from "../src/i18n/locales/ar/common.json"; +import dialogsAr from "../src/i18n/locales/ar/dialogs.json"; import commonEn from "../src/i18n/locales/en/common.json"; import dialogsEn from "../src/i18n/locales/en/dialogs.json"; import commonEs from "../src/i18n/locales/es/common.json"; @@ -18,7 +20,7 @@ import dialogsZh from "../src/i18n/locales/zh-CN/dialogs.json"; import commonZhTw from "../src/i18n/locales/zh-TW/common.json"; import dialogsZhTw from "../src/i18n/locales/zh-TW/dialogs.json"; -type Locale = "en" | "zh-CN" | "zh-TW" | "es" | "fr" | "ja-JP" | "ko-KR" | "tr"; +type Locale = "en" | "zh-CN" | "zh-TW" | "es" | "fr" | "ja-JP" | "ko-KR" | "tr" | "ar"; type Namespace = "common" | "dialogs"; type MessageMap = Record; @@ -31,6 +33,7 @@ const messages: Record> = { "ja-JP": { common: commonJa, dialogs: dialogsJa }, "ko-KR": { common: commonKo, dialogs: dialogsKo }, tr: { common: commonTr, dialogs: dialogsTr }, + ar: { common: commonAr, dialogs: dialogsAr }, }; let currentLocale: Locale = "en"; @@ -44,7 +47,8 @@ export function setMainLocale(locale: string) { locale === "fr" || locale === "ja-JP" || locale === "ko-KR" || - locale === "tr" + locale === "tr" || + locale === "ar" ) { currentLocale = locale; } From 59ecedb0ac10e4e1d82abae8f3e7fc7662a99fe7 Mon Sep 17 00:00:00 2001 From: i1Zeus Date: Sun, 3 May 2026 20:21:42 +0300 Subject: [PATCH 21/59] implement i18n support and dynamic application menu in electron main process --- electron/main.ts | 112 +++++++++++++++++++++++++------- src/i18n/locales/ar/common.json | 22 ++++++- src/i18n/locales/en/common.json | 22 ++++++- 3 files changed, 131 insertions(+), 25 deletions(-) diff --git a/electron/main.ts b/electron/main.ts index ad0a33f..030a8cf 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -124,15 +124,30 @@ function setupApplicationMenu() { template.push({ label: app.name, submenu: [ - { role: "about" }, + { + role: "about", + label: mainT("common", "actions.about") || "About OpenScreen", + }, { type: "separator" }, - { role: "services" }, + { + role: "services", + label: mainT("common", "actions.services") || "Services", + }, { type: "separator" }, - { role: "hide" }, - { role: "hideOthers" }, - { role: "unhide" }, + { + role: "hide", + label: mainT("common", "actions.hide") || "Hide OpenScreen", + }, + { + role: "hideOthers", + label: mainT("common", "actions.hideOthers") || "Hide Others", + }, + { + role: "unhide", + label: mainT("common", "actions.unhide") || "Show All", + }, { type: "separator" }, - { role: "quit" }, + { role: "quit", label: mainT("common", "actions.quit") || "Quit" }, ], }); } @@ -156,40 +171,89 @@ function setupApplicationMenu() { accelerator: "CmdOrCtrl+Shift+S", click: () => sendEditorMenuAction("menu-save-project-as"), }, - ...(isMac ? [] : [{ type: "separator" as const }, { role: "quit" as const }]), + ...(isMac + ? [] + : [ + { type: "separator" as const }, + { + role: "quit" as const, + label: mainT("common", "actions.quit") || "Quit", + }, + ]), ], }, { label: mainT("common", "actions.edit") || "Edit", submenu: [ - { role: "undo" }, - { role: "redo" }, + { role: "undo", label: mainT("common", "actions.undo") || "Undo" }, + { role: "redo", label: mainT("common", "actions.redo") || "Redo" }, { type: "separator" }, - { role: "cut" }, - { role: "copy" }, - { role: "paste" }, - { role: "selectAll" }, + { role: "cut", label: mainT("common", "actions.cut") || "Cut" }, + { role: "copy", label: mainT("common", "actions.copy") || "Copy" }, + { role: "paste", label: mainT("common", "actions.paste") || "Paste" }, + { + role: "selectAll", + label: mainT("common", "actions.selectAll") || "Select All", + }, ], }, { label: mainT("common", "actions.view") || "View", submenu: [ - { role: "reload" }, - { role: "forceReload" }, - { role: "toggleDevTools" }, + { + role: "reload", + label: mainT("common", "actions.reload") || "Reload", + }, + { + role: "forceReload", + label: mainT("common", "actions.forceReload") || "Force Reload", + }, + { + role: "toggleDevTools", + label: mainT("common", "actions.toggleDevTools") || "Toggle Developer Tools", + }, { type: "separator" }, - { role: "resetZoom" }, - { role: "zoomIn" }, - { role: "zoomOut" }, + { + role: "resetZoom", + label: mainT("common", "actions.actualSize") || "Actual Size", + }, + { + role: "zoomIn", + label: mainT("common", "actions.zoomIn") || "Zoom In", + }, + { + role: "zoomOut", + label: mainT("common", "actions.zoomOut") || "Zoom Out", + }, { type: "separator" }, - { role: "togglefullscreen" }, + { + role: "togglefullscreen", + label: mainT("common", "actions.toggleFullScreen") || "Toggle Full Screen", + }, ], }, { label: mainT("common", "actions.window") || "Window", submenu: isMac - ? [{ role: "minimize" }, { role: "zoom" }, { type: "separator" }, { role: "front" }] - : [{ role: "minimize" }, { role: "close" }], + ? [ + { + role: "minimize", + label: mainT("common", "actions.minimize") || "Minimize", + }, + { role: "zoom" }, + { type: "separator" }, + { role: "front" }, + ] + : [ + { + role: "minimize", + label: mainT("common", "actions.minimize") || "Minimize", + }, + { + role: "close", + label: mainT("common", "actions.close") || "Close", + }, + ], }, ); @@ -220,7 +284,9 @@ function getTrayIcon(filename: string, size: number) { function updateTrayMenu(recording: boolean = false) { if (!tray) return; const trayIcon = recording ? recordingTrayIcon : defaultTrayIcon; - const trayToolTip = recording ? `Recording: ${selectedSourceName}` : "OpenScreen"; + const trayToolTip = recording + ? mainT("common", "actions.recordingStatus", { source: selectedSourceName }) + : "OpenScreen"; const menuTemplate = recording ? [ { diff --git a/src/i18n/locales/ar/common.json b/src/i18n/locales/ar/common.json index e4f17fe..3591a29 100644 --- a/src/i18n/locales/ar/common.json +++ b/src/i18n/locales/ar/common.json @@ -15,7 +15,27 @@ "view": "عرض", "window": "نافذة", "quit": "خروج", - "stopRecording": "ايقاف التسجيل" + "stopRecording": "إيقاف التسجيل", + "undo": "تراجع", + "redo": "إعادة", + "cut": "قص", + "copy": "نسخ", + "paste": "لصق", + "selectAll": "تحديد الكل", + "minimize": "تصغير", + "reload": "إعادة تحميل", + "forceReload": "إعادة تحميل إجبارية", + "toggleDevTools": "أدوات المطور", + "actualSize": "الحجم الفعلي", + "zoomIn": "تكبير", + "zoomOut": "تصغير", + "toggleFullScreen": "ملء الشاشة", + "recordingStatus": "جاري التسجيل: {{source}}", + "about": "حول OpenScreen", + "services": "خدمات", + "hide": "إخفاء OpenScreen", + "hideOthers": "إخفاء الآخرين", + "unhide": "إظهار الكل" }, "playback": { "play": "تشغيل", diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index cdefe84..f60a402 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -15,7 +15,27 @@ "view": "View", "window": "Window", "quit": "Quit", - "stopRecording": "Stop Recording" + "stopRecording": "Stop Recording", + "undo": "Undo", + "redo": "Redo", + "cut": "Cut", + "copy": "Copy", + "paste": "Paste", + "selectAll": "Select All", + "minimize": "Minimize", + "reload": "Reload", + "forceReload": "Force Reload", + "toggleDevTools": "Toggle Developer Tools", + "actualSize": "Actual Size", + "zoomIn": "Zoom In", + "zoomOut": "Zoom Out", + "toggleFullScreen": "Toggle Full Screen", + "recordingStatus": "Recording: {{source}}", + "about": "About OpenScreen", + "services": "Services", + "hide": "Hide OpenScreen", + "hideOthers": "Hide Others", + "unhide": "Show All" }, "playback": { "play": "Play", From a0d1cfe8c8003537115152349cca8d8a0677248e Mon Sep 17 00:00:00 2001 From: i1Zeus Date: Sun, 3 May 2026 20:55:11 +0300 Subject: [PATCH 22/59] added ar to config and added fallback to the main.ts recordingStatus --- electron/main.ts | 4 +++- src/components/video-editor/SettingsPanel.tsx | 6 ++++-- src/i18n/config.ts | 1 + src/i18n/locales/ar/settings.json | 2 +- 4 files changed, 9 insertions(+), 4 deletions(-) diff --git a/electron/main.ts b/electron/main.ts index 030a8cf..bace434 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -285,7 +285,9 @@ function updateTrayMenu(recording: boolean = false) { if (!tray) return; const trayIcon = recording ? recordingTrayIcon : defaultTrayIcon; const trayToolTip = recording - ? mainT("common", "actions.recordingStatus", { source: selectedSourceName }) + ? mainT("common", "actions.recordingStatus", { + source: selectedSourceName, + }) || `Recording: ${selectedSourceName}` : "OpenScreen"; const menuTemplate = recording ? [ diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx index 343c4cf..a99a644 100644 --- a/src/components/video-editor/SettingsPanel.tsx +++ b/src/components/video-editor/SettingsPanel.tsx @@ -1079,8 +1079,9 @@ export function SettingsPanel({ const turningOn = !cursorHighlight.onlyOnClicks; if (turningOn) { try { - const result = await window.electronAPI.requestAccessibilityAccess(); - if (!result.granted) { + const result = + await window.electronAPI?.requestAccessibilityAccess?.(); + if (!result?.granted) { toast.message( t("effects.cursorHighlight.accessibilityPermissionTitle"), { @@ -1089,6 +1090,7 @@ export function SettingsPanel({ ), }, ); + return; } } catch (err) { console.warn("Accessibility request failed:", err); diff --git a/src/i18n/config.ts b/src/i18n/config.ts index 788a315..cf0b34c 100644 --- a/src/i18n/config.ts +++ b/src/i18n/config.ts @@ -8,6 +8,7 @@ export const SUPPORTED_LOCALES = [ "tr", "ko-KR", "ja-JP", + "ar", ] as const; export const I18N_NAMESPACES = [ "common", diff --git a/src/i18n/locales/ar/settings.json b/src/i18n/locales/ar/settings.json index e21976d..2d250b1 100644 --- a/src/i18n/locales/ar/settings.json +++ b/src/i18n/locales/ar/settings.json @@ -173,7 +173,7 @@ "dialogTitle": "إضافة خط Google", "urlLabel": "رابط استيراد خطوط Google", "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap", - "urlHelp": "احصل على هذا من خطوط Google: حدد خطًا → انقر على \"Get font\" → انسخ رابط @import", + "urlHelp": "احصل على هذا من خطوط Google: حدد خطًا → انقر على \"احصل على الخط\" → انسخ رابط `@import`", "nameLabel": "اسم العرض", "namePlaceholder": "خطي المخصص", "nameHelp": "هكذا سيظهر الخط في محدد الخطوط", From 7e00cdb1a9eb9da5fb9637921fa1dc4bd6dce54a Mon Sep 17 00:00:00 2001 From: Siddharth Date: Sun, 3 May 2026 11:41:03 -0700 Subject: [PATCH 23/59] preview intentional perf optimizations --- src/components/video-editor/VideoPlayback.tsx | 31 ++++++- .../videoPlayback/videoEventHandlers.ts | 35 ++++++++ .../videoPlayback/zoomRegionUtils.ts | 87 ++++++++++++++----- 3 files changed, 131 insertions(+), 22 deletions(-) diff --git a/src/components/video-editor/VideoPlayback.tsx b/src/components/video-editor/VideoPlayback.tsx index a69c8d7..c35c0c7 100644 --- a/src/components/video-editor/VideoPlayback.tsx +++ b/src/components/video-editor/VideoPlayback.tsx @@ -232,6 +232,9 @@ const VideoPlayback = forwardRef( const maskGraphicsRef = useRef(null); const isPlayingRef = useRef(isPlaying); const isSeekingRef = useRef(false); + const isScrubbingRef = useRef(false); + const scrubEndTimerRef = useRef(null); + const [isScrubbing, setIsScrubbing] = useState(false); const allowPlaybackRef = useRef(false); const lockedVideoDimensionsRef = useRef<{ width: number; @@ -611,6 +614,24 @@ const VideoPlayback = forwardRef( }; }, [pixiReady, videoReady, layoutVideoContent]); + // Drop the PIXI canvas resolution to 1.0 while scrubbing (the user is + // navigating, not previewing) and restore native DPR on play/idle so the + // preview stays faithful. Mutating renderer.resolution per-frame would + // thrash texture uploads; we only do it on scrub-state transitions. + useEffect(() => { + if (!pixiReady) return; + const app = appRef.current; + const container = containerRef.current; + if (!app || !container) return; + + const targetResolution = isScrubbing ? 1 : window.devicePixelRatio || 1; + if (app.renderer.resolution === targetResolution) return; + + app.renderer.resolution = targetResolution; + app.renderer.resize(container.clientWidth, container.clientHeight); + layoutVideoContentRef.current?.(); + }, [isScrubbing, pixiReady]); + useEffect(() => { if (!pixiReady || !videoReady) return; updateOverlayForRegion(selectedZoom); @@ -804,6 +825,9 @@ const VideoPlayback = forwardRef( onTimeUpdate: (time) => onTimeUpdateRef.current(time), trimRegionsRef, speedRegionsRef, + isScrubbingRef, + scrubEndTimerRef, + onScrubChange: (scrubbing) => setIsScrubbing(scrubbing), }); video.addEventListener("play", handlePlay); @@ -1088,7 +1112,8 @@ const VideoPlayback = forwardRef( } } - const isMotionBlurActive = (motionBlurAmountRef.current || 0) > 0 && isPlayingRef.current; + const isMotionBlurActive = + (motionBlurAmountRef.current || 0) > 0 && isPlayingRef.current && !isScrubbingRef.current; if (isMotionBlurActive !== lastMotionBlurActive && videoContainerRef.current) { if (isMotionBlurActive) { @@ -1225,6 +1250,10 @@ const VideoPlayback = forwardRef( cancelAnimationFrame(videoReadyRafRef.current); videoReadyRafRef.current = null; } + if (scrubEndTimerRef.current !== null) { + window.clearTimeout(scrubEndTimerRef.current); + scrubEndTimerRef.current = null; + } }; }, []); diff --git a/src/components/video-editor/videoPlayback/videoEventHandlers.ts b/src/components/video-editor/videoPlayback/videoEventHandlers.ts index 5542d67..a26107d 100644 --- a/src/components/video-editor/videoPlayback/videoEventHandlers.ts +++ b/src/components/video-editor/videoPlayback/videoEventHandlers.ts @@ -1,6 +1,11 @@ import type React from "react"; import type { SpeedRegion, TrimRegion } from "../types"; +// Keep "scrub mode" on for a brief tail after `seeked` — rapid drag-scrubbing +// fires `seeking`/`seeked` dozens of times per second, and toggling effects +// each time would flicker. +const SCRUB_END_DEBOUNCE_MS = 150; + interface VideoEventHandlersParams { video: HTMLVideoElement; isSeekingRef: React.MutableRefObject; @@ -12,6 +17,9 @@ interface VideoEventHandlersParams { onTimeUpdate: (time: number) => void; trimRegionsRef: React.MutableRefObject; speedRegionsRef: React.MutableRefObject; + isScrubbingRef?: React.MutableRefObject; + scrubEndTimerRef?: React.MutableRefObject; + onScrubChange?: (scrubbing: boolean) => void; } export function createVideoEventHandlers(params: VideoEventHandlersParams) { @@ -26,8 +34,18 @@ export function createVideoEventHandlers(params: VideoEventHandlersParams) { onTimeUpdate, trimRegionsRef, speedRegionsRef, + isScrubbingRef, + scrubEndTimerRef, + onScrubChange, } = params; + const clearScrubEndTimer = () => { + if (scrubEndTimerRef && scrubEndTimerRef.current !== null) { + window.clearTimeout(scrubEndTimerRef.current); + scrubEndTimerRef.current = null; + } + }; + const emitTime = (timeValue: number) => { currentTimeRef.current = timeValue * 1000; onTimeUpdate(timeValue); @@ -113,6 +131,15 @@ export function createVideoEventHandlers(params: VideoEventHandlersParams) { const handleSeeked = () => { isSeekingRef.current = false; + if (isScrubbingRef && scrubEndTimerRef) { + clearScrubEndTimer(); + scrubEndTimerRef.current = window.setTimeout(() => { + isScrubbingRef.current = false; + scrubEndTimerRef.current = null; + onScrubChange?.(false); + }, SCRUB_END_DEBOUNCE_MS); + } + const currentTimeMs = video.currentTime * 1000; const activeTrimRegion = findActiveTrimRegion(currentTimeMs); @@ -137,6 +164,14 @@ export function createVideoEventHandlers(params: VideoEventHandlersParams) { const handleSeeking = () => { isSeekingRef.current = true; + if (isScrubbingRef) { + clearScrubEndTimer(); + if (!isScrubbingRef.current) { + isScrubbingRef.current = true; + onScrubChange?.(true); + } + } + if (!isPlayingRef.current && !video.paused) { video.pause(); } diff --git a/src/components/video-editor/videoPlayback/zoomRegionUtils.ts b/src/components/video-editor/videoPlayback/zoomRegionUtils.ts index e5c16e1..ce31e0e 100644 --- a/src/components/video-editor/videoPlayback/zoomRegionUtils.ts +++ b/src/components/video-editor/videoPlayback/zoomRegionUtils.ts @@ -254,34 +254,79 @@ function getConnectedRegionTransition( return null; } -export function findDominantRegion( - regions: ZoomRegion[], - timeMs: number, - options: DominantRegionOptions = {}, -): { +type DominantRegionResult = { region: ZoomRegion | null; strength: number; blendedScale: number | null; transition: ConnectedPanTransition | null; -} { - const connectedPairs = options.connectZooms ? getConnectedRegionPairs(regions) : []; +}; + +// Single-slot cache: the ticker calls findDominantRegion at 60fps with mostly +// unchanged inputs (especially while paused). Reusing the previous result when +// inputs match avoids the per-frame O(N) region scan + allocations. +let dominantRegionCache: { + regions: ZoomRegion[]; + timeMsKey: number; + telemetry: CursorTelemetryPoint[] | undefined; + connectZooms: boolean; + viewportRatio: ViewportRatio | undefined; + result: DominantRegionResult; +} | null = null; + +export function findDominantRegion( + regions: ZoomRegion[], + timeMs: number, + options: DominantRegionOptions = {}, +): DominantRegionResult { + const connectZooms = !!options.connectZooms; const telemetry = options.cursorTelemetry; const vr = options.viewportRatio; + const timeMsKey = Math.round(timeMs); - if (options.connectZooms) { - const connectedTransition = getConnectedRegionTransition(connectedPairs, timeMs, telemetry, vr); - if (connectedTransition) { - return connectedTransition; - } - - const connectedHold = getConnectedRegionHold(timeMs, connectedPairs, telemetry, vr); - if (connectedHold) { - return { ...connectedHold, transition: null }; - } + if ( + dominantRegionCache && + dominantRegionCache.regions === regions && + dominantRegionCache.timeMsKey === timeMsKey && + dominantRegionCache.telemetry === telemetry && + dominantRegionCache.connectZooms === connectZooms && + dominantRegionCache.viewportRatio === vr + ) { + return dominantRegionCache.result; } - const activeRegion = getActiveRegion(regions, timeMs, connectedPairs, telemetry, vr); - return activeRegion - ? { ...activeRegion, transition: null } - : { region: null, strength: 0, blendedScale: null, transition: null }; + const connectedPairs = connectZooms ? getConnectedRegionPairs(regions) : []; + + let result: DominantRegionResult; + if (connectZooms) { + const connectedTransition = getConnectedRegionTransition(connectedPairs, timeMs, telemetry, vr); + if (connectedTransition) { + result = connectedTransition; + } else { + const connectedHold = getConnectedRegionHold(timeMs, connectedPairs, telemetry, vr); + if (connectedHold) { + result = { ...connectedHold, transition: null }; + } else { + const activeRegion = getActiveRegion(regions, timeMs, connectedPairs, telemetry, vr); + result = activeRegion + ? { ...activeRegion, transition: null } + : { region: null, strength: 0, blendedScale: null, transition: null }; + } + } + } else { + const activeRegion = getActiveRegion(regions, timeMs, connectedPairs, telemetry, vr); + result = activeRegion + ? { ...activeRegion, transition: null } + : { region: null, strength: 0, blendedScale: null, transition: null }; + } + + dominantRegionCache = { + regions, + timeMsKey, + telemetry, + connectZooms, + viewportRatio: vr, + result, + }; + + return result; } From 6fc19314ddfc3619c04090ead56fe0890857c3dd Mon Sep 17 00:00:00 2001 From: Siddharth Date: Sun, 3 May 2026 12:03:23 -0700 Subject: [PATCH 24/59] fix dock macos lifecycle --- electron/main.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/electron/main.ts b/electron/main.ts index 1da3603..0b90b89 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -352,10 +352,11 @@ function createCountdownOverlayWindowWrapper() { return countdownOverlayWindow; } -// On macOS, applications and their menu bar stay active until the user quits -// explicitly with Cmd + Q. +// Closing every window quits the app entirely (tray icon goes too). +// The in-app "Return to Recorder" button covers the editor → HUD round-trip, +// so closing the last window is an explicit "I'm done" signal. app.on("window-all-closed", () => { - // Keep app running (macOS behavior) + app.quit(); }); app.on("activate", () => { @@ -377,6 +378,13 @@ app.on("activate", () => { // Register all IPC handlers when app is ready app.whenReady().then(async () => { + // Force the app into "regular" activation policy so the Dock icon appears. + // The HUD overlay (transparent + frameless + skipTaskbar) is the first + // window we open, and AppKit otherwise classifies us as an accessory app. + if (process.platform === "darwin") { + app.dock?.show(); + } + // Allow microphone/media permission checks session.defaultSession.setPermissionCheckHandler((_webContents, permission) => { const allowed = ["media", "audioCapture", "microphone", "videoCapture", "camera"]; From 42c596da6642b52dcd141c67d39acb4964206287 Mon Sep 17 00:00:00 2001 From: Yusuf Mohsinally <463376+yusufm@users.noreply.github.com> Date: Sun, 3 May 2026 14:20:43 -0700 Subject: [PATCH 25/59] Lazy load the editor bundle --- src/App.tsx | 17 ++++++++++++----- src/hooks/useScreenRecorder.ts | 18 +++++++++--------- 2 files changed, 21 insertions(+), 14 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index f5fa7d6..6f737b9 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,14 +1,19 @@ -import { useEffect, useState } from "react"; +import { lazy, Suspense, useEffect, useState } from "react"; import { CountdownOverlay } from "./components/launch/CountdownOverlay.tsx"; import { LaunchWindow } from "./components/launch/LaunchWindow"; import { SourceSelector } from "./components/launch/SourceSelector"; import { Toaster } from "./components/ui/sonner"; import { TooltipProvider } from "./components/ui/tooltip"; -import { ShortcutsConfigDialog } from "./components/video-editor/ShortcutsConfigDialog"; -import VideoEditor from "./components/video-editor/VideoEditor"; import { ShortcutsProvider } from "./contexts/ShortcutsContext"; import { loadAllCustomFonts } from "./lib/customFonts"; +const VideoEditor = lazy(() => import("./components/video-editor/VideoEditor")); +const ShortcutsConfigDialog = lazy(() => + import("./components/video-editor/ShortcutsConfigDialog").then((module) => ({ + default: module.ShortcutsConfigDialog, + })), +); + export default function App() { const [windowType, setWindowType] = useState( () => new URLSearchParams(window.location.search).get("windowType") || "", @@ -59,8 +64,10 @@ export default function App() { case "editor": return ( - - + }> + + + ); default: diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index dc9758f..f14be62 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -408,6 +408,14 @@ export function useScreenRecorder(): UseScreenRecorderReturn { } }); + const safeHideCountdownOverlay = useCallback(async (runId: number) => { + try { + await window.electronAPI.hideCountdownOverlay(runId); + } catch (error) { + console.warn("Failed to hide countdown overlay:", error); + } + }, []); + useEffect(() => { let cleanup: (() => void) | undefined; @@ -450,7 +458,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { webcamRecorder.current = null; teardownMedia(); }; - }, [teardownMedia]); + }, [teardownMedia, safeHideCountdownOverlay]); const safeShowCountdownOverlay = async (value: number, runId: number) => { try { @@ -477,14 +485,6 @@ export function useScreenRecorder(): UseScreenRecorderReturn { } }; - const safeHideCountdownOverlay = async (runId: number) => { - try { - await window.electronAPI.hideCountdownOverlay(runId); - } catch (error) { - console.warn("Failed to hide countdown overlay:", error); - } - }; - const isCountdownRunActive = (runId?: number) => runId === undefined || countdownRunId.current === runId; From 190d5d8ecb2006c355c4c6b907599784b375bf52 Mon Sep 17 00:00:00 2001 From: Siddharth Date: Sun, 3 May 2026 17:54:21 -0700 Subject: [PATCH 26/59] 3d iso,tilt --- src/components/video-editor/SettingsPanel.tsx | 42 +- src/components/video-editor/VideoEditor.tsx | 24 + src/components/video-editor/VideoPlayback.tsx | 421 ++++++++++-------- .../video-editor/projectPersistence.ts | 7 + src/components/video-editor/types.ts | 129 ++++++ .../videoPlayback/zoomRegionUtils.ts | 29 +- src/i18n/locales/en/settings.json | 8 + src/lib/exporter/frameRenderer.ts | 185 ++++++-- src/lib/exporter/threeDPass.ts | 356 +++++++++++++++ 9 files changed, 979 insertions(+), 222 deletions(-) create mode 100644 src/lib/exporter/threeDPass.ts diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx index 5cac573..1ffa0f4 100644 --- a/src/components/video-editor/SettingsPanel.tsx +++ b/src/components/video-editor/SettingsPanel.tsx @@ -54,13 +54,19 @@ import type { CropRegion, FigureData, PlaybackSpeed, + Rotation3DPreset, WebcamLayoutPreset, WebcamMaskShape, WebcamSizePreset, ZoomDepth, ZoomFocusMode, } from "./types"; -import { DEFAULT_WEBCAM_SIZE_PRESET, MAX_PLAYBACK_SPEED, SPEED_OPTIONS } from "./types"; +import { + DEFAULT_WEBCAM_SIZE_PRESET, + MAX_PLAYBACK_SPEED, + ROTATION_3D_PRESET_ORDER, + SPEED_OPTIONS, +} from "./types"; function CustomSpeedInput({ value, @@ -168,6 +174,8 @@ interface SettingsPanelProps { hasCursorTelemetry?: boolean; selectedZoomId?: string | null; onZoomDelete?: (id: string) => void; + selectedZoomRotationPreset?: Rotation3DPreset | null; + onZoomRotationPresetChange?: (preset: Rotation3DPreset | null) => void; selectedTrimId?: string | null; onTrimDelete?: (id: string) => void; shadowIntensity?: number; @@ -258,6 +266,8 @@ export function SettingsPanel({ hasCursorTelemetry = false, selectedZoomId, onZoomDelete, + selectedZoomRotationPreset, + onZoomRotationPresetChange, selectedTrimId, onTrimDelete, shadowIntensity = 0, @@ -647,6 +657,36 @@ export function SettingsPanel({ )}
)} + {zoomEnabled && ( +
+ + {t("zoom.threeD.title")} + +
+ {ROTATION_3D_PRESET_ORDER.map((preset) => { + const isActive = selectedZoomRotationPreset === preset; + return ( + + ); + })} +
+
+ )} + {zoomEnabled && ( + {onSaveDiagnostic && ( + + )}
From f47fa6bdca465de34d67bf2105e3b7e918e5a5f7 Mon Sep 17 00:00:00 2001 From: Trivenzaa-Admin Date: Fri, 8 May 2026 01:48:52 -0700 Subject: [PATCH 37/59] fix(macos): add NSScreenCaptureUsageDescription and screen-capture entitlement Without NSScreenCaptureUsageDescription in Info.plist, macOS silently blocks desktopCapturer.getSources(), breaking window detection on macOS 10.15+. Also adds the com.apple.security.device.screen-capture entitlement to macos.entitlements alongside the existing camera and audio-input entries. Fixes #548 --- electron-builder.json5 | 1 + macos.entitlements | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/electron-builder.json5 b/electron-builder.json5 index ad6cd18..d9fee6b 100644 --- a/electron-builder.json5 +++ b/electron-builder.json5 @@ -51,6 +51,7 @@ "NSAudioCaptureUsageDescription": "OpenScreen needs audio capture permission to record system audio.", "NSMicrophoneUsageDescription": "OpenScreen needs microphone access to record voice audio.", "NSCameraUsageDescription": "OpenScreen needs camera access to record webcam video.", + "NSScreenCaptureUsageDescription": "OpenScreen needs screen recording permission to detect and capture windows.", "NSCameraUseContinuityCameraDeviceType": true, "com.apple.security.device.audio-input": true } diff --git a/macos.entitlements b/macos.entitlements index 5c6ddcf..38d8b29 100644 --- a/macos.entitlements +++ b/macos.entitlements @@ -21,5 +21,9 @@ com.apple.security.device.camera + + + com.apple.security.device.screen-capture + From 37215531c2484acb06dc39f35dec5af6a85b910c Mon Sep 17 00:00:00 2001 From: makaradam Date: Sat, 2 May 2026 10:24:04 +0200 Subject: [PATCH 38/59] feat: add custom zoom slider with continuous scale control (#513) Adds a Radix UI slider below the zoom preset buttons allowing any scale between 1.0x and 5.0x. When the slider value matches a preset exactly, that preset button also shows as active. - Add `customScale?: number` to `ZoomRegion` and `getZoomScale()` helper that returns customScale when set, falling back to ZOOM_DEPTH_SCALES[depth] - Overlay indicator, playback renderer, and frame exporter all use getZoomScale() so preview, playback, and export are consistent - Fix focus clamping in zoomRegionUtils and frameRenderer to use actual scale instead of depth-based preset scale, preventing zoom drift with custom values - Fix drag boundary in VideoPlayback to use clampFocusToScale with the actual scale so the full canvas is clickable at high custom zoom levels - Timeline item label shows custom scale value when set - Slider styled dark with green thumb/fill when a custom (non-preset) value is active Co-Authored-By: Claude Sonnet 4.6 --- src/components/video-editor/SettingsPanel.tsx | 78 ++++++++++++++++++- src/components/video-editor/VideoEditor.tsx | 26 +++++++ src/components/video-editor/VideoPlayback.tsx | 13 +--- src/components/video-editor/timeline/Item.tsx | 6 +- .../video-editor/timeline/TimelineEditor.tsx | 3 + src/components/video-editor/types.ts | 10 +++ .../videoPlayback/overlayUtils.ts | 11 +-- .../videoPlayback/zoomRegionUtils.ts | 10 +-- src/i18n/locales/en/settings.json | 1 + src/lib/exporter/frameRenderer.ts | 17 +--- 10 files changed, 138 insertions(+), 37 deletions(-) diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx index 377cbbe..36fa255 100644 --- a/src/components/video-editor/SettingsPanel.tsx +++ b/src/components/video-editor/SettingsPanel.tsx @@ -1,3 +1,4 @@ +import * as SliderPrimitive from "@radix-ui/react-slider"; import { Bug, ChevronDown, @@ -65,8 +66,11 @@ import type { import { DEFAULT_WEBCAM_SIZE_PRESET, MAX_PLAYBACK_SPEED, + MAX_ZOOM_SCALE, + MIN_ZOOM_SCALE, ROTATION_3D_PRESET_ORDER, SPEED_OPTIONS, + ZOOM_DEPTH_SCALES, } from "./types"; function CustomSpeedInput({ @@ -170,6 +174,9 @@ interface SettingsPanelProps { onWallpaperChange: (path: string) => void; selectedZoomDepth?: ZoomDepth | null; onZoomDepthChange?: (depth: ZoomDepth) => void; + selectedZoomCustomScale?: number | null; + onZoomCustomScaleChange?: (scale: number) => void; + onZoomCustomScaleCommit?: () => void; selectedZoomFocusMode?: ZoomFocusMode | null; onZoomFocusModeChange?: (mode: ZoomFocusMode) => void; hasCursorTelemetry?: boolean; @@ -263,6 +270,9 @@ export function SettingsPanel({ onWallpaperChange, selectedZoomDepth, onZoomDepthChange, + selectedZoomCustomScale, + onZoomCustomScaleChange, + onZoomCustomScaleCommit, selectedZoomFocusMode, onZoomFocusModeChange, hasCursorTelemetry = false, @@ -593,7 +603,9 @@ export function SettingsPanel({
{zoomEnabled && selectedZoomDepth && ( - {ZOOM_DEPTH_OPTIONS.find((o) => o.depth === selectedZoomDepth)?.label} + {selectedZoomCustomScale != null + ? `${selectedZoomCustomScale.toFixed(2)}×` + : ZOOM_DEPTH_OPTIONS.find((o) => o.depth === selectedZoomDepth)?.label} )} @@ -601,7 +613,10 @@ export function SettingsPanel({
{ZOOM_DEPTH_OPTIONS.map((option) => { - const isActive = selectedZoomDepth === option.depth; + const effectiveScale = + selectedZoomCustomScale ?? + (selectedZoomDepth != null ? ZOOM_DEPTH_SCALES[selectedZoomDepth] : null); + const isActive = effectiveScale === ZOOM_DEPTH_SCALES[option.depth]; return (
)} + {zoomEnabled && + selectedZoomFocusMode !== "auto" && + selectedZoomFocus && + onZoomFocusCoordinateChange && + (() => { + const effectiveZoomScale = + selectedZoomCustomScale ?? + (selectedZoomDepth != null ? ZOOM_DEPTH_SCALES[selectedZoomDepth] : MIN_ZOOM_SCALE); + const bounds = getFocusBoundsForScale(effectiveZoomScale); + const xRange = bounds.maxX - bounds.minX; + const yRange = bounds.maxY - bounds.minY; + const focusToPercentX = (cx: number) => + xRange <= 0 ? 50 : Math.max(0, Math.min(100, ((cx - bounds.minX) / xRange) * 100)); + const focusToPercentY = (cy: number) => + yRange <= 0 ? 50 : Math.max(0, Math.min(100, ((cy - bounds.minY) / yRange) * 100)); + const percentToFocusX = (p: number) => + xRange <= 0 ? bounds.minX : bounds.minX + (p / 100) * xRange; + const percentToFocusY = (p: number) => + yRange <= 0 ? bounds.minY : bounds.minY + (p / 100) * yRange; + return ( +
+ + {t("zoom.position.title")} + +
+
+ + + onZoomFocusCoordinateChange({ + cx: percentToFocusX(p), + cy: selectedZoomFocus.cy, + }) + } + onCommit={onZoomFocusCoordinateCommit} + /> +
+
+ + + onZoomFocusCoordinateChange({ + cx: selectedZoomFocus.cx, + cy: percentToFocusY(p), + }) + } + onCommit={onZoomFocusCoordinateCommit} + /> +
+ + {t("zoom.position.hint")} + +
+
+ ); + })()} {zoomEnabled && (
diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 2e04a83..12832ad 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -2102,6 +2102,15 @@ export default function VideoEditor() { : null } onZoomFocusModeChange={(mode) => selectedZoomId && handleZoomFocusModeChange(mode)} + selectedZoomFocus={ + selectedZoomId + ? (zoomRegions.find((z) => z.id === selectedZoomId)?.focus ?? null) + : null + } + onZoomFocusCoordinateChange={(focus) => + selectedZoomId && handleZoomFocusChange(selectedZoomId, focus) + } + onZoomFocusCoordinateCommit={commitState} hasCursorTelemetry={cursorTelemetry.length > 0} selectedZoomId={selectedZoomId} onZoomDelete={handleZoomDelete} diff --git a/src/components/video-editor/videoPlayback/focusUtils.ts b/src/components/video-editor/videoPlayback/focusUtils.ts index f893935..a0973ec 100644 --- a/src/components/video-editor/videoPlayback/focusUtils.ts +++ b/src/components/video-editor/videoPlayback/focusUtils.ts @@ -44,7 +44,7 @@ interface ViewportRatio { heightRatio: number; } -function getFocusBoundsForScale(zoomScale: number, viewportRatio?: ViewportRatio) { +export 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)); diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json index 6620b75..3ec0819 100644 --- a/src/i18n/locales/en/settings.json +++ b/src/i18n/locales/en/settings.json @@ -17,6 +17,12 @@ "left": "Left", "right": "Right" } + }, + "position": { + "title": "Focus Position", + "x": "X (%)", + "y": "Y (%)", + "hint": "0 = leftmost / topmost, 100 = rightmost / bottommost" } }, "speed": { From 3ad3e22a16c279ea0362f17adda7b96abfd22632 Mon Sep 17 00:00:00 2001 From: Siddharth Date: Sat, 9 May 2026 14:43:56 -0700 Subject: [PATCH 49/59] test(i18n): add vi to tutorialHelpTranslations locale map --- src/i18n/__tests__/tutorialHelpTranslations.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/i18n/__tests__/tutorialHelpTranslations.test.ts b/src/i18n/__tests__/tutorialHelpTranslations.test.ts index 2b2f3f2..b029570 100644 --- a/src/i18n/__tests__/tutorialHelpTranslations.test.ts +++ b/src/i18n/__tests__/tutorialHelpTranslations.test.ts @@ -8,6 +8,7 @@ import jaJPDialogs from "@/i18n/locales/ja-JP/dialogs.json"; import koKRDialogs from "@/i18n/locales/ko-KR/dialogs.json"; import ruDialogs from "@/i18n/locales/ru/dialogs.json"; import trDialogs from "@/i18n/locales/tr/dialogs.json"; +import viDialogs from "@/i18n/locales/vi/dialogs.json"; import zhCNDialogs from "@/i18n/locales/zh-CN/dialogs.json"; import zhTWDialogs from "@/i18n/locales/zh-TW/dialogs.json"; @@ -47,6 +48,7 @@ const dialogsByLocale = { ru: ruDialogs, "ja-JP": jaJPDialogs, ar: arDialogs, + vi: viDialogs, } satisfies Record }>; describe("TutorialHelp translations", () => { From 4a0878c3d07a5a6f2c6fd78facaa191b7c7c6334 Mon Sep 17 00:00:00 2001 From: Siddharth Date: Sat, 9 May 2026 16:03:50 -0700 Subject: [PATCH 50/59] add homebrew cask bump workflow Auto-updates the openscreen Homebrew tap on each published release: finds the macOS DMGs, computes sha256, and rewrites Casks/openscreen.rb in siddharthvaddem/homebrew-openscreen. Requires HOMEBREW_TAP_TOKEN secret with contents:write on the tap repo. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/update-homebrew-cask.yml | 162 +++++++++++++++++++++ 1 file changed, 162 insertions(+) create mode 100644 .github/workflows/update-homebrew-cask.yml diff --git a/.github/workflows/update-homebrew-cask.yml b/.github/workflows/update-homebrew-cask.yml new file mode 100644 index 0000000..f75b39e --- /dev/null +++ b/.github/workflows/update-homebrew-cask.yml @@ -0,0 +1,162 @@ +name: Update Homebrew Cask + +on: + release: + types: [published] + workflow_dispatch: + inputs: + tag: + description: "Release tag to publish to the tap (e.g. v1.4.0)" + required: true + type: string + +permissions: + contents: read + +jobs: + update-cask: + runs-on: ubuntu-latest + if: github.event_name == 'workflow_dispatch' || !github.event.release.prerelease + env: + TAP_OWNER: siddharthvaddem + TAP_REPO: homebrew-openscreen + CASK_NAME: openscreen + steps: + - name: Resolve tag and version + id: meta + env: + GH_EVENT_TAG: ${{ github.event.release.tag_name }} + INPUT_TAG: ${{ inputs.tag }} + run: | + set -euo pipefail + TAG="${GH_EVENT_TAG:-$INPUT_TAG}" + if [[ -z "$TAG" ]]; then + echo "::error::No tag resolved from release event or workflow input" + exit 1 + fi + VERSION="${TAG#v}" + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + - name: Find macOS DMG assets + id: assets + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ steps.meta.outputs.tag }} + REPO: ${{ github.repository }} + run: | + set -euo pipefail + + NAMES=$(gh release view "$TAG" --repo "$REPO" --json assets --jq '.assets[].name') + + # arm64 DMG: explicit "arm64" / "apple silicon" / fallback to any .dmg + # whose name does NOT contain "x64" or non-mac platform markers. + ARM_NAME=$(echo "$NAMES" | grep -iE '\.dmg$' \ + | grep -iE '(arm64|apple[-_. ]?silicon)' | head -n1 || true) + if [[ -z "$ARM_NAME" ]]; then + ARM_NAME=$(echo "$NAMES" | grep -iE '\.dmg$' \ + | grep -iv 'x64' | grep -iv 'linux' | grep -iv 'win' | head -n1 || true) + fi + + # x64 DMG + X64_NAME=$(echo "$NAMES" | grep -iE '\.dmg$' \ + | grep -iE '(x64|x86[-_]?64|intel)' | head -n1 || true) + + if [[ -z "$ARM_NAME" || -z "$X64_NAME" ]]; then + echo "::error::Could not locate both arm64 and x64 DMGs in release assets" + echo "Available assets:" + echo "$NAMES" + exit 1 + fi + + echo "arm_name=$ARM_NAME" >> "$GITHUB_OUTPUT" + echo "x64_name=$X64_NAME" >> "$GITHUB_OUTPUT" + echo "Found arm64 asset: $ARM_NAME" + echo "Found x64 asset: $X64_NAME" + + - name: Download DMGs and compute sha256 + id: shas + env: + REPO: ${{ github.repository }} + TAG: ${{ steps.meta.outputs.tag }} + ARM_NAME: ${{ steps.assets.outputs.arm_name }} + X64_NAME: ${{ steps.assets.outputs.x64_name }} + run: | + set -euo pipefail + BASE="https://github.com/${REPO}/releases/download/${TAG}" + curl -fsSL --retry 3 -o /tmp/arm.dmg "${BASE}/${ARM_NAME}" + curl -fsSL --retry 3 -o /tmp/x64.dmg "${BASE}/${X64_NAME}" + ARM_SHA=$(sha256sum /tmp/arm.dmg | awk '{print $1}') + X64_SHA=$(sha256sum /tmp/x64.dmg | awk '{print $1}') + echo "arm_sha=$ARM_SHA" >> "$GITHUB_OUTPUT" + echo "x64_sha=$X64_SHA" >> "$GITHUB_OUTPUT" + + - name: Checkout tap + uses: actions/checkout@v4 + with: + repository: ${{ env.TAP_OWNER }}/${{ env.TAP_REPO }} + token: ${{ secrets.HOMEBREW_TAP_TOKEN }} + path: tap + + - name: Write cask file + env: + REPO: ${{ github.repository }} + TAG: ${{ steps.meta.outputs.tag }} + VERSION: ${{ steps.meta.outputs.version }} + ARM_NAME: ${{ steps.assets.outputs.arm_name }} + X64_NAME: ${{ steps.assets.outputs.x64_name }} + ARM_SHA: ${{ steps.shas.outputs.arm_sha }} + X64_SHA: ${{ steps.shas.outputs.x64_sha }} + run: | + set -euo pipefail + mkdir -p tap/Casks + BASE="https://github.com/${REPO}/releases/download/${TAG}" + + cat > "tap/Casks/${CASK_NAME}.rb" <= :big_sur" + + app "Openscreen.app" + + zap trash: [ + "~/Library/Application Support/Openscreen", + "~/Library/Preferences/com.siddharthvaddem.openscreen.plist", + "~/Library/Logs/Openscreen", + "~/Library/Caches/com.siddharthvaddem.openscreen", + "~/Library/Saved Application State/com.siddharthvaddem.openscreen.savedState", + ] + end + EOF + + - name: Commit and push to tap + working-directory: tap + env: + VERSION: ${{ steps.meta.outputs.version }} + run: | + set -euo pipefail + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add "Casks/${CASK_NAME}.rb" + if git diff --cached --quiet; then + echo "Cask already up to date for ${VERSION} — nothing to commit." + exit 0 + fi + git commit -m "Bump ${CASK_NAME} to ${VERSION}" + git push From f42c478725764c12346c031628f093884ed2668e Mon Sep 17 00:00:00 2001 From: Siddharth Date: Sat, 9 May 2026 16:22:58 -0700 Subject: [PATCH 51/59] fix homebrew cask audit warnings - Use #{version} interpolation in URLs so brew detects them as versioned (silences "Use sha256 :no_check when URL is unversioned"). - Drop blank line between on_arm and on_intel (same stanza group). - Alphabetize zap trash array. - Add verified: stanza for the GitHub release URL. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/update-homebrew-cask.yml | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/.github/workflows/update-homebrew-cask.yml b/.github/workflows/update-homebrew-cask.yml index f75b39e..8beebff 100644 --- a/.github/workflows/update-homebrew-cask.yml +++ b/.github/workflows/update-homebrew-cask.yml @@ -112,18 +112,24 @@ jobs: mkdir -p tap/Casks BASE="https://github.com/${REPO}/releases/download/${TAG}" + # #{version} is Ruby interpolation written literally to the cask + # file (bash heredoc leaves "#{...}" alone). \${VERSION}, \${ARM_SHA}, + # etc. are bash variables expanded by the heredoc. The literal + # #{version} fixes Homebrew's "URL is unversioned" audit warning by + # making the version string statically detectable. cat > "tap/Casks/${CASK_NAME}.rb" < Date: Sat, 9 May 2026 16:28:15 -0700 Subject: [PATCH 52/59] fix: final homebrew cask style + audit cleanup - Drop unnecessary verified: stanza (URL host matches homepage host). - Add blank line between sha256 and url inside on_arm/on_intel (rubocop treats them as separate stanza groups). - Keep no blank line between on_arm and on_intel blocks (same outer stanza group). After re-running the bump workflow, the cask passes both brew audit --cask and brew style --cask cleanly. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/update-homebrew-cask.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/update-homebrew-cask.yml b/.github/workflows/update-homebrew-cask.yml index 8beebff..3d65cb0 100644 --- a/.github/workflows/update-homebrew-cask.yml +++ b/.github/workflows/update-homebrew-cask.yml @@ -123,13 +123,13 @@ jobs: on_arm do sha256 "${ARM_SHA}" - url "https://github.com/${REPO}/releases/download/v#{version}/${ARM_NAME}", - verified: "github.com/${REPO}/" + + url "https://github.com/${REPO}/releases/download/v#{version}/${ARM_NAME}" end on_intel do sha256 "${X64_SHA}" - url "https://github.com/${REPO}/releases/download/v#{version}/${X64_NAME}", - verified: "github.com/${REPO}/" + + url "https://github.com/${REPO}/releases/download/v#{version}/${X64_NAME}" end name "Openscreen" From b48370e3d0dce5b12a7909e8413dbbbd34b0e841 Mon Sep 17 00:00:00 2001 From: Siddharth Date: Sat, 9 May 2026 16:34:02 -0700 Subject: [PATCH 53/59] update readme w brew --- README.md | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 9ed0d1a..295db81 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,20 @@ Download the latest installer for your platform from the [GitHub Releases](https ### macOS -If you encounter issues with macOS Gatekeeper blocking the app (since it does not come with a developer certificate), you can bypass this by running the following command in your terminal after installation: +The easiest way to install on macOS is via [Homebrew](https://brew.sh): + +```bash +brew install --cask siddharthvaddem/openscreen/openscreen +``` + +Brew automatically picks the right build for Apple Silicon or Intel, and verifies the download against a notarized signature so Gatekeeper won't block it. + +To update later: `brew upgrade --cask openscreen` +To uninstall: `brew uninstall --cask openscreen` (add `--zap` to also remove app data) + +#### Manual install (if you prefer) + +If you'd rather grab the `.dmg` directly from the [Releases page](https://github.com/siddharthvaddem/openscreen/releases) and encounter Gatekeeper blocking the app, you can bypass it by running the following command in your terminal after installation: ```bash xattr -rd com.apple.quarantine /Applications/Openscreen.app From ed825d8b37d94b748bba5480ca6425ff50261e71 Mon Sep 17 00:00:00 2001 From: Siddharth Date: Sat, 9 May 2026 16:39:31 -0700 Subject: [PATCH 54/59] add winget-releaser workflow Auto-publishes new releases to winget via vedantmgoyal9/winget-releaser. On every "released" event (not pre-release), the action opens a PR against microsoft/winget-pkgs bumping SiddharthVaddem.OpenScreen. Requires: - WINGET_ACC_TOKEN secret: classic PAT with public_repo scope (fine-grained PATs are NOT supported by the action). - A fork of microsoft/winget-pkgs under siddharthvaddem (or pass fork-user if forked elsewhere). Closes #299 Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/publish-winget.yml | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 .github/workflows/publish-winget.yml diff --git a/.github/workflows/publish-winget.yml b/.github/workflows/publish-winget.yml new file mode 100644 index 0000000..62b4b7a --- /dev/null +++ b/.github/workflows/publish-winget.yml @@ -0,0 +1,26 @@ +name: Publish release to WinGet + +on: + release: + types: [released] + workflow_dispatch: + inputs: + tag: + description: "Release tag to publish to winget (e.g. v1.4.0)" + required: true + type: string + +jobs: + publish: + runs-on: windows-latest + if: github.event_name == 'workflow_dispatch' || !github.event.release.prerelease + steps: + - uses: vedantmgoyal9/winget-releaser@v2 + with: + identifier: SiddharthVaddem.OpenScreen + # Match the Windows installer asset attached to each release. + # Today: "Openscreen.Setup.latest.exe". Adjust this regex if you + # ever rename the installer to include a version (e.g. "Setup\.\d+\.\d+\.\d+\.exe"). + installers-regex: 'Setup\..*\.exe$' + release-tag: ${{ inputs.tag || github.event.release.tag_name }} + token: ${{ secrets.WINGET_ACC_TOKEN }} From 7feb05cca7e9f84be316cc9e0a7be6d6bbe65467 Mon Sep 17 00:00:00 2001 From: Siddharth Date: Sat, 9 May 2026 16:58:51 -0700 Subject: [PATCH 55/59] add nix package auto-bump workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On every published GitHub Release, opens a PR bumping nix/package.nix: - version => the new release version - npmDepsHash => freshly computed via prefetch-npm-deps package-lock.json Mirrors the brew + winget release-bump pattern, but lands the change in this repo (not a separate tap), so it opens a PR instead of pushing directly. Uses GITHUB_TOKEN — note that PRs created by GITHUB_TOKEN do not auto-trigger CI; the diff is two lines, easy to review and merge. Refs the long-standing manual-bump pain (e.g. PR #504 fixing a stale hash). After this lands, Nix users get new releases without anyone having to remember the manual edit. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/bump-nix-package.yml | 118 +++++++++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 .github/workflows/bump-nix-package.yml diff --git a/.github/workflows/bump-nix-package.yml b/.github/workflows/bump-nix-package.yml new file mode 100644 index 0000000..5ff3c73 --- /dev/null +++ b/.github/workflows/bump-nix-package.yml @@ -0,0 +1,118 @@ +name: Bump Nix package on release + +on: + release: + types: [published] + workflow_dispatch: + inputs: + tag: + description: "Release tag to bump (e.g. v1.5.0)" + required: true + type: string + +permissions: + contents: write + pull-requests: write + +jobs: + bump: + runs-on: ubuntu-latest + if: github.event_name == 'workflow_dispatch' || !github.event.release.prerelease + steps: + - name: Resolve tag and version + id: meta + env: + GH_EVENT_TAG: ${{ github.event.release.tag_name }} + INPUT_TAG: ${{ inputs.tag }} + run: | + set -euo pipefail + TAG="${GH_EVENT_TAG:-$INPUT_TAG}" + if [[ -z "$TAG" ]]; then + echo "::error::No tag resolved from release event or workflow input" + exit 1 + fi + VERSION="${TAG#v}" + BRANCH="chore/bump-nix-${VERSION}" + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "branch=$BRANCH" >> "$GITHUB_OUTPUT" + + - name: Checkout main + uses: actions/checkout@v4 + with: + ref: main + fetch-depth: 0 + + - name: Install Nix + uses: cachix/install-nix-action@v27 + with: + nix_path: nixpkgs=channel:nixos-unstable + extra_nix_config: | + experimental-features = nix-command flakes + + - name: Compute npmDepsHash + id: hash + run: | + set -euo pipefail + HASH=$(nix run nixpkgs#prefetch-npm-deps -- package-lock.json) + if [[ -z "$HASH" ]]; then + echo "::error::prefetch-npm-deps returned an empty hash" + exit 1 + fi + echo "hash=$HASH" >> "$GITHUB_OUTPUT" + echo "Computed npmDepsHash: $HASH" + + - name: Update nix/package.nix + env: + VERSION: ${{ steps.meta.outputs.version }} + HASH: ${{ steps.hash.outputs.hash }} + run: | + set -euo pipefail + # Update version line: ` version = "";` + sed -i -E "s|^([[:space:]]*version[[:space:]]*=[[:space:]]*)\"[^\"]*\";|\1\"${VERSION}\";|" nix/package.nix + # Update npmDepsHash line: ` npmDepsHash = "";` + sed -i -E "s|^([[:space:]]*npmDepsHash[[:space:]]*=[[:space:]]*)\"[^\"]*\";|\1\"${HASH}\";|" nix/package.nix + + echo "=== diff ===" + git --no-pager diff nix/package.nix || true + + - name: Create PR + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VERSION: ${{ steps.meta.outputs.version }} + HASH: ${{ steps.hash.outputs.hash }} + BRANCH: ${{ steps.meta.outputs.branch }} + TAG: ${{ steps.meta.outputs.tag }} + run: | + set -euo pipefail + + if git diff --quiet -- nix/package.nix; then + echo "nix/package.nix already at v${VERSION} with this hash — nothing to do." + exit 0 + fi + + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + # Replace any prior bump branch to keep the workflow idempotent. + git push origin --delete "$BRANCH" 2>/dev/null || true + git checkout -b "$BRANCH" + git add nix/package.nix + git commit -m "chore: bump nix package to v${VERSION}" + git push -u origin "$BRANCH" + + gh pr create \ + --title "chore: bump nix package to v${VERSION}" \ + --base main \ + --head "$BRANCH" \ + --body "$(cat < Note: PRs opened by \`GITHUB_TOKEN\` don't auto-trigger CI. The diff is two lines — review the change here, then merge. If you want CI to run, push an empty commit to this branch or close-and-reopen the PR. + EOF + )" From 2ae7aca1853fe154f0f14beaacac75fa68eb8419 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 10 May 2026 00:04:08 +0000 Subject: [PATCH 56/59] chore: bump nix package to v1.4.0 --- nix/package.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nix/package.nix b/nix/package.nix index c0f582f..33dc4f7 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -33,7 +33,7 @@ buildNpmPackage { ); }; - npmDepsHash = "sha256-i8QMhvd/ydFPww7qTG3Bz2LOAIFyp65n1NXakr3MTk8="; + npmDepsHash = "sha256-tOpoJPzaZDK3HJijGHpZ0+jWsbrYyQUuw1pO0Uxcifg="; env.ELECTRON_SKIP_BINARY_DOWNLOAD = "1"; From 52cb709a884686f0b2c96a77c5bcaa5a724d38fd Mon Sep 17 00:00:00 2001 From: Siddharth Date: Sat, 9 May 2026 17:12:27 -0700 Subject: [PATCH 57/59] readme update --- README.md | 58 +++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 56 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 295db81..7dd331b 100644 --- a/README.md +++ b/README.md @@ -73,18 +73,72 @@ Note: Give your terminal Full Disk Access in **System Settings > Privacy & Secur After running this command, proceed to **System Preferences > Security & Privacy** to grant the necessary permissions for "screen recording" and "accessibility". Once permissions are granted, you can launch the app. +### Windows + +Install via [winget](https://learn.microsoft.com/en-us/windows/package-manager/winget/): + +```bash +winget install SiddharthVaddem.OpenScreen +``` + +To update later: `winget upgrade SiddharthVaddem.OpenScreen` +To uninstall: `winget uninstall SiddharthVaddem.OpenScreen` + +If you'd rather grab the `.exe` installer directly, download it from the [Releases page](https://github.com/siddharthvaddem/openscreen/releases). + ### Linux -Download the `.AppImage` file from the releases page. Make it executable and run: +Three packages are published to the [Releases page](https://github.com/siddharthvaddem/openscreen/releases) for each version. Pick the one that matches your distro: +**Debian / Ubuntu / Pop!_OS (`.deb`)** +```bash +sudo apt install ./Openscreen-Linux-latest.deb +``` + +**Arch / Manjaro (`.pacman`)** +```bash +sudo pacman -U Openscreen-Linux-latest.pacman +``` + +**Any distro (`.AppImage`)** ```bash chmod +x Openscreen-Linux-*.AppImage ./Openscreen-Linux-*.AppImage ``` +**NixOS / Nix (flake)** + +Try without installing: +```bash +nix run github:siddharthvaddem/openscreen +``` + +Install into your user profile: +```bash +nix profile install github:siddharthvaddem/openscreen +``` + +For a NixOS system config (flake): +```nix +{ + inputs.openscreen.url = "github:siddharthvaddem/openscreen"; + + outputs = { nixpkgs, openscreen, ... }: { + nixosConfigurations. = nixpkgs.lib.nixosSystem { + modules = [ + openscreen.nixosModules.default + { programs.openscreen.enable = true; } + ]; + }; + }; +} +``` + +For Home Manager, use `openscreen.homeManagerModules.default` with the same `programs.openscreen.enable = true;`. + You may need to grant screen recording permissions depending on your desktop environment. -**Note:** If the app fails to launch due to a "sandbox" error, run it with --no-sandbox: +**Sandbox error:** If the AppImage fails to launch with a "sandbox" error, run it with `--no-sandbox`: ```bash ./Openscreen-Linux-*.AppImage --no-sandbox ``` From 7bbb855e8e8dcca9073fab7dd61543696574f527 Mon Sep 17 00:00:00 2001 From: Siddharth Date: Sat, 9 May 2026 17:32:43 -0700 Subject: [PATCH 58/59] update readme --- README.md | 31 +++++++++++++++++-------------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 7dd331b..7009a22 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ > [!WARNING] -> This is very much in beta and might be buggy here and there (but hope you have a good experience!). +> This started as a side project that took off — it's not production grade and you'll hit bugs, but hopefully it covers what you need.

OpenScreen Logo @@ -21,11 +21,11 @@

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

-If you don't want to pay $29/month for Screen Studio but want a much simpler version that does what most people seem to need, making beautiful product demos and walkthroughs, here's a free-to-use app for you. OpenScreen does not offer all Screen Studio features, but covers the basics well! +If you don't want to pay $29/month for Screen Studio but want a much simpler version that does what most people seem to need - quick, polished product demos and walkthroughs you'd post on X, Reddit. OpenScreen does not offer all Screen Studio features, but covers the basics well! Screen Studio is an awesome product and this is definitely not a 1:1 clone. OpenScreen is a much simpler take, just the basics for folks who want control and don't want to pay. If you need all the fancy features, your best bet is to support Screen Studio (they really do a great job, haha). But if you just want something free (no gotchas) and open, this project does the job! -OpenScreen is 100% free for personal and commercial use. Use it, modify it, distribute it. (Just be cool 😁 and give a shoutout if you feel like it !) +**100% free** for both **personal** and **commercial** use. Use it, modify it, distribute it — just be cool 😁 and shout out the project if you feel like it.

OpenScreen App Preview 3 @@ -33,16 +33,19 @@ OpenScreen is 100% free for personal and commercial use. Use it, modify it, dist

## Core Features -- Record specific windows or your whole screen. -- Add automatic or manual zooms (adjustable depth levels) and customize their durarion and position. +- Record a specific window, region, or your whole screen. - 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 the speed of different segments. -- Export in different aspect ratios and resolutions. +- Webcam overlay with picture-in-picture, drag-to-position, and shape options. +- Auto or manual zooms with adjustable depth, duration, easing, and pixel-precise position. +- Wallpapers, solid colors, gradients, or a custom background. +- Motion blur for smoother pan and zoom transitions. +- Crop, trim, and per-segment speed control on the timeline. +- Blur effects to hide sensitive parts of the screen. +- Cursor and click highlighting. +- Text, arrow, and image annotations. +- Save and reopen projects without re-recording. +- Export to MP4 or GIF in multiple aspect ratios and resolutions. +- Translated into Arabic, English, Spanish, French, Japanese, Korean, Russian, Turkish, Vietnamese, Simplified Chinese, and Traditional Chinese. ## Installation @@ -161,16 +164,16 @@ System audio capture relies on Electron's [desktopCapturer](https://www.electron --- -_I'm new to open source, idk what I'm doing lol. If something is wrong please raise an issue 🙏_ ## Documentation See the documentation here: [OpenScreen Docs](https://deepwiki.com/siddharthvaddem/openscreen) +Refresh if outdated. ## Contributing -Contributions are welcome! If you’d like to help out or see what’s currently being worked on, take a look at the open issues and the [project roadmap](https://github.com/users/siddharthvaddem/projects/3) to understand the current direction of the project and find ways to contribute. +Contributions are welcome - please **include screenshots or a short video** for any UI change or new user-facing feature. If it touches what users see or do, show it. Skip only when it genuinely doesn't apply. PRs that don't follow this will be closed. ## Star History From e3d4a330dfd814072c414b9cd1496b961df972fb Mon Sep 17 00:00:00 2001 From: Siddharth Date: Sat, 9 May 2026 19:18:16 -0700 Subject: [PATCH 59/59] ui revamp --- electron/electron-env.d.ts | 1 + electron/preload.ts | 3 + electron/windows.ts | 7 + src/components/launch/LaunchWindow.module.css | 24 +- src/components/launch/LaunchWindow.tsx | 49 +- .../launch/SourceSelector.module.css | 46 +- src/components/launch/SourceSelector.tsx | 30 +- .../video-editor/AnnotationOverlay.tsx | 5 +- .../video-editor/AnnotationSettingsPanel.tsx | 33 +- .../video-editor/BlurSettingsPanel.tsx | 96 +- src/components/video-editor/SettingsPanel.tsx | 2314 +++++++++-------- src/components/video-editor/VideoEditor.tsx | 552 ++-- src/components/video-editor/VideoPlayback.tsx | 38 +- .../video-editor/projectPersistence.test.ts | 2 +- src/components/video-editor/timeline/Item.tsx | 12 +- .../timeline/ItemGlass.module.css | 46 +- src/components/video-editor/timeline/Row.tsx | 18 +- .../video-editor/timeline/TimelineEditor.tsx | 47 +- src/components/video-editor/types.ts | 2 +- src/index.css | 168 +- src/lib/blurEffects.test.ts | 4 +- src/lib/blurEffects.ts | 10 +- 22 files changed, 1878 insertions(+), 1629 deletions(-) diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index 744c2c7..1d528cd 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -157,6 +157,7 @@ interface Window { saveShortcuts: (shortcuts: unknown) => Promise<{ success: boolean; error?: string }>; hudOverlayHide: () => void; hudOverlayClose: () => void; + setHudOverlayIgnoreMouseEvents: (ignore: boolean) => void; showCountdownOverlay: (value: number, runId: number) => Promise; setCountdownOverlayValue: (value: number, runId: number) => Promise; hideCountdownOverlay: (runId: number) => Promise; diff --git a/electron/preload.ts b/electron/preload.ts index 5334a00..5980b4c 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -16,6 +16,9 @@ contextBridge.exposeInMainWorld("electronAPI", { hudOverlayClose: () => { ipcRenderer.send("hud-overlay-close"); }, + setHudOverlayIgnoreMouseEvents: (ignore: boolean) => { + ipcRenderer.send("hud-overlay-ignore-mouse-events", ignore); + }, getSources: async (opts: Electron.SourcesOptions) => { return await ipcRenderer.invoke("get-sources", opts); }, diff --git a/electron/windows.ts b/electron/windows.ts index f94009a..4d4e752 100644 --- a/electron/windows.ts +++ b/electron/windows.ts @@ -24,6 +24,12 @@ ipcMain.on("hud-overlay-hide", () => { } }); +ipcMain.on("hud-overlay-ignore-mouse-events", (_event, ignore: boolean) => { + if (hudOverlayWindow && !hudOverlayWindow.isDestroyed()) { + hudOverlayWindow.setIgnoreMouseEvents(ignore, { forward: true }); + } +}); + /** * Creates the always-on-top HUD overlay window centred at the bottom of the * primary display. The window is frameless, transparent, and follows the user @@ -63,6 +69,7 @@ export function createHudOverlayWindow(): BrowserWindow { backgroundThrottling: false, }, }); + win.setIgnoreMouseEvents(true, { forward: true }); // Follow the user across macOS Spaces (virtual desktops). // Without this the HUD stays pinned to the Space it was first opened on. diff --git a/src/components/launch/LaunchWindow.module.css b/src/components/launch/LaunchWindow.module.css index 132fa0a..20b8718 100644 --- a/src/components/launch/LaunchWindow.module.css +++ b/src/components/launch/LaunchWindow.module.css @@ -44,13 +44,13 @@ position: fixed; right: 0; top: 0; - width: 12rem; - padding: 0.375rem; - border-radius: 0.75rem; - border: 1px solid rgba(255, 255, 255, 0.14); - background: linear-gradient(160deg, rgba(28, 29, 42, 0.98), rgba(18, 19, 28, 0.98)); - box-shadow: 0 20px 45px rgba(0, 0, 0, 0.55); - backdrop-filter: blur(14px); + width: 11rem; + padding: 0.25rem; + border-radius: 0.625rem; + border: 1px solid rgba(255, 255, 255, 0.1); + background: rgba(8, 9, 12, 0.96); + box-shadow: 0 18px 42px rgba(0, 0, 0, 0.48), inset 0 1px 0 rgba(255, 255, 255, 0.045); + backdrop-filter: blur(18px) saturate(140%); pointer-events: auto; box-sizing: border-box; } @@ -60,10 +60,10 @@ display: flex; align-items: center; justify-content: space-between; - padding: 0.5rem 0.625rem; - border-radius: 0.5rem; + padding: 0.425rem 0.5rem; + border-radius: 0.45rem; font-size: 11px; - color: rgba(255, 255, 255, 0.88); + color: rgba(255, 255, 255, 0.72); background: transparent; border: 0; cursor: pointer; @@ -72,12 +72,12 @@ .languageMenuItem:hover, .languageMenuItem:focus-visible { - background: rgba(255, 255, 255, 0.1); + background: rgba(255, 255, 255, 0.075); color: #ffffff; outline: none; } .languageMenuItemActive { - background: rgba(255, 255, 255, 0.12); + background: rgba(52, 178, 123, 0.14); color: #ffffff; } diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index bffbd9c..6a14fc0 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -62,16 +62,16 @@ function getIcon(name: IconName, className?: string) { } const hudGroupClasses = - "flex items-center gap-0.5 bg-white/5 rounded-full transition-colors duration-150 hover:bg-white/[0.08]"; + "flex items-center gap-0.5 rounded-xl border border-white/[0.07] bg-white/[0.045] transition-colors duration-150 hover:bg-white/[0.075]"; const hudIconBtnClasses = - "flex items-center justify-center p-2 rounded-full transition-all duration-150 cursor-pointer text-white hover:bg-white/10 hover:scale-[1.08] active:scale-95"; + "flex h-8 w-8 items-center justify-center rounded-lg transition-all duration-150 cursor-pointer text-white hover:bg-white/10 active:scale-95"; const hudAuxIconBtnClasses = - "flex items-center justify-center p-1.5 rounded-full transition-colors duration-150 text-white/55 hover:bg-white/10 disabled:opacity-30 disabled:cursor-not-allowed"; + "flex h-7 w-7 items-center justify-center rounded-lg transition-colors duration-150 text-white/55 hover:bg-white/10 disabled:opacity-30 disabled:cursor-not-allowed"; 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]"; + "flex h-8 w-8 items-center justify-center rounded-lg transition-all duration-150 cursor-pointer opacity-50 hover:opacity-90 hover:bg-white/[0.08]"; const hudSidebarClasses = "ml-0.5 pl-1.5 border-l border-white/10 flex items-center gap-0.5"; @@ -87,6 +87,7 @@ export function LaunchWindow() { resolveSystemLocaleSuggestion, } = useI18n(); const suggestedLanguageName = systemLocaleSuggestion ? getLocaleName(systemLocaleSuggestion) : ""; + const activeLanguageLabel = getLocaleName(locale).split(/\s+/)[0] || locale.toUpperCase(); const { recording, @@ -248,6 +249,13 @@ export function LaunchWindow() { return () => cancelAnimationFrame(id); }, [isLanguageMenuOpen]); + useEffect(() => { + window.electronAPI?.setHudOverlayIgnoreMouseEvents?.(true); + return () => { + window.electronAPI?.setHudOverlayIgnoreMouseEvents?.(false); + }; + }, []); + const [selectedSource, setSelectedSource] = useState("Screen"); const [hasSelectedSource, setHasSelectedSource] = useState(false); @@ -320,6 +328,12 @@ export function LaunchWindow() { // recording toolbar widened (issue #305).
{ + const target = event.target as HTMLElement | null; + const shouldCapture = Boolean(target?.closest("[data-hud-interactive='true']")); + window.electronAPI?.setHudOverlayIgnoreMouseEvents?.(!shouldCapture); + }} + onPointerLeave={() => window.electronAPI?.setHudOverlayIgnoreMouseEvents?.(true)} > {systemLocaleSuggestion && (
{/* Mic selector */} {showMicControls && (
setIsMicHovered(true)} onMouseLeave={() => setIsMicHovered(false)} onFocus={() => setIsMicFocused(true)} @@ -409,7 +424,7 @@ export function LaunchWindow() { {/* Webcam selector */} {showWebcamControls && (
setIsWebcamHovered(true)} onMouseLeave={() => setIsWebcamHovered(false)} onFocus={() => setIsWebcamFocused(true)} @@ -485,7 +500,8 @@ export function LaunchWindow() { {/* HUD bar — fixed at bottom center, viewport-relative, never moves */}
{/* Drag handle */}
@@ -494,13 +510,15 @@ export function LaunchWindow() { {/* Source selector */} {/* Audio controls group */} @@ -548,7 +566,7 @@ export function LaunchWindow() { ? paused ? "bg-amber-500/10 hover:bg-amber-500/15" : "bg-red-500/12 hover:bg-red-500/16" - : "bg-white/5 hover:bg-white/[0.08]" + : "bg-white/[0.06] hover:bg-white/[0.10]" }`} onClick={toggleRecording} disabled={!hasSelectedSource && !recording} @@ -624,11 +642,12 @@ export function LaunchWindow() { aria-expanded={isLanguageMenuOpen} aria-haspopup="menu" onClick={() => setIsLanguageMenuOpen((open) => !open)} - className={`h-8 w-8 rounded-lg border border-white/10 bg-white/5 text-white/85 shadow-none transition-colors hover:bg-white/10 ${styles.electronNoDrag}`} + className={`flex h-8 items-center gap-1.5 rounded-lg border border-white/10 bg-white/[0.045] px-2 text-white/85 shadow-none transition-colors hover:bg-white/10 ${styles.electronNoDrag}`} > -
- -
+ + + {activeLanguageLabel} +
diff --git a/src/components/launch/SourceSelector.module.css b/src/components/launch/SourceSelector.module.css index 48d5507..5bd4d96 100644 --- a/src/components/launch/SourceSelector.module.css +++ b/src/components/launch/SourceSelector.module.css @@ -1,8 +1,8 @@ .glassContainer { - background: linear-gradient(135deg, rgba(28, 28, 34, 0.92) 0%, rgba(18, 18, 22, 0.88) 100%); - backdrop-filter: blur(20px) saturate(160%); - -webkit-backdrop-filter: blur(20px) saturate(160%); - border-radius: 30px; + background: linear-gradient(145deg, rgba(13, 14, 17, 0.94) 0%, rgba(8, 9, 12, 0.9) 100%); + backdrop-filter: blur(24px) saturate(150%); + -webkit-backdrop-filter: blur(24px) saturate(150%); + border-radius: 24px; corner-shape: squircle; /* Removed box-shadow here because electron doesn't round corners of the shadow, thereby leaving a square border shadow conflicting with the rounded corners of the SourceSelector. @@ -11,34 +11,36 @@ /* box-shadow: 0 0px 16px 0 rgba(0, 0, 0, 0.32), 0 1px 3px 0 rgba(0, 0, 0, 0.18) inset; */ - border: 1.5px solid rgba(60, 60, 80, 0.3); + border: 1px solid rgba(255, 255, 255, 0.1); } .sourceCard { corner-shape: squircle; - border-radius: 20px; - background: linear-gradient(120deg, rgba(38, 38, 48, 0.98) 0%, rgba(24, 24, 32, 0.96) 100%); - border: 1px solid rgba(60, 60, 80, 0.22); - box-shadow: 0 2px 8px 0 rgba(0, 0, 0, 0.18); + border-radius: 13px; + background: rgba(255, 255, 255, 0.045); + border: 1px solid rgba(255, 255, 255, 0.07); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.035); transition: box-shadow 0.2s ease, border-color 0.2s ease, + background-color 0.2s ease, transform 0.2s ease; cursor: pointer; } .sourceCard:hover { - border-color: rgba(120, 120, 160, 0.35); + background: rgba(255, 255, 255, 0.065); + border-color: rgba(255, 255, 255, 0.14); transform: translateY(-1px); - box-shadow: 0 4px 12px 0 rgba(0, 0, 0, 0.25); + box-shadow: 0 10px 24px rgba(0, 0, 0, 0.22), inset 0 1px 0 rgba(255, 255, 255, 0.04); } .selected { - border: 1.5px solid #34b27b; - background: linear-gradient(120deg, rgba(52, 178, 123, 0.08) 0%, rgba(38, 38, 48, 0.98) 100%); + border-color: rgba(52, 178, 123, 0.68); + background: linear-gradient(145deg, rgba(52, 178, 123, 0.13), rgba(255, 255, 255, 0.045)); box-shadow: - 0 0 12px rgba(52, 178, 123, 0.15), - 0 0 4px rgba(52, 178, 123, 0.1); + 0 0 0 1px rgba(52, 178, 123, 0.18) inset, + 0 12px 28px rgba(0, 0, 0, 0.22); } .selected:hover { @@ -46,16 +48,16 @@ } .icon { - width: 13px; - height: 13px; + width: 12px; + height: 12px; color: #c7d2fe; } .name { - font-size: 0.8rem; + font-size: 0.72rem; color: #e4e4e7; font-weight: 500; - letter-spacing: 0.01em; + letter-spacing: 0; } .cardText { @@ -65,14 +67,14 @@ /* Checkmark badge */ .checkBadge { - width: 18px; - height: 18px; + width: 17px; + height: 17px; background: #34b27b; border-radius: 9999px; display: flex; align-items: center; justify-content: center; - box-shadow: 0 0 8px rgba(52, 178, 123, 0.4); + box-shadow: 0 6px 14px rgba(0, 0, 0, 0.35); } /* scrollbar */ diff --git a/src/components/launch/SourceSelector.tsx b/src/components/launch/SourceSelector.tsx index a2aec55..1a0675a 100644 --- a/src/components/launch/SourceSelector.tsx +++ b/src/components/launch/SourceSelector.tsx @@ -77,24 +77,24 @@ export function SourceSelector() { return (
handleSourceSelect(source)} > -
+
{source.name} {isSelected && ( -
+
- +
)}
-
+
{source.appIcon && ( )} @@ -106,21 +106,21 @@ export function SourceSelector() { return (
-
+
- + {t("sourceSelector.screens", { count: String(screenSources.length) })} {t("sourceSelector.windows", { count: String(windowSources.length) })} @@ -128,14 +128,14 @@ export function SourceSelector() {
{screenSources.map(renderSourceCard)}
{windowSources.map(renderSourceCard)}
@@ -143,18 +143,18 @@ export function SourceSelector() {
-
+
diff --git a/src/components/video-editor/AnnotationOverlay.tsx b/src/components/video-editor/AnnotationOverlay.tsx index f416c32..345423f 100644 --- a/src/components/video-editor/AnnotationOverlay.tsx +++ b/src/components/video-editor/AnnotationOverlay.tsx @@ -82,7 +82,7 @@ export function AnnotationOverlay({ ); const [livePointerPoint, setLivePointerPoint] = useState<{ x: number; y: number } | null>(null); const mosaicCanvasRef = useRef(null); - const blurType = annotation.type === "blur" ? (annotation.blurData?.type ?? "blur") : "blur"; + const blurType = "mosaic"; const blurOverlayColor = annotation.type === "blur" ? getBlurOverlayColor(annotation.blurData) : ""; const mosaicGridOverlayColor = @@ -106,7 +106,7 @@ export function AnnotationOverlay({ const { x, y, width, height } = liveRect; useEffect(() => { - if (annotation.type !== "blur" || blurType !== "mosaic") { + if (annotation.type !== "blur") { return; } void previewFrameVersion; @@ -173,7 +173,6 @@ export function AnnotationOverlay({ ); }, [ annotation, - blurType, containerHeight, containerWidth, height, diff --git a/src/components/video-editor/AnnotationSettingsPanel.tsx b/src/components/video-editor/AnnotationSettingsPanel.tsx index 3f8064e..72e25a8 100644 --- a/src/components/video-editor/AnnotationSettingsPanel.tsx +++ b/src/components/video-editor/AnnotationSettingsPanel.tsx @@ -7,7 +7,6 @@ import { ChevronDown, Copy, Image as ImageIcon, - Info, Italic, Trash2, Type, @@ -148,39 +147,39 @@ export function AnnotationSettingsPanel({ }; return ( -
-
-
- {t("annotation.title")} - +
+
+
+ {t("annotation.active")} +
{t("annotation.title")}
{/* Type Selector */} onTypeChange(value as AnnotationType)} - className="mb-6" + className="mb-4" > - + {t("annotation.typeText")} {t("annotation.typeImage")}
- -
-
- - {t("annotation.shortcutsAndTips")} -
-
    -
  • {t("annotation.tipMovePlayhead")}
  • -
  • {t("annotation.tipTabCycle")}
  • -
  • {t("annotation.tipShiftTabCycle")}
  • -
-
); diff --git a/src/components/video-editor/BlurSettingsPanel.tsx b/src/components/video-editor/BlurSettingsPanel.tsx index 09bfe3a..7ead894 100644 --- a/src/components/video-editor/BlurSettingsPanel.tsx +++ b/src/components/video-editor/BlurSettingsPanel.tsx @@ -1,12 +1,5 @@ -import { Info, Trash2 } from "lucide-react"; +import { Trash2 } from "lucide-react"; import { Button } from "@/components/ui/button"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; import { Slider } from "@/components/ui/slider"; import { useScopedT } from "@/contexts/I18nContext"; import { getBlurOverlayColor } from "@/lib/blurEffects"; @@ -19,9 +12,7 @@ import { DEFAULT_BLUR_BLOCK_SIZE, DEFAULT_BLUR_DATA, MAX_BLUR_BLOCK_SIZE, - MAX_BLUR_INTENSITY, MIN_BLUR_BLOCK_SIZE, - MIN_BLUR_INTENSITY, } from "./types"; interface BlurSettingsPanelProps { @@ -49,13 +40,15 @@ export function BlurSettingsPanel({ ]; return ( -
-
-
- {t("annotation.blurShape")} - - {t("annotation.active")} +
+
+
+ + {t("annotation.blurTypeMosaic")} +
+ {t("annotation.typeBlur")} +
@@ -69,6 +62,7 @@ export function BlurSettingsPanel({ const nextBlurData: BlurData = { ...DEFAULT_BLUR_DATA, ...blurRegion.blurData, + type: "mosaic", shape: shape.value, }; onBlurDataChange(nextBlurData); @@ -77,7 +71,7 @@ export function BlurSettingsPanel({ }); }} className={cn( - "h-16 rounded-lg border flex flex-col items-center justify-center transition-all p-2 gap-1", + "h-12 rounded-lg border flex items-center justify-center transition-all p-2 gap-2", isActive ? "bg-[#34B27B] border-[#34B27B]" : "bg-white/5 border-white/10 hover:bg-white/10 hover:border-white/20", @@ -99,7 +93,7 @@ export function BlurSettingsPanel({ )} /> )} - + {t(`annotation.${shape.labelKey}`)} @@ -107,34 +101,6 @@ export function BlurSettingsPanel({ })}
-
- - -
-
-
+
- {blurRegion.blurData?.type === "mosaic" - ? t("annotation.mosaicBlockSize") - : t("annotation.blurIntensity")} + {t("annotation.mosaicBlockSize")} - {Math.round( - blurRegion.blurData?.type === "mosaic" - ? (blurRegion.blurData?.blockSize ?? DEFAULT_BLUR_BLOCK_SIZE) - : (blurRegion.blurData?.intensity ?? DEFAULT_BLUR_DATA.intensity), - )} + {Math.round(blurRegion.blurData?.blockSize ?? DEFAULT_BLUR_BLOCK_SIZE)} px
{ onBlurDataChange({ ...DEFAULT_BLUR_DATA, ...blurRegion.blurData, - ...(blurRegion.blurData?.type === "mosaic" - ? { blockSize: values[0] } - : { intensity: values[0] }), + type: "mosaic", + blockSize: values[0], }); }} onValueCommit={() => onBlurDataCommit?.()} - min={blurRegion.blurData?.type === "mosaic" ? MIN_BLUR_BLOCK_SIZE : MIN_BLUR_INTENSITY} - max={blurRegion.blurData?.type === "mosaic" ? MAX_BLUR_BLOCK_SIZE : MAX_BLUR_INTENSITY} + min={MIN_BLUR_BLOCK_SIZE} + max={MAX_BLUR_BLOCK_SIZE} step={1} className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3" /> @@ -231,16 +187,6 @@ export function BlurSettingsPanel({ {t("annotation.deleteAnnotation")} - -
-
- - {t("annotation.shortcutsAndTips")} -
-
    -
  • {t("annotation.tipMovePlayhead")}
  • -
-
); diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx index c625f87..9ef66b1 100644 --- a/src/components/video-editor/SettingsPanel.tsx +++ b/src/components/video-editor/SettingsPanel.tsx @@ -7,8 +7,11 @@ import { FileDown, Film, Image, + LayoutPanelTop, Lock, + MousePointerClick, Palette, + SlidersHorizontal, Sparkles, Star, Trash2, @@ -16,7 +19,7 @@ import { Upload, X, } from "lucide-react"; -import { useCallback, useMemo, useRef, useState } from "react"; +import { type ComponentType, useCallback, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; import { Accordion, @@ -185,7 +188,7 @@ function ZoomFocusCoordInput({ onKeyDown={(e) => { if (e.key === "Enter") (e.target as HTMLInputElement).blur(); }} - className="w-[90px] h-8 rounded-md border border-white/10 bg-white/5 px-2 text-xs text-slate-200 outline-none focus:border-[#34B27B]/50 focus:ring-1 focus:ring-[#34B27B]/30 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none disabled:opacity-50 disabled:cursor-not-allowed" + className="h-7 w-full rounded-md border border-white/10 bg-white/5 px-2 text-[11px] text-slate-200 outline-none focus:border-[#34B27B]/50 focus:ring-1 focus:ring-[#34B27B]/30 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none disabled:opacity-50 disabled:cursor-not-allowed" /> ); } @@ -319,6 +322,8 @@ const ZOOM_DEPTH_OPTIONS: Array<{ depth: ZoomDepth; label: string }> = [ { depth: 6, label: "5×" }, ]; +type SettingsPanelMode = "background" | "effects" | "layout" | "cursor" | "export"; + export function SettingsPanel({ cursorHighlight, onCursorHighlightChange, @@ -402,6 +407,7 @@ export function SettingsPanel({ onSaveDiagnostic, }: SettingsPanelProps) { const t = useScopedT("settings"); + const [activePanelMode, setActivePanelMode] = useState("background"); // Resolved URLs are for DOM rendering only (backgroundImage). The canonical // `/wallpapers/wallpaperN.jpg` form in WALLPAPER_PATHS is what gets persisted // on click — never the machine-specific file:// URL. @@ -534,6 +540,31 @@ export function SettingsPanel({ const zoomEnabled = Boolean(selectedZoomDepth); const trimEnabled = Boolean(selectedTrimId); + const hasTimelineSelection = Boolean(selectedZoomId || selectedTrimId || selectedSpeedId); + const panelModes: Array<{ + id: SettingsPanelMode; + label: string; + icon: ComponentType<{ className?: string }>; + disabled?: boolean; + }> = [ + { id: "background", label: t("background.title"), icon: Palette }, + { id: "effects", label: t("effects.title"), icon: SlidersHorizontal }, + { id: "layout", label: t("layout.title"), icon: LayoutPanelTop, disabled: !hasWebcam }, + { id: "cursor", label: t("effects.cursorHighlight.title"), icon: MousePointerClick }, + ]; + const exportPanelMode = { + id: "export" as const, + label: exportFormat === "gif" ? t("export.gifButton") : t("export.videoButton"), + icon: Download, + }; + const activeModeLabel = hasTimelineSelection + ? selectedZoomId + ? t("zoom.level") + : selectedSpeedId + ? t("speed.playbackSpeed") + : t("trim.deleteRegion") + : ([...panelModes, exportPanelMode].find((mode) => mode.id === activePanelMode)?.label ?? + t("background.title")); const handleDeleteClick = () => { if (selectedZoomId && onZoomDelete) { @@ -615,6 +646,42 @@ export function SettingsPanel({ const selectedBlur = selectedBlurId ? blurRegions.find((region) => region.id === selectedBlurId) : null; + const commonFooterLinks = ( +
+ + {onSaveDiagnostic && ( + + )} + +
+ ); // If an annotation is selected, show annotation settings instead if ( @@ -625,88 +692,113 @@ export function SettingsPanel({ onAnnotationDelete ) { return ( - onAnnotationContentChange(selectedAnnotation.id, content)} - onTypeChange={(type) => onAnnotationTypeChange(selectedAnnotation.id, type)} - onStyleChange={(style) => onAnnotationStyleChange(selectedAnnotation.id, style)} - onFigureDataChange={ - onAnnotationFigureDataChange - ? (figureData) => onAnnotationFigureDataChange(selectedAnnotation.id, figureData) - : undefined - } - onDuplicate={ - onAnnotationDuplicate ? () => onAnnotationDuplicate(selectedAnnotation.id) : undefined - } - onDelete={() => onAnnotationDelete(selectedAnnotation.id)} - /> +
+
+ onAnnotationContentChange(selectedAnnotation.id, content)} + onTypeChange={(type) => onAnnotationTypeChange(selectedAnnotation.id, type)} + onStyleChange={(style) => onAnnotationStyleChange(selectedAnnotation.id, style)} + onFigureDataChange={ + onAnnotationFigureDataChange + ? (figureData) => onAnnotationFigureDataChange(selectedAnnotation.id, figureData) + : undefined + } + onDuplicate={ + onAnnotationDuplicate ? () => onAnnotationDuplicate(selectedAnnotation.id) : undefined + } + onDelete={() => onAnnotationDelete(selectedAnnotation.id)} + /> +
+
+ {commonFooterLinks} +
+
); } if (selectedBlur && onBlurDataChange && onBlurDelete) { return ( - onBlurDataChange(selectedBlur.id, blurData)} - onBlurDataCommit={onBlurDataCommit} - onDelete={() => onBlurDelete(selectedBlur.id)} - /> +
+
+ onBlurDataChange(selectedBlur.id, blurData)} + onBlurDataCommit={onBlurDataCommit} + onDelete={() => onBlurDelete(selectedBlur.id)} + /> +
+
+ {commonFooterLinks} +
+
); } return ( -
-
-
-
- {t("zoom.level")} -
- {zoomEnabled && selectedZoomDepth && ( - - {selectedZoomCustomScale != null - ? `${selectedZoomCustomScale.toFixed(2)}×` - : ZOOM_DEPTH_OPTIONS.find((o) => o.depth === selectedZoomDepth)?.label} - - )} - -
-
-
- {ZOOM_DEPTH_OPTIONS.map((option) => { - const effectiveScale = - selectedZoomCustomScale ?? - (selectedZoomDepth != null ? ZOOM_DEPTH_SCALES[selectedZoomDepth] : null); - const isActive = effectiveScale === ZOOM_DEPTH_SCALES[option.depth]; - return ( - - ); - })} +
+
+
+ {panelModes.map((mode) => { + const Icon = mode.icon; + const isActive = activePanelMode === mode.id && !hasTimelineSelection; + return ( + + ); + })} + + +
+
+
+ {activeModeLabel} +
{zoomEnabled && ( -
-
- {t("zoom.customScale")} - +
+
+ + {t("zoom.level")} + + {( selectedZoomCustomScale ?? (selectedZoomDepth != null @@ -716,896 +808,941 @@ export function SettingsPanel({ ×
- + {ZOOM_DEPTH_OPTIONS.map((option) => { + const effectiveScale = + selectedZoomCustomScale ?? + (selectedZoomDepth != null ? ZOOM_DEPTH_SCALES[selectedZoomDepth] : null); + const isActive = effectiveScale === ZOOM_DEPTH_SCALES[option.depth]; + return ( + + ); + })} +
+ {zoomEnabled && ( +
+ onZoomCustomScaleChange?.(values[0])} + onValueCommit={() => onZoomCustomScaleCommit?.()} + disabled={!zoomEnabled} + className="relative flex w-full touch-none select-none items-center py-1" + > + + + + + +
+ {MIN_ZOOM_SCALE.toFixed(1)}× + {MAX_ZOOM_SCALE.toFixed(1)}× +
+
+ )} + {zoomEnabled && hasCursorTelemetry && ( +
+ + {t("zoom.focusMode.title")} + +
+ {(["manual", "auto"] as const).map((mode) => { + const isActive = selectedZoomFocusMode === mode; + return ( + + ); + })} +
+
+ )} + {zoomEnabled && + selectedZoomFocusMode !== "auto" && + selectedZoomFocus && + onZoomFocusCoordinateChange && + (() => { + const effectiveZoomScale = + selectedZoomCustomScale ?? (selectedZoomDepth != null ? ZOOM_DEPTH_SCALES[selectedZoomDepth] - : MIN_ZOOM_SCALE), - ]} - onValueChange={(values) => onZoomCustomScaleChange?.(values[0])} - onValueCommit={() => onZoomCustomScaleCommit?.()} - disabled={!zoomEnabled} - className="relative flex w-full touch-none select-none items-center py-1" - > - - - - - -
- {MIN_ZOOM_SCALE.toFixed(1)}× - {MAX_ZOOM_SCALE.toFixed(1)}× -
-
- )} - {!zoomEnabled && ( -

{t("zoom.selectRegion")}

- )} - {zoomEnabled && hasCursorTelemetry && ( -
- - {t("zoom.focusMode.title")} - -
- {(["manual", "auto"] as const).map((mode) => { - const isActive = selectedZoomFocusMode === mode; + : MIN_ZOOM_SCALE); + const bounds = getFocusBoundsForScale(effectiveZoomScale); + const xRange = bounds.maxX - bounds.minX; + const yRange = bounds.maxY - bounds.minY; + const focusToPercentX = (cx: number) => + xRange <= 0 + ? 50 + : Math.max(0, Math.min(100, ((cx - bounds.minX) / xRange) * 100)); + const focusToPercentY = (cy: number) => + yRange <= 0 + ? 50 + : Math.max(0, Math.min(100, ((cy - bounds.minY) / yRange) * 100)); + const percentToFocusX = (p: number) => + xRange <= 0 ? bounds.minX : bounds.minX + (p / 100) * xRange; + const percentToFocusY = (p: number) => + yRange <= 0 ? bounds.minY : bounds.minY + (p / 100) * yRange; return ( - +
+
+ + + onZoomFocusCoordinateChange({ + cx: percentToFocusX(p), + cy: selectedZoomFocus.cy, + }) + } + onCommit={onZoomFocusCoordinateCommit} + /> +
+
+ + + onZoomFocusCoordinateChange({ + cx: selectedZoomFocus.cx, + cy: percentToFocusY(p), + }) + } + onCommit={onZoomFocusCoordinateCommit} + /> +
+
+
); - })} -
- {selectedZoomFocusMode === "auto" && ( -

- {t("zoom.focusMode.autoDescription")} -

- )} -
- )} - {zoomEnabled && - selectedZoomFocusMode !== "auto" && - selectedZoomFocus && - onZoomFocusCoordinateChange && - (() => { - const effectiveZoomScale = - selectedZoomCustomScale ?? - (selectedZoomDepth != null ? ZOOM_DEPTH_SCALES[selectedZoomDepth] : MIN_ZOOM_SCALE); - const bounds = getFocusBoundsForScale(effectiveZoomScale); - const xRange = bounds.maxX - bounds.minX; - const yRange = bounds.maxY - bounds.minY; - const focusToPercentX = (cx: number) => - xRange <= 0 ? 50 : Math.max(0, Math.min(100, ((cx - bounds.minX) / xRange) * 100)); - const focusToPercentY = (cy: number) => - yRange <= 0 ? 50 : Math.max(0, Math.min(100, ((cy - bounds.minY) / yRange) * 100)); - const percentToFocusX = (p: number) => - xRange <= 0 ? bounds.minX : bounds.minX + (p / 100) * xRange; - const percentToFocusY = (p: number) => - yRange <= 0 ? bounds.minY : bounds.minY + (p / 100) * yRange; - return ( -
- - {t("zoom.position.title")} + })()} + {zoomEnabled && ( +
+ + {t("zoom.threeD.title")} -
-
- - - onZoomFocusCoordinateChange({ - cx: percentToFocusX(p), - cy: selectedZoomFocus.cy, - }) - } - onCommit={onZoomFocusCoordinateCommit} - /> -
-
- - - onZoomFocusCoordinateChange({ - cx: selectedZoomFocus.cx, - cy: percentToFocusY(p), - }) - } - onCommit={onZoomFocusCoordinateCommit} - /> -
- - {t("zoom.position.hint")} - -
-
- ); - })()} - {zoomEnabled && ( -
- - {t("zoom.threeD.title")} - -
- {ROTATION_3D_PRESET_ORDER.map((preset) => { - const isActive = selectedZoomRotationPreset === preset; - return ( - - ); - })} -
-
- )} - - {zoomEnabled && ( - - )} -
- - {trimEnabled && ( -
- -
- )} - -
-
- {t("speed.playbackSpeed")} - {selectedSpeedId && selectedSpeedValue && ( - - {SPEED_OPTIONS.find((o) => o.speed === selectedSpeedValue)?.label ?? - `${selectedSpeedValue}×`} - - )} -
-
- {SPEED_OPTIONS.map((option) => { - const isActive = selectedSpeedValue === option.speed; - return ( - - ); - })} -
-
-
- - {t("speed.customPlaybackSpeed")} - - {selectedSpeedId ? ( - onSpeedChange?.(val)} - onError={() => toast.error(t("speed.maxSpeedError"))} - /> - ) : ( -
-
- -- -
- × -
- )} -
-
- {!selectedSpeedId && ( -

{t("speed.selectRegion")}

- )} - {selectedSpeedId && ( - - )} -
- - - {hasWebcam && ( - - -
- - {t("layout.title")} -
-
- -
-
- {t("layout.preset")} -
- -
- {webcamLayoutPreset === "picture-in-picture" && ( -
-
- {t("layout.webcamShape")} -
-
- {( - [ - { value: "rectangle", label: "Rect" }, - { value: "circle", label: "Circle" }, - { value: "square", label: "Square" }, - { value: "rounded", label: "Rounded" }, - ] as Array<{ value: WebcamMaskShape; label: string }> - ).map((shape) => ( - - ))} -
-
- )} - {webcamLayoutPreset === "picture-in-picture" && ( -
-
-
- {t("layout.webcamSize")} -
-
- {webcamSizePreset}% -
-
- onWebcamSizePresetChange?.(values[0])} - onValueCommit={() => onWebcamSizePresetCommit?.()} - min={10} - max={50} - step={1} - className="w-full" - /> -
- )} -
-
- )} - - - -
- - {t("effects.title")} -
-
- -
-
-
- {t("effects.blurBg")} -
- -
-
- -
-
-
-
- {t("effects.motionBlur")} -
- - {motionBlurAmount === 0 ? t("effects.off") : motionBlurAmount.toFixed(2)} - -
- onMotionBlurChange?.(values[0])} - onValueCommit={() => onMotionBlurCommit?.()} - min={0} - max={1} - step={0.01} - className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3" - /> -
-
-
-
- {t("effects.shadow")} -
- - {Math.round(shadowIntensity * 100)}% - -
- onShadowChange?.(values[0])} - onValueCommit={() => onShadowCommit?.()} - min={0} - max={1} - step={0.01} - className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3" - /> -
-
-
-
- {t("effects.roundness")} -
- {borderRadius}px -
- onBorderRadiusChange?.(values[0])} - onValueCommit={() => onBorderRadiusCommit?.()} - min={0} - max={16} - step={0.5} - className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3" - /> -
-
-
-
- {t("effects.padding")} -
- - {webcamLayoutPreset === "vertical-stack" ? "—" : `${padding}%`} - -
- 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" - /> -
-
- - {cursorHighlight && onCursorHighlightChange && ( -
-
-
- {t("effects.cursorHighlight.title")} -
- -
-
- {(["dot", "ring"] as const).map((style) => ( - - ))} -
-
-
-
- {t("effects.cursorHighlight.size")} -
- - {cursorHighlight.sizePx}px - -
- - onCursorHighlightChange({ - ...cursorHighlight, - sizePx: values[0], - }) - } - min={10} - max={36} - step={1} - className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3" - /> -
- {cursorHighlightSupportsClicks && ( -
-
- {t("effects.cursorHighlight.onlyOnClicks")} -
- -
- )} -
-
- {t("effects.cursorHighlight.color")} -
- - - - - - - onCursorHighlightChange({ - ...cursorHighlight, - color, - }) - } - /> - - -
-
-
-
- {t("effects.cursorHighlight.offsetX")} -
- - {(cursorHighlight.offsetXNorm * 100).toFixed(1)}% - -
- - onCursorHighlightChange({ - ...cursorHighlight, - offsetXNorm: values[0], - }) - } - min={-0.25} - max={0.25} - step={0.005} - className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3" - /> -
-
-
-
- {t("effects.cursorHighlight.offsetY")} -
- - {(cursorHighlight.offsetYNorm * 100).toFixed(1)}% - -
- - onCursorHighlightChange({ - ...cursorHighlight, - offsetYNorm: values[0], - }) - } - min={-0.25} - max={0.25} - step={0.005} - className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3" - /> + ); + })}
)} + {zoomEnabled && ( + + )} +
+ )} + + {trimEnabled && ( +
- - +
+ )} - - -
- - {t("background.title")} + {selectedSpeedId && ( +
+
+ + {t("speed.playbackSpeed")} + + {selectedSpeedId && selectedSpeedValue && ( + + {SPEED_OPTIONS.find((o) => o.speed === selectedSpeedValue)?.label ?? + `${selectedSpeedValue}×`} + + )}
- - - - - - {t("background.image")} - - - {t("background.color")} - - - {t("background.gradient")} - - - -
- - +
+ {SPEED_OPTIONS.map((option) => { + const isActive = selectedSpeedValue === option.speed; + return ( + ); + })} +
+
+ + {t("speed.customPlaybackSpeed")} + + {selectedSpeedId ? ( + onSpeedChange?.(val)} + onError={() => toast.error(t("speed.maxSpeedError"))} + /> + ) : ( +
+
+ -- +
+ × +
+ )} +
+ {selectedSpeedId && ( + + )} +
+ )} -
- {customImages.map((imageUrl, idx) => { - const isSelected = selected === imageUrl; - return ( -
onWallpaperChange(imageUrl)} - role="button" - > + {!hasTimelineSelection && ( + + {hasWebcam && activePanelMode === "layout" && ( + + +
+ + {t("layout.title")} +
+
+ +
+
+ {t("layout.preset")} +
+ +
+ {webcamLayoutPreset === "picture-in-picture" && ( +
+
+ {t("layout.webcamShape")} +
+
+ {( + [ + { value: "rectangle", label: "Rect" }, + { value: "circle", label: "Circle" }, + { value: "square", label: "Square" }, + { value: "rounded", label: "Rounded" }, + ] as Array<{ value: WebcamMaskShape; label: string }> + ).map((shape) => ( + ))} +
+
+ )} + {webcamLayoutPreset === "picture-in-picture" && ( +
+
+
+ {t("layout.webcamSize")} +
+
+ {webcamSizePreset}% +
+
+ onWebcamSizePresetChange?.(values[0])} + onValueCommit={() => onWebcamSizePresetCommit?.()} + min={10} + max={50} + step={1} + className="w-full" + /> +
+ )} +
+
+ )} + + {(activePanelMode === "effects" || activePanelMode === "cursor") && ( + + +
+ {activePanelMode === "cursor" ? ( + + ) : ( + + )} + + {activePanelMode === "cursor" + ? t("effects.cursorHighlight.title") + : t("effects.title")} + +
+
+ + {activePanelMode === "effects" && ( + <> +
+
+
+ {t("effects.blurBg")} +
+ +
+
+ +
+
+
+
+ {t("effects.motionBlur")} +
+ + {motionBlurAmount === 0 + ? t("effects.off") + : motionBlurAmount.toFixed(2)} + +
+ onMotionBlurChange?.(values[0])} + onValueCommit={() => onMotionBlurCommit?.()} + min={0} + max={1} + step={0.01} + className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3" + /> +
+
+
+
+ {t("effects.shadow")} +
+ + {Math.round(shadowIntensity * 100)}% + +
+ onShadowChange?.(values[0])} + onValueCommit={() => onShadowCommit?.()} + min={0} + max={1} + step={0.01} + className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3" + /> +
+
+
+
+ {t("effects.roundness")} +
+ + {borderRadius}px + +
+ onBorderRadiusChange?.(values[0])} + onValueCommit={() => onBorderRadiusCommit?.()} + min={0} + max={16} + step={0.5} + className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3" + /> +
+
+
+
+ {t("effects.padding")} +
+ + {webcamLayoutPreset === "vertical-stack" ? "—" : `${padding}%`} + +
+ 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" + /> +
+
+ + )} + + {activePanelMode === "cursor" && cursorHighlight && onCursorHighlightChange && ( +
+
+
+ {t("effects.cursorHighlight.title")} +
+ +
+
+ {(["dot", "ring"] as const).map((style) => ( + + ))} +
+
+
+
+ {t("effects.cursorHighlight.size")} +
+ + {cursorHighlight.sizePx}px + +
+ + onCursorHighlightChange({ + ...cursorHighlight, + sizePx: values[0], + }) + } + min={10} + max={36} + step={1} + className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3" + /> +
+ {cursorHighlightSupportsClicks && ( +
+
+ {t("effects.cursorHighlight.onlyOnClicks")} +
+
- ); - })} - - {WALLPAPER_PATHS.map((canonicalPath, i) => { - const previewUrl = wallpaperPreviewUrls[i] ?? canonicalPath; - const isSelected = selected === canonicalPath; - return ( -
onWallpaperChange(canonicalPath)} - role="button" - /> - ); - })} -
- - - - { - setSelectedColor(color); - onWallpaperChange(color); - }} - /> - - - -
- {GRADIENTS.map((g, idx) => ( + )}
{ - setGradient(g); - onWallpaperChange(g); - }} - role="button" - /> - ))} + className={ + cursorHighlight.enabled ? "" : "opacity-40 pointer-events-none" + } + > +
+ {t("effects.cursorHighlight.color")} +
+ + + + + + + onCursorHighlightChange({ + ...cursorHighlight, + color, + }) + } + /> + + +
+
+
+
+ {t("effects.cursorHighlight.offsetX")} +
+ + {(cursorHighlight.offsetXNorm * 100).toFixed(1)}% + +
+ + onCursorHighlightChange({ + ...cursorHighlight, + offsetXNorm: values[0], + }) + } + min={-0.25} + max={0.25} + step={0.005} + className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3" + /> +
+
+
+
+ {t("effects.cursorHighlight.offsetY")} +
+ + {(cursorHighlight.offsetYNorm * 100).toFixed(1)}% + +
+ + onCursorHighlightChange({ + ...cursorHighlight, + offsetYNorm: values[0], + }) + } + min={-0.25} + max={0.25} + step={0.005} + className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3" + /> +
+
+ )} + + + )} + + {activePanelMode === "background" && ( + + +
+ + {t("background.title")}
-
-
- -
-
-
+ + + + + + {t("background.image")} + + + {t("background.color")} + + + {t("background.gradient")} + + + +
+ + + + +
+ {customImages.map((imageUrl, idx) => { + const isSelected = selected === imageUrl; + return ( +
onWallpaperChange(imageUrl)} + role="button" + > + +
+ ); + })} + + {WALLPAPER_PATHS.map((canonicalPath, i) => { + const previewUrl = wallpaperPreviewUrls[i] ?? canonicalPath; + const isSelected = selected === canonicalPath; + return ( +
onWallpaperChange(canonicalPath)} + role="button" + /> + ); + })} +
+ + + + { + setSelectedColor(color); + onWallpaperChange(color); + }} + /> + + + +
+ {GRADIENTS.map((g, idx) => ( +
{ + setGradient(g); + onWallpaperChange(g); + }} + role="button" + /> + ))} +
+ +
+ + + + )} + + )} +
{showCropModal && cropRegion && onCropChange && ( @@ -1731,182 +1868,155 @@ export function SettingsPanel({ )} -
-
- - -
- - {exportFormat === "mp4" && ( -
- - - -
- )} - - {exportFormat === "gif" && ( -
-
-
- {GIF_FRAME_RATES.map((rate) => ( - - ))} -
-
- {Object.entries(GIF_SIZE_PRESETS).map(([key, _preset]) => ( - - ))} -
+
+ {activePanelMode === "export" && !hasTimelineSelection && ( + <> +
+ +
-
- - {gifOutputDimensions.width} × {gifOutputDimensions.height}px - -
- {t("gifSettings.loop")} - + + {exportFormat === "mp4" && ( +
+ + +
-
-
- )} + )} - {unsavedExport && ( - - )} - + {exportFormat === "gif" && ( +
+
+
+ {GIF_FRAME_RATES.map((rate) => ( + + ))} +
+
+ {Object.entries(GIF_SIZE_PRESETS).map(([key, _preset]) => ( + + ))} +
+
+
+ + {gifOutputDimensions.width} × {gifOutputDimensions.height}px + +
+ {t("gifSettings.loop")} + +
+
+
+ )} -
- - {onSaveDiagnostic && ( - + )} + - )} - -
+ + {exportFormat === "gif" ? t("export.gifButton") : t("export.videoButton")} + + + )} + + {commonFooterLinks}
); diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 12832ad..e1f6a60 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -1869,7 +1869,7 @@ export default function VideoEditor() {