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,108 @@
|
||||
const eventTypeFilter = document.getElementById("event-type-filter");
|
||||
const dateFilter = document.getElementById("date-filter");
|
||||
const eventList = document.getElementById("event-list");
|
||||
const player = document.getElementById("player");
|
||||
const playerInfo = document.getElementById("player-info");
|
||||
const statsEl = document.getElementById("stats");
|
||||
const cacheInfoEl = document.getElementById("cache-info");
|
||||
const clearCacheBtn = document.getElementById("clear-cache-btn");
|
||||
|
||||
const EVENT_TYPE_LABELS = {
|
||||
person_presence: "人物出现",
|
||||
fall: "摔倒",
|
||||
activity: "活动",
|
||||
};
|
||||
|
||||
function formatEventLabel(event) {
|
||||
const typeName = EVENT_TYPE_LABELS[event.event_type] || event.event_type;
|
||||
return event.label ? `${typeName} · ${event.label}` : typeName;
|
||||
}
|
||||
|
||||
async function loadStats() {
|
||||
const res = await fetch("/api/stats");
|
||||
const stats = await res.json();
|
||||
const parts = Object.entries(stats).map(
|
||||
([type, count]) => `${EVENT_TYPE_LABELS[type] || type}: ${count}`
|
||||
);
|
||||
statsEl.textContent = parts.join(" | ");
|
||||
}
|
||||
|
||||
function formatBytes(bytes) {
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`;
|
||||
}
|
||||
|
||||
async function loadCacheStats() {
|
||||
const res = await fetch("/api/cache/stats");
|
||||
const stats = await res.json();
|
||||
cacheInfoEl.textContent = `转码缓存:${stats.file_count} 个文件,共占用 ${formatBytes(stats.total_size_bytes)}`;
|
||||
}
|
||||
|
||||
clearCacheBtn.addEventListener("click", async () => {
|
||||
if (!confirm("确认清空所有转码缓存吗?清空后再次播放这些视频需要重新转码。")) return;
|
||||
const res = await fetch("/api/cache/clear", { method: "POST" });
|
||||
const result = await res.json();
|
||||
alert(`已清理 ${result.deleted} 个文件,释放 ${formatBytes(result.freed_bytes)} 空间`);
|
||||
loadCacheStats();
|
||||
});
|
||||
|
||||
async function loadEvents() {
|
||||
const params = new URLSearchParams();
|
||||
if (eventTypeFilter.value) params.set("event_type", eventTypeFilter.value);
|
||||
if (dateFilter.value) params.set("date", dateFilter.value.replaceAll("-", ""));
|
||||
|
||||
const res = await fetch(`/api/events?${params.toString()}`);
|
||||
const events = await res.json();
|
||||
renderEventList(events);
|
||||
}
|
||||
|
||||
function renderEventList(events) {
|
||||
eventList.innerHTML = "";
|
||||
for (const event of events) {
|
||||
const item = document.createElement("div");
|
||||
item.className = "event-item";
|
||||
|
||||
const thumb = document.createElement("img");
|
||||
thumb.className = "event-thumb";
|
||||
thumb.alt = formatEventLabel(event);
|
||||
if (event.thumbnail_url) thumb.src = event.thumbnail_url;
|
||||
|
||||
const info = document.createElement("div");
|
||||
info.className = "event-info";
|
||||
info.innerHTML = `
|
||||
<div class="event-type">${formatEventLabel(event)}</div>
|
||||
<div class="event-time">${event.timestamp_abs || event.capture_date}</div>
|
||||
`;
|
||||
|
||||
item.appendChild(thumb);
|
||||
item.appendChild(info);
|
||||
item.addEventListener("click", () => playEvent(event));
|
||||
eventList.appendChild(item);
|
||||
}
|
||||
}
|
||||
|
||||
function playEvent(event) {
|
||||
const isSameVideo = player.dataset.videoId === String(event.video_id);
|
||||
player.dataset.videoId = event.video_id;
|
||||
|
||||
const seekAndPlay = () => {
|
||||
player.currentTime = event.start_offset_sec || 0;
|
||||
player.play();
|
||||
};
|
||||
|
||||
if (isSameVideo) {
|
||||
seekAndPlay();
|
||||
} else {
|
||||
player.src = `/media/${event.video_id}/stream`;
|
||||
player.addEventListener("loadedmetadata", () => { seekAndPlay(); loadCacheStats(); }, { once: true });
|
||||
}
|
||||
playerInfo.textContent = `${event.file_name} · ${formatEventLabel(event)}`;
|
||||
}
|
||||
|
||||
eventTypeFilter.addEventListener("change", loadEvents);
|
||||
dateFilter.addEventListener("change", loadEvents);
|
||||
|
||||
loadStats();
|
||||
loadCacheStats();
|
||||
loadEvents();
|
||||
@@ -0,0 +1,96 @@
|
||||
body {
|
||||
font-family: -apple-system, "PingFang SC", sans-serif;
|
||||
margin: 20px;
|
||||
color: #222;
|
||||
}
|
||||
|
||||
.filters {
|
||||
margin-bottom: 16px;
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
#stats {
|
||||
color: #666;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.cache-panel {
|
||||
margin-bottom: 16px;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
color: #666;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.cache-panel button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.main {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.player-panel {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
#player {
|
||||
width: 100%;
|
||||
max-width: 640px;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
#player-info {
|
||||
margin-top: 8px;
|
||||
color: #555;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.event-list {
|
||||
flex: 1;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.event-item {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
cursor: pointer;
|
||||
padding: 6px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.event-item:hover {
|
||||
background: #f0f0f0;
|
||||
}
|
||||
|
||||
.event-thumb {
|
||||
width: 120px;
|
||||
height: 68px;
|
||||
object-fit: cover;
|
||||
background: #ddd;
|
||||
border-radius: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.event-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.event-type {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.event-time {
|
||||
color: #666;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
Reference in New Issue
Block a user