feat: 新增视频分析流水线、Web界面、孤儿清理工具;修复健壮性问题
- 新增 analysis/ 模块:MOG2活动检测、YOLO人物跟踪、MediaPipe摔倒检测、Ollama活动打标、缩略图抽取 - 新增 webapp/:Flask事件浏览界面,按需HEVC→H264转码缓存 - 新增 analyze.py:7阶段状态机流水线(scan→backfill→motion→person→fall→activity→thumbnail) - 新增 cleanup_orphans.py:清理文件已删除的数据库孤儿记录 - 修复:motion/person/activity三阶段加单视频异常兜底,避免批次中断 - 修复:ollama_client加健康检查和3次重试,num_ctx从8192降到2048 - 修复:transcode_cache加fcntl文件锁,防止并发转码同一视频 - 修复:SQLite开WAL模式+busy_timeout,支持读写并发 - 修复:sync.py ffmpeg路径改为shutil.which查找,删除automate.py死代码PYTHON - 修复:thumbnail失败加3次重试上限,避免无限重试损坏文件 - 新增:requirements.lock锁定版本、README文档 - 更新:.gitignore排除.playwright-mcp、截图、备份文件
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
"""网页播放的按需转码缓存:源文件mp4容器HEVC轨道sample entry编码标签缺失,
|
||||
浏览器<video>标签无法识别,第一次点击播放时用ffmpeg(VideoToolbox硬件加速)转码成
|
||||
标准H264并缓存到 TRANSCODED_DIR,之后复用缓存不重复转码;不预先转码全部录像。
|
||||
"""
|
||||
import fcntl
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from analysis.config import TRANSCODED_DIR
|
||||
|
||||
|
||||
def cached_path(video_id):
|
||||
return TRANSCODED_DIR / f"{video_id}.mp4"
|
||||
|
||||
|
||||
def _lock_path(video_id):
|
||||
return TRANSCODED_DIR / f"{video_id}.lock"
|
||||
|
||||
|
||||
def ensure_transcoded(video_id, source_path):
|
||||
"""返回可直接播放的文件路径。已有缓存直接复用,否则先转码再缓存。
|
||||
|
||||
用 fcntl 文件锁防止两个请求同时转码同一个视频:先到的转码,后到的等锁释放后
|
||||
直接读缓存。锁文件在转码完成后删除。
|
||||
"""
|
||||
output_path = cached_path(video_id)
|
||||
if output_path.exists():
|
||||
return output_path
|
||||
|
||||
TRANSCODED_DIR.mkdir(exist_ok=True)
|
||||
lock_file = _lock_path(video_id)
|
||||
lock_fd = open(lock_file, "w")
|
||||
|
||||
try:
|
||||
# 阻塞等待锁,最多等 10 分钟(转码大文件可能慢)
|
||||
fcntl.flock(lock_fd, fcntl.LOCK_EX)
|
||||
|
||||
# 拿到锁后再检查一次:可能前面的进程已经转码完成
|
||||
if output_path.exists():
|
||||
return output_path
|
||||
|
||||
tmp_path = output_path.with_suffix(".mp4.tmp")
|
||||
result = subprocess.run(
|
||||
[
|
||||
"ffmpeg", "-i", str(source_path),
|
||||
"-c:v", "h264_videotoolbox", "-b:v", "1500k",
|
||||
"-c:a", "aac",
|
||||
"-movflags", "+faststart",
|
||||
"-f", "mp4", # 输出文件名带.tmp后缀,ffmpeg无法从扩展名猜出格式,需显式指定
|
||||
"-y", str(tmp_path),
|
||||
],
|
||||
capture_output=True, timeout=600,
|
||||
)
|
||||
if result.returncode != 0 or not tmp_path.exists():
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
raise RuntimeError(result.stderr.decode("utf-8", errors="ignore"))
|
||||
|
||||
tmp_path.rename(output_path) # 转码完成后原子改名,避免并发请求读到半成品文件
|
||||
return output_path
|
||||
finally:
|
||||
fcntl.flock(lock_fd, fcntl.LOCK_UN)
|
||||
lock_fd.close()
|
||||
lock_file.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def cache_stats():
|
||||
if not TRANSCODED_DIR.exists():
|
||||
return {"file_count": 0, "total_size_bytes": 0, "files": []}
|
||||
|
||||
files = []
|
||||
total = 0
|
||||
for f in TRANSCODED_DIR.glob("*.mp4"):
|
||||
size = f.stat().st_size
|
||||
total += size
|
||||
files.append({"video_id": int(f.stem), "size_bytes": size})
|
||||
|
||||
return {"file_count": len(files), "total_size_bytes": total, "files": files}
|
||||
|
||||
|
||||
def clear_cache():
|
||||
if not TRANSCODED_DIR.exists():
|
||||
return {"deleted": 0, "freed_bytes": 0}
|
||||
|
||||
deleted = 0
|
||||
freed = 0
|
||||
for f in TRANSCODED_DIR.glob("*.mp4"):
|
||||
freed += f.stat().st_size
|
||||
f.unlink()
|
||||
deleted += 1
|
||||
# 清理残留的 .tmp 和 .lock 文件
|
||||
for f in TRANSCODED_DIR.glob("*.tmp"):
|
||||
f.unlink(missing_ok=True)
|
||||
for f in TRANSCODED_DIR.glob("*.lock"):
|
||||
f.unlink(missing_ok=True)
|
||||
|
||||
return {"deleted": deleted, "freed_bytes": freed}
|
||||
Reference in New Issue
Block a user