b3608f534e
- 新增 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、截图、备份文件
104 lines
3.4 KiB
Python
104 lines
3.4 KiB
Python
"""用 ffmpeg 在每条 events 记录的 start_offset_sec 处抽一帧,生成缩略图。
|
|
|
|
供网页事件列表展示预览图,点击缩略图再跳转播放对应视频的完整时间点。
|
|
|
|
失败重试上限:单个事件连续失败超过 MAX_ATTEMPTS 次后,在 extra_json 里标记
|
|
thumbnail_failed,后续运行直接跳过,避免对损坏文件无限重试。
|
|
"""
|
|
import json
|
|
import subprocess
|
|
|
|
from analysis.config import PROJECT_ROOT, THUMBNAILS_DIR
|
|
from analysis.db import get_events, get_video_by_id, update_event_thumbnail, update_event_extra
|
|
|
|
MAX_ATTEMPTS = 3
|
|
|
|
|
|
def _extract_frame(video_path, offset_sec, output_path):
|
|
# -ss 放在 -i 之后用精确seek:放在 -i 之前的快速seek在临近视频结尾处
|
|
# 会因缺少解码所需的参考帧而失败(HEVC "Could not find ref with POC")。
|
|
# 录像多为几秒到几十秒的短片,精确seek的性能代价可忽略。
|
|
result = subprocess.run(
|
|
[
|
|
"ffmpeg", "-i", str(video_path), "-ss", str(offset_sec),
|
|
"-frames:v", "1", "-q:v", "3", "-y", str(output_path),
|
|
],
|
|
capture_output=True, timeout=30,
|
|
)
|
|
return result.returncode == 0 and output_path.exists()
|
|
|
|
|
|
def _get_attempts(event):
|
|
try:
|
|
extra = json.loads(event["extra_json"] or "{}")
|
|
return extra.get("thumbnail_attempts", 0)
|
|
except (json.JSONDecodeError, TypeError):
|
|
return 0
|
|
|
|
|
|
def _increment_attempts(event):
|
|
try:
|
|
extra = json.loads(event["extra_json"] or "{}")
|
|
except (json.JSONDecodeError, TypeError):
|
|
extra = {}
|
|
extra["thumbnail_attempts"] = extra.get("thumbnail_attempts", 0) + 1
|
|
if extra["thumbnail_attempts"] >= MAX_ATTEMPTS:
|
|
extra["thumbnail_failed"] = True
|
|
update_event_extra(event["id"], json.dumps(extra, ensure_ascii=False))
|
|
|
|
|
|
def process_all(limit=None):
|
|
THUMBNAILS_DIR.mkdir(exist_ok=True)
|
|
events = []
|
|
for e in get_events():
|
|
if e["thumbnail_path"]:
|
|
continue
|
|
try:
|
|
extra = json.loads(e["extra_json"] or "{}")
|
|
if extra.get("thumbnail_failed"):
|
|
continue
|
|
except (json.JSONDecodeError, TypeError):
|
|
pass
|
|
events.append(e)
|
|
|
|
if limit:
|
|
events = events[:limit]
|
|
|
|
video_cache = {}
|
|
processed = 0
|
|
failed = 0
|
|
for event in events:
|
|
video_id = event["video_id"]
|
|
if video_id not in video_cache:
|
|
video_cache[video_id] = get_video_by_id(video_id)
|
|
video = video_cache[video_id]
|
|
if video is None:
|
|
_increment_attempts(event)
|
|
failed += 1
|
|
continue
|
|
|
|
video_path = PROJECT_ROOT / video["file_path"]
|
|
if not video_path.exists():
|
|
_increment_attempts(event)
|
|
failed += 1
|
|
continue
|
|
|
|
output_path = THUMBNAILS_DIR / f"{event['id']}.jpg"
|
|
offset_sec = event["start_offset_sec"] or 0.0
|
|
|
|
if _extract_frame(video_path, offset_sec, output_path):
|
|
update_event_thumbnail(event["id"], str(output_path.relative_to(PROJECT_ROOT)))
|
|
processed += 1
|
|
else:
|
|
_increment_attempts(event)
|
|
failed += 1
|
|
|
|
return {"processed": processed, "failed": failed}
|
|
|
|
|
|
if __name__ == "__main__":
|
|
print("===== 开始生成缩略图 =====")
|
|
result = process_all()
|
|
print(f"→ 生成 {result['processed']} 张缩略图,失败 {result['failed']} 条")
|
|
print("===== 生成完成 =====")
|