From c1a7ccce43af22ca4057aba1927d82585ad42507 Mon Sep 17 00:00:00 2001 From: jorgesandev Date: Sat, 10 Oct 2026 09:56:50 -0700 Subject: [PATCH 1/2] feat: add local camera-assisted pingpong scorer --- .github/workflows/quality.yml | 6 + .gitignore | 5 + .prettierignore | 4 + README.md | 4 + app/components/Navbar.tsx | 87 +- app/layout.tsx | 3 - app/pingpong/CameraPanel.tsx | 606 +++++++++++++ app/pingpong/PingPongClient.tsx | 1201 ++++++++++++++++++++++++++ app/pingpong/core/match.ts | 137 +++ app/pingpong/core/storage.ts | 19 + app/pingpong/page.tsx | 29 + app/pingpong/pingpong.module.css | 981 +++++++++++++++++++++ app/pingpong/vision/geometry.ts | 67 ++ app/pingpong/vision/tracker.ts | 299 +++++++ app/pingpong/vision/worker.ts | 29 + bun.lock | 10 + docs/pingpong/README.md | 57 ++ docs/pingpong/validation-report.md | 30 + eslint.config.mjs | 3 + next.config.ts | 14 + package.json | 10 +- playwright.config.ts | 26 + public/pingpong/icon-192.png | Bin 0 -> 9313 bytes public/pingpong/icon-512.png | Bin 0 -> 4095 bytes public/pingpong/icon.svg | 1 + public/pingpong/manifest.webmanifest | 26 + public/pingpong/offline.html | 37 + public/pingpong/sw.js | 65 ++ tests/e2e/pingpong.spec.ts | 223 +++++ tests/pingpong.test.ts | 276 ++++++ vercel.json | 4 + 31 files changed, 4216 insertions(+), 43 deletions(-) create mode 100644 app/pingpong/CameraPanel.tsx create mode 100644 app/pingpong/PingPongClient.tsx create mode 100644 app/pingpong/core/match.ts create mode 100644 app/pingpong/core/storage.ts create mode 100644 app/pingpong/page.tsx create mode 100644 app/pingpong/pingpong.module.css create mode 100644 app/pingpong/vision/geometry.ts create mode 100644 app/pingpong/vision/tracker.ts create mode 100644 app/pingpong/vision/worker.ts create mode 100644 docs/pingpong/README.md create mode 100644 docs/pingpong/validation-report.md create mode 100644 playwright.config.ts create mode 100644 public/pingpong/icon-192.png create mode 100644 public/pingpong/icon-512.png create mode 100644 public/pingpong/icon.svg create mode 100644 public/pingpong/manifest.webmanifest create mode 100644 public/pingpong/offline.html create mode 100644 public/pingpong/sw.js create mode 100644 tests/e2e/pingpong.spec.ts create mode 100644 tests/pingpong.test.ts create mode 100644 vercel.json diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index d2d0300..74e82d5 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -41,3 +41,9 @@ jobs: - name: Build run: bun run build + + - name: Install browser + run: bunx playwright install --with-deps chromium + + - name: Browser tests + run: bun run test:e2e diff --git a/.gitignore b/.gitignore index c8e7737..f828786 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,8 @@ yarn-error.log* next-env.d.ts !.env.example + +/test-results/ +/playwright-report/ + +/public/pingpong/vision.worker.js diff --git a/.prettierignore b/.prettierignore index ab6cc80..9195070 100644 --- a/.prettierignore +++ b/.prettierignore @@ -2,3 +2,7 @@ .claude node_modules public/*.pdf + +public/pingpong/vision.worker.js +test-results +playwright-report diff --git a/README.md b/README.md index 874ab50..fa36c9a 100644 --- a/README.md +++ b/README.md @@ -65,3 +65,7 @@ Professional links and contact details are available at [jorgesandoval.dev](http ## License Copyright © Jorge Sandoval. All rights reserved. + +## PingPong + +The mobile scorer lives at [/pingpong](https://jorgesandoval.dev/pingpong). It includes local camera tracking, assisted points, limited experimental automatic double-bounce scoring, manual correction and local history. See [setup, architecture and limitations](docs/pingpong/README.md) and [validation](docs/pingpong/validation-report.md). The camera mode is a beta; real-game accuracy has not been measured. diff --git a/app/components/Navbar.tsx b/app/components/Navbar.tsx index ffc1671..56e3a34 100644 --- a/app/components/Navbar.tsx +++ b/app/components/Navbar.tsx @@ -1,5 +1,6 @@ "use client"; import { useState } from "react"; +import { usePathname } from "next/navigation"; import Link from "next/link"; const links = [ @@ -10,45 +11,57 @@ const links = [ ]; export default function Navbar() { const [open, setOpen] = useState(false); + const pathname = usePathname(); + if (pathname === "/pingpong" || pathname.startsWith("/pingpong/")) + return null; return ( -
- -
+ {links.map(([label, id]) => ( + setOpen(false)}> + {label} + + ))} + + LinkedIn + + + + + ); } diff --git a/app/layout.tsx b/app/layout.tsx index 3a813bd..922b93b 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -33,9 +33,6 @@ export default function RootLayout({ return ( - - Skip to content - {children} diff --git a/app/pingpong/CameraPanel.tsx b/app/pingpong/CameraPanel.tsx new file mode 100644 index 0000000..f1b9249 --- /dev/null +++ b/app/pingpong/CameraPanel.tsx @@ -0,0 +1,606 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { validCorners, type Corners, type XY } from "./vision/geometry"; +import type { Suggestion, VisionResult } from "./vision/tracker"; +import styles from "./pingpong.module.css"; + +type Props = { + active: boolean; + epoch: string; + onSuggestion: (suggestion: Suggestion) => void; + onReady: (ready: boolean) => void; + onCameraLost: () => void; +}; +const cornerNames = [ + "cercana izquierda", + "cercana derecha", + "lejana derecha", + "lejana izquierda", +]; + +export default function CameraPanel({ + active, + epoch, + onSuggestion, + onReady, + onCameraLost, +}: Props) { + const video = useRef(null); + const stream = useRef(null); + const objectUrl = useRef(null); + const request = useRef(0); + const worker = useRef(null); + const callbacks = useRef({ onSuggestion, onReady, onCameraLost }); + const [source, setSource] = useState<"camera" | "file" | null>(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + const [corners, setCorners] = useState([]); + const [cursor, setCursor] = useState({ x: 0.5, y: 0.5 }); + const [aspect, setAspect] = useState(16 / 9); + const [color, setColor] = useState<"white" | "orange">("white"); + const [sensitivity, setSensitivity] = useState(50); + const [diameter, setDiameter] = useState(9); + const [result, setResult] = useState(null); + const [cameraFps, setCameraFps] = useState(0); + const [facing, setFacing] = useState<"environment" | "user">("environment"); + const [showSettings, setShowSettings] = useState(false); + const [trackingError, setTrackingError] = useState(""); + const [generation, setGeneration] = useState(0); + const calibrated = validCorners(corners); + + useEffect(() => { + callbacks.current = { onSuggestion, onReady, onCameraLost }; + }, [onSuggestion, onReady, onCameraLost]); + useEffect(() => { + callbacks.current.onReady(!!source && calibrated && !trackingError); + }, [source, calibrated, trackingError]); + useEffect( + () => () => { + request.current++; + stream.current?.getTracks().forEach((track) => track.stop()); + if (objectUrl.current) URL.revokeObjectURL(objectUrl.current); + }, + [], + ); + + function releaseSource() { + stream.current?.getTracks().forEach((track) => track.stop()); + stream.current = null; + if (objectUrl.current) URL.revokeObjectURL(objectUrl.current); + objectUrl.current = null; + setSource(null); + setCorners([]); + setResult(null); + } + async function openCamera(nextFacing = facing) { + const id = ++request.current; + releaseSource(); + setLoading(true); + setError(""); + setTrackingError(""); + try { + if (!navigator.mediaDevices?.getUserMedia) throw new Error("unsupported"); + const media = await navigator.mediaDevices.getUserMedia({ + audio: false, + video: { + facingMode: { ideal: nextFacing }, + width: { ideal: 1280 }, + height: { ideal: 720 }, + frameRate: { ideal: 60 }, + }, + }); + if (id !== request.current) { + media.getTracks().forEach((track) => track.stop()); + return; + } + stream.current = media; + const track = media.getVideoTracks()[0]; + setCameraFps(Math.round(track.getSettings().frameRate ?? 0)); + track.onended = () => { + setError("Se interrumpió la cámara. Vuelve a activarla."); + setSource(null); + callbacks.current.onCameraLost(); + }; + if (video.current) { + video.current.srcObject = media; + await video.current.play(); + } + setFacing(nextFacing); + setSource("camera"); + setGeneration((value) => value + 1); + } catch (reason) { + if (id !== request.current) return; + stream.current?.getTracks().forEach((track) => track.stop()); + stream.current = null; + const name = reason instanceof Error ? reason.name : ""; + setError( + name === "NotAllowedError" + ? "Permite el acceso a la cámara en el navegador, o juega con marcador manual." + : "No pudimos abrir la cámara. Comprueba que esté disponible y abre este enlace en Safari o Chrome.", + ); + } finally { + if (id === request.current) setLoading(false); + } + } + function openFile(file?: File) { + if (!file) return; + request.current++; + releaseSource(); + setError(""); + setTrackingError(""); + setLoading(false); + objectUrl.current = URL.createObjectURL(file); + if (video.current) { + video.current.srcObject = null; + video.current.src = objectUrl.current; + } + setSource("file"); + setCameraFps(0); + setGeneration((value) => value + 1); + } + function metadata() { + if (video.current?.videoWidth) + setAspect(video.current.videoWidth / video.current.videoHeight); + } + + useEffect(() => { + if (!source || !calibrated) return; + const instance = new Worker("/pingpong/vision.worker.js"); + worker.current = instance; + let raf = 0, + busy = false, + lastTime = -1, + lastUi = 0, + sentId = ""; + const canvas = document.createElement("canvas"); + const context = canvas.getContext("2d", { willReadFrequently: true }); + let disposed = false; + let watchdog: ReturnType | undefined; + instance.onerror = () => { + setTrackingError( + "El seguimiento no pudo iniciarse. Recarga la página o usa el marcador manual.", + ); + callbacks.current.onCameraLost(); + }; + instance.onmessage = (event: MessageEvent) => { + if (disposed || event.data.generation !== generation) return; + if (event.data.type === "ready") { + busy = false; + return; + } + busy = false; + clearTimeout(watchdog); + if (event.data.type === "error") { + setTrackingError("Se interrumpió el seguimiento. Reinicia la cámara."); + callbacks.current.onCameraLost(); + return; + } + const next = event.data.result as VisionResult; + if (performance.now() - lastUi > 80 || next.suggestion) { + setResult(next); + lastUi = performance.now(); + } + if (next.motion > 0.45 && active) { + callbacks.current.onCameraLost(); + setCorners([]); + setError( + "La cámara se movió o cambió mucho la escena. Marca las esquinas de nuevo.", + ); + } + if (active && next.suggestion && next.suggestion.id !== sentId) { + sentId = next.suggestion.id; + callbacks.current.onSuggestion({ + ...next.suggestion, + id: `${generation}:${next.suggestion.id}`, + }); + } + }; + instance.postMessage({ + type: "configure", + generation, + config: { corners: corners as Corners, color, sensitivity, diameter }, + }); + function tick() { + const element = video.current; + if ( + !disposed && + !busy && + context && + element && + element.readyState >= 2 && + element.currentTime !== lastTime + ) { + lastTime = element.currentTime; + canvas.width = 480; + canvas.height = Math.round( + 480 / (element.videoWidth / element.videoHeight || aspect), + ); + context.drawImage(element, 0, 0, canvas.width, canvas.height); + const image = context.getImageData(0, 0, canvas.width, canvas.height); + busy = true; + instance.postMessage( + { + type: "frame", + generation, + pixels: image.data.buffer, + width: canvas.width, + height: canvas.height, + time: element.currentTime * 1000, + }, + [image.data.buffer], + ); + watchdog = setTimeout(() => { + if (!disposed) { + setTrackingError( + "El teléfono dejó de responder al seguimiento. Prueba el modo manual.", + ); + callbacks.current.onCameraLost(); + instance.terminate(); + } + }, 5000); + } + if (!disposed) raf = requestAnimationFrame(tick); + } + // Calibration previews track the ball but never score. + raf = requestAnimationFrame(tick); + return () => { + disposed = true; + cancelAnimationFrame(raf); + clearTimeout(watchdog); + instance.terminate(); + worker.current = null; + }; + }, [ + source, + calibrated, + corners, + color, + sensitivity, + diameter, + aspect, + generation, + active, + epoch, + ]); + + useEffect(() => { + const element = video.current; + if (source === "file" && element) { + if (active) + void element + .play() + .catch(() => setError("Pulsa reproducir para analizar el video.")); + else element.pause(); + } + worker.current?.postMessage({ type: "reset" }); + }, [active, source]); + + return ( +
+
+ 02 / Cámara + ● Solo en tu teléfono +
+
+
+
+ {source ? ( + <> +
+ + ●{" "} + {active + ? result?.phase === "tracking" + ? "Pelota en juego" + : "Esperando saque" + : "Vista previa"} + + + {cameraFps ? `${cameraFps} fps cámara · ` : ""} + {Math.round(result?.fps ?? 0)} fps análisis + +
+ {!calibrated ? ( +

+ {corners.length < 4 ? ( + <> + Toca la esquina{" "} + {cornerNames[corners.length]} de la mesa.{" "} + {corners.length + 1}/4. También puedes usar flechas y Enter. + + ) : ( + "Las esquinas deben formar un rectángulo en perspectiva, sin cruzarse. Vuelve a marcarlas." + )} +

+ ) : ( +

+ Mesa calibrada.{" "} + {active + ? "El conteo experimental está activo." + : "Comprueba la trayectoria y pulsa Reanudar."} +

+ )} +
+ + + +
+ {showSettings ? ( +
+ + + +
+ ) : null} + + ) : ( + <> +

+ Teléfono horizontal, fijo y elevado a un costado. Deben verse los + dos extremos y la red. Usa buena luz y una sola pelota. +

+ + + )} + {error || trackingError ? ( +

+ {error || trackingError} +

+ ) : null} + {!active ? ( + + ) : null} +

+ Visión experimental por color y movimiento. Los puntos dudosos + necesitan confirmación; los roces de red y borde pueden pasar + inadvertidos. +

+
+
+ ); +} diff --git a/app/pingpong/PingPongClient.tsx b/app/pingpong/PingPongClient.tsx new file mode 100644 index 0000000..75b4c37 --- /dev/null +++ b/app/pingpong/PingPongClient.tsx @@ -0,0 +1,1201 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import dynamic from "next/dynamic"; +import Link from "next/link"; +import { + addPoint, + correctPoint, + isMatch, + other, + resultText, + scoreMatch, + undoPoint, + type Match, + type Mode, + type Player, +} from "./core/match"; +import { loadMatches, saveMatch } from "./core/storage"; +import type { Suggestion } from "./vision/tracker"; +import styles from "./pingpong.module.css"; + +const CameraPanel = dynamic(() => import("./CameraPanel"), { + ssr: false, + loading: () => ( +
+

Preparando cámara…

+
+ ), +}); +type Screen = "home" | "setup" | "match" | "history"; + +function TableArt() { + return ( + + ); +} +function download(value: unknown) { + const url = URL.createObjectURL( + new Blob([JSON.stringify(value, null, 2)], { type: "application/json" }), + ); + const link = document.createElement("a"); + link.href = url; + link.download = "pingpong-historial.json"; + link.click(); + setTimeout(() => URL.revokeObjectURL(url), 1000); +} + +export default function PingPongClient() { + const [screen, setScreen] = useState("home"); + const [match, setMatch] = useState(null); + const [history, setHistory] = useState([]); + const [paused, setPaused] = useState(false); + const [ready, setReady] = useState(false); + const [pending, setPending] = useState(null); + const [sideChange, setSideChange] = useState(false); + const [finishDialog, setFinishDialog] = useState(false); + const [help, setHelp] = useState(false); + const [sound, setSound] = useState(true); + const [wake, setWake] = useState(true); + const [message, setMessage] = useState(""); + const [loaded, setLoaded] = useState(false); + const [visionReset, setVisionReset] = useState(0); + const [storageError, setStorageError] = useState(false); + const [update, setUpdate] = useState(null); + const writeQueue = useRef(Promise.resolve()); + const activeMatch = useRef(match); + const dialog = useRef(null); + const current = match ? scoreMatch(match) : null; + const completed = + !!match?.finished || + (current?.winner !== null && current?.winner !== undefined); + const inPlay = + screen === "match" && + !paused && + !pending && + !sideChange && + !finishDialog && + !completed; + const resumable = history.find( + (item) => !item.finished && scoreMatch(item).winner === null, + ); + + useEffect(() => { + activeMatch.current = match; + }, [match]); + useEffect(() => { + let cancelled = false; + loadMatches() + .then((items) => { + if (!cancelled) { + setHistory(items); + setLoaded(true); + } + }) + .catch(() => { + if (!cancelled) { + setLoaded(true); + setStorageError(true); + } + }); + let remembered: string | null = null; + try { + remembered = localStorage.getItem("pingpong-settings"); + } catch { + /* Optional settings storage. */ + } + if (remembered) { + try { + const parsed = JSON.parse(remembered); + queueMicrotask(() => { + if (!cancelled) { + setSound(parsed.sound !== false); + setWake(parsed.wake !== false); + } + }); + } catch { + /* Defaults survive invalid settings. */ + } + } + return () => { + cancelled = true; + }; + }, []); + useEffect(() => { + try { + localStorage.setItem( + "pingpong-settings", + JSON.stringify({ sound, wake }), + ); + } catch { + /* Settings remain in memory. */ + } + }, [sound, wake]); + useEffect(() => { + if (!match || !loaded) return; + const snapshot = match; + writeQueue.current = writeQueue.current + .then(() => saveMatch(snapshot)) + .catch(() => setStorageError(true)); + }, [match, loaded]); + useEffect(() => { + const visibility = () => { + if (document.hidden) { + setPaused(true); + setPending(null); + if ("speechSynthesis" in window) speechSynthesis.cancel(); + } + }; + document.addEventListener("visibilitychange", visibility); + const rotate = () => { + if (activeMatch.current?.mode !== "manual") { + setPaused(true); + setReady(false); + setMessage( + "La orientación cambió. Recalibra la cámara antes de continuar.", + ); + } + }; + window.addEventListener("orientationchange", rotate); + return () => { + document.removeEventListener("visibilitychange", visibility); + window.removeEventListener("orientationchange", rotate); + }; + }, []); + useEffect(() => { + if (!inPlay || !wake || !("wakeLock" in navigator)) return; + let cancelled = false; + let lock: WakeLockSentinel | null = null; + navigator.wakeLock + .request("screen") + .then((value) => { + if (cancelled) void value.release(); + else lock = value; + }) + .catch(() => { + /* Optional browser capability. */ + }); + return () => { + cancelled = true; + if (lock) void lock.release(); + }; + }, [inPlay, wake]); + useEffect(() => { + if (!("serviceWorker" in navigator)) return; + let disposed = false; + navigator.serviceWorker + .register("/pingpong/sw.js", { scope: "/pingpong" }) + .then((registration) => { + if (!disposed && registration.waiting) setUpdate(registration); + registration.addEventListener("updatefound", () => { + registration.installing?.addEventListener("statechange", () => { + if ( + !disposed && + registration.waiting && + navigator.serviceWorker.controller + ) + setUpdate(registration); + }); + }); + }) + .catch(() => { + /* The web app works without installation support. */ + }); + return () => { + disposed = true; + }; + }, []); + useEffect(() => { + if (!help && !pending && !sideChange && !finishDialog) return; + const previous = document.activeElement as HTMLElement | null; + const node = dialog.current; + node?.focus(); + const trap = (event: KeyboardEvent) => { + if (event.key === "Escape" && help) { + setHelp(false); + return; + } + if (event.key !== "Tab" || !node) return; + const items = [ + ...node.querySelectorAll( + 'button:not(:disabled), a[href], input, select, [tabindex="0"]', + ), + ]; + const first = items[0], + last = items[items.length - 1]; + if (!first) { + event.preventDefault(); + return; + } + if ( + event.shiftKey && + (document.activeElement === first || document.activeElement === node) + ) { + event.preventDefault(); + last.focus(); + } else if ( + !event.shiftKey && + (document.activeElement === last || document.activeElement === node) + ) { + event.preventDefault(); + first.focus(); + } + }; + document.addEventListener("keydown", trap); + return () => { + document.removeEventListener("keydown", trap); + previous?.focus(); + }; + }, [help, pending, sideChange, finishDialog]); + + function announce(text: string) { + if (!sound || !("speechSynthesis" in window)) return; + speechSynthesis.cancel(); + const voice = new SpeechSynthesisUtterance(text); + voice.lang = "es-MX"; + voice.rate = 1; + speechSynthesis.speak(voice); + } + function recordPoint( + winner: Player, + source: "manual" | "camera" = "manual", + rallyId = crypto.randomUUID(), + ) { + if (!match || !current || completed || sideChange) return; + const next = addPoint(match, { + id: crypto.randomUUID(), + rallyId, + winner, + source, + at: Date.now(), + }); + if (next === match) return; + const nextScore = scoreMatch(next); + setMatch(next); + setPending(null); + if (nextScore.winner !== null) { + announce(`Ganó ${next.players[nextScore.winner]}.`); + setPaused(true); + } else { + if (current.sideKey !== nextScore.sideKey) { + setSideChange(true); + setPaused(true); + } + announce( + `Punto para ${next.players[winner]}. ${nextScore.points[0]} a ${nextScore.points[1]}.`, + ); + } + } + const suggestionHandler = useRef<(value: Suggestion) => void>(() => {}); + useEffect(() => { + suggestionHandler.current = (value) => { + if (!match || !current || !inPlay) return; + if ( + value.strong && + value.loserSide !== null && + match.mode === "automatic" + ) { + const loser = + value.loserSide === 0 ? current.near : other(current.near); + recordPoint(other(loser), "camera", value.id); + setMessage( + "Punto experimental por posible doble bote. Puedes corregirlo.", + ); + } else { + setPending(value); + } + }; + }); + const onSuggestion = useCallback( + (value: Suggestion) => suggestionHandler.current(value), + [], + ); + const onReady = useCallback((value: boolean) => setReady(value), []); + const onCameraLost = useCallback(() => { + setPaused(true); + setReady(false); + setPending(null); + }, []); + function createMatch(event: React.FormEvent) { + event.preventDefault(); + const data = new FormData(event.currentTarget); + const next: Match = { + version: 1, + id: crypto.randomUUID(), + players: [ + String(data.get("p1") || "Jugador 1").trim() || "Jugador 1", + String(data.get("p2") || "Jugador 2").trim() || "Jugador 2", + ], + bestOf: Number(data.get("bestOf")) as 1 | 3 | 5, + firstServer: Number(data.get("server")) as Player, + initialNear: Number(data.get("near")) as Player, + mode: String(data.get("mode")) as Mode, + points: [], + created: Date.now(), + }; + setMatch(next); + setPaused(next.mode !== "manual"); + setReady(false); + setPending(null); + setSideChange(false); + setMessage(""); + setScreen("match"); + setHistory((items) => [next, ...items]); + } + async function goHome() { + setPaused(true); + setPending(null); + setSideChange(false); + setFinishDialog(false); + setScreen("home"); + if (match) + setHistory((items) => [ + match, + ...items.filter((item) => item.id !== match.id), + ]); + } + async function share() { + if (!match) return; + const text = resultText(match); + try { + if (navigator.share) await navigator.share({ text }); + else { + await navigator.clipboard.writeText(text); + setMessage("Resultado copiado."); + } + } catch { + setMessage("Puedes exportar el partido para guardarlo."); + } + } + async function importHistory(file?: File) { + if (!file) return; + if (file.size > 5_000_000) { + setMessage("El archivo es demasiado grande."); + return; + } + try { + const input = JSON.parse(await file.text()); + const values: unknown[] = Array.isArray(input) ? input : [input]; + if (values.length > 1000 || !values.every(isMatch)) + throw new Error("Invalid"); + await writeQueue.current; + for (const item of values as Match[]) await saveMatch(item); + setHistory(await loadMatches()); + setMessage("Historial importado."); + } catch { + setMessage( + "No pudimos importar ese archivo. Usa una exportación de PingPong válida.", + ); + } + } + const modalOpen = help || !!pending || sideChange || finishDialog; + + return ( +
+
+
+ +
+ + JUEGO LOCAL / SIN CUENTAS + + +
+
+ {screen === "home" ? ( + <> +
+
+

+ Un teléfono. Una mesa. Otro set. +

+

+ Jueguen. +
+ El marcador +
+ va aquí. +

+

+ Puntos, sets y servicio. Coloca tu teléfono frente a la mesa y + deja que la cámara siga la pelota. +

+
+ + {resumable ? ( + + ) : null} +
+ +
+
+ + CAM / VISIÓN EXPERIMENTAL + + + +
+
+
+
+ 01 +

Abre un partido.

+

Dos nombres y quién saca primero.

+
+
+ 02 +

Encuadra la mesa.

+

Cámara fija, buena luz, pelota visible.

+
+
+ 03 +

Que siga el juego.

+

La cámara sigue. Tú corriges si hace falta.

+
+
+
+ Visión beta +

+ El marcador está listo. La cámara usa color y movimiento: ayuda + a detectar el final del intercambio y propone posibles dobles + botes. La precisión depende de tu encuadre; las jugadas dudosas + se confirman. +

+
+ + ) : null} + {screen === "setup" ? ( +
+ +
+

01 / El partido

+

¿Quién juega?

+

Sets a 11. Gana por dos puntos.

+
+
+
+ + +
+
+ + + +
+
+ Cómo llevamos los puntos + + + +
+ +
+
+ ) : null} + {screen === "match" && match && current ? ( + <> +
+
+

+ PARTIDO /{" "} + {new Date(match.created).toLocaleDateString("es-MX", { + day: "2-digit", + month: "short", + })} +

+

{completed ? "Buen partido." : "Un punto más."}

+
+
+ + +
+
+
+
+
+ + {completed + ? "Resultado" + : `Set ${current.games.length + 1}`}{" "} + / Mejor de {match.bestOf} + + + {match.mode === "manual" + ? "MANUAL" + : match.mode === "assisted" + ? "ASISTIDO" + : "AUTO · BETA"} + +
+
+ {([0, 1] as Player[]).map((player) => ( +
+
+ {match.players[player]} +
+
+ {current.points[player].toString().padStart(2, "0")} +
+
+ + {current.sets[player]}{" "} + {current.sets[player] === 1 ? "set" : "sets"} + + + {!completed && current.server === player + ? "● Saca" + : ""} + +
+ +
+ ))} +
+
+ {completed + ? current.winner !== null + ? `Ganó ${match.players[current.winner]}` + : "Partido terminado" + : paused + ? "En pausa" + : pending + ? "Punto pendiente" + : current.points[0] >= 10 && current.points[1] >= 10 + ? "Ventaja de dos · servicio cada punto" + : `Saca ${match.players[current.server]}`} +
+ {current.games.length ? ( +
+ {current.games.map((game, index) => ( + + S{index + 1} {game.join(" – ")} + + ))} +
+ ) : null} +
+ + + + +
+ {!completed && paused && match.mode !== "manual" && !ready ? ( +

+ Activa la cámara y marca las cuatro esquinas. Después puedes + reanudar. +

+ ) : null} + {!completed ? ( +
+ + {match.mode !== "manual" ? ( + + ) : ( + + )} +
+ ) : ( +
+ + + +
+ )} +
+ {match.mode !== "manual" && !completed ? ( + + ) : null} +
+ + + ) : null} + {screen === "history" ? ( +
+ +
+

LOS PARTIDOS

+

Quedó en la mesa.

+

Guardados solo en este navegador.

+
+ {history.length ? ( +
+ {history.map((item) => { + const score = scoreMatch(item); + return ( + + ); + })} +
+ ) : ( +

Aquí aparecerán tus partidos.

+ )} +
+ + +
+
+ ) : null} + {message ? ( +
+ {message} + +
+ ) : null} + {storageError ? ( +

+ No pudimos guardar en este navegador. Exporta el partido antes de + cerrar. +

+ ) : null} + {update && !inPlay ? ( +
+ Hay una versión nueva. + +
+ ) : null} +
+ HECHO PARA OTRO SET. + Jorge Sandoval ↗ + Cámara local · visión beta +
+
+ {modalOpen ? ( +
+
+ {help ? ( + <> +

ANTES DEL SAQUE

+

+ La mesa completa. +
+ La pelota visible. +

+
    +
  1. + Coloca el teléfono horizontal y fijo en un soporte, elevado + a un costado. +
  2. +
  3. + Marca las esquinas en este orden: cercana izquierda, cercana + derecha, lejana derecha y lejana izquierda. +
  4. +
  5. + Comprueba que la pelota deje una trayectoria. Ajusta color, + sensibilidad y tamaño si es necesario. +
  6. +
  7. + Reanuda y juega. Confirma puntos dudosos o corrige el último + con un toque. +
  8. +
+

+ El modo automático es experimental: solo suma posibles dobles + botes tras un intercambio observado. No arbitra saques, red ni + bordes. En asistido, todos los puntos se confirman. +

+

+ La cámara se procesa aquí, sin subir video. Mantén la página + abierta; al bloquear el teléfono, el partido se pausa. +

+

+ Para instalar: usa «Añadir a pantalla de inicio» en el menú + del navegador. Necesitas conexión para abrir o recargar la + aplicación. El partido abierto sigue funcionando si se corta + la red. +

+ + + ) : pending && match && current ? ( + <> +

PUNTO PENDIENTE

+

¿Quién ganó?

+

{pending.reason}

+ {pending.loserSide !== null ? ( +

+ Posible punto para{" "} + + { + match.players[ + other( + pending.loserSide === 0 + ? current.near + : other(current.near), + ) + ] + } + + . +

+ ) : null} +
+ {([0, 1] as Player[]).map((player) => ( + + ))} +
+ + + ) : sideChange && match && current ? ( + <> +

MISMO JUEGO. OTRO LADO.

+

Cambien de lado.

+

+ {match.players[current.near]} va en el + extremo cercano a la cámara. Mantén el teléfono fijo. +

+ + + ) : finishDialog && match ? ( + <> +

CERRAR EL PARTIDO

+

¿Lo dejamos aquí?

+

Se guardará el resultado actual en tu historial.

+ + + + ) : null} +
+
+ ) : null} +
+ ); +} diff --git a/app/pingpong/core/match.ts b/app/pingpong/core/match.ts new file mode 100644 index 0000000..6279058 --- /dev/null +++ b/app/pingpong/core/match.ts @@ -0,0 +1,137 @@ +export type Player = 0 | 1; +export type Mode = "manual" | "assisted" | "automatic"; +export type Point = { + id: string; + rallyId: string; + winner: Player; + source: "manual" | "camera"; + at: number; +}; +export type Match = { + version: 1; + id: string; + players: [string, string]; + bestOf: 1 | 3 | 5; + firstServer: Player; + initialNear: Player; + mode: Mode; + points: Point[]; + created: number; + finished?: number; +}; +export type Score = { + points: [number, number]; + sets: [number, number]; + games: [number, number][]; + server: Player; + winner: Player | null; + near: Player; + sideKey: string; +}; +export const other = (player: Player): Player => (player === 0 ? 1 : 0); + +export function scoreMatch(match: Match): Score { + let points: [number, number] = [0, 0]; + const sets: [number, number] = [0, 0]; + const games: [number, number][] = []; + let winner: Player | null = null; + const seen = new Set(); + for (const point of match.points) { + if (winner !== null || seen.has(point.rallyId)) continue; + seen.add(point.rallyId); + points[point.winner] += 1; + if ( + points[point.winner] >= 11 && + points[point.winner] - points[other(point.winner)] >= 2 + ) { + sets[point.winner] += 1; + games.push([...points]); + if (sets[point.winner] >= Math.ceil(match.bestOf / 2)) { + winner = point.winner; + } else { + points = [0, 0]; + } + } + } + const gameIndex = winner === null ? games.length : games.length - 1; + const first = gameIndex % 2 ? other(match.firstServer) : match.firstServer; + const total = points[0] + points[1]; + const turns = + points[0] >= 10 && points[1] >= 10 ? total - 20 : Math.floor(total / 2); + const server = turns % 2 ? other(first) : first; + const deciding = gameIndex === match.bestOf - 1 && Math.max(...points) >= 5; + const swapped = (gameIndex + Number(deciding)) % 2; + return { + points, + sets, + games, + server, + winner, + near: swapped ? other(match.initialNear) : match.initialNear, + sideKey: `${gameIndex}:${Number(deciding)}`, + }; +} + +export function addPoint(match: Match, point: Point): Match { + if ( + match.finished || + scoreMatch(match).winner !== null || + match.points.some((item) => item.rallyId === point.rallyId) + ) + return match; + return { ...match, points: [...match.points, point] }; +} + +export function undoPoint(match: Match): Match { + return { ...match, finished: undefined, points: match.points.slice(0, -1) }; +} + +export function correctPoint(match: Match): Match { + if (!match.points.length) return match; + const points = [...match.points]; + const last = points[points.length - 1]; + points[points.length - 1] = { + ...last, + winner: other(last.winner), + source: "manual", + }; + return { ...match, finished: undefined, points }; +} + +export function resultText(match: Match): string { + const score = scoreMatch(match); + const partial = + score.winner === null ? ` · En curso ${score.points.join("–")}` : ""; + return `🏓 ${match.players[0]} ${score.sets[0]}–${score.sets[1]} ${match.players[1]} · ${score.games.map((game) => game.join("–")).join(", ")}${partial} · PingPong`; +} + +export function isMatch(value: unknown): value is Match { + if (!value || typeof value !== "object") return false; + const item = value as Partial; + return ( + item.version === 1 && + typeof item.id === "string" && + Array.isArray(item.players) && + item.players.length === 2 && + item.players.every( + (name) => typeof name === "string" && name.length <= 40, + ) && + [1, 3, 5].includes(item.bestOf ?? 0) && + [0, 1].includes(item.firstServer ?? -1) && + [0, 1].includes(item.initialNear ?? -1) && + ["manual", "assisted", "automatic"].includes(item.mode ?? "") && + Number.isFinite(item.created) && + (item.finished === undefined || Number.isFinite(item.finished)) && + Array.isArray(item.points) && + item.points.length <= 10000 && + item.points.every( + (point) => + point && + typeof point.id === "string" && + typeof point.rallyId === "string" && + [0, 1].includes(point.winner) && + ["manual", "camera"].includes(point.source) && + Number.isFinite(point.at), + ) + ); +} diff --git a/app/pingpong/core/storage.ts b/app/pingpong/core/storage.ts new file mode 100644 index 0000000..d85f420 --- /dev/null +++ b/app/pingpong/core/storage.ts @@ -0,0 +1,19 @@ +import { openDB } from "idb"; +import { isMatch, type Match } from "./match"; +const db = () => + openDB("pingpong", 1, { + upgrade(database) { + database.createObjectStore("matches", { keyPath: "id" }); + }, + }); +export async function saveMatch(match: Match) { + const database = await db(); + await database.put("matches", match); + database.close(); +} +export async function loadMatches(): Promise { + const database = await db(); + const matches: unknown[] = await database.getAll("matches"); + database.close(); + return matches.filter(isMatch).sort((a, b) => b.created - a.created); +} diff --git a/app/pingpong/page.tsx b/app/pingpong/page.tsx new file mode 100644 index 0000000..7ea7112 --- /dev/null +++ b/app/pingpong/page.tsx @@ -0,0 +1,29 @@ +import type { Metadata, Viewport } from "next"; +import PingPongClient from "./PingPongClient"; +export const metadata: Metadata = { + title: "PingPong — Jueguen. El marcador va aquí.", + description: + "Marcador de ping pong con cámara, seguimiento local de pelota, puntos, sets y servicio. Abre el enlace y empieza a jugar.", + alternates: { canonical: "https://jorgesandoval.dev/pingpong" }, + manifest: "/pingpong/manifest.webmanifest", + appleWebApp: { + capable: true, + title: "PingPong", + statusBarStyle: "black-translucent", + }, + openGraph: { + title: "PingPong", + description: "Jueguen. El marcador va aquí.", + url: "https://jorgesandoval.dev/pingpong", + locale: "es_MX", + }, +}; +export const viewport: Viewport = { + themeColor: "#101820", + width: "device-width", + initialScale: 1, + viewportFit: "cover", +}; +export default function PingPongPage() { + return ; +} diff --git a/app/pingpong/pingpong.module.css b/app/pingpong/pingpong.module.css new file mode 100644 index 0000000..9d56b76 --- /dev/null +++ b/app/pingpong/pingpong.module.css @@ -0,0 +1,981 @@ +.app { + --bg: #101820; + --panel: #172229; + --text: #f7f9f0; + --muted: #a2b2b5; + --line: #34444b; + --lime: #d9f95f; + --cyan: #66d9ef; + --orange: #ffb36b; + min-height: 100dvh; + background: var(--bg); + color: var(--text); + line-height: 1.5; + padding: max(20px, env(safe-area-inset-top)) + max(24px, env(safe-area-inset-right)) max(12px, env(safe-area-inset-bottom)) + max(24px, env(safe-area-inset-left)); +} +.app :focus-visible { + outline: 2px solid var(--lime); + outline-offset: 5px; +} +.app button { + transition: + background 120ms, + color 120ms; +} +.app button:disabled { + opacity: 0.4; + cursor: not-allowed; +} +.container { + max-width: 1250px; + margin: auto; +} +.header { + display: flex; + align-items: center; + justify-content: space-between; + border-bottom: 1px solid var(--line); + padding: 9px 0 22px; + gap: 20px; +} +.brand { + background: none; + border: 0; + padding: 0; + color: var(--text); + display: flex; + align-items: center; + font-size: 29px; + font-weight: 600; + letter-spacing: -1.5px; +} +.brand > span:not(.brandMark) { + color: var(--lime); +} +.brandMark { + display: grid; + place-items: center; + background: var(--lime); + color: var(--bg); + width: 38px; + height: 38px; + margin-right: 12px; + border-radius: 50%; + font-size: 30px; +} +.brand sup { + font: + 10px var(--font-plex-mono), + monospace; + margin: 0 0 18px 8px; + color: var(--muted); + letter-spacing: 0; +} +.headerRight { + display: flex; + align-items: center; + gap: 24px; +} +.headerCaption, +.kicker, +.visualTag, +.localBadge, +.pill, +.footer, +.cameraStatus, +.panelHead, +.sampleScore > span { + font-family: var(--font-plex-mono), monospace; + font-size: 10px; + letter-spacing: 0.09em; +} +.headerCaption { + color: var(--muted); +} +.iconButton { + width: 48px; + height: 48px; + border: 1px solid var(--line); + border-radius: 50%; + background: none; + color: var(--text); + font-size: 19px; +} +.iconButton:hover { + border-color: var(--lime); + color: var(--lime); +} +.hero { + display: grid; + grid-template-columns: 1.04fr 1fr; + gap: 28px; + padding: 70px 0 55px; + align-items: center; +} +.kicker { + color: var(--muted); + text-transform: uppercase; +} +.hero h1 { + font-size: clamp(54px, 6.3vw, 88px); + line-height: 0.97; + letter-spacing: -0.065em; + font-weight: 600; + margin: 28px 0; +} +.hero h1 em { + font-style: normal; + color: var(--lime); +} +.heroDescription { + color: var(--muted); + max-width: 360px; + font-size: 16px; +} +.heroActions { + display: flex; + flex-wrap: wrap; + gap: 12px; + margin: 29px 0 12px; +} +.primary, +.secondary { + border: 1px solid var(--line); + padding: 15px 22px; + min-height: 54px; + display: inline-flex; + justify-content: space-between; + align-items: center; + gap: 40px; + border-radius: 4px; + font-weight: 600; + font-size: 14px; +} +.primary { + background: var(--lime); + border-color: var(--lime); + color: var(--bg); +} +.primary:hover { + background: #edffab; +} +.secondary { + background: transparent; + color: var(--text); +} +.secondary:hover, +.smallButton:hover { + background: #26363e; +} +.textButton { + display: inline-flex; + align-items: center; + gap: 20px; + color: var(--muted); + border: 0; + background: none; + padding: 12px 0; + min-height: 48px; + font-size: 13px; +} +.textButton:hover { + color: var(--lime); +} +.heroVisual { + position: relative; + align-self: stretch; + display: flex; + align-items: center; + min-height: 480px; + overflow: hidden; + background: radial-gradient(ellipse at 48% 46%, #263c3a66, transparent 67%); +} +.visualTag { + position: absolute; + top: 27px; + right: 0; + border: 1px solid var(--line); + color: var(--muted); + padding: 8px 12px; +} +.tableArt { + width: 100%; + overflow: visible; +} +.sampleScore { + position: absolute; + bottom: 8px; + right: 15px; + display: grid; + grid-template-columns: 1fr auto 1fr; + align-items: center; + gap: 17px; +} +.sampleScore > span { + color: var(--muted); +} +.sampleScore strong { + font-family: var(--font-plex-mono), monospace; + font-weight: 400; + font-size: 53px; + letter-spacing: -0.08em; +} +.sampleScore strong span { + color: var(--line); +} +.steps { + display: grid; + grid-template-columns: repeat(3, 1fr); + border-top: 1px solid var(--line); + border-bottom: 1px solid var(--line); +} +.steps > div { + padding: 26px 26px 26px 0; +} +.steps > div + div { + padding-left: 28px; + border-left: 1px solid var(--line); +} +.steps > div > span { + font: + 11px var(--font-plex-mono), + monospace; + color: var(--lime); +} +.steps h2 { + font-size: 22px; + letter-spacing: -0.04em; + margin: 12px 0 8px; +} +.steps p { + font-size: 13px; + color: var(--muted); +} +.betaNote { + display: flex; + align-items: baseline; + gap: 20px; + padding: 22px 0; +} +.betaNote p { + color: var(--muted); + font-size: 12px; + max-width: 820px; +} +.pill { + display: inline-block; + font-size: 9px; + letter-spacing: 0.05em; + border: 1px solid var(--line); + border-radius: 3px; + padding: 4px 8px; + white-space: nowrap; + color: var(--muted); +} +.setupSection { + max-width: 800px; + margin: 28px auto 65px; +} +.sectionTitle { + margin: 20px 0 32px; +} +.sectionTitle h1 { + font-size: clamp(36px, 5vw, 60px); + margin: 13px 0; +} +.sectionTitle > p:last-child { + color: var(--muted); + font-size: 14px; +} +.setupForm label, +.detectorSettings label { + display: flex; + flex-direction: column; + gap: 9px; + color: var(--muted); + font-size: 13px; +} +.playerInputs { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 20px; +} +.app input:not([type="radio"]):not([type="range"]):not([type="file"]), +.app select { + padding: 14px 15px; + min-height: 52px; + background: var(--panel); + color: var(--text); + border: 1px solid var(--line); + border-radius: 4px; + width: 100%; +} +.app input::placeholder { + color: var(--muted); +} +.formGrid { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + gap: 16px; + margin-top: 22px; +} +.cyanText { + color: var(--cyan); +} +.orangeText { + color: var(--orange); +} +.modeOptions { + padding: 0; + border: 0; + margin: 30px 0; +} +.modeOptions legend { + font-size: 13px; + color: var(--muted); + padding: 0 0 12px; +} +.modeOptions > label { + display: flex; + flex-direction: row; + align-items: center; + gap: 16px; + padding: 18px; + border: 1px solid var(--line); + border-radius: 4px; + margin-bottom: 10px; + cursor: pointer; +} +.modeOptions > label:has(input:checked) { + background: #d9f95f08; + border-color: var(--lime); +} +.modeOptions input { + accent-color: var(--lime); + width: 18px; + height: 18px; + flex-shrink: 0; +} +.modeOptions strong { + display: block; + color: var(--text); + font-weight: 500; + font-size: 15px; +} +.modeOptions small { + display: block; + font-size: 12px; + margin-top: 5px; +} +.modeOptions .pill { + margin-left: auto; +} +.matchHeading { + display: flex; + justify-content: space-between; + align-items: center; + gap: 20px; + margin: 33px 0 24px; +} +.matchHeading h1 { + font-size: 37px; + margin-top: 10px; +} +.matchToggles { + display: flex; + gap: 8px; +} +.smallButton { + min-height: 44px; + padding: 9px 12px; + border: 1px solid var(--line); + background: transparent; + color: var(--text); + border-radius: 4px; + font-size: 12px; +} +.smallButton[aria-pressed="true"] { + border-color: #899b74; + color: var(--lime); +} +.matchGrid { + display: grid; + grid-template-columns: 1fr 1fr; + align-items: start; + gap: 22px; +} +.manualGrid { + max-width: 850px; + margin: auto; + grid-template-columns: 1fr; +} +.scorePanel, +.cameraPanel { + border: 1px solid var(--line); + border-radius: 7px; + background: var(--panel); + overflow: hidden; +} +.panelHead { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 17px 20px; + border-bottom: 1px solid var(--line); +} +.scoreboard { + display: grid; + grid-template-columns: 1fr 1fr; + padding: 28px 20px 20px; +} +.playerOne, +.playerTwo { + min-width: 0; + padding: 0 16px; +} +.playerOne { + color: var(--cyan); + border-right: 1px solid var(--line); +} +.playerTwo { + color: var(--orange); +} +.playerName { + font-size: 18px; + overflow-wrap: anywhere; + font-weight: 500; +} +.bigScore { + font: + 500 clamp(55px, 7.5vw, 116px) var(--font-plex-mono), + monospace; + line-height: 1.3; + letter-spacing: -0.09em; + font-variant-numeric: tabular-nums; + margin-left: -3px; +} +.manualGrid .bigScore { + font-size: clamp(80px, 14vw, 150px); +} +.playerMeta { + display: flex; + justify-content: space-between; + gap: 8px; + margin: 5px 0 22px; + font-size: 12px; + min-height: 20px; +} +.pointButton { + width: 100%; + min-height: 56px; + border: 1px solid currentColor; + background: transparent; + color: inherit; + border-radius: 4px; + font-size: 23px; + display: flex; + align-items: center; + justify-content: center; + gap: 10px; +} +.pointButton > span { + font-size: 12px; + max-width: 110px; + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; +} +.pointButton:hover { + background: #ffffff08; +} +.gameStatus { + text-align: center; + color: var(--lime); + font-size: 13px; + margin: 0 20px 20px; + min-height: 20px; +} +.setHistory { + display: flex; + justify-content: center; + flex-wrap: wrap; + gap: 20px; + padding: 0 20px 20px; + font: + 10px var(--font-plex-mono), + monospace; + color: var(--muted); +} +.setHistory strong { + color: var(--text); + font-weight: 400; + margin-left: 6px; +} +.matchControls { + display: flex; + justify-content: center; + flex-wrap: wrap; + gap: 8px; + padding: 18px 12px; + border-top: 1px solid var(--line); +} +.matchFooter { + display: flex; + justify-content: space-between; + gap: 12px; + border-top: 1px solid var(--line); + padding: 5px 20px; +} +.resultsActions { + padding: 0 20px 24px; + display: flex; + flex-wrap: wrap; + gap: 12px; +} +.cameraPanel { + background: var(--bg); +} +.localBadge { + font-size: 9px; + color: var(--muted); +} +.videoFrame { + position: relative; + background: #0b1116; + width: 100%; + overflow: hidden; +} +.videoFrame video { + display: block; + width: 100%; + height: 100%; + object-fit: fill; +} +.cameraOverlay { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + cursor: crosshair; +} +.cameraPlaceholder { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; + gap: 10px; + color: var(--muted); +} +.cameraPlaceholder svg { + width: 52px; + fill: none; + stroke: var(--muted); + stroke-width: 1.5; + margin-bottom: 4px; +} +.cameraPlaceholder strong { + font-weight: 500; + color: var(--text); + font-size: 18px; +} +.cameraPlaceholder span { + font-size: 12px; +} +.cameraBody { + padding: 18px 20px; +} +.cameraStatus { + display: flex; + justify-content: space-between; + gap: 8px; + color: var(--muted); + font-size: 9px; + letter-spacing: 0; +} +.statusOn { + color: var(--lime); +} +.instructions { + color: var(--muted); + font-size: 13px; + padding: 14px 0; +} +.scorePanel > .instructions { + padding: 0 24px 20px; +} +.instructions strong { + color: var(--text); +} +.buttonRow { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; +} +.detectorSettings { + display: grid; + gap: 18px; + margin-top: 20px; +} +.detectorSettings input[type="range"] { + width: 100%; + accent-color: var(--lime); +} +.fileLabel { + position: relative; + display: inline-flex; + align-items: center; + color: var(--muted); + font-size: 12px; + text-decoration: underline; + text-underline-offset: 4px; + padding: 13px 0; + cursor: pointer; + min-height: 44px; +} +.fileLabel input { + position: absolute; + inset: 0; + opacity: 0; + width: 100%; + cursor: pointer; +} +.fileLabel:focus-within { + outline: 2px solid var(--lime); + outline-offset: 3px; +} +.finePrint { + padding-top: 12px; + font-size: 11px; + color: var(--muted); + border-top: 1px solid var(--line); + line-height: 1.6; +} +.error { + color: #ffbab0; + padding: 14px 0; + font-size: 13px; +} +.historyList { + border-top: 1px solid var(--line); + margin-bottom: 22px; +} +.historyItem { + display: flex; + align-items: center; + gap: 24px; + justify-content: space-between; + width: 100%; + background: none; + border: 0; + border-bottom: 1px solid var(--line); + color: var(--text); + padding: 22px 0; + text-align: left; +} +.historyItem:hover { + color: var(--lime); +} +.historyItem > span:first-child { + flex: 1; + overflow-wrap: anywhere; +} +.historyItem strong { + font-size: 17px; + font-weight: 500; +} +.historyItem small { + display: block; + font-size: 11px; + color: var(--muted); + margin-top: 5px; +} +.historyScore { + font: + 24px var(--font-plex-mono), + monospace; + white-space: nowrap; +} +.emptyState { + padding: 40px 0; + border-top: 1px solid var(--line); + color: var(--muted); +} +.notice { + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; + padding: 15px 20px; + background: #d9f95f0a; + border: 1px solid #7b8c54; + border-radius: 4px; + color: var(--lime); + font-size: 13px; + margin-top: 20px; +} +.notice .iconButton { + width: 38px; + height: 38px; + flex-shrink: 0; +} +.footer { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + gap: 14px; + margin-top: 40px; + padding: 25px 0 8px; + border-top: 1px solid var(--line); + color: var(--muted); + font-size: 9px; +} +.footer a { + color: var(--text); + text-decoration: none; +} +.modalBackdrop { + position: fixed; + z-index: 100; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + background: #050b11dd; + backdrop-filter: blur(7px); +} +.modal { + width: min(100%, 520px); + max-height: calc(100dvh - 48px); + overflow: auto; + padding: 32px; + border: 1px solid var(--line); + border-radius: 10px; + background: var(--panel); +} +.modal h2 { + font-size: 36px; + margin: 18px 0; +} +.modal p, +.modal ol { + color: var(--muted); + font-size: 14px; + margin-bottom: 18px; +} +.modal ol { + padding-left: 20px; +} +.modal li { + margin-bottom: 12px; +} +.modal .primary, +.modal .secondary { + width: 100%; + margin-top: 10px; +} +.pendingChoices { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 14px; + margin: 22px 0 10px; +} +.choiceCyan, +.choiceOrange { + padding: 20px 12px; + min-height: 64px; + border: 1px solid currentColor; + color: var(--cyan); + background: none; + border-radius: 4px; + overflow-wrap: anywhere; +} +.choiceOrange { + color: var(--orange); +} +.choiceCyan:hover, +.choiceOrange:hover { + background: #ffffff0a; +} +@media (max-width: 980px) { + .matchGrid { + grid-template-columns: 1fr; + max-width: 720px; + margin: auto; + } + .bigScore { + font-size: clamp(70px, 16vw, 140px); + } + .heroVisual { + min-height: 380px; + } + .sampleScore { + right: 0; + } + .sampleScore > span { + font-size: 8px; + } + .sampleScore strong { + font-size: 40px; + } +} +@media (max-width: 640px) { + .app { + padding-inline: max(20px, env(safe-area-inset-left)); + } + .header { + padding-top: 0; + padding-bottom: 15px; + } + .headerCaption { + display: none; + } + .brand { + font-size: 25px; + } + .brandMark { + width: 33px; + height: 33px; + font-size: 26px; + } + .hero { + grid-template-columns: 1fr; + gap: 0; + padding: 35px 0 28px; + } + .hero h1 { + font-size: clamp(55px, 13vw, 77px); + margin: 20px 0; + } + .heroDescription { + max-width: 330px; + font-size: 14px; + } + .heroVisual { + min-height: 300px; + margin-top: 5px; + } + .visualTag { + top: 6px; + font-size: 8px; + } + .tableArt { + margin: -2px 0; + } + .sampleScore { + bottom: 0; + right: 0; + } + .steps { + grid-template-columns: 1fr; + } + .steps > div, + .steps > div + div { + border-left: none; + padding: 18px 0; + display: grid; + grid-template-columns: 32px 1fr; + gap: 0 10px; + } + .steps > div + div { + border-top: 1px solid var(--line); + } + .steps > div > span { + margin-top: 6px; + } + .steps h2 { + margin: 0 0 6px; + font-size: 21px; + } + .steps p { + grid-column: 2; + } + .betaNote { + flex-direction: column; + gap: 12px; + } + .formGrid { + grid-template-columns: 1fr; + } + .playerInputs { + gap: 12px; + } + .modeOptions .pill { + display: none; + } + .matchHeading { + align-items: start; + gap: 12px; + } + .matchHeading h1 { + font-size: 31px; + } + .matchToggles { + flex-direction: column; + gap: 6px; + } + .matchToggles .smallButton { + min-height: 34px; + padding: 6px 8px; + font-size: 10px; + } + .panelHead { + padding: 13px 14px; + } + .scoreboard { + padding: 23px 6px 16px; + } + .playerOne, + .playerTwo { + padding-inline: 12px; + } + .playerName { + font-size: 16px; + } + .bigScore, + .manualGrid .bigScore { + font-size: clamp(70px, 21vw, 112px); + } + .pointButton > span { + max-width: 80px; + } + .playerMeta { + font-size: 11px; + } + .matchFooter { + padding-inline: 14px; + } + .matchFooter .textButton { + font-size: 11px; + } + .cameraStatus { + flex-wrap: wrap; + } + .cameraBody { + padding-inline: 14px; + } + .localBadge { + font-size: 8px; + letter-spacing: 0; + } + .modal { + padding: 24px; + } + .modal h2 { + font-size: 31px; + } + .footer { + font-size: 8px; + } +} +@media (prefers-reduced-motion: reduce) { + .app button { + transition: none; + } +} diff --git a/app/pingpong/vision/geometry.ts b/app/pingpong/vision/geometry.ts new file mode 100644 index 0000000..a002c50 --- /dev/null +++ b/app/pingpong/vision/geometry.ts @@ -0,0 +1,67 @@ +export type XY = { x: number; y: number }; +export type Corners = [XY, XY, XY, XY]; +export type Plane = { u: number; v: number; inside: boolean }; + +export function validCorners(points: XY[]): points is Corners { + if ( + points.length !== 4 || + points.some( + (p) => + !Number.isFinite(p.x) || + !Number.isFinite(p.y) || + p.x < 0 || + p.x > 1 || + p.y < 0 || + p.y > 1, + ) + ) + return false; + const crosses = points.map((p, i) => { + const q = points[(i + 1) % 4]; + const r = points[(i + 2) % 4]; + return (q.x - p.x) * (r.y - q.y) - (q.y - p.y) * (r.x - q.x); + }); + const area = + Math.abs( + points.reduce((sum, p, i) => { + const q = points[(i + 1) % 4]; + return sum + p.x * q.y - p.y * q.x; + }, 0), + ) / 2; + return ( + area > 0.04 && + (crosses.every((c) => c > 0.001) || crosses.every((c) => c < -0.001)) + ); +} + +// Invert the projective mapping from unit table coordinates to image coordinates. +// This maps observations onto a plane; it does not recover the ball's 3D height. +export function tablePlane(corners: Corners): (point: XY) => Plane { + const [p0, p1, p2, p3] = corners; + const dx1 = p1.x - p2.x, + dx2 = p3.x - p2.x; + const dy1 = p1.y - p2.y, + dy2 = p3.y - p2.y; + const dx3 = p0.x - p1.x + p2.x - p3.x; + const dy3 = p0.y - p1.y + p2.y - p3.y; + const det = dx1 * dy2 - dx2 * dy1; + const g = Math.abs(det) < 1e-9 ? 0 : (dx3 * dy2 - dx2 * dy3) / det; + const h = Math.abs(det) < 1e-9 ? 0 : (dx1 * dy3 - dx3 * dy1) / det; + const a = p1.x - p0.x + g * p1.x, + b = p3.x - p0.x + h * p3.x; + const d = p1.y - p0.y + g * p1.y, + e = p3.y - p0.y + h * p3.y; + return (p) => { + const A = a - p.x * g, + B = b - p.x * h; + const D = d - p.y * g, + E = e - p.y * h; + const C = p.x - p0.x, + F = p.y - p0.y; + const denominator = A * E - B * D; + if (Math.abs(denominator) < 1e-9) return { u: -99, v: -99, inside: false }; + const u = (C * E - B * F) / denominator, + v = (A * F - C * D) / denominator; + return { u, v, inside: u >= 0 && u <= 1 && v >= 0 && v <= 1 }; + }; +} diff --git a/app/pingpong/vision/tracker.ts b/app/pingpong/vision/tracker.ts new file mode 100644 index 0000000..7d46d9b --- /dev/null +++ b/app/pingpong/vision/tracker.ts @@ -0,0 +1,299 @@ +import { tablePlane, type Corners, type XY } from "./geometry.ts"; +export type Detection = XY & { confidence: number; time: number; size: number }; +export type Suggestion = { + id: string; + loserSide: 0 | 1 | null; + strong: boolean; + reason: string; +}; +export type VisionResult = { + ball: Detection | null; + trail: XY[]; + phase: "waiting" | "tracking" | "pending"; + suggestion: Suggestion | null; + fps: number; + motion: number; +}; +export type VisionConfig = { + corners: Corners; + color: "white" | "orange"; + sensitivity: number; + diameter: number; +}; + +export class BallTracker { + private previous: Uint8ClampedArray | null = null; + private older: Uint8ClampedArray | null = null; + private last: Detection | null = null; + private velocity: XY = { x: 0, y: 0 }; + private history: Detection[] = []; + private started = 0; + private lastSeen = 0; + private frames = 0; + private crossings = 0; + private side: 0 | 1 | null = null; + private lastBounce = 0; + private bounces: { side: 0 | 1; at: number }[] = []; + private pending: Suggestion | null = null; + private lastFrameTime = 0; + private stableFrames = 0; + private continuous = true; + private project: ReturnType; + private config: VisionConfig; + constructor(config: VisionConfig) { + this.config = config; + this.project = tablePlane(config.corners); + } + reset() { + this.last = null; + this.velocity = { x: 0, y: 0 }; + this.history = []; + this.started = 0; + this.lastSeen = 0; + this.frames = 0; + this.crossings = 0; + this.side = null; + this.bounces = []; + this.lastBounce = 0; + this.pending = null; + this.stableFrames = 0; + this.continuous = true; + this.previous = null; + this.older = null; + this.lastFrameTime = 0; + } + process( + data: Uint8ClampedArray, + width: number, + height: number, + time: number, + ): VisionResult { + const gap = this.lastFrameTime ? time - this.lastFrameTime : 0; + const fps = gap > 0 ? 1000 / gap : 0; + if (gap > 250 || gap < 0) this.reset(); + this.lastFrameTime = time; + const previous = this.previous, + older = this.older; + this.older = previous; + this.previous = data; + if ( + !previous || + !older || + previous.length !== data.length || + older.length !== data.length + ) + return this.result(null, fps, 0); + const mask = new Uint8Array(width * height); + let moving = 0; + const threshold = 36 - this.config.sensitivity * 0.18; + for (let pixel = 0; pixel < mask.length; pixel++) { + const i = pixel * 4; + const r = data[i], + g = data[i + 1], + b = data[i + 2]; + const white = + Math.min(r, g, b) > 130 && Math.max(r, g, b) - Math.min(r, g, b) < 72; + const orange = r > 150 && g > 65 && r > g * 1.18 && g > b * 1.22; + const delta = + (Math.abs(r - previous[i]) + + Math.abs(g - previous[i + 1]) + + Math.abs(b - previous[i + 2])) / + 3; + const delta2 = + (Math.abs(r - older[i]) + + Math.abs(g - older[i + 1]) + + Math.abs(b - older[i + 2])) / + 3; + if (delta > threshold) moving++; + if ( + (this.config.color === "white" ? white : orange) && + delta > threshold && + delta2 > threshold * 0.7 + ) + mask[pixel] = 1; + } + const motion = moving / mask.length; + // A moved camera or a near-total scene change invalidates the sequence. + if (motion > 0.45) { + this.reset(); + return this.result(null, fps, motion); + } + const candidates: Detection[] = []; + const stack = new Int32Array(mask.length); + for (let i = 0; i < mask.length; i++) { + if (!mask[i]) continue; + let top = 0, + count = 0, + sx = 0, + sy = 0; + let minX = width, + minY = height, + maxX = 0, + maxY = 0; + stack[top++] = i; + mask[i] = 0; + while (top) { + const pixel = stack[--top], + x = pixel % width, + y = Math.floor(pixel / width); + count++; + sx += x; + sy += y; + minX = Math.min(minX, x); + maxX = Math.max(maxX, x); + minY = Math.min(minY, y); + maxY = Math.max(maxY, y); + const neighbors = [ + x > 0 ? pixel - 1 : -1, + x < width - 1 ? pixel + 1 : -1, + pixel - width, + pixel + width, + ]; + for (const n of neighbors) + if (n >= 0 && n < mask.length && mask[n]) { + mask[n] = 0; + stack[top++] = n; + } + } + const bw = maxX - minX + 1, + bh = maxY - minY + 1; + const maxDiameter = (this.config.diameter * width) / 320; + if ( + count < 2 || + count > maxDiameter * maxDiameter * 3 || + bw > maxDiameter * 4 || + bh > maxDiameter * 3 || + count / (bw * bh) < 0.2 + ) + continue; + const x = sx / count / width, + y = sy / count / height; + const projected = this.project({ x, y }); + if ( + projected.u < -0.7 || + projected.u > 1.7 || + projected.v < -0.8 || + projected.v > 1.8 + ) + continue; + let confidence = 0.45 + 0.2 * Math.min(1, count / 8); + if (this.last && time - this.last.time < 150) { + const dt = time - this.last.time; + const distance = Math.hypot( + x - this.last.x - this.velocity.x * dt, + y - this.last.y - this.velocity.y * dt, + ); + if (distance > 0.28) continue; + confidence += Math.max(0, 0.33 * (1 - distance / 0.2)); + } + candidates.push({ x, y, confidence, time, size: count }); + } + candidates.sort((a, b) => b.confidence - a.confidence); + let ball: Detection | null = candidates[0] ?? null; + if ( + candidates.length > 1 && + ball && + ball.confidence - candidates[1].confidence < 0.04 + ) + ball = null; + if (!ball && this.started && time - this.lastSeen > 120) + this.continuous = false; + if (ball && !this.pending) this.observe(ball); + if (!ball && this.started && !this.pending && time - this.lastSeen > 1000) { + if ( + this.frames >= 8 && + this.crossings >= 1 && + time - this.started > 500 + ) { + this.pending = { + id: `rally-${Math.round(this.started)}`, + loserSide: null, + strong: false, + reason: "Terminó el seguimiento. Confirma quién ganó.", + }; + } else { + this.last = null; + this.history = []; + this.frames = 0; + this.started = 0; + this.crossings = 0; + this.side = null; + this.bounces = []; + this.continuous = true; + } + } + return this.result(ball, fps, motion); + } + private observe(ball: Detection) { + const prev = this.last; + const dt = prev ? ball.time - prev.time : 0; + if (!this.started) this.started = ball.time; + this.frames++; + this.lastSeen = ball.time; + const plane = this.project(ball); + const currentSide: 0 | 1 | null = + plane.v < 0.43 ? 0 : plane.v > 0.57 ? 1 : null; + if (currentSide !== null) { + if (this.side !== null && this.side !== currentSide) this.crossings++; + this.side = currentSide; + } + if (prev && dt > 0 && dt < 150) { + const vx = (ball.x - prev.x) / dt, + vy = (ball.y - prev.y) / dt; + this.stableFrames++; + const before = this.history[this.history.length - 2]; + if ( + before && + this.stableFrames > 5 && + plane.inside && + currentSide !== null && + ball.confidence > 0.78 && + ball.time - this.lastBounce > 160 + ) { + const oldVy = + (prev.y - before.y) / Math.max(1, prev.time - before.time); + // Downward-to-upward image reversal is a bounce candidate, not 3D proof. + if (oldVy > 0.00045 && vy < -0.00045 && Math.abs(vx) < 0.008) { + this.lastBounce = ball.time; + this.bounces.push({ side: currentSide, at: ball.time }); + const previousBounce = this.bounces[this.bounces.length - 2]; + if ( + previousBounce && + previousBounce.side === currentSide && + ball.time - previousBounce.at < 1000 && + this.crossings >= 2 && + ball.time - this.started > 900 + ) { + this.pending = { + id: `rally-${Math.round(this.started)}`, + loserSide: currentSide, + strong: this.continuous, + reason: "Dos cambios de trayectoria compatibles con doble bote.", + }; + } + } + } + this.velocity = { x: vx, y: vy }; + } else { + this.stableFrames = 0; + this.velocity = { x: 0, y: 0 }; + } + this.last = ball; + this.history.push(ball); + if (this.history.length > 16) this.history.shift(); + } + private result( + ball: Detection | null, + fps: number, + motion: number, + ): VisionResult { + return { + ball, + trail: this.history.map(({ x, y }) => ({ x, y })), + phase: this.pending ? "pending" : this.started ? "tracking" : "waiting", + suggestion: this.pending, + fps, + motion, + }; + } +} diff --git a/app/pingpong/vision/worker.ts b/app/pingpong/vision/worker.ts new file mode 100644 index 0000000..4a3fc46 --- /dev/null +++ b/app/pingpong/vision/worker.ts @@ -0,0 +1,29 @@ +import { BallTracker, type VisionConfig } from "./tracker"; +let tracker: BallTracker | null = null; +let generation = 0; +self.onmessage = (event: MessageEvent) => { + const message = event.data; + if (message.type === "configure") { + generation = message.generation; + tracker = new BallTracker(message.config as VisionConfig); + self.postMessage({ type: "ready", generation }); + } else if (message.type === "reset") { + tracker?.reset(); + } else if ( + message.type === "frame" && + tracker && + message.generation === generation + ) { + try { + const result = tracker.process( + new Uint8ClampedArray(message.pixels), + message.width, + message.height, + message.time, + ); + self.postMessage({ type: "result", result, generation }); + } catch { + self.postMessage({ type: "error", generation }); + } + } +}; diff --git a/bun.lock b/bun.lock index 9e37c02..5acc23d 100644 --- a/bun.lock +++ b/bun.lock @@ -5,11 +5,13 @@ "": { "name": "website", "dependencies": { + "idb": "^8.0.4", "next": "^16.3.5", "react": "^19.3.0", "react-dom": "^19.3.0", }, "devDependencies": { + "@playwright/test": "^1.64.0", "@tailwindcss/postcss": "^4.3.3", "@types/node": "^22.20.3", "@types/react": "^19.3.0", @@ -186,6 +188,8 @@ "@nolyfill/is-core-module": ["@nolyfill/is-core-module@1.0.39", "", {}, "sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA=="], + "@playwright/test": ["@playwright/test@1.64.0", "", { "dependencies": { "playwright": "1.64.0" }, "bin": { "playwright": "cli.js" } }, "sha512-oTN9FNYuN6bqQMwiho6jJKLCG2/QOYCixGXuCjRmzOq23eODeukuY2XiAVoVpAECbuezMt1MHQjAiQxK8F+Y+g=="], + "@rtsao/scc": ["@rtsao/scc@1.1.0", "", {}, "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g=="], "@swc/helpers": ["@swc/helpers@0.5.23", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw=="], @@ -518,6 +522,8 @@ "hermes-parser": ["hermes-parser@0.25.1", "", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="], + "idb": ["idb@8.0.4", "", {}, "sha512-jd0ITCrN+Zah4J7R/Z2jnR3W7A4BXDAjmo62MwWuSjxCiueUAf7X5rbodEdidWzeknwXHmLpdZkoiiCppNi30Q=="], + "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], @@ -702,6 +708,10 @@ "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], + "playwright": ["playwright@1.64.0", "", { "dependencies": { "playwright-core": "1.64.0" }, "bin": { "playwright": "cli.js" } }, "sha512-kVzTnYFYEyQ9RGzBjNCd2+yEqWmVbxl3AHPynv3qQ6JTjv4a+kwRcnqQwIpuP3NnrnIrE/Q6wWAIAvMfIE2/Yg=="], + + "playwright-core": ["playwright-core@1.64.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-T9r+MZkTECl2+oUcZ26YgZzTrYidTsIhYYkJix+6iDOymOb1FB2RLLSOxRGnZGv5DStt+aduIVbt+E8WygXYpg=="], + "possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="], "postcss": ["postcss@8.5.23", "", { "dependencies": { "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg=="], diff --git a/docs/pingpong/README.md b/docs/pingpong/README.md new file mode 100644 index 0000000..b3fffb8 --- /dev/null +++ b/docs/pingpong/README.md @@ -0,0 +1,57 @@ +# PingPong + +Live route: . Integrated into the existing Next.js website and its Vercel deployment. + +## What ships + +- Spanish mobile interface, manual/assisted/experimental automatic modes. +- Singles games to 11, win by two, best of 1/3/5; correct serve rotation and side changes. +- Rear-camera capture, four-corner calibration, orange/white ball settings, live trail and actual processing fps. +- Entire vision pipeline in a dedicated, same-origin Web Worker. No video upload, microphone, account or paid inference service. +- Manual confirmation when a tracked rally disappears; possible double-bounce proposals. Experimental automatic mode can accept those proposals, with an explicit accuracy caveat. +- Undo, correct previous point, repeat/let, pause, interrupted matches, rematches and results sharing. +- IndexedDB history, validated JSON import/export, speech announcements, optional Wake Lock and scoped installable PWA with offline fallback page. + +## Camera setup + +Use a fixed, elevated, diagonal side view, enough light, one visible ball, and no moving camera. Select the four corners in order: near-left, near-right, far-right, far-left. Set the player at the near end during match setup. The overlay marks the estimated halfway line; it is a planar guide, not a reconstruction of the physical net or airborne ball. + +After calibration, check the trail and press Reanudar. If the detector follows clothing or reflections, adjust color, sensitivity and expected size. Try better placement before increasing sensitivity. Keep the page visible. The app pauses when hidden; it cannot see play while the screen is locked. + +## Vision implementation and limits + +The implementation uses original, lightweight TypeScript computer vision rather than distributing unverified pretrained weights: two temporal differences, white/orange pixel gating, connected components, projective ROI filtering, motion prediction and candidate ambiguity rejection. A projected plane and image trajectory reversals generate bounce candidates. A broad scene change invalidates calibration; long frame gaps invalidate the track. + +A bright ball against a pale background, rapid blur, occlusion or moving white clothing can defeat this detector. Image reversals do not prove a 3D bounce. Only two compatible reversals on the same side after observed crossings can yield a possible double-bounce winner; the automatic mode is explicitly experimental. Losing the ball alone never determines a winner. A detected rally disappearing can request human confirmation. Legal serve, net lets, edges, unseen hits and all possible outcomes are not fully adjudicated. + +No measured real-game precision or coverage is claimed. The original plan's 98% precision / 80% coverage target is **not validated or achieved by the available tests**. Completing robust, general automatic scoring remains dependent on labelled real footage and device evaluation, potentially a licensed temporal model. This release is a usable scorer with real local visual tracking and limited experimental automation, not a validated autonomous referee. + +Research reference: [TTNet](https://arxiv.org/abs/2004.09927). It describes specialized temporal vision with 120-fps data and desktop GPU timing; those results are not this app's results. No TTNet, BlurBall or OpenTTR code or weights are incorporated. + +## Development + +Use the root website toolchain, Bun 1.3.14+ and compatible Node (CI 22.18.0). `bun install --frozen-lockfile` then `bun run dev`. Build the standalone worker with `bun run build:vision`; `dev` and `build` do this automatically. The worker's generated JS is ignored by lint/format. + +Checks: + +```sh +bun run format:check +bun run lint +bun run typecheck +bun run test +bun run build +bunx playwright install chromium +bun run test:e2e +``` + +Playwright runs desktop and mobile-size Chromium against a production build. For a deployed build, set `PINGPONG_TEST_URL` to the deployment origin. Synthetic camera streams exercise capture, calibration and worker transport; they do not validate physical game accuracy. Unit tests use Node's existing test runner to avoid a second testing framework. + +## PWA and privacy + +`public/pingpong/sw.js` has scope `/pingpong`, permitted by a narrowly scoped response header. It never caches portfolio pages, RSC navigation responses or worker JS. New installations cache only the manifest, icons and fallback page; an offline reload intentionally displays a reconnect message rather than relying on a stale Next build. An already-open match continues without network access. IndexedDB stays local and can be unavailable/evicted in private or restricted browsers; users can export results. + +Heavy camera modules are dynamically loaded only on camera matches. No OpenCV/ONNX binary or model is loaded. Existing portfolio dependencies and deployment are retained. The route declares its own metadata and manifest; Navbar hides itself only under `/pingpong`. + +## Deploy / rollback + +Changes to the production branch use the existing Vercel Git integration. Verify CI and the Vercel deployment status, then verify `/pingpong`, worker MIME, manifest, icons and `Service-Worker-Allowed`. Revert the integration commit and redeploy for rollback. `vercel.json` explicitly builds the vision worker before Next. Changing the PWA shell requires bumping its cache version and avoiding immediate activation in an active match. diff --git a/docs/pingpong/validation-report.md b/docs/pingpong/validation-report.md new file mode 100644 index 0000000..4d4f8c1 --- /dev/null +++ b/docs/pingpong/validation-report.md @@ -0,0 +1,30 @@ +# Validation report — 2026-10-10 + +## Status + +Local production validation passed: + +- Formatting and ESLint: passed. +- TypeScript and production build: passed. +- Node tests: 18 passed (16 PingPong + 2 existing portfolio tests). +- Playwright: 14 passed across desktop and mobile-size Chromium, including real worker transport and synthetic automatic proposal/correction. +- Visual inspection: desktop landing and mobile landing/scoreboard screenshots; no horizontal overflow in viewport checks. + +Deployment SHA and production status are recorded in the GitHub PR/deployment history. These results do not establish real-game precision. + +## Coverage + +Core: service rotation, deuce, win by two, set transitions, deciding-set side switch, stopping completed matches, duplicate rally prevention, undo/correction across set boundaries, interrupted matches, import validation and calibration geometry. + +Vision: moving white/orange pixels, rejecting static bright pixels, requiring confirmation after a missing ball, and invalidating evidence after capture gaps. These are synthetic inputs, not filmed games. + +Browser: complete manual game, service, undo, correction, persist/reload, result and rematch; camera denial/manual fallback; synthetic camera capture/calibration/worker trail; help dialog keyboard handling and mobile layout; portfolio and privacy smoke checks. + +## Required physical evaluation + +- Actual iPhone/Safari and Android/Chrome camera permissions, orientation and calibrated view. +- Labelled retained matches, including net, edges, blur, occlusions, serve, false detections and double bounces. +- Automatic assignment precision **and** coverage, omissions/duplicates, latency p95. +- 30-minute session for stability, fps degradation, memory and battery observation. + +None of these physical checks has been performed remotely. No physical-game accuracy, continuous 30-minute stability or guaranteed mobile-browser compatibility is claimed. diff --git a/eslint.config.mjs b/eslint.config.mjs index 05e726d..7a6f7e2 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -12,6 +12,9 @@ const eslintConfig = defineConfig([ "out/**", "build/**", "next-env.d.ts", + "public/pingpong/vision.worker.js", + "test-results/**", + "playwright-report/**", ]), ]); diff --git a/next.config.ts b/next.config.ts index f1ca783..80783ce 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,6 +1,20 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { + async headers() { + return [ + { + source: "/pingpong/sw.js", + headers: [ + { key: "Service-Worker-Allowed", value: "/pingpong" }, + { + key: "Cache-Control", + value: "no-cache, no-store, must-revalidate", + }, + ], + }, + ]; + }, images: { remotePatterns: [ { diff --git a/package.json b/package.json index 8e7521c..7824de1 100644 --- a/package.json +++ b/package.json @@ -9,21 +9,25 @@ "node": ">=22.18.0" }, "scripts": { - "dev": "next dev", - "build": "next build", + "dev": "bun run build:vision && next dev", + "build": "bun run build:vision && next build", "start": "next start", "lint": "eslint", "typecheck": "tsc --noEmit", "test": "node --experimental-strip-types --test tests/*.test.ts", "format": "prettier --write .", - "format:check": "prettier --check ." + "format:check": "prettier --check .", + "build:vision": "bun build app/pingpong/vision/worker.ts --target browser --minify --outfile public/pingpong/vision.worker.js", + "test:e2e": "playwright test" }, "dependencies": { + "idb": "^8.0.4", "next": "^16.3.5", "react": "^19.3.0", "react-dom": "^19.3.0" }, "devDependencies": { + "@playwright/test": "^1.64.0", "@tailwindcss/postcss": "^4.3.3", "@types/node": "^22.20.3", "@types/react": "^19.3.0", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..f50de36 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,26 @@ +import { defineConfig, devices } from "@playwright/test"; +export default defineConfig({ + testDir: "./tests/e2e", + fullyParallel: true, + retries: process.env.CI ? 1 : 0, + reporter: "list", + use: { + baseURL: process.env.PINGPONG_TEST_URL || "http://localhost:3000", + trace: "retain-on-failure", + }, + projects: [ + { name: "desktop", use: { ...devices["Desktop Chrome"] } }, + { + name: "mobile", + use: { ...devices["iPhone 13"], defaultBrowserType: "chromium" }, + }, + ], + webServer: process.env.PINGPONG_TEST_URL + ? undefined + : { + command: "bun run start", + url: "http://localhost:3000", + reuseExistingServer: !process.env.CI, + timeout: 60000, + }, +}); diff --git a/public/pingpong/icon-192.png b/public/pingpong/icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..83fc181fbc38d02976fc9f2fb1b418dd3a2680ff GIT binary patch literal 9313 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 n}t4?yqK8f|%StDCoI>!cJiUL9R;b7aC~+v9LHy z^Cp%F!GlBxH_??uUh9;uUy&Rw&JUOqB|S#Yp(Oe1?P`yiE_R!WyiC8*MeVxR&H4+9tj!EKiyj z_~3ru4F3%EJici{R&CTH$~V76V4KJZmgG#wDG0W>JQ>rup(<5HgeG6B->-6^Su$Yw z1G0tI=lSK+xuS{{fg3Z>2Bde=PS>hQPhfW@K;5Ij;0AD8rNO zvFeHjq*^Q*RUzV655EE|C_^`zTNQ6O^r3I0X-NqEzE7{LB!B6A58rO++ie_eRD@~j z0Ya&arbe2i>0IuKn7^W&SdzqiVf1NuY>F8r=4M5QLI+ax){u{NLo!1sDgWJMcl_b! zap8VTNGs@MyX4GFw!2H7H=0U4KQBLp4y!!Tb8}O*A{{Hj#RE)-ArJ*kcB36gOY2_~ zx_63}x$8u7i)1dhv}egYN<96)M5269c6jh9dF*`c=PW$sBDfxw*H;8N4NsIXe9vBi zmM@CE)d)dvU#pnVi?;p9RvXVQP}jKhiQCos&)y{=IZ_}x`4~Txd;+krTGU`O{8Kkw z`$@yms${86yaW#gs$vdJSXMolQ!h;_uVJCGQ>0LNtgq+K6B{la=NI*THhv}A=DHU` zKGW%znW!k)>%fWEj}>*%%-!Lh&0VRVsmnMdT>0l9DBEOLpJ_xZH`z5+dF<3?X=&%6 z-)_LuGZJR?VW?SF&#pE_&^=HH+qP8QLFEd#)K>p}Vr%TnYB1meoZXpgHMDu0eJ=Lk zDlwWH9cs?JclkOtEoeZbO1^DJl41x@(NzO$GB$SIx>q>L2;*fzywF7&)34DK=_7Bj z%!O5=F_<(jVK|F8@CCNbDQOF&pk0%zhuS+WntM)o^{gJ= zJxClZ-RFd?d~~FbuJ$l_Dec$QHXavCyOAsFdEPNxP>{fWRC4|$#DY4N)9%?EcQV`4 zZ(N)Id_cz(yEK@I3PBh1^iktubafW1VipZWEGFY085&Xym!Bp)U4@=1(m&n7>L{t?SwJZ<)~E&C-Qy;K&s!h| zZHX0_v&;528q1f&;f*$(BY%MLJ@&QOVj`b=CkduQ6ayx;cexF(|HFAE#r0Kw}6>(*rkFK3T{NqI= l@_#C6`TwDvfqs1S#ik^8F{mYAc##ePWF-|Ps>BQf{vU>%Ax;1Q literal 0 HcmV?d00001 diff --git a/public/pingpong/icon-512.png b/public/pingpong/icon-512.png new file mode 100644 index 0000000000000000000000000000000000000000..faa171c8dd505267ad339e55f0a95418847d42b2 GIT binary patch literal 4095 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 diff --git a/public/pingpong/manifest.webmanifest b/public/pingpong/manifest.webmanifest new file mode 100644 index 0000000..12415a3 --- /dev/null +++ b/public/pingpong/manifest.webmanifest @@ -0,0 +1,26 @@ +{ + "id": "/pingpong", + "name": "PingPong — marcador local", + "short_name": "PingPong", + "description": "Jueguen. El marcador va aquí.", + "start_url": "/pingpong", + "scope": "/pingpong", + "display": "standalone", + "background_color": "#101820", + "theme_color": "#101820", + "lang": "es-MX", + "icons": [ + { + "src": "/pingpong/icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/pingpong/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any maskable" + } + ] +} diff --git a/public/pingpong/offline.html b/public/pingpong/offline.html new file mode 100644 index 0000000..b471eea --- /dev/null +++ b/public/pingpong/offline.html @@ -0,0 +1,37 @@ + + + + PingPong · Sin conexión +

Volvemos al juego.

+

+ Necesitas conexión para abrir esta versión de PingPong. Tu historial sigue + guardado en este navegador. +

+ + diff --git a/public/pingpong/sw.js b/public/pingpong/sw.js new file mode 100644 index 0000000..bd7a9dd --- /dev/null +++ b/public/pingpong/sw.js @@ -0,0 +1,65 @@ +/* Scope is restricted to PingPong. Never intercept the rest of the portfolio. */ +const CACHE = "pingpong-shell-v1"; +self.addEventListener("install", (event) => { + event.waitUntil( + caches + .open(CACHE) + .then((cache) => + cache.addAll([ + "/pingpong/offline.html", + "/pingpong/icon-192.png", + "/pingpong/icon-512.png", + "/pingpong/manifest.webmanifest", + ]), + ), + ); +}); +self.addEventListener("activate", (event) => { + event.waitUntil( + caches + .keys() + .then((keys) => + Promise.all( + keys + .filter((key) => key.startsWith("pingpong-shell-") && key !== CACHE) + .map((key) => caches.delete(key)), + ), + ) + .then(() => self.clients.claim()), + ); +}); +self.addEventListener("message", (event) => { + if (event.data?.type === "SKIP_WAITING") self.skipWaiting(); +}); +self.addEventListener("fetch", (event) => { + const request = event.request; + const url = new URL(request.url); + if ( + request.method !== "GET" || + url.origin !== self.location.origin || + request.headers.has("RSC") + ) + return; + if (request.mode === "navigate") { + if (url.pathname !== "/pingpong" && url.pathname !== "/pingpong/") return; + event.respondWith( + fetch(request).catch(() => caches.match("/pingpong/offline.html")), + ); + return; + } + if (!url.pathname.startsWith("/pingpong/") || url.pathname.endsWith(".js")) + return; + event.respondWith( + fetch(request) + .then((response) => { + if (response.ok) { + const copy = response.clone(); + event.waitUntil( + caches.open(CACHE).then((cache) => cache.put(request, copy)), + ); + } + return response; + }) + .catch(() => caches.match(request)), + ); +}); diff --git a/tests/e2e/pingpong.spec.ts b/tests/e2e/pingpong.spec.ts new file mode 100644 index 0000000..38aa1e9 --- /dev/null +++ b/tests/e2e/pingpong.spec.ts @@ -0,0 +1,223 @@ +import { expect, test, type Page } from "@playwright/test"; +async function setup(page: Page, mode = "manual", bestOf = "1") { + await page.goto("/pingpong"); + await page + .getByRole("button", { name: "Nuevo partido", exact: false }) + .click(); + await page.getByPlaceholder("Jugador 1", { exact: true }).fill("Alejandro"); + await page.getByPlaceholder("Jugador 2", { exact: true }).fill("Amigo"); + await page.getByLabel("Partido al mejor de").selectOption(bestOf); + await page.locator(`input[name=mode][value=${mode}]`).check(); + await page.getByRole("button", { name: "Empezar partido" }).click(); +} +test("manual match: service, undo, correction, reload and result", async ({ + page, +}) => { + const errors: string[] = []; + page.on("pageerror", (error) => errors.push(error.message)); + await setup(page); + const a = page.getByRole("button", { + name: "Punto para Alejandro", + exact: true, + }); + await a.click(); + await a.click(); + await expect(page.getByTestId("score-0")).toHaveText("02"); + await expect( + page.getByRole("status").filter({ hasText: "Saca Amigo" }), + ).toBeVisible(); + await page.getByRole("button", { name: "Corregir último" }).click(); + await expect(page.getByTestId("score-0")).toHaveText("01"); + await expect(page.getByTestId("score-1")).toHaveText("01"); + await page.getByRole("button", { name: "Reanudar" }).click(); + await page.getByRole("button", { name: "Deshacer" }).click(); + await expect(page.getByTestId("score-1")).toHaveText("00"); + await page.getByRole("button", { name: "Guardar y volver" }).click(); + await page.reload(); + await page.getByRole("button", { name: "Continuar partido" }).click(); + await expect(page.getByTestId("score-0")).toHaveText("01"); + await page.getByRole("button", { name: "Reanudar" }).click(); + for (let i = 0; i < 4; i++) await a.click(); + await page.getByRole("button", { name: "Listo, seguir" }).click(); + for (let i = 0; i < 6; i++) await a.click(); + await expect(page.getByText("Ganó Alejandro", { exact: true })).toBeVisible(); + await page.getByRole("button", { name: "Revancha" }).click(); + await expect(page.getByTestId("score-0")).toHaveText("00"); + expect(errors).toEqual([]); +}); +test("denied camera has clear fallback and manual match works", async ({ + page, +}) => { + await page.addInitScript(() => { + Object.defineProperty(navigator, "mediaDevices", { + value: { + getUserMedia: () => + Promise.reject(new DOMException("denied", "NotAllowedError")), + }, + configurable: true, + }); + }); + await setup(page, "assisted"); + await page + .getByRole("button", { name: "Activar cámara", exact: true }) + .click(); + await expect(page.locator("main").getByRole("alert")).toContainText( + "Permite el acceso", + ); + await page.getByRole("button", { name: "Usar modo manual" }).click(); + await page.getByRole("button", { name: "Punto para Alejandro" }).click(); + await expect(page.getByTestId("score-0")).toHaveText("01"); +}); +test("synthetic camera calibrates and the worker follows pixels without automatic points", async ({ + page, +}) => { + await page.addInitScript(() => { + const canvas = document.createElement("canvas"); + canvas.width = 640; + canvas.height = 360; + const context = canvas.getContext("2d")!; + let x = 0; + setInterval(() => { + context.fillStyle = "#102732"; + context.fillRect(0, 0, 640, 360); + context.fillStyle = "white"; + context.beginPath(); + context.arc(140 + (x++ % 100) * 3, 230, 4, 0, Math.PI * 2); + context.fill(); + }, 33); + Object.defineProperty(navigator, "mediaDevices", { + value: { getUserMedia: () => Promise.resolve(canvas.captureStream(30)) }, + configurable: true, + }); + }); + await setup(page, "assisted"); + await page + .getByRole("button", { name: "Activar cámara", exact: true }) + .click(); + const overlay = page.getByRole("img", { + name: "Vista de calibración de la mesa", + }); + await expect(overlay).toBeVisible(); + const bounds = await overlay.boundingBox(); + if (!bounds) throw new Error("No camera bounds"); + for (const [x, y] of [ + [0.1, 0.85], + [0.9, 0.85], + [0.9, 0.15], + [0.1, 0.15], + ]) + await overlay.click({ + position: { x: x * bounds.width, y: y * bounds.height }, + }); + await expect( + page.getByText("Mesa calibrada.", { exact: false }), + ).toBeVisible(); + await page.getByRole("button", { name: "Reanudar" }).click(); + await expect( + page.getByText("Pelota en juego", { exact: false }), + ).toBeVisible(); + await expect(page.getByTestId("score-0")).toHaveText("00"); + await expect(page.getByTestId("score-1")).toHaveText("00"); + await page.getByRole("button", { name: "Pausa", exact: false }).click(); + await expect(page.getByText("En pausa", { exact: true })).toBeVisible(); +}); +test("help is keyboard accessible and layout fits viewport", async ({ + page, +}) => { + await page.goto("/pingpong"); + await page.getByRole("button", { name: "Cómo funciona" }).click(); + await expect(page.getByRole("dialog")).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(page.getByRole("dialog")).not.toBeVisible(); + expect( + await page.evaluate( + () => document.documentElement.scrollWidth <= window.innerWidth, + ), + ).toBe(true); + await expect(page.locator(".site-header")).toHaveCount(0); +}); +test("portfolio and privacy still render", async ({ page }) => { + await page.goto("/"); + await expect(page.locator(".site-header")).toBeVisible(); + await expect(page.locator("h1")).toBeVisible(); + await page.goto("/privacy"); + await expect(page.locator("h1")).toBeVisible(); +}); + +test("automatic beta scores a synthetic double-bounce proposal and it remains correctable", async ({ + page, +}) => { + await page.addInitScript(() => { + const canvas = document.createElement("canvas"); + canvas.width = 640; + canvas.height = 360; + const context = canvas.getContext("2d")!; + const ys: number[] = []; + for (let i = 0; i < 15; i++) ys.push(65 - i * 3); + for (let i = 0; i < 15; i++) ys.push(23 + i * 3); + ys.push(67, 70, 73, 76, 73, 70, 67, 64, 62, 65, 68, 71, 74, 71, 68); + let index = -1; + (window as typeof window & { startRally: () => void }).startRally = () => { + index = 0; + }; + setInterval(() => { + context.fillStyle = "#14202a"; + context.fillRect(0, 0, 640, 360); + context.fillStyle = "white"; + const x = index < 0 ? 60 : 60 + Math.min(index, ys.length - 1); + const y = index < 0 ? 65 : ys[Math.min(index, ys.length - 1)]; + context.fillRect((x - 1) * 4, (y - 1) * 4, 12, 12); + if (index >= 0 && index < ys.length - 1) index++; + }, 33); + Object.defineProperty(navigator, "mediaDevices", { + value: { getUserMedia: () => Promise.resolve(canvas.captureStream(30)) }, + configurable: true, + }); + }); + await setup(page, "automatic", "3"); + await page + .getByRole("button", { name: "Activar cámara", exact: true }) + .click(); + const overlay = page.getByRole("img", { + name: "Vista de calibración de la mesa", + }); + await expect(overlay).toBeVisible(); + const bounds = await overlay.boundingBox(); + if (!bounds) throw new Error("Missing camera"); + for (const [x, y] of [ + [0.1, 0.85], + [0.9, 0.85], + [0.9, 0.15], + [0.1, 0.15], + ]) + await overlay.click({ + position: { x: x * bounds.width, y: y * bounds.height }, + }); + await page.getByRole("button", { name: "Reanudar" }).click(); + await page.evaluate(() => + (window as typeof window & { startRally: () => void }).startRally(), + ); + await expect(page.getByTestId("score-1")).toHaveText("01", { + timeout: 10000, + }); + await page.getByRole("button", { name: "Corregir último" }).click(); + await expect(page.getByTestId("score-0")).toHaveText("01"); + await expect(page.getByTestId("score-1")).toHaveText("00"); +}); + +test("undoing a deciding-set side change asks players to restore their positions", async ({ + page, +}) => { + await setup(page, "manual", "1"); + const a = page.getByRole("button", { + name: "Punto para Alejandro", + exact: true, + }); + for (let i = 0; i < 5; i++) await a.click(); + await expect(page.getByRole("dialog")).toContainText("Amigo"); + await page.getByRole("button", { name: "Listo, seguir" }).click(); + await page.getByRole("button", { name: "Deshacer" }).click(); + await expect(page.getByRole("dialog")).toContainText("Alejandro"); + await page.getByRole("button", { name: "Listo, seguir" }).click(); + await expect(page.getByTestId("score-0")).toHaveText("04"); +}); diff --git a/tests/pingpong.test.ts b/tests/pingpong.test.ts new file mode 100644 index 0000000..69b941f --- /dev/null +++ b/tests/pingpong.test.ts @@ -0,0 +1,276 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { + addPoint, + correctPoint, + isMatch, + other, + scoreMatch, + undoPoint, + type Match, + type Player, +} from "../app/pingpong/core/match.ts"; +import { + tablePlane, + validCorners, + type Corners, +} from "../app/pingpong/vision/geometry.ts"; +import { + BallTracker, + type Suggestion, +} from "../app/pingpong/vision/tracker.ts"; +const base = (): Match => ({ + version: 1, + id: "test", + players: ["A", "B"], + bestOf: 3, + firstServer: 0, + initialNear: 0, + mode: "manual", + points: [], + created: 1, +}); +function point( + match: Match, + winner: Player, + id = `p${match.points.length}`, +): Match { + return addPoint(match, { + id, + rallyId: id, + winner, + source: "manual", + at: match.points.length + 1, + }); +} +function many(match: Match, winners: Player[]): Match { + return winners.reduce((m, player) => point(m, player), match); +} +const corners: Corners = [ + { x: 0.1, y: 0.85 }, + { x: 0.9, y: 0.85 }, + { x: 0.9, y: 0.15 }, + { x: 0.1, y: 0.15 }, +]; + +test("service rotates every two points, then every point at deuce", () => { + let m = base(); + assert.equal(scoreMatch(m).server, 0); + m = point(m, 0); + assert.equal(scoreMatch(m).server, 0); + m = point(m, 1); + assert.equal(scoreMatch(m).server, 1); + m = many( + base(), + Array.from({ length: 20 }, (_, i) => (i % 2) as Player), + ); + assert.deepEqual(scoreMatch(m).points, [10, 10]); + assert.equal(scoreMatch(m).server, 0); + m = point(m, 0); + assert.equal(scoreMatch(m).winner, null); + assert.equal(scoreMatch(m).server, 1); + m = point(m, 1); + assert.equal(scoreMatch(m).server, 0); + m = many(m, [1, 1]); + assert.deepEqual(scoreMatch(m).games, [[11, 13]]); +}); +test("a set requires an advantage of two and first server alternates between sets", () => { + const m = many(base(), [ + ...Array(10).fill(0), + ...Array(9).fill(1), + 0, + ]); + const score = scoreMatch(m); + assert.deepEqual(score.games, [[11, 9]]); + assert.deepEqual(score.points, [0, 0]); + assert.equal(score.server, 1); + assert.equal(score.near, 1); +}); +test("match stops at the required sets; undo and correction can reopen it", () => { + const m = many(base(), Array(22).fill(0)); + assert.equal(scoreMatch(m).winner, 0); + assert.equal(point(m, 1), m); + const undone = undoPoint(m); + assert.equal(scoreMatch(undone).winner, null); + assert.deepEqual(scoreMatch(undone).points, [10, 0]); + const corrected = correctPoint(m); + assert.equal(scoreMatch(corrected).winner, null); + assert.deepEqual(scoreMatch(corrected).points, [10, 1]); +}); +test("deciding-set side change happens exactly when either player reaches five", () => { + let m = many(base(), [ + ...Array(11).fill(0), + ...Array(11).fill(1), + ]); + assert.equal(scoreMatch(m).near, 0); + m = many(m, [0, 1, 0, 1, 0, 1, 0, 1]); + assert.equal(scoreMatch(m).near, 0); + m = point(m, 1); + assert.equal(scoreMatch(m).near, 1); + assert.equal(scoreMatch(undoPoint(m)).near, 0); +}); +test("duplicate rally IDs cannot add multiple points", () => { + const m = point(base(), 0, "same-rally"); + assert.equal(point(m, 1, "same-rally"), m); + assert.deepEqual( + scoreMatch({ ...m, points: [...m.points, { ...m.points[0], winner: 1 }] }) + .points, + [1, 0], + ); +}); +test("finished matches cannot score; undo opens interrupted match", () => { + const m = { ...point(base(), 0), finished: 3 }; + assert.equal(point(m, 1), m); + assert.equal(undoPoint(m).finished, undefined); +}); +test("import validator rejects malformed, nonfinite and oversized input", () => { + assert.equal(isMatch(base()), true); + assert.equal(isMatch(null), false); + assert.equal(isMatch({ ...base(), players: ["A"] }), false); + assert.equal(isMatch({ ...base(), firstServer: 9 }), false); + assert.equal(isMatch({ ...base(), created: Infinity }), false); + assert.equal(isMatch({ ...base(), points: [null] }), false); + assert.equal( + isMatch({ + ...point(base(), 0), + points: [{ id: "x", rallyId: "x", winner: 4, source: "manual", at: 0 }], + }), + false, + ); +}); +test("calibration rejects crossed, tiny and out-of-frame tables", () => { + assert.equal(validCorners(corners), true); + assert.equal( + validCorners([corners[0], corners[2], corners[1], corners[3]]), + false, + ); + assert.equal( + validCorners([ + { x: 0, y: 0 }, + { x: 0.01, y: 0 }, + { x: 0.01, y: 0.01 }, + { x: 0, y: 0.01 }, + ]), + false, + ); + assert.equal(validCorners([{ x: -1, y: 0 }, ...corners.slice(1)]), false); +}); +test("table homography maps corners and centre correctly for a trapezoid", () => { + const table: Corners = [ + { x: 0.1, y: 0.9 }, + { x: 0.9, y: 0.9 }, + { x: 0.7, y: 0.2 }, + { x: 0.3, y: 0.2 }, + ]; + const project = tablePlane(table); + const targets = [ + [0, 0], + [1, 0], + [1, 1], + [0, 1], + ]; + table.forEach((p, i) => { + const mapped = project(p); + assert.ok(Math.abs(mapped.u - targets[i][0]) < 1e-6); + assert.ok(Math.abs(mapped.v - targets[i][1]) < 1e-6); + }); + assert.ok(Math.abs(project({ x: 0.5, y: 0.5 }).u - 0.5) < 1e-6); +}); +function frame(x?: number, y = 45, orange = false): Uint8ClampedArray { + const pixels = new Uint8ClampedArray(160 * 90 * 4); + for (let i = 0; i < pixels.length; i += 4) { + pixels[i] = 20; + pixels[i + 1] = 30; + pixels[i + 2] = 40; + pixels[i + 3] = 255; + } + if (x !== undefined) + for (let py = y - 1; py <= y + 1; py++) + for (let px = x - 1; px <= x + 1; px++) { + const i = (py * 160 + px) * 4; + pixels[i] = 255; + pixels[i + 1] = orange ? 150 : 255; + pixels[i + 2] = orange ? 20 : 255; + } + return pixels; +} +const tracker = (color: "white" | "orange" = "white") => + new BallTracker({ corners, color, sensitivity: 50, diameter: 9 }); +test("moving white ball is tracked; static white pixels are not a rally", () => { + const t = tracker(); + t.process(frame(60), 160, 90, 100); + t.process(frame(65), 160, 90, 133); + const result = t.process(frame(70), 160, 90, 166); + assert.ok(result.ball); + assert.ok(Math.abs(result.ball.x - 70 / 160) < 0.01); + const still = tracker(); + for (let i = 0; i < 40; i++) { + const r = still.process(frame(70), 160, 90, i * 33); + assert.equal(r.suggestion, null); + } +}); +test("orange mode filters white balls and detects moving orange", () => { + const t = tracker("orange"); + t.process(frame(60, 45, true), 160, 90, 100); + t.process(frame(65, 45, true), 160, 90, 133); + assert.ok(t.process(frame(70, 45, true), 160, 90, 166).ball); + t.reset(); + t.process(frame(60), 160, 90, 100); + t.process(frame(65), 160, 90, 133); + assert.equal(t.process(frame(70), 160, 90, 166).ball, null); +}); +test("losing the ball without observed crossings never assigns a winner", () => { + const t = tracker(); + for (let i = 0; i < 15; i++) + t.process(frame(35 + i * 4, 65), 160, 90, 100 + i * 33); + for (let i = 0; i < 50; i++) { + const r = t.process(frame(), 160, 90, 600 + i * 33); + assert.equal(r.suggestion, null); + } +}); +test("a missing ball after a rally requires human confirmation, not an automatic winner", () => { + const t = tracker(); + let result; + for (let i = 0; i < 40; i++) { + const y = Math.round(60 - 30 * Math.sin((i / 39) * Math.PI)); + result = t.process(frame(60 + i, y), 160, 90, 100 + i * 33); + } + for (let i = 0; i < 40; i++) + result = t.process(frame(), 160, 90, 1420 + i * 33); + assert.ok(result?.suggestion); + assert.equal(result.suggestion.strong, false); + assert.equal(result.suggestion.loserSide, null); +}); +test("long capture gaps reset evidence and never imply a point", () => { + const t = tracker(); + t.process(frame(60), 160, 90, 100); + t.process(frame(65), 160, 90, 133); + t.process(frame(70), 160, 90, 166); + const r = t.process(frame(75), 160, 90, 1000); + assert.equal(r.suggestion, null); + assert.equal(r.phase, "waiting"); +}); +test("other player helper is stable", () => { + assert.equal(other(0), 1); + assert.equal(other(1), 0); +}); + +test("two compatible bounce reversals after crossings produce one experimental proposal", () => { + const t = tracker(); + const ys: number[] = []; + for (let i = 0; i < 15; i++) ys.push(65 - i * 3); + for (let i = 0; i < 15; i++) ys.push(23 + i * 3); + ys.push(67, 70, 73, 76, 73, 70, 67, 64, 62, 65, 68, 71, 74, 71, 68); + let proposal: Suggestion | null = null; + for (const [i, y] of ys.entries()) { + const r = t.process(frame(60 + i, y), 160, 90, 100 + i * 33); + if (r.suggestion) proposal = r.suggestion; + } + assert.ok(proposal); + assert.equal(proposal.strong, true); + assert.equal(proposal.loserSide, 0); + const repeated = t.process(frame(), 160, 90, 1600); + assert.equal(repeated.suggestion?.id, proposal.id); + t.reset(); + assert.equal(t.process(frame(), 160, 90, 1633).suggestion, null); +}); diff --git a/vercel.json b/vercel.json new file mode 100644 index 0000000..3a08547 --- /dev/null +++ b/vercel.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://openapi.vercel.sh/vercel.json", + "buildCommand": "bun run build" +} From 5f49e8031825caf17cf8c2601eefa5f1753c0697 Mon Sep 17 00:00:00 2001 From: jorgesandev Date: Sat, 10 Oct 2026 09:57:55 -0700 Subject: [PATCH 2/2] docs: include idb distribution license --- app/pingpong/PingPongClient.tsx | 5 ++++- public/pingpong/licenses.txt | 6 ++++++ 2 files changed, 10 insertions(+), 1 deletion(-) create mode 100644 public/pingpong/licenses.txt diff --git a/app/pingpong/PingPongClient.tsx b/app/pingpong/PingPongClient.tsx index 75b4c37..a2fc1fd 100644 --- a/app/pingpong/PingPongClient.tsx +++ b/app/pingpong/PingPongClient.tsx @@ -1038,7 +1038,10 @@ export default function PingPongClient() {
HECHO PARA OTRO SET. Jorge Sandoval ↗ - Cámara local · visión beta + + Cámara local · visión beta ·{" "} + Licencias +
{modalOpen ? ( diff --git a/public/pingpong/licenses.txt b/public/pingpong/licenses.txt new file mode 100644 index 0000000..f8b22ce --- /dev/null +++ b/public/pingpong/licenses.txt @@ -0,0 +1,6 @@ +ISC License (ISC) +Copyright (c) 2016, Jake Archibald + +Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.