From 6f006def64e84edbecf14f8351e584d582185ac2 Mon Sep 17 00:00:00 2001 From: zaiun xu Date: Mon, 13 Apr 2026 14:50:44 +0800 Subject: [PATCH] live mp4 --- fish_api/app/main.py | 2 + fish_api/app/routers/biomass.py | 30 ++++++++ fish_api/app/services/measure.py | 5 ++ fish_api/app/services/water_video.py | 101 +++++++++++++++++++++++++++ fish_api/app/settings.py | 6 ++ 5 files changed, 144 insertions(+) create mode 100644 fish_api/app/services/water_video.py diff --git a/fish_api/app/main.py b/fish_api/app/main.py index 29bebc5..a330c79 100644 --- a/fish_api/app/main.py +++ b/fish_api/app/main.py @@ -59,5 +59,7 @@ async def root(): "ingest": "/api/v1/ingest/", "biomass_camera": "/api/v1/biomass/real/camera/", "biomass_health": "/api/v1/biomass/health/result/", + "biomass_water_video": "/api/v1/biomass/water/video/", + "biomass_sonar_video": "/api/v1/biomass/sonar/video/", "note": "若配置了 ACTION_WATCH_DIR / MEASURE_WATCH_DIR,启动后会后台监控对应目录。", } diff --git a/fish_api/app/routers/biomass.py b/fish_api/app/routers/biomass.py index cc39f9c..821ebd6 100644 --- a/fish_api/app/routers/biomass.py +++ b/fish_api/app/routers/biomass.py @@ -6,6 +6,7 @@ from fastapi import APIRouter, Depends, Header, Query from starlette.responses import JSONResponse from app.db import normalize_client_id, pop_next_health, pop_next_measure +from app.services.water_video import get_water_video_public_url from app.settings import Settings, get_settings router = APIRouter(prefix="/api/v1/biomass", tags=["biomass"]) @@ -121,3 +122,32 @@ async def get_health_result( }, headers=_new_headers(True), ) + + +@router.get("/water/video/") +async def get_water_video(settings: Settings = Depends(get_settings)): + """水上视频:FishAction 输入 mp4 经 H.264 转码后托管在 /media/,返回 `video_path` 绝对 URL。""" + video_path = await get_water_video_public_url(settings) + return JSONResponse( + content={ + "code": 200, + "msg": "成功", + "data": { + "video_path": video_path, + }, + }, + ) + + +@router.get("/sonar/video/") +async def get_sonar_video(): + """声纳图像信息:当前返回空 `video_path`;后续将提供 H.264 编码 MP4 的绝对 URL。""" + return JSONResponse( + content={ + "code": 200, + "msg": "成功", + "data": { + "video_path": "", + }, + }, + ) diff --git a/fish_api/app/services/measure.py b/fish_api/app/services/measure.py index c660d5e..29505c2 100644 --- a/fish_api/app/services/measure.py +++ b/fish_api/app/services/measure.py @@ -542,6 +542,11 @@ def _transcode_to_h264(src: Path, dst: Path) -> bool: return _transcode_with_x264(src, dst) +def transcode_src_to_h264_dst(src: Path, dst: Path) -> bool: + """将 MP4 转码为 H.264;供 biomass 水上视频等复用 FishMeasure 同款 ffmpeg 逻辑。""" + return _transcode_to_h264(src, dst) + + def _publish_media( left: Optional[Path], right: Optional[Path], diff --git a/fish_api/app/services/water_video.py b/fish_api/app/services/water_video.py new file mode 100644 index 0000000..551669a --- /dev/null +++ b/fish_api/app/services/water_video.py @@ -0,0 +1,101 @@ +"""水上视频:从 FishAction 输入目录或显式路径发布 H.264 MP4 到 MEDIA_ROOT。""" + +from __future__ import annotations + +import asyncio +import shutil +from pathlib import Path + +from loguru import logger + +from app.services.action_watch import iter_mp4 +from app.services.measure import transcode_src_to_h264_dst +from app.settings import Settings + +_publish_lock = asyncio.Lock() + + +def _public_media_url(settings: Settings, basename: str) -> str: + base = settings.public_base_url.rstrip("/") + return f"{base}/media/{basename}" + + +def _safe_water_media_basename(raw: str) -> str: + n = (raw or "").strip() + if not n: + return "biomass_water_surface.mp4" + return Path(n).name or "biomass_water_surface.mp4" + + +def resolve_water_video_source(settings: Settings) -> Path | None: + """优先 BIOMASS_WATER_VIDEO_SOURCE;否则取 ACTION_WATCH_DIR 中 mtime 最新的 .mp4。""" + cfg = settings.biomass_water_video_source + if cfg is not None: + if cfg.is_file(): + return cfg + logger.warning( + "[water-video] BIOMASS_WATER_VIDEO_SOURCE is not a file: {}", + cfg, + ) + return None + aw = settings.action_watch_dir + if aw is None or not aw.is_dir(): + return None + mp4s = iter_mp4(aw, settings.action_watch_recursive) + if not mp4s: + return None + try: + return max(mp4s, key=lambda p: p.stat().st_mtime) + except OSError as e: + logger.warning("[water-video] could not pick latest mp4: {}", e) + return None + + +async def get_water_video_public_url(settings: Settings) -> str: + """转码并发布到 MEDIA_ROOT 后返回绝对 URL;无可用源且无已发布文件时返回空串。""" + settings.media_root.mkdir(parents=True, exist_ok=True) + basename = _safe_water_media_basename(settings.biomass_water_video_media_name) + dst = settings.media_root / basename + + src = resolve_water_video_source(settings) + if src is None: + if dst.is_file(): + return _public_media_url(settings, dst.name) + return "" + + async with _publish_lock: + need_publish = True + if dst.is_file(): + try: + if dst.stat().st_mtime >= src.stat().st_mtime: + need_publish = False + except OSError: + pass + if not need_publish: + return _public_media_url(settings, dst.name) + + tmp = dst.with_name(dst.stem + "_tmp.mp4") + tmp.unlink(missing_ok=True) + try: + ok = await asyncio.to_thread(transcode_src_to_h264_dst, src, tmp) + if ok and tmp.is_file() and tmp.stat().st_size > 0: + tmp.replace(dst) + logger.info("[water-video] published H.264: {} -> {}", src.name, dst.name) + else: + tmp.unlink(missing_ok=True) + await asyncio.to_thread(shutil.copy2, src, dst) + logger.warning( + "[water-video] transcode failed, copied raw: {} -> {}", + src.name, + dst.name, + ) + except Exception: + logger.exception("[water-video] publish failed") + tmp.unlink(missing_ok=True) + if dst.is_file(): + return _public_media_url(settings, dst.name) + return "" + + if dst.is_file(): + return _public_media_url(settings, dst.name) + return "" diff --git a/fish_api/app/settings.py b/fish_api/app/settings.py index 057260e..22692ac 100644 --- a/fish_api/app/settings.py +++ b/fish_api/app/settings.py @@ -110,6 +110,11 @@ class Settings(BaseSettings): action_watch_state_file: Optional[Path] = None action_watch_use_state_file: bool = True + #: 优先作为「水上视频」源文件;未设置时在 ACTION_WATCH_DIR 取最新 .mp4(FishAction 输入)。**BIOMASS_WATER_VIDEO_SOURCE** + biomass_water_video_source: Optional[Path] = None + #: 发布到 MEDIA_ROOT 的 H.264 文件名。**BIOMASS_WATER_VIDEO_MEDIA_NAME** + biomass_water_video_media_name: str = "biomass_water_surface.mp4" + #: 非空时后台持续扫描该目录中的新 .svo2 并跑 FishMeasure(与 ingest 共用 SQLite 最新结果) measure_watch_dir: Optional[Path] = None measure_watch_poll_interval: float = Field(default=2.0, ge=0.1) @@ -123,6 +128,7 @@ class Settings(BaseSettings): @field_validator( "action_watch_dir", "action_watch_state_file", + "biomass_water_video_source", "measure_watch_dir", "measure_watch_state_file", mode="before",