nodejs + ffmpeg 实现视频转动图

使用 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服务器

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

  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数据

名字 类型 说明 栗子
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;
    }
}

演示

此动图为旧版本演示及生成,新版本功能加了很多懒得录制了,具体扫码体验吧!

体验

体验

微信搜一搜 百万工具箱 或扫码体验

小程序码-视频转动图

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,015评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,262评论 1 292
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,727评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,986评论 0 205
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,363评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,610评论 1 219
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,871评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,582评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,297评论 1 242
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,551评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,053评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,385评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,035评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,079评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,841评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,648评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,550评论 2 270

推荐阅读更多精彩内容