| from __future__ import annotations |
|
|
| import base64 |
| import time |
| from pathlib import Path |
| from typing import Any |
|
|
| import httpx |
|
|
|
|
| DEFAULT_API_URL = "https://visual-narrator-demo.vercel.app" |
|
|
|
|
| class VNApiError(RuntimeError): |
| """Raised when the Visual Narrator API returns an unusable response.""" |
|
|
|
|
| class VNClient: |
| def __init__(self, api_url: str = DEFAULT_API_URL, api_key: str | None = None, timeout: float = 120.0): |
| self.api_url = api_url.rstrip("/") |
| self.api_key = api_key |
| self.timeout = timeout |
|
|
| def describe_frame(self, frame_path: Path) -> dict[str, Any]: |
| frame_base64 = encode_file_base64(frame_path) |
| payload: dict[str, Any] = {"frame_base64": frame_base64} |
| if self.api_key: |
| payload["api_key"] = self.api_key |
|
|
| headers = {"Content-Type": "application/json"} |
| if self.api_key: |
| headers["Authorization"] = f"Bearer {self.api_key}" |
|
|
| started = time.perf_counter() |
| data = self._post_json("/api/v1/describe-frame", payload, headers=headers) |
| elapsed_ms = int((time.perf_counter() - started) * 1000) |
|
|
| if not isinstance(data, dict): |
| raise VNApiError("describe-frame returned a non-object response") |
|
|
| data.setdefault("latency_ms", elapsed_ms) |
| return data |
|
|
| def benchmark_frame(self, image_path: Path) -> dict[str, Any]: |
| payload = {"frame_data": encode_file_base64(image_path)} |
| data = self._post_json("/api/benchmark-competitors", payload) |
| if not isinstance(data, dict): |
| raise VNApiError("benchmark-competitors returned a non-object response") |
| return data |
|
|
| def create_key(self, email: str) -> dict[str, Any]: |
| data = self._post_json("/api/keys/create", {"email": email}) |
| if not isinstance(data, dict): |
| raise VNApiError("keys/create returned a non-object response") |
| return data |
|
|
| def _post_json( |
| self, |
| path: str, |
| payload: dict[str, Any], |
| headers: dict[str, str] | None = None, |
| ) -> Any: |
| url = f"{self.api_url}{path}" |
| try: |
| with httpx.Client(timeout=self.timeout, follow_redirects=True) as client: |
| response = client.post(url, json=payload, headers=headers) |
| except httpx.HTTPError as exc: |
| raise VNApiError(f"request to {url} failed: {exc}") from exc |
|
|
| if response.status_code >= 400: |
| message = _extract_error(response) |
| raise VNApiError(f"{response.status_code} response from {url}: {message}") |
|
|
| try: |
| return response.json() |
| except ValueError as exc: |
| raise VNApiError(f"invalid JSON response from {url}: {response.text[:300]}") from exc |
|
|
|
|
| def encode_file_base64(path: Path) -> str: |
| return base64.b64encode(path.read_bytes()).decode("ascii") |
|
|
|
|
| def _extract_error(response: httpx.Response) -> str: |
| try: |
| data = response.json() |
| except ValueError: |
| return response.text[:300] |
| if isinstance(data, dict): |
| return str(data.get("error") or data.get("message") or data.get("detail") or data) |
| return str(data) |
|
|