import base64 import hashlib import hmac import json import os import shutil import socket import time from datetime import datetime from pathlib import Path from typing import Optional from pydantic import BaseModel from fastapi import FastAPI, HTTPException, Request, Response, WebSocket, WebSocketDisconnect from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse, StreamingResponse from fastapi.staticfiles import StaticFiles from camera_manager import CameraManager from filters import AVAILABLE_FILTERS from recorder import VideoRecorder BASE_DIR = Path(__file__).resolve().parent STORAGE_DIR = BASE_DIR / "storage" SNAPSHOT_DIR = STORAGE_DIR / "snapshots" RECORDING_DIR = STORAGE_DIR / "recordings" CONFIG_PATH = STORAGE_DIR / "cameras.json" STATIC_DIR = BASE_DIR / "static" TEMPLATES_DIR = BASE_DIR / "templates" SNAPSHOT_DIR.mkdir(parents=True, exist_ok=True) RECORDING_DIR.mkdir(parents=True, exist_ok=True) app = FastAPI(title="X-Tracker Studio - GPU AI Vision & Multi-Camera Tracking") app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) camera = CameraManager(config_path=CONFIG_PATH) recorder = VideoRecorder(camera=camera, storage_dir=RECORDING_DIR) class AddCameraRequest(BaseModel): name: str source: str type: str = "network" def get_network_ips(): lan_ip = "127.0.0.1" tailscale_ip = None try: s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) s.connect(("8.8.8.8", 80)) lan_ip = s.getsockname()[0] s.close() except Exception: pass try: ts_out = os.popen("tailscale ip -4 2>/dev/null").read().strip() if ts_out: tailscale_ip = ts_out except Exception: pass return lan_ip, tailscale_ip @app.on_event("startup") def startup_event(): camera.start() @app.on_event("shutdown") def shutdown_event(): if recorder.is_recording: recorder.stop() camera.stop() @app.get("/") def get_dashboard(): return FileResponse(str(TEMPLATES_DIR / "index.html")) @app.get("/api/status") def get_system_status(): cam_status = camera.get_status() rec_status = recorder.get_status() lan_ip, ts_ip = get_network_ips() port = int(os.environ.get("PORT", 7860)) disk_total, disk_used, disk_free = shutil.disk_usage(str(STORAGE_DIR)) return { "status": "online", "camera": cam_status, "recorder": rec_status, "network": { "lan_ip": lan_ip, "tailscale_ip": ts_ip, "port": port, "lan_url": f"http://{lan_ip}:{port}", "tailscale_url": f"http://{ts_ip}:{port}" if ts_ip else None, }, "storage": { "free_gb": round(disk_free / (1024**3), 2), "total_gb": round(disk_total / (1024**3), 2), } } # --- Filter Endpoints --- @app.get("/api/filters") def get_filters(): return { "available_filters": AVAILABLE_FILTERS, "active_filter": camera.current_filter } @app.post("/api/filter/{filter_name}") def set_active_filter(filter_name: str): ok, msg = camera.set_filter(filter_name) if not ok: raise HTTPException(status_code=400, detail=msg) return { "status": "success", "message": msg, "active_filter": camera.current_filter } # --- Zoom & AI Detection Endpoints --- class FocusPointRequest(BaseModel): x: float y: float zoom: Optional[float] = None @app.post("/api/zoom/reset") def reset_zoom(): camera.set_zoom(1.0, 0.5, 0.5) camera.set_auto_tracking_mode("off") return {"status": "success", "message": "Zoom and focus reset to center 1.0x"} @app.post("/api/zoom/focus") def set_zoom_focus_point(payload: FocusPointRequest): ok, msg = camera.set_zoom_focus_point(payload.x, payload.y, payload.zoom) return { "status": "success", "message": msg, "zoom_center_x": camera.zoom_center_x, "zoom_center_y": camera.zoom_center_y, "zoom_level": camera.zoom_level } @app.post("/api/zoom/target/{object_id}") def set_zoom_target_object(object_id: str): target = None if object_id in ["none", "clear", "null", "reset"] else object_id ok, msg = camera.set_focus_target_object(target) return { "status": "success", "message": msg, "target_object_id": camera.target_object_id, "auto_tracking_mode": camera.auto_tracking_mode } @app.post("/api/zoom/track/{mode}") def set_auto_tracking_mode(mode: str): ok, msg = camera.set_auto_tracking_mode(mode) if not ok: raise HTTPException(status_code=400, detail=msg) return { "status": "success", "message": msg, "auto_tracking_mode": camera.auto_tracking_mode } @app.post("/api/zoom/{level}") def set_zoom(level: float): ok, msg, new_zoom = camera.set_zoom(level) return {"status": "success", "message": msg, "zoom_level": new_zoom} @app.post("/api/detection/toggle") def toggle_ai_detection(): new_state = not camera.ai_detection_enabled ok, msg, enabled = camera.set_detection(new_state) return {"status": "success", "message": msg, "ai_detection_enabled": enabled} @app.post("/api/detection/{state}") def set_ai_detection(state: str): enabled = (state.lower() in ["1", "true", "on", "enable"]) ok, msg, enabled = camera.set_detection(enabled) return {"status": "success", "message": msg, "ai_detection_enabled": enabled} @app.get("/api/detections") def get_detections(): return { "ai_detection_enabled": camera.ai_detection_enabled, "detections": camera.current_detections, "telemetry": camera.current_telemetry, "count": len(camera.current_detections) } @app.post("/api/overlay/{state}") def set_hud_overlay(state: str): enabled = (state.lower() in ["1", "true", "on", "enable"]) ok, msg, enabled = camera.set_hud_overlay(enabled) return {"status": "success", "message": msg, "show_hud_overlay": enabled} # --- Multi-Camera Endpoints --- @app.get("/api/cameras") def get_registered_cameras(): return { "cameras": camera.get_cameras(), "active_camera_id": camera.active_camera_id } @app.post("/api/cameras/active/{cam_id}") def switch_active_camera(cam_id: str): if recorder.is_recording: recorder.stop() ok, msg = camera.switch_camera(cam_id) if not ok: raise HTTPException(status_code=400, detail=msg) return {"status": "success", "message": msg, "active_camera": camera.get_active_camera()} @app.post("/api/cameras") def register_new_camera(req: AddCameraRequest): ok, msg, new_cam = camera.add_camera(name=req.name, source=req.source, cam_type=req.type) if not ok: raise HTTPException(status_code=400, detail=msg) return {"status": "success", "message": msg, "camera": new_cam} @app.delete("/api/cameras/{cam_id}") def delete_camera(cam_id: str): ok, msg = camera.remove_camera(cam_id) if not ok: raise HTTPException(status_code=400, detail=msg) return {"status": "success", "message": msg} # --- Remote Power Control --- @app.post("/api/camera/start") def remote_start_camera(): ok, msg = camera.start() if not ok: raise HTTPException(status_code=500, detail=msg) return {"status": "success", "message": msg, "camera": camera.get_status()} @app.post("/api/camera/stop") def remote_stop_camera(): if recorder.is_recording: recorder.stop() ok, msg = camera.stop() return {"status": "success", "message": msg, "camera": camera.get_status()} # --- Streaming, Snapshot, Recording --- @app.get("/api/stream") def get_live_stream(): if not camera.running: camera.start() return StreamingResponse( camera.generate_mjpeg(), media_type="multipart/x-mixed-replace; boundary=frame" ) @app.post("/api/snapshot") def remote_snapshot(): if not camera.running: ok, msg = camera.start() if not ok: raise HTTPException(status_code=503, detail=f"Cannot snapshot: {msg}") jpeg = None for _ in range(10): jpeg = camera.get_clean_jpeg() if jpeg: break time.sleep(0.05) if not jpeg: raise HTTPException(status_code=503, detail="No video frame available to capture") ts = datetime.now().strftime("%Y%m%d_%H%M%S") filt = camera.current_filter filename = f"snap_{filt}_{ts}.jpg" filepath = SNAPSHOT_DIR / filename filepath.write_bytes(jpeg) return { "status": "success", "filename": filename, "url": f"/media/snapshots/{filename}", "size_bytes": len(jpeg), "timestamp": ts, "filter": filt, "width": camera.actual_width, "height": camera.actual_height, } @app.post("/api/record/start") def remote_record_start(): ok, msg, filename = recorder.start() if not ok: raise HTTPException(status_code=400, detail=msg) return {"status": "success", "message": msg, "filename": filename, "recorder": recorder.get_status()} @app.post("/api/record/stop") def remote_record_stop(): ok, msg, info = recorder.stop() if not ok: raise HTTPException(status_code=400, detail=msg) return {"status": "success", "message": msg, "info": info} MEDIA_SECRET = os.getenv("SECRET_KEY", "xtracker-media-secure-key-2026") def generate_signed_media_url(media_type: str, filename: str, expires_in: int = 3600) -> str: exp = int(time.time()) + expires_in msg = f"{media_type}:{filename}:{exp}".encode() sig = hmac.new(MEDIA_SECRET.encode(), msg, hashlib.sha256).hexdigest()[:16] return f"/media/{media_type}/{filename}?exp={exp}&sig={sig}" # --- Media Endpoints --- @app.get("/api/media") def list_media(): snapshots = [] for f in sorted(SNAPSHOT_DIR.glob("*.jpg"), key=os.path.getmtime, reverse=True): st = f.stat() snapshots.append({ "name": f.name, "url": generate_signed_media_url("snapshots", f.name, expires_in=3600), "size_bytes": st.st_size, "size_formatted": f"{round(st.st_size / 1024, 1)} KB", "modified": datetime.fromtimestamp(st.st_mtime).strftime("%Y-%m-%d %H:%M:%S"), "type": "image", }) recordings = [] for f in sorted(RECORDING_DIR.glob("*.mp4"), key=os.path.getmtime, reverse=True): st = f.stat() recordings.append({ "name": f.name, "url": generate_signed_media_url("recordings", f.name, expires_in=3600), "size_bytes": st.st_size, "size_formatted": f"{round(st.st_size / (1024*1024), 2)} MB" if st.st_size >= 1048576 else f"{round(st.st_size / 1024, 1)} KB", "modified": datetime.fromtimestamp(st.st_mtime).strftime("%Y-%m-%d %H:%M:%S"), "type": "video", }) return { "snapshots": snapshots, "recordings": recordings, "total_count": len(snapshots) + len(recordings) } @app.api_route("/media/snapshots/{filename}", methods=["GET", "HEAD"]) def serve_snapshot_image(filename: str): f = SNAPSHOT_DIR / Path(filename).name if not f.exists(): raise HTTPException(status_code=404, detail="Snapshot not found") return FileResponse(str(f), media_type="image/jpeg") @app.api_route("/media/recordings/{filename}", methods=["GET", "HEAD"]) def serve_recording_video(filename: str, request: Request): file_path = RECORDING_DIR / Path(filename).name if not file_path.exists(): raise HTTPException(status_code=404, detail="Recording not found") file_size = file_path.stat().st_size range_header = request.headers.get("Range") if not range_header: return FileResponse(str(file_path), media_type="video/mp4") try: h = range_header.replace("bytes=", "").split("-") start = int(h[0]) if h[0] else 0 end = int(h[1]) if len(h) > 1 and h[1] else file_size - 1 except ValueError: return FileResponse(str(file_path), media_type="video/mp4") if start >= file_size or end >= file_size or start > end: return Response(status_code=416, headers={"Content-Range": f"bytes */{file_size}"}) chunk_size = (end - start) + 1 with open(file_path, "rb") as f: f.seek(start) data = f.read(chunk_size) headers = { "Content-Range": f"bytes {start}-{end}/{file_size}", "Accept-Ranges": "bytes", "Content-Length": str(chunk_size), "Content-Type": "video/mp4", } return Response(content=data, status_code=206, headers=headers) @app.post("/api/infer") async def infer_frame(request: Request): """Remote GPU inference endpoint for Hugging Face Spaces offloading.""" import cv2 import numpy as np body = await request.body() if not body: raise HTTPException(status_code=400, detail="Empty image payload") arr = np.frombuffer(body, dtype=np.uint8) frame = cv2.imdecode(arr, cv2.IMREAD_COLOR) if frame is None: raise HTTPException(status_code=400, detail="Invalid JPEG buffer") dets, telem = camera.detector.detect(frame) return { "status": "success", "detections": dets, "telemetry": telem, "keypoints": camera.detector.latest_keypoints, "gpu_active": bool(telem.get("gpu_active", False)) } @app.post("/api/frame/push") async def push_camera_frame(request: Request): """Ingests live JPEG frames from Browser Webcam (getUserMedia) or local station relay.""" body = await request.body() ok = camera.push_external_frame(body) return {"status": "success" if ok else "ignored"} @app.websocket("/ws/frame") async def websocket_frame_ingest(websocket: WebSocket): """Ultra-low-latency binary WebSocket ingest for live Browser Webcam frames.""" await websocket.accept() try: while True: data = await websocket.receive_bytes() camera.push_external_frame(data) except WebSocketDisconnect: pass except Exception: pass app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static") if __name__ == "__main__": import uvicorn port = int(os.environ.get("PORT", 7860)) uvicorn.run(app, host="0.0.0.0", port=port)