一、开放日前夜,需求从「有摄像头」变成「能打开看」
周五下午五点,某民办小学的信息中心群突然刷屏:周一家长开放日,园长要「全校教室透明」------管理后台能同时看多间教室,出事还能拖时间轴回放。
摄像头早就装好了,App 里单路预览也正常。卡住的是另一件事:画面出不了机房,进不了 Web 后台。
自研播放器?解码、加密、H265、回放时间轴,怎么也是几周。移动端 OpenSDK?官方对接量级往往是月级,赶不上周一的开放日。
真正要的不是「更炫的播放器」,而是一条 7 天内能上线的轻路径:多路预览 + 录像回放,先活下来,再谈炫技。
二、为什么校园 MVP 更适合轻应用,而不是一上来啃私有协议
2.1 校园透明化的真实约束
校园场景和巡店大屏很像,但多了几条硬约束:
- 时间窗极短:决策常在开放日、督导检查前临时拍板。
- 角色多:值班老师看本班,德育处看多楼层,校长偶尔看总览------权限必须可拆。
- 能力要成对:只 live 不够,纠纷时要能回放;回放又分云录像 / 本地卡录像。
- 终端在浏览器:校方习惯 Web 管理后台,不一定愿为预览单独发 App。
对照乐橙开放平台开发总览的对接分层(查阅时避开「旧版本协议」):
| 路径 | 出流 / 延迟量级 | 预览 | 回放 | 对讲 | 对接耗时量级 | 校园 MVP 适配 |
|---|---|---|---|---|---|---|
| 云直播 HLS | 出流较慢、延迟约 8~10s | ✅ | ❌(主路径不覆盖) | ❌ | 小时~天 | 适合家长端「看一眼」 |
| 轻应用 | 出流约 1~3s、延迟约 2~3s | ✅ | ✅ | 设备支持时可 | 约 1~7 日 | 适合管理后台宫格 + 回放 |
| 移动 / 桌面 OpenSDK | 延迟更低 | ✅ | ✅ | ✅ | 约 1~3 月 | 深度定制 App 再上 |
MVP 结论很明确:管理端先上轻应用;家长端若只要「在园时段看班」,再单独走云直播合规链路。两条路别揉成一个页面。
2.2 正确心智:平台管出流,你管「谁能看哪间教室」
text
┌────────────────────────────────────────────────────────────┐
│ 校园资产层 │
│ 教室 IPC / NVR 绑定到开发者账号 │
│ listDeviceDetailsByPage → 设备台账(deviceId/channelId) │
└────────────────────────────┬───────────────────────────────┘
│
▼
┌────────────────────────────────────────────────────────────┐
│ 业务后端(权限核心) │
│ 1. accessToken(服务端缓存,约 3 天生命周期) │
│ 2. 角色 ↔ 教室白名单(自建表) │
│ 3. getKitToken 短时签发(kitToken 约 2 小时) │
│ 4. 审计:谁、何时、看了哪路、预览还是回放 │
└────────────────────────────┬───────────────────────────────┘
│ 只返回 kitToken + 元数据
▼
┌────────────────────────────────────────────────────────────┐
│ 校园 Web 后台 │
│ ImouPlayer 宫格:streamId=1(标清)多路墙 │
│ 焦点路升清:streamId=0 │
│ 回放页:type=2 + beginTime/endTime + cloud/localRecord │
└────────────────────────────────────────────────────────────┘
一句话:开放平台保证「设备能被组件播」;「这个账号此刻能不能播这一路」必须由你的业务裁决。
2.3 为什么多路墙要从第一天就用辅码流
轻应用文档写得很直白:多路同时存在时,因解密 + canvas 渲染,性能压力高于纯 <video>。联调时我们踩过一次------9 路全部 streamId=0(高清),普通办公本风扇狂转,宫格秒卡。
改成:
- 宫格默认
streamId: 1(标清) - 双击某格再对该路重建为
streamId: 0 - 切页 / 关弹层必调
destroy()
CPU 占用立刻下来,开放日演示才站得住。
三、7 天可跑通的最小闭环(代码优先)
环境约定:Node.js ≥ 18;前端静态页 + 轻应用套件(控制台资源下载 / 文档「轻应用组件」);设备已绑定到开发者账号且
deviceStatus === "online"。
3.1 Day 1:签名壳 + accessToken(所有接口的地基)
请求统一:
POST https://openapi.lechange.cn/openapi/{method}
签名原始串(UTF-8,MD5 32 位小写):
text
time:{秒级时间戳},nonce:{随机串},appSecret:{你的密钥}
官方标准案例可自测:time:1706511734 + 文档给定 nonce/appSecret → sign 应为 fd37b62889e4757c58b8f3bf05fb9976。算不对先别调业务接口。
js
// server/imou-client.js
import crypto from 'node:crypto';
import { randomUUID } from 'node:crypto';
const OPENAPI = 'https://openapi.lechange.cn/openapi';
function sign(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,
time,
nonce,
sign: sign(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;
}
/** accessToken:约 3 天有效;TK1002 或临近过期再刷,勿每次页面加载都打 */
let tokenCache = { value: '', expireAt: 0 };
export async function getAccessToken(appId, appSecret) {
const now = Date.now();
// expireTime 为「剩余秒数」量级,这里保守提前 2 小时刷新
if (tokenCache.value && now < tokenCache.expireAt) return tokenCache.value;
const data = await callOpenApi('accessToken', appId, appSecret, {});
tokenCache = {
value: data.accessToken,
expireAt: now + (Number(data.expireTime) - 7200) * 1000,
};
return tokenCache.value;
}
解释 :appSecret 只放服务端;浏览器永远拿不到。时钟误差超过 5 分钟会签挂,服务器开 NTP。
3.2 Day 2:设备台账------只用 listDeviceDetailsByPage
网上不少旧文还在教老的设备列表方法名。新接入请走分页详情接口,返回体里的数组字段也叫 deviceList,别和已停维护的旧接口名混为一谈。
js
// server/devices.js
import { callOpenApi, getAccessToken } from './imou-client.js';
export async function listCampusDevices(appId, appSecret, { page = 1, pageSize = 50 } = {}) {
const token = await getAccessToken(appId, appSecret);
const data = await callOpenApi('listDeviceDetailsByPage', appId, appSecret, {
token,
page, // 从 1 开始
pageSize, // 1~50
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,
encryptMode: d.encryptMode, // "0" 默认加密 / "1" 自定义
});
}
}
return { count: data.count, cells };
}
验收标准:目标教室出现在 cells 里,且 deviceStatus === "online"。App 能看但列表没有,多半是设备还没绑进开发者账号------先绑定,再谈播放。
3.3 Day 3:getKitToken------预览与回放的短时票据
js
// server/kit-token.js
import { callOpenApi, getAccessToken } from './imou-client.js';
/**
* type:
* 0 所有权限
* 1 实时预览
* 2 录像回放(云 + 本地)
* 6 云台
* kitToken 约 2 小时有效;服务端建议缓存约 1 小时,避免每次开播都打 OpenAPI
*/
const kitCache = new Map(); // key -> { token, expireAt }
export async function issueKitToken(appId, appSecret, {
deviceId,
channelId,
type = '1',
userId, // 业务侧操作者,用于审计,不传给 OpenAPI
}) {
// TODO: 在此校验 userId 是否有权访问 deviceId/channelId
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),
});
// 文档:有效期约 2h;缓存 1h
const kitToken = data.kitToken;
kitCache.set(cacheKey, { token: kitToken, expireAt: Date.now() + 3600 * 1000 });
return { kitToken, cached: false };
}
Express 路由示例:
js
// server/app.js
import express from 'express';
import { listCampusDevices } from './devices.js';
import { issueKitToken } from './kit-token.js';
const app = express();
app.use(express.json());
// 多线程解码需要的跨源隔离头(轻应用文档要求)
app.use((req, res, next) => {
res.setHeader('Cross-Origin-Opener-Policy', 'same-origin');
res.setHeader('Cross-Origin-Embedder-Policy', 'require-corp');
next();
});
app.get('/api/devices', async (req, res) => {
try {
const data = await listCampusDevices(process.env.IMOU_APP_ID, process.env.IMOU_APP_SECRET);
res.json(data);
} catch (e) {
res.status(500).json({ message: e.message, detail: e.payload });
}
});
app.post('/api/kit-token', async (req, res) => {
try {
const { deviceId, channelId, type = '1', userId } = req.body;
const data = await issueKitToken(
process.env.IMOU_APP_ID,
process.env.IMOU_APP_SECRET,
{ deviceId, channelId, type, userId },
);
res.json(data);
} catch (e) {
res.status(500).json({ message: e.message, detail: e.payload });
}
});
app.listen(3000);
踩坑记录 :曾在页面 onload 时批量预拉 9 路 kitToken「图个快」,结果用户点开时部分已失效,报错接近 OP1023(kitToken 已过期)。正确姿势是 要点播哪路,再签哪路;服务端 1 小时缓存足够抵挡连点。
3.4 Day 4~5:ImouPlayer 单路跑通(先活一路)
将轻应用套件中的 imou-player.js、imou-player.css 与 WasmLib 放到静态目录(2024-12-31 后的版本务必按文档引入 WasmLib)。路径不对时,常见报错是 Uncaught SyntaxError: Unexpected token '<'------其实是 wasm/js 请求打到了 HTML 404 页。
html
<!-- public/preview.html -->
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<title>校园单路预览</title>
<link href="/vendor/imou/imou-player.css" rel="stylesheet" />
<script src="/vendor/imou/imou-player.js"></script>
<style>
body { font-family: system-ui, sans-serif; margin: 16px; }
#root { background: #111; }
</style>
</head>
<body>
<h1>教室预览</h1>
<div id="root"></div>
<script type="module">
const deviceId = new URLSearchParams(location.search).get('deviceId');
const channelId = new URLSearchParams(location.search).get('channelId') || '0';
const { kitToken } = await fetch('/api/kit-token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ deviceId, channelId, type: '1', userId: 'teacher-demo' }),
}).then((r) => r.json());
const player = new imouPlayer({
id: 'root',
width: 960,
height: 540,
deviceId,
channelId,
token: kitToken,
type: 1, // 1 直播;2 回放
streamId: 0, // 单路演示可用高清;多路墙见下一节
WasmLibPath: '/vendor/imou/', // 按你的 public 实际路径调整
muted: true,
templateMode: 'pc',
handleError(err) {
console.error('play error', err);
// 1001:解密失败 → 检查 code(自定义密钥 / 设备密码 / 默认序列号)
},
handleCallBack(evt) {
if (evt?.type === 'playStart') console.log('playing');
},
});
player.play();
window.addEventListener('beforeunload', () => player.destroy());
document.addEventListener('visibilitychange', () => {
if (document.hidden) player.pause();
else player.start();
});
</script>
</body>
</html>
若设备开启了视频加密:code 填自定义音视频密钥,或设备密码;其他情况默认可用设备序列号(以设备实际加密设置为准)。
3.5 Day 5~6:多路宫格------PlayerPool + 辅码流默认
js
// public/wall.js
/**
* 多路预览最小池:
* - 宫格一律标清 streamId=1
* - 离开视口 / 翻页必须 destroy,释放解码与并发
*/
export class PlayerPool {
constructor({ maxAlive = 9 } = {}) {
this.maxAlive = maxAlive;
this.map = new Map(); // key -> imouPlayer
}
key(deviceId, channelId) {
return `${deviceId}:${channelId}`;
}
async mount(elId, { deviceId, channelId, kitToken, streamId = 1 }) {
const k = this.key(deviceId, channelId);
if (this.map.has(k)) this.destroyOne(k);
if (this.map.size >= this.maxAlive) {
const oldest = this.map.keys().next().value;
this.destroyOne(oldest);
}
const player = new imouPlayer({
id: elId,
width: 320,
height: 180,
deviceId,
channelId: String(channelId),
token: kitToken,
type: 1,
streamId, // 墙:1;焦点:0
muted: true,
controls: false,
WasmLibPath: '/vendor/imou/',
templateMode: 'pc',
title: `${deviceId}-${channelId}`,
});
player.play();
this.map.set(k, player);
return player;
}
destroyOne(k) {
const p = this.map.get(k);
if (!p) return;
try { p.destroy(); } catch (_) {}
this.map.delete(k);
}
destroyAll() {
for (const k of [...this.map.keys()]) this.destroyOne(k);
}
}
// 使用示例
const pool = new PlayerPool({ maxAlive: 9 });
export async function renderWall(cells) {
pool.destroyAll();
const box = document.getElementById('wall');
box.innerHTML = '';
for (const [idx, cell] of cells.slice(0, 9).entries()) {
const elId = `cell-${idx}`;
const div = document.createElement('div');
div.id = elId;
box.appendChild(div);
const { kitToken } = await fetch('/api/kit-token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
deviceId: cell.deviceId,
channelId: cell.channelId,
type: '1',
userId: 'duty-teacher',
}),
}).then((r) => r.json());
await pool.mount(elId, {
deviceId: cell.deviceId,
channelId: cell.channelId,
kitToken,
streamId: 1,
});
}
}
宫格 HTML 骨架:
html
<div id="wall" style="display:grid;grid-template-columns:repeat(3,1fr);gap:8px;"></div>
<script type="module">
import { renderWall, PlayerPool } from './wall.js';
const { cells } = await fetch('/api/devices').then((r) => r.json());
// 业务侧过滤:只渲染当前老师有权看的教室
await renderWall(cells.filter((c) => c.deviceStatus === 'online'));
</script>
3.6 Day 6~7:回放页------type=2 + 时间窗
回放与预览共用组件,换 type 与时间参数即可:
js
async function openPlayback({ deviceId, channelId, beginTime, endTime, recordType = 'cloud' }) {
const { kitToken } = await fetch('/api/kit-token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ deviceId, channelId, type: '2', userId: 'moral-edu' }),
}).then((r) => r.json());
const player = new imouPlayer({
id: 'playback-root',
width: 960,
height: 540,
deviceId,
channelId: String(channelId),
token: kitToken,
type: 2,
recordType, // cloud | localRecord
beginTime, // 'YYYY-MM-DD HH:mm:ss'
endTime,
streamId: 0,
WasmLibPath: '/vendor/imou/',
controls: true,
controlsConfig: [
'play', 'volume', 'speed', 'recordChange',
'recordTimeLine', 'calendar', 'fullScreen',
],
});
player.play();
return player;
}
// 示例:查昨天 14:00-14:30 云录像
await openPlayback({
deviceId: 'YOUR_DEVICE_ID',
channelId: '0',
beginTime: '2026-08-09 14:00:00',
endTime: '2026-08-09 14:30:00',
recordType: 'cloud',
});
回放权限签发时 getKitToken 的 type 建议传 "2"(或 "0" 全权限)。本地卡录像把 recordType 改为 localRecord,并确认设备侧确有该时段录像。
3.7 七日排期(可直接贴进项目周报)
| 天 | 目标 | 验收 |
|---|---|---|
| D1 | 签名 + accessToken |
标准案例 sign 自测通过 |
| D2 | listDeviceDetailsByPage 台账 |
教室通道列表可分页拉全 |
| D3 | getKitToken 签发 API |
鉴权失败的用户拿不到 token |
| D4 | 单路 ImouPlayer + WasmLib | 出流稳定,加密设备可解密 |
| D5 | 3×3 宫格 + 辅码流 | 办公本连续预览 30 分钟不崩 |
| D6 | 回放页 cloud/local | 指定时段可拖时间轴 |
| D7 | 角色白名单 + 审计日志 + 压测 | 开放日演示脚本走通 |
四、边界、性能与生产注意
4.1 并发与路数预算
平台侧并发按「播放端 × 路数」累计:同一设备开 3 个播放器算 3 路并发。校园开放日容易「校长 + 德育 + 值班老师」同时盯墙,提前在控制台核对接入路数与带宽套餐,别把演示当天变成限流现场。
4.2 多线程解码与 COOP/COEP
要吃到 SharedArrayBuffer 多线程红利,响应头必须带:
nginx
add_header Cross-Origin-Opener-Policy "same-origin";
add_header Cross-Origin-Embedder-Policy "require-corp";
这会让「随意嵌第三方脚本的页面」变严格。校园后台若还嵌了统计 SDK、客服挂件,逐个加 CORP/CORS,或把播放页拆到独立子域。
4.3 可见才解码
宫格超过 9 路时,用 IntersectionObserver:进入视口再 mount,离开就 destroy。比「一次创建 16 个播放器再靠浏览器硬扛」稳得多。
4.4 Token 分层缓存
| Token | 生命周期 | 建议 |
|---|---|---|
accessToken |
约 3 天 | 服务端单例缓存;TK1002 再刷 |
kitToken |
约 2 小时 | 按 device+channel+type 缓存 ≤1 小时;用时再签 |
4.5 安全边界(MVP 也别省)
appSecret、管理员 token 不出浏览器。- 签发前查业务白名单:班主任 ≠ 可看全校。
- 回放导出、截图若面向家长,另补未成年人保护与同意记录(本文不展开合规全文,但上线前必须有)。
- 加密设备的
code视作密钥材料,勿写进前端仓库。
4.6 常见报错速查
| 现象 | 优先排查 |
|---|---|
Wasm / Unexpected token '<' |
WasmLibPath、静态资源是否真返回 js/wasm |
| 多路卡顿 | 是否误用全高清;是否未 destroy |
OP1023 |
kitToken 过期;是否过早批量预拉 |
TK1002 |
accessToken 过期 |
| 解密失败 1001 | code 与设备加密模式不匹配 |
| 回放无画面 | 时段无录像 / recordType 选错 / type 未按回权签发 |
五、小结与延伸
校园透明化 MVP 的最短路径,不是重写一套播放器,而是:
listDeviceDetailsByPage把教室变成可管理台账;- 业务白名单 +
getKitToken把「能看」收成短时票据; - ImouPlayer 用同一套组件吃掉预览与回放;
- 辅码流宫格 + 强制销毁 让多路在普通办公本上可演示。
开放日要的是「按时能看、出事能回放」;等节奏稳了,再按楼栋拆子账号、上对讲、或把家长端拆到云直播合规链路------那是第二期的事。
延伸阅读
- 轻应用组件参数:
type/streamId/recordType/controlsConfig/WasmLibPath - 开发规范:sign 计算、
nonce五分钟内不可复用、时钟误差 ≤5 分钟 - 全局返回码:
TK1002、OP1023与播放错误码 1001/1002
若你正在做智慧校园、托幼透明课堂或督导巡查后台,需要把乐橙设备的预览 / 回放嵌进自有 Web:可在 乐橙开放平台 注册开发者并创建应用,于控制台获取 appId / appSecret,按本文现行接口从 accessToken → 设备台账 → getKitToken → ImouPlayer 跑通第一路。平台以视频技术与安全能力为核心,提供低代码轻应用等组件,便于第三方与个人开发者较低成本落地视频场景------先把 MVP 竖起来,比纠结「完美架构」更重要。