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

     

    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;
        }
    }

    优化

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

    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 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,
    });

     

     

    喜欢这篇文章?欢迎打赏~~

  • 相关阅读:
    Jquery对象与DOM对象之间的转换
    关于Content-Type中application/x-www-form-urlencoded 和 multipart/form-data的区别及用法
    ${pageContext.request.contextPath} :JSP取得绝对路径方法
    servlet到底是什么?
    get和post的区别
    HTTP请求返回状态码详解
    XMLHttpRequest对象的readyState和status区别
    hdu 3594 仙人掌图
    hdu 4744 最小费用流
    hdu 4729 树链剖分
  • 原文地址:https://www.cnblogs.com/cangqinglang/p/14545100.html
Copyright © 2020-2023  润新知