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

264 lines
8.5 KiB
Python
Raw Normal View History

2026-04-13 17:13:02 +08:00
"""水上视频:从 FishAction 输入目录或显式路径发布 H.264 MP4 到 MEDIA_ROOT。
支持长视频切片如果视频较长会切分为多个10秒片段并分别转码发布
每个切片被视为独立的视频
对齐机制使用 client_id 区分不同客户端的轮询进度确保 health/result
water/video 两个端点对齐返回同一切片
"""
2026-04-13 14:50:44 +08:00
from __future__ import annotations
import asyncio
import shutil
from pathlib import Path
2026-04-13 17:13:02 +08:00
from typing import Dict, List
2026-04-13 14:50:44 +08:00
from loguru import logger
from app.services.action_watch import iter_mp4
from app.services.measure import transcode_src_to_h264_dst
2026-04-13 17:13:02 +08:00
from app.services.video_slice import get_video_duration, slice_video
2026-04-13 14:50:44 +08:00
from app.settings import Settings
_publish_lock = asyncio.Lock()
2026-04-13 17:13:02 +08:00
# 视频切片配置
SLICE_DURATION = 10.0 # 每个切片的时长(秒)
MIN_DURATION_FOR_SLICE = 15.0 # 超过此时长才切片
# 默认客户端ID与 db.py 保持一致)
DEFAULT_CLIENT_ID = "default"
2026-04-13 14:50:44 +08:00
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"
2026-04-13 17:13:02 +08:00
def _slice_media_basename(base_name: str, slice_index: int) -> str:
"""生成切片视频的媒体文件名。"""
base = Path(base_name).stem
return f"{base}_slice_{slice_index:03d}.mp4"
# 客户端独立的状态:每个 client_id 有自己的切片队列和索引
# _client_slice_queues[client_id] = [url0, url1, url2, ...]
# _client_slice_indices[client_id] = 当前应该返回的索引
_client_slice_queues: Dict[str, List[str]] = {}
_client_slice_indices: Dict[str, int] = {}
# 全局缓存的切片列表(用于检测源文件变化)
_global_slice_urls: List[str] = []
_last_source_mtime: float = 0.0
2026-04-13 14:50:44 +08:00
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
2026-04-13 17:13:02 +08:00
async def _publish_video(
src: Path,
dst: Path,
settings: Settings,
) -> str:
"""发布视频到 MEDIA_ROOT。
2026-04-13 14:50:44 +08:00
2026-04-13 17:13:02 +08:00
Args:
src: 源视频路径
dst: 目标路径
settings: 应用配置
Returns:
发布的视频URL失败返回空串
"""
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,
)
return _public_media_url(settings, dst.name)
except Exception:
logger.exception("[water-video] publish failed")
tmp.unlink(missing_ok=True)
2026-04-13 14:50:44 +08:00
if dst.is_file():
return _public_media_url(settings, dst.name)
return ""
2026-04-13 17:13:02 +08:00
async def _prepare_slices(settings: Settings) -> List[str]:
"""预处理:如果视频较长,切分为多个片段并发布到 MEDIA_ROOT。
返回切片URL列表供各客户端使用
"""
global _global_slice_urls, _last_source_mtime
base_basename = _safe_water_media_basename(settings.biomass_water_video_media_name)
src = resolve_water_video_source(settings)
if src is None:
return []
# 检查是否需要重新切片
try:
current_mtime = src.stat().st_mtime
except OSError:
current_mtime = 0.0
# 如果源文件未变化且已有缓存,直接返回缓存
if current_mtime == _last_source_mtime and _global_slice_urls:
return _global_slice_urls
# 检查视频时长
duration = get_video_duration(src)
should_slice = duration > MIN_DURATION_FOR_SLICE
new_urls: List[str] = []
if should_slice:
# 视频较长,切片处理
logger.info(
"[water-video] video duration {}s > {}s, slicing into {}s segments",
duration,
MIN_DURATION_FOR_SLICE,
SLICE_DURATION,
)
slice_files, slice_dir = slice_video(src, SLICE_DURATION)
if len(slice_files) > 1:
# 发布每个切片
for i, slice_file in enumerate(slice_files):
slice_basename = _slice_media_basename(base_basename, i)
dst = settings.media_root / slice_basename
# 检查是否需要重新发布
need_publish = True
if dst.is_file():
try:
if dst.stat().st_mtime >= slice_file.stat().st_mtime:
need_publish = False
except OSError:
pass
if need_publish:
url = await _publish_video(slice_file, dst, settings)
else:
url = _public_media_url(settings, dst.name)
if url:
new_urls.append(url)
logger.info(
"[water-video] prepared {} slices for {}",
len(new_urls),
src.name,
)
# 更新全局缓存
_global_slice_urls = new_urls
_last_source_mtime = current_mtime
return new_urls
# 视频较短,只保留完整视频
dst = settings.media_root / base_basename
url = await _publish_video(src, dst, settings)
if url:
new_urls = [url]
_global_slice_urls = new_urls
_last_source_mtime = current_mtime
return new_urls
return []
async def get_water_video_public_url(settings: Settings, client_id: str = DEFAULT_CLIENT_ID) -> str:
"""转码并发布到 MEDIA_ROOT 后返回绝对 URL无可用源且无已发布文件时返回空串。
如果视频较长被切片会根据 health/result 端点的状态返回对应的切片URL
对齐机制查询 health 数据库记录的切片索引确保与 health/result 端点对齐
只有当 health/result 返回了第 N 个切片的行为结果后本端点才会返回第 N 个切片的视频
Args:
settings: 应用配置
client_id: 客户端标识默认为 "default"
Returns:
视频URL失败返回空串
"""
from app.db import get_last_health_slice_index
settings.media_root.mkdir(parents=True, exist_ok=True)
2026-04-13 14:50:44 +08:00
async with _publish_lock:
2026-04-13 17:13:02 +08:00
# 确保切片已准备好
slice_urls = await _prepare_slices(settings)
2026-04-13 14:50:44 +08:00
2026-04-13 17:13:02 +08:00
if not slice_urls:
# 没有切片,尝试返回已发布的文件
basename = _safe_water_media_basename(settings.biomass_water_video_media_name)
dst = settings.media_root / basename
2026-04-13 14:50:44 +08:00
if dst.is_file():
return _public_media_url(settings, dst.name)
return ""
2026-04-13 17:13:02 +08:00
# 查询 health 端点上次返回的切片索引
target_slice_idx = get_last_health_slice_index(client_id)
if target_slice_idx >= 0 and target_slice_idx < len(slice_urls):
# 返回与 health 结果对齐的切片
logger.debug(
"[water-video] client_id={} aligned to health slice {}/{}",
client_id,
target_slice_idx,
len(slice_urls),
)
return slice_urls[target_slice_idx]
else:
# 没有对齐的 health 结果,返回空(等待 health/result 先被调用)
logger.debug(
"[water-video] client_id={} no health index yet, returning empty",
client_id,
)
return ""