视频预览花屏/卡顿?排查三步法:从网络到码流的定位思路

视频预览花屏/卡顿?排查三步法:从网络到码流的定位思路

系列 :乐橙开放平台 · 直播排障实战 01

文档依据开发规范 · getLiveStreamInfo · 设备 WiFi · 设备直播说明

说明 :接口均来自现行 OpenAPI,不使用「旧版本协议」栏目下已停维护接口。


门店督导小陈发来一段录屏:画面像「打马赛克的雪花」,声音还一卡一卡。后端同学截图反驳:「bindDeviceLive 返回 200,hls 地址也有。」

我打开日志,先问了两句:App 里同一台机子清晰吗? ------清晰。那问题不在摄像机,在链路的某一环。 二十分钟后,脚本输出:getLiveStreamInfo 主码流 status=3(码流转换异常),Wi-Fi 信号 intensity=1。换辅码流 + 换 AP 后,画面恢复正常。


为什么「花屏/卡顿」值得单独写一篇

接入乐橙设备的开发者,前几周通常卡在 sign、token、设备绑定;第一次被业务方催,往往就是「预览不行」:

用户描述 技术可能 常见误判
马赛克、花屏 上行带宽不足、主码流过高、丢包 「平台挂了」
一直转圈 设备离线、未 create 直播、协议选错 「前端 bug」
播几秒就停 HLS 切片超时、并发路数满 「地址过期」
有画面但模糊 用了辅码流或分辨率低 「镜头脏了」

如果把所有问题都归到「前端播放器」,会陷入 endless 改 CSS。正确做法是把链路切成三层,逐层排除:

text 复制代码
① 网络与设备层   设备在线吗?Wi-Fi 信号如何?隐私遮罩关了吗?
② 平台与码流层   直播创建了吗?status 是 0 还是 2/3?主码流还是辅码流?
③ 客户端播放层   HLS/FLV 选对了吗?HTTPS 混用?视频加密 key 传了吗?

#mermaid-svg-kvWgGS0Xo1pAmbQr{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-kvWgGS0Xo1pAmbQr .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-kvWgGS0Xo1pAmbQr .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-kvWgGS0Xo1pAmbQr .error-icon{fill:#552222;}#mermaid-svg-kvWgGS0Xo1pAmbQr .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-kvWgGS0Xo1pAmbQr .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-kvWgGS0Xo1pAmbQr .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-kvWgGS0Xo1pAmbQr .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-kvWgGS0Xo1pAmbQr .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-kvWgGS0Xo1pAmbQr .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-kvWgGS0Xo1pAmbQr .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-kvWgGS0Xo1pAmbQr .marker{fill:#333333;stroke:#333333;}#mermaid-svg-kvWgGS0Xo1pAmbQr .marker.cross{stroke:#333333;}#mermaid-svg-kvWgGS0Xo1pAmbQr svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-kvWgGS0Xo1pAmbQr p{margin:0;}#mermaid-svg-kvWgGS0Xo1pAmbQr .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-kvWgGS0Xo1pAmbQr .cluster-label text{fill:#333;}#mermaid-svg-kvWgGS0Xo1pAmbQr .cluster-label span{color:#333;}#mermaid-svg-kvWgGS0Xo1pAmbQr .cluster-label span p{background-color:transparent;}#mermaid-svg-kvWgGS0Xo1pAmbQr .label text,#mermaid-svg-kvWgGS0Xo1pAmbQr span{fill:#333;color:#333;}#mermaid-svg-kvWgGS0Xo1pAmbQr .node rect,#mermaid-svg-kvWgGS0Xo1pAmbQr .node circle,#mermaid-svg-kvWgGS0Xo1pAmbQr .node ellipse,#mermaid-svg-kvWgGS0Xo1pAmbQr .node polygon,#mermaid-svg-kvWgGS0Xo1pAmbQr .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-kvWgGS0Xo1pAmbQr .rough-node .label text,#mermaid-svg-kvWgGS0Xo1pAmbQr .node .label text,#mermaid-svg-kvWgGS0Xo1pAmbQr .image-shape .label,#mermaid-svg-kvWgGS0Xo1pAmbQr .icon-shape .label{text-anchor:middle;}#mermaid-svg-kvWgGS0Xo1pAmbQr .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-kvWgGS0Xo1pAmbQr .rough-node .label,#mermaid-svg-kvWgGS0Xo1pAmbQr .node .label,#mermaid-svg-kvWgGS0Xo1pAmbQr .image-shape .label,#mermaid-svg-kvWgGS0Xo1pAmbQr .icon-shape .label{text-align:center;}#mermaid-svg-kvWgGS0Xo1pAmbQr .node.clickable{cursor:pointer;}#mermaid-svg-kvWgGS0Xo1pAmbQr .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-kvWgGS0Xo1pAmbQr .arrowheadPath{fill:#333333;}#mermaid-svg-kvWgGS0Xo1pAmbQr .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-kvWgGS0Xo1pAmbQr .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-kvWgGS0Xo1pAmbQr .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-kvWgGS0Xo1pAmbQr .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-kvWgGS0Xo1pAmbQr .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-kvWgGS0Xo1pAmbQr .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-kvWgGS0Xo1pAmbQr .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-kvWgGS0Xo1pAmbQr .cluster text{fill:#333;}#mermaid-svg-kvWgGS0Xo1pAmbQr .cluster span{color:#333;}#mermaid-svg-kvWgGS0Xo1pAmbQr div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-kvWgGS0Xo1pAmbQr .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-kvWgGS0Xo1pAmbQr rect.text{fill:none;stroke-width:0;}#mermaid-svg-kvWgGS0Xo1pAmbQr .icon-shape,#mermaid-svg-kvWgGS0Xo1pAmbQr .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-kvWgGS0Xo1pAmbQr .icon-shape p,#mermaid-svg-kvWgGS0Xo1pAmbQr .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-kvWgGS0Xo1pAmbQr .icon-shape .label rect,#mermaid-svg-kvWgGS0Xo1pAmbQr .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-kvWgGS0Xo1pAmbQr .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-kvWgGS0Xo1pAmbQr .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-kvWgGS0Xo1pAmbQr :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 否



2 源异常
3 转码异常
0/1
用户反馈花屏/卡顿
App 原生预览正常?
设备/网络层

listDeviceDetailsByPage

currentDeviceWifi
平台码流层

getLiveStreamInfo.status

streamId 0/1
online & intensity≥3?
换网/换 AP/有线
status 0/1?
查设备升级/重启/遮罩
降 streamId 或查带宽
客户端层

协议/HTTPS/加密/decrypt
结案或 createDeviceFlvLive

乐橙开放平台在排查中的价值

乐橙开放平台 不只是「签发 URL」,还提供可编程的诊断信号

接口 诊断价值
listDeviceDetailsByPage deviceStatus / channelStatus / cameraStatus / resolutions
currentDeviceWifi 当前热点 SSID、信号 intensity(0--5)
getLiveStreamInfo 主/辅码流 HLS、status 状态码
queryLiveStatus liveToken 查直播状态
bindDeviceLive 指定 streamId 重建直播

官方对 getLiveStreamInfo 返回的 status 定义(见文档):

status 含义 排查方向
0 正在直播中 正常,查客户端
1 直播中,封面异常 可播,封面问题可忽略
2 视频源异常 设备侧:离线、遮罩、升级中
3 码流转换异常 降码流、查上行带宽
4 云存储访问异常 录播场景
10 直播暂停中 查直播计划 job

公共客户端(全文复用)

javascript 复制代码
// src/openapi-client.js
import crypto from 'node:crypto';
import { randomUUID } from 'node:crypto';

const BASE = 'https://openapi.lechange.cn/openapi';

export function calcSign(time, nonce, appSecret) {
  const raw = `time:${time},nonce:${nonce},appSecret:${appSecret}`;
  return crypto.createHash('md5').update(raw, 'utf8').digest('hex');
}

export async function callOpenApi(method, appId, appSecret, params = {}) {
  const time = Math.floor(Date.now() / 1000);
  const nonce = randomUUID();
  const body = {
    system: { ver: '1.0', appId, sign: calcSign(time, nonce, appSecret), time, nonce },
    id: randomUUID(),
    params,
  };
  const res = await fetch(`${BASE}/${method}`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(body),
  });
  const json = await res.json();
  if (json.result?.code !== '0') throw new Error(`[${json.result.code}] ${json.result.msg}`);
  return json.result.data;
}

export const getToken = (appId, appSecret) =>
  callOpenApi('accessToken', appId, appSecret, {});

第一步:网络与设备层(约 30% 花屏根因)

javascript 复制代码
// scripts/step1-network-device.js
import 'dotenv/config';
import { callOpenApi, getToken } from '../src/openapi-client.js';

const appId = process.env.LECHANGE_APP_ID;
const appSecret = process.env.LECHANGE_APP_SECRET;
const deviceId = process.env.TARGET_DEVICE_ID;
const channelId = process.env.TARGET_CHANNEL_ID ?? '0';

const token = await getToken(appId, appSecret);

// 1-A 设备与通道状态
const page = await callOpenApi('listDeviceDetailsByPage', appId, appSecret, {
  token, pageSize: 50, page: 1, source: 'bindAndShare',
});
const dev = page.deviceList?.find((d) => d.deviceId === deviceId);
const ch = dev?.channelList?.find((c) => String(c.channelId) === channelId);

console.log('--- 设备层 ---');
console.log('deviceStatus:', dev?.deviceStatus);
console.log('channelStatus:', ch?.channelStatus);
console.log('cameraStatus:', ch?.cameraStatus); // on=隐私遮罩开,可能黑屏
console.log('resolutions:', ch?.resolutions);

if (dev?.deviceStatus !== 'online' || ch?.channelStatus !== 'online') {
  console.warn('⚠ P0:设备或通道非 online,先解决离线再谈码流');
}

if (ch?.cameraStatus === 'on') {
  console.warn('⚠ P0:隐私遮罩已开启,画面可能全黑');
}

// 1-B Wi-Fi 信号(无线 IPC)
try {
  const wifi = await callOpenApi('currentDeviceWifi', appId, appSecret, { token, deviceId });
  console.log('--- Wi-Fi ---');
  console.log('ssid:', wifi.ssid, 'intensity:', wifi.intensity, 'linkEnable:', wifi.linkEnable);
  if (Number(wifi.intensity) <= 2) {
    console.warn('⚠ P1:信号偏弱,易花屏/卡顿,建议换 AP 或 5G/有线');
  }
} catch (e) {
  console.log('currentDeviceWifi 跳过(可能为有线/NVR 通道):', e.message);
}

文档:listDeviceDetailsByPage · currentDeviceWifi

踩坑 AlistDeviceDetailsByPage 显示 online,但 App 也卡------可能是同一 Wi-Fi 下上行拥塞 ,intensity 仍可能 4/5。我们会让现场用手机 Speedtest 看上行,而不只看信号格。

踩坑 B :用了已停维护的旧 deviceListstatus 数字 0/1 与新版字符串 online/offline 对不上,误判离线。统一用 listDeviceDetailsByPage


第二步:平台与码流层(status 决定方向)

javascript 复制代码
// scripts/step2-stream-status.js
import 'dotenv/config';
import { callOpenApi, getToken } from '../src/openapi-client.js';

const appId = process.env.LECHANGE_APP_ID;
const appSecret = process.env.LECHANGE_APP_SECRET;
const deviceId = process.env.TARGET_DEVICE_ID;
const channelId = process.env.TARGET_CHANNEL_ID ?? '0';

const token = await getToken(appId, appSecret);

// 2-A 确保已创建直播(须先 bindDeviceLive)
await callOpenApi('bindDeviceLive', appId, appSecret, {
  token, deviceId, channelId, streamId: 1, liveMode: 'proxy',
});

// 2-B 拉全量码流与 status
const info = await callOpenApi('getLiveStreamInfo', appId, appSecret, {
  token, deviceId, channelId,
});

const STATUS_TEXT = {
  '0': '直播中',
  '1': '直播中(封面异常)',
  '2': '视频源异常',
  '3': '码流转换异常',
  '4': '云存储访问异常',
  '10': '直播暂停',
};

console.log('--- 码流层 ---');
for (const s of info.streams ?? []) {
  console.log({
    streamId: s.streamId,
    status: s.status,
    meaning: STATUS_TEXT[s.status] ?? '未知',
    hls: s.hls?.slice(0, 80) + '...',
    liveToken: s.liveToken,
  });
}

// 2-C 按 liveToken 再查(可选)
const main = info.streams?.find((s) => s.streamId === 0);
if (main?.liveToken) {
  const qs = await callOpenApi('queryLiveStatus', appId, appSecret, {
    token, liveToken: main.liveToken,
  });
  console.log('queryLiveStatus:', qs.streams);
}

文档:bindDeviceLive · getLiveStreamInfo · queryLiveStatus

处置策略(代码里可直接用)

javascript 复制代码
function recommendStreamAction(streams) {
  const main = streams?.find((s) => s.streamId === 0);
  const sub = streams?.find((s) => s.streamId === 1);

  if (main?.status === '2') return { action: 'fix_device', hint: '查离线/遮罩/升级' };
  if (main?.status === '3' && sub?.status === '0') {
    return { action: 'use_sub_stream', streamId: 1, hint: '主码流转码异常,切辅码流' };
  }
  if (main?.status === '0') return { action: 'ok', streamId: 0 };
  return { action: 'check_client', hint: '平台侧正常,查播放器/HTTPS/加密' };
}

踩坑 C :未先 bindDeviceLive 就调 getLiveStreamInfostreams 为空------文档明确要求先创建直播

踩坑 D :10 路监控墙全开 streamId: 0,上行 + 解码双爆,第 8 路开始花屏。墙默认 streamId: 1(辅码流),焦点格再升主码流(详见多路预览实践)。


第三步:客户端播放层(协议、HTTPS、加密)

平台返回 HLS 后,前端常见三类问题:

现象 原因 处理
转圈不播 HTTPS 页面加载 HTTP m3u8 getLiveStreamInfo 里带 https / proto=https 的地址
绿屏/解码错误 m3u8 当 flv 喂给 flv.js HLS 用 hls.js,FLV 用 flv.js
花屏但 status=0 视频加密未传 key 设备 encryptMode=1 时传解密 key

轻应用播放组件文档说明:设备开启视频加密时,需传 code (自定义密钥或设备密码,见轻应用组件)。OpenSDK 侧用 LCOpenSDK_Utils.decryptPic 解 alarm 缩略图------直播解密逻辑在 SDK 内处理。

HLS 最小播放验证(浏览器控制台可测,与业务解耦):

html 复制代码
<!-- poc/hls-smoke-test.html -->
<video id="v" controls muted playsinline style="width:640px;height:360px"></video>
<script src="https://cdn.jsdelivr.net/npm/hls.js@1"></script>
<script>
  const url = 'REPLACE_WITH_https_HLS_FROM_getLiveStreamInfo';
  const v = document.getElementById('v');
  if (Hls.isSupported()) {
    const hls = new Hls({ maxBufferLength: 10 });
    hls.loadSource(url);
    hls.attachMedia(v);
    hls.on(Hls.Events.ERROR, (_, data) => console.error('hls.js', data));
  } else if (v.canPlayType('application/vnd.apple.mpegurl')) {
    v.src = url;
  }
</script>

poc 页清晰、业务页花屏 → 查业务侧是否缩放过度、是否多实例未 destroy、是否同时拉主+辅两路。

低延迟备选 :HLS 延迟 3--8 秒属正常;互动场景可试 createDeviceFlvLive(realTime) + flv.js(见设备直播说明)。

javascript 复制代码
const flv = await callOpenApi('createDeviceFlvLive', appId, appSecret, {
  token,
  deviceId,
  channelId,
  type: 'realTime',
});
console.log('flv:', flv.flv, 'flvHD:', flv.flvHD);

一键诊断脚本(三步合并)

javascript 复制代码
// scripts/diagnose-preview.js --- npm run diagnose
import 'dotenv/config';
import { callOpenApi, getToken } from '../src/openapi-client.js';

const appId = process.env.LECHANGE_APP_ID;
const appSecret = process.env.LECHANGE_APP_SECRET;
const deviceId = process.env.TARGET_DEVICE_ID;
const channelId = process.env.TARGET_CHANNEL_ID ?? '0';

const token = await getToken(appId, appSecret);
const report = { deviceId, channelId, steps: [] };

const page = await callOpenApi('listDeviceDetailsByPage', appId, appSecret, {
  token, pageSize: 50, page: 1, source: 'bindAndShare',
});
const dev = page.deviceList?.find((d) => d.deviceId === deviceId);
const ch = dev?.channelList?.find((c) => String(c.channelId) === channelId);

report.steps.push({
  step: 1,
  deviceStatus: dev?.deviceStatus,
  channelStatus: ch?.channelStatus,
  cameraStatus: ch?.cameraStatus,
});

try {
  const wifi = await callOpenApi('currentDeviceWifi', appId, appSecret, { token, deviceId });
  report.steps.push({ step: 1, wifiIntensity: wifi.intensity, ssid: wifi.ssid });
} catch { /* wired device */ }

await callOpenApi('bindDeviceLive', appId, appSecret, {
  token, deviceId, channelId, streamId: 1, liveMode: 'proxy',
});
const info = await callOpenApi('getLiveStreamInfo', appId, appSecret, { token, deviceId, channelId });
report.steps.push({ step: 2, streams: info.streams?.map((s) => ({ streamId: s.streamId, status: s.status })) });

const sub = info.streams?.find((s) => s.streamId === 1 && s.status === '0');
const httpsHls = info.streams?.find((s) => s.hls?.startsWith('https'));
report.recommendation = sub
  ? { playUrl: httpsHls?.hls ?? sub.hls, streamId: 1, note: '建议辅码流+HTTPS' }
  : { note: '检查 status=2/3 或客户端' };

console.log(JSON.stringify(report, null, 2));

运行

bash 复制代码
cp .env.example .env
# LECHANGE_APP_ID / LECHANGE_APP_SECRET / TARGET_DEVICE_ID
node scripts/diagnose-preview.js

排查决策表(贴墙版)

顺序 检查项 通过标准 失败处理
1 App 原生预览 清晰流畅 先修设备/网络,别看 Web
2 deviceStatus / channelStatus online 重启、查电源/网络
3 cameraStatus off 关隐私遮罩
4 currentDeviceWifi.intensity ≥3 换 AP、wifiAround 选强信号
5 getLiveStreamInfo.status 0/1 2→设备源;3→降 streamId
6 播放 URL 协议 HTTPS 页用 https m3u8 换 streams 里 https 条目
7 播放器 poc 页正常 查 hls 实例泄漏/路数

边界:OpenAPI 能查什么、不能查什么

不能
设备在线、遮罩、分辨率列表 替换现场网线质量
直播 status、主/辅 HLS 替你选播放器缓冲策略
Wi-Fi SSID、信号强度 保证门店上行带宽 SLA
签发 FLV/HLS WebRTC 亚秒级(需 SDK/自建转码)

生产环境注意

  1. 带宽与路数 :多路并发预览占账号媒体带宽 与路数,超额常见 FL1001 等错误------控制台看资源,墙场景默认辅码流。
  2. 地址安全:HLS 泄露即裸播;短 ticket + 会话绑定,勿写进前端静态配置。
  3. token 缓存accessToken 约 3 天有效,播放网关统一缓存,勿每观众刷新(accessToken)。
  4. 错峰 bind :批量 bindDeviceLive 间隔 80--150ms,避免并发尖峰。
  5. 升级窗口deviceStatus=upgrading 时 status 常为 2,运维日历里避开验收。
  6. 4G IPC :弱网场景优先辅码流 + FLV;极端情况考虑4G 物联网卡方案。

排错速查

现象 高概率原因 API/动作
全屏马赛克 主码流 + 弱上行 streamId:1
有 URL 黑屏 status=3 转码异常 辅码流 / 降分辨率
时好时坏 Wi-Fi 干扰 currentDeviceWifi + 换 AP
只有 Web 卡 混用协议/HTTP https hls + hls.js
第十路才卡 解码/内存 可见才播、destroy 实例

总结

预览花屏/卡顿,不要从「换摄像头」开始 。按 网络 → 码流 → 客户端 三步:

text 复制代码
listDeviceDetailsByPage + currentDeviceWifi
→ bindDeviceLive + getLiveStreamInfo(看 status)
→ HTTPS HLS / 辅码流 / 正确播放器

小陈那个门店案例,根因是 主码流 status=3 + 弱 Wi-Fi;换辅码流、补一个 AP 后,同一套前端代码无需发布。

延伸阅读

开始接入

如果你正在做监控 SaaS、门店巡店或社区视频墙,建议先把 diagnose-preview.js 接进运维后台------用户报障时一键出报告,比远程猜「是不是网络」高效得多。

乐橙开放平台 open.imou.com 注册开发者并创建应用,可领取免费设备接入额度与媒体带宽 。平台以视频技术和安全为核心,开放云直播、OpenAPI、轻应用与 OpenSDK,帮助第三方厂商和个人开发者快速、低成本 落地视频场景------会播 ,也要会排障,才是长期能维护的项目。

你在预览排查里,卡在 status=2 还是 status=3 更多?评论区说说现象,下篇可以专写「多路墙并发」调优。


参考文档