Files
FishServer/fish_api/app/services/action_watch.py

252 lines
8.1 KiB
Python
Raw Normal View History

from __future__ import annotations
import asyncio
import shutil
from pathlib import Path
from typing import Dict, List, Set, Tuple
from loguru import logger
from app.compat import to_thread
2026-04-10 10:30:01 +08:00
from app.db import (
add_watch_processed,
health_snapshot_deliverable,
load_watch_processed,
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
2026-04-10 10:30:01 +08:00
from app.state import app_state
from app.watch_idle import IdleWatchWarnState, idle_warn_interval_sec, maybe_warn_idle_watch
_ACTION_IDLE_WARN_INTERVAL_SEC = idle_warn_interval_sec(
"FISH_ACTION_WATCH_IDLE_WARN_INTERVAL_SEC"
)
def _state_path(settings: Settings) -> Path:
"""返回旧版 JSON 状态文件路径(仅用于兼容导入 SQLite"""
assert settings.action_watch_dir is not None
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() in _VIDEO_SUFFIXES
)
return sorted(
p
for p in watch_dir.iterdir()
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,
processed: Set[str],
state_file: Path,
) -> None:
key = str(mp4.resolve())
if key in processed:
return
logger.info("[action-watch] inference: {}", mp4)
async with app_state.action_lock:
app_state.action_status = "running"
try:
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"
2026-04-13 17:13:02 +08:00
)
saved_count = 0
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
2026-04-13 17:13:02 +08:00
if saved_count == 0:
2026-04-10 10:30:01 +08:00
logger.warning(
"[action-watch] no deliverable health snapshot for {}, skip SQLite",
mp4.name,
)
2026-04-13 17:13:02 +08:00
app_state.action_status = "idle"
processed.add(key)
if settings.action_watch_use_state_file:
add_watch_processed(settings, key, "action")
2026-04-13 17:13:02 +08:00
pred = (first_snap.raw_class_en or "").strip() if first_snap else ""
2026-04-13 17:13:02 +08:00
logger.info(
"[action-watch] done: {} -> {} (saved {} slices)",
mp4.name,
pred,
saved_count,
)
except Exception as e:
logger.exception("[action-watch] error on {}: {}", mp4, e)
2026-04-10 10:30:01 +08:00
app_state.action_status = "idle"
processed.add(key)
if settings.action_watch_use_state_file:
add_watch_processed(settings, key, "action")
async def watch_tick(
settings: Settings,
processed: Set[str],
stability: Dict[str, Tuple[int, int]],
state_file: Path,
) -> bool:
"""处理一轮目录扫描;若处理了至少一个文件返回 True。"""
assert settings.action_watch_dir is not None
watch_dir = settings.action_watch_dir
did = False
seen_keys: Set[str] = set()
for mp4 in iter_mp4(watch_dir, settings.action_watch_recursive):
key = str(mp4.resolve())
seen_keys.add(key)
if key in processed:
continue
try:
st = mp4.stat()
except OSError:
continue
size = int(st.st_size)
if size <= 0:
stability.pop(key, None)
continue
last = stability.get(key)
if last is None or last[0] != size:
stability[key] = (size, 1)
else:
_, cnt = last
stability[key] = (size, cnt + 1)
_, cnt = stability[key]
if cnt >= settings.action_watch_stable_polls:
2026-04-10 10:30:01 +08:00
await _run_inference_and_state(mp4, settings, processed, state_file)
stability.pop(key, None)
did = True
for k in list(stability.keys()):
if k not in seen_keys:
del stability[k]
return did
async def run_action_watch_loop(settings: Settings) -> None:
assert settings.action_watch_dir is not None
wd = settings.action_watch_dir
if not wd.is_dir():
logger.warning("[action-watch] skip: not a directory: {}", wd)
return
state_file = _state_path(settings)
processed: Set[str] = (
load_watch_processed(settings, state_file, "action")
if settings.action_watch_use_state_file
else set()
)
stability = {} # type: Dict[str, Tuple[int, int]]
logger.info(
"[action-watch] watching {} (poll={}s, stable_polls={}, state={} {})",
wd,
settings.action_watch_poll_interval,
settings.action_watch_stable_polls,
"on" if settings.action_watch_use_state_file else "off",
state_file if settings.action_watch_use_state_file else "",
)
idle_warn_state = IdleWatchWarnState()
while True:
did = await watch_tick(settings, processed, stability, state_file)
maybe_warn_idle_watch(
did_work=did,
log_tag="action-watch",
algo_name="FishAction",
idle_hint="目录内无 .mp4、已全部处理完毕或文件尚未达到稳定轮询次数",
watch_dir=wd,
state=idle_warn_state,
interval_sec=_ACTION_IDLE_WARN_INTERVAL_SEC,
)
await asyncio.sleep(max(settings.action_watch_poll_interval, 0.1))