Electron / Chromium 屏幕錄制 - 那些我踩過的坑
背景
Web 屏幕錄制也許對(duì)我們來說并不陌生,最常見的場景,例如:各種視頻會(huì)議、遠(yuǎn)程桌面軟件,遠(yuǎn)程會(huì)議軟件的出現(xiàn)大大方便了人們的交流與溝通,在 WFH 期間對(duì)眾多企業(yè)的線上運(yùn)轉(zhuǎn)起到關(guān)鍵的作用。除了屏幕的實(shí)時(shí)分享,錄屏的應(yīng)用還存在另一種應(yīng)用場景,即“記錄實(shí)時(shí)操作并保留現(xiàn)場,方便后續(xù)追溯與回放”,即是我們業(yè)務(wù)的主要場景。對(duì)于我們的業(yè)務(wù),強(qiáng)依賴該功能的穩(wěn)定性。以下是我們業(yè)務(wù)對(duì)該功能的一些硬性指標(biāo):
指標(biāo)要求
支持任意時(shí)長的錄制,支持超過 6 小時(shí)時(shí)長的錄制。 支持同時(shí)錄音。在錄屏同時(shí)錄制到屏幕中正在播放的內(nèi)容的聲音。 支持跨平臺(tái),兼容 Windows、Mac、Linux 三個(gè)平臺(tái)。 支持在 App 從 A 窗口拖拽到 B 窗口時(shí)持續(xù)錄制。 支持在最小化,最大化,全屏?xí)r保持錄屏,且錄制范圍僅在 App 內(nèi)部,不可錄制到 App 外。 支持長時(shí)間,不間斷,不關(guān)閉 App 的情況下可以不斷錄制。 支持在無需完整下載錄屏的情況下,在 Web 端隨意拖拽時(shí)間線。 支持 App 多標(biāo)簽頁切換情況下,對(duì)多標(biāo)簽頁的同時(shí)錄制。 支持 App 多開窗口在同一個(gè)系統(tǒng)窗口內(nèi),同時(shí)錄制 App 窗口。 支持直播實(shí)時(shí)流的錄制。 錄屏文件不能存儲(chǔ)在本地,錄制結(jié)束后必須自動(dòng)上傳并加密存儲(chǔ)。
技術(shù)方案探索
目前 Chromium 端上視頻直接錄制,一般來說有兩種技術(shù)方案,即:rrweb 方案、以及 WebRTC API 方案。如果考慮 Electron 場景,又會(huì)額外多出一種 ffmpeg 的方案。
rrweb
優(yōu)勢
支持在錄屏的同時(shí)直接錄制到當(dāng)前 Tab 內(nèi)的聲音。 跨平臺(tái)兼容。 支持窗口的拖拽、最小化、最大化、全屏等情況的持續(xù)錄制。 錄屏尺寸小。 支持在無需完整下載錄屏的情況下,在 Web 端隨意拖拽時(shí)間線。 性能較好。
劣勢
無法錄制直播實(shí)時(shí)流。考慮其實(shí)現(xiàn)原理,錄屏場景有限。 不支持在關(guān)閉 App 標(biāo)簽頁的情況錄制,如果 Renderer 進(jìn)程關(guān)閉,則會(huì)直接終止錄制并丟失錄屏。 某些場景會(huì)對(duì)頁面 DOM 有影響。
ffmpeg
優(yōu)勢
同等體積,錄屏文件的輸出質(zhì)量好。 性能好。 支持錄制直播實(shí)時(shí)流。
劣勢
跨平臺(tái)兼容處理復(fù)雜。 錄制區(qū)域非動(dòng)態(tài),雖支持選區(qū),但若 App 移動(dòng)則無能為力的錄制到屏幕外內(nèi)容。 不支持 App 多標(biāo)簽頁切換情況下,對(duì)多標(biāo)簽頁進(jìn)行暫停或繼續(xù)。 支持在 App 從 A 窗口拖拽到 B 窗口時(shí)持續(xù)對(duì) App 錄制。 錄屏文件中間時(shí)間會(huì)存儲(chǔ)在本地,若 App 關(guān)閉后會(huì)導(dǎo)致錄屏文件的暴露。 不支持 App 多開窗口情況下的,且在同時(shí)錄制。
webRTC
優(yōu)勢
支持全部指標(biāo) 1-11。
劣勢
性能較差,錄制時(shí) CPU 占用率相對(duì)較高。 原生錄制的視頻文件,沒有視頻時(shí)長。 原生錄制的視頻文件,不支持時(shí)間線拖拽。 原生不支持超長時(shí)長的錄制,若錄屏文件大于磁盤空間的 1/10 會(huì)報(bào)錯(cuò)。 原生錄制會(huì)有較大的內(nèi)存占用。 視頻刪除依賴 V8 與 Blob 實(shí)現(xiàn)的垃圾回收機(jī)制,非常容易內(nèi)存泄露。
考慮到 rrweb 較好的性能,最初我們第一版實(shí)際上是基于 rrweb 實(shí)現(xiàn)的,但 rrweb 的原生硬傷最終導(dǎo)致我們放棄該方案,比如如果用戶關(guān)閉窗口會(huì)直接導(dǎo)致錄屏丟失是不可接受的,其次 rrweb 不支持直播實(shí)時(shí)流是我們最終放棄他的根本原因。此外考慮到 ffmpeg 的種種限制,以及我們自身的指標(biāo)要求,最終我們選擇了 webRTC API 直接錄制的方案實(shí)現(xiàn)了錄屏功能,并在后續(xù)踩了一些列的坑,一下是一些分享。
媒體流的獲取
在 WebRTC 標(biāo)準(zhǔn)中,一切持續(xù)不斷產(chǎn)生媒體的起點(diǎn),都被抽象成媒體流,例如我們需要錄制屏幕與聲音,其實(shí)現(xiàn)的關(guān)鍵就是找到需要錄制屏幕的源和錄制音頻的源,整體的流程如下圖所示:
視頻流獲取
想獲取視頻流,首先需要獲取所需要捕獲視頻流的 MediaSourceId。Electron 提供了一個(gè)獲取各個(gè)“窗口”和“屏幕”視頻 MediaSourceId 的通用 API
import { desktopCapturer } from 'electron';
// 獲取全部窗口或屏幕的mediaSourceId
desktopCapturer.getSources({
types: ['screen', 'window'], // 設(shè)定需要捕獲的是"屏幕",還是"窗口"
thumbnailSize: {
height: 300, // 窗口或屏幕的截圖快照高度
width: 300 // 窗口或屏幕的截圖快照寬度
},
fetchWindowIcons: true // 如果視頻源是窗口且有圖標(biāo),則設(shè)置該值可以捕獲到的窗口圖標(biāo)
}).then(sources => {
sources.forEach(source => {
// 如果視頻源是窗口且有圖標(biāo),且fetchWindowIcons設(shè)為true,則為捕獲到的窗口圖標(biāo)
console.log(source.appIcon);
// 顯示器Id
console.log(source.display_id);
// 視頻源的mediaSourceId,可通過該mediaSourceId獲取視頻源
console.log(source.id);
// 窗口名,通常來說與任務(wù)管理器看到的進(jìn)程名一致
console.log(source.name);
// 窗口或屏幕在調(diào)用本API瞬間抓捕到的截圖快照
console.log(source.thumbnail);
});
});
如果你只想獲取當(dāng)前窗口的 MediaSourceID
import { remote } from 'electron';
// 獲取當(dāng)前窗口mediaSourceId的做法
const mediaSourceId = remote.getCurrentWindow().getMediaSourceId();
在獲取到 mediaSourceId 后,繼續(xù)獲取視頻流,方法如下:
import { remote } from 'electron';
// 視頻流獲取
const videoSource: MediaStream = await navigator.mediaDevices.getUserMedia({
audio: false, // 強(qiáng)行表示不錄制音頻,音頻額外獲取
video: {
mandatory: {
chromeMediaSource: 'desktop',
chromeMediaSourceId: remote.getCurrentWindow().getMediaSourceId()
}
}
});
其中如果獲取的視頻源是整個(gè)桌面窗口,且操作系統(tǒng)如果是 macOS,還要授權(quán)“屏幕錄制權(quán)限”
以上步驟執(zhí)行后,我們便可以輕松獲得視頻源。
音頻源獲取
不同于視頻源的輕松獲取,音頻源的獲取著實(shí)有些復(fù)雜,針對(duì) macOS 和 Windows 系統(tǒng),需要分別處理兩種獲取方式。首先,在 Windows 獲取屏幕音頻非常簡單且容易,且不需要任何授權(quán),因此這里如果大家需要錄制音頻,一定要做好權(quán)限提示、
// Windows音頻流獲取
const audioSource: MediaStream = await navigator.mediaDevices.getUserMedia({
audio: {
mandatory: {
// 無需指定mediaSourceId就可以錄音了,錄得是系統(tǒng)音頻
chromeMediaSource: 'desktop',
},
},
// 如果想要錄制音頻,必須同樣把視頻的選項(xiàng)帶上,否則會(huì)失敗
video: {
mandatory: {
chromeMediaSource: 'desktop',
},
},
});
// 接著手工移除點(diǎn)不用的視頻源,即可完成音頻流的獲取
(audioSource.getVideoTracks() || []).forEach(track => audioSource.removeTrack(track));
接著,再看 macOS 音頻流的獲取,這里就有一些難度了,由于 macOS 的音頻權(quán)限設(shè)定(參考[1]),任何人都沒辦法直接錄制系統(tǒng)音頻,除非安裝第三方驅(qū)動(dòng) Kext,比如 soundFlower 或者 blackHole,由于 blackHole 同時(shí)支持 arm64 M1 處理器和 x64 Intel 處理器(參考[2]),因此我們最終選擇 blackHole 的方式獲取系統(tǒng)音頻。那么在引導(dǎo)用戶安裝 BlackHole 前,我們需要先檢查當(dāng)前的安裝狀況,如果用戶沒有安裝過,則提示其安裝,如果安裝過則繼續(xù),這里的方式如下:
import { remote } from 'electron';
const isWin = process.platform === 'win32';
const isMac = process.platform === 'darwin';
declare type AudioRecordPermission =
| 'ALLOWED'
| 'RECORD_PERMISSION_NOT_GRANTED'
| 'NOT_INSTALL_BLACKHOLE'
| 'OS_NOT_SUPPORTED';
// 檢查用戶電腦是否有安裝SoundFlower或者BlackHole
async function getIfAlreadyInstallSoundFlowerOrBlackHole(): Promise<boolean> {
const devices = await navigator.mediaDevices.enumerateDevices();
return devices.some(
device => device.label.includes('Soundflower (2ch)') || device.label.includes('BlackHole 2ch (Virtual)')
);
}
// 獲取是否有麥克風(fēng)權(quán)限(blackhole的實(shí)現(xiàn)方式是將屏幕音頻模擬為麥克風(fēng))
function getMacAudioRecordPermission(): 'not-determined' | 'granted' | 'denied' | 'restricted' | 'unknown' {
return remote.systemPreferences.getMediaAccessStatus('microphone');
}
// 請(qǐng)求麥克風(fēng)權(quán)限(blackhole的實(shí)現(xiàn)方式是將屏幕音頻模擬為麥克風(fēng))
function requestMacAudioRecordPermission(): Promise<boolean> {
return remote.systemPreferences.askForMediaAccess('microphone');
}
async function getAudioRecordPermission(): Promise<AudioRecordPermission> {
if (isWin) {
// Windows直接支持
return 'ALLOWED';
} else if (isMac) {
if (await getIfAlreadyInstallSoundFlowerOrBlackHole()) {
if (getMacAudioRecordPermission() !== 'granted') {
if (!(await requestMacAudioRecordPermission())) {
return 'RECORD_PERMISSION_NOT_GRANTED';
}
}
return 'ALLOWED';
}
return 'NOT_INSTALL_BLACKHOLE';
} else {
// Linux暫時(shí)還不支持錄制音頻
return 'OS_NOT_SUPPORTED';
}
}
此外,Electron 應(yīng)用必須在 info.plist 中聲明自己需要用到音頻錄制權(quán)限,才可以錄制音頻,以 Electron-builder 打包流程為例:
// 添加electron-builder配置
const createMac = () => ({
...commonConfig,
// 聲明afterPack鉤子函數(shù),用于處理音頻授權(quán)時(shí)的i18n
afterPack: 'scripts/macAfterPack.js',
mac: {
...commonMacConfig,
// 必須指定entitlements.mac.plist用于簽名時(shí)的權(quán)限聲明
entitlements: 'scripts/entitlements.mac.plist',
// 必須限制運(yùn)行時(shí)為"hardened",以使應(yīng)用通過natorize公證
hardenedRuntime: true,
extendInfo: {
// 為info.plist添加多語言支持
LSHasLocalizedDisplayName: true,
}
}
});
為了獲取音頻錄制權(quán)限,需要自定義 entitlements.mac.plist,并聲明以下四個(gè)變量:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
<true/>
<key>com.apple.security.device.audio-input</key>
<true/>
</dict>
</plist>
為了使音頻錄制前的“麥克風(fēng)授權(quán)”提示支持多語言,我們這里手動(dòng)添加以下自定義文字到每個(gè)語言的.lproj/InfoPlist.strings 文件內(nèi):
// macAfterPack.js
const fs = require('fs');
// 用于存儲(chǔ)到xxx.lproj/InfoPlist.strings的的i18n文字
const i18nNSStrings = {
en: {
NSMicrophoneUsageDescription: 'Please allow this program to access your system audio',
},
ja: {
NSMicrophoneUsageDescription: 'このプログラムがシステムオーディオにアクセスして録音することを許可してください',
},
th: {
NSMicrophoneUsageDescription: '??????????????????????????????????????????????????????',
},
ko: {
NSMicrophoneUsageDescription: '? ????? ??? ???? ????? ?? ? ? ??? ??????',
},
zh_CN: {
NSMicrophoneUsageDescription: '請(qǐng)?jiān)试S本程序訪問錄制您的系統(tǒng)音頻',
},
};
exports.default = async context => {
const { electronPlatformName, appOutDir } = context;
if (electronPlatformName !== 'darwin') {
return;
}
const productFilename = context.packager.appInfo.productFilename;
const resourcesPath = `${appOutDir}/${productFilename}.app/Contents/Resources/`;
console.log(
`[After Pack] start create i18n NSString bundle, productFilename: ${productFilename}, resourcesPath: ${resourcesPath}`
);
return Promise.all(
Object.keys(i18nNSStrings).map(langKey => {
const infoPlistStrPath = `${langKey}.lproj/InfoPlist.strings`;
let infos = '';
const langItem = i18nNSStrings[langKey];
Object.keys(langItem).forEach(infoKey => {
infos += `"${infoKey}" = "${langItem[infoKey]}";\n`;
});
return new Promise(resolve => {
const filePath = `${resourcesPath}${infoPlistStrPath}`;
fs.writeFile(filePath, infos, err => {
resolve();
if (err) {
throw err;
}
console.log(`[After Pack] ${filePath} create success`);
});
});
})
);
};
以上,可以完成最基本的 macOS 音頻錄制能力權(quán)限。接著,以 Blackhole 安裝過程為例如下圖:


當(dāng)安裝后,需要在「啟動(dòng)臺(tái)」中搜索系統(tǒng)自帶軟件「音頻 MIDI 設(shè)置」并打開。
點(diǎn)擊左下角「+」號(hào),選擇「創(chuàng)建多輸出設(shè)備」。在右側(cè)菜單中的「使用」里勾選「BlackHole」(必選)和「揚(yáng)聲器」/「耳機(jī)」(二選一或多選)「主設(shè)備」選擇「揚(yáng)聲器」/「耳機(jī)」。
在菜單欄的「音量」設(shè)置中選擇剛才創(chuàng)建好的「多輸出設(shè)備」為聲音輸出設(shè)備。
是的,macOS 的音頻錄制步驟非常繁瑣,但是這只能說是目前的最優(yōu)解法了。在完成以上“基本權(quán)限配置”與“Blackhole 擴(kuò)展配置”后,我們便可以在代碼中順利獲取音頻流了:
if (process.platform === 'darwin') {
const permission = await getAudioRecordPermission();
switch (permission) {
case 'ALLOWED':
const devices = await navigator.mediaDevices.enumerateDevices();
const outputdevices = devices.filter(
_device => _device.kind === 'audiooutput' && _device.deviceId !== 'default'
);
const soundFlowerDevices = outputdevices.filter(_device => _device.label === 'Soundflower (2ch)');
const blackHoleDevices = outputdevices.filter(_device => _device.label === 'BlackHole 2ch (Virtual)');
// 如果用戶安裝soundFlower或者blackhole,則按優(yōu)先級(jí)獲取deviceId
const deviceId = soundFlowerDevices.length ?
soundFlowerDevices[0].deviceId :
blackHoleDevices.length ?
blackHoleDevices[0].deviceId :
null;
if (deviceId) {
// 當(dāng)獲取到可使用的deviceId時(shí),抓取音頻流
const audioSource = await navigator.mediaDevices.getUserMedia({
audio: {
deviceId: {
exact: deviceId, // 根據(jù)獲取到的deviceId,獲取音頻流
},
sampleRate: 44100,
// 這里的三個(gè)參數(shù)都關(guān)閉可以獲得最原始的音頻
// 否則Chromium默認(rèn)會(huì)對(duì)音頻做一些處理
echoCancellation: false,
noiseSuppression: false,
autoGainControl: false,
},
video: false,
});
}
break;
case 'NOT_INSTALL_BLACKHOLE':
// 這里做一些提示,告知用戶沒有安裝插件
break;
case 'RECORD_PERMISSION_NOT_GRANTED':
// 這里做一些提示,告知用戶沒有授權(quán)
break;
default:
break;
}
}
以上,雖然有些許繁瑣,但是!至少!我們可以同時(shí)錄制 Windows 和 macOS 的音頻啦~如果正確配置好,執(zhí)行上述代碼后,會(huì)彈出如圖所示的原生授權(quán)彈窗:
如果用戶不小心點(diǎn)了不允許,后續(xù)也可以在“系統(tǒng)偏好設(shè)置-安全與隱私-麥克風(fēng)”這里打開錄制授權(quán)。
合并音視頻流
在以上步驟執(zhí)行后,我們便可以合并兩個(gè)流,提取各自的軌道,完成一個(gè)新的 MediaStream 的創(chuàng)建。
// 合并音頻流與視頻流
const combinedSource = new MediaStream([...this._audioSource.getAudioTracks(), ...this._videoSource.getVideoTracks()]);
媒體流的錄制
編碼格式
我們已經(jīng)有了錄制源,但沒有創(chuàng)建錄制 = 沒有開始錄,Chromium 提供了一個(gè)叫做 MediaRecorder 的類,用于我們傳入媒體流并錄制視頻,因此如何創(chuàng)建 MediaRecorder 并發(fā)起錄制,是錄屏的核心。MediaRecorder 本身支持僅支持錄制 webm 格式,但支持多種編碼格式,例如:vp8、vp9、h264 等,MediaRecorder 貼心的提供了一個(gè) API,方便我們測試編碼格式兼容性
let types: string[] = [
"video/webm",
"audio/webm",
"video/webm;codecs=vp9",
"video/webm;codecs=vp8",
"video/webm;codecs=daala",
"video/webm;codecs=h264",
"audio/webm;codecs=opus",
"video/mpeg"
];
for (let i in types) {
// 可以自行測試需要的編碼的MIME Type是否支持
console.log( "Is " + types[i] + " supported? " + (MediaRecorder.isTypeSupported(types[i]) ? "Yes" : "No :("));
}
經(jīng)測試,以上編碼格式錄制時(shí)的 CPU 占用并沒有什么本質(zhì)區(qū)別,因此建議直接選 VP9 錄。
創(chuàng)建錄制
確定好編碼,并合并好音視頻流,我們可以真正開始錄制了:
const recorder = new MediaRecorder(combinedSource, {
mimeType: 'video/webm;codecs=vp9',
// 支持手動(dòng)設(shè)置碼率,這里設(shè)了1.5Mbps的碼率,以限制碼率較大的情況
// 由于本身還是動(dòng)態(tài)碼率,這個(gè)值并不準(zhǔn)確
videoBitsPerSecond: 1.5e6,
});
const timeslice = 5000;
const fileBits: Blob[] = [];
// 當(dāng)數(shù)據(jù)可用時(shí),會(huì)回調(diào)該函數(shù),有以下四種情況:
// 1. 手動(dòng)停止MediaRecorder時(shí)
// 2. 設(shè)置了timeslice,每到一次timeslice時(shí)間間隔時(shí)
// 3. 媒體流內(nèi)所有軌道均變成非活躍狀態(tài)時(shí)
// 4. 調(diào)用recorder.requestData()轉(zhuǎn)移緩沖區(qū)數(shù)據(jù)時(shí)
recorder.ondataavailable = (event: BlobEvent) => {
fileBits.push(event.data as Blob);
}
recorder.onstop = () => {
// 錄屏停止并獲取錄屏文件
// 觸發(fā)時(shí)機(jī)一定在ondataavailable之后
const videoFile = new Blob(fileBits, { type: 'video/webm;codecs=vp9' });
}
if (timeslice === 0) {
// 開始錄制,并一直存儲(chǔ)數(shù)據(jù)到緩沖區(qū),直到停止
recorder.start();
} else {
// 開始錄制,并且每timeslice毫秒,觸發(fā)一次ondataavailable,輸出并清空緩沖區(qū)(非常重要)
recorder.start(timeslice);
}
setTimeout(() => {
// 30秒后停止
recorder.stop();
}, 30000);
暫停/恢復(fù)錄制
// 暫停錄制
recorder.pause();
// 恢復(fù)錄制
recorder.resume();
完成以上 API 的調(diào)用,我們“錄屏功能 MVP”版本就算跑通了。
錄制產(chǎn)物的處理
正如前面技術(shù)方案探索內(nèi)容中提到的,直接使用瀏覽器實(shí)現(xiàn)的這套方法,會(huì)有一些坑,盡管如此,本文的核心其實(shí)就是這部分,也就是解決錄屏帶來的那些坑。
鎖屏觸發(fā)視頻流停止問題
實(shí)驗(yàn)發(fā)現(xiàn),通過 navigator.getUserMedia 獲取的視頻流,在鎖屏情況(是的 macOS、Windows 全部操作系統(tǒng)都會(huì))會(huì)中斷,我們可以通過一下代碼測試該現(xiàn)象:
import { remote } from 'electron';
// 視頻流獲取
const videoSource: MediaStream = await navigator.mediaDevices.getUserMedia({
audio: false, // 強(qiáng)行表示不錄制音頻,音頻額外獲取
video: {
mandatory: {
chromeMediaSource: 'desktop',
chromeMediaSourceId: remote.getCurrentWindow().getMediaSourceId()
}
}
});
recorder.ondataavailable = () => console.log('數(shù)據(jù)可用');
recorder.onstop = () => console.log('錄屏停止');
const recorder = new MediaRecorder(videoSource, {
mimeType: 'video/webm;codecs=vp9',
// 支持手動(dòng)設(shè)置碼率,這里設(shè)了1.5Mbps的碼率,以限制碼率較大的情況
// 由于本身還是動(dòng)態(tài)碼率,這個(gè)值并不準(zhǔn)確
videoBitsPerSecond: 1.5e6,
});
// 開始錄制,等10秒,手動(dòng)觸發(fā)鎖屏
recorder.start();
setInterval(() => {
console.log('軌道活躍:', videoSource.active);
}, 1000);
10秒后控制臺(tái)輸出:
軌道活躍: true
軌道活躍: true
軌道活躍: true
軌道活躍: true
軌道活躍: true
軌道活躍: true
軌道活躍: true
軌道活躍: true
軌道活躍: true
數(shù)據(jù)可用
錄屏停止
軌道活躍: false
...
以上實(shí)驗(yàn)說明鎖屏?xí)|發(fā)視頻流狀態(tài)由“活躍”轉(zhuǎn)為“不活躍”,該問題最大的坑點(diǎn)在于解鎖后“狀態(tài)并不會(huì)自動(dòng)恢復(fù)為活躍”,必須開發(fā)者手動(dòng)重新調(diào)用 navigator.mediaDevices getUserMedia 獲取視頻流。那么如何知道用戶是否鎖屏呢?這里我探索出來一種方法:
// 啟動(dòng)MediaRecorder的時(shí)候,如果拋錯(cuò),此時(shí)重新獲取視頻流
try {
this.recorder.start(5000);
} catch (e) {
this._combinedSource = await this.getSystemVideoMediaStream()
this.recorder = new MediaRecorder(this._combinedSource, {
mimeType: VIDEO_RECORD_FORMAT,
videoBitsPerSecond: 1.5e6,
});
this.recorder.start(5000);
}
第二個(gè)坑點(diǎn)在于,以上僅針對(duì)純視頻流場景錄屏,如果同時(shí)錄制音頻流+視頻流,那么**“由于音頻流鎖屏?xí)r的狀態(tài)始終保持活躍”,而“僅視頻流鎖屏?xí)r會(huì)觸發(fā)狀態(tài)變?yōu)椴换钴S”**,由于并非全部軌道都變?yōu)椴换钴S,這里“MediaRecorder 并不會(huì)觸發(fā) ondataavailable 和 onstop,錄屏將會(huì)仍然繼續(xù)進(jìn)行,但錄出來的視頻是黑屏”,成為這個(gè)問題的一大槽點(diǎn)與大坑。那么如何解決音視頻流鎖屏?xí)r并不觸發(fā) ondataavailable 和 onstop 的問題呢?這里有一種我探索的方法:
// 如果視頻流不活躍,停止音頻流
// 如果音頻流不活躍,停止視頻流(雖然不會(huì)發(fā)生,只是兜底)
const startStreamActivityChecker = () =>
window.setInterval(() => {
if (this._videoSource?.active === false) {
this._audioSource?.getTracks().forEach(track => track.stop());
}
if (this._audioSource?.active === false) {
this._videoSource?.getTracks().forEach(track => track.stop());
}
}, 1000);
}
缺少視頻時(shí)長與時(shí)間線不可拖拽問題
Issue1: MediaRecorder output should have Cues element -https://bugs.chromium.org/p/chromium/issues/detail?id=561606
Issue2: Videos created with MediaRecorder API are not seekable / scrubbable -https://bugs.chromium.org/p/chromium/issues/detail?id=569840
Issue3: No duration or seeking cue for opus audio produced with mediarecoder -https://bugs.chromium.org/p/chromium/issues/detail?id=599134
Issue4: MediaRecorder: consider producing seekable WebM files -https://bugs.chromium.org/p/chromium/issues/detail?id=642012
私以為這兩個(gè)問題,算是 MediaRecorder api 設(shè)計(jì)的最大失誤了。由于 webm 文件的視頻時(shí)長和拖拽信息是寫在文件頭部的,因此在 WebM 錄制未完成前,頭部的"Duration"永遠(yuǎn)是不斷增加的一個(gè)未知值。但由于 MediaRecorder 支持分片定時(shí)輸出小 Blob 文件,導(dǎo)致第一個(gè) Blob 的頭部是不可能包含 Duration 字段的,同樣搜索頭信息"SeekHead", "Seek", "SeekID", "SeekPosition", "Cues", "CueTime", "CueTrack", "CueClusterPosition", "CueTrackPositions", "CuePoint" 同樣缺失。但 Blob 在設(shè)計(jì)之初又是不可變的文件類型,導(dǎo)致最終錄制出的文件沒有 Duration 視頻時(shí)長字段,這個(gè)問題已經(jīng)被 Chromium 官方標(biāo)識(shí)為“wont fix”,并推薦開發(fā)者自行找社區(qū)解決。
使用 ffmpeg 修復(fù)
社區(qū)內(nèi)的一種方案是使用 ffmpeg 對(duì)文件進(jìn)行“拷貝”并輸出,例如輸入下面的命令:
ffmpeg -i without_meta.webm -vcodec copy -acodec copy with_meta.webm
ffmpeg 會(huì)自動(dòng)計(jì)算 Duration 與搜索頭信息,這種方案最大的問題在于,如果對(duì)客戶端集成 ffmpeg,需要直接操作文件且編寫跨平臺(tái)方案,將文件暴露于本地。如果做在服務(wù)端,又會(huì)增加文件的整體處理流程與時(shí)間,雖然不是不可以,但是這不是我們追求的極致方案。
使用 npm 庫 fix-webm-duration 修復(fù)
這是社區(qū)內(nèi)的另一種方案,即解析 webm 文件的頭部信息,并在前端手工記錄視頻時(shí)長,在解析好之后手動(dòng)將記錄好的 Duration 寫入 webm 頭部,但該方案同樣不能解決搜索頭丟失導(dǎo)致的可拖拽信息,且依賴手工記錄的 duration,修復(fù)內(nèi)容比較有限。
基于 ts-ebml,利用 fix-webm-metainfo 修復(fù)
這是本問題的最終解,即完全解析 webm ebml 和 segment 頭,根據(jù)實(shí)際 simple block 的大小計(jì)算 Duration 與搜索頭。我們利用 ebml 解析 webm,以 MediaRecorder 直出的 webm 文件為例解析,結(jié)構(gòu)如下:
m 0 EBML
u 1 EBMLVersion 1
u 1 EBMLReadVersion 1
u 1 EBMLMaxIDLength 4
u 1 EBMLMaxSizeLength 8
s 1 DocType webm
u 1 DocTypeVersion 4
u 1 DocTypeReadVersion 2
m 0 Segment
m 1 Info segmentContentStartPos, all CueClusterPositions provided in info.cues will be relative to here and will need adjusted
u 2 TimecodeScale 1000000
8 2 MuxingApp Chrome
8 2 WritingApp Chrome
m 1 Tracks tracksStartPos
m 2 TrackEntry
u 3 TrackNumber 1
u 3 TrackUID 31790271978391090
u 3 TrackType 2
s 3 CodecID A_OPUS
b 3 CodecPrivate <Buffer 19>
m 3 Audio
f 4 SamplingFrequency 48000
u 4 Channels 1
m 2 TrackEntry
u 3 TrackNumber 2
u 3 TrackUID 24051277436254136
u 3 TrackType 1
s 3 CodecID V_VP9
m 3 Video
u 4 PixelWidth 1200
u 4 PixelHeight 900
m 1 Cluster clusterStartPos
u 2 Timecode 0
b 2 SimpleBlock track:2 timecode:0 keyframe:true invisible:false discardable:false lacing:1
而根據(jù) webm 官網(wǎng)描述(鏈接[3]),一個(gè)正常的 webm 的頭信息,應(yīng)該解析如下:
m 0 EBML
u 1 EBMLVersion 1
u 1 EBMLReadVersion 1
u 1 EBMLMaxIDLength 4
u 1 EBMLMaxSizeLength 8
s 1 DocType webm
u 1 DocTypeVersion 4
u 1 DocTypeReadVersion 2
m 0 Segment
// 這部分缺失
m 1 SeekHead -> This is SeekPosition 0, so all SeekPositions can be calculated as (bytePos - segmentContentStartPos), which is 44 in this case
m 2 Seek
b 3 SeekID -> Buffer([0x15, 0x49, 0xA9, 0x66]) Info
u 3 SeekPosition -> infoStartPos =
m 2 Seek
b 3 SeekID -> Buffer([0x16, 0x54, 0xAE, 0x6B]) Tracks
u 3 SeekPosition { tracksStartPos }
m 2 Seek
b 3 SeekID -> Buffer([0x1C, 0x53, 0xBB, 0x6B]) Cues
u 3 SeekPosition { cuesStartPos }
m 1 Info
// 這部分缺失
f 2 Duration 32480 -> overwrite, or insert if it doesn't exist
u 2 TimecodeScale 1000000
8 2 MuxingApp Chrome
8 2 WritingApp Chrome
m 1 Tracks
m 2 TrackEntry
u 3 TrackNumber 1
u 3 TrackUID 31790271978391090
u 3 TrackType 2
s 3 CodecID A_OPUS
b 3 CodecPrivate <Buffer 19>
m 3 Audio
f 4 SamplingFrequency 48000
u 4 Channels 1
m 2 TrackEntry
u 3 TrackNumber 2
u 3 TrackUID 24051277436254136
u 3 TrackType 1
s 3 CodecID V_VP9
m 3 Video
u 4 PixelWidth 1200
u 4 PixelHeight 900
// 這部分缺失
m 1 Cues -> cuesStartPos
m 2 CuePoint
u 3 CueTime 0
m 3 CueTrackPositions
u 4 CueTrack 1
u 4 CueClusterPosition 3911
m 2 CuePoint
u 3 CueTime 600
m 3 CueTrackPositions
u 4 CueTrack 1
u 4 CueClusterPosition 3911
m 1 Cluster
u 2 Timecode 0
b 2 SimpleBlock track:2 timecode:0 keyframe:true invisible:false discardable:false lacing:1
可以看到,我們只要修復(fù)好缺失的 Duration、SeakHead、Cues,就可以解決我們的問題,整體流程如下:
ts-ebml 是一個(gè)社區(qū)開源的庫,該庫在 ebml 的 Decoder、Reader 實(shí)現(xiàn)的 ArrayBuffer 到可讀 EBML 的相互轉(zhuǎn)換能力的基礎(chǔ)上,添加了 Webm 修復(fù)功能,但不支持大于 2GB 的視頻文件,根本原因在于直接對(duì) Blob 轉(zhuǎn)換為 ArrayBuffer 是有問題的,ArrayBuffer 的最大長度僅為 2046 * 1024 * 1024, 為此早期我發(fā)布了一個(gè)叫做 fix-webm-metainfo 的 npm 包,利用 Buffer 的 slice 方法,使用 Buffer[]代替 Buffer 解決了該問題。
import { tools, Reader } from 'ts-ebml';
import LargeFileDecorder from './decoder';
// fix-webm-metainfo 早期的實(shí)現(xiàn)過程
async function fixWebmMetaInfo(blob: Blob): Promise<Blob> {
// 解決ts-ebml不支持大于2GB視頻文件的問題
const decoder = new LargeFileDecorder();
const reader = new Reader();
reader.logging = false;
const bufSlices: ArrayBuffer[] = [];
// 由于Uint8Array或者ArrayBuffer支持的最大長度為2046 * 1024 * 1024
const sliceLength = 1 * 1024 * 1024 * 1024;
for (let i = 0; i < blob.size; i = i + sliceLength) {
// 切割Blob,并讀取ArrayBuffer
const bufSlice = await blob.slice(i, Math.min(i + sliceLength, blob.size)).arrayBuffer();
bufSlices.push(bufSlice);
}
// 解析ArrayBuffer到可閱讀與修改的EBML Element類型,并使用reader讀取以計(jì)算Duration和Cues
decoder.decode(bufSlices).forEach(elm => reader.read(elm));
// 當(dāng)全部讀取結(jié)束后,結(jié)束reader
reader.stop();
// 利用reader生成好的cues與duration,重建meta頭,并轉(zhuǎn)換回arrayBuffer
const refinedMetadataBuf = tools.makeMetadataSeekable(reader.metadatas, reader.duration, reader.cues);
const firstPartSlice = bufSlices.shift() as ArrayBuffer;
const firstPartSliceWithoutMetadata = firstPartSlice.slice(reader.metadataSize);
// 重建回Blob
return new Blob([refinedMetadataBuf, firstPartSliceWithoutMetadata, ...bufSlices], { type: blob.type });
}
進(jìn)程卡死與緩存未復(fù)用問題
隨著視頻長度的增加,fix-webm-metainfo 盡管解決了大尺寸長視頻的修復(fù)問題,但面對(duì)大文件在短時(shí)間的全量讀取與計(jì)算,存在短時(shí)間卡死渲染進(jìn)程的問題。
Web Worker 處理
Web Worker 天生適合該場景的處理,利用 Web Worker,我們可以在不額外創(chuàng)建進(jìn)程的同時(shí),額外創(chuàng)建一個(gè) Worker 線程,專門進(jìn)行大視頻文件的處理與解析,同時(shí)不會(huì)卡死主線程,此外由于 Web Worker 支持以引用的方式(Transferable Object)傳遞 ArrayBuffer,因此也成了本問題最佳解決方法。首先在 Electron 的 BrowserWindow 中開啟 nodeIntegrationInWorker:true
webPreferences: {
...
nodeIntegration: true,
nodeIntegrationInWorker: true,
},
接著編寫 Worker 進(jìn)程:
import { tools, Reader } from 'ts-ebml';
import LargeFileDecorder from './decoder';
// index.worker.ts
export interface IWorkerPostData {
type: 'transfer' | 'close';
data?: ArrayBuffer;
}
export interface IWorkerEchoData {
buffer: ArrayBuffer;
size: number;
duration: number;
}
const bufSlices: ArrayBuffer[] = [];
async function fixWebm(): Promise<void> {
const decoder = new LargeFileDecorder();
const reader = new Reader();
reader.logging = false;
decoder.decode(bufSlices).forEach(elm => reader.read(elm));
reader.stop();
const refinedMetadataBuf = tools.makeMetadataSeekable(reader.metadatas, reader.duration, reader.cues);
// 將計(jì)算后的結(jié)果傳回父線程
self.postMessage({
buffer: refinedMetadataBuf,
size: reader.metadataSize,
duration: reader.duration
} as IWorkerEchoData, [refinedMetadataBuf]);
}
self.addEventListener('message', (e: MessageEvent<IWorkerPostData>) => {
switch (e.data.type) {
case 'transfer':
// 保存?zhèn)鬟f過來的ArrayBuffer
bufSlices.push(e.data.data);
break;
case 'close':
// 修復(fù)WebM,之后關(guān)閉Worker進(jìn)程
fixWebm().catch(self.postMessage).finally(() => self.close());
break;
default:
break;
}
});
父進(jìn)程:
import FixWebmWorker from './worker/index.worker';
import type { IWorkerPostData, IWorkerEchoData } from './worker/index.worker';
async function fixWebmMetaInfo(blob: Blob): Promise<Blob> {
// 創(chuàng)建Worker進(jìn)程
const fixWebmWorker: Worker = new FixWebmWorker();
return new Promise(async (resolve, reject) => {
fixWebmWorker.addEventListener('message', (event: MessageEvent<IWorkerEchoData>) => {
if (Object.getPrototypeOf(event.data)?.name === 'Error') {
return reject(event.data);
}
let refinedMetadataBlob = new Blob([event.data.buffer], { type: blob.type });
// 手動(dòng)關(guān)閉Worker進(jìn)程
fixWebmWorker.terminate();
let body: Blob;
let firstPartBlobSlice = blobSlices.shift();
body = firstPartBlobSlice.slice(event.data.size);
firstPartBlobSlice = null;
// 注:除了利用Web Worker,與早期方案相比,并對(duì)meta ArrayBuffer生成Blob
// 不再用ArrayBuffer重建,而是復(fù)用之前的Blob
// 這一步做了之后會(huì)大量減少一次文件寫入,并可解決引用不釋放導(dǎo)致的內(nèi)存泄露問題
// 是本文最關(guān)鍵的決定性一步
let blobFinal = new Blob([refinedMetadataBlob, body, ...blobSlices], { type: blob.type });
refinedMetadataBlob = null;
body = null;
blobSlices = [];
resolve(blobFinal);
blobFinal = null;
});
fixWebmWorker.addEventListener('error', (event: ErrorEvent) => {
blobSlices = [];
reject(event);
});
let blobSlices: Blob[] = [];
let slice: Blob;
const sliceLength = 1 * 1024 * 1024 * 1024;
try {
for (let i = 0; i < blob.size; i = i + sliceLength) {
slice = blob.slice(i, Math.min(i + sliceLength, blob.size));
// 切片讀取ArrayBuffer
const bufSlice = await slice.arrayBuffer();
// 發(fā)送給Worker進(jìn)程,并利用 Transferable Objects 提高性能
fixWebmWorker.postMessage({
type: 'transfer',
data: bufSlice
} as IWorkerPostData, [bufSlice]);
blobSlices.push(slice);
slice = null;
}
// 結(jié)束處理
fixWebmWorker.postMessage({
type: 'close',
});
} catch (e) {
blobSlices = [];
slice = null;
reject(new Error(`[fix webm] read buffer failed: ${e?.message || e}`));
}
});
}
通過對(duì)早期 fix-webm-metainfo 的修復(fù)過程中 blob_storage 暫存目錄的分頁文件進(jìn)行觀察,我們察覺到了明顯的內(nèi)存不釋放以及文件重復(fù)生成的問題,在去除 fix-webm 邏輯后,該問題不再復(fù)現(xiàn),這就說明目前的 fix-webm-metainfo 存在文件緩存未復(fù)用和文件引用未刪除的問題(這個(gè)問題后面討論)。
文件緩存復(fù)用
那么在 ArrayBuffer 與 Blob 的轉(zhuǎn)換中,是否有一種無損,且可復(fù)用文件緩存的方式呢?這就是為什么 fix-webm-metainfo 在后面的迭代中,采用了復(fù)用 Blob 的方式建立修復(fù)后的 Blob,而不是直接使用 ArrayBuffer 建立 Blob 的原因。觀察下面的兩種方式生成的 Blob 有什么區(qū)別:
// 首先創(chuàng)建一個(gè)Blob
const a = new Blob([new ArrayBuffer(10000000)]);
// 讀出它的buffer
const buffer = await a.arrayBuffer();
// 方式1,實(shí)際會(huì)占用多少內(nèi)存?
const b = new Blob([buffer]);
const c = new Blob([buffer]);
const d = new Blob([buffer]);
const e = new Blob([buffer]);
const f = new Blob([buffer]);
const g = new Blob([buffer]);
const h = new Blob([buffer]);
// 方式2,那這種呢?
const i = new Blob([a]);
const j = new Blob([a]);
const k = new Blob([a]);
const l = new Blob([a]);
const m = new Blob([a]);
const n = new Blob([a]);
const o = new Blob([a]);
猜猜答案是什么?是的,Blob 存在復(fù)用本地文件緩存的機(jī)制,方式 1 會(huì)在內(nèi)存或磁盤生成 7 份一模一樣的文件,而方式 2 不會(huì)額外生成一個(gè)文件,i 到 o 的文件均復(fù)用了 a 的 blob,在內(nèi)存或磁盤中只存在一份。那么,修復(fù) webm 的那種方式本質(zhì)上修改了文件頭部的字節(jié),那這種方式也會(huì)復(fù)用同一個(gè)本地文件緩存么?答案是肯定的,被修復(fù)前的 webm 和被修復(fù)后的 webm 由于差異僅在頭部,而整體的大部分區(qū)域均采用相同的 Blob slice 出來的子 blob 建立,因此空間依然是復(fù)用的。
主進(jìn)程內(nèi)存泄露問題
根據(jù) Electron 官方提供的 process.getProcessMemoryInfo() api,我們分別對(duì)主進(jìn)程和渲染進(jìn)程實(shí)現(xiàn)了內(nèi)存監(jiān)控,通過監(jiān)控發(fā)現(xiàn)使用錄屏的用戶的主進(jìn)程內(nèi)存占用經(jīng)常可以達(dá)到 2GB,而不使用錄屏功能的用戶,主進(jìn)程內(nèi)存占用僅 80MB,這說明百分百存在內(nèi)存泄露。在談及主進(jìn)程內(nèi)存泄漏問題之前,不得不提及 Blob 文件類型的實(shí)現(xiàn)方式。根據(jù) Chromium Blob 實(shí)現(xiàn)官方說明(PPT[4])如下圖,我們在 Renderer 進(jìn)程通過任何一種方式創(chuàng)建的 Blob,本質(zhì)上最終都會(huì)有一個(gè)跨進(jìn)程傳輸?shù)?Browser 進(jìn)程的過程(即主進(jìn)程),也就是說盡管 MediaRecorder 是基于渲染進(jìn)程的錄制,但在將緩沖區(qū)文件輸出為 Blob 的過程(即 ondataavailable 觸發(fā)瞬間),會(huì)存在跨進(jìn)程傳輸。
以上說明了在“渲染進(jìn)程”錄制,而“主進(jìn)程”內(nèi)存占用不斷增大的根本原因,那么再具體點(diǎn),Blob 到底是怎么傳輸?shù)模繐Q句話說,我們僅知道創(chuàng)建 Blob 時(shí),二進(jìn)制數(shù)據(jù)會(huì)跨進(jìn)程傳輸?shù)街鬟M(jìn)程是不夠的。如果文件足夠大,主進(jìn)程內(nèi)存不足會(huì)怎樣?Chromium 又是如何管理并存儲(chǔ) Blob 內(nèi)包含的二進(jìn)制文件呢?
Blob 的傳輸方式
這里我們通過閱讀 Chromium 的 Blob Controller(Code[5])并添加 LOG(INFO)觀察
// 作用:判斷傳輸策略
// storage/browser/blob/blob_memory_controller.cc
BlobMemoryController::Strategy BlobMemoryController::DetermineStrategy(
size_t preemptive_transported_bytes,
uint64_t total_transportation_bytes) const {
// Blob文件大小為0,不需要傳輸
if (total_transportation_bytes == 0)
return Strategy::NONE_NEEDED;
// 當(dāng)Blob文件大小大于可用內(nèi)存數(shù),且大于可用磁盤空間時(shí),傳輸直接失敗
if (!CanReserveQuota(total_transportation_bytes))
return Strategy::TOO_LARGE;
// 普通調(diào)用可忽略
if (preemptive_transported_bytes == total_transportation_bytes &&
pending_memory_quota_tasks_.empty() &&
preemptive_transported_bytes <= GetAvailableMemoryForBlobs()) {
return Strategy::NONE_NEEDED;
}
// Chromium編譯時(shí)開啟文件分頁(默認(rèn)開啟),且配置了override_file_transport_min_size時(shí)
if (UNLIKELY(limits_.override_file_transport_min_size > 0) &&
file_paging_enabled_ &&
total_transportation_bytes >= limits_.override_file_transport_min_size) {
return Strategy::FILE;
}
// Blob小于0.25MB時(shí),直接走ipc傳輸
if (total_transportation_bytes <= limits_.max_ipc_memory_size)
return Strategy::IPC;
// Chromium編譯時(shí)開啟文件分頁(默認(rèn)開啟)
// Blob文件大小小于可用磁盤空間
// Blob文件大小大于可用內(nèi)存空間
if (file_paging_enabled_ &&
total_transportation_bytes <= GetAvailableFileSpaceForBlobs() &&
total_transportation_bytes > limits_.memory_limit_before_paging()) {
return Strategy::FILE;
}
// 默認(rèn)傳輸策略,即內(nèi)存共享方式,通過渲染進(jìn)程傳遞給主進(jìn)程
return Strategy::SHARED_MEMORY;
}
bool BlobMemoryController::CanReserveQuota(uint64_t size) const {
// 同時(shí)檢查內(nèi)“可用內(nèi)存空間”與“可用磁盤空間”
return size <= GetAvailableMemoryForBlobs() ||
size <= GetAvailableFileSpaceForBlobs();
}
// 如果當(dāng)前內(nèi)存使用量小于2GB(按x64電腦算,max_blob_in_memory_space = 2 * 1024 * 1024 * 1024)
// 計(jì)算剩余內(nèi)存量
size_t BlobMemoryController::GetAvailableMemoryForBlobs() const {
if (limits_.max_blob_in_memory_space < memory_usage())
return 0;
return limits_.max_blob_in_memory_space - memory_usage();
}
// 計(jì)算剩余磁盤量
uint64_t BlobMemoryController::GetAvailableFileSpaceForBlobs() const {
if (!file_paging_enabled_)
return 0;
uint64_t total_disk_used = disk_used_;
if (in_flight_memory_used_ < pending_memory_quota_total_size_) {
total_disk_used +=
pending_memory_quota_total_size_ - in_flight_memory_used_;
}
if (limits_.effective_max_disk_space < total_disk_used)
return 0;
// 實(shí)際最大磁盤空間 - 已用磁盤空間
return limits_.effective_max_disk_space - total_disk_used;
}
可發(fā)現(xiàn):Blob 的傳輸與儲(chǔ)存基本分為三種,即:“文件”,“共享內(nèi)存”,以及“IPC”,
當(dāng)文件小于 0.25MB 時(shí)優(yōu)先走“IPC”方式傳輸 當(dāng)“可用內(nèi)存空間”大于文件體積時(shí)優(yōu)先走“共享內(nèi)存”方式傳輸 當(dāng)“可用內(nèi)存空間”不足但“可用磁盤空間”充足時(shí),優(yōu)先走“文件”方式傳輸 當(dāng)“可用內(nèi)存空間”與“可用磁盤空間”均不充足時(shí),Blob 不會(huì)傳輸,且最終反饋到渲染進(jìn)程,會(huì)報(bào)“File not readble”之類的報(bào)錯(cuò)。
最大存儲(chǔ)限制
這里引發(fā)一個(gè)問題“可用內(nèi)存空間”與“可用磁盤空間”是如何界定的?如果計(jì)算?想到這里,又引發(fā)我的思考,如果可用內(nèi)存空間非常大,會(huì)造成什么問題?帶著這些疑問,我們繼續(xù)研究 Chromium 的實(shí)現(xiàn):
BlobStorageLimits CalculateBlobStorageLimitsImpl(
const FilePath& storage_dir,
bool disk_enabled,
base::Optional<int64_t> optional_memory_size_for_testing) {
int64_t disk_size = 0ull;
int64_t memory_size = optional_memory_size_for_testing
? optional_memory_size_for_testing.value()
: base::SysInfo::AmountOfPhysicalMemory();
if (disk_enabled && CreateBlobDirectory(storage_dir) == base::File::FILE_OK)
disk_size = base::SysInfo::AmountOfTotalDiskSpace(storage_dir);
BlobStorageLimits limits;
if (memory_size > 0) {
#if !defined(OS_CHROMEOS) && !defined(OS_ANDROID) && !defined(OS_ANDROID) && defined(ARCH_CPU_64_BITS)
// 不是ChromeOS,不是安卓,且架構(gòu)是64位,則“最大可用內(nèi)存大小”為2GB
constexpr size_t kTwoGigabytes = 2ull * 1024 * 1024 * 1024;
limits.max_blob_in_memory_space = kTwoGigabytes;
#elif defined(OS_ANDROID)
// 安卓,“最大可用內(nèi)存”為物理內(nèi)存的1/100
limits.max_blob_in_memory_space = static_cast<size_t>(memory_size / 100ll);
#else
// 其他架構(gòu)或,“最大可用內(nèi)存”為物理內(nèi)存的1/5
limits.max_blob_in_memory_space = static_cast<size_t>(memory_size / 5ll);
#endif
}
// 實(shí)現(xiàn)了一下“最大可用內(nèi)存”的最小值不小于兩倍的“最小分頁大小”
if (limits.max_blob_in_memory_space < limits.min_page_file_size)
limits.max_blob_in_memory_space = limits.min_page_file_size;
if (disk_size >= 0) {
#if defined(OS_CHROMEOS)
// ChromeOS,“最大可用磁盤大小”為物理磁盤大小的1/2
limits.desired_max_disk_space = static_cast<uint64_t>(disk_size / 2ll);
#elif defined(OS_ANDROID)
// Android,“最大可用磁盤大小”為物理磁盤大小3/50
limits.desired_max_disk_space = static_cast<uint64_t>(3ll * disk_size / 50);
#else
// 其他平臺(tái)或架構(gòu),“最大可用磁盤大小”為物理磁盤大小1/10
limits.desired_max_disk_space = static_cast<uint64_t>(disk_size / 10);
#endif
}
if (disk_enabled) {
UMA_HISTOGRAM_COUNTS_1M("Storage.Blob.MaxDiskSpace2",
limits.desired_max_disk_space / kMegabyte);
}
limits.effective_max_disk_space = limits.desired_max_disk_space;
CHECK(limits.IsValid());
return limits;
}
總結(jié)一下兩個(gè)指標(biāo),與 OS、Arch、Memory Size、Disk Size 都有可能有關(guān)系:
最大可用內(nèi)存大小
架構(gòu)是 x64 且平臺(tái)不是 Chrome OS 或 Android:
2GB
平臺(tái)是 Android:
所在設(shè)備物理內(nèi)存大小/ 100其他平臺(tái)或架構(gòu)(例如 macOS arm64,chromeOS):
所在設(shè)備物理內(nèi)存大小 / 5
最大可用磁盤大小
平臺(tái)是 Chrome OS:
所在設(shè)備,軟件所在分區(qū)的邏輯磁盤的大小 / 2
平臺(tái)是安卓:
所在設(shè)備,軟件所在分區(qū)的邏輯磁盤的大小 * 3/50其他平臺(tái)或架構(gòu):
所在設(shè)備,軟件所在分區(qū)的邏輯磁盤的大小 / 10
以上結(jié)論說明了什么?我們從中發(fā)現(xiàn)了兩個(gè)問題:
問題 1:X64 架構(gòu)的最大可用內(nèi)存是 2GB,這實(shí)際上非常大了,用戶的錄屏存儲(chǔ)并非頻繁訪問的內(nèi)容,用戶的電腦可能只有 8GB,如果這 2GB 平白被占據(jù)實(shí)際上是很大一個(gè)浪費(fèi)。 問題 2:X64 與非 X64 架構(gòu)的最大可用內(nèi)存并不一致。 問題 3:最大可用磁盤大小僅為物理硬盤大小的 1/10, 以 128GB 的 SSD 硬盤為例,即使將全部 128GB 均分配給 C 盤,那么最大可用磁盤大小僅為 12.8GB,不考慮其他任何 Blob 的磁盤占用,即使用戶 C 盤有 100GB 的剩余空間,依然逃不了錄屏文件體積被限制到 12.8GB 的尷尬。
事實(shí)真相大白,主進(jìn)程并非“內(nèi)存泄露”而是“設(shè)計(jì)如此”。
修改 Chromium
那么我們?nèi)绻麑⒆畲髢?nèi)存空間改小,將最大可用磁盤空間改大,是不是即可解決主進(jìn)程內(nèi)存占用問題,又解決了錄屏文件體積限制兩個(gè)問題呢?答案是肯定的,修改起來也很簡單:
// 如果物理內(nèi)存數(shù)大于0
if (memory_size > 0) {
#if !defined(OS_CHROMEOS) && !defined(OS_ANDROID)
// 去除64位判斷邏輯,保持32位 Windows,Arm64 Mac一致的2000MB -> 200MB最大內(nèi)存錄制空間邏輯修改
constexpr size_t kTwoHundrendMegabytes = 2ull * 100 * 1024 * 1024;
limits.max_blob_in_memory_space = kTwoHundrendMegabytes;
#elif defined(OS_ANDROID)
limits.max_blob_in_memory_space = static_cast<size_t>(memory_size / 100ll);
#else
limits.max_blob_in_memory_space = static_cast<size_t>(memory_size / 5ll);
#endif
}
if (limits.max_blob_in_memory_space < limits.min_page_file_size)
limits.max_blob_in_memory_space = limits.min_page_file_size;
if (disk_size >= 0) {
#if defined(OS_CHROMEOS)
limits.desired_max_disk_space = static_cast<uint64_t>(disk_size / 2ll);
#elif defined(OS_ANDROID)
limits.desired_max_disk_space = static_cast<uint64_t>(3ll * disk_size / 50);
#else
// 去除錄屏Blob_Storage的大小限制, 最大空間由完整磁盤空間的1/10 變?yōu)?nbsp;1
limits.desired_max_disk_space = static_cast<uint64_t>(disk_size);
#endif
}
如果你有類似的需要,可以直接復(fù)用該修改,且無任何副作用。
緩沖區(qū)內(nèi)存釋放問題
有了上述對(duì) Blob 文件格式的理解,我們基本可以理清錄屏功能的整個(gè)傳輸鏈路。緩沖區(qū)內(nèi)存釋放問題的解法,相信大家也能想到了,在錄制過程中,未對(duì) MediaRecorder stop 前,由于 MediaRecorder 錄制的全部數(shù)據(jù)均存儲(chǔ)于 Renderer 進(jìn)程中,便會(huì)造成內(nèi)存的異常占用,隨著錄屏?xí)r間的增長,這部分的占用會(huì)尤為龐大,解決方法也很簡單,設(shè)定一個(gè) timeslice 或定時(shí) requestData()即可
const recorder = new MediaRecorder(combinedSource, {
mimeType: 'video/webm;codecs=vp9',
videoBitsPerSecond: 1.5e6,
});
const timeslice = 5000;
const fileBits: Blob[] = [];
recorder.ondataavailable = (event: BlobEvent) => {
fileBits.push(event.data as Blob);
}
recorder.onstop = () => {
const videoFile = new Blob(fileBits, { type: 'video/webm;codecs=vp9' });
}
// 解法一,開始錄制時(shí),設(shè)定timeSlice,確保每timeslice毫秒,自動(dòng)觸發(fā)一次ondataavailable,輸出并清空緩沖區(qū)(非常重要)
recorder.start(timeslice);
// 解法二,錄制過程中手動(dòng)requestData清空緩沖區(qū)
recorder.start();
setInterval(() => recorder.requestData(), timeslice);
渲染進(jìn)程內(nèi)存泄露問題
在編寫過程中,由于一些疏忽,我們可能會(huì)寫出具有內(nèi)存泄露的代碼,那么如何解決該問題?結(jié)論是,時(shí)刻遵循以下原則:
一切對(duì)Blob的引用都及時(shí)清除盡量用let 指向Blob并手動(dòng)釋放,防止引用不釋放的情況發(fā)生
// 例1
const a = new Map();
a.set('key', {
blob: new Blob([1]) // Blob1
});
// 手動(dòng)釋放
a.get('key').blob = null;
// 例2
let a = new Blob([]);
doSomething(a);
// 手動(dòng)釋放
a = null;
Blob-Internals 觀察引用
若想隨時(shí) Debug,可以通過觀察 Blob 的引用計(jì)數(shù)的方式,直接訪問 chrome://blob-internals/
以上圖為例,每一個(gè) Blob 均有一個(gè)獨(dú)一無二的 UUID,通過觀察某 UUID 的 Blob 的引用計(jì)數(shù),我們可以相對(duì)較輕松的 Debug Blob 的泄露情況。
Profiler 抓取堆快照
也可以利用 Profiler 抓取內(nèi)存堆棧情況。
blob_storage 目錄觀察
如果你有對(duì) Chromium 修改的能力,可以通過將“最大可用內(nèi)存”改為較小值(比如 10MB,以此迫使 Blob 直接走文件傳輸方式存儲(chǔ)到硬盤),直接觀測 blob_storage 目錄內(nèi)分頁文件的產(chǎn)生。Blob 文件在本地磁盤是以分頁的形式存儲(chǔ),它的大小是一個(gè)動(dòng)態(tài)值,最小為 5MB,最大為 100MB。每次關(guān)閉應(yīng)用時(shí)該目錄都會(huì)被清空,因此需要確保應(yīng)用開啟并持續(xù)觀測,這種方式是目前最為直觀易用的方式,一般來說如果用戶持續(xù)不關(guān)閉應(yīng)用,而你的代碼又存在內(nèi)存泄露,那么基本可以觀察到該目錄會(huì)產(chǎn)生大量的分頁文件而不被釋放。
后續(xù)的性能優(yōu)化
當(dāng)前的處理,盡管已經(jīng)完美解決了一切修復(fù)問題,但存在最后一個(gè)問題,就是修復(fù)時(shí)會(huì)占用大量內(nèi)存,后續(xù)我會(huì)持續(xù)維護(hù) fix-webm-metainfo 庫,通過不傳輸完整 ArrayBuffer 的方式,解決這個(gè)問題。
招人啦招人啦!
我們是字節(jié)跳動(dòng)內(nèi)容安全前端團(tuán)隊(duì)
業(yè)務(wù)方向:基于人工智能的全球內(nèi)容安全、圖像數(shù)據(jù)標(biāo)注,支撐全球產(chǎn)品內(nèi)容安全標(biāo)注系統(tǒng)的架構(gòu)設(shè)計(jì),開發(fā)及優(yōu)化。
技術(shù)方向:覆蓋低代碼(前后端),桌面端(Electron,C++),圖像、音視頻研發(fā)。參與維護(hù)并完善公司基于Electron桌面的CI/CD平臺(tái)。
歡迎感興趣的同學(xué)投遞實(shí)習(xí)、校招、社招簡歷,可發(fā)到:[email protected]
參考資料
參考: https://www.electronjs.org/docs/latest/api/desktop-capturer/#desktopcapturergetsourcesoptions
[2]參考: https://github.com/ExistentialAudio/BlackHole
[3]鏈接: https://www.webmproject.org/docs/container/
[4]PPT: https://docs.google.com/presentation/d/1MOm-8kacXAon1L2tF6VthesNjXgx0fp5AP17L7XDPSM/edit#slide=id.g91839e9b6_4_5
[5]Code: https://source.chromium.org/chromium/chromium/src/+/master:storage/browser/blob/blob_memory_controller.cc?q=CalculateBlobStorageLimitsImpl&ss=chromium
