live sonar feed, and incremental action feed

This commit is contained in:
kevin
2026-04-16 14:53:01 +08:00
parent cc6cef0f73
commit 34ecc33ee5
28 changed files with 1555 additions and 1227 deletions

View File

@@ -1,6 +1,7 @@
from __future__ import annotations
import asyncio
import shutil
from pathlib import Path
from typing import Dict, List, Set, Tuple
@@ -15,6 +16,7 @@ from app.db import (
save_health_snapshot,
)
from app.services import action as action_svc
from app.services.measure import transcode_src_to_h264_dst
from app.settings import Settings
from app.state import app_state
from app.watch_idle import IdleWatchWarnState, idle_warn_interval_sec, maybe_warn_idle_watch
@@ -30,20 +32,68 @@ def _state_path(settings: Settings) -> Path:
return settings.action_watch_dir / ".fishaction_watch_processed.json"
_VIDEO_SUFFIXES = frozenset({".mp4", ".mkv", ".mov"})
def iter_mp4(watch_dir: Path, recursive: bool) -> List[Path]:
"""List video files (.mp4, .mkv, .mov) in *watch_dir*."""
if recursive:
return sorted(
p
for p in watch_dir.rglob("*")
if p.is_file() and p.suffix.lower() == ".mp4"
if p.is_file() and p.suffix.lower() in _VIDEO_SUFFIXES
)
return sorted(
p
for p in watch_dir.iterdir()
if p.is_file() and p.suffix.lower() == ".mp4"
if p.is_file() and p.suffix.lower() in _VIDEO_SUFFIXES
)
def _public_media_url(settings: Settings, basename: str) -> str:
base = settings.public_base_url.rstrip("/")
return f"{base}/media/{basename}"
def _slice_media_basename(base_name: str, slice_index: int) -> str:
"""生成切片视频的媒体文件名。"""
base = Path(base_name).stem
return f"{base}_slice_{slice_index:03d}.mp4"
def _publish_slice_video(
src: Path, dst: Path, settings: Settings,
) -> str:
"""Transcode and publish a single video slice to media_root.
Returns the public URL on success, empty string on failure.
"""
tmp = dst.with_name(dst.stem + "_tmp.mp4")
tmp.unlink(missing_ok=True)
try:
ok = transcode_src_to_h264_dst(src, tmp)
if ok and tmp.is_file() and tmp.stat().st_size > 0:
tmp.replace(dst)
logger.info(
"[action-watch] published H.264: {} -> {}", src.name, dst.name,
)
else:
tmp.unlink(missing_ok=True)
shutil.copy2(src, dst)
logger.warning(
"[action-watch] transcode failed, copied raw: {} -> {}",
src.name,
dst.name,
)
return _public_media_url(settings, dst.name)
except Exception:
logger.exception("[action-watch] publish failed for {}", src.name)
tmp.unlink(missing_ok=True)
if dst.is_file():
return _public_media_url(settings, dst.name)
return ""
async def _run_inference_and_state(
mp4: Path,
settings: Settings,
@@ -57,19 +107,43 @@ async def _run_inference_and_state(
async with app_state.action_lock:
app_state.action_status = "running"
try:
# 返回 (第一个快照, 所有切片快照列表)
first_snap, all_snaps = await to_thread(
action_svc.run_full_action, mp4, settings
slice_files, duration = await to_thread(
action_svc.prepare_action_slices, mp4, settings
)
settings.media_root.mkdir(parents=True, exist_ok=True)
base_name = (
settings.biomass_water_video_media_name or "biomass_water_surface.mp4"
)
# 将所有切片作为独立视频保存到数据库
saved_count = 0
for i, snap in enumerate(all_snaps):
if health_snapshot_deliverable(snap):
# 为每个切片生成独立的 source_path
slice_key = f"{key}#slice{i}"
save_health_snapshot(settings, snap, source_path=slice_key)
saved_count += 1
first_snap = None
total_slices = len(slice_files)
for i, slice_file in enumerate(slice_files):
snap = await to_thread(
action_svc.run_single_slice_inference,
slice_file, i, total_slices, duration, mp4.name, settings,
)
if first_snap is None:
first_snap = snap
if not health_snapshot_deliverable(snap):
continue
video_url = ""
media_basename = _slice_media_basename(base_name, i)
dst = settings.media_root / media_basename
video_url = await to_thread(
_publish_slice_video, slice_file, dst, settings,
)
slice_key = f"{key}#slice{i}"
save_health_snapshot(
settings, snap, source_path=slice_key, video_url=video_url,
)
saved_count += 1
if saved_count == 0:
logger.warning(
@@ -82,7 +156,7 @@ async def _run_inference_and_state(
if settings.action_watch_use_state_file:
add_watch_processed(settings, key, "action")
pred = (first_snap.raw_class_en or "").strip()
pred = (first_snap.raw_class_en or "").strip() if first_snap else ""
logger.info(
"[action-watch] done: {} -> {} (saved {} slices)",
mp4.name,