feat: add Windows cursor capture mode

This commit is contained in:
EtienneLescot
2026-05-06 15:15:48 +02:00
parent b349c0a27c
commit 4d3bce0f20
19 changed files with 255 additions and 84 deletions
+5 -1
View File
@@ -71,7 +71,11 @@ interface Window {
message?: string;
error?: string;
}>;
setRecordingState: (recording: boolean, recordingId?: number) => Promise<void>;
setRecordingState: (
recording: boolean,
recordingId?: number,
cursorCaptureMode?: import("../src/lib/recordingSession").CursorCaptureMode,
) => Promise<void>;
isNativeWindowsCaptureAvailable: () => Promise<{
success: boolean;
available: boolean;
+63 -24
View File
@@ -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(
+4 -2
View File
@@ -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;
}
+18 -24
View File
@@ -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;
@@ -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&);
+6 -2
View File
@@ -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");
+52
View File
@@ -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<HTMLButtonElement | null>(null);
const languageMenuPanelRef = useRef<HTMLDivElement | null>(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")}
</button>
{isWindows && (
<button
data-testid="launch-cursor-mode-button"
className={`${hudIconBtnClasses} ${
cursorCaptureMode === "editable-overlay"
? "drop-shadow-[0_0_4px_rgba(74,222,128,0.4)]"
: ""
}`}
onClick={() =>
!recording &&
setCursorCaptureMode(
cursorCaptureMode === "editable-overlay" ? "system" : "editable-overlay",
)
}
disabled={recording}
title={
cursorCaptureMode === "editable-overlay"
? t("cursor.useSystemCursor")
: t("cursor.useEditableCursor")
}
>
{getIcon(
"cursor",
cursorCaptureMode === "editable-overlay" ? "text-green-400" : "text-white/40",
)}
</button>
)}
</div>
{/* Record/Stop group */}
+40 -18
View File
@@ -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<NativePlatform | null>(null);
const [recordingCursorCaptureMode, setRecordingCursorCaptureMode] =
useState<CursorCaptureMode | null>(null);
const videoPlaybackRef = useRef<VideoPlaybackRef>(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,
+15 -6
View File
@@ -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<boolean>;
cursorCaptureMode: CursorCaptureMode;
setCursorCaptureMode: (mode: CursorCaptureMode) => void;
};
type RecorderHandle = {
@@ -111,6 +114,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
const [webcamDeviceName, setWebcamDeviceName] = useState<string | undefined>(undefined);
const [systemAudioEnabled, setSystemAudioEnabled] = useState(false);
const [webcamEnabled, setWebcamEnabledState] = useState(false);
const [cursorCaptureMode, setCursorCaptureMode] = useState<CursorCaptureMode>("editable-overlay");
const screenRecorder = useRef<RecorderHandle | null>(null);
const webcamRecorder = useRef<RecorderHandle | null>(null);
const nativeWindowsRecording = useRef<NativeWindowsRecordingHandle | null>(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,
};
}
+4
View File
@@ -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}})",
+4
View File
@@ -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}})",
+4
View File
@@ -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}})",
+4
View File
@@ -24,6 +24,10 @@
"noneFound": "カメラが見つかりません",
"unavailable": "カメラが利用できません"
},
"cursor": {
"useEditableCursor": "編集可能なカーソルを使う",
"useSystemCursor": "システムカーソルを使う"
},
"sourceSelector": {
"loading": "ソースを読み込み中...",
"screens": "画面 ({{count}})",
+4
View File
@@ -24,6 +24,10 @@
"noneFound": "카메라를 찾을 수 없음",
"unavailable": "카메라를 사용할 수 없음"
},
"cursor": {
"useEditableCursor": "편집 가능한 커서 사용",
"useSystemCursor": "시스템 커서 사용"
},
"sourceSelector": {
"loading": "소스 불러오는 중...",
"screens": "화면 ({{count}}개)",
+4
View File
@@ -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}})",
+4
View File
@@ -24,6 +24,10 @@
"noneFound": "未找到摄像头",
"unavailable": "摄像头不可用"
},
"cursor": {
"useEditableCursor": "使用可编辑光标",
"useSystemCursor": "使用系统光标"
},
"sourceSelector": {
"loading": "正在加载源...",
"screens": "屏幕 ({{count}})",
+4
View File
@@ -24,6 +24,10 @@
"noneFound": "未找到攝影機",
"unavailable": "攝影機不可用"
},
"cursor": {
"useEditableCursor": "使用可編輯游標",
"useSystemCursor": "使用系統游標"
},
"sourceSelector": {
"loading": "正在載入來源...",
"screens": "螢幕 ({{count}})",
+3
View File
@@ -33,6 +33,9 @@ export type NativeWindowsRecordingRequest = {
height: number;
fps: number;
};
cursor: {
mode: import("./recordingSession").CursorCaptureMode;
};
};
export type NativeWindowsRecordingStartResult = {
+14 -5
View File
@@ -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 {