ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

从ActiveX到WASM:WEB无插件开发包V3.2安防视频预览

从ActiveX到WASM:WEB无插件开发包V3.2安防视频预览

简介:海康威视 WEB 无插件开发包 V3.2 面向需要在浏览器端集成海康设备预览能力的 Web 开发者与项目集成人员,重点解决高版本 Chrome、Firefox 无法继续使用传统插件播放的问题。它要求设备支持 Websocket 取流,并配套 nginx 代理服务器完成无插件访问。压缩包共 75 个文件,约 9.42MB,包含 24 个 JavaScript、4 个 XML、4 个 HTML 页面,以及 PDF 开发文档、可执行程序、license、nginx 配置与启动脚本等,兼顾示例代码、运行环境与说明材料。已有 6737 人学习下载,说明其在同类集成场景中具有较高参考价值。读者可从中获得控件开发包编程指南、无插件 Demo 测试说明、依赖库说明和已知问题列表,并借助现成的 nginx 配置、启停脚本与前端页面快速搭建联调环境,定位取流失败、代理配置和浏览器兼容性等常见问题,适合需要落地设备预览功能的开发与实施人员查阅。

1. 从"插件装了还是黑屏"的现场说起

上周又碰到一个老项目:安防监控页面在旧浏览器里跑得好好的,换到新版 Chrome 之后一片黑,右下角还挂着"请安装插件"的提示。这就是典型的老式 ActiveX/NPAPI 控件撞上新浏览器内核的结果——插件模型被彻底移除,装什么运行时都没用。WEB 无插件开发包 V3.2 就是为这种场景准备的:它把原来由本地控件干的活,拆成 WebSocket 取流、WASM 软解、Canvas 渲染三件事在浏览器里完成,对外仍然保留一套 JS 接口,老项目的调用代码不用推倒重来。适合正在做安防平台前端、NVR/DVR 页面嵌入、监控大屏拼接的人,也适合被"必须装插件"卡了很久、要给自己找一个可复现出口的开发者。

2. 拆开 V3.2 的包结构:一次从 ActiveX 到 WASM 的换芯

拿到压缩包之后不要急着往项目里塞,先花十分钟把目录看明白。这一版最容易被误判的地方在于:它对外暴露的 JS 接口命名跟老的插件版高度相似,很多人以为只是换了个壳,实际上底下整条链路已经重写了。命名相似是为了兼容,链路不同决定了后面所有的坑。

2.1 老插件方案的三条死路

先想清楚为什么非换不可。老方案依赖浏览器提供的本地二进制控件加载能力,这条路现在有三处硬伤。

其一是加载机制没了。控件靠object标签加 classid 注册到系统里,浏览器内核不再支持这种注册方式,页面里那段I_InsertOBJECTPlugin调用只会静默失败,连报错都不给。

其二是部署成本下不去。每个客户端都要下安装包、要管理员权限、要处理杀软误报,一次版本升级就得把所有终端重刷一遍。做内网项目的人应该有体会,光是让现场 IT 放行一个 exe,来回能磨一周。

其三是跨平台彻底断掉。国产系统和移动端根本没有这套控件,同一个监控页面在平板和信创终端上打不开,项目验收就卡在这儿。

无插件方案把这三条一起绕过去了:纯前端资源,浏览器能跑就能用;升级只是替换静态文件;Windows、Linux、移动端共用一套代码。代价是解码从"调用系统/显卡能力"变成"在浏览器里自己算",CPU 开销明显上升,这一点后面会专门讲。

2.2 包里到底有什么:五类文件的职责划分

解压之后通常能看到这么几类东西,名字各版本略有差异,职责是稳定的:

文件/目录作用能不能改常见误操作
主 JS(如 websVideo.js)对外 API 层,初始化、登录、开流、控制不改逻辑,只在外面包一层被压缩工具二次混淆导致内部符号丢失
解码库(.wasm / .mem)H.264/H.265 软解核心不能改只拷了 js 没拷 wasm,路径 404
依赖库(jquery 等)老接口内部用到不建议换版本项目里已有高版本 jQuery 覆盖掉
样式与图标分屏布局、工具栏可覆盖全局样式冲突导致窗口高度为 0
demo 页面最小可跑示例随便改直接拿去当生产代码,没做错误处理

这里有个血泪经验:.wasm文件的加载路径是相对主 JS 所在目录算的,不是相对页面。你把 js 单独抽到static/js/下、wasm 留在原目录,运行时一定报解码库加载失败,控制台里的提示还很含糊。

2.3 取流链路拆解:六步从设备到 Canvas

把一次预览拆开看,大致是这么六步,每一层出问题表现都不一样,定位时按层往下压效率最高。

第一步,JS 层发起登录。浏览器用 HTTP 请求把账号密码发到设备,拿回一个会话标识。这一步失败的表现是"登录超时"或"用户名密码错误",跟画面无关。

第二步,建立 WebSocket 长连接。通常连的是设备侧一个专门端口(常见做法是 7681 这类私有端口,具体以设备手册为准),这条链路上跑的是私有信令加媒体数据,不是标准 RTSP。

第三步,下发开流指令。告诉设备"我要第几通道、主码流还是子码流、什么编码格式",设备开始往这条连接上推数据。

第四步,解复用。推过来的是封装格式(PS 或自定义包头),需要在 JS 里把音视频轨道拆出来,还原成一帧帧 NALU。

第五步,WASM 解码。把 NALU 喂给解码库,输出 YUV 数据。这一步是纯 CPU 计算,也是最吃性能的地方。

第六步,渲染。YUV 转 RGB 后画到 Canvas 上,或者交给 WebGL 做纹理上传。音频则走 WebAudio 播放。

提示:排查时先分清"没连上"还是"连上了没画面"。前者看网络和鉴权,后者看解码和渲染,两者混在一起查会浪费大量时间。

2.4 为什么是 WASM 软解,而不是 WebRTC 或 MSE

这是个绕不开的选型问题,很多人第一反应是"都什么年代了还用软解"。把三条路的账算一下就清楚了:

方案端到端延迟CPU 占用浏览器兼容部署前提
MSE + 服务端转封装2~5 秒低H.265 基本不可用需要流媒体服务中转
WebRTC500ms 以内中需网关做协议转换额外网关 + 证书
WebSocket + WASM 软解300ms~1 秒高全兼容设备直连即可

设备侧私有协议不对外开放,服务端转封装要先破解协议或者加一台网关机;WebRTC 要额外部署信令和转发服务,小项目根本划不来。WASM 软解的好处是浏览器只要能跑 JS 就能跑,设备直连,不用中间件,坏处是把算力压力全甩给客户端。所以这套方案的合理边界是:内网、并发路数可控、单机看 4 到 9 路、对成本敏感的场景。要做几十上百路的分发,还是得在服务端做转封装。

3. 最小可跑 Demo:先把一路预览点亮

不要一上来就往业务代码里塞,先做一个只有一路画面的空白页。能稳定点亮一路,后面 4 路 9 路才有意义。

3.1 静态资源落位与目录约定

目录结构建议保持原样,只在外面套一层业务目录:

# 推荐落位:整个 codebase 目录原封不动拷贝,不改名不拆散 static/ └── webs/ ├── websVideo.js # 主 API ├── jsPlugin-1.0.0.js # 依赖 ├── jquery-1.12.4.min.js ├── decoder.wasm # 解码库,必须与主 JS 同目录 ├── codebase.css └── demo.html

这里的关键点是"不拆散"。decoder.wasm的默认加载路径跟主 JS 绑死,你把它挪到 CDN 上或者放到assets/下面,运行时会直接失败。如果确实要换路径,得在主 JS 加载之前先设一个全局的 basePath 变量,具体变量名看包里的 demo 注释,别猜。

静态服务用 nginx 的话,注意给.wasm补 MIME 类型,否则浏览器会拒绝实例化:

# 缺这一段时,控制台会出现 "Incorrect response MIME type" 之类的报错 location ~ \.wasm$ { types { application/wasm wasm; } add_header Cache-Control "no-cache"; }

application/wasm是必须的,no-cache是为了升级版本时不至于拿到旧解码库——解码库和主 JS 版本不匹配时的表现非常玄学,有时能出画面但颜色发绿,有时干脆黑屏。

3.2 页面骨架与脚本引入顺序

引入顺序不能乱:依赖必须在主库之前,主库必须在调用代码之前。

<!DOCTYPE html> <html> <head> <meta charset="utf-8"> <title>无插件预览最小页</title> <link rel="stylesheet" href="./webs/codebase.css"> <!-- 顺序敏感:jquery -> 插件依赖 -> 主库 --> <script src="./webs/jquery-1.12.4.min.js"></script> <script src="./webs/jsPlugin-1.0.0.js"></script> <script src="./webs/websVideo.js"></script> <style> /* 容器必须有确定高度,否则播放器算出来的画布尺寸是 0 */ #divPlugin { width: 800px; height: 450px; background: #000; } </style> </head> <body> <div id="divPlugin"></div> <script src="./app.js"></script> </body> </html>

这段里最容易被忽略的是容器高度。播放器初始化时会读取容器宽高来创建 Canvas,如果容器是靠 flex 自动撑开的、当时高度还是 0,画面就会渲染到一个 0×0 的画布上,表现为"接口全部成功但就是黑的"。我一般先写死宽高跑通,再改成动态计算。

3.3 初始化与登录:四个必填参数的来源

// 1) 初始化:分屏数、全屏行为、窗口切换回调 WebVideoCtrl.I_InitPlugin({ bWndFull: true, // 双击单个窗口是否全屏 iWndowType: 1, // 1/4/9/16 分屏,先跑通 1 再改 cbSelWnd: function (data) { console.log('当前选中窗口索引', data.index) } }) // 2) 挂载播放器:必须在 DOM 就绪后调用,且只调用一次 WebVideoCtrl.I_InsertOBJECTPlugin('divPlugin') // 3) 登录设备 WebVideoCtrl.I_Login('192.168.1.64', 1, 80, 'admin', 'Admin12345', { success: function () { console.log('登录成功') }, error: function (status, xml) { // status 是数字,xml 里通常带设备返回的错误描述 console.error('登录失败', status, xml) } })

I_Login的五个参数依次是:设备 IP、协议类型(1 走 http、2 走 https)、HTTP 端口、用户名、密码。协议类型和端口必须配对,写1配443这种组合不会报错,只会一直超时。密码里有特殊字符时先确认没被前端转义,抓包看到的是乱码就是这一层的问题。

I_InsertOBJECTPlugin的入参是容器 id,注意它是"挂载"不是"创建",重复调用会把之前的实例覆盖掉,多设备场景下调用时机要控制好。

3.4 开流与参数对照表

登录成功之后才允许开流,顺序反了会返回一个语焉不详的错误码。

WebVideoCtrl.I_StartRealPlay('192.168.1.64', { iStreamType: 2, // 1=主码流 2=子码流 3=第三码流 iChannelID: 1, // 通道号从 1 开始,不是从 0 bZeroChannel: false, // 零通道:多画面拼接流,只有部分设备支持 success: function () { console.log('开流指令已下发,注意此时还没有首帧') }, error: function (status) { console.error('开流失败', status) } })

参数里最容易翻车的是iStreamType和iChannelID。

参数取值说明踩坑点
iStreamType1 / 2 / 3主/子/第三码流主码流 1080P 以上,多路必卡
iChannelID1 起通道号从 0 开始会返回"通道不存在"
bZeroChanneltrue/false零通道拼接设备不支持时直接失败
端口80 / 443 / 自定义与协议类型配对端口改了但协议没改,必超时

sSuccess回调只代表指令下发成功,不代表画面出来了。真正的首帧要等播放事件,这一点在第 4 章的事件体系里展开。

3.5 点亮之后先确认一件事:解码走的是哪条路

画面出来先别急着庆祝,打开开发者工具看两处。

一看 Network 面板里decoder.wasm有没有被真正拉下来,状态码是不是 200。如果显示(from disk cache)但你刚替换过解码库,那就是缓存没清干净,版本对不上。

二看 Performance 面板录五秒,看 CPU 曲线。软解的特征是有一个核跑满、曲线呈锯齿状;如果 CPU 很平稳而画面流畅,可能是设备侧直接推了浏览器能硬解的格式,但这种情况在 H.265 上很少见。

注意:H.265 通道在部分浏览器上会走兼容路径,表现为首帧很慢(3~5 秒)但之后流畅。这是解码器初始化耗时,不是网络问题,别去调设备参数。

4. 播放控制与事件体系:把"能看"变成"能用"

一路能亮只是及格线。真实项目里要求分屏、要求断线自愈、要求能抓图能回放,这些都要靠事件体系撑起来。

4.1 分屏布局与多实例:一页挂 4 路不走样

分屏不是开四个播放器实例,而是把播放器设成 4 窗口模式,然后按窗口索引分别开流。

// 切到 4 分屏,必须在开流之前设置 WebVideoCtrl.I_InitPlugin({ bWndFull: true, iWndowType: 4, // 4 窗口 cbSelWnd: function (data) { currentWnd = data.index // 记住当前选中的窗口,后续抓图/录像作用于它 } }) // 依次给 1~4 号窗口开流,同一台设备的不同通道 const chs = [1, 2, 3, 4] chs.forEach(function (ch, i) { WebVideoCtrl.I_StartRealPlay('192.168.1.64', { iStreamType: 2, // 多路场景一律先上子码流 iChannelID: ch, iWndIndex: i, // 窗口索引从 0 开始 success: function () {}, error: function (s) { console.warn('窗口' + i + '开流失败', s) } }) })

注意iWndIndex是从 0 开始,而iChannelID是从 1 开始,这两个下标基准不一致,是抄代码时最常见的错误来源。

多设备场景下不要反复调I_InsertOBJECTPlugin,一个页面一个播放器实例,不同设备靠 IP 区分,同一个窗口切设备时先I_StopRealPlay再重新开流。

四路以上还想流畅,必须满足两个条件:全部走子码流(一般 720P、1~2Mbps),以及单机不要超过 9 路。我实测过一台普通办公本,4 路 1080P 主码流软解,CPU 直接跑到 90% 以上,风扇声音盖过会议室空调。

4.2 事件回调:断流、错误码与重连

事件是这套方案里文档最薄、坑最深的部分。核心要盯三类:播放状态变化、异常断开、窗口切换。

// 播放状态回调:只有这里触发才代表真正出画面 WebVideoCtrl.I_SetPlayStateCallback(function (state) { // state 常见值:播放中 / 暂停 / 停止 / 缓冲 console.log('播放状态变化', state) if (state === 'playing') { firstFrameTime = Date.now() // 记录首帧时间,用于监控开流耗时 } }) // 异常回调:网络断开、会话失效都会走这里 WebVideoCtrl.I_SetExceptionCallback(function (type, msg) { console.warn('异常', type, msg) if (type === 0) { // 连接类异常,常见做法是延迟重连 setTimeout(reconnectAll, 3000) } })

WebSocket 层的关闭码可以拿来辅助判断:1000是正常关闭,1006是链路异常断开(多半是网络或设备重启),1008通常是策略拒绝(并发数超限或者鉴权过期)。看到1008就别傻等重连了,先把会话重新登一遍。

重连一定要做退避,别用固定 1 秒死循环。我一般写成 3 秒、6 秒、12 秒、封顶 30 秒,重连超过 10 次就弹个提示让用户手动刷新,避免在设备已经挂掉的情况下把浏览器拖死。

还有一个隐蔽问题:浏览器切到后台标签页之后,定时器和 requestAnimationFrame 会被节流,WebSocket 的接收缓冲会越堆越多,切回来时要么延迟暴涨,要么直接卡死。处理办法是监听visibilitychange,切走的时候主动停流,切回来重开。

4.3 抓图、本地录像与回放的时间参数

抓图返回的是 base64,浏览器不能直接写磁盘,得自己触发下载。

WebVideoCtrl.I_GetSnapshot('192.168.1.64', { iChannelID: 1, iWndIndex: 0, success: function (data) { if (!data || !data.base64) return // 首帧之前调用会返回空 const a = document.createElement('a') a.href = 'data:image/jpeg;base64,' + data.base64 a.download = 'snap_' + Date.now() + '.jpg' a.click() } })

抓图失败最常见的原因是调用时机在首帧之前,其次是分辨率和当前窗口不一致导致拿到空白图。稳妥做法是等playing状态回调之后再允许点击抓图按钮。

回放走的是另一套接口,时间格式是 ISO 风格字符串,不带时区:

WebVideoCtrl.I_StartPlayback('192.168.1.64', { iChannelID: 1, startTime: '2024-05-01T08:00:00', endTime: '2024-05-01T09:00:00', iStreamType: 1, success: function () {}, error: function (s) { console.error('回放失败', s) } })

时间区间不要跨天,跨天的请求有些设备会返回空流。做法是按天拆段,前端用时间轴控件切。回放和实时预览不能同时占用同一个窗口,切换前先停流,否则会出现音画不同步这种很难查的现象。

4.4 封一层:把初始化、登录、开流收成一个方法

业务代码里到处散着回调,维护起来很痛苦。我在项目里一般包一层 Promise 化的壳:

// 简单封装:登录 + 开流串行化,失败可 catch async function openChannel(ip, port, user, pwd, ch, streamType) { await new Promise((resolve, reject) => { WebVideoCtrl.I_Login(ip, 1, port, user, pwd, { success: resolve, error: (s, xml) => reject(new Error('登录失败 ' + s)) }) }) await new Promise((resolve, reject) => { WebVideoCtrl.I_StartRealPlay(ip, { iStreamType: streamType, iChannelID: ch, success: resolve, error: (s) => reject(new Error('开流失败 ' + s)) }) }) }

封装之后,重试逻辑、日志埋点、错误上报都能收敛到一处,比在每个页面里抄一遍回调干净得多。

5. 避坑与排查:五个反复出现的现场问题

下面这几条是同一类问题反复出现后攒下来的,按"现象 → 原因 → 解决"记,遇到相似症状可以直接对号入座。

5.1 五个高频故障的定位路径

现象一:页面白的,接口调用了但控制台没反应。原因多半是加载顺序错了,或者主 JS 被二次压缩把内部符号改名了。解决:把压缩插件对webs/目录的排除规则加上,确认引入顺序是 jquery → 插件依赖 → 主库 → 业务脚本。用window.WebVideoCtrl在控制台探一下,是 undefined 就说明主库根本没加载成功。

现象二:登录回调成功,画面一直转圈。先看协议。页面是 https 而设备只提供 ws:// 时,浏览器会以混合内容为由直接掐掉连接,且很多版本不在控制台明显报错。解决:要么页面降到 http(内网可接受),要么给设备配好证书走 wss,要么在本地做一层同源反向代理把 ws 转出去。第二种最省客户端改动,但证书维护成本在服务端。

现象三:四路以上卡顿、CPU 打满、风扇狂转。原因就是软解在跑主码流。1024×768 的子码流两路,跟 1920×1080 主码流一路的 CPU 开销差不多。解决:多路场景全部切子码流;单页并发上限设成 9;在设置里提供画质档位,让用户自己选;超过 9 路就上服务端转封装。

现象四:切到别的标签页再回来,画面卡住或者延迟好几秒。浏览器节流导致的缓冲堆积,前面提过。解决:监听visibilitychange,hidden时调I_StopRealPlay,visible时重新开流。代价是切回来有一点重连延迟,但比卡死强。

现象五:抓图返回空,或者下载下来一张纯黑图。两种原因:一是在首帧之前调用了;二是容器尺寸变化后没有重新计算画布,渲染区域和抓图区域对不上。解决:抓图按钮跟playing状态绑定,容器尺寸变化时调一次窗口重排接口(包里有对应的 resize 方法,看 demo 里的注释),别自己改 canvas 的宽高属性。

5.2 一份三十秒定位清单

手上没有抓包工具的时候,按这个顺序看,能覆盖八成问题:

  1. 控制台有没有 404,特别是.wasm和依赖 js;
  2. window.WebVideoCtrl是不是对象;
  3. 登录回调有没有进 success;
  4. Network 里 WebSocket 那条连接的帧数是不是在涨;
  5. 容器 offsetHeight 是不是 0;
  6. 当前页面协议和取流协议是否一致。

第 4 步最关键,帧数在涨而画面黑,问题一定在解码或渲染;帧数不涨,问题在网络或设备侧,往下翻没意义。

6. 进阶:码流、性能与部署边界怎么摸

前面都是把画面点亮的活,真正决定项目能不能交付的是性能账和部署边界。

先算性能。下表是同一台办公本(四核低压 U)上实测的开销,子码流按 1280×720、2Mbps 算:

路数码流类型分辨率平均 CPU内存占用主观感受
1主码流1920×108025%~35%300MB流畅
4子码流1280×72045%~60%700MB流畅
4主码流1920×108080%~95%1.1GB掉帧明显
9子码流1280×72085%~100%1.4GB勉强可用

这张表能得出两个结论。第一,路数和码流类型的组合决定上限,而不是单看路数;第二,9 路以内是这套方案的合理区间,再往上必须走服务端。

再说部署。最常见的翻车点是 https 页面配 http 设备,前面提过。另一个是跨域:静态资源和设备不同源时,抓图和部分控制接口会被浏览器拦。稳妥的部署结构是把页面、静态资源和反向代理放在同一台服务器上,由代理转发到设备,这样前端看到的永远是同源,证书只需要维护一层。

# 同源反代:页面和取流都走同一个域名,规避混合内容与跨域 location /device/ { proxy_pass http://192.168.1.64/; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; # WebSocket 升级必须透传 proxy_set_header Connection "upgrade"; proxy_read_timeout 3600s; # 长连接别被默认 60s 掐断 }

proxy_read_timeout这一行是坑王。默认 60 秒,正好对上很多设备的保活周期,表现就是"每分钟黑一次",日志里还看不到明显错误。

最后补一个验证方法:上线前用 Chrome 的 Performance 面板录 10 分钟连续播放,看内存曲线是不是单调上涨。这套方案在长时间播放后偶有内存不回收的情况,涨得慢可以接受,涨得快就得在业务层加定时重建实例。从那以后我每次交付这类页面,都会强制跑一遍十分钟压测,把内存曲线和 CPU 曲线截图存档,出了问题至少有对比基准。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进