feat: add Windows cursor capture mode
This commit is contained in:
Vendored
+5
-1
@@ -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
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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");
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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}})",
|
||||
|
||||
@@ -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}})",
|
||||
|
||||
@@ -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}})",
|
||||
|
||||
@@ -24,6 +24,10 @@
|
||||
"noneFound": "カメラが見つかりません",
|
||||
"unavailable": "カメラが利用できません"
|
||||
},
|
||||
"cursor": {
|
||||
"useEditableCursor": "編集可能なカーソルを使う",
|
||||
"useSystemCursor": "システムカーソルを使う"
|
||||
},
|
||||
"sourceSelector": {
|
||||
"loading": "ソースを読み込み中...",
|
||||
"screens": "画面 ({{count}})",
|
||||
|
||||
@@ -24,6 +24,10 @@
|
||||
"noneFound": "카메라를 찾을 수 없음",
|
||||
"unavailable": "카메라를 사용할 수 없음"
|
||||
},
|
||||
"cursor": {
|
||||
"useEditableCursor": "편집 가능한 커서 사용",
|
||||
"useSystemCursor": "시스템 커서 사용"
|
||||
},
|
||||
"sourceSelector": {
|
||||
"loading": "소스 불러오는 중...",
|
||||
"screens": "화면 ({{count}}개)",
|
||||
|
||||
@@ -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}})",
|
||||
|
||||
@@ -24,6 +24,10 @@
|
||||
"noneFound": "未找到摄像头",
|
||||
"unavailable": "摄像头不可用"
|
||||
},
|
||||
"cursor": {
|
||||
"useEditableCursor": "使用可编辑光标",
|
||||
"useSystemCursor": "使用系统光标"
|
||||
},
|
||||
"sourceSelector": {
|
||||
"loading": "正在加载源...",
|
||||
"screens": "屏幕 ({{count}})",
|
||||
|
||||
@@ -24,6 +24,10 @@
|
||||
"noneFound": "未找到攝影機",
|
||||
"unavailable": "攝影機不可用"
|
||||
},
|
||||
"cursor": {
|
||||
"useEditableCursor": "使用可編輯游標",
|
||||
"useSystemCursor": "使用系統游標"
|
||||
},
|
||||
"sourceSelector": {
|
||||
"loading": "正在載入來源...",
|
||||
"screens": "螢幕 ({{count}})",
|
||||
|
||||
@@ -33,6 +33,9 @@ export type NativeWindowsRecordingRequest = {
|
||||
height: number;
|
||||
fps: number;
|
||||
};
|
||||
cursor: {
|
||||
mode: import("./recordingSession").CursorCaptureMode;
|
||||
};
|
||||
};
|
||||
|
||||
export type NativeWindowsRecordingStartResult = {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user