# mjpeg_streamer.pynano import threading import time 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 numpy as np import subprocess import stat # ====== 新增:登录配置 ====== AUTO_LOGIN = True # 👈 设置为 True 可跳过登录 VALID_USER = {"username": "admin", "password": "admin"} class MJPEGServer: def __init__(self, frame_buffer, host="0.0.0.0", port=8080): self.frame_buffer = frame_buffer self.host = host self.port = port self.app = Flask(__name__) 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('/login', 'login', self.login, methods=['GET', 'POST']) 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('/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/', 'serve_video', self.serve_video) self.app.add_url_rule('/api/disk_usage', 'get_disk_usage', self.get_disk_usage) # 静态文件自动托管(Layui) self.app.static_folder = 'static' def is_logged_in(self): return session.get('logged_in', False) def check_auth(self): if AUTO_LOGIN: session['logged_in'] = True return True return self.is_logged_in() def index(self): if not self.check_auth(): return redirect(url_for('login')) return render_template('index.html') def login(self): if request.method == 'POST': username = request.form['username'] password = request.form['password'] if username == VALID_USER['username'] and password == VALID_USER['password']: session['logged_in'] = True return redirect(url_for('index')) else: return '' return render_template('login.html') def logout(self): session.pop('logged_in', None) return redirect(url_for('login')) def video_feed(self): if not self.check_auth(): return '', 403 return Response(self._gen(), mimetype='multipart/x-mixed-replace; boundary=frame') def _gen(self): while True: success, frame = self.frame_buffer.get_frame() if not success or frame is None: time.sleep(0.1) continue ret, buffer = cv2.imencode('.jpg', frame, [int(cv2.IMWRITE_JPEG_QUALITY), 95]) if not ret: continue yield (b'--frame\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): # 启动视频录制 self._start_recording_thread() # 启动Web服务器 thread = threading.Thread( target=self.app.run, kwargs={'host': self.host, 'port': self.port, 'debug': False, 'use_reloader': False}, daemon=True ) thread.start() print(f"[MJPEG] Web 系统已启动,访问 http://{self.host}:{self.port}")