前端使用NDI协议通信播放视频(PS:不要学前端,不然会变得不幸)
NDI可能很多人都不常听到,它是一个音视频的编解码和数据传输的协议,一般广泛用于广播、电视,但是本文不是一个科普文,况且我对NDI的了解也仅限百度百科,所以这里就不过多展开了,有兴趣的可以自己查阅资料。本文的核心点还是前端如何使用NDI接受的信号并播放。
背景
这里先说下技术栈吧,很明显NDI一看就不是web友好格式,纯网页的情况下基本没戏,很巧的是我们使用的是electron,可以借助node的能力来实现和NDI的通信。我们使用的是vite(5.1.6)+electron(30.0.1)+react(18.2.0) ,因为我们的客户端是预计组成一个直播客户端的,obs大家应该知道,本来是还想通过node去调用动态库做一点直播的功能,但是后面考虑到别的因素还是决定客户端仅做展示用,音视频的推流还是由服务器完成(PS:我要是能做个OBS出来我还在你这拿每个月一万不到的牛马费?)
调研
大方向定好后其实给到前端开发的时间是非常少的,甚至没有技术预研阶段。(PS:主要还是架构师觉得前端技术及其简单不用浪费时间调研也不用给很多开发时间😤),所以相当于是边做边查资料了,了解到NDI其实并没有开源,所以社区基本没有什么NDI的内容,而且很多NDI的技术都是需要商业授权的。一般比较常用的NDI视频播放有两种方式:
反正查来查去基本没有前端解决方案,好像是有一个NDI转WebRTC的解决方案提供商,不过是商用的,直接pass,公司给我的钱都不够不可能考虑这个商用的。
最后还是问了下AI然后去藏经阁看了半天,挑中了一个库——grandiose。
集成grandiose
1.安装
首先是安装依赖:
pnpm install --save grandiose
其实这个库已经好几年没更新了,不过好像也确实没啥好更新的,只要NDI的SDK没啥大的变化基本不用变,这里我们下的版本应该是0.0.4的版本。
2.导入
首先是使用确实是一个难题,看仓库给出的demo我们可以知道grandiose是的是commonjs规范,这可麻烦了,我们的项目是module的,而且目前基本前端项目都是基于ESModule的。那么就遇到第一个难题,如何将grandiose导入到项目中使用。恰恰我的工程化不是很好,没办法在项目的中后期将一个使用commonjs规范的包导入到ESModule的项目中。
不过还好我有AI,但是问了一圈改了很多配置后依然不行,不过好在AI给出了一个优秀的解决方案,是用一个cjs文件单独去使用grandiose然后再electron中去调用这个脚本。
// ndi-server.cjs
const grandiose = require('grandiose');
在开发阶段我们可以先手动启动这个脚本做调试使用,目前grandiose已经引入到我们的项目中了,接下来就是重中之重的使用了。
3.发现NDI信号源
NDI协议是根据名称去发现的信号源,即在同一个局域网内,NDI会发现所有NDI信号的名称,然后用户根据名称去选择要连接的服务。所以第一步我们就是要发现所有的NDI信号源。在demo给出的例子中使用过的是GrandioseFinder构造函数,如果你真用了可就倒大霉了,因为这个版本的grandiose根本就没有GrandioseFinder这个构造函数,我们打印一下上面引入的grandiose看看:
{
version: [Function (anonymous)],
find: [Function: find],
isSupportedCPU: [Function (anonymous)],
receive: [Function (anonymous)],
send: [Function (anonymous)],
COLOR_FORMAT_BGRX_BGRA: 0,
COLOR_FORMAT_UYVY_BGRA: 1,
COLOR_FORMAT_RGBX_RGBA: 2,
COLOR_FORMAT_UYVY_RGBA: 3,
COLOR_FORMAT_BGRX_BGRA_FLIPPED: 200,
COLOR_FORMAT_FASTEST: 100,
BANDWIDTH_METADATA_ONLY: -10,
BANDWIDTH_AUDIO_ONLY: 10,
BANDWIDTH_LOWEST: 0,
BANDWIDTH_HIGHEST: 100,
FORMAT_TYPE_PROGRESSIVE: 1,
FORMAT_TYPE_INTERLACED: 0,
FORMAT_TYPE_FIELD_0: 2,
FORMAT_TYPE_FIELD_1: 3,
AUDIO_FORMAT_FLOAT_32_SEPARATE: 0,
AUDIO_FORMAT_FLOAT_32_INTERLEAVED: 1,
AUDIO_FORMAT_INT_16_INTERLEAVED: 2
}
很明显我们应该使用find方法:
const { find } = require('grandiose');
const sources = await find(); // [ { name: 'test_demo_ndi',urlAddress: '1.0.10.10:9527' },]
拿到的sources如上所示,如果有多个NDI信号源数组也会有多个配置项。
4.接收信号
这里应该能看出来,使用的是grandiose对象下的receive方法
const receiver = await grandiose.receive({
source,
})
得到这个receiver对象后我们来获取视频帧
setInterval(async () => {
try {
const frame = await receiver.video(10000);
} catch (err) {
console.error('NDI 读取失败:', err);
}
}, 33);
5.渲染
现在我们拿到视频帧以后就可以用画布渲染了,但是页面如何收到视频帧信息是个问题,因为这不是在主进程,没办法直接通信,所以这里我选择使用ws,然后通过ws去传输二进制数据,这样页面获取到数据后就可以渲染了。
// ndi-server.cjs
setInterval(async () => {
try {
const frame = await receiver.video(10000);
wss.clients.forEach((client) => {
if (client.readyState === 1) client.send(Buffer.from(frame.data), { binary: true });
});
} catch (err) {
console.error('NDI 读取失败:', err);
}
}, 33);
// 页面
useEffect(() => {
const canvas = canvasRef.current!;
const ctx = canvas.getContext('2d')!;
const width = 1080;
const height = 1920;
const ws = new WebSocket('ws://localhost:8080');
ws.binaryType = 'arraybuffer';
ws.onmessage = async (event) => {
const testValue = new Uint8ClampedArray(event.data);
const imageData = new ImageData(testValue, width, height);
ctx.putImageData(imageData, 0, 0);
};
return () => {
ws.close();
};
}, []);
你以为到这里就结束了?其实如果你自己动手就会发现,按我说的根本跑不起来(😋),其实是因为还有一些问题需要解决。
找不到绑定文件?Could not locate the bindings file.
其实在上面的第二步中就算按照我给出的步骤去进行,依然可能会出现问题:
Could not locate the bindings file. Tried: → D:\xx\xxx\node_modules.pnpm\grandiose@0.0.4\node_modules\grandiose\build\grandiose.node
这个库少了一些node模块,翻了下路径依赖中确实没有这些文件。问问神奇的GPT后得知这些模块是编译产生的二进制文件,所以这里我们要重新编译一下。因为我们使用的是electron的项目,所以我们需要用到electron-rebuild
pnpm install -D electron-rebuild
安装好依赖后我们执行一下,这里为了方便我添加了一个命令:
// package:json
"scripts": {
...
"package": "electron-rebuild"
},
编译依赖
执行这个命令后我们再看看依赖中这些文件是否存在,一般都是没问题的。然后我们再走第二步,基本能走到接收到视频帧并发送这一步。但是,我又要说但是了,一般在执行这个编译命令时还是会有问题的,因为这里编译包是需要Python环境的,除了需要Python环境以外,window环境还需要一个编译工具软件——vs_BuildTools。
因为这里没有报错信息无法展示了,所以就不贴图了,直接上流程吧。Python的安装这里就不做赘述了,网上有很多教程,这里需要提一嘴的是这里对Python的版本是有要求的,我这里的版本是3.11.2供参考。
关于windows的编译工具下载,一般在使用上面的编译命令后会给出链接让你去下载的,如果没有链接也可以自行去微软官网搜索名称下载。下载后安装如下工具:
这个时候再执行rebuild编译命令就基本大功告成了。
没有画面
一般走到渲染那一步就会发现页面是没办法渲染,很奇怪对吗,一般我们会怀疑是否是数据传输有问题,不过打印中发现ws返回的确实是一个arraybuffer。这说明数据确实传递过来了,那只能说明是格式有问题。我们先看看拿到的视频帧数据:
{
type: 'video',
xres: 1080,
yres: 1920,
frameRateN: 30000,
frameRateD: 1200,
pictureAspectRatio: 0.5625,
timestamp: [ 1745829481, 581817900 ],
frameFormatType: 1,
timecode: [ 0, 0 ],
lineStrideBytes: 2160,
data: <Buffer 7f eb 80 eb 7f eb 80 eb 7f eb 80 eb 7f eb 80 eb 7f eb 80 eb 7f eb 80 eb 7f eb 80 eb 7f eb 80 eb 7f eb 80 eb 7f eb 80 eb 7f eb 80 eb 7f eb 80 eb 7f eb ... 4147150 more bytes>
}
数据看起来是没问题的,然后看看收到的数据,会发现长度其实是有问题的,收到的arraybuffer的长度为4147200,按照RGBA的格式,四个字节表示一个像素应该是1080x1920x4=8294400字节,很明显这个数据少了一半,那说明返回的数据格式并不是RGBA的格式,然后我看了下后端的设置:
video_frame.FourCC = ndi.FOURCC_VIDEO_TYPE_BGRX
指定的是BGRX,虽然BGRX也需要转换一下,但是字节数应该是一样的8294400字节。
然后我们看看上面视频帧数据中的frameFormatType字段,这个字段值为1,而我们最开始打印grandiose这个对象的时候可以看到有一个字段:FORMAT_TYPE_PROGRESSIVE,它的值也是1,表示该视频帧是逐行扫描的。
然后我们再看另外一个字段:lineStrideBytes,它的值为2160,而我们可以看到帧宽度为1080,所以我们可以知道每行有2160个字节,即每两个字节表示一个像素,这里基本也明确视频帧的格式是YUV422。
这里查了下其实是因为NDI为了方便传输,就算指定了视频格式,在传输过程中SDK还是会转换格式。所以receiver中的参数我们可以稍作修改:
const receiver = await grandiose.receive({
source,
colorFormat: grandiose.COLOR_FORMAT_UYVY_BGRA,
});
这表示返回的数据有可能是UYVY也可能是BGRA。
接下来就是格式转换,这里贴一下gpt提供的UYVY转RGBA工具函数:
function uyvyToRgba(buffer, width, height) {
const rgba = new Uint8ClampedArray(width * height * 4);
let uyvyIndex = 0;
let rgbaIndex = 0;
// 逐步处理每个字节
while (uyvyIndex < buffer.length) {
// 从Buffer读取 U, Y, V, Y
const u = buffer[uyvyIndex++];
const y0 = buffer[uyvyIndex++];
const v = buffer[uyvyIndex++];
const y1 = buffer[uyvyIndex++];
// 第一个像素的YUV转RGBA
yuvToRgbaPixel(y0, u, v, rgba, rgbaIndex);
rgbaIndex += 4;
// 第二个像素的YUV转RGBA
yuvToRgbaPixel(y1, u, v, rgba, rgbaIndex);
rgbaIndex += 4;
}
return rgba;
}
function yuvToRgbaPixel(y, u, v, rgba, index) {
// 这里使用了标准的YUV转RGBA的公式(基于BT.601标准)
const Y = y;
const U = u - 128;
const V = v - 128;
// 转换公式
let R = Y + 1.402 * V;
let G = Y - 0.344136 * U - 0.714136 * V;
let B = Y + 1.772 * U;
// Clamp RGB 值确保它在 [0, 255] 范围内
rgba[index] = clamp(Math.round(R));
rgba[index + 1] = clamp(Math.round(G));
rgba[index + 2] = clamp(Math.round(B));
rgba[index + 3] = 255; // Alpha 通常为 255
}
function clamp(value) {
return Math.max(0, Math.min(255, value));
}
还记得我们上面说的数据格式有可能是UYVY也可能是BGRA吗?所以我们需要根据返回的数据长度来判断使用哪种格式化方法:
if (event.data.byteLength === 4147200) {
rgbaArray = uyvyToRgba(event.data, width, height);
}
if (event.data.byteLength === 8294400) {
rgbaArray = bgraToRgba(event.data);
}
到这步了其实还是不能渲染,我们看一下arraybuffer,数据长度是没问题,现在的长度都是8294400了,但是展开看看发现数据全是0,很明显数据是有问题的。最开始我以为是转换方法有问题,后来证明其实错怪GPT了,GPT它没毛病!
其实很简单,我们不要在页面中去做数据转换,在发送数据前做数据转换即可:
setInterval(async () => {
try {
const frame = await receiver.video(10000);
const obj = {
width: frame.xres,
height: frame.yres,
data: frame.data
}
const rgba = uyvyToRgba(frame.data, frame.xres, frame.yres)
wss.clients.forEach((client) => {
if (client.readyState === 1) client.send(Buffer.from(rgba), { binary: true });
});
} catch (err) {
console.error('NDI 读取失败:', err);
}
}, 33);
然后我们在页面中根据ws传递过来的值就可以在画布中渲染数据了。
结语
按理来说这里的渲染是需要做优化的,无论是用requestAnimationFrame、createImageBitmap、还是用WebGL启用GPU加速那都是后续的事了,这里不做讨论了就。其实这篇文章到这里可以结束了,但是还是想多说些什么,我的文章止步于此主要还是因为领导叫停了这个任务开发,认为这个开发周期太长,我是认同的。可是问题是在这个任务开始前我就表示过,前端去接NDI播放是很少见的需求,而且没什么必要,但是没办法啊前端就是人轻言微,说的话都是直接被无视的。
现在很多公司前端地位较后端是要更低些的,这点从薪资水平就能看出来,其实也能理解,毕竟大部分的数据流转都是在后端的,很多公司的前端其实就是画画管理系统的页面,渲染个数据,用个框架分分钟搞出来。这点不置可否,但是这里不是比个技术高低,只是想作为从业者替这个行业说几句话。前端是连接用户和系统的桥梁,所见即所得这是我当时对前端感兴趣决定入门的原因,但是现在看来这点既是优点也是缺点。这说明任何人都会是用户都可以提出意见,哪怕这个人不懂技术他也能指点一二。反而是前端自己的意见好像是推诿,是在摸鱼,就算他们不懂前端,但是他们看得懂页面就够了,他们认为是什么样那就是什么样。
再一个就是历史原因,切图仔天生就是低人一等的,这就导致很多吃了时代红利成了公司小领导的后端下意思还把前端当以前的切图仔,这类人还不少。点出这个不是judge这些人在前端方面的能力缺失,而是我认为既然是技术小Leader了,对某一个技术一窍不通还颐气指使是否不太行呢?面对一些技术难题,一看就说简单,一问技术方案就是让你自己想办法,虽然他不知道怎么解决但是他就是觉得简单。排期也是,好像前端只需要把东西贴上去一样,不需要考虑别的其他,什么交互逻辑、边界情况考虑、样式等等都是不存在的,所以给的也少,如果你要排期那你就是在摸鱼。万一再碰到一个喜欢把活给下面的人组做,自己摸鱼的小组长,那你这辈子有了。
满打满算在这个行业也有四个年头了,刚工作时的雄心壮志现在早已变成苟且偷生,说是苟且偷生真不为过,进不了大厂的前端就跟下水道的老鼠一样,只能捡点残渣还要人人喊打。说来说去就一句:不要学前端,不然会变得不幸。