屏幕录制
大约 4 分钟
屏幕录制
相关信息
electron 的屏幕录制主要分为两类
- 1.录制系统应用以及屏幕
- 2.录制当前electron应用或者webview的画面
1. 录制系统桌面以及应用
这块主要运用到 electron 的两个api,desktopCapturer
以及 getUserMedia
这边使用hook配合context来共享状态
useMediaRecorder
import { useCallback, useEffect, useRef, useState } from 'react';
// 录制前的准备工作
export enum RecoderStateMsg {
NONE = 'none', // 未开始录制
SELECT_MEDIA = 'select-media', //选择录制源
MEDIA_READY = 'media-ready', // 选择完毕
RECORD_BEFORE = 'record-before', // 录制前准备
RECORD_READY = 'record-ready', // 准备录制
REDORDING = 'recording', // 录制准备完成
BEFORE_STOP = 'before-stop', // 录制结束之前
STOP = 'stop', // 录制结束
PAUSE = 'pause', //暂停
RELOAD = 'reload', //重新录制
START = 'start', // 录制中
ABORT = 'abort', // 中断 ,用户把录制源给关闭了
}
let isSaveVideo = false;
export const useMediaRecorder = (isCurrentWindow = false) => {
const [selectSourceId, setSelectSourceId] = useState('');
const [recoderState, setRecoderState] = useState<RecoderStateMsg>(RecoderStateMsg.NONE);
const mediaStream = useRef<MediaStream>();
const mediaRecorder = useRef<MediaRecorder | null>(null);
const mediaChunks = useRef<Blob[]>([]);
const [mediaBlobUrl, setMediaBlobUrl] = useState<string | undefined>(undefined);
useEffect(() => {
return () => {
// 录制页面卸载的时候全部清除,防止内存泄漏
stopRecording();
clearBlobUrl();
setRecoderState(RecoderStateMsg.NONE);
};
}, []);
// 当前选择的源
const changeSelectSourceId = useCallback(
(id: string) => {
if (selectSourceId !== id) {
setSelectSourceId(() => id);
} else {
setSelectSourceId(() => '');
}
},
[selectSourceId]
);
const trackStopHandler = () => {
if (!mediaStream.current) return;
mediaStream.current.getTracks().forEach((track) => {
track.stop();
mediaStream.current!.removeTrack(track);
});
};
// 获取媒体流
const getMediaStream = async () => {
try {
trackStopHandler();
// 这边做一下录制源的判断看一下是不是复刻的,还是录制的
if (!isCurrentWindow) {
const videoSource: MediaStream = await navigator.mediaDevices.getUserMedia({
audio: false,
video: {
mandatory: {
chromeMediaSource: 'desktop',
chromeMediaSourceId: selectSourceId,
},
} as any,
});
const audioSource = await navigator.mediaDevices.getUserMedia({
audio: {
mandatory: {
chromeMediaSource: 'desktop',
},
},
video: {
mandatory: {
chromeMediaSource: 'desktop',
},
},
} as any);
audioSource.getVideoTracks().forEach((track) => audioSource.removeTrack(track));
mediaStream.current = new MediaStream([...audioSource.getAudioTracks(), ...videoSource.getVideoTracks()]);
} else {
// 获取自己屏幕的录制源
const source: MediaStream = await navigator.mediaDevices.getDisplayMedia({
audio: true,
video: true,
});
mediaStream.current = source;
}
} catch (error) {
console.log('error: ', error);
}
};
// 开始录屏
const startRecording = async () => {
await getMediaStream();
if (!mediaStream.current) {
return;
}
mediaRecorder.current = new MediaRecorder(mediaStream.current!, {
mimeType: 'video/webm',
// 支持手动设置码率,这里设了1.5Mbps的码率,以限制码率较大的情况
// videoBitsPerSecond: 1.5e6
});
mediaRecorder.current.ondataavailable = onRecordingActive;
mediaRecorder.current.onstart = onRecordingStart;
mediaRecorder.current.onstop = onRecodingStop;
mediaRecorder.current!.start();
};
const onRecodingStop = (): void => {
// 这边要生成临时的url来记录
if (isSaveVideo) {
const videoFile = new Blob(mediaChunks.current, { type: 'video/webm' });
const url = URL.createObjectURL(videoFile);
setMediaBlobUrl(url);
}
};
// 开始录制
const onRecordingStart = () => {
clearBlobUrl();
setRecoderState(RecoderStateMsg.START);
};
// 记录blob
const onRecordingActive = ({ data }: BlobEvent) => {
mediaChunks.current.push(data);
};
// 停止录制
const stopRecording = (isSave: boolean = false) => {
isSaveVideo = isSave;
trackStopHandler();
mediaStream.current = undefined;
if (mediaRecorder.current && mediaRecorder.current?.state !== 'inactive') {
mediaRecorder.current?.stop();
}
setRecoderState(RecoderStateMsg.STOP);
changeSelectSourceId('');
};
// 暂停录制
const pauseRecording = () => {
if (mediaRecorder.current && mediaRecorder.current.state === 'recording') {
mediaRecorder.current.pause();
setRecoderState(RecoderStateMsg.PAUSE);
}
};
// 继续录制
const resumeRecording = () => {
if (mediaRecorder.current && mediaRecorder.current.state === 'paused') {
setRecoderState(RecoderStateMsg.START);
mediaRecorder.current.resume();
}
};
// 清空缓存链接
const clearBlobUrl = () => {
if (mediaBlobUrl) {
URL.revokeObjectURL(mediaBlobUrl);
}
mediaChunks.current = [];
setMediaBlobUrl(undefined);
};
return {
selectSourceId,
changeSelectSourceId, //设置激活的源id
setRecoderState, //设置当前状态
recoderState,
startRecording, // 开始录制
getMediaStream,
stopRecording, // 停止录制
mediaRecorder, // 录制对象
mediaStream, // 轨道流
resumeRecording, // 继续录制
pauseRecording, //暂停录制
clearBlobUrl, // 清空缓存
mediaBlobUrl,
mediaChunks,
setMediaBlobUrl,
};
};
Ipc
ipcMain.handle(
'get-media-source',
() =>desktopCapturer.getSources({ types: ['screen', 'window'] })
);
2.录制webview或者应用内视频
要实现这个功能我们需要借助 getDisplayMedia
这个api的方法,不过getDisplayMedia
并不能直接使用,要先通过setDisplayMediaRequestHandler
设置之后才能使用
这边主要演示如何通过webview录制,应用内的录制以此类推
webview 不能直接设置setDisplayMediaRequestHandler
但是我们可以通过getWebContentsId
让主进程我们设置
webview 因为是一个单独的渲染进程,所以我们要使用ipc-message
来进行跨渲染进程通信来实现,二进制的传递
演示中的hook请参照上方
window.ts
import { app, BrowserWindow, ipcMain, Menu, session, webContents } from 'electron';
import { join, resolve } from 'path';
import { resolveHtmlPath } from '@/main/util';
import appConfig, { PRELOAD_JS_URL } from '@/main/config';
let Window: BrowserWindow | null;
// 创建窗口
export const createWindow = async (cookie: string, platform: string, projectName: string) => {
Window = new BrowserWindow({
width: 1300,
height: 700,
show: false,
resizable: false,
maximizable: false,
webPreferences: {
preload: PRELOAD_JS_URL,
webviewTag: true,
nodeIntegration: true,
webSecurity: false,
devTools: true,
},
icon: join(appConfig.ASSETS_PATH, 'agents', appConfig.Agent, 'icons', 'icon.png'),
});
Window.webContents.setWindowOpenHandler(() => {
return { action: 'deny' };
});
if (!app.isPackaged) {
Window.loadURL(resolveHtmlPath('window.html'));
} else {
Window.loadURL(`file://${resolve(__dirname, '../window/window.html')}`);
}
Window.on('ready-to-show', () => {
if (!Window) {
throw new Error('"Window" is not defined');
}
Window.show();
});
};
// 获取webview的preload
ipcMain.handle('get-live-record-preload-url', () => {
return app?.isPackaged ? join(__dirname, 'livePreload.js') : join(__dirname, '../../.erb/dll/livePreload.js');
});
ipcMain.handle('setLive-record-sesstion', (_, id: number) => {
const webc = webContents.fromId(id);
if (webc) {
webc.session.setDisplayMediaRequestHandler((request, callback) => {
callback({ video: request.frame, audio: request.frame, enableLocalEcho: true });
});
}
});
preload.ts
import { ipcRenderer } from 'electron';
function blobToArray(blob) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = function () {
const arrayBuffer = reader.result as ArrayBuffer;
const uint8Array = new Uint8Array(arrayBuffer);
const array = Array.from(uint8Array);
resolve(array);
};
reader.readAsArrayBuffer(blob);
});
}
let mediaRecorder: MediaRecorder | null = null;
let mediaStream = new MediaStream();
const trackStopHandler = () => {
if (!mediaStream) return;
mediaStream.getTracks().forEach((track) => {
track.stop();
mediaStream.removeTrack(track);
});
};
ipcRenderer.on('startRecording', async () => {
if (mediaRecorder && mediaRecorder.state === 'paused') {
mediaRecorder.resume();
return;
}
trackStopHandler();
mediaStream = await navigator.mediaDevices.getDisplayMedia({
audio: true,
video: true,
});
mediaRecorder = new MediaRecorder(mediaStream, {
mimeType: 'video/webm',
});
mediaRecorder.ondataavailable = async ({ data }: BlobEvent) => {
const res = await blobToArray(data);
ipcRenderer.sendToHost('mediaData', res);
};
mediaRecorder.start(1000);
});
ipcRenderer.on('stopRecording', () => {
trackStopHandler();
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
mediaRecorder.stop();
ipcRenderer.sendToHost('stop');
}
});
ipcRenderer.on('pauseRecording', () => {
if (mediaRecorder && mediaRecorder.state === 'recording') {
mediaRecorder.pause();
}
});
ipcRenderer.on('resumeRecording', () => {
if (mediaRecorder && mediaRecorder.state === 'paused') {
mediaRecorder.resume();
}
});
WebView.tsx
import { RecordContext } from '@/renderer/pages/RecordView/context/recordScreentContext';
import { RecoderStateMsg } from '@/renderer/pages/RecordView/hooks/useMediaRecorder';
import { WebviewTag } from 'electron';
import { useContext, useEffect, useRef } from 'react';
const WebView = ({ preloadUrl, roomId }: { preloadUrl: string; roomId: string }) => {
const { recoderState, setRecoderState, setMediaBlobUrl, mediaChunks } = useContext(RecordContext);
const webview = useRef<WebviewTag>(null);
useEffect(() => {
if (webview.current) {
webview.current.addEventListener('dom-ready', () => {
// 设置一下属性
window.liveRecord.setLiveRecordSesstion(webview.current.getWebContentsId());
webview.current.addEventListener('ipc-message', (event) => {
if (event.channel === 'mediaData') {
const blob = new Blob([new Uint8Array(event.args[0])], { type: 'video/webm' });
mediaChunks.current.push(blob);
}
if (event.channel === 'stop') {
const videoFile = new Blob(mediaChunks.current, { type: 'video/webm' });
const url = URL.createObjectURL(videoFile);
setMediaBlobUrl(url);
setRecoderState(RecoderStateMsg.STOP);
}
});
// webview.current.openDevTools();
});
}
}, []);
useEffect(() => {
if (recoderState === RecoderStateMsg.START) {
webview.current.send('startRecording');
}
if (recoderState === RecoderStateMsg.BEFORE_STOP) {
webview.current.send('stopRecording');
}
if (recoderState === RecoderStateMsg.PAUSE) {
webview.current.send('pauseRecording');
}
}, [recoderState]);
return (
<webview
preload={`file://${preloadUrl}`}
ref={webview}
src={`https://live.douyin.com/${roomId}`}
className={`mx-auto transition-all duration-300 ease-in-out w-full h-full `}
key={roomId}
></webview>
);
};
export default WebView;
preloadUrl获取
const [preloadUrl, setPreloadUrl] = useState('');
useEffect(() => {
window.liveRecord.getLivePreloadUrl().then((url) => {
setPreloadUrl(url);
});