分类
nodejs

使用 Node.js 驱动 FFMPEG 录屏

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 可以调用外部程序执行操作,详细的用法可以参考官方文档。大概来说,分为:

  1. exec/execFile 调用程序,当执行完成,一次性获得结果
  2. 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 这样的日志,所以我就会反复检查 stdoutstderr,直到关键日志出现,然后告诉外面的程序开始录制了。

这里比较奇怪的是,日志输出应该是正常的,走 stdout 通道,结果只能从 stderr 通道获取。我为防万一,两边都留下了同样的代码。可能我对 Linux 理解不够,将来再研究一下为什么会这样吧。

上面的代码忽略了 onErroronExit 部分,有兴趣的同学请等我开源(公司代码)。

在 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 超时保护,避免某些暂时没遇到的问题破坏功能。

至此,功能基本稳定完成。