fluent-ffmpeg + worker实现视频切片合成视频


fluent-ffmpeg + worker实现切片合成视频

前因

最近发现之前在B站下载的视频,有一些突然变成大会员才能看了。(我下载的时候,还是都能看的。把我下载的文件给加密了,想逼我充大会员,这谁忍得了)。于是,决定把之前下载的文件都给保存到自己的硬盘中。但是量有点小大,20G。所以就排除了用网上的下载B站视频的方法。于是上网搜索了一下,然后发现了音视频开发库中的王者ffmpeg

ffmpeg极简使用

B站下载视频的地址:Android\data\tv.danmaku.bili\download

1
ffmpeg -i 0.blv -c copy o1.mp4

1
ffmpeg -i video.m4s -i audio.m4s -c copy o2.mp4

上面两种都只是只有一个视频文件,其中第二种是视频和音频分开的。

所以接下来就来试一下多个文件的。

1
ffmpeg -f concat -i file.txt o.mp4

file.txt是一个文件列表,依次存储输入文件:

1
2
3
4
5
file 0.blv
file 1.blv
file 2.blv
file 3.blv
file 4.blv

fluent-ffmpeg

fluent-ffmpeg对FFmpeg的命令行进行了封装,抽象为我们常用的方法和API。

下面就稍微解释一下本次懒人工具用到的几个方法。

input():指定输入(不仅可以指定视频,也可以指定音频)

1
2
3
ffmpeg()
.input('input1.avi')
.input('input2.avi');

save():将输出保存到文件

1
2
3
ffmpeg()
.input('input.avi')
.save('output.mp4');

mergeToFile():连接多个输入

1
2
3
4
ffmpeg()
.input('input1.avi')
.input('input2.avi')
.mergeToFile('output.mp4');

save()mergeToFile()的区别就是mergeToFile()是当有多个视频文件需要合成时,连接,而save()则是一个视频(也可以包括音频文件)。

实现代码

代码可能写的有点拉。勿喷。

入口文件index.js

这里引入了一个findDeepest方法。就是用来递归出当前目录以及子目录下的所有文件。

1
2
3
4
5
6
const path = require('path');
const { findDeepest, createVideoDir } = require('./utils.js')

// 创建Video文件夹
createVideoDir();
findDeepest(path.join(__dirname));

工具文件utils.js

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
111
const fs = require('fs');
const path = require('path');
const {
Worker
} = require('worker_threads');

let dirName = '';
let fileName = '';

// 合成视频的数组:用于Worker
const generateData = [];

const detailsJson = 'entry.json';

function findDeepest(way) {
/*
* 1. 首先获取递归下的文件列表(包括文件和文件夹)。
* 如果没有文件夹,就调用`generateMp4`方法生成mp4。
*/
const files = fs.readdirSync(way);

if (files.every(file => !fs.statSync(path.join(way, file)).isDirectory())) {
generateMp4(way, files);

return;
}

/*
* 2. 如果文件列表里包含`entry.json`文件,
* 那么就调用`getDirAndFileName`方法获取文件夹名、文件名(用全局变量来存储)
*/
if (files.includes(detailsJson)) {
getDirAndFileName(path.join(way, files[files.indexOf(detailsJson)]));
}

/*
* 3. 遍历文件列表,如果是文件夹,并且不是`node_modules`,则递归调用`findDeepest`方法
*/
files.forEach(file => {
if (fs.statSync(path.join(way, file)).isDirectory() && file != "node_modules") {
findDeepest(path.join(way, file));
}
})

/*
* 4. 如果files包含package.json,证明递归完了,并且回退到最初的起点了。
* 这个时候开启Worker来真正合成视频
*/
if (files.includes('package.json')) {
let count = Math.ceil(generateData.length / 10);

while (generateData.length > 0) {
const workerData = generateData.splice(0, count);

const worker = new Worker('./worker.js');
worker.postMessage(workerData);

worker.on('message', (msg) => {
if (msg === 'done') {
worker.terminate();
}
})
}
}
}

// 获取视频文件夹名称、文件名称
function getDirAndFileName(filePath) {
let fileData = fs.readFileSync(filePath, 'utf8');

dirName = JSON.parse(fileData).title;
fileName = JSON.parse(fileData).page_data.part;
}

// 生成Mp4
function generateMp4(way, files) {

let types = ['blv', 'm4s'];

files = files.filter(file => {
return types.some(type => file.endsWith(type));
})


if (files.length === 0) {
return;
}

files.sort((a, b) => {
return (+a.split('.')[0]) - (+b.split('.')[0]);
})

generateData.push({
way,
files,
dirName,
fileName
});
}

// 创建Video文件夹
function createVideoDir() {
if (!fs.readdirSync(path.join(__dirname)).includes('Video')) {
fs.mkdirSync(path.join(__dirname, 'Video'));
}
}

module.exports = {
findDeepest,
createVideoDir
}

解析:

  1. 首先获取递归下的文件列表(包括文件和文件夹)。如果没有文件夹,就调用generateMp4方法生成mp4

  2. 如果文件列表里包含entry.json文件,那么就调用getDirAndFileName方法获取文件夹名、文件名(用全局变量来存储)

  3. 遍历文件列表,如果是文件夹,并且不是node_modules,则递归调用findDeepest方法

  4. 如果files包含package.json,证明递归完了,并且回退到最初的起点了。这个时候开启Worker来真正合成视频。这里的做法是:将前面调用generateMp4方法时,存起来的生成视频的数据数组分成10组。分别开启10个Worker,每个Worker负责生成1组的视频。如果收到done的消息,则调用worker.terminate()终止worker。

utils.js下的方法简单介绍

getDirAndFileName:因为视频的信息时存在entry.json文件中的,所以需要获取该文件下的信息。这里本人用的是title属性作为文件夹名称,page_data.part作为文件名称。

1
2
3
4
5
6
7
// 获取视频文件夹名称、文件名称
function getDirAndFileName(filePath) {
let fileData = fs.readFileSync(filePath, 'utf8');

dirName = JSON.parse(fileData).title;
fileName = JSON.parse(fileData).page_data.part;
}

generateMp4:首先过滤,获取视频格式、音频格式的文件。之后调用sort方法排序。最后给合成视频的数组generateData添加数据。

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
// 生成Mp4
function generateMp4(way, files) {

let types = ['blv', 'm4s'];

files = files.filter(file => {
return types.some(type => file.endsWith(type));
})


if (files.length === 0) {
return;
}

files.sort((a, b) => {
return (+a.split('.')[0]) - (+b.split('.')[0]);
})

generateData.push({
way,
files,
dirName,
fileName
});
}

createVideoDir:创建Video文件夹。如果不存在Video文件夹,那就创建。

1
2
3
4
5
6
// 创建Video文件夹
function createVideoDir() {
if (!fs.readdirSync(path.join(__dirname)).includes('Video')) {
fs.mkdirSync(path.join(__dirname, 'Video'));
}
}

worker.js

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
const fs = require("fs");
const path = require("path");

const { parentPort } = require("worker_threads");

const ffmpegInstaller = require("@ffmpeg-installer/ffmpeg");
const ffprobeInstaller = require("@ffprobe-installer/ffprobe");

const ffmpeg = require("fluent-ffmpeg");
ffmpeg.setFfmpegPath(ffmpegInstaller.path);
ffmpeg.setFfprobePath(ffprobeInstaller.path);

const disbleReg = /\/|:|\*|\?|"|<|>|\|/g;

let dirName = "";
let fileName = "";

parentPort.on("message", async (workerData) => {

for (let i = 0, len = workerData.length; i < len; i++) {

let data = workerData[i];

dirName = data.dirName.replace(disbleReg, "-");
fileName = data.fileName.replace(disbleReg, "-");

createDir();

await createFile(data.way, data.files);

if (i === len - 1) {
// worker执行结束通知主进程
parentPort.postMessage("done");
}
}
});

// 创建次级文件夹
function createDir() {
const files = fs.readdirSync(path.join(__dirname, "Video"));

if (!files.includes(dirName)) {
fs.mkdirSync(path.join(__dirname, "Video", dirName));
}
}

// ffmpeg合成视频
function createFile(way, files) {
return new Promise((resolve, reject) => {
// 如果原本就有这个文件了
if (fs.readdirSync(path.join(__dirname, "Video", dirName)).includes(`${fileName}.mp4`)) {
resolve('done');
return;
}

let ff = ffmpeg();

ff.on("end", () => {
console.log('end');
resolve('done');
});

for (const file of files) {
ff.input(path.join(way, file));
}

console.log(path.join(__dirname, "Video", dirName, `${fileName}.mp4`));

if (files[0].endsWith("blv") && files.length > 1) {
ff.mergeToFile(path.join(__dirname, "Video", dirName, `${fileName}.mp4`));
} else {
ff.save(path.join(__dirname, "Video", dirName, `${fileName}.mp4`));
}
});
}

本项目添加了@ffmpeg-installer/ffmpeg@ffprobe-installer/ffprobe库。它们能为当前平台安装ffmpeg二进制文件,这样子的话,还能够在多个环境中使用(包括远程环境)。所以不需要在电脑安装ffmpeg,并且设置环境变量,但是需要先调用ffmpeg.setFfmpegPathffmpeg.setFfprobePath设置路径。

解析:

  1. worker引入parentPort,监听主线程的信息。遍历数据,将不合法符号修改为’-‘。

  2. 创建文件夹、创建文件。如果数据遍历完了,通知主线程停止该Worker线程。

这里再讲一下实际合成视频的部分。

原理很简单,就是遍历文件,调用input()方法来添加输入。然后判断是不是有多个视频文件,如果是,则调用mergeToFile()。否则,调用save()。并且通过添加end事件的回调函数和Promise来实现,如果合成完毕,才进行下一个视频的合成。这样子就能做到,只有10个线程在合成视频。

实现效果:

总结

递归获取所有视频信息,平均分给10个Worker,开启Worker实际执行合成视频操作。

仓库地址

有需要可以查看整个部分的代码:运行只需要使用node index.js命令即可。需要合成的视频文件夹就放在项目根目录中,运行完成后,会在根路径生成一个Video文件夹。

GitHub - 13535944743/bilibili_ffmpeg

我的博客即将同步至腾讯云开发者社区,邀请大家一同入驻:https://cloud.tencent.com/developer/support-plan?invite_code=2q6bhd3w8qm8o


文章作者: 赤蓝紫
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 赤蓝紫 !
评论
  目录