文章目录
  1. 1. 应用场景
  2. 2. 方案
  3. 3. 相关代码

应用场景

在做一个在线教育项目的时候,有一个需求是要求对页面进行录制,有很多在线教育或者视频直播服务提供商基本都是在服务器端录制的,
有一些支持客户端录制的基本也是采用的BS架构,很少有直接在网页进行的,应该是网页录制不稳定因素比较多,比如说对客户端硬件的
要求,页面刷新带来的影响等等.但我还是做了一些尝试.

方案

前面三种方案都用到了WebRTC,试了一下Electron提供的desktopCapture,发现用这种方法是无法获取系统输出设备(耳机,扬声器)声音的,参考github上这个issue.也就是用WebRTC的getUserMedia方法都会有这个毛病.但是有些chrome的插件,像RecordRTCScreencastify这些是可以录制耳机声音的,应该是做了一些扩展(RecordRTC是开源的,有空可以看看).
ffmpeg提供了捕获桌面的方法,试了一下windows下,可以捕获指定的窗口,效果还可以,就是CPU占用率有点高.

相关代码

  • Electron desktopCapture
    用这种方法的时候,如果只录制单独窗口,无法录制声音(参考)

    To capture video from a source provided by desktopCapturer the constraints passed to navigator.mediaDevices.getUserMedia must include chromeMediaSource: ‘desktop’, and audio: false.

这样的话如果要获取页面上的video/audio元素的音频,可以尝试用captureStream方法获取元素的流,然后从中提取音轨,再用Web Audio Api将其混合.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
function startRecord() {
electron.desktopCapturer.getSources({types: ['window', 'screen']}, (error, sources) => {
if (error) throw error
for (let i = 0; i < sources.length; ++i) {
if (sources[i].name === "foo") {
navigator.mediaDevices.getUserMedia({
audio: false,
video: {
mandatory: {
chromeMediaSource: 'desktop',
chromeMediaSourceId: sources[i].id,
minWidth: 1280,
maxWidth: 1280,
minHeight: 720,
maxHeight: 720
}
}
}).then((stream) => handleStream(stream))
.catch((e) => handleError(e))
return
}
}
});
}

function handleStream(stream) {
//需要electron desktopCapture录制指定窗口的话,无法录制声音,所以再次调用getUserMedia获取声音
navigator.mediaDevices.getUserMedia({audio: true, video: false}).then(function(mediaStream){
var audioTracks = mediaStream.getAudioTracks();
//add video and audio sound
// 获取页面元素video和audio
var medias = $("audio,video");
for (var i = 0; i < medias.length; i++) {
//这里需要在创建BrowserWindow对象的时候将experimentalFeatures设置为true,否则无法调用captureStream
var tmpStream = medias[i].captureStream(); // mainWindow = new BrowserWindow({webPreferences: {experimentalFeatures: true} })
if(tmpStream) {
//获取音轨
var tmpTrack = tmpStream.getAudioTracks()[0];
audioTracks.push(tmpTrack);
}
}

// mix audio tracks
//将音轨加入stream
if(audioTracks.length > 0){
var mixAudioTrack = mixTracks(audioTracks);
stream.addTrack(mixAudioTrack);
}

stream.addTrack(audioTrack);
recorder = new MediaRecorder(stream);
recorder.ondataavailable = function(event) {
// deal with your stream
};
recorder.start(1000);
}).catch(function(err) {
//console.log("handle stream error");
})
}

//用Web Audio Api合成将音轨混合,因为如果有多个音轨的话,最终录制的视频中默认只取第一条.
function mixTracks(tracks) {
var ac = new AudioContext();
var dest = ac.createMediaStreamDestination();
for(var i=0;i<tracks.length;i++) {
const source = ac.createMediaStreamSource(new MediaStream([tracks[i]]));
source.connect(dest);
}
}
return dest.stream.getTracks()[0];
}

  • FFMPEG
    这种方法的好处是可以结合electron的线程机制(ipcMain/ipcRenderer),使录制在单独的线程中进行,从而不受网页刷新的影响.
    ffmpeg在不同操作系统中使用的命令不一样,以下是windows的尝试.
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    92
    93
    94
    95
    96
    97
    98
    99
    100
    101
    102
    103
    104
    105
    106
    107
    108
    109
    110
    const electron = require('electron')
    const app = electron.app

    const ffmpegPath = require('ffmpeg-static');
    const ffdevices = require('ffdevices');
    const child_process = require('child_process');

    const fs = require("fs");
    const path = require('path')
    const userDataPath = app.getPath("userData");
    const recordPath = path.join(userDataPath, 'records');

    console.log(ffmpegPath.path);
    ffdevices.ffmpegPath = ffmpegPath.path;
    ffdevices.gdigrab = false;
    AppCapture = function() {
    var ctx = this;

    this.captureProcess = null;
    this.fileName = null
    this.isCapturing = false;

    this.setFile = function(fileName) {
    this.fileName = fileName;
    }

    this.startCapture = function() {
    console.log('enter start capture');
    ffdevices.getAll(function(error, devices) {
    if(!error) {
    var args = [];
    var audioCount = 0;

    //添加音频设备
    for(var i=0;i<devices.length;i++) {
    if(devices[i].type == 'audio' && devices[i].deviceType == 'dshow'){

    audioCount = audioCount + 1;
    var audioArgs = ['-f', 'dshow', '-i', `audio=${devices[i].name}`];
    args = args.concat(audioArgs);
    }
    }
    //创建保存路径
    if (!fs.existsSync(recordPath)){
    fs.mkdirSync(recordPath);
    }

    //这里-q:v -q:a表示视频的质量,值越底,质量越高,详见ffmpeg文档
    var fullPathName = path.join(recordPath, ctx.fileName);
    var videoArgs = [
    '-y',
    '-f', 'gdigrab',
    '-i', 'title=monkey100',
    '-framerate', '100',
    '-vf', "fps=30",
    '-video_size', '720x480',
    '-q:v', '10',
    '-q:a', '100',
    '-draw_mouse', '1',
    '-t', '00:20:00', //max duration 20 miunites
    fullPathName
    ];
    args = args.concat(videoArgs);

    if(audioCount > 1) {
    var filter_complex_arg = '';
    for(var j=0;j<audioCount;j++) {
    filter_complex_arg += `[${j}:a]`;
    }
    filter_complex_arg += ` amerge=inputs=${audioCount}`;
    args = args.concat([
    '-filter_complex', filter_complex_arg,
    //"-c:a", "pcm_s16le"
    '-map', `${audioCount}`,
    '-map', '[a]'
    ]);
    }

    console.log('start recording');
    ctx.captureProcess = child_process.spawn(ffmpegPath.path, args);
    ctx.isCapturing = true;
    console.log('recording started');

    ctx.captureProcess.stderr.on('data', (data) => {
    console.log(`error: ${data}`);
    });

    // 15 minutes
    setTimeout(function() {
    ctx.stopCapture();
    }, 1000 * 60 * 15);

    } else {
    console.log(`get devices error: ${error}`);
    }
    });
    }

    this.stopCapture = function() {
    console.log('stoping');
    if(this.isCapturing && this.captureProcess){
    //停止录制
    this.captureProcess.stdin.write('q');
    this.isCapturing = false;
    }
    }

    }

    module.exports= AppCapture;
文章目录
  1. 1. 应用场景
  2. 2. 方案
  3. 3. 相关代码