周五下午四点,产品经理推开门:「后台首页嵌个监控,老板周一要看店门口。你就嵌一下,一行代码的事。」你下意识打开搜索:WebRTC、FFmpeg.wasm、HLS.js,仓库里还有半成品拉流页。真正让人沉默的,是文档里那两行------new imouPlayer({...}); player.play();。一行能出画。黑了三小时,多半不是设备离线,是那一行前面的签发、Wasm 和隔离头没对齐。
一、为什么「嵌个播放器」会变成三小时黑屏
后台要的从来不是「再写一个解码器」。店长 App 里画面好好的,Web 首页却一直转圈------问题通常不在镜头,而在你选错了出流路径,或把三种凭证塞进了同一个字段。
1.1 三条合法路径,管理后台先走轻应用
开发总览 把对接方式写得很克制:移动 OpenSDK、云直播 HLS、Web 轻应用、桌面 OpenSDK。数字对上需求,比再发明一套流媒体省事:
| 路径 | 出流 | 延迟量级 | 预览 | 回放 | 对讲 | 对接耗时 |
|---|---|---|---|---|---|---|
| 云直播 HLS | 约 3--8s | 约 8--10s | 有 | 主路径不覆盖 | 无 | 小时级 |
| 轻应用 ImouPlayer | 约 1--3s | 约 2--3s | 有 | 有 | 设备支持时有 | 约 1--7 日 |
| 移动 / 桌面 OpenSDK | 约 1--2s | 约 1--2s | 有 | 有 | 有 | 约 1--3 月 |
首页嵌一路、还可能要回放和云台,轻应用是最短闭环。HLS 适合「外链分发、大屏轮巡」;OpenSDK 留给原生 App。别把三条路揉进同一个 <video>。
轻应用文档写明:组件在网页和移动浏览器里无插件播放 H264/H265,支持预览、回放、对讲、云台、截图。2024-12-31 之后的版本,WasmLib 必须放进项目 public,不再是可选项。
1.2 凭证分层:三种 token 不能互填
联调时最常见的黑屏,是把管理员 accessToken 直接塞进播放器。字段都叫 token,语义完全不是一回事:
text
appId + appSecret
│ 只活在服务端,HMAC-SHA256 签每一个 OpenAPI
▼
accessToken At_* 约 3 天,管控面钥匙
│ listDeviceDetailsByPage / getKitToken
▼
kitToken Kt_* 约 2 小时,唯一能交给浏览器的播放票
│
▼
imouPlayer({ token: kitToken })
listDeviceDetailsByPage 还会返回 playToken------那是 OpenSDK 用的播放码,不是轻应用的 kitToken。三种凭证互填,页面安静得像设备没插电。
全局返回码里,TK1002 是管理员 token 过期,OP1023 是 kitToken 过期。报错先对号,再重启播放器。
1.3 正确心智:平台管出流,你管「这一刻谁能看」
一句话:开放平台保证「这台设备能被组件播」;「这个账号此刻能不能看这一路」必须由你的 BFF 裁决。 签发接口本身不会替你做租户隔离。
二、从签名壳到首页出画
环境约定:Node.js ≥ 18(自带 fetch / crypto);设备已用乐橙 App 或控制台绑到开发者账号;前端从文档「轻应用组件」页或控制台资源下载套件(含 imou-player.js、imou-player.css、WasmLib/)。
先在 open.imou.com 创建应用,于「控制台 → 我的应用 → 应用信息」取 appId / appSecret。文档「轻应用组件」页有 PC / 移动端在线体验,建议先用官方 demo 确认设备能出画,再接到自己的后台。
bash
mkdir imou-web-preview && cd imou-web-preview
npm init -y
npm i express dotenv
bash
# .env 永远不要写成 VITE_ / REACT_APP_ 前缀,否则会被打进前端包
IMOU_APP_ID=lcdxxxxxxxxx
IMOU_APP_SECRET=your_secret
2.1 现行签名壳:HMAC-SHA256,不要抄网上的 MD5
请求统一:
POST https://openapi.lechange.cn/openapi/{method}
开发规范 现行算法分三步:
text
原始串 = time:{秒},nonce:{随机串},appSecret:{密钥}
password = lowercase(hex(SHA-256(appSecret)))
sign = Base64(HMAC-SHA256(原始串, password))
time 是 UTC 秒级时间戳,与服务器误差超过 5 分钟会返回 SN1002;nonce 五分钟内不能复用,否则 SN1005。官方标准案例可自测:time=1706511734、nonce=f5a1ae2d-c09c-4d39-a744-83a5c2c653c2、appSecret=test123456789test123456789,算出来的 sign 必须是:
text
xjhCQBoJ9hRDsCjyDcHjtDNzRZ3ZJezcawsfWeiaoxU=
对不上,先别调任何业务接口。网上大量「MD5 32 位小写」示例对应的是已不维护的旧协议,新接入按现行规范走 HMAC。
js
// server/imou-client.js
import crypto from 'node:crypto';
import { randomUUID } from 'node:crypto';
const OPENAPI = 'https://openapi.lechange.cn/openapi';
function calcSign(time, nonce, appSecret) {
const raw = `time:${time},nonce:${nonce},appSecret:${appSecret}`;
const password = crypto.createHash('sha256').update(appSecret, 'utf8').digest('hex');
return crypto.createHmac('sha256', password).update(raw, 'utf8').digest('base64');
}
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, time, nonce, sign: calcSign(time, nonce, appSecret) },
id: randomUUID(),
params,
};
const res = await fetch(`${OPENAPI}/${method}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const json = await res.json();
if (json?.result?.code !== '0') {
const err = new Error(`${method} failed: ${json?.result?.code} ${json?.result?.msg}`);
err.payload = json;
throw err;
}
return json.result.data;
}
解释 :appSecret 只出现在这一层。浏览器、小程序、埋点日志里都不该出现它。时钟用 NTP,避免整点前后批量 SN1002。
2.2 accessToken:3 天钥匙,别每次刷新页面都打
accessToken:params 可空;返回 accessToken 与 expireTime(剩余秒数 )。有效期约 3 天;过期或 TK1002 再取。超过 2 天再请求会拿到新 token,新旧各自独立可用,但不要为了「刷新」而打满调用次数。
js
// server/access-token.js
import { callOpenApi } from './imou-client.js';
let cache = { value: '', expireAt: 0 };
export async function getAccessToken(appId, appSecret) {
const now = Date.now();
if (cache.value && now < cache.expireAt) return cache.value;
const data = await callOpenApi('accessToken', appId, appSecret, {});
cache = {
value: data.accessToken,
// expireTime 是剩余秒;提前 2 小时刷新,避开临界窗
expireAt: now + (Number(data.expireTime) - 7200) * 1000,
};
return cache.value;
}
2.3 台账:只用 listDeviceDetailsByPage
分页查询设备详细信息 是现行列表接口。page 从 1 起,pageSize 为 1--50;source 默认 bindAndShare。返回数组字段叫 deviceList,通道在 channelList------不要去调已停维护栏目里的旧列表方法名。
js
// server/devices.js
import { callOpenApi } from './imou-client.js';
import { getAccessToken } from './access-token.js';
export async function listPreviewChannels(appId, appSecret, { page = 1, pageSize = 50 } = {}) {
const token = await getAccessToken(appId, appSecret);
const data = await callOpenApi('listDeviceDetailsByPage', appId, appSecret, {
token,
page,
pageSize,
source: 'bindAndShare',
});
const cells = [];
for (const d of data.deviceList || []) {
for (const ch of d.channelList || []) {
cells.push({
deviceId: d.deviceId,
deviceName: d.deviceName,
deviceStatus: d.deviceStatus, // online | offline | sleep | upgrading
channelId: String(ch.channelId),
channelName: ch.channelName,
channelStatus: ch.channelStatus,
cameraStatus: ch.cameraStatus, // on=遮罩打开(画面被盖)
encryptMode: d.encryptMode, // "0" 默认加密 / "1" 自定义
});
}
}
return { count: data.count, cells };
}
验收就一件事:目标镜头出现在 cells 里,且 deviceStatus === "online"、channelStatus === "online"。App 能看、列表没有,多半还没绑进开发者账号。cameraStatus === "on" 时遮罩是盖上的,播放器出画也会是一块隐私盖,别当成组件坏了。
encryptMode 先记下来,后面填 code 要用。
2.4 getKitToken:只签发短时票,且先过业务闸门
getKitToken 的 token 字段是管理员 accessToken 。type 用字符串:
| type | 含义 |
|---|---|
0 |
所有权限 |
1 |
仅实时预览 |
2 |
录像回放(云 + 本地) |
6 |
云台转动 |
首页只要看 live,签发 type: "1"。同一页还要云台,用 "0" 或另外签 "6"。注意:这里的 type 和播放器初始化的 type 不是同一套枚举 ------播放器 type: 1 是直播,type: 2 是回放。混用是第二常见的「能签发、播不了」。
kitToken 约 2 小时 有效,文档建议服务端缓存约 1 小时 。过期对应 OP1023。
js
// server/kit-token.js
import { callOpenApi } from './imou-client.js';
import { getAccessToken } from './access-token.js';
const kitCache = new Map(); // deviceId:channelId:type -> { token, expireAt }
export async function issueKitToken(appId, appSecret, { deviceId, channelId, type = '1', userId }) {
// TODO: 用自建 ACL 判断 userId 是否有权看 deviceId/channelId
// getKitToken 不会替你隔离租户,闸门必须在调用前
const cacheKey = `${deviceId}:${channelId}:${type}`;
const hit = kitCache.get(cacheKey);
if (hit && Date.now() < hit.expireAt) return { kitToken: hit.token, cached: true };
const accessToken = await getAccessToken(appId, appSecret);
const data = await callOpenApi('getKitToken', appId, appSecret, {
token: accessToken,
deviceId,
channelId: String(channelId),
type: String(type),
});
const kitToken = data.kitToken;
if (!kitToken) throw new Error('getKitToken: missing kitToken in result.data');
kitCache.set(cacheKey, { token: kitToken, expireAt: Date.now() + 3600 * 1000 });
return { kitToken, cached: false };
}
Express 只把 kitToken 和元数据回给前端:
js
// server/app.js
import express from 'express';
import { listPreviewChannels } from './devices.js';
import { issueKitToken } from './kit-token.js';
const app = express();
app.use(express.json());
app.use(express.static('public'));
app.get('/api/devices', async (req, res) => {
try {
const data = await listPreviewChannels(process.env.IMOU_APP_ID, process.env.IMOU_APP_SECRET);
res.json(data);
} catch (e) {
res.status(502).json({ error: e.message, detail: e.payload?.result });
}
});
app.post('/api/kit-token', async (req, res) => {
try {
const { deviceId, channelId, type = '1' } = req.body || {};
if (!deviceId) return res.status(400).json({ error: 'deviceId required' });
const data = await issueKitToken(process.env.IMOU_APP_ID, process.env.IMOU_APP_SECRET, {
deviceId,
channelId: channelId ?? '0',
type,
userId: req.headers['x-user-id'], // 换成你自己的登录态
});
res.json(data);
} catch (e) {
res.status(502).json({ error: e.message, detail: e.payload?.result });
}
});
app.listen(3000, () => console.log('preview bff :3000'));
2.5 那一行代码:先静态页,再接到 Vue
套件目录建议这样放,避免 Wasm 请求打到 index.html:
text
public/
vendor/imou/
imou-player.js
imou-player.css
WasmLib/ ← 整夹拷进来,2024-12-31 后必须
html
<!-- public/index.html -->
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>homepage preview</title>
<link href="/vendor/imou/imou-player.css" rel="stylesheet" />
<script src="/vendor/imou/imou-player.js"></script>
</head>
<body>
<div id="player-root"></div>
<script>
async function mountLive() {
const { kitToken } = await fetch('/api/kit-token', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-user-id': 'demo-user' },
body: JSON.stringify({ deviceId: 'YOUR_DEVICE_SN', channelId: '0', type: '1' }),
}).then((r) => r.json());
const player = new imouPlayer({
id: 'player-root',
width: 800,
height: 450,
deviceId: 'YOUR_DEVICE_SN',
channelId: 0,
token: kitToken, // Kt_* ,不是 At_* ,也不是 playToken
type: 1, // 1 直播;2 回放
streamId: 1, // 首页先辅码流,少扛解码
WasmLibPath: '/vendor/imou/',
code: 'YOUR_DEVICE_SN', // 见下文加密规则
templateMode: 'pc',
controls: true,
controlsConfig: ['play', 'volume', 'talk', 'capture', 'ptz', 'resolution', 'fullScreen'],
dpr: window.devicePixelRatio || 0,
handleError(err) {
console.error('play error', err?.errCode, err?.errMsg);
},
handleCallBack(ev) {
if (ev?.type === 'playStart') console.log('first frame');
},
});
player.play();
return player;
}
mountLive();
</script>
</body>
</html>
解释,对着参数看:
token只接getKitToken返回的kitToken。WasmLibPath指向 WasmLib 所在目录 ,末尾斜杠和真实目录一致。指错时浏览器会把 HTML 当 JS/Wasm 解析,控制台出现Uncaught SyntaxError: Unexpected token '<'。code:设备开了视频加密才必填。自定义音视频密钥填该密钥;只设了设备密码填密码;其余情况填序列号。handleError的1001就是解密失败。streamId: 1是标清。首页单路也可以先辅码流,确认链路通了再切0。dpr是 V1.3.0 之后的参数:高清画面超过屏幕分辨率时 canvas 会锯齿,传入window.devicePixelRatio即可。
Vue 3 里同一套逻辑,关键是离开页面必须 destroy(),否则解码线程和 WebSocket 不会走:
js
// src/views/LivePreview.vue
import { onBeforeUnmount, onMounted } from 'vue';
const props = defineProps({
deviceId: { type: String, required: true },
channelId: { type: [String, Number], default: '0' },
});
const boxId = 'imou-box';
let player = null;
onMounted(async () => {
const { kitToken } = await fetch('/api/kit-token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ deviceId: props.deviceId, channelId: props.channelId, type: '1' }),
}).then((r) => r.json());
player = new window.imouPlayer({
id: boxId,
width: 960,
height: 540,
deviceId: props.deviceId,
channelId: Number(props.channelId),
token: kitToken,
type: 1,
streamId: 1,
WasmLibPath: '/vendor/imou/',
code: props.deviceId,
handleError: (err) => console.error(err?.errCode, err?.errMsg),
});
player.play();
});
onBeforeUnmount(() => {
player?.destroy();
player = null;
});
imou-player.js 用 <script> 挂到 index.html,不要试图 import 一份没有 ESM 出口的 UMD。keep-alive 缓存的页面,在 onDeactivated 里同样要 destroy(),回来再重新签发、重新 new。
回放把签发改成 type: "2",播放器改成:
js
new imouPlayer({
id: 'player-root',
width: 800,
height: 450,
deviceId,
channelId,
token: kitToken, // 用 type=2 签出来的票
type: 2, // 回放
recordType: 'cloud', // 本地卡:localRecord
beginTime: '2026-08-24 09:00:00',
endTime: '2026-08-24 09:15:00',
WasmLibPath: '/vendor/imou/',
});
时间格式必须是 YYYY-MM-DD HH:mm:ss。控件里的 recordChange / recordTimeLine / calendar / speed 只在回放态有意义。
2.6 多线程解码:隔离头只加在播放页
ImouPlayer 用 SharedArrayBuffer 做多线程解码(Chrome ≥ 91、Firefox ≥ 97、Edge ≥ 91),不满足则回落单线程。多线程要浏览器处于跨源隔离,文档给的响应头是:
js
// 只挂在承载播放器的页面,不要一刀切到整站
res.setHeader('Cross-Origin-Opener-Policy', 'same-origin');
res.setHeader('Cross-Origin-Embedder-Policy', 'require-corp');
Vite 本地:
js
// vite.config.js
export default {
server: {
headers: {
'Cross-Origin-Opener-Policy': 'same-origin',
'Cross-Origin-Embedder-Policy': 'require-corp',
},
},
};
Nginx 同理,用 location 收窄到 /preview 或静态播放页:
nginx
location /preview {
add_header Cross-Origin-Opener-Policy "same-origin";
add_header Cross-Origin-Embedder-Policy "require-corp";
}
踩过的坑 :全站加 require-corp 之后,没带 Cross-Origin-Resource-Policy 的字体、统计脚本、地图 SDK 会集体加载失败。播放能出画,后台别的页面挂了。隔离头是性能加速,不是出画的唯一条件------单线程回落也能播,只是多路时更吃 CPU。
三、出画之后才算开始
3.1 黑屏排查单(按出现频率)
| 现象 | 先查 | 依据 |
|---|---|---|
完全无画面,控制台 Unexpected token '<' |
WasmLibPath 是否打到 HTML |
轻应用 FAQ |
| 能签发、播放器转圈 | token 是不是 At_* 或 playToken |
播放器要 Kt_* |
| 播到一半断开 | kitToken 是否超过约 2 小时 | OP1023,重新签发再 play() |
| 报 1001 | code 与加密模式是否匹配 |
encryptMode 0/1 |
| 设备在线仍黑 | cameraStatus 是否为遮罩 on |
列表字段 |
| 对讲无声 / 按钮没了 | 麦克风权限;微信 web-view 不展示截图/录像 | 移动端 FAQ |
| 建立连接 404 | 是否提前预取了流地址 | 用时再 play(),不要预热 |
官方 FAQ 写得很直:实时预览、对讲、回放的流地址不要提前请求,用时再触发,避免超时和串流。
3.2 多路与内存
解密 + canvas 渲染比原生 <video> 更吃性能。宫格不要一上来全开主码流:
- 默认
streamId: 1 - 双击某格再对该路
destroy()后以streamId: 0重建 - 切页、关弹层、
keep-alive失活,必须destroy() - 移动端录制文件上限约 100MB,录不足 5 秒可能下到损坏的 MP4
js
document.addEventListener('visibilitychange', () => {
if (!player) return;
if (document.hidden) player.pause();
else player.start();
});
这是文档给移动端的建议:按 Home、切应用后进程还在解,后台继续烧电,回来还可能和系统抢音频焦点。pause / start 是成对 API,不要在可见时再 play() 叠一条连接。
3.3 生产环境注意
- kitToken 按路缓存 1 小时,不要每次 hover 预览都打 OpenAPI。并发和接口次是两本账。
- 签发前写审计:谁、何时、哪路、预览还是回放。出了纠纷,回放的是业务日志,不是播放器。
- HTTPS 页不要混用明文静态资源;套件和 Wasm 跟业务域同站最省事。
- iOS 系统静音键打开时,组件音量开了也没声------先关静音再查对讲。
- 控件按能力裁 :
opticalZoom只在直播且能力集含ZoomFocus或PTZ时出现;微信内置浏览器不展示截图/屏幕录制。 - 需要云台时,确认
getKitToken的 type 覆盖了6或0,只签1的票再点 PTZ,控件在、指令会被拒。
四、把「能播」收成可复用的最小闭环
产品口中的「一行代码」,对应的是构造函数那一行。能稳定出画的,是它前面那条短链路:
- 现行 HMAC-SHA256 签名壳,标准案例能对上。
accessToken服务端缓存,约 3 天,遇TK1002再刷。listDeviceDetailsByPage确认设备在开发者资产里且在线。- 业务 ACL 通过后,
getKitToken签发约 2 小时的kitToken。 - 前端放齐 WasmLib,
new imouPlayer({ token: kitToken }),用时再play()。
延伸阅读可以顺着现行文档往下翻:开发规范 的签名与错误码、accessToken、listDeviceDetailsByPage、轻应用组件 / getKitToken。云直播 HLS、告警回调、子账号分权是另外三条管,不要和「首页嵌一路预览」绑在同一个接口里。
如果你正在做 Web 管理后台、H5 值班页或小程序里的 web-view 预览,可以先在 开放平台(open.imou.com) 注册开发者并创建应用,按本文顺序把签名 → 台账 → 签发 → 挂载跑通。平台以视频技术和安全为核心,开放低代码播放组件,方便把设备预览、回放接到自己的网页里------先让首页出第一帧,再谈宫格、对讲和回放时间轴。