<kbd id="afajh"><form id="afajh"></form></kbd>
<strong id="afajh"><dl id="afajh"></dl></strong>
    <del id="afajh"><form id="afajh"></form></del>
        1. <th id="afajh"><progress id="afajh"></progress></th>
          <b id="afajh"><abbr id="afajh"></abbr></b>
          <th id="afajh"><progress id="afajh"></progress></th>

          Electron / Chromium 屏幕錄制的坑

          共 55955字,需瀏覽 112分鐘

           ·

          2021-08-31 23:20

          背景

          Web 屏幕錄制也許對我們來說并不陌生,最常見的場景,例如:各種視頻會議、遠程桌面軟件,遠程會議軟件的出現(xiàn)大大方便了人們的交流與溝通,在 WFH 期間對眾多企業(yè)的線上運轉(zhuǎn)起到關(guān)鍵的作用。除了屏幕的實時分享,錄屏的應(yīng)用還存在另一種應(yīng)用場景,即“記錄實時操作并保留現(xiàn)場,方便后續(xù)追溯與回放”,即是我們業(yè)務(wù)的主要場景。對于我們的業(yè)務(wù),強依賴該功能的穩(wěn)定性。以下是我們業(yè)務(wù)對該功能的一些硬性指標:

          指標要求

          1. 支持任意時長的錄制,支持超過 6 小時時長的錄制。
          2. 支持同時錄音。在錄屏同時錄制到屏幕中正在播放的內(nèi)容的聲音。
          3. 支持跨平臺,兼容 Windows、Mac、Linux 三個平臺。
          4. 支持在 App 從 A 窗口拖拽到 B 窗口時持續(xù)錄制。
          5. 支持在最小化,最大化,全屏時保持錄屏,且錄制范圍僅在 App 內(nèi)部,不可錄制到 App 外。
          6. 支持長時間,不間斷,不關(guān)閉 App 的情況下可以不斷錄制。
          7. 支持在無需完整下載錄屏的情況下,在 Web 端隨意拖拽時間線。
          8. 支持 App 多標簽頁切換情況下,對多標簽頁的同時錄制。
          9. 支持 App 多開窗口在同一個系統(tǒng)窗口內(nèi),同時錄制 App 窗口。
          10. 支持直播實時流的錄制。
          11. 錄屏文件不能存儲在本地,錄制結(jié)束后必須自動上傳并加密存儲。

          技術(shù)方案探索

          目前 Chromium 端上視頻直接錄制,一般來說有兩種技術(shù)方案,即:rrweb 方案、以及 WebRTC API 方案。如果考慮 Electron 場景,又會額外多出一種 ffmpeg 的方案。

          rrweb

          優(yōu)勢

          1. 支持在錄屏的同時直接錄制到當前 Tab 內(nèi)的聲音。
          2. 跨平臺兼容。
          3. 支持窗口的拖拽、最小化、最大化、全屏等情況的持續(xù)錄制。
          4. 錄屏尺寸小。
          5. 支持在無需完整下載錄屏的情況下,在 Web 端隨意拖拽時間線。
          6. 性能較好。

          劣勢

          1. 無法錄制直播實時流。考慮其實現(xiàn)原理,錄屏場景有限。
          2. 不支持在關(guān)閉 App 標簽頁的情況錄制,如果 Renderer 進程關(guān)閉,則會直接終止錄制并丟失錄屏。
          3. 某些場景會對頁面 DOM 有影響。

          ffmpeg

          優(yōu)勢

          1. 同等體積,錄屏文件的輸出質(zhì)量好。
          2. 性能好。
          3. 支持錄制直播實時流。

          劣勢

          1. 跨平臺兼容處理復雜。
          2. 錄制區(qū)域非動態(tài),雖支持選區(qū),但若 App 移動則無能為力的錄制到屏幕外內(nèi)容。
          3. 不支持 App 多標簽頁切換情況下,對多標簽頁進行暫停或繼續(xù)。
          4. 支持在 App 從 A 窗口拖拽到 B 窗口時持續(xù)對 App 錄制。
          5. 錄屏文件中間時間會存儲在本地,若 App 關(guān)閉后會導致錄屏文件的暴露。
          6. 不支持 App 多開窗口情況下的,且在同時錄制。

          webRTC

          優(yōu)勢

          1. 支持全部指標 1-11。

          劣勢

          1. 性能較差,錄制時 CPU 占用率相對較高。
          2. 原生錄制的視頻文件,沒有視頻時長。
          3. 原生錄制的視頻文件,不支持時間線拖拽。
          4. 原生不支持超長時長的錄制,若錄屏文件大于磁盤空間的 1/10 會報錯。
          5. 原生錄制會有較大的內(nèi)存占用。
          6. 視頻刪除依賴 V8 與 Blob 實現(xiàn)的垃圾回收機制,非常容易內(nèi)存泄露。

          考慮到 rrweb 較好的性能,最初我們第一版實際上是基于 rrweb 實現(xiàn)的,但 rrweb 的原生硬傷最終導致我們放棄該方案,比如如果用戶關(guān)閉窗口會直接導致錄屏丟失是不可接受的,其次 rrweb 不支持直播實時流是我們最終放棄他的根本原因。此外考慮到 ffmpeg 的種種限制,以及我們自身的指標要求,最終我們選擇了 webRTC API 直接錄制的方案實現(xiàn)了錄屏功能,并在后續(xù)踩了一些列的坑,一下是一些分享。

          媒體流的獲取

          在 WebRTC 標準中,一切持續(xù)不斷產(chǎn)生媒體的起點,都被抽象成媒體流,例如我們需要錄制屏幕與聲音,其實現(xiàn)的關(guān)鍵就是找到需要錄制屏幕的源和錄制音頻的源,整體的流程如下圖所示:

          視頻流獲取

          想獲取視頻流,首先需要獲取所需要捕獲視頻流的 MediaSourceId。Electron 提供了一個獲取各個“窗口”和“屏幕”視頻 MediaSourceId 的通用 API

          import { desktopCapturer } from 'electron';



          // 獲取全部窗口或屏幕的mediaSourceId

          desktopCapturer.getSources({
            types: ['screen''window'], // 設(shè)定需要捕獲的是"屏幕",還是"窗口"
            thumbnailSize: {
              height300// 窗口或屏幕的截圖快照高度
              width300 // 窗口或屏幕的截圖快照寬度
            },
            fetchWindowIconstrue // 如果視頻源是窗口且有圖標,則設(shè)置該值可以捕獲到的窗口圖標
          }).then(sources => {

            sources.forEach(source => {

              // 如果視頻源是窗口且有圖標,且fetchWindowIcons設(shè)為true,則為捕獲到的窗口圖標

              console.log(source.appIcon);

              // 顯示器Id

              console.log(source.display_id);

              // 視頻源的mediaSourceId,可通過該mediaSourceId獲取視頻源

              console.log(source.id);

              // 窗口名,通常來說與任務(wù)管理器看到的進程名一致

              console.log(source.name);

              // 窗口或屏幕在調(diào)用本API瞬間抓捕到的截圖快照

              console.log(source.thumbnail);

            });

          });

          如果你只想獲取當前窗口的 MediaSourceID

          import { remote } from 'electron';



          // 獲取當前窗口mediaSourceId的做法

          const mediaSourceId = remote.getCurrentWindow().getMediaSourceId();

          在獲取到 mediaSourceId 后,繼續(xù)獲取視頻流,方法如下:

          import { remote } from 'electron';



          // 視頻流獲取

          const videoSource: MediaStream = await navigator.mediaDevices.getUserMedia({

            audiofalse// 強行表示不錄制音頻,音頻額外獲取

            video: {

              mandatory: {

                chromeMediaSource'desktop',

                chromeMediaSourceId: remote.getCurrentWindow().getMediaSourceId()

              }

            }

          });

          其中如果獲取的視頻源是整個桌面窗口,且操作系統(tǒng)如果是 macOS,還要授權(quán)“屏幕錄制權(quán)限”以上步驟執(zhí)行后,我們便可以輕松獲得視頻源。

          音頻源獲取

          不同于視頻源的輕松獲取,音頻源的獲取著實有些復雜,針對 macOS 和 Windows 系統(tǒng),需要分別處理兩種獲取方式。首先,在 Windows 獲取屏幕音頻非常簡單且容易,且不需要任何授權(quán),因此這里如果大家需要錄制音頻,一定要做好權(quán)限提示、

          // Windows音頻流獲取

          const audioSource: MediaStream = await navigator.mediaDevices.getUserMedia({

            audio: {

              mandatory: {

                // 無需指定mediaSourceId就可以錄音了,錄得是系統(tǒng)音頻

                chromeMediaSource'desktop',

              },

            },

            // 如果想要錄制音頻,必須同樣把視頻的選項帶上,否則會失敗

            video: {

              mandatory: {

                chromeMediaSource'desktop',

              },

            },

          });



          // 接著手工移除點不用的視頻源,即可完成音頻流的獲取

          (audioSource.getVideoTracks() || []).forEach(track => audioSource.removeTrack(track));

          接著,再看 macOS 音頻流的獲取,這里就有一些難度了,由于 macOS 的音頻權(quán)限設(shè)定(參考[1]),任何人都沒辦法直接錄制系統(tǒng)音頻,除非安裝第三方驅(qū)動 Kext,比如 soundFlower 或者 blackHole,由于 blackHole 同時支持 arm64 M1 處理器和 x64 Intel 處理器(參考[2]),因此我們最終選擇 blackHole 的方式獲取系統(tǒng)音頻。那么在引導用戶安裝 BlackHole 前,我們需要先檢查當前的安裝狀況,如果用戶沒有安裝過,則提示其安裝,如果安裝過則繼續(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)')

            );

          }



          // 獲取是否有麥克風權(quán)限(blackhole的實現(xiàn)方式是將屏幕音頻模擬為麥克風)

          function getMacAudioRecordPermission(): 'not-determined' | 'granted' | 'denied' | 'restricted' | 'unknown{

            return remote.systemPreferences.getMediaAccessStatus('microphone');

          }



          // 請求麥克風權(quán)限(blackhole的實現(xiàn)方式是將屏幕音頻模擬為麥克風)

          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暫時還不支持錄制音頻

              return 'OS_NOT_SUPPORTED';

            }

          }

          此外,Electron 應(yīng)用必須在 info.plist 中聲明自己需要用到音頻錄制權(quán)限,才可以錄制音頻,以 Electron-builder 打包流程為例:

          // 添加electron-builder配置

          const createMac = () => ({

            ...commonConfig,

            // 聲明afterPack鉤子函數(shù),用于處理音頻授權(quán)時的i18n

            afterPack'scripts/macAfterPack.js',

            mac: {

              ...commonMacConfig,

              // 必須指定entitlements.mac.plist用于簽名時的權(quán)限聲明

              entitlements'scripts/entitlements.mac.plist',

              // 必須限制運行時為"hardened",以使應(yīng)用通過natorize公證

              hardenedRuntimetrue,

              extendInfo: {

                // 為info.plist添加多語言支持

                LSHasLocalizedDisplayNametrue,

              }

            }

          });

          為了獲取音頻錄制權(quán)限,需要自定義 entitlements.mac.plist,并聲明以下四個變量:

          <?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>

          為了使音頻錄制前的“麥克風授權(quán)”提示支持多語言,我們這里手動添加以下自定義文字到每個語言的.lproj/InfoPlist.strings 文件內(nèi):

          // macAfterPack.js
          const fs = require('fs');



          // 用于存儲到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'請允許本程序訪問錄制您的系統(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 安裝過程為例如下圖:當安裝后,需要在「啟動臺」中搜索系統(tǒng)自帶軟件「音頻 MIDI 設(shè)置」并打開。點擊左下角「+」號,選擇「創(chuàng)建多輸出設(shè)備」。在右側(cè)菜單中的「使用」里勾選「BlackHole」(必選)和「揚聲器」/「耳機」(二選一或多選)「主設(shè)備」選擇「揚聲器」/「耳機」。在菜單欄的「音量」設(shè)置中選擇剛才創(chuàng)建好的「多輸出設(shè)備」為聲音輸出設(shè)備。是的,macOS 的音頻錄制步驟非常繁瑣,但是這只能說是目前的最優(yōu)解法了。在完成以上“基本權(quán)限配置”與“Blackhole 擴展配置”后,我們便可以在代碼中順利獲取音頻流了:

          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)先級獲取deviceId

                    const deviceId = soundFlowerDevices.length ?

                      soundFlowerDevices[0].deviceId :

                      blackHoleDevices.length ?

                        blackHoleDevices[0].deviceId :

                        null;

                    if (deviceId) {

                      // 當獲取到可使用的deviceId時,抓取音頻流

                      const audioSource = await navigator.mediaDevices.getUserMedia({

                        audio: {

                          deviceId: {

                            exact: deviceId, // 根據(jù)獲取到的deviceId,獲取音頻流

                          },

                          sampleRate44100,

                          // 這里的三個參數(shù)都關(guān)閉可以獲得最原始的音頻

                          // 否則Chromium默認會對音頻做一些處理

                          echoCancellationfalse,

                          noiseSuppressionfalse,

                          autoGainControlfalse,

                        },

                        videofalse,

                      });

                    }

                    break;

                  case 'NOT_INSTALL_BLACKHOLE':

                    // 這里做一些提示,告知用戶沒有安裝插件

                    break;

                  case 'RECORD_PERMISSION_NOT_GRANTED':

                    // 這里做一些提示,告知用戶沒有授權(quán)

                    break;

                  default:

                    break;

                }

          }

          以上,雖然有些許繁瑣,但是!至少!我們可以同時錄制 Windows 和 macOS 的音頻啦~如果正確配置好,執(zhí)行上述代碼后,會彈出如圖所示的原生授權(quán)彈窗:如果用戶不小心點了不允許,后續(xù)也可以在“系統(tǒng)偏好設(shè)置-安全與隱私-麥克風”這里打開錄制授權(quán)。

          合并音視頻流

          在以上步驟執(zhí)行后,我們便可以合并兩個流,提取各自的軌道,完成一個新的 MediaStream 的創(chuàng)建。

          // 合并音頻流與視頻流

          const combinedSource = new MediaStream([...this._audioSource.getAudioTracks(), ...this._videoSource.getVideoTracks()]);

          媒體流的錄制

          編碼格式

          我們已經(jīng)有了錄制源,但沒有創(chuàng)建錄制 = 沒有開始錄,Chromium 提供了一個叫做 MediaRecorder 的類,用于我們傳入媒體流并錄制視頻,因此如何創(chuàng)建 MediaRecorder 并發(fā)起錄制,是錄屏的核心。MediaRecorder 本身支持僅支持錄制 webm 格式,但支持多種編碼格式,例如:vp8、vp9、h264 等,MediaRecorder 貼心的提供了一個 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)測試,以上編碼格式錄制時的 CPU 占用并沒有什么本質(zhì)區(qū)別,因此建議直接選 VP9 錄。

          創(chuàng)建錄制

          確定好編碼,并合并好音視頻流,我們可以真正開始錄制了:

          const recorder = new MediaRecorder(combinedSource, {

             mimeType'video/webm;codecs=vp9',

             // 支持手動設(shè)置碼率,這里設(shè)了1.5Mbps的碼率,以限制碼率較大的情況

             // 由于本身還是動態(tài)碼率,這個值并不準確

             videoBitsPerSecond1.5e6,

          });



          const timeslice = 5000;

          const fileBits: Blob[] = [];



          // 當數(shù)據(jù)可用時,會回調(diào)該函數(shù),有以下四種情況:

          // 1. 手動停止MediaRecorder時

          // 2. 設(shè)置了timeslice,每到一次timeslice時間間隔時

          // 3. 媒體流內(nèi)所有軌道均變成非活躍狀態(tài)時

          // 4. 調(diào)用recorder.requestData()轉(zhuǎn)移緩沖區(qū)數(shù)據(jù)時

          recorder.ondataavailable = (event: BlobEvent) => {

              fileBits.push(event.data as Blob);

          }



          recorder.onstop = () => {

              // 錄屏停止并獲取錄屏文件

              // 觸發(fā)時機一定在ondataavailable之后

              const videoFile = new Blob(fileBits, { type'video/webm;codecs=vp9' });

          }



          if (timeslice === 0) {

            // 開始錄制,并一直存儲數(shù)據(jù)到緩沖區(qū),直到停止

            recorder.start();

          else {

            // 開始錄制,并且每timeslice毫秒,觸發(fā)一次ondataavailable,輸出并清空緩沖區(qū)(非常重要)

            recorder.start(timeslice);

          }





          setTimeout(() => {

           // 30秒后停止

           recorder.stop();

          }, 30000);

          暫停/恢復錄制

          // 暫停錄制

          recorder.pause();



          // 恢復錄制

          recorder.resume();

          完成以上 API 的調(diào)用,我們“錄屏功能 MVP”版本就算跑通了。

          錄制產(chǎn)物的處理

          正如前面技術(shù)方案探索內(nèi)容中提到的,直接使用瀏覽器實現(xiàn)的這套方法,會有一些坑,盡管如此,本文的核心其實就是這部分,也就是解決錄屏帶來的那些坑。

          鎖屏觸發(fā)視頻流停止問題

          實驗發(fā)現(xiàn),通過 navigator.getUserMedia 獲取的視頻流,在鎖屏情況(是的 macOS、Windows 全部操作系統(tǒng)都會)會中斷,我們可以通過一下代碼測試該現(xiàn)象:

          import { remote } from 'electron';



          // 視頻流獲取

          const videoSource: MediaStream = await navigator.mediaDevices.getUserMedia({

            audiofalse// 強行表示不錄制音頻,音頻額外獲取

            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',

             // 支持手動設(shè)置碼率,這里設(shè)了1.5Mbps的碼率,以限制碼率較大的情況

             // 由于本身還是動態(tài)碼率,這個值并不準確

             videoBitsPerSecond1.5e6,

          });



          // 開始錄制,等10秒,手動觸發(fā)鎖屏

          recorder.start();



          setInterval(() => {

             console.log('軌道活躍:', videoSource.active);

          }, 1000);



          10秒后控制臺輸出:



          軌道活躍: true

          軌道活躍: true

          軌道活躍: true

          軌道活躍: true

          軌道活躍: true

          軌道活躍: true

          軌道活躍: true

          軌道活躍: true

          軌道活躍: true

          數(shù)據(jù)可用

          錄屏停止

          軌道活躍: false

          ...

          以上實驗說明鎖屏會觸發(fā)視頻流狀態(tài)由“活躍”轉(zhuǎn)為“不活躍”,該問題最大的坑點在于解鎖后“狀態(tài)并不會自動恢復為活躍”,必須開發(fā)者手動重新調(diào)用 navigator.mediaDevices getUserMedia 獲取視頻流。那么如何知道用戶是否鎖屏呢?這里我探索出來一種方法:

          // 啟動MediaRecorder的時候,如果拋錯,此時重新獲取視頻流

           try {

            this.recorder.start(5000);

          catch (e) {

            this._combinedSource = await this.getSystemVideoMediaStream()

            this.recorder = new MediaRecorder(this._combinedSource, {

              mimeType: VIDEO_RECORD_FORMAT,

              videoBitsPerSecond1.5e6,

            });

            this.recorder.start(5000);

          }

          第二個坑點在于,以上僅針對純視頻流場景錄屏,如果同時錄制音頻流+視頻流,那么**“由于音頻流鎖屏時的狀態(tài)始終保持活躍”,而“僅視頻流鎖屏時會觸發(fā)狀態(tài)變?yōu)椴换钴S”**,由于并非全部軌道都變?yōu)椴换钴S,這里“MediaRecorder 并不會觸發(fā) ondataavailable 和 onstop,錄屏將會仍然繼續(xù)進行,但錄出來的視頻是黑屏”,成為這個問題的一大槽點與大坑。那么如何解決音視頻流鎖屏時并不觸發(fā) ondataavailable 和 onstop 的問題呢?這里有一種我探索的方法:

          // 如果視頻流不活躍,停止音頻流

          // 如果音頻流不活躍,停止視頻流(雖然不會發(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);

          }

          缺少視頻時長與時間線不可拖拽問題

          • 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

          私以為這兩個問題,算是 MediaRecorder api 設(shè)計的最大失誤了。由于 webm 文件的視頻時長和拖拽信息是寫在文件頭部的,因此在 WebM 錄制未完成前,頭部的"Duration"永遠是不斷增加的一個未知值。但由于 MediaRecorder 支持分片定時輸出小 Blob 文件,導致第一個 Blob 的頭部是不可能包含 Duration 字段的,同樣搜索頭信息"SeekHead", "Seek", "SeekID", "SeekPosition", "Cues", "CueTime", "CueTrack", "CueClusterPosition", "CueTrackPositions", "CuePoint" 同樣缺失。但 Blob 在設(shè)計之初又是不可變的文件類型,導致最終錄制出的文件沒有 Duration 視頻時長字段,這個問題已經(jīng)被 Chromium 官方標識為“wont fix”,并推薦開發(fā)者自行找社區(qū)解決。

          使用 ffmpeg 修復

          社區(qū)內(nèi)的一種方案是使用 ffmpeg 對文件進行“拷貝”并輸出,例如輸入下面的命令:

          ffmpeg -i without_meta.webm  -vcodec copy -acodec copy with_meta.webm

          ffmpeg 會自動計算 Duration 與搜索頭信息,這種方案最大的問題在于,如果對客戶端集成 ffmpeg,需要直接操作文件且編寫跨平臺方案,將文件暴露于本地。如果做在服務(wù)端,又會增加文件的整體處理流程與時間,雖然不是不可以,但是這不是我們追求的極致方案。

          使用 npm 庫 fix-webm-duration 修復

          這是社區(qū)內(nèi)的另一種方案,即解析 webm 文件的頭部信息,并在前端手工記錄視頻時長,在解析好之后手動將記錄好的 Duration 寫入 webm 頭部,但該方案同樣不能解決搜索頭丟失導致的可拖拽信息,且依賴手工記錄的 duration,修復內(nèi)容比較有限。

          基于 ts-ebml,利用 fix-webm-metainfo 修復

          這是本問題的最終解,即完全解析 webm ebml 和 segment 頭,根據(jù)實際 simple block 的大小計算 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]),一個正常的 webm 的頭信息,應(yīng)該解析如下:

          0 EBML

          1   EBMLVersion 1

          1   EBMLReadVersion 1

          1   EBMLMaxIDLength 4

          1   EBMLMaxSizeLength 8

          1   DocType webm

          1   DocTypeVersion 4

          1   DocTypeReadVersion 2

          0 Segment

          // 這部分缺失

          1   SeekHead                            -> This is SeekPosition 0, so all SeekPositions can be calculated as (bytePos - segmentContentStartPos), which is 44 in this case

          2     Seek

          3       SeekID                          -> Buffer([0x150x490xA90x66])  Info

          3       SeekPosition                    -> infoStartPos =

          2     Seek

          3       SeekID                          -> Buffer([0x160x540xAE0x6B])  Tracks

          3       SeekPosition { tracksStartPos }

          2     Seek

          3       SeekID                          -> Buffer([0x1C0x530xBB0x6B])  Cues

          3       SeekPosition { cuesStartPos }

          1   Info

          // 這部分缺失

          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

          可以看到,我們只要修復好缺失的 Duration、SeakHead、Cues,就可以解決我們的問題,整體流程如下:ts-ebml 是一個社區(qū)開源的庫,該庫在 ebml 的 Decoder、Reader 實現(xiàn)的 ArrayBuffer 到可讀 EBML 的相互轉(zhuǎn)換能力的基礎(chǔ)上,添加了 Webm 修復功能,但不支持大于 2GB 的視頻文件,根本原因在于直接對 Blob 轉(zhuǎn)換為 ArrayBuffer 是有問題的,ArrayBuffer 的最大長度僅為 2046 * 1024 * 1024, 為此早期我發(fā)布了一個叫做 fix-webm-metainfo 的 npm 包,利用 Buffer 的 slice 方法,使用 Buffer[]代替 Buffer 解決了該問題。

          import { tools, Reader } from 'ts-ebml';

          import LargeFileDecorder from './decoder';



          // fix-webm-metainfo 早期的實現(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讀取以計算Duration和Cues

            decoder.decode(bufSlices).forEach(elm => reader.read(elm));



            // 當全部讀取結(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 });

          }

          進程卡死與緩存未復用問題

          隨著視頻長度的增加,fix-webm-metainfo 盡管解決了大尺寸長視頻的修復問題,但面對大文件在短時間的全量讀取與計算,存在短時間卡死渲染進程的問題。

          Web Worker 處理

          Web Worker 天生適合該場景的處理,利用 Web Worker,我們可以在不額外創(chuàng)建進程的同時,額外創(chuàng)建一個 Worker 線程,專門進行大視頻文件的處理與解析,同時不會卡死主線程,此外由于 Web Worker 支持以引用的方式(Transferable Object)傳遞 ArrayBuffer,因此也成了本問題最佳解決方法。首先在 Electron 的 BrowserWindow 中開啟 nodeIntegrationInWorker:true

          webPreferences: {
             ...
             nodeIntegration: true,
             nodeIntegrationInWorkertrue,
          },

          接著編寫 Worker 進程:

          import { tools, Reader } from 'ts-ebml';

          import LargeFileDecorder from './decoder';



          // index.worker.ts

          export interface IWorkerPostData {

            type'transfer' | 'close';

            data?: ArrayBuffer;

          }



          export interface IWorkerEchoData {

            bufferArrayBuffer;

            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);

            // 將計算后的結(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':

                // 修復WebM,之后關(guān)閉Worker進程

                fixWebm().catch(self.postMessage).finally(() => self.close());

                break;

              default:

                break;

            }

          });

          父進程:

          import FixWebmWorker from './worker/index.worker';

          import type { IWorkerPostData, IWorkerEchoData } from './worker/index.worker';



          async function fixWebmMetaInfo(blob: Blob): Promise<Blob{

            // 創(chuàng)建Worker進程

            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 });

                // 手動關(guān)閉Worker進程

                fixWebmWorker.terminate();



                let body: Blob;

                let firstPartBlobSlice = blobSlices.shift();

                body = firstPartBlobSlice.slice(event.data.size);

                firstPartBlobSlice = null;



                // 注:除了利用Web Worker,與早期方案相比,并對meta ArrayBuffer生成Blob

                // 不再用ArrayBuffer重建,而是復用之前的Blob

                // 這一步做了之后會大量減少一次文件寫入,并可解決引用不釋放導致的內(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進程,并利用 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}`));

              }

            });

          }

          通過對早期 fix-webm-metainfo 的修復過程中 blob_storage 暫存目錄的分頁文件進行觀察,我們察覺到了明顯的內(nèi)存不釋放以及文件重復生成的問題,在去除 fix-webm 邏輯后,該問題不再復現(xiàn),這就說明目前的 fix-webm-metainfo 存在文件緩存未復用和文件引用未刪除的問題(這個問題后面討論)。

          文件緩存復用

          那么在 ArrayBuffer 與 Blob 的轉(zhuǎn)換中,是否有一種無損,且可復用文件緩存的方式呢?這就是為什么 fix-webm-metainfo 在后面的迭代中,采用了復用 Blob 的方式建立修復后的 Blob,而不是直接使用 ArrayBuffer 建立 Blob 的原因。觀察下面的兩種方式生成的 Blob 有什么區(qū)別:

          // 首先創(chuàng)建一個Blob

          const a = new Blob([new ArrayBuffer(10000000)]);



          // 讀出它的buffer

          const buffer = await a.arrayBuffer();



          // 方式1,實際會占用多少內(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 存在復用本地文件緩存的機制,方式 1 會在內(nèi)存或磁盤生成 7 份一模一樣的文件,而方式 2 不會額外生成一個文件,i 到 o 的文件均復用了 a 的 blob,在內(nèi)存或磁盤中只存在一份。那么,修復 webm 的那種方式本質(zhì)上修改了文件頭部的字節(jié),那這種方式也會復用同一個本地文件緩存么?答案是肯定的,被修復前的 webm 和被修復后的 webm 由于差異僅在頭部,而整體的大部分區(qū)域均采用相同的 Blob slice 出來的子 blob 建立,因此空間依然是復用的。

          主進程內(nèi)存泄露問題

          根據(jù) Electron 官方提供的 process.getProcessMemoryInfo() api,我們分別對主進程和渲染進程實現(xiàn)了內(nèi)存監(jiān)控,通過監(jiān)控發(fā)現(xiàn)使用錄屏的用戶的主進程內(nèi)存占用經(jīng)常可以達到 2GB,而不使用錄屏功能的用戶,主進程內(nèi)存占用僅 80MB,這說明百分百存在內(nèi)存泄露。在談及主進程內(nèi)存泄漏問題之前,不得不提及 Blob 文件類型的實現(xiàn)方式。根據(jù) Chromium Blob 實現(xiàn)官方說明(PPT[4])如下圖,我們在 Renderer 進程通過任何一種方式創(chuàng)建的 Blob,本質(zhì)上最終都會有一個跨進程傳輸?shù)?Browser 進程的過程(即主進程),也就是說盡管 MediaRecorder 是基于渲染進程的錄制,但在將緩沖區(qū)文件輸出為 Blob 的過程(即 ondataavailable 觸發(fā)瞬間),會存在跨進程傳輸。以上說明了在“渲染進程”錄制,而“主進程”內(nèi)存占用不斷增大的根本原因,那么再具體點,Blob 到底是怎么傳輸?shù)模繐Q句話說,我們僅知道創(chuàng)建 Blob 時,二進制數(shù)據(jù)會跨進程傳輸?shù)街鬟M程是不夠的。如果文件足夠大,主進程內(nèi)存不足會怎樣?Chromium 又是如何管理并存儲 Blob 內(nèi)包含的二進制文件呢?

          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;

            // 當Blob文件大小大于可用內(nèi)存數(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編譯時開啟文件分頁(默認開啟),且配置了override_file_transport_min_size時

            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時,直接走ipc傳輸

            if (total_transportation_bytes <= limits_.max_ipc_memory_size)

              return Strategy::IPC;



            // Chromium編譯時開啟文件分頁(默認開啟)

            // Blob文件大小小于可用磁盤空間

            // Blob文件大小大于可用內(nèi)存空間

            if (file_paging_enabled_ &&

                total_transportation_bytes <= GetAvailableFileSpaceForBlobs() &&

                total_transportation_bytes > limits_.memory_limit_before_paging()) {

              return Strategy::FILE;

            }



            // 默認傳輸策略,即內(nèi)存共享方式,通過渲染進程傳遞給主進程

            return Strategy::SHARED_MEMORY;

          }



          bool BlobMemoryController::CanReserveQuota(uint64_t size) const {

            // 同時檢查內(nèi)“可用內(nèi)存空間”與“可用磁盤空間”

            return size <= GetAvailableMemoryForBlobs() ||

                   size <= GetAvailableFileSpaceForBlobs();

          }



          // 如果當前內(nèi)存使用量小于2GB(按x64電腦算,max_blob_in_memory_space = 2 * 1024 * 1024 * 1024)

          // 計算剩余內(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();

          }



          // 計算剩余磁盤量

          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;

            // 實際最大磁盤空間 - 已用磁盤空間

            return limits_.effective_max_disk_space - total_disk_used;

          }

          可發(fā)現(xiàn):Blob 的傳輸與儲存基本分為三種,即:“文件”,“共享內(nèi)存”,以及“IPC”,

          1. 當文件小于 0.25MB 時優(yōu)先走“IPC”方式傳輸
          2. 當“可用內(nèi)存空間”大于文件體積時優(yōu)先走“共享內(nèi)存”方式傳輸
          3. 當“可用內(nèi)存空間”不足但“可用磁盤空間”充足時,優(yōu)先走“文件”方式傳輸
          4. 當“可用內(nèi)存空間”與“可用磁盤空間”均不充足時,Blob 不會傳輸,且最終反饋到渲染進程,會報“File not readble”之類的報錯。

          最大存儲限制

          這里引發(fā)一個問題“可用內(nèi)存空間”與“可用磁盤空間”是如何界定的?如果計算?想到這里,又引發(fā)我的思考,如果可用內(nèi)存空間非常大,會造成什么問題?帶著這些疑問,我們繼續(xù)研究 Chromium 的實現(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

            }



            // 實現(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

               // 其他平臺或架構(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é)一下兩個指標,與 OS、Arch、Memory Size、Disk Size 都有可能有關(guān)系:

          最大可用內(nèi)存大小

          • 架構(gòu)是 x64 且平臺不是 Chrome OS 或 Android:2GB

          • 平臺是 Android:所在設(shè)備物理內(nèi)存大小/ 100

          • 其他平臺或架構(gòu)(例如 macOS arm64,chromeOS):所在設(shè)備物理內(nèi)存大小 / 5

          最大可用磁盤大小

          • 平臺是 Chrome OS:所在設(shè)備,軟件所在分區(qū)的邏輯磁盤的大小 / 2

          • 平臺是安卓:所在設(shè)備,軟件所在分區(qū)的邏輯磁盤的大小 * 3/50

          • 其他平臺或架構(gòu):所在設(shè)備,軟件所在分區(qū)的邏輯磁盤的大小 / 10

          以上結(jié)論說明了什么?我們從中發(fā)現(xiàn)了兩個問題:

          1. 問題 1:X64 架構(gòu)的最大可用內(nèi)存是 2GB,這實際上非常大了,用戶的錄屏存儲并非頻繁訪問的內(nèi)容,用戶的電腦可能只有 8GB,如果這 2GB 平白被占據(jù)實際上是很大一個浪費。
          2. 問題 2:X64 與非 X64 架構(gòu)的最大可用內(nèi)存并不一致。
          3. 問題 3:最大可用磁盤大小僅為物理硬盤大小的 1/10, 以 128GB 的 SSD 硬盤為例,即使將全部 128GB 均分配給 C 盤,那么最大可用磁盤大小僅為 12.8GB,不考慮其他任何 Blob 的磁盤占用,即使用戶 C 盤有 100GB 的剩余空間,依然逃不了錄屏文件體積被限制到 12.8GB 的尷尬。

          事實真相大白,主進程并非“內(nèi)存泄露”而是“設(shè)計如此”。

          修改 Chromium

          那么我們?nèi)绻麑⒆畲髢?nèi)存空間改小,將最大可用磁盤空間改大,是不是即可解決主進程內(nèi)存占用問題,又解決了錄屏文件體積限制兩個問題呢?答案是肯定的,修改起來也很簡單:

            // 如果物理內(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

            }

          如果你有類似的需要,可以直接復用該修改,且無任何副作用。

          緩沖區(qū)內(nèi)存釋放問題

          有了上述對 Blob 文件格式的理解,我們基本可以理清錄屏功能的整個傳輸鏈路。緩沖區(qū)內(nèi)存釋放問題的解法,相信大家也能想到了,在錄制過程中,未對 MediaRecorder stop 前,由于 MediaRecorder 錄制的全部數(shù)據(jù)均存儲于 Renderer 進程中,便會造成內(nèi)存的異常占用,隨著錄屏時間的增長,這部分的占用會尤為龐大,解決方法也很簡單,設(shè)定一個 timeslice 或定時 requestData()即可

          const recorder = new MediaRecorder(combinedSource, {

             mimeType'video/webm;codecs=vp9',

             videoBitsPerSecond1.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è)定timeSlice,確保每timeslice毫秒,自動觸發(fā)一次ondataavailable,輸出并清空緩沖區(qū)(非常重要)

          recorder.start(timeslice);



          // 解法二,錄制過程中手動requestData清空緩沖區(qū)

          recorder.start();

          setInterval(() => recorder.requestData(), timeslice);

          渲染進程內(nèi)存泄露問題

          在編寫過程中,由于一些疏忽,我們可能會寫出具有內(nèi)存泄露的代碼,那么如何解決該問題?結(jié)論是,時刻遵循以下原則:

          1.    一切對Blob的引用都及時清除
          2.    盡量用let 指向Blob并手動釋放,防止引用不釋放的情況發(fā)生
          // 例1

          const a = new Map();



          a.set('key', {

              blobnew Blob([1]) // Blob1

          });



          // 手動釋放

          a.get('key').blob = null;



          // 例2

          let a = new Blob([]);



          doSomething(a);



          // 手動釋放

          a = null;

          Blob-Internals 觀察引用

          若想隨時 Debug,可以通過觀察 Blob 的引用計數(shù)的方式,直接訪問 chrome://blob-internals/以上圖為例,每一個 Blob 均有一個獨一無二的 UUID,通過觀察某 UUID 的 Blob 的引用計數(shù),我們可以相對較輕松的 Debug Blob 的泄露情況。

          Profiler 抓取堆快照

          也可以利用 Profiler 抓取內(nèi)存堆棧情況。

          blob_storage 目錄觀察

          如果你有對 Chromium 修改的能力,可以通過將“最大可用內(nèi)存”改為較小值(比如 10MB,以此迫使 Blob 直接走文件傳輸方式存儲到硬盤),直接觀測 blob_storage 目錄內(nèi)分頁文件的產(chǎn)生。Blob 文件在本地磁盤是以分頁的形式存儲,它的大小是一個動態(tài)值,最小為 5MB,最大為 100MB。每次關(guān)閉應(yīng)用時該目錄都會被清空,因此需要確保應(yīng)用開啟并持續(xù)觀測,這種方式是目前最為直觀易用的方式,一般來說如果用戶持續(xù)不關(guān)閉應(yīng)用,而你的代碼又存在內(nèi)存泄露,那么基本可以觀察到該目錄會產(chǎn)生大量的分頁文件而不被釋放。

          后續(xù)的性能優(yōu)化

          當前的處理,盡管已經(jīng)完美解決了一切修復問題,但存在最后一個問題,就是修復時會占用大量內(nèi)存,后續(xù)我會持續(xù)維護 fix-webm-metainfo 庫,通過不傳輸完整 ArrayBuffer 的方式,解決這個問題。

          參考資料

          [1]

          參考: 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


          瀏覽 117
          點贊
          評論
          收藏
          分享

          手機掃一掃分享

          分享
          舉報
          評論
          圖片
          表情
          推薦
          點贊
          評論
          收藏
          分享

          手機掃一掃分享

          分享
          舉報
          <kbd id="afajh"><form id="afajh"></form></kbd>
          <strong id="afajh"><dl id="afajh"></dl></strong>
            <del id="afajh"><form id="afajh"></form></del>
                1. <th id="afajh"><progress id="afajh"></progress></th>
                  <b id="afajh"><abbr id="afajh"></abbr></b>
                  <th id="afajh"><progress id="afajh"></progress></th>
                  美女逼视频 | 黄色一级大片免费看 | 肏逼小视频| 女人18片毛片120分钟免费观看 | 国产成人女自拍 |