FFMPEG 功能非常强大,不仅能转换视频格式、压缩视频、添加字幕等,还能录制屏幕内容。使用 FFMPEG 录屏的方法很简单:
# Linux
ffmpeg -video_size 1024x768 -framerate 25 -f x11grab -i :0.0+100,200 output.mp4
# macOS
ffmpeg -f avfoundation -list_devices true -i ""
# Windows
ffmpeg -f gdigrab -framerate 30 -offset_x 10 -offset_y 20 -video_size 640x480 -show_region 1 -i desktop output.mkv
更详细的介绍可以参考官方文档,这里不做太多摘抄。
使用 Node.js child_process 可以调用外部程序执行操作,详细的用法可以参考官方文档。大概来说,分为:
exec
/execFile
调用程序,当执行完成,一次性获得结果spawn
调用程序,过程中流式获得输出
我的录屏对时间点有非常高的要求,力求准确。所以我只能选择 spawn
,然后通过检查日志输出得到准确的录制开始时间。
所以,我的代码大概如此:
class FfmpegRecorder {
// 其它函数略去
startRecording(args) {
this.isFileReady = new Promise((resolve, reject) => {
this._isFileReadyResolve = resolve;
this._isFileReadyReject = reject;
});
this.recording = spawn(this.ffmpeg, args);
this.recording.stdout.on('data', this.onStdData);
this.recording.stderr.on('data', this.onStdErr);
}
stopRecording() {
this.recording.kill('SIGINT');
const path = resolve(process.cwd(), 'a.mp4');
return path;
}
onStdData(data) {
data = data.toString();
this.stdout += data;
if (this.stdout.indexOf('Output #0, mp4, to \'a.mp4\'') !== -1) {
this._isFileReadyResolve();
this._isFileReadyReject = null;
}
}
onStdErr(data) {
data = data.toString();
this.stderr += data;
if (this.stderr.indexOf('Output #0, mp4, to \'a.mp4\'') !== -1) {
this._isFileReadyResolve();
this._isFileReadyReject = null;
}
}
}
根据我在命令行里直接跑 ffmpeg
的结果,它会先初始化,然后开始录屏。录屏开始时,会输出 Output #0, mp4, to xxx.mp4
这样的日志,所以我就会反复检查 stdout
和 stderr
,直到关键日志出现,然后告诉外面的程序开始录制了。
这里比较奇怪的是,日志输出应该是正常的,走 stdout
通道,结果只能从 stderr
通道获取。我为防万一,两边都留下了同样的代码。可能我对 Linux 理解不够,将来再研究一下为什么会这样吧。
上面的代码忽略了 onError
和 onExit
部分,有兴趣的同学请等我开源(公司代码)。
在 Linux,stopRecording()
,即 kill('SIGINT')
(相当于按下 Ctrl+C)之后,FFMPEG 会终止录屏,并且生成可以播放的视频文件。但是在 Windows,只会留下一个无法播放的文件。
通过观察命令行直接运行 ffmpeg
的结果和 node.js 保存的结果,我发现缺失了结束录制后处理视频文件的部分。实际上 FFMPEG 录屏时只会记录视频内容,录制结束后才会生成影片的meta 信息,而播放器必须读取后者才可以正常播放。所以我猜测在 Windows 下 kill('SIGINT')
会直接彻底杀死进程,而不仅仅是发送一个信号给 FFMPEG,并让它完成后续的工作。
做出判断后,我尝试用按下 q 的方式通知 FFMPEG 停止工作,并等待 5s。果然成功,于是我构建了新类,继承前面的 FfmpegRecorder
,大概代码如下:
const endTag = /kb\/s:\d+.\d{2}/m;
class WindowsRecorder extend FfmpegRecorder {
stopRecording() {
// 向 child process 输入 `q`
this.recording.stdin.setEncoding('utf8');
this.recording.stdin.write('q');
const p = new Promise((resolve, reject) => {
this._stopResolve = resolve;
this._stopReject = reject;
});
// 设置一个超时保护 15s
setTimeout(() => {
this._stopReject();
}, 15E3);
return p;
}
onStdData(data) {
super.onStdData(data);
if (this._stopResolve && endTag.test(this.stdout)) {
const path = resolve(process.cwd(), 'a.mp4');
this._stopResolve(path);
}
}
onStdErr(data) {
super.onStdErr(data);
if (this._stopResolve && endTag.test(this.stderr)) {
const path = resolve(process.cwd(), 'a.mp4');
this._stopResolve(path);
}
}
}
为了功能完善,我没有选择等待固定的时间,而是继续检查日志,直到发现 endTag
标记。另外,我也留下了 15s 超时保护,避免某些暂时没遇到的问题破坏功能。
至此,功能基本稳定完成。
欢迎吐槽,共同进步