上一篇博文在浏览器上播放摄像头rtsp视频流的实现方案,我使用了开源的流媒体服务器MediamTX实现了在网页上实时播放家里的监控画面,使用的是MediamTX自带的一个测试页面,但是增加了用户密码授权后体验不太好,在微信上不能打开,而且不能控制摄像头云台上下左右转动,所以决定自己写一个页面,通过在页面上输入用户名密码播放监控画面,同时增加4个按钮,可以控制摄像头云台实时转动。
Onvif协议
ONVIF(Open Network Video Interface Forum,开放网络视频接口论坛)是一种 国际通用标准协议,用于 网络视频设备(如摄像头、NVR、VMS等)之间的互联与互通。它的主要目标是: 让不同品牌、不同型号的安防设备能够通过统一的接口标准进行通信和控制,而不需要依赖厂商的专有协议。
我的摄像头是TP-Link的某一款,是支持部分Onvif协议的,主要是视频流传输RTSP/HTTP流、PTZ(云台控制)相关功能的支持,其余的像是视频存储与回放、音频传输好像都是没有支持的,有比较完整支持的一般都是像海康威视/大华等专业领域的摄像头,所以我这摄像头能实现页面上的监控播放和云台控制也基本到头了。
onvif-zeep
操作云台我这里是用的Python的一个依赖库:onvif-zeep,可以用来与支持 ONVIF 协议的网络摄像头或录像机 进行交互,它可以让你在 Python 中轻松实现 发现设备、获取视频流、云台控制(PTZ)、事件订阅 等操作。主要就使用云台控制相关的API,直接放代码:
from onvif import ONVIFCamera
from flask import Flask, request, jsonify
# 摄像头连接信息
IP = '192.168.1.111'
PORT = 80
USERNAME = 'chengpei'
PASSWORD = 'xxxxxxxxxx'
# 物理最大角度(摄像头厂商说明的最大可旋转角度)
MAX_PAN_DEG = 90.0 # 水平 ±90°
MAX_TILT_DEG = 45.0 # 垂直 ±45°
# 创建摄像头对象
mycam = ONVIFCamera(IP, PORT, USERNAME, PASSWORD)
ptz = mycam.create_ptz_service()
media = mycam.create_media_service()
profile = media.GetProfiles()[0]
profile_token = profile.token
# 获取当前 PTZ 位置
status = ptz.GetStatus({'ProfileToken': profile_token})
current_pan = status.Position.PanTilt.x
current_tilt = status.Position.PanTilt.y
# 获取 PTZ 限制范围(防止超出)
pan_min = -1.0
pan_max = 1.0
tilt_min = -1.0
tilt_max = 1.0
if profile.PTZConfiguration.PanTiltLimits:
pan_min = profile.PTZConfiguration.PanTiltLimits.Range.XRange.Min
pan_max = profile.PTZConfiguration.PanTiltLimits.Range.XRange.Max
tilt_min = profile.PTZConfiguration.PanTiltLimits.Range.YRange.Min
tilt_max = profile.PTZConfiguration.PanTiltLimits.Range.YRange.Max
def move_camera(direction: str):
global current_pan, current_tilt
pan_delta = 0.0
tilt_delta = 0.0
# 映射角度到 [-1,1] ONVIF 坐标
deg_to_pan = 3 / MAX_PAN_DEG
deg_to_tilt = 5 / MAX_TILT_DEG
direction = direction.lower()
if direction == 'up':
tilt_delta = deg_to_tilt
elif direction == 'down':
tilt_delta = -deg_to_tilt
elif direction == 'left':
pan_delta = -deg_to_pan
elif direction == 'right':
pan_delta = deg_to_pan
else:
print("Direction must be 'up', 'down', 'left', 'right'")
return
# 计算目标位置,确保不超过 PTZ 限制
target_pan = max(min(current_pan + pan_delta, pan_max), pan_min)
target_tilt = max(min(current_tilt + tilt_delta, tilt_max), tilt_min)
# 执行绝对移动
ptz.AbsoluteMove({
'ProfileToken': profile_token,
'Position': {
'PanTilt': {'x': target_pan, 'y': target_tilt},
'Zoom': None
},
'Speed': {
'PanTilt': {'x': 0.1, 'y': 0.1},
'Zoom': None
}
})
# 更新当前位置
current_pan = target_pan
current_tilt = target_tilt
print(f"Moved {direction}: Pan={current_pan:.3f}, Tilt={current_tilt:.3f}")
# ---------------- Flask App ----------------
app = Flask(__name__)
# ---------------- HTTP 接口 ----------------
@app.route('/api/camera/move', methods=['GET'])
def api_move():
direction = request.args.get('direction', '').lower()
if not direction:
return jsonify({'success': False, 'message': 'Missing direction parameter'}), 400
move_camera(direction)
return jsonify({'success': True})
# ---------------- 启动 Flask ----------------
if __name__ == '__main__':
app.run(host='0.0.0.0', port=5000, debug=True)
这里是使用flask
提供了一个http接口,方便后续在页面上通过调用接口控制摄像头云台转动。
页面实现
页面比较简单,就是一个基于WebRTC的视频流拉取播放,和4个按钮用户调用接口控制云台转动,代码如下:
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, minimum-scale=1, user-scalable=no">
<title>WebRTC WHEP 播放器</title>
<style>
body { font-family: system-ui, -apple-system, "Segoe UI", Roboto, Arial; background:#f9fafb; color:#111; padding:20px; display:flex; justify-content:center; }
.card { background:#fff; border-radius:10px; box-shadow:0 6px 18px rgba(0,0,0,0.08); padding:16px; max-width:950px; width:100%; }
h2 { text-align:center; color:#2563eb; margin-top:0; }
.controls { display:flex; flex-wrap:nowrap; gap:8px; margin-bottom:12px; align-items:center; justify-content:flex-start; }
.controls input { padding:8px 10px; border-radius:8px; border:1px solid #ddd; font-size:14px; height:36px; box-sizing:border-box; }
.controls button { padding:8px 12px; border:none; border-radius:8px; cursor:pointer; font-weight:600; white-space:nowrap; height:36px; transition:0.2s; }
.controls button:hover { opacity:0.9; transform:translateY(-1px); }
.controls button:active { transform:translateY(0); }
#playBtn { background:#10b981; color:#fff; }
#stopBtn { background:#ef4444; color:#fff; }
#clearBtn { background:#6b7280; color:#fff; }
.controls button:disabled { opacity:0.5; cursor:default; }
video { width:100%; border-radius:10px; background:#000; margin-bottom:10px; }
pre { width:100%; font-size:13px; border:1px solid #ddd; border-radius:8px; padding:8px; background:#f3f4f6; overflow:auto; white-space:pre-wrap; }
.controls input:focus { border-color:#2563eb; outline:none; }
/* 手机/窄屏适配 */
@media (max-width: 600px) {
.controls { flex-wrap: wrap; gap:6px; }
.group-cred { display:flex; flex-direction: column; gap:6px; width:100%; }
.group-buttons { display:flex; gap:6px; width:100%; justify-content:flex-start; }
.group-clear { display:flex; width:100%; }
.controls button { width:100%; }
}
/* 加载转圈 */
#loading {
display:none; /* 默认隐藏 */
border: 4px solid #f3f3f3; /* 灰色背景圈 */
border-top: 4px solid #2563eb; /* 蓝色旋转圈 */
border-radius: 50%;
width: 24px;
height: 24px;
animation: spin 1s linear infinite;
margin-left:8px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
</style>
</head>
<body>
<div class="card">
<!-- <h2>📺 mediamtx WebRTC 播放器</h2>-->
<div class="controls">
<div class="group-cred">
<input id="username" type="text" value="" placeholder="用户名" />
<input id="password" type="password" value="" placeholder="密码" />
</div>
<div class="group-buttons">
<button id="playBtn">▶ 播放</button>
<div id="loading"></div>
<button id="stopBtn" disabled>⏹ 停止</button>
</div>
<div class="group-clear" style="display: none">
<button id="clearBtn">🗑 清除日志</button>
</div>
</div>
<video id="player" controls autoplay playsinline></video>
<!-- 在 video 标签后添加控制按钮 -->
<div class="controls" style="margin-top: 8px; justify-content:center; gap:6px;">
<button id="upBtn">⬆ 上</button>
<button id="downBtn">⬇ 下</button>
<button id="leftBtn">⬅ 左</button>
<button id="rightBtn">➡ 右</button>
</div>
<pre id="log" style="display: none"></pre>
</div>
<script>
const logEl = document.getElementById('log');
const playBtn = document.getElementById('playBtn');
const stopBtn = document.getElementById('stopBtn');
const clearBtn = document.getElementById('clearBtn');
const videoEl = document.getElementById('player');
const usernameInput = document.getElementById('username');
const passwordInput = document.getElementById('password');
const loadingEl = document.getElementById('loading');
const base = 'https://{我的mediamtx拉流地址}/stream1/';
let pc = null;
function ts() { return new Date().toLocaleTimeString(); }
function log(msg){ logEl.textContent += `[${ts()}] ${msg}\n`; logEl.scrollTop = logEl.scrollHeight; }
// 从 URL 获取用户名密码填入输入框
const params = new URLSearchParams(window.location.search);
const username = params.get("username");
const password = params.get("password");
if (username != null) {
document.getElementById("username").value = username;
}
if (password != null) {
document.getElementById("password").value = password;
}
function waitIce(pcInst, timeout=5000){
return new Promise(resolve=>{
if(pcInst.iceGatheringState==='complete') return resolve();
function check(){ if(pcInst.iceGatheringState==='complete'){ pcInst.removeEventListener('icegatheringstatechange',check); resolve(); } }
pcInst.addEventListener('icegatheringstatechange',check);
setTimeout(resolve,timeout);
});
}
async function start(){
const user = usernameInput.value.trim();
const pass = passwordInput.value.trim();
if(!user||!pass){ alert('请填写用户名/密码'); return; }
loadingEl.style.display = 'inline-block'; // 显示转圈
playBtn.disabled = true;
const whepUrl = base.endsWith('/')? base+'whep': base+'/whep';
log('开始播放 -> ' + whepUrl);
pc = new RTCPeerConnection();
pc.addEventListener('iceconnectionstatechange',()=>log('ICE状态 -> ' + pc.iceConnectionState));
pc.addEventListener('track', evt=>{
log(`收到track -> kind=${evt.track.kind}, id=${evt.track.id}`);
const stream = evt.streams[0] || new MediaStream([evt.track]);
videoEl.srcObject = stream;
});
try{
pc.addTransceiver('video',{direction:'recvonly'});
pc.addTransceiver('audio',{direction:'recvonly'});
}catch(e){ log('添加Transceiver失败: ' + e); }
try{
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
log('本地SDP长度: ' + offer.sdp.length);
await waitIce(pc);
log('ICE收集完成');
const resp = await fetch(whepUrl,{
method:'POST',
headers:{
'Content-Type':'application/sdp',
'Authorization':'Basic ' + btoa(user + ':' + pass)
},
body: offer.sdp
});
if(!resp.ok) throw new Error('HTTP ' + resp.status);
const answerSdp = await resp.text();
await pc.setRemoteDescription({type:'answer',sdp:answerSdp});
log('远端SDP已设置,长度: ' + answerSdp.length);
stopBtn.disabled = false;
}catch(err){
log('播放失败: ' + err);
alert('播放失败: ' + err);
stop();
} finally {
loadingEl.style.display = 'none'; // 隐藏转圈
playBtn.disabled = false;
}
}
function stop(){
log('停止播放');
if(pc){ pc.getSenders().forEach(s=>s.track&&s.track.stop()); pc.close(); pc=null; }
if(videoEl.srcObject){ videoEl.srcObject.getTracks().forEach(t=>t.stop()); videoEl.srcObject=null; }
playBtn.disabled=false; stopBtn.disabled=true;
}
// PTZ控制按钮事件
async function moveCamera(direction) {
try {
const user = usernameInput.value.trim();
const pass = passwordInput.value.trim();
if(!user||!pass){ alert('请填写用户名/密码'); return; }
const url = `/api/camera/move?direction=${direction}`;
const resp = await fetch(url, {
method: 'GET'
});
if(!resp.ok) throw new Error('HTTP ' + resp.status);
log(`摄像头移动 -> ${direction}`);
} catch(e) {
log('移动失败: ' + e);
}
}
document.getElementById('upBtn').addEventListener('click',()=>moveCamera('up'));
document.getElementById('downBtn').addEventListener('click',()=>moveCamera('down'));
document.getElementById('leftBtn').addEventListener('click',()=>moveCamera('left'));
document.getElementById('rightBtn').addEventListener('click',()=>moveCamera('right'));
clearBtn.addEventListener('click',()=>logEl.textContent='日志已清除');
playBtn.addEventListener('click',start);
stopBtn.addEventListener('click',stop);
</script>
</body>
</html>
页面代码基本就是让ChatGPT帮我写的,我自己改改就完事了,效果如下:
页面部署到Nginx配置一下把接口/api/camera/move
路由到上面的Python服务提供的接口就行了。
评论区