Electron - Chromium 屏幕錄製 - 那些我踩過的坑

背景

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

指標要求

  1. 支持任意時長的錄製,支持超過 6 小時時長的錄製。

  2. 支持同時錄音。在錄屏同時錄製到屏幕中正在播放的內容的聲音。

  3. 支持跨平臺,兼容 Windows、Mac、Linux 三個平臺。

  4. 支持在 App 從 A 窗口拖拽到 B 窗口時持續錄製。

  5. 支持在最小化,最大化,全屏時保持錄屏,且錄製範圍僅在 App 內部,不可錄製到 App 外。

  6. 支持長時間,不間斷,不關閉 App 的情況下可以不斷錄製。

  7. 支持在無需完整下載錄屏的情況下,在 Web 端隨意拖拽時間線。

  8. 支持 App 多標籤頁切換情況下,對多標籤頁的同時錄製。

  9. 支持 App 多開窗口在同一個系統窗口內,同時錄製 App 窗口。

  10. 支持直播實時流的錄製。

  11. 錄屏文件不能存儲在本地,錄製結束後必須自動上傳並加密存儲。

技術方案探索

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

rrweb

優勢

  1. 支持在錄屏的同時直接錄製到當前 Tab 內的聲音。

  2. 跨平臺兼容。

  3. 支持窗口的拖拽、最小化、最大化、全屏等情況的持續錄製。

  4. 錄屏尺寸小。

  5. 支持在無需完整下載錄屏的情況下,在 Web 端隨意拖拽時間線。

  6. 性能較好。

劣勢

  1. 無法錄製直播實時流。考慮其實現原理,錄屏場景有限。

  2. 不支持在關閉 App 標籤頁的情況錄製,如果 Renderer 進程關閉,則會直接終止錄製並丟失錄屏。

  3. 某些場景會對頁面 DOM 有影響。

ffmpeg

優勢

  1. 同等體積,錄屏文件的輸出質量好。

  2. 性能好。

  3. 支持錄製直播實時流。

劣勢

  1. 跨平臺兼容處理複雜。

  2. 錄製區域非動態,雖支持選區,但若 App 移動則無能爲力的錄製到屏幕外內容。

  3. 不支持 App 多標籤頁切換情況下,對多標籤頁進行暫停或繼續。

  4. 支持在 App 從 A 窗口拖拽到 B 窗口時持續對 App 錄製。

  5. 錄屏文件中間時間會存儲在本地,若 App 關閉後會導致錄屏文件的暴露。

  6. 不支持 App 多開窗口情況下的,且在同時錄製。

webRTC

優勢

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

劣勢

  1. 性能較差,錄製時 CPU 佔用率相對較高。

  2. 原生錄製的視頻文件,沒有視頻時長。

  3. 原生錄製的視頻文件,不支持時間線拖拽。

  4. 原生不支持超長時長的錄製,若錄屏文件大於磁盤空間的 1/10 會報錯。

  5. 原生錄製會有較大的內存佔用。

  6. 視頻刪除依賴 V8 與 Blob 實現的垃圾回收機制,非常容易內存泄露。

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

媒體流的獲取

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

視頻流獲取

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

import { desktopCapturer } from 'electron';



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

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

  sources.forEach(source ={

    // 如果視頻源是窗口且有圖標,且fetchWindowIcons設爲true,則爲捕獲到的窗口圖標

    console.log(source.appIcon);

    // 顯示器Id

    console.log(source.display_id);

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

    console.log(source.id);

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

    console.log(source.name);

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

    console.log(source.thumbnail);

  });

});

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

import { remote } from 'electron';



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

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

在獲取到 mediaSourceId 後,繼續獲取視頻流,方法如下:

import { remote } from 'electron';



// 視頻流獲取

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

  audio: false, // 強行表示不錄製音頻,音頻額外獲取

  video: {

    mandatory: {

      chromeMediaSource: 'desktop',

      chromeMediaSourceId: remote.getCurrentWindow().getMediaSourceId()

    }

  }

});

其中如果獲取的視頻源是整個桌面窗口,且操作系統如果是 macOS,還要授權 “屏幕錄製權限” 以上步驟執行後,我們便可以輕鬆獲得視頻源。

音頻源獲取

不同於視頻源的輕鬆獲取,音頻源的獲取着實有些複雜,針對 macOS 和 Windows 系統,需要分別處理兩種獲取方式。首先,在 Windows 獲取屏幕音頻非常簡單且容易,且不需要任何授權,因此這裏如果大家需要錄製音頻,一定要做好權限提示、

// Windows音頻流獲取

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

  audio: {

    mandatory: {

      // 無需指定mediaSourceId就可以錄音了,錄得是系統音頻

      chromeMediaSource: 'desktop',

    },

  },

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

  video: {

    mandatory: {

      chromeMediaSource: 'desktop',

    },

  },

});



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

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

接着,再看 macOS 音頻流的獲取,這裏就有一些難度了,由於 macOS 的音頻權限設定 (參考 [1]),任何人都沒辦法直接錄製系統音頻,除非安裝第三方驅動 Kext,比如 soundFlower 或者 blackHole,由於 blackHole 同時支持 arm64 M1 處理器和 x64 Intel 處理器 (參考 [2]),因此我們最終選擇 blackHole 的方式獲取系統音頻。那麼在引導用戶安裝 BlackHole 前,我們需要先檢查當前的安裝狀況,如果用戶沒有安裝過,則提示其安裝,如果安裝過則繼續,這裏的方式如下:

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

  );

}



// 獲取是否有麥克風權限(blackhole的實現方式是將屏幕音頻模擬爲麥克風)

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

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

}



// 請求麥克風權限(blackhole的實現方式是將屏幕音頻模擬爲麥克風)

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 應用必須在 info.plist 中聲明自己需要用到音頻錄製權限,纔可以錄製音頻,以 Electron-builder 打包流程爲例:

// 添加electron-builder配置

const createMac = () =({

  ...commonConfig,

  // 聲明afterPack鉤子函數,用於處理音頻授權時的i18n

  afterPack: 'scripts/macAfterPack.js',

  mac: {

    ...commonMacConfig,

    // 必須指定entitlements.mac.plist用於簽名時的權限聲明

    entitlements: 'scripts/entitlements.mac.plist',

    // 必須限制運行時爲"hardened",以使應用通過natorize公證

    hardenedRuntime: true,

    extendInfo: {

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

      LSHasLocalizedDisplayName: true,

    }

  }

});

爲了獲取音頻錄製權限,需要自定義 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>

爲了使音頻錄製前的 “麥克風授權” 提示支持多語言,我們這裏手動添加以下自定義文字到每個語言的. lproj/InfoPlist.strings 文件內:

// 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: '請允許本程序訪問錄製您的系統音頻',
  },
};



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 音頻錄製能力權限。接着,以 Blackhole 安裝過程爲例如下圖:當安裝後,需要在「啓動臺」中搜索系統自帶軟件「音頻 MIDI 設置」並打開。點擊左下角「+」號,選擇「創建多輸出設備」。在右側菜單中的「使用」裏勾選「BlackHole」(必選)和「揚聲器」/「耳機」(二選一或多選)「主設備」選擇「揚聲器」/「耳機」。在菜單欄的「音量」設置中選擇剛纔創建好的「多輸出設備」爲聲音輸出設備。是的,macOS 的音頻錄製步驟非常繁瑣,但是這隻能說是目前的最優解法了。在完成以上 “基本權限配置” 與“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,則按優先級獲取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, // 根據獲取到的deviceId,獲取音頻流

                },

                sampleRate: 44100,

                // 這裏的三個參數都關閉可以獲得最原始的音頻

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

                echoCancellation: false,

                noiseSuppression: false,

                autoGainControl: false,

              },

              video: false,

            });

          }

          break;

        case 'NOT_INSTALL_BLACKHOLE':

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

          break;

        case 'RECORD_PERMISSION_NOT_GRANTED':

          // 這裏做一些提示,告知用戶沒有授權

          break;

        default:

          break;

      }

}

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

合併音視頻流

在以上步驟執行後,我們便可以合併兩個流,提取各自的軌道,完成一個新的 MediaStream 的創建。

// 合併音頻流與視頻流

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

媒體流的錄製

編碼格式

我們已經有了錄製源,但沒有創建錄製 = 沒有開始錄,Chromium 提供了一個叫做 MediaRecorder 的類,用於我們傳入媒體流並錄製視頻,因此如何創建 MediaRecorder 併發起錄製,是錄屏的核心。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 :("));

}

經測試,以上編碼格式錄製時的 CPU 佔用並沒有什麼本質區別,因此建議直接選 VP9 錄。

創建錄製

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

const recorder = new MediaRecorder(combinedSource, {

   mimeType: 'video/webm;codecs=vp9',

   // 支持手動設置碼率,這裏設了1.5Mbps的碼率,以限制碼率較大的情況

   // 由於本身還是動態碼率,這個值並不準確

   videoBitsPerSecond: 1.5e6,

});



const timeslice = 5000;

const fileBits: Blob[] = [];



// 當數據可用時,會回調該函數,有以下四種情況:

// 1. 手動停止MediaRecorder時

// 2. 設置了timeslice,每到一次timeslice時間間隔時

// 3. 媒體流內所有軌道均變成非活躍狀態時

// 4. 調用recorder.requestData()轉移緩衝區數據時

recorder.ondataavailable = (event: BlobEvent) ={

    fileBits.push(event.data as Blob);

}



recorder.onstop = () ={

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

    // 觸發時機一定在ondataavailable之後

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

}



if (timeslice === 0) {

  // 開始錄製,並一直存儲數據到緩衝區,直到停止

  recorder.start();

} else {

  // 開始錄製,並且每timeslice毫秒,觸發一次ondataavailable,輸出並清空緩衝區(非常重要)

  recorder.start(timeslice);

}





setTimeout(() ={

 // 30秒後停止

 recorder.stop();

}, 30000);

暫停 / 恢復錄製

// 暫停錄製

recorder.pause();



// 恢復錄製

recorder.resume();

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

錄製產物的處理

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

鎖屏觸發視頻流停止問題

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

import { remote } from 'electron';



// 視頻流獲取

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

  audio: false, // 強行表示不錄製音頻,音頻額外獲取

  video: {

    mandatory: {

      chromeMediaSource: 'desktop',

      chromeMediaSourceId: remote.getCurrentWindow().getMediaSourceId()

    }

  }

});



recorder.ondataavailable = () => console.log('數據可用');

recorder.onstop = () => console.log('錄屏停止');



const recorder = new MediaRecorder(videoSource, {

   mimeType: 'video/webm;codecs=vp9',

   // 支持手動設置碼率,這裏設了1.5Mbps的碼率,以限制碼率較大的情況

   // 由於本身還是動態碼率,這個值並不準確

   videoBitsPerSecond: 1.5e6,

});



// 開始錄製,等10秒,手動觸發鎖屏

recorder.start();



setInterval(() ={

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

}, 1000);



10秒後控制檯輸出:



軌道活躍: true

軌道活躍: true

軌道活躍: true

軌道活躍: true

軌道活躍: true

軌道活躍: true

軌道活躍: true

軌道活躍: true

軌道活躍: true

數據可用

錄屏停止

軌道活躍: false

...

以上實驗說明鎖屏會觸發視頻流狀態由 “活躍” 轉爲“不活躍”,該問題最大的坑點在於解鎖後 “狀態並不會自動恢復爲活躍”,必須開發者手動重新調用 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,

    videoBitsPerSecond: 1.5e6,

  });

  this.recorder.start(5000);

}

第二個坑點在於,以上僅針對純視頻流場景錄屏,如果同時錄製音頻流 + 視頻流,那麼 “由於音頻流鎖屏時的狀態始終保持活躍”,而** “僅視頻流鎖屏時會觸發狀態變爲不活躍”**,由於並非全部軌道都變爲不活躍,**這裏 “MediaRecorder 並不會觸發 ondataavailable 和 onstop,錄屏將會仍然繼續進行,但錄出來的視頻是黑屏”**,成爲這個問題的一大槽點與大坑。那麼如何解決音視頻流鎖屏時並不觸發 ondataavailable 和 onstop 的問題呢?這裏有一種我探索的方法:

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

// 如果音頻流不活躍,停止視頻流(雖然不會發生,只是兜底)

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 設計的最大失誤了。由於 webm 文件的視頻時長和拖拽信息是寫在文件頭部的,因此在 WebM 錄製未完成前,頭部的 "Duration" 永遠是不斷增加的一個未知值。但由於 MediaRecorder 支持分片定時輸出小 Blob 文件,導致第一個 Blob 的頭部是不可能包含 Duration 字段的,同樣搜索頭信息 "SeekHead", "Seek", "SeekID", "SeekPosition", "Cues", "CueTime", "CueTrack", "CueClusterPosition", "CueTrackPositions", "CuePoint" 同樣缺失。但 Blob 在設計之初又是不可變的文件類型,導致最終錄製出的文件沒有 Duration 視頻時長字段,這個問題已經被 Chromium 官方標識爲 “wont fix”,並推薦開發者自行找社區解決。

使用 ffmpeg 修復

社區內的一種方案是使用 ffmpeg 對文件進行 “拷貝” 並輸出,例如輸入下面的命令:

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

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

使用 npm 庫 fix-webm-duration 修復

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

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

這是本問題的最終解,即完全解析 webm ebml 和 segment 頭,根據實際 simple block 的大小計算 Duration 與搜索頭。我們利用 ebml 解析 webm,以 MediaRecorder 直出的 webm 文件爲例解析,結構如下:

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

而根據 webm 官網描述(鏈接 [3]),一個正常的 webm 的頭信息,應該解析如下:

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

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

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

import LargeFileDecorder from './decoder';



// fix-webm-metainfo 早期的實現過程

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



  // 當全部讀取結束後,結束reader

  reader.stop();



  // 利用reader生成好的cues與duration,重建meta頭,並轉換回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,我們可以在不額外創建進程的同時,額外創建一個 Worker 線程,專門進行大視頻文件的處理與解析,同時不會卡死主線程,此外由於 Web Worker 支持以引用的方式(Transferable Object)傳遞 ArrayBuffer,因此也成了本問題最佳解決方法。首先在 Electron 的 BrowserWindow 中開啓 nodeIntegrationInWorker:true

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

接着編寫 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 {

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

  // 將計算後的結果傳回父線程

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

      // 保存傳遞過來的ArrayBuffer

      bufSlices.push(e.data.data);

      break;

    case 'close':

      // 修復WebM,之後關閉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> {

  // 創建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 });

      // 手動關閉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

      // 這一步做了之後會大量減少一次文件寫入,並可解決引用不釋放導致的內存泄露問題

      // 是本文最關鍵的決定性一步

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

        // 發送給Worker進程,並利用 Transferable Objects 提高性能

        fixWebmWorker.postMessage({

          type: 'transfer',

          data: bufSlice

        } as IWorkerPostData, [bufSlice]);

        blobSlices.push(slice);

        slice = null;

      }

      // 結束處理

      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 暫存目錄的分頁文件進行觀察,我們察覺到了明顯的內存不釋放以及文件重複生成的問題,在去除 fix-webm 邏輯後,該問題不再復現,這就說明目前的 fix-webm-metainfo 存在文件緩存未複用和文件引用未刪除的問題(這個問題後面討論)。

文件緩存複用

那麼在 ArrayBuffer 與 Blob 的轉換中,是否有一種無損,且可複用文件緩存的方式呢?這就是爲什麼 fix-webm-metainfo 在後面的迭代中,採用了複用 Blob 的方式建立修復後的 Blob,而不是直接使用 ArrayBuffer 建立 Blob 的原因。觀察下面的兩種方式生成的 Blob 有什麼區別:

// 首先創建一個Blob

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



// 讀出它的buffer

const buffer = await a.arrayBuffer();



// 方式1,實際會佔用多少內存?

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 會在內存或磁盤生成 7 份一模一樣的文件,而方式 2 不會額外生成一個文件,i 到 o 的文件均複用了 a 的 blob,在內存或磁盤中只存在一份。那麼,修復 webm 的那種方式本質上修改了文件頭部的字節,那這種方式也會複用同一個本地文件緩存麼?答案是肯定的,被修復前的 webm 和被修復後的 webm 由於差異僅在頭部,而整體的大部分區域均採用相同的 Blob slice 出來的子 blob 建立,因此空間依然是複用的。

主進程內存泄露問題

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

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文件大小大於可用內存數,且大於可用磁盤空間時,傳輸直接失敗

  if (!CanReserveQuota(total_transportation_bytes))

    return Strategy::TOO_LARGE;



  // 普通調用可忽略

  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文件大小大於可用內存空間

  if (file_paging_enabled_ &&

      total_transportation_bytes <= GetAvailableFileSpaceForBlobs() &&

      total_transportation_bytes > limits_.memory_limit_before_paging()) {

    return Strategy::FILE;

  }



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

  return Strategy::SHARED_MEMORY;

}



bool BlobMemoryController::CanReserveQuota(uint64_t size) const {

  // 同時檢查內“可用內存空間”與“可用磁盤空間”

  return size <= GetAvailableMemoryForBlobs() ||

         size <= GetAvailableFileSpaceForBlobs();

}



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

// 計算剩餘內存量

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;

}

可發現:Blob 的傳輸與儲存基本分爲三種,即:“文件”,“共享內存”,以及 “IPC”,

  1. 當文件小於 0.25MB 時優先走 “IPC” 方式傳輸

  2. 當 “可用內存空間” 大於文件體積時優先走 “共享內存” 方式傳輸

  3. 當 “可用內存空間” 不足但 “可用磁盤空間” 充足時,優先走 “文件” 方式傳輸

  4. 當 “可用內存空間” 與“可用磁盤空間”均不充足時,Blob 不會傳輸,且最終反饋到渲染進程,會報 “File not readble” 之類的報錯。

最大存儲限制

這裏引發一個問題 “可用內存空間” 與“可用磁盤空間”是如何界定的?如果計算?想到這裏,又引發我的思考,如果可用內存空間非常大,會造成什麼問題?帶着這些疑問,我們繼續研究 Chromium 的實現:

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,不是安卓,且架構是64位,則“最大可用內存大小”爲2GB

    constexpr size_t kTwoGigabytes = 2ull * 1024 * 1024 * 1024;

    limits.max_blob_in_memory_space = kTwoGigabytes;

#elif defined(OS_ANDROID)

    // 安卓,“最大可用內存”爲物理內存的1/100

    limits.max_blob_in_memory_space = static_cast<size_t>(memory_size / 100ll);

#else

    // 其他架構或,“最大可用內存”爲物理內存的1/5

    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)

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

     // 其他平臺或架構,“最大可用磁盤大小”爲物理磁盤大小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;

}

總結一下兩個指標,與 OS、Arch、Memory Size、Disk Size 都有可能有關係:

最大可用內存大小

最大可用磁盤大小

以上結論說明了什麼?我們從中發現了兩個問題:

  1. 問題 1:X64 架構的最大可用內存是 2GB,這實際上非常大了,用戶的錄屏存儲並非頻繁訪問的內容,用戶的電腦可能只有 8GB,如果這 2GB 平白被佔據實際上是很大一個浪費。

  2. 問題 2:X64 與非 X64 架構的最大可用內存並不一致。

  3. 問題 3:最大可用磁盤大小僅爲物理硬盤大小的 1/10, 以 128GB 的 SSD 硬盤爲例,即使將全部 128GB 均分配給 C 盤,那麼最大可用磁盤大小僅爲 12.8GB,不考慮其他任何 Blob 的磁盤佔用,即使用戶 C 盤有 100GB 的剩餘空間,依然逃不了錄屏文件體積被限制到 12.8GB 的尷尬。

事實真相大白,主進程並非 “內存泄露” 而是“設計如此”。

修改 Chromium

那麼我們如果將最大內存空間改小,將最大可用磁盤空間改大,是不是即可解決主進程內存佔用問題,又解決了錄屏文件體積限制兩個問題呢?答案是肯定的,修改起來也很簡單:

  // 如果物理內存數大於0
  if (memory_size > 0) {

#if !defined(OS_CHROMEOS) && !defined(OS_ANDROID)

    // 去除64位判斷邏輯,保持32位 Windows,Arm64 Mac一致的2000MB -> 200MB最大內存錄制空間邏輯修改

    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 變爲 1

    limits.desired_max_disk_space = static_cast<uint64_t>(disk_size);

#endif

  }

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

緩衝區內存釋放問題

有了上述對 Blob 文件格式的理解,我們基本可以理清錄屏功能的整個傳輸鏈路。緩衝區內存釋放問題的解法,相信大家也能想到了,在錄製過程中,未對 MediaRecorder stop 前,由於 MediaRecorder 錄製的全部數據均存儲於 Renderer 進程中,便會造成內存的異常佔用,隨着錄屏時間的增長,這部分的佔用會尤爲龐大,解決方法也很簡單,設定一個 timeslice 或定時 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' });

}



// 解法一,開始錄製時,設定timeSlice,確保每timeslice毫秒,自動觸發一次ondataavailable,輸出並清空緩衝區(非常重要)

recorder.start(timeslice);



// 解法二,錄製過程中手動requestData清空緩衝區

recorder.start();

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

渲染進程內存泄露問題

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

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

const a = new Map();



a.set('key'{

    blob: new Blob([1]) // Blob1

});



// 手動釋放

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



// 例2

let a = new Blob([]);



doSomething(a);



// 手動釋放

a = null;

Blob-Internals 觀察引用

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

Profiler 抓取堆快照

也可以利用 Profiler 抓取內存堆棧情況。

blob_storage 目錄觀察

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

後續的性能優化

當前的處理,儘管已經完美解決了一切修復問題,但存在最後一個問題,就是修復時會佔用大量內存,後續我會持續維護 fix-webm-metainfo 庫,通過不傳輸完整 ArrayBuffer 的方式,解決這個問題。

招人啦招人啦!

我們是字節跳動內容安全前端團隊

業務方向:基於人工智能的全球內容安全、圖像數據標註,支撐全球產品內容安全標註系統的架構設計,開發及優化。

技術方向:覆蓋低代碼(前後端),桌面端(Electron,C++),圖像、音視頻研發。參與維護並完善公司基於 Electron 桌面的 CI/CD 平臺。

歡迎感興趣的同學投遞實習、校招、社招簡歷,可發到:zhusida@bytedance.com

參考資料

[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

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/w8bXmcwzJjZ2WM2sclXP8A