过年期间张哥说要带大家一起搞视频号,自己拍视频的话没有那么大的精力,刚好赶上「生财日历」 的共读营活动,所以有了量产视频的想法,说干就干。
视频的组成
视频(Video)泛指将一系列静态影像以电信号的方式加以捕捉、记录、处理、储存、传送与重现的各种技术。连续的图像变化每秒超过24帧(frame)画面以上时,根据视觉暂留原理,人眼无法辨别单幅的静态画面;看上去是平滑连续的视觉效果,这样连续的画面叫做视频。视频技术最早是为了电视系统而发展,但现在已经发展为各种不同的格式以利消费者将视频记录下来。网络技术的发达也促使视频的纪录片段以串流媒体的形式存在于因特网之上并可被电脑接收与播放。视频与电影属于不同的技术,后者是利用照相术将动态的影像捕捉为一系列的静态照片。
以上内容来源于「百度百科」。
通俗解释就是视频由「连续的图片」加「音频」构成。
视频制作整体思路
通过OCR识别「生财日历」每天的文本内容,转成语音,配合图片资源生成视频。接下来要做的就是技术实现了。
OCR识别直接使用的华为手机的屏幕读取功能,长按图片转文字。
文本转语音这个后面详细说。
视频合成使用ffmpeg
处理,文末提供封装的神器。
接下来详细介绍每一步的操作步骤。
技术栈汇总
- OCR识别-->华为手机自带文字识别
- TTS文本转语音-->半破解科大讯飞特色发音人
- LightProxy 代理抓包工具
- ffmpeg 音频转码处理
- 图片资源下载--->各大资源网站
- 音频、图片转视频--->FFCreator
文本识别&语音文件生成
目前的方案
每天都会去生财日历读每日推送,读完以后直接双指长按屏幕
触发文字识别操作,识别准确率在95%以上,全选复制,通过微信的「文件传输助手」发送到电脑端,校准编辑。
image-20210324222316960
可替代的方案
- QQ 扫一扫-->转文字-->选择本地图片
- 扫描全能王(用法自行研究)
进阶玩法
使用免费OCR识别,目前各大云厂商(百度、华为、腾讯、阿里等)都已经提供了免费的调用量,个人用足够了。具体调用可以考虑单独开文介绍。
文本转语音
这里直接使用的科大讯飞的TTS服务。 原计划使用免费的发声人,demo代码写完发现发音太生硬,好在提供了特色发音人的产品体验功能。
产品体验功能是基于浏览器访问的,这咱就有操作空间了。 体验地址:https://www.xfyun.cn/services/online_tts 打开谷歌浏览器的「开发者工具」,点击立即合成
按钮,观察接口请求,发现进行了验证码处理,本来就是半自动化操作,所以没有仔细研究如何破解这块。
image-20210324224350612
通过分析接口请求,会发现一个ws协议的接口进行了数据响应,
image-20210324225632787
因为chrome原生不支持ws响应结果的保存,这里我们祭出另一大利器LightProxy
。
使用 LightProxy 保存ws请求内容
LightProxy
是 IFE
团队开发的一款基于 Electron
和 whistle
的开源桌面代理软件,致力于让前端开发人员能够精确的掌握自己的开发环境,通过 HTTP
代理使用规则转发、修改每一个请求和响应的内容。
下载地址
https://lightproxy.org/zh-CN
保存接口请求
跟之前抓包流程一样,手动发送请求。然后在LightProxy
的whistle
界面查看请求信息,为了便于查看结果,我们使用Filter
功能只显示xfyun.cn
域名下的请求。
image-20210324231200766
然后选择wss
那条请求,右键--->Export保存到本地。
image-20210324231731214
文件内容生成pcm音频文件
科大讯飞的接口协议使用的是base64的数据,我们需要把base64的数据转成mp3格式的,具体代码如下。
public class LightProxyData { public static void main(String[] args)throws Exception { File file = new File("/Users/xx/Downloads/wss.txt"); BufferedReader reader = new BufferedReader(new FileReader(file)); StringBuilder stringBuilder = new StringBuilder(); String line; while ((line = reader.readLine()) != null) { stringBuilder.append(line); }
JSONArray root = JSON.parseArray(stringBuilder.toString());
JSONArray frames = root.getJSONObject(0).getJSONArray("frames");
String sendData = frames.getJSONObject(0).getString("base64");
String sendText = new String(Base64.getDecoder().decode(sendData), "UTF-8");
String sendTextBase64 = JSON.parseObject(sendText).getJSONObject("data").getString("text");
String sendDataText = new String(Base64.getDecoder().decode(sendTextBase64),"UTF-8");
System.out.println("发送的数据:"+sendDataText);
FileOutputStream os1 = new FileOutputStream(new File("/Users/xx/Downloads/2.pcm"));
int loop = frames.size()-1;
for(int i=1;i<loop;i++){
String receiveDataBase64 = frames.getJSONObject(i).getString("base64");
byte[] textBytes = Base64.getDecoder().decode(receiveDataBase64);
String jsontext = new String(textBytes, "UTF-8");
String audio = JSON.parseObject(jsontext).getJSONObject("data").getString("audio");
if(audio==null){
continue;
}
byte[] audioBytes = Base64.getDecoder().decode(audio);
os1.write(audioBytes);
os1.flush();
}
os1.close();
}
}
pcm文件转MP3
在pcm文件目录执行以下命令
ffmpeg -y -ac 1 -ar 16000 -f s16le -i 2.pcm -c:a libmp3lame -q:a 2 1.mp3
至此音频文件以及准备完毕
获取图片
推荐以下网址进行资源下载
首推:https://www.pexels.com/zh-cn/ https://www.vecteezy.com/ https://www.storyblocks.com/ https://pixabay.com/zh/
pexels 批量下载
作为技术人肯定得想办法批量搞啊,一个个太麻烦了。
同样打开浏览器的开发者模式,把可视区域缩放到很小, 你会发现每一张图片都包含一个下载按钮,对!这就是我们要的,分析过程不再赘述,直接上代码。因为采用的是瀑布流加载,为我们下载又一次提供了便利,你就使劲往下滑~ 越多越好。
然后在Console
面板执行以下代码。
var elements = document.getElementsByClassName('js-download');
var allText = '';
for (var i = 0; i < elements.length; i++) {
var element = elements[i];
var name = element.getAttribute('data-medium-id') + '.jpg';
var downUrl = element.href;
// 下载地址涉及到redirect跳转,所以需要添加-L参数
allText = allText + 'curl -L -o ' + name + ' ' + downUrl + '\r\n';
}
console.log(allText)
image-20210325000503863
也可以把上面拷贝的命令写个shell文件方便执行。
图片,音频都搞定了,接下来就是最重要的视频合成了。
视频合成
这里推荐 https://tnfe.github.io/FFCreator/#/ 一个轻量的nodejs短视频加工库。
FFCreator
的动画实现方式有多种, 还可以添加场景过渡特效动画
直接上代码
const path = require('path');
const colors = require('colors');
const audioLoad = require('audio-loader');
const {
FFSubtitle,
FFScene,
FFVtuber,
FFVideo,
FFAlbum,
FFText,
FFImage,
FFCreator
} = require('ffcreator');
audioLoad('./assets/20210121/1.mp3').then(function (res) {
// 获取音频时长
var duration = res.duration
console.log(duration)
console.log(duration / 2.5)
const zhuti = '微信个人号的价值如今仍然被严重低估';
const neirong = '今天是1月21日星期四,今天分享的话题是,「微信个人号的价值如今仍然被严重低估」,如果你换个角度,把个人号当作一个CRM (客户关系管理)系统,就会发现很多事情其实可以做得更好。结合微信群、朋友圈、聊天,标签、人设、微信支付、转发推荐,微信个人号既是流量池,也是拉新端、交易端、运营端、营销端,品牌端、复购端、供应链端、管理端、工具端。谢谢观看,明天见~';
// tts 语音时长
const audioDuration = Math.ceil(duration);
// tts 语音文件
const tts = path.join(__dirname, './assets/20210121/1.mp3');
const audio = path.join(__dirname, './assets/20210119/1.m4a');
// 整体背景图
const bg = path.join(__dirname, './assets/20210117/feijibaiyun.jpg');
// logo文件
const logo = path.join(__dirname, './assets/imgs/logo/rgyz2.png');// 变化图片 const img0 = path.join(__dirname, '/assets/20210118/8.jpg'); const img1 = path.join(__dirname, '/assets/20210118/6.jpg'); const img2 = path.join(__dirname, '/assets/20210118/1.jpeg'); const img3 = path.join(__dirname, '/assets/20210118/8.jpg'); const img4 = path.join(__dirname, '/assets/20210118/10.jpg'); const img5 = path.join(__dirname, '/assets/20210118/9.jpg'); const img6 = path.join(__dirname, '/assets/20210118/13.jpeg'); const img7 = path.join(__dirname, '/assets/20210118/4.jpg'); const img8 = path.join(__dirname, '/assets/20210118/11.jpeg'); const img9 = path.join(__dirname, '/assets/20210118/9.jpg'); const img10 = path.join(__dirname, '/assets/20210118/7.jpeg'); const img11 = path.join(__dirname, '/assets/20210118/2.jpg'); const img12 = path.join(__dirname, '/assets/20210118/4.jpg'); const img13 = path.join(__dirname, '/assets/20210118/5.jpeg'); const img14 = path.join(__dirname, '/assets/20210118/13.jpeg'); const img15 = path.join(__dirname, '/assets/20210118/3.png'); const img16 = path.join(__dirname, '/assets/20210118/7.jpeg'); const img17 = path.join(__dirname, '/assets/20210118/10.jpg'); const img18 = path.join(__dirname, '/assets/20210118/11.jpeg'); const img19 = path.join(__dirname, '/assets/20210118/2.jpg'); const img20 = path.join(__dirname, '/assets/20210118/14.jpg'); const img21 = path.join(__dirname, '/assets/20210118/3.png'); const img22 = path.join(__dirname, '/assets/20210118/6.jpg'); const img23 = path.join(__dirname, '/assets/20210118/14.jpg'); const imgeList = [img0, img1, img2, img4, img5, img7, img8, img10, img12, img14, img15, img16, img17, img18, img19, img20, img2,img1, img2, img4] console.log(imgeList) const outputDir = path.join(__dirname, './output/'); const cacheDir = path.join(__dirname, './cache/'); // create creator instance const width = 576; const height = 1024; const creator = new FFCreator({ cacheDir, outputDir, width, height, debug: false, // audio, }); // create FFScene const scene1 = new FFScene(); const scene2 = new FFScene(); scene1.setBgColor('#FFFAFA'); scene2.setBgColor('#F8F8FF'); // add image album const album = new FFAlbum({ list: imgeList, x: width / 2, y: height / 2, width: width, height: 384, showCover: true, // 默认显示封面 }); // album.setTransition('random'); album.setDuration(2.5); scene1.addChild(album); console.log('imgeList = ' + imgeList) // add title text const text = new FFText({ text: zhuti, x: width / 2, y: 270, fontSize: 32 }); text.setColor('#FF0000'); text.setBackgroundColor('#FFFF00'); text.addEffect('fadeInUp', 1, 1); text.alignCenter(); text.setStyle({ padding: [4, 20, 6, 20] }); scene1.addChild(text); // add logo const flogo2 = new FFImage({ path: logo, x: width / 2, y: 200 }); flogo2.setScale(0.6); scene1.addChild(flogo2); // add audio to scene1 scene1.addAudio(tts); // subtitle const title = neirong; const subtitle = new FFSubtitle({ comma: true, // 是否逗号分割 backgroundColor: '#FFFAFA', color: '#2E8B57', fontSize: 24, x: width / 2, y: height / 2 + 300, }); subtitle.setText(title); subtitle.setSpeech(tts); // 语音配音-tts subtitle.frameBuffer = 24; subtitle.addEffect("fadeIn", 1, 1.5); // subtitle.setDuration(40); // 没有tts配音时候可以手动设置 scene1.addChild(subtitle); scene1.setDuration(audioDuration); scene1.setTransition('FastSwitch', 1.5); creator.addChild(scene1); // add scene2 background const fbg = new FFImage({ path: bg }); fbg.setXY(width / 2, height / 2); scene2.addChild(fbg); // logo const flogo = new FFImage({ path: logo, x: width / 2, y: height / 2 - 150 }); flogo.addEffect('fadeInDown', 1, 1.2); scene2.addChild(flogo); scene2.setDuration(4); creator.addChild(scene2); creator.start(); creator.closeLog(); creator.on('start', () => { console.log(`FFCreator start`); }); creator.on('error', e => { console.log(`FFCreator error: ${JSON.stringify(e)}`); }); creator.on('progress', e => { console.log(colors.yellow(`FFCreator progress: ${e.state} ${(e.percent * 100) >> 0}%`)); }); creator.on('complete', e => { console.log( colors.magenta(`FFCreator completed: \n USEAGE: ${e.useage} \n PATH: ${e.output} `), ); console.log(colors.green(`\n --- You can press the s key or the w key to restart! --- \n`)); });
});
至此一个完整的视频就出来了,大概几分钟可以产出一个视频,视频刚开始发到视频号的时候还上了官方推荐,播放量达到不到2000,但是几天后就没播放量了~
image-20210325001847988
image-20210325002049460