feat: add native Windows recorder helper
This commit is contained in:
@@ -1832,7 +1832,6 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
src={videoPath}
|
||||
className="hidden"
|
||||
preload="auto"
|
||||
muted
|
||||
playsInline
|
||||
onLoadedMetadata={handleLoadedMetadata}
|
||||
onDurationChange={(e) => {
|
||||
|
||||
@@ -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" ||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user