live mp4
This commit is contained in:
@@ -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,启动后会后台监控对应目录。",
|
||||
}
|
||||
|
||||
@@ -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": "",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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],
|
||||
|
||||
101
fish_api/app/services/water_video.py
Normal file
101
fish_api/app/services/water_video.py
Normal file
@@ -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 ""
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user