From 7d22c49df5c16c7a776eaf9d5d72722a11fb4cba Mon Sep 17 00:00:00 2001 From: KBCats Date: Sun, 15 Mar 2026 18:12:38 -0700 Subject: [PATCH] fix(editor): polish cursor sway and HUD capture controls Keep the recording HUD visible on screen without burning it into captures, and make cursor sway match cursor motion while preserving the old 2x strength at the new 1x slider position. --- electron/electron-env.d.ts | 336 ++- electron/preload.ts | 384 +-- electron/windows.ts | 291 +-- src/components/launch/LaunchWindow.tsx | 706 +++--- src/components/video-editor/SettingsPanel.tsx | 2137 ++++++++--------- .../videoPlayback/cursorSway.test.ts | 50 +- .../video-editor/videoPlayback/cursorSway.ts | 62 +- src/i18n/locales/en/launch.json | 2 + src/i18n/locales/es/launch.json | 2 + src/i18n/locales/zh-CN/launch.json | 2 + src/vite-env.d.ts | 361 +-- 11 files changed, 2279 insertions(+), 2054 deletions(-) diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index f859957d..cf5c2e88 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -1,116 +1,220 @@ -/// - -declare namespace NodeJS { - interface ProcessEnv { - /** - * The built directory structure - * - * ```tree - * ├─┬─┬ dist - * │ │ └── index.html - * │ │ - * │ ├─┬ dist-electron - * │ │ ├── main.js - * │ │ └── preload.js - * │ - * ``` - */ - APP_ROOT: string - /** /dist/ or /public/ */ - VITE_PUBLIC: string - } -} - -// Used in Renderer process, expose in `preload.ts` -interface Window { - electronAPI: { - getSources: (opts: Electron.SourcesOptions) => Promise - switchToEditor: () => Promise - openSourceSelector: () => Promise - selectSource: (source: any) => Promise - getSelectedSource: () => Promise - startNativeScreenRecording: ( - source: any, - options?: { - capturesSystemAudio?: boolean - capturesMicrophone?: boolean - microphoneDeviceId?: string - microphoneLabel?: string - } - ) => Promise<{ success: boolean; path?: string; message?: string; error?: string }> - stopNativeScreenRecording: () => Promise<{ success: boolean; path?: string; message?: string; error?: string }> - startFfmpegRecording: (source: any) => Promise<{ success: boolean; path?: string; message?: string; error?: string }> - stopFfmpegRecording: () => Promise<{ success: boolean; path?: string; message?: string; error?: string }> - storeRecordedVideo: (videoData: ArrayBuffer, fileName: string) => Promise<{ success: boolean; path?: string; message?: string }> - getRecordedVideoPath: () => Promise<{ success: boolean; path?: string; message?: string }> - readLocalFile: (filePath: string) => Promise<{ success: boolean; data?: Uint8Array; error?: string }> - setRecordingState: (recording: boolean) => Promise - getCursorTelemetry: (videoPath?: string) => Promise<{ success: boolean; samples: CursorTelemetryPoint[]; message?: string; error?: string }> - getSystemCursorAssets: () => Promise<{ success: boolean; cursors: Record; error?: string }> - onStopRecordingFromTray: (callback: () => void) => () => void - onRecordingStateChanged: (callback: (state: { recording: boolean; sourceName: string }) => void) => () => void - onRecordingInterrupted: (callback: (state: { reason: string; message: string }) => void) => () => void - onCursorStateChanged: (callback: (state: { cursorType: CursorTelemetryPoint['cursorType'] }) => void) => () => void - openExternalUrl: (url: string) => Promise<{ success: boolean; error?: string }> - getAccessibilityPermissionStatus: () => Promise<{ success: boolean; trusted: boolean; prompted: boolean; error?: string }> - requestAccessibilityPermission: () => Promise<{ success: boolean; trusted: boolean; prompted: boolean; error?: string }> - getScreenRecordingPermissionStatus: () => Promise<{ success: boolean; status: string; error?: string }> - openScreenRecordingPreferences: () => Promise<{ success: boolean; error?: string }> - openAccessibilityPreferences: () => Promise<{ success: boolean; error?: string }> - saveExportedVideo: (videoData: ArrayBuffer, fileName: string) => Promise<{ success: boolean; path?: string; message?: string; canceled?: boolean }> - openVideoFilePicker: () => Promise<{ success: boolean; path?: string; canceled?: boolean }> - setCurrentVideoPath: (path: string) => Promise<{ success: boolean }> - getCurrentVideoPath: () => Promise<{ success: boolean; path?: string }> - clearCurrentVideoPath: () => Promise<{ success: boolean }> - saveProjectFile: (projectData: unknown, suggestedName?: string, existingProjectPath?: string) => Promise<{ success: boolean; path?: string; message?: string; canceled?: boolean; error?: string }> - loadProjectFile: () => Promise<{ success: boolean; path?: string; project?: unknown; message?: string; canceled?: boolean; error?: string }> - loadCurrentProjectFile: () => Promise<{ success: boolean; path?: string; project?: unknown; message?: string; canceled?: boolean; error?: string }> - onMenuLoadProject: (callback: () => void) => () => void - onMenuSaveProject: (callback: () => void) => () => void - onMenuSaveProjectAs: (callback: () => void) => () => void - getPlatform: () => Promise - revealInFolder: (filePath: string) => Promise<{ success: boolean; error?: string; message?: string }>, - openRecordingsFolder: () => Promise<{ success: boolean; error?: string; message?: string }>, - getRecordingsDirectory: () => Promise<{ success: boolean; path: string; isDefault: boolean; error?: string }> - chooseRecordingsDirectory: () => Promise<{ success: boolean; canceled?: boolean; path?: string; isDefault?: boolean; message?: string; error?: string }> - getShortcuts: () => Promise | null> - saveShortcuts: (shortcuts: unknown) => Promise<{ success: boolean; error?: string }> - hudOverlayHide: () => void; - hudOverlayClose: () => void; - setHasUnsavedChanges: (hasChanges: boolean) => void - onRequestSaveBeforeClose: (callback: () => Promise) => () => void - isWgcAvailable: () => Promise<{ available: boolean }> - muxWgcRecording: () => Promise<{ success: boolean; path?: string; message?: string; error?: string }> - /** Hide the OS cursor before browser capture starts. */ - hideOsCursor: () => Promise<{ success: boolean }> - } -} - -interface ProcessedDesktopSource { - id: string - name: string - display_id: string - thumbnail: string | null - appIcon: string | null - originalName?: string - sourceType?: 'screen' | 'window' - appName?: string - windowTitle?: string -} - -interface CursorTelemetryPoint { - timeMs: number - cx: number - cy: number - interactionType?: 'move' | 'click' | 'double-click' | 'right-click' | 'middle-click' | 'mouseup' - cursorType?: 'arrow' | 'text' | 'pointer' | 'crosshair' | 'open-hand' | 'closed-hand' | 'resize-ew' | 'resize-ns' | 'not-allowed' -} - -interface SystemCursorAsset { - dataUrl: string - hotspotX: number - hotspotY: number - width: number - height: number -} - +/// + +declare namespace NodeJS { + interface ProcessEnv { + /** + * The built directory structure + * + * ```tree + * ├─┬─┬ dist + * │ │ └── index.html + * │ │ + * │ ├─┬ dist-electron + * │ │ ├── main.js + * │ │ └── preload.js + * │ + * ``` + */ + APP_ROOT: string; + /** /dist/ or /public/ */ + VITE_PUBLIC: string; + } +} + +// Used in Renderer process, expose in `preload.ts` +interface Window { + electronAPI: { + getSources: (opts: Electron.SourcesOptions) => Promise; + switchToEditor: () => Promise; + openSourceSelector: () => Promise; + selectSource: (source: any) => Promise; + getSelectedSource: () => Promise; + startNativeScreenRecording: ( + source: any, + options?: { + capturesSystemAudio?: boolean; + capturesMicrophone?: boolean; + microphoneDeviceId?: string; + microphoneLabel?: string; + }, + ) => Promise<{ success: boolean; path?: string; message?: string; error?: string }>; + stopNativeScreenRecording: () => Promise<{ + success: boolean; + path?: string; + message?: string; + error?: string; + }>; + startFfmpegRecording: ( + source: any, + ) => Promise<{ success: boolean; path?: string; message?: string; error?: string }>; + stopFfmpegRecording: () => Promise<{ + success: boolean; + path?: string; + message?: string; + error?: string; + }>; + storeRecordedVideo: ( + videoData: ArrayBuffer, + fileName: string, + ) => Promise<{ success: boolean; path?: string; message?: string }>; + getRecordedVideoPath: () => Promise<{ success: boolean; path?: string; message?: string }>; + readLocalFile: ( + filePath: string, + ) => Promise<{ success: boolean; data?: Uint8Array; error?: string }>; + setRecordingState: (recording: boolean) => Promise; + getCursorTelemetry: (videoPath?: string) => Promise<{ + success: boolean; + samples: CursorTelemetryPoint[]; + message?: string; + error?: string; + }>; + getSystemCursorAssets: () => Promise<{ + success: boolean; + cursors: Record; + error?: string; + }>; + onStopRecordingFromTray: (callback: () => void) => () => void; + onRecordingStateChanged: ( + callback: (state: { recording: boolean; sourceName: string }) => void, + ) => () => void; + onRecordingInterrupted: ( + callback: (state: { reason: string; message: string }) => void, + ) => () => void; + onCursorStateChanged: ( + callback: (state: { cursorType: CursorTelemetryPoint["cursorType"] }) => void, + ) => () => void; + openExternalUrl: (url: string) => Promise<{ success: boolean; error?: string }>; + getAccessibilityPermissionStatus: () => Promise<{ + success: boolean; + trusted: boolean; + prompted: boolean; + error?: string; + }>; + requestAccessibilityPermission: () => Promise<{ + success: boolean; + trusted: boolean; + prompted: boolean; + error?: string; + }>; + getScreenRecordingPermissionStatus: () => Promise<{ + success: boolean; + status: string; + error?: string; + }>; + openScreenRecordingPreferences: () => Promise<{ success: boolean; error?: string }>; + openAccessibilityPreferences: () => Promise<{ success: boolean; error?: string }>; + saveExportedVideo: ( + videoData: ArrayBuffer, + fileName: string, + ) => Promise<{ success: boolean; path?: string; message?: string; canceled?: boolean }>; + openVideoFilePicker: () => Promise<{ success: boolean; path?: string; canceled?: boolean }>; + setCurrentVideoPath: (path: string) => Promise<{ success: boolean }>; + getCurrentVideoPath: () => Promise<{ success: boolean; path?: string }>; + clearCurrentVideoPath: () => Promise<{ success: boolean }>; + saveProjectFile: ( + projectData: unknown, + suggestedName?: string, + existingProjectPath?: string, + ) => Promise<{ + success: boolean; + path?: string; + message?: string; + canceled?: boolean; + error?: string; + }>; + loadProjectFile: () => Promise<{ + success: boolean; + path?: string; + project?: unknown; + message?: string; + canceled?: boolean; + error?: string; + }>; + loadCurrentProjectFile: () => Promise<{ + success: boolean; + path?: string; + project?: unknown; + message?: string; + canceled?: boolean; + error?: string; + }>; + onMenuLoadProject: (callback: () => void) => () => void; + onMenuSaveProject: (callback: () => void) => () => void; + onMenuSaveProjectAs: (callback: () => void) => () => void; + getPlatform: () => Promise; + revealInFolder: ( + filePath: string, + ) => Promise<{ success: boolean; error?: string; message?: string }>; + openRecordingsFolder: () => Promise<{ success: boolean; error?: string; message?: string }>; + getRecordingsDirectory: () => Promise<{ + success: boolean; + path: string; + isDefault: boolean; + error?: string; + }>; + chooseRecordingsDirectory: () => Promise<{ + success: boolean; + canceled?: boolean; + path?: string; + isDefault?: boolean; + message?: string; + error?: string; + }>; + getShortcuts: () => Promise | null>; + saveShortcuts: (shortcuts: unknown) => Promise<{ success: boolean; error?: string }>; + hudOverlayHide: () => void; + hudOverlayClose: () => void; + getHudOverlayCaptureProtection: () => Promise<{ success: boolean; enabled: boolean }>; + setHudOverlayCaptureProtection: ( + enabled: boolean, + ) => Promise<{ success: boolean; enabled: boolean }>; + setHasUnsavedChanges: (hasChanges: boolean) => void; + onRequestSaveBeforeClose: (callback: () => Promise) => () => void; + isWgcAvailable: () => Promise<{ available: boolean }>; + muxWgcRecording: () => Promise<{ + success: boolean; + path?: string; + message?: string; + error?: string; + }>; + /** Hide the OS cursor before browser capture starts. */ + hideOsCursor: () => Promise<{ success: boolean }>; + }; +} + +interface ProcessedDesktopSource { + id: string; + name: string; + display_id: string; + thumbnail: string | null; + appIcon: string | null; + originalName?: string; + sourceType?: "screen" | "window"; + appName?: string; + windowTitle?: string; +} + +interface CursorTelemetryPoint { + timeMs: number; + cx: number; + cy: number; + interactionType?: "move" | "click" | "double-click" | "right-click" | "middle-click" | "mouseup"; + cursorType?: + | "arrow" + | "text" + | "pointer" + | "crosshair" + | "open-hand" + | "closed-hand" + | "resize-ew" + | "resize-ns" + | "not-allowed"; +} + +interface SystemCursorAsset { + dataUrl: string; + hotspotX: number; + hotspotY: number; + width: number; + height: number; +} diff --git a/electron/preload.ts b/electron/preload.ts index ebc4fed1..d149dc91 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -1,186 +1,204 @@ -import { contextBridge, ipcRenderer } from 'electron' +import { contextBridge, ipcRenderer } from "electron"; -contextBridge.exposeInMainWorld('electronAPI', { - hudOverlayHide: () => { - ipcRenderer.send('hud-overlay-hide'); - }, - hudOverlayClose: () => { - ipcRenderer.send('hud-overlay-close'); - }, - getAssetBasePath: async () => { - // ask main process for the correct base path (production vs dev) - return await ipcRenderer.invoke('get-asset-base-path') - }, - readLocalFile: (filePath: string) => { - return ipcRenderer.invoke('read-local-file', filePath) - }, - getSources: async (opts: Electron.SourcesOptions) => { - return await ipcRenderer.invoke('get-sources', opts) - }, - switchToEditor: () => { - return ipcRenderer.invoke('switch-to-editor') - }, - openSourceSelector: () => { - return ipcRenderer.invoke('open-source-selector') - }, - selectSource: (source: any) => { - return ipcRenderer.invoke('select-source', source) - }, - getSelectedSource: () => { - return ipcRenderer.invoke('get-selected-source') - }, - startNativeScreenRecording: ( - source: any, - options?: { - capturesSystemAudio?: boolean - capturesMicrophone?: boolean - microphoneDeviceId?: string - microphoneLabel?: string - }, - ) => { - return ipcRenderer.invoke('start-native-screen-recording', source, options) - }, - stopNativeScreenRecording: () => { - return ipcRenderer.invoke('stop-native-screen-recording') - }, - startFfmpegRecording: (source: any) => { - return ipcRenderer.invoke('start-ffmpeg-recording', source) - }, - stopFfmpegRecording: () => { - return ipcRenderer.invoke('stop-ffmpeg-recording') - }, +contextBridge.exposeInMainWorld("electronAPI", { + hudOverlayHide: () => { + ipcRenderer.send("hud-overlay-hide"); + }, + hudOverlayClose: () => { + ipcRenderer.send("hud-overlay-close"); + }, + getHudOverlayCaptureProtection: () => { + return ipcRenderer.invoke("get-hud-overlay-capture-protection"); + }, + setHudOverlayCaptureProtection: (enabled: boolean) => { + return ipcRenderer.invoke("set-hud-overlay-capture-protection", enabled); + }, + getAssetBasePath: async () => { + // ask main process for the correct base path (production vs dev) + return await ipcRenderer.invoke("get-asset-base-path"); + }, + readLocalFile: (filePath: string) => { + return ipcRenderer.invoke("read-local-file", filePath); + }, + getSources: async (opts: Electron.SourcesOptions) => { + return await ipcRenderer.invoke("get-sources", opts); + }, + switchToEditor: () => { + return ipcRenderer.invoke("switch-to-editor"); + }, + openSourceSelector: () => { + return ipcRenderer.invoke("open-source-selector"); + }, + selectSource: (source: any) => { + return ipcRenderer.invoke("select-source", source); + }, + getSelectedSource: () => { + return ipcRenderer.invoke("get-selected-source"); + }, + startNativeScreenRecording: ( + source: any, + options?: { + capturesSystemAudio?: boolean; + capturesMicrophone?: boolean; + microphoneDeviceId?: string; + microphoneLabel?: string; + }, + ) => { + return ipcRenderer.invoke("start-native-screen-recording", source, options); + }, + stopNativeScreenRecording: () => { + return ipcRenderer.invoke("stop-native-screen-recording"); + }, + startFfmpegRecording: (source: any) => { + return ipcRenderer.invoke("start-ffmpeg-recording", source); + }, + stopFfmpegRecording: () => { + return ipcRenderer.invoke("stop-ffmpeg-recording"); + }, - storeRecordedVideo: (videoData: ArrayBuffer, fileName: string) => { - return ipcRenderer.invoke('store-recorded-video', videoData, fileName) - }, - - getRecordedVideoPath: () => { - return ipcRenderer.invoke('get-recorded-video-path') - }, - setRecordingState: (recording: boolean) => { - return ipcRenderer.invoke('set-recording-state', recording) - }, - setCursorScale: (scale: number) => { - return ipcRenderer.invoke('set-cursor-scale', scale) - }, - getCursorTelemetry: (videoPath?: string) => { - return ipcRenderer.invoke('get-cursor-telemetry', videoPath) - }, - getSystemCursorAssets: () => { - return ipcRenderer.invoke('get-system-cursor-assets') - }, - onStopRecordingFromTray: (callback: () => void) => { - const listener = () => callback() - ipcRenderer.on('stop-recording-from-tray', listener) - return () => ipcRenderer.removeListener('stop-recording-from-tray', listener) - }, - onRecordingStateChanged: (callback: (state: { recording: boolean; sourceName: string }) => void) => { - const listener = (_event: Electron.IpcRendererEvent, payload: { recording: boolean; sourceName: string }) => callback(payload) - ipcRenderer.on('recording-state-changed', listener) - return () => ipcRenderer.removeListener('recording-state-changed', listener) - }, - onRecordingInterrupted: (callback: (state: { reason: string; message: string }) => void) => { - const listener = (_event: Electron.IpcRendererEvent, payload: { reason: string; message: string }) => callback(payload) - ipcRenderer.on('recording-interrupted', listener) - return () => ipcRenderer.removeListener('recording-interrupted', listener) - }, - onCursorStateChanged: (callback: (state: { cursorType: CursorTelemetryPoint['cursorType'] }) => void) => { - const listener = (_event: Electron.IpcRendererEvent, payload: { cursorType: CursorTelemetryPoint['cursorType'] }) => callback(payload) - ipcRenderer.on('cursor-state-changed', listener) - return () => ipcRenderer.removeListener('cursor-state-changed', listener) - }, - openExternalUrl: (url: string) => { - return ipcRenderer.invoke('open-external-url', url) - }, - getAccessibilityPermissionStatus: () => { - return ipcRenderer.invoke('get-accessibility-permission-status') - }, - requestAccessibilityPermission: () => { - return ipcRenderer.invoke('request-accessibility-permission') - }, - getScreenRecordingPermissionStatus: () => { - return ipcRenderer.invoke('get-screen-recording-permission-status') - }, - openScreenRecordingPreferences: () => { - return ipcRenderer.invoke('open-screen-recording-preferences') - }, - openAccessibilityPreferences: () => { - return ipcRenderer.invoke('open-accessibility-preferences') - }, - saveExportedVideo: (videoData: ArrayBuffer, fileName: string) => { - return ipcRenderer.invoke('save-exported-video', videoData, fileName) - }, - openVideoFilePicker: () => { - return ipcRenderer.invoke('open-video-file-picker') - }, - setCurrentVideoPath: (path: string) => { - return ipcRenderer.invoke('set-current-video-path', path) - }, - getCurrentVideoPath: () => { - return ipcRenderer.invoke('get-current-video-path') - }, - clearCurrentVideoPath: () => { - return ipcRenderer.invoke('clear-current-video-path') - }, - saveProjectFile: (projectData: unknown, suggestedName?: string, existingProjectPath?: string) => { - return ipcRenderer.invoke('save-project-file', projectData, suggestedName, existingProjectPath) - }, - loadProjectFile: () => { - return ipcRenderer.invoke('load-project-file') - }, - loadCurrentProjectFile: () => { - return ipcRenderer.invoke('load-current-project-file') - }, - onMenuLoadProject: (callback: () => void) => { - const listener = () => callback() - ipcRenderer.on('menu-load-project', listener) - return () => ipcRenderer.removeListener('menu-load-project', listener) - }, - onMenuSaveProject: (callback: () => void) => { - const listener = () => callback() - ipcRenderer.on('menu-save-project', listener) - return () => ipcRenderer.removeListener('menu-save-project', listener) - }, - onMenuSaveProjectAs: (callback: () => void) => { - const listener = () => callback() - ipcRenderer.on('menu-save-project-as', listener) - return () => ipcRenderer.removeListener('menu-save-project-as', listener) - }, - getPlatform: () => { - return ipcRenderer.invoke('get-platform') - }, - revealInFolder: (filePath: string) => { - return ipcRenderer.invoke('reveal-in-folder', filePath) - }, - openRecordingsFolder: () => { - return ipcRenderer.invoke('open-recordings-folder') - }, - getRecordingsDirectory: () => { - return ipcRenderer.invoke('get-recordings-directory') - }, - chooseRecordingsDirectory: () => { - return ipcRenderer.invoke('choose-recordings-directory') - }, - getShortcuts: () => { - return ipcRenderer.invoke('get-shortcuts') - }, - saveShortcuts: (shortcuts: unknown) => { - return ipcRenderer.invoke('save-shortcuts', shortcuts) - }, - setHasUnsavedChanges: (hasChanges: boolean) => { - ipcRenderer.send('set-has-unsaved-changes', hasChanges) - }, - onRequestSaveBeforeClose: (callback: () => Promise) => { - const listener = async () => { - await callback() - ipcRenderer.send('save-before-close-done') - } - ipcRenderer.on('request-save-before-close', listener) - return () => ipcRenderer.removeListener('request-save-before-close', listener) - }, - isWgcAvailable: () => ipcRenderer.invoke('is-wgc-available'), - muxWgcRecording: () => ipcRenderer.invoke('mux-wgc-recording'), - // Cursor visibility control for cursor-free browser capture fallback - hideOsCursor: () => ipcRenderer.invoke('hide-cursor'), -}) + storeRecordedVideo: (videoData: ArrayBuffer, fileName: string) => { + return ipcRenderer.invoke("store-recorded-video", videoData, fileName); + }, + getRecordedVideoPath: () => { + return ipcRenderer.invoke("get-recorded-video-path"); + }, + setRecordingState: (recording: boolean) => { + return ipcRenderer.invoke("set-recording-state", recording); + }, + setCursorScale: (scale: number) => { + return ipcRenderer.invoke("set-cursor-scale", scale); + }, + getCursorTelemetry: (videoPath?: string) => { + return ipcRenderer.invoke("get-cursor-telemetry", videoPath); + }, + getSystemCursorAssets: () => { + return ipcRenderer.invoke("get-system-cursor-assets"); + }, + onStopRecordingFromTray: (callback: () => void) => { + const listener = () => callback(); + ipcRenderer.on("stop-recording-from-tray", listener); + return () => ipcRenderer.removeListener("stop-recording-from-tray", listener); + }, + onRecordingStateChanged: ( + callback: (state: { recording: boolean; sourceName: string }) => void, + ) => { + const listener = ( + _event: Electron.IpcRendererEvent, + payload: { recording: boolean; sourceName: string }, + ) => callback(payload); + ipcRenderer.on("recording-state-changed", listener); + return () => ipcRenderer.removeListener("recording-state-changed", listener); + }, + onRecordingInterrupted: (callback: (state: { reason: string; message: string }) => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + payload: { reason: string; message: string }, + ) => callback(payload); + ipcRenderer.on("recording-interrupted", listener); + return () => ipcRenderer.removeListener("recording-interrupted", listener); + }, + onCursorStateChanged: ( + callback: (state: { cursorType: CursorTelemetryPoint["cursorType"] }) => void, + ) => { + const listener = ( + _event: Electron.IpcRendererEvent, + payload: { cursorType: CursorTelemetryPoint["cursorType"] }, + ) => callback(payload); + ipcRenderer.on("cursor-state-changed", listener); + return () => ipcRenderer.removeListener("cursor-state-changed", listener); + }, + openExternalUrl: (url: string) => { + return ipcRenderer.invoke("open-external-url", url); + }, + getAccessibilityPermissionStatus: () => { + return ipcRenderer.invoke("get-accessibility-permission-status"); + }, + requestAccessibilityPermission: () => { + return ipcRenderer.invoke("request-accessibility-permission"); + }, + getScreenRecordingPermissionStatus: () => { + return ipcRenderer.invoke("get-screen-recording-permission-status"); + }, + openScreenRecordingPreferences: () => { + return ipcRenderer.invoke("open-screen-recording-preferences"); + }, + openAccessibilityPreferences: () => { + return ipcRenderer.invoke("open-accessibility-preferences"); + }, + saveExportedVideo: (videoData: ArrayBuffer, fileName: string) => { + return ipcRenderer.invoke("save-exported-video", videoData, fileName); + }, + openVideoFilePicker: () => { + return ipcRenderer.invoke("open-video-file-picker"); + }, + setCurrentVideoPath: (path: string) => { + return ipcRenderer.invoke("set-current-video-path", path); + }, + getCurrentVideoPath: () => { + return ipcRenderer.invoke("get-current-video-path"); + }, + clearCurrentVideoPath: () => { + return ipcRenderer.invoke("clear-current-video-path"); + }, + saveProjectFile: (projectData: unknown, suggestedName?: string, existingProjectPath?: string) => { + return ipcRenderer.invoke("save-project-file", projectData, suggestedName, existingProjectPath); + }, + loadProjectFile: () => { + return ipcRenderer.invoke("load-project-file"); + }, + loadCurrentProjectFile: () => { + return ipcRenderer.invoke("load-current-project-file"); + }, + onMenuLoadProject: (callback: () => void) => { + const listener = () => callback(); + ipcRenderer.on("menu-load-project", listener); + return () => ipcRenderer.removeListener("menu-load-project", listener); + }, + onMenuSaveProject: (callback: () => void) => { + const listener = () => callback(); + ipcRenderer.on("menu-save-project", listener); + return () => ipcRenderer.removeListener("menu-save-project", listener); + }, + onMenuSaveProjectAs: (callback: () => void) => { + const listener = () => callback(); + ipcRenderer.on("menu-save-project-as", listener); + return () => ipcRenderer.removeListener("menu-save-project-as", listener); + }, + getPlatform: () => { + return ipcRenderer.invoke("get-platform"); + }, + revealInFolder: (filePath: string) => { + return ipcRenderer.invoke("reveal-in-folder", filePath); + }, + openRecordingsFolder: () => { + return ipcRenderer.invoke("open-recordings-folder"); + }, + getRecordingsDirectory: () => { + return ipcRenderer.invoke("get-recordings-directory"); + }, + chooseRecordingsDirectory: () => { + return ipcRenderer.invoke("choose-recordings-directory"); + }, + getShortcuts: () => { + return ipcRenderer.invoke("get-shortcuts"); + }, + saveShortcuts: (shortcuts: unknown) => { + return ipcRenderer.invoke("save-shortcuts", shortcuts); + }, + setHasUnsavedChanges: (hasChanges: boolean) => { + ipcRenderer.send("set-has-unsaved-changes", hasChanges); + }, + onRequestSaveBeforeClose: (callback: () => Promise) => { + const listener = async () => { + await callback(); + ipcRenderer.send("save-before-close-done"); + }; + ipcRenderer.on("request-save-before-close", listener); + return () => ipcRenderer.removeListener("request-save-before-close", listener); + }, + isWgcAvailable: () => ipcRenderer.invoke("is-wgc-available"), + muxWgcRecording: () => ipcRenderer.invoke("mux-wgc-recording"), + // Cursor visibility control for cursor-free browser capture fallback + hideOsCursor: () => ipcRenderer.invoke("hide-cursor"), +}); diff --git a/electron/windows.ts b/electron/windows.ts index d2f7466d..2420743e 100644 --- a/electron/windows.ts +++ b/electron/windows.ts @@ -1,168 +1,191 @@ -import { BrowserWindow, ipcMain } from 'electron' -import { createRequire } from 'node:module' -import path from 'node:path' -import { fileURLToPath } from 'node:url' +import { createRequire } from "node:module"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { BrowserWindow, ipcMain } from "electron"; -const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const nodeRequire = createRequire(import.meta.url) +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const nodeRequire = createRequire(import.meta.url); -const APP_ROOT = path.join(__dirname, '..') -const VITE_DEV_SERVER_URL = process.env['VITE_DEV_SERVER_URL'] -const RENDERER_DIST = path.join(APP_ROOT, 'dist') -const WINDOW_ICON_PATH = path.join(process.env.VITE_PUBLIC || RENDERER_DIST, 'app-icons', 'recordly-512.png') +const APP_ROOT = path.join(__dirname, ".."); +const VITE_DEV_SERVER_URL = process.env["VITE_DEV_SERVER_URL"]; +const RENDERER_DIST = path.join(APP_ROOT, "dist"); +const WINDOW_ICON_PATH = path.join( + process.env.VITE_PUBLIC || RENDERER_DIST, + "app-icons", + "recordly-512.png", +); let hudOverlayWindow: BrowserWindow | null = null; +let hudOverlayHiddenFromCapture = false; function getScreen() { - return nodeRequire('electron').screen as typeof import('electron').screen + return nodeRequire("electron").screen as typeof import("electron").screen; } -ipcMain.on('hud-overlay-hide', () => { - if (hudOverlayWindow && !hudOverlayWindow.isDestroyed()) { - hudOverlayWindow.minimize(); - } +ipcMain.on("hud-overlay-hide", () => { + if (hudOverlayWindow && !hudOverlayWindow.isDestroyed()) { + hudOverlayWindow.minimize(); + } +}); + +ipcMain.handle("get-hud-overlay-capture-protection", () => { + return { + success: true, + enabled: hudOverlayHiddenFromCapture, + }; +}); + +ipcMain.handle("set-hud-overlay-capture-protection", (_event, enabled: boolean) => { + hudOverlayHiddenFromCapture = Boolean(enabled); + + if (hudOverlayWindow && !hudOverlayWindow.isDestroyed()) { + hudOverlayWindow.setContentProtection(hudOverlayHiddenFromCapture); + } + + return { + success: true, + enabled: hudOverlayHiddenFromCapture, + }; }); export function createHudOverlayWindow(): BrowserWindow { - const primaryDisplay = getScreen().getPrimaryDisplay(); - const { workArea } = primaryDisplay; + const primaryDisplay = getScreen().getPrimaryDisplay(); + const { workArea } = primaryDisplay; + const windowWidth = 660; + const windowHeight = 170; - const windowWidth = 600; - const windowHeight = 155; + const x = Math.floor(workArea.x + (workArea.width - windowWidth) / 2); + const y = Math.floor(workArea.y + workArea.height - windowHeight - 5); - const x = Math.floor(workArea.x + (workArea.width - windowWidth) / 2); - const y = Math.floor(workArea.y + workArea.height - windowHeight - 5); + const win = new BrowserWindow({ + width: windowWidth, + height: windowHeight, + minWidth: windowWidth, + maxWidth: windowWidth, + minHeight: windowHeight, + maxHeight: windowHeight, + x: x, + y: y, + frame: false, + transparent: true, + resizable: false, + alwaysOnTop: true, + skipTaskbar: true, + hasShadow: false, + webPreferences: { + preload: path.join(__dirname, "preload.mjs"), + nodeIntegration: false, + contextIsolation: true, + backgroundThrottling: false, + }, + }); - const win = new BrowserWindow({ - width: windowWidth, - height: windowHeight, - minWidth: 600, - maxWidth: 600, - minHeight: 155, - maxHeight: 155, - x: x, - y: y, - frame: false, - transparent: true, - resizable: false, - alwaysOnTop: true, - skipTaskbar: true, - hasShadow: false, - webPreferences: { - preload: path.join(__dirname, 'preload.mjs'), - nodeIntegration: false, - contextIsolation: true, - backgroundThrottling: false, - }, - }) + win.setContentProtection(hudOverlayHiddenFromCapture); + win.webContents.on("did-finish-load", () => { + win?.webContents.send("main-process-message", new Date().toLocaleString()); + }); - win.webContents.on('did-finish-load', () => { - win?.webContents.send('main-process-message', (new Date).toLocaleString()) - }) + hudOverlayWindow = win; - hudOverlayWindow = win; + win.on("closed", () => { + if (hudOverlayWindow === win) { + hudOverlayWindow = null; + } + }); - win.on('closed', () => { - if (hudOverlayWindow === win) { - hudOverlayWindow = null; - } - }); + if (VITE_DEV_SERVER_URL) { + win.loadURL(VITE_DEV_SERVER_URL + "?windowType=hud-overlay"); + } else { + win.loadFile(path.join(RENDERER_DIST, "index.html"), { + query: { windowType: "hud-overlay" }, + }); + } - - if (VITE_DEV_SERVER_URL) { - win.loadURL(VITE_DEV_SERVER_URL + '?windowType=hud-overlay') - } else { - win.loadFile(path.join(RENDERER_DIST, 'index.html'), { - query: { windowType: 'hud-overlay' } - }) - } - - return win + return win; } export function createEditorWindow(): BrowserWindow { - const isMac = process.platform === 'darwin'; + const isMac = process.platform === "darwin"; - const win = new BrowserWindow({ - width: 1200, - height: 800, - minWidth: 800, - minHeight: 600, - ...(process.platform !== 'darwin' && { - icon: WINDOW_ICON_PATH, - }), - ...(isMac && { - titleBarStyle: 'hiddenInset', - trafficLightPosition: { x: 12, y: 12 }, - }), - transparent: false, - resizable: true, - alwaysOnTop: false, - skipTaskbar: false, - title: 'Recordly', - backgroundColor: '#000000', - webPreferences: { - preload: path.join(__dirname, 'preload.mjs'), - nodeIntegration: false, - contextIsolation: true, - webSecurity: false, - backgroundThrottling: false, - }, - }) + const win = new BrowserWindow({ + width: 1200, + height: 800, + minWidth: 800, + minHeight: 600, + ...(process.platform !== "darwin" && { + icon: WINDOW_ICON_PATH, + }), + ...(isMac && { + titleBarStyle: "hiddenInset", + trafficLightPosition: { x: 12, y: 12 }, + }), + transparent: false, + resizable: true, + alwaysOnTop: false, + skipTaskbar: false, + title: "Recordly", + backgroundColor: "#000000", + webPreferences: { + preload: path.join(__dirname, "preload.mjs"), + nodeIntegration: false, + contextIsolation: true, + webSecurity: false, + backgroundThrottling: false, + }, + }); - // Maximize the window by default - win.maximize(); + // Maximize the window by default + win.maximize(); - win.webContents.on('did-finish-load', () => { - win?.webContents.send('main-process-message', (new Date).toLocaleString()) - }) + win.webContents.on("did-finish-load", () => { + win?.webContents.send("main-process-message", new Date().toLocaleString()); + }); - if (VITE_DEV_SERVER_URL) { - win.loadURL(VITE_DEV_SERVER_URL + '?windowType=editor') - } else { - win.loadFile(path.join(RENDERER_DIST, 'index.html'), { - query: { windowType: 'editor' } - }) - } + if (VITE_DEV_SERVER_URL) { + win.loadURL(VITE_DEV_SERVER_URL + "?windowType=editor"); + } else { + win.loadFile(path.join(RENDERER_DIST, "index.html"), { + query: { windowType: "editor" }, + }); + } - return win + return win; } export function createSourceSelectorWindow(): BrowserWindow { - const { width, height } = getScreen().getPrimaryDisplay().workAreaSize - - const win = new BrowserWindow({ - width: 620, - height: 420, - minHeight: 350, - maxHeight: 500, - x: Math.round((width - 620) / 2), - y: Math.round((height - 420) / 2), - frame: false, - resizable: false, - alwaysOnTop: true, - transparent: true, - ...(process.platform !== 'darwin' && { - icon: WINDOW_ICON_PATH, - }), - backgroundColor: '#00000000', - webPreferences: { - preload: path.join(__dirname, 'preload.mjs'), - nodeIntegration: false, - contextIsolation: true, - }, - }) + const { width, height } = getScreen().getPrimaryDisplay().workAreaSize; - if (VITE_DEV_SERVER_URL) { - win.loadURL(VITE_DEV_SERVER_URL + '?windowType=source-selector') - } else { - win.loadFile(path.join(RENDERER_DIST, 'index.html'), { - query: { windowType: 'source-selector' } - }) - } + const win = new BrowserWindow({ + width: 620, + height: 420, + minHeight: 350, + maxHeight: 500, + x: Math.round((width - 620) / 2), + y: Math.round((height - 420) / 2), + frame: false, + resizable: false, + alwaysOnTop: true, + transparent: true, + ...(process.platform !== "darwin" && { + icon: WINDOW_ICON_PATH, + }), + backgroundColor: "#00000000", + webPreferences: { + preload: path.join(__dirname, "preload.mjs"), + nodeIntegration: false, + contextIsolation: true, + }, + }); - return win + if (VITE_DEV_SERVER_URL) { + win.loadURL(VITE_DEV_SERVER_URL + "?windowType=source-selector"); + } else { + win.loadFile(path.join(RENDERER_DIST, "index.html"), { + query: { windowType: "source-selector" }, + }); + } + + return win; } - diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index 20d121b0..f4f69e38 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -1,355 +1,441 @@ +import { Eye, EyeOff, Languages } from "lucide-react"; import { useEffect, useState } from "react"; import { BsRecordCircle } from "react-icons/bs"; import { FaRegStopCircle } from "react-icons/fa"; import { FaFolderOpen } from "react-icons/fa6"; import { FiMinus, FiX } from "react-icons/fi"; import { MdMic, MdMicOff, MdMonitor, MdVideoFile, MdVolumeOff, MdVolumeUp } from "react-icons/md"; -import { Languages } from "lucide-react"; import { RxDragHandleDots2 } from "react-icons/rx"; +import { useI18n } from "@/contexts/I18nContext"; +import type { AppLocale } from "@/i18n/config"; +import { SUPPORTED_LOCALES } from "@/i18n/config"; +import { useScopedT } from "../../contexts/I18nContext"; import { useAudioLevelMeter } from "../../hooks/useAudioLevelMeter"; import { useMicrophoneDevices } from "../../hooks/useMicrophoneDevices"; import { useScreenRecorder } from "../../hooks/useScreenRecorder"; -import { useScopedT } from "../../contexts/I18nContext"; -import { Button } from "../ui/button"; import { AudioLevelMeter } from "../ui/audio-level-meter"; +import { Button } from "../ui/button"; import { ContentClamp } from "../ui/content-clamp"; import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, } from "../ui/dropdown-menu"; -import { useI18n } from "@/contexts/I18nContext"; -import { SUPPORTED_LOCALES } from "@/i18n/config"; -import type { AppLocale } from "@/i18n/config"; import styles from "./LaunchWindow.module.css"; export function LaunchWindow() { - const { locale, setLocale } = useI18n(); - const t = useScopedT('launch'); + const { locale, setLocale } = useI18n(); + const t = useScopedT("launch"); - const LOCALE_LABELS: Record = { en: "EN", es: "ES", "zh-CN": "中文" }; - const { - recording, - toggleRecording, - microphoneEnabled, - setMicrophoneEnabled, - microphoneDeviceId, - setMicrophoneDeviceId, - systemAudioEnabled, - setSystemAudioEnabled, - } = useScreenRecorder(); - const [recordingStart, setRecordingStart] = useState(null); - const [elapsed, setElapsed] = useState(0); - const showMicControls = microphoneEnabled && !recording; - const { devices, selectedDeviceId, setSelectedDeviceId } = useMicrophoneDevices(microphoneEnabled); - const { level } = useAudioLevelMeter({ - enabled: showMicControls, - deviceId: microphoneDeviceId, - }); + const LOCALE_LABELS: Record = { en: "EN", es: "ES", "zh-CN": "中文" }; + const { + recording, + toggleRecording, + microphoneEnabled, + setMicrophoneEnabled, + microphoneDeviceId, + setMicrophoneDeviceId, + systemAudioEnabled, + setSystemAudioEnabled, + } = useScreenRecorder(); + const [recordingStart, setRecordingStart] = useState(null); + const [elapsed, setElapsed] = useState(0); + const showMicControls = microphoneEnabled && !recording; + const { devices, selectedDeviceId, setSelectedDeviceId } = + useMicrophoneDevices(microphoneEnabled); + const { level } = useAudioLevelMeter({ + enabled: showMicControls, + deviceId: microphoneDeviceId, + }); - useEffect(() => { - if (selectedDeviceId && selectedDeviceId !== "default") { - setMicrophoneDeviceId(selectedDeviceId); - } - }, [selectedDeviceId, setMicrophoneDeviceId]); + useEffect(() => { + if (selectedDeviceId && selectedDeviceId !== "default") { + setMicrophoneDeviceId(selectedDeviceId); + } + }, [selectedDeviceId, setMicrophoneDeviceId]); - useEffect(() => { - let timer: NodeJS.Timeout | null = null; - if (recording) { - if (!recordingStart) setRecordingStart(Date.now()); - timer = setInterval(() => { - if (recordingStart) { - setElapsed(Math.floor((Date.now() - recordingStart) / 1000)); - } - }, 1000); - } else { - setRecordingStart(null); - setElapsed(0); - if (timer) clearInterval(timer); - } - return () => { - if (timer) clearInterval(timer); - }; - }, [recording, recordingStart]); + useEffect(() => { + let timer: NodeJS.Timeout | null = null; + if (recording) { + if (!recordingStart) setRecordingStart(Date.now()); + timer = setInterval(() => { + if (recordingStart) { + setElapsed(Math.floor((Date.now() - recordingStart) / 1000)); + } + }, 1000); + } else { + setRecordingStart(null); + setElapsed(0); + if (timer) clearInterval(timer); + } + return () => { + if (timer) clearInterval(timer); + }; + }, [recording, recordingStart]); - const formatTime = (seconds: number) => { - const m = Math.floor(seconds / 60).toString().padStart(2, "0"); - const s = (seconds % 60).toString().padStart(2, "0"); - return `${m}:${s}`; - }; + const formatTime = (seconds: number) => { + const m = Math.floor(seconds / 60) + .toString() + .padStart(2, "0"); + const s = (seconds % 60).toString().padStart(2, "0"); + return `${m}:${s}`; + }; - const [selectedSource, setSelectedSource] = useState("Screen"); - const [hasSelectedSource, setHasSelectedSource] = useState(false); - const [recordingsDirectory, setRecordingsDirectory] = useState(null); + const [selectedSource, setSelectedSource] = useState("Screen"); + const [hasSelectedSource, setHasSelectedSource] = useState(false); + const [recordingsDirectory, setRecordingsDirectory] = useState(null); + const [hideHudFromCapture, setHideHudFromCapture] = useState(false); - useEffect(() => { - const checkSelectedSource = async () => { - if (window.electronAPI) { - const source = await window.electronAPI.getSelectedSource(); - if (source) { - setSelectedSource(source.name); - setHasSelectedSource(true); - } else { - setSelectedSource("Screen"); - setHasSelectedSource(false); - } - } - }; + useEffect(() => { + const checkSelectedSource = async () => { + if (window.electronAPI) { + const source = await window.electronAPI.getSelectedSource(); + if (source) { + setSelectedSource(source.name); + setHasSelectedSource(true); + } else { + setSelectedSource("Screen"); + setHasSelectedSource(false); + } + } + }; - void checkSelectedSource(); - const interval = setInterval(checkSelectedSource, 500); - return () => clearInterval(interval); - }, []); + void checkSelectedSource(); + const interval = setInterval(checkSelectedSource, 500); + return () => clearInterval(interval); + }, []); - const openSourceSelector = () => { - window.electronAPI?.openSourceSelector(); - }; + useEffect(() => { + let cancelled = false; - const openVideoFile = async () => { - const result = await window.electronAPI.openVideoFilePicker(); - if (result.canceled) { - return; - } + const loadHudCaptureProtection = async () => { + try { + const result = await window.electronAPI.getHudOverlayCaptureProtection(); + if (!cancelled && result.success) { + setHideHudFromCapture(result.enabled); + } + } catch (error) { + console.error("Failed to load HUD capture protection state:", error); + } + }; - if (result.success && result.path) { - await window.electronAPI.setCurrentVideoPath(result.path); - await window.electronAPI.switchToEditor(); - } - }; + void loadHudCaptureProtection(); - const openProjectFile = async () => { - const result = await window.electronAPI.loadProjectFile(); - if (result.canceled || !result.success) { - return; - } - await window.electronAPI.switchToEditor(); - }; + return () => { + cancelled = true; + }; + }, []); - const sendHudOverlayHide = () => { - window.electronAPI?.hudOverlayHide?.(); - }; + const openSourceSelector = () => { + window.electronAPI?.openSourceSelector(); + }; - const sendHudOverlayClose = () => { - window.electronAPI?.hudOverlayClose?.(); - }; + const openVideoFile = async () => { + const result = await window.electronAPI.openVideoFilePicker(); + if (result.canceled) { + return; + } - const chooseRecordingsDirectory = async () => { - const result = await window.electronAPI.chooseRecordingsDirectory(); - if (result.canceled) { - return; - } - if (result.success && result.path) { - setRecordingsDirectory(result.path); - } - }; + if (result.success && result.path) { + await window.electronAPI.setCurrentVideoPath(result.path); + await window.electronAPI.switchToEditor(); + } + }; - useEffect(() => { - const loadRecordingsDirectory = async () => { - const result = await window.electronAPI.getRecordingsDirectory(); - if (result.success) { - setRecordingsDirectory(result.path); - } - }; + const openProjectFile = async () => { + const result = await window.electronAPI.loadProjectFile(); + if (result.canceled || !result.success) { + return; + } + await window.electronAPI.switchToEditor(); + }; - void loadRecordingsDirectory(); - }, []); + const sendHudOverlayHide = () => { + window.electronAPI?.hudOverlayHide?.(); + }; - const recordingsDirectoryName = recordingsDirectory - ? recordingsDirectory.split(/[\\/]/).filter(Boolean).pop() || recordingsDirectory - : "recordings"; - const dividerClass = "mx-1 h-5 w-px shrink-0 bg-white/35"; + const sendHudOverlayClose = () => { + window.electronAPI?.hudOverlayClose?.(); + }; - const toggleMicrophone = () => { - if (!recording) { - setMicrophoneEnabled(!microphoneEnabled); - } - }; + const toggleHudCaptureProtection = async () => { + const nextValue = !hideHudFromCapture; - return ( -
-
- {showMicControls && ( -
- - -
- )} + setHideHudFromCapture(nextValue); -
-
- -
+ try { + const result = await window.electronAPI.setHudOverlayCaptureProtection(nextValue); - + if (!result.success) { + setHideHudFromCapture(!nextValue); + return; + } -
+ setHideHudFromCapture(result.enabled); + } catch (error) { + console.error("Failed to update HUD capture protection:", error); + setHideHudFromCapture(!nextValue); + } + }; -
- - -
+ const chooseRecordingsDirectory = async () => { + const result = await window.electronAPI.chooseRecordingsDirectory(); + if (result.canceled) { + return; + } + if (result.success && result.path) { + setRecordingsDirectory(result.path); + } + }; -
+ useEffect(() => { + const loadRecordingsDirectory = async () => { + const result = await window.electronAPI.getRecordingsDirectory(); + if (result.success) { + setRecordingsDirectory(result.path); + } + }; - + void loadRecordingsDirectory(); + }, []); - + const recordingsDirectoryName = recordingsDirectory + ? recordingsDirectory.split(/[\\/]/).filter(Boolean).pop() || recordingsDirectory + : "recordings"; + const dividerClass = "mx-1 h-5 w-px shrink-0 bg-white/35"; -
-
- - - - - - - - {SUPPORTED_LOCALES.map((code) => ( - setLocale(code as AppLocale)} - className={`text-xs cursor-pointer ${ - locale === code ? "text-white font-medium" : "text-white/60" - }`} - > - {LOCALE_LABELS[code] ?? code} - - ))} - - -
- - -
-
-
-
- ); + const toggleMicrophone = () => { + if (!recording) { + setMicrophoneEnabled(!microphoneEnabled); + } + }; + + return ( +
+
+ {showMicControls && ( +
+ + +
+ )} + +
+
+ +
+ + + +
+ +
+ + + +
+ +
+ + + + + +
+
+ + + + + + + + {SUPPORTED_LOCALES.map((code) => ( + setLocale(code as AppLocale)} + className={`text-xs cursor-pointer ${ + locale === code ? "text-white font-medium" : "text-white/60" + }`} + > + {LOCALE_LABELS[code] ?? code} + + ))} + + +
+ + +
+
+
+
+ ); } - diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx index 131e974d..303fd9c4 100644 --- a/src/components/video-editor/SettingsPanel.tsx +++ b/src/components/video-editor/SettingsPanel.tsx @@ -1,1167 +1,1098 @@ -import { cn } from "@/lib/utils"; -import { useEffect, useRef } from "react"; -import { getAssetPath, getRenderableAssetUrl } from "@/lib/assetPath"; -import { - BUILT_IN_WALLPAPERS, - WALLPAPER_PATHS, - WALLPAPER_RELATIVE_PATHS, -} from "@/lib/wallpapers"; -import { SliderControl } from "./SliderControl"; -import { Switch } from "@/components/ui/switch"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { Button } from "@/components/ui/button"; -import { useState } from "react"; import Block from "@uiw/react-color-block"; import { - Trash2, - Download, - Crop, - X, - Bug, - Upload, - Star, - Film, - Image, - Sparkles, - Palette, - Save, - FolderOpen, + Bug, + Crop, + Download, + Film, + FolderOpen, + Image, + Palette, + Save, + Sparkles, + Star, + Trash2, + Upload, + X, } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; import { toast } from "sonner"; -import { useI18n, useScopedT } from "../../contexts/I18nContext"; -import type { - ZoomDepth, - CropRegion, - AnnotationRegion, - AnnotationType, - PlaybackSpeed, -} from "./types"; import { - SPEED_OPTIONS, - DEFAULT_CURSOR_SIZE, - DEFAULT_CURSOR_SMOOTHING, - DEFAULT_CURSOR_MOTION_BLUR, - DEFAULT_CURSOR_CLICK_BOUNCE, - DEFAULT_CURSOR_SWAY, - DEFAULT_ZOOM_MOTION_BLUR, -} from "./types"; + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@/components/ui/accordion"; +import { Button } from "@/components/ui/button"; +import { Switch } from "@/components/ui/switch"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { getAssetPath, getRenderableAssetUrl } from "@/lib/assetPath"; +import type { ExportFormat, ExportQuality, GifFrameRate, GifSizePreset } from "@/lib/exporter"; +import { GIF_FRAME_RATES, GIF_SIZE_PRESETS } from "@/lib/exporter"; +import { cn } from "@/lib/utils"; +import { BUILT_IN_WALLPAPERS, WALLPAPER_PATHS, WALLPAPER_RELATIVE_PATHS } from "@/lib/wallpapers"; +import { type AspectRatio } from "@/utils/aspectRatioUtils"; +import { useI18n, useScopedT } from "../../contexts/I18nContext"; +import { AnnotationSettingsPanel } from "./AnnotationSettingsPanel"; import { CropControl } from "./CropControl"; import { KeyboardShortcutsHelp } from "./KeyboardShortcutsHelp"; -import { AnnotationSettingsPanel } from "./AnnotationSettingsPanel"; -import { type AspectRatio } from "@/utils/aspectRatioUtils"; +import { SliderControl } from "./SliderControl"; import type { - ExportQuality, - ExportFormat, - GifFrameRate, - GifSizePreset, -} from "@/lib/exporter"; -import { GIF_FRAME_RATES, GIF_SIZE_PRESETS } from "@/lib/exporter"; + AnnotationRegion, + AnnotationType, + CropRegion, + PlaybackSpeed, + ZoomDepth, +} from "./types"; import { - Accordion, - AccordionContent, - AccordionItem, - AccordionTrigger, -} from "@/components/ui/accordion"; + DEFAULT_CURSOR_CLICK_BOUNCE, + DEFAULT_CURSOR_MOTION_BLUR, + DEFAULT_CURSOR_SIZE, + DEFAULT_CURSOR_SMOOTHING, + DEFAULT_CURSOR_SWAY, + DEFAULT_ZOOM_MOTION_BLUR, + SPEED_OPTIONS, +} from "./types"; +import { fromCursorSwaySliderValue, toCursorSwaySliderValue } from "./videoPlayback/cursorSway"; + const GRADIENTS = [ - "linear-gradient( 111.6deg, rgba(114,167,232,1) 9.4%, rgba(253,129,82,1) 43.9%, rgba(253,129,82,1) 54.8%, rgba(249,202,86,1) 86.3% )", - "linear-gradient(120deg, #d4fc79 0%, #96e6a1 100%)", - "radial-gradient( circle farthest-corner at 3.2% 49.6%, rgba(80,12,139,0.87) 0%, rgba(161,10,144,0.72) 83.6% )", - "linear-gradient( 111.6deg, rgba(0,56,68,1) 0%, rgba(163,217,185,1) 51.5%, rgba(231, 148, 6, 1) 88.6% )", - "linear-gradient( 107.7deg, rgba(235,230,44,0.55) 8.4%, rgba(252,152,15,1) 90.3% )", - "linear-gradient( 91deg, rgba(72,154,78,1) 5.2%, rgba(251,206,70,1) 95.9% )", - "radial-gradient( circle farthest-corner at 10% 20%, rgba(2,37,78,1) 0%, rgba(4,56,126,1) 19.7%, rgba(85,245,221,1) 100.2% )", - "linear-gradient( 109.6deg, rgba(15,2,2,1) 11.2%, rgba(36,163,190,1) 91.1% )", - "linear-gradient(135deg, #FBC8B4, #2447B1)", - "linear-gradient(109.6deg, #F635A6, #36D860)", - "linear-gradient(90deg, #FF0101, #4DFF01)", - "linear-gradient(315deg, #EC0101, #5044A9)", - "linear-gradient(45deg, #ff9a9e 0%, #fad0c4 99%, #fad0c4 100%)", - "linear-gradient(to top, #a18cd1 0%, #fbc2eb 100%)", - "linear-gradient(to right, #ff8177 0%, #ff867a 0%, #ff8c7f 21%, #f99185 52%, #cf556c 78%, #b12a5b 100%)", - "linear-gradient(120deg, #84fab0 0%, #8fd3f4 100%)", - "linear-gradient(to right, #4facfe 0%, #00f2fe 100%)", - "linear-gradient(to top, #fcc5e4 0%, #fda34b 15%, #ff7882 35%, #c8699e 52%, #7046aa 71%, #0c1db8 87%, #020f75 100%)", - "linear-gradient(to right, #fa709a 0%, #fee140 100%)", - "linear-gradient(to top, #30cfd0 0%, #330867 100%)", - "linear-gradient(to top, #c471f5 0%, #fa71cd 100%)", - "linear-gradient(to right, #f78ca0 0%, #f9748f 19%, #fd868c 60%, #fe9a8b 100%)", - "linear-gradient(to top, #48c6ef 0%, #6f86d6 100%)", - "linear-gradient(to right, #0acffe 0%, #495aff 100%)", + "linear-gradient( 111.6deg, rgba(114,167,232,1) 9.4%, rgba(253,129,82,1) 43.9%, rgba(253,129,82,1) 54.8%, rgba(249,202,86,1) 86.3% )", + "linear-gradient(120deg, #d4fc79 0%, #96e6a1 100%)", + "radial-gradient( circle farthest-corner at 3.2% 49.6%, rgba(80,12,139,0.87) 0%, rgba(161,10,144,0.72) 83.6% )", + "linear-gradient( 111.6deg, rgba(0,56,68,1) 0%, rgba(163,217,185,1) 51.5%, rgba(231, 148, 6, 1) 88.6% )", + "linear-gradient( 107.7deg, rgba(235,230,44,0.55) 8.4%, rgba(252,152,15,1) 90.3% )", + "linear-gradient( 91deg, rgba(72,154,78,1) 5.2%, rgba(251,206,70,1) 95.9% )", + "radial-gradient( circle farthest-corner at 10% 20%, rgba(2,37,78,1) 0%, rgba(4,56,126,1) 19.7%, rgba(85,245,221,1) 100.2% )", + "linear-gradient( 109.6deg, rgba(15,2,2,1) 11.2%, rgba(36,163,190,1) 91.1% )", + "linear-gradient(135deg, #FBC8B4, #2447B1)", + "linear-gradient(109.6deg, #F635A6, #36D860)", + "linear-gradient(90deg, #FF0101, #4DFF01)", + "linear-gradient(315deg, #EC0101, #5044A9)", + "linear-gradient(45deg, #ff9a9e 0%, #fad0c4 99%, #fad0c4 100%)", + "linear-gradient(to top, #a18cd1 0%, #fbc2eb 100%)", + "linear-gradient(to right, #ff8177 0%, #ff867a 0%, #ff8c7f 21%, #f99185 52%, #cf556c 78%, #b12a5b 100%)", + "linear-gradient(120deg, #84fab0 0%, #8fd3f4 100%)", + "linear-gradient(to right, #4facfe 0%, #00f2fe 100%)", + "linear-gradient(to top, #fcc5e4 0%, #fda34b 15%, #ff7882 35%, #c8699e 52%, #7046aa 71%, #0c1db8 87%, #020f75 100%)", + "linear-gradient(to right, #fa709a 0%, #fee140 100%)", + "linear-gradient(to top, #30cfd0 0%, #330867 100%)", + "linear-gradient(to top, #c471f5 0%, #fa71cd 100%)", + "linear-gradient(to right, #f78ca0 0%, #f9748f 19%, #fd868c 60%, #fe9a8b 100%)", + "linear-gradient(to top, #48c6ef 0%, #6f86d6 100%)", + "linear-gradient(to right, #0acffe 0%, #495aff 100%)", ]; interface SettingsPanelProps { - selected: string; - onWallpaperChange: (path: string) => void; - selectedZoomDepth?: ZoomDepth | null; - onZoomDepthChange?: (depth: ZoomDepth) => void; - selectedZoomId?: string | null; - onZoomDelete?: (id: string) => void; - selectedTrimId?: string | null; - onTrimDelete?: (id: string) => void; - shadowIntensity?: number; - onShadowChange?: (intensity: number) => void; - backgroundBlur?: number; - onBackgroundBlurChange?: (amount: number) => void; - zoomMotionBlur?: number; - onZoomMotionBlurChange?: (amount: number) => void; - connectZooms?: boolean; - onConnectZoomsChange?: (enabled: boolean) => void; - showCursor?: boolean; - onShowCursorChange?: (enabled: boolean) => void; - loopCursor?: boolean; - onLoopCursorChange?: (enabled: boolean) => void; - cursorSize?: number; - onCursorSizeChange?: (size: number) => void; - cursorSmoothing?: number; - onCursorSmoothingChange?: (smoothing: number) => void; - cursorMotionBlur?: number; - onCursorMotionBlurChange?: (amount: number) => void; - cursorClickBounce?: number; - onCursorClickBounceChange?: (amount: number) => void; - cursorSway?: number; - onCursorSwayChange?: (amount: number) => void; - borderRadius?: number; - onBorderRadiusChange?: (radius: number) => void; - padding?: number; - onPaddingChange?: (padding: number) => void; - cropRegion?: CropRegion; - onCropChange?: (region: CropRegion) => void; - aspectRatio: AspectRatio; - videoElement?: HTMLVideoElement | null; - exportQuality?: ExportQuality; - onExportQualityChange?: (quality: ExportQuality) => void; - // Export format settings - exportFormat?: ExportFormat; - onExportFormatChange?: (format: ExportFormat) => void; - gifFrameRate?: GifFrameRate; - onGifFrameRateChange?: (rate: GifFrameRate) => void; - gifLoop?: boolean; - onGifLoopChange?: (loop: boolean) => void; - gifSizePreset?: GifSizePreset; - onGifSizePresetChange?: (preset: GifSizePreset) => void; - gifOutputDimensions?: { width: number; height: number }; - onSaveProject?: () => void; - onLoadProject?: () => void; - onExport?: () => void; - selectedAnnotationId?: string | null; - annotationRegions?: AnnotationRegion[]; - onAnnotationContentChange?: (id: string, content: string) => void; - onAnnotationTypeChange?: (id: string, type: AnnotationType) => void; - onAnnotationStyleChange?: ( - id: string, - style: Partial, - ) => void; - onAnnotationFigureDataChange?: (id: string, figureData: any) => void; - onAnnotationDelete?: (id: string) => void; - selectedSpeedId?: string | null; - selectedSpeedValue?: PlaybackSpeed | null; - onSpeedChange?: (speed: PlaybackSpeed) => void; - onSpeedDelete?: (id: string) => void; + selected: string; + onWallpaperChange: (path: string) => void; + selectedZoomDepth?: ZoomDepth | null; + onZoomDepthChange?: (depth: ZoomDepth) => void; + selectedZoomId?: string | null; + onZoomDelete?: (id: string) => void; + selectedTrimId?: string | null; + onTrimDelete?: (id: string) => void; + shadowIntensity?: number; + onShadowChange?: (intensity: number) => void; + backgroundBlur?: number; + onBackgroundBlurChange?: (amount: number) => void; + zoomMotionBlur?: number; + onZoomMotionBlurChange?: (amount: number) => void; + connectZooms?: boolean; + onConnectZoomsChange?: (enabled: boolean) => void; + showCursor?: boolean; + onShowCursorChange?: (enabled: boolean) => void; + loopCursor?: boolean; + onLoopCursorChange?: (enabled: boolean) => void; + cursorSize?: number; + onCursorSizeChange?: (size: number) => void; + cursorSmoothing?: number; + onCursorSmoothingChange?: (smoothing: number) => void; + cursorMotionBlur?: number; + onCursorMotionBlurChange?: (amount: number) => void; + cursorClickBounce?: number; + onCursorClickBounceChange?: (amount: number) => void; + cursorSway?: number; + onCursorSwayChange?: (amount: number) => void; + borderRadius?: number; + onBorderRadiusChange?: (radius: number) => void; + padding?: number; + onPaddingChange?: (padding: number) => void; + cropRegion?: CropRegion; + onCropChange?: (region: CropRegion) => void; + aspectRatio: AspectRatio; + videoElement?: HTMLVideoElement | null; + exportQuality?: ExportQuality; + onExportQualityChange?: (quality: ExportQuality) => void; + // Export format settings + exportFormat?: ExportFormat; + onExportFormatChange?: (format: ExportFormat) => void; + gifFrameRate?: GifFrameRate; + onGifFrameRateChange?: (rate: GifFrameRate) => void; + gifLoop?: boolean; + onGifLoopChange?: (loop: boolean) => void; + gifSizePreset?: GifSizePreset; + onGifSizePresetChange?: (preset: GifSizePreset) => void; + gifOutputDimensions?: { width: number; height: number }; + onSaveProject?: () => void; + onLoadProject?: () => void; + onExport?: () => void; + selectedAnnotationId?: string | null; + annotationRegions?: AnnotationRegion[]; + onAnnotationContentChange?: (id: string, content: string) => void; + onAnnotationTypeChange?: (id: string, type: AnnotationType) => void; + onAnnotationStyleChange?: (id: string, style: Partial) => void; + onAnnotationFigureDataChange?: (id: string, figureData: any) => void; + onAnnotationDelete?: (id: string) => void; + selectedSpeedId?: string | null; + selectedSpeedValue?: PlaybackSpeed | null; + onSpeedChange?: (speed: PlaybackSpeed) => void; + onSpeedDelete?: (id: string) => void; } export default SettingsPanel; const ZOOM_DEPTH_OPTIONS: Array<{ depth: ZoomDepth; label: string }> = [ - { depth: 1, label: "1.25×" }, - { depth: 2, label: "1.5×" }, - { depth: 3, label: "1.8×" }, - { depth: 4, label: "2.2×" }, - { depth: 5, label: "3.5×" }, - { depth: 6, label: "5×" }, + { depth: 1, label: "1.25×" }, + { depth: 2, label: "1.5×" }, + { depth: 3, label: "1.8×" }, + { depth: 4, label: "2.2×" }, + { depth: 5, label: "3.5×" }, + { depth: 6, label: "5×" }, ]; export function SettingsPanel({ - selected, - onWallpaperChange, - selectedZoomDepth, - onZoomDepthChange, - selectedZoomId, - onZoomDelete, - selectedTrimId, - onTrimDelete, - shadowIntensity = 0.67, - onShadowChange, - backgroundBlur = 0, - onBackgroundBlurChange, - zoomMotionBlur = 0, - onZoomMotionBlurChange, - connectZooms = true, - onConnectZoomsChange, - showCursor = false, - onShowCursorChange, - loopCursor = false, - onLoopCursorChange, - cursorSize = 5, - onCursorSizeChange, - cursorSmoothing = 2, - onCursorSmoothingChange, - cursorMotionBlur = 0.35, - onCursorMotionBlurChange, - cursorClickBounce = 1, - onCursorClickBounceChange, - cursorSway = DEFAULT_CURSOR_SWAY, - onCursorSwayChange, - borderRadius = 12.5, - onBorderRadiusChange, - padding = 50, - onPaddingChange, - cropRegion, - onCropChange, - aspectRatio, - videoElement, - exportQuality = "good", - onExportQualityChange, - exportFormat = "mp4", - onExportFormatChange, - gifFrameRate = 15, - onGifFrameRateChange, - gifLoop = true, - onGifLoopChange, - gifSizePreset = "medium", - onGifSizePresetChange, - gifOutputDimensions = { width: 1280, height: 720 }, - onSaveProject, - onLoadProject, - onExport, - selectedAnnotationId, - annotationRegions = [], - onAnnotationContentChange, - onAnnotationTypeChange, - onAnnotationStyleChange, - onAnnotationFigureDataChange, - onAnnotationDelete, - selectedSpeedId, - selectedSpeedValue, - onSpeedChange, - onSpeedDelete, + selected, + onWallpaperChange, + selectedZoomDepth, + onZoomDepthChange, + selectedZoomId, + onZoomDelete, + selectedTrimId, + onTrimDelete, + shadowIntensity = 0.67, + onShadowChange, + backgroundBlur = 0, + onBackgroundBlurChange, + zoomMotionBlur = 0, + onZoomMotionBlurChange, + connectZooms = true, + onConnectZoomsChange, + showCursor = false, + onShowCursorChange, + loopCursor = false, + onLoopCursorChange, + cursorSize = 5, + onCursorSizeChange, + cursorSmoothing = 2, + onCursorSmoothingChange, + cursorMotionBlur = 0.35, + onCursorMotionBlurChange, + cursorClickBounce = 1, + onCursorClickBounceChange, + cursorSway = DEFAULT_CURSOR_SWAY, + onCursorSwayChange, + borderRadius = 12.5, + onBorderRadiusChange, + padding = 50, + onPaddingChange, + cropRegion, + onCropChange, + aspectRatio, + videoElement, + exportQuality = "good", + onExportQualityChange, + exportFormat = "mp4", + onExportFormatChange, + gifFrameRate = 15, + onGifFrameRateChange, + gifLoop = true, + onGifLoopChange, + gifSizePreset = "medium", + onGifSizePresetChange, + gifOutputDimensions = { width: 1280, height: 720 }, + onSaveProject, + onLoadProject, + onExport, + selectedAnnotationId, + annotationRegions = [], + onAnnotationContentChange, + onAnnotationTypeChange, + onAnnotationStyleChange, + onAnnotationFigureDataChange, + onAnnotationDelete, + selectedSpeedId, + selectedSpeedValue, + onSpeedChange, + onSpeedDelete, }: SettingsPanelProps) { - const tSettings = useScopedT("settings"); - const { t } = useI18n(); - const [wallpaperPreviewPaths, setWallpaperPreviewPaths] = useState( - [], - ); - const [customImages, setCustomImages] = useState([]); - const fileInputRef = useRef(null); + const tSettings = useScopedT("settings"); + const { t } = useI18n(); + const [wallpaperPreviewPaths, setWallpaperPreviewPaths] = useState([]); + const [customImages, setCustomImages] = useState([]); + const fileInputRef = useRef(null); - useEffect(() => { - let mounted = true; - (async () => { - try { - const resolved = await Promise.all( - WALLPAPER_RELATIVE_PATHS.map(async (path) => - getRenderableAssetUrl(await getAssetPath(path)), - ), - ); - if (mounted) setWallpaperPreviewPaths(resolved); - } catch (err) { - if (mounted) setWallpaperPreviewPaths(WALLPAPER_PATHS); - } - })(); - return () => { - mounted = false; - }; - }, []); - const colorPalette = [ - "#FF0000", - "#FFD700", - "#00FF00", - "#FFFFFF", - "#0000FF", - "#FF6B00", - "#9B59B6", - "#E91E63", - "#00BCD4", - "#FF5722", - "#8BC34A", - "#FFC107", - "#2563EB", - "#000000", - "#607D8B", - "#795548", - ]; + useEffect(() => { + let mounted = true; + (async () => { + try { + const resolved = await Promise.all( + WALLPAPER_RELATIVE_PATHS.map(async (path) => + getRenderableAssetUrl(await getAssetPath(path)), + ), + ); + if (mounted) setWallpaperPreviewPaths(resolved); + } catch (err) { + if (mounted) setWallpaperPreviewPaths(WALLPAPER_PATHS); + } + })(); + return () => { + mounted = false; + }; + }, []); + const colorPalette = [ + "#FF0000", + "#FFD700", + "#00FF00", + "#FFFFFF", + "#0000FF", + "#FF6B00", + "#9B59B6", + "#E91E63", + "#00BCD4", + "#FF5722", + "#8BC34A", + "#FFC107", + "#2563EB", + "#000000", + "#607D8B", + "#795548", + ]; - const [selectedColor, setSelectedColor] = useState("#ADADAD"); - const [gradient, setGradient] = useState(GRADIENTS[0]); - const [showCropModal, setShowCropModal] = useState(false); - const cropSnapshotRef = useRef(null); + const [selectedColor, setSelectedColor] = useState("#ADADAD"); + const [gradient, setGradient] = useState(GRADIENTS[0]); + const [showCropModal, setShowCropModal] = useState(false); + const cropSnapshotRef = useRef(null); - const zoomEnabled = Boolean(selectedZoomDepth); - const trimEnabled = Boolean(selectedTrimId); + const zoomEnabled = Boolean(selectedZoomDepth); + const trimEnabled = Boolean(selectedTrimId); - const handleDeleteClick = () => { - if (selectedZoomId && onZoomDelete) { - onZoomDelete(selectedZoomId); - } - }; + const handleDeleteClick = () => { + if (selectedZoomId && onZoomDelete) { + onZoomDelete(selectedZoomId); + } + }; - const handleTrimDeleteClick = () => { - if (selectedTrimId && onTrimDelete) { - onTrimDelete(selectedTrimId); - } - }; + const handleTrimDeleteClick = () => { + if (selectedTrimId && onTrimDelete) { + onTrimDelete(selectedTrimId); + } + }; - const handleCropToggle = () => { - if (!showCropModal && cropRegion) { - cropSnapshotRef.current = { ...cropRegion }; - } - setShowCropModal(!showCropModal); - }; + const handleCropToggle = () => { + if (!showCropModal && cropRegion) { + cropSnapshotRef.current = { ...cropRegion }; + } + setShowCropModal(!showCropModal); + }; - const handleCropCancel = () => { - if (cropSnapshotRef.current && onCropChange) { - onCropChange(cropSnapshotRef.current); - } - setShowCropModal(false); - }; + const handleCropCancel = () => { + if (cropSnapshotRef.current && onCropChange) { + onCropChange(cropSnapshotRef.current); + } + setShowCropModal(false); + }; - const handleImageUpload = (event: React.ChangeEvent) => { - const files = event.target.files; - if (!files || files.length === 0) return; + const handleImageUpload = (event: React.ChangeEvent) => { + const files = event.target.files; + if (!files || files.length === 0) return; - const file = files[0]; + const file = files[0]; - // Validate file type - only allow JPG/JPEG - const validTypes = ["image/jpeg", "image/jpg"]; - if (!validTypes.includes(file.type)) { - toast.error(tSettings("background.uploadError"), { - description: tSettings("background.uploadErrorDescription"), - }); - event.target.value = ""; - return; - } + // Validate file type - only allow JPG/JPEG + const validTypes = ["image/jpeg", "image/jpg"]; + if (!validTypes.includes(file.type)) { + toast.error(tSettings("background.uploadError"), { + description: tSettings("background.uploadErrorDescription"), + }); + event.target.value = ""; + return; + } - const reader = new FileReader(); + const reader = new FileReader(); - reader.onload = (e) => { - const dataUrl = e.target?.result as string; - if (dataUrl) { - setCustomImages((prev) => [...prev, dataUrl]); - onWallpaperChange(dataUrl); - toast.success(tSettings("background.uploadSuccess")); - } - }; + reader.onload = (e) => { + const dataUrl = e.target?.result as string; + if (dataUrl) { + setCustomImages((prev) => [...prev, dataUrl]); + onWallpaperChange(dataUrl); + toast.success(tSettings("background.uploadSuccess")); + } + }; - reader.onerror = () => { - toast.error(t("common.failedToUploadImage"), { - description: t("common.errorReadingFile"), - }); - }; + reader.onerror = () => { + toast.error(t("common.failedToUploadImage"), { + description: t("common.errorReadingFile"), + }); + }; - reader.readAsDataURL(file); - // Reset input so the same file can be selected again - event.target.value = ""; - }; + reader.readAsDataURL(file); + // Reset input so the same file can be selected again + event.target.value = ""; + }; - const handleRemoveCustomImage = ( - imageUrl: string, - event: React.MouseEvent, - ) => { - event.stopPropagation(); - setCustomImages((prev) => prev.filter((img) => img !== imageUrl)); - // If the removed image was selected, clear selection - if (selected === imageUrl) { - onWallpaperChange(WALLPAPER_PATHS[0]); - } - }; + const handleRemoveCustomImage = (imageUrl: string, event: React.MouseEvent) => { + event.stopPropagation(); + setCustomImages((prev) => prev.filter((img) => img !== imageUrl)); + // If the removed image was selected, clear selection + if (selected === imageUrl) { + onWallpaperChange(WALLPAPER_PATHS[0]); + } + }; - // Find selected annotation - const selectedAnnotation = selectedAnnotationId - ? annotationRegions.find((a) => a.id === selectedAnnotationId) - : null; + // Find selected annotation + const selectedAnnotation = selectedAnnotationId + ? annotationRegions.find((a) => a.id === selectedAnnotationId) + : null; - // If an annotation is selected, show annotation settings instead - if ( - selectedAnnotation && - onAnnotationContentChange && - onAnnotationTypeChange && - onAnnotationStyleChange && - 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 - } - onDelete={() => onAnnotationDelete(selectedAnnotation.id)} - /> - ); - } + // If an annotation is selected, show annotation settings instead + if ( + selectedAnnotation && + onAnnotationContentChange && + onAnnotationTypeChange && + onAnnotationStyleChange && + 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 + } + onDelete={() => onAnnotationDelete(selectedAnnotation.id)} + /> + ); + } - return ( -
-
-
-
- - {tSettings("zoom.level")} - -
- {zoomEnabled && selectedZoomDepth && ( - - { - ZOOM_DEPTH_OPTIONS.find( - (o) => o.depth === selectedZoomDepth, - )?.label - } - - )} - -
-
-
- {ZOOM_DEPTH_OPTIONS.map((option) => { - const isActive = selectedZoomDepth === option.depth; - return ( - - ); - })} -
- {!zoomEnabled && ( -

- {tSettings("zoom.selectRegion")} -

- )} - {zoomEnabled && ( - - )} -
+ return ( +
+
+
+
+ {tSettings("zoom.level")} +
+ {zoomEnabled && selectedZoomDepth && ( + + {ZOOM_DEPTH_OPTIONS.find((o) => o.depth === selectedZoomDepth)?.label} + + )} + +
+
+
+ {ZOOM_DEPTH_OPTIONS.map((option) => { + const isActive = selectedZoomDepth === option.depth; + return ( + + ); + })} +
+ {!zoomEnabled && ( +

+ {tSettings("zoom.selectRegion")} +

+ )} + {zoomEnabled && ( + + )} +
- {trimEnabled && ( -
- -
- )} + {trimEnabled && ( +
+ +
+ )} -
-
- - {tSettings("speed.playbackSpeed")} - - {selectedSpeedId && selectedSpeedValue && ( - - {SPEED_OPTIONS.find((o) => o.speed === selectedSpeedValue) - ?.label ?? `${selectedSpeedValue}×`} - - )} -
-
- {SPEED_OPTIONS.map((option) => { - const isActive = selectedSpeedValue === option.speed; - return ( - - ); - })} -
- {!selectedSpeedId && ( -

- {tSettings("speed.selectRegion")} -

- )} - {selectedSpeedId && ( - - )} -
+
+
+ + {tSettings("speed.playbackSpeed")} + + {selectedSpeedId && selectedSpeedValue && ( + + {SPEED_OPTIONS.find((o) => o.speed === selectedSpeedValue)?.label ?? + `${selectedSpeedValue}×`} + + )} +
+
+ {SPEED_OPTIONS.map((option) => { + const isActive = selectedSpeedValue === option.speed; + return ( + + ); + })} +
+ {!selectedSpeedId && ( +

+ {tSettings("speed.selectRegion")} +

+ )} + {selectedSpeedId && ( + + )} +
- - - -
- - - {tSettings("effects.title")} - -
-
- -
-
-
- {tSettings("effects.showCursor")} -
- -
-
-
-
- {tSettings("effects.loopCursor")} -
-
- -
-
- onBackgroundBlurChange?.(v)} - formatValue={(v) => `${v.toFixed(1)}px`} - parseInput={(t) => parseFloat(t.replace(/px$/, ""))} - /> -
-
+ + + +
+ + {tSettings("effects.title")} +
+
+ +
+
+
+ {tSettings("effects.showCursor")} +
+ +
+
+
+
+ {tSettings("effects.loopCursor")} +
+
+ +
+
+ onBackgroundBlurChange?.(v)} + formatValue={(v) => `${v.toFixed(1)}px`} + parseInput={(t) => parseFloat(t.replace(/px$/, ""))} + /> +
+
-
-
- onZoomMotionBlurChange?.(v)} - formatValue={(v) => `${v.toFixed(2)}×`} - parseInput={(t) => parseFloat(t.replace(/×$/, ""))} - /> -
+
+
+ onZoomMotionBlurChange?.(v)} + formatValue={(v) => `${v.toFixed(2)}×`} + parseInput={(t) => parseFloat(t.replace(/×$/, ""))} + /> +
-
-
- {tSettings("effects.connectZooms")} -
- -
-
+
+
+ {tSettings("effects.connectZooms")} +
+ +
+
-
-
- onCursorSizeChange?.(v)} - formatValue={(v) => `${v.toFixed(2)}×`} - parseInput={(t) => parseFloat(t.replace(/×$/, ""))} - /> -
-
- onCursorSmoothingChange?.(v)} - formatValue={(v) => (v <= 0 ? "Off" : v.toFixed(2))} - parseInput={(t) => parseFloat(t)} - /> -
-
+
+
+ onCursorSizeChange?.(v)} + formatValue={(v) => `${v.toFixed(2)}×`} + parseInput={(t) => parseFloat(t.replace(/×$/, ""))} + /> +
+
+ onCursorSmoothingChange?.(v)} + formatValue={(v) => (v <= 0 ? "Off" : v.toFixed(2))} + parseInput={(t) => parseFloat(t)} + /> +
+
-
-
- onCursorMotionBlurChange?.(v)} - formatValue={(v) => `${v.toFixed(2)}×`} - parseInput={(t) => parseFloat(t.replace(/×$/, ""))} - /> -
-
- onCursorClickBounceChange?.(v)} - formatValue={(v) => `${v.toFixed(2)}×`} - parseInput={(t) => parseFloat(t.replace(/×$/, ""))} - /> -
-
+
+
+ onCursorMotionBlurChange?.(v)} + formatValue={(v) => `${v.toFixed(2)}×`} + parseInput={(t) => parseFloat(t.replace(/×$/, ""))} + /> +
+
+ onCursorClickBounceChange?.(v)} + formatValue={(v) => `${v.toFixed(2)}×`} + parseInput={(t) => parseFloat(t.replace(/×$/, ""))} + /> +
+
-
-
- onCursorSwayChange?.(v)} - formatValue={(v) => (v <= 0 ? "Off" : `${v.toFixed(2)}×`)} - parseInput={(t) => { - const normalized = t.trim().toLowerCase(); - if (normalized === "off") { - return 0; - } +
+
+ onCursorSwayChange?.(fromCursorSwaySliderValue(v))} + formatValue={(v) => (v <= 0 ? "Off" : `${v.toFixed(2)}×`)} + parseInput={(t) => { + const normalized = t.trim().toLowerCase(); + if (normalized === "off") { + return 0; + } - return parseFloat(t.replace(/×$/, "")); - }} - /> -
-
- onShadowChange?.(v)} - formatValue={(v) => `${Math.round(v * 100)}%`} - parseInput={(t) => parseFloat(t.replace(/%$/, "")) / 100} - /> -
-
- onBorderRadiusChange?.(v)} - formatValue={(v) => `${v}px`} - parseInput={(t) => parseFloat(t.replace(/px$/, ""))} - /> -
-
- onPaddingChange?.(v)} - formatValue={(v) => `${v}%`} - parseInput={(t) => parseFloat(t.replace(/%$/, ""))} - /> -
-
+ return parseFloat(t.replace(/×$/, "")); + }} + /> +
+
+ onShadowChange?.(v)} + formatValue={(v) => `${Math.round(v * 100)}%`} + parseInput={(t) => parseFloat(t.replace(/%$/, "")) / 100} + /> +
+
+ onBorderRadiusChange?.(v)} + formatValue={(v) => `${v}px`} + parseInput={(t) => parseFloat(t.replace(/px$/, ""))} + /> +
+
+ onPaddingChange?.(v)} + formatValue={(v) => `${v}%`} + parseInput={(t) => parseFloat(t.replace(/%$/, ""))} + /> +
+
- -
-
+ +
+
- - -
- - - {tSettings("background.title")} - -
-
- - - - - {tSettings("background.image")} - - - {tSettings("background.color")} - - - {tSettings("background.gradient")} - - + + +
+ + {tSettings("background.title")} +
+
+ + + + + {tSettings("background.image")} + + + {tSettings("background.color")} + + + {tSettings("background.gradient")} + + -
- - - +
+ + + -
- {customImages.map((imageUrl, idx) => { - const isSelected = selected === imageUrl; - return ( -
onWallpaperChange(imageUrl)} - role="button" - > - -
- ); - })} +
+ {customImages.map((imageUrl, idx) => { + const isSelected = selected === imageUrl; + return ( +
onWallpaperChange(imageUrl)} + role="button" + > + +
+ ); + })} - {(wallpaperPreviewPaths.length > 0 - ? wallpaperPreviewPaths - : WALLPAPER_PATHS - ).map((previewPath, index) => { - const wallpaper = BUILT_IN_WALLPAPERS[index]; - const wallpaperValue = - WALLPAPER_PATHS[index] ?? previewPath; - const isSelected = (() => { - if (!selected) return false; - if ( - selected === wallpaperValue || - selected === previewPath - ) - return true; - try { - const clean = (s: string) => - s.replace(/^file:\/\//, "").replace(/^\//, ""); - if (clean(selected).endsWith(clean(wallpaperValue))) - return true; - if (clean(wallpaperValue).endsWith(clean(selected))) - return true; - if (clean(selected).endsWith(clean(previewPath))) - return true; - if (clean(previewPath).endsWith(clean(selected))) - return true; - } catch {} - return false; - })(); - return ( -
onWallpaperChange(wallpaperValue)} - role="button" - /> - ); - })} -
- + {(wallpaperPreviewPaths.length > 0 + ? wallpaperPreviewPaths + : WALLPAPER_PATHS + ).map((previewPath, index) => { + const wallpaper = BUILT_IN_WALLPAPERS[index]; + const wallpaperValue = WALLPAPER_PATHS[index] ?? previewPath; + const isSelected = (() => { + if (!selected) return false; + if (selected === wallpaperValue || selected === previewPath) return true; + try { + const clean = (s: string) => + s.replace(/^file:\/\//, "").replace(/^\//, ""); + if (clean(selected).endsWith(clean(wallpaperValue))) return true; + if (clean(wallpaperValue).endsWith(clean(selected))) return true; + if (clean(selected).endsWith(clean(previewPath))) return true; + if (clean(previewPath).endsWith(clean(selected))) return true; + } catch {} + return false; + })(); + return ( +
onWallpaperChange(wallpaperValue)} + role="button" + /> + ); + })} +
+ - -
- { - setSelectedColor(color.hex); - onWallpaperChange(color.hex); - }} - style={{ - width: "100%", - borderRadius: "8px", - }} - /> -
-
+ +
+ { + setSelectedColor(color.hex); + onWallpaperChange(color.hex); + }} + style={{ + width: "100%", + borderRadius: "8px", + }} + /> +
+
- -
- {GRADIENTS.map((g, idx) => ( -
{ - setGradient(g); - onWallpaperChange(g); - }} - role="button" - /> - ))} -
- -
- - - - -
+ +
+ {GRADIENTS.map((g, idx) => ( +
{ + setGradient(g); + onWallpaperChange(g); + }} + role="button" + /> + ))} +
+ +
+ + + + +
- {showCropModal && cropRegion && onCropChange && ( - <> -
-
-
-
- - {tSettings("crop.title")} - -

- {tSettings("crop.instruction")} -

-
- -
- -
- -
-
- - )} + {showCropModal && cropRegion && onCropChange && ( + <> +
+
+
+
+ {tSettings("crop.title")} +

{tSettings("crop.instruction")}

+
+ +
+ +
+ +
+
+ + )} -
-
- - -
+
+
+ + +
- {exportFormat === "mp4" && ( -
- - - -
- )} + {exportFormat === "mp4" && ( +
+ + + +
+ )} - {exportFormat === "gif" && ( -
-
-
- {GIF_FRAME_RATES.map((rate) => ( - - ))} -
-
- {Object.entries(GIF_SIZE_PRESETS).map(([key, _preset]) => ( - - ))} -
-
-
- - {gifOutputDimensions.width} × {gifOutputDimensions.height}px - -
- - {tSettings("export.loop")} - - -
-
-
- )} + {exportFormat === "gif" && ( +
+
+
+ {GIF_FRAME_RATES.map((rate) => ( + + ))} +
+
+ {Object.entries(GIF_SIZE_PRESETS).map(([key, _preset]) => ( + + ))} +
+
+
+ + {gifOutputDimensions.width} × {gifOutputDimensions.height}px + +
+ {tSettings("export.loop")} + +
+
+
+ )} -
- - -
+
+ + +
- + -
- - -
-
-
- ); +
+ + +
+
+
+ ); } diff --git a/src/components/video-editor/videoPlayback/cursorSway.test.ts b/src/components/video-editor/videoPlayback/cursorSway.test.ts index 842eb020..1f10a51a 100644 --- a/src/components/video-editor/videoPlayback/cursorSway.test.ts +++ b/src/components/video-editor/videoPlayback/cursorSway.test.ts @@ -1,31 +1,37 @@ import { describe, expect, it } from "vitest"; -import { computeCursorSwayRotation } from "./cursorSway"; +import { + computeCursorSwayRotation, + fromCursorSwaySliderValue, + toCursorSwaySliderValue, +} from "./cursorSway"; describe("computeCursorSwayRotation", () => { - it("returns zero when sway is disabled or there is no movement", () => { - expect(computeCursorSwayRotation(120, 0, 16, 0)).toBe(0); - expect(computeCursorSwayRotation(0, 0, 16, 1)).toBe(0); - }); + it("returns zero when sway is disabled or there is no movement", () => { + expect(computeCursorSwayRotation(120, 0, 16, 0)).toBe(0); + expect(computeCursorSwayRotation(0, 0, 16, 1)).toBe(0); + }); - it("leans opposite the motion direction", () => { - expect(computeCursorSwayRotation(120, 0, 16, 1)).toBeLessThan(0); - expect(computeCursorSwayRotation(-120, 0, 16, 1)).toBeGreaterThan(0); - expect(computeCursorSwayRotation(0, 120, 16, 1)).toBeLessThan(0); - expect(computeCursorSwayRotation(0, -120, 16, 1)).toBeGreaterThan(0); - }); + it("leans with the motion direction", () => { + expect(computeCursorSwayRotation(120, 0, 16, 1)).toBeGreaterThan(0); + expect(computeCursorSwayRotation(-120, 0, 16, 1)).toBeLessThan(0); + expect(computeCursorSwayRotation(0, 120, 16, 1)).toBeGreaterThan(0); + expect(computeCursorSwayRotation(0, -120, 16, 1)).toBeLessThan(0); + }); - it("increases with faster movement for the same direction", () => { - const slow = Math.abs(computeCursorSwayRotation(24, 0, 48, 1)); - const fast = Math.abs(computeCursorSwayRotation(120, 0, 16, 1)); + it("increases with faster movement for the same direction", () => { + const slow = Math.abs(computeCursorSwayRotation(24, 0, 48, 1)); + const fast = Math.abs(computeCursorSwayRotation(120, 0, 16, 1)); - expect(fast).toBeGreaterThan(slow); - }); + expect(fast).toBeGreaterThan(slow); + }); - it("maps a 2x slider value to a 6x sway intensity", () => { - expect(computeCursorSwayRotation(-140, 0, 100, 2)).toBeCloseTo( - Math.PI / 3, - 6, - ); - }); + it("maps a 2x slider value to a 6x sway intensity", () => { + expect(computeCursorSwayRotation(-140, 0, 100, 2)).toBeCloseTo(-(Math.PI / 3), 6); + }); + + it("maps a 1x slider value to the previous 2x sway strength", () => { + expect(fromCursorSwaySliderValue(1)).toBe(2); + expect(toCursorSwaySliderValue(2)).toBe(1); + }); }); diff --git a/src/components/video-editor/videoPlayback/cursorSway.ts b/src/components/video-editor/videoPlayback/cursorSway.ts index 08c9c55e..dcbeef41 100644 --- a/src/components/video-editor/videoPlayback/cursorSway.ts +++ b/src/components/video-editor/videoPlayback/cursorSway.ts @@ -4,46 +4,38 @@ const CURSOR_SWAY_MAX_ROTATION = Math.PI / 18; const CURSOR_SWAY_SPEED_REFERENCE = 1400; const CURSOR_SWAY_VERTICAL_WEIGHT = 0.65; const CURSOR_SWAY_INTENSITY_SCALE = 3; +export const CURSOR_SWAY_SLIDER_SCALE = 2; function clamp(value: number, min: number, max: number) { - return Math.min(max, Math.max(min, value)); + return Math.min(max, Math.max(min, value)); } -export function computeCursorSwayRotation( - dx: number, - dy: number, - deltaMs: number, - sway: number, -) { - if (sway <= 0) { - return 0; - } +export function computeCursorSwayRotation(dx: number, dy: number, deltaMs: number, sway: number) { + if (sway <= 0) { + return 0; + } - const distance = Math.hypot(dx, dy); - if (!Number.isFinite(distance) || distance < 0.01) { - return 0; - } + const distance = Math.hypot(dx, dy); + if (!Number.isFinite(distance) || distance < 0.01) { + return 0; + } - const speedPxPerSecond = distance / (clampDeltaMs(deltaMs) / 1000); - const speedFactor = clamp( - speedPxPerSecond / CURSOR_SWAY_SPEED_REFERENCE, - 0, - 1, - ); - if (speedFactor <= 0) { - return 0; - } + const speedPxPerSecond = distance / (clampDeltaMs(deltaMs) / 1000); + const speedFactor = clamp(speedPxPerSecond / CURSOR_SWAY_SPEED_REFERENCE, 0, 1); + if (speedFactor <= 0) { + return 0; + } - const directionalBias = clamp( - (-dx - dy * CURSOR_SWAY_VERTICAL_WEIGHT) / distance, - -1, - 1, - ); - return ( - directionalBias * - speedFactor * - CURSOR_SWAY_MAX_ROTATION * - sway * - CURSOR_SWAY_INTENSITY_SCALE - ); + const directionalBias = clamp((dx + dy * CURSOR_SWAY_VERTICAL_WEIGHT) / distance, -1, 1); + return ( + directionalBias * speedFactor * CURSOR_SWAY_MAX_ROTATION * sway * CURSOR_SWAY_INTENSITY_SCALE + ); +} + +export function toCursorSwaySliderValue(sway: number) { + return sway / CURSOR_SWAY_SLIDER_SCALE; +} + +export function fromCursorSwaySliderValue(sliderValue: number) { + return sliderValue * CURSOR_SWAY_SLIDER_SCALE; } diff --git a/src/i18n/locales/en/launch.json b/src/i18n/locales/en/launch.json index 4829693f..18ef6790 100644 --- a/src/i18n/locales/en/launch.json +++ b/src/i18n/locales/en/launch.json @@ -10,6 +10,8 @@ "folderPath": "Path: /{{name}}/", "openVideoFile": "Open video file", "openProject": "Open project", + "hideHudFromVideo": "Hide HUD from recording", + "showHudInVideo": "Show HUD in recording", "hideHud": "Hide HUD", "closeApp": "Close App" }, diff --git a/src/i18n/locales/es/launch.json b/src/i18n/locales/es/launch.json index 0bd6d05e..8bd42e14 100644 --- a/src/i18n/locales/es/launch.json +++ b/src/i18n/locales/es/launch.json @@ -10,6 +10,8 @@ "folderPath": "Ruta: /{{name}}/", "openVideoFile": "Abrir archivo de video", "openProject": "Abrir proyecto", + "hideHudFromVideo": "Ocultar HUD en la grabación", + "showHudInVideo": "Mostrar HUD en la grabación", "hideHud": "Ocultar HUD", "closeApp": "Cerrar aplicación" }, diff --git a/src/i18n/locales/zh-CN/launch.json b/src/i18n/locales/zh-CN/launch.json index 9ac43f3a..32f42328 100644 --- a/src/i18n/locales/zh-CN/launch.json +++ b/src/i18n/locales/zh-CN/launch.json @@ -10,6 +10,8 @@ "folderPath": "路径:/{{name}}/", "openVideoFile": "打开视频文件", "openProject": "打开项目", + "hideHudFromVideo": "在录制中隐藏 HUD", + "showHudInVideo": "在录制中显示 HUD", "hideHud": "隐藏 HUD", "closeApp": "关闭应用" }, diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 845f5960..b101b2fe 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -1,151 +1,210 @@ -/// -/// - -interface ProcessedDesktopSource { - id: string; - name: string; - display_id: string; - thumbnail: string | null; - appIcon: string | null; - originalName?: string; - sourceType?: 'screen' | 'window'; - appName?: string; - windowTitle?: string; -} - -interface CursorTelemetryPoint { - timeMs: number; - cx: number; - cy: number; - interactionType?: 'move' | 'click' | 'double-click' | 'right-click' | 'middle-click' | 'mouseup'; - cursorType?: 'arrow' | 'text' | 'pointer' | 'crosshair' | 'open-hand' | 'closed-hand' | 'resize-ew' | 'resize-ns' | 'not-allowed'; -} - -interface SystemCursorAsset { - dataUrl: string; - hotspotX: number; - hotspotY: number; - width: number; - height: number; -} - -interface Window { - electronAPI: { - getSources: (opts: Electron.SourcesOptions) => Promise - switchToEditor: () => Promise - openSourceSelector: () => Promise - selectSource: (source: any) => Promise - getSelectedSource: () => Promise - startNativeScreenRecording: (source: any, options?: { - capturesSystemAudio?: boolean - capturesMicrophone?: boolean - microphoneDeviceId?: string - microphoneLabel?: string - }) => Promise<{ - success: boolean - path?: string - message?: string - error?: string - }> - stopNativeScreenRecording: () => Promise<{ - success: boolean - path?: string - message?: string - error?: string - }> - startFfmpegRecording: (source: any) => Promise<{ - success: boolean - path?: string - message?: string - error?: string - }> - stopFfmpegRecording: () => Promise<{ - success: boolean - path?: string - message?: string - error?: string - }> - storeRecordedVideo: (videoData: ArrayBuffer, fileName: string) => Promise<{ - success: boolean - path?: string - message: string - error?: string - }> - getRecordedVideoPath: () => Promise<{ - success: boolean - path?: string - message?: string - error?: string - }> - readLocalFile: (filePath: string) => Promise<{ - success: boolean - data?: Uint8Array - error?: string - }> - getAssetBasePath: () => Promise - setRecordingState: (recording: boolean) => Promise - getCursorTelemetry: (videoPath?: string) => Promise<{ - success: boolean - samples: CursorTelemetryPoint[] - message?: string - error?: string - }> - getSystemCursorAssets: () => Promise<{ - success: boolean - cursors: Record - error?: string - }> - onStopRecordingFromTray: (callback: () => void) => () => void - onRecordingStateChanged: (callback: (state: { recording: boolean; sourceName: string }) => void) => () => void - onRecordingInterrupted: (callback: (state: { reason: string; message: string }) => void) => () => void - onCursorStateChanged: (callback: (state: { cursorType: CursorTelemetryPoint['cursorType'] }) => void) => () => void - openExternalUrl: (url: string) => Promise<{ success: boolean; error?: string }> - getAccessibilityPermissionStatus: () => Promise<{ success: boolean; trusted: boolean; prompted: boolean; error?: string }> - requestAccessibilityPermission: () => Promise<{ success: boolean; trusted: boolean; prompted: boolean; error?: string }> - getScreenRecordingPermissionStatus: () => Promise<{ success: boolean; status: string; error?: string }> - openScreenRecordingPreferences: () => Promise<{ success: boolean; error?: string }> - openAccessibilityPreferences: () => Promise<{ success: boolean; error?: string }> - saveExportedVideo: (videoData: ArrayBuffer, fileName: string) => Promise<{ - success: boolean - path?: string - message?: string - canceled?: boolean - }> - openVideoFilePicker: () => Promise<{ success: boolean; path?: string; canceled?: boolean }> - setCurrentVideoPath: (path: string) => Promise<{ success: boolean }> - getCurrentVideoPath: () => Promise<{ success: boolean; path?: string }> - clearCurrentVideoPath: () => Promise<{ success: boolean }> - saveProjectFile: (projectData: unknown, suggestedName?: string, existingProjectPath?: string) => Promise<{ - success: boolean - path?: string - message?: string - canceled?: boolean - error?: string - }> - loadProjectFile: () => Promise<{ - success: boolean - path?: string - project?: unknown - message?: string - canceled?: boolean - error?: string - }> - loadCurrentProjectFile: () => Promise<{ - success: boolean - path?: string - project?: unknown - message?: string - canceled?: boolean - error?: string - }> - onMenuLoadProject: (callback: () => void) => () => void - onMenuSaveProject: (callback: () => void) => () => void - onMenuSaveProjectAs: (callback: () => void) => () => void - hideOsCursor: () => Promise<{ success: boolean }> - setHasUnsavedChanges: (hasChanges: boolean) => void - onRequestSaveBeforeClose: (callback: () => Promise) => () => void - getRecordingsDirectory: () => Promise<{ success: boolean; path: string; isDefault: boolean; error?: string }> - chooseRecordingsDirectory: () => Promise<{ success: boolean; canceled?: boolean; path?: string; isDefault?: boolean; message?: string; error?: string }> - } -} - +/// +/// + +interface ProcessedDesktopSource { + id: string; + name: string; + display_id: string; + thumbnail: string | null; + appIcon: string | null; + originalName?: string; + sourceType?: "screen" | "window"; + appName?: string; + windowTitle?: string; +} + +interface CursorTelemetryPoint { + timeMs: number; + cx: number; + cy: number; + interactionType?: "move" | "click" | "double-click" | "right-click" | "middle-click" | "mouseup"; + cursorType?: + | "arrow" + | "text" + | "pointer" + | "crosshair" + | "open-hand" + | "closed-hand" + | "resize-ew" + | "resize-ns" + | "not-allowed"; +} + +interface SystemCursorAsset { + dataUrl: string; + hotspotX: number; + hotspotY: number; + width: number; + height: number; +} + +interface Window { + electronAPI: { + hudOverlayHide: () => void; + hudOverlayClose: () => void; + getHudOverlayCaptureProtection: () => Promise<{ success: boolean; enabled: boolean }>; + setHudOverlayCaptureProtection: ( + enabled: boolean, + ) => Promise<{ success: boolean; enabled: boolean }>; + getSources: (opts: Electron.SourcesOptions) => Promise; + switchToEditor: () => Promise; + openSourceSelector: () => Promise; + selectSource: (source: any) => Promise; + getSelectedSource: () => Promise; + startNativeScreenRecording: ( + source: any, + options?: { + capturesSystemAudio?: boolean; + capturesMicrophone?: boolean; + microphoneDeviceId?: string; + microphoneLabel?: string; + }, + ) => Promise<{ + success: boolean; + path?: string; + message?: string; + error?: string; + }>; + stopNativeScreenRecording: () => Promise<{ + success: boolean; + path?: string; + message?: string; + error?: string; + }>; + startFfmpegRecording: (source: any) => Promise<{ + success: boolean; + path?: string; + message?: string; + error?: string; + }>; + stopFfmpegRecording: () => Promise<{ + success: boolean; + path?: string; + message?: string; + error?: string; + }>; + storeRecordedVideo: ( + videoData: ArrayBuffer, + fileName: string, + ) => Promise<{ + success: boolean; + path?: string; + message: string; + error?: string; + }>; + getRecordedVideoPath: () => Promise<{ + success: boolean; + path?: string; + message?: string; + error?: string; + }>; + readLocalFile: (filePath: string) => Promise<{ + success: boolean; + data?: Uint8Array; + error?: string; + }>; + getAssetBasePath: () => Promise; + setRecordingState: (recording: boolean) => Promise; + getCursorTelemetry: (videoPath?: string) => Promise<{ + success: boolean; + samples: CursorTelemetryPoint[]; + message?: string; + error?: string; + }>; + getSystemCursorAssets: () => Promise<{ + success: boolean; + cursors: Record; + error?: string; + }>; + onStopRecordingFromTray: (callback: () => void) => () => void; + onRecordingStateChanged: ( + callback: (state: { recording: boolean; sourceName: string }) => void, + ) => () => void; + onRecordingInterrupted: ( + callback: (state: { reason: string; message: string }) => void, + ) => () => void; + onCursorStateChanged: ( + callback: (state: { cursorType: CursorTelemetryPoint["cursorType"] }) => void, + ) => () => void; + openExternalUrl: (url: string) => Promise<{ success: boolean; error?: string }>; + getAccessibilityPermissionStatus: () => Promise<{ + success: boolean; + trusted: boolean; + prompted: boolean; + error?: string; + }>; + requestAccessibilityPermission: () => Promise<{ + success: boolean; + trusted: boolean; + prompted: boolean; + error?: string; + }>; + getScreenRecordingPermissionStatus: () => Promise<{ + success: boolean; + status: string; + error?: string; + }>; + openScreenRecordingPreferences: () => Promise<{ success: boolean; error?: string }>; + openAccessibilityPreferences: () => Promise<{ success: boolean; error?: string }>; + saveExportedVideo: ( + videoData: ArrayBuffer, + fileName: string, + ) => Promise<{ + success: boolean; + path?: string; + message?: string; + canceled?: boolean; + }>; + openVideoFilePicker: () => Promise<{ success: boolean; path?: string; canceled?: boolean }>; + setCurrentVideoPath: (path: string) => Promise<{ success: boolean }>; + getCurrentVideoPath: () => Promise<{ success: boolean; path?: string }>; + clearCurrentVideoPath: () => Promise<{ success: boolean }>; + saveProjectFile: ( + projectData: unknown, + suggestedName?: string, + existingProjectPath?: string, + ) => Promise<{ + success: boolean; + path?: string; + message?: string; + canceled?: boolean; + error?: string; + }>; + loadProjectFile: () => Promise<{ + success: boolean; + path?: string; + project?: unknown; + message?: string; + canceled?: boolean; + error?: string; + }>; + loadCurrentProjectFile: () => Promise<{ + success: boolean; + path?: string; + project?: unknown; + message?: string; + canceled?: boolean; + error?: string; + }>; + onMenuLoadProject: (callback: () => void) => () => void; + onMenuSaveProject: (callback: () => void) => () => void; + onMenuSaveProjectAs: (callback: () => void) => () => void; + hideOsCursor: () => Promise<{ success: boolean }>; + setHasUnsavedChanges: (hasChanges: boolean) => void; + onRequestSaveBeforeClose: (callback: () => Promise) => () => void; + getRecordingsDirectory: () => Promise<{ + success: boolean; + path: string; + isDefault: boolean; + error?: string; + }>; + chooseRecordingsDirectory: () => Promise<{ + success: boolean; + canceled?: boolean; + path?: string; + isDefault?: boolean; + message?: string; + error?: string; + }>; + }; +}