416 lines
13 KiB
TypeScript
416 lines
13 KiB
TypeScript
import { fixWebmDuration } from "@fix-webm-duration/fix";
|
|
import { useEffect, useRef, useState } from "react";
|
|
import { toast } from "sonner";
|
|
|
|
// Target visually lossless 4K @ 60fps; fall back gracefully when hardware cannot keep up
|
|
const TARGET_FRAME_RATE = 60;
|
|
const MIN_FRAME_RATE = 30;
|
|
const TARGET_WIDTH = 3840;
|
|
const TARGET_HEIGHT = 2160;
|
|
const FOUR_K_PIXELS = TARGET_WIDTH * TARGET_HEIGHT;
|
|
const QHD_WIDTH = 2560;
|
|
const QHD_HEIGHT = 1440;
|
|
const QHD_PIXELS = QHD_WIDTH * QHD_HEIGHT;
|
|
|
|
// Bitrates (bits per second) per resolution tier
|
|
const BITRATE_4K = 45_000_000;
|
|
const BITRATE_QHD = 28_000_000;
|
|
const BITRATE_BASE = 18_000_000;
|
|
const HIGH_FRAME_RATE_THRESHOLD = 60;
|
|
const HIGH_FRAME_RATE_BOOST = 1.7;
|
|
|
|
// Fallback track settings when the driver reports nothing
|
|
const DEFAULT_WIDTH = 1920;
|
|
const DEFAULT_HEIGHT = 1080;
|
|
|
|
// Codec alignment: VP9/AV1 require dimensions divisible by 2
|
|
const CODEC_ALIGNMENT = 2;
|
|
|
|
const RECORDER_TIMESLICE_MS = 1000;
|
|
const BITS_PER_MEGABIT = 1_000_000;
|
|
const CHROME_MEDIA_SOURCE = "desktop";
|
|
const RECORDING_FILE_PREFIX = "recording-";
|
|
const VIDEO_FILE_EXTENSION = ".webm";
|
|
|
|
const AUDIO_BITRATE_VOICE = 128_000;
|
|
const AUDIO_BITRATE_SYSTEM = 192_000;
|
|
|
|
// Boost mic slightly when mixing with system audio so voice isn't drowned out
|
|
const MIC_GAIN_BOOST = 1.4;
|
|
|
|
type UseScreenRecorderReturn = {
|
|
recording: boolean;
|
|
toggleRecording: () => void;
|
|
restartRecording: () => void;
|
|
microphoneEnabled: boolean;
|
|
setMicrophoneEnabled: (enabled: boolean) => void;
|
|
microphoneDeviceId: string | undefined;
|
|
setMicrophoneDeviceId: (deviceId: string | undefined) => void;
|
|
systemAudioEnabled: boolean;
|
|
setSystemAudioEnabled: (enabled: boolean) => void;
|
|
};
|
|
|
|
export function useScreenRecorder(): UseScreenRecorderReturn {
|
|
const [recording, setRecording] = useState(false);
|
|
const [microphoneEnabled, setMicrophoneEnabled] = useState(false);
|
|
const [microphoneDeviceId, setMicrophoneDeviceId] = useState<string | undefined>(undefined);
|
|
const [systemAudioEnabled, setSystemAudioEnabled] = useState(false);
|
|
const mediaRecorder = useRef<MediaRecorder | null>(null);
|
|
const stream = useRef<MediaStream | null>(null);
|
|
const screenStream = useRef<MediaStream | null>(null);
|
|
const microphoneStream = useRef<MediaStream | null>(null);
|
|
const mixingContext = useRef<AudioContext | null>(null);
|
|
const chunks = useRef<Blob[]>([]);
|
|
const startTime = useRef<number>(0);
|
|
const discardRecording = useRef(false);
|
|
const restarting = useRef(false);
|
|
|
|
const selectMimeType = () => {
|
|
const preferred = [
|
|
"video/webm;codecs=av1",
|
|
"video/webm;codecs=h264",
|
|
"video/webm;codecs=vp9",
|
|
"video/webm;codecs=vp8",
|
|
"video/webm",
|
|
];
|
|
|
|
return preferred.find((type) => MediaRecorder.isTypeSupported(type)) ?? "video/webm";
|
|
};
|
|
|
|
const computeBitrate = (width: number, height: number) => {
|
|
const pixels = width * height;
|
|
const highFrameRateBoost =
|
|
TARGET_FRAME_RATE >= HIGH_FRAME_RATE_THRESHOLD ? HIGH_FRAME_RATE_BOOST : 1;
|
|
|
|
if (pixels >= FOUR_K_PIXELS) {
|
|
return Math.round(BITRATE_4K * highFrameRateBoost);
|
|
}
|
|
|
|
if (pixels >= QHD_PIXELS) {
|
|
return Math.round(BITRATE_QHD * highFrameRateBoost);
|
|
}
|
|
|
|
return Math.round(BITRATE_BASE * highFrameRateBoost);
|
|
};
|
|
|
|
const stopRecording = useRef(() => {
|
|
if (mediaRecorder.current?.state === "recording") {
|
|
if (stream.current) {
|
|
stream.current.getTracks().forEach((track) => track.stop());
|
|
}
|
|
if (screenStream.current) {
|
|
screenStream.current.getTracks().forEach((track) => track.stop());
|
|
screenStream.current = null;
|
|
}
|
|
if (microphoneStream.current) {
|
|
microphoneStream.current.getTracks().forEach((track) => track.stop());
|
|
microphoneStream.current = null;
|
|
}
|
|
if (mixingContext.current) {
|
|
mixingContext.current.close().catch(() => {
|
|
// Ignore close errors during recorder teardown.
|
|
});
|
|
mixingContext.current = null;
|
|
}
|
|
mediaRecorder.current.stop();
|
|
setRecording(false);
|
|
|
|
window.electronAPI?.setRecordingState(false);
|
|
}
|
|
});
|
|
|
|
useEffect(() => {
|
|
let cleanup: (() => void) | undefined;
|
|
|
|
if (window.electronAPI?.onStopRecordingFromTray) {
|
|
cleanup = window.electronAPI.onStopRecordingFromTray(() => {
|
|
stopRecording.current();
|
|
});
|
|
}
|
|
|
|
return () => {
|
|
if (cleanup) cleanup();
|
|
|
|
if (mediaRecorder.current?.state === "recording") {
|
|
mediaRecorder.current.stop();
|
|
}
|
|
if (stream.current) {
|
|
stream.current.getTracks().forEach((track) => track.stop());
|
|
stream.current = null;
|
|
}
|
|
if (screenStream.current) {
|
|
screenStream.current.getTracks().forEach((track) => track.stop());
|
|
screenStream.current = null;
|
|
}
|
|
if (microphoneStream.current) {
|
|
microphoneStream.current.getTracks().forEach((track) => track.stop());
|
|
microphoneStream.current = null;
|
|
}
|
|
if (mixingContext.current) {
|
|
mixingContext.current.close().catch(() => {
|
|
// Ignore close errors during cleanup.
|
|
});
|
|
mixingContext.current = null;
|
|
}
|
|
};
|
|
}, []);
|
|
|
|
const startRecording = async () => {
|
|
try {
|
|
const selectedSource = await window.electronAPI.getSelectedSource();
|
|
if (!selectedSource) {
|
|
alert("Please select a source to record");
|
|
return;
|
|
}
|
|
|
|
let screenMediaStream: MediaStream;
|
|
|
|
const videoConstraints = {
|
|
mandatory: {
|
|
chromeMediaSource: CHROME_MEDIA_SOURCE,
|
|
chromeMediaSourceId: selectedSource.id,
|
|
maxWidth: TARGET_WIDTH,
|
|
maxHeight: TARGET_HEIGHT,
|
|
maxFrameRate: TARGET_FRAME_RATE,
|
|
minFrameRate: MIN_FRAME_RATE,
|
|
},
|
|
};
|
|
|
|
if (systemAudioEnabled) {
|
|
try {
|
|
screenMediaStream = await navigator.mediaDevices.getUserMedia({
|
|
audio: {
|
|
mandatory: {
|
|
chromeMediaSource: CHROME_MEDIA_SOURCE,
|
|
chromeMediaSourceId: selectedSource.id,
|
|
},
|
|
},
|
|
video: videoConstraints,
|
|
} as unknown as MediaStreamConstraints);
|
|
} catch (audioErr) {
|
|
console.warn("System audio capture failed, falling back to video-only:", audioErr);
|
|
toast.error("System audio not available. Recording without system audio.");
|
|
screenMediaStream = await navigator.mediaDevices.getUserMedia({
|
|
audio: false,
|
|
video: videoConstraints,
|
|
} as unknown as MediaStreamConstraints);
|
|
}
|
|
} else {
|
|
screenMediaStream = await navigator.mediaDevices.getUserMedia({
|
|
audio: false,
|
|
video: videoConstraints,
|
|
} as unknown as MediaStreamConstraints);
|
|
}
|
|
screenStream.current = screenMediaStream;
|
|
|
|
// If microphone is enabled, request mic stream
|
|
if (microphoneEnabled) {
|
|
try {
|
|
microphoneStream.current = await navigator.mediaDevices.getUserMedia({
|
|
audio: microphoneDeviceId
|
|
? {
|
|
deviceId: { exact: microphoneDeviceId },
|
|
echoCancellation: true,
|
|
noiseSuppression: true,
|
|
autoGainControl: true,
|
|
}
|
|
: {
|
|
echoCancellation: true,
|
|
noiseSuppression: true,
|
|
autoGainControl: true,
|
|
},
|
|
video: false,
|
|
});
|
|
} catch (audioError) {
|
|
console.warn("Failed to get microphone access:", audioError);
|
|
toast.error("Microphone access denied. Recording will continue without audio.");
|
|
setMicrophoneEnabled(false);
|
|
}
|
|
}
|
|
|
|
// Combine streams
|
|
stream.current = new MediaStream();
|
|
const videoTrack = screenMediaStream.getVideoTracks()[0];
|
|
if (!videoTrack) {
|
|
throw new Error("Video track is not available.");
|
|
}
|
|
stream.current.addTrack(videoTrack);
|
|
|
|
const systemAudioTrack = screenMediaStream.getAudioTracks()[0];
|
|
const micAudioTrack = microphoneStream.current?.getAudioTracks()[0];
|
|
|
|
if (systemAudioTrack && micAudioTrack) {
|
|
// Mix system audio + mic using Web Audio API
|
|
const ctx = new AudioContext();
|
|
mixingContext.current = ctx;
|
|
const systemSource = ctx.createMediaStreamSource(new MediaStream([systemAudioTrack]));
|
|
const micSource = ctx.createMediaStreamSource(new MediaStream([micAudioTrack]));
|
|
const micGain = ctx.createGain();
|
|
micGain.gain.value = MIC_GAIN_BOOST;
|
|
const destination = ctx.createMediaStreamDestination();
|
|
systemSource.connect(destination);
|
|
micSource.connect(micGain).connect(destination);
|
|
stream.current.addTrack(destination.stream.getAudioTracks()[0]);
|
|
} else if (systemAudioTrack) {
|
|
stream.current.addTrack(systemAudioTrack);
|
|
} else if (micAudioTrack) {
|
|
stream.current.addTrack(micAudioTrack);
|
|
}
|
|
try {
|
|
await videoTrack.applyConstraints({
|
|
frameRate: { ideal: TARGET_FRAME_RATE, max: TARGET_FRAME_RATE },
|
|
width: { ideal: TARGET_WIDTH, max: TARGET_WIDTH },
|
|
height: { ideal: TARGET_HEIGHT, max: TARGET_HEIGHT },
|
|
});
|
|
} catch (constraintError) {
|
|
console.warn(
|
|
"Unable to lock 4K/60fps constraints, using best available track settings.",
|
|
constraintError,
|
|
);
|
|
}
|
|
|
|
let {
|
|
width = DEFAULT_WIDTH,
|
|
height = DEFAULT_HEIGHT,
|
|
frameRate = TARGET_FRAME_RATE,
|
|
} = videoTrack.getSettings();
|
|
|
|
// Ensure dimensions are divisible by 2 for VP9/AV1 codec compatibility
|
|
width = Math.floor(width / CODEC_ALIGNMENT) * CODEC_ALIGNMENT;
|
|
height = Math.floor(height / CODEC_ALIGNMENT) * CODEC_ALIGNMENT;
|
|
|
|
const videoBitsPerSecond = computeBitrate(width, height);
|
|
const mimeType = selectMimeType();
|
|
|
|
console.log(
|
|
`Recording at ${width}x${height} @ ${frameRate ?? TARGET_FRAME_RATE}fps using ${mimeType} / ${Math.round(
|
|
videoBitsPerSecond / BITS_PER_MEGABIT,
|
|
)} Mbps`,
|
|
);
|
|
|
|
const hasAudio = stream.current.getAudioTracks().length > 0;
|
|
|
|
chunks.current = [];
|
|
const recorder = new MediaRecorder(stream.current, {
|
|
mimeType,
|
|
videoBitsPerSecond,
|
|
...(hasAudio
|
|
? { audioBitsPerSecond: systemAudioTrack ? AUDIO_BITRATE_SYSTEM : AUDIO_BITRATE_VOICE }
|
|
: {}),
|
|
});
|
|
mediaRecorder.current = recorder;
|
|
recorder.ondataavailable = (e) => {
|
|
if (e.data && e.data.size > 0) chunks.current.push(e.data);
|
|
};
|
|
recorder.onstop = async () => {
|
|
stream.current = null;
|
|
if (discardRecording.current) {
|
|
discardRecording.current = false;
|
|
chunks.current = [];
|
|
return;
|
|
}
|
|
if (chunks.current.length === 0) return;
|
|
const duration = Date.now() - startTime.current;
|
|
const recordedChunks = chunks.current;
|
|
const buggyBlob = new Blob(recordedChunks, { type: mimeType });
|
|
// Clear chunks early to free memory immediately after blob creation
|
|
chunks.current = [];
|
|
const timestamp = Date.now();
|
|
const videoFileName = `${RECORDING_FILE_PREFIX}${timestamp}${VIDEO_FILE_EXTENSION}`;
|
|
|
|
try {
|
|
const videoBlob = await fixWebmDuration(buggyBlob, duration);
|
|
const arrayBuffer = await videoBlob.arrayBuffer();
|
|
const videoResult = await window.electronAPI.storeRecordedVideo(
|
|
arrayBuffer,
|
|
videoFileName,
|
|
);
|
|
if (!videoResult.success) {
|
|
console.error("Failed to store video:", videoResult.message);
|
|
return;
|
|
}
|
|
|
|
if (videoResult.path) {
|
|
await window.electronAPI.setCurrentVideoPath(videoResult.path);
|
|
}
|
|
|
|
await window.electronAPI.switchToEditor();
|
|
} catch (error) {
|
|
console.error("Error saving recording:", error);
|
|
}
|
|
};
|
|
recorder.onerror = () => setRecording(false);
|
|
recorder.start(RECORDER_TIMESLICE_MS);
|
|
startTime.current = Date.now();
|
|
setRecording(true);
|
|
window.electronAPI?.setRecordingState(true);
|
|
} catch (error) {
|
|
console.error("Failed to start recording:", error);
|
|
const errorMsg = error instanceof Error ? error.message : "Failed to start recording";
|
|
if (errorMsg.includes("Permission denied") || errorMsg.includes("NotAllowedError")) {
|
|
toast.error("Recording permission denied. Please allow screen recording.");
|
|
} else {
|
|
toast.error(errorMsg);
|
|
}
|
|
setRecording(false);
|
|
if (stream.current) {
|
|
stream.current.getTracks().forEach((track) => track.stop());
|
|
stream.current = null;
|
|
}
|
|
if (screenStream.current) {
|
|
screenStream.current.getTracks().forEach((track) => track.stop());
|
|
screenStream.current = null;
|
|
}
|
|
if (microphoneStream.current) {
|
|
microphoneStream.current.getTracks().forEach((track) => track.stop());
|
|
microphoneStream.current = null;
|
|
}
|
|
if (mixingContext.current) {
|
|
mixingContext.current.close().catch(() => {
|
|
// Ignore close errors during error recovery.
|
|
});
|
|
mixingContext.current = null;
|
|
}
|
|
}
|
|
};
|
|
|
|
const toggleRecording = () => {
|
|
recording ? stopRecording.current() : startRecording();
|
|
};
|
|
|
|
const restartRecording = async () => {
|
|
if (restarting.current) return;
|
|
|
|
const recorder = mediaRecorder.current;
|
|
if (!recorder || recorder.state !== "recording") return;
|
|
|
|
restarting.current = true;
|
|
discardRecording.current = true;
|
|
|
|
const waitForStop = new Promise<void>((resolve) => {
|
|
recorder.addEventListener("stop", () => resolve(), { once: true });
|
|
});
|
|
|
|
stopRecording.current();
|
|
await waitForStop;
|
|
|
|
try {
|
|
await startRecording();
|
|
} finally {
|
|
restarting.current = false;
|
|
}
|
|
};
|
|
|
|
return {
|
|
recording,
|
|
toggleRecording,
|
|
restartRecording,
|
|
microphoneEnabled,
|
|
setMicrophoneEnabled,
|
|
microphoneDeviceId,
|
|
setMicrophoneDeviceId,
|
|
systemAudioEnabled,
|
|
setSystemAudioEnabled,
|
|
};
|
|
}
|