0
点赞
收藏
分享

微信扫一扫

nodejs + ffmpeg 实现视频转动图

使用 node.js + ffmpeg 实现视频转动图接口服务,利用 child_process 执行 ffmpeg 命令行实现,理论上可以ffmpeg所有功能。

环境

  • ffmpeg​​官网下载​​
  • node​​中文网下载​​

依赖包

使用npm 安装所需的依赖包

# npm
npm install express multer
# or yarn
yarn add express multer

 

  • Express 是基于​​Node.js​​ 平台,快速、开放、极简的 Web 开发框架
  • Multer 是用于处理文件上传的中间件

搭建Https服务器

搭建服务器主要有以下作用:

  1. 上传视频文件到服务器以进行处理
  2. 处理完成后的GIF图保存在服务器的静态目录下,以便让用户访问 / 下载

// index.js
const express = require('express');
const fs = require('fs');
const path = require('path');
const http = require('http');
const https = require('https');

//static 托管静态文件 用于客户端访问gif图片
app.use('/public',express.static(path.join(__dirname,'public')));

//引入 ffmpegRouter.js
const ffmpegRouter= require('./ffmpegRouter')
app.use('/ffmpeg',ffmpegRouter);

// Configuare https
const options = {
key : fs.readFileSync('[key文件路径]'),
cert: fs.readFileSync("[pem文件路径]"),
}
http.createServer(app).listen(80); // http端口
https.createServer(options, app).listen(443); // https 端口

 

路由 ffmpegRouter.js

// ffmpegRouter.js
const express = require('express')
const router = express.Router()
const fs = require('fs')
const child = require('child_process')
const multer = require('multer')

const storage = multer.diskStorage({
destination: function(req,file,cb){
cb(null,'./uploads');
},
filename: function(req,file,cb){
// 以时间格式来命名文件,28800000为8小时的毫秒数,为了去除时区的误差
const date = new Date(Date.now()+28800000).toJSON().substring(5, 16).replace(/(T|:)/g, '-');
// 随机 0 ~ 1000 的整数,防止同一时间上传的文件被覆盖
const random = parseInt(Math.random() * 1000);
// 提取文件类型
const type = file.originalname.split('.').pop();
const filename = `${date}-${random}.${type}`
cb(null,filename);
}
});
const upload = multer({ storage })

router.post('/transform/gif', upload.single('file'), (req, res) => {
transform(req.file, req, res)
})

function transform(file, req, res) {
let { path, filename } = file;
let {
start, //开始时间
end, //结束时间
sizeLimit, //大小限制
dpi, //分辨率
framePerSecond, //每秒帧率
pts, //倍速
toning, //调色
contrast, // 对比度
brightness, // 亮度
saturation, // 饱和度
effects, // 特效
crop, // 裁剪
} = req.body;
//类型检查
let type = filename.split('.').pop();
let allowTypes = ['gif', 'mp4','avi', 'amv', 'dmv', 'mov', 'qt', 'flv', 'mpeg', 'mpg', 'm4v', 'm3u8', 'webm',
'mtv', 'dat', 'wmv', 'ram', '3gp', 'viv', 'rm', 'rmvb'];
if (!allowTypes.includes(type)) {
fs.unlink(path, () => {
console.log(`文件类型不支持:${filename} `);
});
return res.send({ err: -2, msg: '文件类型不支持' });
}

const Option = {
list: ['-ss', '-to', '-i', '-fs', '-vf', '-s', '-r', '-y'],
init() {
this.list.forEach(x => this[x] = '')
},
add(name, value) {
this[name] += (this[name] ? ',' : '') + value;
},
get(name) {
return this[name] ? `${name} ${this[name]} ` : ''
},
toString() {
return this.list.reduce(((p,c) => p + this.get(c) ),'')
}
}
Option.init()

/**
* ...配置Option 下文解释
*/

Option.add('-i', path);
let rfilen = `public/picture/gif/${filename}.gif`
Option.add('-y', rfilen);

let optionStr = Option.toString()

child.exec(`ffmpeg ${optionStr}`, function (err) {
fs.unlink(path, () => {
console.log('视频转GIF:' + filename);
console.log(optionStr);
});

if (err) {
console.error(err)
res.send({ err: -1, msg: err })
} else {
//定时删除
const mins = 60 * 3;
const limitTime = mins * 60 * 1000
const expired = +new Date() + limitTime
const stat = fs.statSync(rfilen)
setTimeout(() => {
fs.unlink(rfilen, () => {
console.log(`GIF文件:${filename} 已删除!`)
});
}, limitTime)
res.send({
err: 0,
msg: `视频转gif处理成功,有效期${mins}分钟!`,
url: `https://[服务器地址]/${rfilen}`,
size: stat.size,
expiredIn: expired,
});
}
})
}

module.exports = router

 

body数据

nodejs + ffmpeg 实现视频转动图_服务器

 

Option

const Option = {
list: ['-ss', '-to', '-i', '-fs', '-vf', '-s', '-r', '-y'],
init() {
this.list.forEach(x => this[x] = '')
},
add(name, value) {
this[name] += (this[name] ? ',' : '') + value;
},
get(name) {
return this[name] ? `${name} ${this[name]} ` : ''
},
toString() {
return this.list.reduce(((p,c) => p + this.get(c)),'')
}
}

 

Option.list

该字段的顺序就是导出字符串时的选项顺序

-ss :当用作输入选项时(在-i之前),在该输入文件中查找位置。(作为开始时间点)
-to :结束读取的时间点
-i :输入文件的地址
-fs : 设置文件大小限制,以字节表示。超过限制后不再写入字节块。输出文件的大小略大于请求的文件大小。
-vf :-filter:v的简称,创建滤波图并使用它来过滤流,本文用于修改倍速和分辨率
-s :设置帧大小,用于设置分辨率
-r :设置帧率
-y :输出文件地址,注意:重复名直接覆盖而不询问
内容参考自:​​​ffmpeg 文档​​

Option.init()

初始化设置,为 Option 添加 list 里的所有字段

Option.add(name, value)

为字段添加值,若不为空,则在前面添加 ​​","​​ 来分隔

Option.get(name)

获取某个选项的值,把 key 和 value 拼接起来,自动在尾部添加空格,若没有数据则返回空字符串

Option.toString()

利用 ​​Array.prototype.reduce()​​ 方法,按照顺序返回所有字段字符串

打印结果

 

nodejs + ffmpeg 实现视频转动图_文件路径_02

 

配置

配置的参数设置都是参考 ​​ffmpeg 文档​​​ ,若想要实现更多功能可以前往官网查阅资料。
需要注意的点:

  • 使用了​​-vf scale=...​​ 命令之后,会将视频的分辨率改变,所以crop的对应值会对应改变,具体实现逻辑放在前端实现。 后面会写一篇文章关于小程序端的实现。

//时间
if (start && end){
if (Number(start) > Number(end)) {
return res.send({ err: -4, msg: '时间参数错误' })
}
Option.add('-ss',start)
Option.add('-to',end)
}
//大小限制
if (sizeLimit && sizeLimit != '默认') {
Option.add('-fs', sizeLimit)
}
//分辨率
if (dpi) {
if (dpi == '默认') {
dpi = '480p';
}
if (dpi.endsWith('p')) {
Option.add('-vf', `scale=-2:${dpi.substr(0, dpi.length - 1)}`)
} else {
Option.add('-s',dpi)
}
}
//帧率
if (framePerSecond && framePerSecond != '默认') {
Option.add('-r', framePerSecond);
}
//倍速
if (pts && pts != '默认') {
pts = Number(pts)
pts = 1 / pts;
if (pts < 0.25) {
pts = 0.25
} else if (pts > 4) {
pts = 4
}
Option.add('-vf', `setpts=${pts}*PTS`)
}
//调色
if (contrast !== undefined || brightness !== undefined || saturation !== undefined) {
const list = []
if (contrast !== undefined) {
list.push(`contrast=${contrast}`)
}
if (brightness !== undefined) {
list.push(`brightness=${brightness}`)
}
if (saturation !== undefined) {
list.push(`saturation=${saturation}`)
}
Option.add("-vf", 'eq=' + list.join(':'));
}
if (crop) {
Option.add('-vf', `crop=${crop}`)
}
//特效
if (effects && effects != '默认') {
switch(effects){
case '边缘' : Option.add("-vf", "edgedetect=low=0.1:high=0.4");break;
case '油画' : Option.add("-vf", "edgedetect=mode=colormix:high=0");break;
case '上下切割' : Option.add("-vf", "stereo3d=abl:sbsr");break;
case '模糊' : Option.add('-vf','boxblur=2:1');break;
case '防抖' : Option.add('-vf','deshake=edge=1:search=0');break;
case '倒放' : Option.add('-vf','reverse');break;
default: break;
}
}

 

优化

但只是讲述了最基本的流程,从设计上看比较拙略,有许多可以改进的地方。本文主要从以下几个角度进行优化

  1. 抽离出视频转动图的逻辑作为一个​​RequestHandler​​ (中间件);
  2. 在该中间件的基础上新增更多接口服务;
  3. 抽离出​​Option​​​ 设计成一个​​class​​ (用于执行 ffmpeg 命令行的类);
  4. 使用​​crontab​​​ 定时执行删除任务,替代​​setTimeout​​ 定时器任务;
  5. 使用​​ffmpeg​​ 生成 全局调色板 增加画面质量

优化 - 封装 Option 为 class 对象

// ffmpegOption.js
const propertys = ['-v', '-ss', '-to', '-i', '-fs', '-crf', '-preset', '-vf', "-lavfi", '-s', '-r', '-y']

module.exports = class {
constructor () {
this.init()
}
init() {
propertys.forEach(x => this[x] = '')
}
add(name, value) {
this[name] += (this[name] ? ',' : '') + value
}
set(name, value) {
this[name] = value;
}
get(name) {
return this[name] ? `${name} ${this[name]} ` : ''
}
getValue(name) {
return this[name]
}
toString() {
return 'ffmpeg ' + propertys.reduce(((p, c) => p + this.get(c)), '')
}
}

 

由于某些场景需要,添加了 ​​set​​​ 、​​getValue​​ 方法。使用时只需要引入该模块然后实例化即可。

const ffmpegOption = require("./ffmpegOption")
const Option = new

 

关于 ​​propertys​​​ 的设置,可以参考 ​​ffmpeg 官方文档​​ 。

优化 - 视频转动图 handler

// ffmpeg.js
const fs = require('fs')
const util = require('util');
const child = require('child_process')
const exec = util.promisify(child.exec);
const ffmpegOption = require("./ffmpegOption")

function custom_transfrom(path) {
return (req, res) => {
let { filename } = req.body
const filePath = require('path').join(path, filename)
try {
fs.statSync(filePath)
} catch {
return res.send({ err: -4, msg: 'File Not Found'})
}
req.file = {
filename,
path: filePath,
}
transform(req, res)
}
}

async function transform(req, res) {
// ......
}

module.exports = {
transform,
custom_transfrom,
}

 

该模块里两个方法:

  • ​transform​​ :转换动图的方法。
  • ​custom_transform​​​ :高阶函数,接受一个路径参数,返回一个自定义文件路径的 hander,把文件信息挂载在​​req.file​​​ 然后调用​​transform​​ ,用于处理服务器本地文件的文件,无需用户上传。

用 ​​util.promisify​​​ 方法把 ​​child_process.exec​​​ 方法转换成 ​​promise​​ ,减少回调函数的嵌套。

需要注意的是:"如果调用 ​​exec​​​ 方法的 ​​util.promisify()​​​ 版本,则返回 ​​Promise​​​(会传入具有 ​​stdout​​​ 和 ​​stderr​​​ 属性的 ​​Object​​​)。 返回的 ​​ChildProcess​​​ 实例会作为 ​​child​​​ 属性附加到 ​​Promise​​​。 如果出现错误(包括导致退出码不为 0 的任何错误),则返回 reject 的 promise,并传入与回调中相同的 ​​error​​​ 对象,但是还有两个额外的属性 ​​stdout​​​ 和 ​​stderr​​​。" ( 参考: ​​node 官方文档​​ )

优化 - 路由调用

const express = require('express')
const router = express.Router()
const upload = require('../../util/multer')
const ffmpeg = require('./ffmpeg');

// 上传视频 -> 转成GIF
router.post('/gif', upload.single('file'), ffmpeg.transform)
// 本地视频转换成GIF (无需上传)
router.post('/gif-temp', ffmpeg.custom_transfrom('uploads'))
// 本地动图转换成GIF (GIF修改)
router.post('/gif-local', ffmpeg.custom_transfrom('public/picture/gif'))

module.exports = router
upload 是中间件 multer 构造出的 multer 对象。

/gif :upload.single() 上传文件,再调用 ffmpeg.transform 生成成 gif。

/gif-temp :用户调用 /gif上传视频文件之后,再次视频转动图时无需再次上传,只需调用该接口从上传文件夹找到对应的文件转换就行了。

/gif-local :与上面的类似,只是切换了文件夹路径,在生成的 gif 里找到对应的文件,再根据 body 数据把 gif 转换成对应格式。

 

优化 - 使用全局调色板提升动图质量

此处参考:
​​​Linux公社 - 使用 FFmpeg 处理高质量 GIF 图片​​

// ffmpeg.js transform funciton
// 调色板图片路径
PalettePicPath = `tmp/palette-${filename}.png`;
await exec(`ffmpeg ${Option.get('-ss') + Option.get('-to')} -i ${path} -vf palettegen -y ${PalettePicPath}`)
.catch(err => {
console.error("全局调色板生成错误:", err);
})
Option.add('-i', PalettePicPath)
Option.add('-lavfi', Option.getValue('-vf'))
Option.add('-lavfi', `paletteuse`)
Option.set('-vf', '');

 

​-ss​​​ 和 ​​-to​​​ 指定开始和结束时间,以减少生成的时间,在 ​​-vf​​​ 添加 ​​"palettegen"​​ 。这个滤波器对每一帧的所有颜色制作一个直方图,并且基于这些生成一个调色板。

 

nodejs + ffmpeg 实现视频转动图_上传_03

调色板文件

将调色板作为输入源需要指定两个​​-i​​​ (一个源文件、一个调色板文件), ​​-vf​​​ 的配置需要换成 ​​-lavfi​​​ 以配置全局的滤波 (同​​-filter_complex​​​),并同样在后边添加 ​​"paletteuse"​​。

因为要指定两个输入源,所以在后面配置 ​​-i​​ 时需要这样:

// -i input.mp4 -i palette.png

 

​path​​​ 为源文件路径,​​Option.get('-i')​​ 为上文添加的配置。

这是为什么 ffmpegOption.js 中要添加 ​​set()​​​ 和 ​​getValue()​​ 的原因,实现的方法有很多种,这里选择了最省事的方法

Crontab 定时删除文件

上回使用 ​​setTimeout​​ 来执行定时任务,当访问量较大时,由于闭包导致内存泄露,会让服务器性能下降,是一个不靠谱的设计。

使用 crontab,每小时执行一次 查找并删除两小时前的 gif 和 mp4 文件。

crontab -e
0 * * * * find /root/miniprogram/ -regex ".+\.\(gif\|mp4\)$" -mmin +120 -exec rm {} \;

 

exec 执行命令

去掉了 ​​child_process.exec​​​ 的回调函数,和 ​​setTimeout​​ 函数,整个世界变得很清静。

转换后删除配色板文件,再返回即可。

// ffmpeg.js transform funciton
const ffmpegCommand = Option.toString()

await exec(ffmpegCommand)
.catch(err => {
console.error(err)
res.send({ err: -1, msg: 'exec error' })
})
PalettePicPath && fs.unlink(PalettePicPath, () => {
console.log("配色板文件清除")
})

const expired = +new Date() + 3 * 60 * 60 * 1000
const stat = fs.statSync(rfilen)
res.send({
err: 0,
msg: `ok`,
url: `https://${config.host}/${rfilen}`,
size: stat.size,
expiredIn: expired,
});

 





 



举报

相关推荐

0 条评论