视频记录

This commit is contained in:
2026-01-08 16:03:39 +08:00
parent 69be82f892
commit 69d093fcb9
18 changed files with 550 additions and 13 deletions

35
check_encoders.py Normal file
View File

@@ -0,0 +1,35 @@
import cv2
print("OpenCV版本:", cv2.__version__)
print("可用的视频编码器:")
# 打印常见编码器是否可用
try:
fourcc = cv2.VideoWriter_fourcc(*"MP4V")
print("MP4V (MPEG-4 Part 2)", "可用")
except Exception as e:
print("MP4V (MPEG-4 Part 2)", "不可用:", e)
try:
fourcc = cv2.VideoWriter_fourcc(*"avc1")
print("avc1 (H.264)", "可用")
except Exception as e:
print("avc1 (H.264)", "不可用:", e)
try:
fourcc = cv2.VideoWriter_fourcc(*"DIVX")
print("DIVX (DivX)", "可用")
except Exception as e:
print("DIVX (DivX)", "不可用:", e)
try:
fourcc = cv2.VideoWriter_fourcc(*"XVID")
print("XVID (Xvid)", "可用")
except Exception as e:
print("XVID (Xvid)", "不可用:", e)
try:
fourcc = cv2.VideoWriter_fourcc(*"MJPG")
print("MJPG (Motion JPEG)", "可用")
except Exception as e:
print("MJPG (Motion JPEG)", "不可用:", e)

View File

@@ -1,11 +1,17 @@
# mjpeg_streamer.pynano # mjpeg_streamer.pynano
import threading import threading
import time import time
from flask import Flask, Response, render_template, request, redirect, session, url_for import os
import shutil
from datetime import datetime, timedelta
from flask import Flask, Response, render_template, request, redirect, session, url_for, send_from_directory
import cv2 import cv2
import numpy as np
import subprocess
import stat
# ====== 新增:登录配置 ====== # ====== 新增:登录配置 ======
AUTO_LOGIN = None # 👈 设置为 True 可跳过登录 AUTO_LOGIN = True # 👈 设置为 True 可跳过登录
VALID_USER = {"username": "admin", "password": "admin"} VALID_USER = {"username": "admin", "password": "admin"}
class MJPEGServer: class MJPEGServer:
@@ -15,12 +21,36 @@ class MJPEGServer:
self.port = port self.port = port
self.app = Flask(__name__) self.app = Flask(__name__)
self.app.secret_key = 'your-secret-key-change-in-prod' # 用于 session self.app.secret_key = 'your-secret-key-change-in-prod' # 用于 session
# H264编码器参数
self.h264_encoder = None
self.h264_fourcc = None
self.h264_width = None
self.h264_height = None
# 视频录制配置
self.recording_enabled = True
self.segment_duration = 60 # 视频分段时长(秒)
self.storage_path = "/video_records" # 视频存储路径
self.max_retention_days = 30 # 最大保留天数
self.video_writer = None
self.current_segment_start = None
self.recording_thread = None
self.recording_stop_event = threading.Event()
# 确保存储目录存在
os.makedirs(self.storage_path, exist_ok=True)
os.chmod(self.storage_path, stat.S_IRWXU | stat.S_IRWXG | stat.S_IRWXO)
# 路由 # 路由
self.app.add_url_rule('/', 'index', self.index) self.app.add_url_rule('/', 'index', self.index)
self.app.add_url_rule('/login', 'login', self.login, methods=['GET', 'POST']) self.app.add_url_rule('/login', 'login', self.login, methods=['GET', 'POST'])
self.app.add_url_rule('/logout', 'logout', self.logout) self.app.add_url_rule('/logout', 'logout', self.logout)
self.app.add_url_rule('/video_feed', 'video_feed', self.video_feed) self.app.add_url_rule('/video_feed', 'video_feed', self.video_feed)
self.app.add_url_rule('/h264_feed', 'h264_feed', self.h264_feed)
self.app.add_url_rule('/api/videos', 'get_videos', self.get_videos)
self.app.add_url_rule('/api/video/<path:video_path>', 'serve_video', self.serve_video)
self.app.add_url_rule('/api/disk_usage', 'get_disk_usage', self.get_disk_usage)
# 静态文件自动托管Layui # 静态文件自动托管Layui
self.app.static_folder = 'static' self.app.static_folder = 'static'
@@ -66,13 +96,312 @@ class MJPEGServer:
if not success or frame is None: if not success or frame is None:
time.sleep(0.1) time.sleep(0.1)
continue continue
ret, buffer = cv2.imencode('.jpg', frame, [int(cv2.IMWRITE_JPEG_QUALITY), 70]) ret, buffer = cv2.imencode('.jpg', frame, [int(cv2.IMWRITE_JPEG_QUALITY), 95])
if not ret: if not ret:
continue continue
yield (b'--frame\r\n' yield (b'--frame\r\n'
b'Content-Type: image/jpeg\r\n\r\n' + buffer.tobytes() + b'\r\n') b'Content-Type: image/jpeg\r\n\r\n' + buffer.tobytes() + b'\r\n')
def h264_feed(self):
"""提供H264视频流"""
if not self.check_auth():
return '', 403
return Response(self._gen_h264_ffmpeg(),
mimetype='video/H264')
def _gen_h264_ffmpeg(self):
"""使用ffmpeg生成H264视频流"""
# 获取第一帧以确定视频参数
success, frame = self.frame_buffer.get_frame()
if not success or frame is None:
raise ValueError("无法获取视频帧")
height, width = frame.shape[:2]
fps = 25.0
# 构造ffmpeg命令
ffmpeg_cmd = [
'ffmpeg',
'-f', 'rawvideo',
'-vcodec', 'rawvideo',
'-pix_fmt', 'bgr24',
'-s', f'{width}x{height}',
'-r', str(fps),
'-i', '-',
'-c:v', 'libx264',
'-preset', 'ultrafast',
'-tune', 'zerolatency',
'-b:v', '2000k',
'-f', 'h264',
'-'
]
# 启动ffmpeg进程
ffmpeg_process = subprocess.Popen(
ffmpeg_cmd,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE
)
try:
while True:
# 获取新帧
success, frame = self.frame_buffer.get_frame()
if not success or frame is None:
time.sleep(0.01)
continue
# 确保帧尺寸一致
if frame.shape[1] != width or frame.shape[0] != height:
frame = cv2.resize(frame, (width, height))
# 写入ffmpeg标准输入
ffmpeg_process.stdin.write(frame.tobytes())
ffmpeg_process.stdin.flush()
# 读取编码后的H264数据
if ffmpeg_process.stdout.poll() is None:
h264_data = ffmpeg_process.stdout.read(4096)
if h264_data:
yield h264_data
finally:
# 清理资源
if ffmpeg_process.stdin:
ffmpeg_process.stdin.close()
if ffmpeg_process.stdout:
ffmpeg_process.stdout.close()
if ffmpeg_process.stderr:
ffmpeg_process.stderr.close()
ffmpeg_process.wait()
def _start_recording_thread(self):
"""启动视频录制线程"""
if not self.recording_thread or not self.recording_thread.is_alive():
self.recording_stop_event.clear()
self.recording_thread = threading.Thread(target=self._record_video, daemon=True)
self.recording_thread.start()
print(f"[RECORDING] 视频录制线程已启动,分段时长: {self.segment_duration}")
def _record_video(self):
"""视频录制主循环"""
while not self.recording_stop_event.is_set():
try:
success, frame = self.frame_buffer.get_frame()
if not success or frame is None:
time.sleep(0.1)
continue
# 检查是否需要创建新的视频分段
now = datetime.now()
if not self.video_writer or (now - self.current_segment_start).total_seconds() >= self.segment_duration:
self._create_new_segment(frame)
# 写入视频帧
if self.video_writer:
if self.video_writer == "ffmpeg":
# 使用FFmpeg写入帧
if hasattr(self, 'ffmpeg_process') and self.ffmpeg_process.stdin:
try:
self.ffmpeg_process.stdin.write(frame.tobytes())
self.ffmpeg_process.stdin.flush()
except BrokenPipeError:
print(f"[ERROR] FFmpeg进程已断开重新创建分段")
self._create_new_segment(frame)
else:
# 使用OpenCV写入帧
self.video_writer.write(frame)
# 定期清理旧视频
if now.second % 60 == 0: # 每分钟检查一次
self._clean_old_videos()
# 移除sleep让录制线程尽可能快地处理帧
except Exception as e:
print(f"[RECORDING ERROR] {e}")
time.sleep(1)
def _create_new_segment(self, frame):
"""创建新的视频分段文件"""
# 关闭当前视频写入器
if self.video_writer:
if self.video_writer == "ffmpeg":
# 关闭FFmpeg进程
if hasattr(self, 'ffmpeg_process'):
try:
if self.ffmpeg_process.stdin:
self.ffmpeg_process.stdin.close()
if self.ffmpeg_process.stdout:
self.ffmpeg_process.stdout.close()
if self.ffmpeg_process.stderr:
self.ffmpeg_process.stderr.close()
self.ffmpeg_process.wait(timeout=5)
except Exception as e:
print(f"[ERROR] 关闭FFmpeg进程失败: {e}")
self.ffmpeg_process.terminate()
self.ffmpeg_process.wait(timeout=2)
else:
# 关闭OpenCV VideoWriter
self.video_writer.release()
self.video_writer = None
# 创建日期目录
now = datetime.now()
self.current_segment_start = now
date_dir = os.path.join(self.storage_path, now.strftime("%Y-%m-%d"))
os.makedirs(date_dir, exist_ok=True)
os.chmod(date_dir, stat.S_IRWXU | stat.S_IRWXG | stat.S_IRWXO)
# 生成视频文件名
timestamp = now.strftime("%H-%M-%S")
video_path = os.path.join(date_dir, f"video_{timestamp}.mp4")
# 创建新的视频写入器
height, width = frame.shape[:2]
fps = 15.0
# 使用FFmpeg通过subprocess创建视频录制进程
try:
# 构建FFmpeg命令
self.ffmpeg_cmd = [
'ffmpeg',
'-y', # 覆盖现有文件
'-f', 'rawvideo', # 输入格式为原始视频
'-vcodec', 'rawvideo',
'-pix_fmt', 'bgr24', # OpenCV默认的BGR格式
'-s', f'{width}x{height}', # 视频分辨率
'-r', str(fps), # 帧率
'-i', '-', # 从标准输入读取
'-c:v', 'libx264', # 使用H.264编码器
'-preset', 'ultrafast', # 编码速度优先
'-tune', 'zerolatency', # 低延迟
'-b:v', '2M', # 比特率
'-f', 'mp4', # 输出格式为MP4
video_path
]
# 启动FFmpeg进程
self.ffmpeg_process = subprocess.Popen(
self.ffmpeg_cmd,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE
)
self.video_writer = "ffmpeg" # 标记为使用FFmpeg
print(f"[RECORDING] 使用FFmpeg开始录制新分段: {video_path}")
except Exception as e:
print(f"[ERROR] FFmpeg录制失败: {e}")
# 降级使用OpenCV VideoWriter
fourcc = cv2.VideoWriter_fourcc(*"MJPG")
self.video_writer = cv2.VideoWriter(video_path + '.avi', fourcc, fps, (width, height))
print(f"[RECORDING] 降级使用OpenCV开始录制新分段: {video_path}.avi")
def _clean_old_videos(self):
"""清理超过保留期限的旧视频"""
try:
cutoff_date = datetime.now() - timedelta(days=self.max_retention_days)
cutoff_str = cutoff_date.strftime("%Y-%m-%d")
# 遍历所有日期目录
for date_dir in os.listdir(self.storage_path):
if date_dir < cutoff_str:
dir_path = os.path.join(self.storage_path, date_dir)
if os.path.isdir(dir_path):
shutil.rmtree(dir_path)
print(f"[CLEANUP] 删除过期视频目录: {dir_path}")
except Exception as e:
print(f"[CLEANUP ERROR] {e}")
def get_videos(self):
"""API: 获取视频文件列表"""
if not self.check_auth():
return {'error': 'Unauthorized'}, 403
videos = []
try:
# 获取所有日期目录
date_dirs = sorted([d for d in os.listdir(self.storage_path) if os.path.isdir(os.path.join(self.storage_path, d))], reverse=True)
for date_dir in date_dirs:
date_path = os.path.join(self.storage_path, date_dir)
video_files = sorted([f for f in os.listdir(date_path) if f.endswith('.avi') or f.endswith('.mp4')])
for video_file in video_files:
video_path = os.path.join(date_dir, video_file)
full_path = os.path.join(self.storage_path, video_path)
size = os.path.getsize(full_path) / (1024 * 1024) # MB
mtime = os.path.getmtime(full_path)
# 根据文件扩展名获取时间
if video_file.endswith('.avi'):
time_str = video_file.split('_')[1].replace('.avi', '')
else:
time_str = video_file.split('_')[1].replace('.mp4', '')
videos.append({
'path': video_path,
'date': date_dir,
'time': time_str,
'size': round(size, 2),
'mtime': mtime
})
except Exception as e:
print(f"[API ERROR] 获取视频列表失败: {e}")
return {'error': 'Failed to get videos'}, 500
return {'videos': videos}
def serve_video(self, video_path):
"""API: 提供视频文件下载/播放"""
if not self.check_auth():
return {'error': 'Unauthorized'}, 403
try:
# 确保路径安全,防止目录遍历
full_path = os.path.abspath(os.path.join(self.storage_path, video_path))
if not full_path.startswith(os.path.abspath(self.storage_path)):
return {'error': 'Invalid path'}, 400
directory = os.path.dirname(full_path)
filename = os.path.basename(full_path)
# 根据文件扩展名设置正确的MIME类型
if filename.endswith('.avi'):
mimetype = 'video/x-msvideo'
elif filename.endswith('.mp4'):
mimetype = 'video/mp4'
else:
mimetype = 'video/mp4' # 默认使用mp4的MIME类型
return send_from_directory(directory, filename, mimetype=mimetype)
except Exception as e:
print(f"[API ERROR] 提供视频文件失败: {e}")
return {'error': 'Failed to serve video'}, 500
def get_disk_usage(self):
"""API: 获取磁盘使用情况"""
if not self.check_auth():
return {'error': 'Unauthorized'}, 403
try:
statvfs = os.statvfs(self.storage_path)
total = statvfs.f_frsize * statvfs.f_blocks / (1024 * 1024 * 1024) # GB
used = statvfs.f_frsize * (statvfs.f_blocks - statvfs.f_bfree) / (1024 * 1024 * 1024) # GB
free = statvfs.f_frsize * statvfs.f_bfree / (1024 * 1024 * 1024) # GB
usage = (used / total) * 100 if total > 0 else 0
return {
'total': round(total, 2),
'used': round(used, 2),
'free': round(free, 2),
'usage_percent': round(usage, 1)
}
except Exception as e:
print(f"[API ERROR] 获取磁盘使用情况失败: {e}")
return {'error': 'Failed to get disk usage'}, 500
def start(self): def start(self):
# 启动视频录制
self._start_recording_thread()
# 启动Web服务器
thread = threading.Thread( thread = threading.Thread(
target=self.app.run, target=self.app.run,
kwargs={'host': self.host, 'port': self.port, 'debug': False, 'use_reloader': False}, kwargs={'host': self.host, 'port': self.port, 'debug': False, 'use_reloader': False},

View File

@@ -4,15 +4,22 @@
<meta charset="utf-8"> <meta charset="utf-8">
<title>环视系统</title> <title>环视系统</title>
<link rel="stylesheet" href="/static/layui/css/layui.css"> <link rel="stylesheet" href="/static/layui/css/layui.css">
<link href="https://vjs.zencdn.net/8.23.3/video-js.css" rel="stylesheet" />
<style> <style>
html, body { margin: 0; padding: 0; height: 100%; overflow: hidden; } html, body { margin: 0; padding: 0; height: 100%; overflow: hidden; }
#app { display: flex; height: 100vh; } #app { display: flex; height: 100vh; }
#sidebar { width: 200px; background: #393D49; color: white; padding: 20px 0; } #sidebar { width: 200px; background: #393D49; color: white; padding: 20px 0; }
#main { flex: 1; position: relative; background: black; } #main { flex: 1; position: relative; background: black; }
#video { width: 100%; height: 100%; object-fit: contain; } #video { width: 100%; height: 100%; object-fit: contain; }
#video-player { width: 100%; height: 100%; display: none; }
.menu-item { padding: 12px 20px; cursor: pointer; } .menu-item { padding: 12px 20px; cursor: pointer; }
.menu-item:hover { background: #4B515D; } .menu-item:hover { background: #4B515D; }
.active { background: #009688 !important; } .active { background: #009688 !important; }
.disk-info { padding: 10px; background: #4B515D; margin: 10px; border-radius: 5px; font-size: 12px; }
.disk-bar { width: 100%; height: 6px; background: #5a5e6a; border-radius: 3px; margin: 5px 0; overflow: hidden; }
.disk-fill { height: 100%; background: #009688; transition: width 0.3s; }
.control-panel { position: absolute; top: 10px; right: 10px; background: rgba(0,0,0,0.7); padding: 10px; border-radius: 5px; color: white; }
.playback-panel { position: absolute; top: 10px; left: 220px; background: rgba(0,0,0,0.7); padding: 10px; border-radius: 5px; color: white; display: none; }
</style> </style>
</head> </head>
<body> <body>
@@ -24,26 +31,192 @@
<div class="menu-item" data-view="left">左视</div> <div class="menu-item" data-view="left">左视</div>
<div class="menu-item" data-view="right">右视</div> <div class="menu-item" data-view="right">右视</div>
<hr style="border-color:#5a5e6a;margin:15px 10px"> <hr style="border-color:#5a5e6a;margin:15px 10px">
<div class="menu-item" id="live-view">实时监控</div>
<div class="menu-item" id="playback-view">视频回放</div>
<hr style="border-color:#5a5e6a;margin:15px 10px">
<div class="disk-info">
<div>硬盘使用率</div>
<div class="disk-bar"><div class="disk-fill" id="disk-fill" style="width: 0%"></div></div>
<div id="disk-text">0%</div>
</div>
<hr style="border-color:#5a5e6a;margin:15px 10px">
<div class="menu-item" onclick="logout()">退出登录</div> <div class="menu-item" onclick="logout()">退出登录</div>
</div> </div>
<div id="main"> <div id="main">
<img id="video" src="/video_feed" /> <img id="video" src="/video_feed" />
<video id="video-player" class="video-js vjs-default-skin" controls preload="auto" data-setup="{}">
<p class="vjs-no-js">
要查看此视频请启用JavaScript并考虑升级到支持HTML5视频的浏览器
<a href="https://videojs.com.cn/html5-video-support/" target="_blank">支持HTML5视频</a>
</p>
</video>
<div class="control-panel">
<div>当前模式:<span id="mode-text">实时监控</span></div>
</div>
<div class="playback-panel" id="playback-panel">
<div style="margin-bottom: 10px">
<label>选择日期:</label>
<input type="date" id="date-picker" style="padding: 5px; border-radius: 3px; border: 1px solid #ccc;">
<button onclick="loadVideos()" style="padding: 5px 10px; background: #009688; color: white; border: none; border-radius: 3px; cursor: pointer;">加载</button>
</div>
<div>
<label>选择视频:</label>
<select id="video-select" style="padding: 5px; border-radius: 3px; border: 1px solid #ccc; width: 200px;">
<option value="">请选择视频</option>
</select>
</div>
</div>
</div> </div>
</div> </div>
<script src="https://vjs.zencdn.net/8.23.3/video.min.js"></script>
<script src="/static/layui/layui.js"></script> <script src="/static/layui/layui.js"></script>
<script> <script>
// 切换视图(可选:后续通过 WebSocket 或 REST API 控制后端 current_view // 初始化日期选择器为当前日期
document.querySelectorAll('.menu-item[data-view]').forEach(item => { document.getElementById('date-picker').value = new Date().toISOString().split('T')[0];
item.addEventListener('click', function() {
document.querySelector('.active').classList.remove('active'); // 实时监控和回放模式切换
this.classList.add('active'); document.getElementById('live-view').addEventListener('click', function() {
const view = this.getAttribute('data-view'); setMode('live');
// TODO: 发送 AJAX 请求通知后端切换 current_view
// fetch('/api/set_view?view=' + view);
});
}); });
document.getElementById('playback-view').addEventListener('click', function() {
setMode('playback');
});
function setMode(mode) {
const liveViewBtn = document.getElementById('live-view');
const playbackViewBtn = document.getElementById('playback-view');
const videoImg = document.getElementById('video');
const videoPlayer = document.getElementById('video-player');
const playbackPanel = document.getElementById('playback-panel');
const modeText = document.getElementById('mode-text');
if (mode === 'live') {
liveViewBtn.classList.add('active');
playbackViewBtn.classList.remove('active');
videoImg.style.display = 'block';
videoPlayer.style.display = 'none';
playbackPanel.style.display = 'none';
modeText.textContent = '实时监控';
} else if (mode === 'playback') {
liveViewBtn.classList.remove('active');
playbackViewBtn.classList.add('active');
videoImg.style.display = 'none';
videoPlayer.style.display = 'block';
playbackPanel.style.display = 'block';
modeText.textContent = '视频回放';
loadVideos(); // 加载当天视频
}
}
// 加载指定日期的视频列表
function loadVideos() {
const datePicker = document.getElementById('date-picker');
const videoSelect = document.getElementById('video-select');
const selectedDate = datePicker.value;
if (!selectedDate) {
alert('请选择日期');
return;
}
// 清空视频选择器
videoSelect.innerHTML = '<option value="">请选择视频</option>';
// 请求视频列表
fetch('/api/videos')
.then(response => response.json())
.then(data => {
if (data.error) {
alert('获取视频列表失败:' + data.error);
return;
}
// 筛选指定日期的视频
const filteredVideos = data.videos.filter(video => video.date === selectedDate);
if (filteredVideos.length === 0) {
videoSelect.innerHTML += '<option value="">当天没有视频</option>';
return;
}
// 添加视频选项
filteredVideos.forEach(video => {
const option = document.createElement('option');
option.value = video.path;
option.textContent = video.time + ' (' + video.size + 'MB)';
videoSelect.appendChild(option);
});
// 监听选择变化,播放视频
videoSelect.addEventListener('change', function() {
const selectedPath = this.value;
if (selectedPath) {
playVideo(selectedPath);
}
});
})
.catch(error => {
console.error('获取视频列表出错:', error);
alert('获取视频列表失败');
});
}
// 播放选定的视频
function playVideo(videoPath) {
const videoElement = document.getElementById('video-player');
const player = videojs(videoElement);
// 根据文件扩展名设置视频类型
const videoType = videoPath.endsWith('.avi') ? 'video/x-msvideo' : 'video/mp4';
// 设置视频源
player.src({
src: '/api/video/' + videoPath,
type: videoType
});
// 播放视频
player.play();
}
// 更新磁盘使用率信息
function updateDiskUsage() {
fetch('/api/disk_usage')
.then(response => response.json())
.then(data => {
if (data.error) {
console.error('获取磁盘使用率失败:', data.error);
return;
}
const diskFill = document.getElementById('disk-fill');
const diskText = document.getElementById('disk-text');
const usagePercent = data.usage_percent;
diskFill.style.width = usagePercent + '%';
diskText.textContent = usagePercent + '% (' + data.used + 'GB / ' + data.total + 'GB)';
// 根据使用率设置不同颜色
if (usagePercent > 80) {
diskFill.style.background = '#FF5722'; // 红色
} else if (usagePercent > 60) {
diskFill.style.background = '#FFC107'; // 黄色
} else {
diskFill.style.background = '#009688'; // 绿色
}
})
.catch(error => {
console.error('获取磁盘使用率出错:', error);
});
}
// 初始化并定期更新磁盘使用率
updateDiskUsage();
setInterval(updateDiskUsage, 60000); // 每分钟更新一次
// 退出登录
function logout() { function logout() {
if (confirm('确定退出?')) { if (confirm('确定退出?')) {
window.location.href = '/logout'; window.location.href = '/logout';