使用 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, });
喜歡這篇文章?歡迎打賞~~