在Electron/NodeJS中录制视频并实时展示/推流

2,675 阅读6分钟

完整代码案例在文尾,可直接跳转获得。

Electron是一个使用NodeJS来调用原生操作系统API的应用程序开发框架。代码都是JavaScript或TypeScript写的。那么如何在Electron上录制视频和展示视频呢?

方案一:不在Electron体系内拍摄

调用常用的框架和库,例如OpenCV。我们最开始的方案是建一个Flask服务器,并用Python写一个OpenCV的读取摄像头的程序。在客户端程序启动的时候也启动这一个后端服务器。然后弹出OpenCV的imshow自带窗口来预览。控制录制播放的控件则依然放在Electron窗口之中。这样做的好处是,如果你之前就会用Python和OpenCV,可以很快的实现录制和展示的功能。缺点则是整个应用程序呈现一个割裂感。

方案二:Electron和ffmpeg结合

虽然Electron底层是一个chromium浏览器,可以通过浏览器的API调用相机。但是这样的相机参数不可调节,如果你需要多个相机,并且相机的帧率较高。最好还是通过更加底层代码所编写的工具来做。例如ffmpeg或者opencv(opencv是基于ffmpeg的,不过可以做一些预处理计算)。

ffmpeg是一个非常非常强大的音视频编解码开源软件。它本身是用C写的,也有提供wasm(在浏览器环境中运行的版本)。我们这次选择简单一点直接使用fluent-ffmepgnode-ffmpeg-installer来作为高级接口调用ffmpeg的exe可执行文件。

node-ffmpeg-installer的作用是安装一个ffmpeg,然后告诉我们这个程序的路径和版本信息。在后续打包的过程中会有一个bug(下述代码已经解决该bug)。

环境部署

npm install --save @ffmpeg-installer/ffmpeg
npm install fluent-ffmpeg

然后在自己的js文件中使用:

const ffmpegPath = require('@ffmpeg-installer/ffmpeg').path.replace('app.asar', 'app.asar.unpacked'); // 避免在Electron打包的时候找不到asar之外的ffmpeg路径。
const ffmpeg = require('fluent-ffmpeg');
ffmpeg.setFfmpegPath(ffmpegPath);

fluent-ffmpeg指令构建

ffmpeg是一个管道式的工具。通过叠加一层一层的处理最后达到自己想要的结果。因为这个方案的目的是为了能够在保存录像的同时有一路视频流到前端<video>标签播放,而chromium因为版权的问题默认只能播放H.264、VP8、Theora。(如果你感兴趣重新编译自带的ffmpeg从而支持所有常用视频格式可以看看这个。)

我的ffmepg的指令如下

let bufferStream = new stream.PassThrough();  // 新建一个队列的stream
let instance = ffmpeg()
	.input('video=USB GS CAM') // 输入设备的名称,下面是windows独有的dshow功能。如果是mac或者linux平台,只需要输入/dev/video*之类的地址
	.inputOption('-f', 'dshow')
	.output(saveVideoPath) // 第一个输出文件,在这之前也可以做一下转码
	.videoCodec('copy') // 直接将视频输出的流保存下来
	.output(bufferStream) //将第二路输出到之前新建的stream。
	.videoCodec(videoCodec) //因为electron默认支持的格式有限,在这里选择libx264
	.format('mp4')
	.outputOptions(
	    '-movflags', 'frag_keyframe+empty_moov+faststart',
	    '-g', '18')
	.on('progress', function(progress) {
		//程序进行时的回调
	    console.log('time: ' + progress.timemark);
	})
	.on('error', function(err) {
			//ffmpeg出错时的回调
	    console.log('An error occurred: ' + err.message);
	})
	.on('end', function() {
			//ffmpeg收到停止信号并安全退出时的回调
	    console.log('Processing finished !');
	})
instance.run() //双路时需要添加一个run来执行ffmpeg命令

这时ffmpeg执行保存文件并把一路H264的输出流放到了bufferStream之中 。在后面我们将会建立一个简单的http server来将输出流传给前端界面。

建立服务器

NodeJS自带http server功能。我们通过下面指令来建立一个服务。

const http = require('http')
let server = http.createServer((request, response) => {
								//... ffmpeg指令既可以放到这,也可以放到之前,只要能够获取到bufferStream就可以
                bufferStream.pipe(response);
            })
            server.listen(8889);

这样的服务是非常简单直接的绑定一个闲置端口。在示例里是8889端口。请注意不要和电脑上其他端口冲突。

优雅结束服务器和ffmpeg

当我们开始录制视频并录制了一段事件后,想要关闭服务,可Electron环境并不是命令行,我们无法直接地使用ctrl+c来关闭ffmpeg。关闭服务器和ffmpeg是一个需要仔细处理的问题。否则很可能没有真正的关闭释放端口资源和导致视频文件损坏。

fluent-ffmpeg提供kill() 来直接发送信号给后台真实的ffmpeg执行文件。但是直接调用kill()后保存下来的mp4文件是打不开的,文件直接损坏。kill()可以接受字符串类型的终止信号,我尝试过使用SIGSTOP 依然不工作。后来查找github issue后发现很多人使用instance.fmpegProc.stdin.write("q\n") 来向ffmpeg真正进程的stdin输入q来结束。的确可行。

NodeJS的http server没有直接马上销毁的功能。因为socket还需要完成其生命周期。要么自己去调用所有socket的close()函数,要么使用第三方包来帮我们做这个事情。两者我都尝试过了,为了代码的简介性最后选用http-shutdown 这个第三方库。

npm insatll http-shutdown

然后如下调用

let server = http.createServer((request, response) => {
								//...
                bufferStream.pipe(response);
            })
            server= require('http-shutdown')(server);
            server.listen(8889);

当需要关闭服务器的时候执行下面代码即可。

server.shutdown(function(err) {
            if (err) {
                return console.log('shutdown failed', err.message);
            }
            console.log('Everything is cleanly shutdown.');
        });

VideoJS前端设置

VideoJS 是一个被非常广泛使用的前端视频框架。它对html本身的进行封装并对象化。

本项目前端使用的框架是Vue3,故将VideoJS和Vue结合起来调用,使得代码更加简洁优雅。

下面是一个例子,可以看到前端调用停止播放是调用了一个ipcRenderer并发送了stopRecord信号。那么后端主进程的代码接口为:

ipcMain.on("stopRecord", function (event, arg) {
    console.log("stopRecord", arg);
    if (!server) {
      console.log("To HTTPServer didn't exist, so ignore stop command");
      return;
    }
		instance.ffmpegProc.stdin.write("q\n");
		server.shutdown(function(err) {
            if (err) {
                return console.log('shutdown failed', err.message);
            }
            console.log('Everything is cleanly shutdown.');
    });
    server.on('close', function () {
      console.log('子进程关闭了')
    })
    server.on('exit', function () {
      console.log('子进程退出了')
    })
  });

前端Vue代码为

<template>
<video ref="videoPlayerTop" class="video-js"></video>
</template>
<script lang="ts">
import videojs from 'video.js';
let ipcRenderer = require('electron').ipcRenderer;
export default { //使用选项式api
	data: () => {
		videoOptionsTop: {
        autoplay: false,
        controls: false,
        width: 800,
        height: 400,
        preload: 'metadata',
        sources: [
            {
                src: '<http://127.0.0.1:8889>',
                type: 'video/mp4'
            }
        ],
        techOrder: ['StreamPlay']
    },
	},
	mounted() {
    this.playerTop = videojs(this.$refs.videoPlayerTop, this.videoOptionsTop, () => {
        this.playerTop.log('onPlayerReady', this);
    });
    this.playerSide = videojs(this.$refs.videoPlayerSide, this.videoOptionsSide, () => {
        this.playerSide.log('onPlayerReady', this);
    });
   },
	beforeUnmount() {
    console.log("router leave")
    if (this.playerTop) {
        this.playerTop.dispose();
    }
    if (this.playerSide) {
        this.playerSide.dispose();
    }
    this.stopRecording();
  },
	methods: {
		stopRecording() {
      ipcRenderer.send("stopRecord")
      this.cameraflag = true
    },
	}
}
</script>
<style lang="">
    
</style>

到这里就完成了同时录制视频(即使是高帧率也可以)同时在前端播放的功能。那么以下是进阶内容,若你想录制不止一路视频时可以参考。

多进程录制

理论上调用ffmpeg是新建了一个进程来执行。那么如果我们创建多个fluent-ffmpeg对象是否是多个进程呢?在实践中发现,情况不是这样的。当同时创建多个fluent-ffmpeg对象进行录制的时候。本来2个摄像头的帧率都减半了。而且前端接口出现卡顿现象。也可能是http server没有多进程的原因。这里提供一个解决方案,将每一路录制和httpserver放入一个单独的文件,利用fork()函数传参并显式创建新的子进程。

代码在 gist中

Run ffmpeg recording inside electron with vue

参考资料

nodejs中server.close()的使用_maindek的博客-CSDN博客_node server.close

github.com/fluent-ffmp…

fluent-ffmpeg详解

github.com/ziyang0116/…

Advanced example | Video.js

mengnn.cn/ft11/

videojs播放器插件使用详解