使用 node.js + ffmpeg 实现视频转动图接口服务,利用 child_process 执行 ffmpeg 命令行实现,理论上可以ffmpeg所有功能。
环境
依赖包
使用npm 安装所需的依赖包
# npm
npm install express multer
# or yarn
yarn add express multer
- Express 是基于 Node.js 平台,快速、开放、极简的 Web 开发框架
- Multer 是用于处理文件上传的中间件
搭建Https服务器
搭建服务器主要有以下作用:
- 上传视频文件到服务器以进行处理
- 处理完成后的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数据
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()
方法,按照顺序返回所有字段字符串
打印结果
配置
配置的参数设置都是参考 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; } }
优化
但只是讲述了最基本的流程,从设计上看比较拙略,有许多可以改进的地方。本文主要从以下几个角度进行优化
- 抽离出视频转动图的逻辑作为一个
RequestHandler
(中间件); - 在该中间件的基础上新增更多接口服务;
- 抽离出
Option
设计成一个class
(用于执行 ffmpeg 命令行的类); - 使用
crontab
定时执行删除任务,替代setTimeout
定时器任务; - 使用
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 ffmpegOption()
关于 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 图片
OSCHINA - 使用 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"
。这个滤波器对每一帧的所有颜色制作一个直方图,并且基于这些生成一个调色板。
调色板文件
将调色板作为输入源需要指定两个-i
(一个源文件、一个调色板文件), -vf
的配置需要换成 -lavfi
以配置全局的滤波 (同-filter_complex
),并同样在后边添加 "paletteuse"
。
因为要指定两个输入源,所以在后面配置 -i
时需要这样:
// -i input.mp4 -i palette.png Option.set('-i', `${path} ${Option.get('-i')}`);
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, });
喜欢这篇文章?欢迎打赏~~