使用Nodejs将h264/h265转码成mp4或某一帧的图片
前置知识
-
认识FFmpeg
- FFmpeg是一套可以用来记录、转换数字音频、视频,并能将其转化为流的开源计算机程序。有非常强大的功能包括视频采集功能、视频格式转换、视频抓图、给视频加水印等。
- 官网
http://ffmpeg.org/
- 相关指令集学习
http://ffmpeg.org/ffmpeg-filters.html
-
安装ffmpeg
- Linux
- centos6.x或centos7.x安装ffmpeg方法
- 下载ffmpeg源码包
- 解压ffmpeg源码包并编译
- 执行安装
- 在系统目录下使用vi命令把Ffmpeg加入全局变量,使之所有都可以使用ffmpeg调用
- 在文件最后PATH添加环境变量
- 重载profile
- 测试变量和安装是否完成,任何地方使用ffmpeg -version 查看版本即可
- 下载ffmpeg源码包
- centos6.x或centos7.x安装ffmpeg方法
- Mac
- 下载地址 地址
- Linux
-
指令范例
- 将h264转换为mp4
- 将mp4按指定帧率切割成图片
- 对于24帧每秒的视频 128帧是第5秒的第四帧
- 要在1秒到20秒允许一个blur(模糊)滤镜(smartblur),然后curves滤镜在3秒之后
- 将h264的第一帧保存为图片
技术栈
- Nodejs、Express、FFmpeg、protobuffer
技术方案
graph LR
A[浏览器] --> B[Node服务]
B[Node服务] --> D[Nodejs-shell];
D[Nodejs-shell] -->|使用shell功能执行ffmpeg指令| E[FFmpeg];
E -->|转码并保存| G[静态图片/视频资源];
G -->|资源路径返回| B[Node服务];
B -->|资源路径返回 浏览器直接访问静态文件资源| A[浏览器];
核心代码
const shell = require('shelljs');
// 执行ffmpeg
shell.exec(`ffmpeg -i topic.h264 -vf "select='between(n,0,1)'" -y -acodec copy topic.jpg`, (error, stdout, stderr) => {});
完整代码
- 由于该项目被解析文件并非是一整个纯h264的二进制文件,只是对12路摄像头的topic保存有h264内容,所以会有一些解包操作。
目录
├── config
│ ├── config.js // 阿里云OSS、百度云BOS、serverPort
│ └── topic_list.js // protobuffer的对应关系配置
├── ebag.js // 解包文件
├── package.json
├── proto // protobuffer的proto
│ ├── ...
│ ├── camera_data.proto
│ └── ...
├── public // 静态文件
│ ├── h264 // 存储FFmpeg要读取的h264文件
│ ├── images // FFmpeg转码h264文件后的图片
│ ├── index.html // 静态访问首页
│ └── static // 静态资源
├── server.js // node 服务
├── start_server.sh
├── stop_server.sh
└── topics.js // protobuffer解析文件
config.js
/* bca-disable */
module.exports = {
// Alibaba cloud OSS configuration
'alioss': {
region: '',
accessKeyId: '',
accessKeySecret: '',
},
// Baidu cloud OSS configuration
'bdbos': {
endpoint: '',
credentials: {
ak: '',
sk: ''
}
},
// Service port
'port': ''
};
topic_list.js
module.exports = {
'around_view_camera': {
type: '****.****.CameraDataPb',
file: 'camera_data'
},
...
};
ebag.js
const topics = require('./topics');
const topic_list = require('./config/topic_list');
const config = require('./config/config');
const BosSdk = require('@baiducloud/sdk');
const BosClient = BosSdk.BosClient;
class Ebag {
constructor() {
this.op_blocks = null;
}
async getBuffer(bucket, dir) {
try {
let client = new BosClient(config.bdbos);
let body = await client.getObject(bucket, dir);
return body;
} catch (e) {
console.log(e);
}
}
open_data(bucket, dir, cb) {
if (bucket !== '0' || dir !== '0') {
let self = this;
this.op_blocks = {
message: [],
data: {},
};
function done(e) {
if (cb) {
cb(e);
} else {
console.log(e);
}
}
this.getBuffer(bucket, dir).then(source => {
try {
self.size = source.http_headers['content-length'];
let head = Buffer.allocUnsafe(14);
let pos = 0;
while (pos < self.size) {
let msg = {
head: {}
};
let res1 = source.body.copy(head, 0, pos, pos + 14 + 1);
if (res1 !== 14) {
throw 'read head err: ' + JSON.stringify(r);
}
msg.pos = pos;
msg.block_size = head.readUInt32LE(0);
msg.stamp_low = head.readUInt32LE(4);
msg.stamp_high = head.readUInt32LE(8);
msg.stamp = msg.stamp_low + (msg.stamp_high * 0x100000000);
msg.head.time = new Date(msg.stamp / 1000);
msg.topic_size = head.readUInt16LE(12);
if (msg.topic_size > 0) {
let topic_buf = Buffer.allocUnsafe(msg.topic_size);
let res2 = source.body.copy(topic_buf, 0, pos + 14, pos + 14 + msg.topic_size + 1);
if (res2 !== msg.topic_size) {
throw 'read topic err: ' + JSON.stringify(r);
}
msg.head.topic = topic_buf.toString();
} else {
msg.head.topic = '';
}
self.op_blocks.message.push(msg);
let buf_size = msg.block_size - 14 - msg.topic_size;
let buf = Buffer.allocUnsafe(buf_size);
let res3 = source.body.copy(buf, 0, msg.pos + 14 +
msg.topic_size, msg.pos + msg.block_size + 1);
let decode_obj = {};
if (res3 === buf_size) {
let decode_msg = topics.decode(msg.head.topic, buf);
let type = '';
if (msg.head.topic in topic_list) {
type = topic_list[msg.head.topic].type;
}
decode_obj = {
topic: msg.head.topic,
decode_msg: decode_msg,
type: type
};
self.op_blocks.data[pos] = decode_obj;
}
pos += msg.block_size;
}
} catch (err) {
done(err);
return;
}
done(null);
});
}
}
}
module.exports = Ebag;
package.json
{
"name": "exps",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"start": "nodemon ./server.js",
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "",
"license": "ISC",
"dependencies": {
"@baiducloud/sdk": "^1.0.0-rc.27",
"ali-oss": "^6.15.2",
"base64-js": "^1.3.1",
"body-parser": "^1.19.0",
"express": "^4.17.1",
"jpeg-js": "^0.3.6",
"md5": "^2.3.0",
"mysql": "^2.17.1",
"nodemon": "^2.0.7",
"protobufjs": "^6.8.8"
},
"devDependencies": {
"shelljs": "^0.8.5"
}
}
index.html
<!DOCTYPE html>
<html style="height: 100%">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no">
<link href="./static/ui/json-viewer.css" type="text/css" rel="stylesheet" />
<link rel="stylesheet" href="./static/ui/bootstrap.min.css">
</head>
<body style="height: 99%;">
<!-- Base template -->
<div class="carui-show-base">
<div class="carui-echarts-base">
<div style="display: inline; position: relative; left: 33px; top: 15px;">
<button type="button" class="btn btn-primary btn-sm" data-toggle="modal" data-target="#chooseTpic">选择消息</button>
</div>
<div id="container" style="height: 94%"></div>
</div>
<div class="carui-data-show">
<div class="carui-data-show-top" id="camera">
<img src="./static/img/none.png" alt="">
</div>
<div class="carui-data-show-bot">
<pre id="json-renderer"></pre>
</div>
</div>
</div>
<!--放大图的imgModal-->
<div class="modal fade bs-example-modal-xl text-center" id="imgModal" tabindex="-1" role="dialog" aria-labelledby="myLargeModalLabel" >
<div class="modal-dialog modal-xl" style="display: inline-block; width: auto;">
<div class="modal-content">
<img width="100%" height="100%" id="imgInModalID"
class="carousel-inner img-responsive img-rounded"
onclick="closeImageViewer()"
onmouseover="this.style.cursor='pointer';this.style.cursor='hand'"
/>
</div>
</div>
</div>
<!-- Loading -->
<div class="modal fade" id="loading" tabindex="-1" role="dialog" aria-labelledby="myModalLabel" data-backdrop='static'>
<div class="modal-dialog" role="document" style="margin-top: 23%;">
<div class="d-flex justify-content-center align-items-center">
<div class="spinner-border text-light" style="width: 4rem; height: 4rem;" role="status">
</div>
</div>
</div>
</div>
<!-- Scrollable modal -->
<div class="modal fade" id="chooseTpic" data-backdrop="static" data-keyboard="false" tabindex="-1">
<div class="modal-dialog modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="staticBackdropLabel">请选择</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">×</span>
</button>
</div>
<div class="modal-body">
<form id="topicList"></form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-primary" id="exec_choose" aria-label="Close" data-dismiss="modal">确定</button>
</div>
</div>
</div>
</div>
<script type="text/javascript" src="./static/echarts/jquery.min.js"></script>
<script type="text/javascript" src="./static/echarts/echarts.min.js"></script>
<script src="./static/ui/json-viewer.js"></script>
<script src="./static/ui/bootstrap.min.js"></script>
<script type="text/javascript">
function loadStart() {
$('#loading').modal('show');
}
function loadDone() {
setTimeout(function(){
$('#loading').modal('hide');
}, 500)
}
//获取url中的参数
function getUrlParam(name) {
var reg = new RegExp("(^|&)" + name + "=([^&]*)(&|$)"); //构造一个含有目标参数的正则表达式对象
var r = window.location.search.substr(1).match(reg); //匹配目标参数
if (r != null) return unescape(r[2]); return null; //返回参数值
}
//显示大图
function showimage(data)
{
$("#imgModal").find("#imgInModalID").attr("src", data);
$("#imgModal").modal();
}
//关闭大图
function closeImageViewer(){
$("#imgModal").modal('hide');
}
// 选定topic信息 确定
$('#exec_choose').click(function() {
var topiclist = '';
$('input[name="topiclist"]:checked').each(function(){
topiclist += $(this).val() + ','; //向字符串中添加元素
});
if (topiclist.length === 0) {
alert('请勾选要显示的topic信息!')
return
}
var bucket = getUrlParam('bucket');
var dir = getUrlParam('dir');
if (bucket === '') {
alert('请输入Bucket名称!')
return
}
if (dir === '') {
alert('请输入资源目录地址!')
return
}
execEcharts(bucket, dir, topiclist)
})
// 初始化echarts
$(function() {
var bucket = getUrlParam('bucket');
var dir = getUrlParam('dir');
if (bucket === '') {
alert('请输入Bucket名称!')
return
}
if (dir === '') {
alert('请输入资源目录地址!')
return
}
execEcharts(bucket, dir, '')
})
// echarts执行方法
function execEcharts(bucket, dir, topiclist) {
loadStart();
// 获取echarts信息
$.post('/data', {bucket:bucket, dir:dir, topiclist:topiclist}, function(res) {
if (res.err === -1) {
loadDone();
setTimeout(function(){
alert(res.msg)
}, 500)
return
}
if (res.data != '') {
initEcharts(res.data, res.list, res.topic, res.img)
}
// topic列表赋值
if (res.initTopiclist) {
var topicHtml = '';
for(var i = 0; i < res.initTopiclist.length; i++){
if (res.initTopiclist[i].checked === 0) {
topicHtml += `<div class="form-group">` +
`<div class="custom-control custom-switch">` +
`<input type="checkbox" class="custom-control-input" name="topiclist" id="customSwitch` + i + `" value="` + res.initTopiclist[i].name + `">` +
`<label class="custom-control-label" for="customSwitch` + i + `">` + res.initTopiclist[i].name + `</label>` +
`</div></div>`
} else {
topicHtml += `<div class="form-group">` +
`<div class="custom-control custom-switch">` +
`<input type="checkbox" class="custom-control-input" name="topiclist" id="customSwitch` + i + `" value="` + res.initTopiclist[i].name + `" checked>` +
`<label class="custom-control-label" for="customSwitch` + i + `">` + res.initTopiclist[i].name + `</label>` +
`</div></div>`
}
}
$('#topicList').children().remove();
$('#topicList').append(topicHtml)
}
loadDone();
})
}
function renderItem(params, api)
{
var topic_idx = api.value(0)
var time = api.value(1)
var point = api.coord([time, topic_idx]);
var height = api.size([0, 1])[1] * 0.6;
point[0] -= 2;
if (point[0] < 160) {
point[0] = 160;
}
var rectShape = echarts.graphic.clipRectByRect({
x: point[0],
y: point[1] - height / 2,
width: 2,
height: height
}, {
x: params.coordSys.x,
y: params.coordSys.y,
width: params.coordSys.width,
height: params.coordSys.height
})
if (!rectShape) {
return {
type: 'rect',
shape: {x:0, y:0, width:0, height:0},
style: api.style
}
}
return rectShape && {
type: 'rect',
shape: rectShape,
style: api.style()
}
}
function initEcharts(data, list, topicData, topicPic) {
var dom = document.getElementById("container");
var myChart = echarts.init(dom);
var app = {};
var option;
var startTime = data[0]['value'][1];
var categories = list;
option = {
tooltip: {
trigger: 'item',
formatter: function (params) {},
axisPointer: {
type: 'cross',
snap: true,
label: {
formatter: params => {
if (typeof params.value == 'number') {
// x轴,时间
let date = new Date(params.value);
let t = [date.getHours(), date.getMinutes(), date.getSeconds()]
let d = [date.getFullYear(), date.getMonth()+1, date.getDate()]
let r = d.join('-') + ' ' + t.join(':') + '.' + date.getMilliseconds()
return r
}
return params.value
}
}
}
},
grid: {
left: 160,
tooltip: {
trigger: 'axis',
formatter: 'fff'
}
},
xAxis: {
type: 'value',
scale: true,
min: startTime,
axisLabel: {
formatter: (value, index) => {
let date = new Date(value);
let t = [date.getHours(), date.getMinutes()];
let r = t.join(':')
if (index === 0) {
let d = [date.getMonth()+1, date.getDate()]
r = d.join('-') + ' ' + r
}
return r;
}
}
},
yAxis: {
type: 'category',
position: 'left',
maxInterval: 1,
data: categories
},
toolbox: {
feature: {
dataZoom: {
}
}
},
series: [{
type: 'custom',
renderItem: renderItem,
itemStyle: {
color: "#000"
},
encode: {
x: [1],
y: [0]
},
data: data
}],
dataZoom: [
{
type: 'slider',
show: true,
xAxisIndex: [0],
start: 0,
end: 5,
}, {
type: 'inside',
xAxisIndex: [0],
start: 0,
end: 5,
},
{
type: 'slider',
show: true,
yAxisIndex: [0],
left: '93%',
start: 0,
end: 50,
}, {
type: 'inside',
yAxisIndex: [0],
start: 0,
end: 50,
// zoomOnMouseWheel: false,
// moveOnMouseWheel: false,
}
]
};
myChart.setOption(option);
myChart.off("mouseover"); //防止累计触发
myChart.on('mouseover', function(param) {
var pos = param.data.value[2];
var name = param.data.name;
var showData = topicData[pos]
// 有数据渲染数据
if (showData) {
var options = {
collapsed: $('#collapsed').is(':checked'),
withQuotes: $('#with-quotes').is(':checked')
};
$('#json-renderer').jsonViewer(showData, options);
}
// 有图片渲染图片
if (topicPic[pos]) {
var src = topicPic[pos];
var imgSource = `<img onmouseover="this.style.cursor='pointer';
this.style.cursor='hand'" src=` + src + `
onclick="javascript:showimage(this.src);" />`
// bca-disable-line
$('#camera').html(imgSource);
} else {
var src = './static/img/none.png';
var imgSource = `<img src=` + src + ` />`
// bca-disable-line
$('#camera').html(imgSource);
}
});
myChart.off("click"); //防止累计触发
myChart.on('click', function(param) {
// 只有topic以 _record 结尾的 才可以执行图像标记行为
if (param.name.indexOf('_record') != -1) {
try {
var pos = param.data.value[2];
// 计算就近perception_view
let lastPos = [];
let tmpPos = 0;
$.each(topicData, function (index, value) {
// topic 必须为 perception_view
if (topicData[index].topic == 'perception_view') {
// 获取小于当前pos的perception_view信息
if (index <= pos) {
tmpPos = pos - index;
lastPos[index] = tmpPos;
}
}
});
// 就近的 perception_view 信息
let perceptionData = topicData[lastPos.length - 1];
// 图片信息
let obstacleData = topicPic[pos];
// 障碍物信息获取
let obstaclesData = [];
// 可行驶区域标记
let freeSpaceData = [];
// 不存在障碍物信息 无需标记
if (perceptionData.decode_msg.hasOwnProperty('obstacles')) {
let obstacleInfo = perceptionData.decode_msg.obstacles;
if (obstacleInfo.length < 0) alert('无感知输出信息!无法标记');
for (key in obstacleInfo) {
// 判断当前topic信息是否与障碍物摄像头匹配
if (obstacleInfo[key].cameraName === param.name.replace('_record', '')) {
let tmpObstacle = {
'cameraName' : obstacleInfo[key].cameraName,
'imageH' : obstacleInfo[key].imageH,
'imageW' : obstacleInfo[key].imageW,
'imageX' : obstacleInfo[key].imageX,
'imageY' : obstacleInfo[key].imageY,
}
obstaclesData.push(tmpObstacle)
}
}
}
// 不存在可行驶区域信息 无需标记
if (perceptionData.decode_msg.hasOwnProperty('freeSpace')) {
if (perceptionData.decode_msg.freeSpace.hasOwnProperty('ptsImageCoord')) {
let freeSpaceInfo = perceptionData.decode_msg.freeSpace.ptsImageCoord;
if (freeSpaceInfo.length < 0) alert('无可行驶区域输出信息!无法标记');
for (ki in freeSpaceInfo) {
// 判断当前topic信息是否与障碍物摄像头匹配
if (freeSpaceInfo[ki].cameraName === param.name.replace('_record', '')) {
let tmpFreeSpace = {
'x' : freeSpaceInfo[ki].point.x,
'y' : freeSpaceInfo[ki].point.y,
}
freeSpaceData.push(tmpFreeSpace)
}
}
}
}
// 如果障碍物信息与可行驶区域信息都为空 则无需执行标记
if (obstaclesData.length <= 0 && freeSpaceData.length <= 0) {
alert('无信息可标记,请换一个再试');
return
}
// 发送至node 处理标记信息
$.post('/mark', {
perdata: JSON.stringify(obstaclesData),
obdata: obstacleData,
freedata: JSON.stringify(freeSpaceData),
}, function(res) {
if (res.code == 1) {
var src = res.output;
var imgSource = `<img onmouseover="this.style.cursor='pointer';
this.style.cursor='hand'" src=` + src + `
onclick="javascript:showimage(this.src);" />`
// bca-disable-line
$('#camera').html(imgSource)
} else {
alert(res.msg);
return
}
})
} catch(err) {
alert('当前数据块无图片信息或无障碍物信息!' + err)
}
} else {
alert('当前数据块无图片信息或无障碍物信息!')
}
})
if (option && typeof option === 'object') {
myChart.setOption(option);
}
// 窗口大小改变事件
window.onresize = function () {
myChart.resize();
};
}
</script>
</body>
</html>
server.js
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 |
|
topics.js
const protobuf = require('protobufjs');
let topic_list = require('./config/topic_list.js');
function load_pbs()
{
/* bca-disable */
for (let topic in topic_list) {
let detail = topic_list[topic];
let r = protobuf.loadSync(`${__dirname}/proto/${detail.file}.proto`);
detail.froot = r;
detail.pbType = r.lookupType(detail.type);
}
}
load_pbs();
function decode(topic, buf)
{
let detail = topic_list[topic];
if (!detail) {
// console.error("not found topic: ", topic)
// 使用空消息解析,只解出消息头
detail = topic_list.empty;
if (!detail) {
return {};
}
}
try {
let o = detail.pbType.decode(buf);
return o;
} catch (e) {
// console.log('pb decode err:', topic, e, buf.toString('hex'))
return {};
}
}
module.exports = {
decode,
};
访问结果
拓展
- 使用JavaScript播放H264视频 地址