Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 35 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ A web app for watching Formula 1 sessions with real timing data, car positions o
- **Pit position prediction** (Beta) estimates where a driver would rejoin if they pitted now, with predicted gap ahead and behind, using precomputed pit loss times per circuit with Safety Car and Virtual Safety Car adjustments
- **Telemetry** for any driver showing speed, throttle, brake, gear, and DRS (2025 and earlier) plotted against track distance
- **Picture-in-Picture** mode for a compact floating window with track map, race control, leaderboard, and telemetry
- **Broadcast sync** - match the replay to a recording of a session, either by uploading a screenshot of the timing tower (using AI vision) or by manually entering gap times
- **Broadcast sync** - match the replay to a recording of a session by uploading a screenshot, entering gap times manually, or auto-syncing live from OpenWebIF
- **Weather data** including air and track temperature, humidity, wind, and rainfall status
- **Track status flags** for green, yellow, Safety Car, Virtual Safety Car, and red flag conditions
- **Playback controls** with 0.5x to 20x speed, skip buttons (5s, 30s, 1m, 5m), lap jumping, and a progress bar
Expand All @@ -44,14 +44,17 @@ Requires [Docker](https://docs.docker.com/get-docker/) and Docker Compose.
Create a `docker-compose.yml` file:

```yaml
x-openwebif-url: &openwebif_url "" # Optional default OpenWebIF box URL, for example http://192.168.1.50

services:
backend:
image: ghcr.io/adn8naiagent/f1replaytiming-backend:latest
ports:
- "8000:8000"
environment:
- FRONTEND_URL=http://localhost:3000
- DATA_DIR=/data
FRONTEND_URL: http://localhost:3000
DATA_DIR: /data
OPENWEBIF_URL: *openwebif_url
volumes:
- f1data:/data
- f1cache:/data/fastf1-cache
Expand All @@ -61,7 +64,8 @@ services:
ports:
- "3000:3000"
environment:
- NEXT_PUBLIC_API_URL=http://localhost:8000 # Change to your backend URL if not using localhost
NEXT_PUBLIC_API_URL: http://localhost:8000 # Change to your backend URL if not using localhost
NEXT_PUBLIC_OPENWEBIF_URL: *openwebif_url
depends_on:
- backend

Expand Down Expand Up @@ -94,7 +98,7 @@ Open http://localhost:3000. Select any past session and it will be processed on

#### Network & URL configuration

Two environment variables control how the frontend and backend find each other:
Two required environment variables control how the frontend and backend find each other:

| Variable | Set on | Purpose |
|---|---|---|
Expand Down Expand Up @@ -127,8 +131,20 @@ frontend:

In this setup your reverse proxy routes `f1.example.com` to the frontend container (port 3000) and `api.f1.example.com` to the backend container (port 8000).

#### OpenWebIF auto-sync configuration

If you want the Auto Sync tab to start with your OpenWebIF box URL already filled in, and to let the backend fall back to that same URL, set these optional variables:

| Variable | Set on | Purpose |
|---|---|---|
| `NEXT_PUBLIC_OPENWEBIF_URL` | frontend | Prefills the Auto Sync form with your default OpenWebIF URL |
| `OPENWEBIF_URL` | backend | Default OpenWebIF URL used when the request body omits it |

If you use the `x-openwebif-url` anchor from the example `docker-compose.yml`, you only need to edit that one line.

#### Optional features

- `OPENWEBIF_URL` / `NEXT_PUBLIC_OPENWEBIF_URL` - optional default OpenWebIF URL for the Auto Sync feature
- `OPENROUTER_API_KEY` - enables the photo sync feature ([get a key](https://openrouter.ai/))
- `AUTH_ENABLED` / `AUTH_PASSPHRASE` - restricts access with a passphrase

Expand Down Expand Up @@ -175,6 +191,9 @@ FRONTEND_URL=http://localhost:3000
PORT=8000
DATA_DIR=./data

# Optional - default OpenWebIF URL for Auto Sync
OPENWEBIF_URL=http://192.168.1.50

# Optional - enables photo/screenshot sync (manual entry sync works without this)
# Get a key from https://openrouter.ai/
OPENROUTER_API_KEY=
Expand All @@ -187,6 +206,9 @@ AUTH_PASSPHRASE=
**Frontend** (`frontend/.env`):
```
NEXT_PUBLIC_API_URL=http://localhost:8000

# Optional - prefill the Auto Sync form with your OpenWebIF box URL
NEXT_PUBLIC_OPENWEBIF_URL=http://192.168.1.50
```


Expand Down Expand Up @@ -244,9 +266,15 @@ python precompute.py 2024 2025 --skip-existing

The app also includes a background task that automatically checks for and processes new session data on race weekends (Friday–Monday).

#### Photo Sync Feature
#### Broadcast Sync Features

The broadcast sync feature supports three modes:

- Manual entry, which works without any extra configuration
- Photo/screenshot sync, which requires an [OpenRouter](https://openrouter.ai/) API key as `OPENROUTER_API_KEY`
- OpenWebIF auto sync, which can be preconfigured with `OPENWEBIF_URL` and `NEXT_PUBLIC_OPENWEBIF_URL`

The broadcast sync feature lets you match the replay to a recording of a session. You can always sync manually by entering gap times directly. To also enable photo/screenshot sync (where the app reads the timing tower from an image), set an [OpenRouter](https://openrouter.ai/) API key as `OPENROUTER_API_KEY`. It uses a vision model (Gemini Flash) to read the leaderboard from the photo. Any OpenRouter-compatible API key will work.
Photo/screenshot sync uses a vision model (Gemini Flash) to read the leaderboard from the image. Any OpenRouter-compatible API key will work.

## Acknowledgements

Expand Down
163 changes: 146 additions & 17 deletions backend/routers/sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,15 @@
import logging
import os
import re
import time
from typing import Optional
from urllib.parse import urlparse, urlunparse

import httpx
from PIL import Image
from pillow_heif import register_heif_opener
from fastapi import APIRouter, UploadFile, File, Query, HTTPException, Body
from pydantic import BaseModel, Field

register_heif_opener()

Expand Down Expand Up @@ -57,6 +60,16 @@
- Do NOT guess - only extract what is clearly visible"""


class OpenWebifRequest(BaseModel):
openwebif_url: str = ""
username: str = ""
password: str = ""


class OpenWebifPlaybackRequest(OpenWebifRequest):
action: str = Field(..., pattern="^(play|pause)$")


def _convert_to_jpeg(image_bytes: bytes, max_dim: int = 1200, quality: int = 80) -> bytes:
"""Convert any image format to compressed JPEG."""
img = Image.open(io.BytesIO(image_bytes))
Expand All @@ -72,6 +85,81 @@ def _convert_to_jpeg(image_bytes: bytes, max_dim: int = 1200, quality: int = 80)
return buf.getvalue()


def _normalise_openwebif_url(raw_url: str) -> str:
value = raw_url.strip() or os.environ.get("OPENWEBIF_URL", "").strip()
if not value:
raise HTTPException(status_code=400, detail="OpenWebIF IP or URL is required")
if not re.match(r"^https?://", value, re.IGNORECASE):
value = f"http://{value}"

parsed = urlparse(value)
if not parsed.scheme or not parsed.netloc:
raise HTTPException(status_code=400, detail="Enter a valid OpenWebIF IP or URL")
if parsed.query or parsed.fragment:
raise HTTPException(status_code=400, detail="OpenWebIF URL must not include query or fragment values")

path = parsed.path.rstrip("/")
return urlunparse((parsed.scheme, parsed.netloc, path, "", "", "")).rstrip("/")


def _openwebif_auth(username: str, password: str) -> httpx.BasicAuth | None:
if username or password:
return httpx.BasicAuth(username, password)
return None


async def _fetch_openwebif_grab(
base_url: str,
username: str = "",
password: str = "",
) -> tuple[bytes, int, int]:
request_started = time.perf_counter()
auth = _openwebif_auth(username, password)
try:
async with httpx.AsyncClient(timeout=15.0, follow_redirects=True, auth=auth) as client:
grab_started = time.perf_counter()
resp = await client.get(f"{base_url}/grab")
grab_finished = time.perf_counter()
except httpx.HTTPError as e:
logger.error(f"OpenWebIF grab failed for {base_url}: {e}")
raise HTTPException(status_code=502, detail="Could not reach OpenWebIF screenshot endpoint")

if resp.status_code in (401, 403):
raise HTTPException(status_code=502, detail="OpenWebIF rejected the screenshot request")
if resp.status_code != 200:
raise HTTPException(
status_code=502,
detail=f"OpenWebIF screenshot request failed with status {resp.status_code}",
)
if not resp.content:
raise HTTPException(status_code=502, detail="OpenWebIF returned an empty screenshot")

capture_offset_ms = max(0, int((((grab_started + grab_finished) / 2) - request_started) * 1000))
capture_duration_ms = max(0, int((grab_finished - grab_started) * 1000))
return resp.content, capture_offset_ms, capture_duration_ms


async def _match_sync_image(year: int, round_num: int, session_type: str, image_bytes: bytes) -> dict:
try:
image_bytes = _convert_to_jpeg(image_bytes)
except Exception as e:
logger.error(f"Image conversion failed: {e}")
raise HTTPException(status_code=400, detail="Could not process image")

extracted = await _extract_leaderboard(image_bytes)
logger.info(f"Extracted leaderboard: {json.dumps(extracted)}")

frames = await _get_frames(year, round_num, session_type)
if not frames:
raise HTTPException(status_code=404, detail="No replay data available")

result = _match_frame(frames, extracted)
logger.info(
f"Matched to timestamp={result['timestamp']:.1f}s, lap={result['lap']}, confidence={result['confidence']:.0f}"
)
return result


async def _extract_leaderboard(image_bytes: bytes) -> dict:
"""Send image to Gemini via OpenRouter and extract leaderboard data."""
api_key = os.environ.get("OPENROUTER_API_KEY", "")
Expand Down Expand Up @@ -276,24 +364,65 @@ async def sync_from_photo(
if len(image_bytes) > 20 * 1024 * 1024:
raise HTTPException(status_code=400, detail="File too large (max 20MB)")

# Convert to JPEG (handles HEIC, PNG, etc.)
try:
image_bytes = _convert_to_jpeg(image_bytes)
except Exception as e:
logger.error(f"Image conversion failed: {e}")
raise HTTPException(status_code=400, detail="Could not process image")
return await _match_sync_image(year, round_num, type, image_bytes)

# Extract leaderboard data from image
extracted = await _extract_leaderboard(image_bytes)
logger.info(f"Extracted leaderboard: {json.dumps(extracted)}")

# Load replay frames
frames = await _get_frames(year, round_num, type)
if not frames:
raise HTTPException(status_code=404, detail="No replay data available")
@router.post("/sessions/{year}/{round_num}/sync-auto")
async def sync_from_openwebif(
year: int,
round_num: int,
body: OpenWebifRequest = Body(...),
type: str = Query("R"),
):
base_url = _normalise_openwebif_url(body.openwebif_url)
image_bytes, capture_offset_ms, capture_duration_ms = await _fetch_openwebif_grab(
base_url,
body.username,
body.password,
)
result = await _match_sync_image(year, round_num, type, image_bytes)
result["capture_offset_ms"] = capture_offset_ms
result["capture_duration_ms"] = capture_duration_ms
result["source"] = "openwebif"
return result

# Match against frames
result = _match_frame(frames, extracted)
logger.info(f"Matched to timestamp={result['timestamp']:.1f}s, lap={result['lap']}, confidence={result['confidence']:.0f}")

return result
@router.post("/openwebif/playback")
async def control_openwebif_playback(body: OpenWebifPlaybackRequest):
base_url = _normalise_openwebif_url(body.openwebif_url)
auth = _openwebif_auth(body.username, body.password)

try:
async with httpx.AsyncClient(timeout=10.0, follow_redirects=True, auth=auth) as client:
resp = await client.get(
f"{base_url}/api/mediaplayercmd",
params={"command": body.action},
)
except httpx.HTTPError as e:
logger.error(f"OpenWebIF playback command failed for {base_url}: {e}")
raise HTTPException(status_code=502, detail="Could not reach OpenWebIF playback controls")

if resp.status_code in (401, 403):
raise HTTPException(status_code=502, detail="OpenWebIF rejected the playback command")
if resp.status_code != 200:
raise HTTPException(
status_code=502,
detail=f"OpenWebIF playback command failed with status {resp.status_code}",
)

try:
data = resp.json()
except ValueError:
data = {}

if isinstance(data, dict) and data.get("result") is False:
raise HTTPException(
status_code=502,
detail=data.get("message") or f"OpenWebIF could not {body.action} playback",
)

return {
"result": True,
"action": body.action,
"message": data.get("message") if isinstance(data, dict) else None,
}
16 changes: 10 additions & 6 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
x-openwebif-url: &openwebif_url "" # Optional default OpenWebIF box URL, for example http://192.168.1.50

services:
backend:
build: ./backend
ports:
- "8000:8000" # Change the left side to use a different host port
environment:
- FRONTEND_URL=http://localhost:3000 # The URL your browser uses to access the frontend (for CORS)
- DATA_DIR=/data
FRONTEND_URL: http://localhost:3000 # The URL your browser uses to access the frontend (for CORS)
DATA_DIR: /data
OPENWEBIF_URL: *openwebif_url # Optional default OpenWebIF box URL for auto sync
# Optional - uncomment to enable features
# - OPENROUTER_API_KEY=your-key-here # enables photo/screenshot sync (manual entry sync works without this)
# - AUTH_ENABLED=true
# - AUTH_PASSPHRASE=your-passphrase
OPENROUTER_API_KEY: "" # enables photo/screenshot sync (manual entry sync works without this)
# AUTH_ENABLED: "true"
# AUTH_PASSPHRASE: your-passphrase
volumes:
- f1data:/data
- f1cache:/data/fastf1-cache
Expand All @@ -19,7 +22,8 @@ services:
ports:
- "3000:3000" # Change the left side to use a different host port
environment:
- NEXT_PUBLIC_API_URL=http://localhost:8000 # The URL your browser uses to reach the backend
NEXT_PUBLIC_API_URL: http://localhost:8000 # The URL your browser uses to reach the backend
NEXT_PUBLIC_OPENWEBIF_URL: *openwebif_url # Optional default OpenWebIF box URL shown in Auto Sync
depends_on:
- backend

Expand Down
2 changes: 2 additions & 0 deletions frontend/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ COPY . .

ARG NEXT_PUBLIC_API_URL=__NEXT_PUBLIC_API_URL__
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
ARG NEXT_PUBLIC_OPENWEBIF_URL=__NEXT_PUBLIC_OPENWEBIF_URL__
ENV NEXT_PUBLIC_OPENWEBIF_URL=$NEXT_PUBLIC_OPENWEBIF_URL

RUN npm run build

Expand Down
27 changes: 18 additions & 9 deletions frontend/entrypoint.sh
Original file line number Diff line number Diff line change
@@ -1,12 +1,21 @@
#!/bin/sh
# Replace the build-time placeholder with the runtime NEXT_PUBLIC_API_URL.
# If NEXT_PUBLIC_API_URL is not set, default to http://localhost:8000.
RUNTIME_URL="${NEXT_PUBLIC_API_URL:-http://localhost:8000}"
PLACEHOLDER="__NEXT_PUBLIC_API_URL__"

if [ "$RUNTIME_URL" != "$PLACEHOLDER" ]; then
find /app/.next -name "*.js" -exec sed -i "s|$PLACEHOLDER|$RUNTIME_URL|g" {} +
echo "Configured API URL: $RUNTIME_URL"
fi
# Replace build-time placeholders with runtime public env values.

escape_sed_replacement() {
printf '%s' "$1" | sed 's/[|&]/\\&/g'
}

replace_public_env() {
PLACEHOLDER="$1"
VALUE="$2"
LABEL="$3"
ESCAPED_VALUE=$(escape_sed_replacement "$VALUE")

find /app/.next -name "*.js" -exec sed -i "s|$PLACEHOLDER|$ESCAPED_VALUE|g" {} +
echo "Configured $LABEL: ${VALUE:-<empty>}"
}

replace_public_env "__NEXT_PUBLIC_API_URL__" "${NEXT_PUBLIC_API_URL:-http://localhost:8000}" "API URL"
replace_public_env "__NEXT_PUBLIC_OPENWEBIF_URL__" "${NEXT_PUBLIC_OPENWEBIF_URL:-}" "OpenWebIF URL"

exec "$@"
Loading