diff --git a/fish_api/app/services/measure.py b/fish_api/app/services/measure.py index 666756e..2bc4390 100644 --- a/fish_api/app/services/measure.py +++ b/fish_api/app/services/measure.py @@ -71,10 +71,10 @@ def _load_weight_json(svo_path: Path, settings: Settings) -> Dict[str, Any]: def _find_preview_videos(output_dir: Path) -> Tuple[Optional[Path], Optional[Path]]: - previews = sorted(output_dir.glob("*preview*.mp4")) + previews = sorted(output_dir.rglob("*preview*.mp4")) if len(previews) >= 2: return previews[0], previews[1] - all_mp4 = sorted(output_dir.glob("*.mp4")) + all_mp4 = sorted(output_dir.rglob("*.mp4")) if len(all_mp4) >= 2: return all_mp4[0], all_mp4[1] if len(all_mp4) == 1: @@ -84,6 +84,46 @@ def _find_preview_videos(output_dir: Path) -> Tuple[Optional[Path], Optional[Pat return None, None +def _split_sbs_video(src: Path, left_dst: Path, right_dst: Path) -> bool: + """Split a side-by-side stereo video (W x H where W == 2*H_single) into left/right halves. + + Returns True if split succeeded, False otherwise (caller should fall back to copy). + """ + probe = subprocess.run( + [ + "ffprobe", "-v", "quiet", "-print_format", "json", + "-show_streams", str(src), + ], + capture_output=True, text=True, + ) + if probe.returncode != 0: + return False + import json as _json + try: + streams = _json.loads(probe.stdout).get("streams", []) + vstream = next((s for s in streams if s.get("codec_type") == "video"), None) + if vstream is None: + return False + w, h = int(vstream["width"]), int(vstream["height"]) + except Exception: + return False + half_w = w // 2 + if half_w < 1 or w < h: + return False + + for crop, dst in [ + (f"crop={half_w}:{h}:{half_w}:0", left_dst), + (f"crop={half_w}:{h}:0:0", right_dst), + ]: + r = subprocess.run( + ["ffmpeg", "-y", "-i", str(src), "-vf", crop, "-an", "-q:v", "5", str(dst)], + capture_output=True, text=True, + ) + if r.returncode != 0: + return False + return True + + def _publish_media( left: Optional[Path], right: Optional[Path], @@ -94,6 +134,13 @@ def _publish_media( right_dst = settings.media_root / "latest_right.mp4" base = settings.public_base_url.rstrip("/") + if left is not None and left == right and left.is_file(): + if _split_sbs_video(left, left_dst, right_dst): + return ( + f"{base}/media/{left_dst.name}", + f"{base}/media/{right_dst.name}", + ) + def publish(src: Optional[Path], dst: Path) -> str: if src is None or not src.is_file(): return "" diff --git a/scripts/biomass_poller.py b/scripts/biomass_poller.py index 87678cc..6ecf59e 100755 --- a/scripts/biomass_poller.py +++ b/scripts/biomass_poller.py @@ -29,22 +29,39 @@ def _fmt_body(resp: httpx.Response) -> Any: return resp.text +_last_camera: dict | None = None +_last_health: dict | None = None + + +def _has_data(body: Any) -> bool: + """非空业务数据:code==200 且 data 里至少有一个非空字段。""" + if not isinstance(body, dict): + return False + if body.get("code") != 200: + return True # 错误也算"新信息" + data = body.get("data", {}) + if not isinstance(data, dict): + return bool(data) + return any(bool(v) for v in data.values()) + + async def poll_once(client: httpx.AsyncClient, base: str) -> None: + global _last_camera, _last_health base = base.rstrip("/") camera_url = f"{base}/api/v1/biomass/real/camera/" health_url = f"{base}/api/v1/biomass/health/result/" r1 = await client.get(camera_url) r2 = await client.get(health_url) - logger.info( - "[real/camera/] HTTP {} | {}", - r1.status_code, - json.dumps(_fmt_body(r1), ensure_ascii=False), - ) - logger.info( - "[health/result/] HTTP {} | {}", - r2.status_code, - json.dumps(_fmt_body(r2), ensure_ascii=False), - ) + b1 = _fmt_body(r1) + b2 = _fmt_body(r2) + changed1 = b1 != _last_camera + changed2 = b2 != _last_health + if changed1 and _has_data(b1): + logger.info("[real/camera/] HTTP {} | {}", r1.status_code, json.dumps(b1, ensure_ascii=False)) + if changed2 and _has_data(b2): + logger.info("[health/result/] HTTP {} | {}", r2.status_code, json.dumps(b2, ensure_ascii=False)) + _last_camera = b1 + _last_health = b2 async def poll_loop(base: str, interval: float) -> None: