From f4eab159d719b6a2b65dbe8f6482aba44b415411 Mon Sep 17 00:00:00 2001 From: Ray Carroll Date: Tue, 24 Mar 2026 16:53:13 +0000 Subject: [PATCH 1/2] Adding option for sync using screenshots auto taken from openwebif; added +/- sync(in seconds) for fine tuning sync if needed --- README.md | 42 ++- backend/routers/sync.py | 163 +++++++++- docker-compose.yml | 16 +- frontend/Dockerfile | 2 + frontend/entrypoint.sh | 27 +- .../src/app/replay/[year]/[round]/page.tsx | 64 +++- frontend/src/components/PlaybackControls.tsx | 77 ++++- frontend/src/components/SyncPhoto.tsx | 177 ++++++++++- frontend/src/hooks/useAutoSyncSettings.ts | 70 +++++ frontend/src/hooks/useReplayAutoSync.ts | 296 ++++++++++++++++++ 10 files changed, 881 insertions(+), 53 deletions(-) create mode 100644 frontend/src/hooks/useAutoSyncSettings.ts create mode 100644 frontend/src/hooks/useReplayAutoSync.ts diff --git a/README.md b/README.md index 5b1ccc7..8214dcb 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 @@ -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 @@ -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 | |---|---|---| @@ -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 @@ -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= @@ -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 ``` @@ -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 diff --git a/backend/routers/sync.py b/backend/routers/sync.py index 699d0c9..6cd3601 100644 --- a/backend/routers/sync.py +++ b/backend/routers/sync.py @@ -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() @@ -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)) @@ -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", "") @@ -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, + } diff --git a/docker-compose.yml b/docker-compose.yml index d2240be..e082e32 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: "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" # enables photo/screenshot sync (manual entry sync works without this) + # AUTH_ENABLED: "true" + # AUTH_PASSPHRASE: your-passphrase volumes: - f1data:/data - f1cache:/data/fastf1-cache @@ -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 diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 4e8f689..4a49bad 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -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 diff --git a/frontend/entrypoint.sh b/frontend/entrypoint.sh index 00877f0..db75cfa 100644 --- a/frontend/entrypoint.sh +++ b/frontend/entrypoint.sh @@ -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:-}" +} + +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 "$@" diff --git a/frontend/src/app/replay/[year]/[round]/page.tsx b/frontend/src/app/replay/[year]/[round]/page.tsx index 97fd7bc..292522a 100644 --- a/frontend/src/app/replay/[year]/[round]/page.tsx +++ b/frontend/src/app/replay/[year]/[round]/page.tsx @@ -4,6 +4,8 @@ import { useState, useEffect, useRef, useCallback } from "react"; import { useParams, useSearchParams } from "next/navigation"; import { useApi } from "@/hooks/useApi"; import { useReplaySocket } from "@/hooks/useReplaySocket"; +import { useAutoSyncSettings } from "@/hooks/useAutoSyncSettings"; +import { useReplayAutoSync } from "@/hooks/useReplayAutoSync"; import { useSettings } from "@/hooks/useSettings"; import SessionBanner from "@/components/SessionBanner"; import TrackCanvas from "@/components/TrackCanvas"; @@ -13,7 +15,7 @@ import TelemetryChart from "@/components/TelemetryChart"; import SyncPhoto from "@/components/SyncPhoto"; import PiPWindow from "@/components/PiPWindow"; import type { SectorOverlay } from "@/lib/trackRenderer"; -import { Maximize, Minimize, ArrowUpRight } from "lucide-react"; +import { ArrowUpRight } from "lucide-react"; interface TrackData { track_points: { x: number; y: number }[]; @@ -130,6 +132,7 @@ export default function ReplayPage() { }); } const { settings, update: updateSetting } = useSettings(); + const { settings: autoSyncSettings, save: saveAutoSyncSettings } = useAutoSyncSettings(); const { data: sessionData, loading: sessionLoading, error: sessionError } = useApi( `/api/sessions/${year}/${round}?type=${sessionType}`, @@ -140,6 +143,49 @@ export default function ReplayPage() { ); const replay = useReplaySocket(year, round, sessionType); + const { + status: autoSyncStatus, + syncNow: forceAutoSync, + syncPlaybackState, + } = useReplayAutoSync({ + year, + round, + sessionType, + settings: autoSyncSettings, + replay: { + ready: replay.ready, + playing: replay.playing, + speed: replay.speed, + currentTime: replay.frame?.timestamp || 0, + totalTime: replay.totalTime, + play: replay.play, + pause: replay.pause, + seek: replay.seek, + }, + }); + + const prevAutoEnabledRef = useRef(autoSyncSettings.enabled); + useEffect(() => { + if ( + autoSyncSettings.enabled && + !prevAutoEnabledRef.current && + autoSyncSettings.openwebifUrl.trim() && + replay.ready + ) { + void forceAutoSync(); + } + prevAutoEnabledRef.current = autoSyncSettings.enabled; + }, [autoSyncSettings.enabled, autoSyncSettings.openwebifUrl, forceAutoSync, replay.ready]); + + const handlePlay = useCallback(() => { + replay.play(); + void syncPlaybackState("play"); + }, [replay.play, syncPlaybackState]); + + const handlePause = useCallback(() => { + replay.pause(); + void syncPlaybackState("pause"); + }, [replay.pause, syncPlaybackState]); // RC sound notification const lastRcCountRef = useRef(0); @@ -731,14 +777,18 @@ export default function ReplayPage() { totalLaps={replay.totalLaps} finished={replay.finished} showSessionTime={settings.showSessionTime} - onPlay={replay.play} - onPause={replay.pause} + onPlay={handlePlay} + onPause={handlePause} onSpeedChange={replay.setSpeed} onSeek={replay.seek} onSeekToLap={replay.seekToLap} onReset={replay.reset} isRace={isRace} onSyncPhoto={() => setShowSyncPhoto(true)} + onForceAutoSync={autoSyncSettings.enabled ? () => { void forceAutoSync(); } : undefined} + autoSyncEnabled={isRace && autoSyncSettings.enabled} + autoSyncLabel={autoSyncStatus.label} + autoSyncState={autoSyncStatus.state} onPiP={!isMobile && !isIOS ? () => setPipActive(true) : undefined} pipActive={pipActive} onFullscreen={!isMobile ? () => { @@ -915,8 +965,8 @@ export default function ReplayPage() { totalLaps={replay.totalLaps} finished={replay.finished} showSessionTime={settings.showSessionTime} - onPlay={replay.play} - onPause={replay.pause} + onPlay={handlePlay} + onPause={handlePause} onSpeedChange={replay.setSpeed} onSeek={replay.seek} onSeekToLap={replay.seekToLap} @@ -937,6 +987,10 @@ export default function ReplayPage() { round={round} sessionType={sessionType} onSync={(timestamp) => replay.seek(timestamp)} + autoSyncSettings={autoSyncSettings} + autoSyncStatus={autoSyncStatus} + onAutoSyncSettingsSave={saveAutoSyncSettings} + onAutoSyncNow={(settingsOverride) => forceAutoSync("manual", settingsOverride)} onClose={() => setShowSyncPhoto(false)} /> )} diff --git a/frontend/src/components/PlaybackControls.tsx b/frontend/src/components/PlaybackControls.tsx index e90d027..42b17e9 100644 --- a/frontend/src/components/PlaybackControls.tsx +++ b/frontend/src/components/PlaybackControls.tsx @@ -29,6 +29,10 @@ interface Props { onSeekToLap?: (lap: number) => void; isRace?: boolean; onSyncPhoto?: () => void; + onForceAutoSync?: () => void; + autoSyncEnabled?: boolean; + autoSyncLabel?: string; + autoSyncState?: "disabled" | "idle" | "syncing" | "synced" | "paused" | "error"; onPiP?: () => void; pipActive?: boolean; onFullscreen?: () => void; @@ -54,6 +58,10 @@ export default function PlaybackControls({ onSeekToLap, isRace, onSyncPhoto, + onForceAutoSync, + autoSyncEnabled, + autoSyncLabel, + autoSyncState, onPiP, pipActive, onFullscreen, @@ -77,6 +85,55 @@ export default function PlaybackControls({ onSeek(target); } + function nudgeSync(delta: number) { + const target = Math.max(0, Math.min(totalTime, currentTime + delta)); + onSeek(target); + } + + function renderSyncControls() { + if (!onSyncPhoto) return null; + + return ( +
+ + + +
+ ); + } + + function autoSyncButtonClass() { + switch (autoSyncState) { + case "synced": + return "border-emerald-500/40 text-emerald-200 hover:bg-emerald-500/10"; + case "paused": + return "border-amber-500/40 text-amber-200 hover:bg-amber-500/10"; + case "error": + return "border-red-500/40 text-red-300 hover:bg-red-500/10"; + case "syncing": + return "border-sky-500/40 text-sky-200 hover:bg-sky-500/10"; + default: + return "border-f1-border text-f1-muted hover:text-white hover:bg-white/10"; + } + } + // Play/pause button (shared between mobile compact and full views) const playPauseBtn = ( )} {onSeekToLap && ( @@ -336,12 +395,14 @@ export default function PlaybackControls({ {formatTime(currentTime)}{showSessionTime && ` / ${formatTime(totalTime)}`} - {onSyncPhoto && ( + {renderSyncControls()} + {autoSyncEnabled && onForceAutoSync && ( )} diff --git a/frontend/src/components/SyncPhoto.tsx b/frontend/src/components/SyncPhoto.tsx index 80a934c..af9d0a0 100644 --- a/frontend/src/components/SyncPhoto.tsx +++ b/frontend/src/components/SyncPhoto.tsx @@ -2,6 +2,8 @@ import { useState, useRef, useEffect, useCallback } from "react"; import { getToken } from "@/lib/auth"; +import type { AutoSyncSettings } from "@/hooks/useAutoSyncSettings"; +import type { AutoSyncStatus } from "@/hooks/useReplayAutoSync"; interface Props { year: number; @@ -9,6 +11,10 @@ interface Props { sessionType: string; onSync: (timestamp: number) => void; onClose: () => void; + autoSyncSettings: AutoSyncSettings; + autoSyncStatus: AutoSyncStatus; + onAutoSyncSettingsSave: (settings: AutoSyncSettings) => void; + onAutoSyncNow: (settings?: AutoSyncSettings) => void | Promise; } interface SyncResult { @@ -32,11 +38,17 @@ export default function SyncPhoto({ sessionType, onSync, onClose, + autoSyncSettings, + autoSyncStatus, + onAutoSyncSettingsSave, + onAutoSyncNow, }: Props) { - const [tab, setTab] = useState<"photo" | "manual">("photo"); + const [tab, setTab] = useState<"photo" | "manual" | "auto">("photo"); const [step, setStep] = useState<"instructions" | "capture" | "processing" | "result">("instructions"); const [error, setError] = useState(null); const [result, setResult] = useState(null); + const [autoDraft, setAutoDraft] = useState(autoSyncSettings); + const [autoError, setAutoError] = useState(null); const fileInputRef = useRef(null); const cameraInputRef = useRef(null); @@ -212,6 +224,34 @@ export default function SyncPhoto({ setError(null); } + async function handleAutoSave(runSync: boolean = false) { + setAutoError(null); + const nextSettings: AutoSyncSettings = { + ...autoDraft, + openwebifUrl: autoDraft.openwebifUrl.trim(), + username: autoDraft.username.trim(), + intervalSeconds: Math.max(5, Math.round(Number(autoDraft.intervalSeconds) || 60)), + }; + + if ((nextSettings.enabled || runSync) && !nextSettings.openwebifUrl) { + setAutoError("Enter your OpenWebIF IP or URL"); + return; + } + + onAutoSyncSettingsSave(nextSettings); + if (runSync) { + await onAutoSyncNow(nextSettings); + } + } + + const autoStatusTone = autoSyncStatus.state === "error" + ? "border-red-500/30 bg-red-500/10 text-red-300" + : autoSyncStatus.state === "paused" + ? "border-amber-500/30 bg-amber-500/10 text-amber-200" + : autoSyncStatus.state === "synced" + ? "border-emerald-500/30 bg-emerald-500/10 text-emerald-200" + : "border-f1-border bg-white/5 text-white"; + return (
Manual Entry +
)} @@ -587,6 +637,131 @@ export default function SyncPhoto({ )} + + {tab === "auto" && step !== "result" && ( +
+

+ Grab the current frame directly from OpenWebIF and keep the replay aligned while it is playing. +

+ + {autoError && ( +
+

{autoError}

+
+ )} + +
+ + setAutoDraft({ ...autoDraft, openwebifUrl: e.target.value })} + placeholder="192.168.1.50" + className="w-full bg-f1-dark border border-f1-border rounded-lg px-3 py-2 text-sm text-white placeholder:text-f1-muted/50 focus:outline-none focus:border-f1-red" + /> +
+ +
+
+ + setAutoDraft({ ...autoDraft, username: e.target.value })} + placeholder="Optional" + className="w-full bg-f1-dark border border-f1-border rounded-lg px-3 py-2 text-sm text-white placeholder:text-f1-muted/50 focus:outline-none focus:border-f1-red" + /> +
+
+ + setAutoDraft({ ...autoDraft, password: e.target.value })} + placeholder="Optional" + className="w-full bg-f1-dark border border-f1-border rounded-lg px-3 py-2 text-sm text-white placeholder:text-f1-muted/50 focus:outline-none focus:border-f1-red" + /> +
+
+ +
+ + setAutoDraft({ ...autoDraft, intervalSeconds: Number(e.target.value) || 60 })} + className="w-full bg-f1-dark border border-f1-border rounded-lg px-3 py-2 text-sm text-white placeholder:text-f1-muted/50 focus:outline-none focus:border-f1-red" + /> +

Default is 60 seconds.

+
+ + + + + +
+
+ {autoSyncStatus.label} + {autoSyncStatus.lastSyncAt && ( + {new Date(autoSyncStatus.lastSyncAt).toLocaleTimeString()} + )} +
+

{autoSyncStatus.detail}

+
+ +
+ + +
+
+ )} diff --git a/frontend/src/hooks/useAutoSyncSettings.ts b/frontend/src/hooks/useAutoSyncSettings.ts new file mode 100644 index 0000000..2432815 --- /dev/null +++ b/frontend/src/hooks/useAutoSyncSettings.ts @@ -0,0 +1,70 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; + +export interface AutoSyncSettings { + enabled: boolean; + openwebifUrl: string; + username: string; + password: string; + intervalSeconds: number; + syncPause: boolean; +} + +const ENV_DEFAULT_OPENWEBIF_URL = process.env.NEXT_PUBLIC_OPENWEBIF_URL || ""; + +export const AUTO_SYNC_DEFAULTS: AutoSyncSettings = { + enabled: false, + openwebifUrl: ENV_DEFAULT_OPENWEBIF_URL, + username: "", + password: "", + intervalSeconds: 60, + syncPause: false, +}; + +const STORAGE_KEY = "f1replay_auto_sync"; + +function normaliseAutoSyncSettings(value?: Partial | null): AutoSyncSettings { + const openwebifUrl = typeof value?.openwebifUrl === "string" && value.openwebifUrl.trim() + ? value.openwebifUrl + : ENV_DEFAULT_OPENWEBIF_URL; + const intervalSeconds = Number(value?.intervalSeconds); + + return { + ...AUTO_SYNC_DEFAULTS, + ...value, + openwebifUrl, + intervalSeconds: Number.isFinite(intervalSeconds) && intervalSeconds > 0 + ? intervalSeconds + : AUTO_SYNC_DEFAULTS.intervalSeconds, + }; +} + +function loadAutoSyncSettings(): AutoSyncSettings { + if (typeof window === "undefined") return AUTO_SYNC_DEFAULTS; + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (raw) { + const parsed = JSON.parse(raw); + return normaliseAutoSyncSettings(parsed); + } + } catch {} + return normaliseAutoSyncSettings(); +} + +export function useAutoSyncSettings() { + const [settings, setSettings] = useState(AUTO_SYNC_DEFAULTS); + + useEffect(() => { + setSettings(loadAutoSyncSettings()); + }, []); + + const save = useCallback((next: AutoSyncSettings) => { + setSettings(next); + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(next)); + } catch {} + }, []); + + return { settings, save }; +} diff --git a/frontend/src/hooks/useReplayAutoSync.ts b/frontend/src/hooks/useReplayAutoSync.ts new file mode 100644 index 0000000..8886508 --- /dev/null +++ b/frontend/src/hooks/useReplayAutoSync.ts @@ -0,0 +1,296 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { getToken } from "@/lib/auth"; +import { apiUrl } from "@/lib/api"; +import type { AutoSyncSettings } from "@/hooks/useAutoSyncSettings"; + +type AutoSyncState = "disabled" | "idle" | "syncing" | "synced" | "paused" | "error"; + +interface AutoSyncResponse { + timestamp: number; + lap: number; + confidence: number; + capture_offset_ms?: number; +} + +interface LastSyncSnapshot { + matchedTimestamp: number; + capturePerfTime: number; +} + +export interface AutoSyncStatus { + state: AutoSyncState; + label: string; + detail: string; + lastSyncAt: number | null; +} + +interface ReplaySnapshot { + ready: boolean; + playing: boolean; + speed: number; + currentTime: number; + totalTime: number; + play: () => void; + pause: () => void; + seek: (timestamp: number) => void; +} + +interface Params { + year: number; + round: number; + sessionType: string; + settings: AutoSyncSettings; + replay: ReplaySnapshot; +} + +function formatSignedSeconds(value: number): string { + const sign = value >= 0 ? "+" : "-"; + return `${sign}${Math.abs(value).toFixed(1)}s`; +} + +function buildHeaders(): HeadersInit { + const headers: HeadersInit = { "Content-Type": "application/json" }; + const token = getToken(); + if (token) { + headers.Authorization = `Bearer ${token}`; + } + return headers; +} + +export function useReplayAutoSync({ year, round, sessionType, settings, replay }: Params) { + const [status, setStatus] = useState(() => ({ + state: settings.enabled ? "idle" : "disabled", + label: settings.enabled ? "Waiting" : "Auto Off", + detail: settings.enabled ? "Waiting for replay to start" : "Auto sync is disabled", + lastSyncAt: null, + })); + + const settingsRef = useRef(settings); + const replayRef = useRef(replay); + const inFlightRef = useRef(false); + const autoPausedRef = useRef(false); + const lastSyncRef = useRef(null); + + settingsRef.current = settings; + replayRef.current = replay; + + const syncNow = useCallback(async ( + reason: "manual" | "interval" = "manual", + overrideSettings?: AutoSyncSettings, + ) => { + const currentSettings = overrideSettings ?? settingsRef.current; + const currentReplay = replayRef.current; + + if (inFlightRef.current) return; + if (!currentSettings.enabled && reason !== "manual") return; + if (!currentSettings.openwebifUrl.trim()) { + setStatus({ + state: "error", + label: "No Box IP", + detail: "Enter your OpenWebIF IP or URL in the Auto tab", + lastSyncAt: null, + }); + return; + } + if (!currentReplay.ready) return; + if (reason === "interval" && !currentReplay.playing && !autoPausedRef.current) return; + + inFlightRef.current = true; + setStatus((prev) => ({ + ...prev, + state: "syncing", + label: "Syncing...", + detail: "Fetching a fresh OpenWebIF grab", + })); + + const requestStarted = performance.now(); + const replayAtRequest = { + playing: currentReplay.playing, + speed: currentReplay.speed, + currentTime: currentReplay.currentTime, + totalTime: currentReplay.totalTime, + }; + + try { + const resp = await fetch( + apiUrl(`/api/sessions/${year}/${round}/sync-auto?type=${encodeURIComponent(sessionType)}`), + { + method: "POST", + headers: buildHeaders(), + body: JSON.stringify({ + openwebif_url: currentSettings.openwebifUrl.trim(), + username: currentSettings.username.trim(), + password: currentSettings.password, + }), + }, + ); + + if (!resp.ok) { + const err = await resp.json().catch(() => ({ detail: "Request failed" })); + throw new Error(err.detail || `Error ${resp.status}`); + } + + const data: AutoSyncResponse = await resp.json(); + const elapsedSeconds = (performance.now() - requestStarted) / 1000; + const captureOffsetSeconds = Math.min(elapsedSeconds, (data.capture_offset_ms || 0) / 1000); + const advanceSinceCapture = replayAtRequest.playing + ? Math.max(0, elapsedSeconds - captureOffsetSeconds) * replayAtRequest.speed + : 0; + const targetTime = Math.max( + 0, + Math.min( + replayAtRequest.totalTime || Number.POSITIVE_INFINITY, + data.timestamp + advanceSinceCapture, + ), + ); + const expectedCurrentTime = replayAtRequest.playing + ? replayAtRequest.currentTime + (elapsedSeconds * replayAtRequest.speed) + : replayAtRequest.currentTime; + const driftSeconds = targetTime - expectedCurrentTime; + const capturePerfTime = requestStarted + (captureOffsetSeconds * 1000); + + let tvPaused = false; + const previousSync = lastSyncRef.current; + if (currentSettings.syncPause && previousSync) { + const wallDeltaSeconds = (capturePerfTime - previousSync.capturePerfTime) / 1000; + const tvDeltaSeconds = data.timestamp - previousSync.matchedTimestamp; + if (wallDeltaSeconds >= 2 && tvDeltaSeconds <= Math.max(0.5, wallDeltaSeconds * 0.05)) { + tvPaused = true; + } + } + + if (tvPaused) { + if (currentReplay.playing) { + currentReplay.pause(); + } + autoPausedRef.current = true; + } else { + if ((reason === "manual" || Math.abs(driftSeconds) > 0.35) && Math.abs(targetTime - currentReplay.currentTime) > 0.05) { + currentReplay.seek(targetTime); + } + if (autoPausedRef.current && !currentReplay.playing) { + currentReplay.play(); + } + autoPausedRef.current = false; + } + + lastSyncRef.current = { + matchedTimestamp: data.timestamp, + capturePerfTime, + }; + + setStatus({ + state: tvPaused ? "paused" : "synced", + label: tvPaused ? "TV Paused" : Math.abs(driftSeconds) > 0.35 ? `Adjusted ${formatSignedSeconds(driftSeconds)}` : "In Sync", + detail: `Lap ${data.lap}${data.confidence ? `, ${Math.round(data.confidence)}% confidence` : ""}`, + lastSyncAt: Date.now(), + }); + } catch (e: any) { + setStatus({ + state: "error", + label: "Sync Failed", + detail: e?.message || "Failed to sync from OpenWebIF", + lastSyncAt: Date.now(), + }); + } finally { + inFlightRef.current = false; + } + }, [round, sessionType, year]); + + const syncPlaybackState = useCallback(async (action: "play" | "pause") => { + const currentSettings = settingsRef.current; + if (!currentSettings.enabled || !currentSettings.syncPause || !currentSettings.openwebifUrl.trim()) { + if (action === "play") { + autoPausedRef.current = false; + } + return; + } + + if (action === "play") { + autoPausedRef.current = false; + } + + try { + const resp = await fetch(apiUrl("/api/openwebif/playback"), { + method: "POST", + headers: buildHeaders(), + body: JSON.stringify({ + openwebif_url: currentSettings.openwebifUrl.trim(), + username: currentSettings.username.trim(), + password: currentSettings.password, + action, + }), + }); + + if (!resp.ok) { + const err = await resp.json().catch(() => ({ detail: "Request failed" })); + throw new Error(err.detail || `Error ${resp.status}`); + } + } catch (e: any) { + setStatus((prev) => ({ + ...prev, + state: "error", + label: "TV Control Failed", + detail: e?.message || `Could not send ${action} to OpenWebIF`, + })); + } + }, []); + + useEffect(() => { + if (!settings.enabled) { + autoPausedRef.current = false; + lastSyncRef.current = null; + setStatus({ + state: "disabled", + label: "Auto Off", + detail: "Auto sync is disabled", + lastSyncAt: null, + }); + return; + } + + if (!settings.openwebifUrl.trim()) { + setStatus({ + state: "idle", + label: "Needs Setup", + detail: "Enter your OpenWebIF IP or URL to start syncing", + lastSyncAt: null, + }); + return; + } + + setStatus((prev) => ({ + ...prev, + state: prev.state === "error" ? prev.state : "idle", + label: prev.state === "error" ? prev.label : "Waiting", + detail: prev.state === "error" ? prev.detail : "Auto sync will run while playback is active", + })); + }, [settings.enabled, settings.openwebifUrl]); + + useEffect(() => { + if (!settings.enabled || !settings.openwebifUrl.trim() || !replay.ready) return; + + let cancelled = false; + + const tick = async () => { + if (cancelled) return; + await syncNow("interval"); + }; + + const intervalMs = Math.max(5, settings.intervalSeconds) * 1000; + const timer = window.setInterval(tick, intervalMs); + + return () => { + cancelled = true; + window.clearInterval(timer); + }; + }, [replay.ready, settings.enabled, settings.intervalSeconds, settings.openwebifUrl, syncNow]); + + return { + status, + syncNow, + syncPlaybackState, + }; +} From 128859db543db1f27a9e57d5fe26a5d5a4508b36 Mon Sep 17 00:00:00 2001 From: UGREEN USER Date: Tue, 24 Mar 2026 17:16:26 +0000 Subject: [PATCH 2/2] Fix --- docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index e082e32..6bf29d0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,7 +10,7 @@ services: DATA_DIR: /data OPENWEBIF_URL: *openwebif_url # Optional default OpenWebIF box URL for auto sync # Optional - uncomment to enable features - OPENROUTER_API_KEY: "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" # enables photo/screenshot sync (manual entry sync works without this) + OPENROUTER_API_KEY: "" # enables photo/screenshot sync (manual entry sync works without this) # AUTH_ENABLED: "true" # AUTH_PASSPHRASE: your-passphrase volumes: