使用 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服务器
搭建服务器主要有以下作用:
- 上传视频文件到服务器以进行处理
- 处理完成后的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数据
名字 | 类型 | 说明 | 栗子 |
---|---|---|---|
start | Number | 开始时间 | 0 |
end | Number | 结束时间 | 10 |
sizeLimit | String | 大小限制 | 3M |
dpi | String | 分辨率 | 720p,640x480 |
framePerSecond | String | 帧率 | 30 |
pts | Number | 倍速,取值范围 [0.25,4] | 0.75,2.5 |
contrast | Number | 对比度 | 1 |
brightness | Number | 亮度 | 1 |
saturation | Number | 饱和度 | 1 |
crop | String | 格式为w:h:x:y 表示裁剪的宽高和XY坐标 |
200:300:0:30 |
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;
}
}
演示
此动图为旧版本演示及生成,新版本功能加了很多懒得录制了,具体扫码体验吧!
体验
微信搜一搜 百万工具箱 或扫码体验