feat: add native Windows recorder helper

This commit is contained in:
EtienneLescot
2026-05-05 16:07:07 +02:00
parent d21e5eb34c
commit 062cf2a87c
27 changed files with 2873 additions and 139 deletions
@@ -1832,7 +1832,6 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
src={videoPath}
className="hidden"
preload="auto"
muted
playsInline
onLoadedMetadata={handleLoadedMetadata}
onDurationChange={(e) => {
+166 -1
View File
@@ -2,6 +2,7 @@ import { fixWebmDuration } from "@fix-webm-duration/fix";
import { useCallback, useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { useScopedT } from "@/contexts/I18nContext";
import type { NativeWindowsRecordingRequest } from "@/lib/nativeWindowsRecording";
import { requestCameraAccess } from "@/lib/requestCameraAccess";
const TARGET_FRAME_RATE = 60;
@@ -62,6 +63,11 @@ type RecorderHandle = {
recordedBlobPromise: Promise<Blob>;
};
type NativeWindowsRecordingHandle = {
recordingId: number;
finalizing: boolean;
};
function createRecorderHandle(stream: MediaStream, options: MediaRecorderOptions): RecorderHandle {
const recorder = new MediaRecorder(stream, options);
const chunks: Blob[] = [];
@@ -96,6 +102,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
const [webcamEnabled, setWebcamEnabledState] = useState(false);
const screenRecorder = useRef<RecorderHandle | null>(null);
const webcamRecorder = useRef<RecorderHandle | null>(null);
const nativeWindowsRecording = useRef<NativeWindowsRecordingHandle | null>(null);
const stream = useRef<MediaStream | null>(null);
const screenStream = useRef<MediaStream | null>(null);
const microphoneStream = useRef<MediaStream | null>(null);
@@ -365,7 +372,58 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
[teardownMedia],
);
const finalizeNativeWindowsRecording = useCallback(async (discard = false) => {
const activeNativeRecording = nativeWindowsRecording.current;
if (!activeNativeRecording || activeNativeRecording.finalizing) {
return false;
}
activeNativeRecording.finalizing = true;
nativeWindowsRecording.current = null;
setRecording(false);
setPaused(false);
setElapsedSeconds(0);
accumulatedDurationMs.current = 0;
segmentStartedAt.current = null;
try {
const result = await window.electronAPI.stopNativeWindowsRecording(discard);
if (discard || result.discarded) {
return true;
}
if (!result.success) {
console.error("Failed to stop native Windows recording:", result.error);
toast.error(result.error ?? "Failed to stop native Windows recording");
return true;
}
if (result.session) {
await window.electronAPI.setCurrentRecordingSession(result.session);
} else if (result.path) {
await window.electronAPI.setCurrentVideoPath(result.path);
}
await window.electronAPI.switchToEditor();
return true;
} catch (error) {
console.error("Error saving native Windows recording:", error);
toast.error(
error instanceof Error ? error.message : "Failed to save native Windows recording",
);
return true;
} finally {
if (discardRecordingId.current === activeNativeRecording.recordingId) {
discardRecordingId.current = null;
}
}
}, []);
const stopRecording = useRef(() => {
if (nativeWindowsRecording.current) {
void finalizeNativeWindowsRecording(false);
return;
}
const activeScreenRecorder = screenRecorder.current;
if (!activeScreenRecorder) {
return;
@@ -431,6 +489,9 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
allowAutoFinalize.current = false;
restarting.current = false;
discardRecordingId.current = null;
if (nativeWindowsRecording.current) {
void finalizeNativeWindowsRecording(true);
}
if (
screenRecorder.current?.recorder.state === "recording" ||
@@ -456,7 +517,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
webcamRecorder.current = null;
teardownMedia();
};
}, [teardownMedia, safeHideCountdownOverlay]);
}, [teardownMedia, safeHideCountdownOverlay, finalizeNativeWindowsRecording]);
const safeShowCountdownOverlay = async (value: number, runId: number) => {
try {
@@ -486,6 +547,85 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
const isCountdownRunActive = (runId?: number) =>
runId === undefined || countdownRunId.current === runId;
const startNativeWindowsRecordingIfAvailable = async (
selectedSource: ProcessedDesktopSource,
countdownRunToken?: number,
) => {
try {
const platform = await window.electronAPI.getPlatform();
if (platform !== "win32") {
return false;
}
const availability = await window.electronAPI.isNativeWindowsCaptureAvailable();
if (!availability.success || !availability.available) {
throw new Error(
availability.reason === "missing-helper"
? "Native Windows capture helper is not available."
: (availability.error ?? "Native Windows capture is not available."),
);
}
if (!isCountdownRunActive(countdownRunToken)) {
return true;
}
const activeRecordingId = Date.now();
const displayId = Number(selectedSource.display_id);
const sourceType = selectedSource.id.startsWith("window:") ? "window" : "display";
const request: NativeWindowsRecordingRequest = {
recordingId: activeRecordingId,
source: {
type: sourceType,
sourceId: selectedSource.id,
...(Number.isFinite(displayId) ? { displayId } : {}),
},
video: {
fps: TARGET_FRAME_RATE,
width: TARGET_WIDTH,
height: TARGET_HEIGHT,
},
audio: {
system: {
enabled: systemAudioEnabled,
},
microphone: {
enabled: microphoneEnabled,
deviceId: microphoneDeviceId,
gain: MIC_GAIN_BOOST,
},
},
webcam: {
enabled: webcamEnabled,
deviceId: webcamDeviceId,
width: WEBCAM_TARGET_WIDTH,
height: WEBCAM_TARGET_HEIGHT,
fps: WEBCAM_TARGET_FRAME_RATE,
},
};
const result = await window.electronAPI.startNativeWindowsRecording(request);
if (!result.success || !result.recordingId) {
throw new Error(result.error ?? "Native Windows capture failed.");
}
recordingId.current = result.recordingId;
nativeWindowsRecording.current = {
recordingId: result.recordingId,
finalizing: false,
};
accumulatedDurationMs.current = 0;
segmentStartedAt.current = result.recordingId;
allowAutoFinalize.current = true;
setRecording(true);
setPaused(false);
setElapsedSeconds(0);
return true;
} catch (error) {
console.error("Native Windows capture failed:", error);
throw error;
}
};
const startRecordCountdown = async () => {
if (countdownActive || recording) {
return;
@@ -573,6 +713,10 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
return;
}
if (await startNativeWindowsRecordingIfAvailable(selectedSource, countdownRunToken)) {
return;
}
let screenMediaStream: MediaStream;
// getDisplayMedia + setDisplayMediaRequestHandler (main.ts) supplies the
@@ -846,6 +990,19 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
const restartRecording = async () => {
if (restarting.current) return;
if (nativeWindowsRecording.current) {
const activeRecordingId = recordingId.current;
restarting.current = true;
discardRecordingId.current = activeRecordingId;
try {
await finalizeNativeWindowsRecording(true);
await startRecording();
} finally {
restarting.current = false;
}
return;
}
const activeScreenRecorder = screenRecorder.current;
if (!activeScreenRecorder || activeScreenRecorder.recorder.state === "inactive") return;
@@ -903,6 +1060,14 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
}, [getRecordingDurationMs, paused, recording]);
const cancelRecording = () => {
if (nativeWindowsRecording.current) {
const activeRecordingId = recordingId.current;
discardRecordingId.current = activeRecordingId;
allowAutoFinalize.current = false;
void finalizeNativeWindowsRecording(true);
return;
}
const activeScreenRecorder = screenRecorder.current;
if (
activeScreenRecorder?.recorder.state === "recording" ||
+9 -9
View File
@@ -54,8 +54,8 @@ const PRETTY_NATIVE_CURSOR_ASSETS: Partial<Record<NativeCursorType, PrettyNative
imageDataUrl: arrowUrl,
width: 32,
height: 32,
hotspotX: 5.8,
hotspotY: 3.2,
hotspotX: 16.25,
hotspotY: 15.03,
},
text: {
imageDataUrl: textUrl,
@@ -67,9 +67,9 @@ const PRETTY_NATIVE_CURSOR_ASSETS: Partial<Record<NativeCursorType, PrettyNative
pointer: {
imageDataUrl: pointerUrl,
width: 32,
height: 32,
hotspotX: 11.8,
hotspotY: 2.6,
height: 33,
hotspotX: 16.65,
hotspotY: 14.24,
},
crosshair: {
imageDataUrl: crosshairUrl,
@@ -131,15 +131,15 @@ const PRETTY_NATIVE_CURSOR_ASSETS: Partial<Record<NativeCursorType, PrettyNative
imageDataUrl: appStartingUrl,
width: 32,
height: 32,
hotspotX: 5.8,
hotspotY: 3.2,
hotspotX: 7.25,
hotspotY: 4.03,
},
help: {
imageDataUrl: helpUrl,
width: 32,
height: 32,
hotspotX: 5.8,
hotspotY: 3.2,
hotspotX: 7.25,
hotspotY: 4.03,
},
"up-arrow": {
imageDataUrl: upArrowUrl,
+4 -24
View File
@@ -3,6 +3,7 @@ import type { SpeedRegion, TrimRegion } from "@/components/video-editor/types";
import type { VideoMuxer } from "./muxer";
const AUDIO_BITRATE = 128_000;
const EXPORT_AUDIO_CODEC = "mp4a.40.2";
const DECODE_BACKPRESSURE_LIMIT = 20;
const MIN_SPEED_REGION_DELTA_MS = 0.0001;
const SEEK_TIMEOUT_MS = 5_000;
@@ -138,7 +139,7 @@ export class AudioProcessor {
const channels = audioConfig.numberOfChannels || 2;
const encodeConfig: AudioEncoderConfig = {
codec: "opus",
codec: EXPORT_AUDIO_CODEC,
sampleRate,
numberOfChannels: channels,
bitrate: AUDIO_BITRATE,
@@ -146,7 +147,7 @@ export class AudioProcessor {
const encodeSupport = await AudioEncoder.isConfigSupported(encodeConfig);
if (!encodeSupport.supported) {
console.warn("[AudioProcessor] Opus encoding not supported, skipping audio");
console.warn("[AudioProcessor] AAC encoding not supported, skipping audio");
for (const frame of decodedFrames) frame.close();
return;
}
@@ -397,28 +398,7 @@ export class AudioProcessor {
try {
await demuxer.load(file);
const audioConfig = await demuxer.getDecoderConfig("audio");
const reader = demuxer.read("audio").getReader();
let isFirstChunk = true;
try {
while (!this.cancelled) {
const { done, value: chunk } = await reader.read();
if (done || !chunk) break;
if (isFirstChunk) {
await muxer.addAudioChunk(chunk, { decoderConfig: audioConfig });
isFirstChunk = false;
} else {
await muxer.addAudioChunk(chunk);
}
}
} finally {
try {
await reader.cancel();
} catch {
/* reader already closed */
}
}
await this.processTrimOnlyAudio(demuxer, muxer, []);
} finally {
try {
demuxer.destroy();
+1 -1
View File
@@ -40,7 +40,7 @@ export class VideoMuxer {
// Create audio source if needed
if (this.hasAudio) {
this.audioSource = new EncodedAudioPacketSource("opus");
this.audioSource = new EncodedAudioPacketSource("aac");
this.output.addAudioTrack(this.audioSource);
}
+41
View File
@@ -0,0 +1,41 @@
export type NativeWindowsSourceType = "display" | "window";
export type NativeWindowsRecordingRequest = {
recordingId?: number;
source: {
type: NativeWindowsSourceType;
sourceId: string;
displayId?: number;
windowHandle?: string;
};
video: {
fps: number;
width: number;
height: number;
};
audio: {
system: {
enabled: boolean;
};
microphone: {
enabled: boolean;
deviceId?: string;
gain: number;
};
};
webcam: {
enabled: boolean;
deviceId?: string;
width: number;
height: number;
fps: number;
};
};
export type NativeWindowsRecordingStartResult = {
success: boolean;
recordingId?: number;
path?: string;
helperPath?: string;
error?: string;
};