Node + FFmpeg 實(shí)現(xiàn)Canvas動(dòng)畫導(dǎo)出視頻
導(dǎo)言
Canvas為前端提供了動(dòng)畫展示的平臺(tái),隨著現(xiàn)在視頻娛樂的流行,你是否想過把Canvas動(dòng)畫導(dǎo)出視頻?目前純前端的視頻編碼轉(zhuǎn)換(例如WebM Encoder Whammy)還存在許多限制,較為成熟的方案是將每幀圖片傳給后端實(shí)現(xiàn),由后端調(diào)用FFmpeg進(jìn)行視頻轉(zhuǎn)碼。整體流程并不復(fù)雜,這篇文章將帶大家實(shí)現(xiàn)這個(gè)過程。
整體方案
- 由前端記錄Canvas動(dòng)畫的每幀圖像,以base64字符串形式傳給后端
- 利用node fluent-ffmpeg模塊,調(diào)用FFmpeg將圖片合并成視頻,并將視頻存儲(chǔ)在server端,并返回相應(yīng)下載url
- 前端通過請(qǐng)求得到視頻文件
前端部分
每幀圖片生成
圖片生成可以通過canvas原生接口toDataURL實(shí)現(xiàn),最終返回base64形式的圖像數(shù)據(jù)。
- generatePng () {
- ...
- var imgData = canvas.toDataURL("image/png");
- return imgData;
- }
動(dòng)畫錄制與圖片流傳輸
動(dòng)畫的記錄與傳送是個(gè)異步過程,這里返回一個(gè)Promise,等待后端處理完畢,收到回應(yīng)后,即完成此異步過程。
以下代碼將canvas每幀動(dòng)畫信息存入一個(gè)圖片數(shù)組imgs中,將數(shù)組轉(zhuǎn)成字符串的形式傳給后端。注意這里contentType設(shè)置為“text/plain”。
- generateVideo () {
- var that = this;
- return new Promise (
- function (resolve, reject) {
- var imgs = [];
- ...
- window.requestAnimationFrame(that.recordTick.bind(that, imgs, resolve, reject));
- }
- )
- }
- recordTick (imgs, resolve, reject) {
- ...//每幀動(dòng)畫的記錄信息,如時(shí)間戳等
- if (...) {//動(dòng)畫終止條件
- this.stopPlay();
- imgs.push(this.generatePng());
- $.ajax({
- url: '/video/record',
- data: imgs.join(' '),
- method: 'POST',
- contentType: 'text/plain',
- success: function (data, textStatus, jqXHR) {
- resolve(data);
- },
- error: function (jqXHR, textStatus, errorThrown) {
- reject(errorThrown);
- }
- });
- } else {
- ...//每幀動(dòng)畫展示的代碼
- imgs.push(this.generatePng());
- window.requestAnimationFrame(this.recordTick.bind(this, imgs, resolve, reject));
- }
- }
視頻下載
上一節(jié)代碼中,動(dòng)畫停止時(shí),會(huì)通過post請(qǐng)求給后端傳送所有圖片數(shù)據(jù),后端處理完畢后,返回?cái)?shù)據(jù)中包含一個(gè)url,此url即為視頻文件的下載地址。
為了支持瀏覽器端用戶點(diǎn)擊下載,我們需要用到a標(biāo)簽的download屬性,此屬性可以支持點(diǎn)擊a標(biāo)簽后下載指定文件。
- editor.generateVideo().then(function (data) {
- videoRecordingModal.setDownloadLink(data.url, data.filename);
- videoRecordingModal.changeStatus('recorded');
- });
- setDownloadLink: function (url, filename) {
- this.config.$dom.find('.video-download').attr('href', url);
- this.config.$dom.find('.video-download').attr('download', filename);
- }
后端部分
圖片序列生成
接收到前端傳送的圖片數(shù)據(jù)后,我們首先需要將圖片解析、存儲(chǔ)在服務(wù)器中,我們建立以當(dāng)前時(shí)間戳命名的文件夾,將圖片序列以一定格式存儲(chǔ)于其中。由于每張圖片寫入都是異步過程,為確保所有圖片都已處理完畢后,才執(zhí)行視頻轉(zhuǎn)碼過程,我們需要用到Promise.all。
- Promise.all(imgs.map(function (value, index) {
- var img = decodeBase64Image(value)
- var data = img.data
- var type = img.type
- return new Promise(function (resolve, reject) {
- fs.writeFile(path.resolve(__dirname, (folder + '/img' + index + '.' + type)), data, 'base64', function(err) {
- if (err) {
- reject(err)
- } else {
- resolve()
- }
- })
- })
- })).then(function () {
- …//視頻轉(zhuǎn)碼
- })
其中decodeBase64Image函數(shù)參考這里。
視頻生成
視頻生成利用FFmpeg轉(zhuǎn)碼工具。首先確保server端安裝了FFmpeg
- brew install ffmpeg
在項(xiàng)目中安裝fluent-ffmpeg,這是node調(diào)用ffmpeg的接口模塊
- npm install fluent-ffmpeg --save
結(jié)合上一節(jié)圖片序列存儲(chǔ)的代碼,整個(gè)接口代碼如下:
- app.post('/video/record', function(req, res) {
- var imgs = req.text.split(' ')
- var timeStamp = Date.now()
- var folder = 'images/' + timeStamp
- if (!fs.existsSync(resolve(folder))){
- fs.mkdirSync(resolve(folder));
- }
- Promise.all(imgs.map(function (value, index) {
- var img = decodeBase64Image(value)
- var data = img.data
- var type = img.type
- return new Promise(function (resolve, reject) {
- fs.writeFile(path.resolve(__dirname, (folder + '/img' + index + '.' + type)), data, 'base64', function(err) {
- if (err) {
- reject(err)
- } else {
- resolve()
- }
- })
- })
- })).then(function () {
- var proc = new ffmpeg({ source: resolve(folder + '/img%d.png'), nolog: true })
- .withFps(25)
- .on('end', function() {
- res.status(200)
- res.send({
- url: '/video/mpeg/' + timeStamp,
- filename: 'jianshi' + timeStamp + '.mpeg'
- })
- })
- .on('error', function(err) {
- console.log('ERR: ' + err.message)
- })
- .saveToFile(resolve('video/jianshi' + timeStamp + '.mpeg'))
- })
- })
視頻下載
最終將視頻文件傳輸給前端的接口代碼如下:
- app.get('/video/mpeg/:timeStamp', function(req, res) {
- res.contentType('mpeg');
- var rstream = fs.createReadStream(resolve('video/jianshi' + req.params.timeStamp + '.mpeg'));
- rstream.pipe(res, {end: true});
- })
效果預(yù)覽
注:此功能是個(gè)人項(xiàng)目”簡(jiǎn)詩”的一部分,完整代碼可以查看https://github.com/moyuer1992...






























