diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index eb28420..05ed723 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -71,7 +71,11 @@ interface Window { message?: string; error?: string; }>; - setRecordingState: (recording: boolean, recordingId?: number) => Promise; + setRecordingState: ( + recording: boolean, + recordingId?: number, + cursorCaptureMode?: import("../src/lib/recordingSession").CursorCaptureMode, + ) => Promise; isNativeWindowsCaptureAvailable: () => Promise<{ success: boolean; available: boolean; diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 159ee2f..d82754d 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -18,6 +18,8 @@ import { } from "electron"; import type { NativeWindowsRecordingRequest } from "../../src/lib/nativeWindowsRecording"; import { + type CursorCaptureMode, + normalizeCursorCaptureMode, normalizeProjectMedia, normalizeRecordingSession, type ProjectMedia, @@ -273,6 +275,7 @@ let nativeWindowsCaptureTargetPath: string | null = null; let nativeWindowsCaptureWebcamTargetPath: string | null = null; let nativeWindowsCaptureRecordingId: number | null = null; let nativeWindowsCursorOffsetMs = 0; +let nativeWindowsCursorCaptureMode: CursorCaptureMode = "editable-overlay"; const NATIVE_WINDOWS_CAPTURE_STOP_TIMEOUT_MS = 15_000; function normalizeCursorSample(sample: unknown): CursorRecordingSample | null { @@ -1101,6 +1104,8 @@ export function registerIpcHandlers( const webcamDirectShowClsid = request.webcam.enabled ? await resolveDirectShowWebcamClsid(request.webcam.deviceName) : null; + const cursorCaptureMode = + normalizeCursorCaptureMode(request.cursor?.mode) ?? "editable-overlay"; const config = { schemaVersion: 2, recordingId, @@ -1129,6 +1134,8 @@ export function registerIpcHandlers( webcamWidth: request.webcam.width, webcamHeight: request.webcam.height, webcamFps: request.webcam.fps, + captureCursor: cursorCaptureMode === "system", + cursorCaptureMode, outputs: { screenPath: outputPath, webcamPath: webcamOutputPath, @@ -1143,6 +1150,9 @@ export function registerIpcHandlers( video: request.video, audio: request.audio, webcam: request.webcam, + cursor: { + mode: cursorCaptureMode, + }, }; console.info("[native-wgc] starting Windows capture", { @@ -1150,6 +1160,7 @@ export function registerIpcHandlers( source: request.source, audio: request.audio, webcam: request.webcam, + cursor: { mode: cursorCaptureMode }, bounds, sourceId: selectedSource?.id ?? null, usedDisplayMatch: Boolean(sourceDisplay), @@ -1162,13 +1173,18 @@ export function registerIpcHandlers( nativeWindowsCaptureWebcamTargetPath = request.webcam.enabled ? webcamOutputPath : null; nativeWindowsCaptureRecordingId = recordingId; nativeWindowsCursorOffsetMs = 0; + nativeWindowsCursorCaptureMode = cursorCaptureMode; const cursorStartTimeMs = Date.now(); - await startCursorRecording(cursorStartTimeMs); - console.info("[native-wgc] cursor sampler ready", { - cursorStartTimeMs, - warmupMs: Date.now() - cursorStartTimeMs, - }); + if (cursorCaptureMode === "editable-overlay") { + await startCursorRecording(cursorStartTimeMs); + console.info("[native-wgc] cursor sampler ready", { + cursorStartTimeMs, + warmupMs: Date.now() - cursorStartTimeMs, + }); + } else { + pendingCursorRecordingData = null; + } const proc = spawn(helperPath, [JSON.stringify(config)], { cwd: RECORDINGS_DIR, @@ -1179,7 +1195,10 @@ export function registerIpcHandlers( await waitForNativeWindowsCaptureStart(proc); const captureStartedAtMs = Date.now(); - nativeWindowsCursorOffsetMs = Math.max(0, captureStartedAtMs - cursorStartTimeMs); + nativeWindowsCursorOffsetMs = + cursorCaptureMode === "editable-overlay" + ? Math.max(0, captureStartedAtMs - cursorStartTimeMs) + : 0; const webcamFormat = readNativeWindowsWebcamFormat(nativeWindowsCaptureOutput); console.info("[native-wgc] capture started", { captureStartedAtMs, @@ -1206,6 +1225,7 @@ export function registerIpcHandlers( nativeWindowsCaptureWebcamTargetPath = null; nativeWindowsCaptureRecordingId = null; nativeWindowsCursorOffsetMs = 0; + nativeWindowsCursorCaptureMode = "editable-overlay"; await stopCursorRecording(); return { success: false, error: String(error) }; } @@ -1217,6 +1237,7 @@ export function registerIpcHandlers( const preferredPath = nativeWindowsCaptureTargetPath; const preferredWebcamPath = nativeWindowsCaptureWebcamTargetPath; const recordingId = nativeWindowsCaptureRecordingId ?? Date.now(); + const cursorCaptureMode = nativeWindowsCursorCaptureMode; if (!proc) { return { success: false, error: "Native Windows capture is not running." }; @@ -1231,7 +1252,11 @@ export function registerIpcHandlers( throw new Error("Native Windows capture did not return an output path."); } - await stopCursorRecording(); + if (cursorCaptureMode === "editable-overlay") { + await stopCursorRecording(); + } else { + pendingCursorRecordingData = null; + } if (discard) { pendingCursorRecordingData = null; await Promise.all([ @@ -1242,8 +1267,10 @@ export function registerIpcHandlers( return { success: true, discarded: true }; } - shiftPendingCursorTelemetry(nativeWindowsCursorOffsetMs); - await writePendingCursorTelemetry(screenVideoPath); + if (cursorCaptureMode === "editable-overlay") { + shiftPendingCursorTelemetry(nativeWindowsCursorOffsetMs); + await writePendingCursorTelemetry(screenVideoPath); + } let webcamVideoPath: string | undefined; if (preferredWebcamPath) { try { @@ -1254,8 +1281,8 @@ export function registerIpcHandlers( } } const session: RecordingSession = webcamVideoPath - ? { screenVideoPath, webcamVideoPath, createdAt: recordingId } - : { screenVideoPath, createdAt: recordingId }; + ? { screenVideoPath, webcamVideoPath, createdAt: recordingId, cursorCaptureMode } + : { screenVideoPath, createdAt: recordingId, cursorCaptureMode }; setCurrentRecordingSessionState(session); currentProjectPath = null; @@ -1281,6 +1308,7 @@ export function registerIpcHandlers( nativeWindowsCaptureWebcamTargetPath = null; nativeWindowsCaptureRecordingId = null; nativeWindowsCursorOffsetMs = 0; + nativeWindowsCursorCaptureMode = "editable-overlay"; const source = selectedSource || { name: "Screen" }; if (onRecordingStateChange) { onRecordingStateChange(false, source.name); @@ -1306,6 +1334,7 @@ export function registerIpcHandlers( typeof payload.createdAt === "number" && Number.isFinite(payload.createdAt) ? payload.createdAt : Date.now(); + const cursorCaptureMode = normalizeCursorCaptureMode(payload.cursorCaptureMode); const screenVideoPath = resolveRecordingOutputPath(payload.screen.fileName); await fs.writeFile(screenVideoPath, Buffer.from(payload.screen.videoData)); @@ -1316,8 +1345,13 @@ export function registerIpcHandlers( } const session: RecordingSession = webcamVideoPath - ? { screenVideoPath, webcamVideoPath, createdAt } - : { screenVideoPath, createdAt }; + ? { + screenVideoPath, + webcamVideoPath, + createdAt, + ...(cursorCaptureMode ? { cursorCaptureMode } : {}), + } + : { screenVideoPath, createdAt, ...(cursorCaptureMode ? { cursorCaptureMode } : {}) }; setCurrentRecordingSessionState(session); currentProjectPath = null; @@ -1378,18 +1412,23 @@ export function registerIpcHandlers( } }); - ipcMain.handle("set-recording-state", async (_, recording: boolean, recordingId?: number) => { - if (recording) { - await startCursorRecording(recordingId); - } else { - await stopCursorRecording(); - } + ipcMain.handle( + "set-recording-state", + async (_, recording: boolean, recordingId?: number, cursorCaptureMode?: CursorCaptureMode) => { + const normalizedCursorCaptureMode = + normalizeCursorCaptureMode(cursorCaptureMode) ?? "editable-overlay"; + if (recording && normalizedCursorCaptureMode === "editable-overlay") { + await startCursorRecording(recordingId); + } else { + await stopCursorRecording(); + } - const source = selectedSource || { name: "Screen" }; - if (onRecordingStateChange) { - onRecordingStateChange(recording, source.name); - } - }); + const source = selectedSource || { name: "Screen" }; + if (onRecordingStateChange) { + onRecordingStateChange(recording, source.name); + } + }, + ); ipcMain.handle("get-cursor-telemetry", async (_, videoPath?: string) => { const targetVideoPath = resolveApprovedVideoPath( diff --git a/electron/native/wgc-capture/src/main.cpp b/electron/native/wgc-capture/src/main.cpp index 70aec1a..ad46837 100644 --- a/electron/native/wgc-capture/src/main.cpp +++ b/electron/native/wgc-capture/src/main.cpp @@ -36,6 +36,7 @@ struct CaptureConfig { bool hasDisplayBounds = false; bool captureSystemAudio = false; bool captureMic = false; + bool captureCursor = false; bool webcamEnabled = false; std::string microphoneDeviceId; std::string microphoneDeviceName; @@ -302,6 +303,7 @@ bool parseConfig(const std::string& json, CaptureConfig& config) { config.hasDisplayBounds = findBool(json, "hasDisplayBounds", false); config.captureSystemAudio = findBool(json, "captureSystemAudio", false); config.captureMic = findBool(json, "captureMic", false); + config.captureCursor = findBool(json, "captureCursor", false); config.webcamEnabled = findBool(json, "webcamEnabled", false); config.microphoneDeviceId = findString(json, "microphoneDeviceId"); config.microphoneDeviceName = findString(json, "microphoneDeviceName"); @@ -355,7 +357,7 @@ int main(int argc, char* argv[]) { std::cerr << "ERROR: Could not resolve monitor" << std::endl; return 1; } - if (!session.initialize(monitor, config.fps)) { + if (!session.initialize(monitor, config.fps, config.captureCursor)) { std::cerr << "ERROR: Failed to initialize WGC display session" << std::endl; return 1; } @@ -365,7 +367,7 @@ int main(int argc, char* argv[]) { std::cerr << "ERROR: Native window capture requires a valid HWND" << std::endl; return 1; } - if (!session.initialize(window, config.fps)) { + if (!session.initialize(window, config.fps, config.captureCursor)) { std::cerr << "ERROR: Failed to initialize WGC window session" << std::endl; return 1; } diff --git a/electron/native/wgc-capture/src/wgc_session.cpp b/electron/native/wgc-capture/src/wgc_session.cpp index ab7e9e3..fbdc5a5 100644 --- a/electron/native/wgc-capture/src/wgc_session.cpp +++ b/electron/native/wgc-capture/src/wgc_session.cpp @@ -140,7 +140,21 @@ bool WgcSession::createCaptureItem(HWND window) { return width_ > 0 && height_ > 0; } -bool WgcSession::initialize(HMONITOR monitor, int fps) { +void WgcSession::applySessionOptions(bool captureCursor) { + try { + session_.IsCursorCaptureEnabled(captureCursor); + } catch (...) { + // Older WGC builds can omit this property. They will keep the OS default. + } + + try { + session_.IsBorderRequired(false); + } catch (...) { + // IsBorderRequired is Windows 11-only. Ignore it on older builds. + } +} + +bool WgcSession::initialize(HMONITOR monitor, int fps, bool captureCursor) { fps_ = fps > 0 ? fps : 60; if (!createD3DDevice()) { return false; @@ -156,23 +170,13 @@ bool WgcSession::initialize(HMONITOR monitor, int fps) { item_.Size()); session_ = framePool_.CreateCaptureSession(item_); - try { - session_.IsCursorCaptureEnabled(false); - } catch (...) { - // Older WGC builds can omit this property; callers still overlay their own cursor. - } - - try { - session_.IsBorderRequired(false); - } catch (...) { - // IsBorderRequired is Windows 11-only. Ignore it on older builds. - } + applySessionOptions(captureCursor); frameArrivedToken_ = framePool_.FrameArrived({this, &WgcSession::onFrameArrived}); return true; } -bool WgcSession::initialize(HWND window, int fps) { +bool WgcSession::initialize(HWND window, int fps, bool captureCursor) { fps_ = fps > 0 ? fps : 60; if (!createD3DDevice()) { return false; @@ -188,17 +192,7 @@ bool WgcSession::initialize(HWND window, int fps) { item_.Size()); session_ = framePool_.CreateCaptureSession(item_); - try { - session_.IsCursorCaptureEnabled(false); - } catch (...) { - // Older WGC builds can omit this property; callers still overlay their own cursor. - } - - try { - session_.IsBorderRequired(false); - } catch (...) { - // IsBorderRequired is Windows 11-only. Ignore it on older builds. - } + applySessionOptions(captureCursor); frameArrivedToken_ = framePool_.FrameArrived({this, &WgcSession::onFrameArrived}); return true; diff --git a/electron/native/wgc-capture/src/wgc_session.h b/electron/native/wgc-capture/src/wgc_session.h index 34ad3f5..4b7a0c4 100644 --- a/electron/native/wgc-capture/src/wgc_session.h +++ b/electron/native/wgc-capture/src/wgc_session.h @@ -22,8 +22,8 @@ public: WgcSession(const WgcSession&) = delete; WgcSession& operator=(const WgcSession&) = delete; - bool initialize(HMONITOR monitor, int fps); - bool initialize(HWND window, int fps); + bool initialize(HMONITOR monitor, int fps, bool captureCursor); + bool initialize(HWND window, int fps, bool captureCursor); void setFrameCallback(FrameCallback callback); bool start(); void stop(); @@ -37,6 +37,7 @@ private: bool createD3DDevice(); bool createCaptureItem(HMONITOR monitor); bool createCaptureItem(HWND window); + void applySessionOptions(bool captureCursor); void onFrameArrived( winrt::Windows::Graphics::Capture::Direct3D11CaptureFramePool const& sender, winrt::Windows::Foundation::IInspectable const&); diff --git a/electron/preload.ts b/electron/preload.ts index 2f9059f..022eb79 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -62,8 +62,12 @@ contextBridge.exposeInMainWorld("electronAPI", { getRecordedVideoPath: () => { return ipcRenderer.invoke("get-recorded-video-path"); }, - setRecordingState: (recording: boolean, recordingId?: number) => { - return ipcRenderer.invoke("set-recording-state", recording, recordingId); + setRecordingState: ( + recording: boolean, + recordingId?: number, + cursorCaptureMode?: import("../src/lib/recordingSession").CursorCaptureMode, + ) => { + return ipcRenderer.invoke("set-recording-state", recording, recordingId, cursorCaptureMode); }, isNativeWindowsCaptureAvailable: () => { return ipcRenderer.invoke("is-native-windows-capture-available"); diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index f84de86..071caa7 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -10,6 +10,7 @@ import { MdMic, MdMicOff, MdMonitor, + MdMouse, MdRestartAlt, MdVideocam, MdVideocamOff, @@ -43,6 +44,7 @@ const ICON_CONFIG = { micOff: { icon: MdMicOff, size: ICON_SIZE }, webcamOn: { icon: MdVideocam, size: ICON_SIZE }, webcamOff: { icon: MdVideocamOff, size: ICON_SIZE }, + cursor: { icon: MdMouse, size: ICON_SIZE }, pause: { icon: BsPauseCircle, size: ICON_SIZE }, resume: { icon: BsPlayCircle, size: ICON_SIZE }, stop: { icon: FaRegStopCircle, size: ICON_SIZE }, @@ -110,6 +112,8 @@ export function LaunchWindow() { webcamDeviceId, setWebcamDeviceId, setWebcamDeviceName, + cursorCaptureMode, + setCursorCaptureMode, } = useScreenRecorder(); const showMicControls = microphoneEnabled && !recording; @@ -123,6 +127,7 @@ export function LaunchWindow() { const [isWebcamFocused, setIsWebcamFocused] = useState(false); const webcamExpanded = isWebcamHovered || isWebcamFocused; const [isLanguageMenuOpen, setIsLanguageMenuOpen] = useState(false); + const [isWindows, setIsWindows] = useState(false); const languageTriggerRef = useRef(null); const languageMenuPanelRef = useRef(null); const [languageMenuStyle, setLanguageMenuStyle] = useState<{ @@ -181,6 +186,26 @@ export function LaunchWindow() { } }, [selectedCameraId, cameraDevices, setWebcamDeviceId, setWebcamDeviceName]); + useEffect(() => { + let cancelled = false; + nativeBridgeClient.system + .getPlatform() + .then((platform) => { + if (!cancelled) { + setIsWindows(platform === "win32"); + } + }) + .catch(() => { + if (!cancelled) { + setIsWindows(false); + } + }); + + return () => { + cancelled = true; + }; + }, []); + useEffect(() => { if (!import.meta.env.DEV) { return; @@ -584,6 +609,33 @@ export function LaunchWindow() { ? getIcon("webcamOn", "text-green-400") : getIcon("webcamOff", "text-white/40")} + {isWindows && ( + + )} {/* Record/Stop group */} diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 9af6c25..f3eb8d0 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -16,6 +16,7 @@ import { useShortcuts } from "@/contexts/ShortcutsContext"; import { INITIAL_EDITOR_STATE, useEditorHistory } from "@/hooks/useEditorHistory"; import { type Locale } from "@/i18n/config"; import { getAvailableLocales, getLocaleName } from "@/i18n/loader"; +import { hasNativeCursorRecordingData } from "@/lib/cursor/nativeCursor"; import { calculateOutputDimensions, type ExportFormat, @@ -29,7 +30,7 @@ import { VideoExporter, } from "@/lib/exporter"; import { computeFrameStepTime } from "@/lib/frameStep"; -import type { ProjectMedia } from "@/lib/recordingSession"; +import type { CursorCaptureMode, ProjectMedia } from "@/lib/recordingSession"; import { matchesShortcut } from "@/lib/shortcuts"; import { getExportFolder, @@ -175,6 +176,8 @@ export default function VideoEditor() { const [cursorMotionBlur, setCursorMotionBlur] = useState(DEFAULT_CURSOR_MOTION_BLUR); const [cursorClickBounce, setCursorClickBounce] = useState(DEFAULT_CURSOR_CLICK_BOUNCE); const [nativePlatform, setNativePlatform] = useState(null); + const [recordingCursorCaptureMode, setRecordingCursorCaptureMode] = + useState(null); const videoPlaybackRef = useRef(null); @@ -183,7 +186,11 @@ export default function VideoEditor() { const nextSpeedIdRef = useRef(1); const { shortcuts, isMac } = useShortcuts(); - const showCursorSettings = nativePlatform === "win32"; + const hasEditableCursorRecording = + recordingCursorCaptureMode === "editable-overlay" || + (recordingCursorCaptureMode === null && hasNativeCursorRecordingData(cursorRecordingData)); + const effectiveShowCursor = showCursor && hasEditableCursorRecording; + const showCursorSettings = nativePlatform === "win32" && hasEditableCursorRecording; // Off-Mac doesn't have click telemetry, so force `onlyOnClicks` off for // renderers while keeping the persisted value intact for round-tripping. const effectiveCursorHighlight = useMemo( @@ -216,10 +223,18 @@ export default function VideoEditor() { const webcamSourcePath = webcamVideoSourcePath ?? (webcamVideoPath ? fromFileUrl(webcamVideoPath) : null); - return webcamSourcePath - ? { screenVideoPath, webcamVideoPath: webcamSourcePath } - : { screenVideoPath }; - }, [videoPath, videoSourcePath, webcamVideoPath, webcamVideoSourcePath]); + return { + screenVideoPath, + ...(webcamSourcePath ? { webcamVideoPath: webcamSourcePath } : {}), + ...(recordingCursorCaptureMode ? { cursorCaptureMode: recordingCursorCaptureMode } : {}), + }; + }, [ + videoPath, + videoSourcePath, + webcamVideoPath, + webcamVideoSourcePath, + recordingCursorCaptureMode, + ]); const applyLoadedProject = useCallback( async (candidate: unknown, path?: string | null) => { @@ -234,6 +249,7 @@ export default function VideoEditor() { } const sourcePath = projectMedia.screenVideoPath; const webcamSourcePath = projectMedia.webcamVideoPath ?? null; + const projectCursorCaptureMode = projectMedia.cursorCaptureMode ?? null; const normalizedEditor = normalizeProjectEditor(project.editor); const inferredDurationMs = Math.max( 0, @@ -257,6 +273,7 @@ export default function VideoEditor() { setVideoPath(toFileUrl(sourcePath)); setWebcamVideoSourcePath(webcamSourcePath); setWebcamVideoPath(webcamSourcePath ? toFileUrl(webcamSourcePath) : null); + setRecordingCursorCaptureMode(projectCursorCaptureMode); setCurrentProjectPath(path ?? null); pushState({ @@ -313,9 +330,11 @@ export default function VideoEditor() { setLastSavedSnapshot( createProjectSnapshot( - webcamSourcePath - ? { screenVideoPath: sourcePath, webcamVideoPath: webcamSourcePath } - : { screenVideoPath: sourcePath }, + { + screenVideoPath: sourcePath, + ...(webcamSourcePath ? { webcamVideoPath: webcamSourcePath } : {}), + ...(projectCursorCaptureMode ? { cursorCaptureMode: projectCursorCaptureMode } : {}), + }, normalizedEditor, ), ); @@ -401,15 +420,17 @@ export default function VideoEditor() { setVideoPath(toFileUrl(sourcePath)); setWebcamVideoSourcePath(webcamSourcePath); setWebcamVideoPath(webcamSourcePath ? toFileUrl(webcamSourcePath) : null); + setRecordingCursorCaptureMode(session.cursorCaptureMode ?? null); setCurrentProjectPath(null); setLastSavedSnapshot( createProjectSnapshot( - webcamSourcePath - ? { - screenVideoPath: sourcePath, - webcamVideoPath: webcamSourcePath, - } - : { screenVideoPath: sourcePath }, + { + screenVideoPath: sourcePath, + ...(webcamSourcePath ? { webcamVideoPath: webcamSourcePath } : {}), + ...(session.cursorCaptureMode + ? { cursorCaptureMode: session.cursorCaptureMode } + : {}), + }, INITIAL_EDITOR_STATE, ), ); @@ -420,6 +441,7 @@ export default function VideoEditor() { if (result.success && result.path) { setVideoSourcePath(result.path); setVideoPath(toFileUrl(result.path)); + setRecordingCursorCaptureMode(null); setCurrentProjectPath(null); setLastSavedSnapshot( createProjectSnapshot({ screenVideoPath: result.path }, INITIAL_EDITOR_STATE), @@ -1515,7 +1537,7 @@ export default function VideoEditor() { videoPadding: padding, cropRegion, cursorRecordingData, - cursorScale: showCursor ? cursorSize : 0, + cursorScale: effectiveShowCursor ? cursorSize : 0, cursorSmoothing, cursorMotionBlur, cursorClickBounce, @@ -1661,7 +1683,7 @@ export default function VideoEditor() { padding, cropRegion, cursorRecordingData, - cursorScale: showCursor ? cursorSize : 0, + cursorScale: effectiveShowCursor ? cursorSize : 0, cursorSmoothing, cursorMotionBlur, cursorClickBounce, @@ -1757,7 +1779,7 @@ export default function VideoEditor() { cursorTelemetry, cursorClickTimestamps, effectiveCursorHighlight, - showCursor, + effectiveShowCursor, cursorSize, cursorSmoothing, cursorMotionBlur, diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index 1c39e29..45aa7b3 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -6,6 +6,7 @@ import { type NativeWindowsRecordingRequest, parseWindowHandleFromSourceId, } from "@/lib/nativeWindowsRecording"; +import type { CursorCaptureMode } from "@/lib/recordingSession"; import { requestCameraAccess } from "@/lib/requestCameraAccess"; const TARGET_FRAME_RATE = 60; @@ -65,6 +66,8 @@ type UseScreenRecorderReturn = { setSystemAudioEnabled: (enabled: boolean) => void; webcamEnabled: boolean; setWebcamEnabled: (enabled: boolean) => Promise; + cursorCaptureMode: CursorCaptureMode; + setCursorCaptureMode: (mode: CursorCaptureMode) => void; }; type RecorderHandle = { @@ -111,6 +114,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { const [webcamDeviceName, setWebcamDeviceName] = useState(undefined); const [systemAudioEnabled, setSystemAudioEnabled] = useState(false); const [webcamEnabled, setWebcamEnabledState] = useState(false); + const [cursorCaptureMode, setCursorCaptureMode] = useState("editable-overlay"); const screenRecorder = useRef(null); const webcamRecorder = useRef(null); const nativeWindowsRecording = useRef(null); @@ -368,6 +372,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { } : undefined, createdAt: activeRecordingId, + cursorCaptureMode, }); if (!result.success) { @@ -394,7 +399,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { } })(); }, - [teardownMedia], + [cursorCaptureMode, teardownMedia], ); const finalizeNativeWindowsRecording = useCallback(async (discard = false) => { @@ -645,6 +650,9 @@ export function useScreenRecorder(): UseScreenRecorderReturn { height: WEBCAM_TARGET_HEIGHT, fps: WEBCAM_TARGET_FRAME_RATE, }, + cursor: { + mode: cursorCaptureMode, + }, }; const result = await window.electronAPI.startNativeWindowsRecording(request); if (!result.success || !result.recordingId) { @@ -765,12 +773,11 @@ export function useScreenRecorder(): UseScreenRecorderReturn { if (platform === "win32") { // getDisplayMedia + setDisplayMediaRequestHandler (main.ts) supplies the - // pre-selected source and honors cursor:"never" to exclude the system cursor - // from every captured frame. System audio is provided via WASAPI loopback - // on Windows when the user has enabled it. + // pre-selected source. Editable cursor mode excludes the system cursor so + // the editor can render a replacement; system mode bakes it into the video. screenMediaStream = await navigator.mediaDevices.getDisplayMedia({ video: { - cursor: "never", + cursor: cursorCaptureMode === "editable-overlay" ? "never" : "always", width: { max: TARGET_WIDTH }, height: { max: TARGET_HEIGHT }, frameRate: { ideal: TARGET_FRAME_RATE }, @@ -976,7 +983,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { setRecording(true); setPaused(false); setElapsedSeconds(0); - window.electronAPI?.setRecordingState(true, recordingId.current); + window.electronAPI?.setRecordingState(true, recordingId.current, cursorCaptureMode); const activeScreenRecorder = screenRecorder.current; const activeWebcamRecorder = webcamRecorder.current; @@ -1192,5 +1199,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { setSystemAudioEnabled, webcamEnabled, setWebcamEnabled, + cursorCaptureMode, + setCursorCaptureMode, }; } diff --git a/src/i18n/locales/en/launch.json b/src/i18n/locales/en/launch.json index e959a54..133a961 100644 --- a/src/i18n/locales/en/launch.json +++ b/src/i18n/locales/en/launch.json @@ -24,6 +24,10 @@ "noneFound": "No camera found", "unavailable": "Camera unavailable" }, + "cursor": { + "useEditableCursor": "Use editable cursor", + "useSystemCursor": "Use system cursor" + }, "sourceSelector": { "loading": "Loading sources...", "screens": "Screens ({{count}})", diff --git a/src/i18n/locales/es/launch.json b/src/i18n/locales/es/launch.json index 68919aa..e0d2c3c 100644 --- a/src/i18n/locales/es/launch.json +++ b/src/i18n/locales/es/launch.json @@ -24,6 +24,10 @@ "noneFound": "No se encontró cámara", "unavailable": "Cámara no disponible" }, + "cursor": { + "useEditableCursor": "Usar cursor editable", + "useSystemCursor": "Usar cursor del sistema" + }, "sourceSelector": { "loading": "Cargando fuentes...", "screens": "Pantallas ({{count}})", diff --git a/src/i18n/locales/fr/launch.json b/src/i18n/locales/fr/launch.json index 55521cb..33e6236 100644 --- a/src/i18n/locales/fr/launch.json +++ b/src/i18n/locales/fr/launch.json @@ -24,6 +24,10 @@ "noneFound": "Aucune caméra trouvée", "unavailable": "Caméra non disponible" }, + "cursor": { + "useEditableCursor": "Utiliser le curseur éditable", + "useSystemCursor": "Utiliser le curseur système" + }, "sourceSelector": { "loading": "Chargement des sources...", "screens": "Écrans ({{count}})", diff --git a/src/i18n/locales/ja-JP/launch.json b/src/i18n/locales/ja-JP/launch.json index 51e3833..601cf2d 100644 --- a/src/i18n/locales/ja-JP/launch.json +++ b/src/i18n/locales/ja-JP/launch.json @@ -24,6 +24,10 @@ "noneFound": "カメラが見つかりません", "unavailable": "カメラが利用できません" }, + "cursor": { + "useEditableCursor": "編集可能なカーソルを使う", + "useSystemCursor": "システムカーソルを使う" + }, "sourceSelector": { "loading": "ソースを読み込み中...", "screens": "画面 ({{count}})", diff --git a/src/i18n/locales/ko-KR/launch.json b/src/i18n/locales/ko-KR/launch.json index d9f6d6a..07e0632 100644 --- a/src/i18n/locales/ko-KR/launch.json +++ b/src/i18n/locales/ko-KR/launch.json @@ -24,6 +24,10 @@ "noneFound": "카메라를 찾을 수 없음", "unavailable": "카메라를 사용할 수 없음" }, + "cursor": { + "useEditableCursor": "편집 가능한 커서 사용", + "useSystemCursor": "시스템 커서 사용" + }, "sourceSelector": { "loading": "소스 불러오는 중...", "screens": "화면 ({{count}}개)", diff --git a/src/i18n/locales/tr/launch.json b/src/i18n/locales/tr/launch.json index 177ba3f..19039c8 100644 --- a/src/i18n/locales/tr/launch.json +++ b/src/i18n/locales/tr/launch.json @@ -35,6 +35,10 @@ "noneFound": "Kamera bulunamadı", "unavailable": "Kamera kullanılamıyor" }, + "cursor": { + "useEditableCursor": "Düzenlenebilir imleci kullan", + "useSystemCursor": "Sistem imlecini kullan" + }, "sourceSelector": { "loading": "Kaynaklar yükleniyor...", "screens": "Ekranlar ({{count}})", diff --git a/src/i18n/locales/zh-CN/launch.json b/src/i18n/locales/zh-CN/launch.json index a5c2a9d..ae399e3 100644 --- a/src/i18n/locales/zh-CN/launch.json +++ b/src/i18n/locales/zh-CN/launch.json @@ -24,6 +24,10 @@ "noneFound": "未找到摄像头", "unavailable": "摄像头不可用" }, + "cursor": { + "useEditableCursor": "使用可编辑光标", + "useSystemCursor": "使用系统光标" + }, "sourceSelector": { "loading": "正在加载源...", "screens": "屏幕 ({{count}})", diff --git a/src/i18n/locales/zh-TW/launch.json b/src/i18n/locales/zh-TW/launch.json index ea7e625..9629c86 100644 --- a/src/i18n/locales/zh-TW/launch.json +++ b/src/i18n/locales/zh-TW/launch.json @@ -24,6 +24,10 @@ "noneFound": "未找到攝影機", "unavailable": "攝影機不可用" }, + "cursor": { + "useEditableCursor": "使用可編輯游標", + "useSystemCursor": "使用系統游標" + }, "sourceSelector": { "loading": "正在載入來源...", "screens": "螢幕 ({{count}})", diff --git a/src/lib/nativeWindowsRecording.ts b/src/lib/nativeWindowsRecording.ts index c19e819..5e06851 100644 --- a/src/lib/nativeWindowsRecording.ts +++ b/src/lib/nativeWindowsRecording.ts @@ -33,6 +33,9 @@ export type NativeWindowsRecordingRequest = { height: number; fps: number; }; + cursor: { + mode: import("./recordingSession").CursorCaptureMode; + }; }; export type NativeWindowsRecordingStartResult = { diff --git a/src/lib/recordingSession.ts b/src/lib/recordingSession.ts index 17cf7c1..f5ebf9c 100644 --- a/src/lib/recordingSession.ts +++ b/src/lib/recordingSession.ts @@ -1,8 +1,11 @@ export interface ProjectMedia { screenVideoPath: string; webcamVideoPath?: string; + cursorCaptureMode?: CursorCaptureMode; } +export type CursorCaptureMode = "editable-overlay" | "system"; + export interface RecordingSession extends ProjectMedia { createdAt: number; } @@ -16,6 +19,11 @@ export interface StoreRecordedSessionInput { screen: RecordedVideoAssetInput; webcam?: RecordedVideoAssetInput; createdAt?: number; + cursorCaptureMode?: CursorCaptureMode; +} + +export function normalizeCursorCaptureMode(value: unknown): CursorCaptureMode | undefined { + return value === "editable-overlay" || value === "system" ? value : undefined; } function normalizePath(value: unknown): string | undefined { @@ -40,12 +48,13 @@ export function normalizeProjectMedia(candidate: unknown): ProjectMedia | null { } const webcamVideoPath = normalizePath(raw.webcamVideoPath); + const cursorCaptureMode = normalizeCursorCaptureMode(raw.cursorCaptureMode); - return webcamVideoPath - ? { screenVideoPath, webcamVideoPath } - : { - screenVideoPath, - }; + return { + screenVideoPath, + ...(webcamVideoPath ? { webcamVideoPath } : {}), + ...(cursorCaptureMode ? { cursorCaptureMode } : {}), + }; } export function normalizeRecordingSession(candidate: unknown): RecordingSession | null {