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

 

 

 

喜歡這篇文章?歡迎打賞~~

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM