live sonar feed, and incremental action feed
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user